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