← All Pull Requests

Support explicit balanced pin currents and reject partial loaded-net solves #6

Closed opened by John Lauer
Merges explicit-pin-current-boundaries → fix-physical-two-layer-stack

Fields can infer the wrong terminals for serial fuse/diode/shunt nets and assign a sense branch an inappropriate share. This adds optional signed pin-current assignments to each load. Sources and sinks must each balance to the declared load, pins must resolve uniquely on that net, and missing copper or an unsolved loaded net refuses publication of a partial report. Reports distinguish explicit from heuristic allocation.

Stacked dependency: PR #5, branch fix-physical-two-layer-stack, exact base a12b30f6cd5bf87af73cae2e62420529c5b14449. This PR targets that branch and contains only the terminal feature; do not treat it as a released build. Main remains b8aecfc5de87ca3a8b09ebbd4a82a8c9b416988f. Existing physical-stack, Fusion, signals and heat-block behavior is preserved.

Validation: production cargo check, 25 combined tests, release build, pinned viewer unchanged, combined immutable-main preflight without problems, and reconstruction of all five submitted files from the contextual branch diff. Tests include separate main/sense currents, wrong-net/missing/duplicate/unbalanced pins, ambiguous pad numbers, current-reversal invariance and a missing-copper sink.

Actual saved electronic-load board diagnostic at 0.2 mm: six nets converge below 1e-7 residual, peak density 57.9 A/mm² at 3 A on SHUNT_P. A second solve includes the divider and nominal INA226 bus input; same peak. Three thermal failures remain. These are isolated source-candidate experiments, not acceptance or shared runtime deployment. No PCB geometry changed.

Limits: omitted pins draw zero modeled current; review circuit and fixture boundary conditions. Legacy heuristic allocation remains when no terminals are supplied. Injection retains its existing 1 mm copper-neighborhood approximation; exact package contacts, mesh sensitivity, pulse currents and the external MOSFET heat path remain separate work. The load project has not passed AIFlow current, thermal or finish gates. No source merge, package publication or runtime replacement is requested without owner review.

Diff Skip to comments (1)

