tobari 0.1.0

Earth environment models — atmospheric drag density, IGRF geomagnetic field, and space weather integration.
Documentation
//! Generate IGRF coefficient constants.
//!
//! Default: copies vendored `data/igrf14_generated.rs` to OUT_DIR.
//! With `fetch-igrf` feature: downloads IGRF-14 coefficients from NOAA,
//! parses them, and generates a fresh .rs file (also updates vendored copy).
//!
//! Data source: IAGA / NOAA
//!   <https://www.ngdc.noaa.gov/IAGA/vmod/coeffs/igrf14coeffs.txt>

use std::env;
use std::fs;
use std::io::Write;
use std::path::Path;

const MAX_DEGREE: usize = 13;

fn main() {
    let out_dir = env::var("OUT_DIR").unwrap();
    let gen_path = Path::new(&out_dir).join("igrf_generated.rs");

    #[cfg(feature = "fetch-igrf")]
    {
        const IGRF_URL: &str = "https://www.ngdc.noaa.gov/IAGA/vmod/coeffs/igrf14coeffs.txt";
        eprintln!("cargo:warning=Downloading IGRF coefficients from {IGRF_URL}");
        match download_and_generate(IGRF_URL, &gen_path) {
            Ok(()) => {
                // Vendored copy is NOT auto-updated to avoid invalidating
                // cargo:rerun-if-changed. Run with --update-vendored manually.
            }
            Err(e) => {
                eprintln!("cargo:warning=Download failed ({e}), falling back to vendored data");
                copy_vendored(&gen_path);
            }
        }
    }

    #[cfg(not(feature = "fetch-igrf"))]
    copy_vendored(&gen_path);

    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed=data/igrf14.rs");
}

fn copy_vendored(dest: &Path) {
    let vendored = Path::new("data/igrf14.rs");
    fs::copy(vendored, dest)
        .unwrap_or_else(|e| panic!("Failed to copy vendored {}: {e}", vendored.display()));
}

#[cfg(feature = "fetch-igrf")]
fn download_and_generate(url: &str, dest: &Path) -> Result<(), String> {
    let body: String = ureq::get(url)
        .call()
        .map_err(|e| format!("{e}"))?
        .body_mut()
        .read_to_string()
        .map_err(|e| format!("{e}"))?;
    let parsed = parse_igrf(&body);
    if parsed.columns.is_empty() || parsed.rows.is_empty() {
        return Err("Downloaded data does not appear to be valid IGRF coefficients".into());
    }
    generate_rust(dest, &parsed);
    Ok(())
}

// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------

struct ParsedIgrf {
    columns: Vec<String>,
    rows: Vec<CoeffRow>,
}

struct CoeffRow {
    gh: char,
    n: usize,
    m: usize,
    values: Vec<f64>,
}

fn parse_igrf(raw: &str) -> ParsedIgrf {
    let mut columns = Vec::new();
    let mut rows = Vec::new();

    for line in raw.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        if line.starts_with("g/h") || line.starts_with("c/s") {
            let parts: Vec<&str> = line.split_whitespace().collect();
            columns = parts[3..].iter().map(|s| s.to_string()).collect();
            continue;
        }

        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.len() < 4 {
            continue;
        }

        let gh = match parts[0].chars().next() {
            Some(c @ ('g' | 'h')) => c,
            _ => continue,
        };

        let n: usize = parts[1].parse().unwrap();
        let m: usize = parts[2].parse().unwrap();
        if n > MAX_DEGREE {
            continue;
        }

        let values: Vec<f64> = parts[3..]
            .iter()
            .map(|s| s.parse::<f64>().unwrap())
            .collect();

        rows.push(CoeffRow { gh, n, m, values });
    }

    ParsedIgrf { columns, rows }
}

// ---------------------------------------------------------------------------
// Code generation
// ---------------------------------------------------------------------------

fn coeff_index(n: usize, m: usize) -> usize {
    (n - 1) * n / 2 + (n - 1) + m
}

fn total_coeffs() -> usize {
    MAX_DEGREE * (MAX_DEGREE + 3) / 2
}

fn extract_array(rows: &[CoeffRow], col_idx: usize, is_g: bool) -> Vec<f64> {
    let n = total_coeffs();
    let mut arr = vec![0.0_f64; n];
    let target = if is_g { 'g' } else { 'h' };

    for row in rows {
        if row.gh != target || row.n > MAX_DEGREE {
            continue;
        }
        let idx = coeff_index(row.n, row.m);
        if idx < n && col_idx < row.values.len() {
            arr[idx] = row.values[col_idx];
        }
    }

    arr
}

