Skip to main content

pcb_toolkit/
crosstalk.rs

1//! Standalone crosstalk calculator.
2//!
3//! Note: This calculator is marked "Unsupported" in the original Saturn PCB
4//! Toolkit due to formula accuracy concerns. Included for completeness.
5//!
6//! Estimates backward crosstalk (NEXT) using the standard coupled-line model:
7//!
8//! Kb = 1 / (4 × (1 + (S/H)²))
9//! Lsat = rise_time × v_prop / 2
10//! NEXT = Kb × min(coupled_length / Lsat, 1.0)
11//!
12//! # Known Limitations
13//! The standard Kb formula does not match Saturn's test vector (-2.23 dB / 3.87 V).
14//! Saturn likely uses a different formula or additional correction factors.
15//!
16//! # TODO
17//! - Match Saturn formula exactly
18//! - Stripline variant
19//! - Forward crosstalk (FEXT) estimation
20
21use serde::{Deserialize, Serialize};
22
23use crate::CalcError;
24use crate::constants::SPEED_OF_LIGHT_IN_NS;
25use crate::impedance::common;
26
27/// Inputs for crosstalk estimation.
28pub struct CrosstalkInput {
29    /// Signal rise time (ns).
30    pub rise_time_ns: f64,
31    /// Signal voltage (V).
32    pub voltage: f64,
33    /// Coupled (parallel) trace length (mils).
34    pub coupled_length_mils: f64,
35    /// Edge-to-edge spacing between traces (mils).
36    pub spacing_mils: f64,
37    /// Dielectric height — trace to ground plane (mils).
38    pub height_mils: f64,
39    /// Substrate relative permittivity.
40    pub er: f64,
41    /// Trace width (mils). Used for Er_eff calculation.
42    pub trace_width_mils: f64,
43}
44
45/// Result of a crosstalk estimation.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct CrosstalkResult {
48    /// Backward crosstalk coefficient Kb (dimensionless, 0–0.25).
49    pub kb: f64,
50    /// Crosstalk in dB (20 × log10(NEXT)).
51    pub crosstalk_db: f64,
52    /// Coupled voltage (V) = NEXT × voltage.
53    pub coupled_voltage: f64,
54    /// NEXT coefficient (dimensionless, 0–Kb).
55    pub next_coefficient: f64,
56    /// Saturation length (mils).
57    pub lsat_mils: f64,
58}
59
60/// Estimate backward crosstalk (NEXT) between parallel microstrip traces.
61pub fn calculate(input: &CrosstalkInput) -> Result<CrosstalkResult, CalcError> {
62    let CrosstalkInput {
63        rise_time_ns,
64        voltage,
65        coupled_length_mils,
66        spacing_mils,
67        height_mils,
68        er,
69        trace_width_mils,
70    } = *input;
71
72    if rise_time_ns <= 0.0 {
73        return Err(CalcError::OutOfRange {
74            name: "rise_time_ns",
75            value: rise_time_ns,
76            expected: "> 0",
77        });
78    }
79    if voltage <= 0.0 {
80        return Err(CalcError::OutOfRange {
81            name: "voltage",
82            value: voltage,
83            expected: "> 0",
84        });
85    }
86    if coupled_length_mils <= 0.0 {
87        return Err(CalcError::NegativeDimension {
88            name: "coupled_length_mils",
89            value: coupled_length_mils,
90        });
91    }
92    if spacing_mils <= 0.0 {
93        return Err(CalcError::NegativeDimension {
94            name: "spacing_mils",
95            value: spacing_mils,
96        });
97    }
98    if height_mils <= 0.0 {
99        return Err(CalcError::NegativeDimension {
100            name: "height_mils",
101            value: height_mils,
102        });
103    }
104    if er < 1.0 {
105        return Err(CalcError::OutOfRange {
106            name: "er",
107            value: er,
108            expected: ">= 1.0",
109        });
110    }
111    if trace_width_mils <= 0.0 {
112        return Err(CalcError::NegativeDimension {
113            name: "trace_width_mils",
114            value: trace_width_mils,
115        });
116    }
117
118    // Backward crosstalk coefficient
119    let s_over_h = spacing_mils / height_mils;
120    let kb = 1.0 / (4.0 * (1.0 + s_over_h * s_over_h));
121
122    // Propagation velocity from Er_eff
123    let u = trace_width_mils / height_mils;
124    let er_eff = common::er_eff_static(u, er);
125    // v_prop in in/ns
126    let v_prop = SPEED_OF_LIGHT_IN_NS / er_eff.sqrt();
127    // Convert to mils/ns: 1 in = 1000 mils
128    let v_prop_mils_ns = v_prop * 1000.0;
129
130    // Saturation length (mils)
131    let lsat_mils = rise_time_ns * v_prop_mils_ns / 2.0;
132
133    // NEXT coefficient (saturates at Kb)
134    let length_ratio = coupled_length_mils / lsat_mils;
135    let next_coefficient = kb * length_ratio.min(1.0);
136
137    // Coupled voltage
138    let coupled_voltage = next_coefficient * voltage;
139
140    // Crosstalk in dB
141    let crosstalk_db = 20.0 * next_coefficient.log10();
142
143    Ok(CrosstalkResult {
144        kb,
145        crosstalk_db,
146        coupled_voltage,
147        next_coefficient,
148        lsat_mils,
149    })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn basic_crosstalk() {
158        let result = calculate(&CrosstalkInput {
159            rise_time_ns: 1.0,
160            voltage: 5.0,
161            coupled_length_mils: 1000.0,
162            spacing_mils: 10.0,
163            height_mils: 5.0,
164            er: 4.6,
165            trace_width_mils: 10.0,
166        })
167        .unwrap();
168
169        // Kb should be in range (0, 0.25]
170        assert!(result.kb > 0.0 && result.kb <= 0.25, "Kb = {}", result.kb);
171        // NEXT should be in range (0, Kb]
172        assert!(
173            result.next_coefficient > 0.0 && result.next_coefficient <= result.kb,
174            "NEXT = {}",
175            result.next_coefficient
176        );
177        assert!(result.coupled_voltage > 0.0 && result.coupled_voltage < 5.0);
178        assert!(result.crosstalk_db < 0.0); // dB is negative for NEXT < 1
179        assert!(result.lsat_mils > 0.0);
180    }
181
182    #[test]
183    fn wider_spacing_less_crosstalk() {
184        let close = calculate(&CrosstalkInput {
185            rise_time_ns: 1.0,
186            voltage: 5.0,
187            coupled_length_mils: 1000.0,
188            spacing_mils: 5.0,
189            height_mils: 5.0,
190            er: 4.6,
191            trace_width_mils: 10.0,
192        })
193        .unwrap();
194
195        let far = calculate(&CrosstalkInput {
196            rise_time_ns: 1.0,
197            voltage: 5.0,
198            coupled_length_mils: 1000.0,
199            spacing_mils: 20.0,
200            height_mils: 5.0,
201            er: 4.6,
202            trace_width_mils: 10.0,
203        })
204        .unwrap();
205
206        assert!(
207            close.kb > far.kb,
208            "close Kb {} should be > far Kb {}",
209            close.kb,
210            far.kb
211        );
212    }
213
214    #[test]
215    fn kb_max_at_zero_spacing_limit() {
216        // As S→0, Kb→0.25
217        let result = calculate(&CrosstalkInput {
218            rise_time_ns: 1.0,
219            voltage: 5.0,
220            coupled_length_mils: 1000.0,
221            spacing_mils: 0.01, // very small spacing
222            height_mils: 5.0,
223            er: 4.6,
224            trace_width_mils: 10.0,
225        })
226        .unwrap();
227
228        assert!(result.kb > 0.24, "Kb at near-zero spacing = {}", result.kb);
229    }
230
231    #[test]
232    fn short_coupled_length_reduces_next() {
233        let long = calculate(&CrosstalkInput {
234            rise_time_ns: 1.0,
235            voltage: 5.0,
236            coupled_length_mils: 10000.0,
237            spacing_mils: 10.0,
238            height_mils: 5.0,
239            er: 4.6,
240            trace_width_mils: 10.0,
241        })
242        .unwrap();
243
244        let short = calculate(&CrosstalkInput {
245            rise_time_ns: 1.0,
246            voltage: 5.0,
247            coupled_length_mils: 100.0,
248            spacing_mils: 10.0,
249            height_mils: 5.0,
250            er: 4.6,
251            trace_width_mils: 10.0,
252        })
253        .unwrap();
254
255        assert!(
256            short.next_coefficient <= long.next_coefficient,
257            "short NEXT {} should be <= long NEXT {}",
258            short.next_coefficient,
259            long.next_coefficient
260        );
261    }
262
263    #[test]
264    fn rejects_negative_spacing() {
265        let result = calculate(&CrosstalkInput {
266            rise_time_ns: 1.0,
267            voltage: 5.0,
268            coupled_length_mils: 1000.0,
269            spacing_mils: -1.0,
270            height_mils: 5.0,
271            er: 4.6,
272            trace_width_mils: 10.0,
273        });
274        assert!(result.is_err());
275    }
276
277    #[test]
278    fn rejects_zero_rise_time() {
279        let result = calculate(&CrosstalkInput {
280            rise_time_ns: 0.0,
281            voltage: 5.0,
282            coupled_length_mils: 1000.0,
283            spacing_mils: 10.0,
284            height_mils: 5.0,
285            er: 4.6,
286            trace_width_mils: 10.0,
287        });
288        assert!(result.is_err());
289    }
290}