Skip to main content

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