oscura 0.3.0__py3-none-any.whl → 0.5.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 (59) 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/__init__.py +48 -0
  12. oscura/analyzers/digital/clock.py +9 -1
  13. oscura/analyzers/digital/edges.py +1 -1
  14. oscura/analyzers/digital/extraction.py +195 -0
  15. oscura/analyzers/digital/ic_database.py +498 -0
  16. oscura/analyzers/digital/timing.py +41 -11
  17. oscura/analyzers/digital/timing_paths.py +339 -0
  18. oscura/analyzers/digital/vintage.py +377 -0
  19. oscura/analyzers/digital/vintage_result.py +148 -0
  20. oscura/analyzers/protocols/__init__.py +22 -1
  21. oscura/analyzers/protocols/parallel_bus.py +449 -0
  22. oscura/analyzers/side_channel/__init__.py +52 -0
  23. oscura/analyzers/side_channel/power.py +690 -0
  24. oscura/analyzers/side_channel/timing.py +369 -0
  25. oscura/analyzers/signal_integrity/sparams.py +1 -1
  26. oscura/automotive/__init__.py +4 -2
  27. oscura/automotive/can/patterns.py +3 -1
  28. oscura/automotive/can/session.py +277 -78
  29. oscura/automotive/can/state_machine.py +5 -2
  30. oscura/builders/__init__.py +9 -11
  31. oscura/builders/signal_builder.py +99 -191
  32. oscura/core/exceptions.py +5 -1
  33. oscura/export/__init__.py +12 -0
  34. oscura/export/wavedrom.py +430 -0
  35. oscura/exporters/json_export.py +47 -0
  36. oscura/exporters/vintage_logic_csv.py +247 -0
  37. oscura/loaders/__init__.py +1 -0
  38. oscura/loaders/chipwhisperer.py +393 -0
  39. oscura/loaders/touchstone.py +1 -1
  40. oscura/reporting/__init__.py +7 -0
  41. oscura/reporting/vintage_logic_report.py +523 -0
  42. oscura/session/session.py +54 -46
  43. oscura/sessions/__init__.py +70 -0
  44. oscura/sessions/base.py +323 -0
  45. oscura/sessions/blackbox.py +640 -0
  46. oscura/sessions/generic.py +189 -0
  47. oscura/utils/autodetect.py +5 -1
  48. oscura/visualization/digital_advanced.py +718 -0
  49. oscura/visualization/figure_manager.py +156 -0
  50. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/METADATA +86 -5
  51. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/RECORD +54 -33
  52. oscura/automotive/dtc/data.json +0 -2763
  53. oscura/schemas/bus_configuration.json +0 -322
  54. oscura/schemas/device_mapping.json +0 -182
  55. oscura/schemas/packet_format.json +0 -418
  56. oscura/schemas/protocol_definition.json +0 -363
  57. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/WHEEL +0 -0
  58. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/entry_points.txt +0 -0
  59. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,229 @@
