Files
Oleksandr Bezdieniezhnykh e56d428753 [AZ-649] [AZ-674] [AZ-667] telemetry + vlm schema + mapobjects hydrate batch 6
AZ-649 mission_executor telemetry forwarding:
- shared::models::telemetry::UavTelemetry canonical model
- TelemetryForwarder with atomic ArcSwap snapshot + 3 lossy
  tokio::sync::broadcast channels (MissionExecutor, ScanController,
  MavlinkUplink) + per-consumer drop counters
- MavlinkProjection::from_mavlink for HEARTBEAT/GLOBAL_POSITION_INT/
  ATTITUDE/SYS_STATUS
- spawn_mavlink_pump bridges mavlink_layer into the forwarder at the
  binary edge

AZ-674 vlm_client schema validation + model_version tracking:
- AssessmentParser owns schema validation + model-version state
- wire::read_response_raw splits raw bytes from parsing so invalid
  payloads can be logged size-capped
- VlmStatus gains an Inconclusive variant; exhaustive-match test
  guards downstream consumers
- VlmPipelineStatus mirrors the new variant in shared::models::poi

AZ-667 mapobjects_store hydrate + pending logs + cascade:
- SyncState enum aligned with description.md (FreshBoot, Synced,
  CachedFallback, Degraded, Failed)
- Store::hydrate(MapObjectsBundle) replaces in-memory map atomically;
  freshness=Stale -> CachedFallback
- classify() + end_of_pass append MapObjectObservation events to
  pending_observations (New/Moved/Existing/RemovedCandidate)
- apply_decline + LocalAppended ignored items append to pending_ignored
- drain_pending() returns and clears both logs
- cascade_mission(id) purges by_cell + IgnoredSet + pending logs
- Health surface reports sync_state, pending_obs, pending_ign

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 17:40:43 +03:00

