Skip to content
Draft
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
123 changes: 123 additions & 0 deletions apps/cadecon/src/lib/results-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Standalone results download for the CaDecon GUI.
*
* Bundles the same payload the Python bridge receives -- the activity matrix
* plus the results JSON (see export-utils.ts) -- into a single ZIP:
*
* <source>_cadecon_results.zip
* activity.<ext> the activity matrix, in the imported file's format
* results.json buildCaDeconResultsPayload() + self-documenting descriptions
*
* The array format mirrors the imported file (.npy / .npz / .mat), falling back
* to .npy for demo- or bridge-loaded runs with no source file (which is exactly
* what the bridge itself emits).
*/

import { writeNpy, writeNpz, writeMat, zipFiles } from '@calab/io';
import { buildCaDeconActivityMatrix, buildCaDeconResultsPayload } from './export-utils.ts';
import { rawFile } from './data-store.ts';

type ArrayFormat = 'npy' | 'npz' | 'mat';

/**
* Human-readable explanation of every field in results.json (and the sibling
* activity array). Definitions are sourced from the solver, not inferred:
* the kernel fit is the two-component bi-exponential of crates/solver/biexp_fit.rs.
*/
const FIELD_DESCRIPTIONS: Record<string, string> = {
activity:
'Deconvolved per-cell activity (event counts), shape [n_cells, n_timepoints], float32. ' +
'Stored in the sibling activity.<ext> file. Rows are ordered by ascending cell index and ' +
'their orientation matches the imported traces; row i corresponds to alphas[i]/baselines[i]/pves[i].',
fs: 'Sampling rate of the traces, in Hz.',
alphas:
'Per-cell amplitude scale relating deconvolved event counts to fluorescence units (length n_cells).',
baselines:
'Per-cell fluorescence baseline offset subtracted before deconvolution (length n_cells).',
pves: 'Per-cell proportion of variance explained by the fit, 0-1; a per-cell fit-quality measure (length n_cells).',
tau_rise:
'Rise time constant (seconds) of the slow calcium kernel component T_s(t) = exp(-t/tau_decay) - exp(-t/tau_rise).',
tau_decay: 'Decay time constant (seconds) of the slow calcium kernel component.',
beta:
'Amplitude of the slow (calcium) component in the two-component kernel fit ' +
'h(t) = beta*T_s(t) + beta_fast*T_f(t); beta <= 0 indicates a degenerate fit.',
tau_rise_fast:
'Rise time constant (seconds) of the fast kernel component T_f, whose independent ' +
'time constants absorb a rising-edge noise/false-spike artifact (0 if unused).',
tau_decay_fast: 'Decay time constant (seconds) of the fast kernel component (0 if unused).',
beta_fast:
'Amplitude of the fast artifact component; ~0 when the data is clean (the fit then reduces ' +
'to a single bi-exponential).',
residual:
'Residual of the two-component bi-exponential fit to the free-form kernel h_free ' +
'(lower is a better fit; very large/infinite indicates a degenerate or empty fit).',
h_free:
'Free-form (nonparametric) calcium kernel re-estimated from the current spike solution ' +
'each iteration; the parametric bi-exponential (tau_*, beta) is fit to this shape.',
num_iterations: 'Total CaDecon iterations run.',
converged: 'Whether the run met the convergence criterion.',
converged_at_iteration:
'Iteration index at which convergence was reached, or null if the iteration cap was hit.',
schema_version: 'Version of this results JSON schema.',
export_date: 'ISO 8601 timestamp of when this file was exported.',
};

/** Determine the output array format + base filename from the imported file. */
function resolveOutput(): { format: ArrayFormat; base: string } {
const file = rawFile();
if (!file) return { format: 'npy', base: 'cadecon' };
const dot = file.name.lastIndexOf('.');
const base = dot > 0 ? file.name.slice(0, dot) : file.name;
const ext = dot >= 0 ? file.name.slice(dot + 1).toLowerCase() : '';
const format: ArrayFormat = ext === 'npz' ? 'npz' : ext === 'mat' ? 'mat' : 'npy';
return { format, base };
}

/** Serialize the activity matrix into the chosen container format. */
function serializeActivity(
format: ArrayFormat,
data: Float32Array,
shape: [number, number],
): ArrayBuffer {
switch (format) {
case 'npz':
return writeNpz({ activity: { data, shape } });
case 'mat':
return writeMat('activity', data, shape);
case 'npy':
default:
return writeNpy(data, shape);
}
}

