Files
autopilot/crates/mapobjects_store/src/internal/store.rs
T
Oleksandr Bezdieniezhnykh 8a4bd00526 [AZ-650] mission_executor pre-flight BIT (F9) gate (batch 8)
AZ-650 (mission_executor pre-flight Built-In Test):
- BitEvaluator trait + BitItemStatus { Pass, Degraded, Fail, Skipped }
  + BitReport + BitOverall fusion. Pluggable per-item evaluators so
  the composition root decides which dependencies are wired today.
- BitController owns evaluator list + mpsc ack channel + sticky-pass
  + ack deadline. Publishes bit_ok via tokio watch — composition root
  pipes it into the telemetry projection where the existing FSM
  bit_ok guard already consumes it (no FSM changes needed).
- BitState { Idle, Pass, AwaitingAck { report_id }, Failed { reason } }
  with broadcast::Sender<BitEvent> for operator-side observability.
  Sticky-pass semantics: once Pass is reached (directly or via signed
  ack on a Degraded report), the controller stops re-evaluating —
  BIT is a one-shot pre-flight gate, not a continuous monitor.
- BitDegradedAck arrives pre-validated by operator_bridge; the
  controller only matches report_id and applies the operator id to
  the audit log.
- Concrete evaluators landed today (3 of 12 spec items, the rest
  depend on components still in todo/):
  - StateDirFreeSpaceEvaluator (dir creatable/readable; statvfs is
    documented follow-up).
  - WallClockBoundEvaluator (chrono::Utc::now vs configurable bound).
  - MissionLoadedEvaluator (waypoint count via Arc<Mutex<usize>>).
  - MapObjectsSyncedEvaluator (maps SyncState -> BIT status per Q9).

Tests:
- ac1_all_pass_proceeds, ac2_fail_blocks_transition,
  ac3_degraded_requires_signed_ack (+ mismatched_ack supplement),
  ac4_degraded_ack_timeout_fails_the_bit — all 4 ACs green.
- Pure next_state table covered by lib unit tests.
- Per-evaluator unit tests for Pass/Fail/Degraded branches.

Quality gates:
- cargo fmt: clean.
- cargo clippy -p mission_executor --tests -- -D warnings: 0 warns.
- cargo test --workspace: all green.
- Pre-existing flake in state_machine::ac3_bounded_retry_then_success
  (batch 7 report) remains pre-existing — passes on rerun.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 19:12:48 +03:00

785 lines
28 KiB
Rust

