Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions apps/cadecon/src/components/community/GroundTruthControls.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
/** Ground truth reveal/toggle controls and export button for CaDecon. */

import { Show, type JSX } from 'solid-js';
import { Show, createSignal, type JSX } from 'solid-js';
import {
isDemo,
groundTruthVisible,
groundTruthLocked,
revealGroundTruth,
toggleGroundTruthVisibility,
bridgeUrl,
setBridgeExportDone,
bridgeExportDone,
} from '../../lib/data-store.ts';
import { runState } from '../../lib/iteration-store.ts';
import { exportCaDeconToBridge } from '@calab/io';
import { buildCaDeconActivityMatrix, buildCaDeconResultsPayload } from '../../lib/export-utils.ts';

export function GroundTruthControls(): JSX.Element {
function handleToggle(): void {
Expand Down Expand Up @@ -46,11 +51,58 @@ export function GroundTruthNotices(): JSX.Element {
}

export function ExportButton(): JSX.Element {
const [exporting, setExporting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);

const isComplete = () => runState() === 'complete';
const isBridge = () => !!bridgeUrl();
const isDisabled = () => !isComplete() || exporting() || bridgeExportDone();

async function handleExport(): Promise<void> {
const url = bridgeUrl();
if (!url) return;

setExporting(true);
setError(null);
try {
const { data, shape } = buildCaDeconActivityMatrix();
const results = buildCaDeconResultsPayload();
await exportCaDeconToBridge(url, data, shape, results);
setBridgeExportDone(true);
} catch (e) {
setError(e instanceof Error ? e.message : 'Export failed');
} finally {
setExporting(false);
}
}

return (
<Show when={!isDemo()}>
<button class="btn-secondary btn-small" disabled title="Export coming soon">
{bridgeUrl() ? 'Export to Python' : 'Export Locally'}
<button
class="btn-secondary btn-small"
disabled={isDisabled()}
title={
bridgeExportDone()
? 'Exported'
: !isComplete()
? 'Run solver first'
: isBridge()
? 'Export results to Python'
: 'Export coming soon'
}
onClick={handleExport}
>
{exporting()
? 'Exporting...'
: bridgeExportDone()
? 'Exported'
: isBridge()
? 'Export to Python'
: 'Export Locally'}
</button>
<Show when={error()}>
<span class="submit-panel__error">{error()}</span>
</Show>
</Show>
);
}
91 changes: 91 additions & 0 deletions apps/cadecon/src/lib/export-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Collects CaDecon iteration results for export to the Python bridge.
*/

import { cellResultLookup, convergenceHistory, convergedAtIteration } from './iteration-store.ts';
import { samplingRate, numTimepoints } from './data-store.ts';

/** Sorted cell indices for deterministic row order across both export functions. */
function sortedCellIndices(): number[] {
return [...cellResultLookup().keys()].sort((a, b) => a - b);
}

/**
* Build a contiguous Float32Array activity matrix from per-cell sCounts.
* Returns the flat array and its [n_cells, n_timepoints] shape.
*/
export function buildCaDeconActivityMatrix(): {
data: Float32Array;
shape: [number, number];
} {
const lookup = cellResultLookup();
const nTime = numTimepoints() ?? 0;

const sortedCells = sortedCellIndices();
const data = new Float32Array(sortedCells.length * nTime);

for (let row = 0; row < sortedCells.length; row++) {
const entry = lookup.get(sortedCells[row])!;
const offset = row * nTime;
const len = Math.min(entry.sCounts.length, nTime);
data.set(entry.sCounts.subarray(0, len), offset);
}

return { data, shape: [sortedCells.length, nTime] };
}

/**
* Build the JSON results payload with per-cell scalars, kernel params, and metadata.
*/
export function buildCaDeconResultsPayload(): Record<string, unknown> {
const lookup = cellResultLookup();
const history = convergenceHistory();
const fs = samplingRate() ?? 30;

const sortedCells = sortedCellIndices();
const alphas: number[] = [];
const baselines: number[] = [];
const pves: number[] = [];

for (const cellIdx of sortedCells) {
const entry = lookup.get(cellIdx)!;
alphas.push(entry.alpha);
baselines.push(entry.baseline);
pves.push(entry.pve);
}

// Kernel params from last convergence snapshot
const latest = history.length > 0 ? history[history.length - 1] : null;
const tauRise = latest?.tauRise ?? 0;
const tauDecay = latest?.tauDecay ?? 0;
const beta = latest?.beta ?? 1;
const tauRiseFast = latest?.tauRiseFast ?? 0;
const tauDecayFast = latest?.tauDecayFast ?? 0;
const betaFast = latest?.betaFast ?? 0;
const residual = latest?.residual ?? 0;

// h_free from first subset (data-driven kernel shape)
const hFree = latest && latest.subsets.length > 0 ? Array.from(latest.subsets[0].hFree) : [];

const convergedAt = convergedAtIteration();

return {
alphas,
baselines,
pves,
fs,
tau_rise: tauRise,
tau_decay: tauDecay,
beta,
tau_rise_fast: tauRiseFast,
tau_decay_fast: tauDecayFast,
beta_fast: betaFast,
residual,
h_free: hFree,
num_iterations: history.length,
converged: convergedAt !== null,
converged_at_iteration: convergedAt,
schema_version: 1,
export_date: new Date().toISOString(),
};
}
101 changes: 101 additions & 0 deletions packages/io/src/__tests__/npy-writer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { writeNpy } from '../npy-writer.ts';
import { parseNpy } from '../npy-parser.ts';

describe('writeNpy', () => {
describe('roundtrip with parseNpy', () => {
it('roundtrips a 2D float32 array', () => {
const data = new Float32Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
const shape = [2, 3];

const buffer = writeNpy(data, shape);
const result = parseNpy(buffer);

expect(result.shape).toEqual([2, 3]);
expect(result.dtype).toBe('<f4');
expect(result.fortranOrder).toBe(false);
expect(result.data).toBeInstanceOf(Float32Array);
expect(result.data.length).toBe(6);
for (let i = 0; i < 6; i++) {
expect(result.data[i]).toBeCloseTo(data[i], 5);
}
});

it('roundtrips a 1D float32 array', () => {
const data = new Float32Array([10.5, 20.5, 30.5]);
const shape = [3];

const buffer = writeNpy(data, shape);
const result = parseNpy(buffer);

expect(result.shape).toEqual([3]);
expect(result.data.length).toBe(3);
expect(result.data[0]).toBeCloseTo(10.5, 5);
expect(result.data[2]).toBeCloseTo(30.5, 5);
});

it('roundtrips a larger matrix', () => {
const rows = 10;
const cols = 500;
const data = new Float32Array(rows * cols);
for (let i = 0; i < data.length; i++) {
data[i] = Math.sin(i * 0.01);
}

const buffer = writeNpy(data, [rows, cols]);
const result = parseNpy(buffer);

expect(result.shape).toEqual([rows, cols]);
expect(result.data.length).toBe(rows * cols);
for (let i = 0; i < 10; i++) {
expect(result.data[i]).toBeCloseTo(data[i], 5);
}
});
});

describe('binary format', () => {
it('starts with correct magic bytes', () => {
const data = new Float32Array([1.0]);
const buffer = writeNpy(data, [1]);
const bytes = new Uint8Array(buffer);

// \x93NUMPY
expect(bytes[0]).toBe(0x93);
expect(bytes[1]).toBe(0x4e);
expect(bytes[2]).toBe(0x55);
expect(bytes[3]).toBe(0x4d);
expect(bytes[4]).toBe(0x50);
expect(bytes[5]).toBe(0x59);
});

it('uses version 1.0', () => {
const data = new Float32Array([1.0]);
const buffer = writeNpy(data, [1]);
const bytes = new Uint8Array(buffer);

expect(bytes[6]).toBe(1); // major
expect(bytes[7]).toBe(0); // minor
});

it('header + preamble is 64-byte aligned', () => {
const data = new Float32Array([1.0, 2.0]);
const buffer = writeNpy(data, [2]);
const view = new DataView(buffer);

const headerLen = view.getUint16(8, true);
const totalPreamble = 10 + headerLen; // magic(6) + version(2) + headerLen(2) + header
expect(totalPreamble % 64).toBe(0);
});

it('header terminates with newline', () => {
const data = new Float32Array([1.0]);
const buffer = writeNpy(data, [1]);
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);

const headerLen = view.getUint16(8, true);
// Last byte of header should be newline
expect(bytes[10 + headerLen - 1]).toBe(0x0a);
});
});
});
55 changes: 55 additions & 0 deletions packages/io/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { parseNpy } from './npy-parser.ts';
import { writeNpy } from './npy-writer.ts';
import { processNpyResult } from './array-utils.ts';
import type { NpyResult } from '@calab/core';

Expand Down Expand Up @@ -92,3 +93,57 @@ export function stopBridgeHeartbeat(): void {
heartbeatTimer = null;
}
}

