Skip to content

Commit bba8b71

Browse files
eriedclaude
andcommitted
feat(import): EUC World CSV compatibility (fixes "no GPS" on their backups)
EUC World names trip columns differently (gps_lat / gps_lon / datetime / temp / gps_alt / distance_total / gps_speed), so every trip-CSV parser, which looked for latitude / longitude / date / ..., missed them and read the wrong (or fallback-index) columns - landing lat/lon at 0 = "no GPS". Centralize column resolution in TripCsv.Columns (canonical EUC Planet / DarknessBot names first, foreign aliases after, so our own files are unchanged) and route the viewer, import, replay, and Dropbox/sync parsers through it. Non-destructive. Adds a drift-guard test with the real EUC World + EUC Planet headers plus an end-to-end check that an EUC World row now yields GPS coordinates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHUZZ33sT9FYS6sXiMogJc
1 parent fb0b3c8 commit bba8b71

6 files changed

Lines changed: 176 additions & 37 deletions

File tree

app/src/main/java/com/eried/eucplanet/data/repository/TripRepository.kt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -867,11 +867,9 @@ internal fun parseTripQuads(text: String): List<TripCsv.Quad> {
867867
val lines = text.split('\n')
868868
if (lines.size < 2) return emptyList()
869869
val h = lines[0].split(',').map { it.trim().lowercase(Locale.US) }
870-
fun idx(vararg names: String) =
871-
names.firstNotNullOfOrNull { h.indexOf(it).takeIf { i -> i >= 0 } } ?: -1
872-
val iDate = idx("date"); if (iDate < 0) return emptyList()
873-
val iLat = idx("latitude"); val iLon = idx("longitude")
874-
val iMile = idx("total mileage", "mileage", "distance")
870+
val iDate = TripCsv.Columns.date(h); if (iDate < 0) return emptyList()
871+
val iLat = TripCsv.Columns.latitude(h); val iLon = TripCsv.Columns.longitude(h)
872+
val iMile = TripCsv.Columns.mileage(h)
875873
return lines.asSequence().drop(1).mapNotNull { ln ->
876874
val c = ln.split(',')
877875
if (iDate >= c.size || c[iDate].isBlank()) return@mapNotNull null

app/src/main/java/com/eried/eucplanet/data/sync/SyncManager.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -354,11 +354,10 @@ class SyncManager @Inject constructor(
354354
file.bufferedReader().use { reader ->
355355
val headerLine = reader.readLine() ?: return CsvMeta(startTime, endTime, 0f)
356356
val header = headerLine.lowercase().split(",").map { it.trim() }
357-
val dateIdx = header.indexOfFirst { it == "date" }.takeIf { it >= 0 } ?: 0
358-
val latIdx = header.indexOfFirst { it == "latitude" }.takeIf { it >= 0 } ?: 6
359-
val lonIdx = header.indexOfFirst { it == "longitude" }.takeIf { it >= 0 } ?: 7
360-
val mileageIdx = header.indexOfFirst { it.contains("mileage") }
361-
.takeIf { it >= 0 } ?: 8
357+
val dateIdx = TripCsv.Columns.date(header).takeIf { it >= 0 } ?: 0
358+
val latIdx = TripCsv.Columns.latitude(header).takeIf { it >= 0 } ?: 6
359+
val lonIdx = TripCsv.Columns.longitude(header).takeIf { it >= 0 } ?: 7
360+
val mileageIdx = TripCsv.Columns.mileage(header).takeIf { it >= 0 } ?: 8
362361
var first = true
363362
// Stay streaming (one big CSV never fully resident), but share
364363
// the timestamp + great-circle logic with the import/detail

app/src/main/java/com/eried/eucplanet/ui/recording/RecordingViewModel.kt

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -490,11 +490,10 @@ class RecordingViewModel @Inject constructor(
490490
// Detect column layout from header so foreign exports (different
491491
// column order / names) still line up.
492492
val header = lines[0].lowercase().split(",").map { it.trim() }
493-
val dateIdx = header.indexOfFirst { it == "date" }.takeIf { it >= 0 } ?: 0
494-
val latIdx = header.indexOfFirst { it == "latitude" }.takeIf { it >= 0 } ?: 6
495-
val lonIdx = header.indexOfFirst { it == "longitude" }.takeIf { it >= 0 } ?: 7
496-
val mileageIdx = header.indexOfFirst { it.contains("mileage") }
497-
.takeIf { it >= 0 } ?: 8
493+
val dateIdx = TripCsv.Columns.date(header).takeIf { it >= 0 } ?: 0
494+
val latIdx = TripCsv.Columns.latitude(header).takeIf { it >= 0 } ?: 6
495+
val lonIdx = TripCsv.Columns.longitude(header).takeIf { it >= 0 } ?: 7
496+
val mileageIdx = TripCsv.Columns.mileage(header).takeIf { it >= 0 } ?: 8
498497

499498
val rows = ArrayList<TripCsv.Quad>(lines.size)
500499
for (i in 1 until lines.size) {
@@ -555,19 +554,22 @@ class RecordingViewModel @Inject constructor(
555554
val headers = headerLine.lowercase().split(",").map { it.trim() }
556555

557556
// Detect column indices from header to support DarknessBot and similar formats
558-
val iSpeed = headers.indexOfFirst { it == "speed" }.takeIf { it >= 0 } ?: 1
559-
val iVoltage = headers.indexOfFirst { it == "voltage" }.takeIf { it >= 0 } ?: 2
560-
val iTemp = headers.indexOfFirst { it == "temperature" }.takeIf { it >= 0 } ?: 3
561-
val iBattery = headers.indexOfFirst { it.contains("battery") }.takeIf { it >= 0 } ?: 4
562-
val iAltitude = headers.indexOfFirst { it == "altitude" }.takeIf { it >= 0 } ?: 5
563-
val iLat = headers.indexOfFirst { it == "latitude" }.takeIf { it >= 0 } ?: 6
564-
val iLon = headers.indexOfFirst { it == "longitude" }.takeIf { it >= 0 } ?: 7
565-
val iMileage = headers.indexOfFirst { it.contains("mileage") }.takeIf { it >= 0 } ?: 8
566-
// EUC Planet extensions; -1 when the column is absent (older trip files).
567-
val iGpsSpeed = headers.indexOfFirst { it == "gps speed" }
557+
// Format-tolerant column resolution (canonical EUC Planet /
558+
// DarknessBot names first, EUC World aliases after) lives in
559+
// TripCsv.Columns so every trip-CSV path resolves identically.
560+
val iSpeed = TripCsv.Columns.speed(headers).takeIf { it >= 0 } ?: 1
561+
val iVoltage = TripCsv.Columns.voltage(headers).takeIf { it >= 0 } ?: 2
562+
val iTemp = TripCsv.Columns.temperature(headers).takeIf { it >= 0 } ?: 3
563+
val iBattery = TripCsv.Columns.battery(headers).takeIf { it >= 0 } ?: 4
564+
val iAltitude = TripCsv.Columns.altitude(headers).takeIf { it >= 0 } ?: 5
565+
val iLat = TripCsv.Columns.latitude(headers).takeIf { it >= 0 } ?: 6
566+
val iLon = TripCsv.Columns.longitude(headers).takeIf { it >= 0 } ?: 7
567+
val iMileage = TripCsv.Columns.mileage(headers).takeIf { it >= 0 } ?: 8
568+
// -1 when the column is absent (older trip files / foreign exports).
569+
val iGpsSpeed = TripCsv.Columns.gpsSpeed(headers)
568570
val iExtGps = headers.indexOfFirst { it.startsWith("ext gps") || it.startsWith("ext_gps") || it == "external gps speed" }
569-
val iCurrent = headers.indexOfFirst { it == "current" }
570-
val iPwm = headers.indexOfFirst { it == "pwm" }
571+
val iCurrent = TripCsv.Columns.current(headers)
572+
val iPwm = TripCsv.Columns.pwm(headers)
571573

572574
var line = reader.readLine()
573575
while (line != null) {

app/src/main/java/com/eried/eucplanet/ui/studio/StudioReplay.kt

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.eried.eucplanet.ui.studio
22

33
import com.eried.eucplanet.data.model.WheelData
4+
import com.eried.eucplanet.util.TripCsv
45
import java.text.SimpleDateFormat
56
import java.util.Locale
67

@@ -55,20 +56,20 @@ fun parseTripCsv(text: String): ReplayTrip {
5556
val header = lines.next().split(',').map { it.trim().lowercase(Locale.US) }
5657
fun idx(vararg names: String): Int =
5758
names.firstNotNullOfOrNull { header.indexOf(it).takeIf { i -> i >= 0 } } ?: -1
58-
val iDate = idx("date")
59+
val iDate = TripCsv.Columns.date(header)
5960
if (iDate < 0) return ReplayTrip(emptyList())
60-
val iSpeed = idx("speed")
61-
val iVoltage = idx("voltage")
62-
val iCurrent = idx("current")
63-
val iPwm = idx("pwm")
64-
val iTemp = idx("temperature")
65-
val iBattery = idx("battery level", "battery")
66-
val iMileage = idx("total mileage", "mileage", "distance")
61+
val iSpeed = TripCsv.Columns.speed(header)
62+
val iVoltage = TripCsv.Columns.voltage(header)
63+
val iCurrent = TripCsv.Columns.current(header)
64+
val iPwm = TripCsv.Columns.pwm(header)
65+
val iTemp = TripCsv.Columns.temperature(header)
66+
val iBattery = TripCsv.Columns.battery(header)
67+
val iMileage = TripCsv.Columns.mileage(header)
6768
val iGForce = idx("g-force", "gforce")
6869
val iAccelX = idx("g-force x")
6970
val iAccelY = idx("g-force y")
70-
val iLat = idx("latitude")
71-
val iLon = idx("longitude")
71+
val iLat = TripCsv.Columns.latitude(header)
72+
val iLon = TripCsv.Columns.longitude(header)
7273

7374
val out = ArrayList<ReplaySample>()
7475
var firstMs = -1L

app/src/main/java/com/eried/eucplanet/util/TripCsv.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,47 @@ object TripCsv {
147147

148148
/** One sample's fields relevant to trip metrics. */
149149
data class Quad(val date: String, val lat: Double, val lon: Double, val mileage: Float)
150+
151+
/**
152+
* Resolve a trip-CSV column index from a lowercased, trimmed header row by
153+
* trying each candidate name in order; -1 when none is present. Canonical
154+
* EUC Planet / DarknessBot names MUST come first and foreign aliases after,
155+
* so our own files never change which column wins.
156+
*/
157+
fun columnIndex(header: List<String>, vararg names: String): Int =
158+
names.firstNotNullOfOrNull { header.indexOf(it).takeIf { i -> i >= 0 } } ?: -1
159+
160+
/**
161+
* Header names we accept for each logical trip column, so every parser reads
162+
* foreign exports identically. EUC Planet / DarknessBot use the canonical
163+
* (first) name; EUC World (euc.world) backups name several columns
164+
* differently - gps_lat / gps_lon / datetime / gps_alt / temp /
165+
* distance_total / gps_speed - which is why an EUC World file used to import
166+
* with lat/lon 0 ("no GPS"). Add a new source's names HERE (with a case in
167+
* TripCsvColumnsTest), never at a call site, so every path stays in sync.
168+
*/
169+
object Columns {
170+
fun date(h: List<String>) = columnIndex(h, "date", "datetime")
171+
fun speed(h: List<String>) = columnIndex(h, "speed")
172+
fun voltage(h: List<String>) = columnIndex(h, "voltage")
173+
fun current(h: List<String>) = columnIndex(h, "current")
174+
fun pwm(h: List<String>) = columnIndex(h, "pwm")
175+
fun temperature(h: List<String>) = columnIndex(h, "temperature", "temp")
176+
fun altitude(h: List<String>) = columnIndex(h, "altitude", "gps_alt")
177+
fun latitude(h: List<String>) = columnIndex(h, "latitude", "gps_lat")
178+
fun longitude(h: List<String>) = columnIndex(h, "longitude", "gps_lon")
179+
fun gpsSpeed(h: List<String>) = columnIndex(h, "gps speed", "gps_speed")
180+
181+
/** First column whose name contains "battery" - EUC Planet
182+
* "Battery level", EUC World "battery" - else -1. */
183+
fun battery(h: List<String>) = h.indexOfFirst { it.contains("battery") }
184+
185+
/** Odometer / total-distance column. EUC Planet "Total mileage" (any
186+
* name containing "mileage"); EUC World has no mileage column, so fall
187+
* back to its lifetime odometer "distance_total", then trip "distance". */
188+
fun mileage(h: List<String>): Int {
189+
val byMileage = h.indexOfFirst { it.contains("mileage") }
190+
return if (byMileage >= 0) byMileage else columnIndex(h, "distance_total", "distance")
191+
}
192+
}
150193
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.eried.eucplanet.util
2+
3+
import com.eried.eucplanet.data.repository.parseTripQuads
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Assert.assertNotNull
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
import java.util.Locale
9+
10+
/**
11+
* Guards the trip-CSV column aliases (drift guard, per project rule 13). An
12+
* EUC World backup names its columns differently (gps_lat / gps_lon / datetime
13+
* / temp / gps_alt / distance_total / gps_speed), which is why such a file used
14+
* to import with lat/lon 0 ("no GPS"). These tests pin that (a) EUC World rows
15+
* resolve GPS via the aliases and (b) our own EUC Planet format still resolves
16+
* to the exact same indices. Change TripCsv.Columns and this test together.
17+
*/
18+
class TripCsvColumnsTest {
19+
20+
private fun header(line: String): List<String> =
21+
line.split(',').map { it.trim().lowercase(Locale.US) }
22+
23+
/** The real EUC Planet recorder header (see CsvWriter). */
24+
private val eucPlanet = header(
25+
"Date,Speed,Voltage,Temperature,Battery level,Altitude,Latitude,Longitude," +
26+
"Total mileage,GPS speed,Current,PWM,G-Force,G-Force X,G-Force Y"
27+
)
28+
29+
/** The real EUC World (euc.world) CSV backup header. */
30+
private val eucWorld = header(
31+
"datetime,duration,duration_riding,distance,distance_total,speed,speed_avg," +
32+
"speed_avg_riding,speed_max,speed_limit,voltage,current,current_phase,power," +
33+
"battery,temp,temp_motor,temp_batt,safety_margin,cpu_load,tilt,roll,fan,alert," +
34+
"alarm,gps_datetime,gps_duration,gps_duration_riding,gps_distance,gps_lat,gps_lon," +
35+
"gps_speed,gps_speed_avg,gps_speed_avg_riding,gps_speed_max,gps_alt,gps_bearing," +
36+
"gps_acc,hr,extra"
37+
)
38+
39+
@Test
40+
fun eucPlanetColumnsUnchanged() {
41+
val c = TripCsv.Columns
42+
assertEquals(0, c.date(eucPlanet))
43+
assertEquals(1, c.speed(eucPlanet))
44+
assertEquals(2, c.voltage(eucPlanet))
45+
assertEquals(3, c.temperature(eucPlanet))
46+
assertEquals(4, c.battery(eucPlanet))
47+
assertEquals(5, c.altitude(eucPlanet))
48+
assertEquals(6, c.latitude(eucPlanet))
49+
assertEquals(7, c.longitude(eucPlanet))
50+
assertEquals(8, c.mileage(eucPlanet))
51+
assertEquals(9, c.gpsSpeed(eucPlanet))
52+
assertEquals(10, c.current(eucPlanet))
53+
assertEquals(11, c.pwm(eucPlanet))
54+
}
55+
56+
@Test
57+
fun eucWorldColumnsResolveViaAliases() {
58+
val c = TripCsv.Columns
59+
assertEquals(0, c.date(eucWorld)) // datetime
60+
assertEquals(5, c.speed(eucWorld))
61+
assertEquals(10, c.voltage(eucWorld))
62+
assertEquals(11, c.current(eucWorld))
63+
assertEquals(15, c.temperature(eucWorld)) // temp
64+
assertEquals(14, c.battery(eucWorld))
65+
assertEquals(35, c.altitude(eucWorld)) // gps_alt
66+
assertEquals(29, c.latitude(eucWorld)) // gps_lat
67+
assertEquals(30, c.longitude(eucWorld)) // gps_lon
68+
assertEquals(31, c.gpsSpeed(eucWorld)) // gps_speed
69+
assertEquals(4, c.mileage(eucWorld)) // distance_total (odometer)
70+
assertEquals(-1, c.pwm(eucWorld)) // absent in EUC World
71+
}
72+
73+
@Test
74+
fun eucWorldTripHasGps() {
75+
val csv = (
76+
"datetime,duration,duration_riding,distance,distance_total,speed,speed_avg," +
77+
"speed_avg_riding,speed_max,speed_limit,voltage,current,current_phase,power," +
78+
"battery,temp,temp_motor,temp_batt,safety_margin,cpu_load,tilt,roll,fan,alert," +
79+
"alarm,gps_datetime,gps_duration,gps_duration_riding,gps_distance,gps_lat,gps_lon," +
80+
"gps_speed,gps_speed_avg,gps_speed_avg_riding,gps_speed_max,gps_alt,gps_bearing," +
81+
"gps_acc,hr,extra\n" +
82+
"2023-05-19T15:56:24.869+0200,0,0,0.000,1441.403,0.00,0.00,0.00,0.00,30.00,66.50," +
83+
"-0.09,,-6,95,30,,,99,80,,,0,,0,2023-05-19T15:56:24.000+0200,0,0,0.000,55.3195459," +
84+
"11.9658043,0.00,0.00,0.00,0.00,48.3,,9,,manufacturer=Google\n" +
85+
"2023-05-19T15:56:25.032+0200,0,0,0.000,1441.403,0.00,0.00,0.00,0.00,30.00,66.49," +
86+
"-0.09,,-6,95,30,,,99,80,,,0,,0,2023-05-19T15:56:24.000+0200,0,0,0.000,55.3195460," +
87+
"11.9658050,0.00,0.00,0.00,0.00,48.3,,9,,brand=google"
88+
)
89+
val quads = parseTripQuads(csv)
90+
assertTrue("expected rows to parse", quads.isNotEmpty())
91+
val first = quads.first()
92+
assertEquals(55.3195459, first.lat, 1e-6)
93+
assertEquals(11.9658043, first.lon, 1e-6)
94+
assertNotNull("EUC World timestamp should parse", TripCsv.parseDate(first.date))
95+
}
96+
}

0 commit comments

Comments
 (0)