"""
show1d: Interactive 1D data viewer for spectra, profiles, and time series.
The 1D counterpart to Show2D. Displays single or multiple 1D traces with
interactive zoom/pan, cursor readout, and calibrated axes. Common uses:
line profiles, ROI time series, optimization loss curves, EELS/EDX spectra,
convergence plots, and any 1D signal.
"""
import csv
import json
import pathlib
from typing import Self
import anywidget
import numpy as np
import traitlets
from quantem.widget.array_utils import to_numpy
from quantem.widget.json_state import resolve_widget_version, save_state_file, unwrap_state_payload
from quantem.widget.tool_parity import (
bind_tool_runtime_api,
build_tool_groups,
normalize_tool_groups,
)
_DEFAULT_COLORS = [
"#4fc3f7", # light blue
"#81c784", # green
"#ffb74d", # orange
"#ce93d8", # purple
"#ef5350", # red
"#ffd54f", # yellow
"#90a4ae", # blue-gray
"#a1887f", # brown
]
[docs]
class Show1D(anywidget.AnyWidget):
"""
Interactive 1D data viewer for spectra, profiles, and time series.
Display one or more 1D traces with interactive zoom, pan, cursor readout,
and calibrated axes. Supports log scale, grid lines, legend, and
publication-quality export.
Parameters
----------
data : array_like
1D array for a single trace, 2D array (n_traces, n_points) for
multiple traces, or a list of 1D arrays.
x : array_like, optional
Shared X-axis values. Must have the same length as data points.
If not provided, uses integer indices [0, 1, 2, ...].
labels : list of str, optional
Labels for each trace. Used in legend and stats bar.
colors : list of str, optional
Hex color strings for each trace. If not provided, uses a default
8-color palette.
title : str, optional
Title displayed above the plot.
x_label : str, optional
Label for the X axis (e.g. "Energy", "Distance", "Epoch").
y_label : str, optional
Label for the Y axis (e.g. "Counts", "Intensity", "Loss").
x_unit : str, optional
Unit for the X axis (e.g. "eV", "nm", "").
y_unit : str, optional
Unit for the Y axis.
log_scale : bool, default False
Use logarithmic Y axis.
auto_contrast : bool, default False
Clip Y-axis range to ``[percentile_low, percentile_high]`` of the
data, revealing weak features hidden by outliers (e.g. core-loss
edges behind a zero-loss peak in EELS).
percentile_low : float, default 2.0
Lower percentile for auto-contrast clipping (0–100).
percentile_high : float, default 98.0
Upper percentile for auto-contrast clipping (0–100).
show_stats : bool, default True
Show statistics bar (mean, min, max, std per trace).
show_legend : bool, default True
Show legend when multiple traces are displayed.
show_grid : bool, default True
Show grid lines on the plot.
show_controls : bool, default True
Show control row below the plot.
line_width : float, default 1.5
Line width for traces.
grid_density : int, default 10
Number of grid divisions per axis (5–50).
peak_active : bool, default False
Whether peak placement mode is on.
disabled_tools : list of str, optional
Tool groups to lock (visible but non-interactive):
``"display"``, ``"peaks"``, ``"stats"``, ``"export"``, ``"all"``.
disable_* : bool, optional
Convenience flags (``disable_display``, ``disable_peaks``,
``disable_stats``, ``disable_export``, ``disable_all``) equivalent
to adding those keys to ``disabled_tools``.
hidden_tools : list of str, optional
Tool groups to hide (not rendered). Same values as
``disabled_tools``.
hide_* : bool, optional
Convenience flags mirroring ``disable_*`` for ``hidden_tools``.
state : dict or str or Path, optional
Restore display settings from a dict or a JSON file path.
Examples
--------
>>> import numpy as np
>>> from quantem.widget import Show1D
>>>
>>> # Single trace
>>> Show1D(np.sin(np.linspace(0, 10, 200)))
>>>
>>> # Multiple traces with labels
>>> x = np.linspace(0, 10, 200)
>>> Show1D([np.sin(x), np.cos(x)], x=x, labels=["sin", "cos"])
>>>
>>> # Optimization loss curve
>>> Show1D(losses, x_label="Epoch", y_label="Loss", log_scale=True)
"""
_esm = pathlib.Path(__file__).parent / "static" / "show1d.js"
_css = pathlib.Path(__file__).parent / "static" / "show1d.css"
# =========================================================================
# Core State
# =========================================================================
widget_version = traitlets.Unicode("unknown").tag(sync=True)
y_bytes = traitlets.Bytes(b"").tag(sync=True)
x_bytes = traitlets.Bytes(b"").tag(sync=True)
n_traces = traitlets.Int(1).tag(sync=True)
n_points = traitlets.Int(0).tag(sync=True)
labels = traitlets.List(traitlets.Unicode()).tag(sync=True)
colors = traitlets.List(traitlets.Unicode()).tag(sync=True)
# =========================================================================
# Display Options
# =========================================================================
title = traitlets.Unicode("").tag(sync=True)
x_label = traitlets.Unicode("").tag(sync=True)
y_label = traitlets.Unicode("").tag(sync=True)
x_unit = traitlets.Unicode("").tag(sync=True)
y_unit = traitlets.Unicode("").tag(sync=True)
log_scale = traitlets.Bool(False).tag(sync=True)
auto_contrast = traitlets.Bool(False).tag(sync=True)
percentile_low = traitlets.Float(2.0).tag(sync=True)
percentile_high = traitlets.Float(98.0).tag(sync=True)
show_stats = traitlets.Bool(True).tag(sync=True)
show_legend = traitlets.Bool(True).tag(sync=True)
show_grid = traitlets.Bool(True).tag(sync=True)
show_controls = traitlets.Bool(True).tag(sync=True)
line_width = traitlets.Float(1.5).tag(sync=True)
focused_trace = traitlets.Int(-1).tag(sync=True)
grid_density = traitlets.Int(10).tag(sync=True)
x_range = traitlets.List(traitlets.Float()).tag(sync=True)
y_range = traitlets.List(traitlets.Float()).tag(sync=True)
# =========================================================================
# Peak Features
# =========================================================================
peak_markers = traitlets.List(traitlets.Dict()).tag(sync=True)
peak_active = traitlets.Bool(False).tag(sync=True)
peak_search_radius = traitlets.Int(20).tag(sync=True)
selected_peaks = traitlets.List(traitlets.Int()).tag(sync=True)
# =========================================================================
# Tool Lock/Hide
# =========================================================================
disabled_tools = traitlets.List(traitlets.Unicode()).tag(sync=True)
hidden_tools = traitlets.List(traitlets.Unicode()).tag(sync=True)
@classmethod
def _normalize_tool_groups(cls, tool_groups):
return normalize_tool_groups("Show1D", tool_groups)
@classmethod
def _build_disabled_tools(
cls,
disabled_tools=None,
disable_display: bool = False,
disable_peaks: bool = False,
disable_stats: bool = False,
disable_export: bool = False,
disable_all: bool = False,
):
return build_tool_groups(
"Show1D",
tool_groups=disabled_tools,
all_flag=disable_all,
flag_map={
"display": disable_display,
"peaks": disable_peaks,
"stats": disable_stats,
"export": disable_export,
},
)
@classmethod
def _build_hidden_tools(
cls,
hidden_tools=None,
hide_display: bool = False,
hide_peaks: bool = False,
hide_stats: bool = False,
hide_export: bool = False,
hide_all: bool = False,
):
return build_tool_groups(
"Show1D",
tool_groups=hidden_tools,
all_flag=hide_all,
flag_map={
"display": hide_display,
"peaks": hide_peaks,
"stats": hide_stats,
"export": hide_export,
},
)
@traitlets.validate("disabled_tools")
def _validate_disabled_tools(self, proposal):
return self._normalize_tool_groups(proposal["value"])
@traitlets.validate("hidden_tools")
def _validate_hidden_tools(self, proposal):
return self._normalize_tool_groups(proposal["value"])
# =========================================================================
# Statistics (per-trace)
# =========================================================================
stats_mean = traitlets.List(traitlets.Float()).tag(sync=True)
stats_min = traitlets.List(traitlets.Float()).tag(sync=True)
stats_max = traitlets.List(traitlets.Float()).tag(sync=True)
stats_std = traitlets.List(traitlets.Float()).tag(sync=True)
range_stats = traitlets.List(traitlets.Dict()).tag(sync=True)
# =========================================================================
# Peak FWHM
# =========================================================================
peak_fwhm = traitlets.List(traitlets.Dict()).tag(sync=True)
def __init__(
self,
data,
x=None,
labels=None,
colors=None,
title: str = "",
x_label: str = "",
y_label: str = "",
x_unit: str = "",
y_unit: str = "",
log_scale: bool = False,
auto_contrast: bool = False,
percentile_low: float = 2.0,
percentile_high: float = 98.0,
show_stats: bool = True,
show_legend: bool = True,
show_grid: bool = True,
show_controls: bool = True,
line_width: float = 1.5,
grid_density: int = 10,
peak_active: bool = False,
peak_search_radius: int = 20,
disabled_tools=None,
disable_display: bool = False,
disable_peaks: bool = False,
disable_stats: bool = False,
disable_export: bool = False,
disable_all: bool = False,
hidden_tools=None,
hide_display: bool = False,
hide_peaks: bool = False,
hide_stats: bool = False,
hide_export: bool = False,
hide_all: bool = False,
state=None,
**kwargs,
):
super().__init__(**kwargs)
self.widget_version = resolve_widget_version()
# Dataset duck typing
_extracted_title = ""
if hasattr(data, "array") and hasattr(data, "name"):
if data.name:
_extracted_title = data.name
data = data.array
# Normalize data to 2D (n_traces, n_points)
if isinstance(data, list):
arrays = [to_numpy(d).astype(np.float32).ravel() for d in data]
n_pts = len(arrays[0])
for i, a in enumerate(arrays):
if len(a) != n_pts:
raise ValueError(
f"All traces must have the same length. "
f"Trace 0 has {n_pts} points, trace {i} has {len(a)}."
)
data_2d = np.stack(arrays)
else:
arr = to_numpy(data).astype(np.float32)
if arr.ndim == 1:
data_2d = arr[np.newaxis, :]
elif arr.ndim == 2:
data_2d = arr
else:
raise ValueError(f"Expected 1D or 2D array, got {arr.ndim}D.")
self._data = data_2d
self.n_traces = int(data_2d.shape[0])
self.n_points = int(data_2d.shape[1])
# X axis
self._x = None
if x is not None:
x_arr = to_numpy(x).astype(np.float32).ravel()
if len(x_arr) != self.n_points:
raise ValueError(
f"x has {len(x_arr)} points but data has {self.n_points} points."
)
self._x = x_arr
self.x_bytes = x_arr.tobytes()
# Labels
if labels is not None:
self.labels = [str(l) for l in labels]
else:
if self.n_traces == 1:
self.labels = ["Data"]
else:
self.labels = [f"Data {i + 1}" for i in range(self.n_traces)]
# Colors
if colors is not None:
self.colors = [str(c) for c in colors]
else:
self.colors = [_DEFAULT_COLORS[i % len(_DEFAULT_COLORS)] for i in range(self.n_traces)]
# Display options
self.title = title or _extracted_title
self.x_label = x_label
self.y_label = y_label
self.x_unit = x_unit
self.y_unit = y_unit
self.log_scale = log_scale
self.auto_contrast = auto_contrast
self.percentile_low = percentile_low
self.percentile_high = percentile_high
self.show_stats = show_stats
self.show_legend = show_legend
self.show_grid = show_grid
self.show_controls = show_controls
self.line_width = line_width
self.grid_density = grid_density
self.peak_active = peak_active
self.peak_search_radius = peak_search_radius
self.disabled_tools = self._build_disabled_tools(
disabled_tools=disabled_tools,
disable_display=disable_display,
disable_peaks=disable_peaks,
disable_stats=disable_stats,
disable_export=disable_export,
disable_all=disable_all,
)
self.hidden_tools = self._build_hidden_tools(
hidden_tools=hidden_tools,
hide_display=hide_display,
hide_peaks=hide_peaks,
hide_stats=hide_stats,
hide_export=hide_export,
hide_all=hide_all,
)
# Compute stats and send data
self._compute_stats()
self._compute_range_stats()
self.y_bytes = self._data.tobytes()
# Restore state
if state is not None:
if isinstance(state, (str, pathlib.Path)):
state = unwrap_state_payload(
json.loads(pathlib.Path(state).read_text()),
require_envelope=True,
)
else:
state = unwrap_state_payload(state)
self.load_state_dict(state)
[docs]
def set_data(self, data, x=None, labels=None) -> Self:
"""Replace displayed data. Preserves display settings.
Parameters
----------
data : array_like
1D, 2D (n_traces, n_points), or list of 1D arrays.
x : array_like, optional
New X-axis values.
labels : list of str, optional
New trace labels. If not provided, generates defaults.
"""
if hasattr(data, "array") and hasattr(data, "name"):
data = data.array
if isinstance(data, list):
arrays = [to_numpy(d).astype(np.float32).ravel() for d in data]
n_pts = len(arrays[0])
for i, a in enumerate(arrays):
if len(a) != n_pts:
raise ValueError(
f"All traces must have the same length. "
f"Trace 0 has {n_pts} points, trace {i} has {len(a)}."
)
data_2d = np.stack(arrays)
else:
arr = to_numpy(data).astype(np.float32)
if arr.ndim == 1:
data_2d = arr[np.newaxis, :]
elif arr.ndim == 2:
data_2d = arr
else:
raise ValueError(f"Expected 1D or 2D array, got {arr.ndim}D.")
self._data = data_2d
self.n_traces = int(data_2d.shape[0])
self.n_points = int(data_2d.shape[1])
if x is not None:
x_arr = to_numpy(x).astype(np.float32).ravel()
if len(x_arr) != self.n_points:
raise ValueError(
f"x has {len(x_arr)} points but data has {self.n_points} points."
)
self._x = x_arr
self.x_bytes = x_arr.tobytes()
else:
self._x = None
self.x_bytes = b""
if labels is not None:
self.labels = [str(l) for l in labels]
else:
if self.n_traces == 1:
self.labels = ["Data"]
else:
self.labels = [f"Data {i + 1}" for i in range(self.n_traces)]
# Assign default colors if count changed
self.colors = [_DEFAULT_COLORS[i % len(_DEFAULT_COLORS)] for i in range(self.n_traces)]
self._compute_stats()
self._compute_range_stats()
self.peak_fwhm = []
self.y_bytes = self._data.tobytes()
return self
[docs]
def add_trace(self, y, label=None, color=None) -> Self:
"""Append a trace.
Parameters
----------
y : array_like
1D array with the same number of points as existing traces.
label : str, optional
Trace label.
color : str, optional
Hex color string.
"""
arr = to_numpy(y).astype(np.float32).ravel()
if self.n_points > 0 and len(arr) != self.n_points:
raise ValueError(
f"New trace has {len(arr)} points but existing traces have {self.n_points}."
)
if self._data.size == 0:
self._data = arr[np.newaxis, :]
self.n_points = int(len(arr))
else:
self._data = np.vstack([self._data, arr[np.newaxis, :]])
self.n_traces = int(self._data.shape[0])
lbl = label if label is not None else f"Data {self.n_traces}"
self.labels = list(self.labels) + [lbl]
clr = color if color is not None else _DEFAULT_COLORS[(self.n_traces - 1) % len(_DEFAULT_COLORS)]
self.colors = list(self.colors) + [clr]
self._compute_stats()
self._compute_range_stats()
self.y_bytes = self._data.tobytes()
return self
[docs]
def remove_trace(self, index: int) -> Self:
"""Remove a trace by index.
Parameters
----------
index : int
Zero-based trace index.
"""
if index < 0 or index >= self.n_traces:
raise IndexError(f"Trace index {index} out of range [0, {self.n_traces}).")
self._data = np.delete(self._data, index, axis=0)
self.n_traces = int(self._data.shape[0])
lbls = list(self.labels)
lbls.pop(index)
self.labels = lbls
clrs = list(self.colors)
clrs.pop(index)
self.colors = clrs
self._compute_stats()
self._compute_range_stats()
self.y_bytes = self._data.tobytes()
return self
[docs]
def clear(self) -> Self:
"""Remove all traces."""
self._data = np.empty((0, 0), dtype=np.float32)
self.n_traces = 0
self.n_points = 0
self.labels = []
self.colors = []
self.stats_mean = []
self.stats_min = []
self.stats_max = []
self.stats_std = []
self.range_stats = []
self.peak_fwhm = []
self.y_bytes = b""
return self
# =========================================================================
# Peak Markers
# =========================================================================
@property
def peaks(self):
"""Return list of peak marker dicts."""
return list(self.peak_markers)
@property
def selected_peak_data(self):
"""Return peak marker dicts for currently selected peaks."""
markers = list(self.peak_markers)
return [markers[i] for i in self.selected_peaks if 0 <= i < len(markers)]
[docs]
def add_peak(self, x: float, trace_idx: int = 0, label: str = "") -> Self:
"""Add a peak marker at the given X position.
Finds the nearest data point and searches ±5 points for a local
maximum.
Parameters
----------
x : float
X-axis value near the desired peak.
trace_idx : int
Trace index to search for the peak.
label : str
Optional label for the peak marker.
"""
if trace_idx < 0 or trace_idx >= self.n_traces:
raise IndexError(f"Trace index {trace_idx} out of range [0, {self.n_traces}).")
trace = self._data[trace_idx]
# Find nearest index to x
if self._x is not None:
nearest_idx = int(np.argmin(np.abs(self._x - x)))
else:
nearest_idx = int(round(x))
nearest_idx = max(0, min(self.n_points - 1, nearest_idx))
# Search ±5 points for local maximum
lo = max(0, nearest_idx - 5)
hi = min(self.n_points, nearest_idx + 6)
region = trace[lo:hi]
local_idx = lo + int(np.argmax(region))
peak_x = float(self._x[local_idx]) if self._x is not None else float(local_idx)
peak_y = float(trace[local_idx])
marker = {
"x": peak_x,
"y": peak_y,
"trace_idx": trace_idx,
"label": label or f"{peak_x:.4g}",
"type": "peak",
}
self.peak_markers = list(self.peak_markers) + [marker]
return self
[docs]
def remove_peak(self, index: int = -1) -> Self:
"""Remove a peak marker by index (default: last)."""
markers = list(self.peak_markers)
if not markers:
return self
if index < -len(markers) or index >= len(markers):
raise IndexError(f"Peak index {index} out of range.")
markers.pop(index)
self.peak_markers = markers
return self
[docs]
def clear_peaks(self) -> Self:
"""Remove all peak markers."""
self.peak_markers = []
self.selected_peaks = []
return self
[docs]
def find_peaks(
self,
trace_idx: int = 0,
*,
height=None,
prominence: float = 0.01,
distance: int = 1,
width=None,
) -> Self:
"""Auto-detect peaks using scipy.signal.find_peaks.
Parameters
----------
trace_idx : int
Trace index to search.
height : float, optional
Minimum peak height.
prominence : float
Minimum peak prominence (default 0.01).
distance : int
Minimum horizontal distance between peaks in samples.
width : float, optional
Minimum peak width in samples.
"""
from scipy.signal import find_peaks as _find_peaks
if trace_idx < 0 or trace_idx >= self.n_traces:
raise IndexError(f"Trace index {trace_idx} out of range [0, {self.n_traces}).")
trace = self._data[trace_idx]
kwargs = {"distance": distance}
if height is not None:
kwargs["height"] = height
if prominence is not None:
kwargs["prominence"] = prominence
if width is not None:
kwargs["width"] = width
indices, _ = _find_peaks(trace, **kwargs)
markers = list(self.peak_markers)
for idx in indices:
peak_x = float(self._x[idx]) if self._x is not None else float(idx)
peak_y = float(trace[idx])
markers.append({
"x": peak_x,
"y": peak_y,
"trace_idx": trace_idx,
"label": f"{peak_x:.4g}",
"type": "peak",
})
self.peak_markers = markers
return self
[docs]
def export_peaks(self, path: str) -> pathlib.Path:
"""Export peak markers to CSV or JSON.
Format is inferred from the file extension (.csv or .json).
Parameters
----------
path : str
Output file path.
"""
p = pathlib.Path(path)
markers = list(self.peak_markers)
if not markers:
raise ValueError("No peak markers to export.")
if p.suffix.lower() == ".csv":
with open(p, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["x", "y", "trace_idx", "label", "type"])
writer.writeheader()
for m in markers:
writer.writerow({
"x": m["x"],
"y": m["y"],
"trace_idx": m["trace_idx"],
"label": m.get("label", ""),
"type": m.get("type", "peak"),
})
elif p.suffix.lower() == ".json":
p.write_text(json.dumps(markers, indent=2))
else:
raise ValueError(f"Unsupported format '{p.suffix}'. Use .csv or .json.")
return p
[docs]
def save_image(
self,
path: str | pathlib.Path,
*,
format: str | None = None,
dpi: int = 150,
include_peaks: bool = True,
) -> pathlib.Path:
"""Save publication-quality figure as PNG or PDF.
Renders traces, grid, axes, labels, and peak markers using matplotlib.
Parameters
----------
path : str or pathlib.Path
Output file path.
format : str, optional
``"png"`` or ``"pdf"``. Inferred from extension if omitted.
dpi : int
Output resolution (default 150).
include_peaks : bool
Render peak markers on the figure (default True).
Returns
-------
pathlib.Path
The written file path.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
path = pathlib.Path(path)
fmt = (format or path.suffix.lstrip(".").lower() or "png").lower()
if fmt not in ("png", "pdf"):
raise ValueError(f"Unsupported format: {fmt!r}. Use 'png' or 'pdf'.")
data = self._data
x = self._x if self._x is not None else np.arange(self.n_points, dtype=np.float32)
fig, ax = plt.subplots(figsize=(6, 3.5), dpi=dpi)
# Draw traces
for t in range(self.n_traces):
color = self.colors[t] if t < len(self.colors) else None
label = self.labels[t] if t < len(self.labels) else f"Data {t + 1}"
ax.plot(x, data[t], color=color, label=label, linewidth=self.line_width)
# Peak markers
if include_peaks and self.peak_markers:
for pk in self.peak_markers:
color = self.colors[pk["trace_idx"]] if pk["trace_idx"] < len(self.colors) else "C0"
ax.plot(pk["x"], pk["y"], marker="^", color=color, markersize=6,
markeredgecolor="white", markeredgewidth=0.8, zorder=5)
ax.annotate(
pk.get("label", ""),
(pk["x"], pk["y"]),
textcoords="offset points",
xytext=(0, 6),
ha="center",
va="bottom",
fontsize=7,
color="#333",
)
# Axis labels
x_text = self.x_label or ""
if self.x_unit:
x_text += f" ({self.x_unit})" if x_text else self.x_unit
if x_text:
ax.set_xlabel(x_text)
y_text = self.y_label or ""
if self.y_unit:
y_text += f" ({self.y_unit})" if y_text else self.y_unit
if y_text:
ax.set_ylabel(y_text)
if self.title:
ax.set_title(self.title, fontsize=11)
if self.log_scale:
ax.set_yscale("log")
if self.auto_contrast and not self.log_scale:
all_vals = self._data.ravel()
vmin = float(np.percentile(all_vals, self.percentile_low))
vmax = float(np.percentile(all_vals, self.percentile_high))
if vmin < vmax:
pad = (vmax - vmin) * 0.05
ax.set_ylim(vmin - pad, vmax + pad)
if self.show_grid:
ax.grid(True, alpha=0.3, linestyle="--")
if self.n_traces > 1 and self.show_legend:
ax.legend(fontsize=8, framealpha=0.8)
fig.tight_layout()
path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(str(path), format=fmt, dpi=dpi, bbox_inches="tight")
plt.close(fig)
return path
def _compute_stats(self):
means, mins, maxs, stds = [], [], [], []
for i in range(self.n_traces):
trace = self._data[i]
means.append(float(np.mean(trace)))
mins.append(float(np.min(trace)))
maxs.append(float(np.max(trace)))
stds.append(float(np.std(trace)))
self.stats_mean = means
self.stats_min = mins
self.stats_max = maxs
self.stats_std = stds
def _compute_range_stats(self):
if not self.x_range or len(self.x_range) != 2 or self.n_traces == 0:
self.range_stats = []
return
x_lo, x_hi = self.x_range
x_arr = self._x if self._x is not None else np.arange(self.n_points, dtype=np.float32)
mask = (x_arr >= x_lo) & (x_arr <= x_hi)
if not np.any(mask):
self.range_stats = []
return
x_masked = x_arr[mask]
results = []
for i in range(self.n_traces):
trace = self._data[i]
y_masked = trace[mask]
entry = {
"mean": float(np.mean(y_masked)),
"min": float(np.min(y_masked)),
"max": float(np.max(y_masked)),
"std": float(np.std(y_masked)),
"integral": float(np.trapezoid(y_masked, x=x_masked)),
"n_points": int(np.sum(mask)),
}
results.append(entry)
self.range_stats = results
# =========================================================================
# Peak FWHM Methods
# =========================================================================
def _compute_peak_fwhm(self):
if not self.selected_peaks or not self.peak_markers or self.n_traces == 0:
self.peak_fwhm = []
return
x_arr = self._x if self._x is not None else np.arange(self.n_points, dtype=np.float32)
results = []
for peak_idx in self.selected_peaks:
if peak_idx < 0 or peak_idx >= len(self.peak_markers):
continue
pk = self.peak_markers[peak_idx]
trace_idx = pk["trace_idx"]
if trace_idx < 0 or trace_idx >= self.n_traces:
continue
trace = self._data[trace_idx]
if self._x is not None:
center_idx = int(np.argmin(np.abs(self._x - pk["x"])))
else:
center_idx = int(round(pk["x"]))
center_idx = max(0, min(self.n_points - 1, center_idx))
radius = self.peak_search_radius
lo = max(0, center_idx - radius)
hi = min(self.n_points, center_idx + radius + 1)
x_region = x_arr[lo:hi].astype(np.float64)
y_region = trace[lo:hi].astype(np.float64)
if len(x_region) < 4:
results.append({"peak_idx": peak_idx, "fwhm": None, "error": "Too few points"})
continue
try:
from scipy.optimize import curve_fit as _curve_fit
A0 = float(np.max(y_region) - np.min(y_region))
mu0 = float(x_arr[center_idx])
sigma0 = max(float((x_region[-1] - x_region[0]) / 6), 1e-10)
offset0 = float(np.min(y_region))
def gaussian(x, A, mu, sigma, offset):
return A * np.exp(-0.5 * ((x - mu) / sigma) ** 2) + offset
popt, _ = _curve_fit(gaussian, x_region, y_region, p0=[A0, mu0, sigma0, offset0], maxfev=5000)
A, mu, sigma, offset = popt
fwhm = 2.3548200450309493 * abs(sigma)
y_pred = gaussian(x_region, *popt)
ss_res = np.sum((y_region - y_pred) ** 2)
ss_tot = np.sum((y_region - np.mean(y_region)) ** 2)
r_squared = float(1 - ss_res / ss_tot) if ss_tot > 0 else 0.0
results.append({
"peak_idx": peak_idx,
"fwhm": float(fwhm),
"center": float(mu),
"amplitude": float(A),
"sigma": float(sigma),
"offset": float(offset),
"fit_quality": r_squared,
})
except Exception as e:
results.append({"peak_idx": peak_idx, "fwhm": None, "error": str(e)})
self.peak_fwhm = results
[docs]
def measure_fwhm(self, peak_idx: int | None = None) -> Self:
"""Compute FWHM for selected peaks (or a specific peak).
Parameters
----------
peak_idx : int, optional
If given, adds this peak to the selection before computing.
"""
if peak_idx is not None:
sel = list(self.selected_peaks)
if peak_idx not in sel:
sel.append(peak_idx)
self.selected_peaks = sel
self._compute_peak_fwhm()
return self
# =========================================================================
# Observers
# =========================================================================
@traitlets.observe("x_range")
def _on_x_range_change(self, change=None):
self._compute_range_stats()
@traitlets.observe("selected_peaks")
def _on_selected_peaks_change(self, change=None):
self._compute_peak_fwhm()
# =========================================================================
# State Persistence
# =========================================================================
[docs]
def state_dict(self):
return {
"title": self.title,
"labels": self.labels,
"colors": self.colors,
"x_label": self.x_label,
"y_label": self.y_label,
"x_unit": self.x_unit,
"y_unit": self.y_unit,
"log_scale": self.log_scale,
"auto_contrast": self.auto_contrast,
"percentile_low": self.percentile_low,
"percentile_high": self.percentile_high,
"show_stats": self.show_stats,
"show_legend": self.show_legend,
"show_grid": self.show_grid,
"show_controls": self.show_controls,
"line_width": self.line_width,
"focused_trace": self.focused_trace,
"grid_density": self.grid_density,
"x_range": list(self.x_range),
"y_range": list(self.y_range),
"peak_active": self.peak_active,
"peak_search_radius": self.peak_search_radius,
"peak_markers": list(self.peak_markers),
"disabled_tools": self.disabled_tools,
"hidden_tools": self.hidden_tools,
}
[docs]
def save(self, path: str):
save_state_file(path, "Show1D", self.state_dict())
[docs]
def load_state_dict(self, state):
for key, val in state.items():
if hasattr(self, key):
setattr(self, key, val)
[docs]
def summary(self):
lines = [self.title or "Show1D", "=" * 32]
lines.append(f"Series: {self.n_traces} x {self.n_points} points")
if self.labels:
lines.append(f"Labels: {', '.join(self.labels)}")
if self._x is not None:
lines.append(f"X range: {float(self._x[0]):.4g} - {float(self._x[-1]):.4g}")
if self.x_label or self.x_unit:
x_desc = self.x_label
if self.x_unit:
x_desc += f" ({self.x_unit})" if x_desc else self.x_unit
lines.append(f"X axis: {x_desc}")
if self.y_label or self.y_unit:
y_desc = self.y_label
if self.y_unit:
y_desc += f" ({self.y_unit})" if y_desc else self.y_unit
lines.append(f"Y axis: {y_desc}")
for i in range(self.n_traces):
if i < len(self.stats_mean):
lines.append(
f" [{i}] {self.labels[i] if i < len(self.labels) else ''}: "
f"mean={self.stats_mean[i]:.4g} min={self.stats_min[i]:.4g} "
f"max={self.stats_max[i]:.4g} std={self.stats_std[i]:.4g}"
)
scale = "log" if self.log_scale else "linear"
display = f"{scale}"
if self.auto_contrast:
display += f" | auto [{self.percentile_low:.1f}–{self.percentile_high:.1f}%]"
if self.show_grid:
display += " | grid"
if self.grid_density != 10:
display += f" ({self.grid_density})"
lines.append(f"Display: {display}")
if self.peak_markers:
lines.append(f"Peaks: {len(self.peak_markers)} markers")
if self.disabled_tools:
lines.append(f"Locked: {', '.join(self.disabled_tools)}")
if self.hidden_tools:
lines.append(f"Hidden: {', '.join(self.hidden_tools)}")
print("\n".join(lines))
def __repr__(self) -> str:
if self.n_traces == 1:
return f"Show1D({self.n_points} points)"
return f"Show1D({self.n_traces} traces x {self.n_points} points)"
bind_tool_runtime_api(Show1D, "Show1D")