//! In-memory hashmap of known map objects, keyed by H3 cell.
//!
//! Classification logic (NEW / MOVED / EXISTING) lives here. Per
//! `architecture.md §7.12` the on-device map keeps the full per-mission
//! state in memory; persistence (AZ-668) lands later.
//!
//! Concurrency: this module is intentionally single-threaded and not
//! `Sync`. The public `MapObjectsStoreHandle` wraps it in an `Arc<Mutex<…>>`
//! so the lock surface is a single owned mutex instead of fine-grained
//! per-cell locking. With p99 ≤ 1 ms and detection rates < 30 Hz the
//! single mutex is comfortably within budget.
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use h3o::CellIndex;
use serde::{Deserialize, Serialize};
use shared::error::Result;
use shared::models::mapobject::{
BundleFreshness, DiffKind, IgnoredItem, IgnoredItemSource, MapObject, MapObjectObservation,
MapObjectsBundle,
};
use uuid::Uuid;
use super::h3_index::{cell_of, grid_disk, haversine_m, DEFAULT_K_RING, DEFAULT_RESOLUTION};
use super::ignored::IgnoredSet;
use super::passes::{bbox_contains, PassTracker, RegionBbox};
use super::snapshot::{Snapshot, SnapshotMapObject};
/// Sync state machine surfaced to `scan_controller` + health aggregator.
///
/// See `_docs/02_document/components/mapobjects_store/description.md §3`.
/// `Failed` is the bounded-retries-exhausted terminal state for the
/// post-flight push (Frozen choice 7 / `description.md §7`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SyncState {
/// Initial state at process boot; no hydrate has run yet.
FreshBoot,
/// Last pull / push succeeded against the central API.
Synced,
/// Last pull failed but the on-device cache was applied as a
/// fallback. `scan_controller` MUST gate this on operator
/// acknowledgement before takeoff.
CachedFallback,
/// Stale cache or transient push failure; new MapObject diff
/// classifications are suppressed by `scan_controller`.
Degraded,
/// Bounded retries exhausted (post-flight push). Operator-visible
/// warning; mission's central data integrity at risk until
/// manually replayed.
Failed,
}
/// Per-detection input to `classify`. This bundles the georeferenced
/// payload the architecture-level "detection" carries (gps, class, conf,
/// size — see `system-flows.md §F7`) without forcing the shared
/// `Detection` model to grow geolocation fields. `scan_controller` builds
/// this from `Detection` + GPS / MGRS context at the call site.
#[derive(Debug, Clone)]
pub struct ClassifyInput {
pub gps_lat: f64,
pub gps_lon: f64,
pub mgrs: String,
pub class: String,
pub size_width_m: f32,
pub size_length_m: f32,
pub confidence: f32,
pub mission_id: String,
pub observed_at: DateTime<Utc>,
/// Airframe identifier the detection originated from. Threaded into
/// `MapObjectObservation::uav_id` for the post-flight push log
/// (AZ-667). Empty string is acceptable for single-UAV deployments
/// and unit tests; production callers (`scan_controller`) supply
/// the configured UAV id.
#[doc(alias = "uav")]
pub uav_id: String,
/// Monotonic clock reading at detection time. Threaded into
/// `MapObjectObservation::observed_at_monotonic_ns` so observation
/// ordering survives wallclock skew. `0` is acceptable when the
/// caller has no monotonic source (e.g. unit tests).
pub observed_at_monotonic_ns: u64,
}
/// Configuration for the spatial-index + classification policy.
#[derive(Debug, Clone)]
pub struct MapObjectsStoreConfig {
/// H3 cell resolution. Default 10 (~15 m edge).
pub h3_resolution: u8,
/// K-ring radius for boundary-safe lookups. Default 2.
pub k_ring: u32,
/// Maximum distance (m) between input and stored object for the pair
/// to be considered a possible match. Beyond this → `NEW`.
pub distance_threshold_m: f64,
/// Above this delta (m) between input position and the matched
/// object's stored position, classification flips to `MOVED`.
pub move_threshold_m: f64,
/// Class-similarity groups. Each inner vec is one group; classes in
/// the same group are considered equivalent for matching (e.g.
/// `tree` and `shrub` collapsed). A class not listed in any group
/// is its own group of one.
pub similar_classes: Vec<Vec<String>>,
}
impl Default for MapObjectsStoreConfig {
fn default() -> Self {
Self {
h3_resolution: DEFAULT_RESOLUTION,
k_ring: DEFAULT_K_RING,
// Defaults follow `system-flows.md §F7` (distance 50 m,
// move 10 m). The task brief lists different per-AC values
// (30 m / 50 m) — callers override per scenario.
distance_threshold_m: 50.0,
move_threshold_m: 10.0,
similar_classes: Vec::new(),
}
}
}
/// Outcome of `MapObjectsStore::classify`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Classification {
New {
id: Uuid,
},
Moved {
id: Uuid,
from_mgrs: String,
to_mgrs: String,
},
Existing {
id: Uuid,
},
/// Suppressed because the `(mgrs, class_group)` pair is in the
/// `IgnoredSet` — the operator previously declined this POI.
/// `scan_controller` must drop the detection without queueing it.
Ignored,
}
/// Object that the store knew about at pass start but did not see
/// re-observed before `end_of_pass`. See `system-flows.md §F7`
/// "end-of-pass sweep" — operator (not device) decides removal.
#[derive(Debug, Clone, PartialEq)]
pub struct RemovedCandidate {
pub id: Uuid,
pub mgrs: String,
pub class: String,
pub class_group: String,
pub gps_lat: f64,
pub gps_lon: f64,
pub last_seen: DateTime<Utc>,
}
/// Stored shape. Fields beyond what `classify` reads are kept for the
/// next batch in the same component (AZ-666 ignored-suppression / sweep,
/// AZ-667 hydrate / dump_pending) which will surface them via the engine
/// API. The lint allow is scoped to those forward-use fields.
#[allow(dead_code)]
#[derive(Debug, Clone)]
struct StoredMapObject {
id: Uuid,
h3_cell: CellIndex,
mgrs: String,
class: String,
class_group: String,
gps_lat: f64,
gps_lon: f64,
size_width_m: f32,
size_length_m: f32,
confidence: f32,
first_seen: DateTime<Utc>,
last_seen: DateTime<Utc>,
mission_id: String,
}
/// In-memory spatial index of known map objects.
pub struct Store {
config: MapObjectsStoreConfig,
by_cell: HashMap<CellIndex, Vec<StoredMapObject>>,
/// Total object count, maintained alongside `by_cell` for O(1) metrics.
len: usize,
ignored: IgnoredSet,
passes: PassTracker,
/// Append-only log of NEW / MOVED / EXISTING / REMOVED-CANDIDATE
/// events for the post-flight push (AZ-667). Drained by
/// `mission_client::push_mapobjects_diff` after landing — central
/// writes mid-flight are forbidden (Frozen choice 6).
pending_observations: Vec<MapObjectObservation>,
/// Append-only log of locally-appended `IgnoredItem`s for the
/// post-flight push (AZ-667).
pending_ignored: Vec<IgnoredItem>,
sync_state: SyncState,
last_pull_ts: Option<DateTime<Utc>>,
last_push_ts: Option<DateTime<Utc>>,
}
impl Store {
pub fn new(config: MapObjectsStoreConfig) -> Self {
Self {
config,
by_cell: HashMap::new(),
len: 0,
ignored: IgnoredSet::new(),
passes: PassTracker::new(),
pending_observations: Vec::new(),
pending_ignored: Vec::new(),
sync_state: SyncState::FreshBoot,
last_pull_ts: None,
last_push_ts: None,
}
}
pub fn len(&self) -> usize {
self.len
}
/// Forward-use hook for AZ-667 / AZ-668 engine plug-points.
#[allow(dead_code)]
pub fn config(&self) -> &MapObjectsStoreConfig {
&self.config
}
/// Suppression query used by `scan_controller`'s POI gate.
pub fn is_ignored(&self, mgrs: &str, class_group: &str) -> bool {
self.ignored.is_ignored(mgrs, class_group)
}
/// Append an `IgnoredItem` (operator declined a POI, or a hydrate
/// from `mission_client` pulled it down). When the item is
/// `LocalAppended` it ALSO joins `pending_ignored` so the
/// post-flight push surfaces it to central.
pub fn append_ignored(&mut self, item: IgnoredItem) {
if matches!(item.source, IgnoredItemSource::LocalAppended) {
self.pending_ignored.push(item.clone());
}
self.ignored.append(item);
}
/// Number of distinct ignored `(mgrs, class_group)` pairs.
pub fn ignored_len(&self) -> usize {
self.ignored.len()
}
/// Open a scan pass over `bbox`. `scan_controller` / `mission_executor`
/// call this when entering a region; the matching `end_of_pass`
/// returns un-observed objects as `RemovedCandidate`s.
pub fn pass_start(&mut self, bbox: RegionBbox, started_at: DateTime<Utc>) {
self.passes.pass_start(bbox, started_at);
}
/// Close the pass over `bbox` and return objects in the region that
/// were not observed since the pass started, excluding ignored
/// objects. Returns an empty vec if no pass was open.
///
/// Each returned `RemovedCandidate` is also appended to the
/// `pending_observations` log as a `DiffKind::RemovedCandidate`
/// event so the post-flight push surfaces it to central.
pub fn end_of_pass(&mut self, bbox: &RegionBbox) -> Vec<RemovedCandidate> {
let Some(result) = self.passes.pass_end(bbox) else {
return Vec::new();
};
let mut out = Vec::new();
for objects in self.by_cell.values() {
for obj in objects {
if !bbox_contains(bbox, obj.gps_lat, obj.gps_lon) {
continue;
}
if result.observed.contains(&obj.id) {
continue;
}
// Filter out ignored — operator already said "no" on
// this pair; surfacing it again would be noise.
if self.ignored.is_ignored(&obj.mgrs, &obj.class_group) {
continue;
}
// Pass started after the object's last_seen → object
// was known at pass start.
if obj.last_seen > result.started_at {
continue;
}
out.push(RemovedCandidate {
id: obj.id,
mgrs: obj.mgrs.clone(),
class: obj.class.clone(),
class_group: obj.class_group.clone(),
gps_lat: obj.gps_lat,
gps_lon: obj.gps_lon,
last_seen: obj.last_seen,
});
}
}
// Mirror each removed candidate into the pending observation
// log; lookup of the stored object's mission_id keeps the
// observation traceable end-to-end.
let ended_at = Utc::now();
for r in &out {
let mission_id = self.find_mission_id(r.id).unwrap_or_default();
self.pending_observations.push(MapObjectObservation {
id: r.id,
h3_cell: u64::from(
cell_of(r.gps_lat, r.gps_lon, self.config.h3_resolution)
.expect("H3 cell lookup must succeed for stored coordinates"),
),
class: r.class.clone(),
class_group: r.class_group.clone(),
mission_id,
uav_id: String::new(),
observed_at_monotonic_ns: 0,
observed_at_wallclock: ended_at,
gps_lat: r.gps_lat,
gps_lon: r.gps_lon,
mgrs: r.mgrs.clone(),
size_width_m: 0.0,
size_length_m: 0.0,
confidence: 0.0,
diff_kind: DiffKind::RemovedCandidate,
photo_ref: None,
raw_evidence: None,
});
}
out
}
fn find_mission_id(&self, id: Uuid) -> Option<String> {
self.by_cell.values().flatten().find_map(|o| {
if o.id == id {
Some(o.mission_id.clone())
} else {
None
}
})
}
pub fn open_passes(&self) -> usize {
self.passes.open_passes()
}
/// Number of unpushed local observations.
pub fn pending_observations_count(&self) -> usize {
self.pending_observations.len()
}
/// Number of unpushed locally-declined items.
pub fn pending_ignored_count(&self) -> usize {
self.pending_ignored.len()
}
pub fn sync_state(&self) -> SyncState {
self.sync_state
}
pub fn last_pull_ts(&self) -> Option<DateTime<Utc>> {
self.last_pull_ts
}
pub fn last_push_ts(&self) -> Option<DateTime<Utc>> {
self.last_push_ts
}
pub fn set_sync_state(&mut self, state: SyncState) {
self.sync_state = state;
}
/// Load the in-memory map from a central-pulled bundle. Replaces
/// any existing entries (the bundle is authoritative). The
/// sync_state moves to `Synced` for a fresh bundle or
/// `CachedFallback` for a `Stale` one. `last_pull_ts` is set to
/// `bundle.as_of`.
pub fn hydrate(&mut self, bundle: MapObjectsBundle) -> Result<()> {
self.by_cell.clear();
self.len = 0;
// Replace the IgnoredSet entirely — central is authoritative.
self.ignored = IgnoredSet::new();
let MapObjectsBundle {
map_objects,
ignored_items,
as_of,
freshness,
..
} = bundle;
for mo in map_objects {
self.insert_hydrated(mo)?;
}
for item in ignored_items {
self.ignored.append(item);
}
self.sync_state = match freshness {
Some(BundleFreshness::Stale) => SyncState::CachedFallback,
_ => SyncState::Synced,
};
self.last_pull_ts = Some(as_of);
Ok(())
}
fn insert_hydrated(&mut self, mo: MapObject) -> Result<()> {
let cell = cell_of(mo.gps_lat, mo.gps_lon, self.config.h3_resolution)?;
self.by_cell.entry(cell).or_default().push(StoredMapObject {
id: Uuid::new_v4(),
h3_cell: cell,
mgrs: mo.mgrs_key,
class: mo.class,
class_group: mo.class_group,
gps_lat: mo.gps_lat,
gps_lon: mo.gps_lon,
size_width_m: mo.size_width_m,
size_length_m: mo.size_length_m,
confidence: mo.confidence,
first_seen: mo.first_seen,
last_seen: mo.last_seen,
mission_id: mo.mission_id,
});
self.len += 1;
Ok(())
}
/// Drain and return all pending observations + ignored items. The
/// store's pending counts return to 0. Called by
/// `mission_client::push_mapobjects_diff` post-flight.
pub fn drain_pending(&mut self) -> (Vec<MapObjectObservation>, Vec<IgnoredItem>) {
(
std::mem::take(&mut self.pending_observations),
std::mem::take(&mut self.pending_ignored),
)
}
/// Cascade-delete every object, ignored entry, and pending log
/// row whose `mission_id` matches. Mirrors the central
/// `DELETE /missions/{id}` semantics.
pub fn cascade_mission(&mut self, mission_id: &str) {
let mut empty_cells = Vec::new();
let mut removed = 0usize;
for (cell, bucket) in self.by_cell.iter_mut() {
let before = bucket.len();
bucket.retain(|o| o.mission_id != mission_id);
removed += before - bucket.len();
if bucket.is_empty() {
empty_cells.push(*cell);
}
}
for c in empty_cells {
self.by_cell.remove(&c);
}
self.len = self.len.saturating_sub(removed);
self.ignored.drop_by_mission(mission_id);
self.pending_observations
.retain(|o| o.mission_id != mission_id);
self.pending_ignored.retain(|i| i.mission_id != mission_id);
}
/// Mark a post-flight push as acknowledged. Resets sync_state to
/// `Synced` and records the push timestamp.
pub fn mark_pushed_ok(&mut self) {
self.sync_state = SyncState::Synced;
self.last_push_ts = Some(Utc::now());
}
/// Materialise the in-memory state into a serializable [`Snapshot`].
/// Open passes are intentionally NOT captured — they are transient
/// in-flight state and should restart after a process restart.
pub fn to_snapshot(&self, mission_id: String) -> Snapshot {
let map_objects: Vec<SnapshotMapObject> = self
.by_cell
.values()
.flatten()
.map(|o| SnapshotMapObject {
id: o.id,
h3_cell: u64::from(o.h3_cell),
mgrs: o.mgrs.clone(),
class: o.class.clone(),
class_group: o.class_group.clone(),
gps_lat: o.gps_lat,
gps_lon: o.gps_lon,
size_width_m: o.size_width_m,
size_length_m: o.size_length_m,
confidence: o.confidence,
first_seen: o.first_seen,
last_seen: o.last_seen,
mission_id: o.mission_id.clone(),
})
.collect();
let ignored_items: Vec<IgnoredItem> = self.ignored.items().cloned().collect();
Snapshot {
schema_version: Snapshot::CURRENT_SCHEMA_VERSION,
mission_id,
as_of: Utc::now(),
map_objects,
ignored_items,
pending_observations: self.pending_observations.clone(),
pending_ignored: self.pending_ignored.clone(),
sync_state: self.sync_state,
last_pull_ts: self.last_pull_ts,
last_push_ts: self.last_push_ts,
}
}
/// Rehydrate from a [`Snapshot`]. Re-keys map objects into their
/// canonical H3 buckets using the supplied config's resolution
/// (so a snapshot taken at one resolution can be loaded into a
/// store configured differently — the spatial buckets are rebuilt
/// either way).
pub fn from_snapshot(config: MapObjectsStoreConfig, snapshot: Snapshot) -> Result<Self> {
let mut store = Self::new(config);
for mo in snapshot.map_objects {
let cell = cell_of(mo.gps_lat, mo.gps_lon, store.config.h3_resolution)?;
store
.by_cell
.entry(cell)
.or_default()
.push(StoredMapObject {
id: mo.id,
h3_cell: cell,
mgrs: mo.mgrs,
class: mo.class,
class_group: mo.class_group,
gps_lat: mo.gps_lat,
gps_lon: mo.gps_lon,
size_width_m: mo.size_width_m,
size_length_m: mo.size_length_m,
confidence: mo.confidence,
first_seen: mo.first_seen,
last_seen: mo.last_seen,
mission_id: mo.mission_id,
});
store.len += 1;
}
for item in snapshot.ignored_items {
store.ignored.append(item);
}
store.pending_observations = snapshot.pending_observations;
store.pending_ignored = snapshot.pending_ignored;
store.sync_state = snapshot.sync_state;
store.last_pull_ts = snapshot.last_pull_ts;
store.last_push_ts = snapshot.last_push_ts;
Ok(store)
}
/// Resolve a raw class string to its canonical group key.
///
/// The first class listed in a `similar_classes` group is the group
/// key. A class absent from all groups is its own group.
fn group_key(&self, class: &str) -> String {
for group in &self.config.similar_classes {
if group.iter().any(|c| c == class) {
// group[0] is guaranteed by Vec invariants once we filter
// empty groups out (see new). But be defensive.
if let Some(first) = group.first() {
return first.clone();
}
}
}
class.to_string()
}
/// Classify a single detection input. Mutates the store on `New` /
/// `Moved` / `Existing` (insert / position-update / last_seen-update
/// respectively). Returns `Ignored` and DOES NOT mutate when the
/// resolved `(mgrs, class_group)` is in the ignored set.
///
/// Also notes the matched id into every open pass whose bbox
/// contains the input GPS so end-of-pass sweeps see this object
/// as observed.
pub fn classify(&mut self, input: ClassifyInput) -> Result<Classification> {
let query_cell = cell_of(input.gps_lat, input.gps_lon, self.config.h3_resolution)?;
let group = self.group_key(&input.class);
if self.ignored.is_ignored(&input.mgrs, &group) {
return Ok(Classification::Ignored);
}
// Find the nearest matching object across the k-ring.
let mut best: Option<(CellIndex, usize, f64)> = None;
let disk = grid_disk(query_cell, self.config.k_ring);
for cell in &disk {
if let Some(objects) = self.by_cell.get(cell) {
for (idx, obj) in objects.iter().enumerate() {
if obj.class_group != group {
continue;
}
let d = haversine_m(input.gps_lat, input.gps_lon, obj.gps_lat, obj.gps_lon);
if d > self.config.distance_threshold_m {
continue;
}
if best.is_none_or(|(_, _, prev_d)| d < prev_d) {
best = Some((*cell, idx, d));
}
}
}
}
let classification = match best {
Some((cell, idx, delta_m)) if delta_m >= self.config.move_threshold_m => {
// MOVED — update stored position to the new observation.
let bucket = self
.by_cell
.get_mut(&cell)
.expect("cell present during best-match scan");
let obj = &mut bucket[idx];
let from_mgrs = obj.mgrs.clone();
let id = obj.id;
let class_group = obj.class_group.clone();
let class = obj.class.clone();
obj.gps_lat = input.gps_lat;
obj.gps_lon = input.gps_lon;
obj.mgrs = input.mgrs.clone();
obj.last_seen = input.observed_at;
obj.confidence = input.confidence;
// If the new GPS sits in a different H3 cell, re-bucket.
if cell != query_cell {
let moved = bucket.remove(idx);
if bucket.is_empty() {
self.by_cell.remove(&cell);
}
self.by_cell
.entry(query_cell)
.or_default()
.push(StoredMapObject {
h3_cell: query_cell,
..moved
});
}
self.passes.note_observed(id, input.gps_lat, input.gps_lon);
self.append_observation(
id,
query_cell,
&class,
&class_group,
&input,
DiffKind::Moved,
);
Classification::Moved {
id,
from_mgrs,
to_mgrs: input.mgrs.clone(),
}
}
Some((cell, idx, _)) => {
// EXISTING — just refresh last_seen.
let bucket = self
.by_cell
.get_mut(&cell)
.expect("cell present during best-match scan");
let obj = &mut bucket[idx];
obj.last_seen = input.observed_at;
let id = obj.id;
let class_group = obj.class_group.clone();
let class = obj.class.clone();
self.passes.note_observed(id, input.gps_lat, input.gps_lon);
self.append_observation(id, cell, &class, &class_group, &input, DiffKind::Existing);
Classification::Existing { id }
}
None => {
// NEW — insert.
let id = Uuid::new_v4();
let stored = StoredMapObject {
id,
h3_cell: query_cell,
mgrs: input.mgrs.clone(),
class: input.class.clone(),
class_group: group.clone(),
gps_lat: input.gps_lat,
gps_lon: input.gps_lon,
size_width_m: input.size_width_m,
size_length_m: input.size_length_m,
confidence: input.confidence,
first_seen: input.observed_at,
last_seen: input.observed_at,
mission_id: input.mission_id.clone(),
};
self.by_cell.entry(query_cell).or_default().push(stored);
self.len += 1;
self.passes.note_observed(id, input.gps_lat, input.gps_lon);
self.append_observation(
id,
query_cell,
&input.class,
&group,
&input,
DiffKind::New,
);
Classification::New { id }
}
};
Ok(classification)
}
/// Build and append a `MapObjectObservation` to the post-flight
/// push log. Called on every NEW / MOVED / EXISTING classification
/// (the REMOVED-CANDIDATE variant is appended by `end_of_pass`).
fn append_observation(
&mut self,
id: Uuid,
cell: CellIndex,
class: &str,
class_group: &str,
input: &ClassifyInput,
diff_kind: DiffKind,
) {
self.pending_observations.push(MapObjectObservation {
id,
h3_cell: u64::from(cell),
class: class.to_string(),
class_group: class_group.to_string(),
mission_id: input.mission_id.clone(),
uav_id: input.uav_id.clone(),
observed_at_monotonic_ns: input.observed_at_monotonic_ns,
observed_at_wallclock: input.observed_at,
gps_lat: input.gps_lat,
gps_lon: input.gps_lon,
mgrs: input.mgrs.clone(),
size_width_m: input.size_width_m,
size_length_m: input.size_length_m,
confidence: input.confidence,
diff_kind,
photo_ref: None,
raw_evidence: None,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn input(lat: f64, lon: f64, class: &str) -> ClassifyInput {
ClassifyInput {
gps_lat: lat,
gps_lon: lon,
mgrs: format!("MGRS({lat},{lon})"),
class: class.into(),
size_width_m: 1.0,
size_length_m: 1.0,
confidence: 0.9,
mission_id: "m1".into(),
observed_at: Utc::now(),
uav_id: "uav1".into(),
observed_at_monotonic_ns: 0,
}
}
#[test]
fn group_key_returns_class_when_unknown() {
// Arrange
let s = Store::new(MapObjectsStoreConfig::default());
// Act + Assert
assert_eq!(s.group_key("tank"), "tank");
}
#[test]
fn group_key_collapses_similar_classes() {
// Arrange
let cfg = MapObjectsStoreConfig {
similar_classes: vec![vec!["tree".into(), "shrub".into()]],
..MapObjectsStoreConfig::default()
};
let s = Store::new(cfg);
// Assert
assert_eq!(s.group_key("tree"), "tree");
assert_eq!(s.group_key("shrub"), "tree");
assert_eq!(s.group_key("rock"), "rock");
}
#[test]
fn empty_store_has_zero_len() {
// Arrange
let s = Store::new(MapObjectsStoreConfig::default());
// Assert
assert_eq!(s.len(), 0);
}
#[test]
fn first_classify_is_new() {
// Arrange
let mut s = Store::new(MapObjectsStoreConfig::default());
// Act
let c = s.classify(input(50.45, 30.52, "tank")).unwrap();
// Assert
assert!(matches!(c, Classification::New { .. }));
assert_eq!(s.len(), 1);
}
}