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