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
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
//! Rust library for loading, structuring, and querying astronomical observation datasets —
//! with trajectory grouping, multi-observer support, and efficient lookups.
//!
//! `photom` provides a type-safe pipeline for ingesting astrometric and photometric
//! measurements, associating them with ground-based observatories, and grouping them into
//! trajectories of moving objects.  The library is designed around one primary dataset
//! type — [`observation_dataset::ObsDataset`] for flat observation collections.
//!
//! # Features
//!
//! - **Polars ingestion** (`polars` feature) — load observations from a `DataFrame` or
//!   `LazyFrame` with full schema validation.
//! - **Parallel iteration** (`parallel` feature) — process observations, nights, and
//!   trajectories in parallel via [rayon](https://docs.rs/rayon) with zero data copying.
//! - **ADES ingestion** (`ades` feature) — load observations directly from MPC ADES XML
//!   files ([`observation_dataset::ObsDataset::from_ades`]), supporting both structured
//!   (`obsBlock`/`obsContext`) and flat formats, with automatic MPC observer resolution.
//! - **MPC 80-column ingestion** (`mpc_80_col` feature) — load observations from the
//!   classic MPC fixed-width 80-column ASCII format
//!   ([`observation_dataset::ObsDataset::from_mpc_80_col`]), with automatic trajectory
//!   grouping and nom-based field parsing.
//! - **Parquet ingestion via DataFusion** (`datafusion` feature) — load observations from
//!   any Parquet file reachable by URI (`file://`, `http://`, `https://`, `hdfs://`) using
//!   Apache Arrow / DataFusion ([`observation_dataset::ObsDataset::from_parquet_uri`] and
//!   its async counterpart), with automatic contiguous index optimisation.
//! - **Serialisation / deserialisation** (`serde` feature) — persist and restore an
//!   [`observation_dataset::ObsDataset`] (and all constituent types) via
//!   [serde](https://docs.rs/serde).  Runtime-only state — the
//!   lazy MPC observatory cache, and all derived index maps — is excluded from the
//!   serialised form and rebuilt transparently on deserialisation.
//! - **Multi-observer support** — MPC observatory codes (resolved lazily from the MPC
//!   website), custom geodetic sites (interned and deduplicated), or unknown observer.
//! - **Trajectory grouping** — group observations by a `traj_id` column; supports both
//!   integer (`UInt64`) and string (`String`) identifiers.
//! - **Three astrometric error models** — FCCT14, CBM10, and VFCC17, used to assign
//!   measurement accuracies to MPC-coded observatories.
//!
//! # Modules
//!
//! | Module | Description |
//! |--------|-------------|
//! | [`coordinates`] | Celestial coordinate types and coordinate-system conversions |
//! | [`coordinates::equatorial`] | [`coordinates::equatorial::EquCoord`] — equatorial sky position (RA, Dec) with 1-σ uncertainties, Vincenty angular separation, spherical midpoint, and covariance propagation |
//! | [`coordinates::cartesian`] | [`coordinates::cartesian::CartesianCoord`] / [`coordinates::cartesian::CartesianCoordCov`] — Cartesian unit-sphere position with optional 3×3 covariance and inverse propagation back to equatorial coordinates |
//! | [`coordinates::cov2`] | [`coordinates::cov2::Cov2`] — symmetric 2×2 covariance matrix for tangent-plane error ellipses; eigenvalues, Mahalanobis distance, and isotropic inflation |
//! | [`coordinates::gnomonic_projection`] | [`coordinates::gnomonic_projection::TangentPlane`] / [`coordinates::gnomonic_projection::TangentPoint`] / [`coordinates::gnomonic_projection::TangentVec`] — gnomonic (tangent-plane) projection between equatorial sky coordinates and a local 2-D Cartesian frame |
//! | [`photometry`] | Photometric measurement types: apparent magnitude, uncertainty, and bandpass filter ([`photometry::Photometry`], [`photometry::Filter`]) |
//! | [`observation_dataset`] | Core observation types ([`observation_dataset::observation::Observation`], [`observation_dataset::ObsDataset`]) |
//! | [`observer`] | Ground-based observatory representation ([`observer::Observer`]) and geodetic utilities |
//! | [`observer::error_model`] | Astrometric error model variants ([`observer::error_model::ObsErrorModel`]: FCCT14, CBM10, VFCC17) |
//! | [`constants`] | Physical and geodetic constants (Earth axes, AU, etc.) |
#![cfg_attr(
    feature = "polars",
    doc = "| [`io`] | Internal ingestion backends (Polars adapter, schema validation) |"
)]
#![cfg_attr(
    feature = "ades",
    doc = "| [`io::ades`] | ADES XML ingestion backend ([`io::ades`]) |"
)]
#![cfg_attr(
    feature = "mpc_80_col",
    doc = "| [`io::mpc_80_col`] | MPC 80-column ingestion backend ([`io::mpc_80_col`]) |"
)]
#![cfg_attr(
    feature = "datafusion",
    doc = "| [`io::datafusion`] | DataFusion/Arrow Parquet ingestion backend ([`io::datafusion`]) |"
)]
//!
//! # Type Aliases
//!
//! The crate exports five primitive type aliases used throughout the API to make units
//! explicit in function signatures:
//!
//! | Alias | Underlying type | Unit |
//! |-------|-----------------|------|
//! | [`Arcseconds`] | `f64` | Angle in arcseconds |
//! | [`Radians`] | `f64` | Angle in radians |
//! | [`Degrees`] | `f64` | Angle in degrees |
//! | [`MJDTT`] | `f64` | Modified Julian Date (Terrestrial Time) in days |
//! | [`Meters`] | `f64` | Distance in metres |
//!
//! # DataFrame Schema
//!
//! *Requires the `polars` feature.*
//!
//! When loading data via [`observation_dataset::ObsDataset::from_polars`] or
//! [`observation_dataset::ObsDataset::from_lazy`], the input frame must conform
//! to the following column layout.
//!
//! ## Mandatory base columns (non-nullable)
//!
//! | Column | Polars type | Description |
//! |--------|-------------|-------------|
//! | `id` | `UInt64` | Unique observation identifier |
//! | `ra` | `Float64` | Right ascension (radians) |
//! | `ra_err` | `Float64` | Right ascension uncertainty (radians) |
//! | `dec` | `Float64` | Declination (radians) |
//! | `dec_err` | `Float64` | Declination uncertainty (radians) |
//! | `magnitude` | `Float64` | Apparent magnitude |
//! | `mag_err` | `Float64` | Magnitude uncertainty |
//! | `filter` | `String` | Photometric filter label |
//! | `mjd_tt` | `Float64` | Epoch (MJD, Terrestrial Time) |
//!
//! ## Optional observer columns (nullable; column may be absent)
//!
//! | Column | Polars type | Description |
//! |--------|-------------|-------------|
//! | `obs_lon` | `Float64` | Geodetic longitude (radians, east positive) |
//! | `obs_lat` | `Float64` | Geodetic latitude (radians) |
//! | `obs_alt` | `Float64` | Altitude above ellipsoid (metres) |
//! | `obs_ra_acc` | `Float64` | RA accuracy (radians) — required when the geodetic triplet is set |
//! | `obs_dec_acc` | `Float64` | Dec accuracy (radians) — required when the geodetic triplet is set |
//! | `mpc_code_obs` | `String` | Three-byte ASCII MPC code (takes precedence over geodetic columns) |
//!
//! ## Optional grouping columns
//!
//! | Column | Polars type | Description |
//! |--------|-------------|-------------|
//! | `traj_id` | `UInt32` or `String` | Trajectory identifier; nullable — null rows are loaded into the `ObsDataset` but are not assigned to any trajectory |
//! | `night_id` | `UInt32` | Night identifier; nullable — null rows are included in the `ObsDataset` but are not assigned to any night |
//!
//! ## Observer resolution (per row, in precedence order)
//!
//! 1. `mpc_code_obs` non-null → [`observer::dataset::ObserverId::MpcCode`] (MPC site, resolved lazily).
//! 2. `obs_lon`, `obs_lat`, and `obs_alt` all non-null → [`observer::dataset::ObserverId::IntId`] (geodetic
//!    site; `obs_ra_acc` and `obs_dec_acc` must also be non-null).
//! 3. Otherwise → no observer (`None`).
//!
//! A partially-null geodetic triplet or a complete triplet without accuracy values causes
//! the ingestion to return an error.
//!
//! ## Ingestion arguments (`FromPolarsArgs`)
//!
//! *Requires the `polars` feature.*
//!
//! Both [`observation_dataset::ObsDataset::from_polars`] and
//! [`observation_dataset::ObsDataset::from_lazy`] accept a
//! `FromPolarsArgs` value that controls how the ingestion pipeline behaves.
//! Use `FromPolarsArgs::default()` to get sensible out-of-the-box settings,
//! or construct the struct explicitly to override individual fields.
//!
//! | Field | Type | Default | Description |
//! |-------|------|---------|-------------|
//! | `error_model` | `Option<ObsErrorModel>` | `None` | Astrometric error model used to assign accuracies to MPC-coded observatories; `None` leaves MPC observer accuracies unset until [`ObsDataset::set_error_model`](observation_dataset::ObsDataset::set_error_model) is called |
//! | `do_rechunk` | `Option<bool>` | `Some(false)` | When `true`, forces all multi-chunk columns to be merged into a single contiguous Arrow chunk before ingestion; set to `Some(false)` when the caller has already guaranteed single-chunk layout (e.g. after reading a Parquet file with `rechunk: true`) |
//! | `contiguous_choice` | `Option<ContiguousChoice>` | `Some(ContiguousNight)` | Which grouping column (if any) to sort the frame by before iteration; sorting allows the corresponding index to use compact contiguous ranges instead of per-row index vectors (see below) |
//!
//! ### Contiguous index optimisation (`ContiguousChoice`)
//!
//! By default the ingestion pipeline sorts the input frame by `night_id`
//! (`ContiguousChoice::ContiguousNight`) so that all observations belonging to
//! the same night occupy a single contiguous block in the output `observations`
//! vector.  This lets the night index store a compact `(start, end)` range for
//! each night instead of a `Vec` of scattered positions, which saves memory and
//! improves cache locality during sequential and parallel night iteration.
//!
//! Setting `contiguous_choice` to `ContiguousChoice::ContiguousTraj` applies the
//! same optimisation to trajectories instead.  Setting it to `None` disables the
//! sort entirely; both indices will use the `Vec`-based split representation.
//!
//! Only one grouping column can be made contiguous at a time.  The other
//! column (if present in the frame) is always built as a split index.
//!
//! ```rust,ignore
//! use photom::io::polars::{ContiguousChoice, FromPolarsArgs};
//! use photom::observer::error_model::ObsErrorModel;
//! use photom::observation_dataset::ObsDataset;
//!
//! // Sort by traj_id so trajectory iteration is more efficient.
//! let dataset = ObsDataset::from_polars(
//!     &df,
//!     FromPolarsArgs {
//!         error_model: Some(ObsErrorModel::FCCT14),
//!         contiguous_choice: Some(ContiguousChoice::ContiguousTraj),
//!         ..Default::default()
//!     },
//! )?;
//! ```
//!
//! # Usage Examples
//!
//! ## Build a minimal `DataFrame` and load observations
//!
//! ```rust,ignore
//! use polars::prelude::*;
//! use photom::observation_dataset::ObsDataset;
//! use photom::observer::error_model::ObsErrorModel;
//!
//! // Construct a two-row DataFrame matching the required schema.
//! // RA and Dec are in radians; errors are in radians.
//! // Observer accuracy columns (obs_ra_acc, obs_dec_acc) are also in radians.
//! let df = df! {
//!     "id"        => &[1_u64, 2_u64],
//!     "ra"        => &[1.4633_f64, 1.4682_f64],   // radians
//!     "ra_err"    => &[1.745e-5_f64, 1.745e-5_f64], // radians (~1 arcsec)
//!     "dec"       => &[0.3840_f64, 0.3847_f64],   // radians
//!     "dec_err"   => &[1.745e-5_f64, 1.745e-5_f64], // radians (~1 arcsec)
//!     "magnitude" => &[19.3_f64, 19.5_f64],
//!     "mag_err"   => &[0.05_f64, 0.05_f64],
//!     "filter"    => &["r", "r"],
//!     "mjd_tt"    => &[60000.0_f64, 60000.03_f64],
//! }?;
//!
//! let dataset = ObsDataset::from_polars(&df, ObsErrorModel::FCCT14, Some(1000))?;
//! for obs in dataset.iter_observations() {
//!     println!("{} {:?}", obs.id, obs.equ_coord);
//! }
//! ```
//!
//! ## Use an MPC observatory code
//!
//! Add an optional `mpc_code_obs` column (`String`, nullable) to associate each
//! observation with an MPC-registered observatory.  The accuracy values for MPC
//! sites are derived from the chosen `ObsErrorModel`.
//!
//! ```rust,ignore
//! use polars::prelude::*;
//! use photom::observation_dataset::ObsDataset;
//! use photom::observer::error_model::ObsErrorModel;
//!
//! let df = df! {
//!     "id"           => &[1_u64],
//!     "ra"           => &[1.4633_f64],          // radians
//!     "ra_err"       => &[1.745e-5_f64],        // radians
//!     "dec"          => &[0.3840_f64],           // radians
//!     "dec_err"      => &[1.745e-5_f64],        // radians
//!     "magnitude"    => &[19.3_f64],
//!     "mag_err"      => &[0.05_f64],
//!     "filter"       => &["r"],
//!     "mjd_tt"       => &[60000.0_f64],
//!     "mpc_code_obs" => &[Some("F51")],   // Haleakalā Pan-STARRS 1
//! }?;
//!
//! let dataset = ObsDataset::from_polars(&df, ObsErrorModel::FCCT14, None)?;
//! ```
//!
//! ## Group observations by trajectory
//!
//! ```rust,ignore
//! use polars::prelude::*;
//! use photom::observation_dataset::ObsDataset;
//! use photom::io::polars::FromPolarsArgs;
//! use photom::TrajId;
//!
//! // traj_id can be UInt32 or String; null rows are loaded but not grouped.
//! let df = df! {
//!     "id"        => &[1_u64, 2_u64, 3_u64],
//!     "ra"        => &[1.4633_f64, 1.4682_f64, 0.1745_f64],  // radians
//!     "ra_err"    => &[1.745e-5_f64; 3],                      // radians
//!     "dec"       => &[0.3840_f64, 0.3847_f64, 0.0873_f64],  // radians
//!     "dec_err"   => &[1.745e-5_f64; 3],                      // radians
//!     "magnitude" => &[19.3_f64, 19.5_f64, 18.0_f64],
//!     "mag_err"   => &[0.05_f64; 3],
//!     "filter"    => &["r", "r", "g"],
//!     "mjd_tt"    => &[60000.0_f64, 60000.03_f64, 60001.0_f64],
//!     "traj_id"   => &[Some("2020 AV2"), Some("2020 AV2"), None],
//! }?;
//!
//! let dataset = ObsDataset::from_polars(&df, FromPolarsArgs::default())?;
//! let tid = TrajId::Str("2020 AV2".to_owned());
//! if let Some(iter) = dataset.iter_trajectory_observations(&tid) {
//!     println!("{} observations in trajectory", iter.count());
//! }
//! ```
//!
//! ## Load observations from a `LazyFrame`
//!
//! ```rust,ignore
//! use photom::observation_dataset::ObsDataset;
//! use photom::observer::error_model::ObsErrorModel;
//!
//! // Any DataFrame can be turned into a LazyFrame with .lazy().
//! let dataset = ObsDataset::from_lazy(df.lazy(), ObsErrorModel::VFCC17, None)?;
//! ```
//!
//! ## Coordinate utilities
//!
//! [`coordinates::equatorial::EquCoord`] bundles a sky position (RA, Dec) with
//! its 1-σ uncertainties, all stored in **radians**.
//!
//! ```rust
//! use photom::coordinates::equatorial::EquCoord;
//!
//! // Construct from degrees — values are converted to radians internally.
//! let a = EquCoord::from_degrees(10.0, 0.001, 20.0, 0.001);
//! let b = EquCoord::from_degrees(10.5, 0.001, 20.5, 0.001);
//!
//! // Great-circle separation via the Vincenty formula (result in radians).
//! let sep = a.angular_separation(&b);
//!
//! // Vector-averaging midpoint on the sphere.
//! let mid = a.spherical_midpoint(&b);
//! ```
//!
//! To propagate astrometric uncertainties through the spherical-to-Cartesian
//! mapping use [`coordinates::equatorial::EquCoordCov::to_cartesian_cov`], which
//! returns a [`coordinates::cartesian::CartesianCoordCov`] containing the full
//! 3×3 covariance matrix. The inverse conversion is
//! [`coordinates::cartesian::CartesianCoordCov::to_equatorial_cov`].
//!
//! ## 2-D covariance on the tangent plane
//!
//! [`coordinates::cov2::Cov2`] is a compact symmetric 2×2 covariance matrix
//! designed for astrometric error ellipses expressed in a local tangent-plane
//! frame.  It supports eigenvalue decomposition, Mahalanobis distance, and
//! isotropic inflation.
//!
//! ```rust
//! use photom::coordinates::cov2::Cov2;
//! use photom::coordinates::equatorial::EquCoord;
//! use photom::coordinates::gnomonic_projection::TangentVec;
//!
//! // Build from the marginal 1-σ errors of an EquCoord.
//! let coord = EquCoord::from_degrees(45.0, 0.001, 20.0, 0.002);
//! let cov = Cov2::from_equ(&coord);
//!
//! // Semi-axes of the 1-σ confidence ellipse.
//! let sigma_major = cov.lambda_max().max(0.0).sqrt();
//! let sigma_minor = cov.lambda_min().max(0.0).sqrt();
//!
//! // Mahalanobis distance for an offset vector (radians).
//! let offset = TangentVec { dx: 1e-4, dy: 0.0 };
//! if let Some(d2) = cov.mahalanobis_sq(offset) {
//!     let _ = d2.sqrt(); // normalised distance
//! }
//!
//! // Add isotropic process noise q·I (Kalman-style inflation).
//! let q = 1e-8_f64;
//! let inflated = cov.inflate_isotropic(q);
//! ```
//!
//! ## Gnomonic (tangent-plane) projection
//!
//! [`coordinates::gnomonic_projection::TangentPlane`] projects sky positions
//! near a chosen tangent point $(\alpha_0, \delta_0)$ onto a local 2-D
//! Cartesian frame.  Great circles project to straight lines, making this
//! representation well-suited for short-arc astrometry and kinematic linking.
//!
//! ```rust
//! use photom::coordinates::equatorial::EquCoord;
//! use photom::coordinates::gnomonic_projection::{TangentPlane, TangentVec};
//!
//! // Define the tangent point (degrees, converted internally to radians).
//! let ref_coord = EquCoord::from_degrees(45.0, 0.0, 20.0, 0.0);
//! let plane = TangentPlane::new(ref_coord);
//!
//! // Project a nearby sky position.
//! let target = EquCoord::from_degrees(45.5, 0.0, 20.5, 0.0);
//! let tp = plane.project(&target);
//!
//! // Inverse projection: recover equatorial coordinates.
//! let sky = tp.unproject();
//!
//! // Squared Euclidean distance between two projected points (radians²).
//! let other = plane.project(&EquCoord::from_degrees(45.1, 0.0, 20.1, 0.0));
//! let d2 = tp.dist2(&other);
//!
//! // Translate a projected point by a displacement vector.
//! let v = TangentVec { dx: 1e-3, dy: -1e-3 };
//! let shifted = tp + v;
//! ```
//!
//! ## Parallel iteration
//!
//! *Requires the `parallel` feature.*
//!
//! When the `parallel` feature is enabled, [`observation_dataset::ObsDataset`] gains a
//! family of `par_iter_*` methods that return
//! [`rayon::iter::ParallelIterator`](https://docs.rs/rayon/latest/rayon/iter/trait.ParallelIterator.html)
//! values instead of standard iterators.  These methods take `&self` and can be called
//! while other shared borrows of the dataset are live.
//!
//! ```rust,ignore
//! use photom::observation_dataset::ObsDataset;
//! use rayon::iter::ParallelIterator;
//!
//! // Iterate over every observation in parallel.
//! let count = dataset.par_iter_observations().count();
//!
//! // Iterate over every (NightId, &Observation) pair in parallel.
//! // Returns None if the dataset was built without a night_id column.
//! if let Some(par_iter) = dataset.par_iter_full_night() {
//!     par_iter.for_each(|(night_id, obs)| {
//!         println!("night {:?}: obs id {}", night_id, obs.id());
//!     });
//! }
//! ```
//!
//! # The `polars` Feature
//!
//! Polars-based ingestion is gated behind the optional `polars` feature.  To enable it,
//! add the following to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! photom = { version = "0.1", features = ["polars"] }
//! ```
//!
//! Without this feature the crate is still fully usable: all types, constants, and
//! astrometric utilities are available; only the `from_polars` and `from_lazy`
//! constructors on [`observation_dataset::ObsDataset`] are absent.
//!
//! # The `parallel` Feature
//!
//! Parallel iteration is gated behind the optional `parallel` feature, which brings in
//! [rayon](https://docs.rs/rayon) as a dependency.  To enable it, add the following to
//! your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! photom = { version = "0.1", features = ["parallel"] }
//! ```
//!
//! The `parallel` feature can be combined freely with the `polars` feature:
//!
//! ```toml
//! photom = { version = "0.1", features = ["polars", "parallel"] }
//! ```
//!
//! When enabled, every method documented in
//! [`observation_dataset::parallel`](observation_dataset) becomes available on
//! [`observation_dataset::ObsDataset`].  All parallel methods take `&self`, so they do
//! not conflict with outstanding shared borrows of the dataset.
//!
//! # The `ades` Feature
//!
//! ADES ingestion is gated behind the optional `ades` feature.  To enable it, add the
//! following to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! photom = { version = "0.1", features = ["ades"] }
//! ```
//!
//! Without this feature the crate is still fully usable: all types, constants, and
//! astrometric utilities are available; only the `from_ades` constructor on
//! [`observation_dataset::ObsDataset`] is absent.
//!
//! The `ades` feature can be combined freely with the `polars` and `parallel` features:
//!
//! ```toml
//! photom = { version = "0.1", features = ["polars", "parallel", "ades"] }
//! ```
//!
//! ## Loading an ADES file
//!
//! ```rust,ignore
//! use photom::observation_dataset::ObsDataset;
//!
//! // error_ra and error_dec are optional fallback uncertainties in arcseconds,
//! // used when the XML record does not supply rmsRA/rmsDec or precRA/precDec.
//! let dataset = ObsDataset::from_ades("observations.xml", Some(0.5), Some(0.5))?;
//! ```
//!
//! ## Uncertainty resolution (per observation, in precedence order)
//!
//! 1. `rmsRA` / `rmsDec` present in the XML record → used directly (arcseconds).
//! 2. `precRA` / `precDec` present → used as the uncertainty (arcseconds).
//! 3. Fallback `error_ra` / `error_dec` arguments → applied uniformly when neither
//!    of the above fields is available.
//!
//! ## Observer representation
//!
//! Each observation's `stn` field is stored as an
//! [`observer::dataset::ObserverId::MpcCode`] and resolved lazily from the MPC
//! observatory list the first time accuracy values are requested.
//!
//! # The `datafusion` Feature
//!
//! Parquet ingestion via Apache Arrow and DataFusion is gated behind the optional
//! `datafusion` feature.  When enabled it brings in the `datafusion` and `object_store`
//! crates and exposes both an async entry-point
//! (`ObsDataset::from_parquet_uri`) and a synchronous blocking wrapper on the
//! same function, so callers without an async runtime can still use the loader.
//! To enable it, add the following to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! photom = { version = "0.1", features = ["datafusion"] }
//! ```
//!
//! Without this feature the crate is still fully usable: all types, constants, and
//! astrometric utilities remain available; only the `from_parquet_uri` constructor on
//! [`observation_dataset::ObsDataset`] is absent.
//!
//! The `datafusion` feature can be combined freely with the other optional features:
//!
//! ```toml
//! photom = { version = "0.1", features = ["polars", "parallel", "ades", "datafusion"] }
//! ```
//!
//! ## Parquet URI scheme
//!
//! The loader accepts any URI that `object_store` can resolve.  The following schemes are
//! supported out of the box:
//!
//! | Scheme | Backend |
//! |--------|---------|
//! | `file://` | Local filesystem |
//! | `http://` | Plain HTTP object store |
//! | `https://` | TLS-encrypted HTTP object store |
//! | `hdfs://` | Hadoop Distributed File System (requires the `hdfs` Cargo feature on `object_store`) |
//!
//! ## Parquet column schema
//!
//! The Parquet file must contain the following Arrow-typed columns.  Column names and
//! Arrow types must match exactly; the loader returns an error for any schema mismatch.
//!
//! ### Mandatory base columns (non-nullable)
//!
//! | Column | Arrow type | Description |
//! |--------|------------|-------------|
//! | `id` | `UInt64` | Unique observation identifier |
//! | `ra` | `Float64` | Right ascension (radians) |
//! | `ra_err` | `Float64` | Right ascension uncertainty (radians) |
//! | `dec` | `Float64` | Declination (radians) |
//! | `dec_err` | `Float64` | Declination uncertainty (radians) |
//! | `magnitude` | `Float64` | Apparent magnitude |
//! | `mag_err` | `Float64` | Magnitude uncertainty |
//! | `filter` | `Utf8`, `UInt8`, `UInt16`, or `UInt32` | Photometric filter label or code |
//! | `mjd_tt` | `Float64` | Epoch (MJD, Terrestrial Time) |
//!
//! ### Optional observer columns (nullable; column may be absent)
//!
//! | Column | Arrow type | Description |
//! |--------|------------|-------------|
//! | `obs_lon` | `Float64` | Geodetic longitude (radians, east positive) |
//! | `obs_lat` | `Float64` | Geodetic latitude (radians) |
//! | `obs_alt` | `Float64` | Altitude above ellipsoid (metres) |
//! | `obs_ra_acc` | `Float64` | RA accuracy (radians) — required when the geodetic triplet is set |
//! | `obs_dec_acc` | `Float64` | Dec accuracy (radians) — required when the geodetic triplet is set |
//! | `mpc_code_obs` | `Utf8` | Three-byte ASCII MPC code (takes precedence over geodetic columns) |
//!
//! ### Optional index columns
//!
//! | Column | Arrow type | Description |
//! |--------|------------|-------------|
//! | `night_id` | `UInt32` | Night identifier; nullable — null rows are included but not assigned to any night |
//! | `traj_id` | `UInt32` or `Utf8` | Trajectory identifier; nullable — null rows are loaded but not assigned to any trajectory |
//!
//! ## Loading a Parquet file
//!
//! ```rust,ignore
//! use photom::observation_dataset::ObsDataset;
//! use photom::io::datafusion::LoadObsArgs;
//!
//! let dataset = ObsDataset::from_parquet_uri(
//!     "file:///data/observations.parquet",
//!     LoadObsArgs::default(),
//! )?;
//! ```
//!
//! ## Ingestion arguments (`LoadObsArgs`)
//!
//! Both the async and the blocking variants of `from_parquet_uri` accept a `LoadObsArgs`
//! value that controls how the ingestion pipeline behaves.  Use `LoadObsArgs::default()`
//! to get sensible out-of-the-box settings, or construct the struct explicitly to override
//! individual fields.
//!
//! | Field | Type | Default | Description |
//! |-------|------|---------|-------------|
//! | `error_model` | `Option<ObsErrorModel>` | `None` | Astrometric error model used to assign accuracies to MPC-coded observatories; `None` leaves MPC observer accuracies unset until [`ObsDataset::set_error_model`](observation_dataset::ObsDataset::set_error_model) is called |
//! | `contiguous_choice` | `Option<ContiguousChoice>` | `Some(ContiguousNight)` | Which grouping column (if any) to sort the query by before collecting; sorting allows the corresponding index to use compact contiguous ranges instead of per-row index vectors (see below) |
//!
//! The `contiguous_choice` field (defaulting to `ContiguousNight`) causes DataFusion to
//! append an `ORDER BY` clause to the internal SQL query before collecting the record
//! batches.  As a result, all observations belonging to the same night occupy a contiguous
//! block in the output `observations` vector, enabling the night index to store a compact
//! `(start, end)` range instead of a `Vec` of scattered positions.  This is the same
//! contiguous index optimisation applied by the Polars loader via `FromPolarsArgs`.
//!
//! # Minimum Supported Rust Version
//!
//! `photom` requires **Rust 1.94.0** or later.

