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
5 changes: 3 additions & 2 deletions src/components/Details.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
stats: RideStats;
batterySpecs: ZBatterySpecs;
units: Units;
hasAdcTelemetry: boolean;
}
</script>

Expand All @@ -22,7 +23,7 @@
import { formatFloat, formatInt } from '../lib/misc';
import { globalState } from '../lib/global.svelte';

let { data = empty, stats, batterySpecs, units }: Props = $props();
let { data = empty, stats, batterySpecs, units, hasAdcTelemetry }: Props = $props();

let showStats = $state(false);

Expand Down Expand Up @@ -76,7 +77,7 @@
]}
/>
{:else}
<Footpads {data} />
<Footpads {data} {hasAdcTelemetry} />
{/if}
</div>

Expand Down
15 changes: 8 additions & 7 deletions src/components/Footpads.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
import type { Row } from '../lib/parse/types';
import List from './List.svelte';

let { data }: { data: Row } = $props();
let { data, hasAdcTelemetry }: { data: Row; hasAdcTelemetry: boolean } = $props();
let goingSlow = $derived(data.speed < 2);
let adc1Enabled = $derived(data.adc1 > 2);
let adc2Enabled = $derived(data.adc2 > 2);

const ACTIVE_COLOUR = '#0ea5e9';
const INACTIVE_COLOUR = '#991b1b';
const UNKNOWN_COLOUR = '#475569';
</script>

<svg version="1.1" role="graphics-object" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 80" height="50%">
Expand All @@ -18,11 +19,11 @@
d="M 61.135201,5.4592 H 38.864798 A 33.405601,13.634527 0 0 0 5.4591996,19.09373 V 74.5408 H 94.5408 V 19.09373 A 33.405601,13.634527 0 0 0 61.135201,5.4592 m -11.135196,8.180715 v 52.720169"
/>
<path
fill={adc1Enabled ? ACTIVE_COLOUR : goingSlow ? 'none' : INACTIVE_COLOUR}
fill={hasAdcTelemetry ? (adc1Enabled ? ACTIVE_COLOUR : goingSlow ? 'none' : INACTIVE_COLOUR) : UNKNOWN_COLOUR}
d="M 44.432401,10.913009 V 69.086991 H 11.026803 V 20.366282 a 27.838002,9.4532727 0 0 1 27.837995,-9.453273 h 5.567603"
/>
<path
fill={adc2Enabled ? ACTIVE_COLOUR : goingSlow ? 'none' : INACTIVE_COLOUR}
fill={hasAdcTelemetry ? (adc2Enabled ? ACTIVE_COLOUR : goingSlow ? 'none' : INACTIVE_COLOUR) : UNKNOWN_COLOUR}
d="M 55.567598,69.086991 H 88.973197 V 20.366282 A 27.838002,9.4532727 0 0 0 61.135201,10.913009 h -5.567603 v 58.173982"
/>
</svg>
Expand All @@ -32,13 +33,13 @@
items={[
{
label: 'ADC1',
value: data.adc1.toFixed(2),
color: adc1Enabled ? 'yellowgreen' : goingSlow ? 'grey' : 'red',
value: hasAdcTelemetry ? data.adc1.toFixed(2) : 'unknown',
color: hasAdcTelemetry ? (adc1Enabled ? 'yellowgreen' : goingSlow ? 'grey' : 'red') : 'grey',
},
{
label: 'ADC2',
value: data.adc2.toFixed(2),
color: adc2Enabled ? 'yellowgreen' : goingSlow ? 'grey' : 'red',
value: hasAdcTelemetry ? data.adc2.toFixed(2) : 'unknown',
color: hasAdcTelemetry ? (adc2Enabled ? 'yellowgreen' : goingSlow ? 'grey' : 'red') : 'grey',
},
]}
/>
Expand Down
1 change: 1 addition & 0 deletions src/components/Picker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
an exported <span class="font-mono">CSV</span> or <span class="font-mono">ZIP</span> file from
<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>... or drag and drop a supported file onto this window!</li>
</ul>
Expand Down
77 changes: 77 additions & 0 deletions src/components/View.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, test } from 'vitest';
import { DataSource, State, type RowWithIndex } from '../lib/parse/types';
import { extractGpsInformation, findPointsOfInterest } from './View';

