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.
- perception_pnrf/__init__.py +31 -0
- perception_pnrf/dump.py +310 -0
- perception_pnrf/models.py +288 -0
- perception_pnrf/pnrf.py +2666 -0
- perception_pnrf-1.0.0.dist-info/METADATA +128 -0
- perception_pnrf-1.0.0.dist-info/RECORD +8 -0
- perception_pnrf-1.0.0.dist-info/WHEEL +5 -0
- perception_pnrf-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
perception_pnrf/dump.py
ADDED
|
@@ -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}"
|