← All Pull Requests

Model plated drill voids and distribute layer coupling around the wall #9

Closed opened by John Lauer
Merges distributed-plated-wall-coupling → unclipped-current-maxima

Drilled holes were rasterized as solid copper discs, and each complete plated-barrel conductance was attached to one center cell. Refining the mesh could therefore report a surface-current maximum wholly inside a physical drill. This removes plated drill voids from all copper masks and distributes interlayer electrical/thermal conductance around the plated wall. It also exposes signed connection current, mean barrel density and resolved perimeter fraction, and emits current-barrel findings independently of surface maxima.

Stacked on PR #8, exact base b800fa409313a21151cdc9163839830acfbfe05c, targeting unclipped-current-maxima. PR #8 depends on #6 and #5. Main remains b8aecfc5de87ca3a8b09ebbd4a82a8c9b416988f. Ten focused files change: barrel geometry/coupling, drill raster subtraction, KiCad slot orientation and nonzero-offset refusal, Fusion constructor compatibility, report/module registration, documentation and the actually installed skill copy. Existing terminals, physical stack, heatsinks, signals, UI and unclipped maximum behavior are preserved.

Validation: production cargo check --workspace --bins, 32 combined tests, release build, combined-main preflight with no problems, every focused diff hunk reviewed, and remote patch reconstruction matches all ten local files byte-for-byte. Tests cover circular/rotated slot voids, conserved conductance at three meshes, preserved 1 A through a lone plated connection, and a two-mesh solve (12.19/14.84 A/mm² at 0.2/0.1 mm). That fixture is a regression bound, not universal mesh convergence.

Board diagnostic: with the preceding center-cell model, electronic-load v18 at 0.1 mm reported GND 102.24 and SHUNT_P 78.40 A/mm² in cells wholly within 0.6 mm drills. The drain peak cell only partly intersects a hole and is not dismissed on that basis. This candidate model's v18 rerun at 0.2 mm still reports surface violations: GND 71.93 and SHUNT_P 89.36 A/mm². No current or thermal gate is claimed passed, no live PCB edits resulted from these experiments, and no shared executable was replaced.

Limits requiring owner review: nominal 25 µm plating; perimeter quadrature projected onto resolved DC copper cells; finite-cell/fraction and terminal-neighborhood approximations; mean barrel density is not a local wall stress maximum. Drill voids are removed from copper but not modeled as 3D air cavities. Existing chip-heat placement, Joule approximation and absent package/case/fan physics are not qualified by this change. Real-board mesh/material checks and AIFlow consumption of the new barrel findings remain open. Please review and merge the dependency stack before publishing/installing a shared build.

Diff Skip to comments (2)

