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