photom 0.1.0

Rust library for loading, structuring and querying astronomical observation datasets — with trajectory grouping, multi-observer support, and efficient lookups.
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
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
//! Astrometric error models for observatory weighting.
//!
//! This module provides tools to load and query astrometric bias/RMS tables
//! used in orbit determination.  Each table associates an observatory (by
//! [`MpcCode`]) and a star catalog code (by [`CatalogCode`]) with a pair of
//! RMS values `(rms_ra, rms_dec)` in arcseconds, as recommended in the
//! literature.
//!
//! Three models are supported, selectable via [`ObsErrorModel`]:
//!
//! - `ObsErrorModel::FCCT14` — Farnocchia, Chesley, Chamberlin & Tholen (2014)
//! - `ObsErrorModel::CBM10` — Chesley, Baer & Monet (2010)
//! - `ObsErrorModel::VFCC17` — Vereš, Farnocchia, Chesley & Chamberlin (2017)
//!
//! ## Typical usage
//!
//! 1. Choose a model variant (e.g. `ObsErrorModel::FCCT14`).
//! 2. Call [`ObsErrorModel::read_error_model_file`] to parse the bundled
//!    reference file into an [`ErrorModelData`] map.
//! 3. Query the map with [`get_bias_rms`] to obtain astrometric uncertainties
//!    for weighting residuals.
//!
//! ## Public items
//!
//! | Item | Kind | Description |
//! |------|------|-------------|
//! | [`CatalogCode`] | type alias | Single-character star catalog identifier |
//! | [`ErrorModelData`] | type alias | Map from `(MpcCode, CatalogCode)` to `(rms_ra, rms_dec)` |
//! | [`ObsErrorModel`] | enum | Supported astrometric error model variants |
//! | [`ErrorModelParseError`] | enum | Errors arising when parsing a model file |
//! | [`get_bias_rms`] | fn | Query an [`ErrorModelData`] map with cascade fallbacks |
//!
//! ## References
//!
//! - Farnocchia, D., Chesley, S. R., Chamberlin, A. B., & Tholen, D. J. (2014)
//! - Chesley, S. R., Baer, J., & Monet, D. G. (2010)
//! - Vereš, P., Farnocchia, D., Chesley, S. R., & Chamberlin, A. B. (2017)
pub mod model_correction;
mod vfcc17;
pub use model_correction::ModelCorrection;

use std::{collections::HashMap, fmt, str::FromStr};

use nom::{
    IResult, Parser,
    bytes::complete::{tag, take_while1},
    character::complete::{char, multispace0},
    combinator::{map, opt},
    number::complete::float,
    sequence::{preceded, separated_pair, terminated},
};

use thiserror::Error;

use vfcc17::parse_vfcc17_line;

use crate::observer::mpc::MpcCode;

/// A single-character star catalog identifier (e.g. `"c"`, `"U"`, `"*"`).
///
/// Used as the second key component in [`ErrorModelData`].
pub type CatalogCode = String;

/// Map from `(MpcCode, CatalogCode)` to `(rms_ra, rms_dec)` in arcseconds.
///
/// Built by [`ObsErrorModel::read_error_model_file`] and queried with
/// [`get_bias_rms`].
pub type ErrorModelData = HashMap<(MpcCode, CatalogCode), (f32, f32)>;

/// Supported astrometric error model variants.
///
/// Each variant corresponds to a published astrometric bias/RMS model
/// distributed as a bundled reference file.  Call
/// [`ObsErrorModel::read_error_model_file`] to parse the file for the
/// selected variant into an [`ErrorModelData`] map.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ObsErrorModel {
    /// Farnocchia, Chesley, Chamberlin & Tholen (2014).
    FCCT14,
    /// Chesley, Baer & Monet (2010).
    CBM10,
    /// Vereš, Farnocchia, Chesley & Chamberlin (2017).
    VFCC17,
}

impl fmt::Display for ObsErrorModel {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            ObsErrorModel::FCCT14 => "FCCT14 (Farnocchia et al. 2014)",
            ObsErrorModel::CBM10 => "CBM10  (Chesley, Baer & Monet 2010)",
            ObsErrorModel::VFCC17 => "VFCC17 (Vereš et al. 2017)",
        };
        write!(f, "{s}")
    }
}

