Skip to main content

pcb_toolkit/
current.rs

1//! Conductor current capacity calculator.
2//!
3//! Supports:
4//! - IPC-2221A (legacy empirical formula)
5//! - IPC-2152 (with modifier charts — table data needed)
6//!
7//! Also computes: DC resistance, voltage drop, power dissipation,
8//! skin depth, current density.
9//!
10//! # TODO
11//! - IPC-2152 table data for more accurate results
12//! - Solve-for-width mode (given target current, find required width)
13//! - Temperature-adjusted resistivity
14//! - AC resistance model (skin effect + proximity)
15
16use serde::{Deserialize, Serialize};
17
18use crate::CalcError;
19use crate::copper::EtchFactor;
20
21/// IPC-2221A constant for external (surface) layers.
22const K_EXTERNAL: f64 = 0.048;
23
24/// IPC-2221A constant for internal layers.
25const K_INTERNAL: f64 = 0.024;
26
27/// Copper resistivity in ohm-mil units (6.787e-4 Ω·mil).
28///
29/// Derived from 1.724e-6 Ω·cm × (1 cm / 393.7 mil) = 6.787e-7 Ω·mil …
30/// but for R = ρL/A with L in mils and A in mil², we need the factor as shown.
31/// Saturn uses R = 6.787e-4 × L / A for L,A in mils/mil².
32const RESISTIVITY_OHM_MIL: f64 = 6.787e-4;
33
34/// Copper resistivity in Ω·m (used for skin depth calculation).
35const COPPER_RESISTIVITY_OHM_M: f64 = 1.724e-8;
36
37/// Permeability of free space µ₀ (H/m).
38const MU_0: f64 = 1.256_637_061_435_9e-6;
39
40/// Inputs for conductor current capacity calculation.
41pub struct CurrentInput {
42    /// Trace width (mils).
43    pub width: f64,
44    /// Copper thickness (mils).
45    pub thickness: f64,
46    /// Trace length (mils). Used for resistance and voltage drop.
47    pub length: f64,
48    /// Allowed temperature rise above ambient (°C).
49    pub temperature_rise: f64,
50    /// Ambient temperature (°C). Not currently used in IPC-2221A, reserved.
51    pub ambient_temp: f64,
52    /// Frequency (Hz). Used for skin depth calculation. 0 = DC only.
53    pub frequency: f64,
54    /// Etch factor affecting cross-section geometry.
55    pub etch_factor: EtchFactor,
56    /// Whether the trace is on an internal layer (halves the current capacity).
57    pub is_internal: bool,
58}
59
60/// Result of a conductor current capacity calculation.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct CurrentResult {
63    /// Maximum current capacity (A) per IPC-2221A.
64    pub current_capacity: f64,
65    /// Conductor cross-sectional area (sq mils).
66    pub cross_section: f64,
67    /// DC resistance (Ohms).
68    pub resistance_dc: f64,
69    /// Voltage drop at maximum current (V).
70    pub voltage_drop: f64,
71    /// Power dissipation at maximum current (W).
72    pub power_dissipation: f64,
73    /// Current density at maximum current (A/mil²).
74    pub current_density: f64,
75    /// Skin depth at the given frequency (mils). 0 if frequency is 0.
76    pub skin_depth_mils: f64,
77}
78
79/// Calculate conductor current capacity and related electrical properties.
80///
81/// Uses the IPC-2221A empirical formula:
82///   I = k × ΔT^0.44 × A^0.725
83/// where k = 0.048 (external) or 0.024 (internal), ΔT in °C, A in mil².
84pub fn calculate(input: &CurrentInput) -> Result<CurrentResult, CalcError> {
85    let CurrentInput {
86        width,
87        thickness,
88        length,
89        temperature_rise,
90        etch_factor,
91        is_internal,
92        frequency,
93        ..
94    } = *input;
95
96    if width <= 0.0 {
97        return Err(CalcError::NegativeDimension { name: "width", value: width });
98    }
99    if thickness <= 0.0 {
100        return Err(CalcError::NegativeDimension { name: "thickness", value: thickness });
101    }
102    if length <= 0.0 {
103        return Err(CalcError::NegativeDimension { name: "length", value: length });
104    }
105    if temperature_rise <= 0.0 {
106        return Err(CalcError::OutOfRange {
107            name: "temperature_rise",
108            value: temperature_rise,
109            expected: "> 0",
110        });
111    }
112    if frequency < 0.0 {
113        return Err(CalcError::OutOfRange {
114            name: "frequency",
115            value: frequency,
116            expected: ">= 0",
117        });
118    }
119
120    // Cross-sectional area (sq mils), accounting for etch factor
121    let cross_section = etch_factor.cross_section_sq_mils(width, thickness);
122
123    // IPC-2221A current capacity
124    let k = if is_internal { K_INTERNAL } else { K_EXTERNAL };
125    let current_capacity = k * temperature_rise.powf(0.44) * cross_section.powf(0.725);
126
127    // DC resistance: R = ρ × L / A  (in ohm-mil units)
128    let resistance_dc = RESISTIVITY_OHM_MIL * length / cross_section;
129
130    // Voltage drop and power at max current
131    let voltage_drop = current_capacity * resistance_dc;
132    let power_dissipation = current_capacity * voltage_drop;
133
134    // Current density (A/mil²)
135    let current_density = current_capacity / cross_section;
136
137    // Skin depth: δ = √(ρ / (π × f × µ₀))
138    let skin_depth_mils = if frequency > 0.0 {
139        let delta_m = (COPPER_RESISTIVITY_OHM_M
140            / (std::f64::consts::PI * frequency * MU_0))
141            .sqrt();
142        // Convert m to mils: 1 m = 1 / 2.54e-5 mils = 39370.079 mils
143        delta_m / crate::constants::MIL_TO_M
144    } else {
145        0.0
146    };
147
148    Ok(CurrentResult {
149        current_capacity,
150        cross_section,
151        resistance_dc,
152        voltage_drop,
153        power_dissipation,
154        current_density,
155        skin_depth_mils,
156    })
157}
158
159/// Inputs for IPC-2152 conductor current capacity calculation.
160pub struct Ipc2152Input {
161    /// Trace width (mils).
162    pub width: f64,
163    /// Copper thickness (mils).
164    pub thickness: f64,
165    /// Trace length (mils).
166    pub length: f64,
167    /// Allowed temperature rise above ambient (°C).
168    pub temperature_rise: f64,
169    /// Ambient temperature (°C).
170    pub ambient_temp: f64,
171    /// Frequency (Hz). 0 = DC only.
172    pub frequency: f64,
173    /// Etch factor.
174    pub etch_factor: EtchFactor,
175    /// Whether the trace is on an internal layer.
176    pub is_internal: bool,
177    /// Board thickness (mils).
178    pub board_thickness_mils: f64,
179    /// Whether the board has an adjacent copper plane.
180    pub has_copper_plane: bool,
181    /// Material thermal conductivity modifier (default 1.0).
182    pub material_modifier: f64,
183    /// User-supplied modifier (default 1.0).
184    pub user_modifier: f64,
185}
186
187/// Result of an IPC-2152 calculation.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct Ipc2152Result {
190    /// Final current capacity with all modifiers (A).
191    pub current_capacity: f64,
192    /// Conductor cross-sectional area (sq mils).
193    pub cross_section: f64,
194    /// DC resistance (Ohms).
195    pub resistance_dc: f64,
196    /// Voltage drop at calculated current (V).
197    pub voltage_drop: f64,
198    /// Power dissipation (W).
199    pub power_dissipation: f64,
200    /// Current density (A/mil²).
201    pub current_density: f64,
202    /// Skin depth (mils). 0 if frequency is 0.
203    pub skin_depth_mils: f64,
204    /// Area modifier applied.
205    pub m_area: f64,
206    /// Temperature rise modifier applied.
207    pub m_temp: f64,
208    /// Board thickness modifier applied.
209    pub m_board: f64,
210}
211
212fn m_temp_lookup(dt: f64) -> f64 {
213    if dt <= 10.0 {
214        0.40
215    } else if dt <= 20.0 {
216        0.48
217    } else if dt <= 30.0 {
218        0.58
219    } else if dt <= 40.0 {
220        0.67
221    } else if dt <= 50.0 {
222        0.75
223    } else if dt <= 60.0 {
224        0.85
225    } else if dt <= 70.0 {
226        0.95
227    } else if dt <= 80.0 {
228        1.00
229    } else if dt <= 90.0 {
230        1.10
231    } else if dt <= 100.0 {
232        1.20
233    } else {
234        1.30
235    }
236}
237
238fn m_board_lookup(thickness_mils: f64, has_plane: bool) -> f64 {
239    if has_plane {
240        if thickness_mils <= 10.0 {
241            1.63
242        } else if thickness_mils <= 20.0 {
243            1.59
244        } else if thickness_mils <= 30.0 {
245            1.56
246        } else if thickness_mils <= 40.0 {
247            1.52
248        } else if thickness_mils <= 50.0 {
249            1.49
250        } else if thickness_mils <= 60.0 {
251            1.46
252        } else if thickness_mils <= 70.0 {
253            1.43
254        } else if thickness_mils <= 80.0 {
255            1.41
256        } else if thickness_mils <= 90.0 {
257            1.37
258        } else if thickness_mils <= 100.0 {
259            1.34
260        } else {
261            1.24
262        }
263    } else {
264        if thickness_mils <= 10.0 {
265            1.59
266        } else if thickness_mils <= 20.0 {
267            1.55
268        } else if thickness_mils <= 30.0 {
269            1.52
270        } else if thickness_mils <= 40.0 {
271            1.48
272        } else if thickness_mils <= 50.0 {
273            1.45
274        } else if thickness_mils <= 60.0 {
275            1.42
276        } else if thickness_mils <= 70.0 {
277            1.39
278        } else if thickness_mils <= 80.0 {
279            1.37
280        } else if thickness_mils <= 90.0 {
281            1.33
282        } else if thickness_mils <= 100.0 {
283            1.30
284        } else {
285            1.20
286        }
287    }
288}
289
290fn m_area_lookup(area: f64) -> f64 {
291    if area <= 20.0 {
292        3.0364 * area.powf(-0.145)
293    } else if area <= 60.0 {
294        2.9143 * area.powf(-0.129)
295    } else if area <= 100.0 {
296        2.7877 * area.powf(-0.114)
297    } else {
298        2.801 * area.powf(-0.111)
299    }
300}
301
302fn rho_base_lookup(temp: f64) -> f64 {
303    if temp <= -40.0 {
304        0.000519
305    } else if temp <= -20.0 {
306        0.000572
307    } else if temp <= 0.0 {
308        0.000625
309    } else if temp <= 20.0 {
310        0.0006787
311    } else if temp <= 40.0 {
312        0.000732
313    } else if temp <= 60.0 {
314        0.000785
315    } else {
316        0.000839
317    }
318}
319
320/// Calculate conductor current capacity per IPC-2152 with modifier tables.
321///
322/// Applies area, temperature rise, and board thickness modifiers to the
323/// IPC-2221A base formula, then scales by material and user modifiers.
324pub fn calculate_ipc2152(input: &Ipc2152Input) -> Result<Ipc2152Result, CalcError> {
325    let Ipc2152Input {
326        width,
327        thickness,
328        length,
329        temperature_rise,
330        ambient_temp,
331        frequency,
332        ref etch_factor,
333        is_internal,
334        board_thickness_mils,
335        has_copper_plane,
336        material_modifier,
337        user_modifier,
338    } = *input;
339
340    if width <= 0.0 {
341        return Err(CalcError::NegativeDimension { name: "width", value: width });
342    }
343    if thickness <= 0.0 {
344        return Err(CalcError::NegativeDimension { name: "thickness", value: thickness });
345    }
346    if length <= 0.0 {
347        return Err(CalcError::NegativeDimension { name: "length", value: length });
348    }
349    if temperature_rise <= 0.0 {
350        return Err(CalcError::OutOfRange {
351            name: "temperature_rise",
352            value: temperature_rise,
353            expected: "> 0",
354        });
355    }
356    if frequency < 0.0 {
357        return Err(CalcError::OutOfRange {
358            name: "frequency",
359            value: frequency,
360            expected: ">= 0",
361        });
362    }
363    if board_thickness_mils <= 0.0 {
364        return Err(CalcError::NegativeDimension {
365            name: "board_thickness_mils",
366            value: board_thickness_mils,
367        });
368    }
369
370    // Cross-sectional area (sq mils), accounting for etch factor
371    let cross_section = etch_factor.cross_section_sq_mils(width, thickness);
372
373    // IPC-2221A base current
374    let k = if is_internal { K_INTERNAL } else { K_EXTERNAL };
375    let i_base = k * temperature_rise.powf(0.44) * cross_section.powf(0.725);
376
377    // IPC-2152 modifiers
378    let m_area = m_area_lookup(cross_section);
379    let m_temp = m_temp_lookup(temperature_rise);
380    let m_board = m_board_lookup(board_thickness_mils, has_copper_plane);
381
382    let current_capacity = i_base * m_area * m_temp * m_board * material_modifier * user_modifier;
383
384    // Temperature-adjusted DC resistance
385    let rho_base = rho_base_lookup(ambient_temp);
386    let rho_adj = (1.0 + 0.00393 * (ambient_temp - 20.0)) * rho_base;
387    let resistance_dc = rho_adj * length / cross_section;
388
389    let voltage_drop = current_capacity * resistance_dc;
390    let power_dissipation = current_capacity * voltage_drop;
391    let current_density = current_capacity / cross_section;
392
393    let skin_depth_mils = if frequency > 0.0 {
394        let delta_m = (COPPER_RESISTIVITY_OHM_M
395            / (std::f64::consts::PI * frequency * MU_0))
396            .sqrt();
397        delta_m / crate::constants::MIL_TO_M
398    } else {
399        0.0
400    };
401
402    Ok(Ipc2152Result {
403        current_capacity,
404        cross_section,
405        resistance_dc,
406        voltage_drop,
407        power_dissipation,
408        current_density,
409        skin_depth_mils,
410        m_area,
411        m_temp,
412        m_board,
413    })
414}
415
416#[cfg(test)]
417mod tests {
418    use approx::assert_relative_eq;
419
420    use super::*;
421
422    // Saturn test vector: 1MHz skin depth = 2.599 mil
423    #[test]
424    fn skin_depth_1mhz() {
425        let result = calculate(&CurrentInput {
426            width: 10.0,
427            thickness: 1.4,
428            length: 1000.0,
429            temperature_rise: 10.0,
430            ambient_temp: 25.0,
431            frequency: 1_000_000.0,
432            etch_factor: EtchFactor::None,
433            is_internal: false,
434        })
435        .unwrap();
436
437        assert_relative_eq!(result.skin_depth_mils, 2.599, max_relative = 0.005);
438    }
439
440    // IPC-2221A formula verification [docs/notes/17-test-vectors.md]:
441    // External, dT=10°C, A=100 sq.mils → I = 0.048 × 10^0.44 × 100^0.725 = 3.73 A
442    //
443    // Note: Saturn PDF page 6/46 vectors use IPC-2152 mode (5.076A / 3.723A),
444    // which requires table data not yet implemented.
445    #[test]
446    fn ipc2221a_external_a100() {
447        // W=50, T=2.0, no etch → A = 100 sq mils
448        let result = calculate(&CurrentInput {
449            width: 50.0,
450            thickness: 2.0,
451            length: 1000.0,
452            temperature_rise: 10.0,
453            ambient_temp: 25.0,
454            frequency: 0.0,
455            etch_factor: EtchFactor::None,
456            is_internal: false,
457        })
458        .unwrap();
459
460        assert_relative_eq!(result.cross_section, 100.0, max_relative = 1e-10);
461        assert_relative_eq!(result.current_capacity, 3.73, max_relative = 0.005);
462    }
463
464    // IPC-2221A internal: same area → I = 0.024 × 10^0.44 × 100^0.725 = 1.86 A
465    #[test]
466    fn ipc2221a_internal_a100() {
467        let result = calculate(&CurrentInput {
468            width: 50.0,
469            thickness: 2.0,
470            length: 1000.0,
471            temperature_rise: 10.0,
472            ambient_temp: 25.0,
473            frequency: 0.0,
474            etch_factor: EtchFactor::None,
475            is_internal: true,
476        })
477        .unwrap();
478
479        assert_relative_eq!(result.current_capacity, 1.86, max_relative = 0.005);
480    }
481
482    // Internal layers use k=0.024 → half the current of external
483    #[test]
484    fn internal_lower_than_external() {
485        let ext = calculate(&CurrentInput {
486            width: 10.0,
487            thickness: 1.4,
488            length: 1000.0,
489            temperature_rise: 10.0,
490            ambient_temp: 25.0,
491            frequency: 0.0,
492            etch_factor: EtchFactor::None,
493            is_internal: false,
494        })
495        .unwrap();
496
497        let int = calculate(&CurrentInput {
498            width: 10.0,
499            thickness: 1.4,
500            length: 1000.0,
501            temperature_rise: 10.0,
502            ambient_temp: 25.0,
503            frequency: 0.0,
504            etch_factor: EtchFactor::None,
505            is_internal: true,
506        })
507        .unwrap();
508
509        // k_int / k_ext = 0.024 / 0.048 = 0.5
510        assert_relative_eq!(int.current_capacity / ext.current_capacity, 0.5, max_relative = 1e-10);
511    }
512
513    #[test]
514    fn resistance_and_power() {
515        let result = calculate(&CurrentInput {
516            width: 10.0,
517            thickness: 1.4,
518            length: 1000.0,
519            temperature_rise: 10.0,
520            ambient_temp: 25.0,
521            frequency: 0.0,
522            etch_factor: EtchFactor::None,
523            is_internal: false,
524        })
525        .unwrap();
526
527        // R = 6.787e-4 × 1000 / 14 ≈ 0.04848 Ω
528        let expected_r = RESISTIVITY_OHM_MIL * 1000.0 / 14.0;
529        assert_relative_eq!(result.resistance_dc, expected_r, max_relative = 1e-10);
530
531        // V = I × R
532        assert_relative_eq!(
533            result.voltage_drop,
534            result.current_capacity * result.resistance_dc,
535            max_relative = 1e-10
536        );
537
538        // P = I × V
539        assert_relative_eq!(
540            result.power_dissipation,
541            result.current_capacity * result.voltage_drop,
542            max_relative = 1e-10
543        );
544    }
545
546    #[test]
547    fn rejects_negative_width() {
548        let result = calculate(&CurrentInput {
549            width: -1.0,
550            thickness: 1.4,
551            length: 1000.0,
552            temperature_rise: 10.0,
553            ambient_temp: 25.0,
554            frequency: 0.0,
555            etch_factor: EtchFactor::None,
556            is_internal: false,
557        });
558        assert!(result.is_err());
559    }
560
561    #[test]
562    fn rejects_zero_temperature_rise() {
563        let result = calculate(&CurrentInput {
564            width: 10.0,
565            thickness: 1.4,
566            length: 1000.0,
567            temperature_rise: 0.0,
568            ambient_temp: 25.0,
569            frequency: 0.0,
570            etch_factor: EtchFactor::None,
571            is_internal: false,
572        });
573        assert!(result.is_err());
574    }
575
576    #[test]
577    fn m_temp_boundary_values() {
578        assert_eq!(m_temp_lookup(10.0), 0.40);
579        assert_eq!(m_temp_lookup(10.1), 0.48);
580        assert_eq!(m_temp_lookup(80.0), 1.00);
581        assert_eq!(m_temp_lookup(100.0), 1.20);
582        assert_eq!(m_temp_lookup(101.0), 1.30);
583    }
584
585    #[test]
586    fn m_board_no_plane() {
587        assert_eq!(m_board_lookup(10.0, false), 1.59);
588        assert_eq!(m_board_lookup(50.0, false), 1.45);
589        assert_eq!(m_board_lookup(101.0, false), 1.20);
590    }
591
592    #[test]
593    fn m_board_with_plane() {
594        assert_eq!(m_board_lookup(10.0, true), 1.63);
595        assert_eq!(m_board_lookup(50.0, true), 1.49);
596        assert_eq!(m_board_lookup(101.0, true), 1.24);
597    }
598
599    #[test]
600    fn m_area_segments() {
601        let a20 = m_area_lookup(20.0);
602        let a60 = m_area_lookup(60.0);
603        let a100 = m_area_lookup(100.0);
604        assert!(a20 > a60);
605        assert!(a60 > a100);
606        assert_relative_eq!(a100, 2.7877 * 100.0_f64.powf(-0.114), max_relative = 1e-6);
607    }
608
609    #[test]
610    fn rho_base_lookup_values() {
611        assert_eq!(rho_base_lookup(-50.0), 0.000519);
612        assert_eq!(rho_base_lookup(20.0), 0.0006787);
613        assert_eq!(rho_base_lookup(90.0), 0.000839);
614    }
615
616    #[test]
617    fn ipc2152_modifiers_applied() {
618        let result = calculate_ipc2152(&Ipc2152Input {
619            width: 50.0,
620            thickness: 2.0,
621            length: 1000.0,
622            temperature_rise: 10.0,
623            ambient_temp: 25.0,
624            frequency: 0.0,
625            etch_factor: EtchFactor::None,
626            is_internal: false,
627            board_thickness_mils: 62.0,
628            has_copper_plane: false,
629            material_modifier: 1.0,
630            user_modifier: 1.0,
631        })
632        .unwrap();
633
634        assert_relative_eq!(result.cross_section, 100.0, max_relative = 1e-10);
635        let i_base = K_EXTERNAL * 10.0_f64.powf(0.44) * 100.0_f64.powf(0.725);
636        assert!(result.current_capacity != i_base, "IPC-2152 should differ from IPC-2221A base");
637        assert!(result.m_area > 0.0);
638        assert_relative_eq!(result.m_temp, 0.40, max_relative = 1e-10);
639        assert_relative_eq!(result.m_board, 1.39, max_relative = 1e-10);
640    }
641
642    #[test]
643    fn ipc2152_keeps_existing_tests_unaffected() {
644        let result = calculate(&CurrentInput {
645            width: 50.0,
646            thickness: 2.0,
647            length: 1000.0,
648            temperature_rise: 10.0,
649            ambient_temp: 25.0,
650            frequency: 0.0,
651            etch_factor: EtchFactor::None,
652            is_internal: false,
653        })
654        .unwrap();
655        assert_relative_eq!(result.current_capacity, 3.73, max_relative = 0.005);
656    }
657}