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,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
|
-
]
|