fix(ruview-swarm): honest NED metres in Remote ID, not WGS84 (ADR-159 A3)

RemoteIdBroadcast::update stored NED metres (state.position.x/.y) into
drone_lat/drone_lon, so the ASTM F3411 broadcast would carry physically
-impossible coordinates ("latitude = 37.5 m"). The module doc claimed a
Location/Vector message but only encode_basic_id() exists.

- Rename drone_lat/drone_lon -> drone_north_m/drone_east_m (NED metres
  relative to the operator/takeoff datum), documented as non-geodetic.
  operator_lat/lon stay true WGS84.
- Correct the module doc to claim Basic ID only; Location/Vector encoding
  is deferred until a datum-anchored NED->WGS84 transform lands.

Never broadcast physically-impossible coordinates.

Failing-on-old test:
security::remote_id::tests::test_ned_offset_stored_as_metres_not_latlon.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-06-11 23:10:02 -04:00
parent 6b5fd3cf25
commit 8d9c5994db
1 changed files with 69 additions and 9 deletions

View File

@ -1,16 +1,38 @@
//! ASTM F3411 Remote ID broadcast (Basic ID + Location/Vector message).
//! ASTM F3411 Remote ID — **Basic ID message only** (ADR-159 §A3).
//!
//! Only the Basic ID message (`encode_basic_id`) is implemented. The
//! Location/Vector message is **not** encoded yet because the drone position is
//! tracked in a local NED frame (north/east metres relative to a takeoff datum),
//! and a compliant Location/Vector message requires WGS84 latitude/longitude.
//! Broadcasting NED metres in lat/lon fields would emit physically-impossible
//! coordinates (e.g. "latitude = 12.4 metres"), so we deliberately keep the
//! drone position in honest `drone_north_m` / `drone_east_m` fields until a real
//! local-tangent-plane NED→WGS84 transform (with an operator datum) lands. See
//! the `ACCEPTED-FUTURE` note in ADR-159 §A3.
use crate::types::DroneState;
use serde::{Deserialize, Serialize};
/// Remote ID broadcast state for one drone.
///
/// Drone position is stored as **NED metres** (`drone_north_m` / `drone_east_m`)
/// relative to the operator/takeoff datum — *not* WGS84 lat/lon — because no
/// datum-anchored geodetic transform is wired yet. The operator position is true
/// WGS84 (it comes from the operator's GNSS, not the local frame).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteIdBroadcast {
pub uas_id: [u8; 20], // 20-byte UAS ID (ANSI/CTA-2063-A)
/// Operator latitude (WGS84 degrees) — real geodetic position.
pub operator_lat: f64,
/// Operator longitude (WGS84 degrees) — real geodetic position.
pub operator_lon: f64,
pub drone_lat: f64,
pub drone_lon: f64,
/// Drone north offset in **metres** from the operator/takeoff datum (NED x).
/// NOT a latitude. See module docs — Location/Vector encoding is deferred
/// until a real NED→WGS84 transform exists.
pub drone_north_m: f64,
/// Drone east offset in **metres** from the operator/takeoff datum (NED y).
/// NOT a longitude.
pub drone_east_m: f64,
pub altitude_msl_m: f32,
pub speed_ms: f32,
pub heading_deg: f32,
@ -24,8 +46,8 @@ impl RemoteIdBroadcast {
uas_id,
operator_lat: 0.0,
operator_lon: 0.0,
drone_lat: 0.0,
drone_lon: 0.0,
drone_north_m: 0.0,
drone_east_m: 0.0,
altitude_msl_m: 0.0,
speed_ms: 0.0,
heading_deg: 0.0,
@ -35,11 +57,15 @@ impl RemoteIdBroadcast {
}
/// Update from a drone state and operator position.
///
/// The drone position is stored as honest NED metres — we do **not** fake a
/// lat/lon from a local-frame offset. The operator position is true WGS84.
pub fn update(&mut self, state: &DroneState, operator_pos: (f64, f64)) {
// Convert NED position to approximate lat/lon (placeholder — real impl uses WGS84).
// We store the NED metres as placeholder values here.
self.drone_lat = state.position.x; // placeholder: x ≈ north offset
self.drone_lon = state.position.y; // placeholder: y ≈ east offset
// NED metres, stored as-is in metre-typed fields (no fabricated geodetic
// coordinates). A future Location/Vector encoder must transform these
// through a datum-anchored NED→WGS84 projection before broadcast.
self.drone_north_m = state.position.x; // NED x = north offset, metres
self.drone_east_m = state.position.y; // NED y = east offset, metres
self.altitude_msl_m = state.altitude_agl_m as f32;
self.speed_ms = state.velocity.magnitude() as f32;
self.heading_deg = state.heading_rad.to_degrees() as f32;
@ -80,4 +106,38 @@ mod tests {
let buf = rid.encode_basic_id();
assert_eq!(buf[2], 0xFF);
}
/// ADR-159 §A3 — a known NED offset must land in honest **metre** fields,
/// never in WGS84 lat/lon fields (which would broadcast physically-impossible
/// coordinates like "latitude = 37.5 m"). Fails on old code, where the same
/// values were stored into `drone_lat`/`drone_lon`.
#[test]
fn test_ned_offset_stored_as_metres_not_latlon() {
use crate::types::{DroneState, NodeId, Position3D};
let mut state = DroneState::default_at_origin(NodeId(7));
// 37.5 m north, -12.0 m east of the takeoff datum.
state.position = Position3D {
x: 37.5,
y: -12.0,
z: 5.0,
};
let mut rid = RemoteIdBroadcast::new([0x41u8; 20]);
// Operator at a real WGS84 fix (San Francisco-ish).
rid.update(&state, (37.7749, -122.4194));
// Drone offset is honest NED metres.
assert_eq!(rid.drone_north_m, 37.5);
assert_eq!(rid.drone_east_m, -12.0);
// Operator position is the real geodetic fix and is plausibly a lat/lon.
assert!((-90.0..=90.0).contains(&rid.operator_lat));
assert!((-180.0..=180.0).contains(&rid.operator_lon));
assert!((rid.operator_lat - 37.7749).abs() < 1e-9);
// The drone NED metres would have been an out-of-range "latitude" only
// if a value happened to exceed 90 — but the contract is the field name
// itself: these are metres, not degrees. A future Location/Vector
// encoder must project them through a real NED→WGS84 transform.
}
}