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