Skip to main content

celestial_pointing/commands/
gha.rs

1use std::path::Path;
2
3use crate::error::Result;
4use crate::plot::residuals::{compute_residuals, require_fit};
5use crate::session::Session;
6
7use super::{Command, CommandOutput};
8
9pub struct Gha;
10
11impl Command for Gha {
12    fn name(&self) -> &str {
13        "GHA"
14    }
15
16    fn description(&self) -> &str {
17        "Residuals vs hour angle"
18    }
19
20    fn execute(&self, session: &mut Session, args: &[&str]) -> Result<CommandOutput> {
21        require_fit(session)?;
22        let residuals = compute_residuals(session);
23        if residuals.is_empty() {
24            return Ok(CommandOutput::Text("No active observations".to_string()));
25        }
26        let dx_vs_ha: Vec<(f64, f64)> = residuals.iter().map(|r| (r.ha_deg, r.dx)).collect();
27        let dd_vs_ha: Vec<(f64, f64)> = residuals.iter().map(|r| (r.ha_deg, r.dd)).collect();
28
29        if let Some(path) = args.first() {
30            write_svg(&dx_vs_ha, &dd_vs_ha, Path::new(path))
31        } else {
32            terminal_output(&dx_vs_ha, &dd_vs_ha)
33        }
34    }
35}
36
37fn terminal_output(dx_vs_ha: &[(f64, f64)], dd_vs_ha: &[(f64, f64)]) -> Result<CommandOutput> {
38    let dx_plot = crate::plot::terminal::xy_plot_terminal(
39        dx_vs_ha,
40        "dX vs Hour Angle",
41        "HA (deg)",
42        "dX (arcsec)",
43    );
44    let dd_plot = crate::plot::terminal::xy_plot_terminal(
45        dd_vs_ha,
46        "dDec vs Hour Angle",
47        "HA (deg)",
48        "dDec (arcsec)",
49    );
50    Ok(CommandOutput::Text(format!("{dx_plot}\n{dd_plot}")))
51}
52
53fn write_svg(
54    dx_vs_ha: &[(f64, f64)],
55    dd_vs_ha: &[(f64, f64)],
56    path: &Path,
57) -> Result<CommandOutput> {
58    let stem = path
59        .file_stem()
60        .unwrap_or_default()
61        .to_str()
62        .unwrap_or("plot");
63    let ext = path
64        .extension()
65        .unwrap_or_default()
66        .to_str()
67        .unwrap_or("svg");
68    let parent = path.parent().unwrap_or(Path::new("."));
69
70    let dx_path = parent.join(format!("{stem}_dx.{ext}"));
71    let dd_path = parent.join(format!("{stem}_dd.{ext}"));
72
73    crate::plot::svg::scatter_svg(
74        dx_vs_ha,
75        &dx_path,
76        "dX vs Hour Angle",
77        "HA (deg)",
78        "dX (arcsec)",
79    )
80    .map_err(svg_err)?;
81
82    crate::plot::svg::scatter_svg(
83        dd_vs_ha,
84        &dd_path,
85        "dDec vs Hour Angle",
86        "HA (deg)",
87        "dDec (arcsec)",
88    )
89    .map_err(svg_err)?;
90
91    Ok(CommandOutput::Text(format!(
92        "Written to {} and {}",
93        dx_path.display(),
94        dd_path.display()
95    )))
96}
97
98fn svg_err(e: Box<dyn std::error::Error>) -> crate::error::Error {
99    crate::error::Error::Io(std::io::Error::other(e.to_string()))
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::observation::{Observation, PierSide};
106    use crate::solver::FitResult;
107    use celestial_core::Angle;
108
109    fn make_obs(
110        cmd_ha_arcsec: f64,
111        act_ha_arcsec: f64,
112        cat_dec_deg: f64,
113        obs_dec_deg: f64,
114    ) -> Observation {
115        Observation {
116            catalog_ra: Angle::from_hours(0.0),
117            catalog_dec: Angle::from_degrees(cat_dec_deg),
118            observed_ra: Angle::from_hours(0.0),
119            observed_dec: Angle::from_degrees(obs_dec_deg),
120            lst: Angle::from_hours(0.0),
121            commanded_ha: Angle::from_arcseconds(cmd_ha_arcsec),
122            actual_ha: Angle::from_arcseconds(act_ha_arcsec),
123            pier_side: PierSide::East,
124            masked: false,
125        }
126    }
127
128    fn session_with_fit() -> Session {
129        let mut session = Session::new();
130        session.model.add_term("IH").unwrap();
131        session.model.set_coefficients(&[0.0]).unwrap();
132        session.last_fit = Some(FitResult {
133            coefficients: vec![0.0],
134            sigma: vec![0.1],
135            sky_rms: 1.0,
136            term_names: vec!["IH".to_string()],
137        });
138        session
139    }
140
141    #[test]
142    fn no_fit_returns_error() {
143        let mut session = Session::new();
144        let result = Gha.execute(&mut session, &[]);
145        assert!(result.is_err());
146    }
147
148    #[test]
149    fn empty_observations_returns_message() {
150        let mut session = session_with_fit();
151        let result = Gha.execute(&mut session, &[]).unwrap();
152        match result {
153            CommandOutput::Text(s) => assert!(s.contains("No active observations")),
154            _ => panic!("expected Text output"),
155        }
156    }
157
158    #[test]
159    fn terminal_shows_both_dx_and_ddec() {
160        let mut session = session_with_fit();
161        session.observations.push(make_obs(0.0, 100.0, 45.0, 45.01));
162        session
163            .observations
164            .push(make_obs(0.0, -50.0, 30.0, 30.005));
165        let result = Gha.execute(&mut session, &[]).unwrap();
166        match result {
167            CommandOutput::Text(s) => {
168                assert!(s.contains("dX vs Hour Angle"));
169                assert!(s.contains("dDec vs Hour Angle"));
170            }
171            _ => panic!("expected Text output"),
172        }
173    }
174
175    #[test]
176    fn svg_writes_two_files() {
177        let mut session = session_with_fit();
178        session.observations.push(make_obs(0.0, 100.0, 45.0, 45.01));
179        session
180            .observations
181            .push(make_obs(0.0, -50.0, 30.0, 30.005));
182        let dir = std::env::temp_dir();
183        let path = dir.join("gha_test.svg");
184        let path_str = path.to_str().unwrap();
185        let result = Gha.execute(&mut session, &[path_str]).unwrap();
186
187        let dx_path = dir.join("gha_test_dx.svg");
188        let dd_path = dir.join("gha_test_dd.svg");
189
190        match &result {
191            CommandOutput::Text(s) => {
192                assert!(s.contains("Written to"));
193                assert!(s.contains("_dx.svg"));
194                assert!(s.contains("_dd.svg"));
195            }
196            _ => panic!("expected Text output"),
197        }
198        assert!(dx_path.exists());
199        assert!(dd_path.exists());
200
201        let dx_contents = std::fs::read_to_string(&dx_path).unwrap();
202        assert!(dx_contents.contains("<svg"));
203        let dd_contents = std::fs::read_to_string(&dd_path).unwrap();
204        assert!(dd_contents.contains("<svg"));
205
206        std::fs::remove_file(&dx_path).ok();
207        std::fs::remove_file(&dd_path).ok();
208    }
209}