import numpy as np
import plotly.colors as pc
import plotly.graph_objects as go
from numpy.typing import NDArray
from plotly.subplots import make_subplots
from .plotting_constants import (
DEFAULT_FONT_CONFIG,
DEFAULT_LAYOUT_CONFIG,
LINE_WIDTH,
TEXT_COLOR_DARK,
FontConfig,
LayoutConfig,
)
[docs]
def plot_two_panel_spectrum(
freqs: NDArray[np.float64],
magnitude_by_series: dict[str, NDArray[np.float64]],
phase_by_series: dict[str, NDArray[np.float64]],
*,
title: str = "Frequency response",
magnitude_label: str = "|H| (dB)",
phase_label: str = "phase (deg)",
freq_label: str = "frequency (Hz)",
log_freq: bool = True,
dashed_series: set[str] | None = None,
font_config: FontConfig = DEFAULT_FONT_CONFIG,
layout_config: LayoutConfig = DEFAULT_LAYOUT_CONFIG,
) -> go.Figure:
"""Generic stacked magnitude-over-phase (Bode-style) plot.
Two vertically stacked panels share a frequency x-axis: magnitude on
top, phase below. Each series is drawn in both panels and linked by a
single legend entry, so toggling a series hides both its magnitude and
phase curves.
Parameters
----------
freqs : NDArray[float64]
Shared frequency axis (Hz).
magnitude_by_series : dict[str, NDArray[float64]]
Magnitude curve per series label. Values are plotted verbatim, so
pass dB (or linear) already converted.
phase_by_series : dict[str, NDArray[float64]]
Phase curve (deg) per series label; keys must match
``magnitude_by_series``.
title : str, optional
Figure title.
magnitude_label : str, optional
Y-axis label for the magnitude panel.
phase_label : str, optional
Y-axis label for the phase panel.
freq_label : str, optional
Shared x-axis label.
log_freq : bool, optional
Use a logarithmic frequency axis. Default is True.
dashed_series : set[str], optional
Series labels to render dashed (e.g. a comparison model). Default is None (all solid).
font_config : FontConfig, optional
layout_config : LayoutConfig, optional
Returns
-------
go.Figure
"""
dashed_series = dashed_series or set()
palette = pc.qualitative.Plotly
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
subplot_titles=(magnitude_label, phase_label),
vertical_spacing=layout_config.grid_vertical_spacing,
)
for i, (label, mag) in enumerate(magnitude_by_series.items()):
color = palette[i % len(palette)]
dash = "dash" if label in dashed_series else "solid"
group = f"series_{label}"
fig.add_trace(
go.Scattergl(
x=freqs,
y=mag,
mode="lines",
name=label,
line=dict(color=color, dash=dash, width=LINE_WIDTH),
legendgroup=group,
),
row=1,
col=1,
)
fig.add_trace(
go.Scattergl(
x=freqs,
y=phase_by_series[label],
mode="lines",
name=label,
line=dict(color=color, dash=dash, width=LINE_WIDTH),
legendgroup=group,
showlegend=False,
),
row=2,
col=1,
)
axis_type = "log" if log_freq else "linear"
fig.update_xaxes(type=axis_type, row=1, col=1)
fig.update_xaxes(type=axis_type, title_text=freq_label, row=2, col=1)
fig.update_yaxes(title_text=magnitude_label, row=1, col=1)
fig.update_yaxes(title_text=phase_label, row=2, col=1)
fig.update_layout(
title={
"text": title,
"font": dict(size=font_config.large, color=TEXT_COLOR_DARK),
"x": layout_config.title_x,
"xanchor": layout_config.title_xanchor,
},
height=layout_config.height,
width=layout_config.width,
margin=layout_config.margin,
plot_bgcolor=layout_config.plot_bgcolor,
legend=dict(font=dict(size=font_config.small)),
)
return fig