open-standards/map/map-rs/crates/map-orbital/src/lib.rs
Transaction Science ba1212361f Add aac, map, sandbox standards; catalog the standards family
Commit the previously-untracked standard directories and catalog them in the
root README alongside proof:

- aac/    — communication cargo class: portable boards + a ten-level
            offline-first cascade (Apache-2.0 code / CC-BY-4.0 spec).
- map/    — geospatial cargo class: range-requestable signed tilesets +
            an F0–F4 fidelity cascade (Apache-2.0 code / CC-BY-4.0 spec).
- sandbox/— isolated, metered, attested execution across the heterogeneous
            compute fabric (Apache-2.0 code / CC-BY-4.0 spec).
- jouleclaw-otel-authz, jouleclaw-trust — new jouleclaw reference crates.

Root README now lists aac, map, sandbox, and proof in the catalog, the tree,
and the contribution guide.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 17:04:36 -04:00

194 lines
6.5 KiB
Rust

//! Map orbital — TLE parsing, SGP4/SDP4 propagation, and coordinate transforms
//! for the `F3` live-satellite layer of the Map standard.
//!
//! The [`bridge`] maps a propagated satellite sub-point to a
//! [`map_schema::CellKey`] (S2 cell + altitude band + time bucket), so a live
//! satellite is addressable on the same 4D grid as every other tile. Ported from
//! `joule-orbital`.
//!
//! ## Quick Start
//!
//! ```rust
//! use map_orbital::{parse_tle, Sgp4State, satellite_position};
//!
//! let tle = parse_tle(
//! "ISS (ZARYA)",
//! "1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9009",
//! "2 25544 51.6416 247.4627 0006703 30.0000 10.0000 15.49560000000007",
//! ).unwrap();
//!
//! let state = Sgp4State::initialize(&tle).unwrap();
//! let pos = state.propagate(0.0).unwrap(); // position at epoch
//! let geo = satellite_position(&state, &tle.epoch).unwrap();
//!
//! println!("ISS: {:.2}N, {:.2}E, {:.0} km altitude",
//! geo.lat_deg, geo.lon_deg, geo.alt_km);
//! ```
//!
//! ## Architecture
//!
//! - [`tle`] — Two-Line Element set parser
//! - [`sgp4`] — SGP4 near-Earth propagator
//! - [`sdp4`] — SDP4 deep-space propagator extension
//! - [`ecef`] — ECI to ECEF coordinate transform
//! - [`geodetic`] — ECEF to geodetic (lat/lon/alt)
//! - [`pass_predictor`] — Satellite pass prediction and look angles
//! - [`julian`] — Julian date and sidereal time utilities
//! - [`constants`] — Physical constants (WGS-72, WGS-84)
pub mod constants;
pub mod ecef;
pub mod error;
pub mod geodetic;
pub mod julian;
pub mod pass_predictor;
pub mod sdp4;
pub mod sgp4;
pub mod tle;
pub mod bridge;
// ── Re-exports for convenience ──────────────────────────────
pub use ecef::{eci_to_ecef, ecef_to_eci, EcefPosition};
pub use error::{OrbitalError, OrbitalResult};
pub use geodetic::{ecef_to_geodetic, geodetic_to_ecef, GeodeticPosition};
pub use julian::{datetime_to_jd, epoch_to_jd, gmst, jd_to_datetime};
pub use pass_predictor::{
find_passes, look_angle, next_pass, next_pass_from, satellite_position, LookAngle, PassInfo,
};
pub use sgp4::{EciPosition, Sgp4State};
pub use tle::{parse_tle, parse_tle_pair, TleElements};
#[cfg(test)]
mod tests {
use super::*;
const ISS_NAME: &str = "ISS (ZARYA)";
const ISS_LINE1: &str =
"1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9009";
const ISS_LINE2: &str =
"2 25544 51.6416 247.4627 0006703 30.0000 10.0000 15.49560000000007";
#[test]
fn test_end_to_end_iss() {
// Parse TLE
let tle = parse_tle(ISS_NAME, ISS_LINE1, ISS_LINE2).unwrap();
assert_eq!(tle.norad_id, 25544);
// Initialize propagator
let state = Sgp4State::initialize(&tle).unwrap();
// Propagate at epoch
let eci = state.propagate(0.0).unwrap();
let alt_km = eci.radius() - constants::EARTH_RADIUS_KM;
assert!(alt_km > 300.0 && alt_km < 500.0, "ISS altitude: {alt_km:.1}");
// Convert to geodetic
let geo = satellite_position(&state, &tle.epoch).unwrap();
assert!(geo.lat_deg.abs() <= 52.0);
assert!(geo.alt_km > 300.0 && geo.alt_km < 500.0);
// Propagate forward one orbit (about 92 minutes)
let period = state.orbital_period_minutes();
let pos1 = state.propagate(0.0).unwrap();
let pos2 = state.propagate(period).unwrap();
// After one full orbit, the position should be similar
// (not exact due to perturbations, but within a few hundred km)
let dx = pos1.x - pos2.x;
let dy = pos1.y - pos2.y;
let dz = pos1.z - pos2.z;
let dist = (dx * dx + dy * dy + dz * dz).sqrt();
assert!(
dist < 500.0,
"After one orbit, position drift: {dist:.1} km"
);
}
#[test]
fn test_geodetic_roundtrip_integration() {
let original = GeodeticPosition {
lat_deg: 28.5729,
lon_deg: -80.6490,
alt_km: 0.005,
};
let ecef = geodetic_to_ecef(&original);
let recovered = ecef_to_geodetic(&ecef);
assert!((original.lat_deg - recovered.lat_deg).abs() < 1e-6);
assert!((original.lon_deg - recovered.lon_deg).abs() < 1e-6);
assert!((original.alt_km - recovered.alt_km).abs() < 1e-4);
}
#[test]
fn test_eci_ecef_geodetic_pipeline() {
let tle = parse_tle(ISS_NAME, ISS_LINE1, ISS_LINE2).unwrap();
let state = Sgp4State::initialize(&tle).unwrap();
// Propagate 30 minutes
let eci = state.propagate(30.0).unwrap();
// ECI -> ECEF
let jd = epoch_to_jd(tle.epoch_year, tle.epoch_day) + 30.0 / constants::MINUTES_PER_DAY;
let ecef = eci_to_ecef(&eci, jd);
// ECEF -> Geodetic
let geo = ecef_to_geodetic(&ecef);
assert!(geo.lat_deg.abs() <= 52.0, "lat: {:.2}", geo.lat_deg);
assert!(geo.lon_deg.abs() <= 180.0, "lon: {:.2}", geo.lon_deg);
assert!(
geo.alt_km > 300.0 && geo.alt_km < 500.0,
"alt: {:.1}",
geo.alt_km
);
}
#[test]
fn test_look_angle_integration() {
let observer = GeodeticPosition {
lat_deg: 28.5729,
lon_deg: -80.6490,
alt_km: 0.0,
};
// Satellite directly overhead
let satellite = GeodeticPosition {
lat_deg: 28.5729,
lon_deg: -80.6490,
alt_km: 400.0,
};
let la = look_angle(&observer, &satellite);
assert!(
la.elevation_deg > 85.0,
"Overhead satellite elevation: {:.2}",
la.elevation_deg
);
assert!(
(la.range_km - 400.0).abs() < 10.0,
"Overhead satellite range: {:.2}",
la.range_km
);
}
#[test]
fn test_deep_space_detection() {
// GPS satellite TLE (semi-synchronous orbit, ~12 hour period)
let gps_line1 =
"1 28874U 05038A 24001.50000000 -.00000017 00000-0 00000+0 0 9995";
let gps_line2 =
"2 28874 55.4408 300.3067 0091588 210.5100 149.0200 2.00562998000003";
let tle = parse_tle_pair(gps_line1, gps_line2).unwrap();
let state = Sgp4State::initialize(&tle).unwrap();
// GPS satellites have ~12 hour period (720 min) -> deep space
let period = state.orbital_period_minutes();
assert!(
period > 600.0,
"GPS period should be > 600 min: {period:.1}"
);
}
}