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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: linxi-trodes
3
+ Version: 1.0.0
4
+ Summary: Vibe spike sorting!
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pandas
7
+ Requires-Dist: numpy
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: linxi-trodes
3
+ Version: 1.0.0
4
+ Summary: Vibe spike sorting!
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pandas
7
+ Requires-Dist: numpy
@@ -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,2 @@
1
+ [console_scripts]
2
+ linxi_trode = linxi_trodes.export_dio:main
@@ -0,0 +1,2 @@
1
+ pandas
2
+ numpy
@@ -0,0 +1 @@
1
+ linxi_trodes
@@ -0,0 +1,2 @@
1
+ # Linxi Refactored Trodes DIO Extractor using python implementation
2
+ from .export_dio import TrodesRecParser
@@ -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))