diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 2ff2ec99a..c591b9ce8 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1154,6 +1154,29 @@ 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. + pub(crate) fn note_trip_start(&mut self) { + let Some(day) = self.recorder.ride_stats().trip else { return }; + 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(); + 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; @@ -1168,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. @@ -1212,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/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index 318eb08ee..f4e530b14 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -573,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/metadata.rs b/firmware/obc-app/src/metadata.rs index c49cc50dc..add23a5bc 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,9 +131,23 @@ 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 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) { + if self.progress_owed.is_none() { + self.owe_progress(record, None, stored); + } } pub(crate) fn progress(&self) -> &[TripProgress] { &self.progress @@ -171,6 +193,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() @@ -200,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-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/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 37726b7c2..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 { @@ -293,7 +329,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 +337,20 @@ impl TripSummary { } } + /// 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 /// 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 +456,20 @@ mod tests { assert_eq!(next(&[]), None); } + #[test] + 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]); + 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))); + } + #[test] fn later_days_count_after_the_day_and_skip_a_dangling_one() { let route = |distance_km| RouteSummary { @@ -546,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-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-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-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/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 8f50ff1f0..81b2e3f69 100644 --- a/firmware/obc-route/src/reader.rs +++ b/firmware/obc-route/src/reader.rs @@ -205,6 +205,48 @@ 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`]. 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}; + 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 - crate::matcher::TIE_EPS_M) { + 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..427f38179 100644 --- a/firmware/obc-route/tests/cases/matcher.rs +++ b/firmware/obc-route/tests/cases/matcher.rs @@ -764,3 +764,44 @@ 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 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) + .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, 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/firmware/obc-storage/src/flat/metadata.rs b/firmware/obc-storage/src/flat/metadata.rs index 9ee064418..cb6f8d8c4 100644 --- a/firmware/obc-storage/src/flat/metadata.rs +++ b/firmware/obc-storage/src/flat/metadata.rs @@ -644,7 +644,8 @@ 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. +/// 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, diff --git a/firmware/obc-storage/src/flat/metadata/tests.rs b/firmware/obc-storage/src/flat/metadata/tests.rs index 48e5ce66a..e1333519d 100644 --- a/firmware/obc-storage/src/flat/metadata/tests.rs +++ b/firmware/obc-storage/src/flat/metadata/tests.rs @@ -580,8 +580,10 @@ fn progress_records_survive_row_and_checkpoint_edits_and_a_remount() { 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) }; - write_progress(&store, progress(3), |_| true).unwrap(); write_progress(&store, at_route(1), |_| true).unwrap(); + write_progress(&store, progress(3), |_| 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(); @@ -590,7 +592,11 @@ fn progress_records_survive_row_and_checkpoint_edits_and_a_remount() { read_progress(&store, |p| read.push(p)).unwrap(); 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) }; - assert_eq!(read, [gone.clone(), stamped.clone()], "write order stays; a record takes its route's Revision"); + assert_eq!( + read, + [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); 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", 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 12f8cb030..c78fe3c7c 100644 --- a/specs/obc-ble-interface-spec.md +++ b/specs/obc-ble-interface-spec.md @@ -319,6 +319,17 @@ The device keeps one progress record per trip key. The record never crosses the 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. +- **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 | @@ -348,6 +359,14 @@ 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. 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 the position day. - **Day dates.** Dates follow the rides. For day `k`, take the latest day `j ≤ k` with a finish