From a61dcd1e7d261df85a0972d0ebe16b1c7ce8aa49 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:39:18 +0200 Subject: [PATCH 1/3] feat(rides): OBRF v5 records the ride's descent and energy The ride footer grows from 144 to 150 bytes and goes to version 5. v4 is gone. - Bytes 24..26 hold the ride's descent in metres, next to the climb. The recorder takes it from the same dead band as the climb. - Bytes 38..42 hold the ride's energy from power in whole kJ, next to the power summary. 0xFFFF_FFFF means the ride has no power data, so 0 kJ stays a real value. The device ride detail does not change: its redesign reads these fields in a follow-up. RideSummary carries the energy for that redesign. The descent stays out of the resident ride list; the detail can read it from the opened ride, as the route detail reads a route's descent. TripRef keeps its key as NonZeroU64, so Option loses its tag and a board RideEntry stays at 112 B, now pinned. TripRef::new already refused key 0, so no behaviour changes. The phone decodes both fields and keeps them in summary.json. A ride edit recomputes the descent and estimates the energy from the stored points, with each interval capped at 2 s as on the device. The ride detail shows an Energy row. The web builder decodes v5. Co-Authored-By: Claude Opus 5.5 --- apps/obc-web-convert/Cargo.toml | 2 +- apps/obc-web-convert/src/convert.rs | 8 +- apps/obc-web-convert/src/lib.rs | 2 +- builder/app/dev-harness/ride-library.ts | 4 +- .../dev-harness/simulated-device.svelte.ts | 4 +- builder/app/src/lib/convert/bridge.test.ts | 4 +- builder/app/src/lib/convert/bridge.ts | 2 +- builder/app/src/lib/device/library.test.ts | 4 +- builder/app/src/lib/device/rides.test.ts | 23 ++-- builder/app/src/lib/usb/objects.ts | 61 ++++++---- .../OBCKit/Sources/OBCDomain/Ride.swift | 8 ++ .../OBCKit/Sources/OBCDomain/RideEdit.swift | 18 ++- .../OBCTransport/Codecs/RideCodec.swift | 16 ++- .../Library/FileLibraryStore.swift | 9 +- .../OBCUI/Detail/RouteDetailModel.swift | 1 + .../OBCUI/Main/RideSyncCoordinator.swift | 2 + .../RideCodecVectorTests.swift | 10 +- .../OBCTransportTests/RideEditTests.swift | 18 +++ firmware/obc-app/src/recorder.rs | 5 +- firmware/obc-app/src/ride.rs | 6 + firmware/obc-formats/src/ride.rs | 106 +++++++++++------- .../obc-fw-nrf54l/src/bin/flat_store_bench.rs | 2 +- firmware/obc-link/tests/flat_engine.rs | 2 +- .../obc-link/tests/flat_engine/recovery.rs | 20 ++-- firmware/obc-platform/src/debug_link.rs | 2 +- firmware/obc-route/src/ride.rs | 13 ++- firmware/obc-route/tests/cases/ride.rs | 12 +- firmware/tools/ui_frames/environments.py | 16 +-- host/obc-host-core/src/conformance.rs | 2 + host/obc-host-core/src/flat_recorder/tests.rs | 2 + host/obc-host-core/tests/board_ride.rs | 2 + host/obc-vectors/src/lib.rs | 22 ++-- host/obc-vectors/tests/vectors.rs | 40 +++---- specs/obc-ble-interface-spec.md | 51 +++++---- specs/vectors/README.md | 6 +- specs/vectors/manifest.json | 17 +-- specs/vectors/ride-v4.bin | Bin 204 -> 0 bytes specs/vectors/ride-v5.bin | Bin 0 -> 210 bytes 38 files changed, 329 insertions(+), 193 deletions(-) delete mode 100644 specs/vectors/ride-v4.bin create mode 100644 specs/vectors/ride-v5.bin diff --git a/apps/obc-web-convert/Cargo.toml b/apps/obc-web-convert/Cargo.toml index abd7edfa2..6b8fc28a3 100644 --- a/apps/obc-web-convert/Cargo.toml +++ b/apps/obc-web-convert/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] # The conversion and export algorithms themselves — the same `no_std` route decoder and finished -# ride-v4 reader shared with firmware. The nRF54L records final ride bytes directly; this crate is a +# ride-v5 reader shared with firmware. The nRF54L records final ride bytes directly; this crate is a # byte-buffer adapter plus an error vocabulary. obc-route = { path = "../../firmware/obc-route" } # The byte-I/O seam (`ByteSource`/`ByteSink`/`Error`) and the recorded-track record width — the diff --git a/apps/obc-web-convert/src/convert.rs b/apps/obc-web-convert/src/convert.rs index a09f2fbc5..e52457393 100644 --- a/apps/obc-web-convert/src/convert.rs +++ b/apps/obc-web-convert/src/convert.rs @@ -51,7 +51,7 @@ pub enum ErrorCode { GpxNoTrackPoints, /// The decimated route still exceeds [`MAX_STORED_POINTS`]. GpxTooManyPoints, - /// The bytes are not a finished ride-v4 object. + /// The bytes are not a finished ride-v5 object. NotRide, /// The finished ride carries no recorded points. RideNoPoints, @@ -113,7 +113,7 @@ pub fn gpx_to_obcr(gpx: &[u8], name: &str) -> Result, ConvertFailure> { Ok(sink.0) } -/// Convert a finished ride-v4 object's bytes into a GPX 1.1 document named `name`. +/// Convert a finished ride-v5 object's bytes into a GPX 1.1 document named `name`. /// /// Byte-for-byte the same output as `obc_route::track_to_gpx`; only the buffer adapter is new. pub fn track_to_gpx(ride: &[u8], name: &str) -> Result { @@ -319,7 +319,7 @@ fn describe_track_error(e: Error) -> ConvertFailure { ), Error::BadOffset | Error::BadMagic | Error::BadVersion => ConvertFailure::new( ErrorCode::NotRide, - "These bytes are not one complete ride-v4 object. Download the finished ride again; \ + "These bytes are not one complete ride-v5 object. Download the finished ride again; \ unfinished sample logs and older ride formats are not accepted.", ), Error::Io => ConvertFailure::new( @@ -572,7 +572,7 @@ mod tests { assert!(not_gpx.message.contains(".fit"), "points at the likely real format: {not_gpx}"); let short = track_to_gpx(&[0xAB; 4], "x").unwrap_err(); - assert!(short.message.contains("ride-v4"), "names the required format: {short}"); + assert!(short.message.contains("ride-v5"), "names the required format: {short}"); } /// A route past the storage ceiling reports that, with the number in it. The zig-zag keeps diff --git a/apps/obc-web-convert/src/lib.rs b/apps/obc-web-convert/src/lib.rs index 650cc015f..b304476c9 100644 --- a/apps/obc-web-convert/src/lib.rs +++ b/apps/obc-web-convert/src/lib.rs @@ -39,7 +39,7 @@ mod web { crate::convert::gpx_to_obcr(bytes, name).map_err(to_js) } - /// Convert a finished ride-v4 object into a GPX 1.1 document, naming the track `name`. + /// Convert a finished ride-v5 object into a GPX 1.1 document, naming the track `name`. /// /// Throws an `Error` carrying `code` and `message` on failure; see [`crate::ErrorCode`]. #[wasm_bindgen] diff --git a/builder/app/dev-harness/ride-library.ts b/builder/app/dev-harness/ride-library.ts index 903c43c2c..a484a59a1 100644 --- a/builder/app/dev-harness/ride-library.ts +++ b/builder/app/dev-harness/ride-library.ts @@ -61,18 +61,20 @@ function loopRide( } const distanceM = Math.round(radiusDeg * 111_000 * 2 * Math.PI * 1.2); return { - version: 4, + version: 5, name, startTime, distanceM, movingTimeS: n * 10, avgSpeedCms: Math.round((distanceM / (n * 10)) * 100), climbM, + descentM: climbM, avgHr: 139, maxHr: 171, avgCadence: 78, avgPower: null, maxPower: null, + energyKj: null, bikeType: 0, trip: null, points, diff --git a/builder/app/dev-harness/simulated-device.svelte.ts b/builder/app/dev-harness/simulated-device.svelte.ts index 4cb6d4b72..5a85b7422 100644 --- a/builder/app/dev-harness/simulated-device.svelte.ts +++ b/builder/app/dev-harness/simulated-device.svelte.ts @@ -283,18 +283,20 @@ function syntheticRide(name: string, startTime: number, points: number, sensors: }); } return { - version: 4, + version: 5, name, startTime, distanceM: points * 7, movingTimeS: points, avgSpeedCms: 700, climbM: 600, + descentM: 600, avgHr: sensors ? 139 : null, maxHr: sensors ? 171 : null, avgCadence: sensors ? 79 : null, avgPower: sensors ? 209 : null, maxPower: sensors ? 410 : null, + energyKj: sensors ? Math.floor((points * 209) / 1000) : null, bikeType: 0, trip: null, points: list, diff --git a/builder/app/src/lib/convert/bridge.test.ts b/builder/app/src/lib/convert/bridge.test.ts index 9b241c581..d9fe40270 100644 --- a/builder/app/src/lib/convert/bridge.test.ts +++ b/builder/app/src/lib/convert/bridge.test.ts @@ -106,7 +106,7 @@ describe("gpxToObcr", () => { describe("trackToGpx", () => { it("reproduces the native exporter's GPX byte-for-byte", async () => { - const gpx = await trackToGpx(vector("ride-v4.bin"), TRACK_NAME); + const gpx = await trackToGpx(vector("ride-v5.bin"), TRACK_NAME); const expected = new TextDecoder().decode(vector("track-export.gpx")); // Compared as text so a failure diffs readably; the fixture is ASCII apart from nothing, // so text equality here *is* byte equality. @@ -262,7 +262,7 @@ describe("failures", () => { expect(notGpx.message).toMatch(/\.fit|\.tcx/); const shortLog = await failure(() => trackToGpx(new Uint8Array(9), "x")); - expect(shortLog.message).toContain("ride-v4"); + expect(shortLog.message).toContain("ride-v5"); for (const e of [noTrack, notGpx, shortLog]) { expect(e.message.length, `"${e.message}" is too terse to be actionable`).toBeGreaterThan(60); diff --git a/builder/app/src/lib/convert/bridge.ts b/builder/app/src/lib/convert/bridge.ts index f5802f322..f96a355a8 100644 --- a/builder/app/src/lib/convert/bridge.ts +++ b/builder/app/src/lib/convert/bridge.ts @@ -111,7 +111,7 @@ export async function gpxToObcr(gpx: Uint8Array, name: string): Promise { it("reproduces the native exporter byte-for-byte, pulled from the device", async () => { - const ride = { ...decodeRideObject(vector("ride-v4.bin")), name: TRACK_NAME }; + const ride = { ...decodeRideObject(vector("ride-v5.bin")), name: TRACK_NAME }; const { entries, source, close } = deviceWith([ride]); try { // The catalog is what a rider picks from, so the export starts where they do. @@ -221,21 +225,23 @@ describe("the exported GPX", () => { describe("the ride object", () => { it("decodes and re-encodes the cross-language vector byte-for-byte", () => { - const bytes = vector("ride-v4.bin"); + const bytes = vector("ride-v5.bin"); const ride = decodeRideObject(bytes); expect(ride).toMatchObject({ - version: 4, + version: 5, name: "Sensor Ride", startTime: 1_751_460_000, distanceM: 12_345, movingTimeS: 3_600, avgSpeedCms: 343, climbM: 120, + descentM: 95, avgHr: 142, maxHr: 176, avgCadence: 85, avgPower: 210, maxPower: 480, + energyKj: 756, bikeType: 1, trip: { key: 0x0123_4567_89ab_cdefn, dayIndex: 1, dayCount: 3, name: "Alpen Traverse" }, }); @@ -313,7 +319,8 @@ describe("when the export cannot finish", () => { if (!armed || flipped || slice.length === 0) return slice; flipped = true; const damaged = slice.slice(); - damaged[damaged.length - 1] ^= 0xff; + // The middle byte lies in the payload; the record's tail can be alignment padding. + damaged[damaged.length >> 1] ^= 0xff; return damaged; }, write: (bytes, signal) => stream.write(bytes, signal), @@ -424,7 +431,7 @@ describe("when the export cannot finish", () => { const { device, source, close } = deviceWith([]); try { const future = encodeRideObject(ride); - future[future.length - 140] = 5; + future[future.length - 146] = 6; device.seed({ kind: ObjectKind.Ride, displayName: ride.name, bytes: future }); const failure = await exportRide(source, (await source.listRides())[0], context()).catch( (e: unknown) => e, diff --git a/builder/app/src/lib/usb/objects.ts b/builder/app/src/lib/usb/objects.ts index 0757190d8..ebf5d40e3 100644 --- a/builder/app/src/lib/usb/objects.ts +++ b/builder/app/src/lib/usb/objects.ts @@ -33,10 +33,12 @@ export class ObjectDecodeError extends Error { /** Absent-value sentinels in both samples and the final summary footer. */ const NO_U8 = 0xff; const NO_U16 = 0xffff; +const NO_U32 = 0xffffffff; const RIDE_SAMPLE_LEN = 20; -const RIDE_FOOTER_LEN = 144; +const RIDE_FOOTER_LEN = 150; const RIDE_NAME_CAP = 48; -const RIDE_TRIP_AT = 84; +const RIDE_NAME_AT = 42; +const RIDE_TRIP_AT = 90; /** One recorded point. Coordinates are degrees × 1e7 (a ~1 cm grid); `null` means the sensor was * absent, dropped, or stale. */ @@ -63,20 +65,23 @@ export interface RideTrip { name: string; } -/** A downloaded v4 ride: the recorded sample bytes followed by one fixed summary footer. */ +/** A downloaded v5 ride: the recorded sample bytes followed by one fixed summary footer. */ export interface RideObject { - version: 4; + version: 5; name: string; startTime: number; distanceM: number; movingTimeS: number; avgSpeedCms: number; climbM: number; + descentM: number; avgHr: number | null; maxHr: number | null; avgCadence: number | null; avgPower: number | null; maxPower: number | null; + /** The ride's energy from power; `null` without power data. */ + energyKj: number | null; /** The bike type current at the start, `0..=3` (Road, Gravel, MTB, Touring). */ bikeType: number; trip: RideTrip | null; @@ -84,7 +89,7 @@ export interface RideObject { } /** - * Decode the only ride-object format: verbatim 20-byte samples followed by the fixed 144-byte v4 + * Decode the only ride-object format: verbatim 20-byte samples followed by the fixed 150-byte v5 * footer. The footer's point count determines the complete object length. */ export function decodeRideObject(data: Uint8Array): RideObject { @@ -95,11 +100,11 @@ export function decodeRideObject(data: Uint8Array): RideObject { throw new ObjectDecodeError("ride object has no OBRF footer."); } const version = data[footer + 4]; - if (version !== 4) throw new ObjectDecodeError(`ride object version ${version}; this client decodes 4.`); - if (view.getUint16(footer + 6, true) !== RIDE_FOOTER_LEN || data[footer + 31] !== 0) { + if (version !== 5) throw new ObjectDecodeError(`ride object version ${version}; this client decodes 5.`); + if (view.getUint16(footer + 6, true) !== RIDE_FOOTER_LEN || data[footer + 33] !== 0) { throw new ObjectDecodeError("ride object has a non-canonical summary footer."); } - const name = footerName(data, footer + 36, data[footer + 5]); + const name = footerName(data, footer + RIDE_NAME_AT, data[footer + 5]); const trip = footer + RIDE_TRIP_AT; const tripKey = view.getBigUint64(trip, true); const dayIndex = data[trip + 8]; @@ -110,7 +115,7 @@ export function decodeRideObject(data: Uint8Array): RideObject { if (bikeType > 3 || (tripKey === 0n ? !noTrip : dayIndex >= dayCount)) { throw new ObjectDecodeError("ride object has a non-canonical summary footer."); } - const pointCount = view.getUint32(footer + 24, true); + const pointCount = view.getUint32(footer + 26, true); const expected = pointCount * RIDE_SAMPLE_LEN + RIDE_FOOTER_LEN; if (data.length !== expected) { throw new ObjectDecodeError(`ride object with ${pointCount} points should be ${expected} bytes, got ${data.length}.`); @@ -134,7 +139,7 @@ export function decodeRideObject(data: Uint8Array): RideObject { } return { - version: 4, + version: 5, points, name, startTime: view.getUint32(footer + 8, true), @@ -142,11 +147,13 @@ export function decodeRideObject(data: Uint8Array): RideObject { movingTimeS: view.getUint32(footer + 16, true), avgSpeedCms: view.getUint16(footer + 20, true), climbM: view.getUint16(footer + 22, true), - avgHr: absent8(data[footer + 28]), - maxHr: absent8(data[footer + 29]), - avgCadence: absent8(data[footer + 30]), - avgPower: absent16(view.getUint16(footer + 32, true)), - maxPower: absent16(view.getUint16(footer + 34, true)), + descentM: view.getUint16(footer + 24, true), + avgHr: absent8(data[footer + 30]), + maxHr: absent8(data[footer + 31]), + avgCadence: absent8(data[footer + 32]), + avgPower: absent16(view.getUint16(footer + 34, true)), + maxPower: absent16(view.getUint16(footer + 36, true)), + energyKj: absent32(view.getUint32(footer + 38, true)), bikeType, trip: tripKey === 0n ? null : { key: tripKey, dayIndex, dayCount, name: tripName }, }; @@ -164,7 +171,7 @@ function footerName(data: Uint8Array, at: number, len: number): string { } } -/** Encode a v4 object for the loopback device and byte-contract tests. */ +/** Encode a v5 object for the loopback device and byte-contract tests. */ export function encodeRideObject(r: RideObject): Uint8Array { const name = clippedUtf8(r.name, RIDE_NAME_CAP); const footer = r.points.length * RIDE_SAMPLE_LEN; @@ -181,20 +188,22 @@ export function encodeRideObject(r: RideObject): Uint8Array { out[p + 17] = pt.cadenceRpm ?? NO_U8; view.setUint16(p + 18, pt.powerW ?? NO_U16, true); }); - out.set([0x4f, 0x42, 0x52, 0x46, 4, name.length], footer); + out.set([0x4f, 0x42, 0x52, 0x46, 5, name.length], footer); view.setUint16(footer + 6, RIDE_FOOTER_LEN, true); view.setUint32(footer + 8, r.startTime, true); view.setUint32(footer + 12, r.distanceM, true); view.setUint32(footer + 16, r.movingTimeS, true); view.setUint16(footer + 20, r.avgSpeedCms, true); view.setUint16(footer + 22, r.climbM, true); - view.setUint32(footer + 24, r.points.length, true); - out[footer + 28] = r.avgHr ?? NO_U8; - out[footer + 29] = r.maxHr ?? NO_U8; - out[footer + 30] = r.avgCadence ?? NO_U8; - view.setUint16(footer + 32, r.avgPower ?? NO_U16, true); - view.setUint16(footer + 34, r.maxPower ?? NO_U16, true); - out.set(name, footer + 36); + view.setUint16(footer + 24, r.descentM, true); + view.setUint32(footer + 26, r.points.length, true); + out[footer + 30] = r.avgHr ?? NO_U8; + out[footer + 31] = r.maxHr ?? NO_U8; + out[footer + 32] = r.avgCadence ?? NO_U8; + view.setUint16(footer + 34, r.avgPower ?? NO_U16, true); + view.setUint16(footer + 36, r.maxPower ?? NO_U16, true); + view.setUint32(footer + 38, r.energyKj ?? NO_U32, true); + out.set(name, footer + RIDE_NAME_AT); const trip = footer + RIDE_TRIP_AT; if (r.trip) { const tripName = clippedUtf8(r.trip.name, RIDE_NAME_CAP); @@ -332,3 +341,7 @@ function absent8(v: number): number | null { function absent16(v: number): number | null { return v === NO_U16 ? null : v; } + +function absent32(v: number): number | null { + return v === NO_U32 ? null : v; +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift index cae3956e8..5b0014989 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift @@ -166,6 +166,8 @@ public struct RideSummary: Identifiable, Equatable, Sendable { public var averageSpeedMps: Double /// Total climb, in metres. public var climbMeters: Double + /// Total descent, in metres, dead-banded like the climb. + public var descentMeters: Double /// Normalized polyline for the preview component. Nil until geometry is decoded. public var trackPreview: TrackPreview? @@ -176,6 +178,8 @@ public struct RideSummary: Identifiable, Equatable, Sendable { public var avgCadence: Int? public var avgPower: Int? public var maxPower: Int? + /// The ride's energy from power, in kJ. + public var energyKJ: Int? /// The bike type that was current when the ride started. The rider can change it on the phone; /// the device copy does not change. @@ -191,12 +195,14 @@ public struct RideSummary: Identifiable, Equatable, Sendable { movingTime: TimeInterval = 0, averageSpeedMps: Double = 0, climbMeters: Double = 0, + descentMeters: Double = 0, trackPreview: TrackPreview? = nil, avgHeartRate: Int? = nil, maxHeartRate: Int? = nil, avgCadence: Int? = nil, avgPower: Int? = nil, maxPower: Int? = nil, + energyKJ: Int? = nil, bikeType: BikeType = .road, trip: RideTrip? = nil, source: RideSource? = nil @@ -208,12 +214,14 @@ public struct RideSummary: Identifiable, Equatable, Sendable { self.movingTime = movingTime self.averageSpeedMps = averageSpeedMps self.climbMeters = climbMeters + self.descentMeters = descentMeters self.trackPreview = trackPreview self.avgHeartRate = avgHeartRate self.maxHeartRate = maxHeartRate self.avgCadence = avgCadence self.avgPower = avgPower self.maxPower = maxPower + self.energyKJ = energyKJ self.bikeType = bikeType self.trip = trip self.source = source diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift index 314f590bb..cd6909289 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift @@ -157,13 +157,16 @@ extension RideSummary { /// This summary with the stats of `points`, counted by the device's rules /// (`firmware/obc-app/src/recorder.rs`). Every interval inside a segment counts: the device /// starts a new segment after each interval it does not count. Moving time, the average speed - /// and the sensor averages count only intervals at 0.8 m/s or faster. The climb has the - /// device's 3 m dead band and restarts at each segment. The name, the bike type and the trip - /// stay; a view is no device object, so it has no source. + /// and the sensor averages count only intervals at 0.8 m/s or faster. The climb and the descent + /// have the device's 3 m dead band and restart at each segment. The energy is an estimate from + /// the stored points: each interval with a power value adds watts × its time, at most 2 s, as the + /// device caps a gap, but the device sums every power sample and the points hold only some. The + /// name, the bike type and the trip stay; a view is no device object, so it has no source. public func withStats(of points: [RidePoint], id: RideID) -> RideSummary { - var ridden = 0.0, movingMeters = 0.0, moving = 0.0, climb = 0.0 + var ridden = 0.0, movingMeters = 0.0, moving = 0.0, climb = 0.0, descent = 0.0 var confirmed: Double? var heartRate = Weighted(), cadence = Weighted(), power = Weighted() + var joules: Double? for (index, point) in points.enumerated() { if point.segmentStart { confirmed = nil } if let elevation = point.elevationMeters { @@ -172,6 +175,7 @@ extension RideSummary { climb += elevation - last confirmed = elevation } else if elevation <= last - RouteStats.climbHysteresisMeters { + descent += last - elevation confirmed = elevation } } else { @@ -184,6 +188,7 @@ extension RideSummary { let distance = previous.coordinate.routeDistance(to: point.coordinate) guard dt > 0 else { continue } ridden += distance + if let watts = point.power { joules = (joules ?? 0) + Double(watts) * min(dt, 2) } guard distance / dt >= 0.8 else { continue } movingMeters += distance moving += dt @@ -194,9 +199,10 @@ extension RideSummary { return RideSummary( id: id, name: name, date: points.first?.timestamp ?? date, distanceMeters: ridden, movingTime: moving, averageSpeedMps: moving > 0 ? movingMeters / moving : 0, - climbMeters: climb, trackPreview: TrackPreview.normalizing(points.map(\.coordinate)), + climbMeters: climb, descentMeters: descent, trackPreview: TrackPreview.normalizing(points.map(\.coordinate)), avgHeartRate: heartRate.average, maxHeartRate: heartRate.max, avgCadence: cadence.average, - avgPower: power.average, maxPower: power.max, bikeType: bikeType, trip: trip + avgPower: power.average, maxPower: power.max, energyKJ: joules.map { Int($0 / 1_000) }, + bikeType: bikeType, trip: trip ) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift index 2594c06f4..53612c6f8 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift @@ -1,14 +1,15 @@ import Foundation import OBCDomain -/// Ride object v4: verbatim 20-byte recorded samples followed by one fixed 144-byte `OBRF` footer. +/// Ride object v5: verbatim 20-byte recorded samples followed by one fixed 150-byte `OBRF` footer. public enum RideObjectCodec { - static let version: UInt8 = 4 + static let version: UInt8 = 5 static let sampleLength = 20 - static let footerLength = 144 + static let footerLength = 150 static let nameCapacity = 48 static let noSensorU8: UInt8 = 0xFF static let noSensorU16: UInt16 = 0xFFFF + static let noEnergy: UInt32 = 0xFFFF_FFFF public static func encode(_ ride: Ride) -> Data { let summary = ride.summary @@ -35,6 +36,7 @@ public enum RideObjectCodec { data.appendLE(UInt32(clamping: Int64(summary.movingTime.rounded()))) data.appendLE(UInt16(clamping: Int64((summary.averageSpeedMps * 100).rounded()))) data.appendLE(UInt16(clamping: Int64(summary.climbMeters.rounded()))) + data.appendLE(UInt16(clamping: Int64(summary.descentMeters.rounded()))) data.appendLE(UInt32(ride.points.count)) data.append(sensorU8(summary.avgHeartRate)) data.append(sensorU8(summary.maxHeartRate)) @@ -42,6 +44,7 @@ public enum RideObjectCodec { data.append(0) data.appendLE(sensorU16(summary.avgPower)) data.appendLE(sensorU16(summary.maxPower)) + data.appendLE(summary.energyKJ.map { UInt32(clamping: Swift.min($0, Int(noEnergy) - 1)) } ?? noEnergy) data.append(name) data.append(Data(repeating: 0, count: nameCapacity - name.count)) let trip = summary.trip @@ -74,6 +77,7 @@ public enum RideObjectCodec { let movingTime = TimeInterval(try footer.u32()) let averageSpeed = Double(try footer.u16()) / 100 let climb = Double(try footer.u16()) + let descent = Double(try footer.u16()) let pointCount = Int(try footer.u32()) let avgHR = optSensorU8(try footer.u8()) let maxHR = optSensorU8(try footer.u8()) @@ -81,6 +85,7 @@ public enum RideObjectCodec { guard try footer.u8() == 0 else { throw DeviceError.readFailed } let avgPower = optSensorU16(try footer.u16()) let maxPower = optSensorU16(try footer.u16()) + let energy = try footer.u32() let name = try nameField(&footer, length: nameLength) let tripKey = try footer.u64() let dayIndex = Int(try footer.u8()) @@ -128,10 +133,11 @@ public enum RideObjectCodec { } let summary = RideSummary( id: id, name: name, date: start, distanceMeters: distance, - movingTime: movingTime, averageSpeedMps: averageSpeed, climbMeters: climb, + movingTime: movingTime, averageSpeedMps: averageSpeed, climbMeters: climb, descentMeters: descent, trackPreview: TrackPreview.normalizing(points.map(\.coordinate)), avgHeartRate: avgHR, maxHeartRate: maxHR, avgCadence: avgCadence, - avgPower: avgPower, maxPower: maxPower, bikeType: bikeType, trip: trip) + avgPower: avgPower, maxPower: maxPower, energyKJ: energy == noEnergy ? nil : Int(energy), + bikeType: bikeType, trip: trip) return Ride(summary: summary, points: points) } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift index 0cff53488..8165c9996 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift @@ -923,6 +923,8 @@ private struct RideSummaryDTO: Codable { var movingTime: Double var averageSpeedMps: Double var climbMeters: Double + // Optional, so a `summary.json` written before the footer carried descent still decodes. + var descentMeters: Double? var preview: TrackPreviewDTO? // The per-ride sensor summary is optional, so a `summary.json` written without these keys // still decodes with every field nil. @@ -931,6 +933,7 @@ private struct RideSummaryDTO: Codable { var avgCadence: Int? var avgPower: Int? var maxPower: Int? + var energyKJ: Int? var bikeType: UInt8 var trip: RideTripDTO? @@ -943,12 +946,14 @@ private struct RideSummaryDTO: Codable { movingTime = summary.movingTime averageSpeedMps = summary.averageSpeedMps climbMeters = summary.climbMeters + descentMeters = summary.descentMeters preview = summary.trackPreview.map(TrackPreviewDTO.init) avgHeartRate = summary.avgHeartRate maxHeartRate = summary.maxHeartRate avgCadence = summary.avgCadence avgPower = summary.avgPower maxPower = summary.maxPower + energyKJ = summary.energyKJ bikeType = summary.bikeType.rawValue trip = summary.trip.map(RideTripDTO.init) } @@ -957,10 +962,10 @@ private struct RideSummaryDTO: Codable { RideSummary( id: RideID(id), name: name, date: date, distanceMeters: distanceMeters, movingTime: movingTime, - averageSpeedMps: averageSpeedMps, climbMeters: climbMeters, + averageSpeedMps: averageSpeedMps, climbMeters: climbMeters, descentMeters: descentMeters ?? 0, trackPreview: preview?.domain, avgHeartRate: avgHeartRate, maxHeartRate: maxHeartRate, - avgCadence: avgCadence, avgPower: avgPower, maxPower: maxPower, + avgCadence: avgCadence, avgPower: avgPower, maxPower: maxPower, energyKJ: energyKJ, bikeType: BikeType(rawValue: bikeType) ?? .road, trip: trip?.domain, source: source ) } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailModel.swift index 4ae886c13..4a0195865 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailModel.swift @@ -310,6 +310,7 @@ public final class RouteDetailModel { if let v = ride.maxHeartRate { rows.append(SensorRow(label: "Max heart rate", value: "\(v) bpm")) } if let v = ride.avgPower { rows.append(SensorRow(label: "Avg power", value: "\(v) W")) } if let v = ride.maxPower { rows.append(SensorRow(label: "Max power", value: "\(v) W")) } + if let v = ride.energyKJ { rows.append(SensorRow(label: "Energy", value: "\(v) kJ")) } if let v = ride.avgCadence { rows.append(SensorRow(label: "Avg cadence", value: "\(v) rpm")) } return rows } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift index 80581246b..f113e1aeb 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift @@ -276,11 +276,13 @@ public final class RideSyncCoordinator { let decoded = ride.summary ride.summary = summary if ride.summary.trackPreview == nil { ride.summary.trackPreview = decoded.trackPreview } + ride.summary.descentMeters = decoded.descentMeters ride.summary.avgHeartRate = decoded.avgHeartRate ride.summary.maxHeartRate = decoded.maxHeartRate ride.summary.avgCadence = decoded.avgCadence ride.summary.avgPower = decoded.avgPower ride.summary.maxPower = decoded.maxPower + ride.summary.energyKJ = decoded.energyKJ } let receipt = try library.archiveRide(ride) syncedRideIDs.insert(downloaded.id) diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift index 4891d422c..0ec640ff7 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift @@ -3,7 +3,7 @@ import Testing import OBCDomain @testable import OBCTransport -/// The single cross-language ride-object contract: recorded 20-byte samples plus the v4 footer. +/// The single cross-language ride-object contract: recorded 20-byte samples plus the v5 footer. struct RideCodecVectorTests { private static let vectorsDir = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -15,9 +15,9 @@ struct RideCodecVectorTests { .appendingPathComponent("specs/vectors") private func vector() throws -> Data { - let url = Self.vectorsDir.appendingPathComponent("ride-v4.bin") + let url = Self.vectorsDir.appendingPathComponent("ride-v5.bin") return try #require(FileManager.default.contents(atPath: url.path), - "fixture ride-v4.bin missing at \(url.path)") + "fixture ride-v5.bin missing at \(url.path)") } @Test func decodesAndReencodesTheVectorExactly() throws { @@ -31,11 +31,13 @@ struct RideCodecVectorTests { #expect(summary.movingTime == 3_600) #expect(abs(summary.averageSpeedMps - 3.43) < 0.001) #expect(summary.climbMeters == 120) + #expect(summary.descentMeters == 95) #expect(summary.avgHeartRate == 142) #expect(summary.maxHeartRate == 176) #expect(summary.avgCadence == 85) #expect(summary.avgPower == 210) #expect(summary.maxPower == 480) + #expect(summary.energyKJ == 756) #expect(summary.bikeType == .gravel) #expect(summary.trip == RideTrip(key: 0x0123_4567_89AB_CDEF, dayIndex: 1, dayCount: 3, name: "Alpen Traverse")) @@ -57,7 +59,7 @@ struct RideCodecVectorTests { let bytes = try vector() let footer = bytes.count - RideObjectCodec.footerLength // Reserved byte, a day past the count, a bike type past the four, trip-name padding. - for (offset, value) in [(31, 1), (92, 3), (94, 4), (143, 1)] as [(Int, UInt8)] { + for (offset, value) in [(33, 1), (98, 3), (100, 4), (149, 1)] as [(Int, UInt8)] { var badFooter = bytes badFooter[footer + offset] = value #expect(throws: (any Error).self) { diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift index 616e8979e..0af5160ec 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift @@ -32,6 +32,24 @@ struct RideEditTests { return store } + @Test + func statsEstimateTheEnergyFromTheStoredPointsAndCountTheDescent() { + // One point every 5 s at 500 W, one of them without power. Each interval counts at most + // 2 s, as the device caps a gap: four intervals × 2 s × 500 W = 4 kJ. + let elevations: [Double] = [500, 505, 510, 507, 503, 503] + let points = elevations.enumerated().map { i, elevation in + RidePoint( + timestamp: t0.addingTimeInterval(Double(i) * 5), + coordinate: Coordinate(latitude: 47 + Double(i) * 25 / 111_320, longitude: 8), + elevationMeters: elevation, power: i == 3 ? nil : 500 + ) + } + let summary = Self.ride("a", start: t0, seconds: 1).summary.withStats(of: points, id: RideID("a")) + #expect(summary.energyKJ == 4) + #expect(summary.climbMeters == 10) + #expect(summary.descentMeters == 7, "the dead band counts 3 m and 4 m down") + } + @Test func aTrimKeepsTheRangeAndCountsOnlyItsPoints() throws { let original = Self.ride("a", start: t0, seconds: 600) diff --git a/firmware/obc-app/src/recorder.rs b/firmware/obc-app/src/recorder.rs index b8d05f9f3..f34a4f41f 100644 --- a/firmware/obc-app/src/recorder.rs +++ b/firmware/obc-app/src/recorder.rs @@ -184,7 +184,7 @@ pub enum RecorderError { /// this domain may attempt — a catalog it could not read completely is not one it may mutate. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RideDamage { - /// The recovered bytes are not a ride-v4 sample/footer boundary. + /// The recovered bytes are not a ride-v5 sample/footer boundary. Payload, /// The recovered samples carry no decodable continuation image. Metadata, @@ -1201,6 +1201,7 @@ impl RecorderMachine { moving_time_s: self.moving_s as u32, avg_speed_cms: if self.moving_s > 0.0 { (self.moving_m / self.moving_s * 100.0) as u16 } else { 0 }, climb_m: self.climb.ascent() as u16, + descent_m: self.climb.descent() as u16, unix_at_anchor: self.clock.unix_at_anchor, anchor_ms: self.clock.anchor_ms, clock_trusted: self.clock.trusted, @@ -1210,6 +1211,7 @@ impl RecorderMachine { avg_cadence: self.avg_cadence(), avg_power: self.avg_power(), max_power: self.max_power(), + energy_kj: self.kj(), bike: self.origin.bike, trip: self.origin.trip, trip_name: Name::EMPTY, @@ -2365,6 +2367,7 @@ mod tests { assert_eq!(rec.continuation(), state); assert_eq!((rec.avg_hr(), rec.avg_power(), rec.avg_cadence()), (Some(150), Some(245), Some(87))); assert_eq!(rec.kj(), Some(19), "the ride's energy crosses the reset"); + assert_eq!((rec.ride_stats().energy_kj, rec.ride_stats().descent_m), (Some(19), 123), "and reach the footer"); assert_eq!(rec.climb_m(), 321.0); assert_eq!((rec.ride_stats().bike, rec.ride_stats().trip), (BikeType::Mtb, TripRef::new(9, 1, 3))); } diff --git a/firmware/obc-app/src/ride.rs b/firmware/obc-app/src/ride.rs index a7d654315..a43d39f9b 100644 --- a/firmware/obc-app/src/ride.rs +++ b/firmware/obc-app/src/ride.rs @@ -29,6 +29,10 @@ const _: () = assert!( == core::mem::size_of::() + core::mem::size_of::() ); +// The board holds `UI_RIDES_CAP` entries resident; a field that grows one costs 32 times its size. +#[cfg(target_pointer_width = "32")] +const _: () = assert!(core::mem::size_of::() == 112); + /// A stored ride's header facts for the Rides screen, plus the device-local `synced` flag the /// unsynced-delete guard keys on. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -49,6 +53,7 @@ pub struct RideSummary { pub avg_hr: Option, pub avg_cadence: Option, pub avg_power: Option, + pub energy_kj: Option, } impl RideSummary { @@ -66,6 +71,7 @@ impl RideSummary { avg_hr: info.avg_hr, avg_cadence: info.avg_cadence, avg_power: info.avg_power, + energy_kj: info.energy_kj, } } } diff --git a/firmware/obc-formats/src/ride.rs b/firmware/obc-formats/src/ride.rs index 3e1cd10f2..9e722d50d 100644 --- a/firmware/obc-formats/src/ride.rs +++ b/firmware/obc-formats/src/ride.rs @@ -1,4 +1,4 @@ -//! Recorded-ride v4: verbatim 20-byte samples followed by one fixed summary footer. +//! Recorded-ride v5: verbatim 20-byte samples followed by one fixed summary footer. //! //! A recording appends [`crate::track::RECORD_LEN`]-byte samples directly to its final object. //! Finalize appends [`FOOTER_LEN`] bytes once. There is no leading header and no point rewrite: @@ -6,18 +6,22 @@ //! [`crate::track::encode_record`]. The fixed footer can be fetched alone at //! `object_len - FOOTER_LEN` for a ride-list row. +use core::num::NonZeroU64; + use crate::bike::BikeType; use crate::io::DecodeError; pub const MAGIC: [u8; 4] = *b"OBRF"; -pub const VERSION: u8 = 4; -pub const FOOTER_LEN: usize = 144; +pub const VERSION: u8 = 5; +pub const FOOTER_LEN: usize = 150; pub const NAME_CAP: usize = 48; pub const SAMPLE_LEN: usize = crate::track::RECORD_LEN; pub use crate::track::{CAD_NONE, HR_NONE, PWR_NONE}; +/// The footer's energy sentinel: the ride has no power data. +pub const KJ_NONE: u32 = u32::MAX; -const NAME_AT: usize = 36; +const NAME_AT: usize = 42; const TRIP_AT: usize = NAME_AT + NAME_CAP; const TRIP_NAME_AT: usize = TRIP_AT + 12; @@ -67,7 +71,8 @@ impl Default for Name { /// The trip day a ride started on (`obc-ble-interface-spec.md` §7.7 names the trip key). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TripRef { - key: u64, + /// Nonzero, so an `Option` costs no tag. + key: NonZeroU64, day_index: u8, day_count: u8, } @@ -76,15 +81,14 @@ impl TripRef { /// `None` unless the key is nonzero, because 0 means "no trip" on the wire, and the day lies /// inside the trip. pub const fn new(key: u64, day_index: u8, day_count: u8) -> Option { - if key != 0 && day_index < day_count { - Some(TripRef { key, day_index, day_count }) - } else { - None + match NonZeroU64::new(key) { + Some(key) if day_index < day_count => Some(TripRef { key, day_index, day_count }), + _ => None, } } pub const fn key(&self) -> u64 { - self.key + self.key.get() } /// 0-based. @@ -106,12 +110,16 @@ pub struct Footer { pub moving_time_s: u32, pub avg_speed_cms: u16, pub climb_m: u16, + /// Dead-banded like `climb_m`. + pub descent_m: u16, pub point_count: u32, pub avg_hr: Option, pub max_hr: Option, pub avg_cadence: Option, pub avg_power: Option, pub max_power: Option, + /// The ride's energy from power, or `None` when it has no power data. + pub energy_kj: Option, /// The bike type that was current when the ride started. pub bike: BikeType, name: Name, @@ -120,7 +128,8 @@ pub struct Footer { } impl Footer { - /// Build a footer for a ride on `BikeType::Road` with no trip; long names are clipped. + /// Build a footer for a ride on `BikeType::Road` with no descent, no energy and no trip; long + /// names are clipped. #[allow(clippy::too_many_arguments)] pub fn new( name: &str, @@ -142,12 +151,14 @@ impl Footer { moving_time_s, avg_speed_cms, climb_m, + descent_m: 0, point_count, avg_hr, max_hr, avg_cadence, avg_power, max_power, + energy_kj: None, bike: BikeType::Road, name: Name::new(name), trip: None, @@ -188,16 +199,18 @@ pub fn encode_footer(footer: &Footer) -> [u8; FOOTER_LEN] { b[16..20].copy_from_slice(&footer.moving_time_s.to_le_bytes()); b[20..22].copy_from_slice(&footer.avg_speed_cms.to_le_bytes()); b[22..24].copy_from_slice(&footer.climb_m.to_le_bytes()); - b[24..28].copy_from_slice(&footer.point_count.to_le_bytes()); - b[28] = footer.avg_hr.unwrap_or(HR_NONE); - b[29] = footer.max_hr.unwrap_or(HR_NONE); - b[30] = footer.avg_cadence.unwrap_or(CAD_NONE); - // byte 31 is reserved and remains zero, aligning the following u16 values. - b[32..34].copy_from_slice(&footer.avg_power.unwrap_or(PWR_NONE).to_le_bytes()); - b[34..36].copy_from_slice(&footer.max_power.unwrap_or(PWR_NONE).to_le_bytes()); + b[24..26].copy_from_slice(&footer.descent_m.to_le_bytes()); + b[26..30].copy_from_slice(&footer.point_count.to_le_bytes()); + b[30] = footer.avg_hr.unwrap_or(HR_NONE); + b[31] = footer.max_hr.unwrap_or(HR_NONE); + b[32] = footer.avg_cadence.unwrap_or(CAD_NONE); + // byte 33 is reserved and remains zero, aligning the following u16 values. + b[34..36].copy_from_slice(&footer.avg_power.unwrap_or(PWR_NONE).to_le_bytes()); + b[36..38].copy_from_slice(&footer.max_power.unwrap_or(PWR_NONE).to_le_bytes()); + b[38..42].copy_from_slice(&footer.energy_kj.unwrap_or(KJ_NONE).to_le_bytes()); b[NAME_AT..TRIP_AT].copy_from_slice(&footer.name.bytes); if let Some(trip) = footer.trip { - b[TRIP_AT..TRIP_AT + 8].copy_from_slice(&trip.key.to_le_bytes()); + b[TRIP_AT..TRIP_AT + 8].copy_from_slice(&trip.key().to_le_bytes()); b[TRIP_AT + 8] = trip.day_index; b[TRIP_AT + 9] = trip.day_count; b[TRIP_AT + 11] = footer.trip_name.len; @@ -217,7 +230,7 @@ pub fn decode_footer(b: &[u8; FOOTER_LEN]) -> Result { if b[4] != VERSION { return Err(DecodeError::Version); } - if u16::from_le_bytes([b[6], b[7]]) as usize != FOOTER_LEN || b[31] != 0 { + if u16::from_le_bytes([b[6], b[7]]) as usize != FOOTER_LEN || b[33] != 0 { return Err(DecodeError::Layout); } let name = Name::decode(b[5], &b[NAME_AT..TRIP_AT])?; @@ -237,12 +250,14 @@ pub fn decode_footer(b: &[u8; FOOTER_LEN]) -> Result { moving_time_s: u32::from_le_bytes(b[16..20].try_into().unwrap()), avg_speed_cms: u16::from_le_bytes(b[20..22].try_into().unwrap()), climb_m: u16::from_le_bytes(b[22..24].try_into().unwrap()), - point_count: u32::from_le_bytes(b[24..28].try_into().unwrap()), - avg_hr: opt_u8(b[28], HR_NONE), - max_hr: opt_u8(b[29], HR_NONE), - avg_cadence: opt_u8(b[30], CAD_NONE), - avg_power: opt_u16(u16::from_le_bytes(b[32..34].try_into().unwrap()), PWR_NONE), - max_power: opt_u16(u16::from_le_bytes(b[34..36].try_into().unwrap()), PWR_NONE), + descent_m: u16::from_le_bytes(b[24..26].try_into().unwrap()), + point_count: u32::from_le_bytes(b[26..30].try_into().unwrap()), + avg_hr: opt(b[30], HR_NONE), + max_hr: opt(b[31], HR_NONE), + avg_cadence: opt(b[32], CAD_NONE), + avg_power: opt(u16::from_le_bytes(b[34..36].try_into().unwrap()), PWR_NONE), + max_power: opt(u16::from_le_bytes(b[36..38].try_into().unwrap()), PWR_NONE), + energy_kj: opt(u32::from_le_bytes(b[38..42].try_into().unwrap()), KJ_NONE), bike, name, trip, @@ -259,16 +274,12 @@ pub fn checked_object_len(point_count: u32) -> Result { } #[inline] -fn opt_u8(v: u8, sentinel: u8) -> Option { - (v != sentinel).then_some(v) -} - -#[inline] -fn opt_u16(v: u16, sentinel: u16) -> Option { +fn opt(v: T, sentinel: T) -> Option { (v != sentinel).then_some(v) } const _: () = assert!(SAMPLE_LEN == 20); +const _: () = assert!(core::mem::size_of::>() == core::mem::size_of::()); const _: () = assert!(FOOTER_LEN == TRIP_NAME_AT + NAME_CAP); #[cfg(test)] @@ -292,6 +303,8 @@ mod tests { Some(210), Some(480), ); + footer.descent_m = 640; + footer.energy_kj = Some(756); footer.bike = BikeType::Gravel; footer.set_trip(Some(TRIP), Name::new("Alpen Traverse")); footer @@ -301,12 +314,22 @@ mod tests { fn footer_round_trip_pins_layout() { let footer = example(); let bytes = encode_footer(&footer); - assert_eq!(&bytes[..8], b"OBRF\x04\x0b\x90\0"); - assert_eq!(&bytes[84..96], &[0xEF, 0xCD, 0xAB, 0x89, 0x67, 0x45, 0x23, 0x01, 1, 3, 1, 14]); + assert_eq!(&bytes[..8], b"OBRF\x05\x0b\x96\0"); + assert_eq!(&bytes[22..30], &[0x2A, 3, 0x80, 2, 3, 0, 0, 0], "climb, descent, point count"); + assert_eq!(&bytes[34..42], &[210, 0, 224, 1, 0xF4, 2, 0, 0], "power, then energy"); + assert_eq!(&bytes[90..102], &[0xEF, 0xCD, 0xAB, 0x89, 0x67, 0x45, 0x23, 0x01, 1, 3, 1, 14]); assert_eq!(decode_footer(&bytes), Ok(footer)); assert_eq!(footer.name(), "Sensor Ride"); assert_eq!(footer.trip_name(), "Alpen Traverse"); - assert_eq!(checked_object_len(3), Ok(3 * 20 + 144)); + assert_eq!(checked_object_len(3), Ok(3 * 20 + 150)); + } + + #[test] + fn no_power_data_stays_apart_from_zero_kj() { + for energy_kj in [None, Some(0)] { + let footer = Footer { energy_kj, ..example() }; + assert_eq!(decode_footer(&encode_footer(&footer)), Ok(footer)); + } } #[test] @@ -314,14 +337,15 @@ mod tests { let mut footer = example(); footer.set_trip(None, Name::new("ignored")); let bytes = encode_footer(&footer); - assert!(bytes[84..94].iter().chain(&bytes[95..]).all(|&v| v == 0)); - assert_eq!(bytes[94], BikeType::Gravel as u8); + assert!(bytes[90..100].iter().chain(&bytes[101..]).all(|&v| v == 0)); + assert_eq!(bytes[100], BikeType::Gravel as u8); assert_eq!(decode_footer(&bytes).unwrap().trip(), None); + assert_eq!(TripRef::new(0, 0, 1), None, "key 0 is never a trip"); } #[test] - fn committed_v4_vector_uses_the_production_footer_codec() { - let object = include_bytes!("../../../specs/vectors/ride-v4.bin"); + fn committed_v5_vector_uses_the_production_footer_codec() { + let object = include_bytes!("../../../specs/vectors/ride-v5.bin"); assert_eq!(object.len() as u64, checked_object_len(3).unwrap()); let footer: &[u8; FOOTER_LEN] = object[object.len() - FOOTER_LEN..].try_into().unwrap(); let decoded = decode_footer(footer).unwrap(); @@ -338,7 +362,7 @@ mod tests { let bytes = encode_footer(&example()); // Magic, version, length, reserved, name padding, a day past the count, a bike type past // the four, and trip-name padding. - for (offset, value) in [(0, 0), (4, 3), (6, 84), (31, 1), (83, 1), (92, 3), (94, 4), (143, 1)] { + for (offset, value) in [(0, 0), (4, 4), (6, 148), (33, 1), (89, 1), (98, 3), (100, 4), (149, 1)] { let mut bad = bytes; bad[offset] = value; assert!(decode_footer(&bad).is_err(), "offset {offset}"); @@ -347,7 +371,7 @@ mod tests { no_trip.set_trip(None, Name::EMPTY); let no_trip = encode_footer(&no_trip); // A day index, a day count or a trip name without a trip key. - for offset in [92, 93, 95] { + for offset in [98, 99, 101] { let mut bad = no_trip; bad[offset] = 1; assert!(decode_footer(&bad).is_err(), "offset {offset}"); diff --git a/firmware/obc-fw-nrf54l/src/bin/flat_store_bench.rs b/firmware/obc-fw-nrf54l/src/bin/flat_store_bench.rs index 9c849b46e..45762439c 100644 --- a/firmware/obc-fw-nrf54l/src/bin/flat_store_bench.rs +++ b/firmware/obc-fw-nrf54l/src/bin/flat_store_bench.rs @@ -113,7 +113,7 @@ const COMMIT_SAMPLES: usize = 3; const RIDE_RESERVE: u64 = 32 * EXTENT_SIZE; /// The shipping recorder checkpoints every ten seconds. At the minimum one-second fix cadence that -/// is ten exact ride-v4 records, not an arbitrary byte-growth surrogate. +/// is ten exact ride-v5 records, not an arbitrary byte-growth surrogate. const SAMPLES_PER_CHECKPOINT: u32 = 10; const CHECKPOINT_SAMPLE_BYTES: usize = SAMPLES_PER_CHECKPOINT as usize * SAMPLE_LEN; /// Enough ten-sample checkpoints to cross the 16 KiB boundary and turn the 16-slot ring repeatedly. diff --git a/firmware/obc-link/tests/flat_engine.rs b/firmware/obc-link/tests/flat_engine.rs index 49139d439..0de8f3f85 100644 --- a/firmware/obc-link/tests/flat_engine.rs +++ b/firmware/obc-link/tests/flat_engine.rs @@ -404,7 +404,7 @@ fn metadata_is_readable_but_only_the_device_can_mutate_it() { fn a_finished_journal_ride_is_the_exact_object_served_by_normal_get() { let disk = formatted_card(140); let mut device = boot(&disk); - let bytes = include_bytes!("../../../specs/vectors/ride-v4.bin"); + let bytes = include_bytes!("../../../specs/vectors/ride-v5.bin"); let (id, revision) = device.finish_recording(bytes, "FS8 vector"); let entry = device.entry(id).expect("the final catalog names the ride"); diff --git a/firmware/obc-link/tests/flat_engine/recovery.rs b/firmware/obc-link/tests/flat_engine/recovery.rs index bd1f2e455..4529c5874 100644 --- a/firmware/obc-link/tests/flat_engine/recovery.rs +++ b/firmware/obc-link/tests/flat_engine/recovery.rs @@ -11,8 +11,8 @@ use obc_storage::flat::{ StoreError, RIDE_RESUME_LEN, }; -/// `specs/vectors/ride-v4.bin`: three 20-byte `TrackPoint` samples, then the fixed footer. -const RIDE_V4: &[u8] = include_bytes!("../../../../specs/vectors/ride-v4.bin"); +/// `specs/vectors/ride-v5.bin`: three 20-byte `TrackPoint` samples, then the fixed footer. +const RIDE_V5: &[u8] = include_bytes!("../../../../specs/vectors/ride-v5.bin"); /// The export `specs/vectors/track-export.gpx` was taken under (`obc_vectors::TRACK_NAME`); the /// ride's own footer carries a different one, which is what [`RideInfo`] reads. const EXPORT_NAME: &str = "Schauinsland & back"; @@ -65,9 +65,9 @@ fn journaled_recording(seed: u64) -> (SparseDisk, (u64, u64)) { let key = { let mut device = boot(&disk); let key = device.seed_recording(RESERVE); - let (samples, footer) = RIDE_V4.split_at(RIDE_V4.len() - FOOTER_LEN); + let (samples, footer) = RIDE_V5.split_at(RIDE_V5.len() - FOOTER_LEN); checkpoint(&device, key, samples, samples); - checkpoint(&device, key, footer, RIDE_V4); + checkpoint(&device, key, footer, RIDE_V5); key }; (disk, key) @@ -82,7 +82,7 @@ fn catalog_write_offset() -> u32 { let (disk, key) = journaled_recording(1_420); let device = boot(&disk); let baseline = disk.ops(); - finalise(&device, key, RIDE_V4.len() as u64, crc32(RIDE_V4)).expect("the probe finalises"); + finalise(&device, key, RIDE_V5.len() as u64, crc32(RIDE_V5)).expect("the probe finalises"); let (op, _, _) = disk .write_log() .into_iter() @@ -108,7 +108,7 @@ fn an_interrupted_recording_recovers_and_exports_the_pinned_gpx() { let baseline = disk.ops(); disk.plan(FaultPlan { op: baseline + cut_at, when: When::Before }); assert_eq!( - finalise(&device, key, RIDE_V4.len() as u64, crc32(RIDE_V4)), + finalise(&device, key, RIDE_V5.len() as u64, crc32(RIDE_V5)), Err(StoreError::Media), "the cut did not land inside finalisation", ); @@ -124,8 +124,8 @@ fn an_interrupted_recording_recovers_and_exports_the_pinned_gpx() { ); let recovered = device.store.recovered_ride().expect("the store recovers the interrupted ride"); assert_eq!((recovered.id.0, recovered.revision.0), key); - assert_eq!(recovered.payload_len(), RIDE_V4.len() as u64, "the recovery lost journaled bytes"); - assert_eq!(recovered.payload_crc, crc32(RIDE_V4), "the recovery reconstructed a different ride"); + assert_eq!(recovered.payload_len(), RIDE_V5.len() as u64, "the recovery lost journaled bytes"); + assert_eq!(recovered.payload_crc, crc32(RIDE_V5), "the recovery reconstructed a different ride"); // The recorder retries the commit, publishing what the recovery handed back — not what this test // knows the ride to be, so a recovery that rebuilt the wrong length or CRC cannot pass here. @@ -133,7 +133,7 @@ fn an_interrupted_recording_recovers_and_exports_the_pinned_gpx() { .expect("the retried finalisation publishes the ride"); let entry = device.entry(id).expect("the final catalog names the ride"); assert!(!entry.flags.has(EntryFlags::RECORDING)); - assert_eq!((entry.payload_len, entry.payload_crc), (RIDE_V4.len() as u64, crc32(RIDE_V4))); + assert_eq!((entry.payload_len, entry.payload_crc), (RIDE_V5.len() as u64, crc32(RIDE_V5))); assert!(device.store.recovered_ride().is_none(), "a published ride is still offered as recording"); // What the phone asks for, and what it gets. @@ -141,7 +141,7 @@ fn an_interrupted_recording_recovers_and_exports_the_pinned_gpx() { let answer = Answer::of(wire.answer()); assert!(!answer.is_error(), "{answer:?}"); let payload = wire.payload(); - assert_eq!(payload, RIDE_V4, "GET served something other than the recovered ride"); + assert_eq!(payload, RIDE_V5, "GET served something other than the recovered ride"); // What the phone makes of those bytes: the vector's own totals, then the pinned export. let source = SliceSource(&payload); diff --git a/firmware/obc-platform/src/debug_link.rs b/firmware/obc-platform/src/debug_link.rs index cdc78ee49..9039980c6 100644 --- a/firmware/obc-platform/src/debug_link.rs +++ b/firmware/obc-platform/src/debug_link.rs @@ -81,7 +81,7 @@ pub enum Msg { /// a catalog that cannot be listed, cannot be produced on a real card without risking it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RideDamageKind { - /// Bytes that are not a ride-v4 sample/footer boundary. + /// Bytes that are not a ride-v5 sample/footer boundary. Payload, /// A valid sample boundary with a continuation image that does not decode. Metadata, diff --git a/firmware/obc-route/src/ride.rs b/firmware/obc-route/src/ride.rs index c17379d2f..bb0e6a3f4 100644 --- a/firmware/obc-route/src/ride.rs +++ b/firmware/obc-route/src/ride.rs @@ -1,6 +1,6 @@ -//! Recorded-ride v4 summary access. +//! Recorded-ride v5 summary access. //! -//! The object begins with the existing 20-byte track samples and ends with one fixed 144-byte +//! The object begins with the existing 20-byte track samples and ends with one fixed 150-byte //! footer. Recording therefore writes the final bytes directly; finalize is one footer append, //! never a whole-ride conversion. @@ -21,6 +21,7 @@ pub struct RideStats { pub moving_time_s: u32, pub avg_speed_cms: u16, pub climb_m: u16, + pub descent_m: u16, /// Unix seconds that were true at [`anchor_ms`](RideStats::anchor_ms). pub unix_at_anchor: u32, /// The monotonic sample clock at which [`unix_at_anchor`](RideStats::unix_at_anchor) was read. @@ -32,6 +33,8 @@ pub struct RideStats { pub avg_cadence: Option, pub avg_power: Option, pub max_power: Option, + /// The ride's energy from power; `None` without power data. + pub energy_kj: Option, /// The bike type that was current when the ride started. pub bike: BikeType, /// The trip day the ride started on. @@ -70,6 +73,8 @@ pub fn encode_summary_footer( stats.avg_power, stats.max_power, ); + footer.descent_m = stats.descent_m; + footer.energy_kj = stats.energy_kj; footer.bike = stats.bike; footer.set_trip(stats.trip, stats.trip_name); encode_footer(&footer) @@ -85,12 +90,14 @@ pub struct RideInfo { pub moving_time_s: u32, pub avg_speed_cms: u16, pub climb_m: u16, + pub descent_m: u16, pub point_count: u32, pub avg_hr: Option, pub max_hr: Option, pub avg_cadence: Option, pub avg_power: Option, pub max_power: Option, + pub energy_kj: Option, pub bike: BikeType, pub trip: Option, pub trip_name: String, @@ -129,12 +136,14 @@ impl RideInfo { moving_time_s: footer.moving_time_s, avg_speed_cms: footer.avg_speed_cms, climb_m: footer.climb_m, + descent_m: footer.descent_m, point_count: footer.point_count, avg_hr: footer.avg_hr, max_hr: footer.max_hr, avg_cadence: footer.avg_cadence, avg_power: footer.avg_power, max_power: footer.max_power, + energy_kj: footer.energy_kj, bike: footer.bike, trip: footer.trip(), trip_name, diff --git a/firmware/obc-route/tests/cases/ride.rs b/firmware/obc-route/tests/cases/ride.rs index 33731c825..0b8c55cc9 100644 --- a/firmware/obc-route/tests/cases/ride.rs +++ b/firmware/obc-route/tests/cases/ride.rs @@ -1,4 +1,4 @@ -//! Recorded ride v4 contract: verbatim samples, one fixed footer, and footer-based readers. +//! Recorded ride v5 contract: verbatim samples, one fixed footer, and footer-based readers. use core::cell::RefCell; @@ -16,6 +16,7 @@ const STATS: RideStats = RideStats { moving_time_s: 9_000, avg_speed_cms: 472, climb_m: 200, + descent_m: 180, unix_at_anchor: 1_751_450_000, anchor_ms: 400_000, clock_trusted: true, @@ -24,6 +25,7 @@ const STATS: RideStats = RideStats { avg_cadence: Some(85), avg_power: Some(210), max_power: Some(480), + energy_kj: Some(756), bike: BikeType::Mtb, trip: TripRef::new(7, 0, 2), trip_name: Name::EMPTY, @@ -60,8 +62,8 @@ fn recorded_samples_are_the_served_bytes() { (2_224, 9_000, 472, 200, 2) ); assert_eq!( - (info.avg_hr, info.max_hr, info.avg_cadence, info.avg_power, info.max_power), - (Some(142), Some(176), Some(85), Some(210), Some(480)) + (info.avg_hr, info.max_hr, info.avg_cadence, info.avg_power, info.max_power, info.energy_kj), + (Some(142), Some(176), Some(85), Some(210), Some(480), Some(756)) ); assert_eq!((info.bike, info.trip, info.trip_name.as_str()), (BikeType::Mtb, STATS.trip, "Alpen")); } @@ -91,7 +93,7 @@ fn list_summary_is_one_footer_only_random_read() { } #[test] -fn exact_length_and_v4_are_mandatory() { +fn exact_length_and_v5_are_mandatory() { let ride = ride_of(&[pt(1, 2, 3, 4, true)], "R", &STATS); assert!(RideInfo::read(&SliceSource(&ride[..ride.len() - 1])).is_err()); let mut long = ride.clone(); @@ -100,7 +102,7 @@ fn exact_length_and_v4_are_mandatory() { let mut old = ride; let version = old.len() - FOOTER_LEN + 4; - old[version] = 3; + old[version] = 4; assert!(matches!(RideInfo::read(&SliceSource(&old)), Err(Error::BadVersion))); } diff --git a/firmware/tools/ui_frames/environments.py b/firmware/tools/ui_frames/environments.py index 7a3c8b181..ce4b43c43 100644 --- a/firmware/tools/ui_frames/environments.py +++ b/firmware/tools/ui_frames/environments.py @@ -62,16 +62,16 @@ def plain_route(stage: Stage) -> Staging: def _ride(samples, name, start, distance_m, moving_s, climb_m, avg_hr, trip): """A ride object as `specs/obc-ble-interface-spec.md` §7.2 lays it out: 20-byte samples, then - the 144-byte footer. `samples` are `(lon, lat, ele)`; `trip` is `(key, day index, day count, + the 150-byte footer. `samples` are `(lon, lat, ele)`; `trip` is `(key, day index, day count, name)` or `None`. """ body = bytearray() for i, (lon, lat, ele) in enumerate(samples): body += struct.pack(" Staging: """Four stored rides for the Rides screens, oldest first, so the import gives the newest the - highest id: two loose copies of the pinned `ride-v4.bin` vector, "Sensor Ride" with all three + highest id: two loose copies of the pinned `ride-v5.bin` vector, "Sensor Ride" with all three sensors, and two days of the trip "Alps traverse" on the Grimsel climb's track. Day 2 has a heart rate; Day 1 has no sensor. Every ride is unsynced; the flat store stages no archive rows. """ where = stage.dir("tracks") - vector = (stage.vectors / "ride-v4.bin").read_bytes() - footer = len(vector) - 144 + vector = (stage.vectors / "ride-v5.bin").read_bytes() + footer = len(vector) - 150 for index, distance in enumerate((12_345, 17_800)): loose = bytearray(vector) struct.pack_into("\s*([-\d.]+)', gpx) diff --git a/host/obc-host-core/src/conformance.rs b/host/obc-host-core/src/conformance.rs index ba3299734..038af674f 100644 --- a/host/obc-host-core/src/conformance.rs +++ b/host/obc-host-core/src/conformance.rs @@ -149,6 +149,7 @@ fn stats() -> obc_route::RideStats { moving_time_s: 300, avg_speed_cms: 333, climb_m: 20, + descent_m: 0, unix_at_anchor: 1_720_000_000, anchor_ms: 0, clock_trusted: true, @@ -157,6 +158,7 @@ fn stats() -> obc_route::RideStats { avg_cadence: None, avg_power: None, max_power: None, + energy_kj: None, bike: obc_formats::bike::BikeType::Road, trip: None, trip_name: obc_formats::ride::Name::EMPTY, diff --git a/host/obc-host-core/src/flat_recorder/tests.rs b/host/obc-host-core/src/flat_recorder/tests.rs index 1234195a4..f49095047 100644 --- a/host/obc-host-core/src/flat_recorder/tests.rs +++ b/host/obc-host-core/src/flat_recorder/tests.rs @@ -42,6 +42,7 @@ fn stats() -> RideStats { moving_time_s: 10, avg_speed_cms: 1800, climb_m: 12, + descent_m: 3, unix_at_anchor: 1_720_000_000, anchor_ms: 12_000, clock_trusted: true, @@ -50,6 +51,7 @@ fn stats() -> RideStats { avg_cadence: Some(80), avg_power: Some(200), max_power: Some(250), + energy_kj: Some(2), bike: obc_formats::bike::BikeType::Road, trip: None, trip_name: obc_formats::ride::Name::EMPTY, diff --git a/host/obc-host-core/tests/board_ride.rs b/host/obc-host-core/tests/board_ride.rs index b8eaabb8a..5c6fe70ba 100644 --- a/host/obc-host-core/tests/board_ride.rs +++ b/host/obc-host-core/tests/board_ride.rs @@ -88,6 +88,7 @@ fn stats() -> obc_route::RideStats { moving_time_s: 0, avg_speed_cms: 0, climb_m: 0, + descent_m: 0, unix_at_anchor: 0, anchor_ms: 0, clock_trusted: false, @@ -96,6 +97,7 @@ fn stats() -> obc_route::RideStats { avg_cadence: None, avg_power: None, max_power: None, + energy_kj: None, bike: obc_formats::bike::BikeType::Road, trip: None, trip_name: obc_formats::ride::Name::EMPTY, diff --git a/host/obc-vectors/src/lib.rs b/host/obc-vectors/src/lib.rs index dab74f403..3edb853f0 100644 --- a/host/obc-vectors/src/lib.rs +++ b/host/obc-vectors/src/lib.rs @@ -157,7 +157,7 @@ pub fn track_log() -> Vec { v } -/// The GPX 1.1 export of [`ride_v4`], through the production converter (`track_to_gpx`). +/// The GPX 1.1 export of [`ride_v5`], through the production converter (`track_to_gpx`). /// /// Unlike the binary fixtures there is no independent spec to rebuild this from — the exporter's /// serialization *is* the contract — so this goes through the real code, exactly like @@ -165,7 +165,7 @@ pub fn track_log() -> Vec { /// (`obc-web-convert`, compiled to wasm) must reproduce these bytes character-for-character. pub fn track_export_gpx() -> Vec { let mut sink = VecSink(Vec::new()); - obc_route::track_to_gpx(&SliceSource(&ride_v4()), TRACK_NAME, &mut sink).unwrap(); + obc_route::track_to_gpx(&SliceSource(&ride_v5()), TRACK_NAME, &mut sink).unwrap(); sink.0 } @@ -176,10 +176,10 @@ fn le32(v: u32) -> [u8; 4] { v.to_le_bytes() } -/// Ride object v4: three exact 20-byte recorded samples followed by the fixed 144-byte footer. +/// Ride object v5: three exact 20-byte recorded samples followed by the fixed 150-byte footer. /// Built field-by-field from the specification rather than through the production codec. The ride /// started on day 2 of 3 of the [`TRIP_KEY`] trip, on a Gravel bike. -pub fn ride_v4() -> Vec { +pub fn ride_v5() -> Vec { let mut v = Vec::new(); // lon µdeg, lat µdeg, ele m, flags, t_ms, hr, cadence, power. for (lon, lat, ele, flags, t_ms, hr, cad, pwr) in [ @@ -199,14 +199,15 @@ pub fn ride_v4() -> Vec { let name = b"Sensor Ride"; v.extend_from_slice(b"OBRF"); - v.push(4); // version + v.push(5); // version v.push(name.len() as u8); - v.extend_from_slice(&le16(144)); // fixed footer length + v.extend_from_slice(&le16(150)); // fixed footer length v.extend_from_slice(&le32(1_751_460_000)); // start_time v.extend_from_slice(&le32(12_345)); // distance m v.extend_from_slice(&le32(3_600)); // moving_time s v.extend_from_slice(&le16(343)); // avg_speed cm/s v.extend_from_slice(&le16(120)); // climb m + v.extend_from_slice(&le16(95)); // descent m v.extend_from_slice(&le32(3)); // point_count v.push(142); // avg_hr v.push(176); // max_hr @@ -214,15 +215,16 @@ pub fn ride_v4() -> Vec { v.push(0); // reserved v.extend_from_slice(&le16(210)); // avg_pwr v.extend_from_slice(&le16(480)); // max_pwr + v.extend_from_slice(&le32(756)); // energy kJ v.extend_from_slice(name); - v.resize(3 * 20 + 84, 0); // fixed 48-byte name slot + v.resize(3 * 20 + 90, 0); // fixed 48-byte name slot v.extend_from_slice(&TRIP_KEY.to_le_bytes()); v.push(1); // day index: the second day v.push(3); // day count v.push(1); // bike type: Gravel v.push(TRIP_NAME.len() as u8); v.extend_from_slice(TRIP_NAME.as_bytes()); - v.resize(3 * 20 + 144, 0); // fixed 48-byte trip-name slot + v.resize(3 * 20 + 150, 0); // fixed 48-byte trip-name slot v } @@ -552,7 +554,7 @@ pub fn all() -> Vec<(&'static str, Vec)> { ("route-waypoints.obcr", route_wp), ("route-plain.obcr", route_plain), // The sample-codec fixture remains a codec vector only. GPX export is pinned from the - // finished ride-v4 object; headerless sample arrays are not accepted as rides. + // finished ride-v5 object; headerless sample arrays are not accepted as rides. ("track-log.obct", track_log()), ("track-export.gpx", track_export_gpx()), // The OBCT terrain shard (`OBCT_Spec.md`): a 2 × 2 cell rectangle with a hole @@ -561,7 +563,7 @@ pub fn all() -> Vec<(&'static str, Vec)> { // (the device, the `obc-dem` baker's cross-check, and eventually the browser), and the // spec's guarantee is that they agree bit-for-bit on the same coordinate. ("terrain-shard.obcd", terrain.clone()), - ("ride-v4.bin", ride_v4()), + ("ride-v5.bin", ride_v5()), ("config-v1.bin", config_v1()), ("place-train-v15.bin", place_record()), ("landmark-section-v16.bin", landmarks::section()), diff --git a/host/obc-vectors/tests/vectors.rs b/host/obc-vectors/tests/vectors.rs index 9079f97a3..0ea1aca08 100644 --- a/host/obc-vectors/tests/vectors.rs +++ b/host/obc-vectors/tests/vectors.rs @@ -9,7 +9,7 @@ use obc_route::{ for_each_waypoint, track_to_gpx, BikeType, RouteIndex, RouteObjectInfo, RouteReader, MAX_POINTS_PER_CHUNK, }; use obc_vectors::{ - all, crc32, dir, ride_v4, terrain_coord, terrain_height, terrain_shard, TERRAIN_CELL_LOG2, TERRAIN_CELL_MIN_I, + all, crc32, dir, ride_v5, terrain_coord, terrain_height, terrain_shard, TERRAIN_CELL_LOG2, TERRAIN_CELL_MIN_I, TERRAIN_CELL_MIN_J, TERRAIN_COLS, TERRAIN_NODATA_AT, TERRAIN_POSTING_LOG2, TERRAIN_ROWS, TRACK_NAME, TRIP_DAYS, TRIP_KEY, TRIP_NAME, TRIP_START_DATE, }; @@ -169,7 +169,7 @@ fn route_descriptor_envelopes_match_the_shared_overlap_contract() { /// The sample-codec vector and the finished-ride GPX export. `track-log.obct` is exactly five /// complete 20-byte records used only to pin the sample codec. The exporter consumes -/// `ride-v4.bin`; unfinished/headerless arrays are deliberately not a ride input. +/// `ride-v5.bin`; unfinished/headerless arrays are deliberately not a ride input. #[test] fn track_vectors_pin_the_log_and_its_export() { let log = fixture("track-log.obct"); @@ -200,10 +200,10 @@ fn track_vectors_pin_the_log_and_its_export() { assert_eq!((point(0).segment_start, point(3).segment_start), (true, true), "two segments"); assert_eq!((point(3).lon, point(3).lat, point(3).ele), (-122_419_400, -37_774_900, -12), "negative signs"); - // The export re-derives from the checked-in finished ride-v4 object. + // The export re-derives from the checked-in finished ride-v5 object. let mut sink = VecSink::default(); - track_to_gpx(&SliceSource(&fixture("ride-v4.bin")), TRACK_NAME, &mut sink).unwrap(); - assert_eq!(String::from_utf8(sink.buf).unwrap(), gpx, "track-export.gpx drifted from ride-v4.bin"); + track_to_gpx(&SliceSource(&fixture("ride-v5.bin")), TRACK_NAME, &mut sink).unwrap(); + assert_eq!(String::from_utf8(sink.buf).unwrap(), gpx, "track-export.gpx drifted from ride-v5.bin"); // The shapes the exporter's branches produce, spelled out once (the browser bridge reproduces // this exact text, so a change here is a change to a cross-language contract). @@ -223,43 +223,43 @@ fn track_vectors_pin_the_log_and_its_export() { assert!(!gpx.contains("