Skip to content

Commit c3c6c79

Browse files
committed
refactor: modify psth code to match fib utils packages
1 parent 5c16b94 commit c3c6c79

4 files changed

Lines changed: 298 additions & 54 deletions

File tree

web/src/__tests__/fib-psth.test.js

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
interpTrace,
4+
baselineTrace,
5+
censorWindows,
6+
computePsthSeries,
7+
PSTH_GRID,
8+
PSTH_PRE,
9+
PSTH_POST,
10+
} from '../fiber_photometry/fib-playback.js';
11+
12+
function findAt(series, t) {
13+
let best = null;
14+
let bestD = Infinity;
15+
for (const d of series) {
16+
const dd = Math.abs(d.t - t);
17+
if (dd < bestD) { bestD = dd; best = d; }
18+
}
19+
return best;
20+
}
21+
22+
function sineRows({ nTrials = 6, dt = 0.005, pre = PSTH_PRE - 0.25, post = PSTH_POST + 0.25, evSpacing = 100, channel = 'G' } = {}) {
23+
const rows = [];
24+
for (let k = 0; k < nTrials; k++) {
25+
const ev = k * evSpacing;
26+
for (let t = pre; t <= post + 1e-9; t += dt) {
27+
const tr = Math.round(t * 1000) / 1000;
28+
rows.push({ trial: k, channel, ev_t: ev, t_rel: tr, v: Math.sin(2 * Math.PI * tr) });
29+
}
30+
}
31+
return rows;
32+
}
33+
34+
function constRows({ trial, ev_t, value, channel = 'G', pre = PSTH_PRE - 0.25, post = PSTH_POST + 0.25, dt = 0.02 }) {
35+
const rows = [];
36+
for (let t = pre; t <= post + 1e-9; t += dt) {
37+
rows.push({ trial, channel, ev_t, t_rel: Math.round(t * 1000) / 1000, v: value });
38+
}
39+
return rows;
40+
}
41+
42+
describe('interpTrace', () => {
43+
it('interpolates onto the grid and does not extrapolate outside the sample range', () => {
44+
const samples = [{ t: -1, v: 1 }, { t: 0, v: 2 }, { t: 1, v: 3 }];
45+
const out = interpTrace(samples, PSTH_PRE, PSTH_POST);
46+
47+
const idx = (t) => PSTH_GRID.findIndex((g) => Math.abs(g - t) < 1e-9);
48+
expect(out[idx(-1)]).toBeCloseTo(1, 6);
49+
expect(out[idx(-0.5)]).toBeCloseTo(1.5, 6);
50+
expect(out[idx(0)]).toBeCloseTo(2, 6);
51+
expect(out[idx(0.5)]).toBeCloseTo(2.5, 6);
52+
expect(out[idx(1)]).toBeCloseTo(3, 6);
53+
54+
// Outside [minT, maxT] must be NaN (no extrapolation).
55+
expect(Number.isNaN(out[idx(-1.5)])).toBe(true);
56+
expect(Number.isNaN(out[idx(1.5)])).toBe(true);
57+
expect(Number.isNaN(out[0])).toBe(true);
58+
});
59+
60+
it('respects the censor window bounds', () => {
61+
const samples = [{ t: -2, v: 5 }, { t: 5, v: 5 }];
62+
const out = interpTrace(samples, -1, 2);
63+
const idx = (t) => PSTH_GRID.findIndex((g) => Math.abs(g - t) < 1e-9);
64+
expect(out[idx(0)]).toBeCloseTo(5, 6);
65+
expect(Number.isNaN(out[idx(-1.5)])).toBe(true);
66+
expect(Number.isNaN(out[idx(3)])).toBe(true);
67+
});
68+
69+
it('returns all NaN for fewer than two samples', () => {
70+
const out = interpTrace([{ t: 0, v: 1 }], PSTH_PRE, PSTH_POST);
71+
expect(out.every((v) => Number.isNaN(v))).toBe(true);
72+
});
73+
});
74+
75+
describe('censorWindows', () => {
76+
it('truncates each window at the neighbouring event', () => {
77+
const rows = [
78+
{ trial: 0, ev_t: 0 },
79+
{ trial: 1, ev_t: 1 },
80+
{ trial: 2, ev_t: 100 },
81+
];
82+
const w = censorWindows(rows);
83+
// First event: no previous, next gap 1 -> hi = min(POST, 1)
84+
expect(w.get(0)).toEqual({ lo: -Math.min(2, Infinity), hi: 1 });
85+
// Middle event: prev gap 1 -> lo = -min(2,1); next gap 99 -> hi = min(5,99)
86+
expect(w.get(1)).toEqual({ lo: -1, hi: 5 });
87+
// Last event: prev gap 99 -> lo = -min(2,99); no next
88+
expect(w.get(2)).toEqual({ lo: -2, hi: 5 });
89+
});
90+
});
91+
92+
describe('baselineTrace', () => {
93+
it('subtracts the mean over the pre-event window [-baselineSec, 0)', () => {
94+
const trace = PSTH_GRID.map((t) => (t < 0 ? 5 : 10));
95+
const out = baselineTrace(trace, 0.2);
96+
const idx = (t) => PSTH_GRID.findIndex((g) => Math.abs(g - t) < 1e-9);
97+
expect(out[idx(-0.1)]).toBeCloseTo(0, 6);
98+
expect(out[idx(0)]).toBeCloseTo(5, 6);
99+
expect(out[idx(1)]).toBeCloseTo(5, 6);
100+
});
101+
102+
it('returns the trace unchanged when baselineSec is not positive', () => {
103+
const trace = PSTH_GRID.map(() => 3);
104+
expect(baselineTrace(trace, 0)).toBe(trace);
105+
});
106+
});
107+
108+
describe('computePsthSeries — event-triggered averaging', () => {
109+
it('recovers a sinusoid through trial averaging (mirrors aind event_triggered_response test)', () => {
110+
const { allMean, channels } = computePsthSeries(sineRows());
111+
expect(channels).toEqual(['G']);
112+
expect(findAt(allMean, 0).mean).toBeCloseTo(0, 2);
113+
expect(findAt(allMean, 0.25).mean).toBeCloseTo(1, 2);
114+
expect(findAt(allMean, 0.5).mean).toBeCloseTo(0, 2);
115+
expect(findAt(allMean, 0.75).mean).toBeCloseTo(-1, 2);
116+
expect(findAt(allMean, 1).mean).toBeCloseTo(0, 2);
117+
});
118+
119+
it('has no edge artifact when samples bracket the window (finite mean at every grid point)', () => {
120+
const rows = [
121+
...constRows({ trial: 0, ev_t: 0, value: 4 }),
122+
...constRows({ trial: 1, ev_t: 100, value: 6 }),
123+
];
124+
const { allMean } = computePsthSeries(rows);
125+
// One mean entry per grid point, all finite and equal to the average (5).
126+
expect(allMean).toHaveLength(PSTH_GRID.length);
127+
for (const d of allMean) {
128+
expect(Number.isFinite(d.mean)).toBe(true);
129+
expect(d.mean).toBeCloseTo(5, 6);
130+
}
131+
// Specifically the first and last grid points (edges) are correct.
132+
expect(findAt(allMean, PSTH_PRE).mean).toBeCloseTo(5, 6);
133+
expect(findAt(allMean, PSTH_POST).mean).toBeCloseTo(5, 6);
134+
});
135+
136+
it('censors samples past a neighbouring event (mirrors aind censor test)', () => {
137+
const rows = [
138+
...constRows({ trial: 0, ev_t: 0, value: 10 }),
139+
...constRows({ trial: 1, ev_t: 1, value: 20 }),
140+
];
141+
const { allMean } = computePsthSeries(rows);
142+
// Within both censor windows -> both trials contribute -> mean 15.
143+
expect(findAt(allMean, 0.5).mean).toBeCloseTo(15, 6);
144+
// Past trial 0's next event (t > 1) -> only trial 1 -> mean 20.
145+
expect(findAt(allMean, 2).mean).toBeCloseTo(20, 6);
146+
// Before trial 1's previous event (t < -1) -> only trial 0 -> mean 10.
147+
expect(findAt(allMean, -1.5).mean).toBeCloseTo(10, 6);
148+
});
149+
150+
it('computes SEM across trials (zero when all trials agree)', () => {
151+
const rows = [
152+
...constRows({ trial: 0, ev_t: 0, value: 7 }),
153+
...constRows({ trial: 1, ev_t: 100, value: 7 }),
154+
...constRows({ trial: 2, ev_t: 200, value: 7 }),
155+
];
156+
const { allMean } = computePsthSeries(rows);
157+
const d = findAt(allMean, 0);
158+
expect(d.mean).toBeCloseTo(7, 6);
159+
expect(d.lo).toBeCloseTo(7, 6);
160+
expect(d.hi).toBeCloseTo(7, 6);
161+
});
162+
163+
it('applies a per-trial baseline subtraction when baselineSec > 0', () => {
164+
// Baseline (pre-event) value 2, post-event value 5.
165+
function stepRows(trial, ev_t) {
166+
const rows = [];
167+
for (let t = PSTH_PRE - 0.25; t <= PSTH_POST + 0.25 + 1e-9; t += 0.02) {
168+
const tr = Math.round(t * 1000) / 1000;
169+
rows.push({ trial, channel: 'G', ev_t, t_rel: tr, v: tr < 0 ? 2 : 5 });
170+
}
171+
return rows;
172+
}
173+
const rows = [...stepRows(0, 0), ...stepRows(1, 100)];
174+
const { allMean } = computePsthSeries(rows, 0.2);
175+
expect(findAt(allMean, -0.1).mean).toBeCloseTo(0, 6);
176+
expect(findAt(allMean, 1).mean).toBeCloseTo(3, 6);
177+
});
178+
});

0 commit comments

Comments
 (0)