perception-pnrf 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ from .models import (
2
+ AcquisitionRecord,
3
+ GlobalMeta,
4
+ MainframeRole,
5
+ PnrfChannel,
6
+ PnrfGroup,
7
+ PnrfRecorder,
8
+ PnrfSegment,
9
+ SampleEncoding,
10
+ SegmentRelation,
11
+ SptFrame,
12
+ TriggerRecord,
13
+ TriggerSource,
14
+ )
15
+ from .pnrf import PnrfFile
16
+
17
+ __all__ = [
18
+ "AcquisitionRecord",
19
+ "GlobalMeta",
20
+ "MainframeRole",
21
+ "PnrfChannel",
22
+ "PnrfFile",
23
+ "PnrfGroup",
24
+ "PnrfRecorder",
25
+ "PnrfSegment",
26
+ "SampleEncoding",
27
+ "SegmentRelation",
28
+ "SptFrame",
29
+ "TriggerRecord",
30
+ "TriggerSource",
31
+ ]
@@ -0,0 +1,310 @@
1
+ # pyright: reportPrivateUsage=false
2
+
3
+ """Dump PNRF file contents in HDF5-style format for comparison testing."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import TextIO
11
+
12
+ import numpy as np
13
+
14
+ from .models import PnrfChannel, PnrfSegment, SegmentRelation
15
+ from .pnrf import PnrfFile
16
+
17
+ _SAMPLES_PER_SECTION = 3
18
+
19
+
20
+ def _format_value(value: float) -> str:
21
+ """Format a value with 6 significant digits (matching C# G6)."""
22
+ if math.isnan(value):
23
+ return "NaN"
24
+ if value == 0.0:
25
+ return "0"
26
+ s = f"{value:.6g}"
27
+ # C# G6 uses uppercase E for exponent
28
+ return s.replace("e+", "E+").replace("e-", "E-")
29
+
30
+
31
+ def _utc_start_str(pnrf: PnrfFile) -> str:
32
+ """Format the UTC start time."""
33
+ meta = pnrf.metadata
34
+ if meta.recording_start_utc is not None:
35
+ dt = meta.recording_start_utc
36
+ ticks = meta._utc_start_frac_ticks
37
+ return f"{dt.year:04d}-{dt.month:02d}-{dt.day:02d}T{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d}.{ticks:07d}Z"
38
+ return "(unknown)"
39
+
40
+
41
+ def _time_mark_type_name(channel_bitmask: int) -> str:
42
+ """Convert trigger channel bitmask to TimeMarkType name."""
43
+ low = channel_bitmask & 0xFFFF
44
+ names = {0: "TimeMarkType_Started", 1: "TimeMarkType_Finished", 2: "TimeMarkType_Triggered", 3: "TimeMarkType_Trigger"}
45
+ return names.get(low, f"TimeMarkType_{low}")
46
+
47
+
48
+ def dump(pnrf: PnrfFile, writer: TextIO) -> None:
49
+ """Dump a PNRF file in HDF5-style format."""
50
+ meta = pnrf.metadata
51
+ recorders = pnrf.recorders
52
+
53
+ title = meta.title
54
+ if not title:
55
+ title = Path(meta.filename).stem
56
+
57
+ utc_start = _utc_start_str(pnrf)
58
+
59
+ writer.write("HDF5-style dump (PNRF)\n")
60
+ writer.write("FILE {\n")
61
+ writer.write(" FILE_ATTRIBUTES {\n")
62
+ writer.write(f' TITLE = "{title}"\n')
63
+ comment_str = meta.comment.replace("\n", "\r\n") if meta.comment else "(null)"
64
+ writer.write(f' COMMENT = "{comment_str}"\n')
65
+ writer.write(" }\n")
66
+
67
+ # Data values (excluding Title)
68
+ data_values = [(k, v) for k, v in meta.data_values.items() if k != "Title"]
69
+ if data_values:
70
+ writer.write(" DATA_VALUES {\n")
71
+ for key, val in data_values:
72
+ escaped = val.replace("\n", "\r\n")
73
+ writer.write(f' "{key}" (DataSourceDataType_String) = "{escaped}"\n')
74
+ writer.write(" }\n")
75
+
76
+ writer.write(f" RECORDERS ({len(recorders)}) {{\n")
77
+
78
+ for recorder_index, recorder in enumerate(recorders):
79
+ writer.write(f' RECORDER[{recorder_index + 1}] "{recorder.logical_name}" {{\n')
80
+ writer.write(f' PHYSICAL_NAME = "{recorder.physical_name}"\n')
81
+ writer.write(' X_UNITS = "s"\n')
82
+
83
+ # Triggers
84
+ if recorder.trigger_records:
85
+ writer.write(f" TRIGGERS ({len(recorder.trigger_records)}) {{\n")
86
+ for trigger_index, trigger in enumerate(recorder.trigger_records):
87
+ writer.write(
88
+ f" [{trigger_index + 1}] time={_format_value(trigger.time)}"
89
+ f" type={_time_mark_type_name(trigger._channel_bitmask)}\n"
90
+ )
91
+ writer.write(" }\n")
92
+
93
+ # Visible channels
94
+ visible_channels = [
95
+ ch for ch in recorder.channels if len(ch.segments) > 0 or (ch.is_spt and len(ch._raw_segments) > 0)
96
+ ]
97
+
98
+ writer.write(f" CHANNELS ({len(visible_channels)}) {{\n")
99
+
100
+ for channel_index, channel in enumerate(visible_channels):
101
+ writer.write(f' CHANNEL[{channel_index + 1}] "{channel.logical_name}" {{\n')
102
+
103
+ if channel.is_spt:
104
+ channel_type = "DataChannelType_Blob"
105
+ elif channel.is_event:
106
+ channel_type = "DataChannelType_Event"
107
+ else:
108
+ channel_type = "DataChannelType_Analog"
109
+
110
+ writer.write(f" TYPE = {channel_type}\n")
111
+ writer.write(" TIMESHIFT = 0 s\n")
112
+
113
+ if channel.is_spt:
114
+ writer.write(" // (non-waveform channel, skipping data)\n")
115
+ writer.write(" }\n")
116
+ continue
117
+
118
+ writer.write(f' X_UNIT = "s"\n')
119
+ writer.write(f' Y_UNIT = "{channel.units}"\n')
120
+
121
+ data_type = "DataSourceDataType_DigitalWaveform" if channel.is_event else "DataSourceDataType_AnalogWaveform"
122
+ writer.write(f" DATA_TYPE = {data_type}\n")
123
+
124
+ has_matd = any(s._tag == "MATD" for s in channel.segments)
125
+ time_info = "DataSourceTimeInfo_Explicit" if has_matd else "DataSourceTimeInfo_Implicit"
126
+ writer.write(f" TIME_INFO = {time_info}\n")
127
+ writer.write(f" UTC_START = {utc_start}\n")
128
+
129
+ segments = channel.segments
130
+ sweep_records = recorder.sweep_records
131
+
132
+ # Sweep range
133
+ if segments:
134
+ range_start = min(_segment_range_start(s) for s in segments)
135
+ range_end = max(s.start_time + (s.sample_count - 1) * s.sample_interval for s in segments)
136
+
137
+ # Apply recording_end_time for MATD channels
138
+ if has_matd and channel.recording_end_time is not None:
139
+ if recorder.continuous_records:
140
+ cont_record = recorder.continuous_records[0]
141
+ cdi_end = (
142
+ cont_record.start_time
143
+ + recorder.time_base_offset
144
+ + (cont_record.sample_count - 1) * cont_record.sample_interval
145
+ )
146
+ range_end = max(range_end, min(cdi_end, channel.recording_end_time))
147
+ else:
148
+ range_end = max(range_end, channel.recording_end_time)
149
+
150
+ writer.write(
151
+ f" SWEEP_RANGE = [{_format_value(range_start)} s .. {_format_value(range_end)} s]"
152
+ f" count={len(sweep_records)}\n"
153
+ )
154
+
155
+ # Sweeps
156
+ if sweep_records:
157
+ writer.write(f" SWEEPS ({len(sweep_records)}) {{\n")
158
+ for sweep_index, sweep in enumerate(sweep_records):
159
+ sweep_start = sweep.start_time + recorder.time_base_offset
160
+ sweep_end = sweep_start + (sweep.sample_count - 1) * sweep.sample_interval
161
+ sweep_trigger = sweep.trigger_time + recorder.time_base_offset
162
+ writer.write(
163
+ f" [{sweep_index + 1}] start={_format_value(sweep_start)} s"
164
+ f" end={_format_value(sweep_end)} s"
165
+ f" trigger={_format_value(sweep_trigger)} s"
166
+ f" source=TriggerSource_{sweep.trigger_source.name.capitalize()}"
167
+ f" finished=True\n"
168
+ )
169
+ writer.write(" }\n")
170
+
171
+ # Segments
172
+ writer.write(f" SEGMENTS ({len(segments)}) {{\n")
173
+ for segment_index, segment in enumerate(segments):
174
+ end_time = segment.start_time + (segment.sample_count - 1) * segment.sample_interval
175
+ sample_rate = 1.0 / segment.sample_interval if segment.sample_interval != 0 else 0.0
176
+
177
+ # Compute relation dynamically
178
+ if segment_index == 0:
179
+ relation = SegmentRelation.NONE
180
+ else:
181
+ prev = segments[segment_index - 1]
182
+ prev_end = prev.start_time + (prev.sample_count - 1) * prev.sample_interval
183
+ gap = segment.start_time - prev_end
184
+ relation = (
185
+ SegmentRelation.CONTINUOUS
186
+ if gap <= max(prev.sample_interval, segment.sample_interval) + 1e-9
187
+ else SegmentRelation.NONE
188
+ )
189
+
190
+ writer.write(f" SEGMENT[{segment_index + 1}] {{\n")
191
+ writer.write(f" START_TIME = {_format_value(segment.start_time)} s\n")
192
+ writer.write(f" END_TIME = {_format_value(end_time)} s\n")
193
+ writer.write(
194
+ f" SAMPLE_INTERVAL = {_format_value(segment.sample_interval)} s"
195
+ f" ({_format_value(sample_rate)} Hz)\n"
196
+ )
197
+ writer.write(f" TOTAL_SAMPLES = {segment.sample_count}\n")
198
+ writer.write(
199
+ f" RELATION_TO_PREV = SegmentRelation_{relation.name.capitalize()}\n"
200
+ )
201
+ writer.write(f" Y0 = {_format_value(segment._y0)}\n")
202
+ writer.write(f" Y_STEP = {_format_value(segment._y_step)}\n")
203
+
204
+ # Data samples
205
+ total_samples = segment.sample_count
206
+ fetch_count = min(_SAMPLES_PER_SECTION, total_samples)
207
+ writer.write(f" DATA (first {fetch_count} of {total_samples} samples) {{\n")
208
+
209
+ # First N samples
210
+ _print_samples(channel, segment_index, 0, fetch_count, writer)
211
+
212
+ if total_samples > _SAMPLES_PER_SECTION:
213
+ center = total_samples // 2 - _SAMPLES_PER_SECTION // 2
214
+ if center >= _SAMPLES_PER_SECTION:
215
+ writer.write(f" ... ({center} more values omitted)\n")
216
+ _print_samples(channel, segment_index, center, _SAMPLES_PER_SECTION, writer)
217
+
218
+ end_idx = total_samples - _SAMPLES_PER_SECTION
219
+ if end_idx >= center + _SAMPLES_PER_SECTION:
220
+ omitted2 = end_idx - (center + _SAMPLES_PER_SECTION)
221
+ if omitted2 > 0:
222
+ writer.write(f" ... ({omitted2} more values omitted)\n")
223
+ _print_samples(channel, segment_index, end_idx, _SAMPLES_PER_SECTION, writer)
224
+
225
+ writer.write(" }\n")
226
+ writer.write(" }\n")
227
+ writer.write(" }\n")
228
+ writer.write(" }\n")
229
+ writer.write(" }\n")
230
+ writer.write(" }\n")
231
+ writer.write(" }\n")
232
+
233
+ # CHANNELS_FLAT
234
+ all_channels: list[tuple[str, str, bool, bool]] = []
235
+ for recorder in recorders:
236
+ visible_ch = [
237
+ ch for ch in recorder.channels if len(ch.segments) > 0 or (ch.is_spt and len(ch._raw_segments) > 0)
238
+ ]
239
+ for channel in visible_ch:
240
+ all_channels.append((channel.logical_name, recorder.logical_name, channel.is_event, channel.is_spt))
241
+
242
+ writer.write(f" CHANNELS_FLAT ({len(all_channels)}) {{\n")
243
+ for flat_index, (channel_name, recorder_name, is_event, is_spt) in enumerate(all_channels):
244
+ if is_spt:
245
+ channel_type = "DataChannelType_Blob"
246
+ elif is_event:
247
+ channel_type = "DataChannelType_Event"
248
+ else:
249
+ channel_type = "DataChannelType_Analog"
250
+ writer.write(f' [{flat_index + 1}] "{channel_name}" recorder="{recorder_name}" type={channel_type}\n')
251
+ writer.write(" }\n")
252
+ writer.write("}\n")
253
+
254
+
255
+ def _segment_range_start(s: PnrfSegment) -> float:
256
+ """Get the effective start time for sweep range calculation."""
257
+ if s._tag == "MATD" and s._matd_first_time is not None:
258
+ if s.sample_interval > 0 and s.start_time < s._matd_first_time - 1e-9:
259
+ return s.start_time
260
+ return s._matd_first_time
261
+ return s.start_time
262
+
263
+
264
+ def _print_samples(channel: PnrfChannel, segment_index: int, offset: int, count: int, writer: TextIO) -> None:
265
+ """Print sample values for a segment."""
266
+ segment = channel.segments[segment_index]
267
+ try:
268
+ buffer = np.empty(count, dtype=np.float64)
269
+ n = channel.read_f64(buffer, segment_index=segment_index, start=offset, matd_interpolated=True)
270
+ for i in range(n):
271
+ idx = offset + i
272
+ t = segment.start_time + idx * segment.sample_interval
273
+ writer.write(f" [{idx:6}] t={_format_value(t):>20} y={_format_value(buffer[i]):>20}\n")
274
+ except Exception:
275
+ for i in range(count):
276
+ idx = offset + i
277
+ t = segment.start_time + idx * segment.sample_interval
278
+ writer.write(f" [{idx:6}] t={_format_value(t):>20} y={_format_value(0.0):>20}\n")
279
+
280
+
281
+ def dump_file(path: str | Path, output: str | Path | None = None) -> None:
282
+ """Dump a PNRF file."""
283
+ path = Path(path)
284
+
285
+ if output:
286
+ out = open(output, "w", newline="")
287
+ else:
288
+ out = sys.stdout
289
+
290
+ try:
291
+ with PnrfFile.open(path) as pnrf:
292
+ dump(pnrf, out)
293
+ finally:
294
+ if output and out is not sys.stdout:
295
+ out.close()
296
+
297
+
298
+ def main() -> None:
299
+ """Main entry point."""
300
+ if len(sys.argv) < 2:
301
+ print("Usage: python -m perception_pnrf.dump <pnrf_file> [output_file]", file=sys.stderr)
302
+ sys.exit(1)
303
+
304
+ path = sys.argv[1]
305
+ output = sys.argv[2] if len(sys.argv) > 2 else None
306
+ dump_file(path, output)
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()
@@ -0,0 +1,288 @@
1
+ # pyright: reportPrivateUsage=false
2
+
3
+ """Public model classes matching the C# PerceptionPnrf.Models namespace."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime
8
+ from enum import IntEnum
9
+ from typing import Any, Optional
10
+
11
+ import numpy as np
12
+
13
+
14
+ class MainframeRole(IntEnum):
15
+ """Synchronization role of a mainframe."""
16
+
17
+ UNSYNCHRONIZED = 0
18
+ MASTER = 1
19
+ SLAVE = 2
20
+
21
+
22
+ class SegmentRelation(IntEnum):
23
+ """Relationship of a segment to the preceding one."""
24
+
25
+ NONE = 0
26
+ CONTINUOUS = 1
27
+
28
+
29
+ class TriggerSource(IntEnum):
30
+ """Source that triggered an acquisition."""
31
+
32
+ INTERNAL = 0
33
+ EXTERNAL = 1
34
+ SOFTWARE = 2
35
+ MANUAL = 3
36
+
37
+
38
+ class SampleEncoding(IntEnum):
39
+ """Sample encoding within the NSS container."""
40
+
41
+ INT16 = 0
42
+ INT32 = 1
43
+ FLOAT32 = 2
44
+ FLOAT64 = 3
45
+ MATD = 4
46
+ SPT = 5
47
+ INT64 = 6
48
+
49
+
50
+ class GlobalMeta:
51
+ """Global metadata from RecordingInfo.xml and InfoData."""
52
+
53
+ def __init__(self) -> None:
54
+ self.title: Optional[str] = None
55
+ self.filename: str = ""
56
+ self.comment: Optional[str] = None
57
+ self.recording_start_utc: Optional[datetime] = None
58
+ self.data_values: dict[str, str] = {}
59
+
60
+ # Internal fields
61
+ self._mainframe_time_offsets: dict[str, float] = {}
62
+ self._mainframe_roles: dict[str, "MainframeRole"] = {}
63
+ self._recorder_names: dict[str, str] = {}
64
+ self._utc_start_frac_ticks: int = 0 # 100ns ticks for 7-digit sub-second precision
65
+
66
+ def __repr__(self) -> str:
67
+ return self.title or self.filename
68
+
69
+
70
+ class PnrfSegment:
71
+ """A contiguous block of samples within a channel."""
72
+
73
+ def __init__(self) -> None:
74
+ self.start_time: float = 0.0
75
+ self.sample_interval: float = 0.0
76
+ self.sample_count: int = 0
77
+ self.source_encoding: SampleEncoding = SampleEncoding.INT16
78
+
79
+ # Internal fields (used by reader, not part of public API contract)
80
+ self._y0: float = 0.0
81
+ self._y_step: float = 1.0
82
+ self._relation: SegmentRelation = SegmentRelation.NONE
83
+ self._tag: str = ""
84
+ self._source_start_sector: int = 0
85
+ self._source_sample_offset: int = 0
86
+ self._source_size_bytes: int = 0
87
+ self._matd_first_time: Optional[float] = None
88
+ self._source_bit_index: int = -1
89
+
90
+ def __repr__(self) -> str:
91
+ return f"t={self.start_time}, n={self.sample_count}, dt={self.sample_interval}"
92
+
93
+
94
+ class PnrfChannel:
95
+ """A measurement channel."""
96
+
97
+ def __init__(self) -> None:
98
+ self.physical_name: str = ""
99
+ self.logical_name: str = ""
100
+ self.units: str = ""
101
+ self.is_event: bool = False
102
+ self.is_spt: bool = False
103
+ self.segments: list[PnrfSegment] = []
104
+ self.recording_end_time: Optional[float] = None
105
+
106
+ # Internal wiring
107
+ self._owner: Optional[object] = None # PnrfFile back-reference
108
+ self._owner_recorder: Optional[PnrfRecorder] = None
109
+ self._physical_index: int = 0
110
+ self._channel_type: int = 0
111
+ self._data_type_code: int = 0
112
+ self._tag: str = ""
113
+ self._alias_recorder_index: Optional[int] = None
114
+ self._alias_channel_index: Optional[int] = None
115
+ self._raw_segments: list[Any] = []
116
+
117
+ def __repr__(self) -> str:
118
+ return f"{self.logical_name} [{self.units}], {len(self.segments)} seg"
119
+
120
+ @property
121
+ def recorder(self) -> PnrfRecorder:
122
+ """The recorder this channel belongs to."""
123
+ assert self._owner_recorder is not None
124
+ return self._owner_recorder
125
+
126
+ @property
127
+ def sample_count(self) -> int:
128
+ """Total samples across all segments (computed)."""
129
+ return sum(s.sample_count for s in self.segments)
130
+
131
+ def read_f64(
132
+ self,
133
+ values: np.ndarray,
134
+ times: Optional[np.ndarray] = None,
135
+ segment_index: int = 0,
136
+ start: int = 0,
137
+ matd_interpolated: bool = False,
138
+ ) -> int:
139
+ """Read calibrated float64 samples.
140
+
141
+ Args:
142
+ values: Output array to fill with sample values.
143
+ times: Optional output array to fill with timestamps.
144
+ segment_index: Starting segment index.
145
+ start: Sample offset within the starting segment.
146
+ matd_interpolated: Use ZOH interpolation for MATD channels.
147
+
148
+ Returns:
149
+ Number of samples actually read.
150
+ """
151
+ from .pnrf import PnrfFile
152
+
153
+ assert isinstance(self._owner, PnrfFile)
154
+ return self._owner._read_channel(self, values, times, segment_index, start, matd_interpolated)
155
+
156
+ def read_f32(
157
+ self,
158
+ values: np.ndarray,
159
+ times: Optional[np.ndarray] = None,
160
+ segment_index: int = 0,
161
+ start: int = 0,
162
+ matd_interpolated: bool = False,
163
+ ) -> int:
164
+ """Read calibrated float32 samples.
165
+
166
+ Args:
167
+ values: Output array (float32) to fill with sample values.
168
+ times: Optional output array (float32) to fill with timestamps.
169
+ segment_index: Starting segment index.
170
+ start: Sample offset within the starting segment.
171
+ matd_interpolated: Use ZOH interpolation for MATD channels.
172
+
173
+ Returns:
174
+ Number of samples actually read.
175
+ """
176
+ from .pnrf import PnrfFile
177
+
178
+ assert isinstance(self._owner, PnrfFile)
179
+ return self._owner._read_channel_f32(self, values, times, segment_index, start, matd_interpolated)
180
+
181
+ def read_spt_frames(self, start: int = 0, count: Optional[int] = None) -> list[SptFrame]:
182
+ """Read SPT spectral frames."""
183
+ from .pnrf import PnrfFile
184
+
185
+ assert isinstance(self._owner, PnrfFile)
186
+ return self._owner._read_spt_frames(self, start, count)
187
+
188
+ def read_spt_harmonics(
189
+ self,
190
+ orders: Optional[list[int]] = None,
191
+ start: int = 0,
192
+ count: Optional[int] = None,
193
+ rms: bool = True,
194
+ ) -> tuple[np.ndarray, np.ndarray]:
195
+ """Compute harmonic amplitudes from SPT frames.
196
+
197
+ Returns:
198
+ Tuple of (values, times) numpy arrays.
199
+ """
200
+ from .pnrf import PnrfFile
201
+
202
+ assert isinstance(self._owner, PnrfFile)
203
+ return self._owner._read_spt_harmonics(self, orders, start, count, rms)
204
+
205
+
206
+ class PnrfRecorder:
207
+ """A recorder (data acquisition unit) containing channels."""
208
+
209
+ def __init__(self) -> None:
210
+ self.physical_name: str = ""
211
+ self.logical_name: str = ""
212
+ self.group: Optional[PnrfGroup] = None
213
+ self.mainframe_network_name: Optional[str] = None
214
+ self.mainframe_serial_number: Optional[str] = None
215
+ self.serial_number: Optional[str] = None
216
+ self.time_base_offset: float = 0.0
217
+ self.mainframe_role: MainframeRole = MainframeRole.UNSYNCHRONIZED
218
+ self.recording_end_time: Optional[float] = None
219
+ self.channels: list[PnrfChannel] = []
220
+ self.sweep_records: list[AcquisitionRecord] = []
221
+ self.continuous_records: list[AcquisitionRecord] = []
222
+ self.trigger_records: list[TriggerRecord] = []
223
+
224
+ def __repr__(self) -> str:
225
+ return f"{self.logical_name} ({self.physical_name}), {len(self.channels)} ch"
226
+
227
+
228
+ class AcquisitionRecord:
229
+ """Sweep or continuous acquisition timing record."""
230
+
231
+ def __init__(self) -> None:
232
+ self.start_time: float = 0.0
233
+ self.sample_interval: float = 0.0
234
+ self.sample_count: int = 0
235
+ self.trigger_time: float = 0.0
236
+ self.trigger_source: TriggerSource = TriggerSource.INTERNAL
237
+
238
+ def __repr__(self) -> str:
239
+ return f"t={self.start_time}, n={self.sample_count}, dt={self.sample_interval}"
240
+
241
+
242
+ class TriggerRecord:
243
+ """A trigger event record."""
244
+
245
+ def __init__(self) -> None:
246
+ self.time: float = 0.0
247
+ # Internal fields
248
+ self._channel_bitmask: int = 0
249
+ self._trigger_source: TriggerSource = TriggerSource.INTERNAL
250
+
251
+ def __repr__(self) -> str:
252
+ return f"t={self.time}"
253
+
254
+
255
+ class PnrfGroup:
256
+ """A named group of recorders."""
257
+
258
+ def __init__(self, name: str = "") -> None:
259
+ self.name: str = name
260
+ self.recorders: list[PnrfRecorder] = []
261
+
262
+ def __repr__(self) -> str:
263
+ return f"{self.name}, {len(self.recorders)} recorders"
264
+
265
+ def get_recorder(self, name: str) -> Optional[PnrfRecorder]:
266
+ """Find recorder by logical or physical name (case-insensitive)."""
267
+ name_lower = name.lower()
268
+ for r in self.recorders:
269
+ if r.logical_name.lower() == name_lower or r.physical_name.lower() == name_lower:
270
+ return r
271
+ return None
272
+
273
+
274
+ class SptFrame:
275
+ """Spectral (FFT) frame from SPT blob data."""
276
+
277
+ def __init__(self) -> None:
278
+ self.timestamp: float = 0.0
279
+ self.length: int = 0
280
+ self.fundamental_frequency: float = 0.0
281
+ self.frequency_bin_count_per_harmonic_order: int = 0
282
+ self.fundamental_phase: float = 0.0
283
+ self.frequency_bin_count_per_component: int = 0
284
+ self.real: np.ndarray = np.empty(0, dtype=np.float64)
285
+ self.imag: np.ndarray = np.empty(0, dtype=np.float64)
286
+
287
+ def __repr__(self) -> str:
288
+ return f"t={self.timestamp}, len={self.length}, f0={self.fundamental_frequency}"