photom 0.4.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
//! A single astrometric and photometric measurement.
//!
//! This module defines two related types:
//!
//! - [`ObservationInput`] — an observation that has not yet been placed in a
//!   dataset.  It carries all measurement data but no vector position.  This
//!   is the type accepted by [`crate::observation_dataset::ObsDataset::push_observation`] and produced by
//!   all ingestion backends (ADES, MPC 80-column, Polars, DataFusion).
//!
//! - [`Observation`] — an observation that lives inside an
//!   [`ObsDataset`](crate::observation_dataset::ObsDataset).  It is identical
//!   to [`ObservationInput`] plus an [`ObsIndex`] that records its zero-based
//!   position in the dataset's storage vector.  The index is always set and
//!   correct: it is assigned atomically with insertion by `ObsDataset` and is
//!   never modified afterwards.
//!
//! ## Field access
//!
//! All fields of [`Observation`] are `pub(crate)` to prevent external
//! mutation.  Read-only access is provided through dedicated getter methods:
//! [`Observation::index`], [`Observation::id`], [`Observation::equ_coord`],
//! [`Observation::photometry`], and [`Observation::mjd_tt`].  The `observer`
//! field is accessed indirectly via
//! [`ObsDataset::get_observer`](crate::observation_dataset::ObsDataset::get_observer).

use std::fmt;

use crate::{
    MJDTT,
    coordinates::equatorial::EquCoord,
    observation_dataset::{ObsId, index::ObsIndex},
    observer::dataset::ObserverId,
    photometry::Photometry,
};

// ---------------------------------------------------------------------------
// ObservationInput
// ---------------------------------------------------------------------------

/// An observation that has not yet been placed in an [`ObsDataset`](crate::observation_dataset::ObsDataset).
///
/// `ObservationInput` carries all measurement data for a single
/// astrometric/photometric detection but does not have a vector position yet.
/// It is the type accepted by
/// [`ObsDataset::push_observation`](crate::observation_dataset::ObsDataset::push_observation)
/// and is produced by all ingestion backends (ADES, MPC 80-column, Polars,
/// DataFusion).
///
/// Once inserted into a dataset, `ObservationInput` is converted to
/// [`Observation`] with an immutable [`ObsIndex`] assigned by the dataset.
#[derive(Debug, Clone)]
pub struct ObservationInput {
    /// Unique identifier for this observation within its dataset.
    pub id: ObsId,

    /// Equatorial sky coordinates (right ascension and declination) with
    /// their associated measurement uncertainties, all in **radians**.
    pub equ_coord: EquCoord,

    /// Photometric measurement: apparent magnitude, its uncertainty, and the
    /// filter through which the observation was taken.
    pub photometry: Photometry,

    /// Detection epoch (Modified Julian Date, Terrestrial Time, **days**).
    pub mjd_tt: MJDTT,

    /// Reference to the observatory that recorded this observation, or `None`
    /// when the observer is unknown.
    pub observer: Option<ObserverId>,
}

impl ObservationInput {
    /// Create a new `ObservationInput` with the specified fields.
    ///
    /// The `observer` field is optional; pass `None` when the observer is
    /// unknown.  Use
    /// [`ObsDataset::get_observer`](crate::observation_dataset::ObsDataset::get_observer)
    /// to resolve an [`ObserverId`] to a full [`crate::observer::Observer`] value
    /// after insertion.
    ///
    /// # Parameters
    ///
    /// - `id` — unique identifier for this observation within its dataset
    ///   (corresponds to the `id` column of the source `DataFrame`).
    /// - `equ_coord` — equatorial sky coordinates (right ascension and
    ///   declination) with their associated measurement uncertainties, all
    ///   in **radians**.
    /// - `photometry` — photometric measurement: apparent magnitude, its
    ///   uncertainty, and the filter through which the observation was taken.
    /// - `mjd_tt` — detection epoch as a Modified Julian Date in Terrestrial
    ///   Time, expressed in **days**.
    /// - `observer` — optional reference to the observatory that recorded
    ///   this observation.
    ///
    /// # Returns
    ///
    /// A new `ObservationInput` ready to be inserted into an
    /// [`ObsDataset`](crate::observation_dataset::ObsDataset).
    pub fn new(
        id: ObsId,
        equ_coord: EquCoord,
        photometry: Photometry,
        mjd_tt: MJDTT,
        observer: Option<ObserverId>,
    ) -> Self {
        Self {
            id,
            equ_coord,
            photometry,
            mjd_tt,
            observer,
        }
    }
}

// ---------------------------------------------------------------------------
// Observation
// ---------------------------------------------------------------------------

