Skip to main content

celestial_pointing/commands/
predict.rs

1use super::{Command, CommandOutput};
2use crate::error::Result;
3use crate::observation::PierSide;
4use crate::parser::parse_coordinates;
5use crate::session::Session;
6use celestial_core::Angle;
7
8pub struct Predict;
9
10impl Command for Predict {
11    fn name(&self) -> &str {
12        "PREDICT"
13    }
14    fn description(&self) -> &str {
15        "Show correction breakdown by term"
16    }
17
18    fn execute(&self, session: &mut Session, args: &[&str]) -> Result<CommandOutput> {
19        let (ra, dec) = parse_coordinates(args)?;
20        let lst = session.current_lst()?;
21        let lat = session.latitude();
22        let ha = lst - ra;
23        let pier = pier_from_ha(ha);
24        let breakdown =
25            session
26                .model
27                .predict_breakdown(ha.radians(), dec.radians(), lat, pier.sign());
28        let (cmd_ra, cmd_dec) =
29            session
30                .model
31                .target_to_command(ra, dec, lst, Angle::from_radians(lat), pier);
32        Ok(CommandOutput::Text(format_predict(
33            ra, dec, ha, &breakdown, cmd_ra, cmd_dec,
34        )))
35    }
36}
37
38fn pier_from_ha(ha: Angle) -> PierSide {
39    if ha.radians() >= 0.0 {
40        PierSide::East
41    } else {
42        PierSide::West
43    }
44}
45
46fn format_predict(
47    ra: Angle,
48    dec: Angle,
49    ha: Angle,
50    breakdown: &[(String, f64, f64)],
51    cmd_ra: Angle,
52    cmd_dec: Angle,
53) -> String {
54    let mut lines = Vec::new();
55    lines.push(format!("Target: {}  {}", format_ra(ra), format_dec(dec)));
56    lines.push(format!("HA: {}  Dec: {}", format_ha(ha), format_dec(dec)));
57    lines.push(String::new());
58    lines.push(format!(
59        "{:<12} {:>10} {:>10}",
60        "Term", "\u{0394}HA (\")", "\u{0394}Dec (\")"
61    ));
62    lines.push("\u{2500}".repeat(34));
63    let (total_dh, total_dd) = append_breakdown(&mut lines, breakdown);
64    lines.push("\u{2500}".repeat(34));
65    lines.push(format!(
66        "{:<12} {:>10.2} {:>10.2}",
67        "Total", total_dh, total_dd
68    ));
69    lines.push(String::new());
70    lines.push(format!(
71        "Command: {}  {}",
72        format_ra(cmd_ra),
73        format_dec(cmd_dec)
74    ));
75    lines.join("\n")
76}
77
78fn append_breakdown(lines: &mut Vec<String>, breakdown: &[(String, f64, f64)]) -> (f64, f64) {
79    let mut total_dh = 0.0;
80    let mut total_dd = 0.0;
81    for (name, dh, dd) in breakdown {
82        lines.push(format!("{:<12} {:>10.2} {:>10.2}", name, dh, dd));
83        total_dh += dh;
84        total_dd += dd;
85    }
86    (total_dh, total_dd)
87}
88
89fn format_ra(angle: Angle) -> String {
90    let h = angle.hours().abs();
91    let hh = libm::floor(h) as u32;
92    let remainder = (h - hh as f64) * 60.0;
93    let mm = libm::floor(remainder) as u32;
94    let ss = (remainder - mm as f64) * 60.0;
95    format!("{:02}h {:02}m {:05.2}s", hh, mm, ss)
96}
97
98fn format_dec(angle: Angle) -> String {
99    let deg = angle.degrees();
100    let sign = if deg < 0.0 { "-" } else { "+" };
101    let total = deg.abs();
102    let dd = libm::floor(total) as u32;
103    let remainder = (total - dd as f64) * 60.0;
104    let mm = libm::floor(remainder) as u32;
105    let ss = (remainder - mm as f64) * 60.0;
106    format!("{}{:02}\u{00b0} {:02}' {:04.1}\"", sign, dd, mm, ss)
107}
108
109fn format_ha(angle: Angle) -> String {
110    let h = angle.hours();
111    let sign = if h < 0.0 { "-" } else { "+" };
112    let total = h.abs();
113    let hh = libm::floor(total) as u32;
114    let remainder = (total - hh as f64) * 60.0;
115    let mm = libm::floor(remainder) as u32;
116    let ss = (remainder - mm as f64) * 60.0;
117    format!("{}{:02}h {:02}m {:05.2}s", sign, hh, mm, ss)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::session::Session;
124
125    #[test]
126    fn empty_model_zero_correction() {
127        let mut session = Session::new();
128        session.lst_override = Some(Angle::from_hours(14.0));
129        let result = Predict.execute(&mut session, &["12.5", "45.0"]).unwrap();
130        match result {
131            CommandOutput::Text(s) => {
132                assert!(s.contains("Total"));
133                assert!(s.contains("0.00"));
134                assert!(s.contains("Command:"));
135            }
136            _ => panic!("expected Text output"),
137        }
138    }
139
140    #[test]
141    fn single_ih_term() {
142        let mut session = Session::new();
143        session.lst_override = Some(Angle::from_hours(14.0));
144        session.model.add_term("IH").unwrap();
145        session.model.set_coefficients(&[10.0]).unwrap();
146        let result = Predict.execute(&mut session, &["12.5", "45.0"]).unwrap();
147        match result {
148            CommandOutput::Text(s) => {
149                assert!(s.contains("IH"));
150                assert!(s.contains("-10.00"));
151            }
152            _ => panic!("expected Text output"),
153        }
154    }
155
156    #[test]
157    fn total_matches_sum() {
158        let mut session = Session::new();
159        session.lst_override = Some(Angle::from_hours(14.0));
160        session.model.add_term("IH").unwrap();
161        session.model.add_term("ID").unwrap();
162        session.model.set_coefficients(&[10.0, 20.0]).unwrap();
163        let ha = Angle::from_hours(14.0) - Angle::from_hours(12.5);
164        let breakdown = session.model.predict_breakdown(
165            ha.radians(),
166            Angle::from_degrees(45.0).radians(),
167            0.0,
168            PierSide::East.sign(),
169        );
170        let sum_dh: f64 = breakdown.iter().map(|(_, dh, _)| dh).sum();
171        let sum_dd: f64 = breakdown.iter().map(|(_, _, dd)| dd).sum();
172        let (total_dh, total_dd) = session.model.apply_equatorial(
173            ha.radians(),
174            Angle::from_degrees(45.0).radians(),
175            0.0,
176            PierSide::East.sign(),
177        );
178        assert_eq!(sum_dh, total_dh);
179        assert_eq!(sum_dd, total_dd);
180    }
181
182    #[test]
183    fn requires_lst() {
184        let mut session = Session::new();
185        let result = Predict.execute(&mut session, &["12.5", "45.0"]);
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn requires_coordinates() {
191        let mut session = Session::new();
192        session.lst_override = Some(Angle::from_hours(14.0));
193        let result = Predict.execute(&mut session, &[]);
194        assert!(result.is_err());
195    }
196
197    #[test]
198    fn pier_from_ha_positive_is_east() {
199        let ha = Angle::from_hours(2.0);
200        assert_eq!(pier_from_ha(ha), PierSide::East);
201    }
202
203    #[test]
204    fn pier_from_ha_negative_is_west() {
205        let ha = Angle::from_hours(-2.0);
206        assert_eq!(pier_from_ha(ha), PierSide::West);
207    }
208
209    #[test]
210    fn format_ra_basic() {
211        let ra = Angle::from_hours(12.5);
212        assert_eq!(format_ra(ra), "12h 30m 00.00s");
213    }
214
215    #[test]
216    fn format_dec_positive() {
217        let dec = Angle::from_degrees(45.0);
218        assert_eq!(format_dec(dec), "+45\u{00b0} 00' 00.0\"");
219    }
220
221    #[test]
222    fn format_dec_negative() {
223        let dec = Angle::from_degrees(-30.5);
224        assert_eq!(format_dec(dec), "-30\u{00b0} 30' 00.0\"");
225    }
226
227    #[test]
228    fn format_ha_positive() {
229        let ha = Angle::from_hours(2.25);
230        assert_eq!(format_ha(ha), "+02h 15m 00.00s");
231    }
232
233    #[test]
234    fn format_ha_negative() {
235        let ha = Angle::from_hours(-3.0);
236        assert_eq!(format_ha(ha), "-03h 00m 00.00s");
237    }
238}