/// Bundled FCCT14 rules file, included at compile time.
static FCCT14_RULES: &str = include_str!("data_models/fcct14.rules");
/// Bundled CBM10 rules file, included at compile time.
static CBM10_RULES: &str = include_str!("data_models/cbm10.rules");
/// Bundled VFCC17 rules file, included at compile time.
static VFCC17_RULES: &str = include_str!("data_models/vfcc17.rules");

/// Internal parse result: a list of `((station_str, catalog_code), (rms_ra, rms_dec))`.
/// Station codes are kept as `String` here and converted to `MpcCode` ([u8; 3]) in
/// [`parse_full_file`] so individual parsers stay simple.
pub(in crate::observer::error_model) type ParseResult<'a> =
    IResult<&'a str, Vec<((String, CatalogCode), (f32, f32))>>;

// ---------------------------------------------------------------------------
// Low-level parsers — FCCT14 / CBM10
// ---------------------------------------------------------------------------

/// Parse an alphanumeric station code followed by `:` (e.g. `"699:"`, `"ALL:"`).
///
/// # Arguments
///
/// - `input` — the parser input slice starting at a station code.
///
/// # Returns
///
/// The station code string slice (without the trailing `:`), together with
/// the unconsumed input tail.
fn parse_station(input: &str) -> IResult<&str, &str> {
    terminated(take_while1(|c: char| c.is_alphanumeric()), char(':')).parse(input)
}

/// Parse the optional `c=` prefix followed by one or more catalog letters or `*`.
///
/// Handles both bare catalog chars (e.g. `699:c  @ …`) and the explicit
/// `c=` prefix (e.g. `ALL: c=cd @ …`).  Each ASCII character in the matched
/// run is expanded into its own [`CatalogCode`] string.
///
/// # Arguments
///
/// - `input` — the parser input slice positioned at optional whitespace before
///   the optional `c=` prefix and catalog characters.
///
/// # Returns
///
/// A `Vec<String>` where each element is a single-character catalog code,
/// together with the unconsumed input tail.
fn parse_catalog_codes(input: &str) -> IResult<&str, Vec<String>> {
    preceded(
        (multispace0, opt(tag("c="))),
        map(
            take_while1(|c: char| c.is_alphabetic() || c == '*'),
            // Each ASCII char becomes its own catalog code string.
            // We do a single slice walk instead of allocating one String per char
            // by using byte offsets — all catalog chars are guaranteed ASCII.
            |s: &str| {
                s.as_bytes()
                    .iter()
                    .map(|&b| String::from(b as char))
                    .collect()
            },
        ),
    )
    .parse(input)
}

/// Parse `@ rms_ra, rms_dec`, consuming any leading whitespace before the `@`.
///
/// # Arguments
///
/// - `input` — the parser input slice positioned at optional whitespace before
///   the `@` marker.
///
/// # Returns
///
/// A tuple `(rms_ra, rms_dec)` as `f32` values, together with the unconsumed
/// input tail.
fn parse_rms_values(input: &str) -> IResult<&str, (f32, f32)> {
    preceded(
        (multispace0, char('@')),
        separated_pair(
            preceded(multispace0, float),
            char(','),
            preceded(multispace0, float),
        ),
    )
    .parse(input)
}

