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