sensorfusion-tools 2026.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustaf Hendeby
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: sensorfusion-tools
3
+ Version: 2026.0.0
4
+ Summary: Python helper library for the TSRT14 Sensor Fusion Course at LiU
5
+ Author: Gustaf Hendeby
6
+ Author-email: Gustaf Hendeby <hendeby@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Intended Audience :: Education
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Dist: matplotlib>=3.10.8
15
+ Requires-Dist: numpy>=2.4.4
16
+ Requires-Dist: scipy>=1.17.1
17
+ Requires-Dist: tqdm>=4.67.3
18
+ Requires-Python: >=3.14
19
+ Description-Content-Type: text/markdown
20
+
21
+ # sensorfusion-tools
22
+
23
+ Signal-systems utilities for estimation, modeling, and plotting.
24
+
25
+ ## Versioning
26
+
27
+ This library uses calendar-style semantic versions: `YYYY.FEATURE.BUGFIX`.
28
+
29
+ - `YYYY`: release year (for long-term timeline tracking)
30
+ - `FEATURE`: increments for new functionality
31
+ - `BUGFIX`: increments for patches and fixes
32
+
33
+ ## Framework And Tooling
34
+
35
+ - Language: Python 3.14+
36
+ - Build backend: `uv_build`
37
+ - Package/dependency workflow: `uv`
38
+ - Numerical stack: `numpy`, `scipy`
39
+ - Plotting: `matplotlib`
40
+
41
+ ## Testing Framework
42
+
43
+ - Test runner: `pytest`
44
+ - Property-based testing: `hypothesis`
45
+ - Coverage: `pytest-cov` (configured for `src/sftools`)
46
+
47
+ Run tests:
48
+
49
+ ```bash
50
+ uv run pytest
51
+ ```
52
+
53
+ Run only sftools tests:
54
+
55
+ ```bash
56
+ uv run pytest tests/sftools
57
+ ```
58
+
59
+ ## Code Quality
60
+
61
+ - Linting: `ruff`
62
+
63
+ Run lint:
64
+
65
+ ```bash
66
+ uv run ruff check .
67
+ ```
68
+
69
+ ## CI (GitLab)
70
+
71
+ The pipeline is defined in [.gitlab-ci.yml](.gitlab-ci.yml) and includes:
72
+
73
+ - `quality` stage: `ruff` lint checks
74
+ - `test` stage: `pytest` with JUnit artifact output
75
+
76
+ CI uses `python:3.14-slim` and installs dependencies with:
77
+
78
+ ```bash
79
+ uv sync --group dev
80
+ ```
81
+
82
+ ## License
83
+
84
+ Distributed under the MIT License. See [LICENSE](LICENSE) for details.
85
+
86
+ ## Project Structure
87
+
88
+ ```text
89
+ src/
90
+ sftools/ # Main signal-system models, estimators, helpers, plotting, shared types
91
+
92
+ tests/
93
+ sftools/ # Pytest suite for src/sftools (one test file per source file)
94
+ fixtures/ # Regression test input/output fixtures
95
+ ```
96
+
97
+ For sftools, tests mirror the source layout:
98
+
99
+ - `src/sftools/model_obs.py` -> `tests/sftools/test_model.py`
100
+ - `src/sftools/estimator.py` -> `tests/sftools/test_estimator.py`
101
+ - `src/sftools/helper.py` -> `tests/sftools/test_helper.py`
102
+ - `src/sftools/plot.py` -> `tests/sftools/test_plot.py`
103
+
104
+ Shared state/trajectory datatypes are defined in `src/sftools/types.py`:
105
+
106
+ - `GaussianState`
107
+ - `ParticleState`
108
+ - `StateTrajectory` (supports probabilistic trajectories via `write_state` and deterministic trajectories via `write_mean`)
109
+
110
+ Useful `StateTrajectory` iteration helpers:
111
+
112
+ - `for y in traj` yields state columns with shape `(n, 1)`
113
+ - `traj.iter_samples(include_cov=True, include_time=True)` yields `(y, cov, t)` tuples when available
114
+
115
+ Plotting helpers in `src/sftools/plot.py` use `StateTrajectory` inputs.
116
+
117
+ Particle filter helper:
118
+
119
+ - `ParticleFilter.init_state_gaussian(mean, cov, n_particles)` initializes a `ParticleState` from a Gaussian prior using the filter RNG.
@@ -0,0 +1,99 @@
1
+ # sensorfusion-tools
2
+
3
+ Signal-systems utilities for estimation, modeling, and plotting.
4
+
5
+ ## Versioning
6
+
7
+ This library uses calendar-style semantic versions: `YYYY.FEATURE.BUGFIX`.
8
+
9
+ - `YYYY`: release year (for long-term timeline tracking)
10
+ - `FEATURE`: increments for new functionality
11
+ - `BUGFIX`: increments for patches and fixes
12
+
13
+ ## Framework And Tooling
14
+
15
+ - Language: Python 3.14+
16
+ - Build backend: `uv_build`
17
+ - Package/dependency workflow: `uv`
18
+ - Numerical stack: `numpy`, `scipy`
19
+ - Plotting: `matplotlib`
20
+
21
+ ## Testing Framework
22
+
23
+ - Test runner: `pytest`
24
+ - Property-based testing: `hypothesis`
25
+ - Coverage: `pytest-cov` (configured for `src/sftools`)
26
+
27
+ Run tests:
28
+
29
+ ```bash
30
+ uv run pytest
31
+ ```
32
+
33
+ Run only sftools tests:
34
+
35
+ ```bash
36
+ uv run pytest tests/sftools
37
+ ```
38
+
39
+ ## Code Quality
40
+
41
+ - Linting: `ruff`
42
+
43
+ Run lint:
44
+
45
+ ```bash
46
+ uv run ruff check .
47
+ ```
48
+
49
+ ## CI (GitLab)
50
+
51
+ The pipeline is defined in [.gitlab-ci.yml](.gitlab-ci.yml) and includes:
52
+
53
+ - `quality` stage: `ruff` lint checks
54
+ - `test` stage: `pytest` with JUnit artifact output
55
+
56
+ CI uses `python:3.14-slim` and installs dependencies with:
57
+
58
+ ```bash
59
+ uv sync --group dev
60
+ ```
61
+
62
+ ## License
63
+
64
+ Distributed under the MIT License. See [LICENSE](LICENSE) for details.
65
+
66
+ ## Project Structure
67
+
68
+ ```text
69
+ src/
70
+ sftools/ # Main signal-system models, estimators, helpers, plotting, shared types
71
+
72
+ tests/
73
+ sftools/ # Pytest suite for src/sftools (one test file per source file)
74
+ fixtures/ # Regression test input/output fixtures
75
+ ```
76
+
77
+ For sftools, tests mirror the source layout:
78
+
79
+ - `src/sftools/model_obs.py` -> `tests/sftools/test_model.py`
80
+ - `src/sftools/estimator.py` -> `tests/sftools/test_estimator.py`
81
+ - `src/sftools/helper.py` -> `tests/sftools/test_helper.py`
82
+ - `src/sftools/plot.py` -> `tests/sftools/test_plot.py`
83
+
84
+ Shared state/trajectory datatypes are defined in `src/sftools/types.py`:
85
+
86
+ - `GaussianState`
87
+ - `ParticleState`
88
+ - `StateTrajectory` (supports probabilistic trajectories via `write_state` and deterministic trajectories via `write_mean`)
89
+
90
+ Useful `StateTrajectory` iteration helpers:
91
+
92
+ - `for y in traj` yields state columns with shape `(n, 1)`
93
+ - `traj.iter_samples(include_cov=True, include_time=True)` yields `(y, cov, t)` tuples when available
94
+
95
+ Plotting helpers in `src/sftools/plot.py` use `StateTrajectory` inputs.
96
+
97
+ Particle filter helper:
98
+
99
+ - `ParticleFilter.init_state_gaussian(mean, cov, n_particles)` initializes a `ParticleState` from a Gaussian prior using the filter RNG.
@@ -0,0 +1,60 @@
1
+ [project]
2
+ name = "sensorfusion-tools"
3
+ version = "2026.0.0"
4
+ description = "Python helper library for the TSRT14 Sensor Fusion Course at LiU"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Gustaf Hendeby", email = "hendeby@gmail.com" },
10
+ ]
11
+ classifiers = [
12
+ "Intended Audience :: Education",
13
+ "Intended Audience :: Science/Research",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Topic :: Scientific/Engineering",
17
+ ]
18
+ requires-python = ">=3.14"
19
+ dependencies = [
20
+ "matplotlib>=3.10.8",
21
+ "numpy>=2.4.4",
22
+ "scipy>=1.17.1",
23
+ "tqdm>=4.67.3",
24
+ ]
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "hypothesis>=6.147.0",
29
+ "pre-commit>=4.5.1",
30
+ "pyqt5>=5.15.11",
31
+ "pytest>=9.0.1",
32
+ "pytest-cov>=7.1.0",
33
+ "ruff>=0.14.0",
34
+ "scriptweave>=2026.0.0",
35
+ ]
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.9"]
39
+ build-backend = "uv_build"
40
+
41
+ [tool.uv.build-backend]
42
+ module-name = "sftools"
43
+
44
+ [tool.pytest.ini_options]
45
+ addopts = "-ra --strict-markers --cov=src/sftools --cov-report=term-missing"
46
+ testpaths = ["tests"]
47
+
48
+ [tool.coverage.run]
49
+ source = ["src/sftools"]
50
+ branch = true
51
+
52
+ [tool.coverage.report]
53
+ show_missing = true
54
+ skip_covered = false
55
+
56
+ [[tool.uv.index]]
57
+ name = "testpypi"
58
+ url = "https://test.pypi.org/simple/"
59
+ publish-url = "https://test.pypi.org/legacy/"
60
+ explicit = true
@@ -0,0 +1,194 @@
1
+ """Entry points for models, estimators, and shared types.
2
+
3
+ Most users should import from this package root, for example:
4
+
5
+ - ``create_dynamics(...)`` and ``create_observation(...)`` to build models
6
+ - ``StateTrajectory`` to store time-series state data
7
+ - ``GaussianState`` and ``ParticleState`` for filter states
8
+
9
+ Model classes are registered via decorators in ``model_dyn`` and ``model_obs``.
10
+ """
11
+
12
+ from importlib.metadata import PackageNotFoundError
13
+ from importlib.metadata import version as _pkg_version
14
+ from typing import Any, Callable, ClassVar, Protocol, Self
15
+
16
+ import numpy as np
17
+ import numpy.typing as npt
18
+
19
+ from sftools.types import GaussianState, ParticleState, StateLike, StateTrajectory
20
+
21
+ try:
22
+ __version__ = _pkg_version("sensorfusion-tools")
23
+ except PackageNotFoundError:
24
+ # Fallback when package metadata is unavailable (for example, direct source use).
25
+ __version__ = "0+unknown"
26
+
27
+
28
+ class ObservationModel(Protocol):
29
+ """Protocol for measurement models used by estimators.
30
+
31
+ Implementations must provide deterministic measurement evaluation (``h``),
32
+ Jacobian evaluation (``H``), and measurement sampling (``random``).
33
+
34
+ State input ``x`` may be provided either as a single state vector ``(n,)`` /
35
+ ``(n, 1)`` or as batched columns ``(n, N)`` for models that support batched
36
+ evaluation.
37
+ """
38
+
39
+ R: npt.NDArray
40
+
41
+ def h(self: Self, x: npt.NDArray) -> npt.NDArray: ...
42
+ def H(self: Self, x: npt.NDArray) -> npt.NDArray: ...
43
+ def random(
44
+ self: Self,
45
+ x: npt.NDArray,
46
+ n: int = 1,
47
+ rng: np.random.Generator | None = None,
48
+ ) -> npt.NDArray: ...
49
+
50
+
51
+ class DynamicModel(Protocol):
52
+ """Protocol for state transition models used by estimators.
53
+
54
+ Implementations must provide deterministic propagation (``f``), Jacobian
55
+ evaluation (``F``), process-noise gain (``G``), and stochastic propagation
56
+ (``random``).
57
+ """
58
+
59
+ Q: npt.NDArray
60
+
61
+ def f(self: Self, x: npt.NDArray) -> npt.NDArray: ...
62
+ def F(self: Self, x: npt.NDArray) -> npt.NDArray: ...
63
+ def G(self: Self, x: npt.NDArray) -> npt.NDArray: ...
64
+ def random(
65
+ self: Self,
66
+ n: int = 1,
67
+ x: npt.NDArray | None = None,
68
+ rng: np.random.Generator | None = None,
69
+ ) -> npt.NDArray: ...
70
+
71
+
72
+ class ModelFactory:
73
+ """Registry-based factory for observation and dynamic model classes.
74
+
75
+ The factory powers the main helper functions:
76
+
77
+ - ``create_observation(name, ...)``
78
+ - ``create_dynamics(name, ...)``
79
+
80
+ and supports case-insensitive model names.
81
+ """
82
+
83
+ _registry: ClassVar[dict[str, dict[str, type]]] = {"obs": {}, "dyn": {}}
84
+
85
+ @classmethod
86
+ def register(cls: type[Self], cls_type: str, name: str) -> Callable[..., type]:
87
+ """Return decorator that registers a model class under `name`."""
88
+ cls_type = cls_type.lower()
89
+
90
+ if cls_type not in cls._registry:
91
+ msg = f"Unknown model type '{cls_type}'"
92
+ raise ValueError(msg)
93
+
94
+ def _decorator(model_cls: type) -> type:
95
+ key = name.lower()
96
+ if (
97
+ key in cls._registry[cls_type] and cls._registry[cls_type][key] is not model_cls
98
+ ):
99
+ msg = f"{cls_type} model '{name}' is already registered"
100
+ raise ValueError(msg)
101
+ cls._registry[cls_type][key] = model_cls
102
+ return model_cls
103
+
104
+ return _decorator
105
+
106
+ @classmethod
107
+ def create(
108
+ cls: type[Self], cls_type: str, name: str, *args: Any, **kwargs: Any
109
+ ) -> Any:
110
+ """Instantiate a registered model by kind and name."""
111
+ key = name.lower()
112
+
113
+ if cls_type is not None:
114
+ cls_type = cls_type.lower()
115
+ if cls_type not in cls._registry:
116
+ msg = f"Unknown model type '{cls_type}'"
117
+ raise KeyError(msg)
118
+ if key not in cls._registry[cls_type]:
119
+ available = ", ".join(sorted(cls._registry[cls_type]))
120
+ msg = f"Unknown {cls_type} model '{name}'. Available models: {available}"
121
+ raise KeyError(msg)
122
+ return cls._registry[cls_type][key](*args, **kwargs)
123
+
124
+ available = set()
125
+ for k in ("obs", "dyn"):
126
+ available.update(cls._registry[k].keys())
127
+ available = ", ".join(sorted(available))
128
+ msg = f"Unknown model '{name}'. Available models: {available}"
129
+ raise KeyError(msg)
130
+
131
+ @classmethod
132
+ def registered(cls: type[Self], cls_type: str | None = None) -> tuple[str, ...]:
133
+ """Return sorted registered model names for a kind or globally."""
134
+ if cls_type is not None:
135
+ cls_type = cls_type.lower()
136
+ if cls_type not in cls._registry:
137
+ msg = f"Unknown model kind '{cls_type}'"
138
+ raise KeyError(msg)
139
+ return tuple(sorted(cls._registry[cls_type].keys()))
140
+ names: set[str] = set()
141
+ for v in cls._registry.values():
142
+ names.update(v.keys())
143
+ return tuple(sorted(names))
144
+
145
+
146
+ def register_observation(name: str):
147
+ """Decorator helper to register an observation model class.
148
+
149
+ Example:
150
+ ``@register_observation("toa")``
151
+ """
152
+ return ModelFactory.register("obs", name)
153
+
154
+
155
+ def create_observation(name: str, *args: Any, **kwargs: Any) -> ObservationModel:
156
+ """Create a registered observation model instance by name."""
157
+ return ModelFactory.create("obs", name, *args, **kwargs)
158
+
159
+
160
+ def register_dynamics(name: str):
161
+ """Decorator helper to register a dynamics model class.
162
+
163
+ Example:
164
+ ``@register_dynamics("cv")``
165
+ """
166
+ return ModelFactory.register("dyn", name)
167
+
168
+
169
+ def create_dynamics(name: str, *args: Any, **kwargs: Any) -> DynamicModel:
170
+ """Create a registered dynamics model instance by name."""
171
+ return ModelFactory.create("dyn", name, *args, **kwargs)
172
+
173
+
174
+ __all__ = [
175
+ "DynamicModel",
176
+ "GaussianState",
177
+ "ObservationModel",
178
+ "ParticleState",
179
+ "StateLike",
180
+ "StateTrajectory",
181
+ "__version__",
182
+ "create_dynamics",
183
+ "create_observation",
184
+ "helper",
185
+ "model_dyn",
186
+ "model_obs",
187
+ "register_dynamics",
188
+ "register_observation",
189
+ ]
190
+
191
+ # Import model modules for side-effect registration via decorators.
192
+ import sftools.helper as helper # noqa: E402
193
+ import sftools.model_dyn as model_dyn # noqa: E402
194
+ import sftools.model_obs as model_obs # noqa: E402
@@ -0,0 +1,195 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Self
3
+
4
+ import numpy as np
5
+ import numpy.typing as npt
6
+ from scipy.optimize import least_squares
7
+ from scipy.special import logsumexp
8
+
9
+ from sftools import (
10
+ DynamicModel,
11
+ ObservationModel,
12
+ )
13
+ from sftools.helper import ut
14
+ from sftools.types import GaussianState, ParticleState
15
+
16
+
17
+ def ls(
18
+ model: ObservationModel, y: npt.NDArray, x0: npt.NDArray | None = None
19
+ ) -> GaussianState:
20
+ """Compute linear least-squares state estimate and covariance.
21
+
22
+ Args:
23
+ model: Observation model providing linearized measurement Jacobian via `H`.
24
+ y: Measurement column vector.
25
+ x0: Optional linearization point forwarded to `model.H`.
26
+
27
+ Returns:
28
+ GaussianState with estimate and covariance approximation.
29
+ """
30
+ H = model.H(x0)
31
+ x_hat = np.linalg.lstsq(H, y, rcond=None)[0]
32
+ P_ls = np.linalg.inv(H.T @ np.linalg.lstsq(model.R, H, rcond=None)[0])
33
+ return GaussianState(x_hat, P_ls)
34
+
35
+
36
+ def wls(
37
+ model: ObservationModel, y: npt.NDArray, x0: npt.NDArray | None = None
38
+ ) -> GaussianState:
39
+ """Compute weighted least-squares estimate using measurement covariance."""
40
+ H = model.H(x0)
41
+ L = np.linalg.cholesky(model.R)
42
+ y_ = np.linalg.solve(L, y)
43
+ H_ = np.linalg.solve(L, H)
44
+ x_hat = np.linalg.lstsq(H_, y_, rcond=None)[0]
45
+ P_wls = np.linalg.inv(H_.T @ H_)
46
+ return GaussianState(x_hat, P_wls)
47
+
48
+
49
+ def nls(model: ObservationModel, y: npt.NDArray, x0: npt.NDArray) -> GaussianState:
50
+ """Solve nonlinear least-squares by Levenberg-Marquardt with analytic Jacobian.
51
+
52
+ Returns the converged state estimate and a local covariance approximation.
53
+ """
54
+ L = np.linalg.cholesky(model.R)
55
+
56
+ def eps(x_flat: npt.NDArray) -> npt.NDArray:
57
+ x = x_flat[:, None]
58
+ return np.linalg.solve(L, y - model.h(x)).ravel()
59
+
60
+ def d_eps(x_flat: npt.NDArray) -> npt.NDArray:
61
+ x = x_flat[:, None]
62
+ return -np.linalg.solve(L, model.H(x))
63
+
64
+ result = least_squares(eps, x0=x0.ravel(), jac=d_eps, method="lm")
65
+ H = model.H(result.x[:, None])
66
+ P_nls = np.linalg.inv(H.T @ np.linalg.solve(model.R, H))
67
+ return GaussianState(result.x[:, None], P_nls)
68
+
69
+
70
+ @dataclass
71
+ class KalmanFilter:
72
+ """Classic Kalman filter with explicit prediction and correction steps."""
73
+
74
+ dyn_model: DynamicModel
75
+
76
+ def time_update(self: Self, state: GaussianState) -> GaussianState:
77
+ """Propagate Gaussian belief through the dynamic model."""
78
+ xk, Pk = state.mean(), state.P
79
+ return GaussianState(
80
+ self.dyn_model.f(xk),
81
+ self.dyn_model.F(xk) @ Pk @ self.dyn_model.F(xk).T
82
+ + self.dyn_model.G(xk) @ self.dyn_model.Q @ self.dyn_model.G(xk).T,
83
+ )
84
+
85
+ def meas_update(
86
+ self: Self,
87
+ state: GaussianState,
88
+ obs_model: ObservationModel,
89
+ y: npt.NDArray[np.float64],
90
+ ) -> GaussianState:
91
+ """Condition Gaussian belief on one measurement."""
92
+ xk, Pk = state.mean(), state.P
93
+ S = obs_model.H(xk) @ Pk @ obs_model.H(xk).T + obs_model.R
94
+ K = np.linalg.solve(S, (Pk @ obs_model.H(xk).T).T).T
95
+ ImKH = np.eye(len(xk)) - K @ obs_model.H(xk)
96
+ return GaussianState(
97
+ xk + K @ (y - obs_model.h(xk)), ImKH @ Pk @ ImKH.T + K @ obs_model.R @ K.T
98
+ )
99
+
100
+
101
+ @dataclass
102
+ class UnscentedKalmanFilter:
103
+ """Unscented Kalman filter using the unscented transform for propagation."""
104
+
105
+ dyn_model: DynamicModel
106
+
107
+ def time_update(self: Self, state: GaussianState) -> GaussianState:
108
+ """Propagate Gaussian belief using sigma-point transformation."""
109
+ Xhat = ut(state, self.dyn_model.f)
110
+ G = self.dyn_model.G(state.mean())
111
+ return GaussianState(
112
+ Xhat.x,
113
+ Xhat.P + G @ self.dyn_model.Q @ G.T,
114
+ )
115
+
116
+ def meas_update(
117
+ self: Self,
118
+ state: GaussianState,
119
+ obs_model: ObservationModel,
120
+ y: npt.NDArray[np.float64],
121
+ ) -> GaussianState:
122
+ """Apply measurement update using augmented-state unscented transform."""
123
+ xk, Pk = state.mean(), state.P
124
+ n_x = xk.shape[0]
125
+
126
+ def fun(x):
127
+ return np.vstack([x[:n_x], obs_model.h(x[:n_x]) + x[n_x:]])
128
+
129
+ x = np.vstack((xk, np.zeros((obs_model.R.shape[0], 1))))
130
+ P = np.block(
131
+ [
132
+ [Pk, np.zeros((Pk.shape[0], obs_model.R.shape[1]))],
133
+ [np.zeros((obs_model.R.shape[0], Pk.shape[1])), obs_model.R],
134
+ ]
135
+ )
136
+ Xhat = ut(GaussianState(x, P), fun)
137
+ x_aug = Xhat.x
138
+ P_aug = Xhat.P
139
+ xk_hat, yhat = x_aug[:n_x], x_aug[n_x:]
140
+ Pxy = P_aug[:n_x, n_x:]
141
+ S = P_aug[n_x:, n_x:]
142
+ K = np.linalg.solve(S, Pxy.T).T
143
+ innov = y - yhat[:, None]
144
+ return GaussianState(xk_hat + (K @ innov).ravel(), Pk - K @ S @ K.T)
145
+
146
+
147
+ @dataclass
148
+ class ParticleFilter:
149
+ """Bootstrap particle filter with log-weights and multinomial resampling."""
150
+
151
+ dyn_model: DynamicModel
152
+ rng: np.random.Generator = field(default_factory=lambda: np.random.default_rng(0))
153
+
154
+ def init_state_gaussian(
155
+ self: Self,
156
+ mean: npt.NDArray[np.float64],
157
+ cov: npt.NDArray[np.float64],
158
+ n_particles: int,
159
+ ) -> ParticleState:
160
+ """Initialize particles from a Gaussian prior with uniform weights."""
161
+ if n_particles <= 0:
162
+ msg = "n_particles must be positive"
163
+ raise ValueError(msg)
164
+
165
+ mu = np.asarray(mean, dtype=np.float64).reshape(-1)
166
+ P = np.asarray(cov, dtype=np.float64)
167
+ particles = self.rng.multivariate_normal(mean=mu, cov=P, size=n_particles).T
168
+ logw = np.full(n_particles, -np.log(n_particles), dtype=np.float64)
169
+ return ParticleState(particles, logw)
170
+
171
+ def time_update(self: Self, state: ParticleState) -> ParticleState:
172
+ """Sample propagated particles from the transition model."""
173
+ return ParticleState(
174
+ self.dyn_model.random(x=state.x, n=state.x.shape[1], rng=self.rng), state.logw
175
+ )
176
+
177
+ def meas_update(
178
+ self: Self,
179
+ state: ParticleState,
180
+ obs_model: ObservationModel,
181
+ y: npt.NDArray[np.float64],
182
+ ) -> ParticleState:
183
+ """Update particle log-weights from Gaussian measurement likelihood."""
184
+ xk, wk = state.x, state.logw
185
+ L = np.linalg.cholesky(obs_model.R)
186
+ eps = np.linalg.solve(L, y - obs_model.h(xk))
187
+ log_weights = wk + -0.5 * np.sum(eps**2, axis=0)
188
+ log_weights -= logsumexp(log_weights)
189
+ return ParticleState(xk, log_weights)
190
+
191
+ def resample(self: Self, state: ParticleState) -> ParticleState:
192
+ """Resample particles according to normalized weights."""
193
+ N = len(state.logw)
194
+ indices = self.rng.choice(N, size=N, replace=True, p=np.exp(state.logw))
195
+ return ParticleState(state.x[:, indices], -np.log(N) * np.ones(N))