const makeRow = (overrides: Partial<RowWithIndex> = {}): RowWithIndex => ({
index: 0,
adc1: 0,
adc2: 0,
ah: 0,
altitude: 0,
current_battery: 0,
current_motor: 0,
distance: 0,
duty: 0,
gps_accuracy: 0,
gps_latitude: 0,
gps_longitude: 0,
motor_fault: 0,
pitch: 0,
roll: 0,
speed: 0,
state: State.Riding,
state_raw: 0,
temp_mosfet: 0,
temp_motor: 0,
time: 0,
true_pitch: 0,
voltage: 0,
wh: 0,
...overrides,
});

describe(extractGpsInformation.name, () => {
test('replaces leading (0,0) gps points with first non-zero coordinate for all sources', () => {
const rows: RowWithIndex[] = [
makeRow({ index: 0, time: 0, gps_latitude: 0, gps_longitude: 0 }),
makeRow({ index: 1, time: 1, gps_latitude: 0, gps_longitude: 0 }),
makeRow({ index: 2, time: 2, gps_latitude: -37.81, gps_longitude: 144.96 }),
makeRow({ index: 3, time: 3, gps_latitude: -37.82, gps_longitude: 144.97 }),
];

const sources = [DataSource.FloatControl, DataSource.Floaty, DataSource.VescTool];
for (const source of sources) {
const { gpsPoints } = extractGpsInformation(rows, source);

expect(gpsPoints[0]).toEqual([-37.81, 144.96]);
expect(gpsPoints[1]).toEqual([-37.81, 144.96]);
expect(gpsPoints[2]).toEqual([-37.81, 144.96]);
expect(gpsPoints[3]).toEqual([-37.82, 144.97]);
}
});
});

describe(findPointsOfInterest.name, () => {
test('does not infer footpad faults when ADC telemetry is absent', () => {
const rows: RowWithIndex[] = [
makeRow({ index: 0, speed: 10, adc1: 0, adc2: 0 }),
makeRow({ index: 1, speed: 15, adc1: 0, adc2: 0 }),
];

const points = findPointsOfInterest(rows);
expect(points).toEqual([]);
});

test('still infers footpad faults when ADC telemetry is present', () => {
const rows: RowWithIndex[] = [
makeRow({ index: 0, speed: 10, adc1: 0, adc2: 3 }),
makeRow({ index: 1, speed: 10, adc1: 0, adc2: 1 }),
];

const points = findPointsOfInterest(rows);
expect(points).toEqual([
{ index: 0, state: State.Custom_OneFootpadAtSpeed },
{ index: 1, state: State.Custom_NoFootpadsAtSpeed },
]);
});
});
19 changes: 17 additions & 2 deletions src/components/View.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@
import Button from './Button.svelte';
import { parse, supportedMimeTypes } from '../lib/parse';
import { globalState } from '../lib/global.svelte';
import { computeStats, extractGpsInformation, findPointsOfInterest, type Banner, type RideStats } from './View';
import {
computeStats,
extractGpsInformation,
findPointsOfInterest,
hasAdcTelemetry,
type Banner,
type RideStats,
} from './View';
import { type ChartKey, Charts } from './Chart';
import { riderSvg } from './Map';
import PickerFull from './PickerFull.svelte';
Expand Down Expand Up @@ -62,6 +69,8 @@
let visible = $state<boolean[]>([]);
/** filtered visible rows */
let visibleRows = $derived(rows.filter((_, i) => visible[i]));
/** whether this ride appears to include footpad ADC telemetry */
let adcsEnabled = $derived(hasAdcTelemetry(rows));
/** indices of gaps between non-contiguous ranges in `visibleRows`; used for rendering vertical lines in charts */
let gapIndices = $derived.by(() => {
let gaps: number[] = [];
Expand Down Expand Up @@ -294,7 +303,13 @@
wide:[grid-column:span_2] wide:[grid-row:unset]"
class:details-swapped={swapMapAndDetails}
>
<Details {stats} data={visibleRows[selectedIndex]} batterySpecs={settings.batterySpecs} units={settings.units} />
<Details
{stats}
data={visibleRows[selectedIndex]}
batterySpecs={settings.batterySpecs}
units={settings.units}
hasAdcTelemetry={adcsEnabled}
/>
</div>

{#each settings.charts as key, index}
Expand Down
17 changes: 16 additions & 1 deletion src/components/View.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ export function extractGpsInformation(rows: RowWithIndex[], source: DataSource)
}
}

// Some logs begin before GNSS has a fix, producing leading (0, 0) values.
// Replace only the leading invalid points with the first non-zero coordinate
const firstGoodPointIndex = gpsPoints.findIndex(([lat, lon]) => lat !== 0 || lon !== 0);
if (firstGoodPointIndex > 0) {
const firstGoodPoint = gpsPoints[firstGoodPointIndex]!;
for (let i = 0; i < firstGoodPointIndex; ++i) {
gpsPoints[i] = firstGoodPoint;
}
}

// When Float Control starts recording a ride, it appears that the first few data points
// have incorrect GPS data. If it's the start of the ride, it's (0, 0), but if it's a resumed
// ride, then it seems to be the last known point from the paused ride.
Expand All @@ -178,8 +188,13 @@ export function extractGpsInformation(rows: RowWithIndex[], source: DataSource)
return { gpsPoints, gpsGaps };
}