/// Parse one FCCT14/CBM10 rule line, stripping inline comments (`! …`).
///
/// Expects the format `<station>: [c=]<catalogs> @ <rms_ra>, <rms_dec>`.
/// The portion from `!` to end-of-line is discarded before parsing.
///
/// # Arguments
///
/// - `input` — a single rule line from the FCCT14 or CBM10 rules file.
///
/// # Returns
///
/// A flat list of `((station, catalog), (rms_ra, rms_dec))` — one entry per
/// catalog character in the rule — together with the unconsumed input tail.
fn parse_full_line(input: &str) -> ParseResult<'_> {
    // Strip everything from `!` onward (inline comment), then trim whitespace.
    let line = match input.find('!') {
        Some(pos) => input[..pos].trim(),
        None => input.trim(),
    };

    map(
        (parse_station, parse_catalog_codes, parse_rms_values),
        |(station, catalogs, (rmsa, rmsd))| {
            catalogs
                .into_iter()
                .map(|cat| ((station.to_string(), cat), (rmsa, rmsd)))
                .collect()
        },
    )
    .parse(line)
}

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/// Errors that can arise when parsing an astrometric error model file.
#[derive(Debug, Error, Clone)]
pub enum ErrorModelParseError {
    /// A `nom` parser failed on the given line content.
    #[error("Nom parsing error on: {0}")]
    NomParsingError(String),

    /// A station code in the file is not exactly three ASCII bytes.
    #[error("Station code is not exactly 3 ASCII bytes: {0:?}")]
    InvalidStationCode(String),
}

// ---------------------------------------------------------------------------
// File-level parser
// ---------------------------------------------------------------------------

/// Convert a three-character ASCII station string (e.g. `"ALL"`, `"699"`) into
/// a [`MpcCode`] (`[u8; 3]`).
///
/// # Errors
///
/// Returns [`ErrorModelParseError::InvalidStationCode`] if `s` is not exactly
/// three bytes.
fn str_to_mpc_code(s: &str) -> Result<MpcCode, ErrorModelParseError> {
    s.as_bytes()
        .try_into()
        .map_err(|_| ErrorModelParseError::InvalidStationCode(s.to_string()))
}

/// Parse an entire rules file into an [`ErrorModelData`] map.
///
/// Blank lines and comment-only lines (starting with `!`) are skipped.
/// Each remaining line is parsed with `parse_line`; on error the offending
/// line text is reported in the returned [`ErrorModelParseError`].
///
/// # Arguments
///
/// - `file` — the full text content of the rules file.
/// - `parse_line` — line-level parser function (e.g. [`parse_full_line`] or
///   [`parse_vfcc17_line`]).
///
/// # Returns
///
/// An [`ErrorModelData`] map on success.
///
/// # Errors
///
/// Returns [`ErrorModelParseError::NomParsingError`] if any non-blank,
/// non-comment line fails to parse, or
/// [`ErrorModelParseError::InvalidStationCode`] if a station code is not
/// exactly three bytes.
fn parse_full_file<F>(file: &str, parse_line: F) -> Result<ErrorModelData, ErrorModelParseError>
where
    F: Fn(&str) -> ParseResult,
{
    // Single-pass: parse → flatten entries → convert station → insert.
    // Avoids the intermediate Vec<Vec<_>> allocation of the collect+flatten pattern.
    file.lines()
        .filter(|line| {
            let t = line.trim();
            !t.is_empty() && !t.starts_with('!')
        })
        .try_fold(ErrorModelData::default(), |mut map, line| {
            let (_, pairs) = parse_line(line)
                .map_err(|_| ErrorModelParseError::NomParsingError(line.to_string()))?;
            for ((station, cat), rms) in pairs {
                let code = str_to_mpc_code(&station)?;
                map.insert((code, cat), rms);
            }
            Ok(map)
        })
}

// ---------------------------------------------------------------------------
// ObsErrorModel impl
// ---------------------------------------------------------------------------

impl ObsErrorModel {
    /// Load the internal RMS/bias table for this astrometric error model.
    ///
    /// Parses the reference file for the selected variant and returns an
    /// [`ErrorModelData`] map ready to be queried with [`get_bias_rms`].
    ///
    /// # Returns
    ///
    /// An [`ErrorModelData`] map containing all `(MpcCode, CatalogCode)` →
    /// `(rms_ra, rms_dec)` entries from the bundled reference file.
    ///
    /// # Errors
    ///
    /// Returns [`ErrorModelParseError`] if any line in the reference file
    /// cannot be parsed (this should never happen with the bundled files).
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// let data = ObsErrorModel::FCCT14.read_error_model_file().unwrap();
    /// println!("{} entries", data.len());
    /// ```
    pub fn read_error_model_file(&self) -> Result<ErrorModelData, ErrorModelParseError> {
        match self {
            ObsErrorModel::FCCT14 => parse_full_file(FCCT14_RULES, parse_full_line),
            ObsErrorModel::CBM10 => parse_full_file(CBM10_RULES, parse_full_line),
            ObsErrorModel::VFCC17 => parse_full_file(VFCC17_RULES, parse_vfcc17_line),
        }
    }
}

