Skip to main content

celestial_core/angle/
format.rs

1//! Angle formatting and lightweight parsing for astronomical coordinates.
2//!
3//! This module provides formatters for displaying angles in astronomical notation
4//! and a simple parser for common angle formats. For more flexible parsing with support
5//! for verbose formats like "12 hours 30 minutes", see the [`parse`](super::parse) module.
6//!
7//! # Formatting Conventions
8//!
9//! Astronomy uses two primary sexagesimal (base-60) notations:
10//!
11//! ## Degrees-Minutes-Seconds (DMS)
12//!
13//! Used for declination, latitude, altitude, and general angular measurements.
14//! - Format: `+DD° MM' SS.ss"`
15//! - Sign is always shown (+ or -)
16//! - 1 degree = 60 arcminutes = 3600 arcseconds
17//!
18//! ## Hours-Minutes-Seconds (HMS)
19//!
20//! Used for right ascension and hour angles.
21//! - Format: `HHʰ MMᵐ SS.ssˢ`
22//! - Always positive; negative angles wrap to [0, 24h)
23//! - 24 hours = 360 degrees, so 1 hour = 15 degrees
24//!
25//! # Formatting Examples
26//!
27//! ```
28//! use celestial_core::Angle;
29//! use celestial_core::angle::{DmsFmt, HmsFmt};
30//!
31//! // Declination of Vega: +38° 47' 01"
32//! let dec = Angle::from_degrees(38.783611);
33//! let dms = DmsFmt { frac_digits: 0 };
34//! assert_eq!(dms.fmt(dec), "+38° 47' 1\"");
35//!
36//! // Right ascension of Vega: 18h 36m 56s
37//! let ra = Angle::from_hours(18.615556);
38//! let hms = HmsFmt { frac_digits: 0 };
39//! assert_eq!(hms.fmt(ra), "18ʰ 36ᵐ 56ˢ");
40//!
41//! // With fractional seconds
42//! let hms_precise = HmsFmt { frac_digits: 2 };
43//! assert_eq!(hms_precise.fmt(ra), "18ʰ 36ᵐ 56.00ˢ");
44//! ```
45//!
46//! # Parsing Examples
47//!
48//! The [`parse_angle`] function handles common formats:
49//!
50//! ```
51//! use celestial_core::angle::parse_angle;
52//!
53//! // HMS formats (tries HMS first, then DMS)
54//! let ra = parse_angle("12h30m15s").unwrap();
55//! assert!((ra.angle.hours() - 12.504166666666666).abs() < 1e-10);
56//!
57//! // Also accepts Unicode superscript notation
58//! let ra2 = parse_angle("12ʰ30ᵐ15ˢ").unwrap();
59//!
60//! // Colon-separated (interpreted as HMS by default)
61//! let ra3 = parse_angle("12:30:15").unwrap();
62//!
63//! // DMS formats
64//! let dec = parse_angle("45°30'15\"").unwrap();
65//! assert!((dec.angle.degrees() - 45.504166666666666).abs() < 1e-10);
66//! ```
67//!
68//! # Default Display
69//!
70//! The `Display` trait formats angles as decimal degrees with 6 decimal places:
71//!
72//! ```
73//! use celestial_core::Angle;
74//!
75//! let a = Angle::from_degrees(45.123456789);
76//! assert_eq!(format!("{}", a), "45.123457°");
77//! ```
78use super::Angle;
79use core::fmt;
80
81/// Formatter for degrees-minutes-seconds (DMS) notation.
82///
83/// DMS is the standard format for declination, latitude, altitude, and general
84/// angular measurements in astronomy. The sign is always explicit.
85///
86/// # Fields
87///
88/// * `frac_digits` - Number of decimal places for the arcseconds component.
89///   Use 0 for whole arcseconds, 2-3 for sub-arcsecond precision.
90///
91/// # Output Format
92///
93/// `±DD° MM' SS.ss"` where:
94/// - Sign is always shown (+ or -)
95/// - Degrees, arcminutes are whole numbers
96/// - Arcseconds include decimals per `frac_digits`
97///
98/// # Example
99///
100/// ```
101/// use celestial_core::Angle;
102/// use celestial_core::angle::DmsFmt;
103///
104/// let dec = Angle::from_degrees(-23.4392);
105///
106/// // Whole arcseconds
107/// let fmt0 = DmsFmt { frac_digits: 0 };
108/// assert_eq!(fmt0.fmt(dec), "-23° 26' 21\"");
109///
110/// // Sub-arcsecond precision (typical for catalogs)
111/// let fmt2 = DmsFmt { frac_digits: 2 };
112/// assert_eq!(fmt2.fmt(dec), "-23° 26' 21.12\"");
113/// ```
114pub struct DmsFmt {
115    pub frac_digits: u8,
116}
117
118/// Formatter for hours-minutes-seconds (HMS) notation.
119///
120/// HMS is the standard format for right ascension and hour angles in astronomy.
121/// The output is always positive; negative angles are wrapped to [0, 24h).
122///
123/// # Fields
124///
125/// * `frac_digits` - Number of decimal places for the seconds component.
126///   Use 0 for whole seconds, 2-3 for sub-second precision.
127///
128/// # Output Format
129///
130/// `HHʰ MMᵐ SS.ssˢ` where:
131/// - Uses Unicode superscript characters (ʰ, ᵐ, ˢ)
132/// - Hours, minutes are whole numbers
133/// - Seconds include decimals per `frac_digits`
134/// - Negative angles wrap: -1.5h becomes 22h 30m
135///
136/// # Example
137///
138/// ```
139/// use celestial_core::Angle;
140/// use celestial_core::angle::HmsFmt;
141///
142/// let ra = Angle::from_hours(14.5);  // 14h 30m 00s
143///
144/// let fmt = HmsFmt { frac_digits: 1 };
145/// assert_eq!(fmt.fmt(ra), "14ʰ 30ᵐ 0.0ˢ");
146///
147/// // Negative angles wrap to positive
148/// let neg = Angle::from_hours(-1.5);
149/// assert_eq!(fmt.fmt(neg), "22ʰ 30ᵐ 0.0ˢ");
150/// ```
151pub struct HmsFmt {
152    pub frac_digits: u8,
153}
154
155impl DmsFmt {
156    /// Formats an angle as degrees-minutes-seconds.
157    ///
158    /// Decomposes the angle into integer degrees and arcminutes, with arcseconds
159    /// shown to the precision specified by `frac_digits`.
160    ///
161    /// # Arguments
162    ///
163    /// * `a` - The angle to format
164    ///
165    /// # Returns
166    ///
167    /// A string in the format `±DD° MM' SS.ss"`.
168    #[inline]
169    pub fn fmt(&self, a: Angle) -> String {
170        let sign = if a.degrees() < 0.0 { '-' } else { '+' };
171        let mut d = a.degrees().abs();
172        let deg = libm::trunc(d);
173        d = (d - deg) * 60.0;
174        let min = libm::trunc(d);
175        let sec = (d - min) * 60.0;
176        format!(
177            "{sign}{deg:.0}° {min:.0}' {sec:.*}\"",
178            self.frac_digits as usize
179        )
180    }
181}
182
183impl HmsFmt {
184    /// Formats an angle as hours-minutes-seconds.
185    ///
186    /// Decomposes the angle into integer hours and minutes, with seconds
187    /// shown to the precision specified by `frac_digits`. Negative angles
188    /// are wrapped to the range [0, 24h) using Euclidean remainder.
189    ///
190    /// # Arguments
191    ///
192    /// * `a` - The angle to format
193    ///
194    /// # Returns
195    ///
196    /// A string in the format `HHʰ MMᵐ SS.ssˢ` using Unicode superscript markers.
197    #[inline]
198    pub fn fmt(&self, a: Angle) -> String {
199        let mut h = a.hours();
200        h = h.rem_euclid(24.0);
201        let hh = libm::trunc(h);
202        h = (h - hh) * 60.0;
203        let mm = libm::trunc(h);
204        let ss = (h - mm) * 60.0;
205        format!("{hh:.0}ʰ {mm:.0}ᵐ {ss:.*}ˢ", self.frac_digits as usize)
206    }
207}
208
209impl fmt::Display for Angle {
210    /// Formats the angle as decimal degrees with 6 decimal places.
211    ///
212    /// This provides a simple, unambiguous representation suitable for debugging
213    /// and data export. For astronomical notation, use [`DmsFmt`] or [`HmsFmt`].
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        write!(f, "{:.6}°", self.degrees())
216    }
217}
218
219/// Result of parsing an angle string.
220///
221/// This struct wraps the parsed [`Angle`] and may be extended in the future
222/// to include metadata about the parse (detected unit, original sign, etc.).
223///
224/// # Example
225///
226/// ```
227/// use celestial_core::angle::parse_angle;
228///
229/// let parsed = parse_angle("12h30m15s").unwrap();
230/// let angle = parsed.angle;  // Extract the Angle
231/// ```
232pub struct ParsedAngle {
233    /// The parsed angle value.
234    pub angle: Angle,
235}
236
237/// Parses an angle string, trying HMS format first, then DMS.
238///
239/// This is a lightweight parser for angle formats.
240/// For more flexible parsing including verbose formats ("12 hours 30 minutes"),
241/// see [`super::parse::parse_hms`] and [`super::parse::parse_dms`].
242///
243/// # Supported Formats
244///
245/// **HMS (hours-minutes-seconds):**
246/// - `12h30m15s` or `12h30m15.5s`
247/// - `12ʰ30ᵐ15ˢ` (Unicode superscripts)
248/// - `12:30:15` (colon-separated)
249/// - `12h` (hours only)
250/// - `-12h30m15s` (negative)
251///
252/// **DMS (degrees-minutes-seconds):**
253/// - `45°30'15"` or `45°30'15.5"`
254/// - `45d30m15s`
255/// - `45:30:15` (colon-separated, tried if HMS fails)
256/// - `45°` (degrees only)
257/// - `-45°30'15"` (negative)
258///
259/// # Ambiguity
260///
261/// Colon-separated values like `12:30:15` are tried as HMS first. If you need
262/// to parse this as DMS explicitly, use [`parse_dms`](super::parse::parse_dms).
263///
264/// # Errors
265///
266/// Returns [`AstroError`](crate::AstroError) if:
267/// - The string is empty or contains no valid components
268/// - Minutes or seconds are outside [0, 60)
269/// - Fractional hours/degrees are mixed with minutes/seconds (e.g., "12.5h30m")
270/// - The string cannot be parsed as either HMS or DMS
271///
272/// # Example
273///
274/// ```
275/// use celestial_core::angle::parse_angle;
276///
277/// // Right ascension
278/// let ra = parse_angle("05h14m32.27s").unwrap();
279/// assert!((ra.angle.hours() - 5.242297).abs() < 1e-5);
280///
281/// // Declination
282/// let dec = parse_angle("-08°12'05.9\"").unwrap();
283/// assert!((dec.angle.degrees() - (-8.201639)).abs() < 1e-5);
284/// ```
285pub fn parse_angle(s: &str) -> Result<ParsedAngle, crate::AstroError> {
286    parse_hms(s).or_else(|_| parse_dms(s))
287}
288
289/// Parses an HMS (hours-minutes-seconds) string into an angle.
290///
291/// Accepts formats like: `12h30m15s`, `12ʰ30ᵐ15ˢ`, `12:30:15`, `12h`, `-12h30m15s`
292fn parse_hms(s: &str) -> Result<ParsedAngle, crate::AstroError> {
293    let s = s.trim();
294    let sign = if s.starts_with('-') { -1.0 } else { 1.0 };
295    let s = s.trim_start_matches(['+', '-']);
296
297    let parts: Vec<&str> = s
298        .split(['h', 'ʰ', 'm', 'ᵐ', 's', 'ˢ', ':'])
299        .map(|p| p.trim())
300        .filter(|p| !p.is_empty())
301        .collect();
302
303    if parts.is_empty() {
304        return Err(crate::AstroError::math_error(
305            "parse_hms",
306            crate::errors::MathErrorKind::InvalidInput,
307            "Empty string",
308        ));
309    }
310
311    if parts.len() > 3 {
312        return Err(crate::AstroError::math_error(
313            "parse_hms",
314            crate::errors::MathErrorKind::InvalidInput,
315            "Too many components (max 3: hours, minutes, seconds)",
316        ));
317    }
318
319    let h = parts[0].parse::<f64>().map_err(|_| {
320        crate::AstroError::math_error(
321            "parse_hms",
322            crate::errors::MathErrorKind::InvalidInput,
323            "Invalid hours",
324        )
325    })?;
326
327    let m = if parts.len() > 1 {
328        parts[1].parse::<f64>().map_err(|_| {
329            crate::AstroError::math_error(
330                "parse_hms",
331                crate::errors::MathErrorKind::InvalidInput,
332                "Invalid minutes",
333            )
334        })?
335    } else {
336        0.0
337    };
338
339    let sec = if parts.len() > 2 {
340        parts[2].parse::<f64>().map_err(|_| {
341            crate::AstroError::math_error(
342                "parse_hms",
343                crate::errors::MathErrorKind::InvalidInput,
344                "Invalid seconds",
345            )
346        })?
347    } else {
348        0.0
349    };
350
351    if parts.len() > 1 && h - libm::trunc(h) != 0.0 {
352        return Err(crate::AstroError::math_error(
353            "parse_hms",
354            crate::errors::MathErrorKind::InvalidInput,
355            "Cannot mix fractional hours with minutes/seconds",
356        ));
357    }
358
359    if !(0.0..60.0).contains(&m) {
360        return Err(crate::AstroError::math_error(
361            "parse_hms",
362            crate::errors::MathErrorKind::InvalidInput,
363            "Minutes must be in range [0, 60)",
364        ));
365    }
366
367    if !(0.0..60.0).contains(&sec) {
368        return Err(crate::AstroError::math_error(
369            "parse_hms",
370            crate::errors::MathErrorKind::InvalidInput,
371            "Seconds must be in range [0, 60)",
372        ));
373    }
374
375    Ok(ParsedAngle {
376        angle: Angle::from_hours(sign * (h.abs() + m / 60.0 + sec / 3600.0)),
377    })
378}
379
380/// Parses a DMS (degrees-minutes-seconds) string into an angle.
381///
382/// Accepts formats like: `45°30'15"`, `45d30m15s`, `45:30:15`, `45°`, `-45°30'15"`
383fn parse_dms(s: &str) -> Result<ParsedAngle, crate::AstroError> {
384    let s = s.trim();
385    let sign = if s.starts_with('-') { -1.0 } else { 1.0 };
386    let s = s.trim_start_matches(['+', '-']);
387
388    let parts: Vec<&str> = s
389        .split(['°', '\'', '"', ':', 'd', 'm', 's'])
390        .map(|p| p.trim())
391        .filter(|p| !p.is_empty())
392        .collect();
393
394    if parts.is_empty() {
395        return Err(crate::AstroError::math_error(
396            "parse_dms",
397            crate::errors::MathErrorKind::InvalidInput,
398            "Empty string",
399        ));
400    }
401
402    if parts.len() > 3 {
403        return Err(crate::AstroError::math_error(
404            "parse_dms",
405            crate::errors::MathErrorKind::InvalidInput,
406            "Too many components (max 3: degrees, arcminutes, arcseconds)",
407        ));
408    }
409
410    let deg = parts[0].parse::<f64>().map_err(|_| {
411        crate::AstroError::math_error(
412            "parse_dms",
413            crate::errors::MathErrorKind::InvalidInput,
414            "Invalid degrees",
415        )
416    })?;
417
418    let min = if parts.len() > 1 {
419        parts[1].parse::<f64>().map_err(|_| {
420            crate::AstroError::math_error(
421                "parse_dms",
422                crate::errors::MathErrorKind::InvalidInput,
423                "Invalid arcminutes",
424            )
425        })?
426    } else {
427        0.0
428    };
429
430    let sec = if parts.len() > 2 {
431        parts[2].parse::<f64>().map_err(|_| {
432            crate::AstroError::math_error(
433                "parse_dms",
434                crate::errors::MathErrorKind::InvalidInput,
435                "Invalid arcseconds",
436            )
437        })?
438    } else {
439        0.0
440    };
441
442    if parts.len() > 1 && deg - libm::trunc(deg) != 0.0 {
443        return Err(crate::AstroError::math_error(
444            "parse_dms",
445            crate::errors::MathErrorKind::InvalidInput,
446            "Cannot mix fractional degrees with arcminutes/arcseconds",
447        ));
448    }
449
450    if !(0.0..60.0).contains(&min) {
451        return Err(crate::AstroError::math_error(
452            "parse_dms",
453            crate::errors::MathErrorKind::InvalidInput,
454            "Arcminutes must be in range [0, 60)",
455        ));
456    }
457
458    if !(0.0..60.0).contains(&sec) {
459        return Err(crate::AstroError::math_error(
460            "parse_dms",
461            crate::errors::MathErrorKind::InvalidInput,
462            "Arcseconds must be in range [0, 60)",
463        ));
464    }
465
466    Ok(ParsedAngle {
467        angle: Angle::from_degrees(sign * (deg.abs() + min / 60.0 + sec / 3600.0)),
468    })
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn test_hms_format_normal() {
477        let a = Angle::from_hours(12.5);
478        let fmt = HmsFmt { frac_digits: 2 };
479        let result = fmt.fmt(a);
480        assert!(result.contains("12ʰ"));
481        assert!(result.contains("30ᵐ"));
482    }
483
484    #[test]
485    fn test_hms_format_extreme_positive() {
486        let a = Angle::from_degrees(720.0);
487        let fmt = HmsFmt { frac_digits: 0 };
488        let result = fmt.fmt(a);
489        assert!(result.contains("0ʰ"));
490    }
491
492    #[test]
493    fn test_hms_format_extreme_negative() {
494        let a = Angle::from_degrees(-750.0);
495        let fmt = HmsFmt { frac_digits: 0 };
496        let result = fmt.fmt(a);
497        assert!(result.contains("22ʰ"));
498    }
499
500    #[test]
501    fn test_dms_format_negative_with_precision() {
502        let a = Angle::from_degrees(-12.345678);
503        let fmt = DmsFmt { frac_digits: 2 };
504        let result = fmt.fmt(a);
505        assert_eq!(result, "-12° 20' 44.44\"");
506    }
507
508    #[test]
509    fn test_hms_format_wraps_negative_angle() {
510        let a = Angle::from_hours(-1.5);
511        let fmt = HmsFmt { frac_digits: 1 };
512        let result = fmt.fmt(a);
513        assert_eq!(result, "22ʰ 30ᵐ 0.0ˢ");
514    }
515
516    #[test]
517    fn test_angle_display_precision() {
518        let a = Angle::from_degrees(1.23456789);
519        assert_eq!(format!("{a}"), "1.234568°");
520    }
521
522    #[test]
523    fn test_parse_hms() {
524        let result = parse_hms("12h30m15s").unwrap();
525        assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
526    }
527
528    #[test]
529    fn test_parse_hms_unicode() {
530        let result = parse_hms("12ʰ30ᵐ15ˢ").unwrap();
531        assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
532    }
533
534    #[test]
535    fn test_parse_hms_colon() {
536        let result = parse_hms("12:30:15").unwrap();
537        assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
538    }
539
540    #[test]
541    fn test_parse_hms_partial() {
542        let result = parse_hms("12h").unwrap();
543        assert_eq!(result.angle.hours(), 12.0);
544    }
545
546    #[test]
547    fn test_parse_dms_positive() {
548        let result = parse_dms("45°30'15\"").unwrap();
549        assert!((result.angle.degrees() - 45.50416666666667).abs() < 1e-10);
550    }
551
552    #[test]
553    fn test_parse_dms_negative() {
554        let result = parse_dms("-45°30'15\"").unwrap();
555        assert!((result.angle.degrees() + 45.50416666666667).abs() < 1e-10);
556    }
557
558    #[test]
559    fn test_parse_dms_colon() {
560        let result = parse_dms("45:30:15").unwrap();
561        assert!((result.angle.degrees() - 45.50416666666667).abs() < 1e-10);
562    }
563
564    #[test]
565    fn test_parse_dms_partial() {
566        let result = parse_dms("45°").unwrap();
567        assert_eq!(result.angle.degrees(), 45.0);
568    }
569
570    #[test]
571    fn test_parse_angle_dispatch_hms() {
572        let result = parse_angle("12h30m15s").unwrap();
573        assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
574    }
575
576    #[test]
577    fn test_parse_angle_dispatch_dms() {
578        let result = parse_angle("45°30'15\"").unwrap();
579        assert!((result.angle.degrees() - 45.50416666666667).abs() < 1e-10);
580    }
581
582    #[test]
583    fn test_parse_hms_negative() {
584        let result = parse_hms("-01:30:00").unwrap();
585        assert_eq!(result.angle.hours(), -1.5);
586    }
587
588    #[test]
589    fn test_parse_hms_negative_with_seconds() {
590        let result = parse_hms("-12h30m45s").unwrap();
591        assert_eq!(result.angle.hours(), -12.5125);
592    }
593
594    #[test]
595    fn test_parse_hms_invalid_minutes() {
596        let result = parse_hms("12h99m00s");
597        assert!(result.is_err());
598    }
599
600    #[test]
601    fn test_parse_hms_invalid_seconds() {
602        let result = parse_hms("12h30m80s");
603        assert!(result.is_err());
604    }
605
606    #[test]
607    fn test_parse_dms_invalid_arcminutes() {
608        let result = parse_dms("45°80'00\"");
609        assert!(result.is_err());
610    }
611
612    #[test]
613    fn test_parse_dms_invalid_arcseconds() {
614        let result = parse_dms("45°30'99\"");
615        assert!(result.is_err());
616    }
617
618    #[test]
619    fn test_parse_dms_negative_with_minutes_seconds() {
620        let result = parse_dms("-45°30'15\"").unwrap();
621        assert_eq!(result.angle.degrees(), -45.50416666666667);
622    }
623
624    #[test]
625    fn test_parse_hms_rejects_fractional_hours_with_minutes() {
626        let result = parse_hms("12.5h30m");
627        assert!(result.is_err());
628    }
629
630    #[test]
631    fn test_parse_hms_rejects_empty_string() {
632        let result = parse_hms("");
633        assert!(result.is_err());
634    }
635
636    #[test]
637    fn test_parse_hms_rejects_too_many_components() {
638        let result = parse_hms("12:30:15:99");
639        assert!(result.is_err());
640    }
641
642    #[test]
643    fn test_parse_hms_accepts_fractional_hours_alone() {
644        let result = parse_hms("12.5h").unwrap();
645        assert_eq!(result.angle.hours(), 12.5);
646    }
647
648    #[test]
649    fn test_parse_dms_rejects_fractional_degrees_with_arcminutes() {
650        let result = parse_dms("45.5°30'");
651        assert!(result.is_err());
652    }
653
654    #[test]
655    fn test_parse_dms_rejects_empty_string() {
656        let result = parse_dms("   ");
657        assert!(result.is_err());
658    }
659
660    #[test]
661    fn test_parse_dms_rejects_too_many_components() {
662        let result = parse_dms("45:30:15:99");
663        assert!(result.is_err());
664    }
665
666    #[test]
667    fn test_parse_dms_accepts_fractional_degrees_alone() {
668        let result = parse_dms("45.5°").unwrap();
669        assert_eq!(result.angle.degrees(), 45.5);
670    }
671
672    #[test]
673    fn test_parse_dms_invalid_degrees() {
674        let result = parse_dms("abc°");
675        assert!(result.is_err());
676    }
677
678    #[test]
679    fn test_parse_angle_fails_for_unknown_format() {
680        let result = parse_angle("not an angle");
681        assert!(result.is_err());
682    }
683}