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 +26 -0
- natus_erd/_paths.py +92 -0
- natus_erd/binary.py +292 -0
- natus_erd/decoder.py +207 -0
- natus_erd/ent.py +474 -0
- natus_erd/errors.py +21 -0
- natus_erd/limits.py +60 -0
- natus_erd/models.py +65 -0
- natus_erd/py.typed +0 -0
- natus_erd/reader.py +567 -0
- natus_erd_reader-0.2.1.dist-info/METADATA +289 -0
- natus_erd_reader-0.2.1.dist-info/RECORD +16 -0
- natus_erd_reader-0.2.1.dist-info/WHEEL +5 -0
- natus_erd_reader-0.2.1.dist-info/licenses/LICENSE +21 -0
- natus_erd_reader-0.2.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- natus_erd_reader-0.2.1.dist-info/top_level.txt +1 -0
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
|