celestial_pointing/commands/
gscat.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 Gscat;
10
11impl Command for Gscat {
12 fn name(&self) -> &str {
13 "GSCAT"
14 }
15
16 fn description(&self) -> &str {
17 "Scatter plot of residuals (dX vs dDec)"
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 points: Vec<(f64, f64)> = residuals.iter().map(|r| (r.dx, r.dd)).collect();
27 if let Some(path) = args.first() {
28 write_svg(&points, Path::new(path))
29 } else {
30 terminal_output(&points)
31 }
32 }
33}
34
35fn write_svg(points: &[(f64, f64)], path: &Path) -> Result<CommandOutput> {
36 crate::plot::svg::scatter_svg(
37 points,
38 path,
39 "Residual Scatter",
40 "dX (arcsec)",
41 "dDec (arcsec)",
42 )
43 .map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?;
44 Ok(CommandOutput::Text(format!("Wrote {}", path.display())))
45}
46
47fn terminal_output(points: &[(f64, f64)]) -> Result<CommandOutput> {
48 let text = crate::plot::terminal::scatter_terminal(
49 points,
50 "Residual Scatter (dX vs dDec)",
51 "dX (arcsec)",
52 "dDec (arcsec)",
53 );
54 Ok(CommandOutput::Text(text))
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60 use crate::observation::{Observation, PierSide};
61 use crate::solver::FitResult;
62 use celestial_core::Angle;
63
64 fn make_obs(
65 cmd_ha_arcsec: f64,
66 act_ha_arcsec: f64,
67 cat_dec_deg: f64,
68 obs_dec_deg: f64,
69 ) -> Observation {
70 Observation {
71 catalog_ra: Angle::from_hours(0.0),
72 catalog_dec: Angle::from_degrees(cat_dec_deg),
73 observed_ra: Angle::from_hours(0.0),
74 observed_dec: Angle::from_degrees(obs_dec_deg),
75 lst: Angle::from_hours(0.0),
76 commanded_ha: Angle::from_arcseconds(cmd_ha_arcsec),
77 actual_ha: Angle::from_arcseconds(act_ha_arcsec),
78 pier_side: PierSide::East,
79 masked: false,
80 }
81 }
82
83 fn session_with_fit() -> Session {
84 let mut session = Session::new();
85 session.model.add_term("IH").unwrap();
86 session.model.set_coefficients(&[0.0]).unwrap();
87 session.last_fit = Some(FitResult {
88 coefficients: vec![0.0],
89 sigma: vec![0.1],
90 sky_rms: 1.0,
91 term_names: vec!["IH".to_string()],
92 });
93 session
94 }
95
96 #[test]
97 fn no_fit_returns_error() {
98 let mut session = Session::new();
99 let result = Gscat.execute(&mut session, &[]);
100 assert!(result.is_err());
101 }
102
103 #[test]
104 fn empty_observations_returns_message() {
105 let mut session = session_with_fit();
106 let result = Gscat.execute(&mut session, &[]).unwrap();
107 match result {
108 CommandOutput::Text(s) => assert!(s.contains("No active observations")),
109 _ => panic!("expected Text output"),
110 }
111 }
112
113 #[test]
114 fn terminal_output_contains_title() {
115 let mut session = session_with_fit();
116 session.observations.push(make_obs(0.0, 100.0, 45.0, 45.01));
117 session
118 .observations
119 .push(make_obs(0.0, -50.0, 30.0, 30.005));
120 let result = Gscat.execute(&mut session, &[]).unwrap();
121 match result {
122 CommandOutput::Text(s) => {
123 assert!(s.contains("Residual Scatter"));
124 assert!(s.contains("dX (arcsec)"));
125 assert!(s.contains("dDec (arcsec)"));
126 }
127 _ => panic!("expected Text output"),
128 }
129 }
130
131 #[test]
132 fn svg_writes_to_temp_file() {
133 let mut session = session_with_fit();
134 session.observations.push(make_obs(0.0, 100.0, 45.0, 45.01));
135 session
136 .observations
137 .push(make_obs(0.0, -50.0, 30.0, 30.005));
138 let dir = std::env::temp_dir();
139 let path = dir.join("gscat_test.svg");
140 let path_str = path.to_str().unwrap();
141 let result = Gscat.execute(&mut session, &[path_str]).unwrap();
142 match &result {
143 CommandOutput::Text(s) => assert!(s.contains("Wrote")),
144 _ => panic!("expected Text output"),
145 }
146 assert!(path.exists());
147 let contents = std::fs::read_to_string(&path).unwrap();
148 assert!(contents.contains("<svg"));
149 std::fs::remove_file(&path).ok();
150 }
151}