fn parse_year(label: &str) -> Option<f64> {
    if label.contains('-') {
        return None;
    }
    label.parse::<f64>().ok()
}

fn generate_rust(path: &Path, parsed: &ParsedIgrf) {
    let n = total_coeffs();
    let mut out = fs::File::create(path).unwrap();

    writeln!(out, "// Auto-generated by build.rs — do not edit manually.").unwrap();
    writeln!(
        out,
        "// Source: IGRF-14, IAGA/NOAA (https://www.ngdc.noaa.gov/IAGA/vmod/coeffs/igrf14coeffs.txt)"
    )
    .unwrap();
    writeln!(out).unwrap();
    writeln!(out, "pub const IGRF_REFERENCE_RADIUS: f64 = 6371.2;").unwrap();
    writeln!(out, "pub const IGRF_MAX_DEGREE: usize = {MAX_DEGREE};").unwrap();
    writeln!(out, "pub const N_COEFFS: usize = {n};").unwrap();
    writeln!(out).unwrap();

    let mut epoch_years: Vec<(usize, f64)> = Vec::new();
    let mut sv_col_idx: Option<usize> = None;

    for (i, label) in parsed.columns.iter().enumerate() {
        if let Some(year) = parse_year(label) {
            epoch_years.push((i, year));
        } else {
            sv_col_idx = Some(i);
        }
    }

    let num_epochs = epoch_years.len();
    writeln!(out, "pub const NUM_EPOCHS: usize = {num_epochs};").unwrap();
    writeln!(out).unwrap();

    writeln!(out, "#[rustfmt::skip]").unwrap();
    write!(out, "pub const EPOCH_YEARS: [f64; {num_epochs}] = [").unwrap();
    for (i, (_, year)) in epoch_years.iter().enumerate() {
        if i > 0 {
            write!(out, ", ").unwrap();
        }
        write!(out, "{year:.1}").unwrap();
    }
    writeln!(out, "];").unwrap();
    writeln!(out).unwrap();

    writeln!(out, "#[rustfmt::skip]").unwrap();
    writeln!(out, "pub const G_EPOCHS: [[f64; {n}]; {num_epochs}] = [").unwrap();
    for (col_idx, _) in &epoch_years {
        write_inner_array(&mut out, &extract_array(&parsed.rows, *col_idx, true));
    }
    writeln!(out, "];").unwrap();
    writeln!(out).unwrap();

    writeln!(out, "#[rustfmt::skip]").unwrap();
    writeln!(out, "pub const H_EPOCHS: [[f64; {n}]; {num_epochs}] = [").unwrap();
    for (col_idx, _) in &epoch_years {
        write_inner_array(&mut out, &extract_array(&parsed.rows, *col_idx, false));
    }
    writeln!(out, "];").unwrap();
    writeln!(out).unwrap();

    if let Some(sv_idx) = sv_col_idx {
        write_flat_array(
            &mut out,
            "DG_SV",
            &extract_array(&parsed.rows, sv_idx, true),
        );
        write_flat_array(
            &mut out,
            "DH_SV",
            &extract_array(&parsed.rows, sv_idx, false),
        );
    }

    writeln!(out, "#[inline]").unwrap();
    writeln!(
        out,
        "pub const fn coeff_index(n: usize, m: usize) -> usize {{"
    )
    .unwrap();
    writeln!(out, "    (n - 1) * n / 2 + (n - 1) + m").unwrap();
    writeln!(out, "}}").unwrap();
}

fn write_inner_array(out: &mut fs::File, arr: &[f64]) {
    write!(out, "    [").unwrap();
    for (i, val) in arr.iter().enumerate() {
        if i > 0 && i % 8 == 0 {
            write!(out, "\n     ").unwrap();
        }
        write!(out, "{val:>12.1},").unwrap();
    }
    writeln!(out, "],").unwrap();
}

fn write_flat_array(out: &mut fs::File, name: &str, arr: &[f64]) {
    let n = arr.len();
    writeln!(out, "#[rustfmt::skip]").unwrap();
    writeln!(out, "pub const {name}: [f64; {n}] = [").unwrap();
    for (i, val) in arr.iter().enumerate() {
        if i % 7 == 0 {
            write!(out, "    ").unwrap();
        }
        write!(out, "{val:>12.1},").unwrap();
        if i % 7 == 6 || i == n - 1 {
            writeln!(out).unwrap();
        }
    }
    writeln!(out, "];").unwrap();
    writeln!(out).unwrap();
}