main
Caleb Publish 0.2.0 cbe04a0 11h ago
//! What a rail's copper must carry (4.2 Current capacity). The chart, and the current through each
//! track found from the copper actually connected: pours included, sources at one end and each
//! part's own draw at the other, so a thin track to a pull-up is a branch and not the feed.
//!
//! The chart is IPC-2152's conservative chart, which is the old IPC-2221 internal-conductor chart,
//! as a published curve fit: I = 0.024 dT^0.44 A^0.725, A in square mils. Checked in this repo's
//! work against MIL-STD-275E Figure 4b within 5% at 1 A and above at 20 C; 3 A at 20 C in 35 um gives
//! 2.34 mm against the reference's 2.3 mm. IPC-2152 itself is paywalled and was not read. A board of 3 x 3 in or
//! more with such a plane under the trace may use the universal chart with modifiers; that chart
//! is not sourced here, so it gets this one, which is wider and so safe.

use crate::board::*;
use crate::conn::{touches, Piece};
use crate::geom::*;
use crate::rules::{RailSpec, Rules};

const MIL2_PER_MM2: f64 = 1550.0031;

/// The most of a rail's voltage its copper may lose between a source and a load: the designer's limit,
/// 2026-11-09 (0.25% of the rail). Currents as the rail spec rates them: an off-board contact by
/// what its supply can deliver, an internal rail by the most its devices can pull.
pub const DROP_LIMIT: f64 = 0.0025;
/// Copper's resistivity at 20 C, ohm metres (the International Annealed Copper Standard).
pub const RHO_CU: f64 = 1.7241e-8;
/// Hole wall copper: JLCPCB's capability table, "Average Hole Plating Thickness 18 um"
/// (jlcpcb.com/capabilities/pcb-capabilities, read 2026-11-09).
pub const VIA_PLATING_UM: f64 = 18.0;
/// A via's length for its resistance: a standard 1.6 mm board (the stackup isn't read here).
const VIA_LENGTH_MM: f64 = 1.6;

/// What one load loses in the copper from its source.
pub struct Drop {
    pub load: String,
    pub amps: f64,
    pub volts: f64,
    pub limit: f64,
}

pub fn conservative_mm(amps: f64, rise_c: f64, copper_um: f64) -> f64 {
    if amps <= 0.0 || rise_c <= 0.0 || copper_um <= 0.0 {
        return 0.0;
    }
    (amps / (0.024 * rise_c.powf(0.44))).powf(1.0 / 0.725) / MIL2_PER_MM2 / (copper_um / 1000.0)
}

/// The board's F.Cu thickness from its stackup, 35 um when it states none.
pub fn outer_copper_um(text: &str) -> f64 {
    let Some(st) = text.find("(stackup") else { return 35.0 };
    let rest = &text[st..];
    let Some(l) = rest.find("(layer \"F.Cu\"") else { return 35.0 };
    let rest = &rest[l..];
    let Some(t) = rest.find("(thickness ") else { return 35.0 };
    let rest = &rest[t + 11..];
    rest.split(|c: char| c == ')' || c.is_whitespace()).next().and_then(|x| x.parse::<f64>().ok()).filter(|x| *x > 0.0).map(|x| x * 1000.0).unwrap_or(35.0)
}

/// One track's rating: index into `board.tracks`, what flows through it, and the width that needs.
pub struct Rated {
    pub track: usize,
    pub amps: f64,
    pub need: i64,
    /// The width comes from the voltage-drop limit, not the chart.
    pub by_drop: bool,
}

/// Each track of one rail that current has to pass through. A track in a loop has another way
/// round, so nothing here says it alone carries the load: it is left out rather than guessed at.
pub fn rate_rail(board: &Board, rules: &Rules, net: NetId, rail: &RailSpec) -> Vec<Rated> {
    analyse(board, rules, net, rail).0
}

/// What each load of a rail loses in its copper from the source, where the spec gives the rail's
/// voltage. Tracks and vias count; a pour on the way counts as no loss (it is far wider).
pub fn rail_drops(board: &Board, rules: &Rules, net: NetId, rail: &RailSpec) -> Vec<Drop> {
    analyse(board, rules, net, rail).1
}

