app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
Colby Knox
AI Flow 0.2.62: fixes #190 #191 #188 #189 #186 #187 and the #177 remote-model remainder
01b74f9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756
//! Optional centerline resistor screen for explicit pin currents on unpoured nets.
//! Filled-copper Fields remains mandatory. This is not a pad/contact thermal model.
//!
//! Layers are numbered through the stack, 0 = F.Cu to n-1 = B.Cu (issue #107: multilayer boards).
//! A net whose copper is only on the outer layers needs nothing beyond the declared board thickness;
//! a net with tracks on an inner layer needs the board's own physical stackup to place each barrel tap.
use aiflow_board::{Board, Pt};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
type Key = (usize, i64, i64);
fn key(l: usize, p: Pt) -> Key {
(l, (p.0 * 1e6).round() as i64, (p.1 * 1e6).round() as i64)
}
fn pt(k: Key) -> Pt {
(k.1 as f64 / 1e6, k.2 as f64 / 1e6)
}
fn distance(a: Pt, b: Pt) -> f64 {
(a.0 - b.0).hypot(a.1 - b.1)
}
fn root(mut k: Key, parents: &BTreeMap<Key, Key>) -> Key {
while let Some(p) = parents.get(&k) {
k = *p;
}
k
}
fn unite(a: Key, b: Key, parents: &mut BTreeMap<Key, Key>) {
let (a, b) = (root(a, parents), root(b, parents));
if a != b {
parents.insert(a.max(b), a.min(b));
}
}
#[derive(Clone)]
struct Edge {
a: Key,
b: Key,
r: f64,
width: Option<f64>,
meta: Value,
}
fn positive(v: &Value, name: &str) -> Result<f64, String> {
v.as_f64()
.filter(|x| x.is_finite() && *x > 0.)
.ok_or_else(|| format!("{name} must be explicit, finite and positive"))
}
/// Dense conductance solve with an independent KCL residual. Refuse disconnected graphs.
fn solve(edges: &[Edge], injections: &BTreeMap<Key, f64>) -> Result<Vec<f64>, String> {
let nodes: BTreeSet<_> = edges.iter().flat_map(|e| [e.a, e.b]).collect();
if nodes.len() < 2 || nodes.len() > 1024 {
return Err("branch graph requires 2..1024 nodes".into());
}
let ids: BTreeMap<_, _> = nodes.into_iter().enumerate().map(|(i, k)| (k, i)).collect();
let n = ids.len();
let mut m = vec![vec![0.; n]; n];
let mut rhs = vec![0.; n];
for e in edges {
if !e.r.is_finite() || e.r <= 0. {
return Err("invalid conductor resistance".into());
}
if e.a == e.b {
continue;
}
let (i, j) = (ids[&e.a], ids[&e.b]);
let g = 1. / e.r;
m[i][i] += g;
m[j][j] += g;
m[i][j] -= g;
m[j][i] -= g;
}
for (k, a) in injections {
let i = ids.get(k).ok_or("terminal has no modeled conductor")?;
rhs[*i] += a;
}
if rhs.iter().sum::<f64>().abs() > 1e-8 {
return Err("unbalanced graph injection".into());
}
let (original, original_rhs) = (m.clone(), rhs.clone());
// Ground node zero, remove its row/column from elimination.
for k in 1..n {
let pivot = (k..n)
.max_by(|a, b| m[*a][k].abs().total_cmp(&m[*b][k].abs()))
.unwrap();
if m[pivot][k].abs() < 1e-12 {
return Err("disconnected or singular branch graph".into());
}
m.swap(k, pivot);
rhs.swap(k, pivot);
for i in k + 1..n {
let f = m[i][k] / m[k][k];
for j in k..n {
m[i][j] -= f * m[k][j];
}
rhs[i] -= f * rhs[k];
}
}
let mut v = vec![0.; n];
for i in (1..n).rev() {
v[i] = (rhs[i] - (i + 1..n).map(|j| m[i][j] * v[j]).sum::<f64>()) / m[i][i];
}
if v.iter().any(|x| !x.is_finite()) {
return Err("nonfinite branch voltage solution".into());
}
let residual = (0..n)
.map(|i| ((0..n).map(|j| original[i][j] * v[j]).sum::<f64>() - original_rhs[i]).abs())
.fold(0., f64::max);
if !residual.is_finite() || residual > 1e-7 {
return Err(format!("branch KCL residual {residual} exceeds 1e-7 A"));
}
Ok(edges
.iter()
.map(|e| (v[ids[&e.a]] - v[ids[&e.b]]) / e.r)
.collect())
}
/// The board's copper layers in physical order: F.Cu, In1.Cu .. InN.Cu, B.Cu.
pub fn copper_stack(board: &Board) -> Result<Vec<String>, String> {
copper_stack_of(&board.root)
}
/// `copper_stack` from a parsed board file's root node.
pub fn copper_stack_of(root: &aiflow_board::Node) -> Result<Vec<String>, String> {
let names: Vec<String> = root
.find("layers")
.ok_or("missing physical copper layer table")?
.nodes()
.filter_map(|n| n.atom(1))
.filter(|l| l.ends_with(".Cu"))
.map(str::to_string)
.collect();
let mut stack = vec!["F.Cu".to_string()];
stack.extend((1..=names.len().saturating_sub(2)).map(|k| format!("In{k}.Cu")));
stack.push("B.Cu".into());
let (mut a, mut b) = (names.clone(), stack.clone());
a.sort();
b.sort();
if names.len() < 2 || a != b {
return Err(format!("unrecognised copper layer table {names:?}; expected F.Cu, In1.Cu.., B.Cu"));
}
Ok(stack)
}
/// Where each copper layer's middle sits through the board, 0 at F.Cu and 1 at B.Cu, from the board's
/// own physical stackup (every copper and dielectric thickness between F.Cu and B.Cu), with each copper
/// layer's thickness in um.
fn stack_fractions(board: &Board, stack: &[String]) -> Result<(Vec<f64>, Vec<f64>), String> {
let missing = || "the board file states no complete physical stackup (Board Setup > Physical Stackup: every copper and dielectric thickness)".to_string();
let layers = board.root.find("setup").and_then(|s| s.find("stackup")).ok_or_else(missing)?;
let (mut z, mut started) = (0.0, false);
let mut mid: BTreeMap<String, f64> = BTreeMap::new();
let mut cu: BTreeMap<String, f64> = BTreeMap::new();
for l in layers.find_all("layer") {
let name = l.atom(1).unwrap_or("");
started |= name == "F.Cu";
if !started {
continue;
}
let t: f64 = l.find_all("thickness").filter_map(|t| t.number(1)).sum();
if !t.is_finite() || t <= 0. {
return Err(missing());
}
if name.ends_with(".Cu") {
mid.insert(name.to_string(), z + t / 2.);
cu.insert(name.to_string(), t * 1000.);
}
z += t;
if name == "B.Cu" {
break;
}
}
let (Some(f), Some(b)) = (mid.get("F.Cu").copied(), mid.get("B.Cu").copied()) else { return Err(missing()) };
let fractions = stack
.iter()
.map(|n| mid.get(n).map(|z| (z - f) / (b - f)).ok_or_else(missing))
.collect::<Result<Vec<_>, _>>()?;
Ok((fractions, stack.iter().map(|n| cu[n]).collect()))
}
pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Value, String> {
let stack = copper_stack(board)?;
let n_layers = stack.len();
let outer = |l: usize| l == 0 || l + 1 == n_layers;
let layer_index = |name: &str| stack.iter().position(|s| s == name);
for v in board.root.find_all("via").filter(|v| board.item_net(v) == net) {
if v.atom(1).is_some_and(|x| x == "blind" || x == "micro")
|| v.find("layers")
.is_some_and(|l| l.atom(1) != Some("F.Cu") || l.atom(2) != Some("B.Cu"))
{
return Err(format!("branch model supports only through vias; {net} has a blind, buried or micro via"));
}
}
let model = &spec["branchCurrentModel"];
let thick = positive(
&model["boardThicknessMm"],
"branchCurrentModel.boardThicknessMm",
)?;
let plating = positive(
&model["barrelPlatingUm"],
"branchCurrentModel.barrelPlatingUm",
)? / 1000.;
let copper = positive(&spec["copperUm"], "copperUm")?;
if (plating - 0.025).abs() > 1e-12 {
return Err("the paired Fields solver currently assumes 25 um barrel plating; other plating requires matching solver support".into());
}
if spec["copperUmByLayer"]
.as_object()
.into_iter()
.flatten()
.any(|(_, v)| v.as_f64() != Some(copper))
{
return Err("branch model currently requires uniform declared copper thickness".into());
}
let barrel_limit = positive(
&model["maxBarrelDensityAperMm2"],
"branchCurrentModel.maxBarrelDensityAperMm2",
)?;
let zone_layers: BTreeSet<String> = board
.root
.find_all("zone")
.filter(|z| z.find("keepout").is_none() && board.item_net(z) == net)
.flat_map(|z| {
let mut v: Vec<String> = z.find("layer").and_then(|l| l.value()).map(|l| vec![l.to_string()]).unwrap_or_default();
if let Some(ls) = z.find("layers") {
v.extend((1..).map_while(|i| ls.atom(i)).map(str::to_string));
}
v
})
.collect();
if !zone_layers.is_empty() {
return Err(format!(
"branch model does not support pours or planes ({net} has a zone on {}); use filled-copper analysis",
zone_layers.into_iter().collect::<Vec<_>>().join(", ")
));
}
if board.root.find_all("arc").next().is_some() {
return Err("branch model does not support boards with copper arcs".into());
}
let amps = positive(&load["amps"], "load amps")?;
let max_rise = load["maxRiseC"]
.as_f64()
.or(spec["maxRiseC"].as_f64())
.unwrap_or(10.);
if !max_rise.is_finite() || max_rise <= 0. {
return Err("maxRiseC must be positive".into());
}
let rows = load["terminals"]
.as_array()
.ok_or("branch model requires explicit terminals")?;
let pads: Vec<_> = board.pads.iter().filter(|p| p.net == net).collect();
let mut declarations = BTreeMap::new();
let (mut sources, mut sinks) = (0., 0.);
for row in rows {
let reference = row["reference"]
.as_str()
.ok_or("terminal reference missing")?;
let pad = row["pad"].as_str().ok_or("terminal pad missing")?;
let a = row["amps"]
.as_f64()
.filter(|v| v.is_finite())
.ok_or("terminal amps must be finite")?;
let at = if row["at"].is_null() {
None
} else {
let xy = row["at"].as_array().filter(|xy| xy.len() == 2)
.ok_or("terminal at must be two finite board coordinates")?;
Some((xy[0].as_f64().filter(|v| v.is_finite())
.ok_or("terminal at must be two finite board coordinates")?,
xy[1].as_f64().filter(|v| v.is_finite())
.ok_or("terminal at must be two finite board coordinates")?))
};
let matches: Vec<_> = pads
.iter()
.enumerate()
.filter(|(_, p)| p.reference == reference && p.name == pad
&& at.is_none_or(|(x, y)| (p.x-x).abs() <= 1e-6 && (p.y-y).abs() <= 1e-6))
.map(|(i, _)| i)
.collect();
// adom-fields#26: one pad number on several lands at one centre (a KiCad exposed pad drawn as a
// 2 x 3 mm and a 1.6 x 2.94 mm land) is one piece of copper. The largest land takes the current,
// as in Fields; the others count as declared, with no current of their own.
let mut matches = matches;
let coincident = matches.len() > 1 && matches.iter().all(|&i| (pads[i].x - pads[matches[0]].x).abs() <= 1e-6 && (pads[i].y - pads[matches[0]].y).abs() <= 1e-6);
if coincident {
matches.sort_by(|&i, &j| (pads[j].size.0 * pads[j].size.1).partial_cmp(&(pads[i].size.0 * pads[i].size.1)).unwrap_or(std::cmp::Ordering::Equal));
} else if matches.len() != 1 {
return Err(format!(
"{reference}.{pad}: terminal is missing, ambiguous or on another net"
));
}
for (k, &i) in matches.iter().enumerate() {
if declarations.insert(i, if k == 0 { a } else { 0. }).is_some() {
return Err("duplicate physical terminal assignment".into());
}
}
sources += a.max(0.);
sinks += (-a).max(0.);
}
if (sources - amps).abs() > 1e-8 * amps.max(1.) || (sinks - amps).abs() > 1e-8 * amps.max(1.) {
return Err("sources and sinks must each equal load amps".into());
}
for (i, p) in pads.iter().enumerate() {
if !declarations.contains_key(&i) {
return Err(format!(
"declare current for {}.{} (zero for an unloaded pad)",
p.reference, p.name
));
}
}
struct Track {
layer: usize,
a: Pt,
b: Pt,
width: f64,
}
let mut tracks = Vec::new();
for node in board.root.find_all("segment").filter(|s| board.item_net(s) == net) {
let name = node.find("layer").and_then(|l| l.value()).unwrap_or("");
let layer = layer_index(name).ok_or_else(|| format!("{net} has a track on {name:?}, which is not in the copper stack {stack:?}"))?;
let p = |tag: &str| node.find(tag).and_then(|n| Some((n.number(1)?, n.number(2)?)));
let (Some(a), Some(b)) = (p("start"), p("end")) else { return Err("invalid track geometry".into()) };
tracks.push(Track { layer, a, b, width: node.find("width").and_then(|w| w.number(1)).unwrap_or(0.) });
}
if tracks.is_empty() {
return Err("no tracks for branch screen".into());
}
// barrel taps: the outer layers, plus every inner layer this net has tracks on
let mut taps: Vec<usize> = vec![0, n_layers - 1];
taps.extend(tracks.iter().map(|t| t.layer).filter(|l| !outer(*l)));
taps.sort();
taps.dedup();
let fraction = if taps.len() > 2 {
let inner: Vec<_> = taps[1..taps.len() - 1].iter().map(|l| stack[*l].as_str()).collect();
let (f, cu) = stack_fractions(board, &stack).map_err(|e| format!("{net} has tracks on {}; placing their barrel taps needs {e}", inner.join(", ")))?;
if let Some(l) = taps.iter().find(|l| (cu[**l] - copper).abs() > 0.5) {
return Err(format!("{} is {:.1} um in the board stackup but the spec declares copperUm {copper}; the branch model needs one uniform copper thickness on the layers it models", stack[*l], cu[*l]));
}
f
} else {
(0..n_layers).map(|l| if l == 0 { 0. } else { 1. }).collect()
};
let pad_on = |p: &aiflow_board::Pad, l: usize| (p.thru && taps.contains(&l)) || (l == 0 && p.on_f) || (l + 1 == n_layers && p.on_b);
let mut candidates: BTreeSet<Key> = tracks
.iter()
.flat_map(|t| [key(t.layer, t.a), key(t.layer, t.b)])
.collect();
for p in &pads {
for &l in &taps {
if pad_on(p, l) {
candidates.insert(key(l, (p.x, p.y)));
}
}
}
for v in board.vias().iter().filter(|v| v.net == net) {
for &l in &taps {
candidates.insert(key(l, (v.x, v.y)));
}
}
let mut edges = Vec::new();
// Split at existing centerline junctions. A crossing with no endpoint is unsupported.
for (i, t) in tracks.iter().enumerate() {
for u in tracks.iter().skip(i + 1).filter(|u| u.layer == t.layer) {
let (a, b, c, d) = (t.a, t.b, u.a, u.b);
let den = (b.0 - a.0) * (d.1 - c.1) - (b.1 - a.1) * (d.0 - c.0);
if den.abs() > 1e-12 {
let s = ((c.0 - a.0) * (d.1 - c.1) - (c.1 - a.1) * (d.0 - c.0)) / den;
let q = ((c.0 - a.0) * (b.1 - a.1) - (c.1 - a.1) * (b.0 - a.0)) / den;
if s > 1e-7 && s < 1. - 1e-7 && q > 1e-7 && q < 1. - 1e-7 {
return Err(
"interior track crossing needs native segmentation before branch screening"
.into(),
);
}
} else {
let cross = (c.0 - a.0) * (b.1 - a.1) - (c.1 - a.1) * (b.0 - a.0);
let length2 = (b.0 - a.0).powi(2) + (b.1 - a.1).powi(2);
if cross.abs() < 1e-9 && length2 > 0.0 {
let project =
|p: Pt| ((p.0 - a.0) * (b.0 - a.0) + (p.1 - a.1) * (b.1 - a.1)) / length2;
let (u, v) = (project(c), project(d));
if u.max(v).min(1.0) - u.min(v).max(0.0) > 1e-7 {
return Err("overlapping collinear tracks would double-count copper; reconcile native geometry first".into());
}
}
}
}
let length = distance(t.a, t.b);
if length <= 0. || t.width <= 0. {
return Err("invalid track geometry".into());
}
let mut points: Vec<_> = candidates
.iter()
.copied()
.filter(|k| {
k.0 == t.layer
&& (distance(t.a, pt(*k)) + distance(pt(*k), t.b) - length).abs() < 1e-7
})
.collect();
points.sort_by(|a, b| distance(t.a, pt(*a)).total_cmp(&distance(t.a, pt(*b))));
for pair in points.windows(2) {
let len = distance(pt(pair[0]), pt(pair[1]));
if len < 1e-8 {
continue;
}
edges.push(Edge{a:pair[0],b:pair[1],r:len/(58000.*t.width*copper/1000.),width:Some(t.width),meta:json!({"kind":"track","trackIndex":i,"layer":stack[t.layer],"external":outer(t.layer),"start":pt(pair[0]),"end":pt(pair[1]),"widthMm":t.width})});
}
}
let mut parents = BTreeMap::new();
for p in &pads {
if !["circle", "rect", "roundrect", "oval"].contains(&p.shape.as_str()) {
return Err("unsupported pad shape for branch contact approximation".into());
}
for &l in &taps {
if pad_on(p, l) {
let center = key(l, (p.x, p.y));
for k in &candidates {
if k.0 == l
&& if p.shape == "rect" {
p.polys.iter().any(|poly| {
let (x, y) = pt(*k);
aiflow_board::geom::point_poly_distance(x, y, poly) <= 1e-7
})
} else {
// Rounded pads retain the conservative central-circle model.
// The board parser currently represents roundrects by bounding boxes.
distance(pt(*k), (p.x, p.y)) <= p.size.0.min(p.size.1) / 2. + 1e-7
}
{
unite(center, *k, &mut parents);
}
}
}
}
}
let mut barrel = |x: f64, y: f64, drill: f64, mut meta: Value| -> Result<(), String> {
if drill <= 0. || !drill.is_finite() {
return Err("barrel drill must be positive".into());
}
let area = std::f64::consts::PI * ((drill / 2. + plating).powi(2) - (drill / 2.).powi(2));
meta["barrelAreaMm2"] = json!(area);
// one barrel piece between each pair of adjacent taps, its length from the stack position
for pair in taps.windows(2) {
let length = thick * (fraction[pair[1]] - fraction[pair[0]]);
if !length.is_finite() || length <= 0. {
return Err("physical stackup gives a non-positive barrel length".into());
}
let mut m = meta.clone();
m["from"] = json!(stack[pair[0]]);
m["to"] = json!(stack[pair[1]]);
edges.push(Edge {
a: key(pair[0], (x, y)),
b: key(pair[1], (x, y)),
r: length / (58000. * area),
width: None,
meta: m,
});
}
Ok(())
};
for v in board.vias().iter().filter(|v| v.net == net) {
barrel(v.x, v.y, v.drill, json!({"kind":"via","at":[v.x,v.y]}))?;
}
for p in &pads {
if p.thru {
barrel(
p.x,
p.y,
p.drill.ok_or("through pad has no drill")?,
json!({"kind":"plated-pad","reference":p.reference,"pad":p.name}),
)?;
}
}
for e in &mut edges {
e.a = root(e.a, &parents);
e.b = root(e.b, &parents);
}
let active: Vec<_> = pads
.iter()
.enumerate()
.filter(|(i, _)| declarations[i] != 0.)
.collect();
let through = active.iter().filter(|(_, p)| p.thru).count();
if through > 4 {
return Err(
"more than four active through-pad terminals exceeds exhaustive layer cases".into(),
);
}
let mut cases = Vec::new();
let mut worst = 0.0_f64;
let mut peak_barrel = 0.0_f64;
for mask in 0..(1usize << through) {
let mut injection = BTreeMap::new();
let mut j = 0;
for (i, p) in &active {
let l = if p.thru {
let l = if (mask >> j) & 1 == 0 { 0 } else { n_layers - 1 };
j += 1;
l
} else if p.on_f {
0
} else {
n_layers - 1
};
*injection
.entry(root(key(l, (p.x, p.y)), &parents))
.or_insert(0.) += declarations[i];
}
let currents = solve(&edges, &injection)?;
let mut conductors = Vec::new();
for (e, current) in edges.iter().zip(currents) {
let mut r = e.meta.clone();
r["amps"] = json!(current.abs());
if let Some(width) = e.width {
let rise = crate::rise_for(width, copper, current.abs(), e.meta["external"] != false);
worst = worst.max(rise);
r["riseEstimateC"] = json!(rise);
} else {
let area = e.meta["barrelAreaMm2"].as_f64().ok_or("missing barrel area")?;
// issue #191: a machine contact or pin hole carries an inserted conductor (spec.contacts.<REF>.insertMm2)
let rf = e.meta["reference"].as_str().unwrap_or("");
let insert = spec["contacts"][rf]["insertMm2"].as_f64().filter(|v| v.is_finite() && *v > 0.0);
let machine = (rf.starts_with("MC") || rf.starts_with("MP")) && rf.len() > 2 && rf[2..].chars().all(|c| c.is_ascii_digit());
let density = current.abs() / (area + insert.unwrap_or(0.0));
r["barrelDensityAperMm2"] = json!(density);
if let Some(i) = insert { r["insertMm2"] = json!(i); }
if machine && insert.is_none() {
r["insertedConductor"] = json!("machine contact or pin: an inserted conductor the model does not include; not screened on plating alone (declare spec.contacts.<REF>.insertMm2 to screen it)");
} else {
peak_barrel = peak_barrel.max(density);
}
}
conductors.push(r);
}
cases.push(json!({"throughPadLayerCase":mask,"conductors":conductors}));
}
Ok(
json!({"net":net,"status":if worst<=max_rise && peak_barrel<=barrel_limit{"pass"}else{"fail"},"method":"explicit-pin resistive centerlines plus mandatory same-input Fields","peakTrackRiseC":worst,"peakBarrelDensityAperMm2":peak_barrel,"barrelDensityLimitAperMm2":barrel_limit,"maxRiseC":max_rise,"copperStack":stack,"barrelTaps":taps.iter().map(|l| stack[*l].clone()).collect::<Vec<_>>(),"cases":cases,"how":format!("branch currents give {worst:.2} C maximum track estimate against {max_rise:.2} C; barrel density {peak_barrel:.2} against {barrel_limit:.2} A/mm2; all through-terminal layer extremes checked"),"limits":"Nominal straight-track and barrel resistances (inner-layer tracks by IPC-2221's internal curve; barrel pieces between taps placed by the board's own stackup), through-pad current injected at the F.Cu or B.Cu end, ideal rectangular pad outlines and conservative rounded-pad central regions; no pad/contact thermal qualification. Filled-copper Fields and physical stack validation remain mandatory."}),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn edge(a: i64, b: i64, r: f64) -> Edge {
Edge {
a: (0, a, 0),
b: (0, b, 0),
r,
width: Some(1.),
meta: json!({}),
}
}
#[test]
fn series_and_parallel_currents_follow_resistance() {
let series = solve(
&[edge(0, 1, 1.), edge(1, 2, 2.)],
&BTreeMap::from([((0, 0, 0), 3.), ((0, 2, 0), -3.)]),
)
.unwrap();
assert!(series.iter().all(|i| (i.abs() - 3.).abs() < 1e-9));
let parallel = solve(
&[edge(0, 1, 1.), edge(0, 1, 2.)],
&BTreeMap::from([((0, 0, 0), 3.), ((0, 1, 0), -3.)]),
)
.unwrap();
assert!((parallel[0].abs() - 2.).abs() < 1e-9);
assert!((parallel[1].abs() - 1.).abs() < 1e-9);
}
#[test]
fn disconnected_or_unbalanced_networks_refuse() {
assert!(solve(
&[edge(0, 1, 1.), edge(2, 3, 1.)],
&BTreeMap::from([((0, 0, 0), 3.), ((0, 1, 0), -3.)])
)
.is_err());
assert!(solve(&[edge(0, 1, 1.)], &BTreeMap::from([((0, 0, 0), 3.)])).is_err());
}
}
#[cfg(test)]
mod geometry_tests {
use super::*;
fn board(width: f64) -> Board {
let text = format!(
r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (31 "B.Cu" signal)) (net 1 "V")
(gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts") (width 0.05))
(footprint "A" (layer "F.Cu") (at 2 2) (property "Reference" "J1") (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))
(footprint "A" (layer "F.Cu") (at 12 2) (property "Reference" "F1") (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))
(footprint "A" (layer "F.Cu") (at 2 12) (property "Reference" "R1") (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))
(segment (start 2 2) (end 12 2) (width {width}) (layer "F.Cu") (net 1))
(segment (start 2 2) (end 2 12) (width 0.25) (layer "F.Cu") (net 1)))"#
);
Board::from_text("test.kicad_pcb", text).unwrap()
}
fn spec() -> Value {
json!({"copperUm":35,"branchCurrentModel":{"boardThicknessMm":1.6,"barrelPlatingUm":25,"maxBarrelDensityAperMm2":60},"loads":{"V":{"amps":3.001,"maxRiseC":10,"terminals":[{"reference":"J1","pad":"1","amps":3.001},{"reference":"F1","pad":"1","amps":-3},{"reference":"R1","pad":"1","amps":-0.001}]}}})
}
/// adom-fields#26 (Noah, TI DGN0008B on am32-esc-v1): pad 9 is two lands at one centre plus via pads
/// around it. Named with `at`, the coincident lands are one terminal; a pad number on lands at different
/// places still needs `at` to say which.
#[test]
fn coincident_lands_of_one_pad_are_one_terminal() {
let text = r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (31 "B.Cu" signal)) (net 1 "V")
(gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts") (width 0.05))
(footprint "A" (layer "F.Cu") (at 2 2) (property "Reference" "J1") (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))
(footprint "U" (layer "F.Cu") (at 12 2) (property "Reference" "U1")
(pad "9" smd rect (at 0 0) (size 2 3) (layers "F.Cu") (net 1 "V"))
(pad "9" smd rect (at 0 0) (size 1.6 2.94) (layers "F.Cu") (net 1 "V"))
(pad "9" smd circle (at 0.5 1) (size 0.5 0.5) (layers "F.Cu") (net 1 "V")))
(segment (start 2 2) (end 12 2) (width 2) (layer "F.Cu") (net 1)))"#;
let b = Board::from_text("test.kicad_pcb", text.to_string()).unwrap();
let mut s = spec();
s["loads"]["V"] = json!({"amps": 3, "maxRiseC": 10, "terminals": [
{"reference": "J1", "pad": "1", "amps": 3},
{"reference": "U1", "pad": "9", "at": [12.0, 2.0], "amps": -3},
{"reference": "U1", "pad": "9", "at": [12.5, 3.0], "amps": 0}]});
let r = screen(&b, "V", &s["loads"]["V"], &s).unwrap();
assert_eq!(r["status"], "pass", "{r}");
// without `at`, pad 9's lands are in two places: still ambiguous
s["loads"]["V"]["terminals"][1] = json!({"reference": "U1", "pad": "9", "amps": -3});
assert!(screen(&b, "V", &s["loads"]["V"], &s).unwrap_err().contains("ambiguous"));
}
#[test]
fn sense_branch_does_not_take_main_load_but_narrow_main_path_still_fails() {
let s = spec();
let r = screen(&board(2.), "V", &s["loads"]["V"], &s).unwrap();
assert_eq!(r["status"], "pass");
let r = screen(&board(0.25), "V", &s["loads"]["V"], &s).unwrap();
assert_eq!(r["status"], "fail");
}
#[test]
fn rectangular_corner_contact_and_rotated_contact_are_connected_but_gaps_refuse() {
for rotation in [0., 90., 37.] {
let base = board(2.).text;
let (dx, dy) = aiflow_board::geom::rot_kicad(0.45, 0.9, rotation);
let shape = format!("(at 0 0 {rotation}) (size 1 2)");
let text = base.replace(
r#"(property "Reference" "F1") (pad "1" smd circle (at 0 0) (size 1 1)"#,
&format!(r#"(property "Reference" "F1") (pad "1" smd rect {shape}"#),
);
for (offset, connected) in [(0., true), (2., false)] {
let endpoint = format!("(end {:.6} {:.6})", 12. + dx + offset, 2. + dy);
let text = text.replace("(end 12 2)", &endpoint);
let b = Board::from_text("corner.kicad_pcb", text).unwrap();
let s = spec();
let result = screen(&b, "V", &s["loads"]["V"], &s);
assert_eq!(result.is_ok(), connected, "rotation={rotation}, offset={offset}: {result:?}");
}
}
}
#[test]
fn missing_pin_and_implicit_material_assumptions_refuse() {
let mut s = spec();
s["loads"]["V"]["terminals"].as_array_mut().unwrap().pop();
assert!(screen(&board(2.), "V", &s["loads"]["V"], &s).is_err());
let mut s = spec();
s["branchCurrentModel"] = json!({});
assert!(screen(&board(2.), "V", &s["loads"]["V"], &s).is_err());
}
fn repeated_pad_case() -> (Board, Value) {
let mut text = board(2.).text.replace("(width 0.25)", "(width 2)");
let old = r#"(property "Reference" "F1") (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V"))"#;
let new = format!("{old} {}", r#"(pad "1" smd circle (at 0 10) (size 1 1) (layers "F.Cu") (net 1 "V"))"#);
assert!(text.contains(old));
text = text.replace(old, &new);
text.pop();
text.push_str(r#"(segment (start 2 12) (end 12 12) (width 2) (layer "F.Cu") (net 1)))"#);
let mut s = spec();
s["loads"]["V"]["terminals"][1] = json!({"reference":"F1","pad":"1","amps":-1.5,"at":[12,2]});
s["loads"]["V"]["terminals"].as_array_mut().unwrap()
.push(json!({"reference":"F1","pad":"1","amps":-1.5,"at":[12,12]}));
(Board::from_text("repeated.kicad_pcb", text).unwrap(), s)
}
#[test]
fn repeated_physical_pads_accept_explicit_centers_and_preserve_balance() {
let (b, mut s) = repeated_pad_case();
assert_eq!(screen(&b, "V", &s["loads"]["V"], &s).unwrap()["status"], "pass");
s["loads"]["V"]["terminals"].as_array_mut().unwrap().reverse();
assert_eq!(screen(&b, "V", &s["loads"]["V"], &s).unwrap()["status"], "pass");
s["loads"]["V"]["terminals"][0]["amps"] = json!(-1.4);
assert!(screen(&b, "V", &s["loads"]["V"], &s).unwrap_err().contains("sources and sinks"));
}
#[test]
fn repeated_physical_pads_refuse_missing_duplicate_or_invalid_selectors() {
let (b, s) = repeated_pad_case();
for at in [Value::Null, json!([12,12]), json!([12,3]), json!(["x",2]), json!([12])] {
let mut bad = s.clone();
bad["loads"]["V"]["terminals"][1]["at"] = at;
assert!(screen(&b, "V", &bad["loads"]["V"], &bad).is_err());
}
let mut missing = s.clone();
missing["loads"]["V"]["terminals"].as_array_mut().unwrap().pop();
missing["loads"]["V"]["terminals"][1]["amps"] = json!(-3);
assert!(screen(&b, "V", &missing["loads"]["V"], &missing).unwrap_err().contains("declare current"));
}
// issue #107: a four-layer board (In1 GND plane, In2 3V3A plane of other nets)
const STACKUP: &str = r#"(setup (stackup (layer "F.Mask" (type "Top Solder Mask") (thickness 0.01)) (layer "F.Cu" (type "copper") (thickness 0.035)) (layer "dielectric 1" (type "prepreg") (thickness 0.2)) (layer "In1.Cu" (type "copper") (thickness 0.035)) (layer "dielectric 2" (type "core") (thickness 1.06)) (layer "In2.Cu" (type "copper") (thickness 0.035)) (layer "dielectric 3" (type "prepreg") (thickness 0.2)) (layer "B.Cu" (type "copper") (thickness 0.035)) (layer "B.Mask" (type "Bottom Solder Mask") (thickness 0.01))))"#;
fn four_layer(inner_width: Option<f64>, stackup: bool) -> Board {
let route = match inner_width {
None => "(segment (start 2 2) (end 12 2) (width 2) (layer \"F.Cu\") (net 1))".to_string(),
Some(w) => format!("(segment (start 2 2) (end 4 2) (width 2) (layer \"F.Cu\") (net 1)) (via (at 4 2) (size 1.6) (drill 1) (layers \"F.Cu\" \"B.Cu\") (net 1)) (segment (start 4 2) (end 10 2) (width {w}) (layer \"In1.Cu\") (net 1)) (via (at 10 2) (size 1.6) (drill 1) (layers \"F.Cu\" \"B.Cu\") (net 1)) (segment (start 10 2) (end 12 2) (width 2) (layer \"F.Cu\") (net 1))"),
};
let text = format!(
r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (4 "In1.Cu" power) (6 "In2.Cu" power) (2 "B.Cu" signal)) {} (net 1 "V") (net 2 "GND") (net 3 "3V3A")
(gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts") (width 0.05))
(footprint "A" (layer "F.Cu") (at 2 2) (property "Reference" "J1") (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))
(footprint "A" (layer "F.Cu") (at 12 2) (property "Reference" "F1") (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))
{route}
(zone (net 2) (net_name "GND") (layer "In1.Cu") (polygon (pts (xy 0 0) (xy 20 0) (xy 20 20) (xy 0 20))))
(zone (net 3) (net_name "3V3A") (layer "In2.Cu") (polygon (pts (xy 0 0) (xy 20 0) (xy 20 20) (xy 0 20)))))"#,
if stackup { STACKUP } else { "" }
);
Board::from_text("four.kicad_pcb", text).unwrap()
}
fn four_spec() -> Value {
json!({"copperUm":35,"branchCurrentModel":{"boardThicknessMm":1.6,"barrelPlatingUm":25,"maxBarrelDensityAperMm2":60},"loads":{"V":{"amps":3,"maxRiseC":10,"terminals":[{"reference":"J1","pad":"1","amps":3},{"reference":"F1","pad":"1","amps":-3}]}}})
}
#[test]
fn outer_layer_net_on_a_four_layer_board_is_modelled_like_two_layers() {
let s = four_spec();
let r = screen(&four_layer(None, false), "V", &s["loads"]["V"], &s).unwrap();
assert_eq!(r["status"], "pass", "{r}");
assert_eq!(r["copperStack"], json!(["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]));
assert_eq!(r["barrelTaps"], json!(["F.Cu", "B.Cu"]));
}
#[test]
fn inner_layer_track_uses_the_stackup_and_the_internal_curve() {
let s = four_spec();
// 4 mm on In1.Cu carries 3 A within 10 C on the internal curve; 2 mm does not (it would externally)
let r = screen(&four_layer(Some(4.), true), "V", &s["loads"]["V"], &s).unwrap();
assert_eq!(r["status"], "pass", "{r}");
assert_eq!(r["barrelTaps"], json!(["F.Cu", "In1.Cu", "B.Cu"]));
let inner = r["cases"][0]["conductors"].as_array().unwrap().iter().find(|c| c["layer"] == "In1.Cu").unwrap().clone();
assert_eq!(inner["external"], false);
let r = screen(&four_layer(Some(2.), true), "V", &s["loads"]["V"], &s).unwrap();
assert_eq!(r["status"], "fail", "{r}");
assert!(crate::rise_for(2., 35., 3., true) < 10.);
// the F.Cu to In1.Cu barrel piece is 0.2 + 0.035 mm of the 1.6 mm board, scaled to F-B centres
let barrels: Vec<_> = r["cases"][0]["conductors"].as_array().unwrap().iter().filter(|c| c["kind"] == "via").cloned().collect();
assert!(barrels.iter().any(|b| b["from"] == "F.Cu" && b["to"] == "In1.Cu"));
}
#[test]
fn inner_copper_without_a_stackup_or_a_same_net_plane_refuses_and_says_which() {
let s = four_spec();
let e = screen(&four_layer(Some(4.), false), "V", &s["loads"]["V"], &s).unwrap_err();
assert!(e.contains("In1.Cu") && e.contains("stackup"), "{e}");
let mut text = four_layer(None, false).text;
text.pop();
text.push_str(r#"(zone (net 1) (net_name "V") (layer "In2.Cu") (polygon (pts (xy 0 0) (xy 5 0) (xy 5 5) (xy 0 5)))))"#);
let e = screen(&Board::from_text("p.kicad_pcb", text).unwrap(), "V", &s["loads"]["V"], &s).unwrap_err();
assert!(e.contains("In2.Cu") && e.contains("pours or planes"), "{e}");
// half-ounce inner copper against a 35 um declaration
let b = four_layer(Some(4.), true);
let b = Board::from_text("h.kicad_pcb", b.text.replacen(r#"(layer "In1.Cu" (type "copper") (thickness 0.035))"#, r#"(layer "In1.Cu" (type "copper") (thickness 0.0175))"#, 1)).unwrap();
assert!(screen(&b, "V", &s["loads"]["V"], &s).unwrap_err().contains("uniform copper"));
}
}