siderust 0.6.0

High-precision astronomy and satellite mechanics in Rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Vallés Puig, Ramon

//! Shared build-time pipeline for JPL DE4xx ephemerides.
//!
//! Both DE440 and DE441 share identical processing logic. This module
//! parameterizes the pipeline over a [`DeConfig`] so each version is
//! a thin configuration wrapper.

use super::{daf, spk};
use std::env;
use std::path::Path;
use std::process::Command;

/// NAIF body IDs for the segments we extract.
const SUN_ID: i32 = 10;
const EMB_ID: i32 = 3;
const MOON_ID: i32 = 301;

/// Bodies to extract: (target_id, center_id, output_name)
const BODIES: &[(i32, i32, &str)] = &[
    (SUN_ID, 0, "sun"),
    (EMB_ID, 0, "emb"),
    (MOON_ID, EMB_ID, "moon"),
];

/// Configuration for a specific DE version (DE440, DE441, etc.).
pub struct DeConfig {
    /// File prefix for binary output, e.g. `"de440"` or `"de441"`.
    pub prefix: &'static str,
    /// Human-readable label, e.g. `"DE440"` or `"DE441"`.
    pub label: &'static str,
    /// URL to download the BSP file from NAIF.
    pub bsp_url: &'static str,
    /// BSP filename on disk, e.g. `"de440.bsp"`.
    pub bsp_filename: &'static str,
    /// Minimum plausible BSP file size in bytes (for validation).
    pub min_bsp_size: u64,
    /// Download timeout in seconds (curl/wget).
    pub download_timeout: u64,
    /// Human-readable size hint for log messages, e.g. `"~120 MB"`.
    pub size_hint: &'static str,
}

fn jpl_stub_enabled(cfg: &DeConfig) -> bool {
    // `SIDERUST_JPL_STUB`:
    // - unset/empty/0/false: normal behavior (download + parse + codegen)
    // - "1"/"true"/"all": stub all JPL datasets
    // - "de441" or "de440,de441": stub only those prefixes (case-insensitive)
    let Ok(raw) = env::var("SIDERUST_JPL_STUB") else {
        return false;
    };
    let raw = raw.trim();
    if raw.is_empty() {
        return false;
    }
    let lower = raw.to_ascii_lowercase();
    if lower == "all" || lower == "1" || lower == "true" || lower == "yes" || lower == "on" {
        return true;
    }

    lower
        .split(|c: char| c == ',' || c.is_whitespace())
        .filter(|s| !s.is_empty())
        .any(|tok| tok == cfg.prefix.to_ascii_lowercase())
}

/// Run the full DE4xx build pipeline: fetch → parse DAF → extract → codegen.
pub fn run(cfg: &DeConfig, data_dir: &Path) -> anyhow::Result<()> {
    let out_dir =
        std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set by Cargo"));

    if jpl_stub_enabled(cfg) {
        println!(
            "cargo:warning=Stubbed {} build (SIDERUST_JPL_STUB set): skipping BSP download/codegen; runtime use will panic",
            cfg.label
        );
        let rs_path = out_dir.join(format!("{}_data.rs", cfg.prefix));
        generate_stub_rust_module(cfg, &rs_path)?;
        eprintln!("{} (stub)", rs_path.display());
        return Ok(());
    }

    // 1. Fetch
    let bsp_path = ensure_bsp(cfg, data_dir)?;
    eprintln!("  {} BSP at: {}", cfg.label, bsp_path.display());

    // 2. Parse DAF
    let file_data = std::fs::read(&bsp_path)?;
    let daf = daf::Daf::parse(&file_data)?;
    eprintln!(
        "  DAF parsed: {} summaries, ND={}, NI={}",
        daf.summaries.len(),
        daf.nd,
        daf.ni
    );

    // 3. Extract bodies → binary files + codegen
    let mut generated_bodies: Vec<(&str, spk::SegmentMeta)> = Vec::new();

    for &(target, center, name) in BODIES {
        let summary = daf
            .summaries
            .iter()
            .find(|s| s.target_id == target && s.center_id == center)
            .unwrap_or_else(|| {
                panic!(
                    "{}: segment target={} center={} not found in BSP",
                    cfg.label, target, center
                )
            });

        eprintln!(
            "  Segment {}: target={}, center={}, type={}, records at words {}..{}",
            name,
            summary.target_id,
            summary.center_id,
            summary.data_type,
            summary.start_word,
            summary.end_word
        );

        assert!(
            summary.data_type == 2 || summary.data_type == 3,
            "{}: only SPK Type 2/3 supported, got Type {}",
            cfg.label,
            summary.data_type
        );

        let meta = spk::read_type2_segment(&file_data, &daf, summary)?;
        eprintln!(
            "    ncoeff={}, n_records={}, intlen={:.1}s ({:.1} days), rsize={}",
            meta.ncoeff,
            meta.n_records,
            meta.intlen,
            meta.intlen / 86400.0,
            meta.rsize
        );

        // Write binary data file
        let bin_path = out_dir.join(format!("{}_{}.bin", cfg.prefix, name));
        spk::write_binary(&meta, &bin_path)?;
        eprintln!(
            "{} ({} bytes)",
            bin_path.display(),
            std::fs::metadata(&bin_path)?.len()
        );

        generated_bodies.push((name, meta));
    }

    // 4. Generate Rust accessor module
    let rs_path = out_dir.join(format!("{}_data.rs", cfg.prefix));
    generate_rust_module(cfg, &generated_bodies, &rs_path)?;
    eprintln!("{}", rs_path.display());

    Ok(())
}

