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/health.py ADDED
@@ -0,0 +1,149 @@
1
+ """Evidence-oriented modal health comparison."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import math
7
+
8
+ from .modal import ModalResult, identify, pair_modes
9
+ from .multichannel import MultiChannelData
10
+ from .oma import FDDResult, identify_fdd
11
+ from .sensors import SensorData
12
+ from .structure import Structure
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ModeChange:
17
+ """One observed mode compared with the reference mode it was paired to.
18
+
19
+ ``mode_number`` is the 1-based reference mode and ``observed_mode_number``
20
+ the 1-based position in the modal result. A change no larger than the
21
+ spectral resolution is ``resolution_limited``: its sign and size are set
22
+ by the frequency grid, not by the structure.
23
+ """
24
+
25
+ mode_number: int
26
+ reference_frequency_hz: float
27
+ observed_frequency_hz: float
28
+ change_pct: float
29
+ observed_mode_number: int = 0
30
+ resolution_limited: bool = False
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class HealthAssessment:
35
+ structure_id: str
36
+ status: str
37
+ mode_changes: tuple[ModeChange, ...]
38
+ modal_result: ModalResult | FDDResult
39
+ review_recommended: bool
40
+ evidence_summary: str
41
+ limitations: tuple[str, ...]
42
+ review_threshold_pct: float | None = None
43
+
44
+ def to_dict(self) -> dict:
45
+ return {
46
+ "structure_id": self.structure_id,
47
+ "status": self.status,
48
+ "mode_changes": [
49
+ {
50
+ "mode_number": item.mode_number,
51
+ "observed_mode_number": item.observed_mode_number,
52
+ "reference_frequency_hz": item.reference_frequency_hz,
53
+ "observed_frequency_hz": item.observed_frequency_hz,
54
+ "change_pct": item.change_pct,
55
+ "resolution_limited": item.resolution_limited,
56
+ }
57
+ for item in self.mode_changes
58
+ ],
59
+ "modal_result": self.modal_result.to_dict(),
60
+ "review_recommended": self.review_recommended,
61
+ "review_threshold_pct": self.review_threshold_pct,
62
+ "evidence_summary": self.evidence_summary,
63
+ "limitations": list(self.limitations),
64
+ }
65
+
66
+
67
+ def validate_review_threshold(value: float | None) -> float | None:
68
+ """Return a finite positive review threshold in percent, or ``None``."""
69
+ if value is None:
70
+ return None
71
+ threshold = float(value)
72
+ if not math.isfinite(threshold) or threshold <= 0.0:
73
+ raise ValueError("review_threshold_pct must be finite and greater than zero, or None")
74
+ return threshold
75
+
76
+
77
+ def assess(
78
+ *,
79
+ structure: Structure,
80
+ observations: SensorData | MultiChannelData,
81
+ modal_result: ModalResult | FDDResult | None = None,
82
+ review_threshold_pct: float | None = None,
83
+ ) -> HealthAssessment:
84
+ """Compare observed frequencies with the structure's preserved baseline.
85
+
86
+ Observed modes are paired with reference modes by nearest frequency (see
87
+ :func:`timoshenko.modal.pair_modes`). ``review_recommended`` is raised
88
+ only when the caller supplies ``review_threshold_pct`` and a paired mode
89
+ drops by at least that percentage by more than the spectral resolution.
90
+ The engine does not choose that threshold: a meaningful value depends on
91
+ the asset, its environmental variability, and a calibrated baseline.
92
+ No safety category or probability of failure is assigned.
93
+ """
94
+ if not isinstance(structure, Structure):
95
+ raise TypeError("structure must be a timoshenko.Structure")
96
+ if not isinstance(observations, (SensorData, MultiChannelData)):
97
+ raise TypeError("observations must be SensorData or MultiChannelData")
98
+ threshold = validate_review_threshold(review_threshold_pct)
99
+ if modal_result is not None:
100
+ result = modal_result
101
+ elif isinstance(observations, SensorData):
102
+ result = identify(observations)
103
+ else:
104
+ result = identify_fdd(observations)
105
+ if not isinstance(result, (ModalResult, FDDResult)):
106
+ raise TypeError("modal_result must be a ModalResult or FDDResult")
107
+ reference = structure.baseline_frequencies_hz
108
+ pairs = pair_modes(reference, result.frequencies_hz)
109
+ changes = []
110
+ for reference_index, observed_index in pairs:
111
+ reference_hz = float(reference[reference_index])
112
+ observed_hz = float(result.modes[observed_index].frequency_hz)
113
+ changes.append(ModeChange(
114
+ mode_number=reference_index + 1,
115
+ reference_frequency_hz=reference_hz,
116
+ observed_frequency_hz=observed_hz,
117
+ change_pct=100.0 * (observed_hz - reference_hz) / reference_hz,
118
+ observed_mode_number=observed_index + 1,
119
+ resolution_limited=abs(observed_hz - reference_hz) <= result.resolution_hz,
120
+ ))
121
+ enough = result.status == "ok" and bool(changes)
122
+ status = "evidence_available" if enough else "insufficient_evidence"
123
+ summary = (
124
+ f"Compared {len(changes)} observed mode(s) with the reference model."
125
+ if enough
126
+ else "No usable modal frequency could be compared with the reference model."
127
+ )
128
+ notes = [
129
+ "Frequency shifts can have several causes, including temperature, boundary conditions, sensor placement, and structural change.",
130
+ "This assessment is not a diagnosis of damage or a statement of structural safety.",
131
+ ]
132
+ unpaired = len(result.modes) - len(changes)
133
+ if unpaired:
134
+ notes.append(f"{unpaired} observed peak(s) were not paired with a reference mode and were not compared.")
135
+ if threshold is None:
136
+ notes.append("No review threshold was supplied, so no review flag is raised; choose one from a calibrated baseline.")
137
+ review = threshold is not None and any(
138
+ not change.resolution_limited and change.change_pct <= -threshold for change in changes
139
+ )
140
+ return HealthAssessment(
141
+ structure_id=structure.structure_id,
142
+ status=status,
143
+ mode_changes=tuple(changes),
144
+ modal_result=result,
145
+ review_recommended=review,
146
+ evidence_summary=summary,
147
+ limitations=tuple(notes),
148
+ review_threshold_pct=threshold,
149
+ )
@@ -0,0 +1,54 @@
1
+ """Small, unit-explicit mechanics calculations for linear elastic materials.
2
+
3
+ All inputs use SI units. These functions calculate textbook quantities; they
4
+ do not perform code checks, resistance factors, or safety certification.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ from ._validation import finite as _finite, positive as _positive
12
+
13
+
14
+ def axial_stress(force_n: float, area_m2: float) -> float:
15
+ """Average normal stress ``N/A`` in pascals; tension is positive."""
16
+ return _finite("force_n", force_n) / _positive("area_m2", area_m2)
17
+
18
+
19
+ def bending_stress(moment_nm: float, section_modulus_m3: float) -> float:
20
+ """Extreme-fiber elastic bending stress ``M/S`` in pascals."""
21
+ return _finite("moment_nm", moment_nm) / _positive("section_modulus_m3", section_modulus_m3)
22
+
23
+
24
+ def average_shear_stress(shear_n: float, area_m2: float) -> float:
25
+ """Average shear stress ``V/A`` in pascals (not a peak-stress estimate)."""
26
+ return _finite("shear_n", shear_n) / _positive("area_m2", area_m2)
27
+
28
+
29
+ def rectangular_max_shear_stress(shear_n: float, area_m2: float) -> float:
30
+ """Maximum elastic shear stress ``1.5 V/A`` for a solid rectangle."""
31
+ return 1.5 * average_shear_stress(shear_n, area_m2)
32
+
33
+
34
+ def axial_strain(stress_pa: float, youngs_modulus_pa: float) -> float:
35
+ """Uniaxial elastic strain ``sigma/E`` (dimensionless)."""
36
+ return _finite("stress_pa", stress_pa) / _positive("youngs_modulus_pa", youngs_modulus_pa)
37
+
38
+
39
+ def thermal_strain(expansion_per_k: float, temperature_change_k: float) -> float:
40
+ """Free isotropic thermal strain ``alpha * delta_T`` (dimensionless)."""
41
+ alpha = float(expansion_per_k)
42
+ delta_t = float(temperature_change_k)
43
+ if not math.isfinite(alpha) or not math.isfinite(delta_t):
44
+ raise ValueError("thermal expansion coefficient and temperature change must be finite")
45
+ return alpha * delta_t
46
+
47
+
48
+ def youngs_modulus_from_shear(shear_modulus_pa: float, poisson_ratio: float) -> float:
49
+ """Return ``E = 2 G (1 + nu)`` for an isotropic linear elastic material."""
50
+ shear = _positive("shear_modulus_pa", shear_modulus_pa)
51
+ nu = float(poisson_ratio)
52
+ if not math.isfinite(nu) or not -1.0 < nu < 0.5:
53
+ raise ValueError("poisson_ratio must be finite and satisfy -1 < nu < 0.5")
54
+ return 2.0 * shear * (1.0 + nu)
timoshenko/modal.py ADDED
@@ -0,0 +1,243 @@
1
+ """Frequency-domain operational modal identification for one sensor channel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import math
7
+
8
+ import numpy as np
9
+
10
+ from .sensors import SensorData
11
+ from .oma import FDDMode, FDDResult, identify_fdd
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Mode:
16
+ frequency_hz: float
17
+ amplitude: float
18
+ damping_ratio: float | None = None
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ModalResult:
23
+ modes: tuple[Mode, ...]
24
+ sampling_hz: float
25
+ sample_count: int
26
+ resolution_hz: float
27
+ channel: str
28
+ method: str = "hann_windowed_fft_peak_picking"
29
+ status: str = "ok"
30
+ notes: tuple[str, ...] = ()
31
+
32
+ @property
33
+ def frequencies_hz(self) -> tuple[float, ...]:
34
+ return tuple(mode.frequency_hz for mode in self.modes)
35
+
36
+ def to_dict(self) -> dict:
37
+ return {
38
+ "modes": [
39
+ {"frequency_hz": mode.frequency_hz, "amplitude": mode.amplitude, "damping_ratio": mode.damping_ratio}
40
+ for mode in self.modes
41
+ ],
42
+ "sampling_hz": self.sampling_hz,
43
+ "sample_count": self.sample_count,
44
+ "resolution_hz": self.resolution_hz,
45
+ "channel": self.channel,
46
+ "method": self.method,
47
+ "status": self.status,
48
+ "notes": list(self.notes),
49
+ }
50
+
51
+
52
+ def identify(
53
+ sensor_data: SensorData,
54
+ *,
55
+ max_modes: int = 6,
56
+ min_frequency_hz: float | None = None,
57
+ max_frequency_hz: float | None = None,
58
+ min_peak_ratio: float = 0.03,
59
+ ) -> ModalResult:
60
+ """Estimate modal peaks with a Hann-windowed single-sided FFT.
61
+
62
+ This simple operational modal analysis uses one channel, so modes may be
63
+ missed when that sensor is near a modal node. It is intended as a
64
+ screening estimate; poly-reference identification and mode shapes are not
65
+ part of release 0.1.
66
+ """
67
+ if not isinstance(sensor_data, SensorData):
68
+ raise TypeError("sensor_data must be a timoshenko.SensorData; use tm.load_sensors() first")
69
+ resolution, low, high = _plan(
70
+ len(sensor_data.samples),
71
+ sensor_data.sampling_hz,
72
+ max_modes=max_modes,
73
+ min_frequency_hz=min_frequency_hz,
74
+ max_frequency_hz=max_frequency_hz,
75
+ min_peak_ratio=min_peak_ratio,
76
+ )
77
+
78
+ values = np.asarray(sensor_data.samples, dtype=float)
79
+ values = values - float(np.mean(values))
80
+ if float(np.max(np.abs(values))) <= np.finfo(float).eps:
81
+ return ModalResult((), sensor_data.sampling_hz, len(values), resolution, sensor_data.channel, status="insufficient_signal", notes=("The input channel is constant after mean removal.",))
82
+ window = np.hanning(len(values))
83
+ spectrum = np.abs(np.fft.rfft(values * window))
84
+ frequencies = np.fft.rfftfreq(len(values), d=1.0 / sensor_data.sampling_hz)
85
+ in_band = (frequencies >= low) & (frequencies <= high)
86
+ candidates = [
87
+ idx for idx in range(1, len(spectrum) - 1)
88
+ if in_band[idx] and spectrum[idx] >= spectrum[idx - 1] and spectrum[idx] > spectrum[idx + 1]
89
+ ]
90
+ max_amplitude = float(max((spectrum[idx] for idx in candidates), default=0.0))
91
+ candidates = [idx for idx in candidates if max_amplitude > 0.0 and spectrum[idx] >= max_amplitude * min_peak_ratio]
92
+ selected: list[int] = []
93
+ for idx in sorted(candidates, key=lambda item: float(spectrum[item]), reverse=True):
94
+ if all(abs(float(frequencies[idx] - frequencies[other])) >= 2.0 * resolution for other in selected):
95
+ selected.append(idx)
96
+ if len(selected) >= max_modes:
97
+ break
98
+ selected.sort(key=lambda item: float(frequencies[item]))
99
+ damping_psd = _averaged_psd(values, sensor_data.sampling_hz)
100
+ modes = tuple(
101
+ Mode(
102
+ frequency_hz=float(frequencies[idx]),
103
+ amplitude=float(spectrum[idx]),
104
+ damping_ratio=None if damping_psd is None else _half_power_damping(*damping_psd, float(frequencies[idx])),
105
+ )
106
+ for idx in selected
107
+ )
108
+ notes = ["Frequency spacing is limited by the record duration."] if resolution > 0.25 else []
109
+ if any(mode.damping_ratio is not None for mode in modes):
110
+ notes.append(
111
+ "Damping ratios are coarse half-power screening estimates from an averaged spectrum; "
112
+ "expect scatter of about a factor of two on ambient data."
113
+ )
114
+ if any(mode.damping_ratio is None for mode in modes):
115
+ notes.append(
116
+ "Damping is withheld where the record is too short to resolve the half-power bandwidth "
117
+ f"with {_MIN_DAMPING_AVERAGES} averages and at least {_MIN_DAMPING_BINS:g} frequency bins."
118
+ )
119
+ notes = tuple(notes)
120
+ return ModalResult(
121
+ modes=modes,
122
+ sampling_hz=sensor_data.sampling_hz,
123
+ sample_count=len(values),
124
+ resolution_hz=resolution,
125
+ channel=sensor_data.channel,
126
+ status="ok" if modes else "no_peaks_found",
127
+ notes=notes,
128
+ )
129
+
130
+
131
+ def pair_modes(reference_hz, observed_hz) -> tuple[tuple[int, int], ...]:
132
+ """Pair observed with reference frequencies by nearest log-frequency.
133
+
134
+ Each observed frequency is assigned to the reference mode it is closest
135
+ to on a logarithmic scale; when several observed peaks claim the same
136
+ reference mode only the closest one is kept. Returns ``(reference_index,
137
+ observed_index)`` pairs in reference order. Unlike index pairing, a mode
138
+ missed at a sensor node, or a spurious peak, does not shift the comparison
139
+ onto the wrong reference mode. A uniform stiffness change scales every
140
+ frequency by the same factor, so each mode stays nearest to its own
141
+ reference unless the change exceeds the spacing between modes.
142
+ """
143
+ references = [float(value) for value in reference_hz]
144
+ best: dict[int, tuple[float, int]] = {}
145
+ for observed_index, value in enumerate(observed_hz):
146
+ frequency = float(value)
147
+ if not references or not math.isfinite(frequency) or frequency <= 0.0:
148
+ continue
149
+ distances = [abs(math.log(frequency / reference)) for reference in references]
150
+ reference_index = min(range(len(references)), key=distances.__getitem__)
151
+ if reference_index not in best or distances[reference_index] < best[reference_index][0]:
152
+ best[reference_index] = (distances[reference_index], observed_index)
153
+ return tuple((reference_index, best[reference_index][1]) for reference_index in sorted(best))
154
+
155
+
156
+ _MIN_DAMPING_AVERAGES = 8
157
+ _MIN_DAMPING_BINS = 4.0
158
+
159
+
160
+ def validate_options(sample_count: int, sampling_hz: float, **options) -> None:
161
+ """Check ``identify`` options for a record length without any data.
162
+
163
+ Raises the same errors ``identify`` would raise for these options, so
164
+ long-running callers can reject a bad configuration up front.
165
+ """
166
+ _plan(int(sample_count), float(sampling_hz), **options)
167
+
168
+
169
+ def _plan(
170
+ sample_count: int,
171
+ sampling_hz: float,
172
+ *,
173
+ max_modes: int = 6,
174
+ min_frequency_hz: float | None = None,
175
+ max_frequency_hz: float | None = None,
176
+ min_peak_ratio: float = 0.03,
177
+ ) -> tuple[float, float, float]:
178
+ """Validate options and return resolution and the searched frequency band."""
179
+ if max_modes < 1:
180
+ raise ValueError("max_modes must be at least one")
181
+ if not 0.0 <= min_peak_ratio < 1.0:
182
+ raise ValueError("min_peak_ratio must be in [0, 1)")
183
+ resolution = sampling_hz / sample_count
184
+ low = float(min_frequency_hz) if min_frequency_hz is not None else resolution
185
+ high = min(float(max_frequency_hz), sampling_hz / 2.0) if max_frequency_hz is not None else sampling_hz / 2.0
186
+ if not math.isfinite(low) or low < 0.0 or not math.isfinite(high) or high <= low:
187
+ raise ValueError("frequency bounds must be finite, non-negative, and have max greater than min")
188
+ return resolution, low, high
189
+
190
+
191
+ def _averaged_psd(values: np.ndarray, sampling_hz: float) -> tuple[np.ndarray, np.ndarray] | None:
192
+ """Welch power spectrum with 50% overlap and at least eight averages.
193
+
194
+ A single periodogram of ambient response fluctuates by about 100% per
195
+ bin, so its half-power points land on noise spikes. The segment length is
196
+ the longest power of two that still gives the required averages.
197
+ """
198
+ longest = len(values) / (1.0 + (_MIN_DAMPING_AVERAGES - 1) / 2.0)
199
+ if longest < 64:
200
+ return None
201
+ nperseg = 2 ** int(math.floor(math.log2(longest)))
202
+ window = np.hanning(nperseg)
203
+ power = np.zeros(nperseg // 2 + 1)
204
+ count = 0
205
+ for start in range(0, len(values) - nperseg + 1, nperseg // 2):
206
+ segment = values[start : start + nperseg]
207
+ power += np.abs(np.fft.rfft((segment - np.mean(segment)) * window)) ** 2
208
+ count += 1
209
+ return power / count, np.fft.rfftfreq(nperseg, d=1.0 / sampling_hz)
210
+
211
+
212
+ def _half_power_damping(power: np.ndarray, frequencies: np.ndarray, frequency_hz: float) -> float | None:
213
+ """Half-power bandwidth damping, or ``None`` when it is not resolved.
214
+
215
+ A Hann window alone gives a half-power width of about 1.44 bins, so a
216
+ bandwidth narrower than ``_MIN_DAMPING_BINS`` bins describes the window
217
+ and record length rather than the structure.
218
+ """
219
+ resolution = float(frequencies[1] - frequencies[0])
220
+ nearest = int(round(frequency_hz / resolution))
221
+ low, high = max(1, nearest - 1), min(len(power) - 2, nearest + 1)
222
+ if high < low:
223
+ return None
224
+ peak_idx = low + int(np.argmax(power[low : high + 1]))
225
+ half_power = float(power[peak_idx]) / 2.0
226
+ if half_power <= 0.0:
227
+ return None
228
+ f_left: float | None = None
229
+ for idx in range(peak_idx, 0, -1):
230
+ p0, p1 = float(power[idx - 1]), float(power[idx])
231
+ if p0 <= half_power < p1:
232
+ f_left = float(frequencies[idx - 1]) + (half_power - p0) * resolution / (p1 - p0)
233
+ break
234
+ f_right: float | None = None
235
+ for idx in range(peak_idx, len(power) - 1):
236
+ p0, p1 = float(power[idx]), float(power[idx + 1])
237
+ if p0 > half_power >= p1:
238
+ f_right = float(frequencies[idx]) + (p0 - half_power) * resolution / (p0 - p1)
239
+ break
240
+ f_peak = float(frequencies[peak_idx])
241
+ if f_left is None or f_right is None or f_right - f_left < _MIN_DAMPING_BINS * resolution:
242
+ return None
243
+ return min(1.0, (f_right - f_left) / (2.0 * f_peak))
timoshenko/monitor.py ADDED
@@ -0,0 +1,48 @@
1
+ """One-shot composition of the 0.1 engineering analysis steps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .health import HealthAssessment, assess
8
+ from .modal import ModalResult, identify
9
+ from .sensors import SensorData
10
+ from .structure import Structure
11
+ from .update import update
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class MonitoringResult:
16
+ structure: Structure
17
+ modal: ModalResult
18
+ health: HealthAssessment
19
+
20
+ def to_dict(self) -> dict:
21
+ return {
22
+ "structure": {
23
+ "structure_id": self.structure.structure_id,
24
+ "story_count": self.structure.story_count,
25
+ "natural_frequencies_hz": list(self.structure.natural_frequencies_hz),
26
+ "reference_frequencies_hz": list(self.structure.baseline_frequencies_hz),
27
+ "observed_frequencies_hz": list(self.structure.observed_frequencies_hz),
28
+ "update_scale_factor": self.structure.update_scale_factor,
29
+ "update_mode_count": self.structure.update_mode_count,
30
+ "update_mode_scale_spread_pct": self.structure.update_mode_scale_spread_pct,
31
+ "update_status": self.structure.update_status,
32
+ },
33
+ "modal": self.modal.to_dict(),
34
+ "health": self.health.to_dict(),
35
+ }
36
+
37
+
38
+ def monitor(structure: Structure, sensors: SensorData, *, review_threshold_pct: float | None = None) -> MonitoringResult:
39
+ """Run modal identification, uniform model update, then health comparison."""
40
+ modal_result = identify(sensors)
41
+ updated_structure = update(structure, modal_result) if modal_result.modes else structure
42
+ health_result = assess(
43
+ structure=updated_structure,
44
+ observations=sensors,
45
+ modal_result=modal_result,
46
+ review_threshold_pct=review_threshold_pct,
47
+ )
48
+ return MonitoringResult(structure=updated_structure, modal=modal_result, health=health_result)