README.md+8
@@ -112,3 +112,11 @@ Every DC net must converge before a summary is published, even when the thermal Plated through-hole pads (including circular and oblong KiCad drills) connect copper layers in both electrical and thermal models. They are counted separately as `platedPads`; routing-via counts retain their original meaning. Barrel conduction assumes 25 µm plating, not a measured fabrication result. Omitting these pad barrels can falsely isolate a connector pad even when native DRC reports full connectivity.  `dcMaxIterations` defaults to 6000 and can be explicitly increased up to 100000 for diagnostic convergence work. This does not change the acceptance tolerance. Reported solver residuals are recomputed from A*x-b, rather than trusting only the internal CG recurrence. Retain failed runs and compare mesh sensitivity; extra iterations cannot repair missing geometry or justify a power rating.++## Explicit pin currents (source candidate)++Each `loads.<net>` may supply `terminals`, an array of `{ "reference": "J1", "pad": "1", "amps": 3.0 }` rows. Positive current enters the board copper; negative current leaves it. Positive and negative totals must each equal that net's `amps`. Include sense-branch current separately instead of assigning the entire load to every narrow track. Pin identifiers must resolve uniquely on the specified net; duplicate, missing, wrong-net and unbalanced assignments are rejected. Pads omitted from this explicit boundary condition draw zero modeled current, so this is an engineering assumption to review against the circuit, including fixture return paths and auxiliary loads. Repeated pad numbers currently require a unique physical pad and are rejected when ambiguous.++Reports retain the declared pins in `terminalAssignments` and label every net as `explicit` or `heuristic`. Legacy loads without the array retain the existing terminal heuristic. A loaded net that cannot be solved now prevents publishing a partial result. No partial current injection is accepted if any nonzero terminal has no resolved copper.++This change does not replace the current injection's existing 1 mm neighborhood approximation with an exact package contact model. Refine the mesh and review pad geometry and boundary conditions before accepting density values. It also does not model the external MOSFET heatsink or certify thermal performance. This candidate is stacked on physical-stack PR #5; neither change is a deployed shared release yet.
SKILL.md+8
@@ -104,3 +104,11 @@ Every DC net must converge before a summary is published, even when the thermal Plated through-hole pads (including circular and oblong KiCad drills) connect copper layers in both electrical and thermal models. They are counted separately as `platedPads`; routing-via counts retain their original meaning. Barrel conduction assumes 25 µm plating, not a measured fabrication result. Omitting these pad barrels can falsely isolate a connector pad even when native DRC reports full connectivity.  `dcMaxIterations` defaults to 6000 and can be explicitly increased up to 100000 for diagnostic convergence work. This does not change the acceptance tolerance. Reported solver residuals are recomputed from A*x-b, rather than trusting only the internal CG recurrence. Retain failed runs and compare mesh sensitivity; extra iterations cannot repair missing geometry or justify a power rating.++## Explicit pin currents (source candidate)++Each `loads.<net>` may supply `terminals`, an array of `{ "reference": "J1", "pad": "1", "amps": 3.0 }` rows. Positive current enters the board copper; negative current leaves it. Positive and negative totals must each equal that net's `amps`. Include sense-branch current separately instead of assigning the entire load to every narrow track. Pin identifiers must resolve uniquely on the specified net; duplicate, missing, wrong-net and unbalanced assignments are rejected. Pads omitted from this explicit boundary condition draw zero modeled current, so this is an engineering assumption to review against the circuit, including fixture return paths and auxiliary loads. Repeated pad numbers currently require a unique physical pad and are rejected when ambiguous.++Reports retain the declared pins in `terminalAssignments` and label every net as `explicit` or `heuristic`. Legacy loads without the array retain the existing terminal heuristic. A loaded net that cannot be solved now prevents publishing a partial result. No partial current injection is accepted if any nonzero terminal has no resolved copper.++This change does not replace the current injection's existing 1 mm neighborhood approximation with an exact package contact model. Refine the mesh and review pad geometry and boundary conditions before accepting density values. It also does not model the external MOSFET heatsink or certify thermal performance. This candidate is stacked on physical-stack PR #5; neither change is a deployed shared release yet.
src/fields.rs+49−9
@@ -30,7 +30,7 @@ pub struct Spec { fn d6000() -> usize { 6000 } fn d35() -> f64 { 35.0 } fn d60() -> f64 { 60.0 }-#[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64> }+#[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64>, #[serde(default)] pub terminals: Option<Vec<crate::terminals::Terminal>> } #[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, pub tabNet: String }  #[derive(Serialize, Clone)]@@ -123,7 +123,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu                 chips = if rest.is_empty() { cands.iter().cloned().filter(|p| p.reference != big).collect() } else { rest };             }         }-        if ext.is_empty() || chips.is_empty() { log(&format!("{net}: no terminals found (needs pads of two parts on the net), skipped")); continue; }+        if load.terminals.is_none() && (ext.is_empty() || chips.is_empty()) { log(&format!("{net}: no terminals found (needs pads of two parts on the net), skipped")); continue; }         let mut area_by_ref: BTreeMap<String, f64> = BTreeMap::new();         for p in &chips { *area_by_ref.entry(p.reference.clone()).or_default() += p.area.max(0.05); }         let area_total: f64 = area_by_ref.values().sum();@@ -143,15 +143,30 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             spots         };         let (mut hs, mut hk) = (0usize, 0usize);-        let per_ext = load.amps / ext.len() as f64;-        for p in &ext { let sp = spots_of(p); if sp.is_empty() { continue; } hs += 1; for (a, r, c) in &sp { rhs[a * n + r * w + c] += per_ext / sp.len() as f64; } }+        let assignments: Vec<(&crate::board::Pad, f64)> = if let Some(rows) = &load.terminals {+            match crate::terminals::resolve(b, net, load.amps, rows) {+                Ok(rows) => rows,+                Err(error) => { log(&error); continue; }+            }+        } else {+            let mut rows: Vec<_> = ext.iter().map(|p| (*p, load.amps / ext.len() as f64)).collect();+            for p in &chips {+                let share = load.amps * area_by_ref[&p.reference] / area_total / chips.iter().filter(|q| q.reference == p.reference).count() as f64;+                rows.push((*p, -share));+            }+            rows+        };         let mut pinned = false;-        for p in &chips {-            let share = load.amps * area_by_ref[&p.reference] / area_total / chips.iter().filter(|q| q.reference == p.reference).count() as f64;-            let sp = spots_of(p); if sp.is_empty() { continue; } hk += 1;-            for (a, r, c) in &sp { rhs[a * n + r * w + c] -= share / sp.len() as f64; }-            if !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; }+        let mut missing = false;+        for (p, amps) in &assignments {+            if *amps == 0.0 { continue; }+            let sp = spots_of(p);+            if sp.is_empty() { missing = true; break; }+            if *amps > 0.0 { hs += 1; } else { hk += 1; }+            for (a, r, c) in &sp { rhs[a * n + r * w + c] += amps / sp.len() as f64; }+            if *amps < 0.0 && !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; }         }+        if missing { log(&format!("{net}: a terminal has no resolved copper; refusing partial current injection")); continue; }         if hs == 0 || hk == 0 { log(&format!("{net}: terminals not on copper (sources {hs}, sinks {hk}), skipped")); continue; }         let (v, it, res) = sys.solve(&rhs, 1e-7, spec.dcMaxIterations);         log(&format!("{net}: {} A, {} layer(s), {it} iterations, residual {res:.1e}", load.amps, nl));@@ -321,6 +336,7 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value     json!({         "board": {"name": spec.board, "x0": b.x0, "y0": b.y0, "widthMm": b.width(), "heightMm": b.height(), "layers": layers, "copperUm": spec.copperUm, "stackupSource":if spec.stackupMm.is_empty() {"legacy-four-layer-assumption"} else {"explicit-spec"}, "dielectricMm":dielectric_mm(spec, layers.len()).expect("validated stackup"), "copperUmByLayer":layers.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": dielectric_mm(spec, layers.len()).expect("validated stackup").iter().sum::<f64>() + layers.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).sum::<f64>(), "cellMm": f.d, "cols": f.w, "rows": f.h},         "nets": nets, "chips": chips, "issues": f.issues,+        "terminalAssignments": spec.loads.iter().map(|(net, load)| (net.clone(), json!({"mode": if load.terminals.is_some() {"explicit"} else {"heuristic"}, "pins": load.terminals}))).collect::<BTreeMap<_,_>>(),         "signals": f.signals, "heatsinks": f.heatsinks, "dcMaxIterations":spec.dcMaxIterations, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual},         "vias": b.vias.iter().map(|v| json!({"net": v.net, "x": v.x, "y": v.y, "size": v.size, "drill": v.drill})).collect::<Vec<_>>(),         "pads": b.pads.iter().filter(|p| spec.hot.contains_key(&p.reference) || spec.loads.contains_key(&p.net)).map(|p| json!({"ref": p.reference, "pad": p.pad, "net": p.net, "x": p.x, "y": p.y})).collect::<Vec<_>>(),@@ -332,6 +348,30 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value     }) } +#[cfg(test)] mod terminal_tests {+    use super::*;+    #[test] fn explicit_fuse_diode_path_is_independent_of_designator_and_current_direction() {+        let mut b = Board { x1: 10., y1: 6., layer_names: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+        b.polys.push(crate::board::Poly { net:"V".into(), layer:"F.Cu".into(), pts:vec![(0.,0.),(10.,0.),(10.,6.),(0.,6.)] });+        for (reference,x) in [("F1",1.5),("D1",8.5),("R24",5.)] {+            b.pads.push(crate::board::Pad { reference:reference.into(), pad:"1".into(), net:"V".into(), x,y:3., layers:vec!["F.Cu".into()],area:1. });+        }+        let mut spec:Spec=serde_json::from_value(json!({"copperUm":35,"stackupMm":[1.53],"loads":{"V":{"amps":3,"terminals":[{"reference":"F1","pad":"1","amps":3},{"reference":"D1","pad":"1","amps":-2.9999},{"reference":"R24","pad":"1","amps":-0.0001}]}}})).unwrap();+        let f=analyze(&b,&spec,0.5,60.,&mut |_|{});+        assert_eq!(f.nets.len(),1);+        validate_current_residual("V",f.nets[0].residual).unwrap();+        assert!(f.nets[0].jouleW > 0.);+        for row in spec.loads.get_mut("V").unwrap().terminals.as_mut().unwrap() { row.amps = -row.amps; }+        let reversed=analyze(&b,&spec,0.5,60.,&mut |_|{});+        assert_eq!(reversed.nets.len(),1);+        assert!((f.nets[0].jouleW-reversed.nets[0].jouleW).abs()<1e-5);+        assert!((f.nets[0].peakJ-reversed.nets[0].peakJ).abs()<0.1);+        b.pads[1].x = 50.;+        let missing=analyze(&b,&spec,0.5,60.,&mut |_|{});+        assert!(missing.nets.is_empty(), "a missing sink must not be silently omitted");+    }+}+ #[cfg(test)] mod thermal_tests {     use super::*;     #[test] fn board_with_block_lowers_tab_rise_and_reports_heat_received() {
src/main.rs+3
@@ -1,5 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};+mod terminals; mod eagle; mod parts; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use std::path::PathBuf;@@ -61,6 +62,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     b.validate_layers()?;     fields::dielectric_mm(&spec, b.layer_names.len())?;     for (layer,t) in &spec.copperUmByLayer { if !b.layer_names.contains(layer) || !t.is_finite() || *t<=0.0 {return Err("copperUmByLayer requires actual board copper layers and positive thicknesses".into());} }+    for (net, load) in &spec.loads { if let Some(rows) = &load.terminals { terminals::resolve(&b, net, load.amps, rows)?; } }     let layers: Vec<&str> = b.layer_names.iter().map(String::as_str).collect();     spec.signals.validate_layers(&layers)?;     for hot in spec.hot.values() {if !hot.watts.is_finite() || hot.watts<0.0 {return Err("hot watts must be finite and nonnegative".into());}}@@ -84,6 +86,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     let t0 = std::time::Instant::now();     let mut lines: Vec<String> = Vec::new();     let f = fields::analyze(&b, &spec, pitch, neck, &mut |s| { eprintln!("{s}"); lines.push(s.to_string()); });+    for (net, load) in &spec.loads { if load.amps > 0.0 && !f.nets.iter().any(|r| r.net == *net) { return Err(format!("{net}: loaded net was not solved; no partial report will be published")); } }     for net in &f.nets { fields::validate_current_residual(&net.net, net.residual)?; }     if !f.heat_residual.is_finite() || f.heat_residual>1e-6 || !f.heat_moving_residual.is_finite() || f.heat_moving_residual>1e-6 {return Err(format!("thermal solve did not converge: still {}, moving {}; refine the model or increase the solve budget",f.heat_residual,f.heat_moving_residual));}     let tex = out.join("tex");
src/terminals.rsadded+62
@@ -0,0 +1,62 @@+//! Explicit signed pin currents. Positive enters copper; negative leaves it.+use crate::board::{Board, Pad};+use serde::{Deserialize, Serialize};+use std::collections::BTreeSet;++#[derive(Clone, Debug, Deserialize, Serialize)]+#[serde(deny_unknown_fields)]+pub struct Terminal {+    pub reference: String,+    pub pad: String,+    pub amps: f64,+}++pub fn resolve<'a>(board: &'a Board, net: &str, amps: f64, rows: &[Terminal])+    -> Result<Vec<(&'a Pad, f64)>, String>+{+    if rows.is_empty() { return Err(format!("{net}: explicit terminals must not be empty")); }+    let mut seen = BTreeSet::new();+    let mut result = Vec::new();+    let (mut positive, mut negative) = (0.0, 0.0);+    for t in rows {+        if !t.amps.is_finite() || !seen.insert((&t.reference, &t.pad)) {+            return Err(format!("{net}: terminal currents must be finite and pin assignments unique"));+        }+        let pads: Vec<_> = board.pads.iter().filter(|p| p.reference == t.reference && p.pad == t.pad).collect();+        if pads.len() != 1 || pads[0].net != net {+            return Err(format!("{net}: {}.{} must identify exactly one pad on this net", t.reference, t.pad));+        }+        positive += t.amps.max(0.0);+        negative += (-t.amps).max(0.0);+        result.push((pads[0], t.amps));+    }+    let tolerance = 1e-9_f64.max(amps.abs() * 1e-8);+    if !amps.is_finite() || amps < 0.0 || (positive - amps).abs() > tolerance || (negative - amps).abs() > tolerance {+        return Err(format!("{net}: sources ({positive} A) and sinks ({negative} A) must each equal load ({amps} A)"));+    }+    Ok(result)+}++#[cfg(test)]+mod tests {+    use super::*;+    fn board() -> Board {+        Board { pads: [("J1", "1", "V"), ("F1", "1", "V"), ("R1", "1", "V"), ("J2", "1", "GND")].into_iter().map(|(r,p,n)| Pad { reference:r.into(), pad:p.into(), net:n.into(), x:0., y:0., layers:vec!["F.Cu".into()], area:1. }).collect(), ..Board::default() }+    }+    fn row(reference: &str, amps: f64) -> Terminal { Terminal { reference:reference.into(), pad:"1".into(), amps } }+    #[test] fn main_and_sense_branches_preserve_assigned_currents() {+        let b=board();+        let r=resolve(&b,"V",3.,&[row("J1",3.),row("F1",-2.9999),row("R1",-0.0001)]).unwrap();+        assert_eq!(r[1].0.reference,"F1"); assert_eq!(r[2].1,-0.0001);+    }+    #[test] fn reject_wrong_net_missing_duplicate_and_unbalanced_terminals() {+        let b=board();+        for rows in [vec![],vec![row("J1",3.),row("J2",-3.)],vec![row("J1",3.),row("MISSING",-3.)],vec![row("J1",3.),row("F1",-2.)],vec![row("J1",3.),row("F1",-1.5),row("F1",-1.5)],vec![row("J1",f64::NAN),row("F1",-3.)]] {+            assert!(resolve(&b,"V",3.,&rows).is_err());+        }+    }+    #[test] fn reject_ambiguous_repeated_pad_number() {+        let mut b=board(); b.pads.push(b.pads[0].clone());+        assert!(resolve(&b,"V",3.,&[row("J1",3.),row("F1",-3.)]).is_err());+    }+}

Comments

John Lauer ·

Merged into main as adom-fields 0.7.17: explicit signed pin currents (loads.<net>.terminals), terminals.rs with its tests, and terminalAssignments. One change: a net with explicit terminals that cannot be solved still fails the run, but a net whose terminals were guessed and cannot be found becomes a high load-unsolved issue instead (AI Flow already refuses a solve missing a loaded net), so one internal rail no longer blocks a whole board's report.

Log in to comment.