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
72 changes: 71 additions & 1 deletion src/dataprocessor.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
console.error('Config Loader:', error);
try {
Config.ANOMALY_TEMPLATES = {};
} catch (e) {

Check warning on line 46 in src/dataprocessor.js

View workflow job for this annotation

GitHub Actions / validate_and_build

'e' is defined but never used

Check warning on line 46 in src/dataprocessor.js

View workflow job for this annotation

GitHub Actions / validate_and_build

'e' is defined but never used
/* ignore */
}
}
Expand Down Expand Up @@ -74,7 +74,13 @@
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);
}
Expand Down Expand Up @@ -180,6 +186,70 @@
}
}

/**
* 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
Expand Down
106 changes: 106 additions & 0 deletions tests/dataprocessor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<div id="chartContainer"></div>
<div id="fileInfo"></div>
`;

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);
});
});
});
Loading