aero-agent-roles 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +18 -0
- package/README.md +49 -0
- package/bin/aero-agent-roles.js +127 -0
- package/lib/catalog.js +97 -0
- package/lib/install.js +48 -0
- package/lib/mcp.js +199 -0
- package/manifest.json +516 -0
- package/package.json +44 -0
- package/roles/aerodynamics-engineer/ROLE.md +153 -0
- package/roles/aerodynamics-engineer/SOURCES.md +12 -0
- package/roles/aerodynamics-engineer/cli.py +143 -0
- package/roles/aerodynamics-engineer/core/aerodynamics_core.py +1218 -0
- package/roles/aerodynamics-engineer/templates/aero-design-report-template.md +72 -0
- package/roles/aerodynamics-engineer/tests/test_aerodynamics_core.py +227 -0
- package/roles/aerodynamics-engineer/tests/test_role_aerodynamics.py +94 -0
- package/roles/aircraft-design-engineer/ROLE.md +138 -0
- package/roles/aircraft-design-engineer/SOURCES.md +12 -0
- package/roles/aircraft-design-engineer/cli.py +152 -0
- package/roles/aircraft-design-engineer/core/aircraft_design_core.py +1278 -0
- package/roles/aircraft-design-engineer/templates/concept-design-template.md +99 -0
- package/roles/aircraft-design-engineer/tests/test_aircraft_design_core.py +269 -0
- package/roles/aircraft-design-engineer/tests/test_role_aircraft_design_engineer.py +106 -0
- package/roles/airworthiness-compliance-engineer/ROLE.md +122 -0
- package/roles/airworthiness-compliance-engineer/SOURCES.md +16 -0
- package/roles/airworthiness-compliance-engineer/cli.py +146 -0
- package/roles/airworthiness-compliance-engineer/core/airworthiness_core.py +894 -0
- package/roles/airworthiness-compliance-engineer/templates/compliance-matrix-template.md +46 -0
- package/roles/airworthiness-compliance-engineer/tests/test_airworthiness_core.py +224 -0
- package/roles/airworthiness-compliance-engineer/tests/test_role_airworthiness.py +85 -0
- package/roles/as9100-quality-auditor/ROLE.md +128 -0
- package/roles/as9100-quality-auditor/SOURCES.md +16 -0
- package/roles/as9100-quality-auditor/cli.py +136 -0
- package/roles/as9100-quality-auditor/core/as9100_core.py +857 -0
- package/roles/as9100-quality-auditor/templates/findings-report-template.md +119 -0
- package/roles/as9100-quality-auditor/tests/test_as9100_cli_profile.py +105 -0
- package/roles/as9100-quality-auditor/tests/test_as9100_core.py +204 -0
- package/roles/as9100-quality-auditor/tests/test_role_as9100.py +90 -0
- package/roles/data-bus-avionics-engineer/ROLE.md +146 -0
- package/roles/data-bus-avionics-engineer/SOURCES.md +23 -0
- package/roles/data-bus-avionics-engineer/cli.py +311 -0
- package/roles/data-bus-avionics-engineer/core/data_bus_avionics_core.py +1121 -0
- package/roles/data-bus-avionics-engineer/templates/bus-assessment-template.md +134 -0
- package/roles/data-bus-avionics-engineer/tests/test_bundle_protocol.py +137 -0
- package/roles/data-bus-avionics-engineer/tests/test_data_bus_avionics_core.py +261 -0
- package/roles/data-bus-avionics-engineer/tests/test_role_data_bus_avionics_engineer.py +121 -0
- package/roles/do160-environmental-engineer/ROLE.md +136 -0
- package/roles/do160-environmental-engineer/SOURCES.md +30 -0
- package/roles/do160-environmental-engineer/cli.py +147 -0
- package/roles/do160-environmental-engineer/core/do160_environmental_core.py +1246 -0
- package/roles/do160-environmental-engineer/templates/environmental-qualification-plan-template.md +114 -0
- package/roles/do160-environmental-engineer/tests/test_do160_environmental_core.py +312 -0
- package/roles/do160-environmental-engineer/tests/test_role_do160_environmental_engineer.py +281 -0
- package/roles/do178c-cert-engineer/ROLE.md +120 -0
- package/roles/do178c-cert-engineer/SOURCES.md +16 -0
- package/roles/do178c-cert-engineer/cli.py +129 -0
- package/roles/do178c-cert-engineer/core/do178c_core.py +411 -0
- package/roles/do178c-cert-engineer/templates/psac-template.md +74 -0
- package/roles/do178c-cert-engineer/tests/test_do178c_cert_engineer_core.py +91 -0
- package/roles/do178c-cert-engineer/tests/test_role_do178c.py +109 -0
- package/roles/do254-hardware-engineer/ROLE.md +127 -0
- package/roles/do254-hardware-engineer/SOURCES.md +19 -0
- package/roles/do254-hardware-engineer/cli.py +308 -0
- package/roles/do254-hardware-engineer/core/do254_hardware_core.py +761 -0
- package/roles/do254-hardware-engineer/templates/phac-template.md +87 -0
- package/roles/do254-hardware-engineer/tests/test_do254_bundle_profile.py +152 -0
- package/roles/do254-hardware-engineer/tests/test_do254_hardware_core.py +200 -0
- package/roles/do254-hardware-engineer/tests/test_role_do254_hardware.py +108 -0
- package/roles/engineering-analysis-engineer/ROLE.md +154 -0
- package/roles/engineering-analysis-engineer/SOURCES.md +15 -0
- package/roles/engineering-analysis-engineer/cli.py +151 -0
- package/roles/engineering-analysis-engineer/core/engineering_analysis_engineer_core.py +866 -0
- package/roles/engineering-analysis-engineer/templates/analysis-memo-template.md +73 -0
- package/roles/engineering-analysis-engineer/tests/test_engineering_analysis_engineer_core.py +367 -0
- package/roles/engineering-analysis-engineer/tests/test_role_engineering_analysis_engineer.py +113 -0
- package/roles/flight-management-engineer/ROLE.md +160 -0
- package/roles/flight-management-engineer/SOURCES.md +30 -0
- package/roles/flight-management-engineer/cli.py +258 -0
- package/roles/flight-management-engineer/core/flight_management_core.py +1473 -0
- package/roles/flight-management-engineer/templates/route-assessment-template.md +94 -0
- package/roles/flight-management-engineer/tests/test_flight_management_core.py +437 -0
- package/roles/flight-management-engineer/tests/test_role_flight_management_engineer.py +123 -0
- package/roles/flight-mechanics-engineer/ROLE.md +162 -0
- package/roles/flight-mechanics-engineer/SOURCES.md +51 -0
- package/roles/flight-mechanics-engineer/cli.py +149 -0
- package/roles/flight-mechanics-engineer/core/flight_mechanics_core.py +1507 -0
- package/roles/flight-mechanics-engineer/templates/perf-sc-report-template.md +82 -0
- package/roles/flight-mechanics-engineer/tests/test_flight_mechanics_core.py +277 -0
- package/roles/flight-mechanics-engineer/tests/test_role_flight_mechanics_engineer.py +141 -0
- package/roles/flight-test-engineer/ROLE.md +137 -0
- package/roles/flight-test-engineer/SOURCES.md +24 -0
- package/roles/flight-test-engineer/cli.py +135 -0
- package/roles/flight-test-engineer/core/flight_test_core.py +931 -0
- package/roles/flight-test-engineer/templates/flight-test-template.md +143 -0
- package/roles/flight-test-engineer/tests/test_flight_test_engineer_core.py +227 -0
- package/roles/flight-test-engineer/tests/test_role_flight_test_engineer.py +150 -0
- package/roles/flight-test-performance-engineer/ROLE.md +165 -0
- package/roles/flight-test-performance-engineer/SOURCES.md +20 -0
- package/roles/flight-test-performance-engineer/cli.py +319 -0
- package/roles/flight-test-performance-engineer/core/flight_test_performance_core.py +1398 -0
- package/roles/flight-test-performance-engineer/templates/performance-report-template.md +105 -0
- package/roles/flight-test-performance-engineer/tests/test_flight_test_performance_engineer_core.py +308 -0
- package/roles/flight-test-performance-engineer/tests/test_role_flight_test_performance_engineer.py +165 -0
- package/roles/gnc-engineer/ROLE.md +141 -0
- package/roles/gnc-engineer/SOURCES.md +35 -0
- package/roles/gnc-engineer/cli.py +141 -0
- package/roles/gnc-engineer/core/gnc_core.py +1074 -0
- package/roles/gnc-engineer/templates/gnc-report-template.md +105 -0
- package/roles/gnc-engineer/tests/test_gnc_engineer_core.py +238 -0
- package/roles/gnc-engineer/tests/test_role_gnc_engineer.py +142 -0
- package/roles/guidance-engineer/ROLE.md +156 -0
- package/roles/guidance-engineer/SOURCES.md +43 -0
- package/roles/guidance-engineer/cli.py +280 -0
- package/roles/guidance-engineer/core/guidance_core.py +820 -0
- package/roles/guidance-engineer/templates/guidance-report-template.md +70 -0
- package/roles/guidance-engineer/tests/test_guidance_engineer_core.py +269 -0
- package/roles/guidance-engineer/tests/test_role_guidance_engineer.py +146 -0
- package/roles/high-speed-aerodynamics-engineer/ROLE.md +161 -0
- package/roles/high-speed-aerodynamics-engineer/SOURCES.md +18 -0
- package/roles/high-speed-aerodynamics-engineer/cli.py +282 -0
- package/roles/high-speed-aerodynamics-engineer/core/high_speed_aero_core.py +1566 -0
- package/roles/high-speed-aerodynamics-engineer/templates/high-speed-memo-template.md +75 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_bundle_protocol.py +103 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_high_speed_aero_core.py +280 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_role_high_speed_aerodynamics.py +125 -0
- package/roles/ndt-engineer/ROLE.md +157 -0
- package/roles/ndt-engineer/SOURCES.md +18 -0
- package/roles/ndt-engineer/cli.py +316 -0
- package/roles/ndt-engineer/core/ndt_core.py +1590 -0
- package/roles/ndt-engineer/templates/ndt-plan-template.md +109 -0
- package/roles/ndt-engineer/tests/test_ndt_cli_bundle.py +183 -0
- package/roles/ndt-engineer/tests/test_ndt_core.py +437 -0
- package/roles/ndt-engineer/tests/test_role_ndt_engineer.py +148 -0
- package/roles/numerical-analysis-engineer/ROLE.md +158 -0
- package/roles/numerical-analysis-engineer/SOURCES.md +19 -0
- package/roles/numerical-analysis-engineer/cli.py +414 -0
- package/roles/numerical-analysis-engineer/core/numerical_analysis_core.py +1220 -0
- package/roles/numerical-analysis-engineer/templates/verification-memo-template.md +120 -0
- package/roles/numerical-analysis-engineer/tests/test_bundle_protocol.py +157 -0
- package/roles/numerical-analysis-engineer/tests/test_numerical_analysis_core.py +337 -0
- package/roles/numerical-analysis-engineer/tests/test_role_numerical_analysis_engineer.py +117 -0
- package/roles/propulsion-engineer/ROLE.md +163 -0
- package/roles/propulsion-engineer/SOURCES.md +15 -0
- package/roles/propulsion-engineer/cli.py +168 -0
- package/roles/propulsion-engineer/core/propulsion_core.py +1291 -0
- package/roles/propulsion-engineer/templates/propulsion-report-template.md +92 -0
- package/roles/propulsion-engineer/tests/test_propulsion_engineer_core.py +294 -0
- package/roles/propulsion-engineer/tests/test_role_propulsion_engineer.py +106 -0
- package/roles/space-systems-engineer/ROLE.md +161 -0
- package/roles/space-systems-engineer/SOURCES.md +49 -0
- package/roles/space-systems-engineer/cli.py +173 -0
- package/roles/space-systems-engineer/core/space_systems_core.py +980 -0
- package/roles/space-systems-engineer/templates/space-systems-template.md +88 -0
- package/roles/space-systems-engineer/tests/test_role_space_systems_engineer.py +117 -0
- package/roles/space-systems-engineer/tests/test_space_systems_engineer_core.py +247 -0
- package/roles/state-estimation-engineer/ROLE.md +154 -0
- package/roles/state-estimation-engineer/SOURCES.md +27 -0
- package/roles/state-estimation-engineer/cli.py +326 -0
- package/roles/state-estimation-engineer/core/state_estimation_core.py +958 -0
- package/roles/state-estimation-engineer/templates/nav-state-estimator-design-template.md +76 -0
- package/roles/state-estimation-engineer/tests/test_bundle_protocol.py +110 -0
- package/roles/state-estimation-engineer/tests/test_role_state_estimation_engineer.py +127 -0
- package/roles/state-estimation-engineer/tests/test_state_estimation_engineer_core.py +231 -0
- package/roles/structures-loads-engineer/ROLE.md +171 -0
- package/roles/structures-loads-engineer/SOURCES.md +44 -0
- package/roles/structures-loads-engineer/cli.py +219 -0
- package/roles/structures-loads-engineer/core/structures_loads_core.py +1112 -0
- package/roles/structures-loads-engineer/templates/loads-strength-report-template.md +96 -0
- package/roles/structures-loads-engineer/tests/test_bundle_protocol.py +101 -0
- package/roles/structures-loads-engineer/tests/test_role_structures_loads_engineer.py +147 -0
- package/roles/structures-loads-engineer/tests/test_structures_loads_core.py +261 -0
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""flight_test_performance_core.py - Flight Test Performance Engineer executable core.
|
|
3
|
+
|
|
4
|
+
This is the role's ENGINE: given a flight-test sortie data set (recorded
|
|
5
|
+
takeoff, landing, level-acceleration, cruise and stall-speed runs plus the
|
|
6
|
+
test-day conditions) it reduces the measured data to standard conditions
|
|
7
|
+
and reference weight, computes the takeoff/landing distance legs, the
|
|
8
|
+
determined thrust from the level acceleration (total energy method), the
|
|
9
|
+
corrected cruise fuel flows and range-performance curve, the weight
|
|
10
|
+
corrections to the stall/V speeds, and the computed-vs-predicted scatter
|
|
11
|
+
table. It BUILDS the Flight Test Performance Data Analysis Report and
|
|
12
|
+
gate-checks deliverables. Standalone: no external repo needed.
|
|
13
|
+
|
|
14
|
+
Domain rules encoded here are public flight-test methodology as bound in
|
|
15
|
+
the Aero Agent Skills flight-test-operations/performance leaves and in the
|
|
16
|
+
public FAR-25 / CS-25 context those leaves reference (summary only, never
|
|
17
|
+
reproduced text): ISA atmosphere physics, the 35 ft obstacle height of the
|
|
18
|
+
takeoff distance definition, trapezoid ground-roll integration, the Vref =
|
|
19
|
+
1.23*Vs0 landing approach practice, the 1.67 certified landing field
|
|
20
|
+
length factor, the total-energy specific excess power P_s = V*a/g, the
|
|
21
|
+
thrust lapse sigma^0.7 model, the sqrt(w_ref/w_test) fuel-flow weight
|
|
22
|
+
correction, and the sqrt(w/w_ref) stall-speed correction.
|
|
23
|
+
|
|
24
|
+
Units: SI. Forces and weights in N, speeds in m/s, distances in m,
|
|
25
|
+
fuel flows in kg/s, altitudes in m, temperatures in K/C as noted.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import date
|
|
32
|
+
|
|
33
|
+
def _today() -> str:
|
|
34
|
+
import os
|
|
35
|
+
from datetime import date
|
|
36
|
+
return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Physical constants (public ISA atmosphere physics, SI)
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
RHO0 = 1.225 # standard sea level density, kg/m^3
|
|
43
|
+
P0_PA = 101325.0 # standard sea level pressure, Pa
|
|
44
|
+
T0_K = 288.15 # standard sea level temperature, K
|
|
45
|
+
LAPSE_K_PER_M = 0.0065 # troposphere lapse rate, K/m
|
|
46
|
+
G0 = 9.80665 # standard gravity, m/s^2
|
|
47
|
+
R_GAS = 287.05287 # specific gas constant of air, J/(kg K)
|
|
48
|
+
GAMMA = 1.4
|
|
49
|
+
H_TROPO_M = 11000.0 # tropopause geopotential altitude, m
|
|
50
|
+
T_TROPO_K = 216.65 # tropopause temperature, K
|
|
51
|
+
MAX_H_M = 20000.0 # model ceiling, m
|
|
52
|
+
H_ISO_M = R_GAS * T_TROPO_K / G0 # isothermal scale height, m
|
|
53
|
+
ISA_DENSITY_EXP = G0 / (R_GAS * LAPSE_K_PER_M) - 1.0 # 4.25588
|
|
54
|
+
ISA_PRESSURE_EXP = G0 / (R_GAS * LAPSE_K_PER_M) # 5.25588
|
|
55
|
+
FT_PER_M = 1.0 / 0.3048
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Domain constants (flight-test practice, FAR-25 / CS-25 summary context as
|
|
59
|
+
# encoded in the bound AeroSkills performance leaves)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
OBSTACLE_35FT_M = 10.668 # 35 ft obstacle height (takeoff distance def)
|
|
63
|
+
APPROACH_FACTOR = 1.23 # Vref = 1.23 * Vs0 (landing-distance leaf)
|
|
64
|
+
DEFAULT_FLARE_TIME_S = 5.0 # flare segment time model (landing-distance leaf)
|
|
65
|
+
FIELD_LENGTH_FACTOR = 1.67 # certified landing field length factor
|
|
66
|
+
LAPSE_EXP = 0.7 # installed thrust lapse exponent (sigma^0.7)
|
|
67
|
+
SCATTER_BAND_PCT = 10.0 # computed-vs-predicted agreement band, percent
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _check_positive(name, value):
|
|
71
|
+
if value <= 0:
|
|
72
|
+
raise ValueError("%s must be > 0, got %r" % (name, value))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_nonnegative(name, value):
|
|
76
|
+
if value < 0:
|
|
77
|
+
raise ValueError("%s must be >= 0, got %r" % (name, value))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# ISA atmosphere (public physics; same model as the bound leaves)
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def isa_conditions(altitude_m):
|
|
85
|
+
"""ISA (T in K, P in Pa, rho in kg/m^3) at geopotential altitude.
|
|
86
|
+
|
|
87
|
+
Troposphere lapse 0.0065 K/m to 11000 m, isothermal 216.65 K above.
|
|
88
|
+
Anchors: 0 m -> (288.15, 101325.0, 1.225); 8000 m -> (236.15,
|
|
89
|
+
35599.8, 0.52517); 11000 m density ratio 0.29707.
|
|
90
|
+
"""
|
|
91
|
+
if altitude_m < 0 or altitude_m > MAX_H_M:
|
|
92
|
+
raise ValueError("altitude must be within 0 to 20000 m, got %r"
|
|
93
|
+
% (altitude_m,))
|
|
94
|
+
if altitude_m <= H_TROPO_M:
|
|
95
|
+
t = T0_K - LAPSE_K_PER_M * altitude_m
|
|
96
|
+
p = P0_PA * (1.0 - LAPSE_K_PER_M * altitude_m / T0_K) ** ISA_PRESSURE_EXP
|
|
97
|
+
else:
|
|
98
|
+
t = T_TROPO_K
|
|
99
|
+
p_t = P0_PA * (1.0 - LAPSE_K_PER_M * H_TROPO_M / T0_K) ** ISA_PRESSURE_EXP
|
|
100
|
+
p = p_t * math.exp(-(altitude_m - H_TROPO_M) / H_ISO_M)
|
|
101
|
+
return (t, p, p / (R_GAS * t))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def isa_density_ratio_m(altitude_m):
|
|
105
|
+
"""ISA density ratio sigma = rho/rho0 at geopotential altitude (m)."""
|
|
106
|
+
if altitude_m < 0 or altitude_m > MAX_H_M:
|
|
107
|
+
raise ValueError("altitude must be within 0 to 20000 m, got %r"
|
|
108
|
+
% (altitude_m,))
|
|
109
|
+
if altitude_m <= H_TROPO_M:
|
|
110
|
+
return (1.0 - LAPSE_K_PER_M * altitude_m / T0_K) ** ISA_DENSITY_EXP
|
|
111
|
+
sigma_t = (1.0 - LAPSE_K_PER_M * H_TROPO_M / T0_K) ** ISA_DENSITY_EXP
|
|
112
|
+
return sigma_t * math.exp(-(altitude_m - H_TROPO_M) / H_ISO_M)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def isa_pressure_ratio_m(altitude_m):
|
|
116
|
+
"""ISA pressure ratio delta = P/P0 at geopotential altitude (m)."""
|
|
117
|
+
if altitude_m < 0 or altitude_m > MAX_H_M:
|
|
118
|
+
raise ValueError("altitude must be within 0 to 20000 m, got %r"
|
|
119
|
+
% (altitude_m,))
|
|
120
|
+
if altitude_m <= H_TROPO_M:
|
|
121
|
+
return (1.0 - LAPSE_K_PER_M * altitude_m / T0_K) ** ISA_PRESSURE_EXP
|
|
122
|
+
delta_t = (1.0 - LAPSE_K_PER_M * H_TROPO_M / T0_K) ** ISA_PRESSURE_EXP
|
|
123
|
+
return delta_t * math.exp(-(altitude_m - H_TROPO_M) / H_ISO_M)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def isa_temperature_m(altitude_m):
|
|
127
|
+
"""ISA static temperature in K at geopotential altitude (m)."""
|
|
128
|
+
if altitude_m < 0 or altitude_m > MAX_H_M:
|
|
129
|
+
raise ValueError("altitude must be within 0 to 20000 m, got %r"
|
|
130
|
+
% (altitude_m,))
|
|
131
|
+
if altitude_m <= H_TROPO_M:
|
|
132
|
+
return T0_K - LAPSE_K_PER_M * altitude_m
|
|
133
|
+
return T_TROPO_K
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def day_sigma_from_alt_oat(pressure_alt_m, oat_deg_c):
|
|
137
|
+
"""Test-day density ratio sigma = (P/P0_ISA) * (T0 / T_amb).
|
|
138
|
+
|
|
139
|
+
The test-day density ratio from the measured pressure altitude and
|
|
140
|
+
outside air temperature (the density-altitude formulation used by the
|
|
141
|
+
climb-performance leaf). Returns a float in (0, 1] for field levels.
|
|
142
|
+
"""
|
|
143
|
+
_check_nonnegative("pressure altitude", pressure_alt_m)
|
|
144
|
+
t_amb = oat_deg_c + 273.15
|
|
145
|
+
if t_amb <= 0:
|
|
146
|
+
raise ValueError("outside air temperature must be above absolute zero")
|
|
147
|
+
return isa_pressure_ratio_m(pressure_alt_m) * (T0_K / t_amb)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def density_altitude_m(pressure_alt_m, oat_deg_c):
|
|
151
|
+
"""Density altitude in m for a measured pressure altitude and OAT.
|
|
152
|
+
|
|
153
|
+
sigma from the pressure altitude and the ambient temperature; the
|
|
154
|
+
density altitude is the altitude where the ISA density ratio equals
|
|
155
|
+
sigma (bisection, same model as the climb-performance leaf).
|
|
156
|
+
"""
|
|
157
|
+
_check_nonnegative("pressure altitude", pressure_alt_m)
|
|
158
|
+
t_amb = oat_deg_c + 273.15
|
|
159
|
+
if t_amb <= 0:
|
|
160
|
+
raise ValueError("outside air temperature must be above absolute zero")
|
|
161
|
+
sigma = isa_pressure_ratio_m(pressure_alt_m) * (T0_K / t_amb)
|
|
162
|
+
lo, hi = 0.0, MAX_H_M
|
|
163
|
+
for _ in range(80):
|
|
164
|
+
mid = 0.5 * (lo + hi)
|
|
165
|
+
if isa_density_ratio_m(mid) > sigma:
|
|
166
|
+
lo = mid
|
|
167
|
+
else:
|
|
168
|
+
hi = mid
|
|
169
|
+
return 0.5 * (lo + hi)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def tas_from_eas(v_eas, rho, rho0=RHO0):
|
|
173
|
+
"""True airspeed in m/s from equivalent airspeed and air density.
|
|
174
|
+
|
|
175
|
+
V_tas = V_eas * sqrt(rho0 / rho) (equal dynamic pressure). Anchor:
|
|
176
|
+
110 m/s EAS at the 8000 m ISA density 0.52517 kg/m^3 -> 168.0 m/s.
|
|
177
|
+
"""
|
|
178
|
+
_check_positive("equivalent airspeed", v_eas)
|
|
179
|
+
_check_positive("air density", rho)
|
|
180
|
+
_check_positive("sea level density", rho0)
|
|
181
|
+
return v_eas * math.sqrt(rho0 / rho)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def speed_of_sound_m(altitude_m):
|
|
185
|
+
"""ISA speed of sound in m/s at altitude (m)."""
|
|
186
|
+
_check_nonnegative("altitude", altitude_m)
|
|
187
|
+
t = isa_temperature_m(altitude_m)
|
|
188
|
+
return math.sqrt(GAMMA * R_GAS * t)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def tas_from_mach(mach, altitude_m):
|
|
192
|
+
"""True airspeed in m/s from Mach number and altitude (m)."""
|
|
193
|
+
if mach < 0:
|
|
194
|
+
raise ValueError("mach must be >= 0, got %r" % (mach,))
|
|
195
|
+
return mach * speed_of_sound_m(altitude_m)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Weight corrections (V speeds / stall speeds / fuel flow)
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def vs1g_from_wing_loading(wing_loading_n_m2, rho_kg_m3, cl_max):
|
|
203
|
+
"""Reference stall speed Vs1g = sqrt(2*(W/S)/(rho*CLmax)), m/s."""
|
|
204
|
+
for name, value in (("wing loading", wing_loading_n_m2),
|
|
205
|
+
("air density", rho_kg_m3), ("cl_max", cl_max)):
|
|
206
|
+
_check_positive(name, value)
|
|
207
|
+
return math.sqrt(2.0 * wing_loading_n_m2 / (rho_kg_m3 * cl_max))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def weight_corrected_speed(v_ref, w_ref, w_new):
|
|
211
|
+
"""Speed corrected for a weight change: v_new = v_ref*sqrt(w_new/w_ref)."""
|
|
212
|
+
_check_positive("reference speed", v_ref)
|
|
213
|
+
_check_positive("reference weight", w_ref)
|
|
214
|
+
_check_positive("new weight", w_new)
|
|
215
|
+
return v_ref * math.sqrt(w_new / float(w_ref))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def stall_margin(vs, v_current):
|
|
219
|
+
"""Stall margin (v_current - vs) / vs (negative = below stall speed)."""
|
|
220
|
+
_check_positive("stall speed", vs)
|
|
221
|
+
_check_nonnegative("current speed", v_current)
|
|
222
|
+
return (v_current - vs) / vs
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def weight_correction_factor(w_test, w_ref):
|
|
226
|
+
"""Square-root fuel-flow weight correction sqrt(w_ref / w_test)."""
|
|
227
|
+
_check_positive("test weight", w_test)
|
|
228
|
+
_check_positive("reference weight", w_ref)
|
|
229
|
+
return math.sqrt(w_ref / w_test)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def corrected_fuel_flow(wf_measured, w_test, w_ref):
|
|
233
|
+
"""Fuel flow (kg/s) corrected from the test weight to the reference."""
|
|
234
|
+
_check_nonnegative("measured fuel flow", wf_measured)
|
|
235
|
+
return wf_measured * weight_correction_factor(w_test, w_ref)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def range_performance(tas_m_s, wf_corrected):
|
|
239
|
+
"""Range performance (distance per unit fuel mass), m/kg."""
|
|
240
|
+
_check_positive("corrected fuel flow", wf_corrected)
|
|
241
|
+
return tas_m_s / wf_corrected
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Traces: integration, differentiation, quality
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def ground_roll_integrate(speeds, times):
|
|
249
|
+
"""Ground distance from ground speed samples (trapezoid rule), m.
|
|
250
|
+
|
|
251
|
+
s = sum((v_i + v_{i+1})/2 * dt_i). Mirrors the takeoff-distance leaf.
|
|
252
|
+
"""
|
|
253
|
+
if len(speeds) != len(times):
|
|
254
|
+
raise ValueError("speed and time samples must be equal length")
|
|
255
|
+
if len(speeds) < 2:
|
|
256
|
+
raise ValueError("need at least two speed samples")
|
|
257
|
+
s = 0.0
|
|
258
|
+
for i in range(len(speeds) - 1):
|
|
259
|
+
v0, v1 = speeds[i], speeds[i + 1]
|
|
260
|
+
t0, t1 = times[i], times[i + 1]
|
|
261
|
+
if v0 < 0 or v1 < 0:
|
|
262
|
+
raise ValueError("speed samples must be >= 0")
|
|
263
|
+
if t1 <= t0:
|
|
264
|
+
raise ValueError("time samples must be strictly increasing")
|
|
265
|
+
s += (v0 + v1) / 2.0 * (t1 - t0)
|
|
266
|
+
return s
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def trace_quality_verdict(times, values, max_gap=None):
|
|
270
|
+
"""Data quality verdict for a reduced trace: NaN and gap flags.
|
|
271
|
+
|
|
272
|
+
Returns {"verdict": "ok"|"flagged", "issues": [...]}; mirrors the
|
|
273
|
+
data-reduction leaf's pre-analysis gate.
|
|
274
|
+
"""
|
|
275
|
+
if len(times) != len(values):
|
|
276
|
+
raise ValueError("times and values must have equal length")
|
|
277
|
+
if not times:
|
|
278
|
+
raise ValueError("trace must not be empty")
|
|
279
|
+
issues = []
|
|
280
|
+
for i, vi in enumerate(values):
|
|
281
|
+
if isinstance(vi, float) and math.isnan(vi):
|
|
282
|
+
issues.append({"type": "nan", "index": i, "detail": "NaN sample"})
|
|
283
|
+
if max_gap is not None and len(times) > 1:
|
|
284
|
+
for i in range(1, len(times)):
|
|
285
|
+
d = times[i] - times[i - 1]
|
|
286
|
+
if d > max_gap:
|
|
287
|
+
issues.append({"type": "gap", "index": i,
|
|
288
|
+
"detail": "time gap %.3f s exceeds %.3f s"
|
|
289
|
+
% (d, max_gap)})
|
|
290
|
+
return {"verdict": "ok" if not issues else "flagged", "issues": issues}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def smooth_trace(trace, window):
|
|
294
|
+
"""Moving average over an odd centered window (same length output)."""
|
|
295
|
+
if not isinstance(window, int) or window < 1 or window % 2 == 0:
|
|
296
|
+
raise ValueError("smoothing window must be a positive odd integer")
|
|
297
|
+
if not trace:
|
|
298
|
+
raise ValueError("trace must not be empty")
|
|
299
|
+
half = (window - 1) // 2
|
|
300
|
+
out = []
|
|
301
|
+
for i in range(len(trace)):
|
|
302
|
+
lo = max(0, i - half)
|
|
303
|
+
hi = min(len(trace) - 1, i + half)
|
|
304
|
+
out.append(sum(trace[lo:hi + 1]) / (hi - lo + 1.0))
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def acceleration_from_trace(speeds, times):
|
|
309
|
+
"""Acceleration in m/s^2: central differences, one-sided at the ends."""
|
|
310
|
+
if len(speeds) != len(times):
|
|
311
|
+
raise ValueError("airspeed and time lists must match in length")
|
|
312
|
+
n = len(speeds)
|
|
313
|
+
if n < 2:
|
|
314
|
+
raise ValueError("at least two samples are required")
|
|
315
|
+
for i in range(1, n):
|
|
316
|
+
if times[i] <= times[i - 1]:
|
|
317
|
+
raise ValueError("time must be strictly increasing")
|
|
318
|
+
out = [0.0] * n
|
|
319
|
+
out[0] = (speeds[1] - speeds[0]) / (times[1] - times[0])
|
|
320
|
+
for i in range(1, n - 1):
|
|
321
|
+
out[i] = (speeds[i + 1] - speeds[i - 1]) / (times[i + 1] - times[i - 1])
|
|
322
|
+
out[n - 1] = (speeds[n - 1] - speeds[n - 2]) / (times[n - 1] - times[n - 2])
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Level acceleration (total energy method) - thrust determination
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def specific_excess_power(v, a, g=G0, dh_dt=0.0):
|
|
331
|
+
"""Specific excess power P_s = dh/dt + V*a/g in m/s (level: V*a/g)."""
|
|
332
|
+
_check_positive("true airspeed", v)
|
|
333
|
+
_check_positive("gravity", g)
|
|
334
|
+
return dh_dt + v * a / g
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def excess_thrust_from_ps(ps, v, w):
|
|
338
|
+
"""Excess thrust in N: delta_T = W * P_s / V."""
|
|
339
|
+
_check_positive("true airspeed", v)
|
|
340
|
+
_check_positive("weight", w)
|
|
341
|
+
return w * ps / v
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def lift_coefficient(w, v, rho, s):
|
|
345
|
+
"""CL = W / (0.5 * rho * V^2 * S)."""
|
|
346
|
+
for name, value in (("weight", w), ("true airspeed", v),
|
|
347
|
+
("air density", rho), ("wing area", s)):
|
|
348
|
+
_check_positive(name, value)
|
|
349
|
+
return w / (0.5 * rho * v * v * s)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def drag_coefficient(cd0, k, cl):
|
|
353
|
+
"""CD = cd0 + k * CL^2 (parabolic drag polar)."""
|
|
354
|
+
for name, value in (("zero-lift drag coefficient", cd0),
|
|
355
|
+
("induced drag factor", k), ("lift coefficient", cl)):
|
|
356
|
+
_check_nonnegative(name, value)
|
|
357
|
+
return cd0 + k * cl * cl
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def drag_from_polar(v, rho, s, w, cd0, k):
|
|
361
|
+
"""Drag force in N from the parabolic polar at the test density."""
|
|
362
|
+
_check_positive("true airspeed", v)
|
|
363
|
+
_check_positive("air density", rho)
|
|
364
|
+
_check_positive("wing area", s)
|
|
365
|
+
_check_positive("weight", w)
|
|
366
|
+
cl = lift_coefficient(w, v, rho, s)
|
|
367
|
+
return 0.5 * rho * v * v * s * drag_coefficient(cd0, k, cl)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def thrust_available_estimate(delta_t, drag):
|
|
371
|
+
"""Installed thrust estimate in N: T = delta_T + D (drag gap closes)."""
|
|
372
|
+
_check_nonnegative("drag", drag)
|
|
373
|
+
total = delta_t + drag
|
|
374
|
+
if total < 0:
|
|
375
|
+
raise ValueError("negative thrust available: delta_T %r below drag %r"
|
|
376
|
+
% (delta_t, drag))
|
|
377
|
+
return total
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def thrust_from_acceleration(weight_n, accel_m_s2, drag_n, g_m_s2=G0):
|
|
381
|
+
"""Installed thrust determined from a level acceleration, in N.
|
|
382
|
+
|
|
383
|
+
T = D + (W/g)*a (Newton's second law along the flight path).
|
|
384
|
+
"""
|
|
385
|
+
_check_positive("weight", weight_n)
|
|
386
|
+
_check_nonnegative("acceleration", accel_m_s2)
|
|
387
|
+
_check_positive("drag", drag_n)
|
|
388
|
+
_check_positive("gravity", g_m_s2)
|
|
389
|
+
return drag_n + (weight_n / g_m_s2) * accel_m_s2
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def weight_corrected_ps(ps, w_test, w_ref):
|
|
393
|
+
"""P_s corrected to the reference weight: P_s_ref = P_s * w_test/w_ref."""
|
|
394
|
+
_check_positive("test weight", w_test)
|
|
395
|
+
_check_positive("reference weight", w_ref)
|
|
396
|
+
return ps * w_test / w_ref
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def density_corrected_ps(ps, rho_test, rho_std=RHO0, lapse_exp=LAPSE_EXP):
|
|
400
|
+
"""P_s corrected to the reference density at constant IAS, m/s.
|
|
401
|
+
|
|
402
|
+
P_s_std = P_s * (rho_test/rho_std)^(lapse_exp - 0.5): thrust scales
|
|
403
|
+
sigma^lapse_exp, true airspeed sigma^-0.5.
|
|
404
|
+
"""
|
|
405
|
+
_check_positive("test density", rho_test)
|
|
406
|
+
_check_positive("reference density", rho_std)
|
|
407
|
+
if not 0.0 < lapse_exp <= 1.0:
|
|
408
|
+
raise ValueError("thrust lapse exponent must be in (0, 1]")
|
|
409
|
+
return ps * (rho_test / rho_std) ** (lapse_exp - 0.5)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def ps_at_reference_conditions(ps, w_test, w_ref, rho_test,
|
|
413
|
+
rho_std=RHO0, lapse_exp=LAPSE_EXP):
|
|
414
|
+
"""P_s corrected to the reference weight AND the standard density."""
|
|
415
|
+
return density_corrected_ps(weight_corrected_ps(ps, w_test, w_ref),
|
|
416
|
+
rho_test, rho_std, lapse_exp)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _assessment_region(n, window):
|
|
420
|
+
margin = (window + 1) // 2
|
|
421
|
+
return margin, n - margin
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def reduce_level_accel_run(times_s, speeds_tas_ms, weight_n, rho_kg_m3,
|
|
425
|
+
s_m2, cd0, k, w_ref_n=None, rho_std=None,
|
|
426
|
+
window=5, dh_dt=0.0):
|
|
427
|
+
"""Reduce one level-acceleration run (total energy method).
|
|
428
|
+
|
|
429
|
+
Returns the smoothed trace, acceleration, specific excess power,
|
|
430
|
+
excess thrust, the assessment-region means (acceleration, P_s,
|
|
431
|
+
excess thrust, drag, thrust available), the sustained-over-band
|
|
432
|
+
verdict and, when requested, the corrected mean P_s at the reference
|
|
433
|
+
weight and standard density. Mirrors the level-acceleration leaf.
|
|
434
|
+
"""
|
|
435
|
+
_check_positive("test weight", weight_n)
|
|
436
|
+
if len(speeds_tas_ms) != len(times_s):
|
|
437
|
+
raise ValueError("airspeed and time lists must match in length")
|
|
438
|
+
n = len(speeds_tas_ms)
|
|
439
|
+
if n < 3:
|
|
440
|
+
raise ValueError("at least three samples are required")
|
|
441
|
+
for i in range(n):
|
|
442
|
+
_check_positive("true airspeed sample", speeds_tas_ms[i])
|
|
443
|
+
_check_positive("air density", rho_kg_m3)
|
|
444
|
+
if w_ref_n is not None:
|
|
445
|
+
_check_positive("reference weight", w_ref_n)
|
|
446
|
+
if rho_std is not None:
|
|
447
|
+
_check_positive("reference density", rho_std)
|
|
448
|
+
if isinstance(dh_dt, (int, float)):
|
|
449
|
+
dh_dt_list = [float(dh_dt)] * n
|
|
450
|
+
else:
|
|
451
|
+
if len(dh_dt) != n:
|
|
452
|
+
raise ValueError("dh_dt list must match the trace length")
|
|
453
|
+
dh_dt_list = list(dh_dt)
|
|
454
|
+
|
|
455
|
+
v_s = smooth_trace(speeds_tas_ms, window)
|
|
456
|
+
acc = acceleration_from_trace(v_s, times_s)
|
|
457
|
+
ps = [specific_excess_power(v_s[i], acc[i], G0, dh_dt_list[i])
|
|
458
|
+
for i in range(n)]
|
|
459
|
+
delta_t = [excess_thrust_from_ps(ps[i], v_s[i], weight_n)
|
|
460
|
+
for i in range(n)]
|
|
461
|
+
drag = [drag_from_polar(v_s[i], rho_kg_m3, s_m2, weight_n, cd0, k)
|
|
462
|
+
for i in range(n)]
|
|
463
|
+
t_avail = [thrust_available_estimate(delta_t[i], drag[i])
|
|
464
|
+
for i in range(n)]
|
|
465
|
+
start, end = _assessment_region(n, window)
|
|
466
|
+
if end <= start:
|
|
467
|
+
raise ValueError("trace too short for the smoothing window")
|
|
468
|
+
mean = lambda xs: sum(xs[start:end]) / (end - start) # noqa: E731
|
|
469
|
+
result = {
|
|
470
|
+
"density_kgm3": rho_kg_m3,
|
|
471
|
+
"window": window,
|
|
472
|
+
"assessment_start": start,
|
|
473
|
+
"assessment_end": end,
|
|
474
|
+
"v_smoothed": v_s,
|
|
475
|
+
"acceleration": acc,
|
|
476
|
+
"specific_excess_power": ps,
|
|
477
|
+
"excess_thrust": delta_t,
|
|
478
|
+
"drag": drag,
|
|
479
|
+
"thrust_available": t_avail,
|
|
480
|
+
"mean_acceleration": mean(acc),
|
|
481
|
+
"mean_specific_excess_power": mean(ps),
|
|
482
|
+
"mean_excess_thrust": mean(delta_t),
|
|
483
|
+
"mean_drag": mean(drag),
|
|
484
|
+
"mean_thrust_available": mean(t_avail),
|
|
485
|
+
"sustained_over_band": all(delta_t[i] > 0 for i in range(start, end)),
|
|
486
|
+
"mean_ps_reference_weight": None,
|
|
487
|
+
"mean_ps_reference_conditions": None,
|
|
488
|
+
}
|
|
489
|
+
if w_ref_n is not None:
|
|
490
|
+
result["mean_ps_reference_weight"] = weight_corrected_ps(
|
|
491
|
+
result["mean_specific_excess_power"], weight_n, w_ref_n)
|
|
492
|
+
if rho_std is not None:
|
|
493
|
+
result["mean_ps_reference_conditions"] = ps_at_reference_conditions(
|
|
494
|
+
result["mean_specific_excess_power"], weight_n, w_ref_n,
|
|
495
|
+
rho_kg_m3, rho_std)
|
|
496
|
+
return result
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
# Takeoff / landing distance reductions
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
def constant_accel_distance(v1_m_s, a_m_s2):
|
|
504
|
+
"""Distance to accelerate from rest to v1 (or stop): v1^2/(2a), m."""
|
|
505
|
+
_check_positive("speed", v1_m_s)
|
|
506
|
+
_check_positive("acceleration", a_m_s2)
|
|
507
|
+
return v1_m_s ** 2 / (2.0 * a_m_s2)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def engine_failure_distance(speeds, times, v_ef_mps):
|
|
511
|
+
"""Ground distance from brake release to the engine failure speed, m.
|
|
512
|
+
|
|
513
|
+
Trapezoid integration of the measured ground speed samples with a
|
|
514
|
+
partial trapezoid across the segment where v_ef is crossed (linear
|
|
515
|
+
interpolation of the crossing time). Mirrors the engine-failure
|
|
516
|
+
takeoff leaf.
|
|
517
|
+
"""
|
|
518
|
+
if len(speeds) != len(times):
|
|
519
|
+
raise ValueError("speed and time samples must be equal length")
|
|
520
|
+
if len(speeds) < 2:
|
|
521
|
+
raise ValueError("at least two samples are required")
|
|
522
|
+
_check_positive("engine failure speed", v_ef_mps)
|
|
523
|
+
if v_ef_mps < speeds[0] or v_ef_mps > speeds[-1]:
|
|
524
|
+
raise ValueError("v_ef must lie within the sampled speed range")
|
|
525
|
+
for i in range(len(speeds) - 1):
|
|
526
|
+
if speeds[i + 1] <= speeds[i] or times[i + 1] <= times[i]:
|
|
527
|
+
raise ValueError("speeds and times must be strictly increasing")
|
|
528
|
+
dist = 0.0
|
|
529
|
+
for j in range(len(speeds) - 1):
|
|
530
|
+
if speeds[j + 1] <= v_ef_mps:
|
|
531
|
+
dist += 0.5 * (speeds[j] + speeds[j + 1]) * (
|
|
532
|
+
times[j + 1] - times[j])
|
|
533
|
+
else:
|
|
534
|
+
frac = ((v_ef_mps - speeds[j])
|
|
535
|
+
/ (speeds[j + 1] - speeds[j]))
|
|
536
|
+
t_cross = times[j] + frac * (times[j + 1] - times[j])
|
|
537
|
+
dist += 0.5 * (speeds[j] + v_ef_mps) * (t_cross - times[j])
|
|
538
|
+
break
|
|
539
|
+
return dist
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def reduce_takeoff_run(run, h_target_m=OBSTACLE_35FT_M):
|
|
543
|
+
"""Reduce one takeoff run to its measured distance legs, m.
|
|
544
|
+
|
|
545
|
+
run keys: times_s, speeds_mps (ground speed samples from brake
|
|
546
|
+
release to rotation), v_rot_mps, t_rot_s, roc_mps (climb rate after
|
|
547
|
+
liftoff), liftoff_mps. Measured legs mirror the takeoff-distance
|
|
548
|
+
leaf: ground roll (trapezoid to rotation speed) + rotation leg
|
|
549
|
+
v_rot*t_rot + airborne climb v_liftoff*h_target/roc. The predicted
|
|
550
|
+
ground roll uses the constant-acceleration model at the measured
|
|
551
|
+
mean acceleration, giving the computed-vs-predicted comparison.
|
|
552
|
+
"""
|
|
553
|
+
times = run["times_s"]
|
|
554
|
+
speeds = run["speeds_mps"]
|
|
555
|
+
v_rot = run["v_rot_mps"]
|
|
556
|
+
t_rot = run["t_rot_s"]
|
|
557
|
+
roc = run["roc_mps"]
|
|
558
|
+
v_lf = run.get("liftoff_mps", speeds[-1])
|
|
559
|
+
_check_positive("rotation speed", v_rot)
|
|
560
|
+
_check_positive("rotation time", t_rot)
|
|
561
|
+
_check_positive("climb rate", roc)
|
|
562
|
+
_check_positive("liftoff speed", v_lf)
|
|
563
|
+
|
|
564
|
+
s_ground = ground_roll_integrate(speeds, times)
|
|
565
|
+
s_rot = v_rot * t_rot
|
|
566
|
+
s_air = v_lf * h_target_m / roc
|
|
567
|
+
total = s_ground + s_rot + s_air
|
|
568
|
+
|
|
569
|
+
a_mean = (speeds[-1] - speeds[0]) / (times[-1] - times[0])
|
|
570
|
+
s_ground_pred = constant_accel_distance(speeds[-1], a_mean)
|
|
571
|
+
total_pred = s_ground_pred + s_rot + s_air
|
|
572
|
+
return {
|
|
573
|
+
"id": run.get("id", "TO-1"),
|
|
574
|
+
"weight_n": run.get("weight_n"),
|
|
575
|
+
"v_rot_mps": v_rot,
|
|
576
|
+
"rotation_time_s": t_rot,
|
|
577
|
+
"liftoff_mps": v_lf,
|
|
578
|
+
"climb_rate_mps": roc,
|
|
579
|
+
"mean_accel_mps2": a_mean,
|
|
580
|
+
"ground_roll_m": s_ground,
|
|
581
|
+
"rotation_m": s_rot,
|
|
582
|
+
"climb_35ft_m": s_air,
|
|
583
|
+
"total_m": total,
|
|
584
|
+
"ground_roll_predicted_m": s_ground_pred,
|
|
585
|
+
"total_predicted_m": total_pred,
|
|
586
|
+
"speeds_mps": speeds,
|
|
587
|
+
"times_s": times,
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def reduce_landing_run(run, vs0_ref_eas_ms, w_ref_n, sigma_test,
|
|
592
|
+
factor=APPROACH_FACTOR,
|
|
593
|
+
field_length_factor=FIELD_LENGTH_FACTOR,
|
|
594
|
+
runway_m=None):
|
|
595
|
+
"""Reduce one landing run to the demonstrated + certified distances, m.
|
|
596
|
+
|
|
597
|
+
Vref = factor * Vs0 at the run weight, converted to true airspeed at
|
|
598
|
+
the test-day density ratio; airborne flare Vref*t_air; braking
|
|
599
|
+
ground roll recorded (measured) and modelled V_td^2/(2*mu*g);
|
|
600
|
+
certified field length = 1.67 * demonstrated total.
|
|
601
|
+
"""
|
|
602
|
+
w_run = run["weight_n"]
|
|
603
|
+
_check_positive("run weight", w_run)
|
|
604
|
+
vs0_run = weight_corrected_speed(vs0_ref_eas_ms, w_ref_n, w_run)
|
|
605
|
+
vref_eas = factor * vs0_run
|
|
606
|
+
vref_tas = tas_from_eas(vref_eas, RHO0 * sigma_test)
|
|
607
|
+
s_air = vref_tas * run["t_air_s"]
|
|
608
|
+
v_td = vref_tas
|
|
609
|
+
s_ground_meas = v_td ** 2 / (2.0 * run["a_brake_meas_mps2"])
|
|
610
|
+
total_meas = s_air + s_ground_meas
|
|
611
|
+
mu = run.get("mu_brake")
|
|
612
|
+
s_ground_pred = None
|
|
613
|
+
total_pred = None
|
|
614
|
+
if mu:
|
|
615
|
+
s_ground_pred = constant_accel_distance(v_td, mu * G0)
|
|
616
|
+
total_pred = s_air + s_ground_pred
|
|
617
|
+
certified = field_length_factor * total_meas
|
|
618
|
+
row = {
|
|
619
|
+
"id": run.get("id", "LD-1"),
|
|
620
|
+
"weight_n": w_run,
|
|
621
|
+
"vs0_run_eas_ms": vs0_run,
|
|
622
|
+
"vref_eas_ms": vref_eas,
|
|
623
|
+
"vref_tas_ms": vref_tas,
|
|
624
|
+
"airborne_m": s_air,
|
|
625
|
+
"ground_roll_measured_m": s_ground_meas,
|
|
626
|
+
"ground_roll_predicted_m": s_ground_pred,
|
|
627
|
+
"demonstrated_m": total_meas,
|
|
628
|
+
"predicted_m": total_pred,
|
|
629
|
+
"certified_field_length_m": certified,
|
|
630
|
+
"field_length_factor": field_length_factor,
|
|
631
|
+
}
|
|
632
|
+
if runway_m is not None:
|
|
633
|
+
margin = runway_m - certified
|
|
634
|
+
row["runway_m"] = runway_m
|
|
635
|
+
row["margin_m"] = margin
|
|
636
|
+
row["fits"] = margin >= 0
|
|
637
|
+
return row
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def reduce_accelerate_stop(v1_m_s, a_acc_m_s2, a_brake_m_s2):
|
|
641
|
+
"""Rejected takeoff accelerate-stop distance: accel leg + stop leg, m."""
|
|
642
|
+
_check_positive("decision speed", v1_m_s)
|
|
643
|
+
_check_positive("acceleration", a_acc_m_s2)
|
|
644
|
+
_check_positive("braking deceleration", a_brake_m_s2)
|
|
645
|
+
s_acc = constant_accel_distance(v1_m_s, a_acc_m_s2)
|
|
646
|
+
s_stop = constant_accel_distance(v1_m_s, a_brake_m_s2)
|
|
647
|
+
return {"accelerate_m": s_acc, "stop_m": s_stop,
|
|
648
|
+
"total_m": s_acc + s_stop}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def reduce_oei_takeoff(failure_leg_m, v1_m_s, v2_m_s, t_rec_s,
|
|
652
|
+
a_cont_m_s2, roc_oei_m_s, h_target_m=OBSTACLE_35FT_M):
|
|
653
|
+
"""Engine-out takeoff distance (continued takeoff legs chained), m.
|
|
654
|
+
|
|
655
|
+
failure leg (measured ground run to VEF) + recognition v1*t_rec +
|
|
656
|
+
continued ground (v2^2 - v1^2)/(2*a_cont) + climb at V2 to 35 ft.
|
|
657
|
+
"""
|
|
658
|
+
_check_nonnegative("failure distance", failure_leg_m)
|
|
659
|
+
_check_positive("decision speed", v1_m_s)
|
|
660
|
+
_check_positive("takeoff safety speed", v2_m_s)
|
|
661
|
+
if v2_m_s < v1_m_s:
|
|
662
|
+
raise ValueError("v2 must be at or above the decision speed v1")
|
|
663
|
+
_check_positive("recognition time", t_rec_s)
|
|
664
|
+
_check_positive("continued acceleration", a_cont_m_s2)
|
|
665
|
+
_check_positive("engine-out climb rate", roc_oei_m_s)
|
|
666
|
+
s_rec = v1_m_s * t_rec_s
|
|
667
|
+
s_ground = (v2_m_s ** 2 - v1_m_s ** 2) / (2.0 * a_cont_m_s2)
|
|
668
|
+
s_climb = v2_m_s * h_target_m / roc_oei_m_s
|
|
669
|
+
total = failure_leg_m + s_rec + s_ground + s_climb
|
|
670
|
+
return {"failure_m": failure_leg_m, "recognition_m": s_rec,
|
|
671
|
+
"ground_continue_m": s_ground, "climb_m": s_climb,
|
|
672
|
+
"total_m": total}
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# ---------------------------------------------------------------------------
|
|
676
|
+
# Cruise reduction (quadratic range-performance fit over Mach)
|
|
677
|
+
# ---------------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
def _solve_linear_system(matrix, rhs):
|
|
680
|
+
"""Solve A x = b by Gaussian elimination with partial pivoting."""
|
|
681
|
+
n = len(matrix)
|
|
682
|
+
a = [row[:] for row in matrix]
|
|
683
|
+
b = list(rhs)
|
|
684
|
+
for col in range(n):
|
|
685
|
+
pivot_row = max(range(col, n), key=lambda r: abs(a[r][col]))
|
|
686
|
+
if abs(a[pivot_row][col]) < 1.0e-300:
|
|
687
|
+
raise ValueError("singular linear system in quadratic fit")
|
|
688
|
+
if pivot_row != col:
|
|
689
|
+
a[col], a[pivot_row] = a[pivot_row], a[col]
|
|
690
|
+
b[col], b[pivot_row] = b[pivot_row], b[col]
|
|
691
|
+
for row in range(col + 1, n):
|
|
692
|
+
factor = a[row][col] / a[col][col]
|
|
693
|
+
for c in range(col, n):
|
|
694
|
+
a[row][c] -= factor * a[col][c]
|
|
695
|
+
b[row] -= factor * b[col]
|
|
696
|
+
solution = [0.0] * n
|
|
697
|
+
for row in range(n - 1, -1, -1):
|
|
698
|
+
acc = b[row] - sum(a[row][c] * solution[c] for c in range(row + 1, n))
|
|
699
|
+
solution[row] = acc / a[row][row]
|
|
700
|
+
return solution
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def reduce_cruise_points(points, w_ref, lrc_fraction=0.99):
|
|
704
|
+
"""Reduce cruise fuel-flow runs to the range-performance curve.
|
|
705
|
+
|
|
706
|
+
points: list of {mach, altitude_m, w_test_kg, wf_measured_kg_s}.
|
|
707
|
+
Each measured fuel flow is corrected to w_ref (sqrt weight
|
|
708
|
+
correction), range performance rp = tas/wf follows, and a quadratic
|
|
709
|
+
rp(M) is fitted by least squares. Returns the point table, the
|
|
710
|
+
coefficients, the fitted residuals, the max-range Mach (vertex) and
|
|
711
|
+
the long-range-cruise Mach at lrc_fraction of the maximum.
|
|
712
|
+
"""
|
|
713
|
+
if len(points) < 3:
|
|
714
|
+
raise ValueError("at least 3 test points are required")
|
|
715
|
+
_check_positive("reference weight", w_ref)
|
|
716
|
+
machs = [p["mach"] for p in points]
|
|
717
|
+
for mach in machs:
|
|
718
|
+
if not (0.3 < mach < 1.0):
|
|
719
|
+
raise ValueError("mach must lie within (0.3, 1.0)")
|
|
720
|
+
if len(set(machs)) != len(machs):
|
|
721
|
+
raise ValueError("duplicate Mach values are not allowed")
|
|
722
|
+
table = []
|
|
723
|
+
for p in points:
|
|
724
|
+
_check_positive("test weight", p["w_test_kg"])
|
|
725
|
+
_check_positive("measured fuel flow", p["wf_measured_kg_s"])
|
|
726
|
+
tas = tas_from_mach(p["mach"], p["altitude_m"])
|
|
727
|
+
wf_c = corrected_fuel_flow(p["wf_measured_kg_s"],
|
|
728
|
+
p["w_test_kg"], w_ref)
|
|
729
|
+
table.append({"mach": p["mach"], "altitude_m": p["altitude_m"],
|
|
730
|
+
"w_test_kg": p["w_test_kg"],
|
|
731
|
+
"wf_measured_kg_s": p["wf_measured_kg_s"],
|
|
732
|
+
"tas": tas, "wf_corr": wf_c,
|
|
733
|
+
"rp": range_performance(tas, wf_c)})
|
|
734
|
+
n = len(table)
|
|
735
|
+
sum_x = sum(p["mach"] for p in table)
|
|
736
|
+
sum_x2 = sum(p["mach"] ** 2 for p in table)
|
|
737
|
+
sum_x3 = sum(p["mach"] ** 3 for p in table)
|
|
738
|
+
sum_x4 = sum(p["mach"] ** 4 for p in table)
|
|
739
|
+
sum_y = sum(p["rp"] for p in table)
|
|
740
|
+
sum_xy = sum(p["mach"] * p["rp"] for p in table)
|
|
741
|
+
sum_x2y = sum(p["mach"] ** 2 * p["rp"] for p in table)
|
|
742
|
+
normal = [[sum_x4, sum_x3, sum_x2], [sum_x3, sum_x2, sum_x],
|
|
743
|
+
[sum_x2, sum_x, float(n)]]
|
|
744
|
+
rhs = [sum_x2y, sum_xy, sum_y]
|
|
745
|
+
c2, c1, c0 = _solve_linear_system(normal, rhs)
|
|
746
|
+
fitted = [c2 * p["mach"] ** 2 + c1 * p["mach"] + c0 for p in table]
|
|
747
|
+
residuals = [f - p["rp"] for f, p in zip(fitted, table)]
|
|
748
|
+
if c2 < 0.0:
|
|
749
|
+
max_rp_mach = -c1 / (2.0 * c2)
|
|
750
|
+
max_rp = c2 * max_rp_mach ** 2 + c1 * max_rp_mach + c0
|
|
751
|
+
target = lrc_fraction * max_rp
|
|
752
|
+
disc = c1 ** 2 - 4.0 * c2 * (c0 - target)
|
|
753
|
+
lrc_mach = (max((-c1 - math.sqrt(disc)) / (2.0 * c2),
|
|
754
|
+
(-c1 + math.sqrt(disc)) / (2.0 * c2))
|
|
755
|
+
if disc >= 0.0 else None)
|
|
756
|
+
verdict = "maximum-found"
|
|
757
|
+
else:
|
|
758
|
+
max_rp_mach = max_rp = lrc_mach = None
|
|
759
|
+
verdict = "no-maximum"
|
|
760
|
+
mean_rp = sum(p["rp"] for p in table) / n
|
|
761
|
+
rms = math.sqrt(sum(r * r for r in residuals) / n)
|
|
762
|
+
return {"points": table, "coefficients": (c2, c1, c0),
|
|
763
|
+
"fitted": fitted, "residuals": residuals,
|
|
764
|
+
"max_rp_mach": max_rp_mach, "max_rp": max_rp,
|
|
765
|
+
"lrc_mach": lrc_mach, "verdict": verdict,
|
|
766
|
+
"mean_rp": mean_rp, "rms_residual": rms}
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def scatter_row(label, measured, predicted):
|
|
770
|
+
"""Computed-vs-predicted scatter row with percent delta and band flag."""
|
|
771
|
+
if measured == 0:
|
|
772
|
+
raise ValueError("measured value must be non-zero for a scatter row")
|
|
773
|
+
delta_pct = (predicted - measured) / abs(measured) * 100.0
|
|
774
|
+
return {"item": label, "measured": measured, "predicted": predicted,
|
|
775
|
+
"delta_pct": delta_pct,
|
|
776
|
+
"within_band": abs(delta_pct) <= SCATTER_BAND_PCT}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# ---------------------------------------------------------------------------
|
|
780
|
+
# Project facts (the sortie data set = the item)
|
|
781
|
+
# ---------------------------------------------------------------------------
|
|
782
|
+
|
|
783
|
+
@dataclass
|
|
784
|
+
class PerformanceTestItem:
|
|
785
|
+
"""Sortie data set + aircraft facts the role reduces and reports on."""
|
|
786
|
+
aircraft_name: str
|
|
787
|
+
description: str = ""
|
|
788
|
+
basis: str = "FAR/CS-25 (context only)"
|
|
789
|
+
w_ref_n: float = 250000.0 # reference gross weight for corrections
|
|
790
|
+
s_m2: float = 122.6
|
|
791
|
+
cd0: float = 0.020
|
|
792
|
+
k: float = 0.042
|
|
793
|
+
cl_max: float = 2.0 # clean (1g stall) max lift coefficient
|
|
794
|
+
cl_max_land: float = 2.7 # landing-config max lift coefficient
|
|
795
|
+
runway_m: float = 900.0 # available landing runway length
|
|
796
|
+
field_pressure_alt_m: float = 152.4 # ~500 ft field elevation
|
|
797
|
+
field_oat_c: float = 22.0 # test-day outside air temperature
|
|
798
|
+
sortie_id: str = "SORTIE-FT2-047"
|
|
799
|
+
takeoff_run: dict = field(default_factory=dict)
|
|
800
|
+
landing_run: dict = field(default_factory=dict)
|
|
801
|
+
level_accel_run: dict = field(default_factory=dict)
|
|
802
|
+
cruise_points: list = field(default_factory=list)
|
|
803
|
+
stall_runs: list = field(default_factory=list)
|
|
804
|
+
oei_run: dict = field(default_factory=dict)
|
|
805
|
+
accel_stop: dict = field(default_factory=dict)
|
|
806
|
+
|
|
807
|
+
def wing_loading_n_m2(self, weight_n=None):
|
|
808
|
+
return (weight_n or self.w_ref_n) / self.s_m2
|
|
809
|
+
|
|
810
|
+
def vs1g_ref_eas_ms(self):
|
|
811
|
+
"""Clean 1g stall speed at the reference weight, m/s EAS."""
|
|
812
|
+
return vs1g_from_wing_loading(self.wing_loading_n_m2(self.w_ref_n),
|
|
813
|
+
RHO0, self.cl_max)
|
|
814
|
+
|
|
815
|
+
def vs0_ref_eas_ms(self):
|
|
816
|
+
"""Landing-config stall speed at the reference weight, m/s EAS."""
|
|
817
|
+
return vs1g_from_wing_loading(self.wing_loading_n_m2(self.w_ref_n),
|
|
818
|
+
RHO0, self.cl_max_land)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _takeoff_speed_profile(t_rot_idx=19, n_samples=21):
|
|
822
|
+
"""Deterministic worked-example ground speed profile v(t), m/s.
|
|
823
|
+
|
|
824
|
+
v(t) = 2.0*t + 0.015*t^2 sampled every second from brake release
|
|
825
|
+
(t=0) to rotation at t=t_rot_idx; the liftoff sample follows at the
|
|
826
|
+
next second. The small quadratic term keeps the trapezoid integral
|
|
827
|
+
different from the constant-acceleration model, giving the example
|
|
828
|
+
a realistic computed-vs-predicted scatter.
|
|
829
|
+
"""
|
|
830
|
+
times = [float(i) for i in range(n_samples)]
|
|
831
|
+
speeds = [round(2.0 * t + 0.015 * t * t, 3) for t in times]
|
|
832
|
+
return times, speeds, speeds[t_rot_idx]
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def example_item() -> PerformanceTestItem:
|
|
836
|
+
"""The worked-example item: a flight-test sortie data set for
|
|
837
|
+
takeoff / landing / cruise test points (SIMULATED data)."""
|
|
838
|
+
times, speeds, v_rot = _takeoff_speed_profile()
|
|
839
|
+
takeoff_run = {
|
|
840
|
+
"id": "TO-1",
|
|
841
|
+
"weight_n": 240000.0,
|
|
842
|
+
"times_s": times,
|
|
843
|
+
"speeds_mps": speeds,
|
|
844
|
+
"v_rot_mps": v_rot, # speed at rotation, m/s ground speed
|
|
845
|
+
"t_rot_s": 3.0, # recorded rotation time, s
|
|
846
|
+
"roc_mps": 6.0, # measured climb rate after liftoff
|
|
847
|
+
"liftoff_mps": speeds[-1],
|
|
848
|
+
}
|
|
849
|
+
landing_run = {
|
|
850
|
+
"id": "LD-1",
|
|
851
|
+
"weight_n": 232000.0,
|
|
852
|
+
"t_air_s": 5.0, # recorded flare/airborne time, s
|
|
853
|
+
"a_brake_meas_mps2": 3.9, # measured braking deceleration
|
|
854
|
+
"mu_brake": 0.42, # dry-runway braking friction model
|
|
855
|
+
}
|
|
856
|
+
# Level acceleration at 8000 m ISA, MTOW, 150 -> 170 m/s TAS in 20 s
|
|
857
|
+
n = 21
|
|
858
|
+
la_times = [float(i) for i in range(n)]
|
|
859
|
+
la_speeds = [round(150.0 + 1.0 * i, 3) for i in range(n)]
|
|
860
|
+
level_accel_run = {
|
|
861
|
+
"id": "LA-1",
|
|
862
|
+
"weight_n": 250000.0,
|
|
863
|
+
"altitude_m": 8000.0, # ISA density used at the test altitude
|
|
864
|
+
"times_s": la_times,
|
|
865
|
+
"speeds_tas_ms": la_speeds,
|
|
866
|
+
"window": 5,
|
|
867
|
+
}
|
|
868
|
+
# Cruise fuel-flow sweep at FL350 with a linear weight ramp; test
|
|
869
|
+
# masses in kg (gross weights 240000 -> 234000 N converted / g).
|
|
870
|
+
cruise_points = [
|
|
871
|
+
{"mach": 0.70, "altitude_m": 10668.0,
|
|
872
|
+
"w_test_kg": round(240000.0 / G0, 1), "wf_measured_kg_s": 0.455},
|
|
873
|
+
{"mach": 0.74, "altitude_m": 10668.0,
|
|
874
|
+
"w_test_kg": round(238000.0 / G0, 1), "wf_measured_kg_s": 0.442},
|
|
875
|
+
{"mach": 0.78, "altitude_m": 10668.0,
|
|
876
|
+
"w_test_kg": round(236000.0 / G0, 1), "wf_measured_kg_s": 0.448},
|
|
877
|
+
{"mach": 0.82, "altitude_m": 10668.0,
|
|
878
|
+
"w_test_kg": round(234000.0 / G0, 1), "wf_measured_kg_s": 0.500},
|
|
879
|
+
]
|
|
880
|
+
# Stall runs at the takeoff gross weight
|
|
881
|
+
stall_runs = [
|
|
882
|
+
{"config": "clean", "weight_n": 240000.0,
|
|
883
|
+
"vs_measured_eas_ms": 39.9, "warning_onset_mps": 41.2},
|
|
884
|
+
{"config": "landing", "weight_n": 232000.0,
|
|
885
|
+
"vs_measured_eas_ms": 34.1, "warning_onset_mps": 35.6},
|
|
886
|
+
]
|
|
887
|
+
oei_run = {
|
|
888
|
+
"id": "TO-OEI-1",
|
|
889
|
+
"weight_n": 240000.0,
|
|
890
|
+
"v_ef_mps": 37.0, "v1_mps": 38.0, "v2_mps": 46.3,
|
|
891
|
+
"t_rec_s": 1.0, "a_cont_mps2": 2.2, "roc_oei_mps": 2.8,
|
|
892
|
+
}
|
|
893
|
+
accel_stop = {"v1_mps": 38.0, "a_acc_mps2": 2.3, "mu_brake": 0.42}
|
|
894
|
+
return PerformanceTestItem(
|
|
895
|
+
aircraft_name="FT-2 Worked-Example Turbofan Regional Jet",
|
|
896
|
+
description="Simulated flight-test sortie data set covering "
|
|
897
|
+
"takeoff, landing, level-acceleration and cruise "
|
|
898
|
+
"test points (worked example).",
|
|
899
|
+
sortie_id="SORTIE-FT2-047",
|
|
900
|
+
takeoff_run=takeoff_run,
|
|
901
|
+
landing_run=landing_run,
|
|
902
|
+
level_accel_run=level_accel_run,
|
|
903
|
+
cruise_points=cruise_points,
|
|
904
|
+
stall_runs=stall_runs,
|
|
905
|
+
oei_run=oei_run,
|
|
906
|
+
accel_stop=accel_stop,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
# ---------------------------------------------------------------------------
|
|
911
|
+
# Sortie analysis: reduce everything into the results model
|
|
912
|
+
# ---------------------------------------------------------------------------
|
|
913
|
+
|
|
914
|
+
def analyze_sortie(item: PerformanceTestItem) -> dict:
|
|
915
|
+
"""Reduce the full sortie data set to the analysis results model."""
|
|
916
|
+
w_ref = item.w_ref_n
|
|
917
|
+
vs1g_ref = item.vs1g_ref_eas_ms()
|
|
918
|
+
vs0_ref = item.vs0_ref_eas_ms()
|
|
919
|
+
sigma_test = day_sigma_from_alt_oat(item.field_pressure_alt_m, item.field_oat_c)
|
|
920
|
+
rho_test = RHO0 * sigma_test
|
|
921
|
+
da_m = density_altitude_m(item.field_pressure_alt_m, item.field_oat_c)
|
|
922
|
+
da_ft = da_m * FT_PER_M
|
|
923
|
+
_, _, rho_la = isa_conditions(item.level_accel_run["altitude_m"])
|
|
924
|
+
|
|
925
|
+
# -- conditions --------------------------------------------------------
|
|
926
|
+
conditions = {
|
|
927
|
+
"pressure_alt_m": item.field_pressure_alt_m,
|
|
928
|
+
"oat_deg_c": item.field_oat_c,
|
|
929
|
+
"sigma_test": sigma_test,
|
|
930
|
+
"rho_test_kgm3": rho_test,
|
|
931
|
+
"density_altitude_m": da_m,
|
|
932
|
+
"density_altitude_ft": da_ft,
|
|
933
|
+
"delta_from_isa_k": (item.field_oat_c + 273.15)
|
|
934
|
+
- isa_temperature_m(item.field_pressure_alt_m),
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
# -- data quality (speed traces) ----------------------------------------
|
|
938
|
+
quality = {
|
|
939
|
+
"takeoff_trace": trace_quality_verdict(
|
|
940
|
+
item.takeoff_run["times_s"], item.takeoff_run["speeds_mps"],
|
|
941
|
+
max_gap=2.0),
|
|
942
|
+
"level_accel_trace": trace_quality_verdict(
|
|
943
|
+
item.level_accel_run["times_s"],
|
|
944
|
+
item.level_accel_run["speeds_tas_ms"], max_gap=2.0),
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
# -- takeoff -------------------------------------------------------------
|
|
948
|
+
to = reduce_takeoff_run(item.takeoff_run)
|
|
949
|
+
# measured failure leg: trapezoid of the recorded profile to VEF
|
|
950
|
+
oei_failure_leg = engine_failure_distance(
|
|
951
|
+
item.takeoff_run["speeds_mps"], item.takeoff_run["times_s"],
|
|
952
|
+
item.oei_run["v_ef_mps"])
|
|
953
|
+
oei = reduce_oei_takeoff(
|
|
954
|
+
oei_failure_leg, item.oei_run["v1_mps"],
|
|
955
|
+
item.oei_run["v2_mps"], item.oei_run["t_rec_s"],
|
|
956
|
+
item.oei_run["a_cont_mps2"], item.oei_run["roc_oei_mps"])
|
|
957
|
+
oei["failure_measured_m"] = oei_failure_leg
|
|
958
|
+
asd = reduce_accelerate_stop(item.accel_stop["v1_mps"],
|
|
959
|
+
item.accel_stop["a_acc_mps2"],
|
|
960
|
+
item.accel_stop["mu_brake"] * G0)
|
|
961
|
+
takeoff = {"run": to, "oei": oei, "accelerate_stop": asd,
|
|
962
|
+
"v1_mps": item.oei_run["v1_mps"],
|
|
963
|
+
"v2_mps": item.oei_run["v2_mps"]}
|
|
964
|
+
|
|
965
|
+
# -- landing -------------------------------------------------------------
|
|
966
|
+
landing = reduce_landing_run(
|
|
967
|
+
item.landing_run, vs0_ref, w_ref, sigma_test, runway_m=item.runway_m)
|
|
968
|
+
|
|
969
|
+
# -- level acceleration ---------------------------------------------------
|
|
970
|
+
la = item.level_accel_run
|
|
971
|
+
la_res = reduce_level_accel_run(
|
|
972
|
+
la["times_s"], la["speeds_tas_ms"], la["weight_n"], rho_la,
|
|
973
|
+
item.s_m2, item.cd0, item.k, w_ref_n=w_ref, rho_std=RHO0,
|
|
974
|
+
window=la["window"])
|
|
975
|
+
la_res["run_id"] = la["id"]
|
|
976
|
+
la_res["altitude_m"] = la["altitude_m"]
|
|
977
|
+
la_res["weight_n"] = la["weight_n"]
|
|
978
|
+
|
|
979
|
+
# -- cruise ---------------------------------------------------------------
|
|
980
|
+
# Fuel-flow reductions use masses (kg); the reference mass is the
|
|
981
|
+
# reference gross weight expressed as a mass (W_ref / g).
|
|
982
|
+
cruise = reduce_cruise_points(item.cruise_points, w_ref / G0)
|
|
983
|
+
cruise["w_ref_kg"] = w_ref / G0
|
|
984
|
+
|
|
985
|
+
# -- speeds / stall --------------------------------------------------------
|
|
986
|
+
speeds = {
|
|
987
|
+
"vs1g_ref_eas_ms": vs1g_ref,
|
|
988
|
+
"vs0_ref_eas_ms": vs0_ref,
|
|
989
|
+
"stall_rows": [],
|
|
990
|
+
}
|
|
991
|
+
for s in item.stall_runs:
|
|
992
|
+
vs_pred = weight_corrected_speed(vs1g_ref if s["config"] == "clean"
|
|
993
|
+
else vs0_ref, w_ref, s["weight_n"])
|
|
994
|
+
margin = stall_margin(vs_pred, s["vs_measured_eas_ms"])
|
|
995
|
+
speeds["stall_rows"].append({
|
|
996
|
+
"config": s["config"], "weight_n": s["weight_n"],
|
|
997
|
+
"vs_measured_eas_ms": s["vs_measured_eas_ms"],
|
|
998
|
+
"vs_predicted_eas_ms": vs_pred,
|
|
999
|
+
"delta_pct": (s["vs_measured_eas_ms"] - vs_pred) / vs_pred * 100.0,
|
|
1000
|
+
"stall_margin": margin,
|
|
1001
|
+
"warning_onset_mps": s["warning_onset_mps"],
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
# -- computed vs predicted scatter -----------------------------------------
|
|
1005
|
+
scatter_rows = [
|
|
1006
|
+
scatter_row("takeoff ground roll", to["ground_roll_m"],
|
|
1007
|
+
to["ground_roll_predicted_m"]),
|
|
1008
|
+
scatter_row("takeoff distance (35 ft)", to["total_m"],
|
|
1009
|
+
to["total_predicted_m"]),
|
|
1010
|
+
]
|
|
1011
|
+
if landing["ground_roll_predicted_m"] is not None:
|
|
1012
|
+
scatter_rows.append(scatter_row(
|
|
1013
|
+
"landing ground roll", landing["ground_roll_measured_m"],
|
|
1014
|
+
landing["ground_roll_predicted_m"]))
|
|
1015
|
+
scatter_rows.append(scatter_row(
|
|
1016
|
+
"landing distance", landing["demonstrated_m"],
|
|
1017
|
+
landing["predicted_m"]))
|
|
1018
|
+
# cruise fit residuals: fitted vs measured range performance per point
|
|
1019
|
+
for p, f in zip(cruise["points"], cruise["fitted"]):
|
|
1020
|
+
scatter_rows.append(scatter_row(
|
|
1021
|
+
"cruise rp M=%.2f" % p["mach"], p["rp"], f))
|
|
1022
|
+
for s in speeds["stall_rows"]:
|
|
1023
|
+
scatter_rows.append(scatter_row(
|
|
1024
|
+
"Vs1g %s" % s["config"], s["vs_measured_eas_ms"],
|
|
1025
|
+
s["vs_predicted_eas_ms"]))
|
|
1026
|
+
max_abs = max(abs(r["delta_pct"]) for r in scatter_rows)
|
|
1027
|
+
scatter = {"rows": scatter_rows, "max_abs_delta_pct": max_abs,
|
|
1028
|
+
"band_pct": SCATTER_BAND_PCT,
|
|
1029
|
+
"within_band": max_abs <= SCATTER_BAND_PCT}
|
|
1030
|
+
|
|
1031
|
+
return {
|
|
1032
|
+
"conditions": conditions,
|
|
1033
|
+
"quality": quality,
|
|
1034
|
+
"takeoff": takeoff,
|
|
1035
|
+
"landing": landing,
|
|
1036
|
+
"level_accel": la_res,
|
|
1037
|
+
"cruise": cruise,
|
|
1038
|
+
"speeds": speeds,
|
|
1039
|
+
"scatter": scatter,
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
# ---------------------------------------------------------------------------
|
|
1044
|
+
# Report builder / renderer / gates
|
|
1045
|
+
# ---------------------------------------------------------------------------
|
|
1046
|
+
|
|
1047
|
+
def build_report(item: PerformanceTestItem,
|
|
1048
|
+
results: dict | None = None) -> dict:
|
|
1049
|
+
"""Build the Flight Test Performance Data Analysis Report model."""
|
|
1050
|
+
if results is None:
|
|
1051
|
+
results = analyze_sortie(item)
|
|
1052
|
+
return {
|
|
1053
|
+
"document_type": "Flight Test Performance Data Analysis Report",
|
|
1054
|
+
"status": "draft-for-review",
|
|
1055
|
+
"item": item.aircraft_name,
|
|
1056
|
+
"sortie_id": item.sortie_id,
|
|
1057
|
+
"description": item.description,
|
|
1058
|
+
"basis": item.basis,
|
|
1059
|
+
"aircraft": {
|
|
1060
|
+
"w_ref_n": item.w_ref_n, "s_m2": item.s_m2, "cd0": item.cd0,
|
|
1061
|
+
"k": item.k, "cl_max": item.cl_max, "cl_max_land": item.cl_max_land,
|
|
1062
|
+
"vs1g_ref_eas_ms": item.vs1g_ref_eas_ms(),
|
|
1063
|
+
"vs0_ref_eas_ms": item.vs0_ref_eas_ms(),
|
|
1064
|
+
"runway_m": item.runway_m,
|
|
1065
|
+
},
|
|
1066
|
+
"results": results,
|
|
1067
|
+
"generated": _today(),
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def render_report_markdown(model: dict) -> str:
|
|
1072
|
+
"""Render the analysis results as the deliverable markdown document."""
|
|
1073
|
+
r = model["results"]
|
|
1074
|
+
c = r["conditions"]
|
|
1075
|
+
ac = model["aircraft"]
|
|
1076
|
+
la = r["level_accel"]
|
|
1077
|
+
cruise = r["cruise"]
|
|
1078
|
+
sc = r["scatter"]
|
|
1079
|
+
lines = [
|
|
1080
|
+
"# Flight Test Performance Data Analysis Report",
|
|
1081
|
+
"",
|
|
1082
|
+
"**Aircraft:** %s" % model["item"],
|
|
1083
|
+
"**Sortie:** %s" % model["sortie_id"],
|
|
1084
|
+
"**Basis:** %s" % model["basis"],
|
|
1085
|
+
"**Status:** %s" % model["status"],
|
|
1086
|
+
"",
|
|
1087
|
+
"_Flight data below are SIMULATED for the worked example and must "
|
|
1088
|
+
"be replaced by recorded flight data._",
|
|
1089
|
+
"",
|
|
1090
|
+
"## 1. Test conditions and data quality",
|
|
1091
|
+
"",
|
|
1092
|
+
"- Field pressure altitude: %.0f m (%.0f ft); OAT %.1f C "
|
|
1093
|
+
"(ISA deviation %+.1f K)."
|
|
1094
|
+
% (c["pressure_alt_m"], c["pressure_alt_m"] * FT_PER_M,
|
|
1095
|
+
c["oat_deg_c"], c["delta_from_isa_k"]),
|
|
1096
|
+
"- Test-day density ratio sigma = %.4f (rho = %.4f kg/m^3); "
|
|
1097
|
+
"density altitude %.0f m (%.0f ft)."
|
|
1098
|
+
% (c["sigma_test"], c["rho_test_kgm3"], c["density_altitude_m"],
|
|
1099
|
+
c["density_altitude_ft"]),
|
|
1100
|
+
"- Reference gross weight for corrections: %.0f N. Wing area "
|
|
1101
|
+
"%.1f m^2; drag polar cd0 = %.3f, k = %.3f."
|
|
1102
|
+
% (ac["w_ref_n"], ac["s_m2"], ac["cd0"], ac["k"]),
|
|
1103
|
+
"- Data quality: takeoff trace %s; level-acceleration trace %s."
|
|
1104
|
+
% (r["quality"]["takeoff_trace"]["verdict"],
|
|
1105
|
+
r["quality"]["level_accel_trace"]["verdict"]),
|
|
1106
|
+
"",
|
|
1107
|
+
"## 2. Takeoff performance",
|
|
1108
|
+
"",
|
|
1109
|
+
]
|
|
1110
|
+
to = r["takeoff"]
|
|
1111
|
+
row = to["run"]
|
|
1112
|
+
lines += [
|
|
1113
|
+
"Run %s at W = %.0f N: liftoff %.1f m/s at a mean acceleration of "
|
|
1114
|
+
"%.2f m/s^2."
|
|
1115
|
+
% (row["id"], row["weight_n"], row["liftoff_mps"],
|
|
1116
|
+
row["mean_accel_mps2"]),
|
|
1117
|
+
"",
|
|
1118
|
+
"| Leg | Measured (m) | Predicted (m) |",
|
|
1119
|
+
"|---|---|---|",
|
|
1120
|
+
"| Ground roll (to rotation) | %.1f | %.1f |"
|
|
1121
|
+
% (row["ground_roll_m"], row["ground_roll_predicted_m"]),
|
|
1122
|
+
"| Rotation | %.1f | %.1f |" % (row["rotation_m"], row["rotation_m"]),
|
|
1123
|
+
"| Airborne to 35 ft | %.1f | %.1f |"
|
|
1124
|
+
% (row["climb_35ft_m"], row["climb_35ft_m"]),
|
|
1125
|
+
"| **Takeoff distance** | **%.1f** | **%.1f** |"
|
|
1126
|
+
% (row["total_m"], row["total_predicted_m"]),
|
|
1127
|
+
"",
|
|
1128
|
+
"Engine-out (OEI) continued takeoff at V1 = %.1f m/s, V2 = %.1f "
|
|
1129
|
+
"m/s: failure leg %.1f m + recognition %.1f m + continued ground "
|
|
1130
|
+
"%.1f m + climb %.1f m = **%.1f m**."
|
|
1131
|
+
% (to["v1_mps"], to["v2_mps"], to["oei"]["failure_m"],
|
|
1132
|
+
to["oei"]["recognition_m"], to["oei"]["ground_continue_m"],
|
|
1133
|
+
to["oei"]["climb_m"], to["oei"]["total_m"]),
|
|
1134
|
+
"Accelerate-stop at V1 = %.1f m/s: accelerate %.1f m + stop %.1f m "
|
|
1135
|
+
"= **%.1f m**."
|
|
1136
|
+
% (to["v1_mps"], to["accelerate_stop"]["accelerate_m"],
|
|
1137
|
+
to["accelerate_stop"]["stop_m"],
|
|
1138
|
+
to["accelerate_stop"]["total_m"]),
|
|
1139
|
+
"",
|
|
1140
|
+
"## 3. Landing performance",
|
|
1141
|
+
"",
|
|
1142
|
+
]
|
|
1143
|
+
ld = r["landing"]
|
|
1144
|
+
lines += [
|
|
1145
|
+
"Run %s at W = %.0f N: Vs0 at run weight %.2f m/s EAS, "
|
|
1146
|
+
"Vref = %.2f m/s EAS (1.23 x Vs0), %.2f m/s TAS at the test-day "
|
|
1147
|
+
"density ratio."
|
|
1148
|
+
% (ld["id"], ld["weight_n"], ld["vs0_run_eas_ms"],
|
|
1149
|
+
ld["vref_eas_ms"], ld["vref_tas_ms"]),
|
|
1150
|
+
"",
|
|
1151
|
+
"| Segment | Measured (m) | Model (m) |",
|
|
1152
|
+
"|---|---|---|",
|
|
1153
|
+
"| Airborne (flare) | %.1f | %.1f |" % (ld["airborne_m"],
|
|
1154
|
+
ld["airborne_m"]),
|
|
1155
|
+
"| Ground roll (braking) | %.1f | %s |"
|
|
1156
|
+
% (ld["ground_roll_measured_m"],
|
|
1157
|
+
"%.1f" % ld["ground_roll_predicted_m"]
|
|
1158
|
+
if ld["ground_roll_predicted_m"] is not None else "n/a"),
|
|
1159
|
+
"| **Demonstrated total** | **%.1f** | %s |"
|
|
1160
|
+
% (ld["demonstrated_m"],
|
|
1161
|
+
"%.1f" % ld["predicted_m"]
|
|
1162
|
+
if ld["predicted_m"] is not None else "n/a"),
|
|
1163
|
+
"",
|
|
1164
|
+
"Certified field length = %.2f x demonstrated = **%.1f m** "
|
|
1165
|
+
"against a %.0f m runway: %s (margin %+.1f m)."
|
|
1166
|
+
% (ld["field_length_factor"], ld["certified_field_length_m"],
|
|
1167
|
+
ld["runway_m"], "FITS" if ld["fits"] else "TOO SHORT",
|
|
1168
|
+
ld["margin_m"]),
|
|
1169
|
+
"",
|
|
1170
|
+
"## 4. Level-acceleration thrust determination",
|
|
1171
|
+
"",
|
|
1172
|
+
"Run %s at %.0f m (ISA density %.4f kg/m^3), W = %.0f N, trace "
|
|
1173
|
+
"smoothed over a %d-sample window; assessment region samples %d-%d."
|
|
1174
|
+
% (la["run_id"], la["altitude_m"], la["density_kgm3"],
|
|
1175
|
+
la["weight_n"], la["window"],
|
|
1176
|
+
la["assessment_start"] + 1, la["assessment_end"]),
|
|
1177
|
+
"",
|
|
1178
|
+
"| Quantity | Value |",
|
|
1179
|
+
"|---|---|",
|
|
1180
|
+
"| Mean acceleration | %.3f m/s^2 |" % la["mean_acceleration"],
|
|
1181
|
+
"| Mean specific excess power P_s | %.3f m/s |"
|
|
1182
|
+
% la["mean_specific_excess_power"],
|
|
1183
|
+
"| Mean excess thrust (drag gap) | %.1f N |"
|
|
1184
|
+
% la["mean_excess_thrust"],
|
|
1185
|
+
"| Mean drag (polar) | %.1f N |" % la["mean_drag"],
|
|
1186
|
+
"| **Mean thrust available** | **%.1f N** |"
|
|
1187
|
+
% la["mean_thrust_available"],
|
|
1188
|
+
"| Sustained acceleration over band | %s |"
|
|
1189
|
+
% ("PASS" if la["sustained_over_band"] else "FAIL"),
|
|
1190
|
+
]
|
|
1191
|
+
if la["mean_ps_reference_conditions"] is not None:
|
|
1192
|
+
lines += [
|
|
1193
|
+
"| P_s at reference weight and standard density | %.3f m/s |"
|
|
1194
|
+
% la["mean_ps_reference_conditions"],
|
|
1195
|
+
]
|
|
1196
|
+
cruise_alt_m = (cruise["points"][0]["altitude_m"]
|
|
1197
|
+
if cruise["points"] else 0.0)
|
|
1198
|
+
lines += [
|
|
1199
|
+
"",
|
|
1200
|
+
"## 5. Cruise performance (fuel flow reduction)",
|
|
1201
|
+
"",
|
|
1202
|
+
"Measured fuel flows corrected from the test weight to the "
|
|
1203
|
+
"reference weight with the sqrt(w_ref/w_test) correction at "
|
|
1204
|
+
"%.0f m; range performance rp = V_tas / wf_corrected."
|
|
1205
|
+
% cruise_alt_m,
|
|
1206
|
+
"",
|
|
1207
|
+
"| Mach | wf (kg/s) | wf corr (kg/s) | rp (m/kg) | fitted rp |",
|
|
1208
|
+
"|---|---|---|---|---|",
|
|
1209
|
+
]
|
|
1210
|
+
for p, f in zip(cruise["points"], cruise["fitted"]):
|
|
1211
|
+
lines.append("| %.2f | %.3f | %.3f | %.1f | %.1f |"
|
|
1212
|
+
% (p["mach"], p["wf_measured_kg_s"], p["wf_corr"],
|
|
1213
|
+
p["rp"], f))
|
|
1214
|
+
lines += [
|
|
1215
|
+
"",
|
|
1216
|
+
"Quadratic range-performance fit rp(M) = %.3f M^2 %+.3f M %+.1f; "
|
|
1217
|
+
"maximum range cruise at M = %.3f; long range cruise (99%% of "
|
|
1218
|
+
"max rp) at M = %s."
|
|
1219
|
+
% (cruise["coefficients"][0], cruise["coefficients"][1],
|
|
1220
|
+
cruise["coefficients"][2],
|
|
1221
|
+
cruise["max_rp_mach"] if cruise["max_rp_mach"] else float("nan"),
|
|
1222
|
+
"%.3f" % cruise["lrc_mach"] if cruise["lrc_mach"] else "n/a"),
|
|
1223
|
+
"Fit residuals RMS: %.1f m/kg (mean rp %.1f m/kg)."
|
|
1224
|
+
% (cruise["rms_residual"], cruise["mean_rp"]),
|
|
1225
|
+
"",
|
|
1226
|
+
"## 6. Stall speeds and V-speed corrections",
|
|
1227
|
+
"",
|
|
1228
|
+
]
|
|
1229
|
+
lines.append("| Config | Vs measured (m/s EAS) | Vs predicted at run "
|
|
1230
|
+
"weight (m/s EAS) | Delta | Margin |")
|
|
1231
|
+
lines.append("|---|---|---|---|---|")
|
|
1232
|
+
for s in r["speeds"]["stall_rows"]:
|
|
1233
|
+
lines.append("| %s | %.2f | %.2f | %+.2f%% | %+.3f |"
|
|
1234
|
+
% (s["config"], s["vs_measured_eas_ms"],
|
|
1235
|
+
s["vs_predicted_eas_ms"], s["delta_pct"],
|
|
1236
|
+
s["stall_margin"]))
|
|
1237
|
+
lines += [
|
|
1238
|
+
"",
|
|
1239
|
+
"Weight correction v(w) = v_ref * sqrt(w/w_ref) applied to the "
|
|
1240
|
+
"reference stall speeds Vs1g = %.2f m/s EAS and Vs0 = %.2f m/s EAS."
|
|
1241
|
+
% (ac["vs1g_ref_eas_ms"], ac["vs0_ref_eas_ms"]),
|
|
1242
|
+
"",
|
|
1243
|
+
"## 7. Computed vs predicted scatter",
|
|
1244
|
+
"",
|
|
1245
|
+
"| Item | Measured | Predicted | Delta (%s) | Within %s%s |"
|
|
1246
|
+
% ("%", "%+", sc["band_pct"]),
|
|
1247
|
+
"|---|---|---|---|---|",
|
|
1248
|
+
]
|
|
1249
|
+
for srow in sc["rows"]:
|
|
1250
|
+
lines.append("| %s | %.1f | %.1f | %+.2f | %s |"
|
|
1251
|
+
% (srow["item"], srow["measured"], srow["predicted"],
|
|
1252
|
+
srow["delta_pct"],
|
|
1253
|
+
"PASS" if srow["within_band"] else "FAIL"))
|
|
1254
|
+
lines += [
|
|
1255
|
+
"",
|
|
1256
|
+
"Maximum absolute scatter: %+.2f%% (band %+.0f%%) -> %s."
|
|
1257
|
+
% (sc["max_abs_delta_pct"], sc["band_pct"],
|
|
1258
|
+
"within band" if sc["within_band"] else "OUT OF BAND"),
|
|
1259
|
+
"",
|
|
1260
|
+
"## 8. Findings",
|
|
1261
|
+
"",
|
|
1262
|
+
"- Takeoff distance legs are complete and the measured ground roll "
|
|
1263
|
+
"agrees with the constant-acceleration model within the band.",
|
|
1264
|
+
"- Landing certified field length (1.67 x demonstrated) fits the "
|
|
1265
|
+
"available runway (margin %+.0f m)." % ld["margin_m"],
|
|
1266
|
+
"- Level-acceleration thrust determination is internally consistent "
|
|
1267
|
+
"(drag-polar closure) and reduces to the reference conditions.",
|
|
1268
|
+
"- Cruise maximum-range Mach and long-range-cruise Mach read off "
|
|
1269
|
+
"the fitted range-performance curve with an RMS residual of "
|
|
1270
|
+
"%.1f m/kg." % cruise["rms_residual"],
|
|
1271
|
+
"",
|
|
1272
|
+
"---",
|
|
1273
|
+
"*Generated by Aero Agent Roles flight-test-performance-engineer "
|
|
1274
|
+
"core. DRAFT for human review by the flight test / performance "
|
|
1275
|
+
"lead. Not an approval document and not a performance guarantee.*",
|
|
1276
|
+
]
|
|
1277
|
+
return "\n".join(lines)
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
# ---------------------------------------------------------------------------
|
|
1281
|
+
# Evidence gate checkers
|
|
1282
|
+
# ---------------------------------------------------------------------------
|
|
1283
|
+
|
|
1284
|
+
GATE_CHECKS = {
|
|
1285
|
+
"test_conditions_identified": "density altitude + sigma recorded",
|
|
1286
|
+
"data_quality_ok": "takeoff and level-accel traces flagged clean",
|
|
1287
|
+
"takeoff_legs_complete": "ground roll + rotation + 35-ft climb legs",
|
|
1288
|
+
"landing_distance_present": "demonstrated + certified landing distance",
|
|
1289
|
+
"thrust_determined": "level-accel thrust available > 0, sustained",
|
|
1290
|
+
"cruise_curve_fit": "range-performance fit with maximum found",
|
|
1291
|
+
"speed_corrections_applied": "weight-corrected stall speeds recorded",
|
|
1292
|
+
"scatter_recorded": "computed-vs-predicted rows within the band",
|
|
1293
|
+
"sign_off_honest": "document is draft-for-review, not approval",
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def check_report(model: dict) -> dict:
|
|
1298
|
+
"""Run the evidence gates against the report content model."""
|
|
1299
|
+
r = model.get("results", {})
|
|
1300
|
+
la = r.get("level_accel", {})
|
|
1301
|
+
cruise = r.get("cruise", {})
|
|
1302
|
+
sc = r.get("scatter", {})
|
|
1303
|
+
conditions = r.get("conditions", {})
|
|
1304
|
+
quality = r.get("quality", {})
|
|
1305
|
+
results = {
|
|
1306
|
+
"test_conditions_identified": bool(
|
|
1307
|
+
conditions.get("density_altitude_m") is not None
|
|
1308
|
+
and conditions.get("sigma_test")),
|
|
1309
|
+
"data_quality_ok": (
|
|
1310
|
+
quality.get("takeoff_trace", {}).get("verdict") == "ok"
|
|
1311
|
+
and quality.get("level_accel_trace", {}).get("verdict") == "ok"),
|
|
1312
|
+
"takeoff_legs_complete": bool(
|
|
1313
|
+
r.get("takeoff", {}).get("run", {}).get("total_m")),
|
|
1314
|
+
"landing_distance_present": bool(
|
|
1315
|
+
r.get("landing", {}).get("certified_field_length_m")),
|
|
1316
|
+
"thrust_determined": bool(
|
|
1317
|
+
la.get("mean_thrust_available")
|
|
1318
|
+
and la.get("sustained_over_band") is True),
|
|
1319
|
+
"cruise_curve_fit": bool(cruise.get("max_rp_mach")),
|
|
1320
|
+
"speed_corrections_applied": bool(
|
|
1321
|
+
r.get("speeds", {}).get("stall_rows")),
|
|
1322
|
+
"scatter_recorded": bool(
|
|
1323
|
+
sc.get("rows") and sc.get("within_band") is True),
|
|
1324
|
+
"sign_off_honest": model.get("status") == "draft-for-review",
|
|
1325
|
+
}
|
|
1326
|
+
results["all_pass"] = all(results.values())
|
|
1327
|
+
return results
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def check_report_markdown(md_text: str) -> dict:
|
|
1331
|
+
"""Gate-check the rendered markdown deliverable."""
|
|
1332
|
+
low = md_text.lower()
|
|
1333
|
+
checks = {
|
|
1334
|
+
"has_title": "flight test performance data analysis report" in low,
|
|
1335
|
+
"has_aircraft": "aircraft:" in low,
|
|
1336
|
+
"has_conditions": "density altitude" in low,
|
|
1337
|
+
"has_takeoff": "takeoff distance" in low and "35 ft" in low,
|
|
1338
|
+
"has_landing": "landing distance" in low and "1.67" in low,
|
|
1339
|
+
"has_thrust": "specific excess power" in low
|
|
1340
|
+
and "thrust available" in low,
|
|
1341
|
+
"has_cruise": "fuel flow" in low and "range performance" in low,
|
|
1342
|
+
"has_speeds": "stall speed" in low and "weight" in low,
|
|
1343
|
+
"has_scatter": "computed vs predicted" in low,
|
|
1344
|
+
"has_draft_marker": "draft" in low,
|
|
1345
|
+
"has_not_approval": "not an approval" in low,
|
|
1346
|
+
}
|
|
1347
|
+
checks["all_pass"] = all(checks.values())
|
|
1348
|
+
return checks
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def validate_deliverable_text(md_text: str) -> dict:
|
|
1352
|
+
"""Public entry point used by gate tooling: check a report document."""
|
|
1353
|
+
return check_report_markdown(md_text)
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
# ---------------------------------------------------------------------------
|
|
1357
|
+
# Example deliverable (for tests and worked-example generation)
|
|
1358
|
+
# ---------------------------------------------------------------------------
|
|
1359
|
+
|
|
1360
|
+
def example_report_model() -> dict:
|
|
1361
|
+
return build_report(example_item())
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def example_report_markdown() -> str:
|
|
1365
|
+
return render_report_markdown(example_report_model())
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
if __name__ == "__main__":
|
|
1369
|
+
model = example_report_model()
|
|
1370
|
+
results = model["results"]
|
|
1371
|
+
md = render_report_markdown(model)
|
|
1372
|
+
print("ITEM: %s (%s)" % (model["item"], model["sortie_id"]))
|
|
1373
|
+
print("DENSITY ALTITUDE: %.0f m (%.0f ft), sigma %.4f"
|
|
1374
|
+
% (results["conditions"]["density_altitude_m"],
|
|
1375
|
+
results["conditions"]["density_altitude_ft"],
|
|
1376
|
+
results["conditions"]["sigma_test"]))
|
|
1377
|
+
to = results["takeoff"]["run"]
|
|
1378
|
+
print("TAKEOFF: ground %.1f m, total %.1f m (predicted %.1f m)"
|
|
1379
|
+
% (to["ground_roll_m"], to["total_m"], to["total_predicted_m"]))
|
|
1380
|
+
ld = results["landing"]
|
|
1381
|
+
print("LANDING: demonstrated %.1f m, certified %.1f m, fits=%s"
|
|
1382
|
+
% (ld["demonstrated_m"], ld["certified_field_length_m"],
|
|
1383
|
+
ld["fits"]))
|
|
1384
|
+
la = results["level_accel"]
|
|
1385
|
+
print("LEVEL ACCEL: a=%.3f m/s2, Ps=%.3f m/s, T=%.1f N"
|
|
1386
|
+
% (la["mean_acceleration"], la["mean_specific_excess_power"],
|
|
1387
|
+
la["mean_thrust_available"]))
|
|
1388
|
+
print("CRUISE: MRC M=%.3f, LRC M=%s, RMS %.1f m/kg"
|
|
1389
|
+
% (results["cruise"]["max_rp_mach"],
|
|
1390
|
+
("%.3f" % results["cruise"]["lrc_mach"])
|
|
1391
|
+
if results["cruise"]["lrc_mach"] else "n/a",
|
|
1392
|
+
results["cruise"]["rms_residual"]))
|
|
1393
|
+
print("SCATTER: max abs %+.2f%%, within band %s"
|
|
1394
|
+
% (results["scatter"]["max_abs_delta_pct"],
|
|
1395
|
+
results["scatter"]["within_band"]))
|
|
1396
|
+
print("GATES: %s" % check_report(model))
|
|
1397
|
+
print("MD GATES: %s" % check_report_markdown(md))
|
|
1398
|
+
print("RENDERED: %d chars" % len(md))
|