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.
- sensorfusion_tools-2026.0.0/LICENSE +21 -0
- sensorfusion_tools-2026.0.0/PKG-INFO +119 -0
- sensorfusion_tools-2026.0.0/README.md +99 -0
- sensorfusion_tools-2026.0.0/pyproject.toml +60 -0
- sensorfusion_tools-2026.0.0/src/sftools/__init__.py +194 -0
- sensorfusion_tools-2026.0.0/src/sftools/estimator.py +195 -0
- sensorfusion_tools-2026.0.0/src/sftools/helper.py +139 -0
- sensorfusion_tools-2026.0.0/src/sftools/model_dyn.py +305 -0
- sensorfusion_tools-2026.0.0/src/sftools/model_obs.py +375 -0
- sensorfusion_tools-2026.0.0/src/sftools/plot.py +316 -0
- sensorfusion_tools-2026.0.0/src/sftools/types.py +398 -0
|
@@ -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))
|