otwin 0.2.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.
- otwin/__init__.py +91 -0
- otwin/advise/__init__.py +9 -0
- otwin/advise/envelope.py +294 -0
- otwin/estimate/__init__.py +80 -0
- otwin/estimate/energy.py +485 -0
- otwin/estimate/kalman.py +470 -0
- otwin/estimate/linear.py +198 -0
- otwin/estimate/mhe.py +411 -0
- otwin/forecast/__init__.py +82 -0
- otwin/forecast/baselines.py +186 -0
- otwin/forecast/calibration.py +150 -0
- otwin/forecast/ensemble.py +112 -0
- otwin/forecast/gp_phs.py +134 -0
- otwin/forecast/metrics.py +301 -0
- otwin/forecast/protocol.py +298 -0
- otwin/forecast/report.py +257 -0
- otwin/forecast/splitters.py +155 -0
- otwin/interfaces/__init__.py +55 -0
- otwin/interfaces/manifest.py +265 -0
- otwin/interfaces/protocols.py +390 -0
- otwin/interfaces/results.py +388 -0
- otwin/io/__init__.py +150 -0
- otwin/io/loader.py +129 -0
- otwin/io/modbus.py +712 -0
- otwin/io/registry.py +74 -0
- otwin/io/simulator.py +659 -0
- otwin/io/source.py +419 -0
- otwin/io/sunspec.py +1189 -0
- otwin/model/__init__.py +86 -0
- otwin/model/integrators.py +1119 -0
- otwin/model/iphs.py +255 -0
- otwin/model/library.py +371 -0
- otwin/model/linalg.py +129 -0
- otwin/model/losses.py +34 -0
- otwin/model/phnn.py +235 -0
- otwin/model/phs.py +248 -0
- otwin/model/solvers.py +127 -0
- otwin/py.typed +0 -0
- otwin/signal/__init__.py +17 -0
- otwin/signal/condition.py +218 -0
- otwin-0.2.0.dist-info/METADATA +537 -0
- otwin-0.2.0.dist-info/RECORD +45 -0
- otwin-0.2.0.dist-info/WHEEL +4 -0
- otwin-0.2.0.dist-info/licenses/LICENSE +202 -0
- otwin-0.2.0.dist-info/licenses/NOTICE +7 -0
otwin/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Otwin: digital twins whose physics is checked, not claimed.
|
|
2
|
+
|
|
3
|
+
A twin built here is an energy-based model -- a bond graph in state-space form.
|
|
4
|
+
Power is routed by ``J``, dissipated by ``R``, stored by ``H``, and crosses the
|
|
5
|
+
boundary through ``g``. With the terminals open, stored energy cannot increase:
|
|
6
|
+
not approximately, not for well-chosen parameters, not only inside the range you
|
|
7
|
+
fitted. It is an algebraic property, so it holds at any step size and any
|
|
8
|
+
horizon.
|
|
9
|
+
|
|
10
|
+
That is a falsifiable claim, so something checks it. ``otwin-spec`` is a type
|
|
11
|
+
test procedure -- reference cases with closed-form answers, and deliberately
|
|
12
|
+
broken implementations proving each check catches the fault it was written for.
|
|
13
|
+
|
|
14
|
+
The package follows the ISO 13374 processing blocks, so the module layout is
|
|
15
|
+
the one a condition-monitoring engineer already has in their head:
|
|
16
|
+
|
|
17
|
+
otwin.io DA Data Acquisition SunSpec Modbus, Modbus TCP/RTU
|
|
18
|
+
otwin.signal DM Data Manipulation resampling, gaps, units
|
|
19
|
+
otwin.estimate SD State Detection EKF, MHE, energy-consistent observer
|
|
20
|
+
otwin.model HA Health Assessment the energy-based model itself
|
|
21
|
+
otwin.forecast PA Prognostic Assessment forecasts, skill, calibrated bands
|
|
22
|
+
otwin.advise AG Advisory Generation the validity envelope, and refusal
|
|
23
|
+
|
|
24
|
+
Ten seconds:
|
|
25
|
+
|
|
26
|
+
>>> import numpy as np
|
|
27
|
+
>>> from otwin.model import PortHamiltonianSystem, integrate_phs
|
|
28
|
+
>>>
|
|
29
|
+
>>> # A damped oscillator: a spring, a mass, and a damper.
|
|
30
|
+
>>> osc = PortHamiltonianSystem(
|
|
31
|
+
... H = lambda x: 0.5 * 2.0 * x[0]**2 + 0.5 * x[1]**2,
|
|
32
|
+
... grad_H = lambda x: np.array([2.0 * x[0], x[1]]),
|
|
33
|
+
... J = lambda x: np.array([[0.0, 1.0], [-1.0, 0.0]]),
|
|
34
|
+
... R = lambda x: np.array([[0.0, 0.0], [0.0, 0.3]]),
|
|
35
|
+
... g = lambda x: np.array([[0.0], [1.0]]),
|
|
36
|
+
... n_states=2, n_inputs=1,
|
|
37
|
+
... )
|
|
38
|
+
>>> t = np.linspace(0, 20, 400)
|
|
39
|
+
>>> sol = integrate_phs(osc, np.array([1.0, 0.0]), t, np.zeros((400, 1)))
|
|
40
|
+
>>> E = np.array([osc.energy(x) for x in sol["x"]])
|
|
41
|
+
>>> bool(np.all(np.diff(E) <= 1e-9)) # holds at every step, by construction
|
|
42
|
+
True
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
__version__ = "0.2.0"
|
|
46
|
+
|
|
47
|
+
from otwin.interfaces import (
|
|
48
|
+
MANIFEST_VERSION,
|
|
49
|
+
Array,
|
|
50
|
+
Baseline,
|
|
51
|
+
EmpiricalLawModel,
|
|
52
|
+
Estimator,
|
|
53
|
+
EvaluationProtocol,
|
|
54
|
+
Forecast,
|
|
55
|
+
HasEnergyGradient,
|
|
56
|
+
Integrator,
|
|
57
|
+
Interval,
|
|
58
|
+
IrreversibleModel,
|
|
59
|
+
MetricSet,
|
|
60
|
+
PortHamiltonianModel,
|
|
61
|
+
Provenance,
|
|
62
|
+
Report,
|
|
63
|
+
Splitter,
|
|
64
|
+
TwinManifest,
|
|
65
|
+
TwinModel,
|
|
66
|
+
UncertaintyModel,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"__version__",
|
|
71
|
+
# The interface specification. Everything else is an implementation of it.
|
|
72
|
+
"TwinModel",
|
|
73
|
+
"PortHamiltonianModel",
|
|
74
|
+
"HasEnergyGradient",
|
|
75
|
+
"IrreversibleModel",
|
|
76
|
+
"EmpiricalLawModel",
|
|
77
|
+
"Integrator",
|
|
78
|
+
"Estimator",
|
|
79
|
+
"UncertaintyModel",
|
|
80
|
+
"Baseline",
|
|
81
|
+
"Splitter",
|
|
82
|
+
"EvaluationProtocol",
|
|
83
|
+
"Forecast",
|
|
84
|
+
"Interval",
|
|
85
|
+
"MetricSet",
|
|
86
|
+
"Report",
|
|
87
|
+
"TwinManifest",
|
|
88
|
+
"Provenance",
|
|
89
|
+
"MANIFEST_VERSION",
|
|
90
|
+
"Array",
|
|
91
|
+
]
|
otwin/advise/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Advisory Generation (ISO 13374 block AG): what to do, or why we won't say.
|
|
2
|
+
|
|
3
|
+
The interesting part of an advisory layer is not what it recommends. It is
|
|
4
|
+
what it refuses. See :mod:`otwin.advise.envelope`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .envelope import Breach, Envelope, OutsideEnvelope, Verdict
|
|
8
|
+
|
|
9
|
+
__all__ = ["Envelope", "Verdict", "Breach", "OutsideEnvelope"]
|
otwin/advise/envelope.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""The twin can say no.
|
|
2
|
+
|
|
3
|
+
A model that always returns a number is not being careful, it is being polite.
|
|
4
|
+
Every forecast this library produces carries a validity envelope: the operating
|
|
5
|
+
range the model was actually identified over, the horizon it was actually
|
|
6
|
+
validated to, and whether its intervals have actually been checked against
|
|
7
|
+
held-out data. Ask outside that envelope and you get a refusal with a reason,
|
|
8
|
+
not a plausible number.
|
|
9
|
+
|
|
10
|
+
This is the ISO 13374 Advisory Generation block (AG), and it is the difference
|
|
11
|
+
between a twin and a plotting library. A confident wrong answer about when a
|
|
12
|
+
40 MWh bank reaches end of life costs more than no answer at all.
|
|
13
|
+
|
|
14
|
+
The envelope is read from the :class:`~otwin.interfaces.TwinManifest` -- the
|
|
15
|
+
same record that says what was estimated, under which split protocol, and at
|
|
16
|
+
what measured coverage. Nothing here is inferred. If the manifest does not
|
|
17
|
+
record that something was checked, the answer is that it was not checked, and
|
|
18
|
+
``not yet checked`` is not the same as ``fine``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import numpy.typing as npt
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OutsideEnvelope(RuntimeError):
|
|
29
|
+
"""The question falls outside what this twin has been shown to answer."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, verdict: "Verdict"):
|
|
32
|
+
self.verdict = verdict
|
|
33
|
+
super().__init__(verdict.explain())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Breach:
|
|
38
|
+
"""One reason a request falls outside the envelope."""
|
|
39
|
+
|
|
40
|
+
kind: str
|
|
41
|
+
detail: str
|
|
42
|
+
observed: float | None = None
|
|
43
|
+
limit: float | None = None
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
if self.observed is None or self.limit is None:
|
|
47
|
+
return f"{self.kind}: {self.detail}"
|
|
48
|
+
return (
|
|
49
|
+
f"{self.kind}: {self.detail} "
|
|
50
|
+
f"(asked for {self.observed:g}, validated to {self.limit:g})"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Verdict:
|
|
56
|
+
"""What the twin is willing to say, and why.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
answerable: Whether the question is inside the validated envelope.
|
|
60
|
+
breaches: Every reason it is not. Empty when ``answerable``.
|
|
61
|
+
checked: Every check that ran and passed -- so a clean verdict is
|
|
62
|
+
evidence rather than silence.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
answerable: bool
|
|
66
|
+
breaches: list[Breach] = field(default_factory=list)
|
|
67
|
+
checked: list[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
def __bool__(self) -> bool:
|
|
70
|
+
return self.answerable
|
|
71
|
+
|
|
72
|
+
def explain(self) -> str:
|
|
73
|
+
if self.answerable:
|
|
74
|
+
passed = "; ".join(self.checked) or "no checks configured"
|
|
75
|
+
return f"inside the validated envelope ({passed})"
|
|
76
|
+
lines = ["outside the validated envelope:"]
|
|
77
|
+
lines += [f" - {b}" for b in self.breaches]
|
|
78
|
+
lines.append("")
|
|
79
|
+
lines.append(
|
|
80
|
+
"This is a refusal, not a failure. The twin has not been shown to "
|
|
81
|
+
"answer this question, and returning a number anyway would hide that."
|
|
82
|
+
)
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict[str, Any]:
|
|
86
|
+
return {
|
|
87
|
+
"answerable": self.answerable,
|
|
88
|
+
"breaches": [
|
|
89
|
+
{
|
|
90
|
+
"kind": b.kind,
|
|
91
|
+
"detail": b.detail,
|
|
92
|
+
"observed": b.observed,
|
|
93
|
+
"limit": b.limit,
|
|
94
|
+
}
|
|
95
|
+
for b in self.breaches
|
|
96
|
+
],
|
|
97
|
+
"checked": list(self.checked),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Envelope:
|
|
103
|
+
"""The range over which a twin has been shown to work.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
state_bounds: Per-state ``(low, high)`` the model was identified over.
|
|
107
|
+
``None`` for a state leaves it unconstrained.
|
|
108
|
+
max_horizon: Longest forecast horizon that was actually validated, in
|
|
109
|
+
steps. ``None`` means no horizon was validated -- which is a
|
|
110
|
+
refusal for every horizon, not a licence for all of them.
|
|
111
|
+
requires_validated: Refuse if the manifest does not record a
|
|
112
|
+
leakage-free validation. Default True.
|
|
113
|
+
requires_calibrated: Refuse if interval coverage was never measured.
|
|
114
|
+
Only applies to requests that ask for an interval.
|
|
115
|
+
max_extrapolation: How far outside ``state_bounds`` to tolerate, as a
|
|
116
|
+
fraction of each range. 0.0 means none at all.
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
>>> env = Envelope(state_bounds=[(0.0, 1.0)], max_horizon=500)
|
|
120
|
+
>>> v = env.check(state=[0.4], horizon=100)
|
|
121
|
+
>>> bool(v)
|
|
122
|
+
True
|
|
123
|
+
>>> v = env.check(state=[0.4], horizon=900)
|
|
124
|
+
>>> bool(v)
|
|
125
|
+
False
|
|
126
|
+
>>> print(v.breaches[0])
|
|
127
|
+
horizon: beyond the validated forecast horizon (asked for 900, validated to 500)
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
state_bounds: list[tuple[float, float] | None] | None = None
|
|
131
|
+
max_horizon: int | None = None
|
|
132
|
+
requires_validated: bool = True
|
|
133
|
+
requires_calibrated: bool = True
|
|
134
|
+
max_extrapolation: float = 0.0
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_manifest(cls, manifest: Any, **overrides: Any) -> "Envelope":
|
|
139
|
+
"""Build an envelope from what a fitted twin actually recorded.
|
|
140
|
+
|
|
141
|
+
Reads ``validation`` and ``calibration`` off a
|
|
142
|
+
:class:`~otwin.interfaces.TwinManifest`. Fields the manifest does not
|
|
143
|
+
carry stay ``None``, which is a refusal rather than a default.
|
|
144
|
+
"""
|
|
145
|
+
validation = getattr(manifest, "validation", None) or {}
|
|
146
|
+
kwargs: dict[str, Any] = {
|
|
147
|
+
"max_horizon": validation.get("horizon"),
|
|
148
|
+
"state_bounds": validation.get("state_bounds"),
|
|
149
|
+
}
|
|
150
|
+
kwargs.update(overrides)
|
|
151
|
+
return cls(**kwargs)
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
def check(
|
|
155
|
+
self,
|
|
156
|
+
state: npt.ArrayLike | None = None,
|
|
157
|
+
horizon: int | None = None,
|
|
158
|
+
manifest: Any = None,
|
|
159
|
+
wants_interval: bool = False,
|
|
160
|
+
) -> Verdict:
|
|
161
|
+
"""Decide whether this question is inside the envelope.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
state: The operating point the forecast starts from.
|
|
165
|
+
horizon: Steps ahead being requested.
|
|
166
|
+
manifest: The fitted twin's record, for the validation and
|
|
167
|
+
calibration checks.
|
|
168
|
+
wants_interval: Whether the caller is asking for an uncertainty
|
|
169
|
+
band. Calibration is only required if they are.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
A :class:`Verdict`. Truthy when the question can be answered.
|
|
173
|
+
"""
|
|
174
|
+
breaches: list[Breach] = []
|
|
175
|
+
checked: list[str] = []
|
|
176
|
+
|
|
177
|
+
if horizon is not None:
|
|
178
|
+
if self.max_horizon is None:
|
|
179
|
+
breaches.append(
|
|
180
|
+
Breach(
|
|
181
|
+
"horizon",
|
|
182
|
+
"no forecast horizon has been validated for this twin",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
elif horizon > self.max_horizon:
|
|
186
|
+
breaches.append(
|
|
187
|
+
Breach(
|
|
188
|
+
"horizon",
|
|
189
|
+
"beyond the validated forecast horizon",
|
|
190
|
+
float(horizon),
|
|
191
|
+
float(self.max_horizon),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
checked.append(f"horizon {horizon} <= {self.max_horizon}")
|
|
196
|
+
|
|
197
|
+
if state is not None and self.state_bounds is None:
|
|
198
|
+
# Symmetric with the horizon check above. An operating range that
|
|
199
|
+
# was never recorded is a refusal, not a licence.
|
|
200
|
+
#
|
|
201
|
+
# The previous version skipped this branch entirely when
|
|
202
|
+
# `state_bounds` was None, so a twin with no recorded range
|
|
203
|
+
# returned a clean verdict for any operating point at all -- a
|
|
204
|
+
# state of charge of 1e12 came back answerable with zero breaches.
|
|
205
|
+
# `from_manifest` already states the intended rule: fields the
|
|
206
|
+
# manifest does not carry stay None, "which is a refusal rather
|
|
207
|
+
# than a default". That held for `max_horizon` and not for this.
|
|
208
|
+
breaches.append(
|
|
209
|
+
Breach(
|
|
210
|
+
"state",
|
|
211
|
+
"no operating range has been recorded for this twin, so no "
|
|
212
|
+
"operating point can be shown to be inside it",
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
elif state is not None and self.state_bounds is not None:
|
|
216
|
+
x = np.atleast_1d(np.asarray(state, dtype=float))
|
|
217
|
+
if len(x) != len(self.state_bounds):
|
|
218
|
+
breaches.append(
|
|
219
|
+
Breach(
|
|
220
|
+
"state",
|
|
221
|
+
f"envelope describes {len(self.state_bounds)} states, "
|
|
222
|
+
f"got {len(x)}",
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
else:
|
|
226
|
+
for i, (value, bound) in enumerate(
|
|
227
|
+
zip(x, self.state_bounds, strict=True)
|
|
228
|
+
):
|
|
229
|
+
if bound is None:
|
|
230
|
+
continue
|
|
231
|
+
lo, hi = float(bound[0]), float(bound[1])
|
|
232
|
+
slack = self.max_extrapolation * (hi - lo)
|
|
233
|
+
if value < lo - slack:
|
|
234
|
+
breaches.append(
|
|
235
|
+
Breach(
|
|
236
|
+
"state",
|
|
237
|
+
f"state {i} below the identified range",
|
|
238
|
+
float(value),
|
|
239
|
+
lo - slack,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
elif value > hi + slack:
|
|
243
|
+
breaches.append(
|
|
244
|
+
Breach(
|
|
245
|
+
"state",
|
|
246
|
+
f"state {i} above the identified range",
|
|
247
|
+
float(value),
|
|
248
|
+
hi + slack,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
if not any(b.kind == "state" for b in breaches):
|
|
252
|
+
checked.append("operating point inside the identified range")
|
|
253
|
+
|
|
254
|
+
if manifest is not None:
|
|
255
|
+
if self.requires_validated:
|
|
256
|
+
if not getattr(manifest, "is_validated", False):
|
|
257
|
+
breaches.append(
|
|
258
|
+
Breach(
|
|
259
|
+
"validation",
|
|
260
|
+
"this twin has never been validated under a "
|
|
261
|
+
"leakage-free protocol",
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
checked.append("validated, leakage-free")
|
|
266
|
+
|
|
267
|
+
if wants_interval and self.requires_calibrated:
|
|
268
|
+
calibration = getattr(manifest, "calibration", None) or {}
|
|
269
|
+
if calibration.get("empirical_coverage") is None:
|
|
270
|
+
breaches.append(
|
|
271
|
+
Breach(
|
|
272
|
+
"calibration",
|
|
273
|
+
"interval coverage has never been measured, so the "
|
|
274
|
+
"band has no demonstrated meaning",
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
checked.append(
|
|
279
|
+
f"coverage measured at {calibration['empirical_coverage']:.2f}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return Verdict(answerable=not breaches, breaches=breaches, checked=checked)
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
def require(self, **kwargs: Any) -> Verdict:
|
|
286
|
+
"""Like :meth:`check`, but raises :class:`OutsideEnvelope` on refusal.
|
|
287
|
+
|
|
288
|
+
Use this at the boundary of anything automated. A dispatch decision
|
|
289
|
+
should stop, not proceed with a caveat nobody reads.
|
|
290
|
+
"""
|
|
291
|
+
verdict = self.check(**kwargs)
|
|
292
|
+
if not verdict:
|
|
293
|
+
raise OutsideEnvelope(verdict)
|
|
294
|
+
return verdict
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""State estimation — the ISO 13374 **State Detection (SD)** block.
|
|
2
|
+
|
|
3
|
+
ISO 13374 lays out six processing blocks for condition monitoring: Data
|
|
4
|
+
Acquisition, Data Manipulation, **State Detection**, Health Assessment,
|
|
5
|
+
Prognostic Assessment, and Advisory Generation. This package is the third. It
|
|
6
|
+
is the block that turns a *simulation* into a *twin*.
|
|
7
|
+
|
|
8
|
+
Without it, :mod:`otwin.model` gives you a physically correct trajectory that
|
|
9
|
+
drifts away from the real machine the moment the initial condition, a
|
|
10
|
+
parameter, or an unmodelled disturbance is slightly wrong — and nothing ever
|
|
11
|
+
pulls it back. State detection closes that loop: measurements come in, the
|
|
12
|
+
estimated state is corrected, and the twin tracks the asset instead of merely
|
|
13
|
+
resembling it. Everything downstream depends on it. Health assessment scores a
|
|
14
|
+
state; prognostics extrapolate one; advisory generation acts on one. If the
|
|
15
|
+
state is wrong, all three are confidently wrong.
|
|
16
|
+
|
|
17
|
+
What is here
|
|
18
|
+
------------
|
|
19
|
+
|
|
20
|
+
:class:`ExtendedKalmanFilter`
|
|
21
|
+
The workhorse. Continuous model, discrete measurements, RK4 propagation
|
|
22
|
+
with a matched transition Jacobian, Joseph-form covariance.
|
|
23
|
+
|
|
24
|
+
:class:`KalmanFilter`
|
|
25
|
+
The exact linear filter. Its job is to be the closed-form answer that the
|
|
26
|
+
EKF is verified against, and to be the right tool when the model really is
|
|
27
|
+
linear.
|
|
28
|
+
|
|
29
|
+
:class:`MovingHorizonEstimator`
|
|
30
|
+
When the state has hard physical limits — state of charge in ``[0, 1]``,
|
|
31
|
+
non-negative absolute temperature, a valve between shut and open — and an
|
|
32
|
+
estimate outside them would be acted on downstream.
|
|
33
|
+
|
|
34
|
+
:class:`EnergyConsistentObserver`
|
|
35
|
+
For port-Hamiltonian twins. A Kalman correction knows nothing about the
|
|
36
|
+
stored energy ``H(x)`` and will happily push it uphill with no input to
|
|
37
|
+
have supplied it, destroying the passivity guarantee that the model was
|
|
38
|
+
built to provide. This observer bounds every correction by the energy that
|
|
39
|
+
actually flowed through the ports. See :mod:`otwin.estimate.energy`.
|
|
40
|
+
|
|
41
|
+
A note on ``R_meas``
|
|
42
|
+
--------------------
|
|
43
|
+
|
|
44
|
+
Throughout this package the measurement-noise covariance is called ``R_meas``,
|
|
45
|
+
never ``R``. In :mod:`otwin.model` ``R`` is the dissipation matrix of a
|
|
46
|
+
port-Hamiltonian system. Both objects appear in the same call sites here, they
|
|
47
|
+
are both square, both symmetric, and both positive semidefinite, so a mix-up
|
|
48
|
+
type-checks, runs, and produces wrong estimates that look right. The name is
|
|
49
|
+
ugly on purpose.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
>>> import numpy as np
|
|
53
|
+
>>> from otwin.estimate import ExtendedKalmanFilter
|
|
54
|
+
>>> class Decay:
|
|
55
|
+
... def rhs(self, x, u, t):
|
|
56
|
+
... return -0.5 * x
|
|
57
|
+
... def observe(self, x, u, t):
|
|
58
|
+
... return x.copy()
|
|
59
|
+
>>> ekf = ExtendedKalmanFilter(Decay(), Q=np.array([[1e-6]]),
|
|
60
|
+
... R_meas=np.array([[1e-2]]),
|
|
61
|
+
... P0=np.array([[1.0]]), x0=np.array([0.0]))
|
|
62
|
+
>>> ts = np.linspace(0.0, 2.0, 21)
|
|
63
|
+
>>> res = ekf.filter(np.exp(-0.5 * ts).reshape(-1, 1), None, ts)
|
|
64
|
+
>>> res.x.shape
|
|
65
|
+
(21, 1)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from .energy import EnergyConsistentObserver, EnergyFilterResult
|
|
69
|
+
from .kalman import ExtendedKalmanFilter, FilterResult
|
|
70
|
+
from .linear import KalmanFilter
|
|
71
|
+
from .mhe import MovingHorizonEstimator
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"EnergyConsistentObserver",
|
|
75
|
+
"EnergyFilterResult",
|
|
76
|
+
"ExtendedKalmanFilter",
|
|
77
|
+
"FilterResult",
|
|
78
|
+
"KalmanFilter",
|
|
79
|
+
"MovingHorizonEstimator",
|
|
80
|
+
]
|