satkit 0.22.0

Satellite Toolkit
Documentation
//! Core data files compiled into the library.
//!
//! satkit needs three kinds of data at runtime, and they are handled
//! differently by size and by how often they change:
//!
//! | tier | files | how it is provided |
//! |---|---|---|
//! | **embedded** (this module) | IERS Tables 5.2a/b/d (nutation / CIO series); EGM96, JGM2, JGM3, ITU_GRACE16 gravity coefficients truncated to degree 70 | gzip'd and compiled in with `include_bytes!` (~300 KB total), inflated on first use. Frames and gravity therefore work with **no data directory and no network** |
//! | **ephemeris** | JPL DE440 (102 MB) or DE421 (14 MB) | downloaded on first use through the SHA-256-verified [manifest](super::manifest) fetch, or provided by the user |
//! | **refreshed** | Earth orientation (`EOP-All.csv`), space weather (`SW-All.csv`) | change daily; fetched from CelesTrak by `update_datafiles()` or on first use |
//!
//! # Precedence
//!
//! A file present in a data search directory ([`search_dirs`](super::datadir::search_dirs))
//! always wins over the embedded copy — so a user can drop in a full-degree
//! gravity file or an updated IERS table without rebuilding. The embedded
//! copy is the fallback when the file is absent (or no data directory can
//! be resolved at all). A one-time note is printed the first time an
//! embedded copy is used, so it is visible which source is in effect.
//!
//! The blobs are generated by `tools/embed_data.py` from a verified data
//! directory; `data/embedded/SOURCES.json` records the SHA-256 of each full
//! source file (matching `data/manifest.json`), the truncation degree for the
//! gravity models, and the SHA-256 of the inflated bytes.

use std::io::Read;
use std::sync::OnceLock;

/// Highest spherical-harmonic degree kept in the embedded gravity files.
/// The evaluator uses at most [`crate::earthgravity::MAX_GRAVITY_DEGREE`]
/// (40), so nothing is lost; the headroom is there in case that cap is
/// raised.
pub const EMBED_MAX_DEGREE: usize = 70;

/// Names of the embedded files (as they would appear in the data directory).
pub const EMBEDDED_FILES: [&str; 7] = [
    "tab5.2a.txt",
    "tab5.2b.txt",
    "tab5.2d.txt",
    "EGM96.gfc",
    "ITU_GRACE16.gfc",
    "JGM2.gfc",
    "JGM3.gfc",
];

/// `data/embedded/SOURCES.json` — provenance of the embedded blobs.
pub const SOURCES_JSON: &str = include_str!("../../data/embedded/SOURCES.json");

fn compressed(name: &str) -> Option<&'static [u8]> {
    Some(match name {
        "tab5.2a.txt" => include_bytes!("../../data/embedded/tab5.2a.txt.gz"),
        "tab5.2b.txt" => include_bytes!("../../data/embedded/tab5.2b.txt.gz"),
        "tab5.2d.txt" => include_bytes!("../../data/embedded/tab5.2d.txt.gz"),
        "EGM96.gfc" => include_bytes!("../../data/embedded/EGM96.gfc.gz"),
        "ITU_GRACE16.gfc" => include_bytes!("../../data/embedded/ITU_GRACE16.gfc.gz"),
        "JGM2.gfc" => include_bytes!("../../data/embedded/JGM2.gfc.gz"),
        "JGM3.gfc" => include_bytes!("../../data/embedded/JGM3.gfc.gz"),
        _ => return None,
    })
}

/// `true` if `name` has a compiled-in copy.
pub fn has(name: &str) -> bool {
    compressed(name).is_some()
}

/// Total size of the compressed blobs, in bytes.
pub fn compressed_size() -> usize {
    EMBEDDED_FILES
        .iter()
        .filter_map(|n| compressed(n))
        .map(|b| b.len())
        .sum()
}

/// The inflated contents of an embedded file, or `None` if `name` is not
/// embedded. Inflation happens on every call (the loaders cache the parsed
/// result in their own singletons, so this is called once per file).
pub fn get(name: &str) -> Option<Vec<u8>> {
    let gz = compressed(name)?;
    let mut out = Vec::with_capacity(gz.len() * 4);
    // The blobs are produced by our own script, so a decode failure is a
    // build defect, not a runtime condition; the unit test below catches it.
    flate2::read::GzDecoder::new(gz)
        .read_to_end(&mut out)
        .expect("embedded data blob is not valid gzip");
    note_once(name);
    Some(out)
}

