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 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)
@@ -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"]