Observed since 20 Oct, 20:47 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
aeroid
Aerospace system identification, model validation, and uncertainty quantification
What the data says the vehicle really is — with error bars.
AeroID connects experimental aerospace data with physics-based models. Given flight-test (or bench-test) time series and an ODE model of the vehicle, it estimates the model's physical parameters, quantifies their uncertainty, validates the model against the data, designs the next maneuver to fly, and propagates parameter uncertainty into the engineering quantities you actually care about. It is deliberately not another simulator: bring your own dynamics function — or plug in JSBSim, RocketPy, or AeroSandbox — and AeroID closes the loop between measurement and model.
Every estimate ± uncertainty · Every algorithm pinned to analytic truth · Data → model → decision in one chain
pip install aeroid
# or
uv add aeroid
Requires Python ≥ 3.11. Runtime dependencies: NumPy and SciPy. Everything
else is an optional extra: aeroid[parquet], aeroid[jax],
aeroid[jsbsim], aeroid[rocketpy], aeroid[aerosandbox].
import aeroid, numpy as np
def short_period(t, x, u, p):
alpha, q = x
return np.array(
[
p["Z_alpha"] * alpha + q,
p["M_alpha"] * alpha + p["M_q"] * q + p["M_delta"] * u[0],
]
)
model = aeroid.Model(
states=["alpha", "q"],
controls=["elevator"],
parameters={"Z_alpha": -0.8, "M_alpha": -4.0, "M_q": -1.5, "M_delta": -6.0},
dynamics=short_period,
)
data = aeroid.load_flight_test("flight_042.csv") # columns: time, alpha, q, elevator
result = aeroid.identify(model, data, ["Z_alpha", "M_alpha", "M_q", "M_delta"])
result.parameters["M_alpha"] # -5.926 (truth: -6.0)
result.stderr["M_alpha"] # ±0.105
AeroID is the interface layer between measurement and model: physics
engines are abundant, but the connective tissue — measurements → parameter
estimation → calibration → uncertainty → validation → engineering decision —
is what this package standardizes. A Model (or a wrapped external
simulator) plus a FlightData record feeds every stage of that chain
through one consistent API.
identify() — output-error parameter estimation (nonlinear least
squares) with standard errors, full covariance, and identifiability
diagnostics; exact JAX Jacobians optional.validate() — simulation vs. measurement: RMSE, bias with confidence
intervals, fit score, residual whiteness (Ljung–Box), and residual–input
cross-correlation.filter_states() — EKF/UKF state estimation with RTS smoothing;
identify(method="filter_error") fits on whitened Kalman innovations
when process noise (turbulence, model error) matters.sensitivities() — Fisher information and Cramér–Rao bounds: which
parameters can this trajectory actually constrain, before you fit?frequency_response() — empirical vs. model transfer functions with
coherence weighting.infer() — Bayesian inference via a built-in affine-invariant
ensemble sampler (no extra dependencies), with prior objects, estimated
sensor noise, and convergence diagnostics.propagate() — Monte Carlo propagation of covariance or full
posterior samples through any metric you define.design_experiment() — D-/A-optimal input design over maneuver
families: what should the next flight fly?report() — one Markdown engineering report tying it all together.Every numerical algorithm in AeroID is pinned to an analytic closed form, a conjugate solution, or seeded recovery of known truth — these checks are the test suite, and they gate CI:
| What | Checked against | Verified within |
|---|---|---|
ODE simulation (Model.simulate) | closed-form x0·e^(−kt) | 1e-6 |
Output-error identify, noise-free | known truth parameters | rel 1e-4 |
Output-error identify, noisy flight | truth within ±3σ and 5 % | pass |
| Parameter scaling invariance | ×1000 magnitude reparameterization | rel 1e-4 |
| EKF steady state | scalar discrete Riccati closed form | rel 1e-3 |
| UKF vs EKF on a linear system | must coincide | rel 1e-6 |
| Correctly-specified filter innovations | whiteness + unit variance | pass |
Filter-error identify | truth within 5 %, σ² ≈ 1 | pass |
| Sensitivities | analytic partials of first-order step response | rel 1e-3 |
| Cramér–Rao bound | identify covariance on the same data | rel 0.3 |
| Empirical transfer function | exact short-period H(s) where γ² > 0.95 | 10 % mag / 0.15 rad |
| Ensemble sampler | 2-D Gaussian target moments | rel 0.15 |
infer() posterior | conjugate Normal–Normal closed form | rel 0.05 |
| Autocorrelation time | AR(1) chains with known τ | rel 0.2 |
| JAX RK4 rollout | solve_ivp on the same model | 1e-7 |
| JAX Jacobian | analytic sensitivity of e^(−kt) | rel 1e-6 |
Posterior resampling in propagate | every draw an exact posterior row | exact |
| Experiment design | amplitude pinned to bound (monotone information) | rel 1e-2 |
| Designed-experiment CRB | stderr of actually fitting the flown design | rel 0.3–0.5 |
| JSBSim adapter | injected property recovered; bit-identical resets | rel 2e-2 / exact |
| RocketPy adapter | injected drag scale recovered | rel 3e-2 |
| AeroSandbox adapter | injected coefficient scales recovered | rel 2e-2 |
The Quickstart numbers in this README are real program output, and the README workflows run verbatim as integration tests — the docs cannot drift from the code.
summary() and a matching report() section.IdentifiabilityWarning names the offending combinations and the result
is flagged, instead of silently reporting a huge or truncated covariance.Identify the short-period longitudinal dynamics of an aircraft from a flight-test log (continuing the 30-second example):
print(result.summary())
| parameter | estimate | std. error | rel. error |
|-----------|----------|------------|------------|
| Z_alpha | -1.17 | 0.04187 | 3.6% |
| M_alpha | -5.926 | 0.1048 | 1.8% |
| M_q | -2.473 | 0.085 | 3.4% |
| M_delta | -8.927 | 0.1682 | 1.9% |
Validate the fitted model against the data and propagate the parameter uncertainty into a quantity you care about — here the short-period natural frequency:
import math
validation = aeroid.validate(model, data, result)
def natural_frequency(p):
return math.sqrt(p["M_q"] * p["Z_alpha"] - p["M_alpha"])
mc = aeroid.propagate(result, natural_frequency, n_samples=2000, seed=0)
print(aeroid.report(validation, result, mc))
| statistic | value |
|-----------|--------|
| mean | 2.969 |
| std | 0.0254 |
| P2.5 | 2.922 |
| P50 | 2.969 |
| P97.5 | 3.019 |
The true natural frequency of the synthetic aircraft is 3.0 rad/s — inside the interval. The report also covers per-channel RMSE, bias with its confidence interval, residual whiteness, and residual–input correlation.
filter_states runs an extended (or unscented) Kalman filter over the
record and, by default, an RTS smoothing pass — useful for reconstructing
states between noisy sensors and for checking noise assumptions via the
innovations:
filtered = aeroid.filter_states(
model,
data,
result,
process_noise={"alpha": 1e-8, "q": 1e-8}, # continuous PSD
measurement_noise={"alpha": 0.005**2, "q": 0.01**2}, # variances
)
filtered.smoothed_state("alpha") # best state estimate using the full record
filtered.log_likelihood # for comparing noise models
When real flights contain turbulence or model error, output-error fits are
biased; identify(method="filter_error") fits on whitened innovations
instead, using the same noise specification — and sigma2 ≈ 1 doubles as a
check that your noise levels are consistent with the data.
Before flying (or fitting), ask which parameters the maneuver can constrain:
sens = aeroid.sensitivities(model, data, noise={"alpha": 0.005, "q": 0.01})
print(sens.summary()) # per-parameter sensitivity norms + Cramér–Rao bounds
An unidentifiable parameter combination triggers an IdentifiabilityWarning
naming the offending parameters; the Cramér–Rao bound is directly comparable
to the covariance identify will achieve.
When a point estimate with error bars isn't enough, sample the full
posterior. Priors are plain objects, sensor noise is estimated by default
(as sigma_<output> parameters), and no extra dependencies are needed:
posterior = aeroid.infer(
model,
data,
parameters={
"Z_alpha": aeroid.Normal(-1.0, 1.0),
"M_alpha": aeroid.Normal(-4.0, 4.0),
"M_q": aeroid.Normal(-2.0, 2.0),
"M_delta": aeroid.Normal(-6.0, 6.0),
},
initial=result, # start the walkers at the least-squares fit
n_steps=2000,
seed=0,
)
print(posterior.summary()) # means, credible intervals, R-hat, ESS
mc = aeroid.propagate(posterior, natural_frequency, seed=0)
print(aeroid.report(validation, inference=posterior))
The result plugs into validate, sensitivities, and propagate exactly
like a least-squares fit — and propagate resamples actual posterior rows,
preserving skew, bounds, and correlations rather than assuming a Gaussian.
Compare empirical and model transfer functions, weighted by coherence so only frequencies the data actually excites count:
freq = aeroid.frequency_response(model, data, result, frequency_range=(0.2, 3.0))
freq.channel("elevator", "q").mismatch # coherence-weighted relative error
print(aeroid.report(validation, result, frequency=freq))
Before the next flight, ask what maneuver would constrain the parameters
best. design_experiment optimizes a maneuver family's variables (for
example per-line multisine amplitudes) against a D- or A-optimal
Fisher-information criterion, within your amplitude limits:
design = aeroid.design_experiment(
model,
aeroid.Multisine("elevator", frequencies=(0.2, 0.5, 1.0, 1.5), amplitude=(0.0, 0.05)),
["M_q", "M_delta"],
values=result, # design at the identified point
noise={"alpha": 0.005, "q": 0.01},
duration=10.0,
sample_rate=50.0,
seed=0,
)
print(design.recommendation)
# designed multisine on 'elevator': dominant energy 0.5-1.5 Hz, peak
# amplitude 0.05; predicted stderr improves 2.1x over the initial design for M_q
flight_plan = design.to_flight_data() # fly-ready control history
Doublet, Multisine (Schroeder phases), and Chirp are built in; any
object satisfying the ManeuverFamily protocol works. The amplitude bound
encodes your safety/actuator limit — for near-linear dynamics the optimizer
will use all of it; the interesting freedom is in the frequency content.
Bring existing simulators' physics into the same pipeline. Each adapter is an optional extra:
pip install "aeroid[jsbsim]" # JSBSim flight dynamics
pip install "aeroid[rocketpy]" # RocketPy rocket flights
pip install "aeroid[aerosandbox]" # AeroSandbox aerodynamics
from aeroid.adapters.jsbsim import JsbsimModel
model = JsbsimModel(
"c172x",
parameters={"pitch_trim": ("fcs/pitch-trim-cmd-norm", 0.0)},
controls={"elevator": "fcs/elevator-cmd-norm"},
outputs={"q_rad_s": "velocities/q-rad_sec"},
initial_conditions={"ic/h-sl-ft": 5000.0, "ic/vc-kts": 120.0},
)
result = aeroid.identify(model, data, ["pitch_trim"]) # works unchanged
SimulatorModel): they work with identify (output-error, finite
differences), validate, frequency_response, sensitivities, infer
(numpy backend), propagate, and report; they are rejected with clear
errors by filter_states, filter-error identification, and the JAX
paths, which need a continuous dynamics callable.aerosandbox_model(...)) returns a genuine Model
(longitudinal 3-DOF from tabulated AeroBuildup aerodynamics), so
everything except gradient="jax" applies.load_jsbsim_output() and rocketpy_flight_data() bring each
simulator's native output in as FlightData.Flight tolerances
(rtol=1e-9, atol=1e-9) so finite-difference identification stays
smooth.With pip install "aeroid[jax]", write the dynamics with jax.numpy (the
same function works in every aeroid path) and get exact Jacobians instead of
finite differences, from a jit-compiled fixed-step RK4 rollout:
result = aeroid.identify(model, data, ["Z_alpha", "M_alpha", "M_q", "M_delta"], gradient="jax")
posterior = aeroid.infer(model, data, priors, backend="jax")
aeroid.simulate_rk4(...) exposes the underlying integrator for parity
checks against Model.simulate. Estimates can differ from the adaptive
integrator at roughly the 1e-4 relative level; raise substeps for coarse
sample rates.
aeroid.Model wraps any ODE vehicle model
x_dot = f(t, x, u, parameters)
y = h(t, x, u, parameters)
where x is the state vector, u the control inputs (interpolated
piecewise-linearly from your measured channels), and parameters a plain
dict of named physical parameters. The measurement function is optional and
defaults to y = x. Data channels are matched to states, controls, and
outputs by name, so a FlightData loaded from CSV or Parquet plugs
straight in. Simulation uses scipy.integrate.solve_ivp, evaluated exactly
on the measurement time grid — AeroID never resamples your data.
All names below (except the adapters) are importable from the top-level
aeroid namespace.
| Name | Kind | Purpose |
|---|---|---|
Model(states, controls, parameters, dynamics, outputs, measurement, name) | class | Frozen ODE model; .simulate(time, controls, x0, parameters, ...), .simulate_data(data, ...) |
SimulationResult | class | Trajectory on the requested grid; .state(name), .output(name) |
SimulatorModel | protocol | Duck type for external stepped simulators accepted by the pipeline |
FlightData(time, channels, units) | class | Immutable time series; .array(names), .window(t0, t1), [name] |
load_flight_test(path, *, time_column, channels, units) | function | CSV / Parquet loader (name-matched channels) |
simulate_rk4(model, time, controls, x0, parameters, *, substeps) | function | Jit-compiled differentiable RK4 (aeroid[jax]) |
| Name | Kind | Purpose |
|---|---|---|
identify(model, data, parameters, *, method, gradient, ...) | function | Output-error or filter-error NLS → IdentificationResult |
IdentificationResult | class | .parameters, .stderr, .covariance, .correlation, .identifiable, .summary() |
filter_states(model, data, parameters, *, process_noise, measurement_noise, method, smooth, ...) | function | EKF/UKF + RTS → FilterResult |
FilterResult | class | .state(name), .smoothed_state(name), .state_std(name), .innovation(name), .log_likelihood |
| Name | Kind | Purpose |
|---|---|---|
validate(model, data, parameters, *, confidence, whiteness_lags, ...) | function | Residual statistics per output → ValidationResult |
ValidationResult / OutputMetrics | class | .metrics[name]: RMSE, bias ± CI, fit %, Ljung–Box whiteness, input correlation |
frequency_response(model, data, parameters, *, nperseg, frequency_range, coherence_threshold, ...) | function | Welch/CSD transfer functions → FrequencyResponseResult |
FrequencyResponseResult / ChannelFrequencyResponse | class | .channel(input, output): magnitude, phase, coherence, mismatch |
| Name | Kind | Purpose |
|---|---|---|
sensitivities(model, data, parameters, *, values, noise, ...) | function | dy/dθ histories, Fisher information, Cramér–Rao → SensitivityResult |
SensitivityResult | class | .sensitivity(output, parameter), .fisher_information, .cramer_rao_bound, .norms, .identifiable |
design_experiment(model, maneuver, parameters, *, criterion, duration, sample_rate, ...) | function | D-/A-optimal input design → ExperimentDesign |
ExperimentDesign | class | .design, .predicted_stderr, .recommendation, .to_flight_data() |
Doublet / Multisine / Chirp / ManeuverFamily | class/protocol | Built-in maneuver families and the extension point |
| Name | Kind | Purpose |
|---|---|---|
infer(model, data, parameters, *, noise, initial, n_walkers, n_steps, backend, seed, ...) | function | Ensemble MCMC posterior → InferenceResult |
InferenceResult | class | .samples, .parameters, .percentiles, .map_parameters, .r_hat, .effective_sample_size |
Normal / Uniform / LogUniform / HalfNormal / Prior | class | Prior distributions ({"M_alpha": aeroid.Normal(-4, 2)}) |
propagate(result, func, *, n_samples, percentiles, seed) | function | Monte Carlo through any metric; resamples posteriors → PropagationResult |
PropagationResult | class | .mean, .std, .percentiles, .samples, .n_failed |
aeroid.adapters.*)| Name | Module | Purpose |
|---|---|---|
JsbsimModel / load_jsbsim_output | adapters.jsbsim | Property-mapped JSBSim aircraft; JSBSim CSV loader |
RocketpyModel / rocketpy_flight_data | adapters.rocketpy | Builder-parameterized rocket flights; Flight sampler |
aerosandbox_model | adapters.aerosandbox | Longitudinal 3-DOF Model from AeroBuildup tables |
report(validation, identification, propagation, *, experiment, inference, frequency, title, path) renders the Markdown engineering report. Package
errors derive from aeroid.AeroidError (ModelDefinitionError,
DataFormatError, ChannelError, SimulationError, IdentificationError,
FilterError, InferenceError, AdapterError), and statistical degeneracy
warns via IdentifiabilityWarning.
AeroID is unit-agnostic: use any consistent unit system and results come back in it. The conventions that do matter:
| Convention | Rule |
|---|---|
| Channel matching | Data channels map to states/controls/outputs by name |
| Time | Seconds, strictly increasing; solver output lands exactly on the data grid |
| Controls | Interpolated piecewise-linearly between samples |
| Parameters | Plain dict[str, float] at every user boundary |
| Process noise | Continuous power spectral density (state² per second) |
| Measurement noise | Variances in filter_states/identify, standard deviations in sensitivities/infer(noise=...) |
| Adapters | Keep each simulator's native units (JSBSim: imperial; RocketPy/AeroSandbox: SI) — encode the unit in the channel name |
git clone git@github.com:alphabench/aeroid.git
cd aeroid
uv sync # runtime + dev dependencies (incl. jax and all adapters)
uv run ruff check . && uv run ruff format --check . # style
uv run mypy # strict typing
uv run pytest -m "not slow" # fast truth-pinned subset
uv run pytest # full suite incl. long MCMC/design runs
The adapter test modules skip automatically when jsbsim / rocketpy /
aerosandbox are not installed; uv sync installs all of them so the full
suite runs.
MIT — see LICENSE.
Canonical history lives in CHANGELOG.md.
The complete measurement-to-decision chain:
Model / FlightData abstractions, output-error identify with
covariance and identifiability diagnostics, validate, Monte Carlo
propagate, Markdown report.filter_states with RTS smoothing and filter-error
identification on whitened innovations.sensitivities (Fisher information, Cramér–Rao) and
coherence-weighted frequency_response.infer with priors, estimated
sensor noise, and convergence diagnostics; posterior-aware propagate.gradient="jax"), fast likelihoods (backend="jax").SimulatorModel protocol.design_experiment over Doublet /
Multisine / Chirp families with predicted parameter precision.Download counts and popularity will appear here as the collector receives them.
| Package / target | Requirement | Relationship |
|---|---|---|
numpyEnvironment{
"extras": "",
"marker": null,
"requires_python": ">=3.11",
"evaluation": "NOT_EVALUATED"
} | numpy>=1.26 | runtime |
scipyEnvironment{
"extras": "",
"marker": null,
"requires_python": ">=3.11",
"evaluation": "NOT_EVALUATED"
} | scipy>=1.11 | runtime |
aerosandboxEnvironment{
"extras": "",
"marker": "extra == \"aerosandbox\"",
"requires_python": ">=3.11",
"evaluation": "NOT_EVALUATED"
} | aerosandbox>=4.2; extra == "aerosandbox" | runtime |
jaxEnvironment{
"extras": "",
"marker": "extra == \"jax\"",
"requires_python": ">=3.11",
"evaluation": "NOT_EVALUATED"
} | jax>=0.4; extra == "jax" | runtime |
jsbsimEnvironment{
"extras": "",
"marker": "extra == \"jsbsim\"",
"requires_python": ">=3.11",
"evaluation": "NOT_EVALUATED"
} | jsbsim>=1.2; extra == "jsbsim" | runtime |
pyarrowEnvironment{
"extras": "",
"marker": "extra == \"parquet\"",
"requires_python": ">=3.11",
"evaluation": "NOT_EVALUATED"
} | pyarrow>=15; extra == "parquet" | runtime |
rocketpyEnvironment{
"extras": "",
"marker": "extra == \"rocketpy\"",
"requires_python": ">=3.11",
"evaluation": "NOT_EVALUATED"
} | rocketpy>=1.4; extra == "rocketpy" | runtime |
bdist_wheel · 68.83 KB · Python py3
sdist · 220.69 KB · Python source
Observed since 20 Oct, 20:47 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package_version:5246fbc781f050bb9bba0f6b42b3f8d8ea7eb4896dcd7d84c4308fd7ad3f4f26