pub mod constants;
pub mod coordinates;
pub mod io;
pub mod observation_dataset;
pub mod observer;
pub mod photometry;
pub mod traj_id;

use std::{
    fmt::{self, Display},
    hash::Hash,
};

#[cfg(feature = "mpc_80_col")]
pub use io::mpc_80_col::Mpc80ColError;

#[cfg(feature = "ades")]
pub use io::ades::AdesError;

#[cfg(feature = "serde")]
pub use io::serde::{IndexLayout, ObsDatasetSeed};

pub use observation_dataset::builder::{LoadWarning, ObsDatasetBuilder};

pub use crate::traj_id::TrajId;

use ordered_float::{FloatIsNan, NotNan};

/// Arcseconds.
pub type Arcseconds = f64;
/// Radians.
pub type Radians = f64;
/// Degrees.
pub type Degrees = f64;
/// Modified Julian Date (Terrestrial Time).
pub type MJDTT = f64;
/// Meters.
pub type Meters = f64;

/// Logical identifier for a night of observation.
///
/// Wraps a `u32` that typically represents an integer MJD day number
/// (e.g. `60312`).  The value must be stable across runs because it is used
/// as a directory name in on-disk outputs.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct NightId(pub u32);

impl Display for NightId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl NightId {
    /// Create a new `NightId` from an integer.
    pub fn new(id: u32) -> Self {
        Self(id)
    }

