linxi-trodes 1.0.0__tar.gz
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.
- linxi_trodes-1.0.0/PKG-INFO +7 -0
- linxi_trodes-1.0.0/README.md +49 -0
- linxi_trodes-1.0.0/pyproject.toml +20 -0
- linxi_trodes-1.0.0/setup.cfg +4 -0
- linxi_trodes-1.0.0/src/Linxi_Trodes.egg-info/PKG-INFO +7 -0
- linxi_trodes-1.0.0/src/Linxi_Trodes.egg-info/SOURCES.txt +19 -0
- linxi_trodes-1.0.0/src/Linxi_Trodes.egg-info/dependency_links.txt +1 -0
- linxi_trodes-1.0.0/src/Linxi_Trodes.egg-info/entry_points.txt +2 -0
- linxi_trodes-1.0.0/src/Linxi_Trodes.egg-info/requires.txt +2 -0
- linxi_trodes-1.0.0/src/Linxi_Trodes.egg-info/top_level.txt +1 -0
- linxi_trodes-1.0.0/src/linxi_trodes/__init__.py +2 -0
- linxi_trodes-1.0.0/src/linxi_trodes/_dio_format.py +115 -0
- linxi_trodes-1.0.0/src/linxi_trodes/_rec_header.py +291 -0
- linxi_trodes-1.0.0/src/linxi_trodes/export_dio.py +297 -0
- linxi_trodes-1.0.0/tests/test_export_dio.py +402 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Linxi-Trodes
|
|
2
|
+
|
|
3
|
+
Linxi-Trodes is forked from [Trodes](https://bitbucket.org/mkarlsso/trodes)
|
|
4
|
+
|
|
5
|
+
Now this repo provides a python implemented SpikeGadgets DIO data extractor. Please follow the instruction to use it.
|
|
6
|
+
|
|
7
|
+
Install the package in editable mode:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Use it with a python script:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from linxi_trodes import TrodesRecParser
|
|
17
|
+
|
|
18
|
+
# Default mode (legacy behavior): only state transitions are kept.
|
|
19
|
+
parser = TrodesRecParser("path/to/your/recording.rec")
|
|
20
|
+
result = parser.extract_dio(output_dir="path/to/save/dio/")
|
|
21
|
+
# result[channel_name] -> (timestamps: np.uint32[N], states: np.uint8[N])
|
|
22
|
+
|
|
23
|
+
# Raw packet-sample mode: one record per valid packet, no deduplication.
|
|
24
|
+
# Useful for waveform-style analysis of every digital sample.
|
|
25
|
+
result_raw = parser.extract_dio(
|
|
26
|
+
output_dir="path/to/save/dio/",
|
|
27
|
+
return_type="raw",
|
|
28
|
+
)
|
|
29
|
+
# result_raw[channel_name] -> (timestamps: np.uint32[N], states: np.bool_[N])
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The `return_type` selector chooses the semantics of the returned arrays and
|
|
33
|
+
the `.dat` files written under `output_dir`. Omitting the argument preserves
|
|
34
|
+
the legacy event-extraction behavior (default is `"event"`). Both modes share
|
|
35
|
+
the same on-disk record layout — five little-endian bytes per record
|
|
36
|
+
(`<uint32 timestamp><uint8 state>`); the difference is only which packets get
|
|
37
|
+
recorded:
|
|
38
|
+
|
|
39
|
+
- `return_type="event"` — only state-transition edges are written (default).
|
|
40
|
+
- `return_type="raw"` — every valid-packet sample is written (no dedup).
|
|
41
|
+
|
|
42
|
+
Use it with a simple CLI:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python src/linxi_trodes/export_dio.py \
|
|
46
|
+
"path/to/your/recording.rec" \
|
|
47
|
+
"path/to/save/dio/" \
|
|
48
|
+
[--only-export-enabled-dio]
|
|
49
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "linxi-trodes"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Vibe spike sorting!"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"pandas",
|
|
8
|
+
"numpy"
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["setuptools", "wheel"]
|
|
13
|
+
build-backend = "setuptools.build_meta"
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
where = ["src"]
|
|
17
|
+
include = ["linxi_trodes*"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
linxi_trode = "linxi_trodes.export_dio:main"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/Linxi_Trodes.egg-info/PKG-INFO
|
|
4
|
+
src/Linxi_Trodes.egg-info/SOURCES.txt
|
|
5
|
+
src/Linxi_Trodes.egg-info/dependency_links.txt
|
|
6
|
+
src/Linxi_Trodes.egg-info/entry_points.txt
|
|
7
|
+
src/Linxi_Trodes.egg-info/requires.txt
|
|
8
|
+
src/Linxi_Trodes.egg-info/top_level.txt
|
|
9
|
+
src/linxi_trodes/__init__.py
|
|
10
|
+
src/linxi_trodes/_dio_format.py
|
|
11
|
+
src/linxi_trodes/_rec_header.py
|
|
12
|
+
src/linxi_trodes/export_dio.py
|
|
13
|
+
src/linxi_trodes.egg-info/PKG-INFO
|
|
14
|
+
src/linxi_trodes.egg-info/SOURCES.txt
|
|
15
|
+
src/linxi_trodes.egg-info/dependency_links.txt
|
|
16
|
+
src/linxi_trodes.egg-info/entry_points.txt
|
|
17
|
+
src/linxi_trodes.egg-info/requires.txt
|
|
18
|
+
src/linxi_trodes.egg-info/top_level.txt
|
|
19
|
+
tests/test_export_dio.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
linxi_trodes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Private helper: format the Trodes ``.dat`` text header and filenames.
|
|
2
|
+
|
|
3
|
+
Extracted from :mod:`linxi_trodes.export_dio` for the
|
|
4
|
+
dio-raw-return-type refactor (task 2). Provides:
|
|
5
|
+
|
|
6
|
+
* :func:`dio_dat_filename` — the per-channel filename constructor.
|
|
7
|
+
* :func:`write_dat_header` — the standard Trodes text header
|
|
8
|
+
writer used by both the event-mode and (future) raw-mode paths.
|
|
9
|
+
|
|
10
|
+
The legacy ``_write_dat_header`` instance method's bytes are
|
|
11
|
+
preserved verbatim: every ``f.write(...)`` line produces the
|
|
12
|
+
exact same ``.dat`` header as before. The new ``mode`` parameter
|
|
13
|
+
is a forward-compatible hook so the raw-mode header variant
|
|
14
|
+
(planned in task 4) can flip a single argument without
|
|
15
|
+
touching this signature again. ``mode="event"`` is the current
|
|
16
|
+
behavior; the parameter is accepted but currently unused.
|
|
17
|
+
|
|
18
|
+
Only the standard library is used.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import BinaryIO
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def dio_dat_filename(base_name: str, channel_name: str) -> str:
|
|
27
|
+
"""Return the per-channel ``.dat`` filename used by the extractor.
|
|
28
|
+
|
|
29
|
+
Format: ``{base_name}.dio_{channel_name}.dat``. ``base_name`` is
|
|
30
|
+
the ``.rec`` filename without its extension, ``channel_name`` is
|
|
31
|
+
the digital channel ID (e.g. ``"DIN1"``).
|
|
32
|
+
"""
|
|
33
|
+
return f"{base_name}.dio_{channel_name}.dat"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# On-disk label for each metadata key, matching the legacy parser's
|
|
37
|
+
# literal f-string labels exactly (no whitespace, no transforms).
|
|
38
|
+
_HEADER_LABELS = {
|
|
39
|
+
"trodes_version": "Trodes_version",
|
|
40
|
+
"compile_date": "Compile_date",
|
|
41
|
+
"compile_time": "Compile_time",
|
|
42
|
+
"qt_version": "QT_version",
|
|
43
|
+
"commit_tag": "Commit_tag",
|
|
44
|
+
"controller_firmware": "Controller_firmware",
|
|
45
|
+
"headstage_firmware": "Headstage_firmware",
|
|
46
|
+
"controller_serialnum": "Controller_serialnum",
|
|
47
|
+
"headstage_serialnum": "Headstage_serialnum",
|
|
48
|
+
"autosettle": "AutoSettle",
|
|
49
|
+
"smartref": "SmartRef",
|
|
50
|
+
"gyro": "Gyro",
|
|
51
|
+
"accelerometer": "Accelerometer",
|
|
52
|
+
"magnetometer": "Magnetometer",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Ordered metadata keys written into the .dat text header. Same order
|
|
56
|
+
# as the legacy parser.
|
|
57
|
+
_HEADER_METADATA_KEYS = tuple(_HEADER_LABELS.keys())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def write_dat_header(
|
|
61
|
+
f: BinaryIO,
|
|
62
|
+
channel_name: str,
|
|
63
|
+
channel_index: int,
|
|
64
|
+
channel_is_input: bool,
|
|
65
|
+
original_filename: str,
|
|
66
|
+
sampling_rate: float,
|
|
67
|
+
metadata: dict[str, str],
|
|
68
|
+
mode: str = "event",
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Write the standard Trodes ``.dat`` text header.
|
|
71
|
+
|
|
72
|
+
Parameters mirror the legacy instance method
|
|
73
|
+
``TrodesRecParser._write_dat_header``. ``mode`` is accepted as
|
|
74
|
+
a forward-compatible hook for the raw-event variant (task 4);
|
|
75
|
+
it is currently a no-op and the event header is always written.
|
|
76
|
+
|
|
77
|
+
The bytes written by this function are byte-for-byte identical
|
|
78
|
+
to the legacy implementation; the SHA-256 of the resulting
|
|
79
|
+
``.dat`` file on the deterministic task-1 fixture is therefore
|
|
80
|
+
preserved (``1a076c2e...ddae7b39ff``).
|
|
81
|
+
"""
|
|
82
|
+
# The two modes MUST share every header byte except the
|
|
83
|
+
# description line so existing Trodes readers consume both files.
|
|
84
|
+
if mode == "raw":
|
|
85
|
+
description = (
|
|
86
|
+
b"Description: Sampled data for one digital channel. "
|
|
87
|
+
b"Display_order is 1-based\n"
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
description = (
|
|
91
|
+
b"Description: State change data for one digital channel. "
|
|
92
|
+
b"Display_order is 1-based\n"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
f.write(b"<Start settings>\n")
|
|
96
|
+
f.write(description)
|
|
97
|
+
f.write(f"Direction: {'input' if channel_is_input else 'output'}\n".encode())
|
|
98
|
+
f.write(f"ID: {channel_name}\n".encode())
|
|
99
|
+
f.write(f"Display_order: {channel_index + 1}\n".encode())
|
|
100
|
+
f.write(b"Byte_order: little endian\n")
|
|
101
|
+
f.write(f"Original_file: {original_filename}\n".encode())
|
|
102
|
+
f.write(f"Clockrate: {int(sampling_rate)}\n".encode())
|
|
103
|
+
for key in _HEADER_METADATA_KEYS:
|
|
104
|
+
f.write(f"{_HEADER_LABELS[key]}: {metadata.get(key, '-1')}\n".encode())
|
|
105
|
+
f.write(b"Time_offset: 0\n")
|
|
106
|
+
f.write(
|
|
107
|
+
f"System_time_at_creation: "
|
|
108
|
+
f"{metadata.get('system_time_at_creation', '-1')}\n".encode()
|
|
109
|
+
)
|
|
110
|
+
f.write(
|
|
111
|
+
f"Timestamp_at_creation: "
|
|
112
|
+
f"{metadata.get('timestamp_at_creation', '-1')}\n".encode()
|
|
113
|
+
)
|
|
114
|
+
f.write(b"Fields: <time uint32><state uint8>\n")
|
|
115
|
+
f.write(b"<End settings>\n")
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Private helper: parse the XML header of a Trodes ``.rec`` file.
|
|
2
|
+
|
|
3
|
+
Extracted from :mod:`linxi_trodes.export_dio` for the
|
|
4
|
+
dio-raw-return-type refactor (task 2). Owns the XML header scan,
|
|
5
|
+
device/channel layout calculation, metadata extraction, and the
|
|
6
|
+
``only_export_enabled`` filter; returns a frozen
|
|
7
|
+
:class:`RecHeader` dataclass.
|
|
8
|
+
|
|
9
|
+
Behavior preserved verbatim against the legacy ``_parse_header``
|
|
10
|
+
instance method: the ``</Configuration>`` guard, the
|
|
11
|
+
``interleavedDataIDByte`` rejection, the Hardware-vs-Global
|
|
12
|
+
fallback, the ``<DigitalIO><Input>`` augmentation, the
|
|
13
|
+
``only_export_enabled`` filter, and the byte-identical print
|
|
14
|
+
summary. Only the standard library is used.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
_CONFIG_END_TAG = b"</Configuration>"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RecHeader:
|
|
27
|
+
"""Immutable result of parsing a Trodes ``.rec`` XML header.
|
|
28
|
+
|
|
29
|
+
Mirrors the instance attributes populated by the legacy
|
|
30
|
+
``TrodesRecParser._parse_header``. ``header_channels`` captures
|
|
31
|
+
every channel declared by the hardware configuration (any
|
|
32
|
+
``dataType``); ``dio_channels`` is the digital-only subset
|
|
33
|
+
consumed by :func:`linxi_trodes.export_dio.extract_dio`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
sampling_rate: float
|
|
37
|
+
num_channels: int
|
|
38
|
+
header_size: int
|
|
39
|
+
packet_size: int
|
|
40
|
+
binary_offset: int
|
|
41
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
dio_channels: list[dict] = field(default_factory=list)
|
|
43
|
+
header_channels: list[dict] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Internal helpers
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _scan_until_config_close(f) -> bytes:
|
|
52
|
+
data = b""
|
|
53
|
+
while _CONFIG_END_TAG not in data:
|
|
54
|
+
chunk = f.read(1024)
|
|
55
|
+
if not chunk:
|
|
56
|
+
break
|
|
57
|
+
data += chunk
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _compute_binary_offset(header_data: bytes, f) -> int:
|
|
62
|
+
"""Skip ``\\n`` or ``\\r\\n`` after ``</Configuration>`` (legacy trodes_data convention)."""
|
|
63
|
+
end_pos = header_data.find(_CONFIG_END_TAG)
|
|
64
|
+
if end_pos == -1:
|
|
65
|
+
raise ValueError("Could not find </Configuration> tag in file.")
|
|
66
|
+
binary_offset = end_pos + len(_CONFIG_END_TAG)
|
|
67
|
+
f.seek(binary_offset)
|
|
68
|
+
next_byte = f.read(1)
|
|
69
|
+
if next_byte == b"\n":
|
|
70
|
+
binary_offset += 1
|
|
71
|
+
elif next_byte == b"\r":
|
|
72
|
+
binary_offset += 1
|
|
73
|
+
if f.read(1) == b"\n":
|
|
74
|
+
binary_offset += 1
|
|
75
|
+
return binary_offset
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# XML attribute name -> metadata dict key. Same order as the legacy
|
|
79
|
+
# parser; whitespace-stripped on read.
|
|
80
|
+
_METADATA_FIELDS = (
|
|
81
|
+
('trodes_version', 'trodesVersion'),
|
|
82
|
+
('compile_date', 'compileDate'),
|
|
83
|
+
('compile_time', 'compileTime'),
|
|
84
|
+
('qt_version', 'qtVersion'),
|
|
85
|
+
('commit_tag', 'commitHead'),
|
|
86
|
+
('controller_firmware', 'controllerFirmwareVersion'),
|
|
87
|
+
('headstage_firmware', 'headstageFirmwareVersion'),
|
|
88
|
+
('controller_serialnum', 'controllerSerial'),
|
|
89
|
+
('headstage_serialnum', 'headstageSerial'),
|
|
90
|
+
('autosettle', 'headstageAutoSettleOn'),
|
|
91
|
+
('smartref', 'headstageSmartRefOn'),
|
|
92
|
+
('gyro', 'headstageGyroSensorOn'),
|
|
93
|
+
('accelerometer', 'headstageAccelSensorOn'),
|
|
94
|
+
('magnetometer', 'headstageMagSensorOn'),
|
|
95
|
+
('system_time_at_creation', 'systemTimeAtCreation'),
|
|
96
|
+
('timestamp_at_creation', 'timestampAtCreation'),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _build_metadata(global_conf) -> dict[str, str]:
|
|
101
|
+
if global_conf is None:
|
|
102
|
+
return {}
|
|
103
|
+
out: dict[str, str] = {}
|
|
104
|
+
for key, attr in _METADATA_FIELDS:
|
|
105
|
+
value = global_conf.get(attr, "-1")
|
|
106
|
+
if value:
|
|
107
|
+
value = value.strip()
|
|
108
|
+
out[key] = value
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_hardware_config(root):
|
|
113
|
+
global_conf = root.find("GlobalConfiguration")
|
|
114
|
+
hw_conf = root.find("HardwareConfiguration")
|
|
115
|
+
if hw_conf is None:
|
|
116
|
+
hw_conf = global_conf
|
|
117
|
+
if hw_conf is None:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"Could not find HardwareConfiguration or GlobalConfiguration in XML"
|
|
120
|
+
)
|
|
121
|
+
return global_conf, hw_conf
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build_device_layout(hw_conf):
|
|
125
|
+
"""Sort devices by priority and compute byte offsets per device."""
|
|
126
|
+
devices = []
|
|
127
|
+
for device_node in hw_conf.findall("Device"):
|
|
128
|
+
name = device_node.get("name")
|
|
129
|
+
priority = int(device_node.get("packetOrderPreference", 0))
|
|
130
|
+
# Force SysClock to be last, matching the C++/trodes_data logic.
|
|
131
|
+
if name == "SysClock":
|
|
132
|
+
priority = 10000
|
|
133
|
+
devices.append({
|
|
134
|
+
"name": name,
|
|
135
|
+
"numBytes": int(device_node.get("numBytes", 0)),
|
|
136
|
+
"priority": priority,
|
|
137
|
+
"node": device_node,
|
|
138
|
+
})
|
|
139
|
+
devices.sort(key=lambda x: x["priority"])
|
|
140
|
+
|
|
141
|
+
xml_header_size = int(hw_conf.get("headerSize", 0))
|
|
142
|
+
header_size = 0
|
|
143
|
+
current_offset = 1 # Start after the sync byte.
|
|
144
|
+
device_offsets: dict[str, int] = {}
|
|
145
|
+
|
|
146
|
+
for dev in devices:
|
|
147
|
+
# If we have a defined headerSize, SysClock must start after it.
|
|
148
|
+
if dev["name"] == "SysClock" and xml_header_size > 0:
|
|
149
|
+
# headerSize is in words (2 bytes), so multiply by 2.
|
|
150
|
+
expected_start = xml_header_size * 2
|
|
151
|
+
if current_offset < expected_start:
|
|
152
|
+
current_offset = expected_start
|
|
153
|
+
device_offsets[dev["name"]] = current_offset - 1
|
|
154
|
+
current_offset += dev["numBytes"]
|
|
155
|
+
|
|
156
|
+
header_size = current_offset - 1
|
|
157
|
+
return devices, device_offsets, header_size
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _collect_header_channels(devices, device_offsets) -> list[dict]:
|
|
161
|
+
"""Every channel declared by hardware config (any dataType).
|
|
162
|
+
|
|
163
|
+
Raises ``NotImplementedError`` on interleaved digital channels
|
|
164
|
+
using the legacy message verbatim.
|
|
165
|
+
"""
|
|
166
|
+
out: list[dict] = []
|
|
167
|
+
for dev in devices:
|
|
168
|
+
dev_offset = device_offsets[dev["name"]]
|
|
169
|
+
for chan_node in dev["node"].findall("Channel"):
|
|
170
|
+
data_type = chan_node.get("dataType")
|
|
171
|
+
if data_type == "digital":
|
|
172
|
+
if chan_node.get("interleavedDataIDByte", "-1") != "-1":
|
|
173
|
+
raise NotImplementedError(
|
|
174
|
+
f"Channel '{chan_node.get('id')}' uses interleaved data, "
|
|
175
|
+
"which is not currently supported by this Python script."
|
|
176
|
+
)
|
|
177
|
+
out.append({
|
|
178
|
+
"id": chan_node.get("id"),
|
|
179
|
+
"device": dev["name"],
|
|
180
|
+
"dataType": data_type,
|
|
181
|
+
"startByte": int(chan_node.get("startByte", 0)),
|
|
182
|
+
"bit": int(chan_node.get("bit", 0)),
|
|
183
|
+
"input": chan_node.get("input") == "1",
|
|
184
|
+
"byte_offset": 1 + dev_offset + int(chan_node.get("startByte", 0)),
|
|
185
|
+
})
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _dio_from_header_channels(header_channels: list[dict]) -> list[dict]:
|
|
190
|
+
return [
|
|
191
|
+
{"name": ch["id"], "byte_offset": ch["byte_offset"],
|
|
192
|
+
"bit": ch["bit"], "input": ch["input"]}
|
|
193
|
+
for ch in header_channels if ch["dataType"] == "digital"
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _append_digital_io_inputs(root, dio_channels: list[dict]) -> None:
|
|
198
|
+
dio_root = root.find("DigitalIO")
|
|
199
|
+
if dio_root is None:
|
|
200
|
+
return
|
|
201
|
+
for input_node in dio_root.findall("Input"):
|
|
202
|
+
name = input_node.get("name")
|
|
203
|
+
if any(ch["name"] == name for ch in dio_channels):
|
|
204
|
+
continue
|
|
205
|
+
dio_channels.append({
|
|
206
|
+
"name": name,
|
|
207
|
+
"byte_offset": 1,
|
|
208
|
+
"bit": int(input_node.get("channel", 0)) - 1,
|
|
209
|
+
"input": True,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _apply_enabled_filter(root, dio_channels: list[dict]) -> list[dict]:
|
|
214
|
+
aux_disp = root.find("AuxDisplayConfiguration")
|
|
215
|
+
if aux_disp is None:
|
|
216
|
+
aux_disp = root.find("HeaderDisplay")
|
|
217
|
+
if aux_disp is None:
|
|
218
|
+
return dio_channels
|
|
219
|
+
enabled_ids = {child.get("id") for child in aux_disp if child.get("id")}
|
|
220
|
+
original_count = len(dio_channels)
|
|
221
|
+
filtered = [ch for ch in dio_channels if ch["name"] in enabled_ids]
|
|
222
|
+
print(
|
|
223
|
+
f"Filtered DIO channels: {original_count} -> {len(filtered)} "
|
|
224
|
+
"(based on enabled config)"
|
|
225
|
+
)
|
|
226
|
+
return filtered
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Public entry point
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def parse_rec_header(rec_file: str, only_export_enabled: bool = False) -> RecHeader:
|
|
235
|
+
"""Parse the XML header of ``rec_file`` and return a frozen dataclass.
|
|
236
|
+
|
|
237
|
+
See module docstring for preserved behaviors. Raises
|
|
238
|
+
``ValueError`` on a missing ``</Configuration>``, missing
|
|
239
|
+
HardwareConfiguration (with no GlobalConfiguration fallback),
|
|
240
|
+
or missing ``samplingRate`` attribute; raises
|
|
241
|
+
``NotImplementedError`` for interleaved digital channels.
|
|
242
|
+
"""
|
|
243
|
+
with open(rec_file, "rb") as f:
|
|
244
|
+
header_data = _scan_until_config_close(f)
|
|
245
|
+
binary_offset = _compute_binary_offset(header_data, f)
|
|
246
|
+
header_text = header_data[:binary_offset].decode("utf-8", errors="ignore")
|
|
247
|
+
root = ET.fromstring(header_text)
|
|
248
|
+
|
|
249
|
+
global_conf, hw_conf = _resolve_hardware_config(root)
|
|
250
|
+
metadata = _build_metadata(global_conf)
|
|
251
|
+
|
|
252
|
+
sampling_rate_str = hw_conf.get("samplingRate")
|
|
253
|
+
if sampling_rate_str is None:
|
|
254
|
+
raise ValueError("Could not find 'samplingRate' attribute in XML header.")
|
|
255
|
+
sampling_rate = float(sampling_rate_str)
|
|
256
|
+
num_channels = int(hw_conf.get("numChannels", 0))
|
|
257
|
+
|
|
258
|
+
devices, device_offsets, header_size = _build_device_layout(hw_conf)
|
|
259
|
+
packet_size = 1 + header_size + 4 + 2 * num_channels
|
|
260
|
+
|
|
261
|
+
print("Parsed Header:")
|
|
262
|
+
print(f" Sampling Rate: {sampling_rate} Hz")
|
|
263
|
+
print(f" Num Neural Channels: {num_channels}")
|
|
264
|
+
print(f" Device Header Size: {header_size} bytes")
|
|
265
|
+
print(f" Total Packet Size: {packet_size} bytes")
|
|
266
|
+
print(f" Binary Offset: {binary_offset}")
|
|
267
|
+
|
|
268
|
+
header_channels = _collect_header_channels(devices, device_offsets)
|
|
269
|
+
dio_channels = _dio_from_header_channels(header_channels)
|
|
270
|
+
_append_digital_io_inputs(root, dio_channels)
|
|
271
|
+
|
|
272
|
+
if only_export_enabled:
|
|
273
|
+
dio_channels = _apply_enabled_filter(root, dio_channels)
|
|
274
|
+
|
|
275
|
+
if not dio_channels:
|
|
276
|
+
print("Warning: No digital channels found in XML header.")
|
|
277
|
+
else:
|
|
278
|
+
print(f"Found {len(dio_channels)} digital channels:")
|
|
279
|
+
for ch in dio_channels:
|
|
280
|
+
print(f" - {ch['name']} (Byte {ch['byte_offset']}, Bit {ch['bit']})")
|
|
281
|
+
|
|
282
|
+
return RecHeader(
|
|
283
|
+
sampling_rate=sampling_rate,
|
|
284
|
+
num_channels=num_channels,
|
|
285
|
+
header_size=header_size,
|
|
286
|
+
packet_size=packet_size,
|
|
287
|
+
binary_offset=binary_offset,
|
|
288
|
+
metadata=metadata,
|
|
289
|
+
dio_channels=dio_channels,
|
|
290
|
+
header_channels=header_channels,
|
|
291
|
+
)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""The python implementation of the Trodes DIO extractor.
|
|
2
|
+
|
|
3
|
+
Reference: `trodes/python/trodes/trodes_data.py`.
|
|
4
|
+
|
|
5
|
+
The XML/header layout calculation lives in :mod:`linxi_trodes._rec_header`
|
|
6
|
+
and the `.dat` text header formatting in
|
|
7
|
+
:mod:`linxi_trodes._dio_format` (see task 2 of the
|
|
8
|
+
dio-raw-return-type refactor).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import struct
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from tqdm import tqdm
|
|
17
|
+
|
|
18
|
+
from ._dio_format import dio_dat_filename, write_dat_header
|
|
19
|
+
from ._rec_header import parse_rec_header
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TrodesRecParser:
|
|
23
|
+
def __init__(self, rec_file, only_export_enabled=False, buffer_packets=20000):
|
|
24
|
+
self.rec_file = rec_file
|
|
25
|
+
self.only_export_enabled = only_export_enabled
|
|
26
|
+
self.buffer_packets = buffer_packets
|
|
27
|
+
self._parse_header()
|
|
28
|
+
|
|
29
|
+
def _parse_header(self):
|
|
30
|
+
header = parse_rec_header(
|
|
31
|
+
self.rec_file, only_export_enabled=self.only_export_enabled
|
|
32
|
+
)
|
|
33
|
+
self.sampling_rate = header.sampling_rate
|
|
34
|
+
self.packet_size = header.packet_size
|
|
35
|
+
self.header_size = header.header_size
|
|
36
|
+
self.binary_offset = header.binary_offset
|
|
37
|
+
self.metadata = header.metadata
|
|
38
|
+
self.dio_channels = header.dio_channels
|
|
39
|
+
self.num_channels = header.num_channels
|
|
40
|
+
|
|
41
|
+
def extract_dio(
|
|
42
|
+
self,
|
|
43
|
+
output_dir=None,
|
|
44
|
+
return_type: Literal["event", "raw"] = "event",
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
从 Trodes .rec 文件中提取数字输入/输出 (DIO) 通道的状态变化事件。
|
|
48
|
+
|
|
49
|
+
该函数遍历二进制数据包,检测指定的 DIO 通道位是否发生变化。
|
|
50
|
+
如果检测到状态变化,则记录当前的时间戳和新状态。
|
|
51
|
+
结果可以同时保存为 .dat 文件并作为字典返回。
|
|
52
|
+
|
|
53
|
+
Args
|
|
54
|
+
----
|
|
55
|
+
output_dir (str, optional): 输出目录路径。
|
|
56
|
+
如果提供,将为每个 DIO 通道创建一个 .dat 文件(格式为 <base_name>.dio_<ch_name>.dat)。
|
|
57
|
+
文件包含二进制格式的 <timestamp uint32><state uint8>。
|
|
58
|
+
默认为 None,即不保存文件。
|
|
59
|
+
return_type (Literal["event", "raw"], optional):
|
|
60
|
+
选择返回数据的语义模式。默认为 `event`(与原始行为完全一致)。
|
|
61
|
+
- `event`:仅保留状态跳变点(默认,旧行为不变)。
|
|
62
|
+
返回的 `states` 是 `np.uint8`。
|
|
63
|
+
- `raw`:每个有效包返回一条采样,状态不进行去重;
|
|
64
|
+
返回的 `states` 是 `np.bool_`。
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
dict: 包含提取数据的字典。
|
|
69
|
+
键 (key): 通道名称 (str)。
|
|
70
|
+
值 (value): 一个元组 (timestamps, states):
|
|
71
|
+
- timestamps (np.ndarray, dtype=np.uint32): 状态发生变化时的采样计数(Clock ticks)。
|
|
72
|
+
单位:1 / sampling_rate 秒。
|
|
73
|
+
- states (np.ndarray): 变化后的新状态(0 表示低电平,1 表示高电平)。
|
|
74
|
+
`event` 模式的 dtype 为 `np.uint8`;`raw` 模式的 dtype 为 `np.bool_`。
|
|
75
|
+
|
|
76
|
+
Raises
|
|
77
|
+
------
|
|
78
|
+
ValueError
|
|
79
|
+
当 `return_type` 不是 `event` 也不是 `raw` 时抛出,且在抛出前
|
|
80
|
+
不会创建 `output_dir`、不会打开任何文件、不会计算 `num_packets`。
|
|
81
|
+
|
|
82
|
+
Note
|
|
83
|
+
----
|
|
84
|
+
- 时间戳的单位取决于 header 中定义的 sampling_rate。
|
|
85
|
+
- 该函数会自动跳过不以同步字节 0x55 开头的数据包。
|
|
86
|
+
"""
|
|
87
|
+
# 验证 return_type 必须早于任何副作用:此处还在创建 output_dir、
|
|
88
|
+
# 打开文件、计算 num_packets 之前就抛出 ValueError,确保无效
|
|
89
|
+
# 输入不会留下空目录或半截文件。
|
|
90
|
+
if return_type not in ("event", "raw"):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Unknown return_type {return_type!r}; expected 'event' or 'raw'."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# 没有 DIO 通道时直接返回空字典,避免后面按通道分配缓冲区时出错。
|
|
96
|
+
if not self.dio_channels:
|
|
97
|
+
print("No DIO channels to extract.")
|
|
98
|
+
return {}
|
|
99
|
+
|
|
100
|
+
# output_dir 缺失则自动创建;与原版行为保持一致。
|
|
101
|
+
if output_dir and not os.path.exists(output_dir):
|
|
102
|
+
os.makedirs(output_dir)
|
|
103
|
+
|
|
104
|
+
# 基础文件名来自 .rec 文件名(去后缀),用于生成 .dat 文件名。
|
|
105
|
+
base_name = os.path.splitext(os.path.basename(self.rec_file))[0]
|
|
106
|
+
# 写入 .dat 文件头的 Original_file 字段需要原始文件名。
|
|
107
|
+
original_filename = os.path.basename(self.rec_file)
|
|
108
|
+
|
|
109
|
+
# 把每个通道的元数据(name、byte_offset、bit)展开成平行本地列表,
|
|
110
|
+
# 这样热循环里不再需要按字符串键查找字典,仅按下标访问即可。
|
|
111
|
+
ch_names = [ch["name"] for ch in self.dio_channels]
|
|
112
|
+
ch_byte_offsets = [ch["byte_offset"] for ch in self.dio_channels]
|
|
113
|
+
ch_bits = [ch["bit"] for ch in self.dio_channels]
|
|
114
|
+
n_channels = len(ch_names)
|
|
115
|
+
|
|
116
|
+
# 这三个列表按下标与 ch_names/ch_byte_offsets/ch_bits 对齐:
|
|
117
|
+
# output_files 用于按通道写入 .dat;last_states 用于 event 模式的去重
|
|
118
|
+
# 比较;sample_counts 用于汇总打印。
|
|
119
|
+
output_files: list = [None] * n_channels
|
|
120
|
+
last_states: list = [None] * n_channels
|
|
121
|
+
sample_counts: list = [0] * n_channels
|
|
122
|
+
|
|
123
|
+
# 按 chunk 累积 typed numpy 数组:每个通道一个 list,最终只在循环外
|
|
124
|
+
# 做一次 np.concatenate,避免在热路径里反复拼接造成 O(n²) 复制。
|
|
125
|
+
chunk_ts_lists = {nm: [] for nm in ch_names}
|
|
126
|
+
chunk_state_lists = {nm: [] for nm in ch_names}
|
|
127
|
+
|
|
128
|
+
# 为每个通道打开输出文件并写入文本头部;mode 参数控制描述行的语义。
|
|
129
|
+
for i, ch in enumerate(self.dio_channels):
|
|
130
|
+
if output_dir:
|
|
131
|
+
file_path = os.path.join(output_dir, dio_dat_filename(base_name, ch["name"]))
|
|
132
|
+
f = open(file_path, "wb")
|
|
133
|
+
write_dat_header(
|
|
134
|
+
f,
|
|
135
|
+
channel_name=ch["name"],
|
|
136
|
+
channel_index=i,
|
|
137
|
+
channel_is_input=ch["input"],
|
|
138
|
+
original_filename=original_filename,
|
|
139
|
+
sampling_rate=self.sampling_rate,
|
|
140
|
+
metadata=self.metadata,
|
|
141
|
+
mode=return_type,
|
|
142
|
+
)
|
|
143
|
+
output_files[i] = f
|
|
144
|
+
|
|
145
|
+
# 计算整个文件中的完整数据包数量(不足一个包的尾部会被静默丢弃)。
|
|
146
|
+
file_size = os.path.getsize(self.rec_file)
|
|
147
|
+
data_size = file_size - self.binary_offset
|
|
148
|
+
num_packets = data_size // self.packet_size
|
|
149
|
+
|
|
150
|
+
print(f"Extracting samples from {num_packets} packets...")
|
|
151
|
+
|
|
152
|
+
# 把循环里频繁访问的常量提到外面,避免每包重复求值。
|
|
153
|
+
packet_size = self.packet_size
|
|
154
|
+
is_event = return_type == "event" # 仅 event 模式需要去重
|
|
155
|
+
ts_start = 1 + self.header_size # 时间戳在包内的固定偏移
|
|
156
|
+
state_buf_dtype = np.bool_ if return_type == "raw" else np.uint8
|
|
157
|
+
# align=False 强制每条记录正好 5 字节;"<u4" 强制 timestamp 小端序。
|
|
158
|
+
rec_dtype = np.dtype([("ts", "<u4"), ("state", "u1")], align=False)
|
|
159
|
+
|
|
160
|
+
with open(self.rec_file, "rb") as f:
|
|
161
|
+
f.seek(self.binary_offset)
|
|
162
|
+
|
|
163
|
+
# 每次最多读 buffer_packets 个完整包,避免一次性占用过多内存;
|
|
164
|
+
# 末尾不足 buffer_size 的部分会在下一轮 read() 返回空 chunk 时退出。
|
|
165
|
+
buffer_size = self.buffer_packets * packet_size
|
|
166
|
+
|
|
167
|
+
with tqdm(total=num_packets, unit="pkt", dynamic_ncols=True) as pbar:
|
|
168
|
+
while True:
|
|
169
|
+
chunk = f.read(buffer_size)
|
|
170
|
+
if not chunk:
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
# 实际包数可能小于 buffer_packets
|
|
174
|
+
# 用整除丢弃尾部不足一包的字节。
|
|
175
|
+
actual_packets = len(chunk) // packet_size
|
|
176
|
+
|
|
177
|
+
# 为本 chunk 预分配 typed numpy 缓冲区(按通道各两个):
|
|
178
|
+
# 一个时间戳、一个状态;写满的子数组再 append 到 chunk_*_lists。
|
|
179
|
+
# 这种「先占位、按写指针填」的方式避免了 Python list 反复 append。
|
|
180
|
+
ts_bufs = [
|
|
181
|
+
np.empty(actual_packets, dtype=np.uint32)
|
|
182
|
+
for _ in range(n_channels)
|
|
183
|
+
]
|
|
184
|
+
state_bufs = [
|
|
185
|
+
np.empty(actual_packets, dtype=state_buf_dtype)
|
|
186
|
+
for _ in range(n_channels)
|
|
187
|
+
]
|
|
188
|
+
# write_idxs[i] 记录第 i 个通道在本 chunk 已写入缓冲区的行数;
|
|
189
|
+
# 无效同步字节的包不会推进写指针,因此最终用 [:w] 切片即可丢弃空位。
|
|
190
|
+
write_idxs = [0] * n_channels
|
|
191
|
+
|
|
192
|
+
# 内层循环:对 chunk 中的每个完整包做一次解析。
|
|
193
|
+
for pi in range(actual_packets):
|
|
194
|
+
ps = pi * packet_size
|
|
195
|
+
|
|
196
|
+
# 同步字节过滤:Trodes 用 0x55 标记一个合法数据包的起点;
|
|
197
|
+
# 不以 0x55 开头的字节会被跳过,因此时间戳和位值均不可信。
|
|
198
|
+
if chunk[ps] != 0x55:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
# struct.unpack_from 直接在 chunk 上以偏移读取 uint32,
|
|
202
|
+
# 避免 struct.unpack(...) 之前先切片 timestamp 字段。
|
|
203
|
+
timestamp = struct.unpack_from("<I", chunk, ps + ts_start)[0]
|
|
204
|
+
|
|
205
|
+
for ci in range(n_channels):
|
|
206
|
+
# 直接按下标访问字节,再右移指定 bit 位拿到该通道当前电平。
|
|
207
|
+
byte_val = chunk[ps + ch_byte_offsets[ci]]
|
|
208
|
+
state = (byte_val >> ch_bits[ci]) & 1
|
|
209
|
+
|
|
210
|
+
# event 模式去重:与上一包电平相同则跳过本条采样;
|
|
211
|
+
# raw 模式不做这一步,保证每个有效包都产生一条采样。
|
|
212
|
+
if is_event and state == last_states[ci]:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
# 把当前样本写入本 chunk 的 typed 缓冲区对应位置。
|
|
216
|
+
w = write_idxs[ci]
|
|
217
|
+
ts_bufs[ci][w] = timestamp
|
|
218
|
+
state_bufs[ci][w] = state
|
|
219
|
+
write_idxs[ci] = w + 1
|
|
220
|
+
last_states[ci] = state
|
|
221
|
+
sample_counts[ci] += 1
|
|
222
|
+
|
|
223
|
+
# chunk 内部循环结束:按通道把已写入的子数组 append 到
|
|
224
|
+
# 跨 chunk 累积列表,并对已打开的输出文件做一次批量写入。
|
|
225
|
+
for ci in range(n_channels):
|
|
226
|
+
w = write_idxs[ci]
|
|
227
|
+
if w == 0:
|
|
228
|
+
continue
|
|
229
|
+
nm = ch_names[ci]
|
|
230
|
+
ts_slice = ts_bufs[ci][:w]
|
|
231
|
+
state_slice = state_bufs[ci][:w]
|
|
232
|
+
chunk_ts_lists[nm].append(ts_slice)
|
|
233
|
+
chunk_state_lists[nm].append(state_slice)
|
|
234
|
+
|
|
235
|
+
if output_files[ci] is not None:
|
|
236
|
+
# 用结构化 dtype 一次性构造一批 <IB> 记录并 tobytes()
|
|
237
|
+
# 写出;与逐条 struct.pack("<IB", ts, state) 字节完全一致。
|
|
238
|
+
records = np.empty(w, dtype=rec_dtype)
|
|
239
|
+
records["ts"] = ts_slice
|
|
240
|
+
records["state"] = state_slice.astype(np.uint8)
|
|
241
|
+
output_files[ci].write(records.tobytes())
|
|
242
|
+
|
|
243
|
+
pbar.update(actual_packets)
|
|
244
|
+
|
|
245
|
+
# 关闭所有按通道打开的输出文件;output_files 中的 None 项是未启用
|
|
246
|
+
# output_dir 时留下的占位,需要跳过。
|
|
247
|
+
for f in output_files:
|
|
248
|
+
if f is not None:
|
|
249
|
+
f.close()
|
|
250
|
+
|
|
251
|
+
# 最后再决定一次返回数组的状态 dtype,与 buffer dtype 一致。
|
|
252
|
+
state_final_dtype = np.bool_ if return_type == "raw" else np.uint8
|
|
253
|
+
final_results = {}
|
|
254
|
+
for nm in ch_names:
|
|
255
|
+
ts_pieces = chunk_ts_lists[nm]
|
|
256
|
+
st_pieces = chunk_state_lists[nm]
|
|
257
|
+
if ts_pieces:
|
|
258
|
+
# 全文件最终只各做一次 np.concatenate;这里就是 first-layer 优化
|
|
259
|
+
# 「不在热循环里反复拼接」的兑现点。
|
|
260
|
+
ts_arr = np.concatenate(ts_pieces).astype(np.uint32)
|
|
261
|
+
state_arr = np.concatenate(st_pieces).astype(state_final_dtype)
|
|
262
|
+
else:
|
|
263
|
+
# 没有有效采样(例如整个文件没有 0x55 同步字节)时返回空数组,
|
|
264
|
+
# 保证调用方仍能拿到正确 dtype 的空结果。
|
|
265
|
+
ts_arr = np.empty(0, dtype=np.uint32)
|
|
266
|
+
state_arr = np.empty(0, dtype=state_final_dtype)
|
|
267
|
+
final_results[nm] = (ts_arr, state_arr)
|
|
268
|
+
|
|
269
|
+
print("\nExtraction Summary:")
|
|
270
|
+
print(f"{'Channel':<25} {'Samples':<10} {'Final State':<10}")
|
|
271
|
+
print("-" * 50)
|
|
272
|
+
for ci, ch in enumerate(self.dio_channels):
|
|
273
|
+
state_str = "High" if last_states[ci] == 1 else "Low"
|
|
274
|
+
print(f"{ch['name']:<25} {sample_counts[ci]:<10} {state_str:<10}")
|
|
275
|
+
|
|
276
|
+
if output_dir:
|
|
277
|
+
print(f"\nFiles saved in: {output_dir}")
|
|
278
|
+
|
|
279
|
+
return final_results
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main():
|
|
283
|
+
import argparse
|
|
284
|
+
|
|
285
|
+
arg_parser = argparse.ArgumentParser(description="Extract DIO data from Trodes .rec file.")
|
|
286
|
+
arg_parser.add_argument("rec_file", help="Path to the .rec file")
|
|
287
|
+
arg_parser.add_argument("output_dir", help="Directory to save output files")
|
|
288
|
+
arg_parser.add_argument("--only-export-enabled-dio", action="store_true", help="Only export DIO channels enabled in the configuration")
|
|
289
|
+
|
|
290
|
+
args = arg_parser.parse_args()
|
|
291
|
+
|
|
292
|
+
parser = TrodesRecParser(args.rec_file, only_export_enabled=args.only_export_enabled_dio)
|
|
293
|
+
parser.extract_dio(output_dir=args.output_dir)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
if __name__ == "__main__":
|
|
297
|
+
main()
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Tests for ``linxi_trodes.export_dio.TrodesRecParser``.
|
|
2
|
+
|
|
3
|
+
Pins the existing event-mode behavior of ``extract_dio`` and specifies the
|
|
4
|
+
contract for the upcoming ``return_type`` selector. The fixture-backed tests
|
|
5
|
+
below are split into:
|
|
6
|
+
|
|
7
|
+
* **Baseline tests** exercise the default event path on the current code and
|
|
8
|
+
must pass before any product change is made.
|
|
9
|
+
* **Red-phase tests** pin the new ``raw`` mode, invalid ``return_type``
|
|
10
|
+
handling, missing-directory behavior, empty-channel semantics,
|
|
11
|
+
chunk-boundary safety, and malformed-header guard rails. These tests fail
|
|
12
|
+
on the current code because ``return_type`` is not yet implemented, but
|
|
13
|
+
their ``pytest --collect-only`` succeeds and the failure messages point
|
|
14
|
+
at missing behavior (e.g. ``TypeError: extract_dio() got an unexpected
|
|
15
|
+
keyword argument 'return_type'``).
|
|
16
|
+
|
|
17
|
+
Only the Python standard library, NumPy, ``struct``, ``xml.etree.ElementTree``,
|
|
18
|
+
and ``pytest`` are imported; no new dependencies are introduced.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import struct
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import pytest
|
|
29
|
+
|
|
30
|
+
from linxi_trodes import TrodesRecParser
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Layout constants used by the synthetic .rec fixture builder
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
# Packet layout (1 + 1 + 4 + 0 = 6 bytes):
|
|
37
|
+
# offset 0: sync byte (0x55 marks valid packets)
|
|
38
|
+
# offset 1: single DIO device byte (carries the digital bit)
|
|
39
|
+
# offset 2..6: timestamp, uint32 little-endian
|
|
40
|
+
PACKET_SIZE = 6
|
|
41
|
+
HEADER_SIZE = 1
|
|
42
|
+
DIO_BYTE_OFFSET = 1
|
|
43
|
+
DIO_BIT = 0
|
|
44
|
+
SAMPLING_RATE_HZ = 30_000.0
|
|
45
|
+
CHANNEL_NAME = "DIN1"
|
|
46
|
+
BASE_NAME = "synthetic"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Synthetic .rec fixture builder
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_xml_header() -> bytes:
|
|
55
|
+
"""Return the minimal valid Trodes XML header bytes.
|
|
56
|
+
|
|
57
|
+
Describes a single Device (``DIO``) with one digital channel
|
|
58
|
+
(``DIN1``). The closing ``</Configuration>`` tag is followed by a ``\\n``
|
|
59
|
+
separator that the parser already strips.
|
|
60
|
+
"""
|
|
61
|
+
xml = (
|
|
62
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
63
|
+
'<Configuration>\n'
|
|
64
|
+
' <GlobalConfiguration trodesVersion="2.7.2"/>\n'
|
|
65
|
+
' <HardwareConfiguration samplingRate="30000" numChannels="0" headerSize="0">\n'
|
|
66
|
+
' <Device name="DIO" numBytes="1" packetOrderPreference="0">\n'
|
|
67
|
+
' <Channel id="DIN1" dataType="digital" startByte="0" bit="0" input="1"/>\n'
|
|
68
|
+
' </Device>\n'
|
|
69
|
+
' </HardwareConfiguration>\n'
|
|
70
|
+
'</Configuration>\n'
|
|
71
|
+
)
|
|
72
|
+
return xml.encode("utf-8")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_packet(sync: int, dio_byte: int, timestamp: int) -> bytes:
|
|
76
|
+
"""Construct one ``PACKET_SIZE``-byte packet.
|
|
77
|
+
|
|
78
|
+
Layout: ``<sync u8><dio u8><timestamp u32 LE>``.
|
|
79
|
+
"""
|
|
80
|
+
return struct.pack(
|
|
81
|
+
"<BBI", sync & 0xFF, dio_byte & 0xFF, timestamp & 0xFFFFFFFF
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build_trailing_partial(fill_byte: int = 0xAA) -> bytes:
|
|
86
|
+
"""Three garbage bytes that the parser must drop (less than PACKET_SIZE)."""
|
|
87
|
+
return bytes([fill_byte]) * (PACKET_SIZE - 3)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def write_synthetic_rec(
|
|
91
|
+
target: Path,
|
|
92
|
+
packets: list[tuple[int, int, int]],
|
|
93
|
+
trailing_partial: bytes = b"",
|
|
94
|
+
) -> Path:
|
|
95
|
+
"""Write a synthetic Trodes ``.rec`` file to ``target``.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
target : Path
|
|
100
|
+
Destination file path.
|
|
101
|
+
packets : list of (sync, dio_byte, timestamp)
|
|
102
|
+
Per-packet builder arguments. Use ``sync != 0x55`` to mark a packet
|
|
103
|
+
that the parser must skip.
|
|
104
|
+
trailing_partial : bytes
|
|
105
|
+
Bytes appended after the final full packet. Anything shorter than
|
|
106
|
+
``PACKET_SIZE`` is silently dropped by the parser.
|
|
107
|
+
"""
|
|
108
|
+
header = _build_xml_header()
|
|
109
|
+
body = b"".join(_build_packet(*p) for p in packets) + trailing_partial
|
|
110
|
+
target.write_bytes(header + body)
|
|
111
|
+
return target
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.fixture
|
|
115
|
+
def synthetic_rec(tmp_path: Path) -> Path:
|
|
116
|
+
"""A deterministic ``.rec`` covering the canonical parser scenarios.
|
|
117
|
+
|
|
118
|
+
Contains ten full packets (plus one invalid-sync packet and a trailing
|
|
119
|
+
three-byte partial) covering repeated states, transitions, the invalid
|
|
120
|
+
sync-byte rule, a chunk-boundary case (driven by ``buffer_packets=1``
|
|
121
|
+
in the chunk-boundary test), and a trailing partial packet.
|
|
122
|
+
"""
|
|
123
|
+
packets = [
|
|
124
|
+
(0x55, 0x00, 0), # state=0, baseline
|
|
125
|
+
(0x55, 0x00, 1), # state=0 (repeat; event mode drops)
|
|
126
|
+
(0x55, 0x00, 2), # state=0 (repeat)
|
|
127
|
+
(0x55, 0x01, 3), # transition 0 -> 1
|
|
128
|
+
(0x55, 0x01, 4), # state=1 (repeat)
|
|
129
|
+
(0x55, 0x01, 5), # state=1 (repeat)
|
|
130
|
+
(0x55, 0x00, 6), # transition 1 -> 0
|
|
131
|
+
(0x00, 0x01, 7), # INVALID sync byte (parser must skip)
|
|
132
|
+
(0x55, 0x01, 8), # transition 0 -> 1
|
|
133
|
+
(0x55, 0x01, 9), # state=1 (repeat)
|
|
134
|
+
]
|
|
135
|
+
return write_synthetic_rec(
|
|
136
|
+
tmp_path / "synthetic.rec",
|
|
137
|
+
packets=packets,
|
|
138
|
+
trailing_partial=_build_trailing_partial(),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.fixture
|
|
143
|
+
def no_valid_packet_rec(tmp_path: Path) -> Path:
|
|
144
|
+
"""A ``.rec`` whose every packet has an invalid sync byte."""
|
|
145
|
+
packets = [
|
|
146
|
+
(0x00, 0x00, 0),
|
|
147
|
+
(0xAA, 0x00, 1),
|
|
148
|
+
(0x33, 0x01, 2),
|
|
149
|
+
]
|
|
150
|
+
return write_synthetic_rec(tmp_path / "no_valid.rec", packets=packets)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.fixture
|
|
154
|
+
def malformed_xml_rec(tmp_path: Path) -> Path:
|
|
155
|
+
"""A ``.rec`` whose XML header lacks the ``</Configuration>`` terminator."""
|
|
156
|
+
broken = (
|
|
157
|
+
b'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
158
|
+
b'<Configuration>\n'
|
|
159
|
+
b' <GlobalConfiguration/>\n'
|
|
160
|
+
)
|
|
161
|
+
target = tmp_path / "broken.rec"
|
|
162
|
+
target.write_bytes(broken + b"\x55" * 16)
|
|
163
|
+
return target
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Helpers for parsing .dat files written by the extractor
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _read_dat_records(dat_path: Path) -> list[tuple[int, int]]:
|
|
172
|
+
"""Return the ``(timestamp, state)`` tuples after the ``.dat`` text header."""
|
|
173
|
+
raw = dat_path.read_bytes()
|
|
174
|
+
end_marker = b"<End settings>\n"
|
|
175
|
+
end_pos = raw.find(end_marker)
|
|
176
|
+
assert end_pos != -1, f"<End settings> marker missing from {dat_path}"
|
|
177
|
+
binary = raw[end_pos + len(end_marker):]
|
|
178
|
+
records: list[tuple[int, int]] = []
|
|
179
|
+
for offset in range(0, len(binary), 5):
|
|
180
|
+
chunk = binary[offset : offset + 5]
|
|
181
|
+
if len(chunk) < 5:
|
|
182
|
+
break
|
|
183
|
+
ts, state = struct.unpack("<IB", chunk)
|
|
184
|
+
records.append((ts, state))
|
|
185
|
+
return records
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _sha256_hex(data: bytes) -> str:
|
|
189
|
+
return hashlib.sha256(data).hexdigest()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# Pre-change baseline tests (must pass on the current code)
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_event_default_matches_event_explicit(synthetic_rec: Path) -> None:
|
|
198
|
+
"""Default ``extract_dio()`` returns event-mode arrays.
|
|
199
|
+
|
|
200
|
+
Pins the default behavior so the upcoming ``return_type`` selector
|
|
201
|
+
cannot change what callers get when the keyword is omitted.
|
|
202
|
+
"""
|
|
203
|
+
parser = TrodesRecParser(str(synthetic_rec))
|
|
204
|
+
result = parser.extract_dio()
|
|
205
|
+
assert CHANNEL_NAME in result
|
|
206
|
+
timestamps, states = result[CHANNEL_NAME]
|
|
207
|
+
assert timestamps.dtype == np.uint32
|
|
208
|
+
assert states.dtype == np.uint8
|
|
209
|
+
np.testing.assert_array_equal(
|
|
210
|
+
timestamps, np.array([0, 3, 6, 8], dtype=np.uint32)
|
|
211
|
+
)
|
|
212
|
+
np.testing.assert_array_equal(
|
|
213
|
+
states, np.array([0, 1, 0, 1], dtype=np.uint8)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_event_bytes_are_byte_identical_to_default(
|
|
218
|
+
synthetic_rec: Path, tmp_path: Path
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Two consecutive ``extract_dio(output_dir=...)`` runs produce
|
|
221
|
+
byte-identical event ``.dat`` files."""
|
|
222
|
+
out_a = tmp_path / "out_a"
|
|
223
|
+
out_b = tmp_path / "out_b"
|
|
224
|
+
TrodesRecParser(str(synthetic_rec)).extract_dio(output_dir=str(out_a))
|
|
225
|
+
TrodesRecParser(str(synthetic_rec)).extract_dio(output_dir=str(out_b))
|
|
226
|
+
file_a = out_a / f"{BASE_NAME}.dio_{CHANNEL_NAME}.dat"
|
|
227
|
+
file_b = out_b / f"{BASE_NAME}.dio_{CHANNEL_NAME}.dat"
|
|
228
|
+
assert file_a.exists(), "expected event .dat to be written to out_a"
|
|
229
|
+
assert file_b.exists(), "expected event .dat to be written to out_b"
|
|
230
|
+
assert file_a.read_bytes() == file_b.read_bytes()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_event_returns_uint32_timestamps_and_uint8_states(
|
|
234
|
+
synthetic_rec: Path,
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Event mode exposes ``np.uint32`` timestamps and ``np.uint8`` states."""
|
|
237
|
+
parser = TrodesRecParser(str(synthetic_rec))
|
|
238
|
+
timestamps, states = parser.extract_dio()[CHANNEL_NAME]
|
|
239
|
+
assert timestamps.dtype == np.uint32
|
|
240
|
+
assert states.dtype == np.uint8
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# Red-phase tests (fail on current code because ``return_type`` is not yet
|
|
245
|
+
# implemented; failures must point at missing behavior)
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_raw_returns_one_sample_per_valid_packet(synthetic_rec: Path) -> None:
|
|
250
|
+
"""``return_type='raw'`` returns one (timestamp, state) sample per valid
|
|
251
|
+
packet and skips packets with a non-``0x55`` leading byte."""
|
|
252
|
+
parser = TrodesRecParser(str(synthetic_rec))
|
|
253
|
+
timestamps, states = parser.extract_dio(return_type="raw")[CHANNEL_NAME]
|
|
254
|
+
# 10 packets total; packet with sync byte 0x00 is invalid; the 3-byte
|
|
255
|
+
# trailing partial is shorter than PACKET_SIZE and is ignored.
|
|
256
|
+
assert len(timestamps) == 9
|
|
257
|
+
assert len(states) == 9
|
|
258
|
+
np.testing.assert_array_equal(
|
|
259
|
+
timestamps,
|
|
260
|
+
np.array([0, 1, 2, 3, 4, 5, 6, 8, 9], dtype=np.uint32),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_raw_dtype_is_uint32_and_bool(synthetic_rec: Path) -> None:
|
|
265
|
+
"""Raw timestamps are ``np.uint32`` and raw states are ``np.bool_``."""
|
|
266
|
+
parser = TrodesRecParser(str(synthetic_rec))
|
|
267
|
+
timestamps, states = parser.extract_dio(return_type="raw")[CHANNEL_NAME]
|
|
268
|
+
assert timestamps.dtype == np.uint32
|
|
269
|
+
assert states.dtype == np.bool_
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_raw_retains_repeated_states(synthetic_rec: Path) -> None:
|
|
273
|
+
"""Raw mode does NOT deduplicate; consecutive identical states are kept."""
|
|
274
|
+
parser = TrodesRecParser(str(synthetic_rec))
|
|
275
|
+
timestamps, states = parser.extract_dio(return_type="raw")[CHANNEL_NAME]
|
|
276
|
+
# The three leading state=0 samples remain, the three middle state=1
|
|
277
|
+
# samples remain, and the closing state=1 samples remain, unlike the
|
|
278
|
+
# event view which collapses to transitions only.
|
|
279
|
+
assert bool(states[0]) is False
|
|
280
|
+
assert bool(states[1]) is False
|
|
281
|
+
assert bool(states[2]) is False
|
|
282
|
+
assert bool(states[3]) is True
|
|
283
|
+
assert bool(states[4]) is True
|
|
284
|
+
assert bool(states[5]) is True
|
|
285
|
+
assert bool(states[6]) is False
|
|
286
|
+
assert bool(states[7]) is True
|
|
287
|
+
assert bool(states[8]) is True
|
|
288
|
+
assert len(timestamps) == len(states) == 9
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_raw_files_contain_every_valid_packet(
|
|
292
|
+
synthetic_rec: Path, tmp_path: Path
|
|
293
|
+
) -> None:
|
|
294
|
+
"""``output_dir`` in raw mode writes one record per valid packet."""
|
|
295
|
+
out = tmp_path / "raw_out"
|
|
296
|
+
TrodesRecParser(str(synthetic_rec)).extract_dio(
|
|
297
|
+
return_type="raw", output_dir=str(out)
|
|
298
|
+
)
|
|
299
|
+
dat = out / f"{BASE_NAME}.dio_{CHANNEL_NAME}.dat"
|
|
300
|
+
records = _read_dat_records(dat)
|
|
301
|
+
assert len(records) == 9, f"expected 9 raw records, got {len(records)}"
|
|
302
|
+
assert records == [
|
|
303
|
+
(0, 0), (1, 0), (2, 0), (3, 1), (4, 1), (5, 1), (6, 0), (8, 1), (9, 1)
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_raw_file_records_are_five_bytes_little_endian(
|
|
308
|
+
synthetic_rec: Path, tmp_path: Path
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Each raw ``.dat`` record is exactly 5 bytes: little-endian uint32 + uint8."""
|
|
311
|
+
out = tmp_path / "raw_out"
|
|
312
|
+
TrodesRecParser(str(synthetic_rec)).extract_dio(
|
|
313
|
+
return_type="raw", output_dir=str(out)
|
|
314
|
+
)
|
|
315
|
+
dat = out / f"{BASE_NAME}.dio_{CHANNEL_NAME}.dat"
|
|
316
|
+
raw = dat.read_bytes()
|
|
317
|
+
end_marker = b"<End settings>\n"
|
|
318
|
+
binary = raw[raw.find(end_marker) + len(end_marker):]
|
|
319
|
+
assert len(binary) == 5 * 9, (
|
|
320
|
+
f"raw binary region must be 5*9=45 bytes, got {len(binary)}"
|
|
321
|
+
)
|
|
322
|
+
# Spot-check little-endian byte ordering: ts=3 -> 03 00 00 00; state=1 -> 01.
|
|
323
|
+
assert binary[0:5] == struct.pack("<IB", 0, 0)
|
|
324
|
+
assert binary[5:10] == struct.pack("<IB", 1, 0)
|
|
325
|
+
assert binary[10:15] == struct.pack("<IB", 2, 0)
|
|
326
|
+
assert binary[15:20] == struct.pack("<IB", 3, 1)
|
|
327
|
+
# The 3-byte trailing partial from the input must NOT appear in the output.
|
|
328
|
+
assert b"\xaa\xaa\xaa" not in binary
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_raw_output_dir_respects_missing_directory(
|
|
332
|
+
synthetic_rec: Path, tmp_path: Path
|
|
333
|
+
) -> None:
|
|
334
|
+
"""A non-existent ``output_dir`` is created automatically when writing raw ``.dat``."""
|
|
335
|
+
out = tmp_path / "deep" / "nested" / "raw"
|
|
336
|
+
assert not out.exists()
|
|
337
|
+
TrodesRecParser(str(synthetic_rec)).extract_dio(
|
|
338
|
+
return_type="raw", output_dir=str(out)
|
|
339
|
+
)
|
|
340
|
+
assert out.is_dir()
|
|
341
|
+
assert (out / f"{BASE_NAME}.dio_{CHANNEL_NAME}.dat").exists()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_invalid_return_type_raises_value_error(synthetic_rec: Path) -> None:
|
|
345
|
+
"""An unknown ``return_type`` raises ``ValueError`` before opening output."""
|
|
346
|
+
parser = TrodesRecParser(str(synthetic_rec))
|
|
347
|
+
with pytest.raises(ValueError):
|
|
348
|
+
parser.extract_dio(return_type="bogus")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_no_valid_packets_returns_empty_arrays(no_valid_packet_rec: Path) -> None:
|
|
352
|
+
"""A file with no valid packets returns empty raw arrays with the right dtypes."""
|
|
353
|
+
parser = TrodesRecParser(str(no_valid_packet_rec))
|
|
354
|
+
result = parser.extract_dio(return_type="raw")
|
|
355
|
+
assert CHANNEL_NAME in result
|
|
356
|
+
timestamps, states = result[CHANNEL_NAME]
|
|
357
|
+
assert len(timestamps) == 0
|
|
358
|
+
assert len(states) == 0
|
|
359
|
+
assert timestamps.dtype == np.uint32
|
|
360
|
+
assert states.dtype == np.bool_
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_event_to_raw_transitions_match(synthetic_rec: Path) -> None:
|
|
364
|
+
"""Compressing raw samples to transitions reproduces event-mode results."""
|
|
365
|
+
parser = TrodesRecParser(str(synthetic_rec))
|
|
366
|
+
raw_ts, raw_st = parser.extract_dio(return_type="raw")[CHANNEL_NAME]
|
|
367
|
+
# Build the event-mode view by selecting transitions only.
|
|
368
|
+
transitions_ts = [int(raw_ts[0])]
|
|
369
|
+
transitions_st = [int(raw_st[0])]
|
|
370
|
+
for i in range(1, len(raw_ts)):
|
|
371
|
+
if int(raw_st[i]) != transitions_st[-1]:
|
|
372
|
+
transitions_ts.append(int(raw_ts[i]))
|
|
373
|
+
transitions_st.append(int(raw_st[i]))
|
|
374
|
+
# Compare against a fresh event-mode run.
|
|
375
|
+
parser_event = TrodesRecParser(str(synthetic_rec))
|
|
376
|
+
event_ts, event_st = parser_event.extract_dio()[CHANNEL_NAME]
|
|
377
|
+
np.testing.assert_array_equal(
|
|
378
|
+
np.array(transitions_ts, dtype=np.uint32), event_ts
|
|
379
|
+
)
|
|
380
|
+
np.testing.assert_array_equal(
|
|
381
|
+
np.array(transitions_st, dtype=np.uint8), event_st
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_chunk_boundary_does_not_lose_packets(synthetic_rec: Path) -> None:
|
|
386
|
+
"""With ``buffer_packets=1`` each chunk holds one packet; no packet is lost."""
|
|
387
|
+
parser = TrodesRecParser(str(synthetic_rec), buffer_packets=1)
|
|
388
|
+
timestamps, states = parser.extract_dio(return_type="raw")[CHANNEL_NAME]
|
|
389
|
+
assert len(timestamps) == 9
|
|
390
|
+
assert len(states) == 9
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_malformed_xml_raises(malformed_xml_rec: Path) -> None:
|
|
394
|
+
"""A header missing ``</Configuration>`` raises a parse error rather than
|
|
395
|
+
silently producing empty output.
|
|
396
|
+
|
|
397
|
+
Guards the existing header validation so future changes to
|
|
398
|
+
``extract_dio`` cannot accidentally swallow header errors (e.g. by
|
|
399
|
+
silently returning empty results).
|
|
400
|
+
"""
|
|
401
|
+
with pytest.raises(ValueError):
|
|
402
|
+
TrodesRecParser(str(malformed_xml_rec))
|