Skip to main content

celestial_pointing/
parser.rs

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}