timoshenko-engine 2.0.1__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.
- timoshenko/__init__.py +173 -0
- timoshenko/_validation.py +19 -0
- timoshenko/adapters.py +111 -0
- timoshenko/assets.py +57 -0
- timoshenko/beams.py +75 -0
- timoshenko/csv_source.py +187 -0
- timoshenko/health.py +149 -0
- timoshenko/mechanics.py +54 -0
- timoshenko/modal.py +243 -0
- timoshenko/monitor.py +48 -0
- timoshenko/mqtt.py +272 -0
- timoshenko/multichannel.py +105 -0
- timoshenko/observations.py +96 -0
- timoshenko/oma.py +254 -0
- timoshenko/plugins.py +142 -0
- timoshenko/polygon.py +254 -0
- timoshenko/pressure.py +33 -0
- timoshenko/project.py +251 -0
- timoshenko/report.py +154 -0
- timoshenko/sections.py +119 -0
- timoshenko/sensors.py +142 -0
- timoshenko/sensorthings.py +259 -0
- timoshenko/session.py +388 -0
- timoshenko/shafts.py +36 -0
- timoshenko/stability.py +30 -0
- timoshenko/storage.py +413 -0
- timoshenko/strength.py +29 -0
- timoshenko/structure.py +103 -0
- timoshenko/uncertainty.py +261 -0
- timoshenko/update.py +46 -0
- timoshenko/vibration.py +138 -0
- timoshenko_engine-2.0.1.dist-info/METADATA +237 -0
- timoshenko_engine-2.0.1.dist-info/RECORD +37 -0
- timoshenko_engine-2.0.1.dist-info/WHEEL +5 -0
- timoshenko_engine-2.0.1.dist-info/entry_points.txt +3 -0
- timoshenko_engine-2.0.1.dist-info/licenses/LICENSE +190 -0
- timoshenko_engine-2.0.1.dist-info/top_level.txt +1 -0
timoshenko/mqtt.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Optional MQTT source adapter using the Paho Python client API v2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from queue import Full, Queue
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from .observations import Observation, ObservationBatch
|
|
13
|
+
from .plugins import PLUGIN_API_VERSION
|
|
14
|
+
from .session import SessionIngestResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _MqttEnvelope:
|
|
18
|
+
__slots__ = ("batch", "message_id", "qos")
|
|
19
|
+
|
|
20
|
+
def __init__(self, batch: ObservationBatch, message_id: int, qos: int):
|
|
21
|
+
self.batch = batch
|
|
22
|
+
self.message_id = int(message_id)
|
|
23
|
+
self.qos = int(qos)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MQTTSourceError(RuntimeError):
|
|
27
|
+
"""Raised for connection, payload, or bounded-queue failures."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_STOP = object()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MqttObservationSource:
|
|
34
|
+
"""Subscribe to a topic carrying version-1 Timoshenko JSON batch payloads.
|
|
35
|
+
|
|
36
|
+
Paho is imported only when ``open()`` is called. A client factory can be
|
|
37
|
+
injected for tests or an application-managed Paho configuration.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
host: str,
|
|
44
|
+
topic: str,
|
|
45
|
+
port: int = 1883,
|
|
46
|
+
qos: int = 1,
|
|
47
|
+
client_id: str = "",
|
|
48
|
+
clean_session: bool = False,
|
|
49
|
+
username: str | None = None,
|
|
50
|
+
password: str | None = None,
|
|
51
|
+
tls: bool = False,
|
|
52
|
+
keepalive_s: int = 60,
|
|
53
|
+
connect_timeout_s: float = 10.0,
|
|
54
|
+
queue_capacity: int = 1024,
|
|
55
|
+
max_payload_bytes: int = 1_048_576,
|
|
56
|
+
allow_retained: bool = False,
|
|
57
|
+
client_factory: Callable[[], Any] | None = None,
|
|
58
|
+
):
|
|
59
|
+
host_value, topic_value = str(host).strip(), str(topic).strip()
|
|
60
|
+
port_value, qos_value = int(port), int(qos)
|
|
61
|
+
keepalive, capacity, payload_limit = int(keepalive_s), int(queue_capacity), int(max_payload_bytes)
|
|
62
|
+
timeout = float(connect_timeout_s)
|
|
63
|
+
if not host_value or not topic_value or "+" in topic_value or "#" in topic_value:
|
|
64
|
+
raise ValueError("host and a concrete subscription topic are required; topic filters are not accepted")
|
|
65
|
+
if not 1 <= port_value <= 65535 or qos_value not in {0, 1, 2}:
|
|
66
|
+
raise ValueError("port must be in 1..65535 and qos must be 0, 1, or 2")
|
|
67
|
+
if keepalive < 1 or capacity < 1 or payload_limit < 1:
|
|
68
|
+
raise ValueError("keepalive_s, queue_capacity, and max_payload_bytes must be positive")
|
|
69
|
+
if not math.isfinite(timeout) or timeout <= 0.0:
|
|
70
|
+
raise ValueError("connect_timeout_s must be finite and positive")
|
|
71
|
+
if not bool(clean_session) and not str(client_id).strip():
|
|
72
|
+
raise ValueError("a stable client_id is required when clean_session=False")
|
|
73
|
+
if password is not None and username is None:
|
|
74
|
+
raise ValueError("username is required when password is supplied")
|
|
75
|
+
self.host = host_value
|
|
76
|
+
self.topic = topic_value
|
|
77
|
+
self.port = port_value
|
|
78
|
+
self.qos = qos_value
|
|
79
|
+
self.client_id = str(client_id)
|
|
80
|
+
self.clean_session = bool(clean_session)
|
|
81
|
+
self.username = username
|
|
82
|
+
self.password = password
|
|
83
|
+
self.tls = bool(tls)
|
|
84
|
+
self.keepalive_s = keepalive
|
|
85
|
+
self.connect_timeout_s = timeout
|
|
86
|
+
self.queue_capacity = capacity
|
|
87
|
+
self.max_payload_bytes = payload_limit
|
|
88
|
+
self.allow_retained = bool(allow_retained)
|
|
89
|
+
self._client_factory = client_factory
|
|
90
|
+
self._queue: Queue[_MqttEnvelope | object] = Queue(maxsize=capacity)
|
|
91
|
+
self._client: Any = None
|
|
92
|
+
self._connected = threading.Event()
|
|
93
|
+
self._state_lock = threading.Lock()
|
|
94
|
+
self._failure: BaseException | None = None
|
|
95
|
+
self._opened = False
|
|
96
|
+
self._network_started = False
|
|
97
|
+
self._retained_drop_count = 0
|
|
98
|
+
self._pending_acks: dict[str, list[_MqttEnvelope]] = {}
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def retained_drop_count(self) -> int:
|
|
102
|
+
return self._retained_drop_count
|
|
103
|
+
|
|
104
|
+
def open(self) -> None:
|
|
105
|
+
if self._opened:
|
|
106
|
+
raise RuntimeError("MQTT source is already open")
|
|
107
|
+
if self._client_factory is None:
|
|
108
|
+
try:
|
|
109
|
+
mqtt = import_module("paho.mqtt.client")
|
|
110
|
+
except ImportError as error:
|
|
111
|
+
raise ImportError("MQTT support requires the optional dependency; install timoshenko-engine[mqtt]") from error
|
|
112
|
+
client = mqtt.Client(
|
|
113
|
+
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
|
114
|
+
client_id=self.client_id,
|
|
115
|
+
clean_session=self.clean_session,
|
|
116
|
+
manual_ack=True,
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
client = self._client_factory()
|
|
120
|
+
self._client = client
|
|
121
|
+
self._failure = None
|
|
122
|
+
self._queue = Queue(maxsize=self.queue_capacity)
|
|
123
|
+
self._pending_acks.clear()
|
|
124
|
+
self._connected.clear()
|
|
125
|
+
self._retained_drop_count = 0
|
|
126
|
+
client.on_connect = self._on_connect
|
|
127
|
+
client.on_message = self._on_message
|
|
128
|
+
if self.username is not None:
|
|
129
|
+
client.username_pw_set(self.username, self.password)
|
|
130
|
+
if self.tls:
|
|
131
|
+
client.tls_set()
|
|
132
|
+
try:
|
|
133
|
+
result = client.connect(self.host, self.port, self.keepalive_s)
|
|
134
|
+
if result not in (None, 0):
|
|
135
|
+
raise MQTTSourceError(f"MQTT connect returned error code {result!r}")
|
|
136
|
+
client.loop_start()
|
|
137
|
+
self._network_started = True
|
|
138
|
+
if not self._connected.wait(self.connect_timeout_s):
|
|
139
|
+
raise TimeoutError(f"timed out waiting for MQTT subscription to {self.topic!r}")
|
|
140
|
+
self._raise_failure()
|
|
141
|
+
self._opened = True
|
|
142
|
+
except BaseException:
|
|
143
|
+
self.close()
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
def _on_connect(self, client, userdata, flags, reason_code, properties=None) -> None:
|
|
147
|
+
try:
|
|
148
|
+
if int(reason_code) != 0:
|
|
149
|
+
raise MQTTSourceError(f"MQTT broker rejected the connection: {reason_code}")
|
|
150
|
+
result, _message_id = client.subscribe(self.topic, qos=self.qos)
|
|
151
|
+
if result not in (None, 0):
|
|
152
|
+
raise MQTTSourceError(f"MQTT subscribe returned error code {result!r}")
|
|
153
|
+
except BaseException as error:
|
|
154
|
+
self._set_failure(error)
|
|
155
|
+
finally:
|
|
156
|
+
self._connected.set()
|
|
157
|
+
|
|
158
|
+
def _on_message(self, client, userdata, message) -> None:
|
|
159
|
+
with self._state_lock:
|
|
160
|
+
if self._failure is not None:
|
|
161
|
+
return
|
|
162
|
+
if bool(getattr(message, "retain", False)) and not self.allow_retained:
|
|
163
|
+
self._retained_drop_count += 1
|
|
164
|
+
if int(getattr(message, "qos", 0)) > 0:
|
|
165
|
+
result_code = client.ack(message.mid, message.qos)
|
|
166
|
+
if result_code not in (None, 0):
|
|
167
|
+
self._set_failure(MQTTSourceError(f"MQTT acknowledgement of retained message failed with code {result_code!r}"))
|
|
168
|
+
return
|
|
169
|
+
payload = message.payload
|
|
170
|
+
if len(payload) > self.max_payload_bytes:
|
|
171
|
+
self._set_failure(MQTTSourceError(f"MQTT payload exceeded {self.max_payload_bytes} bytes"))
|
|
172
|
+
return
|
|
173
|
+
try:
|
|
174
|
+
decoded = json.loads(payload.decode("utf-8"))
|
|
175
|
+
schema_version = decoded.get("schema_version") if isinstance(decoded, dict) else None
|
|
176
|
+
valid_schema_version = (type(schema_version) is int and schema_version == 1) or schema_version == "1"
|
|
177
|
+
if not isinstance(decoded, dict) or not valid_schema_version:
|
|
178
|
+
raise ValueError("payload must be a JSON object with schema_version=1")
|
|
179
|
+
source_id = decoded.get("source_id", "")
|
|
180
|
+
batch_id = decoded.get("batch_id", "")
|
|
181
|
+
observations = decoded.get("observations")
|
|
182
|
+
if not isinstance(source_id, str) or not source_id.strip() or not isinstance(batch_id, str) or not batch_id.strip() or not isinstance(observations, list) or not observations:
|
|
183
|
+
raise ValueError("source_id, batch_id, and a non-empty observations list are required")
|
|
184
|
+
if len(observations) > 4096:
|
|
185
|
+
raise ValueError("one MQTT batch may contain at most 4096 observations")
|
|
186
|
+
if any(not isinstance(item, dict) or item.get("timestamp") is None for item in observations):
|
|
187
|
+
raise ValueError("each MQTT observation must be an object with an event timestamp")
|
|
188
|
+
batch = ObservationBatch(
|
|
189
|
+
[Observation(**item) for item in observations],
|
|
190
|
+
source_id=source_id.strip(),
|
|
191
|
+
batch_id=batch_id.strip(),
|
|
192
|
+
)
|
|
193
|
+
self._queue.put_nowait(_MqttEnvelope(batch, message.mid, message.qos))
|
|
194
|
+
except Full:
|
|
195
|
+
self._set_failure(MQTTSourceError("MQTT receive queue is full; ingestion stopped to expose possible message loss"))
|
|
196
|
+
except Exception as error:
|
|
197
|
+
self._set_failure(MQTTSourceError(f"invalid MQTT observation payload: {error}"))
|
|
198
|
+
|
|
199
|
+
def _set_failure(self, error: BaseException) -> None:
|
|
200
|
+
with self._state_lock:
|
|
201
|
+
if self._failure is None:
|
|
202
|
+
self._failure = error
|
|
203
|
+
try:
|
|
204
|
+
self._queue.put_nowait(_STOP)
|
|
205
|
+
except Full:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
def _raise_failure(self) -> None:
|
|
209
|
+
with self._state_lock:
|
|
210
|
+
error = self._failure
|
|
211
|
+
if error is not None:
|
|
212
|
+
raise MQTTSourceError(str(error)) from error
|
|
213
|
+
|
|
214
|
+
def read_batch(self) -> ObservationBatch | None:
|
|
215
|
+
if not self._opened:
|
|
216
|
+
raise RuntimeError("MQTT source must be opened before reading")
|
|
217
|
+
self._raise_failure()
|
|
218
|
+
item = self._queue.get()
|
|
219
|
+
if item is _STOP:
|
|
220
|
+
self._raise_failure()
|
|
221
|
+
return None
|
|
222
|
+
assert isinstance(item, _MqttEnvelope)
|
|
223
|
+
with self._state_lock:
|
|
224
|
+
self._pending_acks.setdefault(item.batch.batch_id, []).append(item)
|
|
225
|
+
return item.batch
|
|
226
|
+
|
|
227
|
+
def acknowledge(self, batch: ObservationBatch, result: SessionIngestResult) -> None:
|
|
228
|
+
"""Acknowledge QoS 1/2 publications after session ingestion succeeds."""
|
|
229
|
+
with self._state_lock:
|
|
230
|
+
pending = list(self._pending_acks.get(batch.batch_id, ()))
|
|
231
|
+
if not pending:
|
|
232
|
+
raise MQTTSourceError(f"no pending MQTT delivery for batch {batch.batch_id!r}")
|
|
233
|
+
client = self._client
|
|
234
|
+
if client is None:
|
|
235
|
+
raise MQTTSourceError("cannot acknowledge MQTT delivery after source close")
|
|
236
|
+
for envelope in pending:
|
|
237
|
+
if envelope.qos > 0:
|
|
238
|
+
result_code = client.ack(envelope.message_id, envelope.qos)
|
|
239
|
+
if result_code not in (None, 0):
|
|
240
|
+
raise MQTTSourceError(f"MQTT acknowledgement failed with code {result_code!r}")
|
|
241
|
+
with self._state_lock:
|
|
242
|
+
self._pending_acks.pop(batch.batch_id, None)
|
|
243
|
+
|
|
244
|
+
def close(self) -> None:
|
|
245
|
+
client, self._client = self._client, None
|
|
246
|
+
self._opened = False
|
|
247
|
+
self._connected.set()
|
|
248
|
+
try:
|
|
249
|
+
self._queue.put_nowait(_STOP)
|
|
250
|
+
except Full:
|
|
251
|
+
pass
|
|
252
|
+
if client is None:
|
|
253
|
+
return
|
|
254
|
+
try:
|
|
255
|
+
client.disconnect()
|
|
256
|
+
finally:
|
|
257
|
+
if self._network_started:
|
|
258
|
+
self._network_started = False
|
|
259
|
+
client.loop_stop()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class MqttPlugin:
|
|
263
|
+
"""Built-in optional-dependency plugin for ``PluginRegistry``."""
|
|
264
|
+
|
|
265
|
+
name = "timoshenko-mqtt"
|
|
266
|
+
api_version = PLUGIN_API_VERSION
|
|
267
|
+
|
|
268
|
+
def register(self, registry) -> None:
|
|
269
|
+
registry.register_source("mqtt", MqttObservationSource)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
__all__ = ["MQTTSourceError", "MqttObservationSource", "MqttPlugin"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Regularly sampled, aligned observations from multiple sensor channels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import csv
|
|
7
|
+
import math
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class MultiChannelData:
|
|
16
|
+
"""Aligned array shaped ``(sample, channel)`` with explicit metadata.
|
|
17
|
+
|
|
18
|
+
Input values are copied once into a C-contiguous float64 array so later
|
|
19
|
+
caller mutations cannot silently change a completed analysis input.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
samples: Sequence[Sequence[float]] | np.ndarray
|
|
23
|
+
sampling_hz: float
|
|
24
|
+
channel_ids: Sequence[str]
|
|
25
|
+
units: Sequence[str]
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
values = np.asarray(self.samples, dtype=np.float64)
|
|
29
|
+
if values.ndim != 2:
|
|
30
|
+
raise ValueError("samples must be a 2D array shaped (sample, channel)")
|
|
31
|
+
channels = tuple(str(item).strip() for item in self.channel_ids)
|
|
32
|
+
units = tuple(str(item).strip() for item in self.units)
|
|
33
|
+
hz = float(self.sampling_hz)
|
|
34
|
+
if values.shape[0] < 8 or values.shape[1] < 2:
|
|
35
|
+
raise ValueError("at least 8 samples and 2 channels are required")
|
|
36
|
+
if values.shape[1] > 32:
|
|
37
|
+
raise ValueError("at most 32 channels are accepted per analysis to bound memory use")
|
|
38
|
+
if len(channels) != values.shape[1] or len(units) != values.shape[1]:
|
|
39
|
+
raise ValueError("channel_ids and units must have one entry per channel")
|
|
40
|
+
if any(not item for item in channels) or len(set(channels)) != len(channels):
|
|
41
|
+
raise ValueError("channel_ids must be non-empty and unique")
|
|
42
|
+
if any(not item for item in units):
|
|
43
|
+
raise ValueError("units must be non-empty")
|
|
44
|
+
if not math.isfinite(hz) or hz <= 0.0:
|
|
45
|
+
raise ValueError("sampling_hz must be finite and greater than zero")
|
|
46
|
+
if not np.isfinite(values).all():
|
|
47
|
+
raise ValueError("all sensor samples must be finite")
|
|
48
|
+
frozen = np.array(values, dtype=np.float64, order="C", copy=True)
|
|
49
|
+
frozen.setflags(write=False)
|
|
50
|
+
object.__setattr__(self, "samples", frozen)
|
|
51
|
+
object.__setattr__(self, "channel_ids", channels)
|
|
52
|
+
object.__setattr__(self, "units", units)
|
|
53
|
+
object.__setattr__(self, "sampling_hz", hz)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def sample_count(self) -> int:
|
|
57
|
+
return int(self.samples.shape[0])
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def channel_count(self) -> int:
|
|
61
|
+
return int(self.samples.shape[1])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_multichannel_csv(
|
|
65
|
+
source: str | Path,
|
|
66
|
+
*,
|
|
67
|
+
columns: Sequence[str],
|
|
68
|
+
sampling_hz: float,
|
|
69
|
+
units: Sequence[str] | None = None,
|
|
70
|
+
channel_ids: Sequence[str] | None = None,
|
|
71
|
+
) -> MultiChannelData:
|
|
72
|
+
"""Read aligned numeric channels from a headered CSV file.
|
|
73
|
+
|
|
74
|
+
Blank or invalid values are rejected instead of interpolated. Timestamps
|
|
75
|
+
and asynchronous/resampled data are not inferred in this release.
|
|
76
|
+
"""
|
|
77
|
+
names = tuple(str(name).strip() for name in columns)
|
|
78
|
+
if len(names) < 2 or any(not name for name in names) or len(set(names)) != len(names):
|
|
79
|
+
raise ValueError("columns must contain at least two distinct non-empty CSV headings")
|
|
80
|
+
unit_names = tuple(units) if units is not None else tuple("unknown" for _ in names)
|
|
81
|
+
if len(unit_names) != len(names):
|
|
82
|
+
raise ValueError("units must contain one entry per selected column")
|
|
83
|
+
ids = tuple(channel_ids) if channel_ids is not None else names
|
|
84
|
+
if len(ids) != len(names) or any(not str(item).strip() for item in ids) or len(set(ids)) != len(ids):
|
|
85
|
+
raise ValueError("channel_ids must contain one distinct, non-empty id per selected column")
|
|
86
|
+
data: list[list[float]] = []
|
|
87
|
+
with Path(source).open("r", encoding="utf-8-sig", newline="") as handle:
|
|
88
|
+
reader = csv.DictReader(handle)
|
|
89
|
+
headings = reader.fieldnames or []
|
|
90
|
+
missing = [name for name in names if name not in headings]
|
|
91
|
+
if missing:
|
|
92
|
+
raise ValueError(f"CSV columns not found: {missing}; available columns are {headings}")
|
|
93
|
+
for row_number, row in enumerate(reader, start=2):
|
|
94
|
+
record: list[float] = []
|
|
95
|
+
for name in names:
|
|
96
|
+
raw = row.get(name)
|
|
97
|
+
try:
|
|
98
|
+
value = float(raw)
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
raise ValueError(f"CSV row {row_number} has a missing/non-numeric value in {name!r}") from None
|
|
101
|
+
if not math.isfinite(value):
|
|
102
|
+
raise ValueError(f"CSV row {row_number} has a non-finite value in {name!r}")
|
|
103
|
+
record.append(value)
|
|
104
|
+
data.append(record)
|
|
105
|
+
return MultiChannelData(data, sampling_hz=sampling_hz, channel_ids=ids, units=unit_names)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Generic point-observation records shared by adapters and asset workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any, Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Observation:
|
|
12
|
+
"""One sensor value with event time, unit, quality, and provenance metadata.
|
|
13
|
+
|
|
14
|
+
``timestamp`` is Unix time in seconds. It may be ``None`` for a
|
|
15
|
+
non-temporal snapshot; streaming adapters should preserve device event
|
|
16
|
+
time separately from ingestion/arrival time in ``metadata``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
sensor_id: str
|
|
20
|
+
name: str
|
|
21
|
+
unit: str
|
|
22
|
+
value: float
|
|
23
|
+
timestamp: float | None = None
|
|
24
|
+
quality: bool = True
|
|
25
|
+
asset_id: str | None = None
|
|
26
|
+
source_id: str | None = None
|
|
27
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
sensor_id, name, unit = (str(item).strip() for item in (self.sensor_id, self.name, self.unit))
|
|
31
|
+
value = float(self.value)
|
|
32
|
+
timestamp = None if self.timestamp is None else float(self.timestamp)
|
|
33
|
+
if not sensor_id or not name or not unit:
|
|
34
|
+
raise ValueError("sensor_id, name, and unit must be non-empty")
|
|
35
|
+
if not math.isfinite(value):
|
|
36
|
+
raise ValueError("observation value must be finite")
|
|
37
|
+
if timestamp is not None and not math.isfinite(timestamp):
|
|
38
|
+
raise ValueError("timestamp must be finite or None")
|
|
39
|
+
if not isinstance(self.metadata, Mapping):
|
|
40
|
+
raise ValueError("metadata must be a mapping")
|
|
41
|
+
object.__setattr__(self, "sensor_id", sensor_id)
|
|
42
|
+
object.__setattr__(self, "name", name)
|
|
43
|
+
object.__setattr__(self, "unit", unit)
|
|
44
|
+
object.__setattr__(self, "value", value)
|
|
45
|
+
object.__setattr__(self, "timestamp", timestamp)
|
|
46
|
+
object.__setattr__(self, "quality", bool(self.quality))
|
|
47
|
+
object.__setattr__(self, "asset_id", None if self.asset_id is None else str(self.asset_id))
|
|
48
|
+
object.__setattr__(self, "source_id", None if self.source_id is None else str(self.source_id))
|
|
49
|
+
object.__setattr__(self, "metadata", dict(self.metadata))
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"sensor_id": self.sensor_id,
|
|
54
|
+
"name": self.name,
|
|
55
|
+
"unit": self.unit,
|
|
56
|
+
"value": self.value,
|
|
57
|
+
"timestamp": self.timestamp,
|
|
58
|
+
"quality": self.quality,
|
|
59
|
+
"asset_id": self.asset_id,
|
|
60
|
+
"source_id": self.source_id,
|
|
61
|
+
"metadata": dict(self.metadata),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ObservationBatch:
|
|
67
|
+
"""A batch in arrival order; consumers choose event-time sorting policy."""
|
|
68
|
+
|
|
69
|
+
observations: Sequence[Observation]
|
|
70
|
+
batch_id: str = ""
|
|
71
|
+
source_id: str = ""
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
items = tuple(self.observations)
|
|
75
|
+
if any(not isinstance(item, Observation) for item in items):
|
|
76
|
+
raise TypeError("observations must contain only Observation objects")
|
|
77
|
+
object.__setattr__(self, "observations", items)
|
|
78
|
+
object.__setattr__(self, "batch_id", str(self.batch_id).strip())
|
|
79
|
+
object.__setattr__(self, "source_id", str(self.source_id).strip())
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def count(self) -> int:
|
|
83
|
+
return len(self.observations)
|
|
84
|
+
|
|
85
|
+
def for_sensor(self, sensor_id: str) -> tuple[Observation, ...]:
|
|
86
|
+
"""Return matching records in original batch arrival order."""
|
|
87
|
+
target = str(sensor_id)
|
|
88
|
+
return tuple(item for item in self.observations if item.sensor_id == target)
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
return {
|
|
92
|
+
"batch_id": self.batch_id,
|
|
93
|
+
"source_id": self.source_id,
|
|
94
|
+
"count": self.count,
|
|
95
|
+
"observations": [item.to_dict() for item in self.observations],
|
|
96
|
+
}
|