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.
@@ -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
+ )
@@ -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).