/// A single astrometric and photometric measurement placed inside an
/// [`ObsDataset`](crate::observation_dataset::ObsDataset).
///
/// Each `Observation` bundles the equatorial sky position, the photometric
/// measurement, the detection epoch, an optional reference to the observatory
/// that recorded it, and — crucially — the **zero-based index** of its
/// position in the parent dataset's storage vector.
///
/// `Observation` values are created exclusively by `ObsDataset`; they cannot
/// be constructed directly by external code.  This invariant guarantees that
/// `index` is always consistent with the observation's actual position.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Observation {
    /// Zero-based index of this observation in the parent dataset's storage
    /// vector.  Assigned atomically by `ObsDataset` during insertion and
    /// never modified afterwards.
    index: ObsIndex,

    /// Unique identifier for this observation within its dataset.
    ///
    /// Corresponds to the `id` column of the source `DataFrame`.
    pub(crate) id: ObsId,

    /// Equatorial sky coordinates (right ascension and declination) with
    /// their associated measurement uncertainties, all in **radians**.
    pub(crate) equ_coord: EquCoord,

    /// Photometric measurement: apparent magnitude, its uncertainty, and the
    /// filter through which the observation was taken.
    pub(crate) photometry: Photometry,

    /// Detection epoch (Modified Julian Date, Terrestrial Time, **days**).
    pub(crate) mjd_tt: MJDTT,

    /// Reference to the observatory that recorded this observation, or `None`
    /// when the observer is unknown.
    ///
    /// Use [`ObsDataset::get_observer`](crate::observation_dataset::ObsDataset::get_observer)
    /// to resolve this identifier to a full `Observer` value.
    pub(crate) observer: Option<ObserverId>,
}

/// Implement equality and ordering based on the unique identifier and detection epoch.
impl PartialEq for Observation {
    fn eq(&self, other: &Self) -> bool {
        self.id == other.id
    }
}

impl Eq for Observation {}

/// Implement ordering based on detection epoch (MJDTT), then by unique identifier (ObsId) to break ties.
impl Ord for Observation {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.mjd_tt
            .total_cmp(&other.mjd_tt)
            .then(self.id.cmp(&other.id))
    }
}

/// Implement partial ordering consistent with the total ordering defined in `Ord`.
impl PartialOrd for Observation {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

// ---------------------------------------------------------------------------
// Observation Display
// ---------------------------------------------------------------------------

/// Formats the observation in either compact or verbose form.
///
/// # Modes
///
/// - **Verbose** `{}`:
///
/// ```text
/// Observation
///   Epoch   : 60310.123456 MJD (TT)
///   Position: HHh MMm SS.sss s (+DD° MM' SS.sss") (RA/Dec decimal) ± ...
///   Photometry: 18.432 ± 0.023 mag [r']
///   Observer: MPC:I41
/// ```
///
/// - **Compact** `{:#}`:
///
/// ```text
/// [60310.123456 MJD] HHh MMm SS.sss s +DDd MMm SS.sss s | 18.432 ± 0.023 mag [r']
/// ```
impl fmt::Display for Observation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if f.alternate() {
            // ------------------------------------------------------------------
            // Compact form: {:#}
            // ------------------------------------------------------------------
            write!(
                f,
                "[{:.6} MJD] {} | {}",
                self.mjd_tt, self.equ_coord, self.photometry
            )
        } else {
            // ------------------------------------------------------------------
            // Verbose form: {}
            // ------------------------------------------------------------------
            writeln!(f, "Observation")?;
            writeln!(f, "  Epoch     : {:.6} MJD (TT)", self.mjd_tt)?;
            writeln!(f, "  Position  : {}", self.equ_coord)?;
            writeln!(f, "  Photometry: {}", self.photometry)?;
            match &self.observer {
                Some(obs) => write!(f, "  Observer  : {obs}"),
                None => write!(f, "  Observer  : unknown"),
            }
        }
    }
}

impl Observation {
    /// Place an [`ObservationInput`] into a dataset by attaching its vector position.
    ///
    /// This is the only constructor for `Observation`.  It is `pub(crate)` so
    /// that only `ObsDataset` methods can create placed observations, preserving
    /// the invariant that `index` always matches the observation's actual position.
    pub(crate) fn place(input: ObservationInput, index: ObsIndex) -> Self {
        Self {
            index,
            id: input.id,
            equ_coord: input.equ_coord,
            photometry: input.photometry,
            mjd_tt: input.mjd_tt,
            observer: input.observer,
        }
    }

    /// Re-assign the vector position of this observation, consuming `self` and
    /// returning a new `Observation` with `index` set to `new_idx`.
    ///
    /// Used by [`crate::observation_dataset::ObsDataset`] when merging datasets:
    /// observations that were valid in their source dataset are re-placed at
    /// their new position in the merged vector.  All measurement fields are
    /// preserved unchanged.
    pub(crate) fn reindex(self, new_idx: ObsIndex) -> Self {
        Self {
            index: new_idx,
            ..self
        }
    }

    /// Return the zero-based position of this observation in its parent
    /// dataset's storage vector.
    ///
    /// # Returns
    ///
    /// The [`ObsIndex`] (a `usize`) that was assigned during insertion into
    /// the dataset.
    pub fn index(&self) -> ObsIndex {
        self.index
    }

    /// Return a reference to the unique identifier of this observation.
    ///
    /// The identifier corresponds to the value in the `id` column of the
    /// source `DataFrame` and is unique within a given `ObsDataset`.
    ///
    /// # Returns
    ///
    /// A shared reference to the `ObsId` (`u64`) of this observation.
    pub fn id(&self) -> &ObsId {
        &self.id
    }

