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.
@@ -0,0 +1,259 @@
1
+ """Bounded HTTP pull adapter for scalar OGC SensorThings observations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ import hashlib
7
+ import json
8
+ import math
9
+ from typing import Any, Callable
10
+ from urllib.error import HTTPError, URLError
11
+ from urllib.parse import urlsplit
12
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
13
+
14
+ from .observations import Observation, ObservationBatch
15
+ from .plugins import PLUGIN_API_VERSION
16
+
17
+
18
+ class SensorThingsSourceError(RuntimeError):
19
+ """Raised for SensorThings transport, pagination, or mapping failures."""
20
+
21
+
22
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
23
+
24
+
25
+ def _origin(url: str) -> tuple[str, str, int]:
26
+ parsed = urlsplit(url)
27
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
28
+ raise ValueError("SensorThings URLs must be absolute HTTP(S) URLs without embedded credentials or fragments")
29
+ try:
30
+ port = parsed.port
31
+ except ValueError as error:
32
+ raise ValueError("SensorThings URL contains an invalid port") from error
33
+ scheme = parsed.scheme.lower()
34
+ return scheme, parsed.hostname.lower(), _DEFAULT_PORTS[scheme] if port is None else port
35
+
36
+
37
+ class _SameOriginRedirectHandler(HTTPRedirectHandler):
38
+ """Follow HTTP redirects only within the configured origin.
39
+
40
+ urllib's default handler follows any redirect and copies request headers,
41
+ including ``Authorization``, to the new location. A redirect to another
42
+ host, port, or scheme (including an HTTPS to HTTP downgrade) would hand
43
+ the bearer token to that origin, so it is refused instead.
44
+ """
45
+
46
+ def __init__(self, origin: tuple[str, str, int]):
47
+ self._allowed_origin = origin
48
+
49
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
50
+ try:
51
+ target = _origin(newurl)
52
+ except ValueError:
53
+ target = None
54
+ if target != self._allowed_origin:
55
+ fp.close()
56
+ raise SensorThingsSourceError(f"refusing HTTP {code} redirect away from the configured SensorThings origin")
57
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
58
+
59
+
60
+ def _phenomenon_timestamp(value: Any) -> float:
61
+ if not isinstance(value, str) or not value.strip():
62
+ raise ValueError("phenomenonTime must be a timezone-aware ISO-8601 instant")
63
+ text = value.strip()
64
+ if "/" in text:
65
+ raise ValueError("interval phenomenonTime values are not supported; provide instant observations")
66
+ iso_text = text[:-1] + "+00:00" if text.endswith(("Z", "z")) else text
67
+ parsed = datetime.fromisoformat(iso_text)
68
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
69
+ raise ValueError("phenomenonTime must include a timezone")
70
+ return parsed.timestamp()
71
+
72
+
73
+ class SensorThingsObservationSource:
74
+ """Read one configured SensorThings Datastream Observations collection.
75
+
76
+ Supply the collection URL, a stable sensor identifier, and a unit because
77
+ this adapter does not perform Datastream metadata discovery. It follows
78
+ server-provided ``@iot.nextLink`` URLs as opaque pagination cursors and
79
+ emits one bounded batch per response page. Only scalar numeric ``result``
80
+ values and instant ``phenomenonTime`` values are mapped. Quality defaults
81
+ to false unless the caller explicitly opts into another default or maps a
82
+ boolean property from SensorThings ``parameters``.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ observations_url: str,
88
+ *,
89
+ sensor_id: str,
90
+ unit: str,
91
+ name: str | None = None,
92
+ source_id: str | None = None,
93
+ bearer_token: str | None = None,
94
+ quality_parameter: str | None = None,
95
+ default_quality: bool = False,
96
+ timeout_s: float = 15.0,
97
+ max_response_bytes: int = 4_194_304,
98
+ max_observations_per_page: int = 4096,
99
+ fetcher: Callable[[Request, float, int], bytes] | None = None,
100
+ ):
101
+ url = str(observations_url).strip()
102
+ if not url:
103
+ raise ValueError("observations_url must be non-empty")
104
+ origin = _origin(url)
105
+ sensor_value, unit_value = str(sensor_id).strip(), str(unit).strip()
106
+ if not sensor_value or not unit_value:
107
+ raise ValueError("sensor_id and unit must be non-empty")
108
+ timeout = float(timeout_s)
109
+ byte_limit, observation_limit = int(max_response_bytes), int(max_observations_per_page)
110
+ if not math.isfinite(timeout) or timeout <= 0:
111
+ raise ValueError("timeout_s must be finite and positive")
112
+ if isinstance(max_response_bytes, bool) or byte_limit != max_response_bytes or not 1 <= byte_limit <= 67_108_864:
113
+ raise ValueError("max_response_bytes must be an integer between 1 and 67108864")
114
+ if isinstance(max_observations_per_page, bool) or observation_limit != max_observations_per_page or not 1 <= observation_limit <= 65_536:
115
+ raise ValueError("max_observations_per_page must be an integer between 1 and 65536")
116
+ token = None if bearer_token is None else str(bearer_token).strip()
117
+ if token == "":
118
+ raise ValueError("bearer_token must be non-empty or None")
119
+ if type(default_quality) is not bool:
120
+ raise ValueError("default_quality must be a bool")
121
+ quality_key = None if quality_parameter is None else str(quality_parameter).strip()
122
+ if quality_key == "":
123
+ raise ValueError("quality_parameter must be non-empty or None")
124
+ self.observations_url = url
125
+ self.sensor_id = sensor_value
126
+ self.name = sensor_value if name is None else str(name).strip()
127
+ if not self.name:
128
+ raise ValueError("name must be non-empty")
129
+ self.unit = unit_value
130
+ self.source_id = str(source_id).strip() if source_id is not None else f"sensorthings:{sensor_value}"
131
+ if not self.source_id:
132
+ raise ValueError("source_id must be non-empty")
133
+ self.bearer_token = token
134
+ self.quality_parameter = quality_key
135
+ self.default_quality = default_quality
136
+ self.timeout_s = timeout
137
+ self.max_response_bytes = byte_limit
138
+ self.max_observations_per_page = observation_limit
139
+ self._origin = origin
140
+ self._fetcher = fetcher
141
+ self._next_url: str | None = None
142
+ self._opened = False
143
+
144
+ def open(self) -> None:
145
+ if self._opened:
146
+ raise RuntimeError("SensorThings observation source is already open")
147
+ self._next_url = self.observations_url
148
+ self._opened = True
149
+
150
+ def read_batch(self) -> ObservationBatch | None:
151
+ if not self._opened:
152
+ raise RuntimeError("open() must be called before read_batch()")
153
+ if self._next_url is None:
154
+ return None
155
+ page_url, self._next_url = self._next_url, None
156
+ try:
157
+ payload = self._fetch_page(page_url)
158
+ document = json.loads(payload.decode("utf-8"))
159
+ if not isinstance(document, dict) or not isinstance(document.get("value"), list):
160
+ raise ValueError("response must be a JSON object with a 'value' array")
161
+ items = document["value"]
162
+ if len(items) > self.max_observations_per_page:
163
+ raise ValueError(f"response has more than {self.max_observations_per_page} observations")
164
+ next_link = document.get("@iot.nextLink")
165
+ if next_link is not None:
166
+ if not isinstance(next_link, str) or not next_link.strip():
167
+ raise ValueError("@iot.nextLink must be a non-empty URL string")
168
+ next_link = next_link.strip()
169
+ if _origin(next_link) != self._origin:
170
+ raise ValueError("@iot.nextLink must remain on the configured service origin")
171
+ observations = [self._observation(item, index) for index, item in enumerate(items)]
172
+ self._next_url = next_link
173
+ batch_material = [item.get("@iot.id") for item in items]
174
+ if any(item_id is None for item_id in batch_material):
175
+ batch_material = items
176
+ digest = hashlib.sha256(
177
+ json.dumps(batch_material, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode("utf-8")
178
+ ).hexdigest()
179
+ return ObservationBatch(
180
+ observations,
181
+ source_id=self.source_id,
182
+ batch_id=f"{self.source_id}:page:{digest}",
183
+ )
184
+ except SensorThingsSourceError:
185
+ raise
186
+ except (ValueError, TypeError, KeyError, OverflowError, UnicodeDecodeError, json.JSONDecodeError) as error:
187
+ raise SensorThingsSourceError(f"invalid SensorThings observation page: {error}") from error
188
+
189
+ def _fetch_page(self, url: str) -> bytes:
190
+ headers = {"Accept": "application/json"}
191
+ if self.bearer_token is not None:
192
+ headers["Authorization"] = f"Bearer {self.bearer_token}"
193
+ request = Request(url, headers=headers, method="GET")
194
+ try:
195
+ if self._fetcher is not None:
196
+ payload = self._fetcher(request, self.timeout_s, self.max_response_bytes)
197
+ else:
198
+ opener = build_opener(_SameOriginRedirectHandler(self._origin))
199
+ with opener.open(request, timeout=self.timeout_s) as response:
200
+ status = getattr(response, "status", 200)
201
+ if not 200 <= int(status) < 300:
202
+ raise SensorThingsSourceError(f"SensorThings server returned HTTP {status}")
203
+ payload = response.read(self.max_response_bytes + 1)
204
+ except SensorThingsSourceError:
205
+ raise
206
+ except (HTTPError, URLError, TimeoutError, OSError) as error:
207
+ raise SensorThingsSourceError(f"SensorThings request failed: {error}") from error
208
+ if not isinstance(payload, bytes):
209
+ raise SensorThingsSourceError("SensorThings fetcher must return response bytes")
210
+ if len(payload) > self.max_response_bytes:
211
+ raise SensorThingsSourceError(f"SensorThings response exceeded {self.max_response_bytes} bytes")
212
+ return payload
213
+
214
+ def _observation(self, item: Any, index: int) -> Observation:
215
+ if not isinstance(item, dict):
216
+ raise ValueError(f"value[{index}] must be an Observation JSON object")
217
+ result = item.get("result")
218
+ if isinstance(result, bool) or not isinstance(result, (int, float)):
219
+ raise ValueError(f"value[{index}].result must be a scalar number")
220
+ timestamp = _phenomenon_timestamp(item.get("phenomenonTime"))
221
+ observation_id = item.get("@iot.id")
222
+ metadata = {} if observation_id is None else {"sensorthings_observation_id": observation_id}
223
+ quality = self.default_quality
224
+ if self.quality_parameter is not None:
225
+ parameters = item.get("parameters")
226
+ if parameters is not None and not isinstance(parameters, dict):
227
+ raise ValueError(f"value[{index}].parameters must be an object")
228
+ if isinstance(parameters, dict) and self.quality_parameter in parameters:
229
+ parameter_value = parameters[self.quality_parameter]
230
+ if type(parameter_value) is not bool:
231
+ raise ValueError(f"value[{index}].parameters[{self.quality_parameter!r}] must be boolean")
232
+ quality = parameter_value
233
+ return Observation(
234
+ sensor_id=self.sensor_id,
235
+ name=self.name,
236
+ unit=self.unit,
237
+ value=float(result),
238
+ timestamp=timestamp,
239
+ quality=quality,
240
+ source_id=self.source_id,
241
+ metadata=metadata,
242
+ )
243
+
244
+ def close(self) -> None:
245
+ self._opened = False
246
+ self._next_url = None
247
+
248
+
249
+ class SensorThingsPlugin:
250
+ """Built-in source plugin for ``PluginRegistry``."""
251
+
252
+ name = "timoshenko-sensorthings"
253
+ api_version = PLUGIN_API_VERSION
254
+
255
+ def register(self, registry) -> None:
256
+ registry.register_source("sensorthings", SensorThingsObservationSource)
257
+
258
+
259
+ __all__ = ["SensorThingsObservationSource", "SensorThingsPlugin", "SensorThingsSourceError"]
timoshenko/session.py ADDED
@@ -0,0 +1,388 @@
1
+ """Bounded-memory event-time monitoring sessions for caller-supplied streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import dataclass
7
+ import heapq
8
+ import math
9
+ from typing import Any, Sequence
10
+
11
+ import numpy as np
12
+
13
+ from .health import HealthAssessment, assess, validate_review_threshold
14
+ from .modal import ModalResult, identify
15
+ from .modal import validate_options as validate_peak_picking_options
16
+ from .multichannel import MultiChannelData
17
+ from .observations import ObservationBatch
18
+ from .oma import FDDResult, identify_fdd
19
+ from .oma import validate_options as validate_fdd_options
20
+ from .sensors import SensorData
21
+ from .storage import SQLiteStore
22
+ from .structure import Structure
23
+ from .update import update
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SessionReport:
28
+ sequence: int
29
+ event_time_s: float
30
+ method: str
31
+ structure: Structure
32
+ modal: ModalResult | FDDResult
33
+ health: HealthAssessment
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "sequence": self.sequence,
38
+ "event_time_s": self.event_time_s,
39
+ "method": self.method,
40
+ "structure": {
41
+ "structure_id": self.structure.structure_id,
42
+ "natural_frequencies_hz": list(self.structure.natural_frequencies_hz),
43
+ "reference_frequencies_hz": list(self.structure.baseline_frequencies_hz),
44
+ "update_status": self.structure.update_status,
45
+ },
46
+ "modal": self.modal.to_dict(),
47
+ "health": self.health.to_dict(),
48
+ }
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class SessionIngestResult:
53
+ accepted_count: int
54
+ rejected_quality_count: int
55
+ unknown_sensor_count: int
56
+ missing_timestamp_count: int
57
+ invalid_time_count: int
58
+ out_of_order_count: int
59
+ unit_mismatch_count: int
60
+ duplicate_batch: bool
61
+ reports: tuple[SessionReport, ...]
62
+
63
+ @property
64
+ def status(self) -> str:
65
+ return "analyzed" if self.reports else "buffering"
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ return {
69
+ "status": self.status,
70
+ "accepted_count": self.accepted_count,
71
+ "rejected_quality_count": self.rejected_quality_count,
72
+ "unknown_sensor_count": self.unknown_sensor_count,
73
+ "missing_timestamp_count": self.missing_timestamp_count,
74
+ "invalid_time_count": self.invalid_time_count,
75
+ "out_of_order_count": self.out_of_order_count,
76
+ "unit_mismatch_count": self.unit_mismatch_count,
77
+ "duplicate_batch": self.duplicate_batch,
78
+ "reports": [report.to_dict() for report in self.reports],
79
+ }
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class SessionRestoreResult:
84
+ restored_samples: int
85
+ samples_per_sensor: int
86
+ ready_for_analysis: bool
87
+ last_event_time_s: float | None
88
+
89
+ def to_dict(self) -> dict[str, Any]:
90
+ return {
91
+ "restored_samples": self.restored_samples,
92
+ "samples_per_sensor": self.samples_per_sensor,
93
+ "ready_for_analysis": self.ready_for_analysis,
94
+ "last_event_time_s": self.last_event_time_s,
95
+ }
96
+
97
+
98
+ class MonitoringSession:
99
+ """Analyze regularly sampled point observations in bounded rolling windows.
100
+
101
+ The host supplies observations; this class does not connect to a broker.
102
+ Each sensor timestamp is mapped to the nearest sample grid point and must
103
+ fall within ``timestamp_tolerance_s``. Late/duplicate sensor points are
104
+ counted and excluded from the active buffer. If a sample is missing, the
105
+ session waits until a new contiguous full window is available; it never
106
+ interpolates or bridges a gap.
107
+
108
+ Each analysis updates the model supplied at construction, so the reported
109
+ ``update_scale_factor`` is always relative to that original model. With a
110
+ store, a batch is persisted only after it has been processed: a batch that
111
+ fails part-way can be redelivered and its remaining samples still reach
112
+ the session, while an already processed batch is skipped as a duplicate.
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ structure: Structure,
118
+ *,
119
+ sensor_ids: Sequence[str],
120
+ units: Sequence[str],
121
+ sampling_hz: float,
122
+ window_samples: int,
123
+ hop_samples: int | None = None,
124
+ timestamp_tolerance_s: float | None = None,
125
+ analysis_options: dict[str, Any] | None = None,
126
+ store: SQLiteStore | None = None,
127
+ review_threshold_pct: float | None = None,
128
+ ):
129
+ if not isinstance(structure, Structure):
130
+ raise TypeError("structure must be a timoshenko.Structure")
131
+ ids = tuple(str(value).strip() for value in sensor_ids)
132
+ unit_values = tuple(str(value).strip() for value in units)
133
+ hz = float(sampling_hz)
134
+ window = int(window_samples)
135
+ hop = max(1, window // 4) if hop_samples is None else int(hop_samples)
136
+ tolerance = 0.25 / hz if timestamp_tolerance_s is None and math.isfinite(hz) and hz > 0 else float(timestamp_tolerance_s or 0.0)
137
+ if not ids or len(ids) > 32 or any(not value for value in ids) or len(set(ids)) != len(ids):
138
+ raise ValueError("sensor_ids must contain 1–32 distinct, non-empty ids")
139
+ if len(unit_values) != len(ids) or any(not value for value in unit_values):
140
+ raise ValueError("units must contain one non-empty unit per sensor")
141
+ if len(ids) > 1 and len(set(unit_values)) != 1:
142
+ raise ValueError("FDD monitoring requires every channel to use the same measurement unit")
143
+ if not math.isfinite(hz) or hz <= 0.0:
144
+ raise ValueError("sampling_hz must be finite and positive")
145
+ if window < 16 or window > 65_536:
146
+ raise ValueError("window_samples must be between 16 and 65536")
147
+ if hop < 1 or hop > window:
148
+ raise ValueError("hop_samples must be between 1 and window_samples")
149
+ if not math.isfinite(tolerance) or tolerance < 0.0 or tolerance >= 0.5 / hz:
150
+ raise ValueError("timestamp_tolerance_s must be finite, non-negative, and less than half a sample interval")
151
+ if store is not None and not isinstance(store, SQLiteStore):
152
+ raise TypeError("store must be a SQLiteStore or None")
153
+
154
+ self._structure = structure
155
+ self._sensor_ids = ids
156
+ self._units = unit_values
157
+ self._sampling_hz = hz
158
+ self._window_samples = window
159
+ self._hop_samples = hop
160
+ self._timestamp_tolerance_s = tolerance
161
+ self._options = dict(analysis_options or {})
162
+ allowed = (
163
+ {"max_modes", "min_frequency_hz", "max_frequency_hz", "min_peak_ratio"}
164
+ if len(ids) == 1
165
+ else {
166
+ "nperseg", "overlap", "max_modes", "max_singular_values",
167
+ "min_frequency_hz", "max_frequency_hz", "min_peak_ratio",
168
+ "min_singular_value_ratio",
169
+ }
170
+ )
171
+ unexpected = set(self._options) - allowed
172
+ if unexpected:
173
+ raise ValueError(f"unsupported analysis option(s) for this session: {', '.join(sorted(unexpected))}")
174
+ # Check option values now; otherwise a bad value surfaces only when the
175
+ # first full window arrives, possibly hours into a live stream.
176
+ validate = validate_peak_picking_options if len(ids) == 1 else validate_fdd_options
177
+ validate(window, hz, **self._options)
178
+ self._review_threshold_pct = validate_review_threshold(review_threshold_pct)
179
+ self._baseline_structure = structure
180
+ self._store = store
181
+ self._buffers = {sensor_id: deque(maxlen=window) for sensor_id in ids}
182
+ self._last_key: dict[str, int] = {}
183
+ self._seen_by_key: dict[int, set[str]] = {}
184
+ self._seen_key_heap: list[int] = []
185
+ self._last_complete_key: int | None = None
186
+ self._complete_run = 0
187
+ self._last_analysis_key: int | None = None
188
+ self._sequence = 0
189
+
190
+ @property
191
+ def structure(self) -> Structure:
192
+ return self._structure
193
+
194
+ @property
195
+ def buffered_samples_by_sensor(self) -> dict[str, int]:
196
+ return {sensor_id: len(buffer) for sensor_id, buffer in self._buffers.items()}
197
+
198
+ def restore(self, *, extra_history_samples: int = 64) -> SessionRestoreResult:
199
+ """Restore the newest common contiguous window from the configured store.
200
+
201
+ The stored structure/model definition remains the caller's input; this
202
+ method restores observation buffers and stream cursors only. It never
203
+ re-appends restored observations or reruns an old analysis report.
204
+ """
205
+ if self._store is None:
206
+ raise RuntimeError("restore requires a SQLiteStore configured on this session")
207
+ extra = int(extra_history_samples)
208
+ if extra < 0 or extra > 4096:
209
+ raise ValueError("extra_history_samples must be between 0 and 4096")
210
+ candidate_limit = min(self._window_samples + extra, 262_144)
211
+ values_by_sensor: dict[str, dict[int, float]] = {}
212
+ last_keys: dict[str, int] = {}
213
+ for sensor_id, unit in zip(self._sensor_ids, self._units):
214
+ samples: dict[int, float] = {}
215
+ recent = self._store.recent_observations(
216
+ sensor_id=sensor_id,
217
+ unit=unit,
218
+ limit=candidate_limit,
219
+ )
220
+ for observation in recent:
221
+ if observation.timestamp is None:
222
+ continue
223
+ key = int(round(observation.timestamp * self._sampling_hz))
224
+ grid_time = key / self._sampling_hz
225
+ if abs(observation.timestamp - grid_time) > self._timestamp_tolerance_s:
226
+ continue
227
+ samples.setdefault(key, observation.value)
228
+ values_by_sensor[sensor_id] = samples
229
+ if samples:
230
+ last_keys[sensor_id] = max(samples)
231
+
232
+ common = set.intersection(*(set(samples) for samples in values_by_sensor.values()))
233
+ common_keys = sorted(common)
234
+ contiguous: list[int] = []
235
+ if common_keys:
236
+ contiguous = [common_keys[-1]]
237
+ for key in reversed(common_keys[:-1]):
238
+ if contiguous[-1] - key != 1:
239
+ break
240
+ contiguous.append(key)
241
+ contiguous.reverse()
242
+ restored_keys = contiguous[-self._window_samples :]
243
+ self._seen_by_key.clear()
244
+ for sensor_id, buffer in self._buffers.items():
245
+ buffer.clear()
246
+ values = values_by_sensor[sensor_id]
247
+ for key in restored_keys:
248
+ buffer.append((key, values[key]))
249
+ for key in restored_keys:
250
+ self._seen_by_key[key] = set(self._sensor_ids)
251
+ self._seen_key_heap = list(restored_keys)
252
+ self._last_complete_key = restored_keys[-1] if restored_keys else None
253
+ self._complete_run = len(restored_keys)
254
+ self._last_key = last_keys
255
+ self._last_analysis_key = restored_keys[-1] if len(restored_keys) >= self._window_samples else None
256
+ last_time = None if not restored_keys else restored_keys[-1] / self._sampling_hz
257
+ return SessionRestoreResult(
258
+ restored_samples=len(restored_keys) * len(self._sensor_ids),
259
+ samples_per_sensor=len(restored_keys),
260
+ ready_for_analysis=len(restored_keys) >= self._window_samples,
261
+ last_event_time_s=last_time,
262
+ )
263
+
264
+ def ingest(self, batch: ObservationBatch) -> SessionIngestResult:
265
+ if not isinstance(batch, ObservationBatch):
266
+ raise TypeError("batch must be an ObservationBatch")
267
+ if self._store is not None and self._store.has_batch(batch):
268
+ return SessionIngestResult(0, 0, 0, 0, 0, 0, 0, True, ())
269
+
270
+ accepted = rejected_quality = unknown = missing_time = invalid_time = out_of_order = unit_mismatch = 0
271
+ reports: list[SessionReport] = []
272
+ index_by_sensor = {sensor_id: idx for idx, sensor_id in enumerate(self._sensor_ids)}
273
+ for observation in batch.observations:
274
+ idx = index_by_sensor.get(observation.sensor_id)
275
+ if idx is None:
276
+ unknown += 1
277
+ continue
278
+ if not observation.quality:
279
+ rejected_quality += 1
280
+ continue
281
+ if observation.timestamp is None:
282
+ missing_time += 1
283
+ continue
284
+ if observation.unit != self._units[idx]:
285
+ unit_mismatch += 1
286
+ continue
287
+ key = int(round(observation.timestamp * self._sampling_hz))
288
+ grid_time = key / self._sampling_hz
289
+ if abs(observation.timestamp - grid_time) > self._timestamp_tolerance_s:
290
+ invalid_time += 1
291
+ continue
292
+ previous = self._last_key.get(observation.sensor_id)
293
+ if previous is not None and key <= previous:
294
+ out_of_order += 1
295
+ continue
296
+ self._last_key[observation.sensor_id] = key
297
+ self._buffers[observation.sensor_id].append((key, observation.value))
298
+ if key not in self._seen_by_key:
299
+ self._seen_by_key[key] = set()
300
+ heapq.heappush(self._seen_key_heap, key)
301
+ self._seen_by_key[key].add(observation.sensor_id)
302
+ accepted += 1
303
+ if len(self._seen_by_key[key]) == len(self._sensor_ids):
304
+ # Keys complete in increasing order because every sensor's keys
305
+ # increase, so a run counter tells cheaply whether a full
306
+ # contiguous window can exist before scanning the buffers.
307
+ contiguous = self._last_complete_key is not None and key == self._last_complete_key + 1
308
+ self._complete_run = self._complete_run + 1 if contiguous else 1
309
+ self._last_complete_key = key
310
+ if self._complete_run >= self._window_samples:
311
+ report = self._maybe_analyze(key)
312
+ if report is not None:
313
+ reports.append(report)
314
+ cutoff = key - 2 * self._window_samples
315
+ while self._seen_key_heap and self._seen_key_heap[0] < cutoff:
316
+ self._seen_by_key.pop(heapq.heappop(self._seen_key_heap), None)
317
+
318
+ if self._store is not None:
319
+ self._store.append_batch(batch)
320
+ return SessionIngestResult(accepted, rejected_quality, unknown, missing_time, invalid_time, out_of_order, unit_mismatch, False, tuple(reports))
321
+
322
+ def _latest_contiguous_window(self) -> tuple[list[int], np.ndarray] | None:
323
+ values_by_sensor = {
324
+ sensor_id: {key: value for key, value in buffer}
325
+ for sensor_id, buffer in self._buffers.items()
326
+ }
327
+ common = set.intersection(*(set(values) for values in values_by_sensor.values()))
328
+ if not common:
329
+ return None
330
+ keys = sorted(common)
331
+ run: list[int] = [keys[-1]]
332
+ for key in reversed(keys[:-1]):
333
+ if run[-1] - key != 1:
334
+ break
335
+ run.append(key)
336
+ run.reverse()
337
+ if len(run) < self._window_samples:
338
+ return None
339
+ selected = run[-self._window_samples :]
340
+ matrix = np.asarray(
341
+ [[values_by_sensor[sensor_id][key] for sensor_id in self._sensor_ids] for key in selected],
342
+ dtype=float,
343
+ )
344
+ return selected, matrix
345
+
346
+ def _maybe_analyze(self, completed_key: int) -> SessionReport | None:
347
+ if self._last_analysis_key is not None and completed_key - self._last_analysis_key < self._hop_samples:
348
+ return None
349
+ snapshot = self._latest_contiguous_window()
350
+ if snapshot is None:
351
+ return None
352
+ keys, matrix = snapshot
353
+ if len(self._sensor_ids) == 1:
354
+ observations: SensorData | MultiChannelData = SensorData(
355
+ samples=matrix[:, 0],
356
+ sampling_hz=self._sampling_hz,
357
+ unit=self._units[0],
358
+ channel=self._sensor_ids[0],
359
+ )
360
+ modal = identify(observations, **self._options)
361
+ method = "peak_picking"
362
+ else:
363
+ observations = MultiChannelData(
364
+ samples=matrix,
365
+ sampling_hz=self._sampling_hz,
366
+ channel_ids=self._sensor_ids,
367
+ units=self._units,
368
+ )
369
+ modal = identify_fdd(observations, **self._options)
370
+ method = "fdd"
371
+ if modal.modes:
372
+ self._structure = update(self._baseline_structure, modal)
373
+ health = assess(
374
+ structure=self._structure,
375
+ observations=observations,
376
+ modal_result=modal,
377
+ review_threshold_pct=self._review_threshold_pct,
378
+ )
379
+ self._sequence += 1
380
+ self._last_analysis_key = keys[-1]
381
+ return SessionReport(
382
+ sequence=self._sequence,
383
+ event_time_s=keys[-1] / self._sampling_hz,
384
+ method=method,
385
+ structure=self._structure,
386
+ modal=modal,
387
+ health=health,
388
+ )
timoshenko/shafts.py ADDED
@@ -0,0 +1,36 @@
1
+ """Elastic Saint-Venant torsion formulas for circular prismatic shafts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import math
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class CircularTorsionResult:
11
+ polar_moment_m4: float
12
+ maximum_shear_stress_pa: float
13
+ twist_rad: float
14
+
15
+
16
+ def circular_shaft_torsion(torque_nm: float, length_m: float, shear_modulus_pa: float,
17
+ outer_diameter_m: float, inner_diameter_m: float = 0.0) -> CircularTorsionResult:
18
+ """Return elastic outer-fiber shear stress and twist for a round shaft.
19
+
20
+ The shaft is prismatic and circular, with constant torque and linear
21
+ isotropic elastic behavior. An inner diameter of zero describes a solid
22
+ shaft. Non-circular sections and restrained warping are not covered.
23
+ """
24
+ torque, length = float(torque_nm), float(length_m)
25
+ shear = float(shear_modulus_pa)
26
+ outer, inner = float(outer_diameter_m), float(inner_diameter_m)
27
+ if not all(math.isfinite(v) for v in (torque, length, shear, outer, inner)):
28
+ raise ValueError("all inputs must be finite")
29
+ if length <= 0.0 or shear <= 0.0 or outer <= 0.0 or inner < 0.0 or inner >= outer:
30
+ raise ValueError("length, shear modulus, and outer diameter must be positive; inner diameter must be in [0, outer)")
31
+ polar = math.pi * (outer**4 - inner**4) / 32.0
32
+ return CircularTorsionResult(
33
+ polar_moment_m4=polar,
34
+ maximum_shear_stress_pa=torque * (outer / 2.0) / polar,
35
+ twist_rad=torque * length / (shear * polar),
36
+ )