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/oma.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Basic frequency-domain decomposition (FDD) for aligned output channels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .multichannel import MultiChannelData
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class FDDMode:
|
|
15
|
+
frequency_hz: float
|
|
16
|
+
singular_value: float
|
|
17
|
+
singular_value_index: int
|
|
18
|
+
channel_ids: tuple[str, ...]
|
|
19
|
+
units: tuple[str, ...]
|
|
20
|
+
shape_real: tuple[float, ...]
|
|
21
|
+
shape_imag: tuple[float, ...]
|
|
22
|
+
singular_value_ratio: float
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
return {
|
|
26
|
+
"frequency_hz": self.frequency_hz,
|
|
27
|
+
"singular_value": self.singular_value,
|
|
28
|
+
"singular_value_index": self.singular_value_index,
|
|
29
|
+
"channel_ids": list(self.channel_ids),
|
|
30
|
+
"units": list(self.units),
|
|
31
|
+
"mode_shape": [
|
|
32
|
+
{"real": real, "imag": imag} for real, imag in zip(self.shape_real, self.shape_imag)
|
|
33
|
+
],
|
|
34
|
+
"singular_value_ratio": self.singular_value_ratio,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class FDDResult:
|
|
40
|
+
modes: tuple[FDDMode, ...]
|
|
41
|
+
sampling_hz: float
|
|
42
|
+
sample_count: int
|
|
43
|
+
resolution_hz: float
|
|
44
|
+
segment_count: int
|
|
45
|
+
nperseg: int
|
|
46
|
+
channel_ids: tuple[str, ...]
|
|
47
|
+
units: tuple[str, ...]
|
|
48
|
+
method: str = "welch_frequency_domain_decomposition"
|
|
49
|
+
status: str = "ok"
|
|
50
|
+
notes: tuple[str, ...] = ()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def frequencies_hz(self) -> tuple[float, ...]:
|
|
54
|
+
return tuple(mode.frequency_hz for mode in self.modes)
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict:
|
|
57
|
+
return {
|
|
58
|
+
"modes": [mode.to_dict() for mode in self.modes],
|
|
59
|
+
"sampling_hz": self.sampling_hz,
|
|
60
|
+
"sample_count": self.sample_count,
|
|
61
|
+
"resolution_hz": self.resolution_hz,
|
|
62
|
+
"segment_count": self.segment_count,
|
|
63
|
+
"nperseg": self.nperseg,
|
|
64
|
+
"channel_ids": list(self.channel_ids),
|
|
65
|
+
"units": list(self.units),
|
|
66
|
+
"method": self.method,
|
|
67
|
+
"status": self.status,
|
|
68
|
+
"notes": list(self.notes),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def identify_fdd(
|
|
73
|
+
observations: MultiChannelData,
|
|
74
|
+
*,
|
|
75
|
+
nperseg: int | None = None,
|
|
76
|
+
overlap: float = 0.5,
|
|
77
|
+
max_modes: int = 8,
|
|
78
|
+
max_singular_values: int = 4,
|
|
79
|
+
min_frequency_hz: float | None = None,
|
|
80
|
+
max_frequency_hz: float | None = None,
|
|
81
|
+
min_peak_ratio: float = 0.05,
|
|
82
|
+
min_singular_value_ratio: float = 0.05,
|
|
83
|
+
) -> FDDResult:
|
|
84
|
+
"""Estimate modal frequencies and complex mode shapes with Welch FDD.
|
|
85
|
+
|
|
86
|
+
The cross-spectral-density matrix is estimated from mean-removed Hann
|
|
87
|
+
segments, then decomposed frequency-by-frequency. The largest singular
|
|
88
|
+
value curves are searched for local peaks. This is a first-pass FDD
|
|
89
|
+
estimator: it does not use stabilization diagrams, SSI, damping fits,
|
|
90
|
+
environmental normalization, or damage classification.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(observations, MultiChannelData):
|
|
93
|
+
raise TypeError("observations must be MultiChannelData; use tm.load_multichannel_csv() or construct it explicitly")
|
|
94
|
+
if len(set(observations.units)) != 1:
|
|
95
|
+
raise ValueError("FDD requires channels with the same measurement unit; calibrate/transform mixed-unit channels first")
|
|
96
|
+
sample_count, channel_count = observations.samples.shape
|
|
97
|
+
nperseg_value, hop, segment_count, resolution, low, high = _plan(
|
|
98
|
+
sample_count,
|
|
99
|
+
observations.sampling_hz,
|
|
100
|
+
nperseg=nperseg,
|
|
101
|
+
overlap=overlap,
|
|
102
|
+
max_modes=max_modes,
|
|
103
|
+
max_singular_values=max_singular_values,
|
|
104
|
+
min_frequency_hz=min_frequency_hz,
|
|
105
|
+
max_frequency_hz=max_frequency_hz,
|
|
106
|
+
min_peak_ratio=min_peak_ratio,
|
|
107
|
+
min_singular_value_ratio=min_singular_value_ratio,
|
|
108
|
+
)
|
|
109
|
+
starts = range(0, sample_count - nperseg_value + 1, hop)
|
|
110
|
+
|
|
111
|
+
values = observations.samples
|
|
112
|
+
window = np.hanning(nperseg_value)
|
|
113
|
+
window_power = float(np.sum(window**2))
|
|
114
|
+
frequencies = np.fft.rfftfreq(nperseg_value, d=1.0 / observations.sampling_hz)
|
|
115
|
+
frequency_count = len(frequencies)
|
|
116
|
+
retained = min(int(max_singular_values), channel_count)
|
|
117
|
+
spectral_matrices = np.zeros((frequency_count, channel_count, channel_count), dtype=np.complex128)
|
|
118
|
+
for start in starts:
|
|
119
|
+
segment = values[start : start + nperseg_value]
|
|
120
|
+
segment = segment - np.mean(segment, axis=0, keepdims=True)
|
|
121
|
+
spectrum = np.fft.rfft(segment * window[:, None], axis=0)
|
|
122
|
+
spectral_matrices += np.einsum("fc,fd->fcd", spectrum, np.conjugate(spectrum), optimize=True)
|
|
123
|
+
spectral_matrices /= segment_count * observations.sampling_hz * window_power
|
|
124
|
+
if nperseg_value % 2 == 0:
|
|
125
|
+
spectral_matrices[1:-1] *= 2.0
|
|
126
|
+
else:
|
|
127
|
+
spectral_matrices[1:] *= 2.0
|
|
128
|
+
|
|
129
|
+
singular = np.zeros((frequency_count, retained), dtype=float)
|
|
130
|
+
vectors = np.zeros((frequency_count, channel_count, retained), dtype=np.complex128)
|
|
131
|
+
for idx in range(frequency_count):
|
|
132
|
+
eigenvalues, eigenvectors = np.linalg.eigh(spectral_matrices[idx])
|
|
133
|
+
order = np.argsort(eigenvalues)[::-1][:retained]
|
|
134
|
+
singular[idx, :] = np.maximum(eigenvalues[order].real, 0.0)
|
|
135
|
+
vectors[idx, :, :] = eigenvectors[:, order]
|
|
136
|
+
|
|
137
|
+
in_band = (frequencies >= low) & (frequencies <= high)
|
|
138
|
+
leading_peak = float(np.max(singular[in_band, 0])) if np.any(in_band) else 0.0
|
|
139
|
+
candidates: list[tuple[float, int, int]] = []
|
|
140
|
+
for rank in range(retained):
|
|
141
|
+
curve = singular[:, rank]
|
|
142
|
+
curve_max = float(np.max(curve[in_band])) if np.any(in_band) else 0.0
|
|
143
|
+
if curve_max <= 0.0:
|
|
144
|
+
continue
|
|
145
|
+
if rank > 0 and leading_peak > 0.0 and curve_max / leading_peak < min_singular_value_ratio:
|
|
146
|
+
continue
|
|
147
|
+
for idx in range(1, frequency_count - 1):
|
|
148
|
+
rank_ratio = float(curve[idx] / singular[idx, 0]) if singular[idx, 0] > 0.0 else 0.0
|
|
149
|
+
if (
|
|
150
|
+
in_band[idx]
|
|
151
|
+
and curve[idx] >= curve[idx - 1]
|
|
152
|
+
and curve[idx] > curve[idx + 1]
|
|
153
|
+
and curve[idx] >= curve_max * min_peak_ratio
|
|
154
|
+
and rank_ratio >= min_singular_value_ratio
|
|
155
|
+
):
|
|
156
|
+
candidates.append((float(curve[idx]), rank, idx))
|
|
157
|
+
candidates.sort(reverse=True)
|
|
158
|
+
chosen: list[tuple[float, int, int]] = []
|
|
159
|
+
for candidate in candidates:
|
|
160
|
+
_, rank, idx = candidate
|
|
161
|
+
if all(abs(float(frequencies[idx] - frequencies[other_idx])) >= 2.0 * resolution for _, _, other_idx in chosen):
|
|
162
|
+
chosen.append(candidate)
|
|
163
|
+
if len(chosen) >= max_modes:
|
|
164
|
+
break
|
|
165
|
+
chosen.sort(key=lambda item: float(frequencies[item[2]]))
|
|
166
|
+
|
|
167
|
+
modes: list[FDDMode] = []
|
|
168
|
+
for value, rank, idx in chosen:
|
|
169
|
+
shape = vectors[idx, :, rank]
|
|
170
|
+
magnitude = np.abs(shape)
|
|
171
|
+
maximum = float(np.max(magnitude))
|
|
172
|
+
if maximum > 0.0:
|
|
173
|
+
shape = shape / maximum
|
|
174
|
+
phase_reference = int(np.argmax(np.abs(shape)))
|
|
175
|
+
shape = shape * np.exp(-1j * np.angle(shape[phase_reference]))
|
|
176
|
+
modes.append(FDDMode(
|
|
177
|
+
frequency_hz=float(frequencies[idx]),
|
|
178
|
+
singular_value=value,
|
|
179
|
+
singular_value_index=rank + 1,
|
|
180
|
+
channel_ids=tuple(observations.channel_ids),
|
|
181
|
+
units=tuple(observations.units),
|
|
182
|
+
shape_real=tuple(float(item.real) for item in shape),
|
|
183
|
+
shape_imag=tuple(float(item.imag) for item in shape),
|
|
184
|
+
singular_value_ratio=float(value / singular[idx, 0]) if singular[idx, 0] > 0.0 else 0.0,
|
|
185
|
+
))
|
|
186
|
+
notes = (
|
|
187
|
+
"Frequency resolution is sampling_hz / nperseg; longer windows improve resolution but reduce the number of averages.",
|
|
188
|
+
"Mode-shape components are normalized to unit peak magnitude; their overall scale and phase are arbitrary.",
|
|
189
|
+
"Peaks from different singular-value curves can be missed or duplicated for close/repeated modes; review results against engineering context.",
|
|
190
|
+
)
|
|
191
|
+
return FDDResult(
|
|
192
|
+
modes=tuple(modes),
|
|
193
|
+
sampling_hz=observations.sampling_hz,
|
|
194
|
+
sample_count=int(sample_count),
|
|
195
|
+
resolution_hz=resolution,
|
|
196
|
+
segment_count=segment_count,
|
|
197
|
+
nperseg=nperseg_value,
|
|
198
|
+
channel_ids=tuple(observations.channel_ids),
|
|
199
|
+
units=tuple(observations.units),
|
|
200
|
+
status="ok" if modes else "no_peaks_found",
|
|
201
|
+
notes=notes,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def validate_options(sample_count: int, sampling_hz: float, **options) -> None:
|
|
206
|
+
"""Check ``identify_fdd`` options for a record length without any data.
|
|
207
|
+
|
|
208
|
+
Raises the same errors ``identify_fdd`` would raise for these options, so
|
|
209
|
+
long-running callers can reject a bad configuration up front.
|
|
210
|
+
"""
|
|
211
|
+
_plan(int(sample_count), float(sampling_hz), **options)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _plan(
|
|
215
|
+
sample_count: int,
|
|
216
|
+
sampling_hz: float,
|
|
217
|
+
*,
|
|
218
|
+
nperseg: int | None = None,
|
|
219
|
+
overlap: float = 0.5,
|
|
220
|
+
max_modes: int = 8,
|
|
221
|
+
max_singular_values: int = 4,
|
|
222
|
+
min_frequency_hz: float | None = None,
|
|
223
|
+
max_frequency_hz: float | None = None,
|
|
224
|
+
min_peak_ratio: float = 0.05,
|
|
225
|
+
min_singular_value_ratio: float = 0.05,
|
|
226
|
+
) -> tuple[int, int, int, float, float, float]:
|
|
227
|
+
"""Validate options and return segment length, hop, count, resolution and band."""
|
|
228
|
+
if max_modes < 1 or max_singular_values < 1:
|
|
229
|
+
raise ValueError("max_modes and max_singular_values must be positive")
|
|
230
|
+
if not 0.0 <= min_peak_ratio < 1.0:
|
|
231
|
+
raise ValueError("min_peak_ratio must be in [0, 1)")
|
|
232
|
+
if not math.isfinite(float(min_singular_value_ratio)) or not 0.0 <= float(min_singular_value_ratio) <= 1.0:
|
|
233
|
+
raise ValueError("min_singular_value_ratio must be finite and in [0, 1]")
|
|
234
|
+
overlap_value = float(overlap)
|
|
235
|
+
if not math.isfinite(overlap_value) or not 0.0 <= overlap_value <= 0.9:
|
|
236
|
+
raise ValueError("overlap must be finite and in [0, 0.9]")
|
|
237
|
+
if nperseg is None:
|
|
238
|
+
available = max(8, min(1024, int(sample_count * (1.0 - overlap_value))))
|
|
239
|
+
nperseg_value = 2 ** int(math.floor(math.log2(available)))
|
|
240
|
+
else:
|
|
241
|
+
nperseg_value = int(nperseg)
|
|
242
|
+
if nperseg_value < 8 or nperseg_value > 4096 or nperseg_value > sample_count:
|
|
243
|
+
raise ValueError("nperseg must be between 8 and min(sample_count, 4096)")
|
|
244
|
+
hop = max(1, int(round(nperseg_value * (1.0 - overlap_value))))
|
|
245
|
+
segment_count = len(range(0, sample_count - nperseg_value + 1, hop))
|
|
246
|
+
if segment_count < 2:
|
|
247
|
+
raise ValueError("at least two overlapping FFT segments are required; lower nperseg or overlap less")
|
|
248
|
+
resolution = sampling_hz / nperseg_value
|
|
249
|
+
low = float(min_frequency_hz) if min_frequency_hz is not None else resolution
|
|
250
|
+
high = float(max_frequency_hz) if max_frequency_hz is not None else sampling_hz / 2.0
|
|
251
|
+
high = min(high, sampling_hz / 2.0)
|
|
252
|
+
if not math.isfinite(low) or low < 0.0 or not math.isfinite(high) or high <= low:
|
|
253
|
+
raise ValueError("frequency bounds must be finite and non-negative, with max greater than min")
|
|
254
|
+
return nperseg_value, hop, segment_count, resolution, low, high
|
timoshenko/plugins.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Explicit, versioned discovery of optional observation-source plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import entry_points
|
|
6
|
+
import inspect
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Callable, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
from .adapters import ObservationSource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
PLUGIN_API_VERSION = "1"
|
|
14
|
+
ENTRY_POINT_GROUP = "timoshenko.plugins"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PluginError(RuntimeError):
|
|
18
|
+
"""Base exception for invalid or incompatible engine plugins."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PluginCompatibilityError(PluginError):
|
|
22
|
+
"""Raised when a plugin targets a different plugin API version."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PluginLoadError(PluginError):
|
|
26
|
+
"""Raised when installed plugin code cannot be loaded or registered."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class EnginePlugin(Protocol):
|
|
31
|
+
name: str
|
|
32
|
+
api_version: str
|
|
33
|
+
|
|
34
|
+
def register(self, registry: "PluginContributions") -> None: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PluginContributions:
|
|
38
|
+
"""A staging area; contributions become visible only after registration succeeds."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._source_factories: dict[str, Callable[..., ObservationSource]] = {}
|
|
42
|
+
|
|
43
|
+
def register_source(self, name: str, factory: Callable[..., ObservationSource]) -> None:
|
|
44
|
+
key = _normalize_name(name, "source")
|
|
45
|
+
if not callable(factory):
|
|
46
|
+
raise TypeError("source factory must be callable")
|
|
47
|
+
if key in self._source_factories:
|
|
48
|
+
raise PluginError(f"source factory {key!r} was registered more than once by this plugin")
|
|
49
|
+
self._source_factories[key] = factory
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PluginRegistry:
|
|
53
|
+
"""Registry for optional adapter/source factories supplied by plugins.
|
|
54
|
+
|
|
55
|
+
Entry points are loaded only when ``load_entry_points`` is explicitly
|
|
56
|
+
called. Loading executes installed Python code in this process; this is
|
|
57
|
+
discovery/version checking, not a sandbox.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
self._source_factories: dict[str, Callable[..., ObservationSource]] = {}
|
|
62
|
+
self._plugins: dict[str, str] = {}
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def installed_plugins(self) -> tuple[str, ...]:
|
|
66
|
+
return tuple(sorted(self._plugins))
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def source_names(self) -> tuple[str, ...]:
|
|
70
|
+
return tuple(sorted(self._source_factories))
|
|
71
|
+
|
|
72
|
+
def register(self, plugin: EnginePlugin) -> None:
|
|
73
|
+
name = _normalize_name(getattr(plugin, "name", ""), "plugin")
|
|
74
|
+
api_version = str(getattr(plugin, "api_version", ""))
|
|
75
|
+
callback = getattr(plugin, "register", None)
|
|
76
|
+
if not callable(callback):
|
|
77
|
+
raise PluginError("plugin must provide a callable register(registry) method")
|
|
78
|
+
if api_version != PLUGIN_API_VERSION:
|
|
79
|
+
raise PluginCompatibilityError(
|
|
80
|
+
f"plugin {name!r} targets API {api_version!r}; this engine supports {PLUGIN_API_VERSION!r}"
|
|
81
|
+
)
|
|
82
|
+
if name in self._plugins:
|
|
83
|
+
raise PluginError(f"plugin {name!r} is already registered")
|
|
84
|
+
|
|
85
|
+
staged = PluginContributions()
|
|
86
|
+
try:
|
|
87
|
+
callback(staged)
|
|
88
|
+
except Exception as error:
|
|
89
|
+
raise PluginLoadError(f"plugin {name!r} failed during registration: {error}") from error
|
|
90
|
+
collisions = sorted(set(staged._source_factories) & set(self._source_factories))
|
|
91
|
+
if collisions:
|
|
92
|
+
raise PluginError(f"plugin {name!r} conflicts with registered source name(s): {', '.join(collisions)}")
|
|
93
|
+
self._source_factories.update(staged._source_factories)
|
|
94
|
+
self._plugins[name] = api_version
|
|
95
|
+
|
|
96
|
+
def create_source(self, name: str, **configuration: Any) -> ObservationSource:
|
|
97
|
+
key = _normalize_name(name, "source")
|
|
98
|
+
try:
|
|
99
|
+
factory = self._source_factories[key]
|
|
100
|
+
except KeyError:
|
|
101
|
+
raise KeyError(f"no source factory registered as {key!r}; available: {', '.join(self.source_names) or '(none)'}") from None
|
|
102
|
+
source = factory(**configuration)
|
|
103
|
+
if any(not callable(getattr(source, method, None)) for method in ("open", "read_batch", "close")):
|
|
104
|
+
raise PluginError(f"source factory {key!r} did not return an ObservationSource")
|
|
105
|
+
return source
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def load_entry_points(cls, *, group: str = ENTRY_POINT_GROUP) -> "PluginRegistry":
|
|
109
|
+
"""Load plugins advertised in package metadata; failures are explicit."""
|
|
110
|
+
registry = cls()
|
|
111
|
+
for point in sorted(entry_points(group=group), key=lambda item: item.name):
|
|
112
|
+
try:
|
|
113
|
+
exported = point.load()
|
|
114
|
+
if inspect.isclass(exported) or (callable(exported) and not callable(getattr(exported, "register", None))):
|
|
115
|
+
exported = exported()
|
|
116
|
+
registry.register(exported)
|
|
117
|
+
except Exception as error:
|
|
118
|
+
if isinstance(error, PluginLoadError):
|
|
119
|
+
raise PluginLoadError(f"entry point {point.name!r}: {error}") from error
|
|
120
|
+
raise PluginLoadError(f"could not load entry point {point.name!r}: {error}") from error
|
|
121
|
+
return registry
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _normalize_name(value: Any, kind: str) -> str:
|
|
125
|
+
if not isinstance(value, str):
|
|
126
|
+
raise PluginError(f"{kind} name must be a string")
|
|
127
|
+
name = value.strip().lower()
|
|
128
|
+
if not re.fullmatch(r"[a-z][a-z0-9_.-]{0,62}", name):
|
|
129
|
+
raise PluginError(f"{kind} name must match [a-z][a-z0-9_.-]{{0,62}}")
|
|
130
|
+
return name
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = [
|
|
134
|
+
"ENTRY_POINT_GROUP",
|
|
135
|
+
"PLUGIN_API_VERSION",
|
|
136
|
+
"EnginePlugin",
|
|
137
|
+
"PluginCompatibilityError",
|
|
138
|
+
"PluginContributions",
|
|
139
|
+
"PluginError",
|
|
140
|
+
"PluginLoadError",
|
|
141
|
+
"PluginRegistry",
|
|
142
|
+
]
|
timoshenko/polygon.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Analytic area properties for simple polygonal cross-sections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import math
|
|
7
|
+
from typing import Iterable, Sequence
|
|
8
|
+
|
|
9
|
+
Point2D = tuple[float, float]
|
|
10
|
+
Ring = Sequence[Point2D]
|
|
11
|
+
_MAX_VERTICES = 512
|
|
12
|
+
_MAX_SPAN_M = 1e50
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PolygonSectionProperties:
|
|
17
|
+
"""Geometric properties of a uniform polygonal section (SI units).
|
|
18
|
+
|
|
19
|
+
``second_moment_x_m4`` is about the centroidal x-axis (integral of y²),
|
|
20
|
+
``second_moment_y_m4`` is about the centroidal y-axis (integral of x²),
|
|
21
|
+
and ``product_moment_xy_m4`` is the centroidal integral of x*y.
|
|
22
|
+
Section moduli distinguish the positive and negative sides because a
|
|
23
|
+
general polygon need not be symmetric about either centroidal axis.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
area_m2: float
|
|
27
|
+
centroid_x_m: float
|
|
28
|
+
centroid_y_m: float
|
|
29
|
+
second_moment_x_m4: float
|
|
30
|
+
second_moment_y_m4: float
|
|
31
|
+
product_moment_xy_m4: float
|
|
32
|
+
section_modulus_x_positive_m3: float
|
|
33
|
+
section_modulus_x_negative_m3: float
|
|
34
|
+
section_modulus_y_positive_m3: float
|
|
35
|
+
section_modulus_y_negative_m3: float
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def polygon_section(
|
|
39
|
+
outer: Ring,
|
|
40
|
+
*,
|
|
41
|
+
holes: Iterable[Ring] = (),
|
|
42
|
+
) -> PolygonSectionProperties:
|
|
43
|
+
"""Calculate area, centroid, second moments, product moment and moduli.
|
|
44
|
+
|
|
45
|
+
Coordinates are ``(x, y)`` pairs in metres. The outer ring must describe a
|
|
46
|
+
simple boundary; each optional hole must be a simple ring strictly inside
|
|
47
|
+
it. Ring winding and explicit closure (repeating the first point) do not
|
|
48
|
+
affect the result. Up to 512 total vertices are accepted.
|
|
49
|
+
|
|
50
|
+
This computes uniform-density geometric properties only. It does not
|
|
51
|
+
resolve stress under coupled bending, principal-axis design checks,
|
|
52
|
+
material regions, plastic properties or torsion constants.
|
|
53
|
+
"""
|
|
54
|
+
hole_rings = list(holes)
|
|
55
|
+
rings = [_normalize_ring(outer, "outer")] + [
|
|
56
|
+
_normalize_ring(ring, f"holes[{index}]") for index, ring in enumerate(hole_rings)
|
|
57
|
+
]
|
|
58
|
+
if sum(map(len, rings)) > _MAX_VERTICES:
|
|
59
|
+
raise ValueError(f"outer and hole rings may contain at most {_MAX_VERTICES} total vertices")
|
|
60
|
+
|
|
61
|
+
origin_x, origin_y = rings[0][0]
|
|
62
|
+
shifted = [[(x - origin_x, y - origin_y) for x, y in ring] for ring in rings]
|
|
63
|
+
span = max(
|
|
64
|
+
max(x for ring in shifted for x, _ in ring) - min(x for ring in shifted for x, _ in ring),
|
|
65
|
+
max(y for ring in shifted for _, y in ring) - min(y for ring in shifted for _, y in ring),
|
|
66
|
+
)
|
|
67
|
+
if not math.isfinite(span) or span <= 0.0 or span > _MAX_SPAN_M:
|
|
68
|
+
raise ValueError("polygon coordinate span must be finite, non-zero, and no greater than 1e50 m")
|
|
69
|
+
|
|
70
|
+
area_tolerance = 1e-12 * span * span
|
|
71
|
+
length_tolerance = 1e-12 * span
|
|
72
|
+
for ring_index, ring in enumerate(shifted):
|
|
73
|
+
_ensure_simple(ring, area_tolerance, length_tolerance, f"ring {ring_index}")
|
|
74
|
+
if abs(_ring_integrals(ring)[0]) <= area_tolerance:
|
|
75
|
+
raise ValueError(f"ring {ring_index} must enclose a non-zero area")
|
|
76
|
+
_validate_holes(shifted, area_tolerance, length_tolerance)
|
|
77
|
+
|
|
78
|
+
area = first_x = first_y = inertia_x_origin = inertia_y_origin = product_origin = 0.0
|
|
79
|
+
for index, ring in enumerate(shifted):
|
|
80
|
+
values = _ring_integrals(ring)
|
|
81
|
+
signed_area = values[0]
|
|
82
|
+
role = 1.0 if index == 0 else -1.0
|
|
83
|
+
normalize = role if signed_area > 0.0 else -role
|
|
84
|
+
area += normalize * values[0]
|
|
85
|
+
first_x += normalize * values[1]
|
|
86
|
+
first_y += normalize * values[2]
|
|
87
|
+
inertia_x_origin += normalize * values[3]
|
|
88
|
+
inertia_y_origin += normalize * values[4]
|
|
89
|
+
product_origin += normalize * values[5]
|
|
90
|
+
|
|
91
|
+
if not math.isfinite(area) or area <= area_tolerance:
|
|
92
|
+
raise ValueError("outer ring and holes must leave a positive finite material area")
|
|
93
|
+
cx_local, cy_local = first_x / area, first_y / area
|
|
94
|
+
ixx = inertia_x_origin - area * cy_local**2
|
|
95
|
+
iyy = inertia_y_origin - area * cx_local**2
|
|
96
|
+
ixy = product_origin - area * cx_local * cy_local
|
|
97
|
+
# Roundoff can make a theoretically zero product moment tiny and negative
|
|
98
|
+
# inertias tiny around very slender geometries. Do not hide material errors.
|
|
99
|
+
if not all(math.isfinite(v) for v in (cx_local, cy_local, ixx, iyy, ixy)):
|
|
100
|
+
raise ValueError("polygon dimensions produced non-finite properties")
|
|
101
|
+
if ixx <= 0.0 or iyy <= 0.0:
|
|
102
|
+
raise ValueError("polygon dimensions produced non-positive centroidal second moments")
|
|
103
|
+
|
|
104
|
+
min_x, max_x = min(x for x, _ in shifted[0]), max(x for x, _ in shifted[0])
|
|
105
|
+
min_y, max_y = min(y for _, y in shifted[0]), max(y for _, y in shifted[0])
|
|
106
|
+
distances = (max_y - cy_local, cy_local - min_y, max_x - cx_local, cx_local - min_x)
|
|
107
|
+
if any(distance <= 0.0 or not math.isfinite(distance) for distance in distances):
|
|
108
|
+
raise ValueError("polygon section must have positive extreme-fibre distances on every side")
|
|
109
|
+
|
|
110
|
+
result_values = (
|
|
111
|
+
area,
|
|
112
|
+
origin_x + cx_local,
|
|
113
|
+
origin_y + cy_local,
|
|
114
|
+
ixx,
|
|
115
|
+
iyy,
|
|
116
|
+
ixy,
|
|
117
|
+
ixx / distances[0],
|
|
118
|
+
ixx / distances[1],
|
|
119
|
+
iyy / distances[2],
|
|
120
|
+
iyy / distances[3],
|
|
121
|
+
)
|
|
122
|
+
if not all(math.isfinite(value) for value in result_values):
|
|
123
|
+
raise ValueError("polygon dimensions produced non-finite section properties")
|
|
124
|
+
return PolygonSectionProperties(*result_values)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _normalize_ring(ring: Ring, name: str) -> list[Point2D]:
|
|
128
|
+
try:
|
|
129
|
+
points = []
|
|
130
|
+
for point in ring:
|
|
131
|
+
if len(point) != 2:
|
|
132
|
+
raise ValueError(f"{name} vertices must have exactly two coordinates")
|
|
133
|
+
points.append((float(point[0]), float(point[1])))
|
|
134
|
+
except (TypeError, ValueError, IndexError) as exc:
|
|
135
|
+
raise ValueError(f"{name} must be an iterable of finite (x, y) coordinate pairs") from exc
|
|
136
|
+
if len(points) > 1 and points[-1] == points[0]:
|
|
137
|
+
points.pop()
|
|
138
|
+
if len(points) < 3:
|
|
139
|
+
raise ValueError(f"{name} must contain at least three distinct vertices")
|
|
140
|
+
if any(not math.isfinite(x) or not math.isfinite(y) for x, y in points):
|
|
141
|
+
raise ValueError(f"{name} coordinates must be finite")
|
|
142
|
+
if any(points[index] == points[(index + 1) % len(points)] for index in range(len(points))):
|
|
143
|
+
raise ValueError(f"{name} must not contain repeated consecutive vertices")
|
|
144
|
+
return points
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ring_integrals(ring: Sequence[Point2D]) -> tuple[float, float, float, float, float, float]:
|
|
148
|
+
area2 = first_x6 = first_y6 = inertia_x12 = inertia_y12 = product24 = 0.0
|
|
149
|
+
for (x0, y0), (x1, y1) in zip(ring, (*ring[1:], ring[0])):
|
|
150
|
+
cross = x0 * y1 - x1 * y0
|
|
151
|
+
area2 += cross
|
|
152
|
+
first_x6 += (x0 + x1) * cross
|
|
153
|
+
first_y6 += (y0 + y1) * cross
|
|
154
|
+
inertia_x12 += (y0 * y0 + y0 * y1 + y1 * y1) * cross
|
|
155
|
+
inertia_y12 += (x0 * x0 + x0 * x1 + x1 * x1) * cross
|
|
156
|
+
product24 += (2*x0*y0 + x0*y1 + x1*y0 + 2*x1*y1) * cross
|
|
157
|
+
return area2/2, first_x6/6, first_y6/6, inertia_x12/12, inertia_y12/12, product24/24
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _ensure_simple(ring: Sequence[Point2D], area_tolerance: float, length_tolerance: float, label: str) -> None:
|
|
161
|
+
count = len(ring)
|
|
162
|
+
for index in range(count):
|
|
163
|
+
previous, current, following = ring[index - 1], ring[index], ring[(index + 1) % count]
|
|
164
|
+
incoming = (previous[0] - current[0], previous[1] - current[1])
|
|
165
|
+
outgoing = (following[0] - current[0], following[1] - current[1])
|
|
166
|
+
cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0]
|
|
167
|
+
dot = incoming[0] * outgoing[0] + incoming[1] * outgoing[1]
|
|
168
|
+
if abs(cross) <= area_tolerance and dot > length_tolerance**2:
|
|
169
|
+
raise ValueError(f"{label} must not contain a backtracking overlapping edge")
|
|
170
|
+
for first in range(count):
|
|
171
|
+
a, b = ring[first], ring[(first + 1) % count]
|
|
172
|
+
for second in range(first + 1, count):
|
|
173
|
+
if second == first or second == (first + 1) % count or (second + 1) % count == first:
|
|
174
|
+
continue
|
|
175
|
+
c, d = ring[second], ring[(second + 1) % count]
|
|
176
|
+
if _segments_intersect(a, b, c, d, area_tolerance, length_tolerance):
|
|
177
|
+
raise ValueError(f"{label} must not self-intersect")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _segments_intersect(
|
|
181
|
+
a: Point2D,
|
|
182
|
+
b: Point2D,
|
|
183
|
+
c: Point2D,
|
|
184
|
+
d: Point2D,
|
|
185
|
+
area_tolerance: float,
|
|
186
|
+
length_tolerance: float,
|
|
187
|
+
) -> bool:
|
|
188
|
+
def orient(p: Point2D, q: Point2D, r: Point2D) -> float:
|
|
189
|
+
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
|
|
190
|
+
|
|
191
|
+
def on_segment(p: Point2D, q: Point2D, r: Point2D) -> bool:
|
|
192
|
+
return (
|
|
193
|
+
min(p[0], r[0]) - length_tolerance <= q[0] <= max(p[0], r[0]) + length_tolerance
|
|
194
|
+
and min(p[1], r[1]) - length_tolerance <= q[1] <= max(p[1], r[1]) + length_tolerance
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
ab_c, ab_d, cd_a, cd_b = orient(a, b, c), orient(a, b, d), orient(c, d, a), orient(c, d, b)
|
|
198
|
+
if ((ab_c > area_tolerance and ab_d < -area_tolerance) or (ab_c < -area_tolerance and ab_d > area_tolerance)) and (
|
|
199
|
+
(cd_a > area_tolerance and cd_b < -area_tolerance) or (cd_a < -area_tolerance and cd_b > area_tolerance)
|
|
200
|
+
):
|
|
201
|
+
return True
|
|
202
|
+
return (
|
|
203
|
+
abs(ab_c) <= area_tolerance and on_segment(a, c, b)
|
|
204
|
+
or abs(ab_d) <= area_tolerance and on_segment(a, d, b)
|
|
205
|
+
or abs(cd_a) <= area_tolerance and on_segment(c, a, d)
|
|
206
|
+
or abs(cd_b) <= area_tolerance and on_segment(c, b, d)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _point_in_ring(point: Point2D, ring: Sequence[Point2D]) -> bool:
|
|
211
|
+
x, y = point
|
|
212
|
+
inside = False
|
|
213
|
+
previous = ring[-1]
|
|
214
|
+
for current in ring:
|
|
215
|
+
x0, y0 = previous
|
|
216
|
+
x1, y1 = current
|
|
217
|
+
if (y0 > y) != (y1 > y):
|
|
218
|
+
crossing_x = (x1 - x0) * (y - y0) / (y1 - y0) + x0
|
|
219
|
+
if x < crossing_x:
|
|
220
|
+
inside = not inside
|
|
221
|
+
previous = current
|
|
222
|
+
return inside
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _validate_holes(
|
|
226
|
+
rings: Sequence[Sequence[Point2D]], area_tolerance: float, length_tolerance: float
|
|
227
|
+
) -> None:
|
|
228
|
+
outer = rings[0]
|
|
229
|
+
holes = rings[1:]
|
|
230
|
+
for index, hole in enumerate(holes):
|
|
231
|
+
if not _point_in_ring(hole[0], outer):
|
|
232
|
+
raise ValueError(f"hole {index} must be strictly inside the outer ring")
|
|
233
|
+
if _rings_intersect(hole, outer, area_tolerance, length_tolerance):
|
|
234
|
+
raise ValueError(f"hole {index} must not touch or cross the outer ring")
|
|
235
|
+
for earlier_index, earlier in enumerate(holes[:index]):
|
|
236
|
+
if (
|
|
237
|
+
_rings_intersect(hole, earlier, area_tolerance, length_tolerance)
|
|
238
|
+
or _point_in_ring(hole[0], earlier)
|
|
239
|
+
or _point_in_ring(earlier[0], hole)
|
|
240
|
+
):
|
|
241
|
+
raise ValueError(f"holes {earlier_index} and {index} must not overlap or contain one another")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _rings_intersect(
|
|
245
|
+
first: Sequence[Point2D],
|
|
246
|
+
second: Sequence[Point2D],
|
|
247
|
+
area_tolerance: float,
|
|
248
|
+
length_tolerance: float,
|
|
249
|
+
) -> bool:
|
|
250
|
+
return any(
|
|
251
|
+
_segments_intersect(a, b, c, d, area_tolerance, length_tolerance)
|
|
252
|
+
for a, b in zip(first, (*first[1:], first[0]))
|
|
253
|
+
for c, d in zip(second, (*second[1:], second[0]))
|
|
254
|
+
)
|
timoshenko/pressure.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Thin-wall closed-cylinder membrane stress estimates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ThinWallCylinderResult:
|
|
11
|
+
hoop_stress_pa: float
|
|
12
|
+
longitudinal_stress_pa: float
|
|
13
|
+
thickness_to_mean_radius_ratio: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def thin_wall_cylinder_stress(pressure_difference_pa: float, mean_radius_m: float,
|
|
17
|
+
wall_thickness_m: float) -> ThinWallCylinderResult:
|
|
18
|
+
"""Estimate membrane stresses in a closed-end thin cylindrical vessel.
|
|
19
|
+
|
|
20
|
+
The input is the inside-minus-outside pressure. The approximation assumes
|
|
21
|
+
a circular, thin, closed-ended cylinder and membrane behavior; local end,
|
|
22
|
+
nozzle, instability, fatigue, code, and thick-wall effects are excluded.
|
|
23
|
+
"""
|
|
24
|
+
pressure, radius, thickness = map(float, (pressure_difference_pa, mean_radius_m, wall_thickness_m))
|
|
25
|
+
if not all(math.isfinite(v) for v in (pressure, radius, thickness)):
|
|
26
|
+
raise ValueError("inputs must be finite")
|
|
27
|
+
if radius <= 0.0 or thickness <= 0.0:
|
|
28
|
+
raise ValueError("mean radius and wall thickness must be positive")
|
|
29
|
+
return ThinWallCylinderResult(
|
|
30
|
+
hoop_stress_pa=pressure * radius / thickness,
|
|
31
|
+
longitudinal_stress_pa=pressure * radius / (2.0 * thickness),
|
|
32
|
+
thickness_to_mean_radius_ratio=thickness / radius,
|
|
33
|
+
)
|