Skip to main content

celestial_pointing/commands/
gdist.rs

1use std::path::Path;
2
3use super::{Command, CommandOutput};
4use crate::error::Result;
5use crate::plot::residuals::{compute_residuals, require_fit};
6use crate::session::Session;
7
8pub struct Gdist;
9
10impl Command for Gdist {
11    fn name(&self) -> &str {
12        "GDIST"
13    }
14    fn description(&self) -> &str {
15        "Histogram of residual distribution"
16    }
17
18    fn execute(&self, session: &mut Session, args: &[&str]) -> Result<CommandOutput> {
19        require_fit(session)?;
20        let residuals = compute_residuals(session);
21        if residuals.is_empty() {
22            return Ok(CommandOutput::Text("No active observations".to_string()));
23        }
24        let dx_vals: Vec<f64> = residuals.iter().map(|r| r.dx).collect();
25        let dd_vals: Vec<f64> = residuals.iter().map(|r| r.dd).collect();
26
27        match args.first() {
28            Some(path) => svg_output(path, args, &dx_vals, &dd_vals),
29            None => terminal_output(&dx_vals, &dd_vals),
30        }
31    }
32}
33
34fn terminal_output(dx: &[f64], dd: &[f64]) -> Result<CommandOutput> {
35    let dx_hist = crate::plot::terminal::histogram_terminal(dx, "dX Distribution", "dX");
36    let dd_hist = crate::plot::terminal::histogram_terminal(dd, "dDec Distribution", "dDec");
37    Ok(CommandOutput::Text(format!("{dx_hist}\n{dd_hist}")))
38}
39
40fn svg_output(path: &str, args: &[&str], dx: &[f64], dd: &[f64]) -> Result<CommandOutput> {
41    let is_dec = args.get(1).is_some_and(|a| a.eq_ignore_ascii_case("D"));
42    let (values, title, label) = if is_dec {
43        (dd, "dDec Distribution", "dDec (arcsec)")
44    } else {
45        (dx, "dX Distribution", "dX (arcsec)")
46    };
47    write_svg(values, Path::new(path), title, label)
48}
49
50fn write_svg(values: &[f64], path: &Path, title: &str, x_label: &str) -> Result<CommandOutput> {
51    crate::plot::svg::histogram_svg(values, path, title, x_label)
52        .map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?;
53    Ok(CommandOutput::Text(format!(
54        "Written to {}",
55        path.display()
56    )))
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::session::Session;
63
64    #[test]
65    fn no_fit_returns_error() {
66        let mut session = Session::new();
67        let result = Gdist.execute(&mut session, &[]);
68        let err = result.err().expect("expected error");
69        assert!(err.to_string().contains("no fit"));
70    }
71
72    #[test]
73    fn empty_observations_returns_message() {
74        let mut session = Session::new();
75        session.last_fit = Some(crate::solver::FitResult {
76            coefficients: vec![1.0],
77            sigma: vec![0.1],
78            sky_rms: 5.0,
79            term_names: vec!["IH".to_string()],
80        });
81        let result = Gdist.execute(&mut session, &[]).unwrap();
82        match result {
83            CommandOutput::Text(s) => assert_eq!(s, "No active observations"),
84            _ => panic!("expected Text output"),
85        }
86    }
87
88    #[test]
89    fn terminal_output_contains_both_distributions() {
90        let mut session = build_session_with_obs();
91        let result = Gdist.execute(&mut session, &[]).unwrap();
92        match result {
93            CommandOutput::Text(s) => {
94                assert!(s.contains("dX Distribution"), "missing dX Distribution");
95                assert!(s.contains("dDec Distribution"), "missing dDec Distribution");
96            }
97            _ => panic!("expected Text output"),
98        }
99    }
100
101    fn build_session_with_obs() -> Session {
102        use crate::observation::{Observation, PierSide};
103        use celestial_core::Angle;
104
105        let mut session = Session::new();
106        session.last_fit = Some(crate::solver::FitResult {
107            coefficients: vec![],
108            sigma: vec![],
109            sky_rms: 5.0,
110            term_names: vec![],
111        });
112        for i in 0..10 {
113            let offset = (i as f64) * 10.0;
114            session.observations.push(Observation {
115                catalog_ra: Angle::from_hours(0.0),
116                catalog_dec: Angle::from_degrees(45.0),
117                observed_ra: Angle::from_hours(0.0),
118                observed_dec: Angle::from_degrees(45.0 + offset / 3600.0),
119                lst: Angle::from_hours(0.0),
120                commanded_ha: Angle::from_arcseconds(0.0),
121                actual_ha: Angle::from_arcseconds(offset),
122                pier_side: PierSide::East,
123                masked: false,
124            });
125        }
126        session
127    }
128}