powercontract 0.1.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.
- powercontract/__init__.py +3 -0
- powercontract/capture.py +498 -0
- powercontract/cli.py +91 -0
- powercontract/errors.py +10 -0
- powercontract/evaluate.py +347 -0
- powercontract/model.py +72 -0
- powercontract/spec.py +170 -0
- powercontract/units.py +49 -0
- powercontract-0.1.0.dist-info/METADATA +186 -0
- powercontract-0.1.0.dist-info/RECORD +14 -0
- powercontract-0.1.0.dist-info/WHEEL +5 -0
- powercontract-0.1.0.dist-info/entry_points.txt +2 -0
- powercontract-0.1.0.dist-info/licenses/LICENSE +21 -0
- powercontract-0.1.0.dist-info/top_level.txt +1 -0
powercontract/capture.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from math import isclose, isfinite
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import CaptureError
|
|
8
|
+
from .model import Capture, Contract
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_capture(
|
|
12
|
+
path: str | Path,
|
|
13
|
+
contract: Contract,
|
|
14
|
+
additional_paths: list[str | Path] | None = None,
|
|
15
|
+
) -> Capture:
|
|
16
|
+
paths = [Path(path), *(Path(p) for p in (additional_paths or []))]
|
|
17
|
+
if len(paths) > 1:
|
|
18
|
+
if all(_looks_like_siglent_csv(_read_lines(path)) for path in paths):
|
|
19
|
+
return _load_siglent_group(paths, contract)
|
|
20
|
+
raise CaptureError(
|
|
21
|
+
"multi-file capture groups currently require Siglent per-channel CSV; "
|
|
22
|
+
"use a single multi-channel CSV for other formats"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
path = paths[0]
|
|
26
|
+
lines = _read_lines(path)
|
|
27
|
+
if _looks_like_siglent_csv(lines):
|
|
28
|
+
return _load_siglent_group(paths, contract)
|
|
29
|
+
if _looks_like_rigol_sequence_csv(lines):
|
|
30
|
+
return _load_rigol_sequence_csv(lines, contract, path)
|
|
31
|
+
if _looks_like_paired_xy_csv(lines):
|
|
32
|
+
return _load_paired_xy_csv(lines, contract, path)
|
|
33
|
+
if _looks_like_source_units_csv(lines):
|
|
34
|
+
return _load_source_units_csv(lines, contract, path)
|
|
35
|
+
if _looks_like_legacy_metadata_xy_csv(lines):
|
|
36
|
+
return _load_legacy_metadata_xy_csv(lines, contract, path)
|
|
37
|
+
return _load_table_csv(lines, contract)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_lines(path: Path) -> list[str]:
|
|
41
|
+
try:
|
|
42
|
+
lines = path.read_text(encoding="utf-8-sig").splitlines()
|
|
43
|
+
except OSError as exc:
|
|
44
|
+
raise CaptureError(f"cannot read capture {path}: {exc}") from exc
|
|
45
|
+
if not lines:
|
|
46
|
+
raise CaptureError(f"capture {path} is empty")
|
|
47
|
+
return lines
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_table_csv(lines: list[str], contract: Contract) -> Capture:
|
|
51
|
+
expected = {contract.time_column, *(s.column for s in contract.signals.values())}
|
|
52
|
+
header_index, delimiter = _find_header(lines, expected)
|
|
53
|
+
reader = csv.DictReader(lines[header_index:], delimiter=delimiter)
|
|
54
|
+
if reader.fieldnames is None:
|
|
55
|
+
raise CaptureError("capture has no header")
|
|
56
|
+
reader.fieldnames = [field.strip().strip('"') for field in reader.fieldnames]
|
|
57
|
+
if len(set(reader.fieldnames)) != len(reader.fieldnames):
|
|
58
|
+
raise CaptureError("capture has duplicate column names")
|
|
59
|
+
missing = expected.difference(reader.fieldnames)
|
|
60
|
+
if missing:
|
|
61
|
+
raise CaptureError(f"capture is missing columns: {', '.join(sorted(missing))}")
|
|
62
|
+
|
|
63
|
+
times: list[float] = []
|
|
64
|
+
signal_values: dict[str, list[float]] = {name: [] for name in contract.signals}
|
|
65
|
+
for row_number, row in enumerate(reader, start=header_index + 2):
|
|
66
|
+
if not any((value or "").strip() for value in row.values()):
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
t = float(row[contract.time_column])
|
|
70
|
+
values = {name: float(row[signal.column]) for name, signal in contract.signals.items()}
|
|
71
|
+
except (TypeError, ValueError) as exc:
|
|
72
|
+
raise CaptureError(f"row {row_number} contains non-numeric waveform data") from exc
|
|
73
|
+
_validate_sample_row(t, values.values(), row_number)
|
|
74
|
+
if times and t <= times[-1]:
|
|
75
|
+
raise CaptureError(f"time must be strictly increasing (row {row_number})")
|
|
76
|
+
times.append(t)
|
|
77
|
+
for name, value in values.items():
|
|
78
|
+
signal_values[name].append(value)
|
|
79
|
+
|
|
80
|
+
if len(times) < 2:
|
|
81
|
+
raise CaptureError("capture needs at least two samples")
|
|
82
|
+
return Capture(tuple(times), {name: tuple(values) for name, values in signal_values.items()})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _row(line: str) -> list[str]:
|
|
87
|
+
fields = [field.strip().strip('"') for field in next(csv.reader([line]))]
|
|
88
|
+
while fields and not fields[-1]:
|
|
89
|
+
fields.pop()
|
|
90
|
+
return fields
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _looks_like_rigol_sequence_csv(lines: list[str]) -> bool:
|
|
94
|
+
if len(lines) < 3:
|
|
95
|
+
return False
|
|
96
|
+
names = _row(lines[0])
|
|
97
|
+
units = _row(lines[1])
|
|
98
|
+
return (
|
|
99
|
+
len(names) >= 4
|
|
100
|
+
and len(units) >= len(names) - 1
|
|
101
|
+
and names[0].lower() == "x"
|
|
102
|
+
and "Start" in names
|
|
103
|
+
and "Increment" in names
|
|
104
|
+
and units[0].lower() == "sequence"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _load_rigol_sequence_csv(lines: list[str], contract: Contract, path: Path) -> Capture:
|
|
109
|
+
names = _row(lines[0])
|
|
110
|
+
units = _row(lines[1])
|
|
111
|
+
start_index = names.index("Start")
|
|
112
|
+
increment_index = names.index("Increment")
|
|
113
|
+
source_names = names[1:start_index]
|
|
114
|
+
if not source_names:
|
|
115
|
+
raise CaptureError(f"{path}: Rigol CSV contains no analog sources")
|
|
116
|
+
if len(set(source_names)) != len(source_names):
|
|
117
|
+
raise CaptureError(f"{path}: Rigol CSV contains duplicate source names")
|
|
118
|
+
if any(units[index].lower() not in {"volt", "v"} for index in range(1, start_index)):
|
|
119
|
+
raise CaptureError(f"{path}: Rigol CSV analog sources must be in volts")
|
|
120
|
+
|
|
121
|
+
expected = {signal.column for signal in contract.signals.values()}
|
|
122
|
+
missing = expected.difference(source_names)
|
|
123
|
+
if missing:
|
|
124
|
+
raise CaptureError(f"{path}: Rigol CSV is missing source(s): {', '.join(sorted(missing))}")
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
start = float(units[start_index])
|
|
128
|
+
increment = float(units[increment_index])
|
|
129
|
+
except (IndexError, ValueError) as exc:
|
|
130
|
+
raise CaptureError(f"{path}: Rigol CSV has invalid Start/Increment metadata") from exc
|
|
131
|
+
if not isfinite(start) or not isfinite(increment) or increment <= 0:
|
|
132
|
+
raise CaptureError(f"{path}: Rigol Start/Increment must be finite and increment > 0")
|
|
133
|
+
|
|
134
|
+
source_positions = {name: names.index(name) for name in expected}
|
|
135
|
+
times: list[float] = []
|
|
136
|
+
signal_values: dict[str, list[float]] = {name: [] for name in contract.signals}
|
|
137
|
+
for row_number, line in enumerate(lines[2:], start=3):
|
|
138
|
+
fields = _row(line)
|
|
139
|
+
if not fields or not any(fields):
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
sequence = float(fields[0])
|
|
143
|
+
t = start + sequence * increment
|
|
144
|
+
values = {
|
|
145
|
+
name: float(fields[source_positions[signal.column]])
|
|
146
|
+
for name, signal in contract.signals.items()
|
|
147
|
+
}
|
|
148
|
+
except (IndexError, ValueError) as exc:
|
|
149
|
+
raise CaptureError(f"{path}: row {row_number} contains invalid Rigol waveform data") from exc
|
|
150
|
+
_validate_sample_row(t, values.values(), row_number, path)
|
|
151
|
+
if times and t <= times[-1]:
|
|
152
|
+
raise CaptureError(f"{path}: derived time must be strictly increasing (row {row_number})")
|
|
153
|
+
times.append(t)
|
|
154
|
+
for name, value in values.items():
|
|
155
|
+
signal_values[name].append(value)
|
|
156
|
+
|
|
157
|
+
if len(times) < 2:
|
|
158
|
+
raise CaptureError(f"{path}: capture needs at least two samples")
|
|
159
|
+
return Capture(tuple(times), {name: tuple(values) for name, values in signal_values.items()})
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _looks_like_source_units_csv(lines: list[str]) -> bool:
|
|
163
|
+
if len(lines) < 3:
|
|
164
|
+
return False
|
|
165
|
+
names = _row(lines[0])
|
|
166
|
+
units = _row(lines[1])
|
|
167
|
+
if len(names) < 2 or len(units) < len(names):
|
|
168
|
+
return False
|
|
169
|
+
return (
|
|
170
|
+
names[0].lower() in {"source", "x-axis", "x", "time"}
|
|
171
|
+
and units[0].lower() in {"second", "seconds", "s"}
|
|
172
|
+
and all(unit.lower() in {"volt", "volts", "v"} for unit in units[1:len(names)])
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _load_source_units_csv(lines: list[str], contract: Contract, path: Path) -> Capture:
|
|
177
|
+
names = _row(lines[0])
|
|
178
|
+
source_names = names[1:]
|
|
179
|
+
if len(set(source_names)) != len(source_names):
|
|
180
|
+
raise CaptureError(f"{path}: capture contains duplicate source names")
|
|
181
|
+
expected = {signal.column for signal in contract.signals.values()}
|
|
182
|
+
missing = expected.difference(source_names)
|
|
183
|
+
if missing:
|
|
184
|
+
raise CaptureError(f"{path}: capture is missing source(s): {', '.join(sorted(missing))}")
|
|
185
|
+
|
|
186
|
+
positions = {source: source_names.index(source) + 1 for source in expected}
|
|
187
|
+
times: list[float] = []
|
|
188
|
+
signal_values: dict[str, list[float]] = {name: [] for name in contract.signals}
|
|
189
|
+
for row_number, line in enumerate(lines[2:], start=3):
|
|
190
|
+
fields = _row(line)
|
|
191
|
+
if not fields or not any(fields):
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
t = float(fields[0])
|
|
195
|
+
values = {
|
|
196
|
+
name: float(fields[positions[signal.column]])
|
|
197
|
+
for name, signal in contract.signals.items()
|
|
198
|
+
}
|
|
199
|
+
except (IndexError, ValueError) as exc:
|
|
200
|
+
raise CaptureError(f"{path}: row {row_number} contains invalid waveform data") from exc
|
|
201
|
+
_validate_sample_row(t, values.values(), row_number, path)
|
|
202
|
+
if times and t <= times[-1]:
|
|
203
|
+
raise CaptureError(f"{path}: time must be strictly increasing (row {row_number})")
|
|
204
|
+
times.append(t)
|
|
205
|
+
for name, value in values.items():
|
|
206
|
+
signal_values[name].append(value)
|
|
207
|
+
|
|
208
|
+
if len(times) < 2:
|
|
209
|
+
raise CaptureError(f"{path}: capture needs at least two samples")
|
|
210
|
+
return Capture(tuple(times), {name: tuple(values) for name, values in signal_values.items()})
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _looks_like_paired_xy_csv(lines: list[str]) -> bool:
|
|
214
|
+
if len(lines) < 3:
|
|
215
|
+
return False
|
|
216
|
+
names = _row(lines[0])
|
|
217
|
+
units = _row(lines[1])
|
|
218
|
+
if len(names) < 2 or len(names) % 2 or len(units) < len(names):
|
|
219
|
+
return False
|
|
220
|
+
return all(
|
|
221
|
+
units[index].lower() in {"second", "seconds", "s"}
|
|
222
|
+
and units[index + 1].lower() in {"volt", "volts", "v"}
|
|
223
|
+
for index in range(0, len(names), 2)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _load_paired_xy_csv(lines: list[str], contract: Contract, path: Path) -> Capture:
|
|
228
|
+
names = _row(lines[0])
|
|
229
|
+
source_positions = {names[index + 1]: (index, index + 1) for index in range(0, len(names), 2)}
|
|
230
|
+
if len(source_positions) != len(names) // 2:
|
|
231
|
+
raise CaptureError(f"{path}: paired XY CSV contains duplicate source names")
|
|
232
|
+
expected = {signal.column for signal in contract.signals.values()}
|
|
233
|
+
missing = expected.difference(source_positions)
|
|
234
|
+
if missing:
|
|
235
|
+
raise CaptureError(f"{path}: paired XY CSV is missing source(s): {', '.join(sorted(missing))}")
|
|
236
|
+
|
|
237
|
+
source_times: dict[str, list[float]] = {source: [] for source in expected}
|
|
238
|
+
source_values: dict[str, list[float]] = {source: [] for source in expected}
|
|
239
|
+
for row_number, line in enumerate(lines[2:], start=3):
|
|
240
|
+
fields = _row(line)
|
|
241
|
+
if not fields or not any(fields):
|
|
242
|
+
continue
|
|
243
|
+
for source in expected:
|
|
244
|
+
time_index, value_index = source_positions[source]
|
|
245
|
+
try:
|
|
246
|
+
t = float(fields[time_index])
|
|
247
|
+
value = float(fields[value_index])
|
|
248
|
+
except (IndexError, ValueError) as exc:
|
|
249
|
+
raise CaptureError(f"{path}: row {row_number} contains invalid paired XY data") from exc
|
|
250
|
+
_validate_sample_row(t, (value,), row_number, path)
|
|
251
|
+
if source_times[source] and t <= source_times[source][-1]:
|
|
252
|
+
raise CaptureError(f"{path}: {source} time must be strictly increasing (row {row_number})")
|
|
253
|
+
source_times[source].append(t)
|
|
254
|
+
source_values[source].append(value)
|
|
255
|
+
|
|
256
|
+
reference_source = next(iter(sorted(expected)))
|
|
257
|
+
reference_times = tuple(source_times[reference_source])
|
|
258
|
+
if len(reference_times) < 2:
|
|
259
|
+
raise CaptureError(f"{path}: capture needs at least two samples")
|
|
260
|
+
for source in expected:
|
|
261
|
+
if tuple(source_times[source]) != reference_times:
|
|
262
|
+
raise CaptureError(f"{path}: paired XY source {source} does not share the same time axis")
|
|
263
|
+
|
|
264
|
+
signal_values = {
|
|
265
|
+
name: tuple(source_values[signal.column])
|
|
266
|
+
for name, signal in contract.signals.items()
|
|
267
|
+
}
|
|
268
|
+
return Capture(reference_times, signal_values)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
_LEGACY_XY_METADATA = {
|
|
272
|
+
"Record Length",
|
|
273
|
+
"Sample Interval",
|
|
274
|
+
"Trigger Point",
|
|
275
|
+
"Trigger Time",
|
|
276
|
+
"Horizontal Offset",
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _looks_like_legacy_metadata_xy_csv(lines: list[str]) -> bool:
|
|
281
|
+
hits = 0
|
|
282
|
+
numeric_xy = 0
|
|
283
|
+
for line in lines[:20]:
|
|
284
|
+
fields = _row(line)
|
|
285
|
+
if fields and fields[0] in _LEGACY_XY_METADATA:
|
|
286
|
+
hits += 1
|
|
287
|
+
if len(fields) >= 5:
|
|
288
|
+
try:
|
|
289
|
+
float(fields[3])
|
|
290
|
+
float(fields[4])
|
|
291
|
+
except ValueError:
|
|
292
|
+
pass
|
|
293
|
+
else:
|
|
294
|
+
numeric_xy += 1
|
|
295
|
+
return hits >= 2 and numeric_xy >= 2
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _load_legacy_metadata_xy_csv(lines: list[str], contract: Contract, path: Path) -> Capture:
|
|
299
|
+
if len(contract.signals) != 1:
|
|
300
|
+
raise CaptureError(
|
|
301
|
+
f"{path}: legacy metadata+XY CSV contains one waveform; "
|
|
302
|
+
"use a single-signal contract or normalize multiple traces into one table"
|
|
303
|
+
)
|
|
304
|
+
signal_name, signal = next(iter(contract.signals.items()))
|
|
305
|
+
if signal.column.lower() not in {"value", "voltage", "amplitude"}:
|
|
306
|
+
raise CaptureError(
|
|
307
|
+
f"{path}: legacy metadata+XY CSV has no embedded channel name; "
|
|
308
|
+
f"map signals.{signal_name}.column to Value"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
times: list[float] = []
|
|
312
|
+
values: list[float] = []
|
|
313
|
+
for row_number, line in enumerate(lines, start=1):
|
|
314
|
+
fields = _row(line)
|
|
315
|
+
if len(fields) < 5:
|
|
316
|
+
continue
|
|
317
|
+
try:
|
|
318
|
+
t = float(fields[3])
|
|
319
|
+
value = float(fields[4])
|
|
320
|
+
except ValueError:
|
|
321
|
+
continue
|
|
322
|
+
_validate_sample_row(t, (value,), row_number, path)
|
|
323
|
+
if times and t <= times[-1]:
|
|
324
|
+
raise CaptureError(f"{path}: time must be strictly increasing (row {row_number})")
|
|
325
|
+
times.append(t)
|
|
326
|
+
values.append(value)
|
|
327
|
+
|
|
328
|
+
if len(times) < 2:
|
|
329
|
+
raise CaptureError(f"{path}: capture needs at least two waveform samples")
|
|
330
|
+
return Capture(tuple(times), {signal_name: tuple(values)})
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _looks_like_siglent_csv(lines: list[str]) -> bool:
|
|
334
|
+
head = lines[:100]
|
|
335
|
+
return "Second,Value" in head and any(line.startswith("Source,CH") for line in head)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _load_siglent_group(paths: list[Path], contract: Contract) -> Capture:
|
|
339
|
+
traces: dict[str, tuple[dict[str, str], tuple[float, ...], tuple[float, ...]]] = {}
|
|
340
|
+
for path in paths:
|
|
341
|
+
metadata, times, values = _read_siglent_trace(path)
|
|
342
|
+
source = metadata["Source"]
|
|
343
|
+
if source in traces:
|
|
344
|
+
raise CaptureError(f"Siglent source {source} appears more than once")
|
|
345
|
+
traces[source] = (metadata, times, values)
|
|
346
|
+
|
|
347
|
+
expected_sources = {signal.column for signal in contract.signals.values()}
|
|
348
|
+
missing = expected_sources.difference(traces)
|
|
349
|
+
if missing:
|
|
350
|
+
raise CaptureError(
|
|
351
|
+
"Siglent capture group is missing source(s): " + ", ".join(sorted(missing))
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
reference_source = next(iter(sorted(traces)))
|
|
355
|
+
reference_meta, reference_times, _ = traces[reference_source]
|
|
356
|
+
comparable_metadata = (
|
|
357
|
+
"Record Length",
|
|
358
|
+
"Sample Interval",
|
|
359
|
+
"Horizontal Units",
|
|
360
|
+
"Horizontal Scale",
|
|
361
|
+
"Model",
|
|
362
|
+
"Serial Number",
|
|
363
|
+
"Software Version",
|
|
364
|
+
)
|
|
365
|
+
# Validate every file the user supplied, not only the sources referenced by
|
|
366
|
+
# the contract. Silently ignoring an incompatible extra file would make a
|
|
367
|
+
# claimed capture group less trustworthy.
|
|
368
|
+
for source in sorted(traces):
|
|
369
|
+
metadata, times, _ = traces[source]
|
|
370
|
+
if times != reference_times:
|
|
371
|
+
raise CaptureError(f"Siglent source {source} does not share the same time axis")
|
|
372
|
+
for key in comparable_metadata:
|
|
373
|
+
if metadata.get(key) != reference_meta.get(key):
|
|
374
|
+
raise CaptureError(
|
|
375
|
+
f"Siglent source {source} has different {key!r}: "
|
|
376
|
+
f"{metadata.get(key)!r} != {reference_meta.get(key)!r}"
|
|
377
|
+
)
|
|
378
|
+
if metadata.get("Horizontal Units") != "s":
|
|
379
|
+
raise CaptureError(f"Siglent source {source} horizontal units must be seconds")
|
|
380
|
+
if metadata.get("Vertical Units") != "V":
|
|
381
|
+
raise CaptureError(f"Siglent source {source} vertical units must be volts")
|
|
382
|
+
|
|
383
|
+
signal_values = {
|
|
384
|
+
name: traces[signal.column][2]
|
|
385
|
+
for name, signal in contract.signals.items()
|
|
386
|
+
}
|
|
387
|
+
return Capture(reference_times, signal_values)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _read_siglent_trace(
|
|
391
|
+
path: Path,
|
|
392
|
+
) -> tuple[dict[str, str], tuple[float, ...], tuple[float, ...]]:
|
|
393
|
+
lines = _read_lines(path)
|
|
394
|
+
try:
|
|
395
|
+
header_index = lines.index("Second,Value")
|
|
396
|
+
except ValueError as exc:
|
|
397
|
+
raise CaptureError(f"{path} is not a supported Siglent CSV export") from exc
|
|
398
|
+
|
|
399
|
+
metadata: dict[str, str] = {}
|
|
400
|
+
for line in lines[:header_index]:
|
|
401
|
+
fields = next(csv.reader([line]))
|
|
402
|
+
if len(fields) >= 2:
|
|
403
|
+
key = fields[0].strip()
|
|
404
|
+
if not key:
|
|
405
|
+
continue
|
|
406
|
+
if key in metadata:
|
|
407
|
+
raise CaptureError(f"{path}: duplicate Siglent metadata field {key!r}")
|
|
408
|
+
metadata[key] = fields[1].strip()
|
|
409
|
+
|
|
410
|
+
required_meta = {
|
|
411
|
+
"Record Length",
|
|
412
|
+
"Sample Interval",
|
|
413
|
+
"Vertical Units",
|
|
414
|
+
"Horizontal Units",
|
|
415
|
+
"Source",
|
|
416
|
+
}
|
|
417
|
+
missing_meta = required_meta.difference(metadata)
|
|
418
|
+
if missing_meta:
|
|
419
|
+
raise CaptureError(
|
|
420
|
+
f"{path} is missing Siglent metadata: {', '.join(sorted(missing_meta))}"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
reader = csv.DictReader(lines[header_index:])
|
|
424
|
+
times: list[float] = []
|
|
425
|
+
values: list[float] = []
|
|
426
|
+
for row_number, row in enumerate(reader, start=header_index + 2):
|
|
427
|
+
try:
|
|
428
|
+
t = float(row["Second"])
|
|
429
|
+
value = float(row["Value"])
|
|
430
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
431
|
+
raise CaptureError(f"{path}: row {row_number} is not numeric") from exc
|
|
432
|
+
_validate_sample_row(t, (value,), row_number, path)
|
|
433
|
+
if times and t <= times[-1]:
|
|
434
|
+
raise CaptureError(f"{path}: time must be strictly increasing (row {row_number})")
|
|
435
|
+
times.append(t)
|
|
436
|
+
values.append(value)
|
|
437
|
+
|
|
438
|
+
if len(times) < 2:
|
|
439
|
+
raise CaptureError(f"{path}: capture needs at least two samples")
|
|
440
|
+
|
|
441
|
+
record_length = metadata["Record Length"]
|
|
442
|
+
if not record_length.startswith("Analog:"):
|
|
443
|
+
raise CaptureError(f"{path}: unsupported Record Length {record_length!r}")
|
|
444
|
+
try:
|
|
445
|
+
declared = int(record_length.removeprefix("Analog:"))
|
|
446
|
+
except ValueError as exc:
|
|
447
|
+
raise CaptureError(f"{path}: invalid Record Length {record_length!r}") from exc
|
|
448
|
+
if declared != len(times):
|
|
449
|
+
raise CaptureError(
|
|
450
|
+
f"{path}: Record Length declares {declared} samples but file contains {len(times)}"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
declared_interval = float(metadata["Sample Interval"])
|
|
455
|
+
except ValueError as exc:
|
|
456
|
+
raise CaptureError(
|
|
457
|
+
f"{path}: invalid Sample Interval {metadata['Sample Interval']!r}"
|
|
458
|
+
) from exc
|
|
459
|
+
if not isfinite(declared_interval) or declared_interval <= 0:
|
|
460
|
+
raise CaptureError(
|
|
461
|
+
f"{path}: Sample Interval must be finite and > 0, got {metadata['Sample Interval']!r}"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
interval_abs_tol = max(1e-15, declared_interval * 1e-9)
|
|
465
|
+
for index in range(1, len(times)):
|
|
466
|
+
observed_interval = times[index] - times[index - 1]
|
|
467
|
+
if not isclose(
|
|
468
|
+
observed_interval,
|
|
469
|
+
declared_interval,
|
|
470
|
+
rel_tol=1e-6,
|
|
471
|
+
abs_tol=interval_abs_tol,
|
|
472
|
+
):
|
|
473
|
+
raise CaptureError(
|
|
474
|
+
f"{path}: time axis interval at sample {index} is {observed_interval:.9g} s "
|
|
475
|
+
f"but metadata declares {declared_interval:.9g} s"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
return metadata, tuple(times), tuple(values)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _validate_sample_row(
|
|
482
|
+
time: float,
|
|
483
|
+
values,
|
|
484
|
+
row_number: int,
|
|
485
|
+
path: Path | None = None,
|
|
486
|
+
) -> None:
|
|
487
|
+
prefix = f"{path}: " if path is not None else ""
|
|
488
|
+
if not isfinite(time) or any(not isfinite(value) for value in values):
|
|
489
|
+
raise CaptureError(f"{prefix}row {row_number} contains non-finite waveform data")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _find_header(lines: list[str], expected: set[str]) -> tuple[int, str]:
|
|
493
|
+
for index, line in enumerate(lines[:100]):
|
|
494
|
+
for delimiter in (",", ";", "\t"):
|
|
495
|
+
fields = [field.strip().strip('"') for field in next(csv.reader([line], delimiter=delimiter))]
|
|
496
|
+
if expected.issubset(fields):
|
|
497
|
+
return index, delimiter
|
|
498
|
+
raise CaptureError("could not find a CSV header containing all configured columns")
|
powercontract/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .capture import load_capture
|
|
10
|
+
from .errors import PowerContractError
|
|
11
|
+
from .evaluate import evaluate
|
|
12
|
+
from .spec import load_contract
|
|
13
|
+
from .units import format_seconds
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="powercontract",
|
|
19
|
+
description="Validate oscilloscope captures against electrical requirements.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--version",
|
|
23
|
+
action="version",
|
|
24
|
+
version=f"%(prog)s {__version__}",
|
|
25
|
+
)
|
|
26
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
27
|
+
check = sub.add_parser("check", help="evaluate capture data against one contract")
|
|
28
|
+
check.add_argument("capture", type=Path)
|
|
29
|
+
check.add_argument("contract", type=Path)
|
|
30
|
+
check.add_argument(
|
|
31
|
+
"--with",
|
|
32
|
+
dest="additional_captures",
|
|
33
|
+
action="append",
|
|
34
|
+
default=[],
|
|
35
|
+
type=Path,
|
|
36
|
+
metavar="CAPTURE",
|
|
37
|
+
help="add another per-channel capture file from the same acquisition",
|
|
38
|
+
)
|
|
39
|
+
check.add_argument("--json", dest="json_path", type=Path, help="write a machine-readable report")
|
|
40
|
+
return parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv: list[str] | None = None) -> int:
|
|
44
|
+
args = build_parser().parse_args(argv)
|
|
45
|
+
try:
|
|
46
|
+
contract = load_contract(args.contract)
|
|
47
|
+
capture = load_capture(args.capture, contract, args.additional_captures)
|
|
48
|
+
result = evaluate(contract, capture)
|
|
49
|
+
except PowerContractError as exc:
|
|
50
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
51
|
+
return 2
|
|
52
|
+
|
|
53
|
+
for finding in result.findings:
|
|
54
|
+
margin = ""
|
|
55
|
+
if finding.margin is not None:
|
|
56
|
+
if finding.unit == "s":
|
|
57
|
+
margin = f"; margin {format_seconds(finding.margin)}"
|
|
58
|
+
else:
|
|
59
|
+
margin = f"; margin {finding.margin:.6g} {finding.unit or ''}".rstrip()
|
|
60
|
+
print(f"{finding.status:<13} {finding.requirement_id:<16} {finding.summary}{margin}")
|
|
61
|
+
|
|
62
|
+
passed = sum(f.status == "PASS" for f in result.findings)
|
|
63
|
+
failed = sum(f.status == "FAIL" for f in result.findings)
|
|
64
|
+
indeterminate = sum(f.status == "INDETERMINATE" for f in result.findings)
|
|
65
|
+
print(
|
|
66
|
+
f"\n{passed}/{len(result.findings)} requirements passed"
|
|
67
|
+
f"; {failed} failed; {indeterminate} indeterminate"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if args.json_path:
|
|
71
|
+
payload = {
|
|
72
|
+
"schema_version": 1,
|
|
73
|
+
"powercontract_version": __version__,
|
|
74
|
+
"passed": result.passed,
|
|
75
|
+
"events": result.events,
|
|
76
|
+
"findings": [finding.as_dict() for finding in result.findings],
|
|
77
|
+
}
|
|
78
|
+
try:
|
|
79
|
+
args.json_path.write_text(
|
|
80
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
)
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
print(f"error: cannot write JSON report: {exc}", file=sys.stderr)
|
|
85
|
+
return 2
|
|
86
|
+
|
|
87
|
+
return 0 if result.passed else 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
raise SystemExit(main())
|
powercontract/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class PowerContractError(Exception):
|
|
2
|
+
"""Base error for invalid input or unsupported specifications."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SpecError(PowerContractError):
|
|
6
|
+
"""Raised when a contract file is invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CaptureError(PowerContractError):
|
|
10
|
+
"""Raised when a capture cannot be parsed safely."""
|