Skip to content

Commit d819367

Browse files
Alignment with legacy MATLAB code (AEIC v2.1) (#106)
* Fix time interpolation behavior * Bring trajectory calculations into line with MATLAB v2.1 results * Fix constant emissions with custom fuel file; rename files * update verification data with correct edb data * updated hc co tests * remove PM from matlab verification * Regenerate golden test data --------- Co-authored-by: aditeya <aditeya@mit.edu>
1 parent 69b35de commit d819367

27 files changed

Lines changed: 1696 additions & 2945 deletions

notebooks/test-cases.ipynb

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,151 @@
564564
"source": [
565565
"print(NOx_speciation_from_matlab())"
566566
]
567+
},
568+
{
569+
"cell_type": "markdown",
570+
"id": "d3f82ea5",
571+
"metadata": {},
572+
"source": [
573+
"# 4. Test HCCO function"
574+
]
575+
},
576+
{
577+
"cell_type": "markdown",
578+
"id": "c838c3f8",
579+
"metadata": {},
580+
"source": [
581+
"The Boeing Fuel flow 2 method (from the Boeing Fuel Flow Method 2 (BFFM2) from DuBois, D. & Paynter, G. (2006). *Fuel Flow Method 2 for Estimating Aircraft Emissions*. SAE Technical Paper 2006-01-1987) calculates the emissions index of HC, CO from fuel flow, ambient temperatures and pressures, as well as reference fuel flow and EI points that it interpolates from.\n",
582+
"\n",
583+
"The below implementation recreates while also using the same matlab code in AEICv2 (https://github.mit.edu/LAE/PW_EEI/blob/main/AEIC/Model_Files/hccoEIsfunc.m) or (/home/aditeya/SM_Thesis/PW_EEI/AEIC/Model_Files/hccoEIsfunc.m)\n"
584+
]
585+
},
586+
{
587+
"cell_type": "code",
588+
"execution_count": 4,
589+
"id": "3b6f6947",
590+
"metadata": {},
591+
"outputs": [],
592+
"source": [
593+
"import numpy as np\n",
594+
"def EI_HCCO(\n",
595+
" ff_eval,\n",
596+
" x_EI,\n",
597+
" ff_cal,\n",
598+
" Tamb,\n",
599+
" Pamb,\n",
600+
"):\n",
601+
" ff = np.asarray(ff_eval, dtype=float)\n",
602+
" x_EI = np.asarray(x_EI, dtype=float).reshape(-1)\n",
603+
" ff_ref = np.asarray(ff_cal, dtype=float).reshape(-1)\n",
604+
"\n",
605+
" if x_EI.size != 4 or ff_ref.size != 4:\n",
606+
" raise ValueError(\"x_EI and ff_cal must be length-4 arrays.\")\n",
607+
"\n",
608+
" with np.errstate(divide=\"ignore\", invalid=\"ignore\"):\n",
609+
" slope = (np.log10(x_EI[1]) - np.log10(x_EI[0])) / (\n",
610+
" np.log10(ff_ref[1]) - np.log10(ff_ref[0])\n",
611+
" )\n",
612+
" x2 = np.log10(ff_ref[0])\n",
613+
" x3 = np.log10(x_EI[0])\n",
614+
" horz = (np.log10(x_EI[2]) + np.log10(x_EI[3])) / 2.0\n",
615+
"\n",
616+
" intercept = (\n",
617+
" 2.0 * np.log10(ff_ref[0]) * slope\n",
618+
" + np.log10(x_EI[2])\n",
619+
" + np.log10(x_EI[3])\n",
620+
" - 2.0 * np.log10(x_EI[0])\n",
621+
" ) / (2.0 * slope)\n",
622+
"\n",
623+
" if intercept > np.log10(ff_ref[2]):\n",
624+
" intercept = np.log10(ff_ref[2])\n",
625+
" elif (intercept < np.log10(ff_ref[1])) and (slope < 0):\n",
626+
" horz = np.log10(x_EI[1])\n",
627+
" intercept = np.log10(ff_ref[1])\n",
628+
" elif slope >= 0:\n",
629+
" slope = 0.0\n",
630+
" x2 = 0.0\n",
631+
" x3 = horz\n",
632+
" intercept = np.log10(ff_ref[1])\n",
633+
"\n",
634+
" xEI = np.zeros_like(ff, dtype=float)\n",
635+
" logff = np.log10(ff)\n",
636+
"\n",
637+
" mask_slant = (logff < intercept) & (ff > 0)\n",
638+
" if np.any(mask_slant):\n",
639+
" xEI[mask_slant] = 10.0 ** (slope * (logff[mask_slant] - x2) + x3)\n",
640+
"\n",
641+
" mask_horz = (logff >= intercept)\n",
642+
" if np.any(mask_horz):\n",
643+
" xEI[mask_horz] = 10.0 ** horz\n",
644+
"\n",
645+
" xEI = np.where(np.isnan(xEI), 0.0, xEI)\n",
646+
"\n",
647+
" theta_amb = np.asarray(Tamb, dtype=float) / 288.15\n",
648+
" delta_amb = np.asarray(Pamb, dtype=float) / 101325.0\n",
649+
" xEI = xEI * (theta_amb ** 3.3) / (delta_amb ** 1.02)\n",
650+
"\n",
651+
" return xEI"
652+
]
653+
},
654+
{
655+
"cell_type": "code",
656+
"execution_count": 5,
657+
"id": "ee378dfc",
658+
"metadata": {},
659+
"outputs": [],
660+
"source": [
661+
"# FROM EDB excel\n",
662+
"EDB_fuel_flows = np.array([0.4, 0.8, 1.2, 1.8])\n",
663+
"EDB_EI_HC = np.array([1.54, 0.05, 0.02, 0.03])\n",
664+
"\n",
665+
"\n",
666+
"# Temperatures [K] and pressures [Pa] along test trajectory (calculated from ISA atmosphere).\n",
667+
"temperatures = np.array([288.15, 278.4, 249.15, 216.65, 229.65, 275.15])\n",
668+
"pressures = np.array([101325.0, 84555.9940737564, 47181.0021852292, 22632.0400950078, 30742.4326120969, 79495.201934051])\n",
669+
"\n",
670+
"# Total fuel flows along test trajectory [kg/s].\n",
671+
"full_fuel_flows = np.array([0.3, 0.35, 0.55, 0.65, 0.5, 0.32])\n",
672+
"\n",
673+
"# Per-engine fuel flows along test trajectory [kg/s].\n",
674+
"number_of_engines = 2\n",
675+
"fuel_flows = [ff / number_of_engines for ff in full_fuel_flows]"
676+
]
677+
},
678+
{
679+
"cell_type": "code",
680+
"execution_count": 8,
681+
"id": "64cf0c4f",
682+
"metadata": {},
683+
"outputs": [],
684+
"source": [
685+
"HC_result = EI_HCCO(\n",
686+
" fuel_flows,\n",
687+
" EDB_EI_HC,\n",
688+
" EDB_fuel_flows,\n",
689+
" Tamb=temperatures,\n",
690+
" Pamb=pressures,\n",
691+
")"
692+
]
693+
},
694+
{
695+
"cell_type": "code",
696+
"execution_count": 9,
697+
"id": "e6db5e00",
698+
"metadata": {},
699+
"outputs": [
700+
{
701+
"name": "stdout",
702+
"output_type": "stream",
703+
"text": [
704+
"[196.73236113 98.54714941 13.25415652 7.73938389 25.11776059\n",
705+
" 157.25298236]\n"
706+
]
707+
}
708+
],
709+
"source": [
710+
"print(HC_result)"
711+
]
567712
}
568713
],
569714
"metadata": {

scripts/run_legacy_verification.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from matplotlib.backends.backend_pdf import PdfPages
1212

1313
import AEIC.trajectories.builders as tb
14-
from AEIC.config import Config, config
14+
from AEIC.config import Config
1515
from AEIC.emissions import compute_emissions
1616
from AEIC.missions import Mission
1717
from AEIC.performance.models import PerformanceModel
@@ -25,19 +25,22 @@
2525
)
2626

