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]>
194 lines
6.5 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|