gistools/space/
sat.rs

1use crate::{
2    space::{
3        propagation::{SGP4ErrorOutput, SGP4Output, sgp4, sgp4init},
4        util::{
5            constants::MINUTES_PER_DAY,
6            time::{TimeStamp, days2mdhms, jday},
7        },
8    },
9    util::Date,
10};
11use alloc::{format, string::String, vec, vec::Vec};
12use core::f64::consts::PI;
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15
16/// Classification of TLE
17/// - U: unclassified
18/// - C: classified
19/// - S: secret
20#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[repr(u8)]
22pub enum Classification {
23    /// Unclassified
24    #[default]
25    U,
26    /// Classified
27    C,
28    /// Secret
29    S,
30}
31impl From<&str> for Classification {
32    fn from(s: &str) -> Self {
33        match s {
34            "U" | "u" => Classification::U,
35            "C" | "c" => Classification::C,
36            "S" | "s" => Classification::S,
37            _ => Classification::U,
38        }
39    }
40}
41
42/// Mode of operation AFSPC or Improved
43/// - a: afspc
44/// - i: improved
45#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[repr(u8)]
47pub enum OperationMode {
48    /// AFSPC
49    A,
50    /// Improved
51    #[default]
52    I,
53}
54/// Method of orbit determination
55/// - d: deep space
56/// - n: near earth
57#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[repr(u8)]
59pub enum Method {
60    /// Deep Space
61    D,
62    /// Near Earth
63    #[default]
64    N,
65}
66
67/// TLE Data Interface
68#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
69pub struct TLEData {
70    /// Name
71    pub name: String,
72    /// Number
73    pub number: f64,
74    /// Classification
75    pub class: Classification,
76    /// Identifier
77    pub id: String,
78    /// Date
79    pub date: Date,
80    /// Epoch Days
81    pub epochdays: f64,
82    /// fd mm
83    pub fdmm: f64,
84    /// sd mm
85    pub sdmm: f64,
86    /// Drag coefficient
87    pub drag: f64,
88    /// Mean motion
89    pub ephemeris: f64,
90    /// Eccentricity
91    pub esn: f64,
92    /// Inclination
93    pub inclination: f64,
94    /// Right ascension
95    pub ascension: f64,
96    /// Eccentricity
97    pub eccentricity: f64,
98    /// Perigee
99    pub perigee: f64,
100    /// Mean anomaly
101    pub anomaly: f64,
102    /// Mean motion
103    pub motion: f64,
104    /// Revolution
105    pub revolution: f64,
106    /// Bstar
107    pub rms: Option<f64>,
108}
109impl From<&str> for TLEData {
110    /// Parse TLE string
111    /// https://en.wikipedia.org/wiki/Two-line_element_set
112    fn from(value: &str) -> Self {
113        let mut lines: Vec<&str> = trim(value).lines().collect();
114        let mut tle = TLEData::default();
115
116        // Line 0: optional name
117        if lines.len() >= 3 {
118            let mut name = trim(lines.remove(0));
119            if name.starts_with("0 ") {
120                name = &name[2..];
121            }
122            tle.name = name.into();
123        }
124
125        // Line 1
126        let line = lines.remove(0);
127        let checksum = check(line);
128        if checksum != line[68..69].parse::<u32>().unwrap() {
129            panic!("Line 1 checksum mismatch: {} != {}: {}", checksum, &line[68..69], line);
130        }
131
132        tle.number = parse_float(&alpha5_converter(&line[2..7]));
133        tle.class = trim(&line[7..9]).into();
134        tle.id = trim(&line[9..18]).into();
135        (tle.date, tle.epochdays) = parse_epoch(&line[18..33]);
136        tle.fdmm = parse_float(&line[33..44]);
137        tle.sdmm = parse_float(&line[44..53]);
138        tle.drag = parse_drag(&line[53..62]);
139        tle.ephemeris = parse_float(&line[62..64]);
140        tle.esn = parse_float(&line[64..68]);
141
142        // Line 2
143        let line = lines.remove(0);
144        let checksum = check(line);
145        if checksum != line[68..69].parse::<u32>().unwrap() {
146            panic!("Line 2 checksum mismatch: {} != {}: {}", checksum, &line[68..69], line);
147        }
148
149        tle.inclination = parse_float(&line[8..17]);
150        tle.ascension = parse_float(&line[17..26]);
151        tle.eccentricity = parse_float(&format!("0.{}", &line[26..34]));
152        tle.perigee = parse_float(&line[34..43]);
153        tle.anomaly = parse_float(&line[43..52]);
154        tle.motion = parse_float(&line[52..63]);
155        tle.revolution = parse_float(&line[63..68]);
156
157        tle
158    }
159}
160
161/// Celestrak TLE Data Interface
162#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
163pub struct TLEDataCelestrak {
164    /// Object name
165    #[serde(rename = "OBJECT_NAME")]
166    pub object_name: String,
167    /// Object ID
168    #[serde(rename = "OBJECT_ID")]
169    pub object_id: String,
170    /// Epoch
171    #[serde(rename = "EPOCH")]
172    pub epoch: String,
173    /// Mean Motion
174    #[serde(rename = "MEAN_MOTION")]
175    pub mean_motion: f64,
176    /// Eccentricity
177    #[serde(rename = "ECCENTRICITY")]
178    pub eccentricity: f64,
179    /// Inclination
180    #[serde(rename = "INCLINATION")]
181    pub inclination: f64,
182    /// Right Ascension
183    #[serde(rename = "RA_OF_ASC_NODE")]
184    pub ra_of_asc_node: f64,
185    /// Argument of Peri-center
186    #[serde(rename = "ARG_OF_PERICENTER")]
187    pub arg_of_pericenter: f64,
188    /// Mean Anomaly
189    #[serde(rename = "MEAN_ANOMALY")]
190    pub mean_anomaly: f64,
191    /// Ephemeris Type
192    #[serde(rename = "EPHEMERIS_TYPE")]
193    pub ephemeris_type: f64,
194    /// Classification Type
195    #[serde(rename = "CLASSIFICATION_TYPE")]
196    pub classification_type: String,
197    /// Norad Cat ID
198    #[serde(rename = "NORAD_CAT_ID")]
199    pub norad_cat_id: f64,
200    /// Element Set Number
201    #[serde(rename = "ELEMENT_SET_NO")]
202    pub element_set_no: f64,
203    /// Rev at Epoch
204    #[serde(rename = "REV_AT_EPOCH")]
205    pub rev_at_epoch: f64,
206    /// Bstar
207    #[serde(rename = "BSTAR")]
208    pub bstar: f64,
209    /// Mean Motion Dot
210    #[serde(rename = "MEAN_MOTION_DOT")]
211    pub mean_motion_dot: f64,
212    /// Mean Motion Ddot
213    #[serde(rename = "MEAN_MOTION_DDOT")]
214    pub mean_motion_ddot: f64,
215    /// RMS
216    #[serde(rename = "RMS")]
217    pub rms: String,
218    /// Data Source
219    #[serde(rename = "DATA_SOURCE")]
220    pub data_source: String,
221}
222/// Convert Celestrak TLE data to a standard TLE data object
223/// [JSON example](https://celestrak.org/NORAD/elements/supplemental/index.php?FORMAT=json)
224impl From<&TLEDataCelestrak> for TLEData {
225    fn from(data: &TLEDataCelestrak) -> Self {
226        // convert date to UTC to avoid javascripts localization issues
227        let date: Date = (&*data.epoch).into();
228        let start = Date::new(date.year, 0, 0);
229        TLEData {
230            name: data.object_name.clone(),
231            number: data.norad_cat_id,
232            class: (&*data.classification_type).into(),
233            id: data.object_id.clone(),
234            date,
235            epochdays: jday(&date) - jday(&start),
236            fdmm: data.mean_motion_dot,
237            sdmm: data.mean_motion_ddot,
238            drag: data.bstar,
239            ephemeris: data.ephemeris_type,
240            esn: data.element_set_no,
241            inclination: data.inclination,
242            ascension: data.ra_of_asc_node,
243            eccentricity: data.eccentricity,
244            perigee: data.arg_of_pericenter,
245            anomaly: data.mean_anomaly,
246            motion: data.mean_motion,
247            revolution: data.rev_at_epoch,
248            rms: data.rms.parse().ok(),
249        }
250    }
251}
252
253/// # Satellite Orbit Class
254///
255/// ## Description
256/// A class representing a satellite orbit.
257///
258/// ## Examples
259///
260/// ### Input TLE example
261/// ```txt
262/// STARLINK-1007
263/// 1 44713C 19074A   23048.53451389 -.00009219  00000+0 -61811-3 0   482
264/// 2 44713  53.0512 157.2379 0001140  81.3827  74.7980 15.06382459    15
265/// ```
266///
267/// ### Run example
268/// ```ts
269/// import { Satellite } from 'gis-tools-ts';
270///
271/// const sat = new Satellite(tleString);
272/// // get propagation at time
273/// const { position, velocity } = sat.propagate(new Date());
274/// ```
275///
276/// ## Usage
277/// - [`Satellite::new`]: Create a new Satellite object from a TLE string
278/// - [`Satellite::gpu`]: Convert the satellite state to an array that is readable by the GPU
279/// - [`Satellite::propagate`]: Propagate the orbit of the satellite to a given time
280/// - [`Satellite::sgp4`]: Propagate the orbit of the satellite to a given time
281///
282/// ## Links
283/// - https://en.wikipedia.org/wiki/Two-line_element_set
284/// - https://celestrak.org/NORAD/documentation/tle-fmt.php
285/// - https://www.space-track.org/documentation#tle-basic
286#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
287pub struct Satellite {
288    /// If the satellite is initialized
289    pub init: bool, // = false;
290    // Line 0
291    /// Name of the satellite
292    pub name: String, // = 'default',
293    // Line 1
294    /// (satnum) Satellite catalog number or NORAD (North American Aerospace Defense) Catalog Number
295    pub number: f64,
296    /// Classification (U: unclassified, C: classified, S: secret)
297    pub class: Classification,
298    /// International Designator
299    pub id: String, // = 'null';
300    /// (epochyr + epochdays)
301    pub date: Date, // = new Date();
302    /// Epoch year
303    pub epochyr: f64,
304    /// Epoch days
305    pub epochdays: f64,
306    /// full Sat epoch
307    pub jdsatepoch: f64,
308    /// (ndot) First derivative of mean motion; the ballistic coefficient
309    pub fdmm: f64,
310    /// (nddot) Second derivative of mean motion (decimal point assumed)
311    pub sdmm: f64,
312    /// (bstar) B*, the drag term, or radiation pressure coefficient (decimal point assumed)
313    pub drag: f64,
314    /// Ephemeris type (always zero; only used in undistributed TLE data)
315    pub ephemeris: f64,
316    /// Element set number. Incremented when a new TLE is generated for this object.
317    pub esn: f64,
318    // Line 2
319    /// Inclination (degrees)
320    pub inclination: f64,
321    /// Right ascension of the ascending node (degrees)
322    pub ascension: f64,
323    /// Eccentricity (decimal point assumed)
324    pub eccentricity: f64,
325    /// Argument of perigee (degrees)
326    pub perigee: f64,
327    /// Mean anomaly (degrees)    
328    pub anomaly: f64,
329    /// Mean motion (revolutions per day)
330    pub motion: f64,
331    /// Revolution number at epoch (revolutions)
332    pub revolution: f64,
333    // extra
334    /// Operation Mode
335    pub opsmode: OperationMode,
336    /// RMS
337    pub rms: Option<f64>,
338    // ------------ all near earth variables ------------
339    /// Is IMP
340    pub isimp: f64,
341    /// method
342    pub method: Method,
343    /// ay coefficient
344    pub aycof: f64,
345    /// con 41
346    pub con41: f64,
347    /// cc1
348    pub cc1: f64,
349    /// cc4
350    pub cc4: f64,
351    /// cc5
352    pub cc5: f64,
353    /// d2
354    pub d2: f64,
355    /// d3
356    pub d3: f64,
357    /// d4
358    pub d4: f64,
359    /// delmo
360    pub delmo: f64,
361    /// eta
362    pub eta: f64,
363    /// argpdot
364    pub argpdot: f64,
365    /// omgcof
366    pub omgcof: f64,
367    /// sinmao
368    pub sinmao: f64,
369    /// t2cof
370    pub t2cof: f64,
371    /// t3cof
372    pub t3cof: f64,
373    /// t4cof
374    pub t4cof: f64,
375    /// t5cof
376    pub t5cof: f64,
377    /// x1mth2
378    pub x1mth2: f64,
379    /// x7thm1
380    pub x7thm1: f64,
381    /// mdot
382    pub mdot: f64,
383    /// nodedot
384    pub nodedot: f64,
385    /// xlcof
386    pub xlcof: f64,
387    /// xmcof
388    pub xmcof: f64,
389    /// nodecf
390    pub nodecf: f64,
391    // ------------ all deep space variables ------------
392    /// irez
393    pub irez: f64,
394    /// d2201
395    pub d2201: f64,
396    /// d2211
397    pub d2211: f64,
398    /// d3210
399    pub d3210: f64,
400    /// d3222
401    pub d3222: f64,
402    /// d4410
403    pub d4410: f64,
404    /// d4422
405    pub d4422: f64,
406    /// d5220
407    pub d5220: f64,
408    /// d5232
409    pub d5232: f64,
410    /// d5421
411    pub d5421: f64,
412    /// d5433
413    pub d5433: f64,
414    /// dedt
415    pub dedt: f64,
416    /// del1
417    pub del1: f64,
418    /// del2
419    pub del2: f64,
420    /// del3
421    pub del3: f64,
422    /// didt
423    pub didt: f64,
424    /// dmdt
425    pub dmdt: f64,
426    /// dnodt
427    pub dnodt: f64,
428    /// domdt
429    pub domdt: f64,
430    /// e3
431    pub e3: f64,
432    /// ee2
433    pub ee2: f64,
434    /// peo
435    pub peo: f64,
436    /// pgho
437    pub pgho: f64,
438    /// pho
439    pub pho: f64,
440    /// pinco
441    pub pinco: f64,
442    /// plo
443    pub plo: f64,
444    /// se2
445    pub se2: f64,
446    /// se3
447    pub se3: f64,
448    /// sgh2
449    pub sgh2: f64,
450    /// sgh3
451    pub sgh3: f64,
452    /// sgh4
453    pub sgh4: f64,
454    /// sh2
455    pub sh2: f64,
456    /// sh3
457    pub sh3: f64,
458    /// si2
459    pub si2: f64,
460    /// si3
461    pub si3: f64,
462    /// sl2
463    pub sl2: f64,
464    /// sl3
465    pub sl3: f64,
466    /// sl4
467    pub sl4: f64,
468    /// gsto
469    pub gsto: f64,
470    /// xfact
471    pub xfact: f64,
472    /// xgh2
473    pub xgh2: f64,
474    /// xgh3
475    pub xgh3: f64,
476    /// xgh4
477    pub xgh4: f64,
478    /// xh2
479    pub xh2: f64,
480    /// xh3
481    pub xh3: f64,
482    /// xi2
483    pub xi2: f64,
484    /// xi3
485    pub xi3: f64,
486    /// xl2
487    pub xl2: f64,
488    /// xl3
489    pub xl3: f64,
490    /// xl4
491    pub xl4: f64,
492    /// xlamo
493    pub xlamo: f64,
494    /// zmol
495    pub zmol: f64,
496    /// zmos
497    pub zmos: f64,
498    /// atime
499    pub atime: f64,
500    /// xli
501    pub xli: f64,
502    /// xni
503    pub xni: f64,
504}
505impl Satellite {
506    /// Create a new Satellite using TLE data
507    ///
508    /// ## Parameters
509    /// - `data`: TLE data or TLE string
510    /// - `initialize`: initialize the object on creation
511    pub fn new(data: &TLEData, initialize: Option<bool>) -> Self {
512        let mut this = Self::default();
513        let initialize = initialize.unwrap_or(true);
514        this.rms = data.rms;
515        this.name = data.name.clone();
516        this.number = data.number;
517        this.class = data.class;
518        this.id = data.id.clone();
519        this.date = data.date;
520        this.fdmm = data.fdmm;
521        this.sdmm = data.sdmm;
522        this.drag = data.drag;
523        this.ephemeris = data.ephemeris;
524        this.esn = data.esn;
525        this.inclination = data.inclination.to_radians();
526        this.ascension = data.ascension.to_radians();
527        this.eccentricity = data.eccentricity;
528        this.perigee = data.perigee.to_radians();
529        this.anomaly = data.anomaly.to_radians();
530        // convert revolution from deg/day to rad/minute
531        this.motion = data.motion;
532        this.revolution = data.revolution;
533        this.epochdays = data.epochdays;
534
535        this.epochyr = (this.date.year % 100) as f64;
536        // convert revolution from deg/day to rad/minute
537        this.motion /= 1440. / (2. * PI); // rad/min (229.1831180523293)
538        // find sgp4epoch time of element set
539        // remember that sgp4 uses units of days from 0 jan 1950 (sgp4epoch)
540        // and minutes from the epoch (time)
541        let year = if this.epochyr < 57. { this.epochyr + 2000. } else { this.epochyr + 1900. };
542        let mdhms_result = days2mdhms(year as u16, this.epochdays);
543
544        let TimeStamp { mon, day, hr, min, sec } = mdhms_result;
545        this.jdsatepoch = jday(&Date::new_full(
546            year as u16,
547            mon as u8,
548            day as u8,
549            hr as u8,
550            min as u8,
551            sec as u8,
552        ));
553
554        if initialize {
555            sgp4init(&mut this);
556        }
557
558        this
559    }
560
561    /// Converts satellite state to an array that is readable by the GPU
562    ///
563    /// ## Returns
564    /// Satellite state in an array
565    pub fn gpu(&self) -> Vec<f64> {
566        vec![
567            self.anomaly,
568            self.motion,
569            self.eccentricity,
570            self.inclination,
571            if self.method == Method::D { 0. } else { 1. }, // 0 -> 'd', 1 -> 'n'
572            if self.opsmode == OperationMode::A { 0. } else { 1. }, // 0 -> 'a'; 1 -> 'i'
573            self.drag,
574            self.mdot,
575            self.perigee,
576            self.argpdot,
577            self.ascension,
578            self.nodedot,
579            self.nodecf,
580            self.cc1,
581            self.cc4,
582            self.cc5,
583            self.t2cof,
584            self.isimp,
585            self.omgcof,
586            self.eta,
587            self.xmcof,
588            self.delmo,
589            self.d2,
590            self.d3,
591            self.d4,
592            self.sinmao,
593            self.t3cof,
594            self.t4cof,
595            self.t5cof,
596            self.irez,
597            self.d2201,
598            self.d2211,
599            self.d3210,
600            self.d3222,
601            self.d4410,
602            self.d4422,
603            self.d5220,
604            self.d5232,
605            self.d5421,
606            self.d5433,
607            self.dedt,
608            self.del1,
609            self.del2,
610            self.del3,
611            self.didt,
612            self.dmdt,
613            self.dnodt,
614            self.domdt,
615            self.gsto,
616            self.xfact,
617            self.xlamo,
618            self.atime,
619            self.xli,
620            self.xni,
621            self.aycof,
622            self.xlcof,
623            self.con41,
624            self.x1mth2,
625            self.x7thm1,
626            self.zmos,
627            self.zmol,
628            self.se2,
629            self.se3,
630            self.si2,
631            self.si3,
632            self.sl2,
633            self.sl3,
634            self.sl4,
635            self.sgh2,
636            self.sgh3,
637            self.sgh4,
638            self.sh2,
639            self.sh3,
640            self.ee2,
641            self.e3,
642            self.xi2,
643            self.xi3,
644            self.xl2,
645            self.xl3,
646            self.xl4,
647            self.xgh2,
648            self.xgh3,
649            self.xgh4,
650            self.xh2,
651            self.xh3,
652            self.peo,
653            self.pinco,
654            self.plo,
655            self.pgho,
656            self.pho,
657        ]
658    }
659
660    /// propagate the satellite's position and velocity given a Date input
661    ///
662    /// ## Parameters
663    /// - `time`: Date object
664    ///
665    /// ## Returns
666    /// satellite state at that time given
667    pub fn propagate(&self, time: &Date) -> Result<SGP4Output, SGP4ErrorOutput> {
668        let j = jday(time);
669        sgp4(self, (j - self.jdsatepoch) * MINUTES_PER_DAY)
670    }
671
672    /// time in minutes since epoch
673    ///
674    /// ## Parameters
675    /// - `time`: time in minutes from satellite epoch
676    ///
677    /// ## Returns
678    /// satellite state at that time given
679    pub fn sgp4(&self, time: f64) -> Result<SGP4Output, SGP4ErrorOutput> {
680        sgp4(self, time)
681    }
682}
683
684/// Parse a float number from a string (TLE-style scientific notation)
685fn parse_float(value: &str) -> f64 {
686    let re = Regex::new(r"([-])?([.\d]+)([+-]\d+)?").unwrap();
687
688    if let Some(caps) = re.captures(value) {
689        let sign = if caps.get(1).map_or("", |m| m.as_str()) == "-" { -1.0 } else { 1.0 };
690        let base = caps.get(2).map_or("0", |m| m.as_str());
691        let power = caps.get(3).map_or_else(|| "e0".into(), |m| format!("e{}", m.as_str()));
692        let combined = format!("{}{}", base, power);
693        return sign * combined.parse::<f64>().unwrap();
694    }
695
696    0.0
697}
698
699/// Parse a drag coefficient from TLE-style string
700fn parse_drag(value: &str) -> f64 {
701    let re = Regex::new(r"([-])?([.\d]+)([+-]\d+)?").unwrap();
702    if let Some(caps) = re.captures(value) {
703        let sign = if caps.get(1).map_or("", |m| m.as_str()) == "-" { -1.0 } else { 1.0 };
704        let base = caps.get(2).map_or("0", |m| m.as_str());
705        let base = if base.contains('.') { base.into() } else { format!("0.{}", base) };
706        let power = caps.get(3).map_or_else(|| "e0".into(), |m| format!("e{}", m.as_str()));
707        return sign * format!("{}{}", base, power).parse::<f64>().unwrap();
708    }
709
710    0.0
711}
712
713/// Parse TLE epoch string (e.g., "22345.6789") into your Date struct
714fn parse_epoch(value: &str) -> (Date, f64) {
715    // Trim whitespace
716    let re = Regex::new(r"^\s+|\s+$").unwrap();
717    let value: String = re.replace_all(value, "").into();
718
719    // Parse epoch year and day-of-year
720    let epoch = value[0..2].parse::<u16>().unwrap();
721    let days = value[2..].parse::<f64>().unwrap();
722
723    // Compute full year
724    let now_year = 2025;
725    let current_epoch = (now_year % 100) as u16;
726    let century = now_year - current_epoch as i32;
727    let year = if epoch > current_epoch + 1 {
728        (century - 100 + epoch as i32) as u16
729    } else {
730        (century + epoch as i32) as u16
731    };
732
733    // Convert day-of-year to month/day/hour/minute/second
734    let ts = days2mdhms(year, days);
735
736    // Return Date
737    (
738        Date::new_full(year, ts.mon as u8, ts.day as u8, ts.hr as u8, ts.min as u8, ts.sec as u8),
739        days,
740    )
741}
742
743/// Check TLE checksum
744fn check(line: &str) -> u32 {
745    let mut sum = 0;
746
747    for c in line.chars().take(68) {
748        if c.is_ascii_digit() {
749            sum += c.to_digit(10).unwrap();
750        } else if c == '-' {
751            sum += 1;
752        }
753    }
754
755    sum % 10
756}
757
758/// Converts alpha5 to alpha2
759/// NOTE: Alpha5 skips I and O
760fn alpha5_converter(s: &str) -> String {
761    if let Some(first_char) = s.chars().next() {
762        // if first char is numeric, return unchanged
763        if first_char.is_ascii_digit() {
764            return s.into();
765        }
766
767        let alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ";
768        if let Some(idx) = alphabet.find(first_char) {
769            // prepend converted index+10, keep rest
770            let rest: String = s.chars().skip(1).collect();
771            return format!("{}{}", idx + 10, rest);
772        }
773    }
774
775    // fallback: return unchanged
776    s.into()
777}
778
779/// Trim leading and trailing whitespace, BOM, and non-breaking spaces
780fn trim(s: &str) -> &str {
781    fn is_trim_char(c: char) -> bool {
782        c.is_whitespace() || c == '\u{FEFF}' || c == '\u{00A0}'
783    }
784    s.trim_matches(is_trim_char)
785}