README.md+4
@@ -124,3 +124,7 @@ This change does not replace the current injection's existing 1 mm neighborhood ## Current maxima and display clipping (source candidate)  `nets[].peakJ` now reports the unclipped, unrounded modeled cell maximum among cells with at least 25% copper coverage. `peakAt` and `peakLayer` locate that same maximum. The viewer texture still caps its 99.5th percentile for legibility; that display cap must never be used as an acceptance maximum. `peakJDefinition` records these semantics. Earlier reports lack the marker and their reported peaks may be clipped; rerun them before making a maximum-density acceptance claim. The 25% coverage cutoff, finite mesh and terminal injection approximation remain screening limitations, so this is not an exact geometric singularity or physical qualification.++## Plated-wall current and thermal coupling++Read [plated-wall-coupling](docs/plated-wall-coupling.md) before accepting a via-dominated maximum. Drill voids are removed from copper and layer conductance is distributed around the plated wall. Inspect each net's `platedConnections` and `current-barrel` issues as well as the unclipped surface maximum. Do not substitute a passing coarse mesh for refinement, relabel old center-cell reports, or treat this as a package/heatsink or physical qualification model. Nonzero plated-hole offsets are refused.
SKILL.md+4
@@ -116,3 +116,7 @@ This change does not replace the current injection's existing 1 mm neighborhood ## Current maxima and display clipping (source candidate)  `nets[].peakJ` now reports the unclipped, unrounded modeled cell maximum among cells with at least 25% copper coverage. `peakAt` and `peakLayer` locate that same maximum. The viewer texture still caps its 99.5th percentile for legibility; that display cap must never be used as an acceptance maximum. `peakJDefinition` records these semantics. Earlier reports lack the marker and their reported peaks may be clipped; rerun them before making a maximum-density acceptance claim. The 25% coverage cutoff, finite mesh and terminal injection approximation remain screening limitations, so this is not an exact geometric singularity or physical qualification.++## Plated-wall current and thermal coupling++Read [plated-wall-coupling](docs/plated-wall-coupling.md) before accepting a via-dominated maximum. Drill voids are removed from copper and layer conductance is distributed around the plated wall. Inspect each net's `platedConnections` and `current-barrel` issues as well as the unclipped surface maximum. Do not substitute a passing coarse mesh for refinement, relabel old center-cell reports, or treat this as a package/heatsink or physical qualification model. Nonzero plated-hole offsets are refused.
docs/plated-wall-coupling.mdadded+11
@@ -0,0 +1,11 @@+# Plated holes in the field screen++The previous model painted a solid copper disc across each via drill and attached its complete vertical conductance to the center solve cell. That produces a point-transfer singularity: refining the mesh can increase an apparent copper peak inside a physical hole. On the electronic-load v18 diagnostic at 0.1 mm, the complete worst GND and SHUNT_P cells lie inside 0.6 mm drills. Their reported 102.24 and 78.40 A/mm² values cannot describe surface copper in those cells. The drain peak cell intersects a drill boundary and must not be dismissed solely because its center lies in the hole.++This change subtracts the circular/rotated oblong plated drill after drawing tracks, pads and fills. It distributes vertical coupling around the plated wall, using uniform arc-length quadrature at the middle of the assumed 25 µm plating. Total barrel conductance remains conductivity × barrel area / layer distance. The DC projection uses cells with resolved copper on both faces and reports the retained perimeter fraction. A missing resolved wall or out-of-grid loaded connection refuses that net; the CLI's existing completeness check rejects the report. Nonzero drill offsets are explicitly unsupported.++The same perimeter weights distribute thermal barrel coupling and the per-part barrel heat-flow accounting. This does not add a package/case/heatsink model. Existing FR4 and chip heat-source approximations remain; a drilled hole is removed from copper but not meshed as a separate 3D air cavity.++Every net now includes `platedConnections`: position, adjacent modeled layers, signed current, assumed barrel area, mean barrel current density and resolved perimeter fraction. `current-barrel` issues use the same configured screening density threshold. Mean barrel density and the unclipped surface-cell maximum are different quantities and both require review. `barrelCoupling` identifies the model; old reports must be re-solved, not stamped with new metadata.++Tests check drill subtraction despite overlapping copper, rotated slots, conserved distributed conductance, terminal-current conservation through a lone plated connection, and a two-mesh fixture. Its surface maxima were 12.19 and 14.84 A/mm² at 0.2 and 0.1 mm; this is a bounded regression check, not universal mesh convergence. For real boards inspect finer meshes, individual connections, copper coverage, material assumptions and boundary currents. The model remains a screening approximation, not manufacturing or thermal qualification.
skills/adom-fields/SKILL.md+6
@@ -82,3 +82,9 @@ Each solve records SHA-256 identities of the board and spec in `fields.json.inpu Read `docs/heatsinks-and-signals.md` in the package before specifying these models. Use measured conductivity/contact resistance for `heatsinks`; contact is to the board face, not an automatically modeled chip lid. Never add a fictitious heatsink to make a gate pass. Inspect both air conditions and mesh sensitivity.  For signal screening, provide `signals.capacitance` or `signals.differentialPairs`, explicit consistent `stackupMm`, and justified epsilonR. Inspect Signals with `adom-fields ui --port 8874 view --quantity signals`. Unsupported geometry must stay unsupported; do not replace null estimates with guessed numbers. This is outer-layer microstrip and adjacent-layer overlap screening, not high-frequency signoff. The summary records assumptions and primary references.++## Source-candidate current checks++Before accepting a current screen, inspect `inputIdentity`, explicit signed `loads.<net>.terminals`, convergence for every loaded net, and `peakJDefinition`. The maximum must be the unclipped, unrounded cell maximum; the texture percentile is display-only. Use actual copper layers and an explicit dielectric stack. Missing or stale report metadata requires a fresh solve, not manual relabeling.++The plated-wall model removes plated drill voids from copper and distributes coupling around the wall. Inspect `barrelCoupling`, each net's `platedConnections` (signed amps, mean barrel density and resolved perimeter fraction), and `current-barrel` issues as well as surface maxima. Refine the mesh near holes; a cell center within a hole may still represent a partly copper-covered cell, but a wholly copper-free cell must not carry surface current. Nonzero drill offsets are unsupported. These remain screening approximations, not a package/heatsink model or physical qualification. See the package's `docs/plated-wall-coupling.md`.
src/barrel.rsadded+90
@@ -0,0 +1,90 @@+//! Plated-hole geometry and distributed layer coupling. No conducting disc in the drill.+use crate::board::Via;+use std::collections::BTreeMap;+const PLATING_MM: f64 = 0.025;++pub fn transform(v: &Via, x: f64, y: f64) -> (f64, f64) {+    let (s, c) = v.drill_angle.to_radians().sin_cos();+    (v.x + x * c + y * s, v.y - x * s + y * c)+}+pub fn axis(v: &Via) -> ((f64, f64), (f64, f64)) {+    let a = (v.drill_long.unwrap_or(v.drill) - v.drill) / 2.0;+    (transform(v, -a, 0.0), transform(v, a, 0.0))+}+/// Uniform arc-length quadrature at the middle of the plated wall. The weights+/// sum to one; multiplying by sigma * barrel area / length preserves conductance.+pub fn weights(v: &Via, x0: f64, y0: f64, pitch: f64, w: usize, h: usize) -> Vec<(usize, f64)> {+    let a = (v.drill_long.unwrap_or(v.drill) - v.drill) / 2.0;+    let r = v.drill / 2.0 + PLATING_MM / 2.0;+    let arc = std::f64::consts::PI * r;+    let perimeter = 4.0 * a + 2.0 * arc;+    let samples = ((perimeter / pitch * 32.0).ceil() as usize).max(128);+    let mut bins = BTreeMap::<usize, usize>::new();+    for j in 0..samples {+        let t = (j as f64 + 0.5) * perimeter / samples as f64;+        let (x, y) = if t < 2.0 * a {+            (-a + t, -r)+        } else if t < 2.0 * a + arc {+            let q = (t - 2.0 * a) / r - std::f64::consts::FRAC_PI_2;+            (a + r * q.cos(), r * q.sin())+        } else if t < 4.0 * a + arc {+            (a - (t - 2.0 * a - arc), r)+        } else {+            let q = (t - 4.0 * a - arc) / r + std::f64::consts::FRAC_PI_2;+            (-a + r * q.cos(), r * q.sin())+        };+        let (x, y) = transform(v, x, y);+        let c = ((x - x0) / pitch).floor() as isize;+        let row = ((y - y0) / pitch).floor() as isize;+        if c >= 0 && row >= 0 && c < (w as isize) && row < (h as isize) {+            *bins.entry(row as usize * w + c as usize).or_default() += 1;+        }+    }+    bins.into_iter()+        .map(|(i, n)| (i, n as f64 / samples as f64))+        .collect()+}+#[cfg(test)]+mod tests {+    use super::*;+    fn via() -> Via {+        Via {+            net: "N".into(),+            x: 3.0,+            y: 3.0,+            size: 1.2,+            drill: 0.6,+            drill_long: None,+            drill_angle: 0.0,+        }+    }+    #[test]+    fn coupling_is_conserved_and_avoids_center_at_both_meshes() {+        for pitch in [0.2, 0.1, 0.05] {+            let n = (6.0 / pitch) as usize;+            let ws = weights(&via(), 0.0, 0.0, pitch, n, n);+            assert!((ws.iter().map(|x| x.1).sum::<f64>() - 1.0).abs() < 1e-12);+            let center = (3.0 / pitch) as usize * n + (3.0 / pitch) as usize;+            assert!(!ws.iter().any(|(i, _)| *i == center));+            assert!(ws.len() > 4);+        }+    }+    #[test]+    fn rotated_slot_preserves_conductance_and_changes_axis() {+        let mut v = via();+        v.drill_long = Some(1.6);+        v.drill_angle = 90.0;+        let (a, b) = axis(&v);+        assert!((a.0 - b.0).abs() < 1e-12);+        assert!((a.1 - b.1).abs() > 0.99);+        assert!(+            (weights(&v, 0.0, 0.0, 0.1, 60, 60)+                .iter()+                .map(|x| x.1)+                .sum::<f64>()+                - 1.0)+                .abs()+                < 1e-12+        );+    }+}
src/board.rs+6−4
@@ -8,7 +8,7 @@ pub struct Poly { pub net: String, pub layer: String, pub pts: Vec<(f64, f64)> } #[derive(Debug, Clone)] pub struct Track { pub net: String, pub layer: String, pub a: (f64, f64), pub b: (f64, f64), pub w: f64 } #[derive(Debug, Clone)]-pub struct Via { pub net: String, pub x: f64, pub y: f64, pub size: f64, pub drill: f64, pub drill_long: Option<f64> }+pub struct Via { pub net: String, pub x: f64, pub y: f64, pub size: f64, pub drill: f64, pub drill_long: Option<f64>, pub drill_angle: f64 } #[derive(Debug, Clone)] pub struct Pad { pub reference: String, pub pad: String, pub net: String, pub x: f64, pub y: f64, pub layers: Vec<String>, pub area: f64 } @@ -86,7 +86,7 @@ impl Board {                 }             }             let Some(at) = v.kid("at") else { continue };-            b.vias.push(Via { net: v.kid("net").map(&net_name).unwrap_or_default(), x: at.num(1).unwrap_or(0.0), y: at.num(2).unwrap_or(0.0), size: v.kid("size").and_then(|s| s.num(1)).unwrap_or(0.6), drill: v.kid("drill").and_then(|d| d.num(1)).unwrap_or(0.3), drill_long: None });+            b.vias.push(Via { net: v.kid("net").map(&net_name).unwrap_or_default(), x: at.num(1).unwrap_or(0.0), y: at.num(2).unwrap_or(0.0), size: v.kid("size").and_then(|s| s.num(1)).unwrap_or(0.6), drill: v.kid("drill").and_then(|d| d.num(1)).unwrap_or(0.3), drill_long: None, drill_angle: 0.0 });         }         for fp in root.kids("footprint") {             let reference = fp.kids("property").iter().find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).unwrap_or("?").to_string();@@ -111,7 +111,9 @@ impl Board {                         (a.min(b), Some(a.max(b)))                     } else { (drill_node.num(1).ok_or("plated pad requires drill dimensions")?, None) };                     if drill <= 0.0 { return Err("plated pad drill must be positive".into()); }-                    b.plated_holes.push(Via { net: net.clone(), x, y, size: sw.min(sh), drill, drill_long });+                    let drill_angle = pa + if drill_node.atom(1) == Some("oval") && drill_node.num(3) > drill_node.num(2) { 90.0 } else { 0.0 };+                    if let Some(offset)=drill_node.kid("offset") { if offset.num(1).unwrap_or(0.0)!=0.0 || offset.num(2).unwrap_or(0.0)!=0.0 { return Err("offset plated holes are not supported by the annular field model".into()); } }+                    b.plated_holes.push(Via { net: net.clone(), x, y, size: sw.min(sh), drill, drill_long, drill_angle });                 }                 let lay: Vec<String> = p.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| if let Sx::Atom(a) = x { Some(a.clone()) } else { None }).collect()).unwrap_or_default();                 let on: Vec<String> = if kind == "thru_hole" { b.layer_names.clone() } else { b.layer_names.iter().filter(|l| lay.iter().any(|x| x == *l || x == "*.Cu")).cloned().collect() };@@ -206,7 +208,7 @@ impl Via { #[cfg(test)] mod barrel_tests {     use super::*;     #[test] fn circular_and_slotted_plating_cross_sections() {-        let round=Via{net:"GND".into(),x:0.,y:0.,size:2.,drill:1.,drill_long:None};+        let round=Via{net:"GND".into(),x:0.,y:0.,size:2.,drill:1.,drill_long:None,drill_angle:0.0};         assert!((round.barrel_area_mm2()-0.08050331175).abs()<1e-9);         let slot=Via{drill:0.6,drill_long:Some(1.7),..round};         assert!((slot.barrel_area_mm2()-0.10408738521).abs()<1e-9);
src/eagle.rs+2−2
@@ -126,7 +126,7 @@ pub fn load(text: &str) -> Result<Board, String> {                 let drill = f(&p, "drill");                 if drill <= 0.0 { return Err("plated pad drill must be positive".into()); }                 let dia = f(&p, "diameter").max(drill + 2.0 * ring(drill, pad_rv, pad_min, pad_max));-                b.plated_holes.push(Via { net: net.clone(), x: cx, y: cy, size: dia, drill, drill_long: None });+                b.plated_holes.push(Via { net: net.clone(), x: cx, y: cy, size: dia, drill, drill_long: None, drill_angle: 0.0 });                 let ring = |l: &str| Poly { net: net.clone(), layer: l.into(), pts: (0..24).map(|k| { let a = k as f64 / 24.0 * std::f64::consts::TAU; (cx + dia / 2.0 * a.cos(), cy + dia / 2.0 * a.sin()) }).collect() };                 let on: Vec<String> = b.layer_names.clone();                 for l in &on { b.pad_polys.push(ring(l)); }@@ -144,7 +144,7 @@ pub fn load(text: &str) -> Result<Board, String> {             if v.attribute("extent") != Some("1-16") { return Err("only Fusion through vias with extent 1-16 are supported".into()); }             let drill = f(&v, "drill");             let size = f(&v, "diameter").max(drill + 2.0 * ring(drill, via_rv, via_min, via_max));-            b.vias.push(Via { net: net.clone(), x: f(&v, "x"), y: y(f(&v, "y")), size, drill, drill_long: None });+            b.vias.push(Via { net: net.clone(), x: f(&v, "x"), y: y(f(&v, "y")), size, drill, drill_long: None, drill_angle: 0.0 });         }         // Fusion's own pours: <polygonpour> with the outline the user drew and, once Fusion has         // filled it (RATSNEST), <polygonfilldetails>: each island's outline and its holes. Use the
src/fields.rs+58−11
@@ -34,7 +34,7 @@ fn d60() -> f64 { 60.0 } #[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, pub tabNet: String }  #[derive(Serialize, Clone)]-pub struct NetResult { pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize, pub residual: f64, pub platedPads: usize }+pub struct NetResult { pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize, pub residual: f64, pub platedPads: usize, pub platedConnections: Vec<Value> } #[derive(Serialize, Clone)] pub struct ChipResult { pub reference: String, pub watts: f64, pub tabNet: String, pub x: f64, pub y: f64, pub riseC: f64, pub riseMovingC: f64, pub planeW: f64, pub viaW: f64, pub vias: usize, pub otherViaW: f64, pub otherVias: usize, pub platedPadW: f64, pub platedPads: usize, pub dielW: f64, pub airW: f64, pub viasWanted: usize } #[derive(Serialize, Clone)]@@ -76,7 +76,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu     let mut j_all: Vec<Grid> = layers.iter().map(|_| Grid::new(w, h)).collect();     let mut p_joule:Vec<Grid> = layers.iter().map(|_|Grid::new(w,h)).collect();     let mut nets: Vec<NetResult> = Vec::new();-    for (net, load) in &spec.loads {+    'loaded_nets: for (net, load) in &spec.loads {         let fr: Vec<Grid> = layers.iter().map(|l| ras.fraction(net, l, pool)).collect();         let live: Vec<usize> = (0..layers.len()).filter(|k| fr[*k].sum() > 0.0).collect();         if live.is_empty() { log(&format!("{net}: no copper on the board, skipped")); continue; }@@ -91,11 +91,16 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         let nvias = b.vias.iter().filter(|v| v.net == *net).count();         let plated_pads = b.plated_holes.iter().filter(|v| v.net == *net).count();         for v in b.vias.iter().chain(&b.plated_holes).filter(|v| v.net == *net) {-            let Some((r, c)) = cell(v.x, v.y) else { continue };+            let weights=crate::barrel::weights(v,b.x0,b.y0,d,w,h);+            if (weights.iter().map(|(_,q)|q).sum::<f64>()-1.0).abs()>1e-9 {log(&format!("{net}: plated wall crosses solver bounds"));continue 'loaded_nets;}             let sigma_area = SIGMA * v.barrel_area_mm2();             for a in 0..nl.saturating_sub(1) {                 let distance: f64 = (live[a]..live[a+1]).map(|k| t_diel[k] + (t_cu[k] + t_cu[k+1])/2.0).sum();-                sys.gz[a][r * w + c] += sigma_area / distance;+                // Project wall quadrature only onto cells with resolved copper on+                // both faces; retain total barrel conductance after projection.+                let norm:f64=weights.iter().filter(|(i,_)|fr[live[a]].v[*i]>0.0 && fr[live[a+1]].v[*i]>0.0).map(|(_,q)|q).sum();+                if norm<=0.0 {log(&format!("{net}: plated wall has no resolved copper at {},{}",v.x,v.y));continue 'loaded_nets;}+                for (i,q) in &weights {if fr[live[a]].v[*i]>0.0 && fr[live[a+1]].v[*i]>0.0 {sys.gz[a][*i]+=sigma_area/distance*q/norm;}}             }         }         // terminals: the external pads (wire pads, connectors) on one side, the chips that draw the@@ -170,6 +175,17 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         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));+        let mut connections=Vec::new();+        for hole in b.vias.iter().chain(&b.plated_holes).filter(|q|q.net==*net) {+            let weights=crate::barrel::weights(hole,b.x0,b.y0,d,w,h);+            for a in 0..nl.saturating_sub(1) {+                let distance:f64=(live[a]..live[a+1]).map(|k|t_diel[k]+(t_cu[k]+t_cu[k+1])/2.0).sum();+                let norm:f64=weights.iter().filter(|(i,_)|fr[live[a]].v[*i]>0.0 && fr[live[a+1]].v[*i]>0.0).map(|(_,q)|q).sum();+                if norm<=0.0 {continue;}+                let amps=SIGMA*hole.barrel_area_mm2()/distance*weights.iter().filter(|(i,_)|fr[live[a]].v[*i]>0.0 && fr[live[a+1]].v[*i]>0.0).map(|(i,q)|q/norm*(v[a*n+*i]-v[(a+1)*n+*i])).sum::<f64>();+                connections.push(json!({"at":[hole.x,hole.y],"from":layers[live[a]],"to":layers[live[a+1]],"amps":amps,"meanBarrelJ":amps.abs()/hole.barrel_area_mm2(),"barrelAreaMm2":hole.barrel_area_mm2(),"resolvedPerimeterFraction":norm}));+            }+        }         let mut grids: Vec<Grid> = layers.iter().map(|_| Grid::new(w, h)).collect();         let (mut peak, mut peak_at, mut peak_layer) = (0.0f64, [0.0, 0.0], String::new());         let mut joule = 0.0;@@ -198,7 +214,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             j_all[*k].max_with(&grids[*k]);         }         let copper: f64 = live.iter().map(|k| fr[*k].sum() * d * d).sum();-        nets.push(NetResult { net: net.clone(), amps: load.amps, layers: live.iter().map(|k| layers[*k].to_string()).collect(), peakJ: peak, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it, residual: res, platedPads: plated_pads });+        nets.push(NetResult { net: net.clone(), amps: load.amps, layers: live.iter().map(|k| layers[*k].to_string()).collect(), peakJ: peak, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it, residual: res, platedPads: plated_pads, platedConnections: connections });         j_net.insert(net.clone(), grids);     }     let joule_total: f64 = nets.iter().map(|x| x.jouleW).sum();@@ -223,9 +239,9 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         }         for k in 0..nl - 1 { let g = K_FR4_Z * d * d / t_diel[k]; for i in 0..n { sys.gz[k][i] = g; } }         for v in b.vias.iter().chain(&b.plated_holes) {-            let Some((r, c)) = cell(v.x, v.y) else { continue };+            let weights=crate::barrel::weights(v,b.x0,b.y0,d,w,h);             let area = v.barrel_area_mm2();-            for k in 0..nl - 1 { sys.gz[k][r * w + c] += K_CU * area / t_diel[k]; }+            for k in 0..nl - 1 {for (i,q) in &weights {sys.gz[k][*i] += K_CU * area / t_diel[k]*q;}}         }         let meshes:Vec<_> = spec.heatsinks.iter().map(|block| crate::heatsink::attach(&mut sys,block,b.x0,b.y0,d,h_air)).collect();         (sys, meshes)@@ -287,8 +303,8 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         let g_diel = K_FR4_Z * d * d / t_diel[0];         for (plated, v) in b.vias.iter().map(|v| (false,v)).chain(b.plated_holes.iter().map(|v| (true,v))) {             if let Some((r,c)) = cell(v.x,v.y) { if inside(r,c) {-                let i = r*w+c;-                let flow = K_CU * v.barrel_area_mm2() / t_diel[0] * (tv[i]-tv[n+i]);+                let weights=crate::barrel::weights(v,b.x0,b.y0,d,w,h);+                let flow = K_CU * v.barrel_area_mm2() / t_diel[0] * weights.iter().map(|(i,q)|q*(tv[*i]-tv[n+*i])).sum::<f64>();                 if plated { plated_w += flow; np += 1; }                 else if v.net == *tab { via_w += flow; nv += 1; }                 else { other_w += flow; no += 1; }@@ -305,7 +321,15 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         // amp through a thin track is a warm trace and twenty amps through a neck is a fuse         if x.peakJ > neck {             let severity = if x.amps < 1.0 { "low" } else if x.peakJ > 2.5 * neck { "high" } else { "medium" };-            issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A through a neck about {:.2} mm wide at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)/1000.0), spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } });+            issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A total; equivalent full-net width {:.2} mm at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)/1000.0), spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } });+        }+    }+    for net in &nets {+        for connection in &net.platedConnections {+            let density=connection["meanBarrelJ"].as_f64().unwrap_or(f64::INFINITY);+            if density>neck {+                issues.push(Issue {severity:"medium".into(),kind:"current-barrel".into(),what:format!("{}: plated connection mean density {:.2} A/mm² exceeds {neck:.2} A/mm²",net.net,density),at:Some([connection["at"][0].as_f64().unwrap(),connection["at"][1].as_f64().unwrap()]),layer:None,net:Some(net.net.clone()),reference:None,fix:"Review barrel plating, drill and current sharing; add a parallel physical connection where needed.".into()});+            }         }     }     for c in &chips {@@ -345,6 +369,7 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value         "heat": {"tabW": f.tab_total, "jouleW": f.joule_total, "peakRiseC": tmax, "peakRiseMovingC": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max), "budgetC": spec.hotMaxRiseC, "stillAirWPerM2K": H_STILL * 1e6, "movingAirWPerM2K": H_MOVING * 1e6},         "textures": textures,         "pourNets": pour_nets,+        "barrelCoupling": {"model":"distributed-plated-wall", "platingUm":25, "drillVoidsRemoved":true, "quadrature":"uniform-perimeter-32-samples-per-cell-minimum-128"},         "method": "Copper fraction per cell from the board's filled polygons, tracks, pads and vias; DC conduction per loaded net between its terminals (σ 5.8e4 S/mm × thickness); heat: actual copper layers coupled through the dielectric and every via and plated-pad barrel, copper 0.40 W/mm·K, FR4 0.3/0.6 W/m·K, 10 (still) or 25 (moving) W/m²K to the air on both faces; one steady-state solve each. Optional rectangular heatsinks use a 3D finite-volume conducting block with specified conductivity, contact resistance and air loss on exposed faces. Copper thickness may differ by layer; Joule heat is deposited on its source layer. This remains a screening model: rectangular board outline, prescribed chip watts and convection; no fins, radiation, airflow solve or junction-temperature qualification."     }) }@@ -447,7 +472,7 @@ pub fn validate_current_residual(net: &str, residual: f64) -> Result<(), String>         for (reference,layer,x) in [("J1","F.Cu",1.5),("Q1","B.Cu",4.5)] {             b.pads.push(crate::board::Pad { reference:reference.into(), pad:"1".into(), net:"GND".into(), x,y:3., layers:vec![layer.into()],area:1. });         }-        b.plated_holes.push(crate::board::Via { net:"GND".into(),x:3.,y:3.,size:2.,drill:1.,drill_long:None });+        b.plated_holes.push(crate::board::Via { net:"GND".into(),x:3.,y:3.,size:2.,drill:1.,drill_long:None,drill_angle:0.0 });         let spec:Spec=serde_json::from_value(json!({"copperUm":35,"stackupMm":[1.53],"loads":{"GND":{"amps":1}}})).unwrap();         let f=analyze(&b,&spec,0.5,60.,&mut |_|{});         assert_eq!(f.nets.len(),1);@@ -457,6 +482,28 @@ pub fn validate_current_residual(net: &str, residual: f64) -> Result<(), String>         assert!(f.nets[0].jouleW<0.1);         assert!(f.heat_residual<1e-6);     }+    #[test] fn annular_coupling_preserves_terminal_current_across_meshes() {+        let mut b = Board { x1: 6., y1: 6., layer_names: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+        for layer in ["F.Cu", "B.Cu"] { b.polys.push(crate::board::Poly { net:"GND".into(), layer:layer.into(), pts:vec![(0.,0.),(6.,0.),(6.,6.),(0.,6.)] }); }+        for (reference,layer,x) in [("J1","F.Cu",1.5),("Q1","B.Cu",4.5)] {+            b.pads.push(crate::board::Pad { reference:reference.into(), pad:"1".into(), net:"GND".into(), x,y:3., layers:vec![layer.into()],area:1. });+        }+        b.plated_holes.push(crate::board::Via { net:"GND".into(),x:3.,y:3.,size:2.,drill:1.,drill_long:None,drill_angle:0.0 });+        let spec:Spec=serde_json::from_value(json!({"copperUm":35,"stackupMm":[1.53],"loads":{"GND":{"amps":1}}})).unwrap();+        let mut peaks=Vec::new();+        for pitch in [0.2,0.1] {+            let f=analyze(&b,&spec,pitch,60.,&mut |_|{});+            assert_eq!(f.nets.len(),1);let net=&f.nets[0];+            let connection=&net.platedConnections[0];+            assert!((connection["amps"].as_f64().unwrap().abs()-1.0).abs()<1e-5);+            let dx=net.peakAt[0]-3.0;let dy=net.peakAt[1]-3.0;+            assert!((dx*dx+dy*dy).sqrt()+pitch*std::f64::consts::SQRT_2/2.0>=0.5,"peak cell must intersect copper outside the drill");+            peaks.push(net.peakJ);+        }+        eprintln!("annular mesh maxima: {:?}",peaks);+        assert!((peaks[0]-peaks[1]).abs()/peaks[1]<0.25,"mesh changed peak by more than fixture tolerance");+    }+ }  /// Preserve the modeled maximum before clipping only the displayed texture.
src/main.rs+2−1
@@ -1,7 +1,8 @@ //! 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;+mod eagle; mod parts; mod signals; mod heatsink; mod board;+mod barrel; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use std::path::PathBuf; 
src/raster.rs+33
@@ -63,6 +63,16 @@ impl Raster {         for v in &b.vias { for l in &b.layer_names { let (net, x, y, s) = (v.net.clone(), v.x, v.y, v.size); self.disc(&net, l, x, y, s / 2.0); } }         self.draw_fills(b);         self.fill_pours(b);+        // Drill voids remove copper even when tracks or pours cross the hole.+        // Use a temporary mask so every net/layer loses the same physical void.+        let key="\u{0}drills";+        for v in b.vias.iter().chain(&b.plated_holes) {+            let (a,z)=crate::barrel::axis(v);+            self.thick_line(key,key,a,z,v.drill);+        }+        if let Some(holes)=self.masks.remove(&(key.into(),key.into())) {+            for mask in self.masks.values_mut() { for (m,h) in mask.iter_mut().zip(&holes) {if *h!=0 {*m=0;}} }+        }     }     /// Pour islands the file carries already filled (Fusion): each outline minus its holes.     fn draw_fills(&mut self, b: &Board) {@@ -159,3 +169,26 @@ fn dilate(m: &[u8], w: usize, h: usize, r: i64) -> Vec<u8> {     }     out }++#[cfg(test)] mod drill_tests {+    use super::*;+    #[test] fn drill_removes_overlapping_track_and_pour_copper_on_every_layer() {+        let mut b=Board{x1:4.0,y1:4.0,layer_names:vec!["F.Cu".into(),"B.Cu".into()],..Board::default()};+        for layer in &b.layer_names {b.polys.push(Poly{net:"N".into(),layer:layer.clone(),pts:vec![(0.,0.),(4.,0.),(4.,4.),(0.,4.)]});}+        b.vias.push(crate::board::Via{net:"N".into(),x:2.0,y:2.0,size:1.4,drill:0.8,drill_long:None,drill_angle:0.0});+        let mut r=Raster::new(&b,0.025);r.draw_board(&b);+        for layer in &b.layer_names {+            let g=r.fraction("N",layer,2);+            assert_eq!(g.at(40,40),0.0);assert_eq!(g.at(40,50),1.0);+            let removed=16.0-g.sum()*0.05*0.05;+            assert!((removed-std::f64::consts::PI*0.4*0.4).abs()<0.025);+        }+    }+    #[test] fn rotated_slot_is_void_along_its_actual_axis() {+        let mut b=Board{x1:4.0,y1:4.0,layer_names:vec!["F.Cu".into()],..Board::default()};+        b.polys.push(Poly{net:"N".into(),layer:"F.Cu".into(),pts:vec![(0.,0.),(4.,0.),(4.,4.),(0.,4.)]});+        b.plated_holes.push(crate::board::Via{net:"N".into(),x:2.,y:2.,size:2.,drill:0.4,drill_long:Some(1.4),drill_angle:90.});+        let mut r=Raster::new(&b,0.025);r.draw_board(&b);let g=r.fraction("N","F.Cu",2);+        assert_eq!(g.at(30,40),0.0);assert_eq!(g.at(40,30),1.0);+    }+}

Comments

senthilkumaranms ·

Please review the correction stack for use in the nRF52832/HX711 two-layer PCB workflow and identify an accepted build/commit for formal AIFlow integration. The project owner has authorized this review request.

We exercised an isolated local candidate containing PR #9 and its dependencies, the physical-pad terminal selector work associated with PR #10, and additional local changes described in the attached provenance. This is evidence for review, not an approval request limited to PR #9 alone. Please identify which additional changes need separate review/submission before the complete build can be accepted.

Results on the unchanged saved PCB:

  • Fresh 0.2 mm solve with explicit signed terminal currents, physical two-layer stack and specified heat-source positions.
  • Both actual AIFlow 0.1.92 consumers (analyze current and analyze thermal) returned exit 0 and recorded pass=true in an isolated integration run, with matching board/spec SHA-256 identities.
  • Original current loads (25/14/50 mA), device powers (40/5/15 mW) and all 5 C rise limits were preserved.
  • The first integration attempt passed current but failed thermal on a stale six-via provision. The revised integration input explicitly carries forward the earlier layout-review acknowledgement: one retained GND via within 3 mm of U3.11, versus three near any U3 ground pad. This change is disclosed in the report; no PCB copper or holes changed, and the thermal budget was not relaxed.
  • Earlier 0.2/0.1 mm diagnostics showed material peak-current mesh sensitivity (up to roughly 12%); no mesh-independent or physical qualification claim is made.

Identities:

  • PCB SHA-256: e15c130e125a566dd4c2660585a2eee355b08fea71d6dfee8c17a6c84d40a57c
  • Integration specification SHA-256: a0aa7f72f24e771ddea8822bfbbcd702a1ce3db315c09fec7f79e6a33903a5bb
  • Candidate executable SHA-256: 9bcf9848d1d80739862b6f3b7eff73a1e4252eed976c98214f722fff6350afc4
  • The executable reports version 0.6.2 despite containing later unmerged changes; it must not be confused with the published 0.6.2 package.

Additional candidate changes needing coverage in the review: numeric KiCad net-ID handling, disconnected-boundary diagnostics, actual-residual CG restart checking, and explicit per-part heat-location selection. The provenance records historical tests/builds; we did not rebuild or rerun the Rust unit tests during this PCB integration check.

No shared runtime was replaced and no main-run production gate was promoted. This remains a board screening model: actual enclosure/shaft cooling, package junction temperatures, ground-return currents, battery behaviour, RF, measurement accuracy and rotating assembly qualification are not established by these results.

Attached are the integration report, machine-readable consumer outcomes, exact integration input, fresh solver report and candidate provenance. Local workspace paths in these artifacts identify our evidence files; they are not public links. Please confirm the review path and the accepted solver build needed to rerun the main engineering flow.

solver-integration.md

solver-integration-review.json

fields-integration-spec.json

fields.json

provenance.json

John Lauer ·

Merged into main as adom-fields 0.7.17: barrel.rs (wall quadrature, slot axis and angle), drill voids on every net and layer, wall-distributed electrical and thermal coupling, and platedConnections per net. Two changes:

  • Issue 11's ring problem: a barrel whose wall resolves no cell with copper on both faces falls back to the nearest such cells instead of dropping the net, so a small via never splits a net at a coarse pitch.
  • An offset drill moves the hole with the pad's rotation rather than refusing the board.

Also fixed alongside: terminal injection now reaches a large plated pad's own ring (its drill void had emptied the 1 mm search), and the ground-island check joins layers over each hole's whole ring.

Log in to comment.