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/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
|
+
)
|
timoshenko/mechanics.py
ADDED
|
@@ -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)
|