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/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