fn analyse(board: &Board, rules: &Rules, net: NetId, rail: &RailSpec) -> (Vec<Rated>, Vec<Drop>) {
    let mut pieces: Vec<Piece> = Vec::new();
    let mut pad_of: Vec<Option<usize>> = Vec::new();
    let mut track_of: Vec<Option<usize>> = Vec::new();
    for (i, p) in board.pads.iter().enumerate().filter(|(_, p)| p.net == net && !p.layers.is_empty()) {
        pieces.push(Piece::Pad(i));
        pad_of.push(Some(i));
        track_of.push(None);
    }
    for (i, t) in board.tracks.iter().enumerate().filter(|(_, t)| t.net == net) {
        pieces.push(Piece::Seg { layer: t.layer, a: t.a.into(), b: t.b.into(), r: t.width as f64 / 2.0 });
        pad_of.push(None);
        track_of.push(Some(i));
    }
    for v in board.vias.iter().filter(|v| v.net == net) {
        pieces.push(Piece::Via { at: v.at.into(), r: v.size as f64 / 2.0 });
        pad_of.push(None);
        track_of.push(None);
    }
    for z in board.zones.iter().filter(|z| z.net == net && !z.keepout_tracks) {
        if z.fills.is_empty() {
            for &l in &z.layers {
                pieces.push(Piece::Fill { layer: l, pts: z.outline.clone() });
                pad_of.push(None);
                track_of.push(None);
            }
        } else {
            for (l, f) in &z.fills {
                pieces.push(Piece::Fill { layer: *l, pts: f.clone() });
                pad_of.push(None);
                track_of.push(None);
            }
        }
    }
    let n = pieces.len();
    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
    for i in 0..n {
        for j in i + 1..n {
            if touches(&pieces[i], &pieces[j], board) {
                adj[i].push(j);
                adj[j].push(i);
            }
        }
    }
    let reference = |k: usize| pad_of[k].map(|p| board.footprints[board.pads[p].fp].reference.as_str());
    let sources: Vec<usize> = (0..n).filter(|&k| reference(k).map(|r| rail.sources.iter().any(|s| s == r)).unwrap_or(false)).collect();
    if sources.is_empty() {
        return (Vec::new(), Vec::new());
    }
    let reach = |skip: Option<usize>| -> Vec<bool> {
        let mut seen = vec![false; n];
        let mut stack: Vec<usize> = sources.clone();
        for &s in &sources {
            seen[s] = true;
        }
        while let Some(k) = stack.pop() {
            for &m in &adj[k] {
                if !seen[m] && Some(m) != skip {
                    seen[m] = true;
                    stack.push(m);
                }
            }
        }
        seen
    };
    let all = reach(None);
    let mut out = Vec::new();
    for k in 0..n {
        let Some(t) = track_of[k] else { continue };
        if !all[k] {
            continue;
        }
        let seen = reach(Some(k));
        // A part is cut off when none of its pads on this net can still be reached.
        let mut flowing = 0.0;
        for (name, amps) in &rail.loads {
            let mine: Vec<usize> = (0..n).filter(|&m| reference(m) == Some(name.as_str())).collect();
            if !mine.is_empty() && mine.iter().all(|&m| !seen[m]) && mine.iter().any(|&m| all[m]) {
                flowing += *amps;
            }
        }
        let amps = flowing.min(rail.amps);
        if amps > 0.0 {
            let main = amps >= rail.amps - 1e-9;
            out.push(Rated { track: t, amps, need: rules.rail_width(amps, main), by_drop: false });
        }
    }
    // The drop to each load, along the copper that joins it to a source; where it is over the
    // limit, every track on the way widens by the same factor.
    let mut drops = Vec::new();
    if let Some(volts) = rail.volts.filter(|v| *v > 0.0) {
        let limit = volts * DROP_LIMIT;
        let mut parent: Vec<Option<usize>> = vec![None; n];
        let mut depth: Vec<usize> = vec![usize::MAX; n];
        let mut queue: std::collections::VecDeque<usize> = sources.iter().copied().collect();
        for &s in &sources {
            depth[s] = 0;
        }
        while let Some(k) = queue.pop_front() {
            for &m in &adj[k] {
                if depth[m] == usize::MAX {
                    depth[m] = depth[k] + 1;
                    parent[m] = Some(k);
                    queue.push_back(m);
                }
            }
        }
        let t_cu = rules.copper_um * 1e-6;
        let (plate, drill) = (VIA_PLATING_UM * 1e-6, to_mm(rules.via_for(net).1) * 1e-3);
        let r_via = RHO_CU * VIA_LENGTH_MM * 1e-3 / (std::f64::consts::PI * (drill + plate) * plate);
        let mut loads: Vec<(&String, &f64)> = rail.loads.iter().filter(|(_, a)| **a > 0.0).collect();
        // Heaviest first, each seeing the widths the ones before it asked for: a pull-up at the end
        // of a trunk loses what the trunk loses, and is put right by the trunk, not by widening
        // its own thin branch.
        loads.sort_by(|x, y| y.1.total_cmp(x.1).then(x.0.cmp(y.0)));
        let mut eff: std::collections::HashMap<usize, i64> = Default::default();
        for (name, amps) in loads {
            let Some(end) = (0..n).filter(|&m| depth[m] != usize::MAX && reference(m) == Some(name.as_str())).min_by_key(|&m| depth[m]) else { continue };
            let (mut v_tracks, mut v_vias, mut v_drawn) = (0.0, 0.0, 0.0);
            let mut on: Vec<(usize, f64)> = Vec::new();
            let mut k = end;
            while let Some(p) = parent[k] {
                match &pieces[k] {
                    Piece::Seg { a, b, r, .. } => {
                        if let Some(t) = track_of[k] {
                            let i = out.iter().find(|x| x.track == t).map(|x| x.amps).unwrap_or(*amps).max(*amps).min(rail.amps);
                            let (len, w) = (hypot(sub(*a, *b)) * 1e-9, eff.get(&t).map(|w| *w as f64).unwrap_or(2.0 * r) * 1e-9);
                            if w > 0.0 {
                                v_drawn += i * RHO_CU * len / (2.0 * r * 1e-9 * t_cu);
                                v_tracks += i * RHO_CU * len / (w * t_cu);
                                on.push((t, i));
                            }
                        }
                    }
                    // Vias of the net side by side share the current.
                    Piece::Via { at, .. } => {
                        let group = board.vias.iter().filter(|v| v.net == net && hypot(sub(V::from(v.at), *at)) <= 1.5 * NM).count().max(1);
                        v_vias += amps * r_via / group as f64;
                    }
                    _ => {}
                }
                k = p;
            }
            let total = v_tracks + v_vias;
            drops.push(Drop { load: name.clone(), amps: *amps, volts: v_drawn + v_vias, limit });
            if total > limit && v_tracks > 0.0 && limit > v_vias {
                // Only the stretches that matter widen: one that loses under a twentieth of the
                // path's drop (a thin branch carrying milliamps at the end of a trunk) stays.
                let lost = |t: usize, i: f64| -> f64 {
                    let tr = &board.tracks[t];
                    let w = eff.get(&t).copied().unwrap_or(tr.width) as f64 * 1e-9;
                    i * RHO_CU * hypot(sub(tr.a.into(), tr.b.into())) * 1e-9 / (w * t_cu)
                };
                let (big, small): (Vec<(usize, f64)>, Vec<(usize, f64)>) = on.iter().copied().partition(|&(t, i)| lost(t, i) >= v_tracks / 20.0);
                let (v_big, v_small): (f64, f64) = (big.iter().map(|&(t, i)| lost(t, i)).sum(), small.iter().map(|&(t, i)| lost(t, i)).sum());
                let room = limit - v_vias - v_small;
                let (on, f) = if room > 0.0 && v_big > 0.0 { (big, v_big / room) } else { (on, v_tracks / (limit - v_vias)) };
                for (t, i) in on {
                    let need = mm((to_mm(eff.get(&t).copied().unwrap_or(board.tracks[t].width)) * f * 100.0).ceil() / 100.0);
                    eff.insert(t, need);
                    match out.iter_mut().find(|x| x.track == t) {
                        Some(x) => {
                            if need > x.need {
                                x.need = need;
                                x.by_drop = true;
                            }
                        }
                        None => out.push(Rated { track: t, amps: i, need, by_drop: true }),
                    }
                }
            }
        }
    }
    (out, drops)
}