fn generate_stub_rust_module(cfg: &DeConfig, path: &Path) -> anyhow::Result<()> {
    use std::io::Write;

    let mut f = std::fs::File::create(path)?;
    writeln!(f, "// AUTOGENERATED by build.rs — do not edit")?;
    writeln!(
        f,
        "// STUBBED {} dataset: SIDERUST_JPL_STUB requested no-download build.",
        cfg.label
    )?;
    writeln!(f, "// Any attempt to evaluate ephemerides will panic.")?;
    writeln!(f)?;

    writeln!(
        f,
        "#[inline(never)]\nfn unavailable_record(_: usize) -> &'static [f64] {{\n    panic!(\"{} dataset is stubbed (SIDERUST_JPL_STUB). Disable stubbing or provide the BSP to build real data.\")\n}}",
        cfg.label
    )?;
    writeln!(f)?;

    for name in ["sun", "emb", "moon"] {
        writeln!(f, "pub mod {} {{", name)?;
        writeln!(f, "    pub const INIT: f64 = 0.0;")?;
        writeln!(f, "    pub const INTLEN: f64 = 1.0;")?;
        writeln!(f, "    pub const NCOEFF: usize = 0;")?;
        writeln!(f, "    pub const RSIZE: usize = 2;")?;
        writeln!(f, "    pub const N_RECORDS: usize = 1;")?;
        writeln!(f)?;
        writeln!(
            f,
            "    #[inline(never)]\n    pub fn record(i: usize) -> &'static [f64] {{\n        let _ = i;\n        super::unavailable_record(i)\n    }}",
        )?;
        writeln!(f, "}}")?;
        writeln!(f)?;
    }

    writeln!(
        f,
        "/// Pre-built segment descriptors for the {} bodies.",
        cfg.label
    )?;
    writeln!(
        f,
        "/// These require `SegmentDescriptor` to be in scope via `use`."
    )?;
    writeln!(f)?;

    writeln!(f, "pub const SUN: SegmentDescriptor = SegmentDescriptor {{")?;
    writeln!(f, "    init: qtty::Seconds::new(sun::INIT),")?;
    writeln!(f, "    intlen: qtty::Seconds::new(sun::INTLEN),")?;
    writeln!(f, "    ncoeff: sun::NCOEFF,")?;
    writeln!(f, "    n_records: sun::N_RECORDS,")?;
    writeln!(f, "    record_fn: sun::record,")?;
    writeln!(f, "}};")?;
    writeln!(f)?;

    writeln!(f, "pub const EMB: SegmentDescriptor = SegmentDescriptor {{")?;
    writeln!(f, "    init: qtty::Seconds::new(emb::INIT),")?;
    writeln!(f, "    intlen: qtty::Seconds::new(emb::INTLEN),")?;
    writeln!(f, "    ncoeff: emb::NCOEFF,")?;
    writeln!(f, "    n_records: emb::N_RECORDS,")?;
    writeln!(f, "    record_fn: emb::record,")?;
    writeln!(f, "}};")?;
    writeln!(f)?;

    writeln!(
        f,
        "pub const MOON: SegmentDescriptor = SegmentDescriptor {{"
    )?;
    writeln!(f, "    init: qtty::Seconds::new(moon::INIT),")?;
    writeln!(f, "    intlen: qtty::Seconds::new(moon::INTLEN),")?;
    writeln!(f, "    ncoeff: moon::NCOEFF,")?;
    writeln!(f, "    n_records: moon::N_RECORDS,")?;
    writeln!(f, "    record_fn: moon::record,")?;
    writeln!(f, "}};")?;
    writeln!(f)?;

    Ok(())
}

