Source code for fastoad.models.performances.mission.openmdao.payload_range
"""Payload-Range diagram computation."""
# This file is part of FAST-OAD : A framework for rapid Overall Aircraft Design
# Copyright (C) 2023 ONERA & ISAE-SUPAERO
# FAST is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from dataclasses import dataclass
from typing import Dict
import numpy as np
import openmdao.api as om
from pyDOE3 import lhs
from scipy.interpolate import interp1d
from fastoad.module_management.constants import ModelDomain
from fastoad.module_management.service_registry import RegisterOpenMDAOSystem
from fastoad.openmdao.problem import get_variable_list_from_system
from .base import BaseMissionComp, NeedsMFW, NeedsMTOW, NeedsOWE
from .mission_run import MissionComp
[docs]@RegisterOpenMDAOSystem("fastoad.performances.payload_range", domain=ModelDomain.PERFORMANCE)
class PayloadRange(om.Group, BaseMissionComp, NeedsOWE, NeedsMTOW, NeedsMFW):
"""OpenMDAO component for computing data for payload-range plots."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._contour_names = None
self._grid_names = None
[docs] def initialize(self):
super().initialize()
self.options.declare(
"nb_contour_points",
default=4,
types=int,
lower=4,
desc='If >4, additional points are used in the final "MFW slope" of '
"the diagram contour.",
)
self.options.declare(
"nb_grid_points",
default=0,
types=int,
lower=0,
desc="If >0, the provided number of points inside the payload-range "
"contour will be computed.",
)
self.options.declare(
"grid_random_seed",
default=None,
types=int,
allow_none=True,
desc="Used as random state for initializing the Latin Hypercube Sampling "
"algorithm for generating the inner grid.",
)
self.options.declare(
"grid_lhs_criterion",
default="maximin",
types=str,
allow_none=True,
desc="Criterion for the Latin Hypercube Sampling algorithm, as asked by pyDOE2.lhs.",
)
self.options.declare(
"min_payload_ratio",
default=0.3,
lower=0.0,
upper=0.9,
desc="Sets the minimum payload for inner grid points, as a ratio w.r.t. max payload.",
)
self.options.declare(
"min_block_fuel_ratio",
default=0.3,
lower=0.0,
upper=0.9,
desc="Sets the minimum block fuel for inner grid points, as a ratio w.r.t. max "
"possible fuel weight for the current payload.",
)
# This one is declared again to change default value
self.options.declare(
"variable_prefix",
default="data:payload_range",
types=str,
check_valid=self._update_mission_wrapper,
desc="How auto-generated names of variables should begin.",
)
[docs] def setup(self):
super().setup()
self._contour_names = _VariableNamer(
self.options["variable_prefix"], self.mission_name, grid=False
)
self._grid_names = _VariableNamer(
self.options["variable_prefix"], self.mission_name, grid=True
)
self._add_payload_range_contour_group()
if self.options["nb_grid_points"] > 0:
self._add_payload_range_grid_group()
def _update_mission_wrapper(self, name, value):
super()._update_mission_wrapper(name, value)
if self._mission_wrapper is not None:
self._mission_wrapper.force_all_block_fuel_usage()
def _add_payload_range_contour_group(self):
"""Creates the group for computing payload-range contour."""
nb_contour_points = self.options["nb_contour_points"]
group = om.Group()
group.add_subsystem(
"input_values",
PayloadRangeContourInputValues(
mission_name=self.mission_name,
nb_points=nb_contour_points,
PR_variable_prefix=self.variable_prefix,
),
promotes=["*"],
)
var_connections = {"block_fuel": "block_fuel", "TOW": "TOW"}
self._add_mission_runs(group, nb_contour_points, var_connections, "mux_contour")
mux_comp = group.add_subsystem(
name="mux_contour", subsys=om.MuxComp(vec_size=nb_contour_points)
)
mux_comp.add_var("range", shape=(1,), axis=0, units="m")
mux_comp.add_var("duration", shape=(1,), axis=0, units="s")
group.promotes(
"mux_contour",
outputs=[
("range", self._contour_names.range),
("duration", self._contour_names.duration),
],
)
self.add_subsystem(
"contour_calc",
group,
promotes_inputs=["*"],
promotes_outputs=[
self._contour_names.block_fuel,
self._contour_names.payload,
self._contour_names.TOW,
self._contour_names.range,
self._contour_names.duration,
],
)
return group
def _add_payload_range_grid_group(self):
"""Creates the group for computing payload-range inner grid values."""
nb_grid_points = self.options["nb_grid_points"]
group = om.Group()
# Build grid inputs
group.add_subsystem(
"input_values",
PayloadRangeGridInputValues(
mission_name=self.mission_name,
nb_points=nb_grid_points,
PR_variable_prefix=self.variable_prefix,
random_seed=self.options["grid_random_seed"],
lhs_criterion=self.options["grid_lhs_criterion"],
min_payload_ratio=self.options["min_payload_ratio"],
min_block_fuel_ratio=self.options["min_block_fuel_ratio"],
),
promotes=["*"],
)
# Run computations
var_connections = {"grid:block_fuel": "block_fuel", "grid:TOW": "TOW"}
self._add_mission_runs(group, nb_grid_points, var_connections, "mux_grid")
# Assemble mission results in variables
# 2 points are added to include the 2 MTOW points of the contour in the grid points.
# (These 2 points are already added in grid inputs by PayloadRangeGridInputValues)
mux_comp = group.add_subsystem(
name="mux_grid", subsys=om.MuxComp(vec_size=nb_grid_points + 2)
)
mux_comp.add_var("range", shape=(1,), axis=0, units="m")
mux_comp.add_var("duration", shape=(1,), axis=0, units="s")
group.promotes(
"mux_grid",
outputs=[
("range", self._grid_names.range),
("duration", self._grid_names.duration),
],
)
# Adding the results of the 2 MTOW points of the contour.
for i in range(2):
self.connect(
self._contour_names.range,
f"mux_grid.range_{nb_grid_points+i}",
src_indices=[i + 1],
)
self.connect(
self._contour_names.duration,
f"mux_grid.duration_{nb_grid_points+i}",
src_indices=om.slicer[i + 1],
)
# Computation of specific burned fuel
group.add_subsystem(
"sbf_comp",
om.ExecComp(
"sbf = block_fuel / range / payload",
block_fuel={"units": "kg", "shape_by_conn": True},
range={"units": "NM", "copy_shape": "block_fuel"},
payload={"units": "kg", "copy_shape": "block_fuel"},
sbf={"units": "NM**-1", "copy_shape": "block_fuel"},
),
promotes_inputs=[
("payload", self._grid_names.payload),
("range", self._grid_names.range),
("block_fuel", self._grid_names.block_fuel),
],
promotes_outputs=[("sbf", self._grid_names.specific_burned_fuel)],
)
# Adding the whole group
self.add_subsystem(
"grid_calc",
group,
promotes_inputs=["*"],
promotes_outputs=[
self._grid_names.block_fuel,
self._grid_names.payload,
self._grid_names.TOW,
self._grid_names.range,
self._grid_names.duration,
self._grid_names.specific_burned_fuel,
],
)
return group
def _add_mission_runs(
self, group: om.Group, nb_missions: int, input_var_connections: Dict[str, str], mux_name
):
"""Adds MissionRun components to the provided group."""
input_var_connections = {
self._contour_names.get_variable_name(
name1
): f"data:mission:{self.mission_name}:{name2}"
for name1, name2 in input_var_connections.items()
}
mission_options = {
key: val for key, val in self.options.items() if key in MissionComp().options
}
# We don't want to use the same mission wrapper because we modify
# its variable prefix.
mission_options["mission_file_path"] = self._mission_wrapper.definition
mission_options["variable_prefix"] = "data:mission"
mission_inputs = get_variable_list_from_system(
MissionComp(**mission_options), io_status="inputs"
)
for i in range(nb_missions):
subsys_name = f"mission_{i}"
group.add_subsystem(subsys_name, MissionComp(**mission_options))
# Connect block_fuel and TOW mission inputs to contour inputs
for payload_range_var, mission_var in input_var_connections.items():
group.connect(
payload_range_var,
f"{subsys_name}.{mission_var}",
src_indices=[i],
)
# All other inputs are promoted.
promoted_inputs = [
variable.name
for variable in mission_inputs
if variable.name not in input_var_connections.values()
]
group.promotes(subsys_name, inputs=promoted_inputs)
group.connect(
f"{subsys_name}.data:mission:{self.mission_name}:{self.first_route_name}:distance",
f"{mux_name}.range_{i}",
)
group.connect(
f"{subsys_name}.data:mission:{self.mission_name}:{self.first_route_name}:duration",
f"{mux_name}.duration_{i}",
)
[docs]class PayloadRangeContourInputValues(
om.ExplicitComponent, BaseMissionComp, NeedsOWE, NeedsMTOW, NeedsMFW
):
"""
This class provides input values for missions that will compute the contour
of the payload-range diagram.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._names = None
[docs] def initialize(self):
super().initialize()
self.options.declare(
"nb_points",
default=4,
types=int,
lower=4,
desc='If >4, additional points are used in the final "MFW slope" of the diagram.',
)
self.options.declare(
"PR_variable_prefix",
default="data:payload_range",
types=str,
desc="How auto-generated names of payload-range variables should begin.",
)
[docs] def setup(self):
super().setup()
self._names = _VariableNamer(
self.options["PR_variable_prefix"], self.mission_name, grid=False
)
nb_points = self.options["nb_points"]
self.add_input("data:weight:aircraft:max_payload", val=np.nan, units="kg")
self.add_input(self.options["MTOW_variable"], val=np.nan, units="kg")
self.add_input(self.options["OWE_variable"], val=np.nan, units="kg")
self.add_input(self.options["MFW_variable"], val=np.nan, units="kg")
self.add_input(
self.name_provider.CONSUMED_FUEL_BEFORE_INPUT_WEIGHT.value,
val=np.nan,
units="kg",
)
self.add_output(self._names.payload, shape=(nb_points,), units="kg")
self.add_output(self._names.block_fuel, shape=(nb_points,), units="kg")
self.add_output(self._names.TOW, shape=(nb_points,), units="kg")
[docs] def compute(self, inputs, outputs, discrete_inputs=None, discrete_outputs=None):
nb_points = self.options["nb_points"]
max_payload = inputs["data:weight:aircraft:max_payload"].item()
payload_at_max_takeoff_weight_and_max_fuel_weight = (
self._calculate_payload_at_max_takeoff_weight_and_max_fuel_weight(inputs)
)
payload_values = outputs[self._names.payload]
block_fuel_values = outputs[self._names.block_fuel]
tow_values = outputs[self._names.TOW]
payload_values[0:2] = max_payload
payload_values[2:] = np.linspace(
payload_at_max_takeoff_weight_and_max_fuel_weight.squeeze(), 0.0, nb_points - 2
)
block_fuel_values[0] = 0.0
block_fuel_values[1] = self._calculate_block_fuel_at_max_takeoff_weight(inputs, max_payload)
block_fuel_values[2:] = inputs[self.options["MFW_variable"]].item()
tow_values[:2] = inputs[self.options["MTOW_variable"]].item()
tow_values[2:] = self._calculate_takeoff_weight_at_max_fuel_weight(
inputs, payload_values[2:]
)
def _calculate_block_fuel_at_max_takeoff_weight(self, inputs, payload):
fuel_at_takeoff = (
inputs[self.options["MTOW_variable"]].item()
- payload
- inputs[self.options["OWE_variable"]].item()
)
block_fuel_at_max_takeoff_weight = (
fuel_at_takeoff
+ inputs[self.name_provider.CONSUMED_FUEL_BEFORE_INPUT_WEIGHT.value].item()
)
return block_fuel_at_max_takeoff_weight
def _calculate_takeoff_weight_at_max_fuel_weight(self, inputs, payload):
fuel_at_takeoff = (
inputs[self.options["MFW_variable"]].item()
- inputs[self.name_provider.CONSUMED_FUEL_BEFORE_INPUT_WEIGHT.value].item()
)
takeoff_weight_at_max_fuel_weight = (
fuel_at_takeoff + payload + inputs[self.options["OWE_variable"]].item()
)
return takeoff_weight_at_max_fuel_weight
def _calculate_payload_at_max_takeoff_weight_and_max_fuel_weight(self, inputs):
consumed_fuel_before_takeoff = inputs[
self.name_provider.CONSUMED_FUEL_BEFORE_INPUT_WEIGHT.value
]
fuel_at_takeoff = inputs[self.options["MFW_variable"]].item() - consumed_fuel_before_takeoff
payload_at_max_takeoff_weight_and_max_fuel_weight = (
inputs[self.options["MTOW_variable"]].item()
- fuel_at_takeoff
- inputs[self.options["OWE_variable"]].item()
)
return payload_at_max_takeoff_weight_and_max_fuel_weight
[docs]class PayloadRangeGridInputValues(om.ExplicitComponent, BaseMissionComp, NeedsOWE):
"""
This class provides input values for missions that will compute points inside the contour
of the payload-range diagram.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._contour_names = None
self._grid_names = None
[docs] def initialize(self):
super().initialize()
self.options.declare(
"nb_points",
default=20,
types=int,
desc='If >4, additional points are used in the final "MFW slope" of the diagram.',
)
self.options.declare(
"PR_variable_prefix",
default="data:payload_range",
types=str,
desc="How auto-generated names of payload-range variables should begin.",
)
self.options.declare(
"random_seed",
default=None,
types=int,
allow_none=True,
desc="Used as random state for initializing the Latin Hypercube Sampling "
"algorithm for generating the inner grid.",
)
self.options.declare(
"lhs_criterion",
default="maximin",
types=str,
allow_none=True,
desc="Criterion for the Latin Hypercube Sampling algorithm, as asked by pyDOE2.lhs.",
)
self.options.declare(
"min_payload_ratio",
default=0.3,
lower=0.0,
upper=0.9,
desc="Sets the minimum payload for inner grid points, as a ratio w.r.t. max payload.",
)
self.options.declare(
"min_block_fuel_ratio",
default=0.3,
lower=0.0,
upper=0.9,
desc="Sets the minimum block fuel for inner grid points, as a ratio w.r.t. max "
"possible fuel weight for the current payload.",
)
[docs] def setup(self):
super().setup()
self._contour_names = _VariableNamer(
self.options["PR_variable_prefix"], self.mission_name, grid=False
)
self._grid_names = _VariableNamer(
self.options["PR_variable_prefix"], self.mission_name, grid=True
)
nb_points = self.options["nb_points"]
self.add_input(self._contour_names.payload, val=np.nan, shape_by_conn=True, units="kg")
self.add_input(self._contour_names.block_fuel, val=np.nan, shape_by_conn=True, units="kg")
self.add_input(self.options["OWE_variable"], val=np.nan, units="kg")
self.add_input(
self.name_provider.CONSUMED_FUEL_BEFORE_INPUT_WEIGHT.value, val=np.nan, units="kg"
)
self.add_output(self._grid_names.payload, shape=(nb_points + 2,), units="kg")
self.add_output(self._grid_names.block_fuel, shape=(nb_points + 2,), units="kg")
self.add_output(self._grid_names.TOW, shape=(nb_points + 2,), units="kg")
[docs] def compute(self, inputs, outputs, discrete_inputs=None, discrete_outputs=None):
min_payload_ratio = self.options["min_payload_ratio"]
min_block_fuel_ratio = self.options["min_block_fuel_ratio"]
payload_contour_values = inputs[self._contour_names.payload]
block_fuel_contour_values = inputs[self._contour_names.block_fuel]
max_payload = payload_contour_values[0]
get_max_block_fuel = interp1d(payload_contour_values[1:], block_fuel_contour_values[1:])
lhs_grid = lhs(
2,
criterion=self.options["lhs_criterion"],
samples=(self.options["nb_points"]),
random_state=self.options["random_seed"],
)
payload_values = (
((min_payload_ratio + (1.0 - min_payload_ratio) * lhs_grid[:, 1]) * max_payload),
)
block_fuel_values = (
min_block_fuel_ratio + (1.0 - min_block_fuel_ratio) * lhs_grid[:, 0]
) * get_max_block_fuel(payload_values)
payload_values = np.append(payload_values, payload_contour_values[1:3])
block_fuel_values = np.append(block_fuel_values, block_fuel_contour_values[1:3])
outputs[self._grid_names.payload] = payload_values
outputs[self._grid_names.block_fuel] = block_fuel_values
outputs[self._grid_names.TOW] = self._calculate_takeoff_weight(
inputs, payload_values, block_fuel_values
)
def _calculate_takeoff_weight(self, inputs, payload, block_fuel):
fuel_at_takeoff = (
block_fuel - inputs[self.name_provider.CONSUMED_FUEL_BEFORE_INPUT_WEIGHT.value].item()
)
takeoff_weight_at_max_fuel_weight = (
fuel_at_takeoff + payload + inputs[self.options["OWE_variable"]].item()
)
return takeoff_weight_at_max_fuel_weight
@dataclass
class _VariableNamer:
"""Provides variable names."""
variable_prefix: str
mission_name: str
grid: bool = False
def get_variable_name(self, suffix: str):
"""returns variable name w.r.t. provided instance attributes."""
grid_part = ":grid" if self.grid else ""
return f"{self.variable_prefix}:{self.mission_name}{grid_part}:{suffix}"
# Here we add properties to _VariableNamer for convenience
def _get_property(suffix):
def prop(self):
return self.get_variable_name(suffix)
return property(prop)
for main_name in [
"payload",
"block_fuel",
"range",
"duration",
"TOW",
"specific_burned_fuel",
]:
setattr(_VariableNamer, main_name, _get_property(main_name))