celestial_pointing/commands/
ghyst.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 Ghyst;
10
11impl Command for Ghyst {
12 fn name(&self) -> &str {
13 "GHYST"
14 }
15
16 fn description(&self) -> &str {
17 "Hysteresis plot (residuals by sequence and pier side)"
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 (east, west) = split_by_pier(&residuals);
27 let all: Vec<(f64, f64)> = residuals.iter().map(|r| (r.index as f64, r.dr)).collect();
28 if let Some(path) = args.first() {
29 write_svg(&east, &west, Path::new(path))
30 } else {
31 terminal_output(&all, east.len(), west.len())
32 }
33 }
34}
35
36type PointVec = Vec<(f64, f64)>;
37
38fn split_by_pier(residuals: &[crate::plot::residuals::ObsResidual]) -> (PointVec, PointVec) {
39 let east = residuals
40 .iter()
41 .filter(|r| r.pier_east)
42 .map(|r| (r.index as f64, r.dr))
43 .collect();
44 let west = residuals
45 .iter()
46 .filter(|r| !r.pier_east)
47 .map(|r| (r.index as f64, r.dr))
48 .collect();
49 (east, west)
50}
51
52fn terminal_output(all: &[(f64, f64)], n_east: usize, n_west: usize) -> Result<CommandOutput> {
53 let plot = crate::plot::terminal::xy_plot_terminal(
54 all,
55 "Residual vs Observation Sequence",
56 "Obs #",
57 "dR (arcsec)",
58 );
59 let summary = format!(" East: {} obs West: {} obs", n_east, n_west);
60 Ok(CommandOutput::Text(format!("{plot}\n{summary}")))
61}
62
63fn write_svg(east: &[(f64, f64)], west: &[(f64, f64)], path: &Path) -> Result<CommandOutput> {
64 let stem = path
65 .file_stem()
66 .unwrap_or_default()
67 .to_str()
68 .unwrap_or("plot");
69 let ext = path
70 .extension()
71 .unwrap_or_default()
72 .to_str()
73 .unwrap_or("svg");
74 let parent = path.parent().unwrap_or(Path::new("."));
75 let east_path = parent.join(format!("{stem}_east.{ext}"));
76 let west_path = parent.join(format!("{stem}_west.{ext}"));
77 if !east.is_empty() {
78 crate::plot::svg::scatter_svg(
79 east,
80 &east_path,
81 "Hysteresis - East",
82 "Obs #",
83 "dR (arcsec)",
84 )
85 .map_err(svg_err)?;
86 }
87 if !west.is_empty() {
88 crate::plot::svg::scatter_svg(
89 west,
90 &west_path,
91 "Hysteresis - West",
92 "Obs #",
93 "dR (arcsec)",
94 )
95 .map_err(svg_err)?;
96 }
97 Ok(CommandOutput::Text(format!(
98 "Written to {} and {}",
99 east_path.display(),
100 west_path.display()
101 )))
102}
103
104fn svg_err(e: Box<dyn std::error::Error>) -> crate::error::Error {
105 crate::error::Error::Io(std::io::Error::other(e.to_string()))
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use crate::observation::{Observation, PierSide};
112 use crate::solver::FitResult;
113 use celestial_core::Angle;
114
115 fn make_obs(
116 cmd_ha_arcsec: f64,
117 act_ha_arcsec: f64,
118 cat_dec_deg: f64,
119 obs_dec_deg: f64,
120 pier: PierSide,
121 ) -> Observation {
122 Observation {
123 catalog_ra: Angle::from_hours(0.0),
124 catalog_dec: Angle::from_degrees(cat_dec_deg),
125 observed_ra: Angle::from_hours(0.0),
126 observed_dec: Angle::from_degrees(obs_dec_deg),
127 lst: Angle::from_hours(0.0),
128 commanded_ha: Angle::from_arcseconds(cmd_ha_arcsec),
129 actual_ha: Angle::from_arcseconds(act_ha_arcsec),
130 pier_side: pier,
131 masked: false,
132 }
133 }
134
135 fn session_with_fit() -> Session {
136 let mut session = Session::new();
137 session.model.add_term("IH").unwrap();
138 session.model.set_coefficients(&[0.0]).unwrap();
139 session.last_fit = Some(FitResult {
140 coefficients: vec![0.0],
141 sigma: vec![0.1],
142 sky_rms: 1.0,
143 term_names: vec!["IH".to_string()],
144 });
145 session
146 }
147
148 #[test]
149 fn no_fit_returns_error() {
150 let mut session = Session::new();
151 let result = Ghyst.execute(&mut session, &[]);
152 assert!(result.is_err());
153 }
154
155 #[test]
156 fn empty_observations_returns_message() {
157 let mut session = session_with_fit();
158 let result = Ghyst.execute(&mut session, &[]).unwrap();
159 match result {
160 CommandOutput::Text(s) => assert_eq!(s, "No active observations"),
161 _ => panic!("expected Text output"),
162 }
163 }
164
165 #[test]
166 fn pier_side_splitting() {
167 let mut session = session_with_fit();
168 session
169 .observations
170 .push(make_obs(0.0, 100.0, 45.0, 45.01, PierSide::East));
171 session
172 .observations
173 .push(make_obs(0.0, -50.0, 30.0, 30.005, PierSide::West));
174 session
175 .observations
176 .push(make_obs(0.0, 200.0, 60.0, 60.02, PierSide::East));
177 let residuals = compute_residuals(&session);
178 let (east, west) = split_by_pier(&residuals);
179 assert_eq!(east.len(), 2);
180 assert_eq!(west.len(), 1);
181 }
182
183 #[test]
184 fn terminal_output_contains_summary() {
185 let mut session = session_with_fit();
186 session
187 .observations
188 .push(make_obs(0.0, 100.0, 45.0, 45.01, PierSide::East));
189 session
190 .observations
191 .push(make_obs(0.0, -50.0, 30.0, 30.005, PierSide::West));
192 let result = Ghyst.execute(&mut session, &[]).unwrap();
193 match result {
194 CommandOutput::Text(s) => {
195 assert!(s.contains("Residual vs Observation Sequence"));
196 assert!(s.contains("East: 1 obs"));
197 assert!(s.contains("West: 1 obs"));
198 }
199 _ => panic!("expected Text output"),
200 }
201 }
202
203 #[test]
204 fn svg_writes_both_files() {
205 let mut session = session_with_fit();
206 session
207 .observations
208 .push(make_obs(0.0, 100.0, 45.0, 45.01, PierSide::East));
209 session
210 .observations
211 .push(make_obs(0.0, 200.0, 50.0, 50.02, PierSide::East));
212 session
213 .observations
214 .push(make_obs(0.0, -50.0, 30.0, 30.005, PierSide::West));
215 session
216 .observations
217 .push(make_obs(0.0, -80.0, 35.0, 35.008, PierSide::West));
218 let dir = std::env::temp_dir();
219 let path = dir.join("ghyst_test.svg");
220 let path_str = path.to_str().unwrap();
221 let result = Ghyst.execute(&mut session, &[path_str]).unwrap();
222 let east_path = dir.join("ghyst_test_east.svg");
223 let west_path = dir.join("ghyst_test_west.svg");
224 match &result {
225 CommandOutput::Text(s) => {
226 assert!(s.contains("Written to"));
227 assert!(s.contains("east"));
228 assert!(s.contains("west"));
229 }
230 _ => panic!("expected Text output"),
231 }
232 assert!(east_path.exists());
233 assert!(west_path.exists());
234 let east_svg = std::fs::read_to_string(&east_path).unwrap();
235 let west_svg = std::fs::read_to_string(&west_path).unwrap();
236 assert!(east_svg.contains("<svg"));
237 assert!(west_svg.contains("<svg"));
238 std::fs::remove_file(&east_path).ok();
239 std::fs::remove_file(&west_path).ok();
240 }
241}