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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Uncertainty propagation for user-supplied scalar engineering equations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import math
|
|
7
|
+
from statistics import NormalDist
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UncertaintyError(ValueError):
|
|
15
|
+
"""Raised when uncertainty inputs or equation evaluations are invalid."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class UncertaintyResult:
|
|
20
|
+
"""Scalar estimate and propagated uncertainty with method provenance."""
|
|
21
|
+
|
|
22
|
+
estimate: float
|
|
23
|
+
standard_uncertainty: float
|
|
24
|
+
confidence_level: float
|
|
25
|
+
interval_low: float
|
|
26
|
+
interval_high: float
|
|
27
|
+
method: str
|
|
28
|
+
input_names: tuple[str, ...]
|
|
29
|
+
sensitivity_coefficients: Mapping[str, float]
|
|
30
|
+
sample_count: int
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
object.__setattr__(self, "input_names", tuple(self.input_names))
|
|
34
|
+
object.__setattr__(self, "sensitivity_coefficients", MappingProxyType(dict(self.sensitivity_coefficients)))
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"estimate": self.estimate,
|
|
39
|
+
"standard_uncertainty": self.standard_uncertainty,
|
|
40
|
+
"confidence_level": self.confidence_level,
|
|
41
|
+
"interval": [self.interval_low, self.interval_high],
|
|
42
|
+
"method": self.method,
|
|
43
|
+
"input_names": list(self.input_names),
|
|
44
|
+
"sensitivity_coefficients": dict(self.sensitivity_coefficients),
|
|
45
|
+
"sample_count": self.sample_count,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def propagate(
|
|
50
|
+
function: Callable[..., float],
|
|
51
|
+
inputs: Mapping[str, float],
|
|
52
|
+
*,
|
|
53
|
+
standard_uncertainties: Mapping[str, float] | None = None,
|
|
54
|
+
covariance: Sequence[Sequence[float]] | None = None,
|
|
55
|
+
method: str = "first_order",
|
|
56
|
+
confidence_level: float = 0.95,
|
|
57
|
+
relative_step: float = 1e-5,
|
|
58
|
+
samples: int = 10_000,
|
|
59
|
+
seed: int | None = 0,
|
|
60
|
+
) -> UncertaintyResult:
|
|
61
|
+
"""Propagate input standard uncertainties through a scalar callable.
|
|
62
|
+
|
|
63
|
+
``inputs`` names are passed to ``function`` as keyword arguments. Supply
|
|
64
|
+
either independent ``standard_uncertainties`` by name or a full covariance
|
|
65
|
+
matrix ordered like ``inputs``. ``first_order`` uses centered finite
|
|
66
|
+
differences and the GUM linearized covariance law. ``monte_carlo`` draws
|
|
67
|
+
from the multivariate normal distribution described by the same input
|
|
68
|
+
estimates and covariance. The caller remains responsible for choosing
|
|
69
|
+
suitable input uncertainty models and units.
|
|
70
|
+
"""
|
|
71
|
+
if not callable(function):
|
|
72
|
+
raise TypeError("function must be callable")
|
|
73
|
+
if not isinstance(inputs, Mapping) or not inputs:
|
|
74
|
+
raise UncertaintyError("inputs must be a non-empty mapping of keyword names to values")
|
|
75
|
+
if any(not isinstance(name, str) for name in inputs):
|
|
76
|
+
raise UncertaintyError("input names must be strings")
|
|
77
|
+
names = tuple(name.strip() for name in inputs)
|
|
78
|
+
if any(not name or name != original for name, original in zip(names, inputs)) or len(set(names)) != len(names):
|
|
79
|
+
raise UncertaintyError("input names must be non-empty and distinct")
|
|
80
|
+
if len(names) > 32:
|
|
81
|
+
raise UncertaintyError("at most 32 uncertain inputs are supported")
|
|
82
|
+
values = np.asarray([_finite_scalar(inputs[key], f"input {key!r}") for key in inputs], dtype=float)
|
|
83
|
+
if (standard_uncertainties is None) == (covariance is None):
|
|
84
|
+
raise UncertaintyError("supply exactly one of standard_uncertainties or covariance")
|
|
85
|
+
if standard_uncertainties is not None:
|
|
86
|
+
if not isinstance(standard_uncertainties, Mapping) or set(standard_uncertainties) != set(inputs):
|
|
87
|
+
raise UncertaintyError("standard_uncertainties must provide one value for every input name")
|
|
88
|
+
deviations = np.asarray(
|
|
89
|
+
[_finite_scalar(standard_uncertainties[key], f"standard uncertainty for {key!r}") for key in inputs],
|
|
90
|
+
dtype=float,
|
|
91
|
+
)
|
|
92
|
+
if np.any(deviations < 0.0):
|
|
93
|
+
raise UncertaintyError("standard uncertainties must be non-negative")
|
|
94
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
95
|
+
covariance_matrix = np.diag(deviations * deviations)
|
|
96
|
+
if not np.isfinite(covariance_matrix).all():
|
|
97
|
+
raise UncertaintyError("standard uncertainties are too large to form finite variances")
|
|
98
|
+
else:
|
|
99
|
+
try:
|
|
100
|
+
covariance_matrix = np.asarray(covariance, dtype=float)
|
|
101
|
+
except (TypeError, ValueError) as error:
|
|
102
|
+
raise UncertaintyError("covariance must be a numeric square matrix") from error
|
|
103
|
+
if covariance_matrix.shape != (len(names), len(names)) or not np.isfinite(covariance_matrix).all():
|
|
104
|
+
raise UncertaintyError("covariance must be a finite square matrix ordered like inputs")
|
|
105
|
+
scale = max(float(np.max(np.abs(covariance_matrix))), np.finfo(float).tiny)
|
|
106
|
+
if not np.allclose(covariance_matrix, covariance_matrix.T, rtol=1e-10, atol=1e-12 * scale):
|
|
107
|
+
raise UncertaintyError("covariance matrix must be symmetric")
|
|
108
|
+
covariance_matrix = 0.5 * (covariance_matrix + covariance_matrix.T)
|
|
109
|
+
try:
|
|
110
|
+
eigenvalues, eigenvectors = np.linalg.eigh(covariance_matrix)
|
|
111
|
+
except np.linalg.LinAlgError as error:
|
|
112
|
+
raise UncertaintyError("could not validate covariance matrix") from error
|
|
113
|
+
if float(eigenvalues[0]) < -1e-10 * scale:
|
|
114
|
+
raise UncertaintyError("covariance matrix must be positive semidefinite")
|
|
115
|
+
if np.any(eigenvalues < 0.0):
|
|
116
|
+
covariance_matrix = (eigenvectors * np.maximum(eigenvalues, 0.0)) @ eigenvectors.T
|
|
117
|
+
|
|
118
|
+
confidence = _finite_scalar(confidence_level, "confidence_level")
|
|
119
|
+
if not 0.5 < confidence < 1.0:
|
|
120
|
+
raise UncertaintyError("confidence_level must be greater than 0.5 and less than 1")
|
|
121
|
+
method_value = str(method).strip().lower()
|
|
122
|
+
if method_value not in {"first_order", "monte_carlo"}:
|
|
123
|
+
raise UncertaintyError("method must be 'first_order' or 'monte_carlo'")
|
|
124
|
+
nominal = _evaluate(function, names, values, "nominal inputs")
|
|
125
|
+
|
|
126
|
+
if method_value == "first_order":
|
|
127
|
+
step_scale = _finite_scalar(relative_step, "relative_step")
|
|
128
|
+
if not 0.0 < step_scale < 0.1:
|
|
129
|
+
raise UncertaintyError("relative_step must be greater than 0 and less than 0.1")
|
|
130
|
+
deviations = np.sqrt(np.maximum(np.diag(covariance_matrix), 0.0))
|
|
131
|
+
sensitivities: dict[str, float] = {}
|
|
132
|
+
for index, name in enumerate(names):
|
|
133
|
+
scale = max(abs(float(values[index])), float(deviations[index]))
|
|
134
|
+
if scale == 0.0:
|
|
135
|
+
scale = 1.0
|
|
136
|
+
step = step_scale * scale
|
|
137
|
+
if step == 0.0 or not math.isfinite(step):
|
|
138
|
+
raise UncertaintyError(f"could not choose a finite difference step for {name!r}")
|
|
139
|
+
plus, minus = values.copy(), values.copy()
|
|
140
|
+
plus[index] += step
|
|
141
|
+
minus[index] -= step
|
|
142
|
+
y_plus = _try_evaluate(function, names, plus)
|
|
143
|
+
y_minus = _try_evaluate(function, names, minus)
|
|
144
|
+
if y_plus is not None and y_minus is not None:
|
|
145
|
+
derivative = (y_plus - y_minus) / (2.0 * step)
|
|
146
|
+
elif y_plus is not None:
|
|
147
|
+
derivative = (y_plus - nominal) / step
|
|
148
|
+
elif y_minus is not None:
|
|
149
|
+
derivative = (nominal - y_minus) / step
|
|
150
|
+
else:
|
|
151
|
+
raise UncertaintyError(f"cannot estimate sensitivity for {name!r} near the supplied input")
|
|
152
|
+
if not math.isfinite(derivative):
|
|
153
|
+
raise UncertaintyError(f"sensitivity for {name!r} is non-finite")
|
|
154
|
+
sensitivities[name] = float(derivative)
|
|
155
|
+
gradient = np.asarray([sensitivities[name] for name in names], dtype=float)
|
|
156
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
157
|
+
variance = float(gradient @ covariance_matrix @ gradient)
|
|
158
|
+
variance_scale = float(np.abs(gradient) @ np.abs(covariance_matrix) @ np.abs(gradient))
|
|
159
|
+
if not math.isfinite(variance) or not math.isfinite(variance_scale):
|
|
160
|
+
raise UncertaintyError("propagated variance is non-finite")
|
|
161
|
+
if variance < -1e-12 * max(variance_scale, np.finfo(float).tiny):
|
|
162
|
+
raise UncertaintyError("propagated variance became negative")
|
|
163
|
+
standard = math.sqrt(max(variance, 0.0))
|
|
164
|
+
coverage = NormalDist().inv_cdf(0.5 + confidence / 2.0)
|
|
165
|
+
low, high = nominal - coverage * standard, nominal + coverage * standard
|
|
166
|
+
if not all(math.isfinite(value) for value in (standard, low, high)):
|
|
167
|
+
raise UncertaintyError("uncertainty interval is non-finite")
|
|
168
|
+
return UncertaintyResult(
|
|
169
|
+
estimate=nominal,
|
|
170
|
+
standard_uncertainty=standard,
|
|
171
|
+
confidence_level=confidence,
|
|
172
|
+
interval_low=low,
|
|
173
|
+
interval_high=high,
|
|
174
|
+
method=method_value,
|
|
175
|
+
input_names=names,
|
|
176
|
+
sensitivity_coefficients=sensitivities,
|
|
177
|
+
sample_count=0,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
sample_count = int(samples)
|
|
182
|
+
except (TypeError, ValueError, OverflowError) as error:
|
|
183
|
+
raise UncertaintyError("samples must be an integer between 100 and 100000") from error
|
|
184
|
+
if isinstance(samples, bool) or sample_count != samples or not 100 <= sample_count <= 100_000:
|
|
185
|
+
raise UncertaintyError("samples must be an integer between 100 and 100000")
|
|
186
|
+
if seed is not None and (isinstance(seed, bool) or not isinstance(seed, (int, np.integer))):
|
|
187
|
+
raise UncertaintyError("seed must be an integer or None")
|
|
188
|
+
try:
|
|
189
|
+
rng = np.random.default_rng(seed)
|
|
190
|
+
draws = rng.multivariate_normal(
|
|
191
|
+
values,
|
|
192
|
+
covariance_matrix,
|
|
193
|
+
size=sample_count,
|
|
194
|
+
check_valid="raise",
|
|
195
|
+
method="eigh",
|
|
196
|
+
)
|
|
197
|
+
except (ValueError, np.linalg.LinAlgError) as error:
|
|
198
|
+
raise UncertaintyError(f"could not sample the configured input distribution: {error}") from error
|
|
199
|
+
output = np.empty(sample_count, dtype=float)
|
|
200
|
+
for index, draw in enumerate(draws):
|
|
201
|
+
try:
|
|
202
|
+
output[index] = _evaluate(function, names, draw, f"Monte Carlo sample {index}")
|
|
203
|
+
except UncertaintyError as error:
|
|
204
|
+
raise UncertaintyError(
|
|
205
|
+
f"Monte Carlo evaluation failed at sample {index}; choose an input distribution whose samples stay in the equation domain"
|
|
206
|
+
) from error
|
|
207
|
+
estimate = float(np.mean(output))
|
|
208
|
+
standard = float(np.std(output, ddof=1))
|
|
209
|
+
alpha = (1.0 - confidence) / 2.0
|
|
210
|
+
low, high = (float(value) for value in np.quantile(output, [alpha, 1.0 - alpha]))
|
|
211
|
+
if not all(math.isfinite(value) for value in (estimate, standard, low, high)):
|
|
212
|
+
raise UncertaintyError("Monte Carlo summary contains a non-finite value")
|
|
213
|
+
return UncertaintyResult(
|
|
214
|
+
estimate=estimate,
|
|
215
|
+
standard_uncertainty=standard,
|
|
216
|
+
confidence_level=confidence,
|
|
217
|
+
interval_low=low,
|
|
218
|
+
interval_high=high,
|
|
219
|
+
method=method_value,
|
|
220
|
+
input_names=names,
|
|
221
|
+
sensitivity_coefficients={},
|
|
222
|
+
sample_count=sample_count,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _finite_scalar(value: Any, label: str) -> float:
|
|
227
|
+
if isinstance(value, (bool, np.bool_)) or not np.isscalar(value):
|
|
228
|
+
raise UncertaintyError(f"{label} must be a finite real number")
|
|
229
|
+
try:
|
|
230
|
+
result = float(value)
|
|
231
|
+
except (TypeError, ValueError) as error:
|
|
232
|
+
raise UncertaintyError(f"{label} must be a finite real number") from error
|
|
233
|
+
if not math.isfinite(result):
|
|
234
|
+
raise UncertaintyError(f"{label} must be a finite real number")
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _evaluate(function: Callable[..., float], names: tuple[str, ...], values: Sequence[float], label: str) -> float:
|
|
239
|
+
try:
|
|
240
|
+
result = function(**dict(zip(names, values)))
|
|
241
|
+
except Exception as error:
|
|
242
|
+
raise UncertaintyError(f"equation evaluation failed for {label}: {error}") from error
|
|
243
|
+
if isinstance(result, (bool, np.bool_)) or not np.isscalar(result):
|
|
244
|
+
raise UncertaintyError(f"equation output for {label} must be a finite scalar number")
|
|
245
|
+
try:
|
|
246
|
+
output = float(result)
|
|
247
|
+
except (TypeError, ValueError) as error:
|
|
248
|
+
raise UncertaintyError(f"equation output for {label} must be a finite scalar number") from error
|
|
249
|
+
if not math.isfinite(output):
|
|
250
|
+
raise UncertaintyError(f"equation output for {label} must be a finite scalar number")
|
|
251
|
+
return output
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _try_evaluate(function: Callable[..., float], names: tuple[str, ...], values: Sequence[float]) -> float | None:
|
|
255
|
+
try:
|
|
256
|
+
return _evaluate(function, names, values, "finite difference input")
|
|
257
|
+
except UncertaintyError:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
__all__ = ["UncertaintyError", "UncertaintyResult", "propagate"]
|
timoshenko/update.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Transparent, deliberately constrained modal model update."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import statistics
|
|
7
|
+
|
|
8
|
+
from .modal import ModalResult, pair_modes
|
|
9
|
+
from .oma import FDDResult
|
|
10
|
+
from .structure import Structure
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def update(structure: Structure, modal: ModalResult | FDDResult) -> Structure:
|
|
14
|
+
"""Scale all story stiffnesses uniformly to fit identified frequencies.
|
|
15
|
+
|
|
16
|
+
The scale is the median of ``(measured / analytical frequency) ** 2``
|
|
17
|
+
across modes paired by :func:`timoshenko.modal.pair_modes`. This follows
|
|
18
|
+
the uniform-stiffness relation ``f proportional to sqrt(k/m)``. It accepts
|
|
19
|
+
single-channel FFT or multi-channel FDD modal results. It cannot localize
|
|
20
|
+
damage or update stories independently. The scale is relative to the
|
|
21
|
+
stiffness of ``structure`` as passed in.
|
|
22
|
+
"""
|
|
23
|
+
if not isinstance(structure, Structure):
|
|
24
|
+
raise TypeError("structure must be a timoshenko.Structure")
|
|
25
|
+
if not isinstance(modal, (ModalResult, FDDResult)):
|
|
26
|
+
raise TypeError("modal must be a result from tm.modal.identify() or tm.modal.identify_fdd()")
|
|
27
|
+
if modal.status != "ok" or not modal.modes:
|
|
28
|
+
raise ValueError(f"cannot update structure from modal result with status {modal.status!r}")
|
|
29
|
+
|
|
30
|
+
analytical = structure.natural_frequencies_hz
|
|
31
|
+
pairs = pair_modes(analytical, modal.frequencies_hz)
|
|
32
|
+
scale_by_mode = [
|
|
33
|
+
(modal.modes[observed].frequency_hz / analytical[reference]) ** 2
|
|
34
|
+
for reference, observed in pairs
|
|
35
|
+
]
|
|
36
|
+
if not scale_by_mode or any(not math.isfinite(scale) or scale <= 0.0 for scale in scale_by_mode):
|
|
37
|
+
raise ValueError("modal/model frequency pairing produced an invalid stiffness scale")
|
|
38
|
+
scale = float(statistics.median(scale_by_mode))
|
|
39
|
+
spread_pct = 100.0 * statistics.median(abs(value - scale) for value in scale_by_mode) / scale
|
|
40
|
+
status = "updated" if len(pairs) == structure.story_count else "updated_partial_modes"
|
|
41
|
+
return structure.with_update(
|
|
42
|
+
stiffness_scale=scale,
|
|
43
|
+
observed_frequencies_hz=[modal.modes[observed].frequency_hz for _, observed in pairs],
|
|
44
|
+
mode_scale_spread_pct=spread_pct,
|
|
45
|
+
status=status,
|
|
46
|
+
)
|
timoshenko/vibration.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Single-degree-of-freedom and proportional vibration primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from ._validation import positive as _positive
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def natural_frequency_hz(mass_kg: float, stiffness_n_m: float) -> float:
|
|
12
|
+
"""Undamped natural frequency ``sqrt(k/m)/(2*pi)`` for an SDOF system."""
|
|
13
|
+
m, k = _positive("mass_kg", mass_kg), _positive("stiffness_n_m", stiffness_n_m)
|
|
14
|
+
return math.sqrt(k / m) / (2.0 * math.pi)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def damping_ratio(mass_kg: float, stiffness_n_m: float, damping_n_s_m: float) -> float:
|
|
18
|
+
"""Viscous damping ratio ``c/(2*sqrt(k*m))``."""
|
|
19
|
+
m, k = _positive("mass_kg", mass_kg), _positive("stiffness_n_m", stiffness_n_m)
|
|
20
|
+
c = float(damping_n_s_m)
|
|
21
|
+
if not math.isfinite(c) or c < 0.0:
|
|
22
|
+
raise ValueError("damping_n_s_m must be finite and non-negative")
|
|
23
|
+
return c / (2.0 * math.sqrt(k * m))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class HarmonicResponse:
|
|
28
|
+
displacement_amplitude_m: float
|
|
29
|
+
phase_lag_rad: float
|
|
30
|
+
frequency_ratio: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RayleighDampingResult:
|
|
35
|
+
"""Two-frequency fit for mass- and stiffness-proportional viscous damping."""
|
|
36
|
+
|
|
37
|
+
frequency_1_hz: float
|
|
38
|
+
damping_ratio_1: float
|
|
39
|
+
frequency_2_hz: float
|
|
40
|
+
damping_ratio_2: float
|
|
41
|
+
alpha_mass_s_inv: float
|
|
42
|
+
beta_stiffness_s: float
|
|
43
|
+
|
|
44
|
+
def modal_damping_ratio(self, frequency_hz: float) -> float:
|
|
45
|
+
"""Evaluate the fitted Rayleigh damping ratio at a positive frequency."""
|
|
46
|
+
frequency = _positive("frequency_hz", frequency_hz)
|
|
47
|
+
omega = 2.0 * math.pi * frequency
|
|
48
|
+
ratio = 0.5 * (self.alpha_mass_s_inv / omega + self.beta_stiffness_s * omega)
|
|
49
|
+
if not math.isfinite(ratio):
|
|
50
|
+
raise ValueError("Rayleigh damping ratio is non-finite at this frequency")
|
|
51
|
+
return ratio
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, float | str]:
|
|
54
|
+
return {
|
|
55
|
+
"frequency_1_hz": self.frequency_1_hz,
|
|
56
|
+
"damping_ratio_1": self.damping_ratio_1,
|
|
57
|
+
"frequency_2_hz": self.frequency_2_hz,
|
|
58
|
+
"damping_ratio_2": self.damping_ratio_2,
|
|
59
|
+
"alpha_mass_s_inv": self.alpha_mass_s_inv,
|
|
60
|
+
"beta_stiffness_s": self.beta_stiffness_s,
|
|
61
|
+
"alpha_unit": "s^-1",
|
|
62
|
+
"beta_unit": "s",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def rayleigh_damping_coefficients(
|
|
67
|
+
frequency_1_hz: float,
|
|
68
|
+
damping_ratio_1: float,
|
|
69
|
+
frequency_2_hz: float,
|
|
70
|
+
damping_ratio_2: float,
|
|
71
|
+
) -> RayleighDampingResult:
|
|
72
|
+
"""Fit passive Rayleigh coefficients to two modal damping targets.
|
|
73
|
+
|
|
74
|
+
For ``C = alpha_M M + beta_K K``, modal damping is
|
|
75
|
+
``zeta(omega) = alpha_M/(2 omega) + beta_K omega/2``. Frequencies are
|
|
76
|
+
supplied in Hz and converted internally to angular frequency. Negative
|
|
77
|
+
fitted coefficients are rejected because they do not define a passive
|
|
78
|
+
two-term Rayleigh damping model over positive frequencies.
|
|
79
|
+
"""
|
|
80
|
+
f1, f2 = _positive("frequency_1_hz", frequency_1_hz), _positive("frequency_2_hz", frequency_2_hz)
|
|
81
|
+
z1, z2 = float(damping_ratio_1), float(damping_ratio_2)
|
|
82
|
+
if not math.isfinite(z1) or z1 < 0.0 or not math.isfinite(z2) or z2 < 0.0:
|
|
83
|
+
raise ValueError("target damping ratios must be finite and non-negative")
|
|
84
|
+
original = (f1, z1, f2, z2)
|
|
85
|
+
if f2 < f1:
|
|
86
|
+
f1, f2, z1, z2 = f2, f1, z2, z1
|
|
87
|
+
relative_gap = (f2 - f1) / f2
|
|
88
|
+
if relative_gap <= 1e-8:
|
|
89
|
+
raise ValueError("target frequencies must be distinct and sufficiently separated")
|
|
90
|
+
omega_1, omega_2 = 2.0 * math.pi * f1, 2.0 * math.pi * f2
|
|
91
|
+
if not math.isfinite(omega_1) or not math.isfinite(omega_2) or omega_1 <= 0.0 or omega_2 <= 0.0:
|
|
92
|
+
raise ValueError("target frequencies are outside the supported numerical range")
|
|
93
|
+
ratio = omega_1 / omega_2
|
|
94
|
+
denominator = 1.0 - ratio * ratio
|
|
95
|
+
alpha = 2.0 * omega_1 * (z1 - z2 * ratio) / denominator
|
|
96
|
+
beta = 2.0 * (z2 - z1 * ratio) / (omega_2 * denominator)
|
|
97
|
+
if not math.isfinite(alpha) or not math.isfinite(beta):
|
|
98
|
+
raise ValueError("Rayleigh coefficient fit produced a non-finite value")
|
|
99
|
+
alpha_tolerance = 1e-12 * max(omega_1 * z1, omega_2 * z2, math.ulp(0.0))
|
|
100
|
+
beta_tolerance = 1e-12 * max(z1 / omega_1, z2 / omega_2, math.ulp(0.0))
|
|
101
|
+
if alpha < -alpha_tolerance or beta < -beta_tolerance:
|
|
102
|
+
raise ValueError("these two targets require a negative coefficient and cannot be fit by passive Rayleigh damping")
|
|
103
|
+
alpha, beta = max(alpha, 0.0), max(beta, 0.0)
|
|
104
|
+
result = RayleighDampingResult(
|
|
105
|
+
frequency_1_hz=original[0],
|
|
106
|
+
damping_ratio_1=original[1],
|
|
107
|
+
frequency_2_hz=original[2],
|
|
108
|
+
damping_ratio_2=original[3],
|
|
109
|
+
alpha_mass_s_inv=alpha,
|
|
110
|
+
beta_stiffness_s=beta,
|
|
111
|
+
)
|
|
112
|
+
if not (
|
|
113
|
+
math.isclose(result.modal_damping_ratio(original[0]), original[1], rel_tol=1e-9, abs_tol=1e-12)
|
|
114
|
+
and math.isclose(result.modal_damping_ratio(original[2]), original[3], rel_tol=1e-9, abs_tol=1e-12)
|
|
115
|
+
):
|
|
116
|
+
raise ValueError("Rayleigh coefficient fit could not reproduce both targets at floating-point precision")
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def harmonic_response(force_amplitude_n: float, excitation_hz: float, mass_kg: float,
|
|
121
|
+
stiffness_n_m: float, damping_n_s_m: float) -> HarmonicResponse:
|
|
122
|
+
"""Steady-state displacement amplitude and phase for a harmonically forced SDOF."""
|
|
123
|
+
force = float(force_amplitude_n)
|
|
124
|
+
if not math.isfinite(force):
|
|
125
|
+
raise ValueError("force_amplitude_n must be finite")
|
|
126
|
+
excitation = float(excitation_hz)
|
|
127
|
+
if not math.isfinite(excitation) or excitation < 0.0:
|
|
128
|
+
raise ValueError("excitation_hz must be finite and non-negative")
|
|
129
|
+
m, k = _positive("mass_kg", mass_kg), _positive("stiffness_n_m", stiffness_n_m)
|
|
130
|
+
zeta = damping_ratio(m, k, damping_n_s_m)
|
|
131
|
+
omega_n = math.sqrt(k / m)
|
|
132
|
+
ratio = (2.0 * math.pi * excitation) / omega_n
|
|
133
|
+
denominator = math.hypot(1.0 - ratio**2, 2.0 * zeta * ratio)
|
|
134
|
+
if denominator == 0.0:
|
|
135
|
+
raise ValueError("undamped resonance has unbounded steady-state response")
|
|
136
|
+
amplitude = abs(force) / k / denominator
|
|
137
|
+
phase = math.atan2(2.0 * zeta * ratio, 1.0 - ratio**2)
|
|
138
|
+
return HarmonicResponse(amplitude, phase, ratio)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: timoshenko-engine
|
|
3
|
+
Version: 2.0.1
|
|
4
|
+
Summary: Composable structural engineering calculations and analysis primitives for Python applications.
|
|
5
|
+
Author: Ayberk Korkmaz
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/Ayberkrk/timoshenko
|
|
8
|
+
Project-URL: Repository, https://github.com/Ayberkrk/timoshenko
|
|
9
|
+
Project-URL: Issues, https://github.com/Ayberkrk/timoshenko/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/Ayberkrk/timoshenko/tree/main/docs
|
|
11
|
+
Keywords: civil engineering,structural engineering,structural health monitoring,modal analysis,digital twin
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: numpy>=1.24
|
|
25
|
+
Provides-Extra: mqtt
|
|
26
|
+
Requires-Dist: paho-mqtt<3,>=2.1; extra == "mqtt"
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
29
|
+
Provides-Extra: docs
|
|
30
|
+
Requires-Dist: mkdocs-material<10,>=9.5; extra == "docs"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
<img src="https://raw.githubusercontent.com/Ayberkrk/timoshenko/main/assets/timoshenko-logo.png" alt="Timoshenko Engine logo" width="920">
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
<h1 align="center">Timoshenko Engine</h1>
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
Reusable structural engineering building blocks for Python applications.
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<p align="center">
|
|
44
|
+
<img alt="Version 2.0.1" src="https://img.shields.io/badge/version-2.0.1-orange?style=for-the-badge">
|
|
45
|
+
<img alt="Alpha" src="https://img.shields.io/badge/stage-alpha-orange?style=for-the-badge">
|
|
46
|
+
<img alt="Python 3.10 to 3.13" src="https://img.shields.io/badge/python-3.10%20to%203.13-blue?style=for-the-badge">
|
|
47
|
+
<img alt="Apache 2.0 license" src="https://img.shields.io/badge/license-Apache--2.0-green?style=for-the-badge">
|
|
48
|
+
<a href="https://github.com/Ayberkrk/timoshenko/actions/workflows/tests.yml"><img alt="Tests" src="https://github.com/Ayberkrk/timoshenko/actions/workflows/tests.yml/badge.svg"></a>
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
Timoshenko packages common structural calculations, modal analysis, sensor
|
|
52
|
+
workflows, and monitoring components so applications can reuse them instead
|
|
53
|
+
of rebuilding the same foundations for every project. It is an embeddable
|
|
54
|
+
Python library and engine core, not a hosted monitoring service or a general
|
|
55
|
+
finite-element solver.
|
|
56
|
+
|
|
57
|
+
> **Alpha:** the API may still change between releases. Timoshenko is not yet
|
|
58
|
+
> published on PyPI; install it from GitHub or from a checkout as shown below.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
From GitHub:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python -m pip install "timoshenko-engine @ git+https://github.com/Ayberkrk/timoshenko"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or from the root of a checkout:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python -m pip install .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The distribution package is named `timoshenko-engine`. The Python import
|
|
75
|
+
package is named `timoshenko`:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import timoshenko as tm
|
|
79
|
+
print(tm.__version__)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
To install optional MQTT support, use:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
python -m pip install '.[mqtt]'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A future published release can be installed with
|
|
89
|
+
`python -m pip install timoshenko-engine`. That command is only usable after a
|
|
90
|
+
release is available from the selected package index.
|
|
91
|
+
|
|
92
|
+
## Quick start
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import timoshenko as tm
|
|
96
|
+
|
|
97
|
+
structure = tm.Structure(
|
|
98
|
+
structure_id="building-01",
|
|
99
|
+
story_masses_kg=[120_000.0, 110_000.0],
|
|
100
|
+
story_stiffness_n_m=[85_000_000.0, 70_000_000.0],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
sensors = tm.load_sensors(
|
|
104
|
+
"acceleration.csv",
|
|
105
|
+
sampling_hz=100.0,
|
|
106
|
+
column="acceleration_m_s2",
|
|
107
|
+
unit="m/s^2",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
modal = tm.modal.identify(sensors)
|
|
111
|
+
updated = tm.update(structure, modal)
|
|
112
|
+
health = tm.health.assess(structure=updated, observations=sensors)
|
|
113
|
+
|
|
114
|
+
print(health.to_dict())
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
For a one-shot pipeline, `tm.monitor(structure, sensors)` performs the same
|
|
118
|
+
analysis sequence and returns a serializable result. The model update applies
|
|
119
|
+
one global stiffness multiplier and keeps the analytical reference frequencies
|
|
120
|
+
separate from measured frequencies.
|
|
121
|
+
|
|
122
|
+
## What it provides
|
|
123
|
+
|
|
124
|
+
| Area | Reusable components |
|
|
125
|
+
|---|---|
|
|
126
|
+
| Structural models | Lumped-mass shear-building models and analytical natural frequencies |
|
|
127
|
+
| Modal analysis | Single-channel peak picking, damping estimates when resolvable, and multi-channel FDD with complex mode shapes |
|
|
128
|
+
| Model comparison | Log-frequency mode pairing, global stiffness updating, and evidence-oriented health assessment |
|
|
129
|
+
| Monitoring | Caller-fed bounded sessions, batch validation, source adapters, and optional plugins |
|
|
130
|
+
| Data workflow | Sensor CSV loading, project manifests, local SQLite history, and CSV replay |
|
|
131
|
+
| Reporting | Standalone HTML reports with an embedded SVG frequency comparison |
|
|
132
|
+
| Engineering calculations | Beam cases, section properties, mechanics, vibration, stability, stress, pressure, torsion, and uncertainty helpers |
|
|
133
|
+
|
|
134
|
+
Timoshenko can be used module by module or embedded in a larger product such as
|
|
135
|
+
Cauren. Sensor collection, application-specific risk rules, and engineering
|
|
136
|
+
interpretation remain with the integrating application.
|
|
137
|
+
|
|
138
|
+
## Multi-channel modal screening
|
|
139
|
+
|
|
140
|
+
FDD requires synchronized channels with a common sample rate and unit:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
signals = tm.load_multichannel_csv(
|
|
144
|
+
"aligned_accelerometers.csv",
|
|
145
|
+
columns=["deck_left", "deck_center", "deck_right"],
|
|
146
|
+
sampling_hz=100.0,
|
|
147
|
+
units=["m/s^2"] * 3,
|
|
148
|
+
)
|
|
149
|
+
fdd = tm.identify_fdd(signals, nperseg=1024, max_modes=5)
|
|
150
|
+
print(fdd.to_dict())
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
This first FDD implementation returns candidate frequencies and complex mode
|
|
154
|
+
shapes. It does not estimate damping or issue a damage or safety conclusion.
|
|
155
|
+
|
|
156
|
+
## Bounded monitoring session
|
|
157
|
+
|
|
158
|
+
A caller supplies timestamped observation batches. The session aligns samples,
|
|
159
|
+
waits for a fresh contiguous analysis window after gaps, and emits reports after
|
|
160
|
+
each configured hop:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
session = tm.MonitoringSession(
|
|
164
|
+
structure,
|
|
165
|
+
sensor_ids=["deck-left", "deck-right"],
|
|
166
|
+
units=["m/s^2", "m/s^2"],
|
|
167
|
+
sampling_hz=100.0,
|
|
168
|
+
window_samples=2048,
|
|
169
|
+
hop_samples=512,
|
|
170
|
+
analysis_options={"nperseg": 512, "max_modes": 4},
|
|
171
|
+
)
|
|
172
|
+
result = session.ingest(batch)
|
|
173
|
+
for report in result.reports:
|
|
174
|
+
tm.report.save_html(report, "reports/latest.html")
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
A gateway remains responsible for collecting data and handling transport
|
|
178
|
+
reconnection. Timoshenko does not run a background collector or select an alarm
|
|
179
|
+
policy.
|
|
180
|
+
|
|
181
|
+
## Further examples and documentation
|
|
182
|
+
|
|
183
|
+
- [Documentation home](docs/index.md)
|
|
184
|
+
- [Architecture](docs/architecture.md)
|
|
185
|
+
- [Data contract](docs/data-contract.md)
|
|
186
|
+
- [Historical CSV replay](docs/csv-source.md)
|
|
187
|
+
- [Plugin contract](docs/plugin-contract.md)
|
|
188
|
+
- [Rayleigh damping](docs/rayleigh-damping.md)
|
|
189
|
+
- [Section properties](docs/section-properties.md)
|
|
190
|
+
- [Polygon section properties](docs/polygon-sections.md)
|
|
191
|
+
- [Equation uncertainty](docs/uncertainty.md)
|
|
192
|
+
- [Numerical methods and limits](docs/numerical-methods.md)
|
|
193
|
+
- [Monitoring sessions](docs/live-sessions.md)
|
|
194
|
+
- [Source adapters](docs/adapters.md)
|
|
195
|
+
- [Project manifests](docs/project-manifest.md)
|
|
196
|
+
- [Local storage](docs/storage.md)
|
|
197
|
+
- [Reports](docs/reporting.md)
|
|
198
|
+
- [Optional MQTT adapter](docs/mqtt-adapter.md)
|
|
199
|
+
- [SensorThings adapter](docs/sensorthings-adapter.md)
|
|
200
|
+
- [Engineering calculations](examples/engineering_primitives.py)
|
|
201
|
+
- [Runnable examples](examples/)
|
|
202
|
+
- [Publishing and citation](docs/publishing.md)
|
|
203
|
+
|
|
204
|
+
## Limits and engineering posture
|
|
205
|
+
|
|
206
|
+
- Timoshenko is alpha software and is not a structural safety certification tool.
|
|
207
|
+
- Its shear-building model is a small lumped-mass reference model, not a full FEM solver.
|
|
208
|
+
- Modal identification is a screening estimate. A single sensor may miss a mode near a modal node.
|
|
209
|
+
- Damping is reported only when the averaged spectrum resolves the half-power bandwidth; otherwise it is `None`.
|
|
210
|
+
- FDD requires synchronized channels and does not estimate damping.
|
|
211
|
+
- The model update applies a single stiffness scale. It cannot locate or size local damage.
|
|
212
|
+
- A frequency shift is evidence for human review, not a damage verdict. Temperature, sensor placement, boundary conditions, and other effects can shift measurements.
|
|
213
|
+
- A monitoring session consumes data supplied by the caller. It does not implement a broker subscription, reconnect loop, scheduler, hosted dashboard, or alarm policy.
|
|
214
|
+
- Closed-form mechanics and section functions rely on their documented ideal assumptions. They are not code-compliance checks.
|
|
215
|
+
|
|
216
|
+
## Development
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
python -m pip install -e ".[test]"
|
|
220
|
+
python -m pytest
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
To build the documentation locally, install the optional documentation tools
|
|
224
|
+
and run MkDocs:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
python -m pip install -e ".[docs]"
|
|
228
|
+
python -m mkdocs serve
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The test suite checks analytical cases, input validation, adapters, storage,
|
|
232
|
+
monitoring sessions, and reports. Package distributions should be built and
|
|
233
|
+
installed in a clean environment before a release is uploaded.
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
Apache License 2.0. See [LICENSE](LICENSE).
|