/// Parse an [`ObsErrorModel`] from its canonical string name.
///
/// Recognised names (case-sensitive): `"FCCT14"`, `"CBM10"`, `"VFCC17"`.
///
/// # Arguments
///
/// - `s` — the string to parse (e.g. `"FCCT14"`).
///
/// # Returns
///
/// `Ok(ObsErrorModel)` for a recognised name.
///
/// # Errors
///
/// Returns [`ErrorModelParseError::NomParsingError`] if `s` does not match
/// any known model name.
impl FromStr for ObsErrorModel {
    type Err = ErrorModelParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "FCCT14" => Ok(ObsErrorModel::FCCT14),
            "CBM10" => Ok(ObsErrorModel::CBM10),
            "VFCC17" => Ok(ObsErrorModel::VFCC17),
            _ => Err(ErrorModelParseError::NomParsingError(format!(
                "Unknown error model: {s}"
            ))),
        }
    }
}

/// Infallible conversion from `&str` to [`ObsErrorModel`] by delegating to [`FromStr`].
///
/// Recognised names (case-sensitive): `"FCCT14"`, `"CBM10"`, `"VFCC17"`.
///
/// # Arguments
///
/// - `value` — the string slice to convert (e.g. `"VFCC17"`).
///
/// # Returns
///
/// `Ok(ObsErrorModel)` for a recognised name.
///
/// # Errors
///
/// Returns [`ErrorModelParseError::NomParsingError`] if `value` is not a
/// recognised model name.
impl TryFrom<&str> for ObsErrorModel {
    type Error = ErrorModelParseError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        value.parse()
    }
}

// ---------------------------------------------------------------------------
// Public query helper
// ---------------------------------------------------------------------------

