app
AI Flow: Routing
Public Made by Adomby adom
The routing stage of AI Flow: routes a placed KiCad board connection by connection and reports what it made, what it could not and why. A guide, never a gate.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
//! 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
}