"""Plot phase error analysis results (visualization layer).
Simplified design:
- Always plots binned bar chart with AM/PM fitted curves
- Displays R² for model validation
- No mode selection needed
"""
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpecFromSubplotSpec
def _format_value_with_unit(value_v: float) -> str:
"""Format voltage value with appropriate SI unit prefix."""
abs_val = abs(value_v)
if abs_val < 1e-12: # Treat as zero
return "0.00 uV"
elif abs_val >= 1:
return f"{value_v:.2f} V"
elif abs_val >= 1e-3:
return f"{value_v * 1e3:.2f} mV"
elif abs_val >= 1e-6:
return f"{value_v * 1e6:.2f} uV"
elif abs_val >= 1e-9:
return f"{value_v * 1e9:.2f} nV"
else:
return f"{value_v * 1e12:.2f} pV"
[文档]
def plot_rearranged_error_by_phase(results: dict, axes=None, ax=None, title: str | None = None):
"""Plot phase error analysis results.
Parameters
----------
results : dict
Dictionary from rearrange_error_by_phase().
axes : tuple, optional
Tuple of (ax1, ax2) for top and bottom panels.
ax : matplotlib.axes.Axes, optional
Single axis to split into 2 panels.
title : str, optional
Test setup description for title.
"""
# Extract data
error = results.get('error', np.array([]))
phase = results.get('phase', np.array([]))
fitted_signal = results.get('fitted_signal', np.array([]))
bin_error_rms_v = results.get('bin_error_rms_v', np.array([]))
bin_error_mean_v = results.get('bin_error_mean_v', np.array([]))
phase_bin_centers_rad = results.get('phase_bin_centers_rad', np.array([]))
am_noise_rms_v = results.get('am_noise_rms_v', 0.0)
pm_noise_rms_v = results.get('pm_noise_rms_v', 0.0)
pm_noise_rms_rad = results.get('pm_noise_rms_rad', 0.0)
base_noise_rms_v = results.get('base_noise_rms_v', 0.0)
r_squared_binned = results.get('r_squared_binned', 0.0) # Model confidence
include_base_noise = results.get('_include_base_noise', True)
# Use REDISTRIBUTED coefficients for curve plotting (after overlap absorption)
# This ensures curves match the legend values (physically interpreted)
coeffs_plot = results.get('_coeffs_plot', [0.0, 0.0, 0.0])
am_var_plot = coeffs_plot[0]
pm_var_plot = coeffs_plot[1]
base_noise_var_plot = coeffs_plot[2] if len(coeffs_plot) > 2 else 0.0
# Convert to degrees
phase_bins_deg = phase_bin_centers_rad * 180 / np.pi
phase_deg = np.mod(phase * 180 / np.pi, 360)
# --- Axes Management ---
if axes is not None:
ax1, ax2 = axes if isinstance(axes, (tuple, list)) else axes.flatten()
else:
if ax is None:
ax = plt.gca()
fig = ax.get_figure()
if hasattr(ax, 'get_subplotspec') and ax.get_subplotspec():
gs = GridSpecFromSubplotSpec(2, 1, subplot_spec=ax.get_subplotspec(), hspace=0.35)
ax.remove()
ax1 = fig.add_subplot(gs[0])
ax2 = fig.add_subplot(gs[1])
else:
pos = ax.get_position()
ax.remove()
ax1 = fig.add_axes([pos.x0, pos.y0 + pos.height/2, pos.width, pos.height/2])
ax2 = fig.add_axes([pos.x0, pos.y0, pos.width, pos.height/2])
# --- Top Panel: Signal and Error vs Phase ---
ax1_left = ax1
ax1_right = ax1.twinx()
lines_ax1 = []
labels_ax1 = []
if len(phase_deg) > 0 and len(fitted_signal) > 0:
sort_idx = np.argsort(phase_deg)
line_data, = ax1_left.plot(phase_deg[sort_idx], fitted_signal[sort_idx], 'k-', linewidth=2)
lines_ax1.append(line_data)
labels_ax1.append('Data')
ax1_left.set_xlim([0, 360])
s_min, s_max = np.min(fitted_signal), np.max(fitted_signal)
margin = (s_max - s_min) * 0.10
ax1_left.set_ylim([s_min - margin, s_max + margin])
ax1_left.set_ylabel('Data', color='k')
ax1_left.tick_params(axis='y', labelcolor='k')
if len(phase_deg) > 0 and len(error) > 0:
line_err, = ax1_right.plot(phase_deg, error, 'r.', markersize=2, alpha=0.5)
lines_ax1.append(line_err)
labels_ax1.append('Error')
if len(bin_error_mean_v) > 0:
line_mean, = ax1_right.plot(phase_bins_deg, bin_error_mean_v, 'b-', linewidth=2)
lines_ax1.append(line_mean)
labels_ax1.append('Error Mean')
ax1_right.set_xlim([0, 360])
# Smart Y-limits with 10% margin
y_min, y_max = np.min(error), np.max(error)
y_range = y_max - y_min
margin = y_range * 0.1 if y_range != 0 else 1.0
ax1_right.set_ylim([y_min - margin, y_max + margin])
ax1_right.set_ylabel('Error', color='r')
ax1_right.tick_params(axis='y', labelcolor='r')
ax1.set_xlabel('Phase (deg)')
if title:
ax1.set_title(title)
else:
ax1.set_title('Signal and Error vs Phase')
ax1.grid(True, alpha=0.3)
if lines_ax1:
ax1.legend(lines_ax1, labels_ax1, loc='upper left', fontsize=8)
# --- Bottom Panel: RMS Error Bar Chart with Fitted Curves ---
if len(bin_error_rms_v) > 0 and len(phase_bin_centers_rad) > 0:
bin_width = 360 / len(phase_bin_centers_rad)
# Bar plot
ax2.bar(phase_bins_deg, bin_error_rms_v, width=bin_width*0.8,
color='skyblue', alpha=0.8, edgecolor='darkblue', linewidth=0.5)
# Fitted curves: use REDISTRIBUTED coefficients (after overlap absorption)
# Cosine basis: AM sensitivity = cos², PM sensitivity = sin²
am_sen = np.cos(phase_bin_centers_rad) ** 2
pm_sen = np.sin(phase_bin_centers_rad) ** 2
# Curves use redistributed coefficients (physically interpreted values)
am_curve = np.sqrt(am_var_plot * am_sen + base_noise_var_plot)
pm_curve = np.sqrt(pm_var_plot * pm_sen + base_noise_var_plot)
total_curve = np.sqrt(am_var_plot * am_sen + pm_var_plot * pm_sen + base_noise_var_plot)
# Legend labels show REDISTRIBUTED physical values (after overlap adjustment)
am_str = _format_value_with_unit(am_noise_rms_v)
pm_str = _format_value_with_unit(pm_noise_rms_v)
pm_rad_str = '0.00 urad' if pm_noise_rms_rad < 1e-12 else f'{pm_noise_rms_rad * 1e6:.2f} urad'
base_noise_str = _format_value_with_unit(base_noise_rms_v)
total_rms = results.get('total_rms_v', 0.0)
total_str = _format_value_with_unit(total_rms)
ax2.plot(phase_bins_deg, am_curve, 'b-', linewidth=2, label=f'AM = {am_str}')
ax2.plot(phase_bins_deg, pm_curve, 'r-', linewidth=2, label=f'PM = {pm_str} ({pm_rad_str})')
if include_base_noise:
base_noise_curve = np.full_like(phase_bins_deg, np.sqrt(base_noise_var_plot))
ax2.plot(phase_bins_deg, base_noise_curve, 'g-', linewidth=1.5, label=f'Base Noise = {base_noise_str}')
ax2.plot(phase_bins_deg, total_curve, 'k--', linewidth=2, label=f'Total = {total_str}')
ax2.set_xlim([0, 360])
max_rms = np.nanmax(bin_error_rms_v)
ax2.set_ylim([0, max_rms * 1.5])
ax2.set_ylabel('RMS Error')
ax2.set_title('RMS Error vs Phase')
ax2.legend(loc='upper left', fontsize=9)
# Add confidence text box in right upper corner
text_str = f'R²={r_squared_binned:.3f}'
ax2.text(0.98, 0.98, text_str, transform=ax2.transAxes,
fontsize=10, verticalalignment='top', horizontalalignment='right',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
ax2.set_xlabel('Phase (deg)')
ax2.grid(True, alpha=0.3)
if ax is None:
plt.tight_layout()