Skip to content

Commit

Permalink
Merge pull request #467 from fast-aircraft-design/issue-438_mission-f…
Browse files Browse the repository at this point in the history
…uel-as-objective

Mission fuel as objective
  • Loading branch information
ScottDelbecq committed Nov 22, 2022
2 parents 0a98f52 + 7bdc068 commit 8dfa966
Show file tree
Hide file tree
Showing 21 changed files with 1,708 additions and 471 deletions.
21 changes: 18 additions & 3 deletions docs/documentation/mission_module/mission_file/mission_file.rst
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,16 @@ Mission definition section
This is the main section. It allows to define one or several missions, that will be computed
by the mission module.

A mission is identified by its name and has only the :code:`parts` attribute that lists the
:ref:`phase<phase-section>` and/or :ref:`route<route-section>` names that compose the mission, with
optionally a last item that is the :code:`reserve` (see below).
A mission is identified by its name and has 3 attributes:

- :code:`parts`: list of the :ref:`phase<phase-section>` and/or :ref:`route<route-section>`
names that compose the mission, with optionally a last item that is the :code:`reserve`
(see below).
- :code:`use_all_block_fuel`: if True, the range of the main :ref:`route <route-section>`
of the mission will be adjusted so that all block fuel (provided as input
`data:mission:<mission_name>:block_fuel`) will be consumed for the mission, excepted the
reserve, if defined. The provided range for first route is overridden but used as a first guess
to initiate the iterative process.


The mission name is used when configuring the mission module in the FAST-OAD configuration file.
Expand Down Expand Up @@ -248,6 +255,14 @@ Example:
- route: main_route
- phase: landing
- phase: taxi_in
fuel_driven:
parts:
- phase: taxi_out
- phase: takeoff
- route: main_route
- phase: landing
- phase: taxi_in
use_all_block_fuel: true
Expand Down
38 changes: 23 additions & 15 deletions src/fastoad/models/performances/mission/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,28 @@ def compute_from(self, start: FlightPoint) -> pd.DataFrame:


@dataclass
class FlightSequence(IFlightPart):
class FlightSequence(IFlightPart, list):
"""
Defines and computes a flight sequence.
"""
#: List of IFlightPart instances that should be run sequentially.
flight_sequence: List[IFlightPart] = field(default_factory=list)
Use .extend() method to add a list of parts in the sequence.
"""

#: Consumed mass between sequence start and target mass, if any defined
consumed_mass_before_input_weight: float = field(default=0.0, init=False)

#: List of flight points for each part of the sequence, obtained after
# running :meth:`compute_from`
part_flight_points: List[pd.DataFrame] = field(default_factory=list, init=False)

def compute_from(self, start: FlightPoint) -> pd.DataFrame:
parts = []
self.part_flight_points = []
part_start = deepcopy(start)
part_start.scalarize()

self.consumed_mass_before_input_weight = 0.0
consumed_mass = 0.0
for part in self.flight_sequence:
for part in self:
# This check has to be done first because relative target parameters
# will be made absolute during compute_from()
part_has_target_mass = not (part.target.mass is None or part.target.is_relative("mass"))
Expand All @@ -81,39 +84,44 @@ def compute_from(self, start: FlightPoint) -> pd.DataFrame:
# (mass consumption of previous parts is assumed independent of aircraft mass)
if part_has_target_mass:
mass_offset = flight_points.iloc[0].mass - part_start.mass
for previous_flight_points in parts:
for previous_flight_points in self.part_flight_points:
previous_flight_points.mass += mass_offset
self.consumed_mass_before_input_weight = float(consumed_mass)

if len(parts) > 0 and len(flight_points) > 1:
if len(self.part_flight_points) > 0 and len(flight_points) > 1:
# First point of the segment is omitted, as it is the last of previous segment.
#
# But sometimes (especially in the case of simplistic segments), the new first
# point may contain more information than the previous last one. In such case,
# it is interesting to complete the previous last one.
last_flight_points = parts[-1]
last_flight_points = self.part_flight_points[-1]
last_index = last_flight_points.index[-1]
for name in flight_points.columns:
value = last_flight_points.loc[last_index, name]
if not value:
last_flight_points.loc[last_index, name] = flight_points.loc[0, name]

parts.append(flight_points.iloc[1:])
self.part_flight_points.append(flight_points.iloc[1:])

else:
# But it is kept if the computed segment is the first one.
parts.append(flight_points)
self.part_flight_points.append(flight_points)

part_start = FlightPoint.create(flight_points.iloc[-1])
part_start.scalarize()

if parts:
return pd.concat(parts).reset_index(drop=True)
if self.part_flight_points:
return pd.concat(self.part_flight_points).reset_index(drop=True)

@property
def target(self) -> Optional[FlightPoint]:
"""Target of the last element of current sequence."""
if len(self.flight_sequence) > 0:
return self.flight_sequence[-1].target
if len(self) > 0:
return self[-1].target

return None

def __add__(self, other):
result = self.__class__()
result.extend(other)
return result
160 changes: 160 additions & 0 deletions src/fastoad/models/performances/mission/mission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""Definition of aircraft mission."""

