← Commit history
Cargo.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.