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.
Files changed (151) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/analyzers/__init__.py +2 -0
  3. oscura/analyzers/digital/extraction.py +2 -3
  4. oscura/analyzers/digital/quality.py +1 -1
  5. oscura/analyzers/digital/timing.py +1 -1
  6. oscura/analyzers/patterns/__init__.py +66 -0
  7. oscura/analyzers/power/basic.py +3 -3
  8. oscura/analyzers/power/soa.py +1 -1
  9. oscura/analyzers/power/switching.py +3 -3
  10. oscura/analyzers/signal_classification.py +529 -0
  11. oscura/analyzers/signal_integrity/sparams.py +3 -3
  12. oscura/analyzers/statistics/basic.py +10 -7
  13. oscura/analyzers/validation.py +1 -1
  14. oscura/analyzers/waveform/measurements.py +200 -156
  15. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  16. oscura/analyzers/waveform/spectral.py +164 -73
  17. oscura/api/dsl/commands.py +15 -6
  18. oscura/api/server/templates/base.html +137 -146
  19. oscura/api/server/templates/export.html +84 -110
  20. oscura/api/server/templates/home.html +248 -267
  21. oscura/api/server/templates/protocols.html +44 -48
  22. oscura/api/server/templates/reports.html +27 -35
  23. oscura/api/server/templates/session_detail.html +68 -78
  24. oscura/api/server/templates/sessions.html +62 -72
  25. oscura/api/server/templates/waveforms.html +54 -64
  26. oscura/automotive/__init__.py +1 -1
  27. oscura/automotive/can/session.py +1 -1
  28. oscura/automotive/dbc/generator.py +638 -23
  29. oscura/automotive/uds/decoder.py +99 -6
  30. oscura/cli/analyze.py +8 -2
  31. oscura/cli/batch.py +36 -5
  32. oscura/cli/characterize.py +18 -4
  33. oscura/cli/export.py +47 -5
  34. oscura/cli/main.py +2 -0
  35. oscura/cli/onboarding/wizard.py +10 -6
  36. oscura/cli/pipeline.py +585 -0
  37. oscura/cli/visualize.py +6 -4
  38. oscura/convenience.py +400 -32
  39. oscura/core/measurement_result.py +286 -0
  40. oscura/core/progress.py +1 -1
  41. oscura/core/types.py +232 -239
  42. oscura/correlation/multi_protocol.py +1 -1
  43. oscura/export/legacy/__init__.py +11 -0
  44. oscura/export/legacy/wav.py +75 -0
  45. oscura/exporters/__init__.py +19 -0
  46. oscura/exporters/wireshark.py +809 -0
  47. oscura/hardware/acquisition/file.py +5 -19
  48. oscura/hardware/acquisition/saleae.py +10 -10
  49. oscura/hardware/acquisition/socketcan.py +4 -6
  50. oscura/hardware/acquisition/synthetic.py +1 -5
  51. oscura/hardware/acquisition/visa.py +6 -6
  52. oscura/hardware/security/side_channel_detector.py +5 -508
  53. oscura/inference/message_format.py +686 -1
  54. oscura/jupyter/display.py +2 -2
  55. oscura/jupyter/magic.py +3 -3
  56. oscura/loaders/__init__.py +17 -12
  57. oscura/loaders/binary.py +1 -1
  58. oscura/loaders/chipwhisperer.py +1 -2
  59. oscura/loaders/configurable.py +1 -1
  60. oscura/loaders/csv_loader.py +2 -2
  61. oscura/loaders/hdf5_loader.py +1 -1
  62. oscura/loaders/lazy.py +6 -1
  63. oscura/loaders/mmap_loader.py +0 -1
  64. oscura/loaders/numpy_loader.py +8 -7
  65. oscura/loaders/preprocessing.py +3 -5
  66. oscura/loaders/rigol.py +21 -7
  67. oscura/loaders/sigrok.py +2 -5
  68. oscura/loaders/tdms.py +3 -2
  69. oscura/loaders/tektronix.py +38 -32
  70. oscura/loaders/tss.py +20 -27
  71. oscura/loaders/vcd.py +13 -8
  72. oscura/loaders/wav.py +1 -6
  73. oscura/pipeline/__init__.py +76 -0
  74. oscura/pipeline/handlers/__init__.py +165 -0
  75. oscura/pipeline/handlers/analyzers.py +1045 -0
  76. oscura/pipeline/handlers/decoders.py +899 -0
  77. oscura/pipeline/handlers/exporters.py +1103 -0
  78. oscura/pipeline/handlers/filters.py +891 -0
  79. oscura/pipeline/handlers/loaders.py +640 -0
  80. oscura/pipeline/handlers/transforms.py +768 -0
  81. oscura/reporting/formatting/measurements.py +55 -14
  82. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  83. oscura/side_channel/__init__.py +38 -57
  84. oscura/utils/builders/signal_builder.py +5 -5
  85. oscura/utils/comparison/compare.py +7 -9
  86. oscura/utils/comparison/golden.py +1 -1
  87. oscura/utils/filtering/convenience.py +2 -2
  88. oscura/utils/math/arithmetic.py +38 -62
  89. oscura/utils/math/interpolation.py +20 -20
  90. oscura/utils/pipeline/__init__.py +4 -17
  91. oscura/utils/progressive.py +1 -4
  92. oscura/utils/triggering/edge.py +1 -1
  93. oscura/utils/triggering/pattern.py +2 -2
  94. oscura/utils/triggering/pulse.py +2 -2
  95. oscura/utils/triggering/window.py +3 -3
  96. oscura/validation/hil_testing.py +11 -11
  97. oscura/visualization/__init__.py +46 -284
  98. oscura/visualization/batch.py +72 -433
  99. oscura/visualization/plot.py +542 -53
  100. oscura/visualization/styles.py +184 -318
  101. oscura/workflows/batch/advanced.py +1 -1
  102. oscura/workflows/batch/aggregate.py +7 -8
  103. oscura/workflows/complete_re.py +251 -23
  104. oscura/workflows/digital.py +27 -4
  105. oscura/workflows/multi_trace.py +136 -17
  106. oscura/workflows/waveform.py +11 -6
  107. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  108. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/RECORD +111 -136
  109. oscura/side_channel/dpa.py +0 -1025
  110. oscura/utils/optimization/__init__.py +0 -19
  111. oscura/utils/optimization/parallel.py +0 -443
  112. oscura/utils/optimization/search.py +0 -532
  113. oscura/utils/pipeline/base.py +0 -338
  114. oscura/utils/pipeline/composition.py +0 -248
  115. oscura/utils/pipeline/parallel.py +0 -449
  116. oscura/utils/pipeline/pipeline.py +0 -375
  117. oscura/utils/search/__init__.py +0 -16
  118. oscura/utils/search/anomaly.py +0 -424
  119. oscura/utils/search/context.py +0 -294
  120. oscura/utils/search/pattern.py +0 -288
  121. oscura/utils/storage/__init__.py +0 -61
  122. oscura/utils/storage/database.py +0 -1166
  123. oscura/visualization/accessibility.py +0 -526
  124. oscura/visualization/annotations.py +0 -371
  125. oscura/visualization/axis_scaling.py +0 -305
  126. oscura/visualization/colors.py +0 -451
  127. oscura/visualization/digital.py +0 -436
  128. oscura/visualization/eye.py +0 -571
  129. oscura/visualization/histogram.py +0 -281
  130. oscura/visualization/interactive.py +0 -1035
  131. oscura/visualization/jitter.py +0 -1042
  132. oscura/visualization/keyboard.py +0 -394
  133. oscura/visualization/layout.py +0 -400
  134. oscura/visualization/optimization.py +0 -1079
  135. oscura/visualization/palettes.py +0 -446
  136. oscura/visualization/power.py +0 -508
  137. oscura/visualization/power_extended.py +0 -955
  138. oscura/visualization/presets.py +0 -469
  139. oscura/visualization/protocols.py +0 -1246
  140. oscura/visualization/render.py +0 -223
  141. oscura/visualization/rendering.py +0 -444
  142. oscura/visualization/reverse_engineering.py +0 -838
  143. oscura/visualization/signal_integrity.py +0 -989
  144. oscura/visualization/specialized.py +0 -643
  145. oscura/visualization/spectral.py +0 -1226
  146. oscura/visualization/thumbnails.py +0 -340
  147. oscura/visualization/time_axis.py +0 -351
  148. oscura/visualization/waveform.py +0 -454
  149. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  150. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  151. {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
- ]