/// What pad `pad` itself draws from its rail, if its net has one and its part is listed.
pub fn pad_load(board: &Board, rules: &Rules, pad: usize) -> Option<f64> {
    let p = &board.pads[pad];
    let rail = rules.rail(board.net_name(p.net))?;
    let name = board.footprints[p.fp].reference.as_str();
    if rail.sources.iter().any(|s| s == name) {
        return Some(rail.amps);
    }
    Some(rail.loads.get(name).copied().unwrap_or(0.0).min(rail.amps))
}

/// What flows into each pad's branch of a rail: its own draw plus everything the rail feeds through
/// it. Parts are joined in the shortest tree from the sources (each part to the nearest one already
/// joined, as the router lays a rail's taps), and a part's branch carries its own load and the loads
/// of the parts hanging off it. A decoupling capacitor draws nothing itself, but the tap that reaches
/// it first carries its IC too: sized by the capacitor's own 0 A, benchmark C's +5V ran 33.6 mm at
/// 0.20 mm to U10, which draws 1.24 A (2026-11-06 to 11-09). Never more than the rail's current.
/// Empty when no source part of the rail is on the board.
pub fn branch_amps(board: &Board, net: NetId, rail: &RailSpec) -> std::collections::HashMap<usize, f64> {
    let mut parts: Vec<(usize, Vec<usize>)> = Vec::new();
    for (i, p) in board.pads.iter().enumerate().filter(|(_, p)| p.net == net && !p.layers.is_empty()) {
        match parts.iter_mut().find(|(fp, _)| *fp == p.fp) {
            Some((_, pads)) => pads.push(i),
            None => parts.push((p.fp, vec![i])),
        }
    }
    let n = parts.len();
    let name = |k: usize| board.footprints[parts[k].0].reference.as_str();
    let is_source = |k: usize| rail.sources.iter().any(|s| s == name(k));
    let mut out = std::collections::HashMap::new();
    if !(0..n).any(is_source) {
        return out;
    }
    let apart = |a: usize, b: usize| -> f64 {
        let mut best = f64::INFINITY;
        for &x in &parts[a].1 {
            for &y in &parts[b].1 {
                best = best.min(hypot(sub(board.pads[x].center.into(), board.pads[y].center.into())));
            }
        }
        best
    };
    let mut parent: Vec<Option<usize>> = vec![None; n];
    let mut joined: Vec<bool> = (0..n).map(is_source).collect();
    let mut order: Vec<usize> = Vec::new();
    loop {
        let mut best: Option<(f64, usize, usize)> = None;
        for k in (0..n).filter(|&k| !joined[k]) {
            for j in (0..n).filter(|&j| joined[j]) {
                let d = apart(k, j);
                if best.map(|b| d < b.0).unwrap_or(true) {
                    best = Some((d, k, j));
                }
            }
        }
        let Some((_, k, j)) = best else { break };
        joined[k] = true;
        parent[k] = Some(j);
        order.push(k);
    }
    let mut amps: Vec<f64> = (0..n).map(|k| if is_source(k) { 0.0 } else { rail.loads.get(name(k)).copied().unwrap_or(0.0) }).collect();
    for &k in order.iter().rev() {
        if let Some(p) = parent[k] {
            amps[p] += amps[k];
        }
    }
    for k in 0..n {
        let a = if is_source(k) { rail.amps } else { amps[k].min(rail.amps) };
        for &p in &parts[k].1 {
            out.insert(p, a);
        }
    }
    out
}