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.
Files changed (116) hide show
  1. oscura/__init__.py +1 -7
  2. oscura/acquisition/__init__.py +147 -0
  3. oscura/acquisition/file.py +255 -0
  4. oscura/acquisition/hardware.py +186 -0
  5. oscura/acquisition/saleae.py +340 -0
  6. oscura/acquisition/socketcan.py +315 -0
  7. oscura/acquisition/streaming.py +38 -0
  8. oscura/acquisition/synthetic.py +229 -0
  9. oscura/acquisition/visa.py +376 -0
  10. oscura/analyzers/__init__.py +3 -0
  11. oscura/analyzers/digital/clock.py +9 -1
  12. oscura/analyzers/digital/edges.py +1 -1
  13. oscura/analyzers/digital/timing.py +41 -11
  14. oscura/analyzers/packet/payload_extraction.py +2 -4
  15. oscura/analyzers/packet/stream.py +5 -5
  16. oscura/analyzers/patterns/__init__.py +4 -3
  17. oscura/analyzers/patterns/clustering.py +3 -1
  18. oscura/analyzers/power/ac_power.py +0 -2
  19. oscura/analyzers/power/basic.py +0 -2
  20. oscura/analyzers/power/ripple.py +0 -2
  21. oscura/analyzers/side_channel/__init__.py +52 -0
  22. oscura/analyzers/side_channel/power.py +690 -0
  23. oscura/analyzers/side_channel/timing.py +369 -0
  24. oscura/analyzers/signal_integrity/embedding.py +0 -2
  25. oscura/analyzers/signal_integrity/sparams.py +28 -206
  26. oscura/analyzers/spectral/fft.py +0 -2
  27. oscura/analyzers/statistical/__init__.py +3 -3
  28. oscura/analyzers/statistical/checksum.py +2 -0
  29. oscura/analyzers/statistical/classification.py +2 -0
  30. oscura/analyzers/statistical/entropy.py +11 -9
  31. oscura/analyzers/statistical/ngrams.py +4 -2
  32. oscura/api/fluent.py +2 -2
  33. oscura/automotive/__init__.py +4 -4
  34. oscura/automotive/can/__init__.py +0 -2
  35. oscura/automotive/can/patterns.py +3 -1
  36. oscura/automotive/can/session.py +277 -78
  37. oscura/automotive/can/state_machine.py +5 -2
  38. oscura/automotive/dbc/__init__.py +0 -2
  39. oscura/automotive/dtc/__init__.py +0 -2
  40. oscura/automotive/dtc/data.json +2763 -0
  41. oscura/automotive/dtc/database.py +37 -2769
  42. oscura/automotive/j1939/__init__.py +0 -2
  43. oscura/automotive/loaders/__init__.py +0 -2
  44. oscura/automotive/loaders/asc.py +0 -2
  45. oscura/automotive/loaders/blf.py +0 -2
  46. oscura/automotive/loaders/csv_can.py +0 -2
  47. oscura/automotive/obd/__init__.py +0 -2
  48. oscura/automotive/uds/__init__.py +0 -2
  49. oscura/automotive/uds/models.py +0 -2
  50. oscura/builders/__init__.py +9 -11
  51. oscura/builders/signal_builder.py +99 -191
  52. oscura/cli/main.py +0 -2
  53. oscura/cli/shell.py +0 -2
  54. oscura/config/loader.py +0 -2
  55. oscura/core/backend_selector.py +1 -1
  56. oscura/core/correlation.py +0 -2
  57. oscura/core/exceptions.py +61 -3
  58. oscura/core/lazy.py +5 -3
  59. oscura/core/memory_limits.py +0 -2
  60. oscura/core/numba_backend.py +5 -7
  61. oscura/core/uncertainty.py +3 -3
  62. oscura/dsl/interpreter.py +2 -0
  63. oscura/dsl/parser.py +8 -6
  64. oscura/exploratory/error_recovery.py +3 -3
  65. oscura/exploratory/parse.py +2 -0
  66. oscura/exploratory/recovery.py +2 -0
  67. oscura/exploratory/sync.py +2 -0
  68. oscura/export/wireshark/generator.py +1 -1
  69. oscura/export/wireshark/type_mapping.py +2 -0
  70. oscura/exporters/hdf5.py +1 -3
  71. oscura/extensibility/templates.py +0 -8
  72. oscura/inference/active_learning/lstar.py +2 -4
  73. oscura/inference/active_learning/observation_table.py +0 -2
  74. oscura/inference/active_learning/oracle.py +3 -1
  75. oscura/inference/active_learning/teachers/simulator.py +1 -3
  76. oscura/inference/alignment.py +2 -0
  77. oscura/inference/message_format.py +2 -0
  78. oscura/inference/protocol_dsl.py +7 -5
  79. oscura/inference/sequences.py +12 -14
  80. oscura/inference/state_machine.py +2 -0
  81. oscura/integrations/llm.py +3 -1
  82. oscura/jupyter/display.py +0 -2
  83. oscura/loaders/__init__.py +68 -51
  84. oscura/loaders/chipwhisperer.py +393 -0
  85. oscura/loaders/pcap.py +1 -1
  86. oscura/loaders/touchstone.py +221 -0
  87. oscura/math/arithmetic.py +0 -2
  88. oscura/optimization/parallel.py +9 -6
  89. oscura/pipeline/composition.py +0 -2
  90. oscura/plugins/cli.py +0 -2
  91. oscura/reporting/comparison.py +0 -2
  92. oscura/reporting/config.py +1 -1
  93. oscura/reporting/formatting/emphasis.py +2 -0
  94. oscura/reporting/formatting/numbers.py +0 -2
  95. oscura/reporting/output.py +1 -3
  96. oscura/reporting/sections.py +0 -2
  97. oscura/search/anomaly.py +2 -0
  98. oscura/session/session.py +91 -16
  99. oscura/sessions/__init__.py +70 -0
  100. oscura/sessions/base.py +323 -0
  101. oscura/sessions/blackbox.py +640 -0
  102. oscura/sessions/generic.py +189 -0
  103. oscura/testing/synthetic.py +2 -0
  104. oscura/ui/formatters.py +4 -2
  105. oscura/utils/buffer.py +2 -2
  106. oscura/utils/lazy.py +5 -5
  107. oscura/utils/memory_advanced.py +2 -2
  108. oscura/utils/memory_extensions.py +2 -2
  109. oscura/visualization/colors.py +0 -2
  110. oscura/visualization/power.py +2 -0
  111. oscura/workflows/multi_trace.py +2 -0
  112. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/METADATA +122 -20
  113. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/RECORD +116 -98
  114. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/WHEEL +0 -0
  115. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/entry_points.txt +0 -0
  116. {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
@@ -27,7 +27,7 @@ if TYPE_CHECKING:
27
27
 
28
28
  # Try to import dpkt for full PCAP support
29
29
  try:
30
- import dpkt # type: ignore[import-untyped]
30
+ import dpkt # type: ignore[import-not-found]
31
31
 
32
32
  DPKT_AVAILABLE = True
33
33
  except ImportError:
@@ -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
@@ -14,8 +14,6 @@ References:
14
14
  IEEE 181-2011: Standard for Transitional Waveform Definitions
15
15
  """
16
16
 
17
- from __future__ import annotations
18
-
19
17
  import ast
20
18
  import operator
21
19
  from collections.abc import Callable
@@ -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[T, R](
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[T, R](
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[T, R](
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[T](
303
+ def parallel_filter(
301
304
  func: Callable[[T], bool],
302
305
  iterable: Iterable[T],
303
306
  *,
@@ -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
@@ -4,8 +4,6 @@ This module provides command-line interface for plugin management including
4
4
  list, info, enable/disable, install, and validate operations.
5
5
  """
6
6
 
7
- from __future__ import annotations
8
-
9
7
  import hashlib
10
8
  import logging
11
9
  import shutil