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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Now you'll have `Float View` all the time, even offline!
## How do I use it?

1. Get on your board and record a ride with [Float Control] or [Floaty]
2. Export your ride data (Float Control will put it in a `.csv.zip`, Floaty in a `.json`)
2. Export your ride data (Float Control will put it in a `.csv.zip`, Floaty in a `.json` or `.csv`)
3. Load it up in <https://acheronfail.github.io/float-view/> (if it's zipped, unzip it first!)
4. Enjoy!

Expand Down
5 changes: 4 additions & 1 deletion src/components/Picker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
<strong>Float Control</strong>
</li>
<li>an exported <span class="font-mono">CSV</span> file from <strong>VESC Tool</strong></li>
<li>an exported <span class="font-mono">JSON</span> file from <strong>Floaty</strong></li>
<li>
an exported <span class="font-mono">JSON</span> or <span class="font-mono">CSV</span> file from
<strong>Floaty</strong>
</li>
<li>... or drag and drop a supported file onto this window!</li>
</ul>
<input
Expand Down
27 changes: 23 additions & 4 deletions src/components/View.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -140,27 +140,46 @@
});
}

const parseErrors = results.errors.filter((err) => err instanceof ParseError);
for (const err of results.errors) {
if (err instanceof FloatControlLimitedError) {
banners.push({ text: err.message, kind: 'warning' });
}

if (err instanceof ParseError) {
console.error(err, err.cause);
alert(
`An error occurred when parsing ride, displayed data may be incomplete or incorrect! (${err.message})`,
);
}
}

// Fatal: nothing usable to show — surface the error and return to the file picker.
if (results.data.length === 0 && parseErrors.length > 0) {
alert(`Could not load ride:\n\n${parseErrors.map((err) => err.message).join('\n')}`);
file = undefined;
source = DataSource.None;
return;
}

if (parseErrors.length > 0) {
alert(
`An error occurred when parsing ride, displayed data may be incomplete or incorrect! (${parseErrors.map((err) => err.message).join('; ')})`,
);
}

rows = results.data;
// initialize visibility and trimming to full range for the newly loaded ride
visibleFromMap = new Array(rows.length).fill(true);
trimStart = 0;
trimEnd = rows.length ? rows.length - 1 : 0;
selectedIndex = 0;

stats = computeStats(rows, pointsOfInterest);
stats = computeStats(rows, findPointsOfInterest(rows));
})
.catch((error) => {
clearTimeout(timer);
console.error(error);
alert(`Could not load ride:\n\n${error instanceof Error ? error.message : String(error)}`);
file = undefined;
source = DataSource.None;
})
.finally(() => (loading = false));
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/View.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function computeStats(rows: RowWithIndex[], pois: PointOfInterest[]): Rid
highestFieldWeakeningCurrent,
highestTempMotor,
highestTempController,
totalDistanceMeters: rows[rows.length - 1]!.distance,
totalDistanceMeters: rows.at(-1)?.distance ?? 0,
};
}

Expand Down
4 changes: 4 additions & 0 deletions src/lib/parse/__fixtures__/floaty.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
timestamp,speed,dutyCycle,batteryVolts,batteryPercent,batteryCurrent,motorCurrent,motorTemp,controllerTemp,tripDistance,lifeDistance,remainingDistance,rollAngle,pitchAngle,truePitchAngle,inputTilt,throttle,ampHours,wattHours,state,switchState,setpointAdjustmentType,faultCode,adc1,adc2,sessionId,altitude,latitude,longitude,accuracy,gpsSpeed,gpsTimestamp
105,0.4,0.03,81.9,0.92,0.2,9.9,18,18,0,592.211,0,-4,1,0,0,0,0,0,1,1,0,0,3.1,0.08,00000000-0000-0000-0000-000000000000,122.16287420969456,-1.0,1.5,4.55257009550728,0.23000000417232513,110.0522
115,0.7,0.04,81.8,0.92,0.4,14,21,18,0.5,592.211,0,-4,0,-1,0,0,0,0,1,1,2,0,3.1,0.08,00000000-0000-0000-0000-000000000000,123.54580882564187,-1.1,1.6,4.552570096263577,0.769999980926508,120.0503
125,,,,0.92,,,0,0,0,592.211,0,-4,0,-1,0,0,0,0,1,3,2,0,3.1,3.08,00000000-0000-0000-0000-000000000000,123.54580882564187,-1.1,1.6,4.552570096263577,0.769999980926508,120.0503
53 changes: 53 additions & 0 deletions src/lib/parse/csv-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import csv from 'papaparse';

import { floatControlKeyMap, FloatControlRawHeader } from './float-control.types';
import { RowKey } from './types';

export enum CsvFormat {
FloatControl = 'float_control',
Floaty = 'floaty',
VescTool = 'vesc_tool',
Unknown = 'unknown',
}

const FLOATY_SIGNATURE = ['timestamp', 'dutyCycle', 'batteryVolts', 'tripDistance'] as const;
const FLOAT_CONTROL_SIGNATURE = [RowKey.Time, RowKey.Speed, RowKey.Duty] as const;
const VESC_TOOL_SIGNATURE = ['ms_today', 'input_voltage', 'duty_cycle'] as const;
const rowKeys = new Set<string>(Object.values(RowKey));

