Skip to main content

celestial_pointing/commands/
gmap.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 Gmap;
10
11impl Command for Gmap {
12    fn name(&self) -> &str {
13        "GMAP"
14    }
15
16    fn description(&self) -> &str {
17        "Sky map with residual vectors"
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 positions: Vec<(f64, f64)> = residuals.iter().map(|r| (r.ha_deg, r.dec_deg)).collect();
27        let vectors: Vec<(f64, f64)> = residuals
28            .iter()
29            .map(|r| (r.dx / 3600.0, r.dd / 3600.0))
30            .collect();
31
32        if let Some(path) = args.first() {
33            let scale = parse_scale(args);
34            write_svg(&positions, &vectors, Path::new(path), scale)
35        } else {
36            terminal_output(&positions)
37        }
38    }
39}
40
41fn parse_scale(args: &[&str]) -> f64 {
42    args.get(1)
43        .and_then(|s| s.parse::<f64>().ok())
44        .unwrap_or(10.0)
45}
46
47fn terminal_output(positions: &[(f64, f64)]) -> Result<CommandOutput> {
48    let text =
49        crate::plot::terminal::scatter_terminal(positions, "Sky Map", "HA (deg)", "Dec (deg)");
50    Ok(CommandOutput::Text(text))
51}
52
53fn write_svg(
54    positions: &[(f64, f64)],
55    vectors: &[(f64, f64)],
56    path: &Path,
57    scale: f64,
58) -> Result<CommandOutput> {
59    crate::plot::svg::vector_map_svg(
60        positions,
61        vectors,
62        path,
63        "Sky Map - Residual Vectors",
64        "HA (deg)",
65        "Dec (deg)",
66        scale,
67    )
68    .map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?;
69    Ok(CommandOutput::Text(format!(
70        "Written to {}",
71        path.display()
72    )))
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::observation::{Observation, PierSide};
79    use crate::solver::FitResult;
80    use celestial_core::Angle;
81
82    fn make_obs(
83        cmd_ha_arcsec: f64,
84        act_ha_arcsec: f64,
85        cat_dec_deg: f64,
86        obs_dec_deg: f64,
87    ) -> Observation {
88        Observation {
89            catalog_ra: Angle::from_hours(0.0),
90            catalog_dec: Angle::from_degrees(cat_dec_deg),
91            observed_ra: Angle::from_hours(0.0),
92            observed_dec: Angle::from_degrees(obs_dec_deg),
93            lst: Angle::from_hours(0.0),
94            commanded_ha: Angle::from_arcseconds(cmd_ha_arcsec),
95            actual_ha: Angle::from_arcseconds(act_ha_arcsec),
96            pier_side: PierSide::East,
97            masked: false,
98        }
99    }
100
101    fn session_with_fit() -> Session {
102        let mut session = Session::new();
103        session.model.add_term("IH").unwrap();
104        session.model.set_coefficients(&[0.0]).unwrap();
105        session.last_fit = Some(FitResult {
106            coefficients: vec![0.0],
107            sigma: vec![0.1],
108            sky_rms: 1.0,
109            term_names: vec!["IH".to_string()],
110        });
111        session
112    }
113
114    #[test]
115    fn no_fit_returns_error() {
116        let mut session = Session::new();
117        let result = Gmap.execute(&mut session, &[]);
118        assert!(result.is_err());
119    }
120
121    #[test]
122    fn empty_observations_returns_message() {
123        let mut session = session_with_fit();
124        let result = Gmap.execute(&mut session, &[]).unwrap();
125        match result {
126            CommandOutput::Text(s) => assert!(s.contains("No active observations")),
127            _ => panic!("expected Text output"),
128        }
129    }
130
131    #[test]
132    fn terminal_output_contains_title() {
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 result = Gmap.execute(&mut session, &[]).unwrap();
139        match result {
140            CommandOutput::Text(s) => {
141                assert!(s.contains("Sky Map"));
142                assert!(s.contains("HA (deg)"));
143                assert!(s.contains("Dec (deg)"));
144            }
145            _ => panic!("expected Text output"),
146        }
147    }
148
149    #[test]
150    fn svg_writes_to_temp_file() {
151        let mut session = session_with_fit();
152        session.observations.push(make_obs(0.0, 100.0, 45.0, 45.01));
153        session
154            .observations
155            .push(make_obs(0.0, -50.0, 30.0, 30.005));
156        let dir = std::env::temp_dir();
157        let path = dir.join("gmap_test.svg");
158        let path_str = path.to_str().unwrap();
159        let result = Gmap.execute(&mut session, &[path_str]).unwrap();
160        match &result {
161            CommandOutput::Text(s) => assert!(s.contains("Written to")),
162            _ => panic!("expected Text output"),
163        }
164        assert!(path.exists());
165        let contents = std::fs::read_to_string(&path).unwrap();
166        assert!(contents.contains("<svg"));
167        std::fs::remove_file(&path).ok();
168    }
169
170    #[test]
171    fn scale_parsed_from_args() {
172        assert_eq!(parse_scale(&["out.svg"]), 10.0);
173        assert_eq!(parse_scale(&["out.svg", "5.0"]), 5.0);
174        assert_eq!(parse_scale(&["out.svg", "notanumber"]), 10.0);
175        assert_eq!(parse_scale(&[]), 10.0);
176    }
177
178    #[test]
179    fn svg_with_custom_scale() {
180        let mut session = session_with_fit();
181        session.observations.push(make_obs(0.0, 100.0, 45.0, 45.01));
182        let dir = std::env::temp_dir();
183        let path = dir.join("gmap_scale_test.svg");
184        let path_str = path.to_str().unwrap();
185        let result = Gmap.execute(&mut session, &[path_str, "20.0"]).unwrap();
186        match &result {
187            CommandOutput::Text(s) => assert!(s.contains("Written to")),
188            _ => panic!("expected Text output"),
189        }
190        assert!(path.exists());
191        std::fs::remove_file(&path).ok();
192    }
193}