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 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
+ ]
@@ -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"]
@@ -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
+ ]