export const cleanCsvHeader = (header: string): string => header.replace(/^\uFEFF/, '').trim();

export function normalizeFloatControlHeader(header: string): string {
const cleaned = cleanCsvHeader(header);
if (Object.hasOwn(floatControlKeyMap, cleaned)) {
return floatControlKeyMap[cleaned as FloatControlRawHeader];
}

return cleaned;
}

export const isNormalizedRowKey = (header: string): boolean => rowKeys.has(header);

const containsAll = (headers: Set<string>, signature: readonly string[]): boolean =>
signature.every((header) => headers.has(header));

export function detectCsvFormat(text: string): CsvFormat {
const parsed = csv.parse<string[]>(text, {
preview: 1,
skipEmptyLines: true,
});
if (parsed.errors.length > 0 || parsed.data.length === 0) {
return CsvFormat.Unknown;
}

const headers = parsed.data[0]!.map((header) => cleanCsvHeader(String(header)));
const rawHeaders = new Set(headers);
const normalizedHeaders = new Set(headers.map(normalizeFloatControlHeader));
const matches = [
containsAll(rawHeaders, FLOATY_SIGNATURE) && CsvFormat.Floaty,
containsAll(normalizedHeaders, FLOAT_CONTROL_SIGNATURE) && CsvFormat.FloatControl,
containsAll(rawHeaders, VESC_TOOL_SIGNATURE) && CsvFormat.VescTool,
].filter((format): format is CsvFormat => format !== false);

return matches.length === 1 ? matches[0]! : CsvFormat.Unknown;
}
9 changes: 5 additions & 4 deletions src/lib/parse/float-control.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import csv, { type ParseResult } from 'papaparse';
import { floatControlKeyMap, FloatControlRawHeader } from './float-control.types';
import { FloatControlRawHeader } from './float-control.types';
import demoCsv from '../../assets/demo.csv?raw';
import { attachIndex } from '../misc';
import { RowKey, State, type Row, type RowWithIndex, Units } from './types';
import { FloatControlLimitedError, ParseError } from './errors';
import { isNormalizedRowKey, normalizeFloatControlHeader } from './csv-format';

const transformHeader = (header: string) => {
const key = floatControlKeyMap[header as FloatControlRawHeader];
if (!key && !Object.values(RowKey).includes(header as RowKey)) {
const normalized = normalizeFloatControlHeader(header);
if (normalized === header.trim() && !isNormalizedRowKey(normalized)) {
console.warn('Unknown header found in CSV file', { header });
}

return key ?? header;
return normalized;
};

const parseFloatValue = (input: string): number => {
Expand Down
46 changes: 45 additions & 1 deletion src/lib/parse/floaty.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, test } from 'vitest';
import { parseFloatyJson } from './floaty';
import { parseFloatyCsv, parseFloatyJson } from './floaty';
import floatyJsonString from './__fixtures__/floaty.json?raw';
import floatyJson from './__fixtures__/floaty.json';
import floatyCsv from './__fixtures__/floaty.csv?raw';

describe(parseFloatyJson.name, () => {
test('maps gps locations to logs', async () => {
Expand Down Expand Up @@ -57,3 +58,46 @@ describe(parseFloatyJson.name, () => {
expect(data[2]!.distance).toBe(data[1]!.distance);
});
});

describe(parseFloatyCsv.name, () => {
test('maps floaty csv rows including inline gps', async () => {
const { data, units, source, errors } = await parseFloatyCsv(floatyCsv);
expect(errors).toEqual([]);
expect(source).toBe('floaty');
expect(units).toEqual('metric');
expect(data).toHaveLength(3);

expect(data[0]!.speed).toBe(0.4);
expect(data[0]!.duty).toBe(3);
expect(data[0]!.voltage).toBe(81.9);
expect(data[0]!.gps_latitude).toBe(-1.0);
expect(data[0]!.gps_longitude).toBe(1.5);
expect(data[0]!.time).toBe(0);
expect(data[1]!.time).toBe(0.01);
});

test('backfills empty csv cells like floaty json nulls', async () => {
const { data } = await parseFloatyCsv(floatyCsv);
expect(data[2]!.current_battery).toBe(data[1]!.current_battery);
expect(data[2]!.voltage).toBe(data[1]!.voltage);
expect(data[2]!.duty).toBe(data[1]!.duty);
expect(data[2]!.speed).toBe(data[1]!.speed);
expect(data[2]!.temp_mosfet).toBe(data[1]!.temp_mosfet);
expect(data[2]!.temp_motor).toBe(data[1]!.temp_motor);
expect(data[2]!.distance).toBe(data[1]!.distance);
});

test('backfills the second row from the first row', async () => {
const header =
'timestamp,speed,dutyCycle,batteryVolts,tripDistance,latitude,longitude,altitude,accuracy,gpsSpeed,gpsTimestamp';
const input = `${header}\n1000,1,0.1,80,1,1,2,3,4,5,1000\n1010,,,,,1,2,3,4,5,1010`;
const { data } = await parseFloatyCsv(input);

expect(data[1]).toMatchObject({
speed: 1,
duty: 10,
voltage: 80,
distance: 1,
});
});
});
Loading
Loading