    /// Return the underlying integer.
    pub fn value(self) -> u32 {
        self.0
    }
}

impl From<u32> for NightId {
    #[inline]
    fn from(v: u32) -> Self {
        NightId(v)
    }
}

impl From<NightId> for u32 {
    #[inline]
    fn from(n: NightId) -> Self {
        n.0
    }
}

pub use crate::observation_dataset::index::ObsIndex;

/// Lift an `Option<f64>` into an `Option<NotNan<f64>>`, propagating `NaN` as an error.
///
/// - `None` passes through as `Ok(None)`.
/// - `Some(x)` where `x` is finite becomes `Ok(Some(NotNan::new(x)))`.
/// - `Some(NaN)` returns `Err(FloatIsNan)`.
///
/// # Arguments
///
/// - `x` — the optional floating-point value to wrap.
///
/// # Returns
///
/// `Ok(Some(NotNan<f64>))` when `x` is `Some` and finite, `Ok(None)` when
/// `x` is `None`, or `Err(FloatIsNan)` when `x` is `Some(NaN)`.
///
/// # Errors
///
/// Returns [`ordered_float::FloatIsNan`] if `x` is `Some(NaN)`.
#[inline]
fn to_opt_notnan(x: Option<f64>) -> Result<Option<NotNan<f64>>, ordered_float::FloatIsNan> {
    x.map(NotNan::new).transpose()
}

pub trait ToNotNan {
    type Output;
    fn to_notnan(self) -> Result<Self::Output, FloatIsNan>;
}

impl ToNotNan for f64 {
    type Output = NotNan<f64>;
    fn to_notnan(self) -> Result<Self::Output, FloatIsNan> {
        NotNan::new(self)
    }
}

impl ToNotNan for Option<f64> {
    type Output = Option<NotNan<f64>>;
    fn to_notnan(self) -> Result<Self::Output, FloatIsNan> {
        to_opt_notnan(self)
    }
}