Q2NSViz 0.1.0
Q2NS trace visualizer
Loading...
Searching...
No Matches
charts.py
Go to the documentation of this file.
1# -----------------------------------------------------------------------------
2# Q2NSViz - Quantum Network Trace Visualizer
3# Copyright (c) 2026 QuantumInternet.it
4#
5# This program is released under the MIT License - see LICENSE for details.
6# -----------------------------------------------------------------------------
7
8import bisect
9import logging
10
11from .theme import is_dark
12
13try:
14 import matplotlib
15
16 matplotlib.use("QtAgg")
17 from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
18 from matplotlib.figure import Figure
19 from matplotlib.ticker import FixedLocator
20
21 from ..logic import SimulationStateManager as _SimulationStateManager
22
23 MATPLOTLIB_AVAILABLE = True
24except ImportError:
25 MATPLOTLIB_AVAILABLE = False
26 logging.getLogger(__name__).warning("matplotlib not available -- chart tab will be disabled")
27
28
29if MATPLOTLIB_AVAILABLE:
30
31 def _expand_series(
32 times: list[int], values: list[float], operation_windows: set[tuple[int, int]]
33 ) -> tuple[list[int], list[float]]:
34 """Convert keyframe arrays into a step-function suitable for plotting.
35
36 Interior points that fall inside an operation window are left as-is so
37 that the linear ramp inserted by ``_rebuild_series`` is preserved.
38 All other value changes produce a duplicate x-point at the next
39 timestamp (step function).
40
41 @param times Keyframe timestamps in nanoseconds.
42 @param values Series value at each keyframe.
43 @param operation_windows Set of ``(t_start_ns, t_end_ns)`` operation duration
44 windows (entangle / measure / graphMeasure).
45 @returns Tuple of ``(xs, ys)`` lists ready for plotting.
46 """
47 xs: list[int] = []
48 ys: list[float] = []
49 for i in range(len(times)):
50 xs.append(times[i])
51 ys.append(values[i])
52 if i < len(times) - 1:
53 t_c, t_n = times[i], times[i + 1]
54 v_c, v_n = values[i], values[i + 1]
55 in_window = any(t0 <= t_c and t_n <= t1 for t0, t1 in operation_windows)
56 if not in_window and v_c != v_n:
57 xs.append(t_n)
58 ys.append(v_c)
59 return xs, ys
60
61 def _live_qubit_series(events: list[dict], times: list[int]) -> list[float]:
62 """Per-keyframe live-qubit counts from a single pass over the events.
63
64 A qubit is live from its first ``createQubit`` to its first
65 ``measure`` / ``graphMeasure`` / ``removeQubit`` event, matching
66 ``Snapshot.live_qubit_labels`` without an O(all qubits) registry scan
67 per keyframe.
68
69 @param events Full trace event list.
70 @param times Sorted keyframe timestamps to evaluate.
71 @returns Live-qubit count at each timestamp in *times*.
72 """
73 birth: dict[str, int] = {}
74 death: dict[str, int] = {}
75 for event in events:
76 event_type = event.get("type")
77 if event_type == "createQubit":
78 label = event.get("label")
79 if label:
80 t = event.get("t_ns", 0)
81 if label not in birth or t < birth[label]:
82 birth[label] = t
83 elif event_type in {"measure", "graphMeasure", "removeQubit"}:
84 label = event.get("bit")
85 if label:
86 # Commit-anchored stamps: the transition lands at t_ns.
87 t = event.get("t_ns", 0)
88 if label not in death or t < death[label]:
89 death[label] = t
90 births = sorted(birth.values())
91 # A death before the qubit's creation takes effect at creation time.
92 deaths = sorted(max(t, birth[label]) for label, t in death.items() if label in birth)
93 return [float(bisect.bisect_right(births, t) - bisect.bisect_right(deaths, t)) for t in times]
94
95 def _make_ticks(lo, hi, n_inner=2, integer=False):
96 """Return a tick list that always contains lo and hi with ~n_inner interior ticks."""
97 if lo == hi:
98 return [lo]
99 if integer:
100 lo, hi = int(lo), int(hi)
101 if hi - lo <= n_inner + 1:
102 return list(range(lo, hi + 1))
103 inner = sorted({round(lo + (hi - lo) * i / (n_inner + 1)) for i in range(1, n_inner + 1)} - {lo, hi})
104 return [lo, *inner, hi]
105 inner = [lo + (hi - lo) * i / (n_inner + 1) for i in range(1, n_inner + 1)]
106 return [lo, *inner, hi]
107
108 class ChartCanvas(FigureCanvasQTAgg):
109 """Matplotlib canvas for time-series and summary charts.
110
111 @ingroup q2nsviz_charts
112 """
113
114 def __init__(self):
115 self.fig = Figure(figsize=(5.2, 10), facecolor="white")
116 super().__init__(self.fig)
117 self.setMinimumHeight(480)
118 # Cached time series --- rebuilt only when a new file is loaded.
119 # Cache key is the *identity* of state_manager.time_array (not
120 # equality), so any new load() replaces the list object and
121 # triggers a rebuild automatically.
122 self._cached_time_array: list[int] | None = None
123 self._cached_live: list[float] = []
124 self._cached_entangled: list[float] = []
125 self._cached_measurements: list[float] = []
126 self._cached_operation_windows: set[tuple[int, int]] = set()
127
128 def clear(self):
129 """Clear the figure and redraw an empty canvas."""
130 self.fig.clear()
131 self.draw()
132
133 def _rebuild_series(self, state_manager) -> None:
134 """Pre-compute time-series arrays for all keyframes.
135
136 Uses an isolated ``SimulationStateManager`` instance so that the
137 shared state manager is never mutated during series computation.
138 The shared instance is accessed read-only for its event list;
139 the timeline sweep positions the private copy via ``seek()`` --
140 the same reconstruction path as ``snapshot_at()``.
141
142 @param state_manager Shared ``SimulationStateManager`` from the window.
143 Accessed read-only within this method.
144 """
145 _sm = _SimulationStateManager()
146 _sm.load_events(state_manager.events)
147
148 measurement_times = sorted(
149 event.get("t_ns", 0)
150 for event in _sm.events
151 if event.get("type") in {"measure", "graphMeasure"} and event.get("t_ns") is not None
152 )
153 # Live-qubit counts come from a single event pass; only the
154 # entanglement series needs the per-keyframe state sweep.
155 live_qubits = _live_qubit_series(_sm.events, _sm.time_array)
156 entangled_states: list[float] = []
157 total_measurements: list[float] = []
158 measurement_index = 0
159 measurement_total = 0
160
161 for t in _sm.time_array:
162 # seek() shares snapshot_at()'s positioning path but skips the
163 # per-step Snapshot packaging, keeping the sweep O(actions).
164 _sm.seek(t)
165 while measurement_index < len(measurement_times) and measurement_times[measurement_index] <= t:
166 measurement_total += 1
167 measurement_index += 1
168
169 entangled_states.append(float(len(_sm.get_entangled_states())))
170 total_measurements.append(float(measurement_total))
171
172 # Operation-window linear interpolation: smooth the step changes across
173 # operation durations so the series does not show a hard jump mid-operation.
174 time_to_idx = {t: i for i, t in enumerate(_sm.time_array)}
175 operation_windows: set[tuple[int, int]] = set()
176 for ev in _sm.events:
177 if ev.get("type") not in {"entangle", "measure", "graphMeasure"}:
178 continue
179 dur = ev.get("duration_ns", 0)
180 t_end = ev.get("t_ns")
181 if not dur or t_end is None:
182 continue
183 t_start = max(0, t_end - dur)
184 operation_windows.add((t_start, t_end))
185 i0 = time_to_idx.get(t_start)
186 i1 = time_to_idx.get(t_end)
187 if i0 is None or i1 is None or i0 >= i1:
188 continue
189 live_start, live_end = live_qubits[i0], live_qubits[i1]
190 entangled_start, entangled_end = entangled_states[i0], entangled_states[i1]
191 meas_start, meas_end = total_measurements[i0], total_measurements[i1]
192 for i in range(i0 + 1, i1):
193 frac = (_sm.time_array[i] - t_start) / (t_end - t_start)
194 live_qubits[i] = live_start + frac * (live_end - live_start)
195 entangled_states[i] = entangled_start + frac * (entangled_end - entangled_start)
196 total_measurements[i] = meas_start + frac * (meas_end - meas_start)
197
198 self._cached_live = live_qubits
199 self._cached_entangled = entangled_states
200 self._cached_measurements = total_measurements
201 self._cached_operation_windows = operation_windows
202 self._cached_time_array = state_manager.time_array
203
204 def update_charts(self, state_manager, snap):
205 """Render all four chart panels for the given simulation time.
206
207 Chart layout (top to bottom)
208 ----------------------------
209 1. Live qubit count over time (step function).
210 2. Entangled states over time (step function).
211 3. Total measurements over time (step function).
212 4. Per-node qubit distribution at ``snap.t_ns`` (bar chart).
213
214 Cached series are rebuilt only when a new trace is loaded, detected
215 by an identity change on ``state_manager.time_array``. The shared
216 state manager is never queried here: the controller performs the
217 per-frame ``snapshot_at()`` call and passes the resulting
218 ``Snapshot``, which drives both the bar chart and the current-time
219 marker.
220
221 @param state_manager Shared ``SimulationStateManager`` instance,
222 read only for its event list and timeline.
223 @param snap ``Snapshot`` at the current simulation time.
224 """
225 current_time = snap.t_ns
226 self.fig.clear()
227 _dark = is_dark()
228 _fig_bg = "#161b22" if _dark else "#ffffff"
229 _ax_bg = "#1c2128" if _dark else "#ffffff"
230 _spine_c = "#8d96a0" if _dark else "#24292f"
231 _grid_c = "#30363d" if _dark else "#f0f2f5"
232 _text_c = "#e6edf3" if _dark else "#24292f"
233 _marker_c = "#8d96a0" if _dark else "#555555"
234 self.fig.set_facecolor(_fig_bg)
235 if not state_manager.time_array:
236 return
237 if self._cached_time_array is not state_manager.time_array:
238 self._rebuild_series(state_manager)
239
240 live_qubits = self._cached_live
241 entangled_states = self._cached_entangled
242 total_measurements = self._cached_measurements
243 operation_windows = self._cached_operation_windows
244
245 current_node_qubits: dict[str, int] = {
246 node_name: sum(
247 1
248 for q in snap.qubits.values()
249 if q.node == node_name
250 and q.label in snap.live_qubit_labels
251 and q.label not in snap.inflight_qubits
252 )
253 for node_name in snap.nodes
254 }
255 current_inflight_count: int = len(snap.inflight_qubits)
256 current_loss_count: int = len(snap.lost_qubits)
257 rc_params = {
258 "font.size": 9.5,
259 "axes.linewidth": 0.95,
260 "xtick.major.width": 0.95,
261 "ytick.major.width": 0.95,
262 "xtick.major.size": 0,
263 "ytick.major.size": 0,
264 }
265 with matplotlib.rc_context(rc_params):
266 ax1 = self.fig.add_subplot(4, 1, 1)
267 ax2 = self.fig.add_subplot(4, 1, 2)
268 ax3 = self.fig.add_subplot(4, 1, 3)
269 ax4 = self.fig.add_subplot(4, 1, 4)
270 times_us = [t / 1000 for t in state_manager.time_array]
271 current_us = current_time / 1000
272
273 colors = ["#0072B2", "#E69F00", "#009E73", "#D55E00", "#CC79A7", "#56B4E8"]
274 line_width = 1.6
275 spine_color = _spine_c
276 grid_color = _grid_c
277 text_color = _text_c
278 current_line = {"color": _marker_c, "linestyle": "--", "linewidth": 1.2, "alpha": 0.6}
279
280 def style_ax(ax, show_xlabels=True):
281 for spine in ax.spines.values():
282 spine.set_visible(True)
283 spine.set_color(spine_color)
284 spine.set_linewidth(0.95)
285 ax.tick_params(
286 axis="x",
287 which="major",
288 bottom=True,
289 labelbottom=show_xlabels,
290 length=0,
291 width=0,
292 color=spine_color,
293 labelcolor=text_color,
294 pad=4,
295 )
296 ax.tick_params(
297 axis="y", which="major", length=0, width=0, color=spine_color, labelcolor=text_color, pad=4
298 )
299 ax.yaxis.grid(True, linestyle="-", linewidth=0.6, alpha=1.0, color=grid_color)
300 ax.xaxis.grid(False)
301 ax.set_axisbelow(True)
302 ax.set_facecolor(_ax_bg)
303 ax.margins(x=0.01)
304 ax.title.set_color(text_color)
305 ax.xaxis.label.set_color(text_color)
306 ax.yaxis.label.set_color(text_color)
307
308 _lx, _ly = _expand_series(state_manager.time_array, live_qubits, operation_windows)
309 ax1.plot([t / 1000 for t in _lx], _ly, color=colors[0], linewidth=line_width)
310 ax1.axvline(current_us, **current_line)
311 ax1.set_ylabel("Num. Qubits", fontsize=8.5)
312 ax1.set_title(
313 f"Live Qubits \u2014 t\u2009=\u2009{current_us:.3f}\u202f\u03bcs",
314 fontsize=9.5,
315 fontweight="normal",
316 pad=5,
317 )
318 ax1.yaxis.set_major_locator(
319 FixedLocator(_make_ticks(min(live_qubits), max(live_qubits), integer=True))
320 )
321 ax1.xaxis.set_major_locator(FixedLocator(_make_ticks(times_us[0], times_us[-1], n_inner=0)))
322 style_ax(ax1, show_xlabels=True)
323
324 _ex, _ey = _expand_series(state_manager.time_array, entangled_states, operation_windows)
325 ax2.plot([t / 1000 for t in _ex], _ey, color=colors[1], linewidth=line_width)
326 ax2.axvline(current_us, **current_line)
327 ax2.set_ylabel("Num. States", fontsize=8.5)
328 ax2.set_title(
329 f"Entangled States \u2014 t\u2009=\u2009{current_us:.3f}\u202f\u03bcs",
330 fontsize=9.5,
331 fontweight="normal",
332 pad=5,
333 )
334 ax2.yaxis.set_major_locator(
335 FixedLocator(_make_ticks(min(entangled_states), max(entangled_states), integer=True))
336 )
337 ax2.xaxis.set_major_locator(FixedLocator(_make_ticks(times_us[0], times_us[-1], n_inner=0)))
338 style_ax(ax2, show_xlabels=True)
339
340 _mx, _my = _expand_series(state_manager.time_array, total_measurements, operation_windows)
341 ax3.plot([t / 1000 for t in _mx], _my, color=colors[2], linewidth=line_width)
342 ax3.axvline(current_us, **current_line)
343 ax3.set_ylabel("Num. Measurements", fontsize=8.5)
344 ax3.set_xlabel("Time (\u03bcs)", fontsize=8.5)
345 ax3.set_title(
346 f"Total Measurements \u2014 t\u2009=\u2009{current_us:.3f}\u202f\u03bcs",
347 fontsize=9.5,
348 fontweight="normal",
349 pad=5,
350 )
351 ax3.yaxis.set_major_locator(
352 FixedLocator(_make_ticks(min(total_measurements), max(total_measurements), integer=True))
353 )
354 ax3.xaxis.set_major_locator(FixedLocator(_make_ticks(times_us[0], times_us[-1], n_inner=0)))
355 style_ax(ax3)
356
357 node_names = list(state_manager.nodes.keys())
358 bar_counts = [current_node_qubits.get(name, 0) for name in node_names]
359 bar_colors = [colors[i % len(colors)] for i in range(len(node_names))]
360 all_bar_names = [*node_names, "In-Flight", "Lost"]
361 all_bar_counts = [*bar_counts, current_inflight_count, current_loss_count]
362 all_bar_colors = [*bar_colors, "#999999", "#D55E00"]
363 ax4.bar(
364 all_bar_names, all_bar_counts, color=all_bar_colors, width=0.52, edgecolor="none", linewidth=0
365 )
366 ax4.set_ylabel("Num. Qubits", fontsize=8.5)
367 ax4.set_xlabel("Node", fontsize=8.5)
368 ax4.set_title(
369 f"Live Qubits per Node \u2014 t\u2009=\u2009{current_us:.3f}\u202f\u03bcs",
370 fontsize=9.5,
371 fontweight="normal",
372 pad=5,
373 )
374 ax4.set_ylim(bottom=0)
375 max_bar = max(all_bar_counts) if all_bar_counts else 0
376 ax4.yaxis.set_major_locator(FixedLocator(_make_ticks(0, max_bar, integer=True)))
377 style_ax(ax4, show_xlabels=True)
378 if len(all_bar_names) > 5:
379 ax4.tick_params(axis="x", rotation=30)
380
381 self.fig.set_layout_engine("constrained")
382 self.draw()
383
384
385else:
386 ChartCanvas = None
387
388
389__all__ = ["MATPLOTLIB_AVAILABLE", "ChartCanvas"]
Matplotlib canvas for time-series and summary charts.
Definition charts.py:108
clear(self)
Clear the figure and redraw an empty canvas.
Definition charts.py:128
update_charts(self, state_manager, snap)
Render all four chart panels for the given simulation time.
Definition charts.py:204