oscura 0.8.0__py3-none-any.whl → 0.11.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 (161) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/__main__.py +4 -0
  3. oscura/analyzers/__init__.py +2 -0
  4. oscura/analyzers/digital/extraction.py +2 -3
  5. oscura/analyzers/digital/quality.py +1 -1
  6. oscura/analyzers/digital/timing.py +1 -1
  7. oscura/analyzers/ml/signal_classifier.py +6 -0
  8. oscura/analyzers/patterns/__init__.py +66 -0
  9. oscura/analyzers/power/basic.py +3 -3
  10. oscura/analyzers/power/soa.py +1 -1
  11. oscura/analyzers/power/switching.py +3 -3
  12. oscura/analyzers/signal_classification.py +529 -0
  13. oscura/analyzers/signal_integrity/sparams.py +3 -3
  14. oscura/analyzers/statistics/basic.py +10 -7
  15. oscura/analyzers/validation.py +1 -1
  16. oscura/analyzers/waveform/measurements.py +200 -156
  17. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  18. oscura/analyzers/waveform/spectral.py +182 -84
  19. oscura/api/dsl/commands.py +15 -6
  20. oscura/api/server/templates/base.html +137 -146
  21. oscura/api/server/templates/export.html +84 -110
  22. oscura/api/server/templates/home.html +248 -267
  23. oscura/api/server/templates/protocols.html +44 -48
  24. oscura/api/server/templates/reports.html +27 -35
  25. oscura/api/server/templates/session_detail.html +68 -78
  26. oscura/api/server/templates/sessions.html +62 -72
  27. oscura/api/server/templates/waveforms.html +54 -64
  28. oscura/automotive/__init__.py +1 -1
  29. oscura/automotive/can/session.py +1 -1
  30. oscura/automotive/dbc/generator.py +638 -23
  31. oscura/automotive/dtc/data.json +17 -102
  32. oscura/automotive/flexray/fibex.py +9 -1
  33. oscura/automotive/uds/decoder.py +99 -6
  34. oscura/cli/analyze.py +8 -2
  35. oscura/cli/batch.py +36 -5
  36. oscura/cli/characterize.py +18 -4
  37. oscura/cli/export.py +47 -5
  38. oscura/cli/main.py +2 -0
  39. oscura/cli/onboarding/wizard.py +10 -6
  40. oscura/cli/pipeline.py +585 -0
  41. oscura/cli/visualize.py +6 -4
  42. oscura/convenience.py +400 -32
  43. oscura/core/measurement_result.py +286 -0
  44. oscura/core/progress.py +1 -1
  45. oscura/core/schemas/device_mapping.json +2 -8
  46. oscura/core/schemas/packet_format.json +4 -24
  47. oscura/core/schemas/protocol_definition.json +2 -12
  48. oscura/core/types.py +232 -239
  49. oscura/correlation/multi_protocol.py +1 -1
  50. oscura/export/legacy/__init__.py +11 -0
  51. oscura/export/legacy/wav.py +75 -0
  52. oscura/exporters/__init__.py +19 -0
  53. oscura/exporters/wireshark.py +809 -0
  54. oscura/hardware/acquisition/file.py +5 -19
  55. oscura/hardware/acquisition/saleae.py +10 -10
  56. oscura/hardware/acquisition/socketcan.py +4 -6
  57. oscura/hardware/acquisition/synthetic.py +1 -5
  58. oscura/hardware/acquisition/visa.py +6 -6
  59. oscura/hardware/security/side_channel_detector.py +5 -508
  60. oscura/inference/message_format.py +686 -1
  61. oscura/jupyter/display.py +2 -2
  62. oscura/jupyter/magic.py +3 -3
  63. oscura/loaders/__init__.py +17 -12
  64. oscura/loaders/binary.py +1 -1
  65. oscura/loaders/chipwhisperer.py +1 -2
  66. oscura/loaders/configurable.py +1 -1
  67. oscura/loaders/csv_loader.py +2 -2
  68. oscura/loaders/hdf5_loader.py +1 -1
  69. oscura/loaders/lazy.py +6 -1
  70. oscura/loaders/mmap_loader.py +0 -1
  71. oscura/loaders/numpy_loader.py +8 -7
  72. oscura/loaders/preprocessing.py +3 -5
  73. oscura/loaders/rigol.py +21 -7
  74. oscura/loaders/sigrok.py +2 -5
  75. oscura/loaders/tdms.py +3 -2
  76. oscura/loaders/tektronix.py +38 -32
  77. oscura/loaders/tss.py +20 -27
  78. oscura/loaders/validation.py +17 -10
  79. oscura/loaders/vcd.py +13 -8
  80. oscura/loaders/wav.py +1 -6
  81. oscura/pipeline/__init__.py +76 -0
  82. oscura/pipeline/handlers/__init__.py +165 -0
  83. oscura/pipeline/handlers/analyzers.py +1045 -0
  84. oscura/pipeline/handlers/decoders.py +899 -0
  85. oscura/pipeline/handlers/exporters.py +1103 -0
  86. oscura/pipeline/handlers/filters.py +891 -0
  87. oscura/pipeline/handlers/loaders.py +640 -0
  88. oscura/pipeline/handlers/transforms.py +768 -0
  89. oscura/reporting/formatting/measurements.py +55 -14
  90. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  91. oscura/sessions/legacy.py +49 -1
  92. oscura/side_channel/__init__.py +38 -57
  93. oscura/utils/builders/signal_builder.py +5 -5
  94. oscura/utils/comparison/compare.py +7 -9
  95. oscura/utils/comparison/golden.py +1 -1
  96. oscura/utils/filtering/convenience.py +2 -2
  97. oscura/utils/math/arithmetic.py +38 -62
  98. oscura/utils/math/interpolation.py +20 -20
  99. oscura/utils/pipeline/__init__.py +4 -17
  100. oscura/utils/progressive.py +1 -4
  101. oscura/utils/triggering/edge.py +1 -1
  102. oscura/utils/triggering/pattern.py +2 -2
  103. oscura/utils/triggering/pulse.py +2 -2
  104. oscura/utils/triggering/window.py +3 -3
  105. oscura/validation/hil_testing.py +11 -11
  106. oscura/visualization/__init__.py +46 -284
  107. oscura/visualization/batch.py +72 -433
  108. oscura/visualization/plot.py +542 -53
  109. oscura/visualization/styles.py +184 -318
  110. oscura/workflows/batch/advanced.py +1 -1
  111. oscura/workflows/batch/aggregate.py +12 -9
  112. oscura/workflows/complete_re.py +251 -23
  113. oscura/workflows/digital.py +27 -4
  114. oscura/workflows/multi_trace.py +136 -17
  115. oscura/workflows/waveform.py +11 -6
  116. oscura-0.11.0.dist-info/METADATA +460 -0
  117. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/RECORD +120 -145
  118. oscura/side_channel/dpa.py +0 -1025
  119. oscura/utils/optimization/__init__.py +0 -19
  120. oscura/utils/optimization/parallel.py +0 -443
  121. oscura/utils/optimization/search.py +0 -532
  122. oscura/utils/pipeline/base.py +0 -338
  123. oscura/utils/pipeline/composition.py +0 -248
  124. oscura/utils/pipeline/parallel.py +0 -449
  125. oscura/utils/pipeline/pipeline.py +0 -375
  126. oscura/utils/search/__init__.py +0 -16
  127. oscura/utils/search/anomaly.py +0 -424
  128. oscura/utils/search/context.py +0 -294
  129. oscura/utils/search/pattern.py +0 -288
  130. oscura/utils/storage/__init__.py +0 -61
  131. oscura/utils/storage/database.py +0 -1166
  132. oscura/visualization/accessibility.py +0 -526
  133. oscura/visualization/annotations.py +0 -371
  134. oscura/visualization/axis_scaling.py +0 -305
  135. oscura/visualization/colors.py +0 -451
  136. oscura/visualization/digital.py +0 -436
  137. oscura/visualization/eye.py +0 -571
  138. oscura/visualization/histogram.py +0 -281
  139. oscura/visualization/interactive.py +0 -1035
  140. oscura/visualization/jitter.py +0 -1042
  141. oscura/visualization/keyboard.py +0 -394
  142. oscura/visualization/layout.py +0 -400
  143. oscura/visualization/optimization.py +0 -1079
  144. oscura/visualization/palettes.py +0 -446
  145. oscura/visualization/power.py +0 -508
  146. oscura/visualization/power_extended.py +0 -955
  147. oscura/visualization/presets.py +0 -469
  148. oscura/visualization/protocols.py +0 -1246
  149. oscura/visualization/render.py +0 -223
  150. oscura/visualization/rendering.py +0 -444
  151. oscura/visualization/reverse_engineering.py +0 -838
  152. oscura/visualization/signal_integrity.py +0 -989
  153. oscura/visualization/specialized.py +0 -643
  154. oscura/visualization/spectral.py +0 -1226
  155. oscura/visualization/thumbnails.py +0 -340
  156. oscura/visualization/time_axis.py +0 -351
  157. oscura/visualization/waveform.py +0 -454
  158. oscura-0.8.0.dist-info/METADATA +0 -661
  159. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/WHEEL +0 -0
  160. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/entry_points.txt +0 -0
  161. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,643 +0,0 @@
