summaryrefslogtreecommitdiff
path: root/src/data/parse-replay-csv.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/parse-replay-csv.js')
-rw-r--r--src/data/parse-replay-csv.js108
1 files changed, 108 insertions, 0 deletions
diff --git a/src/data/parse-replay-csv.js b/src/data/parse-replay-csv.js
new file mode 100644
index 0000000..b6ce97a
--- /dev/null
+++ b/src/data/parse-replay-csv.js
@@ -0,0 +1,108 @@
+function splitRow(line) {
+ return line.split(/[;,\t]/).map((value) => value.trim());
+}
+
+function isNumeric(value) {
+ return value !== '' && Number.isFinite(Number(value));
+}
+
+function detectHeader(rows) {
+ if (rows.length === 0) {
+ return { hasHeader: false, headers: [] };
+ }
+
+ const [firstRow] = rows;
+ const hasHeader = firstRow.some((value) => !isNumeric(value));
+ return {
+ hasHeader,
+ headers: hasHeader ? firstRow.map((value) => value.toLowerCase()) : [],
+ };
+}
+
+function detectTimeScale(headers) {
+ const timeHeader = headers.find((header) => header.includes('time') || header.includes('timestamp'));
+ if (!timeHeader) {
+ return 1;
+ }
+
+ if (timeHeader.includes('sec') && !timeHeader.includes('msec') && !timeHeader.includes('ms')) {
+ return 1000;
+ }
+
+ return 1;
+}
+
+function detectColumnIndexes(headers, columnCount) {
+ if (headers.length === 0) {
+ return {
+ timeIndex: columnCount > 1 ? 0 : -1,
+ valueIndex: columnCount > 1 ? 1 : 0,
+ };
+ }
+
+ const timeIndex = headers.findIndex((header) => header.includes('time') || header.includes('timestamp'));
+ const valueIndex = headers.findIndex((header) => header.includes('value') || header.includes('signal') || header.includes('y'));
+
+ return {
+ timeIndex,
+ valueIndex: valueIndex >= 0 ? valueIndex : (headers.length > 1 ? 1 : 0),
+ };
+}
+
+export function parseReplayCsv(text, { sampleRateHz = 60 } = {}) {
+ const rows = text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith('#'))
+ .map(splitRow)
+ .filter((row) => row.some((value) => value !== ''));
+
+ if (rows.length === 0) {
+ throw new Error('CSV file is empty');
+ }
+
+ const { hasHeader, headers } = detectHeader(rows);
+ const dataRows = hasHeader ? rows.slice(1) : rows;
+ const columnCount = rows[0].length;
+ const { timeIndex, valueIndex } = detectColumnIndexes(headers, columnCount);
+ const timeScale = detectTimeScale(headers);
+ const intervalMs = 1000 / Math.max(1, sampleRateHz);
+
+ const points = dataRows
+ .map((row, index) => {
+ const rawValue = row[valueIndex];
+ if (!isNumeric(rawValue)) {
+ return null;
+ }
+
+ const parsedValue = Number(rawValue);
+ const parsedTime = timeIndex >= 0 && isNumeric(row[timeIndex])
+ ? Number(row[timeIndex]) * timeScale
+ : index * intervalMs;
+
+ return {
+ timeMs: parsedTime,
+ value: parsedValue,
+ };
+ })
+ .filter(Boolean)
+ .sort((left, right) => left.timeMs - right.timeMs);
+
+ if (points.length === 0) {
+ throw new Error('CSV file did not contain any numeric data points');
+ }
+
+ const firstTime = points[0].timeMs;
+ const normalizedPoints = points.map((point) => ({
+ timeMs: point.timeMs - firstTime,
+ value: point.value,
+ }));
+
+ return {
+ points: normalizedPoints,
+ metadata: {
+ pointCount: normalizedPoints.length,
+ durationMs: normalizedPoints.at(-1)?.timeMs ?? 0,
+ },
+ };
+}