Skip to main content

pcb_toolkit/
ppm.rs

1//! PPM / frequency conversion and crystal load capacitor calculator.
2//!
3//! Sub-calculators:
4//! 1. XTAL load capacitor value: C_load = (C1×C2)/(C1+C2) + C_stray
5//! 2. Hz to PPM: PPM = (variation / center_freq) × 1,000,000
6//! 3. PPM to Hz: variation = center_freq × PPM / 1,000,000
7
8use serde::{Deserialize, Serialize};
9
10use crate::CalcError;
11
12/// Result of a Hz→PPM conversion.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct HzToPpmResult {
15    /// Frequency variation in Hz (max_hz - center_hz).
16    pub variation_hz: f64,
17    /// Variation expressed in parts per million.
18    pub ppm: f64,
19}
20
21/// Result of a PPM→Hz conversion.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct PpmToHzResult {
24    /// Frequency variation in Hz.
25    pub variation_hz: f64,
26    /// Upper frequency limit: center + variation.
27    pub max_hz: f64,
28    /// Lower frequency limit: center - variation.
29    pub min_hz: f64,
30}
31
32/// Result of a crystal load capacitor calculation.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct XtalLoadResult {
35    /// Calculated load capacitance: (C1×C2)/(C1+C2) + C_stray, in Farads.
36    pub c_load_calc_f: f64,
37    /// Rule-of-thumb load capacitance: (C1 + C2) / 2, in Farads.
38    pub c_load_rule_of_thumb_f: f64,
39}
40
41/// Convert frequency deviation to PPM.
42///
43/// # Arguments
44/// - `center_hz` — nominal (center) frequency in Hz (must be > 0)
45/// - `max_hz` — upper frequency limit in Hz (must be > center_hz)
46///
47/// # Errors
48/// Returns [`CalcError::OutOfRange`] if inputs are invalid.
49pub fn hz_to_ppm(center_hz: f64, max_hz: f64) -> Result<HzToPpmResult, CalcError> {
50    if center_hz <= 0.0 {
51        return Err(CalcError::OutOfRange {
52            name: "center_hz",
53            value: center_hz,
54            expected: "> 0",
55        });
56    }
57    if max_hz <= center_hz {
58        return Err(CalcError::OutOfRange {
59            name: "max_hz",
60            value: max_hz,
61            expected: "> center_hz",
62        });
63    }
64
65    let variation_hz = max_hz - center_hz;
66    let ppm = (variation_hz / center_hz) * 1_000_000.0;
67
68    Ok(HzToPpmResult { variation_hz, ppm })
69}
70
71/// Convert PPM to frequency deviation.
72///
73/// # Arguments
74/// - `center_hz` — nominal (center) frequency in Hz (must be > 0)
75/// - `ppm` — parts-per-million deviation (must be > 0)
76///
77/// # Errors
78/// Returns [`CalcError::OutOfRange`] if inputs are invalid.
79pub fn ppm_to_hz(center_hz: f64, ppm: f64) -> Result<PpmToHzResult, CalcError> {
80    if center_hz <= 0.0 {
81        return Err(CalcError::OutOfRange {
82            name: "center_hz",
83            value: center_hz,
84            expected: "> 0",
85        });
86    }
87    if ppm <= 0.0 {
88        return Err(CalcError::OutOfRange {
89            name: "ppm",
90            value: ppm,
91            expected: "> 0",
92        });
93    }
94
95    let variation_hz = center_hz * ppm / 1_000_000.0;
96
97    Ok(PpmToHzResult {
98        variation_hz,
99        max_hz: center_hz + variation_hz,
100        min_hz: center_hz - variation_hz,
101    })
102}
103
104/// Calculate crystal load capacitance.
105///
106/// # Arguments
107/// - `c_load_spec_f` — specified load capacitance from the crystal datasheet, in Farads
108/// - `c_stray_f` — stray PCB capacitance in Farads (must be ≥ 0)
109/// - `c1_f` — load capacitor 1 in Farads (must be > 0)
110/// - `c2_f` — load capacitor 2 in Farads (must be > 0)
111///
112/// # Errors
113/// Returns [`CalcError::OutOfRange`] if inputs are invalid.
114pub fn xtal_load(
115    c_stray_f: f64,
116    c1_f: f64,
117    c2_f: f64,
118) -> Result<XtalLoadResult, CalcError> {
119    if c_stray_f < 0.0 {
120        return Err(CalcError::OutOfRange {
121            name: "c_stray_f",
122            value: c_stray_f,
123            expected: ">= 0",
124        });
125    }
126    if c1_f <= 0.0 {
127        return Err(CalcError::OutOfRange {
128            name: "c1_f",
129            value: c1_f,
130            expected: "> 0",
131        });
132    }
133    if c2_f <= 0.0 {
134        return Err(CalcError::OutOfRange {
135            name: "c2_f",
136            value: c2_f,
137            expected: "> 0",
138        });
139    }
140
141    let c_series = (c1_f * c2_f) / (c1_f + c2_f);
142    let c_load_calc_f = c_series + c_stray_f;
143    let c_load_rule_of_thumb_f = (c1_f + c2_f) / 2.0;
144
145    Ok(XtalLoadResult {
146        c_load_calc_f,
147        c_load_rule_of_thumb_f,
148    })
149}
150
151#[cfg(test)]
152mod tests {
153    use approx::assert_relative_eq;
154
155    use super::*;
156
157    // Saturn PDF page 32: Hz→PPM
158    // center=32000 Hz, max=32001 Hz → variation=1 Hz, PPM=31.25
159    #[test]
160    fn saturn_hz_to_ppm() {
161        let result = hz_to_ppm(32000.0, 32001.0).unwrap();
162        assert_relative_eq!(result.variation_hz, 1.0, epsilon = 1e-10);
163        assert_relative_eq!(result.ppm, 31.25, epsilon = 1e-6);
164    }
165
166    // Saturn PDF page 32: PPM→Hz
167    // center=50 MHz, PPM=25 → variation=1250 Hz, max=50001250, min=49998750
168    #[test]
169    fn saturn_ppm_to_hz() {
170        let result = ppm_to_hz(50e6, 25.0).unwrap();
171        assert_relative_eq!(result.variation_hz, 1250.0, epsilon = 1e-6);
172        assert_relative_eq!(result.max_hz, 50_001_250.0, epsilon = 1e-4);
173        assert_relative_eq!(result.min_hz, 49_998_750.0, epsilon = 1e-4);
174    }
175
176    // Saturn PDF page 32: XTAL load caps
177    // C_stray=3pF, C1=14pF, C2=14pF → calc=10pF, rule_of_thumb=14pF
178    #[test]
179    fn saturn_xtal_load() {
180        let result = xtal_load(3e-12, 14e-12, 14e-12).unwrap();
181        assert_relative_eq!(result.c_load_calc_f, 10e-12, epsilon = 1e-14);
182        assert_relative_eq!(result.c_load_rule_of_thumb_f, 14e-12, epsilon = 1e-14);
183    }
184
185    #[test]
186    fn error_on_zero_center_freq() {
187        assert!(hz_to_ppm(0.0, 100.0).is_err());
188        assert!(ppm_to_hz(0.0, 10.0).is_err());
189    }
190
191    #[test]
192    fn error_on_max_not_greater_than_center() {
193        assert!(hz_to_ppm(1000.0, 999.0).is_err());
194        assert!(hz_to_ppm(1000.0, 1000.0).is_err());
195    }
196}