1
- """Specialized plot types for protocol analysis and state visualization.
2
-
3
- This module provides specialized visualizations including protocol timing
4
- diagrams, state machine views, and bus transaction timelines.
5
-
6
-
7
- Example:
8
- >>> from oscura.visualization.specialized import plot_protocol_timing
9
- >>> fig = plot_protocol_timing(decoded_packets, sample_rate=1e6)
10
-
11
- References:
12
- - Wavedrom-style digital waveform rendering
13
- - State machine diagram standards
14
- - Bus protocol visualization best practices
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- from dataclasses import dataclass
20
- from typing import TYPE_CHECKING, Any, Literal
21
-
22
- import numpy as np
23
-
24
- if TYPE_CHECKING:
25
- from matplotlib.axes import Axes
26
- from matplotlib.figure import Figure
27
- from numpy.typing import NDArray
28
-
29
- try:
30
- import matplotlib.pyplot as plt
31
- from matplotlib import patches
32
-
33
- HAS_MATPLOTLIB = True
34
- except ImportError:
35
- HAS_MATPLOTLIB = False
36
-
37
-
38
- @dataclass
39
- class ProtocolSignal:
40
- """Protocol signal for timing diagram.
41
-
42
- Attributes:
43
- name: Signal name
44
- data: Signal data (0/1 for digital, values for analog)
45
- type: Signal type ("digital", "clock", "bus", "analog")
46
- transitions: List of transition times
47
- annotations: Dict of time -> annotation text
48
- """
49
-
50
- name: str
51
- data: NDArray[np.float64]
52
- type: Literal["digital", "clock", "bus", "analog"] = "digital"
53
- transitions: list[float] | None = None
54
- annotations: dict[float, str] | None = None
55
-
56
-
57
- @dataclass
58
- class StateTransition:
59
- """State machine transition.
60
-
61
- Attributes:
62
- from_state: Source state name
63
- to_state: Target state name
64
- condition: Transition condition/label
65
- style: Line style ("solid", "dashed", "dotted")
66
- """
67
-
68
- from_state: str
69
- to_state: str
70
- condition: str = ""
71
- style: Literal["solid", "dashed", "dotted"] = "solid"
72
-
73
-
74
- def plot_protocol_timing(
75
- signals: list[ProtocolSignal],
76
- sample_rate: float,
77
- *,
78
- time_range: tuple[float, float] | None = None,
79
- time_unit: str = "auto",
80
- style: Literal["wavedrom", "classic"] = "wavedrom",
81
- figsize: tuple[float, float] | None = None,
82
- title: str | None = None,
83
- ) -> Figure:
84
- """Plot protocol timing diagram in wavedrom style.
85
-
86
- Creates a timing diagram showing digital signals, clock edges, and
87
- bus transactions with annotations for protocol events.
88
-
89
- Args:
90
- signals: List of ProtocolSignal objects to plot.
91
- sample_rate: Sample rate in Hz.
92
- time_range: Time range to plot (t_min, t_max) in seconds. None = full range.
93
- time_unit: Time unit for x-axis ("s", "ms", "us", "ns", "auto").
94
- style: Diagram style ("wavedrom" = clean digital, "classic" = traditional).
95
- figsize: Figure size (width, height). Auto-calculated if None.
96
- title: Plot title.
97
-
98
- Returns:
99
- Matplotlib Figure object.
100
-
101
- Raises:
102
- ImportError: If matplotlib is not available.
103
- ValueError: If signals list is empty.
104
-
105
- Example:
106
- >>> sda = ProtocolSignal("SDA", sda_data, type="digital")
107
- >>> scl = ProtocolSignal("SCL", scl_data, type="clock")
108
- >>> fig = plot_protocol_timing(
109
- ... [scl, sda],
110
- ... sample_rate=1e6,
111
- ... style="wavedrom",
112
- ... title="I2C Transaction"
113
- ... )
114
-
115
- References:
116
- VIS-021: Specialized - Protocol Timing Diagram
117
- Wavedrom digital waveform rendering
118
- """
119
- if not HAS_MATPLOTLIB:
120
- raise ImportError("matplotlib is required for visualization")
121
-
122
- if len(signals) == 0:
123
- raise ValueError("signals list cannot be empty")
124
-
125
- # Setup figure and axes
126
- fig, axes = _setup_timing_figure(signals, figsize)
127
-
128
- # Determine time parameters
129
- t_min, t_max = _determine_time_range(signals, sample_rate, time_range)
130
- time_unit_final, time_mult = _select_timing_unit(time_unit, t_min, t_max)
131
-
132
- # Plot each signal
133
- _plot_all_signals(axes, signals, sample_rate, t_min, t_max, time_mult, style)
134
-
135
- # Finalize plot
136
- _finalize_timing_plot(fig, axes, time_unit_final, title)
137
-
138
- return fig
139
-
140
-
141
- def _setup_timing_figure(
142
- signals: list[ProtocolSignal], figsize: tuple[float, float] | None
143
- ) -> tuple[Figure, list[Any]]:
144
- """Setup figure and axes for timing diagram."""
145
- n_signals = len(signals)
146
-
147
- if figsize is None:
148
- width = 12
149
- height = max(4, n_signals * 0.8 + 1)
150
- figsize = (width, height)
151
-
152
- fig, axes = plt.subplots(
153
- n_signals,
154
- 1,
155
- figsize=figsize,
156
- sharex=True,
157
- gridspec_kw={"hspace": 0.1},
158
- )
159
-
160
- if n_signals == 1:
161
- axes = [axes]
162
-
163
- return fig, axes
164
-
165
-
166
- def _determine_time_range(
167
- signals: list[ProtocolSignal],
168
- sample_rate: float,
169
- time_range: tuple[float, float] | None,
170
- ) -> tuple[float, float]:
171
- """Determine time range for plotting."""
172
- if time_range is not None:
173
- return time_range
174
-
175
- max_len = max(len(sig.data) for sig in signals)
176
- return 0.0, max_len / sample_rate
177
-
178
-
179
- def _select_timing_unit(time_unit: str, t_min: float, t_max: float) -> tuple[str, float]:
180
- """Select appropriate time unit and multiplier."""
181
- if time_unit == "auto":
182
- time_range_val = t_max - t_min
183
- if time_range_val < 1e-6:
184
- return "ns", 1e9
185
- elif time_range_val < 1e-3:
186
- return "us", 1e6
187
- elif time_range_val < 1:
188
- return "ms", 1e3
189
- else:
190
- return "s", 1.0
191
- else:
192
- time_mult = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}.get(time_unit, 1.0)
193
- return time_unit, time_mult
194
-
195
-
196
- def _plot_all_signals(
197
- axes: list[Any],
198
- signals: list[ProtocolSignal],
199
- sample_rate: float,
200
- t_min: float,
201
- t_max: float,
202
- time_mult: float,
203
- style: str,
204
- ) -> None:
205
- """Plot all signals on their respective axes."""
206
- for _idx, (signal, ax) in enumerate(zip(signals, axes, strict=False)):
207
- time = np.arange(len(signal.data)) / sample_rate * time_mult
208
-
209
- # Filter to time range
210
- mask = (time >= t_min * time_mult) & (time <= t_max * time_mult)
211
- time_filtered = time[mask]
212
- data_filtered = signal.data[mask]
213
-
214
- # Plot signal
215
- if style == "wavedrom":
216
- _plot_wavedrom_signal(ax, time_filtered, data_filtered, signal)
217
- else:
218
- _plot_classic_signal(ax, time_filtered, data_filtered, signal)
219
-
220
- # Format axes
221
- _format_signal_axes(ax, signal.name)
222
-
223
- # Add annotations
224
- _add_signal_annotations(ax, signal, t_min, t_max, time_mult)
225
-
226
-
227
- def _format_signal_axes(ax: Axes, signal_name: str) -> None:
228
- """Format axes for a single signal."""
229
- ax.set_ylabel(signal_name, rotation=0, ha="right", va="center", fontsize=10)
230
- ax.set_ylim(-0.2, 1.3)
231
- ax.set_yticks([])
232
- ax.grid(True, axis="x", alpha=0.3, linestyle=":")
233
-
234
-
235
- def _add_signal_annotations(
236
- ax: Axes,
237
- signal: ProtocolSignal,
238
- t_min: float,
239
- t_max: float,
240
- time_mult: float,
241
- ) -> None:
242
- """Add annotations to signal plot."""
243
- if signal.annotations:
244
- for t, text in signal.annotations.items():
245
- if t_min <= t <= t_max:
246
- ax.annotate(
247
- text,
248
- xy=(t * time_mult, 1.2),
249
- fontsize=8,
250
- ha="center",
251
- bbox={"boxstyle": "round,pad=0.3", "facecolor": "yellow", "alpha": 0.7},
252
- )
253
-
254
-
255
- def _finalize_timing_plot(fig: Figure, axes: list[Any], time_unit: str, title: str | None) -> None:
256
- """Finalize timing plot with labels and title."""
257
- axes[-1].set_xlabel(f"Time ({time_unit})", fontsize=11)
258
-
259
- if title:
260
- fig.suptitle(title, fontsize=14, y=0.98)
261
-
262
- fig.tight_layout()
263
-
264
-
265
- def _plot_wavedrom_signal(
266
- ax: Axes,
267
- time: NDArray[np.float64],
268
- data: NDArray[np.float64],
269
- signal: ProtocolSignal,
270
- ) -> None:
271
- """Plot signal in wavedrom style (clean digital waveform)."""
272
- if signal.type == "clock":
273
- # Clock signal: square wave
274
- for i in range(len(time) - 1):
275
- level = 1 if data[i] > 0.5 else 0
276
- ax.plot(
277
- [time[i], time[i + 1]],
278
- [level, level],
279
- "b-",
280
- linewidth=1.5,
281
- )
282
- # Vertical transition
283
- if i < len(time) - 1:
284
- next_level = 1 if data[i + 1] > 0.5 else 0
285
- if level != next_level:
286
- ax.plot(
287
- [time[i + 1], time[i + 1]],
288
- [level, next_level],
289
- "b-",
290
- linewidth=1.5,
291
- )
292
-
293
- elif signal.type == "digital":
294
- # Digital signal: step function with transitions
295
- for i in range(len(time) - 1):
296
- level = 1 if data[i] > 0.5 else 0
297
- ax.plot(
298
- [time[i], time[i + 1]],
299
- [level, level],
300
- "k-",
301
- linewidth=1.5,
302
- )
303
- # Vertical transition with slight slant for visual clarity
304
- if i < len(time) - 1:
305
- next_level = 1 if data[i + 1] > 0.5 else 0
306
- if level != next_level:
307
- transition_width = (time[i + 1] - time[i]) * 0.1
308
- ax.plot(
309
- [time[i + 1] - transition_width, time[i + 1]],
310
- [level, next_level],
311
- "k-",
312
- linewidth=1.5,
313
- )
314
-
315
- elif signal.type == "bus":
316
- # Bus signal: show as high-impedance or data values
317
- ax.fill_between(time, 0.3, 0.7, alpha=0.3, color="gray")
318
- ax.plot(time, np.full_like(time, 0.5), "k-", linewidth=0.5)
319
-
320
- else:
321
- # Analog signal
322
- ax.plot(time, data, "r-", linewidth=1.2)
323
-
324
-
325
- def _plot_classic_signal(
326
- ax: Axes,
327
- time: NDArray[np.float64],
328
- data: NDArray[np.float64],
329
- signal: ProtocolSignal,
330
- ) -> None:
331
- """Plot signal in classic style (traditional oscilloscope-like)."""
332
- ax.plot(time, data, "b-", linewidth=1.2)
333
- ax.axhline(0.5, color="gray", linestyle="--", linewidth=0.5, alpha=0.5)
334
-
335
-
336
- def plot_state_machine(
337
- states: list[str],
338
- transitions: list[StateTransition],
339
- *,
340
- initial_state: str | None = None,
341
- final_states: list[str] | None = None,
342
- layout: Literal["circular", "hierarchical", "force"] = "circular",
343
- figsize: tuple[float, float] = (10, 8),
344
- title: str | None = None,
345
- ) -> Figure:
346
- """Plot state machine diagram.
347
-
348
- Creates a state diagram showing states as nodes and transitions as
349
- directed edges with condition labels.
350
-
351
- Args:
352
- states: List of state names.
353
- transitions: List of StateTransition objects.
354
- initial_state: Initial state (marked with double circle).
355
- final_states: List of final states (marked with double circle).
356
- layout: Layout algorithm for state positioning.
357
- figsize: Figure size (width, height).
358
- title: Plot title.
359
-
360
- Returns:
361
- Matplotlib Figure object.
362
-
363
- Raises:
364
- ImportError: If matplotlib is not available.
365
-
366
- Example:
367
- >>> states = ["IDLE", "ACTIVE", "WAIT", "DONE"]
368
- >>> transitions = [
369
- ... StateTransition("IDLE", "ACTIVE", "START"),
370
- ... StateTransition("ACTIVE", "WAIT", "BUSY"),
371
- ... StateTransition("WAIT", "ACTIVE", "RETRY"),
372
- ... StateTransition("ACTIVE", "DONE", "COMPLETE"),
373
- ... ]
374
- >>> fig = plot_state_machine(
375
- ... states, transitions, initial_state="IDLE", final_states=["DONE"]
376
- ... )
377
-
378
- References:
379
- VIS-022: Specialized - State Machine View
380
- """
381
- if not HAS_MATPLOTLIB:
382
- raise ImportError("matplotlib is required for visualization")
383
-
384
- # Setup figure
385
- fig, ax = plt.subplots(figsize=figsize)
386
- positions = _calculate_state_positions(states, layout)
387
- state_radius = 0.15
388
-
389
- # Draw all states
390
- _draw_all_states(ax, positions, state_radius, initial_state, final_states)
391
-
392
- # Draw all transitions
393
- _draw_all_transitions(ax, transitions, positions, state_radius)
394
-
395
- # Finalize axes
396
- _finalize_state_machine_plot(ax, title)
397
-
398
- fig.tight_layout()
399
- return fig
400
-
401
-
402
- def _draw_all_states(
403
- ax: Axes,
404
- positions: dict[str, tuple[float, float]],
405
- state_radius: float,
406
- initial_state: str | None,
407
- final_states: list[str] | None,
408
- ) -> None:
409
- """Draw all state nodes with appropriate markers."""
410
- for state, (x, y) in positions.items():
411
- # Draw state circle
412
- circle = patches.Circle(
413
- (x, y),
414
- state_radius,
415
- fill=True,
416
- facecolor="lightblue",
417
- edgecolor="black",
418
- linewidth=2.0,
419
- )
420
- ax.add_patch(circle)
421
-
422
- # Mark initial state with double circle
423
- if state == initial_state:
424
- outer_circle = patches.Circle(
425
- (x, y),
426
- state_radius * 1.2,
427
- fill=False,
428
- edgecolor="black",
429
- linewidth=2.0,
430
- )
431
- ax.add_patch(outer_circle)
432
-
433
- # Mark final states with double circle
434
- if final_states and state in final_states:
435
- inner_circle = patches.Circle(
436
- (x, y),
437
- state_radius * 0.8,
438
- fill=False,
439
- edgecolor="black",
440
- linewidth=2.0,
441
- )
442
- ax.add_patch(inner_circle)
443
-
444
- # Add state label
445
- ax.text(
446
- x,
447
- y,
448
- state,
449
- ha="center",
450
- va="center",
451
- fontsize=10,
452
- fontweight="bold",
453
- )
454
-
455
-
456
- def _draw_all_transitions(
457
- ax: Axes,
458
- transitions: list[StateTransition],
459
- positions: dict[str, tuple[float, float]],
460
- state_radius: float,
461
- ) -> None:
462
- """Draw all transition arrows between states."""
463
- for trans in transitions:
464
- if trans.from_state not in positions or trans.to_state not in positions:
465
- continue
466
-
467
- x1, y1 = positions[trans.from_state]
468
- x2, y2 = positions[trans.to_state]
469
-
470
- # Check for self-loop
471
- dx = x2 - x1
472
- dy = y2 - y1
473
- dist = np.sqrt(dx**2 + dy**2)
474
-
475
- if dist < 1e-6:
476
- _draw_self_loop(ax, x1, y1, state_radius, trans.condition)
477
- continue
478
-
479
- # Draw regular transition arrow
480
- _draw_transition_arrow(ax, x1, y1, x2, y2, state_radius, trans)
481
-
482
-
483
- def _draw_transition_arrow(
484
- ax: Axes,
485
- x1: float,
486
- y1: float,
487
- x2: float,
488
- y2: float,
489
- state_radius: float,
490
- trans: StateTransition,
491
- ) -> None:
492
- """Draw single transition arrow with label."""
493
- # Calculate arrow start/end on circle perimeter
494
- dx = x2 - x1
495
- dy = y2 - y1
496
- dist = np.sqrt(dx**2 + dy**2)
497
-
498
- # Normalize
499
- dx_norm = dx / dist
500
- dy_norm = dy / dist
501
-
502
- # Arrow start/end on circle edges
503
- arrow_start_x = x1 + dx_norm * state_radius
504
- arrow_start_y = y1 + dy_norm * state_radius
505
- arrow_end_x = x2 - dx_norm * state_radius
506
- arrow_end_y = y2 - dy_norm * state_radius
507
-
508
- # Line style
509
- linestyle = {
510
- "solid": "-",
511
- "dashed": "--",
512
- "dotted": ":",
513
- }.get(trans.style, "-")
514
-
515
- # Draw arrow
516
- ax.annotate(
517
- "",
518
- xy=(arrow_end_x, arrow_end_y),
519
- xytext=(arrow_start_x, arrow_start_y),
520
- arrowprops={
521
- "arrowstyle": "->",
522
- "lw": 1.5,
523
- "linestyle": linestyle,
524
- "color": "black",
525
- },
526
- )
527
-
528
- # Add transition label
529
- if trans.condition:
530
- mid_x = (x1 + x2) / 2
531
- mid_y = (y1 + y2) / 2
532
- ax.text(
533
- mid_x,
534
- mid_y,
535
- trans.condition,
536
- fontsize=8,
537
- ha="center",
538
- bbox={
539
- "boxstyle": "round,pad=0.3",
540
- "facecolor": "white",
541
- "edgecolor": "gray",
542
- "alpha": 0.9,
543
- },
544
- )
545
-
546
-
547
- def _finalize_state_machine_plot(ax: Axes, title: str | None) -> None:
548
- """Set axis properties and title for state machine plot."""
549
- ax.set_aspect("equal")
550
- ax.axis("off")
551
- ax.set_xlim(-0.2, 1.2)
552
- ax.set_ylim(-0.2, 1.2)
553
-
554
- if title:
555
- ax.set_title(title, fontsize=14, pad=20)
556
-
557
-
558
- def _calculate_state_positions(
559
- states: list[str],
560
- layout: str,
561
- ) -> dict[str, tuple[float, float]]:
562
- """Calculate state positions using layout algorithm."""
563
- n_states = len(states)
564
- positions = {}
565
-
566
- if layout == "circular":
567
- # Arrange states in a circle
568
- angle_step = 2 * np.pi / n_states
569
- for i, state in enumerate(states):
570
- angle = i * angle_step
571
- x = 0.5 + 0.4 * np.cos(angle)
572
- y = 0.5 + 0.4 * np.sin(angle)
573
- positions[state] = (x, y)
574
-
575
- elif layout == "hierarchical":
576
- # Arrange in rows (simplified hierarchical)
577
- states_per_row = int(np.ceil(np.sqrt(n_states)))
578
- for i, state in enumerate(states):
579
- row = i // states_per_row
580
- col = i % states_per_row
581
- x = (col + 0.5) / states_per_row
582
- y = 1.0 - (row + 0.5) / np.ceil(n_states / states_per_row)
583
- positions[state] = (x, y)
584
-
585
- else: # force-directed (simplified)
586
- # Use random positions as a placeholder for true force-directed layout
587
- np.random.seed(42)
588
- for i, state in enumerate(states):
589
- x = 0.2 + 0.6 * np.random.rand()
590
- y = 0.2 + 0.6 * np.random.rand()
591
- positions[state] = (x, y)
592
-
593
- return positions
594
-
595
-
596
- def _draw_self_loop(
597
- ax: Axes,
598
- x: float,
599
- y: float,
600
- radius: float,
601
- label: str,
602
- ) -> None:
603
- """Draw self-loop transition on state."""
604
- # Draw arc above state
605
- arc = patches.Arc(
606
- (x, y + radius),
607
- width=radius * 1.5,
608
- height=radius * 1.5,
609
- angle=0,
610
- theta1=0,
611
- theta2=180,
612
- linewidth=1.5,
613
- edgecolor="black",
614
- fill=False,
615
- )
616
- ax.add_patch(arc)
617
-
618
- # Add arrow head
619
- ax.annotate(
620
- "",
621
- xy=(x - radius * 0.7, y + radius * 0.3),
622
- xytext=(x - radius * 0.5, y + radius * 0.5),
623
- arrowprops={"arrowstyle": "->", "lw": 1.5, "color": "black"},
624
- )
625
-
626
- # Add label
627
- if label:
628
- ax.text(
629
- x,
630
- y + radius * 2.2,
631
- label,
632
- fontsize=8,
633
- ha="center",
634
- bbox={"boxstyle": "round,pad=0.2", "facecolor": "white", "alpha": 0.9},
635
- )
636
-
637
-
638
- __all__ = [
639
- "ProtocolSignal",
640
- "StateTransition",
641
- "plot_protocol_timing",
642
- "plot_state_machine",
643
- ]