Skip to main content

celestial_core/angle/
parse.rs

1//! Angle parsing from string representations.
2//!
3//! This module provides flexible parsing for angles in formats commonly used in astronomy:
4//!
5//! - **HMS (Hours-Minutes-Seconds)**: Used for Right Ascension. 1 hour = 15 degrees.
6//! - **DMS (Degrees-Minutes-Seconds)**: Used for Declination, altitude, and general angles.
7//! - **Decimal**: Plain numeric values with explicit unit conversion.
8//!
9//! # Format Support
10//!
11//! Both HMS and DMS accept multiple notations:
12//!
13//! ```text
14//! Colon-separated:  12:34:56.789
15//! Letter markers:   12h34m56.789s  or  45d30m15s
16//! Verbose:          12 hours 34 minutes 56 seconds
17//! Symbol notation:  45d 30' 15"  or  45d 30' 15''
18//! ```
19//!
20//! Signs are only valid at the beginning: `-12:34:56` works, `12:-34:56` does not.
21//!
22//! # Usage Patterns
23//!
24//! Two traits provide parsing:
25//!
26//! - [`AngleUnits`]: Explicit unit conversion via methods like `.deg()`, `.hms()`
27//! - [`ParseAngle`]: Auto-detection via `.to_angle()` (tries HMS, then DMS, then decimal degrees)
28//!
29//! ```
30//! use celestial_core::angle::{AngleUnits, ParseAngle};
31//!
32//! // Explicit unit - you know what format you have
33//! let ra = "12:34:56".hms().unwrap();      // Right ascension
34//! let dec = "-45:30:15".dms().unwrap();    // Declination
35//! let alt = "30.5".deg().unwrap();         // Altitude in degrees
36//!
37//! // Auto-detection - useful for user input
38//! let angle = "12:34:56".to_angle().unwrap();  // Parsed as HMS (tries first)
39//! let angle = "45d30m15s".to_angle().unwrap(); // Parsed as DMS
40//! let angle = "45.5".to_angle().unwrap();      // Parsed as decimal degrees
41//! ```
42//!
43//! # HMS vs DMS Ambiguity
44//!
45//! The colon format `12:34:56` is ambiguous - it could be HMS or DMS. When using
46//! auto-detection (`.to_angle()`), HMS is tried first. If you know the intended
47//! interpretation, use `.hms()` or `.dms()` explicitly.
48//!
49//! For Right Ascension, always use `.hms()`. For Declination, always use `.dms()`.
50
51use super::Angle;
52use crate::AstroError;
53use once_cell::sync::Lazy;
54use regex::Regex;
55
56/// Parse strings as angles with explicit unit specification.
57///
58/// Implemented for `str`. Each method interprets the string value in its respective unit.
59///
60/// # Decimal Methods
61///
62/// - `deg()` - Parse as decimal degrees
63/// - `rad()` - Parse as radians
64/// - `hours()` - Parse as decimal hours (1h = 15 deg)
65/// - `arcmin()` - Parse as arcminutes (60' = 1 deg)
66/// - `arcsec()` - Parse as arcseconds (3600" = 1 deg)
67///
68/// # Sexagesimal Methods
69///
70/// - `hms()` - Parse hours:minutes:seconds format
71/// - `dms()` - Parse degrees:minutes:seconds format
72pub trait AngleUnits {
73    /// Parse as decimal degrees.
74    fn deg(&self) -> Result<Angle, AstroError>;
75    /// Parse as radians.
76    fn rad(&self) -> Result<Angle, AstroError>;
77    /// Parse as decimal hours (1 hour = 15 degrees).
78    fn hours(&self) -> Result<Angle, AstroError>;
79    /// Parse as arcminutes (60 arcmin = 1 degree).
80    fn arcmin(&self) -> Result<Angle, AstroError>;
81    /// Parse as arcseconds (3600 arcsec = 1 degree).
82    fn arcsec(&self) -> Result<Angle, AstroError>;
83    /// Parse degrees-minutes-seconds format. See module docs for accepted formats.
84    fn dms(&self) -> Result<Angle, AstroError>;
85    /// Parse hours-minutes-seconds format. See module docs for accepted formats.
86    fn hms(&self) -> Result<Angle, AstroError>;
87}
88
89impl AngleUnits for str {
90    #[inline]
91    fn deg(&self) -> Result<Angle, AstroError> {
92        parse_decimal(self).map(Angle::from_degrees)
93    }
94
95    #[inline]
96    fn rad(&self) -> Result<Angle, AstroError> {
97        parse_decimal(self).map(Angle::from_radians)
98    }
99
100    #[inline]
101    fn hours(&self) -> Result<Angle, AstroError> {
102        parse_decimal(self).map(Angle::from_hours)
103    }
104
105    #[inline]
106    fn arcmin(&self) -> Result<Angle, AstroError> {
107        parse_decimal(self).map(|v| Angle::from_degrees(v / 60.0))
108    }
109
110    #[inline]
111    fn arcsec(&self) -> Result<Angle, AstroError> {
112        parse_decimal(self).map(|v| Angle::from_degrees(v / 3600.0))
113    }
114
115    #[inline]
116    fn dms(&self) -> Result<Angle, AstroError> {
117        parse_dms(self)
118    }
119
120    #[inline]
121    fn hms(&self) -> Result<Angle, AstroError> {
122        parse_hms(self)
123    }
124}
125
126/// Auto-detect and parse angle format.
127///
128/// Tries formats in order: HMS, then DMS, then decimal degrees.
129/// Use this for user input where format is unknown.
130///
131/// For coordinates with known semantics (RA vs Dec), prefer explicit
132/// `.hms()` or `.dms()` via [`AngleUnits`].
133pub trait ParseAngle {
134    /// Parse angle, auto-detecting format.
135    ///
136    /// Detection order: HMS -> DMS -> decimal degrees.
137    fn to_angle(&self) -> Result<Angle, AstroError>;
138}
139
140impl ParseAngle for str {
141    fn to_angle(&self) -> Result<Angle, AstroError> {
142        parse_hms(self)
143            .or_else(|_| parse_dms(self))
144            .or_else(|_| parse_decimal(self).map(Angle::from_degrees))
145    }
146}
147
148fn parse_decimal(s: &str) -> Result<f64, AstroError> {
149    s.trim().parse::<f64>().map_err(|_| {
150        AstroError::calculation_error("parse_decimal", &format!("Cannot parse '{}' as number", s))
151    })
152}
153
154static HMS_REGEX: Lazy<Regex> = Lazy::new(|| {
155    Regex::new(
156        r#"(?xi)
157        ^\s*
158        ([+-])?                          # optional sign
159        (\d{1,3})                        # hours (1-3 digits)
160        (?:                              # separator group
161            [:hH\s]+|                    # colons, h/H, spaces
162            h(?:ou)?r?s?\s*              # hour/hours variants
163        )
164        (\d{1,2})                        # minutes (1-2 digits)
165        (?:                              # separator group
166            [:mM\s']+|                   # colons, m/M, spaces, apostrophes
167            m(?:in(?:ute)?s?)?\s*        # min/minute variants
168        )
169        (\d{1,2}(?:\.\d+)?)              # seconds with optional decimal
170        (?:                              # optional trailing markers
171            [sS\s"']+|                   # s/S, spaces, quotes
172            s(?:ec(?:ond)?s?)?           # sec/second variants
173        )?
174        \s*$
175        "#,
176    )
177    .unwrap()
178});
179
180static DMS_REGEX: Lazy<Regex> = Lazy::new(|| {
181    Regex::new(
182        r#"(?xi)
183        ^\s*
184        ([+-])?                          # optional sign
185        (\d{1,3})                        # degrees (1-3 digits)
186        (?:                              # separator group
187            [dD\s:*]+|                   # d/D, colon, asterisk, spaces
188            d(?:eg(?:ree)?s?)?\s*        # deg/degree variants
189        )
190        (\d{1,2})                        # minutes (1-2 digits)
191        (?:                              # separator group
192            ['mM\s:]+|                   # apostrophes, m/M, spaces, colon
193            m(?:in(?:ute)?s?)?\s*|       # min/minute variants
194            arc\s?m(?:in(?:ute)?s?)?\s*  # arcmin/arcminute
195        )
196        (\d{1,2}(?:\.\d+)?)              # seconds with optional decimal
197        (?:                              # optional trailing markers
198            ["'sS\s]+|                   # quotes, s/S, spaces
199            s(?:ec(?:ond)?s?)?|          # sec/second variants
200            arc\s?s(?:ec(?:ond)?s?)?     # arcsec/arcsecond
201        )?
202        \s*$
203        "#,
204    )
205    .unwrap()
206});
207
208static COLON_REGEX: Lazy<Regex> =
209    Lazy::new(|| Regex::new(r#"^\s*([+-])?(\d{1,4}):(\d{1,3}):(\d{1,3}(?:\.\d+)?)\s*$"#).unwrap());
210
211/// Parse a string as hours-minutes-seconds.
212///
213/// Accepts formats like `12:34:56`, `12h34m56s`, `12 hours 34 min 56 sec`.
214/// Returns the angle with the value interpreted as hours (1h = 15 degrees).
215///
216/// Use this for Right Ascension values. The result can exceed 24h if the input does.
217pub fn parse_hms(s: &str) -> Result<Angle, AstroError> {
218    let s = normalize_input(s);
219
220    if let Some(caps) = COLON_REGEX.captures(&s) {
221        return parse_hms_captures(caps, &s);
222    }
223
224    if let Some(caps) = HMS_REGEX.captures(&s) {
225        return parse_hms_captures(caps, &s);
226    }
227
228    Err(AstroError::calculation_error(
229        "parse_hms",
230        &format!("Cannot parse '{}' as HMS format", s),
231    ))
232}
233
234/// Parse a string as degrees-minutes-seconds.
235///
236/// Accepts formats like `45:30:15`, `45d30m15s`, `45* 30' 15"`, `45 deg 30 arcmin 15 arcsec`.
237/// Returns the angle with the value interpreted as degrees.
238///
239/// Use this for Declination, altitude, azimuth, or general angular measurements.
240pub fn parse_dms(s: &str) -> Result<Angle, AstroError> {
241    let s = normalize_input(s);
242
243    if let Some(caps) = COLON_REGEX.captures(&s) {
244        return parse_dms_captures(caps, &s);
245    }
246
247    if let Some(caps) = DMS_REGEX.captures(&s) {
248        return parse_dms_captures(caps, &s);
249    }
250
251    Err(AstroError::calculation_error(
252        "parse_dms",
253        &format!("Cannot parse '{}' as DMS format", s),
254    ))
255}
256
257fn parse_hms_captures(caps: regex::Captures, _original: &str) -> Result<Angle, AstroError> {
258    let sign = caps
259        .get(1)
260        .map_or(1.0, |m| if m.as_str() == "-" { -1.0 } else { 1.0 });
261    let hours: f64 = caps[2].parse().unwrap();
262    let minutes: f64 = caps[3].parse().unwrap();
263    let seconds: f64 = caps[4].parse().unwrap();
264
265    let total_hours = sign * (hours + minutes / 60.0 + seconds / 3600.0);
266    Ok(Angle::from_hours(total_hours))
267}
268
269fn parse_dms_captures(caps: regex::Captures, _original: &str) -> Result<Angle, AstroError> {
270    let sign = caps
271        .get(1)
272        .map_or(1.0, |m| if m.as_str() == "-" { -1.0 } else { 1.0 });
273    let degrees: f64 = caps[2].parse().unwrap();
274    let minutes: f64 = caps[3].parse().unwrap();
275    let seconds: f64 = caps[4].parse().unwrap();
276
277    let total_degrees = sign * (degrees + minutes / 60.0 + seconds / 3600.0);
278    Ok(Angle::from_degrees(total_degrees))
279}
280
281fn normalize_input(s: &str) -> String {
282    let mut result = s.trim().to_string();
283
284    result = result.replace("degrees", "d");
285    result = result.replace("degree", "d");
286    result = result.replace("deg", "d");
287    result = result.replace('*', "d");
288
289    result = result.replace("arcminutes", "m");
290    result = result.replace("arcminute", "m");
291    result = result.replace("arcmin", "m");
292    result = result.replace("minutes", "m");
293    result = result.replace("minute", "m");
294    result = result.replace("min", "m");
295
296    result = result.replace("arcseconds", "s");
297    result = result.replace("arcsecond", "s");
298    result = result.replace("arcsec", "s");
299    result = result.replace("seconds", "s");
300    result = result.replace("second", "s");
301    result = result.replace("sec", "s");
302    result = result.replace("''", "\"");
303
304    result = result.replace("hours", "h");
305    result = result.replace("hour", "h");
306    result = result.replace("hrs", "h");
307    result = result.replace("hr", "h");
308
309    result
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::constants::PI;
316
317    const EPSILON: f64 = 1e-10;
318
319    #[test]
320    fn test_decimal_parsing() {
321        assert_eq!("45.5".deg().unwrap().degrees(), 45.5);
322        assert_eq!(format!("{:?}", PI).rad().unwrap().radians(), PI);
323        assert_eq!("12.5".hours().unwrap().hours(), 12.5);
324
325        assert!(("60.0".arcmin().unwrap().degrees() - 1.0).abs() < EPSILON);
326        assert!(("3600.0".arcsec().unwrap().degrees() - 1.0).abs() < EPSILON);
327
328        assert_eq!("-45.5".deg().unwrap().degrees(), -45.5);
329        assert_eq!("-12.5".hours().unwrap().hours(), -12.5);
330
331        assert_eq!("  45.5  ".deg().unwrap().degrees(), 45.5);
332        assert_eq!(format!("\t{:?}\n", PI).rad().unwrap().radians(), PI);
333    }
334
335    #[test]
336    fn test_hms_colon_format() {
337        let angle = "12:34:56".hms().unwrap();
338        let expected_hours = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0;
339        assert!((angle.hours() - expected_hours).abs() < EPSILON);
340
341        let angle = "12:34:56.789".hms().unwrap();
342        let expected = 12.0 + 34.0 / 60.0 + 56.789 / 3600.0;
343        assert!((angle.hours() - expected).abs() < EPSILON);
344
345        let angle = "-5:30:45".hms().unwrap();
346        let expected = -(5.0 + 30.0 / 60.0 + 45.0 / 3600.0);
347        assert!((angle.hours() - expected).abs() < EPSILON);
348
349        assert!("0:0:0".hms().unwrap().hours() < EPSILON);
350        assert!("23:59:59.999".hms().is_ok());
351    }
352
353    #[test]
354    fn test_dms_colon_format() {
355        let angle = "45:30:15".dms().unwrap();
356        let expected_deg = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
357        assert!((angle.degrees() - expected_deg).abs() < EPSILON);
358
359        let angle = "+45:30:15.5".dms().unwrap();
360        let expected = 45.0 + 30.0 / 60.0 + 15.5 / 3600.0;
361        assert!((angle.degrees() - expected).abs() < EPSILON);
362
363        let angle = "-90:30:0".dms().unwrap();
364        let expected = -(90.0 + 30.0 / 60.0);
365        assert!((angle.degrees() - expected).abs() < EPSILON);
366    }
367
368    #[test]
369    fn test_hms_verbose_formats() {
370        let angle = "12h34m56s".hms().unwrap();
371        let expected = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0;
372        assert!((angle.hours() - expected).abs() < EPSILON);
373
374        let angle = "12H34M56S".hms().unwrap();
375        assert!((angle.hours() - expected).abs() < EPSILON);
376
377        let angle = "12h 34m 56s".hms().unwrap();
378        assert!((angle.hours() - expected).abs() < EPSILON);
379
380        let angle = "12 hours 34 minutes 56 seconds".hms().unwrap();
381        assert!((angle.hours() - expected).abs() < EPSILON);
382
383        let angle = "12hr 34min 56sec".hms().unwrap();
384        assert!((angle.hours() - expected).abs() < EPSILON);
385    }
386
387    #[test]
388    fn test_dms_verbose_formats() {
389        let angle = "45d30m15s".dms().unwrap();
390        let expected = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
391        assert!((angle.degrees() - expected).abs() < EPSILON);
392
393        let angle = "45*30m15s".dms().unwrap();
394        assert!((angle.degrees() - expected).abs() < EPSILON);
395
396        let angle = "45 degrees 30 minutes 15 seconds".dms().unwrap();
397        assert!((angle.degrees() - expected).abs() < EPSILON);
398
399        let angle = "45deg 30min 15sec".dms().unwrap();
400        assert!((angle.degrees() - expected).abs() < EPSILON);
401
402        let angle = "45d 30 arcmin 15 arcsec".dms().unwrap();
403        assert!((angle.degrees() - expected).abs() < EPSILON);
404    }
405
406    #[test]
407    fn test_quote_formats() {
408        let angle = "45d 30' 15\"".dms().unwrap();
409        let expected = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
410        assert!((angle.degrees() - expected).abs() < EPSILON);
411
412        let angle = "45d 30' 15''".dms().unwrap();
413        assert!((angle.degrees() - expected).abs() < EPSILON);
414    }
415
416    #[test]
417    fn test_auto_detection() {
418        let angle = "12:34:56".to_angle().unwrap();
419        let expected_hours = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0;
420        assert!((angle.hours() - expected_hours).abs() < EPSILON);
421
422        let angle = "45d30m15s".to_angle().unwrap();
423        let expected_deg = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
424        assert!((angle.degrees() - expected_deg).abs() < EPSILON);
425
426        let angle = "45.5".to_angle().unwrap();
427        assert_eq!(angle.degrees(), 45.5);
428    }
429
430    #[test]
431    fn test_edge_cases() {
432        assert!("0:0:0".hms().unwrap().radians().abs() < EPSILON);
433        assert!("0:0:0".dms().unwrap().radians().abs() < EPSILON);
434        assert!("0".deg().unwrap().radians().abs() < EPSILON);
435
436        assert!("359:59:59".dms().is_ok());
437        assert!("999:59:59".hms().is_ok());
438
439        let angle = "12:34:56.123456789".hms().unwrap();
440        assert!(angle.hours() > 12.0);
441
442        assert!("01:02:03".hms().is_ok());
443        assert!("001:02:03".dms().is_ok());
444    }
445
446    #[test]
447    fn test_error_cases() {
448        assert!("not_a_number".deg().is_err());
449        assert!("12:34".hms().is_err());
450        assert!("12:34:".hms().is_err());
451        assert!(":12:34".hms().is_err());
452
453        assert!("".deg().is_err());
454        assert!("   ".deg().is_err());
455    }
456
457    #[test]
458    fn test_sign_handling() {
459        assert!("+45:30:15".dms().unwrap().degrees() > 0.0);
460        assert!("+12:34:56".hms().unwrap().hours() > 0.0);
461
462        assert!("-45:30:15".dms().unwrap().degrees() < 0.0);
463        assert!("-12:34:56".hms().unwrap().hours() < 0.0);
464
465        assert!("45:-30:15".dms().is_err());
466        assert!("12:34:-56".hms().is_err());
467    }
468
469    #[test]
470    fn test_whitespace_tolerance() {
471        assert!("  45:30:15  ".dms().is_ok());
472        assert!("\t12:34:56\n".hms().is_ok());
473
474        assert!("45 : 30 : 15".dms().is_ok());
475        assert!("12 h 34 m 56 s".hms().is_ok());
476    }
477
478    #[test]
479    fn test_precision_preservation() {
480        let input_deg = 123.456789012345;
481        let angle = format!("{}", input_deg).deg().unwrap();
482        assert!((angle.degrees() - input_deg).abs() < 1e-12);
483
484        let angle = "12:34:56.123456".hms().unwrap();
485        let back_to_hms = angle.hours();
486        let expected = 12.0 + 34.0 / 60.0 + 56.123456 / 3600.0;
487        assert!((back_to_hms - expected).abs() < 1e-9);
488    }
489}