"""
Interactive visualization for qiskit-waveform-sim.
Provides LabOne Q-style Pulse Sheet (multi-channel schedule view) and
synchronized I/Q Oscilloscope (sample-precise waveform view) using Plotly.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots
from qiskit_waveform_sim.core import TargetWaveformSimulator
from qiskit_waveform_sim.visualization.matplotlib import (
plot_iq_waveforms,
plot_multi_channel_iq,
plot_phase_tracking,
plot_pulse_sheet,
)
[docs]
@dataclass
class PulseSheetConfig:
"""Configuration for pulse sheet visualization."""
height_per_channel: int = 60
min_channel_height: int = 40
max_channels: int = 20
time_window_ns: tuple[float, float] | None = None
show_phase_markers: bool = True
show_annotations: bool = True
theme: str = "plotly_white"
[docs]
def create_pulse_sheet(
sim: TargetWaveformSimulator,
config: PulseSheetConfig | None = None,
) -> go.Figure:
"""
Create an interactive Plotly Pulse Sheet (multi-channel schedule view).
This is the primary visualization showing the logical timing structure
across all channels, similar to LabOne Q's pulse sheet.
Parameters
----------
sim : TargetWaveformSimulator
Compiled waveform simulator
config : PulseSheetConfig, optional
Visualization configuration
Returns
-------
go.Figure
Interactive Plotly figure with pulse sheet
"""
config = config or PulseSheetConfig()
channels = sim.get_channels()
if not channels:
fig = go.Figure()
fig.add_annotation(
text="No events to display", xref="paper", yref="paper", x=0.5, y=0.5
)
return fig
display_channels = channels[: config.max_channels]
if config.time_window_ns:
t_start_ns, t_stop_ns = config.time_window_ns
t_start_dt = int(t_start_ns / (sim.dt * 1e9))
t_stop_dt = int(t_stop_ns / (sim.dt * 1e9))
else:
t_start_dt = 0
t_stop_dt = sim.total_duration_dt
t_start_ns = t_start_dt * sim.dt * 1e9
t_stop_ns = t_stop_dt * sim.dt * 1e9
n_channels = len(display_channels)
fig = make_subplots(
rows=n_channels,
cols=1,
shared_xaxes=True,
vertical_spacing=0.02,
subplot_titles=[f"<b>{ch}</b>" for ch in display_channels],
row_heights=[1.0 / n_channels] * n_channels,
)
op_colors = {
"drag": "#1f77b4", # Blue for single-qubit drives
"gaussian_square": "#2ca02c", # Green for two-qubit/measure
"virtual_z": "#ff7f0e", # Orange for virtual Z
"measure": "#d62728", # Red for measure
"ecr_echo_x": "#9467bd", # Purple for echo
"default": "#7f7f7f", # Gray
}
for row_idx, channel in enumerate(display_channels, 1):
events = sim.events_by_channel.get(channel, [])
if not events:
continue
visible_events = [
ev
for ev in events
if ev.start_dt + ev.duration_dt > t_start_dt and ev.start_dt < t_stop_dt
]
for ev in visible_events:
ev_start_ns = ev.start_dt * sim.dt * 1e9
ev_stop_ns = (ev.start_dt + ev.duration_dt) * sim.dt * 1e9
ev_start_ns = max(ev_start_ns, t_start_ns)
ev_stop_ns = min(ev_stop_ns, t_stop_ns)
ev_duration_ns = ev_stop_ns - ev_start_ns
if ev_duration_ns <= 0:
continue
color = op_colors.get(ev.shape, op_colors["default"])
fig.add_shape(
type="rect",
x0=ev_start_ns,
x1=ev_stop_ns,
y0=0.1,
y1=0.9,
xref=f"x{row_idx}",
yref=f"y{row_idx}",
fillcolor=color,
opacity=0.7,
line={"color": color, "width": 1},
layer="below",
)
label = ev.op_name
if config.show_annotations and ev.shape != "virtual_z":
label += f" (φ={ev.frame_phase_rad:.2f})"
fig.add_annotation(
x=(ev_start_ns + ev_stop_ns) / 2,
y=0.5,
xref=f"x{row_idx}",
yref=f"y{row_idx}",
text=label,
showarrow=False,
font={"size": 10, "color": "white"},
align="center",
)
if ev.shape == "virtual_z" and config.show_phase_markers:
fig.add_shape(
type="line",
x0=ev_start_ns,
x1=ev_start_ns,
y0=0,
y1=1,
xref=f"x{row_idx}",
yref=f"y{row_idx}",
line={"color": "red", "width": 2, "dash": "dash"},
)
fig.add_annotation(
x=ev_start_ns,
y=1.05,
xref=f"x{row_idx}",
yref=f"y{row_idx}",
text=f"φ={ev.frame_phase_rad:.2f}",
showarrow=False,
font={"size": 9, "color": "red"},
)
fig.update_layout(
title={
"text": "Qiskit Waveform Simulator - Pulse Sheet",
"x": 0.5,
"font": {"size": 16},
},
height=max(n_channels * config.height_per_channel, 400),
showlegend=False,
template=config.theme,
hovermode="x unified",
margin={"l": 100, "r": 50, "t": 80, "b": 50},
)
fig.update_xaxes(
title_text="Time (ns)",
row=n_channels,
col=1,
range=[t_start_ns, t_stop_ns],
)
for i in range(1, n_channels + 1):
fig.update_yaxes(visible=False, row=i, col=1)
return fig
[docs]
def create_iq_oscilloscope(
sim: TargetWaveformSimulator,
channel: str,
start_dt: int = 0,
length_dt: int | None = None,
show_phase: bool = True,
) -> go.Figure:
"""
Create an interactive I/Q Oscilloscope for a specific channel and time window.
This provides sample-precise waveform visualization with phase tracking,
similar to LabOne Q's Signal tab when clicking a pulse.
Parameters
----------
sim : TargetWaveformSimulator
Compiled waveform simulator
channel : str
Channel to display (e.g., "d0", "u(0,1)", "m0")
start_dt : int
Start time in dt units
length_dt : int, optional
Window length in dt units
show_phase : bool
Whether to show phase trace
Returns
-------
go.Figure
Interactive Plotly figure with I/Q waveforms
"""
snippet = sim.get_snippet(channel, start_dt, length_dt)
if length_dt is None:
length_dt = len(snippet.wave)
time_ns = snippet.time_ns
wave = snippet.wave
phase_rad = snippet.phase_rad
rows = 3 if show_phase else 2
row_heights = [0.4, 0.4, 0.2] if show_phase else [0.5, 0.5]
subplot_titles = (
("I (In-Phase)", "Q (Quadrature)", "Frame Phase (rad)")
if show_phase
else ("I (In-Phase)", "Q (Quadrature)")
)
fig = make_subplots(
rows=rows,
cols=1,
shared_xaxes=True,
vertical_spacing=0.05,
subplot_titles=subplot_titles,
row_heights=row_heights,
)
# I component
fig.add_trace(
go.Scatter(
x=time_ns,
y=wave.real,
mode="lines",
name="I",
line={"color": "#1f77b4", "width": 1},
hovertemplate="Time: %{x:.2f} ns<br>I: %{y:.4f}<extra></extra>",
),
row=1,
col=1,
)
# Q component
fig.add_trace(
go.Scatter(
x=time_ns,
y=wave.imag,
mode="lines",
name="Q",
line={"color": "#ff7f0e", "width": 1},
hovertemplate="Time: %{x:.2f} ns<br>Q: %{y:.4f}<extra></extra>",
),
row=2,
col=1,
)
envelope = np.abs(wave)
fig.add_trace(
go.Scatter(
x=time_ns,
y=envelope,
mode="lines",
name="|IQ|",
line={"color": "gray", "width": 1, "dash": "dot"},
hovertemplate="Time: %{x:.2f} ns<br>|IQ|: %{y:.4f}<extra></extra>",
showlegend=False,
),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(
x=time_ns,
y=-envelope,
mode="lines",
name="|IQ|",
line={"color": "gray", "width": 1, "dash": "dot"},
hovertemplate="Time: %{x:.2f} ns<br>|IQ|: %{y:.4f}<extra></extra>",
showlegend=False,
),
row=1,
col=1,
)
if show_phase:
fig.add_trace(
go.Scatter(
x=time_ns,
y=phase_rad,
mode="lines",
name="Phase",
line={"color": "#d62728", "width": 1},
hovertemplate="Time: %{x:.2f} ns<br>Phase: %{y:.4f} rad<extra></extra>",
),
row=3,
col=1,
)
for ev in snippet.events:
if ev.duration_dt > 0:
ev_start = ev.start_dt * sim.dt * 1e9
ev_stop = (ev.start_dt + ev.duration_dt) * sim.dt * 1e9
for row in range(1, rows + 1):
fig.add_vrect(
x0=ev_start,
x1=ev_stop,
fillcolor="rgba(0,100,80,0.1)",
line_width=0,
layer="below",
row=row,
col=1,
)
fig.add_annotation(
x=(ev_start + ev_stop) / 2,
y=1.02,
xref="x",
yref="paper",
text=f"{ev.op_name}",
showarrow=False,
font={"size": 10, "color": "#2ca02c"},
bgcolor="rgba(255,255,255,0.8)",
bordercolor="#2ca02c",
borderwidth=1,
)
fig.update_layout(
title={
"text": f"I/Q Oscilloscope - Channel: {channel}",
"x": 0.5,
"font": {"size": 14},
},
height=600 if show_phase else 450,
showlegend=True,
template="plotly_white",
hovermode="x unified",
legend={
"orientation": "h",
"yanchor": "bottom",
"y": 1.02,
"xanchor": "right",
"x": 1,
},
margin={"l": 80, "r": 50, "t": 80, "b": 50},
)
fig.update_xaxes(title_text="Time (ns)", row=rows, col=1)
fig.update_yaxes(title_text="Amplitude", row=1, col=1)
fig.update_yaxes(title_text="Amplitude", row=2, col=1)
if show_phase:
fig.update_yaxes(title_text="Phase (rad)", row=3, col=1)
return fig
[docs]
def show_pulse_sheet(
sim: TargetWaveformSimulator,
interactive: bool = True,
config: PulseSheetConfig | None = None,
) -> go.Figure | None:
"""
Display the interactive pulse sheet in browser/notebook.
Parameters
----------
sim : TargetWaveformSimulator
Compiled waveform simulator
interactive : bool
If True, opens in browser; if False, returns figure for notebook display
config : PulseSheetConfig, optional
Visualization configuration
Returns
-------
go.Figure or None
Returns the figure when interactive=False, otherwise None
"""
fig = create_pulse_sheet(sim, config)
if interactive:
fig.show()
return None
else:
return fig
[docs]
def show_iq_oscilloscope(
sim: TargetWaveformSimulator,
channel: str,
start_dt: int = 0,
length_dt: int | None = None,
interactive: bool = True,
) -> go.Figure | None:
"""
Display the I/Q oscilloscope for a channel.
Parameters
----------
sim : TargetWaveformSimulator
Compiled waveform simulator
channel : str
Channel to display
start_dt : int
Start time in dt
length_dt : int, optional
Window length in dt
interactive : bool
If True, opens in browser; if False, returns figure
Returns
-------
go.Figure or None
Returns the figure when interactive=False, otherwise None
"""
fig = create_iq_oscilloscope(sim, channel, start_dt, length_dt)
if interactive:
fig.show()
return None
else:
return fig
[docs]
def create_combined_view(
sim: TargetWaveformSimulator,
channel: str,
start_dt: int = 0,
length_dt: int | None = None,
config: PulseSheetConfig | None = None,
) -> go.Figure:
"""
Create a combined view with Pulse Sheet (top) and I/Q Oscilloscope (bottom).
Synchronized x-axis for coordinated exploration.
Parameters
----------
sim : TargetWaveformSimulator
Compiled waveform simulator
channel : str
Channel for I/Q detail view
start_dt : int
Start time in dt
length_dt : int, optional
Window length in dt
config : PulseSheetConfig, optional
Configuration for pulse sheet
Returns
-------
go.Figure
Combined interactive figure
"""
config = config or PulseSheetConfig()
snippet = sim.get_snippet(channel, start_dt, length_dt)
if length_dt is None:
length_dt = len(snippet.wave)
time_ns = snippet.time_ns
t_start_ns = time_ns[0]
t_stop_ns = time_ns[-1]
config.time_window_ns = (t_start_ns, t_stop_ns)
# Simpler: create fresh combined figure
channels = sim.get_channels()
display_channels = channels[: config.max_channels]
n_channels = len(display_channels)
total_rows = n_channels + 3 # pulse sheet rows + I + Q + Phase
fig = make_subplots(
rows=total_rows,
cols=1,
shared_xaxes=True,
vertical_spacing=0.015,
subplot_titles=[f"<b>{ch}</b>" for ch in display_channels]
+ ["I (In-Phase)", "Q (Quadrature)", "Frame Phase (rad)"],
row_heights=[0.6 / total_rows] * n_channels + [0.4 / total_rows] * 3,
)
op_colors = {
"drag": "#1f77b4",
"gaussian_square": "#2ca02c",
"virtual_z": "#ff7f0e",
"measure": "#d62728",
"ecr_echo_x": "#9467bd",
"default": "#7f7f7f",
}
for row_idx, ch in enumerate(display_channels, 1):
events = sim.events_by_channel.get(ch, [])
for ev in events:
ev_start_ns = ev.start_dt * sim.dt * 1e9
ev_stop_ns = (ev.start_dt + ev.duration_dt) * sim.dt * 1e9
if ev_stop_ns < t_start_ns or ev_start_ns > t_stop_ns:
continue
ev_start_ns = max(ev_start_ns, t_start_ns)
ev_stop_ns = min(ev_stop_ns, t_stop_ns)
if ev_stop_ns <= ev_start_ns:
continue
color = op_colors.get(ev.shape, op_colors["default"])
fig.add_shape(
type="rect",
x0=ev_start_ns,
x1=ev_stop_ns,
y0=0.1,
y1=0.9,
xref=f"x{row_idx}",
yref=f"y{row_idx}",
fillcolor=color,
opacity=0.7,
line={"color": color, "width": 1},
layer="below",
)
iq_row = n_channels + 1
fig.add_trace(
go.Scatter(
x=time_ns,
y=snippet.wave.real,
mode="lines",
name="I",
line={"color": "#1f77b4", "width": 1},
hovertemplate="Time: %{x:.2f} ns<br>I: %{y:.4f}<extra></extra>",
),
row=iq_row,
col=1,
)
fig.add_trace(
go.Scatter(
x=time_ns,
y=snippet.wave.imag,
mode="lines",
name="Q",
line={"color": "#ff7f0e", "width": 1},
hovertemplate="Time: %{x:.2f} ns<br>Q: %{y:.4f}<extra></extra>",
),
row=iq_row + 1,
col=1,
)
fig.add_trace(
go.Scatter(
x=time_ns,
y=snippet.phase_rad,
mode="lines",
name="Phase",
line={"color": "#d62728", "width": 1},
hovertemplate="Time: %{x:.2f} ns<br>Phase: %{y:.4f} rad<extra></extra>",
),
row=iq_row + 2,
col=1,
)
fig.update_layout(
title={
"text": f"Combined View: Pulse Sheet + I/Q Oscilloscope ({channel})",
"x": 0.5,
},
height=max(total_rows * 50, 800),
showlegend=True,
template="plotly_white",
hovermode="x unified",
margin={"l": 100, "r": 50, "t": 80, "b": 50},
)
fig.update_xaxes(
title_text="Time (ns)", row=total_rows, col=1, range=[t_start_ns, t_stop_ns]
)
for i in range(1, n_channels + 1):
fig.update_yaxes(visible=False, row=i, col=1)
return fig
[docs]
def export_html(fig: go.Figure, filename: str) -> None:
"""Export figure to standalone HTML file."""
pio.write_html(fig, filename, include_plotlyjs="cdn", full_html=True)
[docs]
def export_static(fig: go.Figure, filename: str, format: str = "png") -> None:
"""Export figure to static image (requires kaleido)."""
fig.write_image(filename, format=format)
# Matplotlib static visualization
__all__ = [
"PulseSheetConfig",
"create_pulse_sheet",
"create_iq_oscilloscope",
"show_pulse_sheet",
"show_iq_oscilloscope",
"create_combined_view",
"export_html",
"export_static",
"plot_pulse_sheet",
"plot_iq_waveforms",
"plot_multi_channel_iq",
"plot_phase_tracking",
]