function triggerDownload(buffer: ArrayBuffer, filename: string): void {
const blob = new Blob([buffer], { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}

/**
* Build and download the CaDecon results ZIP for the completed run.
* Safe to call only when a run has finished (activity matrix populated).
*/
export function downloadResults(): void {
const { format, base } = resolveOutput();
const { data, shape } = buildCaDeconActivityMatrix();

const activityBuffer = serializeActivity(format, data, shape);
const results = { ...buildCaDeconResultsPayload(), field_descriptions: FIELD_DESCRIPTIONS };
const resultsJson = new TextEncoder().encode(JSON.stringify(results, null, 2));

const zip = zipFiles({
[`activity.${format}`]: new Uint8Array(activityBuffer),
'results.json': resultsJson,
});

triggerDownload(zip, `${base}_cadecon_results.zip`);
}
40 changes: 40 additions & 0 deletions packages/io/src/__tests__/mat-writer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { writeMat } from '../mat-writer.ts';
import { parseMat } from '../mat-parser.ts';
import { processNpyResult } from '../array-utils.ts';

describe('writeMat', () => {
it('round-trips a 2D matrix through parseMat (C order preserved)', () => {
// Row-major [[1,2,3],[4,5,6]] (2 cells x 3 timepoints).
const data = new Float32Array([1, 2, 3, 4, 5, 6]);
const buf = writeMat('activity', data, [2, 3]);

const parsed = parseMat(buf);
expect(parsed.arrayNames).toEqual(['activity']);
expect(parsed.arrays['activity'].shape).toEqual([2, 3]);
// MATLAB is column-major, so the writer stores Fortran order.
expect(parsed.arrays['activity'].fortranOrder).toBe(true);

const c = processNpyResult(parsed.arrays['activity']);
expect(c.shape).toEqual([2, 3]);
expect(Array.from(c.data)).toEqual([1, 2, 3, 4, 5, 6]);
});

it('writes column-major storage (raw order is Fortran)', () => {
const data = new Float32Array([1, 2, 3, 4, 5, 6]); // [[1,2,3],[4,5,6]]
const parsed = parseMat(writeMat('x', data, [2, 3]));
// Column-major of [[1,2,3],[4,5,6]] is [1,4,2,5,3,6].
expect(Array.from(parsed.arrays['x'].data)).toEqual([1, 4, 2, 5, 3, 6]);
});

it('preserves the variable name', () => {
const parsed = parseMat(writeMat('traces_out', new Float32Array([1, 2, 3, 4]), [2, 2]));
expect(parsed.arrayNames).toEqual(['traces_out']);
});

it('throws when data length does not match shape', () => {
expect(() => writeMat('x', new Float32Array([1, 2, 3]), [2, 2])).toThrow(
'does not match shape',
);
});
});
29 changes: 29 additions & 0 deletions packages/io/src/__tests__/npz-writer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { writeNpz } from '../npz-writer.ts';
import { parseNpz } from '../npz-parser.ts';

describe('writeNpz', () => {
it('round-trips a single named array through parseNpz', () => {
const data = new Float32Array([1, 2, 3, 4, 5, 6]);
const buf = writeNpz({ activity: { data, shape: [2, 3] } });

const parsed = parseNpz(buf);
expect(parsed.arrayNames).toEqual(['activity']);
expect(parsed.arrays['activity'].shape).toEqual([2, 3]);
expect(parsed.arrays['activity'].dtype).toBe('<f4');
expect(Array.from(parsed.arrays['activity'].data)).toEqual([1, 2, 3, 4, 5, 6]);
});

it('round-trips multiple named arrays', () => {
const buf = writeNpz({
activity: { data: new Float32Array([1, 2, 3, 4]), shape: [2, 2] },
alpha: { data: new Float32Array([0.5, 1.5]), shape: [2] },
});

const parsed = parseNpz(buf);
expect(parsed.arrayNames.sort()).toEqual(['activity', 'alpha']);
expect(parsed.arrays['activity'].shape).toEqual([2, 2]);
expect(parsed.arrays['alpha'].shape).toEqual([2]);
expect(Array.from(parsed.arrays['alpha'].data)).toEqual([0.5, 1.5]);
});
});
93 changes: 93 additions & 0 deletions packages/io/src/mat-writer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// MATLAB Level-5 .mat writer (uncompressed). Inverse of mat-parser.ts.
//
// Writes a single real Float32 matrix as one named variable. MATLAB stores
// arrays column-major, so C-order (row-major) input is transposed on the way
// out; mat-parser flags the result fortranOrder:true and processNpyResult
// transposes it back, making writeMat -> parseMat a round trip.
//
// Uncompressed only: no zlib on write (a reader accepts uncompressed v5/v6).

const miINT8 = 1;
const miINT32 = 5;
const miUINT32 = 6;
const miSINGLE = 7;
const miMATRIX = 14;
const mxSINGLE = 7; // array class

/** Build a standard-format data element: 8-byte tag + data padded to 8 bytes. */
function element(mdtype: number, data: Uint8Array): Uint8Array {
const padded = data.length + ((8 - (data.length % 8)) % 8);
const buf = new Uint8Array(8 + padded);
const dv = new DataView(buf.buffer);
dv.setUint32(0, mdtype, true);
dv.setUint32(4, data.length, true);
buf.set(data, 8);
return buf;
}

function concat(chunks: Uint8Array[]): Uint8Array {
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.length;
}
return out;
}

/**
* Write a 2D Float32 matrix as an uncompressed MATLAB Level-5 .mat buffer.
*
* @param name - MATLAB variable name for the array
* @param data - flat row-major (C-order) Float32 values, length rows*cols
* @param shape - [rows, cols]
* @returns ArrayBuffer containing the complete .mat file
* @throws Error if data length does not match rows*cols
*/
export function writeMat(name: string, data: Float32Array, shape: [number, number]): ArrayBuffer {
const [rows, cols] = shape;
if (data.length !== rows * cols) {
throw new Error(`writeMat: data length ${data.length} does not match shape ${rows}x${cols}`);
}

// 1. Array flags (miUINT32, 2 words): [class|flags, nzmax]. Low byte = class.
const flagsData = new Uint8Array(8);
new DataView(flagsData.buffer).setUint32(0, mxSINGLE, true);
const flagsEl = element(miUINT32, flagsData);

// 2. Dimensions (miINT32).
const dimsData = new Uint8Array(8);
const dimsView = new DataView(dimsData.buffer);
dimsView.setInt32(0, rows, true);
dimsView.setInt32(4, cols, true);
const dimsEl = element(miINT32, dimsData);

// 3. Array name (miINT8).
const nameEl = element(miINT8, new TextEncoder().encode(name));

// 4. Real part (miSINGLE), written column-major.
const prData = new Uint8Array(rows * cols * 4);
const prView = new DataView(prData.buffer);
let k = 0;
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
prView.setFloat32(k, data[r * cols + c], true);
k += 4;
}
}
const prEl = element(miSINGLE, prData);

