celestial_pointing/commands/
gmap.rs1use 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}