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/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
+ }