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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787
//! Analysis passes for the finish line: current capacity and thermal, from the routed and poured board.
//!
//! 0.1 is deliberately conservative and says so: tracks and vias by IPC-2221's external-layer
//! curve, pours by presence, connection style and via count, not by a field solve. PASS means "no
//! obvious under-sized conductor and every hot tab has copper"; FAIL names the net or the part and
//! the fix. 0.2 computes cross-sections through the filled polygons and IR drop per net.
pub mod board_sink;
pub mod branches;
pub mod current_acks;
pub mod fields_report;
pub mod lead_cooling;
pub mod package_cooling;
pub mod plane_backed;
pub mod pour_cover;
pub mod solved_ampacity;
use std::collections::BTreeMap;
use serde_json::{json, Value};
const MIL2_PER_MM2: f64 = 1550.0031;
pub fn ipc2221_amps(width_mm: f64, thickness_um: f64, rise_c: f64, external: bool) -> f64 {
let k = if external { 0.048 } else { 0.024 };
let a = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2;
k * rise_c.powf(0.44) * a.powf(0.725)
}
pub fn rise_for(width_mm: f64, thickness_um: f64, amps: f64, external: bool) -> f64 {
let k = if external { 0.048 } else { 0.024 };
let a = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2;
if a <= 0.0 || amps <= 0.0 {
return 0.0;
}
(amps / (k * a.powf(0.725))).powf(1.0 / 0.44)
}
struct NetCopper {
widths: Vec<f64>,
vias: usize,
via_drill: f64,
}
fn plan_widths(plan: &Value) -> BTreeMap<String, NetCopper> {
let mut out: BTreeMap<String, NetCopper> = BTreeMap::new();
for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {
let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
let d = out.entry(net).or_insert(NetCopper { widths: Vec::new(), vias: 0, via_drill: e.get("viaDrill").and_then(|v| v.as_f64()).unwrap_or(0.3) });
if let Some(w) = e.get("width").and_then(|w| w.as_f64()) {
if !d.widths.iter().any(|x| (x - w).abs() < 1e-9) {
d.widths.push(w);
}
}
for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {
d.vias += path.as_array().map(|a| a.iter().filter(|w| w.get("layer").is_some() && w.get("x").is_some()).count()).unwrap_or(0);
}
}
out
}
fn pours_by_net(zone_state: Option<&Value>) -> BTreeMap<String, Vec<Value>> {
let mut m: BTreeMap<String, Vec<Value>> = BTreeMap::new();
for z in zone_state.and_then(|z| z.get("zones")).and_then(|z| z.as_array()).cloned().unwrap_or_default() {
if z.get("type").and_then(|t| t.as_str()) == Some("copper") {
if let Some(net) = z.get("net").and_then(|n| n.as_str()) {
if !net.is_empty() {
m.entry(net.to_string()).or_default().push(z);
}
}
}
}
m
}
fn fill_area(zs: &[Value]) -> f64 {
zs.iter().flat_map(|z| z.get("fills").and_then(|f| f.as_array()).cloned().unwrap_or_default()).map(|f| f.get("areaMm2").and_then(|a| a.as_f64()).unwrap_or(0.0)).sum()
}
fn layers_of(zs: &[Value]) -> Vec<String> {
let mut v: Vec<String> = zs.iter().flat_map(|z| z.get("layers").and_then(|l| l.as_array()).cloned().unwrap_or_default()).filter_map(|l| l.as_str().map(str::to_string)).collect();
v.sort();
v.dedup();
v
}
/// spec.loads: {net: {amps, peakAmps?, maxRiseC?}}, spec.copperUm outer thickness (35), spec.maxRiseC (10).
/// `pad_layers`: net -> the outer layers its SMD pads sit on (through-hole pads count for neither).
/// When every pad of a loaded net is on one outer layer and that layer is poured, the pour on that
/// layer carries the current between them; vias on the net then only spread heat.
/// A pour pass here is provisional (`pourCarries`): `analyze_current_on_board` keeps it only when the
/// pour covers the narrow copper or joins every pad (pour_cover, issue #107). A failure names its
/// kind (`failure`: no-copper, bottleneck, via-stitching; a poured net with no tracks is pour-carried, issue #149), so a same-input Fields solve can judge the
/// width approximation (solved_ampacity).
pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, pad_layers: Option<&BTreeMap<String, Vec<String>>>) -> (bool, Vec<Value>) {
let t_um = spec.get("copperUm").and_then(|v| v.as_f64()).unwrap_or(35.0);
let max_rise_default = spec.get("maxRiseC").and_then(|v| v.as_f64()).unwrap_or(10.0);
let widths = plan_widths(plan);
let pours = pours_by_net(zone_state);
let mut results = Vec::new();
let mut ok = true;
for (net, ld) in spec.get("loads").and_then(|l| l.as_object()).cloned().unwrap_or_default() {
let amps = ld.get("amps").and_then(|v| v.as_f64()).unwrap_or(0.0);
let max_rise = ld.get("maxRiseC").and_then(|v| v.as_f64()).unwrap_or(max_rise_default);
let Some(w) = widths.get(&net) else {
// issue #149: no tracks, but the net is poured: the pour carries it (judged by pour_cover
// on the saved board, then by a same-input Fields solve), not "no copper"
let zs = pours.get(&net).cloned().unwrap_or_default();
let pour_area = (fill_area(&zs) * 10.0).round() / 10.0;
if !zs.is_empty() && pour_area > 0.0 {
let pour_layers = layers_of(&zs);
results.push(json!({"net": net, "amps": amps, "maxRiseC": max_rise, "status": "pass", "pourCarries": true, "pourOnly": true, "vias": 0, "pourLayers": pour_layers, "pourFilledMm2": pour_area,
"how": format!("{net} is carried only by its pour ({pour_area:.0} mm2 on {}), no tracks", pour_layers.join("+"))}));
continue;
}
results.push(json!({"net": net, "amps": amps, "status": "no-copper", "failure": "no-copper", "hint": format!("net {net} carries {amps:.1} A but the routing plan has no copper for it")}));
ok = false;
continue;
};
let mut signal: Vec<f64> = w.widths.iter().copied().filter(|x| *x > 0.2).collect();
signal.sort_by(|a, b| a.partial_cmp(b).unwrap());
let narrowest = signal.first().copied().unwrap_or_else(|| w.widths.iter().cloned().fold(f64::INFINITY, f64::min));
let widest = w.widths.iter().cloned().fold(0.0, f64::max);
let rise_narrow = rise_for(narrowest, t_um, amps, true);
let rise_wide = rise_for(widest, t_um, amps, true);
let zs = pours.get(&net).cloned().unwrap_or_default();
let pour_layers = layers_of(&zs);
let pour_area = (fill_area(&zs) * 10.0).round() / 10.0;
let via_amps = if w.via_drill >= 0.4 { 1.5 } else { 1.0 };
let via_capacity = w.vias as f64 * via_amps;
let mut r = json!({"net": net, "amps": amps, "maxRiseC": max_rise, "narrowestTrackMm": narrowest, "widestTrackMm": widest, "riseAtNarrowestC": (rise_narrow * 10.0).round() / 10.0, "riseAtWidestC": (rise_wide * 10.0).round() / 10.0, "vias": w.vias, "viaCapacityA": via_capacity, "pourLayers": pour_layers, "pourFilledMm2": pour_area});
if amps == 0.0 || (narrowest > 0.0 && rise_narrow <= max_rise) {
r["status"] = json!("pass");
r["how"] = json!(format!("the narrowest listed track carries the full specified load within {max_rise:.0} C"));
} else if !pour_layers.is_empty() && pour_area > 0.0 {
let one_layer = pad_layers.and_then(|m| m.get(&net)).filter(|l| l.len() == 1).map(|l| l[0].clone()).filter(|l| pour_layers.contains(l));
if let (true, Some(l)) = (pour_layers.len() >= 2 && via_capacity < amps, one_layer.clone()) {
r["status"] = json!("pass");
r["pourCarries"] = json!(true);
r["how"] = json!(format!("every pad of {net} is on {l} and the {l} pour ({pour_area:.0} mm2 on {}) spans them; the other layer and its {} vias spread heat", pour_layers.join("+"), w.vias));
} else if pour_layers.len() >= 2 && via_capacity < amps {
r["status"] = json!("fail");
r["failure"] = json!("via-stitching");
r["hint"] = json!(format!("{net} is carried by pours on {} but only {} vias ({via_capacity:.0} A) join the layers for {amps:.1} A: add stitching vias (0.8/0.4 mm, about 1.5 A each)", pour_layers.join("+"), w.vias));
ok = false;
} else {
r["status"] = json!("pass");
r["pourCarries"] = json!(true);
r["how"] = json!(format!("pour on {} ({pour_area:.0} mm2 filled) carries the load; tracks are {widest:.2} mm at most", pour_layers.join("+")));
}
} else {
r["status"] = json!("fail");
r["failure"] = json!("bottleneck");
let need = [0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0].iter().find(|w| rise_for(**w, t_um, amps, true) <= max_rise).map(|w| format!("{w}")).unwrap_or_else(|| ">6".into());
r["hint"] = json!(format!("{net} carries {amps:.1} A on a {narrowest:.2} mm bottleneck ({rise_narrow:.0} C rise, limit {max_rise:.0} C) and has no pour: pour it on the outer layers or widen to {need} mm"));
ok = false;
}
results.push(r);
}
(ok, results)
}
/// An explicitly selected branch model replaces the whole-net width approximation only for
/// native saved-board copper. It remains ANDed with the same-input field screen by the CLI.
pub fn analyze_current_on_board(plan: &Value, spec: &Value, zone_state: Option<&Value>, pad_layers: Option<&BTreeMap<String, Vec<String>>>, board: &aiflow_board::Board) -> (bool, Vec<Value>) {
let (_, mut results) = analyze_current(plan, spec, zone_state, pad_layers);
for result in &mut results {
let net = result["net"].as_str().unwrap_or("").to_string();
let load = &spec["loads"][&net];
if load["currentModel"] != "resistive-branches" {
// issue #107: a pour passes only where it carries the current
if result["pourCarries"] == true && result["status"] == "pass" {
let t_um = spec["copperUm"].as_f64().unwrap_or(35.0);
let amps = result["amps"].as_f64().unwrap_or(0.0);
let max_rise = result["maxRiseC"].as_f64().unwrap_or(10.0);
match pour_cover::check(board, &net, spec, amps, max_rise, t_um) {
Ok(why) => { result["how"] = json!(format!("{}; {why}", result["how"].as_str().unwrap_or(""))); }
Err(why) => { result["status"] = json!("fail"); result["failure"] = json!("pour-coverage"); result["hint"] = json!(why); if let Some(o) = result.as_object_mut() { o.remove("how"); } }
}
}
continue;
}
let planned = plan["nets"].as_array().into_iter().flatten().any(|e| e["net"] == net && e["source"] != "board");
let branch = if planned { Err("land and adopt planned copper before branch analysis".into()) } else { branches::screen(board, &net, load, spec) };
match branch {
Ok(mut r) => { r["fullNetWidthScreen"] = result.clone(); *result = r; },
Err(e) => { result["status"] = json!("fail"); result["hint"] = json!(format!("explicit branch model refused: {e}")); }
}
}
(results.iter().all(|r| r["status"] == "pass"), results)
}
/// spec.hot: {ref: {watts, tabNet, maxThetaCPerW?}}. 0.1: copper presence and vias at the tab.
pub fn analyze_thermal(plan: &Value, spec: &Value, zone_state: Option<&Value>) -> (bool, Vec<Value>) {
thermal_with_vias(plan, spec, zone_state, None)
}
/// `analyze_thermal` counting the vias on the saved board too: thermal vias added after routing
/// (thermal-vias, live via landing) are on the board, not in the routing plan.
pub fn analyze_thermal_on_board(plan: &Value, spec: &Value, zone_state: Option<&Value>, board: &aiflow_board::Board) -> (bool, Vec<Value>) {
let mut on_board: BTreeMap<String, usize> = BTreeMap::new();
for v in board.vias() { *on_board.entry(v.net).or_default() += 1; }
let (_, mut results) = thermal_with_vias(plan, spec, zone_state, Some(&on_board));
for r in results.iter_mut().filter(|r| r["cooling"] == lead_cooling::LEADS) {
if let Err(e) = lead_cooling::check_footprint(board, r["ref"].as_str().unwrap_or("")) { r["status"] = json!("fail"); r["hint"] = json!(e); }
}
// a tab pour over a solid plane on the next copper layer: the single-layer estimate does not apply
plane_backed::classify(&mut results, board);
// issue #67: a declared board-face sink, for the parts it names only
if let Err(e) = board_sink::apply(&mut results, spec, board, zone_state) { results.push(json!({"ref": "boardSink", "status": "fail", "hint": e})); }
(results.iter().all(|r| r["status"] == "pass"), results)
}
fn thermal_with_vias(plan: &Value, spec: &Value, zone_state: Option<&Value>, board_vias: Option<&BTreeMap<String, usize>>) -> (bool, Vec<Value>) {
if let Err(error) = package_cooling::validate(spec) {
return (false, vec![json!({"ref":"packageCooling","status":"fail","hint":error})]);
}
let widths = plan_widths(plan);
let pours = pours_by_net(zone_state);
let mut results = Vec::new();
let mut ok = true;
for (reference, h) in spec.get("hot").and_then(|l| l.as_object()).cloned().unwrap_or_default() {
// issue #116: a part with no exposed tab is judged by the Fields solve, not by a tab pour
match lead_cooling::declared(&reference, &h) {
Err(e) => { ok = false; results.push(json!({"ref": reference, "status": "fail", "hint": e})); continue; }
Ok(true) if package_cooling::definition(spec, &reference).is_some() => { ok = false; results.push(json!({"ref": reference, "status": "fail", "hint": format!("{reference}: declared both cooling \"leads\" and packageCooling; a part with an external sink is not lead-cooled, keep one")})); continue; }
Ok(true) => { let r = lead_cooling::preliminary(&reference, &h); ok &= r["status"] == "pass"; results.push(r); continue; }
Ok(false) => {}
}
if let Some(package) = package_cooling::definition(spec, &reference) {
let result = package_cooling::preliminary(package, &h, spec);
ok &= result["status"] == "pass";
results.push(result);
continue;
}
let net = h.get("tabNet").and_then(|n| n.as_str()).unwrap_or("").to_string();
let watts = h.get("watts").and_then(|v| v.as_f64()).unwrap_or(0.0);
let zs = pours.get(&net).cloned().unwrap_or_default();
let area = (fill_area(&zs) * 10.0).round() / 10.0;
let layers = layers_of(&zs);
let vias = widths.get(&net).map(|w| w.vias).unwrap_or(0).max(board_vias.and_then(|m| m.get(&net)).copied().unwrap_or(0));
let theta = 40.0 / (area / 645.16).max(0.05);
// limits: a theta ceiling (maxThetaCPerW, per part or spec-wide) and/or a rise budget
// (maxRiseC per part, hotMaxRiseC spec-wide); with neither, 60 C/W
let theta_limit = h.get("maxThetaCPerW").and_then(|v| v.as_f64()).or_else(|| spec.get("maxThetaCPerW").and_then(|v| v.as_f64()));
let rise_limit = h.get("maxRiseC").and_then(|v| v.as_f64()).or_else(|| spec.get("hotMaxRiseC").and_then(|v| v.as_f64()));
let limit = theta_limit.unwrap_or(if rise_limit.is_some() { f64::INFINITY } else { 60.0 });
let rise = theta * watts;
let mut r = json!({"ref": reference, "watts": watts, "tabNet": net, "pourLayers": layers, "pourFilledMm2": area, "viasOnNet": vias, "thetaEstimateCPerW": (theta * 10.0).round() / 10.0, "limitCPerW": theta_limit, "riseEstimateC": (rise * 10.0).round() / 10.0, "riseLimitC": rise_limit});
if zs.is_empty() {
r["status"] = json!("fail");
r["hint"] = json!(if net.is_empty() { format!("{reference} dissipates {watts:.2} W but names no tabNet: give the net of its exposed tab, or, for a part with no tab (an LED, an inductor, a small leaded IC), declare \"cooling\": \"leads\" so the Fields solve judges it through its leads") } else { format!("{reference} dissipates {watts:.1} W into {net} but {net} has no pour: pour the tab net on the outer layers with a solid connection and thermal vias in the tab") });
ok = false;
} else if theta > limit {
r["status"] = json!("fail");
if theta_limit.is_none() { r["estimateFailed"] = json!(true); }
r["hint"] = json!(format!("{reference}: {area:.0} mm2 of copper on {net} gives about {theta:.0} C/W (limit {limit:.0}): widen the pour or add the other outer layer with vias"));
ok = false;
} else if rise_limit.is_some_and(|l| rise > l) {
r["status"] = json!("fail");
r["estimateFailed"] = json!(true);
r["hint"] = json!(format!("{reference}: {watts:.2} W into {area:.0} mm2 of {net} copper is about {rise:.0} C above ambient (budget {:.0} C): widen the pour or add the other outer layer with vias", rise_limit.unwrap()));
ok = false;
} else if vias == 0 && layers.len() >= 2 {
r["status"] = json!("fail");
r["hint"] = json!(format!("{reference}: the {net} pour is on two layers but no via joins them near the tab: add thermal vias in or beside the tab"));
ok = false;
} else {
r["status"] = json!("pass");
r["how"] = json!(format!("{area:.0} mm2 of {net} copper on {}, about {theta:.0} C/W, {rise:.0} C at {watts:.2} W, {vias} vias", layers.join("+")));
}
results.push(r);
}
(ok, results)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ipc_curve_is_sane() {
// a 1 mm track of 35 um copper carries about 2 to 3 A at 10 C rise on an outer layer
let a = ipc2221_amps(1.0, 35.0, 10.0, true);
assert!(a > 1.5 && a < 3.5, "{a}");
assert!(rise_for(0.25, 35.0, 20.0, true) > 100.0);
}
}
#[cfg(test)]
mod regression_tests {
use super::*;
#[test] fn thermal_counts_vias_landed_after_routing() {
// run 2026-10-15: SW poured on both layers, two vias landed after routing; the plan had none
let plan=json!({"nets":[{"net":"SW","width":0.8,"paths":[[{"x":1.0,"y":1.0},{"x":2.0,"y":1.0}]]}]});
let spec=json!({"hot":{"L1":{"watts":0.066,"tabNet":"SW"}},"hotMaxRiseC":50});
let zs=json!({"zones":[{"type":"copper","net":"SW","layers":["F.Cu"],"fills":[{"areaMm2":30.0}]},{"type":"copper","net":"SW","layers":["B.Cu"],"fills":[{"areaMm2":35.0}]}]});
let (pass,r)=analyze_thermal(&plan,&spec,Some(&zs));
assert!(!pass); assert_eq!(r[0]["viasOnNet"],0);
let pcb=r#"(kicad_pcb (version 20241229) (generator "pcbnew") (net 0 "") (net 1 "SW") (gr_rect (start 100 80) (end 130 100) (layer "Edge.Cuts"))
(via (at 116 90.2) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 1))
(via (at 116 91.2) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 1)))"#;
let b=aiflow_board::Board::from_text("t.kicad_pcb",pcb.to_string()).unwrap();
let (pass,r)=analyze_thermal_on_board(&plan,&spec,Some(&zs),&b);
assert_eq!(r[0]["viasOnNet"],2); assert!(pass,"{r:?}");
}
#[test] fn on_board_a_token_pour_no_longer_carries_the_load() {
// issue #107: 1.5 A on a 0.30 mm track between two pads; a pour on the net passes only where it covers it
let plan=json!({"nets":[{"net":"V","width":0.3,"source":"board"}]});
let spec=json!({"loads":{"V":{"amps":1.5,"maxRiseC":10.0}},"copperUm":35.0});
let zs=json!({"zones":[{"type":"copper","net":"V","layers":["F.Cu"],"fills":[{"areaMm2":2.0}]}]});
let board=|x0:f64,x1:f64,y0:f64,y1:f64| aiflow_board::Board::from_text("t.kicad_pcb",format!(r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (net 0 "") (net 1 "V")
(gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.05))
(footprint "A" (layer "F.Cu") (at 5 10) (property "Reference" "J1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))
(footprint "A" (layer "F.Cu") (at 25 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))
(segment (start 5 10) (end 25 10) (width 0.3) (layer "F.Cu") (net 1))
(zone (net 1) (net_name "V") (layer "F.Cu") (polygon (pts (xy {x0} {y0}) (xy {x1} {y0}) (xy {x1} {y1}) (xy {x0} {y1}))) (filled_polygon (layer "F.Cu") (pts (xy {x0} {y0}) (xy {x1} {y0}) (xy {x1} {y1}) (xy {x0} {y1})))))"#)).unwrap();
// the board-less screen still takes any pour; the board screen does not
assert!(analyze_current(&plan,&spec,Some(&zs),None).0);
let (pass,r)=analyze_current_on_board(&plan,&spec,Some(&zs),None,&board(10.0,11.0,9.0,11.0));
assert!(!pass); assert_eq!(r[0]["failure"],"pour-coverage"); assert!(r[0]["hint"].as_str().unwrap().contains("uncovered"));
let (pass,r)=analyze_current_on_board(&plan,&spec,Some(&zs),None,&board(4.0,26.0,8.0,12.0));
assert!(pass,"{r:?}"); assert!(r[0]["how"].as_str().unwrap().contains("covers every piece"));
}
#[test] fn a_net_carried_only_by_its_pour_is_not_no_copper() {
// issue #149: +BATT poured on F.Cu with no tracks read "the routing plan has no copper for it"
let plan=json!({"nets":[]});
let spec=json!({"loads":{"+BATT":{"amps":20.0,"maxRiseC":10.0}},"copperUm":35.0});
let zs=json!({"zones":[{"type":"copper","net":"+BATT","layers":["F.Cu"],"fills":[{"areaMm2":498.0}]}]});
let (pass,r)=analyze_current(&plan,&spec,Some(&zs),None);
assert!(pass,"{r:?}"); assert_eq!(r[0]["pourOnly"],true);
assert!(r[0]["how"].as_str().unwrap().contains("carried only by its pour (498 mm2 on F.Cu)"),"{r:?}");
// still no copper at all: no-copper as before
let (pass,r)=analyze_current(&plan,&spec,None,None);
assert!(!pass); assert_eq!(r[0]["failure"],"no-copper");
let board=|x1:f64| aiflow_board::Board::from_text("t.kicad_pcb",format!(r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (net 0 "") (net 1 "+BATT") (net 2 "GND")
(gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.05))
(footprint "A" (layer "F.Cu") (at 5 10) (property "Reference" "J1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "+BATT")) (pad "2" smd rect (at 0 -6) (size 1 1) (layers "F.Cu") (net 2 "GND")))
(footprint "A" (layer "F.Cu") (at 25 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "+BATT")) (pad "2" smd rect (at 0 -6) (size 1 1) (layers "F.Cu") (net 2 "GND")))
(zone (net 1) (net_name "+BATT") (layer "F.Cu") (polygon (pts (xy 4 8) (xy {x1} 8) (xy {x1} 12) (xy 4 12))) (filled_polygon (layer "F.Cu") (pts (xy 4 8) (xy {x1} 8) (xy {x1} 12) (xy 4 12)))))"#)).unwrap();
// the pour reaches both pads: it carries the load
let (pass,r)=analyze_current_on_board(&plan,&spec,Some(&zs),None,&board(26.0));
assert!(pass,"{r:?}"); assert!(r[0]["how"].as_str().unwrap().contains("reaches all 2"),"{r:?}");
// the pour misses U1: a pour-coverage finding a Fields solve can judge, never no-copper
let (pass,r)=analyze_current_on_board(&plan,&spec,Some(&zs),None,&board(15.0));
assert!(!pass); assert_eq!(r[0]["failure"],"pour-coverage");
assert!(r[0]["hint"].as_str().unwrap().contains("carried only by its pour"),"{r:?}");
}
#[test] fn wide_series_segment_cannot_hide_a_narrow_bottleneck() {
let plan=json!({"nets":[{"net":"POWER","width":0.25},{"net":"POWER","width":40.0}]});
let spec=json!({"loads":{"POWER":{"amps":20.0,"maxRiseC":10.0}},"copperUm":35.0});
let (pass,result)=analyze_current(&plan,&spec,None,None);
assert!(!pass); assert_eq!(result[0]["status"],"fail");
assert!(result[0]["riseAtWidestC"].as_f64().unwrap()<10.0);
let (pass,_)=analyze_current(&json!({"nets":[{"net":"POWER","width":40.0}]}),&spec,None,None);
assert!(pass);
}
}
/// Bind a field screen to the exact saved board and specification, and require coverage.
pub fn field_screen(fields: &Value, spec: &Value, board_bytes: &[u8], spec_bytes: &[u8], kind: &str) -> Result<(), String> {
field_screen_with_acks(fields, spec, board_bytes, spec_bytes, kind, &[]).map(|_| ())
}
/// `field_screen` where reviewed current-peak acknowledgements (current_acks; the caller passes
/// only live ones) cover the over-screen points they name. Returns the covered points; any
/// over-screen point no acknowledgement covers still fails, and barrel findings are never covered.
pub fn field_screen_with_acks(fields: &Value, spec: &Value, board_bytes: &[u8], spec_bytes: &[u8], kind: &str, acks: &[Value]) -> Result<Vec<Value>, String> {
use sha2::{Digest, Sha256};
let mut covered: Vec<Value> = Vec::new();
for (key,bytes) in [("board",board_bytes),("spec",spec_bytes)] {
let expected=format!("{:x}",Sha256::digest(bytes));
if fields["inputIdentity"][key].as_str()!=Some(expected.as_str()) {
return Err(format!("Fields {key} identity is missing or stale; run adom-fields analyze on this saved board and spec again"));
}
}
if kind == "thermal" {
package_cooling::screen(fields, spec)?;
for key in ["stillResidual", "movingResidual"] {
if !fields["thermalConvergence"][key].as_f64().is_some_and(|v| v.is_finite() && v >= 0.0 && v <= 1e-6) {
return Err(format!("Fields thermal solve is missing or unconverged: {key}"));
}
}
}
let (group, results, key, demand) = if kind=="current" { ("loads","nets","net","amps") } else { ("hot","chips","reference","watts") };
let entries = fields[results].as_array().ok_or_else(||format!("Fields report has no {results}"))?;
for (name,value) in spec[group].as_object().into_iter().flatten() {
if value[demand].as_f64().unwrap_or(0.0) <= 0.0 { continue; }
let row = entries.iter().find(|r|r[key].as_str()==Some(name.as_str())).ok_or_else(||format!("Fields did not solve {group}.{name}; resolve its missing terminals or copper and rerun analyze"))?;
if kind == "thermal" {
let watts = value["watts"].as_f64().unwrap();
let leads = lead_cooling::declared(name, value)?;
let tab_ok = if leads { row["tabNet"] == lead_cooling::LEADS } else { row["tabNet"] == value["tabNet"] };
if !row["watts"].as_f64().is_some_and(|v| v.is_finite() && (v-watts).abs() <= 1e-8) || !tab_ok {
if !leads && row["tabNet"] == lead_cooling::LEADS && value["tabNet"].as_str().unwrap_or("").is_empty() {
return Err(format!("Fields modelled {name} through its leads (no tabNet): if it has no exposed tab, declare spec.hot.{name}.cooling \"leads\"; if it has one, give its tabNet"));
}
return Err(format!("Fields {name} thermal source differs from its declared watts or tab net"));
}
let rise_limit = value.get("maxRiseC").or_else(|| spec.get("hotMaxRiseC"));
let theta_limit = value.get("maxThetaCPerW").or_else(|| spec.get("maxThetaCPerW"));
let mut limit = if rise_limit.is_none() && theta_limit.is_none() { 60.0 * watts } else { f64::INFINITY };
for (item, multiplier) in [(rise_limit, 1.0), (theta_limit, watts)] {
if let Some(item) = item {
let v = item.as_f64().filter(|v| v.is_finite() && *v > 0.0)
.ok_or_else(|| format!("{name} thermal limits must be finite and positive"))?;
limit = limit.min(v * multiplier);
}
}
for key in ["riseC", "riseMovingC"] {
let rise = row[key].as_f64().filter(|v| v.is_finite() && *v >= 0.0)
.ok_or_else(|| format!("Fields {name} is missing a finite {key}"))?;
// Fields chip summaries round to 0.1 C. Reserve half a display step.
if rise + 0.05 > limit {
return Err(format!("Fields {name} {key} plus 0.05 C reporting allowance exceeds its {limit} C rise budget"));
}
// a claimed junction limit on a lead-cooled part: ambient, the solved board rise and the declared package rise
if leads { if let Some(tj_max) = value["tjMaxC"].as_f64() {
let ambient = spec["ambientC"].as_f64().unwrap_or(25.0);
let tj = ambient + rise + 0.05 + value["thetaJcKPerW"].as_f64().unwrap_or(f64::INFINITY) * watts;
if tj > tj_max { return Err(format!("Fields {name} junction about {tj:.1} C from {key} ({ambient} C ambient + rise + {} K/W x {watts} W, {}) exceeds its tjMaxC {tj_max}", value["thetaJcKPerW"], value["thetaSource"].as_str().unwrap_or(""))); }
} }
}
}
if kind == "current" {
let definition = &fields["peakJDefinition"];
if definition["statistic"] != "unclipped-cell-maximum"
|| definition["rounded"] != false
|| !definition["minimumCopperFraction"].as_f64().is_some_and(|v| v.is_finite() && v > 0.0 && v <= 0.25)
{
return Err("Fields report lacks an unclipped, unrounded current maximum; older reports use display-percentile clipping. Rerun with the reviewed peak-reporting fix; do not relabel old results or waive the density limit".into());
}
let residual = row["residual"].as_f64().filter(|v| v.is_finite() && *v >= 0.0 && *v <= 1e-6);
let peak = row["peakJ"].as_f64().filter(|v| v.is_finite() && *v >= 0.0);
let amps = row["amps"].as_f64().unwrap_or(f64::NAN);
if residual.is_none() || peak.is_none() || !amps.is_finite() || (amps-value["amps"].as_f64().unwrap()).abs() > 1e-8 {
return Err(format!("Fields {name} must report a converged, finite solve at the full declared current"));
}
let density_limit=spec["maxCurrentDensityAperMm2"].as_f64().unwrap_or(60.0);
if !density_limit.is_finite() || density_limit<=0.0 { return Err("maxCurrentDensityAperMm2 must be finite and positive".into()); }
if peak.unwrap()>density_limit {
let point = current_acks::Point { net: name.clone(), at: (row["peakAt"][0].as_f64().unwrap_or(f64::NAN), row["peakAt"][1].as_f64().unwrap_or(f64::NAN)), layer: row["peakLayer"].as_str().unwrap_or("").to_string(), peak: peak.unwrap() };
match acks.iter().find(|a| current_acks::covers(a, &point)) {
Some(a) => covered.push(json!({"net": name, "peakJ": point.peak, "at": [point.at.0, point.at.1], "layer": point.layer, "ack": a["target"], "why": a["why"], "aiThread": a["aiThread"]})),
None => return Err(format!("Fields {name} peak current density exceeds the {density_limit} A/mm2 screen ({:.1} A/mm2 at ({:.2}, {:.2}) on {}; a reviewed part-limited peak takes `analyze current --ack {name}@REF.PAD --why ...`)", point.peak, point.at.0, point.at.1, point.layer)),
}
}
let coupling=&fields["barrelCoupling"];
if coupling["model"]!="distributed-plated-wall" || coupling["drillVoidsRemoved"]!=true
|| coupling["platingUm"].as_f64()!=Some(25.0) {
return Err("Fields lacks the reviewed plated-wall/drill-void model. Older center-cell coupling can report peaks inside holes. Rerun with the reviewed Fields correction (adom/adom-fields PR 9 and dependencies); preserve owner review and do not relabel cached reports".into());
}
let connections=row["platedConnections"].as_array().ok_or_else(||format!("Fields {name} has no plated-connection report"))?;
let layers=row["layers"].as_array().ok_or("Fields net layers are missing")?;
let vias=row["vias"].as_u64().ok_or("Fields via count is missing")?;
let pads=row["platedPads"].as_u64().ok_or("Fields plated-pad count is missing")?;
if layers.is_empty() {return Err("Fields net has no copper layers".into());}
let expected=vias.checked_add(pads).and_then(|n|n.checked_mul(layers.len().saturating_sub(1) as u64)).ok_or("Fields connection count overflow")?;
if connections.len() as u64!=expected {return Err(format!("Fields {name} omitted plated connections"));}
let barrel_limit=spec["branchCurrentModel"]["maxBarrelDensityAperMm2"].as_f64().unwrap_or(density_limit);
if !barrel_limit.is_finite() || barrel_limit<=0.0 {return Err("barrel density limit must be positive".into());}
// issue #191: a machine contact or pin hole carries an inserted conductor (a battery lead, a needle)
// the solver does not model, so its plating alone is not the current path
let holes: Vec<(String, f64, f64)> = aiflow_board::Board::from_text("screen.kicad_pcb", String::from_utf8_lossy(board_bytes).to_string())
.map(|b| b.pads.iter().filter(|p| p.thru).map(|p| (p.reference.clone(), p.x, p.y)).collect()).unwrap_or_default();
let hole_ref = |c: &Value| -> Option<String> {
let (x, y) = (c["at"][0].as_f64()?, c["at"][1].as_f64()?);
holes.iter().find(|(_, hx, hy)| (hx - x).abs() < 0.01 && (hy - y).abs() < 0.01).map(|(r, _, _)| r.clone())
};
let machine = |r: &str| (r.starts_with("MC") || r.starts_with("MP")) && r.len() > 2 && r[2..].chars().all(|c| c.is_ascii_digit());
for c in connections {
let amps=c["amps"].as_f64().filter(|v|v.is_finite());
let area=c["barrelAreaMm2"].as_f64().filter(|v|v.is_finite() && *v>0.0);
let density=c["meanBarrelJ"].as_f64().filter(|v|v.is_finite() && *v>=0.0);
if amps.is_none() || area.is_none() || density.is_none()
|| !c["resolvedPerimeterFraction"].as_f64().is_some_and(|v|v.is_finite() && v>0.0 && v<=1.0+1e-9)
|| c["from"]==c["to"] || !layers.contains(&c["from"]) || !layers.contains(&c["to"]) {
return Err(format!("Fields {name} has an incomplete plated-connection solve"));
}
let actual=amps.unwrap().abs()/area.unwrap();
if (actual-density.unwrap()).abs()>1e-8*actual.max(1.0) {
return Err(format!("Fields {name} barrel density is inconsistent (|amps|/area differs from meanBarrelJ)"));
}
if actual>barrel_limit {
let r = hole_ref(c);
let insert = r.as_deref().and_then(|r| spec["contacts"][r]["insertMm2"].as_f64()).filter(|v| v.is_finite() && *v > 0.0);
match (r, insert) {
(Some(r), Some(ins)) => {
let with = amps.unwrap().abs() / (area.unwrap() + ins);
if with > barrel_limit { return Err(format!("Fields {name} carries {:.1} A through {r}: {with:.1} A/mm2 over plating plus the declared {ins} mm2 insert, above {barrel_limit} A/mm2", amps.unwrap().abs())); }
covered.push(json!({"net": name, "peakJ": with, "at": c["at"], "layer": format!("{}-{}", c["from"].as_str().unwrap_or(""), c["to"].as_str().unwrap_or("")), "ack": r, "why": format!("barrel plus the declared {ins} mm2 inserted conductor (spec.contacts.{r}.insertMm2)")}));
}
(Some(r), None) if machine(&r) => {
covered.push(json!({"net": name, "peakJ": actual, "at": c["at"], "layer": format!("{}-{}", c["from"].as_str().unwrap_or(""), c["to"].as_str().unwrap_or("")), "ack": r, "why": format!("machine contact or pin hole: it carries an inserted conductor the solver does not model, so plating alone ({actual:.0} A/mm2) is not the current path; declare spec.contacts.{r}.insertMm2 to screen it with the conductor")}));
}
(r, _) => return Err(format!("Fields {name} barrel density exceeds {barrel_limit} A/mm2 ({actual:.1} A/mm2 in the hole at ({:.2}, {:.2}){}); a hole that carries an inserted conductor takes spec.contacts.<REF>.insertMm2", c["at"][0].as_f64().unwrap_or(f64::NAN), c["at"][1].as_f64().unwrap_or(f64::NAN), r.map(|r| format!(", {r}")).unwrap_or_default())),
}
}
}
if value["currentModel"] == "resistive-branches" {
let terminals = &fields["terminalAssignments"][name];
if terminals["mode"] != "explicit" { return Err(format!("Fields {name} used heuristic terminals; solve the declared explicit pins")); }
let wanted = value["terminals"].as_array().ok_or("branch model has no terminal array")?;
let actual = terminals["pins"].as_array().ok_or("Fields has no explicit terminal pins")?;
let same_at = |a: &Value, b: &Value| {
if a.is_null() && b.is_null() { return true; }
match (a.as_array(), b.as_array()) {
(Some(a), Some(b)) if a.len() == 2 && b.len() == 2 =>
a.iter().zip(b).all(|(x, y)| x.as_f64().is_some_and(|x|
x.is_finite() && y.as_f64() == Some(x))),
_ => false,
}
};
let mut matched = vec![false; actual.len()];
let same_boundary = wanted.len() == actual.len() && wanted.iter().all(|w| {
let found = actual.iter().enumerate().position(|(i, a)| !matched[i]
&& a["reference"] == w["reference"] && a["pad"] == w["pad"]
&& a["amps"].as_f64() == w["amps"].as_f64()
&& same_at(&a["at"], &w["at"]));
if let Some(i) = found { matched[i] = true; true } else { false }
});
if !same_boundary {
return Err(format!("Fields {name} terminal assignments differ from the branch model, including physical pad selectors"));
}
// the solve must model this board's own copper stack (adom-fields 0.10 reports it as
// copperLayers beside a fixed six-slot `layers`; older reports put it in `layers`)
let stack = aiflow_board::sx::parse(&String::from_utf8_lossy(board_bytes))
.and_then(|root| branches::copper_stack_of(&root))
.map_err(|e| format!("branch screening cannot read the solved board's copper stack: {e}"))?;
let solved = fields["board"].get("copperLayers").unwrap_or(&fields["board"]["layers"]);
if *solved != json!(stack) { return Err(format!("branch screening requires a Fields solve of this board's physical copper stack {stack:?}; the solve modelled {solved}")); }
for (report_key, expected) in [("thicknessMm",spec["branchCurrentModel"]["boardThicknessMm"].as_f64()),("copperUm",spec["copperUm"].as_f64())] {
if !expected.is_some_and(|e| fields["board"][report_key].as_f64().is_some_and(|a| (a-e).abs()<1e-9)) {
return Err(format!("Fields {report_key} differs from the branch model"));
}
}
}
}
}
let issues=fields["issues"].as_array().ok_or("Fields report has no issues array")?;
// a neck finding at an acknowledged point is the same reviewed peak; barrel findings never are
let acked_neck = |i: &Value| i["kind"]=="current-neck" && covered.iter().any(|c| c["net"]==i["net"]
&& i["at"][0].as_f64().zip(i["at"][1].as_f64()).is_some_and(|(x,y)| (x-c["at"][0].as_f64().unwrap_or(f64::NAN)).hypot(y-c["at"][1].as_f64().unwrap_or(f64::NAN)) <= current_acks::TOLERANCE_MM));
let relevant: Vec<_>=issues.iter().filter(|i| if kind=="current" { (i["kind"]=="current-neck" && !acked_neck(i)) || i["kind"]=="current-barrel" } else { i["kind"]=="tab-rise" || i["kind"]=="thermal-vias" || (i["kind"]=="solid-uncoupled" && i["severity"]=="high") }).collect();
if !relevant.is_empty() { return Err(relevant.iter().map(|i|i["what"].as_str().unwrap_or("unresolved field finding")).collect::<Vec<_>>().join("; ")); }
Ok(covered)
}
#[cfg(test)]
mod field_screen_tests {
use super::*;
use sha2::{Digest,Sha256};
fn report() -> Value { serde_json::json!({"barrelCoupling":{"model":"distributed-plated-wall","drillVoidsRemoved":true,"platingUm":25},"peakJDefinition":{"statistic":"unclipped-cell-maximum","rounded":false,"minimumCopperFraction":0.25},"inputIdentity":{"board":format!("{:x}",Sha256::digest(b"board")),"spec":format!("{:x}",Sha256::digest(b"spec"))},"nets":[{"net":"VCC","amps":2,"residual":1e-8,"peakJ":20,"layers":["F.Cu"],"vias":0,"platedPads":0,"platedConnections":[]}],"chips":[{"reference":"Q1"}],"issues":[]}) }
#[test]
fn old_barrel_model_and_omitted_connections_cannot_pass() {
let spec=json!({"loads":{"VCC":{"amps":2}}});
for model in [Value::Null,json!({"model":"center-cell"}),json!({"model":"distributed-plated-wall","drillVoidsRemoved":false,"platingUm":25})] {
let mut f=report();f["barrelCoupling"]=model;
assert!(field_screen(&f,&spec,b"board",b"spec","current").is_err());
}
let mut f=report();f["nets"][0]["layers"]=json!(["F.Cu","B.Cu"]);f["nets"][0]["vias"]=json!(1);
assert!(field_screen(&f,&spec,b"board",b"spec","current").unwrap_err().contains("omitted plated"));
}
#[test]
fn machine_contact_holes_are_not_failed_on_plating_alone() {
// issue #191: 16 A through a 2.62 mm MachineContactLarge hole is about 77 A/mm2 of plating; the battery
// lead inserted in it carries the current, so the screen lists it as covered instead of failing
let board=br#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (net 0 "") (net 1 "VCC")
(gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.05))
(footprint "Molecule:MachineContactLarge" (layer "F.Cu") (at 10 10) (property "Reference" "MC13") (pad "1" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers "*.Cu" "*.Mask") (net 1 "VCC")))
(footprint "A" (layer "F.Cu") (at 20 10) (property "Reference" "J1") (pad "1" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers "*.Cu" "*.Mask") (net 1 "VCC"))))"#;
let area=std::f64::consts::PI*((1.31f64+0.025).powi(2)-1.31f64.powi(2));
let conn=|x:f64|json!({"at":[x,10.0],"from":"F.Cu","to":"B.Cu","amps":16.0,"barrelAreaMm2":area,"meanBarrelJ":16.0/area,"resolvedPerimeterFraction":1.0});
let mut f=report();
f["inputIdentity"]["board"]=json!(format!("{:x}",Sha256::digest(board)));
f["nets"][0]["amps"]=json!(16);f["nets"][0]["layers"]=json!(["F.Cu","B.Cu"]);f["nets"][0]["platedPads"]=json!(1);f["nets"][0]["platedConnections"]=json!([conn(10.0)]);
let spec=json!({"loads":{"VCC":{"amps":16}}});
let covered=field_screen_with_acks(&f,&spec,board,b"spec","current",&[]).unwrap();
assert!(covered.iter().any(|c|c["ack"]=="MC13" && c["why"].as_str().unwrap().contains("inserted conductor")),"{covered:?}");
// with the 14 AWG lead declared (2.08 mm2) it is screened on plating plus the lead: 16 / 2.29 = 7 A/mm2
let declared=json!({"loads":{"VCC":{"amps":16}},"contacts":{"MC13":{"insertMm2":2.08}}});
assert!(field_screen_with_acks(&f,&declared,board,b"spec","current",&[]).unwrap().iter().any(|c|c["why"].as_str().unwrap().contains("2.08")));
// the same hole on an ordinary part (J1) still fails the screen
f["nets"][0]["platedConnections"]=json!([conn(20.0)]);
assert!(field_screen(&f,&spec,board,b"spec","current").unwrap_err().contains("J1"));
}
fn thermal_report() -> Value {
let mut f=report();
f["thermalConvergence"]=json!({"stillResidual":1e-8,"movingResidual":1e-8});
f["chips"]=json!([{"reference":"Q1","watts":5,"tabNet":"VCC","riseC":14.9,"riseMovingC":10.0}]);
f
}
#[test]
fn lead_cooled_sources_need_their_declaration_and_keep_every_limit() {
// issue #116: Fields reports a part with no tabNet as tabNet "leads"
let mut f=thermal_report();f["chips"]=json!([{"reference":"D10","watts":0.07,"tabNet":"leads","riseC":4.0,"riseMovingC":3.0}]);
// Matt's rule (#116): no tabNet is lead-cooled, with or without the explicit key
let implicit=json!({"hotMaxRiseC":20,"hot":{"D10":{"watts":0.07}}});
field_screen(&f,&implicit,b"board",b"spec","thermal").unwrap();
for net in [Value::Null,json!(""),json!("GND")] { let mut bad=f.clone();bad["chips"][0]["tabNet"]=net; assert!(field_screen(&bad,&implicit,b"board",b"spec","thermal").is_err()); }
let named=json!({"hotMaxRiseC":20,"hot":{"D10":{"watts":0.07,"tabNet":"VCC"}}});
assert!(field_screen(&f,&named,b"board",b"spec","thermal").is_err());
let spec=json!({"hotMaxRiseC":20,"hot":{"D10":{"watts":0.07,"cooling":"leads"}}});
field_screen(&f,&spec,b"board",b"spec","thermal").unwrap();
let mut hot=f.clone();hot["chips"][0]["riseC"]=json!(25.0);
assert!(field_screen(&hot,&spec,b"board",b"spec","thermal").unwrap_err().contains("rise budget"));
let mut watts=f.clone();watts["chips"][0]["watts"]=json!(0.05);
assert!(field_screen(&watts,&spec,b"board",b"spec","thermal").is_err());
let mut tab=f.clone();tab["chips"][0]["tabNet"]=json!("GND");
assert!(field_screen(&tab,&spec,b"board",b"spec","thermal").is_err());
// a claimed junction limit: 25 + 4.05 + 300 K/W x 0.07 W = 50.05 C
let mut tj=spec.clone();tj["hot"]["D10"]["tjMaxC"]=json!(50);tj["hot"]["D10"]["thetaJcKPerW"]=json!(300);tj["hot"]["D10"]["thetaSource"]=json!("datasheet");
assert!(field_screen(&f,&tj,b"board",b"spec","thermal").unwrap_err().contains("junction"));
tj["hot"]["D10"]["tjMaxC"]=json!(85);
field_screen(&f,&tj,b"board",b"spec","thermal").unwrap();
}
#[test]
fn individual_thermal_limit_and_rounding_allowance_are_enforced() {
let spec=json!({"hotMaxRiseC":35,"hot":{"Q1":{"watts":5,"tabNet":"VCC","maxRiseC":15}}});
field_screen(&thermal_report(),&spec,b"board",b"spec","thermal").unwrap();
for rise in [15.0,20.0] {
let mut f=thermal_report();f["chips"][0]["riseC"]=json!(rise);
assert!(field_screen(&f,&spec,b"board",b"spec","thermal").unwrap_err().contains("15 C rise budget"));
}
let mut tighter=spec.clone();tighter["hot"]["Q1"]["maxThetaCPerW"]=json!(2);
assert!(field_screen(&thermal_report(),&tighter,b"board",b"spec","thermal").is_err());
}
#[test]
fn thermal_coverage_requires_full_heat_finite_values_and_convergence() {
let spec=json!({"hotMaxRiseC":35,"hot":{"Q1":{"watts":5,"tabNet":"VCC"}}});
for (key,val) in [("watts",json!(1)),("tabNet",json!("GND")),("riseC",Value::Null),("riseMovingC",json!(-1))] {
let mut f=thermal_report();f["chips"][0][key]=val;
assert!(field_screen(&f,&spec,b"board",b"spec","thermal").is_err());
}
for key in ["stillResidual","movingResidual"] {
let mut f=thermal_report();f["thermalConvergence"][key]=json!(0.01);
assert!(field_screen(&f,&spec,b"board",b"spec","thermal").is_err());
}
}
#[test]
fn lean_report_with_detail_passes_the_same_screen_as_inline() {
// issue #94: adom-fields 0.10+ moves platedConnections to detail.json beside a lean fields.json
let spec=json!({"loads":{"VCC":{"amps":2}}});let mut f=report();
f["nets"][0]["layers"]=json!(["F.Cu","B.Cu"]);f["nets"][0]["vias"]=json!(1);
let pc=json!([{"from":"F.Cu","to":"B.Cu","amps":2,"barrelAreaMm2":0.05,"meanBarrelJ":40,"resolvedPerimeterFraction":1}]);
f["nets"][0]["platedConnections"]=pc.clone();
field_screen(&f,&spec,b"board",b"spec","current").unwrap();
let mut lean=f.clone();lean["nets"][0]["platedConnections"]=json!("detail.json");lean["detail"]=json!({"file":"detail.json"});
assert!(field_screen(&lean,&spec,b"board",b"spec","current").unwrap_err().contains("no plated-connection report"));
let d=std::env::temp_dir().join(format!("aiflow-lean-screen-{}",std::process::id()));let _=std::fs::remove_dir_all(&d);std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("fields.json"),lean.to_string()).unwrap();
std::fs::write(d.join("detail.json"),json!({"platedConnections":{"VCC":pc}}).to_string()).unwrap();
let merged=fields_report::load(&d.join("fields.json")).unwrap();
field_screen(&merged,&spec,b"board",b"spec","current").unwrap();
std::fs::write(d.join("fields.json"),f.to_string()).unwrap();
field_screen(&fields_report::load(&d.join("fields.json")).unwrap(),&spec,b"board",b"spec","current").unwrap();
std::fs::remove_file(d.join("detail.json")).unwrap();
std::fs::write(d.join("fields.json"),lean.to_string()).unwrap();
assert!(fields_report::load(&d.join("fields.json")).unwrap_err().contains("detail.json"));
}
#[test]
fn overloaded_or_inconsistent_barrel_fails_even_without_issue_text() {
let spec=json!({"loads":{"VCC":{"amps":2}}});let mut f=report();
f["nets"][0]["layers"]=json!(["F.Cu","B.Cu"]);f["nets"][0]["vias"]=json!(1);
f["nets"][0]["platedConnections"]=json!([{"from":"F.Cu","to":"B.Cu","amps":2,"barrelAreaMm2":0.05,"meanBarrelJ":40,"resolvedPerimeterFraction":1}]);
field_screen(&f,&spec,b"board",b"spec","current").unwrap();
for (key,val) in [("barrelAreaMm2",json!(0.01)),("meanBarrelJ",json!(0)),("resolvedPerimeterFraction",json!(0)),("amps",Value::Null)] {
let mut bad=f.clone();bad["nets"][0]["platedConnections"][0][key]=val;
assert!(field_screen(&bad,&spec,b"board",b"spec","current").is_err());
}
f["issues"]=json!([{"kind":"current-barrel","what":"unresolved barrel finding"}]);
assert!(field_screen(&f,&spec,b"board",b"spec","current").unwrap_err().contains("unresolved barrel"));
}
#[test]
fn display_clipped_or_rounded_peaks_cannot_pass_acceptance() {
let spec=json!({"loads":{"VCC":{"amps":2}}});
for definition in [Value::Null, json!({"statistic":"percentile"}),
json!({"statistic":"unclipped-cell-maximum","rounded":true,"minimumCopperFraction":0.25}),
json!({"statistic":"unclipped-cell-maximum","rounded":false,"minimumCopperFraction":0.5})] {
let mut f=report();f["peakJDefinition"]=definition;
assert!(field_screen(&f,&spec,b"board",b"spec","current").unwrap_err().contains("unclipped"));
}
let mut f=report(); f["nets"][0]["peakJ"]=json!(60.04);
assert!(field_screen(&f,&spec,b"board",b"spec","current").is_err());
}
#[test]
fn an_acknowledged_peak_is_covered_and_an_unacknowledged_one_still_fails() {
let spec=json!({"loads":{"VCC":{"amps":2}}});
let mut f=report(); f["nets"][0]["peakJ"]=json!(67.0); f["nets"][0]["peakAt"]=json!([10.0,9.0]); f["nets"][0]["peakLayer"]=json!("F.Cu");
f["issues"]=json!([{"kind":"current-neck","net":"VCC","at":[10.0,9.0],"what":"VCC: 67 A/mm2 at its worst point"}]);
assert!(field_screen(&f,&spec,b"board",b"spec","current").unwrap_err().contains("--ack"));
let ack=json!({"net":"VCC","target":"VCC@10,9","location":[10.0,9.0],"peakJ":67.0,"why":"part land","aiThread":"t"});
let covered=field_screen_with_acks(&f,&spec,b"board",b"spec","current",&[ack.clone()]).unwrap();
assert_eq!(covered.len(),1); assert_eq!(covered[0]["peakJ"],67.0);
// elsewhere on the net, another net, or a barrel finding: not covered
let mut moved=f.clone(); moved["nets"][0]["peakAt"]=json!([14.0,9.0]); moved["issues"][0]["at"]=json!([14.0,9.0]);
assert!(field_screen_with_acks(&moved,&spec,b"board",b"spec","current",&[ack.clone()]).is_err());
let mut other=ack.clone(); other["net"]=json!("GND");
assert!(field_screen_with_acks(&f,&spec,b"board",b"spec","current",&[other]).is_err());
let mut barrel=f.clone(); barrel["issues"]=json!([{"kind":"current-barrel","net":"VCC","at":[10.0,9.0],"what":"barrel over"}]);
assert!(field_screen_with_acks(&barrel,&spec,b"board",b"spec","current",&[ack]).unwrap_err().contains("barrel"));
}
#[test]
fn nonconvergence_underload_and_suppressed_neck_issue_cannot_pass() {
let spec=json!({"loads":{"VCC":{"amps":2}}});
for (key,value) in [("residual",json!(0.01)),("residual",Value::Null),("amps",json!(1)),("peakJ",json!(61))] {
let mut f=report();f["nets"][0][key]=value;
assert!(field_screen(&f,&spec,b"board",b"spec","current").is_err());
}
}
const B2: &[u8] = br#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (2 "B.Cu" signal)))"#;
const B4: &[u8] = br#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (4 "In1.Cu" power) (6 "In2.Cu" power) (2 "B.Cu" signal)))"#;
fn branch_report() -> Value { let mut f=report(); f["inputIdentity"]["board"]=json!(format!("{:x}",Sha256::digest(B2))); f }
#[test]
fn explicit_branch_screen_requires_matching_pin_boundary_conditions() {
let pins=json!([{"reference":"J1","pad":"1","amps":2},{"reference":"U1","pad":"1","amps":-2}]);
let spec=json!({"copperUm":35,"branchCurrentModel":{"boardThicknessMm":1.6},"loads":{"VCC":{"amps":2,"currentModel":"resistive-branches","terminals":pins}}});
let mut f=branch_report();assert!(field_screen(&f,&spec,B2,b"spec","current").is_err());
f["terminalAssignments"]=json!({"VCC":{"mode":"explicit","pins":pins}});
f["board"]=json!({"layers":["F.Cu","B.Cu"],"copperUm":35,"thicknessMm":1.6});
assert!(field_screen(&f,&spec,B2,b"spec","current").is_ok());
f["terminalAssignments"]["VCC"]["pins"][1]["pad"]=json!("2");
assert!(field_screen(&f,&spec,B2,b"spec","current").is_err());
}
#[test]
fn repeated_pin_boundaries_require_each_physical_selector_exactly_once() {
let pins=json!([{"reference":"J1","pad":"1","amps":2},
{"reference":"D1","pad":"2","amps":-1,"at":[1.0,2.0]},
{"reference":"D1","pad":"2","amps":-1,"at":[1.0,4.0]}]);
let spec=json!({"copperUm":35,"branchCurrentModel":{"boardThicknessMm":1.6},"loads":{"VCC":{"amps":2,"currentModel":"resistive-branches","terminals":pins}}});
let mut f=branch_report();f["terminalAssignments"]=json!({"VCC":{"mode":"explicit","pins":pins}});
f["board"]=json!({"layers":["F.Cu","B.Cu"],"copperUm":35,"thicknessMm":1.6});
field_screen(&f,&spec,B2,b"spec","current").unwrap();
f["terminalAssignments"]["VCC"]["pins"].as_array_mut().unwrap().reverse();
field_screen(&f,&spec,B2,b"spec","current").unwrap();
f["terminalAssignments"]["VCC"]["pins"][1]["at"]=json!([1,2]);
field_screen(&f,&spec,B2,b"spec","current").unwrap();
for at in [Value::Null,json!([1.0,4.0]),json!([1.0,2.1])] {
let mut bad=f.clone();bad["terminalAssignments"]["VCC"]["pins"][1]["at"]=at;
assert!(field_screen(&bad,&spec,B2,b"spec","current").is_err());
}
let mut invalid=spec.clone();invalid["loads"]["VCC"]["terminals"][1]["at"]=json!(["x",2]);
f["terminalAssignments"]["VCC"]["pins"]=invalid["loads"]["VCC"]["terminals"].clone();
assert!(field_screen(&f,&invalid,B2,b"spec","current").is_err());
}
#[test]
fn branch_screen_accepts_a_solve_of_the_boards_own_multilayer_stack() {
let pins=json!([{"reference":"J1","pad":"1","amps":2},{"reference":"U1","pad":"1","amps":-2}]);
let spec=json!({"copperUm":35,"branchCurrentModel":{"boardThicknessMm":1.6},"loads":{"VCC":{"amps":2,"currentModel":"resistive-branches","terminals":pins}}});
let mut f=report();f["inputIdentity"]["board"]=json!(format!("{:x}",Sha256::digest(B4)));
f["terminalAssignments"]=json!({"VCC":{"mode":"explicit","pins":pins}});
// adom-fields 0.10: six fixed slots in `layers`, the real stack in `copperLayers`
f["board"]=json!({"layers":["F.Cu","In1.Cu","In2.Cu","In3.Cu","In4.Cu","B.Cu"],"copperLayers":["F.Cu","In1.Cu","In2.Cu","B.Cu"],"copperUm":35,"thicknessMm":1.6});
field_screen(&f,&spec,B4,b"spec","current").unwrap();
f["board"]["copperLayers"]=json!(["F.Cu","B.Cu"]);
assert!(field_screen(&f,&spec,B4,b"spec","current").unwrap_err().contains("copper stack"));
}
/// adom-fields 0.10.6: a body meant to touch the board that touches no copper takes 0 W, so the
/// temperatures read cooler or hotter than the board really runs. That is a false thermal result.
/// The medium kind, a body with no heat and no path to the board, can be deliberate and does not stop it.
#[test]
fn a_body_that_never_reaches_the_board_stops_the_thermal_screen() {
let spec=json!({"hotMaxRiseC":20,"loads":{"VCC":{"amps":2}},"hot":{"Q1":{"watts":5,"tabNet":"VCC"}}});
let mut f=thermal_report();
assert!(field_screen(&f,&spec,b"board",b"spec","thermal").is_ok(), "the report itself is clean");
f["issues"]=json!([{"severity":"high","kind":"solid-uncoupled","what":"carrier: boardContactM2kW is set, but no face is on the copper"}]);
assert!(field_screen(&f,&spec,b"board",b"spec","thermal").unwrap_err().contains("carrier"));
assert!(field_screen(&f,&spec,b"board",b"spec","current").is_ok(), "not a current finding");
f["issues"]=json!([{"severity":"medium","kind":"solid-uncoupled","what":"shield: no heat of its own and no path to the board"}]);
assert!(field_screen(&f,&spec,b"board",b"spec","thermal").is_ok());
}
#[test]
fn fresh_solve_requires_identity_coverage_and_no_relevant_findings() {
let spec=serde_json::json!({"loads":{"VCC":{"amps":2}},"hot":{"Q1":{"watts":1}}});
assert!(field_screen(&report(),&spec,b"board",b"spec","current").is_ok());
assert!(field_screen(&report(),&spec,b"changed",b"spec","current").is_err());
let mut f=report();f["nets"]=serde_json::json!([]);
assert!(field_screen(&f,&spec,b"board",b"spec","current").unwrap_err().contains("VCC"));
let mut f=report();f["issues"]=serde_json::json!([{"kind":"tab-rise","what":"Q1 exceeds its rise budget"}]);
assert!(field_screen(&f,&spec,b"board",b"spec","thermal").is_err());
assert!(field_screen(&f,&spec,b"board",b"spec","current").is_ok());
}
}
/// A board with no specified positive loads/losses has no corresponding field screen to run.
pub fn needs_field_screen(spec:&Value,kind:&str)->bool {
let (group,demand)=if kind=="current"{("loads","amps")}else{("hot","watts")};
spec[group].as_object().into_iter().flatten().any(|(_,v)|v[demand].as_f64().unwrap_or(0.0)>0.0)
}