diff --git a/src/dataprocessor.js b/src/dataprocessor.js index 4aa45f6..28c9008 100644 --- a/src/dataprocessor.js +++ b/src/dataprocessor.js @@ -74,7 +74,13 @@ class DataProcessor { let rawData; if (file.name.endsWith('.csv')) { const parsedCSV = this.#parseCSV(e.target.result); - rawData = this.#normalizeWideCSV(parsedCSV); + + // NEW: Explicitly route AlfaOBD files + if (this.#isAlfaOBD(parsedCSV)) { + rawData = this.#normalizeAlfaOBD(parsedCSV); + } else { + rawData = this.#normalizeWideCSV(parsedCSV); + } } else { rawData = JSON.parse(e.target.result); } @@ -180,6 +186,70 @@ class DataProcessor { } } + /** + * Detects if the parsed CSV matches the AlfaOBD log format. + * @private + */ + #isAlfaOBD(rows) { + if (!rows || rows.length === 0) return false; + + const keys = Object.keys(rows[0]); + // AlfaOBD files specifically use a 'Time' column formatted as 'HH:MM:SS.mmm' + const hasTimeColumn = keys.includes('Time'); + const firstTimeValue = rows[0]['Time']; + + return ( + hasTimeColumn && + typeof firstTimeValue === 'string' && + firstTimeValue.includes(':') + ); + } + + /** + * Explicit normalizer for AlfaOBD Wide CSVs. + * Converts 'HH:MM:SS.mmm' to absolute milliseconds and flattens data. + * @private + */ + #normalizeAlfaOBD(rows) { + const normalized = []; + if (!rows || rows.length === 0) return normalized; + + const keys = Object.keys(rows[0]); + const timeKey = 'Time'; + const signalKeys = keys.filter((k) => k !== timeKey); + + rows.forEach((row) => { + const rawTime = row[timeKey]; + if (!rawTime) return; + + // Parse HH:MM:SS.mmm into milliseconds + const parts = rawTime.split(':'); + if (parts.length !== 3) return; + + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + const seconds = parseFloat(parts[2]); + + if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return; + + const timestampMs = (hours * 3600 + minutes * 60 + seconds) * 1000; + + // Flatten the wide row into Long Format + signalKeys.forEach((sigKey) => { + const val = row[sigKey]; + if (val !== '' && val !== null && val !== undefined) { + normalized.push({ + SensorName: sigKey, + Time_ms: timestampMs, + Reading: val, + }); + } + }); + }); + + return normalized; + } + /** * Determines which schema to use based on the keys present in the first data point. * @private diff --git a/tests/dataprocessor.test.js b/tests/dataprocessor.test.js index 0d2bcb9..cf69b29 100644 --- a/tests/dataprocessor.test.js +++ b/tests/dataprocessor.test.js @@ -714,4 +714,110 @@ describe('DataProcessor: Nested Object Support', () => { expect(result.availableSignals).toContain('GPS-Lat'); expect(result.availableSignals).toContain('GPS-Lon'); }); + + describe('DataProcessor: AlfaOBD CSV Handling', () => { + beforeEach(() => { + AppState.files = []; + jest.clearAllMocks(); + + document.body.innerHTML = ` +
+
+ `; + + DOM.get = jest.fn((id) => document.getElementById(id)); + dbManager.getAllFiles = jest.fn().mockResolvedValue([]); + dbManager.saveTelemetry = jest.fn().mockResolvedValue(1); + projectManager.registerFile = jest.fn(); + }); + + test('should detect and parse AlfaOBD HH:MM:SS.mmm timestamps into milliseconds', (done) => { + // Math verification for 13:48:35.666 + // 13 hours = 46,800 sec | 48 min = 2,880 sec | 35.666 sec + // Total seconds: 49715.666 -> * 1000 = 49715666 milliseconds + const alfaOBD_CSV = `Time,Engine speed rpm,Spark advance ° +13:48:35.666,1584,5.938 +13:48:37.223,1858,14.938`; + + const event = { + target: { + files: [ + new File([alfaOBD_CSV], 'alfaobd_log.csv', { + type: 'text/csv', + }), + ], + }, + }; + + dataProcessor.handleLocalFile(event); + + setTimeout(() => { + try { + expect(messenger.emit).toHaveBeenCalledWith( + expect.stringContaining('ui:set-loading'), + { message: 'Parsing 1 Files...' } + ); + + expect(AppState.files.length).toBe(1); + const file = AppState.files[0]; + + // 1. Verify Signals were correctly extracted + expect(file.availableSignals).toEqual( + expect.arrayContaining(['Engine speed rpm', 'Spark advance °']) + ); + + // 2. Verify Absolute Milliseconds Conversion + const rpmSignal = file.signals['Engine speed rpm']; + + expect(rpmSignal[0].x).toBe(49715666); // 13:48:35.666 + expect(rpmSignal[0].y).toBe(1584); + + expect(rpmSignal[1].x).toBe(49717223); // 13:48:37.223 + expect(rpmSignal[1].y).toBe(1858); + + done(); + } catch (error) { + done(error); + } + }, 50); + }); + + test('should gracefully skip AlfaOBD rows with badly formatted or missing time', (done) => { + const alfaOBD_CSV = `Time,Engine speed rpm +13:48:35.666,1584 +invalid_time_string,2000 +13:48:37.223,1858 +13:48,2200`; // Missing seconds entirely (parts.length === 2) + + const event = { + target: { + files: [ + new File([alfaOBD_CSV], 'alfaobd_corrupt.csv', { + type: 'text/csv', + }), + ], + }, + }; + + dataProcessor.handleLocalFile(event); + + setTimeout(() => { + try { + const file = AppState.files[0]; + const rpmSignal = file.signals['Engine speed rpm']; + + // Should have skipped 'invalid_time_string' and '13:48' (since parts.length !== 3) + expect(rpmSignal.length).toBe(2); + + // Ensure only the valid timestamps made it through + expect(rpmSignal[0].x).toBe(49715666); // 13:48:35.666 + expect(rpmSignal[1].x).toBe(49717223); // 13:48:37.223 + + done(); + } catch (error) { + done(error); + } + }, 50); + }); + }); });