/// Generate the Rust source that includes the binary data and exposes typed accessors.
fn generate_rust_module(
    cfg: &DeConfig,
    bodies: &[(&str, spk::SegmentMeta)],
    path: &Path,
) -> anyhow::Result<()> {
    use std::io::Write;
    let mut f = std::fs::File::create(path)?;
    writeln!(f, "// AUTOGENERATED by build.rs — do not edit")?;
    writeln!(f, "// {} embedded Chebyshev coefficient data", cfg.label)?;
    writeln!(f)?;

    for (name, meta) in bodies {
        let upper = name.to_uppercase();
        let byte_count = meta.n_records * meta.rsize * 8;

        writeln!(f, "/// {} Chebyshev data for body `{}`.", cfg.label, name)?;
        writeln!(f, "pub mod {} {{", name)?;
        writeln!(f, "    /// Initial epoch (TDB seconds past J2000).")?;
        writeln!(f, "    pub const INIT: f64 = {:?};", meta.init)?;
        writeln!(f, "    /// Interval length (seconds).")?;
        writeln!(f, "    pub const INTLEN: f64 = {:?};", meta.intlen)?;
        writeln!(
            f,
            "    /// Number of Chebyshev coefficients per coordinate."
        )?;
        writeln!(f, "    pub const NCOEFF: usize = {};", meta.ncoeff)?;
        writeln!(f, "    /// Doubles per record (2 + 3 * NCOEFF).")?;
        writeln!(f, "    pub const RSIZE: usize = {};", meta.rsize)?;
        writeln!(f, "    /// Number of records.")?;
        writeln!(f, "    pub const N_RECORDS: usize = {};", meta.n_records)?;
        writeln!(f)?;
        writeln!(f, "    /// Total byte count of the binary data.")?;
        writeln!(f, "    const BYTE_COUNT: usize = {};", byte_count)?;
        writeln!(f)?;
        writeln!(
            f,
            "    /// 8-byte aligned wrapper for `include_bytes!` data."
        )?;
        writeln!(f, "    #[repr(C, align(8))]")?;
        writeln!(f, "    struct Aligned([u8; BYTE_COUNT]);")?;
        writeln!(f)?;
        writeln!(
            f,
            "    /// Raw coefficient data, embedded with 8-byte alignment."
        )?;
        writeln!(
            f,
            "    static DATA: &Aligned = &Aligned(*include_bytes!(concat!(env!(\"OUT_DIR\"), \"/{}_{}.bin\")));",
            cfg.prefix, name
        )?;
        writeln!(f)?;
        writeln!(
            f,
            "    /// Return the `i`-th record as a slice of `RSIZE` f64 values."
        )?;
        writeln!(f, "    #[inline]")?;
        writeln!(f, "    pub fn record(i: usize) -> &'static [f64] {{")?;
        writeln!(
            f,
            "        debug_assert!(i < N_RECORDS, \"{} {}: record index {{}} out of range (max {{}})\", i, N_RECORDS - 1);",
            cfg.label, upper
        )?;
        writeln!(f, "        let byte_offset = i * RSIZE * 8;")?;
        writeln!(f, "        let ptr = DATA.0.as_ptr();")?;
        writeln!(
            f,
            "        // SAFETY: `DATA` is #[repr(align(8))] so `ptr` is 8-byte aligned."
        )?;
        writeln!(
            f,
            "        // Each record starts at a multiple of RSIZE*8 bytes (also aligned)."
        )?;
        writeln!(
            f,
            "        // The binary data was written as native-endian f64 by the build script."
        )?;
        writeln!(f, "        unsafe {{")?;
        writeln!(
            f,
            "            std::slice::from_raw_parts(ptr.add(byte_offset) as *const f64, RSIZE)"
        )?;
        writeln!(f, "        }}")?;
        writeln!(f, "    }}")?;
        writeln!(f, "}}")?;
        writeln!(f)?;
    }

    // Emit SegmentDescriptor constants so the runtime data.rs needs no macro
    writeln!(
        f,
        "/// Pre-built segment descriptors for the {} bodies.",
        cfg.label
    )?;
    writeln!(
        f,
        "/// These require `SegmentDescriptor` to be in scope via `use`."
    )?;
    writeln!(f)?;
    writeln!(f, "/// Segment descriptor for the Sun (NAIF 10 → SSB).")?;
    writeln!(f, "pub const SUN: SegmentDescriptor = SegmentDescriptor {{")?;
    writeln!(f, "    init: qtty::Seconds::new(sun::INIT),")?;
    writeln!(f, "    intlen: qtty::Seconds::new(sun::INTLEN),")?;
    writeln!(f, "    ncoeff: sun::NCOEFF,")?;
    writeln!(f, "    n_records: sun::N_RECORDS,")?;
    writeln!(f, "    record_fn: sun::record,")?;
    writeln!(f, "}};")?;
    writeln!(f)?;
    writeln!(
        f,
        "/// Segment descriptor for the Earth-Moon Barycenter (NAIF 3 → SSB)."
    )?;
    writeln!(f, "pub const EMB: SegmentDescriptor = SegmentDescriptor {{")?;
    writeln!(f, "    init: qtty::Seconds::new(emb::INIT),")?;
    writeln!(f, "    intlen: qtty::Seconds::new(emb::INTLEN),")?;
    writeln!(f, "    ncoeff: emb::NCOEFF,")?;
    writeln!(f, "    n_records: emb::N_RECORDS,")?;
    writeln!(f, "    record_fn: emb::record,")?;
    writeln!(f, "}};")?;
    writeln!(f)?;
    writeln!(f, "/// Segment descriptor for the Moon (NAIF 301 → EMB).")?;
    writeln!(
        f,
        "pub const MOON: SegmentDescriptor = SegmentDescriptor {{"
    )?;
    writeln!(f, "    init: qtty::Seconds::new(moon::INIT),")?;
    writeln!(f, "    intlen: qtty::Seconds::new(moon::INTLEN),")?;
    writeln!(f, "    ncoeff: moon::NCOEFF,")?;
    writeln!(f, "    n_records: moon::N_RECORDS,")?;
    writeln!(f, "    record_fn: moon::record,")?;
    writeln!(f, "}};")?;
    writeln!(f)?;

    Ok(())
}

