diff --git a/apps/cadecon/src/components/charts/AsymptoteTrends.tsx b/apps/cadecon/src/components/charts/AsymptoteTrends.tsx index b1441bf..ef21250 100644 --- a/apps/cadecon/src/components/charts/AsymptoteTrends.tsx +++ b/apps/cadecon/src/components/charts/AsymptoteTrends.tsx @@ -1,16 +1,21 @@ /** - * Asymptote dashboard: small-multiples view of the four quantities that should - * stabilize across iterations — - * 1. kernel shape (peak time + FWHM) - * 2. normalized bi-exponential fit quality of the free kernel (kernel-fit R²) - * 3. reconstruction quality (median PVE) - * 4. stability of the deconvolved activity traces (iteration-to-iteration Δ) + * Asymptote dashboard: small-multiples view of the quantities that should + * stabilize across iterations, laid out as two rows: + * Top — the convergence signals (decay toward 0, log-y): + * 1. kernel-shape change (peak-normalized RMSE vs the previous iteration) — + * the actual convergence metric, with the tolerance band drawn in + * 2. deconvolved-activity change vs the previous iteration — it trails the + * kernel by ~1 iteration (spikes are inferred from the prior kernel), so + * its marker shows both the gate iteration and where activity settles + * Bottom — the fit-quality signals (rise toward a plateau, linear): + * 3. normalized bi-exponential fit quality of the free kernel (kernel-fit R²) + * 4. reconstruction quality (median PVE) * - * All panels share the iteration x-axis, the convergence marker, and the - * viewed-iteration marker so a reader can see everything settle together. + * All panels share the iteration x-axis and cursor. The raw kernel parameters + * (τ_rise, τ_decay, t_peak, FWHM) live on the Kernel tab. */ -import { createMemo, createEffect, For, Show, type JSX } from 'solid-js'; +import { createMemo, createEffect, For, Show, type JSX, type Accessor } from 'solid-js'; import { SolidUplot } from '@dschz/solid-uplot'; import type uPlot from 'uplot'; import 'uplot/dist/uPlot.min.css'; @@ -20,6 +25,7 @@ import { convergedAtIteration, type KernelSnapshot, } from '../../lib/iteration-store.ts'; +import { convergenceTol, convergencePatience } from '../../lib/algorithm-store.ts'; import { viewedIteration } from '../../lib/viz-store.ts'; import { wheelZoomPlugin, @@ -29,23 +35,33 @@ import { syncCursor, safeRange, METRIC_COLORS, + withOpacity, } from '@calab/ui/chart'; import { convergenceMarkerPlugin } from '../../lib/chart/convergence-marker-plugin.ts'; +import { drawVerticalMarker } from '../../lib/chart/vertical-marker-plugin.ts'; import { viewedIterationPlugin } from '../../lib/chart/viewed-iteration-plugin.ts'; -// Colorblind-safe Okabe-Ito metric colors (shared palette). t_peak and FWHM -// never appear as a red/green pair. -const TPEAK_COLOR = METRIC_COLORS.tPeak; -const FWHM_COLOR = METRIC_COLORS.fwhm; +// Colorblind-safe Okabe-Ito metric colors (shared palette). +const RMSE_COLOR = METRIC_COLORS.tPeak; +const STABILITY_COLOR = METRIC_COLORS.stability; const R2_COLOR = METRIC_COLORS.r2; const PVE_COLOR = METRIC_COLORS.pve; -const STABILITY_COLOR = METRIC_COLORS.stability; +/** Convergence green — matches the vertical convergence marker. */ +const CONVERGED_COLOR = '#4caf50'; +const CONVERGED_LABEL = '#388e3c'; +/** + * Iterations by which activity trails the kernel: spikes at iteration k are + * inferred from iteration k−1's kernel, so activity settles exactly one + * iteration after the kernel-RMSE gate fires. + */ +const ACTIVITY_LAG_ITERS = 1; + +type HistoryAccessor = Accessor; interface SeriesDef { label: string; color: string; pick: (s: KernelSnapshot) => number | null; - scale?: number; } interface PanelDef { @@ -54,16 +70,9 @@ interface PanelDef { series: SeriesDef[]; } -// Static panel descriptors — defined once so never recreates the charts. +// Linear plateau panels (bottom row). The two decay signals are dedicated log-y +// panels rendered directly in AsymptoteTrends. const PANELS: PanelDef[] = [ - { - title: 'Kernel shape', - unit: 'ms', - series: [ - { label: 't_peak', color: TPEAK_COLOR, pick: (s) => s.tPeak, scale: 1000 }, - { label: 'FWHM', color: FWHM_COLOR, pick: (s) => s.fwhm, scale: 1000 }, - ], - }, { title: 'Kernel fit R²', unit: 'R²', @@ -74,45 +83,166 @@ const PANELS: PanelDef[] = [ unit: 'PVE', series: [{ label: 'median PVE', color: PVE_COLOR, pick: (s) => s.medianPve }], }, - { - title: 'Activity stability', - unit: 'Δ (norm.)', - series: [{ label: 'trace Δ', color: STABILITY_COLOR, pick: (s) => s.traceStability }], - }, ]; // Degenerate-span guards (uPlot throws in drawAxesGrid on a zero span). Y pads -// 10%; X uses the exact iteration extent (the createEffect below drives the -// real x-scale — these are the fallbacks). +// 10%; X uses the exact iteration extent (TrendChart's effect drives the real +// x-scale — these are the fallbacks). const yRange = safeRange(0.1); const xRange = safeRange(0); -/** One compact trend chart; reads convergence history reactively. */ -function MiniTrend(props: { panel: PanelDef }): JSX.Element { - let uplot: uPlot | null = null; - - const history = createMemo(() => convergenceHistory().filter((s) => s.iteration > 0)); +/** Positive log-y range fallback (used only before the reactive effect sets the scale). */ +function logYRangeFallback(_u: uPlot, dataMin: number, dataMax: number): [number, number] { + const lo = dataMin > 0 && isFinite(dataMin) ? dataMin / 10 : 1e-4; + const hi = dataMax > 0 && isFinite(dataMax) ? dataMax * 1.5 : 1e-1; + return [Math.max(1e-6, lo), Math.max(hi, lo * 10)]; +} - const data = createMemo(() => { - const h = history(); - const x = h.map((s) => s.iteration); - const cols = props.panel.series.map((sd) => - h.map((s) => { - const v = sd.pick(s); - return v == null ? null : v * (sd.scale ?? 1); - }), - ); - return [x, ...cols] as uPlot.AlignedData; +/** Compact log-axis tick formatter (e.g. 0.1, 0.01, 1e-3). */ +function logAxisValues(_u: uPlot, splits: number[]): (string | null)[] { + return (splits ?? []).map((v) => { + if (v <= 0) return ''; + const rounded = Number(v.toPrecision(1)); + return rounded >= 0.001 ? String(rounded) : rounded.toExponential(0); }); +} + +/** + * Shade the "within tolerance" region (RMSE below convTol) and draw the + * threshold line. Reactive to the convTol parameter via `getTol`. + */ +function toleranceBandPlugin(getTol: () => number): uPlot.Plugin { + return { + hooks: { + draw(u: uPlot) { + const tol = getTol(); + if (!tol || !isFinite(tol)) return; + const yMin = u.scales.y.min; + const yMax = u.scales.y.max; + if (yMin == null || yMax == null) return; + + const ctx = u.ctx; + const dpr = devicePixelRatio; + const { left, width, top, height } = u.bbox; + const bottom = top + height; + const tolClamped = Math.min(Math.max(tol, yMin), yMax); + const yTol = u.valToPos(tolClamped, 'y', true); + + ctx.save(); + ctx.fillStyle = withOpacity(CONVERGED_COLOR, 0.1); + ctx.fillRect(left, yTol, width, bottom - yTol); + ctx.strokeStyle = CONVERGED_COLOR; + ctx.lineWidth = 1.5 * dpr; + ctx.setLineDash([4 * dpr, 3 * dpr]); + ctx.beginPath(); + ctx.moveTo(left, yTol); + ctx.lineTo(left + width, yTol); + ctx.stroke(); + ctx.setLineDash([]); + ctx.font = `${9 * dpr}px sans-serif`; + ctx.fillStyle = CONVERGED_COLOR; + ctx.textAlign = 'right'; + ctx.textBaseline = 'bottom'; + ctx.fillText(`tol ${tol.toFixed(3)}`, left + width - 2 * dpr, yTol - 2 * dpr); + ctx.restore(); + }, + }, + }; +} + +/** Faintly shade the iteration span [a, b] (in x-scale units). */ +function shadeIterSpan(u: uPlot, a: number, b: number, fill: string): void { + const xMin = u.scales.x.min; + const xMax = u.scales.x.max; + if (xMin == null || xMax == null) return; + const lo = Math.max(a, xMin); + const hi = Math.min(b, xMax); + if (hi < lo) return; + const ctx = u.ctx; + const { top, height } = u.bbox; + const xa = u.valToPos(lo, 'x', true); + const xb = u.valToPos(hi, 'x', true); + ctx.save(); + ctx.fillStyle = fill; + ctx.fillRect(xa, top, Math.max(xb - xa, 1), height); + ctx.restore(); +} + +/** + * Highlight the `patience` consecutive in-band iterations that triggered + * convergence — [convergedAt, convergedAt + patience − 1]. + */ +function patienceBandPlugin( + getConvergedAt: () => number | null, + getPatience: () => number, +): uPlot.Plugin { + return { + hooks: { + draw(u: uPlot) { + const c = getConvergedAt(); + if (c == null) return; + const p = Math.max(1, getPatience()); + shadeIterSpan(u, c, c + p - 1, withOpacity(CONVERGED_COLOR, 0.08)); + }, + }, + }; +} + +/** + * Convergence marker for the activity panel. Because activity trails the kernel + * by ACTIVITY_LAG_ITERS (spikes are inferred from the previous iteration's + * kernel), this draws the gate iteration where the kernel converged AND a fainter + * marker where the activity actually settles, with the lag shaded between them — + * so it's clear the gate fired a step before activity flattens. + */ +function laggedActivityMarkerPlugin(getConvergedAt: () => number | null): uPlot.Plugin { + return { + hooks: { + draw(u: uPlot) { + const c = getConvergedAt(); + if (c == null) return; + const cLag = c + ACTIVITY_LAG_ITERS; + shadeIterSpan(u, c, cLag, withOpacity(CONVERGED_COLOR, 0.08)); + drawVerticalMarker(u, c, { + stroke: CONVERGED_COLOR, + labelColor: CONVERGED_LABEL, + label: 'gate', + dash: [4, 3], + }); + drawVerticalMarker(u, cLag, { + stroke: withOpacity(CONVERGED_COLOR, 0.5), + labelColor: withOpacity(CONVERGED_LABEL, 0.8), + label: `settles +${ACTIVITY_LAG_ITERS}`, + dash: [4, 3], + }); + }, + }, + }; +} + +/** + * Shared compact trend chart: owns the uPlot ref, cursor, cell markup, and the + * x-scale sync. Callers supply the data, series/axes/scales/plugins, and a + * y-range computed from the data (log or linear). `track` names extra reactive + * signals that should force a redraw (e.g. viewed iteration, convergence marker). + */ +function TrendChart(props: { + title: string; + data: Accessor; + series: uPlot.Series[]; + axes: uPlot.Axis[]; + scales: uPlot.Scales; + plugins: uPlot.Plugin[]; + computeYRange: (data: uPlot.AlignedData, u: uPlot) => [number, number]; + track?: () => void; +}): JSX.Element { + let uplot: uPlot | null = null; - // Keep the scales tracking the data. SolidUplot updates data without resetting - // scales (to preserve zoom), so the x-scale would otherwise stay frozen at the - // extent from an earlier iteration count. Recompute both scales from the data, - // and redraw so the convergence / viewed-iteration markers stay in sync. + // SolidUplot preserves scales across data updates (to keep zoom), so recompute + // both scales from the data and redraw so markers/bands stay in sync. createEffect(() => { - const d = data(); - viewedIteration(); - convergedAtIteration(); + const d = props.data(); + props.track?.(); const u = uplot; if (!u) return; const xs = d[0]; @@ -126,23 +256,118 @@ function MiniTrend(props: { panel: PanelDef }): JSX.Element { xmin -= 0.5; xmax += 0.5; } - let ymin = Infinity; - let ymax = -Infinity; - for (let si = 1; si < d.length; si++) { - for (const v of d[si]) { - if (v != null && isFinite(v)) { - if (v < ymin) ymin = v; - if (v > ymax) ymax = v; - } - } - } - const [yLo, yHi] = isFinite(ymin) ? yRange(u, ymin, ymax) : [0, 1]; + const [yLo, yHi] = props.computeYRange(d, u); u.batch(() => { u.setScale('x', { min: xmin, max: xmax }); u.setScale('y', { min: yLo, max: yHi }); }); }); + const cursor = syncCursor('cadecon-asymptote'); + + return ( +
+
+ {props.title} +
+ (uplot = u)} + /> +
+ ); +} + +/** A log-y "decay toward 0" panel (kernel RMSE, activity change). */ +function LogDecayTrend(props: { + history: HistoryAccessor; + title: string; + unit: string; + seriesLabel: string; + color: string; + pick: (s: KernelSnapshot) => number | null; + includeTolInRange?: boolean; + extraPlugins: uPlot.Plugin[]; +}): JSX.Element { + const data = createMemo(() => { + const h = props.history(); + const x = h.map((s) => s.iteration); + // Non-positive values are invalid on a log axis; drop them to gaps. + const y = h.map((s) => { + const v = props.pick(s); + return v != null && v > 0 ? v : null; + }); + return [x, y] as uPlot.AlignedData; + }); + + const series: uPlot.Series[] = [ + {}, + { label: props.seriesLabel, stroke: props.color, width: 2, points: { show: true, size: 5 } }, + ]; + const axes: uPlot.Axis[] = [ + chartAxis({ size: 24, values: integerTickValues }), + labeledAxis(props.unit, { size: 44, values: logAxisValues }), + ]; + const plugins = [ + ...props.extraPlugins, + viewedIterationPlugin(() => viewedIteration()), + wheelZoomPlugin(), + ]; + + const computeYRange = (d: uPlot.AlignedData): [number, number] => { + let lo = Infinity; + let hi = -Infinity; + for (const v of d[1] as (number | null)[]) { + if (v != null && isFinite(v) && v > 0) { + if (v < lo) lo = v; + if (v > hi) hi = v; + } + } + if (props.includeTolInRange) { + const tol = convergenceTol(); + if (isFinite(tol) && tol > 0) { + lo = Math.min(lo, tol); + hi = Math.max(hi, tol); + } + } + return isFinite(lo) ? [Math.max(1e-6, lo / 10), hi * 1.5] : [1e-4, 1e-1]; + }; + + return ( + { + viewedIteration(); + convergedAtIteration(); + convergencePatience(); + }} + /> + ); +} + +/** A linear "rise toward a plateau" panel (R², PVE). */ +function MiniTrend(props: { history: HistoryAccessor; panel: PanelDef }): JSX.Element { + const data = createMemo(() => { + const h = props.history(); + const x = h.map((s) => s.iteration); + const cols = props.panel.series.map((sd) => h.map((s) => sd.pick(s))); + return [x, ...cols] as uPlot.AlignedData; + }); + const series: uPlot.Series[] = [ {}, ...props.panel.series.map((s) => ({ @@ -152,55 +377,60 @@ function MiniTrend(props: { panel: PanelDef }): JSX.Element { points: { show: true, size: 5 }, })), ]; - const axes: uPlot.Axis[] = [ chartAxis({ size: 24, values: integerTickValues }), labeledAxis(props.panel.unit, { size: 38 }), ]; - const plugins = [ convergenceMarkerPlugin(() => convergedAtIteration()), viewedIterationPlugin(() => viewedIteration()), wheelZoomPlugin(), ]; - const cursor = syncCursor('cadecon-asymptote'); + const computeYRange = (d: uPlot.AlignedData, u: uPlot): [number, number] => { + let ymin = Infinity; + let ymax = -Infinity; + for (let si = 1; si < d.length; si++) { + for (const v of d[si]) { + if (v != null && isFinite(v)) { + if (v < ymin) ymin = v; + if (v > ymax) ymax = v; + } + } + } + return isFinite(ymin) ? yRange(u, ymin, ymax) : [0, 1]; + }; return ( -
-
- {props.panel.title} - 1}> - - - {(s) => ( - - - {s.label} - - )} - - - -
- (uplot = u)} - /> -
+ { + viewedIteration(); + convergedAtIteration(); + }} + /> ); } export function AsymptoteTrends(): JSX.Element { - const hasData = createMemo(() => convergenceHistory().some((s) => s.iteration > 0)); + const history = createMemo(() => convergenceHistory().filter((s) => s.iteration > 0)); + const hasData = createMemo(() => history().length > 0); + + const kernelPlugins = [ + toleranceBandPlugin(() => convergenceTol()), + patienceBandPlugin( + () => convergedAtIteration(), + () => convergencePatience(), + ), + convergenceMarkerPlugin(() => convergedAtIteration()), + ]; + const activityPlugins = [laggedActivityMarkerPlugin(() => convergedAtIteration())]; return (
- {(panel) => } + s.kernelRmse} + includeTolInRange + extraPlugins={kernelPlugins} + /> + s.traceStability} + extraPlugins={activityPlugins} + /> + {(panel) => }
); diff --git a/apps/cadecon/src/lib/__tests__/iteration-manager.test.ts b/apps/cadecon/src/lib/__tests__/iteration-manager.test.ts index a75a17f..39167a2 100644 --- a/apps/cadecon/src/lib/__tests__/iteration-manager.test.ts +++ b/apps/cadecon/src/lib/__tests__/iteration-manager.test.ts @@ -279,7 +279,7 @@ describe('iteration-manager: startRun dispatch sequence', () => { it('converges early when the kernel shape stabilises (patience streak)', async () => { // The fake pool always returns the same tauRise/tauDecay, so every - // iteration's (tPeak, FWHM) is identical → shapeDelta ~ 0 < convTol. After + // iteration's kernel is identical → kernelRmse ~ 0 < convTol. After // `patience` consecutive stable iterations (past minIters) the loop stops and // records the FIRST iteration of the confirming window. seedMinimalRun(); diff --git a/apps/cadecon/src/lib/__tests__/iteration-store.test.ts b/apps/cadecon/src/lib/__tests__/iteration-store.test.ts index 6e02827..c2ddffe 100644 --- a/apps/cadecon/src/lib/__tests__/iteration-store.test.ts +++ b/apps/cadecon/src/lib/__tests__/iteration-store.test.ts @@ -186,7 +186,7 @@ describe('iteration-store: derived memos', () => { fs: 30, tPeak: 0.05, fwhm: 0.2, - shapeDelta: null, + kernelRmse: null, riseUnresolved: false, kernelFitR2: null, medianPve: null, @@ -250,7 +250,7 @@ describe('iteration-store: history actions', () => { fs: 30, tPeak: null, fwhm: null, - shapeDelta: null, + kernelRmse: null, riseUnresolved: false, kernelFitR2: null, medianPve: null, @@ -355,7 +355,7 @@ describe('iteration-store: resetIterationState', () => { fs: 30, tPeak: null, fwhm: null, - shapeDelta: null, + kernelRmse: null, riseUnresolved: false, kernelFitR2: null, medianPve: null, diff --git a/apps/cadecon/src/lib/algorithm-store.ts b/apps/cadecon/src/lib/algorithm-store.ts index bb96c60..3fb0340 100644 --- a/apps/cadecon/src/lib/algorithm-store.ts +++ b/apps/cadecon/src/lib/algorithm-store.ts @@ -17,11 +17,12 @@ const [noiseConstrained, setNoiseConstrained] = createSignal(true); const [sparsityCompareEnabled, setSparsityCompareEnabled] = createSignal(false); const [maxIterations, setMaxIterations] = createSignal(20); -// Convergence is tested in kernel SHAPE space (peak time + FWHM). convergenceTol -// is the relative change of BOTH peak and FWHM below which an iteration counts as -// stable; patience/minIters gate when convergence may be declared; the final -// kernel is the median of the last `finalSelectionWindow` iterates' shapes. -// Defaults live in @calab/core CONVERGENCE_RANGES (single source of truth). +// Convergence is tested with the peak-normalized RMSE between successive +// iterations' kernels. convergenceTol is the fraction-of-peak change below which +// an iteration counts as stable; patience/minIters gate when convergence may be +// declared; the final kernel is the median of the last `finalSelectionWindow` +// iterates' shapes. Defaults live in @calab/core CONVERGENCE_RANGES (single +// source of truth). const [convergenceTol, setConvergenceTol] = createSignal( CONVERGENCE_RANGES.convergenceTol.default, ); diff --git a/apps/cadecon/src/lib/chart/vertical-marker-plugin.ts b/apps/cadecon/src/lib/chart/vertical-marker-plugin.ts index 2da00e3..765fd1c 100644 --- a/apps/cadecon/src/lib/chart/vertical-marker-plugin.ts +++ b/apps/cadecon/src/lib/chart/vertical-marker-plugin.ts @@ -2,6 +2,48 @@ import type uPlot from 'uplot'; +interface VerticalMarkerStyle { + stroke: string; + labelColor: string; + label?: string; + dash?: number[]; +} + +/** + * Draw one labeled dashed vertical marker at data-x `value` (self-contained: + * saves/restores ctx; no-op if `value` is outside the x-scale). Shared by + * verticalMarkerPlugin and any plugin that draws several markers in one hook. + */ +export function drawVerticalMarker(u: uPlot, value: number, style: VerticalMarkerStyle): void { + const xMin = u.scales.x.min; + const xMax = u.scales.x.max; + if (xMin == null || xMax == null || value < xMin || value > xMax) return; + + const ctx = u.ctx; + const dpr = devicePixelRatio; + const { top, height } = u.bbox; + const xPx = u.valToPos(value, 'x', true); + + ctx.save(); + ctx.strokeStyle = style.stroke; + ctx.lineWidth = 1.5 * dpr; + ctx.setLineDash((style.dash ?? []).map((d) => d * dpr)); + ctx.beginPath(); + ctx.moveTo(xPx, top); + ctx.lineTo(xPx, top + height); + ctx.stroke(); + + if (style.label) { + ctx.setLineDash([]); + ctx.font = `${9 * dpr}px sans-serif`; + ctx.fillStyle = style.labelColor; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(style.label, xPx, top - 2 * dpr); + } + ctx.restore(); +} + interface VerticalMarkerOptions { getValue: () => number | null; label: (value: number) => string; @@ -16,37 +58,12 @@ export function verticalMarkerPlugin(opts: VerticalMarkerOptions): uPlot.Plugin draw(u: uPlot) { const value = opts.getValue(); if (value == null) return; - - const xMin = u.scales.x.min!; - const xMax = u.scales.x.max!; - if (value < xMin || value > xMax) return; - - const ctx = u.ctx; - const dpr = devicePixelRatio; - const { top, height } = u.bbox; - const xPx = u.valToPos(value, 'x', true); - - ctx.save(); - - // Vertical line - ctx.strokeStyle = opts.strokeColor; - ctx.lineWidth = 1.5 * dpr; - ctx.setLineDash((opts.dash ?? []).map((d) => d * dpr)); - ctx.beginPath(); - ctx.moveTo(xPx, top); - ctx.lineTo(xPx, top + height); - ctx.stroke(); - - // Label - const fontSize = 9 * dpr; - ctx.font = `${fontSize}px sans-serif`; - ctx.setLineDash([]); - ctx.fillStyle = opts.labelColor; - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText(opts.label(value), xPx, top - 2 * dpr); - - ctx.restore(); + drawVerticalMarker(u, value, { + stroke: opts.strokeColor, + labelColor: opts.labelColor, + label: opts.label(value), + dash: opts.dash, + }); }, }, }; diff --git a/apps/cadecon/src/lib/iteration-manager.ts b/apps/cadecon/src/lib/iteration-manager.ts index 1a510eb..48d08cf 100644 --- a/apps/cadecon/src/lib/iteration-manager.ts +++ b/apps/cadecon/src/lib/iteration-manager.ts @@ -8,7 +8,13 @@ // 5. On convergence/max iters: finalization pass on ALL cells import { batch } from 'solid-js'; -import { tauToShape, shapeToTau, type WorkerPool } from '@calab/compute'; +import { + tauToShape, + shapeToTau, + kernelShapeRmse, + KERNEL_DURATION_MULTIPLE, + type WorkerPool, +} from '@calab/compute'; import { createCaDeconWorkerPool, type CaDeconPoolJob } from './cadecon-pool.ts'; import type { TraceResult, @@ -79,8 +85,6 @@ export const BIEXP_FIT_SKIP = 0; */ type KernelJobResult = KernelResult & { subsetIdx: number }; -/** Denominator guard for relative shape deltas (peak/FWHM are in seconds). */ -const SHAPE_EPS = 1e-9; /** Absolute floor of the Rust tau_rise clamp (mirrors biexp_fit.rs tau_r_lo). */ const RISE_CLAMP_FLOOR_S = 0.005; /** tau_rise within this factor of the clamp floor is flagged "rise unresolved". */ @@ -100,7 +104,14 @@ function sumSq(a: ArrayLike): number { /** * Median normalized L2 change in per-cell activity between two iterations' * stitched s_counts maps: median over cells present in both (and non-silent) of - * ||s_new - s_old|| / (||s_new|| + eps). Returns null if no comparable cells. + * ||s_new - s_old|| / (max(||s_new||, ||s_old||) + eps). Returns null if no + * comparable cells. + * + * The symmetric max(...) denominator keeps the ratio bounded to [0, sqrt(2)] and + * stops it from exploding when a cell's activity collapses toward zero between + * iterations (which a ||s_new||-only denominator would). s_counts are native-rate + * bin counts (the solver downsamples the upsampled train before returning), so + * this measures change at the resolution the data actually constrains. */ function computeTraceStability( prev: Map | undefined, @@ -112,13 +123,15 @@ function computeTraceStability( const sOld = prev.get(cell); if (!sOld || sOld.length !== sNew.length) continue; const normNew = Math.sqrt(sumSq(sNew)); - if (normNew < STABILITY_MIN_ACTIVITY) continue; + const normOld = Math.sqrt(sumSq(sOld)); + const denom = Math.max(normNew, normOld); + if (denom < STABILITY_MIN_ACTIVITY) continue; let diff = 0; for (let i = 0; i < sNew.length; i++) { const d = sNew[i] - sOld[i]; diff += d * d; } - deltas.push(Math.sqrt(diff) / (normNew + STABILITY_EPS)); + deltas.push(Math.sqrt(diff) / (denom + STABILITY_EPS)); } return deltas.length > 0 ? median(deltas) : null; } @@ -438,7 +451,8 @@ export async function startRun(): Promise { let tauD = TAU_DECAY_FALLBACK; const upFactor = upsampleFactor(); const maxIter = maxIterations(); - // Shape-space convergence controls (see algorithm-store / CONVERGENCE_RANGES). + // Convergence controls (see algorithm-store / CONVERGENCE_RANGES). convTol is + // the kernel-RMSE threshold; selWindow is still a shape-space (median) control. const convTol = convergenceTol(); const patience = convergencePatience(); const minIters = convergenceMinIters(); @@ -502,22 +516,26 @@ export async function startRun(): Promise { return; } - // Kernel length: 5x tau_decay in samples (matches CaTune's computeKernel convention) - const kernelLength = Math.max(10, Math.ceil(5.0 * tauD * fs)); + // Kernel length: KERNEL_DURATION_MULTIPLE x tau_decay in samples (matches CaTune's computeKernel convention) + const kernelLength = Math.max(10, Math.ceil(KERNEL_DURATION_MULTIPLE * tauD * fs)); // Warm-start state carried between iterations let prevTraceCounts: Map | undefined; let prevKernels: Float32Array[] | undefined; let prevBiexpResults: WarmBiexp[] | undefined; - // Shape-space convergence tracking. (tau_rise, tau_decay) is a degenerate - // convergence coordinate (tau_rise <-> tau_decay thrash inflates the delta), so - // we test convergence in (tPeak, FWHM) space: an iteration is "stable" when both - // move less than convTol relative to the previous one, and we declare - // convergence after `patience` consecutive stable iterations. The final kernel - // is the median of the last `selWindow` iterates' shapes — not the argmin of the - // (bouncy, unreliable) bi-exponential residual, which the old revert used. + // Convergence tracking. We test convergence with the peak-normalized RMSE + // between successive iterations' bi-exponential kernels (kernelShapeRmse): an + // iteration is "stable" when that whole-waveform change is < convTol, and we + // declare convergence after `patience` consecutive stable iterations. RMSE on + // the waveform avoids the old (tPeak, FWHM) relative delta's over-sensitivity to + // t_peak jitter on the poorly-constrained rising edge. (tPeak/FWHM are still + // tracked for the Kernel-tab diagnostics and the final-selection step.) The + // final kernel is the median of the last `selWindow` iterates' shapes — not the + // argmin of the (bouncy, unreliable) bi-exponential residual. let prevShape = tauToShape(tauR, tauD); + let prevTauR = tauR; + let prevTauD = tauD; let stableCount = 0; let firstStableIter: number | null = null; const shapeTrail: Array<{ tauRise: number; tauDecay: number; tPeak: number; fwhm: number }> = []; @@ -536,7 +554,7 @@ export async function startRun(): Promise { fs, tPeak: prevShape?.tPeak ?? null, fwhm: prevShape?.fwhm ?? null, - shapeDelta: null, + kernelRmse: null, riseUnresolved: false, kernelFitR2: null, medianPve: null, @@ -783,13 +801,13 @@ export async function startRun(): Promise { tauR = median(tauRises); tauD = median(tauDecays); - // Convergence coordinate: kernel shape (peak time + FWHM). + // Convergence metric: peak-normalized RMSE between this iteration's kernel and + // the previous one (fraction of peak, → 0 at convergence). A degenerate shape + // (current or previous) leaves it null, which resets the stability streak. const shape = tauToShape(tauR, tauD); - let shapeDelta: number | null = null; + let kernelRmse: number | null = null; if (shape && prevShape) { - const dPeak = Math.abs(shape.tPeak - prevShape.tPeak) / (prevShape.tPeak + SHAPE_EPS); - const dFwhm = Math.abs(shape.fwhm - prevShape.fwhm) / (prevShape.fwhm + SHAPE_EPS); - shapeDelta = Math.max(dPeak, dFwhm); + kernelRmse = kernelShapeRmse(prevTauR, prevTauD, tauR, tauD, fs); } const riseUnresolved = tauR <= tauRiseFloor * RISE_FLOOR_MARGIN; if (shape) { @@ -834,7 +852,7 @@ export async function startRun(): Promise { fs, tPeak: shape?.tPeak ?? null, fwhm: shape?.fwhm ?? null, - shapeDelta, + kernelRmse, riseUnresolved, kernelFitR2, medianPve, @@ -855,18 +873,23 @@ export async function startRun(): Promise { }); }); - // Step 4: Convergence check in shape space. An iteration is "stable" when - // both peak time and FWHM change less than convTol; convergence is declared - // after `patience` consecutive stable iterations, once past `minIters`. A - // degenerate (null) shape resets the streak — it can never count as stable. - if (shape && shapeDelta !== null && iter + 1 >= minIters && shapeDelta < convTol) { + // Step 4: Convergence check on kernel-shape RMSE. An iteration is "stable" + // when the peak-normalized kernel changed less than convTol vs the previous + // iteration; convergence is declared after `patience` consecutive stable + // iterations, once past `minIters`. A null RMSE (degenerate current/previous + // shape) resets the streak — it can never count as stable. + if (kernelRmse !== null && iter + 1 >= minIters && kernelRmse < convTol) { if (stableCount === 0) firstStableIter = iter + 1; stableCount++; } else { stableCount = 0; firstStableIter = null; } - if (shape) prevShape = shape; + if (shape) { + prevShape = shape; + prevTauR = tauR; + prevTauD = tauD; + } if (stableCount >= patience) { setConvergedAtIteration(firstStableIter); diff --git a/apps/cadecon/src/lib/iteration-store.ts b/apps/cadecon/src/lib/iteration-store.ts index 79e131d..bedadc4 100644 --- a/apps/cadecon/src/lib/iteration-store.ts +++ b/apps/cadecon/src/lib/iteration-store.ts @@ -28,12 +28,16 @@ export interface KernelSnapshot { tauDecayFast: number; betaFast: number; fs: number; - /** Kernel peak time (s), the convergence coordinate. null if shape is degenerate. */ + /** Kernel peak time (s), the final-selection coordinate / Kernel-tab diagnostic. null if degenerate. */ tPeak: number | null; - /** Kernel full-width-half-max (s), the convergence coordinate. null if degenerate. */ + /** Kernel full-width-half-max (s), the final-selection coordinate / Kernel-tab diagnostic. null if degenerate. */ fwhm: number | null; - /** Max relative change of tPeak/FWHM vs the previous iteration. null on iter 0 / degenerate. */ - shapeDelta: number | null; + /** + * Peak-normalized RMSE between this iteration's kernel and the previous one + * (fraction of peak, → 0 at convergence) — the convergence metric. null on + * iter 0 / degenerate shape. + */ + kernelRmse: number | null; /** True when tau_rise is pinned at the sampling-rate clamp floor (rise unresolved at this fs). */ riseUnresolved: boolean; // --- Asymptote diagnostics (per iteration) --- diff --git a/apps/cadecon/src/lib/tutorial/content/01-basics.ts b/apps/cadecon/src/lib/tutorial/content/01-basics.ts index 41bc272..7ba03c0 100644 --- a/apps/cadecon/src/lib/tutorial/content/01-basics.ts +++ b/apps/cadecon/src/lib/tutorial/content/01-basics.ts @@ -67,7 +67,7 @@ export const basicsTutorial: Tutorial = { element: '[data-tutorial="kernel-convergence"]', title: 'Watching It Converge', description: - 'As the run iterates, these tabbed charts track the learning process. Asymptote (shown first) is the at-a-glance health check \u2014 small charts of the four things that should flatten out as the run settles: kernel shape (peak time & FWHM), kernel-fit quality (R\u00b2), reconstruction quality (PVE), and activity stability. Kernel shows the kernel shape evolving over iterations with the per-subset spread; Distributions shows per-cell histograms once the run finishes. A good run shows the Asymptote curves leveling off \u2014 don\u2019t judge a fit until the run has converged.', + 'As the run iterates, these tabbed charts track the learning process. Asymptote (shown first) is the at-a-glance health check \u2014 small charts of the signals that should settle as the run converges: the kernel-change RMSE and activity change (both decaying toward 0 / the tolerance band), plus kernel-fit quality (R\u00b2) and reconstruction quality (PVE). Kernel shows the kernel shape evolving over iterations with the per-subset spread; Distributions shows per-cell histograms once the run finishes. A good run shows the Asymptote curves leveling off \u2014 don\u2019t judge a fit until the run has converged.', side: 'bottom', popoverClass: 'driver-popover--wide', }, diff --git a/apps/cadecon/src/lib/tutorial/content/03-theory.ts b/apps/cadecon/src/lib/tutorial/content/03-theory.ts index 6e96349..bdfd111 100644 --- a/apps/cadecon/src/lib/tutorial/content/03-theory.ts +++ b/apps/cadecon/src/lib/tutorial/content/03-theory.ts @@ -99,7 +99,7 @@ export const theoryTutorial: Tutorial = { { title: 'How does CaDecon know when to stop?', description: - 'More iterations aren\u2019t always better. Once the kernel stops changing much from one pass to the next, extra iterations add little \u2014 and the fit can even drift after it has peaked. Rather than watch the raw time constants (which can trade off against each other), CaDecon judges convergence by the kernel\u2019s shape \u2014 its rise-to-peak time and its width (FWHM). An iteration counts as stable when both change by less than a small tolerance, and CaDecon declares convergence only after several consecutive stable iterations, so one lucky pass can\u2019t stop it early. In practice a good solution usually emerges within just a handful of iterations.

' + + 'More iterations aren\u2019t always better. Once the kernel stops changing much from one pass to the next, extra iterations add little \u2014 and the fit can even drift after it has peaked. Rather than watch the raw time constants (which can trade off against each other), CaDecon judges convergence by how much the kernel\u2019s overall shape changes from one pass to the next \u2014 the typical difference between the two kernel waveforms, measured as a small fraction of the kernel\u2019s peak. An iteration counts as stable when that change falls below a small tolerance, and CaDecon declares convergence only after several consecutive stable iterations, so one lucky pass can\u2019t stop it early. In practice a good solution usually emerges within just a handful of iterations.

' + 'To guard against any late drift, the reported kernel is the consensus (the median shape) of the last few iterations, not whatever the final pass happened to produce. You can make the run stop sooner or keep refining with the Convergence Tolerance setting. There is also a maximum-iteration limit, but that is only a safety cap for a run that never settles \u2014 not how a healthy run should normally end. With a stable kernel in hand, CaDecon runs one final inference across every cell, which is why results appear for all your cells, not just the subsets it learned from.', }, // Step 12: Completion diff --git a/apps/cadecon/src/lib/tutorial/content/04-interpreting.ts b/apps/cadecon/src/lib/tutorial/content/04-interpreting.ts index 09ce4ad..075617f 100644 --- a/apps/cadecon/src/lib/tutorial/content/04-interpreting.ts +++ b/apps/cadecon/src/lib/tutorial/content/04-interpreting.ts @@ -22,7 +22,7 @@ export const interpretingTutorial: Tutorial = { element: '[data-tutorial="kernel-convergence"]', title: 'Signs of Healthy Convergence', description: - 'Start on the Asymptote tab — its four small charts are your first check. Kernel shape (peak time & FWHM), kernel-fit R², reconstruction PVE, and activity stability should all flatten toward a plateau as the run settles; the green marker shows the iteration where CaDecon declared convergence. The Kernel tab shows the shape itself over iterations, and Distributions shows the per-cell alpha, PVE, and event-rate spread. Warning signs: curves still moving when the run ends (the kernel never stabilized — usually low SNR or an unlucky subset draw; try higher coverage or re-tiling), or a sharp reset mid-run followed by divergence.', + 'Start on the Asymptote tab — its four small charts are your first check. The kernel-change RMSE and activity change should decay toward 0 (the RMSE toward its tolerance band), while kernel-fit R² and reconstruction PVE rise to a plateau, as the run settles; the green marker shows the iteration where CaDecon declared convergence. The Kernel tab shows the shape itself over iterations, and Distributions shows the per-cell alpha, PVE, and event-rate spread. Warning signs: curves still moving when the run ends (the kernel never stabilized — usually low SNR or an unlucky subset draw; try higher coverage or re-tiling), or a sharp reset mid-run followed by divergence.', side: 'bottom', popoverClass: 'driver-popover--wide', }, diff --git a/apps/cadecon/src/styles/controls.css b/apps/cadecon/src/styles/controls.css index 25ce931..d9436f3 100644 --- a/apps/cadecon/src/styles/controls.css +++ b/apps/cadecon/src/styles/controls.css @@ -533,28 +533,6 @@ padding-bottom: 2px; } -.asymptote-cell__legend { - display: inline-flex; - gap: var(--space-sm); - text-transform: none; - letter-spacing: 0; - color: var(--text-tertiary); - font-weight: var(--font-weight-normal); -} - -.asymptote-cell__legend-item { - display: inline-flex; - align-items: center; - gap: 3px; -} - -.asymptote-cell__swatch { - width: 8px; - height: 8px; - border-radius: 2px; - display: inline-block; -} - /* ======================== Progress Bar ======================== */ diff --git a/packages/compute/src/__tests__/kernel-math.test.ts b/packages/compute/src/__tests__/kernel-math.test.ts index 42052b2..803efae 100644 --- a/packages/compute/src/__tests__/kernel-math.test.ts +++ b/packages/compute/src/__tests__/kernel-math.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { computeKernel } from '@calab/compute'; +import { computeKernel, kernelShapeRmse, KERNEL_RMSE_MIN_SAMPLES } from '@calab/compute'; describe('computeKernel', () => { const tauRise = 0.02; @@ -69,3 +69,38 @@ describe('computeKernel', () => { expect(y[y.length - 1]).toBeLessThan(0.05); }); }); + +describe('kernelShapeRmse', () => { + const fs = 30; + + it('is 0 for identical kernels', () => { + expect(kernelShapeRmse(0.05, 0.5, 0.05, 0.5, fs)).toBe(0); + }); + + it('is symmetric', () => { + const a = kernelShapeRmse(0.05, 0.5, 0.06, 0.55, fs); + const b = kernelShapeRmse(0.06, 0.55, 0.05, 0.5, fs); + expect(a).toBeCloseTo(b, 12); + }); + + it('grows with the size of the shape change', () => { + const small = kernelShapeRmse(0.05, 0.5, 0.05, 0.505, fs); // 1% tau_decay change + const large = kernelShapeRmse(0.05, 0.5, 0.05, 0.55, fs); // 10% tau_decay change + expect(small).toBeGreaterThan(0); + expect(large).toBeGreaterThan(small); + }); + + it('reports a small change as a small fraction of peak', () => { + // ~2% tau_decay change should land near the 0.005 default tolerance scale. + const d = kernelShapeRmse(0.05, 0.5, 0.05, 0.51, fs); + expect(d).toBeGreaterThan(0.001); + expect(d).toBeLessThan(0.02); + }); + + it('densifies the grid to KERNEL_RMSE_MIN_SAMPLES for a fast kernel at low fs', () => { + // At 10 Hz with tau_decay 0.2 s the native window is only ~10 samples; the + // floor keeps the RMS estimate stable (identical kernels still give 0). + expect(kernelShapeRmse(0.02, 0.2, 0.02, 0.2, 10)).toBe(0); + expect(KERNEL_RMSE_MIN_SAMPLES).toBe(24); + }); +}); diff --git a/packages/compute/src/index.ts b/packages/compute/src/index.ts index ccab49c..ebad1ba 100644 --- a/packages/compute/src/index.ts +++ b/packages/compute/src/index.ts @@ -4,7 +4,14 @@ export { createCaTuneWorkerPool } from './catune-pool.ts'; export type { CaTunePoolJob } from './catune-pool.ts'; export { resolveWorkerCount, getWorkersOverride, getDefaultWorkerCount } from './worker-sizing.ts'; export { computePaddedWindow, computeSafeMargin, WarmStartCache } from './warm-start-cache.ts'; -export { computeKernel, computeKernelAnnotations } from './kernel-math.ts'; +export { + computeKernel, + computeKernelAnnotations, + sampleBiexp, + kernelShapeRmse, + KERNEL_DURATION_MULTIPLE, + KERNEL_RMSE_MIN_SAMPLES, +} from './kernel-math.ts'; export { tauToShape, shapeToTau, isValidShapePair } from './kernel-shape.ts'; export { downsampleMinMax } from './downsample.ts'; export { makeTimeAxis } from './time-axis.ts'; diff --git a/packages/compute/src/kernel-math.ts b/packages/compute/src/kernel-math.ts index 71be61c..b0ab8f1 100644 --- a/packages/compute/src/kernel-math.ts +++ b/packages/compute/src/kernel-math.ts @@ -3,46 +3,88 @@ * h(t) = exp(-t/tauDecay) - exp(-t/tauRise), normalized to peak = 1. */ +/** Kernel duration as a multiple of tauDecay (e^-5 ≈ 0.7% of peak remains). */ +export const KERNEL_DURATION_MULTIPLE = 5; + +/** Floor on the convergence-RMSE grid sample count (see kernelShapeRmse). */ +export const KERNEL_RMSE_MIN_SAMPLES = 24; + +/** + * Sample a peak-normalized double-exponential kernel h(t)=exp(-t/τ_d)-exp(-t/τ_r) + * onto `n` points at spacing `dt`, normalized to its sampled peak of 1. The + * shared sampling primitive behind both computeKernel and kernelShapeRmse. + */ +export function sampleBiexp(tauRise: number, tauDecay: number, n: number, dt: number): number[] { + const y = new Array(n); + let peak = 0; + for (let i = 0; i < n; i++) { + const t = i * dt; + const v = Math.exp(-t / tauDecay) - Math.exp(-t / tauRise); + y[i] = v; + if (v > peak) peak = v; + } + if (peak > 0) { + for (let i = 0; i < n; i++) y[i] /= peak; + } + return y; +} + /** * Compute a double-exponential calcium impulse response kernel. * * @param tauRise - Rise time constant in seconds (e.g., 0.02) * @param tauDecay - Decay time constant in seconds (e.g., 0.4) * @param fs - Sampling rate in Hz (e.g., 30) - * @param durationMultiple - Multiple of tauDecay for kernel duration (default 5) + * @param durationMultiple - Multiple of tauDecay for kernel duration * @returns Object with x (time in seconds) and y (kernel amplitude) as number[] */ export function computeKernel( tauRise: number, tauDecay: number, fs: number, - durationMultiple: number = 5, + durationMultiple: number = KERNEL_DURATION_MULTIPLE, ): { x: number[]; y: number[] } { const dt = 1 / fs; - const duration = durationMultiple * tauDecay; - const numPoints = Math.ceil(duration * fs); - + const numPoints = Math.ceil(durationMultiple * tauDecay * fs); + const y = sampleBiexp(tauRise, tauDecay, numPoints, dt); const x: number[] = new Array(numPoints); - const y: number[] = new Array(numPoints); - - for (let i = 0; i < numPoints; i++) { - const t = i * dt; - x[i] = t; - y[i] = Math.exp(-t / tauDecay) - Math.exp(-t / tauRise); - } + for (let i = 0; i < numPoints; i++) x[i] = i * dt; + return { x, y }; +} - // Normalize to peak = 1 - let peak = 0; - for (let i = 0; i < numPoints; i++) { - if (y[i] > peak) peak = y[i]; - } - if (peak > 0) { - for (let i = 0; i < numPoints; i++) { - y[i] /= peak; - } +/** + * Convergence metric: peak-normalized RMSE between two bi-exponential kernels. + * + * Both kernels are sampled on a shared grid spanning [0, 5·max(τ_d)] — the `max` + * guarantees neither decay tail is truncated when τ_decay changes between + * iterations — at the native sampling rate (grid spacing ≈ 1/fs), floored to + * KERNEL_RMSE_MIN_SAMPLES points so a low fs / fast kernel doesn't reduce it to a + * handful of samples. Each is normalized to peak 1, so the result is a fraction + * of peak: 0 = identical, ~0.01 ≈ a 1%-of-peak typical deviation. + * + * Measuring the whole waveform (rather than a relative (t_peak, FWHM) delta) + * weights each parameter's change by how much it actually moves the kernel, so a + * jittery t_peak on the poorly-constrained rising edge no longer delays + * convergence over a change that barely alters the shape. + */ +export function kernelShapeRmse( + tauR1: number, + tauD1: number, + tauR2: number, + tauD2: number, + fs: number, +): number { + const window = KERNEL_DURATION_MULTIPLE * Math.max(tauD1, tauD2); + const n = Math.max(KERNEL_RMSE_MIN_SAMPLES, Math.ceil(window * fs)); + const dt = window / n; + const k1 = sampleBiexp(tauR1, tauD1, n, dt); + const k2 = sampleBiexp(tauR2, tauD2, n, dt); + let s = 0; + for (let i = 0; i < n; i++) { + const d = k1[i] - k2[i]; + s += d * d; } - - return { x, y }; + return Math.sqrt(s / n); } /** diff --git a/packages/core/README.md b/packages/core/README.md index f6edd95..d4ae0f0 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -25,7 +25,7 @@ Only `wasm-adapter.ts` may import from the WASM solver package (`crates/solver/p | `CellSolverStatus`, `SolverParams`, `WarmStartStrategy`, ... | `solver-types.ts` | Worker communication protocol types | | `computeAR2` | `ar2.ts` | AR(2) coefficient derivation from tau parameters | | `PARAM_RANGES` | `param-config.ts` | Scientifically reasonable parameter ranges (rise 1–500 ms, decay 50–3000 ms, sparsity 0–10) | -| `CONVERGENCE_RANGES` | `param-config.ts` | Shape-space convergence defaults: `convergenceTol` (0.02), `convergencePatience` (3), `convergenceMinIters` (2), `finalSelectionWindow` (5) | +| `CONVERGENCE_RANGES` | `param-config.ts` | Kernel-RMSE convergence defaults: `convergenceTol` (0.005), `convergencePatience` (3), `convergenceMinIters` (2), `finalSelectionWindow` (5) | | `formatDuration` | `format-utils.ts` | Duration formatting utility | | `computePeakSNR`, `snrToQuality` | `metrics/snr.ts` | Peak signal-to-noise ratio and quality tier classification | | `computeSparsityRatio`, `computeResidualRMS`, `computeRSquared` | `metrics/solver-metrics.ts` | Solver quality metrics | diff --git a/packages/core/src/param-config.ts b/packages/core/src/param-config.ts index f6804c4..97ba9e6 100644 --- a/packages/core/src/param-config.ts +++ b/packages/core/src/param-config.ts @@ -30,17 +30,24 @@ export const PARAM_RANGES = { /** * CaDecon iterative-loop convergence parameters. * - * Convergence is tested in kernel SHAPE space (peak time + FWHM) rather than in - * (tau_rise, tau_decay): the tau pair is degenerate (tau_rise <-> tau_decay - * thrash inflates the delta), so a shape-space tolerance settles meaningfully. + * Convergence is tested with the peak-normalized RMSE between successive + * iterations' bi-exponential kernels (a fraction of peak, → 0 at convergence), + * rather than a relative change of (tau_rise, tau_decay) or (peak time, FWHM): + * the waveform RMSE weights each parameter's change by how much it actually moves + * the kernel, so a jittery t_peak on the poorly-constrained rising edge no longer + * delays convergence over a change that barely alters the shape. */ export const CONVERGENCE_RANGES = { - /** Relative change of peak time AND FWHM below which an iteration counts as "stable". */ + /** + * Peak-normalized kernel RMSE below which an iteration counts as "stable", + * as a fraction of peak (0.005 ≈ 0.5%-of-peak typical deviation ≈ a ~2% change + * in tau). + */ convergenceTol: { - min: 0.005, - max: 0.1, - default: 0.02, - step: 0.005, + min: 0.001, + max: 0.05, + default: 0.005, + step: 0.001, }, /** Consecutive stable iterations required before declaring convergence. */ convergencePatience: {