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/__init__.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Timoshenko: composable structural engineering primitives."""
|
|
2
|
+
|
|
3
|
+
from . import adapters, assets, beams, csv_source, health, mechanics, modal, mqtt, observations, oma, plugins, polygon, pressure, project, report, sections, sensorthings, session, shafts, stability, storage, strength, uncertainty, vibration
|
|
4
|
+
from .assets import Asset, Relation
|
|
5
|
+
from .adapters import AcknowledgingObservationSource, ObservationSource, SessionRunner
|
|
6
|
+
from .csv_source import CSVObservationSource, CSVSourceError
|
|
7
|
+
from .plugins import (
|
|
8
|
+
ENTRY_POINT_GROUP,
|
|
9
|
+
PLUGIN_API_VERSION,
|
|
10
|
+
EnginePlugin,
|
|
11
|
+
PluginCompatibilityError,
|
|
12
|
+
PluginContributions,
|
|
13
|
+
PluginError,
|
|
14
|
+
PluginLoadError,
|
|
15
|
+
PluginRegistry,
|
|
16
|
+
)
|
|
17
|
+
from .beams import (
|
|
18
|
+
BeamDeflection,
|
|
19
|
+
cantilever_tip_load,
|
|
20
|
+
cantilever_uniform_load,
|
|
21
|
+
simply_supported_midpoint_load,
|
|
22
|
+
simply_supported_uniform_load,
|
|
23
|
+
)
|
|
24
|
+
from .health import HealthAssessment
|
|
25
|
+
from .mechanics import (
|
|
26
|
+
axial_strain,
|
|
27
|
+
axial_stress,
|
|
28
|
+
average_shear_stress,
|
|
29
|
+
bending_stress,
|
|
30
|
+
rectangular_max_shear_stress,
|
|
31
|
+
thermal_strain,
|
|
32
|
+
youngs_modulus_from_shear,
|
|
33
|
+
)
|
|
34
|
+
from .modal import ModalResult, Mode
|
|
35
|
+
from .mqtt import MQTTSourceError, MqttObservationSource
|
|
36
|
+
from .sensorthings import SensorThingsObservationSource, SensorThingsPlugin, SensorThingsSourceError
|
|
37
|
+
from .monitor import MonitoringResult, monitor
|
|
38
|
+
from .observations import Observation, ObservationBatch
|
|
39
|
+
from .multichannel import MultiChannelData, load_multichannel_csv
|
|
40
|
+
from .oma import FDDMode, FDDResult, identify_fdd
|
|
41
|
+
from .pressure import ThinWallCylinderResult, thin_wall_cylinder_stress
|
|
42
|
+
from .polygon import PolygonSectionProperties, polygon_section
|
|
43
|
+
from .project import LoadedProject, ProjectManifest, ProjectRunResult, load_project, read_manifest, run_project
|
|
44
|
+
from .storage import BatchAppendResult, SQLiteStore
|
|
45
|
+
from .session import MonitoringSession, SessionIngestResult, SessionReport, SessionRestoreResult
|
|
46
|
+
from .sections import SectionProperties
|
|
47
|
+
from .sections import circular_tube as circular_tube_section
|
|
48
|
+
from .sections import i_section
|
|
49
|
+
from .sections import rectangular_tube as rectangular_tube_section
|
|
50
|
+
from .sections import rectangle as rectangle_section
|
|
51
|
+
from .sections import solid_circle as solid_circle_section
|
|
52
|
+
from .sensors import SensorData, load_sensors
|
|
53
|
+
from .shafts import CircularTorsionResult, circular_shaft_torsion
|
|
54
|
+
from .structure import Structure
|
|
55
|
+
from .stability import euler_critical_load
|
|
56
|
+
from .stability import slenderness_ratio
|
|
57
|
+
from .strength import PlaneStressResult, plane_stress
|
|
58
|
+
from .update import update
|
|
59
|
+
from .uncertainty import UncertaintyError, UncertaintyResult, propagate as propagate_uncertainty
|
|
60
|
+
from .vibration import RayleighDampingResult, damping_ratio, harmonic_response, natural_frequency_hz, rayleigh_damping_coefficients
|
|
61
|
+
|
|
62
|
+
__version__ = "2.0.1"
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"HealthAssessment",
|
|
66
|
+
"Asset",
|
|
67
|
+
"BatchAppendResult",
|
|
68
|
+
"CSVObservationSource",
|
|
69
|
+
"CSVSourceError",
|
|
70
|
+
"UncertaintyError",
|
|
71
|
+
"UncertaintyResult",
|
|
72
|
+
"SensorThingsObservationSource",
|
|
73
|
+
"SensorThingsPlugin",
|
|
74
|
+
"SensorThingsSourceError",
|
|
75
|
+
"BeamDeflection",
|
|
76
|
+
"CircularTorsionResult",
|
|
77
|
+
"ModalResult",
|
|
78
|
+
"Mode",
|
|
79
|
+
"MonitoringSession",
|
|
80
|
+
"MqttObservationSource",
|
|
81
|
+
"MQTTSourceError",
|
|
82
|
+
"FDDMode",
|
|
83
|
+
"FDDResult",
|
|
84
|
+
"LoadedProject",
|
|
85
|
+
"MultiChannelData",
|
|
86
|
+
"MonitoringResult",
|
|
87
|
+
"Observation",
|
|
88
|
+
"ObservationBatch",
|
|
89
|
+
"ObservationSource",
|
|
90
|
+
"AcknowledgingObservationSource",
|
|
91
|
+
"EnginePlugin",
|
|
92
|
+
"ENTRY_POINT_GROUP",
|
|
93
|
+
"PLUGIN_API_VERSION",
|
|
94
|
+
"PluginCompatibilityError",
|
|
95
|
+
"PluginContributions",
|
|
96
|
+
"PluginError",
|
|
97
|
+
"PluginLoadError",
|
|
98
|
+
"PluginRegistry",
|
|
99
|
+
"SensorData",
|
|
100
|
+
"Structure",
|
|
101
|
+
"SectionProperties",
|
|
102
|
+
"ThinWallCylinderResult",
|
|
103
|
+
"RayleighDampingResult",
|
|
104
|
+
"PlaneStressResult",
|
|
105
|
+
"PolygonSectionProperties",
|
|
106
|
+
"ProjectManifest",
|
|
107
|
+
"ProjectRunResult",
|
|
108
|
+
"Relation",
|
|
109
|
+
"SQLiteStore",
|
|
110
|
+
"SessionIngestResult",
|
|
111
|
+
"SessionReport",
|
|
112
|
+
"SessionRestoreResult",
|
|
113
|
+
"SessionRunner",
|
|
114
|
+
"axial_strain",
|
|
115
|
+
"axial_stress",
|
|
116
|
+
"average_shear_stress",
|
|
117
|
+
"bending_stress",
|
|
118
|
+
"beams",
|
|
119
|
+
"csv_source",
|
|
120
|
+
"cantilever_tip_load",
|
|
121
|
+
"cantilever_uniform_load",
|
|
122
|
+
"circular_shaft_torsion",
|
|
123
|
+
"circular_tube_section",
|
|
124
|
+
"damping_ratio",
|
|
125
|
+
"euler_critical_load",
|
|
126
|
+
"harmonic_response",
|
|
127
|
+
"health",
|
|
128
|
+
"i_section",
|
|
129
|
+
"load_sensors",
|
|
130
|
+
"load_multichannel_csv",
|
|
131
|
+
"mechanics",
|
|
132
|
+
"modal",
|
|
133
|
+
"mqtt",
|
|
134
|
+
"monitor",
|
|
135
|
+
"natural_frequency_hz",
|
|
136
|
+
"identify_fdd",
|
|
137
|
+
"oma",
|
|
138
|
+
"observations",
|
|
139
|
+
"plane_stress",
|
|
140
|
+
"propagate_uncertainty",
|
|
141
|
+
"pressure",
|
|
142
|
+
"project",
|
|
143
|
+
"plugins",
|
|
144
|
+
"polygon",
|
|
145
|
+
"polygon_section",
|
|
146
|
+
"report",
|
|
147
|
+
"rectangle_section",
|
|
148
|
+
"rectangular_tube_section",
|
|
149
|
+
"rayleigh_damping_coefficients",
|
|
150
|
+
"rectangular_max_shear_stress",
|
|
151
|
+
"read_manifest",
|
|
152
|
+
"sections",
|
|
153
|
+
"sensorthings",
|
|
154
|
+
"session",
|
|
155
|
+
"shafts",
|
|
156
|
+
"simply_supported_midpoint_load",
|
|
157
|
+
"simply_supported_uniform_load",
|
|
158
|
+
"slenderness_ratio",
|
|
159
|
+
"solid_circle_section",
|
|
160
|
+
"stability",
|
|
161
|
+
"strength",
|
|
162
|
+
"thermal_strain",
|
|
163
|
+
"thin_wall_cylinder_stress",
|
|
164
|
+
"update",
|
|
165
|
+
"vibration",
|
|
166
|
+
"assets",
|
|
167
|
+
"adapters",
|
|
168
|
+
"storage",
|
|
169
|
+
"uncertainty",
|
|
170
|
+
"load_project",
|
|
171
|
+
"run_project",
|
|
172
|
+
"youngs_modulus_from_shear",
|
|
173
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared argument checks for the analytic formula modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def positive(name: str, value: float) -> float:
|
|
9
|
+
value = float(value)
|
|
10
|
+
if not math.isfinite(value) or value <= 0.0:
|
|
11
|
+
raise ValueError(f"{name} must be finite and greater than zero")
|
|
12
|
+
return value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def finite(name: str, value: float) -> float:
|
|
16
|
+
value = float(value)
|
|
17
|
+
if not math.isfinite(value):
|
|
18
|
+
raise ValueError(f"{name} must be finite")
|
|
19
|
+
return value
|
timoshenko/adapters.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Lifecycle contracts for caller-provided observation sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterator, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from .observations import ObservationBatch
|
|
8
|
+
from .session import MonitoringSession, SessionIngestResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class ObservationSource(Protocol):
|
|
13
|
+
"""Synchronous pull-source contract for gateways and replay adapters.
|
|
14
|
+
|
|
15
|
+
``open`` acquires the source, ``read_batch`` returns the next arrival-order
|
|
16
|
+
batch (or ``None`` at end of stream), and ``close`` releases resources.
|
|
17
|
+
Implementations own protocol timeouts, reconnect rules, and authentication.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def open(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
def read_batch(self) -> ObservationBatch | None: ...
|
|
23
|
+
|
|
24
|
+
def close(self) -> None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class AcknowledgingObservationSource(ObservationSource, Protocol):
|
|
29
|
+
"""Optional extension for sources that acknowledge only after ingestion."""
|
|
30
|
+
|
|
31
|
+
def acknowledge(self, batch: ObservationBatch, result: SessionIngestResult) -> None: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SessionRunner:
|
|
35
|
+
"""Drive one monitoring session from a source with explicit cleanup.
|
|
36
|
+
|
|
37
|
+
Use as a context manager so early loop exit and exceptions still close the
|
|
38
|
+
source. The runner does not retry reads or hide source errors.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
source: ObservationSource,
|
|
44
|
+
session: MonitoringSession,
|
|
45
|
+
*,
|
|
46
|
+
max_batches: int | None = None,
|
|
47
|
+
):
|
|
48
|
+
if not isinstance(session, MonitoringSession):
|
|
49
|
+
raise TypeError("session must be a MonitoringSession")
|
|
50
|
+
if any(not callable(getattr(source, name, None)) for name in ("open", "read_batch", "close")):
|
|
51
|
+
raise TypeError("source must provide callable open(), read_batch(), and close() methods")
|
|
52
|
+
if max_batches is not None and (isinstance(max_batches, bool) or int(max_batches) != max_batches or int(max_batches) < 1):
|
|
53
|
+
raise ValueError("max_batches must be a positive integer or None")
|
|
54
|
+
self.source = source
|
|
55
|
+
self.session = session
|
|
56
|
+
self.max_batches = None if max_batches is None else int(max_batches)
|
|
57
|
+
self._active = False
|
|
58
|
+
self._consuming = False
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> "SessionRunner":
|
|
61
|
+
if self._active:
|
|
62
|
+
raise RuntimeError("SessionRunner is already active")
|
|
63
|
+
try:
|
|
64
|
+
self.source.open()
|
|
65
|
+
except BaseException as open_error:
|
|
66
|
+
try:
|
|
67
|
+
self.source.close()
|
|
68
|
+
except BaseException as close_error:
|
|
69
|
+
if hasattr(open_error, "add_note"):
|
|
70
|
+
open_error.add_note(f"Source cleanup after open failure also failed: {close_error!r}")
|
|
71
|
+
raise
|
|
72
|
+
self._active = True
|
|
73
|
+
self._consuming = False
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(self, exc_type, exc, traceback) -> bool:
|
|
77
|
+
self._active = False
|
|
78
|
+
try:
|
|
79
|
+
self.source.close()
|
|
80
|
+
except BaseException as close_error:
|
|
81
|
+
if exc is None:
|
|
82
|
+
raise
|
|
83
|
+
if hasattr(exc, "add_note"):
|
|
84
|
+
exc.add_note(f"Closing the observation source also failed: {close_error!r}")
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def __iter__(self) -> Iterator[SessionIngestResult]:
|
|
88
|
+
if not self._active:
|
|
89
|
+
raise RuntimeError("use SessionRunner inside a 'with' block")
|
|
90
|
+
if self._consuming:
|
|
91
|
+
raise RuntimeError("SessionRunner supports one active iteration at a time")
|
|
92
|
+
self._consuming = True
|
|
93
|
+
try:
|
|
94
|
+
read_count = 0
|
|
95
|
+
while self.max_batches is None or read_count < self.max_batches:
|
|
96
|
+
batch = self.source.read_batch()
|
|
97
|
+
if batch is None:
|
|
98
|
+
return
|
|
99
|
+
if not isinstance(batch, ObservationBatch):
|
|
100
|
+
raise TypeError("ObservationSource.read_batch() must return ObservationBatch or None")
|
|
101
|
+
read_count += 1
|
|
102
|
+
result = self.session.ingest(batch)
|
|
103
|
+
acknowledge = getattr(self.source, "acknowledge", None)
|
|
104
|
+
if callable(acknowledge):
|
|
105
|
+
acknowledge(batch, result)
|
|
106
|
+
yield result
|
|
107
|
+
finally:
|
|
108
|
+
self._consuming = False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["AcknowledgingObservationSource", "ObservationSource", "SessionRunner"]
|
timoshenko/assets.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Minimal digital-twin asset and relation records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Asset:
|
|
11
|
+
asset_id: str
|
|
12
|
+
asset_type: str
|
|
13
|
+
name: str = ""
|
|
14
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
def __post_init__(self) -> None:
|
|
17
|
+
asset_id, asset_type = str(self.asset_id).strip(), str(self.asset_type).strip()
|
|
18
|
+
if not asset_id or not asset_type:
|
|
19
|
+
raise ValueError("asset_id and asset_type must be non-empty")
|
|
20
|
+
if not isinstance(self.metadata, Mapping):
|
|
21
|
+
raise ValueError("asset metadata must be a mapping")
|
|
22
|
+
object.__setattr__(self, "asset_id", asset_id)
|
|
23
|
+
object.__setattr__(self, "asset_type", asset_type)
|
|
24
|
+
object.__setattr__(self, "name", str(self.name))
|
|
25
|
+
object.__setattr__(self, "metadata", dict(self.metadata))
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
return {"asset_id": self.asset_id, "asset_type": self.asset_type, "name": self.name, "metadata": dict(self.metadata)}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Relation:
|
|
33
|
+
source_asset_id: str
|
|
34
|
+
relation_type: str
|
|
35
|
+
target_asset_id: str
|
|
36
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
source, kind, target = (str(item).strip() for item in (self.source_asset_id, self.relation_type, self.target_asset_id))
|
|
40
|
+
if not source or not kind or not target:
|
|
41
|
+
raise ValueError("relation source, type, and target must be non-empty")
|
|
42
|
+
if source == target:
|
|
43
|
+
raise ValueError("self-relations are not supported")
|
|
44
|
+
if not isinstance(self.metadata, Mapping):
|
|
45
|
+
raise ValueError("relation metadata must be a mapping")
|
|
46
|
+
object.__setattr__(self, "source_asset_id", source)
|
|
47
|
+
object.__setattr__(self, "relation_type", kind)
|
|
48
|
+
object.__setattr__(self, "target_asset_id", target)
|
|
49
|
+
object.__setattr__(self, "metadata", dict(self.metadata))
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"source_asset_id": self.source_asset_id,
|
|
54
|
+
"relation_type": self.relation_type,
|
|
55
|
+
"target_asset_id": self.target_asset_id,
|
|
56
|
+
"metadata": dict(self.metadata),
|
|
57
|
+
}
|
timoshenko/beams.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Closed-form small-deflection beam responses for common load cases.
|
|
2
|
+
|
|
3
|
+
Euler-Bernoulli bending deflection is always reported. If both shear modulus
|
|
4
|
+
and cross-sectional area are supplied, a first-order shear-deflection term is
|
|
5
|
+
also returned (constant ``kappa`` shear-correction approximation).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from ._validation import finite as _finite, positive as _positive
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class BeamDeflection:
|
|
17
|
+
bending_m: float
|
|
18
|
+
shear_m: float
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def total_m(self) -> float:
|
|
22
|
+
return self.bending_m + self.shear_m
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _response(bending: float, shear_force_factor: float, *, shear_modulus_pa: float | None,
|
|
26
|
+
area_m2: float | None, shear_correction: float) -> BeamDeflection:
|
|
27
|
+
if (shear_modulus_pa is None) != (area_m2 is None):
|
|
28
|
+
raise ValueError("provide both shear_modulus_pa and area_m2 to include shear deflection")
|
|
29
|
+
shear = 0.0
|
|
30
|
+
if shear_modulus_pa is not None and area_m2 is not None:
|
|
31
|
+
g = _positive("shear_modulus_pa", shear_modulus_pa)
|
|
32
|
+
a = _positive("area_m2", area_m2)
|
|
33
|
+
kappa = _positive("shear_correction", shear_correction)
|
|
34
|
+
shear = shear_force_factor / (kappa * g * a)
|
|
35
|
+
return BeamDeflection(float(bending), float(shear))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cantilever_tip_load(load_n: float, span_m: float, youngs_modulus_pa: float,
|
|
39
|
+
second_moment_m4: float, *, shear_modulus_pa: float | None = None,
|
|
40
|
+
area_m2: float | None = None, shear_correction: float = 5.0 / 6.0) -> BeamDeflection:
|
|
41
|
+
"""Tip deflection of a cantilever with a point load at its free end."""
|
|
42
|
+
p, length = _finite("load_n", load_n), _positive("span_m", span_m)
|
|
43
|
+
e, inertia = _positive("youngs_modulus_pa", youngs_modulus_pa), _positive("second_moment_m4", second_moment_m4)
|
|
44
|
+
return _response(p * length**3 / (3 * e * inertia), p * length,
|
|
45
|
+
shear_modulus_pa=shear_modulus_pa, area_m2=area_m2, shear_correction=shear_correction)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cantilever_uniform_load(load_n_per_m: float, span_m: float, youngs_modulus_pa: float,
|
|
49
|
+
second_moment_m4: float, *, shear_modulus_pa: float | None = None,
|
|
50
|
+
area_m2: float | None = None, shear_correction: float = 5.0 / 6.0) -> BeamDeflection:
|
|
51
|
+
"""Free-end deflection of a cantilever under a full-span uniform load."""
|
|
52
|
+
w, length = _finite("load_n_per_m", load_n_per_m), _positive("span_m", span_m)
|
|
53
|
+
e, inertia = _positive("youngs_modulus_pa", youngs_modulus_pa), _positive("second_moment_m4", second_moment_m4)
|
|
54
|
+
return _response(w * length**4 / (8 * e * inertia), w * length**2 / 2,
|
|
55
|
+
shear_modulus_pa=shear_modulus_pa, area_m2=area_m2, shear_correction=shear_correction)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def simply_supported_midpoint_load(load_n: float, span_m: float, youngs_modulus_pa: float,
|
|
59
|
+
second_moment_m4: float, *, shear_modulus_pa: float | None = None,
|
|
60
|
+
area_m2: float | None = None, shear_correction: float = 5.0 / 6.0) -> BeamDeflection:
|
|
61
|
+
"""Midspan deflection of a simply supported beam with a center point load."""
|
|
62
|
+
p, length = _finite("load_n", load_n), _positive("span_m", span_m)
|
|
63
|
+
e, inertia = _positive("youngs_modulus_pa", youngs_modulus_pa), _positive("second_moment_m4", second_moment_m4)
|
|
64
|
+
return _response(p * length**3 / (48 * e * inertia), p * length / 4,
|
|
65
|
+
shear_modulus_pa=shear_modulus_pa, area_m2=area_m2, shear_correction=shear_correction)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def simply_supported_uniform_load(load_n_per_m: float, span_m: float, youngs_modulus_pa: float,
|
|
69
|
+
second_moment_m4: float, *, shear_modulus_pa: float | None = None,
|
|
70
|
+
area_m2: float | None = None, shear_correction: float = 5.0 / 6.0) -> BeamDeflection:
|
|
71
|
+
"""Midspan deflection of a simply supported beam under full-span UDL."""
|
|
72
|
+
w, length = _finite("load_n_per_m", load_n_per_m), _positive("span_m", span_m)
|
|
73
|
+
e, inertia = _positive("youngs_modulus_pa", youngs_modulus_pa), _positive("second_moment_m4", second_moment_m4)
|
|
74
|
+
return _response(5 * w * length**4 / (384 * e * inertia), w * length**2 / 8,
|
|
75
|
+
shear_modulus_pa=shear_modulus_pa, area_m2=area_m2, shear_correction=shear_correction)
|
timoshenko/csv_source.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Bounded-memory CSV observation replay source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import math
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TextIO
|
|
10
|
+
|
|
11
|
+
from .observations import Observation, ObservationBatch
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CSVSourceError(ValueError):
|
|
15
|
+
"""Raised when a CSV file cannot be read as the configured observation schema."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_timestamp(value: str) -> float:
|
|
19
|
+
text = value.strip()
|
|
20
|
+
try:
|
|
21
|
+
result = float(text)
|
|
22
|
+
except ValueError:
|
|
23
|
+
iso_text = text[:-1] + "+00:00" if text.endswith(("Z", "z")) else text
|
|
24
|
+
try:
|
|
25
|
+
parsed = datetime.fromisoformat(iso_text)
|
|
26
|
+
except ValueError as error:
|
|
27
|
+
raise ValueError("timestamp must be Unix seconds or timezone-aware ISO-8601") from error
|
|
28
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
29
|
+
raise ValueError("ISO-8601 timestamps must include a timezone")
|
|
30
|
+
result = parsed.timestamp()
|
|
31
|
+
if not math.isfinite(result):
|
|
32
|
+
raise ValueError("timestamp must be finite")
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_quality(value: str) -> bool:
|
|
37
|
+
normalized = value.strip().lower()
|
|
38
|
+
if normalized in {"1", "true", "yes", "good", "valid"}:
|
|
39
|
+
return True
|
|
40
|
+
if normalized in {"0", "false", "no", "bad", "invalid"}:
|
|
41
|
+
return False
|
|
42
|
+
raise ValueError("quality must be one of true/false, 1/0, yes/no, good/bad, or valid/invalid")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CSVObservationSource:
|
|
46
|
+
"""Read long-format sensor CSV incrementally as :class:`ObservationBatch` objects.
|
|
47
|
+
|
|
48
|
+
The source never loads the whole file. Column names are explicit, event time
|
|
49
|
+
is required, and every emitted batch has a stable source/row-range identity.
|
|
50
|
+
Numeric timestamps mean Unix seconds; ISO-8601 timestamps must include a
|
|
51
|
+
timezone. All values are preserved in the CSV's declared unit.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
path: str | Path,
|
|
57
|
+
*,
|
|
58
|
+
source_id: str | None = None,
|
|
59
|
+
batch_size: int = 256,
|
|
60
|
+
timestamp_column: str = "timestamp",
|
|
61
|
+
sensor_id_column: str = "sensor_id",
|
|
62
|
+
name_column: str | None = "name",
|
|
63
|
+
unit_column: str = "unit",
|
|
64
|
+
value_column: str = "value",
|
|
65
|
+
quality_column: str | None = "quality",
|
|
66
|
+
asset_id_column: str | None = "asset_id",
|
|
67
|
+
):
|
|
68
|
+
size = int(batch_size)
|
|
69
|
+
if isinstance(batch_size, bool) or size != batch_size or not 1 <= size <= 4096:
|
|
70
|
+
raise ValueError("batch_size must be an integer between 1 and 4096")
|
|
71
|
+
columns = [timestamp_column, sensor_id_column, unit_column, value_column]
|
|
72
|
+
columns.extend(column for column in (name_column, quality_column, asset_id_column) if column is not None)
|
|
73
|
+
normalized = [str(column).strip() for column in columns]
|
|
74
|
+
if any(not column for column in normalized) or len(set(normalized)) != len(normalized):
|
|
75
|
+
raise ValueError("configured CSV column names must be non-empty and distinct")
|
|
76
|
+
self.path = Path(path)
|
|
77
|
+
if source_id is None:
|
|
78
|
+
source_value = f"csv:{self.path.name}"
|
|
79
|
+
else:
|
|
80
|
+
source_value = str(source_id).strip()
|
|
81
|
+
if not source_value:
|
|
82
|
+
raise ValueError("source_id must be non-empty")
|
|
83
|
+
self.source_id = source_value
|
|
84
|
+
self.batch_size = size
|
|
85
|
+
self.timestamp_column = str(timestamp_column).strip()
|
|
86
|
+
self.sensor_id_column = str(sensor_id_column).strip()
|
|
87
|
+
self.name_column = None if name_column is None else str(name_column).strip()
|
|
88
|
+
self.unit_column = str(unit_column).strip()
|
|
89
|
+
self.value_column = str(value_column).strip()
|
|
90
|
+
self.quality_column = None if quality_column is None else str(quality_column).strip()
|
|
91
|
+
self.asset_id_column = None if asset_id_column is None else str(asset_id_column).strip()
|
|
92
|
+
self._file: TextIO | None = None
|
|
93
|
+
self._reader: csv.DictReader | None = None
|
|
94
|
+
self._row_sequence = 0
|
|
95
|
+
|
|
96
|
+
def open(self) -> None:
|
|
97
|
+
if self._file is not None:
|
|
98
|
+
raise RuntimeError("CSV observation source is already open")
|
|
99
|
+
try:
|
|
100
|
+
file = self.path.open("r", newline="", encoding="utf-8-sig")
|
|
101
|
+
except OSError as error:
|
|
102
|
+
raise CSVSourceError(f"cannot open observation CSV {self.path}: {error}") from error
|
|
103
|
+
reader = csv.DictReader(file, strict=True)
|
|
104
|
+
headers = reader.fieldnames
|
|
105
|
+
if not headers or any(header is None or not header.strip() for header in headers):
|
|
106
|
+
file.close()
|
|
107
|
+
raise CSVSourceError("CSV must contain a header row with non-empty column names")
|
|
108
|
+
if len(set(headers)) != len(headers):
|
|
109
|
+
file.close()
|
|
110
|
+
raise CSVSourceError("CSV header contains duplicate column names")
|
|
111
|
+
required = {
|
|
112
|
+
self.timestamp_column,
|
|
113
|
+
self.sensor_id_column,
|
|
114
|
+
self.unit_column,
|
|
115
|
+
self.value_column,
|
|
116
|
+
}
|
|
117
|
+
required.update(column for column in (self.name_column, self.quality_column, self.asset_id_column) if column is not None)
|
|
118
|
+
missing = required - set(headers)
|
|
119
|
+
if missing:
|
|
120
|
+
file.close()
|
|
121
|
+
raise CSVSourceError(f"CSV is missing configured columns: {', '.join(sorted(missing))}")
|
|
122
|
+
self._file = file
|
|
123
|
+
self._reader = reader
|
|
124
|
+
self._row_sequence = 0
|
|
125
|
+
|
|
126
|
+
def read_batch(self) -> ObservationBatch | None:
|
|
127
|
+
if self._reader is None:
|
|
128
|
+
raise RuntimeError("open() must be called before read_batch()")
|
|
129
|
+
rows: list[Observation] = []
|
|
130
|
+
first_row_sequence = self._row_sequence + 1
|
|
131
|
+
while len(rows) < self.batch_size:
|
|
132
|
+
try:
|
|
133
|
+
row = next(self._reader)
|
|
134
|
+
except StopIteration:
|
|
135
|
+
break
|
|
136
|
+
except csv.Error as error:
|
|
137
|
+
raise CSVSourceError(f"malformed CSV near line {self._reader.line_num}: {error}") from error
|
|
138
|
+
line_number = self._reader.line_num
|
|
139
|
+
if None in row:
|
|
140
|
+
raise CSVSourceError(f"row near line {line_number} has more fields than the CSV header")
|
|
141
|
+
if all(value is None or not value.strip() for value in row.values()):
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
sensor_id = self._required(row, self.sensor_id_column)
|
|
145
|
+
unit = self._required(row, self.unit_column)
|
|
146
|
+
raw_name = sensor_id if self.name_column is None else self._required(row, self.name_column)
|
|
147
|
+
timestamp = _parse_timestamp(self._required(row, self.timestamp_column))
|
|
148
|
+
value = float(self._required(row, self.value_column))
|
|
149
|
+
quality = True if self.quality_column is None else _parse_quality(self._required(row, self.quality_column))
|
|
150
|
+
raw_asset_id = "" if self.asset_id_column is None else (row.get(self.asset_id_column) or "").strip()
|
|
151
|
+
observation = Observation(
|
|
152
|
+
sensor_id=sensor_id,
|
|
153
|
+
name=raw_name,
|
|
154
|
+
unit=unit,
|
|
155
|
+
value=value,
|
|
156
|
+
timestamp=timestamp,
|
|
157
|
+
quality=quality,
|
|
158
|
+
asset_id=raw_asset_id or None,
|
|
159
|
+
source_id=self.source_id,
|
|
160
|
+
metadata={"csv_line": line_number},
|
|
161
|
+
)
|
|
162
|
+
except (TypeError, ValueError) as error:
|
|
163
|
+
raise CSVSourceError(f"invalid observation near CSV line {line_number}: {error}") from error
|
|
164
|
+
rows.append(observation)
|
|
165
|
+
self._row_sequence += 1
|
|
166
|
+
if not rows:
|
|
167
|
+
return None
|
|
168
|
+
return ObservationBatch(
|
|
169
|
+
rows,
|
|
170
|
+
source_id=self.source_id,
|
|
171
|
+
batch_id=f"{self.source_id}:rows:{first_row_sequence}-{self._row_sequence}",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _required(row: dict[str | None, str | None], column: str) -> str:
|
|
176
|
+
value = row.get(column)
|
|
177
|
+
if value is None or not value.strip():
|
|
178
|
+
raise ValueError(f"{column!r} must not be empty")
|
|
179
|
+
return value.strip()
|
|
180
|
+
|
|
181
|
+
def close(self) -> None:
|
|
182
|
+
file, self._file, self._reader = self._file, None, None
|
|
183
|
+
if file is not None:
|
|
184
|
+
file.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = ["CSVObservationSource", "CSVSourceError"]
|