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/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..825fa13d4 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,36 @@ "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(\n", + " 'aviary.mission.two_dof.phases.breguet_cruise_phase.BreguetCruisePhaseOptions'\n", + ")" + ] + }, + { + "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..86ea52103 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", @@ -204,16 +208,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 +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", - " 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()" ] }, { 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/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/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/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/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/ode/simple_cruise_eom.py b/aviary/mission/two_dof/ode/simple_cruise_eom.py new file mode 100644 index 000000000..c15039dca --- /dev/null +++ b/aviary/mission/two_dof/ode/simple_cruise_eom.py @@ -0,0 +1,88 @@ +import numpy as np +import openmdao.api as om + +from aviary.variable_info.variables import Dynamic + + +class DistanceComp(om.ExplicitComponent): + """Computes distance 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( + '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( + Dynamic.Mission.DISTANCE, + 'TAS_cruise', + rows=row_col, + cols=row_col, + ) + self.declare_partials( + Dynamic.Mission.DISTANCE, + 'cruise_distance_initial', + rows=[0], + cols=[0], + 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)) + + self.declare_partials(Dynamic.Mission.DISTANCE, 'time', rows=all_row, cols=all_col) + + def compute(self, inputs, outputs): + v_x = inputs['TAS_cruise'] + r0 = inputs['cruise_distance_initial'] + t = inputs['time'] + t0 = t[0] + + outputs[Dynamic.Mission.DISTANCE] = r0 + v_x * (t - t0) + + def compute_partials(self, inputs, J): + v_x = inputs['TAS_cruise'] + t = inputs['time'] + t0 = t[0] + nn = self.options['num_nodes'] + + J[Dynamic.Mission.DISTANCE, 'TAS_cruise'] = t - t0 + + 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:] 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..3acafbeeb --- /dev/null +++ b/aviary/mission/two_dof/ode/simple_cruise_ode.py @@ -0,0 +1,148 @@ +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') 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/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() 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/breguet_cruise_phase.py similarity index 84% rename from aviary/mission/two_dof/phases/cruise_phase.py rename to aviary/mission/two_dof/phases/breguet_cruise_phase.py index 370c3cda9..7381e58ea 100644 --- a/aviary/mission/two_dof/phases/cruise_phase.py +++ b/aviary/mission/two_dof/phases/breguet_cruise_phase.py @@ -10,19 +10,12 @@ from aviary.variable_info.variables import Dynamic -class CruisePhaseOptions(AviaryOptionsDictionary): +class BreguetCruisePhaseOptions(AviaryOptionsDictionary): def declare_options(self): 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( - 'analytic', - types=bool, - default=False, - desc='When set to True, this is an analytic phase.', - ) - self.declare( 'reserve', types=bool, @@ -67,7 +60,7 @@ def declare_options(self): ) -class CruisePhase(PhaseBuilder): +class BreguetCruisePhase(PhaseBuilder): """ A phase builder for a climb phase in a mission simulation. @@ -84,9 +77,9 @@ 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 = CruisePhaseOptions + default_options_class = BreguetCruisePhaseOptions _initial_guesses_meta_data_ = {} @@ -100,7 +93,12 @@ def __init__( transcription=None, subsystems=None, 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, @@ -110,7 +108,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): @@ -146,32 +144,37 @@ 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 -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/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 new file mode 100644 index 000000000..5adfae882 --- /dev/null +++ b/aviary/mission/two_dof/phases/simple_cruise_phase.py @@ -0,0 +1,221 @@ +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 = 'simple_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='nmi', + 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(Dynamic.Atmosphere.MACH, units='unitless') + + 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 b0c9d561a..441246d18 100644 --- a/aviary/mission/two_dof_problem_configurator.py +++ b/aviary/mission/two_dof_problem_configurator.py @@ -6,17 +6,20 @@ 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 ( + 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 +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 +from aviary.variable_info.enums import LegacyCode, PhaseType from aviary.variable_info.variables import Aircraft, Dynamic, Mission @@ -211,6 +214,21 @@ def get_phase_builder(self, aviary_group, phase_name, phase_options): PhaseBuilder Phase builder for requested phase. """ + # 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'] + + if builder is PhaseType.BREGUET_RANGE: + if 'electric_cruise' in phase_name: + phase_builder = ElectricCruisePhase + else: + phase_builder = BreguetCruisePhase + 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: @@ -219,10 +237,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 - elif 'cruise' in phase_name: - phase_builder = CruisePhase else: # All other phases are flight phases. phase_builder = FlightPhase @@ -250,8 +264,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 +320,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 +331,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 +387,85 @@ 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, + ref=100.0, + ) + 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,62 +486,32 @@ 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 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. 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 ) - # 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 +566,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 +700,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 +728,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 +858,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/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..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 @@ -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, 15.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..9be4677d1 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, 15.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 72e13da7a..19a1c9ac7 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,16 +180,17 @@ '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, 15.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'), - 'initial_time': (1516.0, 's'), - 'altitude': (37.5e3, 'ft'), - 'mach': (0.8, 'unitless'), + 'mass': ([171481.0, 135000], 'lbm'), + 'time': ([1516.0, 26500.0], 's'), }, }, 'desc1': { @@ -295,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. @@ -323,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/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 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: 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 3209181ca..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 @@ -2,9 +2,10 @@ 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 from aviary.subsystems.energy.battery_builder import BatteryBuilder @@ -167,9 +168,42 @@ 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) + + 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('SNOPT') + + 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() + # unittest.main() + test = TestBatteryMission() + test.test_subsystems_in_a_mission_2dof() diff --git a/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py b/aviary/validation_cases/benchmark_tests/test_bench_GwGm.py index 206aa067b..466a7a17a 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,16 +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) rtol = 1e-3 @@ -38,25 +30,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,10 +56,21 @@ def test_bench_GwGm_IPOPT(self): assert_near_equal( prob.get_val(Mission.Landing.TOUCHDOWN_MASS, units='lbm'), - 136098.07583783, + 136087.98897716, 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 +80,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'), - 171646.48312684, - tolerance=rtol, - ) - - assert_near_equal( - prob.get_val(Mission.Summary.OPERATING_MASS, units='lbm'), - 95100.07583783, - tolerance=rtol, - ) - - assert_near_equal( - prob.get_val(Mission.Summary.TOTAL_FUEL_MASS, units='lbm'), - 40546.40728901, - tolerance=rtol, - ) - - assert_near_equal( - prob.get_val(Mission.Landing.GROUND_DISTANCE, units='ft'), - 2655.5028412, - 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'), - 136098.07583783, - 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/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]) 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..547d5c5cf 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 @@ -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( @@ -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() diff --git a/aviary/variable_info/enums.py b/aviary/variable_info/enums.py index 3692253dc..3c0b60b70 100644 --- a/aviary/variable_info/enums.py +++ b/aviary/variable_info/enums.py @@ -165,6 +165,23 @@ 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 EquationsOfMotion. + + 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 = 'breguet_range' + SIMPLE_CRUISE = 'simple_cruise' + + class ProblemType(Enum): """ ProblemType is used to switch between different combinations of