diff --git a/aviary/docs/user_guide/aerodynamics.ipynb b/aviary/docs/user_guide/aerodynamics.ipynb index 023e8883a..e8504ef81 100644 --- a/aviary/docs/user_guide/aerodynamics.ipynb +++ b/aviary/docs/user_guide/aerodynamics.ipynb @@ -112,6 +112,7 @@ "- `computed`: uses regression-based techniques to estimate lift and drag\n", "- `low_speed`: for use in detailed takeoff analysis, and includes high-lift devices and considers angle-of-attack\n", "- `tabular`: allows the user to substitute the lift and drag coefficient calculations in `computed` with data tables\n", + "- `external`: disables Aviary's core aerodynamics computation, intended for use with external subsystems to replace all aerodynamic calculations.\n", "\n", "### Computed Aerodynamics\n", "The FLOPS based aerodynamics subsystem uses a modified version of algorithms from the EDET (Empirical Drag Estimation Technique) program [^edet] to internally compute drag polars. FLOPS improvements to EDET as implemented in Aviary include smoothing of drag polars, more accurate Reynolds number calculations, and use of the Sommer and Short T' method [^tprime] for skin friction calculations.\n", @@ -124,7 +125,10 @@ "- The lift-dependent drag coefficient table must include Mach number and lift coefficient as independent variables.\n", "- The zero-lift drag coefficient table must include altitude and Mach number as independent variables.\n", "\n", - "Tabular aerodynamics uses Aviary's [data_interpolator_builder](../_srcdocs/packages/utils/data_interpolator_builder) interface. This component is unique as it requires two data tables to be provided. All configuration options, such as the choice to use a structured metamodel or training data, are applied to both tables." + "Tabular aerodynamics uses Aviary's [data_interpolator_builder](../_srcdocs/packages/utils/data_interpolator_builder) interface. This component is unique as it requires two data tables to be provided. All configuration options, such as the choice to use a structured metamodel or training data, are applied to both tables.\n", + "\n", + "### External Aerodynamics\n", + "Selecting the `external` aerodynamics method disables Aviary's core aerodynamics group. This allows for external subsystems to completely replace these calculations." ] }, { @@ -142,7 +146,7 @@ "cab = CoreAerodynamicsBuilder(code_origin=LegacyCode.FLOPS)\n", "# here we are only checking that the CoreAerodynamicsBuilder has a build_mission for a given method\n", "# we know this will fail when it attempts to build the aero groups\n", - "for method in (None,'computed','low_speed','tabular','solved_alpha'):\n", + "for method in (None,'computed','low_speed','tabular','solved_alpha','external'):\n", " try:\n", " cab.build_mission(1,AviaryValues(),method=method)\n", " except ValueError as e:\n", @@ -191,7 +195,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.1.-1" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/aviary/examples/external_subsystems/custom_aero/__init__.py b/aviary/examples/external_subsystems/custom_aero/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aviary/examples/external_subsystems/custom_aero/custom_aero_builder.py b/aviary/examples/external_subsystems/custom_aero/custom_aero_builder.py new file mode 100644 index 000000000..482bcf39b --- /dev/null +++ b/aviary/examples/external_subsystems/custom_aero/custom_aero_builder.py @@ -0,0 +1,107 @@ +""" +Builder for a simple drag calculation that replaces Aviary's calculation. +""" +import openmdao.api as om + +from aviary.examples.external_subsystems.custom_aero.simple_drag import SimpleAeroGroup +from aviary.subsystems.subsystem_builder_base import SubsystemBuilderBase +from aviary.variable_info.variables import Aircraft, Dynamic + + +class CustomAeroBuilder(SubsystemBuilderBase): + """ + Prototype of a subsystem that overrides an aviary internally computed var. + + It also provides a method to build OpenMDAO systems for the pre-mission and mission computations of the subsystem. + + Attributes + ---------- + name : str ('simple_aero') + object label + """ + + def __init__(self, name='simple_aero'): + super().__init__(name) + + def build_mission(self, num_nodes, aviary_inputs, **kwargs): + """ + Build an OpenMDAO system for the mission computations of the subsystem. + + Returns + ------- + Returns + ------- + mission_sys : openmdao.core.System + An OpenMDAO system containing all computations that need to happen + during the mission. This includes time-dependent states that are + being integrated as well as any other variables that vary during + the mission. + """ + aero_group = SimpleAeroGroup( + num_nodes=num_nodes, + ) + return aero_group + + def mission_inputs(self, **kwargs): + promotes = [ + Dynamic.Atmosphere.STATIC_PRESSURE, + Dynamic.Atmosphere.MACH, + Dynamic.Vehicle.MASS, + 'aircraft:*', + ] + return promotes + + def mission_outputs(self, **kwargs): + promotes = [ + Dynamic.Vehicle.DRAG, + Dynamic.Vehicle.LIFT, + ] + return promotes + + def get_parameters(self, aviary_inputs=None, phase_info=None): + """ + Return a dictionary of fixed values for the subsystem. + + Optional, used if subsystems have fixed values. + + Used in the phase builders (e.g. cruise_phase.py) when other parameters are added to the phase. + + This is distinct from `get_design_vars` in a nuanced way. Design variables + are variables that are optimized by the problem that are not at the phase level. + An example would be something that occurs in the pre-mission level of the problem. + Parameters are fixed values that are held constant throughout a phase, but if + `opt=True`, they are able to change during the optimization. + + Parameters + ---------- + phase_info : dict + The phase_info subdict for this phase. + + Returns + ------- + fixed_values : dict + A dictionary where the keys are the names of the fixed variables + and the values are dictionaries with the following keys: + + - 'value': float or array + The fixed value for the variable. + - 'units': str + The units for the fixed value (optional). + - any additional keyword arguments required by OpenMDAO for the fixed + variable. + """ + params = {} + params[Aircraft.Wing.AREA] = { + 'shape': (1, ), + 'static_target': True, + 'units': 'ft**2', + } + return params + + def needs_mission_solver(self, aviary_inputs): + """ + Return True if the mission subsystem needs to be in the solver loop in mission, otherwise + return False. Aviary will only place it in the solver loop when True. The default is + True. + """ + return False diff --git a/aviary/examples/external_subsystems/custom_aero/run_simple_aero.py b/aviary/examples/external_subsystems/custom_aero/run_simple_aero.py new file mode 100644 index 000000000..47fa42488 --- /dev/null +++ b/aviary/examples/external_subsystems/custom_aero/run_simple_aero.py @@ -0,0 +1,60 @@ +""" +Run the a mission with a simple external component that computes the wing +and horizontal tail mass. +""" +from copy import deepcopy + +import aviary.api as av +from aviary.examples.external_subsystems.custom_aero.custom_aero_builder import CustomAeroBuilder + + +phase_info = deepcopy(av.default_height_energy_phase_info) + +# Just do cruise in this example. +phase_info.pop('climb') +phase_info.pop('descent') + +# Add custom aero. +# TODO: This API for replacing aero will be changed an upcoming release. +phase_info['cruise']['external_subsystems'] = [CustomAeroBuilder()] + +# Disable internal aero +# TODO: This API for replacing aero will be changed an upcoming release. +phase_info['cruise']['subsystem_options']['core_aerodynamics'] = { + 'method': 'external', +} + + +if __name__ == '__main__': + prob = av.AviaryProblem() + + # Load aircraft and options data from user + # Allow for user overrides here + prob.load_inputs('models/test_aircraft/aircraft_for_bench_FwFm.csv', phase_info) + + # Preprocess inputs + prob.check_and_preprocess_inputs() + + prob.add_pre_mission_systems() + + prob.add_phases() + + prob.add_post_mission_systems() + + # Link phases and variables + prob.link_phases() + + # Note, SLSQP might have troubles here. + prob.add_driver("SLSQP") + + prob.add_design_variables() + + prob.add_objective() + + prob.setup() + + prob.set_initial_guesses() + + prob.run_aviary_problem(suppress_solver_print=True) + + print('done') diff --git a/aviary/examples/external_subsystems/custom_aero/simple_drag.py b/aviary/examples/external_subsystems/custom_aero/simple_drag.py new file mode 100644 index 000000000..23a146bf8 --- /dev/null +++ b/aviary/examples/external_subsystems/custom_aero/simple_drag.py @@ -0,0 +1,104 @@ +import numpy as np + +import openmdao.api as om + +from aviary.subsystems.aerodynamics.aero_common import DynamicPressure +from aviary.subsystems.aerodynamics.flops_based.lift import LiftEqualsWeight +from aviary.subsystems.aerodynamics.flops_based.drag import SimpleDrag +from aviary.variable_info.variables import Aircraft, Dynamic, Mission + + +class SimplestDragCoeff(om.ExplicitComponent): + """ + Simple representation of aircraft drag as CD = CD_zero + k * CL**2 + + Values are fictional. Typically, some higher fidelity method will go here instead. + """ + + def initialize(self): + self.options.declare( + "num_nodes", default=1, types=int, + desc="Number of nodes along mission segment" + ) + + self.options.declare("CD_zero", default=0.01) + self.options.declare("k", default=0.065) + + def setup(self): + nn = self.options["num_nodes"] + + self.add_input('cl', val=np.zeros(nn)) + + self.add_output('CD', val=np.zeros(nn)) + + def setup_partials(self): + nn = self.options["num_nodes"] + arange = np.arange(nn) + + self.declare_partials('CD', 'cl', rows=arange, cols=arange) + + def compute(self, inputs, outputs, discrete_inputs=None, discrete_outputs=None): + CD_zero = self.options["CD_zero"] + k = self.options["k"] + + cl = inputs['cl'] + + outputs['CD'] = CD_zero + k * cl**2 + + def compute_partials(self, inputs, partials, discrete_inputs=None): + k = self.options["k"] + + cl = inputs['cl'] + + partials['CD', 'cl'] = 2.0 * k * cl + + +class SimpleAeroGroup(om.Group): + + def initialize(self): + self.options.declare( + "num_nodes", default=1, types=int, + desc="Number of nodes along mission segment" + ) + + def setup(self): + nn = self.options["num_nodes"] + + self.add_subsystem( + 'DynamicPressure', + DynamicPressure(num_nodes=nn), + promotes_inputs=[ + Dynamic.Atmosphere.MACH, + Dynamic.Atmosphere.STATIC_PRESSURE, + ], + promotes_outputs=[Dynamic.Atmosphere.DYNAMIC_PRESSURE], + ) + + self.add_subsystem( + "Lift", + LiftEqualsWeight(num_nodes=nn), + promotes_inputs=[ + Aircraft.Wing.AREA, + Dynamic.Vehicle.MASS, + Dynamic.Atmosphere.DYNAMIC_PRESSURE, + ], + promotes_outputs=['cl', Dynamic.Vehicle.LIFT], + ) + + self.add_subsystem( + "SimpleDragCoeff", + SimplestDragCoeff(num_nodes=nn), + promotes_inputs=['cl'], + promotes_outputs=['CD'], + ) + + self.add_subsystem( + "SimpleDrag", + SimpleDrag(num_nodes=nn), + promotes_inputs=[ + 'CD', + Dynamic.Atmosphere.DYNAMIC_PRESSURE, + Aircraft.Wing.AREA, + ], + promotes_outputs=[Dynamic.Vehicle.DRAG], + ) diff --git a/aviary/mission/flops_based/ode/mission_ODE.py b/aviary/mission/flops_based/ode/mission_ODE.py index 389ce80a5..f78b08f40 100644 --- a/aviary/mission/flops_based/ode/mission_ODE.py +++ b/aviary/mission/flops_based/ode/mission_ODE.py @@ -165,7 +165,10 @@ def setup(self): target = external_subsystem_group target.add_subsystem( - subsystem.name, subsystem_mission + subsystem.name, + subsystem_mission, + promotes_inputs=subsystem.mission_inputs(**kwargs), + promotes_outputs=subsystem.mission_outputs(**kwargs), ) # Only add the external subsystem group if it has at least one subsystem. diff --git a/aviary/subsystems/aerodynamics/aerodynamics_builder.py b/aviary/subsystems/aerodynamics/aerodynamics_builder.py index 0dd98dfd8..52a82d710 100644 --- a/aviary/subsystems/aerodynamics/aerodynamics_builder.py +++ b/aviary/subsystems/aerodynamics/aerodynamics_builder.py @@ -143,6 +143,10 @@ def build_mission(self, num_nodes, aviary_inputs, **kwargs): CDI_data=kwargs.pop('CDI_data'), **kwargs) + elif method == 'external': + # Aero completely replaced by external group. + aero_group = None + else: raise ValueError('FLOPS-based aero method is not one of the following: ' '(computed, low_speed, solved_alpha, tabular)') diff --git a/aviary/subsystems/aerodynamics/test/__init__.py b/aviary/subsystems/aerodynamics/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aviary/subsystems/aerodynamics/test/test_external_mission_aero.py b/aviary/subsystems/aerodynamics/test/test_external_mission_aero.py new file mode 100644 index 000000000..e613c03fe --- /dev/null +++ b/aviary/subsystems/aerodynamics/test/test_external_mission_aero.py @@ -0,0 +1,67 @@ +from copy import deepcopy +import unittest + +from openmdao.utils.assert_utils import assert_near_equal +from openmdao.utils.testing_utils import require_pyoptsparse + +import aviary.api as av +from aviary.examples.external_subsystems.custom_aero.custom_aero_builder import CustomAeroBuilder + +phase_info = deepcopy(av.default_height_energy_phase_info) + + +class TestBattery(av.TestSubsystemBuilderBase): + """ + Test replacing internal drag calculation with an external subsystem. + + Mainly, this shows that the "external" method works, and that the external + subsystems in mission are correctly promoting inputs/outputs. + """ + + @require_pyoptsparse(optimizer="IPOPT") + def test_external_drag(self): + + # Just do cruise in this example. + phase_info.pop('climb') + phase_info.pop('descent') + + # Add custom aero. + # TODO: This API for replacing aero will be changed an upcoming release. + phase_info['cruise']['external_subsystems'] = [CustomAeroBuilder()] + + # Disable internal aero + # TODO: This API for replacing aero will be changed an upcoming release. + phase_info['cruise']['subsystem_options']['core_aerodynamics'] = { + 'method': 'external', + } + + prob = av.AviaryProblem() + + # Load aircraft and options data from user + prob.load_inputs('models/test_aircraft/aircraft_for_bench_FwFm.csv', phase_info) + + prob.check_and_preprocess_inputs() + prob.add_pre_mission_systems() + prob.add_phases() + prob.add_post_mission_systems() + + prob.link_phases() + + # SLSQP didn't work so well here. + prob.add_driver("IPOPT") + + prob.add_design_variables() + prob.add_objective() + + prob.setup() + + prob.set_initial_guesses() + + prob.run_aviary_problem(suppress_solver_print=True) + + drag = prob.get_val("traj.cruise.rhs_all.drag", units='lbf') + assert_near_equal(drag[0], 7272.0265, tolerance=1e-3) + + +if __name__ == '__main__': + unittest.main() diff --git a/aviary/subsystems/subsystem_builder_base.py b/aviary/subsystems/subsystem_builder_base.py index 9d4fa20fb..b651529a8 100644 --- a/aviary/subsystems/subsystem_builder_base.py +++ b/aviary/subsystems/subsystem_builder_base.py @@ -219,6 +219,20 @@ def build_mission(self, num_nodes, aviary_inputs, **kwargs): """ return None + def mission_inputs(self, **kwargs): + """ + Returns list of mission inputs to be promoted out of the external subsystem. By + default, all aircraft:* and mission:* inputs are promoted. + """ + return [] + + def mission_outputs(self, **kwargs): + """ + Returns list of mission outputs to be promoted out of the external subsystem. By + default, all aircraft:* and mission:* outputs are promoted. + """ + return [] + def define_order(self): """ Return a list of subsystem names that must be defined before this one. E.g., must go before or after aero or prop.