Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/lib/parse/float-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { attachIndex } from '../misc';
import { RowKey, State, type Row, type RowWithIndex, Units } from './types';
import { FloatControlLimitedError, ParseError } from './errors';

export function looksLikeFloatControlCsv(headerLine: string): boolean {
return headerLine
.split(',')
.map((header) => header.trim())
.some((header) => header in floatControlKeyMap);
}

const transformHeader = (header: string) => {
const key = floatControlKeyMap[header as FloatControlRawHeader];
if (!key && !Object.values(RowKey).includes(header as RowKey)) {
Expand Down
32 changes: 31 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,32 @@ 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);
});
});
216 changes: 170 additions & 46 deletions src/lib/parse/floaty.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,74 @@
import csv from 'papaparse';

import { type ParseResult } from './index';
import { attachIndex } from '../misc';
import { FloatyJsonSchema, type ZFloatyJson, type ZLocation, type ZLog } from './floaty.types';
import { DataSource, stateCodeMap, Units, type Row } from './types';
import { ParseError } from './errors';

function rowsFromFloatyJson(json: ZFloatyJson): Row[] {
const rows: Row[] = [];
// NOTE: sometimes Floaty doesn't record values, and seems to just put `null` (or 0) in its logs.
// When it does, we backtrack until we find the last known value for it.
const findValue = (index: number, key: keyof ZLog, floatyEmptyValue?: unknown): number => {
const current = json.logs[index]![key];
if (current !== null && (floatyEmptyValue === undefined || current !== floatyEmptyValue)) {
return current;
}
/** Headers that uniquely identify a Floaty CSV export (vs Float Control / VESC Tool). */
const FLOATY_CSV_MARKERS = ['dutyCycle', 'batteryVolts', 'tripDistance'] as const;

let i = index - 1;
while (i > 0) {
const value = json.logs[i]![key];
if (value !== null && (floatyEmptyValue === undefined || value !== floatyEmptyValue)) {
return value;
}
export function looksLikeFloatyCsv(headerLine: string): boolean {
const headers = new Set(headerLine.split(',').map((header) => header.trim()));
return FLOATY_CSV_MARKERS.every((marker) => headers.has(marker));
}

i--;
/**
* NOTE: sometimes Floaty doesn't record values, and seems to just put `null` (or 0) in its logs.
* When it does, we backtrack until we find the last known value for it.
*/
function findValue(logs: ZLog[], index: number, key: keyof ZLog, floatyEmptyValue?: unknown): number {
const current = logs[index]![key];
if (current !== null && (floatyEmptyValue === undefined || current !== floatyEmptyValue)) {
return current as number;
}

let i = index - 1;
while (i > 0) {
const value = logs[i]![key];
if (value !== null && (floatyEmptyValue === undefined || value !== floatyEmptyValue)) {
return value as number;
}

return 0;
};
i--;
}

const map = (log: ZLog, location: ZLocation, index: number): Row => {
const state_raw = findValue(index, 'state');
return {
adc1: findValue(index, 'adc1'),
adc2: findValue(index, 'adc2'),
ah: findValue(index, 'ampHours'),
altitude: location.altitude,
current_battery: findValue(index, 'batteryCurrent'),
current_motor: findValue(index, 'motorCurrent'),
distance: findValue(index, 'tripDistance', 0),
duty: findValue(index, 'dutyCycle') * 100,
gps_accuracy: location.accuracy,
gps_latitude: location.latitude,
gps_longitude: location.longitude,
motor_fault: findValue(index, 'faultCode'),
pitch: findValue(index, 'pitchAngle'),
roll: findValue(index, 'rollAngle'),
speed: findValue(index, 'speed'),
state_raw,
state: stateCodeMap[state_raw] ?? '??',
temp_mosfet: findValue(index, 'controllerTemp', 0),
temp_motor: findValue(index, 'motorTemp', 0),
time: (log.timestamp - json.startTime) / 1000,
true_pitch: findValue(index, 'truePitchAngle'),
voltage: findValue(index, 'batteryVolts'),
wh: findValue(index, 'wattHours'),
};
return 0;
}

function mapFloatyLog(logs: ZLog[], location: ZLocation, index: number, startTime: number): Row {
const log = logs[index]!;
const state_raw = findValue(logs, index, 'state');
return {
adc1: findValue(logs, index, 'adc1'),
adc2: findValue(logs, index, 'adc2'),
ah: findValue(logs, index, 'ampHours'),
altitude: location.altitude,
current_battery: findValue(logs, index, 'batteryCurrent'),
current_motor: findValue(logs, index, 'motorCurrent'),
distance: findValue(logs, index, 'tripDistance', 0),
duty: findValue(logs, index, 'dutyCycle') * 100,
gps_accuracy: location.accuracy,
gps_latitude: location.latitude,
gps_longitude: location.longitude,
motor_fault: findValue(logs, index, 'faultCode'),
pitch: findValue(logs, index, 'pitchAngle'),
roll: findValue(logs, index, 'rollAngle'),
speed: findValue(logs, index, 'speed'),
state_raw,
state: stateCodeMap[state_raw] ?? '??',
temp_mosfet: findValue(logs, index, 'controllerTemp', 0),
temp_motor: findValue(logs, index, 'motorTemp', 0),
time: (log.timestamp - startTime) / 1000,
true_pitch: findValue(logs, index, 'truePitchAngle'),
voltage: findValue(logs, index, 'batteryVolts'),
wh: findValue(logs, index, 'wattHours'),
};
}

function rowsFromFloatyJson(json: ZFloatyJson): Row[] {
const rows: Row[] = [];
const { logs, locations } = json;
let locationIdx = 0;
for (let i = 0; i < logs.length; ++i) {
Expand All @@ -65,12 +78,123 @@ function rowsFromFloatyJson(json: ZFloatyJson): Row[] {
location = locations[++locationIdx]!;
}

rows.push(map(log, location, i));
rows.push(mapFloatyLog(logs, location, i, json.startTime));
}

return rows;
}

const optionalNumber = (value: string | undefined): number | null => {
if (value === undefined || value === '') {
return null;
}

const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};

const requiredNumber = (value: string | undefined, fallback = 0): number => {
const parsed = optionalNumber(value);
return parsed === null ? fallback : parsed;
};

type FloatyCsvRow = Record<string, string>;

function csvRowToLog(row: FloatyCsvRow): ZLog {
return {
adc1: optionalNumber(row.adc1),
adc2: optionalNumber(row.adc2),
ampHours: optionalNumber(row.ampHours),
batteryCurrent: optionalNumber(row.batteryCurrent),
batteryPercent: optionalNumber(row.batteryPercent),
batteryVolts: optionalNumber(row.batteryVolts),
controllerTemp: optionalNumber(row.controllerTemp),
dutyCycle: optionalNumber(row.dutyCycle),
faultCode: optionalNumber(row.faultCode),
inputTilt: optionalNumber(row.inputTilt),
lifeDistance: optionalNumber(row.lifeDistance),
motorCurrent: optionalNumber(row.motorCurrent),
motorTemp: optionalNumber(row.motorTemp),
pitchAngle: optionalNumber(row.pitchAngle),
remainingDistance: optionalNumber(row.remainingDistance),
rollAngle: optionalNumber(row.rollAngle),
setpointAdjustmentType: optionalNumber(row.setpointAdjustmentType),
speed: optionalNumber(row.speed),
state: optionalNumber(row.state),
switchState: optionalNumber(row.switchState),
throttle: optionalNumber(row.throttle),
timestamp: requiredNumber(row.timestamp),
tripDistance: optionalNumber(row.tripDistance),
truePitchAngle: optionalNumber(row.truePitchAngle),
wattHours: optionalNumber(row.wattHours),
};
}

function csvRowToLocation(row: FloatyCsvRow): ZLocation {
return {
timestamp: requiredNumber(row.gpsTimestamp ?? row.timestamp),
altitude: requiredNumber(row.altitude),
latitude: requiredNumber(row.latitude),
longitude: requiredNumber(row.longitude),
accuracy: requiredNumber(row.accuracy),
speed: requiredNumber(row.gpsSpeed),
};
}

function rowsFromFloatyCsv(rawRows: FloatyCsvRow[]): Row[] {
const logs = rawRows.map(csvRowToLog);
const startTime = logs[0]?.timestamp ?? 0;
return rawRows.map((raw, index) => mapFloatyLog(logs, csvRowToLocation(raw), index, startTime));
}

export async function parseFloatyCsv(input: string | File): Promise<ParseResult> {
const text = typeof input === 'string' ? input : await input.text();

return new Promise((resolve) => {
csv.parse<FloatyCsvRow>(text, {
header: true,
skipEmptyLines: true,
complete: (results) => {
try {
if (results.data.length === 0) {
resolve({
source: DataSource.Floaty,
data: [],
units: Units.Metric,
errors: [new ParseError('Floaty CSV contained no rows!', results.errors)],
});
return;
}

resolve({
source: DataSource.Floaty,
data: attachIndex(rowsFromFloatyCsv(results.data)),
units: Units.Metric,
errors: results.errors.length
? [new ParseError('Failed to parse Floaty CSV properly!', results.errors)]
: [],
});
} catch (error) {
resolve({
source: DataSource.Floaty,
data: [],
units: Units.Metric,
errors: [new ParseError('Failed to parse Floaty CSV!', error)],
});
}
},
error: (error: Error) => {
resolve({
source: DataSource.Floaty,
data: [],
units: Units.Metric,
errors: [new ParseError('Failed to parse Floaty CSV!', error)],
});
},
});
});
}

export async function parseFloatyJson(input: string | File): Promise<ParseResult> {
try {
const json = JSON.parse(typeof input === 'string' ? input : await input.text());
Expand Down
Loading