const matrixEl = element(miMATRIX, concat([flagsEl, dimsEl, nameEl, prEl]));

// 5. 128-byte header: descriptive text + version (0x0100) + endian 'IM' (LE).
const header = new Uint8Array(128);
const desc = 'MATLAB 5.0 MAT-file, created by CaLab';
for (let i = 0; i < desc.length && i < 116; i++) header[i] = desc.charCodeAt(i);
const headerView = new DataView(header.buffer);
headerView.setUint16(124, 0x0100, true);
header[126] = 0x49; // 'I'
header[127] = 0x4d; // 'M'

return concat([header, matrixEl]).buffer as ArrayBuffer;
}
22 changes: 22 additions & 0 deletions packages/io/src/npz-writer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// .npz writer - bundles one or more Float32 arrays into a .npz (ZIP of .npy).
// Inverse of npz-parser.ts. Each array is written as "<name>.npy" so numpy's
// np.load(...)[name] recovers it.

import { writeNpy } from './npy-writer.ts';
import { zipFiles } from './zip.ts';

/**
* Write named Float32 arrays to a .npz (zip of .npy) buffer.
*
* @param arrays - map of array name to its data + shape
* @returns ArrayBuffer containing the .npz archive
*/
export function writeNpz(
arrays: Record<string, { data: Float32Array; shape: number[] }>,
): ArrayBuffer {
const entries: Record<string, Uint8Array> = {};
for (const [name, arr] of Object.entries(arrays)) {
entries[`${name}.npy`] = new Uint8Array(writeNpy(arr.data, arr.shape));
}
return zipFiles(entries);
}
19 changes: 19 additions & 0 deletions packages/io/src/zip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Thin wrapper over fflate's zipSync so consumers can bundle named byte blobs
// (e.g. a results archive: activity.<ext> + results.json) without importing
// fflate directly.

import { zipSync } from 'fflate';

/**
* Bundle named byte blobs into an uncompressed-container-friendly ZIP.
*
* @param files - map of entry name (e.g. "results.json") to its bytes
* @returns ArrayBuffer containing the ZIP archive
*/
export function zipFiles(files: Record<string, Uint8Array>): ArrayBuffer {
const zipped = zipSync(files);
return zipped.buffer.slice(
zipped.byteOffset,
zipped.byteOffset + zipped.byteLength,
) as ArrayBuffer;
}