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/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
+ )