"""Experimental data plotting functions for homodyne XPCS analysis.
This module provides functions for visualizing raw experimental C2 correlation data.
Extracted from cli/commands.py for better modularity.
"""
from pathlib import Path
from typing import Any
import matplotlib
import numpy as np
# Set Agg backend only if no interactive backend is already active.
# Checking is_interactive() alone can be True for non-GUI backends in some
# environments; compare against the known interactive backend families instead.
_current_backend = matplotlib.get_backend().lower()
_interactive_backends = ("qt", "gtk", "wx", "tk", "macosx", "nbagg", "webagg")
if not any(_current_backend.startswith(b) for b in _interactive_backends):
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from homodyne.utils.logging import get_logger # noqa: E402
logger = get_logger(__name__)
[docs]
def plot_experimental_data(
data: dict[str, Any],
plots_dir: Path,
angle_filter_func: Any | None = None,
) -> None:
"""Generate validation plots of experimental data.
Parameters
----------
data : dict[str, Any]
Data dictionary containing:
- c2_exp: Experimental correlation data (n_phi, n_t1, n_t2) or (n_t1, n_t2)
- t1: Time array 1 (optional)
- t2: Time array 2 (optional)
- phi_angles_list: Phi angles in degrees (optional)
- config: Configuration dict for angle filtering (optional)
plots_dir : Path
Output directory for plot files
angle_filter_func : callable, optional
Function to apply angle filtering. Signature:
(phi_angles, c2_exp, data) -> (filtered_indices, filtered_phi, filtered_c2)
"""
c2_exp = data.get("c2_exp", None)
if c2_exp is None:
logger.warning("No experimental data to plot")
return
# Get time arrays if available for proper axis labels
t1 = data.get("t1", None)
t2 = data.get("t2", None)
# Extract time extent for imshow if time arrays are available
if t1 is not None and t2 is not None:
t1_min, t1_max = float(np.nanmin(t1)), float(np.nanmax(t1))
t2_min, t2_max = float(np.nanmin(t2)), float(np.nanmax(t2))
if all(np.isfinite(v) for v in [t1_min, t1_max, t2_min, t2_max]):
extent = [t1_min, t1_max, t2_min, t2_max]
else:
extent = None
xlabel = "t₁ (s)"
ylabel = "t₂ (s)"
logger.debug(
f"Using time extent: t1=[{t1_min:.3f}, {t1_max:.3f}], t2=[{t2_min:.3f}, {t2_max:.3f}] seconds"
)
else:
extent = None
xlabel = "t₁ Index"
ylabel = "t₂ Index"
logger.debug("Time arrays not available, using frame indices")
# Get phi angles array from data
phi_angles_list = data.get("phi_angles_list", None)
if phi_angles_list is None:
logger.warning("phi_angles_list not found in data, using indices")
phi_angles_list = np.arange(c2_exp.shape[0])
# Apply angle filtering for plotting if configured and filter function provided
if angle_filter_func is not None:
filtered_indices, filtered_phi_angles, filtered_c2_exp = angle_filter_func(
phi_angles_list, c2_exp, data
)
else:
filtered_indices = list(range(len(phi_angles_list)))
filtered_phi_angles = phi_angles_list
filtered_c2_exp = c2_exp
# Use filtered data for plotting
phi_angles_list = filtered_phi_angles
c2_exp = filtered_c2_exp
logger.info(
f"Plotting {len(filtered_indices)} angles after filtering: {filtered_phi_angles}",
)
# Handle different data shapes
if c2_exp.ndim == 3:
_plot_3d_experimental_data(
c2_exp, phi_angles_list, t1, extent, xlabel, ylabel, plots_dir
)
elif c2_exp.ndim == 2:
_plot_2d_experimental_data(c2_exp, extent, xlabel, ylabel, plots_dir)
elif c2_exp.ndim == 1:
_plot_1d_experimental_data(c2_exp, plots_dir)
else:
logger.warning(f"Unsupported data dimensionality: {c2_exp.ndim}D")
return
logger.debug(f"Plotted experimental data with shape {c2_exp.shape}")
def _plot_3d_experimental_data(
c2_exp: np.ndarray,
phi_angles_list: np.ndarray,
t1: np.ndarray | None,
extent: list[float] | None,
xlabel: str,
ylabel: str,
plots_dir: Path,
) -> None:
"""Plot 3D experimental data (n_phi, n_t1, n_t2)."""
n_angles = c2_exp.shape[0]
logger.info(f"Generating individual C2 heatmaps for {n_angles} phi angles...")
for angle_idx in range(n_angles):
phi_deg = (
phi_angles_list[angle_idx] if len(phi_angles_list) > angle_idx else 0.0
)
angle_data = c2_exp[angle_idx]
fig, ax = plt.subplots(figsize=(8, 7))
data_min = float(np.nanmin(angle_data))
data_max = float(np.nanmax(angle_data))
vmin = max(data_min, 1.0)
vmax = min(data_max, 1.5)
if vmin >= vmax:
vmin, vmax = data_min, data_max
im = ax.imshow(
angle_data.T,
aspect="equal",
cmap="jet",
origin="lower",
extent=extent,
vmin=vmin,
vmax=vmax,
)
ax.set_xlabel(xlabel, fontsize=11)
ax.set_ylabel(ylabel, fontsize=11)
ax.set_title(
f"Experimental C₂(t₁, t₂) at φ={phi_deg:.1f}°",
fontsize=13,
fontweight="bold",
)
cbar = plt.colorbar(im, ax=ax, label="C₂", shrink=0.9)
cbar.ax.tick_params(labelsize=9)
# Calculate and display key statistics (use nan-safe variants for masked pixels)
mean_val = float(np.nanmean(angle_data))
max_val = float(np.nanmax(angle_data))
min_val = float(np.nanmin(angle_data))
stats_text = f"Mean: {mean_val:.4f}\nRange: [{min_val:.4f}, {max_val:.4f}]"
ax.text(
0.02,
0.98,
stats_text,
transform=ax.transAxes,
fontsize=9,
verticalalignment="top",
bbox={"boxstyle": "round", "facecolor": "white", "alpha": 0.8},
)
plt.tight_layout()
filename = f"experimental_data_phi_{phi_deg:.1f}.png"
plt.savefig(plots_dir / filename, dpi=150, bbox_inches="tight")
plt.close()
logger.debug(f" Saved: {filename}")
logger.info(f"Generated {n_angles} individual C2 heatmaps")
# Plot diagonal (t1=t2) for all phi angles
fig, ax = plt.subplots(figsize=(10, 6))
if t1 is not None:
time_diagonal = t1
else:
time_diagonal = np.arange(c2_exp.shape[-1])
for idx in range(min(10, c2_exp.shape[0])):
min_dim = min(c2_exp[idx].shape)
diagonal = np.diag(c2_exp[idx][:min_dim, :min_dim])
phi_deg = phi_angles_list[idx] if len(phi_angles_list) > idx else idx
ax.plot(time_diagonal[:min_dim], diagonal, label=f"φ={phi_deg:.1f}°", alpha=0.7)
ax.set_xlabel("Time (s)" if t1 is not None else "Time Index")
ax.set_ylabel("C₂(t, t)")
ax.set_title("C₂ Diagonal (t₁=t₂) for Different φ Angles")
ax.legend(loc="best")
ax.grid(True, alpha=0.3)
plt.savefig(
plots_dir / "experimental_data_diagonal.png",
dpi=150,
bbox_inches="tight",
)
plt.close()
def _plot_2d_experimental_data(
c2_exp: np.ndarray,
extent: list[float] | None,
xlabel: str,
ylabel: str,
plots_dir: Path,
) -> None:
"""Plot 2D experimental data (single correlation matrix)."""
fig, ax = plt.subplots(figsize=(10, 8))
data_min = float(np.nanmin(c2_exp))
data_max = float(np.nanmax(c2_exp))
vmin = max(data_min, 1.0)
vmax = min(data_max, 1.5)
if vmin >= vmax:
vmin, vmax = data_min, data_max
im = ax.imshow(
c2_exp.T,
aspect="equal",
cmap="jet",
origin="lower",
extent=extent,
vmin=vmin,
vmax=vmax,
)
plt.colorbar(im, ax=ax, label="C₂(t₁,t₂)", shrink=0.8)
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title("Experimental C₂ Data")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(plots_dir / "experimental_data.png", dpi=150, bbox_inches="tight")
plt.close()
def _plot_1d_experimental_data(c2_exp: np.ndarray, plots_dir: Path) -> None:
"""Plot 1D experimental data."""
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(c2_exp, marker="o", linestyle="-", alpha=0.7)
ax.set_xlabel("Data Point Index")
ax.set_ylabel("C₂")
ax.set_title("Experimental C₂ Data")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(plots_dir / "experimental_data.png", dpi=150, bbox_inches="tight")
plt.close()
[docs]
def plot_fit_comparison(
result: Any,
data: dict[str, Any],
plots_dir: Path,
) -> None:
"""Generate comparison plots between fit and experimental data.
Parameters
----------
result : Any
Optimization result object
data : dict[str, Any]
Experimental data dictionary
plots_dir : Path
Output directory for plot files
"""
c2_exp = data.get("c2_exp", None)
if c2_exp is None:
return
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Plot experimental data
if c2_exp.ndim == 1:
axes[0].plot(c2_exp, marker="o", linestyle="-", alpha=0.7, label="Experimental")
axes[0].set_xlabel("Data Point Index")
axes[0].set_ylabel("C₂")
else:
fit_vmin = max(float(np.nanmin(c2_exp)), 1.0)
fit_vmax = min(float(np.nanmax(c2_exp)), 1.5)
if fit_vmin >= fit_vmax:
fit_vmin = float(np.nanmin(c2_exp))
fit_vmax = float(np.nanmax(c2_exp))
im0 = axes[0].imshow(
c2_exp, aspect="auto", cmap="jet", vmin=fit_vmin, vmax=fit_vmax
)
plt.colorbar(im0, ax=axes[0], label="C₂")
axes[0].set_xlabel("t₂ Index")
axes[0].set_ylabel("φ Index")
axes[0].set_title("Experimental Data")
axes[0].grid(True, alpha=0.3)
# Plot fit results placeholder
axes[1].text(
0.5,
0.5,
"Fit visualization\nrequires full\nplotting backend",
ha="center",
va="center",
fontsize=14,
)
axes[1].set_title("Fit Results")
axes[1].axis("off")
plt.tight_layout()
plt.savefig(plots_dir / "fit_comparison.png", dpi=150, bbox_inches="tight")
plt.close()
logger.info("Generated basic fit comparison plot")