Skip to main content

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