use std::fmt;
use hifitime::{Epoch, TimeScale};
use crate::constants::{JDTOMJD, RAD2ARC};
use crate::conversion::{dec_sdms_prec, ra_hms_prec};
use crate::observations::Observation;
use crate::time::{fmt_ss, iso_tt_from_epoch, iso_utc_from_epoch};
use crate::{Observations, Outfit};
use comfy_table::{presets::UTF8_FULL, Cell, CellAlignment, ContentArrangement, Row, Table};
enum TableMode {
Default, Wide, Iso, }
pub struct ObservationsDisplay<'a> {
obs: &'a Observations,
env: Option<&'a Outfit>,
mode: TableMode,
sec_prec: usize,
dist_prec: usize,
sorted: bool,
}
struct RowFields {
i: usize,
site_label: String,
mjd_tt: f64,
ra_rad: f64,
dec_rad: f64,
ra_str: String,
dec_str: String,
jd_tt: Option<f64>,
r_geo: Option<f64>,
r_hel: Option<f64>,
iso_tt: Option<String>,
iso_utc: Option<String>,
}
impl<'a> ObservationsDisplay<'a> {
pub fn new(obs: &'a Observations) -> Self {
Self {
obs,
env: None,
mode: TableMode::Default,
sec_prec: 3,
dist_prec: 6,
sorted: false,
}
}
pub fn wide(mut self, yes: bool) -> Self {
self.mode = if yes {
TableMode::Wide
} else {
TableMode::Default
};
self
}
pub fn iso(mut self) -> Self {
self.mode = TableMode::Iso;
self
}
pub fn with_seconds_precision(mut self, p: usize) -> Self {
self.sec_prec = p;
self
}
pub fn with_distance_precision(mut self, p: usize) -> Self {
self.dist_prec = p;
self
}
pub fn sorted(mut self) -> Self {
self.sorted = true;
self
}
pub fn with_env(mut self, env: &'a Outfit) -> Self {
self.env = Some(env);
self
}
fn site_label(&self, i: usize) -> String {
if let Some(env) = self.env {
let site_id = self.obs[i].observer;
let site = env.get_observer_from_uint16(site_id);
if let Some(name) = site.name.as_deref() {
if !name.is_empty() {
return format!("{name} (#{site_id})");
}
}
format!("Site ID #{site_id}")
} else {
format!("{}", self.obs[i].observer)
}
}
fn write_header(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.mode {
TableMode::Default => {
writeln!(
f,
"{:>3} {:>5} {:>14} {:>20} {:>20}",
"#", "Site", "MJD (TT)", "RA ±σ[arcsec]", "DEC ±σ[arcsec]"
)
}
TableMode::Wide => {
writeln!(
f,
"{:>3} {:>5} {:>14} {:>14} {:>26} {:>11} {:>26} {:>11} {:>12} {:>12}",
"#",
"Site",
"MJD (TT)",
"JD (TT)",
"RA ±σ[arcsec]",
"RA [rad]",
"DEC ±σ[arcsec]",
"DEC [rad]",
"|r_geo| AU",
"|r_hel| AU"
)
}
TableMode::Iso => {
writeln!(
f,
"{:>3} {:>5} {:>26} {:>26} {:>26} {:>26}",
"#", "Site", "ISO (TT)", "ISO (UTC)", "RA ±σ[arcsec]", "DEC ±σ[arcsec]"
)
}
}
}
fn row_iter(&self) -> Box<dyn Iterator<Item = (usize, &Observation)> + '_> {
if self.sorted {
use std::cmp::Ordering;
let mut order: Vec<usize> = (0..self.obs.len()).collect();
order.sort_by(|&a, &b| {
let ta: f64 = self.obs[a].time;
let tb: f64 = self.obs[b].time;
match ta.partial_cmp(&tb) {
Some(ord) => ord,
None => Ordering::Equal, }
.then_with(|| a.cmp(&b))
});
Box::new(order.into_iter().map(|i| (i, &self.obs[i])))
} else {
Box::new(self.obs.iter().enumerate())
}
}
fn format_row_fields(&self, i: usize, o: &Observation) -> RowFields {
let sp = self.sec_prec;
let ra_rad: f64 = o.ra;
let dec_rad: f64 = o.dec;
let (hh, mm, ss) = ra_hms_prec(ra_rad, sp);
let (sgn, dd, dm, ds) = dec_sdms_prec(dec_rad, sp);
let ss_s = fmt_ss(ss, sp);
let ds_s = fmt_ss(ds, sp);
let sra_as = o.error_ra * RAD2ARC;
let sdec_as = o.error_dec * RAD2ARC;
let ra_str = format!("{hh:02}h{mm:02}m{ss_s}s ± {sra_as:.3}\"");
let dec_str = format!("{sgn}{dd:02}°{dm:02}'{ds_s}\" ± {sdec_as:.3}\"");
let mjd_tt: f64 = o.time;
let (jd_tt, r_geo, r_hel, iso_tt, iso_utc) = match self.mode {
TableMode::Default => (None, None, None, None, None),
TableMode::Wide => {
let jd = mjd_tt + JDTOMJD;
let g = o.observer_earth_position.norm();
let h = o.observer_helio_position.norm();
(Some(jd), Some(g), Some(h), None, None)
}
TableMode::Iso => {
let epoch_tt = Epoch::from_mjd_in_time_scale(mjd_tt, TimeScale::TT);
let tt_str = iso_tt_from_epoch(epoch_tt, sp);
let utc_str = iso_utc_from_epoch(epoch_tt, sp);
(None, None, None, Some(tt_str), Some(utc_str))
}
};
RowFields {
i,
site_label: self.site_label(i),
mjd_tt,
ra_rad,
dec_rad,
ra_str,
dec_str,
jd_tt,
r_geo,
r_hel,
iso_tt,
iso_utc,
}
}
fn render_wide_comfy(&self) -> String {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![
Cell::new("#"),
Cell::new("Site"),
Cell::new("MJD (TT)"),
Cell::new("JD (TT)"),
Cell::new("RA ±σ[arcsec]"),
Cell::new("RA [rad]"),
Cell::new("DEC ±σ[arcsec]"),
Cell::new("DEC [rad]"),
Cell::new("|r_geo| AU"),
Cell::new("|r_hel| AU"),
]);
for (i, o) in self.row_iter() {
let r = self.format_row_fields(i, o);
let dp = self.dist_prec;
table.add_row(Row::from(vec![
Cell::new(r.i).set_alignment(CellAlignment::Right),
Cell::new(r.site_label).set_alignment(CellAlignment::Right),
Cell::new(format!("{:.6}", r.mjd_tt)).set_alignment(CellAlignment::Right),
Cell::new(format!("{:.6}", r.jd_tt.unwrap_or_default()))
.set_alignment(CellAlignment::Right),
Cell::new(r.ra_str.clone()).set_alignment(CellAlignment::Right),
Cell::new(format!("{:.7}", r.ra_rad)).set_alignment(CellAlignment::Right),
Cell::new(r.dec_str.clone()).set_alignment(CellAlignment::Right),
Cell::new(format!("{:.7}", r.dec_rad)).set_alignment(CellAlignment::Right),
Cell::new(format!("{:.*}", dp, r.r_geo.unwrap_or_default()))
.set_alignment(CellAlignment::Right),
Cell::new(format!("{:.*}", dp, r.r_hel.unwrap_or_default()))
.set_alignment(CellAlignment::Right),
]));
}
table.to_string()
}
fn render_iso_comfy(&self) -> String {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![
Cell::new("#"),
Cell::new("Site"),
Cell::new("ISO (TT)"),
Cell::new("ISO (UTC)"),
Cell::new("RA ±σ[arcsec]"),
Cell::new("DEC ±σ[arcsec]"),
]);
for (i, o) in self.row_iter() {
let r = self.format_row_fields(i, o);
table.add_row(Row::from(vec![
Cell::new(r.i).set_alignment(CellAlignment::Right),
Cell::new(r.site_label).set_alignment(CellAlignment::Right),
Cell::new(r.iso_tt.as_deref().unwrap_or("")).set_alignment(CellAlignment::Right),
Cell::new(r.iso_utc.as_deref().unwrap_or("")).set_alignment(CellAlignment::Right),
Cell::new(r.ra_str.clone()).set_alignment(CellAlignment::Right),
Cell::new(r.dec_str.clone()).set_alignment(CellAlignment::Right),
]));
}
table.to_string()
}
fn write_row(&self, f: &mut fmt::Formatter<'_>, r: &RowFields) -> fmt::Result {
match self.mode {
TableMode::Default => {
writeln!(
f,
"{i:>3} {site:>5} {mjd:>14.6} {ra:>20} {dec:>20}",
i = r.i,
site = r.site_label,
mjd = r.mjd_tt,
ra = r.ra_str,
dec = r.dec_str
)
}
TableMode::Wide => {
let dp = self.dist_prec;
writeln!(
f,
"{i:>3} {site:>5} {mjd:>14.6} {jd:>14.6} {ra:>20} {ra_rad:>11.7} {dec:>20} {dec_rad:>11.7} {rgeo:>12.dp$} {rhel:>12.dp$}",
i = r.i,
site = r.site_label,
mjd = r.mjd_tt,
jd = r.jd_tt.unwrap_or_default(),
ra = r.ra_str,
ra_rad = r.ra_rad,
dec = r.dec_str,
dec_rad = r.dec_rad,
rgeo = r.r_geo.unwrap_or_default(),
rhel = r.r_hel.unwrap_or_default(),
dp = dp
)
}
TableMode::Iso => {
writeln!(
f,
"{i:>3} {site:>5} {iso_tt:>26} {iso_utc:>26} {ra:>20} {dec:>20}",
i = r.i,
site = r.site_label,
iso_tt = r.iso_tt.as_deref().unwrap_or(""),
iso_utc = r.iso_utc.as_deref().unwrap_or(""),
ra = r.ra_str,
dec = r.dec_str
)
}
}
}
}
pub trait ObservationsDisplayExt {
fn table_wide(&self) -> ObservationsDisplay<'_>;
fn table_iso(&self) -> ObservationsDisplay<'_>;
fn show(&self) -> ObservationsDisplay<'_>;
fn show_string(&self) -> String {
format!("{}", self.show())
}
}
impl ObservationsDisplayExt for Observations {
fn table_wide(&self) -> ObservationsDisplay<'_> {
ObservationsDisplay::new(self).wide(true)
}
fn table_iso(&self) -> ObservationsDisplay<'_> {
ObservationsDisplay::new(self).iso()
}
fn show(&self) -> ObservationsDisplay<'_> {
ObservationsDisplay::new(self)
}
}
impl fmt::Display for ObservationsDisplay<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let n = self.obs.len();
writeln!(f, "Observations (n={n})")?;
writeln!(f, "-------------------")?;
match self.mode {
TableMode::Wide => {
let out = self.render_wide_comfy();
f.write_str(&out)?;
}
TableMode::Iso => {
let out = self.render_iso_comfy();
f.write_str(&out)?;
}
TableMode::Default => {
self.write_header(f)?;
for (i, o) in self.row_iter() {
let row = self.format_row_fields(i, o);
self.write_row(f, &row)?;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod observation_display_tests {
use super::*;
use crate::observations::display::ObservationsDisplayExt;
use crate::Observations;
use nalgebra::Vector3;
fn make_obs(
site: u16,
ra_deg: f64,
dec_deg: f64,
err_arcsec: f64,
mjd_tt: f64,
rgeo: (f64, f64, f64),
rhel: (f64, f64, f64),
) -> Observation {
let ra_rad = ra_deg.to_radians();
let dec_rad = dec_deg.to_radians();
let err_rad = (err_arcsec / 3600.0).to_radians();
Observation {
observer: site,
ra: ra_rad, error_ra: err_rad, dec: dec_rad, error_dec: err_rad, time: mjd_tt, observer_earth_position: Vector3::new(rgeo.0, rgeo.1, rgeo.2),
observer_helio_position: Vector3::new(rhel.0, rhel.1, rhel.2),
}
}
fn sample_observations() -> Observations {
let mut obs: Observations = Observations::default();
obs.push(make_obs(
809,
0.0,
0.0,
1.0,
60000.123456,
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
));
obs.push(make_obs(
2,
0.0,
-10.0,
0.5,
59000.0,
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
));
obs.push(make_obs(
500,
180.0,
20.0,
1.2,
60001.0,
(1.0, 0.0, 0.0),
(0.0, 2.0, 0.0),
));
obs
}
#[test]
fn default_headers_and_basic_format() {
let obs = sample_observations();
let s = format!("{}", obs.show());
assert!(s.contains("MJD (TT)"));
assert!(s.contains("RA ±σ[arcsec]"));
assert!(s.contains("DEC ±σ[arcsec]"));
assert!(s.contains("00h00m00.000s ± 1.000\""));
assert!(s.contains("+00°00'00.000\" ± 1.000\""));
}
#[test]
fn dec_negative_sign_is_preserved_in_table() {
let obs = sample_observations();
let s = format!("{}", obs.show()); assert!(
s.contains("-10°00'00.000\""),
"Negative DEC sign or DMS formatting incorrect: {s}"
);
assert!(s.contains("± 0.500\""));
}
#[test]
fn sorted_orders_by_time_and_keeps_original_index() {
let obs = sample_observations();
let s = format!("{}", obs.show().sorted());
let mut lines = s.lines();
let _title = lines.next().unwrap_or_default();
let _rule = lines.next().unwrap_or_default();
let _hdr = lines.next().unwrap_or_default();
let first_row = lines.next().unwrap_or_default();
assert!(
first_row.trim_start().starts_with("1"),
"Expected first printed row to be original index 1, got: {first_row}"
);
let rest = lines.collect::<Vec<_>>().join("\n");
assert!(
rest.contains("\n 0") || rest.starts_with(" 0"),
"Index 0 should also appear."
);
}
#[test]
fn wide_mode_headers_and_radians_and_distances() {
let obs = sample_observations();
let s = format!("{}", obs.table_wide());
assert!(s.contains("JD (TT)"));
assert!(s.contains("RA [rad]"));
assert!(s.contains("DEC [rad]"));
assert!(s.contains("|r_geo| AU"));
assert!(s.contains("|r_hel| AU"));
assert!(
s.contains("-0.1745329"),
"Expected DEC in radians around -0.1745329 rad in wide mode: {s}"
);
assert!(
s.contains(" 0.000000"),
"Expected at least one zero distance in wide mode for idx 0: {s}"
);
assert!(
s.contains(" 1.000000") && s.contains(" 2.000000"),
"Expected distances 1.000000 and 2.000000 AU for idx 2: {s}"
);
}
#[test]
fn iso_mode_headers_and_suffixes() {
let obs = sample_observations();
let s = format!("{}", obs.table_iso());
assert!(s.contains("ISO (TT)"));
assert!(s.contains("ISO (UTC)"));
assert!(s.contains(" TT"), "TT suffix missing in ISO TT column: {s}");
assert!(s.contains('Z'), "Z suffix missing in ISO UTC column: {s}");
}
#[test]
fn seconds_and_distance_precision_knobs() {
let obs = sample_observations();
let s_iso = format!("{}", obs.table_iso().with_seconds_precision(4));
assert!(
s_iso.contains("00h00m00.0000s"),
"Seconds precision not applied: {s_iso}"
);
let s_wide = format!("{}", obs.table_wide().with_distance_precision(4));
assert!(
s_wide.contains(" 0.0000"),
"Distance precision not applied (expected 4 decimals): {s_wide}"
);
}
#[test]
fn show_string_matches_display_default() {
let obs = sample_observations();
let s1 = obs.show_string();
let s2 = format!("{}", obs.show());
assert_eq!(s1, s2, "show_string() must match Display in default mode");
}
}