Source code for specsrbench.figures.fig5_per_line_snr

"""Figure 5 -- per-line behaviour: S/N, detection, false detection, width bias.

Four rows over the four diagnostic lines.  The first two say how much signal a
method reports and how often it reports any; the last two are what stop those
being read as quality.

* **Median S/N**, over the subset where the reference itself detects the line.
* **Detection fraction** at S/N > 5, with the reference's own rate marked.
* **False detection rate** -- how often a method reports S/N > 5 where the
  reference says the line is absent (S/N < 3).  This is the row where SR2
  separates from every classical method: 0.30 on Hbeta and 0.44 on [O II],
  against <= 0.09 for anything classical.
* **FWHM bias** in nanometres against the reference's fitted width.

A method can lead the first row by inventing lines, and only the third row
shows it.  They are drawn together for that reason.
"""
from __future__ import annotations

from pathlib import Path

import numpy as np

from .. import paths, style
from ..data import load_cache
from ..methods import LINES, NON_HR, ORDER, registry

#: The reference detects a line above this; below TRUE_ABSENT it says there is
#: none, and anything a method "finds" there is false.
HR_DETECT_THRESH = 5.0
TRUE_ABSENT_THRESH = 3.0
#: Widths are only meaningful where the reference line is solidly detected.
FWHM_MIN_SN = 5.0


[docs] def compute(cache) -> dict[str, dict[str, list[float]]]: """The four rows, each as ``{method key: [value per line]}``.""" reg = registry() abs_snr, det_frac, fdr, fwhm_bias = {}, {}, {}, {} for key in ORDER: abs_snr[key], det_frac[key], fdr[key], fwhm_bias[key] = [], [], [], [] label = reg[key].label for lname, _disp, _rest in LINES: hr_sn = cache.snr[f"HR_{lname}"] sn_m = cache.snr[f"{key}_{lname}"] real = np.isfinite(hr_sn) & (hr_sn > HR_DETECT_THRESH) sub = sn_m[real & np.isfinite(sn_m)] abs_snr[key].append(float(np.median(sub)) if sub.size else np.nan) det_frac[key].append(float(np.nanmean(sn_m > HR_DETECT_THRESH))) if key == "HR": fdr[key].append(np.nan) fwhm_bias[key].append(0.0) # zero by definition continue fit_sn_hr = cache.fits[f"HR target_{lname}_sn"] fit_sn_m = cache.fits[f"{label}_{lname}_sn"] absent = np.isfinite(fit_sn_hr) & (fit_sn_hr < TRUE_ABSENT_THRESH) valid = absent & np.isfinite(fit_sn_m) fdr[key].append(float(np.nanmean(fit_sn_m[valid] > HR_DETECT_THRESH)) if valid.sum() else np.nan) hr_fwhm = 2.355 * cache.fits[f"HR target_{lname}_sigma"] * 1e3 m_fwhm = 2.355 * cache.fits[f"{label}_{lname}_sigma"] * 1e3 hr_sn2 = cache.fits[f"HR target_{lname}_sn"] v = np.isfinite(hr_fwhm) & np.isfinite(m_fwhm) & (hr_sn2 > FWHM_MIN_SN) fwhm_bias[key].append(float(np.nanmedian(m_fwhm[v] - hr_fwhm[v]))) return {"abs_snr": abs_snr, "det_frac": det_frac, "fdr": fdr, "fwhm_bias": fwhm_bias}
[docs] def build(cache=None, outdir: Path | None = None) -> Path: style.use_agg() import matplotlib.patches as mpatches import matplotlib.pyplot as plt style.use_paper_style() cache = cache or load_cache() outdir = Path(outdir) if outdir else paths.figures_dir() outdir.mkdir(parents=True, exist_ok=True) reg = registry() print(cache.summary()) print(f"Loaded fit_data: {len(cache.fits)} arrays") d = compute(cache) methods_show = list(NON_HR) + ["HR"] rows = [d["abs_snr"], d["det_frac"], d["fdr"], d["fwhm_bias"]] row_labels = [ f"Median S/N\n(HR S/N > {HR_DETECT_THRESH} subset)", f"Detection fraction\n(S/N > {HR_DETECT_THRESH})", f"False detection rate\n(HR S/N < {TRUE_ABSENT_THRESH})", "FWHM bias (nm)", ] x_limits = [None, (0, 1.05), (0, 0.75), None] for li, (_lname, disp, _r) in enumerate(LINES): print(f" {disp:24s} median S/N SR2 {d['abs_snr']['SR2'][li]:8.1f}" f" HR {d['abs_snr']['HR'][li]:8.1f}" f" FDR SR2 {d['fdr']['SR2'][li]:.3f}") fig, axes = plt.subplots(4, 4, figsize=(16, 14), sharey="row") for row_i, (data, ylabel, xlim) in enumerate(zip(rows, row_labels, x_limits)): for li, (_lname, ldisplay, _rest) in enumerate(LINES): ax = axes[row_i, li] vals = [data[m][li] for m in methods_show] colours = [reg[m].color for m in methods_show] # The reference has no meaningful false-detection rate or width # bias against itself; leave those bars off rather than at zero. if row_i in (2, 3) and not np.isnan(vals[-1]): vals[-1] = np.nan ax.barh(np.arange(len(methods_show)), vals, color=colours, alpha=0.85) ax.set_yticks(np.arange(len(methods_show))) ax.set_yticklabels([reg[m].label for m in methods_show] if li == 0 else [""] * len(methods_show)) if row_i == 0: ax.set_title(ldisplay) if row_i == 1: ax.axvline(1.0, color="black", lw=0.8, ls="--", alpha=0.5) if xlim: ax.set_xlim(*xlim) ax.set_xlabel(ylabel.replace("\n", " ")) ax.invert_yaxis() axes[row_i, 0].set_ylabel(ylabel) fig.legend(handles=[mpatches.Patch(color=reg[m].color, label=reg[m].label) for m in methods_show], loc="upper center", ncol=len(methods_show), bbox_to_anchor=(0.5, 1.02), fontsize=9, frameon=False, handlelength=1.2, handleheight=0.9) plt.tight_layout() out = outdir / "fig_jades_per_line_snr.pdf" plt.savefig(out, bbox_inches="tight") plt.close(fig) print(f"Saved → {out}") return out