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,371 +0,0 @@
1
- """Enhanced annotation placement with collision detection.
2
-
3
- This module provides intelligent annotation placement with collision avoidance,
4
- priority-based positioning, and dynamic hiding at different zoom levels.
5
-
6
-
7
- Example:
8
- >>> from oscura.visualization.annotations import place_annotations
9
- >>> placed = place_annotations(annotations, viewport=(0, 10), density_limit=20)
10
-
11
- References:
12
- - Force-directed graph layout (Fruchterman-Reingold)
13
- - Greedy placement with priority
14
- - Leader line routing algorithms
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- from dataclasses import dataclass
20
-
21
- import numpy as np
22
-
23
- from oscura.utils.geometry import generate_leader_line
24
-
25
-
26
- @dataclass
27
- class Annotation:
28
- """Annotation specification with position and metadata.
29
-
30
- Attributes:
31
- text: Annotation text
32
- x: X coordinate in data units
33
- y: Y coordinate in data units
34
- bbox_width: Bounding box width in pixels
35
- bbox_height: Bounding box height in pixels
36
- priority: Priority for placement (0-1, higher is more important)
37
- anchor: Preferred anchor position
38
- metadata: Additional metadata
39
- """
40
-
41
- text: str
42
- x: float
43
- y: float
44
- bbox_width: float = 60.0
45
- bbox_height: float = 20.0
46
- priority: float = 0.5
47
- anchor: str = "auto"
48
- metadata: dict | None = None # type: ignore[type-arg]
49
-
50
- def __post_init__(self): # type: ignore[no-untyped-def]
51
- if self.metadata is None:
52
- self.metadata = {}
53
-
54
-
55
- @dataclass
56
- class PlacedAnnotation:
57
- """Annotation with optimized placement and leader line.
58
-
59
- Attributes:
60
- annotation: Original annotation
61
- display_x: Optimized X position in data units
62
- display_y: Optimized Y position in data units
63
- visible: Whether annotation is visible at current zoom
64
- needs_leader: Whether a leader line is needed
65
- leader_points: Points for leader line (if needed)
66
- """
67
-
68
- annotation: Annotation
69
- display_x: float
70
- display_y: float
71
- visible: bool = True
72
- needs_leader: bool = False
73
- leader_points: list[tuple[float, float]] | None = None
74
-
75
-
76
- def place_annotations(
77
- annotations: list[Annotation],
78
- *,
79
- viewport: tuple[float, float] | None = None,
80
- density_limit: int = 20,
81
- collision_threshold: float = 5.0,
82
- max_iterations: int = 50,
83
- ) -> list[PlacedAnnotation]:
84
- """Place annotations with collision detection and density limiting.
85
-
86
- Enhanced version with viewport-aware density limiting and dynamic hiding.
87
-
88
- Args:
89
- annotations: List of annotations to place.
90
- viewport: Viewport range (x_min, x_max) for density calculation (None = all visible).
91
- density_limit: Maximum annotations per viewport.
92
- collision_threshold: Minimum spacing in pixels.
93
- max_iterations: Maximum iterations for collision resolution.
94
-
95
- Returns:
96
- List of PlacedAnnotation with optimized positions.
97
-
98
- Example:
99
- >>> annots = [
100
- ... Annotation("Peak", 5.0, 1.0, priority=0.9),
101
- ... Annotation("Min", 3.0, -0.5, priority=0.7),
102
- ... ]
103
- >>> placed = place_annotations(annots, density_limit=10)
104
-
105
- References:
106
- VIS-016: Annotation Placement Intelligence (enhanced)
107
- """
108
- if len(annotations) == 0:
109
- return []
110
-
111
- # Filter and limit annotations
112
- visible_annots = _filter_by_viewport(annotations, viewport)
113
- visible_annots = _apply_density_limit(visible_annots, density_limit)
114
-
115
- # Initialize placement
116
- placed = _initialize_placements(visible_annots)
117
-
118
- # Resolve collisions
119
- _resolve_collisions(placed, collision_threshold, max_iterations)
120
-
121
- # Add leader lines where needed
122
- _add_leader_lines(placed, leader_threshold=30.0)
123
-
124
- return placed
125
-
126
-
127
- def _filter_by_viewport(
128
- annotations: list[Annotation],
129
- viewport: tuple[float, float] | None,
130
- ) -> list[Annotation]:
131
- """Filter annotations by viewport range."""
132
- if viewport is None:
133
- return annotations
134
-
135
- x_min, x_max = viewport
136
- return [a for a in annotations if x_min <= a.x <= x_max]
137
-
138
-
139
- def _apply_density_limit(
140
- annotations: list[Annotation],
141
- density_limit: int,
142
- ) -> list[Annotation]:
143
- """Apply density limiting by keeping top priority annotations."""
144
- if len(annotations) <= density_limit:
145
- return annotations
146
-
147
- sorted_annots = sorted(annotations, key=lambda a: a.priority, reverse=True)
148
- return sorted_annots[:density_limit]
149
-
150
-
151
- def _initialize_placements(annotations: list[Annotation]) -> list[PlacedAnnotation]:
152
- """Initialize placed annotations at anchor points."""
153
- return [
154
- PlacedAnnotation(
155
- annotation=annot,
156
- display_x=annot.x,
157
- display_y=annot.y,
158
- visible=True,
159
- needs_leader=False,
160
- )
161
- for annot in annotations
162
- ]
163
-
164
-
165
- def _resolve_collisions(
166
- placed: list[PlacedAnnotation],
167
- collision_threshold: float,
168
- max_iterations: int,
169
- ) -> None:
170
- """Resolve collisions using iterative adjustment."""
171
- for _iteration in range(max_iterations):
172
- moved = False
173
-
174
- for i in range(len(placed)):
175
- for j in range(i + 1, len(placed)):
176
- if _check_collision(placed[i], placed[j], collision_threshold):
177
- # Move lower-priority annotation
178
- if placed[i].annotation.priority >= placed[j].annotation.priority:
179
- moved = _move_annotation(placed[j], placed[i], collision_threshold) or moved
180
- else:
181
- moved = _move_annotation(placed[i], placed[j], collision_threshold) or moved
182
-
183
- if not moved:
184
- break
185
-
186
-
187
- def _add_leader_lines(placed: list[PlacedAnnotation], leader_threshold: float) -> None:
188
- """Add leader lines for displaced annotations."""
189
- for p in placed:
190
- dx = abs(p.display_x - p.annotation.x)
191
- dy = abs(p.display_y - p.annotation.y)
192
- displacement = np.sqrt(dx**2 + dy**2)
193
-
194
- if displacement > leader_threshold:
195
- p.needs_leader = True
196
- p.leader_points = generate_leader_line(
197
- (p.annotation.x, p.annotation.y),
198
- (p.display_x, p.display_y),
199
- )
200
-
201
-
202
- def _check_collision(
203
- p1: PlacedAnnotation,
204
- p2: PlacedAnnotation,
205
- threshold: float,
206
- ) -> bool:
207
- """Check if two annotations collide.
208
-
209
- Args:
210
- p1: First annotation
211
- p2: Second annotation
212
- threshold: Minimum spacing threshold
213
-
214
- Returns:
215
- True if annotations collide
216
- """
217
- # Bounding box collision detection
218
- dx = abs(p2.display_x - p1.display_x)
219
- dy = abs(p2.display_y - p1.display_y)
220
-
221
- # Minimum separation (sum of half-widths + threshold)
222
- min_dx = (p1.annotation.bbox_width + p2.annotation.bbox_width) / 2 + threshold
223
- min_dy = (p1.annotation.bbox_height + p2.annotation.bbox_height) / 2 + threshold
224
-
225
- return dx < min_dx and dy < min_dy
226
-
227
-
228
- def _move_annotation(
229
- to_move: PlacedAnnotation,
230
- fixed: PlacedAnnotation,
231
- threshold: float,
232
- ) -> bool:
233
- """Move annotation away from collision.
234
-
235
- Args:
236
- to_move: Annotation to move
237
- fixed: Fixed annotation to move away from
238
- threshold: Minimum spacing
239
-
240
- Returns:
241
- True if annotation was moved
242
- """
243
- dx = to_move.display_x - fixed.display_x
244
- dy = to_move.display_y - fixed.display_y
245
-
246
- distance = np.sqrt(dx**2 + dy**2)
247
-
248
- if distance < 1e-6:
249
- # Randomize if overlapping exactly
250
- dx = np.random.randn() * 10
251
- dy = np.random.randn() * 10
252
- distance = np.sqrt(dx**2 + dy**2)
253
-
254
- # Required separation
255
- min_dx = (to_move.annotation.bbox_width + fixed.annotation.bbox_width) / 2 + threshold
256
- min_dy = (to_move.annotation.bbox_height + fixed.annotation.bbox_height) / 2 + threshold
257
- min_dist = np.sqrt(min_dx**2 + min_dy**2)
258
-
259
- # Move away if too close
260
- if distance < min_dist:
261
- # Move proportionally to required distance
262
- scale = min_dist / distance
263
- new_x = fixed.display_x + dx * scale
264
- new_y = fixed.display_y + dy * scale
265
-
266
- # Apply with damping to avoid oscillation
267
- damping = 0.5
268
- to_move.display_x += (new_x - to_move.display_x) * damping
269
- to_move.display_y += (new_y - to_move.display_y) * damping
270
-
271
- return True
272
-
273
- return False
274
-
275
-
276
- def filter_by_zoom_level(
277
- placed: list[PlacedAnnotation],
278
- zoom_range: tuple[float, float],
279
- *,
280
- min_width_for_display: float = 0.1,
281
- ) -> list[PlacedAnnotation]:
282
- """Filter annotations based on zoom level.
283
-
284
- Hide annotations when zoom range is too large for readability.
285
-
286
- Args:
287
- placed: List of placed annotations.
288
- zoom_range: Current zoom range (x_min, x_max).
289
- min_width_for_display: Minimum zoom width to display annotations.
290
-
291
- Returns:
292
- Filtered list with visibility updated.
293
-
294
- Example:
295
- >>> # Hide annotations when zoomed out too far
296
- >>> filtered = filter_by_zoom_level(placed, (0, 1000), min_width_for_display=1.0)
297
-
298
- References:
299
- VIS-016: Annotation Placement Intelligence (dynamic hiding)
300
- """
301
- x_min, x_max = zoom_range
302
- zoom_width = x_max - x_min
303
-
304
- result = []
305
- for p in placed:
306
- # Update visibility based on zoom level
307
- if zoom_width < min_width_for_display:
308
- p.visible = True
309
- else:
310
- # Hide if outside viewport or too zoomed out
311
- in_viewport = x_min <= p.annotation.x <= x_max
312
- p.visible = in_viewport
313
-
314
- result.append(p)
315
-
316
- return result
317
-
318
-
319
- def create_priority_annotation( # type: ignore[no-untyped-def]
320
- text: str,
321
- x: float,
322
- y: float,
323
- *,
324
- importance: str = "normal",
325
- **kwargs,
326
- ) -> Annotation:
327
- """Create annotation with priority based on importance level.
328
-
329
- Args:
330
- text: Annotation text.
331
- x: X position in data units.
332
- y: Y position in data units.
333
- importance: Importance level ("critical", "high", "normal", "low").
334
- **kwargs: Additional Annotation parameters.
335
-
336
- Returns:
337
- Annotation with appropriate priority.
338
-
339
- Example:
340
- >>> peak_annot = create_priority_annotation(
341
- ... "Critical Peak", 5.0, 1.0, importance="critical"
342
- ... )
343
-
344
- References:
345
- VIS-016: Annotation Placement Intelligence (priority-based positioning)
346
- """
347
- priority_map = {
348
- "critical": 1.0,
349
- "high": 0.8,
350
- "normal": 0.5,
351
- "low": 0.2,
352
- }
353
-
354
- priority = priority_map.get(importance, 0.5)
355
-
356
- return Annotation(
357
- text=text,
358
- x=x,
359
- y=y,
360
- priority=priority,
361
- **kwargs,
362
- )
363
-
364
-
365
- __all__ = [
366
- "Annotation",
367
- "PlacedAnnotation",
368
- "create_priority_annotation",
369
- "filter_by_zoom_level",
370
- "place_annotations",
371
- ]
@@ -1,305 +0,0 @@
1
- """Intelligent axis scaling and range optimization.
2
-
3
- This module provides enhanced Y-axis scaling with nice number rounding,
4
- outlier exclusion, and per-channel scaling for multi-channel plots.
5
-
6
-
7
- Example:
8
- >>> from oscura.visualization.axis_scaling import calculate_axis_limits
9
- >>> y_min, y_max = calculate_axis_limits(signal, nice_numbers=True)
10
-
11
- References:
12
- - Wilkinson's tick placement algorithm
13
- - Percentile-based outlier detection
14
- - IEEE publication best practices
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
-
27
- def calculate_axis_limits(
28
- data: NDArray[np.float64],
29
- *,
30
- nice_numbers: bool = True,
31
- outlier_percentile: float = 1.0,
32
- margin_percent: float = 5.0,
33
- symmetric: bool = False,
34
- zero_centered: bool = False,
35
- ) -> tuple[float, float]:
36
- """Calculate optimal axis limits with nice number rounding.
37
-
38
- Enhanced version with nice number rounding for publication-quality plots.
39
-
40
- Args:
41
- data: Signal data array.
42
- nice_numbers: Round limits to nice numbers (1, 2, 5 × 10^n). # noqa: RUF002
43
- outlier_percentile: Percentile for outlier exclusion (default 1% each side).
44
- margin_percent: Margin as percentage of data range (default 5%).
45
- symmetric: Use symmetric range ±max for bipolar signals.
46
- zero_centered: Force zero to be centered in range.
47
-
48
- Returns:
49
- Tuple of (y_min, y_max) with nice rounded values.
50
-
51
- Raises:
52
- ValueError: If data is empty or all NaN.
53
-
54
- Example:
55
- >>> signal = np.array([1.234, 5.678, 9.012])
56
- >>> y_min, y_max = calculate_axis_limits(signal, nice_numbers=True)
57
- >>> # Returns nice values like (0.0, 10.0) instead of (1.234, 9.012)
58
-
59
- References:
60
- VIS-013: Auto Y-Axis Range Optimization
61
- Wilkinson (1999): The Grammar of Graphics
62
- """
63
- if len(data) == 0:
64
- raise ValueError("Data array is empty")
65
-
66
- # Remove NaN values
67
- clean_data = data[~np.isnan(data)]
68
-
69
- if len(clean_data) == 0:
70
- raise ValueError("Data contains only NaN values")
71
-
72
- # Exclude outliers using percentiles
73
- lower_pct = outlier_percentile
74
- upper_pct = 100.0 - outlier_percentile
75
-
76
- data_min = np.percentile(clean_data, lower_pct)
77
- data_max = np.percentile(clean_data, upper_pct)
78
- data_range = data_max - data_min
79
-
80
- # Apply margin
81
- margin = margin_percent / 100.0
82
- margin_value = data_range * margin
83
-
84
- if symmetric:
85
- # Symmetric range: ±max
86
- max_abs = max(abs(data_min), abs(data_max))
87
- y_min = -(max_abs + margin_value)
88
- y_max = max_abs + margin_value
89
- elif zero_centered:
90
- # Force zero to be centered
91
- max_extent = max(abs(data_min), abs(data_max)) + margin_value
92
- y_min = -max_extent
93
- y_max = max_extent
94
- else:
95
- # Asymmetric range
96
- y_min = data_min - margin_value
97
- y_max = data_max + margin_value
98
-
99
- # Round to nice numbers if requested
100
- if nice_numbers:
101
- y_min = _round_to_nice_number(y_min, direction="down")
102
- y_max = _round_to_nice_number(y_max, direction="up")
103
-
104
- return (float(y_min), float(y_max))
105
-
106
-
107
- def calculate_multi_channel_limits( # type: ignore[no-untyped-def]
108
- channels: list[NDArray[np.float64]],
109
- *,
110
- mode: Literal["per_channel", "common", "grouped"] = "per_channel",
111
- nice_numbers: bool = True,
112
- **kwargs,
113
- ) -> list[tuple[float, float]]:
114
- """Calculate axis limits for multiple channels.
115
-
116
- Args:
117
- channels: List of channel data arrays.
118
- mode: Scaling mode:
119
- - "per_channel": Independent ranges per channel
120
- - "common": Single range for all channels
121
- - "grouped": Group similar ranges
122
- nice_numbers: Round to nice numbers.
123
- **kwargs: Additional arguments passed to calculate_axis_limits.
124
-
125
- Returns:
126
- List of (y_min, y_max) tuples, one per channel.
127
-
128
- Raises:
129
- ValueError: If unknown mode specified.
130
-
131
- Example:
132
- >>> ch1 = np.array([0, 1, 2])
133
- >>> ch2 = np.array([0, 10, 20])
134
- >>> limits = calculate_multi_channel_limits([ch1, ch2], mode="per_channel")
135
-
136
- References:
137
- VIS-013: Auto Y-Axis Range Optimization (per-channel scaling)
138
- VIS-015: Multi-Channel Stack Optimization
139
- """
140
- if len(channels) == 0:
141
- return []
142
-
143
- if mode == "per_channel":
144
- # Independent ranges
145
- return [calculate_axis_limits(ch, nice_numbers=nice_numbers, **kwargs) for ch in channels]
146
-
147
- elif mode == "common":
148
- # Single range for all channels
149
- all_data = np.concatenate([ch for ch in channels if len(ch) > 0])
150
- if len(all_data) == 0:
151
- return [(0.0, 1.0)] * len(channels)
152
-
153
- common_limits = calculate_axis_limits(all_data, nice_numbers=nice_numbers, **kwargs)
154
- return [common_limits] * len(channels)
155
-
156
- elif mode == "grouped":
157
- # Group channels with similar ranges
158
- # First calculate individual ranges
159
- individual_limits = [
160
- calculate_axis_limits(ch, nice_numbers=False, **kwargs) for ch in channels
161
- ]
162
-
163
- # Simple grouping: group by order of magnitude
164
- grouped_limits = []
165
- for y_min, y_max in individual_limits:
166
- range_mag = np.log10(max(abs(y_max - y_min), 1e-10))
167
- # Round to nearest integer magnitude
168
- group_mag = int(np.round(range_mag))
169
-
170
- # Use 10^group_mag as the range scale
171
- scale = 10.0**group_mag
172
-
173
- # Round to this scale
174
- grouped_min = np.floor(y_min / scale) * scale
175
- grouped_max = np.ceil(y_max / scale) * scale
176
-
177
- if nice_numbers:
178
- grouped_min = _round_to_nice_number(grouped_min, direction="down")
179
- grouped_max = _round_to_nice_number(grouped_max, direction="up")
180
-
181
- grouped_limits.append((float(grouped_min), float(grouped_max)))
182
-
183
- return grouped_limits
184
-
185
- else:
186
- raise ValueError(f"Unknown mode: {mode}")
187
-
188
-
189
- def _round_to_nice_number(
190
- value: float,
191
- *,
192
- direction: Literal["up", "down", "nearest"] = "nearest",
193
- ) -> float:
194
- """Round value to nice number (1, 2, 5 × 10^n). # noqa: RUF002
195
-
196
- Args:
197
- value: Value to round.
198
- direction: Rounding direction ("up", "down", "nearest").
199
-
200
- Returns:
201
- Rounded nice number.
202
-
203
- Example:
204
- >>> _round_to_nice_number(3.7, direction="up")
205
- 5.0
206
- >>> _round_to_nice_number(3.7, direction="down")
207
- 2.0
208
- >>> _round_to_nice_number(0.037, direction="up")
209
- 0.05
210
- """
211
- if value == 0:
212
- return 0.0
213
-
214
- # Determine sign
215
- sign = 1 if value >= 0 else -1
216
- abs_value = abs(value)
217
-
218
- # Find exponent
219
- exponent = np.floor(np.log10(abs_value))
220
- mantissa = abs_value / (10**exponent)
221
-
222
- # Round mantissa to nice fraction (1, 2, 5)
223
- nice_fractions = [1.0, 2.0, 5.0, 10.0]
224
-
225
- if direction == "up":
226
- # Find smallest nice fraction >= mantissa
227
- nice_mantissa = next((f for f in nice_fractions if f >= mantissa), 10.0)
228
- elif direction == "down":
229
- # Find largest nice fraction <= mantissa
230
- nice_mantissa = 1.0
231
- for f in nice_fractions:
232
- if f <= mantissa:
233
- nice_mantissa = f
234
- else:
235
- break
236
- else: # nearest
237
- # Find closest nice fraction
238
- distances = [abs(f - mantissa) for f in nice_fractions]
239
- min_idx = np.argmin(distances)
240
- nice_mantissa = nice_fractions[min_idx]
241
-
242
- # Handle mantissa = 10 case (move to next exponent)
243
- if nice_mantissa >= 10.0:
244
- nice_mantissa = 1.0
245
- exponent += 1
246
-
247
- return sign * nice_mantissa * (10**exponent) # type: ignore[no-any-return]
248
-
249
-
250
- def suggest_tick_spacing(
251
- y_min: float,
252
- y_max: float,
253
- *,
254
- target_ticks: int = 5,
255
- minor_ticks: bool = True,
256
- ) -> tuple[float, float]:
257
- """Suggest tick spacing for axis.
258
-
259
- Args:
260
- y_min: Minimum axis value.
261
- y_max: Maximum axis value.
262
- target_ticks: Target number of major ticks.
263
- minor_ticks: Generate minor tick spacing.
264
-
265
- Returns:
266
- Tuple of (major_spacing, minor_spacing).
267
-
268
- Example:
269
- >>> major, minor = suggest_tick_spacing(0, 10, target_ticks=5)
270
- >>> # Returns (2.0, 0.5) for nice tick marks at 0, 2, 4, 6, 8, 10
271
-
272
- References:
273
- VIS-019: Grid Auto-Spacing
274
- """
275
- axis_range = y_max - y_min
276
-
277
- if axis_range <= 0:
278
- return (1.0, 0.2)
279
-
280
- # Calculate rough spacing
281
- rough_spacing = axis_range / target_ticks
282
-
283
- # Round to nice number
284
- major_spacing = _round_to_nice_number(rough_spacing, direction="nearest")
285
-
286
- # Minor spacing: 1/5 of major for most cases
287
- if minor_ticks:
288
- # Use 1/5 for multiples of 5, 1/4 for multiples of 2, 1/2 otherwise
289
- if major_spacing % 5 == 0:
290
- minor_spacing = major_spacing / 5
291
- elif major_spacing % 2 == 0:
292
- minor_spacing = major_spacing / 4
293
- else:
294
- minor_spacing = major_spacing / 2
295
- else:
296
- minor_spacing = major_spacing
297
-
298
- return (float(major_spacing), float(minor_spacing))
299
-
300
-
301
- __all__ = [
302
- "calculate_axis_limits",
303
- "calculate_multi_channel_limits",
304
- "suggest_tick_spacing",
305
- ]