/// Print a one-time note (per process, not per file) that embedded data is
/// being used, so it is clear which source is in effect. Suppressed by
/// `SATKIT_QUIET=1`.
fn note_once(name: &str) {
    static NOTED: OnceLock<()> = OnceLock::new();
    NOTED.get_or_init(|| {
        if std::env::var_os("SATKIT_QUIET").is_none() {
            eprintln!(
                "satkit: using the compiled-in copy of {name} (no copy found in the data \
                 directory); frames and gravity models to degree {EMBED_MAX_DEGREE} work \
                 without any data files. Run satkit::utils::update_datafiles() to install \
                 the full files if you need them."
            );
        }
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sha256_hex(b: &[u8]) -> String {
        super::super::manifest::sha256_hex(b)
    }

    /// Every embedded blob inflates and matches the SHA-256 recorded by
    /// `tools/embed_data.py` in SOURCES.json.
    #[test]
    fn offline_embedded_blobs_match_sources_json() {
        let sources: serde_json::Value = serde_json::from_str(SOURCES_JSON).unwrap();
        assert_eq!(sources["embed_max_degree"], EMBED_MAX_DEGREE as u64);
        for name in EMBEDDED_FILES {
            let bytes = get(name).expect(name);
            let rec = &sources["files"][name];
            assert_eq!(
                rec["embedded_size"].as_u64().unwrap() as usize,
                bytes.len(),
                "{name}"
            );
            assert_eq!(
                rec["embedded_sha256"].as_str().unwrap(),
                sha256_hex(&bytes),
                "{name}"
            );
        }
        assert!(get("nonexistent").is_none());
        assert!(
            compressed_size() < 1_500_000,
            "embedded data budget exceeded"
        );
    }

    /// The embedded gravity files keep their headers (attribution, tide
    /// system) and every coefficient up to `EMBED_MAX_DEGREE`, and parse
    /// through the normal loader.
    #[test]
    fn offline_embedded_gravity_files_parse() {
        for name in ["EGM96.gfc", "ITU_GRACE16.gfc", "JGM2.gfc", "JGM3.gfc"] {
            let bytes = get(name).unwrap();
            let text = String::from_utf8_lossy(&bytes);
            assert!(text.contains("end_of_head"), "{name}: header terminator");
            // The highest-degree row present must be EMBED_MAX_DEGREE (or the
            // model's own maximum, for JGM2/JGM3 which stop at 70).
            let max_n = text
                .lines()
                .filter(|l| l.starts_with("gfc "))
                .filter_map(|l| l.split_whitespace().nth(1)?.parse::<usize>().ok())
                .max()
                .unwrap();
            assert_eq!(max_n, EMBED_MAX_DEGREE, "{name}");
            let g = crate::earthgravity::Gravity::from_bytes(&bytes).expect(name);
            assert!(g.max_degree >= 40, "{name}: parsed max degree");
        }
    }

    /// The embedded IERS tables parse through the normal loader.
    #[test]
    fn offline_embedded_iers_tables_parse() {
        use crate::frametransform::ierstable::IERSTable;
        for name in ["tab5.2a.txt", "tab5.2b.txt", "tab5.2d.txt"] {
            let bytes = get(name).unwrap();
            IERSTable::from_bytes(&bytes).expect(name);
        }
    }

    /// Truncating the gravity files to degree 70 changes nothing the
    /// evaluator can see: accelerations at the degree-40 cap are bit-identical
    /// to the full file's. Runs only where a full file is available on disk
    /// (a provisioned developer machine); the embedded blob is the fallback.
    #[test]
    fn embedded_gravity_matches_full_file_to_degree_40() {
        use crate::earthgravity::Gravity;
        let mut compared = 0;
        for name in ["EGM96.gfc", "ITU_GRACE16.gfc", "JGM2.gfc", "JGM3.gfc"] {
            let Some(full_path) = crate::utils::find_data_file(name) else {
                continue;
            };
            let full = Gravity::from_path(&full_path).unwrap();
            let emb = Gravity::from_bytes(&get(name).unwrap()).unwrap();
            for (x, y, z) in [
                (6.778e6, 0.0, 0.0),
                (2.0e6, 5.0e6, 4.0e6),
                (-1.0e6, -3.0e6, 6.5e6),
                (4.2164e7, 0.0, 1.0e5),
            ] {
                let p = numeris::vector![x, y, z];
                let a = full.accel(&p, 40, 40);
                let b = emb.accel(&p, 40, 40);
                assert_eq!(a.as_slice(), b.as_slice(), "{name} at {p:?}");
            }
            compared += 1;
        }
        eprintln!("compared {compared} gravity models against full files");
    }
}