From ed14c54b9354c262bbd73ffbb81872f40e651898 Mon Sep 17 00:00:00 2001 From: Eytan Adler <63426601+eytanadler@users.noreply.github.com> Date: Mon, 25 Mar 2024 14:56:35 -0400 Subject: [PATCH] Lots of new models for clean sheet aircraft design (#64) * Add tags to the mult-div comp * Convergence improvements for the incompressible duct. Add some analytic derivatives and create a full duct model that can take an external HX model as connections * Added lower bounds on some components * various tweaks to duct test group for diagnostics * Limit throttle to 1.05 rated * Tweaks and doc updates to N3hybrid model * remove dymos * remove dymos import, reduce tol on B737 test due to differences in scipy model training behavior * Add heat sinks specific to motors and batteries * Further reduce tol of 738 test * Fix docstring indentation * WIP: N3hybrid (#21) * First dymos-based phase * add hybrid engine surrogate model * Add ISA temp offset to standard atmosphere * Add dymos to requirements * Install dymos from git * continued install problems * remove dymos test scratch folder * pin openmdao for now * identify why 737 test is failing * Fix trajectory test bugs introduced during dymos work * Add a "BasicMission" with a ground roll phase that's not a real BFL phase * Minor change to CFM56 surrogate * Add tags to the mult-div comp * Convergence improvements for the incompressible duct. Add some analytic derivatives and create a full duct model that can take an external HX model as connections * Added lower bounds on some components * various tweaks to duct test group for diagnostics * Limit throttle to 1.05 rated * Tweaks and doc updates to N3hybrid model * remove dymos * remove dymos import, reduce tol on B737 test due to differences in scipy model training behavior * Add heat sinks specific to motors and batteries * Further reduce tol of 738 test * Fix docstring indentation * Fix docstring and reference to battery heatsink model from Eytan's PR * ignore scratch folder * Battery tests done * Reg tests for the motor heat sink * First draft of the hybrid single aisle with some todos * Resize HX for lower hot side pressure drop * Thermal optimization is functional * deprecate a couple of debugging things * Add pressure losses proportional to dynamic pressure * Optimization with lossy diffuser * Add proportional losses to the compressible duct * Added refrigerator capability for the hybrid single aisle and compressible duct robustness improvements * Add refrigerator weight to hybrid single aisle and reformulate control parameter * base case * Added LiquidCooledMotor to ParallelTMS * Update hydraulic diameter computation and add regression tests against Kays and London empirical coefficients for the heat exchanger * Add a comprehensive HX reg test case * Add compressible duct reg tests vs pycycle * Add reg tests that don't depend on pycycle * Add warning when pycycle not installed * Added heat pipe model and tests * Removed duplicate vapor temp drop calc and changed defaults from 6061 to 7075 aluminum * Fix trajectories for OM 3.3+ (works on 3.7 dev) * Roll openmdao version, fix a couple examples * Heat pipe docstring fixes * Tested w/ openmdao 3.5 and still works so relaxing dependency pinning * Update duct test for latest pycycle * tweaks to nasa model * repo maintenance * Add a warning to cached surrogate * - Add tests for hose and pump comps - Add effectiveness/NTU calc for the motor heat sink comp - Refactor tms and heat sink tests - Clean up unused code in thermal.py * remove hybrid single aisle examples until conclusion of trees study * implements promote_add and promote_mult convenience funcs * refactor the empirical engine decks and update the n+3 design * missed a promote in the ducts comp * add two geom design vars to the hx group * Change name of top-level integrator comp to allow multimission problems at the top-level * lower bound on duct mass flow for convergence * Updated engine maps * New chiller component * Make case cooling coefficient a user parameter * Change bounds on duct comp * Add a muliple trajectory plot utility * Remove a print statement * Added tests for chiller * Removed eff_factor option from COPExplicit and added docstrings * Changed iv comp to set_input_defaults for steady flight phase so linear interpolator can be connected for trajectory optimization * Corrected documentation for I/O of updated HeatPumpWithIntegratedCoolantLoop * Changed default batteries per bandolier from 21 to 82 * Added hydrogen tank composite thickness estimate with netting and tests * Added liner and insulation weight components and simplified composite calc * Made some fixes and added validation * Moved hydrogen work to subfolder in components * Reorganized hydrogen folder * Prototypes (in progress) of a couple necessary parts for boil-off estimation * Added fairing to outside of insulation * Added derivatives to components * Added tests for hydrogen tank thermals * Attempt for full tank component, but still in progress * Added tests for some new components * Added components and modified to make it solve semi-robustly * Missed some parentheses, oops * Added analytic derivatives to COPVHeatFromEnvironmentIntoTankWalls * Added more tests for hydrogen thermal comps * Added LH2 tank tests and made a couple more corrections * Note about LH2 tank initial fill level * First attempt at good boil-off model (still not functional) * Failed attempt at better boil off model * Added GH2 reservoir and test * New LH2 tank looking decent, need to change a few connections still * LH2 tank more connections made but still not solving * Working LH2 tank! Now just need venting... * Removed arccos from heat into propellant to vastly increase robustness but slightly decrease accuracy * Added venting and heating features to tank, updated tests * Fixed deprecation error in ExecComp * Added validation for LH2 tank * Added ability to pull LH2 from tank in addition from GH2 * Added lower bound to ullage temperature * Added lower bound to pressure in ullage ODE * Added more bounds for physical behavior * Updated defaults of LH2 tank to better reflect real world * Updated LH2 tank tests for new default options * Small change to LH2 tank docs * Removed TMS to match mdolab/master * Ran black * Added proper validation script for MHTB boil-off * Working on new boil off model * Boil off model gets reasonable results, needs testing, cleanup, and verification of ODE * Cleaned up boil off API and improved initial guess for solver * Added tests and fixed derivatives for fill level calc * Tested boil off ODE against EBM * Added derivatives for hydrogen property functions * Analytic derivatives for half of the boil of ODE outputs * Analytic derivatives for V_dot_gas in boil off ODE * Added derivatives for T_dot_liq * Analytic partials for boil off ODE done * Fixed incorrect derivative when T_dot_liq is zeroed out * Boil off with complex step to check works great now * New structural model for vacuum tank * Added air thermal conductivity * Added viscosity * Ran black * Added vacuum wall model * Starting new thermal model * Getting thermal model together, still need to make it work * Thermal model is done-ish * First pass at final tank, needs robustness and some other stuff * Added MLI weight * Added tests for LH2 tank * Trying to improve robustness of the boil-off * Added integrated test for boil off model * Removed previous versions of files for LH2 tank * Added rubberized turbofan that can do hydrogen * Added options to LH2 tank for more customizability * Consistent radius default * Trying to figure out what's going on with the solvers and guess_nonlinear * Adjusted tolerance on LH2Tank NLBGS solver * Boil off model should now work with mission integrator linking properly * LH2 tank test was failing because there was no top level nonlinear solver after NLBGS was removed * Actually....I added back in the NLBGS solver because the Newton was getting funny results * Working on improving the tank model robustness * Added some notes for myself and corrected a docstring * Added LH2 tank without boil-off model * Added tests for new LH2 tank comps * Added LH2 tanks to based energy storage imports * Removed viscosity and conductivity comps * New boil off model, still needs robustness improvement and derivatives * Fixed error in boil off * adding SOFC fuel cell model * adding sizing functions, fuel cell PEM, and updating SOFC * Adding Venkats sizing file, does not run * Making some fixes in sizing stuff * Fixed wing weight typo in init * First pass at fixed jet transport weight model * Updated docstrings and init file * Vastly improved jet transport weight estimation * Fixing up sizing example * First pass at jet transport drag buildup * Added tests for new drag buildup * Tests for geometry comps * Cylinder wetted area component * No more approximation of tail lever arm * Added tests and docstrings for tail volume coefficient sizing * Added CL max estimation * Removing old sizing * Modified RubberizedTurbofan to take in rated thrust instead of number of engines * Moved CD_nonwing up to the VLMDragPolar and enabled it to be a vector input * Formatted and modified FullMissionWithReserve * Extended PolarDrag to accept vectorized CD0 * Changed name of CL max output * Initial pass at setting up B738 sizing run script * Done with mission analysis of Boeing 737-800 with empirical weight and drag * Ran black * Removed n2 from drag test * Changed some variables bounds * Increased upper bounds in basic mission segment durations * Note on engine model and profile variable bound setting * Added wing planform geometry utilities * Corrected docstring * Added wing weight multiplier * Modified equipment weight to make pressurized cabin geometry more general * Removed unused option * Added BWB weight model * Added BWB drag estimate that is just nacelles and dirty stuff * Tools to compute planform area of parts of the wing * Max lift coefficient estimation via the critical section method * Added todo * Ran black * Removed some unused imports * Minor changes to critical section method * Added Schur solver for critical section CL max * Reduced landing gear CD for BWB * Added output documentation for BWB weights * Removed CODEOWNERS to avoid unneccesary PR reviews while developing hyfi, ADD BACK BEFORE PUSH TO MAIN * Utility to compute mean aerodynamic chord of OpenAeroStruct section-defined wing * Forgot a summation in MAC calc * Fixed docstrings * Added comps to compute wave drag * Fix a bug in the wave drag calculation * Fix error in flap CLmax calculation * Extension to critical section method (#54) * Restructure * Extended critical section method to handle varying Clmax across span * Added thermal tests back * Corrected heat leak multiplier in thermal calcs * Removed stale code and other code that is not ready to merge * Fixed BWB test * Fixed options in BWB test * One more bit of code not ready to merge * Remove other dated code * Fixed convergence and added test for B738_sizing example * Add back CODEOWNERS file * Inconsequential rendition thump * Removed old B738 sizing data * Ran black * Fix most flake8 errors * Fixed ascii art * Try to fix remaining flake8 * Indentation fix * Documentation for new components * Fixed some errors I noticed in the documentation * Address most comments * Upper bound on OpenMDAO version * Forgot a comma * Oldest OpenMDAO version was being overridden on OpenConcept install * Try OM 3.16 * Try OM 3.17 * 3.17 it is * Bump again to 3.21 to fix spline shape problem * Add code for checking against OAS and weighted avg of cos(sweep) rather than sweep directly * Reorganized to remove duplicated code * Tightened Newton solver tolerance to make testing more consistent * Fixing black and flake8 * Make up your mind flake8 * Better descriptions of takeoff|h behavior * Even more descriptive takeoff|h behavior --------- Co-authored-by: Ben Brelje Co-authored-by: Ben Brelje Co-authored-by: Cody Karcher --- .github/workflows/openconcept.yaml | 9 +- doc/features/aerodynamics.rst | 46 +- doc/features/energy_storage.rst | 11 +- doc/features/geometry.rst | 47 + doc/features/mission.rst | 7 +- doc/features/propulsion.rst | 7 + doc/features/stability.rst | 14 + doc/features/weights.rst | 35 +- doc/index.rst | 6 +- doc/ref.bib | 22 +- doc/tutorials/more_examples.rst | 1 + openconcept/__init__.py | 2 +- openconcept/aerodynamics/CL_max_estimation.py | 204 ++ openconcept/aerodynamics/__init__.py | 3 + openconcept/aerodynamics/aerodynamics.py | 12 +- openconcept/aerodynamics/drag_BWB.py | 193 ++ .../aerodynamics/drag_jet_transport.py | 638 ++++++ .../openaerostruct/CL_max_critical_section.py | 146 ++ .../aerodynamics/openaerostruct/__init__.py | 2 + .../aerodynamics/openaerostruct/drag_polar.py | 32 +- .../tests/test_CL_max_critical_section.py | 101 + .../openaerostruct/tests/test_drag_polar.py | 28 +- .../openaerostruct/tests/test_wave_drag.py | 139 ++ .../aerodynamics/openaerostruct/wave_drag.py | 145 ++ .../tests/test_CL_max_estimation.py | 92 + .../aerodynamics/tests/test_aerodynamics.py | 28 + .../aerodynamics/tests/test_drag_BWB.py | 74 + .../tests/test_drag_jet_transport.py | 176 ++ .../atmospherics/tests/test_atmospherics.py | 4 + openconcept/energy_storage/__init__.py | 1 + .../hydrogen/LH2_tank_no_boil_off.py | 270 +++ .../energy_storage/hydrogen/__init__.py | 1 + .../energy_storage/hydrogen/structural.py | 479 +++++ .../energy_storage/hydrogen/tests/__init__.py | 0 .../tests/test_LH2_tank_no_boil_off.py | 152 ++ .../hydrogen/tests/test_structural.py | 176 ++ openconcept/examples/B738_VLM_drag.py | 4 +- openconcept/examples/B738_aerostructural.py | 4 +- openconcept/examples/B738_sizing.py | 586 ++++++ openconcept/examples/Caravan.py | 4 +- openconcept/examples/HybridTwin.py | 4 +- .../examples/aircraft_data/B738_sizing.py | 99 + .../examples/tests/test_example_aircraft.py | 23 + openconcept/geometry/__init__.py | 9 + .../geometry/tests/test_wetted_area.py | 29 + .../geometry/tests/test_wing_planform.py | 331 +++ openconcept/geometry/wetted_area.py | 35 + openconcept/geometry/wing_planform.py | 394 ++++ openconcept/mission/__init__.py | 2 +- openconcept/mission/profiles.py | 675 +++--- openconcept/propulsion/__init__.py | 1 + openconcept/propulsion/rubberized_turbofan.py | 131 ++ .../tests/test_rubberized_turbofan.py | 83 + openconcept/stability/__init__.py | 1 + .../tail_volume_coefficient_sizing.py | 138 ++ .../test_tail_volume_coefficient_sizing.py | 98 + openconcept/utilities/constants.py | 5 +- openconcept/weights/__init__.py | 23 + openconcept/weights/tests/test_weights_BWB.py | 63 + .../tests/test_weights_jet_transport.py | 195 ++ openconcept/weights/weights_BWB.py | 523 +++++ openconcept/weights/weights_jet_transport.py | 1827 +++++++++++++++++ readme.md | 2 +- setup.py | 4 +- 64 files changed, 8135 insertions(+), 461 deletions(-) create mode 100644 doc/features/geometry.rst create mode 100644 doc/features/stability.rst create mode 100644 openconcept/aerodynamics/CL_max_estimation.py create mode 100644 openconcept/aerodynamics/drag_BWB.py create mode 100644 openconcept/aerodynamics/drag_jet_transport.py create mode 100644 openconcept/aerodynamics/openaerostruct/CL_max_critical_section.py create mode 100644 openconcept/aerodynamics/openaerostruct/tests/test_CL_max_critical_section.py create mode 100644 openconcept/aerodynamics/openaerostruct/tests/test_wave_drag.py create mode 100644 openconcept/aerodynamics/openaerostruct/wave_drag.py create mode 100644 openconcept/aerodynamics/tests/test_CL_max_estimation.py create mode 100644 openconcept/aerodynamics/tests/test_drag_BWB.py create mode 100644 openconcept/aerodynamics/tests/test_drag_jet_transport.py create mode 100644 openconcept/energy_storage/hydrogen/LH2_tank_no_boil_off.py create mode 100644 openconcept/energy_storage/hydrogen/__init__.py create mode 100644 openconcept/energy_storage/hydrogen/structural.py create mode 100644 openconcept/energy_storage/hydrogen/tests/__init__.py create mode 100644 openconcept/energy_storage/hydrogen/tests/test_LH2_tank_no_boil_off.py create mode 100644 openconcept/energy_storage/hydrogen/tests/test_structural.py create mode 100644 openconcept/examples/B738_sizing.py create mode 100644 openconcept/examples/aircraft_data/B738_sizing.py create mode 100644 openconcept/geometry/__init__.py create mode 100644 openconcept/geometry/tests/test_wetted_area.py create mode 100644 openconcept/geometry/tests/test_wing_planform.py create mode 100644 openconcept/geometry/wetted_area.py create mode 100644 openconcept/geometry/wing_planform.py create mode 100644 openconcept/propulsion/rubberized_turbofan.py create mode 100644 openconcept/propulsion/tests/test_rubberized_turbofan.py create mode 100644 openconcept/stability/__init__.py create mode 100644 openconcept/stability/tail_volume_coefficient_sizing.py create mode 100644 openconcept/stability/tests/test_tail_volume_coefficient_sizing.py create mode 100644 openconcept/weights/tests/test_weights_BWB.py create mode 100644 openconcept/weights/tests/test_weights_jet_transport.py create mode 100644 openconcept/weights/weights_BWB.py create mode 100644 openconcept/weights/weights_jet_transport.py diff --git a/.github/workflows/openconcept.yaml b/.github/workflows/openconcept.yaml index fc4ceaed..a97c7fc5 100644 --- a/.github/workflows/openconcept.yaml +++ b/.github/workflows/openconcept.yaml @@ -29,7 +29,7 @@ jobs: SETUPTOOLS_VERSION_OLDEST: ['66.0.0'] # setuptools >= 67.0.0 can't build the oldest OpenMDAO NUMPY_VERSION_OLDEST: ['1.20'] # latest is most recent on PyPI SCIPY_VERSION_OLDEST: ['1.6.0'] # latest is most recent on PyPI - OPENMDAO_VERSION_OLDEST: ['3.10'] # latest is most recent on PyPI + OPENMDAO_VERSION_OLDEST: ['3.21'] # latest is most recent on PyPI fail-fast: false env: OMP_NUM_THREADS: 1 @@ -56,7 +56,7 @@ jobs: run: | conda config --set always_yes yes python -m pip install pip==${{ matrix.PIP_VERSION_OLDEST }} setuptools==${{ matrix.SETUPTOOLS_VERSION_OLDEST }} --upgrade wheel - pip install numpy==${{ matrix.NUMPY_VERSION_OLDEST }} scipy==${{ matrix.SCIPY_VERSION_OLDEST }} openmdao==${{ matrix.OPENMDAO_VERSION_OLDEST }} om-pycycle + pip install numpy==${{ matrix.NUMPY_VERSION_OLDEST }} scipy==${{ matrix.SCIPY_VERSION_OLDEST }} om-pycycle - name: Install dependencies (latest versions) if: ${{ matrix.dep-versions == 'latest' }} run: | @@ -68,6 +68,11 @@ jobs: pip install -e .[testing] pip install -e .[docs] + - name: Install oldest OpenMDAO versions + if: ${{ matrix.dep-versions == 'oldest' }} + run: | + pip install openmdao==${{ matrix.OPENMDAO_VERSION_OLDEST }} + - name: List Python and package info run: | python --version diff --git a/doc/features/aerodynamics.rst b/doc/features/aerodynamics.rst index 26245226..58454e8b 100644 --- a/doc/features/aerodynamics.rst +++ b/doc/features/aerodynamics.rst @@ -28,6 +28,22 @@ In run script, users should set the values for the following aircraft design par - Zero-lift drag coefficient +Drag buildups +============= +A drag buildup can provide a first estimate of an aircraft's drag coefficient. +It uses empirical estimates for the drag of individual components, such as the fuselage and engine, and sums them to predict the total drag. +Empirical interference factors included in the summation account for drag caused by the interaction of components. + +In OpenConcept, the drag buildups return :math:`C_{D, 0}`, the zero-lift drag coefficient. +Drag buildups for two configurations are included. +For a conventional tube and wing configuration, use ``ParasiteDragCoefficient_JetTransport``. +For a blended wing body configuration, use ``ParasiteDragCoefficient_BWB`` (the BWB version **requires** the use of OpenAeroStruct to predict the wing and centerbody drag). +This value can then be used either with the simple drag polar (``PolarDrag``) or one of the OpenAeroStruct-based drag models to add in the lift-induced component of drag. +OpenAeroStruct already includes the zero-lift drag of the wing. +To prevent double counting this drag, the ``ParasiteDragCoefficient_JetTransport`` has an option called ``include_wing``, which should be set to ``False`` when using OpenAeroStruct for drag prediction. + +The source code describes details of the implementation, including sources for the individual empirical equations and constants. + Using OpenAeroStruct ==================== Instead of the simple drag polar model, you can use `OpenAeroStruct `_ to compute the drag. @@ -57,7 +73,7 @@ The aerodynamic mesh can be defined in one of three ways: More details on the inputs, outputs, and options are available in the source code documentation. -Aerostructural model: ``AeroStructDragPolar`` +Aerostructural model: ``AerostructDragPolar`` ----------------------------------------------------- This model is similar to the VLM-based aerodynamic model, but it performs aerostructural analysis (that couples VLM and structural FEM) instead of aerodynamic analysis (just FEM). This means that we now consider the wing deformation due to aerodynamic loads, which is important for high aspect ratio wings. @@ -91,10 +107,34 @@ Understanding the surrogate modeling OpenConcept uses surrogate models based on OpenAeroStruct analyses to reduce the computational cost for mission analysis. The surrogate models are trained in the 3D input space of Mach number, angle of attack, and altitude. -The outputs of the surrogate models are CL and CD (and failure for ``AeroStructDragPolar``). +The outputs of the surrogate models are CL and CD (and failure for ``AerostructDragPolar``). For more details about the surrogate models, see our `paper `_. +:math:`C_{L, \text{max}}` estimates +================================== +Accurately predicting :math:`C_{L, \text{max}}`, the maximum lift coefficient, is a notoriously challenging task, but doing so is crucial for estimating stall speed and takeoff field length. + +Empirical fits +-------------- +In conceptual design, empirical estimates are often used. +OpenConcept's ``CleanCLmax`` uses a method from :footcite:t:`raymer2006aircraft` to model the maximum lift coefficient of a clean wing (without high lift devices extended). +The ``FlapCLmax`` component adds a delta to the clean :math:`C_{L, \text{max}}` to account for flaps and slats, using fits of data from :footcite:t:`roskam1989VI`. + +With OpenAeroStruct +------------------- +An alternative way to predict :math:`C_{L, \text{max}}` is to use the critical section method with a panel code. +In this method, the wing angle of attack is increased until the wing's sectional lift coefficient first hits the airfoil's :math:`C_{l, \text{max}}` at some point along the span. +As with before, the sectional :math:`C_{l, \text{max}}` is often predicted using empirical estimates. + +OpenConcept includes a method to use OpenAeroStruct to carry out the critical section method. +The first step is to perform an OpenAeroStruct analysis of the wing. +Next, the difference between the spanwise sectional lift coefficient computed by OpenAeroStruct and the associated :math:`C_{l, \text{max}}` is aggregated to smoothly compute the nearest point to stall. +Finally, a solver varies OpenAeroStruct's angle of attack to drive the aggregated :math:`\max(C_l - C_{l, \text{max}})` to zero. +A Newton solver is capable of this system, but it is very slow because it needs to invert the whole system's Jacobian. +A better method is to use OpenMDAO's ``NonlinearSchurSolver``. +At the time of writing this, it is available on `this OpenMDAO branch `_, but not in the main OpenMDAO repository. + Other models ============ @@ -102,3 +142,5 @@ The aerodynamics module also includes a couple components that may be useful: - ``StallSpeed``, which uses :math:`C_{L, \text{max}}`, aircraft weight, and wing area to compute the stall speed - ``Lift``, which computes lift force using lift coefficient, wing area, and dynamic pressure + +.. footbibliography:: diff --git a/doc/features/energy_storage.rst b/doc/features/energy_storage.rst index 49487e72..f588b1a3 100644 --- a/doc/features/energy_storage.rst +++ b/doc/features/energy_storage.rst @@ -5,7 +5,6 @@ Energy Storage ************** This module contains components that can store energy. -For now this consists only of battery models, but hydrogen tanks would go here too, for example. Battery models ============== @@ -26,3 +25,13 @@ This is not automatically forced to be less than one, so the user is responsible This component uses the same model as the ``SimpleBattery``, but adds an integrator to compute the state of charge (from 0.0 to 1.0). By default, it starts at a state of charge of 1.0 (100% charge). + +Hydrogen tank model +=================== + +``LH2TankNoBoilOff`` +-------------------- + +This provides a physics-based structural weight model of a liquid hydrogen tank. +It includes an integrator for computing the current mass of LH2 inside the tank. +For details, see the source code. diff --git a/doc/features/geometry.rst b/doc/features/geometry.rst new file mode 100644 index 00000000..436c0b84 --- /dev/null +++ b/doc/features/geometry.rst @@ -0,0 +1,47 @@ +.. _Geometry: + +******** +Geometry +******** + +This module includes tools for computing useful geometry quantities. + +Wing geometry +============= + +``WingMACTrapezoidal`` +---------------------- +This component computes the mean aerodynamic chord of a trapezoidal wing planform (defined by area, aspect ratio, and taper). + +``WingSpan`` +------------ +This component computes the span of an arbitrary wing as :math:`\sqrt{S_{ref} AR}`, where :math:`S_{ref}` is the wing planform area and :math:`AR` is the wing aspect ratio. + +``WingAspectRatio`` +------------------- +This component computes the aspect ratio of an arbitrary wing as :math:`b^2 / S_{ref}` where :math:`b` is the wing span and :math:`S_{ref}` is the wing planform area. + +``WingSweepFromSections`` +------------------------- +This component computes the average quarter chord sweep angle of a wing defined in linearly-varying piecewise sections in the spanwise direction. +The average sweep angle is weighted by section areas. + +``WingAreaFromSections`` +------------------------- +This component computes the planform area of a wing defined in linearly-varying piecewise sections in the spanwise direction. + +.. warning:: + If you are using this component in conjunction with ``SectionPlanformMesh`` and the inputs you are passing to this component are the same as those passed to ``SectionPlanformMesh``, ensure that you have set the ``scale_area`` option in ``SectionPlanformMesh`` to ``False``. + Otherwise, the resulting wing area will be off by a factor. + +``WingMACFromSections`` +------------------------ +This component computes the mean aerodynamic chord of a wing defined in linearly-varying piecewise sections in the spanwise direction. +It returns both the mean aerodynamic chord and the longitudinal position of the mean aerodynamic chord's quarter chord. + +Other quantities +================ + +``CylinderSurfaceArea`` +----------------------- +Computes the surface area of a cylinder, which can be used to estimate, for example, the wetted area of a fuselage or engine nacelle. diff --git a/doc/features/mission.rst b/doc/features/mission.rst index dd940387..a42dce51 100644 --- a/doc/features/mission.rst +++ b/doc/features/mission.rst @@ -24,7 +24,7 @@ This is a basic climb-cruise-descent mission for a fixed-wing aircraft. For this mission, users should specify the following variables in the run script: -- takeoff altitude ``takeoff|h0``, default is 0 ft. +- takeoff and landing altitude ``takeoff|h``. If a ground roll is included, that altitude needs to be set separately via the ground roll's ``fltcond|h`` variable. This parameter should not be used with the ``FullMissionAnalysis`` or ``FullMissionWithReserve`` because it does not properly set takeoff altitudes as you may expect. - cruise altitude ``cruise|h0``. - mission range ``mission_range``. - payload weight ``payload``. @@ -65,6 +65,11 @@ Additional variables you need to set in the run script are - reserve range ``reserve_range`` and altitude ``reserve|h0``. - loiter duration ``loiter_duration`` and loiter altitude ``loiter|h0``. +Full mission with reserve: ``FullMissionWithReserve`` +-------------------------------------------- +This mission combines ``FullMissionAnalysis`` and ``MissionWithReserve``, so it includes takeoff phases, climb, cruise, descent, and a reserve mission. +Refer to the documentation for ``FullMissionAnalysis`` and ``MissionWithReserve`` to determine which parameters must be set. + Phase types =========== A phase is a building block of a mission profile. diff --git a/doc/features/propulsion.rst b/doc/features/propulsion.rst index c4302a81..6c179506 100644 --- a/doc/features/propulsion.rst +++ b/doc/features/propulsion.rst @@ -88,4 +88,11 @@ It uses either a fractional or fixed split method where fractional splits the in The efficiency can be changed from the default of 100%, which results in some heat being generated. Cost and weight are modeled as linear functions of the power rating. +Rubber turbofan: ``RubberizedTurbofan`` +--------------------------------------- + +This model enables the pyCycle-based CFM56 and N+3 turbofan models to be scaled to different thrust ratings. +The scaling is done by multiplying thrust and fuel flow by the same value. +The model also has an option to use hydrogen, which scales the fuel flow to maintain the same thrust-specific energy consumption (see :footcite:t:`Adler2023` for a definition) as the kerosene-powered CFM56 and N+3 engine models. + .. footbibliography:: diff --git a/doc/features/stability.rst b/doc/features/stability.rst new file mode 100644 index 00000000..4bd3bd75 --- /dev/null +++ b/doc/features/stability.rst @@ -0,0 +1,14 @@ +.. _Stability: + +********* +Stability +********* + +Tail volume coefficients +======================== + +In conceptual aircraft design, required horizontal and vertical tail areas of conventional configurations can be approximated using tail volume coefficients. +This method considers the wing area, wing mean aerodynamic chord, and distance between the wing's quarter mean aerodynamic chord and tail's quarter mean aerodynamic chord. +``HStabVolumeCoefficientSizing`` and ``VStabVolumeCoefficientSizing`` use tail volume coefficient relationships from :footcite:t:`raymer2006aircraft` to predict horizontal and vertical tail reference areas, respectively. + +.. footbibliography:: diff --git a/doc/features/weights.rst b/doc/features/weights.rst index e08cdb22..d3250ec6 100644 --- a/doc/features/weights.rst +++ b/doc/features/weights.rst @@ -5,9 +5,42 @@ Weights ******* This module provides empty weight approximations using mostly empirical textbook methods. -For now there are only models for small turboprop-sized aircraft, but more may be added in the future. Component positions within the aircraft are not considered; all masses are accumulated into a single number. +Conventional jet transport aircraft OEW: ``JetTransportEmptyWeight`` +==================================================================== + +This model combines estimates from :footcite:t:`raymer2006aircraft`, :footcite:t:`roskam2019airplane`, and others to estimate the operating empty weight of a jet transport aircraft. +The model includes two correction factor options: ``structural_fudge`` that multiplies structural weights and another ``total_fudge`` which multiplies the final total weight. +A complete list of the required inputs and outputs can be found in OpenConcept's API documentation, and more details are available in the source code. + +This model uses the following components from the `openconcept.weights` module to estimate the total empty weight: + +- ``WingWeight_JetTransport`` +- ``HstabConst_JetTransport`` +- ``HstabWeight_JetTransport`` +- ``VstabWeight_JetTransport`` +- ``FuselageKws_JetTransport`` +- ``FuselageWeight_JetTransport`` +- ``MainLandingGearWeight_JetTransport`` +- ``NoseLandingGearWeight_JetTransport`` +- ``EngineWeight_JetTransport`` +- ``EngineSystemsWeight_JetTransport`` +- ``NacelleWeight_JetTransport`` +- ``FurnishingWeight_JetTransport`` +- ``EquipmentWeight_JetTransport`` + +Blended wing body jet OEW: ``BWBEmptyWeight`` +============================================= + +This blended wing body empty weight model is a modified version of the ``JetTransportEmptyWeight`` buildup. +It contains the following changes from the conventional configuration jet transport empty weight buildup: + +- Separate model for the weight of the pressurized portion of the centerbody for passengers or cargo (``CabinWeight_BWB`` component) +- Separate model for the weight of the unpressurized portion of the centerbody behind the passengers or cargo (``AftbodyWeight_BWB`` component) +- Removed fuselage and tail weights + + Single-engine turboprop OEW: ``SingleTurboPropEmptyWeight`` =========================================================== diff --git a/doc/index.rst b/doc/index.rst index 066d0dac..9783892d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -64,8 +64,8 @@ OpenConcept is tested regularly on builds with the oldest and latest supported p - 3.8 - 3.11 * - OpenMDAO - - 3.10 - - latest + - 3.21 + - 3.30 * - NumPy - 1.20 - latest @@ -137,8 +137,10 @@ Eytan J. Adler and Joaquim R.R.A. Martins, "Efficient Aerostructural Wing Optimi features/atmospherics.rst features/costs.rst features/energy_storage.rst + features/geometry.rst features/mission.rst features/propulsion.rst + features/stability.rst features/thermal.rst features/weights.rst features/utilities.rst diff --git a/doc/ref.bib b/doc/ref.bib index 50ab37c5..e7175c7d 100644 --- a/doc/ref.bib +++ b/doc/ref.bib @@ -14,4 +14,24 @@ @book{roskam2019airplane address={Lawrence, Kansas}, year={2019}, isbn={978-1-994995-50-1}, -} \ No newline at end of file +} + +@book{roskam1989VI, + author = {Jan Roskam}, + title = {Airplane Design Part VI: Preliminary Calculation of Aerodynamic, Thrust, and Power Characteristics}, + publisher = {{DARcorporation}}, + year = {1989}, + isbn = {978-1-884885-52-5}, +} + +@article{Adler2023, + author = {Eytan J. Adler and Joaquim R. R. A. Martins}, + title = {Hydrogen-Powered Aircraft: Fundamental Concepts, Key Technologies, and Environmental Impacts}, + doi = {10.1016/j.paerosci.2023.100922}, + journal = {Progress in Aerospace Sciences}, + month = {August}, + pages = {100922}, + pdfurl = {https://www.researchgate.net/publication/366157885}, + volume = {141}, + year = {2023}, +} diff --git a/doc/tutorials/more_examples.rst b/doc/tutorials/more_examples.rst index f7cbc60f..ef1c1eaf 100644 --- a/doc/tutorials/more_examples.rst +++ b/doc/tutorials/more_examples.rst @@ -45,3 +45,4 @@ Other useful places to look =========================== The ``B738_VLM_drag.py`` and ``B738_aerostructural.py`` examples show how to use the OpenAeroStruct VLM and aerostructural models. +The ``B738_sizing.py`` example demonstrates the use of the weight buildup, drag buildup, and :math:`C_{L, \text{max}}` estimate. diff --git a/openconcept/__init__.py b/openconcept/__init__.py index 72f26f59..c68196d1 100644 --- a/openconcept/__init__.py +++ b/openconcept/__init__.py @@ -1 +1 @@ -__version__ = "1.1.2" +__version__ = "1.2.0" diff --git a/openconcept/aerodynamics/CL_max_estimation.py b/openconcept/aerodynamics/CL_max_estimation.py new file mode 100644 index 00000000..11eae035 --- /dev/null +++ b/openconcept/aerodynamics/CL_max_estimation.py @@ -0,0 +1,204 @@ +""" +@File : CLmax_jet_transport.py +@Date : 2023/03/24 +@Author : Eytan Adler +@Description : Max lift coefficient estimate for jet transport aircraft +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== + + +# ============================================================================== +# External Python modules +# ============================================================================== +import numpy as np +import openmdao.api as om + +# ============================================================================== +# Extension modules +# ============================================================================== + + +class CleanCLmax(om.ExplicitComponent): + """ + Predict the maximum lift coefficient of the clean configuration. + Method from Raymer (Equation 12.15, 1992 edition). + + Inputs + ------ + ac|aero|airfoil_Cl_max : float + Maximum 2D lift coefficient of the wing airfoil (scalar, dimensionless) + ac|geom|wing|c4sweep : float + Wing quarter chord sweep angle (scalar, radians) + + Outputs + ------- + CL_max_clean : float + Maximum lift coefficient with no flaps or slats (scalar, dimensionless) + + Options + ------- + fudge_factor : float + Optional multiplier on resulting lift coefficient, by default 1.0 + """ + + def initialize(self): + self.options.declare("fudge_factor", default=1.0, desc="Multiplier of CL max") + + def setup(self): + self.add_input("ac|aero|airfoil_Cl_max") + self.add_input("ac|geom|wing|c4sweep", units="rad") + self.add_output("CL_max_clean") + self.declare_partials("CL_max_clean", "*") + + def compute(self, inputs, outputs): + Cl_max = inputs["ac|aero|airfoil_Cl_max"] + sweep = inputs["ac|geom|wing|c4sweep"] + + outputs["CL_max_clean"] = self.options["fudge_factor"] * 0.9 * Cl_max * np.cos(sweep) + + def compute_partials(self, inputs, J): + Cl_max = inputs["ac|aero|airfoil_Cl_max"] + sweep = inputs["ac|geom|wing|c4sweep"] + mult = self.options["fudge_factor"] + + J["CL_max_clean", "ac|aero|airfoil_Cl_max"] = mult * 0.9 * np.cos(sweep) + J["CL_max_clean", "ac|geom|wing|c4sweep"] = -mult * 0.9 * Cl_max * np.sin(sweep) + + +class FlapCLmax(om.ExplicitComponent): + """ + Predict the maximum lift coefficient with Fowler flaps and slats + extended. Method from Roskam Part VI Chapter 8 1989. + + Inputs + ------ + flap_extension : float + Flap extension amount (scalar, deg) + ac|geom|wing|c4sweep : float + Wing sweep at 25% mean aerodynamic chord (scalar, radians) + ac|geom|wing|toverc : float + Wing thickness-to-chord ratio (scalar, dimensionless) + CL_max_clean : float + Maximum lift coefficient with no flaps or slats (scalar, dimensionless) + + Outputs + ------- + CL_max_flap : float + Maximum lift coefficient with flaps and slats extended (scalar, dimensionless) + + + Options + ------- + flap_chord_frac : float + Flap chord divided by wing chord, by default 0.2 + wing_area_flapped_frac : float + Flapped wing area divided by total wing area. Flapped wing area integrates the chord + over any portions of the span that contain flaps (not just the area of the flap itself). + By default 0.9. + slat_chord_frac : float + Slat chord divided by wing chord, by default 0.1. Set to 0.0 to remove slats. + slat_span_frac : float + Fraction of the wing span that has slats, by default 0.8 + fudge_factor : float + Optional multiplier on resulting lift coefficient, by default 1.0 + """ + + def initialize(self): + self.options.declare("flap_chord_frac", default=0.2, desc="Flap chord / wing chord") + self.options.declare("wing_area_flapped_frac", default=0.9, desc="Flapped wing area / wing area") + self.options.declare("slat_chord_frac", default=0.1, desc="Slat chord / wing chord") + self.options.declare("slat_span_frac", default=0.8, desc="Slat span / wing span") + self.options.declare("fudge_factor", default=1.0, desc="Multiplier of CL max") + + def setup(self): + self.add_input("flap_extension", units="deg") + self.add_input("ac|geom|wing|c4sweep", units="rad") + self.add_input("CL_max_clean") + self.add_input("ac|geom|wing|toverc") + + self.add_output("CL_max_flap") + + self.declare_partials("CL_max_flap", ["flap_extension", "ac|geom|wing|c4sweep", "ac|geom|wing|toverc"]) + self.declare_partials("CL_max_flap", "CL_max_clean", val=self.options["fudge_factor"]) + + def compute(self, inputs, outputs): + delta = inputs["flap_extension"] + sweep = inputs["ac|geom|wing|c4sweep"] + tc = inputs["ac|geom|wing|toverc"] + + # -------------- Compute the increase in 2D airfoil lift coefficient from flaps -------------- + # See Roskam 1989 Part VI Chapter 8 Equation 8.18 + delta_cl_max_base = 1 + 2.33 * tc - 77.9 * tc**2 + 1120 * tc**3 - 3430 * tc**4 + k1 = 4 * self.options["flap_chord_frac"] + k2 = 0.4 + 0.0234 * delta - 2.04e-4 * delta**2 + k3 = 1.36 * k2 - 0.389 * k2**2 + delta_cl_max = delta_cl_max_base * k1 * k2 * k3 + + # -------------- Compute the increment to the total wing lift coefficient from flaps -------------- + # See Roskam 1989 Part VI Chapter 8 Equation 8.29 + delta_CL_max_flaps = ( + delta_cl_max + * self.options["wing_area_flapped_frac"] + * (1 - 0.08 * np.cos(sweep) ** 2) + * np.cos(sweep) ** 0.75 + ) + + # -------------- Compute the increment to the total wing lift coefficient from slats -------------- + # See Roskam 1989 Part VI Chapter 8 Equation 8.30 + delta_CL_max_slats = ( + 7.11 * self.options["slat_chord_frac"] * self.options["slat_span_frac"] ** 2 * np.cos(sweep) ** 2 + ) + + outputs["CL_max_flap"] = self.options["fudge_factor"] * ( + inputs["CL_max_clean"] + delta_CL_max_flaps + delta_CL_max_slats + ) + + def compute_partials(self, inputs, J): + delta = inputs["flap_extension"] + sweep = inputs["ac|geom|wing|c4sweep"] + tc = inputs["ac|geom|wing|toverc"] + + # -------------- Compute the increase in 2D airfoil lift coefficient from flaps -------------- + # See Roskam 1989 Part VI Chapter 8 Equation 8.18 + delta_cl_max_base = 1 + 2.33 * tc - 77.9 * tc**2 + 1120 * tc**3 - 3430 * tc**4 + k1 = 4 * self.options["flap_chord_frac"] + k2 = 0.4 + 0.0234 * delta - 2.04e-4 * delta**2 + k3 = 1.36 * k2 - 0.389 * k2**2 + delta_cl_max = delta_cl_max_base * k1 * k2 * k3 + + ddclmax_dtc = k1 * k2 * k3 * (2.33 - 2 * 77.9 * tc + 3 * 1120 * tc**2 - 4 * 3430 * tc**3) + dk2_ddelta = 0.0234 - 2 * 2.04e-4 * delta + dk3_ddelta = (1.36 - 2 * 0.389 * k2) * dk2_ddelta + ddclmax_ddelta = delta_cl_max_base * k1 * (k2 * dk3_ddelta + dk2_ddelta * k3) + + # -------------- Compute the increment to the total wing lift coefficient from flaps -------------- + # See Roskam 1989 Part VI Chapter 8 Equation 8.29 + J["CL_max_flap", "ac|geom|wing|c4sweep"] = ( + delta_cl_max + * self.options["wing_area_flapped_frac"] + * (np.sin(sweep) * (0.22 * np.cos(sweep) ** 2 - 0.75) / np.cos(sweep) ** 0.25) + ) + + ddclmaxflap_ddclmax = ( + self.options["wing_area_flapped_frac"] * (1 - 0.08 * np.cos(sweep) ** 2) * np.cos(sweep) ** 0.75 + ) + J["CL_max_flap", "flap_extension"] = ddclmaxflap_ddclmax * ddclmax_ddelta + J["CL_max_flap", "ac|geom|wing|toverc"] = ddclmaxflap_ddclmax * ddclmax_dtc + + # -------------- Compute the increment to the total wing lift coefficient from slats -------------- + # See Roskam 1989 Part VI Chapter 8 Equation 8.30 + J["CL_max_flap", "ac|geom|wing|c4sweep"] -= ( + 7.11 + * self.options["slat_chord_frac"] + * self.options["slat_span_frac"] ** 2 + * 2 + * np.cos(sweep) + * np.sin(sweep) + ) + + J["CL_max_flap", "flap_extension"] *= self.options["fudge_factor"] + J["CL_max_flap", "ac|geom|wing|toverc"] *= self.options["fudge_factor"] + J["CL_max_flap", "ac|geom|wing|c4sweep"] *= self.options["fudge_factor"] diff --git a/openconcept/aerodynamics/__init__.py b/openconcept/aerodynamics/__init__.py index 8f405a75..e24c511f 100644 --- a/openconcept/aerodynamics/__init__.py +++ b/openconcept/aerodynamics/__init__.py @@ -1,4 +1,7 @@ from .aerodynamics import PolarDrag, StallSpeed, Lift +from .drag_jet_transport import ParasiteDragCoefficient_JetTransport +from .drag_BWB import ParasiteDragCoefficient_BWB +from .CL_max_estimation import CleanCLmax, FlapCLmax try: from .openaerostruct import VLMDragPolar, AerostructDragPolar diff --git a/openconcept/aerodynamics/aerodynamics.py b/openconcept/aerodynamics/aerodynamics.py index 250174d1..11ba5cf6 100644 --- a/openconcept/aerodynamics/aerodynamics.py +++ b/openconcept/aerodynamics/aerodynamics.py @@ -32,26 +32,30 @@ class PolarDrag(ExplicitComponent): ------- num_nodes : int Number of analysis points to run (sets vec length) (default 1) + vec_CD0 : bool + Take in zero-lift drag coefficient as a vector of length num_nodes, + otherwise take in as a scalar; by default False """ def initialize(self): self.options.declare("num_nodes", default=1, desc="Number of nodes to compute") + self.options.declare("vec_CD0", default=False, types=bool, desc="Take CD0 in as a vector") def setup(self): nn = self.options["num_nodes"] + vec_CD0 = self.options["vec_CD0"] arange = np.arange(0, nn) self.add_input("fltcond|CL", shape=(nn,)) self.add_input("fltcond|q", units="N * m**-2", shape=(nn,)) self.add_input("ac|geom|wing|S_ref", units="m **2") - self.add_input("CD0") + self.add_input("CD0", shape=(nn,) if vec_CD0 else (1,)) self.add_input("e") self.add_input("ac|geom|wing|AR") self.add_output("drag", units="N", shape=(nn,)) self.declare_partials(["drag"], ["fltcond|CL", "fltcond|q"], rows=arange, cols=arange) - self.declare_partials( - ["drag"], ["ac|geom|wing|S_ref", "ac|geom|wing|AR", "CD0", "e"], rows=arange, cols=np.zeros(nn) - ) + self.declare_partials(["drag"], ["ac|geom|wing|S_ref", "ac|geom|wing|AR", "e"], rows=arange, cols=np.zeros(nn)) + self.declare_partials(["drag"], ["CD0"], rows=arange, cols=arange if vec_CD0 else np.zeros(nn)) def compute(self, inputs, outputs): outputs["drag"] = ( diff --git a/openconcept/aerodynamics/drag_BWB.py b/openconcept/aerodynamics/drag_BWB.py new file mode 100644 index 00000000..afc23766 --- /dev/null +++ b/openconcept/aerodynamics/drag_BWB.py @@ -0,0 +1,193 @@ +""" +@File : drag_jet_transport.py +@Date : 2023/03/23 +@Author : Eytan Adler +@Description : Zero lift drag coefficient buildup for tube-and-wing jet transport aircraft +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== + + +# ============================================================================== +# External Python modules +# ============================================================================== +import openmdao.api as om + +# ============================================================================== +# Extension modules +# ============================================================================== +from openconcept.utilities import AddSubtractComp, ElementMultiplyDivideComp +from openconcept.aerodynamics.drag_jet_transport import SkinFrictionCoefficient_JetTransport, FlapDrag_JetTransport + + +class ParasiteDragCoefficient_BWB(om.Group): + """ + Zero-lift drag coefficient buildup for BWB based on + a combination of methods from Roskam, Raymer, and the approach presented by + OpenVSP (https://openvsp.org/wiki/doku.php?id=parasitedrag). See the individual + component docstrings for a more detailed explanation. + + NOTE: This component does not include drag from the wing, because it assumes + it is computed by OpenAeroStruct. Add it in! + + Inputs + ------ + fltcond|Utrue : float + True airspeed (vector, m/s) + fltcond|rho : float + Air density (vector, kg/m^3) + fltcond|T : float + Air temperature (vector, K) + ac|geom|wing|S_ref : float + Wing planform area (scalar, sq m) + ac|geom|wing|c4sweep : float + If configuration is \"takeoff\" (otherwise not an input), wing quarter chord sweep (scalar, rad) + ac|geom|nacelle|length : float + Nacelle length (scalar, m) + ac|geom|nacelle|S_wet : float + Nacelle wetted area (scalar, sq m) + ac|propulsion|num_engines : float + Number of engines, multiplier on nacelle drag (scalar, dimensionless) + ac|aero|takeoff_flap_deg : float + If configuration is \"takeoff\" (otherwise not an input), flap setting on takeoff (scalar, deg) + + Outputs + ------- + CD0 : float + Zero-lift drag coefficient (vector, dimensionless) + + Options + ------- + num_nodes : int + Number of analysis points per phase, by default 1 + configuration : str + Aircraft configuration, either \"takeoff\" or \"clean\". Takeoff includes drag + from landing gear and extended flaps. Clean assumes gear and flaps are retracted. + FF_nacelle : float + Nacelle form factor. By default 1.25 * 1.2, which is taken from the Jenkinson wing nacelle + specified in the OpenVSP documentation (https://openvsp.org/wiki/doku.php?id=parasitedrag) + multiplied by a rough estimate of the interference factor of 1.2, appx taken from Raymer. + It was originally published in Civil Jet Aircraft by Jenkinson, Simpkin, and Rhodes (1999). + Include any desired interference factor in the value provided to this option. + flap_chord_frac : float + Flap chord divided by wing chord, by default 0.2 + Q_flap : float + Interference drag of flap. By default 1.25, from Roskam Equation 4.75 for Fowler flaps. + wing_area_flapped_frac : float + Flapped wing area divided by total wing area. Flapped wing area integrates the chord + over any portions of the span that contain flaps (not just the area of the flap itself). + Set this to zero to neglect drag from flaps, by default 0. + drag_fudge_factor : float + Multiplier on the resulting zero-lift drag coefficient estimate, by default 1.0 + nacelle_laminar_frac : float + Fraction of the total engine nacelle length that has a laminary boundary layer, by default 0.0 + """ + + def initialize(self): + self.options.declare("num_nodes", default=1, desc="Number of analysis points per phase") + self.options.declare("configuration", default="clean", values=["takeoff", "clean"]) + self.options.declare("drag_fudge_factor", default=1.0, desc="Multiplier on total drag coefficient") + self.options.declare("FF_nacelle", default=1.25 * 1.2, desc="Nacelle form factor times interference factor") + self.options.declare("flap_chord_frac", default=0.2, desc="Flap chord / wing chord") + self.options.declare("Q_flap", default=1.25, desc="Flap interference factor") + self.options.declare("wing_area_flapped_frac", default=0.0, desc="Flapped wing area / wing area") + self.options.declare("nacelle_laminar_frac", default=0.0, desc="Fraction of engine nacelle with laminar flow") + + def setup(self): + is_clean = self.options["configuration"] == "clean" + nn = self.options["num_nodes"] + + iv = self.add_subsystem("iv", om.IndepVarComp()) + + # ============================================================================== + # Compute form factors + # ============================================================================== + # -------------- Nacelle -------------- + iv.add_output("FF_nacelle", val=self.options["FF_nacelle"]) + + # ============================================================================== + # Skin friction coefficients for each component + # ============================================================================== + # -------------- Nacelle -------------- + self.add_subsystem( + "nacelle_friction", + SkinFrictionCoefficient_JetTransport(num_nodes=nn, laminar_frac=self.options["nacelle_laminar_frac"]), + promotes_inputs=["fltcond|Utrue", "fltcond|rho", "fltcond|T", ("L", "ac|geom|nacelle|length")], + ) + + # ============================================================================== + # Compute the parasitic drag coefficient + # ============================================================================== + mult = self.add_subsystem( + "drag_coeffs", + ElementMultiplyDivideComp(), + promotes_inputs=[ + "ac|geom|nacelle|S_wet", + "ac|propulsion|num_engines", + "ac|geom|wing|S_ref", + ], + ) + mult.add_equation( + output_name="CD_nacelle", + input_names=[ + "ac|geom|nacelle|S_wet", + "FF_nacelle", + "Cf_nacelle", + "ac|propulsion|num_engines", + "ac|geom|wing|S_ref", + ], + vec_size=[1, 1, nn, 1, 1], + input_units=["m**2", None, None, None, "m**2"], + divide=[False, False, False, False, True], + ) + + # -------------- Internal connections -------------- + self.connect("iv.FF_nacelle", "drag_coeffs.FF_nacelle") + self.connect("nacelle_friction.Cf", "drag_coeffs.Cf_nacelle") + + # ============================================================================== + # Any addition drag sources in the takeoff configuration + # ============================================================================== + # -------------- Flaps -------------- + if is_clean: + CD_flap_source = "iv.CD_flap" + iv.add_output("CD_flap", val=0.0) + else: + CD_flap_source = "flaps.CD_flap" + self.add_subsystem( + "flaps", + FlapDrag_JetTransport( + flap_chord_frac=self.options["flap_chord_frac"], + Q_flap=self.options["Q_flap"], + wing_area_flapped_frac=self.options["wing_area_flapped_frac"], + ), + promotes_inputs=[("flap_extension", "ac|aero|takeoff_flap_deg"), "ac|geom|wing|c4sweep"], + ) + + # -------------- Landing gear -------------- + # Raymer suggests adding 0.02 to the zero-lift drag coefficient when retractable + # landing gear are in the down position. See Section 5.3, page 99 in the 1992 edition. + # For BWBs, this value has been halved because the wing area has roughly doubled from + # a comparable tube-and-wing configuration (assuming same drag force for gear). + iv.add_output("CD_landing_gear", val=0.0 if is_clean else 0.01) + + # ============================================================================== + # Sum the total drag coefficients + # ============================================================================== + drag_coeff_inputs = ["CD_nacelle", "CD_flap", "CD_landing_gear"] + self.add_subsystem( + "sum_CD0", + AddSubtractComp( + output_name="CD0", + input_names=drag_coeff_inputs, + vec_size=[nn, 1, 1], + scaling_factors=[self.options["drag_fudge_factor"]] * len(drag_coeff_inputs), + ), + promotes_outputs=["CD0"], + ) + + self.connect("drag_coeffs.CD_nacelle", "sum_CD0.CD_nacelle") + self.connect(CD_flap_source, "sum_CD0.CD_flap") + self.connect("iv.CD_landing_gear", "sum_CD0.CD_landing_gear") diff --git a/openconcept/aerodynamics/drag_jet_transport.py b/openconcept/aerodynamics/drag_jet_transport.py new file mode 100644 index 00000000..3495d01e --- /dev/null +++ b/openconcept/aerodynamics/drag_jet_transport.py @@ -0,0 +1,638 @@ +""" +@File : drag_jet_transport.py +@Date : 2023/03/23 +@Author : Eytan Adler +@Description : Zero lift drag coefficient buildup for tube-and-wing jet transport aircraft +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== + + +# ============================================================================== +# External Python modules +# ============================================================================== +import numpy as np +import openmdao.api as om + +# ============================================================================== +# Extension modules +# ============================================================================== +from openconcept.utilities import AddSubtractComp, ElementMultiplyDivideComp +from openconcept.geometry import WingMACTrapezoidal + + +class ParasiteDragCoefficient_JetTransport(om.Group): + """ + Zero-lift drag coefficient buildup for jet transport aircraft based on + a combination of methods from Roskam, Raymer, and the approach presented by + OpenVSP (https://openvsp.org/wiki/doku.php?id=parasitedrag). See the individual + component docstrings for a more detailed explanation. + + Inputs + ------ + fltcond|Utrue : float + True airspeed (vector, m/s) + fltcond|rho : float + Air density (vector, kg/m^3) + fltcond|T : float + Air temperature (vector, K) + ac|geom|fuselage|length : float + Fuselage length (scalar, m) + ac|geom|fuselage|height : float + Fuselage height (scalar, m) + ac|geom|fuselage|S_wet : float + Fuselage wetted area (scalar, sq m) + ac|geom|hstab|S_ref : float + Horizontal stabilizer planform area (scalar, sq m) + ac|geom|hstab|AR : float + Horizontal stabilizer aspect ratio (scalar, dimensionless) + ac|geom|hstab|taper : float + Horizontal stabilizer taper ratio (scalar, dimensionless) + ac|geom|hstab|toverc : float + Horizontal stabilizer thickness-to-chord ratio (scalar, dimensionless) + ac|geom|vstab|S_ref : float + Vertical stabilizer planform area (scalar, sq m) + ac|geom|vstab|AR : float + Vertical stabilizer aspect ratio (scalar, dimensionless) + ac|geom|vstab|taper : float + Vertical stabilizer taper ratio (scalar, dimensionless) + ac|geom|vstab|toverc : float + Vertical stabilizer thickness-to-chord ratio (scalar, dimensionless) + ac|geom|wing|S_ref : float + Wing planform area (scalar, sq m) + ac|geom|wing|AR : float + If include wing (otherwise not an input), wing aspect ratio (scalar, dimensionless) + ac|geom|wing|taper : float + If include wing (otherwise not an input), wing taper ratio (scalar, dimensionless) + ac|geom|wing|toverc : float + If include wing (otherwise not an input), wing thickness-to-chord ratio (scalar, dimensionless) + ac|geom|wing|c4sweep : float + If configuration is \"takeoff\" (otherwise not an input), wing quarter chord sweep (scalar, rad) + ac|geom|nacelle|length : float + Nacelle length (scalar, m) + ac|geom|nacelle|S_wet : float + Nacelle wetted area (scalar, sq m) + ac|propulsion|num_engines : float + Number of engines, multiplier on nacelle drag (scalar, dimensionless) + ac|aero|takeoff_flap_deg : float + If configuration is \"takeoff\" (otherwise not an input), flap setting on takeoff (scalar, deg) + + Outputs + ------- + CD0 : float + Zero-lift drag coefficient (vector, dimensionless) + + Options + ------- + num_nodes : int + Number of analysis points per phase, by default 1 + include_wing : bool + Include an estimate of the drag of the wing in the output drag estimate, by default True. + configuration : str + Aircraft configuration, either \"takeoff\" or \"clean\". Takeoff includes drag + from landing gear and extended flaps. Clean assumes gear and flaps are retracted. + FF_nacelle : float + Nacelle form factor. By default 1.25 * 1.2, which is taken from the Jenkinson wing nacelle + specified in the OpenVSP documentation (https://openvsp.org/wiki/doku.php?id=parasitedrag) + multiplied by a rough estimate of the interference factor of 1.2, appx taken from Raymer. + It was originally published in Civil Jet Aircraft by Jenkinson, Simpkin, and Rhodes (1999). + Include any desired interference factor in the value provided to this option. + Q_fuselage : float + Interference factor for fuselage to multiply the form factor estimate. By + default 1.0 from Raymer. + Q_tail : float + Interference factor for horizontal and vertical stabilizers to multiply the form factor + estimate. By default 1.05 from Raymer for conventional tail configuration. + Q_wing : float + Interference factor for wing to multiply the form factor estimate. By + default 1.0. + flap_chord_frac : float + Flap chord divided by wing chord, by default 0.2 + Q_flap : float + Interference drag of flap. By default 1.25, from Roskam Equation 4.75 for Fowler flaps. + wing_area_flapped_frac : float + Flapped wing area divided by total wing area. Flapped wing area integrates the chord + over any portions of the span that contain flaps (not just the area of the flap itself). + By default 0.9. + drag_fudge_factor : float + Multiplier on the resulting zero-lift drag coefficient estimate, by default 1.0 + fuselage_laminar_frac : float + Fraction of the total fuselage length that has a laminary boundary layer, by default 0.0 + hstab_laminar_frac : float + Fraction of the total horizontal stabilizer that has a laminary boundary layer, by default 0.15 + vstab_laminar_frac : float + Fraction of the total vertical stabilizer that has a laminary boundary layer, by default 0.15 + wing_laminar_frac : float + Fraction of the total wing that has a laminary boundary layer, by default 0.15 + nacelle_laminar_frac : float + Fraction of the total engine nacelle length that has a laminary boundary layer, by default 0.0 + """ + + def initialize(self): + self.options.declare("num_nodes", default=1, desc="Number of analysis points per phase") + self.options.declare("include_wing", default=True, types=bool, desc="Include the wing drag") + self.options.declare("configuration", default="clean", values=["takeoff", "clean"]) + self.options.declare("drag_fudge_factor", default=1.0, desc="Multiplier on total drag coefficient") + self.options.declare("FF_nacelle", default=1.25 * 1.2, desc="Nacelle form factor times interference factor") + self.options.declare("Q_fuselage", default=1.0, desc="Fuselage interference factor") + self.options.declare("Q_tail", default=1.05, desc="Tail interference factor") + self.options.declare("Q_wing", default=1.0, desc="Wing interference factor") + self.options.declare("flap_chord_frac", default=0.2, desc="Flap chord / wing chord") + self.options.declare("Q_flap", default=1.25, desc="Flap interference factor") + self.options.declare("wing_area_flapped_frac", default=0.9, desc="Flapped wing area / wing area") + self.options.declare("fuselage_laminar_frac", default=0.0, desc="Fraction of fuselage with laminar flow") + self.options.declare("hstab_laminar_frac", default=0.15, desc="Fraction of horizontal tail with laminar flow") + self.options.declare("vstab_laminar_frac", default=0.15, desc="Fraction of vertical tail with laminar flow") + self.options.declare("wing_laminar_frac", default=0.15, desc="Fraction of wing with laminar flow") + self.options.declare("nacelle_laminar_frac", default=0.0, desc="Fraction of engine nacelle with laminar flow") + + def setup(self): + is_clean = self.options["configuration"] == "clean" + include_wing = self.options["include_wing"] + nn = self.options["num_nodes"] + + iv = self.add_subsystem("iv", om.IndepVarComp()) + + # ============================================================================== + # Compute form factors + # ============================================================================== + # -------------- Fuselage -------------- + self.add_subsystem( + "fuselage_form", + FuselageFormFactor_JetTransport(Q_fuselage=self.options["Q_fuselage"]), + promotes_inputs=["ac|geom|fuselage|length", "ac|geom|fuselage|height"], + ) + + # -------------- Horizontal stabilizer -------------- + self.add_subsystem( + "hstab_form", + WingFormFactor_JetTransport(Q=self.options["Q_tail"]), + promotes_inputs=[("toverc", "ac|geom|hstab|toverc")], + ) + + # -------------- Vertical stabilizer -------------- + self.add_subsystem( + "vstab_form", + WingFormFactor_JetTransport(Q=self.options["Q_tail"]), + promotes_inputs=[("toverc", "ac|geom|vstab|toverc")], + ) + + # -------------- Wing -------------- + if include_wing: + self.add_subsystem( + "wing_form", + WingFormFactor_JetTransport(Q=self.options["Q_wing"]), + promotes_inputs=[("toverc", "ac|geom|wing|toverc")], + ) + + # -------------- Nacelle -------------- + iv.add_output("FF_nacelle", val=self.options["FF_nacelle"]) + + # ============================================================================== + # Skin friction coefficients for each component + # ============================================================================== + # -------------- Fuselage -------------- + self.add_subsystem( + "fuselage_friction", + SkinFrictionCoefficient_JetTransport(num_nodes=nn, laminar_frac=self.options["fuselage_laminar_frac"]), + promotes_inputs=["fltcond|Utrue", "fltcond|rho", "fltcond|T", ("L", "ac|geom|fuselage|length")], + ) + + # -------------- Horizontal stabilizer, vertical stabilizer, and wing (if included) -------------- + wing_surfs = ["hstab", "vstab"] + if include_wing: + wing_surfs.append("wing") + for surf in wing_surfs: + self.add_subsystem( + f"{surf}_MAC_calc", + WingMACTrapezoidal(), + promotes_inputs=[ + ("S_ref", f"ac|geom|{surf}|S_ref"), + ("AR", f"ac|geom|{surf}|AR"), + ("taper", f"ac|geom|{surf}|taper"), + ], + ) + self.add_subsystem( + f"{surf}_friction", + SkinFrictionCoefficient_JetTransport(num_nodes=nn, laminar_frac=self.options[f"{surf}_laminar_frac"]), + promotes_inputs=["fltcond|Utrue", "fltcond|rho", "fltcond|T"], + ) + self.connect(f"{surf}_MAC_calc.MAC", f"{surf}_friction.L") + + # -------------- Nacelle -------------- + self.add_subsystem( + "nacelle_friction", + SkinFrictionCoefficient_JetTransport(num_nodes=nn, laminar_frac=self.options["nacelle_laminar_frac"]), + promotes_inputs=["fltcond|Utrue", "fltcond|rho", "fltcond|T", ("L", "ac|geom|nacelle|length")], + ) + + # ============================================================================== + # Compute the parasitic drag coefficient + # ============================================================================== + mult = self.add_subsystem( + "drag_coeffs", + ElementMultiplyDivideComp(), + promotes_inputs=[ + "ac|geom|fuselage|S_wet", + "ac|geom|hstab|S_ref", + "ac|geom|vstab|S_ref", + "ac|geom|nacelle|S_wet", + "ac|propulsion|num_engines", + ("S_wing_1", "ac|geom|wing|S_ref"), # This is a bit of a hack needed to + ("S_wing_2", "ac|geom|wing|S_ref"), # allow multiple equations to share + ("S_wing_3", "ac|geom|wing|S_ref"), # the same input + ("S_wing_4", "ac|geom|wing|S_ref"), + ], + ) + mult.add_equation( + output_name="CD_fuselage", + input_names=["ac|geom|fuselage|S_wet", "FF_fuselage", "Cf_fuselage", "S_wing_1"], + vec_size=[1, 1, nn, 1], + input_units=["m**2", None, None, "m**2"], + divide=[False, False, False, True], + ) + mult.add_equation( + output_name="CD_hstab", + input_names=["ac|geom|hstab|S_ref", "FF_hstab", "Cf_hstab", "S_wing_2"], + vec_size=[1, 1, nn, 1], + input_units=["m**2", None, None, "m**2"], + divide=[False, False, False, True], + scaling_factor=2, + ) # scaling factor of two is since wetted area is ~2x reference area + mult.add_equation( + output_name="CD_vstab", + input_names=["ac|geom|vstab|S_ref", "FF_vstab", "Cf_vstab", "S_wing_3"], + vec_size=[1, 1, nn, 1], + input_units=["m**2", None, None, "m**2"], + divide=[False, False, False, True], + scaling_factor=2, + ) # scaling factor of two is since wetted area is ~2x reference area + mult.add_equation( + output_name="CD_nacelle", + input_names=["ac|geom|nacelle|S_wet", "FF_nacelle", "Cf_nacelle", "ac|propulsion|num_engines", "S_wing_4"], + vec_size=[1, 1, nn, 1, 1], + input_units=["m**2", None, None, None, "m**2"], + divide=[False, False, False, False, True], + ) + if include_wing: + mult.add_equation( + output_name="CD_wing", + input_names=["FF_wing", "Cf_wing"], + vec_size=[1, nn], + input_units=[None, None], + divide=[False, False], + scaling_factor=2, + ) # scaling factor of two is since wetted area is ~2x reference area + + # -------------- Internal connections -------------- + self.connect("fuselage_form.FF_fuselage", "drag_coeffs.FF_fuselage") + self.connect("hstab_form.FF_wing", "drag_coeffs.FF_hstab") + self.connect("vstab_form.FF_wing", "drag_coeffs.FF_vstab") + self.connect("iv.FF_nacelle", "drag_coeffs.FF_nacelle") + + for surf in ["fuselage", "hstab", "vstab", "nacelle"]: + self.connect(f"{surf}_friction.Cf", f"drag_coeffs.Cf_{surf}") + + if include_wing: + self.connect("wing_form.FF_wing", "drag_coeffs.FF_wing") + self.connect("wing_friction.Cf", "drag_coeffs.Cf_wing") + + # ============================================================================== + # Any addition drag sources in the takeoff configuration + # ============================================================================== + # -------------- Flaps -------------- + if is_clean: + CD_flap_source = "iv.CD_flap" + iv.add_output("CD_flap", val=0.0) + else: + CD_flap_source = "flaps.CD_flap" + self.add_subsystem( + "flaps", + FlapDrag_JetTransport( + flap_chord_frac=self.options["flap_chord_frac"], + Q_flap=self.options["Q_flap"], + wing_area_flapped_frac=self.options["wing_area_flapped_frac"], + ), + promotes_inputs=[("flap_extension", "ac|aero|takeoff_flap_deg"), "ac|geom|wing|c4sweep"], + ) + + # -------------- Landing gear -------------- + # Raymer suggests adding 0.02 to the zero-lift drag coefficient when retractable + # landing gear are in the down position. See Section 5.3, page 99 in the 1992 edition. + iv.add_output("CD_landing_gear", val=0.0 if is_clean else 0.02) + + # ============================================================================== + # Sum the total drag coefficients + # ============================================================================== + drag_coeff_inputs = ["CD_fuselage", "CD_hstab", "CD_vstab", "CD_nacelle"] + if include_wing: + drag_coeff_inputs.append("CD_wing") + drag_coeff_inputs += ["CD_flap", "CD_landing_gear"] + self.add_subsystem( + "sum_CD0", + AddSubtractComp( + output_name="CD0", + input_names=drag_coeff_inputs, + vec_size=[nn] * (len(drag_coeff_inputs) - 2) + [1, 1], + scaling_factors=[self.options["drag_fudge_factor"]] * len(drag_coeff_inputs), + ), + promotes_outputs=["CD0"], + ) + + self.connect("drag_coeffs.CD_fuselage", "sum_CD0.CD_fuselage") + self.connect("drag_coeffs.CD_hstab", "sum_CD0.CD_hstab") + self.connect("drag_coeffs.CD_vstab", "sum_CD0.CD_vstab") + self.connect("drag_coeffs.CD_nacelle", "sum_CD0.CD_nacelle") + if include_wing: + self.connect("drag_coeffs.CD_wing", "sum_CD0.CD_wing") + self.connect(CD_flap_source, "sum_CD0.CD_flap") + self.connect("iv.CD_landing_gear", "sum_CD0.CD_landing_gear") + + +class FuselageFormFactor_JetTransport(om.ExplicitComponent): + """ + Form factor of fuselage based on slender body form factor equation from Torenbeek + 1982 (Synthesis of Subsonic Aircraft Design), taken from OpenVSP documentation: + https://openvsp.org/wiki/doku.php?id=parasitedrag (accessed March 23, 2023) + + Inputs + ------ + ac|geom|fuselage|length : float + Fuselage structural length (scalar, m) + ac|geom|fuselage|height : float + Fuselage height (scalar, m) + + Outputs + ------- + FF_fuselage : float + Fuselage form factor (scalar, dimensionless) + + Options + ------- + Q_fuselage : float + Interference factor for fuselage to multiply the form factor estimate. By + default 1.0 from Raymer. + """ + + def initialize(self): + self.options.declare("Q_fuselage", default=1.0, desc="Fuselage interference factor") + + def setup(self): + self.add_input("ac|geom|fuselage|length", units="m") + self.add_input("ac|geom|fuselage|height", units="m") + self.add_output("FF_fuselage") + self.declare_partials("FF_fuselage", ["ac|geom|fuselage|length", "ac|geom|fuselage|height"]) + + def compute(self, inputs, outputs): + fineness_ratio = inputs["ac|geom|fuselage|length"] / inputs["ac|geom|fuselage|height"] + outputs["FF_fuselage"] = self.options["Q_fuselage"] * (1 + 2.2 / fineness_ratio + 3.8 / fineness_ratio**3) + + def compute_partials(self, inputs, J): + L = inputs["ac|geom|fuselage|length"] + D = inputs["ac|geom|fuselage|height"] + Q = self.options["Q_fuselage"] + fineness_ratio = L / D + df_dL = 1 / D + df_dD = -L / D**2 + J["FF_fuselage", "ac|geom|fuselage|length"] = ( + Q * (-2.2 / fineness_ratio**2 - 3 * 3.8 / fineness_ratio**4) * df_dL + ) + J["FF_fuselage", "ac|geom|fuselage|height"] = ( + Q * (-2.2 / fineness_ratio**2 - 3 * 3.8 / fineness_ratio**4) * df_dD + ) + + +class WingFormFactor_JetTransport(om.ExplicitComponent): + """ + Form factor of wing times the optional interference factor based on wing form factor equation from + Torenbeek 1982 (Synthesis of Subsonic Aircraft Design), but can be applied to other lifting surfaces. + Taken from OpenVSP documentation: https://openvsp.org/wiki/doku.php?id=parasitedrag (accessed March 23, 2023) + + Inputs + ------ + toverc : float + Thickness-to-chord ratio of the wing surface + + Outputs + ------- + FF_wing : float + Wing form factor (scalar, dimensionless) + + Options + ------- + Q : float + Interference factor for fuselage to multiply the form factor estimate, by default 1.0 + """ + + def initialize(self): + self.options.declare("Q", default=1.0, desc="Wing interference factor") + + def setup(self): + self.add_input("toverc") + self.add_output("FF_wing") + self.declare_partials("FF_wing", "toverc") + + def compute(self, inputs, outputs): + tc = inputs["toverc"] + outputs["FF_wing"] = self.options["Q"] * (1 + 2.7 * tc + 100 * tc**4) + + def compute_partials(self, inputs, J): + tc = inputs["toverc"] + J["FF_wing", "toverc"] = self.options["Q"] * (2.7 + 400 * tc**3) + + +class FlapDrag_JetTransport(om.ExplicitComponent): + """ + Estimates the additional drag from extending the flaps using Roskam 1989 Part VI + Chapter 4 Equation 4.71 and 4.75. This assumes Fowler flaps. + + Inputs + ------ + flap_extension : float + Flap extension amount (scalar, deg) + ac|geom|wing|c4sweep : float + Wing sweep at 25% mean aerodynamic chord (scalar, radians) + + Outputs + ------- + CD_flap : float + Increment to drag coefficient from flap profile drag (scalar, dimensionless) + + Options + ------- + flap_chord_frac : float + Flap chord divided by wing chord, by default 0.2 + Q_flap : float + Interference drag of flap. By default 1.25, from Roskam Equation 4.75 for Fowler flaps. + wing_area_flapped_frac : float + Flapped wing area divided by total wing area. Flapped wing area integrates the chord + over any portions of the span that contain flaps (not just the area of the flap itself). + By default 0.9. + """ + + def initialize(self): + self.options.declare("flap_chord_frac", default=0.2, desc="Flap chord / wing chord") + self.options.declare("Q_flap", default=1.25, desc="Flap interference factor") + self.options.declare("wing_area_flapped_frac", default=0.9, desc="Flapped wing area / wing area") + + def setup(self): + self.add_input("flap_extension", units="deg") + self.add_input("ac|geom|wing|c4sweep", units="rad") + self.add_output("CD_flap") + self.declare_partials("CD_flap", "*") + + # -------------- 2D profile drag increment due to flaps -------------- + # This approximation is a very rough curve of Figure 4.48 in Roskam 1989 Part VI + # Chapter 4, which shows the 2D drag increment of Fowler flaps as a function of + # flap extension angle and cf/c, which I presume is flap chord over wing chord + cf_c = self.options["flap_chord_frac"] + self.quadratic_coeff = 5.75e-4 * cf_c**2 - 7.45e-5 * cf_c + 1.23e-5 + + def compute(self, inputs, outputs): + delta = inputs["flap_extension"] + sweep = inputs["ac|geom|wing|c4sweep"] + + # 2D profile drag increment + airfoil_drag_incr = self.quadratic_coeff * delta**2 + + outputs["CD_flap"] = ( + airfoil_drag_incr * np.cos(sweep) * self.options["wing_area_flapped_frac"] * self.options["Q_flap"] + ) + + def compute_partials(self, inputs, J): + delta = inputs["flap_extension"] + sweep = inputs["ac|geom|wing|c4sweep"] + + # 2D profile drag increment + airfoil_drag_incr = self.quadratic_coeff * delta**2 + + J["CD_flap", "flap_extension"] = ( + 2 + * self.quadratic_coeff + * delta + * np.cos(sweep) + * self.options["wing_area_flapped_frac"] + * self.options["Q_flap"] + ) + J["CD_flap", "ac|geom|wing|c4sweep"] = ( + -airfoil_drag_incr * np.sin(sweep) * self.options["wing_area_flapped_frac"] * self.options["Q_flap"] + ) + + +class SkinFrictionCoefficient_JetTransport(om.ExplicitComponent): + """ + Compute the average coefficient of friction using the methodology described here: + https://openvsp.org/wiki/doku.php?id=parasitedrag. It uses the Blasius and explicit + fit of Spalding correlations. + + Inputs + ------ + L : float + Characteristic length to use in the Reynolds number computation (scalar, m) + fltcond|Utrue : float + True airspeed (vector, m/s) + fltcond|rho : float + Air density (vector, kg/m^3) + fltcond|T : float + Air temperature (vector, K) + + Outputs + ------- + Cf : float + Skin friction coefficient (vector, dimensionless) + + Options + ------- + num_nodes : int + Number of analysis points per phase, by default 1 + laminar_frac : float + Fraction of total length that has a laminar boundary layer, by default 0.0 + """ + + def initialize(self): + self.options.declare("num_nodes", default=1, desc="Number of analysis points per phase") + self.options.declare("laminar_frac", default=0.0, desc="Fraction of length that is laminar") + + def setup(self): + nn = self.options["num_nodes"] + + self.add_input("L", units="m") + self.add_input("fltcond|Utrue", units="m/s", shape=(nn,)) + self.add_input("fltcond|rho", units="kg/m**3", shape=(nn,)) + self.add_input("fltcond|T", units="K", shape=(nn,)) + + self.add_output("Cf", shape=(nn,)) + + arng = np.arange(nn) + self.declare_partials("Cf", "fltcond|*", rows=arng, cols=arng) + self.declare_partials("Cf", "L", rows=arng, cols=np.zeros(nn)) + + def compute(self, inputs, outputs): + # Compute the kinematic viscosity + beta = 1.458e-6 + sutherlands_constant = 100.4 + visc_dyn = beta * inputs["fltcond|T"] ** (3 / 2) / (inputs["fltcond|T"] + sutherlands_constant) + visc_kin = visc_dyn / inputs["fltcond|rho"] + + # Reynolds number + Re = inputs["fltcond|Utrue"] * inputs["L"] / visc_kin + + # Skin friction coefficient assuming fully turbulent + Cf = 0.523 / np.log(0.06 * Re) ** 2 # explicit fit of Spalding + + # Case where some is laminar + lam_frac = self.options["laminar_frac"] + if lam_frac > 0.0: + Re_lam = Re * lam_frac + Cf_lam = 1.32824 / np.sqrt(Re_lam) # Blasius + Cf_turb = 0.523 / np.log(0.06 * Re_lam) ** 2 # explicit fit of Spalding + Cf = Cf + (Cf_lam - Cf_turb) * lam_frac + + outputs["Cf"] = Cf + + def compute_partials(self, inputs, J): + # Compute the kinematic viscosity + beta = 1.458e-6 + S = 100.4 + T = inputs["fltcond|T"] + visc_dyn = beta * T ** (3 / 2) / (T + S) + visc_kin = visc_dyn / inputs["fltcond|rho"] + + dvdyn_dT = beta * np.sqrt(T) * (3 * S + T) / (2 * (S + T) ** 2) + dvkin_dT = dvdyn_dT / inputs["fltcond|rho"] + dvkin_drho = -visc_dyn / inputs["fltcond|rho"] ** 2 + + # Reynolds number + U = inputs["fltcond|Utrue"] + L = inputs["L"] + Re = U * L / visc_kin + dRe_dT = -U * L / visc_kin**2 * dvkin_dT + dRe_drho = -U * L / visc_kin**2 * dvkin_drho + dRe_dU = L / visc_kin + dRe_dL = U / visc_kin + + # Skin friction coefficient assuming fully turbulent + Cf = 0.523 / np.log(0.06 * Re) ** 2 # explicit fit of Spalding + dCf_dRe = -1.046 / (Re * (np.log(Re) - 2.81341) ** 3) + + J["Cf", "fltcond|T"] = dCf_dRe * dRe_dT + J["Cf", "fltcond|rho"] = dCf_dRe * dRe_drho + J["Cf", "fltcond|Utrue"] = dCf_dRe * dRe_dU + J["Cf", "L"] = dCf_dRe * dRe_dL + + # Case where some is laminar + lam_frac = self.options["laminar_frac"] + if lam_frac > 0.0: + Re_lam = Re * lam_frac + Cf_lam = 1.32824 / np.sqrt(Re_lam) # Blasius + Cf_turb = 0.523 / np.log(0.06 * Re_lam) ** 2 # explicit fit of Spalding + dCflam_dRelam = -0.5 * 1.32824 / Re_lam**1.5 + dCfturb_dRelam = -1.046 / (Re_lam * (np.log(Re_lam) - 2.81341) ** 3) + Cf = Cf + (Cf_lam - Cf_turb) * lam_frac + + J["Cf", "fltcond|T"] += lam_frac * (dCflam_dRelam - dCfturb_dRelam) * lam_frac * dRe_dT + J["Cf", "fltcond|rho"] += lam_frac * (dCflam_dRelam - dCfturb_dRelam) * lam_frac * dRe_drho + J["Cf", "fltcond|Utrue"] += lam_frac * (dCflam_dRelam - dCfturb_dRelam) * lam_frac * dRe_dU + J["Cf", "L"] += lam_frac * (dCflam_dRelam - dCfturb_dRelam) * lam_frac * dRe_dL diff --git a/openconcept/aerodynamics/openaerostruct/CL_max_critical_section.py b/openconcept/aerodynamics/openaerostruct/CL_max_critical_section.py new file mode 100644 index 00000000..383ad9fb --- /dev/null +++ b/openconcept/aerodynamics/openaerostruct/CL_max_critical_section.py @@ -0,0 +1,146 @@ +""" +@File : CLmax_jet_transport.py +@Date : 2023/04/11 +@Author : Eytan Adler +@Description : Max lift coefficient estimate using critical section method +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== +import warnings + +# ============================================================================== +# External Python modules +# ============================================================================== +import openmdao.api as om +from openconcept.utilities import AddSubtractComp + +# ============================================================================== +# Extension modules +# ============================================================================== +from openconcept.aerodynamics.openaerostruct import VLM + + +class CLmaxCriticalSectionVLM(om.Group): + """ + Predict the maximum lift coefficient of the wing by solving for the + angle of attack where the maximum sectional lift coefficient equals the + provided maximum lift coefficient of the airfoil. The solution for the + sectional lift coefficients is done using OpenAeroStruct. + + Inputs + ------ + ac|aero|airfoil_Cl_max : float + Maximum 2D lift coefficient of the wing airfoil (scalar or, if vec_Cl_max is set + to true, vector of length num_y, dimensionless) + fltcond|M : float + Mach number for maximum CL calculation (scalar, dimensionless) + fltcond|h : float + Altitude for maximum CL calculation (scalar, m) + fltcond|TempIncrement : float + Temperature increment for maximum CL calculation (scalar, degC) + ac|geom|wing|OAS_mesh: float + OpenAeroStruct 3D mesh (num_x + 1 x num_y + 1 x 3 vector, m) + ac|geom|wing|toverc : float + Thickness to chord ratio of each streamwise strip of panels ordered from wing + tip to wing root; used for the viscous and wave drag calculations + (vector of length num_y, dimensionless) + + Outputs + ------- + CL_max : float + Maximum lift coefficient estimated using the critical section method (scalar, dimensionless) + + Options + ------- + num_x : int + Number of panels in x (streamwise) direction (scalar, dimensionless) + num_y : int + Number of panels in y (spanwise) direction for one wing because + uses symmetry (scalar, dimensionless) + vec_Cl_max : bool + Make the input ac|aero|airfoil_Cl_max a vector of length num_y where each item in the vector is + a spanwise panel's local maximum lift coefficient, ordered from wing tip to root. This enables + specification of a varying maximum sectional lift coefficient along the span, which can be used + to model high lift devices on only a portion of the wing. If this option is False, + ac|aero|airfoil_Cl_max is a scalar, which represents the maximum airfoil lift coefficient across + the entire span of the wing, by default False + surf_options : dict + Dictionary of OpenAeroStruct surface options; any options provided here + will override the default ones; see the OpenAeroStruct documentation for more information. + Because the geometry transformations are excluded in this model (to simplify the interface), + the _cp options are not supported. The t_over_c option is also removed since + it is provided instead as an input. + rho : float or int + Constraint aggregation factor for sectional lift coefficient aggregation, by default 200 + """ + + def initialize(self): + self.options.declare("num_x", default=2, desc="Number of streamwise mesh panels") + self.options.declare("num_y", default=6, desc="Number of spanwise (half wing) mesh panels") + self.options.declare("vec_Cl_max", default=False, types=bool, desc="Make ac|aero|airfoil_Cl_max input a vector") + self.options.declare("surf_options", default=None, desc="Dictionary of OpenAeroStruct surface options") + self.options.declare("rho", default=200, types=(float, int), desc="Sectional CL aggregation factor") + + def setup(self): + Cl_max_shape = self.options["num_y"] if self.options["vec_Cl_max"] else 1 + + # -------------- Simulate the wing in OpenAeroStruct -------------- + aero = om.Group() + aero.add_subsystem( + "VLM", + VLM(num_x=self.options["num_x"], num_y=self.options["num_y"], surf_options=self.options["surf_options"]), + promotes_inputs=[ + "fltcond|M", + "fltcond|h", + "fltcond|TempIncrement", + "ac|geom|wing|OAS_mesh", + "ac|geom|wing|toverc", + ], + promotes_outputs=[("fltcond|CL", "CL_max")], + ) + aero.set_input_defaults("VLM.fltcond|alpha", 5, units="deg") + + # -------------- Compute and aggregate Cl - Clmax across the span (Cl - Clmax should be <= 0) -------------- + aero.add_subsystem( + "Cl_max_limit", + AddSubtractComp( + output_name="Cl_limit_vec", + input_names=["Cl", "Cl_max"], + vec_size=[self.options["num_y"], Cl_max_shape], + scaling_factors=[1, -1], + ), + promotes_inputs=[("Cl_max", "ac|aero|airfoil_Cl_max")], + ) + aero.connect("VLM.sectional_CL", "Cl_max_limit.Cl") + + aero.add_subsystem("max_limit", om.KSComp(width=self.options["num_y"], rho=self.options["rho"])) + aero.connect("Cl_max_limit.Cl_limit_vec", "max_limit.g") + + self.add_subsystem("aero", aero, promotes=["*"]) + + # -------------- Solve for the angle of attack that makes max(Cl - Clmax) = 0 -------------- + self.add_subsystem( + "sectional_CL_balance", + om.BalanceComp("alpha", lhs_name="max_Cl_limit_VLM", rhs_val=0.0, val=5, units="deg"), + ) + self.connect("max_limit.KS", "sectional_CL_balance.max_Cl_limit_VLM") + self.connect("sectional_CL_balance.alpha", "VLM.fltcond|alpha") + + # -------------- Solver setup -------------- + # Use the Schur solver if it's available, otherwise this will be very expensive + try: + self.nonlinear_solver = om.NonlinearSchurSolver( + groupNames=["aero", "sectional_CL_balance"], iprint=2, solve_subsystems=True + ) + self.linear_solver = om.LinearSchur() + except AttributeError: + warnings.warn( + "OpenMDAO NonlinearSchurSolver is not available, CLmaxCriticalSectionVLM will be very slow!", + stacklevel=2, + ) + + # Add the Newton solver + self.nonlinear_solver = om.NewtonSolver(solve_subsystems=True, iprint=2, maxiter=10) + self.linear_solver = om.DirectSolver() diff --git a/openconcept/aerodynamics/openaerostruct/__init__.py b/openconcept/aerodynamics/openaerostruct/__init__.py index 8b82ec78..a0dfcae9 100644 --- a/openconcept/aerodynamics/openaerostruct/__init__.py +++ b/openconcept/aerodynamics/openaerostruct/__init__.py @@ -1,3 +1,5 @@ from .mesh_gen import TrapezoidalPlanformMesh, SectionPlanformMesh, ThicknessChordRatioInterp, SectionLinearInterp from .drag_polar import VLMDragPolar, VLMDataGen, VLM from .aerostructural import AerostructDragPolar, OASDataGen, Aerostruct, AerostructDragPolarExact +from .CL_max_critical_section import CLmaxCriticalSectionVLM +from .wave_drag import WaveDragFromSections diff --git a/openconcept/aerodynamics/openaerostruct/drag_polar.py b/openconcept/aerodynamics/openaerostruct/drag_polar.py index f2edf8d0..fe5fa4e4 100644 --- a/openconcept/aerodynamics/openaerostruct/drag_polar.py +++ b/openconcept/aerodynamics/openaerostruct/drag_polar.py @@ -80,7 +80,8 @@ class VLMDragPolar(om.Group): ac|aero|CD_nonwing : float Drag coefficient of components other than the wing; e.g. fuselage, tail, interference drag, etc.; this value is simply added to the - drag coefficient computed by OpenAeroStruct (scalar, dimensionless) + drag coefficient computed by OpenAeroStruct (vector if vec_CD_nonwing + option is set to True and scalar otherwise, dimensionless) fltcond|TempIncrement : float Temperature increment for non-standard day (scalar, degC) NOTE: fltcond|TempIncrement is a scalar in this component but a vector in OC. \ @@ -148,6 +149,9 @@ class VLMDragPolar(om.Group): Number of spline control points for twist, only used if geometry is set to \"trapezoidal\" because \"mesh\" linearly interpolates twist between sections and \"mesh\" does not provide twist functionality (scalar, dimensionless) + vec_CD_nonwing : bool + Set to True if ac|aero|CD_nonwing is passed in as a vector of length num_nodes, otherwise + set to False and it will be set as a scalar value, by default False alpha_train : list or ndarray List of angle of attack values at which to train the model (ndarray, degrees) Mach_train : list or ndarray @@ -182,6 +186,9 @@ def initialize(self): "num_sections", default=2, types=int, desc="Number of sections along the half span to define" ) self.options.declare("num_twist", default=4, desc="Number of twist spline control points") + self.options.declare( + "vec_CD_nonwing", default=False, types=bool, desc="Take in CD_nonwing input as a vector of length num_nodes" + ) self.options.declare( "Mach_train", default=np.array([0.1, 0.3, 0.45, 0.6, 0.7, 0.75, 0.8, 0.85, 0.9]), @@ -302,7 +309,7 @@ def setup(self): # Inputs to promote from the calls to OpenAeroStruct (if geometry is "mesh", # promote the thickness-to-chord ratio directly) - VLM_promote_inputs = ["ac|aero|CD_nonwing", "fltcond|TempIncrement"] + VLM_promote_inputs = ["fltcond|TempIncrement"] if geo == "mesh": VLM_promote_inputs += ["ac|geom|wing|toverc", "ac|geom|wing|OAS_mesh"] else: @@ -348,20 +355,22 @@ def setup(self): self.connect("aero_surrogate.CL", "alpha_bal.CL_VLM") # Compute drag force from drag coefficient + CD_nw_size = nn if self.options["vec_CD_nonwing"] else 1 self.add_subsystem( "drag_calc", om.ExecComp( - "drag = q * S * CD", + "drag = q * S * (CD_wing + CD_nonwing)", drag={"units": "N", "shape": (nn,)}, q={"units": "Pa", "shape": (nn,)}, S={"units": "m**2"}, - CD={"shape": (nn,)}, + CD_wing={"shape": (nn,)}, + CD_nonwing={"shape": (CD_nw_size,), "val": np.zeros(CD_nw_size)}, has_diag_partials=True, ), - promotes_inputs=[("q", "fltcond|q"), ("S", "ac|geom|wing|S_ref")], + promotes_inputs=[("q", "fltcond|q"), ("S", "ac|geom|wing|S_ref"), ("CD_nonwing", "ac|aero|CD_nonwing")], promotes_outputs=["drag"], ) - self.connect("aero_surrogate.CD", "drag_calc.CD") + self.connect("aero_surrogate.CD", "drag_calc.CD_wing") # Set input defaults for inputs promoted from different places with different values self.set_input_defaults("ac|geom|wing|S_ref", 1.0, units="m**2") @@ -383,10 +392,6 @@ class VLMDataGen(om.ExplicitComponent): Thickness to chord ratio of each streamwise strip of panels ordered from wing tip to wing root; used for the viscous and wave drag calculations (vector of length num_y, dimensionless) - ac|aero|CD_nonwing : float - Drag coefficient of components other than the wing; e.g. fuselage, - tail, interference drag, etc.; this value is simply added to the - drag coefficient computed by OpenAeroStruct (scalar, dimensionless) fltcond|TempIncrement : float Temperature increment for non-standard day (scalar, degC) @@ -444,7 +449,6 @@ def setup(self): self.add_input("ac|geom|wing|OAS_mesh", units="m", shape=(nx + 1, ny + 1, 3)) self.add_input("ac|geom|wing|toverc", shape=(ny,), val=0.12) - self.add_input("ac|aero|CD_nonwing", val=0.0) self.add_input("fltcond|TempIncrement", val=0.0, units="degC") n_Mach = self.options["Mach_train"].size @@ -490,7 +494,6 @@ def setup(self): def compute(self, inputs, outputs): mesh = inputs["ac|geom|wing|OAS_mesh"] toverc = inputs["ac|geom|wing|toverc"] - CD_nonwing = inputs["ac|aero|CD_nonwing"] temp_incr = inputs["fltcond|TempIncrement"] # If the inputs are unchaged, use the previously calculated values @@ -501,7 +504,7 @@ def compute(self, inputs, outputs): and np.abs(temp_incr - VLMDataGen.temp_incr) < tol ): outputs["CL_train"] = VLMDataGen.CL - outputs["CD_train"] = VLMDataGen.CD + CD_nonwing + outputs["CD_train"] = VLMDataGen.CD return # Copy new values to cached ones @@ -525,14 +528,13 @@ def compute(self, inputs, outputs): VLMDataGen.CD[:] = data["CD"] VLMDataGen.partials = copy(data["partials"]) outputs["CL_train"] = VLMDataGen.CL - outputs["CD_train"] = VLMDataGen.CD + CD_nonwing + outputs["CD_train"] = VLMDataGen.CD def compute_partials(self, inputs, partials): # Compute partials if they haven't been already and return them self.compute(inputs, {}) for key, value in VLMDataGen.partials.items(): partials[key][:] = value - partials["CD_train", "ac|aero|CD_nonwing"] = np.ones(VLMDataGen.CD.shape) """ diff --git a/openconcept/aerodynamics/openaerostruct/tests/test_CL_max_critical_section.py b/openconcept/aerodynamics/openaerostruct/tests/test_CL_max_critical_section.py new file mode 100644 index 00000000..ee09c894 --- /dev/null +++ b/openconcept/aerodynamics/openaerostruct/tests/test_CL_max_critical_section.py @@ -0,0 +1,101 @@ +""" +@File : test_CL_max_critical_section.py +@Date : 2023/04/11 +@Author : Eytan Adler +@Description : Test the critical section lift coefficient utility. +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== + +# ============================================================================== +# External Python modules +# ============================================================================== +import numpy as np +import unittest +import openmdao.api as om +from openmdao.utils.assert_utils import assert_near_equal + +# ============================================================================== +# Extension modules +# ============================================================================== +from openconcept.aerodynamics.openaerostruct import CLmaxCriticalSectionVLM, TrapezoidalPlanformMesh + + +class CLmaxCriticalSectionVLMTestCase(unittest.TestCase): + def test_scalar_Clmax(self): + nx = 2 + ny = 5 + p = om.Problem() + + p.model.add_subsystem( + "mesh", + TrapezoidalPlanformMesh(num_x=nx, num_y=ny), + promotes_inputs=["*"], + promotes_outputs=[("mesh", "ac|geom|wing|OAS_mesh")], + ) + p.model.add_subsystem("CL_max_comp", CLmaxCriticalSectionVLM(num_x=nx, num_y=ny), promotes=["*"]) + + # Top level solver needed if NonlinearSchurSolver isn't available + p.model.nonlinear_solver = om.NewtonSolver(solve_subsystems=True, iprint=2, maxiter=10) + p.model.linear_solver = om.DirectSolver() + + p.setup() + + p.set_val("S", 125, units="m**2") + p.set_val("AR", 10) + p.set_val("taper", 0.15) + p.set_val("sweep", 25, units="deg") + + Cl_max_foil = 1.5 + p.set_val("ac|aero|airfoil_Cl_max", Cl_max_foil) + p.set_val("fltcond|M", 0.2) + p.set_val("fltcond|h", 0, units="ft") + p.set_val("ac|geom|wing|toverc", np.full(ny, 0.12)) + + p.run_model() + + assert_near_equal(np.max(p.get_val("VLM.sectional_CL")), Cl_max_foil, tolerance=1e-6) + assert_near_equal(p.get_val("max_limit.KS").item(), 0.0, tolerance=1e-13) + + def test_vector_Clmax(self): + nx = 2 + ny = 5 + p = om.Problem() + + p.model.add_subsystem( + "mesh", + TrapezoidalPlanformMesh(num_x=nx, num_y=ny), + promotes_inputs=["*"], + promotes_outputs=[("mesh", "ac|geom|wing|OAS_mesh")], + ) + p.model.add_subsystem( + "CL_max_comp", CLmaxCriticalSectionVLM(num_x=nx, num_y=ny, vec_Cl_max=True), promotes=["*"] + ) + + # Top level solver needed if NonlinearSchurSolver isn't available + p.model.nonlinear_solver = om.NewtonSolver(solve_subsystems=True, iprint=2, maxiter=10) + p.model.linear_solver = om.DirectSolver() + + p.setup() + + p.set_val("S", 125, units="m**2") + p.set_val("AR", 10) + p.set_val("taper", 0.9) + p.set_val("sweep", 25, units="deg") + + Cl_max_foil = [2.5, 2.5, 1.5, 1.5, 1.5] + p.set_val("ac|aero|airfoil_Cl_max", Cl_max_foil) + p.set_val("fltcond|M", 0.2) + p.set_val("fltcond|h", 0, units="ft") + p.set_val("ac|geom|wing|toverc", np.full(ny, 0.12)) + + p.run_model() + + assert_near_equal(np.max(p.get_val("VLM.sectional_CL") - Cl_max_foil), 0.0, tolerance=1e-4) + assert_near_equal(p.get_val("max_limit.KS").item(), 0.0, tolerance=1e-10) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/aerodynamics/openaerostruct/tests/test_drag_polar.py b/openconcept/aerodynamics/openaerostruct/tests/test_drag_polar.py index 57fcb2ac..c4724216 100644 --- a/openconcept/aerodynamics/openaerostruct/tests/test_drag_polar.py +++ b/openconcept/aerodynamics/openaerostruct/tests/test_drag_polar.py @@ -79,8 +79,10 @@ def test(self): mesh.get_val("fltcond|CL"), p.get_val("aero_surrogate.CL"), tolerance=1e-10 ) # check convergence assert_near_equal(2, p.get_val("alpha_bal.alpha", units="deg"), tolerance=1e-7) - assert_near_equal(mesh.get_val("fltcond|CD") + 0.01, p.get_val("aero_surrogate.CD"), tolerance=2e-2) - assert_near_equal(p.get_val("drag", units="N"), p.get_val("aero_surrogate.CD") * 100 * 5e3, tolerance=2e-2) + assert_near_equal(mesh.get_val("fltcond|CD"), p.get_val("aero_surrogate.CD"), tolerance=3e-2) + assert_near_equal( + p.get_val("drag", units="N"), (p.get_val("aero_surrogate.CD") + 0.01) * 100 * 5e3, tolerance=1e-7 + ) # Test off training point mesh.set_val("fltcond|M", 0.3) @@ -97,8 +99,10 @@ def test(self): mesh.get_val("fltcond|CL"), p.get_val("aero_surrogate.CL"), tolerance=1e-10 ) # check convergence assert_near_equal(6, p.get_val("alpha_bal.alpha", units="deg"), tolerance=1e-2) - assert_near_equal(mesh.get_val("fltcond|CD") + 0.01, p.get_val("aero_surrogate.CD"), tolerance=6e-2) - assert_near_equal(p.get_val("drag", units="N"), p.get_val("aero_surrogate.CD") * 100 * 5e3, tolerance=5e-2) + assert_near_equal(mesh.get_val("fltcond|CD"), p.get_val("aero_surrogate.CD"), tolerance=6e-2) + assert_near_equal( + p.get_val("drag", units="N"), (p.get_val("aero_surrogate.CD") + 0.01) * 100 * 5e3, tolerance=5e-2 + ) def test_surf_options(self): nn = 1 @@ -135,7 +139,7 @@ def test_surf_options(self): assert_near_equal(p.get_val("drag", units="N"), 34905.69308752 * np.ones(nn), tolerance=1e-10) def test_vectorized(self): - nn = 7 + nn = 3 twist = np.array([-1, 0, 1]) p = om.Problem( VLMDragPolar( @@ -143,6 +147,7 @@ def test_vectorized(self): num_x=2, num_y=4, num_twist=twist.size, + vec_CD_nonwing=True, Mach_train=np.linspace(0.1, 0.8, 2), alpha_train=np.linspace(-11, 15, 2), alt_train=np.linspace(0, 15e3, 2), @@ -157,7 +162,7 @@ def test_vectorized(self): p.set_val("ac|geom|wing|taper", 0.1) p.set_val("ac|geom|wing|c4sweep", 20, units="deg") p.set_val("ac|geom|wing|twist", twist, units="deg") - p.set_val("ac|aero|CD_nonwing", 0.01) + p.set_val("ac|aero|CD_nonwing", np.linspace(0, 0.01, nn)) p.set_val("fltcond|q", 5e3 * np.ones(nn), units="Pa") p.set_val("fltcond|M", 0.5 * np.ones(nn)) p.set_val("fltcond|h", 7.5e3 * np.ones(nn), units="m") @@ -165,7 +170,9 @@ def test_vectorized(self): p.run_model() # Ensure they're all the same - assert_near_equal(p.get_val("drag", units="N"), 37615.14285108 * np.ones(nn), tolerance=1e-10) + assert_near_equal( + p.get_val("drag", units="N"), [32615.14285108, 35115.14285108, 37615.14285108], tolerance=1e-10 + ) def test_section_geometry(self): nn = 1 @@ -209,7 +216,7 @@ def test_section_geometry(self): # Ensure they're all the same assert_near_equal(vlm.get_val("fltcond|CL"), 0.15579806, tolerance=1e-3) - assert_near_equal(vlm.get_val("fltcond|CD") + 0.01, p.get_val("aero_surrogate.CD"), tolerance=1e-3) + assert_near_equal(vlm.get_val("fltcond|CD"), p.get_val("aero_surrogate.CD"), tolerance=2e-3) def test_mesh_geometry_option(self): nn = 1 @@ -262,7 +269,7 @@ def test_mesh_geometry_option(self): # Ensure they're all the same assert_near_equal(vlm.get_val("fltcond|CL"), 0.10634777, tolerance=1e-6) - assert_near_equal(vlm.get_val("fltcond|CD") + 0.001, p.get_val("aero_surrogate.CD"), tolerance=1e-4) + assert_near_equal(vlm.get_val("fltcond|CD"), p.get_val("aero_surrogate.CD"), tolerance=1e-4) @unittest.skipIf(not OAS_installed, "OpenAeroStruct is not installed") @@ -302,7 +309,6 @@ def test_defaults(self): p.set_val("AR", 10) p.set_val("taper", 0.1) p.set_val("sweep", 20, units="deg") - p.set_val("ac|aero|CD_nonwing", 0.01) p.run_model() CL = np.array( @@ -312,7 +318,7 @@ def test_defaults(self): ] ) CD = np.array( - [[[0.03547695, 0.03770253], [0.05900183, 0.0612274]], [[0.03537478, 0.03719636], [0.18710518, 0.18892676]]] + [[[0.02547695, 0.02770253], [0.04900183, 0.0512274]], [[0.02537478, 0.02719636], [0.17710518, 0.17892676]]] ) assert_near_equal(CL, p.get_val("CL_train"), tolerance=1e-7) diff --git a/openconcept/aerodynamics/openaerostruct/tests/test_wave_drag.py b/openconcept/aerodynamics/openaerostruct/tests/test_wave_drag.py new file mode 100644 index 00000000..90e7029c --- /dev/null +++ b/openconcept/aerodynamics/openaerostruct/tests/test_wave_drag.py @@ -0,0 +1,139 @@ +""" +@File : test_wave_drag.py +@Date : 2023/04/17 +@Author : Eytan Adler +@Description : Test the wave drag utilities +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== +import unittest + +# ============================================================================== +# External Python modules +# ============================================================================== +import openmdao.api as om +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials + +# ============================================================================== +# Extension modules +# ============================================================================== +from openconcept.aerodynamics.openaerostruct import WaveDragFromSections + + +class WaveDragFromSectionsTestCase(unittest.TestCase): + def test_verify_OAS(self): + """ + Verify against OpenAeroStruct + """ + p = om.Problem() + p.model.add_subsystem("comp", WaveDragFromSections(num_sections=4), promotes=["*"]) + p.setup() + + p.set_val("fltcond|M", 0.95) + p.set_val("fltcond|CL", 0.2997707162790314) + + p.set_val("toverc_sec", [0.08, 0.11, 0.15, 0.15]) + p.set_val("y_sec", [-105, -41, -23], units="ft") + p.set_val("chord_sec", [8, 25, 75, 104], units="ft") + p.set_val("c4sweep", 37.69966129860842, units="deg") + + p.run_model() + + assert_near_equal(p.get_val("CD_wave"), 0.011138076370115135, tolerance=1e-3) + + partials = p.check_partials(method="fd", out_stream=None) + assert_check_partials(partials, atol=1e-4, rtol=1e-4) + + def test_indices(self): + """ + Test the indices by adding a section to the beginning and end of the previous test but ignore them using the indices. + """ + p = om.Problem() + p.model.add_subsystem( + "comp", WaveDragFromSections(num_sections=6, idx_sec_start=1, idx_sec_end=4), promotes=["*"] + ) + p.setup() + + p.set_val("fltcond|M", 0.95) + p.set_val("fltcond|CL", 0.2997707162790314) + + p.set_val("toverc_sec", [0.01, 0.08, 0.11, 0.15, 0.15, 0.2]) + p.set_val("y_sec", [-110, -105, -41, -23, 0], units="ft") + p.set_val("chord_sec", [8, 8, 25, 75, 104, 8], units="ft") + p.set_val("c4sweep", 37.69966129860842, units="deg") + + p.run_model() + + assert_near_equal(p.get_val("CD_wave"), 0.011138076370115135, tolerance=1e-3) + + partials = p.check_partials(method="fd", out_stream=None) + assert_check_partials(partials, atol=1e-4, rtol=1e-4) + + def test_area_norm(self): + """ + Test the area normalization with a different area. + """ + p = om.Problem() + p.model.add_subsystem("comp", WaveDragFromSections(num_sections=4, specify_area_norm=True), promotes=["*"]) + p.setup() + + p.set_val("fltcond|M", 0.95) + p.set_val("fltcond|CL", 0.2997707162790314) + + S_orig = 4014.5 * 2 + S_new = 1e4 + p.set_val("S_ref", S_new, units="ft**2") + p.set_val("toverc_sec", [0.08, 0.11, 0.15, 0.15]) + p.set_val("y_sec", [-105, -41, -23], units="ft") + p.set_val("chord_sec", [8, 25, 75, 104], units="ft") + p.set_val("c4sweep", 37.69966129860842, units="deg") + + p.run_model() + + assert_near_equal(p.get_val("CD_wave"), 0.011138076370115135 * S_new / S_orig, tolerance=1e-3) + + partials = p.check_partials(method="fd", out_stream=None) + assert_check_partials(partials, atol=1e-4, rtol=1e-4) + + +if __name__ == "__main__": + unittest.main() + + """ + Below is the code used to compute OpenAeroStruct's wave drag coefficient + against which we compare the values out of OpenConcept. + """ + # from openconcept.aerodynamics.openaerostruct import SectionPlanformMesh, VLM + # from openconcept.aerodynamics.openaerostruct import ThicknessChordRatioInterp + # from openconcept.geometry import WingSweepFromSections + # p = om.Problem() + + # nx = 1 + # ny_sec = 61 # many spanwise panels make OpenAeroStruct's t/c averaging more accurate + # p.model.add_subsystem("mesh", SectionPlanformMesh(num_sections=4, num_x=nx, num_y=ny_sec, scale_area=False), promotes=["*"]) + # p.model.add_subsystem("tc", ThicknessChordRatioInterp(num_y=ny_sec, num_sections=4, cos_spacing=True), promotes=["*"]) + # p.model.add_subsystem("sweep", WingSweepFromSections(num_sections=4), promotes=["*"]) + # p.model.add_subsystem("oas", VLM(num_x=nx, num_y=ny_sec * 3), promotes=["*"]) + # p.model.add_subsystem( + # "comp", WaveDragFromSections(num_sections=4), promotes=["*"] + # ) + # p.model.connect("mesh", "ac|geom|wing|OAS_mesh") + # p.model.connect("panel_toverc", "ac|geom|wing|toverc") + + # p.model.set_input_defaults("fltcond|M", 0.95) + # p.model.set_input_defaults("fltcond|alpha", 5) + # for var in ["toverc_sec", "section_toverc"]: + # p.model.set_input_defaults(var, [0.08, 0.11, 0.15, 0.15]) + # p.model.set_input_defaults("x_LE_sec", [95, 52, 29, 0], units="ft") + # p.model.set_input_defaults("y_sec", [-105, -41, -23], units="ft") + # p.model.set_input_defaults("chord_sec", [8, 25, 75, 104], units="ft") + + # p.setup() + # p.run_model() + + # print(f"OpenAeroStruct CL {p.get_val('fltcond|CL').item()}") + # print(f"OpenConcept sweep {p.get_val('c4sweep', units='deg').item()} deg") + # print(f"OpenAeroStruct CDw {p.get_val('fltcond|CDw').item()}") + # print(f"OpenConcept CD_wave {p.get_val('CD_wave').item()}") diff --git a/openconcept/aerodynamics/openaerostruct/wave_drag.py b/openconcept/aerodynamics/openaerostruct/wave_drag.py new file mode 100644 index 00000000..d9af58a5 --- /dev/null +++ b/openconcept/aerodynamics/openaerostruct/wave_drag.py @@ -0,0 +1,145 @@ +""" +@File : wave_drag.py +@Date : 2023/04/17 +@Author : Eytan Adler +@Description : Compute wave drag +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== + +# ============================================================================== +# External Python modules +# ============================================================================== +import numpy as np +import openmdao.api as om + +# ============================================================================== +# Extension modules +# ============================================================================== + + +class WaveDragFromSections(om.ExplicitComponent): + """ + Compute the wave drag given a section-based geometry definition for an + OpenAeroStruct mesh. This uses the same wave drag approximation as OpenAeroStruct, + based on the Korn equation. Unlike OpenAeroStruct, it allows easy computation + of wave drag for only a portion of the wing, which is useful for BWBs. + + Inputs + ------ + fltcond|M : float + Mach number (vector of lenght num_nodes, dimensionless) + fltcond|CL : float + Lift coefficient (vector of lenght num_nodes, dimensionless) + y_sec : float + Spanwise location of each section, starting with the outboard section (wing + tip) at the MOST NEGATIVE y value and moving inboard (increasing y value) + toward the root; the user does not provide a value for the root because it + is always 0.0 (vector of length num_sections - 1, m) + chord_sec : float + Chord of each section, starting with the outboard section (wing tip) and + moving inboard toward the root (vector of length num_sections, m) + toverc_sec : float + Thickness-to-chord ratio of each section, starting with the outboard section + (wing tip) and moving inboard toward the root (vector of length num_sections, m) + c4sweep : float + Average quarter-chord sweep of the wing's region of interest (from idx_sec_start + to idx_sec_end); can be computed using OpenConcept's WingSweepFromSections + component in the geometry directory (scalar, deg) + S_ref : float + If specify_area_norm set to True, this input provides the area by which to normalize + the drag coefficient (scalar, sq m) + + Outputs + ------- + CD_wave : float + Wave drag coefficient of the specified wing region normalized by the planform + area of that region, unless specify_norm is set to True, in which case + it is normalized by the planform area of the whole wing (scalar, dimensionless) + + Options + ------- + num_nodes : int + Number of analysis points per phase + num_sections : int + Number of spanwise sections to define planform shape (scalar, dimensionless) + idx_sec_start : int + Index in the inputs to begin the average sweep calculation (negative indices not + accepted), inclusive, by default 0 + idx_sec_end : int + Index in the inputs to end the average sweep calculation (negative indices not + accepted), inclusive, by default num_sections - 1 + specify_area_norm : bool + Add an input which determines the area by which to normalize the drag coefficient, + otherwise will normalize by the area of the specified region, by default False + airfoil_tech_level : float + Airfoil technology level, by default 0.95 + """ + + def initialize(self): + self.options.declare("num_nodes", default=1, types=int, desc="Number of analysis points") + self.options.declare( + "num_sections", default=2, types=int, desc="Number of sections along the half span to define" + ) + self.options.declare("idx_sec_start", default=0, desc="Index of wing section at which to start") + self.options.declare("idx_sec_end", default=None, desc="Index of wing section at which to end") + self.options.declare( + "specify_area_norm", default=False, types=bool, desc="Add area input by which to normalize" + ) + self.options.declare("airfoil_tech_level", default=0.95, desc="Airfoil technology level") + + def setup(self): + nn = self.options["num_nodes"] + self.n_sec = self.options["num_sections"] + self.i_start = self.options["idx_sec_start"] + self.i_end = self.options["idx_sec_end"] + if self.i_end is None: + self.i_end = self.n_sec + else: + self.i_end += 1 # make it exclusive + + self.add_input("fltcond|M", shape=(nn,)) + self.add_input("fltcond|CL", shape=(nn,)) + self.add_input("toverc_sec", shape=(self.n_sec,)) + self.add_input("y_sec", shape=(self.n_sec - 1,), units="m") + self.add_input("chord_sec", shape=(self.n_sec,), units="m") + self.add_input("c4sweep", units="deg") + if self.options["specify_area_norm"]: + self.add_input("S_ref", units="m**2") + + self.add_output("CD_wave", val=0.0, shape=(nn,)) + + # TODO: Add analytic derivatives + self.declare_partials("*", "*", method="cs") + + def compute(self, inputs, outputs): + # Extract out the ones we care about + chord_sec = inputs["chord_sec"][self.i_start : self.i_end] + toverc_sec = inputs["toverc_sec"][self.i_start : self.i_end] + y_sec = np.hstack((inputs["y_sec"], [0.0]))[self.i_start : self.i_end] + cos_sweep = np.cos(inputs["c4sweep"] * np.pi / 180) + M = inputs["fltcond|M"] + CL = inputs["fltcond|CL"] + + panel_areas = 0.5 * (chord_sec[:-1] + chord_sec[1:]) * (y_sec[1:] - y_sec[:-1]) + + # Numerically integrate to get area-averaged t/c (integrate t/c * c in y and divide result by region area) + # TODO: Derive this analytically + n = 500 + toverc_interp = np.linspace(toverc_sec[:-1], toverc_sec[1:], n) + chord_interp = np.linspace(chord_sec[:-1], chord_sec[1:], n) + y_interp = np.linspace(y_sec[:-1], y_sec[1:], n) + panel_toverc = np.trapz((toverc_interp * chord_interp).T, x=y_interp.T) / panel_areas + avg_toverc = np.sum(panel_toverc * panel_areas) / np.sum(panel_areas) + + MDD = self.options["airfoil_tech_level"] / cos_sweep - avg_toverc / cos_sweep**2 - CL / (10 * cos_sweep**3) + M_crit = MDD - (0.1 / 80.0) ** (1 / 3) + + outputs["CD_wave"] = 0.0 + outputs["CD_wave"][M > M_crit] = 20 * (M[M > M_crit] - M_crit[M > M_crit]) ** 4 + outputs["CD_wave"] *= 2 # account for symmetry + + if self.options["specify_area_norm"]: + outputs["CD_wave"] *= inputs["S_ref"] / (2 * np.sum(panel_areas)) diff --git a/openconcept/aerodynamics/tests/test_CL_max_estimation.py b/openconcept/aerodynamics/tests/test_CL_max_estimation.py new file mode 100644 index 00000000..ecd23df7 --- /dev/null +++ b/openconcept/aerodynamics/tests/test_CL_max_estimation.py @@ -0,0 +1,92 @@ +import unittest +import openmdao.api as om +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +from openconcept.aerodynamics import CleanCLmax, FlapCLmax + + +class CleanCLmaxTestCase(unittest.TestCase): + def test_B738(self): + """ + Test roughly B738 parameters. + """ + p = om.Problem() + p.model.add_subsystem("comp", CleanCLmax(), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("ac|aero|airfoil_Cl_max", 1.75) + p.set_val("ac|geom|wing|c4sweep", 25, units="deg") + + p.run_model() + + assert_near_equal(p.get_val("CL_max_clean"), 1.42743476, tolerance=1e-8) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + def test_fudge_factor(self): + p = om.Problem() + p.model.add_subsystem("comp", CleanCLmax(fudge_factor=2.0), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("ac|aero|airfoil_Cl_max", 1.75) + p.set_val("ac|geom|wing|c4sweep", 25, units="deg") + + p.run_model() + + assert_near_equal(p.get_val("CL_max_clean"), 2 * 1.42743476, tolerance=1e-8) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + +class FlapCLmaxTestCase(unittest.TestCase): + def test_B738(self): + """ + Test roughly B738 parameters. + """ + p = om.Problem() + p.model.add_subsystem("comp", FlapCLmax(), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("flap_extension", 40, units="deg") + p.set_val("ac|geom|wing|c4sweep", 25, units="deg") + p.set_val("CL_max_clean", 1.42743476) + p.set_val("ac|geom|wing|toverc", 0.12) + + p.run_model() + + assert_near_equal(p.get_val("CL_max_flap"), 2.65255284, tolerance=1e-8) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + def test_options(self): + p = om.Problem() + p.model.add_subsystem( + "comp", + FlapCLmax( + flap_chord_frac=0.25, + wing_area_flapped_frac=0.8, + slat_chord_frac=0.01, + slat_span_frac=0.7, + fudge_factor=1.1, + ), + promotes=["*"], + ) + p.setup(force_alloc_complex=True) + + p.set_val("flap_extension", 15, units="deg") + p.set_val("ac|geom|wing|c4sweep", 25, units="deg") + p.set_val("CL_max_clean", 1.42743476) + p.set_val("ac|geom|wing|toverc", 0.12) + + p.run_model() + + assert_near_equal(p.get_val("CL_max_flap"), 2.17133794, tolerance=1e-8) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/aerodynamics/tests/test_aerodynamics.py b/openconcept/aerodynamics/tests/test_aerodynamics.py index f9d2f00b..ee8a4e3a 100644 --- a/openconcept/aerodynamics/tests/test_aerodynamics.py +++ b/openconcept/aerodynamics/tests/test_aerodynamics.py @@ -58,6 +58,30 @@ def test_partials(self): assert_check_partials(partials) +class VectorDragVectorCD0TestCase(unittest.TestCase): + def test(self): + nn = 3 + p = Problem() + p.model.add_subsystem("comp", PolarDrag(num_nodes=nn, vec_CD0=True), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("fltcond|CL", np.linspace(0, 1, nn)) + p.set_val("fltcond|q", 10e3, units="Pa") + p.set_val("ac|geom|wing|S_ref", 100, units="m**2") + p.set_val("ac|geom|wing|AR", 10) + p.set_val("CD0", np.linspace(0.01, 0.02, nn)) + p.set_val("e", 0.9) + + p.run_model() + + drag = 10e3 * 100 * (np.linspace(0.01, 0.02, nn) + np.linspace(0, 1, nn) ** 2 / np.pi / 10 / 0.9) + + assert_near_equal(p.get_val("drag", units="N"), drag) + + partials = p.check_partials(method="cs", out_stream=None) + assert_check_partials(partials) + + class LiftTestGroup(Group): """ This is a simple analysis group for testing the lift component @@ -135,3 +159,7 @@ def test_stall_speed(self): def test_partials(self): partials = self.prob.check_partials(method="cs", out_stream=None) assert_check_partials(partials) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/aerodynamics/tests/test_drag_BWB.py b/openconcept/aerodynamics/tests/test_drag_BWB.py new file mode 100644 index 00000000..ec737f36 --- /dev/null +++ b/openconcept/aerodynamics/tests/test_drag_BWB.py @@ -0,0 +1,74 @@ +import unittest +import numpy as np +import openmdao.api as om +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +from openconcept.aerodynamics import ParasiteDragCoefficient_BWB + + +class ParasiteDragCoefficient_BWBTestCase(unittest.TestCase): + def test_BWB_clean(self): + prob = om.Problem() + prob.model = om.Group() + + nn = 2 + + dvs = prob.model.add_subsystem("dvs", om.IndepVarComp(), promotes_outputs=["*"]) + + dvs.add_output("ac|geom|wing|S_ref", 100, units="m**2") + dvs.add_output("ac|propulsion|engine|rating", 74.1e3, units="lbf") + dvs.add_output("ac|propulsion|num_engines", 2) + dvs.add_output("ac|geom|nacelle|length", 4.3, units="m") + dvs.add_output("ac|geom|nacelle|S_wet", 27, units="m**2") # estimate using cylinder and nacelle diameter of 2 m + + # Flight conditions at 37k ft Mach 0.875 cruise (ISA) + dvs.add_output("fltcond|Utrue", np.full(nn, 450), units="kn") + dvs.add_output("fltcond|rho", np.full(nn, 0.348), units="kg/m**3") + dvs.add_output("fltcond|T", np.full(nn, 217), units="K") + + prob.model.add_subsystem("drag", ParasiteDragCoefficient_BWB(num_nodes=nn), promotes_inputs=["*"]) + + prob.setup(force_alloc_complex=True) + prob.run_model() + + # This does not include the wing so it shouldn't be a shock that it's a low + assert_near_equal(prob.get_val("drag.CD0"), [0.00211192, 0.00211192], tolerance=1e-5) + + partials = prob.check_partials(method="cs", compact_print=True, show_only_incorrect=False) + assert_check_partials(partials) + + def test_BWB_takeoff(self): + prob = om.Problem() + prob.model = om.Group() + + nn = 2 + + dvs = prob.model.add_subsystem("dvs", om.IndepVarComp(), promotes_outputs=["*"]) + + dvs.add_output("ac|geom|wing|S_ref", 100, units="m**2") + dvs.add_output("ac|geom|wing|c4sweep", 32.2, units="deg") + dvs.add_output("ac|propulsion|engine|rating", 74.1e3, units="lbf") + dvs.add_output("ac|propulsion|num_engines", 2) + dvs.add_output("ac|geom|nacelle|length", 4.3, units="m") + dvs.add_output("ac|geom|nacelle|S_wet", 27, units="m**2") # estimate using cylinder and nacelle diameter of 2 m + dvs.add_output("ac|aero|takeoff_flap_deg", 15, units="deg") + + # Flight conditions at 37k ft Mach 0.875 cruise (ISA) + dvs.add_output("fltcond|Utrue", np.full(nn, 100), units="kn") + dvs.add_output("fltcond|rho", np.full(nn, 1.225), units="kg/m**3") + dvs.add_output("fltcond|T", np.full(nn, 288), units="K") + + prob.model.add_subsystem( + "drag", ParasiteDragCoefficient_BWB(num_nodes=nn, configuration="takeoff"), promotes_inputs=["*"] + ) + + prob.setup(force_alloc_complex=True) + prob.run_model() + + assert_near_equal(prob.get_val("drag.CD0"), [0.01225882, 0.01225882], tolerance=1e-5) + + partials = prob.check_partials(method="cs", compact_print=True, show_only_incorrect=False) + assert_check_partials(partials) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/aerodynamics/tests/test_drag_jet_transport.py b/openconcept/aerodynamics/tests/test_drag_jet_transport.py new file mode 100644 index 00000000..65350a99 --- /dev/null +++ b/openconcept/aerodynamics/tests/test_drag_jet_transport.py @@ -0,0 +1,176 @@ +import unittest +import numpy as np +import openmdao.api as om +from openmdao.utils.assert_utils import assert_check_partials, assert_near_equal +from openconcept.aerodynamics import ParasiteDragCoefficient_JetTransport + + +class ParasiteDragCoefficient_JetTransportTestCase(unittest.TestCase): + def test_B738_clean(self): + """ + 737-800 cruise drag validation-ish case. Data from a combination of: + - Technical site: http://www.b737.org.uk/techspecsdetailed.htm + - Wikipedia: https://en.wikipedia.org/wiki/Boeing_737_Next_Generation#Specifications + """ + prob = om.Problem() + prob.model = om.Group() + + nn = 2 + + dvs = prob.model.add_subsystem("dvs", om.IndepVarComp(), promotes_outputs=["*"]) + + dvs.add_output("ac|geom|wing|S_ref", 124.6, units="m**2") + dvs.add_output("ac|geom|wing|AR", 9.45) + dvs.add_output("ac|geom|wing|taper", 0.159) + dvs.add_output("ac|geom|wing|toverc", 0.12) # guess + + dvs.add_output("ac|geom|hstab|S_ref", 32.78, units="m**2") + dvs.add_output("ac|geom|hstab|AR", 6.16) + dvs.add_output("ac|geom|hstab|taper", 0.203) + dvs.add_output("ac|geom|hstab|toverc", 0.12) # guess + + dvs.add_output("ac|geom|vstab|S_ref", 26.44, units="m**2") + dvs.add_output("ac|geom|vstab|AR", 1.91) + dvs.add_output("ac|geom|vstab|taper", 0.271) + dvs.add_output("ac|geom|vstab|toverc", 0.12) # guess + + dvs.add_output("ac|geom|fuselage|height", 3.76, units="m") + dvs.add_output("ac|geom|fuselage|length", 38.08, units="m") + dvs.add_output("ac|geom|fuselage|S_wet", 450, units="m**2") # estimate using cylinder + + dvs.add_output("ac|geom|nacelle|length", 4.3, units="m") # photogrammetry + dvs.add_output("ac|geom|nacelle|S_wet", 27, units="m**2") # estimate using cylinder and nacelle diameter of 2 m + + dvs.add_output("ac|propulsion|num_engines", 2) + + # Flight conditions at 37k ft Mach 0.875 cruise (ISA) + dvs.add_output("fltcond|Utrue", np.full(nn, 450), units="kn") + dvs.add_output("fltcond|rho", np.full(nn, 0.348), units="kg/m**3") + dvs.add_output("fltcond|T", np.full(nn, 217), units="K") + + prob.model.add_subsystem("drag", ParasiteDragCoefficient_JetTransport(num_nodes=nn), promotes_inputs=["*"]) + + prob.setup(force_alloc_complex=True) + prob.run_model() + + # Check result is unchanged, actual 737-800 cruise CD0 Ben has in the B738 data file is 0.01925 (very close!) + assert_near_equal(prob.get_val("drag.CD0"), [0.01930831, 0.01930831], tolerance=1e-6) + + partials = prob.check_partials(method="cs", compact_print=True, show_only_incorrect=False) + assert_check_partials(partials) + + def test_B738_takeoff(self): + """ + 737-800 takeoff drag validation-ish case. Data from a combination of: + - Technical site: http://www.b737.org.uk/techspecsdetailed.htm + - Wikipedia: https://en.wikipedia.org/wiki/Boeing_737_Next_Generation#Specifications + """ + prob = om.Problem() + prob.model = om.Group() + + nn = 2 + + dvs = prob.model.add_subsystem("dvs", om.IndepVarComp(), promotes_outputs=["*"]) + + dvs.add_output("ac|geom|wing|S_ref", 124.6, units="m**2") + dvs.add_output("ac|geom|wing|AR", 9.45) + dvs.add_output("ac|geom|wing|c4sweep", 25, units="deg") + dvs.add_output("ac|geom|wing|taper", 0.159) + dvs.add_output("ac|geom|wing|toverc", 0.12) # guess + + dvs.add_output("ac|geom|hstab|S_ref", 32.78, units="m**2") + dvs.add_output("ac|geom|hstab|AR", 6.16) + dvs.add_output("ac|geom|hstab|taper", 0.203) + dvs.add_output("ac|geom|hstab|toverc", 0.12) # guess + + dvs.add_output("ac|geom|vstab|S_ref", 26.44, units="m**2") + dvs.add_output("ac|geom|vstab|AR", 1.91) + dvs.add_output("ac|geom|vstab|taper", 0.271) + dvs.add_output("ac|geom|vstab|toverc", 0.12) # guess + + dvs.add_output("ac|geom|fuselage|height", 3.76, units="m") + dvs.add_output("ac|geom|fuselage|length", 38.08, units="m") + dvs.add_output("ac|geom|fuselage|S_wet", 450, units="m**2") # estimate using cylinder + + dvs.add_output("ac|geom|nacelle|length", 4.3, units="m") # photogrammetry + dvs.add_output("ac|geom|nacelle|S_wet", 27, units="m**2") # estimate using cylinder and nacelle diameter of 2 m + + dvs.add_output("ac|propulsion|num_engines", 2) + + dvs.add_output("ac|aero|takeoff_flap_deg", 15, units="deg") + + # Flight conditions at 37k ft Mach 0.875 cruise (ISA) + dvs.add_output("fltcond|Utrue", np.full(nn, 100), units="kn") + dvs.add_output("fltcond|rho", np.full(nn, 1.225), units="kg/m**3") + dvs.add_output("fltcond|T", np.full(nn, 288), units="K") + + prob.model.add_subsystem( + "drag", ParasiteDragCoefficient_JetTransport(num_nodes=nn, configuration="takeoff"), promotes_inputs=["*"] + ) + + prob.setup(force_alloc_complex=True) + prob.run_model() + + # Check result is unchanged. Actual 737-800 takeoff CD0 is challenging to predict, but + # estimates range between 0.03 (in B738 data file) and 0.08 + # (https://www.sesarju.eu/sites/default/files/documents/sid/2018/papers/SIDs_2018_paper_75.pdf), + # so this value isn't unreasonable + assert_near_equal(prob.get_val("drag.CD0"), [0.04531526, 0.04531526], tolerance=1e-6) + + partials = prob.check_partials(method="cs", compact_print=True, show_only_incorrect=False) + assert_check_partials(partials) + + def test_B738_nowing(self): + """ + 737-800 cruise drag without the wing drag. Data from a combination of: + - Technical site: http://www.b737.org.uk/techspecsdetailed.htm + - Wikipedia: https://en.wikipedia.org/wiki/Boeing_737_Next_Generation#Specifications + """ + prob = om.Problem() + prob.model = om.Group() + + nn = 2 + + dvs = prob.model.add_subsystem("dvs", om.IndepVarComp(), promotes_outputs=["*"]) + + dvs.add_output("ac|geom|wing|S_ref", 124.6, units="m**2") + + dvs.add_output("ac|geom|hstab|S_ref", 32.78, units="m**2") + dvs.add_output("ac|geom|hstab|AR", 6.16) + dvs.add_output("ac|geom|hstab|taper", 0.203) + dvs.add_output("ac|geom|hstab|toverc", 0.12) # guess + + dvs.add_output("ac|geom|vstab|S_ref", 26.44, units="m**2") + dvs.add_output("ac|geom|vstab|AR", 1.91) + dvs.add_output("ac|geom|vstab|taper", 0.271) + dvs.add_output("ac|geom|vstab|toverc", 0.12) # guess + + dvs.add_output("ac|geom|fuselage|height", 3.76, units="m") + dvs.add_output("ac|geom|fuselage|length", 38.08, units="m") + dvs.add_output("ac|geom|fuselage|S_wet", 450, units="m**2") # estimate using cylinder + + dvs.add_output("ac|geom|nacelle|length", 4.3, units="m") # photogrammetry + dvs.add_output("ac|geom|nacelle|S_wet", 27, units="m**2") # estimate using cylinder and nacelle diameter of 2 m + + dvs.add_output("ac|propulsion|num_engines", 2) + + # Flight conditions at 37k ft Mach 0.875 cruise (ISA) + dvs.add_output("fltcond|Utrue", np.full(nn, 450), units="kn") + dvs.add_output("fltcond|rho", np.full(nn, 0.348), units="kg/m**3") + dvs.add_output("fltcond|T", np.full(nn, 217), units="K") + + prob.model.add_subsystem( + "drag", ParasiteDragCoefficient_JetTransport(num_nodes=nn, include_wing=False), promotes_inputs=["*"] + ) + + prob.setup(force_alloc_complex=True) + prob.run_model() + + assert_near_equal(prob.get_val("drag.CD0"), [0.0134069, 0.0134069], tolerance=1e-6) + + partials = prob.check_partials(method="cs", compact_print=True, show_only_incorrect=False) + assert_check_partials(partials) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/atmospherics/tests/test_atmospherics.py b/openconcept/atmospherics/tests/test_atmospherics.py index ec9939df..d8173e07 100644 --- a/openconcept/atmospherics/tests/test_atmospherics.py +++ b/openconcept/atmospherics/tests/test_atmospherics.py @@ -90,3 +90,7 @@ def test_sea_level(self): def test_partials(self): partials = self.prob.check_partials(method="cs", out_stream=None) assert_check_partials(partials) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/energy_storage/__init__.py b/openconcept/energy_storage/__init__.py index 56c5d998..06ec5e41 100644 --- a/openconcept/energy_storage/__init__.py +++ b/openconcept/energy_storage/__init__.py @@ -1 +1,2 @@ from .battery import SimpleBattery, SOCBattery +from .hydrogen import LH2TankNoBoilOff diff --git a/openconcept/energy_storage/hydrogen/LH2_tank_no_boil_off.py b/openconcept/energy_storage/hydrogen/LH2_tank_no_boil_off.py new file mode 100644 index 00000000..d486c94d --- /dev/null +++ b/openconcept/energy_storage/hydrogen/LH2_tank_no_boil_off.py @@ -0,0 +1,270 @@ +import openmdao.api as om +import numpy as np + +from openconcept.energy_storage.hydrogen.structural import VacuumTankWeight +from openconcept.utilities.math import Integrator +from openconcept.utilities.math.add_subtract_comp import AddSubtractComp + + +class LH2TankNoBoilOff(om.Group): + """ + Model of a liquid hydrogen storage tank that is cylindrical with hemispherical + end caps. It uses vacuum insulation with MLI and aluminum inner and outer tank + walls. This model does not include the boil-off or thermal models, so it only + estimates the weight and not pressure/temperature time histories. + + This model includes an integrator to compute the weight of liquid hydrogen + remaining in the tank. It is the responsibility of the user to constrain + the fill level to be greater than zero (or slightly more), since the integrator + is perfectly happy with negative fill levels. + + .. code-block:: text + + |--- length ---| + . -------------- . --- + ,' `. | radius + / \ | + | | --- + \ / + `. ,' + ` -------------- ' + + WARNING: Do not modify or connect anything to the initial integrated delta state value + "integ.delta_m_liq_initial". It must remain zero for the initial tank state to be + the expected value. Set the initial tank condition using the fill_level_init option. + + Inputs + ------ + radius : float + Inner radius of the cylinder and hemispherical end caps. This value + does not include the insulation (scalar, m). + length : float + Length of JUST THE CYLIDRICAL part of the tank (scalar, m) + m_dot_liq : float + Mass flow rate of liquid hydrogen out of the tank; positive indicates fuel leaving the tank (vector, kg/s) + N_layers : float + Number of reflective sheild layers in the MLI, should be at least ~10 for model + to retain reasonable accuracy (scalar, dimensionless) + environment_design_pressure : float + Maximum environment exterior pressure expected, probably ~1 atmosphere (scalar, Pa) + max_expected_operating_pressure : float + Maximum expected operating pressure of tank (scalar, Pa) + vacuum_gap : float + Thickness of vacuum gap, used to compute radius of outer vacuum wall, by default + 5 cm, which seems standard. This parameter only affects the radius of the outer + shell, so it's probably ok to leave at 5 cm (scalar, m) + + Outputs + ------- + m_liq : float + Mass of the liquid hydrogen in the tank (vector, kg) + fill_level : float + Fraction of tank volume filled with liquid (vector, dimensionless) + tank_weight : float + Weight of the tank walls (scalar, kg) + total_weight : float + Current total weight of the liquid hydrogen, gaseous hydrogen, and tank structure (vector, kg) + + Options + ------- + num_nodes : int + Number of analysis points to run (scalar, dimensionless) + fill_level_init : float + Initial fill level (in range 0-1) of the tank, default 0.95 + to leave space for boil off gas; 5% adopted from Millis et al. 2009 (scalar, dimensionless) + LH2_density : float + Liquid hydrogen density, by default 70.85 kg/m^3 (scalar, kg/m^3) + weight_fudge_factor : float + Multiplier on tank weight to account for supports, valves, etc., by default 1.1 + stiffening_multiplier : float + Machining stiffeners into the inner side of the vacuum shell enhances its buckling + performance, enabling weight reductions. The value provided in this option is a + multiplier on the outer wall thickness. The default value of 0.8 is higher than it + would be if it were purely empirically determined from Sullivan et al. 2006 + (https://ntrs.nasa.gov/citations/20060021606), but has been made much more + conservative to fall more in line with ~60% gravimetric efficiency tanks + inner_safety_factor : float + Safety factor for sizing inner wall, by default 1.5 + inner_yield_stress : float + Yield stress of inner wall material (Pa), by default Al 2014-T6 taken from Table IV of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + inner_density : float + Density of inner wall material (kg/m^3), by default Al 2014-T6 taken from Table IV of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + outer_safety_factor : float + Safety factor for sizing outer wall, by default 2 + outer_youngs_modulus : float + Young's modulus of outer wall material (Pa), by default LiAl 2090 taken from Table XIII of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + outer_density : float + Density of outer wall material (kg/m^3), by default LiAl 2090 taken from Table XIII of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + """ + + def initialize(self): + self.options.declare("num_nodes", default=1, desc="Number of design points to run") + self.options.declare("fill_level_init", default=0.95, desc="Initial fill level") + self.options.declare("LH2_density", default=70.85, desc="Liquid hydrogen density in kg/m^3") + self.options.declare("weight_fudge_factor", default=1.1, desc="Weight multiplier to account for other stuff") + self.options.declare("stiffening_multiplier", default=0.8, desc="Multiplier on wall thickness") + self.options.declare("inner_safety_factor", default=1.5, desc="Safety factor on inner wall thickness") + self.options.declare("inner_yield_stress", default=413.7e6, desc="Yield stress of inner wall material in Pa") + self.options.declare("inner_density", default=2796.0, desc="Density of inner wall material in kg/m^3") + self.options.declare("outer_safety_factor", default=2.0, desc="Safety factor on outer wall thickness") + self.options.declare("outer_youngs_modulus", default=8.0e10, desc="Young's modulus of outer wall material, Pa") + self.options.declare("outer_density", default=2699.0, desc="Density of outer wall material in kg/m^3") + + def setup(self): + nn = self.options["num_nodes"] + + # Structural weight model + self.add_subsystem( + "structure", + VacuumTankWeight( + weight_fudge_factor=self.options["weight_fudge_factor"], + stiffening_multiplier=self.options["stiffening_multiplier"], + inner_safety_factor=self.options["inner_safety_factor"], + inner_yield_stress=self.options["inner_yield_stress"], + inner_density=self.options["inner_density"], + outer_safety_factor=self.options["outer_safety_factor"], + outer_youngs_modulus=self.options["outer_youngs_modulus"], + outer_density=self.options["outer_density"], + ), + promotes_inputs=[ + "environment_design_pressure", + "max_expected_operating_pressure", + "vacuum_gap", + "radius", + "length", + "N_layers", + ], + promotes_outputs=[("weight", "tank_weight")], + ) + + # The initial tank states are specified indirectly by the fill_level_init and LH2_density options, along + # with the input tank radius and length. We can't connect a component directly to the integrator's + # inputs because those initial values are linked between phases. Thus, we use a bit of a trick where + # we actually integrate the amount of LH2 consumed since the beginning of the mission and then + # add the correct initial values in the add_init_state_values component. + integ = self.add_subsystem( + "integ", + Integrator(num_nodes=nn, diff_units="s", time_setup="duration", method="simpson"), + promotes_inputs=["m_dot_liq"], + ) + integ.add_integrand("delta_m_liq", rate_name="m_dot_liq", units="kg", val=0, start_val=0) + + self.add_subsystem( + "add_init_state_values", + InitialLH2MassModification( + num_nodes=nn, + fill_level_init=self.options["fill_level_init"], + LH2_density=self.options["LH2_density"], + ), + promotes_inputs=["radius", "length"], + promotes_outputs=["m_liq", "fill_level"], + ) + self.connect("integ.delta_m_liq", "add_init_state_values.delta_m_liq") + + # Add all the weights + self.add_subsystem( + "sum_weight", + AddSubtractComp( + output_name="total_weight", + input_names=["m_liq", "tank_weight"], + vec_size=[nn, 1], + units="kg", + ), + promotes_inputs=["m_liq", "tank_weight"], + promotes_outputs=["total_weight"], + ) + + # Set default for some inputs + self.set_input_defaults("radius", 1.0, units="m") + self.set_input_defaults("N_layers", 20) + self.set_input_defaults("vacuum_gap", 5, units="cm") + + +class InitialLH2MassModification(om.ExplicitComponent): + """ + Subtract the change in LH2 mass from the initial value (computed internally). + Also computes the fill level. + + Inputs + ------ + radius : float + Inner radius of the cylinder and hemispherical end caps. This value + does not include the insulation (scalar, m). + length : float + Length of JUST THE CYLIDRICAL part of the tank (scalar, m) + delta_m_liq : float + Change in mass of liquid hydrogen in the tank since the beginning of the mission (vector, kg) + + Outputs + ------- + m_liq : float + Mass of liquid hydrogen in the tank (vector, kg) + fill_level : float + Fraction of the tank filled with LH2 (vector, dimensionless) + + Options + ------- + num_nodes : int + Number of analysis points to run (scalar, dimensionless) + fill_level_init : float + Initial fill level (in range 0-1) of the tank, default 0.95 + to leave space for boil off gas; 5% adopted from Millis et al. 2009 (scalar, dimensionless) + LH2_density : float + Liquid hydrogen density, by default 70.85 kg/m^3 (scalar, kg/m^3) + """ + + def initialize(self): + self.options.declare("num_nodes", default=1, desc="Number of design points to run") + self.options.declare("fill_level_init", default=0.95, desc="Initial fill level") + self.options.declare("LH2_density", default=70.85, desc="Liquid hydrogen density in kg/m^3") + + def setup(self): + nn = self.options["num_nodes"] + + self.add_input("radius", val=1.0, units="m") + self.add_input("length", val=0.5, units="m") + self.add_input("delta_m_liq", shape=(nn,), units="kg", val=0.0) + self.add_output("m_liq", shape=(nn,), units="kg") + self.add_output("fill_level", shape=(nn,)) + + arng = np.arange(nn) + self.declare_partials("m_liq", "delta_m_liq", rows=arng, cols=arng, val=-np.ones(nn)) + self.declare_partials("fill_level", "delta_m_liq", rows=arng, cols=arng) + self.declare_partials(["m_liq", "fill_level"], ["radius", "length"], rows=arng, cols=np.zeros(nn)) + + def compute(self, inputs, outputs): + r = inputs["radius"] + L = inputs["length"] + fill_init = self.options["fill_level_init"] + rho = self.options["LH2_density"] + + V_tank = 4 / 3 * np.pi * r**3 + np.pi * r**2 * L + V_liq_init = V_tank * fill_init + outputs["m_liq"] = V_liq_init * rho - inputs["delta_m_liq"] + outputs["fill_level"] = outputs["m_liq"] / (rho * V_tank) + + def compute_partials(self, inputs, partials): + r = inputs["radius"] + L = inputs["length"] + fill_init = self.options["fill_level_init"] + rho = self.options["LH2_density"] + + V_tank = 4 / 3 * np.pi * r**3 + np.pi * r**2 * L + Vtank_r = 4 * np.pi * r**2 + 2 * np.pi * r * L + Vtank_L = np.pi * r**2 + V_liq_init = V_tank * fill_init + m_liq = V_liq_init * rho - inputs["delta_m_liq"] + + partials["m_liq", "radius"] = Vtank_r * fill_init * rho + partials["m_liq", "length"] = Vtank_L * fill_init * rho + partials["fill_level", "delta_m_liq"] = -1 / (rho * V_tank) + partials["fill_level", "radius"] = ( + partials["m_liq", "radius"] / (rho * V_tank) - m_liq / (rho * V_tank) ** 2 * rho * Vtank_r + ) + partials["fill_level", "length"] = ( + partials["m_liq", "length"] / (rho * V_tank) - m_liq / (rho * V_tank) ** 2 * rho * Vtank_L + ) diff --git a/openconcept/energy_storage/hydrogen/__init__.py b/openconcept/energy_storage/hydrogen/__init__.py new file mode 100644 index 00000000..f398c889 --- /dev/null +++ b/openconcept/energy_storage/hydrogen/__init__.py @@ -0,0 +1 @@ +from .LH2_tank_no_boil_off import LH2TankNoBoilOff diff --git a/openconcept/energy_storage/hydrogen/structural.py b/openconcept/energy_storage/hydrogen/structural.py new file mode 100644 index 00000000..82256e2b --- /dev/null +++ b/openconcept/energy_storage/hydrogen/structural.py @@ -0,0 +1,479 @@ +import numpy as np +import openmdao.api as om +from openconcept.utilities import AddSubtractComp + + +class VacuumTankWeight(om.Group): + """ + Sizes the structure and computes the weight of the tank's vacuum walls. + This includes the weight of MLI. + + .. code-block:: text + + |--- length ---| + . -------------- . --- + ,' `. | radius + / \ | + | | --- + \ / + `. ,' + ` -------------- ' + + Inputs + ------ + environment_design_pressure : float + Maximum environment exterior pressure expected, probably ~1 atmosphere (scalar, Pa) + max_expected_operating_pressure : float + Maximum expected operating pressure of tank (scalar, Pa) + vacuum_gap : float + Thickness of vacuum gap, used to compute radius of outer vacuum wall (scalar, m) + radius : float + Tank inner radius of the cylinder and hemispherical end caps (scalar, m) + length : float + Length of JUST THE CYLIDRICAL part of the tank (scalar, m) + N_layers : float + Number of reflective sheild layers in the MLI, should be at least ~10 for model + to retain reasonable accuracy (scalar, dimensionless) + + Outputs + ------- + weight : float + Weight of the tank walls (scalar, kg) + + Options + ------- + weight_fudge_factor : float + Multiplier on tank weight to account for supports, valves, etc., by default 1.1 + stiffening_multiplier : float + Machining stiffeners into the inner side of the vacuum shell enhances its buckling + performance, enabling weight reductions. The value provided in this option is a + multiplier on the outer wall thickness. The default value of 0.8 is higher than it + would be if it were purely empirically determined from Sullivan et al. 2006 + (https://ntrs.nasa.gov/citations/20060021606), but has been made much more + conservative to fall more in line with ~60% gravimetric efficiency tanks + inner_safety_factor : float + Safety factor for sizing inner wall, by default 1.5 + inner_yield_stress : float + Yield stress of inner wall material (Pa), by default Al 2014-T6 taken from Table IV of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + inner_density : float + Density of inner wall material (kg/m^3), by default Al 2014-T6 taken from Table IV of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + outer_safety_factor : float + Safety factor for sizing outer wall, by default 2 + outer_youngs_modulus : float + Young's modulus of outer wall material (Pa), by default LiAl 2090 taken from Table XIII of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + outer_density : float + Density of outer wall material (kg/m^3), by default LiAl 2090 taken from Table XIII of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + """ + + def initialize(self): + self.options.declare("weight_fudge_factor", default=1.1, desc="Weight multiplier to account for other stuff") + self.options.declare("stiffening_multiplier", default=0.8, desc="Multiplier on wall thickness") + self.options.declare("inner_safety_factor", default=1.5, desc="Safety factor on inner wall thickness") + self.options.declare("inner_yield_stress", default=413.7e6, desc="Yield stress of inner wall material in Pa") + self.options.declare("inner_density", default=2796.0, desc="Density of inner wall material in kg/m^3") + self.options.declare("outer_safety_factor", default=2.0, desc="Safety factor on outer wall thickness") + self.options.declare("outer_youngs_modulus", default=8.0e10, desc="Young's modulus of outer wall material, Pa") + self.options.declare("outer_density", default=2699.0, desc="Density of outer wall material in kg/m^3") + + def setup(self): + # Inner tank wall thickness and weight computation + self.add_subsystem( + "inner_wall", + PressureVesselWallThickness( + safety_factor=self.options["inner_safety_factor"], + yield_stress=self.options["inner_yield_stress"], + density=self.options["inner_density"], + ), + promotes_inputs=[("design_pressure_differential", "max_expected_operating_pressure"), "radius", "length"], + ) + + # Compute radius of outer tank wall + self.add_subsystem( + "outer_radius", + AddSubtractComp( + output_name="outer_radius", + input_names=["radius", "vacuum_gap"], + scaling_factors=[1, 1], + lower=0.0, + units="m", + ), + promotes_inputs=["radius", "vacuum_gap"], + ) + + # Outer tank wall thickness and weight computation + self.add_subsystem( + "outer_wall", + VacuumWallThickness( + safety_factor=self.options["outer_safety_factor"], + stiffening_multiplier=self.options["stiffening_multiplier"], + youngs_modulus=self.options["outer_youngs_modulus"], + density=self.options["outer_density"], + ), + promotes_inputs=[("design_pressure_differential", "environment_design_pressure"), "length"], + ) + self.connect("outer_radius.outer_radius", "outer_wall.radius") + + # Compute the weight of the MLI + self.add_subsystem("MLI", MLIWeight(), promotes_inputs=["radius", "length", "N_layers"]) + + # Compute total weight multiplied by fudge factor + W_mult = self.options["weight_fudge_factor"] + self.add_subsystem( + "total_weight", + AddSubtractComp( + output_name="weight", + input_names=["W_outer", "W_inner", "W_MLI"], + scaling_factors=[W_mult, W_mult, W_mult], + lower=0.0, + units="kg", + ), + promotes_outputs=["weight"], + ) + self.connect("inner_wall.weight", "total_weight.W_inner") + self.connect("outer_wall.weight", "total_weight.W_outer") + self.connect("MLI.weight", "total_weight.W_MLI") + + # Set defaults for inputs promoted from multiple sources + self.set_input_defaults("radius", 1.0, units="m") + self.set_input_defaults("length", 0.5, units="m") + + +class PressureVesselWallThickness(om.ExplicitComponent): + """ + Compute the wall thickness of a metallic pressure vessel to support a specified + pressure load. The model assumes an isotropic wall material, hence the metallic + constraint. This uses a simple equation to compute the hoop stress (also referred + to as Barlow's formula) to size the wall thickness. + + This component assumes that the wall is thin enough relative to the radius such that + it is valid to compute the weight as the product of the surface area, wall thickness, + and material density. + + .. code-block:: text + + |--- length ---| + . -------------- . --- + ,' `. | radius + / \ | + | | --- + \ / + `. ,' + ` -------------- ' + + Inputs + ------ + design_pressure_differential : float + The maximum pressure differential between the interior and exterior of the + pressure vessel that is used to size the wall thickness; should ALWAYS + be positive, otherwise wall thickness and weight will be negative (scalar, Pa) + radius : float + Inner radius of the cylinder and hemispherical end caps (scalar, m) + length : float + Length of JUST THE CYLIDRICAL part of the tank (scalar, m) + + Outputs + ------- + thickness : float + Pressure vessel wall thickness (scalar, m) + weight : float + Weight of the wall (scalar, kg) + + Options + ------- + safety_factor : float + Safety factor for sizing wall, by default 2 + yield_stress : float + Yield stress of wall material (Pa), by default LiAl 2090 taken from Table XIII of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + density : float + Density of wall material (kg/m^3), by default LiAl 2090 taken from Table XIII of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + """ + + def initialize(self): + self.options.declare("safety_factor", default=2.0, desc="Safety factor on wall thickness") + self.options.declare("yield_stress", default=470.2e6, desc="Yield stress of wall material in Pa") + self.options.declare("density", default=2699.0, desc="Density of wall material in kg/m^3") + + def setup(self): + self.add_input("design_pressure_differential", val=3e5, units="Pa") + self.add_input("radius", val=0.5, units="m") + self.add_input("length", val=2.0, units="m") + + self.add_output("thickness", lower=0.0, units="m") + self.add_output("weight", lower=0.0, units="kg") + + self.declare_partials("thickness", ["design_pressure_differential", "radius"]) + self.declare_partials("weight", ["design_pressure_differential", "radius", "length"]) + + def compute(self, inputs, outputs): + p = inputs["design_pressure_differential"] + r = inputs["radius"] + L = inputs["length"] + SF = self.options["safety_factor"] + yield_stress = self.options["yield_stress"] + density = self.options["density"] + + outputs["thickness"] = p * r * SF / yield_stress + + surface_area = 4 * np.pi * r**2 + 2 * np.pi * r * L + outputs["weight"] = surface_area * outputs["thickness"] * density + + def compute_partials(self, inputs, J): + p = inputs["design_pressure_differential"] + r = inputs["radius"] + L = inputs["length"] + SF = self.options["safety_factor"] + yield_stress = self.options["yield_stress"] + density = self.options["density"] + + t = p * r * SF / yield_stress + + J["thickness", "design_pressure_differential"] = r * SF / yield_stress + J["thickness", "radius"] = p * SF / yield_stress + + A = 4 * np.pi * r**2 + 2 * np.pi * r * L + dAdr = 8 * np.pi * r + 2 * np.pi * L + dAdL = 2 * np.pi * r + J["weight", "design_pressure_differential"] = A * J["thickness", "design_pressure_differential"] * density + J["weight", "radius"] = (dAdr * t + A * J["thickness", "radius"]) * density + J["weight", "length"] = dAdL * t * density + + +class VacuumWallThickness(om.ExplicitComponent): + """ + Compute the wall thickness when the exterior pressure is greater than the interior + one. This applies to the outer wall of a vacuum-insulated tank. It does this by + computing the necessary wall thickness for a cylindrical shell under uniform compression + and sphere under uniform compression and taking the maximum thickness of the two. + + The equations are from Table 15.2 of Roark's Formulas for Stress and Strain, 9th + Edition by Budynas and Sadegh. + + This component assumes that the wall is thin relative to the radius. + + .. code-block:: text + + |--- length ---| + . -------------- . --- + ,' `. | radius + / \ | + | | --- + \ / + `. ,' + ` -------------- ' + + Inputs + ------ + design_pressure_differential : float + The maximum pressure differential between the interior and exterior of the + pressure vessel that is used to size the wall thickness; should ALWAYS + be positive (scalar, Pa) + radius : float + Inner radius of the cylinder and hemispherical end caps (scalar, m) + length : float + Length of JUST THE CYLIDRICAL part of the tank (scalar, m) + + Outputs + ------- + thickness : float + Pressure vessel wall thickness (scalar, m) + weight : float + Weight of the wall (scalar, kg) + + Options + ------- + safety_factor : float + Safety factor for sizing wall applied to design pressure, by default 2 + stiffening_multiplier : float + Machining stiffeners into the inner side of the vacuum shell enhances its buckling + performance, enabling weight reductions. The value provided in this option is a + multiplier on the outer wall thickness. The default value of 0.8 is higher than it + would be if it were purely empirically determined from Sullivan et al. 2006 + (https://ntrs.nasa.gov/citations/20060021606), but has been made much more + conservative to fall more in line with ~60% gravimetric efficiency tanks + youngs_modulus : float + Young's modulus of wall material (Pa), by default LiAl 2090 taken from Table XIII of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + density : float + Density of wall material (kg/m^3), by default LiAl 2090 taken from Table XIII of + Sullivan et al. 2006 (https://ntrs.nasa.gov/citations/20060021606) + """ + + def initialize(self): + self.options.declare("safety_factor", default=2.0, desc="Safety factor on wall thickness") + self.options.declare("stiffening_multiplier", default=0.8, desc="Multiplier on wall thickness") + self.options.declare("youngs_modulus", default=8.0e10, desc="Young's modulus of wall material in Pa") + self.options.declare("density", default=2699.0, desc="Density of wall material in kg/m^3") + + def setup(self): + self.add_input("design_pressure_differential", val=101325.0, units="Pa") + self.add_input("radius", val=0.5, units="m") + self.add_input("length", val=2.0, units="m") + + self.add_output("thickness", lower=0.0, units="m") + self.add_output("weight", lower=0.0, units="kg") + + self.declare_partials(["thickness", "weight"], ["design_pressure_differential", "radius", "length"]) + + def compute(self, inputs, outputs): + p = inputs["design_pressure_differential"] + r = inputs["radius"] + L = inputs["length"] + SF = self.options["safety_factor"] + E = self.options["youngs_modulus"] + density = self.options["density"] + stiff_mult = self.options["stiffening_multiplier"] + + # Compute the thickness necessary for the cylindrical portion + t_cyl = (p * SF * L * r**1.5 / (0.92 * E)) ** (1 / 2.5) + + # Compute the thickness necessary for the spherical portion + t_sph = r * np.sqrt(p * SF / (0.365 * E)) + + # Take the maximum of the two, when r and L are small the KS + # isn't a great approximation and the weighting parameter needs + # to be very high, so just let it be C1 discontinuous + outputs["thickness"] = stiff_mult * np.maximum(t_cyl, t_sph) + + surface_area = 4 * np.pi * r**2 + 2 * np.pi * r * L + outputs["weight"] = surface_area * outputs["thickness"] * density + + def compute_partials(self, inputs, J): + p = inputs["design_pressure_differential"] + r = inputs["radius"] + L = inputs["length"] + SF = self.options["safety_factor"] + E = self.options["youngs_modulus"] + density = self.options["density"] + stiff_mult = self.options["stiffening_multiplier"] + + # Compute the thickness necessary for the cylindrical portion + t_cyl = (p * SF * L * r**1.5 / (0.92 * E)) ** (1 / 2.5) + if L < 1e-6: + dtcyl_dp = 0.0 + dtcyl_dr = 0.0 + dtcyl_dL = 0.0 + else: + first_term = (p * SF * L * r**1.5 / (0.92 * E)) ** (1 / 2.5 - 1) / 2.5 + dtcyl_dp = first_term * SF * L * r**1.5 / (0.92 * E) + dtcyl_dr = first_term * p * SF * L * r**0.5 / (0.92 * E) * 1.5 + dtcyl_dL = first_term * p * SF * r**1.5 / (0.92 * E) + + # Compute the thickness necessary for the spherical portion + t_sph = r * np.sqrt(p * SF / (0.365 * E)) + dtsph_dp = 0.5 * r * (p * SF / (0.365 * E)) ** (-0.5) * SF / (0.365 * E) + dtsph_dr = t_sph / r + dtsph_dL = 0.0 + + # Derivative is from whichever thickness is greater + use_cyl = t_cyl.item() > t_sph.item() + J["thickness", "design_pressure_differential"] = (dtcyl_dp if use_cyl else dtsph_dp) * stiff_mult + J["thickness", "radius"] = (dtcyl_dr if use_cyl else dtsph_dr) * stiff_mult + J["thickness", "length"] = (dtcyl_dL if use_cyl else dtsph_dL) * stiff_mult + + t = stiff_mult * np.maximum(t_cyl, t_sph) + A = 4 * np.pi * r**2 + 2 * np.pi * r * L + dAdr = 8 * np.pi * r + 2 * np.pi * L + dAdL = 2 * np.pi * r + J["weight", "design_pressure_differential"] = A * J["thickness", "design_pressure_differential"] * density + J["weight", "radius"] = (dAdr * t + A * J["thickness", "radius"]) * density + J["weight", "length"] = (dAdL * t + A * J["thickness", "length"]) * density + + +class MLIWeight(om.ExplicitComponent): + """ + Compute the weight of the MLI given the tank geometry and number of MLI layers. + Foil and spacer areal density per layer estimated from here: + https://frakoterm.com/cryogenics/multi-layer-insulation-mli/ + + Inputs + ------ + radius : float + Inner radius of the cylinder and hemispherical end caps. This value + does not include the insulation (scalar, m). + length : float + Length of JUST THE CYLIDRICAL part of the tank (scalar, m) + N_layers : float + Number of reflective sheild layers in the MLI, should be at least ~10 for model + to retain reasonable accuracy (scalar, dimensionless) + + Outputs + ------- + weight : float + Total weight of the MLI insulation (scalar, kg) + + Options + ------- + foil_layer_areal_weight : float + Areal weight of a single foil layer, by default 18e-3 (scalar, kg/m^2) + spacer_layer_areal_weight : float + Areal weight of a single spacer layer, by default 12e-3 (scalar, kg/m^2) + """ + + def initialize(self): + self.options.declare("foil_layer_areal_weight", default=18e-3, desc="Areal weight of foil layer in kg/m^2") + self.options.declare("spacer_layer_areal_weight", default=12e-3, desc="Areal weight of spacer layer in kg/m^2") + + def setup(self): + self.add_input("radius", units="m") + self.add_input("length", units="m") + self.add_input("N_layers") + + self.add_output("weight", units="kg") + + self.declare_partials("weight", ["radius", "length", "N_layers"]) + + def compute(self, inputs, outputs): + r = inputs["radius"] + L = inputs["length"] + N = inputs["N_layers"] + W_foil = self.options["foil_layer_areal_weight"] + W_spacer = self.options["spacer_layer_areal_weight"] + + # Compute surface area + A = 4 * np.pi * r**2 + 2 * np.pi * r * L + + outputs["weight"] = (W_foil + W_spacer) * N * A + + def compute_partials(self, inputs, J): + r = inputs["radius"] + L = inputs["length"] + N = inputs["N_layers"] + W_foil = self.options["foil_layer_areal_weight"] + W_spacer = self.options["spacer_layer_areal_weight"] + + # Compute surface area + A = 4 * np.pi * r**2 + 2 * np.pi * r * L + + J["weight", "N_layers"] = (W_foil + W_spacer) * A + J["weight", "radius"] = (W_foil + W_spacer) * N * (8 * np.pi * r + 2 * np.pi * L) + J["weight", "length"] = (W_foil + W_spacer) * N * (2 * np.pi * r) + + +if __name__ == "__main__": + p = om.Problem() + p.model.add_subsystem("model", VacuumTankWeight(), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("environment_design_pressure", 1.0, units="atm") + p.set_val("max_expected_operating_pressure", 2.5, units="bar") + p.set_val("vacuum_gap", 4, units="inch") + p.set_val("radius", 8.5 / 2, units="ft") + p.set_val("length", 0.0, units="ft") + + p.run_model() + + # p.check_partials(method="cs", compact_print=True) + + p.model.list_outputs(units=True) + + r = p.get_val("radius", units="m").item() + L = p.get_val("length", units="m").item() + W_LH2 = (4 / 3 * np.pi * r**3 + np.pi * r**2 * L) * 70 * 0.95 + W_tank = p.get_val("weight", units="kg").item() + print(f"\n-------- Approximate gravimetric efficiency: {W_LH2 / (W_LH2 + W_tank) * 100:.1f}% --------") diff --git a/openconcept/energy_storage/hydrogen/tests/__init__.py b/openconcept/energy_storage/hydrogen/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openconcept/energy_storage/hydrogen/tests/test_LH2_tank_no_boil_off.py b/openconcept/energy_storage/hydrogen/tests/test_LH2_tank_no_boil_off.py new file mode 100644 index 00000000..85ff8f01 --- /dev/null +++ b/openconcept/energy_storage/hydrogen/tests/test_LH2_tank_no_boil_off.py @@ -0,0 +1,152 @@ +import unittest +import numpy as np +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +import openmdao.api as om +from openconcept.energy_storage.hydrogen.LH2_tank_no_boil_off import LH2TankNoBoilOff, InitialLH2MassModification + + +class LH2TankTestCase(unittest.TestCase): + def test_simple(self): + p = om.Problem() + p.model = LH2TankNoBoilOff(fill_level_init=0.95) + p.setup(force_alloc_complex=True) + + p.run_model() + + assert_near_equal(p.get_val("m_liq", units="kg"), 387.66337047, tolerance=1e-9) + assert_near_equal(p.get_val("fill_level"), 0.95, tolerance=1e-9) + assert_near_equal(p.get_val("tank_weight", units="kg"), 252.70942027, tolerance=1e-9) + assert_near_equal( + p.get_val("total_weight", units="kg"), + p.get_val("tank_weight", units="kg") + p.get_val("m_liq", units="kg"), + tolerance=1e-9, + ) + + def test_time_history(self): + nn = 5 + + p = om.Problem() + p.model = LH2TankNoBoilOff(num_nodes=nn, fill_level_init=0.95) + p.setup(force_alloc_complex=True) + + duration = nn - 1 # sec + + p.set_val("radius", 0.7, units="m") + p.set_val("length", 1.7, units="m") + p.set_val("m_dot_liq", 1.0, units="kg/s") + p.set_val("integ.duration", duration, units="s") + + p.run_model() + + assert_near_equal(p.get_val("m_liq", units="kg"), 272.84452856480556 - np.arange(nn), tolerance=1e-9) + assert_near_equal( + p.get_val("fill_level"), np.array([0.95, 0.94651816, 0.94303633, 0.93955449, 0.93607265]), tolerance=1e-8 + ) + assert_near_equal(p.get_val("tank_weight", units="kg"), 263.38260155, tolerance=1e-9) + assert_near_equal( + p.get_val("total_weight", units="kg"), + p.get_val("tank_weight", units="kg") + p.get_val("m_liq", units="kg"), + tolerance=1e-9, + ) + + partials = p.check_partials(method="cs") + assert_check_partials(partials) + + +class InitialLH2MassModificationTestCase(unittest.TestCase): + def test_init_values(self): + p = om.Problem() + + fill = 0.6 + rho = 70.0 + + p.model.add_subsystem( + "model", + InitialLH2MassModification( + num_nodes=1, + fill_level_init=fill, + LH2_density=rho, + ), + promotes=["*"], + ) + + p.setup() + + r = 1.3 # m + L = 0.7 # m + + p.set_val("radius", r, units="m") + p.set_val("length", L, units="m") + + # Set input to zero so we can test the computed initial values + p.set_val("delta_m_liq", 0.0, units="kg") + + p.run_model() + + V_tank = 4 / 3 * np.pi * r**3 + np.pi * r**2 * L + m_liq = V_tank * fill * rho + + assert_near_equal(p.get_val("m_liq", units="kg"), m_liq, tolerance=1e-12) + assert_near_equal(p.get_val("fill_level"), fill, tolerance=1e-12) + + def test_vectorized(self): + p = om.Problem() + + nn = 5 + fill = 0.6 + rho = 70.0 + + p.model.add_subsystem( + "model", + InitialLH2MassModification( + num_nodes=nn, + fill_level_init=fill, + LH2_density=rho, + ), + promotes=["*"], + ) + + p.setup() + + r = 1.3 # m + L = 0.7 # m + + p.set_val("radius", r, units="m") + p.set_val("length", L, units="m") + + # Set a specified change to liquid mass + val = np.linspace(-10, 10, nn) + p.set_val("delta_m_liq", val, units="kg") + + p.run_model() + + V_tank = 4 / 3 * np.pi * r**3 + np.pi * r**2 * L + m_liq = V_tank * fill * rho + + assert_near_equal(p.get_val("m_liq", units="kg"), m_liq - val, tolerance=1e-12) + assert_near_equal(p.get_val("fill_level"), fill - val / rho / V_tank, tolerance=1e-12) + + def test_partials(self): + p = om.Problem() + + nn = 5 + + p.model.add_subsystem("model", InitialLH2MassModification(num_nodes=nn), promotes=["*"]) + + p.setup(force_alloc_complex=True) + + p.set_val("radius", 1.6, units="m") + p.set_val("length", 0.3, units="m") + + # Set a specified change to liquid mass + val = np.linspace(-10, 10, nn) + p.set_val("delta_m_liq", val, units="kg") + + p.run_model() + + partials = p.check_partials(method="cs") + assert_check_partials(partials) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/energy_storage/hydrogen/tests/test_structural.py b/openconcept/energy_storage/hydrogen/tests/test_structural.py new file mode 100644 index 00000000..7f339729 --- /dev/null +++ b/openconcept/energy_storage/hydrogen/tests/test_structural.py @@ -0,0 +1,176 @@ +import unittest +import numpy as np +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +import openmdao.api as om +from openconcept.energy_storage.hydrogen.structural import ( + VacuumTankWeight, + VacuumWallThickness, + PressureVesselWallThickness, + MLIWeight, +) + + +class VacuumTankWeightTestCase(unittest.TestCase): + def test_simple(self): + """ + Regression test with some reasonable values that also checks the partials. + """ + p = om.Problem() + p.model.add_subsystem("model", VacuumTankWeight(), promotes=["*"]) + + p.setup(force_alloc_complex=True) + + p.set_val("environment_design_pressure", 1, units="atm") + p.set_val("max_expected_operating_pressure", 3, units="atm") + p.set_val("vacuum_gap", 2, units="inch") + p.set_val("radius", 1.0, units="m") + p.set_val("length", 1.0, units="m") + p.set_val("N_layers", 10) + + p.run_model() + + assert_near_equal(p.get_val("weight", units="kg"), 369.22641856, tolerance=1e-8) + + partials = p.check_partials(method="cs") + assert_check_partials(partials, atol=1e-12, rtol=1e-12) + + def test_different_options(self): + """ + Regression test with some reasonable values that also checks the partials. + """ + p = om.Problem() + p.model.add_subsystem( + "model", + VacuumTankWeight( + weight_fudge_factor=1.0, stiffening_multiplier=0.4, inner_safety_factor=2.0, outer_safety_factor=3.0 + ), + promotes=["*"], + ) + + p.setup(force_alloc_complex=True) + + p.set_val("environment_design_pressure", 1.2, units="atm") + p.set_val("max_expected_operating_pressure", 5, units="atm") + p.set_val("vacuum_gap", 1, units="inch") + p.set_val("radius", 1.0, units="m") + p.set_val("length", 0.0, units="m") + p.set_val("N_layers", 30) + + p.run_model() + + assert_near_equal(p.get_val("weight", units="kg"), 149.06278353, tolerance=1e-8) + + partials = p.check_partials(method="cs") + assert_check_partials(partials, atol=1e-12, rtol=1e-12) + + +class PressureVesselWallThicknessTestCase(unittest.TestCase): + def test_simple(self): + """ + Regression test with some reasonable values that also checks the partials. + """ + P = 3e5 # Pa + r = 1 # m + L = 0.8 # m + SF = 1.7 + yield_stress = 1e7 + rho = 1.0 + + p = om.Problem() + p.model.add_subsystem( + "model", + PressureVesselWallThickness(safety_factor=SF, yield_stress=yield_stress, density=rho), + promotes=["*"], + ) + + p.setup(force_alloc_complex=True) + + p.set_val("design_pressure_differential", P, units="Pa") + p.set_val("radius", r, units="m") + p.set_val("length", L, units="m") + + p.run_model() + + t = P * r * SF / yield_stress + W = (4 * np.pi * r**2 + 2 * np.pi * r * L) * t * rho + assert_near_equal(p.get_val("thickness", units="m"), t, tolerance=1e-13) + assert_near_equal(p.get_val("weight", units="kg"), W, tolerance=1e-13) + + partials = p.check_partials(method="cs") + assert_check_partials(partials, atol=1e-12, rtol=1e-12) + + +class VacuumWallThicknessTestCase(unittest.TestCase): + def test_sphere(self): + """ + Test a sphere to check the case where the hemispheres buckle first. + """ + p = om.Problem() + p.model.add_subsystem("model", VacuumWallThickness(), promotes=["*"]) + + p.setup(force_alloc_complex=True) + + p.set_val("design_pressure_differential", 1, units="atm") + p.set_val("radius", 1.0, units="m") + p.set_val("length", 0.0, units="m") + + p.run_model() + + assert_near_equal(p.get_val("thickness", units="m"), 0.002107520779403, tolerance=1e-8) + assert_near_equal(p.get_val("weight", units="kg"), 71.48001153, tolerance=1e-8) + + partials = p.check_partials(method="cs") + assert_check_partials(partials, atol=1e-12, rtol=1e-12) + + def test_cylinder(self): + """ + Test a cylinder to check the case where the cylndrical portion buckles first. + """ + p = om.Problem() + p.model.add_subsystem("model", VacuumWallThickness(), promotes=["*"]) + + p.setup(force_alloc_complex=True) + + p.set_val("design_pressure_differential", 1, units="atm") + p.set_val("radius", 1.0, units="m") + p.set_val("length", 10.0, units="m") + + p.run_model() + + assert_near_equal(p.get_val("thickness", units="m"), 0.011996028931134, tolerance=1e-8) + assert_near_equal(p.get_val("weight", units="kg"), 2441.189557, tolerance=1e-8) + + partials = p.check_partials(method="cs") + assert_check_partials(partials, atol=1e-12, rtol=1e-12) + + +class MLIWeightTestCase(unittest.TestCase): + def test_simple(self): + rho_spacer = 0.5 # kg/m^2 + rho_foil = 1.0 # kg/m^2 + r = 1.0 # m + L = 0.5 # m + N = 10 + + p = om.Problem() + p.model.add_subsystem( + "model", MLIWeight(foil_layer_areal_weight=rho_foil, spacer_layer_areal_weight=rho_spacer), promotes=["*"] + ) + + p.setup(force_alloc_complex=True) + + p.set_val("N_layers", N) + p.set_val("radius", r, units="m") + p.set_val("length", L, units="m") + + p.run_model() + + W = (4 * np.pi * r**2 + 2 * np.pi * r * L) * N * (rho_spacer + rho_foil) + assert_near_equal(p.get_val("weight", units="kg"), W, tolerance=1e-13) + + partials = p.check_partials(method="cs") + assert_check_partials(partials, atol=1e-12, rtol=1e-12) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/examples/B738_VLM_drag.py b/openconcept/examples/B738_VLM_drag.py index a0f6b37c..88939a65 100644 --- a/openconcept/examples/B738_VLM_drag.py +++ b/openconcept/examples/B738_VLM_drag.py @@ -154,8 +154,8 @@ def configure_problem(): prob.model.nonlinear_solver = om.NewtonSolver(iprint=2, solve_subsystems=True) prob.model.linear_solver = om.DirectSolver() prob.model.nonlinear_solver.options["maxiter"] = 10 - prob.model.nonlinear_solver.options["atol"] = 1e-6 - prob.model.nonlinear_solver.options["rtol"] = 1e-6 + prob.model.nonlinear_solver.options["atol"] = 1e-10 + prob.model.nonlinear_solver.options["rtol"] = 1e-10 prob.model.nonlinear_solver.options["err_on_non_converge"] = True prob.model.nonlinear_solver.linesearch = om.BoundsEnforceLS(bound_enforcement="scalar", print_bound_enforce=False) diff --git a/openconcept/examples/B738_aerostructural.py b/openconcept/examples/B738_aerostructural.py index 1d0e999e..d8f90f7a 100644 --- a/openconcept/examples/B738_aerostructural.py +++ b/openconcept/examples/B738_aerostructural.py @@ -324,8 +324,8 @@ def configure_problem(num_nodes): prob.model.nonlinear_solver = om.NewtonSolver(iprint=2, solve_subsystems=True) prob.model.linear_solver = om.DirectSolver() prob.model.nonlinear_solver.options["maxiter"] = 10 - prob.model.nonlinear_solver.options["atol"] = 1e-6 - prob.model.nonlinear_solver.options["rtol"] = 1e-6 + prob.model.nonlinear_solver.options["atol"] = 1e-10 + prob.model.nonlinear_solver.options["rtol"] = 1e-10 prob.model.nonlinear_solver.options["err_on_non_converge"] = True prob.model.nonlinear_solver.linesearch = om.BoundsEnforceLS(bound_enforcement="scalar", print_bound_enforce=True) diff --git a/openconcept/examples/B738_sizing.py b/openconcept/examples/B738_sizing.py new file mode 100644 index 00000000..5e956829 --- /dev/null +++ b/openconcept/examples/B738_sizing.py @@ -0,0 +1,586 @@ +""" +@File : B738_sizing.py +@Date : 2023/03/25 +@Author : Eytan Adler +@Description : Boeing 737-800 estimate using empirical weight and drag buildups +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== + +# ============================================================================== +# External Python modules +# ============================================================================== +import numpy as np +import openmdao.api as om + +# ============================================================================== +# Extension modules +# ============================================================================== +from openconcept.examples.aircraft_data.B738_sizing import data as acdata +from openconcept.aerodynamics import PolarDrag, ParasiteDragCoefficient_JetTransport, CleanCLmax, FlapCLmax +from openconcept.propulsion import RubberizedTurbofan +from openconcept.geometry import CylinderSurfaceArea, WingMACTrapezoidal +from openconcept.stability import HStabVolumeCoefficientSizing, VStabVolumeCoefficientSizing +from openconcept.weights import JetTransportEmptyWeight +from openconcept.mission import FullMissionWithReserve +from openconcept.utilities import Integrator, AddSubtractComp, ElementMultiplyDivideComp, DictIndepVarComp + + +class B738AircraftModel(om.Group): + """ + A Boeing 737-800 aircraft model group. Instead of using known weight + and drag estimates of the existing airplane, this group uses empirical + weight and drag buildups to enable the design of clean sheet aircraft. + """ + + def initialize(self): + self.options.declare("num_nodes", default=1, types=int, desc="Number of analysis points to run") + self.options.declare("flight_phase", default=None, types=str, desc="Phase of mission this group lives in") + + def setup(self): + nn = self.options["num_nodes"] + phase = self.options["flight_phase"] + in_takeoff = phase in ["v0v1", "v1v0", "v1vr", "rotate"] + + # ============================================================================== + # Aerodynamics + # ============================================================================== + # -------------- Zero-lift drag coefficient buildup -------------- + drag_buildup_promotes = [ + "fltcond|Utrue", + "fltcond|rho", + "fltcond|T", + "ac|geom|fuselage|length", + "ac|geom|fuselage|height", + "ac|geom|fuselage|S_wet", + "ac|geom|hstab|S_ref", + "ac|geom|hstab|AR", + "ac|geom|hstab|taper", + "ac|geom|hstab|toverc", + "ac|geom|vstab|S_ref", + "ac|geom|vstab|AR", + "ac|geom|vstab|taper", + "ac|geom|vstab|toverc", + "ac|geom|wing|S_ref", + "ac|geom|wing|AR", + "ac|geom|wing|taper", + "ac|geom|wing|toverc", + "ac|geom|nacelle|length", + "ac|geom|nacelle|S_wet", + "ac|propulsion|num_engines", + ] + if in_takeoff: + drag_buildup_promotes += ["ac|aero|takeoff_flap_deg", "ac|geom|wing|c4sweep"] + self.add_subsystem( + "zero_lift_drag", + ParasiteDragCoefficient_JetTransport(num_nodes=nn, configuration="takeoff" if in_takeoff else "clean"), + promotes_inputs=drag_buildup_promotes, + ) + + # -------------- Drag polar -------------- + self.add_subsystem( + "drag_polar", + PolarDrag(num_nodes=nn, vec_CD0=True), + promotes_inputs=[ + "fltcond|CL", + "fltcond|q", + "ac|geom|wing|S_ref", + "ac|geom|wing|AR", + ("e", "ac|aero|polar|e"), + ], + promotes_outputs=["drag"], + ) + self.connect("zero_lift_drag.CD0", "drag_polar.CD0") + + # ============================================================================== + # Propulsion + # ============================================================================== + # -------------- CFM56 engine surrogate model -------------- + self.add_subsystem( + "CFM56", + RubberizedTurbofan(num_nodes=nn, engine="CFM56"), + promotes_inputs=["throttle", "fltcond|h", "fltcond|M", "ac|propulsion|engine|rating"], + ) + + # -------------- Multiply fuel flow and thrust by the number of active engines -------------- + # propulsor_active is 0 if failed engine and 1 otherwise, so + # num active engines = num engines - 1 + propulsor_active + self.add_subsystem( + "num_engine_calc", + AddSubtractComp( + output_name="num_active_engines", + input_names=["num_engines", "propulsor_active", "one"], + vec_size=[1, nn, 1], + scaling_factors=[1, 1, -1], + ), + promotes_inputs=[("num_engines", "ac|propulsion|num_engines"), "propulsor_active"], + ) + self.set_input_defaults("num_engine_calc.one", 1.0) + + prop_mult = self.add_subsystem( + "propulsion_multiplier", ElementMultiplyDivideComp(), promotes_outputs=["thrust"] + ) + prop_mult.add_equation( + output_name="thrust", + input_names=["thrust_per_engine", "num_active_engines_1"], + vec_size=nn, + input_units=["lbf", None], + ) + prop_mult.add_equation( + output_name="fuel_flow", + input_names=["fuel_flow_per_engine", "num_active_engines_2"], + vec_size=nn, + input_units=["kg/s", None], + ) + self.connect("CFM56.fuel_flow", "propulsion_multiplier.fuel_flow_per_engine") + self.connect("CFM56.thrust", "propulsion_multiplier.thrust_per_engine") + + # This hacky thing is necessary to enable two equations to pull from the same input + self.connect( + "num_engine_calc.num_active_engines", + ["propulsion_multiplier.num_active_engines_1", "propulsion_multiplier.num_active_engines_2"], + ) + + # ============================================================================== + # Weight + # ============================================================================== + # -------------- Integrate fuel burn -------------- + integ = self.add_subsystem( + "fuel_burn_integ", Integrator(num_nodes=nn, diff_units="s", method="simpson", time_setup="duration") + ) + integ.add_integrand( + "fuel_burn", + rate_name="fuel_flow", + rate_units="kg/s", + lower=0.0, + upper=1e6, + ) + self.connect("propulsion_multiplier.fuel_flow", "fuel_burn_integ.fuel_flow") + + # -------------- Subtract fuel burn from takeoff weight -------------- + self.add_subsystem( + "weight_calc", + AddSubtractComp( + output_name="weight", + input_names=["ac|weights|MTOW", "fuel_burn"], + scaling_factors=[1, -1], + vec_size=[1, nn], + units="kg", + ), + promotes_inputs=["ac|weights|MTOW"], + promotes_outputs=["weight"], + ) + self.connect("fuel_burn_integ.fuel_burn", "weight_calc.fuel_burn") + + +class B738SizingMissionAnalysis(om.Group): + """ + Group that performs mission analysis, geometry calculations, and operating empty weight estimate. + """ + + def initialize(self): + self.options.declare("num_nodes", default=11, types=int, desc="Analysis points per mission phase") + + def setup(self): + nn = self.options["num_nodes"] + + # ============================================================================== + # Variables from B738_sizing data file + # ============================================================================== + dv = self.add_subsystem("ac_vars", DictIndepVarComp(acdata), promotes_outputs=["*"]) + dv_outputs = [ + # -------------- Aero -------------- + "ac|aero|polar|e", + "ac|aero|Mach_max", + "ac|aero|Vstall_land", + "ac|aero|airfoil_Cl_max", + "ac|aero|takeoff_flap_deg", + # -------------- Propulsion -------------- + "ac|propulsion|engine|rating", + "ac|propulsion|num_engines", + # -------------- Geometry -------------- + # Wing + "ac|geom|wing|S_ref", + "ac|geom|wing|AR", + "ac|geom|wing|c4sweep", + "ac|geom|wing|taper", + "ac|geom|wing|toverc", + # Horizontal stabilizer + "ac|geom|hstab|AR", + "ac|geom|hstab|c4sweep", + "ac|geom|hstab|taper", + "ac|geom|hstab|toverc", + # Vertical stabilizer + "ac|geom|vstab|AR", + "ac|geom|vstab|c4sweep", + "ac|geom|vstab|taper", + "ac|geom|vstab|toverc", + # Fuselage + "ac|geom|fuselage|length", + "ac|geom|fuselage|height", + # Nacelle + "ac|geom|nacelle|length", + "ac|geom|nacelle|diameter", + # Main gear + "ac|geom|maingear|length", + "ac|geom|maingear|num_wheels", + "ac|geom|maingear|num_shock_struts", + # Nose gear + "ac|geom|nosegear|length", + "ac|geom|nosegear|num_wheels", + # -------------- Weights -------------- + "ac|weights|W_payload", + # -------------- Miscellaneous -------------- + "ac|num_passengers_max", + "ac|num_flight_deck_crew", + "ac|num_cabin_crew", + "ac|cabin_pressure", + ] + for output_name in dv_outputs: + dv.add_output_from_dict(output_name) + + # ============================================================================== + # Geometry + # ============================================================================== + # -------------- Estimate wing to tail quarter chord as half fuselage length -------------- + self.add_subsystem( + "tail_lever_arm_estimate", + AddSubtractComp( + output_name="c4_to_wing_c4", + input_names=["fuselage_length"], + units="m", + scaling_factors=[0.5], + ), + promotes_inputs=[("fuselage_length", "ac|geom|fuselage|length")], + ) + self.connect( + "tail_lever_arm_estimate.c4_to_wing_c4", ["ac|geom|hstab|c4_to_wing_c4", "ac|geom|vstab|c4_to_wing_c4"] + ) + + # -------------- Compute mean aerodynamic chord assuming trapezoidal wing -------------- + self.add_subsystem( + "wing_MAC", + WingMACTrapezoidal(), + promotes_inputs=[ + ("S_ref", "ac|geom|wing|S_ref"), + ("AR", "ac|geom|wing|AR"), + ("taper", "ac|geom|wing|taper"), + ], + promotes_outputs=[("MAC", "ac|geom|wing|MAC")], + ) + + # -------------- Vertical and horizontal tail area -------------- + self.add_subsystem( + "vstab_area", + VStabVolumeCoefficientSizing(), + promotes_inputs=["ac|geom|wing|S_ref", "ac|geom|wing|AR", "ac|geom|vstab|c4_to_wing_c4"], + promotes_outputs=["ac|geom|vstab|S_ref"], + ) + self.add_subsystem( + "hstab_area", + HStabVolumeCoefficientSizing(), + promotes_inputs=["ac|geom|wing|S_ref", "ac|geom|wing|MAC", "ac|geom|hstab|c4_to_wing_c4"], + promotes_outputs=["ac|geom|hstab|S_ref"], + ) + + # -------------- Compute the fuselage and nacelle wetted areas assuming a cylinder -------------- + self.add_subsystem( + "nacelle_wetted_area", + CylinderSurfaceArea(), + promotes_inputs=[("L", "ac|geom|nacelle|length"), ("D", "ac|geom|nacelle|diameter")], + promotes_outputs=[("A", "ac|geom|nacelle|S_wet")], + ) + self.add_subsystem( + "fuselage_wetted_area", + CylinderSurfaceArea(), + promotes_inputs=[("L", "ac|geom|fuselage|length"), ("D", "ac|geom|fuselage|height")], + promotes_outputs=[("A", "ac|geom|fuselage|S_wet")], + ) + + # ============================================================================== + # Operating empty weight + # ============================================================================== + # Estimate MLW as 80% of MTOW so it's not necessary to know apriori. + # MLW is used only for the landing gear weight estimate, so it's accuracy isn't hugely important. + self.add_subsystem( + "MLW_calc", + AddSubtractComp( + output_name="ac|weights|MLW", + input_names=["ac|weights|MTOW"], + units="kg", + scaling_factors=[0.8], + ), + promotes_inputs=["ac|weights|MTOW"], + promotes_outputs=["ac|weights|MLW"], + ) + + self.add_subsystem( + "empty_weight", + JetTransportEmptyWeight(), + promotes_inputs=[ + "ac|num_passengers_max", + "ac|num_flight_deck_crew", + "ac|num_cabin_crew", + "ac|cabin_pressure", + "ac|aero|Mach_max", + "ac|aero|Vstall_land", + "ac|geom|wing|S_ref", + "ac|geom|wing|AR", + "ac|geom|wing|c4sweep", + "ac|geom|wing|taper", + "ac|geom|wing|toverc", + "ac|geom|hstab|S_ref", + "ac|geom|hstab|AR", + "ac|geom|hstab|c4sweep", + "ac|geom|hstab|c4_to_wing_c4", + "ac|geom|vstab|S_ref", + "ac|geom|vstab|AR", + "ac|geom|vstab|c4sweep", + "ac|geom|vstab|toverc", + "ac|geom|vstab|c4_to_wing_c4", + "ac|geom|fuselage|height", + "ac|geom|fuselage|length", + "ac|geom|fuselage|S_wet", + "ac|geom|maingear|length", + "ac|geom|maingear|num_wheels", + "ac|geom|maingear|num_shock_struts", + "ac|geom|nosegear|length", + "ac|geom|nosegear|num_wheels", + "ac|propulsion|engine|rating", + "ac|propulsion|num_engines", + "ac|weights|MTOW", + "ac|weights|MLW", + ], + promotes_outputs=[("OEW", "ac|weights|OEW")], + ) + + # ============================================================================== + # CL max in cruise and takeoff + # ============================================================================== + self.add_subsystem( + "CL_max_cruise", + CleanCLmax(), + promotes_inputs=["ac|aero|airfoil_Cl_max", "ac|geom|wing|c4sweep"], + promotes_outputs=[("CL_max_clean", "ac|aero|CLmax_cruise")], + ) + self.add_subsystem( + "CL_max_takeoff", + FlapCLmax(), + promotes_inputs=[ + ("flap_extension", "ac|aero|takeoff_flap_deg"), + "ac|geom|wing|c4sweep", + "ac|geom|wing|toverc", + ("CL_max_clean", "ac|aero|CLmax_cruise"), + ], + promotes_outputs=[("CL_max_flap", "ac|aero|CLmax_TO")], + ) + + # ============================================================================== + # Remaining misc stuff and input settings + # ============================================================================== + # -------------- Set MTOW to OEW + payload + fuel burn -------------- + self.add_subsystem( + "MTOW_calc", + AddSubtractComp( + output_name="MTOW", + input_names=["OEW", "W_payload", "W_fuel"], + units="kg", + lower=1e-6, + ), + promotes_inputs=[("OEW", "ac|weights|OEW"), ("W_payload", "ac|weights|W_payload")], + promotes_outputs=[("MTOW", "ac|weights|MTOW")], + ) + self.connect( + "mission.loiter.fuel_burn_integ.fuel_burn_final", + ["MTOW_calc.W_fuel", "empty_weight.ac|weights|W_fuel_max"], + ) + + # -------------- Initial guesses for important solver states for better performance -------------- + self.set_input_defaults("ac|weights|MTOW", 50e3, units="kg") + for fuel_burn_var in ["MTOW_calc.W_fuel", "empty_weight.ac|weights|W_fuel_max"]: + self.set_input_defaults(fuel_burn_var, 30e3, units="kg") + + # ============================================================================== + # Mission analysis + # ============================================================================== + self.add_subsystem( + "mission", + FullMissionWithReserve(num_nodes=nn, aircraft_model=B738AircraftModel), + promotes_inputs=["ac|*"], + ) + + +def set_mission_profile(prob): + """ + Set the parameters in the OpenMDAO problem that define the mission profile. + + Parameters + ---------- + prob : OpenMDAO Problem + Problem with B378MissionAnalysis as model in which to set values + """ + # Get the number of nodes in the mission + nn = prob.model.options["num_nodes"] + + # ============================================================================== + # Basic mission phases + # ============================================================================== + # -------------- Climb -------------- + prob.set_val("mission.climb.fltcond|vs", np.linspace(2300.0, 400.0, nn), units="ft/min") + prob.set_val("mission.climb.fltcond|Ueas", np.linspace(230, 252, nn), units="kn") + + # -------------- Cruise -------------- + prob.set_val("mission.cruise.fltcond|vs", np.full((nn,), 0.0), units="ft/min") + prob.set_val("mission.cruise.fltcond|Ueas", np.linspace(252, 252, nn), units="kn") + + # -------------- Descent -------------- + prob.set_val("mission.descent.fltcond|vs", np.linspace(-1300, -800, nn), units="ft/min") + prob.set_val("mission.descent.fltcond|Ueas", np.linspace(252, 250, nn), units="kn") + + # ============================================================================== + # Reserve mission phases + # ============================================================================== + # -------------- Reserve climb -------------- + prob.set_val("mission.reserve_climb.fltcond|vs", np.linspace(3000.0, 2300.0, nn), units="ft/min") + prob.set_val("mission.reserve_climb.fltcond|Ueas", np.linspace(230, 230, nn), units="kn") + + # -------------- Reserve cruise -------------- + prob.set_val("mission.reserve_cruise.fltcond|vs", np.full((nn,), 0.0), units="ft/min") + prob.set_val("mission.reserve_cruise.fltcond|Ueas", np.linspace(250, 250, nn), units="kn") + + # -------------- Reserve descent -------------- + prob.set_val("mission.reserve_descent.fltcond|vs", np.linspace(-800, -800, nn), units="ft/min") + prob.set_val("mission.reserve_descent.fltcond|Ueas", np.full((nn,), 250.0), units="kn") + + # -------------- Loiter -------------- + prob.set_val("mission.loiter.fltcond|vs", np.linspace(0.0, 0.0, nn), units="ft/min") + prob.set_val("mission.loiter.fltcond|Ueas", np.full((nn,), 250.0), units="kn") + + # ============================================================================== + # Other parameters + # ============================================================================== + prob.set_val("mission.cruise|h0", 35000.0, units="ft") + prob.set_val("mission.reserve|h0", 15000.0, units="ft") + prob.set_val("mission.mission_range", 2800, units="nmi") + + # -------------- Set takeoff speed guesses to improve solver performance -------------- + prob.set_val("mission.v0v1.fltcond|Utrue", np.full((nn,), 100.0), units="kn") + prob.set_val("mission.v1vr.fltcond|Utrue", np.full((nn,), 100.0), units="kn") + prob.set_val("mission.v1v0.fltcond|Utrue", np.full((nn,), 100.0), units="kn") + + # Converge the model first with an easier mission profile and work up to the intended + # mission profile. This is needed to help the Newton solver converge the actual mission. + prob.set_val("mission.descent.fltcond|vs", np.linspace(-800, -800, nn), units="ft/min") + prob.set_val("mission.cruise|h0", 5000.0, units="ft") + prob.set_val("mission.reserve|h0", 1000.0, units="ft") + prob.set_val("mission.mission_range", 500, units="nmi") + prob.set_val("mission.reserve_range", 100, units="nmi") + prob.run_model() + + # Almost there, just not quite with descent rate + prob.set_val("mission.cruise|h0", 35000.0, units="ft") + prob.set_val("mission.reserve|h0", 15000.0, units="ft") + prob.set_val("mission.mission_range", 2800, units="nmi") + prob.set_val("mission.reserve_range", 200, units="nmi") + prob.run_model() + + # Finally, set the descent rate we want + prob.set_val("mission.descent.fltcond|vs", np.linspace(-1300, -800, nn), units="ft/min") + + +def plot_results(prob, filename=None): + """ + Make a plot with the results of the mission analysis. + + Parameters + ---------- + prob : OpenMDAO Problem + Problem with B738SizingMissionAnalysis model that has been run + filename : str (optional) + Filename to save to, by default will show plot + """ + import matplotlib.pyplot as plt + from matplotlib.ticker import FuncFormatter + + fig, axs = plt.subplots(2, 3, figsize=(11, 6)) + axs = axs.flatten() + + for phase in ["climb", "cruise", "descent", "reserve_climb", "reserve_cruise", "reserve_descent", "loiter"]: + dist = prob.get_val(f"mission.{phase}.range", units="nmi") + + axs[0].plot(dist, prob.get_val(f"mission.{phase}.fltcond|h", units="ft"), color="tab:blue") + axs[1].plot(dist, prob.get_val(f"mission.{phase}.fltcond|M"), color="tab:blue") + axs[2].plot(dist, prob.get_val(f"mission.{phase}.fltcond|vs", units="ft/min"), color="tab:blue") + axs[3].plot(dist, prob.get_val(f"mission.{phase}.weight", units="lb"), color="tab:blue") + axs[4].plot(dist, prob.get_val(f"mission.{phase}.drag", units="lbf"), color="tab:blue") + axs[4].plot(dist, prob.get_val(f"mission.{phase}.thrust", units="lbf"), color="tab:orange") + axs[5].plot(dist, prob.get_val(f"mission.{phase}.throttle") * 100, color="tab:blue") + + axs[0].set_ylabel("Altitude (ft)") + axs[1].set_ylabel("Mach number") + axs[2].set_ylabel("Vertical speed (ft/min)") + axs[3].set_ylabel("Weight (lb)") + axs[4].set_ylabel("Longitudinal force (lb)") + axs[5].set_ylabel("Throttle (%)") + axs[4].legend(["Drag", "Thrust"]) + for i in range(6): + axs[i].set_xlabel("Distance flown (nmi)") + axs[i].spines[["right", "top"]].set_visible(False) + if i != 1: + axs[i].get_yaxis().set_major_formatter(FuncFormatter(lambda x, p: format(int(x), ","))) + axs[i].get_xaxis().set_major_formatter(FuncFormatter(lambda x, p: format(int(x), ","))) + + plt.tight_layout() + + if filename is None: + plt.show() + else: + fig.savefig(filename) + + +def run_738_sizing_analysis(num_nodes=21): + p = om.Problem() + p.model = B738SizingMissionAnalysis(num_nodes=num_nodes) + + # -------------- Add solvers -------------- + p.model.nonlinear_solver = om.NewtonSolver() + p.model.nonlinear_solver.options["iprint"] = 2 + p.model.nonlinear_solver.options["solve_subsystems"] = True + p.model.nonlinear_solver.options["maxiter"] = 20 + p.model.nonlinear_solver.options["atol"] = 1e-9 + p.model.nonlinear_solver.options["rtol"] = 1e-9 + + p.model.linear_solver = om.DirectSolver() + + p.model.nonlinear_solver.linesearch = om.BoundsEnforceLS() + p.model.nonlinear_solver.linesearch.options["print_bound_enforce"] = False + + p.setup() + + set_mission_profile(p) + + p.run_model() + + return p + + +if __name__ == "__main__": + p = run_738_sizing_analysis() + om.n2(p, show_browser=False) + + # Print some useful numbers + print("\n\n================= Computed values =================") + print(f"MTOW: {p.get_val('ac|weights|MTOW', units='lb').item():.1f} lb") + print(f"Payload weight: {p.get_val('ac|weights|W_payload', units='lb').item()} lb") + print(f"OEW: {p.get_val('ac|weights|OEW', units='lb').item():.1f} lb") + print(f"Fuel burned: {p.get_val('mission.descent.fuel_burn_integ.fuel_burn_final', units='lb').item():.1f} lb") + print(f"CL max cruise: {p.get_val('ac|aero|CLmax_cruise').item():.3f}") + print(f"CL max takeoff: {p.get_val('ac|aero|CLmax_TO').item():.3f}") + print(f"Balanced field length (continue): {p.get_val('mission.bfl.distance_continue', units='ft').item():.1f} ft") + print( + f"Balanced field length (abort): {p.get_val('mission.bfl.distance_abort', units='ft').item():.1f} ft (this should be the same as continue)" + ) + + plot_results(p, filename="plot.pdf") diff --git a/openconcept/examples/Caravan.py b/openconcept/examples/Caravan.py index 1fa8af0c..5307548c 100644 --- a/openconcept/examples/Caravan.py +++ b/openconcept/examples/Caravan.py @@ -144,8 +144,8 @@ def run_caravan_analysis(): prob.model.nonlinear_solver = om.NewtonSolver(iprint=2, solve_subsystems=True) prob.model.linear_solver = om.DirectSolver() prob.model.nonlinear_solver.options["maxiter"] = 20 - prob.model.nonlinear_solver.options["atol"] = 1e-6 - prob.model.nonlinear_solver.options["rtol"] = 1e-6 + prob.model.nonlinear_solver.options["atol"] = 1e-10 + prob.model.nonlinear_solver.options["rtol"] = 1e-10 prob.model.nonlinear_solver.linesearch = om.BoundsEnforceLS(bound_enforcement="scalar", print_bound_enforce=False) prob.setup(check=True, mode="fwd") diff --git a/openconcept/examples/HybridTwin.py b/openconcept/examples/HybridTwin.py index 6e933bf3..0bb0becc 100644 --- a/openconcept/examples/HybridTwin.py +++ b/openconcept/examples/HybridTwin.py @@ -213,8 +213,8 @@ def configure_problem(): prob.model.linear_solver = DirectSolver(assemble_jac=True) prob.model.nonlinear_solver.options["solve_subsystems"] = True prob.model.nonlinear_solver.options["maxiter"] = 10 - prob.model.nonlinear_solver.options["atol"] = 1e-7 - prob.model.nonlinear_solver.options["rtol"] = 1e-7 + prob.model.nonlinear_solver.options["atol"] = 1e-10 + prob.model.nonlinear_solver.options["rtol"] = 1e-10 return prob diff --git a/openconcept/examples/aircraft_data/B738_sizing.py b/openconcept/examples/aircraft_data/B738_sizing.py new file mode 100644 index 00000000..342c5266 --- /dev/null +++ b/openconcept/examples/aircraft_data/B738_sizing.py @@ -0,0 +1,99 @@ +""" +@File : B738_sizing.py +@Date : 2023/03/25 +@Author : Eytan Adler +@Description : Data needed B738_sizing OpenConcept example. The data is from a combination of: +- Technical site: http://www.b737.org.uk/techspecsdetailed.htm +- Wikipedia: https://en.wikipedia.org/wiki/Boeing_737#Specifications +- OpenConcept B738 model +""" + + +data = { + "ac": { + # ============================================================================== + # Aerodynamics + # ============================================================================== + "aero": { + "polar": { + "e": {"value": 0.801}, # estimate from B738 example + }, + "Mach_max": {"value": 0.82}, + "Vstall_land": {"value": 115, "units": "kn"}, # estimate + "airfoil_Cl_max": {"value": 1.75}, # estimate for supercritical airfoil + "takeoff_flap_deg": {"value": 15, "units": "deg"}, + }, + # ============================================================================== + # Propulsion + # ============================================================================== + "propulsion": { + "engine": { + "rating": {"value": 27e3, "units": "lbf"}, + }, + "num_engines": {"value": 2}, + }, + # ============================================================================== + # Geometry + # ============================================================================== + "geom": { + # -------------- Wing -------------- + "wing": { + "S_ref": {"value": 124.6, "units": "m**2"}, + "AR": {"value": 9.45}, + "c4sweep": {"value": 25, "units": "deg"}, + "taper": {"value": 0.159}, + "toverc": {"value": 0.12}, # estimate + }, + # -------------- Horizontal stabilizer -------------- + "hstab": { + # "S_ref": {"value": 32.78, "units": "m**2"}, # not needed since tail volume coefficients are used + "AR": {"value": 6.16}, + "c4sweep": {"value": 30, "units": "deg"}, + "taper": {"value": 0.203}, + "toverc": {"value": 0.12}, # guess + }, + # -------------- Vertical stabilizer -------------- + "vstab": { + # "S_ref": {"value": 26.44, "units": "m**2"}, # not needed since tail volume coefficients are used + "AR": {"value": 1.91}, + "c4sweep": {"value": 35, "units": "deg"}, + "taper": {"value": 0.271}, + "toverc": {"value": 0.12}, # guess + }, + # -------------- Fuselage -------------- + "fuselage": { + "length": {"value": 38.08, "units": "m"}, + "height": {"value": 3.76, "units": "m"}, + }, + # -------------- Nacelle -------------- + "nacelle": { + "length": {"value": 4.3, "units": "m"}, # photogrammetry estimate + "diameter": {"value": 2, "units": "m"}, # photogrammetry estimate + }, + # -------------- Main landing gear -------------- + "maingear": { + "length": {"value": 1.8, "units": "m"}, + "num_wheels": {"value": 4}, + "num_shock_struts": {"value": 2}, + }, + # -------------- Nose landing gear -------------- + "nosegear": { + "length": {"value": 1.3, "units": "m"}, + "num_wheels": {"value": 2}, + }, + }, + # ============================================================================== + # Weights + # ============================================================================== + "weights": { + "W_payload": {"value": 18e3, "units": "kg"}, + }, + # ============================================================================== + # Miscellaneous + # ============================================================================== + "num_passengers_max": {"value": 189}, + "num_flight_deck_crew": {"value": 2}, + "num_cabin_crew": {"value": 4}, + "cabin_pressure": {"value": 8.95, "units": "psi"}, + }, +} diff --git a/openconcept/examples/tests/test_example_aircraft.py b/openconcept/examples/tests/test_example_aircraft.py index 0d9b812a..a2e59c9c 100644 --- a/openconcept/examples/tests/test_example_aircraft.py +++ b/openconcept/examples/tests/test_example_aircraft.py @@ -11,6 +11,7 @@ from openconcept.examples.N3_HybridSingleAisle_Refrig import run_hybrid_sa_analysis from openconcept.examples.minimal import setup_problem as setup_minimal_problem from openconcept.examples.minimal_integrator import MissionAnalysisWithFuelBurn as MinimalIntegratorMissionAnalysis +from openconcept.examples.B738_sizing import run_738_sizing_analysis try: from openconcept.examples.B738_VLM_drag import run_738_analysis as run_738VLM_analysis @@ -153,6 +154,28 @@ def test_values_B738(self): # changelog: 9/2020 - previously 34555.313, updated CFM surrogate model to reject spurious high Mach, low altitude points +class B738SizingTestCase(unittest.TestCase): + def setUp(self): + self.prob = run_738_sizing_analysis(num_nodes=5) + + def test_values_B738(self): + prob = self.prob + # block fuel + assert_near_equal( + prob.get_val("mission.descent.fuel_burn_integ.fuel_burn_final", units="lbm"), + 35213.7673772348, + tolerance=1e-4, + ) + # total fuel + assert_near_equal( + prob.get_val("mission.loiter.fuel_burn_integ.fuel_burn_final", units="lbm"), + 40991.187944303405, + tolerance=1e-4, + ) + # MTOW + assert_near_equal(prob.get_val("ac|weights|MTOW", units="lbm"), 172711.3034007032, tolerance=1e-4) + + @unittest.skipIf(not OAS_installed, "OpenAeroStruct is not installed") class B738VLMTestCase(unittest.TestCase): def setUp(self): diff --git a/openconcept/geometry/__init__.py b/openconcept/geometry/__init__.py new file mode 100644 index 00000000..ef4f4bc3 --- /dev/null +++ b/openconcept/geometry/__init__.py @@ -0,0 +1,9 @@ +from .wing_planform import ( + WingMACTrapezoidal, + WingSpan, + WingAspectRatio, + WingSweepFromSections, + WingAreaFromSections, + WingMACFromSections, +) +from .wetted_area import CylinderSurfaceArea diff --git a/openconcept/geometry/tests/test_wetted_area.py b/openconcept/geometry/tests/test_wetted_area.py new file mode 100644 index 00000000..adeb4e7d --- /dev/null +++ b/openconcept/geometry/tests/test_wetted_area.py @@ -0,0 +1,29 @@ +import unittest +import numpy as np +import openmdao.api as om +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +from openconcept.geometry import CylinderSurfaceArea + + +class CylinderSurfaceAreaTestCase(unittest.TestCase): + def test(self): + p = om.Problem() + p.model.add_subsystem("comp", CylinderSurfaceArea(), promotes=["*"]) + p.setup(force_alloc_complex=True) + + L = 10 + D = 7 + + p.set_val("L", L, units="m") + p.set_val("D", D, units="m") + + p.run_model() + + assert_near_equal(p.get_val("A", units="m**2"), np.pi * L * D) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/geometry/tests/test_wing_planform.py b/openconcept/geometry/tests/test_wing_planform.py new file mode 100644 index 00000000..7edfc643 --- /dev/null +++ b/openconcept/geometry/tests/test_wing_planform.py @@ -0,0 +1,331 @@ +import unittest +import numpy as np +import openmdao.api as om +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +from openconcept.geometry import ( + WingMACTrapezoidal, + WingSpan, + WingAspectRatio, + WingSweepFromSections, + WingAreaFromSections, + WingMACFromSections, +) + + +class WingMACTrapezoidalTestCase(unittest.TestCase): + def test_rectangular(self): + """ + Test a rectangular wing. + """ + p = om.Problem() + p.model.add_subsystem("comp", WingMACTrapezoidal(), promotes=["*"]) + p.setup(force_alloc_complex=True) + + b = 10 + c = 1 + + p.set_val("S_ref", b * c, units="m**2") + p.set_val("AR", b / c) + p.set_val("taper", 1.0) + + p.run_model() + + assert_near_equal(p.get_val("MAC", units="m"), c) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + def test_tapered(self): + """ + Test a tapered wing. + """ + p = om.Problem() + p.model.add_subsystem("comp", WingMACTrapezoidal(), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("S_ref", 10, units="m**2") + p.set_val("AR", 10) + p.set_val("taper", 0.3) + + p.run_model() + + assert_near_equal(p.get_val("MAC", units="m"), 1.09664694, tolerance=1e-8) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + +class WingSpanTestCase(unittest.TestCase): + def test(self): + p = om.Problem() + p.model.add_subsystem("comp", WingSpan(), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("S_ref", 10, units="m**2") + p.set_val("AR", 2.5) + + p.run_model() + + assert_near_equal(p.get_val("span", units="m"), 5.0) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + +class WingAspectRatioTestCase(unittest.TestCase): + def test(self): + p = om.Problem() + p.model.add_subsystem("comp", WingAspectRatio(), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("S_ref", 10, units="m**2") + p.set_val("span", 5.0, units="m") + + p.run_model() + + assert_near_equal(p.get_val("AR"), 2.5) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + +class WingSweepFromSectionsTestCase(unittest.TestCase): + def test_no_sweep(self): + p = om.Problem() + p.model.add_subsystem("comp", WingSweepFromSections(num_sections=2), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("x_LE_sec", [0, 0], units="m") + p.set_val("y_sec", -1.0, units="m") + p.set_val("chord_sec", [1.0, 1.0], units="m") + + p.run_model() + + assert_near_equal(p.get_val("c4sweep", units="deg"), 0.0) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + def test_sweep(self): + p = om.Problem() + p.model.add_subsystem("comp", WingSweepFromSections(num_sections=2), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("x_LE_sec", [1, 0], units="m") + p.set_val("y_sec", -1.0, units="m") + p.set_val("chord_sec", [0.5, 1.0], units="m") + + p.run_model() + + assert_near_equal(p.get_val("c4sweep", units="deg"), np.rad2deg(np.arctan(0.875))) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + def test_sweep_three_sections(self): + p = om.Problem() + p.model.add_subsystem("comp", WingSweepFromSections(num_sections=3), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("x_LE_sec", [0, 1, 0], units="m") + p.set_val("y_sec", [-2, -1.0], units="m") + p.set_val("chord_sec", [0.1, 0.5, 1.0], units="m") + + p.run_model() + + assert_near_equal(p.get_val("c4sweep", units="deg"), 43.1348109, tolerance=1e-8) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + def test_indices(self): + p = om.Problem() + p.model.add_subsystem( + "comp", WingSweepFromSections(num_sections=4, idx_sec_start=1, idx_sec_end=2), promotes=["*"] + ) + p.setup(force_alloc_complex=True) + + p.set_val("x_LE_sec", [1, 0, 1, 0], units="m") + p.set_val("y_sec", [-3, -2, -1], units="m") + p.set_val("chord_sec", [0.1, 1.0, 1.0, 1.5], units="m") + + p.run_model() + + assert_near_equal(p.get_val("c4sweep", units="deg"), 45) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + +class WingAreaFromSectionsTestCase(unittest.TestCase): + def test_no_sweep(self): + p = om.Problem() + p.model.add_subsystem("comp", WingAreaFromSections(num_sections=2), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("y_sec", -1.0, units="m") + p.set_val("chord_sec", [1.0, 1.0], units="m") + + p.run_model() + + assert_near_equal(p.get_val("S", units="m**2"), 2.0) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + def test_sweep(self): + p = om.Problem() + p.model.add_subsystem("comp", WingAreaFromSections(num_sections=2), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("y_sec", -1.0, units="m") + p.set_val("chord_sec", [0.5, 1.0], units="m") + + p.run_model() + + assert_near_equal(p.get_val("S", units="m**2"), 1.5) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + def test_sweep_three_sections(self): + p = om.Problem() + p.model.add_subsystem("comp", WingAreaFromSections(num_sections=3), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("y_sec", [-2, -1.0], units="m") + p.set_val("chord_sec", [0.1, 0.5, 1.0], units="m") + + p.run_model() + + assert_near_equal(p.get_val("S", units="m**2"), 2.1) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + def test_indices(self): + p = om.Problem() + p.model.add_subsystem( + "comp", + WingAreaFromSections( + num_sections=4, idx_sec_start=1, idx_sec_end=2, chord_frac_start=0.1, chord_frac_end=0.3 + ), + promotes=["*"], + ) + p.setup(force_alloc_complex=True) + + p.set_val("y_sec", [-2.5, -2, -1], units="m") + p.set_val("chord_sec", [0.1, 1.0, 1.0, 1.5], units="m") + + p.run_model() + + assert_near_equal(p.get_val("S", units="m**2"), 0.4) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + +class WingMACFromSectionsTestCase(unittest.TestCase): + def test_rectangular(self): + """ + MAC of rectangular wing is just chord and quarter MAC is at quarter chord. + """ + p = om.Problem() + p.model.add_subsystem("comp", WingMACFromSections(num_sections=2), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("x_LE_sec", [0, 0], units="m") + p.set_val("y_sec", -1.0, units="m") + p.set_val("chord_sec", [1.0, 1.0], units="m") + + p.run_model() + + assert_near_equal(p.get_val("MAC", units="m"), 1.0) + assert_near_equal(p.get_val("x_c4MAC", units="m"), 0.25) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + def test_tapered(self): + p = om.Problem() + p.model.add_subsystem("comp", WingMACFromSections(num_sections=2), promotes=["*"]) + p.setup(force_alloc_complex=True) + + Cr = 1.0 + taper = 0.5 + b = 4 + + p.set_val("x_LE_sec", [Cr * taper / 2, 0], units="m") + p.set_val("y_sec", -b / 2, units="m") + p.set_val("chord_sec", [Cr * taper, Cr], units="m") + + p.run_model() + + # Test against equation for trapezoidal wing MAC + MAC = 2 / 3 * Cr * (1 + taper + taper**2) / (1 + taper) + y_MAC = b / 6 * (1 + 2 * taper) / (1 + taper) + x_c4MAC = (y_MAC / (b / 2)) * (Cr * taper / 2) + 0.25 * MAC + + assert_near_equal(p.get_val("MAC", units="m"), MAC) + assert_near_equal(p.get_val("x_c4MAC", units="m"), x_c4MAC) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + def test_tapered_swept(self): + p = om.Problem() + p.model.add_subsystem("comp", WingMACFromSections(num_sections=2), promotes=["*"]) + p.setup(force_alloc_complex=True) + + Cr = 1.5 + taper = 0.5 + b = 4 + + p.set_val("x_LE_sec", [Cr, 0], units="m") + p.set_val("y_sec", -b / 2, units="m") + p.set_val("chord_sec", [Cr * taper, Cr], units="m") + + p.run_model() + + # Test against equation for trapezoidal wing MAC + MAC = 2 / 3 * Cr * (1 + taper + taper**2) / (1 + taper) + y_MAC = b / 6 * (1 + 2 * taper) / (1 + taper) + x_c4MAC = (y_MAC / (b / 2)) * (Cr) + 0.25 * MAC + + assert_near_equal(p.get_val("MAC", units="m"), MAC) + assert_near_equal(p.get_val("x_c4MAC", units="m"), x_c4MAC) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + def test_indices(self): + p = om.Problem() + p.model.add_subsystem( + "comp", WingMACFromSections(num_sections=4, idx_sec_start=1, idx_sec_end=2), promotes=["*"] + ) + p.setup(force_alloc_complex=True) + + Cr = 1.5 + taper = 0.5 + b = 4 + + p.set_val("x_LE_sec", [-10, Cr, 0, 4], units="m") + p.set_val("y_sec", [-2 * b, -b, -b / 2], units="m") + p.set_val("chord_sec", [5, Cr * taper, Cr, 0.1], units="m") + + p.run_model() + + # Test against equation for trapezoidal wing MAC + MAC = 2 / 3 * Cr * (1 + taper + taper**2) / (1 + taper) + y_MAC = b / 6 * (1 + 2 * taper) / (1 + taper) + x_c4MAC = (y_MAC / (b / 2)) * (Cr) + 0.25 * MAC + + assert_near_equal(p.get_val("MAC", units="m"), MAC) + assert_near_equal(p.get_val("x_c4MAC", units="m"), x_c4MAC) + + partials = p.check_partials(method="cs", step=1e-125, out_stream=None) + assert_check_partials(partials) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/geometry/wetted_area.py b/openconcept/geometry/wetted_area.py new file mode 100644 index 00000000..a432fc14 --- /dev/null +++ b/openconcept/geometry/wetted_area.py @@ -0,0 +1,35 @@ +import numpy as np +import openmdao.api as om + + +class CylinderSurfaceArea(om.ExplicitComponent): + """ + Compute the surface area of a cylinder. This can be + used to estimate the wetted area of a fuselage or + engine nacelle. + + Inputs + ------ + L : float + Cylinder length (scalar, m) + D : float + Cylinder diameter (scalar, m) + + Outputs + ------- + A : float + Cylinder surface area (scalar, sq m) + """ + + def setup(self): + self.add_input("L", units="m") + self.add_input("D", units="m") + self.add_output("A", units="m**2") + self.declare_partials("A", ["L", "D"]) + + def compute(self, inputs, outputs): + outputs["A"] = np.pi * inputs["D"] * inputs["L"] + + def compute_partials(self, inputs, J): + J["A", "L"] = np.pi * inputs["D"] + J["A", "D"] = np.pi * inputs["L"] diff --git a/openconcept/geometry/wing_planform.py b/openconcept/geometry/wing_planform.py new file mode 100644 index 00000000..dfdb60c6 --- /dev/null +++ b/openconcept/geometry/wing_planform.py @@ -0,0 +1,394 @@ +import numpy as np +import openmdao.api as om + + +class WingMACTrapezoidal(om.ExplicitComponent): + """ + Compute the mean aerodynamic chord of a trapezoidal planform. + + Inputs + ------ + S_ref : float + Wing planform area (scalar, sq m) + AR : float + Wing aspect ratio (scalar, dimensionless) + taper : float + Wing taper ratio (scalar, dimensionless) + + Outputs + ------- + MAC : float + Mean aerodynamic chord of the trapezoidal planform (scalar, m) + """ + + def setup(self): + self.add_input("S_ref", units="m**2") + self.add_input("AR") + self.add_input("taper") + self.add_output("MAC", lower=1e-6, units="m") + self.declare_partials("MAC", "*") + + def compute(self, inputs, outputs): + S = inputs["S_ref"] + AR = inputs["AR"] + taper = inputs["taper"] + + c_root = np.sqrt(S / AR) * 2 / (1 + taper) + c_tip = taper * c_root + outputs["MAC"] = 2 / 3 * (c_root + c_tip - c_root * c_tip / (c_root + c_tip)) + + def compute_partials(self, inputs, J): + S = inputs["S_ref"] + AR = inputs["AR"] + taper = inputs["taper"] + + c_root = np.sqrt(S / AR) * 2 / (1 + taper) + dcr_dS = 0.5 / np.sqrt(S * AR) * 2 / (1 + taper) + dcr_dAR = -0.5 * S**0.5 / AR**1.5 * 2 / (1 + taper) + dcr_dtaper = -np.sqrt(S / AR) * 2 / (1 + taper) ** 2 + + c_tip = taper * c_root + + dMAC_dcr = 2 / 3 * (1 - c_tip**2 / (c_root + c_tip) ** 2) + dMAC_dct = 2 / 3 * (1 - c_root**2 / (c_root + c_tip) ** 2) + + J["MAC", "S_ref"] = (dMAC_dcr + dMAC_dct * taper) * dcr_dS + J["MAC", "AR"] = (dMAC_dcr + dMAC_dct * taper) * dcr_dAR + J["MAC", "taper"] = (dMAC_dcr + dMAC_dct * taper) * dcr_dtaper + dMAC_dct * c_root + + +class WingSpan(om.ExplicitComponent): + """ + Compute the wing span as the square root of wing area times aspect ratio. + + Inputs + ------ + S_ref : float + Wing planform area (scalar, sq m) + AR : float + Wing aspect ratio (scalar, dimensionless) + + Outputs + ------- + span : float + Wing span (scalar, m) + """ + + def setup(self): + self.add_input("S_ref", units="m**2") + self.add_input("AR") + + self.add_output("span", units="m") + self.declare_partials(["span"], ["*"]) + + def compute(self, inputs, outputs): + b = inputs["S_ref"] ** 0.5 * inputs["AR"] ** 0.5 + outputs["span"] = b + + def compute_partials(self, inputs, J): + J["span", "S_ref"] = 0.5 * inputs["S_ref"] ** (0.5 - 1) * inputs["AR"] ** 0.5 + J["span", "AR"] = inputs["S_ref"] ** 0.5 * 0.5 * inputs["AR"] ** (0.5 - 1) + + +class WingAspectRatio(om.ExplicitComponent): + """ + Compute the aspect ratio from span and wing area. + + Inputs + ------ + S_ref : float + Planform area (scalar, sq m) + span : float + Wing span (scalar, m) + + Outputs + ------- + AR : float + Aspect ratio, weighted by section areas (scalar, deg) + """ + + def setup(self): + self.add_input("S_ref", units="m**2") + self.add_input("span", units="m") + self.add_output("AR", val=10.0, lower=1e-6) + self.declare_partials("*", "*") + + def compute(self, inputs, outputs): + outputs["AR"] = inputs["span"] ** 2 / inputs["S_ref"] + + def compute_partials(self, inputs, J): + J["AR", "span"] = 2 * inputs["span"] / inputs["S_ref"] + J["AR", "S_ref"] = -inputs["span"] ** 2 / inputs["S_ref"] ** 2 + + +class WingSweepFromSections(om.ExplicitComponent): + """ + Compute the average quarter chord sweep angle weighted by section areas + by taking in sectional parameters as they would be defined for a + sectional OpenAeroStruct mesh. The actual average is of the cosine of + the sweep angle, rather than the angle itself. This means that it will + always return a positive sweep angle (because it does an arccos), even + if the wing is forward swept. + + Inputs + ------ + x_LE_sec : float + Streamwise offset of the section's leading edge, starting with the outboard + section (wing tip) and moving inboard toward the root (vector of length + num_sections, m) + y_sec : float + Spanwise location of each section, starting with the outboard section (wing + tip) at the MOST NEGATIVE y value and moving inboard (increasing y value) + toward the root; the user does not provide a value for the root because it + is always 0.0 (vector of length num_sections - 1, m) + chord_sec : float + Chord of each section, starting with the outboard section (wing tip) and + moving inboard toward the root (vector of length num_sections, m) + + Outputs + ------- + c4sweep : float + Average quarter chord sweep, computed as the weighted average of + cos(section sweep angle) by section areas and then arccos of the + resulting quantity. This means it does not discriminate between + forward and backward sweep angles (scalar, deg) + + Options + ------- + num_sections : int + Number of spanwise sections to define planform shape (scalar, dimensionless) + idx_sec_start : int + Index in the inputs to begin the average sweep calculation (negative indices not + accepted), inclusive, by default 0 + idx_sec_end : int + Index in the inputs to end the average sweep calculation (negative indices not + accepted), inclusive, by default num_sections - 1 + """ + + def initialize(self): + self.options.declare( + "num_sections", default=2, types=int, desc="Number of sections along the half span to define" + ) + self.options.declare("idx_sec_start", default=0) + self.options.declare("idx_sec_end", default=None) + + def setup(self): + self.n_sec = self.options["num_sections"] + self.i_start = self.options["idx_sec_start"] + self.i_end = self.options["idx_sec_end"] + if self.i_end is None: + self.i_end = self.n_sec + else: + self.i_end += 1 # make it exclusive + + self.add_input("x_LE_sec", shape=(self.n_sec,), units="m") + self.add_input("y_sec", shape=(self.n_sec - 1,), units="m") + self.add_input("chord_sec", shape=(self.n_sec,), units="m") + + self.add_output("c4sweep", units="deg") + + self.declare_partials("*", "*", method="cs") + + def compute(self, inputs, outputs): + # Extract out the ones we care about + LE_sec = inputs["x_LE_sec"][self.i_start : self.i_end] + chord_sec = inputs["chord_sec"][self.i_start : self.i_end] + y_sec = np.hstack((inputs["y_sec"], [0.0]))[self.i_start : self.i_end] + + # Compute the c4sweep for each section + x_c4 = LE_sec + chord_sec * 0.25 + widths = y_sec[1:] - y_sec[:-1] # section width in y direction + setback = x_c4[:-1] - x_c4[1:] # relative offset of sections in streamwise direction + to_rad = np.pi / 180 + c4sweep_sec = np.arctan(setback / widths) / to_rad + + # Perform a weighted average with panel areas as weights. Do the weighted average + # on the cosine of the sweep angle rather than the angle itself. + # This is consistent with OpenAeroStruct. + A_sec = 0.5 * (chord_sec[:-1] + chord_sec[1:]) * widths + outputs["c4sweep"] = np.arccos(np.sum(np.cos(c4sweep_sec * to_rad) * A_sec) / np.sum(A_sec)) / to_rad + + +class WingAreaFromSections(om.ExplicitComponent): + """ + Compute the planform area of a specified portion of the wing + by taking in sectional parameters as they would be defined for a + sectional OpenAeroStruct mesh. + + NOTE: The area from this component is valid only if the scale_area + option of mesh_gen is set to False! Otherwise, the area computed + here will be off by a factor. + + Inputs + ------ + y_sec : float + Spanwise location of each section, starting with the outboard section (wing + tip) at the MOST NEGATIVE y value and moving inboard (increasing y value) + toward the root; the user does not provide a value for the root because it + is always 0.0 (vector of length num_sections - 1, m) + chord_sec : float + Chord of each section, starting with the outboard section (wing tip) and + moving inboard toward the root (vector of length num_sections, m) + + Outputs + ------- + S : float + Planform area of the specified region (scalar, sq m) + + Options + ------- + num_sections : int + Number of spanwise sections to define planform shape (scalar, dimensionless) + idx_sec_start : int + Index in the inputs to begin the average sweep calculation (negative indices not + accepted), inclusive, by default 0 + idx_sec_end : int + Index in the inputs to end the average sweep calculation (negative indices not + accepted), inclusive, by default num_sections - 1 + chord_frac_start : float + Fraction of the chord (streamwise direction) at which to begin the computed area, by default 0.0 + chord_frac_end : float + Fraction of the chord (streamwise direction) at which to end the computed area, by default 1.0 + """ + + def initialize(self): + self.options.declare( + "num_sections", default=2, types=int, desc="Number of sections along the half span to define" + ) + self.options.declare("idx_sec_start", default=0) + self.options.declare("idx_sec_end", default=None) + self.options.declare("chord_frac_start", default=0.0, desc="Fraction of chord to begin area computation") + self.options.declare("chord_frac_end", default=1.0, desc="Fraction of chord to end area computation") + + def setup(self): + self.n_sec = self.options["num_sections"] + self.i_start = self.options["idx_sec_start"] + self.i_end = self.options["idx_sec_end"] + if self.i_end is None: + self.i_end = self.n_sec + else: + self.i_end += 1 # make it exclusive + + self.add_input("y_sec", shape=(self.n_sec - 1,), units="m") + self.add_input("chord_sec", shape=(self.n_sec,), units="m") + + self.add_output("S", units="m**2") + + self.declare_partials("*", "*", method="cs") + + def compute(self, inputs, outputs): + # Extract out the ones we care about + chord_sec = inputs["chord_sec"][self.i_start : self.i_end] + y_sec = np.hstack((inputs["y_sec"], [0.0]))[self.i_start : self.i_end] + + # Compute the area + avg_chord = ( + 0.5 * (chord_sec[:-1] + chord_sec[1:]) * (self.options["chord_frac_end"] - self.options["chord_frac_start"]) + ) + widths = y_sec[1:] - y_sec[:-1] + outputs["S"] = 2 * np.sum(avg_chord * widths) + + +class WingMACFromSections(om.ExplicitComponent): + """ + Compute the mean aerodynamic chord of an OpenAeroStruct section geoemtry. + + Inputs + ------ + x_LE_sec : float + Streamwise offset of the section's leading edge, starting with the outboard + section (wing tip) and moving inboard toward the root (vector of length + num_sections, m) + y_sec : float + Spanwise location of each section, starting with the outboard section (wing + tip) at the MOST NEGATIVE y value and moving inboard (increasing y value) + toward the root; the user does not provide a value for the root because it + is always 0.0 (vector of length num_sections - 1, m) + chord_sec : float + Chord of each section, starting with the outboard section (wing tip) and + moving inboard toward the root (vector of length num_sections, m) + + Outputs + ------- + MAC : float + Mean aerodynamic chord (scalar, m) + x_c4MAC : float + X location of the quarter chord of MAC in the same x coordinates as x_LE_sec (scalar, m) + + Options + ------- + num_sections : int + Number of spanwise sections to define planform shape (scalar, dimensionless) + idx_sec_start : int + Index in the inputs to begin the average sweep calculation (negative indices not + accepted), inclusive, by default 0 + idx_sec_end : int + Index in the inputs to end the average sweep calculation (negative indices not + accepted), inclusive, by default num_sections - 1 + """ + + def initialize(self): + self.options.declare( + "num_sections", default=2, types=int, desc="Number of sections along the half span to define" + ) + self.options.declare("idx_sec_start", default=0) + self.options.declare("idx_sec_end", default=None) + + def setup(self): + self.n_sec = self.options["num_sections"] + self.i_start = self.options["idx_sec_start"] + self.i_end = self.options["idx_sec_end"] + if self.i_end is None: + self.i_end = self.n_sec + else: + self.i_end += 1 # make it exclusive + + self.add_input("x_LE_sec", shape=(self.n_sec,), units="m") + self.add_input("y_sec", shape=(self.n_sec - 1,), units="m") + self.add_input("chord_sec", shape=(self.n_sec,), units="m") + + self.add_output("MAC", lower=1e-6, units="m") + self.add_output("x_c4MAC", units="m") + + self.declare_partials("*", "*", method="cs") + + def compute(self, inputs, outputs): + # Extract out the ones we care about + LE_sec = inputs["x_LE_sec"][self.i_start : self.i_end] + chord_sec = inputs["chord_sec"][self.i_start : self.i_end] + y_sec = np.hstack((inputs["y_sec"], [0.0]))[self.i_start : self.i_end] + + # Properties at the ouboard and inboard sections of each region + x1 = LE_sec[:-1] + c1 = chord_sec[:-1] + y1 = y_sec[:-1] + x2 = LE_sec[1:] + c2 = chord_sec[1:] + y2 = y_sec[1:] + + # Compute the planform area of the half wing + widths = y2 - y1 + S = np.sum(0.5 * (c1 + c2) * widths) + + # Derived using the following MATLAB symbolic math code + # + # syms y y1 y2 x1 x2 xq c1 c2 Sw + + # % Shape functions + # N1 = (y2 - y) / (y2 - y1); + # N2 = (y - y1) / (y2 - y1); + + # % Chord and quarter chord for the section as a function of y + # c = c1 * N1 + c2 * N2; + # xq = x1 * N1 + x2 * N2 + c / 4; + + # % Mean aerodynamic chord + # cw = 1 / Sw * int(c^2, y, y1, y2) + + # % Longitudinal position of MAC quarter chord from x = 0 + # xqw = simplify(1 / Sw * int(c * xq, y, y1, y2)) + # + outputs["MAC"] = np.sum((y2 - y1) * (c1**2 + c1 * c2 + c2**2) / (3 * S)) + outputs["x_c4MAC"] = np.sum( + (y2 - y1) * (c1 * c2 + 4 * c1 * x1 + 2 * c1 * x2 + 2 * c2 * x1 + 4 * c2 * x2 + c1**2 + c2**2) / (12 * S) + ) diff --git a/openconcept/mission/__init__.py b/openconcept/mission/__init__.py index 5c0071e1..ebe137cf 100644 --- a/openconcept/mission/__init__.py +++ b/openconcept/mission/__init__.py @@ -1,4 +1,4 @@ -from .profiles import MissionWithReserve, FullMissionAnalysis, BasicMission +from .profiles import MissionWithReserve, FullMissionAnalysis, BasicMission, FullMissionWithReserve from .phases import ( ClimbAngleComp, BFLImplicitSolve, diff --git a/openconcept/mission/profiles.py b/openconcept/mission/profiles.py index 9a1fd5ea..922dc5ce 100644 --- a/openconcept/mission/profiles.py +++ b/openconcept/mission/profiles.py @@ -11,58 +11,32 @@ from .mission_groups import TrajectoryGroup -class MissionWithReserve(TrajectoryGroup): +class FullMissionWithReserve(TrajectoryGroup): """ - This analysis group is set up to compute all the major parameters - of a fixed wing mission, including climb, cruise, and descent as well as Part 25 reserve fuel phases. - The 5% of block fuel is not accounted for here. - - To use this analysis, pass in an aircraft model following OpenConcept interface. - Namely, the model should consume the following: - - flight conditions (fltcond|q/rho/p/T/Utrue/Ueas/...) - - aircraft design parameters (ac|*) - - lift coefficient (fltcond|CL; either solved from steady flight or assumed during ground roll) - - throttle - - propulsor_failed (value 0 when failed, 1 when not failed) - - and produce top-level outputs: - - thrust - - drag - - weight - - the following parameters need to either be defined as design variables or - given as top-level analysis outputs from the airplane model: - - ac|geom|S_ref - - ac|aero|CL_max_flaps30 - - ac|weights|MTOW - - - Inputs - ------ - ac|* : various - All relevant airplane design variables to pass to the airplane model - takeoff|h : float - Takeoff obstacle clearance height (default 50 ft) - cruise|h0 : float - Initial cruise altitude (default 28000 ft) - payload : float - Mission payload (default 1000 lbm) - mission_range : float - Design range (deault 1250 NM) + A combination of the FullMissionAnalysis and MissionWithReserve profiles (see their documentation) + for more details. This combination includes the balanced field length simulation from FullMissionAnalysis, + climb, cruise, and descent phases, and the reserve phases from MissionWithReserve. - Options - ------- - aircraft_model : class - An aircraft model class with the standard OpenConcept interfaces promoted correctly - num_nodes : int - Number of analysis points per phase. Higher is more accurate but more expensive + This acts as the main group that includes all the features of FullMissionAnalysis, BasicMission, and + MissionWithReserve profiles so that those can inherit from this without duplicating code. """ + def __init__(self, include_takeoff=True, include_reserve=True, **kwargs): + self.include_takeoff = include_takeoff # include flight phases associated with takeoff + self.include_reserve = include_reserve # include flight phases associated with reserve mission + super().__init__(**kwargs) + def initialize(self): self.options.declare( "num_nodes", default=9, desc="Number of points per phase. Needs to be 2N + 1 due to simpson's rule" ) self.options.declare("aircraft_model", default=None, desc="OpenConcept-compliant airplane model") + if self.include_takeoff: + self.options.declare( + "transition_method", default="simplified", desc="Method to use for computing transition" + ) + if not self.include_reserve and not self.include_takeoff: # BasicMission + self.options.declare("include_ground_roll", default=False, desc="Whether to include groundroll phase") def setup(self): nn = self.options["num_nodes"] @@ -72,50 +46,115 @@ def setup(self): mp.add_output("takeoff|h", val=0.0, units="ft") mp.add_output("cruise|h0", val=28000.0, units="ft") mp.add_output("mission_range", val=1250.0, units="NM") - mp.add_output("reserve_range", val=200.0, units="NM") - mp.add_output("reserve|h0", val=25000.0, units="ft") - mp.add_output("loiter|h0", val=1500.0, units="ft") - mp.add_output("loiter_duration", val=30.0 * 60.0, units="s") mp.add_output("payload", val=1000.0, units="lbm") + if self.include_reserve: + mp.add_output("reserve_range", val=200.0, units="NM") + mp.add_output("reserve|h0", val=25000.0, units="ft") + mp.add_output("loiter|h0", val=1500.0, units="ft") + mp.add_output("loiter_duration", val=30.0 * 60.0, units="s") + if not self.include_reserve and not self.include_takeoff: # BasicMission + mp.add_output("takeoff|v2", val=150.0, units="kn") + + if self.include_takeoff: + # add the four balanced field length takeoff phases and the implicit v1 solver + # v0v1 - from a rolling start to v1 speed + # v1vr - from the decision speed to rotation + # rotate - in the air following rotation in 2DOF + # v1v0 - emergency stopping from v1 to a stop. + + self.add_subsystem("bfl", BFLImplicitSolve(), promotes_outputs=["takeoff|v1"]) + v0v1 = self.add_subsystem( + "v0v1", + GroundRollPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="v0v1"), + promotes_inputs=["ac|*", "takeoff|v1"], + ) + v1vr = self.add_subsystem( + "v1vr", + GroundRollPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="v1vr"), + promotes_inputs=["ac|*"], + ) + self.connect("takeoff|v1", "v1vr.fltcond|Utrue_initial") + self.connect("v0v1.range_final", "v1vr.range_initial") + if self.options["transition_method"] == "simplified": + rotate = self.add_subsystem( + "rotate", + RobustRotationPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="rotate"), + promotes_inputs=["ac|*"], + ) + elif self.options["transition_method"] == "ode": + rotate = self.add_subsystem( + "rotate", + RotationPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="rotate"), + promotes_inputs=["ac|*"], + ) + self.connect("v1vr.fltcond|Utrue_final", "rotate.fltcond|Utrue_initial") + else: + raise IOError("Invalid option for transition method") + self.connect("v1vr.range_final", "rotate.range_initial") + self.connect("rotate.range_final", "bfl.distance_continue") + self.connect("v1vr.takeoff|vr", "bfl.takeoff|vr") + v1v0 = self.add_subsystem( + "v1v0", + GroundRollPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="v1v0"), + promotes_inputs=["ac|*", "takeoff|v1"], + ) + self.connect("v0v1.range_final", "v1v0.range_initial") + self.connect("v1v0.range_final", "bfl.distance_abort") + self.add_subsystem( + "engineoutclimb", + ClimbAnglePhase(num_nodes=1, aircraft_model=acmodelclass, flight_phase="EngineOutClimbAngle"), + promotes_inputs=["ac|*"], + ) + elif not self.include_reserve: # BasicMission + if self.options["include_ground_roll"]: + mp.add_output("takeoff|v0", val=4.0, units="kn") + ground_roll = self.add_subsystem( + "groundroll", + GroundRollPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="v0v1"), + promotes_inputs=["ac|*"], + ) + self.connect("takeoff|v2", "groundroll.takeoff|v1") # add the climb, cruise, and descent phases - phase1 = self.add_subsystem( + climb = self.add_subsystem( "climb", SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="climb"), promotes_inputs=["ac|*"], ) # set the climb time such that the specified initial cruise altitude is exactly reached - phase1.add_subsystem( + climb.add_subsystem( "climbdt", om.BalanceComp( name="duration", units="s", eq_units="m", - val=120, - upper=2000, + val=1200, + upper=1e4, lower=0, rhs_name="cruise|h0", lhs_name="fltcond|h_final", ), promotes_outputs=["duration"], ) - phase1.connect("ode_integ_phase.fltcond|h_final", "climbdt.fltcond|h_final") + climb.connect("ode_integ_phase.fltcond|h_final", "climbdt.fltcond|h_final") self.connect("cruise|h0", "climb.climbdt.cruise|h0") + if not self.include_reserve and not self.include_takeoff: # BasicMission + self.connect("takeoff|h", "climb.ode_integ_phase.fltcond|h_initial") - phase2 = self.add_subsystem( + cruise = self.add_subsystem( "cruise", SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="cruise"), promotes_inputs=["ac|*"], ) # set the cruise time such that the desired design range is flown by the end of the mission - phase2.add_subsystem( + cruise.add_subsystem( "cruisedt", om.BalanceComp( name="duration", units="s", - eq_units="m", - val=120, - upper=25000, + eq_units="km", + val=1e4, + upper=1e5, lower=0, rhs_name="mission_range", lhs_name="range_final", @@ -123,20 +162,20 @@ def setup(self): promotes_outputs=["duration"], ) self.connect("mission_range", "cruise.cruisedt.mission_range") - phase3 = self.add_subsystem( + descent = self.add_subsystem( "descent", SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="descent"), promotes_inputs=["ac|*"], ) # set the descent time so that the final altitude is sea level again - phase3.add_subsystem( + descent.add_subsystem( "descentdt", om.BalanceComp( name="duration", units="s", eq_units="m", - val=120, - upper=8000, + val=1200, + upper=1e4, lower=0, rhs_name="takeoff|h", lhs_name="fltcond|h_final", @@ -145,111 +184,125 @@ def setup(self): ) self.connect("descent.ode_integ_phase.range_final", "cruise.cruisedt.range_final") self.connect("takeoff|h", "descent.descentdt.takeoff|h") - phase3.connect("ode_integ_phase.fltcond|h_final", "descentdt.fltcond|h_final") - - # add the climb, cruise, and descent phases for the reserve mission - phase4 = self.add_subsystem( - "reserve_climb", - SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="reserve_climb"), - promotes_inputs=["ac|*"], - ) - # set the climb time such that the specified initial cruise altitude is exactly reached - phase4.add_subsystem( - "reserve_climbdt", - om.BalanceComp( - name="duration", - units="s", - eq_units="m", - val=120, - upper=2000, - lower=0, - rhs_name="reserve|h0", - lhs_name="fltcond|h_final", - ), - promotes_outputs=["duration"], - ) - phase4.connect("ode_integ_phase.fltcond|h_final", "reserve_climbdt.fltcond|h_final") - self.connect("reserve|h0", "reserve_climb.reserve_climbdt.reserve|h0") + descent.connect("ode_integ_phase.fltcond|h_final", "descentdt.fltcond|h_final") - phase5 = self.add_subsystem( - "reserve_cruise", - SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="reserve_cruise"), - promotes_inputs=["ac|*"], - ) - # set the reserve_cruise time such that the desired design range is flown by the end of the mission - phase5.add_subsystem( - "reserve_cruisedt", - om.BalanceComp( - name="duration", - units="s", - eq_units="m", - val=120, - upper=25000, - lower=0, - rhs_name="reserve_range", - lhs_name="range_final", - ), - promotes_outputs=["duration"], - ) - self.connect("reserve_range", "reserve_cruise.reserve_cruisedt.reserve_range") + # add the phases for the reserve mission + if self.include_reserve: + reserve_climb = self.add_subsystem( + "reserve_climb", + SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="reserve_climb"), + promotes_inputs=["ac|*"], + ) + # set the climb time such that the specified initial cruise altitude is exactly reached + reserve_climb.add_subsystem( + "reserve_climbdt", + om.BalanceComp( + name="duration", + units="s", + eq_units="m", + val=120, + upper=2000, + lower=0, + rhs_name="reserve|h0", + lhs_name="fltcond|h_final", + ), + promotes_outputs=["duration"], + ) + reserve_climb.connect("ode_integ_phase.fltcond|h_final", "reserve_climbdt.fltcond|h_final") + self.connect("reserve|h0", "reserve_climb.reserve_climbdt.reserve|h0") - phase6 = self.add_subsystem( - "reserve_descent", - SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="reserve_descent"), - promotes_inputs=["ac|*"], - ) - # set the reserve_descent time so that the final altitude is sea level again - phase6.add_subsystem( - "reserve_descentdt", - om.BalanceComp( - name="duration", - units="s", - eq_units="m", - val=120, - upper=8000, - lower=0, - rhs_name="takeoff|h", - lhs_name="fltcond|h_final", - ), - promotes_outputs=["duration"], - ) - phase6.connect("ode_integ_phase.fltcond|h_final", "reserve_descentdt.fltcond|h_final") - self.connect("takeoff|h", "reserve_descent.reserve_descentdt.takeoff|h") - - reserverange = om.ExecComp( - "reserverange=rangef-rangeo", - reserverange={"val": 100.0, "units": "NM"}, - rangeo={"val": 0.0, "units": "NM"}, - rangef={"val": 100.0, "units": "NM"}, - ) - self.add_subsystem("resrange", reserverange) - self.connect("descent.ode_integ_phase.range_final", "resrange.rangeo") - self.connect("reserve_descent.ode_integ_phase.range_final", "resrange.rangef") - self.connect("resrange.reserverange", "reserve_cruise.reserve_cruisedt.range_final") - # self.connect('reserve_descent.range_final', 'reserve_cruisedt.range_final') - - phase7 = self.add_subsystem( - "loiter", - SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="loiter"), - promotes_inputs=["ac|*"], - ) - dvlist = [["duration_in", "duration", 300, "s"]] - phase7.add_subsystem("loiter_dt", DVLabel(dvlist), promotes_inputs=["*"], promotes_outputs=["*"]) - self.connect("loiter|h0", "loiter.ode_integ_phase.fltcond|h_initial") - self.connect("loiter_duration", "loiter.duration_in") + reserve_cruise = self.add_subsystem( + "reserve_cruise", + SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="reserve_cruise"), + promotes_inputs=["ac|*"], + ) + # set the reserve_cruise time such that the desired design range is flown by the end of the mission + reserve_cruise.add_subsystem( + "reserve_cruisedt", + om.BalanceComp( + name="duration", + units="s", + eq_units="m", + val=120, + upper=25000, + lower=0, + rhs_name="reserve_range", + lhs_name="range_final", + ), + promotes_outputs=["duration"], + ) + self.connect("reserve_range", "reserve_cruise.reserve_cruisedt.reserve_range") - self.link_phases(phase1, phase2) - self.link_phases(phase2, phase3) - self.link_phases(phase3, phase4, states_to_skip=["ode_integ_phase.fltcond|h"]) - self.link_phases(phase4, phase5) - self.link_phases(phase5, phase6) - self.link_phases(phase6, phase7, states_to_skip=["ode_integ_phase.fltcond|h"]) + reserve_descent = self.add_subsystem( + "reserve_descent", + SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="reserve_descent"), + promotes_inputs=["ac|*"], + ) + # set the reserve_descent time so that the final altitude is sea level again + reserve_descent.add_subsystem( + "reserve_descentdt", + om.BalanceComp( + name="duration", + units="s", + eq_units="m", + val=120, + upper=8000, + lower=0, + rhs_name="takeoff|h", + lhs_name="fltcond|h_final", + ), + promotes_outputs=["duration"], + ) + reserve_descent.connect("ode_integ_phase.fltcond|h_final", "reserve_descentdt.fltcond|h_final") + self.connect("takeoff|h", "reserve_descent.reserve_descentdt.takeoff|h") + + reserverange = om.ExecComp( + "reserverange=rangef-rangeo", + reserverange={"val": 100.0, "units": "NM"}, + rangeo={"val": 0.0, "units": "NM"}, + rangef={"val": 100.0, "units": "NM"}, + ) + self.add_subsystem("resrange", reserverange) + self.connect("descent.ode_integ_phase.range_final", "resrange.rangeo") + self.connect("reserve_descent.ode_integ_phase.range_final", "resrange.rangef") + self.connect("resrange.reserverange", "reserve_cruise.reserve_cruisedt.range_final") + # self.connect('reserve_descent.range_final', 'reserve_cruisedt.range_final') + + loiter = self.add_subsystem( + "loiter", + SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="loiter"), + promotes_inputs=["ac|*"], + ) + dvlist = [["duration_in", "duration", 300, "s"]] + loiter.add_subsystem("loiter_dt", DVLabel(dvlist), promotes_inputs=["*"], promotes_outputs=["*"]) + self.connect("loiter|h0", "loiter.ode_integ_phase.fltcond|h_initial") + self.connect("loiter_duration", "loiter.duration_in") + + if self.include_takeoff: + self.link_phases(v0v1, v1vr, states_to_skip=["fltcond|Utrue", "range"]) + self.link_phases(v1vr, rotate, states_to_skip=["fltcond|Utrue", "range"]) + self.link_phases(v0v1, v1v0, states_to_skip=["fltcond|Utrue", "range"]) + self.link_phases(rotate, climb) + self.connect("rotate.range_final", "climb.ode_integ_phase.range_initial") + self.connect("rotate.fltcond|h_final", "climb.ode_integ_phase.fltcond|h_initial") + elif not self.include_reserve: # BasicMission + if self.options["include_ground_roll"]: + self.link_phases(ground_roll, climb, states_to_skip=["fltcond|h"]) + self.link_phases(climb, cruise) + self.link_phases(cruise, descent) + if self.include_reserve: + self.link_phases(descent, reserve_climb, states_to_skip=["ode_integ_phase.fltcond|h"]) + self.link_phases(reserve_climb, reserve_cruise) + self.link_phases(reserve_cruise, reserve_descent) + self.link_phases(reserve_descent, loiter, states_to_skip=["ode_integ_phase.fltcond|h"]) -class BasicMission(TrajectoryGroup): +class MissionWithReserve(FullMissionWithReserve): """ This analysis group is set up to compute all the major parameters - of a fixed wing mission, including climb, cruise, and descent but no Part 25 reserves + of a fixed wing mission, including climb, cruise, and descent as well as Part 25 reserve fuel phases. + The 5% of block fuel is not accounted for here. + To use this analysis, pass in an aircraft model following OpenConcept interface. Namely, the model should consume the following: - flight conditions (fltcond|q/rho/p/T/Utrue/Ueas/...) @@ -257,22 +310,25 @@ class BasicMission(TrajectoryGroup): - lift coefficient (fltcond|CL; either solved from steady flight or assumed during ground roll) - throttle - propulsor_failed (value 0 when failed, 1 when not failed) + and produce top-level outputs: - thrust - drag - weight + the following parameters need to either be defined as design variables or given as top-level analysis outputs from the airplane model: - ac|geom|S_ref - ac|aero|CL_max_flaps30 - ac|weights|MTOW + Inputs ------ ac|* : various All relevant airplane design variables to pass to the airplane model takeoff|h : float - Takeoff obstacle clearance height (default 50 ft) + Takeoff and landing altitude (default 0 ft) cruise|h0 : float Initial cruise altitude (default 28000 ft) payload : float @@ -288,112 +344,58 @@ class BasicMission(TrajectoryGroup): Number of analysis points per phase. Higher is more accurate but more expensive """ - def initialize(self): - self.options.declare( - "num_nodes", default=9, desc="Number of points per phase. Needs to be 2N + 1 due to simpson's rule" - ) - self.options.declare("aircraft_model", default=None, desc="OpenConcept-compliant airplane model") - self.options.declare("include_ground_roll", default=False, desc="Whether to include groundroll phase") - - def setup(self): - nn = self.options["num_nodes"] - acmodelclass = self.options["aircraft_model"] - grflag = self.options["include_ground_roll"] - - mp = self.add_subsystem("missionparams", om.IndepVarComp(), promotes_outputs=["*"]) - mp.add_output("takeoff|h", val=0.0, units="ft") - mp.add_output("cruise|h0", val=28000.0, units="ft") - mp.add_output("mission_range", val=1250.0, units="NM") - mp.add_output("payload", val=1000.0, units="lbm") - mp.add_output("takeoff|v2", val=150.0, units="kn") + def __init__(self, **kwargs): + super().__init__(include_takeoff=False, include_reserve=True, **kwargs) - if grflag: - mp.add_output("takeoff|v0", val=4.0, units="kn") - phase0 = self.add_subsystem( - "groundroll", - GroundRollPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="v0v1"), - promotes_inputs=["ac|*"], - ) - self.connect("takeoff|v2", "groundroll.takeoff|v1") - # add the climb, cruise, and descent phases - phase1 = self.add_subsystem( - "climb", - SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="climb"), - promotes_inputs=["ac|*"], - ) - # set the climb time such that the specified initial cruise altitude is exactly reached - phase1.add_subsystem( - "climbdt", - om.BalanceComp( - name="duration", - units="s", - eq_units="m", - val=120, - upper=2000, - lower=0, - rhs_name="cruise|h0", - lhs_name="fltcond|h_final", - ), - promotes_outputs=["duration"], - ) - phase1.connect("ode_integ_phase.fltcond|h_final", "climbdt.fltcond|h_final") - self.connect("cruise|h0", "climb.climbdt.cruise|h0") - self.connect("takeoff|h", "climb.ode_integ_phase.fltcond|h_initial") +class BasicMission(FullMissionWithReserve): + """ + This analysis group is set up to compute all the major parameters + of a fixed wing mission, including climb, cruise, and descent but no Part 25 reserves + To use this analysis, pass in an aircraft model following OpenConcept interface. + Namely, the model should consume the following: + - flight conditions (fltcond|q/rho/p/T/Utrue/Ueas/...) + - aircraft design parameters (ac|*) + - lift coefficient (fltcond|CL; either solved from steady flight or assumed during ground roll) + - throttle + - propulsor_failed (value 0 when failed, 1 when not failed) + and produce top-level outputs: + - thrust + - drag + - weight + the following parameters need to either be defined as design variables or + given as top-level analysis outputs from the airplane model: + - ac|geom|S_ref + - ac|aero|CL_max_flaps30 + - ac|weights|MTOW - phase2 = self.add_subsystem( - "cruise", - SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="cruise"), - promotes_inputs=["ac|*"], - ) - # set the cruise time such that the desired design range is flown by the end of the mission - phase2.add_subsystem( - "cruisedt", - om.BalanceComp( - name="duration", - units="s", - eq_units="m", - val=120, - upper=25000, - lower=0, - rhs_name="mission_range", - lhs_name="range_final", - ), - promotes_outputs=["duration"], - ) - self.connect("mission_range", "cruise.cruisedt.mission_range") + Inputs + ------ + ac|* : various + All relevant airplane design variables to pass to the airplane model + takeoff|h : float + Takeoff and landing altitude (default 0 ft). However, if the ground roll is + included it will always occur at 0 ft unless its fltcond|h is specifically set. + cruise|h0 : float + Initial cruise altitude (default 28000 ft) + payload : float + Mission payload (default 1000 lbm) + mission_range : float + Design range (deault 1250 NM) - phase3 = self.add_subsystem( - "descent", - SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="descent"), - promotes_inputs=["ac|*"], - ) - # set the descent time so that the final altitude is sea level again - phase3.add_subsystem( - "descentdt", - om.BalanceComp( - name="duration", - units="s", - eq_units="m", - val=120, - upper=8000, - lower=0, - rhs_name="takeoff|h", - lhs_name="fltcond|h_final", - ), - promotes_outputs=["duration"], - ) - self.connect("descent.ode_integ_phase.range_final", "cruise.cruisedt.range_final") - self.connect("takeoff|h", "descent.descentdt.takeoff|h") - phase3.connect("ode_integ_phase.fltcond|h_final", "descentdt.fltcond|h_final") + Options + ------- + aircraft_model : class + An aircraft model class with the standard OpenConcept interfaces promoted correctly + num_nodes : int + Number of analysis points per phase. Higher is more accurate but more expensive + """ - if grflag: - self.link_phases(phase0, phase1, states_to_skip=["fltcond|h"]) - self.link_phases(phase1, phase2) - self.link_phases(phase2, phase3) + def __init__(self, **kwargs): + super().__init__(include_takeoff=False, include_reserve=False, **kwargs) -class FullMissionAnalysis(TrajectoryGroup): +class FullMissionAnalysis(FullMissionWithReserve): """ This analysis group is set up to compute all the major parameters of a fixed wing mission, including balanced-field takeoff, climb, cruise, and descent. @@ -423,7 +425,11 @@ class FullMissionAnalysis(TrajectoryGroup): ac|* : various All relevant airplane design variables to pass to the airplane model takeoff|h : float - Takeoff obstacle clearance height (default 50 ft) + WARNING: This parameter will set the landing altitude, but takeoff + altitude will always be 0 ft unless specifically set in each takeoff + phase's fltcond|h value. However, even if you change this value, the + climb phase will begin at the rotation phase's obstacle height. Also, + the rotation phase does it's own thing (see the source to understand). cruise|h0 : float Initial cruise altitude (default 28000 ft) payload : float @@ -448,150 +454,5 @@ class FullMissionAnalysis(TrajectoryGroup): Option "ode" is a 2DOF ODE integration method which is arguably just as inaccurate and less robust """ - def initialize(self): - self.options.declare( - "num_nodes", default=9, desc="Number of points per phase. Needs to be 2N + 1 due to simpson's rule" - ) - self.options.declare("aircraft_model", default=None, desc="OpenConcept-compliant airplane model") - self.options.declare("transition_method", default="simplified", desc="Method to use for computing transition") - - def setup(self): - nn = self.options["num_nodes"] - acmodelclass = self.options["aircraft_model"] - transition_method = self.options["transition_method"] - - # add the four balanced field length takeoff phases and the implicit v1 solver - # v0v1 - from a rolling start to v1 speed - # v1vr - from the decision speed to rotation - # rotate - in the air following rotation in 2DOF - # v1vr - emergency stopping from v1 to a stop. - - mp = self.add_subsystem("missionparams", om.IndepVarComp(), promotes_outputs=["*"]) - mp.add_output("takeoff|h", val=0.0, units="ft") - mp.add_output("cruise|h0", val=28000.0, units="ft") - mp.add_output("mission_range", val=1250.0, units="NM") - mp.add_output("payload", val=1000.0, units="lbm") - - self.add_subsystem("bfl", BFLImplicitSolve(), promotes_outputs=["takeoff|v1"]) - v0v1 = self.add_subsystem( - "v0v1", - GroundRollPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="v0v1"), - promotes_inputs=["ac|*", "takeoff|v1"], - ) - v1vr = self.add_subsystem( - "v1vr", - GroundRollPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="v1vr"), - promotes_inputs=["ac|*"], - ) - self.connect("takeoff|v1", "v1vr.fltcond|Utrue_initial") - self.connect("v0v1.range_final", "v1vr.range_initial") - if transition_method == "simplified": - rotate = self.add_subsystem( - "rotate", - RobustRotationPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="rotate"), - promotes_inputs=["ac|*"], - ) - elif transition_method == "ode": - rotate = self.add_subsystem( - "rotate", - RotationPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="rotate"), - promotes_inputs=["ac|*"], - ) - self.connect("v1vr.fltcond|Utrue_final", "rotate.fltcond|Utrue_initial") - else: - raise IOError("Invalid option for transition method") - self.connect("v1vr.range_final", "rotate.range_initial") - self.connect("rotate.range_final", "bfl.distance_continue") - self.connect("v1vr.takeoff|vr", "bfl.takeoff|vr") - v1v0 = self.add_subsystem( - "v1v0", - GroundRollPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="v1v0"), - promotes_inputs=["ac|*", "takeoff|v1"], - ) - self.connect("v0v1.range_final", "v1v0.range_initial") - self.connect("v1v0.range_final", "bfl.distance_abort") - self.add_subsystem( - "engineoutclimb", - ClimbAnglePhase(num_nodes=1, aircraft_model=acmodelclass, flight_phase="EngineOutClimbAngle"), - promotes_inputs=["ac|*"], - ) - - # add the climb, cruise, and descent phases - climb = self.add_subsystem( - "climb", - SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="climb"), - promotes_inputs=["ac|*"], - ) - # set the climb time such that the specified initial cruise altitude is exactly reached - climb.add_subsystem( - "climbdt", - om.BalanceComp( - name="duration", - units="s", - eq_units="m", - val=120, - lower=0, - upper=3000, - rhs_name="cruise|h0", - lhs_name="fltcond|h_final", - ), - promotes_outputs=["duration"], - ) - climb.connect("ode_integ_phase.fltcond|h_final", "climbdt.fltcond|h_final") - self.connect("cruise|h0", "climb.climbdt.cruise|h0") - - cruise = self.add_subsystem( - "cruise", - SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="cruise"), - promotes_inputs=["ac|*"], - ) - # set the cruise time such that the desired design range is flown by the end of the mission - cruise.add_subsystem( - "cruisedt", - om.BalanceComp( - name="duration", - units="s", - eq_units="km", - val=120, - lower=0, - upper=30000, - rhs_name="mission_range", - lhs_name="range_final", - ), - promotes_outputs=["duration"], - ) - self.connect("mission_range", "cruise.cruisedt.mission_range") - - descent = self.add_subsystem( - "descent", - SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase="descent"), - promotes_inputs=["ac|*"], - ) - # set the descent time so that the final altitude is sea level again - descent.add_subsystem( - "descentdt", - om.BalanceComp( - name="duration", - units="s", - eq_units="m", - val=120, - lower=0, - upper=3000, - rhs_name="takeoff|h", - lhs_name="fltcond|h_final", - ), - promotes_outputs=["duration"], - ) - self.connect("takeoff|h", "descent.descentdt.takeoff|h") - self.connect("descent.ode_integ_phase.range_final", "cruise.cruisedt.range_final") - self.connect("descent.ode_integ_phase.fltcond|h_final", "descent.descentdt.fltcond|h_final") - - # connect range, fuel burn, and altitude from the end of each phase to the beginning of the next, in order - self.link_phases(v0v1, v1vr, states_to_skip=["fltcond|Utrue", "range"]) - self.link_phases(v1vr, rotate, states_to_skip=["fltcond|Utrue", "range"]) - self.link_phases(v0v1, v1v0, states_to_skip=["fltcond|Utrue", "range"]) - self.link_phases(rotate, climb) - self.link_phases(climb, cruise) - self.link_phases(cruise, descent) - self.connect("rotate.range_final", "climb.ode_integ_phase.range_initial") - self.connect("rotate.fltcond|h_final", "climb.ode_integ_phase.fltcond|h_initial") + def __init__(self, **kwargs): + super().__init__(include_takeoff=True, include_reserve=False, **kwargs) diff --git a/openconcept/propulsion/__init__.py b/openconcept/propulsion/__init__.py index 8f9bac97..14af3ffa 100644 --- a/openconcept/propulsion/__init__.py +++ b/openconcept/propulsion/__init__.py @@ -4,6 +4,7 @@ from .motor import SimpleMotor from .N3 import N3, N3Hybrid from .propeller import SimplePropeller, WeightCalc, ThrustCalc, PropCoefficients +from .rubberized_turbofan import RubberizedTurbofan from .splitter import PowerSplit from .turboshaft import SimpleTurboshaft diff --git a/openconcept/propulsion/rubberized_turbofan.py b/openconcept/propulsion/rubberized_turbofan.py new file mode 100644 index 00000000..edf1859a --- /dev/null +++ b/openconcept/propulsion/rubberized_turbofan.py @@ -0,0 +1,131 @@ +import openmdao.api as om +from openconcept.propulsion import N3, CFM56 +from openconcept.utilities import ElementMultiplyDivideComp + + +class RubberizedTurbofan(om.Group): + """ + Optimized N+3 GTF engine deck (can optionally be switched to CFM56) + generated as a surrogate of pyCycle data. This version adds the rated thrust + input which adds a multiplier on thrust and fuel flow to enable continuous + scaling of the engine power. + + This version of the engine can also be converted to hydrogen with the + hydrogen option. It will scale the fuel flow by the ratio of LHV between + jet fuel and hydrogen to deliver the same energy to the engine. This + maintains the same thrust-specific energy consumption. + + NOTE: The CFM56 and N3 engine models only include data Mach 0.2 to 0.8 + and up to 35,000 ft. Outside that range, the model is unreliable. + + Inputs + ------ + throttle: float + Engine throttle. Controls power and fuel flow. + Produces 100% of rated power at throttle = 1. + Should be in range 0 to 1 or slightly above 1. + (vector, dimensionless) + fltcond|h: float + Altitude + (vector, dimensionless) + fltcond|M: float + Mach number + (vector, dimensionless) + ac|propulsion|engine|rating : float + Desired thrust rating (sea level static) of each engine; the CFM56 thrust + and fuel flow are scaled by this value divided by 27,300, while the N+3 thrust + and fuel flow are scaled by this value divided by 28,620 (scalar, lbf) + + Outputs + ------- + thrust : float + Thrust developed by the engine (vector, lbf) + fuel_flow : float + Fuel flow consumed (vector, lbm/s) + surge_margin or T4 : float + Surge margin if engine is "N3" or T4 if engine is "CFM56" (vector, percent) + + Options + ------- + num_nodes : int + Number of analysis points to run (sets vec length; default 1) + hydrogen : bool + True to convert fuel_flow to an equivalent fuel flow of hydrogen by + multiplying by the ratio of lower heating value between jet fuel + and hydrogen. Otherwise it will keep the fuel flow from the jet + fuel-powered version of the engine deck, by default False + engine : str + Engine deck to use, valid options are "N3" and "CFM56", by default "N3" + """ + + def initialize(self): + self.options.declare("num_nodes", default=1, types=int, desc="Number of analysis points to run") + self.options.declare("hydrogen", default=False, types=bool, desc="Convert fuel flow to hydrogen energy") + self.options.declare("engine", default="N3", values=["N3", "CFM56"], desc="Engine deck to use") + + def setup(self): + nn = self.options["num_nodes"] + hy = self.options["hydrogen"] + eng = self.options["engine"] + + # Scale the fuel flow by the ratio of LHV if hydrogen is specified, else leave as is + LHV_ker = 43 # MJ/kg, Jet A-1 specific energy + LHV_hy = 120.0 # Mj/kg, hydrogen specific energy + LHV_scale_fac = LHV_ker / LHV_hy if hy else 1.0 + + # Original rated SLS thrust of the engine to use in scaling factor + if eng == "N3": + # https://ntrs.nasa.gov/citations/20170005426 + orig_rated_thrust = 28620 # lbf + elif eng == "CFM56": + # https://web.archive.org/web/20161220201436/http://www.safran-aircraft-engines.com/file/download/fiche_cfm56-7b_ang.pdf + orig_rated_thrust = 27300 # lbf + + # Engine deck + if eng == "N3": + self.add_subsystem( + "engine_deck", + N3(num_nodes=nn), + promotes_inputs=["throttle", "fltcond|h", "fltcond|M"], + promotes_outputs=["surge_margin"], + ) + elif eng == "CFM56": + self.add_subsystem( + "engine_deck", + CFM56(num_nodes=nn), + promotes_inputs=["throttle", "fltcond|h", "fltcond|M"], + promotes_outputs=["T4"], + ) + else: + raise ValueError(f"{eng} is not a recognized engine") + + # Scale thrust and fuel flow by the engine thrust rating and then divide by + # the original sea level static rating of the engine + scale = self.add_subsystem( + "scale_engine", + ElementMultiplyDivideComp(), + promotes_inputs=[ + ("rating_thrust", "ac|propulsion|engine|rating"), + ("rating_fuel_flow", "ac|propulsion|engine|rating"), + ], + promotes_outputs=["thrust", "fuel_flow"], + ) + scale.add_equation( + output_name="thrust", + input_names=["unit_thrust", "rating_thrust", "orig_rating_thrust"], + vec_size=[nn, 1, 1], + input_units=["lbf", "lbf", "lbf"], + divide=[False, False, True], + ) + scale.add_equation( + output_name="fuel_flow", + input_names=["unit_fuel_flow", "rating_fuel_flow", "orig_rating_fuel_flow"], + vec_size=[nn, 1, 1], + input_units=["lbm/s", "lbf", "lbf"], + divide=[False, False, True], + scaling_factor=LHV_scale_fac, + ) + self.set_input_defaults("scale_engine.orig_rating_thrust", orig_rated_thrust, units="lbf") + self.set_input_defaults("scale_engine.orig_rating_fuel_flow", orig_rated_thrust, units="lbf") + self.connect("engine_deck.thrust", "scale_engine.unit_thrust") + self.connect("engine_deck.fuel_flow", "scale_engine.unit_fuel_flow") diff --git a/openconcept/propulsion/tests/test_rubberized_turbofan.py b/openconcept/propulsion/tests/test_rubberized_turbofan.py new file mode 100644 index 00000000..9fbeaeaf --- /dev/null +++ b/openconcept/propulsion/tests/test_rubberized_turbofan.py @@ -0,0 +1,83 @@ +import unittest +import openmdao.api as om +from openmdao.utils.assert_utils import assert_near_equal +from openconcept.propulsion import RubberizedTurbofan +from parameterized import parameterized_class + + +@parameterized_class([{"engine": "N3", "rating": 28620}, {"engine": "CFM56", "rating": 27300}]) +class RubberizedTurbofanTestCase(unittest.TestCase): + def test_default(self): + """ + Check that when rating equals original and hydrogen is False the outputs + are the same as the surrogate model. + """ + p = om.Problem() + p.model.add_subsystem("model", RubberizedTurbofan(engine=self.engine), promotes=["*"]) + p.setup() + p.set_val("ac|propulsion|engine|rating", self.rating, units="lbf") + p.run_model() + + assert_near_equal(p.get_val("engine_deck.thrust", units="N"), p.get_val("thrust", units="N")) + assert_near_equal(p.get_val("engine_deck.fuel_flow", units="kg/s"), p.get_val("fuel_flow", units="kg/s")) + + def test_multiple_engines(self): + """ + Check that thrust rating scales properly. + """ + N_eng = 3.4 + + p = om.Problem() + p.model.add_subsystem("model", RubberizedTurbofan(num_nodes=3, engine=self.engine), promotes=["*"]) + p.setup() + p.set_val("ac|propulsion|engine|rating", N_eng * self.rating) + p.run_model() + + assert_near_equal(p.get_val("engine_deck.thrust", units="N") * N_eng, p.get_val("thrust", units="N")) + assert_near_equal( + p.get_val("engine_deck.fuel_flow", units="kg/s") * N_eng, p.get_val("fuel_flow", units="kg/s") + ) + + def test_hydrogen(self): + """ + Check that hydrogen scales the fuel flow properly. + """ + p = om.Problem() + p.model.add_subsystem( + "model", RubberizedTurbofan(num_nodes=3, engine=self.engine, hydrogen=True), promotes=["*"] + ) + p.setup() + p.set_val("ac|propulsion|engine|rating", self.rating, units="lbf") + p.run_model() + + fuel_ratio = 43 / 120 + + assert_near_equal(p.get_val("engine_deck.thrust", units="N"), p.get_val("thrust", units="N")) + assert_near_equal( + p.get_val("engine_deck.fuel_flow", units="kg/s") * fuel_ratio, p.get_val("fuel_flow", units="kg/s") + ) + + def test_all_together(self): + """ + Check hydrogen and N_engines together. + """ + N_eng = 3.4 + + p = om.Problem() + p.model.add_subsystem( + "model", RubberizedTurbofan(num_nodes=3, engine=self.engine, hydrogen=True), promotes=["*"] + ) + p.setup() + p.set_val("ac|propulsion|engine|rating", N_eng * self.rating, units="lbf") + p.run_model() + + fuel_ratio = 43 / 120 + + assert_near_equal(p.get_val("engine_deck.thrust", units="N") * N_eng, p.get_val("thrust", units="N")) + assert_near_equal( + p.get_val("engine_deck.fuel_flow", units="kg/s") * fuel_ratio * N_eng, p.get_val("fuel_flow", units="kg/s") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/stability/__init__.py b/openconcept/stability/__init__.py new file mode 100644 index 00000000..df594d18 --- /dev/null +++ b/openconcept/stability/__init__.py @@ -0,0 +1 @@ +from .tail_volume_coefficient_sizing import HStabVolumeCoefficientSizing, VStabVolumeCoefficientSizing diff --git a/openconcept/stability/tail_volume_coefficient_sizing.py b/openconcept/stability/tail_volume_coefficient_sizing.py new file mode 100644 index 00000000..bb5b8c5e --- /dev/null +++ b/openconcept/stability/tail_volume_coefficient_sizing.py @@ -0,0 +1,138 @@ +import numpy as np +import openmdao.api as om + + +class HStabVolumeCoefficientSizing(om.ExplicitComponent): + """ + Computes horizontal stabilizer reference area using tail volume coefficient + method from Raymer (see Equation 6.27 in Section 6.4 of 1992 edition). + + Inputs + ------ + ac|geom|wing|S_ref : float + Wing planform area (scalar, sq ft) + ac|geom|wing|MAC : float + Wing mean aerodynamic chord (scalar, ft) + ac|geom|hstab|c4_to_wing_c4 : float + Distance from the horizontal stabilizer's quarter chord (of the MAC) to the wing's quarter chord (scalar, ft) + + Outputs + ------- + ac|geom|hstab|S_ref : float + Horizontal stabilizer reference area (scalar, sq ft) + + Options + ------- + C_ht : float + Tail volume coefficient for horizontal stabilizer, by default 1.00 from Table 6.4 in Raymer 1992 + for jet transport aircraft. See the table for other values (twin turboprop is 0.9). + """ + + def initialize(self): + self.options.declare( + "C_ht", + default=1.0, + desc="Horizontal tail volume coefficient", + ) + + def setup(self): + self.add_input("ac|geom|wing|S_ref", units="ft**2", desc="Reference wing area in sq ft") + self.add_input("ac|geom|wing|MAC", units="ft", desc="Wing mean aerodynamic chord") + self.add_input( + "ac|geom|hstab|c4_to_wing_c4", + units="ft", + desc="Distance from wing c/4 to horiz stab c/4 (tail arm distance)", + ) + + self.add_output("ac|geom|hstab|S_ref", units="ft**2") + self.declare_partials(["ac|geom|hstab|S_ref"], ["*"]) + + def compute(self, inputs, outputs): + C_ht = self.options["C_ht"] + outputs["ac|geom|hstab|S_ref"] = (C_ht * inputs["ac|geom|wing|MAC"] * inputs["ac|geom|wing|S_ref"]) / inputs[ + "ac|geom|hstab|c4_to_wing_c4" + ] + + def compute_partials(self, inputs, J): + C_ht = self.options["C_ht"] + J["ac|geom|hstab|S_ref", "ac|geom|wing|S_ref"] = (C_ht * inputs["ac|geom|wing|MAC"]) / inputs[ + "ac|geom|hstab|c4_to_wing_c4" + ] + J["ac|geom|hstab|S_ref", "ac|geom|wing|MAC"] = (C_ht * inputs["ac|geom|wing|S_ref"]) / inputs[ + "ac|geom|hstab|c4_to_wing_c4" + ] + J["ac|geom|hstab|S_ref", "ac|geom|hstab|c4_to_wing_c4"] = ( + -C_ht * inputs["ac|geom|wing|MAC"] * inputs["ac|geom|wing|S_ref"] + ) / (inputs["ac|geom|hstab|c4_to_wing_c4"] ** 2) + + +class VStabVolumeCoefficientSizing(om.ExplicitComponent): + """ + Computes vertical stabilizer reference area using tail volume coefficient + method from Raymer (see Equation 6.26 in Section 6.4 of 1992 edition). + + Inputs + ------ + ac|geom|wing|S_ref : float + Wing planform area (scalar, sq ft) + ac|geom|wing|AR : float + Wing aspect ratio (scalar, dimensionless) + ac|geom|vstab|c4_to_wing_c4 : float + Distance from the vertical stabilizer's quarter chord (of the MAC) to the wing's quarter chord (scalar, ft) + + Outputs + ------- + ac|geom|vstab|S_ref : float + Vertical stabilizer reference area (scalar, sq ft) + + Options + ------- + C_vt : float + Tail volume coefficient for vertical stabilizer, by default 0.09 from Table 6.4 in Raymer 1992 + for jet transport aircraft. See the table for other values (twin turboprop is 0.08). + """ + + def initialize(self): + self.options.declare( + "C_vt", + default=0.09, + desc="Vertical tail volume coefficient", + ) + + def setup(self): + self.add_input("ac|geom|wing|S_ref", units="ft**2", desc="Reference wing area in sq ft") + self.add_input("ac|geom|wing|AR", desc="Wing aspect ratio") + self.add_input( + "ac|geom|vstab|c4_to_wing_c4", + units="ft", + desc="Distance from wing c/4 to vertical stab c/4 (tail arm distance)", + ) + + self.add_output("ac|geom|vstab|S_ref", units="ft**2") + self.declare_partials(["ac|geom|vstab|S_ref"], ["*"]) + + def compute(self, inputs, outputs): + C_vt = self.options["C_vt"] + outputs["ac|geom|vstab|S_ref"] = ( + C_vt + * np.sqrt(inputs["ac|geom|wing|AR"]) + * (inputs["ac|geom|wing|S_ref"] ** (1.5)) + / inputs["ac|geom|vstab|c4_to_wing_c4"] + ) + + def compute_partials(self, inputs, J): + C_vt = self.options["C_vt"] + J["ac|geom|vstab|S_ref", "ac|geom|wing|S_ref"] = ( + 1.5 + * (C_vt * np.sqrt(inputs["ac|geom|wing|AR"] * inputs["ac|geom|wing|S_ref"])) + / inputs["ac|geom|vstab|c4_to_wing_c4"] + ) + J["ac|geom|vstab|S_ref", "ac|geom|wing|AR"] = 0.5 * ( + C_vt + * (inputs["ac|geom|wing|AR"] ** (-0.5)) + * (inputs["ac|geom|wing|S_ref"] ** (1.5)) + / inputs["ac|geom|vstab|c4_to_wing_c4"] + ) + J["ac|geom|vstab|S_ref", "ac|geom|vstab|c4_to_wing_c4"] = ( + -C_vt * np.sqrt(inputs["ac|geom|wing|AR"]) * inputs["ac|geom|wing|S_ref"] ** (1.5) + ) / (inputs["ac|geom|vstab|c4_to_wing_c4"] ** 2) diff --git a/openconcept/stability/tests/test_tail_volume_coefficient_sizing.py b/openconcept/stability/tests/test_tail_volume_coefficient_sizing.py new file mode 100644 index 00000000..8717d574 --- /dev/null +++ b/openconcept/stability/tests/test_tail_volume_coefficient_sizing.py @@ -0,0 +1,98 @@ +import unittest +import openmdao.api as om +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +from openconcept.stability import HStabVolumeCoefficientSizing, VStabVolumeCoefficientSizing + + +class HStabVolumeCoefficientSizingTestCase(unittest.TestCase): + def test_jet_transport(self): + S = 10.1 + c = 0.8 + L = 7 + K = 1.0 + + p = om.Problem() + p.model.add_subsystem("comp", HStabVolumeCoefficientSizing(C_ht=K), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("ac|geom|wing|S_ref", S, units="ft**2") + p.set_val("ac|geom|wing|MAC", c, units="ft") + p.set_val("ac|geom|hstab|c4_to_wing_c4", L, units="ft") + + p.run_model() + + assert_near_equal(p.get_val("ac|geom|hstab|S_ref", units="ft**2"), K * c * S / L) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + def test_twin_turboprop(self): + S = 6.1 + c = 0.5 + L = 7 + K = 0.9 + + p = om.Problem() + p.model.add_subsystem("comp", HStabVolumeCoefficientSizing(C_ht=K), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("ac|geom|wing|S_ref", S, units="ft**2") + p.set_val("ac|geom|wing|MAC", c, units="ft") + p.set_val("ac|geom|hstab|c4_to_wing_c4", L, units="ft") + + p.run_model() + + assert_near_equal(p.get_val("ac|geom|hstab|S_ref", units="ft**2"), K * c * S / L) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + +class VStabVolumeCoefficientSizingTestCase(unittest.TestCase): + def test_jet_transport(self): + S = 10.1 + AR = 9 + L = 7 + K = 0.09 + + p = om.Problem() + p.model.add_subsystem("comp", VStabVolumeCoefficientSizing(C_vt=K), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("ac|geom|wing|S_ref", S, units="ft**2") + p.set_val("ac|geom|wing|AR", AR) + p.set_val("ac|geom|vstab|c4_to_wing_c4", L, units="ft") + + p.run_model() + + span = (AR * S) ** 0.5 + assert_near_equal(p.get_val("ac|geom|vstab|S_ref", units="ft**2"), K * span * S / L) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + def test_twin_turboprop(self): + S = 8.1 + AR = 5 + L = 6 + K = 0.08 + + p = om.Problem() + p.model.add_subsystem("comp", VStabVolumeCoefficientSizing(C_vt=K), promotes=["*"]) + p.setup(force_alloc_complex=True) + + p.set_val("ac|geom|wing|S_ref", S, units="ft**2") + p.set_val("ac|geom|wing|AR", AR) + p.set_val("ac|geom|vstab|c4_to_wing_c4", L, units="ft") + + p.run_model() + + span = (AR * S) ** 0.5 + assert_near_equal(p.get_val("ac|geom|vstab|S_ref", units="ft**2"), K * span * S / L) + + p = p.check_partials(method="cs", out_stream=None) + assert_check_partials(p) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/utilities/constants.py b/openconcept/utilities/constants.py index dc34bca9..49333f88 100644 --- a/openconcept/utilities/constants.py +++ b/openconcept/utilities/constants.py @@ -1 +1,4 @@ -GRAV_CONST = 9.80665 # m/s^2 +GRAV_CONST = 9.80665 # m/s^2, gravitational constant +UNIVERSAL_GAS_CONST = 8.3145 # J/(mol-K), universal gas constant +MOLEC_WEIGHT_H2 = 2.01588e-3 # kg/mol, molecular weight of hydrogen +STEFAN_BOLTZMANN_CONST = 5.670374419e-8 # W/(m^2 K^4), Stefan-Boltzmann constant diff --git a/openconcept/weights/__init__.py b/openconcept/weights/__init__.py index 460a676f..b550ff32 100644 --- a/openconcept/weights/__init__.py +++ b/openconcept/weights/__init__.py @@ -10,3 +10,26 @@ EquipmentWeight_SmallTurboprop, ) from .weights_twin_hybrid import TwinSeriesHybridEmptyWeight + +from .weights_jet_transport import ( + WingWeight_JetTransport, + HstabConst_JetTransport, + HstabWeight_JetTransport, + VstabWeight_JetTransport, + FuselageKws_JetTransport, + FuselageWeight_JetTransport, + MainLandingGearWeight_JetTransport, + NoseLandingGearWeight_JetTransport, + EngineWeight_JetTransport, + EngineSystemsWeight_JetTransport, + NacelleWeight_JetTransport, + FurnishingWeight_JetTransport, + EquipmentWeight_JetTransport, + JetTransportEmptyWeight, +) + +from .weights_BWB import ( + CabinWeight_BWB, + AftbodyWeight_BWB, + BWBEmptyWeight, +) diff --git a/openconcept/weights/tests/test_weights_BWB.py b/openconcept/weights/tests/test_weights_BWB.py new file mode 100644 index 00000000..da1eebe5 --- /dev/null +++ b/openconcept/weights/tests/test_weights_BWB.py @@ -0,0 +1,63 @@ +import unittest +import openmdao.api as om +from openmdao.utils.assert_utils import assert_check_partials, assert_near_equal +from openconcept.weights import BWBEmptyWeight + + +class BWBEmptyWeightTestCase(unittest.TestCase): + def test_BWB(self): + """ + There is very limited validation data for BWB empty weights, so this + just ensures the output value is reasonable. + """ + prob = om.Problem() + prob.model = om.Group() + + dvs = prob.model.add_subsystem("dvs", om.IndepVarComp(), promotes_outputs=["*"]) + dvs.add_output("ac|num_passengers_max", 406) + dvs.add_output("ac|num_flight_deck_crew", 2) + dvs.add_output("ac|num_cabin_crew", 9) + dvs.add_output("ac|cabin_pressure", 10, units="psi") + + dvs.add_output("ac|aero|Mach_max", 0.9) + dvs.add_output("ac|aero|Vstall_land", 135, units="kn") + + dvs.add_output("ac|geom|wing|S_ref", 100, units="m**2") + dvs.add_output("ac|geom|wing|AR", 9) + dvs.add_output("ac|geom|wing|c4sweep", 32.2, units="deg") + dvs.add_output("ac|geom|wing|taper", 0.3) + dvs.add_output("ac|geom|wing|toverc", 0.12) + + dvs.add_output("ac|geom|centerbody|S_cabin", 2800, units="ft**2") + dvs.add_output("ac|geom|centerbody|S_aftbody", 1000, units="ft**2") + dvs.add_output("ac|geom|centerbody|taper_aftbody", 0.6) + + dvs.add_output("ac|geom|V_pressurized", 2800 * 10, units="ft**3") + + dvs.add_output("ac|geom|maingear|length", 9.7, units="ft") + dvs.add_output("ac|geom|maingear|num_wheels", 8) + dvs.add_output("ac|geom|maingear|num_shock_struts", 2) + + dvs.add_output("ac|geom|nosegear|length", 6, units="ft") + dvs.add_output("ac|geom|nosegear|num_wheels", 2) + + dvs.add_output("ac|propulsion|engine|rating", 74.1e3, units="lbf") + dvs.add_output("ac|propulsion|num_engines", 2) + + dvs.add_output("ac|weights|MTOW", 500e3, units="lb") + dvs.add_output("ac|weights|MLW", 400e3, units="lb") + dvs.add_output("ac|weights|W_fuel_max", 200e3, units="lb") + + prob.model.add_subsystem("OEW", BWBEmptyWeight(structural_fudge=1.2, total_fudge=1.15), promotes_inputs=["*"]) + + prob.setup(force_alloc_complex=True) + prob.run_model() + + assert_near_equal(prob.get_val("OEW.OEW"), 276337.38740904, tolerance=1e-6) + + partials = prob.check_partials(method="cs", out_stream=None, compact_print=True, show_only_incorrect=False) + assert_check_partials(partials) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/weights/tests/test_weights_jet_transport.py b/openconcept/weights/tests/test_weights_jet_transport.py new file mode 100644 index 00000000..411f6e23 --- /dev/null +++ b/openconcept/weights/tests/test_weights_jet_transport.py @@ -0,0 +1,195 @@ +import unittest +import openmdao.api as om +from openmdao.utils.assert_utils import assert_check_partials, assert_near_equal +from openconcept.weights import JetTransportEmptyWeight + + +class JetTransportEmptyWeightTestCase(unittest.TestCase): + def test_B732(self): + """ + 737-200 validation case. This is useful since Roskam has the component weight + breakdown in the appendix. Parameter data from a combination of: + - Technical site: http://www.b737.org.uk/techspecsdetailed.htm + - Wikipedia: https://en.wikipedia.org/wiki/Boeing_737#Specifications + """ + prob = om.Problem() + prob.model = om.Group() + + dvs = prob.model.add_subsystem("dvs", om.IndepVarComp(), promotes_outputs=["*"]) + dvs.add_output("ac|num_passengers_max", 136) + dvs.add_output("ac|num_flight_deck_crew", 2) + dvs.add_output("ac|num_cabin_crew", 3) + dvs.add_output("ac|cabin_pressure", 8.5, units="psi") + + dvs.add_output("ac|aero|Mach_max", 0.82) + dvs.add_output("ac|aero|Vstall_land", 115, units="kn") # estimate + + dvs.add_output("ac|geom|wing|S_ref", 102, units="m**2") + dvs.add_output("ac|geom|wing|AR", 8.83) + dvs.add_output("ac|geom|wing|c4sweep", 25, units="deg") + dvs.add_output("ac|geom|wing|taper", 0.266) + dvs.add_output("ac|geom|wing|toverc", 0.12) # guess + + dvs.add_output("ac|geom|hstab|S_ref", 28.99, units="m**2") + dvs.add_output("ac|geom|hstab|AR", 4.15) + dvs.add_output("ac|geom|hstab|c4sweep", 30, units="deg") + dvs.add_output("ac|geom|hstab|c4_to_wing_c4", 29.54 / 2, units="m") # guess (half of fuselage length) + + dvs.add_output("ac|geom|vstab|S_ref", 20.81, units="m**2") + dvs.add_output("ac|geom|vstab|AR", 1.64) + dvs.add_output("ac|geom|vstab|c4sweep", 35, units="deg") + dvs.add_output("ac|geom|vstab|toverc", 0.12) # guess + dvs.add_output("ac|geom|vstab|c4_to_wing_c4", 29.54 / 2, units="m") # guess (half of fuselage length) + + dvs.add_output("ac|geom|fuselage|height", 3.76, units="m") + dvs.add_output("ac|geom|fuselage|length", 29.54, units="m") + dvs.add_output("ac|geom|fuselage|S_wet", 350, units="m**2") # estimate using cylinder + + dvs.add_output("ac|geom|maingear|length", 1.8, units="m") + dvs.add_output("ac|geom|maingear|num_wheels", 4) + dvs.add_output("ac|geom|maingear|num_shock_struts", 2) + + dvs.add_output("ac|geom|nosegear|length", 1.3, units="m") + dvs.add_output("ac|geom|nosegear|num_wheels", 2) + + dvs.add_output("ac|propulsion|engine|rating", 16e3, units="lbf") + dvs.add_output("ac|propulsion|num_engines", 2) + + dvs.add_output("ac|weights|MTOW", 115500, units="lb") + dvs.add_output("ac|weights|MLW", 48534, units="lb") + dvs.add_output("ac|weights|W_fuel_max", 34718, units="lb") + + prob.model.add_subsystem("OEW", JetTransportEmptyWeight(), promotes_inputs=["*"]) + + prob.setup(force_alloc_complex=True) + prob.run_model() + + # Check that the result is unchanged, the actual 737-200 OEW is 60,210 lbs according to Roskam + assert_near_equal(prob.get_val("OEW.OEW"), 60724.22319076, tolerance=1e-6) + + partials = prob.check_partials(method="cs", out_stream=None, compact_print=True, show_only_incorrect=False) + assert_check_partials(partials) + + def test_B738(self): + """ + 737-800 validation case. Data from a combination of: + - Technical site: http://www.b737.org.uk/techspecsdetailed.htm + - Wikipedia: https://en.wikipedia.org/wiki/Boeing_737_Next_Generation#Specifications + """ + prob = om.Problem() + prob.model = om.Group() + + dvs = prob.model.add_subsystem("dvs", om.IndepVarComp(), promotes_outputs=["*"]) + dvs.add_output("ac|num_passengers_max", 189) + dvs.add_output("ac|num_flight_deck_crew", 2) + dvs.add_output("ac|num_cabin_crew", 4) + dvs.add_output("ac|cabin_pressure", 8.95, units="psi") + + dvs.add_output("ac|aero|Mach_max", 0.82) + dvs.add_output("ac|aero|Vstall_land", 115, units="kn") # estimate + + dvs.add_output("ac|geom|wing|S_ref", 124.6, units="m**2") + dvs.add_output("ac|geom|wing|AR", 9.45) + dvs.add_output("ac|geom|wing|c4sweep", 25, units="deg") + dvs.add_output("ac|geom|wing|taper", 0.159) + dvs.add_output("ac|geom|wing|toverc", 0.12) # guess + + dvs.add_output("ac|geom|hstab|S_ref", 32.78, units="m**2") + dvs.add_output("ac|geom|hstab|AR", 6.16) + dvs.add_output("ac|geom|hstab|c4sweep", 30, units="deg") + dvs.add_output("ac|geom|hstab|c4_to_wing_c4", 38.08 / 2, units="m") # guess (half of fuselage length) + + dvs.add_output("ac|geom|vstab|S_ref", 26.44, units="m**2") + dvs.add_output("ac|geom|vstab|AR", 1.91) + dvs.add_output("ac|geom|vstab|c4sweep", 35, units="deg") + dvs.add_output("ac|geom|vstab|toverc", 0.12) # guess + dvs.add_output("ac|geom|vstab|c4_to_wing_c4", 38.08 / 2, units="m") # guess (half of fuselage length) + + dvs.add_output("ac|geom|fuselage|height", 3.76, units="m") + dvs.add_output("ac|geom|fuselage|length", 38.08, units="m") + dvs.add_output("ac|geom|fuselage|S_wet", 450, units="m**2") # estimate using cylinder + + dvs.add_output("ac|geom|maingear|length", 1.8, units="m") + dvs.add_output("ac|geom|maingear|num_wheels", 4) + dvs.add_output("ac|geom|maingear|num_shock_struts", 2) + + dvs.add_output("ac|geom|nosegear|length", 1.3, units="m") + dvs.add_output("ac|geom|nosegear|num_wheels", 2) + + dvs.add_output("ac|propulsion|engine|rating", 24.2e3, units="lbf") + dvs.add_output("ac|propulsion|num_engines", 2) + + dvs.add_output("ac|weights|MTOW", 79002, units="kg") + dvs.add_output("ac|weights|MLW", 66349, units="kg") + dvs.add_output("ac|weights|W_fuel_max", 21000, units="kg") + + prob.model.add_subsystem("OEW", JetTransportEmptyWeight(), promotes_inputs=["*"]) + + prob.setup(force_alloc_complex=True) + prob.run_model() + + # Check that the result is unchanged, the actual 737-800 OEW is 91,300 lbs + assert_near_equal(prob.get_val("OEW.OEW"), 91377.02987079, tolerance=1e-6) + + def test_B789(self): + """ + 787-9 validation case. Data from https://en.wikipedia.org/wiki/Boeing_787_Dreamliner#Specifications. + """ + prob = om.Problem() + prob.model = om.Group() + + dvs = prob.model.add_subsystem("dvs", om.IndepVarComp(), promotes_outputs=["*"]) + dvs.add_output("ac|num_passengers_max", 406) + dvs.add_output("ac|num_flight_deck_crew", 2) + dvs.add_output("ac|num_cabin_crew", 9) + dvs.add_output("ac|cabin_pressure", 11.8, units="psi") # 6,000 ft cabin altitude + + dvs.add_output("ac|aero|Mach_max", 0.9) + dvs.add_output("ac|aero|Vstall_land", 135, units="kn") # estimate + + dvs.add_output("ac|geom|wing|S_ref", 377, units="m**2") + dvs.add_output("ac|geom|wing|AR", 9.59) + dvs.add_output("ac|geom|wing|c4sweep", 32.2, units="deg") + dvs.add_output("ac|geom|wing|taper", 0.18) + dvs.add_output("ac|geom|wing|toverc", 0.13) # guess + + dvs.add_output("ac|geom|hstab|S_ref", 77.3, units="m**2") + dvs.add_output("ac|geom|hstab|AR", 5) + dvs.add_output("ac|geom|hstab|c4sweep", 36, units="deg") + dvs.add_output("ac|geom|hstab|c4_to_wing_c4", 61 / 2, units="m") # guess (half of fuselage length) + + dvs.add_output("ac|geom|vstab|S_ref", 39.7, units="m**2") + dvs.add_output("ac|geom|vstab|AR", 1.7) + dvs.add_output("ac|geom|vstab|c4sweep", 40, units="deg") + dvs.add_output("ac|geom|vstab|toverc", 0.1) # guess + dvs.add_output("ac|geom|vstab|c4_to_wing_c4", 61 / 2, units="m") # guess (half of fuselage length) + + dvs.add_output("ac|geom|fuselage|height", 5.9, units="m") + dvs.add_output("ac|geom|fuselage|length", 61, units="m") + dvs.add_output("ac|geom|fuselage|S_wet", 1131, units="m**2") # estimate using cylinder + + dvs.add_output("ac|geom|maingear|length", 9.7, units="ft") + dvs.add_output("ac|geom|maingear|num_wheels", 8) + dvs.add_output("ac|geom|maingear|num_shock_struts", 2) + + dvs.add_output("ac|geom|nosegear|length", 6, units="ft") + dvs.add_output("ac|geom|nosegear|num_wheels", 2) + + dvs.add_output("ac|propulsion|engine|rating", 74.1e3, units="lbf") + dvs.add_output("ac|propulsion|num_engines", 2) + + dvs.add_output("ac|weights|MTOW", 561.5e3, units="lb") + dvs.add_output("ac|weights|MLW", 425e3, units="lb") + dvs.add_output("ac|weights|W_fuel_max", 223673, units="lb") + + prob.model.add_subsystem("OEW", JetTransportEmptyWeight(), promotes_inputs=["*"]) + + prob.setup(force_alloc_complex=True) + prob.run_model() + + # Check that the result is unchanged, the actual 787-9 OEW is 284,000 lbs + assert_near_equal(prob.get_val("OEW.OEW"), 289119.77474878, tolerance=1e-6) + + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/weights/weights_BWB.py b/openconcept/weights/weights_BWB.py new file mode 100644 index 00000000..4acc042c --- /dev/null +++ b/openconcept/weights/weights_BWB.py @@ -0,0 +1,523 @@ +""" +@File : weights_BWB.py +@Date : 2023/03/20 +@Author : Eytan Adler +@Description : BWB weight estimation methods +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== + + +# ============================================================================== +# External Python modules +# ============================================================================== +import openmdao.api as om + +# ============================================================================== +# Extension modules +# ============================================================================== +from openconcept.utilities import ElementMultiplyDivideComp, AddSubtractComp +from openconcept.weights.weights_jet_transport import ( + WingWeight_JetTransport, + MainLandingGearWeight_JetTransport, + NoseLandingGearWeight_JetTransport, + EngineWeight_JetTransport, + EngineSystemsWeight_JetTransport, + NacelleWeight_JetTransport, + FurnishingWeight_JetTransport, + EquipmentWeight_JetTransport, +) + + +class BWBEmptyWeight(om.Group): + """ + Estimate the empty weight of a BWB. + + Inputs + ------ + ac|num_passengers_max : float + Maximum number of passengers (scalar, dimensionless) + ac|num_flight_deck_crew : float + Number of flight crew members (scalar, dimensionless) + ac|num_cabin_crew : float + Number of flight attendants (scalar, dimensionless) + ac|cabin_pressure : float + Cabin pressure (scalar, psi) + ac|aero|Mach_max : float + Maximum aircraft Mach number (scalar, dimensionless) + ac|aero|Vstall_land : float + Landing stall speed (scalar, knots) + ac|geom|centerbody|S_cabin : float + Planform area of the pressurized centerbody cabin area (scalar, sq ft) + ac|geom|centerbody|S_aftbody : float + Planform area of the centerbody aft of the cabin (scalar, sq ft) + ac|geom|centerbody|taper_aftbody : float + Taper ratio of the ceterbody region aft of the cabin (scalar, dimensionless) + ac|geom|wing|S_ref : float + Outboard wing planform reference area (scalar, sq ft) + ac|geom|wing|AR : float + Outboard wing aspect ratio (scalar, dimensionless) + ac|geom|wing|c4sweep : float + Outboard wing sweep at 25% mean aerodynamic chord (scalar, radians) + ac|geom|wing|taper : float + Outboard wing taper ratio (scalar, dimensionless) + ac|geom|wing|toverc : float + Outboard wing root thickness-to-chord ratio (scalar, dimensionless) + ac|geom|maingear|length : float + Length of the main landing gear (scalar, inch) + ac|geom|maingear|num_wheels : float + Total number of main landing gear wheels (scalar, dimensionless) + ac|geom|maingear|num_shock_struts : float + Total number of main landing gear shock struts (scalar, dimensionless) + ac|geom|nosegear|length : float + Length of the nose landing gear (scalar, inch) + ac|geom|nosegear|num_wheels : float + Total number of nose landing gear wheels (scalar, dimensionless) + ac|geom|V_pressurized : float + Volume of the pressurized cabin (scalar, cubic ft) + ac|propulsion|engine|rating : float + Rated thrust of each engine (scalar, lbf) + ac|propulsion|num_engines : float + Number of engines (scalar, dimensionless) + ac|weights|MTOW : float + Maximum takeoff weight (scalar, lb) + ac|weights|MLW : float + Maximum landing weight (scalar, lb) + ac|weights|W_fuel_max : float + Maximum fuel weight (scalar, lb) + + Outputs + ------- + OEW : float + Total operating empty weight (scalar, lb) + W_cabin : float + Weight of the pressurized cabin region of the BWB without structural fudge factor multiplier (scalar, lb) + W_aftbody : float + Weight of the centerbody region aft of the pressurized cabin without structural fudge factor multiplier (scalar, lb) + W_wing : float + Estimated outboard wing weight without structural fudge factor multiplier (scalar, lb) + W_mlg : float + Main landing gear weight without structural fudge factor multiplier (scalar, lb) + W_nlg : float + Nose landing gear weight without structural fudge factor multiplier (scalar, lb) + W_nacelle : float + Weight of the nacelles (scalar, lb) + W_structure : float + Total structural weight = fudge factor * (W_cabin + W_aftbody + W_wing + W_mlg + W_nlg + W_nacelle) (scalar, lb) + W_engines : float + Total dry engine weight (scalar, lb) + W_thrust_rev : float + Total thrust reverser weight (scalar, lb) + W_eng_control : float + Total engine control weight (scalar, lb) + W_fuelsystem : float + Total fuel system weight including tanks and plumbing (scalar, lb) + W_eng_start : float + Total engine starter weight (scalar, lb) + W_furnishings : float + Weight estimate of seats, galleys, lavatories, and other furnishings (scalar, lb) + W_flight_controls : float + Flight control system weight (scalar, lb) + W_avionics : float + Intrumentation, avionics, and electronics weight (scalar, lb) + W_electrical : float + Electrical system weight (scalar, lb) + W_ac_pressurize_antiice : float + Air conditioning, pressurization, and anti-icing system weight (scalar, lb) + W_oxygen : float + Oxygen system weight (scalar, lb) + W_APU : float + Auxiliary power unit weight (scalar, lb) + + Options + ------- + structural_fudge : float + Multiplier on the structural weight to allow the user to account for miscellaneous items and + advanced materials. Structural weight includes wing, horizontal stabilizer, vertical stabilizer, + fuselage, landing gear, and nacelle weights. By default 1.0 (scalar, dimensionless) + total_fudge : float + Multiplier on the final operating empty weight estimate. Structural components have both the + structural fudge and total fudge factors applied. By default 1.0 (scalar, dimensionless) + wing_weight_multiplier : float + Multiplier on wing weight. This can be used as a very rough way of increasing wing weight + due to lack of inertial load relief from the fuel. By default 1.0 (scalar, dimensionless) + n_ult : float + Ultimate load factor, 1.5 x limit load factor, by default 1.5 x 2.5 (scalar, dimensionless) + n_land_ult : float + Ultimate landing load factor, which is 1.5 times the gear load factor (defined + in equation 11.11). Table 11.5 gives reasonable gear load factor values for + different aircraft types, with commercial aircraft in the 2.7-3 range. Default + is taken at 2.8, thus the ultimate landing load factor is 2.8 x 1.5 (scalar, dimensionless) + control_surface_area_frac : float + Fraction of the total wing area covered by control surfaces and flaps, by default 0.1 (scalar, dimensionless) + kneeling_main_gear_parameter : float + Set to 1.126 for kneeling main gear and 1 otherwise, by default 1 (scalar, dimensionless) + kneeling_nose_gear_parameter : float + Set to 1.15 for kneeling nose gear and 1 otherwise, by default 1 (scalar, dimensionless) + K_lav : float + Lavatory coefficient; 0.31 for short ranges and 1.11 for long ranges, by default 0.7 + K_buf : float + Food provisions coefficient; 1.02 for short range and 5.68 for very long range, by default 4 + coeff_fc : float + K_fc in Roskam times any additional coefficient. The book says take K_fc as 0.44 for un-powered + flight controls and 0.64 for powered flight controls. Multiply this coefficient by 1.2 if leading + edge devices are employed. If lift dumpers are employed, use a factor of 1.15. By default 1.2 * 0.64. + coeff_avionics : float + Roskam notes that the avionics weight estimates are probably conservative for modern computer-based + flight management and navigation systems. This coefficient is multiplied by the Roskam estimate to + account for this. By default 0.5. + APU_weight_frac : float + Auxiliary power unit weight divided by maximum takeoff weight, by deafult 0.0085. + """ + + def initialize(self): + self.options.declare("structural_fudge", default=1.0, desc="Fudge factor on structural weights") + self.options.declare("total_fudge", default=1.0, desc="Fudge factor applied to the final OEW value") + self.options.declare("wing_weight_multiplier", default=1.0, desc="Multiplier on wing weight") + self.options.declare("n_ult", default=2.5 * 1.5, desc="Ultimate load factor (dimensionless)") + self.options.declare("n_land_ult", default=2.8 * 1.5, desc="ultimate landing load factor") + self.options.declare( + "control_surface_area_frac", default=0.1, desc="Fraction of wing area covered by control surfaces and flaps" + ) + self.options.declare("kneeling_main_gear_parameter", default=1.0, desc="Kneeling main landing gear parameter") + self.options.declare("kneeling_nose_gear_parameter", default=1.0, desc="Kneeling nose landing gear parameter") + self.options.declare("K_lav", default=0.7, desc="Lavatory weight coefficient") + self.options.declare("K_buf", default=4.0, desc="Food weight coefficient") + self.options.declare("coeff_fc", default=1.2 * 0.64, desc="Coefficient on flight control system weight") + self.options.declare("coeff_avionics", default=0.5, desc="Coefficient on avionics weight") + self.options.declare("APU_weight_frac", default=0.0085, desc="APU weight / MTOW") + + def setup(self): + n_ult = self.options["n_ult"] + + # ============================================================================== + # BWB structure weights + # ============================================================================== + # -------------- Pressurized cabin -------------- + self.add_subsystem( + "cabin", + CabinWeight_BWB(), + promotes_inputs=["ac|geom|centerbody|S_cabin", "ac|weights|MTOW"], + promotes_outputs=["W_cabin"], + ) + + # -------------- Unpressurized portion of the centerbody -------------- + self.add_subsystem( + "aftbody", + AftbodyWeight_BWB(), + promotes_inputs=[ + "ac|geom|centerbody|S_aftbody", + "ac|geom|centerbody|taper_aftbody", + "ac|propulsion|num_engines", + "ac|weights|MTOW", + ], + promotes_outputs=["W_aftbody"], + ) + + # -------------- Outboard wing modeled with conventional wing weight estimate -------------- + self.add_subsystem( + "wing", + WingWeight_JetTransport(n_ult=n_ult, control_surface_area_frac=self.options["control_surface_area_frac"]), + promotes_inputs=[ + "ac|weights|MTOW", + "ac|geom|wing|S_ref", + "ac|geom|wing|AR", + "ac|geom|wing|c4sweep", + "ac|geom|wing|taper", + "ac|geom|wing|toverc", + ], + promotes_outputs=["W_wing"], + ) + + # ============================================================================== + # Landing gear + # ============================================================================== + # -------------- Main gear -------------- + self.add_subsystem( + "main_gear", + MainLandingGearWeight_JetTransport( + n_land_ult=self.options["n_land_ult"], + kneeling_gear_parameter=self.options["kneeling_main_gear_parameter"], + ), + promotes_inputs=[ + "ac|weights|MLW", + "ac|geom|maingear|length", + "ac|geom|maingear|num_wheels", + "ac|geom|maingear|num_shock_struts", + "ac|aero|Vstall_land", + ], + promotes_outputs=["W_mlg"], + ) + + # -------------- Nose gear -------------- + self.add_subsystem( + "nose_gear", + NoseLandingGearWeight_JetTransport( + n_land_ult=self.options["n_land_ult"], + kneeling_gear_parameter=self.options["kneeling_nose_gear_parameter"], + ), + promotes_inputs=[ + "ac|weights|MLW", + "ac|geom|nosegear|length", + "ac|geom|nosegear|num_wheels", + ], + promotes_outputs=["W_nlg"], + ) + + # ============================================================================== + # Propulsion system + # ============================================================================== + # -------------- Dry engine -------------- + # Engine weight computes a single engine, so it must be multiplied by the number of engines + self.add_subsystem( + "single_engine", + EngineWeight_JetTransport(), + promotes_inputs=["ac|propulsion|engine|rating"], + ) + self.add_subsystem( + "engine_multiplier", + ElementMultiplyDivideComp( + output_name="W_engines", input_names=["W_engine", "ac|propulsion|num_engines"], input_units=["lb", None] + ), + promotes_inputs=["ac|propulsion|num_engines"], + promotes_outputs=["W_engines"], + ) + self.connect("single_engine.W_engine", "engine_multiplier.W_engine") + + # -------------- Engine systems -------------- + self.add_subsystem( + "engine_systems", + EngineSystemsWeight_JetTransport(), + promotes_inputs=[ + "ac|propulsion|engine|rating", + "ac|propulsion|num_engines", + "ac|aero|Mach_max", + "ac|weights|W_fuel_max", + ], + promotes_outputs=[ + "W_thrust_rev", + "W_eng_control", + "W_fuelsystem", + "W_eng_start", + ], + ) + self.connect("single_engine.W_engine", "engine_systems.W_engine") + + # -------------- Nacelle -------------- + self.add_subsystem( + "nacelles", + NacelleWeight_JetTransport(), + promotes_inputs=["ac|propulsion|engine|rating", "ac|propulsion|num_engines"], + promotes_outputs=["W_nacelle"], + ) + + # ============================================================================== + # Furnishings for passengers + # ============================================================================== + self.add_subsystem( + "furnishings", + FurnishingWeight_JetTransport(K_lav=self.options["K_lav"], K_buf=self.options["K_buf"]), + promotes_inputs=[ + "ac|num_passengers_max", + "ac|num_flight_deck_crew", + "ac|num_cabin_crew", + "ac|cabin_pressure", + "ac|weights|MTOW", + ], + promotes_outputs=["W_furnishings"], + ) + + # ============================================================================== + # Other equipment + # ============================================================================== + self.add_subsystem( + "equipment", + EquipmentWeight_JetTransport( + coeff_fc=self.options["coeff_fc"], + coeff_avionics=self.options["coeff_avionics"], + APU_weight_frac=self.options["APU_weight_frac"], + ), + promotes_inputs=[ + "ac|weights|MTOW", + "ac|num_passengers_max", + "ac|num_cabin_crew", + "ac|num_flight_deck_crew", + "ac|propulsion|num_engines", + "ac|geom|V_pressurized", + "W_fuelsystem", + ], + promotes_outputs=[ + "W_flight_controls", + "W_avionics", + "W_electrical", + "W_ac_pressurize_antiice", + "W_oxygen", + "W_APU", + ], + ) + + # ============================================================================== + # Multiply structural weights by fudge factor + # ============================================================================== + structure_weight_outputs = ["W_wing", "W_cabin", "W_aftbody", "W_mlg", "W_nlg", "W_nacelle"] + scaling_factors = [self.options["structural_fudge"]] * len(structure_weight_outputs) + scaling_factors[0] *= self.options["wing_weight_multiplier"] + self.add_subsystem( + "structural_adjustment", + AddSubtractComp( + output_name="W_structure", + input_names=structure_weight_outputs, + scaling_factors=scaling_factors, + units="lb", + ), + promotes_inputs=["*"], + promotes_outputs=["W_structure"], + ) + + # ============================================================================== + # Sum all weights to compute total operating empty weight + # ============================================================================== + final_weight_components = [ + "W_structure", + "W_engines", + "W_thrust_rev", + "W_eng_control", + "W_fuelsystem", + "W_eng_start", + "W_furnishings", + "W_flight_controls", + "W_avionics", + "W_electrical", + "W_ac_pressurize_antiice", + "W_oxygen", + "W_APU", + ] + self.add_subsystem( + "sum_weights", + AddSubtractComp( + output_name="OEW", + input_names=final_weight_components, + scaling_factors=[self.options["total_fudge"]] * len(final_weight_components), + units="lb", + ), + promotes_inputs=["*"], + promotes_outputs=["OEW"], + ) + + +class CabinWeight_BWB(om.ExplicitComponent): + """ + Compute the weight of the pressurized cabin portion of a BWB centerbody. Estimate + is based on a curve fit of FEA models. Details described in "A Sizing Methodology + for the Conceptual Design of Blended-Wing-Body Transports" by Kevin R. Bradley. + + Inputs + ------ + ac|geom|centerbody|S_cabin : float + Planform area of the pressurized centerbody cabin area (scalar, sq ft) + ac|weights|MTOW : float + Maximum takeoff weight (scalar, lb) + + Outputs + ------- + W_cabin : float + Weight of the pressurized cabin region of the BWB (scalar, lb) + """ + + def setup(self): + self.add_input("ac|geom|centerbody|S_cabin", units="ft**2") + self.add_input("ac|weights|MTOW", units="lb") + self.add_output("W_cabin", val=100e3, units="lb") + self.declare_partials("*", "*") + + def compute(self, inputs, outputs): + outputs["W_cabin"] = ( + 1.803246 * inputs["ac|weights|MTOW"] ** 0.166552 * inputs["ac|geom|centerbody|S_cabin"] ** 1.061158 + ) + + def compute_partials(self, inputs, J): + J["W_cabin", "ac|geom|centerbody|S_cabin"] = ( + 1.803246 + * inputs["ac|weights|MTOW"] ** 0.166552 + * 1.061158 + * inputs["ac|geom|centerbody|S_cabin"] ** 0.061158 + ) + J["W_cabin", "ac|weights|MTOW"] = ( + 1.803246 + * 0.166552 + * inputs["ac|weights|MTOW"] ** (0.166552 - 1) + * inputs["ac|geom|centerbody|S_cabin"] ** 1.061158 + ) + + +class AftbodyWeight_BWB(om.ExplicitComponent): + """ + Compute the weight of the portion of the centerbody aft of the pressurized region + (behind the rear spar). Estimate is based on a curve fit of FEA models. Details + described in "A Sizing Methodology for the Conceptual Design of Blended-Wing-Body + Transports" by Kevin R. Bradley. + + Inputs + ------ + ac|geom|centerbody|S_aftbody : float + Planform area of the centerbody aft of the cabin (scalar, sq ft) + ac|geom|centerbody|taper_aftbody : float + Taper ratio of the ceterbody region aft of the cabin (scalar, dimensionless) + ac|propulsion|num_engines : float + Number of engines (scalar, dimensionless) + ac|weights|MTOW : float + Maximum takeoff weight (scalar, lb) + + Outputs + ------- + W_aftbody : float + Weight of the centerbody region aft of the pressurized cabin (scalar, lb) + """ + + def setup(self): + self.add_input("ac|geom|centerbody|S_aftbody", units="ft**2") + self.add_input("ac|geom|centerbody|taper_aftbody") + self.add_input("ac|propulsion|num_engines") + self.add_input("ac|weights|MTOW", units="lb") + self.add_output("W_aftbody", units="lb") + self.declare_partials("*", "*") + + def compute(self, inputs, outputs): + outputs["W_aftbody"] = ( + 0.53 + * (1 + 0.05 * inputs["ac|propulsion|num_engines"]) + * inputs["ac|geom|centerbody|S_aftbody"] + * inputs["ac|weights|MTOW"] ** 0.2 + * (inputs["ac|geom|centerbody|taper_aftbody"] + 0.5) + ) + + def compute_partials(self, inputs, J): + J["W_aftbody", "ac|geom|centerbody|S_aftbody"] = ( + 0.53 + * (1 + 0.05 * inputs["ac|propulsion|num_engines"]) + * inputs["ac|weights|MTOW"] ** 0.2 + * (inputs["ac|geom|centerbody|taper_aftbody"] + 0.5) + ) + J["W_aftbody", "ac|geom|centerbody|taper_aftbody"] = ( + 0.53 + * (1 + 0.05 * inputs["ac|propulsion|num_engines"]) + * inputs["ac|geom|centerbody|S_aftbody"] + * inputs["ac|weights|MTOW"] ** 0.2 + ) + J["W_aftbody", "ac|propulsion|num_engines"] = ( + 0.53 + * 0.05 + * inputs["ac|geom|centerbody|S_aftbody"] + * inputs["ac|weights|MTOW"] ** 0.2 + * (inputs["ac|geom|centerbody|taper_aftbody"] + 0.5) + ) + J["W_aftbody", "ac|weights|MTOW"] = ( + 0.53 + * (1 + 0.05 * inputs["ac|propulsion|num_engines"]) + * inputs["ac|geom|centerbody|S_aftbody"] + * 0.2 + * inputs["ac|weights|MTOW"] ** (0.2 - 1) + * (inputs["ac|geom|centerbody|taper_aftbody"] + 0.5) + ) diff --git a/openconcept/weights/weights_jet_transport.py b/openconcept/weights/weights_jet_transport.py new file mode 100644 index 00000000..5c61684f --- /dev/null +++ b/openconcept/weights/weights_jet_transport.py @@ -0,0 +1,1827 @@ +import numpy as np +import openmdao.api as om +from openconcept.utilities.math import AddSubtractComp, ElementMultiplyDivideComp + + +class JetTransportEmptyWeight(om.Group): + """ + Estimate of a jet transport aircraft's operating empty weight using a combination + of weight estimation methods from Raymer, Roskam, and others. See the docstrings + for individual weight components for more details on the models used. + + Inputs + ------ + ac|num_passengers_max : float + Maximum number of passengers (scalar, dimensionless) + ac|num_flight_deck_crew : float + Number of flight crew members (scalar, dimensionless) + ac|num_cabin_crew : float + Number of flight attendants (scalar, dimensionless) + ac|cabin_pressure : float + Cabin pressure (scalar, psi) + ac|aero|Mach_max : float + Maximum aircraft Mach number (scalar, dimensionless) + ac|aero|Vstall_land : float + Landing stall speed (scalar, knots) + ac|geom|wing|S_ref : float + Wing planform reference area (scalar, sq ft) + ac|geom|wing|AR : float + Wing aspect ratio (scalar, dimensionless) + ac|geom|wing|c4sweep : float + Wing sweep at 25% mean aerodynamic chord (scalar, radians) + ac|geom|wing|taper : float + Wing taper ratio (scalar, dimensionless) + ac|geom|wing|toverc : float + Wing root thickness-to-chord ratio (scalar, dimensionless) + ac|geom|hstab|S_ref : float + Horizontal stabilizer reference area (scalar, sq ft) + ac|geom|hstab|AR : float + Horizontal stabilizer aspect ratio (scalar, dimensionless) + ac|geom|hstab|c4sweep : float + Horizontal stabilizer sweep at 25% mean aerodynamic chord (scalar, radians) + ac|geom|hstab|c4_to_wing_c4 : float + Distance from the horizontal stabilizer's quarter chord (of the MAC) to the wing's quarter chord (scalar, ft) + ac|geom|vstab|S_ref : float + Vertical stabilizer wing area (scalar, sq ft) + ac|geom|vstab|AR : float + Vertical stabilizer aspect ratio (scalar, dimensionless) + ac|geom|vstab|c4sweep : float + Vertical stabilizer sweep at 25% mean aerodynamic chord (scalar, radians) + ac|geom|vstab|toverc : float + Vertical stabilizer thickness-to-chord ratio (scalar, dimensionless) + ac|geom|vstab|c4_to_wing_c4 : float + Distance from the vertical stabilizer's quarter chord (of the MAC) to the wing's quarter chord (scalar, ft) + ac|geom|fuselage|height : float + Fuselage height (scalar, ft) + ac|geom|fuselage|length : float + Fuselage length, used to compute distance between quarter chord of wing and horizontal stabilizer (scalar, ft) + ac|geom|fuselage|S_wet : float + Fuselage wetted area (scalar, sq ft) + ac|geom|maingear|length : float + Length of the main landing gear (scalar, inch) + ac|geom|maingear|num_wheels : float + Total number of main landing gear wheels (scalar, dimensionless) + ac|geom|maingear|num_shock_struts : float + Total number of main landing gear shock struts (scalar, dimensionless) + ac|geom|nosegear|length : float + Length of the nose landing gear (scalar, inch) + ac|geom|nosegear|num_wheels : float + Total number of nose landing gear wheels (scalar, dimensionless) + ac|propulsion|engine|rating : float + Rated thrust of each engine (scalar, lbf) + ac|propulsion|num_engines : float + Number of engines (scalar, dimensionless) + ac|weights|MTOW : float + Maximum takeoff weight (scalar, lb) + ac|weights|MLW : float + Maximum landing weight (scalar, lb) + ac|weights|W_fuel_max : float + Maximum fuel weight (scalar, lb) + + Outputs + ------- + OEW : float + Total operating empty weight (scalar, lb) + W_wing : float + Estimated wing weight without structural fudge factor multiplier (scalar, lb) + W_hstab : float + Weight of the horizontal stabilizer without structural fudge factor multiplier (scalar, lb) + W_vstab : float + Weight of the vertical stabilizer without structural fudge factor multiplier (scalar, lb) + W_fuselage : float + Fuselage weight without structural fudge factor multiplier (scalar, lb) + W_mlg : float + Main landing gear weight without structural fudge factor multiplier (scalar, lb) + W_nlg : float + Nose landing gear weight without structural fudge factor multiplier (scalar, lb) + W_nacelle : float + Weight of the nacelles (scalar, lb) + W_structure : float + Total structural weight = fudge factor * (W_wing + W_hstab + W_vstab + W_fuselage + W_mlg + W_nlg + W_nacelle) (scalar, lb) + W_engines : float + Total dry engine weight (scalar, lb) + W_thrust_rev : float + Total thrust reverser weight (scalar, lb) + W_eng_control : float + Total engine control weight (scalar, lb) + W_fuelsystem : float + Total fuel system weight including tanks and plumbing (scalar, lb) + W_eng_start : float + Total engine starter weight (scalar, lb) + W_furnishings : float + Weight estimate of seats, galleys, lavatories, and other furnishings (scalar, lb) + W_flight_controls : float + Flight control system weight (scalar, lb) + W_avionics : float + Intrumentation, avionics, and electronics weight (scalar, lb) + W_electrical : float + Electrical system weight (scalar, lb) + W_ac_pressurize_antiice : float + Air conditioning, pressurization, and anti-icing system weight (scalar, lb) + W_oxygen : float + Oxygen system weight (scalar, lb) + W_APU : float + Auxiliary power unit weight (scalar, lb) + + Options + ------- + structural_fudge : float + Multiplier on the structural weight to allow the user to account for miscellaneous items and + advanced materials. Structural weight includes wing, horizontal stabilizer, vertical stabilizer, + fuselage, landing gear, and nacelle weights. By default 1.2 (scalar, dimensionless) + total_fudge : float + Multiplier on the final operating empty weight estimate. Structural components have both the + structural fudge and total fudge factors applied. By default 1.15 (scalar, dimensionless) + wing_weight_multiplier : float + Multiplier on wing weight. This can be used as a very rough way of increasing wing weight + due to lack of inertial load relief from the fuel. By default 1.0 (scalar, dimensionless) + n_ult : float + Ultimate load factor, 1.5 x limit load factor, by default 1.5 x 2.5 (scalar, dimensionless) + n_land_ult : float + Ultimate landing load factor, which is 1.5 times the gear load factor (defined + in equation 11.11). Table 11.5 gives reasonable gear load factor values for + different aircraft types, with commercial aircraft in the 2.7-3 range. Default + is taken at 2.8, thus the ultimate landing load factor is 2.8 x 1.5 (scalar, dimensionless) + control_surface_area_frac : float + Fraction of the total wing area covered by control surfaces and flaps, by default 0.1 (scalar, dimensionless) + fuselage_width_frac : float + Fuselage width at horizontal tail intersection divided by fuselage diameter, by default 0.5 (scalar, dimensionless) + K_uht : float + Correction for all-moving tail; set to 1.143 for all-moving tail or 1.0 otherwise, by default 1.0 (scalar, dimensionless) + elevator_area_frac : float + Fraction of horizontal stabilizer area covered by elevators, by default 0.2 (scalar, dimensionless) + T_tail : bool + True if the tail is a T-tail, False otherwise + K_door : float + Fuselage door parameter; 1 if no cargo door, 1.06 if one side cargo door, 1.12 if two side + cargo doors, 1.12 if aft clamshell door, 1.25 if two side cargo doors, and aft clamshell door, + by default 1 (scalar, dimensionless) + K_lg : float + Fuselage-mounted landing gear parameter; 1.12 if fuselage-mounted main landing gear and 1 + otherwise, by default 1 (scalar, dimensionless) + kneeling_main_gear_parameter : float + Set to 1.126 for kneeling main gear and 1 otherwise, by default 1 (scalar, dimensionless) + kneeling_nose_gear_parameter : float + Set to 1.15 for kneeling nose gear and 1 otherwise, by default 1 (scalar, dimensionless) + K_lav : float + Lavatory coefficient; 0.31 for short ranges and 1.11 for long ranges, by default 0.7 + K_buf : float + Food provisions coefficient; 1.02 for short range and 5.68 for very long range, by default 4 + coeff_fc : float + K_fc in Roskam times any additional coefficient. The book says take K_fc as 0.44 for un-powered + flight controls and 0.64 for powered flight controls. Multiply this coefficient by 1.2 if leading + edge devices are employed. If lift dumpers are employed, use a factor of 1.15. By default 1.2 * 0.64. + coeff_avionics : float + Roskam notes that the avionics weight estimates are probably conservative for modern computer-based + flight management and navigation systems. This coefficient is multiplied by the Roskam estimate to + account for this. By default 0.5. + cabin_length_frac : float + The length of the passenger cabin divided by the total fuselage length, by default 0.75. + APU_weight_frac : float + Auxiliary power unit weight divided by maximum takeoff weight, by deafult 0.0085. + """ + + def initialize(self): + self.options.declare("structural_fudge", default=1.2, desc="Fudge factor on structural weights") + self.options.declare("total_fudge", default=1.15, desc="Fudge factor applied to the final OEW value") + self.options.declare("wing_weight_multiplier", default=1.0, desc="Multiplier on wing weight") + self.options.declare("n_ult", default=2.5 * 1.5, desc="Ultimate load factor (dimensionless)") + self.options.declare("n_land_ult", default=2.8 * 1.5, desc="ultimate landing load factor") + self.options.declare( + "control_surface_area_frac", default=0.1, desc="Fraction of wing area covered by control surfaces and flaps" + ) + self.options.declare( + "fuselage_width_frac", default=0.5, desc="Fuselage width at tail intersection divided by fuselage diameter" + ) + self.options.declare("K_uht", default=1.0, desc="Scaling for all moving stabilizer") + self.options.declare( + "elevator_area_frac", default=0.2, desc="Fraction of horizontal stabilizer covered by elevators" + ) + self.options.declare("T_tail", default=False, types=bool, desc="True if T-tail, False otherwise") + self.options.declare("K_door", default=1, desc="Number of doors parameter") + self.options.declare("K_lg", default=1, desc="Fuselage-mounted landing gear parameter") + self.options.declare("kneeling_main_gear_parameter", default=1.0, desc="Kneeling main landing gear parameter") + self.options.declare("kneeling_nose_gear_parameter", default=1.0, desc="Kneeling nose landing gear parameter") + self.options.declare("K_lav", default=0.7, desc="Lavatory weight coefficient") + self.options.declare("K_buf", default=4.0, desc="Food weight coefficient") + self.options.declare("coeff_fc", default=1.2 * 0.64, desc="Coefficient on flight control system weight") + self.options.declare("coeff_avionics", default=0.5, desc="Coefficient on avionics weight") + self.options.declare("cabin_length_frac", default=0.75, desc="Cabin length / fuselage length") + self.options.declare("APU_weight_frac", default=0.0085, desc="APU weight / MTOW") + + def setup(self): + n_ult = self.options["n_ult"] + + # ============================================================================== + # Lifting surface weights (wing and stabilizers) + # ============================================================================== + # -------------- Wing -------------- + self.add_subsystem( + "wing", + WingWeight_JetTransport(n_ult=n_ult, control_surface_area_frac=self.options["control_surface_area_frac"]), + promotes_inputs=[ + "ac|weights|MTOW", + "ac|geom|wing|S_ref", + "ac|geom|wing|AR", + "ac|geom|wing|c4sweep", + "ac|geom|wing|taper", + "ac|geom|wing|toverc", + ], + promotes_outputs=["W_wing"], + ) + + # -------------- Horizontal stabilizer -------------- + hstab = om.Group() + hstab.add_subsystem( + "hstab_const", + HstabConst_JetTransport(fuselage_width_frac=self.options["fuselage_width_frac"]), + promotes_inputs=[ + "ac|geom|hstab|S_ref", + "ac|geom|hstab|AR", + "ac|geom|fuselage|height", + ], + ) + hstab.add_subsystem( + "hstab_calc", + HstabWeight_JetTransport( + n_ult=n_ult, + K_uht=self.options["K_uht"], + elevator_area_frac=self.options["elevator_area_frac"], + ), + promotes_inputs=[ + "ac|weights|MTOW", + "ac|geom|hstab|S_ref", + "ac|geom|hstab|AR", + "ac|geom|hstab|c4sweep", + "ac|geom|hstab|c4_to_wing_c4", + ], + promotes_outputs=["W_hstab"], + ) + hstab.connect("hstab_const.HstabConst", "hstab_calc.HstabConst") + self.add_subsystem("hstab", hstab, promotes_inputs=["*"], promotes_outputs=["W_hstab"]) + + # -------------- Vertical stabilizer -------------- + self.add_subsystem( + "vstab", + VstabWeight_JetTransport( + n_ult=n_ult, + T_tail=self.options["T_tail"], + ), + promotes_inputs=[ + "ac|weights|MTOW", + "ac|geom|vstab|S_ref", + "ac|geom|vstab|AR", + "ac|geom|vstab|c4sweep", + "ac|geom|vstab|toverc", + "ac|geom|vstab|c4_to_wing_c4", + ], + promotes_outputs=["W_vstab"], + ) + + # ============================================================================== + # Fuselage + # ============================================================================== + fuselage = om.Group() + fuselage.add_subsystem( + "K_ws_term", + FuselageKws_JetTransport(), + promotes_inputs=[ + "ac|geom|wing|taper", + "ac|geom|wing|S_ref", + "ac|geom|wing|AR", + "ac|geom|wing|c4sweep", + "ac|geom|fuselage|length", + ], + ) + fuselage.add_subsystem( + "fuselage_calc", + FuselageWeight_JetTransport(n_ult=n_ult, K_door=self.options["K_door"], K_lg=self.options["K_lg"]), + promotes_inputs=[ + "ac|weights|MTOW", + "ac|geom|fuselage|length", + "ac|geom|fuselage|S_wet", + "ac|geom|fuselage|height", + ], + promotes_outputs=["W_fuselage"], + ) + fuselage.connect("K_ws_term.K_ws", "fuselage_calc.K_ws") + self.add_subsystem("fuselage", fuselage, promotes_inputs=["*"], promotes_outputs=["W_fuselage"]) + + # ============================================================================== + # Landing gear + # ============================================================================== + # -------------- Main gear -------------- + self.add_subsystem( + "main_gear", + MainLandingGearWeight_JetTransport( + n_land_ult=self.options["n_land_ult"], + kneeling_gear_parameter=self.options["kneeling_main_gear_parameter"], + ), + promotes_inputs=[ + "ac|weights|MLW", + "ac|geom|maingear|length", + "ac|geom|maingear|num_wheels", + "ac|geom|maingear|num_shock_struts", + "ac|aero|Vstall_land", + ], + promotes_outputs=["W_mlg"], + ) + + # -------------- Nose gear -------------- + self.add_subsystem( + "nose_gear", + NoseLandingGearWeight_JetTransport( + n_land_ult=self.options["n_land_ult"], + kneeling_gear_parameter=self.options["kneeling_nose_gear_parameter"], + ), + promotes_inputs=[ + "ac|weights|MLW", + "ac|geom|nosegear|length", + "ac|geom|nosegear|num_wheels", + ], + promotes_outputs=["W_nlg"], + ) + + # ============================================================================== + # Propulsion system + # ============================================================================== + # -------------- Dry engine -------------- + # Engine weight computes a single engine, so it must be multiplied by the number of engines + self.add_subsystem( + "single_engine", + EngineWeight_JetTransport(), + promotes_inputs=["ac|propulsion|engine|rating"], + ) + self.add_subsystem( + "engine_multiplier", + ElementMultiplyDivideComp( + output_name="W_engines", input_names=["W_engine", "ac|propulsion|num_engines"], input_units=["lb", None] + ), + promotes_inputs=["ac|propulsion|num_engines"], + promotes_outputs=["W_engines"], + ) + self.connect("single_engine.W_engine", "engine_multiplier.W_engine") + + # -------------- Engine systems -------------- + self.add_subsystem( + "engine_systems", + EngineSystemsWeight_JetTransport(), + promotes_inputs=[ + "ac|propulsion|engine|rating", + "ac|propulsion|num_engines", + "ac|aero|Mach_max", + "ac|weights|W_fuel_max", + ], + promotes_outputs=[ + "W_thrust_rev", + "W_eng_control", + "W_fuelsystem", + "W_eng_start", + ], + ) + self.connect("single_engine.W_engine", "engine_systems.W_engine") + + # -------------- Nacelle -------------- + self.add_subsystem( + "nacelles", + NacelleWeight_JetTransport(), + promotes_inputs=["ac|propulsion|engine|rating", "ac|propulsion|num_engines"], + promotes_outputs=["W_nacelle"], + ) + + # ============================================================================== + # Furnishings for passengers + # ============================================================================== + self.add_subsystem( + "furnishings", + FurnishingWeight_JetTransport(K_lav=self.options["K_lav"], K_buf=self.options["K_buf"]), + promotes_inputs=[ + "ac|num_passengers_max", + "ac|num_flight_deck_crew", + "ac|num_cabin_crew", + "ac|cabin_pressure", + "ac|weights|MTOW", + ], + promotes_outputs=["W_furnishings"], + ) + + # ============================================================================== + # Other equipment + # ============================================================================== + # Estimate the volume of the passenger cabin by treating it as a cylinder with + # the fuselage diameter and length of fuselage length times a constant factor + # (by default 0.75) + self.add_subsystem( + "cabin_volume", + om.ExecComp( + "V_pressurized = pi * 0.25 * fus_height * fus_height * fus_length * cabin_frac", + V_pressurized={"units": "ft**3", "val": 1}, + fus_height={"units": "ft", "val": 1}, + fus_length={"units": "ft", "val": 1}, + cabin_frac={"val": self.options["cabin_length_frac"], "constant": True}, + ), + promotes_inputs=[("fus_height", "ac|geom|fuselage|height"), ("fus_length", "ac|geom|fuselage|length")], + ) + self.add_subsystem( + "equipment", + EquipmentWeight_JetTransport( + coeff_fc=self.options["coeff_fc"], + coeff_avionics=self.options["coeff_avionics"], + APU_weight_frac=self.options["APU_weight_frac"], + ), + promotes_inputs=[ + "ac|weights|MTOW", + "ac|num_passengers_max", + "ac|num_cabin_crew", + "ac|num_flight_deck_crew", + "ac|propulsion|num_engines", + "W_fuelsystem", + ], + promotes_outputs=[ + "W_flight_controls", + "W_avionics", + "W_electrical", + "W_ac_pressurize_antiice", + "W_oxygen", + "W_APU", + ], + ) + self.connect("cabin_volume.V_pressurized", "equipment.ac|geom|V_pressurized") + + # ============================================================================== + # Multiply structural weights by fudge factor + # ============================================================================== + structure_weight_outputs = ["W_wing", "W_hstab", "W_vstab", "W_fuselage", "W_mlg", "W_nlg", "W_nacelle"] + scaling_factors = [self.options["structural_fudge"]] * len(structure_weight_outputs) + scaling_factors[0] *= self.options["wing_weight_multiplier"] + self.add_subsystem( + "structural_adjustment", + AddSubtractComp( + output_name="W_structure", + input_names=structure_weight_outputs, + scaling_factors=scaling_factors, + units="lb", + ), + promotes_inputs=["*"], + promotes_outputs=["W_structure"], + ) + + # ============================================================================== + # Sum all weights to compute total operating empty weight + # ============================================================================== + final_weight_components = [ + "W_structure", + "W_engines", + "W_thrust_rev", + "W_eng_control", + "W_fuelsystem", + "W_eng_start", + "W_furnishings", + "W_flight_controls", + "W_avionics", + "W_electrical", + "W_ac_pressurize_antiice", + "W_oxygen", + "W_APU", + ] + self.add_subsystem( + "sum_weights", + AddSubtractComp( + output_name="OEW", + input_names=final_weight_components, + scaling_factors=[self.options["total_fudge"]] * len(final_weight_components), + units="lb", + ), + promotes_inputs=["*"], + promotes_outputs=["OEW"], + ) + + +class WingWeight_JetTransport(om.ExplicitComponent): + """ + Transport aircraft wing weight estimated from Raymer (eqn 15.25 in 1992 edition). + + Inputs + ------ + ac|weights|MTOW : float + Maximum takeoff weight (scalar, lb) + ac|geom|wing|S_ref : float + Wing planform reference area (scalar, sq ft) + ac|geom|wing|AR : float + Wing aspect ratio (scalar, dimensionless) + ac|geom|wing|c4sweep : float + Wing sweep at 25% mean aerodynamic chord (scalar, radians) + ac|geom|wing|taper : float + Wing taper ratio (scalar, dimensionless) + ac|geom|wing|toverc : float + Wing root thickness-to-chord ratio (scalar, dimensionless) + + Outputs + ------- + W_wing : float + Estimated wing weight (scalar, lb) + + Options + ------- + n_ult : float + Ultimate load factor, 1.5 x limit load factor, by default 1.5 x 2.5 (scalar, dimensionless) + control_surface_area_frac : float + Fraction of the total wing area covered by control surfaces and flaps, by default 0.1 (scalar, dimensionless) + """ + + def initialize(self): + self.options.declare("n_ult", default=2.5 * 1.5, desc="Ultimate load factor (dimensionless)") + self.options.declare( + "control_surface_area_frac", default=0.1, desc="Fraction of wing area covered by control surfaces and flaps" + ) + + def setup(self): + self.add_input("ac|weights|MTOW", units="lb", desc="Maximum rated takeoff weight") + self.add_input("ac|geom|wing|S_ref", units="ft**2", desc="Reference wing area in sq ft") + self.add_input("ac|geom|wing|AR", desc="Wing aspect ratio") + self.add_input("ac|geom|wing|c4sweep", units="rad", desc="Quarter-chord sweep angle") + self.add_input("ac|geom|wing|taper", desc="Wing taper ratio") + self.add_input("ac|geom|wing|toverc", desc="Wing max thickness to chord ratio") + + self.add_output("W_wing", units="lb", desc="Wing weight") + self.declare_partials(["W_wing"], ["*"]) + + def compute(self, inputs, outputs): + n_ult = self.options["n_ult"] + W_wing_Raymer = ( + 0.0051 + * (inputs["ac|weights|MTOW"] * n_ult) ** 0.557 + * (inputs["ac|geom|wing|S_ref"]) ** 0.649 + * (inputs["ac|geom|wing|AR"]) ** 0.5 + * (inputs["ac|geom|wing|toverc"]) ** -0.4 + * (1 + inputs["ac|geom|wing|taper"]) ** 0.1 + * np.cos(inputs["ac|geom|wing|c4sweep"]) ** -1 + * ((self.options["control_surface_area_frac"] * inputs["ac|geom|wing|S_ref"]) ** 0.1) + ) + + outputs["W_wing"] = W_wing_Raymer + + def compute_partials(self, inputs, J): # TO DO + n_ult = self.options["n_ult"] + J["W_wing", "ac|weights|MTOW"] = ( + (0.0051 * 0.557) + * (inputs["ac|weights|MTOW"] ** (0.557 - 1)) + * n_ult**0.557 + * (inputs["ac|geom|wing|S_ref"]) ** 0.649 + * (inputs["ac|geom|wing|AR"]) ** 0.5 + * (inputs["ac|geom|wing|toverc"]) ** -0.4 + * (1 + inputs["ac|geom|wing|taper"]) ** 0.1 + * np.cos(inputs["ac|geom|wing|c4sweep"]) ** -1 + * (self.options["control_surface_area_frac"] * inputs["ac|geom|wing|S_ref"]) ** 0.1 + ) + J["W_wing", "ac|geom|wing|S_ref"] = ( + 0.0051 + * (inputs["ac|weights|MTOW"] * n_ult) ** 0.557 + * (0.649 + 0.1) + * (inputs["ac|geom|wing|S_ref"]) ** (0.649 + 0.1 - 1) + * (inputs["ac|geom|wing|AR"]) ** 0.5 + * (inputs["ac|geom|wing|toverc"]) ** -0.4 + * (1 + inputs["ac|geom|wing|taper"]) ** 0.1 + * np.cos(inputs["ac|geom|wing|c4sweep"]) ** -1 + * (self.options["control_surface_area_frac"] ** 0.1) + ) + J["W_wing", "ac|geom|wing|AR"] = ( + 0.0051 + * (inputs["ac|weights|MTOW"] * n_ult) ** 0.557 + * (inputs["ac|geom|wing|S_ref"]) ** 0.649 + * 0.5 + * (inputs["ac|geom|wing|AR"]) ** (0.5 - 1) + * (inputs["ac|geom|wing|toverc"]) ** -0.4 + * (1 + inputs["ac|geom|wing|taper"]) ** 0.1 + * np.cos(inputs["ac|geom|wing|c4sweep"]) ** -1 + * (self.options["control_surface_area_frac"] ** 0.1) + * (inputs["ac|geom|wing|S_ref"]) ** 0.1 + ) + J["W_wing", "ac|geom|wing|c4sweep"] = ( + 0.0051 + * (inputs["ac|weights|MTOW"] * n_ult) ** 0.557 + * (inputs["ac|geom|wing|S_ref"]) ** (0.649 + 0.1) + * (inputs["ac|geom|wing|AR"]) ** 0.5 + * (inputs["ac|geom|wing|toverc"]) ** -0.4 + * (1 + inputs["ac|geom|wing|taper"]) ** 0.1 + * -1 + * np.cos(inputs["ac|geom|wing|c4sweep"]) ** -2 + * (-1 * np.sin(inputs["ac|geom|wing|c4sweep"])) + * (self.options["control_surface_area_frac"] ** 0.1) + ) + J["W_wing", "ac|geom|wing|taper"] = ( + 0.0051 + * (inputs["ac|weights|MTOW"] * n_ult) ** 0.557 + * (inputs["ac|geom|wing|S_ref"]) ** 0.649 + * (inputs["ac|geom|wing|AR"]) ** 0.5 + * (inputs["ac|geom|wing|toverc"]) ** -0.4 + * 0.1 + * (1 + inputs["ac|geom|wing|taper"]) ** (0.1 - 1) + * np.cos(inputs["ac|geom|wing|c4sweep"]) ** -1 + * (self.options["control_surface_area_frac"] ** 0.1) + * (inputs["ac|geom|wing|S_ref"]) ** 0.1 + ) + J["W_wing", "ac|geom|wing|toverc"] = ( + 0.0051 + * (inputs["ac|weights|MTOW"] * n_ult) ** 0.557 + * (inputs["ac|geom|wing|S_ref"]) ** 0.649 + * (inputs["ac|geom|wing|AR"]) ** 0.5 + * -0.4 + * (inputs["ac|geom|wing|toverc"]) ** (-0.4 - 1) + * (1 + inputs["ac|geom|wing|taper"]) ** 0.1 + * np.cos(inputs["ac|geom|wing|c4sweep"]) ** -1 + * (self.options["control_surface_area_frac"] ** 0.1) + * (inputs["ac|geom|wing|S_ref"]) ** 0.1 + ) + + +class HstabConst_JetTransport(om.ExplicitComponent): + """ + The 1 + Fw/Bh term in Raymer's horizontal tail weight estimate (in eqn 15.26 in 1992 edition). + + Inputs + ------ + ac|geom|hstab|S_ref : float + Horizontal stabilizer reference area (scalar, sq ft) + ac|geom|hstab|AR : float + Horizontal stabilizer aspect ratio (scalar, dimensionless) + ac|geom|fuselage|height : float + Fuselage height (scalar, ft) + + Outputs + ------- + HstasbConst : float + The 1 + Fw/Bh term in the weight estimate (scalar, dimensionless) + + Options + ------- + fuselage_width_frac : float + Fuselage width at horizontal tail intersection divided by fuselage diameter, by default 0.5 (scalar, dimensionless) + """ + + def initialize(self): + self.options.declare( + "fuselage_width_frac", default=0.5, desc="Fuselage width at tail intersection divided by fuselage diameter" + ) + + def setup(self): + self.add_input("ac|geom|hstab|S_ref", units="ft**2", desc="Horizontal stabizer reference area") + self.add_input("ac|geom|hstab|AR", desc="Horizontal stabilizer aspect ratio") + self.add_input("ac|geom|fuselage|height", units="ft", desc="Fuselage height") + self.add_output("HstabConst") + self.declare_partials(["HstabConst"], ["*"]) + + def compute(self, inputs, outputs): + Fw = inputs["ac|geom|fuselage|height"] * self.options["fuselage_width_frac"] + Bh = np.sqrt(inputs["ac|geom|hstab|S_ref"] * inputs["ac|geom|hstab|AR"]) + outputs["HstabConst"] = 1 + Fw / Bh + + def compute_partials(self, inputs, J): + Fw = inputs["ac|geom|fuselage|height"] * self.options["fuselage_width_frac"] + Bh = np.sqrt(inputs["ac|geom|hstab|S_ref"] * inputs["ac|geom|hstab|AR"]) + J["HstabConst", "ac|geom|hstab|S_ref"] = -Fw / Bh**2 * (0.5 / Bh) * inputs["ac|geom|hstab|AR"] + J["HstabConst", "ac|geom|hstab|AR"] = -Fw / Bh**2 * (0.5 / Bh) * inputs["ac|geom|hstab|S_ref"] + J["HstabConst", "ac|geom|fuselage|height"] = 1 / Bh * self.options["fuselage_width_frac"] + + +class HstabWeight_JetTransport(om.ExplicitComponent): + """ + Horizontal stabilizer weight estimation from Raymer (eqn 15.26 in 1992 edition). + This component makes the additional assumption that the distance between the wing + quarter chord and horizontal stabilizer quarter chord is a constant fraction of + the fuselage length (by default half). + + Inputs + ------ + ac|weights|MTOW : float + Maximum takeoff weight (scalar, lb) + ac|geom|hstab|S_ref : float + Horizontal stabilizer wing area (scalar, sq ft) + ac|geom|hstab|AR : float + Horizontal stabilizer aspect ratio (scalar, dimensionless) + ac|geom|hstab|c4sweep : float + Horizontal stabilizer sweep at 25% mean aerodynamic chord (scalar, radians) + ac|geom|fuselage|length : float + Fuselage length, used to compute distance between quarter chord of wing and horizontal stabilizer (scalar, ft) + ac|geom|hstab|c4_to_wing_c4 : float + Distance from the horizontal stabilizer's quarter chord (of the MAC) to the wing's quarter chord (scalar, ft) + HstasbConst : float + The 1 + Fw/Bh term in the weight estimate (scalar, dimensionless) + + Outputs + ------- + W_hstab : float + Weight of the horizontal stabilizer (scalar, lb) + + Options + ------- + n_ult : float + Ultimate load factor, 1.5 x limit load factor, by default 1.5 x 2.5 (scalar, dimensionless) + K_uht : float + Correction for all-moving tail; set to 1.143 for all-moving tail or 1.0 otherwise, by default 1.0 (scalar, dimensionless) + elevator_area_frac : float + Fraction of horizontal stabilizer area covered by elevators, by default 0.2 (scalar, dimensionless) + """ + + def initialize(self): + self.options.declare("n_ult", default=2.5 * 1.5, desc="Ultimate load factor (dimensionless)") + self.options.declare("K_uht", default=1.0, desc="Scaling for all moving stabilizer") + self.options.declare( + "elevator_area_frac", default=0.2, desc="Fraction of horizontal stabilizer covered by elevators" + ) + + def setup(self): + self.add_input("ac|weights|MTOW", units="lb", desc="Maximum rated takeoff weight") + self.add_input("ac|geom|hstab|S_ref", units="ft**2", desc="Reference wing area in sq ft") + self.add_input("ac|geom|hstab|AR", desc="Wing aspect ratio") + self.add_input("ac|geom|hstab|c4sweep", units="rad", desc="Quarter-chord sweep angle") + self.add_input("ac|geom|hstab|c4_to_wing_c4", units="ft", desc="Distance from wing to tail quarter chord") + self.add_input("HstabConst", desc="1 + Fw/Bh term in equation") + + self.add_output("W_hstab", units="lb", desc="Hstab weight") + self.declare_partials(["W_hstab"], ["*"]) + + def compute(self, inputs, outputs): + n_ult = self.options["n_ult"] + K_uht = self.options["K_uht"] + Se_Sht = self.options["elevator_area_frac"] + c4_wing_c4_tail = inputs["ac|geom|hstab|c4_to_wing_c4"] + + outputs["W_hstab"] = ( + 0.0379 + * K_uht + * inputs["HstabConst"] ** -0.25 + * inputs["ac|weights|MTOW"] ** 0.639 + * n_ult**0.10 + * inputs["ac|geom|hstab|S_ref"] ** 0.75 + * (0.3 * c4_wing_c4_tail) ** 0.704 + * c4_wing_c4_tail**-1 + * np.cos(inputs["ac|geom|hstab|c4sweep"]) ** -1 + * inputs["ac|geom|hstab|AR"] ** 0.166 + * (1 + Se_Sht) ** 0.1 + ) + + def compute_partials(self, inputs, J): + n_ult = self.options["n_ult"] + K_uht = self.options["K_uht"] + Se_Sht = self.options["elevator_area_frac"] + c4_wing_c4_tail = inputs["ac|geom|hstab|c4_to_wing_c4"] + + J["W_hstab", "ac|weights|MTOW"] = ( + 0.0379 + * K_uht + * inputs["HstabConst"] ** -0.25 + * 0.639 + * inputs["ac|weights|MTOW"] ** (0.639 - 1) + * n_ult**0.10 + * inputs["ac|geom|hstab|S_ref"] ** 0.75 + * (0.3 * c4_wing_c4_tail) ** 0.704 + * c4_wing_c4_tail**-1 + * np.cos(inputs["ac|geom|hstab|c4sweep"]) ** -1 + * inputs["ac|geom|hstab|AR"] ** 0.166 + * (1 + Se_Sht) ** 0.1 + ) + + J["W_hstab", "ac|geom|hstab|S_ref"] = ( + 0.0379 + * K_uht + * inputs["HstabConst"] ** -0.25 + * inputs["ac|weights|MTOW"] ** 0.639 + * n_ult**0.10 + * 0.75 + * inputs["ac|geom|hstab|S_ref"] ** (0.75 - 1) + * (0.3 * c4_wing_c4_tail) ** 0.704 + * c4_wing_c4_tail**-1 + * np.cos(inputs["ac|geom|hstab|c4sweep"]) ** -1 + * inputs["ac|geom|hstab|AR"] ** 0.166 + * (1 + Se_Sht) ** 0.1 + ) + + J["W_hstab", "ac|geom|hstab|AR"] = ( + 0.0379 + * K_uht + * inputs["HstabConst"] ** -0.25 + * inputs["ac|weights|MTOW"] ** 0.639 + * n_ult**0.10 + * inputs["ac|geom|hstab|S_ref"] ** 0.75 + * (0.3 * c4_wing_c4_tail) ** 0.704 + * c4_wing_c4_tail**-1 + * np.cos(inputs["ac|geom|hstab|c4sweep"]) ** -1 + * 0.166 + * inputs["ac|geom|hstab|AR"] ** (0.166 - 1) + * (1 + Se_Sht) ** 0.1 + ) + + J["W_hstab", "ac|geom|hstab|c4sweep"] = ( + 0.0379 + * K_uht + * inputs["HstabConst"] ** -0.25 + * inputs["ac|weights|MTOW"] ** 0.639 + * n_ult**0.10 + * inputs["ac|geom|hstab|S_ref"] ** 0.75 + * (0.3 * c4_wing_c4_tail) ** 0.704 + * c4_wing_c4_tail**-1 + * np.cos(inputs["ac|geom|hstab|c4sweep"]) ** -2 + * np.sin(inputs["ac|geom|hstab|c4sweep"]) + * inputs["ac|geom|hstab|AR"] ** 0.166 + * (1 + Se_Sht) ** 0.1 + ) + + J["W_hstab", "ac|geom|hstab|c4_to_wing_c4"] = ( + 0.0379 + * K_uht + * inputs["HstabConst"] ** -0.25 + * inputs["ac|weights|MTOW"] ** 0.639 + * n_ult**0.10 + * inputs["ac|geom|hstab|S_ref"] ** 0.75 + * 0.3**0.704 + * (-0.296) + * c4_wing_c4_tail**-1.296 + * np.cos(inputs["ac|geom|hstab|c4sweep"]) ** -1 + * inputs["ac|geom|hstab|AR"] ** 0.166 + * (1 + Se_Sht) ** 0.1 + ) + + J["W_hstab", "HstabConst"] = ( + 0.0379 + * K_uht + * (-0.25) + * inputs["HstabConst"] ** (-0.25 - 1) + * inputs["ac|weights|MTOW"] ** 0.639 + * n_ult**0.10 + * inputs["ac|geom|hstab|S_ref"] ** 0.75 + * (0.3 * c4_wing_c4_tail) ** 0.704 + * c4_wing_c4_tail**-1 + * np.cos(inputs["ac|geom|hstab|c4sweep"]) ** -1 + * inputs["ac|geom|hstab|AR"] ** 0.166 + * (1 + Se_Sht) ** 0.1 + ) + + +class VstabWeight_JetTransport(om.ExplicitComponent): + """ + Vertical stabilizer weight estimate from Raymer (eqn 15.27 in 1992 edition). + This component makes the additional assumption that the distance between the wing + quarter chord and vertical stabilizer quarter chord is a constant fraction of + the fuselage length (by default half). + + Inputs + ------ + ac|weights|MTOW : float + Maximum takeoff weight (scalar, lb) + ac|geom|vstab|S_ref : float + vertical stabilizer wing area (scalar, sq ft) + ac|geom|vstab|AR : float + vertical stabilizer aspect ratio (scalar, dimensionless) + ac|geom|vstab|toverc : float + vertical stabilizer thickness-to-chord ratio (scalar, dimensionless) + ac|geom|vstab|c4sweep : float + vertical stabilizer sweep at 25% mean aerodynamic chord (scalar, radians) + ac|geom|vstab|c4_to_wing_c4 : float + Distance from the vertical stabilizer's quarter chord (of the MAC) to the wing's quarter chord (scalar, ft) + + Outputs + ------- + W_vstab : float + Weight of the vertical stabilizer (scalar, lb) + + Options + ------- + n_ult : float + Ultimate load factor, 1.5 x limit load factor, by default 1.5 x 2.5 (scalar, dimensionless) + T_tail : bool + True if the tail is a T-tail, False otherwise + """ + + def initialize(self): + self.options.declare("n_ult", default=2.5 * 1.5, desc="Ultimate load factor (dimensionless)") + self.options.declare("T_tail", default=False, types=bool, desc="True if T-tail, False otherwise") + + def setup(self): + self.add_input("ac|weights|MTOW", units="lb", desc="Maximum rated takeoff weight") + self.add_input("ac|geom|vstab|S_ref", units="ft**2", desc="Reference vtail area in sq ft") + self.add_input("ac|geom|vstab|AR", desc="vtail aspect ratio") + self.add_input("ac|geom|vstab|c4sweep", units="rad", desc="Quarter-chord sweep angle") + self.add_input("ac|geom|vstab|c4_to_wing_c4", units="ft", desc="Distance from wing to tail quarter chord") + self.add_input("ac|geom|vstab|toverc", desc="root t/c of v-tail, estimated same as wing") + + self.add_output("W_vstab", units="lb", desc="Vstab weight") + self.declare_partials(["W_vstab"], ["*"]) + + def compute(self, inputs, outputs): + n_ult = self.options["n_ult"] + Ht_Hv = 1.0 if self.options["T_tail"] else 0.0 + c4_wing_c4_tail = inputs["ac|geom|vstab|c4_to_wing_c4"] + + outputs["W_vstab"] = ( + 0.0026 + * (1 + Ht_Hv) ** 0.225 + * inputs["ac|weights|MTOW"] ** 0.556 + * n_ult**0.536 + * c4_wing_c4_tail ** (-0.5 + 0.875) + * inputs["ac|geom|vstab|S_ref"] ** 0.5 + * np.cos(inputs["ac|geom|vstab|c4sweep"]) ** -1 + * inputs["ac|geom|vstab|AR"] ** 0.35 + * inputs["ac|geom|vstab|toverc"] ** -0.5 + ) + + def compute_partials(self, inputs, J): + n_ult = self.options["n_ult"] + Ht_Hv = 1.0 if self.options["T_tail"] else 0.0 + c4_wing_c4_tail = inputs["ac|geom|vstab|c4_to_wing_c4"] + + J["W_vstab", "ac|weights|MTOW"] = ( + 0.0026 + * (1 + Ht_Hv) ** 0.225 + * 0.556 + * inputs["ac|weights|MTOW"] ** (0.556 - 1) + * n_ult**0.536 + * c4_wing_c4_tail ** (-0.5 + 0.875) + * inputs["ac|geom|vstab|S_ref"] ** 0.5 + * np.cos(inputs["ac|geom|vstab|c4sweep"]) ** -1 + * inputs["ac|geom|vstab|AR"] ** 0.35 + * inputs["ac|geom|vstab|toverc"] ** -0.5 + ) + J["W_vstab", "ac|geom|vstab|S_ref"] = ( + 0.0026 + * (1 + Ht_Hv) ** 0.225 + * inputs["ac|weights|MTOW"] ** 0.556 + * n_ult**0.536 + * c4_wing_c4_tail ** (-0.5 + 0.875) + * 0.5 + * inputs["ac|geom|vstab|S_ref"] ** -0.5 + * np.cos(inputs["ac|geom|vstab|c4sweep"]) ** -1 + * inputs["ac|geom|vstab|AR"] ** 0.35 + * inputs["ac|geom|vstab|toverc"] ** -0.5 + ) + J["W_vstab", "ac|geom|vstab|AR"] = ( + 0.0026 + * (1 + Ht_Hv) ** 0.225 + * inputs["ac|weights|MTOW"] ** 0.556 + * n_ult**0.536 + * c4_wing_c4_tail ** (-0.5 + 0.875) + * inputs["ac|geom|vstab|S_ref"] ** 0.5 + * np.cos(inputs["ac|geom|vstab|c4sweep"]) ** -1 + * 0.35 + * inputs["ac|geom|vstab|AR"] ** (0.35 - 1) + * inputs["ac|geom|vstab|toverc"] ** -0.5 + ) + J["W_vstab", "ac|geom|vstab|c4sweep"] = ( + 0.0026 + * (1 + Ht_Hv) ** 0.225 + * inputs["ac|weights|MTOW"] ** 0.556 + * n_ult**0.536 + * c4_wing_c4_tail ** (-0.5 + 0.875) + * inputs["ac|geom|vstab|S_ref"] ** 0.5 + * np.cos(inputs["ac|geom|vstab|c4sweep"]) ** -2 + * np.sin(inputs["ac|geom|vstab|c4sweep"]) + * inputs["ac|geom|vstab|AR"] ** 0.35 + * inputs["ac|geom|vstab|toverc"] ** -0.5 + ) + J["W_vstab", "ac|geom|vstab|c4_to_wing_c4"] = ( + 0.0026 + * (1 + Ht_Hv) ** 0.225 + * inputs["ac|weights|MTOW"] ** 0.556 + * n_ult**0.536 + * (-0.5 + 0.875) + * c4_wing_c4_tail ** (-0.5 + 0.875 - 1) + * inputs["ac|geom|vstab|S_ref"] ** 0.5 + * np.cos(inputs["ac|geom|vstab|c4sweep"]) ** -1 + * inputs["ac|geom|vstab|AR"] ** 0.35 + * inputs["ac|geom|vstab|toverc"] ** -0.5 + ) + J["W_vstab", "ac|geom|vstab|toverc"] = ( + 0.0026 + * (1 + Ht_Hv) ** 0.225 + * inputs["ac|weights|MTOW"] ** 0.556 + * n_ult**0.536 + * c4_wing_c4_tail ** (-0.5 + 0.875) + * inputs["ac|geom|vstab|S_ref"] ** 0.5 + * np.cos(inputs["ac|geom|vstab|c4sweep"]) ** -1 + * inputs["ac|geom|vstab|AR"] ** 0.35 + * (-0.5) + * inputs["ac|geom|vstab|toverc"] ** -1.5 + ) + + +class FuselageKws_JetTransport(om.ExplicitComponent): + """ + Compute Raymer's Kws term for the fuselage weight estimation (in eqn 15.28 in the 1992 edition). + + Inputs + ------ + ac|geom|wing|taper : float + Main wing taper ratio (scalar, dimensionless) + ac|geom|wing|S_ref : float + Main wing reference area (scalar, sq ft) + ac|geom|wing|AR : float + Main wing aspect ratio (scalar, dimensionless) + ac|geom|wing|c4sweep : float + Main wing quarter chord sweep angle (scalar, radians) + ac|geom|fuselage|length : float + Fuselage length (scalar, ft) + + Outputs + ------- + K_ws : float + K_ws term in Raymer's fuselage weight approximation (scalar, dimensionless) + """ + + def setup(self): + self.add_input("ac|geom|wing|taper", desc="Wing taper ratio") + self.add_input("ac|geom|wing|S_ref", units="ft**2", desc="Wing reference area") + self.add_input("ac|geom|wing|AR", desc="Wing aspect ratio") + self.add_input("ac|geom|wing|c4sweep", units="rad", desc="Wing aspect Ratio") + self.add_input("ac|geom|fuselage|length", units="ft", desc="Fuselage structural length") + self.add_output("K_ws", desc="Fuselage constant Kws defined in Raymer") + self.declare_partials(["K_ws"], ["*"]) + + def compute(self, inputs, outputs): + Kws_raymer = ( + 0.75 + * (1 + 2 * inputs["ac|geom|wing|taper"]) + / (1 + inputs["ac|geom|wing|taper"]) + * inputs["ac|geom|wing|S_ref"] ** 0.5 + * inputs["ac|geom|wing|AR"] ** 0.5 + * np.tan(inputs["ac|geom|wing|c4sweep"]) + * inputs["ac|geom|fuselage|length"] ** -1 + ) + outputs["K_ws"] = Kws_raymer + + def compute_partials(self, inputs, J): + J["K_ws", "ac|geom|wing|taper"] = ( + 0.75 + * (1 + inputs["ac|geom|wing|taper"]) ** -2 + * inputs["ac|geom|wing|S_ref"] ** 0.5 + * inputs["ac|geom|wing|AR"] ** 0.5 + * np.tan(inputs["ac|geom|wing|c4sweep"]) + * inputs["ac|geom|fuselage|length"] ** -1 + ) + J["K_ws", "ac|geom|wing|S_ref"] = ( + 0.75 + * (1 + 2 * inputs["ac|geom|wing|taper"]) + / (1 + inputs["ac|geom|wing|taper"]) + * 0.5 + * inputs["ac|geom|wing|S_ref"] ** (0.5 - 1) + * inputs["ac|geom|wing|AR"] ** 0.5 + * np.tan(inputs["ac|geom|wing|c4sweep"]) + * inputs["ac|geom|fuselage|length"] ** -1 + ) + J["K_ws", "ac|geom|wing|AR"] = ( + 0.75 + * (1 + 2 * inputs["ac|geom|wing|taper"]) + / (1 + inputs["ac|geom|wing|taper"]) + * inputs["ac|geom|wing|S_ref"] ** 0.5 + * 0.5 + * inputs["ac|geom|wing|AR"] ** (0.5 - 1) + * np.tan(inputs["ac|geom|wing|c4sweep"]) + * inputs["ac|geom|fuselage|length"] ** -1 + ) + J["K_ws", "ac|geom|wing|c4sweep"] = ( + 0.75 + * (1 + 2 * inputs["ac|geom|wing|taper"]) + / (1 + inputs["ac|geom|wing|taper"]) + * inputs["ac|geom|wing|S_ref"] ** 0.5 + * inputs["ac|geom|wing|AR"] ** 0.5 + * (1 / np.cos(inputs["ac|geom|wing|c4sweep"])) ** 2 + * inputs["ac|geom|fuselage|length"] ** -1 + ) + J["K_ws", "ac|geom|fuselage|length"] = ( + 0.75 + * (1 + 2 * inputs["ac|geom|wing|taper"]) + / (1 + inputs["ac|geom|wing|taper"]) + * inputs["ac|geom|wing|S_ref"] ** 0.5 + * inputs["ac|geom|wing|AR"] ** 0.5 + * np.tan(inputs["ac|geom|wing|c4sweep"]) + * -1 + * inputs["ac|geom|fuselage|length"] ** (-1 - 1) + ) + + +class FuselageWeight_JetTransport(om.ExplicitComponent): + """ + Fuselage weight estimation from Raymer (eqn 15.28 in 1992 edition). + + Inputs + ------ + ac|weights|MTOW : float + Maximum takeoff weight (scalar, lb) + ac|geom|fuselage|length : float + Fuselage structural length (scalar, ft) + ac|geom|fuselage|S_wet : float + Fuselage wetted area (scalar, sq ft) + ac|geom|fuselage|height : float + Fuselage height (scalar, ft) + K_ws : float + Fuselage parameter computed in FuselageKws_JetTransport (scalar, dimensionless) + + Outputs + ------- + W_fuselage : float + Fuselage weight (scalar, lb) + + Options + ------- + n_ult : float + Ultimate load factor, 1.5 x limit load factor, by default 1.5 x 2.5 (scalar, dimensionless) + K_door : float + Fuselage door parameter; 1 if no cargo door, 1.06 if one side cargo door, 1.12 if two side + cargo doors, 1.12 if aft clamshell door, 1.25 if two side cargo doors, and aft clamshell door, + by default 1 (scalar, dimensionless) + K_lg : float + Fuselage-mounted landing gear parameter; 1.12 if fuselage-mounted main landing gear and 1 + otherwise, by default 1 (scalar, dimensionless) + """ + + def initialize(self): + self.options.declare("n_ult", default=2.5 * 1.5, desc="Ultimate load factor (dimensionless)") + self.options.declare("K_door", default=1, desc="Number of doors parameter") + self.options.declare("K_lg", default=1, desc="Fuselage-mounted landing gear parameter") + + def setup(self): + self.add_input("ac|weights|MTOW", units="lb", desc="Maximum rated takeoff weight") + self.add_input("ac|geom|fuselage|length", units="ft", desc="fuselage structural length") + self.add_input("ac|geom|fuselage|S_wet", units="ft**2", desc="fuselage wetted area") + self.add_input("ac|geom|fuselage|height", units="ft", desc="Fuselage height") + self.add_input("K_ws") + + self.add_output("W_fuselage", units="lb", desc="fuselage weight") + self.declare_partials(["W_fuselage"], ["*"]) + + def compute(self, inputs, outputs): + n_ult = self.options["n_ult"] + K_door = self.options["K_door"] + K_lg = self.options["K_lg"] + + outputs["W_fuselage"] = ( + 0.3280 + * K_door + * K_lg + * inputs["ac|weights|MTOW"] ** 0.5 + * n_ult**0.5 + * inputs["ac|geom|fuselage|length"] ** (0.25 + 0.1) + * inputs["ac|geom|fuselage|S_wet"] ** 0.302 + * (1 + inputs["K_ws"]) ** 0.04 + * inputs["ac|geom|fuselage|height"] ** -0.10 + ) + + def compute_partials(self, inputs, J): + n_ult = self.options["n_ult"] + K_door = self.options["K_door"] + K_lg = self.options["K_lg"] + + J["W_fuselage", "ac|weights|MTOW"] = ( + 0.3280 + * K_door + * K_lg + * 0.5 + * inputs["ac|weights|MTOW"] ** -0.5 + * n_ult**0.5 + * inputs["ac|geom|fuselage|length"] ** (0.25 + 0.1) + * inputs["ac|geom|fuselage|S_wet"] ** 0.302 + * (1 + inputs["K_ws"]) ** 0.04 + * inputs["ac|geom|fuselage|height"] ** -0.10 + ) + J["W_fuselage", "ac|geom|fuselage|length"] = ( + 0.3280 + * K_door + * K_lg + * inputs["ac|weights|MTOW"] ** 0.5 + * n_ult**0.5 + * 0.35 + * inputs["ac|geom|fuselage|length"] ** (0.25 + 0.1 - 1) + * inputs["ac|geom|fuselage|S_wet"] ** 0.302 + * (1 + inputs["K_ws"]) ** 0.04 + * inputs["ac|geom|fuselage|height"] ** -0.10 + ) + J["W_fuselage", "ac|geom|fuselage|S_wet"] = ( + 0.3280 + * K_door + * K_lg + * inputs["ac|weights|MTOW"] ** 0.5 + * n_ult**0.5 + * inputs["ac|geom|fuselage|length"] ** (0.25 + 0.1) + * 0.302 + * inputs["ac|geom|fuselage|S_wet"] ** (0.302 - 1) + * (1 + inputs["K_ws"]) ** 0.04 + * inputs["ac|geom|fuselage|height"] ** -0.10 + ) + J["W_fuselage", "ac|geom|fuselage|height"] = ( + 0.3280 + * K_door + * K_lg + * inputs["ac|weights|MTOW"] ** 0.5 + * n_ult**0.5 + * inputs["ac|geom|fuselage|length"] ** (0.25 + 0.1) + * inputs["ac|geom|fuselage|S_wet"] ** 0.302 + * (1 + inputs["K_ws"]) ** 0.04 + * (-0.1) + * inputs["ac|geom|fuselage|height"] ** -1.10 + ) + J["W_fuselage", "K_ws"] = ( + 0.3280 + * K_door + * K_lg + * inputs["ac|weights|MTOW"] ** 0.5 + * n_ult**0.5 + * inputs["ac|geom|fuselage|length"] ** (0.25 + 0.1) + * inputs["ac|geom|fuselage|S_wet"] ** 0.302 + * 0.04 + * (1 + inputs["K_ws"]) ** (0.04 - 1) + * inputs["ac|geom|fuselage|height"] ** -0.10 + ) + + +class MainLandingGearWeight_JetTransport(om.ExplicitComponent): + """ + Main landing gear weight estimate from Raymer (eqn 15.29 in 1992 edition). + + Inputs + ------ + ac|weights|MLW : float + Maximum landing weight (scalar, lb) + ac|geom|maingear|length : float + Length of the main landing gear (scalar, inch) + ac|geom|maingear|num_wheels : float + Total number of main landing gear wheels (scalar, dimensionless) + ac|geom|maingear|num_shock_struts : float + Total number of main landing gear shock struts (scalar, dimensionless) + ac|aero|Vstall_land : float + Landing stall speed (scalar, knots) + + Outputs + ------- + W_mlg : float + Main landing gear weight (scalar, lb) + + Options + ------- + n_land_ult : float + Ultimate landing load factor, which is 1.5 times the gear load factor (defined + in equation 11.11). Table 11.5 gives reasonable gear load factor values for + different aircraft types, with commercial aircraft in the 2.7-3 range. Default + is taken at 2.8, thus the ultimate landing load factor is 2.8 x 1.5 (scalar, dimensionless) + kneeling_gear_parameter : float + Set to 1.126 for kneeling gear and 1 otherwise, by default 1 (scalar, dimensionless) + """ + + def initialize(self): + self.options.declare("n_land_ult", default=2.8 * 1.5, desc="ultimate landing load factor") + self.options.declare("kneeling_gear_parameter", default=1.0, desc="Kneeling landing gear parameter") + + def setup(self): + self.add_input("ac|geom|maingear|length", units="inch", desc="main landing gear length") + self.add_input("ac|weights|MLW", units="lb", desc="max landing weight") + self.add_input("ac|geom|maingear|num_wheels", desc="numer of main landing gear wheels") + self.add_input("ac|geom|maingear|num_shock_struts", desc="numer of main landing gear shock struts") + self.add_input("ac|aero|Vstall_land", units="kn", desc="stall speed in max landing configuration") + + self.add_output("W_mlg", units="lb", desc="Main gear weight") + self.declare_partials(["W_mlg"], ["*"]) + + def compute(self, inputs, outputs): + n_land_ult = self.options["n_land_ult"] + + outputs["W_mlg"] = ( + 0.0106 + * self.options["kneeling_gear_parameter"] + * inputs["ac|weights|MLW"] ** 0.888 + * n_land_ult**0.25 + * inputs["ac|geom|maingear|length"] ** 0.4 + * inputs["ac|geom|maingear|num_wheels"] ** 0.321 + * inputs["ac|geom|maingear|num_shock_struts"] ** -0.5 + * inputs["ac|aero|Vstall_land"] ** 0.1 + ) + + def compute_partials(self, inputs, J): + n_land_ult = self.options["n_land_ult"] + + J["W_mlg", "ac|weights|MLW"] = ( + 0.0106 + * self.options["kneeling_gear_parameter"] + * 0.888 + * inputs["ac|weights|MLW"] ** (0.888 - 1) + * n_land_ult**0.25 + * inputs["ac|geom|maingear|length"] ** 0.4 + * inputs["ac|geom|maingear|num_wheels"] ** 0.321 + * inputs["ac|geom|maingear|num_shock_struts"] ** -0.5 + * inputs["ac|aero|Vstall_land"] ** 0.1 + ) + J["W_mlg", "ac|geom|maingear|length"] = ( + 0.0106 + * self.options["kneeling_gear_parameter"] + * inputs["ac|weights|MLW"] ** 0.888 + * n_land_ult**0.25 + * 0.4 + * inputs["ac|geom|maingear|length"] ** (0.4 - 1) + * inputs["ac|geom|maingear|num_wheels"] ** 0.321 + * inputs["ac|geom|maingear|num_shock_struts"] ** -0.5 + * inputs["ac|aero|Vstall_land"] ** 0.1 + ) + J["W_mlg", "ac|geom|maingear|num_wheels"] = ( + 0.0106 + * self.options["kneeling_gear_parameter"] + * inputs["ac|weights|MLW"] ** 0.888 + * n_land_ult**0.25 + * inputs["ac|geom|maingear|length"] ** 0.4 + * 0.321 + * inputs["ac|geom|maingear|num_wheels"] ** (0.321 - 1) + * inputs["ac|geom|maingear|num_shock_struts"] ** -0.5 + * inputs["ac|aero|Vstall_land"] ** 0.1 + ) + J["W_mlg", "ac|geom|maingear|num_shock_struts"] = ( + 0.0106 + * self.options["kneeling_gear_parameter"] + * inputs["ac|weights|MLW"] ** 0.888 + * n_land_ult**0.25 + * inputs["ac|geom|maingear|length"] ** 0.4 + * inputs["ac|geom|maingear|num_wheels"] ** 0.321 + * (-0.5) + * inputs["ac|geom|maingear|num_shock_struts"] ** -1.5 + * inputs["ac|aero|Vstall_land"] ** 0.1 + ) + J["W_mlg", "ac|aero|Vstall_land"] = ( + 0.0106 + * self.options["kneeling_gear_parameter"] + * inputs["ac|weights|MLW"] ** 0.888 + * n_land_ult**0.25 + * inputs["ac|geom|maingear|length"] ** 0.4 + * inputs["ac|geom|maingear|num_wheels"] ** 0.321 + * inputs["ac|geom|maingear|num_shock_struts"] ** -0.5 + * 0.1 + * inputs["ac|aero|Vstall_land"] ** (0.1 - 1) + ) + + +class NoseLandingGearWeight_JetTransport(om.ExplicitComponent): + """ + Nose landing gear weight estimate from Raymer (eqn 15.30 in 1992 edition). + + Inputs + ------ + ac|weights|MLW : float + Maximum landing weight (scalar, lb) + ac|geom|nosegear|length : float + Length of the nose landing gear (scalar, inch) + ac|geom|nosegear|num_wheels : float + Total number of nose landing gear wheels (scalar, dimensionless) + + Outputs + ------- + W_nlg : float + Nose landing gear weight (scalar, lb) + + Options + ------- + n_land_ult : float + Ultimate landing load factor, which is 1.5 times the gear load factor (defined + in equation 11.11). Table 11.5 gives reasonable gear load factor values for + different aircraft types, with commercial aircraft in the 2.7-3 range. Default + is taken at 2.8, thus the ultimate landing load factor is 2.8 x 1.5 (scalar, dimensionless) + kneeling_gear_parameter : float + Set to 1.15 for kneeling gear and 1 otherwise, by default 1 (scalar, dimensionless) + """ + + def initialize(self): + self.options.declare("n_land_ult", default=2.8 * 1.5, desc="ultimate landing load factor") + self.options.declare("kneeling_gear_parameter", default=1.0, desc="Kneeling landing gear parameter") + + def setup(self): + self.add_input("ac|geom|nosegear|length", units="inch", desc="nose landing gear length") + self.add_input("ac|weights|MLW", units="lb", desc="max landing weight") + self.add_input("ac|geom|nosegear|num_wheels", desc="numer of nose landing gear wheels") + + self.add_output("W_nlg", units="lb", desc="nosegear weight") + self.declare_partials(["W_nlg"], ["*"]) + + def compute(self, inputs, outputs): + n_land_ult = self.options["n_land_ult"] + outputs["W_nlg"] = ( + 0.032 + * self.options["kneeling_gear_parameter"] + * inputs["ac|weights|MLW"] ** 0.646 + * n_land_ult**0.2 + * inputs["ac|geom|nosegear|length"] ** 0.5 + * inputs["ac|geom|nosegear|num_wheels"] ** 0.45 + ) + + def compute_partials(self, inputs, J): + n_land_ult = self.options["n_land_ult"] + J["W_nlg", "ac|weights|MLW"] = ( + 0.032 + * self.options["kneeling_gear_parameter"] + * 0.646 + * inputs["ac|weights|MLW"] ** (0.646 - 1) + * n_land_ult**0.2 + * inputs["ac|geom|nosegear|length"] ** 0.5 + * inputs["ac|geom|nosegear|num_wheels"] ** 0.45 + ) + J["W_nlg", "ac|geom|nosegear|length"] = ( + 0.032 + * self.options["kneeling_gear_parameter"] + * inputs["ac|weights|MLW"] ** 0.646 + * n_land_ult**0.2 + * 0.5 + * inputs["ac|geom|nosegear|length"] ** -0.5 + * inputs["ac|geom|nosegear|num_wheels"] ** 0.45 + ) + J["W_nlg", "ac|geom|nosegear|num_wheels"] = ( + 0.032 + * self.options["kneeling_gear_parameter"] + * inputs["ac|weights|MLW"] ** 0.646 + * n_land_ult**0.2 + * inputs["ac|geom|nosegear|length"] ** 0.5 + * 0.45 + * inputs["ac|geom|nosegear|num_wheels"] ** (0.45 - 1) + ) + + +class EngineWeight_JetTransport(om.ExplicitComponent): + """ + Turbofan weight as estimated by the FLOPS weight estimation method (https://ntrs.nasa.gov/citations/20170005851). + This approach adopts equation 76's transport and HWB weight estimation method. The computed engine weight + is per engine (must be multiplied by number of engines to get total engine weight). + + Inputs + ------ + ac|propulsion|engine|rating : float + Rated thrust of each engine (scalar, lbf) + + Outputs + ------- + W_engine : float + Engine weight (scalar, lb) + """ + + def setup(self): + self.add_input("ac|propulsion|engine|rating", units="lbf", desc="Rated thrust per engine") + self.add_output("W_engine", units="lb") + self.declare_partials("W_engine", "ac|propulsion|engine|rating", val=1 / 5.5) + + def compute(self, inputs, outputs): + outputs["W_engine"] = inputs["ac|propulsion|engine|rating"] / 5.5 + + +class EngineSystemsWeight_JetTransport(om.ExplicitComponent): + """ + Engine system weight as estimated by the FLOPS weight estimation method + (https://ntrs.nasa.gov/citations/20170005851). The computed weight is for + all engines (does not need to be multiplied by number of engines). The + equations are from sections 5.3.3 to 5.3.5 of the linked paper. This + assumes that all engines have thrust reversers and there are no + center-mounted engines. + + Roskam is used to estimate the engine starting system weight, assuming + a pneumatic starting system and one or two get engines (eqn 6.27, Part V, 1989) + + Inputs + ------ + ac|propulsion|engine|rating : float + Rated thrust of each engine (scalar, lbf) + ac|propulsion|num_engines : float + Number of engines (scalar, dimensionless) + ac|aero|Mach_max : float + Maximum aircraft Mach number (scalar, dimensionless) + ac|weights|W_fuel_max : float + Maximum fuel weight (scalar, lb) + W_engine : float + Engine weight (scalar, lb) + + Outputs + ------- + W_thrust_rev : float + Total thrust reverser weight (scalar, lb) + W_eng_control : float + Total engine control weight (scalar, lb) + W_fuelsystem : float + Total fuel system weight including tanks and plumbing (scalar, lb) + W_eng_start : float + Total engine starter weight (scalar, lb) + """ + + def setup(self): + self.add_input("ac|propulsion|engine|rating", units="lbf", desc="rated thrust per engine") + self.add_input("ac|propulsion|num_engines", desc="number of engines") + self.add_input("W_engine", units="lb", desc="engine weight") + self.add_input("ac|aero|Mach_max", desc="maximum aircraft Mach number") + self.add_input("ac|weights|W_fuel_max", units="lb", desc="maximum fuel weight") + + self.add_output("W_thrust_rev", units="lb") + self.add_output("W_eng_control", units="lb") + self.add_output("W_fuelsystem", units="lb") + self.add_output("W_eng_start", units="lb") + + self.declare_partials("W_thrust_rev", ["ac|propulsion|engine|rating", "ac|propulsion|num_engines"]) + self.declare_partials("W_eng_control", ["ac|propulsion|engine|rating", "ac|propulsion|num_engines"]) + self.declare_partials( + "W_fuelsystem", ["ac|weights|W_fuel_max", "ac|propulsion|num_engines", "ac|aero|Mach_max"] + ) + self.declare_partials("W_eng_start", ["W_engine", "ac|propulsion|num_engines"]) + + def compute(self, inputs, outputs): + N_eng = inputs["ac|propulsion|num_engines"] + T_rated = inputs["ac|propulsion|engine|rating"] + M_max = inputs["ac|aero|Mach_max"] + + outputs["W_thrust_rev"] = 0.034 * T_rated * N_eng + outputs["W_eng_control"] = 0.26 * N_eng * T_rated**0.5 + outputs["W_fuelsystem"] = 1.07 * inputs["ac|weights|W_fuel_max"] ** 0.58 * N_eng**0.43 * M_max**0.34 + + # Roskam 1989, Part V, Equation 6.27 + outputs["W_eng_start"] = 9.33 * (inputs["W_engine"] / 1e3) ** 1.078 * N_eng + + def compute_partials(self, inputs, J): + N_eng = inputs["ac|propulsion|num_engines"] + T_rated = inputs["ac|propulsion|engine|rating"] + M_max = inputs["ac|aero|Mach_max"] + + J["W_thrust_rev", "ac|propulsion|engine|rating"] = 0.034 * N_eng + J["W_thrust_rev", "ac|propulsion|num_engines"] = 0.034 * T_rated + J["W_eng_control", "ac|propulsion|engine|rating"] = 0.26 * N_eng * 0.5 / T_rated**0.5 + J["W_eng_control", "ac|propulsion|num_engines"] = 0.26 * T_rated**0.5 + J["W_fuelsystem", "ac|weights|W_fuel_max"] = ( + 1.07 * 0.58 * inputs["ac|weights|W_fuel_max"] ** (0.58 - 1) * N_eng**0.43 * M_max**0.34 + ) + J["W_fuelsystem", "ac|propulsion|num_engines"] = ( + 1.07 * inputs["ac|weights|W_fuel_max"] ** 0.58 * 0.43 * N_eng ** (0.43 - 1) * M_max**0.34 + ) + J["W_fuelsystem", "ac|aero|Mach_max"] = ( + 1.07 * inputs["ac|weights|W_fuel_max"] ** 0.58 * N_eng**0.43 * 0.34 * M_max ** (0.34 - 1) + ) + J["W_eng_start", "W_engine"] = 9.33 * 1.078 * inputs["W_engine"] ** 0.078 / 1e3**1.078 * N_eng + J["W_eng_start", "ac|propulsion|num_engines"] = 9.33 * (inputs["W_engine"] / 1e3) ** 1.078 + + +class NacelleWeight_JetTransport(om.ExplicitComponent): + """ + Nacelle weight estimate from Roskam (eqn 5.37, Chapter 5, Part V, 1989). + + Inputs + ------ + ac|propulsion|engine|rating : float + Rated thrust of each engine (scalar, lbf) + ac|propulsion|num_engines : float + Number of engines (scalar, dimensionless) + + Outputs + ------- + W_nacelle : float + Nacelle weight (scalar, lb) + """ + + def setup(self): + self.add_input("ac|propulsion|engine|rating", units="lbf", desc="rated thrust per engine") + self.add_input("ac|propulsion|num_engines", desc="number of engines") + self.add_output("W_nacelle", units="lb", desc="nacelle weight") + + self.declare_partials("W_nacelle", ["*"]) + + def compute(self, inputs, outputs): + outputs["W_nacelle"] = 0.065 * inputs["ac|propulsion|engine|rating"] * inputs["ac|propulsion|num_engines"] + + def compute_partials(self, inputs, J): + J["W_nacelle", "ac|propulsion|engine|rating"] = 0.065 * inputs["ac|propulsion|num_engines"] + J["W_nacelle", "ac|propulsion|num_engines"] = 0.065 * inputs["ac|propulsion|engine|rating"] + + +class FurnishingWeight_JetTransport(om.ExplicitComponent): + """ + Weight estimate of seats, insulation, trim panels, sound proofing, instrument panels, control stands, + lighting, wiring, galleys, lavatories, overhead luggage containers, escape provisions, and fire fighting + equipment. Estimated using the General Dynamics method in Roskam (eqn 7.44, Chapter 7, Part V, 1989). + + Inputs + ------ + ac|num_passengers_max : float + Maximum number of passengers (scalar, dimensionless) + ac|num_flight_deck_crew : float + Number of flight crew members (scalar, dimensionless) + ac|num_cabin_crew : float + Number of flight attendants (scalar, dimensionless) + ac|cabin_pressure : float + Cabin pressure (scalar, psi) + ac|weights|MTOW : float + Maximum takeoff weight (scalar, lb) + + Outputs + ------- + W_furnishings : float + Weight estimate of seats, galleys, lavatories, and other furnishings (scalar, lb) + + Options + ------- + K_lav : float + Lavatory coefficient; 0.31 for short ranges and 1.11 for long ranges, by default 0.7 + K_buf : float + Food provisions coefficient; 1.02 for short range and 5.68 for very long range, by default 4 + """ + + def initialize(self): + self.options.declare("K_lav", default=0.7, desc="Lavatory weight coefficient") + self.options.declare("K_buf", default=4.0, desc="Food weight coefficient") + + def setup(self): + self.add_input("ac|num_passengers_max") + self.add_input("ac|num_flight_deck_crew") + self.add_input("ac|num_cabin_crew") + self.add_input("ac|cabin_pressure", units="psi") + self.add_input("ac|weights|MTOW", units="lb") + self.add_output("W_furnishings", units="lb") + + self.declare_partials("W_furnishings", ["ac|num_passengers_max", "ac|cabin_pressure"]) + self.declare_partials("W_furnishings", "ac|num_flight_deck_crew", val=55.0) + self.declare_partials("W_furnishings", "ac|num_cabin_crew", val=15.0) + self.declare_partials("W_furnishings", "ac|weights|MTOW", val=0.771 / 1e3) + + def compute(self, inputs, outputs): + n_pax = inputs["ac|num_passengers_max"] + outputs["W_furnishings"] = ( + 55 * inputs["ac|num_flight_deck_crew"] # flight deck seats + + 32 * n_pax # passenger seats + + 15 * inputs["ac|num_cabin_crew"] # cabin crew seats + + self.options["K_lav"] * n_pax**1.33 # lavatories and water + + self.options["K_buf"] * n_pax**1.12 # food provisions + + 109 * (n_pax * (1 + inputs["ac|cabin_pressure"]) / 100) ** 0.505 # cabin windows + + 0.771 * (inputs["ac|weights|MTOW"] / 1e3) # misc + ) + + def compute_partials(self, inputs, J): + n_pax = inputs["ac|num_passengers_max"] + J["W_furnishings", "ac|num_passengers_max"] = ( + 32 + + 1.33 * self.options["K_lav"] * n_pax**0.33 # lavatories and water + + 1.12 * self.options["K_buf"] * n_pax**0.12 # food provisions + + 109 * 0.505 * n_pax ** (0.505 - 1) * ((1 + inputs["ac|cabin_pressure"]) / 100) ** 0.505 # cabin windows + ) + J["W_furnishings", "ac|cabin_pressure"] = ( + 109 * 0.505 * (n_pax / 100) ** 0.505 * (1 + inputs["ac|cabin_pressure"]) ** (0.505 - 1) + ) + + +class EquipmentWeight_JetTransport(om.ExplicitComponent): + """ + Weight estimate of the flight control system, electrical system, avionics, air conditioning, + pressurization system, anti-icing system, oxygen system, and APU. The estimates are all from + Roskam 1989 Part V. + + Inputs + ------ + ac|weights|MTOW : float + Maximum takeoff weight (scalar, lb) + ac|num_passengers_max : float + Maximum number of passengers (scalar, dimensionless) + ac|num_cabin_crew : float + Number of flight attendants (scalar, dimensionless) + ac|num_flight_deck_crew : float + Number of flight crew members; the Roskam equation uses number of pilots, but this is + the same value for modern aircraft (scalar, dimensionless) + ac|propulsion|num_engines : float + Number of engines (scalar, dimensionless) + ac|geom|V_pressurized : float + Pressurized cabin volume (scalar, cubic ft) + W_fuelsystem : float + Fuel system weight (scalar, lb) + + Outputs + ------- + W_flight_controls : float + Flight control system weight (scalar, lb) + W_avionics : float + Intrumentation, avionics, and electronics weight (scalar, lb) + W_electrical : float + Electrical system weight (scalar, lb) + W_ac_pressurize_antiice : float + Air conditioning, pressurization, and anti-icing system weight (scalar, lb) + W_oxygen : float + Oxygen system weight (scalar, lb) + W_APU : float + Auxiliary power unit weight (scalar, lb) + + Options + ------- + coeff_fc : float + K_fc in Roskam times any additional coefficient. The book says take K_fc as 0.44 for un-powered + flight controls and 0.64 for powered flight controls. Multiply this coefficient by 1.2 if leading + edge devices are employed. If lift dumpers are employed, use a factor of 1.15. By default 1.2 * 0.64. + coeff_avionics : float + Roskam notes that the avionics weight estimates are probably conservative for modern computer-based + flight management and navigation systems. This coefficient is multiplied by the Roskam estimate to + account for this. By default 0.5. + APU_weight_frac : float + APU weight divided by maximum takeoff weight, by deafult 0.0085. + """ + + def initialize(self): + self.options.declare("coeff_fc", default=1.2 * 0.64, desc="Coefficient on flight control system weight") + self.options.declare("coeff_avionics", default=0.5, desc="Coefficient on avionics weight") + self.options.declare("APU_weight_frac", default=0.0085, desc="APU weight / MTOW") + + def setup(self): + self.add_input("ac|weights|MTOW", units="lb") + self.add_input("ac|num_passengers_max") + self.add_input("ac|num_cabin_crew") + self.add_input("ac|num_flight_deck_crew") + self.add_input("ac|propulsion|num_engines") + self.add_input("ac|geom|V_pressurized", units="ft**3") + self.add_input("W_fuelsystem", units="lb") + + self.add_output("W_flight_controls", units="lb") + self.add_output("W_avionics", units="lb") + self.add_output("W_electrical", units="lb") + self.add_output("W_ac_pressurize_antiice", units="lb") + self.add_output("W_oxygen", units="lb") + self.add_output("W_APU", units="lb") + + self.declare_partials("W_flight_controls", "ac|weights|MTOW") + self.declare_partials("W_avionics", ["ac|num_flight_deck_crew", "ac|weights|MTOW", "ac|propulsion|num_engines"]) + self.declare_partials( + "W_electrical", ["W_fuelsystem", "ac|num_flight_deck_crew", "ac|weights|MTOW", "ac|propulsion|num_engines"] + ) + self.declare_partials( + "W_ac_pressurize_antiice", + [ + "ac|num_flight_deck_crew", + "ac|num_cabin_crew", + "ac|num_passengers_max", + "ac|geom|V_pressurized", + ], + ) + self.declare_partials("W_oxygen", ["ac|num_flight_deck_crew", "ac|num_cabin_crew", "ac|num_passengers_max"]) + self.declare_partials("W_APU", "ac|weights|MTOW", val=self.options["APU_weight_frac"]) + + def compute(self, inputs, outputs): + MTOW = inputs["ac|weights|MTOW"] + + # Torenbeek method from Roskam Part V 1989 Chapter 7 Equation 7.6 + outputs["W_flight_controls"] = self.options["coeff_fc"] * MTOW ** (2 / 3) + + # General Dynamics method from Roskam Part V 1989 Chapter 7 Equation 7.23 + outputs["W_avionics"] = self.options["coeff_avionics"] * ( + inputs["ac|num_flight_deck_crew"] * (15 + 0.032e-3 * MTOW) # flight instruments + + inputs["ac|propulsion|num_engines"] * (5 + 0.006e-3 * MTOW) # engine intruments + + (0.15e-3 + 0.012) * MTOW # other instruments + ) + + # General Dynamics method from Roskam Part V 1989 Chapter 7 Equation 7.15 + outputs["W_electrical"] = 1163 * (1e-3 * (inputs["W_fuelsystem"] + outputs["W_avionics"])) ** 0.506 + + # Air conditioning, pressurization, and anti-icing systems from General Dynamics method from + # Roskam Part V 1989 Chapter 7 Equation 7.29 + n_people = inputs["ac|num_flight_deck_crew"] + inputs["ac|num_cabin_crew"] + inputs["ac|num_passengers_max"] + outputs["W_ac_pressurize_antiice"] = 469 * (1e-4 * inputs["ac|geom|V_pressurized"] * n_people) ** 0.419 + + # General Dynamics method from Roskam Part V 1989 Chapter 7 Equation 7.35 + outputs["W_oxygen"] = 7 * n_people**0.702 + + # Roskam Part V 1989 Chapter 7 Equation 7.40 + outputs["W_APU"] = self.options["APU_weight_frac"] * MTOW + + def compute_partials(self, inputs, J): + MTOW = inputs["ac|weights|MTOW"] + + J["W_flight_controls", "ac|weights|MTOW"] = 2 / 3 * self.options["coeff_fc"] * MTOW ** (-1 / 3) + + J["W_avionics", "ac|num_flight_deck_crew"] = self.options["coeff_avionics"] * (15 + 0.032e-3 * MTOW) + J["W_avionics", "ac|propulsion|num_engines"] = self.options["coeff_avionics"] * (5 + 0.006e-3 * MTOW) + J["W_avionics", "ac|weights|MTOW"] = self.options["coeff_avionics"] * ( + inputs["ac|num_flight_deck_crew"] * 0.032e-3 + + inputs["ac|propulsion|num_engines"] * 0.006e-3 + + (0.15e-3 + 0.012) + ) + + W_avionics = self.options["coeff_avionics"] * ( + inputs["ac|num_flight_deck_crew"] * (15 + 0.032e-3 * MTOW) # flight instruments + + inputs["ac|propulsion|num_engines"] * (5 + 0.006e-3 * MTOW) # engine intruments + + (0.15e-3 + 0.012) * MTOW # other instruments + ) + J["W_electrical", "W_fuelsystem"] = ( + 1163 * 0.506 * (1e-3 * (inputs["W_fuelsystem"] + W_avionics)) ** (0.506 - 1) * 1e-3 + ) + delectrical_davionics = J["W_electrical", "W_fuelsystem"] + J["W_electrical", "ac|num_flight_deck_crew"] = ( + delectrical_davionics * J["W_avionics", "ac|num_flight_deck_crew"] + ) + J["W_electrical", "ac|propulsion|num_engines"] = ( + delectrical_davionics * J["W_avionics", "ac|propulsion|num_engines"] + ) + J["W_electrical", "ac|weights|MTOW"] = delectrical_davionics * J["W_avionics", "ac|weights|MTOW"] + + n_people = inputs["ac|num_flight_deck_crew"] + inputs["ac|num_cabin_crew"] + inputs["ac|num_passengers_max"] + J["W_ac_pressurize_antiice", "ac|num_flight_deck_crew"] = ( + 469 + * 0.419 + * (1e-4 * inputs["ac|geom|V_pressurized"] * n_people) ** (0.419 - 1) + * 1e-4 + * inputs["ac|geom|V_pressurized"] + ) + J["W_ac_pressurize_antiice", "ac|num_cabin_crew"][:] = J["W_ac_pressurize_antiice", "ac|num_flight_deck_crew"] + J["W_ac_pressurize_antiice", "ac|num_passengers_max"][:] = J[ + "W_ac_pressurize_antiice", "ac|num_flight_deck_crew" + ] + J["W_ac_pressurize_antiice", "ac|geom|V_pressurized"] = ( + 469 * 0.419 * (1e-4 * inputs["ac|geom|V_pressurized"] * n_people) ** (0.419 - 1) * 1e-4 * n_people + ) + + J["W_oxygen", "ac|num_flight_deck_crew"] = 7 * 0.702 * n_people ** (0.702 - 1) + J["W_oxygen", "ac|num_cabin_crew"][:] = J["W_oxygen", "ac|num_flight_deck_crew"] + J["W_oxygen", "ac|num_passengers_max"][:] = J["W_oxygen", "ac|num_flight_deck_crew"] diff --git a/readme.md b/readme.md index b735db58..8e639af2 100644 --- a/readme.md +++ b/readme.md @@ -54,7 +54,7 @@ OpenConcept is tested regularly on builds with the oldest and latest supported p | Package | Oldest | Latest | | ------- | ------- | ------ | | Python | 3.8 | 3.11 | -| OpenMDAO | 3.10 | latest | +| OpenMDAO | 3.21 | 3.30 | | NumPy | 1.20 | latest | | SciPy | 1.6.0 | latest | | OpenAeroStruct | latest | latest | diff --git a/setup.py b/setup.py index 480fc85e..3f5865b3 100644 --- a/setup.py +++ b/setup.py @@ -42,10 +42,10 @@ # and the index.rst file in the docs when you change these "numpy>=1.20", "scipy>=1.6.0", - "openmdao>=3.10", + "openmdao >=3.21, <=3.30", ], extras_require={ - "testing": ["pytest", "pytest-cov", "coverage", "openaerostruct"], + "testing": ["pytest", "pytest-cov", "coverage", "openaerostruct", "parameterized"], "docs": ["sphinx_mdolab_theme", "openaerostruct"], "plot": ["matplotlib"], },