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/stability.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Ideal elastic column stability formulas (not member design checks)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def euler_critical_load(youngs_modulus_pa: float, second_moment_m4: float,
|
|
9
|
+
length_m: float, effective_length_factor: float = 1.0) -> float:
|
|
10
|
+
"""Euler elastic critical load ``pi^2 E I / (K L)^2`` in newtons.
|
|
11
|
+
|
|
12
|
+
The caller selects ``K`` for the actual restraint and buckling axis.
|
|
13
|
+
``K=1`` is the ideal pin-pin case. The equation assumes a straight,
|
|
14
|
+
prismatic, slender, perfectly elastic column with ideal axial loading.
|
|
15
|
+
"""
|
|
16
|
+
e, inertia, length, factor = map(float, (youngs_modulus_pa, second_moment_m4, length_m, effective_length_factor))
|
|
17
|
+
if not all(math.isfinite(v) and v > 0.0 for v in (e, inertia, length, factor)):
|
|
18
|
+
raise ValueError("E, I, length, and effective length factor must be finite and positive")
|
|
19
|
+
effective_length = factor * length
|
|
20
|
+
return math.pi**2 * e * inertia / effective_length**2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def slenderness_ratio(length_m: float, effective_length_factor: float,
|
|
24
|
+
area_m2: float, second_moment_m4: float) -> float:
|
|
25
|
+
"""Geometric slenderness ``K L / r_g`` for one selected buckling axis."""
|
|
26
|
+
length, factor, area, inertia = map(float, (length_m, effective_length_factor, area_m2, second_moment_m4))
|
|
27
|
+
if not all(math.isfinite(v) and v > 0.0 for v in (length, factor, area, inertia)):
|
|
28
|
+
raise ValueError("length, factor, area, and second moment must be finite and positive")
|
|
29
|
+
radius_gyration = math.sqrt(inertia / area)
|
|
30
|
+
return factor * length / radius_gyration
|
timoshenko/storage.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Small local SQLite asset, observation, and analysis history store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import json
|
|
8
|
+
import hashlib
|
|
9
|
+
import math
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import sqlite3
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from .assets import Asset, Relation
|
|
16
|
+
from .observations import Observation, ObservationBatch
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
# Only for annotations: importing the project runner at module level would
|
|
20
|
+
# make this low-level store depend on the whole analysis stack.
|
|
21
|
+
from .project import ProjectRunResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_SCHEMA_VERSION = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _utc_now() -> str:
|
|
28
|
+
return datetime.now(timezone.utc).isoformat(timespec="microseconds")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _json(value: Any) -> str:
|
|
32
|
+
try:
|
|
33
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False, separators=(",", ":"))
|
|
34
|
+
except (TypeError, ValueError) as error:
|
|
35
|
+
raise ValueError(f"value is not JSON-serializable: {error}") from None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _batch_digest(batch: ObservationBatch) -> str:
|
|
39
|
+
if not isinstance(batch, ObservationBatch):
|
|
40
|
+
raise TypeError("batch must be an ObservationBatch")
|
|
41
|
+
if not batch.batch_id.strip() or not batch.source_id.strip():
|
|
42
|
+
raise ValueError("batch_id and source_id are required for idempotent persistence")
|
|
43
|
+
serialized = _json([item.to_dict() for item in batch.observations])
|
|
44
|
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class BatchAppendResult:
|
|
49
|
+
batch_id: str
|
|
50
|
+
source_id: str
|
|
51
|
+
inserted_count: int
|
|
52
|
+
duplicate_count: int
|
|
53
|
+
duplicate_batch: bool
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def was_duplicate(self) -> bool:
|
|
57
|
+
return self.duplicate_batch
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SQLiteStore:
|
|
61
|
+
"""A one-process local history store backed by Python's SQLite module.
|
|
62
|
+
|
|
63
|
+
Batch ingestion is idempotent for the unique ``(source_id, batch_id)``
|
|
64
|
+
pair. Observations retain arrival order and timestamps as supplied. Reads
|
|
65
|
+
can choose arrival or event-time order; this class does not interpolate,
|
|
66
|
+
delete late values, or infer missing values.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, path: str | Path, *, timeout_seconds: float = 5.0):
|
|
70
|
+
self.path = str(path)
|
|
71
|
+
if self.path != ":memory:":
|
|
72
|
+
db_path = Path(self.path).expanduser().resolve()
|
|
73
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
self.path = str(db_path)
|
|
75
|
+
timeout = float(timeout_seconds)
|
|
76
|
+
if not math.isfinite(timeout) or timeout <= 0.0:
|
|
77
|
+
raise ValueError("timeout_seconds must be finite and positive")
|
|
78
|
+
self._connection = sqlite3.connect(self.path, timeout=timeout)
|
|
79
|
+
self._connection.row_factory = sqlite3.Row
|
|
80
|
+
self._db.execute("PRAGMA foreign_keys = ON")
|
|
81
|
+
self._initialize()
|
|
82
|
+
|
|
83
|
+
def _initialize(self) -> None:
|
|
84
|
+
version = int(self._db.execute("PRAGMA user_version").fetchone()[0])
|
|
85
|
+
if version > _SCHEMA_VERSION:
|
|
86
|
+
self.close()
|
|
87
|
+
raise RuntimeError(f"database schema {version} is newer than supported schema {_SCHEMA_VERSION}")
|
|
88
|
+
if version == _SCHEMA_VERSION:
|
|
89
|
+
return
|
|
90
|
+
with self._db:
|
|
91
|
+
self._db.executescript(
|
|
92
|
+
"""
|
|
93
|
+
CREATE TABLE IF NOT EXISTS assets (
|
|
94
|
+
asset_id TEXT PRIMARY KEY,
|
|
95
|
+
asset_type TEXT NOT NULL,
|
|
96
|
+
name TEXT NOT NULL,
|
|
97
|
+
metadata_json TEXT NOT NULL,
|
|
98
|
+
updated_at TEXT NOT NULL
|
|
99
|
+
);
|
|
100
|
+
CREATE TABLE IF NOT EXISTS relations (
|
|
101
|
+
source_asset_id TEXT NOT NULL REFERENCES assets(asset_id) ON DELETE CASCADE,
|
|
102
|
+
relation_type TEXT NOT NULL,
|
|
103
|
+
target_asset_id TEXT NOT NULL REFERENCES assets(asset_id) ON DELETE CASCADE,
|
|
104
|
+
metadata_json TEXT NOT NULL,
|
|
105
|
+
PRIMARY KEY (source_asset_id, relation_type, target_asset_id)
|
|
106
|
+
);
|
|
107
|
+
CREATE TABLE IF NOT EXISTS observation_batches (
|
|
108
|
+
source_id TEXT NOT NULL,
|
|
109
|
+
batch_id TEXT NOT NULL,
|
|
110
|
+
item_count INTEGER NOT NULL,
|
|
111
|
+
batch_digest TEXT NOT NULL,
|
|
112
|
+
received_at TEXT NOT NULL,
|
|
113
|
+
PRIMARY KEY (source_id, batch_id)
|
|
114
|
+
);
|
|
115
|
+
CREATE TABLE IF NOT EXISTS observations (
|
|
116
|
+
observation_row_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
117
|
+
source_id TEXT NOT NULL,
|
|
118
|
+
batch_id TEXT NOT NULL,
|
|
119
|
+
record_index INTEGER NOT NULL,
|
|
120
|
+
sensor_id TEXT NOT NULL,
|
|
121
|
+
name TEXT NOT NULL,
|
|
122
|
+
unit TEXT NOT NULL,
|
|
123
|
+
value REAL NOT NULL,
|
|
124
|
+
event_timestamp REAL,
|
|
125
|
+
quality INTEGER NOT NULL,
|
|
126
|
+
asset_id TEXT,
|
|
127
|
+
observation_source_id TEXT,
|
|
128
|
+
metadata_json TEXT NOT NULL,
|
|
129
|
+
ingested_at TEXT NOT NULL,
|
|
130
|
+
UNIQUE (source_id, batch_id, record_index)
|
|
131
|
+
);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS observations_sensor_event_idx
|
|
133
|
+
ON observations (sensor_id, event_timestamp, observation_row_id);
|
|
134
|
+
CREATE INDEX IF NOT EXISTS observations_asset_event_idx
|
|
135
|
+
ON observations (asset_id, event_timestamp, observation_row_id);
|
|
136
|
+
CREATE TABLE IF NOT EXISTS analysis_runs (
|
|
137
|
+
run_id TEXT PRIMARY KEY,
|
|
138
|
+
project_id TEXT NOT NULL,
|
|
139
|
+
method TEXT NOT NULL,
|
|
140
|
+
source_sha256 TEXT NOT NULL,
|
|
141
|
+
manifest_sha256 TEXT NOT NULL,
|
|
142
|
+
created_at TEXT NOT NULL,
|
|
143
|
+
result_json TEXT NOT NULL
|
|
144
|
+
);
|
|
145
|
+
PRAGMA user_version = 1;
|
|
146
|
+
"""
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def _db(self) -> sqlite3.Connection:
|
|
151
|
+
connection = getattr(self, "_connection", None)
|
|
152
|
+
if connection is None:
|
|
153
|
+
raise RuntimeError("SQLiteStore is closed")
|
|
154
|
+
return connection
|
|
155
|
+
|
|
156
|
+
def close(self) -> None:
|
|
157
|
+
if getattr(self, "_connection", None) is not None:
|
|
158
|
+
self._connection.close()
|
|
159
|
+
self._connection = None
|
|
160
|
+
|
|
161
|
+
def __enter__(self) -> "SQLiteStore":
|
|
162
|
+
if self._connection is None:
|
|
163
|
+
raise RuntimeError("store is closed")
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
167
|
+
self.close()
|
|
168
|
+
|
|
169
|
+
def upsert_asset(self, asset: Asset) -> None:
|
|
170
|
+
if not isinstance(asset, Asset):
|
|
171
|
+
raise TypeError("asset must be an Asset")
|
|
172
|
+
with self._db:
|
|
173
|
+
self._db.execute(
|
|
174
|
+
"""INSERT INTO assets(asset_id, asset_type, name, metadata_json, updated_at)
|
|
175
|
+
VALUES (?, ?, ?, ?, ?)
|
|
176
|
+
ON CONFLICT(asset_id) DO UPDATE SET asset_type=excluded.asset_type,
|
|
177
|
+
name=excluded.name, metadata_json=excluded.metadata_json, updated_at=excluded.updated_at""",
|
|
178
|
+
(asset.asset_id, asset.asset_type, asset.name, _json(asset.metadata), _utc_now()),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def get_asset(self, asset_id: str) -> Asset | None:
|
|
182
|
+
row = self._db.execute("SELECT * FROM assets WHERE asset_id=?", (str(asset_id),)).fetchone()
|
|
183
|
+
if row is None:
|
|
184
|
+
return None
|
|
185
|
+
return Asset(row["asset_id"], row["asset_type"], row["name"], json.loads(row["metadata_json"]))
|
|
186
|
+
|
|
187
|
+
def list_assets(self, *, asset_type: str | None = None) -> tuple[Asset, ...]:
|
|
188
|
+
if asset_type is None:
|
|
189
|
+
rows = self._db.execute("SELECT * FROM assets ORDER BY asset_id").fetchall()
|
|
190
|
+
else:
|
|
191
|
+
rows = self._db.execute("SELECT * FROM assets WHERE asset_type=? ORDER BY asset_id", (asset_type,)).fetchall()
|
|
192
|
+
return tuple(Asset(row["asset_id"], row["asset_type"], row["name"], json.loads(row["metadata_json"])) for row in rows)
|
|
193
|
+
|
|
194
|
+
def add_relation(self, relation: Relation) -> None:
|
|
195
|
+
if not isinstance(relation, Relation):
|
|
196
|
+
raise TypeError("relation must be a Relation")
|
|
197
|
+
with self._db:
|
|
198
|
+
self._db.execute(
|
|
199
|
+
"""INSERT INTO relations(source_asset_id, relation_type, target_asset_id, metadata_json)
|
|
200
|
+
VALUES (?, ?, ?, ?)
|
|
201
|
+
ON CONFLICT(source_asset_id, relation_type, target_asset_id)
|
|
202
|
+
DO UPDATE SET metadata_json=excluded.metadata_json""",
|
|
203
|
+
(relation.source_asset_id, relation.relation_type, relation.target_asset_id, _json(relation.metadata)),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def relations_for(self, asset_id: str) -> tuple[Relation, ...]:
|
|
207
|
+
rows = self._db.execute(
|
|
208
|
+
"SELECT * FROM relations WHERE source_asset_id=? OR target_asset_id=? ORDER BY relation_type, source_asset_id, target_asset_id",
|
|
209
|
+
(str(asset_id), str(asset_id)),
|
|
210
|
+
).fetchall()
|
|
211
|
+
return tuple(Relation(row["source_asset_id"], row["relation_type"], row["target_asset_id"], json.loads(row["metadata_json"])) for row in rows)
|
|
212
|
+
|
|
213
|
+
def has_batch(self, batch: ObservationBatch) -> bool:
|
|
214
|
+
"""Return whether this exact batch is already stored.
|
|
215
|
+
|
|
216
|
+
Raises ``ValueError`` when the ``(source_id, batch_id)`` pair is stored
|
|
217
|
+
with different content, exactly as :meth:`append_batch` would.
|
|
218
|
+
"""
|
|
219
|
+
batch_digest = _batch_digest(batch)
|
|
220
|
+
existing = self._db.execute(
|
|
221
|
+
"SELECT batch_digest FROM observation_batches WHERE source_id=? AND batch_id=?",
|
|
222
|
+
(batch.source_id, batch.batch_id),
|
|
223
|
+
).fetchone()
|
|
224
|
+
if existing is None:
|
|
225
|
+
return False
|
|
226
|
+
if existing["batch_digest"] != batch_digest:
|
|
227
|
+
raise ValueError("batch_id was already stored with different observation content")
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
def append_batch(self, batch: ObservationBatch) -> BatchAppendResult:
|
|
231
|
+
batch_digest = _batch_digest(batch)
|
|
232
|
+
inserted = 0
|
|
233
|
+
with self._db:
|
|
234
|
+
cursor = self._db.execute(
|
|
235
|
+
"INSERT OR IGNORE INTO observation_batches(source_id, batch_id, item_count, batch_digest, received_at) VALUES (?, ?, ?, ?, ?)",
|
|
236
|
+
(batch.source_id, batch.batch_id, batch.count, batch_digest, _utc_now()),
|
|
237
|
+
)
|
|
238
|
+
is_new = cursor.rowcount == 1
|
|
239
|
+
if not is_new:
|
|
240
|
+
existing = self._db.execute(
|
|
241
|
+
"SELECT batch_digest FROM observation_batches WHERE source_id=? AND batch_id=?",
|
|
242
|
+
(batch.source_id, batch.batch_id),
|
|
243
|
+
).fetchone()
|
|
244
|
+
if existing is None or existing["batch_digest"] != batch_digest:
|
|
245
|
+
raise ValueError("batch_id was already stored with different observation content")
|
|
246
|
+
if is_new:
|
|
247
|
+
for record_index, observation in enumerate(batch.observations):
|
|
248
|
+
self._db.execute(
|
|
249
|
+
"""INSERT INTO observations(
|
|
250
|
+
source_id, batch_id, record_index, sensor_id, name, unit, value,
|
|
251
|
+
event_timestamp, quality, asset_id, observation_source_id, metadata_json, ingested_at
|
|
252
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
253
|
+
(
|
|
254
|
+
batch.source_id,
|
|
255
|
+
batch.batch_id,
|
|
256
|
+
record_index,
|
|
257
|
+
observation.sensor_id,
|
|
258
|
+
observation.name,
|
|
259
|
+
observation.unit,
|
|
260
|
+
observation.value,
|
|
261
|
+
observation.timestamp,
|
|
262
|
+
int(observation.quality),
|
|
263
|
+
observation.asset_id,
|
|
264
|
+
observation.source_id or batch.source_id,
|
|
265
|
+
_json(observation.metadata),
|
|
266
|
+
_utc_now(),
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
inserted += 1
|
|
270
|
+
duplicate = 0 if is_new else batch.count
|
|
271
|
+
return BatchAppendResult(batch.batch_id, batch.source_id, inserted, duplicate, not is_new)
|
|
272
|
+
|
|
273
|
+
def observations(
|
|
274
|
+
self,
|
|
275
|
+
*,
|
|
276
|
+
sensor_id: str | None = None,
|
|
277
|
+
asset_id: str | None = None,
|
|
278
|
+
start_timestamp: float | None = None,
|
|
279
|
+
end_timestamp: float | None = None,
|
|
280
|
+
order_by: Literal["event_time", "arrival"] = "event_time",
|
|
281
|
+
limit: int | None = None,
|
|
282
|
+
) -> tuple[Observation, ...]:
|
|
283
|
+
if order_by not in {"event_time", "arrival"}:
|
|
284
|
+
raise ValueError("order_by must be 'event_time' or 'arrival'")
|
|
285
|
+
clauses: list[str] = []
|
|
286
|
+
parameters: list[Any] = []
|
|
287
|
+
if sensor_id is not None:
|
|
288
|
+
clauses.append("sensor_id=?")
|
|
289
|
+
parameters.append(str(sensor_id))
|
|
290
|
+
if asset_id is not None:
|
|
291
|
+
clauses.append("asset_id=?")
|
|
292
|
+
parameters.append(str(asset_id))
|
|
293
|
+
if start_timestamp is not None:
|
|
294
|
+
start = float(start_timestamp)
|
|
295
|
+
if not math.isfinite(start):
|
|
296
|
+
raise ValueError("start_timestamp must be finite")
|
|
297
|
+
clauses.append("event_timestamp>=?")
|
|
298
|
+
parameters.append(start)
|
|
299
|
+
if end_timestamp is not None:
|
|
300
|
+
end = float(end_timestamp)
|
|
301
|
+
if not math.isfinite(end):
|
|
302
|
+
raise ValueError("end_timestamp must be finite")
|
|
303
|
+
clauses.append("event_timestamp<=?")
|
|
304
|
+
parameters.append(end)
|
|
305
|
+
if start_timestamp is not None and end_timestamp is not None and start > end:
|
|
306
|
+
raise ValueError("start_timestamp must be less than or equal to end_timestamp")
|
|
307
|
+
ordering = "event_timestamp IS NULL, event_timestamp, observation_row_id" if order_by == "event_time" else "observation_row_id"
|
|
308
|
+
sql = "SELECT * FROM observations" + (" WHERE " + " AND ".join(clauses) if clauses else "") + " ORDER BY " + ordering
|
|
309
|
+
if limit is not None:
|
|
310
|
+
if int(limit) < 1:
|
|
311
|
+
raise ValueError("limit must be positive")
|
|
312
|
+
sql += " LIMIT ?"
|
|
313
|
+
parameters.append(int(limit))
|
|
314
|
+
rows = self._db.execute(sql, parameters).fetchall()
|
|
315
|
+
return tuple(
|
|
316
|
+
Observation(
|
|
317
|
+
sensor_id=row["sensor_id"],
|
|
318
|
+
name=row["name"],
|
|
319
|
+
unit=row["unit"],
|
|
320
|
+
value=row["value"],
|
|
321
|
+
timestamp=row["event_timestamp"],
|
|
322
|
+
quality=bool(row["quality"]),
|
|
323
|
+
asset_id=row["asset_id"],
|
|
324
|
+
source_id=row["observation_source_id"],
|
|
325
|
+
metadata=json.loads(row["metadata_json"]),
|
|
326
|
+
)
|
|
327
|
+
for row in rows
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def recent_observations(
|
|
331
|
+
self,
|
|
332
|
+
*,
|
|
333
|
+
sensor_id: str,
|
|
334
|
+
limit: int,
|
|
335
|
+
unit: str | None = None,
|
|
336
|
+
) -> tuple[Observation, ...]:
|
|
337
|
+
"""Return a bounded event-time tail for one sensor, oldest first.
|
|
338
|
+
|
|
339
|
+
Only timestamped, good-quality observations are eligible. This is a
|
|
340
|
+
history query, not a resampler; consumers must still check the sample
|
|
341
|
+
grid and continuity of the returned records.
|
|
342
|
+
"""
|
|
343
|
+
count = int(limit)
|
|
344
|
+
if count < 1 or count > 262_144:
|
|
345
|
+
raise ValueError("limit must be between 1 and 262144")
|
|
346
|
+
clauses = ["sensor_id=?", "quality=1", "event_timestamp IS NOT NULL"]
|
|
347
|
+
parameters: list[Any] = [str(sensor_id)]
|
|
348
|
+
if unit is not None:
|
|
349
|
+
clauses.append("unit=?")
|
|
350
|
+
parameters.append(str(unit))
|
|
351
|
+
parameters.append(count)
|
|
352
|
+
rows = self._db.execute(
|
|
353
|
+
"SELECT * FROM (SELECT * FROM observations WHERE "
|
|
354
|
+
+ " AND ".join(clauses)
|
|
355
|
+
+ " ORDER BY event_timestamp DESC, observation_row_id DESC LIMIT ?) "
|
|
356
|
+
+ "ORDER BY event_timestamp ASC, observation_row_id ASC",
|
|
357
|
+
parameters,
|
|
358
|
+
).fetchall()
|
|
359
|
+
return tuple(
|
|
360
|
+
Observation(
|
|
361
|
+
sensor_id=row["sensor_id"],
|
|
362
|
+
name=row["name"],
|
|
363
|
+
unit=row["unit"],
|
|
364
|
+
value=row["value"],
|
|
365
|
+
timestamp=row["event_timestamp"],
|
|
366
|
+
quality=True,
|
|
367
|
+
asset_id=row["asset_id"],
|
|
368
|
+
source_id=row["observation_source_id"],
|
|
369
|
+
metadata=json.loads(row["metadata_json"]),
|
|
370
|
+
)
|
|
371
|
+
for row in rows
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def save_run(self, result: ProjectRunResult, *, run_id: str | None = None) -> str:
|
|
375
|
+
from .project import ProjectRunResult
|
|
376
|
+
|
|
377
|
+
if not isinstance(result, ProjectRunResult):
|
|
378
|
+
raise TypeError("result must be a ProjectRunResult")
|
|
379
|
+
identifier = str(run_id or uuid.uuid4()).strip()
|
|
380
|
+
if not identifier:
|
|
381
|
+
raise ValueError("run_id must be non-empty")
|
|
382
|
+
with self._db:
|
|
383
|
+
self._db.execute(
|
|
384
|
+
"""INSERT INTO analysis_runs(
|
|
385
|
+
run_id, project_id, method, source_sha256, manifest_sha256, created_at, result_json
|
|
386
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
387
|
+
(
|
|
388
|
+
identifier,
|
|
389
|
+
result.project_id,
|
|
390
|
+
result.method,
|
|
391
|
+
result.source_sha256,
|
|
392
|
+
result.manifest_sha256,
|
|
393
|
+
_utc_now(),
|
|
394
|
+
_json(result.to_dict()),
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
return identifier
|
|
398
|
+
|
|
399
|
+
def get_run(self, run_id: str) -> dict[str, Any] | None:
|
|
400
|
+
row = self._db.execute("SELECT result_json FROM analysis_runs WHERE run_id=?", (str(run_id),)).fetchone()
|
|
401
|
+
return None if row is None else json.loads(row["result_json"])
|
|
402
|
+
|
|
403
|
+
def list_runs(self, *, project_id: str | None = None, limit: int = 100) -> tuple[dict[str, Any], ...]:
|
|
404
|
+
if int(limit) < 1:
|
|
405
|
+
raise ValueError("limit must be positive")
|
|
406
|
+
if project_id is None:
|
|
407
|
+
rows = self._db.execute("SELECT result_json FROM analysis_runs ORDER BY created_at DESC, run_id DESC LIMIT ?", (int(limit),)).fetchall()
|
|
408
|
+
else:
|
|
409
|
+
rows = self._db.execute(
|
|
410
|
+
"SELECT result_json FROM analysis_runs WHERE project_id=? ORDER BY created_at DESC, run_id DESC LIMIT ?",
|
|
411
|
+
(str(project_id), int(limit)),
|
|
412
|
+
).fetchall()
|
|
413
|
+
return tuple(json.loads(row["result_json"]) for row in rows)
|
timoshenko/strength.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Plane-stress transformations for homogeneous linear elasticity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class PlaneStressResult:
|
|
11
|
+
principal_max_pa: float
|
|
12
|
+
principal_min_pa: float
|
|
13
|
+
maximum_in_plane_shear_pa: float
|
|
14
|
+
von_mises_pa: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def plane_stress(sigma_x_pa: float, sigma_y_pa: float, tau_xy_pa: float) -> PlaneStressResult:
|
|
18
|
+
"""Return principal stresses, in-plane max shear, and plane-stress von Mises.
|
|
19
|
+
|
|
20
|
+
The third principal stress is assumed zero (plane stress). No material
|
|
21
|
+
yield criterion, failure probability, or allowable stress is applied.
|
|
22
|
+
"""
|
|
23
|
+
sx, sy, txy = map(float, (sigma_x_pa, sigma_y_pa, tau_xy_pa))
|
|
24
|
+
if not all(math.isfinite(v) for v in (sx, sy, txy)):
|
|
25
|
+
raise ValueError("stress components must be finite")
|
|
26
|
+
center = (sx + sy) / 2.0
|
|
27
|
+
radius = math.hypot((sx - sy) / 2.0, txy)
|
|
28
|
+
von_mises = math.sqrt(max(0.0, sx**2 - sx * sy + sy**2 + 3.0 * txy**2))
|
|
29
|
+
return PlaneStressResult(center + radius, center - radius, radius, von_mises)
|
timoshenko/structure.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Simple structural models used by Timoshenko's first release.
|
|
2
|
+
|
|
3
|
+
Version 0.1 implements a linear lumped-mass shear-building model. It is a
|
|
4
|
+
reference model for modal comparison, not a general purpose FEM solver.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
import math
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Structure:
|
|
18
|
+
"""A shear-building model with one lateral degree of freedom per floor.
|
|
19
|
+
|
|
20
|
+
``story_masses_kg[i]`` and ``story_stiffness_n_m[i]`` describe floor i,
|
|
21
|
+
ordered from the base upward. Supply a measured baseline frequency list
|
|
22
|
+
when an analytical model baseline is not available.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
story_masses_kg: tuple[float, ...] | Sequence[float]
|
|
26
|
+
story_stiffness_n_m: tuple[float, ...] | Sequence[float]
|
|
27
|
+
structure_id: str = "structure"
|
|
28
|
+
name: str = ""
|
|
29
|
+
reference_frequencies_hz: tuple[float, ...] = ()
|
|
30
|
+
observed_frequencies_hz: tuple[float, ...] = ()
|
|
31
|
+
update_scale_factor: float | None = None
|
|
32
|
+
update_mode_count: int = 0
|
|
33
|
+
update_mode_scale_spread_pct: float | None = None
|
|
34
|
+
update_status: str = "not_updated"
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
masses = tuple(float(value) for value in self.story_masses_kg)
|
|
38
|
+
stiffnesses = tuple(float(value) for value in self.story_stiffness_n_m)
|
|
39
|
+
if not masses or len(masses) != len(stiffnesses):
|
|
40
|
+
raise ValueError("story_masses_kg and story_stiffness_n_m must have the same non-zero length")
|
|
41
|
+
if any(not math.isfinite(value) or value <= 0.0 for value in masses):
|
|
42
|
+
raise ValueError("story masses must be finite positive values in kilograms")
|
|
43
|
+
if any(not math.isfinite(value) or value <= 0.0 for value in stiffnesses):
|
|
44
|
+
raise ValueError("story stiffnesses must be finite positive values in newtons per metre")
|
|
45
|
+
reference = tuple(float(value) for value in self.reference_frequencies_hz)
|
|
46
|
+
observed = tuple(float(value) for value in self.observed_frequencies_hz)
|
|
47
|
+
for label, values in (("reference", reference), ("observed", observed)):
|
|
48
|
+
if any(not math.isfinite(value) or value <= 0.0 for value in values):
|
|
49
|
+
raise ValueError(f"{label} modal frequencies must be finite positive values")
|
|
50
|
+
if reference and len(reference) > len(masses):
|
|
51
|
+
raise ValueError("reference_frequencies_hz cannot contain more entries than the number of stories")
|
|
52
|
+
if observed and len(observed) > len(masses):
|
|
53
|
+
raise ValueError("observed_frequencies_hz cannot contain more entries than the number of stories")
|
|
54
|
+
object.__setattr__(self, "story_masses_kg", masses)
|
|
55
|
+
object.__setattr__(self, "story_stiffness_n_m", stiffnesses)
|
|
56
|
+
object.__setattr__(self, "reference_frequencies_hz", reference)
|
|
57
|
+
object.__setattr__(self, "observed_frequencies_hz", observed)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def story_count(self) -> int:
|
|
61
|
+
return len(self.story_masses_kg)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def natural_frequencies_hz(self) -> tuple[float, ...]:
|
|
65
|
+
"""Return analytical frequencies for the undamped shear model."""
|
|
66
|
+
n = self.story_count
|
|
67
|
+
stiffness = np.zeros((n, n), dtype=float)
|
|
68
|
+
for floor, story_k in enumerate(self.story_stiffness_n_m):
|
|
69
|
+
stiffness[floor, floor] += story_k
|
|
70
|
+
if floor > 0:
|
|
71
|
+
stiffness[floor - 1, floor - 1] += story_k
|
|
72
|
+
stiffness[floor, floor - 1] -= story_k
|
|
73
|
+
stiffness[floor - 1, floor] -= story_k
|
|
74
|
+
inv_sqrt_mass = np.diag(1.0 / np.sqrt(np.asarray(self.story_masses_kg, dtype=float)))
|
|
75
|
+
mass_normalized = inv_sqrt_mass @ stiffness @ inv_sqrt_mass
|
|
76
|
+
eigenvalues = np.linalg.eigvalsh(mass_normalized)
|
|
77
|
+
frequencies = np.sqrt(np.maximum(eigenvalues, 0.0)) / (2.0 * math.pi)
|
|
78
|
+
return tuple(float(value) for value in frequencies if value > 0.0)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def baseline_frequencies_hz(self) -> tuple[float, ...]:
|
|
82
|
+
"""Explicit reference frequencies, or the model's analytical modes."""
|
|
83
|
+
return self.reference_frequencies_hz or self.natural_frequencies_hz
|
|
84
|
+
|
|
85
|
+
def with_update(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
stiffness_scale: float,
|
|
89
|
+
observed_frequencies_hz: Sequence[float],
|
|
90
|
+
mode_scale_spread_pct: float,
|
|
91
|
+
status: str,
|
|
92
|
+
) -> "Structure":
|
|
93
|
+
reference = self.reference_frequencies_hz or self.natural_frequencies_hz
|
|
94
|
+
return replace(
|
|
95
|
+
self,
|
|
96
|
+
story_stiffness_n_m=tuple(k * stiffness_scale for k in self.story_stiffness_n_m),
|
|
97
|
+
reference_frequencies_hz=reference,
|
|
98
|
+
observed_frequencies_hz=tuple(float(f) for f in observed_frequencies_hz),
|
|
99
|
+
update_scale_factor=float(stiffness_scale),
|
|
100
|
+
update_mode_count=len(observed_frequencies_hz),
|
|
101
|
+
update_mode_scale_spread_pct=float(mode_scale_spread_pct),
|
|
102
|
+
update_status=status,
|
|
103
|
+
)
|