2727
TRAJ_FIELDS = [
28-
'fuel_flow',
29-
'aircraft_mass',
28+
'flight_time',
3029
'ground_distance',
3130
'latitude',
3231
'longitude',
32+
'altitude',
33+
'fuel_flow',
34+
'aircraft_mass',
3335
'azimuth',
3436
'true_airspeed',
35-
'altitude',
3637
'rate_of_climb',
3738
]
3839

3940
COMPARISON_FIELDS = TRAJ_FIELDS + ['trajectory_indices']
4041

42+
SKIP_FINAL_POINT_FIELDS = set(['true_airspeed'])
43+
4144

4245
def metrics_page(
4346
pdf: PdfPages,
@@ -64,14 +67,19 @@ def metrics_page(
6467
# Title: mission ID.
6568
fig.text(0.5, 0.9, mission_id, ha='center', va='center', fontsize=24, weight='bold')
6669

67-
def t(x, line, txt):
70+
def t(x, line, txt, error=False):
71+
kwargs = {}
72+
if error:
73+
kwargs['color'] = 'red'
74+
kwargs['weight'] = 'bold'
6875
fig.text(
6976
lmargin + x,
7077
tmargin - line * line_height,
7178
txt,
7279
ha='left',
7380
va='center',
7481
fontsize=14,
82+
**kwargs,
7583
)
7684

7785
# Display flight times.
@@ -82,7 +90,7 @@ def t(x, line, txt):
8290
t(0.5, 0, 'Legacy point count:')
8391
t(0.5, 1, 'New point count:')
8492
t(0.76, 0, f'{len(legacy_traj)}')
85-
t(0.76, 1, f'{len(new_traj)}')
93+
t(0.76, 1, f'{len(new_traj)}', error=len(new_traj) != len(legacy_traj))
8694

8795
col_width = 0.1
8896

@@ -103,10 +111,24 @@ def cell(col, row, txt, bold=False, left=False):
103111

104112
# Table row headers.
105113
for idx, field in enumerate(TRAJ_FIELDS):
106-
cell(0, idx + 1, field, bold=True, left=True)
114+
cell(
115+
0,
116+
idx + 1,
117+
field + ('*' if field in SKIP_FINAL_POINT_FIELDS else ''),
118+
bold=True,
119+
left=True,
120+
)
107121
for idx, sp in enumerate(species):
108122
cell(0, idx + len(TRAJ_FIELDS) + 1, f'EI_{sp.name}', bold=True, left=True)
109123

124+
cell(
125+
0,
126+
len(TRAJ_FIELDS) + len(species) + 2,
127+
"* Omit final point from comparison "
128+
"(MATLAB code doesn't fill in the last point)",
129+
left=True,
130+
)
131+
110132
# Table values.
111133
for idx, field in enumerate(TRAJ_FIELDS):
112134
ms = metrics[field]
@@ -245,8 +267,9 @@ def run(report_file) -> None:
245267
# Set up paths to test data.
246268
data_dir = Path(__file__).parent.parent / 'tests/data/verification/legacy'
247269
legacy_dir = data_dir / 'matlab-output'
248-
missions_file = data_dir / 'legacy_verf_missions.toml'
249-
perf_path = data_dir / 'legacy_verification.toml'
270+
missions_file = data_dir / 'missions.toml'
271+
fuel_file = data_dir / 'fuel.toml'
272+
perf_path = data_dir / 'performance-model.toml'
250273

251274
# Set up AEIC configuration and set the AEIC_PATH to include the test data
252275
# directory.
@@ -258,7 +281,7 @@ def run(report_file) -> None:
258281
with open(missions_file, 'rb') as fp:
259282
mission_dict = tomllib.load(fp)
260283
missions = Mission.from_toml(mission_dict)
261-
with open(config.emissions.fuel_file, 'rb') as fp:
284+
with open(fuel_file, 'rb') as fp:
262285
fuel = Fuel.model_validate(tomllib.load(fp))
263286

264287
# Create a single trajectory builder to fly all missions.
@@ -277,14 +300,17 @@ def run(report_file) -> None:
277300
new_traj = builder.fly(pm, mission)
278301
new_traj.add_fields(compute_emissions(pm, fuel, new_traj))
279302

280-
# For comparison, we need to interpolate the new AEIC trajectory onto
281-
# the same time points as the legacy trajectory, since they may not
282-
# match.
283-
interp_traj = new_traj.interpolate_time(legacy_traj.flight_time)
303+
# For comparison, we do *not* interpolate the new AEIC trajectory
304+
# onto the same time points as the legacy trajectory. The match
305+
# should be close enough that we can compare corresponding points
306+
# along the trajectories. The number of points in the trajectories
307+
# should match exactly.
284308

285309
# Compute comparison metrics.
286310
# dict[str, ComparisonMetrics | SpeciesValues[ComparisonMetrics]]
287-
metrics = legacy_traj.compare(interp_traj, COMPARISON_FIELDS)
311+
metrics = legacy_traj.compare(
312+
new_traj, COMPARISON_FIELDS, SKIP_FINAL_POINT_FIELDS
313+
)
288314

289315
# Record any metrics that are outside tolerance.
290316
bad_metrics = out_of_tolerance(metrics, rtol=0.05, atol=1.0e-3)

src/AEIC/emissions/ei/hcco.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,12 @@ def EI_HCCO(
136136
# xEI_acrp = xEI * [1 + (–52) * (ff_eval – ff_cal[0])]
137137
# Then overwrite those points with xEI_acrp.
138138
# ----------------------------------------------------------------------------
139-
ACRP_slope = -52.0
140-
low_thrust_mask = ff_eval < ff_cal[ThrustMode.IDLE]
141-
if np.any(low_thrust_mask):
142-
delta_ff = ff_eval[low_thrust_mask] - ff_cal[ThrustMode.IDLE]
143-
xEI_acrp = xEI_out[low_thrust_mask] * (1.0 + ACRP_slope * delta_ff)
144-
xEI_out[low_thrust_mask] = xEI_acrp
139+
# ACRP_slope = -52.0
140+
# low_thrust_mask = ff_eval < ff_cal[ThrustMode.IDLE]
141+
# if np.any(low_thrust_mask):
142+
# delta_ff = ff_eval[low_thrust_mask] - ff_cal[ThrustMode.IDLE]
143+
# xEI_acrp = xEI_out[low_thrust_mask] * (1.0 + ACRP_slope * delta_ff)
144+
# xEI_out[low_thrust_mask] = xEI_acrp
145145

146146
# ----------------------------------------------------------------------------
147147
# 7. Cruise correction:

src/AEIC/storage/container.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,8 @@ def make_point(self, idx: int | None = None) -> Container:
330330
if idx is not None:
331331
if idx < -self._size or idx >= self._size:
332332
raise IndexError('point index out of range')
333+
if idx < 0:
334+
idx += self._size
333335
for name in pt._data_dictionary:
334336
if name in self._data:
335337
val = self._data[name]
@@ -344,25 +346,27 @@ def _expand_capacity(self) -> None:
344346
if isinstance(self._data[name], np.ndarray):
345347
self._data[name] = np.resize(self._data[name], (self._capacity,))
346348

347-
def append(self, point: Container | None = None, **kwargs) -> None:
349+
def append(
350+
self, point: Container | None = None, replace: bool = False, **kwargs
351+
) -> None:
348352
if not self._extensible:
349353
raise ValueError('cannot append to fixed-size Container')
350354
if point is not None and len(kwargs) != 0:
351355
raise ValueError('cannot specify both point and keyword arguments')
352356
if point is not None:
353-
self._append_point(point)
357+
self._append_point(point, replace)
354358
else:
355-
self._append_from_dict(kwargs)
359+
self._append_from_dict(kwargs, replace)
356360

357-
def _append_point(self, point: Container) -> None:
361+
def _append_point(self, point: Container, replace: bool = False) -> None:
358362
# TODO: Cache single point fieldset.
359363
if point._data_dictionary != self._data_dictionary.single_point():
360364
raise ValueError('cannot append point with different data dictionary')
361365
if len(point) != 1:
362366
raise ValueError('can only append a single point')
363-
self._append_from_dict(point._data)
367+
self._append_from_dict(point._data, replace)
364368

365-
def _append_from_dict(self, data: dict[str, Any]) -> None:
369+
def _append_from_dict(self, data: dict[str, Any], replace: bool = False) -> None:
366370
# Fields should match those in single point field set.
367371
field_set = self._data_dictionary.single_point()
368372
fields = set(field_set.keys())
@@ -375,13 +379,23 @@ def _append_from_dict(self, data: dict[str, Any]) -> None:
375379
if extra:
376380
raise ValueError(f'extra fields in appended point: {extra}')
377381

378-
# Increase container capacity if needed.
379-
if self._size == self._capacity:
380-
self._expand_capacity()
382+
if not replace:
383+
# Increase container capacity if needed.
384+
if self._size == self._capacity:
385+
self._expand_capacity()
381386

382-
for name, value in data.items():
383-
# Check that the type of the assigned value can be safely cast to
384-
# the field type and cast and assign the value if OK.
385-
self._data[name][self._size] = field_set[name].convert_in(value, name, 0)
387+
for name, value in data.items():
388+
# Check that the type of the assigned value can be safely cast to
389+
# the field type and cast and assign the value if OK.
390+
self._data[name][self._size] = field_set[name].convert_in(
391+
value, name, 0
392+
)
386393

387-
self._size += 1
394+
self._size += 1
395+
else:
396+
for name, value in data.items():
397+
# Check that the type of the assigned value can be safely cast to
398+
# the field type and cast and assign the value if OK.
399+
self._data[name][self._size - 1] = field_set[name].convert_in(
400+
value, name, 0
401+
)

0 commit comments

Comments
 (0)