From b9b8a4ef1dff2fea3e610783b5bb31c3dde11f2e Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Fri, 1 May 2026 07:51:22 -0400 Subject: [PATCH] feat(frontend): add computeNumericSeries helper --- frontend/src/lib/csvAnalysis.js | 33 +++++++++++++++++++ frontend/tests/csvAnalysis.test.js | 53 ++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/frontend/src/lib/csvAnalysis.js b/frontend/src/lib/csvAnalysis.js index 80c388a..b4d8503 100644 --- a/frontend/src/lib/csvAnalysis.js +++ b/frontend/src/lib/csvAnalysis.js @@ -230,3 +230,36 @@ export function extractNumber(str) { const m = String(str).match(/-?\d+(\.\d+)?/); return m ? parseFloat(m[0]) : null; } + +/** + * Build a numeric-vs-x series for a numerical bucket. + * + * @param {object[]} rows - Parsed CSV rows + * @param {string} xColName - Column to plot on the x-axis (frame col, or timestamp as fallback) + * @param {string} noteCol - Column containing note values + * @param {object} bucket - { name, type:'numerical', notes: string[] } + * @returns {{points: {x:number,value:number,raw:string}[], avg:number|null, skipped:number, total:number, xLabel:string}} + */ +export function computeNumericSeries(rows, xColName, noteCol, bucket) { + const noteSet = new Set(bucket.notes); + const points = []; + let skipped = 0; + let total = 0; + + for (const r of rows) { + const note = String(r[noteCol] ?? '').trim(); + if (!noteSet.has(note)) continue; + total++; + const value = extractNumber(note); + const x = parseFloat(r[xColName]); + if (value == null || Number.isNaN(x)) { skipped++; continue; } + points.push({ x, value, raw: note }); + } + + points.sort((a, b) => a.x - b.x); + const avg = points.length === 0 + ? null + : points.reduce((s, p) => s + p.value, 0) / points.length; + + return { points, avg, skipped, total, xLabel: xColName }; +} diff --git a/frontend/tests/csvAnalysis.test.js b/frontend/tests/csvAnalysis.test.js index 14544b9..c3d1d6d 100644 --- a/frontend/tests/csvAnalysis.test.js +++ b/frontend/tests/csvAnalysis.test.js @@ -4,6 +4,7 @@ import { getUniqueNoteValues, runAnalysis, extractNumber, + computeNumericSeries, } from '../src/lib/csvAnalysis'; // ── Fixtures ─────────────────────────────────────────────────────────────────── @@ -306,3 +307,55 @@ describe('extractNumber', () => { expect(extractNumber(undefined)).toBeNull(); }); }); + +describe('computeNumericSeries', () => { + const rows = [ + { frame: '1', timestamp: '0.0', note: 'L25' }, + { frame: '3', timestamp: '2.0', note: 'L30.5' }, + { frame: '2', timestamp: '1.0', note: 'L40' }, + { frame: '4', timestamp: '3.0', note: 'F' }, + { frame: '5', timestamp: '4.0', note: 'abc' }, // bucket-member but no number + { frame: 'x', timestamp: '5.0', note: 'L99' }, // bad x + { frame: '6', timestamp: '6.0', note: '' }, // not in bucket + ]; + const bucket = { name: 'Lick latency', type: 'numerical', notes: ['L25', 'L30.5', 'L40', 'abc', 'L99'] }; + + test('returns sorted points with average and counts', () => { + const out = computeNumericSeries(rows, 'frame', 'note', bucket); + expect(out.points).toEqual([ + { x: 1, value: 25, raw: 'L25' }, + { x: 2, value: 40, raw: 'L40' }, + { x: 3, value: 30.5, raw: 'L30.5' }, + ]); + expect(out.avg).toBeCloseTo((25 + 40 + 30.5) / 3); + expect(out.skipped).toBe(2); // 'abc' (no number) + bad x + expect(out.total).toBe(5); // bucket-member rows + expect(out.xLabel).toBe('frame'); + }); + + test('falls back to timestamp x-column when frame is empty string', () => { + const out = computeNumericSeries(rows, 'timestamp', 'note', bucket); + expect(out.xLabel).toBe('timestamp'); + expect(out.points[0].x).toBe(0); + }); + + test('returns avg=null and empty points when nothing plottable', () => { + const onlyBad = [ + { frame: '1', note: 'abc' }, + ]; + const b = { name: 'Z', type: 'numerical', notes: ['abc'] }; + const out = computeNumericSeries(onlyBad, 'frame', 'note', b); + expect(out.points).toEqual([]); + expect(out.avg).toBeNull(); + expect(out.skipped).toBe(1); + expect(out.total).toBe(1); + }); + + test('returns total=0 when bucket notes match nothing in rows', () => { + const b = { name: 'Empty', type: 'numerical', notes: ['nope'] }; + const out = computeNumericSeries(rows, 'frame', 'note', b); + expect(out.total).toBe(0); + expect(out.points).toEqual([]); + expect(out.avg).toBeNull(); + }); +});