# This file is part of FAST-OAD : A framework for rapid Overall Aircraft Design
# Copyright (C) 2022 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 copy import deepcopy
from dataclasses import dataclass, field
from typing import Optional

import pandas as pd
from scipy.optimize import root_scalar

from fastoad.model_base import FlightPoint
from fastoad.models.performances.mission.base import FlightSequence
from fastoad.models.performances.mission.routes import RangedRoute
from fastoad.models.performances.mission.segments.cruise import CruiseSegment


@dataclass
class Mission(FlightSequence):
"""
Computes a whole mission.
Allows to define a target fuel consumption for the whole mission.
"""

#: If not None, the mission will adjust the first
target_fuel_consumption: Optional[float] = None

reserve_ratio: Optional[float] = 0.0
reserve_base_route_name: Optional[str] = None

#: Accuracy on actual consumed fuel for the solver. In kg
fuel_accuracy: float = 10.0

_flight_points: Optional[pd.DataFrame] = field(init=False, default=None)
_first_cruise_segment: Optional[CruiseSegment] = field(init=False, default=None)

@property
def consumed_fuel(self) -> Optional[float]:
"""Total consumed fuel for the whole mission (after launching :meth:`compute_from`)"""
if self._flight_points is None:
return None

return (
self._flight_points.mass.iloc[0]
- self._flight_points.mass.iloc[-1]
+ self.get_reserve_fuel()
)

@property
def first_route(self) -> RangedRoute:
"""First route in the mission."""
return self._get_first_route_in_sequence(self)

def _get_first_route_in_sequence(
self, flight_sequence: FlightSequence
) -> Optional[RangedRoute]:
for part in flight_sequence:
if isinstance(part, RangedRoute):
return part
if isinstance(part, FlightSequence):
route = self._get_first_route_in_sequence(part)
if route:
return route

return None

def compute_from(self, start: FlightPoint) -> pd.DataFrame:
if self.target_fuel_consumption is None:
flight_points = super().compute_from(start)
flight_points.name.loc[flight_points.name.isnull()] = ""
self._compute_reserve(flight_points)
return flight_points

return self._solve_cruise_distance(start)

def get_reserve_fuel(self):
""":returns: the fuel quantity for reserve, obtained after mission computation."""
if not self.reserve_ratio:
return 0.0

reserve_points = self.part_flight_points[-1]

return reserve_points.iloc[0].mass - reserve_points.iloc[-1].mass

def _get_consumed_mass_in_route(self, route_name: str) -> float:
route = [part for part in self if part.name == route_name][0]
route_idx = self.index(route)
route_points = self.part_flight_points[route_idx]
return route_points.mass.iloc[0] - route_points.mass.iloc[-1]

def _compute_reserve(self, flight_points):
"""Adds a "reserve part" in self.part_flight_points."""
if self.reserve_ratio > 0.0:
if self.reserve_base_route_name is None:
base_route_name = self.first_route.name
else:
base_route_name = self.reserve_base_route_name

reserve_fuel = self.reserve_ratio * self._get_consumed_mass_in_route(base_route_name)
reserve_points = pd.DataFrame(
[
deepcopy(flight_points.iloc[-1]),
deepcopy(flight_points.iloc[-1]),
]
)

# We are not using -= here because it would operate last_flight_point.mass can be
# an array. The operator would operate element-wise and would modify the original
# flight point, despite the deepcopy.
reserve_points.mass.iloc[-1] = reserve_points.mass.iloc[0] - reserve_fuel
reserve_points["name"] = f"{self.name}:reserve"

self.part_flight_points.append(reserve_points)

def _solve_cruise_distance(self, start: FlightPoint) -> pd.DataFrame:
"""
Adjusts cruise distance through a solver to have whole route that
matches provided consumed fuel.
"""

self.first_route.solve_distance = False
input_cruise_distance = self.first_route.flight_distance

root_scalar(
self._compute_flight,
args=(start,),
x0=input_cruise_distance * 0.5,
x1=input_cruise_distance * 1.0,
xtol=self.fuel_accuracy,
method="secant",
)

return self._flight_points