/// Retrieve the astrometric `(rms_ra, rms_dec)` for a given observatory and
/// star catalog code, with a cascade of fallbacks.
///
/// Lookup priority:
///
/// 1. Exact match: `(mpc_code, catalog_code)`
/// 2. `(mpc_code, "e")` — generic entry for that observatory
/// 3. `(mpc_code, "c")` — catalog default for that observatory
/// 4. `(*b"ALL", catalog_code)` — global entry for this catalog
/// 5. `(*b"ALL", "e")` — global `"e"` fallback
/// 6. `(*b"ALL", "c")` — global catalog fallback
///
/// # Arguments
///
/// - `error_model` — preloaded [`ErrorModelData`] table.
/// - `mpc_code` — MPC observatory code as `[u8; 3]` (e.g. `*b"699"`).
/// - `catalog_code` — star catalog identifier (e.g. `"c"`, `"U"`).
///
/// # Returns
///
/// `Some((rms_ra, rms_dec))` on a match, `None` if no entry is found at any
/// fallback level.
pub fn get_bias_rms(
    error_model: &ErrorModelData,
    mpc_code: MpcCode,
    catalog_code: &str,
) -> Option<(f32, f32)> {
    // Build lookup keys inline to avoid heap-allocating the catalog fallbacks
    // on every call; the compiler will inline these small closures.
    let lookup = |code: MpcCode, cat: &str| -> Option<(f32, f32)> {
        error_model.get(&(code, cat.to_string())).copied()
    };

    lookup(mpc_code, catalog_code)
        .or_else(|| lookup(mpc_code, "e"))
        .or_else(|| lookup(mpc_code, "c"))
        .or_else(|| lookup(*b"ALL", catalog_code))
        .or_else(|| lookup(*b"ALL", "e"))
        .or_else(|| lookup(*b"ALL", "c"))
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    /// Verifies that `parse_full_line` correctly parses representative FCCT14/CBM10 rule lines.
    ///
    /// Covers three patterns:
    /// - multi-catalog `c=` prefix with inline `!` comment (`ALL: c=eqru @ …`),
    /// - multi-catalog `c=` prefix with inline comment and space padding
    ///   (`ALL: c=cd @ … ! …`),
    /// - bare single-catalog code without `c=` prefix (`699:c @ …`).
    #[test]
    fn test_parse_fcct14_line() {
        let line = "ALL:  c=eqru @ 0.33, 0.30";
        let result = parse_full_line(line);
        assert!(result.is_ok());
        let ((mpc_code, catalog_code), (rmsa, rmsd)) = &result.unwrap().1[0];
        assert_eq!(mpc_code, "ALL");
        assert_eq!(catalog_code, "e");
        assert_eq!(*rmsa, 0.33);
        assert_eq!(*rmsd, 0.3);

        let line = "ALL:  c=cd   @ 0.51, 0.40 ! CBM Generic Catalog weights";
        let result = parse_full_line(line);
        assert!(result.is_ok());
        let ((mpc_code, catalog_code), (rmsa, rmsd)) = &result.unwrap().1[0];
        assert_eq!(mpc_code, "ALL");
        assert_eq!(catalog_code, "c");
        assert_eq!(*rmsa, 0.51);
        assert_eq!(*rmsd, 0.4);

        let line = "699:c  @ 0.93, 0.78";
        let result = parse_full_line(line);
        assert!(result.is_ok());
        let ((mpc_code, catalog_code), (rmsa, rmsd)) = &result.unwrap().1[0];
        assert_eq!(mpc_code, "699");
        assert_eq!(catalog_code, "c");
        assert_eq!(*rmsa, 0.93);
        assert_eq!(*rmsd, 0.78);
    }

    /// Verifies that `read_error_model_file` succeeds and returns a non-empty map
    /// for all three bundled model variants (FCCT14, CBM10, and VFCC17).
    #[test]
    fn test_read_error_model_file() {
        let result = ObsErrorModel::FCCT14.read_error_model_file();
        assert!(result.is_ok());
        assert!(!result.unwrap().is_empty());

        let result = ObsErrorModel::CBM10.read_error_model_file();
        assert!(result.is_ok());
        assert!(!result.unwrap().is_empty());

        let result = ObsErrorModel::VFCC17.read_error_model_file();
        assert!(result.is_ok());
        assert!(!result.unwrap().is_empty());
    }

    /// Verifies that `get_bias_rms` returns the expected RMS values for known entries
    /// in all three bundled model files (FCCT14, CBM10, and VFCC17), covering both
    /// the `*b"ALL"` wildcard key and a specific observatory code (`*b"699"`).
    #[test]
    fn test_get_bias_rms() {
        let model = ObsErrorModel::FCCT14.read_error_model_file().unwrap();
        let (rmsa, rmsd) = get_bias_rms(&model, *b"ALL", "c").unwrap();
        assert_eq!(rmsa, 0.51);
        assert_eq!(rmsd, 0.4);

        let (rmsa, rmsd) = get_bias_rms(&model, *b"699", "c").unwrap();
        assert_eq!(rmsa, 0.47);
        assert_eq!(rmsd, 0.39);

        let model = ObsErrorModel::CBM10.read_error_model_file().unwrap();
        let (rmsa, rmsd) = get_bias_rms(&model, *b"ALL", "c").unwrap();
        assert_eq!(rmsa, 0.5);
        assert_eq!(rmsd, 0.5);

        let (rmsa, rmsd) = get_bias_rms(&model, *b"699", "c").unwrap();
        assert_eq!(rmsa, 0.84);
        assert_eq!(rmsd, 0.81);

        let model = ObsErrorModel::VFCC17.read_error_model_file().unwrap();
        let (rmsa, rmsd) = get_bias_rms(&model, *b"ALL", "U").unwrap();
        assert_eq!(rmsa, 0.6);
        assert_eq!(rmsd, 0.6);

        let (rmsa, rmsd) = get_bias_rms(&model, *b"699", "*").unwrap();
        assert_eq!(rmsa, 0.8);
        assert_eq!(rmsd, 0.8);
    }
}