274 lines
9.6 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AZ-665 — H3 indexing + k-ring classify acceptance tests.
use chrono::Utc;
use mapobjects_store::{Classification, ClassifyInput, MapObjectsStore, MapObjectsStoreConfig};
/// Approximate metres-per-degree of latitude. Good enough at all
/// latitudes for the small per-test offsets used below (560 m).
const M_PER_DEG_LAT: f64 = 111_320.0;
/// Approximate metres-per-degree of longitude at a given latitude.
fn m_per_deg_lon(lat_deg: f64) -> f64 {
M_PER_DEG_LAT * lat_deg.to_radians().cos()
}
/// Shift a base point north by `dn` metres and east by `de` metres.
/// Sufficiently accurate for the < 100 m offsets in these tests.
fn shift_m(base_lat: f64, base_lon: f64, dn_m: f64, de_m: f64) -> (f64, f64) {
let lat = base_lat + dn_m / M_PER_DEG_LAT;
let lon = base_lon + de_m / m_per_deg_lon(base_lat);
(lat, lon)
}
fn input(lat: f64, lon: f64, class: &str) -> ClassifyInput {
ClassifyInput {
gps_lat: lat,
gps_lon: lon,
mgrs: format!("MGRS({lat:.6},{lon:.6})"),
class: class.into(),
size_width_m: 2.0,
size_length_m: 2.0,
confidence: 0.9,
mission_id: "m-az665".into(),
observed_at: Utc::now(),
uav_id: "uav-az665".into(),
observed_at_monotonic_ns: 0,
}
}
const ANCHOR_LAT: f64 = 50.450_000;
const ANCHOR_LON: f64 = 30.520_000;
// ---------------------------------------------------------------------
// AC-1: New detection at unseen MGRS → Classification::New
// ---------------------------------------------------------------------
#[test]
fn ac1_first_detection_returns_new() {
// Arrange
let h = MapObjectsStore::default().handle();
// Act
let c = h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank")).unwrap();
// Assert
assert!(
matches!(c, Classification::New { .. }),
"expected New, got {c:?}",
);
assert_eq!(h.len().unwrap(), 1);
}
// ---------------------------------------------------------------------
// AC-2: Existing within distance_threshold → Classification::Existing
// distance_threshold_m = 30, move_threshold high enough that
// delta < move_threshold yields Existing.
// ---------------------------------------------------------------------
#[test]
fn ac2_within_distance_threshold_returns_existing() {
// Arrange
let cfg = MapObjectsStoreConfig {
distance_threshold_m: 30.0,
// Anything > distance_threshold guarantees the in-window match
// never flips to Moved.
move_threshold_m: 100.0,
..MapObjectsStoreConfig::default()
};
let store = MapObjectsStore::new(cfg);
let h = store.handle();
let first = h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank")).unwrap();
let original_id = match first {
Classification::New { id } => id,
other => panic!("setup: expected New, got {other:?}"),
};
// Act — same class, 5 m north of the anchor.
let (lat2, lon2) = shift_m(ANCHOR_LAT, ANCHOR_LON, 5.0, 0.0);
let c = h.classify(input(lat2, lon2, "tank")).unwrap();
// Assert
match c {
Classification::Existing { id } => assert_eq!(id, original_id),
other => panic!("expected Existing, got {other:?}"),
}
assert_eq!(h.len().unwrap(), 1, "no new objects should be inserted");
}
// ---------------------------------------------------------------------
// AC-3: Moved beyond move_threshold → Classification::Moved
// distance_threshold large enough to admit the 60 m candidate.
// ---------------------------------------------------------------------
#[test]
fn ac3_beyond_move_threshold_returns_moved() {
// Arrange
let cfg = MapObjectsStoreConfig {
distance_threshold_m: 100.0,
move_threshold_m: 50.0,
..MapObjectsStoreConfig::default()
};
let store = MapObjectsStore::new(cfg);
let h = store.handle();
let initial = input(ANCHOR_LAT, ANCHOR_LON, "tank");
let from_mgrs = initial.mgrs.clone();
let first = h.classify(initial).unwrap();
let original_id = match first {
Classification::New { id } => id,
other => panic!("setup: expected New, got {other:?}"),
};
// Act — same class, 60 m north of the anchor.
let (lat2, lon2) = shift_m(ANCHOR_LAT, ANCHOR_LON, 60.0, 0.0);
let next = input(lat2, lon2, "tank");
let to_mgrs = next.mgrs.clone();
let c = h.classify(next).unwrap();
// Assert
match c {
Classification::Moved {
id,
from_mgrs: f,
to_mgrs: t,
} => {
assert_eq!(id, original_id);
assert_eq!(f, from_mgrs);
assert_eq!(t, to_mgrs);
}
other => panic!("expected Moved, got {other:?}"),
}
assert_eq!(h.len().unwrap(), 1, "Moved is an update, not an insert");
}
// ---------------------------------------------------------------------
// AC-4: k-ring boundary lookup. A second detection in a *different* H3
// cell (boundary cell) must still match the original because k=2 widens
// the lookup. We pick a delta (~12 m east) that crosses the ~15 m res-10
// cell boundary while staying well within distance_threshold.
// ---------------------------------------------------------------------
#[test]
fn ac4_k_ring_finds_match_in_neighbour_cell() {
// Arrange
let cfg = MapObjectsStoreConfig {
h3_resolution: 10,
k_ring: 2,
distance_threshold_m: 30.0,
move_threshold_m: 100.0,
..MapObjectsStoreConfig::default()
};
let store = MapObjectsStore::new(cfg);
let h = store.handle();
h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank")).unwrap();
// Act — 12 m east. At res 10 (~15 m edge) this crosses to a
// neighbouring cell with very high probability for arbitrary anchor.
let (lat2, lon2) = shift_m(ANCHOR_LAT, ANCHOR_LON, 0.0, 12.0);
let c = h.classify(input(lat2, lon2, "tank")).unwrap();
// Assert — the k-ring widen must catch it.
assert!(
matches!(c, Classification::Existing { .. }),
"expected Existing (k-ring match), got {c:?}",
);
assert_eq!(h.len().unwrap(), 1);
}
// ---------------------------------------------------------------------
// Class-group similarity widens matching beyond exact-class equality.
// Covers `similar_classes` configuration.
// ---------------------------------------------------------------------
#[test]
fn similar_classes_collapse_to_same_group() {
// Arrange
let cfg = MapObjectsStoreConfig {
distance_threshold_m: 30.0,
move_threshold_m: 100.0,
similar_classes: vec![vec!["tree".into(), "shrub".into()]],
..MapObjectsStoreConfig::default()
};
let store = MapObjectsStore::new(cfg);
let h = store.handle();
h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tree")).unwrap();
// Act — same place, different (but collapsed) class.
let c = h.classify(input(ANCHOR_LAT, ANCHOR_LON, "shrub")).unwrap();
// Assert
assert!(matches!(c, Classification::Existing { .. }), "got {c:?}");
}
#[test]
fn different_classes_do_not_collapse() {
// Arrange
let store = MapObjectsStore::default();
let h = store.handle();
h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tree")).unwrap();
// Act
let c = h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank")).unwrap();
// Assert — disjoint classes must each get their own row.
assert!(matches!(c, Classification::New { .. }), "got {c:?}");
assert_eq!(h.len().unwrap(), 2);
}
// ---------------------------------------------------------------------
// AC-5: p99 ≤ 1 ms with 10 000 warm objects.
//
// Debug builds are 3-10× slower than release. Gate behind `--ignored`
// so default `cargo test` stays fast and CI explicitly opts in via
// `cargo test --release -- --ignored ac5_classify_p99`. Asserting on a
// debug build would be flaky.
// ---------------------------------------------------------------------
#[test]
#[ignore = "perf-only: run with `cargo test --release -p mapobjects_store -- --ignored`"]
fn ac5_classify_p99_under_one_ms() {
// Arrange — tight match window so seeded points placed on a 30 m grid
// remain distinct rows. 100 × 100 grid → 3 km × 3 km area, 10 000 rows.
let cfg = MapObjectsStoreConfig {
h3_resolution: 10,
k_ring: 2,
distance_threshold_m: 5.0,
move_threshold_m: 100.0,
similar_classes: Vec::new(),
};
let store = MapObjectsStore::new(cfg);
let h = store.handle();
const GRID_STEP_M: f64 = 30.0;
for i in 0..10_000_u32 {
let row = i / 100;
let col = i % 100;
let dn = row as f64 * GRID_STEP_M;
let de = col as f64 * GRID_STEP_M;
let (lat, lon) = shift_m(ANCHOR_LAT, ANCHOR_LON, dn, de);
h.classify(input(lat, lon, "tank")).unwrap();
}
assert_eq!(h.len().unwrap(), 10_000);
// Act — 1 000 classifications at points midway between grid nodes so
// most queries land inside a populated k-ring without matching any
// single row (worst-case lookup cost).
let mut samples = Vec::with_capacity(1_000);
for i in 0..1_000_u32 {
let row = (i / 50) as f64;
let col = (i % 50) as f64;
let dn = row * GRID_STEP_M + GRID_STEP_M / 2.0;
let de = col * GRID_STEP_M + GRID_STEP_M / 2.0;
let (lat, lon) = shift_m(ANCHOR_LAT, ANCHOR_LON, dn, de);
let t0 = std::time::Instant::now();
let _ = h.classify(input(lat, lon, "tank")).unwrap();
samples.push(t0.elapsed());
}
// Assert — p99 ≤ 1 ms.
samples.sort();
let p99 = samples[(samples.len() as f64 * 0.99) as usize];
assert!(
p99 <= std::time::Duration::from_millis(1),
"p99 was {p99:?} (expected ≤1 ms)",
);
}