oscura 0.8.0__py3-none-any.whl → 0.10.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 +19 -19
- oscura/analyzers/__init__.py +2 -0
- oscura/analyzers/digital/extraction.py +2 -3
- oscura/analyzers/digital/quality.py +1 -1
- oscura/analyzers/digital/timing.py +1 -1
- oscura/analyzers/patterns/__init__.py +66 -0
- oscura/analyzers/power/basic.py +3 -3
- oscura/analyzers/power/soa.py +1 -1
- oscura/analyzers/power/switching.py +3 -3
- oscura/analyzers/signal_classification.py +529 -0
- oscura/analyzers/signal_integrity/sparams.py +3 -3
- oscura/analyzers/statistics/basic.py +10 -7
- oscura/analyzers/validation.py +1 -1
- oscura/analyzers/waveform/measurements.py +200 -156
- oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
- oscura/analyzers/waveform/spectral.py +164 -73
- oscura/api/dsl/commands.py +15 -6
- oscura/api/server/templates/base.html +137 -146
- oscura/api/server/templates/export.html +84 -110
- oscura/api/server/templates/home.html +248 -267
- oscura/api/server/templates/protocols.html +44 -48
- oscura/api/server/templates/reports.html +27 -35
- oscura/api/server/templates/session_detail.html +68 -78
- oscura/api/server/templates/sessions.html +62 -72
- oscura/api/server/templates/waveforms.html +54 -64
- oscura/automotive/__init__.py +1 -1
- oscura/automotive/can/session.py +1 -1
- oscura/automotive/dbc/generator.py +638 -23
- oscura/automotive/uds/decoder.py +99 -6
- oscura/cli/analyze.py +8 -2
- oscura/cli/batch.py +36 -5
- oscura/cli/characterize.py +18 -4
- oscura/cli/export.py +47 -5
- oscura/cli/main.py +2 -0
- oscura/cli/onboarding/wizard.py +10 -6
- oscura/cli/pipeline.py +585 -0
- oscura/cli/visualize.py +6 -4
- oscura/convenience.py +400 -32
- oscura/core/measurement_result.py +286 -0
- oscura/core/progress.py +1 -1
- oscura/core/types.py +232 -239
- oscura/correlation/multi_protocol.py +1 -1
- oscura/export/legacy/__init__.py +11 -0
- oscura/export/legacy/wav.py +75 -0
- oscura/exporters/__init__.py +19 -0
- oscura/exporters/wireshark.py +809 -0
- oscura/hardware/acquisition/file.py +5 -19
- oscura/hardware/acquisition/saleae.py +10 -10
- oscura/hardware/acquisition/socketcan.py +4 -6
- oscura/hardware/acquisition/synthetic.py +1 -5
- oscura/hardware/acquisition/visa.py +6 -6
- oscura/hardware/security/side_channel_detector.py +5 -508
- oscura/inference/message_format.py +686 -1
- oscura/jupyter/display.py +2 -2
- oscura/jupyter/magic.py +3 -3
- oscura/loaders/__init__.py +17 -12
- oscura/loaders/binary.py +1 -1
- oscura/loaders/chipwhisperer.py +1 -2
- oscura/loaders/configurable.py +1 -1
- oscura/loaders/csv_loader.py +2 -2
- oscura/loaders/hdf5_loader.py +1 -1
- oscura/loaders/lazy.py +6 -1
- oscura/loaders/mmap_loader.py +0 -1
- oscura/loaders/numpy_loader.py +8 -7
- oscura/loaders/preprocessing.py +3 -5
- oscura/loaders/rigol.py +21 -7
- oscura/loaders/sigrok.py +2 -5
- oscura/loaders/tdms.py +3 -2
- oscura/loaders/tektronix.py +38 -32
- oscura/loaders/tss.py +20 -27
- oscura/loaders/vcd.py +13 -8
- oscura/loaders/wav.py +1 -6
- oscura/pipeline/__init__.py +76 -0
- oscura/pipeline/handlers/__init__.py +165 -0
- oscura/pipeline/handlers/analyzers.py +1045 -0
- oscura/pipeline/handlers/decoders.py +899 -0
- oscura/pipeline/handlers/exporters.py +1103 -0
- oscura/pipeline/handlers/filters.py +891 -0
- oscura/pipeline/handlers/loaders.py +640 -0
- oscura/pipeline/handlers/transforms.py +768 -0
- oscura/reporting/formatting/measurements.py +55 -14
- oscura/reporting/templates/enhanced/protocol_re.html +504 -503
- oscura/side_channel/__init__.py +38 -57
- oscura/utils/builders/signal_builder.py +5 -5
- oscura/utils/comparison/compare.py +7 -9
- oscura/utils/comparison/golden.py +1 -1
- oscura/utils/filtering/convenience.py +2 -2
- oscura/utils/math/arithmetic.py +38 -62
- oscura/utils/math/interpolation.py +20 -20
- oscura/utils/pipeline/__init__.py +4 -17
- oscura/utils/progressive.py +1 -4
- oscura/utils/triggering/edge.py +1 -1
- oscura/utils/triggering/pattern.py +2 -2
- oscura/utils/triggering/pulse.py +2 -2
- oscura/utils/triggering/window.py +3 -3
- oscura/validation/hil_testing.py +11 -11
- oscura/visualization/__init__.py +46 -284
- oscura/visualization/batch.py +72 -433
- oscura/visualization/plot.py +542 -53
- oscura/visualization/styles.py +184 -318
- oscura/workflows/batch/advanced.py +1 -1
- oscura/workflows/batch/aggregate.py +7 -8
- oscura/workflows/complete_re.py +251 -23
- oscura/workflows/digital.py +27 -4
- oscura/workflows/multi_trace.py +136 -17
- oscura/workflows/waveform.py +11 -6
- {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
- {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/RECORD +111 -136
- oscura/side_channel/dpa.py +0 -1025
- oscura/utils/optimization/__init__.py +0 -19
- oscura/utils/optimization/parallel.py +0 -443
- oscura/utils/optimization/search.py +0 -532
- oscura/utils/pipeline/base.py +0 -338
- oscura/utils/pipeline/composition.py +0 -248
- oscura/utils/pipeline/parallel.py +0 -449
- oscura/utils/pipeline/pipeline.py +0 -375
- oscura/utils/search/__init__.py +0 -16
- oscura/utils/search/anomaly.py +0 -424
- oscura/utils/search/context.py +0 -294
- oscura/utils/search/pattern.py +0 -288
- oscura/utils/storage/__init__.py +0 -61
- oscura/utils/storage/database.py +0 -1166
- oscura/visualization/accessibility.py +0 -526
- oscura/visualization/annotations.py +0 -371
- oscura/visualization/axis_scaling.py +0 -305
- oscura/visualization/colors.py +0 -451
- oscura/visualization/digital.py +0 -436
- oscura/visualization/eye.py +0 -571
- oscura/visualization/histogram.py +0 -281
- oscura/visualization/interactive.py +0 -1035
- oscura/visualization/jitter.py +0 -1042
- oscura/visualization/keyboard.py +0 -394
- oscura/visualization/layout.py +0 -400
- oscura/visualization/optimization.py +0 -1079
- oscura/visualization/palettes.py +0 -446
- oscura/visualization/power.py +0 -508
- oscura/visualization/power_extended.py +0 -955
- oscura/visualization/presets.py +0 -469
- oscura/visualization/protocols.py +0 -1246
- oscura/visualization/render.py +0 -223
- oscura/visualization/rendering.py +0 -444
- oscura/visualization/reverse_engineering.py +0 -838
- oscura/visualization/signal_integrity.py +0 -989
- oscura/visualization/specialized.py +0 -643
- oscura/visualization/spectral.py +0 -1226
- oscura/visualization/thumbnails.py +0 -340
- oscura/visualization/time_axis.py +0 -351
- oscura/visualization/waveform.py +0 -454
- {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
- {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
"""Thumbnail rendering for fast signal previews.
|
|
2
|
-
|
|
3
|
-
This module provides fast preview rendering with reduced detail
|
|
4
|
-
for gallery and browser contexts.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Example:
|
|
8
|
-
>>> from oscura.visualization.thumbnails import render_thumbnail
|
|
9
|
-
>>> fig = render_thumbnail(signal, sample_rate, size=(400, 300))
|
|
10
|
-
|
|
11
|
-
References:
|
|
12
|
-
Aggressive decimation for performance
|
|
13
|
-
Simplified rendering without expensive features
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
from typing import TYPE_CHECKING, Any
|
|
19
|
-
|
|
20
|
-
import numpy as np
|
|
21
|
-
|
|
22
|
-
if TYPE_CHECKING:
|
|
23
|
-
from matplotlib.figure import Figure
|
|
24
|
-
from numpy.typing import NDArray
|
|
25
|
-
|
|
26
|
-
try:
|
|
27
|
-
import matplotlib # noqa: F401
|
|
28
|
-
import matplotlib.pyplot as plt
|
|
29
|
-
|
|
30
|
-
HAS_MATPLOTLIB = True
|
|
31
|
-
except ImportError:
|
|
32
|
-
HAS_MATPLOTLIB = False
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def render_thumbnail(
|
|
36
|
-
signal: NDArray[np.float64],
|
|
37
|
-
sample_rate: float | None = None,
|
|
38
|
-
*,
|
|
39
|
-
size: tuple[int, int] = (400, 300),
|
|
40
|
-
width: int | None = None,
|
|
41
|
-
height: int | None = None,
|
|
42
|
-
max_samples: int = 1000,
|
|
43
|
-
time_unit: str = "auto",
|
|
44
|
-
title: str | None = None,
|
|
45
|
-
dpi: int = 72,
|
|
46
|
-
) -> Figure:
|
|
47
|
-
"""Render fast preview thumbnail of signal.
|
|
48
|
-
|
|
49
|
-
: Fast preview rendering mode with reduced detail,
|
|
50
|
-
simplified styles, and lower resolution for quick plot generation.
|
|
51
|
-
|
|
52
|
-
Target performance: <100ms for typical signals (goal: 50ms)
|
|
53
|
-
|
|
54
|
-
Args:
|
|
55
|
-
signal: Input signal array
|
|
56
|
-
sample_rate: Sample rate in Hz. If None, uses 1.0 (sample indices as x-axis).
|
|
57
|
-
size: Thumbnail size in pixels (width, height), default (400, 300)
|
|
58
|
-
width: Width in pixels (alternative to size). If specified, height defaults to 3/4 of width.
|
|
59
|
-
height: Height in pixels (alternative to size).
|
|
60
|
-
max_samples: Maximum samples to plot (default: 1000, aggressive decimation)
|
|
61
|
-
time_unit: Time unit for x-axis ("s", "ms", "us", "ns", "auto")
|
|
62
|
-
title: Optional title
|
|
63
|
-
dpi: DPI for rendering (default: 72)
|
|
64
|
-
|
|
65
|
-
Returns:
|
|
66
|
-
Matplotlib Figure object configured for fast rendering
|
|
67
|
-
|
|
68
|
-
Raises:
|
|
69
|
-
ValueError: If signal is empty or sample_rate is invalid
|
|
70
|
-
ImportError: If matplotlib is not available
|
|
71
|
-
|
|
72
|
-
Example:
|
|
73
|
-
>>> signal = np.sin(2*np.pi*1000*np.arange(0, 0.01, 1/1e6))
|
|
74
|
-
>>> fig = render_thumbnail(signal, 1e6, size=(400, 300))
|
|
75
|
-
>>> fig.savefig("preview.png")
|
|
76
|
-
>>> # Without sample rate
|
|
77
|
-
>>> fig = render_thumbnail(data, width=100, height=50)
|
|
78
|
-
|
|
79
|
-
References:
|
|
80
|
-
VIS-018: Thumbnail Mode
|
|
81
|
-
Fixed-count decimation for uniform sampling
|
|
82
|
-
"""
|
|
83
|
-
if not HAS_MATPLOTLIB:
|
|
84
|
-
raise ImportError("matplotlib is required for visualization")
|
|
85
|
-
|
|
86
|
-
sample_rate = sample_rate if sample_rate is not None else 1.0
|
|
87
|
-
_validate_thumbnail_params(signal, sample_rate, max_samples)
|
|
88
|
-
size = _compute_thumbnail_size(size, width, height)
|
|
89
|
-
|
|
90
|
-
with plt.rc_context(_get_fast_rendering_config()):
|
|
91
|
-
fig, ax = _create_thumbnail_figure(size, dpi)
|
|
92
|
-
decimated_signal = _decimate_uniform(signal, max_samples)
|
|
93
|
-
total_time = len(signal) / sample_rate
|
|
94
|
-
time_scaled, time_unit = _prepare_time_axis(decimated_signal, total_time, time_unit)
|
|
95
|
-
_plot_thumbnail_signal(ax, time_scaled, decimated_signal, time_unit, title)
|
|
96
|
-
fig.tight_layout(pad=0.5)
|
|
97
|
-
|
|
98
|
-
return fig
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _validate_thumbnail_params(
|
|
102
|
-
signal: NDArray[np.float64], sample_rate: float, max_samples: int
|
|
103
|
-
) -> None:
|
|
104
|
-
"""Validate thumbnail rendering parameters."""
|
|
105
|
-
if len(signal) == 0:
|
|
106
|
-
raise ValueError("Signal cannot be empty")
|
|
107
|
-
if sample_rate <= 0:
|
|
108
|
-
raise ValueError("Sample rate must be positive")
|
|
109
|
-
if max_samples < 10:
|
|
110
|
-
raise ValueError("max_samples must be >= 10")
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _compute_thumbnail_size(
|
|
114
|
-
size: tuple[int, int], width: int | None, height: int | None
|
|
115
|
-
) -> tuple[int, int]:
|
|
116
|
-
"""Compute thumbnail size from width/height or size tuple."""
|
|
117
|
-
if width is not None:
|
|
118
|
-
h = height if height is not None else int(width * 0.75)
|
|
119
|
-
return (width, h)
|
|
120
|
-
if height is not None:
|
|
121
|
-
return (int(height * 4 / 3), height)
|
|
122
|
-
return size
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _get_fast_rendering_config() -> dict[str, bool | float]:
|
|
126
|
-
"""Get matplotlib configuration for fast rendering."""
|
|
127
|
-
return {
|
|
128
|
-
"path.simplify": True,
|
|
129
|
-
"path.simplify_threshold": 1.0,
|
|
130
|
-
"agg.path.chunksize": 1000,
|
|
131
|
-
"lines.antialiased": False,
|
|
132
|
-
"patch.antialiased": False,
|
|
133
|
-
"text.antialiased": False,
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _create_thumbnail_figure(size: tuple[int, int], dpi: int) -> tuple[Figure, Any]:
|
|
138
|
-
"""Create matplotlib figure for thumbnail."""
|
|
139
|
-
width_inches = size[0] / dpi
|
|
140
|
-
height_inches = size[1] / dpi
|
|
141
|
-
return plt.subplots(figsize=(width_inches, height_inches), dpi=dpi)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _prepare_time_axis(
|
|
145
|
-
decimated_signal: NDArray[np.float64], total_time: float, time_unit: str
|
|
146
|
-
) -> tuple[NDArray[np.float64], str]:
|
|
147
|
-
"""Prepare time axis with auto unit selection."""
|
|
148
|
-
time = np.linspace(0, total_time, len(decimated_signal))
|
|
149
|
-
if time_unit == "auto":
|
|
150
|
-
time_unit = _auto_select_time_unit(total_time)
|
|
151
|
-
time_multipliers = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
|
|
152
|
-
multiplier = time_multipliers.get(time_unit, 1.0)
|
|
153
|
-
return time * multiplier, time_unit
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def _auto_select_time_unit(total_time: float) -> str:
|
|
157
|
-
"""Auto-select appropriate time unit based on signal duration."""
|
|
158
|
-
if total_time < 1e-6:
|
|
159
|
-
return "ns"
|
|
160
|
-
if total_time < 1e-3:
|
|
161
|
-
return "us"
|
|
162
|
-
if total_time < 1:
|
|
163
|
-
return "ms"
|
|
164
|
-
return "s"
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def _plot_thumbnail_signal(
|
|
168
|
-
ax: Any,
|
|
169
|
-
time_scaled: NDArray[np.float64],
|
|
170
|
-
decimated_signal: NDArray[np.float64],
|
|
171
|
-
time_unit: str,
|
|
172
|
-
title: str | None,
|
|
173
|
-
) -> None:
|
|
174
|
-
"""Plot signal on thumbnail axes."""
|
|
175
|
-
ax.plot(time_scaled, decimated_signal, "b-", linewidth=0.5, antialiased=False)
|
|
176
|
-
ax.set_xlabel(f"Time ({time_unit})", fontsize=8)
|
|
177
|
-
ax.set_ylabel("Amplitude", fontsize=8)
|
|
178
|
-
if title:
|
|
179
|
-
ax.set_title(title, fontsize=9)
|
|
180
|
-
ax.tick_params(labelsize=7)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def _decimate_uniform(signal: NDArray[np.float64], target_samples: int) -> NDArray[np.float64]:
|
|
184
|
-
"""Decimate signal to exactly target_samples using uniform stride.
|
|
185
|
-
|
|
186
|
-
Args:
|
|
187
|
-
signal: Input signal
|
|
188
|
-
target_samples: Target number of samples
|
|
189
|
-
|
|
190
|
-
Returns:
|
|
191
|
-
Decimated signal with exactly target_samples
|
|
192
|
-
"""
|
|
193
|
-
if len(signal) <= target_samples:
|
|
194
|
-
return signal
|
|
195
|
-
|
|
196
|
-
# Calculate uniform stride
|
|
197
|
-
stride = len(signal) // target_samples
|
|
198
|
-
|
|
199
|
-
# Sample at uniform intervals
|
|
200
|
-
indices = np.arange(0, len(signal), stride)[:target_samples]
|
|
201
|
-
|
|
202
|
-
decimated: NDArray[np.float64] = signal[indices]
|
|
203
|
-
return decimated
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def render_thumbnail_multichannel(
|
|
207
|
-
signals: list[NDArray[np.float64]],
|
|
208
|
-
sample_rate: float,
|
|
209
|
-
*,
|
|
210
|
-
size: tuple[int, int] = (400, 300),
|
|
211
|
-
max_samples: int = 1000,
|
|
212
|
-
time_unit: str = "auto",
|
|
213
|
-
channel_names: list[str] | None = None,
|
|
214
|
-
dpi: int = 72,
|
|
215
|
-
) -> Figure:
|
|
216
|
-
"""Render fast preview thumbnail of multiple channels.
|
|
217
|
-
|
|
218
|
-
: Fast multi-channel preview rendering.
|
|
219
|
-
|
|
220
|
-
Args:
|
|
221
|
-
signals: List of signal arrays
|
|
222
|
-
sample_rate: Sample rate in Hz
|
|
223
|
-
size: Thumbnail size in pixels (width, height)
|
|
224
|
-
max_samples: Maximum samples per channel
|
|
225
|
-
time_unit: Time unit for x-axis
|
|
226
|
-
channel_names: Optional channel names
|
|
227
|
-
dpi: DPI for rendering
|
|
228
|
-
|
|
229
|
-
Returns:
|
|
230
|
-
Matplotlib Figure object
|
|
231
|
-
|
|
232
|
-
Raises:
|
|
233
|
-
ValueError: If inputs are invalid
|
|
234
|
-
ImportError: If matplotlib is not available
|
|
235
|
-
|
|
236
|
-
Example:
|
|
237
|
-
>>> signals = [ch1_data, ch2_data, ch3_data]
|
|
238
|
-
>>> fig = render_thumbnail_multichannel(signals, 1e6)
|
|
239
|
-
|
|
240
|
-
References:
|
|
241
|
-
VIS-018: Thumbnail Mode
|
|
242
|
-
"""
|
|
243
|
-
_validate_multichannel_params(signals, sample_rate)
|
|
244
|
-
n_channels = len(signals)
|
|
245
|
-
names = channel_names if channel_names is not None else _default_channel_names(n_channels)
|
|
246
|
-
time_unit_resolved, multiplier = _resolve_time_unit(signals[0], sample_rate, time_unit)
|
|
247
|
-
|
|
248
|
-
with plt.rc_context(_get_fast_rendering_config()):
|
|
249
|
-
fig, axes = _create_multichannel_figure(n_channels, size, dpi)
|
|
250
|
-
_plot_multichannel_signals(
|
|
251
|
-
axes, signals, names, sample_rate, max_samples, multiplier, time_unit_resolved
|
|
252
|
-
)
|
|
253
|
-
fig.tight_layout(pad=0.3)
|
|
254
|
-
|
|
255
|
-
return fig
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def _validate_multichannel_params(signals: list[NDArray[np.float64]], sample_rate: float) -> None:
|
|
259
|
-
"""Validate multichannel thumbnail parameters."""
|
|
260
|
-
if not HAS_MATPLOTLIB:
|
|
261
|
-
raise ImportError("matplotlib is required for visualization")
|
|
262
|
-
if len(signals) == 0:
|
|
263
|
-
raise ValueError("Must provide at least one signal")
|
|
264
|
-
if sample_rate <= 0:
|
|
265
|
-
raise ValueError("Sample rate must be positive")
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
def _default_channel_names(n_channels: int) -> list[str]:
|
|
269
|
-
"""Generate default channel names."""
|
|
270
|
-
return [f"CH{i + 1}" for i in range(n_channels)]
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def _resolve_time_unit(
|
|
274
|
-
first_signal: NDArray[np.float64], sample_rate: float, time_unit: str
|
|
275
|
-
) -> tuple[str, float]:
|
|
276
|
-
"""Resolve time unit and multiplier."""
|
|
277
|
-
if len(first_signal) > 0 and time_unit == "auto":
|
|
278
|
-
total_time = len(first_signal) / sample_rate
|
|
279
|
-
time_unit = _auto_select_time_unit(total_time)
|
|
280
|
-
elif time_unit == "auto":
|
|
281
|
-
time_unit = "s"
|
|
282
|
-
|
|
283
|
-
time_multipliers = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
|
|
284
|
-
multiplier = time_multipliers.get(time_unit, 1.0)
|
|
285
|
-
return time_unit, multiplier
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def _create_multichannel_figure(
|
|
289
|
-
n_channels: int, size: tuple[int, int], dpi: int
|
|
290
|
-
) -> tuple[Figure, Any]:
|
|
291
|
-
"""Create matplotlib figure for multichannel display."""
|
|
292
|
-
width_inches = size[0] / dpi
|
|
293
|
-
height_inches = size[1] / dpi
|
|
294
|
-
|
|
295
|
-
fig, axes = plt.subplots(
|
|
296
|
-
n_channels,
|
|
297
|
-
1,
|
|
298
|
-
figsize=(width_inches, height_inches),
|
|
299
|
-
dpi=dpi,
|
|
300
|
-
sharex=True,
|
|
301
|
-
)
|
|
302
|
-
|
|
303
|
-
if n_channels == 1:
|
|
304
|
-
axes = [axes]
|
|
305
|
-
|
|
306
|
-
return fig, axes
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def _plot_multichannel_signals(
|
|
310
|
-
axes: Any,
|
|
311
|
-
signals: list[NDArray[np.float64]],
|
|
312
|
-
names: list[str],
|
|
313
|
-
sample_rate: float,
|
|
314
|
-
max_samples: int,
|
|
315
|
-
multiplier: float,
|
|
316
|
-
time_unit: str,
|
|
317
|
-
) -> None:
|
|
318
|
-
"""Plot all channels on their respective axes."""
|
|
319
|
-
n_channels = len(signals)
|
|
320
|
-
|
|
321
|
-
for i, (sig, name, ax) in enumerate(zip(signals, names, axes, strict=False)):
|
|
322
|
-
if len(sig) == 0:
|
|
323
|
-
continue
|
|
324
|
-
|
|
325
|
-
decimated = _decimate_uniform(sig, max_samples)
|
|
326
|
-
total_time = len(sig) / sample_rate
|
|
327
|
-
time = np.linspace(0, total_time, len(decimated)) * multiplier
|
|
328
|
-
|
|
329
|
-
ax.plot(time, decimated, "b-", linewidth=0.5, antialiased=False)
|
|
330
|
-
ax.set_ylabel(name, fontsize=7, rotation=0, ha="right", va="center")
|
|
331
|
-
ax.tick_params(labelsize=6)
|
|
332
|
-
|
|
333
|
-
if i == n_channels - 1:
|
|
334
|
-
ax.set_xlabel(f"Time ({time_unit})", fontsize=8)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
__all__ = [
|
|
338
|
-
"render_thumbnail",
|
|
339
|
-
"render_thumbnail_multichannel",
|
|
340
|
-
]
|
|
@@ -1,351 +0,0 @@
|
|
|
1
|
-
"""Time-aware X-axis formatting and optimization.
|
|
2
|
-
|
|
3
|
-
This module provides intelligent time axis formatting with automatic unit
|
|
4
|
-
selection, relative time offsets, and cursor readout with full precision.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Example:
|
|
8
|
-
>>> from oscura.visualization.time_axis import format_time_axis
|
|
9
|
-
>>> labels = format_time_axis(time_values, unit="auto")
|
|
10
|
-
|
|
11
|
-
References:
|
|
12
|
-
- SI prefixes for time units
|
|
13
|
-
- IEEE publication time axis standards
|
|
14
|
-
- Matplotlib formatter customization
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
from typing import TYPE_CHECKING, Literal
|
|
20
|
-
|
|
21
|
-
import numpy as np
|
|
22
|
-
|
|
23
|
-
if TYPE_CHECKING:
|
|
24
|
-
from numpy.typing import NDArray
|
|
25
|
-
|
|
26
|
-
TimeUnit = Literal["s", "ms", "us", "ns", "ps", "auto"]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def select_time_unit(
|
|
30
|
-
time_range: float,
|
|
31
|
-
*,
|
|
32
|
-
prefer_larger: bool = False,
|
|
33
|
-
) -> TimeUnit:
|
|
34
|
-
"""Automatically select appropriate time unit based on range.
|
|
35
|
-
|
|
36
|
-
Args:
|
|
37
|
-
time_range: Time range in seconds.
|
|
38
|
-
prefer_larger: Prefer larger units when ambiguous.
|
|
39
|
-
|
|
40
|
-
Returns:
|
|
41
|
-
Selected time unit ("s", "ms", "us", "ns", "ps").
|
|
42
|
-
|
|
43
|
-
Example:
|
|
44
|
-
>>> select_time_unit(0.001) # 1 ms
|
|
45
|
-
'ms'
|
|
46
|
-
>>> select_time_unit(1e-6) # 1 us
|
|
47
|
-
'us'
|
|
48
|
-
|
|
49
|
-
References:
|
|
50
|
-
VIS-014: Adaptive X-Axis Time Window
|
|
51
|
-
"""
|
|
52
|
-
if time_range >= 1.0:
|
|
53
|
-
return "s"
|
|
54
|
-
elif time_range >= 1e-3:
|
|
55
|
-
return "ms" if not prefer_larger else "s"
|
|
56
|
-
elif time_range >= 1e-6:
|
|
57
|
-
return "us" if not prefer_larger else "ms"
|
|
58
|
-
elif time_range >= 1e-9:
|
|
59
|
-
return "ns" if not prefer_larger else "us"
|
|
60
|
-
else:
|
|
61
|
-
return "ps" if not prefer_larger else "ns"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def convert_time_values(
|
|
65
|
-
time: NDArray[np.float64],
|
|
66
|
-
unit: TimeUnit,
|
|
67
|
-
) -> NDArray[np.float64]:
|
|
68
|
-
"""Convert time values to specified unit.
|
|
69
|
-
|
|
70
|
-
Args:
|
|
71
|
-
time: Time array in seconds.
|
|
72
|
-
unit: Target time unit.
|
|
73
|
-
|
|
74
|
-
Returns:
|
|
75
|
-
Time array in target unit.
|
|
76
|
-
|
|
77
|
-
Raises:
|
|
78
|
-
ValueError: If unit is invalid.
|
|
79
|
-
|
|
80
|
-
Example:
|
|
81
|
-
>>> time_s = np.array([0.001, 0.002, 0.003])
|
|
82
|
-
>>> time_ms = convert_time_values(time_s, "ms")
|
|
83
|
-
>>> # Returns [1.0, 2.0, 3.0]
|
|
84
|
-
|
|
85
|
-
References:
|
|
86
|
-
VIS-014: Adaptive X-Axis Time Window
|
|
87
|
-
"""
|
|
88
|
-
multipliers = {
|
|
89
|
-
"s": 1.0,
|
|
90
|
-
"ms": 1e3,
|
|
91
|
-
"us": 1e6,
|
|
92
|
-
"ns": 1e9,
|
|
93
|
-
"ps": 1e12,
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if unit == "auto":
|
|
97
|
-
time_range = float(np.ptp(time))
|
|
98
|
-
unit = select_time_unit(time_range)
|
|
99
|
-
|
|
100
|
-
if unit not in multipliers:
|
|
101
|
-
raise ValueError(f"Invalid time unit: {unit}")
|
|
102
|
-
|
|
103
|
-
return time * multipliers[unit]
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def format_time_labels(
|
|
107
|
-
time: NDArray[np.float64],
|
|
108
|
-
unit: TimeUnit = "auto",
|
|
109
|
-
*,
|
|
110
|
-
precision: int | None = None,
|
|
111
|
-
scientific_threshold: float = 1e6,
|
|
112
|
-
) -> list[str]:
|
|
113
|
-
"""Format time values as labels with appropriate precision.
|
|
114
|
-
|
|
115
|
-
Args:
|
|
116
|
-
time: Time array in seconds.
|
|
117
|
-
unit: Time unit ("s", "ms", "us", "ns", "ps", "auto").
|
|
118
|
-
precision: Number of decimal places (auto if None).
|
|
119
|
-
scientific_threshold: Use scientific notation above this value.
|
|
120
|
-
|
|
121
|
-
Returns:
|
|
122
|
-
List of formatted time labels.
|
|
123
|
-
|
|
124
|
-
Example:
|
|
125
|
-
>>> time = np.array([0.0, 0.001, 0.002])
|
|
126
|
-
>>> labels = format_time_labels(time, unit="ms")
|
|
127
|
-
>>> # Returns ['0', '1', '2']
|
|
128
|
-
|
|
129
|
-
References:
|
|
130
|
-
VIS-014: Adaptive X-Axis Time Window
|
|
131
|
-
"""
|
|
132
|
-
# Convert to target unit
|
|
133
|
-
time_converted = convert_time_values(time, unit)
|
|
134
|
-
|
|
135
|
-
# Auto-select precision based on value range
|
|
136
|
-
if precision is None:
|
|
137
|
-
value_range = np.ptp(time_converted)
|
|
138
|
-
if value_range == 0:
|
|
139
|
-
precision = 1
|
|
140
|
-
else:
|
|
141
|
-
# Use enough precision to show differences
|
|
142
|
-
magnitude = np.log10(value_range)
|
|
143
|
-
precision = max(0, int(np.ceil(2 - magnitude)))
|
|
144
|
-
|
|
145
|
-
# Format labels
|
|
146
|
-
labels = []
|
|
147
|
-
for val in time_converted:
|
|
148
|
-
if abs(val) >= scientific_threshold:
|
|
149
|
-
# Scientific notation
|
|
150
|
-
labels.append(f"{val:.{precision}e}")
|
|
151
|
-
else:
|
|
152
|
-
# Fixed point
|
|
153
|
-
labels.append(f"{val:.{precision}f}".rstrip("0").rstrip("."))
|
|
154
|
-
|
|
155
|
-
return labels
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def create_relative_time(
|
|
159
|
-
time: NDArray[np.float64],
|
|
160
|
-
*,
|
|
161
|
-
start_at_zero: bool = True,
|
|
162
|
-
reference_time: float | None = None,
|
|
163
|
-
) -> NDArray[np.float64]:
|
|
164
|
-
"""Create relative time axis starting at zero or reference.
|
|
165
|
-
|
|
166
|
-
Args:
|
|
167
|
-
time: Absolute time array in seconds.
|
|
168
|
-
start_at_zero: Start time axis at t=0.
|
|
169
|
-
reference_time: Reference time (uses first sample if None).
|
|
170
|
-
|
|
171
|
-
Returns:
|
|
172
|
-
Relative time array.
|
|
173
|
-
|
|
174
|
-
Example:
|
|
175
|
-
>>> time_abs = np.array([1000.5, 1000.6, 1000.7])
|
|
176
|
-
>>> time_rel = create_relative_time(time_abs)
|
|
177
|
-
>>> # Returns [0.0, 0.1, 0.2]
|
|
178
|
-
|
|
179
|
-
References:
|
|
180
|
-
VIS-014: Adaptive X-Axis Time Window
|
|
181
|
-
"""
|
|
182
|
-
if len(time) == 0:
|
|
183
|
-
return time
|
|
184
|
-
|
|
185
|
-
if reference_time is None:
|
|
186
|
-
reference_time = time[0] if start_at_zero else 0.0
|
|
187
|
-
|
|
188
|
-
return time - reference_time
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def calculate_major_ticks(
|
|
192
|
-
time_min: float,
|
|
193
|
-
time_max: float,
|
|
194
|
-
*,
|
|
195
|
-
target_count: int = 7,
|
|
196
|
-
unit: TimeUnit = "auto",
|
|
197
|
-
) -> NDArray[np.float64]:
|
|
198
|
-
"""Calculate major tick positions for time axis.
|
|
199
|
-
|
|
200
|
-
Args:
|
|
201
|
-
time_min: Minimum time value in seconds.
|
|
202
|
-
time_max: Maximum time value in seconds.
|
|
203
|
-
target_count: Target number of major ticks.
|
|
204
|
-
unit: Time unit for tick alignment.
|
|
205
|
-
|
|
206
|
-
Returns:
|
|
207
|
-
Array of major tick positions in seconds.
|
|
208
|
-
|
|
209
|
-
Example:
|
|
210
|
-
>>> ticks = calculate_major_ticks(0, 0.01, target_count=5, unit="ms")
|
|
211
|
-
|
|
212
|
-
References:
|
|
213
|
-
VIS-014: Adaptive X-Axis Time Window
|
|
214
|
-
VIS-019: Grid Auto-Spacing
|
|
215
|
-
"""
|
|
216
|
-
time_range = time_max - time_min
|
|
217
|
-
|
|
218
|
-
if time_range <= 0:
|
|
219
|
-
return np.array([time_min])
|
|
220
|
-
|
|
221
|
-
# Select unit if auto
|
|
222
|
-
if unit == "auto":
|
|
223
|
-
unit = select_time_unit(time_range)
|
|
224
|
-
|
|
225
|
-
# Convert to selected unit
|
|
226
|
-
multipliers = {
|
|
227
|
-
"s": 1.0,
|
|
228
|
-
"ms": 1e3,
|
|
229
|
-
"us": 1e6,
|
|
230
|
-
"ns": 1e9,
|
|
231
|
-
"ps": 1e12,
|
|
232
|
-
}
|
|
233
|
-
multiplier = multipliers[unit]
|
|
234
|
-
|
|
235
|
-
time_min_unit = time_min * multiplier
|
|
236
|
-
time_max_unit = time_max * multiplier
|
|
237
|
-
range_unit = time_max_unit - time_min_unit
|
|
238
|
-
|
|
239
|
-
# Calculate rough spacing
|
|
240
|
-
rough_spacing = range_unit / target_count
|
|
241
|
-
|
|
242
|
-
# Round to nice number
|
|
243
|
-
nice_spacing = _round_to_nice_time(rough_spacing)
|
|
244
|
-
|
|
245
|
-
# Generate ticks
|
|
246
|
-
first_tick = np.ceil(time_min_unit / nice_spacing) * nice_spacing
|
|
247
|
-
n_ticks = int((time_max_unit - first_tick) / nice_spacing) + 1
|
|
248
|
-
|
|
249
|
-
ticks_unit = first_tick + np.arange(n_ticks) * nice_spacing
|
|
250
|
-
|
|
251
|
-
# Convert back to seconds
|
|
252
|
-
ticks = ticks_unit / multiplier
|
|
253
|
-
|
|
254
|
-
# Filter to range
|
|
255
|
-
filtered_ticks: NDArray[np.float64] = ticks[(ticks >= time_min) & (ticks <= time_max)]
|
|
256
|
-
|
|
257
|
-
return filtered_ticks
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def _round_to_nice_time(value: float) -> float:
|
|
261
|
-
"""Round to nice time value (1, 2, 5, 10, 20, 50 × 10^n). # noqa: RUF002
|
|
262
|
-
|
|
263
|
-
Args:
|
|
264
|
-
value: Value to round.
|
|
265
|
-
|
|
266
|
-
Returns:
|
|
267
|
-
Nice rounded value.
|
|
268
|
-
"""
|
|
269
|
-
if value <= 0:
|
|
270
|
-
return 1.0
|
|
271
|
-
|
|
272
|
-
exponent = np.floor(np.log10(value))
|
|
273
|
-
mantissa = value / (10**exponent)
|
|
274
|
-
|
|
275
|
-
# Nice fractions for time
|
|
276
|
-
nice_fractions = [1.0, 2.0, 5.0, 10.0]
|
|
277
|
-
|
|
278
|
-
# Find closest
|
|
279
|
-
distances = [abs(f - mantissa) for f in nice_fractions]
|
|
280
|
-
min_idx = np.argmin(distances)
|
|
281
|
-
nice_mantissa = nice_fractions[min_idx]
|
|
282
|
-
|
|
283
|
-
# Handle overflow
|
|
284
|
-
if nice_mantissa >= 10.0:
|
|
285
|
-
nice_mantissa = 1.0
|
|
286
|
-
exponent += 1
|
|
287
|
-
|
|
288
|
-
return nice_mantissa * (10**exponent) # type: ignore[no-any-return]
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def format_cursor_readout(
|
|
292
|
-
time_value: float,
|
|
293
|
-
*,
|
|
294
|
-
unit: TimeUnit = "auto",
|
|
295
|
-
full_precision: bool = True,
|
|
296
|
-
) -> str:
|
|
297
|
-
"""Format time value for cursor readout with full precision.
|
|
298
|
-
|
|
299
|
-
Args:
|
|
300
|
-
time_value: Time value in seconds.
|
|
301
|
-
unit: Display unit.
|
|
302
|
-
full_precision: Show full floating-point precision.
|
|
303
|
-
|
|
304
|
-
Returns:
|
|
305
|
-
Formatted time string.
|
|
306
|
-
|
|
307
|
-
Example:
|
|
308
|
-
>>> readout = format_cursor_readout(1.23456789e-6, unit="us")
|
|
309
|
-
>>> # Returns "1.23456789 μs"
|
|
310
|
-
|
|
311
|
-
References:
|
|
312
|
-
VIS-014: Adaptive X-Axis Time Window (cursor readout)
|
|
313
|
-
"""
|
|
314
|
-
# Select unit if auto
|
|
315
|
-
if unit == "auto":
|
|
316
|
-
unit = select_time_unit(abs(time_value))
|
|
317
|
-
|
|
318
|
-
# Convert to unit
|
|
319
|
-
time_converted = convert_time_values(np.array([time_value]), unit)[0]
|
|
320
|
-
|
|
321
|
-
# Unit symbols
|
|
322
|
-
unit_symbols = {
|
|
323
|
-
"s": "s",
|
|
324
|
-
"ms": "ms",
|
|
325
|
-
"us": "μs",
|
|
326
|
-
"ns": "ns",
|
|
327
|
-
"ps": "ps",
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
symbol = unit_symbols.get(unit, unit)
|
|
331
|
-
|
|
332
|
-
# Format with appropriate precision
|
|
333
|
-
if full_precision:
|
|
334
|
-
# Maximum useful precision (avoid floating point noise)
|
|
335
|
-
formatted = f"{time_converted:.12g}"
|
|
336
|
-
else:
|
|
337
|
-
# Standard precision
|
|
338
|
-
formatted = f"{time_converted:.6g}"
|
|
339
|
-
|
|
340
|
-
return f"{formatted} {symbol}"
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
__all__ = [
|
|
344
|
-
"TimeUnit",
|
|
345
|
-
"calculate_major_ticks",
|
|
346
|
-
"convert_time_values",
|
|
347
|
-
"create_relative_time",
|
|
348
|
-
"format_cursor_readout",
|
|
349
|
-
"format_time_labels",
|
|
350
|
-
"select_time_unit",
|
|
351
|
-
]
|