export function hasAdcTelemetry(rows: RowWithIndex[]): boolean {
return rows.some((row) => row.adc1 !== 0 || row.adc2 !== 0);
}

export function findPointsOfInterest(rows: RowWithIndex[]): PointOfInterest[] {
const points: PointOfInterest[] = [];
const adcFaultsEnabled = hasAdcTelemetry(rows);
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;

Expand All @@ -191,7 +206,7 @@ export function findPointsOfInterest(rows: RowWithIndex[]): PointOfInterest[] {
}

// custom footpad faults
if (row.speed > 2) {
if (adcFaultsEnabled && row.speed > 2) {
const combinedAdcVoltage = row.adc1 + row.adc2;
if (combinedAdcVoltage < 2) {
states.push(State.Custom_NoFootpadsAtSpeed);
Expand Down
2 changes: 2 additions & 0 deletions src/lib/parse/__fixtures__/vesc_fault_unknown.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ms_today;input_voltage;temp_mos_max;temp_motor;current_motor;current_in;erpm;duty_cycle;amp_hours_used;watt_hours_used;tacho_meters;fault_code;speed_meters_per_sec;roll;pitch;gnss_lat;gnss_lon;gnss_alt;gnss_hAcc;
2000;80.0;19.1;27.0;14;11;1300;0.45;0.7000;60.0;2000;99;1.5;0.2;0.1;41.0;-76.0;150;1.2;
4 changes: 4 additions & 0 deletions src/lib/parse/__fixtures__/vesc_metric.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ms_today;input_voltage;temp_mos_max;temp_motor;current_motor;current_in;erpm;duty_cycle;amp_hours_used;watt_hours_used;tacho_meters;fault_code;speed_meters_per_sec;roll;pitch;gnss_lat;gnss_lon;gnss_alt;gnss_hAcc;
1000;81.5;18.8;26.5;10;9;1200;0.50;0.6502;51.836;1710.23;0;2.0;0.1;0.2;42.1;-77.1;200;2.5;
1100;81.4;18.9;26.7;12;10;1202;0.52;0.6504;52.0;1710.33;3;2.2;0.3;0.4;42.3;-77.3;202;2.7;
1300;81.3;19.0;26.8;13;10.5;1203;0.53;0.6505;52.1;1710.40;0;2.3;0.4;0.5;42.4;-77.4;203;2.8;
31 changes: 31 additions & 0 deletions src/lib/parse/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'vitest';

import fcMetricCsv from './__fixtures__/fc_metric.csv?raw';
import vescMetricCsv from './__fixtures__/vesc_metric.csv?raw';
import { parse } from './index';

class MockFile extends File {
constructor(
private fileParts: BlobPart[],
fileName: string,
options?: FilePropertyBag,
) {
super(fileParts, fileName, options);
}

async text() {
return this.fileParts.map((part) => part.toString()).join('');
}
}

describe(parse.name, () => {
test('routes semicolon-delimited CSV to VESC Tool parser', async () => {
const result = await parse(new MockFile([vescMetricCsv], 'vesc.csv', { type: 'text/csv' }));
expect(result.source).toBe('vesc_tool');
});

test('routes comma-delimited CSV to Float Control parser', async () => {
const result = await parse(new MockFile([fcMetricCsv], 'fc.csv', { type: 'text/csv' }));
expect(result.source).toBe('float_control');
});
});
14 changes: 13 additions & 1 deletion src/lib/parse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as fflate from 'fflate';

import { parseFloatControlCsv } from './float-control';
import { parseFloatyJson } from './floaty';
import { parseVescToolCsv } from './vesc-tool';
import { DataSource, Units, type RowWithIndex } from './types';
import { ParseError } from './errors';

Expand Down Expand Up @@ -50,7 +51,18 @@ export async function parse(file: File): Promise<ParseResult> {
}

if (file.type === SupportedMimeTypes.Csv || lowerName.endsWith('.csv')) {
const parsed = await parseFloatControlCsv(file);
const text = await file.text();
const firstLine = text.split(/\r?\n/, 1)[0] ?? '';
const semicolonCount = (firstLine.match(/;/g) ?? []).length;
const commaCount = (firstLine.match(/,/g) ?? []).length;

// Heuristic: VESC Tool exports are semicolon-delimited, while Float Control uses commas.
// If this does not look like VESC Tool, we fall back to Float Control parsing.
if (semicolonCount > commaCount) {
return await parseVescToolCsv(text);
}

const parsed = await parseFloatControlCsv(text);
return {
source: DataSource.FloatControl,
data: parsed.csv.data,
Expand Down
1 change: 1 addition & 0 deletions src/lib/parse/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export enum DataSource {
None = 'none',
FloatControl = 'float_control',
Floaty = 'floaty',
VescTool = 'vesc_tool',
}

export enum Units {
Expand Down
46 changes: 46 additions & 0 deletions src/lib/parse/vesc-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'vitest';

import csvMetric from './__fixtures__/vesc_metric.csv?raw';
import csvUnknownFault from './__fixtures__/vesc_fault_unknown.csv?raw';
import { parseVescToolCsv } from './vesc-tool';

describe(parseVescToolCsv.name, () => {
test('maps VESC CSV fields', async () => {
const parsed = await parseVescToolCsv(csvMetric);

expect(parsed.source).toBe('vesc_tool');
expect(parsed.units).toBe('metric');
expect(parsed.errors).toEqual([]);

const first = parsed.data[0]!;
expect(first.time).toBe(0);
expect(first.voltage).toBe(81.5);
expect(first.temp_mosfet).toBe(18.8);
expect(first.temp_motor).toBe(26.5);
expect(first.current_motor).toBe(10);
expect(first.current_battery).toBe(9);
expect(first.duty).toBe(50);
expect(first.speed).toBeCloseTo(7.2, 6);
expect(first.distance).toBeCloseTo(1.71023, 6);
expect(first.state).toBe('riding');
expect(first.state_raw).toBe(0);
expect(parsed.data).toHaveLength(3);

const second = parsed.data[1]!;
expect(second.time).toBeCloseTo(0.1, 8);
expect(second.state_raw).toBe(3);
expect(second.state).toBe('wheelslip');

const third = parsed.data[2]!;
expect(third.time).toBeCloseTo(0.3, 8);
});

test('keeps unknown fault codes as explicit state labels', async () => {
const parsed = await parseVescToolCsv(csvUnknownFault);

expect(parsed.errors).toEqual([]);
expect(parsed.data).toHaveLength(1);
expect(parsed.data[0]!.state_raw).toBe(99);
expect(parsed.data[0]!.state).toBe('fault 99');
});
});
Loading
Loading