# This file is part of FAST-OAD_CS23-HE : A framework for rapid Overall Aircraft Design of Hybrid
# Electric Aircraft.
# Copyright (C) 2025 ISAE-SUPAERO
import openmdao.api as om
import numpy as np
import fastoad.api as oad
from fastoad.module_management.constants import ModelDomain
from fastoad.openmdao.problem import AutoUnitsDefaultGroup
from fastga_he.command.api import list_inputs_metadata
from fastga_he.powertrain_builder.powertrain import FASTGAHEPowerTrainConfigurator, PT_DATA_PREFIX
from fastga_he.models.performances.op_mission_vector.op_mission_vector import (
OperationalMissionVector,
)
from fastga_he.models.performances.mission_vector.constants import (
HE_SUBMODEL_ENERGY_CONSUMPTION,
HE_SUBMODEL_DEP_EFFECT,
)
from .mission_range_from_soc import OperationalMissionVectorWithTargetSoC
from .mission_range_from_fuel import OperationalMissionVectorWithTargetFuel
[docs]
@oad.RegisterOpenMDAOSystem("fastga_he.payload_range.outer", domain=ModelDomain.PERFORMANCE)
class ComputePayloadRange(om.ExplicitComponent):
"""
Computation of the characteristic points of the payload-range diagram. Will use the
operational mission module and inputs to compute the different points.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.configurator = FASTGAHEPowerTrainConfigurator()
self._input_zip = None
self.cached_problem = None
[docs]
def initialize(self):
self.options.declare(
name="power_train_file_path",
default="",
desc="Path to the file containing the description of the power",
)
[docs]
def setup(self):
# I'm not really happy with doing it here, but for that model to work we need to ensure
# those submodels are active
oad.RegisterSubmodel.active_models[HE_SUBMODEL_ENERGY_CONSUMPTION] = (
"fastga_he.submodel.performances.energy_consumption.from_pt_file"
)
oad.RegisterSubmodel.active_models[HE_SUBMODEL_DEP_EFFECT] = (
"fastga_he.submodel.performances.dep_effect.from_pt_file"
)
self.configurator.load(self.options["power_train_file_path"])
self._input_zip = zip_op_mission_input(self.options["power_train_file_path"])
for (
var_names,
var_unit,
var_value,
var_shape,
var_shape_by_conn,
var_copy_shape,
) in self._input_zip:
var_prefix = var_names.split(":")[0]
if var_prefix == "data" or var_prefix == "settings" or var_prefix == "convergence":
if var_shape_by_conn:
self.add_input(
name=var_names,
val=np.nan,
units=var_unit,
shape_by_conn=var_shape_by_conn,
copy_shape=var_copy_shape,
)
else:
self.add_input(
name=var_names,
val=var_value,
units=var_unit,
shape=var_shape,
)
if self.configurator.will_aircraft_mass_vary():
tank_names, tank_types = self.configurator.get_fuel_tank_list()
for tank_name, tank_type in zip(tank_names, tank_types):
self.add_input(
name=PT_DATA_PREFIX + tank_type + ":" + tank_name + ":capacity",
val=np.nan,
units="kg",
)
else:
self.add_input("data:mission:payload_range:threshold_SoC", val=np.nan, units="percent")
self.add_input("data:weight:aircraft:max_payload", val=np.nan, units="kg")
self.add_input("data:weight:aircraft:MTOW", val=np.nan, units="kg")
self.add_input(
"data:mission:payload_range:carbon_intensity_fuel",
val=3.81,
desc="Carbon intensity of the fuel in kgCO2 per kg of fuel",
)
self.add_input(
"data:mission:payload_range:carbon_intensity_electricity", val=72.7, units="g/MJ"
)
self.add_output("data:mission:payload_range:range", val=1.0, units="NM", shape=4)
self.add_output("data:mission:payload_range:payload", val=1.0, units="kg", shape=4)
self.add_output(
"data:mission:payload_range:emission_factor",
val=1.0,
shape=4,
desc="Emission factor in kgCO2 per kg of payload per km",
)
self.declare_partials(of="*", wrt="*", method="exact")
[docs]
def compute(self, inputs, outputs, discrete_inputs=None, discrete_outputs=None):
self._input_zip = zip_op_mission_input(self.options["power_train_file_path"])
ivc = om.IndepVarComp()
for var_names, var_unit, _, _, _, _ in self._input_zip:
var_prefix = var_names.split(":")[0]
if var_prefix == "data" or var_prefix == "settings" or var_prefix == "convergence":
if var_names != "data:mission:operational:range":
ivc.add_output(
name=var_names,
val=inputs[var_names],
units=var_unit,
shape=np.shape(inputs[var_names]),
)
# Add it manually
if not self.configurator.will_aircraft_mass_vary():
ivc.add_output(
name="data:mission:payload_range:threshold_SoC",
val=inputs["data:mission:payload_range:threshold_SoC"],
units="percent",
shape=np.shape(inputs["data:mission:payload_range:threshold_SoC"]),
)
else:
tank_names, tank_types = self.configurator.get_fuel_tank_list()
mfw = 0.0
for tank_name, tank_type in zip(tank_names, tank_types):
mfw += inputs[PT_DATA_PREFIX + tank_type + ":" + tank_name + ":capacity"]
self.cached_problem = om.Problem(reports=False)
model = self.cached_problem.model
model.add_subsystem("ivc", ivc, promotes_outputs=["*"])
if self.configurator.will_aircraft_mass_vary():
model.add_subsystem(
"op_mission",
OperationalMissionVectorWithTargetFuel(
number_of_points_climb=30,
number_of_points_cruise=30,
number_of_points_descent=20,
number_of_points_reserve=10,
power_train_file_path=self.options["power_train_file_path"],
pre_condition_pt=True,
use_linesearch=False,
use_apply_nonlinear=False,
variable_name_threshold_fuel="data:mission:payload_range:target_fuel",
),
promotes=["*"],
)
else:
model.add_subsystem(
"op_mission",
OperationalMissionVectorWithTargetSoC(
number_of_points_climb=30,
number_of_points_cruise=30,
number_of_points_descent=20,
number_of_points_reserve=10,
power_train_file_path=self.options["power_train_file_path"],
pre_condition_pt=True,
use_linesearch=False,
use_apply_nonlinear=False,
variable_name_target_SoC="data:propulsion:he_power_train:battery_pack:battery_pack_1:SOC_min",
variable_name_threshold_SoC="data:mission:payload_range:threshold_SoC",
),
promotes=["*"],
)
# TODO: find a way to do this that doesn't involve accessing private attribute
self.cached_problem.model_options = self._problem_meta["model_options"]
self.cached_problem.setup()
# There are four points in the payload range as computed by this framework. Points A, B,
# D and E. Yes, I know.
range_array = np.zeros(4)
payload_array = np.zeros(4)
ef_array = np.zeros(4)
max_payload = inputs["data:weight:aircraft:max_payload"].item()
mtow = inputs["data:weight:aircraft:MTOW"].item()
owe = inputs["data:weight:aircraft:OWE"].item()
carbon_intensity_fuel = inputs["data:mission:payload_range:carbon_intensity_fuel"]
carbon_intensity_electricity = (
inputs["data:mission:payload_range:carbon_intensity_electricity"] / 1000.0
) # In kgCO2 per MJ
# Point A correspond to max payload, no range. The emission factor is not really defined
# here since emissions are nil but so is the distances
payload_array[0] = max_payload
ef_array[0] = 0
# Point B correspond to max payload and the range that leads to the MTOW. On an electric
# aircraft this correspond to the design point
payload_array[1] = max_payload
self.cached_problem.set_val(
"data:mission:operational:payload:mass",
max_payload,
units="kg",
)
if self.configurator.will_aircraft_mass_vary():
target_fuel = max(mtow - owe - max_payload, 0.0)
self.cached_problem.set_val(
"data:mission:payload_range:target_fuel", target_fuel, units="kg"
)
self.cached_problem.run_model()
range_point_b = self.cached_problem.get_val(
"data:mission:operational:range", units="NM"
)[0]
range_array[1] = range_point_b
else:
# The threshold value is already set we can just go ahead and compute the corresponding
# range
self.cached_problem.run_model()
range_point_b = self.cached_problem.get_val(
"data:mission:operational:range", units="NM"
)[0]
range_array[1] = range_point_b
emissions_point_b = (
self.cached_problem.get_val("data:mission:operational:fuel", units="kg")[0]
* carbon_intensity_fuel
+ self.cached_problem.get_val("data:mission:operational:energy", units="kW*h")[0]
* 3.6
* carbon_intensity_electricity
)
emission_factor_b = emissions_point_b / (range_point_b * 1.852) / max_payload
ef_array[1] = emission_factor_b.item()
# Point D corresponds to MFW and MTOW. On an electric aircraft this point is the same as
# the previous one, so we will simply not recompute it.
if self.configurator.will_aircraft_mass_vary():
payload = mtow - mfw - owe
self.cached_problem.set_val(
"data:mission:operational:payload:mass",
payload,
units="kg",
)
self.cached_problem.set_val("data:mission:payload_range:target_fuel", mfw, units="kg")
self.cached_problem.run_model()
range_point_d = self.cached_problem.get_val(
"data:mission:operational:range", units="NM"
)[0]
payload_array[2] = payload.item()
range_array[2] = range_point_d
emissions_point_d = (
self.cached_problem.get_val("data:mission:operational:fuel", units="kg")[0]
* carbon_intensity_fuel
+ self.cached_problem.get_val("data:mission:operational:energy", units="kW*h")[0]
* 3.6
* carbon_intensity_electricity
)
emission_factor_d = emissions_point_d / (range_point_d * 1.852) / payload
ef_array[2] = emission_factor_d.item()
else:
payload_array[2] = payload_array[1]
range_array[2] = range_array[1]
ef_array[2] = ef_array[1]
# Point E correspond to no payload and MFW. On an electric aircraft, it's simply no
# payload. Here, the emission factor is not defined as the payload goes to 0 so we will
# have an emission factor of 0.
self.cached_problem.set_val(
"data:mission:operational:payload:mass",
0.0,
units="kg",
)
self.cached_problem.run_model()
range_point_e = self.cached_problem.get_val("data:mission:operational:range", units="NM")[0]
payload_array[3] = 0.0
range_array[3] = range_point_e
ef_array[3] = 0.0
outputs["data:mission:payload_range:range"] = range_array
outputs["data:mission:payload_range:payload"] = payload_array
outputs["data:mission:payload_range:emission_factor"] = ef_array