oscura 0.1.2__py3-none-any.whl → 0.4.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.
- oscura/__init__.py +1 -7
- oscura/acquisition/__init__.py +147 -0
- oscura/acquisition/file.py +255 -0
- oscura/acquisition/hardware.py +186 -0
- oscura/acquisition/saleae.py +340 -0
- oscura/acquisition/socketcan.py +315 -0
- oscura/acquisition/streaming.py +38 -0
- oscura/acquisition/synthetic.py +229 -0
- oscura/acquisition/visa.py +376 -0
- oscura/analyzers/__init__.py +3 -0
- oscura/analyzers/digital/clock.py +9 -1
- oscura/analyzers/digital/edges.py +1 -1
- oscura/analyzers/digital/timing.py +41 -11
- oscura/analyzers/packet/payload_extraction.py +2 -4
- oscura/analyzers/packet/stream.py +5 -5
- oscura/analyzers/patterns/__init__.py +4 -3
- oscura/analyzers/patterns/clustering.py +3 -1
- oscura/analyzers/power/ac_power.py +0 -2
- oscura/analyzers/power/basic.py +0 -2
- oscura/analyzers/power/ripple.py +0 -2
- oscura/analyzers/side_channel/__init__.py +52 -0
- oscura/analyzers/side_channel/power.py +690 -0
- oscura/analyzers/side_channel/timing.py +369 -0
- oscura/analyzers/signal_integrity/embedding.py +0 -2
- oscura/analyzers/signal_integrity/sparams.py +28 -206
- oscura/analyzers/spectral/fft.py +0 -2
- oscura/analyzers/statistical/__init__.py +3 -3
- oscura/analyzers/statistical/checksum.py +2 -0
- oscura/analyzers/statistical/classification.py +2 -0
- oscura/analyzers/statistical/entropy.py +11 -9
- oscura/analyzers/statistical/ngrams.py +4 -2
- oscura/api/fluent.py +2 -2
- oscura/automotive/__init__.py +4 -4
- oscura/automotive/can/__init__.py +0 -2
- oscura/automotive/can/patterns.py +3 -1
- oscura/automotive/can/session.py +277 -78
- oscura/automotive/can/state_machine.py +5 -2
- oscura/automotive/dbc/__init__.py +0 -2
- oscura/automotive/dtc/__init__.py +0 -2
- oscura/automotive/dtc/data.json +2763 -0
- oscura/automotive/dtc/database.py +37 -2769
- oscura/automotive/j1939/__init__.py +0 -2
- oscura/automotive/loaders/__init__.py +0 -2
- oscura/automotive/loaders/asc.py +0 -2
- oscura/automotive/loaders/blf.py +0 -2
- oscura/automotive/loaders/csv_can.py +0 -2
- oscura/automotive/obd/__init__.py +0 -2
- oscura/automotive/uds/__init__.py +0 -2
- oscura/automotive/uds/models.py +0 -2
- oscura/builders/__init__.py +9 -11
- oscura/builders/signal_builder.py +99 -191
- oscura/cli/main.py +0 -2
- oscura/cli/shell.py +0 -2
- oscura/config/loader.py +0 -2
- oscura/core/backend_selector.py +1 -1
- oscura/core/correlation.py +0 -2
- oscura/core/exceptions.py +61 -3
- oscura/core/lazy.py +5 -3
- oscura/core/memory_limits.py +0 -2
- oscura/core/numba_backend.py +5 -7
- oscura/core/uncertainty.py +3 -3
- oscura/dsl/interpreter.py +2 -0
- oscura/dsl/parser.py +8 -6
- oscura/exploratory/error_recovery.py +3 -3
- oscura/exploratory/parse.py +2 -0
- oscura/exploratory/recovery.py +2 -0
- oscura/exploratory/sync.py +2 -0
- oscura/export/wireshark/generator.py +1 -1
- oscura/export/wireshark/type_mapping.py +2 -0
- oscura/exporters/hdf5.py +1 -3
- oscura/extensibility/templates.py +0 -8
- oscura/inference/active_learning/lstar.py +2 -4
- oscura/inference/active_learning/observation_table.py +0 -2
- oscura/inference/active_learning/oracle.py +3 -1
- oscura/inference/active_learning/teachers/simulator.py +1 -3
- oscura/inference/alignment.py +2 -0
- oscura/inference/message_format.py +2 -0
- oscura/inference/protocol_dsl.py +7 -5
- oscura/inference/sequences.py +12 -14
- oscura/inference/state_machine.py +2 -0
- oscura/integrations/llm.py +3 -1
- oscura/jupyter/display.py +0 -2
- oscura/loaders/__init__.py +68 -51
- oscura/loaders/chipwhisperer.py +393 -0
- oscura/loaders/pcap.py +1 -1
- oscura/loaders/touchstone.py +221 -0
- oscura/math/arithmetic.py +0 -2
- oscura/optimization/parallel.py +9 -6
- oscura/pipeline/composition.py +0 -2
- oscura/plugins/cli.py +0 -2
- oscura/reporting/comparison.py +0 -2
- oscura/reporting/config.py +1 -1
- oscura/reporting/formatting/emphasis.py +2 -0
- oscura/reporting/formatting/numbers.py +0 -2
- oscura/reporting/output.py +1 -3
- oscura/reporting/sections.py +0 -2
- oscura/search/anomaly.py +2 -0
- oscura/session/session.py +91 -16
- oscura/sessions/__init__.py +70 -0
- oscura/sessions/base.py +323 -0
- oscura/sessions/blackbox.py +640 -0
- oscura/sessions/generic.py +189 -0
- oscura/testing/synthetic.py +2 -0
- oscura/ui/formatters.py +4 -2
- oscura/utils/buffer.py +2 -2
- oscura/utils/lazy.py +5 -5
- oscura/utils/memory_advanced.py +2 -2
- oscura/utils/memory_extensions.py +2 -2
- oscura/visualization/colors.py +0 -2
- oscura/visualization/power.py +2 -0
- oscura/workflows/multi_trace.py +2 -0
- {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/METADATA +122 -20
- {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/RECORD +116 -98
- {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/WHEEL +0 -0
- {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""ChipWhisperer trace loader.
|
|
2
|
+
|
|
3
|
+
This module loads power/EM traces from ChipWhisperer capture files (.npy, .trs).
|
|
4
|
+
|
|
5
|
+
ChipWhisperer is a widely-used open-source platform for side-channel analysis
|
|
6
|
+
and hardware security testing.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
>>> from oscura.loaders.chipwhisperer import load_chipwhisperer
|
|
10
|
+
>>> traces, metadata = load_chipwhisperer("capture_data.npy")
|
|
11
|
+
>>> print(f"Loaded {len(traces)} traces")
|
|
12
|
+
|
|
13
|
+
References:
|
|
14
|
+
ChipWhisperer Project: https://github.com/newaetech/chipwhisperer
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from oscura.core.exceptions import FormatError, LoaderError
|
|
26
|
+
from oscura.core.types import TraceMetadata, WaveformTrace
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from os import PathLike
|
|
30
|
+
|
|
31
|
+
from numpy.typing import NDArray
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ChipWhispererTraceSet",
|
|
35
|
+
"load_chipwhisperer",
|
|
36
|
+
"load_chipwhisperer_npy",
|
|
37
|
+
"load_chipwhisperer_trs",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ChipWhispererTraceSet:
|
|
43
|
+
"""ChipWhisperer trace set container.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
traces: Power/EM traces (n_traces, n_samples).
|
|
47
|
+
plaintexts: Input plaintexts (n_traces, plaintext_size).
|
|
48
|
+
ciphertexts: Output ciphertexts (n_traces, ciphertext_size).
|
|
49
|
+
keys: Encryption keys if known (n_traces, key_size).
|
|
50
|
+
sample_rate: Sample rate in Hz.
|
|
51
|
+
metadata: Additional metadata.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
traces: NDArray[np.floating[Any]]
|
|
55
|
+
plaintexts: NDArray[np.integer[Any]] | None = None
|
|
56
|
+
ciphertexts: NDArray[np.integer[Any]] | None = None
|
|
57
|
+
keys: NDArray[np.integer[Any]] | None = None
|
|
58
|
+
sample_rate: float = 1e6
|
|
59
|
+
metadata: dict[str, object] | None = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def n_traces(self) -> int:
|
|
63
|
+
"""Number of traces."""
|
|
64
|
+
return int(self.traces.shape[0])
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def n_samples(self) -> int:
|
|
68
|
+
"""Number of samples per trace."""
|
|
69
|
+
return int(self.traces.shape[1])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_chipwhisperer(
|
|
73
|
+
path: str | PathLike[str],
|
|
74
|
+
*,
|
|
75
|
+
sample_rate: float | None = None,
|
|
76
|
+
) -> ChipWhispererTraceSet:
|
|
77
|
+
"""Load ChipWhisperer traces from file.
|
|
78
|
+
|
|
79
|
+
Auto-detects file format (.npy, .trs) and delegates to appropriate loader.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
path: Path to ChipWhisperer trace file.
|
|
83
|
+
sample_rate: Override sample rate (if not in file).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
ChipWhispererTraceSet with traces and metadata.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
LoaderError: If file cannot be loaded.
|
|
90
|
+
FormatError: If file format invalid.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
>>> traceset = load_chipwhisperer("traces.npy")
|
|
94
|
+
>>> print(f"Loaded {traceset.n_traces} traces")
|
|
95
|
+
>>> print(f"Samples per trace: {traceset.n_samples}")
|
|
96
|
+
"""
|
|
97
|
+
path = Path(path)
|
|
98
|
+
|
|
99
|
+
if not path.exists():
|
|
100
|
+
raise LoaderError("File not found", file_path=str(path))
|
|
101
|
+
|
|
102
|
+
ext = path.suffix.lower()
|
|
103
|
+
|
|
104
|
+
if ext == ".npy":
|
|
105
|
+
return load_chipwhisperer_npy(path, sample_rate=sample_rate)
|
|
106
|
+
elif ext == ".trs":
|
|
107
|
+
return load_chipwhisperer_trs(path, sample_rate=sample_rate)
|
|
108
|
+
else:
|
|
109
|
+
raise FormatError(
|
|
110
|
+
f"Unsupported ChipWhisperer format: {ext}",
|
|
111
|
+
file_path=str(path),
|
|
112
|
+
expected=".npy or .trs",
|
|
113
|
+
got=ext,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_chipwhisperer_npy(
|
|
118
|
+
path: str | PathLike[str],
|
|
119
|
+
*,
|
|
120
|
+
sample_rate: float | None = None,
|
|
121
|
+
) -> ChipWhispererTraceSet:
|
|
122
|
+
"""Load ChipWhisperer traces from .npy file.
|
|
123
|
+
|
|
124
|
+
ChipWhisperer often saves trace data as numpy .npy files with
|
|
125
|
+
associated metadata in .npy files (textin.npy, textout.npy, etc.).
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
path: Path to traces .npy file.
|
|
129
|
+
sample_rate: Override sample rate.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
ChipWhispererTraceSet with traces and metadata.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
LoaderError: If file cannot be loaded.
|
|
136
|
+
|
|
137
|
+
Example:
|
|
138
|
+
>>> traceset = load_chipwhisperer_npy("traces.npy")
|
|
139
|
+
>>> # Look for associated files
|
|
140
|
+
>>> if traceset.plaintexts is not None:
|
|
141
|
+
... print("Plaintexts available")
|
|
142
|
+
"""
|
|
143
|
+
path = Path(path)
|
|
144
|
+
base_path = path.parent
|
|
145
|
+
base_name = path.stem
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
# Load main trace data
|
|
149
|
+
traces = np.load(path)
|
|
150
|
+
|
|
151
|
+
# Ensure 2D array (n_traces, n_samples)
|
|
152
|
+
if traces.ndim == 1:
|
|
153
|
+
traces = traces.reshape(1, -1)
|
|
154
|
+
elif traces.ndim > 2:
|
|
155
|
+
raise FormatError(
|
|
156
|
+
f"Expected 1D or 2D trace array, got {traces.ndim}D",
|
|
157
|
+
file_path=str(path),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
except (OSError, ValueError) as e:
|
|
161
|
+
# Catch file I/O errors, but let FormatError propagate
|
|
162
|
+
raise LoaderError(
|
|
163
|
+
"Failed to load trace file",
|
|
164
|
+
file_path=str(path),
|
|
165
|
+
details=str(e),
|
|
166
|
+
) from e
|
|
167
|
+
|
|
168
|
+
# Try to load associated files (common ChipWhisperer naming)
|
|
169
|
+
plaintexts = None
|
|
170
|
+
ciphertexts = None
|
|
171
|
+
keys = None
|
|
172
|
+
|
|
173
|
+
# Look for textin.npy (plaintexts)
|
|
174
|
+
textin_path = base_path / f"{base_name}_textin.npy"
|
|
175
|
+
if not textin_path.exists():
|
|
176
|
+
textin_path = base_path / "textin.npy"
|
|
177
|
+
if textin_path.exists():
|
|
178
|
+
try:
|
|
179
|
+
plaintexts = np.load(textin_path)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass # Optional metadata file, silently ignore if missing or corrupt # Not critical
|
|
182
|
+
|
|
183
|
+
# Look for textout.npy (ciphertexts)
|
|
184
|
+
textout_path = base_path / f"{base_name}_textout.npy"
|
|
185
|
+
if not textout_path.exists():
|
|
186
|
+
textout_path = base_path / "textout.npy"
|
|
187
|
+
if textout_path.exists():
|
|
188
|
+
try:
|
|
189
|
+
ciphertexts = np.load(textout_path)
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
# Look for keys.npy
|
|
194
|
+
keys_path = base_path / f"{base_name}_keys.npy"
|
|
195
|
+
if not keys_path.exists():
|
|
196
|
+
keys_path = base_path / "keys.npy"
|
|
197
|
+
if keys_path.exists():
|
|
198
|
+
try:
|
|
199
|
+
keys = np.load(keys_path)
|
|
200
|
+
except Exception:
|
|
201
|
+
pass # Optional metadata file, silently ignore if corrupt
|
|
202
|
+
|
|
203
|
+
# Use default sample rate if not specified
|
|
204
|
+
if sample_rate is None:
|
|
205
|
+
sample_rate = 1e6 # Default 1 MS/s
|
|
206
|
+
|
|
207
|
+
return ChipWhispererTraceSet(
|
|
208
|
+
traces=traces.astype(np.float64),
|
|
209
|
+
plaintexts=plaintexts.astype(np.uint8) if plaintexts is not None else None,
|
|
210
|
+
ciphertexts=ciphertexts.astype(np.uint8) if ciphertexts is not None else None,
|
|
211
|
+
keys=keys.astype(np.uint8) if keys is not None else None,
|
|
212
|
+
sample_rate=sample_rate,
|
|
213
|
+
metadata={
|
|
214
|
+
"source_file": str(path),
|
|
215
|
+
"format": "chipwhisperer_npy",
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def load_chipwhisperer_trs(
|
|
221
|
+
path: str | PathLike[str],
|
|
222
|
+
*,
|
|
223
|
+
sample_rate: float | None = None,
|
|
224
|
+
) -> ChipWhispererTraceSet:
|
|
225
|
+
"""Load ChipWhisperer traces from Inspector .trs file.
|
|
226
|
+
|
|
227
|
+
The .trs format is used by Riscure Inspector and supported by ChipWhisperer.
|
|
228
|
+
|
|
229
|
+
TRS file structure:
|
|
230
|
+
- Header with metadata
|
|
231
|
+
- Trace data (interleaved with trace-specific data)
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
path: Path to .trs file.
|
|
235
|
+
sample_rate: Override sample rate.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
ChipWhispererTraceSet with traces and metadata.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
LoaderError: If file cannot be loaded.
|
|
242
|
+
FormatError: If TRS format invalid.
|
|
243
|
+
|
|
244
|
+
Example:
|
|
245
|
+
>>> traceset = load_chipwhisperer_trs("capture.trs")
|
|
246
|
+
>>> print(f"Loaded {traceset.n_traces} traces")
|
|
247
|
+
|
|
248
|
+
References:
|
|
249
|
+
Inspector Trace Set (.trs) file format specification
|
|
250
|
+
"""
|
|
251
|
+
path = Path(path)
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
with open(path, "rb") as f:
|
|
255
|
+
# Read TRS header
|
|
256
|
+
# Tag-Length-Value structure
|
|
257
|
+
tags = {}
|
|
258
|
+
|
|
259
|
+
while True:
|
|
260
|
+
tag_byte = f.read(1)
|
|
261
|
+
if not tag_byte or tag_byte == b"\x5f": # End of header
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
tag = tag_byte[0]
|
|
265
|
+
length = int.from_bytes(f.read(1), byteorder="little")
|
|
266
|
+
|
|
267
|
+
# Extended length for large values
|
|
268
|
+
if length == 0xFF:
|
|
269
|
+
length = int.from_bytes(f.read(4), byteorder="little")
|
|
270
|
+
|
|
271
|
+
value = f.read(length)
|
|
272
|
+
tags[tag] = value
|
|
273
|
+
|
|
274
|
+
# Parse critical tags
|
|
275
|
+
# 0x41: Number of traces
|
|
276
|
+
n_traces = int.from_bytes(tags.get(0x41, b"\x00\x00"), byteorder="little")
|
|
277
|
+
|
|
278
|
+
# 0x42: Number of samples per trace
|
|
279
|
+
n_samples = int.from_bytes(tags.get(0x42, b"\x00\x00"), byteorder="little")
|
|
280
|
+
|
|
281
|
+
# 0x43: Sample coding (1=byte, 2=short, 4=float)
|
|
282
|
+
sample_coding = tags.get(0x43, b"\x01")[0]
|
|
283
|
+
|
|
284
|
+
# 0x44: Data length (plaintext/ciphertext)
|
|
285
|
+
data_length = int.from_bytes(tags.get(0x44, b"\x00\x00"), byteorder="little")
|
|
286
|
+
|
|
287
|
+
if n_traces == 0 or n_samples == 0:
|
|
288
|
+
raise FormatError(
|
|
289
|
+
"Invalid TRS file: zero traces or samples",
|
|
290
|
+
file_path=str(path),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Determine numpy dtype from sample coding
|
|
294
|
+
dtype: type[np.int8] | type[np.int16] | type[np.float32]
|
|
295
|
+
if sample_coding == 1:
|
|
296
|
+
dtype = np.int8
|
|
297
|
+
elif sample_coding == 2:
|
|
298
|
+
dtype = np.int16
|
|
299
|
+
elif sample_coding == 4:
|
|
300
|
+
dtype = np.float32
|
|
301
|
+
else:
|
|
302
|
+
raise FormatError(
|
|
303
|
+
f"Unsupported sample coding: {sample_coding}",
|
|
304
|
+
file_path=str(path),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Read traces
|
|
308
|
+
traces = np.zeros((n_traces, n_samples), dtype=np.float64)
|
|
309
|
+
plaintexts = (
|
|
310
|
+
np.zeros((n_traces, data_length), dtype=np.uint8) if data_length > 0 else None
|
|
311
|
+
)
|
|
312
|
+
ciphertexts = None # Not typically in TRS files
|
|
313
|
+
|
|
314
|
+
for trace_idx in range(n_traces):
|
|
315
|
+
# Read trace-specific data (plaintext/key)
|
|
316
|
+
if data_length > 0:
|
|
317
|
+
trace_data = np.frombuffer(f.read(data_length), dtype=np.uint8)
|
|
318
|
+
if plaintexts is not None:
|
|
319
|
+
plaintexts[trace_idx] = trace_data
|
|
320
|
+
|
|
321
|
+
# Read trace samples
|
|
322
|
+
trace_samples = np.frombuffer(f.read(n_samples * dtype(0).itemsize), dtype=dtype)
|
|
323
|
+
traces[trace_idx] = trace_samples.astype(np.float64)
|
|
324
|
+
|
|
325
|
+
except OSError as e:
|
|
326
|
+
raise LoaderError(
|
|
327
|
+
"Failed to read TRS file",
|
|
328
|
+
file_path=str(path),
|
|
329
|
+
details=str(e),
|
|
330
|
+
) from e
|
|
331
|
+
except Exception as e:
|
|
332
|
+
if isinstance(e, (LoaderError, FormatError)):
|
|
333
|
+
raise
|
|
334
|
+
raise LoaderError(
|
|
335
|
+
"Failed to parse TRS file",
|
|
336
|
+
file_path=str(path),
|
|
337
|
+
details=str(e),
|
|
338
|
+
) from e
|
|
339
|
+
|
|
340
|
+
# Use default sample rate if not specified
|
|
341
|
+
if sample_rate is None:
|
|
342
|
+
sample_rate = 1e6 # Default 1 MS/s
|
|
343
|
+
|
|
344
|
+
return ChipWhispererTraceSet(
|
|
345
|
+
traces=traces,
|
|
346
|
+
plaintexts=plaintexts,
|
|
347
|
+
ciphertexts=ciphertexts,
|
|
348
|
+
keys=None,
|
|
349
|
+
sample_rate=sample_rate,
|
|
350
|
+
metadata={
|
|
351
|
+
"source_file": str(path),
|
|
352
|
+
"format": "chipwhisperer_trs",
|
|
353
|
+
"n_traces": n_traces,
|
|
354
|
+
"n_samples": n_samples,
|
|
355
|
+
"sample_coding": sample_coding,
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def to_waveform_trace(
|
|
361
|
+
traceset: ChipWhispererTraceSet,
|
|
362
|
+
trace_index: int = 0,
|
|
363
|
+
) -> WaveformTrace:
|
|
364
|
+
"""Convert ChipWhisperer trace to WaveformTrace.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
traceset: ChipWhisperer trace set.
|
|
368
|
+
trace_index: Index of trace to convert.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
WaveformTrace for single trace.
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
IndexError: If trace_index out of range.
|
|
375
|
+
|
|
376
|
+
Example:
|
|
377
|
+
>>> traceset = load_chipwhisperer("traces.npy")
|
|
378
|
+
>>> trace = to_waveform_trace(traceset, trace_index=0)
|
|
379
|
+
>>> print(f"Sample rate: {trace.metadata.sample_rate} Hz")
|
|
380
|
+
"""
|
|
381
|
+
if not 0 <= trace_index < traceset.n_traces:
|
|
382
|
+
raise IndexError(f"trace_index {trace_index} out of range [0, {traceset.n_traces})")
|
|
383
|
+
|
|
384
|
+
metadata = TraceMetadata(
|
|
385
|
+
sample_rate=traceset.sample_rate,
|
|
386
|
+
source_file=str(traceset.metadata.get("source_file", "")) if traceset.metadata else "",
|
|
387
|
+
channel_name=f"trace_{trace_index}",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
return WaveformTrace(
|
|
391
|
+
data=traceset.traces[trace_index],
|
|
392
|
+
metadata=metadata,
|
|
393
|
+
)
|
oscura/loaders/pcap.py
CHANGED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Touchstone file loader for S-parameter data.
|
|
2
|
+
|
|
3
|
+
Supports .s1p through .s8p formats (Touchstone 1.0 and 2.0).
|
|
4
|
+
|
|
5
|
+
Example:
|
|
6
|
+
>>> from oscura.loaders import load_touchstone
|
|
7
|
+
>>> s_params = load_touchstone("cable.s2p")
|
|
8
|
+
>>> print(f"Loaded {s_params.n_ports}-port, {len(s_params.frequencies)} points")
|
|
9
|
+
|
|
10
|
+
References:
|
|
11
|
+
Touchstone 2.0 File Format Specification
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from oscura.analyzers.signal_integrity.sparams import SParameterData
|
|
23
|
+
from oscura.core.exceptions import FormatError, LoaderError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_touchstone(path: str | Path) -> SParameterData:
|
|
27
|
+
"""Load S-parameter data from Touchstone file.
|
|
28
|
+
|
|
29
|
+
Supports .s1p through .s8p formats and both Touchstone 1.0
|
|
30
|
+
and 2.0 file formats.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
path: Path to Touchstone file.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
SParameterData with loaded S-parameters.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
LoaderError: If file cannot be read.
|
|
40
|
+
FormatError: If file format is invalid.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
>>> s_params = load_touchstone("cable.s2p")
|
|
44
|
+
>>> print(f"Loaded {s_params.n_ports}-port, {len(s_params.frequencies)} points")
|
|
45
|
+
|
|
46
|
+
References:
|
|
47
|
+
Touchstone 2.0 File Format Specification
|
|
48
|
+
"""
|
|
49
|
+
path = Path(path)
|
|
50
|
+
|
|
51
|
+
if not path.exists():
|
|
52
|
+
raise LoaderError(f"File not found: {path}")
|
|
53
|
+
|
|
54
|
+
# Determine number of ports from extension
|
|
55
|
+
suffix = path.suffix.lower()
|
|
56
|
+
match = re.match(r"\.s(\d+)p", suffix)
|
|
57
|
+
if not match:
|
|
58
|
+
raise FormatError(f"Unsupported file extension: {suffix}")
|
|
59
|
+
|
|
60
|
+
n_ports = int(match.group(1))
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with open(path) as f:
|
|
64
|
+
lines = f.readlines()
|
|
65
|
+
except Exception as e:
|
|
66
|
+
raise LoaderError(f"Failed to read file: {e}") # noqa: B904
|
|
67
|
+
|
|
68
|
+
return _parse_touchstone(lines, n_ports, str(path))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_touchstone(
|
|
72
|
+
lines: list[str],
|
|
73
|
+
n_ports: int,
|
|
74
|
+
source_file: str,
|
|
75
|
+
) -> SParameterData:
|
|
76
|
+
"""Parse Touchstone file content.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
lines: File lines.
|
|
80
|
+
n_ports: Number of ports.
|
|
81
|
+
source_file: Source file path.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Parsed SParameterData.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
FormatError: If file format is invalid.
|
|
88
|
+
"""
|
|
89
|
+
comments = []
|
|
90
|
+
option_line = None
|
|
91
|
+
data_lines = []
|
|
92
|
+
|
|
93
|
+
for line in lines:
|
|
94
|
+
line = line.strip()
|
|
95
|
+
|
|
96
|
+
if not line:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if line.startswith("!"):
|
|
100
|
+
comments.append(line[1:].strip())
|
|
101
|
+
elif line.startswith("#"):
|
|
102
|
+
option_line = line
|
|
103
|
+
else:
|
|
104
|
+
data_lines.append(line)
|
|
105
|
+
|
|
106
|
+
# Parse option line
|
|
107
|
+
freq_unit = 1e9 # Default GHz
|
|
108
|
+
format_type = "ma" # Default MA (magnitude/angle)
|
|
109
|
+
z0 = 50.0
|
|
110
|
+
|
|
111
|
+
if option_line:
|
|
112
|
+
option_line = option_line.lower()
|
|
113
|
+
parts = option_line.split()
|
|
114
|
+
|
|
115
|
+
for i, part in enumerate(parts):
|
|
116
|
+
if part in ("hz", "khz", "mhz", "ghz"):
|
|
117
|
+
freq_unit = {
|
|
118
|
+
"hz": 1.0,
|
|
119
|
+
"khz": 1e3,
|
|
120
|
+
"mhz": 1e6,
|
|
121
|
+
"ghz": 1e9,
|
|
122
|
+
}[part]
|
|
123
|
+
elif part in ("db", "ma", "ri"):
|
|
124
|
+
format_type = part
|
|
125
|
+
elif part == "r":
|
|
126
|
+
# Reference impedance follows
|
|
127
|
+
if i + 1 < len(parts):
|
|
128
|
+
with contextlib.suppress(ValueError):
|
|
129
|
+
z0 = float(parts[i + 1])
|
|
130
|
+
|
|
131
|
+
# Parse data
|
|
132
|
+
frequencies = []
|
|
133
|
+
s_data = []
|
|
134
|
+
|
|
135
|
+
# Number of S-parameters per frequency
|
|
136
|
+
n_s_params = n_ports * n_ports
|
|
137
|
+
|
|
138
|
+
i = 0
|
|
139
|
+
while i < len(data_lines):
|
|
140
|
+
# First line has frequency and first S-parameters
|
|
141
|
+
parts = data_lines[i].split()
|
|
142
|
+
|
|
143
|
+
if len(parts) < 1:
|
|
144
|
+
i += 1
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
freq = float(parts[0]) * freq_unit
|
|
148
|
+
frequencies.append(freq)
|
|
149
|
+
|
|
150
|
+
# Collect all S-parameter values for this frequency
|
|
151
|
+
s_values = []
|
|
152
|
+
|
|
153
|
+
# Add values from first line
|
|
154
|
+
for j in range(1, len(parts), 2):
|
|
155
|
+
if j + 1 < len(parts):
|
|
156
|
+
val1 = float(parts[j])
|
|
157
|
+
val2 = float(parts[j + 1])
|
|
158
|
+
s_values.append((val1, val2))
|
|
159
|
+
|
|
160
|
+
i += 1
|
|
161
|
+
|
|
162
|
+
# Continue collecting from subsequent lines if needed
|
|
163
|
+
while len(s_values) < n_s_params and i < len(data_lines):
|
|
164
|
+
parts = data_lines[i].split()
|
|
165
|
+
|
|
166
|
+
# Check if this is a new frequency (has odd number of values)
|
|
167
|
+
try:
|
|
168
|
+
float(parts[0])
|
|
169
|
+
if len(parts) % 2 == 1:
|
|
170
|
+
break # New frequency line
|
|
171
|
+
except (ValueError, IndexError):
|
|
172
|
+
pass # Skip lines that can't be parsed as numeric data
|
|
173
|
+
|
|
174
|
+
for j in range(0, len(parts), 2):
|
|
175
|
+
if j + 1 < len(parts):
|
|
176
|
+
val1 = float(parts[j])
|
|
177
|
+
val2 = float(parts[j + 1])
|
|
178
|
+
s_values.append((val1, val2))
|
|
179
|
+
|
|
180
|
+
i += 1
|
|
181
|
+
|
|
182
|
+
# Convert to complex based on format
|
|
183
|
+
s_complex = []
|
|
184
|
+
for val1, val2 in s_values:
|
|
185
|
+
if format_type == "ri":
|
|
186
|
+
# Real/Imaginary
|
|
187
|
+
s_complex.append(complex(val1, val2))
|
|
188
|
+
elif format_type == "ma":
|
|
189
|
+
# Magnitude/Angle (degrees)
|
|
190
|
+
mag = val1
|
|
191
|
+
angle_rad = np.radians(val2)
|
|
192
|
+
s_complex.append(mag * np.exp(1j * angle_rad))
|
|
193
|
+
elif format_type == "db":
|
|
194
|
+
# dB/Angle (degrees)
|
|
195
|
+
mag = 10 ** (val1 / 20)
|
|
196
|
+
angle_rad = np.radians(val2)
|
|
197
|
+
s_complex.append(mag * np.exp(1j * angle_rad))
|
|
198
|
+
|
|
199
|
+
# Reshape into matrix
|
|
200
|
+
if len(s_complex) == n_s_params:
|
|
201
|
+
s_matrix = np.array(s_complex).reshape(n_ports, n_ports)
|
|
202
|
+
s_data.append(s_matrix)
|
|
203
|
+
|
|
204
|
+
if len(frequencies) == 0:
|
|
205
|
+
raise FormatError("No valid frequency points found")
|
|
206
|
+
|
|
207
|
+
frequencies_arr = np.array(frequencies, dtype=np.float64)
|
|
208
|
+
s_matrix_arr = np.array(s_data, dtype=np.complex128)
|
|
209
|
+
|
|
210
|
+
return SParameterData(
|
|
211
|
+
frequencies=frequencies_arr,
|
|
212
|
+
s_matrix=s_matrix_arr,
|
|
213
|
+
n_ports=n_ports,
|
|
214
|
+
z0=z0,
|
|
215
|
+
format=format_type,
|
|
216
|
+
source_file=source_file,
|
|
217
|
+
comments=comments,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = ["load_touchstone"]
|
oscura/math/arithmetic.py
CHANGED
oscura/optimization/parallel.py
CHANGED
|
@@ -14,12 +14,15 @@ from concurrent.futures import (
|
|
|
14
14
|
as_completed,
|
|
15
15
|
)
|
|
16
16
|
from dataclasses import dataclass
|
|
17
|
-
from typing import TYPE_CHECKING, Any
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
18
18
|
|
|
19
19
|
import numpy as np
|
|
20
20
|
|
|
21
21
|
from oscura.core.exceptions import AnalysisError
|
|
22
22
|
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
R = TypeVar("R")
|
|
25
|
+
|
|
23
26
|
if TYPE_CHECKING:
|
|
24
27
|
from collections.abc import Iterable
|
|
25
28
|
|
|
@@ -29,7 +32,7 @@ logger = logging.getLogger(__name__)
|
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
@dataclass
|
|
32
|
-
class ParallelResult[R]:
|
|
35
|
+
class ParallelResult(Generic[R]):
|
|
33
36
|
"""Result from parallel execution.
|
|
34
37
|
|
|
35
38
|
Attributes:
|
|
@@ -93,7 +96,7 @@ def get_optimal_workers(max_workers: int | None = None) -> int:
|
|
|
93
96
|
return min(max_workers, cpu_count)
|
|
94
97
|
|
|
95
98
|
|
|
96
|
-
def parallel_map
|
|
99
|
+
def parallel_map(
|
|
97
100
|
func: Callable[[T], R],
|
|
98
101
|
iterable: Iterable[T],
|
|
99
102
|
*,
|
|
@@ -171,7 +174,7 @@ def parallel_map[T, R](
|
|
|
171
174
|
)
|
|
172
175
|
|
|
173
176
|
|
|
174
|
-
def parallel_reduce
|
|
177
|
+
def parallel_reduce(
|
|
175
178
|
func: Callable[[T], R],
|
|
176
179
|
iterable: Iterable[T],
|
|
177
180
|
reducer: Callable[[list[R]], Any],
|
|
@@ -219,7 +222,7 @@ def parallel_reduce[T, R](
|
|
|
219
222
|
return reducer(result.results)
|
|
220
223
|
|
|
221
224
|
|
|
222
|
-
def batch_parallel_map
|
|
225
|
+
def batch_parallel_map(
|
|
223
226
|
func: Callable[[list[T]], list[R]],
|
|
224
227
|
iterable: Iterable[T],
|
|
225
228
|
*,
|
|
@@ -297,7 +300,7 @@ def batch_parallel_map[T, R](
|
|
|
297
300
|
)
|
|
298
301
|
|
|
299
302
|
|
|
300
|
-
def parallel_filter
|
|
303
|
+
def parallel_filter(
|
|
301
304
|
func: Callable[[T], bool],
|
|
302
305
|
iterable: Iterable[T],
|
|
303
306
|
*,
|
oscura/pipeline/composition.py
CHANGED
|
@@ -4,8 +4,6 @@ This module implements compose() and pipe() functions for functional-style
|
|
|
4
4
|
trace processing, with support for operator overloading.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
7
|
from collections.abc import Callable
|
|
10
8
|
from functools import reduce, wraps
|
|
11
9
|
from typing import Any, TypeVar
|
oscura/plugins/cli.py
CHANGED