Run deconvolution to see kernel convergence.
diff --git a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx
index 8946eb4..aa79e5a 100644
--- a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx
+++ b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx
@@ -28,8 +28,8 @@ import {
import { convergenceMarkerPlugin } from '../../lib/chart/convergence-marker-plugin.ts';
import { viewedIterationPlugin } from '../../lib/chart/viewed-iteration-plugin.ts';
-const IQR_FILL = 'rgba(31, 119, 180, 0.08)';
-const Q_COLOR = 'rgba(31, 119, 180, 0.3)';
+const IQR_FILL = 'rgba(31, 119, 180, 0.18)';
+const Q_COLOR = 'rgba(31, 119, 180, 0.4)';
export interface TrendsData {
iterations: number[];
@@ -263,7 +263,10 @@ export interface PerCellTrendsChartProps {
export function PerCellTrendsChart(props: PerCellTrendsChartProps): JSX.Element {
const [uplotRef, setUplotRef] = createSignal
(null);
- const trendsData = createMemo(() => deriveTrendsData(iterationHistory(), props.accessor));
+ const trendsData = createMemo(() => {
+ const history = iterationHistory().filter((h) => h.iteration > 0);
+ return deriveTrendsData(history, props.accessor);
+ });
// Redraw when overlay state changes
createEffect(() => {
@@ -287,8 +290,8 @@ export function PerCellTrendsChart(props: PerCellTrendsChartProps): JSX.Element
width: 2,
points: { show: true, size: 5 },
},
- { label: 'Q25', stroke: Q_COLOR, width: 1, dash: [4, 2], show: false },
- { label: 'Q75', stroke: Q_COLOR, width: 1, dash: [4, 2], show: false },
+ { label: 'Q25', stroke: Q_COLOR, width: 1, dash: [4, 2] },
+ { label: 'Q75', stroke: Q_COLOR, width: 1, dash: [4, 2] },
];
const scales = createMemo((): uPlot.Scales => {
@@ -308,6 +311,8 @@ export function PerCellTrendsChart(props: PerCellTrendsChartProps): JSX.Element
label: 'Iteration',
labelSize: 10,
labelFont: '10px sans-serif',
+ values: (_u: uPlot, splits: number[]) =>
+ splits.map((v) => (Number.isInteger(v) ? String(v) : '')),
},
{
stroke: AXIS_TEXT,
@@ -333,7 +338,7 @@ export function PerCellTrendsChart(props: PerCellTrendsChartProps): JSX.Element
return (
0}
+ when={trendsData().iterations.length > 0}
fallback={
{props.emptyMessage}
diff --git a/apps/cadecon/src/components/charts/PveTrends.tsx b/apps/cadecon/src/components/charts/PveTrends.tsx
new file mode 100644
index 0000000..8292fa3
--- /dev/null
+++ b/apps/cadecon/src/components/charts/PveTrends.tsx
@@ -0,0 +1,18 @@
+/**
+ * PVE Trends chart: shows per-cell percent variance explained evolving over iterations.
+ */
+
+import type { JSX } from 'solid-js';
+import { PerCellTrendsChart } from './PerCellTrendsChart.tsx';
+
+export function PveTrends(): JSX.Element {
+ return (
+
entry.pve}
+ yLabel="PVE"
+ medianLabel="Median PVE"
+ medianColor="#2ca02c"
+ emptyMessage="Run deconvolution to see PVE trends."
+ />
+ );
+}
diff --git a/apps/cadecon/src/lib/algorithm-store.ts b/apps/cadecon/src/lib/algorithm-store.ts
index fff2d77..71e4af6 100644
--- a/apps/cadecon/src/lib/algorithm-store.ts
+++ b/apps/cadecon/src/lib/algorithm-store.ts
@@ -3,8 +3,14 @@ import { samplingRate } from './data-store.ts';
// --- Algorithm parameter signals ---
-const [tauRiseInit, setTauRiseInit] = createSignal(0.1);
-const [tauDecayInit, setTauDecayInit] = createSignal(0.6);
+// Initial kernel time constants (seconds). These MUST start at or above the
+// true values. The iterative solver can only refine time constants downward —
+// a too-fast kernel is compensated by placing extra spikes, so the free-form
+// kernel never receives an error signal pushing it slower. Starting from
+// conservatively slow values ensures the optimizer passes through the optimum
+// on the way down, where best-residual tracking (iteration-manager.ts) catches it.
+const [tauRiseInit, setTauRiseInit] = createSignal(0.2);
+const [tauDecayInit, setTauDecayInit] = createSignal(1.0);
const [upsampleTarget, setUpsampleTarget] = createSignal(300);
const [weightingEnabled, setWeightingEnabled] = createSignal(false);
const [hpFilterEnabled, setHpFilterEnabled] = createSignal(true);
diff --git a/apps/cadecon/src/lib/iteration-manager.ts b/apps/cadecon/src/lib/iteration-manager.ts
index be3c8e8..17460ff 100644
--- a/apps/cadecon/src/lib/iteration-manager.ts
+++ b/apps/cadecon/src/lib/iteration-manager.ts
@@ -295,6 +295,18 @@ export async function startRun(): Promise {
let prevTraceCounts: Map | undefined;
let prevKernels: Float32Array[] | undefined;
+ // Best-residual tracking: remember the kernel parameters from the iteration
+ // whose bi-exponential fit residual was lowest. This prevents the rise time
+ // from collapsing toward 0 — the residual has a U-shape, so the minimum
+ // corresponds to the correct kernel even though the optimizer keeps pushing
+ // tau_rise down on subsequent iterations.
+ let bestResidual = Infinity;
+ let bestTauR = tauR;
+ let bestTauD = tauD;
+ let bestIteration = 0;
+ const RESIDUAL_PATIENCE = 3; // stop after this many consecutive increases
+ let residualIncreaseCount = 0;
+
// Iteration 0: record initial kernel state and alpha=1 baseline
addConvergenceSnapshot({
iteration: 0,
@@ -518,7 +530,35 @@ export async function startRun(): Promise {
})),
});
- // Step 4: Convergence check
+ // Step 4: Best-residual tracking & early stop
+ //
+ // TODO: The current stopping criterion uses the bi-exponential fit residual
+ // (||h_free - β·template||²), which measures kernel shape mismatch. This
+ // doesn't always work — it can be noisy or non-monotonic depending on the
+ // data. A more robust approach would use the trace-reconstruction residual
+ // (||y - α·(K*s) - b||² across cells), which directly measures how well
+ // the model explains the data. That's more expensive to compute but would
+ // be a stronger signal for when the kernel has overshot.
+ //
+ if (medResidual < bestResidual) {
+ bestResidual = medResidual;
+ bestTauR = tauR;
+ bestTauD = tauD;
+ bestIteration = iter + 1;
+ residualIncreaseCount = 0;
+ } else {
+ residualIncreaseCount++;
+ }
+
+ // Early stop: if residual has risen for RESIDUAL_PATIENCE consecutive
+ // iterations, the optimizer has overshot. The post-loop revert below will
+ // restore the best-residual kernel parameters before finalization.
+ if (residualIncreaseCount >= RESIDUAL_PATIENCE) {
+ setConvergedAtIteration(bestIteration);
+ break;
+ }
+
+ // Step 5: Convergence check (relative change in tau values)
const relChangeTauR = Math.abs(tauR - prevTauR) / (prevTauR + 1e-20);
const relChangeTauD = Math.abs(tauD - prevTauD) / (prevTauD + 1e-20);
const maxRelChange = Math.max(relChangeTauR, relChangeTauD);
@@ -528,6 +568,16 @@ export async function startRun(): Promise {
}
}
+ // Use the best-residual kernel for finalization. If the loop ran to maxIter
+ // without early-stopping, the current tauR/tauD may have overshot. Revert to
+ // the iteration that produced the lowest bi-exponential fit residual.
+ if (bestResidual < Infinity) {
+ tauR = bestTauR;
+ tauD = bestTauD;
+ setCurrentTauRise(tauR);
+ setCurrentTauDecay(tauD);
+ }
+
// Finalization: re-run trace inference on ALL cells with converged kernel
if (runState() !== 'stopping') {
setRunPhase('finalization');
diff --git a/apps/cadecon/src/styles/layout.css b/apps/cadecon/src/styles/layout.css
index ce6f068..6f75fb9 100644
--- a/apps/cadecon/src/styles/layout.css
+++ b/apps/cadecon/src/styles/layout.css
@@ -1,4 +1,4 @@
-/* 3-row visualization grid */
+/* 2-row visualization grid */
.viz-grid {
display: flex;
@@ -24,11 +24,6 @@
min-height: 180px;
}
-.viz-grid__row--bottom {
- flex: 0 0 auto;
- min-height: 130px;
-}
-
/* Column sizing within rows */
.viz-grid__col--raster {
flex: 6 1 0;
@@ -50,17 +45,6 @@
min-width: 0;
}
-/* Distribution card row */
-.viz-grid__distributions {
- display: flex;
- gap: var(--space-sm, 8px);
-}
-
-.viz-grid__distributions > * {
- flex: 1 1 0;
- min-width: 0;
-}
-
/* Responsive: stack columns at narrow widths */
@media (max-width: 900px) {
.viz-grid__row {
@@ -70,13 +54,4 @@
.viz-grid__col--kernel {
flex: 0 0 auto;
}
-
- .viz-grid__distributions {
- flex-wrap: wrap;
- }
-
- .viz-grid__distributions > * {
- flex: 1 1 calc(50% - 4px);
- min-width: 120px;
- }
}