Skip to main content

pcb_toolkit/impedance/
coplanar.rs

1//! Coplanar waveguide (CPW over ground) impedance calculator.
2//!
3//! Reference: Wadell, "Transmission Line Design Handbook", 1991.
4
5use crate::CalcError;
6use crate::impedance::{common, types::ImpedanceResult};
7
8/// Inputs for coplanar waveguide (CPW over ground) impedance calculation.
9/// All dimensions in mils.
10pub struct CoplanarInput {
11    /// Center conductor width (mils).
12    pub width: f64,
13    /// Gap between center conductor and coplanar ground (mils).
14    pub gap: f64,
15    /// Substrate height to bottom ground plane (mils).
16    pub height: f64,
17    /// Conductor thickness (mils).
18    pub thickness: f64,
19    /// Substrate relative permittivity.
20    pub er: f64,
21}
22
23/// Complete elliptic integral ratio K(k)/K(k') via the Hilberg approximation.
24///
25/// Returns K(k)/K(k'), where k' = sqrt(1 - k²).
26///
27/// For k <= 1/sqrt(2): K(k)/K(k') = π / ln(2·(1+sqrt(k'))/(1-sqrt(k')))
28/// For k >  1/sqrt(2): K(k)/K(k') = (1/π) · ln(2·(1+sqrt(k))/(1-sqrt(k)))
29fn elliptic_ratio(k: f64) -> f64 {
30    let threshold = 1.0 / std::f64::consts::SQRT_2;
31    if k <= threshold {
32        let kp = (1.0 - k * k).sqrt();
33        std::f64::consts::PI / (2.0 * (1.0 + kp.sqrt()) / (1.0 - kp.sqrt())).ln()
34    } else {
35        (1.0 / std::f64::consts::PI) * (2.0 * (1.0 + k.sqrt()) / (1.0 - k.sqrt())).ln()
36    }
37}
38
39/// Compute coplanar waveguide (CPW over ground) impedance and derived quantities.
40pub fn calculate(input: &CoplanarInput) -> Result<ImpedanceResult, CalcError> {
41    let CoplanarInput { width, gap, height, thickness: _, er } = *input;
42
43    if width <= 0.0 {
44        return Err(CalcError::NegativeDimension { name: "width", value: width });
45    }
46    if gap <= 0.0 {
47        return Err(CalcError::NegativeDimension { name: "gap", value: gap });
48    }
49    if height <= 0.0 {
50        return Err(CalcError::NegativeDimension { name: "height", value: height });
51    }
52    if er < 1.0 {
53        return Err(CalcError::OutOfRange {
54            name: "er",
55            value: er,
56            expected: ">= 1.0",
57        });
58    }
59
60    // Modulus for the air-region elliptic integral
61    let k = width / (width + 2.0 * gap);
62
63    // Modulus for the substrate elliptic integral (via hyperbolic tangents)
64    let k3 = (std::f64::consts::PI * width / (4.0 * height)).tanh()
65        / (std::f64::consts::PI * (width + 2.0 * gap) / (4.0 * height)).tanh();
66
67    // Effective dielectric constant (Wadell CPW-over-ground formula)
68    //   Er_eff = 1 + (Er-1)/2 · [K(k')/K(k)] · [K(k3)/K(k3')]
69    // Since elliptic_ratio(k) = K(k)/K(k'):
70    //   K(k')/K(k) = 1 / elliptic_ratio(k)
71    //   K(k3)/K(k3') = elliptic_ratio(k3)
72    let er_eff = 1.0 + (er - 1.0) / 2.0 * (1.0 / elliptic_ratio(k)) * elliptic_ratio(k3);
73
74    // Characteristic impedance
75    //   Z0 = 30π / (sqrt(Er_eff) · K(k)/K(k'))
76    let zo = (30.0 * std::f64::consts::PI) / (er_eff.sqrt() * elliptic_ratio(k));
77
78    let tpd = common::propagation_delay(er_eff);
79    let lo = common::inductance_per_length(zo, tpd);
80    let co = common::capacitance_per_length(zo, tpd);
81
82    Ok(ImpedanceResult {
83        zo,
84        er_eff,
85        tpd_ps_per_in: tpd,
86        lo_nh_per_in: lo,
87        co_pf_per_in: co,
88    })
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use approx::assert_relative_eq;
95
96    fn typical() -> CoplanarInput {
97        CoplanarInput { width: 10.0, gap: 5.0, height: 10.0, thickness: 1.4, er: 4.6 }
98    }
99
100    #[test]
101    fn reasonable_impedance() {
102        let result = calculate(&typical()).unwrap();
103        assert!(
104            result.zo >= 30.0 && result.zo <= 150.0,
105            "Z0 = {} should be in 30–150 Ω range",
106            result.zo
107        );
108    }
109
110    #[test]
111    fn narrower_gap_lowers_impedance() {
112        let wide_gap = calculate(&typical()).unwrap();
113        let narrow_gap = calculate(&CoplanarInput { gap: 2.0, ..typical() }).unwrap();
114        assert!(
115            narrow_gap.zo < wide_gap.zo,
116            "narrow gap Z0 {} should be < wide gap Z0 {}",
117            narrow_gap.zo,
118            wide_gap.zo
119        );
120    }
121
122    #[test]
123    fn higher_er_lowers_impedance() {
124        let low_er = calculate(&typical()).unwrap();
125        let high_er = calculate(&CoplanarInput { er: 9.8, ..typical() }).unwrap();
126        assert!(
127            high_er.zo < low_er.zo,
128            "high-Er Z0 {} should be < low-Er Z0 {}",
129            high_er.zo,
130            low_er.zo
131        );
132    }
133
134    #[test]
135    fn er_eff_between_one_and_er() {
136        let result = calculate(&typical()).unwrap();
137        assert!(
138            result.er_eff > 1.0 && result.er_eff < 4.6,
139            "er_eff = {} should be in (1.0, 4.6)",
140            result.er_eff
141        );
142    }
143
144    #[test]
145    fn wide_gap_approaches_microstrip_range() {
146        // With a very wide gap the coplanar grounds are far from the center conductor;
147        // the bottom ground plane dominates and Z0 should be in the microstrip ballpark.
148        let result = calculate(&CoplanarInput {
149            width: 10.0,
150            gap: 1000.0,
151            height: 10.0,
152            thickness: 1.4,
153            er: 4.6,
154        })
155        .unwrap();
156        // With the coplanar grounds removed, the field is predominantly in air above
157        // the substrate; Er_eff approaches 1 and Z0 rises well above the microstrip
158        // value.  The Wadell CPW formula yields roughly 140 Ω for this geometry.
159        // Accept anything in the plausible 40–200 Ω transmission-line range.
160        assert!(
161            result.zo > 40.0 && result.zo < 200.0,
162            "wide-gap Z0 {} should be in a plausible transmission-line range (40–200 Ω)",
163            result.zo
164        );
165    }
166
167    #[test]
168    fn rejects_non_positive_width() {
169        let result = calculate(&CoplanarInput { width: 0.0, ..typical() });
170        assert!(result.is_err());
171        let result = calculate(&CoplanarInput { width: -1.0, ..typical() });
172        assert!(result.is_err());
173    }
174
175    #[test]
176    fn rejects_non_positive_gap() {
177        let result = calculate(&CoplanarInput { gap: 0.0, ..typical() });
178        assert!(result.is_err());
179        let result = calculate(&CoplanarInput { gap: -5.0, ..typical() });
180        assert!(result.is_err());
181    }
182
183    #[test]
184    fn rejects_non_positive_height() {
185        let result = calculate(&CoplanarInput { height: 0.0, ..typical() });
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn rejects_er_below_one() {
191        let result = calculate(&CoplanarInput { er: 0.5, ..typical() });
192        assert!(result.is_err());
193    }
194
195    #[test]
196    fn derived_quantities_consistent() {
197        let r = calculate(&typical()).unwrap();
198        // Lo = Zo × Tpd (ps/in → ns/in ÷1000)
199        let lo_check = r.zo * r.tpd_ps_per_in / 1000.0;
200        assert_relative_eq!(r.lo_nh_per_in, lo_check, max_relative = 1e-10);
201        // Co = Tpd / Zo (same unit bookkeeping)
202        let co_check = (r.tpd_ps_per_in / 1000.0) / r.zo * 1000.0;
203        assert_relative_eq!(r.co_pf_per_in, co_check, max_relative = 1e-10);
204    }
205}