contrax 0.1.0__py3-none-any.whl
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.
- contrax/__init__.py +197 -0
- contrax/_ekf.py +269 -0
- contrax/_estimation_diagnostics.py +155 -0
- contrax/_kalman.py +322 -0
- contrax/_mhe.py +242 -0
- contrax/_place.py +392 -0
- contrax/_precision.py +16 -0
- contrax/_riccati.py +389 -0
- contrax/_ukf.py +285 -0
- contrax/analysis.py +422 -0
- contrax/compat/__init__.py +7 -0
- contrax/compat/python_control.py +142 -0
- contrax/control.py +238 -0
- contrax/core.py +401 -0
- contrax/estimation.py +54 -0
- contrax/interconnect.py +113 -0
- contrax/nonlinear.py +148 -0
- contrax/parameterization.py +88 -0
- contrax/phs.py +397 -0
- contrax/sim.py +639 -0
- contrax/simulation.py +25 -0
- contrax/systems.py +48 -0
- contrax/types.py +209 -0
- contrax-0.1.0.dist-info/METADATA +172 -0
- contrax-0.1.0.dist-info/RECORD +28 -0
- contrax-0.1.0.dist-info/WHEEL +5 -0
- contrax-0.1.0.dist-info/licenses/LICENSE +21 -0
- contrax-0.1.0.dist-info/top_level.txt +1 -0
contrax/__init__.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from contrax.analysis import (
|
|
2
|
+
ctrb,
|
|
3
|
+
ctrb_gramian,
|
|
4
|
+
dcgain,
|
|
5
|
+
dlyap,
|
|
6
|
+
evalfr,
|
|
7
|
+
freqresp,
|
|
8
|
+
lyap,
|
|
9
|
+
obsv,
|
|
10
|
+
obsv_gramian,
|
|
11
|
+
poles,
|
|
12
|
+
zeros,
|
|
13
|
+
)
|
|
14
|
+
from contrax.control import (
|
|
15
|
+
augment_integrator,
|
|
16
|
+
care,
|
|
17
|
+
dare,
|
|
18
|
+
feedback,
|
|
19
|
+
lqi,
|
|
20
|
+
lqr,
|
|
21
|
+
place,
|
|
22
|
+
state_feedback,
|
|
23
|
+
)
|
|
24
|
+
from contrax.estimation import (
|
|
25
|
+
ekf,
|
|
26
|
+
ekf_predict,
|
|
27
|
+
ekf_step,
|
|
28
|
+
ekf_update,
|
|
29
|
+
innovation_diagnostics,
|
|
30
|
+
innovation_rms,
|
|
31
|
+
kalman,
|
|
32
|
+
kalman_gain,
|
|
33
|
+
kalman_predict,
|
|
34
|
+
kalman_step,
|
|
35
|
+
kalman_update,
|
|
36
|
+
likelihood_diagnostics,
|
|
37
|
+
mhe,
|
|
38
|
+
mhe_objective,
|
|
39
|
+
mhe_warm_start,
|
|
40
|
+
rts,
|
|
41
|
+
smoother_diagnostics,
|
|
42
|
+
soft_quadratic_penalty,
|
|
43
|
+
ukf,
|
|
44
|
+
ukf_diagnostics,
|
|
45
|
+
uks,
|
|
46
|
+
)
|
|
47
|
+
from contrax.parameterization import (
|
|
48
|
+
diagonal_spd,
|
|
49
|
+
lower_triangular,
|
|
50
|
+
positive_exp,
|
|
51
|
+
positive_softplus,
|
|
52
|
+
spd_from_cholesky_raw,
|
|
53
|
+
)
|
|
54
|
+
from contrax.simulation import (
|
|
55
|
+
as_ode_term,
|
|
56
|
+
foh_inputs,
|
|
57
|
+
impulse_response,
|
|
58
|
+
initial_response,
|
|
59
|
+
lsim,
|
|
60
|
+
rollout,
|
|
61
|
+
sample_system,
|
|
62
|
+
simulate,
|
|
63
|
+
step_response,
|
|
64
|
+
)
|
|
65
|
+
from contrax.systems import (
|
|
66
|
+
ContLTI,
|
|
67
|
+
DiscLTI,
|
|
68
|
+
NonlinearSystem,
|
|
69
|
+
PHSSystem,
|
|
70
|
+
block_matrix,
|
|
71
|
+
block_observation,
|
|
72
|
+
c2d,
|
|
73
|
+
canonical_J,
|
|
74
|
+
dss,
|
|
75
|
+
linearize,
|
|
76
|
+
linearize_ss,
|
|
77
|
+
nonlinear_system,
|
|
78
|
+
parallel,
|
|
79
|
+
partition_state,
|
|
80
|
+
phs_diagnostics,
|
|
81
|
+
phs_system,
|
|
82
|
+
phs_to_ss,
|
|
83
|
+
project_psd,
|
|
84
|
+
schedule_phs,
|
|
85
|
+
series,
|
|
86
|
+
ss,
|
|
87
|
+
symmetrize_matrix,
|
|
88
|
+
)
|
|
89
|
+
from contrax.types import (
|
|
90
|
+
InnovationDiagnostics,
|
|
91
|
+
KalmanGainResult,
|
|
92
|
+
KalmanResult,
|
|
93
|
+
LikelihoodDiagnostics,
|
|
94
|
+
LQRResult,
|
|
95
|
+
MHEResult,
|
|
96
|
+
PHSStructureDiagnostics,
|
|
97
|
+
RTSResult,
|
|
98
|
+
SmootherDiagnostics,
|
|
99
|
+
UKFResult,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
__all__ = [
|
|
103
|
+
# system types
|
|
104
|
+
"ContLTI",
|
|
105
|
+
"DiscLTI",
|
|
106
|
+
"NonlinearSystem",
|
|
107
|
+
"PHSSystem",
|
|
108
|
+
"partition_state",
|
|
109
|
+
"block_observation",
|
|
110
|
+
"block_matrix",
|
|
111
|
+
"symmetrize_matrix",
|
|
112
|
+
"project_psd",
|
|
113
|
+
"phs_diagnostics",
|
|
114
|
+
# constructors
|
|
115
|
+
"ss",
|
|
116
|
+
"dss",
|
|
117
|
+
"c2d",
|
|
118
|
+
"nonlinear_system",
|
|
119
|
+
"phs_system",
|
|
120
|
+
"phs_to_ss",
|
|
121
|
+
"schedule_phs",
|
|
122
|
+
"canonical_J",
|
|
123
|
+
"linearize",
|
|
124
|
+
"linearize_ss",
|
|
125
|
+
"series",
|
|
126
|
+
"parallel",
|
|
127
|
+
# control
|
|
128
|
+
"lqr",
|
|
129
|
+
"lqi",
|
|
130
|
+
"dare",
|
|
131
|
+
"care",
|
|
132
|
+
"place",
|
|
133
|
+
"state_feedback",
|
|
134
|
+
"feedback",
|
|
135
|
+
"augment_integrator",
|
|
136
|
+
# simulation
|
|
137
|
+
"rollout",
|
|
138
|
+
"foh_inputs",
|
|
139
|
+
"sample_system",
|
|
140
|
+
"lsim",
|
|
141
|
+
"simulate",
|
|
142
|
+
"step_response",
|
|
143
|
+
"impulse_response",
|
|
144
|
+
"initial_response",
|
|
145
|
+
"as_ode_term",
|
|
146
|
+
# estimation
|
|
147
|
+
"kalman",
|
|
148
|
+
"kalman_gain",
|
|
149
|
+
"kalman_predict",
|
|
150
|
+
"kalman_update",
|
|
151
|
+
"kalman_step",
|
|
152
|
+
"innovation_diagnostics",
|
|
153
|
+
"innovation_rms",
|
|
154
|
+
"likelihood_diagnostics",
|
|
155
|
+
"ekf",
|
|
156
|
+
"ekf_predict",
|
|
157
|
+
"ekf_update",
|
|
158
|
+
"ekf_step",
|
|
159
|
+
"mhe_objective",
|
|
160
|
+
"mhe_warm_start",
|
|
161
|
+
"mhe",
|
|
162
|
+
"soft_quadratic_penalty",
|
|
163
|
+
"smoother_diagnostics",
|
|
164
|
+
"ukf",
|
|
165
|
+
"ukf_diagnostics",
|
|
166
|
+
"uks",
|
|
167
|
+
"rts",
|
|
168
|
+
# analysis
|
|
169
|
+
"ctrb",
|
|
170
|
+
"obsv",
|
|
171
|
+
"poles",
|
|
172
|
+
"zeros",
|
|
173
|
+
"evalfr",
|
|
174
|
+
"freqresp",
|
|
175
|
+
"dcgain",
|
|
176
|
+
"ctrb_gramian",
|
|
177
|
+
"obsv_gramian",
|
|
178
|
+
"lyap",
|
|
179
|
+
"dlyap",
|
|
180
|
+
# parameterization
|
|
181
|
+
"positive_exp",
|
|
182
|
+
"positive_softplus",
|
|
183
|
+
"lower_triangular",
|
|
184
|
+
"spd_from_cholesky_raw",
|
|
185
|
+
"diagonal_spd",
|
|
186
|
+
# result types
|
|
187
|
+
"LQRResult",
|
|
188
|
+
"InnovationDiagnostics",
|
|
189
|
+
"SmootherDiagnostics",
|
|
190
|
+
"KalmanGainResult",
|
|
191
|
+
"KalmanResult",
|
|
192
|
+
"LikelihoodDiagnostics",
|
|
193
|
+
"UKFResult",
|
|
194
|
+
"RTSResult",
|
|
195
|
+
"MHEResult",
|
|
196
|
+
"PHSStructureDiagnostics",
|
|
197
|
+
]
|
contrax/_ekf.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""contrax._ekf — Extended Kalman filter."""
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
import jax.numpy as jnp
|
|
5
|
+
from jax import Array
|
|
6
|
+
|
|
7
|
+
from contrax._kalman import _measurement_flag, _symmetrize
|
|
8
|
+
from contrax.nonlinear import (
|
|
9
|
+
DynamicsLike,
|
|
10
|
+
ObservationFn,
|
|
11
|
+
ObservationLike,
|
|
12
|
+
_coerce_dynamics,
|
|
13
|
+
_coerce_observation,
|
|
14
|
+
_is_system_model,
|
|
15
|
+
_system_dt,
|
|
16
|
+
)
|
|
17
|
+
from contrax.types import KalmanResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ekf_predict(
|
|
21
|
+
model_or_f: DynamicsLike,
|
|
22
|
+
x: Array,
|
|
23
|
+
P: Array,
|
|
24
|
+
u: Array,
|
|
25
|
+
Q_noise: Array,
|
|
26
|
+
*,
|
|
27
|
+
t: Array | float = 0.0,
|
|
28
|
+
) -> tuple[Array, Array]:
|
|
29
|
+
"""Predict one extended Kalman filter step.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
model_or_f: Nonlinear system model or dynamics function mapping
|
|
33
|
+
`(x, u)` to the next state.
|
|
34
|
+
x: Filtered mean at the previous time step. Shape: `(n,)`.
|
|
35
|
+
P: Filtered covariance at the previous time step. Shape: `(n, n)`.
|
|
36
|
+
u: Current input. Shape: `(m,)`.
|
|
37
|
+
Q_noise: Process noise covariance. Shape: `(n, n)`.
|
|
38
|
+
t: Current sample time.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple `(x_pred, P_pred)` with the predicted mean and covariance.
|
|
42
|
+
"""
|
|
43
|
+
f = _coerce_dynamics(model_or_f)
|
|
44
|
+
x_pred = f(x, u, t)
|
|
45
|
+
F = jax.jacfwd(f, argnums=0)(x, u, t)
|
|
46
|
+
P_pred = _symmetrize(F @ P @ F.T + Q_noise)
|
|
47
|
+
return x_pred, P_pred
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ekf_update(
|
|
51
|
+
model_or_h: ObservationLike,
|
|
52
|
+
x_pred: Array,
|
|
53
|
+
P_pred: Array,
|
|
54
|
+
y: Array,
|
|
55
|
+
R_noise: Array,
|
|
56
|
+
*,
|
|
57
|
+
u: Array | None = None,
|
|
58
|
+
t: Array | float = 0.0,
|
|
59
|
+
has_measurement: Array | bool = True,
|
|
60
|
+
num_iter: int = 1,
|
|
61
|
+
) -> tuple[Array, Array, Array]:
|
|
62
|
+
"""Update one extended Kalman filter step.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
model_or_h: Nonlinear system model or observation function.
|
|
66
|
+
x_pred: Predicted mean. Shape: `(n,)`.
|
|
67
|
+
P_pred: Predicted covariance. Shape: `(n, n)`.
|
|
68
|
+
y: Measurement vector. Shape: `(p,)`.
|
|
69
|
+
R_noise: Measurement noise covariance. Shape: `(p, p)`.
|
|
70
|
+
u: Current input used by the observation model. Shape: `(m,)`.
|
|
71
|
+
t: Current sample time.
|
|
72
|
+
has_measurement: Whether to apply the measurement update.
|
|
73
|
+
num_iter: Number of iterated-EKF measurement linearization passes.
|
|
74
|
+
`1` gives the ordinary EKF update.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Tuple `(x, P, innovation)` with the filtered mean, filtered covariance,
|
|
78
|
+
and final measurement residual. Missing-measurement steps return the
|
|
79
|
+
prediction and a zero innovation.
|
|
80
|
+
"""
|
|
81
|
+
if num_iter < 1:
|
|
82
|
+
raise ValueError("num_iter must be at least 1")
|
|
83
|
+
|
|
84
|
+
n = x_pred.shape[0]
|
|
85
|
+
if u is None:
|
|
86
|
+
u = jnp.zeros((0,), dtype=x_pred.dtype)
|
|
87
|
+
h = _coerce_observation(
|
|
88
|
+
model_or_h,
|
|
89
|
+
None if _is_system_model(model_or_h) else model_or_h,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Reference: Jazwinski (1970), "Stochastic Processes and Filtering
|
|
93
|
+
# Theory"; the iterated EKF relinearizes the measurement model around the
|
|
94
|
+
# current update iterate while keeping the prediction covariance fixed.
|
|
95
|
+
def one_iteration(x_lin, _):
|
|
96
|
+
H = jax.jacfwd(h, argnums=0)(x_lin, u, t)
|
|
97
|
+
S = H @ P_pred @ H.T + R_noise
|
|
98
|
+
K = jnp.linalg.solve(S.T, (P_pred @ H.T).T).T
|
|
99
|
+
innov = y - h(x_lin, u, t) + H @ (x_lin - x_pred)
|
|
100
|
+
x_next = x_pred + K @ innov
|
|
101
|
+
return x_next, (x_next, H, K, innov)
|
|
102
|
+
|
|
103
|
+
x_final, (_, Hs, Ks, innovations) = jax.lax.scan(
|
|
104
|
+
one_iteration,
|
|
105
|
+
x_pred,
|
|
106
|
+
xs=None,
|
|
107
|
+
length=num_iter,
|
|
108
|
+
)
|
|
109
|
+
H_final = Hs[-1]
|
|
110
|
+
K_final = Ks[-1]
|
|
111
|
+
innov_final = innovations[-1]
|
|
112
|
+
P_final = _symmetrize((jnp.eye(n, dtype=P_pred.dtype) - K_final @ H_final) @ P_pred)
|
|
113
|
+
|
|
114
|
+
def apply_update():
|
|
115
|
+
return x_final, P_final, innov_final
|
|
116
|
+
|
|
117
|
+
def skip_update():
|
|
118
|
+
return x_pred, P_pred, jnp.zeros_like(y)
|
|
119
|
+
|
|
120
|
+
return jax.lax.cond(
|
|
121
|
+
_measurement_flag(has_measurement),
|
|
122
|
+
apply_update,
|
|
123
|
+
skip_update,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def ekf_step(
|
|
128
|
+
model_or_f: DynamicsLike,
|
|
129
|
+
x: Array,
|
|
130
|
+
P: Array,
|
|
131
|
+
u: Array,
|
|
132
|
+
y: Array,
|
|
133
|
+
Q_noise: Array,
|
|
134
|
+
R_noise: Array,
|
|
135
|
+
*,
|
|
136
|
+
t: Array | float = 0.0,
|
|
137
|
+
has_measurement: Array | bool = True,
|
|
138
|
+
num_iter: int = 1,
|
|
139
|
+
observation: ObservationFn | None = None,
|
|
140
|
+
) -> tuple[Array, Array, Array]:
|
|
141
|
+
"""Run one predict-update step of an extended Kalman filter.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
model_or_f: Nonlinear system model or dynamics function.
|
|
145
|
+
x: Filtered mean at the previous time step. Shape: `(n,)`.
|
|
146
|
+
P: Filtered covariance at the previous time step. Shape: `(n, n)`.
|
|
147
|
+
u: Current input. Shape: `(m,)`.
|
|
148
|
+
y: Measurement vector. Shape: `(p,)`.
|
|
149
|
+
Q_noise: Process noise covariance. Shape: `(n, n)`.
|
|
150
|
+
R_noise: Measurement noise covariance. Shape: `(p, p)`.
|
|
151
|
+
has_measurement: Whether to apply the measurement update.
|
|
152
|
+
num_iter: Number of iterated-EKF measurement linearization passes.
|
|
153
|
+
observation: Observation function `h(x)` for plain dynamics callables.
|
|
154
|
+
Omit this when passing a system model with its own output map.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Tuple `(x, P, innovation)` after the step.
|
|
158
|
+
"""
|
|
159
|
+
if _is_system_model(model_or_f):
|
|
160
|
+
if observation is not None:
|
|
161
|
+
raise ValueError("ekf_step(sys, ...) does not accept observation=.")
|
|
162
|
+
obs = model_or_f
|
|
163
|
+
else:
|
|
164
|
+
if observation is None:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"ekf_step(f, ..., observation=...) requires an observation function."
|
|
167
|
+
)
|
|
168
|
+
obs = observation
|
|
169
|
+
x_pred, P_pred = ekf_predict(model_or_f, x, P, u, Q_noise, t=t)
|
|
170
|
+
return ekf_update(
|
|
171
|
+
obs,
|
|
172
|
+
x_pred,
|
|
173
|
+
P_pred,
|
|
174
|
+
y,
|
|
175
|
+
R_noise,
|
|
176
|
+
u=u,
|
|
177
|
+
t=t,
|
|
178
|
+
has_measurement=has_measurement,
|
|
179
|
+
num_iter=num_iter,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def ekf(
|
|
184
|
+
model_or_f: DynamicsLike,
|
|
185
|
+
Q_noise: Array,
|
|
186
|
+
R_noise: Array,
|
|
187
|
+
ys: Array,
|
|
188
|
+
us: Array,
|
|
189
|
+
x0: Array,
|
|
190
|
+
P0: Array,
|
|
191
|
+
*,
|
|
192
|
+
observation: ObservationFn | None = None,
|
|
193
|
+
) -> KalmanResult:
|
|
194
|
+
"""Run an extended Kalman filter for nonlinear dynamics and observations.
|
|
195
|
+
|
|
196
|
+
Uses automatic Jacobians of the transition and observation functions rather
|
|
197
|
+
than requiring a hand-derived local linearization.
|
|
198
|
+
|
|
199
|
+
This is the main nonlinear recursive estimation primitive in the current
|
|
200
|
+
library. It works well with JAX-defined model functions, but the API is
|
|
201
|
+
intentionally simple and does not yet expose multiple EKF variants.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
model_or_f: Nonlinear system model or dynamics function.
|
|
205
|
+
Q_noise: Process noise covariance. Shape: `(n, n)`.
|
|
206
|
+
R_noise: Measurement noise covariance. Shape: `(p, p)`.
|
|
207
|
+
ys: Measurement sequence. Shape: `(T, p)`.
|
|
208
|
+
us: Input sequence. Shape: `(T, m)`.
|
|
209
|
+
x0: Initial mean. Shape: `(n,)`.
|
|
210
|
+
P0: Initial covariance. Shape: `(n, n)`.
|
|
211
|
+
observation: Observation function `h(x)` for plain dynamics callables.
|
|
212
|
+
Omit this when passing a system model with its own output map.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
[KalmanResult][contrax.types.KalmanResult]: A bundle containing
|
|
216
|
+
filtered means, covariances, and innovations.
|
|
217
|
+
|
|
218
|
+
Examples:
|
|
219
|
+
>>> import jax.numpy as jnp
|
|
220
|
+
>>> import contrax as cx
|
|
221
|
+
>>> def f(x, u):
|
|
222
|
+
... return jnp.array([x[0] + 0.1 * x[1], x[1] + u[0]])
|
|
223
|
+
>>> def h(x):
|
|
224
|
+
... return x[:1]
|
|
225
|
+
>>> result = cx.ekf(
|
|
226
|
+
... f,
|
|
227
|
+
... Q_noise=1e-3 * jnp.eye(2),
|
|
228
|
+
... R_noise=1e-2 * jnp.eye(1),
|
|
229
|
+
... ys=jnp.zeros((10, 1)),
|
|
230
|
+
... us=jnp.zeros((10, 1)),
|
|
231
|
+
... x0=jnp.zeros(2),
|
|
232
|
+
... P0=jnp.eye(2),
|
|
233
|
+
... observation=h,
|
|
234
|
+
... )
|
|
235
|
+
"""
|
|
236
|
+
if _is_system_model(model_or_f):
|
|
237
|
+
if observation is not None:
|
|
238
|
+
raise ValueError("ekf(sys, ...) does not accept observation=.")
|
|
239
|
+
obs = model_or_f
|
|
240
|
+
else:
|
|
241
|
+
if observation is None:
|
|
242
|
+
raise ValueError(
|
|
243
|
+
"ekf(f, ..., observation=...) requires an observation function."
|
|
244
|
+
)
|
|
245
|
+
obs = observation
|
|
246
|
+
|
|
247
|
+
dt = _system_dt(model_or_f)
|
|
248
|
+
ts = jnp.arange(ys.shape[0], dtype=x0.dtype)
|
|
249
|
+
if dt is not None:
|
|
250
|
+
ts = ts * dt
|
|
251
|
+
|
|
252
|
+
# Update-then-predict: x0/P0 is the prior on x_0.
|
|
253
|
+
def update_predict(carry, inputs):
|
|
254
|
+
x_prior, P_prior = carry
|
|
255
|
+
y, u, t = inputs
|
|
256
|
+
x_post, P_post, innov = ekf_update(
|
|
257
|
+
obs,
|
|
258
|
+
x_prior,
|
|
259
|
+
P_prior,
|
|
260
|
+
y,
|
|
261
|
+
R_noise,
|
|
262
|
+
u=u,
|
|
263
|
+
t=t,
|
|
264
|
+
)
|
|
265
|
+
x_next, P_next = ekf_predict(model_or_f, x_post, P_post, u, Q_noise, t=t)
|
|
266
|
+
return (x_next, P_next), (x_post, P_post, innov)
|
|
267
|
+
|
|
268
|
+
_, (x_hats, Ps, innovations) = jax.lax.scan(update_predict, (x0, P0), (ys, us, ts))
|
|
269
|
+
return KalmanResult(x_hat=x_hats, P=Ps, innovations=innovations)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""contrax._estimation_diagnostics — estimation diagnostics and health checks."""
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
import jax.numpy as jnp
|
|
5
|
+
from jax import Array
|
|
6
|
+
|
|
7
|
+
from contrax.types import (
|
|
8
|
+
InnovationDiagnostics,
|
|
9
|
+
KalmanResult,
|
|
10
|
+
LikelihoodDiagnostics,
|
|
11
|
+
RTSResult,
|
|
12
|
+
SmootherDiagnostics,
|
|
13
|
+
UKFResult,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def innovation_diagnostics(
|
|
18
|
+
innovations: Array,
|
|
19
|
+
innovation_covariances: Array,
|
|
20
|
+
) -> InnovationDiagnostics:
|
|
21
|
+
"""Summarize innovation magnitude and covariance conditioning.
|
|
22
|
+
|
|
23
|
+
The returned NIS values are the per-step normalized innovation squared
|
|
24
|
+
terms `v_k^T S_k^{-1} v_k`. They are useful for routine filter health
|
|
25
|
+
inspection, but Contrax does not present them as a formal standalone
|
|
26
|
+
consistency certificate.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def step_nis(innovation, covariance):
|
|
30
|
+
solved = jnp.linalg.solve(covariance, innovation[:, None])
|
|
31
|
+
return (innovation[None, :] @ solved).squeeze()
|
|
32
|
+
|
|
33
|
+
nis = jax.vmap(step_nis)(innovations, innovation_covariances)
|
|
34
|
+
innovation_norms = jnp.linalg.norm(innovations, axis=-1)
|
|
35
|
+
innovation_cov_condition_numbers = jax.vmap(jnp.linalg.cond)(innovation_covariances)
|
|
36
|
+
all_values = (
|
|
37
|
+
jnp.isfinite(nis)
|
|
38
|
+
& jnp.isfinite(innovation_norms)
|
|
39
|
+
& jnp.isfinite(innovation_cov_condition_numbers)
|
|
40
|
+
)
|
|
41
|
+
return InnovationDiagnostics(
|
|
42
|
+
nis=nis,
|
|
43
|
+
innovation_norms=innovation_norms,
|
|
44
|
+
innovation_cov_condition_numbers=innovation_cov_condition_numbers,
|
|
45
|
+
mean_nis=jnp.mean(nis),
|
|
46
|
+
max_nis=jnp.max(nis),
|
|
47
|
+
mean_innovation_norm=jnp.mean(innovation_norms),
|
|
48
|
+
max_innovation_norm=jnp.max(innovation_norms),
|
|
49
|
+
max_innovation_cov_condition_number=jnp.max(innovation_cov_condition_numbers),
|
|
50
|
+
nonfinite=jnp.logical_not(jnp.all(all_values)),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def likelihood_diagnostics(log_likelihood_terms: Array) -> LikelihoodDiagnostics:
|
|
55
|
+
"""Summarize per-step innovation-form log-likelihood terms."""
|
|
56
|
+
return LikelihoodDiagnostics(
|
|
57
|
+
log_likelihood_terms=log_likelihood_terms,
|
|
58
|
+
total_log_likelihood=jnp.sum(log_likelihood_terms),
|
|
59
|
+
mean_log_likelihood=jnp.mean(log_likelihood_terms),
|
|
60
|
+
min_log_likelihood=jnp.min(log_likelihood_terms),
|
|
61
|
+
max_log_likelihood=jnp.max(log_likelihood_terms),
|
|
62
|
+
nonfinite=jnp.logical_not(jnp.all(jnp.isfinite(log_likelihood_terms))),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ukf_diagnostics(
|
|
67
|
+
result: UKFResult,
|
|
68
|
+
) -> tuple[InnovationDiagnostics, LikelihoodDiagnostics]:
|
|
69
|
+
"""Build the standard diagnostics pair for a UKF result."""
|
|
70
|
+
return (
|
|
71
|
+
innovation_diagnostics(result.innovations, result.innovation_covariances),
|
|
72
|
+
likelihood_diagnostics(result.log_likelihood_terms),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def innovation_rms(result: KalmanResult | UKFResult) -> Array:
|
|
77
|
+
"""Return the RMS innovation magnitude for a filtered result."""
|
|
78
|
+
return jnp.sqrt(jnp.mean(result.innovations**2))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def smoother_diagnostics(
|
|
82
|
+
smoothed: RTSResult,
|
|
83
|
+
filtered: KalmanResult | UKFResult,
|
|
84
|
+
) -> SmootherDiagnostics:
|
|
85
|
+
"""Compute health diagnostics for an RTS or UKS smoother result.
|
|
86
|
+
|
|
87
|
+
A numerically healthy smoother satisfies ``P_smooth ≤ P_filtered`` in the
|
|
88
|
+
PSD sense at every step. This function checks that guarantee and surfaces
|
|
89
|
+
the worst-case violation so callers can detect breakdown before trusting
|
|
90
|
+
smoothed trajectories.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
smoothed: Output of `rts()` or `uks()`.
|
|
94
|
+
filtered: The forward filter result used to produce ``smoothed``.
|
|
95
|
+
Accepts both `KalmanResult` and `UKFResult`.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
[SmootherDiagnostics][contrax.types.SmootherDiagnostics]: Diagnostic
|
|
99
|
+
bundle. ``min_covariance_reduction < 0`` is the primary health flag.
|
|
100
|
+
|
|
101
|
+
Examples:
|
|
102
|
+
>>> import jax.numpy as jnp
|
|
103
|
+
>>> import contrax as cx
|
|
104
|
+
>>> sys = cx.dss(
|
|
105
|
+
... jnp.array([[0.9]]), jnp.zeros((1, 1)),
|
|
106
|
+
... jnp.array([[1.0]]), jnp.zeros((1, 1)), dt=1.0,
|
|
107
|
+
... )
|
|
108
|
+
>>> filtered = cx.kalman(sys, 1e-3 * jnp.eye(1), 1e-2 * jnp.eye(1),
|
|
109
|
+
... jnp.zeros((10, 1)))
|
|
110
|
+
>>> smoothed = cx.rts(sys, filtered, Q_noise=1e-3 * jnp.eye(1))
|
|
111
|
+
>>> diag = cx.smoother_diagnostics(smoothed, filtered)
|
|
112
|
+
"""
|
|
113
|
+
x_smooth = smoothed.x_smooth # (T, n)
|
|
114
|
+
P_smooth = smoothed.P_smooth # (T, n, n)
|
|
115
|
+
x_filt = filtered.x_hat # (T, n)
|
|
116
|
+
P_filt = filtered.P # (T, n, n)
|
|
117
|
+
|
|
118
|
+
def _sym(M: Array) -> Array:
|
|
119
|
+
return (M + M.T) / 2
|
|
120
|
+
|
|
121
|
+
# Min eigenvalue of (P_f - P_s): >= 0 means smoother reduced uncertainty.
|
|
122
|
+
cov_reduction = jax.vmap(
|
|
123
|
+
lambda Pf, Ps: jnp.min(jnp.linalg.eigvalsh(_sym(Pf) - _sym(Ps)))
|
|
124
|
+
)(P_filt, P_smooth)
|
|
125
|
+
|
|
126
|
+
state_corrections = jnp.linalg.norm(x_smooth - x_filt, axis=-1)
|
|
127
|
+
|
|
128
|
+
smoothed_min_eig = jax.vmap(lambda Ps: jnp.min(jnp.linalg.eigvalsh(_sym(Ps))))(
|
|
129
|
+
P_smooth
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
nonfinite = jnp.logical_not(
|
|
133
|
+
jnp.all(jnp.isfinite(x_smooth)) & jnp.all(jnp.isfinite(P_smooth))
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return SmootherDiagnostics(
|
|
137
|
+
covariance_reduction=cov_reduction,
|
|
138
|
+
min_covariance_reduction=jnp.min(cov_reduction),
|
|
139
|
+
state_corrections=state_corrections,
|
|
140
|
+
max_state_correction=jnp.max(state_corrections),
|
|
141
|
+
smoothed_min_eigenvalue=smoothed_min_eig,
|
|
142
|
+
nonfinite=nonfinite,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"InnovationDiagnostics",
|
|
148
|
+
"LikelihoodDiagnostics",
|
|
149
|
+
"SmootherDiagnostics",
|
|
150
|
+
"innovation_diagnostics",
|
|
151
|
+
"innovation_rms",
|
|
152
|
+
"likelihood_diagnostics",
|
|
153
|
+
"smoother_diagnostics",
|
|
154
|
+
"ukf_diagnostics",
|
|
155
|
+
]
|