diff --git a/apps/cadecon/src/App.tsx b/apps/cadecon/src/App.tsx index 590a15a..35f5286 100644 --- a/apps/cadecon/src/App.tsx +++ b/apps/cadecon/src/App.tsx @@ -23,11 +23,6 @@ import { ConvergencePanel } from './components/charts/ConvergencePanel.tsx'; import { KernelDisplay } from './components/kernel/KernelDisplay.tsx'; import { TraceInspector } from './components/traces/TraceInspector.tsx'; import { IterationScrubber } from './components/traces/IterationScrubber.tsx'; -import { AlphaDistribution } from './components/distributions/AlphaDistribution.tsx'; -import { PVEDistribution } from './components/distributions/PVEDistribution.tsx'; -import { EventRateDistribution } from './components/distributions/EventRateDistribution.tsx'; -import { SubsetVariance } from './components/distributions/SubsetVariance.tsx'; -import { SubsetDrillDown } from './components/drilldown/SubsetDrillDown.tsx'; import { SubmitPanel } from './components/community/SubmitPanel.tsx'; import { CommunityBrowser } from './components/community/CommunityBrowser.tsx'; import { @@ -38,16 +33,14 @@ import { loadFromBridge, bridgeUrl, } from './lib/data-store.ts'; -import { selectedSubsetIdx, setSeed } from './lib/subset-store.ts'; +import { setSeed } from './lib/subset-store.ts'; import { isRunLocked } from './lib/iteration-store.ts'; import './styles/controls.css'; import './styles/layout.css'; -import './styles/distributions.css'; import './styles/trace-inspector.css'; import './styles/iteration-scrubber.css'; import './styles/kernel-display.css'; -import './styles/drilldown.css'; import './styles/community.css'; function DiceIcon(): JSX.Element { @@ -181,23 +174,6 @@ const App: Component = () => { - - {/* Row 3: Distribution Cards OR Subset Drill-Down */} -
- - - - - -
- } - > - - - diff --git a/apps/cadecon/src/components/charts/ConvergencePanel.tsx b/apps/cadecon/src/components/charts/ConvergencePanel.tsx index 8e8f085..bb68f54 100644 --- a/apps/cadecon/src/components/charts/ConvergencePanel.tsx +++ b/apps/cadecon/src/components/charts/ConvergencePanel.tsx @@ -1,14 +1,31 @@ /** - * Tabbed panel switching between Kernel Convergence, Alpha Trends, and Threshold Trends. + * Tabbed panel switching between Kernel Convergence, Alpha Trends, Threshold Trends, + * PVE Trends, and Event Rate Trends. * All charts remain mounted (display toggled) to preserve uPlot state across tab switches. */ -import { createSignal, type JSX } from 'solid-js'; +import { createSignal, For, type JSX } from 'solid-js'; import { KernelConvergence } from './KernelConvergence.tsx'; import { AlphaTrends } from './AlphaTrends.tsx'; import { ThresholdTrends } from './ThresholdTrends.tsx'; +import { PveTrends } from './PveTrends.tsx'; +import { EventRateTrends } from './EventRateTrends.tsx'; -type ConvergenceTab = 'kernel' | 'alpha' | 'threshold'; +type ConvergenceTab = 'kernel' | 'alpha' | 'threshold' | 'pve' | 'event-rate'; + +interface TabEntry { + id: ConvergenceTab; + label: string; + content: () => JSX.Element; +} + +const TABS: TabEntry[] = [ + { id: 'kernel', label: 'Kernel', content: () => }, + { id: 'alpha', label: 'Alpha', content: () => }, + { id: 'threshold', label: 'Threshold', content: () => }, + { id: 'pve', label: 'PVE', content: () => }, + { id: 'event-rate', label: 'Event Rate', content: () => }, +]; export function ConvergencePanel(): JSX.Element { const [activeTab, setActiveTab] = createSignal('kernel'); @@ -16,46 +33,28 @@ export function ConvergencePanel(): JSX.Element { return (
- - - -
-
- -
-
- -
-
- + + {(tab) => ( + + )} +
+ + {(tab) => ( +
+ {tab.content()} +
+ )} +
); } diff --git a/apps/cadecon/src/components/charts/EventRateTrends.tsx b/apps/cadecon/src/components/charts/EventRateTrends.tsx new file mode 100644 index 0000000..1fc26c1 --- /dev/null +++ b/apps/cadecon/src/components/charts/EventRateTrends.tsx @@ -0,0 +1,30 @@ +/** + * Event Rate Trends chart: shows per-cell spike rate (Hz) evolving over iterations. + * + * Event rate = sum(sCounts) / (sCounts.length / fs). When sCounts is empty or + * fs is unavailable the rate is 0. + */ + +import { createMemo, type JSX } from 'solid-js'; +import { PerCellTrendsChart } from './PerCellTrendsChart.tsx'; +import { samplingRate } from '../../lib/data-store.ts'; + +export function EventRateTrends(): JSX.Element { + const fs = createMemo(() => samplingRate() ?? 1); + + return ( + { + const n = entry.sCounts.length; + if (n === 0) return 0; + let sum = 0; + for (let i = 0; i < n; i++) sum += entry.sCounts[i]; + return sum / (n / fs()); + }} + yLabel="Hz" + medianLabel="Median Event Rate" + medianColor="#ff7f0e" + emptyMessage="Run deconvolution to see event rate trends." + /> + ); +} diff --git a/apps/cadecon/src/components/charts/KernelConvergence.tsx b/apps/cadecon/src/components/charts/KernelConvergence.tsx index dbff483..475e2b5 100644 --- a/apps/cadecon/src/components/charts/KernelConvergence.tsx +++ b/apps/cadecon/src/components/charts/KernelConvergence.tsx @@ -10,6 +10,12 @@ import 'uplot/dist/uPlot.min.css'; import '@calab/ui/chart/chart-theme.css'; import { convergenceHistory, convergedAtIteration } from '../../lib/iteration-store.ts'; import { viewedIteration } from '../../lib/viz-store.ts'; +import { + groundTruthVisible, + isDemo, + groundTruthTauRise, + groundTruthTauDecay, +} from '../../lib/data-store.ts'; import { wheelZoomPlugin, AXIS_TEXT, AXIS_GRID, AXIS_TICK } from '@calab/ui/chart'; import { convergenceMarkerPlugin } from '../../lib/chart/convergence-marker-plugin.ts'; import { viewedIterationPlugin } from '../../lib/chart/viewed-iteration-plugin.ts'; @@ -20,6 +26,41 @@ const RESIDUAL_COLOR = '#9e9e9e'; const TAU_RISE_FAINT = 'rgba(66, 165, 245, 0.3)'; const TAU_DECAY_FAINT = 'rgba(239, 83, 80, 0.3)'; +/** Draw a single horizontal line at `yVal` on scale `'y'`. Caller must save/restore ctx. */ +function drawHLine(ctx: CanvasRenderingContext2D, u: uPlot, yVal: number, color: string): void { + ctx.strokeStyle = color; + const yPx = u.valToPos(yVal, 'y', true); + ctx.beginPath(); + ctx.moveTo(u.bbox.left, yPx); + ctx.lineTo(u.bbox.left + u.bbox.width, yPx); + ctx.stroke(); +} + +/** Plugin that draws horizontal dashed lines at ground truth tau_rise (blue) and tau_decay (red). */ +function groundTruthPlugin(): uPlot.Plugin { + return { + hooks: { + draw(u: uPlot) { + if (!groundTruthVisible() || !isDemo()) return; + const gtTauR = groundTruthTauRise(); + const gtTauD = groundTruthTauDecay(); + if (gtTauR == null && gtTauD == null) return; + + const dpr = devicePixelRatio; + const ctx = u.ctx; + ctx.save(); + ctx.lineWidth = 1.5 * dpr; + ctx.setLineDash([6 * dpr, 4 * dpr]); + + if (gtTauR != null) drawHLine(ctx, u, gtTauR * 1000, TAU_RISE_COLOR); + if (gtTauD != null) drawHLine(ctx, u, gtTauD * 1000, TAU_DECAY_COLOR); + + ctx.restore(); + }, + }, + }; +} + /** Plugin that draws faint per-subset scatter circles behind the main lines. */ function subsetScatterPlugin(): uPlot.Plugin { return { @@ -57,14 +98,17 @@ function subsetScatterPlugin(): uPlot.Plugin { export function KernelConvergence(): JSX.Element { const [uplotRef, setUplotRef] = createSignal(null); - // Redraw when viewedIteration changes so the overlay marker updates + // Redraw when viewedIteration or ground truth visibility changes so overlay markers update createEffect(() => { viewedIteration(); // track + groundTruthVisible(); // track uplotRef()?.redraw(); }); + const filteredHistory = createMemo(() => convergenceHistory().filter((s) => s.iteration > 0)); + const chartData = createMemo((): uPlot.AlignedData => { - const h = convergenceHistory(); + const h = filteredHistory(); if (h.length === 0) return [[], [], [], []]; return [ h.map((s) => s.iteration), @@ -101,6 +145,8 @@ export function KernelConvergence(): 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, @@ -124,6 +170,7 @@ export function KernelConvergence(): JSX.Element { const plugins = [ subsetScatterPlugin(), + groundTruthPlugin(), convergenceMarkerPlugin(() => convergedAtIteration()), viewedIterationPlugin(() => viewedIteration()), wheelZoomPlugin(), @@ -135,7 +182,7 @@ export function KernelConvergence(): JSX.Element { return ( 0} + when={filteredHistory().length > 0} fallback={
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; - } }