Skip to main content

pcb_toolkit/
units.rs

1//! Unit conversion at the API boundary.
2//!
3//! All internal computation uses canonical units (mils for length, Hz for frequency, etc.).
4//! These types and functions convert user-facing values to/from internal representation.
5//!
6//! The newtype wrappers ([`Length`], [`Freq`], etc.) implement [`FromStr`] to parse
7//! strings like `"0.254mm"` or `"1GHz"`, converting to canonical units on construction.
8
9use std::fmt;
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13
14/// Length units accepted at the API boundary.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub enum LengthUnit {
17    Mils,
18    Mm,
19    Inches,
20    #[serde(rename = "um")]
21    Um,
22}
23
24/// Frequency units accepted at the API boundary.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum FreqUnit {
27    Hz,
28    #[serde(rename = "kHz")]
29    KHz,
30    MHz,
31    GHz,
32}
33
34/// Capacitance units for display.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum CapUnit {
37    F,
38    #[serde(rename = "uF")]
39    UF,
40    #[serde(rename = "nF")]
41    NF,
42    #[serde(rename = "pF")]
43    PF,
44}
45
46/// Inductance units for display.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48pub enum IndUnit {
49    H,
50    #[serde(rename = "mH")]
51    MH,
52    #[serde(rename = "uH")]
53    UH,
54    #[serde(rename = "nH")]
55    NH,
56}
57
58/// Resistance units for display.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60pub enum ResUnit {
61    #[serde(rename = "mOhm")]
62    MOhm,
63    Ohm,
64    #[serde(rename = "kOhm")]
65    KOhm,
66    #[serde(rename = "MOhm")]
67    MOhmMega,
68}
69
70/// Temperature units.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72pub enum TempUnit {
73    Celsius,
74    Fahrenheit,
75}
76
77// ── Length conversions ───────────────────────────────────────────────
78
79/// Convert from user units to mils (internal canonical unit).
80pub fn to_mils(value: f64, unit: LengthUnit) -> f64 {
81    match unit {
82        LengthUnit::Mils => value,
83        LengthUnit::Mm => value / 0.0254,
84        LengthUnit::Inches => value * 1000.0,
85        LengthUnit::Um => value / 25.4,
86    }
87}
88
89/// Convert from mils to user units.
90pub fn from_mils(value: f64, unit: LengthUnit) -> f64 {
91    match unit {
92        LengthUnit::Mils => value,
93        LengthUnit::Mm => value * 0.0254,
94        LengthUnit::Inches => value / 1000.0,
95        LengthUnit::Um => value * 25.4,
96    }
97}
98
99// ── Frequency conversions ───────────────────────────────────────────
100
101/// Convert from user units to Hz (internal canonical unit).
102pub fn to_hz(value: f64, unit: FreqUnit) -> f64 {
103    match unit {
104        FreqUnit::Hz => value,
105        FreqUnit::KHz => value * 1e3,
106        FreqUnit::MHz => value * 1e6,
107        FreqUnit::GHz => value * 1e9,
108    }
109}
110
111/// Convert from Hz to user units.
112pub fn from_hz(value: f64, unit: FreqUnit) -> f64 {
113    match unit {
114        FreqUnit::Hz => value,
115        FreqUnit::KHz => value / 1e3,
116        FreqUnit::MHz => value / 1e6,
117        FreqUnit::GHz => value / 1e9,
118    }
119}
120
121// ── Capacitance conversions ─────────────────────────────────────────
122
123/// Convert from user units to Farads (internal canonical unit).
124pub fn to_farads(value: f64, unit: CapUnit) -> f64 {
125    match unit {
126        CapUnit::F => value,
127        CapUnit::UF => value * 1e-6,
128        CapUnit::NF => value * 1e-9,
129        CapUnit::PF => value * 1e-12,
130    }
131}
132
133/// Convert from Farads to user units.
134pub fn from_farads(value: f64, unit: CapUnit) -> f64 {
135    match unit {
136        CapUnit::F => value,
137        CapUnit::UF => value / 1e-6,
138        CapUnit::NF => value / 1e-9,
139        CapUnit::PF => value / 1e-12,
140    }
141}
142
143// ── Inductance conversions ──────────────────────────────────────────
144
145/// Convert from user units to Henries (internal canonical unit).
146pub fn to_henries(value: f64, unit: IndUnit) -> f64 {
147    match unit {
148        IndUnit::H => value,
149        IndUnit::MH => value * 1e-3,
150        IndUnit::UH => value * 1e-6,
151        IndUnit::NH => value * 1e-9,
152    }
153}
154
155/// Convert from Henries to user units.
156pub fn from_henries(value: f64, unit: IndUnit) -> f64 {
157    match unit {
158        IndUnit::H => value,
159        IndUnit::MH => value / 1e-3,
160        IndUnit::UH => value / 1e-6,
161        IndUnit::NH => value / 1e-9,
162    }
163}
164
165// ── Temperature conversions ─────────────────────────────────────────
166
167/// Convert to Celsius (internal canonical unit).
168pub fn to_celsius(value: f64, unit: TempUnit) -> f64 {
169    match unit {
170        TempUnit::Celsius => value,
171        TempUnit::Fahrenheit => (value - 32.0) * 5.0 / 9.0,
172    }
173}
174
175/// Convert from Celsius to user units.
176pub fn from_celsius(value: f64, unit: TempUnit) -> f64 {
177    match unit {
178        TempUnit::Celsius => value,
179        TempUnit::Fahrenheit => value * 9.0 / 5.0 + 32.0,
180    }
181}
182
183// ── Parse error ────────────────────────────────────────────────────
184
185/// Error returned when parsing a unit-annotated value from a string.
186#[derive(Debug, Clone, PartialEq, thiserror::Error)]
187pub enum UnitParseError {
188    #[error("invalid number in '{0}'")]
189    InvalidNumber(String),
190
191    #[error("unknown unit suffix in '{0}'")]
192    UnknownSuffix(String),
193
194    #[error("value is not finite")]
195    NotFinite,
196}
197
198// ── Parsing helper ─────────────────────────────────────────────────
199
200/// Split `"3.14mm"` into `("3.14", "mm")`.
201///
202/// Handles leading/trailing whitespace, negative signs, and scientific notation
203/// (`1e3`, `1.5E-6`). The suffix portion is trimmed of leading whitespace so
204/// that `"100 mil"` (quoted on the shell) also works.
205fn split_number_suffix(s: &str) -> (&str, &str) {
206    let s = s.trim();
207    let bytes = s.as_bytes();
208    let mut i = 0;
209
210    // Optional leading sign.
211    if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'+') {
212        i += 1;
213    }
214
215    // Integer part.
216    while i < bytes.len() && bytes[i].is_ascii_digit() {
217        i += 1;
218    }
219
220    // Optional fractional part.
221    if i < bytes.len() && bytes[i] == b'.' {
222        i += 1;
223        while i < bytes.len() && bytes[i].is_ascii_digit() {
224            i += 1;
225        }
226    }
227
228    // Optional exponent — only consume 'e'/'E' if followed by digit or sign+digit.
229    if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
230        let mut j = i + 1;
231        if j < bytes.len() && (bytes[j] == b'-' || bytes[j] == b'+') {
232            j += 1;
233        }
234        if j < bytes.len() && bytes[j].is_ascii_digit() {
235            i = j;
236            while i < bytes.len() && bytes[i].is_ascii_digit() {
237                i += 1;
238            }
239        }
240    }
241
242    (&s[..i], s[i..].trim_start())
243}
244
245// ── Newtype wrappers ───────────────────────────────────────────────
246
247/// A length value stored in canonical mils.
248///
249/// Parses strings like `"10mil"`, `"0.254mm"`, `"0.01in"`, `"254um"`.
250/// Bare numbers (no suffix) are interpreted as mils.
251#[derive(Debug, Clone, Copy, PartialEq)]
252pub struct Length(pub f64);
253
254impl Length {
255    pub fn mils(self) -> f64 {
256        self.0
257    }
258}
259
260impl FromStr for Length {
261    type Err = UnitParseError;
262
263    fn from_str(s: &str) -> Result<Self, Self::Err> {
264        let (num, suffix) = split_number_suffix(s);
265        let value: f64 = num
266            .parse()
267            .map_err(|_| UnitParseError::InvalidNumber(s.to_string()))?;
268        if !value.is_finite() {
269            return Err(UnitParseError::NotFinite);
270        }
271        let unit = match suffix.to_lowercase().as_str() {
272            "" | "mil" | "mils" => LengthUnit::Mils,
273            "mm" => LengthUnit::Mm,
274            "in" | "inch" | "inches" => LengthUnit::Inches,
275            "um" | "µm" => LengthUnit::Um,
276            _ => return Err(UnitParseError::UnknownSuffix(s.to_string())),
277        };
278        Ok(Length(to_mils(value, unit)))
279    }
280}
281
282impl fmt::Display for Length {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        write!(f, "{}mil", self.0)
285    }
286}
287
288/// A frequency value stored in canonical Hz.
289///
290/// Parses strings like `"1GHz"`, `"100MHz"`, `"50kHz"`, `"1000"`.
291/// Bare numbers (no suffix) are interpreted as Hz.
292#[derive(Debug, Clone, Copy, PartialEq)]
293pub struct Freq(pub f64);
294
295impl Freq {
296    pub fn hz(self) -> f64 {
297        self.0
298    }
299}
300
301impl FromStr for Freq {
302    type Err = UnitParseError;
303
304    fn from_str(s: &str) -> Result<Self, Self::Err> {
305        let (num, suffix) = split_number_suffix(s);
306        let value: f64 = num
307            .parse()
308            .map_err(|_| UnitParseError::InvalidNumber(s.to_string()))?;
309        if !value.is_finite() {
310            return Err(UnitParseError::NotFinite);
311        }
312        let unit = match suffix.to_lowercase().as_str() {
313            "" | "hz" => FreqUnit::Hz,
314            "khz" => FreqUnit::KHz,
315            "mhz" => FreqUnit::MHz,
316            "ghz" => FreqUnit::GHz,
317            _ => return Err(UnitParseError::UnknownSuffix(s.to_string())),
318        };
319        Ok(Freq(to_hz(value, unit)))
320    }
321}
322
323impl fmt::Display for Freq {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        write!(f, "{}Hz", self.0)
326    }
327}
328
329/// A capacitance value stored in canonical Farads.
330///
331/// Parses strings like `"100pF"`, `"10nF"`, `"1uF"`, `"1µF"`.
332/// Bare numbers (no suffix) are interpreted as Farads.
333#[derive(Debug, Clone, Copy, PartialEq)]
334pub struct Capacitance(pub f64);
335
336impl Capacitance {
337    pub fn farads(self) -> f64 {
338        self.0
339    }
340}
341
342impl FromStr for Capacitance {
343    type Err = UnitParseError;
344
345    fn from_str(s: &str) -> Result<Self, Self::Err> {
346        let (num, suffix) = split_number_suffix(s);
347        let value: f64 = num
348            .parse()
349            .map_err(|_| UnitParseError::InvalidNumber(s.to_string()))?;
350        if !value.is_finite() {
351            return Err(UnitParseError::NotFinite);
352        }
353        let norm = suffix.replace('µ', "u").to_lowercase();
354        let unit = match norm.as_str() {
355            "" | "f" => CapUnit::F,
356            "uf" => CapUnit::UF,
357            "nf" => CapUnit::NF,
358            "pf" => CapUnit::PF,
359            _ => return Err(UnitParseError::UnknownSuffix(s.to_string())),
360        };
361        Ok(Capacitance(to_farads(value, unit)))
362    }
363}
364
365impl fmt::Display for Capacitance {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        write!(f, "{}F", self.0)
368    }
369}
370
371/// An inductance value stored in canonical Henries.
372///
373/// Parses strings like `"10nH"`, `"1uH"`, `"1µH"`, `"100mH"`.
374/// Bare numbers (no suffix) are interpreted as Henries.
375#[derive(Debug, Clone, Copy, PartialEq)]
376pub struct Inductance(pub f64);
377
378impl Inductance {
379    pub fn henries(self) -> f64 {
380        self.0
381    }
382}
383
384impl FromStr for Inductance {
385    type Err = UnitParseError;
386
387    fn from_str(s: &str) -> Result<Self, Self::Err> {
388        let (num, suffix) = split_number_suffix(s);
389        let value: f64 = num
390            .parse()
391            .map_err(|_| UnitParseError::InvalidNumber(s.to_string()))?;
392        if !value.is_finite() {
393            return Err(UnitParseError::NotFinite);
394        }
395        let norm = suffix.replace('µ', "u").to_lowercase();
396        let unit = match norm.as_str() {
397            "" | "h" => IndUnit::H,
398            "mh" => IndUnit::MH,
399            "uh" => IndUnit::UH,
400            "nh" => IndUnit::NH,
401            _ => return Err(UnitParseError::UnknownSuffix(s.to_string())),
402        };
403        Ok(Inductance(to_henries(value, unit)))
404    }
405}
406
407impl fmt::Display for Inductance {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        write!(f, "{}H", self.0)
410    }
411}
412
413/// A temperature value stored in canonical Celsius.
414///
415/// Parses strings like `"25C"`, `"77F"`, `"25°C"`, `"25degC"`.
416/// Bare numbers (no suffix) are interpreted as Celsius.
417/// Suffix matching is **case-sensitive** (`C`/`F` uppercase only).
418#[derive(Debug, Clone, Copy, PartialEq)]
419pub struct Temperature(pub f64);
420
421impl Temperature {
422    pub fn celsius(self) -> f64 {
423        self.0
424    }
425}
426
427impl FromStr for Temperature {
428    type Err = UnitParseError;
429
430    fn from_str(s: &str) -> Result<Self, Self::Err> {
431        let (num, suffix) = split_number_suffix(s);
432        let value: f64 = num
433            .parse()
434            .map_err(|_| UnitParseError::InvalidNumber(s.to_string()))?;
435        if !value.is_finite() {
436            return Err(UnitParseError::NotFinite);
437        }
438        let unit = match suffix {
439            "" | "C" | "°C" | "degC" => TempUnit::Celsius,
440            "F" | "°F" | "degF" => TempUnit::Fahrenheit,
441            _ => return Err(UnitParseError::UnknownSuffix(s.to_string())),
442        };
443        Ok(Temperature(to_celsius(value, unit)))
444    }
445}
446
447impl fmt::Display for Temperature {
448    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449        write!(f, "{}°C", self.0)
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn length_roundtrip() {
459        let mils = 100.0;
460        for unit in [LengthUnit::Mils, LengthUnit::Mm, LengthUnit::Inches, LengthUnit::Um] {
461            let converted = from_mils(mils, unit);
462            let back = to_mils(converted, unit);
463            assert!((back - mils).abs() < 1e-10, "roundtrip failed for {unit:?}");
464        }
465    }
466
467    #[test]
468    fn known_conversions() {
469        // 1 mil = 0.0254 mm
470        assert!((to_mils(0.0254, LengthUnit::Mm) - 1.0).abs() < 1e-10);
471        // 1 inch = 1000 mils
472        assert!((to_mils(1.0, LengthUnit::Inches) - 1000.0).abs() < 1e-10);
473        // 25.4 µm = 1 mil
474        assert!((to_mils(25.4, LengthUnit::Um) - 1.0).abs() < 1e-10);
475        // 1 GHz = 1e9 Hz
476        assert!((to_hz(1.0, FreqUnit::GHz) - 1e9).abs() < 1.0);
477        // 32°F = 0°C
478        assert!((to_celsius(32.0, TempUnit::Fahrenheit)).abs() < 1e-10);
479    }
480
481    // ── split_number_suffix ────────────────────────────────────────
482
483    #[test]
484    fn split_bare_number() {
485        assert_eq!(split_number_suffix("100"), ("100", ""));
486    }
487
488    #[test]
489    fn split_with_suffix() {
490        assert_eq!(split_number_suffix("0.254mm"), ("0.254", "mm"));
491    }
492
493    #[test]
494    fn split_scientific() {
495        assert_eq!(split_number_suffix("1e3mm"), ("1e3", "mm"));
496        assert_eq!(split_number_suffix("1.5E-6nH"), ("1.5E-6", "nH"));
497    }
498
499    #[test]
500    fn split_negative() {
501        assert_eq!(split_number_suffix("-5mil"), ("-5", "mil"));
502    }
503
504    #[test]
505    fn split_whitespace() {
506        assert_eq!(split_number_suffix("  100mil  "), ("100", "mil"));
507        // Space between number and suffix (quoted on shell).
508        assert_eq!(split_number_suffix("100 mil"), ("100", "mil"));
509    }
510
511    // ── Length parsing ─────────────────────────────────────────────
512
513    #[test]
514    fn parse_length_bare() {
515        let l: Length = "100".parse().unwrap();
516        assert!((l.mils() - 100.0).abs() < 1e-10);
517    }
518
519    #[test]
520    fn parse_length_mil() {
521        let l: Length = "100mil".parse().unwrap();
522        assert!((l.mils() - 100.0).abs() < 1e-10);
523        let l2: Length = "100mils".parse().unwrap();
524        assert!((l2.mils() - 100.0).abs() < 1e-10);
525    }
526
527    #[test]
528    fn parse_length_mm() {
529        let l: Length = "0.254mm".parse().unwrap();
530        assert!((l.mils() - 10.0).abs() < 1e-6);
531    }
532
533    #[test]
534    fn parse_length_inches() {
535        let l: Length = "0.1in".parse().unwrap();
536        assert!((l.mils() - 100.0).abs() < 1e-10);
537        let l2: Length = "0.1inch".parse().unwrap();
538        assert!((l2.mils() - 100.0).abs() < 1e-10);
539    }
540
541    #[test]
542    fn parse_length_um() {
543        let l: Length = "25.4um".parse().unwrap();
544        assert!((l.mils() - 1.0).abs() < 1e-10);
545    }
546
547    #[test]
548    fn parse_length_um_unicode() {
549        let l: Length = "25.4µm".parse().unwrap();
550        assert!((l.mils() - 1.0).abs() < 1e-10);
551    }
552
553    #[test]
554    fn parse_length_scientific() {
555        let l: Length = "1e3mil".parse().unwrap();
556        assert!((l.mils() - 1000.0).abs() < 1e-10);
557    }
558
559    #[test]
560    fn parse_length_negative() {
561        let l: Length = "-5mil".parse().unwrap();
562        assert!((l.mils() - (-5.0)).abs() < 1e-10);
563    }
564
565    #[test]
566    fn parse_length_case_insensitive() {
567        let l: Length = "10MIL".parse().unwrap();
568        assert!((l.mils() - 10.0).abs() < 1e-10);
569        let l2: Length = "1MM".parse().unwrap();
570        assert!((l2.mils() - to_mils(1.0, LengthUnit::Mm)).abs() < 1e-10);
571    }
572
573    #[test]
574    fn parse_length_errors() {
575        assert!("".parse::<Length>().is_err());
576        assert!("mm".parse::<Length>().is_err());
577        assert!("100ft".parse::<Length>().is_err());
578        assert!("abc".parse::<Length>().is_err());
579    }
580
581    // ── Freq parsing ───────────────────────────────────────────────
582
583    #[test]
584    fn parse_freq_bare() {
585        let f: Freq = "1000000".parse().unwrap();
586        assert!((f.hz() - 1_000_000.0).abs() < 1.0);
587    }
588
589    #[test]
590    fn parse_freq_mhz() {
591        let f: Freq = "100MHz".parse().unwrap();
592        assert!((f.hz() - 100e6).abs() < 1.0);
593    }
594
595    #[test]
596    fn parse_freq_ghz() {
597        let f: Freq = "2.4GHz".parse().unwrap();
598        assert!((f.hz() - 2.4e9).abs() < 1.0);
599    }
600
601    #[test]
602    fn parse_freq_khz() {
603        let f: Freq = "50kHz".parse().unwrap();
604        assert!((f.hz() - 50e3).abs() < 1.0);
605    }
606
607    #[test]
608    fn parse_freq_case_insensitive() {
609        let f: Freq = "1ghz".parse().unwrap();
610        assert!((f.hz() - 1e9).abs() < 1.0);
611    }
612
613    #[test]
614    fn parse_freq_errors() {
615        assert!("".parse::<Freq>().is_err());
616        assert!("Hz".parse::<Freq>().is_err());
617        assert!("100rpm".parse::<Freq>().is_err());
618    }
619
620    // ── Capacitance parsing ────────────────────────────────────────
621
622    #[test]
623    fn parse_cap_pf() {
624        let c: Capacitance = "100pF".parse().unwrap();
625        assert!((c.farads() - 100e-12).abs() < 1e-20);
626    }
627
628    #[test]
629    fn parse_cap_uf_unicode() {
630        let c: Capacitance = "10µF".parse().unwrap();
631        assert!((c.farads() - 10e-6).abs() < 1e-14);
632    }
633
634    // ── Inductance parsing ─────────────────────────────────────────
635
636    #[test]
637    fn parse_ind_nh() {
638        let i: Inductance = "10nH".parse().unwrap();
639        assert!((i.henries() - 10e-9).abs() < 1e-18);
640    }
641
642    #[test]
643    fn parse_ind_uh_unicode() {
644        let i: Inductance = "4.7µH".parse().unwrap();
645        assert!((i.henries() - 4.7e-6).abs() < 1e-14);
646    }
647
648    // ── Temperature parsing ────────────────────────────────────────
649
650    #[test]
651    fn parse_temp_celsius() {
652        let t: Temperature = "25C".parse().unwrap();
653        assert!((t.celsius() - 25.0).abs() < 1e-10);
654        let t2: Temperature = "25°C".parse().unwrap();
655        assert!((t2.celsius() - 25.0).abs() < 1e-10);
656        let t3: Temperature = "25degC".parse().unwrap();
657        assert!((t3.celsius() - 25.0).abs() < 1e-10);
658    }
659
660    #[test]
661    fn parse_temp_fahrenheit() {
662        let t: Temperature = "77F".parse().unwrap();
663        assert!((t.celsius() - 25.0).abs() < 0.01);
664        let t2: Temperature = "77°F".parse().unwrap();
665        assert!((t2.celsius() - 25.0).abs() < 0.01);
666    }
667
668    #[test]
669    fn parse_temp_bare_is_celsius() {
670        let t: Temperature = "100".parse().unwrap();
671        assert!((t.celsius() - 100.0).abs() < 1e-10);
672    }
673
674    // ── Display ────────────────────────────────────────────────────
675
676    #[test]
677    fn display_length() {
678        assert_eq!(format!("{}", Length(100.0)), "100mil");
679    }
680
681    #[test]
682    fn display_freq() {
683        assert_eq!(format!("{}", Freq(1e9)), "1000000000Hz");
684    }
685
686    #[test]
687    fn display_temp() {
688        assert_eq!(format!("{}", Temperature(25.0)), "25°C");
689    }
690}