1
+ """Synthetic signal generation source.
2
+
3
+ This module provides SyntheticSource, which wraps SignalBuilder to implement
4
+ the unified Source protocol. SyntheticSource makes synthetic signals consistent
5
+ with all other acquisition methods (files, hardware).
6
+
7
+ Example:
8
+ >>> from oscura.acquisition import SyntheticSource
9
+ >>> from oscura import SignalBuilder
10
+ >>>
11
+ >>> # Create signal builder
12
+ >>> builder = SignalBuilder(sample_rate=1e6, duration=0.01)
13
+ >>> builder = builder.add_sine(frequency=1000, amplitude=1.0)
14
+ >>> builder = builder.add_noise(snr_db=40)
15
+ >>>
16
+ >>> # Wrap in Source for unified interface
17
+ >>> source = SyntheticSource(builder)
18
+ >>> trace = source.read()
19
+ >>>
20
+ >>> # Or one-liner
21
+ >>> source = SyntheticSource(
22
+ ... SignalBuilder().sample_rate(1e6).add_sine(1000).add_noise(snr_db=40)
23
+ ... )
24
+ >>> trace = source.read()
25
+
26
+ Pattern:
27
+ SyntheticSource bridges SignalBuilder (generator pattern) with
28
+ Source protocol (acquisition pattern). This enables:
29
+ - Polymorphic use with FileSource and HardwareSource
30
+ - Session management with synthetic signals
31
+ - Pipeline composition with generated data
32
+
33
+ Note:
34
+ SignalBuilder.build() returns WaveformTrace for single-channel signals.
35
+ SignalBuilder.build_channels() returns dict[str, WaveformTrace] for multi-channel.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from collections.abc import Iterator
41
+ from typing import TYPE_CHECKING
42
+
43
+ if TYPE_CHECKING:
44
+ from oscura.builders.signal_builder import SignalBuilder
45
+ from oscura.core.types import Trace, WaveformTrace
46
+
47
+
48
+ class SyntheticSource:
49
+ """Synthetic signal source implementing Source protocol.
50
+
51
+ Wraps SignalBuilder to provide unified acquisition interface.
52
+ Enables synthetic signals to be used anywhere a Source is expected.
53
+
54
+ Attributes:
55
+ builder: SignalBuilder instance to generate from.
56
+ channel: Channel name to extract (for multi-channel signals).
57
+
58
+ Example:
59
+ >>> from oscura import SignalBuilder
60
+ >>> from oscura.acquisition import SyntheticSource
61
+ >>>
62
+ >>> # Create builder
63
+ >>> builder = (SignalBuilder(sample_rate=1e6, duration=0.01)
64
+ ... .add_sine(frequency=1000)
65
+ ... .add_noise(snr_db=40))
66
+ >>>
67
+ >>> # Wrap in source
68
+ >>> source = SyntheticSource(builder)
69
+ >>> trace = source.read()
70
+ >>>
71
+ >>> # Multi-channel
72
+ >>> builder = SignalBuilder().sample_rate(1e6)
73
+ >>> builder = builder.add_sine(1000, channel="sig")
74
+ >>> builder = builder.add_square(500, channel="clk")
75
+ >>> source = SyntheticSource(builder, channel="sig")
76
+ >>> trace = source.read() # Gets "sig" channel only
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ builder: SignalBuilder,
82
+ *,
83
+ channel: str | None = None,
84
+ ) -> None:
85
+ """Initialize synthetic source.
86
+
87
+ Args:
88
+ builder: SignalBuilder instance to generate from.
89
+ channel: Optional channel name for multi-channel signals.
90
+ If None, uses first channel (or converts multi-channel to
91
+ single-channel trace if possible).
92
+
93
+ Example:
94
+ >>> builder = SignalBuilder().sample_rate(1e6).add_sine(1000)
95
+ >>> source = SyntheticSource(builder)
96
+ >>> source = SyntheticSource(builder, channel="ch1")
97
+ """
98
+ self.builder = builder
99
+ self.channel = channel
100
+ self._closed = False
101
+ self._cached_trace: WaveformTrace | None = None
102
+
103
+ def read(self) -> Trace:
104
+ """Generate and return complete trace.
105
+
106
+ Returns:
107
+ WaveformTrace with generated signal data.
108
+
109
+ Raises:
110
+ ValueError: If source is closed or builder has no signals.
111
+
112
+ Example:
113
+ >>> builder = SignalBuilder().sample_rate(1e6).add_sine(1000)
114
+ >>> source = SyntheticSource(builder)
115
+ >>> trace = source.read()
116
+ >>> print(f"Generated {len(trace.data)} samples")
117
+
118
+ Note:
119
+ This method caches the generated trace to avoid regenerating
120
+ on multiple calls. Call close() and recreate to generate new data.
121
+ """
122
+ if self._closed:
123
+ raise ValueError("Cannot read from closed source")
124
+
125
+ # Return cached trace if available
126
+ if self._cached_trace is not None:
127
+ return self._cached_trace
128
+
129
+ # Build signal
130
+ # Phase 0.2: build() now returns WaveformTrace directly
131
+ trace = self.builder.build(channel=self.channel)
132
+
133
+ self._cached_trace = trace
134
+ return trace
135
+
136
+ def stream(self, chunk_size: int) -> Iterator[Trace]:
137
+ """Stream generated signal in chunks.
138
+
139
+ Args:
140
+ chunk_size: Number of samples per chunk.
141
+
142
+ Yields:
143
+ Trace chunks.
144
+
145
+ Example:
146
+ >>> builder = SignalBuilder().sample_rate(1e6).duration(1.0).add_sine(1000)
147
+ >>> source = SyntheticSource(builder)
148
+ >>> for chunk in source.stream(chunk_size=10000):
149
+ ... process_chunk(chunk)
150
+
151
+ Note:
152
+ Synthetic signals are generated once and sliced into chunks.
153
+ This is different from hardware streaming where data arrives continuously.
154
+ """
155
+ if self._closed:
156
+ raise ValueError("Cannot stream from closed source")
157
+
158
+ # Generate full trace
159
+ trace = self.read()
160
+
161
+ # Yield chunks
162
+ from oscura.core.types import DigitalTrace, IQTrace, TraceMetadata, WaveformTrace
163
+
164
+ # IQTrace not supported by SyntheticSource
165
+ if isinstance(trace, IQTrace):
166
+ raise TypeError("IQTrace not supported by SyntheticSource")
167
+
168
+ n_samples = len(trace.data)
169
+
170
+ for start in range(0, n_samples, chunk_size):
171
+ end = min(start + chunk_size, n_samples)
172
+ chunk_data = trace.data[start:end]
173
+
174
+ # Create chunk metadata (same as parent)
175
+ chunk_metadata = TraceMetadata(
176
+ sample_rate=trace.metadata.sample_rate,
177
+ vertical_scale=trace.metadata.vertical_scale,
178
+ vertical_offset=trace.metadata.vertical_offset,
179
+ acquisition_time=trace.metadata.acquisition_time,
180
+ trigger_info=trace.metadata.trigger_info,
181
+ source_file=trace.metadata.source_file,
182
+ channel_name=trace.metadata.channel_name,
183
+ calibration_info=trace.metadata.calibration_info,
184
+ )
185
+
186
+ if isinstance(trace, DigitalTrace):
187
+ yield DigitalTrace(data=chunk_data, metadata=chunk_metadata) # type: ignore[arg-type]
188
+ else:
189
+ yield WaveformTrace(data=chunk_data, metadata=chunk_metadata) # type: ignore[arg-type]
190
+
191
+ def close(self) -> None:
192
+ """Close the source and release resources.
193
+
194
+ For synthetic sources, this clears the cached trace.
195
+
196
+ Example:
197
+ >>> source = SyntheticSource(builder)
198
+ >>> trace = source.read()
199
+ >>> source.close()
200
+ >>> # source.read() would now fail
201
+ """
202
+ self._closed = True
203
+ self._cached_trace = None
204
+
205
+ def __enter__(self) -> SyntheticSource:
206
+ """Context manager entry.
207
+
208
+ Returns:
209
+ Self for use in 'with' statement.
210
+
211
+ Example:
212
+ >>> with SyntheticSource(builder) as source:
213
+ ... trace = source.read()
214
+ """
215
+ return self
216
+
217
+ def __exit__(self, *args: object) -> None:
218
+ """Context manager exit.
219
+
220
+ Automatically calls close() when exiting 'with' block.
221
+ """
222
+ self.close()
223
+
224
+ def __repr__(self) -> str:
225
+ """String representation."""
226
+ return f"SyntheticSource(builder={self.builder!r}, channel={self.channel!r})"
227
+
228
+
229
+ __all__ = ["SyntheticSource"]
@@ -0,0 +1,376 @@
1
+ """PyVISA oscilloscope and instrument acquisition source.
2
+
3
+ This module provides VISASource for acquiring waveforms from oscilloscopes and
4
+ other SCPI-compatible instruments via PyVISA. Supports USB, GPIB, Ethernet, and
5
+ serial connections.
6
+
7
+ The VISA source communicates with instruments using SCPI commands and acquires
8
+ waveform data directly from the oscilloscope, returning WaveformTrace format
9
+ for analysis.
10
+
11
+ Example:
12
+ >>> from oscura.acquisition import HardwareSource
13
+ >>>
14
+ >>> # USB oscilloscope
15
+ >>> with HardwareSource.visa("USB0::0x0699::0x0401::INSTR") as scope:
16
+ ... scope.configure(channels=[1, 2], timebase=1e-6)
17
+ ... trace = scope.read()
18
+ >>>
19
+ >>> # Ethernet oscilloscope
20
+ >>> with HardwareSource.visa("TCPIP::192.168.1.100::INSTR") as scope:
21
+ ... scope.configure(channels=[1], vertical_scale=0.5)
22
+ ... trace = scope.read()
23
+ >>>
24
+ >>> # Auto-detect
25
+ >>> with HardwareSource.visa() as scope:
26
+ ... trace = scope.read()
27
+
28
+ Dependencies:
29
+ Requires pyvisa and pyvisa-py: pip install pyvisa pyvisa-py
30
+
31
+ Platform:
32
+ Cross-platform (Windows, macOS, Linux).
33
+
34
+ Supported Instruments:
35
+ - Tektronix oscilloscopes (DPO, MSO, MDO series)
36
+ - Keysight oscilloscopes (InfiniiVision, MSO-X series)
37
+ - Rigol oscilloscopes (DS1000Z, DS4000 series)
38
+ - Other SCPI-compatible instruments
39
+
40
+ References:
41
+ PyVISA: https://pyvisa.readthedocs.io/
42
+ SCPI Standard: IEEE 488.2
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from collections.abc import Iterator
48
+ from datetime import datetime
49
+ from typing import TYPE_CHECKING, Any
50
+
51
+ if TYPE_CHECKING:
52
+ from pyvisa import Resource, ResourceManager
53
+
54
+ from oscura.core.types import Trace
55
+
56
+
57
+ class VISASource:
58
+ """PyVISA instrument acquisition source.
59
+
60
+ Acquires waveforms from oscilloscopes and other SCPI-compatible instruments
61
+ via PyVISA. Supports multiple connection types (USB, GPIB, Ethernet, serial).
62
+
63
+ Attributes:
64
+ resource: VISA resource string.
65
+ instrument: PyVISA instrument instance.
66
+ channels: Configured channel list.
67
+ timebase: Configured timebase in seconds/division.
68
+ vertical_scale: Configured vertical scale in volts/division.
69
+
70
+ Example:
71
+ >>> scope = VISASource("USB0::0x0699::0x0401::INSTR")
72
+ >>> scope.configure(channels=[1, 2], timebase=1e-6)
73
+ >>> trace = scope.read()
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ resource: str | None = None,
79
+ **kwargs: Any,
80
+ ) -> None:
81
+ """Initialize VISA source.
82
+
83
+ Args:
84
+ resource: VISA resource string (optional, auto-detects if None).
85
+ Examples: "USB0::0x0699::0x0401::INSTR", "TCPIP::192.168.1.100::INSTR"
86
+ **kwargs: Additional PyVISA configuration options.
87
+
88
+ Raises:
89
+ ImportError: If pyvisa is not installed.
90
+
91
+ Example:
92
+ >>> scope = VISASource("USB0::0x0699::0x0401::INSTR")
93
+ >>> scope = VISASource() # Auto-detect
94
+ """
95
+ self.resource = resource
96
+ self.kwargs = kwargs
97
+ self.rm: ResourceManager | None = None
98
+ self.instrument: Resource | None = None
99
+ self._closed = False
100
+
101
+ # Configuration
102
+ self.channels: list[int] = [1]
103
+ self.timebase: float = 1e-6
104
+ self.vertical_scale: float = 1.0
105
+ self.record_length: int = 10000
106
+
107
+ def _ensure_connection(self) -> None:
108
+ """Ensure connection to VISA instrument.
109
+
110
+ Raises:
111
+ ImportError: If pyvisa is not installed.
112
+ RuntimeError: If no instrument found or connection fails.
113
+ """
114
+ if self.instrument is not None:
115
+ return
116
+
117
+ try:
118
+ import pyvisa
119
+ except ImportError as e:
120
+ raise ImportError(
121
+ "VISA source requires pyvisa library. Install with: pip install pyvisa pyvisa-py"
122
+ ) from e
123
+
124
+ try:
125
+ self.rm = pyvisa.ResourceManager()
126
+
127
+ # Auto-detect if resource not specified
128
+ if self.resource is None:
129
+ resources = self.rm.list_resources()
130
+ if not resources:
131
+ raise RuntimeError("No VISA instruments found")
132
+ self.resource = resources[0]
133
+
134
+ self.instrument = self.rm.open_resource(self.resource, **self.kwargs)
135
+
136
+ # Query instrument identity
137
+ try:
138
+ idn = self.instrument.query("*IDN?")
139
+ print(f"Connected to: {idn.strip()}")
140
+ except Exception:
141
+ # Some instruments may not support *IDN?
142
+ pass
143
+
144
+ except Exception as e:
145
+ raise RuntimeError(
146
+ f"Failed to connect to VISA instrument '{self.resource}'. "
147
+ f"Ensure instrument is connected and powered on. "
148
+ f"Error: {e}"
149
+ ) from e
150
+
151
+ def configure(
152
+ self,
153
+ *,
154
+ channels: list[int] | None = None,
155
+ timebase: float | None = None,
156
+ vertical_scale: float | None = None,
157
+ record_length: int | None = None,
158
+ ) -> None:
159
+ """Configure oscilloscope acquisition parameters.
160
+
161
+ Args:
162
+ channels: List of channel numbers to acquire (e.g., [1, 2]).
163
+ timebase: Timebase in seconds/division (e.g., 1e-6 for 1 µs/div).
164
+ vertical_scale: Vertical scale in volts/division (e.g., 0.5).
165
+ record_length: Number of samples to acquire (e.g., 10000).
166
+
167
+ Example:
168
+ >>> scope = VISASource()
169
+ >>> scope.configure(
170
+ ... channels=[1, 2],
171
+ ... timebase=1e-6,
172
+ ... vertical_scale=0.5,
173
+ ... record_length=10000
174
+ ... )
175
+ """
176
+ if channels is not None:
177
+ self.channels = channels
178
+ if timebase is not None:
179
+ self.timebase = timebase
180
+ if vertical_scale is not None:
181
+ self.vertical_scale = vertical_scale
182
+ if record_length is not None:
183
+ self.record_length = record_length
184
+
185
+ # Apply configuration to instrument
186
+ self._ensure_connection()
187
+
188
+ try:
189
+ # Set horizontal (timebase)
190
+ self.instrument.write(f":TIMebase:SCALe {self.timebase}") # type: ignore[union-attr]
191
+
192
+ # Set vertical scale for each channel
193
+ for ch in self.channels:
194
+ self.instrument.write(f":CHANnel{ch}:SCALe {self.vertical_scale}") # type: ignore[union-attr]
195
+ self.instrument.write(f":CHANnel{ch}:DISPlay ON") # type: ignore[union-attr]
196
+
197
+ # Set record length
198
+ self.instrument.write(f":ACQuire:POINts {self.record_length}") # type: ignore[union-attr]
199
+
200
+ except Exception as e:
201
+ # SCPI commands vary by manufacturer, log but continue
202
+ print(f"Warning: Configuration command failed: {e}")
203
+
204
+ def read(self, channel: int | None = None) -> Trace:
205
+ """Read waveform from oscilloscope.
206
+
207
+ Args:
208
+ channel: Channel to read (uses first configured channel if None).
209
+
210
+ Returns:
211
+ WaveformTrace containing acquired waveform.
212
+
213
+ Raises:
214
+ ImportError: If pyvisa is not installed.
215
+ RuntimeError: If acquisition fails.
216
+ ValueError: If source is closed.
217
+
218
+ Example:
219
+ >>> scope = VISASource()
220
+ >>> scope.configure(channels=[1, 2])
221
+ >>> trace = scope.read(channel=1)
222
+ """
223
+ if self._closed:
224
+ raise ValueError("Cannot read from closed source")
225
+
226
+ self._ensure_connection()
227
+
228
+ import numpy as np
229
+
230
+ from oscura.core.types import CalibrationInfo, TraceMetadata, WaveformTrace
231
+
232
+ if channel is None:
233
+ channel = self.channels[0]
234
+
235
+ acquisition_start = datetime.now()
236
+
237
+ try:
238
+ # Single acquisition
239
+ self.instrument.write(":SINGle") # type: ignore[union-attr]
240
+
241
+ # Wait for acquisition to complete
242
+ import time
243
+
244
+ time.sleep(0.1)
245
+
246
+ # Set data source
247
+ self.instrument.write(f":DATa:SOURce CHANnel{channel}") # type: ignore[union-attr]
248
+
249
+ # Get waveform preamble (metadata)
250
+ preamble = self.instrument.query(":WAVeform:PREamble?") # type: ignore[union-attr]
251
+ preamble_parts = preamble.split(",")
252
+
253
+ # Parse preamble (format varies by manufacturer)
254
+ try:
255
+ x_increment = float(preamble_parts[4]) # Time between samples
256
+ sample_rate = 1.0 / x_increment
257
+ except (IndexError, ValueError):
258
+ sample_rate = 1e9 # Default 1 GSa/s
259
+
260
+ # Get waveform data
261
+ self.instrument.write(":WAVeform:FORMat WORD") # type: ignore[union-attr]
262
+ raw_data = self.instrument.query_binary_values( # type: ignore[union-attr]
263
+ ":WAVeform:DATA?", datatype="h", is_big_endian=True
264
+ )
265
+
266
+ # Convert to voltage
267
+ data = np.array(raw_data, dtype=np.float64)
268
+
269
+ # Get instrument identity for calibration info
270
+ try:
271
+ idn = self.instrument.query("*IDN?").strip() # type: ignore[union-attr]
272
+ except Exception:
273
+ idn = "Unknown Instrument"
274
+
275
+ calibration_info = CalibrationInfo(
276
+ instrument=idn,
277
+ coupling="DC", # Default
278
+ vertical_resolution=8, # Typical for oscilloscopes
279
+ )
280
+
281
+ metadata = TraceMetadata(
282
+ sample_rate=sample_rate,
283
+ vertical_scale=self.vertical_scale,
284
+ acquisition_time=acquisition_start,
285
+ source_file=f"visa://{self.resource}",
286
+ channel_name=f"CH{channel}",
287
+ calibration_info=calibration_info,
288
+ )
289
+
290
+ return WaveformTrace(data=data, metadata=metadata)
291
+
292
+ except Exception as e:
293
+ raise RuntimeError(
294
+ f"Failed to acquire waveform from channel {channel}. Error: {e}"
295
+ ) from e
296
+
297
+ def stream(self, duration: float = 60.0, interval: float = 1.0) -> Iterator[Trace]:
298
+ """Stream waveforms at regular intervals.
299
+
300
+ Args:
301
+ duration: Total acquisition duration in seconds (default: 60.0).
302
+ interval: Time between acquisitions in seconds (default: 1.0).
303
+
304
+ Yields:
305
+ WaveformTrace for each acquisition.
306
+
307
+ Raises:
308
+ ValueError: If source is closed.
309
+
310
+ Example:
311
+ >>> scope = VISASource()
312
+ >>> scope.configure(channels=[1])
313
+ >>> for trace in scope.stream(duration=60, interval=1):
314
+ ... analyze(trace)
315
+
316
+ Note:
317
+ Acquires repeated single-shot waveforms, not continuous streaming.
318
+ """
319
+ if self._closed:
320
+ raise ValueError("Cannot stream from closed source")
321
+
322
+ import time
323
+
324
+ start_time = time.time()
325
+
326
+ while time.time() - start_time < duration:
327
+ # Acquire waveform
328
+ trace = self.read()
329
+ yield trace
330
+
331
+ # Wait for next acquisition
332
+ time.sleep(interval)
333
+
334
+ def close(self) -> None:
335
+ """Close connection to VISA instrument.
336
+
337
+ Example:
338
+ >>> scope = VISASource()
339
+ >>> scope.configure(channels=[1])
340
+ >>> trace = scope.read()
341
+ >>> scope.close()
342
+ """
343
+ if self.instrument is not None:
344
+ self.instrument.close()
345
+ self.instrument = None
346
+ if self.rm is not None:
347
+ self.rm.close()
348
+ self.rm = None
349
+ self._closed = True
350
+
351
+ def __enter__(self) -> VISASource:
352
+ """Context manager entry.
353
+
354
+ Returns:
355
+ Self for use in 'with' statement.
356
+
357
+ Example:
358
+ >>> with VISASource() as scope:
359
+ ... scope.configure(channels=[1])
360
+ ... trace = scope.read()
361
+ """
362
+ return self
363
+
364
+ def __exit__(self, *args: object) -> None:
365
+ """Context manager exit.
366
+
367
+ Automatically calls close() when exiting 'with' block.
368
+ """
369
+ self.close()
370
+
371
+ def __repr__(self) -> str:
372
+ """String representation."""
373
+ return f"VISASource(resource={self.resource!r})"
374
+
375
+
376
+ __all__ = ["VISASource"]
@@ -9,6 +9,7 @@ Provides signal analysis functionality including:
9
9
  - Jitter analysis (RJ, DJ, PJ, DDJ, bathtub curves)
10
10
  - Eye diagram analysis (height, width, Q-factor)
11
11
  - Signal integrity (S-parameters, equalization)
12
+ - Side-channel analysis (DPA, CPA, timing attacks)
12
13
  """
13
14
 
14
15
  # Import measurements module as namespace for DSL compatibility
@@ -18,6 +19,7 @@ from oscura.analyzers import (
18
19
  jitter,
19
20
  measurements,
20
21
  protocols,
22
+ side_channel,
21
23
  signal_integrity,
22
24
  statistics,
23
25
  validation,
@@ -30,6 +32,7 @@ __all__ = [
30
32
  "jitter",
31
33
  "measurements",
32
34
  "protocols",
35
+ "side_channel",
33
36
  "signal_integrity",
34
37
  "statistics",
35
38
  "validation",