From e1e2a90d78d05a2d9f9242de22e190180f3501c8 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 28 Jan 2026 15:37:12 -0500 Subject: [PATCH 01/23] Added a new enum --- aviary/variable_info/enums.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/aviary/variable_info/enums.py b/aviary/variable_info/enums.py index fcfc83a74..53b10e78c 100644 --- a/aviary/variable_info/enums.py +++ b/aviary/variable_info/enums.py @@ -165,6 +165,20 @@ def __str__(self): return self.value +class PhaseType(Enum): + """ + PhaseType is used for replacing a default phase and its equations of motion with a + different one. + + DEFAULT: Use the default phase builder for this phase. + + BREGUET_RANGE: Use a phase builder that implements the Breguet Range equations. + """ + + DEFAULT = 'default' + BREGUET_RANGE = "bregeut_range" + + class ProblemType(Enum): """ ProblemType is used to switch between different combinations of From 6e8f9a5875dfa9c10232782e2199198e8ac4b431 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Fri, 30 Jan 2026 10:50:09 -0500 Subject: [PATCH 02/23] moving towards getting Breguet phase to work with external dynamic subsystems --- aviary/mission/phase_builder.py | 10 +++++ aviary/mission/two_dof/phases/cruise_phase.py | 25 ++++++++++-- .../test_battery_in_a_mission.py | 39 +++++++++++++++++-- ...h_electrified_large_turboprop_freighter.py | 2 +- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/aviary/mission/phase_builder.py b/aviary/mission/phase_builder.py index 1db9fbdb7..1ebdc9062 100644 --- a/aviary/mission/phase_builder.py +++ b/aviary/mission/phase_builder.py @@ -326,6 +326,15 @@ def from_phase_info( user_options = phase_info.get('user_options', ()) initial_guesses = AviaryValues(phase_info.get('initial_guesses', ())) + # This may be an analytic phase, but we require a dynamic phase if the + # external subsystems have any states. + extra_kwargs = {} + for sub in subsystems: + states = sub.get_states() + if len(states) > 0: + extra_kwargs['is_analytic_phase'] = False + break + # TODO some of these may be purely programming API hooks, rather than for use # with phase info # - ode_class @@ -340,6 +349,7 @@ def from_phase_info( meta_data=meta_data, subsystems=subsystems, transcription=transcription, + **extra_kwargs, ) return phase_builder diff --git a/aviary/mission/two_dof/phases/cruise_phase.py b/aviary/mission/two_dof/phases/cruise_phase.py index 370c3cda9..49efb6276 100644 --- a/aviary/mission/two_dof/phases/cruise_phase.py +++ b/aviary/mission/two_dof/phases/cruise_phase.py @@ -10,8 +10,26 @@ from aviary.variable_info.variables import Dynamic -class CruisePhaseOptions(AviaryOptionsDictionary): +class BreguetCruisePhaseOptions(AviaryOptionsDictionary): def declare_options(self): + self.declare( + name='num_segments', + types=int, + default=5, + desc='The number of segments in transcription creation in Dymos. ' + 'While this phase is usually an analytic phase, this option is ' + 'needed if an external subsystem requires a dynamic transcription.', + ) + + self.declare( + name='order', + types=int, + default=3, + desc='The order of polynomials for interpolation in the transcription ' + 'created in Dymos. While this phase is usually an analytic phase, this option is ' + 'needed if an external subsystem requires a dynamic transcription.', + ) + self.declare(name='alt_cruise', default=0.0, units='ft', desc='Cruise altitude.') self.declare(name='mach_cruise', default=0.0, desc='Cruise Mach number.') @@ -86,7 +104,7 @@ class CruisePhase(PhaseBuilder): default_name = 'cruise_phase' default_ode_class = BreguetCruiseODE - default_options_class = CruisePhaseOptions + default_options_class = BreguetCruisePhaseOptions _initial_guesses_meta_data_ = {} @@ -100,6 +118,7 @@ def __init__( transcription=None, subsystems=None, meta_data=None, + is_analytic_phase=True, ): super().__init__( name=name, @@ -110,7 +129,7 @@ def __init__( transcription=transcription, subsystems=subsystems, meta_data=meta_data, - is_analytic_phase=True, + is_analytic_phase=is_analytic_phase, ) def build_phase(self, aviary_options: AviaryValues = None): diff --git a/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py b/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py index cadf6e841..4cd0f8de9 100644 --- a/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py +++ b/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py @@ -5,6 +5,7 @@ from openmdao.utils.testing_utils import use_tempdirs import aviary.api as av +from aviary.models.missions.two_dof_default import phase_info as twodof_phase_info from aviary.subsystems.energy.battery_builder import BatteryBuilder @@ -167,9 +168,41 @@ def test_subsystems_in_a_mission(self): 1e-7, ) + def test_subsystems_in_a_mission_2dof(self): + phase_info = deepcopy(twodof_phase_info) + + prob = av.AviaryProblem(verbosity=0) + + prob.load_inputs( + 'models/aircraft/test_aircraft/aircraft_for_bench_GwGm.csv', + phase_info, + ) + prob.load_external_subsystems([BatteryBuilder()]) + + prob.aviary_inputs.set_val(av.Aircraft.Battery.EFFICIENCY, 0.95, 'unitless') + + # Preprocess inputs + prob.check_and_preprocess_inputs() + + prob.build_model() + + prob.add_driver('SLSQP') + + prob.add_design_variables() + + prob.add_objective('fuel_burned') + + prob.setup() + + prob.set_val(av.Aircraft.Battery.PACK_ENERGY_DENSITY, 550, units='kJ/kg') + prob.set_val(av.Aircraft.Battery.PACK_MASS, 1000, units='lbm') + prob.set_val(av.Aircraft.Battery.ADDITIONAL_MASS, 115, units='lbm') + + prob.run_aviary_problem() + self.assertTrue(prob.result.success) + if __name__ == '__main__': unittest.main() - # test = TestSubsystemsMission() - # test.setUp() - # test.test_subsystems_in_a_mission() + test = TestSubsystemsMission() + test.test_subsystems_in_a_mission_2dof() diff --git a/aviary/validation_cases/benchmark_tests/test_bench_electrified_large_turboprop_freighter.py b/aviary/validation_cases/benchmark_tests/test_bench_electrified_large_turboprop_freighter.py index 87bf7914b..3e22a92e3 100644 --- a/aviary/validation_cases/benchmark_tests/test_bench_electrified_large_turboprop_freighter.py +++ b/aviary/validation_cases/benchmark_tests/test_bench_electrified_large_turboprop_freighter.py @@ -64,7 +64,7 @@ def build_and_run_problem(self, mission_method): # load_inputs needs to be updated to accept an already existing aviary options prob.load_inputs( options, # "models/aircraft/large_turboprop_freighter/large_turboprop_freighter_GASP.csv", - energy_phase_info, + phase_info, ) prob.load_external_subsystems([electroprop]) From 220d68092eaf5f7e31eb564ce5485cba3503e617 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 4 Feb 2026 15:42:25 -0500 Subject: [PATCH 03/23] Beginning support for a new simple cruise --- aviary/mission/two_dof/phases/cruise_phase.py | 5 +++++ aviary/mission/two_dof_problem_configurator.py | 2 +- aviary/variable_info/enums.py | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/aviary/mission/two_dof/phases/cruise_phase.py b/aviary/mission/two_dof/phases/cruise_phase.py index 49efb6276..214ce1d2f 100644 --- a/aviary/mission/two_dof/phases/cruise_phase.py +++ b/aviary/mission/two_dof/phases/cruise_phase.py @@ -120,6 +120,11 @@ def __init__( meta_data=None, is_analytic_phase=True, ): + + if is_analytic_phase is not True: + msg = "The Breguet Cruise phase does not support dynamic variables in its subsystems." + raise AttributeError(msg) + super().__init__( name=name, subsystem_options=subsystem_options, diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index b0c9d561a..b15c20287 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -16,7 +16,7 @@ from aviary.subsystems.propulsion.utils import build_engine_deck from aviary.utils.process_input_decks import initialization_guessing, update_GASP_options from aviary.utils.utils import wrapped_convert_units -from aviary.variable_info.enums import LegacyCode +from aviary.variable_info.enums import LegacyCode, PhaseType from aviary.variable_info.variables import Aircraft, Dynamic, Mission diff --git a/aviary/variable_info/enums.py b/aviary/variable_info/enums.py index 53b10e78c..0a5ff6100 100644 --- a/aviary/variable_info/enums.py +++ b/aviary/variable_info/enums.py @@ -173,10 +173,13 @@ class PhaseType(Enum): DEFAULT: Use the default phase builder for this phase. BREGUET_RANGE: Use a phase builder that implements the Breguet Range equations. + + SIMPLE_CRUISE: Use a phase builder that implements a single DOF (mass) cruise. """ DEFAULT = 'default' - BREGUET_RANGE = "bregeut_range" + BREGUET_RANGE = "breguet_range" + SIMPLE_CRUISE = "simple_cruise" class ProblemType(Enum): From 3a45cbe199536551900fc06440a99c46b077449a Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 4 Feb 2026 15:47:20 -0500 Subject: [PATCH 04/23] Need to commit to merge --- aviary/mission/two_dof/ode/simple_cruise_eom.py | 0 aviary/mission/two_dof/ode/simple_cruise_ode.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 aviary/mission/two_dof/ode/simple_cruise_eom.py create mode 100644 aviary/mission/two_dof/ode/simple_cruise_ode.py diff --git a/aviary/mission/two_dof/ode/simple_cruise_eom.py b/aviary/mission/two_dof/ode/simple_cruise_eom.py new file mode 100644 index 000000000..e69de29bb diff --git a/aviary/mission/two_dof/ode/simple_cruise_ode.py b/aviary/mission/two_dof/ode/simple_cruise_ode.py new file mode 100644 index 000000000..e69de29bb From 33eb05c6705ac8b2e67d7f8b1d66028812703e85 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Thu, 5 Feb 2026 12:31:25 -0500 Subject: [PATCH 05/23] initial --- .../mission/two_dof/ode/simple_cruise_eom.py | 383 ++++++++++++++++++ .../two_dof/phases/simple_cruise_phase.py | 0 2 files changed, 383 insertions(+) create mode 100644 aviary/mission/two_dof/phases/simple_cruise_phase.py diff --git a/aviary/mission/two_dof/ode/simple_cruise_eom.py b/aviary/mission/two_dof/ode/simple_cruise_eom.py index e69de29bb..8355e44f0 100644 --- a/aviary/mission/two_dof/ode/simple_cruise_eom.py +++ b/aviary/mission/two_dof/ode/simple_cruise_eom.py @@ -0,0 +1,383 @@ +import numpy as np +import openmdao.api as om + +from aviary.constants import GRAV_ENGLISH_LBM +from aviary.variable_info.variables import Dynamic + + +class DistanceComp(om.ExplicitComponent): + """Computes range for a simple cruise phase.""" + + def initialize(self): + self.options.declare('num_nodes', types=int) + + def setup(self): + nn = self.options['num_nodes'] + + self.add_input( + 'cruise_time', + val=np.ones(nn), + units='s', + desc='Vector of time points to compute distance.', + ) + self.add_input( + 'cruise_distance_initial', + val=0.0, + units='NM', + desc='Total distance at the start of the cruise phase.', + ) + + self.add_input( + 'TAS_cruise', + val=0.0001 * np.ones(nn), + units='NM/s', desc='Constant true airspeed at each point in cruise.') + + self.add_output( + Dynamic.Mission.DISTANCE, + shape=(nn, ), + units='NM', + desc='Computed distance at each point in the cruise phase.', + ) + + def setup_partials(self): + nn = self.options['num_nodes'] + row_col = np.arange(nn) + + self.declare_partials('cruise_range', 'cruise_time', rows=row_col, cols=row_col) + self.declare_partials('cruise_range', 'TAS_cruise', rows=row_col, cols=row_col) + self.declare_partials('cruise_range', 'cruise_distance_initial', rows=0, cols=0, val=1.0) + + def compute(self, inputs, outputs): + v_x = inputs['TAS_cruise'] + r0 = inputs['cruise_distance_initial'] + t = inputs['cruise_time'] + t0 = t[0] + + outputs['cruise_range'][0] = r0 + outputs['cruise_range'][1:] = r0 + v_x * (t - t0) + + def compute_partials(self, inputs, J): + v_x = inputs['TAS_cruise'] + vx_1 = v_x[:-1] # Initial airspeed across each two-node pair + vx_2 = v_x[1:] # Final airspeed across each two-node pair + vx_m = (vx_1 + vx_2) / 2 # Average airspeed across each two-node pair. + + m = inputs['mass'] + # Initial mass across each two-node pair + W1 = m[:-1] * GRAV_ENGLISH_LBM + W2 = m[1:] * GRAV_ENGLISH_LBM # Final mass across each two-node pair + + FF = -inputs[Dynamic.Vehicle.Propulsion.FUEL_FLOW_RATE_NEGATIVE_TOTAL] + FF_1 = FF[:-1] # Initial fuel flow across each two-node pair + FF_2 = FF[1:] # Final fuel flow across each two_node pair + + breg_1 = vx_1 * W1 * 3600 / FF_1 + breg_2 = vx_2 * W2 * 3600 / FF_2 + bregA = (breg_1 + breg_2) / 2 + star = np.log(1 / (1 - (W1 - W2) / W1)) + + dBreg1_dVx1 = W1 * 3600 / FF_1 + dBreg1_dW1 = vx_1 * 3600 / FF_1 + dBreg1_dFF1 = vx_1 * W1 * 3600 / FF_1**2 + + dBreg2_dVx2 = W2 * 3600 / FF_2 + dBreg2_dW2 = vx_2 * 3600 / FF_2 + dBreg2_dFF2 = vx_2 * W2 * 3600 / FF_2**2 + + dStar_dW1 = 1.0 / W1 + dStar_dW2 = -1.0 / W2 + + dBregA_dVx1 = dBreg1_dVx1 / 2 + dBregA_dVx2 = dBreg2_dVx2 / 2 + dBregA_dW1 = dBreg1_dW1 / 2 + dBregA_dW2 = dBreg2_dW2 / 2 + dBregA_dFF1 = dBreg1_dFF1 / 2 + dBregA_dFF2 = dBreg2_dFF2 / 2 + + dRange_dVx1 = dBregA_dVx1 * star + dRange_dVx2 = dBregA_dVx2 * star + dRange_dW1 = dBregA_dW1 * star + bregA * dStar_dW1 + dRange_dW2 = dBregA_dW2 * star + bregA * dStar_dW2 + dRange_dFF1 = dBregA_dFF1 * star + dRange_dFF2 = dBregA_dFF2 * star + + # Partials of cruise_range + + # WRT Fuel Flow + np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dFF1) + np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dFF2) + + J['cruise_range', Dynamic.Vehicle.Propulsion.FUEL_FLOW_RATE_NEGATIVE_TOTAL][...] = ( + self._d_cumsum_dx @ self._scratch_nn_x_nn + )[self._tril_rs, self._tril_cs] + + # WRT Mass: dRange_dm = dRange_dW * dW_dm + np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dW1 * GRAV_ENGLISH_LBM) + np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dW2 * GRAV_ENGLISH_LBM) + + J['cruise_range', 'mass'][...] = (self._d_cumsum_dx @ self._scratch_nn_x_nn)[ + self._tril_rs, self._tril_cs + ] + + # WRT TAS_cruise + np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dVx1) + np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dVx2) + + J['cruise_range', 'TAS_cruise'][...] = (self._d_cumsum_dx @ self._scratch_nn_x_nn)[ + self._tril_rs, self._tril_cs + ] + + # Partials of cruise_time + + # Here we need to multiply rows [1:] of the jacobian by (6076.1 / vx_m) + # But the jacobian is in a flat format in row-major order. The rows associated + # with the nonzero elements are stored in self._tril_rs. + + J['cruise_time', Dynamic.Vehicle.Propulsion.FUEL_FLOW_RATE_NEGATIVE_TOTAL][1:] = ( + J['cruise_range', Dynamic.Vehicle.Propulsion.FUEL_FLOW_RATE_NEGATIVE_TOTAL][1:] + / vx_m[self._tril_rs[1:] - 1] + ) + J['cruise_time', 'mass'][1:] = J['cruise_range', 'mass'][1:] / vx_m[self._tril_rs[1:] - 1] + + drange_cruise = bregA * star + + f = np.cumsum(drange_cruise)[:, np.newaxis] + df_du = self._d_cumsum_dx @ self._scratch_nn_x_nn + + np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], 0.5) + np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], 0.5) + + g = vx_m[:, np.newaxis] + dg_du = self._scratch_nn_x_nn + + dt_dvx = ((df_du[1:, ...] * g) - (dg_du[1:, ...] * f)) / g**2 + + J['cruise_time', 'TAS_cruise'][1:] = dt_dvx[self._tril_rs[1:] - 1, self._tril_cs[1:]] + + +class ElectricRangeComp(om.ExplicitComponent): + """ + Compute the cruise range and time for all-electric aircraft. + Assume the battery mass does not change during the cruise. + """ + + def initialize(self): + self.options.declare('num_nodes', types=int) + + def setup(self): + nn = self.options['num_nodes'] + if nn < 2: + raise Exception('num_nodes should be at least 2.') + + self.add_input( + 'cruise_time_initial', val=0.0, units='s', desc='time at which cruise begins' + ) + self.add_input( + 'cruise_distance_initial', + val=0.0, + units='NM', + desc='range reference at which cruise begins', + ) + self.add_input('TAS_cruise', val=0.0001 * np.ones(nn), units='NM/s', desc='true airspeed') + self.add_input( + Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED, + 10.0 * np.ones(nn), + units='kW*h', + desc='total energy consumption, comes from propulsion', + ) + self.add_input( + Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL, + 0.74 * np.ones(nn), + units='kW', + desc='total energy consumption, comes from propulsion', + ) + + self.add_output( + 'cruise_time', + shape=(nn,), + units='s', + desc='time in cruise', + tags=['dymos.state_source:cruise_time'], + ) + self.add_output( + 'cruise_range', + shape=(nn,), + units='NM', + desc='cruise range', + tags=['dymos.state_source:distance'], + ) + + def setup_partials(self): + nn = self.options['num_nodes'] + + # The nonzero partials of the change in range between each two nodes (dr) wrt fuel flow and mass are + # along two diagonals. The lower diagonal contains the partials wrt the initial values of mass or fuel + # flow across each two-node section. The upper diagonal contains the partials wrt the final values of mass + # or fuel flow across each two-node section. For instance, for five nodes we have: + # + # 0 0 0 0 0 + # i f 0 0 0 + # ddR/dW = 0 i f 0 0 + # 0 0 i f 0 + # 0 0 0 i f + # + # The change of range and time at the first node is zero, and it has no dependence on the mass + # history or fuel flow setting. + # Since dR is the difference in range between two nodes, we need to accumulate it across all provided masses + # using np.cumsum. + # The partial derivative of np.cumsum is just a lower triangular matrix of ones. + # Thus, the derivative of the accumulated range (and time) will be the matrix product: + # 1 0 0 0 0 0 0 0 0 0 + # 1 1 0 0 0 i f 0 0 0 + # ddR/dW = 1 1 1 0 0 @ 0 i f 0 0 + # 1 1 1 1 0 0 0 i f 0 + # 1 1 1 1 1 0 0 0 i f + rs, cs = np.tril_indices(nn) + self._tril_rs, self._tril_cs = rs, cs + + self.declare_partials( + 'cruise_range', + [ + Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED, + Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL, + 'TAS_cruise', + ], + rows=rs, + cols=cs, + ) + self.declare_partials( + 'cruise_time', + [ + Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED, + Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL, + 'TAS_cruise', + ], + rows=rs, + cols=cs, + ) + + self.declare_partials('cruise_range', 'cruise_distance_initial', val=1.0) + self.declare_partials('cruise_time', 'cruise_time_initial', val=1.0) + + # Allocated memory so we don't have to repeatedly do it in compute_partials + # Note: since these are only used in compute_partials we don't have to worry about them supporting + # complex values under complex step. + self._d_cumsum_dx = np.tri(nn) + # Note: We could make this sparse, probably doesn't matter. + self._scratch_nn_x_nn = np.zeros((nn, nn)) + + def compute(self, inputs, outputs): + v_x = inputs['TAS_cruise'] + EE = inputs[Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED] + EP = inputs[Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL] + r0 = inputs['cruise_distance_initial'] + t0 = inputs['cruise_time_initial'] + r0 = r0[0] + t0 = t0[0] + + # All-electric version + E_1 = EE[:-1] # Initial energy across each two-node pair, kW*h + E_2 = EE[1:] # Final power energy each two-node pair + P_1 = EP[:-1] # Initial power across each two-node pair, kW + P_2 = EP[1:] # Final power across each two-node pair + + vx_1 = v_x[:-1] # Initial airspeed across each two-node pair, NM/s + vx_2 = v_x[1:] # Final airspeed across each two-node pair + vx_m = (vx_1 + vx_2) / 2 # Average airspeed across each two-node pair. + + e_breg_1 = vx_1 * E_1 * 3600 / P_1 # NM + e_breg_2 = vx_2 * E_2 * 3600 / P_2 + e_drange_cruise = e_breg_2 - e_breg_1 + + outputs['cruise_range'][0] = r0 + outputs['cruise_range'][1:] = r0 + np.cumsum(e_drange_cruise) + outputs['cruise_time'][0] = t0 + outputs['cruise_time'][1:] = t0 + np.cumsum(e_drange_cruise) / vx_m + + def compute_partials(self, inputs, J): + v_x = inputs['TAS_cruise'] + vx_1 = v_x[:-1] # Initial airspeed across each two-node pair + vx_2 = v_x[1:] # Final airspeed across each two-node pair + vx_m = (vx_1 + vx_2) / 2 # Average airspeed across each two-node pair. + + e = inputs[Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED] + # Initial energy across each two-node pair + E_1 = e[:-1] + E_2 = e[1:] # Final energy across each two-node pair + + EP = inputs[Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL] + EP_1 = EP[:-1] # Initial power across each two-node pair + EP_2 = EP[1:] # Final power across each two-node pair + + e_breg_1 = vx_1 * E_1 * 3600 / EP_1 + e_breg_2 = vx_2 * E_2 * 3600 / EP_2 + e_drange_cruise = e_breg_2 - e_breg_1 + + dBreg1_dVx1 = E_1 * 3600 / EP_1 + dBreg1_dE1 = vx_1 * 3600 / EP_1 + dBreg1_dP1 = -vx_1 * E_1 * 3600 / EP_1**2 + + dBreg2_dVx2 = E_2 * 3600 / EP_2 + dBreg2_dE2 = vx_2 * 3600 / EP_2 + dBreg2_dP2 = -vx_2 * E_2 * 3600 / EP_2**2 + + dRange_dVx1 = -dBreg1_dVx1 + dRange_dVx2 = dBreg2_dVx2 + dRange_dE1 = -dBreg1_dE1 + dRange_dE2 = dBreg2_dE2 + dRange_dP1 = -dBreg1_dP1 + dRange_dP2 = dBreg2_dP2 + + # Partials of cruise_range + + # WRT electric power + np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dP1) + np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dP2) + + J['cruise_range', Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL][...] = ( + self._d_cumsum_dx @ self._scratch_nn_x_nn + )[self._tril_rs, self._tril_cs] + + # WRT CUMULATIVE_ELECTRIC_ENERGY_USED: + np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dE1) + np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dE2) + + J['cruise_range', Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED][...] = ( + self._d_cumsum_dx @ self._scratch_nn_x_nn + )[self._tril_rs, self._tril_cs] + + # WRT TAS_cruise + np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dVx1) + np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dVx2) + + J['cruise_range', 'TAS_cruise'][...] = (self._d_cumsum_dx @ self._scratch_nn_x_nn)[ + self._tril_rs, self._tril_cs + ] + + # Partials of cruise_time + + # Here we need to multiply rows [1:] of the jacobian by (6076.1 / vx_m) + # But the jacobian is in a flat format in row-major order. The rows associated + # with the nonzero elements are stored in self._tril_rs. + + J['cruise_time', Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL][1:] = ( + J['cruise_range', Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL][1:] + / vx_m[self._tril_rs[1:] - 1] + ) + J['cruise_time', Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED][1:] = ( + J['cruise_range', Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED][1:] + / vx_m[self._tril_rs[1:] - 1] + ) + + f = np.cumsum(e_drange_cruise)[:, np.newaxis] + df_du = self._d_cumsum_dx @ self._scratch_nn_x_nn + + np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], 0.5) + np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], 0.5) + + g = vx_m[:, np.newaxis] + dg_du = self._scratch_nn_x_nn + + dt_dvx = ((df_du[1:, ...] * g) - (dg_du[1:, ...] * f)) / g**2 + + J['cruise_time', 'TAS_cruise'][1:] = dt_dvx[self._tril_rs[1:] - 1, self._tril_cs[1:]] diff --git a/aviary/mission/two_dof/phases/simple_cruise_phase.py b/aviary/mission/two_dof/phases/simple_cruise_phase.py new file mode 100644 index 000000000..e69de29bb From 01267a8ff79c91ae02e29d5b578bd164ce877b5d Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Fri, 6 Feb 2026 14:11:45 -0500 Subject: [PATCH 06/23] First test passes --- aviary/core/aviary_group.py | 38 +- .../mission/two_dof/ode/simple_cruise_eom.py | 351 ++---------------- .../mission/two_dof/ode/simple_cruise_ode.py | 285 ++++++++++++++ aviary/mission/two_dof/phases/accel_phase.py | 7 - aviary/mission/two_dof/phases/ascent_phase.py | 7 - aviary/mission/two_dof/phases/cruise_phase.py | 8 +- aviary/mission/two_dof/phases/flight_phase.py | 7 - .../two_dof/phases/groundroll_phase.py | 9 - .../mission/two_dof/phases/rotation_phase.py | 7 - .../two_dof/phases/simple_cruise_phase.py | 214 +++++++++++ .../mission/two_dof_problem_configurator.py | 178 +++++---- aviary/models/missions/two_dof_default.py | 4 +- aviary/utils/aviary_options_dict.py | 13 + 13 files changed, 666 insertions(+), 462 deletions(-) diff --git a/aviary/core/aviary_group.py b/aviary/core/aviary_group.py index 403fc0067..7fd80deda 100644 --- a/aviary/core/aviary_group.py +++ b/aviary/core/aviary_group.py @@ -34,7 +34,13 @@ update_GASP_options, ) from aviary.utils.utils import wrapped_convert_units -from aviary.variable_info.enums import EquationsOfMotion, LegacyCode, ProblemType, Verbosity +from aviary.variable_info.enums import ( + EquationsOfMotion, + LegacyCode, + PhaseType, + ProblemType, + Verbosity, +) from aviary.variable_info.functions import setup_trajectory_params from aviary.variable_info.variables import Aircraft, Mission, Settings @@ -345,19 +351,6 @@ def check_and_preprocess_inputs(self, verbosity=None): # Sets duration_bounds, initial_guesses, and fixed_duration for phase_name, phase in self.mission_info.items(): if 'user_options' in phase: - analytic = False - if self.mission_method is EquationsOfMotion.TWO_DEGREES_OF_FREEDOM: - try: - # if the user provided an option, use it - analytic = phase['user_options']['analytic'] - except KeyError: - # if it isn't specified, only the default 2DOF cruise for - # collocation is analytic - if 'cruise' in phase_name: - analytic = phase['user_options']['analytic'] = True - else: - analytic = phase['user_options']['analytic'] = False - if 'time_duration' in phase['user_options']: time_duration, units = phase['user_options']['time_duration'] @@ -952,9 +945,9 @@ def add_post_mission_systems(self, verbosity=None): # this is only used for analytic phases with a target duration time_duration = user_options.get('time_duration', (None, 'min')) time_duration = wrapped_convert_units(time_duration, 'min') - analytic = user_options.get('analytic', False) + integrates_mass = user_options['phase_builder'] is PhaseType.BREGUET_RANGE - if analytic and time_duration is not None: + if integrates_mass and time_duration is not None: post_mission.add_subsystem( f'{phase_name}_duration_constraint', om.ExecComp( @@ -1129,15 +1122,16 @@ def link_phases(self, verbosity=None, comm=None): # and configurators for ii in range(len(phases) - 1): phase1, phase2 = phases[ii : ii + 2] - analytic1 = self.mission_info[phase1]['user_options'].get('analytic', False) - analytic2 = self.mission_info[phase2]['user_options'].get('analytic', False) - - if not (analytic1 or analytic2): - self.traj.link_phases(phases=[phase1, phase2], vars=[var], connected=True) + opt1 = self.mission_info[phase1]['user_options'] + opt2 = self.mission_info[phase2]['user_options'] + integrates_mass1 = opt1['phase_builder'] is PhaseType.BREGUET_RANGE + integrates_mass2 = opt2['phase_builder'] is PhaseType.BREGUET_RANGE - else: + if integrates_mass1 or integrates_mass2: # TODO need ref value for these linkage constraints self.traj.add_linkage_constraint(phase1, phase2, var, var, connected=False) + else: + self.traj.link_phases(phases=[phase1, phase2], vars=[var], connected=True) self.configurator.link_phases(self, phases, connect_directly=true_unless_mpi) diff --git a/aviary/mission/two_dof/ode/simple_cruise_eom.py b/aviary/mission/two_dof/ode/simple_cruise_eom.py index 8355e44f0..016301017 100644 --- a/aviary/mission/two_dof/ode/simple_cruise_eom.py +++ b/aviary/mission/two_dof/ode/simple_cruise_eom.py @@ -1,7 +1,6 @@ import numpy as np import openmdao.api as om -from aviary.constants import GRAV_ENGLISH_LBM from aviary.variable_info.variables import Dynamic @@ -15,7 +14,7 @@ def setup(self): nn = self.options['num_nodes'] self.add_input( - 'cruise_time', + 'time', val=np.ones(nn), units='s', desc='Vector of time points to compute distance.', @@ -43,341 +42,45 @@ def setup_partials(self): nn = self.options['num_nodes'] row_col = np.arange(nn) - self.declare_partials('cruise_range', 'cruise_time', rows=row_col, cols=row_col) - self.declare_partials('cruise_range', 'TAS_cruise', rows=row_col, cols=row_col) - self.declare_partials('cruise_range', 'cruise_distance_initial', rows=0, cols=0, val=1.0) - - def compute(self, inputs, outputs): - v_x = inputs['TAS_cruise'] - r0 = inputs['cruise_distance_initial'] - t = inputs['cruise_time'] - t0 = t[0] - - outputs['cruise_range'][0] = r0 - outputs['cruise_range'][1:] = r0 + v_x * (t - t0) - - def compute_partials(self, inputs, J): - v_x = inputs['TAS_cruise'] - vx_1 = v_x[:-1] # Initial airspeed across each two-node pair - vx_2 = v_x[1:] # Final airspeed across each two-node pair - vx_m = (vx_1 + vx_2) / 2 # Average airspeed across each two-node pair. - - m = inputs['mass'] - # Initial mass across each two-node pair - W1 = m[:-1] * GRAV_ENGLISH_LBM - W2 = m[1:] * GRAV_ENGLISH_LBM # Final mass across each two-node pair - - FF = -inputs[Dynamic.Vehicle.Propulsion.FUEL_FLOW_RATE_NEGATIVE_TOTAL] - FF_1 = FF[:-1] # Initial fuel flow across each two-node pair - FF_2 = FF[1:] # Final fuel flow across each two_node pair - - breg_1 = vx_1 * W1 * 3600 / FF_1 - breg_2 = vx_2 * W2 * 3600 / FF_2 - bregA = (breg_1 + breg_2) / 2 - star = np.log(1 / (1 - (W1 - W2) / W1)) - - dBreg1_dVx1 = W1 * 3600 / FF_1 - dBreg1_dW1 = vx_1 * 3600 / FF_1 - dBreg1_dFF1 = vx_1 * W1 * 3600 / FF_1**2 - - dBreg2_dVx2 = W2 * 3600 / FF_2 - dBreg2_dW2 = vx_2 * 3600 / FF_2 - dBreg2_dFF2 = vx_2 * W2 * 3600 / FF_2**2 - - dStar_dW1 = 1.0 / W1 - dStar_dW2 = -1.0 / W2 - - dBregA_dVx1 = dBreg1_dVx1 / 2 - dBregA_dVx2 = dBreg2_dVx2 / 2 - dBregA_dW1 = dBreg1_dW1 / 2 - dBregA_dW2 = dBreg2_dW2 / 2 - dBregA_dFF1 = dBreg1_dFF1 / 2 - dBregA_dFF2 = dBreg2_dFF2 / 2 - - dRange_dVx1 = dBregA_dVx1 * star - dRange_dVx2 = dBregA_dVx2 * star - dRange_dW1 = dBregA_dW1 * star + bregA * dStar_dW1 - dRange_dW2 = dBregA_dW2 * star + bregA * dStar_dW2 - dRange_dFF1 = dBregA_dFF1 * star - dRange_dFF2 = dBregA_dFF2 * star - - # Partials of cruise_range - - # WRT Fuel Flow - np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dFF1) - np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dFF2) - - J['cruise_range', Dynamic.Vehicle.Propulsion.FUEL_FLOW_RATE_NEGATIVE_TOTAL][...] = ( - self._d_cumsum_dx @ self._scratch_nn_x_nn - )[self._tril_rs, self._tril_cs] - - # WRT Mass: dRange_dm = dRange_dW * dW_dm - np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dW1 * GRAV_ENGLISH_LBM) - np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dW2 * GRAV_ENGLISH_LBM) - - J['cruise_range', 'mass'][...] = (self._d_cumsum_dx @ self._scratch_nn_x_nn)[ - self._tril_rs, self._tril_cs - ] - - # WRT TAS_cruise - np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dVx1) - np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dVx2) - - J['cruise_range', 'TAS_cruise'][...] = (self._d_cumsum_dx @ self._scratch_nn_x_nn)[ - self._tril_rs, self._tril_cs - ] - - # Partials of cruise_time - - # Here we need to multiply rows [1:] of the jacobian by (6076.1 / vx_m) - # But the jacobian is in a flat format in row-major order. The rows associated - # with the nonzero elements are stored in self._tril_rs. - - J['cruise_time', Dynamic.Vehicle.Propulsion.FUEL_FLOW_RATE_NEGATIVE_TOTAL][1:] = ( - J['cruise_range', Dynamic.Vehicle.Propulsion.FUEL_FLOW_RATE_NEGATIVE_TOTAL][1:] - / vx_m[self._tril_rs[1:] - 1] - ) - J['cruise_time', 'mass'][1:] = J['cruise_range', 'mass'][1:] / vx_m[self._tril_rs[1:] - 1] - - drange_cruise = bregA * star - - f = np.cumsum(drange_cruise)[:, np.newaxis] - df_du = self._d_cumsum_dx @ self._scratch_nn_x_nn - - np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], 0.5) - np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], 0.5) - - g = vx_m[:, np.newaxis] - dg_du = self._scratch_nn_x_nn - - dt_dvx = ((df_du[1:, ...] * g) - (dg_du[1:, ...] * f)) / g**2 - - J['cruise_time', 'TAS_cruise'][1:] = dt_dvx[self._tril_rs[1:] - 1, self._tril_cs[1:]] - - -class ElectricRangeComp(om.ExplicitComponent): - """ - Compute the cruise range and time for all-electric aircraft. - Assume the battery mass does not change during the cruise. - """ - - def initialize(self): - self.options.declare('num_nodes', types=int) - - def setup(self): - nn = self.options['num_nodes'] - if nn < 2: - raise Exception('num_nodes should be at least 2.') - - self.add_input( - 'cruise_time_initial', val=0.0, units='s', desc='time at which cruise begins' - ) - self.add_input( - 'cruise_distance_initial', - val=0.0, - units='NM', - desc='range reference at which cruise begins', - ) - self.add_input('TAS_cruise', val=0.0001 * np.ones(nn), units='NM/s', desc='true airspeed') - self.add_input( - Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED, - 10.0 * np.ones(nn), - units='kW*h', - desc='total energy consumption, comes from propulsion', - ) - self.add_input( - Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL, - 0.74 * np.ones(nn), - units='kW', - desc='total energy consumption, comes from propulsion', - ) - - self.add_output( - 'cruise_time', - shape=(nn,), - units='s', - desc='time in cruise', - tags=['dymos.state_source:cruise_time'], - ) - self.add_output( - 'cruise_range', - shape=(nn,), - units='NM', - desc='cruise range', - tags=['dymos.state_source:distance'], - ) - - def setup_partials(self): - nn = self.options['num_nodes'] - - # The nonzero partials of the change in range between each two nodes (dr) wrt fuel flow and mass are - # along two diagonals. The lower diagonal contains the partials wrt the initial values of mass or fuel - # flow across each two-node section. The upper diagonal contains the partials wrt the final values of mass - # or fuel flow across each two-node section. For instance, for five nodes we have: - # - # 0 0 0 0 0 - # i f 0 0 0 - # ddR/dW = 0 i f 0 0 - # 0 0 i f 0 - # 0 0 0 i f - # - # The change of range and time at the first node is zero, and it has no dependence on the mass - # history or fuel flow setting. - # Since dR is the difference in range between two nodes, we need to accumulate it across all provided masses - # using np.cumsum. - # The partial derivative of np.cumsum is just a lower triangular matrix of ones. - # Thus, the derivative of the accumulated range (and time) will be the matrix product: - # 1 0 0 0 0 0 0 0 0 0 - # 1 1 0 0 0 i f 0 0 0 - # ddR/dW = 1 1 1 0 0 @ 0 i f 0 0 - # 1 1 1 1 0 0 0 i f 0 - # 1 1 1 1 1 0 0 0 i f - rs, cs = np.tril_indices(nn) - self._tril_rs, self._tril_cs = rs, cs - self.declare_partials( - 'cruise_range', - [ - Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED, - Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL, - 'TAS_cruise', - ], - rows=rs, - cols=cs, + Dynamic.Mission.DISTANCE, + 'TAS_cruise', + rows=row_col, + cols=row_col, ) self.declare_partials( - 'cruise_time', - [ - Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED, - Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL, - 'TAS_cruise', - ], - rows=rs, - cols=cs, + Dynamic.Mission.DISTANCE, + 'cruise_distance_initial', + rows=[0], + cols=[0], + val=1.0, ) - self.declare_partials('cruise_range', 'cruise_distance_initial', val=1.0) - self.declare_partials('cruise_time', 'cruise_time_initial', val=1.0) + # Sparsity pattern includes a vertical row at i=0 + xtra_row = np.arange(nn-1) + 1 + xtra_col = np.zeros(nn-1) + + all_row = np.hstack((row_col, xtra_row)) + all_col = np.hstack((row_col, xtra_col)) - # Allocated memory so we don't have to repeatedly do it in compute_partials - # Note: since these are only used in compute_partials we don't have to worry about them supporting - # complex values under complex step. - self._d_cumsum_dx = np.tri(nn) - # Note: We could make this sparse, probably doesn't matter. - self._scratch_nn_x_nn = np.zeros((nn, nn)) + self.declare_partials(Dynamic.Mission.DISTANCE, 'time', rows=all_row, cols=all_col) def compute(self, inputs, outputs): v_x = inputs['TAS_cruise'] - EE = inputs[Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED] - EP = inputs[Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL] r0 = inputs['cruise_distance_initial'] - t0 = inputs['cruise_time_initial'] - r0 = r0[0] - t0 = t0[0] - - # All-electric version - E_1 = EE[:-1] # Initial energy across each two-node pair, kW*h - E_2 = EE[1:] # Final power energy each two-node pair - P_1 = EP[:-1] # Initial power across each two-node pair, kW - P_2 = EP[1:] # Final power across each two-node pair - - vx_1 = v_x[:-1] # Initial airspeed across each two-node pair, NM/s - vx_2 = v_x[1:] # Final airspeed across each two-node pair - vx_m = (vx_1 + vx_2) / 2 # Average airspeed across each two-node pair. - - e_breg_1 = vx_1 * E_1 * 3600 / P_1 # NM - e_breg_2 = vx_2 * E_2 * 3600 / P_2 - e_drange_cruise = e_breg_2 - e_breg_1 + t = inputs['time'] + t0 = t[0] - outputs['cruise_range'][0] = r0 - outputs['cruise_range'][1:] = r0 + np.cumsum(e_drange_cruise) - outputs['cruise_time'][0] = t0 - outputs['cruise_time'][1:] = t0 + np.cumsum(e_drange_cruise) / vx_m + outputs[Dynamic.Mission.DISTANCE] = r0 + v_x * (t - t0) def compute_partials(self, inputs, J): v_x = inputs['TAS_cruise'] - vx_1 = v_x[:-1] # Initial airspeed across each two-node pair - vx_2 = v_x[1:] # Final airspeed across each two-node pair - vx_m = (vx_1 + vx_2) / 2 # Average airspeed across each two-node pair. - - e = inputs[Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED] - # Initial energy across each two-node pair - E_1 = e[:-1] - E_2 = e[1:] # Final energy across each two-node pair - - EP = inputs[Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL] - EP_1 = EP[:-1] # Initial power across each two-node pair - EP_2 = EP[1:] # Final power across each two-node pair - - e_breg_1 = vx_1 * E_1 * 3600 / EP_1 - e_breg_2 = vx_2 * E_2 * 3600 / EP_2 - e_drange_cruise = e_breg_2 - e_breg_1 - - dBreg1_dVx1 = E_1 * 3600 / EP_1 - dBreg1_dE1 = vx_1 * 3600 / EP_1 - dBreg1_dP1 = -vx_1 * E_1 * 3600 / EP_1**2 - - dBreg2_dVx2 = E_2 * 3600 / EP_2 - dBreg2_dE2 = vx_2 * 3600 / EP_2 - dBreg2_dP2 = -vx_2 * E_2 * 3600 / EP_2**2 - - dRange_dVx1 = -dBreg1_dVx1 - dRange_dVx2 = dBreg2_dVx2 - dRange_dE1 = -dBreg1_dE1 - dRange_dE2 = dBreg2_dE2 - dRange_dP1 = -dBreg1_dP1 - dRange_dP2 = dBreg2_dP2 - - # Partials of cruise_range - - # WRT electric power - np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dP1) - np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dP2) - - J['cruise_range', Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL][...] = ( - self._d_cumsum_dx @ self._scratch_nn_x_nn - )[self._tril_rs, self._tril_cs] - - # WRT CUMULATIVE_ELECTRIC_ENERGY_USED: - np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dE1) - np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dE2) - - J['cruise_range', Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED][...] = ( - self._d_cumsum_dx @ self._scratch_nn_x_nn - )[self._tril_rs, self._tril_cs] - - # WRT TAS_cruise - np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], dRange_dVx1) - np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], dRange_dVx2) - - J['cruise_range', 'TAS_cruise'][...] = (self._d_cumsum_dx @ self._scratch_nn_x_nn)[ - self._tril_rs, self._tril_cs - ] - - # Partials of cruise_time - - # Here we need to multiply rows [1:] of the jacobian by (6076.1 / vx_m) - # But the jacobian is in a flat format in row-major order. The rows associated - # with the nonzero elements are stored in self._tril_rs. - - J['cruise_time', Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL][1:] = ( - J['cruise_range', Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL][1:] - / vx_m[self._tril_rs[1:] - 1] - ) - J['cruise_time', Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED][1:] = ( - J['cruise_range', Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED][1:] - / vx_m[self._tril_rs[1:] - 1] - ) - - f = np.cumsum(e_drange_cruise)[:, np.newaxis] - df_du = self._d_cumsum_dx @ self._scratch_nn_x_nn - - np.fill_diagonal(self._scratch_nn_x_nn[1:, :-1], 0.5) - np.fill_diagonal(self._scratch_nn_x_nn[1:, 1:], 0.5) - - g = vx_m[:, np.newaxis] - dg_du = self._scratch_nn_x_nn + t = inputs['time'] + t0 = t[0] + nn = self.options['num_nodes'] - dt_dvx = ((df_du[1:, ...] * g) - (dg_du[1:, ...] * f)) / g**2 + J[Dynamic.Mission.DISTANCE, 'TAS_cruise'] = t - t0 - J['cruise_time', 'TAS_cruise'][1:] = dt_dvx[self._tril_rs[1:] - 1, self._tril_cs[1:]] + J[Dynamic.Mission.DISTANCE, 'time'][0] = 0.0 + J[Dynamic.Mission.DISTANCE, 'time'][1:nn] = v_x[1:] + J[Dynamic.Mission.DISTANCE, 'time'][nn:] = -v_x[1:] \ No newline at end of file diff --git a/aviary/mission/two_dof/ode/simple_cruise_ode.py b/aviary/mission/two_dof/ode/simple_cruise_ode.py index e69de29bb..2af3ef370 100644 --- a/aviary/mission/two_dof/ode/simple_cruise_ode.py +++ b/aviary/mission/two_dof/ode/simple_cruise_ode.py @@ -0,0 +1,285 @@ +import numpy as np +import openmdao.api as om + +from aviary.mission.two_dof.ode.simple_cruise_eom import DistanceComp +from aviary.mission.two_dof.ode.params import ParamPort +from aviary.mission.two_dof.ode.two_dof_ode import TwoDOFODE +from aviary.mission.ode.altitude_rate import AltitudeRate +from aviary.mission.ode.specific_energy_rate import SpecificEnergyRate +from aviary.subsystems.mass.mass_to_weight import MassToWeight +from aviary.subsystems.propulsion.propulsion_builder import PropulsionBuilder +from aviary.variable_info.enums import SpeedType +from aviary.variable_info.variables import Dynamic + + +class SimpleCruiseODE(TwoDOFODE): + """A simple ODE for cruise that only integrates mass.""" + + def setup(self): + nn = self.options['num_nodes'] + aviary_options = self.options['aviary_options'] + subsystems = self.options['subsystems'] + subsystem_options = self.options['subsystem_options'] + + # TODO: paramport + self.add_subsystem('params', ParamPort(), promotes=['*']) + + self.add_atmosphere(input_speed_type=SpeedType.MACH) + + self.add_subsystem( + 'calc_weight', + MassToWeight(num_nodes=nn), + promotes_inputs=['mass'], + promotes_outputs=['weight'], + ) + + prop_group = om.Group() + + kwargs = { + 'num_nodes': nn, + 'aviary_inputs': aviary_options, + 'method': 'cruise', + 'output_alpha': True, + } + for subsystem in subsystems: + # check if subsystem_options has entry for a subsystem of this name + if subsystem.name in subsystem_options: + kwargs.update(subsystem_options[subsystem.name]) + system = subsystem.build_mission(**kwargs) + if system is not None: + if isinstance(subsystem, PropulsionBuilder): + prop_group.add_subsystem( + subsystem.name, + system, + promotes_inputs=subsystem.mission_inputs(**kwargs), + promotes_outputs=subsystem.mission_outputs(**kwargs), + ) + else: + self.add_subsystem( + subsystem.name, + system, + promotes_inputs=subsystem.mission_inputs(**kwargs), + promotes_outputs=subsystem.mission_outputs(**kwargs), + ) + + bal = om.BalanceComp( + name=Dynamic.Vehicle.Propulsion.THROTTLE, + val=np.ones(nn), + upper=1.0, + lower=0.0, + units='unitless', + lhs_name=Dynamic.Vehicle.Propulsion.THRUST_TOTAL, + rhs_name=Dynamic.Vehicle.DRAG, + eq_units='lbf', + ) + + prop_group.add_subsystem( + 'thrust_balance', subsys=bal, promotes_inputs=['*'], promotes_outputs=['*'] + ) + + prop_group.linear_solver = om.DirectSolver() + + prop_group.nonlinear_solver = om.NewtonSolver( + solve_subsystems=True, + maxiter=20, + rtol=1e-12, + atol=1e-12, + err_on_non_converge=False, + ) + prop_group.nonlinear_solver.linesearch = om.BoundsEnforceLS() + + prop_group.nonlinear_solver.options['iprint'] = 2 + prop_group.linear_solver.options['iprint'] = 2 + + self.add_subsystem( + 'prop_group', subsys=prop_group, promotes_inputs=['*'], promotes_outputs=['*'] + ) + + # + # collect initial/final outputs + # + self.add_subsystem( + 'distance_eom', + DistanceComp(num_nodes=nn), + promotes_inputs=[ + ('cruise_distance_initial', 'initial_distance'), + ('TAS_cruise', Dynamic.Mission.VELOCITY), + 'time', + ], + promotes_outputs=[Dynamic.Mission.DISTANCE], + ) + + self.add_subsystem( + name='SPECIFIC_ENERGY_RATE_EXCESS', + subsys=SpecificEnergyRate(num_nodes=nn), + promotes_inputs=[ + Dynamic.Mission.VELOCITY, + Dynamic.Vehicle.MASS, + ( + Dynamic.Vehicle.Propulsion.THRUST_TOTAL, + Dynamic.Vehicle.Propulsion.THRUST_MAX_TOTAL, + ), + Dynamic.Vehicle.DRAG, + ], + promotes_outputs=[ + ( + Dynamic.Mission.SPECIFIC_ENERGY_RATE, + Dynamic.Mission.SPECIFIC_ENERGY_RATE_EXCESS, + ) + ], + ) + + self.add_subsystem( + name='ALTITUDE_RATE_MAX', + subsys=AltitudeRate(num_nodes=nn), + promotes_inputs=[ + ( + Dynamic.Mission.SPECIFIC_ENERGY_RATE, + Dynamic.Mission.SPECIFIC_ENERGY_RATE_EXCESS, + ), + Dynamic.Mission.VELOCITY_RATE, + Dynamic.Mission.VELOCITY, + ], + promotes_outputs=[(Dynamic.Mission.ALTITUDE_RATE, Dynamic.Mission.ALTITUDE_RATE_MAX)], + ) + + ParamPort.set_default_vals(self) + self.set_input_defaults(Dynamic.Mission.ALTITUDE, val=37500 * np.ones(nn), units='ft') + self.set_input_defaults('mass', val=np.linspace(171481, 171581 - 10000, nn), units='lbm') + + +class ElectricBreguetCruiseODE(TwoDOFODE): + """The GASP based cruise ODE for electric aircraft.""" + + def setup(self): + nn = self.options['num_nodes'] + aviary_options = self.options['aviary_options'] + subsystems = self.options['subsystems'] + + # TODO: paramport + self.add_subsystem('params', ParamPort(), promotes=['*']) + + self.add_atmosphere(input_speed_type=SpeedType.MACH) + + self.add_subsystem( + 'calc_weight', + MassToWeight(num_nodes=nn), + promotes_inputs=['mass'], + promotes_outputs=['weight'], + ) + + prop_group = om.Group() + + kwargs = { + 'num_nodes': nn, + 'aviary_inputs': aviary_options, + 'method': 'cruise', + 'output_alpha': True, + } + for subsystem in subsystems: + system = subsystem.build_mission(**kwargs) + if system is not None: + if isinstance(subsystem, PropulsionBuilder): + prop_group.add_subsystem( + subsystem.name, + system, + promotes_inputs=subsystem.mission_inputs(**kwargs), + promotes_outputs=subsystem.mission_outputs(**kwargs), + ) + else: + self.add_subsystem( + subsystem.name, + system, + promotes_inputs=subsystem.mission_inputs(**kwargs), + promotes_outputs=subsystem.mission_outputs(**kwargs), + ) + + bal = om.BalanceComp( + name=Dynamic.Vehicle.Propulsion.THROTTLE, + val=np.ones(nn), + upper=1.0, + lower=0.0, + units='unitless', + lhs_name=Dynamic.Vehicle.Propulsion.THRUST_TOTAL, + rhs_name=Dynamic.Vehicle.DRAG, + eq_units='lbf', + ) + + prop_group.add_subsystem( + 'thrust_balance', subsys=bal, promotes_inputs=['*'], promotes_outputs=['*'] + ) + + prop_group.linear_solver = om.DirectSolver() + + prop_group.nonlinear_solver = om.NewtonSolver( + solve_subsystems=True, + maxiter=20, + rtol=1e-12, + atol=1e-12, + err_on_non_converge=False, + ) + prop_group.nonlinear_solver.linesearch = om.BoundsEnforceLS() + + prop_group.nonlinear_solver.options['iprint'] = 2 + prop_group.linear_solver.options['iprint'] = 2 + + self.add_subsystem( + 'prop_group', subsys=prop_group, promotes_inputs=['*'], promotes_outputs=['*'] + ) + + # + # collect initial/final outputs + # + self.add_subsystem( + 'electric_breguet_eom', + ElectricRangeComp(num_nodes=nn), + promotes_inputs=[ + ('cruise_distance_initial', 'initial_distance'), + ('cruise_time_initial', 'initial_time'), + Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED, + Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL, + ('TAS_cruise', Dynamic.Mission.VELOCITY), + ], + promotes_outputs=[ + ('cruise_range', Dynamic.Mission.DISTANCE), + ('cruise_time', 'time'), + ], + ) + + self.add_subsystem( + name='SPECIFIC_ENERGY_RATE_EXCESS', + subsys=SpecificEnergyRate(num_nodes=nn), + promotes_inputs=[ + Dynamic.Mission.VELOCITY, + Dynamic.Vehicle.MASS, + ( + Dynamic.Vehicle.Propulsion.THRUST_TOTAL, + Dynamic.Vehicle.Propulsion.THRUST_MAX_TOTAL, + ), + Dynamic.Vehicle.DRAG, + ], + promotes_outputs=[ + ( + Dynamic.Mission.SPECIFIC_ENERGY_RATE, + Dynamic.Mission.SPECIFIC_ENERGY_RATE_EXCESS, + ) + ], + ) + + self.add_subsystem( + name='ALTITUDE_RATE_MAX', + subsys=AltitudeRate(num_nodes=nn), + promotes_inputs=[ + ( + Dynamic.Mission.SPECIFIC_ENERGY_RATE, + Dynamic.Mission.SPECIFIC_ENERGY_RATE_EXCESS, + ), + Dynamic.Mission.VELOCITY_RATE, + Dynamic.Mission.VELOCITY, + ], + promotes_outputs=[(Dynamic.Mission.ALTITUDE_RATE, Dynamic.Mission.ALTITUDE_RATE_MAX)], + ) + + ParamPort.set_default_vals(self) + self.set_input_defaults(Dynamic.Mission.ALTITUDE, val=37500 * np.ones(nn), units='ft') + self.set_input_defaults('mass', val=np.linspace(171481, 171581 - 10000, nn), units='lbm') diff --git a/aviary/mission/two_dof/phases/accel_phase.py b/aviary/mission/two_dof/phases/accel_phase.py index ac94a6a98..da51fa86c 100644 --- a/aviary/mission/two_dof/phases/accel_phase.py +++ b/aviary/mission/two_dof/phases/accel_phase.py @@ -73,13 +73,6 @@ def declare_options(self): # The options below have not yet been revamped. - self.declare( - 'analytic', - types=bool, - default=False, - desc='When set to True, this is an analytic phase.', - ) - self.declare( name='alt', default=500.0, units='ft', desc='Constant altitude for this phase.' ) diff --git a/aviary/mission/two_dof/phases/ascent_phase.py b/aviary/mission/two_dof/phases/ascent_phase.py index 078077af5..322bbe317 100644 --- a/aviary/mission/two_dof/phases/ascent_phase.py +++ b/aviary/mission/two_dof/phases/ascent_phase.py @@ -93,13 +93,6 @@ def declare_options(self): # The options below have not yet been revamped. - self.declare( - 'analytic', - types=bool, - default=False, - desc='When set to True, this is an analytic phase.', - ) - self.declare( name='pitch_constraint_bounds', default=(0.0, 15.0), diff --git a/aviary/mission/two_dof/phases/cruise_phase.py b/aviary/mission/two_dof/phases/cruise_phase.py index 214ce1d2f..7d245ad26 100644 --- a/aviary/mission/two_dof/phases/cruise_phase.py +++ b/aviary/mission/two_dof/phases/cruise_phase.py @@ -34,13 +34,6 @@ def declare_options(self): self.declare(name='mach_cruise', default=0.0, desc='Cruise Mach number.') - self.declare( - 'analytic', - types=bool, - default=False, - desc='When set to True, this is an analytic phase.', - ) - self.declare( 'reserve', types=bool, @@ -170,6 +163,7 @@ def build_phase(self, aviary_options: AviaryValues = None): phase.add_timeseries_output('time', units='s', output_name='time') phase.add_timeseries_output(Dynamic.Vehicle.MASS, units='lbm') phase.add_timeseries_output(Dynamic.Mission.DISTANCE, units='nmi') + phase.add_timeseries_output(Dynamic.Mission.DISTANCE, units='nmi') return phase diff --git a/aviary/mission/two_dof/phases/flight_phase.py b/aviary/mission/two_dof/phases/flight_phase.py index e2cd51d5d..923da393f 100644 --- a/aviary/mission/two_dof/phases/flight_phase.py +++ b/aviary/mission/two_dof/phases/flight_phase.py @@ -74,13 +74,6 @@ def declare_options(self): 'throughout the flight envelope. Default value is None for no constraint.', ) - self.declare( - 'analytic', - types=bool, - default=False, - desc='When set to True, this is an analytic phase.', - ) - self.declare( name='EAS_target', default=0.0, diff --git a/aviary/mission/two_dof/phases/groundroll_phase.py b/aviary/mission/two_dof/phases/groundroll_phase.py index 96868ec66..c7c27c168 100644 --- a/aviary/mission/two_dof/phases/groundroll_phase.py +++ b/aviary/mission/two_dof/phases/groundroll_phase.py @@ -54,15 +54,6 @@ def declare_options(self): } self.add_state_options('velocity', units='kn', defaults=defaults) - # The options below have not yet been revamped. - - self.declare( - 'analytic', - types=bool, - default=False, - desc='When set to True, this is an analytic phase.', - ) - class GroundrollPhase(PhaseBuilder): """ diff --git a/aviary/mission/two_dof/phases/rotation_phase.py b/aviary/mission/two_dof/phases/rotation_phase.py index e1f0c5d35..30e89a2f4 100644 --- a/aviary/mission/two_dof/phases/rotation_phase.py +++ b/aviary/mission/two_dof/phases/rotation_phase.py @@ -80,13 +80,6 @@ def declare_options(self): # The options below have not yet been revamped. - self.declare( - 'analytic', - types=bool, - default=False, - desc='When set to True, this is an analytic phase.', - ) - self.declare( name='normal_ref', default=1.0, diff --git a/aviary/mission/two_dof/phases/simple_cruise_phase.py b/aviary/mission/two_dof/phases/simple_cruise_phase.py index e69de29bb..cd7320608 100644 --- a/aviary/mission/two_dof/phases/simple_cruise_phase.py +++ b/aviary/mission/two_dof/phases/simple_cruise_phase.py @@ -0,0 +1,214 @@ +from aviary.mission.two_dof.ode.simple_cruise_ode import SimpleCruiseODE +from aviary.mission.initial_guess_builders import InitialGuessIntegrationVariable, InitialGuessState +from aviary.mission.phase_builder import PhaseBuilder +from aviary.mission.phase_utils import add_subsystem_variables_to_phase +from aviary.utils.aviary_options_dict import AviaryOptionsDictionary +from aviary.utils.aviary_values import AviaryValues +from aviary.variable_info.variables import Dynamic + + +class SimpleCruisePhaseOptions(AviaryOptionsDictionary): + def declare_options(self): + self.declare( + name='num_segments', + types=int, + default=5, + desc='The number of segments in transcription creation in Dymos. ' + 'While this phase is usually an analytic phase, this option is ' + 'needed if an external subsystem requires a dynamic transcription.', + ) + + self.declare( + name='order', + types=int, + default=3, + desc='The order of polynomials for interpolation in the transcription ' + 'created in Dymos. While this phase is usually an analytic phase, this option is ' + 'needed if an external subsystem requires a dynamic transcription.', + ) + + defaults = { + 'mass_bounds': (0.0, None), + } + self.add_state_options('mass', units='lbm', defaults=defaults) + + self.add_time_options(units='s') + + self.declare(name='alt_cruise', default=0.0, units='ft', desc='Cruise altitude.') + + self.declare(name='mach_cruise', default=0.0, desc='Cruise Mach number.') + + self.declare( + 'reserve', + types=bool, + default=False, + desc='Designate this phase as a reserve phase and contributes its fuel burn ' + 'towards the reserve mission fuel requirements. Reserve phases should be ' + 'be placed after all non-reserve phases in the phase_info.', + ) + + self.declare( + name='target_distance', + default=None, + units='m', + desc='The total distance traveled by the aircraft from takeoff to landing ' + 'for the primary mission, not including reserve missions. This value must ' + 'be positive.', + ) + + self.declare( + 'time_duration', + default=None, + units='s', + desc='The amount of time taken by this phase added as a constraint.', + ) + + self.declare( + name='time_duration_bounds', + default=(0, 3600), + units='s', + desc='Lower and upper bounds on the phase duration, in the form of a nested tuple: ' + 'i.e. ((20, 36), "min") This constrains the duration to be between 20 and 36 min.', + ) + + self.declare( + 'time_initial_bounds', + types=tuple, + default=(0.0, 100.0), + units='s', + desc='Lower and upper bounds on the starting time for this phase relative to the ' + 'starting time of the mission, i.e., ((25, 45), "min") constrians this phase to ' + 'start between 25 and 45 minutes after the start of the mission.', + ) + + +class SimpleCruisePhase(PhaseBuilder): + """ + A phase builder for a cruise phase in a mission simulation. + + This class extends the PhaseBuilder class, providing specific implementations for + the cruise phase of a flight mission. + + Attributes + ---------- + Inherits all attributes from PhaseBuilder. + + Methods + ------- + Inherits all methods from PhaseBuilder. + Additional method overrides and new methods specific to the cruise phase are included. + """ + + default_name = 'cruise_phase' + default_ode_class = SimpleCruiseODE + default_options_class = SimpleCruisePhaseOptions + + _initial_guesses_meta_data_ = {} + + def __init__( + self, + name=None, + subsystem_options=None, + user_options=None, + initial_guesses=None, + ode_class=None, + transcription=None, + subsystems=None, + meta_data=None, + is_analytic_phase=False, + ): + super().__init__( + name=name, + subsystem_options=subsystem_options, + user_options=user_options, + initial_guesses=initial_guesses, + ode_class=ode_class, + transcription=transcription, + subsystems=subsystems, + meta_data=meta_data, + is_analytic_phase=is_analytic_phase, + ) + + def build_phase(self, aviary_options: AviaryValues = None): + """ + Return a new cruise phase for analysis using these constraints. + + If ode_class is None, SimpleCruiseODE is used as the default. + + Parameters + ---------- + aviary_options : AviaryValues + Collection of Aircraft/Mission specific options + + Returns + ------- + dymos.Phase + """ + phase = self.phase = super().build_phase(aviary_options) + + # Custom configurations for the climb phase + user_options = self.user_options + + # Add states + self.add_state( + 'mass', + Dynamic.Vehicle.MASS, + Dynamic.Vehicle.Propulsion.FUEL_FLOW_RATE_NEGATIVE_TOTAL, + ) + + # These are constant. + mach_cruise = user_options.get_val('mach_cruise') + alt_cruise, alt_units = user_options['alt_cruise'] + + add_subsystem_variables_to_phase(phase, self.name, self.subsystems) + + phase.add_parameter(Dynamic.Mission.ALTITUDE, opt=False, val=alt_cruise, units=alt_units) + phase.add_parameter(Dynamic.Atmosphere.MACH, opt=False, val=mach_cruise) + phase.add_parameter('initial_distance', opt=True, val=0.0, units='NM', static_target=True) + + phase.add_timeseries_output(Dynamic.Vehicle.MASS, units='lbm') + phase.add_timeseries_output(Dynamic.Mission.DISTANCE, units='nmi') + phase.add_timeseries_output(Dynamic.Mission.ALTITUDE, units=alt_units) + + phase.add_timeseries_output('EAS', output_name='EAS', units='kn') + phase.add_timeseries_output( + Dynamic.Vehicle.Propulsion.FUEL_FLOW_RATE_NEGATIVE_TOTAL, units='lbm/s' + ) + phase.add_timeseries_output( + Dynamic.Mission.VELOCITY, + output_name=Dynamic.Mission.VELOCITY, + units='kn', + ) + phase.add_timeseries_output( + Dynamic.Vehicle.ANGLE_OF_ATTACK, + output_name=Dynamic.Vehicle.ANGLE_OF_ATTACK, + units='deg', + ) + phase.add_timeseries_output( + Dynamic.Vehicle.Propulsion.THRUST_TOTAL, + output_name=Dynamic.Vehicle.Propulsion.THRUST_TOTAL, + units='lbf', + ) + # TODO: These should be promoted in the 2dof mission outputs. + phase.add_timeseries_output('aerodynamics.CL', output_name='CL', units='unitless') + phase.add_timeseries_output('aerodynamics.CD', output_name='CD', units='unitless') + + return phase + + +SimpleCruisePhase._add_initial_guess_meta_data( + InitialGuessIntegrationVariable(), + desc='initial guess for initial time and duration specified as a tuple', +) + +SimpleCruisePhase._add_initial_guess_meta_data(InitialGuessState('mass'), desc='initial guess for mass') + +SimpleCruisePhase._add_initial_guess_meta_data( + InitialGuessState('initial_distance'), desc='initial guess for initial_distance' +) + +SimpleCruisePhase._add_initial_guess_meta_data( + InitialGuessState('initial_time'), desc='initial guess for initial_time' +) + + diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index b15c20287..2d0032ce4 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -10,11 +10,11 @@ from aviary.mission.two_dof.phases.flight_phase import FlightPhase from aviary.mission.two_dof.phases.groundroll_phase import GroundrollPhase from aviary.mission.two_dof.phases.rotation_phase import RotationPhase +from aviary.mission.two_dof.phases.simple_cruise_phase import SimpleCruisePhase from aviary.mission.two_dof.polynomial_fit import PolynomialFit from aviary.mission.problem_configurator import ProblemConfiguratorBase from aviary.mission.utils import process_guess_var -from aviary.subsystems.propulsion.utils import build_engine_deck -from aviary.utils.process_input_decks import initialization_guessing, update_GASP_options +from aviary.utils.process_input_decks import update_GASP_options from aviary.utils.utils import wrapped_convert_units from aviary.variable_info.enums import LegacyCode, PhaseType from aviary.variable_info.variables import Aircraft, Dynamic, Mission @@ -211,6 +211,17 @@ def get_phase_builder(self, aviary_group, phase_name, phase_options): PhaseBuilder Phase builder for requested phase. """ + # TODO: Move this to aviary_group. + if 'phase_builder' in phase_options['user_options']: + builder = phase_options['user_options']['phase_builder'] + + if builder is PhaseType.BREGUET_RANGE: + phase_builder = CruisePhase + return phase_builder + elif builder is PhaseType.SIMPLE_CRUISE: + phase_builder = SimpleCruisePhase + return phase_builder + if 'groundroll' in phase_name: phase_builder = GroundrollPhase elif 'rotation' in phase_name: @@ -250,8 +261,8 @@ def set_phase_options(self, aviary_group, phase_name, phase_idx, phase, user_opt comm : MPI.Comm or MPI Communicator from OpenMDAO problem. """ - if 'cruise' in phase_name: - # Cruise phase integrates over mass instead of time. + if user_options['phase_builder'] is PhaseType.BREGUET_RANGE: + # The Breguet Range Cruise phase integrates over mass instead of time. # We rely on mass being monotonically non-increasing across the phase. phase.set_time_options( name='mass', @@ -306,6 +317,7 @@ def set_phase_options(self, aviary_group, phase_name, phase_idx, phase, user_opt duration_ref = 0.5 * (duration_bounds[0] + duration_bounds[1]) input_initial = phase_idx > 0 + extra_args = {} if fix_initial or input_initial: if comm.size > 1: @@ -316,32 +328,29 @@ def set_phase_options(self, aviary_group, phase_name, phase_idx, phase, user_opt # Redundant on a fixed input; raises a warning if specified. initial_ref = None - phase.set_time_options( - fix_initial=fix_initial, - fix_duration=fix_duration, - units=time_units, - duration_bounds=duration_bounds, - duration_ref=duration_ref, - initial_ref=initial_ref, - ) + else: + extra_args['initial_bounds'] = initial_bounds - else: # TODO: figure out how to handle this now that fix_initial is dict - phase.set_time_options( - fix_initial=fix_initial, - fix_duration=fix_duration, - units=time_units, - duration_bounds=duration_bounds, - duration_ref=duration_ref, - initial_bounds=initial_bounds, - initial_ref=initial_ref, - ) + if user_options['phase_builder'] is PhaseType.SIMPLE_CRUISE: + extra_args['targets'] = 'time' - phase.add_control( - Dynamic.Vehicle.Propulsion.THROTTLE, - targets=Dynamic.Vehicle.Propulsion.THROTTLE, - units='unitless', - opt=False, - ) + phase.set_time_options( + fix_initial=fix_initial, + fix_duration=fix_duration, + units=time_units, + duration_bounds=duration_bounds, + duration_ref=duration_ref, + initial_ref=initial_ref, + **extra_args, + ) + + if user_options['phase_builder'] is not PhaseType.SIMPLE_CRUISE: + phase.add_control( + Dynamic.Vehicle.Propulsion.THROTTLE, + targets=Dynamic.Vehicle.Propulsion.THROTTLE, + units='unitless', + opt=False, + ) # TODO: This seems like a hack. We might want to find a better way. # The issue is that aero methods are hardcoded for GASP mission phases @@ -375,22 +384,80 @@ def link_phases(self, aviary_group, phases, connect_directly=True): """ for ii in range(len(phases) - 1): phase1, phase2 = phases[ii : ii + 2] - analytic1 = aviary_group.mission_info[phase1]['user_options']['analytic'] - analytic2 = aviary_group.mission_info[phase2]['user_options']['analytic'] + info1 = aviary_group.mission_info[phase1] + info2 = aviary_group.mission_info[phase2] + phase_builder1 = info1['user_options']['phase_builder'] + phase_builder2 = info2['user_options']['phase_builder'] + + integrate_mass1 = phase_builder1 is PhaseType.BREGUET_RANGE + integrate_mass2 = phase_builder2 is PhaseType.BREGUET_RANGE + + if integrate_mass1 or integrate_mass2: + # if either phase integrates mass we have to use a linkage_constraint. + # This phase use the prefix "initial" for time and distance, but not mass. + if integrate_mass2: + prefix = 'initial_' + else: + prefix = '' + + aviary_group.traj.add_linkage_constraint( + phase1, phase2, 'time', prefix + 'time', connected=True + ) + aviary_group.traj.add_linkage_constraint( + phase1, phase2, 'distance', prefix + 'distance', connected=True + ) + aviary_group.traj.add_linkage_constraint( + phase1, phase2, 'mass', 'mass', connected=False, ref=1.0e5 + ) + + # This isn't computed, but is instead set in the cruise phase_info. + # We still need altitude continuity. + # Note: if both sides are Breguet Range, the user is doing something odd like a + # step cruise, so don't enforce a constraint. + if not (integrate_mass1 and integrate_mass2): + aviary_group.traj.add_linkage_constraint( + phase1, phase2, 'altitude', 'altitude', connected=False, ref=1.0e4 + ) + else: + # we always want time, distance, and mass to be continuous. - if not (analytic1 or analytic2): - # we always want time, distance, and mass to be continuous + # Time and mass are always available. states_to_link = { 'time': connect_directly, - Dynamic.Mission.DISTANCE: connect_directly, Dynamic.Vehicle.MASS: False, } + # Distance is a constraint for SIMPLE_CRUISE + if ( + phase_builder1 is PhaseType.SIMPLE_CRUISE or + phase_builder2 is PhaseType.SIMPLE_CRUISE + ): + if phase_builder2 is PhaseType.SIMPLE_CRUISE: + prefix = 'initial_' + else: + prefix = '' + + aviary_group.traj.add_linkage_constraint( + phase1, phase2, 'distance', prefix + 'distance', connected=False + ) + else: + # Add distance to the linked states. + states_to_link[Dynamic.Mission.DISTANCE] = connect_directly + + # Alititude is more complicated. SIMPLE_CRUISE should be a constraint. # if both phases are reserve phases or neither is a reserve phase # (we are not on the boundary between the regular and reserve missions) # and neither phase is ground roll or rotation (altitude isn't a state): # we want altitude to be continuous as well if ( + phase_builder1 is PhaseType.SIMPLE_CRUISE or + phase_builder2 is PhaseType.SIMPLE_CRUISE + ): + aviary_group.traj.add_linkage_constraint( + phase1, phase2, 'altitude', 'altitude', connected=False, ref=1.0e4 + ) + + elif ( ( (phase1 in aviary_group.reserve_phases) == (phase2 in aviary_group.reserve_phases) @@ -411,8 +478,8 @@ def link_phases(self, aviary_group, phases, connect_directly=True): for state, connected in states_to_link.items(): # in initial guesses, all of the states, other than time use # the same name - initial_guesses1 = aviary_group.mission_info[phase1]['initial_guesses'] - initial_guesses2 = aviary_group.mission_info[phase2]['initial_guesses'] + initial_guesses1 = info1['initial_guesses'] + initial_guesses2 = info2['initial_guesses'] # if a state is in the initial guesses, get the units of the # initial guess @@ -439,34 +506,6 @@ def link_phases(self, aviary_group, phases, connect_directly=True): [phase1, phase2], [state], connected=connected, **kwargs ) - # if either phase is analytic we have to use a linkage_constraint - else: - # analytic phases use the prefix "initial" for time and distance, - # but not mass - if analytic2: - prefix = 'initial_' - else: - prefix = '' - - aviary_group.traj.add_linkage_constraint( - phase1, phase2, 'time', prefix + 'time', connected=True - ) - aviary_group.traj.add_linkage_constraint( - phase1, phase2, 'distance', prefix + 'distance', connected=True - ) - aviary_group.traj.add_linkage_constraint( - phase1, phase2, 'mass', 'mass', connected=False, ref=1.0e5 - ) - - # This isn't computed, but is instead set in the cruise phase_info. - # We still need altitude continuity. - # Note: if both sides are Breguet Range, the user is doing something odd like a - # step cruise, so don't enforce a constraint. - if not (analytic1 and analytic2): - aviary_group.traj.add_linkage_constraint( - phase1, phase2, 'altitude', 'altitude', connected=False, ref=1.0e4 - ) - # add all params and promote them to aviary_group level ParamPort.promote_params( aviary_group, @@ -521,7 +560,7 @@ def link_phases(self, aviary_group, phases, connect_directly=True): src_indices=[-1], ) - # promote all ParamPort inputs for analytic segments as well + # promote all ParamPort inputs param_list = list(ParamPort.param_data) aviary_group.promotes('taxi', inputs=param_list) aviary_group.promotes('landing', inputs=param_list) @@ -655,13 +694,14 @@ def set_phase_initial_guesses( parent_prefix : str Location of this trajectory in the hierarchy. """ - # Handle Analytic Phase - if aviary_group.mission_info[phase_name]['user_options'].get('analytic', False): + # Breguet cruise integrates mass, so initial guesses are different. + phase_type = aviary_group.mission_info[phase_name]['user_options']['phase_builder'] + if phase_type is PhaseType.BREGUET_RANGE: for guess_key, guess_data in guesses.items(): val, units = guess_data if 'mass' == guess_key: - # Set initial and duration mass for the analytic cruise phase. + # Set initial and duration mass for the Breguet cruise phase. # Note we are integrating over mass, not time for this phase. target_prob.set_val( parent_prefix + f'traj.{phase_name}.t_initial', val[0], units=units @@ -682,7 +722,7 @@ def set_phase_initial_guesses( units=units, ) - # Analytic phase should have nothing else to set. + # Breguet phase should have nothing else to set. return # Set initial guesses for the rotation mass and flight duration @@ -812,7 +852,7 @@ def set_phase_initial_guesses( parent_prefix + f'traj.{phase_name}.t_duration', t_duration, units='s' ) - if 'distance' not in guesses: + if 'distance' not in guesses and phase_type is not PhaseType.SIMPLE_CRUISE: # Determine initial distance guesses depending on the phase name if 'desc1' == base_phase: ys = [aviary_group.target_range * 0.97, aviary_group.target_range * 0.99] diff --git a/aviary/models/missions/two_dof_default.py b/aviary/models/missions/two_dof_default.py index 72e13da7a..6ccc09454 100644 --- a/aviary/models/missions/two_dof_default.py +++ b/aviary/models/missions/two_dof_default.py @@ -187,9 +187,7 @@ # [Initial mass, delta mass] for special cruise phase. 'mass': ([171481.0, -35000], 'lbm'), 'initial_distance': (200.0e3, 'ft'), - 'initial_time': (1516.0, 's'), - 'altitude': (37.5e3, 'ft'), - 'mach': (0.8, 'unitless'), + 'time': ([1516.0, 26500.0], 's'), }, }, 'desc1': { diff --git a/aviary/utils/aviary_options_dict.py b/aviary/utils/aviary_options_dict.py index 1b5f8e8c4..b0e61f65b 100644 --- a/aviary/utils/aviary_options_dict.py +++ b/aviary/utils/aviary_options_dict.py @@ -2,6 +2,7 @@ from openmdao.core.constants import _UNDEFINED from aviary.utils.utils import wrapped_convert_units +from aviary.variable_info.enums import PhaseType def units_setter(opt_meta, value): @@ -47,6 +48,18 @@ class AviaryOptionsDictionary(om.OptionsDictionary): def __init__(self, data=None, parent_name=None): super().__init__(parent_name) + # This one needs to be in all phase_builder dictionaries. + self.declare( + name='phase_builder', + types=PhaseType, + default=PhaseType.DEFAULT, + desc='The phase builder to use for this phase. This is an experimental feature that ' + 'currently allows a limited ability to select the equations of motion in certain ' + 'cases. \n' + 'Currently, if you have a steady cruise phase in a two-dof mission, you can select ' + '"BREGUET_RANGE" or "SIMPLE_CRUISE".', + ) + self.declare_options() if data is None: From cee1bec5fb97adcc4139cd6a0ea81bdbff34691c Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Fri, 6 Feb 2026 15:05:58 -0500 Subject: [PATCH 07/23] Added unit tests --- .../custom_aero/custom_aero_builder.py | 2 +- .../mission/two_dof/ode/simple_cruise_eom.py | 2 +- .../ode/test/test_simple_cruise_eom.py | 73 +++++++++++++++++++ ...ruise_phase.py => breguet_cruise_phase.py} | 2 +- .../two_dof/phases/simple_cruise_phase.py | 2 +- .../mission/two_dof_problem_configurator.py | 2 +- aviary/models/missions/two_dof_default.py | 11 ++- .../aerodynamics/aerodynamics_builder.py | 2 +- aviary/subsystems/subsystem_builder.py | 8 +- 9 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 aviary/mission/two_dof/ode/test/test_simple_cruise_eom.py rename aviary/mission/two_dof/phases/{cruise_phase.py => breguet_cruise_phase.py} (99%) diff --git a/aviary/examples/external_subsystems/custom_aero/custom_aero_builder.py b/aviary/examples/external_subsystems/custom_aero/custom_aero_builder.py index f0e087d3d..b10f412da 100644 --- a/aviary/examples/external_subsystems/custom_aero/custom_aero_builder.py +++ b/aviary/examples/external_subsystems/custom_aero/custom_aero_builder.py @@ -59,7 +59,7 @@ def get_parameters(self, aviary_inputs=None, phase_info=None): 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. + Used in the phase builders 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. diff --git a/aviary/mission/two_dof/ode/simple_cruise_eom.py b/aviary/mission/two_dof/ode/simple_cruise_eom.py index 016301017..25df6927c 100644 --- a/aviary/mission/two_dof/ode/simple_cruise_eom.py +++ b/aviary/mission/two_dof/ode/simple_cruise_eom.py @@ -5,7 +5,7 @@ class DistanceComp(om.ExplicitComponent): - """Computes range for a simple cruise phase.""" + """Computes distance for a simple cruise phase.""" def initialize(self): self.options.declare('num_nodes', types=int) diff --git a/aviary/mission/two_dof/ode/test/test_simple_cruise_eom.py b/aviary/mission/two_dof/ode/test/test_simple_cruise_eom.py new file mode 100644 index 000000000..60013d2b8 --- /dev/null +++ b/aviary/mission/two_dof/ode/test/test_simple_cruise_eom.py @@ -0,0 +1,73 @@ +import unittest + +import numpy as np +import openmdao.api as om +from openmdao.utils.assert_utils import assert_check_partials, assert_near_equal + +from aviary.constants import GRAV_ENGLISH_LBM +from aviary.mission.two_dof.ode.simple_cruise_eom import DistanceComp +from aviary.variable_info.variables import Dynamic + + +class TestSimpleCruiseResults(unittest.TestCase): + """Test cruise with DistanceComp component.""" + + def setUp(self): + nn = 10 + + self.prob = om.Problem() + self.prob.model.add_subsystem('range_comp', DistanceComp(num_nodes=nn), promotes=['*']) + + self.prob.setup(check=False, force_alloc_complex=True) + + self.prob.set_val('TAS_cruise', 458.8, units='kn') + self.prob.set_val('cruise_distance_initial', 0.0, units='NM') + self.prob.set_val('time', np.arange(nn) * 6134.72 / 9, units='s') + + def test_case1(self): + tol = 1e-6 + self.prob.run_model() + + distance = self.prob.get_val(Dynamic.Mission.DISTANCE, units='NM') + + r_expected = 781.838598222 + + assert_near_equal(distance[-1, ...], r_expected, tolerance=0.001) + + +class TestDistanceCompPartials(unittest.TestCase): + def setUp(self): + nn = 10 + + self.prob = om.Problem() + self.prob.model.add_subsystem('range_comp', DistanceComp(num_nodes=nn), promotes=['*']) + + self.prob.model.set_input_defaults( + 'TAS_cruise', + 458.8 + + 50 + * np.random.rand( + nn, + ), + units='kn', + ) + self.prob.setup(check=False, force_alloc_complex=True) + + self.prob.set_val('cruise_distance_initial', 1.0, units='NM') + self.prob.set_val('time', np.arange(nn) * 6134.72 / 9, units='s') + + def test_partials(self): + tol = 1e-10 + self.prob.run_model() + + with np.printoptions(linewidth=1024): + self.prob.model.list_outputs(prom_name=True, print_arrays=True) + partial_data = self.prob.check_partials(method='cs', out_stream=None) + assert_check_partials(partial_data, atol=tol, rtol=tol) + + +if __name__ == '__main__': + # unittest.main() + test = TestSimpleCruiseResults() + test.setUp() + test.test_case1() diff --git a/aviary/mission/two_dof/phases/cruise_phase.py b/aviary/mission/two_dof/phases/breguet_cruise_phase.py similarity index 99% rename from aviary/mission/two_dof/phases/cruise_phase.py rename to aviary/mission/two_dof/phases/breguet_cruise_phase.py index 7d245ad26..37f0b8289 100644 --- a/aviary/mission/two_dof/phases/cruise_phase.py +++ b/aviary/mission/two_dof/phases/breguet_cruise_phase.py @@ -95,7 +95,7 @@ class CruisePhase(PhaseBuilder): Additional method overrides and new methods specific to the cruise phase are included. """ - default_name = 'cruise_phase' + default_name = 'breguet_cruise_phase' default_ode_class = BreguetCruiseODE default_options_class = BreguetCruisePhaseOptions diff --git a/aviary/mission/two_dof/phases/simple_cruise_phase.py b/aviary/mission/two_dof/phases/simple_cruise_phase.py index cd7320608..e9935e3aa 100644 --- a/aviary/mission/two_dof/phases/simple_cruise_phase.py +++ b/aviary/mission/two_dof/phases/simple_cruise_phase.py @@ -99,7 +99,7 @@ class SimpleCruisePhase(PhaseBuilder): Additional method overrides and new methods specific to the cruise phase are included. """ - default_name = 'cruise_phase' + default_name = 'simple_cruise_phase' default_ode_class = SimpleCruiseODE default_options_class = SimpleCruisePhaseOptions diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index 2d0032ce4..a3d45da20 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -6,7 +6,7 @@ from aviary.mission.two_dof.ode.taxi_ode import TaxiSegment from aviary.mission.two_dof.phases.accel_phase import AccelPhase from aviary.mission.two_dof.phases.ascent_phase import AscentPhase -from aviary.mission.two_dof.phases.cruise_phase import CruisePhase, ElectricCruisePhase +from aviary.mission.two_dof.phases.breguet_cruise_phase import CruisePhase, ElectricCruisePhase from aviary.mission.two_dof.phases.flight_phase import FlightPhase from aviary.mission.two_dof.phases.groundroll_phase import GroundrollPhase from aviary.mission.two_dof.phases.rotation_phase import RotationPhase diff --git a/aviary/models/missions/two_dof_default.py b/aviary/models/missions/two_dof_default.py index 6ccc09454..f6d231f79 100644 --- a/aviary/models/missions/two_dof_default.py +++ b/aviary/models/missions/two_dof_default.py @@ -1,4 +1,4 @@ -from aviary.variable_info.enums import SpeedType +from aviary.variable_info.enums import PhaseType, SpeedType from aviary.variable_info.variables import Mission # defaults for 2DOF based phases @@ -180,13 +180,16 @@ 'cruise': { 'subsystem_options': {'aerodynamics': {'method': 'cruise'}}, 'user_options': { + 'phase_builder': PhaseType.SIMPLE_CRUISE, 'alt_cruise': (37.5e3, 'ft'), 'mach_cruise': 0.8, + 'mass_bounds': ((0, None), 'lbm'), + 'mass_ref': (150_000, 'lbm'), + 'time_duration_bounds': ((0.0, 8.0), 'h'), + 'time_duration_ref': (8, 'h'), }, 'initial_guesses': { - # [Initial mass, delta mass] for special cruise phase. - 'mass': ([171481.0, -35000], 'lbm'), - 'initial_distance': (200.0e3, 'ft'), + 'mass': ([171481.0, 135000], 'lbm'), 'time': ([1516.0, 26500.0], 's'), }, }, diff --git a/aviary/subsystems/aerodynamics/aerodynamics_builder.py b/aviary/subsystems/aerodynamics/aerodynamics_builder.py index e5167ffc9..59df89590 100644 --- a/aviary/subsystems/aerodynamics/aerodynamics_builder.py +++ b/aviary/subsystems/aerodynamics/aerodynamics_builder.py @@ -383,7 +383,7 @@ def get_parameters(self, aviary_inputs=None, **kwargs): Optional, used if subsystems have fixed values. - Used in the phase builders (e.g. cruise_phase.py) when other parameters are + Used in the phase builders (e.g. breguet_cruise_phase.py) when other parameters are added to the phase. This is distinct from `get_design_vars` in a nuanced way. Design variables diff --git a/aviary/subsystems/subsystem_builder.py b/aviary/subsystems/subsystem_builder.py index 2469ae70d..0068c0790 100644 --- a/aviary/subsystems/subsystem_builder.py +++ b/aviary/subsystems/subsystem_builder.py @@ -66,7 +66,7 @@ def get_states(self): derivative of each state. The recommended convention is to name the output f"{state_name}_rate", where `state_name` is the name of the state variable. - Use in the phase builders (e.g. cruise_phase.py) when other states are added to the phase. + Use in the phase builders when other states are added to the phase. Returns ------- @@ -95,7 +95,7 @@ def get_controls(self, phase_name=None): Notes ----- This method is optional, used if subsystems have control variables. - Used in the phase builders (e.g. cruise_phase.py) when other controls are added to the phase. + Used in the phase builders when other controls are added to the phase. Returns ------- @@ -115,7 +115,7 @@ def get_parameters(self, aviary_inputs=None, **kwargs): Optional, used if subsystems have fixed values. - Used in the phase builders (e.g. cruise_phase.py) when other parameters are added to the + Used in the phase builders when other parameters are added to the phase. This is distinct from `get_design_vars` in a nuanced way. Design variables are variables @@ -149,7 +149,7 @@ def get_constraints(self): Optional, used if subsystems have path or boundary constraints. - Used in the phase builders (e.g. cruise_phase.py) when other constraints are added to the + Used in the phase builders when other constraints are added to the phase. Returns From 4ec81f7b2b2eb03109722147c51464eb2f5eae3e Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Fri, 6 Feb 2026 16:38:59 -0500 Subject: [PATCH 08/23] Down to a couple failures --- aviary/interface/test/test_phase_info.py | 3 +-- .../test_external_subsystems_in_mission.py | 4 ++-- .../two_dof/phases/simple_cruise_phase.py | 1 + .../mission/two_dof_problem_configurator.py | 4 +--- .../generic_BWB_2dof_phase_info.py | 15 +++++++------- .../large_turboprop_freighter/phase_info.py | 15 +++++++------- aviary/models/missions/two_dof_default.py | 8 ++++---- .../benchmark_tests/test_bench_GwGm.py | 20 +++++++++---------- .../benchmark_tests/test_bench_off_design.py | 12 +++++------ 9 files changed, 40 insertions(+), 42 deletions(-) diff --git a/aviary/interface/test/test_phase_info.py b/aviary/interface/test/test_phase_info.py index 2a9ea3b40..baaecae1a 100644 --- a/aviary/interface/test/test_phase_info.py +++ b/aviary/interface/test/test_phase_info.py @@ -21,7 +21,6 @@ ) from aviary.interface.methods_for_level2 import AviaryProblem from aviary.mission.phase_builder import PhaseBuilder as PhaseBuilder -from aviary.mission.phase_builder import phase_info_to_builder from aviary.variable_info.variables import Mission @@ -63,7 +62,7 @@ def test_phase_info_parameterization_two_dof(self): assert_near_equal( prob.get_val('traj.groundroll.timeseries.input_values:mass', units='lbm')[0], 120000.0 ) - assert_near_equal(prob.get_val('traj.cruise.rhs.mach')[0], 0.6) + assert_near_equal(prob.get_val('traj.cruise.timeseries.mach')[0], 0.6) def test_phase_info_parameterization_height_energy(self): phase_info = deepcopy(ph_in_height_energy) diff --git a/aviary/mission/test/test_external_subsystems_in_mission.py b/aviary/mission/test/test_external_subsystems_in_mission.py index b612ffcd1..d4aa1252a 100644 --- a/aviary/mission/test/test_external_subsystems_in_mission.py +++ b/aviary/mission/test/test_external_subsystems_in_mission.py @@ -109,7 +109,7 @@ def test_mission_solver_2DOF(self): # NOTE currently 2DOF ODEs do not use the solver subsystem self.assertTrue( hasattr( - prob.model.traj.phases.cruise.rhs, + prob.model.traj.phases.cruise.rhs_all, 'solve_me', ) ) @@ -135,7 +135,7 @@ def test_no_mission_solver_2DOF(self): self.assertTrue( hasattr( - prob.model.traj.phases.cruise.rhs, + prob.model.traj.phases.cruise.rhs_all, 'do_not_solve_me', ) ) diff --git a/aviary/mission/two_dof/phases/simple_cruise_phase.py b/aviary/mission/two_dof/phases/simple_cruise_phase.py index e9935e3aa..aec7e929a 100644 --- a/aviary/mission/two_dof/phases/simple_cruise_phase.py +++ b/aviary/mission/two_dof/phases/simple_cruise_phase.py @@ -169,6 +169,7 @@ def build_phase(self, aviary_options: AviaryValues = None): phase.add_timeseries_output(Dynamic.Vehicle.MASS, units='lbm') phase.add_timeseries_output(Dynamic.Mission.DISTANCE, units='nmi') phase.add_timeseries_output(Dynamic.Mission.ALTITUDE, units=alt_units) + phase.add_timeseries_output(Dynamic.Atmosphere.MACH, units='unitless') phase.add_timeseries_output('EAS', output_name='EAS', units='kn') phase.add_timeseries_output( diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index a3d45da20..c87bbe9a1 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -211,7 +211,7 @@ def get_phase_builder(self, aviary_group, phase_name, phase_options): PhaseBuilder Phase builder for requested phase. """ - # TODO: Move this to aviary_group. + # TODO: Move this to aviary_group and let other EOM use them. if 'phase_builder' in phase_options['user_options']: builder = phase_options['user_options']['phase_builder'] @@ -232,8 +232,6 @@ def get_phase_builder(self, aviary_group, phase_name, phase_options): phase_builder = AscentPhase elif 'electric_cruise' in phase_name: phase_builder = ElectricCruisePhase - elif 'cruise' in phase_name: - phase_builder = CruisePhase else: # All other phases are flight phases. phase_builder = FlightPhase diff --git a/aviary/models/aircraft/blended_wing_body/generic_BWB_2dof_phase_info.py b/aviary/models/aircraft/blended_wing_body/generic_BWB_2dof_phase_info.py index d709e0cc0..4e7586da7 100644 --- a/aviary/models/aircraft/blended_wing_body/generic_BWB_2dof_phase_info.py +++ b/aviary/models/aircraft/blended_wing_body/generic_BWB_2dof_phase_info.py @@ -1,5 +1,4 @@ -from aviary.variable_info.enums import SpeedType - +from aviary.variable_info.enums import PhaseType, SpeedType # 2DOF phase_info = { @@ -174,16 +173,16 @@ 'cruise': { 'subsystem_options': {'core_aerodynamics': {'method': 'cruise'}}, 'user_options': { + 'phase_builder': PhaseType.SIMPLE_CRUISE, 'alt_cruise': (41_000, 'ft'), 'mach_cruise': 0.8, + 'mass_ref': (150_000, 'lbm'), + 'time_duration_bounds': ((0.0, 8.0), 'h'), + 'time_duration_ref': (8, 'h'), }, 'initial_guesses': { - # [Initial mass, delta mass] for special cruise phase. - 'mass': ([140_000.0, -35_000], 'lbm'), - 'initial_distance': (100.0e3, 'ft'), - 'initial_time': (1_000.0, 's'), - 'altitude': (41_000, 'ft'), - 'mach': (0.8, 'unitless'), + 'mass': ([140_000.0, 105000], 'lbm'), + 'time': ([1000.0, 28000.0], 's'), }, }, 'desc1': { diff --git a/aviary/models/aircraft/large_turboprop_freighter/phase_info.py b/aviary/models/aircraft/large_turboprop_freighter/phase_info.py index 5f9d3ba51..b8a513824 100644 --- a/aviary/models/aircraft/large_turboprop_freighter/phase_info.py +++ b/aviary/models/aircraft/large_turboprop_freighter/phase_info.py @@ -1,4 +1,4 @@ -from aviary.variable_info.enums import SpeedType +from aviary.variable_info.enums import PhaseType, SpeedType # Energy method energy_phase_info = { @@ -245,16 +245,17 @@ }, 'cruise': { 'user_options': { + 'phase_builder': PhaseType.SIMPLE_CRUISE, 'alt_cruise': (21_000, 'ft'), 'mach_cruise': 0.475, + 'mass_bounds': ((0, None), 'lbm'), + 'mass_ref': (150_000, 'lbm'), + 'time_duration_bounds': ((0.0, 8.0), 'h'), + 'time_duration_ref': (8, 'h'), }, 'initial_guesses': { - # [Initial mass, delta mass] for special cruise phase. - 'mass': ([150_000.0, -35_000], 'lbm'), - 'initial_distance': (100.0e3, 'ft'), - 'initial_time': (1_000.0, 's'), - 'altitude': (21_000, 'ft'), - 'mach': (0.475, 'unitless'), + 'mass': ([150_000.0, 115000], 'lbm'), + 'time': ([1516.0, 2100.0], 's'), }, }, 'desc1': { diff --git a/aviary/models/missions/two_dof_default.py b/aviary/models/missions/two_dof_default.py index f6d231f79..2660105c1 100644 --- a/aviary/models/missions/two_dof_default.py +++ b/aviary/models/missions/two_dof_default.py @@ -296,11 +296,11 @@ def phase_info_parameterization(phase_info, post_mission_info, aviary_inputs): range_scale = range_cruise / old_range_cruise # Altitude - old_alt_cruise = phase_info['climb2']['user_options']['altitude_final'][0] + old_alt_cruise = phase_info['cruise']['user_options']['alt_cruise'][0] if alt_cruise != old_alt_cruise: phase_info['climb2']['user_options']['altitude_final'] = (alt_cruise, 'ft') phase_info['climb2']['initial_guesses']['altitude'] = ([10.0e3, alt_cruise], 'ft') - phase_info['cruise']['initial_guesses']['altitude'] = (alt_cruise, 'ft') + phase_info['cruise']['user_options']['alt_cruise'] = (alt_cruise, 'ft') phase_info['desc1']['initial_guesses']['altitude'] = ([alt_cruise, 10.0e3], 'ft') # TODO - Could adjust time guesses/bounds in climb2 and desc2. @@ -324,8 +324,8 @@ def phase_info_parameterization(phase_info, post_mission_info, aviary_inputs): phase_info['desc2']['initial_guesses']['mass'] = (end_mass, 'lbm') # Mach - old_mach_cruise = phase_info['cruise']['initial_guesses']['mach'][0] + old_mach_cruise = phase_info['cruise']['user_options']['mach_cruise'] if mach_cruise != old_mach_cruise: - phase_info['cruise']['initial_guesses']['mach'] = (mach_cruise, 'unitless') + phase_info['cruise']['user_options']['mach_cruise'] = (mach_cruise, 'unitless') return phase_info, post_mission_info diff --git a/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py b/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py index 206aa067b..b76092d1c 100644 --- a/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py +++ b/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py @@ -38,25 +38,25 @@ def test_bench_GwGm_IPOPT(self): # There are no truth values for these. assert_near_equal( prob.get_val(Mission.Design.GROSS_MASS, units='lbm'), - 171646.48312684, + 171595.06049335, tolerance=rtol, ) assert_near_equal( prob.get_val(Mission.Summary.OPERATING_MASS, units='lbm'), - 95100.07583783, + 95089.98897716, tolerance=rtol, ) assert_near_equal( prob.get_val(Mission.Summary.TOTAL_FUEL_MASS, units='lbm'), - 40546.40728901, + 40505.07151619, tolerance=rtol, ) assert_near_equal( prob.get_val(Mission.Landing.GROUND_DISTANCE, units='ft'), - 2655.5028412, + 2657.88663983, tolerance=rtol, ) @@ -64,7 +64,7 @@ def test_bench_GwGm_IPOPT(self): assert_near_equal( prob.get_val(Mission.Landing.TOUCHDOWN_MASS, units='lbm'), - 136098.07583783, + 136087.98897716, tolerance=rtol, ) @@ -85,25 +85,25 @@ def test_bench_GwGm_SNOPT(self): # There are no truth values for these. assert_near_equal( prob.get_val(Mission.Design.GROSS_MASS, units='lbm'), - 171646.48312684, + 171595.06049335, tolerance=rtol, ) assert_near_equal( prob.get_val(Mission.Summary.OPERATING_MASS, units='lbm'), - 95100.07583783, + 95089.98897716, tolerance=rtol, ) assert_near_equal( prob.get_val(Mission.Summary.TOTAL_FUEL_MASS, units='lbm'), - 40546.40728901, + 40505.07151619, tolerance=rtol, ) assert_near_equal( prob.get_val(Mission.Landing.GROUND_DISTANCE, units='ft'), - 2655.5028412, + 2657.88663983, tolerance=rtol, ) @@ -111,7 +111,7 @@ def test_bench_GwGm_SNOPT(self): assert_near_equal( prob.get_val(Mission.Landing.TOUCHDOWN_MASS, units='lbm'), - 136098.07583783, + 136087.98897716, tolerance=rtol, ) diff --git a/aviary/validation_cases/benchmark_tests/test_bench_off_design.py b/aviary/validation_cases/benchmark_tests/test_bench_off_design.py index 678019c8e..554d58015 100644 --- a/aviary/validation_cases/benchmark_tests/test_bench_off_design.py +++ b/aviary/validation_cases/benchmark_tests/test_bench_off_design.py @@ -7,7 +7,7 @@ from aviary.interface.methods_for_level2 import AviaryProblem from aviary.models.missions.height_energy_default import phase_info as energy_phase_info from aviary.models.missions.two_dof_default import phase_info as twodof_phase_info -from aviary.variable_info.variables import Aircraft, Mission, Settings +from aviary.variable_info.variables import Aircraft, Mission @use_tempdirs @@ -432,17 +432,17 @@ def test_alternate_mission_changed(self): assert_near_equal(prob_alternate.get_val(Mission.Summary.RANGE), 1800, tolerance=1e-6) assert_near_equal( prob_alternate.get_val(Mission.Summary.FUEL_MASS, 'lbm'), - 40522.37757121, + 40505.07149946, tolerance=1e-6, ) assert_near_equal( prob_alternate.get_val(Mission.Summary.TOTAL_FUEL_MASS, 'lbm'), - 21487.24163836, + 21484.2904701, tolerance=1e-6, ) assert_near_equal( prob_alternate.get_val(Mission.Summary.OPERATING_MASS, 'lbm'), - 95095.50665928, + 95089.98897155, tolerance=1e-6, ) assert_near_equal( @@ -473,7 +473,7 @@ def test_alternate_mission_changed(self): ) assert_near_equal( prob_alternate.get_val(Mission.Summary.GROSS_MASS, 'lbm'), - 148682.74829764, + 148674.27944164, tolerance=1e-6, ) assert_near_equal( @@ -558,7 +558,7 @@ def test_payload_range(self): # unittest.main() test = Test2DOFOffDesign() test.setUp() - test.test_alternate_mission_changed() + test.test_fallout_mission_changed() # test = PayloadRangeTest() # test.test_payload_range() From eaaa9567617582b93307bce21d57e885b9e3b377 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Fri, 6 Feb 2026 17:22:58 -0500 Subject: [PATCH 09/23] experimental revamp on scaling --- .../mission/two_dof_problem_configurator.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index c87bbe9a1..aa1f29070 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -483,22 +483,21 @@ def link_phases(self, aviary_group, phases, connect_directly=True): # initial guess kwargs = {} if not connected: - if state in initial_guesses1: - kwargs = {'units': initial_guesses1[state][-1]} - elif state in initial_guesses2: - kwargs = {'units': initial_guesses2[state][-1]} - # Some better scaling of the linkage constraint. + kwargs = {} if state in initial_guesses1: - val = initial_guesses1[state][0] + val, units = initial_guesses1[state] if isinstance(val, (tuple, list)): - val = val[-1] - kwargs['ref'] = abs(val) + val = abs(val[-1]) elif state in initial_guesses2: - val = initial_guesses2[state][0] + val, units = initial_guesses2[state] if isinstance(val, (tuple, list)): - val = val[0] - kwargs['ref'] = abs(val) + val = abs(val[0]) + else: + val, units = info1['user_options'][f'{state}_ref'] + + kwargs['ref'] = val + kwargs['units'] = units aviary_group.traj.link_phases( [phase1, phase2], [state], connected=connected, **kwargs From c6cffe2166cc7bb41dcf960e2101eef81af593e0 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Fri, 6 Feb 2026 18:06:38 -0500 Subject: [PATCH 10/23] playing with refs --- aviary/mission/two_dof/phases/simple_cruise_phase.py | 9 ++++++++- aviary/mission/two_dof_problem_configurator.py | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/aviary/mission/two_dof/phases/simple_cruise_phase.py b/aviary/mission/two_dof/phases/simple_cruise_phase.py index aec7e929a..dac0df612 100644 --- a/aviary/mission/two_dof/phases/simple_cruise_phase.py +++ b/aviary/mission/two_dof/phases/simple_cruise_phase.py @@ -164,7 +164,14 @@ def build_phase(self, aviary_options: AviaryValues = None): phase.add_parameter(Dynamic.Mission.ALTITUDE, opt=False, val=alt_cruise, units=alt_units) phase.add_parameter(Dynamic.Atmosphere.MACH, opt=False, val=mach_cruise) - phase.add_parameter('initial_distance', opt=True, val=0.0, units='NM', static_target=True) + phase.add_parameter( + 'initial_distance', + opt=True, + val=0.0, + units='nmi', + #ref=100.0, + static_target=True, + ) phase.add_timeseries_output(Dynamic.Vehicle.MASS, units='lbm') phase.add_timeseries_output(Dynamic.Mission.DISTANCE, units='nmi') diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index aa1f29070..200f87959 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -436,7 +436,12 @@ def link_phases(self, aviary_group, phases, connect_directly=True): prefix = '' aviary_group.traj.add_linkage_constraint( - phase1, phase2, 'distance', prefix + 'distance', connected=False + phase1, + phase2, + 'distance', + prefix + 'distance', + connected=False, + ref=100.0, ) else: # Add distance to the linked states. From 47d24e7a943c392d26835ab6ce5920578def1d7e Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Fri, 6 Feb 2026 18:17:42 -0500 Subject: [PATCH 11/23] My old nemesis: upper bound on cruise phase. --- .../aircraft/blended_wing_body/generic_BWB_2dof_phase_info.py | 2 +- aviary/models/aircraft/large_turboprop_freighter/phase_info.py | 2 +- aviary/models/missions/two_dof_default.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aviary/models/aircraft/blended_wing_body/generic_BWB_2dof_phase_info.py b/aviary/models/aircraft/blended_wing_body/generic_BWB_2dof_phase_info.py index 4e7586da7..e1ef9a191 100644 --- a/aviary/models/aircraft/blended_wing_body/generic_BWB_2dof_phase_info.py +++ b/aviary/models/aircraft/blended_wing_body/generic_BWB_2dof_phase_info.py @@ -177,7 +177,7 @@ 'alt_cruise': (41_000, 'ft'), 'mach_cruise': 0.8, 'mass_ref': (150_000, 'lbm'), - 'time_duration_bounds': ((0.0, 8.0), 'h'), + 'time_duration_bounds': ((0.0, 15.0), 'h'), 'time_duration_ref': (8, 'h'), }, 'initial_guesses': { diff --git a/aviary/models/aircraft/large_turboprop_freighter/phase_info.py b/aviary/models/aircraft/large_turboprop_freighter/phase_info.py index b8a513824..9be4677d1 100644 --- a/aviary/models/aircraft/large_turboprop_freighter/phase_info.py +++ b/aviary/models/aircraft/large_turboprop_freighter/phase_info.py @@ -250,7 +250,7 @@ 'mach_cruise': 0.475, 'mass_bounds': ((0, None), 'lbm'), 'mass_ref': (150_000, 'lbm'), - 'time_duration_bounds': ((0.0, 8.0), 'h'), + 'time_duration_bounds': ((0.0, 15.0), 'h'), 'time_duration_ref': (8, 'h'), }, 'initial_guesses': { diff --git a/aviary/models/missions/two_dof_default.py b/aviary/models/missions/two_dof_default.py index 2660105c1..19a1c9ac7 100644 --- a/aviary/models/missions/two_dof_default.py +++ b/aviary/models/missions/two_dof_default.py @@ -185,7 +185,7 @@ 'mach_cruise': 0.8, 'mass_bounds': ((0, None), 'lbm'), 'mass_ref': (150_000, 'lbm'), - 'time_duration_bounds': ((0.0, 8.0), 'h'), + 'time_duration_bounds': ((0.0, 15.0), 'h'), 'time_duration_ref': (8, 'h'), }, 'initial_guesses': { From 3467ed5b576236674cc4995c16a08d853098fe95 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Mon, 9 Feb 2026 09:33:43 -0500 Subject: [PATCH 12/23] One more test passes --- .../benchmark_tests/test_battery_in_a_mission.py | 4 ++-- .../benchmark_tests/test_bench_off_design.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py b/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py index 9527cd6c3..45f827149 100644 --- a/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py +++ b/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py @@ -203,6 +203,6 @@ def test_subsystems_in_a_mission_2dof(self): if __name__ == '__main__': - unittest.main() - test = TestSubsystemsMission() + # unittest.main() + test = TestBatteryMission() test.test_subsystems_in_a_mission_2dof() diff --git a/aviary/validation_cases/benchmark_tests/test_bench_off_design.py b/aviary/validation_cases/benchmark_tests/test_bench_off_design.py index 554d58015..547d5c5cf 100644 --- a/aviary/validation_cases/benchmark_tests/test_bench_off_design.py +++ b/aviary/validation_cases/benchmark_tests/test_bench_off_design.py @@ -350,21 +350,21 @@ def test_fallout_mission_changed(self): tolerance=1e-12, ) assert_near_equal( - prob_fallout.get_val(Mission.Summary.RANGE), 3991.32364531, tolerance=1e-4 + prob_fallout.get_val(Mission.Summary.RANGE), 3994.25223046, tolerance=1e-4 ) assert_near_equal( prob_fallout.get_val(Mission.Summary.FUEL_MASS, 'lbm'), - 40522.37757121, + 40505.09154366, tolerance=1e-6, ) assert_near_equal( prob_fallout.get_val(Mission.Summary.TOTAL_FUEL_MASS, 'lbm'), - 39904.49334072, + 39910.0046378, tolerance=1e-6, ) assert_near_equal( prob_fallout.get_val(Mission.Summary.OPERATING_MASS, 'lbm'), - 95095.50665928, + 95089.9953622, tolerance=1e-6, ) assert_near_equal( From e9a198bd65605f1f7015d2f3b6f35a692c0f54e8 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Tue, 10 Feb 2026 15:49:46 -0500 Subject: [PATCH 13/23] Doc fixes --- .../input_csv_phase_info.ipynb | 4 +- .../getting_started/onboarding_level2.ipynb | 5 +- .../getting_started/onboarding_level3.ipynb | 2 + aviary/docs/user_guide/phase_info.ipynb | 4 +- .../user_guide/phase_info_conversion.ipynb | 21 ++-- .../docs/user_guide/phase_info_detailed.ipynb | 28 ++++- aviary/docs/user_guide/propulsion.ipynb | 4 +- aviary/docs/user_guide/reserve_missions.ipynb | 14 +-- .../run_level2_with_detailed_landing.py | 115 +++++++++--------- .../mission/two_dof_problem_configurator.py | 7 +- 10 files changed, 109 insertions(+), 95 deletions(-) diff --git a/aviary/docs/getting_started/input_csv_phase_info.ipynb b/aviary/docs/getting_started/input_csv_phase_info.ipynb index 60beba97c..18324e9ed 100644 --- a/aviary/docs/getting_started/input_csv_phase_info.ipynb +++ b/aviary/docs/getting_started/input_csv_phase_info.ipynb @@ -140,10 +140,10 @@ "from aviary.mission.height_energy.phases.groundroll_phase import GroundrollPhaseOptions as FGopt\n", "from aviary.mission.two_dof.phases.accel_phase import AccelPhaseOptions\n", "from aviary.mission.two_dof.phases.ascent_phase import AscentPhaseOptions\n", - "from aviary.mission.two_dof.phases.cruise_phase import CruisePhaseOptions\n", "from aviary.mission.two_dof.phases.flight_phase import FlightPhaseOptions\n", "from aviary.mission.two_dof.phases.groundroll_phase import GroundrollPhaseOptions as GGopt\n", "from aviary.mission.two_dof.phases.rotation_phase import RotationPhaseOptions\n", + "from aviary.mission.two_dof.phases.simple_cruise_phase import SimpleCruisePhaseOptions\n", "from aviary.mission.solved_two_dof.phases.solved_twodof_phase import SolvedTwoDOFPhaseOptions\n", "from aviary.utils.doctape import glue_keys\n", "\n", @@ -167,12 +167,12 @@ "\n", "dummy_phase_info.update(AccelPhaseOptions().items())\n", "dummy_phase_info.update(AscentPhaseOptions().items())\n", - "dummy_phase_info.update(CruisePhaseOptions().items())\n", "dummy_phase_info.update(FlightPhaseOptions().items())\n", "dummy_phase_info.update(FGopt().items())\n", "dummy_phase_info.update(FlightPhaseOptions().items())\n", "dummy_phase_info.update(GGopt().items())\n", "dummy_phase_info.update(RotationPhaseOptions().items())\n", + "dummy_phase_info.update(SimpleCruisePhaseOptions().items())\n", "dummy_phase_info.update(SolvedTwoDOFPhaseOptions().items())\n", "glue_keys(dummy_phase_info)" ] diff --git a/aviary/docs/getting_started/onboarding_level2.ipynb b/aviary/docs/getting_started/onboarding_level2.ipynb index 7620fade4..621f46333 100644 --- a/aviary/docs/getting_started/onboarding_level2.ipynb +++ b/aviary/docs/getting_started/onboarding_level2.ipynb @@ -856,10 +856,7 @@ "print('Accel Final Mass (lbm)', prob.get_val('traj.accel.states:mass', units='lbm')[-1])\n", "print('Climb1 Final Mass (lbm)', prob.get_val('traj.climb1.states:mass', units='lbm')[-1])\n", "print('Climb2 Final Mass (lbm)', prob.get_val('traj.climb2.states:mass', units='lbm')[-1])\n", - "print(\n", - " 'Cruise Final Mass (lbm)',\n", - " prob.get_val('traj.phases.cruise.rhs.calc_weight.mass', units='lbm')[-1],\n", - ")\n", + "print('Cruise Final Mass (lbm)', prob.get_val('traj.cruise.states:mass', units='lbm')[-1])\n", "print('Desc1 Final Mass (lbm)', prob.get_val('traj.desc1.states:mass', units='lbm')[-1])\n", "print('Desc2 Final Mass (lbm)', prob.get_val('traj.desc2.states:mass', units='lbm')[-1])\n", "print('done')" diff --git a/aviary/docs/getting_started/onboarding_level3.ipynb b/aviary/docs/getting_started/onboarding_level3.ipynb index 37a394678..dac4cb3d0 100644 --- a/aviary/docs/getting_started/onboarding_level3.ipynb +++ b/aviary/docs/getting_started/onboarding_level3.ipynb @@ -517,6 +517,8 @@ "\n", "av.setup_model_options(prob, aviary_inputs)\n", "\n", + "prob.model.set_input_defaults(av.Aircraft.Wing.AREA, val=1.0, units='ft**2')\n", + "\n", "prob.setup()\n", "\n", "phase = prob.model.traj.phases.cruise\n", diff --git a/aviary/docs/user_guide/phase_info.ipynb b/aviary/docs/user_guide/phase_info.ipynb index b8a6893a8..493d0e48e 100644 --- a/aviary/docs/user_guide/phase_info.ipynb +++ b/aviary/docs/user_guide/phase_info.ipynb @@ -15,22 +15,22 @@ "from aviary.mission.height_energy.phases.groundroll_phase import GroundrollPhaseOptions as FGopt\n", "from aviary.mission.two_dof.phases.accel_phase import AccelPhaseOptions\n", "from aviary.mission.two_dof.phases.ascent_phase import AscentPhaseOptions\n", - "from aviary.mission.two_dof.phases.cruise_phase import CruisePhaseOptions\n", "from aviary.mission.two_dof.phases.flight_phase import FlightPhaseOptions\n", "from aviary.mission.two_dof.phases.groundroll_phase import GroundrollPhaseOptions as GGopt\n", "from aviary.mission.two_dof.phases.rotation_phase import RotationPhaseOptions\n", + "from aviary.mission.two_dof.phases.simple_cruise_phase import SimpleCruisePhaseOptions\n", "from aviary.mission.solved_two_dof.phases.solved_twodof_phase import SolvedTwoDOFPhaseOptions\n", "from aviary.utils.doctape import glue_keys\n", "\n", "dummy_phase_info = {}\n", "dummy_phase_info.update(AccelPhaseOptions().items())\n", "dummy_phase_info.update(AscentPhaseOptions().items())\n", - "dummy_phase_info.update(CruisePhaseOptions().items())\n", "dummy_phase_info.update(FlightPhaseOptions().items())\n", "dummy_phase_info.update(FGopt().items())\n", "dummy_phase_info.update(FlightPhaseOptions().items())\n", "dummy_phase_info.update(GGopt().items())\n", "dummy_phase_info.update(RotationPhaseOptions().items())\n", + "dummy_phase_info.update(SimpleCruisePhaseOptions().items())\n", "dummy_phase_info.update(SolvedTwoDOFPhaseOptions().items())\n", "glue_keys(dummy_phase_info)" ] diff --git a/aviary/docs/user_guide/phase_info_conversion.ipynb b/aviary/docs/user_guide/phase_info_conversion.ipynb index cf96acd74..07660b803 100644 --- a/aviary/docs/user_guide/phase_info_conversion.ipynb +++ b/aviary/docs/user_guide/phase_info_conversion.ipynb @@ -15,22 +15,22 @@ "from aviary.mission.height_energy.phases.groundroll_phase import GroundrollPhaseOptions as FGopt\n", "from aviary.mission.two_dof.phases.accel_phase import AccelPhaseOptions\n", "from aviary.mission.two_dof.phases.ascent_phase import AscentPhaseOptions\n", - "from aviary.mission.two_dof.phases.cruise_phase import CruisePhaseOptions\n", "from aviary.mission.two_dof.phases.flight_phase import FlightPhaseOptions\n", "from aviary.mission.two_dof.phases.groundroll_phase import GroundrollPhaseOptions as GGopt\n", "from aviary.mission.two_dof.phases.rotation_phase import RotationPhaseOptions\n", + "from aviary.mission.two_dof.phases.simple_cruise_phase import SimpleCruisePhaseOptions\n", "from aviary.mission.solved_two_dof.phases.solved_twodof_phase import SolvedTwoDOFPhaseOptions\n", "from aviary.utils.doctape import glue_keys\n", "\n", "dummy_phase_info = {}\n", "dummy_phase_info.update(AccelPhaseOptions().items())\n", "dummy_phase_info.update(AscentPhaseOptions().items())\n", - "dummy_phase_info.update(CruisePhaseOptions().items())\n", "dummy_phase_info.update(FlightPhaseOptions().items())\n", "dummy_phase_info.update(FGopt().items())\n", "dummy_phase_info.update(FlightPhaseOptions().items())\n", "dummy_phase_info.update(GGopt().items())\n", "dummy_phase_info.update(RotationPhaseOptions().items())\n", + "dummy_phase_info.update(SimpleCruisePhaseOptions().items())\n", "dummy_phase_info.update(SolvedTwoDOFPhaseOptions().items())\n", "glue_keys(dummy_phase_info)" ] @@ -613,6 +613,8 @@ "metadata": {}, "outputs": [], "source": [ + "from aviary.variable_info.enums import PhaseType, SpeedType\n", + "\n", "new_phase_info = {\n", " 'groundroll': {\n", " 'subsystem_options': {'core_aerodynamics': {'method': 'low_speed'}},\n", @@ -787,18 +789,19 @@ " },\n", " },\n", " 'cruise': {\n", - " 'subsystem_options': {'core_aerodynamics': {'method': 'cruise'}},\n", + " 'subsystem_options': {'aerodynamics': {'method': 'cruise'}},\n", " 'user_options': {\n", + " 'phase_builder': PhaseType.SIMPLE_CRUISE,\n", " 'alt_cruise': (37.5e3, 'ft'),\n", " 'mach_cruise': 0.8,\n", + " 'mass_bounds': ((0, None), 'lbm'),\n", + " 'mass_ref': (150_000, 'lbm'),\n", + " 'time_duration_bounds': ((0.0, 15.0), 'h'),\n", + " 'time_duration_ref': (8, 'h'),\n", " },\n", " 'initial_guesses': {\n", - " # [Initial mass, delta mass] for special cruise phase.\n", - " 'mass': ([171481.0, -35000], 'lbm'),\n", - " 'initial_distance': (200.0e3, 'ft'),\n", - " 'initial_time': (1516.0, 's'),\n", - " 'altitude': (37.5e3, 'ft'),\n", - " 'mach': (0.8, 'unitless'),\n", + " 'mass': ([171481.0, 135000], 'lbm'),\n", + " 'time': ([1516.0, 26500.0], 's'),\n", " },\n", " },\n", " 'desc1': {\n", diff --git a/aviary/docs/user_guide/phase_info_detailed.ipynb b/aviary/docs/user_guide/phase_info_detailed.ipynb index a21538aad..9d0b38bbe 100644 --- a/aviary/docs/user_guide/phase_info_detailed.ipynb +++ b/aviary/docs/user_guide/phase_info_detailed.ipynb @@ -101,7 +101,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### CruisePhase\n" + "### SimpleCruisePhase\n", + "\n", + "This is the best choice for a level cruise when using the two DOF equations. SimpleCruisePhase integrates mass using the specified transcription, but the distance is computed from the fixed velocity. This can be selected by setting the \"phase_type\" option to `PhaseType.SIMPLE_CRUISE`.\n" ] }, { @@ -110,14 +112,34 @@ "metadata": {}, "outputs": [], "source": [ - "om.show_options_table('aviary.mission.two_dof.phases.cruise_phase.CruisePhaseOptions')" + "om.show_options_table('aviary.mission.two_dof.phases.simple_cruise_phase.SimpleCruisePhaseOptions')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### FlightPhase\n" + "### BreguetCruisePhase (Optional)\n", + "\n", + "The `BreguetCruisePhase` computes distance analytically using a form of the Breguet Range equation. This will generally be a little faster than `SimpleCruisePhase`, but it comes with a limitation that no external subsystems can contain a state that requires integration. This can be selected by setting the \"phase_type\" option to `PhaseType.BREGUET_RANGE`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "om.show_options_table('aviary.mission.two_dof.phases.breguet_cruise_phase.BreguetCruisePhaseOptions')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### FlightPhase\n", + "\n", + "The FlightPhase can be used for ascending and descending mission phases." ] }, { diff --git a/aviary/docs/user_guide/propulsion.ipynb b/aviary/docs/user_guide/propulsion.ipynb index fc16e3944..72e36c271 100644 --- a/aviary/docs/user_guide/propulsion.ipynb +++ b/aviary/docs/user_guide/propulsion.ipynb @@ -437,7 +437,7 @@ ], "metadata": { "kernelspec": { - "display_name": "aviary", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -451,7 +451,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.8" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/aviary/docs/user_guide/reserve_missions.ipynb b/aviary/docs/user_guide/reserve_missions.ipynb index 359578f1f..5f07059df 100644 --- a/aviary/docs/user_guide/reserve_missions.ipynb +++ b/aviary/docs/user_guide/reserve_missions.ipynb @@ -204,16 +204,10 @@ "## Theory\n", "\n", "When adding a reserve phase, {glue:md}`check_and_preprocess_inputs()` divides all the phases into two dictionaries: `regular_phases` which contain your nominal phases and `reserve_phases` which contains any phases with the `reserve` flag set to `True`.\n", - "Additionally, {glue:md}`check_and_preprocess_inputs()` will add the `\"analytic\"` flag to each phase.\n", - "This is used to indicate if a phase is an analytic phase (i.e. Breguet range) or a ordinary differential equation (ODE).\n", "\n", "Only the final mission mass and range from `regular_phases` are automatically connected to the first point of the `reserve_phases`.\n", "All other state variables (i.e. altitude, mach) are not automatically connected, allowing you to start the reserve mission at whatever altitude you want.\n", "\n", - "The `\"analytic\"` flag helps to properly connect phases for {glue:md}`2DOF` missions.\n", - "{glue:md}`2DOF` `cruise` missions are analytic because they use a Breguet range calculation instead of integrating an EOM. \n", - "Analytic phases have a slightly different naming convention in order to access state/timeseries variables like distance, mass, and range compared with their non-analytic counterparts.\n", - "\n", "You cannot create a reserve mission that enforces time or range constraints over multiple phases (i.e specify the total range covered by a climb + cruise + descent).\n", "This is because each constraint `\"target_distance\"` or `\"target_time\"` is only enforced on a single phase.\n", "It is essential that you run {glue:md}`prob.check_and_preprocess_inputs()` after {glue:md}`prob.load_inputs()` to make sure that regular and reserve phases are separated via `phase_separator()`." @@ -248,13 +242,7 @@ " if '_phase.py' in file and 'twodof' not in file:\n", " phase_name = file.split('_phase.py')[0].capitalize()\n", " file_path = os.path.join(str(gasp_phase_path), file)\n", - " module = SourceFileLoader(phase_name, file_path).load_module()\n", - " check_contains(\n", - " 'analytic',\n", - " getattr(module, phase_name + 'PhaseOptions')(),\n", - " error_string=f'analytic is not a valid key for {phase_name}',\n", - " error_type=NameError,\n", - " )" + " module = SourceFileLoader(phase_name, file_path).load_module()\n" ] }, { diff --git a/aviary/examples/run_level2_with_detailed_landing.py b/aviary/examples/run_level2_with_detailed_landing.py index 86ca2c28f..0643509fa 100644 --- a/aviary/examples/run_level2_with_detailed_landing.py +++ b/aviary/examples/run_level2_with_detailed_landing.py @@ -147,60 +147,61 @@ }, } -prob = av.AviaryProblem() - -# Load aircraft and options data from user -# Allow for user overrides here -prob.load_inputs('models/aircraft/test_aircraft/aircraft_for_bench_solved2dof.csv', phase_info) - -prob.check_and_preprocess_inputs() - -prob.build_model() - -prob.add_driver('SLSQP', max_iter=100) - -prob.add_design_variables() - -# Load optimization problem formulation -# Detail which variables the optimizer can control -prob.add_objective('mass') - -prob.setup() - -prob.run_aviary_problem() - -try: - loc = prob.get_outputs_dir() - cr = om.CaseReader(f'{loc}/problem_history.db') -except: - cr = om.CaseReader('problem_history.db') - -cases = cr.get_cases('problem') -case = cases[0] - -output_data = {} - -point_name = 'P3' -phase_name = 'GH' -output_data[point_name] = {} -output_data[point_name]['thrust_fraction'] = ( - case.get_val(f'traj.{phase_name}.rhs_all.thrust_net', units='N')[-1][0] - / case.get_val(f'traj.{phase_name}.rhs_all.thrust_net_max', units='N')[-1][0] -) -output_data[point_name]['true_airspeed'] = case.get_val( - f'traj.{phase_name}.timeseries.velocity', units='kn' -)[-1][0] -output_data[point_name]['angle_of_attack'] = case.get_val( - f'traj.{phase_name}.timeseries.angle_of_attack', units='deg' -)[-1][0] -output_data[point_name]['flight_path_angle'] = case.get_val( - f'traj.{phase_name}.timeseries.flight_path_angle', units='deg' -)[-1][0] -output_data[point_name]['altitude'] = case.get_val( - f'traj.{phase_name}.timeseries.altitude', units='ft' -)[-1][0] -output_data[point_name]['distance'] = case.get_val( - f'traj.{phase_name}.timeseries.distance', units='ft' -)[-1][0] - -print(output_data) +if __name__ == '__main__': + prob = av.AviaryProblem() + + # Load aircraft and options data from user + # Allow for user overrides here + prob.load_inputs('models/aircraft/test_aircraft/aircraft_for_bench_solved2dof.csv', phase_info) + + prob.check_and_preprocess_inputs() + + prob.build_model() + + prob.add_driver('SLSQP', max_iter=100) + + prob.add_design_variables() + + # Load optimization problem formulation + # Detail which variables the optimizer can control + prob.add_objective('mass') + + prob.setup() + + prob.run_aviary_problem() + + try: + loc = prob.get_outputs_dir() + cr = om.CaseReader(f'{loc}/problem_history.db') + except: + cr = om.CaseReader('problem_history.db') + + cases = cr.get_cases('problem') + case = cases[0] + + output_data = {} + + point_name = 'P3' + phase_name = 'GH' + output_data[point_name] = {} + output_data[point_name]['thrust_fraction'] = ( + case.get_val(f'traj.{phase_name}.rhs_all.thrust_net', units='N')[-1][0] + / case.get_val(f'traj.{phase_name}.rhs_all.thrust_net_max', units='N')[-1][0] + ) + output_data[point_name]['true_airspeed'] = case.get_val( + f'traj.{phase_name}.timeseries.velocity', units='kn' + )[-1][0] + output_data[point_name]['angle_of_attack'] = case.get_val( + f'traj.{phase_name}.timeseries.angle_of_attack', units='deg' + )[-1][0] + output_data[point_name]['flight_path_angle'] = case.get_val( + f'traj.{phase_name}.timeseries.flight_path_angle', units='deg' + )[-1][0] + output_data[point_name]['altitude'] = case.get_val( + f'traj.{phase_name}.timeseries.altitude', units='ft' + )[-1][0] + output_data[point_name]['distance'] = case.get_val( + f'traj.{phase_name}.timeseries.distance', units='ft' + )[-1][0] + + print(output_data) diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index 200f87959..4e6f81a69 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -216,7 +216,10 @@ def get_phase_builder(self, aviary_group, phase_name, phase_options): builder = phase_options['user_options']['phase_builder'] if builder is PhaseType.BREGUET_RANGE: - phase_builder = CruisePhase + if 'electric_cruise' in phase_name: + phase_builder = ElectricCruisePhase + else: + phase_builder = CruisePhase return phase_builder elif builder is PhaseType.SIMPLE_CRUISE: phase_builder = SimpleCruisePhase @@ -230,8 +233,6 @@ def get_phase_builder(self, aviary_group, phase_name, phase_options): phase_builder = AccelPhase elif 'ascent' in phase_name: phase_builder = AscentPhase - elif 'electric_cruise' in phase_name: - phase_builder = ElectricCruisePhase else: # All other phases are flight phases. phase_builder = FlightPhase From 6e96100d1edd55dd6d575fda6700ee7af6e83503 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Tue, 10 Feb 2026 22:19:51 -0500 Subject: [PATCH 14/23] Docs are working --- aviary/docs/user_guide/reserve_missions.ipynb | 4 ++++ .../two_dof/phases/breguet_cruise_phase.py | 16 ++++++++-------- aviary/mission/two_dof_problem_configurator.py | 4 ++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/aviary/docs/user_guide/reserve_missions.ipynb b/aviary/docs/user_guide/reserve_missions.ipynb index 5f07059df..d3450a080 100644 --- a/aviary/docs/user_guide/reserve_missions.ipynb +++ b/aviary/docs/user_guide/reserve_missions.ipynb @@ -86,6 +86,10 @@ " file_path = os.path.join(str(gasp_phase_path), file)\n", " phase_name = file.split('_phase.py')[0].capitalize()\n", " module = SourceFileLoader(phase_name, file_path).load_module()\n", + " if phase_name == 'Simple_cruise':\n", + " phase_name = 'SimpleCruise'\n", + " elif phase_name == 'Breguet_cruise':\n", + " phase_name = 'BreguetCruise'\n", " phases.append(getattr(module, phase_name + 'Phase'))\n", "\n", "for phase in phases:\n", diff --git a/aviary/mission/two_dof/phases/breguet_cruise_phase.py b/aviary/mission/two_dof/phases/breguet_cruise_phase.py index 37f0b8289..7a5eff089 100644 --- a/aviary/mission/two_dof/phases/breguet_cruise_phase.py +++ b/aviary/mission/two_dof/phases/breguet_cruise_phase.py @@ -78,7 +78,7 @@ def declare_options(self): ) -class CruisePhase(PhaseBuilder): +class BreguetCruisePhase(PhaseBuilder): """ A phase builder for a climb phase in a mission simulation. @@ -168,28 +168,28 @@ def build_phase(self, aviary_options: AviaryValues = None): return phase -CruisePhase._add_initial_guess_meta_data( +BreguetCruisePhase._add_initial_guess_meta_data( InitialGuessIntegrationVariable(), desc='initial guess for initial time and duration specified as a tuple', ) -CruisePhase._add_initial_guess_meta_data(InitialGuessState('mass'), desc='initial guess for mass') +BreguetCruisePhase._add_initial_guess_meta_data(InitialGuessState('mass'), desc='initial guess for mass') -CruisePhase._add_initial_guess_meta_data( +BreguetCruisePhase._add_initial_guess_meta_data( InitialGuessState('initial_distance'), desc='initial guess for initial_distance' ) -CruisePhase._add_initial_guess_meta_data( +BreguetCruisePhase._add_initial_guess_meta_data( InitialGuessState('initial_time'), desc='initial guess for initial_time' ) -CruisePhase._add_initial_guess_meta_data( +BreguetCruisePhase._add_initial_guess_meta_data( InitialGuessState('altitude'), desc='initial guess for altitude' ) -CruisePhase._add_initial_guess_meta_data(InitialGuessState('mach'), desc='initial guess for mach') +BreguetCruisePhase._add_initial_guess_meta_data(InitialGuessState('mach'), desc='initial guess for mach') -class ElectricCruisePhase(CruisePhase): +class ElectricCruisePhase(BreguetCruisePhase): default_name = 'electric_cruise_phase' default_ode_class = ElectricBreguetCruiseODE diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index 4e6f81a69..6ae7b78bb 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -6,7 +6,7 @@ from aviary.mission.two_dof.ode.taxi_ode import TaxiSegment from aviary.mission.two_dof.phases.accel_phase import AccelPhase from aviary.mission.two_dof.phases.ascent_phase import AscentPhase -from aviary.mission.two_dof.phases.breguet_cruise_phase import CruisePhase, ElectricCruisePhase +from aviary.mission.two_dof.phases.breguet_cruise_phase import BreguetCruisePhase, ElectricCruisePhase from aviary.mission.two_dof.phases.flight_phase import FlightPhase from aviary.mission.two_dof.phases.groundroll_phase import GroundrollPhase from aviary.mission.two_dof.phases.rotation_phase import RotationPhase @@ -219,7 +219,7 @@ def get_phase_builder(self, aviary_group, phase_name, phase_options): if 'electric_cruise' in phase_name: phase_builder = ElectricCruisePhase else: - phase_builder = CruisePhase + phase_builder = BreguetCruisePhase return phase_builder elif builder is PhaseType.SIMPLE_CRUISE: phase_builder = SimpleCruisePhase From 6a99f326173f2c84713ad4ac8f591f33fc42c9ad Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 11 Feb 2026 10:12:10 -0500 Subject: [PATCH 15/23] Cleanup and added a bench for Breguet Range --- .../mission/two_dof/ode/simple_cruise_ode.py | 137 ------------------ .../two_dof/phases/breguet_cruise_phase.py | 18 --- .../mission/two_dof_problem_configurator.py | 2 +- .../benchmark_tests/test_bench_GwGm.py | 83 +++++------ aviary/variable_info/enums.py | 2 +- 5 files changed, 42 insertions(+), 200 deletions(-) diff --git a/aviary/mission/two_dof/ode/simple_cruise_ode.py b/aviary/mission/two_dof/ode/simple_cruise_ode.py index 2af3ef370..3acafbeeb 100644 --- a/aviary/mission/two_dof/ode/simple_cruise_ode.py +++ b/aviary/mission/two_dof/ode/simple_cruise_ode.py @@ -146,140 +146,3 @@ def setup(self): ParamPort.set_default_vals(self) self.set_input_defaults(Dynamic.Mission.ALTITUDE, val=37500 * np.ones(nn), units='ft') self.set_input_defaults('mass', val=np.linspace(171481, 171581 - 10000, nn), units='lbm') - - -class ElectricBreguetCruiseODE(TwoDOFODE): - """The GASP based cruise ODE for electric aircraft.""" - - def setup(self): - nn = self.options['num_nodes'] - aviary_options = self.options['aviary_options'] - subsystems = self.options['subsystems'] - - # TODO: paramport - self.add_subsystem('params', ParamPort(), promotes=['*']) - - self.add_atmosphere(input_speed_type=SpeedType.MACH) - - self.add_subsystem( - 'calc_weight', - MassToWeight(num_nodes=nn), - promotes_inputs=['mass'], - promotes_outputs=['weight'], - ) - - prop_group = om.Group() - - kwargs = { - 'num_nodes': nn, - 'aviary_inputs': aviary_options, - 'method': 'cruise', - 'output_alpha': True, - } - for subsystem in subsystems: - system = subsystem.build_mission(**kwargs) - if system is not None: - if isinstance(subsystem, PropulsionBuilder): - prop_group.add_subsystem( - subsystem.name, - system, - promotes_inputs=subsystem.mission_inputs(**kwargs), - promotes_outputs=subsystem.mission_outputs(**kwargs), - ) - else: - self.add_subsystem( - subsystem.name, - system, - promotes_inputs=subsystem.mission_inputs(**kwargs), - promotes_outputs=subsystem.mission_outputs(**kwargs), - ) - - bal = om.BalanceComp( - name=Dynamic.Vehicle.Propulsion.THROTTLE, - val=np.ones(nn), - upper=1.0, - lower=0.0, - units='unitless', - lhs_name=Dynamic.Vehicle.Propulsion.THRUST_TOTAL, - rhs_name=Dynamic.Vehicle.DRAG, - eq_units='lbf', - ) - - prop_group.add_subsystem( - 'thrust_balance', subsys=bal, promotes_inputs=['*'], promotes_outputs=['*'] - ) - - prop_group.linear_solver = om.DirectSolver() - - prop_group.nonlinear_solver = om.NewtonSolver( - solve_subsystems=True, - maxiter=20, - rtol=1e-12, - atol=1e-12, - err_on_non_converge=False, - ) - prop_group.nonlinear_solver.linesearch = om.BoundsEnforceLS() - - prop_group.nonlinear_solver.options['iprint'] = 2 - prop_group.linear_solver.options['iprint'] = 2 - - self.add_subsystem( - 'prop_group', subsys=prop_group, promotes_inputs=['*'], promotes_outputs=['*'] - ) - - # - # collect initial/final outputs - # - self.add_subsystem( - 'electric_breguet_eom', - ElectricRangeComp(num_nodes=nn), - promotes_inputs=[ - ('cruise_distance_initial', 'initial_distance'), - ('cruise_time_initial', 'initial_time'), - Dynamic.Vehicle.CUMULATIVE_ELECTRIC_ENERGY_USED, - Dynamic.Vehicle.Propulsion.ELECTRIC_POWER_IN_TOTAL, - ('TAS_cruise', Dynamic.Mission.VELOCITY), - ], - promotes_outputs=[ - ('cruise_range', Dynamic.Mission.DISTANCE), - ('cruise_time', 'time'), - ], - ) - - self.add_subsystem( - name='SPECIFIC_ENERGY_RATE_EXCESS', - subsys=SpecificEnergyRate(num_nodes=nn), - promotes_inputs=[ - Dynamic.Mission.VELOCITY, - Dynamic.Vehicle.MASS, - ( - Dynamic.Vehicle.Propulsion.THRUST_TOTAL, - Dynamic.Vehicle.Propulsion.THRUST_MAX_TOTAL, - ), - Dynamic.Vehicle.DRAG, - ], - promotes_outputs=[ - ( - Dynamic.Mission.SPECIFIC_ENERGY_RATE, - Dynamic.Mission.SPECIFIC_ENERGY_RATE_EXCESS, - ) - ], - ) - - self.add_subsystem( - name='ALTITUDE_RATE_MAX', - subsys=AltitudeRate(num_nodes=nn), - promotes_inputs=[ - ( - Dynamic.Mission.SPECIFIC_ENERGY_RATE, - Dynamic.Mission.SPECIFIC_ENERGY_RATE_EXCESS, - ), - Dynamic.Mission.VELOCITY_RATE, - Dynamic.Mission.VELOCITY, - ], - promotes_outputs=[(Dynamic.Mission.ALTITUDE_RATE, Dynamic.Mission.ALTITUDE_RATE_MAX)], - ) - - ParamPort.set_default_vals(self) - self.set_input_defaults(Dynamic.Mission.ALTITUDE, val=37500 * np.ones(nn), units='ft') - self.set_input_defaults('mass', val=np.linspace(171481, 171581 - 10000, nn), units='lbm') diff --git a/aviary/mission/two_dof/phases/breguet_cruise_phase.py b/aviary/mission/two_dof/phases/breguet_cruise_phase.py index 7a5eff089..06058ab67 100644 --- a/aviary/mission/two_dof/phases/breguet_cruise_phase.py +++ b/aviary/mission/two_dof/phases/breguet_cruise_phase.py @@ -12,24 +12,6 @@ class BreguetCruisePhaseOptions(AviaryOptionsDictionary): def declare_options(self): - self.declare( - name='num_segments', - types=int, - default=5, - desc='The number of segments in transcription creation in Dymos. ' - 'While this phase is usually an analytic phase, this option is ' - 'needed if an external subsystem requires a dynamic transcription.', - ) - - self.declare( - name='order', - types=int, - default=3, - desc='The order of polynomials for interpolation in the transcription ' - 'created in Dymos. While this phase is usually an analytic phase, this option is ' - 'needed if an external subsystem requires a dynamic transcription.', - ) - self.declare(name='alt_cruise', default=0.0, units='ft', desc='Cruise altitude.') self.declare(name='mach_cruise', default=0.0, desc='Cruise Mach number.') diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index 6ae7b78bb..f8a3d1dfe 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -221,6 +221,7 @@ def get_phase_builder(self, aviary_group, phase_name, phase_options): else: phase_builder = BreguetCruisePhase return phase_builder + elif builder is PhaseType.SIMPLE_CRUISE: phase_builder = SimpleCruisePhase return phase_builder @@ -490,7 +491,6 @@ def link_phases(self, aviary_group, phases, connect_directly=True): kwargs = {} if not connected: # Some better scaling of the linkage constraint. - kwargs = {} if state in initial_guesses1: val, units = initial_guesses1[state] if isinstance(val, (tuple, list)): diff --git a/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py b/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py index b76092d1c..03422a352 100644 --- a/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py +++ b/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py @@ -8,6 +8,7 @@ from aviary.models.missions.two_dof_default import phase_info from aviary.interface.methods_for_level1 import run_aviary from aviary.variable_info.variables import Aircraft, Dynamic, Mission +from aviary.variable_info.enums import PhaseType @use_tempdirs @@ -21,15 +22,7 @@ class ProblemPhaseTestCase(unittest.TestCase): def setUp(self): _clear_problem_names() # need to reset these to simulate separate runs - @require_pyoptsparse(optimizer='IPOPT') - def test_bench_GwGm_IPOPT(self): - local_phase_info = deepcopy(phase_info) - prob = run_aviary( - 'models/aircraft/test_aircraft/aircraft_for_bench_GwGm.csv', - local_phase_info, - optimizer='IPOPT', - verbosity=0, - ) + def check_values(self, prob): self.assertTrue(prob.result.success) @@ -68,6 +61,17 @@ def test_bench_GwGm_IPOPT(self): tolerance=rtol, ) + @require_pyoptsparse(optimizer='IPOPT') + def test_bench_GwGm_IPOPT(self): + local_phase_info = deepcopy(phase_info) + prob = run_aviary( + 'models/aircraft/test_aircraft/aircraft_for_bench_GwGm.csv', + local_phase_info, + optimizer='IPOPT', + verbosity=0, + ) + self.check_values(prob) + @require_pyoptsparse(optimizer='SNOPT') def test_bench_GwGm_SNOPT(self): local_phase_info = deepcopy(phase_info) @@ -77,47 +81,40 @@ def test_bench_GwGm_SNOPT(self): optimizer='SNOPT', verbosity=0, ) + self.check_values(prob) - self.assertTrue(prob.result.success) - - rtol = 1e-3 - - # There are no truth values for these. - assert_near_equal( - prob.get_val(Mission.Design.GROSS_MASS, units='lbm'), - 171595.06049335, - tolerance=rtol, - ) - - assert_near_equal( - prob.get_val(Mission.Summary.OPERATING_MASS, units='lbm'), - 95089.98897716, - tolerance=rtol, - ) - - assert_near_equal( - prob.get_val(Mission.Summary.TOTAL_FUEL_MASS, units='lbm'), - 40505.07151619, - tolerance=rtol, - ) - - assert_near_equal( - prob.get_val(Mission.Landing.GROUND_DISTANCE, units='ft'), - 2657.88663983, - tolerance=rtol, - ) + @require_pyoptsparse(optimizer='IPOPT') + def test_bench_GwGm_IPOPT_Breguet_Cruise(self): + local_phase_info = deepcopy(phase_info) - assert_near_equal(prob.get_val(Mission.Summary.RANGE, units='NM'), 3675.0, tolerance=rtol) + local_phase_info['cruise'] = { + 'subsystem_options': {'aerodynamics': {'method': 'cruise'}}, + 'user_options': { + 'phase_builder': PhaseType.BREGUET_RANGE, + 'alt_cruise': (37.5e3, 'ft'), + 'mach_cruise': 10.8, + }, + 'initial_guesses': { + # [Initial mass, delta mass] for special cruise phase. + 'mass': ([171481.0, -35000], 'lbm'), + 'initial_distance': (200.0e3, 'ft'), + 'initial_time': (1516.0, 's'), + 'altitude': (37.5e3, 'ft'), + 'mach': (0.8, 'unitless'), + }, + } - assert_near_equal( - prob.get_val(Mission.Landing.TOUCHDOWN_MASS, units='lbm'), - 136087.98897716, - tolerance=rtol, + prob = run_aviary( + 'models/aircraft/test_aircraft/aircraft_for_bench_GwGm.csv', + local_phase_info, + optimizer='IPOPT', + verbosity=0, ) + self.check_values(prob) if __name__ == '__main__': # unittest.main() test = ProblemPhaseTestCase() test.setUp() - test.test_bench_GwGm_SNOPT() + test.test_bench_GwGm_IPOPT_Breguet_Cruise() diff --git a/aviary/variable_info/enums.py b/aviary/variable_info/enums.py index 6fb2bc2ad..843c1f06a 100644 --- a/aviary/variable_info/enums.py +++ b/aviary/variable_info/enums.py @@ -170,7 +170,7 @@ class PhaseType(Enum): PhaseType is used for replacing a default phase and its equations of motion with a different one. - DEFAULT: Use the default phase builder for this phase. + DEFAULT: Use the default phase builder for this EquationsOfMotion. BREGUET_RANGE: Use a phase builder that implements the Breguet Range equations. From d4934526bda142498bc100b4a8201fc4914c6d82 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 11 Feb 2026 10:41:45 -0500 Subject: [PATCH 16/23] RUFF --- aviary/docs/user_guide/phase_info_detailed.ipynb | 4 +++- aviary/docs/user_guide/reserve_missions.ipynb | 2 +- aviary/mission/two_dof/ode/simple_cruise_eom.py | 12 +++++++----- .../mission/two_dof/phases/breguet_cruise_phase.py | 10 +++++++--- .../mission/two_dof/phases/simple_cruise_phase.py | 5 +++-- aviary/mission/two_dof_problem_configurator.py | 13 ++++++++----- .../benchmark_tests/test_bench_GwGm.py | 1 - aviary/variable_info/enums.py | 4 ++-- 8 files changed, 31 insertions(+), 20 deletions(-) diff --git a/aviary/docs/user_guide/phase_info_detailed.ipynb b/aviary/docs/user_guide/phase_info_detailed.ipynb index 9d0b38bbe..825fa13d4 100644 --- a/aviary/docs/user_guide/phase_info_detailed.ipynb +++ b/aviary/docs/user_guide/phase_info_detailed.ipynb @@ -130,7 +130,9 @@ "metadata": {}, "outputs": [], "source": [ - "om.show_options_table('aviary.mission.two_dof.phases.breguet_cruise_phase.BreguetCruisePhaseOptions')" + "om.show_options_table(\n", + " 'aviary.mission.two_dof.phases.breguet_cruise_phase.BreguetCruisePhaseOptions'\n", + ")" ] }, { diff --git a/aviary/docs/user_guide/reserve_missions.ipynb b/aviary/docs/user_guide/reserve_missions.ipynb index d3450a080..86ea52103 100644 --- a/aviary/docs/user_guide/reserve_missions.ipynb +++ b/aviary/docs/user_guide/reserve_missions.ipynb @@ -246,7 +246,7 @@ " if '_phase.py' in file and 'twodof' not in file:\n", " phase_name = file.split('_phase.py')[0].capitalize()\n", " file_path = os.path.join(str(gasp_phase_path), file)\n", - " module = SourceFileLoader(phase_name, file_path).load_module()\n" + " module = SourceFileLoader(phase_name, file_path).load_module()" ] }, { diff --git a/aviary/mission/two_dof/ode/simple_cruise_eom.py b/aviary/mission/two_dof/ode/simple_cruise_eom.py index 25df6927c..e9f5eee93 100644 --- a/aviary/mission/two_dof/ode/simple_cruise_eom.py +++ b/aviary/mission/two_dof/ode/simple_cruise_eom.py @@ -29,11 +29,13 @@ def setup(self): self.add_input( 'TAS_cruise', val=0.0001 * np.ones(nn), - units='NM/s', desc='Constant true airspeed at each point in cruise.') + units='NM/s', + desc='Constant true airspeed at each point in cruise.' + ) self.add_output( Dynamic.Mission.DISTANCE, - shape=(nn, ), + shape=(nn,), units='NM', desc='Computed distance at each point in the cruise phase.', ) @@ -57,8 +59,8 @@ def setup_partials(self): ) # Sparsity pattern includes a vertical row at i=0 - xtra_row = np.arange(nn-1) + 1 - xtra_col = np.zeros(nn-1) + xtra_row = np.arange(nn - 1) + 1 + xtra_col = np.zeros(nn - 1) all_row = np.hstack((row_col, xtra_row)) all_col = np.hstack((row_col, xtra_col)) @@ -83,4 +85,4 @@ def compute_partials(self, inputs, J): J[Dynamic.Mission.DISTANCE, 'time'][0] = 0.0 J[Dynamic.Mission.DISTANCE, 'time'][1:nn] = v_x[1:] - J[Dynamic.Mission.DISTANCE, 'time'][nn:] = -v_x[1:] \ No newline at end of file + J[Dynamic.Mission.DISTANCE, 'time'][nn:] = -v_x[1:] diff --git a/aviary/mission/two_dof/phases/breguet_cruise_phase.py b/aviary/mission/two_dof/phases/breguet_cruise_phase.py index 06058ab67..692c5ad6d 100644 --- a/aviary/mission/two_dof/phases/breguet_cruise_phase.py +++ b/aviary/mission/two_dof/phases/breguet_cruise_phase.py @@ -97,7 +97,7 @@ def __init__( ): if is_analytic_phase is not True: - msg = "The Breguet Cruise phase does not support dynamic variables in its subsystems." + msg = 'The Breguet Cruise phase does not support dynamic variables in its subsystems.' raise AttributeError(msg) super().__init__( @@ -155,7 +155,9 @@ def build_phase(self, aviary_options: AviaryValues = None): desc='initial guess for initial time and duration specified as a tuple', ) -BreguetCruisePhase._add_initial_guess_meta_data(InitialGuessState('mass'), desc='initial guess for mass') +BreguetCruisePhase._add_initial_guess_meta_data( + InitialGuessState('mass'), desc='initial guess for mass' +) BreguetCruisePhase._add_initial_guess_meta_data( InitialGuessState('initial_distance'), desc='initial guess for initial_distance' @@ -169,7 +171,9 @@ def build_phase(self, aviary_options: AviaryValues = None): InitialGuessState('altitude'), desc='initial guess for altitude' ) -BreguetCruisePhase._add_initial_guess_meta_data(InitialGuessState('mach'), desc='initial guess for mach') +BreguetCruisePhase._add_initial_guess_meta_data( + InitialGuessState('mach'), desc='initial guess for mach' +) class ElectricCruisePhase(BreguetCruisePhase): diff --git a/aviary/mission/two_dof/phases/simple_cruise_phase.py b/aviary/mission/two_dof/phases/simple_cruise_phase.py index dac0df612..0a1bdf1ce 100644 --- a/aviary/mission/two_dof/phases/simple_cruise_phase.py +++ b/aviary/mission/two_dof/phases/simple_cruise_phase.py @@ -169,7 +169,6 @@ def build_phase(self, aviary_options: AviaryValues = None): opt=True, val=0.0, units='nmi', - #ref=100.0, static_target=True, ) @@ -209,7 +208,9 @@ def build_phase(self, aviary_options: AviaryValues = None): desc='initial guess for initial time and duration specified as a tuple', ) -SimpleCruisePhase._add_initial_guess_meta_data(InitialGuessState('mass'), desc='initial guess for mass') +SimpleCruisePhase._add_initial_guess_meta_data( + InitialGuessState('mass'), desc='initial guess for mass' +) SimpleCruisePhase._add_initial_guess_meta_data( InitialGuessState('initial_distance'), desc='initial guess for initial_distance' diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index f8a3d1dfe..515c8e0f5 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -6,7 +6,10 @@ from aviary.mission.two_dof.ode.taxi_ode import TaxiSegment from aviary.mission.two_dof.phases.accel_phase import AccelPhase from aviary.mission.two_dof.phases.ascent_phase import AscentPhase -from aviary.mission.two_dof.phases.breguet_cruise_phase import BreguetCruisePhase, ElectricCruisePhase +from aviary.mission.two_dof.phases.breguet_cruise_phase import ( + BreguetCruisePhase, + ElectricCruisePhase, +) from aviary.mission.two_dof.phases.flight_phase import FlightPhase from aviary.mission.two_dof.phases.groundroll_phase import GroundrollPhase from aviary.mission.two_dof.phases.rotation_phase import RotationPhase @@ -429,8 +432,8 @@ def link_phases(self, aviary_group, phases, connect_directly=True): # Distance is a constraint for SIMPLE_CRUISE if ( - phase_builder1 is PhaseType.SIMPLE_CRUISE or - phase_builder2 is PhaseType.SIMPLE_CRUISE + phase_builder1 is PhaseType.SIMPLE_CRUISE + or phase_builder2 is PhaseType.SIMPLE_CRUISE ): if phase_builder2 is PhaseType.SIMPLE_CRUISE: prefix = 'initial_' @@ -455,8 +458,8 @@ def link_phases(self, aviary_group, phases, connect_directly=True): # and neither phase is ground roll or rotation (altitude isn't a state): # we want altitude to be continuous as well if ( - phase_builder1 is PhaseType.SIMPLE_CRUISE or - phase_builder2 is PhaseType.SIMPLE_CRUISE + phase_builder1 is PhaseType.SIMPLE_CRUISEr + or phase_builder2 is PhaseType.SIMPLE_CRUISE ): aviary_group.traj.add_linkage_constraint( phase1, phase2, 'altitude', 'altitude', connected=False, ref=1.0e4 diff --git a/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py b/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py index 03422a352..466a7a17a 100644 --- a/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py +++ b/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py @@ -23,7 +23,6 @@ def setUp(self): _clear_problem_names() # need to reset these to simulate separate runs def check_values(self, prob): - self.assertTrue(prob.result.success) rtol = 1e-3 diff --git a/aviary/variable_info/enums.py b/aviary/variable_info/enums.py index 843c1f06a..3c0b60b70 100644 --- a/aviary/variable_info/enums.py +++ b/aviary/variable_info/enums.py @@ -178,8 +178,8 @@ class PhaseType(Enum): """ DEFAULT = 'default' - BREGUET_RANGE = "breguet_range" - SIMPLE_CRUISE = "simple_cruise" + BREGUET_RANGE = 'breguet_range' + SIMPLE_CRUISE = 'simple_cruise' class ProblemType(Enum): From 95d03d6f563c7970fa0f32168424a5e90a754e64 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 11 Feb 2026 10:48:27 -0500 Subject: [PATCH 17/23] RUFF --- aviary/mission/two_dof/ode/simple_cruise_eom.py | 2 +- aviary/mission/two_dof/phases/breguet_cruise_phase.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/aviary/mission/two_dof/ode/simple_cruise_eom.py b/aviary/mission/two_dof/ode/simple_cruise_eom.py index e9f5eee93..c15039dca 100644 --- a/aviary/mission/two_dof/ode/simple_cruise_eom.py +++ b/aviary/mission/two_dof/ode/simple_cruise_eom.py @@ -30,7 +30,7 @@ def setup(self): 'TAS_cruise', val=0.0001 * np.ones(nn), units='NM/s', - desc='Constant true airspeed at each point in cruise.' + desc='Constant true airspeed at each point in cruise.', ) self.add_output( diff --git a/aviary/mission/two_dof/phases/breguet_cruise_phase.py b/aviary/mission/two_dof/phases/breguet_cruise_phase.py index 692c5ad6d..7381e58ea 100644 --- a/aviary/mission/two_dof/phases/breguet_cruise_phase.py +++ b/aviary/mission/two_dof/phases/breguet_cruise_phase.py @@ -95,7 +95,6 @@ def __init__( meta_data=None, is_analytic_phase=True, ): - if is_analytic_phase is not True: msg = 'The Breguet Cruise phase does not support dynamic variables in its subsystems.' raise AttributeError(msg) From 53b662958c1bd4e7f648f327099a4853024995e9 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 11 Feb 2026 10:50:44 -0500 Subject: [PATCH 18/23] RUFF --- aviary/mission/two_dof/phases/simple_cruise_phase.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/aviary/mission/two_dof/phases/simple_cruise_phase.py b/aviary/mission/two_dof/phases/simple_cruise_phase.py index 0a1bdf1ce..5adfae882 100644 --- a/aviary/mission/two_dof/phases/simple_cruise_phase.py +++ b/aviary/mission/two_dof/phases/simple_cruise_phase.py @@ -219,5 +219,3 @@ def build_phase(self, aviary_options: AviaryValues = None): SimpleCruisePhase._add_initial_guess_meta_data( InitialGuessState('initial_time'), desc='initial guess for initial_time' ) - - From 331d529645dd83bb7401be2f28f90bb4415bc31c Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 11 Feb 2026 11:03:00 -0500 Subject: [PATCH 19/23] RUFF --- aviary/mission/two_dof_problem_configurator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aviary/mission/two_dof_problem_configurator.py b/aviary/mission/two_dof_problem_configurator.py index 515c8e0f5..441246d18 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -458,7 +458,7 @@ def link_phases(self, aviary_group, phases, connect_directly=True): # and neither phase is ground roll or rotation (altitude isn't a state): # we want altitude to be continuous as well if ( - phase_builder1 is PhaseType.SIMPLE_CRUISEr + phase_builder1 is PhaseType.SIMPLE_CRUISE or phase_builder2 is PhaseType.SIMPLE_CRUISE ): aviary_group.traj.add_linkage_constraint( From aa0b313581e5cd67dcec20de232cc1a135a04665 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 11 Feb 2026 15:02:00 -0500 Subject: [PATCH 20/23] SLSQP works with Height Energy, but doesn't seem to be able to handle the basic 2DOF problem. May need to investigate --- .../benchmark_tests/test_subsystems_within_a_mission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aviary/validation_cases/benchmark_tests/test_subsystems_within_a_mission.py b/aviary/validation_cases/benchmark_tests/test_subsystems_within_a_mission.py index 806a28614..cf9a80268 100644 --- a/aviary/validation_cases/benchmark_tests/test_subsystems_within_a_mission.py +++ b/aviary/validation_cases/benchmark_tests/test_subsystems_within_a_mission.py @@ -144,7 +144,7 @@ def test_subsystems_in_a_mission_2dof(self): # 'm', # ) - prob.add_driver('SLSQP', max_iter=0, verbosity=0) + prob.add_driver('SNOPT', max_iter=0, verbosity=0) prob.add_design_variables() From fe0cec6776d92cd589983fda490671a52264549d Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 11 Feb 2026 15:02:47 -0500 Subject: [PATCH 21/23] SLSQP works with Height Energy, but doesn't seem to be able to handle the basic 2DOF problem. May need to investigate --- .../benchmark_tests/test_battery_in_a_mission.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py b/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py index 45f827149..a2eee93b6 100644 --- a/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py +++ b/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py @@ -2,7 +2,7 @@ from copy import deepcopy from openmdao.utils.assert_utils import assert_near_equal -from openmdao.utils.testing_utils import use_tempdirs +from openmdao.utils.testing_utils import require_pyoptsparse, use_tempdirs import aviary.api as av from aviary.models.missions.two_dof_default import phase_info as twodof_phase_info @@ -168,6 +168,7 @@ def test_subsystems_in_a_mission(self): 1e-6, ) + @require_pyoptsparse(optimizer='SNOPT') def test_subsystems_in_a_mission_2dof(self): phase_info = deepcopy(twodof_phase_info) From f93c8d290abe769027ef92ee2152b0d70942abe7 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Wed, 11 Feb 2026 15:20:14 -0500 Subject: [PATCH 22/23] SLSQP works with Height Energy, but doesn't seem to be able to handle the basic 2DOF problem. May need to investigate --- .../benchmark_tests/test_battery_in_a_mission.py | 2 +- .../benchmark_tests/test_subsystems_within_a_mission.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py b/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py index a2eee93b6..fac3111c0 100644 --- a/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py +++ b/aviary/validation_cases/benchmark_tests/test_battery_in_a_mission.py @@ -187,7 +187,7 @@ def test_subsystems_in_a_mission_2dof(self): prob.build_model() - prob.add_driver('SLSQP') + prob.add_driver('SNOPT') prob.add_design_variables() diff --git a/aviary/validation_cases/benchmark_tests/test_subsystems_within_a_mission.py b/aviary/validation_cases/benchmark_tests/test_subsystems_within_a_mission.py index cf9a80268..806a28614 100644 --- a/aviary/validation_cases/benchmark_tests/test_subsystems_within_a_mission.py +++ b/aviary/validation_cases/benchmark_tests/test_subsystems_within_a_mission.py @@ -144,7 +144,7 @@ def test_subsystems_in_a_mission_2dof(self): # 'm', # ) - prob.add_driver('SNOPT', max_iter=0, verbosity=0) + prob.add_driver('SLSQP', max_iter=0, verbosity=0) prob.add_design_variables() From ceacb6de02a67bc55f527ed7bd93787f1c7d09e9 Mon Sep 17 00:00:00 2001 From: Kenneth-T-Moore Date: Fri, 13 Feb 2026 10:22:56 -0500 Subject: [PATCH 23/23] Forgot to add the ode test file to the repo --- .../ode/test/test_simple_cruise_ode.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 aviary/mission/two_dof/ode/test/test_simple_cruise_ode.py diff --git a/aviary/mission/two_dof/ode/test/test_simple_cruise_ode.py b/aviary/mission/two_dof/ode/test/test_simple_cruise_ode.py new file mode 100644 index 000000000..c1fb6ea9f --- /dev/null +++ b/aviary/mission/two_dof/ode/test/test_simple_cruise_ode.py @@ -0,0 +1,79 @@ +import unittest + +import numpy as np +import openmdao.api as om +from openmdao.utils.assert_utils import assert_check_partials, assert_near_equal + +from aviary.mission.two_dof.ode.simple_cruise_ode import SimpleCruiseODE +from aviary.mission.two_dof.ode.params import set_params_for_unit_tests +from aviary.subsystems.propulsion.utils import build_engine_deck +from aviary.utils.test_utils.default_subsystems import get_default_mission_subsystems +from aviary.variable_info.functions import setup_model_options +from aviary.variable_info.options import get_option_defaults +from aviary.variable_info.variables import Aircraft, Dynamic + + +class CruiseODETestCase(unittest.TestCase): + def setUp(self): + self.prob = om.Problem() + + aviary_options = get_option_defaults() + aviary_options.set_val(Aircraft.Engine.GLOBAL_THROTTLE, True) + default_mission_subsystems = get_default_mission_subsystems( + 'GASP', [build_engine_deck(aviary_options)] + ) + + self.prob.model = SimpleCruiseODE( + num_nodes=2, + aviary_options=aviary_options, + subsystems=default_mission_subsystems, + ) + + self.prob.model.set_input_defaults( + Dynamic.Atmosphere.MACH, np.array([0, 0]), units='unitless' + ) + + setup_model_options(self.prob, aviary_options) + + def test_cruise(self): + # test partial derivatives + self.prob.setup(check=False, force_alloc_complex=True) + + self.prob.set_val(Dynamic.Atmosphere.MACH, [0.7, 0.7], units='unitless') + self.prob.set_val('interference_independent_of_shielded_area', 1.89927266) + self.prob.set_val('drag_loss_due_to_shielded_wing_area', 68.02065834) + self.prob.set_val(Aircraft.Wing.FORM_FACTOR, 1.25) + self.prob.set_val(Aircraft.VerticalTail.FORM_FACTOR, 1.25) + self.prob.set_val(Aircraft.HorizontalTail.FORM_FACTOR, 1.25) + self.prob.set_val('time', np.array([0, 8280.30660691]), units='s') + + set_params_for_unit_tests(self.prob) + + self.prob.run_model() + + tol = tol = 1e-6 + assert_near_equal(self.prob[Dynamic.Mission.VELOCITY_RATE], np.array([1.0, 1.0]), tol) + assert_near_equal(self.prob[Dynamic.Mission.DISTANCE], np.array([0.0, 923.39168758]), tol) + assert_near_equal(self.prob['time'], np.array([0, 8280.30660691]), tol) + assert_near_equal( + self.prob[Dynamic.Mission.SPECIFIC_ENERGY_RATE_EXCESS], + np.array([3.88465429, 4.90288541]), + tol, + ) + assert_near_equal( + self.prob[Dynamic.Mission.ALTITUDE_RATE_MAX], + np.array([-17.17541759, -16.15718646]), + tol, + ) + + partial_data = self.prob.check_partials( + out_stream=None, method='cs', excludes=['*USatm*', '*params*', '*aero*'] + ) + assert_check_partials(partial_data, atol=1e-8, rtol=1e-8) + + +if __name__ == '__main__': + # unittest.main() + z = CruiseODETestCase() + z.setUp() + z.test_cruise()