Skip to main content

astro_math/
location.rs

1//! Geographic location representation and utilities.
2//!
3//! This module provides the [`Location`] struct for representing observer
4//! positions on Earth, with flexible coordinate parsing for various formats.
5//!
6//! # Supported Coordinate Formats
7//!
8//! The parsing system handles an extensive range of formats:
9//!
10//! ## Decimal Degrees
11//! - `"40.7128"` or `"-74.0060"`
12//! - `"40.7128N"` or `"74.0060W"`
13//! - `"N40.7128"` or `"W74.0060"`
14//!
15//! ## DMS (Degrees Minutes Seconds)
16//! - `"40 42 46"` or `"40° 42' 46\""`
17//! - `"40d42m46s"` or `"40deg42min46sec"`
18//! - `"40:42:46"` or `"40-42-46"`
19//! - `"40°42'46.08\"N"` (with decimals and direction)
20//!
21//! ## HMS (Hours Minutes Seconds) for longitude
22//! - `"4h 56m 27s"` or `"4:56:27"`
23//! - `"4h56m27s"` or `"4 hours 56 minutes 27 seconds"`
24//!
25//! ## Special handling
26//! - Unicode symbols: `"40°42′46″"` (with proper Unicode prime symbols)
27//! - Mixed formats: `"40° 42.767'"` (degrees and decimal minutes)
28//! - Fuzzy matching: handles typos, extra spaces, mixed separators
29//! - Case insensitive: `"40D42M46S"` or `"n40.7128"`
30//!
31//! # Error Handling
32//!
33//! Parsing returns `Result<Location>` with detailed error messages:
34//! - `AstroError::InvalidDmsFormat` with suggestions for fixing common issues
35
36use crate::time::julian_date;
37use crate::{local_mean_sidereal_time, sidereal::apparent_sidereal_time};
38use crate::error::{AstroError, Result};
39use chrono::{DateTime, Utc};
40use std::str::FromStr;
41use regex::{Regex, RegexBuilder};
42use lazy_static::lazy_static;
43
44// Pre-compiled regex patterns for performance
45lazy_static! {
46    /// HMS pattern with DoS protection
47    static ref HMS_REGEX: Regex = RegexBuilder::new(
48        r"(\d{1,3}(?:\.\d{1,10})?)\s*h\s*(\d{1,2}(?:\.\d{1,10})?)\s*m?\s*(\d{1,2}(?:\.\d{1,10})?)\s*s?"
49    )
50    .size_limit(1024 * 1024)  // 1MB regex size limit
51    .dfa_size_limit(10 * 1024 * 1024) // 10MB DFA size limit
52    .build()
53    .expect("HMS regex compilation failed");
54    
55    /// DMS pattern with DoS protection
56    static ref DMS_REGEX: Regex = RegexBuilder::new(
57        r#"([+-]?\d{1,3}(?:\.\d{1,10})?)\s*[°d]?\s*(\d{1,2}(?:\.\d{1,10})?)\s*['′m]?\s*(\d{1,2}(?:\.\d{1,10})?)\s*["″s]?"#
58    )
59    .size_limit(1024 * 1024)
60    .dfa_size_limit(10 * 1024 * 1024)
61    .build()
62    .expect("DMS regex compilation failed");
63    
64    /// Decimal degrees pattern with size limits
65    static ref DECIMAL_REGEX: Regex = RegexBuilder::new(
66        r"^[+-]?\d{1,3}(?:\.\d{1,15})?[NSEW]?$"
67    )
68    .case_insensitive(true)
69    .size_limit(1024 * 1024)
70    .build()
71    .expect("Decimal regex compilation failed");
72    
73    /// Compact format pattern (DDMM.mmm or DDMMSS) with validation
74    static ref COMPACT_REGEX: Regex = RegexBuilder::new(
75        r"^([+-]?)(\d{2,3})(\d{2})(?:(\d{2})(?:\.(\d{1,6}))?)?$"
76    )
77    .size_limit(1024 * 1024)
78    .build()
79    .expect("Compact regex compilation failed");
80}
81
82/// Represents a physical observer location on Earth.
83///
84/// Used for computing local sidereal time, converting celestial coordinates,
85/// and modeling telescope geometry.
86#[derive(Debug, Clone, Copy)]
87pub struct Location {
88    /// Latitude in degrees (+N, -S)
89    pub latitude_deg: f64,
90    /// Longitude in degrees (+E, -W, Greenwich = 0)
91    pub longitude_deg: f64,
92    /// Altitude above sea level in meters
93    pub altitude_m: f64,
94}
95
96impl Location {
97    /// Parses a location from flexible coordinate strings.
98    ///
99    /// Automatically detects the coordinate format and applies appropriate parsing.
100    ///
101    /// # Supported Formats
102    ///
103    /// ## Decimal degrees
104    /// - `"40.7128"` or `"-74.0060"`
105    /// - `"40.7128N"` or `"74.0060W"`
106    /// - `"N40.7128"` or `"W 74.0060"`
107    /// - `"40.7128 N"` or `"74.0060 West"`
108    /// - `"north 40.7128"` or `"west 74.0060"`
109    ///
110    /// ## DMS (Degrees Minutes Seconds)
111    /// - `"40 42 46"` or `"40° 42' 46\""`
112    /// - `"40d42m46s"` or `"40deg42min46sec"`
113    /// - `"40:42:46"` or `"40-42-46"`
114    /// - `"40°42'46.08\"N"` (with decimals and direction)
115    /// - `"40d 42' 46.08\" N"` (mixed separators)
116    /// - `"40 degrees 42 minutes 46 seconds"`
117    ///
118    /// ## DM (Degrees Decimal Minutes)
119    /// - `"40° 42.767'"` or `"40d 42.767m"`
120    /// - `"40 42.767"` (assumed DM if only 2 parts)
121    ///
122    /// ## HMS (Hours Minutes Seconds) for longitude
123    /// - `"4h 56m 27s"` or `"4:56:27"`
124    /// - `"4h56m27.5s"` or `"4 hours 56 minutes 27.5 seconds"`
125    /// - `"4h 56' 27\""` (using arcminute/arcsecond symbols)
126    ///
127    /// ## Special handling
128    /// - Unicode: `"40°42′46″"` (proper Unicode prime/double-prime)
129    /// - Compact: `"404246N"` or `"0740060W"` (DDMMSS format)
130    /// - Aviation: `"4042.767N"` (DDMM.mmm format)
131    /// - Fuzzy: Handles extra spaces, mixed case, common typos
132    ///
133    /// # Arguments
134    /// - `lat_str`: Latitude string in any supported format
135    /// - `lon_str`: Longitude string in any supported format  
136    /// - `alt_m`: Altitude in meters
137    ///
138    /// # Returns
139    /// `Ok(Location)` if parsing succeeds
140    ///
141    /// # Errors
142    /// Returns `Err(AstroError::InvalidDmsFormat)` with helpful error messages
143    ///
144    /// # Examples
145    ///
146    /// ```
147    /// use astro_math::location::Location;
148    /// 
149    /// // Decimal degrees with compass directions
150    /// let loc = Location::parse("40.7128 N", "74.0060 W", 10.0).unwrap();
151    /// assert!((loc.latitude_deg - 40.7128).abs() < 1e-6);
152    /// assert!((loc.longitude_deg + 74.0060).abs() < 1e-6);
153    ///
154    /// // DMS with symbols
155    /// let loc = Location::parse("40°42'46.08\"N", "74°0'21.6\"W", 10.0).unwrap();
156    /// assert!((loc.latitude_deg - 40.7128).abs() < 1e-4);
157    ///
158    /// // HMS for longitude
159    /// let loc = Location::parse("51.5074 N", "0h 7m 39.84s W", 0.0).unwrap();
160    /// assert!((loc.longitude_deg + 1.9166).abs() < 1e-3);
161    ///
162    /// // Mixed formats and fuzzy matching
163    /// let loc = Location::parse("40d 42m 46s North", "74 deg 0 min 21.6 sec west", 10.0).unwrap();
164    /// assert!((loc.latitude_deg - 40.7128).abs() < 1e-4);
165    /// ```
166    pub fn parse(lat_str: &str, lon_str: &str, alt_m: f64) -> Result<Self> {
167        let lat = parse_coordinate(lat_str, true)?;
168        let lon = parse_coordinate(lon_str, false)?;
169        Ok(Location {
170            latitude_deg: lat,
171            longitude_deg: lon,
172            altitude_m: alt_m,
173        })
174    }
175
176    /// Parses a `Location` from sexagesimal (DMS) strings for latitude and longitude.
177    ///
178    /// Supports a wide range of common DMS formats:
179    /// - `"39 00 01.7"`
180    /// - `"39:00:01.7"`
181    /// - `"39°00'01.7\""`
182    ///
183    /// # Arguments
184    /// - `lat_str`: Latitude string in sexagesimal format
185    /// - `lon_str`: Longitude string in sexagesimal format
186    /// - `alt_m`: Altitude in meters
187    ///
188    /// # Returns
189    /// `Ok(Location)` if parsing succeeds
190    ///
191    /// # Errors
192    /// Returns `Err(AstroError::InvalidDmsFormat)` if:
193    /// - String doesn't match any supported DMS format
194    /// - Degrees, minutes, or seconds are out of valid ranges
195    ///
196    /// # Examples
197    ///
198    /// ## DMS with spaces
199    /// ```
200    /// use astro_math::location::Location;
201    /// let loc = Location::from_dms("+39 00 01.7", "-92 18 03.2", 250.0).unwrap();
202    /// assert!((loc.latitude_deg - 39.0004722).abs() < 1e-6);
203    /// assert!((loc.longitude_deg + 92.3008888).abs() < 1e-6);
204    /// ```
205    ///
206    /// ## DMS with colons
207    /// ```
208    /// use astro_math::location::Location;
209    /// let loc = Location::from_dms("+39:00:01.7", "-92:18:03.2", 250.0).unwrap();
210    /// assert!((loc.latitude_deg - 39.0004722).abs() < 1e-6);
211    /// ```
212    ///
213    /// ## ASCII punctuation
214    /// ```
215    /// use astro_math::location::Location;
216    /// let loc = Location::from_dms("+39°00'01.7\"", "-92°18'03.2\"", 250.0).unwrap();
217    /// assert!((loc.longitude_deg + 92.3008888).abs() < 1e-6);
218    /// ```
219    ///
220    /// ## Invalid input
221    /// ```
222    /// use astro_math::location::Location;
223    /// use astro_math::error::AstroError;
224    /// 
225    /// match Location::from_dms("foo", "bar", 100.0) {
226    ///     Err(AstroError::InvalidDmsFormat { input, .. }) => {
227    ///         assert_eq!(input, "foo");
228    ///     }
229    ///     _ => panic!("Expected InvalidDmsFormat error"),
230    /// }
231    /// ```
232    pub fn from_dms(lat_str: &str, lon_str: &str, alt_m: f64) -> Result<Self> {
233        let lat = parse_dms(lat_str)?;
234        let lon = parse_dms(lon_str)?;
235        Ok(Location {
236            latitude_deg: lat,
237            longitude_deg: lon,
238            altitude_m: alt_m,
239        })
240    }
241
242    pub fn latitude_dms_string(&self) -> String {
243        format_dms(self.latitude_deg, true)
244    }
245
246    pub fn longitude_dms_string(&self) -> String {
247        format_dms(self.longitude_deg, false)
248    }
249
250    /// Computes the Local Sidereal Time (LST) at this location for a given UTC timestamp.
251    ///
252    /// # Arguments
253    /// - `datetime`: UTC datetime
254    ///
255    /// # Returns
256    /// Local Sidereal Time in fractional hours
257    ///
258    /// # Example
259    /// ```
260    /// use chrono::{Utc, TimeZone};
261    /// use astro_math::location::Location;
262    ///
263    /// let dt = Utc.with_ymd_and_hms(1987, 4, 10, 19, 21, 0).unwrap();
264    /// let loc = Location {
265    ///     latitude_deg: 32.0,
266    ///     longitude_deg: -64.0,
267    ///     altitude_m: 200.0,
268    /// };
269    /// let lst = loc.local_sidereal_time(dt);
270    /// assert!((lst - 4.3157).abs() < 1e-3);
271    /// ```
272    pub fn local_sidereal_time(&self, datetime: DateTime<Utc>) -> f64 {
273        let jd = julian_date(datetime);
274        apparent_sidereal_time(jd, self.longitude_deg)
275    }
276
277    /// Local Mean Sidreal Time (LMST) is calculated using the
278    /// "mean equinox," a theoretical reference point in space that
279    /// moves at a constant rate.
280    /// # Arguments
281    /// - `datetime`: UTC datetime
282    ///
283    /// # Returns
284    /// Local Sidereal Time in fractional hours
285    ///
286    /// # Example
287    /// ```
288    /// use chrono::{Utc, TimeZone};
289    /// use astro_math::location::Location;
290    ///
291    /// let dt = Utc.with_ymd_and_hms(1987, 4, 10, 19, 21, 0).unwrap();
292    /// let loc = Location {
293    ///     latitude_deg: 32.0,
294    ///     longitude_deg: -64.0,
295    ///     altitude_m: 200.0,
296    /// };
297    /// let lst = loc.local_mean_sidereal_time(dt);
298    /// assert!((lst - 4.315).abs() < 1e-3);
299    /// ```
300    pub fn local_mean_sidereal_time(&self, datetime: DateTime<Utc>) -> f64 {
301        let jd = julian_date(datetime);
302        local_mean_sidereal_time(jd, self.longitude_deg)
303    }
304
305    /// Returns latitude formatted as ±DD° MM′ SS.sss″ (DMS)
306    pub fn latitude_dms(&self) -> String {
307        format_dms(self.latitude_deg, true)
308    }
309
310    /// Returns longitude formatted as ±DDD° MM′ SS.sss″ (DMS)
311    pub fn longitude_dms(&self) -> String {
312        format_dms(self.longitude_deg, false)
313    }
314}
315
316/// Converts decimal degrees to DMS string format:
317/// - `±DD° MM′ SS.sss″` for latitude
318/// - `±DDD° MM′ SS.sss″` for longitude
319fn format_dms(deg: f64, is_lat: bool) -> String {
320    let sign = if deg < 0.0 { "-" } else { "" };
321    let abs = deg.abs();
322    let d = abs.trunc();
323    let m = ((abs - d) * 60.0).trunc();
324    let s = ((abs - d) * 60.0 - m) * 60.0;
325
326    if is_lat {
327        format!("{sign}{:02.0}° {:02.0}′ {:06.3}″", d, m, s)
328    } else {
329        format!("{sign}{:03.0}° {:02.0}′ {:06.3}″", d, m, s)
330    }
331}
332
333// Legacy DMS parser for backward compatibility
334fn parse_dms(s: &str) -> Result<f64> {
335    // Accepts: "+39 00 01.7", "-92 18 03.2", "39:00:01.7", "-00 30 00"
336    let original = s.trim();
337    
338    // Check for negative sign at the beginning
339    let is_negative = original.starts_with('-');
340    
341    let cleaned = original
342        .replace(['°', '\'', ':', '"'], " ");
343
344    let parts: Vec<&str> = cleaned.split_whitespace().collect();
345    if parts.len() < 2 {
346        return Err(AstroError::InvalidDmsFormat {
347            input: s.to_string(),
348            expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
349        });
350    }
351
352    let d = f64::from_str(parts[0].trim_start_matches(['+', '-']))
353        .map_err(|_| AstroError::InvalidDmsFormat {
354            input: s.to_string(),
355            expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
356        })?;
357    let m = f64::from_str(parts.get(1).unwrap_or(&"0")).map_err(|_| AstroError::InvalidDmsFormat {
358        input: s.to_string(),
359        expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
360    })?;
361    let s = f64::from_str(parts.get(2).unwrap_or(&"0")).map_err(|_| AstroError::InvalidDmsFormat {
362        input: s.to_string(),
363        expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
364    })?;
365
366    // Calculate the absolute value first, then apply sign
367    let abs_value = d.abs() + m / 60.0 + s / 3600.0;
368    
369    // Apply negative sign if original string started with -
370    Ok(if is_negative { -abs_value } else { abs_value })
371}
372
373/// Parse coordinate from various input formats
374fn parse_coordinate(input: &str, is_latitude: bool) -> Result<f64> {
375    let s = input.trim();
376    
377    // Extract compass direction if present
378    let (value_str, compass_dir) = extract_compass_direction(s);
379    
380    // Try various parsing strategies in order of likelihood
381    
382    // 1. Try compact formats first (specific patterns)
383    if let Ok(deg) = try_parse_compact(&value_str) {
384        return apply_compass_direction(deg, compass_dir, is_latitude);
385    }
386    
387    // 2. Try decimal degrees (most common)
388    if let Ok(deg) = try_parse_decimal_degrees(&value_str) {
389        return apply_compass_direction(deg, compass_dir, is_latitude);
390    }
391    
392    // 3. Try HMS format (for longitude)
393    if !is_latitude {
394        if let Ok(deg) = try_parse_hms(&value_str) {
395            return apply_compass_direction(deg, compass_dir, is_latitude);
396        }
397    }
398    
399    // 4. Try DMS format
400    if let Ok(deg) = try_parse_dms(&value_str) {
401        return apply_compass_direction(deg, compass_dir, is_latitude);
402    }
403    
404    // 5. Try degrees + decimal minutes
405    if let Ok(deg) = try_parse_dm(&value_str) {
406        return apply_compass_direction(deg, compass_dir, is_latitude);
407    }
408    
409    // If all parsing fails, provide helpful error message
410    Err(AstroError::InvalidDmsFormat {
411        input: input.to_string(),
412        expected: if is_latitude {
413            "Examples: 40.7128, 40.7128N, N40.7128, 40°42'46\", 40 42 46, 40d42m46s"
414        } else {
415            "Examples: -74.0060, 74.0060W, W74.0060, 74°0'21.6\", 74 0 21.6, 4h56m27s"
416        }
417    })
418}
419
420/// Extract compass direction from string and return cleaned value
421fn extract_compass_direction(s: &str) -> (String, Option<char>) {
422    let upper = s.to_uppercase();
423    
424    // Check for direction at the beginning
425    if let Some(first_char) = upper.chars().next() {
426        if matches!(first_char, 'N' | 'S' | 'E' | 'W') {
427            // Handle cases like "N40.7" or "N 40.7"
428            let remainder = s[1..].trim_start();
429            return (remainder.to_string(), Some(first_char));
430        }
431    }
432    
433    // Check for direction at the end - but only if it's a standalone letter or at the end of a word boundary
434    if let Some(last_char) = upper.chars().last() {
435        if matches!(last_char, 'N' | 'S' | 'E' | 'W') {
436            // Check if this is likely a compass direction vs part of a word
437            // Look at the character before the last one
438            let chars: Vec<char> = upper.chars().collect();
439            #[allow(clippy::comparison_chain)]
440            if chars.len() == 1 {
441                // Single character, definitely a direction
442                let value = s[..s.len()-1].trim_end();
443                return (value.to_string(), Some(last_char));
444            } else if chars.len() > 1 {
445                let second_to_last = chars[chars.len()-2];
446                // If preceded by space, digit, or punctuation, it's likely a direction
447                // If preceded by a letter, it's probably part of a word like "seconds"
448                // BUT: watch out for patterns like "40d42m46s" where 's' is seconds, not South
449                if !second_to_last.is_alphabetic() {
450                    // Special case: detect "seconds" vs "South" direction
451                    if last_char == 'S' && chars.len() >= 3 {
452                        // Look for patterns that indicate "seconds" vs "South"
453                        // "46s" or "27s" = seconds (digit immediately before 's', no other indicators)
454                        // "8\"S" or "33.8688 S" = South (has space, quotes, or other separators)
455                        let has_separators = s.contains(' ') || s.contains('"') || s.contains('\'') || s.contains('°');
456                        if !has_separators && second_to_last.is_ascii_digit() {
457                            // Pattern like "46s" - likely seconds
458                        } else {
459                            // Pattern like "8\"S" or "33.8688 S" - likely South direction
460                            let value = s[..s.len()-1].trim_end();
461                            return (value.to_string(), Some(last_char));
462                        }
463                    } else {
464                        let value = s[..s.len()-1].trim_end();
465                        return (value.to_string(), Some(last_char));
466                    }
467                }
468            }
469        }
470    }
471    
472    // Check for spelled out directions
473    let words: Vec<&str> = upper.split_whitespace().collect();
474    let s_upper = s.to_uppercase();
475    for word in &words {
476        match *word {
477            "NORTH" => return (s_upper.replace("NORTH", "").trim().to_string(), Some('N')),
478            "SOUTH" => return (s_upper.replace("SOUTH", "").trim().to_string(), Some('S')),
479            "EAST" => return (s_upper.replace("EAST", "").trim().to_string(), Some('E')),
480            "WEST" => return (s_upper.replace("WEST", "").trim().to_string(), Some('W')),
481            _ => {}
482        }
483    }
484    
485    (s.to_string(), None)
486}
487
488/// Apply compass direction to coordinate value
489fn apply_compass_direction(mut value: f64, direction: Option<char>, is_latitude: bool) -> Result<f64> {
490    if let Some(dir) = direction {
491        match dir {
492            'S' if is_latitude => value = -value.abs(),
493            'W' if !is_latitude => value = -value.abs(),
494            'N' if !is_latitude => return Err(AstroError::InvalidDmsFormat {
495                input: format!("{}{}", value, dir),
496                expected: "N/S for latitude, E/W for longitude"
497            }),
498            'E' if is_latitude => return Err(AstroError::InvalidDmsFormat {
499                input: format!("{}{}", value, dir),
500                expected: "N/S for latitude, E/W for longitude"
501            }),
502            _ => {}
503        }
504    }
505    
506    // Validate ranges
507    if is_latitude {
508        crate::error::validate_latitude(value)?;
509    } else {
510        crate::error::validate_longitude(value)?;
511    }
512    
513    Ok(value)
514}
515
516/// Try to parse decimal degrees
517fn try_parse_decimal_degrees(s: &str) -> Result<f64> {
518    // Must not contain letters (except scientific notation)
519    if s.chars().any(|c| c.is_alphabetic() && c != 'e' && c != 'E') {
520        return Err(AstroError::InvalidDmsFormat {
521            input: s.to_string(),
522            expected: "decimal degrees"
523        });
524    }
525    
526    // Simple decimal number
527    if let Ok(value) = f64::from_str(s) {
528        return Ok(value);
529    }
530    
531    // Handle leading + or - with spaces
532    let cleaned = s.trim_start_matches('+').trim();
533    if let Ok(value) = f64::from_str(cleaned) {
534        return Ok(value);
535    }
536    
537    Err(AstroError::InvalidDmsFormat {
538        input: s.to_string(),
539        expected: "decimal degrees"
540    })
541}
542
543/// Input validation to prevent DoS attacks
544fn validate_input_length(s: &str, _context: &str) -> Result<()> {
545    const MAX_INPUT_LENGTH: usize = 1000; // Prevent extremely long inputs
546    const MAX_UNICODE_LENGTH: usize = 500; // Unicode chars can be larger
547    
548    if s.len() > MAX_INPUT_LENGTH {
549        return Err(AstroError::InvalidDmsFormat {
550            input: format!("Input too long ({} chars)", s.len()),
551            expected: "Input must be < 1000 characters",
552        });
553    }
554    
555    if s.chars().count() > MAX_UNICODE_LENGTH {
556        return Err(AstroError::InvalidDmsFormat {
557            input: format!("Too many Unicode characters ({} chars)", s.chars().count()),
558            expected: "Input must be < 500 Unicode characters", 
559        });
560    }
561    
562    Ok(())
563}
564
565/// Try to parse HMS format (for longitude)
566fn try_parse_hms(s: &str) -> Result<f64> {
567    validate_input_length(s, "HMS")?;
568    
569    let normalized = s.to_lowercase()
570        .replace("hours", "h").replace("hour", "h")
571        .replace("minutes", "m").replace("minute", "m") 
572        .replace("seconds", "s").replace("second", "s")
573        .replace("hrs", "h").replace("hr", "h")
574        .replace("mins", "m").replace("min", "m")
575        .replace("secs", "s").replace("sec", "s")
576        .replace('′', "'")  // Unicode prime
577        .replace(['″', '"'], "\"");
578    
579    if let Some(caps) = HMS_REGEX.captures(&normalized) {
580        let h = f64::from_str(&caps[1]).map_err(|_| AstroError::InvalidDmsFormat {
581            input: s.to_string(),
582            expected: "HMS format"
583        })?;
584        let m = caps.get(2).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
585        let s = caps.get(3).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
586        
587        // Convert HMS to degrees (15 degrees per hour)
588        return Ok((h + m/60.0 + s/3600.0) * 15.0);
589    }
590    
591    // Try colon-separated HMS
592    if s.contains('h') || s.contains('H') {
593        let parts: Vec<&str> = s.split(':').collect();
594        if parts.len() >= 2 {
595            let h_part = parts[0].trim_end_matches(['h', 'H']);
596            if let Ok(h) = f64::from_str(h_part) {
597                let m = parts.get(1).and_then(|p| f64::from_str(p.trim()).ok()).unwrap_or(0.0);
598                let s = parts.get(2).and_then(|p| f64::from_str(p.trim()).ok()).unwrap_or(0.0);
599                return Ok((h + m/60.0 + s/3600.0) * 15.0);
600            }
601        }
602    }
603    
604    Err(AstroError::InvalidDmsFormat {
605        input: s.to_string(),
606        expected: "HMS format"
607    })
608}
609
610/// Try to parse DMS format with maximum flexibility
611fn try_parse_dms(s: &str) -> Result<f64> {
612    // First handle verbose format like "40 degrees 42 minutes 46 seconds"
613    let verbose_normalized = s.to_lowercase()
614        .replace("degrees", "d")
615        .replace("degree", "d") 
616        .replace("deg", "d")
617        .replace("minutes", "m")
618        .replace("minute", "m")
619        .replace("min", "m")
620        .replace("seconds", "s")
621        .replace("second", "s")
622        .replace("sec", "s");
623    
624    // Try parsing the verbose-normalized version first
625    if verbose_normalized != s.to_lowercase() {
626        // Only try this if we actually made substitutions
627        // But make sure to preserve the original negative sign detection
628        if let Ok(mut result) = try_parse_dms_internal(&verbose_normalized) {
629            // Check the original string for negative sign since that's what we want to preserve
630            if s.starts_with('-') {
631                result = -result.abs();
632            }
633            return Ok(result);
634        }
635    }
636    
637    // Then try the original string
638    try_parse_dms_internal(s)
639}
640
641/// Internal DMS parser that handles the actual parsing logic
642fn try_parse_dms_internal(s: &str) -> Result<f64> {
643    validate_input_length(s, "DMS")?;
644    
645    if let Some(caps) = DMS_REGEX.captures(s) {
646        if caps.get(2).is_some() {  // Ensure at least degrees and minutes
647            let d_str = &caps[1];
648            let is_negative = s.starts_with('-') || d_str.starts_with('-');
649            
650            let d = f64::from_str(d_str.trim_start_matches('-')).map_err(|_| AstroError::InvalidDmsFormat {
651                input: s.to_string(),
652                expected: "DMS format"
653            })?;
654            let m = caps.get(2).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
655            let s = caps.get(3).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
656            
657            let abs_value = d + m/60.0 + s/3600.0;
658            return Ok(if is_negative { -abs_value } else { abs_value });
659        }
660    }
661    
662    // Normalize Unicode and common symbols  
663    let _normalized = s
664        .replace(['°', 'º', '′', '″', '\'', '"', '"', '`'], " ")
665        .replace("''", " ") // Double apostrophe as seconds
666        .replace(['d', 'D', 'm', 'M', 's', 'S'], " ")
667        .to_lowercase();
668    
669    // Try various separators
670    let separators = [' ', ':', ',', ';'];
671    
672    // Check if the string starts with a negative sign
673    let is_negative = s.starts_with('-');
674    
675    for sep in &separators {
676        let parts: Vec<&str> = s.split(*sep).filter(|p| !p.is_empty()).collect();
677        if parts.len() >= 2 {
678            // Clean up parts
679            let clean_parts: Vec<String> = parts.iter().enumerate().map(|(i, p)| {
680                let cleaned = p.trim()
681                    .trim_end_matches(|c: char| c.is_alphabetic() || "°'\"″′".contains(c));
682                // For the first part, also trim leading sign
683                if i == 0 {
684                    cleaned.trim_start_matches(['+', '-']).to_string()
685                } else {
686                    cleaned.to_string()
687                }
688            }).collect();
689            
690            if let Ok(d) = f64::from_str(&clean_parts[0]) {
691                if let Ok(m) = f64::from_str(&clean_parts[1]) {
692                    let s = clean_parts.get(2)
693                        .and_then(|p| f64::from_str(p).ok())
694                        .unwrap_or(0.0);
695                    
696                    let abs_value = d + m/60.0 + s/3600.0;
697                    return Ok(if is_negative { -abs_value } else { abs_value });
698                }
699            }
700        }
701    }
702    
703    // Try dash separator specially 
704    if s.contains('-') {
705        // For negative numbers, skip the first dash
706        let dash_parts: Vec<&str> = if is_negative {
707            let no_first_dash = &s[1..]; // Remove the leading -
708            no_first_dash.split('-').collect()
709        } else {
710            s.split('-').collect()
711        };
712        
713        let parts: Vec<&str> = dash_parts.into_iter().filter(|p| !p.is_empty()).collect();
714        if parts.len() >= 2 {
715            let clean_parts: Vec<String> = parts.iter().map(|p| {
716                p.trim()
717                    .trim_end_matches(|c: char| c.is_alphabetic() || "°'\"″′".contains(c))
718                    .to_string()
719            }).collect();
720            
721            if let Ok(d) = f64::from_str(&clean_parts[0]) {
722                if let Ok(m) = f64::from_str(&clean_parts[1]) {
723                    let s = clean_parts.get(2)
724                        .and_then(|p| f64::from_str(p).ok())
725                        .unwrap_or(0.0);
726                    
727                    let abs_value = d + m/60.0 + s/3600.0;
728                    return Ok(if is_negative { -abs_value } else { abs_value });
729                }
730            }
731        }
732    }
733    
734    Err(AstroError::InvalidDmsFormat {
735        input: s.to_string(),
736        expected: "DMS format"
737    })
738}
739
740/// Try to parse compact formats like DDMMSS or DDMM.mmm
741fn try_parse_compact(s: &str) -> Result<f64> {
742    // Only try compact format if string has no spaces or separators
743    if s.contains(' ') || s.contains(':') || s.contains('-') || s.contains('°') {
744        return Err(AstroError::InvalidDmsFormat {
745            input: s.to_string(),
746            expected: "compact format"
747        });
748    }
749    
750    // Remove all non-digit characters except decimal point
751    let digits_only: String = s.chars()
752        .filter(|c| c.is_ascii_digit() || *c == '.')
753        .collect();
754    
755    // Must be mostly digits
756    if digits_only.len() < s.len() / 2 {
757        return Err(AstroError::InvalidDmsFormat {
758            input: s.to_string(),
759            expected: "compact format"
760        });
761    }
762    
763    // DDMM.mmm format (aviation/marine)
764    if digits_only.contains('.') && digits_only.len() >= 6 {
765        let parts: Vec<&str> = digits_only.split('.').collect();
766        if parts[0].len() == 4 || parts[0].len() == 5 {  // DDMM or DDDMM
767            if let Ok(ddmm) = i32::from_str(parts[0]) {
768                let dd = ddmm / 100;
769                let mm = ddmm % 100;
770                if mm < 60 {  // Valid minutes
771                    let decimal_minutes = parts.get(1)
772                        .and_then(|p| f64::from_str(&format!("0.{}", p)).ok())
773                        .unwrap_or(0.0);
774                    
775                    return Ok(dd as f64 + (mm as f64 + decimal_minutes) / 60.0);
776                }
777            }
778        }
779    }
780    
781    // DDMMSS format
782    if !digits_only.contains('.') && (digits_only.len() == 6 || digits_only.len() == 7) {
783        // DDMMSS or DDDMMSS
784        let (dd_len, _is_longitude) = if digits_only.len() == 7 { (3, true) } else { (2, false) };
785        
786        if let Ok(dd) = i32::from_str(&digits_only[..dd_len]) {
787            if let Ok(mm) = i32::from_str(&digits_only[dd_len..dd_len+2]) {
788                if let Ok(ss) = i32::from_str(&digits_only[dd_len+2..]) {
789                    if mm < 60 && ss < 60 {  // Valid minutes and seconds
790                        return Ok(dd as f64 + mm as f64 / 60.0 + ss as f64 / 3600.0);
791                    }
792                }
793            }
794        }
795    }
796    
797    Err(AstroError::InvalidDmsFormat {
798        input: s.to_string(),
799        expected: "compact format"
800    })
801}
802
803/// Try to parse degrees and decimal minutes
804fn try_parse_dm(s: &str) -> Result<f64> {
805    // Normalize the string
806    let normalized = s
807        .replace(['°', '′', '\'', 'd', 'm'], " ")
808        .to_lowercase();
809    
810    // Split and clean parts
811    let parts: Vec<&str> = normalized.split_whitespace()
812        .filter(|p| p.chars().any(|c| c.is_ascii_digit()))
813        .collect();
814    
815    if parts.len() == 2 {
816        if let Ok(d) = f64::from_str(parts[0]) {
817            if let Ok(m) = f64::from_str(parts[1]) {
818                // Check if minutes value makes sense (should be < 60 if integer part)
819                if m < 60.0 || m.fract() != 0.0 {
820                    let sign = if d < 0.0 { -1.0 } else { 1.0 };
821                    return Ok(sign * (d.abs() + m / 60.0));
822                }
823            }
824        }
825    }
826    
827    Err(AstroError::InvalidDmsFormat {
828        input: s.to_string(),
829        expected: "degrees and decimal minutes"
830    })
831}