// ── BSP acquisition ─────────────────────────────────────────────────────

/// Ensure the BSP file exists in `data_dir`, downloading if necessary.
fn ensure_bsp(cfg: &DeConfig, data_dir: &Path) -> anyhow::Result<std::path::PathBuf> {
    std::fs::create_dir_all(data_dir)?;
    let bsp_path = data_dir.join(cfg.bsp_filename);

    // 1) Early return if already present with valid size
    if bsp_path.exists() {
        let meta = std::fs::metadata(&bsp_path)?;
        if meta.len() > cfg.min_bsp_size {
            eprintln!(
                "  {} BSP already cached ({:.1} MB)",
                cfg.label,
                meta.len() as f64 / 1_000_000.0
            );
            return Ok(bsp_path);
        }
        eprintln!(
            "  {} BSP exists but too small ({} B), re-acquiring...",
            cfg.label,
            meta.len()
        );
        std::fs::remove_file(&bsp_path)?;
    }

    // Download from NAIF
    eprintln!(
        "  Downloading {} BSP from NAIF ({})...",
        cfg.label, cfg.size_hint
    );
    eprintln!("  URL: {}", cfg.bsp_url);

    let result = download_with_curl(cfg, &bsp_path).or_else(|_| download_with_wget(cfg, &bsp_path));

    match result {
        Ok(()) => {
            let size = std::fs::metadata(&bsp_path)?.len();
            if size < cfg.min_bsp_size {
                anyhow::bail!(
                    "Downloaded file too small ({} bytes), expected >{}",
                    size,
                    cfg.min_bsp_size
                );
            }
            eprintln!("  Downloaded {:.1} MB", size as f64 / 1_000_000.0);
            Ok(bsp_path)
        }
        Err(e) => {
            anyhow::bail!(
                "Failed to download {} BSP. Ensure `curl` or `wget` is installed.\n\
                 You can also manually download:\n  {}\n\
                 and place it at:\n  {}\n\
                 Error: {}",
                cfg.label,
                cfg.bsp_url,
                bsp_path.display(),
                e
            );
        }
    }
}

fn download_with_curl(cfg: &DeConfig, dest: &Path) -> anyhow::Result<()> {
    eprintln!("  Trying curl...");
    let timeout_str = cfg.download_timeout.to_string();
    let status = Command::new("curl")
        .args([
            "--fail",
            "--silent",
            "--show-error",
            "--location",
            "--max-time",
            &timeout_str,
            "--output",
        ])
        .arg(dest.as_os_str())
        .arg(cfg.bsp_url)
        .status()?;

    if !status.success() {
        anyhow::bail!("curl exited with status {}", status);
    }
    Ok(())
}

fn download_with_wget(cfg: &DeConfig, dest: &Path) -> anyhow::Result<()> {
    eprintln!("  Trying wget...");
    let timeout_arg = format!("--timeout={}", cfg.download_timeout);
    let status = Command::new("wget")
        .args(["--quiet", &timeout_arg, "--output-document"])
        .arg(dest.as_os_str())
        .arg(cfg.bsp_url)
        .status()?;

    if !status.success() {
        anyhow::bail!("wget exited with status {}", status);
    }
    Ok(())
}