    /// Return a reference to the observer identifier for this observation, if any.
    ///
    /// # Returns
    ///
    /// `Some(&ObserverId)` when an observer is associated with this observation;
    /// `None` when the observer is unknown.
    pub fn observer_id(&self) -> Option<&ObserverId> {
        self.observer.as_ref()
    }

    /// Return a reference to the equatorial sky coordinates of this observation.
    ///
    /// The coordinates include right ascension and declination together with their
    /// associated measurement uncertainties, all expressed in **radians**.
    ///
    /// # Returns
    ///
    /// A shared reference to the `EquCoord` of this observation.
    pub fn equ_coord(&self) -> &EquCoord {
        &self.equ_coord
    }

    /// Return a reference to the photometric measurement of this observation.
    ///
    /// The `Photometry` value contains the apparent magnitude, its uncertainty, and the
    /// bandpass filter label.
    ///
    /// # Returns
    ///
    /// A shared reference to the `Photometry` of this observation.
    pub fn photometry(&self) -> &Photometry {
        &self.photometry
    }

    /// Return the detection epoch as a Modified Julian Date in Terrestrial Time.
    ///
    /// # Returns
    ///
    /// The epoch as an `MJDTT` value (an `f64`, in days).
    pub fn mjd_tt(&self) -> MJDTT {
        self.mjd_tt
    }
}

#[cfg(test)]
mod observation_tests {
    use super::*;
    use crate::{
        coordinates::equatorial::EquCoord,
        observation_dataset::index::ObsIndex,
        observer::dataset::ObserverId,
        photometry::{Filter, Photometry},
    };

    fn make_photometry() -> Photometry {
        Photometry {
            magnitude: 15.0,
            error: 0.1,
            filter: Filter::String("V".to_string()),
        }
    }

    fn make_input(id: u64, mjd: f64) -> ObservationInput {
        ObservationInput::new(
            id,
            EquCoord::new(0.5, 1e-5, 0.2, 1e-5),
            make_photometry(),
            mjd,
            None,
        )
    }

    fn make_obs(id: u64, mjd: f64) -> Observation {
        Observation::place(make_input(id, mjd), 0)
    }

    // ------------------------------------------------------------------
    // Constructor
    // ------------------------------------------------------------------

    #[test]
    fn place_sets_correct_index() {
        let input = make_input(42, 60000.0);
        let obs = Observation::place(input, 7);
        assert_eq!(obs.index(), 7);
    }

    #[test]
    fn getters_return_correct_values() {
        let obs = make_obs(99, 60123.5);
        assert_eq!(*obs.id(), 99);
        assert_eq!(obs.mjd_tt(), 60123.5);
        assert_eq!(obs.equ_coord().ra, 0.5);
        assert_eq!(obs.photometry().magnitude, 15.0);
    }

    #[test]
    fn observer_field_preserved() {
        let input = ObservationInput::new(
            1,
            EquCoord::new(0.0, 1e-5, 0.0, 1e-5),
            make_photometry(),
            60000.0,
            Some(ObserverId::MpcCode(*b"T05")),
        );
        let obs = Observation::place(input, 0);
        // We can't directly inspect the observer field (pub(crate)), but we can
        // verify the observation was constructed without panic.
        assert_eq!(*obs.id(), 1);
    }

    // ------------------------------------------------------------------
    // Equality (based on id)
    // ------------------------------------------------------------------

    #[test]
    fn eq_by_id() {
        let a = make_obs(7, 60000.0);
        let b = make_obs(7, 60001.0); // different epoch, same id
        assert_eq!(a, b);
    }

    #[test]
    fn ne_different_ids() {
        let a = make_obs(1, 60000.0);
        let b = make_obs(2, 60000.0);
        assert_ne!(a, b);
    }

    // ------------------------------------------------------------------
    // Ordering (based on mjd_tt, then id)
    // ------------------------------------------------------------------

    #[test]
    fn ord_by_epoch() {
        let earlier = make_obs(1, 59000.0);
        let later = make_obs(2, 60000.0);
        assert!(earlier < later);
    }

    #[test]
    fn ord_tie_broken_by_id() {
        let a = make_obs(1, 60000.0);
        let b = make_obs(2, 60000.0);
        assert!(a < b);
    }

    // ------------------------------------------------------------------
    // Clone
    // ------------------------------------------------------------------

    #[test]
    fn clone_is_equal() {
        let obs = make_obs(5, 60000.0);
        let cloned = obs.clone();
        assert_eq!(obs, cloned);
        assert_eq!(obs.mjd_tt(), cloned.mjd_tt());
    }

    // ------------------------------------------------------------------
    // Index is ObsIndex (non-optional)
    // ------------------------------------------------------------------

    #[test]
    fn index_is_set_by_place() {
        let input = make_input(1, 60000.0);
        let obs = Observation::place(input, ObsIndex::from(3usize));
        assert_eq!(obs.index(), ObsIndex::from(3usize));
    }
}