1use crate::error::{Error, Result};
2use crate::observation::{
3 decode_pier_side, IndatFile, IndatOption, MountType, Observation, PierSide, SiteParams,
4};
5use celestial_core::Angle;
6use celestial_time::JulianDate;
7
8pub fn parse_indat(content: &str) -> Result<IndatFile> {
9 let mut header_lines = Vec::new();
10 let mut options = Vec::new();
11 let mut mount_type = MountType::GermanEquatorial;
12 let mut site_and_date = None;
13 let mut observations = Vec::new();
14
15 for line in content.lines() {
16 let trimmed = line.trim();
17 if trimmed.is_empty() {
18 continue;
19 }
20 if site_and_date.is_some() {
21 observations.push(parse_observation_line(trimmed)?);
22 } else {
23 classify_line(
24 trimmed,
25 &mut header_lines,
26 &mut options,
27 &mut mount_type,
28 &mut site_and_date,
29 )?;
30 }
31 }
32
33 let (site, date) = site_and_date.ok_or_else(|| Error::Parse("no site line found".into()))?;
34 Ok(IndatFile {
35 site,
36 options,
37 observations,
38 mount_type,
39 header_lines,
40 date,
41 })
42}
43
44fn classify_line(
45 line: &str,
46 headers: &mut Vec<String>,
47 options: &mut Vec<IndatOption>,
48 mount: &mut MountType,
49 site: &mut Option<(SiteParams, JulianDate)>,
50) -> Result<()> {
51 if line.starts_with('!') {
52 headers.push(line.to_string());
53 } else if line.starts_with(':') {
54 let (opt, maybe_mount) = parse_option(line)?;
55 options.push(opt);
56 if let Some(m) = maybe_mount {
57 *mount = m;
58 }
59 } else if is_site_line(line) {
60 *site = Some(parse_site_line(line)?);
61 } else {
62 headers.push(line.to_string());
63 }
64 Ok(())
65}
66
67fn is_site_line(line: &str) -> bool {
68 let first = line.trim().as_bytes().first().copied().unwrap_or(0);
69 first == b'+' || first == b'-' || first.is_ascii_digit()
70}
71
72fn parse_option(line: &str) -> Result<(IndatOption, Option<MountType>)> {
73 let keyword = line.trim_start_matches(':').trim().to_uppercase();
74 match keyword.as_str() {
75 "NODA" => Ok((IndatOption::NoDA, None)),
76 "ALLSKY" => Ok((IndatOption::AllSky, None)),
77 "EQUINOX" => Ok((IndatOption::Equinox, None)),
78 "EQUAT" => Ok((IndatOption::Equatorial, Some(MountType::GermanEquatorial))),
79 "ALTAZ" => Ok((IndatOption::Altaz, Some(MountType::Altazimuth))),
80 "ROTTEL" => Ok((IndatOption::RotatorTelescope, None)),
81 "ROTNL" => Ok((IndatOption::RotatorNasmythLeft, None)),
82 "ROTNR" => Ok((IndatOption::RotatorNasmythRight, None)),
83 "ROTCL" => Ok((IndatOption::RotatorCoudeLeft, None)),
84 "ROTCR" => Ok((IndatOption::RotatorCoudeRight, None)),
85 s if s.starts_with("GIMBAL") => parse_gimbal(s),
86 _ => Err(Error::Parse(format!("unknown option: {}", keyword))),
87 }
88}
89
90fn parse_gimbal(s: &str) -> Result<(IndatOption, Option<MountType>)> {
91 let parts: Vec<&str> = s.split_whitespace().collect();
92 if parts.len() != 4 {
93 return Err(Error::Parse(format!(
94 "GIMBAL requires 3 angles, got: {}",
95 s
96 )));
97 }
98 let z = parse_f64(parts[1], "gimbal z")?;
99 let y = parse_f64(parts[2], "gimbal y")?;
100 let x = parse_f64(parts[3], "gimbal x")?;
101 Ok((
102 IndatOption::Gimbal {
103 z: Angle::from_degrees(z),
104 y: Angle::from_degrees(y),
105 x: Angle::from_degrees(x),
106 },
107 None,
108 ))
109}
110
111fn parse_site_line(line: &str) -> Result<(SiteParams, JulianDate)> {
112 let p: Vec<&str> = line.split_whitespace().collect();
113 if p.len() < 12 {
114 return Err(Error::Parse(format!(
115 "site line needs 12 fields, got {}",
116 p.len()
117 )));
118 }
119 let lat = parse_dms_latitude(
120 parse_f64(p[0], "lat_d")?,
121 parse_f64(p[1], "lat_m")?,
122 parse_f64(p[2], "lat_s")?,
123 );
124 let date = build_julian_date(&p[3..6])?;
125 let site = SiteParams {
126 latitude: lat,
127 longitude: Angle::from_degrees(0.0),
128 temperature: parse_f64(p[6], "temp")?,
129 pressure: parse_f64(p[7], "pressure")?,
130 elevation: parse_f64(p[8], "elevation")?,
131 humidity: parse_f64(p[9], "humidity")?,
132 wavelength: parse_f64(p[10], "wavelength")?,
133 lapse_rate: parse_f64(p[11], "lapse_rate")?,
134 };
135 Ok((site, date))
136}
137
138fn build_julian_date(parts: &[&str]) -> Result<JulianDate> {
139 let year: i32 = parts[0]
140 .parse()
141 .map_err(|e| Error::Parse(format!("year: {}", e)))?;
142 let month: u8 = parts[1]
143 .parse()
144 .map_err(|e| Error::Parse(format!("month: {}", e)))?;
145 let day: u8 = parts[2]
146 .parse()
147 .map_err(|e| Error::Parse(format!("day: {}", e)))?;
148 Ok(JulianDate::from_calendar(year, month, day, 0, 0, 0.0))
149}
150
151fn parse_dms_latitude(d: f64, m: f64, s: f64) -> Angle {
152 let sign = if d < 0.0 || (d == 0.0 && d.is_sign_negative()) {
153 -1.0
154 } else {
155 1.0
156 };
157 let deg = d.abs() + m / 60.0 + s / 3600.0;
158 Angle::from_degrees(sign * deg)
159}
160
161fn parse_observation_line(line: &str) -> Result<Observation> {
162 let p: Vec<&str> = line.split_whitespace().collect();
163 if p.len() < 14 {
164 return Err(Error::Parse(format!(
165 "obs line needs 14 fields, got {}",
166 p.len()
167 )));
168 }
169 let catalog_ra = parse_ra(&p[0..3])?;
170 let catalog_dec = parse_dec_as_angle(&p[3..6])?;
171 let tel_ra = parse_ra(&p[6..9])?;
172 let raw_tel_dec_deg = parse_dec_raw(&p[9..12])?;
173 let lst = parse_lst(&p[12..14])?;
174
175 let (observed_dec, pier_side) = decode_pier_side(raw_tel_dec_deg);
176 let observed_ra = compute_observed_ra(tel_ra, &pier_side);
177 let commanded_ha = (lst - catalog_ra).wrapped();
178 let actual_ha = (lst - observed_ra).wrapped();
179
180 Ok(Observation {
181 catalog_ra,
182 catalog_dec,
183 observed_ra,
184 observed_dec,
185 lst,
186 commanded_ha,
187 actual_ha,
188 pier_side,
189 masked: false,
190 })
191}
192
193fn compute_observed_ra(tel_ra: Angle, pier_side: &PierSide) -> Angle {
194 match pier_side {
195 PierSide::West => (tel_ra + Angle::from_hours(12.0)).normalized(),
196 _ => tel_ra,
197 }
198}
199
200fn parse_ra(parts: &[&str]) -> Result<Angle> {
201 let h = parse_f64(parts[0], "ra_h")?;
202 let m = parse_f64(parts[1], "ra_m")?;
203 let s = parse_f64(parts[2], "ra_s")?;
204 Ok(Angle::from_hours(h + m / 60.0 + s / 3600.0))
205}
206
207fn parse_dec_raw(parts: &[&str]) -> Result<f64> {
208 let d = parse_f64(parts[0], "dec_d")?;
209 let m = parse_f64(parts[1], "dec_m")?;
210 let s = parse_f64(parts[2], "dec_s")?;
211 let sign = if d < 0.0 || (d == 0.0 && parts[0].starts_with('-')) {
212 -1.0
213 } else {
214 1.0
215 };
216 Ok(sign * (d.abs() + m / 60.0 + s / 3600.0))
217}
218
219fn parse_dec_as_angle(parts: &[&str]) -> Result<Angle> {
220 Ok(Angle::from_degrees(parse_dec_raw(parts)?))
221}
222
223fn parse_lst(parts: &[&str]) -> Result<Angle> {
224 let h = parse_f64(parts[0], "lst_h")?;
225 let m = parse_f64(parts[1], "lst_m")?;
226 Ok(Angle::from_hours(h + m / 60.0))
227}
228
229fn parse_lst_hms(parts: &[&str]) -> Result<Angle> {
230 let h = parse_f64(parts[0], "lst_h")?;
231 let m = parse_f64(parts[1], "lst_m")?;
232 let s = parse_f64(parts[2], "lst_s")?;
233 Ok(Angle::from_hours(h + m / 60.0 + s / 3600.0))
234}
235
236pub fn parse_coordinates(args: &[&str]) -> Result<(Angle, Angle)> {
237 match args.len() {
238 6 => {
239 let ra = parse_ra(&args[0..3])?;
240 let dec = parse_dec_as_angle(&args[3..6])?;
241 Ok((ra, dec))
242 }
243 2 => {
244 let ra_hours = parse_f64(args[0], "ra_hours")?;
245 let dec_deg = parse_f64(args[1], "dec_degrees")?;
246 Ok((Angle::from_hours(ra_hours), Angle::from_degrees(dec_deg)))
247 }
248 _ => Err(Error::Parse(
249 "expected 6 args (h m s d m s) or 2 args (decimal_hours decimal_degrees)".into(),
250 )),
251 }
252}
253
254pub fn parse_lst_args(args: &[&str]) -> Result<Angle> {
255 match args.len() {
256 3 => parse_lst_hms(args),
257 1 => {
258 let hours = parse_f64(args[0], "lst_hours")?;
259 Ok(Angle::from_hours(hours))
260 }
261 _ => Err(Error::Parse(
262 "expected 3 args (h m s) or 1 arg (decimal_hours)".into(),
263 )),
264 }
265}
266
267fn parse_f64(s: &str, field: &str) -> Result<f64> {
268 s.parse::<f64>()
269 .map_err(|e| Error::Parse(format!("{}: {}", field, e)))
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 const SIMPLE_DAT: &str = "\
277ASCOM Mount
278:NODA
279:EQUAT
280+39 00 26 2024 7 14 29.20 987.00 231.65 0.94 0.5500 0.0065
28121 43 18.4460 +72 29 08.368 09 28 59.9527 +109 20 06.469 16 23.130
28223 46 02.2988 +77 38 38.725 11 26 17.6308 +104 03 28.734 16 24.711";
283
284 #[test]
285 fn parse_header_lines() {
286 let indat = parse_indat(SIMPLE_DAT).unwrap();
287 assert_eq!(indat.header_lines.len(), 1);
288 assert_eq!(indat.header_lines[0], "ASCOM Mount");
289 }
290
291 #[test]
292 fn parse_options_count_and_values() {
293 let indat = parse_indat(SIMPLE_DAT).unwrap();
294 assert_eq!(indat.options.len(), 2);
295 assert_eq!(indat.options[0], IndatOption::NoDA);
296 assert_eq!(indat.options[1], IndatOption::Equatorial);
297 }
298
299 #[test]
300 fn parse_mount_type() {
301 let indat = parse_indat(SIMPLE_DAT).unwrap();
302 assert_eq!(indat.mount_type, MountType::GermanEquatorial);
303 }
304
305 #[test]
306 fn parse_observation_count() {
307 let indat = parse_indat(SIMPLE_DAT).unwrap();
308 assert_eq!(indat.observations.len(), 2);
309 }
310
311 #[test]
312 fn parse_site_latitude() {
313 let indat = parse_indat(SIMPLE_DAT).unwrap();
314 let expected = Angle::from_degrees(39.0 + 0.0 / 60.0 + 26.0 / 3600.0);
315 assert_eq!(indat.site.latitude, expected);
316 }
317
318 #[test]
319 fn parse_site_conditions() {
320 let indat = parse_indat(SIMPLE_DAT).unwrap();
321 assert_eq!(indat.site.temperature, 29.20);
322 assert_eq!(indat.site.pressure, 987.00);
323 assert_eq!(indat.site.elevation, 231.65);
324 assert_eq!(indat.site.humidity, 0.94);
325 assert_eq!(indat.site.wavelength, 0.5500);
326 assert_eq!(indat.site.lapse_rate, 0.0065);
327 assert_eq!(indat.site.longitude, Angle::from_degrees(0.0));
328 }
329
330 #[test]
331 fn parse_site_date() {
332 let indat = parse_indat(SIMPLE_DAT).unwrap();
333 let expected = JulianDate::from_calendar(2024, 7, 14, 0, 0, 0.0);
334 assert_eq!(indat.date, expected);
335 }
336
337 #[test]
338 fn first_obs_catalog_ra() {
339 let indat = parse_indat(SIMPLE_DAT).unwrap();
340 let obs = &indat.observations[0];
341 let expected = Angle::from_hours(21.0 + 43.0 / 60.0 + 18.4460 / 3600.0);
342 assert_eq!(obs.catalog_ra, expected);
343 }
344
345 #[test]
346 fn first_obs_catalog_dec() {
347 let indat = parse_indat(SIMPLE_DAT).unwrap();
348 let obs = &indat.observations[0];
349 let expected = Angle::from_degrees(72.0 + 29.0 / 60.0 + 8.368 / 3600.0);
350 assert_eq!(obs.catalog_dec, expected);
351 }
352
353 #[test]
354 fn first_obs_pier_side_west() {
355 let indat = parse_indat(SIMPLE_DAT).unwrap();
356 let obs = &indat.observations[0];
357 assert_eq!(obs.pier_side, PierSide::West);
358 }
359
360 #[test]
361 fn second_obs_pier_side_west() {
362 let indat = parse_indat(SIMPLE_DAT).unwrap();
363 let obs = &indat.observations[1];
364 assert_eq!(obs.pier_side, PierSide::West);
365 }
366
367 #[test]
368 fn first_obs_lst() {
369 let indat = parse_indat(SIMPLE_DAT).unwrap();
370 let obs = &indat.observations[0];
371 let expected = Angle::from_hours(16.0 + 23.130 / 60.0);
372 assert_eq!(obs.lst, expected);
373 }
374
375 #[test]
376 fn first_obs_observed_ra_includes_12h_flip() {
377 let indat = parse_indat(SIMPLE_DAT).unwrap();
378 let obs = &indat.observations[0];
379 let tel_ra = Angle::from_hours(9.0 + 28.0 / 60.0 + 59.9527 / 3600.0);
380 let expected = (tel_ra + Angle::from_hours(12.0)).normalized();
381 assert_eq!(obs.observed_ra, expected);
382 }
383
384 #[test]
385 fn first_obs_observed_dec_decoded() {
386 let indat = parse_indat(SIMPLE_DAT).unwrap();
387 let obs = &indat.observations[0];
388 let raw_deg = 109.0 + 20.0 / 60.0 + 6.469 / 3600.0;
389 let (expected_dec, _) = decode_pier_side(raw_deg);
390 assert_eq!(obs.observed_dec, expected_dec);
391 }
392
393 #[test]
394 fn first_obs_commanded_ha() {
395 let indat = parse_indat(SIMPLE_DAT).unwrap();
396 let obs = &indat.observations[0];
397 let expected = obs.lst - obs.catalog_ra;
398 assert_eq!(obs.commanded_ha, expected);
399 }
400
401 #[test]
402 fn first_obs_actual_ha() {
403 let indat = parse_indat(SIMPLE_DAT).unwrap();
404 let obs = &indat.observations[0];
405 let expected = obs.lst - obs.observed_ra;
406 assert_eq!(obs.actual_ha, expected);
407 }
408
409 #[test]
410 fn parse_dms_latitude_positive() {
411 let lat = parse_dms_latitude(39.0, 0.0, 26.0);
412 assert_eq!(lat, Angle::from_degrees(39.0 + 26.0 / 3600.0));
413 }
414
415 #[test]
416 fn parse_dms_latitude_negative() {
417 let lat = parse_dms_latitude(-33.0, 15.0, 30.0);
418 let expected = Angle::from_degrees(-(33.0 + 15.0 / 60.0 + 30.0 / 3600.0));
419 assert_eq!(lat, expected);
420 }
421
422 #[test]
423 fn option_case_insensitive() {
424 let (opt, _) = parse_option(":noda").unwrap();
425 assert_eq!(opt, IndatOption::NoDA);
426 }
427
428 #[test]
429 fn option_altaz_sets_mount() {
430 let (opt, mount) = parse_option(":ALTAZ").unwrap();
431 assert_eq!(opt, IndatOption::Altaz);
432 assert_eq!(mount, Some(MountType::Altazimuth));
433 }
434
435 #[test]
436 fn empty_content_errors() {
437 let result = parse_indat("");
438 assert!(result.is_err());
439 }
440
441 #[test]
442 fn no_site_line_errors() {
443 let result = parse_indat("!comment\n:NODA\n");
444 assert!(result.is_err());
445 }
446
447 #[test]
448 fn parse_coordinates_sexagesimal() {
449 let args = vec!["12", "30", "00", "+45", "00", "00"];
450 let (ra, dec) = parse_coordinates(&args).unwrap();
451 assert_eq!(ra, Angle::from_hours(12.0 + 30.0 / 60.0));
452 assert_eq!(dec, Angle::from_degrees(45.0));
453 }
454
455 #[test]
456 fn parse_coordinates_sexagesimal_negative_dec() {
457 let args = vec!["6", "0", "0", "-30", "15", "30"];
458 let (ra, dec) = parse_coordinates(&args).unwrap();
459 assert_eq!(ra, Angle::from_hours(6.0));
460 let expected_dec = Angle::from_degrees(-(30.0 + 15.0 / 60.0 + 30.0 / 3600.0));
461 assert_eq!(dec, expected_dec);
462 }
463
464 #[test]
465 fn parse_coordinates_decimal() {
466 let args = vec!["12.5", "45.0"];
467 let (ra, dec) = parse_coordinates(&args).unwrap();
468 assert_eq!(ra, Angle::from_hours(12.5));
469 assert_eq!(dec, Angle::from_degrees(45.0));
470 }
471
472 #[test]
473 fn parse_coordinates_wrong_arg_count() {
474 let args = vec!["12", "30", "00", "+45"];
475 assert!(parse_coordinates(&args).is_err());
476 }
477
478 #[test]
479 fn parse_coordinates_zero_args() {
480 let args: Vec<&str> = vec![];
481 assert!(parse_coordinates(&args).is_err());
482 }
483
484 #[test]
485 fn parse_lst_args_hms() {
486 let args = vec!["14", "30", "0"];
487 let lst = parse_lst_args(&args).unwrap();
488 assert_eq!(lst, Angle::from_hours(14.5));
489 }
490
491 #[test]
492 fn parse_lst_args_decimal() {
493 let args = vec!["14.5"];
494 let lst = parse_lst_args(&args).unwrap();
495 assert_eq!(lst, Angle::from_hours(14.5));
496 }
497
498 #[test]
499 fn parse_lst_args_wrong_count() {
500 let args = vec!["14", "30"];
501 assert!(parse_lst_args(&args).is_err());
502 }
503}