Skip to main content

celestial_pointing/commands/
gscat.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 Gscat;
10
11impl Command for Gscat {
12    fn name(&self) -> &str {
13        "GSCAT"
14    }
15
16    fn description(&self) -> &str {
17        "Scatter plot of residuals (dX vs dDec)"
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 points: Vec<(f64, f64)> = residuals.iter().map(|r| (r.dx, r.dd)).collect();
27        if let Some(path) = args.first() {
28            write_svg(&points, Path::new(path))
29        } else {
30            terminal_output(&points)
31        }
32    }
33}
34
35fn write_svg(points: &[(f64, f64)], path: &Path) -> Result<CommandOutput> {
36    crate::plot::svg::scatter_svg(
37        points,
38        path,
39        "Residual Scatter",
40        "dX (arcsec)",
41        "dDec (arcsec)",
42    )
43    .map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?;
44    Ok(CommandOutput::Text(format!("Wrote {}", path.display())))
45}
46
47fn terminal_output(points: &[(f64, f64)]) -> Result<CommandOutput> {
48    let text = crate::plot::terminal::scatter_terminal(
49        points,
50        "Residual Scatter (dX vs dDec)",
51        "dX (arcsec)",
52        "dDec (arcsec)",
53    );
54    Ok(CommandOutput::Text(text))
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::observation::{Observation, PierSide};
61    use crate::solver::FitResult;
62    use celestial_core::Angle;
63
64    fn make_obs(
65        cmd_ha_arcsec: f64,
66        act_ha_arcsec: f64,
67        cat_dec_deg: f64,
68        obs_dec_deg: f64,
69    ) -> Observation {
70        Observation {
71            catalog_ra: Angle::from_hours(0.0),
72            catalog_dec: Angle::from_degrees(cat_dec_deg),
73            observed_ra: Angle::from_hours(0.0),
74            observed_dec: Angle::from_degrees(obs_dec_deg),
75            lst: Angle::from_hours(0.0),
76            commanded_ha: Angle::from_arcseconds(cmd_ha_arcsec),
77            actual_ha: Angle::from_arcseconds(act_ha_arcsec),
78            pier_side: PierSide::East,
79            masked: false,
80        }
81    }
82
83    fn session_with_fit() -> Session {
84        let mut session = Session::new();
85        session.model.add_term("IH").unwrap();
86        session.model.set_coefficients(&[0.0]).unwrap();
87        session.last_fit = Some(FitResult {
88            coefficients: vec![0.0],
89            sigma: vec![0.1],
90            sky_rms: 1.0,
91            term_names: vec!["IH".to_string()],
92        });
93        session
94    }
95
96    #[test]
97    fn no_fit_returns_error() {
98        let mut session = Session::new();
99        let result = Gscat.execute(&mut session, &[]);
100        assert!(result.is_err());
101    }
102
103    #[test]
104    fn empty_observations_returns_message() {
105        let mut session = session_with_fit();
106        let result = Gscat.execute(&mut session, &[]).unwrap();
107        match result {
108            CommandOutput::Text(s) => assert!(s.contains("No active observations")),
109            _ => panic!("expected Text output"),
110        }
111    }
112
113    #[test]
114    fn terminal_output_contains_title() {
115        let mut session = session_with_fit();
116        session.observations.push(make_obs(0.0, 100.0, 45.0, 45.01));
117        session
118            .observations
119            .push(make_obs(0.0, -50.0, 30.0, 30.005));
120        let result = Gscat.execute(&mut session, &[]).unwrap();
121        match result {
122            CommandOutput::Text(s) => {
123                assert!(s.contains("Residual Scatter"));
124                assert!(s.contains("dX (arcsec)"));
125                assert!(s.contains("dDec (arcsec)"));
126            }
127            _ => panic!("expected Text output"),
128        }
129    }
130
131    #[test]
132    fn svg_writes_to_temp_file() {
133        let mut session = session_with_fit();
134        session.observations.push(make_obs(0.0, 100.0, 45.0, 45.01));
135        session
136            .observations
137            .push(make_obs(0.0, -50.0, 30.0, 30.005));
138        let dir = std::env::temp_dir();
139        let path = dir.join("gscat_test.svg");
140        let path_str = path.to_str().unwrap();
141        let result = Gscat.execute(&mut session, &[path_str]).unwrap();
142        match &result {
143            CommandOutput::Text(s) => assert!(s.contains("Wrote")),
144            _ => panic!("expected Text output"),
145        }
146        assert!(path.exists());
147        let contents = std::fs::read_to_string(&path).unwrap();
148        assert!(contents.contains("<svg"));
149        std::fs::remove_file(&path).ok();
150    }
151}