def _compute_flight(self, cruise_distance, start: FlightPoint):
"""
Computes flight for provided cruise distance
:param cruise_distance:
:param start:
:return: difference between computed fuel and self.target_fuel_consumption
"""
self.first_route.cruise_distance = cruise_distance
flight_points = super().compute_from(start)
flight_points.name.loc[flight_points.name.isnull()] = ""
self._compute_reserve(flight_points)
self._flight_points = flight_points
return self.target_fuel_consumption - self.consumed_fuel
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
SEGMENT_TAG,
)
from ...base import FlightSequence
from ...mission import Mission
from ...polar import Polar
from ...routes import RangedRoute
from ...segments.base import AbstractFlightSegment, SegmentDefinitions
Expand Down Expand Up @@ -113,7 +114,7 @@ def reference_area(self) -> float:
def reference_area(self, reference_area: float):
self._base_kwargs["reference_area"] = reference_area

def build(self, inputs: Optional[Mapping] = None, mission_name: str = None) -> FlightSequence:
def build(self, inputs: Optional[Mapping] = None, mission_name: str = None) -> Mission:
"""
Builds the flight sequence from definition file.
Expand Down Expand Up @@ -142,7 +143,7 @@ def get_route_ranges(
:param mission_name: mission name (can be omitted if only one mission is defined)
:return: list of flight ranges for each element of the flight sequence that is a route
"""
routes = self.build(inputs, mission_name).flight_sequence
routes = self.build(inputs, mission_name)
return [route.flight_distance for route in routes if isinstance(route, RangedRoute)]

def get_reserve(self, flight_points: pd.DataFrame, mission_name: str = None) -> float:
Expand Down Expand Up @@ -215,58 +216,43 @@ def get_input_weight_variable_name(self, mission_name: str) -> Optional[str]:
self._get_mission_part_structures(mission_name)
)

def get_mission_start_mass_input(self, mission_name: str) -> Optional[str]:
"""
:param mission_name:
:return: Target mass variable of first segment, if any.
"""
part = self._get_first_segment_structure(mission_name)
if "mass" in part["target"]:
return part["target"]["mass"].variable_name

return None

def _get_first_segment_structure(self, mission_name: str):
part = self._get_mission_part_structures(mission_name)[0]
while PARTS_TAG in part:
part = part[PARTS_TAG][0]
return part

def get_mission_part_names(self, mission_name: str) -> List[str]:
"""
:param mission_name:
:return: list of names of parts (phase or route) for specified mission.
"""
return [
part[NAME_TAG]
for part in self._get_mission_part_structures(mission_name)
if part.get(TYPE_TAG) in [ROUTE_TAG, PHASE_TAG]
]

def _build_mission(self, mission_structure: OrderedDict) -> FlightSequence:
def _build_mission(self, mission_structure: OrderedDict) -> Mission:
"""
Builds mission instance from provided structure.
:param mission_structure: structure of the mission to build
:return: the mission instance
"""
mission = FlightSequence()

part_kwargs = self._get_part_kwargs({}, mission_structure)

mission = Mission(target_fuel_consumption=part_kwargs.get("target_fuel_consumption"))

mission.name = mission_structure[NAME_TAG]
for part_spec in mission_structure[PARTS_TAG]:
if TYPE_TAG not in part_spec:
continue
if part_spec[TYPE_TAG] == SEGMENT_TAG:
part = self._build_segment(part_spec, part_kwargs)
if part_spec[TYPE_TAG] == ROUTE_TAG:
elif part_spec[TYPE_TAG] == ROUTE_TAG:
part = self._build_route(part_spec, part_kwargs)
elif part_spec[TYPE_TAG] == PHASE_TAG:
part = self._build_phase(part_spec, part_kwargs)
mission.flight_sequence.append(part)
else:
raise RuntimeError(
"Unknown part type. This error should have been prevented "
"by the JSON schema validation."
)
mission.append(part)

last_part = mission_structure[PARTS_TAG][-1]
if RESERVE_TAG in last_part:
mission.reserve_ratio = last_part[RESERVE_TAG]["multiplier"].value
base_route_name_definition = last_part[RESERVE_TAG].get("ref")
if base_route_name_definition:
mission.reserve_base_route_name = (
f"{mission.name}:{base_route_name_definition.value}"
)

return mission

Expand Down Expand Up @@ -338,7 +324,7 @@ def _build_phase(self, phase_structure: Mapping, kwargs: Mapping = None):
flight_part = self._build_phase(part_structure, part_kwargs)
else:
flight_part = self._build_segment(part_structure, part_kwargs)
phase.flight_sequence.append(flight_part)
phase.append(flight_part)

return phase

Expand Down

0 comments on commit 8dfa966

Please sign in to comment.