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/project.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Explicit, reproducible JSON project manifests and one-shot execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .health import HealthAssessment, assess
|
|
13
|
+
from .modal import ModalResult, identify
|
|
14
|
+
from .multichannel import MultiChannelData, load_multichannel_csv
|
|
15
|
+
from .oma import FDDResult, identify_fdd
|
|
16
|
+
from .sensors import SensorData, load_sensors
|
|
17
|
+
from .structure import Structure
|
|
18
|
+
from .update import update
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ProjectManifest:
|
|
23
|
+
"""Validated manifest fields with file paths resolved from its directory."""
|
|
24
|
+
|
|
25
|
+
manifest_path: Path
|
|
26
|
+
schema_version: str
|
|
27
|
+
project_id: str
|
|
28
|
+
project_name: str
|
|
29
|
+
structure: Structure
|
|
30
|
+
observations_path: Path
|
|
31
|
+
sampling_hz: float
|
|
32
|
+
columns: tuple[str, ...]
|
|
33
|
+
channel_ids: tuple[str, ...]
|
|
34
|
+
units: tuple[str, ...]
|
|
35
|
+
method: str
|
|
36
|
+
options: dict[str, Any]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class LoadedProject:
|
|
41
|
+
manifest: ProjectManifest
|
|
42
|
+
observations: SensorData | MultiChannelData
|
|
43
|
+
source_sha256: str
|
|
44
|
+
manifest_sha256: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ProjectRunResult:
|
|
49
|
+
project_id: str
|
|
50
|
+
project_name: str
|
|
51
|
+
manifest_path: str
|
|
52
|
+
source_path: str
|
|
53
|
+
source_sha256: str
|
|
54
|
+
manifest_sha256: str
|
|
55
|
+
method: str
|
|
56
|
+
analysis_options: dict[str, Any]
|
|
57
|
+
structure: Structure
|
|
58
|
+
modal: ModalResult | FDDResult
|
|
59
|
+
health: HealthAssessment
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"project_id": self.project_id,
|
|
64
|
+
"project_name": self.project_name,
|
|
65
|
+
"manifest_path": self.manifest_path,
|
|
66
|
+
"source_path": self.source_path,
|
|
67
|
+
"source_sha256": self.source_sha256,
|
|
68
|
+
"manifest_sha256": self.manifest_sha256,
|
|
69
|
+
"method": self.method,
|
|
70
|
+
"analysis_options": dict(self.analysis_options),
|
|
71
|
+
"structure": {
|
|
72
|
+
"structure_id": self.structure.structure_id,
|
|
73
|
+
"story_count": self.structure.story_count,
|
|
74
|
+
"natural_frequencies_hz": list(self.structure.natural_frequencies_hz),
|
|
75
|
+
"reference_frequencies_hz": list(self.structure.baseline_frequencies_hz),
|
|
76
|
+
"observed_frequencies_hz": list(self.structure.observed_frequencies_hz),
|
|
77
|
+
"update_scale_factor": self.structure.update_scale_factor,
|
|
78
|
+
"update_status": self.structure.update_status,
|
|
79
|
+
},
|
|
80
|
+
"modal": self.modal.to_dict(),
|
|
81
|
+
"health": self.health.to_dict(),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_SINGLE_OPTIONS = {"max_modes", "min_frequency_hz", "max_frequency_hz", "min_peak_ratio"}
|
|
86
|
+
_FDD_OPTIONS = _SINGLE_OPTIONS | {"nperseg", "overlap", "max_singular_values", "min_singular_value_ratio"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_manifest(path: str | Path) -> ProjectManifest:
|
|
90
|
+
"""Read and validate a version-1 JSON manifest; relative files resolve beside it."""
|
|
91
|
+
manifest_path = Path(path).expanduser().resolve()
|
|
92
|
+
if manifest_path.suffix.lower() != ".json":
|
|
93
|
+
raise ValueError("project manifests must use the .json extension in version 0.6")
|
|
94
|
+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
95
|
+
if not isinstance(payload, dict):
|
|
96
|
+
raise ValueError("project manifest root must be a JSON object")
|
|
97
|
+
schema_version = str(payload.get("schema_version", ""))
|
|
98
|
+
if schema_version not in {"1", "1.0"}:
|
|
99
|
+
raise ValueError("unsupported schema_version; expected '1' or '1.0'")
|
|
100
|
+
project = payload.get("project")
|
|
101
|
+
if not isinstance(project, dict) or not str(project.get("id", "")).strip():
|
|
102
|
+
raise ValueError("project.id must be a non-empty string")
|
|
103
|
+
structure_payload = payload.get("structure")
|
|
104
|
+
if not isinstance(structure_payload, dict) or structure_payload.get("type") != "shear_building":
|
|
105
|
+
raise ValueError("version 0.6 supports structure.type='shear_building'")
|
|
106
|
+
structure = Structure(
|
|
107
|
+
structure_id=str(structure_payload.get("id") or project["id"]),
|
|
108
|
+
name=str(structure_payload.get("name") or project.get("name") or ""),
|
|
109
|
+
story_masses_kg=structure_payload.get("story_masses_kg", ()),
|
|
110
|
+
story_stiffness_n_m=structure_payload.get("story_stiffness_n_m", ()),
|
|
111
|
+
reference_frequencies_hz=structure_payload.get("reference_frequencies_hz", ()),
|
|
112
|
+
)
|
|
113
|
+
observation_payload = payload.get("observations")
|
|
114
|
+
if not isinstance(observation_payload, dict):
|
|
115
|
+
raise ValueError("observations must be a JSON object")
|
|
116
|
+
file_value = str(observation_payload.get("file", "")).strip()
|
|
117
|
+
if not file_value:
|
|
118
|
+
raise ValueError("observations.file must be a non-empty path")
|
|
119
|
+
source_path = (manifest_path.parent / file_value).resolve()
|
|
120
|
+
if source_path.suffix.lower() != ".csv":
|
|
121
|
+
raise ValueError("version 0.6 project manifests support CSV observations")
|
|
122
|
+
try:
|
|
123
|
+
sampling_hz = float(observation_payload["sampling_hz"])
|
|
124
|
+
except (KeyError, TypeError, ValueError):
|
|
125
|
+
raise ValueError("observations.sampling_hz must be a finite positive number") from None
|
|
126
|
+
if not math.isfinite(sampling_hz) or sampling_hz <= 0.0:
|
|
127
|
+
raise ValueError("observations.sampling_hz must be a finite positive number")
|
|
128
|
+
channels_payload = observation_payload.get("channels")
|
|
129
|
+
if not isinstance(channels_payload, list) or not channels_payload:
|
|
130
|
+
raise ValueError("observations.channels must be a non-empty list")
|
|
131
|
+
columns: list[str] = []
|
|
132
|
+
channel_ids: list[str] = []
|
|
133
|
+
units: list[str] = []
|
|
134
|
+
for idx, channel in enumerate(channels_payload, start=1):
|
|
135
|
+
if not isinstance(channel, dict):
|
|
136
|
+
raise ValueError(f"observations.channels[{idx - 1}] must be an object")
|
|
137
|
+
column = str(channel.get("column", "")).strip()
|
|
138
|
+
channel_id = str(channel.get("id") or column).strip()
|
|
139
|
+
unit = str(channel.get("unit", "unknown")).strip()
|
|
140
|
+
if not column or not channel_id or not unit:
|
|
141
|
+
raise ValueError(f"observations.channels[{idx - 1}] needs non-empty column, id, and unit")
|
|
142
|
+
columns.append(column)
|
|
143
|
+
channel_ids.append(channel_id)
|
|
144
|
+
units.append(unit)
|
|
145
|
+
if len(set(columns)) != len(columns) or len(set(channel_ids)) != len(channel_ids):
|
|
146
|
+
raise ValueError("channel columns and ids must each be unique")
|
|
147
|
+
|
|
148
|
+
analysis = payload.get("analysis", {})
|
|
149
|
+
if not isinstance(analysis, dict):
|
|
150
|
+
raise ValueError("analysis must be a JSON object")
|
|
151
|
+
default_method = "peak_picking" if len(columns) == 1 else "fdd"
|
|
152
|
+
method = str(analysis.get("method", default_method)).strip().lower()
|
|
153
|
+
allowed_options = _SINGLE_OPTIONS if method == "peak_picking" else _FDD_OPTIONS
|
|
154
|
+
if method not in {"peak_picking", "fdd"}:
|
|
155
|
+
raise ValueError("analysis.method must be 'peak_picking' or 'fdd'")
|
|
156
|
+
options = analysis.get("options", {})
|
|
157
|
+
if not isinstance(options, dict):
|
|
158
|
+
raise ValueError("analysis.options must be a JSON object")
|
|
159
|
+
unknown = sorted(set(options) - allowed_options)
|
|
160
|
+
if unknown:
|
|
161
|
+
raise ValueError(f"unsupported analysis options for {method}: {unknown}")
|
|
162
|
+
if method == "peak_picking" and len(columns) != 1:
|
|
163
|
+
raise ValueError("peak_picking requires exactly one channel; use fdd for multiple channels")
|
|
164
|
+
if method == "fdd" and len(columns) < 2:
|
|
165
|
+
raise ValueError("fdd requires at least two channels")
|
|
166
|
+
|
|
167
|
+
return ProjectManifest(
|
|
168
|
+
manifest_path=manifest_path,
|
|
169
|
+
schema_version=schema_version,
|
|
170
|
+
project_id=str(project["id"]).strip(),
|
|
171
|
+
project_name=str(project.get("name", "")).strip(),
|
|
172
|
+
structure=structure,
|
|
173
|
+
observations_path=source_path,
|
|
174
|
+
sampling_hz=sampling_hz,
|
|
175
|
+
columns=tuple(columns),
|
|
176
|
+
channel_ids=tuple(channel_ids),
|
|
177
|
+
units=tuple(units),
|
|
178
|
+
method=method,
|
|
179
|
+
options=dict(options),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _sha256(path: Path) -> str:
|
|
184
|
+
digest = hashlib.sha256()
|
|
185
|
+
with path.open("rb") as handle:
|
|
186
|
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
187
|
+
digest.update(block)
|
|
188
|
+
return digest.hexdigest()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def load_project(path: str | Path) -> LoadedProject:
|
|
192
|
+
"""Validate a manifest, load its CSV channels, and capture input provenance."""
|
|
193
|
+
manifest = read_manifest(path)
|
|
194
|
+
if not manifest.observations_path.is_file():
|
|
195
|
+
raise FileNotFoundError(f"observation CSV not found: {manifest.observations_path}")
|
|
196
|
+
if len(manifest.columns) == 1:
|
|
197
|
+
observations: SensorData | MultiChannelData = load_sensors(
|
|
198
|
+
manifest.observations_path,
|
|
199
|
+
sampling_hz=manifest.sampling_hz,
|
|
200
|
+
column=manifest.columns[0],
|
|
201
|
+
unit=manifest.units[0],
|
|
202
|
+
channel=manifest.channel_ids[0],
|
|
203
|
+
)
|
|
204
|
+
else:
|
|
205
|
+
observations = load_multichannel_csv(
|
|
206
|
+
manifest.observations_path,
|
|
207
|
+
columns=manifest.columns,
|
|
208
|
+
sampling_hz=manifest.sampling_hz,
|
|
209
|
+
units=manifest.units,
|
|
210
|
+
channel_ids=manifest.channel_ids,
|
|
211
|
+
)
|
|
212
|
+
return LoadedProject(
|
|
213
|
+
manifest=manifest,
|
|
214
|
+
observations=observations,
|
|
215
|
+
source_sha256=_sha256(manifest.observations_path),
|
|
216
|
+
manifest_sha256=_sha256(manifest.manifest_path),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def run_project(project: LoadedProject | str | Path) -> ProjectRunResult:
|
|
221
|
+
"""Execute one deterministic one-shot analysis described by a manifest."""
|
|
222
|
+
loaded = load_project(project) if isinstance(project, (str, Path)) else project
|
|
223
|
+
if not isinstance(loaded, LoadedProject):
|
|
224
|
+
raise TypeError("project must be a manifest path or LoadedProject from tm.load_project()")
|
|
225
|
+
manifest = loaded.manifest
|
|
226
|
+
if manifest.method == "peak_picking":
|
|
227
|
+
if not isinstance(loaded.observations, SensorData):
|
|
228
|
+
raise TypeError("peak_picking manifest must load one SensorData channel")
|
|
229
|
+
modal = identify(loaded.observations, **manifest.options)
|
|
230
|
+
else:
|
|
231
|
+
if not isinstance(loaded.observations, MultiChannelData):
|
|
232
|
+
raise TypeError("fdd manifest must load multiple channels")
|
|
233
|
+
modal = identify_fdd(loaded.observations, **manifest.options)
|
|
234
|
+
|
|
235
|
+
structure = manifest.structure
|
|
236
|
+
if modal.modes:
|
|
237
|
+
structure = update(structure, modal)
|
|
238
|
+
health = assess(structure=structure, observations=loaded.observations, modal_result=modal)
|
|
239
|
+
return ProjectRunResult(
|
|
240
|
+
project_id=manifest.project_id,
|
|
241
|
+
project_name=manifest.project_name,
|
|
242
|
+
manifest_path=str(manifest.manifest_path),
|
|
243
|
+
source_path=str(manifest.observations_path),
|
|
244
|
+
source_sha256=loaded.source_sha256,
|
|
245
|
+
manifest_sha256=loaded.manifest_sha256,
|
|
246
|
+
method=manifest.method,
|
|
247
|
+
analysis_options=dict(manifest.options),
|
|
248
|
+
structure=structure,
|
|
249
|
+
modal=modal,
|
|
250
|
+
health=health,
|
|
251
|
+
)
|
timoshenko/report.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Dependency-free human-readable reports for Timoshenko analysis results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
import math
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _result_parts(result: Any) -> tuple[Any, Any, Any, str, str]:
|
|
12
|
+
"""Extract the stable structure/modal/health view shared by result types."""
|
|
13
|
+
if not all(hasattr(result, name) for name in ("structure", "modal", "health")):
|
|
14
|
+
raise TypeError("result must be a Timoshenko monitoring, session, or project result")
|
|
15
|
+
structure, modal, health = result.structure, result.modal, result.health
|
|
16
|
+
if not hasattr(structure, "structure_id") or not hasattr(modal, "modes") or not hasattr(health, "mode_changes"):
|
|
17
|
+
raise TypeError("result does not expose the Timoshenko analysis result contract")
|
|
18
|
+
title = str(getattr(result, "project_name", "") or structure.name or structure.structure_id)
|
|
19
|
+
method = str(getattr(result, "method", "") or modal.method)
|
|
20
|
+
return structure, modal, health, title, method
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _number(value: float | None, digits: int = 3) -> str:
|
|
24
|
+
if value is None:
|
|
25
|
+
return "—"
|
|
26
|
+
number = float(value)
|
|
27
|
+
if not math.isfinite(number):
|
|
28
|
+
return "—"
|
|
29
|
+
return f"{number:.{digits}f}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _chart_svg(modal: Any, health: Any) -> str:
|
|
33
|
+
modes = list(modal.modes)
|
|
34
|
+
changes = {int(item.observed_mode_number): item for item in health.mode_changes}
|
|
35
|
+
width, height, margin = 780, max(230, 84 + 44 * len(modes)), 56
|
|
36
|
+
usable_width = width - 2 * margin
|
|
37
|
+
max_frequency = max(
|
|
38
|
+
[float(item.frequency_hz) for item in modes]
|
|
39
|
+
+ [float(item.reference_frequency_hz) for item in changes.values()]
|
|
40
|
+
+ [1.0]
|
|
41
|
+
)
|
|
42
|
+
scale = usable_width / max_frequency
|
|
43
|
+
pieces = [
|
|
44
|
+
f'<svg class="frequency-chart" viewBox="0 0 {width} {height}" role="img" aria-labelledby="chart-title chart-desc">',
|
|
45
|
+
'<title id="chart-title">Reference and observed modal frequencies</title>',
|
|
46
|
+
'<desc id="chart-desc">Horizontal lines compare model reference frequencies with the frequencies identified from observations.</desc>',
|
|
47
|
+
f'<line x1="{margin}" y1="26" x2="{width-margin}" y2="26" stroke="#94a3b8" stroke-width="1"/>',
|
|
48
|
+
f'<text x="{margin}" y="18" class="axis-label">0 Hz</text>',
|
|
49
|
+
f'<text x="{width-margin}" y="18" text-anchor="end" class="axis-label">{_number(max_frequency, 1)} Hz</text>',
|
|
50
|
+
]
|
|
51
|
+
for index, mode in enumerate(modes):
|
|
52
|
+
y = 62 + index * 44
|
|
53
|
+
change = changes.get(index + 1)
|
|
54
|
+
label = f"Mode {change.mode_number}" if change is not None else "Unpaired"
|
|
55
|
+
pieces.append(f'<text x="4" y="{y+4}" class="mode-label">{label}</text>')
|
|
56
|
+
if change is not None:
|
|
57
|
+
reference_x = margin + float(change.reference_frequency_hz) * scale
|
|
58
|
+
pieces.append(f'<line x1="{margin}" y1="{y-5}" x2="{reference_x:.2f}" y2="{y-5}" stroke="#94a3b8" stroke-width="5" stroke-linecap="round"/>')
|
|
59
|
+
pieces.append(f'<circle cx="{reference_x:.2f}" cy="{y-5}" r="5" fill="#475569"/>')
|
|
60
|
+
observed_x = margin + float(mode.frequency_hz) * scale
|
|
61
|
+
pieces.append(f'<line x1="{margin}" y1="{y+7}" x2="{observed_x:.2f}" y2="{y+7}" stroke="#2563eb" stroke-width="5" stroke-linecap="round"/>')
|
|
62
|
+
pieces.append(f'<circle cx="{observed_x:.2f}" cy="{y+7}" r="5" fill="#1d4ed8"/>')
|
|
63
|
+
pieces.append(f'<text x="{width-margin+8}" y="{y-2}" class="value-label">{_number(mode.frequency_hz)} Hz</text>')
|
|
64
|
+
pieces.extend([
|
|
65
|
+
f'<circle cx="{margin+8}" cy="{height-19}" r="5" fill="#475569"/><text x="{margin+19}" y="{height-15}" class="legend">Model reference</text>',
|
|
66
|
+
f'<circle cx="{margin+170}" cy="{height-19}" r="5" fill="#1d4ed8"/><text x="{margin+181}" y="{height-15}" class="legend">Observed</text>',
|
|
67
|
+
'</svg>',
|
|
68
|
+
])
|
|
69
|
+
return "".join(pieces)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def to_html(result: Any, *, title: str | None = None) -> str:
|
|
73
|
+
"""Render a self-contained accessible HTML report with a small SVG chart.
|
|
74
|
+
|
|
75
|
+
Supported inputs are ``MonitoringResult``, ``SessionReport``, and
|
|
76
|
+
``ProjectRunResult``. Project paths/hashes and session timestamps are
|
|
77
|
+
included when the input provides them.
|
|
78
|
+
"""
|
|
79
|
+
structure, modal, health, default_title, method = _result_parts(result)
|
|
80
|
+
display_title = escape(str(title or default_title))
|
|
81
|
+
modes = list(modal.modes)
|
|
82
|
+
changes = {int(item.observed_mode_number): item for item in health.mode_changes}
|
|
83
|
+
rows: list[str] = []
|
|
84
|
+
for index, mode in enumerate(modes, start=1):
|
|
85
|
+
change = changes.get(index)
|
|
86
|
+
reference = None if change is None else float(change.reference_frequency_hz)
|
|
87
|
+
shift = None if change is None else float(change.change_pct)
|
|
88
|
+
damping = getattr(mode, "damping_ratio", None)
|
|
89
|
+
rows.append(
|
|
90
|
+
"<tr>"
|
|
91
|
+
f"<th scope=\"row\">{change.mode_number if change is not None else 'unpaired'}</th>"
|
|
92
|
+
f"<td>{_number(reference)}</td>"
|
|
93
|
+
f"<td>{_number(float(mode.frequency_hz))}</td>"
|
|
94
|
+
f"<td>{_number(shift, 2)}{'%' if shift is not None else ''}</td>"
|
|
95
|
+
f"<td>{_number(damping, 4)}</td>"
|
|
96
|
+
"</tr>"
|
|
97
|
+
)
|
|
98
|
+
if not rows:
|
|
99
|
+
rows.append('<tr><td colspan="5">No usable modal frequencies were identified.</td></tr>')
|
|
100
|
+
|
|
101
|
+
metadata: list[str] = [
|
|
102
|
+
f"<dt>Structure</dt><dd>{escape(str(structure.structure_id))}</dd>",
|
|
103
|
+
f"<dt>Method</dt><dd>{escape(method)}</dd>",
|
|
104
|
+
f"<dt>Analysis status</dt><dd>{escape(str(modal.status))}</dd>",
|
|
105
|
+
f"<dt>Health evidence</dt><dd>{escape(str(health.status))}</dd>",
|
|
106
|
+
]
|
|
107
|
+
if hasattr(result, "event_time_s"):
|
|
108
|
+
metadata.append(f"<dt>Window end (Unix time)</dt><dd>{_number(result.event_time_s, 3)}</dd>")
|
|
109
|
+
if hasattr(result, "source_sha256"):
|
|
110
|
+
metadata.append(f"<dt>Input SHA-256</dt><dd><code>{escape(str(result.source_sha256))}</code></dd>")
|
|
111
|
+
metadata.append(f"<dt>Manifest SHA-256</dt><dd><code>{escape(str(result.manifest_sha256))}</code></dd>")
|
|
112
|
+
limitations = "".join(f"<li>{escape(str(item))}</li>" for item in health.limitations)
|
|
113
|
+
notes = "".join(f"<li>{escape(str(item))}</li>" for item in getattr(modal, "notes", ()))
|
|
114
|
+
summary = escape(str(health.evidence_summary))
|
|
115
|
+
threshold = getattr(health, "review_threshold_pct", None)
|
|
116
|
+
if health.review_recommended:
|
|
117
|
+
review = f"Review recommended (frequency drop of at least {_number(threshold, 2)}%)"
|
|
118
|
+
elif threshold is None:
|
|
119
|
+
review = "No review threshold supplied; no review flag evaluated"
|
|
120
|
+
else:
|
|
121
|
+
review = f"No paired mode dropped by {_number(threshold, 2)}% or more"
|
|
122
|
+
chart = _chart_svg(modal, health)
|
|
123
|
+
return f"""<!doctype html>
|
|
124
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
125
|
+
<title>{display_title} — Timoshenko report</title>
|
|
126
|
+
<style>
|
|
127
|
+
:root{{color-scheme:light;--ink:#172033;--muted:#536178;--line:#dbe2ea;--blue:#1d4ed8;--paper:#fff;--wash:#f4f7fb}}
|
|
128
|
+
*{{box-sizing:border-box}}body{{margin:0;background:var(--wash);color:var(--ink);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}}
|
|
129
|
+
main{{max-width:980px;margin:36px auto;padding:36px;background:var(--paper);border:1px solid var(--line);border-radius:14px}}
|
|
130
|
+
h1{{margin:0 0 8px;font-size:2rem}}h2{{margin:30px 0 10px;font-size:1.2rem}}.subtle,.axis-label,.legend{{color:var(--muted)}}
|
|
131
|
+
dl{{display:grid;grid-template-columns:minmax(150px,220px) 1fr;gap:8px 16px;margin:20px 0}}dt{{font-weight:650}}dd{{margin:0;overflow-wrap:anywhere}}
|
|
132
|
+
.summary{{padding:14px 16px;border-left:4px solid var(--blue);background:#eff6ff}}table{{width:100%;border-collapse:collapse}}th,td{{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}}thead{{background:var(--wash)}}
|
|
133
|
+
.frequency-chart{{width:100%;height:auto;overflow:visible}}.mode-label,.value-label{{font:12px system-ui,sans-serif;fill:var(--ink)}}.axis-label,.legend{{font:11px system-ui,sans-serif;fill:var(--muted)}}code{{font-size:.85em;overflow-wrap:anywhere}}
|
|
134
|
+
@media(max-width:640px){{main{{margin:0;padding:20px;border-radius:0}}dl{{grid-template-columns:1fr;gap:2px}}dd{{margin-bottom:10px}}}}
|
|
135
|
+
</style></head><body><main>
|
|
136
|
+
<p class="subtle">Timoshenko Engine · analysis report</p><h1>{display_title}</h1>
|
|
137
|
+
<p class="summary">{summary}<br><strong>{escape(review)}</strong></p>
|
|
138
|
+
<dl>{''.join(metadata)}</dl>
|
|
139
|
+
<h2>Modal frequency comparison</h2>{chart}
|
|
140
|
+
<table><thead><tr><th>Mode</th><th>Reference (Hz)</th><th>Observed (Hz)</th><th>Difference</th><th>Damping ratio</th></tr></thead><tbody>{''.join(rows)}</tbody></table>
|
|
141
|
+
<h2>Method notes</h2><ul>{notes or '<li>No additional method notes.</li>'}</ul>
|
|
142
|
+
<h2>Interpretation limits</h2><ul>{limitations or '<li>No limitation notes were supplied.</li>'}</ul>
|
|
143
|
+
</main></body></html>"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def save_html(result: Any, path: str | Path, *, title: str | None = None) -> Path:
|
|
147
|
+
"""Write a UTF-8 self-contained HTML report and return its resolved path."""
|
|
148
|
+
destination = Path(path).expanduser().resolve()
|
|
149
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
destination.write_text(to_html(result, title=title), encoding="utf-8")
|
|
151
|
+
return destination
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = ["save_html", "to_html"]
|
timoshenko/sections.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Cross-section properties for common, prismatic sections (SI units)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from ._validation import positive as _positive
|
|
9
|
+
# Re-exported so the general polygon helper is also reachable as tm.sections.polygon_section.
|
|
10
|
+
from .polygon import PolygonSectionProperties, polygon_section # noqa: F401
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class SectionProperties:
|
|
15
|
+
area_m2: float
|
|
16
|
+
second_moment_y_m4: float
|
|
17
|
+
second_moment_z_m4: float
|
|
18
|
+
section_modulus_y_m3: float
|
|
19
|
+
section_modulus_z_m3: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def rectangle(width_m: float, height_m: float) -> SectionProperties:
|
|
23
|
+
"""Return centroidal properties; y is the width axis, z the height axis."""
|
|
24
|
+
b, h = _positive("width_m", width_m), _positive("height_m", height_m)
|
|
25
|
+
area = b * h
|
|
26
|
+
iy, iz = b * h**3 / 12.0, h * b**3 / 12.0
|
|
27
|
+
return SectionProperties(area, iy, iz, iy / (h / 2.0), iz / (b / 2.0))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def solid_circle(diameter_m: float) -> SectionProperties:
|
|
31
|
+
"""Return centroidal properties for a solid circular section."""
|
|
32
|
+
d = _positive("diameter_m", diameter_m)
|
|
33
|
+
area = math.pi * d**2 / 4.0
|
|
34
|
+
inertia = math.pi * d**4 / 64.0
|
|
35
|
+
modulus = inertia / (d / 2.0)
|
|
36
|
+
return SectionProperties(area, inertia, inertia, modulus, modulus)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def circular_tube(outer_diameter_m: float, inner_diameter_m: float) -> SectionProperties:
|
|
40
|
+
"""Return centroidal properties for a concentric circular hollow section."""
|
|
41
|
+
outer = _positive("outer_diameter_m", outer_diameter_m)
|
|
42
|
+
inner = float(inner_diameter_m)
|
|
43
|
+
if not math.isfinite(inner) or inner < 0.0 or inner >= outer:
|
|
44
|
+
raise ValueError("inner_diameter_m must be finite, non-negative, and smaller than outer diameter")
|
|
45
|
+
area = math.pi * (outer**2 - inner**2) / 4.0
|
|
46
|
+
inertia = math.pi * (outer**4 - inner**4) / 64.0
|
|
47
|
+
modulus = inertia / (outer / 2.0)
|
|
48
|
+
return SectionProperties(area, inertia, inertia, modulus, modulus)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def i_section(
|
|
52
|
+
overall_width_m: float,
|
|
53
|
+
overall_height_m: float,
|
|
54
|
+
web_thickness_m: float,
|
|
55
|
+
flange_thickness_m: float,
|
|
56
|
+
) -> SectionProperties:
|
|
57
|
+
"""Return centroidal properties of an ideal symmetric sharp-corner I-section.
|
|
58
|
+
|
|
59
|
+
The web is centered and joins two identical rectangular flanges. Fillets,
|
|
60
|
+
flange taper, and manufacturing tolerances are not included.
|
|
61
|
+
"""
|
|
62
|
+
width = _positive("overall_width_m", overall_width_m)
|
|
63
|
+
height = _positive("overall_height_m", overall_height_m)
|
|
64
|
+
web = _positive("web_thickness_m", web_thickness_m)
|
|
65
|
+
flange = _positive("flange_thickness_m", flange_thickness_m)
|
|
66
|
+
if web >= width:
|
|
67
|
+
raise ValueError("web_thickness_m must be smaller than overall_width_m")
|
|
68
|
+
if 2.0 * flange >= height:
|
|
69
|
+
raise ValueError("twice flange_thickness_m must be smaller than overall_height_m")
|
|
70
|
+
web_height = height - 2.0 * flange
|
|
71
|
+
flange_offset = (height - flange) / 2.0
|
|
72
|
+
area = 2.0 * width * flange + web * web_height
|
|
73
|
+
inertia_y = (
|
|
74
|
+
2.0 * (width * flange**3 / 12.0 + width * flange * flange_offset**2)
|
|
75
|
+
+ web * web_height**3 / 12.0
|
|
76
|
+
)
|
|
77
|
+
inertia_z = 2.0 * flange * width**3 / 12.0 + web_height * web**3 / 12.0
|
|
78
|
+
return _checked_properties(area, inertia_y, inertia_z, height / 2.0, width / 2.0)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def rectangular_tube(
|
|
82
|
+
outer_width_m: float,
|
|
83
|
+
outer_height_m: float,
|
|
84
|
+
wall_thickness_m: float,
|
|
85
|
+
) -> SectionProperties:
|
|
86
|
+
"""Return centroidal properties of an ideal uniform-wall rectangular tube.
|
|
87
|
+
|
|
88
|
+
The shape has sharp corners and constant wall thickness. Corner radii,
|
|
89
|
+
weld geometry, and manufacturing tolerances are not included.
|
|
90
|
+
"""
|
|
91
|
+
width = _positive("outer_width_m", outer_width_m)
|
|
92
|
+
height = _positive("outer_height_m", outer_height_m)
|
|
93
|
+
thickness = _positive("wall_thickness_m", wall_thickness_m)
|
|
94
|
+
if 2.0 * thickness >= min(width, height):
|
|
95
|
+
raise ValueError("twice wall_thickness_m must be smaller than both outer dimensions")
|
|
96
|
+
inner_height = height - 2.0 * thickness
|
|
97
|
+
inner_width = width - 2.0 * thickness
|
|
98
|
+
horizontal_plate_offset = (height - thickness) / 2.0
|
|
99
|
+
vertical_plate_offset = (width - thickness) / 2.0
|
|
100
|
+
area = 2.0 * width * thickness + 2.0 * thickness * inner_height
|
|
101
|
+
inertia_y = (
|
|
102
|
+
2.0 * (width * thickness**3 / 12.0 + width * thickness * horizontal_plate_offset**2)
|
|
103
|
+
+ 2.0 * thickness * inner_height**3 / 12.0
|
|
104
|
+
)
|
|
105
|
+
inertia_z = (
|
|
106
|
+
2.0 * thickness * width**3 / 12.0
|
|
107
|
+
+ 2.0 * (inner_height * thickness**3 / 12.0 + inner_height * thickness * vertical_plate_offset**2)
|
|
108
|
+
)
|
|
109
|
+
return _checked_properties(area, inertia_y, inertia_z, height / 2.0, width / 2.0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _checked_properties(area: float, inertia_y: float, inertia_z: float, half_height: float, half_width: float) -> SectionProperties:
|
|
113
|
+
values = (area, inertia_y, inertia_z, half_height, half_width)
|
|
114
|
+
if any(not math.isfinite(value) or value <= 0.0 for value in values):
|
|
115
|
+
raise ValueError("section dimensions produced non-finite or non-positive geometric properties")
|
|
116
|
+
modulus_y, modulus_z = inertia_y / half_height, inertia_z / half_width
|
|
117
|
+
if not math.isfinite(modulus_y) or not math.isfinite(modulus_z):
|
|
118
|
+
raise ValueError("section dimensions produced non-finite section moduli")
|
|
119
|
+
return SectionProperties(area, inertia_y, inertia_z, modulus_y, modulus_z)
|
timoshenko/sensors.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Sensor series loading and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import csv
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Sequence
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SensorData:
|
|
17
|
+
"""A single regularly sampled sensor channel."""
|
|
18
|
+
|
|
19
|
+
samples: tuple[float, ...] | Sequence[float]
|
|
20
|
+
sampling_hz: float
|
|
21
|
+
unit: str = "m/s^2"
|
|
22
|
+
channel: str = "sensor"
|
|
23
|
+
timestamps: tuple[float, ...] = ()
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
samples = tuple(float(value) for value in self.samples)
|
|
27
|
+
hz = float(self.sampling_hz)
|
|
28
|
+
if not math.isfinite(hz) or hz <= 0.0:
|
|
29
|
+
raise ValueError("sampling_hz must be finite and greater than zero")
|
|
30
|
+
if len(samples) < 8:
|
|
31
|
+
raise ValueError("at least 8 numeric samples are required")
|
|
32
|
+
if any(not math.isfinite(value) for value in samples):
|
|
33
|
+
raise ValueError("sensor samples must all be finite numeric values")
|
|
34
|
+
timestamps = tuple(float(value) for value in self.timestamps)
|
|
35
|
+
if timestamps and len(timestamps) != len(samples):
|
|
36
|
+
raise ValueError("timestamps must have one entry per sample")
|
|
37
|
+
if any(not math.isfinite(value) for value in timestamps):
|
|
38
|
+
raise ValueError("timestamps must be finite")
|
|
39
|
+
if timestamps and any(right <= left for left, right in zip(timestamps, timestamps[1:])):
|
|
40
|
+
raise ValueError("timestamps must be strictly increasing")
|
|
41
|
+
object.__setattr__(self, "samples", samples)
|
|
42
|
+
object.__setattr__(self, "sampling_hz", hz)
|
|
43
|
+
object.__setattr__(self, "timestamps", timestamps)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def duration_seconds(self) -> float:
|
|
47
|
+
return len(self.samples) / self.sampling_hz
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_sensors(
|
|
51
|
+
source: str | Path | Sequence[float] | SensorData,
|
|
52
|
+
*,
|
|
53
|
+
sampling_hz: float | None = None,
|
|
54
|
+
column: str = "value",
|
|
55
|
+
unit: str = "m/s^2",
|
|
56
|
+
channel: str | None = None,
|
|
57
|
+
) -> SensorData:
|
|
58
|
+
"""Load one numeric channel from CSV, JSON, an array, or SensorData.
|
|
59
|
+
|
|
60
|
+
CSV files need a header matching ``column``. JSON may be a numeric array,
|
|
61
|
+
an object with ``samples``, or an array of objects containing ``column``.
|
|
62
|
+
Sampling frequency must be supplied because 0.1 does not infer time from
|
|
63
|
+
arbitrary file metadata. Blank or non-numeric samples are rejected rather
|
|
64
|
+
than skipped or interpolated.
|
|
65
|
+
"""
|
|
66
|
+
if isinstance(source, SensorData):
|
|
67
|
+
if sampling_hz is not None and not math.isclose(float(sampling_hz), source.sampling_hz):
|
|
68
|
+
raise ValueError("sampling_hz conflicts with the supplied SensorData")
|
|
69
|
+
return source
|
|
70
|
+
if sampling_hz is None:
|
|
71
|
+
raise ValueError("sampling_hz is required for sensor input")
|
|
72
|
+
|
|
73
|
+
values: list[float]
|
|
74
|
+
source_name = channel or column
|
|
75
|
+
if isinstance(source, (str, Path)):
|
|
76
|
+
path = Path(source)
|
|
77
|
+
suffix = path.suffix.lower()
|
|
78
|
+
if suffix == ".csv":
|
|
79
|
+
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
|
80
|
+
# csv.reader rather than DictReader: DictReader silently skips
|
|
81
|
+
# blank lines, which in a one-column file are missing samples.
|
|
82
|
+
# Dropping them would shorten a regularly sampled record and
|
|
83
|
+
# shift every later sample in time.
|
|
84
|
+
reader = csv.reader(handle)
|
|
85
|
+
header = next(reader, [])
|
|
86
|
+
if column not in header:
|
|
87
|
+
raise ValueError(f"CSV column {column!r} not found; columns are {header}")
|
|
88
|
+
position = header.index(column)
|
|
89
|
+
values = [
|
|
90
|
+
_parse_numeric(row[position] if position < len(row) else None, reader.line_num)
|
|
91
|
+
for row in reader
|
|
92
|
+
]
|
|
93
|
+
elif suffix in {".json", ".jsonl"}:
|
|
94
|
+
text = path.read_text(encoding="utf-8")
|
|
95
|
+
if suffix == ".jsonl":
|
|
96
|
+
payload: Any = [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
97
|
+
else:
|
|
98
|
+
payload = json.loads(text)
|
|
99
|
+
values = _values_from_json(payload, column)
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError("sensor source must be a .csv, .json, or .jsonl file")
|
|
102
|
+
if not channel:
|
|
103
|
+
source_name = path.stem
|
|
104
|
+
else:
|
|
105
|
+
values = [float(value) for value in source]
|
|
106
|
+
|
|
107
|
+
return SensorData(
|
|
108
|
+
samples=values,
|
|
109
|
+
sampling_hz=float(sampling_hz),
|
|
110
|
+
unit=unit,
|
|
111
|
+
channel=source_name,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_numeric(value: Any, line_no: int) -> float:
|
|
116
|
+
try:
|
|
117
|
+
number = float(value)
|
|
118
|
+
except (TypeError, ValueError):
|
|
119
|
+
raise ValueError(f"sensor input line {line_no} is not numeric: {value!r}") from None
|
|
120
|
+
if not math.isfinite(number):
|
|
121
|
+
raise ValueError(f"sensor input line {line_no} is not finite")
|
|
122
|
+
return number
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _values_from_json(payload: Any, column: str) -> list[float]:
|
|
126
|
+
if isinstance(payload, dict):
|
|
127
|
+
if "samples" in payload:
|
|
128
|
+
payload = payload["samples"]
|
|
129
|
+
elif column in payload:
|
|
130
|
+
payload = payload[column]
|
|
131
|
+
else:
|
|
132
|
+
raise ValueError("JSON object must contain 'samples' or the selected channel key")
|
|
133
|
+
if not isinstance(payload, list):
|
|
134
|
+
raise ValueError("JSON sensor data must be an array")
|
|
135
|
+
values = []
|
|
136
|
+
for idx, item in enumerate(payload, 1):
|
|
137
|
+
if isinstance(item, dict):
|
|
138
|
+
if column not in item:
|
|
139
|
+
raise ValueError(f"JSON sample {idx} has no {column!r} value")
|
|
140
|
+
item = item[column]
|
|
141
|
+
values.append(_parse_numeric(item, idx))
|
|
142
|
+
return values
|