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
|
@@ -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
|
+
)
|