Skip to main content

pcb_toolkit/
spacing.rs

1//! IPC-2221C minimum conductor spacing lookup.
2//!
3//! 8 device categories × 9 voltage ranges, plus linear extrapolation above 500 V.
4
5use serde::{Deserialize, Serialize};
6
7use crate::CalcError;
8
9/// IPC-2221C device type categories.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum DeviceType {
12    B1,
13    B2,
14    B3,
15    B4,
16    B5,
17    A6,
18    A7,
19    A8,
20}
21
22/// Inputs for the IPC-2221C conductor spacing lookup.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct SpacingInput {
25    /// Peak voltage across the conductor gap (V).
26    pub voltage: f64,
27    /// IPC-2221C device type category.
28    pub device_type: DeviceType,
29}
30
31/// Results of an IPC-2221C conductor spacing lookup.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct SpacingResult {
34    /// Minimum conductor spacing in mils.
35    pub spacing_mils: f64,
36    /// Minimum conductor spacing in millimetres.
37    pub spacing_mm: f64,
38}
39
40// Lookup table: rows = device types [B1..A8], columns = voltage ranges [0..8].
41// Voltage ranges: 0-15, 16-30, 31-50, 51-100, 101-150, 151-170, 171-250, 251-300, 301-500.
42// All values in mils.
43#[rustfmt::skip]
44const TABLE: [[f64; 9]; 8] = [
45    //   0-15    16-30   31-50   51-100  101-150 151-170 171-250 251-300 301-500
46    [  1.97,   1.97,   3.94,   3.94,   7.87,   7.87,   7.87,   7.87,   9.84 ], // B1
47    [  3.94,   3.94,  25.17,  25.17,  25.17,  49.21,  49.21,  49.21,  98.43 ], // B2
48    [  3.94,   3.94,  25.17,  59.06, 125.98, 125.98, 251.97, 492.13, 492.13 ], // B3
49    [  2.95,   2.95,  11.81,  11.81,  31.50,  31.50,  31.50,  31.50,  62.99 ], // B4
50    [  2.95,   2.95,   5.12,   5.12,  15.75,  15.75,  15.75,  15.75,  31.50 ], // B5
51    [  5.12,   5.12,   5.12,   5.12,  15.75,  15.75,  15.75,  15.75,  31.50 ], // A6
52    [  5.12,   9.84,  15.75,  19.69,  31.50,  31.50,  31.50,  31.50,  59.06 ], // A7
53    [  5.12,   9.84,  31.50,  39.37,  62.99,  62.99,  62.99,  62.99, 118.11 ], // A8
54];
55
56/// Linear extrapolation coefficients for voltages above 500 V.
57/// Each entry is (slope mils/V, intercept mils) for `spacing = (V - 500) * slope + intercept`.
58#[rustfmt::skip]
59const EXTRAP: [(f64, f64); 8] = [
60    (0.098425, 9.8425),   // B1
61    (0.196850, 98.4252),  // B2
62    (0.984252, 492.1260), // B3
63    (0.120070, 62.9900),  // B4
64    (0.120070, 31.4961),  // B5
65    (0.120070, 31.4961),  // A6
66    (0.120070, 59.0551),  // A7
67    (0.240157, 118.1100), // A8
68];
69
70fn device_index(d: DeviceType) -> usize {
71    match d {
72        DeviceType::B1 => 0,
73        DeviceType::B2 => 1,
74        DeviceType::B3 => 2,
75        DeviceType::B4 => 3,
76        DeviceType::B5 => 4,
77        DeviceType::A6 => 5,
78        DeviceType::A7 => 6,
79        DeviceType::A8 => 7,
80    }
81}
82
83fn voltage_column(voltage: f64) -> usize {
84    if voltage <= 15.0 {
85        0
86    } else if voltage <= 30.0 {
87        1
88    } else if voltage <= 50.0 {
89        2
90    } else if voltage <= 100.0 {
91        3
92    } else if voltage <= 150.0 {
93        4
94    } else if voltage <= 170.0 {
95        5
96    } else if voltage <= 250.0 {
97        6
98    } else if voltage <= 300.0 {
99        7
100    } else {
101        8
102    }
103}
104
105/// Look up the IPC-2221C minimum conductor spacing for the given voltage and device type.
106///
107/// # Errors
108///
109/// Returns [`CalcError::OutOfRange`] if `voltage` is negative.
110pub fn spacing(input: &SpacingInput) -> Result<SpacingResult, CalcError> {
111    if input.voltage < 0.0 {
112        return Err(CalcError::OutOfRange {
113            name: "voltage",
114            value: input.voltage,
115            expected: ">= 0",
116        });
117    }
118
119    let row = device_index(input.device_type);
120
121    let spacing_mils = if input.voltage > 500.0 {
122        let (slope, intercept) = EXTRAP[row];
123        (input.voltage - 500.0) * slope + intercept
124    } else {
125        let col = voltage_column(input.voltage);
126        TABLE[row][col]
127    };
128
129    Ok(SpacingResult {
130        spacing_mils,
131        spacing_mm: spacing_mils * 0.0254,
132    })
133}
134
135#[cfg(test)]
136mod tests {
137    use approx::assert_relative_eq;
138
139    use super::*;
140
141    fn lookup(voltage: f64, device_type: DeviceType) -> SpacingResult {
142        spacing(&SpacingInput { voltage, device_type }).unwrap()
143    }
144
145    #[test]
146    fn b1_10v() {
147        let r = lookup(10.0, DeviceType::B1);
148        assert_relative_eq!(r.spacing_mils, 1.97, epsilon = 1e-6);
149    }
150
151    #[test]
152    fn b3_40v() {
153        let r = lookup(40.0, DeviceType::B3);
154        assert_relative_eq!(r.spacing_mils, 25.17, epsilon = 1e-6);
155    }
156
157    #[test]
158    fn b1_600v_extrapolation() {
159        let r = lookup(600.0, DeviceType::B1);
160        let expected = (600.0 - 500.0) * 0.098425 + 9.8425;
161        assert_relative_eq!(r.spacing_mils, expected, epsilon = 1e-6);
162        assert_relative_eq!(r.spacing_mils, 19.685, epsilon = 1e-3);
163    }
164
165    #[test]
166    fn boundary_0v() {
167        // 0 V → column 0
168        let r = lookup(0.0, DeviceType::B1);
169        assert_relative_eq!(r.spacing_mils, TABLE[0][0], epsilon = 1e-9);
170    }
171
172    #[test]
173    fn boundary_15v() {
174        // 15 V → column 0 (inclusive upper bound)
175        let r = lookup(15.0, DeviceType::B1);
176        assert_relative_eq!(r.spacing_mils, TABLE[0][0], epsilon = 1e-9);
177    }
178
179    #[test]
180    fn boundary_16v() {
181        // 16 V → column 1
182        let r = lookup(16.0, DeviceType::B1);
183        assert_relative_eq!(r.spacing_mils, TABLE[0][1], epsilon = 1e-9);
184    }
185
186    #[test]
187    fn boundary_30v() {
188        // 30 V → column 1 (inclusive upper bound)
189        let r = lookup(30.0, DeviceType::B1);
190        assert_relative_eq!(r.spacing_mils, TABLE[0][1], epsilon = 1e-9);
191    }
192
193    #[test]
194    fn boundary_500v() {
195        // 500 V → column 8 (last table column)
196        let r = lookup(500.0, DeviceType::B1);
197        assert_relative_eq!(r.spacing_mils, TABLE[0][8], epsilon = 1e-9);
198    }
199
200    #[test]
201    fn boundary_501v_extrapolation() {
202        // 501 V → linear extrapolation
203        let r = lookup(501.0, DeviceType::B1);
204        let expected = 1.0 * 0.098425 + 9.8425;
205        assert_relative_eq!(r.spacing_mils, expected, epsilon = 1e-6);
206    }
207
208    #[test]
209    fn mm_conversion() {
210        let r = lookup(10.0, DeviceType::B2);
211        assert_relative_eq!(r.spacing_mm, r.spacing_mils * 0.0254, epsilon = 1e-9);
212    }
213
214    #[test]
215    fn rejects_negative_voltage() {
216        let result = spacing(&SpacingInput {
217            voltage: -1.0,
218            device_type: DeviceType::B1,
219        });
220        assert!(matches!(result, Err(CalcError::OutOfRange { name: "voltage", .. })));
221    }
222}