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.
← Commit history
Add explicit branch-current screening with mandatory converged Fields validation
15 files changed
+601−17
Cargo.lock+11−10Cargo.toml+1−1SKILL.md+4bin/adom-aiflowcrates/adom-aiflow/src/main.rs+1−1crates/aiflow-analyze/Cargo.toml+2crates/aiflow-analyze/examples/branch_screen.rs+22crates/aiflow-analyze/src/branches.rs+465crates/aiflow-analyze/src/lib.rs+67−3docs/branch-current-screen.md+11docs/release-0.1.79.md+7package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+4skills/aiflow-measurement/SKILL.md+4Cargo.lock+11−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.78"+version = "0.1.79" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -24,8 +24,9 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.78"+version = "0.1.79" dependencies = [+ "aiflow-board", "serde", "serde_json", "sha2",@@ -33,7 +34,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.78"+version = "0.1.79" dependencies = [ "roxmltree", "serde",@@ -42,7 +43,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.78"+version = "0.1.79" dependencies = [ "aiflow-board", "serde",@@ -51,7 +52,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.78"+version = "0.1.79" dependencies = [ "aiflow-board", "aiflow-grid",@@ -61,7 +62,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.78"+version = "0.1.79" dependencies = [ "aiflow-board", "serde",@@ -70,7 +71,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.78"+version = "0.1.79" dependencies = [ "aiflow-board", "serde",@@ -79,7 +80,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.78"+version = "0.1.79" dependencies = [ "aiflow-board", "aiflow-copper",@@ -89,7 +90,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.78"+version = "0.1.79" dependencies = [ "aiflow-board", "aiflow-grid",@@ -99,7 +100,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.78"+version = "0.1.79" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.78"+version = "0.1.79" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+4@@ -369,3 +369,7 @@ The dashboard adapts to early design stages without requiring a board. Optional schematic, simulation or board images belong in `<run>/timeline/presentation.json` with an accurate caption and a same-origin image filename; see `tools/timeline/README.md`. Do not present a saved capture as live state or a workflow completion as hardware validation.++## Explicit branch currents++When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+1−1@@ -2963,7 +2963,7 @@ fn main() { let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None }; let name = format!("analyze-{what}"); r.stage_start(&name, Some("binary"), None);- let (heuristic_passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now))), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };+ let (heuristic_passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current_on_board(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now)), &board_now), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) }; let fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json")); let screen=if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(()) } else if fields_file.is_file() { let f=read_json(&fields_file.display().to_string());
crates/aiflow-analyze/Cargo.toml+2@@ -10,3 +10,5 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = "0.10"++aiflow-board = { path = "../aiflow-board" }
crates/aiflow-analyze/examples/branch_screen.rsadded+22@@ -0,0 +1,22 @@+use serde_json::{json, Value};+fn main() {+ let a: Vec<_> = std::env::args().collect();+ let b = aiflow_board::Board::load(&a[1]).unwrap();+ let spec: Value = serde_json::from_slice(&std::fs::read(&a[2]).unwrap()).unwrap();+ let mut result = Vec::new();+ for (net, load) in spec["loads"].as_object().unwrap() {+ if load["currentModel"] == "resistive-branches" {+ match aiflow_analyze::branches::screen(&b, net, load, &spec) {+ Ok(r) => result.push(r),+ Err(e) => {+ eprintln!("{net}: {e}");+ std::process::exit(1);+ }+ }+ }+ }+ println!(+ "{}",+ serde_json::to_string_pretty(&json!({"results":result})).unwrap()+ );+}
crates/aiflow-analyze/src/branches.rsadded+465@@ -0,0 +1,465 @@+//! Optional centerline resistor screen for explicit pin currents on unpoured two-layer nets.+//! Filled-copper Fields remains mandatory. This is not a pad/contact thermal model.+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())+}++pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Value, String> {+ let layers: Vec<_> = board+ .root+ .find("layers")+ .ok_or("missing physical copper layer table")?+ .nodes()+ .filter_map(|n| n.atom(1))+ .filter(|l| l.ends_with(".Cu"))+ .collect();+ if layers != ["F.Cu", "B.Cu"] {+ return Err("branch model requires exactly F.Cu and B.Cu".into());+ }+ for v in board.root.find_all("via") {+ 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("branch model supports only through vias".into());+ }+ }+ 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",+ )?;+ if board.zones().iter().any(|z| z.net == net) {+ return Err("branch model does not support pours; use filled-copper analysis".into());+ }+ 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")?;+ if declarations.insert((reference, pad), a).is_some() {+ return Err("duplicate terminal assignment".into());+ }+ if pads+ .iter()+ .filter(|p| p.reference == reference && p.name == pad)+ .count()+ != 1+ {+ return Err(format!(+ "{reference}.{pad}: terminal is missing, ambiguous or on another net"+ ));+ }+ 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 p in &pads {+ if !declarations.contains_key(&(p.reference.as_str(), p.name.as_str())) {+ return Err(format!(+ "declare current for {}.{} (zero for an unloaded pad)",+ p.reference, p.name+ ));+ }+ }+ let tracks: Vec<_> = board+ .tracks()+ .into_iter()+ .filter(|t| t.net == net)+ .collect();+ if tracks.is_empty() {+ return Err("no tracks for branch screen".into());+ }+ 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 0..2 {+ if p.on_layer(l) {+ candidates.insert(key(l, (p.x, p.y)));+ }+ }+ }+ for v in board.vias().iter().filter(|v| v.net == net) {+ for l in 0..2 {+ 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":if t.layer==0{"F.Cu"}else{"B.Cu"},"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 0..2 {+ if p.on_layer(l) {+ let center = key(l, (p.x, p.y));+ for k in &candidates {+ if k.0 == l+ && 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);+ edges.push(Edge {+ a: key(0, (x, y)),+ b: key(1, (x, y)),+ r: thick / (58000. * area),+ width: None,+ meta,+ });+ 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()+ .filter(|p| declarations[&(p.reference.as_str(), p.name.as_str())] != 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 p in &active {+ let l = if p.thru {+ let l = (mask >> j) & 1;+ j += 1;+ l+ } else if p.on_f {+ 0+ } else {+ 1+ };+ *injection+ .entry(root(key(l, (p.x, p.y)), &parents))+ .or_insert(0.) += declarations[&(p.reference.as_str(), p.name.as_str())];+ }+ 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(), true);+ worst = worst.max(rise);+ r["riseEstimateC"] = json!(rise);+ } else {+ let density = current.abs()+ / e.meta["barrelAreaMm2"]+ .as_f64()+ .ok_or("missing barrel area")?;+ peak_barrel = peak_barrel.max(density);+ r["barrelDensityAperMm2"] = json!(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,"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, ideal 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}]}}})+ }+ #[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 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());+ }+}
crates/aiflow-analyze/src/lib.rs+67−3@@ -4,6 +4,7 @@ //! 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 branches; use std::collections::BTreeMap; use serde_json::{json, Value};@@ -131,6 +132,24 @@ pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, p (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" { 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>) { let widths = plan_widths(plan);@@ -216,8 +235,34 @@ pub fn field_screen(fields: &Value, spec: &Value, board_bytes: &[u8], spec_bytes 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 && !entries.iter().any(|r|r[key].as_str()==Some(name.as_str())) {- return Err(format!("Fields did not solve {group}.{name}; resolve its missing terminals or copper and rerun analyze"));+ 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 == "current" {+ 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 || peak.unwrap()>density_limit {+ return Err(format!("Fields {name} peak current density exceeds the {density_limit} A/mm2 screen"));+ }+ 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")?;+ if wanted.len()!=actual.len() || !wanted.iter().all(|w| actual.iter().any(|a|a["reference"]==w["reference"] && a["pad"]==w["pad"] && a["amps"].as_f64()==w["amps"].as_f64())) {+ return Err(format!("Fields {name} terminal assignments differ from the branch model"));+ }+ if fields["board"]["layers"] != json!(["F.Cu","B.Cu"]) { return Err("branch screening requires a physical two-layer Fields solve".into()); }+ 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")?;@@ -230,7 +275,26 @@ pub fn field_screen(fields: &Value, spec: &Value, board_bytes: &[u8], spec_bytes mod field_screen_tests { use super::*; use sha2::{Digest,Sha256};- fn report() -> Value { serde_json::json!({"inputIdentity":{"board":format!("{:x}",Sha256::digest(b"board")),"spec":format!("{:x}",Sha256::digest(b"spec"))},"nets":[{"net":"VCC"}],"chips":[{"reference":"Q1"}],"issues":[]}) }+ fn report() -> Value { serde_json::json!({"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}],"chips":[{"reference":"Q1"}],"issues":[]}) }+ #[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());+ }+ }+ #[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=report();assert!(field_screen(&f,&spec,b"board",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,b"board",b"spec","current").is_ok());+ f["terminalAssignments"]["VCC"]["pins"][1]["pad"]=json!("2");+ assert!(field_screen(&f,&spec,b"board",b"spec","current").is_err());+ } #[test] fn fresh_solve_requires_identity_coverage_and_no_relevant_findings() { let spec=serde_json::json!({"loads":{"VCC":{"amps":2}},"hot":{"Q1":{"watts":1}}});
docs/branch-current-screen.mdadded+11@@ -0,0 +1,11 @@+# Explicit branch-current screening++A voltage-sense branch must not inherit the entire net's load current. For a saved, unpoured two-layer net, a load can opt into `"currentModel": "resistive-branches"` and declare signed `terminals` with `reference`, `pad`, and `amps`. Positive current enters copper; negative current leaves it. Declare every pad on that net, including zero-current test pads, and balance both source and sink totals to the load's `amps`.++The spec must state `copperUm` and `branchCurrentModel` with `boardThicknessMm`, `barrelPlatingUm` and `maxBarrelDensityAperMm2`. These are engineering assumptions that require fabrication verification. Barrel plating must currently be 25 um to match the paired Fields solver; other plating is refused until that solver supports it. The screen constructs a resistive track/barrel graph, applies the declared terminal currents, checks Kirchhoff residuals, estimates each track's IPC-2221 rise at its calculated branch current and checks barrel density. It evaluates every layer-injection extreme for up to four active through-hole terminals. A narrow main series segment still fails; a lightly loaded sense branch is evaluated at its own current.++Only actual saved-board tracks may use this model. Planned copper must be landed and adopted first. Pours, unsupported layers/pad shapes/vias/arcs, overlapping collinear tracks, unsegmented interior crossings, missing declarations and disconnected graphs refuse the branch result. Existing full-net width results remain attached for comparison. Other nets retain the existing conservative screen.++This is a nominal centerline resistor model with equipotential central pad regions. It does not resolve pad necks, contact resistance, copper overlap redistribution, package contacts, temperature-dependent conductivity or transient heating. An independent filled-copper Fields solve remains mandatory, with exact input hashes, finite converged results, full specified current, explicit matching terminals and the actual two-layer stack. Current density is independently checked against `maxCurrentDensityAperMm2` (60 A/mm² default), even if a Fields invocation suppressed its neck issue by using a larger threshold.++Do not infer a hardware rating or a thermal pass. A model passing under stated assumptions does not validate those assumptions. A software release or an isolated passing run does not close the live project gates; review its actual boundary conditions, native state and complete deliverables separately.
docs/release-0.1.79.mdadded+7@@ -0,0 +1,7 @@+# 0.1.79: explicit branch-current screen++An optional pin-current resistor model distinguishes lightly loaded sense branches and parallel paths from true series bottlenecks. It requires explicit balanced terminal currents, complete pin declarations, actual unpoured two-layer board geometry and material assumptions. Track heating and barrel density are checked across through-terminal layer extremes. Unsupported or ambiguous geometry refuses analysis.++The ordinary full-net width screen is retained, including its narrow-series regression. Branch results remain ANDed with a matching Fields report. Field validation now refuses unconverged, nonfinite, underloaded and over-density results even when their issue list is empty; branch mode also requires matching explicit terminal assignments and a physical two-layer solve. The electronic-load source-candidate diagnostic passes current in an isolated run, while its live current/thermal/finish acceptance remains separate.++No host watchdog, automatic chat restart, thermal waiver or hardware qualification is added. All existing CLI commands and the continuation guard remain.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.78",+ "version": "0.1.79", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.78",+ "version": "0.1.79", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+4@@ -335,3 +335,7 @@ For high-speed nets, explicitly define capacitance or differential-pair checks w `land pours` delegates the complete owned-zone plan to KiCad Bridge in one native transaction. Never replace another tool's zones or infer ownership from names, areas or absence in the intake board. Use Bridge 1.0.24+; no legacy delete/create fallback. An unchanged plan is not a DRC-clean certificate. Refill acknowledgement is asynchronous. Request it once, then read without refill until two delayed revision/area snapshots agree. This proves observed stability, not a native completion event. Do not use the acknowledgement's old geometry; do not replay a timed-out or post-commit-failed mutation. Inspect the saved response, exact board and current revision. Measurement failure must remain a failed outcome. See docs/native-zone-landing.md.++## Explicit branch currents++When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification.
skills/aiflow-measurement/SKILL.md+4@@ -61,3 +61,7 @@ A long interval without ledger events is **unobserved**, not proof the human was Custom manual stages default to AI. `take custom-stage=ai` or `take custom-stage=binary` records an explicit owner. Before closing a session or emitting a final checkpoint, run `adom-aiflow finish-guard --json` with the active run. A continue verdict means resume work in the same turn. `done` refuses intermediate closure; explicit user-pause/external-dependency evidence is required for an incomplete handoff. See the main AIFlow skill.++## Explicit branch currents++When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification.