"""
Defines the analysis and plotting functions for postprocessing.
"""
# This file is part of FAST-OAD_CS23 : A framework for rapid Overall Aircraft Design
# Copyright (C) 2026 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 random import SystemRandom
import fastoad.api as oad
import numpy as np
import plotly
import plotly.graph_objects as go
from fastoad.io import VariableIO
from plotly.subplots import make_subplots
from fastga.models.aerodynamics.constants import FIRST_INVALID_COEFF
from .postprocessing_utils import _unit_conversion
COLS = plotly.colors.DEFAULT_PLOTLY_COLORS
[docs]def aircraft_geometry_plot(
aircraft_file_path: str,
name="",
fig=None,
plot_nacelle: bool = True,
file_formatter=None,
length_unit="m",
) -> go.FigureWidget:
"""
Returns a figure plot of the top view of the wing.
Different designs can be superposed by providing an existing fig.
Each design can be provided a name.
:param aircraft_file_path: path of data file
:param name: name to give to the trace added to the figure
:param fig: existing figure to which add the plot
:param plot_nacelle: boolean to turn on or off the plotting of the nacelles
:param file_formatter: the formatter that defines the format of data file. If not provided,
default format will be assumed.
:param length_unit: The length unit of the plot, meter is the default unit
:return: wing plot figure.
"""
variables = VariableIO(aircraft_file_path, file_formatter).read()
# Wing parameters
wing_kink_leading_edge_x = _unit_conversion(
variables["data:geometry:wing:kink:leading_edge:x:local"], length_unit
)
wing_tip_leading_edge_x = _unit_conversion(
variables["data:geometry:wing:tip:leading_edge:x:local"], length_unit
)
wing_root_y = _unit_conversion(variables["data:geometry:wing:root:y"], length_unit)
wing_kink_y = _unit_conversion(variables["data:geometry:wing:kink:y"], length_unit)
wing_tip_y = _unit_conversion(variables["data:geometry:wing:tip:y"], length_unit)
wing_root_chord = _unit_conversion(variables["data:geometry:wing:root:chord"], length_unit)
wing_kink_chord = _unit_conversion(variables["data:geometry:wing:kink:chord"], length_unit)
wing_tip_chord = _unit_conversion(variables["data:geometry:wing:tip:chord"], length_unit)
y_wing = np.array(
[0, wing_root_y, wing_kink_y, wing_tip_y, wing_tip_y, wing_kink_y, wing_root_y, 0, 0]
)
x_wing = np.array(
[
0,
0,
wing_kink_leading_edge_x,
wing_tip_leading_edge_x,
wing_tip_leading_edge_x + wing_tip_chord,
wing_kink_leading_edge_x + wing_kink_chord,
wing_root_chord,
wing_root_chord,
0,
]
)
# Horizontal Tail parameters
ht_root_chord = _unit_conversion(
variables["data:geometry:horizontal_tail:root:chord"], length_unit
)
ht_tip_chord = _unit_conversion(
variables["data:geometry:horizontal_tail:tip:chord"], length_unit
)
ht_span = _unit_conversion(variables["data:geometry:horizontal_tail:span"], length_unit)
ht_sweep_0 = _unit_conversion(variables["data:geometry:horizontal_tail:sweep_0"], "rad")
ht_tip_leading_edge_x = ht_span / 2.0 * np.tan(ht_sweep_0)
y_ht = np.array([0, ht_span / 2.0, ht_span / 2.0, 0.0, 0.0])
x_ht = np.array(
[0, ht_tip_leading_edge_x, ht_tip_leading_edge_x + ht_tip_chord, ht_root_chord, 0]
)
# Fuselage parameters
fuselage_max_width = _unit_conversion(
variables["data:geometry:fuselage:maximum_width"], length_unit
)
fuselage_length = _unit_conversion(variables["data:geometry:fuselage:length"], length_unit)
fuselage_front_length = _unit_conversion(
variables["data:geometry:fuselage:front_length"], length_unit
)
fuselage_rear_length = _unit_conversion(
variables["data:geometry:fuselage:rear_length"], length_unit
)
x_fuselage = np.array(
[
0.0,
0.0,
fuselage_front_length,
fuselage_length - fuselage_rear_length,
fuselage_length,
fuselage_length,
]
)
y_fuselage = np.array(
[
0.0,
fuselage_max_width / 4.0,
fuselage_max_width / 2.0,
fuselage_max_width / 2.0,
fuselage_max_width / 4.0,
0.0,
]
)
# CGs
wing_25mac_x = _unit_conversion(variables["data:geometry:wing:MAC:at25percent:x"], length_unit)
wing_mac_length = _unit_conversion(variables["data:geometry:wing:MAC:length"], length_unit)
local_wing_mac_le_x = _unit_conversion(
variables["data:geometry:wing:MAC:leading_edge:x:local"], length_unit
)
local_ht_25mac_x = _unit_conversion(
variables["data:geometry:horizontal_tail:MAC:at25percent:x:local"], length_unit
)
ht_distance_from_wing = _unit_conversion(
variables["data:geometry:horizontal_tail:MAC:at25percent:x:from_wingMAC25"], length_unit
)
x_wing = x_wing + wing_25mac_x - 0.25 * wing_mac_length - local_wing_mac_le_x
x_ht = x_ht + wing_25mac_x + ht_distance_from_wing - local_ht_25mac_x
# pylint: disable=invalid-name
# that's a common naming
x = np.concatenate((x_fuselage, x_wing, x_ht))
# pylint: disable=invalid-name
# that's a common naming
y = np.concatenate((y_fuselage, y_wing, y_ht))
# pylint: disable=invalid-name
# that's a common naming
y = np.concatenate((-y, y))
# pylint: disable=invalid-name
# that's a common naming
x = np.concatenate((x, x))
if fig is None:
fig = go.Figure()
scatter = go.Scatter(x=y, y=x, mode="lines+markers", name=name, showlegend=True)
fig.add_trace(scatter)
# Nacelle + propeller
prop_layout = variables["data:geometry:propulsion:engine:layout"].value[0]
nac_width = _unit_conversion(variables["data:geometry:propulsion:nacelle:width"], length_unit)
nac_length = _unit_conversion(variables["data:geometry:propulsion:nacelle:length"], length_unit)
prop_diam = _unit_conversion(variables["data:geometry:propeller:diameter"], length_unit)
if variables["data:geometry:propulsion:nacelle:y"].metadata["size"] == 1:
pos_y_nacelle = np.array(
[_unit_conversion(variables["data:geometry:propulsion:nacelle:y"], length_unit)]
)
pos_x_nacelle = np.array(
[_unit_conversion(variables["data:geometry:propulsion:nacelle:x"], length_unit)]
)
else:
pos_y_nacelle = _unit_conversion(
variables["data:geometry:propulsion:nacelle:y"], length_unit
)
pos_x_nacelle = _unit_conversion(
variables["data:geometry:propulsion:nacelle:x"], length_unit
)
if prop_layout == 1.0:
x_nacelle_plot = np.array([0.0, nac_length, nac_length, 0.0, 0.0, 0.0])
y_nacelle_plot = np.array(
[
-nac_width / 2,
-nac_width / 2,
nac_width / 2,
nac_width / 2,
prop_diam / 2,
-prop_diam / 2,
]
)
elif prop_layout == 3.0:
x_nacelle_plot = np.array([0.0, nac_length, nac_length, 0.0, 0.0, 0.0])
y_nacelle_plot = np.array(
[
max(-nac_width / 2, -fuselage_max_width / 4.0),
-nac_width / 2,
nac_width / 2,
min(nac_width / 2, fuselage_max_width / 4.0),
prop_diam / 2,
-prop_diam / 2,
]
)
else:
x_nacelle_plot = np.array([])
y_nacelle_plot = np.array([])
if plot_nacelle:
if prop_layout == 1.0:
random_generator = SystemRandom()
trace_colour = COLS[random_generator.randrange(0, len(COLS))]
show_legend = True
for y_nacelle_local, x_nacelle_local in zip(pos_y_nacelle, pos_x_nacelle):
y_nacelle_left = y_nacelle_plot + y_nacelle_local
y_nacelle_right = -y_nacelle_plot - y_nacelle_local
x_nacelle = x_nacelle_local - x_nacelle_plot
if show_legend:
scatter_right = go.Scatter(
x=y_nacelle_right,
y=x_nacelle,
name="right nacelle",
legendgroup=name + "nacelle",
mode="lines+markers",
line=dict(color=trace_colour),
legendgrouptitle_text=name + " nacelle + propeller",
)
fig.add_trace(scatter_right)
scatter_left = go.Scatter(
x=y_nacelle_left,
y=x_nacelle,
name="left nacelle",
legendgroup=name + "nacelle",
mode="lines+markers",
line=dict(color=trace_colour),
)
fig.add_trace(scatter_left)
show_legend = False
else:
scatter = go.Scatter(
x=y_nacelle_plot,
y=x_nacelle_plot,
mode="lines+markers",
name=name + " nacelle + propeller",
)
fig.add_trace(scatter)
fig.layout = go.Layout(yaxis=dict(scaleanchor="x", scaleratio=1))
fig = go.FigureWidget(fig)
fig.update_layout(
title_text="Aircraft Geometry",
title_x=0.5,
xaxis_title="y",
yaxis_title="x",
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99),
)
return fig
[docs]def evolution_diagram(
aircraft_file_path: str, name="", fig=None, file_formatter=None
) -> go.FigureWidget:
"""
Returns a figure plot of the V-N diagram of the aircraft.
Different designs can be superposed by providing an existing fig.
Each design can be provided a name.
:param aircraft_file_path: path of data file
:param name: name to give to the trace added to the figure
:param fig: existing figure to which add the plot
:param file_formatter: the formatter that defines the format of data file. If not provided,
default format will be assumed.
:return: V-N plot figure.
"""
variables = VariableIO(aircraft_file_path, file_formatter).read()
velocity_array = list(variables["data:mission:sizing:cs23:flight_domain:mtow:velocity"].value)
load_factor_array = list(
variables["data:mission:sizing:cs23:flight_domain:mtow:load_factor"].value
)
category = variables["data:TLAR:category"].value
level = variables["data:TLAR:level"].value
# Save maneuver envelope
x_maneuver_line = list(np.linspace(velocity_array[0], velocity_array[2], 10))
y_maneuver_line = []
x_maneuver_pts = [velocity_array[0], velocity_array[2]]
y_maneuver_pts = [load_factor_array[0], load_factor_array[2]]
for idx in range(len(x_maneuver_line)):
y_maneuver_line.append(
load_factor_array[0] * (x_maneuver_line[idx] / velocity_array[0]) ** 2.0
)
x_maneuver_line.extend(
[velocity_array[9], velocity_array[10], velocity_array[6], velocity_array[3]]
)
y_maneuver_line.extend(
[load_factor_array[9], load_factor_array[10], load_factor_array[6], load_factor_array[3]]
)
x_local = list(np.linspace(velocity_array[3], velocity_array[1], 10))
x_maneuver_line.extend(x_local)
x_maneuver_pts.extend(
[
velocity_array[9],
velocity_array[10],
velocity_array[6],
velocity_array[3],
velocity_array[1],
]
)
y_maneuver_pts.extend(
[
load_factor_array[9],
load_factor_array[10],
load_factor_array[6],
load_factor_array[3],
load_factor_array[1],
]
)
for idx in range(len(x_local)):
y_maneuver_line.append(load_factor_array[1] * (x_local[idx] / x_local[-1]) ** 2.0)
x_maneuver_line.extend([x_local[-1], velocity_array[0], velocity_array[0]])
y_maneuver_line.extend([0.0, 0.0, load_factor_array[0]])
# Save gust envelope
x_gust = [0.0]
y_gust = [1.0]
if not (velocity_array[4] == 0.0):
x_gust.append(velocity_array[4])
y_gust.append(load_factor_array[4])
if (level == 4.0) or (category == 4.0):
x_gust.append(velocity_array[15])
y_gust.append(load_factor_array[15])
x_gust.append(velocity_array[7])
y_gust.append(load_factor_array[7])
x_gust.append(velocity_array[11])
y_gust.append(load_factor_array[11])
x_gust.append(velocity_array[12])
y_gust.append(load_factor_array[12])
x_gust.append(velocity_array[8])
y_gust.append(load_factor_array[8])
if not (velocity_array[5] == 0.0):
x_gust.append(velocity_array[5])
y_gust.append(load_factor_array[5])
x_gust.append(0.0)
y_gust.append(1.0)
if fig is None:
fig = go.Figure()
scatter = go.Scatter(
x=x_maneuver_line,
y=y_maneuver_line,
mode="lines",
name=name + " - maneuver",
legendgroup=name,
legendgrouptitle_text=name + " Evolution diagram",
)
fig.add_trace(scatter)
scatter = go.Scatter(
x=x_maneuver_pts,
y=y_maneuver_pts,
mode="markers",
name=name + " - maneuver [points]",
legendgroup=name,
)
fig.add_trace(scatter)
scatter = go.Scatter(
x=x_gust,
y=y_gust,
mode="lines+markers",
name=name + " - gust",
legendgroup=name,
)
fig.add_trace(scatter)
fig = go.FigureWidget(fig)
fig.update_layout(
title_text="Evolution Diagram",
title_x=0.5,
xaxis=dict(range=[0.0, max(max(x_maneuver_line), max(x_gust)) * 1.1]),
xaxis_title="speed [m/s]",
yaxis=dict(
range=[
min(min(y_maneuver_line), min(y_gust)) * 1.1,
max(max(y_maneuver_line), max(y_gust)) * 1.1,
]
),
yaxis_title="load [g]",
)
return fig
[docs]def compressibility_effects_diagram(
aircraft_file_path: str,
name: str = "",
fig=None,
file_formatter=None,
) -> go.FigureWidget:
"""
Returns a figure plot of the evolution of the lift curve slope with Mach number.
:param aircraft_file_path: path of the aircraft data file
:param name: name to give to the trace added to the figure
:param fig: existing figure to which add the plot
:param file_formatter: the formatter that defines the format of data file. If not provided,
default format will be assumed.
:return: Cl_alpha distribution with Mach number.
"""
variables = VariableIO(aircraft_file_path, file_formatter).read()
cl_alpha_array = list(
variables["data:aerodynamics:aircraft:mach_interpolation:CL_alpha_vector"].value
)
cl_alpha_unit = variables["data:aerodynamics:aircraft:mach_interpolation:CL_alpha_vector"].units
if cl_alpha_unit == "1/deg" or cl_alpha_unit == "deg**-1":
cl_alpha_array = [i * 180.0 / np.pi for i in cl_alpha_array]
mach_array = list(variables["data:aerodynamics:aircraft:mach_interpolation:mach_vector"].value)
if fig is None:
fig = go.Figure()
scatter = go.Scatter(x=mach_array, y=cl_alpha_array, name=name)
fig.add_trace(scatter)
fig = go.FigureWidget(fig)
fig.update_layout(
title_text="Lift coefficient slope as a function of Mach number",
title_x=0.5,
xaxis_title="Mach number [-]",
yaxis_title="Lift coefficient slope [rad**-1]",
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99),
)
return fig
[docs]def cl_wing_diagram(
aircraft_file_path: str,
name: str = "",
prop_on: bool = False,
fig=None,
file_formatter=None,
) -> go.FigureWidget:
"""
Returns a figure plot of the CL distribution on the semi-wing.
:param aircraft_file_path: path of the aircraft data file
:param name: name to give to the trace added to the figure
:param prop_on: boolean stating if the rotor is on or off (for single propeller plane)
:param fig: existing figure to which add the plot
:param file_formatter: the formatter that defines the format of data file. If not provided,
default format will be assumed.
:return: Cl distribution figure along the span.
"""
variables = VariableIO(aircraft_file_path, file_formatter).read()
if prop_on:
try:
cl_array = list(
variables["data:aerodynamics:slipstream:wing:cruise:prop_on:CL_vector"].value
)
span_array = list(
variables["data:aerodynamics:slipstream:wing:cruise:prop_on:Y_vector"].value
)
except ValueError:
cl_array = list(variables["data:aerodynamics:wing:low_speed:CL_vector"].value)
span_array = list(variables["data:aerodynamics:wing:low_speed:Y_vector"].value)
else:
try:
cl_array = list(
variables["data:aerodynamics:slipstream:wing:cruise:prop_off:CL_vector"].value
)
span_array = list(
variables["data:aerodynamics:slipstream:wing:cruise:prop_off:Y_vector"].value
)
except ValueError:
cl_array = list(variables["data:aerodynamics:wing:low_speed:CL_vector"].value)
span_array = list(variables["data:aerodynamics:wing:low_speed:Y_vector"].value)
cl_array = [i for i in cl_array if i != 0]
cl_array.append(0)
span_array = [i for i in span_array if i != 0]
semi_span = variables["data:geometry:wing:span"].value[0] / 2
span_array.append(semi_span)
if fig is None:
fig = go.Figure()
if prop_on:
name_diagram = " propeller ON"
else:
name_diagram = " propeller OFF"
scatter = go.Scatter(x=span_array, y=cl_array, name=name + name_diagram)
fig.add_trace(scatter)
fig = go.FigureWidget(fig)
fig.update_layout(
title_text="CL wing distribution",
title_x=0.5,
xaxis_title="Semi-Span [m]",
yaxis_title="CL [-]",
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99),
)
return fig
[docs]def cg_lateral_diagram(
aircraft_file_path: str,
name="",
fig=None,
file_formatter=None,
color=None,
length_unit="m",
) -> go.FigureWidget:
"""
Returns a figure plot of the lateral view of the plane.
Different designs can be superposed by providing an existing fig.
Each design can be provided a name.
:param aircraft_file_path: path of data file
:param name: name to give to the trace added to the figure
:param color: color that we give to the aft, empty and fwd CGs of the aircraft
:param fig: existing figure to which add the plot
:param file_formatter: the formatter that defines the format of data file. If not provided,
default format will be assumed.
:param length_unit: The length unit of the plot, meter is the default unit
:return: wing plot figure.
"""
variables = VariableIO(aircraft_file_path, file_formatter).read()
# Fuselage parameters
fuselage_max_height = _unit_conversion(
variables["data:geometry:fuselage:maximum_height"], length_unit
)
fuselage_length = _unit_conversion(variables["data:geometry:fuselage:length"], length_unit)
fuselage_front_length = _unit_conversion(
variables["data:geometry:fuselage:front_length"], length_unit
)
fuselage_rear_length = _unit_conversion(
variables["data:geometry:fuselage:rear_length"], length_unit
)
x_fuselage = np.array(
[
0.0,
0.0,
fuselage_front_length,
fuselage_length - fuselage_rear_length,
fuselage_length,
fuselage_length,
]
)
z_fuselage = np.array(
[
0.0,
fuselage_max_height / 5.0,
fuselage_max_height / 2.0,
fuselage_max_height / 2.5,
fuselage_max_height / 5.0,
0.0,
]
)
z_fuselage = np.concatenate((-z_fuselage, z_fuselage))
x_fuselage = np.concatenate((x_fuselage, x_fuselage))
# Vertical Tail parameters
vt_root_chord = _unit_conversion(
variables["data:geometry:vertical_tail:root:chord"], length_unit
)
vt_tip_chord = _unit_conversion(variables["data:geometry:vertical_tail:tip:chord"], length_unit)
vt_span = _unit_conversion(variables["data:geometry:vertical_tail:span"], length_unit)
vt_sweep_0 = _unit_conversion(variables["data:geometry:vertical_tail:sweep_0"], "rad")
vt_tip_leading_edge_x = vt_span * np.tan(vt_sweep_0)
x_vt = np.array(
[0, vt_tip_leading_edge_x, vt_tip_leading_edge_x + vt_tip_chord, vt_root_chord, 0]
)
z_vt = np.array([0, vt_span, vt_span, 0, 0])
wing_25mac_x = _unit_conversion(variables["data:geometry:wing:MAC:at25percent:x"], length_unit)
local_vt_25mac_x = _unit_conversion(
variables["data:geometry:vertical_tail:MAC:at25percent:x:local"], length_unit
)
vt_distance_from_wing = _unit_conversion(
variables["data:geometry:vertical_tail:MAC:at25percent:x:from_wingMAC25"], length_unit
)
x_vt = x_vt + wing_25mac_x + vt_distance_from_wing - local_vt_25mac_x
z_vt = z_vt + fuselage_max_height / 4.0
# CGs
cg_aft_x = _unit_conversion(variables["data:weight:aircraft:CG:aft:x"], length_unit)
cg_fwd_x = _unit_conversion(variables["data:weight:aircraft:CG:fwd:x"], length_unit)
cg_empty_x = _unit_conversion(variables["data:weight:aircraft_empty:CG:x"], length_unit)
cg_empty_z = _unit_conversion(variables["data:weight:aircraft_empty:CG:z"], length_unit)
lg_height = _unit_conversion(variables["data:geometry:landing_gear:height"], length_unit)
x_cg = np.array([cg_fwd_x, cg_empty_x, cg_aft_x])
z_cg = np.array([cg_empty_z, cg_empty_z, cg_empty_z])
z_cg = z_cg - lg_height - fuselage_max_height / 2.0
# Stability
l0 = _unit_conversion(variables["data:geometry:wing:MAC:length"], length_unit)
mac_position = _unit_conversion(variables["data:geometry:wing:MAC:at25percent:x"], length_unit)
stick_fixed_sm = variables["data:handling_qualities:stick_fixed_static_margin"].value[0]
stick_free_sm = variables["data:handling_qualities:stick_free_static_margin"].value[0]
ac_ratio_fixed = _unit_conversion(
variables["data:aerodynamics:cruise:neutral_point:stick_fixed:x"], length_unit
)
ac_ratio_free = _unit_conversion(
variables["data:aerodynamics:cruise:neutral_point:stick_free:x"], length_unit
)
ac_fixed_x = mac_position + (ac_ratio_fixed - 0.25) * l0
ac_free_x = mac_position + (ac_ratio_free - 0.25) * l0
if fig is None:
fig = make_subplots(
rows=1,
cols=2,
column_widths=[0.7, 0.3],
subplot_titles=("Aircraft Lateral View : Barycenter Position", "Zoom"),
)
scatter = go.Scatter(
x=x_fuselage,
y=z_fuselage,
mode="lines+markers",
name=name + " geometry",
line=dict(color=color),
)
fig.add_trace(scatter, 1, 1)
scatter = go.Scatter(
x=x_vt,
y=z_vt,
mode="lines+markers",
name=name,
line=dict(color=color),
showlegend=False,
)
fig.add_trace(scatter, 1, 1)
else:
scatter = go.Scatter(
x=x_fuselage,
y=z_fuselage,
mode="lines+markers",
name=name + " geometry",
line=dict(color=color),
)
fig.add_trace(scatter, 1, 1)
scatter = go.Scatter(
x=x_vt,
y=z_vt,
mode="lines+markers",
name=name,
line=dict(color=color),
showlegend=False,
)
fig.add_trace(scatter, 1, 1)
scatter = go.Scatter(
x=x_cg,
y=z_cg,
mode="lines+markers",
name=name + " CG positions",
line=dict(color=color, width=2),
marker_line=dict(width=2),
)
fig.add_trace(scatter, 1, 1)
scatter = go.Scatter(
x=x_cg,
y=z_cg,
text=["fwd CG", "empty CG", "aft CG"],
mode="lines+markers+text",
textposition=["bottom center", "top center", "top center"],
name=name + " CG positions",
line={"dash": "dash"},
marker_line=dict(width=2),
line_color=color,
showlegend=False,
)
fig.add_trace(scatter, 1, 2)
scatter = go.Scatter(
x=[ac_fixed_x],
y=[z_cg[0]],
text=" Neutral Point"
+ "<br>"
+ "Stick Fixed"
+ "<br>"
+ "Static Margin = "
+ str(round(stick_fixed_sm, 3)),
textposition="bottom center",
mode="markers+text",
line=dict(color="DarkRed"),
showlegend=False,
marker_line=dict(width=2),
)
fig.add_trace(scatter, 1, 2)
scatter = go.Scatter(
x=[ac_free_x],
y=[z_cg[0]],
text="Neutral Point"
+ "<br>"
+ "Stick Free"
+ "<br>"
+ "Static Margin = "
+ str(round(stick_free_sm, 3)),
textposition="bottom center",
mode="markers+text",
line=dict(color="DodgerBlue"),
showlegend=False,
marker_line=dict(width=2),
)
fig.add_trace(scatter, 1, 2)
fig.update_xaxes(title_text="X", row=1, col=1)
fig.update_xaxes(title_text="X", row=1, col=2)
fig.update_yaxes(title_text="Z", row=1, col=1)
fig.update_yaxes(title_text="Z", row=1, col=2)
fig.update_layout(legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01))
return fig
def _data_weight_decomposition(variables: oad.VariableList, owe=None, weight_unit="kg"):
"""
Returns the two level weight decomposition of MTOW and optionally the decomposition of owe
subcategories.
:param variables: instance containing variables information
:param owe: value of OWE, if provided names of owe subcategories will be provided
:param weight_unit: The weight unit of the plot, kilogram is the default unit
:return: variable values, names and optionally owe subcategories names.
"""
category_values = []
category_names = []
owe_subcategory_names = []
for variable in variables.names():
name_split = variable.split(":")
if isinstance(name_split, list) and len(name_split) == 4:
if (
name_split[0] + name_split[1] + name_split[3] == "dataweightmass"
and "aircraft" not in name_split[2]
):
value = _unit_conversion(variables[variable], weight_unit)
category_values.append(value)
category_names.append(name_split[2])
if owe:
owe_subcategory_names.append(
name_split[2]
+ "<br>"
+ str(int(value))
+ " ["
+ weight_unit
+ "] ("
+ str(round(value / owe * 100, 1))
+ "%)"
)
if owe:
result = category_values, category_names, owe_subcategory_names
else:
result = category_values, category_names, None
return result
[docs]def mass_breakdown_bar_plot(
aircraft_file_path: str, name=None, fig=None, file_formatter=None, weight_unit="kg"
) -> go.FigureWidget:
"""
Returns a figure plot of the aircraft mass breakdown using bar plots.
Different designs can be superposed by providing an existing fig.
Each design can be provided a name.
:param aircraft_file_path: path of data file
:param name: name to give to the trace added to the figure
:param fig: existing figure to which add the plot
:param file_formatter: the formatter that defines the format of data file. If not provided,
default format will be assumed.
:param weight_unit: The weight unit of the plot, kilogram is the default unit
:return: bar plot figure.
"""
variables = VariableIO(aircraft_file_path, file_formatter).read()
mtow = _unit_conversion(variables["data:weight:aircraft:MTOW"], weight_unit)
owe = _unit_conversion(variables["data:weight:aircraft:OWE"], weight_unit)
payload = _unit_conversion(variables["data:weight:aircraft:payload"], weight_unit)
fuel_mission = _unit_conversion(variables["data:mission:sizing:fuel"], weight_unit)
if fig is None:
fig = make_subplots(
rows=1,
cols=2,
subplot_titles=("Maximum Take-Off Weight Breakdown", "Overall Weight Empty Breakdown"),
)
# Same color for each aircraft configuration
i = len(fig.data)
weight_labels = ["MTOW", "OWE", "Fuel - Mission", "Payload"]
weight_values = [mtow, owe, fuel_mission, payload]
fig.add_trace(
go.Bar(name="", x=weight_labels, y=weight_values, marker_color=COLS[i], showlegend=False),
row=1,
col=1,
)
# Get data:weight decomposition
main_weight_values, main_weight_names, _ = _data_weight_decomposition(
variables, owe=None, weight_unit=weight_unit
)
fig.add_trace(
go.Bar(name=name, x=main_weight_names, y=main_weight_values, marker_color=COLS[i]),
row=1,
col=2,
)
fig.update_layout(yaxis_title="[" + weight_unit + "]")
return fig
# pylint: disable=too-many-locals
[docs]def mass_breakdown_sun_plot(aircraft_file_path: str, file_formatter=None, weight_unit="kg"):
"""
Returns a figure sunburst plot of the mass breakdown.
On the left a MTOW sunburst and on the right a OWE sunburst.
:param aircraft_file_path: path of data file
:param file_formatter: the formatter that defines the format of data file. If not provided,
default format will be assumed.
:param weight_unit: The weight unit of the plot, kilogram is the default unit
:return: sunburst plot figure.
"""
variables = VariableIO(aircraft_file_path, file_formatter).read()
mtow = _unit_conversion(variables["data:weight:aircraft:MTOW"], weight_unit)
owe = _unit_conversion(variables["data:weight:aircraft:OWE"], weight_unit)
payload = _unit_conversion(variables["data:weight:aircraft:payload"], weight_unit)
onboard_fuel_at_takeoff = _unit_conversion(variables["data:mission:sizing:fuel"], weight_unit)
# TODO: Deal with this in a more generic manner ?
# Looks like if the precise value are not equal then nothing will be displayed which can
# happen when an OAD process is not ran with sufficient accuracy hence this line.
if round(mtow, 0) == round(owe + payload + onboard_fuel_at_takeoff, 0):
mtow = owe + payload + onboard_fuel_at_takeoff
fig = make_subplots(
1,
2,
specs=[[{"type": "domain"}, {"type": "domain"}]],
)
fig.add_trace(
go.Sunburst(
labels=[
"MTOW" + "<br>" + str(int(mtow)) + " [" + weight_unit + "]",
"payload"
+ "<br>"
+ str(int(payload))
+ " ["
+ weight_unit
+ "] ("
+ str(round(payload / mtow * 100, 1))
+ "%)",
"onboard_fuel_at_takeoff"
+ "<br>"
+ str(int(onboard_fuel_at_takeoff))
+ " ["
+ weight_unit
+ "] ("
+ str(round(onboard_fuel_at_takeoff / mtow * 100, 1))
+ "%)",
"OWE"
+ "<br>"
+ str(int(owe))
+ " ["
+ weight_unit
+ "] ("
+ str(round(owe / mtow * 100, 1))
+ "%)",
],
parents=[
"",
"MTOW" + "<br>" + str(int(mtow)) + " [" + weight_unit + "]",
"MTOW" + "<br>" + str(int(mtow)) + " [" + weight_unit + "]",
"MTOW" + "<br>" + str(int(mtow)) + " [" + weight_unit + "]",
],
values=[mtow, payload, onboard_fuel_at_takeoff, owe],
branchvalues="total",
),
1,
1,
)
# Get data:weight 2-levels decomposition
categories_values, categories_names, categories_labels = _data_weight_decomposition(
variables, owe=owe, weight_unit=weight_unit
)
sub_categories_values = []
sub_categories_names = []
sub_categories_parent = []
for variable in variables.names():
name_split = variable.split(":")
if isinstance(name_split, list) and len(name_split) >= 5:
parent_name = name_split[2]
if parent_name in categories_names and name_split[-1] == "mass":
variable_name = "_".join(name_split[3:-1])
if variable_name not in ("unusable_fuel", "wing_distributed_mass"):
sub_categories_values.append(_unit_conversion(variables[variable], weight_unit))
sub_categories_parent.append(
categories_labels[categories_names.index(parent_name)]
)
sub_categories_names.append(variable_name)
# Define figure data
figure_labels = ["OWE" + "<br>" + str(int(owe)) + " [" + weight_unit + "]"]
figure_labels.extend(categories_labels)
figure_labels.extend(sub_categories_names)
figure_parents = [""]
for _ in categories_names:
figure_parents.append("OWE" + "<br>" + str(int(owe)) + " [" + weight_unit + "]")
figure_parents.extend(sub_categories_parent)
figure_values = [owe]
figure_values.extend(categories_values)
figure_values.extend(sub_categories_values)
# Plot figure
fig.add_trace(
go.Sunburst(
labels=figure_labels,
parents=figure_parents,
values=figure_values,
branchvalues="total",
),
1,
2,
)
fig.update_layout(title_text="Mass Breakdown", title_x=0.5)
return fig
[docs]def drag_breakdown_diagram(
aircraft_file_path: str,
file_formatter=None,
) -> go.FigureWidget:
"""Return a plot of the drag breakdown of the wing in cruise conditions."""
variables = VariableIO(aircraft_file_path, file_formatter).read()
parasite_drag_cruise = variables["data:aerodynamics:aircraft:cruise:CD0"].value[0]
induced_drag_cruise = variables["data:aerodynamics:wing:cruise:induced_drag_coefficient"].value[
0
]
fuselage_drag_cruise = variables["data:aerodynamics:fuselage:cruise:CD0"].value[0]
wing_parasite_drag_cruise = variables["data:aerodynamics:wing:cruise:CD0"].value[0]
htp_drag_cruise = variables["data:aerodynamics:horizontal_tail:cruise:CD0"].value[0]
vtp_drag_cruise = variables["data:aerodynamics:vertical_tail:cruise:CD0"].value[0]
lg_drag_cruise = variables["data:aerodynamics:landing_gear:cruise:CD0"].value[0]
nacelle_drag_cruise = variables["data:aerodynamics:nacelles:cruise:CD0"].value[0]
other_drag_cruise = variables["data:aerodynamics:other:cruise:CD0"].value[0]
parasite_drag_low_speed = variables["data:aerodynamics:aircraft:low_speed:CD0"].value[0]
induced_drag_low_speed = variables[
"data:aerodynamics:wing:low_speed:induced_drag_coefficient"
].value[0]
fuselage_drag_low_speed = variables["data:aerodynamics:fuselage:low_speed:CD0"].value[0]
wing_parasite_drag_low_speed = variables["data:aerodynamics:wing:low_speed:CD0"].value[0]
htp_drag_low_speed = variables["data:aerodynamics:horizontal_tail:low_speed:CD0"].value[0]
vtp_drag_low_speed = variables["data:aerodynamics:vertical_tail:low_speed:CD0"].value[0]
lg_drag_low_speed = variables["data:aerodynamics:landing_gear:low_speed:CD0"].value[0]
nacelle_drag_low_speed = variables["data:aerodynamics:nacelles:low_speed:CD0"].value[0]
other_drag_low_speed = variables["data:aerodynamics:other:low_speed:CD0"].value[0]
# CRUD (other undesirable drag). Factor from Gudmundsson book. Introduced in
# aerodynamics.components.cd0_total.py.
crud_factor = 1.25
fig = make_subplots(
rows=1,
cols=2,
subplot_titles=(
"Drag coefficient breakdown in cruise conditions",
"Drag coefficient breakdown in low_speed conditions",
),
specs=[[{"type": "domain"}, {"type": "domain"}]],
)
fig.add_trace(
go.Sunburst(
labels=[
"Parasite Drag",
"Induced Drag",
"Fuselage",
"Wing",
"Horizontal Tail",
"Vertical Tail",
"Landing Gears",
"Nacelle",
"Other",
],
parents=[
"",
"",
"Parasite Drag",
"Parasite Drag",
"Parasite Drag",
"Parasite Drag",
"Parasite Drag",
"Parasite Drag",
"Parasite Drag",
],
values=[
parasite_drag_cruise,
induced_drag_cruise,
crud_factor * fuselage_drag_cruise,
crud_factor * wing_parasite_drag_cruise,
crud_factor * htp_drag_cruise,
crud_factor * vtp_drag_cruise,
crud_factor * lg_drag_cruise,
crud_factor * nacelle_drag_cruise,
crud_factor * other_drag_cruise,
],
branchvalues="total",
),
1,
1,
)
fig.add_trace(
go.Sunburst(
labels=[
"Parasite Drag",
"Induced Drag",
"Fuselage",
"Wing",
"Horizontal Tail",
"Vertical Tail",
"Landing Gears",
"Nacelle",
"Other",
],
parents=[
"",
"",
"Parasite Drag",
"Parasite Drag",
"Parasite Drag",
"Parasite Drag",
"Parasite Drag",
"Parasite Drag",
"Parasite Drag",
],
values=[
parasite_drag_low_speed,
induced_drag_low_speed,
crud_factor * fuselage_drag_low_speed,
crud_factor * wing_parasite_drag_low_speed,
crud_factor * htp_drag_low_speed,
crud_factor * vtp_drag_low_speed,
crud_factor * lg_drag_low_speed,
crud_factor * nacelle_drag_low_speed,
crud_factor * other_drag_low_speed,
],
branchvalues="total",
),
1,
2,
)
fig.update_layout(
margin=dict(t=0, l=0, r=0, b=0),
)
fig = go.FigureWidget(fig)
return fig
[docs]def payload_range(
aircraft_file_path: str, name="", fig=None, file_formatter=None
) -> go.FigureWidget:
"""
Returns a figure plot of the payload range diagram of the plane.
Different designs can be superposed by providing an existing fig.
Each design can be provided a name.
:param aircraft_file_path: path of data file
:param name: name to give to the trace added to the figure
:param fig: existing figure to which add the plot
:param file_formatter: the formatter that defines the format of data file. If not provided,
default format will be assumed.
:return: payload range figure.
"""
variables = VariableIO(aircraft_file_path, file_formatter).read()
payload_array = _unit_conversion(variables["data:payload_range:payload_array"], "kg").tolist()
range_array = _unit_conversion(variables["data:payload_range:range_array"], "nmi").tolist()
sr_array = _unit_conversion(
variables["data:payload_range:specific_range_array"], "nmi/kg"
).tolist()
text_plot = [
"<br>" + "<b>A<b>" + "<br>" + "SR = " + str(round(sr_array[0], 1)) + " nm/kg",
"<br>" + "<b>B<b>" + "<br>" + "SR = " + str(round(sr_array[1], 1)) + " nm/kg",
"<b>C<b>" + "<br>" + "SR = " + str(round(sr_array[2], 1)) + " nm/kg" + "<br>",
" <b>D<b>" + "<br>" + " SR = " + str(round(sr_array[3], 1)) + " nm/kg",
" <b>E<b>" + "<br>" + " SR = " + str(round(sr_array[4], 1)) + " nm/kg",
]
ax = [0, 0, 50, 50, 75]
# Plotting of the diagram
if fig is None:
fig = go.Figure()
scatter = go.Scatter(
x=range_array[0:2] + range_array[3:],
y=payload_array[0:2] + payload_array[3:],
mode="lines+markers",
name=name + " Computed Points",
)
fig.add_trace(scatter)
scatter = go.Scatter(
x=[range_array[2]],
y=[payload_array[2]],
mode="lines+markers",
name=name + " Design Point",
)
fig.add_trace(scatter)
for i in range(len(text_plot)):
fig.add_annotation(
x=range_array[i],
y=payload_array[i],
text=text_plot[i],
font=dict(
size=14,
),
align="center",
bordercolor="Black",
borderpad=4,
ax=ax[i],
)
fig = go.FigureWidget(fig)
fig.update_layout(
title_text="Payload Range Diagram",
title_x=0.5,
xaxis_title="Range [nm]",
yaxis_title="Payload [kg]",
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99),
)
fig.update_xaxes(
range=[-100, range_array[-1] * 1.15], title_font=dict(size=18), tickfont=dict(size=14)
)
fig.update_yaxes(title_font=dict(size=18), tickfont=dict(size=14))
return fig
[docs]def aircraft_polar(
aircraft_file_path: str, name=None, fig=None, file_formatter=None, equilibrated=False
) -> go.FigureWidget:
"""
Returns a figure plot of the polar of the plane.
Different designs can be superposed by providing an existing fig.
Each design can be provided a name.
The value obtained for the finesse for the equilibrated drag polar is quite low.
:param aircraft_file_path: path of data file
:param name: name to give to the trace added to the figure
:param fig: existing figure to which add the plot
:param file_formatter: the formatter that defines the format of data file. If not provided,
default format will be assumed.
:param equilibrated: boolean stating if the polar plotted is the equilibrated one or not
:return: plane polar figure.
"""
variables = VariableIO(aircraft_file_path, file_formatter).read()
if equilibrated:
cl_array_cruise = list(variables["data:aerodynamics:aircraft:cruise:equilibrated:CL"].value)
cl_array_cruise = [
e for i, e in enumerate(cl_array_cruise) if e != 0 and e < FIRST_INVALID_COEFF
]
cd_array_cruise = list(variables["data:aerodynamics:aircraft:cruise:equilibrated:CD"].value)
cd_array_cruise = [
e for i, e in enumerate(cd_array_cruise) if e != 0 and e < FIRST_INVALID_COEFF
]
cl_array_low_speed = list(
variables["data:aerodynamics:aircraft:low_speed:equilibrated:CL"].value
)
cl_array_low_speed = [
e for i, e in enumerate(cl_array_low_speed) if e != 0 and e < FIRST_INVALID_COEFF
]
cd_array_low_speed = list(
variables["data:aerodynamics:aircraft:low_speed:equilibrated:CD"].value
)
cd_array_low_speed = [
e for i, e in enumerate(cd_array_low_speed) if e != 0 and e < FIRST_INVALID_COEFF
]
else:
cl_array_cruise = list(variables["data:aerodynamics:aircraft:cruise:CL"].value)
cl_array_cruise = [
e for i, e in enumerate(cl_array_cruise) if e != 0 and e < FIRST_INVALID_COEFF
]
cd_array_cruise = list(variables["data:aerodynamics:aircraft:cruise:CD"].value)
cd_array_cruise = [
e for i, e in enumerate(cd_array_cruise) if e != 0 and e < FIRST_INVALID_COEFF
]
cl_array_low_speed = list(variables["data:aerodynamics:aircraft:low_speed:CL"].value)
cl_array_low_speed = [
e for i, e in enumerate(cl_array_low_speed) if e != 0 and e < FIRST_INVALID_COEFF
]
cd_array_low_speed = list(variables["data:aerodynamics:aircraft:low_speed:CD"].value)
cd_array_low_speed = [
e for i, e in enumerate(cd_array_low_speed) if e != 0 and e < FIRST_INVALID_COEFF
]
# Computation of the highest CL/CD ratio which gives the L/D max.
l_d_max_cruise = max(np.asarray(cl_array_cruise) / np.asarray(cd_array_cruise))
l_d_max_low_speed = max(np.asarray(cl_array_low_speed) / np.asarray(cd_array_low_speed))
l_d_max_cruise_index = np.where(
np.asarray(cl_array_cruise) / np.asarray(cd_array_cruise) == l_d_max_cruise
)[0]
l_d_max_low_speed_index = np.where(
np.asarray(cl_array_low_speed) / np.asarray(cd_array_low_speed) == l_d_max_low_speed
)[0]
text_cruise = []
text_low_speed = []
for i in range(len(cl_array_cruise)):
if i == l_d_max_cruise_index:
text_cruise.append("max L/D = " + "<br>" + str(round(l_d_max_cruise, 3)))
else:
text_cruise.append("")
if i == l_d_max_low_speed_index:
text_low_speed.append("max L/D = " + "<br>" + str(round(l_d_max_low_speed, 3)))
else:
text_low_speed.append("")
# Plotting of the diagram
if fig is None:
fig = make_subplots(rows=1, cols=2, subplot_titles=("Cruise", "Low Speed"))
scatter = go.Scatter(
x=cd_array_cruise,
y=cl_array_cruise,
mode="lines+markers+text",
name=name,
text=text_cruise,
textposition="top left",
)
fig.add_trace(scatter, 1, 1)
scatter = go.Scatter(
x=cd_array_cruise,
y=cl_array_cruise[int(l_d_max_cruise_index)]
/ cd_array_cruise[int(l_d_max_cruise_index)]
* np.asarray(cd_array_cruise),
mode="lines",
line=dict(width=2, dash="dot"),
showlegend=False,
)
fig.add_trace(scatter, 1, 1)
scatter = go.Scatter(
x=cd_array_low_speed,
y=cl_array_low_speed,
mode="lines+markers+text",
name=name,
text=text_low_speed,
textposition="top left",
)
fig.add_trace(scatter, 1, 2)
scatter = go.Scatter(
x=cd_array_low_speed,
y=cl_array_low_speed[int(l_d_max_low_speed_index)]
/ cd_array_low_speed[int(l_d_max_low_speed_index)]
* np.asarray(cd_array_low_speed),
mode="lines",
line=dict(width=2, dash="dot"),
showlegend=False,
)
fig.add_trace(scatter, 1, 2)
fig = go.FigureWidget(fig)
fig.update_xaxes(title_text="CD", row=1, col=1)
fig.update_xaxes(title_text="CD", row=1, col=2)
fig.update_yaxes(title_text="CL", row=1, col=1)
fig.update_yaxes(title_text="CL", row=1, col=2)
if equilibrated:
title = "Equilibrated Aircraft Polar"
else:
title = "Non Equilibrated Aircraft Polar"
fig.update_layout(
title_text=title,
title_x=0.5,
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99),
)
return fig