Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
Model plated drill voids and distribute layer coupling around the wall #9
distributed-plated-wall-coupling → unclipped-current-maximaDrilled 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
Log in to comment.