Architecture
This page explains how Acropole turns a trajectory DataFrame into a fuel-flow estimate:
the backend-agnostic narwhals front end, the numpy compute core, the ONNX model, feature
normalization, and the per-typecode dispatch.
Overview
graph TD
IN["DataFrame (pandas, polars, pyarrow, …)"] --> NW["narwhals wrapper (no copy, no conversion)"]
NW --> COLS["Extract feature columns to numpy, once"]
COLS --> DISP["Dispatch by typecode (numpy mask)"]
DISP --> A320["AircraftFuelEstimator(A320)"]
DISP --> B738["AircraftFuelEstimator(B738)"]
A320 --> FEAT["Build 12-feature matrix + min/max normalize"]
B738 --> FEAT
FEAT --> ONNX["ONNX InferenceSession"]
ONNX --> SCALE["× FUEL_FLOW_TO × ENGINE_NUM"]
SCALE --> OUT["fuel_flow, fuel_flow_kgh, fuel_cumsum"]
OUT --> RET["return same type as input"]
A numpy core behind a backend-agnostic API
FuelEstimator.estimate() accepts any eager DataFrame narwhals supports — pandas,
polars, pyarrow, and others. narwhals is a
zero-dependency compatibility layer: it wraps the frame you pass in and dispatches to its
native methods, so there is no conversion to an intermediate frame library. The input
type is preserved on output — pandas in, pandas out; polars in, polars out.
The CI explicitly validates pandas, Polars and PyArrow; other eager Narwhals
backends have not been verified by this project.
Consequently acropole depends on no frame library at all. Users bring the one they
already have, and the [polars], [pandas] and [pyarrow] extras are conveniences,
not requirements.
The narwhals layer is deliberately thin: the feature columns are pulled out to numpy once, up front, and everything after that — the per-typecode split, the derivatives, the feature matrix — is numpy. The frame backend therefore costs one column extraction per call, not per typecode group, and it never touches the hot path.
The aircraft parameter table is read with the stdlib csv module rather than a frame
backend, which is what lets the numpy-only AircraftFuelEstimator run with no
dataframe library installed whatsoever.
Dispatch by typecode
A frame may mix several aircraft types. estimate() groups rows by typecode, and for
each group builds an AircraftFuelEstimator bound to that typecode via
for_aircraft() — which reuses the already-loaded ONNX session and the parsed
parameters, so adding a typecode costs only a parameter lookup, not a model reload.
Each group's predictions are scattered back into the original row order. Rows whose
typecode is unknown or missing stay NaN and raise a warning. When timestamps
are present, a single-sample typecode group also has undefined computed derivatives.
Cumulative fuel integrates all rows in their input order and propagates unknown
contributions to subsequent totals; it never assumes unknown consumption is zero.
AircraftFuelEstimator is also the public fast path: bound to one typecode, it works on
plain 1-D numpy arrays with all per-aircraft constants (engine scale, normalization
arrays) precomputed at construction.
The ONNX model
The estimator runs a single ONNX InferenceSession on the CPU. The model takes a
(N, 12) matrix of features and emits one fuel-flow value per sample. The twelve
features, in order, are:
engine_type, d_altitude, d_groundspeed, d_airspeed, surface,
max_ope_alti, max_ope_speed, altitude, groundspeed, airspeed,
vertical_rate, mass_norm
Several of these (engine_type, surface, max_ope_alti, max_ope_speed) are
per-aircraft constants read from aircraft_params.csv; the rest come from the
trajectory (and its derivatives). The raw model output is the per-engine fuel flow; it is
scaled to the whole aircraft by multiplying by FUEL_FLOW_TO × ENGINE_NUM (the take-off
reference flow times the engine count), yielding fuel_flow in kg/s. fuel_flow_kgh
is that value × 3600, and fuel_cumsum is the running integral over the time step.
Feature normalization
Before inference every feature is min/max-normalized into [0, 1] using fixed bounds —
one (min, max) pair per feature, baked into the package. The transform is the plain
(x − min) / (max − min). The bounds encode the physical ranges the model was trained on
(e.g. altitude up to 50 000 ft, vertical rate ±5 000 ft/min). The normalized matrix is
cast to float32 to match the model's input dtype.
Why ONNX
Acropole originally ran a TensorFlow model. It was migrated to ONNX for two reasons:
- No heavy ML framework — the runtime depends only on
numpy,narwhalsandonnxruntime. There is no TensorFlow install, no GPU toolchain, no multi-hundred-MB dependency tree. The model ships as a single portable.onnxfile inside the package. - Speed — the ONNX path is 2–4.8× faster than the original TensorFlow inference, depending on batch size, with the largest gains on small/medium batches.
The migration was validated to numerical parity of 1e-6 against the TensorFlow reference, so existing results are reproduced to floating-point tolerance.
Design decisions
| Decision | Rationale |
|---|---|
| narwhals front end, numpy core | Works with any frame library; none is a hard dependency, and the hot path stays numpy |
narwhals.stable.v1, not top-level |
Backwards-compatibility guarantee across narwhals major versions, as recommended for libraries |
| Same type in / same type out | Drop-in whichever frame library you use, no surprise conversions |
Params table via stdlib csv |
A 30-row lookup needs no frame backend, so the numpy-only API has zero of them |
| Per-typecode dispatch with shared session | Process a mixed fleet in one call without reloading the model |
| ONNX over TensorFlow | Portable single-file model, no heavy framework, 2–4.8× faster, 1e-6 parity |
| Fixed min/max normalization | Deterministic, baked-in physical ranges matching the training distribution |
src/ layout + py.typed |
PEP 561 typed package, no import-shadowing, strict mypy |