natus-erd-reader 0.2.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.
natus_erd/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Lazy native reader for Natus NeuroWorks ERD recordings."""
2
+
3
+ from .errors import (
4
+ DataIntegrityError,
5
+ NatusERDError,
6
+ ResourceLimitError,
7
+ UnsupportedFormatError,
8
+ )
9
+ from .limits import ReadLimits
10
+ from .models import ChannelInfo, Event, RecordingInfo, ValidationReport
11
+ from .reader import NatusERDReader
12
+
13
+ __all__ = [
14
+ "ChannelInfo",
15
+ "DataIntegrityError",
16
+ "Event",
17
+ "NatusERDError",
18
+ "NatusERDReader",
19
+ "RecordingInfo",
20
+ "ReadLimits",
21
+ "ResourceLimitError",
22
+ "UnsupportedFormatError",
23
+ "ValidationReport",
24
+ ]
25
+
26
+ __version__ = "0.2.1"
natus_erd/_paths.py ADDED
@@ -0,0 +1,92 @@
1
+ """Bounded, non-recursive lookup of the real names in a recording directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from .binary import regular_file_size
9
+ from .errors import DataIntegrityError, UnsupportedFormatError
10
+ from .limits import DEFAULT_LIMITS, ReadLimits, check_limit
11
+
12
+
13
+ class RecordingDirectory:
14
+ """Snapshot directory names, not payloads; resolve only selected members.
15
+
16
+ Windows recordings often use mixed-case extensions. Use unique casefold
17
+ matches on every platform without guessing between colliding filenames.
18
+ The top-level path is still resolved by the operating system as supplied.
19
+ """
20
+
21
+ def __init__(self, directory: Path, *, limits: ReadLimits = DEFAULT_LIMITS) -> None:
22
+ self.directory = directory.resolve(strict=True)
23
+ if any(part.casefold() == "decimated" for part in self.directory.parts):
24
+ raise UnsupportedFormatError("The Decimated derivative is outside the supported scope")
25
+ self._names: dict[str, list[Path]] = {}
26
+ self._stc_names: list[str] = []
27
+ with os.scandir(self.directory) as entries:
28
+ for count, entry in enumerate(entries, 1):
29
+ check_limit(count, limits.max_directory_entries, "Recording directory entries")
30
+ self._names.setdefault(entry.name.casefold(), []).append(Path(entry.path))
31
+ # An unrelated ordinary backup.stc/ folder is not a recording
32
+ # candidate. Links remain candidates so boundary checks apply.
33
+ if entry.name.casefold().endswith(".stc") and not entry.is_dir(follow_symlinks=False):
34
+ self._stc_names.append(entry.name)
35
+
36
+ def lookup(self, name: str, *, optional: bool = False) -> Path | None:
37
+ matches = self._names.get(name.casefold(), ())
38
+ if len(matches) > 1:
39
+ raise DataIntegrityError("Ambiguous case-insensitive recording filename")
40
+ if not matches:
41
+ if optional:
42
+ return None
43
+ raise DataIntegrityError("Missing required recording file")
44
+ try:
45
+ resolved = matches[0].resolve(strict=True)
46
+ except (OSError, RuntimeError) as exc:
47
+ raise DataIntegrityError("Missing or inaccessible recording file") from exc
48
+ if resolved.parent != self.directory:
49
+ raise DataIntegrityError("Recording file resolves outside its directory")
50
+ regular_file_size(resolved)
51
+ return resolved
52
+
53
+ def stc_paths(self) -> tuple[Path, ...]:
54
+ paths: list[Path] = []
55
+ for name in self._stc_names:
56
+ path = self.lookup(name)
57
+ assert path is not None # Required lookup raises instead of returning None.
58
+ paths.append(path)
59
+ return tuple(paths)
60
+
61
+ def resolve_stc(self, source: Path) -> Path:
62
+ """Select one recording without scanning other records' metadata."""
63
+ if source.is_file():
64
+ suffix = source.suffix.casefold()
65
+ if suffix not in {".stc", ".eeg", ".erd"}:
66
+ raise ValueError("Expected a recording directory or EEG/STC/ERD file")
67
+ self.lookup(source.name) # Also reject case collisions on an explicit entry.
68
+ if suffix == ".stc":
69
+ return source
70
+ if suffix == ".eeg":
71
+ stc = self.lookup(source.stem + ".stc", optional=True)
72
+ if stc is None:
73
+ raise FileNotFoundError("No STC matches the supplied EEG file")
74
+ return stc
75
+ elif not source.is_dir():
76
+ raise ValueError("Expected a recording directory or EEG/STC/ERD file")
77
+
78
+ candidates = self.stc_paths()
79
+ if not candidates:
80
+ raise FileNotFoundError("No STC file found; pass the recording directory itself (discovery is not recursive)")
81
+ if len(candidates) != 1:
82
+ raise DataIntegrityError(
83
+ f"Expected one main STC file, found {len(candidates)}; pass the target STC or matching EEG file"
84
+ )
85
+ return candidates[0]
86
+
87
+
88
+ def resolve_recording(path: Path, *, limits: ReadLimits = DEFAULT_LIMITS) -> tuple[Path, RecordingDirectory]:
89
+ source = path.expanduser().resolve(strict=True)
90
+ directory = source if source.is_dir() else source.parent
91
+ files = RecordingDirectory(directory, limits=limits)
92
+ return files.resolve_stc(source), files
natus_erd/binary.py ADDED
@@ -0,0 +1,292 @@
1
+ """Binary structure parsing for NeuroWorks schema-9 recordings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from math import isfinite
7
+ import os
8
+ from pathlib import Path
9
+ import stat
10
+ from struct import Struct, unpack_from
11
+
12
+ from .errors import DataIntegrityError, UnsupportedFormatError
13
+ from .limits import DEFAULT_LIMITS, ReadLimits, check_limit
14
+
15
+ GENERIC_HEADER_SIZE = 352
16
+ ERD_HEADER_SIZE = 8656
17
+ STC_PREFIX_SIZE = 408
18
+ STC_ENTRY_SIZE = 272
19
+ ETC_ENTRY_SIZE = 16
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class GenericHeader:
24
+ file_schema: int
25
+ base_schema: int
26
+ creation_time_unix: int
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ErdHeader:
31
+ generic: GenericHeader
32
+ sample_rate: float
33
+ n_channels: int
34
+ delta_bits: int
35
+ physical_channels: tuple[int, ...]
36
+ headbox_types: tuple[int, int, int, int]
37
+ discard_bits: int
38
+ shorted: tuple[bool, ...]
39
+ frequency_factors: tuple[int, ...]
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class StcEntry:
44
+ index: int
45
+ segment_name: str
46
+ start_stamp: int
47
+ end_stamp: int
48
+ sample_number: int
49
+ stored_samples: int
50
+
51
+ @property
52
+ def logical_span(self) -> int:
53
+ """Elapsed sample positions, including gaps with no stored packets."""
54
+ return self.end_stamp - self.start_stamp + 1
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class StcFile:
59
+ generic: GenericHeader
60
+ next_segment: int
61
+ final: int
62
+ entries: tuple[StcEntry, ...]
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class EtcEntry:
67
+ index: int
68
+ offset: int
69
+ sample_stamp: int
70
+ sample_number: int
71
+ sample_span: int
72
+ unknown: int
73
+
74
+ @property
75
+ def end_stamp_exclusive(self) -> int:
76
+ return self.sample_stamp + self.sample_span
77
+
78
+
79
+ def regular_file_size(path: Path) -> int:
80
+ """Reject special files before opening them (e.g. named pipes/devices)."""
81
+ try:
82
+ info = path.stat()
83
+ except OSError as exc:
84
+ raise DataIntegrityError(f"Cannot stat {path.name}: {exc}") from exc
85
+ if not stat.S_ISREG(info.st_mode):
86
+ raise DataIntegrityError(f"Expected a regular file: {path.name}")
87
+ return info.st_size
88
+
89
+
90
+ def _read_file(path: Path, *, maximum: int) -> bytes:
91
+ """Check size before allocation and use a finite read even if a file grows."""
92
+ size = regular_file_size(path)
93
+ check_limit(size, maximum, "Metadata file bytes")
94
+ try:
95
+ with path.open("rb") as stream:
96
+ if os.fstat(stream.fileno()).st_size != size:
97
+ raise DataIntegrityError("Metadata changed while opening")
98
+ data = stream.read(size)
99
+ if len(data) != size or stream.read(1):
100
+ raise DataIntegrityError("Metadata changed or was truncated while reading")
101
+ except OSError as exc:
102
+ raise DataIntegrityError(f"Cannot read {path.name}: {exc}") from exc
103
+ return data
104
+
105
+
106
+ def check_generic_schema(header: GenericHeader, expected: int, context: str) -> None:
107
+ if header.file_schema != expected or header.base_schema != 1:
108
+ raise UnsupportedFormatError(
109
+ f"Unsupported {context} schema {header.file_schema}, base {header.base_schema}"
110
+ )
111
+
112
+
113
+ def _safe_segment_name(name: str) -> bool:
114
+ # Windows device/drive/alternate-stream names are unsafe even on POSIX.
115
+ if not name or name in {".", ".."} or name[-1] in " .":
116
+ return False
117
+ if any(char in '<>:"/\\|?*' or ord(char) < 32 for char in name):
118
+ return False
119
+ device = name.split(".", 1)[0].upper()
120
+ return device not in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"} and not (
121
+ len(device) == 4 and device[:3] in {"COM", "LPT"}
122
+ and device[3] in "123456789\u00b9\u00b2\u00b3"
123
+ )
124
+
125
+
126
+ def _generic_from_bytes(data: bytes, path: Path) -> GenericHeader:
127
+ if len(data) < GENERIC_HEADER_SIZE:
128
+ raise DataIntegrityError(
129
+ f"{path.name} is shorter than the {GENERIC_HEADER_SIZE}-byte header"
130
+ )
131
+ file_schema, base_schema = unpack_from("<HH", data, 16)
132
+ creation_time = unpack_from("<i", data, 20)[0]
133
+ return GenericHeader(file_schema, base_schema, creation_time)
134
+
135
+
136
+ def read_generic_header(path: Path) -> GenericHeader:
137
+ regular_file_size(path)
138
+ try:
139
+ with path.open("rb") as stream:
140
+ data = stream.read(GENERIC_HEADER_SIZE)
141
+ except OSError as exc:
142
+ raise DataIntegrityError(f"Cannot read {path.name}: {exc}") from exc
143
+ return _generic_from_bytes(data, path)
144
+
145
+
146
+ def read_erd_header(path: Path) -> ErdHeader:
147
+ regular_file_size(path)
148
+ try:
149
+ with path.open("rb") as stream:
150
+ data = stream.read(ERD_HEADER_SIZE)
151
+ except OSError as exc:
152
+ raise DataIntegrityError(f"Cannot read {path.name}: {exc}") from exc
153
+
154
+ if len(data) < ERD_HEADER_SIZE:
155
+ raise DataIntegrityError(
156
+ f"{path.name} is shorter than the {ERD_HEADER_SIZE}-byte ERD header"
157
+ )
158
+ generic = _generic_from_bytes(data, path)
159
+ check_generic_schema(generic, 9, "ERD")
160
+ sample_rate = unpack_from("<d", data, 352)[0]
161
+ n_channels, delta_bits = unpack_from("<ii", data, 360)
162
+ if not 1 <= n_channels <= 1024:
163
+ raise DataIntegrityError(
164
+ f"{path.name} declares an invalid channel count: {n_channels}"
165
+ )
166
+ if not isfinite(sample_rate) or sample_rate <= 0:
167
+ raise DataIntegrityError("ERD sample rate must be finite and positive")
168
+ physical = unpack_from(f"<{n_channels}i", data, 368)
169
+ headbox_values = unpack_from("<4i", data, 4464)
170
+ headbox_types: tuple[int, int, int, int] = (
171
+ int(headbox_values[0]),
172
+ int(headbox_values[1]),
173
+ int(headbox_values[2]),
174
+ int(headbox_values[3]),
175
+ )
176
+ discard_bits = unpack_from("<i", data, 4556)[0]
177
+ shorted_raw = unpack_from("<1024h", data, 4560)[:n_channels]
178
+ if any(value not in (0, 1) for value in shorted_raw):
179
+ raise DataIntegrityError("ERD shorted flags must be zero or one")
180
+ frequency_factors = unpack_from("<1024h", data, 6608)[:n_channels]
181
+ return ErdHeader(
182
+ generic=generic,
183
+ sample_rate=sample_rate,
184
+ n_channels=n_channels,
185
+ delta_bits=delta_bits,
186
+ physical_channels=tuple(physical),
187
+ headbox_types=headbox_types,
188
+ discard_bits=discard_bits,
189
+ shorted=tuple(bool(value) for value in shorted_raw),
190
+ frequency_factors=tuple(frequency_factors),
191
+ )
192
+
193
+
194
+ def read_stc(path: Path, *, limits: ReadLimits = DEFAULT_LIMITS) -> StcFile:
195
+ data = _read_file(
196
+ path, maximum=min(limits.max_metadata_bytes, STC_PREFIX_SIZE + limits.max_segments * STC_ENTRY_SIZE)
197
+ )
198
+ generic = _generic_from_bytes(data, path)
199
+ check_generic_schema(generic, 1, "STC")
200
+ if len(data) < STC_PREFIX_SIZE:
201
+ raise DataIntegrityError(f"{path.name} has a truncated STC prefix")
202
+ payload_size = len(data) - STC_PREFIX_SIZE
203
+ if payload_size % STC_ENTRY_SIZE:
204
+ raise DataIntegrityError(
205
+ f"{path.name} has {payload_size % STC_ENTRY_SIZE} trailing STC bytes"
206
+ )
207
+
208
+ next_segment, final = unpack_from("<ii", data, GENERIC_HEADER_SIZE)
209
+ entries: list[StcEntry] = []
210
+ names: set[str] = set()
211
+ for index, offset in enumerate(
212
+ range(STC_PREFIX_SIZE, len(data), STC_ENTRY_SIZE)
213
+ ):
214
+ raw_name = data[offset : offset + 256].split(b"\0", 1)[0]
215
+ try:
216
+ name = raw_name.decode("utf-8")
217
+ except UnicodeDecodeError as exc:
218
+ raise DataIntegrityError(
219
+ f"STC segment {index} has an invalid UTF-8 name"
220
+ ) from exc
221
+ if not _safe_segment_name(name):
222
+ raise DataIntegrityError(f"STC segment {index} has an unsafe name")
223
+ stem = name[:-4] if name.casefold().endswith(".erd") else name
224
+ if not _safe_segment_name(stem) or stem.casefold() in names:
225
+ raise DataIntegrityError(f"STC segment {index} has an unsafe or duplicate stem")
226
+ names.add(stem.casefold())
227
+ start, end, sample_number, stored_samples = unpack_from("<4i", data, offset + 256)
228
+ if end < start:
229
+ raise DataIntegrityError(
230
+ f"STC segment {index} has inconsistent stamp bounds"
231
+ )
232
+ if not 0 <= stored_samples <= end - start + 1:
233
+ raise DataIntegrityError(f"STC segment {index} has an invalid stored sample count")
234
+ if entries and start <= entries[-1].end_stamp:
235
+ raise DataIntegrityError(f"STC segments overlap at entry {index}")
236
+ entries.append(
237
+ StcEntry(index, name, start, end, sample_number, stored_samples)
238
+ )
239
+
240
+ if not entries:
241
+ raise DataIntegrityError(f"{path.name} contains no STC entries")
242
+ return StcFile(generic, next_segment, final, tuple(entries))
243
+
244
+
245
+ _ETC_STRUCT = Struct("<iiihh")
246
+
247
+
248
+ def read_etc(
249
+ path: Path, *, erd_size: int | None = None, limits: ReadLimits = DEFAULT_LIMITS
250
+ ) -> tuple[EtcEntry, ...]:
251
+ data = _read_file(
252
+ path, maximum=min(limits.max_metadata_bytes, GENERIC_HEADER_SIZE + limits.max_packets_per_segment * ETC_ENTRY_SIZE)
253
+ )
254
+ check_generic_schema(_generic_from_bytes(data, path), 3, "ETC")
255
+ payload_size = len(data) - GENERIC_HEADER_SIZE
256
+ if payload_size % ETC_ENTRY_SIZE:
257
+ raise DataIntegrityError(
258
+ f"{path.name} has {payload_size % ETC_ENTRY_SIZE} trailing ETC bytes"
259
+ )
260
+
261
+ entries: list[EtcEntry] = []
262
+ for index, offset in enumerate(
263
+ range(GENERIC_HEADER_SIZE, len(data), ETC_ENTRY_SIZE)
264
+ ):
265
+ data_offset, stamp, sample_number, span, unknown = _ETC_STRUCT.unpack_from(
266
+ data, offset
267
+ )
268
+ if span <= 0:
269
+ raise DataIntegrityError(f"ETC packet {index} has invalid span {span}")
270
+ if data_offset < ERD_HEADER_SIZE:
271
+ raise DataIntegrityError(
272
+ f"ETC packet {index} points inside the ERD header"
273
+ )
274
+ if erd_size is not None and data_offset >= erd_size:
275
+ raise DataIntegrityError(
276
+ f"ETC packet {index} offset is outside its ERD file"
277
+ )
278
+ if entries:
279
+ previous = entries[-1]
280
+ if data_offset <= previous.offset:
281
+ raise DataIntegrityError(f"ETC offsets are not increasing at {index}")
282
+ if stamp < previous.end_stamp_exclusive:
283
+ raise DataIntegrityError(f"ETC packets overlap at entry {index}")
284
+ expected_number = previous.sample_number + previous.sample_span
285
+ if sample_number != expected_number:
286
+ raise DataIntegrityError(
287
+ f"ETC sample numbers are discontinuous at entry {index}"
288
+ )
289
+ entries.append(
290
+ EtcEntry(index, data_offset, stamp, sample_number, span, unknown)
291
+ )
292
+ return tuple(entries)
natus_erd/decoder.py ADDED
@@ -0,0 +1,207 @@
1
+ """Bounded, incremental decoder for schema-9 compressed ERD packets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from numbers import Integral
7
+ from struct import unpack_from
8
+ from typing import BinaryIO, TYPE_CHECKING
9
+
10
+ from .errors import DataIntegrityError
11
+ from .limits import DEFAULT_LIMITS, ReadLimits, check_limit, check_output_size
12
+
13
+ if TYPE_CHECKING:
14
+ import numpy as np
15
+ from numpy.typing import NDArray
16
+
17
+
18
+ _READ_CHUNK_BYTES = 64 * 1024
19
+
20
+
21
+ def _integer(value: int, context: str) -> int:
22
+ if isinstance(value, bool) or not isinstance(value, Integral):
23
+ raise ValueError(f"{context} must be an integer")
24
+ return int(value)
25
+
26
+
27
+ def validate_packet_bounds(
28
+ *,
29
+ offset: int,
30
+ byte_end: int,
31
+ sample_count: int,
32
+ n_channels: int,
33
+ shorted: Sequence[bool],
34
+ limits: ReadLimits = DEFAULT_LIMITS,
35
+ ) -> None:
36
+ """Reject impossible or oversized packet spans without reading any bytes."""
37
+ offset = _integer(offset, "packet offset")
38
+ byte_end = _integer(byte_end, "packet end")
39
+ sample_count = _integer(sample_count, "packet sample count")
40
+ n_channels = _integer(n_channels, "packet channel count")
41
+ if offset < 0 or byte_end <= offset:
42
+ raise DataIntegrityError("ERD packet has an invalid byte range")
43
+ if not 1 <= sample_count <= 32767:
44
+ raise DataIntegrityError("ERD packet sample count must be between 1 and 32767")
45
+ if not 1 <= n_channels <= 1024:
46
+ raise DataIntegrityError("ERD packet channel count must be between 1 and 1024")
47
+ if len(shorted) != n_channels or any(value not in (False, True) for value in shorted):
48
+ raise DataIntegrityError("ERD shorted mask must contain one boolean per channel")
49
+
50
+ mask_bytes = (n_channels + 7) // 8
51
+ active = sum(not value for value in shorted)
52
+ maximum_sample_bytes = 1 + mask_bytes + 6 * active
53
+ minimum_bytes = maximum_sample_bytes + (sample_count - 1) * (1 + mask_bytes + active)
54
+ maximum_bytes = sample_count * maximum_sample_bytes
55
+ packet_bytes = byte_end - offset
56
+ if not minimum_bytes <= packet_bytes <= maximum_bytes:
57
+ raise DataIntegrityError(
58
+ "ERD packet byte span is impossible for its sample and channel counts"
59
+ )
60
+ check_limit(packet_bytes, limits.max_packet_bytes, "Compressed ERD packet bytes")
61
+
62
+
63
+ def decode_schema9_packet(
64
+ stream: BinaryIO,
65
+ *,
66
+ offset: int,
67
+ byte_end: int,
68
+ sample_count: int,
69
+ start: int,
70
+ stop: int,
71
+ n_channels: int,
72
+ shorted: Sequence[bool],
73
+ selected: Sequence[int],
74
+ limits: ReadLimits = DEFAULT_LIMITS,
75
+ out: NDArray[np.float64] | None = None,
76
+ ) -> NDArray[np.float64]:
77
+ """Decode a half-open sample window from an independently compressed packet.
78
+
79
+ Byte ranges and allocation budgets are checked before NumPy is imported.
80
+ Reads are at most 64 KiB; the compressed working buffer is bounded by a
81
+ chunk plus one sample. Only the prefix needed to reach stop is decoded.
82
+ A complete-packet read additionally checks exact payload consumption.
83
+ out permits writing directly into a caller-provided float64 array view.
84
+ """
85
+ validate_packet_bounds(
86
+ offset=offset, byte_end=byte_end, sample_count=sample_count,
87
+ n_channels=n_channels, shorted=shorted, limits=limits,
88
+ )
89
+ offset, byte_end = int(offset), int(byte_end)
90
+ sample_count, n_channels = int(sample_count), int(n_channels)
91
+ start, stop = _integer(start, "packet start"), _integer(stop, "packet stop")
92
+ if not 0 <= start <= stop <= sample_count:
93
+ raise ValueError("packet sample bounds are invalid")
94
+ check_output_size(len(selected), stop - start, limits)
95
+ selected_rows: list[tuple[int, int]] = []
96
+ for row, raw_channel in enumerate(selected):
97
+ channel = _integer(raw_channel, "selected channel")
98
+ if not 0 <= channel < n_channels:
99
+ raise ValueError(f"channel index is out of range: {channel}")
100
+ if not shorted[channel]:
101
+ selected_rows.append((row, channel))
102
+
103
+ import numpy as np
104
+
105
+ shape = (len(selected), stop - start)
106
+ if out is None:
107
+ output = np.full(shape, np.nan, dtype=np.float64)
108
+ else:
109
+ if not isinstance(out, np.ndarray) or out.shape != shape or out.dtype != np.dtype("float64"):
110
+ raise ValueError("out must be a float64 NumPy array with the requested shape")
111
+ if not out.flags.writeable:
112
+ raise ValueError("out must be writeable")
113
+ output = out
114
+ output.fill(np.nan)
115
+ if start == stop:
116
+ return output
117
+
118
+ mask_size = (n_channels + 7) // 8
119
+ active_layout = tuple(
120
+ (channel, channel >> 3, 1 << (channel & 7))
121
+ for channel in range(n_channels)
122
+ if not shorted[channel]
123
+ )
124
+ maximum_sample_bytes = 1 + mask_size + 6 * len(active_layout)
125
+ packet_bytes = byte_end - offset
126
+ # Limiting the prefix avoids reading an entire large packet for a tiny window.
127
+ read_limit = min(packet_bytes, stop * maximum_sample_bytes)
128
+ state = [0] * n_channels
129
+ payload = b""
130
+ position = 0
131
+ consumed_before_buffer = 0
132
+ bytes_read = 0
133
+ payload_length = 0
134
+ try:
135
+ stream.seek(offset)
136
+ except OSError as exc:
137
+ raise DataIntegrityError(f"Cannot seek to ERD packet: {exc}") from exc
138
+
139
+ def require(size: int, context: str) -> None:
140
+ if position + size > payload_length:
141
+ raise DataIntegrityError(
142
+ f"ERD packet ended while reading {context} at sample {sample_index}"
143
+ )
144
+
145
+ for sample_index in range(stop):
146
+ # Refill only between samples, leaving the tight channel loop free of I/O.
147
+ if payload_length - position < maximum_sample_bytes and bytes_read < read_limit:
148
+ tail = payload[position:]
149
+ consumed_before_buffer += position
150
+ position = 0
151
+ requested = min(_READ_CHUNK_BYTES, read_limit - bytes_read)
152
+ try:
153
+ block = stream.read(requested)
154
+ except OSError as exc:
155
+ raise DataIntegrityError(f"Cannot read ERD packet: {exc}") from exc
156
+ if len(block) != requested:
157
+ raise DataIntegrityError("ERD packet is truncated")
158
+ bytes_read += len(block)
159
+ payload = tail + block
160
+ payload_length = len(payload)
161
+
162
+ require(1 + mask_size, "event byte and delta mask")
163
+ event = payload[position]
164
+ position += 1
165
+ if event not in (0, 1):
166
+ raise DataIntegrityError(
167
+ f"Invalid ERD event byte 0x{event:02x} at sample {sample_index}"
168
+ )
169
+ mask_position = position
170
+ position += mask_size
171
+
172
+ absolute_channels: list[int] = []
173
+ for channel, mask_byte, mask_bit in active_layout:
174
+ wide = bool(payload[mask_position + mask_byte] & mask_bit)
175
+ if wide:
176
+ require(2, "16-bit delta")
177
+ value = payload[position] | (payload[position + 1] << 8)
178
+ position += 2
179
+ if value >= 0x8000:
180
+ value -= 0x10000
181
+ if value == -1:
182
+ absolute_channels.append(channel)
183
+ continue
184
+ else:
185
+ require(1, "8-bit delta")
186
+ value = payload[position]
187
+ position += 1
188
+ if value >= 0x80:
189
+ value -= 0x100
190
+ if sample_index == 0:
191
+ raise DataIntegrityError("The first sample of an ERD packet is not absolute")
192
+ state[channel] += value
193
+
194
+ for channel in absolute_channels:
195
+ require(4, "absolute channel value")
196
+ state[channel] = unpack_from("<i", payload, position)[0]
197
+ position += 4
198
+ if sample_index == 0 and len(absolute_channels) != len(active_layout):
199
+ raise DataIntegrityError("The first ERD sample does not initialize every channel")
200
+ if sample_index >= start:
201
+ output_column = sample_index - start
202
+ for row, channel in selected_rows:
203
+ output[row, output_column] = state[channel]
204
+
205
+ if stop == sample_count and consumed_before_buffer + position != packet_bytes:
206
+ raise DataIntegrityError("ERD packet has unexplained trailing bytes")
207
+ return output