From 705ffd9045e13eccd8ba70640134f0636fd9f2f2 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 23:08:52 +0200 Subject: [PATCH 1/5] feat(trips): a ride start on a trip day makes that trip active The start of a fresh ride on a trip day writes the trip's progress record again, unchanged, so it is the latest record. The start card reads the active trip from the latest record, so its day row follows the trip the rider started, and the store keeps that order across a power cycle. A progress write keeps a Revision the record carries and stamps only a record without one, so a moved record keeps the Revision its metres belong to. Co-Authored-By: Claude Opus 5.5 --- firmware/obc-app/src/app.rs | 14 ++++ firmware/obc-app/src/device_core/pass.rs | 1 + firmware/obc-app/src/metadata.rs | 6 ++ firmware/obc-app/src/navigator/arrival.rs | 26 ++++---- firmware/obc-app/src/trip.rs | 27 +++++++- firmware/obc-app/tests/cases/trips.rs | 65 +++++++++++++++---- firmware/obc-storage/src/flat/metadata.rs | 7 +- .../obc-storage/src/flat/metadata/tests.rs | 17 +++-- specs/obc-ble-interface-spec.md | 8 ++- 9 files changed, 136 insertions(+), 35 deletions(-) diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 2ff2ec99a..350188eaf 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1154,6 +1154,20 @@ impl App { self.metadata.progress_payload(token) } + /// A ride that starts on a trip day makes that trip the active one: its record moves to the end + /// of the records, in the store too, so the start card follows it after a power cycle. + pub(crate) fn note_trip_start(&mut self) { + let Some(day) = self.recorder.ride_stats().trip else { return }; + let records = self.metadata.progress(); + if records.last().is_some_and(|p| p.key == day.key()) { + return; + } + let Some(trip) = self.trips().iter().find(|t| t.key == day.key()) else { return }; + let record = trip.start(trip.progress_in(records)); + let trips = self.catalogs.trips(); + self.metadata.owe_start(record, |key| trips.iter().any(|t| t.key == key)); + } + /// A saved ride on a trip day moves that trip's progress to where the ride ended. pub(crate) fn note_trip_finish(&mut self) { use crate::trip::TripPosition; diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index 318eb08ee..9b727da8d 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -555,6 +555,7 @@ impl App { self.navigator.reset_ride(); self.recorder.reset_totals(); self.recorder.set_origin(self.ride_origin()); + self.note_trip_start(); self.metadata.begin_ride(); self.navigator.reset_detour(); // Only a measured anchor re-joins the route. A plain route selection records no diff --git a/firmware/obc-app/src/metadata.rs b/firmware/obc-app/src/metadata.rs index c49cc50dc..4703f3afa 100644 --- a/firmware/obc-app/src/metadata.rs +++ b/firmware/obc-app/src/metadata.rs @@ -127,6 +127,12 @@ impl MetadataMachine { obc_formats::trip_progress::record(&mut self.progress, record.clone(), stored); self.progress_owed = Some(record); } + /// A start's record. It never displaces a Finish's record that the store does not hold yet. + pub(crate) fn owe_start(&mut self, record: TripProgress, stored: impl Fn(u64) -> bool) { + if self.progress_owed.is_none() { + self.owe_progress(record, stored); + } + } pub(crate) fn progress(&self) -> &[TripProgress] { &self.progress } diff --git a/firmware/obc-app/src/navigator/arrival.rs b/firmware/obc-app/src/navigator/arrival.rs index 7ea005066..6c80a27a1 100644 --- a/firmware/obc-app/src/navigator/arrival.rs +++ b/firmware/obc-app/src/navigator/arrival.rs @@ -78,7 +78,7 @@ mod tests { use super::Arrival; use crate::activity::Mode; use crate::device_core::{DerivedInputs, DerivedTargets, ExternalFacts, OutcomeSlots, PassClock, PassInputs}; - use crate::harness::support::{mount_store, quiet_pass, VecSink, EVERY_CAPABILITY}; + use crate::harness::support::{VecSink, EVERY_CAPABILITY}; use crate::screen::{self, ArrivalView, MapScreen, RouteSwapScreen, Screen, Transition, WarningFlags}; use crate::trip::TripInput; use crate::{App, AppState, Gesture, RecorderIntent}; @@ -124,13 +124,15 @@ mod tests { std::thread_local! { /// The executor's answers to the last pass, delivered on the next one. static OUTCOMES: core::cell::RefCell = const { core::cell::RefCell::new(OutcomeSlots::new()) }; + /// The store's revision. Each write moves it, and the app reads the catalog again. + static REVISION: core::cell::Cell = const { core::cell::Cell::new(1) }; } /// One pass at `ms`, with a fresh fix at `(lon, lat)` µdeg when given. It answers the store work /// the pass asks for, as an executor would, and returns a trip progress record it writes. fn pass( app: &mut App, - route: &RouteReader, + route: Option<&RouteReader>, ms: u32, fix: Option<(i32, i32)>, gestures: &[Gesture], @@ -140,7 +142,7 @@ mod tests { use crate::metadata::{MetadataEffect, MetadataOutcome}; use crate::recorder::{CheckpointStatus, RecorderEffect, RecorderOutcome}; - let scope = StoreRevision { store: StoreIdentity::new(1), revision: Revision::new(1) }; + let scope = StoreRevision { store: StoreIdentity::new(1), revision: Revision::new(REVISION.get()) }; let mut loc = Once(fix.map(|(lon, lat)| Fix::at(lat, lon))); let mut facts = ExternalFacts::NONE; facts.note_store_revision(scope); @@ -149,7 +151,7 @@ mod tests { now: PassClock { ride: RideClock(ms), ui: InputClock(ms) }, gestures, sensors: Sensors::new(&mut loc), - route: Some(route), + route, support: EVERY_CAPABILITY, outcomes, facts: &mut facts, @@ -172,6 +174,7 @@ mod tests { } let Some(MetadataEffect::WriteProgress { token, .. }) = plan.effects.metadata.take() else { return None }; let _ = outcomes.metadata.try_put(MetadataOutcome::ProgressWritten { token }); + REVISION.set(REVISION.get() + 1); app.trip_progress_payload(token).cloned() }) } @@ -180,15 +183,15 @@ mod tests { fn ride(app: &mut App, route: &RouteReader, fixes: &[(i32, i32)]) { for &fix in fixes { let ms = app.ui.now_ms + 1_000; - pass(app, route, ms, Some(fix), &[]); + pass(app, Some(route), ms, Some(fix), &[]); } let ms = app.ui.now_ms + 1_000; - pass(app, route, ms, None, &[]); + pass(app, Some(route), ms, None, &[]); } fn press(app: &mut App, route: &RouteReader, gestures: &[Gesture]) { let ms = app.ui.now_ms + 1_000; - pass(app, route, ms, None, gestures); + pass(app, Some(route), ms, None, gestures); } /// Recording on catalog route 0 of `routes`, on the Map. @@ -207,14 +210,11 @@ mod tests { let (summaries, ids): (Vec<_>, Vec<_>) = routes.iter().cloned().unzip(); app.set_routes_with_ids(&summaries, &ids); app.set_trips(trips); - mount_store(&mut app); - let store = crate::device_core::StoreIdentity::new(1); - let scope = crate::device_core::StoreRevision { store, revision: crate::device_core::Revision::new(1) }; - app.catalogs.loaded_scope = Some(scope); + pass(&mut app, None, 0, None, &[]); // mounts the store, which answers the catalog read app.activate_route(first); prepare(&mut app); app.recorder.request(RecorderIntent::Start); - quiet_pass(&mut app, 1); + pass(&mut app, None, 1, None, &[]); assert!(app.recorder.recording()); app.activity.mode = Mode::Riding; screen::apply(&mut app.ui.stack, Transition::Root(Screen::Map(MapScreen::new()))); @@ -393,7 +393,7 @@ mod tests { (0..40) .find_map(|_| { let ms = app.ui.now_ms + 1_000; - pass(app, route, ms, None, &[]) + pass(app, Some(route), ms, None, &[]) }) .expect("the Finish writes the trip progress") } diff --git a/firmware/obc-app/src/trip.rs b/firmware/obc-app/src/trip.rs index 37726b7c2..53b8165b1 100644 --- a/firmware/obc-app/src/trip.rs +++ b/firmware/obc-app/src/trip.rs @@ -293,7 +293,7 @@ impl TripSummary { TripProgress { key: self.key, day: at.day, - // The store stamps the revision it holds when it writes the record. + // Revision 0: the store stamps the revision it holds when it writes the record. day_route: RouteVersion { id: at.route, revision: 0 }, metres: at.metres, last_finished: Some(ridden), @@ -301,6 +301,20 @@ impl TripSummary { } } + /// The record a ride that starts on this trip writes. It is the trip's record as it is, so only + /// its place changes: the latest record names the active trip. A trip without a record gets + /// one without progress. + pub fn start(&self, old: Option<&TripProgress>) -> TripProgress { + self.own(old).cloned().unwrap_or_else(|| TripProgress { + key: self.key, + day: 0, + day_route: RouteVersion { id: self.stage_ids.first().copied().unwrap_or(0), revision: 0 }, + metres: 0, + last_finished: None, + dates: [0; MAX_TRIP_DAYS], + }) + } + /// How day `day` loads. When the position is on the day before, more than [`REST_MIN_M`] before /// it leaves the line, and no transfer lies between the two days, the day is the rest of that /// day and then this day. Otherwise it is this day's route as it is. @@ -406,6 +420,17 @@ mod tests { assert_eq!(next(&[]), None); } + #[test] + fn a_start_keeps_the_record_and_gives_a_trip_without_one_no_progress() { + let t = trip(0); + let early = progress(1, Some(0), &[MON]); + assert_eq!(t.start(Some(&early)), early); + let fresh = t.start(None); + assert_eq!((fresh.key, fresh.metres, fresh.last_finished), (KEY, 0, None)); + assert_eq!(t.next_day(Some(&fresh)), Some(0)); + assert!(!t.is_ticked(0, Some(&fresh))); + } + #[test] fn later_days_count_after_the_day_and_skip_a_dangling_one() { let route = |distance_km| RouteSummary { diff --git a/firmware/obc-app/tests/cases/trips.rs b/firmware/obc-app/tests/cases/trips.rs index 47f018ea7..1c9c38dad 100644 --- a/firmware/obc-app/tests/cases/trips.rs +++ b/firmware/obc-app/tests/cases/trips.rs @@ -2,6 +2,7 @@ //! filed folders — the filed/unfiled partition, dangling refs, order, overflow, and re-resolution //! across a route rescan. +use obc_app::trip::{RouteVersion, TripProgress}; use obc_app::{App, AppState, RouteSummary, TripInput, MAX_TRIPS}; use obc_map_scene::BBox; @@ -180,31 +181,30 @@ fn a_ride_records_its_trip_day_and_bike_type() { assert_eq!((bike, trip, name.as_str()), (BikeType::Gravel, None, "")); } -/// Ride Day 2 of a three-day trip and save it. `ride_on` loads Day 3 during the ride, as Ride on in -/// the arrival view does. Returns the progress record the Finish writes. -fn finish_day_2(ride_on: bool) -> (App, obc_app::trip::TripProgress) { +/// Ride the route at catalog index `route` over a store that takes every write, and save the ride +/// when `save`. `ride_on` loads the next route during the ride, as Ride on in the arrival view does. +/// Returns the progress records the store takes, in write order. +fn ride(app: &mut App, route: usize, ride_on: bool, save: bool) -> Vec { use obc_app::catalog_state::{CatalogEffect, CatalogOutcome}; use obc_app::device_core::{ExternalFacts, OutcomeSlots, Revision, StoreIdentity, StoreRevision}; use obc_app::metadata::{MetadataEffect, MetadataOutcome}; use obc_app::recorder::{CheckpointStatus, RecorderEffect, RecorderOutcome}; use obc_app::RecorderIntent; - let mut app = app_with_three_routes(); - app.set_trips(&[TripInput { id: 1, key: 42, name: "Alpen Traverse", start_date: 0, stage_ids: &[7, 8, 9] }]); - app.activate_route(1); + app.activate_route(route); let scope = StoreRevision { store: StoreIdentity::new(1), revision: Revision::new(1) }; let mut outcomes = OutcomeSlots::new(); - let mut written = None; + let mut written = Vec::new(); for pass in 0..40 { let mut facts = ExternalFacts::NONE; facts.note_store_revision(scope); match pass { 1 => app.recorder.request(RecorderIntent::Start), - 2 if ride_on => app.activate_route(2), - 3 => app.recorder.request(RecorderIntent::Save), + 2 if ride_on => app.activate_route(route + 1), + 3 if save => app.recorder.request(RecorderIntent::Save), _ => {} } - let mut plan = crate::common::pass(&mut app, pass * 1_000, &mut outcomes, &mut facts, None); + let mut plan = crate::common::pass(app, pass * 1_000, &mut outcomes, &mut facts, None); if let Some(CatalogEffect::ReadCatalog { token }) = plan.effects.catalog.take() { outcomes.catalog.try_put(CatalogOutcome::CatalogRead { token, scope: Some(scope) }).unwrap(); } @@ -222,12 +222,53 @@ fn finish_day_2(ride_on: bool) -> (App, obc_app::trip::TripProgress) { _ => {} } if let Some(MetadataEffect::WriteProgress { token, .. }) = plan.effects.metadata.take() { - written = app.trip_progress_payload(token).cloned(); + written.extend(app.trip_progress_payload(token).cloned()); outcomes.metadata.try_put(MetadataOutcome::ProgressWritten { token }).unwrap(); } } + written +} + +/// Ride Day 2 of a three-day trip and save it. Returns the progress record the Finish writes. +fn finish_day_2(ride_on: bool) -> (App, TripProgress) { + let mut app = app_with_three_routes(); + app.set_trips(&[TripInput { id: 1, key: 42, name: "Alpen Traverse", start_date: 0, stage_ids: &[7, 8, 9] }]); + let written = ride(&mut app, 1, ride_on, true); + let finish = written.last().cloned().expect("the Finish writes the trip's progress"); + (app, finish) +} + +/// A ride that starts on a day of another trip makes that trip active before any Finish: the start +/// card's day row follows it, and the store takes the moved record, so a power cycle keeps it. A +/// ride on the active trip writes nothing at its start. +#[test] +fn a_ride_on_a_day_of_another_trip_makes_that_trip_active() { + let trips = || { + let mut app = app_with_three_routes(); + app.set_trips(&[ + TripInput { id: 1, key: 42, name: "Alps", start_date: 0, stage_ids: &[7, 8] }, + TripInput { id: 2, key: 5, name: "Jura", start_date: 0, stage_ids: &[9] }, + ]); + app.set_trip_progress([TripProgress { + key: 42, + day: 1, + day_route: RouteVersion { id: 8, revision: 1 }, + metres: 0, + last_finished: Some(0), + dates: [0; obc_route::MAX_TRIP_DAYS], + }]); + assert_eq!(app.next_trip_day().map(|(t, day)| (t.key, day)), Some((42, 1))); + app + }; + + let mut app = trips(); + let written = ride(&mut app, 2, false, false); + assert_eq!(written.iter().map(|p| (p.key, p.last_finished)).collect::>(), [(5, None)]); + assert_eq!(app.next_trip_day().map(|(t, day)| (t.key, day)), Some((5, 0))); + assert_eq!(app.trip_progress().iter().map(|p| p.key).collect::>(), [42, 5], "Alps keeps its record"); - (app, written.expect("the Finish writes the trip's progress")) + let mut app = trips(); + assert!(ride(&mut app, 1, false, false).is_empty()); } /// Finish of a ride on a trip day moves the trip's progress: the store gets one record, and the diff --git a/firmware/obc-storage/src/flat/metadata.rs b/firmware/obc-storage/src/flat/metadata.rs index 9ee064418..8e65edfd3 100644 --- a/firmware/obc-storage/src/flat/metadata.rs +++ b/firmware/obc-storage/src/flat/metadata.rs @@ -644,14 +644,17 @@ pub fn write_checkpoint( /// Write one trip progress record by the bound rules of /// [`record`](obc_formats::trip_progress::record); `stored` says whether a stored trip holds a key. -/// The record takes the current Revision of its day route, so a later replace voids its metres. +/// A record with Revision 0 takes the current Revision of its day route, so a later replace voids +/// its metres. A record that carries a Revision keeps it: its metres belong to that Revision. #[inline(never)] pub fn write_progress( store: &FlatStore, mut new: TripProgress, stored: impl Fn(u64) -> bool, ) -> Result<(), Error> { - new.day_route.revision = route_revision(store, new.day_route.id)?.unwrap_or(0); + if new.day_route.revision == 0 { + new.day_route.revision = route_revision(store, new.day_route.id)?.unwrap_or(0); + } let mut bytes = [0; MAX_LEN]; let mut owner = Metadata::new(store); let mut image = owner.load(store, &mut bytes)?; diff --git a/firmware/obc-storage/src/flat/metadata/tests.rs b/firmware/obc-storage/src/flat/metadata/tests.rs index 48e5ce66a..2720a73b0 100644 --- a/firmware/obc-storage/src/flat/metadata/tests.rs +++ b/firmware/obc-storage/src/flat/metadata/tests.rs @@ -579,23 +579,30 @@ fn progress_records_survive_row_and_checkpoint_edits_and_a_remount() { let store = FlatStore::initialize(&disk, CARD).unwrap(); let route = publish(&store, ObjectKind::Route, b"route"); let ride = publish(&store, ObjectKind::Ride, b"ride"); - let at_route = |key| TripProgress { day_route: RouteVersion { id: route.id.0, revision: 9 }, ..progress(key) }; + let at_route = + |key, revision| TripProgress { day_route: RouteVersion { id: route.id.0, revision }, ..progress(key) }; write_progress(&store, progress(3), |_| true).unwrap(); - write_progress(&store, at_route(1), |_| true).unwrap(); + write_progress(&store, at_route(1, 0), |_| true).unwrap(); + write_progress(&store, at_route(5, 9), |_| true).unwrap(); write_proof(&store, ride).unwrap(); write_checkpoint(&store, CARD, store.sequence(), None, Some(checkpoint(route, None))).unwrap(); disk.reboot(); let store = FlatStore::mount(&disk); let mut read = Vec::new(); read_progress(&store, |p| read.push(p)).unwrap(); - let stamped = TripProgress { day_route: RouteVersion { id: route.id.0, revision: 1 }, ..progress(1) }; + let stamped = at_route(1, 1); let gone = TripProgress { metres: 0, day_route: RouteVersion { id: 7, revision: 0 }, ..progress(3) }; - assert_eq!(read, [gone.clone(), stamped.clone()], "write order stays; a record takes its route's Revision"); + let kept = TripProgress { metres: 0, ..at_route(5, 9) }; + assert_eq!( + read, + [gone.clone(), stamped.clone(), kept], + "write order stays; a record without a Revision takes its route's, and one with a Revision keeps it" + ); let mut bytes = [0; MAX_LEN]; let mut image = Metadata::new(&store).load(&store, &mut bytes).unwrap(); assert_eq!(image.rows().count(), 1); image.set_checkpoint(None).unwrap(); - assert_eq!(image.progress().map(|p| p.key).collect::>(), [3, 1]); + assert_eq!(image.progress().map(|p| p.key).collect::>(), [3, 1, 5]); assert_eq!(image.set_progress(&[progress(3), progress(3)]), Err(Error::Invalid), "one record per key"); let len = image.bytes().len(); diff --git a/specs/obc-ble-interface-spec.md b/specs/obc-ble-interface-spec.md index 12f8cb030..da715fcc8 100644 --- a/specs/obc-ble-interface-spec.md +++ b/specs/obc-ble-interface-spec.md @@ -317,12 +317,14 @@ any other length, which also rejects a torn write. The device keeps one progress record per trip key. The record never crosses the wire. The device writes it at Finish of a ride on a trip day, and keeps it in the ride-archive Metadata object -([`Ride_Archive_Metadata.md`](Ride_Archive_Metadata.md)) with the navigator checkpoint. +([`Ride_Archive_Metadata.md`](Ride_Archive_Metadata.md)) with the navigator checkpoint. The start +of a ride on a trip day writes the record of its trip again, unchanged. A trip without a record +gets a record without a position, a finished day or dates. | Field | Meaning | | :-- | :-- | | position day | the day that contains the last matched position | -| position route | the route ObjectId and Revision of that day when the record was written | +| position route | the route ObjectId and Revision of that day when the Finish wrote the position | | position metres | metres into that day's route | | last finished day | the last day the rider finished; none before the first Finish | | finish dates | for each day, the date of its Finish in days since 1970-01-01; 0 = none | @@ -348,6 +350,8 @@ Days count from 0 in the object; the rider sees Day 1 for day 0. rider instead rides 20 km into Day 3, Day 3 is next with 20 km less to ride. A ride on the rest of Day 2 plus Day 3 that ends before it joins Day 3 finishes Day 2, not Day 3. The position stays on Day 2, so Day 3 is still next. +- **Active trip.** The trip of the latest record, while it has a next day. The start of a ride on a + trip day moves the record of its trip to the end, so that trip is active before its Finish. - **Ticks.** A day is ticked when it is at or before the last finished day, or when it is before the position day. - **Day dates.** Dates follow the rides. For day `k`, take the latest day `j ≤ k` with a finish From 71efe9360080048a5fb133194ca874bf82f026d9 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 23:30:19 +0200 Subject: [PATCH 2/5] fix(trips): the store moves the trip's record on a start A start writes a start record: day 0, day 0's route, 0 m, no finished day, no dates. The bound rules never let a start record replace a record: the store moves the stored record of its key to the end, byte for byte, and adds the start record only when the key has none. So a start before the device has read the records cannot blank a trip's progress, and every written record takes the store's Revision again. A continuation after a reset writes the start record too, because a reset can come before the start's write lands. Co-Authored-By: Claude Opus 5.5 --- firmware/obc-app/src/app.rs | 10 ++++---- firmware/obc-app/src/device_core/pass.rs | 3 ++- firmware/obc-app/src/trip.rs | 23 +++++++++++-------- firmware/obc-formats/src/trip_progress.rs | 19 +++++++++++++++ firmware/obc-storage/src/flat/metadata.rs | 8 +++---- .../obc-storage/src/flat/metadata/tests.rs | 17 +++++++------- specs/obc-ble-interface-spec.md | 21 ++++++++++++----- 7 files changed, 65 insertions(+), 36 deletions(-) diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 350188eaf..414e2d76f 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1154,16 +1154,16 @@ impl App { self.metadata.progress_payload(token) } - /// A ride that starts on a trip day makes that trip the active one: its record moves to the end - /// of the records, in the store too, so the start card follows it after a power cycle. + /// A ride on a trip day makes that trip the active one: its start record moves the trip's record + /// to the end of the records, in the store too, so the start card follows it after a power + /// cycle. The store moves its own copy, so records the device has not read yet are safe. pub(crate) fn note_trip_start(&mut self) { let Some(day) = self.recorder.ride_stats().trip else { return }; - let records = self.metadata.progress(); - if records.last().is_some_and(|p| p.key == day.key()) { + if self.metadata.progress().last().is_some_and(|p| p.key == day.key()) { return; } let Some(trip) = self.trips().iter().find(|t| t.key == day.key()) else { return }; - let record = trip.start(trip.progress_in(records)); + let record = trip.start(); let trips = self.catalogs.trips(); self.metadata.owe_start(record, |key| trips.iter().any(|t| t.key == key)); } diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index 9b727da8d..f4e530b14 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -555,7 +555,6 @@ impl App { self.navigator.reset_ride(); self.recorder.reset_totals(); self.recorder.set_origin(self.ride_origin()); - self.note_trip_start(); self.metadata.begin_ride(); self.navigator.reset_detour(); // Only a measured anchor re-joins the route. A plain route selection records no @@ -574,6 +573,8 @@ impl App { } } } + // A continuation writes it too: a reset can come before the start's write lands. + self.note_trip_start(); self.recorder.restart_buffers(); self.ui.map_dirty = true; } diff --git a/firmware/obc-app/src/trip.rs b/firmware/obc-app/src/trip.rs index 53b8165b1..b4ef6ef1d 100644 --- a/firmware/obc-app/src/trip.rs +++ b/firmware/obc-app/src/trip.rs @@ -301,18 +301,18 @@ impl TripSummary { } } - /// The record a ride that starts on this trip writes. It is the trip's record as it is, so only - /// its place changes: the latest record names the active trip. A trip without a record gets - /// one without progress. - pub fn start(&self, old: Option<&TripProgress>) -> TripProgress { - self.own(old).cloned().unwrap_or_else(|| TripProgress { + /// The start record a ride on this trip writes. It has no finished day, so it never replaces + /// the trip's record: the write moves that record to the end, where the latest record names the + /// active trip. A trip without a record gets this one. + pub fn start(&self) -> TripProgress { + TripProgress { key: self.key, day: 0, day_route: RouteVersion { id: self.stage_ids.first().copied().unwrap_or(0), revision: 0 }, metres: 0, last_finished: None, dates: [0; MAX_TRIP_DAYS], - }) + } } /// How day `day` loads. When the position is on the day before, more than [`REST_MIN_M`] before @@ -421,12 +421,15 @@ mod tests { } #[test] - fn a_start_keeps_the_record_and_gives_a_trip_without_one_no_progress() { + fn a_start_moves_the_record_and_gives_a_trip_without_one_no_progress() { let t = trip(0); let early = progress(1, Some(0), &[MON]); - assert_eq!(t.start(Some(&early)), early); - let fresh = t.start(None); - assert_eq!((fresh.key, fresh.metres, fresh.last_finished), (KEY, 0, None)); + let mut records = obc_formats::trip_progress::Records::new(); + obc_formats::trip_progress::record(&mut records, early.clone(), |_| true); + obc_formats::trip_progress::record(&mut records, t.start(), |_| true); + assert_eq!(records.as_slice(), [early]); + let fresh = t.start(); + assert_eq!((fresh.day, fresh.day_route.id, fresh.metres, fresh.last_finished), (0, 10, 0, None)); assert_eq!(t.next_day(Some(&fresh)), Some(0)); assert!(!t.is_ticked(0, Some(&fresh))); } diff --git a/firmware/obc-formats/src/trip_progress.rs b/firmware/obc-formats/src/trip_progress.rs index e0cf6b861..de924ba1e 100644 --- a/firmware/obc-formats/src/trip_progress.rs +++ b/firmware/obc-formats/src/trip_progress.rs @@ -79,7 +79,14 @@ impl TripProgress { /// Write `new` into `records` by the bound rules: drop each record whose key no stored trip holds, /// then the first record when the list is full; `new` goes to the end and replaces its key's record. +/// A start record, one without a finished day, never replaces: its key's record moves to the end +/// as it is, and `new` goes in only when its key has none. pub fn record(records: &mut Records, new: TripProgress, stored: impl Fn(u64) -> bool) { + let own = records.iter().position(|r| r.key == new.key); + let new = match own { + Some(i) if new.last_finished.is_none() => records.remove(i), + _ => new, + }; records.retain(|r| r.key != new.key && stored(r.key)); if records.is_full() { records.remove(0); @@ -127,6 +134,18 @@ mod tests { assert_eq!(records.len(), MAX_RECORDS); } + #[test] + fn a_start_record_moves_the_stored_record_and_never_replaces_it() { + let start = |key| TripProgress { day: 0, metres: 0, last_finished: None, dates: [0; MAX_DAYS], ..at(key) }; + let mut records = Records::new(); + record(&mut records, at(1), |_| true); + record(&mut records, at(2), |_| true); + record(&mut records, start(1), |_| true); + assert_eq!(records.as_slice(), [at(2), at(1)]); + record(&mut records, start(3), |_| true); + assert_eq!(records.last(), Some(&start(3)), "a trip without a record gets the start record"); + } + #[test] fn a_write_drops_the_records_of_deleted_trips_first() { let mut records = Records::new(); diff --git a/firmware/obc-storage/src/flat/metadata.rs b/firmware/obc-storage/src/flat/metadata.rs index 8e65edfd3..cb6f8d8c4 100644 --- a/firmware/obc-storage/src/flat/metadata.rs +++ b/firmware/obc-storage/src/flat/metadata.rs @@ -644,17 +644,15 @@ pub fn write_checkpoint( /// Write one trip progress record by the bound rules of /// [`record`](obc_formats::trip_progress::record); `stored` says whether a stored trip holds a key. -/// A record with Revision 0 takes the current Revision of its day route, so a later replace voids -/// its metres. A record that carries a Revision keeps it: its metres belong to that Revision. +/// The record takes the current Revision of its day route, so a later replace voids its metres. A +/// start record that finds its key's record moves that record as stored. #[inline(never)] pub fn write_progress( store: &FlatStore, mut new: TripProgress, stored: impl Fn(u64) -> bool, ) -> Result<(), Error> { - if new.day_route.revision == 0 { - new.day_route.revision = route_revision(store, new.day_route.id)?.unwrap_or(0); - } + new.day_route.revision = route_revision(store, new.day_route.id)?.unwrap_or(0); let mut bytes = [0; MAX_LEN]; let mut owner = Metadata::new(store); let mut image = owner.load(store, &mut bytes)?; diff --git a/firmware/obc-storage/src/flat/metadata/tests.rs b/firmware/obc-storage/src/flat/metadata/tests.rs index 2720a73b0..e1333519d 100644 --- a/firmware/obc-storage/src/flat/metadata/tests.rs +++ b/firmware/obc-storage/src/flat/metadata/tests.rs @@ -579,30 +579,29 @@ fn progress_records_survive_row_and_checkpoint_edits_and_a_remount() { let store = FlatStore::initialize(&disk, CARD).unwrap(); let route = publish(&store, ObjectKind::Route, b"route"); let ride = publish(&store, ObjectKind::Ride, b"ride"); - let at_route = - |key, revision| TripProgress { day_route: RouteVersion { id: route.id.0, revision }, ..progress(key) }; + let at_route = |key| TripProgress { day_route: RouteVersion { id: route.id.0, revision: 9 }, ..progress(key) }; + write_progress(&store, at_route(1), |_| true).unwrap(); write_progress(&store, progress(3), |_| true).unwrap(); - write_progress(&store, at_route(1, 0), |_| true).unwrap(); - write_progress(&store, at_route(5, 9), |_| true).unwrap(); + let start = TripProgress { day: 0, metres: 0, last_finished: None, ..at_route(1) }; + write_progress(&store, start, |_| true).unwrap(); write_proof(&store, ride).unwrap(); write_checkpoint(&store, CARD, store.sequence(), None, Some(checkpoint(route, None))).unwrap(); disk.reboot(); let store = FlatStore::mount(&disk); let mut read = Vec::new(); read_progress(&store, |p| read.push(p)).unwrap(); - let stamped = at_route(1, 1); + let stamped = TripProgress { day_route: RouteVersion { id: route.id.0, revision: 1 }, ..progress(1) }; let gone = TripProgress { metres: 0, day_route: RouteVersion { id: 7, revision: 0 }, ..progress(3) }; - let kept = TripProgress { metres: 0, ..at_route(5, 9) }; assert_eq!( read, - [gone.clone(), stamped.clone(), kept], - "write order stays; a record without a Revision takes its route's, and one with a Revision keeps it" + [gone.clone(), stamped.clone()], + "a record takes its route's Revision; a start moves the stored record as it is" ); let mut bytes = [0; MAX_LEN]; let mut image = Metadata::new(&store).load(&store, &mut bytes).unwrap(); assert_eq!(image.rows().count(), 1); image.set_checkpoint(None).unwrap(); - assert_eq!(image.progress().map(|p| p.key).collect::>(), [3, 1, 5]); + assert_eq!(image.progress().map(|p| p.key).collect::>(), [3, 1]); assert_eq!(image.set_progress(&[progress(3), progress(3)]), Err(Error::Invalid), "one record per key"); let len = image.bytes().len(); diff --git a/specs/obc-ble-interface-spec.md b/specs/obc-ble-interface-spec.md index da715fcc8..533480e7f 100644 --- a/specs/obc-ble-interface-spec.md +++ b/specs/obc-ble-interface-spec.md @@ -317,14 +317,23 @@ any other length, which also rejects a torn write. The device keeps one progress record per trip key. The record never crosses the wire. The device writes it at Finish of a ride on a trip day, and keeps it in the ride-archive Metadata object -([`Ride_Archive_Metadata.md`](Ride_Archive_Metadata.md)) with the navigator checkpoint. The start -of a ride on a trip day writes the record of its trip again, unchanged. A trip without a record -gets a record without a position, a finished day or dates. +([`Ride_Archive_Metadata.md`](Ride_Archive_Metadata.md)) with the navigator checkpoint. + +- **Start record.** The fresh start of a ride on a trip day, and the continuation of that ride + after a reset, write a start record for the trip: position day 0, the route ObjectId of day 0, + 0 m, no last finished day, and all finish dates 0. The device writes no start record when the + trip's record is already the last record. +- **A start record never replaces a record.** When the Metadata object holds a record for the + trip key, the write moves that record to the end, byte for byte. Its Revision stays, so its + metres read as 0 when its route has another Revision now. Only when the object holds no record + for the key does the start record go in, with the Revision the store holds for the route of + day 0. The store tells a start record by its missing last finished day; a Finish always writes + one. The store applies this rule, so a start is safe before the device has read the records. | Field | Meaning | | :-- | :-- | | position day | the day that contains the last matched position | -| position route | the route ObjectId and Revision of that day when the Finish wrote the position | +| position route | the route ObjectId and Revision of that day when the record was written | | position metres | metres into that day's route | | last finished day | the last day the rider finished; none before the first Finish | | finish dates | for each day, the date of its Finish in days since 1970-01-01; 0 = none | @@ -350,8 +359,8 @@ Days count from 0 in the object; the rider sees Day 1 for day 0. rider instead rides 20 km into Day 3, Day 3 is next with 20 km less to ride. A ride on the rest of Day 2 plus Day 3 that ends before it joins Day 3 finishes Day 2, not Day 3. The position stays on Day 2, so Day 3 is still next. -- **Active trip.** The trip of the latest record, while it has a next day. The start of a ride on a - trip day moves the record of its trip to the end, so that trip is active before its Finish. +- **Active trip.** The trip of the latest record, while it has a next day. A start record moves the + trip's record to the end, so the trip of a started ride is active before its Finish. - **Ticks.** A day is ticked when it is at or before the last finished day, or when it is before the position day. - **Day dates.** Dates follow the rides. For day `k`, take the latest day `j ≤ k` with a finish From cf482a315afc80e57281b62ea701f5c39fb2193e Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 23:38:47 +0200 Subject: [PATCH 3/5] feat(trips): a Finish after Keep riding moves into the next day A Finish after the rider arrived at the end of the loaded route and rode on past it owes, beside its record, the last fix and the next day. The executor projects the fix onto the next day's route once when it writes the record: within 50 m, the position moves to the nearest point of that route, in the next day. obc-route's `nearest_along` walks the route through one small block, so the board needs no second route index. Co-Authored-By: Claude Opus 5.5 --- firmware/obc-app/src/app.rs | 21 ++++++++- firmware/obc-app/src/metadata.rs | 19 +++++++- firmware/obc-app/src/navigator/following.rs | 3 ++ firmware/obc-app/src/trip.rs | 51 +++++++++++++++++++++ firmware/obc-fw-nrf54l/src/flat_store.rs | 17 +++++-- firmware/obc-fw-nrf54l/src/ride.rs | 1 + firmware/obc-route/src/lib.rs | 6 +-- firmware/obc-route/src/reader.rs | 41 +++++++++++++++++ firmware/obc-route/tests/cases/matcher.rs | 23 ++++++++++ host/obc-host-core/src/dispatch.rs | 9 +++- specs/obc-ble-interface-spec.md | 4 ++ 11 files changed, 184 insertions(+), 11 deletions(-) diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 414e2d76f..c591b9ce8 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1154,6 +1154,15 @@ impl App { self.metadata.progress_payload(token) } + /// The ride on past the end of its day of the record that `token` writes: the executor settles + /// the record with [`RodeOn::settle`](crate::trip::RodeOn::settle) before it writes it. + pub fn trip_progress_rode_on( + &self, + token: crate::device_core::OperationToken, + ) -> Option { + self.metadata.progress_rode_on(token) + } + /// A ride on a trip day makes that trip the active one: its start record moves the trip's record /// to the end of the records, in the store too, so the start card follows it after a power /// cycle. The store moves its own copy, so records the device has not read yet are safe. @@ -1182,6 +1191,8 @@ impl App { let active_index = end.map_or(self.active_route_index(), |end| Some(end.route)); let progress_m = end.map_or(self.progress_m(), |end| end.progress_m); let arrived = end.map_or(self.navigator.route_state().arrival.arrived(), |end| end.arrived); + let rode_on = + end.map_or(self.navigator.route_state().arrival == crate::navigator::Arrival::RodeOn, |end| end.rode_on); let active = active_index.and_then(|i| self.route_ids().get(i).copied()); // After a reset the adopted lead-in is gone, but a built day is still the internal route // the record and the line facts describe: its rest starts where the record stands. @@ -1226,8 +1237,16 @@ impl App { // can have moved past the day the ride started on. let finished = if arrived { at.day } else { day }; let record = trip.finish(old, finished, at, today); + // Past the end of its day the ride may be on the next day's line; the executor reads that + // route and projects the fix onto it once. + let rode_on = rode_on.then_some(()).and_then(|()| { + let fix = self.state.user_fix?; + let day = at.day.checked_add(1)?; + let &route = trip.stage_ids.get(usize::from(day))?; + Some(crate::trip::RodeOn { fix: (fix.lon, fix.lat), day, route }) + }); let trips = self.catalogs.trips(); - self.metadata.owe_progress(record, |key| trips.iter().any(|t| t.key == key)); + self.metadata.owe_progress(record, rode_on, |key| trips.iter().any(|t| t.key == key)); } /// The card after the save of a ride on a trip day: DAY N DONE, or TRIP DONE after the last diff --git a/firmware/obc-app/src/metadata.rs b/firmware/obc-app/src/metadata.rs index 4703f3afa..34d3103a8 100644 --- a/firmware/obc-app/src/metadata.rs +++ b/firmware/obc-app/src/metadata.rs @@ -64,6 +64,8 @@ pub(crate) struct MetadataMachine { progress: Records, /// A Finish's record that the store does not hold yet. progress_owed: Option, + /// The owed Finish rode on past the end of its day. + rode_on: Option, /// The write in flight is the owed record. writing_progress: bool, /// Where the active trip's next day meets the day before, from the last catalog read. @@ -80,6 +82,7 @@ impl MetadataMachine { blocked: false, progress: Records::new(), progress_owed: None, + rode_on: None, writing_progress: false, day_join: None, replaced: heapless::Vec::new(), @@ -105,6 +108,10 @@ impl MetadataMachine { pub(crate) fn progress_payload(&self, token: OperationToken) -> Option<&TripProgress> { self.progress_owed.as_ref().filter(|_| self.writing_progress && self.ops.is_current(token)) } + /// The owed record's ride on past the end of its day, while `token` is the write in flight. + pub(crate) fn progress_rode_on(&self, token: OperationToken) -> Option { + self.progress_payload(token).and(self.rode_on) + } pub(crate) fn apply_outcome(&mut self, outcome: MetadataOutcome) -> bool { if !self.inflight || !self.ops.is_current(outcome.token()) { return false; @@ -116,6 +123,7 @@ impl MetadataMachine { ); if core::mem::take(&mut self.writing_progress) && !retry { self.progress_owed = None; + self.rode_on = None; } self.ops.invalidate(); self.inflight = false; @@ -123,14 +131,20 @@ impl MetadataMachine { true } /// A Finish's record. The resident records take it at once; the store takes it when it can. - pub(crate) fn owe_progress(&mut self, record: TripProgress, stored: impl Fn(u64) -> bool) { + pub(crate) fn owe_progress( + &mut self, + record: TripProgress, + rode_on: Option, + stored: impl Fn(u64) -> bool, + ) { obc_formats::trip_progress::record(&mut self.progress, record.clone(), stored); self.progress_owed = Some(record); + self.rode_on = rode_on; } /// A start's record. It never displaces a Finish's record that the store does not hold yet. pub(crate) fn owe_start(&mut self, record: TripProgress, stored: impl Fn(u64) -> bool) { if self.progress_owed.is_none() { - self.owe_progress(record, stored); + self.owe_progress(record, None, stored); } } pub(crate) fn progress(&self) -> &[TripProgress] { @@ -177,6 +191,7 @@ impl MetadataMachine { && !self.blocked && self.progress.is_empty() && self.progress_owed.is_none() + && self.rode_on.is_none() && !self.writing_progress && self.day_join.is_none() && self.replaced.is_empty() diff --git a/firmware/obc-app/src/navigator/following.rs b/firmware/obc-app/src/navigator/following.rs index d8379aa6b..faff9e132 100644 --- a/firmware/obc-app/src/navigator/following.rs +++ b/firmware/obc-app/src/navigator/following.rs @@ -31,6 +31,8 @@ pub(crate) struct RideEnd { pub(crate) progress_m: u32, /// The rider had arrived at the route's end. pub(crate) arrived: bool, + /// The rider rode on past the route's end after arriving. + pub(crate) rode_on: bool, } /// A seam re-anchor waiting for the next tick with matching route geometry. @@ -243,6 +245,7 @@ impl NavigatorMachine { route, progress_m: self.following.progress_m, arrived: self.following.arrival.arrived(), + rode_on: self.following.arrival == Arrival::RodeOn, }); } diff --git a/firmware/obc-app/src/trip.rs b/firmware/obc-app/src/trip.rs index b4ef6ef1d..de04662ad 100644 --- a/firmware/obc-app/src/trip.rs +++ b/firmware/obc-app/src/trip.rs @@ -212,6 +212,42 @@ pub fn gap_m(end: (i32, i32), start: (i32, i32)) -> u32 { m + u32::from((m as f32) < d) } +/// A fix at most this far from the next day's route puts a ride that rode on past the end of its +/// day onto the next day (spec §7.7). +pub const RODE_ON_MATCH_M: f32 = 50.0; + +/// A Finish after the rider rode on past the end of the loaded day: the last fix, `(lon, lat)` +/// µdeg, and the next day with its route. The executor projects the fix onto that route once, +/// when it writes the Finish's record. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RodeOn { + pub fix: (i32, i32), + pub day: u16, + pub route: CatalogObjectId, +} + +impl RodeOn { + /// The Finish's record once the fix is projected. `nearest` is `(metres along, metres away)` on + /// the next day's route. Within [`RODE_ON_MATCH_M`] the position moves into the next day, and + /// the finished day stays. + pub fn apply(self, record: TripProgress, nearest: Option<(u32, f32)>) -> TripProgress { + match nearest { + Some((metres, away)) if away <= RODE_ON_MATCH_M => TripProgress { + day: self.day, + day_route: RouteVersion { id: self.route, revision: 0 }, + metres, + ..record + }, + _ => record, + } + } + + /// [`apply`](Self::apply) with the projection read from `next`, the next day's route bytes. + pub fn settle(self, record: TripProgress, next: &dyn obc_formats::io::ByteSource) -> TripProgress { + self.apply(record, obc_route::nearest_along(next, self.fix).ok().flatten()) + } +} + /// A position on a trip: a day, that day's route, and metres into it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TripPosition { @@ -574,6 +610,21 @@ mod tests { assert_eq!(t.next_day(Some(&short)), Some(2)); } + #[test] + fn a_ride_that_rode_on_moves_into_the_next_day_when_the_fix_is_on_its_route() { + let t = trip(0); + // Day 2 ridden to its end, then 20 km on along Day 3 before the Finish. + let end = t.finish(None, 1, TripPosition { day: 1, route: 20, metres: 74_000 }, MON); + let rode_on = RodeOn { fix: (8_000_000, 46_000_000), day: 2, route: 30 }; + let on = rode_on.apply(end.clone(), Some((20_000, RODE_ON_MATCH_M))); + assert_eq!((on.day, on.day_route.id, on.metres, on.last_finished), (2, 30, 20_000, Some(1))); + assert_eq!((t.next_day(Some(&on)), t.position_m(Some(&on))), (Some(2), 20_000)); + assert_eq!(on.dates, end.dates); + // Off Day 3's line, or no line to read: the position stays at the end of Day 2. + assert_eq!(rode_on.apply(end.clone(), Some((20_000, RODE_ON_MATCH_M + 1.0))), end); + assert_eq!(rode_on.apply(end.clone(), None), end); + } + #[test] fn progress_past_a_shorter_reupload_is_dropped_not_done() { // Recorded on a five-day version: in Day 5, Day 4 finished. The trip now has three days. diff --git a/firmware/obc-fw-nrf54l/src/flat_store.rs b/firmware/obc-fw-nrf54l/src/flat_store.rs index 4f55c8d7c..32db0f8a4 100644 --- a/firmware/obc-fw-nrf54l/src/flat_store.rs +++ b/firmware/obc-fw-nrf54l/src/flat_store.rs @@ -389,6 +389,7 @@ pub(crate) enum Request { WriteProgress { record: obc_app::trip::TripProgress, keys: heapless::Vec, + rode_on: Option, }, CleanupRoute { before_utc: u32, @@ -997,10 +998,18 @@ fn serve( ) .map_err(metadata_error), )), - Request::WriteProgress { record, keys } => Ok(Outcome::Metadata( - obc_storage::flat::metadata::write_progress(store, record, |key| keys.contains(&key)) - .map_err(metadata_error), - )), + Request::WriteProgress { record, keys, rode_on } => { + let record = match rode_on { + Some(rode_on) => store + .with_source(ObjectId(rode_on.route), None, |source| rode_on.settle(record.clone(), source)) + .unwrap_or(record), + None => record, + }; + Ok(Outcome::Metadata( + obc_storage::flat::metadata::write_progress(store, record, |key| keys.contains(&key)) + .map_err(metadata_error), + )) + } Request::ReconcileMetadata => { Ok(Outcome::Metadata(obc_storage::flat::metadata::reconcile(store).map_err(metadata_error))) } diff --git a/firmware/obc-fw-nrf54l/src/ride.rs b/firmware/obc-fw-nrf54l/src/ride.rs index c8c9412d9..7170aca4a 100644 --- a/firmware/obc-fw-nrf54l/src/ride.rs +++ b/firmware/obc-fw-nrf54l/src/ride.rs @@ -1285,6 +1285,7 @@ pub(crate) async fn run_app( let request = crate::flat_store::Request::WriteProgress { record: record.clone(), keys: app.trips().iter().map(|trip| trip.key).collect(), + rode_on: app.trip_progress_rode_on(token), }; match metadata_call(request).await { Ok(()) => MetadataOutcome::ProgressWritten { token }, diff --git a/firmware/obc-route/src/lib.rs b/firmware/obc-route/src/lib.rs index 2d1319a6f..741e5e787 100644 --- a/firmware/obc-route/src/lib.rs +++ b/firmware/obc-route/src/lib.rs @@ -60,9 +60,9 @@ pub use nav::{plan_detour, plan_route, NavError, NavPhase, NavPlanner, NavScratc pub use obc_elevation::{ElevationSource, NullElevation}; pub use profile::{elevation_sparkline, ride_track_into, DayProfile, Profile, Window, PROFILE_COLS, SPARKLINE_BUCKETS}; pub use reader::{ - for_each_waypoint, route_end, ChunkMeta, RouteCache, RouteIndex, RouteObjectInfo, RoutePoint, RoutePosition, - RouteReader, RouteSummary, Waypoint, WaypointCursor, Waypoints, WptEntry, MAX_POINTS_PER_CHUNK, MAX_ROUTE_CHUNKS, - MAX_WAYPOINTS, + for_each_waypoint, nearest_along, route_end, ChunkMeta, RouteCache, RouteIndex, RouteObjectInfo, RoutePoint, + RoutePosition, RouteReader, RouteSummary, Waypoint, WaypointCursor, Waypoints, WptEntry, MAX_POINTS_PER_CHUNK, + MAX_ROUTE_CHUNKS, MAX_WAYPOINTS, }; pub use ride::{encode_summary_footer, RideInfo, RideStats, RideTrackFacts, POWER_STEP_W, RIDE_SERIES_BUCKETS}; pub use splice::{original_name, splice_detour, Leg, SpliceStep, Splicer}; diff --git a/firmware/obc-route/src/reader.rs b/firmware/obc-route/src/reader.rs index 8f50ff1f0..518ad25e4 100644 --- a/firmware/obc-route/src/reader.rs +++ b/firmware/obc-route/src/reader.rs @@ -205,6 +205,47 @@ pub fn route_end(src: &dyn ByteSource) -> Result<(i32, i32), Error> { Ok((lon, lat)) } +/// The route point nearest `p`, `(lon, lat)` µdeg, as `(metres along, metres away)`. It walks every +/// chunk through one small block, so it needs no [`RouteIndex`]. The earliest of equal distances +/// wins. `None` for a route without a segment. +pub fn nearest_along(src: &dyn ByteSource, p: (i32, i32)) -> Result, Error> { + use crate::geo::project_to_segment; + use obc_map_scene::{cos_lat, ground_dist_m_cl}; + let h = read_header(src)?; + let mut best: Option<(u32, f32)> = None; + const BLOCK: usize = 16 * POINT_RECORD_LEN; + let mut block = [0u8; BLOCK]; + for c in 0..h.chunk_count { + let off = c + .checked_mul(CHUNK_META_LEN as u32) + .and_then(|rel| h.index_offset.checked_add(rel)) + .ok_or(Error::BadOffset)?; + let mut meta = [0u8; CHUNK_META_LEN]; + src.read_at(off.into(), &mut meta)?; + let cm = parse_chunk_meta(&meta, src.len())?; + let cl = cos_lat(cm.anchor_lat); + let mut a = (cm.anchor_lon, cm.anchor_lat); + let mut along = cm.cum_distance_m as f32; + let (mut at, end) = (u64::from(cm.byte_offset), u64::from(cm.byte_offset) + u64::from(cm.byte_len)); + while at < end { + let bytes = &mut block[..(end - at).min(BLOCK as u64) as usize]; + src.read_at(at, bytes)?; + decode_records(a, bytes, |point| { + let b = (point.lon, point.lat); + let seg = ground_dist_m_cl(a, b, cl); + let (t, dist) = project_to_segment(a, b, p, cl); + if best.is_none_or(|(_, nearest)| dist < nearest) { + best = Some(((along + t * seg) as u32, dist)); + } + along += seg; + a = b; + })?; + at += bytes.len() as u64; + } + } + Ok(best) +} + /// The resident, source-independent parse of a route: the header fields plus the chunk index and /// its segment prefix sums. [`read`](Self::read) pays the route's only up-front cost, the header /// read and the full chunk-meta walk. diff --git a/firmware/obc-route/tests/cases/matcher.rs b/firmware/obc-route/tests/cases/matcher.rs index a9e10990e..db4cf27a7 100644 --- a/firmware/obc-route/tests/cases/matcher.rs +++ b/firmware/obc-route/tests/cases/matcher.rs @@ -764,3 +764,26 @@ fn recovery_scans_the_phase_and_refuses_repeated_occurrences() { let matched = matcher.recover(0, 100_000, &route, 0, route.total_distance_m).unwrap(); assert!(matched.progress_m > 10_000, "recovery is not limited to the live forward segment window"); } + +/// The index-free scan finds the point the join scan finds, across chunk seams. +#[test] +fn nearest_along_agrees_with_the_join_scan() { + let pts: Vec<(f64, f64, f64)> = (0..1_200) + .map(|i| { + let x = i as f64; + (48.0 + 0.0004 * (x / 2.5).sin(), 7.8 + x * 0.0003, 200.0) + }) + .collect(); + let bytes = convert("Wiggle", &gpx_from(&pts)); + let src = SliceSource(&bytes); + let ridx = RouteIndex::read(&src).unwrap(); + let r = RouteReader::new(&ridx, &src); + assert!(r.chunks().len() > 1, "the route spans chunk seams"); + for p in decode_all(&r).iter().step_by(11) { + let fix = (p.lon + 150, p.lat + north_ud(20.0)); + let scan = RouteMatch::nearest(fix.0, fix.1, &r, 0.0).unwrap(); + let (along, away) = obc_route::nearest_along(&src, fix).unwrap().unwrap(); + assert!(along.abs_diff(scan.progress_m) <= 1, "{along} m against {} m", scan.progress_m); + assert_eq!(away as u32, scan.dist_m); + } +} diff --git a/host/obc-host-core/src/dispatch.rs b/host/obc-host-core/src/dispatch.rs index 30eaf806d..a712a38b5 100644 --- a/host/obc-host-core/src/dispatch.rs +++ b/host/obc-host-core/src/dispatch.rs @@ -516,7 +516,14 @@ impl HostLoop { let outcome = match app.trip_progress_payload(token) { Some(record) if scope.is_some() && scope.map(|s| s.store) == routes.store_scope().map(|s| s.store) => { let keys: Vec = app.trips().iter().map(|t| t.key).collect(); - match trips.write_progress(record.clone(), &keys) { + let record = match app.trip_progress_rode_on(token) { + Some(rode_on) => rode_on.settle( + record.clone(), + &obc_formats::io::SliceSource(&routes.route_bytes(rode_on.route).unwrap_or_default()), + ), + None => record.clone(), + }; + match trips.write_progress(record, &keys) { Ok(()) => MetadataOutcome::ProgressWritten { token }, Err(error) => MetadataOutcome::Failed { token, error }, } diff --git a/specs/obc-ble-interface-spec.md b/specs/obc-ble-interface-spec.md index 533480e7f..d7e22de93 100644 --- a/specs/obc-ble-interface-spec.md +++ b/specs/obc-ble-interface-spec.md @@ -359,6 +359,10 @@ Days count from 0 in the object; the rider sees Day 1 for day 0. rider instead rides 20 km into Day 3, Day 3 is next with 20 km less to ride. A ride on the rest of Day 2 plus Day 3 that ends before it joins Day 3 finishes Day 2, not Day 3. The position stays on Day 2, so Day 3 is still next. +- **Rode on.** A Finish after the rider arrived at the end of the loaded route and rode on past + it writes the position at that end, unless the last fix lies within 50 m of the route of the + next day. Then the position is the point of that route nearest the fix, in the next day. The + device projects that one fix once, when it writes the record. - **Active trip.** The trip of the latest record, while it has a next day. A start record moves the trip's record to the end, so the trip of a started ride is active before its Finish. - **Ticks.** A day is ticked when it is at or before the last finished day, or when it is before From 5fb5eb1e994c472f4bc896d964c09419c6d1e7dc Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 23:48:12 +0200 Subject: [PATCH 4/5] fix(trips): keep a Finish owed during a start write; tie the projection A Finish owed while a start's write is in flight is no longer cleared by that write's answer: owing a record ends the in-flight write's claim on it. `nearest_along` keeps the earliest point unless a later one is more than the matcher's first-lock tie nearer, so a fix beside an out-and-back day lands on its outbound leg. Co-Authored-By: Claude Opus 5.5 --- firmware/obc-app/src/metadata.rs | 21 +++++++++++++++++++++ firmware/obc-route/src/matcher.rs | 2 +- firmware/obc-route/src/reader.rs | 7 ++++--- firmware/obc-route/tests/cases/matcher.rs | 22 ++++++++++++++++++++-- specs/obc-ble-interface-spec.md | 6 ++++-- 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/firmware/obc-app/src/metadata.rs b/firmware/obc-app/src/metadata.rs index 34d3103a8..add23a5bc 100644 --- a/firmware/obc-app/src/metadata.rs +++ b/firmware/obc-app/src/metadata.rs @@ -140,6 +140,8 @@ impl MetadataMachine { obc_formats::trip_progress::record(&mut self.progress, record.clone(), stored); self.progress_owed = Some(record); self.rode_on = rode_on; + // A write in flight carries the record before this one, so its answer must not clear this. + self.writing_progress = false; } /// A start's record. It never displaces a Finish's record that the store does not hold yet. pub(crate) fn owe_start(&mut self, record: TripProgress, stored: impl Fn(u64) -> bool) { @@ -221,4 +223,23 @@ mod tests { assert!(machine.apply_outcome(MetadataOutcome::Cancelled { token: retry })); assert!(machine.next_checkpoint_effect().is_some()); } + + #[test] + fn a_finish_owed_while_a_start_write_is_in_flight_is_written_next() { + let record = |key, last_finished| TripProgress { + key, + day: 0, + day_route: obc_formats::trip_progress::RouteVersion { id: 7, revision: 0 }, + metres: 0, + last_finished, + dates: [0; obc_formats::trip_progress::MAX_DAYS], + }; + let mut machine = MetadataMachine::new(); + machine.owe_start(record(1, None), |_| true); + let start = machine.next_progress_effect().unwrap().token(); + machine.owe_progress(record(1, Some(0)), None, |_| true); + assert!(machine.apply_outcome(MetadataOutcome::ProgressWritten { token: start })); + let finish = machine.next_progress_effect().expect("the Finish is still owed").token(); + assert_eq!(machine.progress_payload(finish), Some(&record(1, Some(0)))); + } } diff --git a/firmware/obc-route/src/matcher.rs b/firmware/obc-route/src/matcher.rs index 13ae74653..56b7e4ed7 100644 --- a/firmware/obc-route/src/matcher.rs +++ b/firmware/obc-route/src/matcher.rs @@ -21,7 +21,7 @@ const WINDOW_SEGS_ON: i64 = 64; const WINDOW_SEGS_OFF: i64 = 320; /// GPS tolerance (m) for continuity and earliest-occurrence ties on first lock. This keeps a small /// cross-track offset from selecting the finish of an out-and-back instead of its outbound leg. -const TIE_EPS_M: f32 = 8.0; +pub(crate) const TIE_EPS_M: f32 = 8.0; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Match { diff --git a/firmware/obc-route/src/reader.rs b/firmware/obc-route/src/reader.rs index 518ad25e4..81b2e3f69 100644 --- a/firmware/obc-route/src/reader.rs +++ b/firmware/obc-route/src/reader.rs @@ -206,8 +206,9 @@ pub fn route_end(src: &dyn ByteSource) -> Result<(i32, i32), Error> { } /// The route point nearest `p`, `(lon, lat)` µdeg, as `(metres along, metres away)`. It walks every -/// chunk through one small block, so it needs no [`RouteIndex`]. The earliest of equal distances -/// wins. `None` for a route without a segment. +/// chunk through one small block, so it needs no [`RouteIndex`]. As on the live first lock, a later +/// point replaces the kept one only when it is more than the matcher's tie nearer, so the outbound +/// leg of an out-and-back wins. `None` for a route without a segment. pub fn nearest_along(src: &dyn ByteSource, p: (i32, i32)) -> Result, Error> { use crate::geo::project_to_segment; use obc_map_scene::{cos_lat, ground_dist_m_cl}; @@ -234,7 +235,7 @@ pub fn nearest_along(src: &dyn ByteSource, p: (i32, i32)) -> Result 10_000, "recovery is not limited to the live forward segment window"); } -/// The index-free scan finds the point the join scan finds, across chunk seams. +/// The index-free scan finds the point the live first lock finds, across chunk seams, and keeps the +/// outbound leg of an out-and-back whose return leg is less than the 8 m tie nearer. #[test] fn nearest_along_agrees_with_the_join_scan() { let pts: Vec<(f64, f64, f64)> = (0..1_200) @@ -781,9 +782,26 @@ fn nearest_along_agrees_with_the_join_scan() { assert!(r.chunks().len() > 1, "the route spans chunk seams"); for p in decode_all(&r).iter().step_by(11) { let fix = (p.lon + 150, p.lat + north_ud(20.0)); - let scan = RouteMatch::nearest(fix.0, fix.1, &r, 0.0).unwrap(); + let scan = RouteMatch::nearest(fix.0, fix.1, &r, 8.0).unwrap(); let (along, away) = obc_route::nearest_along(&src, fix).unwrap().unwrap(); assert!(along.abs_diff(scan.progress_m) <= 1, "{along} m against {} m", scan.progress_m); assert_eq!(away as u32, scan.dist_m); } + + // Out along the equator of the fixture and back 5.6 m north of it; the fix is 3 m north of the + // return leg, halfway along. + let bytes = convert( + "OutBack", + &gpx_from(&[ + (48.00000, 7.8000, 200.0), + (48.00000, 7.8300, 200.0), + (48.00005, 7.8300, 200.0), + (48.00005, 7.8000, 200.0), + ]), + ); + let src = SliceSource(&bytes); + let total = RouteIndex::read(&src).unwrap().total_distance_m; + let (along, away) = obc_route::nearest_along(&src, (7_815_000, 48_000_050 + north_ud(3.0))).unwrap().unwrap(); + assert!(along < total / 2, "the outbound leg wins, got {along} of {total} m"); + assert!(away > 8.0, "{away} m to the outbound leg"); } diff --git a/specs/obc-ble-interface-spec.md b/specs/obc-ble-interface-spec.md index d7e22de93..c78fe3c7c 100644 --- a/specs/obc-ble-interface-spec.md +++ b/specs/obc-ble-interface-spec.md @@ -361,8 +361,10 @@ Days count from 0 in the object; the rider sees Day 1 for day 0. Day 3. The position stays on Day 2, so Day 3 is still next. - **Rode on.** A Finish after the rider arrived at the end of the loaded route and rode on past it writes the position at that end, unless the last fix lies within 50 m of the route of the - next day. Then the position is the point of that route nearest the fix, in the next day. The - device projects that one fix once, when it writes the record. + next day. Then the position is the point of that route nearest the fix, in the next day. A later + point of the route counts as nearer only when it is more than 8 m nearer than an earlier one, so + the outbound leg of an out-and-back wins. The device projects that one fix once, when it writes + the record. - **Active trip.** The trip of the latest record, while it has a next day. A start record moves the trip's record to the end, so the trip of a started ride is active before its Finish. - **Ticks.** A day is ticked when it is at or before the last finished day, or when it is before From da301c3d00ead6f6d8ae9f236d2c6af14c4bfd2f Mon Sep 17 00:00:00 2001 From: timohueser Date: Thu, 24 Sep 2026 00:06:35 +0200 Subject: [PATCH 5/5] chore(firmware): re-pin the App allocation for the rode-on Finish The embedded CI job on 5fb5eb1e9 measured App 58,064 B, 32 B above the baseline: the owed Finish keeps the last fix and the next day of a ride that rode on past the end of its day. Co-Authored-By: Claude Opus 5.5 --- firmware/tools/resource_baseline.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/firmware/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index c126cb1cc..71362c9fa 100644 --- a/firmware/tools/resource_baseline.json +++ b/firmware/tools/resource_baseline.json @@ -26,7 +26,7 @@ "compile_time_allocations": { "framebuffer": 76800, "row_diff": 1284, - "app": 58032, + "app": 58064, "map_cache": 37084, "map_tables": 7528, "route_cache": 9260, @@ -88,6 +88,12 @@ "_deep_ride_margin_note": "A durable raw 8,704 B floor leaves 8,192 B effective after the 512 B below MSPLIM reserved for fault handling; it is deliberately not pinned to today's exact margin. The shipping image measured beside the run above has 46,328 B residual, so it clears the 31,340 B peak by 14,988 B raw / 14,476 B effective.", "_deep_ride_high_water_note": "Read off the board's own stackmeter on the nRF54LM20-DK over the VCOM harness, with the grimsel-demo map on the card. One walk reached the peak and a second boot reproduced it exactly. What the walk covered: a route opened from the Route menu and a ride started from it; map redraws under an injected 1 Hz fix; a Find a place search and its return; three planner runs through the debug N line, each released; Peak View generation, a peak article and its photo page, and the return to Peak View; the ride finished and saved; and, across a warm reset taken while recording, the section 7.3 ride recovery, its Continue and a second finish. The peak lands on ride entry; nothing after it goes deeper. The image is the debug-uart one, because the host has to inject the buttons and the fixes, and that image compiles the ride loop differently: it binds the debug sensor sources where the shipping image builds the hub drains as call temporaries, and it never spawns sensor_task. The gated image is therefore not the measured one, so the static frames were compared before this row moved down. On the same two ELFs, resource_guard.py frames --match run_app gives 4,640 B shipping against 4,656 B debug-uart, --match ride gives the same 16,184 B largest frame in both, the largest guarded poll frame is 9,776 B in both, and the largest task body is 4,816 B shipping against 4,808 B. The measured compilation of the ride loop is therefore 16 B DEEPER than the shipping one, and the whole shipping-only sensor path fits in frames of at most 200 B inside its own task. Not covered, and therefore not claimed: the real-sensor paths, because the measured image runs none of them - the GPS, altimeter and compass drains and sensor_task itself; a plan that finds a path (this map answers no-path); an off-route rejoin; an archive receipt; and BLE or USB traffic during the ride.", "_boot_chain_roots_note": "The mount_in_place root is unscoped because toolchains demangle FlatStore differently. The symbol resolver rejects missing or ambiguous roots; a second matching constructor must be resolved with an explicit, portable needle.", + "_trip_rode_on_allocation_measurement": { + "source_commit": "5fb5eb1e9 on claude/rtr-trip-active", + "build": "the embedded CI job's resource report on that head (App 58,064 B)", + "changes": "App +32 B: the Metadata machine keeps, beside a Finish's owed progress record, the last fix and the next day of a ride that rode on past the end of its day, and the navigator's ride end keeps whether the rider rode on. All other named allocations and ceilings are unchanged.", + "verification": "No local board build: the DK is in use and the build host is short of disk. The same CI job passed the resident band (315,192 B linked), the poll frame (9,864 B) and the residual main stack (44,232 B)." + }, "_map_icons_allocation_measurement": { "source_commit": "c4bf8201d", "build": "cargo build --release --locked --features resource-report on aarch64-apple-darwin",