celestial_pointing/commands/
gdist.rs1use std::path::Path;
2
3use super::{Command, CommandOutput};
4use crate::error::Result;
5use crate::plot::residuals::{compute_residuals, require_fit};
6use crate::session::Session;
7
8pub struct Gdist;
9
10impl Command for Gdist {
11 fn name(&self) -> &str {
12 "GDIST"
13 }
14 fn description(&self) -> &str {
15 "Histogram of residual distribution"
16 }
17
18 fn execute(&self, session: &mut Session, args: &[&str]) -> Result<CommandOutput> {
19 require_fit(session)?;
20 let residuals = compute_residuals(session);
21 if residuals.is_empty() {
22 return Ok(CommandOutput::Text("No active observations".to_string()));
23 }
24 let dx_vals: Vec<f64> = residuals.iter().map(|r| r.dx).collect();
25 let dd_vals: Vec<f64> = residuals.iter().map(|r| r.dd).collect();
26
27 match args.first() {
28 Some(path) => svg_output(path, args, &dx_vals, &dd_vals),
29 None => terminal_output(&dx_vals, &dd_vals),
30 }
31 }
32}
33
34fn terminal_output(dx: &[f64], dd: &[f64]) -> Result<CommandOutput> {
35 let dx_hist = crate::plot::terminal::histogram_terminal(dx, "dX Distribution", "dX");
36 let dd_hist = crate::plot::terminal::histogram_terminal(dd, "dDec Distribution", "dDec");
37 Ok(CommandOutput::Text(format!("{dx_hist}\n{dd_hist}")))
38}
39
40fn svg_output(path: &str, args: &[&str], dx: &[f64], dd: &[f64]) -> Result<CommandOutput> {
41 let is_dec = args.get(1).is_some_and(|a| a.eq_ignore_ascii_case("D"));
42 let (values, title, label) = if is_dec {
43 (dd, "dDec Distribution", "dDec (arcsec)")
44 } else {
45 (dx, "dX Distribution", "dX (arcsec)")
46 };
47 write_svg(values, Path::new(path), title, label)
48}
49
50fn write_svg(values: &[f64], path: &Path, title: &str, x_label: &str) -> Result<CommandOutput> {
51 crate::plot::svg::histogram_svg(values, path, title, x_label)
52 .map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?;
53 Ok(CommandOutput::Text(format!(
54 "Written to {}",
55 path.display()
56 )))
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use crate::session::Session;
63
64 #[test]
65 fn no_fit_returns_error() {
66 let mut session = Session::new();
67 let result = Gdist.execute(&mut session, &[]);
68 let err = result.err().expect("expected error");
69 assert!(err.to_string().contains("no fit"));
70 }
71
72 #[test]
73 fn empty_observations_returns_message() {
74 let mut session = Session::new();
75 session.last_fit = Some(crate::solver::FitResult {
76 coefficients: vec![1.0],
77 sigma: vec![0.1],
78 sky_rms: 5.0,
79 term_names: vec!["IH".to_string()],
80 });
81 let result = Gdist.execute(&mut session, &[]).unwrap();
82 match result {
83 CommandOutput::Text(s) => assert_eq!(s, "No active observations"),
84 _ => panic!("expected Text output"),
85 }
86 }
87
88 #[test]
89 fn terminal_output_contains_both_distributions() {
90 let mut session = build_session_with_obs();
91 let result = Gdist.execute(&mut session, &[]).unwrap();
92 match result {
93 CommandOutput::Text(s) => {
94 assert!(s.contains("dX Distribution"), "missing dX Distribution");
95 assert!(s.contains("dDec Distribution"), "missing dDec Distribution");
96 }
97 _ => panic!("expected Text output"),
98 }
99 }
100
101 fn build_session_with_obs() -> Session {
102 use crate::observation::{Observation, PierSide};
103 use celestial_core::Angle;
104
105 let mut session = Session::new();
106 session.last_fit = Some(crate::solver::FitResult {
107 coefficients: vec![],
108 sigma: vec![],
109 sky_rms: 5.0,
110 term_names: vec![],
111 });
112 for i in 0..10 {
113 let offset = (i as f64) * 10.0;
114 session.observations.push(Observation {
115 catalog_ra: Angle::from_hours(0.0),
116 catalog_dec: Angle::from_degrees(45.0),
117 observed_ra: Angle::from_hours(0.0),
118 observed_dec: Angle::from_degrees(45.0 + offset / 3600.0),
119 lst: Angle::from_hours(0.0),
120 commanded_ha: Angle::from_arcseconds(0.0),
121 actual_ha: Angle::from_arcseconds(offset),
122 pier_side: PierSide::East,
123 masked: false,
124 });
125 }
126 session
127 }
128}