/**
* POST the activity matrix as .npy binary to the bridge server.
* Used by CaDecon to send the large activity array before the JSON results.
*/
export async function postActivityToBridge(
bridgeUrl: string,
activity: Float32Array,
shape: [number, number],
): Promise<void> {
const npyBuffer = writeNpy(activity, shape);
const resp = await fetch(`${bridgeUrl}/api/v1/results/activity`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: npyBuffer,
});
if (!resp.ok) {
throw new Error(`Bridge: failed to post activity (${resp.status})`);
}
}

/**
* POST the results JSON (scalars + metadata) to the bridge server.
* This acts as the "done" signal for the two-POST CaDecon export.
*/
export async function postResultsToBridge(
bridgeUrl: string,
results: Record<string, unknown>,
): Promise<void> {
const resp = await fetch(`${bridgeUrl}/api/v1/results`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(results),
});
if (!resp.ok) {
throw new Error(`Bridge: failed to post results (${resp.status})`);
}
}

/**
* Export CaDecon results to the bridge server.
* Sequences: activity POST first (large binary), then results POST (small JSON, triggers done).
* Stops the heartbeat after both succeed.
*/
export async function exportCaDeconToBridge(
bridgeUrl: string,
activity: Float32Array,
shape: [number, number],
results: Record<string, unknown>,
): Promise<void> {
await postActivityToBridge(bridgeUrl, activity, shape);
await postResultsToBridge(bridgeUrl, results);
stopBridgeHeartbeat();
}
2 changes: 2 additions & 0 deletions packages/io/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { parseNpy } from './npy-parser.ts';
export { writeNpy } from './npy-writer.ts';
export { parseNpz } from './npz-parser.ts';
export { validateTraceData } from './validation.ts';
export { extractCellTrace, processNpyResult } from './array-utils.ts';
Expand All @@ -9,6 +10,7 @@ export {
getBridgeUrl,
fetchBridgeData,
postParamsToBridge,
exportCaDeconToBridge,
startBridgeHeartbeat,
stopBridgeHeartbeat,
} from './bridge.ts';
Expand Down
Loading
Loading