Metadata-Version: 2.5
Name: aeroid
Version: 0.5.0
Summary: Aerospace system identification, model validation, and uncertainty quantification
Author-email: alphabench <contact@alphabench.in>
License-Expression: MIT
License-File: LICENSE
Keywords: aerospace,flight-test,parameter-estimation,system-identification,uncertainty-quantification
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: numpy>=1.26
Requires-Dist: scipy>=1.11
Provides-Extra: aerosandbox
Requires-Dist: aerosandbox>=4.2; extra == 'aerosandbox'
Provides-Extra: jax
Requires-Dist: jax>=0.4; extra == 'jax'
Provides-Extra: jsbsim
Requires-Dist: jsbsim>=1.2; extra == 'jsbsim'
Provides-Extra: parquet
Requires-Dist: pyarrow>=15; extra == 'parquet'
Provides-Extra: rocketpy
Requires-Dist: rocketpy>=1.4; extra == 'rocketpy'
Description-Content-Type: text/markdown

# AeroID

[![PyPI](https://img.shields.io/pypi/v/aeroid.svg)](https://pypi.org/project/aeroid/)
[![Python](https://img.shields.io/pypi/pyversions/aeroid.svg)](https://pypi.org/project/aeroid/)
[![Downloads](https://api.pepy.tech/badge/aeroid/month)](https://pepy.tech/projects/aeroid)
[![License](https://img.shields.io/pypi/l/aeroid.svg)](https://opensource.org/licenses/MIT)

**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.

<p align="center">
  <strong>Every estimate ± uncertainty</strong> · <strong>Every algorithm pinned to analytic truth</strong> · <strong>Data → model → decision in one chain</strong>
</p>

---

### Quick Install

```bash
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]`.

### 30-Second Example

```python
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
```

---

## Table of Contents

- [Overview](#overview)
- [Validated Accuracy](#validated-accuracy)
- [Determinism and Honesty](#determinism-and-honesty)
- [Identification and Validation](#identification-and-validation)
- [State Estimation and Filter-Error Identification](#state-estimation-and-filter-error-identification)
- [Sensitivity Analysis](#sensitivity-analysis)
- [Bayesian Inference](#bayesian-inference)
- [Frequency-Domain Validation](#frequency-domain-validation)
- [Experiment Design](#experiment-design)
- [Simulator Adapters](#simulator-adapters)
- [JAX Acceleration](#jax-acceleration)
- [Defining a Model](#defining-a-model)
- [API Reference](#api-reference)
- [Units and Conventions](#units-and-conventions)
- [Building from Source](#building-from-source)
- [References](#references)
- [License](#license)
- [Changelog](#changelog)

---

## Overview

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.

### Key Features

- **`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?
- **Simulator adapters** — JSBSim, RocketPy, and AeroSandbox plug into the
  identical pipeline as optional extras.
- **`report()`** — one Markdown engineering report tying it all together.

---

## Validated Accuracy

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.

## Determinism and Honesty

- **Every stochastic entry point takes a seed** — Monte Carlo propagation,
  the MCMC sampler, the experiment-design optimizer, and every synthetic
  test dataset are bit-reproducible for a fixed seed.
- **Results are immutable records** — frozen dataclasses holding read-only
  arrays, each with a `summary()` and a matching `report()` section.
- **Uncertainty is never optional** — estimates ship with standard errors
  and correlations; posteriors ship with credible intervals, R-hat, and
  effective sample size.
- **Degeneracy is loud** — when the data cannot distinguish parameters, an
  `IdentifiabilityWarning` names the offending combinations and the result
  is flagged, instead of silently reporting a huge or truncated covariance.
- **No silent extrapolation of trust** — colored residuals, low spectral
  coherence, and unconverged chains are all reported as such.

---

## Identification and Validation

Identify the short-period longitudinal dynamics of an aircraft from a
flight-test log (continuing the 30-second example):

```python
print(result.summary())
```

```text
| 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:

```python
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))
```

```text
| 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.

## State Estimation and Filter-Error Identification

`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:

```python
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.

## Sensitivity Analysis

Before flying (or fitting), ask which parameters the maneuver can constrain:

```python
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.

## Bayesian Inference

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:

```python
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.

## Frequency-Domain Validation

Compare empirical and model transfer functions, weighted by coherence so
only frequencies the data actually excites count:

```python
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))
```

## Experiment Design

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:

```python
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.

## Simulator Adapters

Bring existing simulators' physics into the same pipeline. Each adapter is
an optional extra:

```bash
pip install "aeroid[jsbsim]"      # JSBSim flight dynamics
pip install "aeroid[rocketpy]"    # RocketPy rocket flights
pip install "aeroid[aerosandbox]" # AeroSandbox aerodynamics
```

```python
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
```

- **JSBSim** and **RocketPy** models are stepped black boxes
  (`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** (`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`.
- RocketPy builders should pass tight `Flight` tolerances
  (`rtol=1e-9, atol=1e-9`) so finite-difference identification stays
  smooth.

## JAX Acceleration

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:

```python
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.

## Defining a Model

`aeroid.Model` wraps any ODE vehicle model

```text
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.

---

## API Reference

All names below (except the adapters) are importable from the top-level
`aeroid` namespace.

### Models and data

| 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]`) |

### Identification and filtering

| 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` |

### Validation

| 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 |

### Sensitivity and experiment design

| 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 |

### Bayesian inference and uncertainty

| 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` |

### Adapters (`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 |

### Everything else

`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`.

---

## Units and Conventions

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 |

## Building from Source

```bash
git clone git@github.com:alphabench/aeroid.git
cd aeroid
uv sync          # runtime + dev dependencies (incl. jax and all adapters)
```

### Verification Test

```bash
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.

## References

- V. Klein and E.A. Morelli, *Aircraft System Identification: Theory and
  Practice*, AIAA (2006) — output-error method, maneuver design practice.
- R.V. Jategaonkar, *Flight Vehicle System Identification*, AIAA (2015) —
  filter-error method.
- G.M. Ljung and G.E.P. Box, *Biometrika* **65** (1978) 297 — residual
  whiteness test.
- P.D. Welch, *IEEE Trans. Audio Electroacoust.* **15** (1967) 70 —
  spectral estimation for the transfer-function comparison.
- H.E. Rauch, F. Tung and C.T. Striebel, *AIAA Journal* **3** (1965) 1445 —
  RTS smoothing.
- S.J. Julier and J.K. Uhlmann, *Proc. SPIE* **3068** (1997) — unscented
  Kalman filtering (Merwe scaled sigma points).
- J. Goodman and J. Weare, *Comm. App. Math. Comp. Sci.* **5** (2010) 65 —
  affine-invariant ensemble sampler.
- A. Gelman and D.B. Rubin, *Statistical Science* **7** (1992) 457 —
  split-R-hat convergence diagnostic.
- A.D. Sokal, *Functional Integration* (1997) — autocorrelation-time
  estimation.
- M.R. Schroeder, *IEEE Trans. Inf. Theory* **16** (1970) 85 — low-crest
  multisine phases.
- R. Storn and K. Price, *J. Global Optimization* **11** (1997) 341 —
  differential evolution (experiment-design optimizer).

## License

MIT — see [LICENSE](LICENSE).

## Changelog

Canonical history lives in [CHANGELOG.md](CHANGELOG.md).

### v0.5.0 — first public release

The complete measurement-to-decision chain:

- Core: `Model` / `FlightData` abstractions, output-error `identify` with
  covariance and identifiability diagnostics, `validate`, Monte Carlo
  `propagate`, Markdown `report`.
- Filtering: EKF/UKF `filter_states` with RTS smoothing and filter-error
  identification on whitened innovations.
- Analysis: `sensitivities` (Fisher information, Cramér–Rao) and
  coherence-weighted `frequency_response`.
- Bayesian: dependency-free ensemble-MCMC `infer` with priors, estimated
  sensor noise, and convergence diagnostics; posterior-aware `propagate`.
- Optional JAX: differentiable RK4 rollout, exact Jacobians
  (`gradient="jax"`), fast likelihoods (`backend="jax"`).
- Adapters: JSBSim, RocketPy, and AeroSandbox as optional extras behind the
  `SimulatorModel` protocol.
- Experiment design: D-/A-optimal `design_experiment` over `Doublet` /
  `Multisine` / `Chirp` families with predicted parameter precision.
