mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 13:01:09 +00:00
e56d428753
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>
246 lines
8.0 KiB
Rust
246 lines
8.0 KiB
Rust
//! AZ-666 acceptance tests — `IgnoredItem` set + end-of-pass sweep.
|
|
|
|
use chrono::{Duration as ChronoDuration, Utc};
|
|
use mapobjects_store::{
|
|
Classification, ClassifyInput, MapObjectsStore, MapObjectsStoreConfig, RegionBbox,
|
|
};
|
|
use shared::models::mapobject::{IgnoredItem, IgnoredItemSource, RetentionScope};
|
|
use shared::models::mission::Coordinate;
|
|
use uuid::Uuid;
|
|
|
|
const M_PER_DEG_LAT: f64 = 111_320.0;
|
|
|
|
fn m_per_deg_lon(lat_deg: f64) -> f64 {
|
|
M_PER_DEG_LAT * lat_deg.to_radians().cos()
|
|
}
|
|
|
|
fn shift_m(base_lat: f64, base_lon: f64, dn_m: f64, de_m: f64) -> (f64, f64) {
|
|
(
|
|
base_lat + dn_m / M_PER_DEG_LAT,
|
|
base_lon + de_m / m_per_deg_lon(base_lat),
|
|
)
|
|
}
|
|
|
|
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-az666".into(),
|
|
observed_at: Utc::now(),
|
|
uav_id: "uav-az666".into(),
|
|
observed_at_monotonic_ns: 0,
|
|
}
|
|
}
|
|
|
|
fn ignored(mgrs: &str, class_group: &str) -> IgnoredItem {
|
|
IgnoredItem {
|
|
id: Uuid::new_v4(),
|
|
mgrs: mgrs.into(),
|
|
h3_cell: 0,
|
|
class_group: class_group.into(),
|
|
decline_time: Utc::now(),
|
|
operator_id: None,
|
|
mission_id: "m-az666".into(),
|
|
retention_scope: RetentionScope::Mission,
|
|
expires_at: None,
|
|
source: IgnoredItemSource::LocalAppended,
|
|
pending_upload: true,
|
|
}
|
|
}
|
|
|
|
fn bbox(nw_lat: f64, nw_lon: f64, se_lat: f64, se_lon: f64) -> RegionBbox {
|
|
[
|
|
Coordinate {
|
|
latitude: nw_lat,
|
|
longitude: nw_lon,
|
|
altitude_m: 0.0,
|
|
},
|
|
Coordinate {
|
|
latitude: se_lat,
|
|
longitude: se_lon,
|
|
altitude_m: 0.0,
|
|
},
|
|
]
|
|
}
|
|
|
|
const ANCHOR_LAT: f64 = 50.450_000;
|
|
const ANCHOR_LON: f64 = 30.520_000;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// AC-1: append(IgnoredItem { mgrs, class_group }) → is_ignored returns true.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ac1_ignored_item_suppresses_lookup() {
|
|
// Arrange
|
|
let store = MapObjectsStore::default();
|
|
let h = store.handle();
|
|
|
|
// Act
|
|
h.append_ignored(ignored("MGRS-A", "concealed_position_group"))
|
|
.unwrap();
|
|
|
|
// Assert
|
|
assert!(h.is_ignored("MGRS-A", "concealed_position_group").unwrap());
|
|
assert!(!h.is_ignored("MGRS-A", "movement_candidate").unwrap());
|
|
assert!(!h.is_ignored("MGRS-B", "concealed_position_group").unwrap());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// classify() with an ignored (mgrs, class_group) returns Classification::Ignored
|
|
// and DOES NOT insert into the store.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn classify_returns_ignored_when_pair_is_in_set() {
|
|
// Arrange
|
|
let store = MapObjectsStore::default();
|
|
let h = store.handle();
|
|
let input = input(ANCHOR_LAT, ANCHOR_LON, "tank");
|
|
h.append_ignored(ignored(&input.mgrs, "tank")).unwrap();
|
|
|
|
// Act
|
|
let c = h.classify(input).unwrap();
|
|
|
|
// Assert
|
|
assert!(matches!(c, Classification::Ignored), "got {c:?}");
|
|
assert_eq!(h.len().unwrap(), 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// AC-2: end_of_pass returns objects un-observed during the pass.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ac2_end_of_pass_returns_un_observed() {
|
|
// Arrange
|
|
let cfg = MapObjectsStoreConfig {
|
|
distance_threshold_m: 5.0,
|
|
move_threshold_m: 50.0,
|
|
..MapObjectsStoreConfig::default()
|
|
};
|
|
let store = MapObjectsStore::new(cfg);
|
|
let h = store.handle();
|
|
// Seed three objects M1, M2, M3 spaced 50 m apart.
|
|
let (m1_lat, m1_lon) = (ANCHOR_LAT, ANCHOR_LON);
|
|
let (m2_lat, m2_lon) = shift_m(ANCHOR_LAT, ANCHOR_LON, 50.0, 0.0);
|
|
let (m3_lat, m3_lon) = shift_m(ANCHOR_LAT, ANCHOR_LON, 100.0, 0.0);
|
|
let m1_in = input(m1_lat, m1_lon, "tank");
|
|
let m1_mgrs = m1_in.mgrs.clone();
|
|
let _ = h.classify(m1_in).unwrap();
|
|
let m2_in = input(m2_lat, m2_lon, "tank");
|
|
let m2_mgrs = m2_in.mgrs.clone();
|
|
let _ = h.classify(m2_in).unwrap();
|
|
let m3_in = input(m3_lat, m3_lon, "tank");
|
|
let m3_mgrs = m3_in.mgrs.clone();
|
|
let _ = h.classify(m3_in).unwrap();
|
|
let region = bbox(
|
|
ANCHOR_LAT + 0.01,
|
|
ANCHOR_LON - 0.01,
|
|
ANCHOR_LAT - 0.01,
|
|
ANCHOR_LON + 0.01,
|
|
);
|
|
|
|
// Act — open pass, re-observe only M1, close pass.
|
|
// Backdate seeded last_seen so the un-observed objects qualify.
|
|
// (Pass tracker only flags objects whose last_seen <= pass.started_at;
|
|
// since we just inserted them, advance the wall clock by sleeping is
|
|
// expensive, so instead start the pass with an as-of slightly in
|
|
// the future relative to the seeded timestamps.)
|
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
|
h.pass_start(region).unwrap();
|
|
// Re-observe M1 via a small ε offset so it stays an Existing match.
|
|
let (m1_obs_lat, m1_obs_lon) = shift_m(m1_lat, m1_lon, 0.5, 0.0);
|
|
let again = h.classify(input(m1_obs_lat, m1_obs_lon, "tank")).unwrap();
|
|
assert!(matches!(again, Classification::Existing { .. }));
|
|
let removed = h.end_of_pass(®ion).unwrap();
|
|
|
|
// Assert
|
|
let mgrs_seen: Vec<_> = removed.iter().map(|r| r.mgrs.clone()).collect();
|
|
assert!(
|
|
mgrs_seen.contains(&m2_mgrs),
|
|
"expected M2 in removed candidates, got {mgrs_seen:?}",
|
|
);
|
|
assert!(mgrs_seen.contains(&m3_mgrs));
|
|
assert!(!mgrs_seen.contains(&m1_mgrs));
|
|
assert_eq!(removed.len(), 2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// AC-3: end_of_pass excludes ignored objects from the candidate list.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ac3_end_of_pass_excludes_ignored() {
|
|
// Arrange
|
|
let cfg = MapObjectsStoreConfig {
|
|
distance_threshold_m: 5.0,
|
|
move_threshold_m: 50.0,
|
|
..MapObjectsStoreConfig::default()
|
|
};
|
|
let store = MapObjectsStore::new(cfg);
|
|
let h = store.handle();
|
|
let m2_in = input(ANCHOR_LAT, ANCHOR_LON, "tank");
|
|
let m2_mgrs = m2_in.mgrs.clone();
|
|
let _ = h.classify(m2_in).unwrap();
|
|
let region = bbox(
|
|
ANCHOR_LAT + 0.01,
|
|
ANCHOR_LON - 0.01,
|
|
ANCHOR_LAT - 0.01,
|
|
ANCHOR_LON + 0.01,
|
|
);
|
|
h.append_ignored(ignored(&m2_mgrs, "tank")).unwrap();
|
|
|
|
// Act
|
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
|
h.pass_start(region).unwrap();
|
|
let removed = h.end_of_pass(®ion).unwrap();
|
|
|
|
// Assert — M2 was un-observed during the pass but ignored, so it
|
|
// MUST NOT be surfaced.
|
|
assert!(
|
|
removed.iter().all(|r| r.mgrs != m2_mgrs),
|
|
"ignored object leaked into removed candidates: {removed:?}",
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// apply_decline(POI) installs the equivalent IgnoredItem.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn apply_decline_suppresses_subsequent_detections() {
|
|
use shared::models::poi::{Poi, VlmPipelineStatus};
|
|
// Arrange
|
|
let store = MapObjectsStore::default();
|
|
let h = store.handle();
|
|
let now = Utc::now();
|
|
let poi = Poi {
|
|
id: Uuid::new_v4(),
|
|
confidence: 0.9,
|
|
mgrs: "MGRS-DECLINED".into(),
|
|
class: "concealed_position".into(),
|
|
class_group: "concealed_position_group".into(),
|
|
source_detection_ids: vec![],
|
|
enqueued_at: now,
|
|
priority: 1.0,
|
|
decline_suppressed: false,
|
|
vlm_status: VlmPipelineStatus::NotRequested,
|
|
tier2_evidence: None,
|
|
deadline: now + ChronoDuration::seconds(60),
|
|
};
|
|
|
|
// Act
|
|
h.apply_decline(poi).unwrap();
|
|
|
|
// Assert
|
|
assert!(h
|
|
.is_ignored("MGRS-DECLINED", "concealed_position_group")
|
|
.unwrap());
|
|
}
|