app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
Comparing main ← fix-physical-two-layer-stack
Changes on fix-physical-two-layer-stack that are not yet on main (three-dot, from the merge base).
11 files changed, 311 insertions(+), 68 deletions(-)
README.md+14@@ -98,3 +98,17 @@ adom-fields analyze --board buck.brd --spec spec.json --out fields --models-dir Fusion bridge binds it. - Two copper layers plus up to two inner layers, as for KiCad boards. ++## Physical copper stack++The solver and viewer use the board's physical copper layers. Supported stacks are two layers (F.Cu/B.Cu) and four layers (F.Cu/In1.Cu/In2.Cu/B.Cu). KiCad uses the copper layer table; Fusion uses `layerSetup`, or active copper declarations when that parameter is absent. Empty or hidden inner layers must not disappear from a physical stack. Other stacks and blind/buried/micro vias are rejected.++For two layers supply one positive `stackupMm` dielectric interval, for example `[1.53]` with 35 µm copper on both sides for a nominal 1.6 mm board. This is an explicit design assumption, not a fabrication measurement. Four-layer legacy defaults remain 0.2/1.065/0.2 mm and are labeled in `board.stackupSource`. Signal checks require explicit intervals and use actual physical adjacency. Check the summary's layer list, stack source and total thickness before interpreting a field plot.++These are board conduction screens. They do not establish MOSFET junction temperature, heatsink performance, fan airflow, thermal-contact resistance or an industrial power rating.++Every DC net must converge before a summary is published, even when the thermal solve converges. An unconverged net can inject nonsensical Joule heat; inspect its raster connectivity and mesh resolution instead of interpreting that temperature as a prediction.++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.
SKILL.md+14@@ -90,3 +90,17 @@ on the Fusion bridge). Run RATSNEST in Fusion first so the pours are filled: the Fusion's own fill. Put the parts' STEP files, named by MPN, in a folder and pass `--models-dir` so the chips sit over the fields. ++## Physical copper stack++The solver and viewer use the board's physical copper layers. Supported stacks are two layers (F.Cu/B.Cu) and four layers (F.Cu/In1.Cu/In2.Cu/B.Cu). KiCad uses the copper layer table; Fusion uses `layerSetup`, or active copper declarations when that parameter is absent. Empty or hidden inner layers must not disappear from a physical stack. Other stacks and blind/buried/micro vias are rejected.++For two layers supply one positive `stackupMm` dielectric interval, for example `[1.53]` with 35 µm copper on both sides for a nominal 1.6 mm board. This is an explicit design assumption, not a fabrication measurement. Four-layer legacy defaults remain 0.2/1.065/0.2 mm and are labeled in `board.stackupSource`. Signal checks require explicit intervals and use actual physical adjacency. Check the summary's layer list, stack source and total thickness before interpreting a field plot.++These are board conduction screens. They do not establish MOSFET junction temperature, heatsink performance, fan airflow, thermal-contact resistance or an industrial power rating.++Every DC net must converge before a summary is published, even when the thermal solve converges. An unconverged net can inject nonsensical Joule heat; inspect its raster connectivity and mesh resolution instead of interpreting that temperature as a prediction.++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.
src/board.rs+82−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 struct Via { pub net: String, pub x: f64, pub y: f64, pub size: f64, pub drill: f64, pub drill_long: Option<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 } @@ -29,6 +29,8 @@ pub struct Board { pub pad_polys: Vec<Poly>, pub arc_nets: Vec<String>, pub layer_names: Vec<String>,+ /// Plated through-hole pad barrels, separate from routing vias.+ pub plated_holes: Vec<Via>, /// pours to fill on the solve grid (Fusion/EAGLE boards; empty for KiCad, whose fill is in the file) pub pours: Vec<Pour>, /// pours whose fill the file DOES carry as outline-with-holes islands (Fusion's polygonfilldetails)@@ -45,7 +47,8 @@ impl Board { if crate::eagle::is_eagle(&text) { let mut b = crate::eagle::load(&text)?; b.source = "fusion".into(); return Ok(b); } let root = parse(&text); let mut b = Board::default(); b.source = "kicad".into();- b.layer_names = root.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| x.atom(1).map(str::to_string)).collect()).unwrap_or_default();+ b.layer_names = root.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| x.atom(1).filter(|n| n.ends_with(".Cu")).map(str::to_string)).collect()).unwrap_or_default();+ b.validate_layers()?; let net_names: std::collections::BTreeMap<String,String> = root.kids("net").iter().filter_map(|n|Some((n.atom(1)?.to_string(),n.atom(2)?.to_string()))).collect(); let net_name = |n: &Sx| { let value=n.last_atom().unwrap_or(""); net_names.get(value).cloned().unwrap_or_else(||value.to_string()) }; // outline: the Edge.Cuts graphics' bounding box@@ -74,8 +77,16 @@ impl Board { b.tracks.push(Track { net: s.kid("net").map(&net_name).unwrap_or_default(), layer: s.kid("layer").and_then(|l| l.atom(1)).unwrap_or("").to_string(), a: (st.num(1).unwrap_or(0.0), st.num(2).unwrap_or(0.0)), b: (en.num(1).unwrap_or(0.0), en.num(2).unwrap_or(0.0)), w: s.kid("width").and_then(|w| w.num(1)).unwrap_or(0.2) }); } for v in root.kids("via") {+ if v.atom(1).is_some_and(|kind| kind == "blind" || kind == "micro") {+ return Err("blind/buried/micro vias are not supported by the through-via field model".into());+ }+ if let Some(span) = v.kid("layers") {+ if span.atom(1) != Some("F.Cu") || span.atom(2) != Some("B.Cu") {+ return Err("only through vias spanning F.Cu to B.Cu are supported".into());+ }+ } 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) });+ 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 }); } 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();@@ -92,8 +103,18 @@ impl Board { let (sw, sh) = (p.kid("size").and_then(|s| s.num(1)).unwrap_or(1.0), p.kid("size").and_then(|s| s.num(2)).unwrap_or(1.0)); let kind = p.atom(2).unwrap_or("smd"); let shape = p.atom(3).unwrap_or("rect");+ if kind == "thru_hole" {+ let drill_node = p.kid("drill").ok_or("plated pad is missing its drill")?;+ let (drill, drill_long) = if drill_node.atom(1) == Some("oval") {+ let a = drill_node.num(2).ok_or("slot requires two drill dimensions")?;+ let b = drill_node.num(3).ok_or("slot requires two drill dimensions")?;+ (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 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" { LAYERS.iter().map(|s| s.to_string()).collect() } else { LAYERS.iter().filter(|l| lay.iter().any(|x| x == *l || x == "*.Cu")).map(|s| s.to_string()).collect() };+ 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() }; let mut area = sw * sh; for l in &on { let mut shapes: Vec<Vec<(f64, f64)>> = Vec::new();@@ -120,6 +141,13 @@ impl Board { } Ok(b) }+ pub fn validate_layers(&self) -> Result<(), String> {+ let layers: Vec<&str> = self.layer_names.iter().map(String::as_str).collect();+ if layers != ["F.Cu", "B.Cu"] && layers != LAYERS {+ return Err(format!("supported copper stacks are F.Cu/B.Cu or F.Cu/In1.Cu/In2.Cu/B.Cu; found {:?}", layers));+ }+ Ok(())+ } pub fn width(&self) -> f64 { self.x1 - self.x0 } pub fn height(&self) -> f64 { self.y1 - self.y0 } }@@ -134,3 +162,53 @@ fn stadium_poly(x: f64, y: f64, w: f64, h: f64, deg: f64) -> Vec<(f64, f64)> { let ang = if w >= h { deg } else { deg + 90.0 }; pts.into_iter().map(|(px, py)| { let (qx, qy) = rot(px, -py, ang); (x + qx, y - qy) }).collect() }++#[cfg(test)]+mod stack_tests {+ use super::*;+ #[test]+ fn two_layer_plated_pad_and_via_have_no_phantom_inner_copper() {+ let path = std::env::temp_dir().join(format!("fields-two-layer-{}.kicad_pcb", std::process::id()));+ std::fs::write(&path, r#"(kicad_pcb (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))+ (net 1 "GND") (gr_rect (start 0 0) (end 10 10) (layer "Edge.Cuts"))+ (via (at 5 5) (size 1) (drill 0.3) (layers "F.Cu" "B.Cu") (net 1))+ (footprint "test" (property "Reference" "J1") (at 3 3)+ (pad "1" thru_hole circle (at 0 0) (size 2 2) (drill 1) (layers "*.Cu" "*.Mask") (net 1 "GND"))))"#).unwrap();+ let b = Board::load(path.to_str().unwrap()).unwrap();+ std::fs::remove_file(path).unwrap();+ assert_eq!(b.layer_names, ["F.Cu", "B.Cu"]);+ assert_eq!(b.pads[0].layers, b.layer_names);+ assert_eq!(b.plated_holes.len(), 1);+ assert_eq!(b.plated_holes[0].drill, 1.0);+ let mut raster = crate::raster::Raster::new(&b, 0.1);+ raster.draw_board(&b);+ assert_eq!(raster.masks.keys().map(|(_, l)| l).collect::<std::collections::BTreeSet<_>>().len(), 2);+ assert!(raster.masks.keys().all(|(_, l)| l == "F.Cu" || l == "B.Cu"));+ }+ #[test]+ fn unsupported_stack_is_rejected() {+ let mut b = Board::default();+ assert!(b.validate_layers().is_err());+ b.layer_names = vec!["F.Cu".into(), "In1.Cu".into(), "B.Cu".into()];+ assert!(b.validate_layers().is_err());+ }+}++impl Via {+ /// Circular or oblong plated barrel, assuming 25 µm plating as in the existing via model.+ pub fn barrel_area_mm2(&self) -> f64 {+ let plating = 0.025;+ let perimeter = std::f64::consts::PI * self.drill + 2.0 * (self.drill_long.unwrap_or(self.drill) - self.drill);+ perimeter * plating + std::f64::consts::PI * plating * plating+ }+}++#[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};+ 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+38−9@@ -38,16 +38,22 @@ pub fn load(text: &str) -> Result<Board, String> { let doc = roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).map_err(|e| format!("not a readable EAGLE board: {e}"))?; let board = doc.descendants().find(|n| n.has_tag_name("board")).ok_or("no <board> in the file: export the BOARD (.brd) from Fusion, not the schematic")?; let mut b = Board::default();- // used inner copper layers, in stack order, become In1.Cu / In2.Cu- let mut inner: Vec<String> = Vec::new();- for l in doc.descendants().filter(|n| n.has_tag_name("layer")) {- let num = l.attribute("number").unwrap_or("");- if let Ok(k) = num.parse::<u32>() { if (2..=15).contains(&k) && l.attribute("active") != Some("no") && l.attribute("visible") != Some("no") { inner.push(num.to_string()); } }+ // Physical stack order must not depend on whether a layer is visible or contains copper.+ let declared = board.descendants().find(|n| n.has_tag_name("param") && n.attribute("name") == Some("layerSetup")).and_then(|n| n.attribute("value"));+ let mut numbers: Vec<u32> = if let Some(setup) = declared {+ setup.split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty()).map(|s| s.parse::<u32>().map_err(|_| "invalid layerSetup".to_string())).collect::<Result<_, _>>()?+ } else {+ doc.descendants().filter(|n| n.has_tag_name("layer") && n.attribute("active") != Some("no"))+ .filter_map(|n| n.attribute("number").and_then(|v| v.parse::<u32>().ok())).filter(|n| (1..=16).contains(n)).collect()+ };+ numbers.sort_unstable();+ numbers.dedup();+ if !matches!(numbers.len(), 2 | 4) || numbers.first() != Some(&1) || numbers.last() != Some(&16) {+ return Err(format!("Fusion copper stack must declare two or four layers including 1 and 16; found {:?}", numbers)); }- // only inner layers that actually carry copper count- let used: std::collections::BTreeSet<String> = board.descendants().filter_map(|n| n.attribute("layer").map(str::to_string)).collect();- inner.retain(|l| used.contains(l)); inner.truncate(2);+ let inner: Vec<String> = numbers.iter().copied().filter(|n| *n != 1 && *n != 16).map(|n| n.to_string()).collect(); b.layer_names = std::iter::once("F.Cu".to_string()).chain((0..inner.len()).map(|i| LAYERS[1 + i].to_string())).chain(std::iter::once("B.Cu".to_string())).collect();+ b.validate_layers()?; let y = |v: f64| -v; // outline: the Dimension layer (20) of the plain section@@ -118,7 +124,9 @@ pub fn load(text: &str) -> Result<Board, String> { b.pads.push(Pad { reference: reference.clone(), pad: name, net, x: cx, y: cy, layers: vec![layer.into()], area: dx * dy }); } else { 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 }); 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)); }@@ -133,9 +141,10 @@ pub fn load(text: &str) -> Result<Board, String> { b.tracks.push(Track { net: net.clone(), layer, a: (f(&w, "x1"), y(f(&w, "y1"))), b: (f(&w, "x2"), y(f(&w, "y2"))), w: f(&w, "width").max(0.05) }); } for v in s.children().filter(|n| n.has_tag_name("via")) {+ 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 });+ b.vias.push(Via { net: net.clone(), x: f(&v, "x"), y: y(f(&v, "y")), size, drill, drill_long: None }); } // 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@@ -216,6 +225,26 @@ pub fn elements(text: &str) -> Result<Vec<EagleElement>, String> { let parts = super::elements(BRD).unwrap(); assert_eq!(parts[0].names[0], "RC0402"); assert!(parts[1].bottom); }+ #[test] fn fusion_plated_pads_keep_barrels_separate_from_vias() {+ let text = BRD.replace("</package>", "<pad name=\"3\" x=\"0\" y=\"1\" drill=\"0.8\" diameter=\"1.6\"/></package>")+ .replace("<signal name=\"A\">", "<signal name=\"A\"><contactref element=\"R1\" pad=\"3\"/>");+ let b = super::load(&text).unwrap();+ assert_eq!(b.plated_holes.len(), 1);+ assert_eq!(b.plated_holes[0].net, "A");+ assert_eq!(b.plated_holes[0].drill, 0.8);+ assert_eq!(b.vias.len(), 1);+ }+ #[test] fn declared_empty_hidden_inner_layers_are_preserved() {+ let text = BRD.replace("<board>", "<board><designrules><param name=\"layerSetup\" value=\"(1*2*15*16)\"/></designrules>");+ let b = super::load(&text).unwrap();+ assert_eq!(b.layer_names, ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);+ let hidden = BRD.replace("</layers>", "<layer number=\"2\" active=\"yes\" visible=\"no\"/><layer number=\"15\" active=\"yes\" visible=\"no\"/></layers>");+ assert_eq!(super::load(&hidden).unwrap().layer_names, b.layer_names);+ }+ #[test] fn blind_vias_and_six_layers_are_rejected() {+ assert!(super::load(&BRD.replace("extent=\"1-16\"", "extent=\"1-2\"")).is_err());+ assert!(super::load(&BRD.replace("<board>", "<board><designrules><param name=\"layerSetup\" value=\"(1*2*3*14*15*16)\"/></designrules>")).is_err());+ } #[test] fn unfilled_pour_falls_back_to_a_computed_fill() { let t = BRD.replace(|_: char| false, ""); let i = t.find("<polygonfilldetails>").unwrap(); let j = t.find("</polygonfilldetails>").unwrap() + "</polygonfilldetails>".len();
src/fields.rs+120−38@@ -1,5 +1,5 @@ //! The fields: current density per loaded net, heat flow in the copper, temperature over the board.-use crate::board::{Board, LAYERS};+use crate::board::Board; use crate::raster::{Grid, Raster}; use crate::solve::{harm, System}; use serde::{Deserialize, Serialize};@@ -25,16 +25,18 @@ pub struct Spec { #[serde(default)] pub signals: crate::signals::Config, #[serde(default)] pub heatsinks: Vec<crate::heatsink::Block>, #[serde(default)] pub stackupMm: Vec<f64>,+ #[serde(default = "d6000")] pub dcMaxIterations: usize, }+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 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 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 } #[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 dielW: f64, pub airW: f64, pub viasWanted: usize }+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)] pub struct Issue { pub severity: String, pub kind: String, pub what: String, pub at: Option<[f64; 2]>, pub layer: Option<String>, pub net: Option<String>, pub reference: Option<String>, pub fix: String } @@ -55,26 +57,28 @@ pub struct Fields { } pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMut(&str)) -> Fields {+ let layers: Vec<&str> = b.layer_names.iter().map(String::as_str).collect(); let fine = pitch / 2.0; let mut ras = Raster::new(b, fine); ras.draw_board(b); let pool = 2; let (w, h) = (ras.w / pool, ras.h / pool); let d = pitch;- let t_cu: Vec<f64> = LAYERS.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).collect();+ let t_cu: Vec<f64> = layers.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).collect();+ let t_diel = dielectric_mm(spec, layers.len()).expect("validated stackup"); let n = w * h; let cell = |x: f64, y: f64| -> Option<(usize, usize)> { let (c, r) = (((x - b.x0) / d) as isize, ((y - b.y0) / d) as isize); if r >= 0 && c >= 0 && (r as usize) < h && (c as usize) < w { Some((r as usize, c as usize)) } else { None } }; // all copper per layer- let mut frac: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();- for (k, l) in LAYERS.iter().enumerate() { for net in ras.nets_on(l) { let g = ras.fraction(&net, l, pool); frac[k].max_with(&g); } }+ let mut frac: Vec<Grid> = layers.iter().map(|_| Grid::new(w, h)).collect();+ for (k, l) in layers.iter().enumerate() { for net in ras.nets_on(l) { let g = ras.fraction(&net, l, pool); frac[k].max_with(&g); } } // ---- current, one DC solve per loaded net -------------------------------------------------- let mut j_net: BTreeMap<String, Vec<Grid>> = BTreeMap::new();- 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 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 {- 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();+ 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; } let nl = live.len(); let mut sys = System::new(nl, h, w);@@ -84,12 +88,15 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu for r in 0..h - 1 { for c in 0..w { sys.gy[a][r * w + c] = SIGMA * t_cu[*k] * harm(f.at(r, c) as f64, f.at(r + 1, c) as f64); } } for i in 0..n { sys.leak[a][i] = if f.v[i] > 0.0 { 1e-9 } else { 1.0 }; } }- let mut nvias = 0;- for v in b.vias.iter().filter(|v| v.net == *net) {+ 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 };- nvias += 1;- let g = SIGMA * std::f64::consts::PI * ((v.drill / 2.0 + 0.025).powi(2) - (v.drill / 2.0).powi(2)) / 1.6;- for a in 0..nl.saturating_sub(1) { sys.gz[a][r * w + c] += g; }+ 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;+ } } // terminals: the external pads (wire pads, connectors) on one side, the chips that draw the // current on the other, each chip's share by its pad area on the net (a gate driver's sense pin@@ -126,7 +133,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu let rad = (1.0 / d) as isize; let mut spots: Vec<(usize, usize, usize)> = Vec::new(); for (a, k) in live.iter().enumerate() {- if !p.layers.iter().any(|l| l == LAYERS[*k]) { continue; }+ if !p.layers.iter().any(|l| l == layers[*k]) { continue; } for dr in -rad..=rad { for dc in -rad..=rad { let (r, c) = (r0 as isize + dr, c0 as isize + dc); if r < 0 || c < 0 || r as usize >= h || c as usize >= w || dr * dr + dc * dc > rad * rad { continue; }@@ -146,9 +153,9 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu if !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; } } 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, 6000);+ 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 grids: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();+ 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; for (a, k) in live.iter().enumerate() {@@ -171,26 +178,25 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu if !vals.is_empty() { vals.sort_by(|a, b2| a.partial_cmp(b2).unwrap()); let cap = vals[((vals.len() as f64 * 0.995) as usize).min(vals.len() - 1)] as f64;- for i in 0..n { if grids[*k].v[i] as f64 > cap { grids[*k].v[i] = cap as f32; } if f.v[i] >= 0.25 && (grids[*k].v[i] as f64) > peak { peak = grids[*k].v[i] as f64; peak_at = [b.x0 + (i % w) as f64 * d + d / 2.0, b.y0 + (i / w) as f64 * d + d / 2.0]; peak_layer = LAYERS[*k].to_string(); } }+ for i in 0..n { if grids[*k].v[i] as f64 > cap { grids[*k].v[i] = cap as f32; } if f.v[i] >= 0.25 && (grids[*k].v[i] as f64) > peak { peak = grids[*k].v[i] as f64; peak_at = [b.x0 + (i % w) as f64 * d + d / 2.0, b.y0 + (i / w) as f64 * d + d / 2.0]; peak_layer = layers[*k].to_string(); } } } 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 * 10.0).round() / 10.0, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it });+ nets.push(NetResult { net: net.clone(), amps: load.amps, layers: live.iter().map(|k| layers[*k].to_string()).collect(), peakJ: (peak * 10.0).round() / 10.0, 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 }); j_net.insert(net.clone(), grids); } let joule_total: f64 = nets.iter().map(|x| x.jouleW).sum(); // every net with real copper on the board, so the walkthrough can show one pour at a time let mut cu_net: BTreeMap<String, Vec<Grid>> = BTreeMap::new();- for l in LAYERS { for net in ras.nets_on(l) { if cu_net.contains_key(&net) { continue; }- let per: Vec<Grid> = LAYERS.iter().map(|ll| ras.fraction(&net, ll, pool)).collect();+ for l in &layers { for net in ras.nets_on(l) { if cu_net.contains_key(&net) { continue; }+ let per: Vec<Grid> = layers.iter().map(|ll| ras.fraction(&net, ll, pool)).collect(); let area: f64 = per.iter().map(|g| g.sum()).sum::<f64>() * d * d; if area > 5.0 { cu_net.insert(net.clone(), per); } } }- // ---- heat: four layers, the dielectric and the vias between them, the air on the faces --------- let nl = LAYERS.len();- let t_diel: Vec<f64> = if spec.stackupMm.len() == 3 { spec.stackupMm.clone() } else { vec![0.2, 1.065, 0.2] };- let share = [t_diel[0] / 2.0, t_diel[0] / 2.0 + t_diel[1] / 2.0, t_diel[1] / 2.0 + t_diel[2] / 2.0, t_diel[2] / 2.0];+ // ---- heat: actual copper layers, the dielectric and the vias between them, the air on the faces --------+ let nl = layers.len();+ let share = dielectric_share(&t_diel); let build = |h_air: f64| { let mut sys = System::new(nl, h, w); for k in 0..nl {@@ -201,9 +207,9 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu for i in 0..n { sys.leak[k][i] = leak + 1e-12; } } 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 {+ for v in b.vias.iter().chain(&b.plated_holes) { let Some((r, c)) = cell(v.x, v.y) else { continue };- let area = std::f64::consts::PI * ((v.drill / 2.0 + 0.025).powi(2) - (v.drill / 2.0).powi(2));+ let area = v.barrel_area_mm2(); for k in 0..nl - 1 { sys.gz[k][r * w + c] += K_CU * area / t_diel[k]; } } let meshes:Vec<_> = spec.heatsinks.iter().map(|block| crate::heatsink::attach(&mut sys,block,b.x0,b.y0,d,h_air)).collect();@@ -262,13 +268,19 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu for r in 0..h - 1 { for c in 0..w { let (a, bb) = (inside(r, c), inside(r + 1, c)); if a && !bb { plane += fy_all[0][r * w + c]; } else if !a && bb { plane -= fy_all[0][r * w + c]; } } } // the tab's own thermal vias are the ones on its net; other nets' barrels nearby still carry // heat (through the dielectric first) and are counted apart, never as the tab's provision- let (mut via_w, mut other_w, mut diel_w, mut air_w, mut nv, mut no) = (0.0, 0.0, 0.0, 0.0, 0usize, 0usize);+ let (mut via_w, mut other_w, mut plated_w, mut diel_w, mut air_w, mut nv, mut no, mut np) = (0.0, 0.0, 0.0, 0.0, 0.0, 0usize, 0usize, 0usize); let g_diel = K_FR4_Z * d * d / t_diel[0];- let mut own_cells: std::collections::HashSet<usize> = std::collections::HashSet::new();- for v in b.vias.iter().filter(|v| v.net == *tab) { if let Some((r, c)) = cell(v.x, v.y) { if inside(r, c) { own_cells.insert(r * w + c); nv += 1; } } }- for v in b.vias.iter().filter(|v| v.net != *tab) { if let Some((r, c)) = cell(v.x, v.y) { if inside(r, c) { no += 1; } } }- for r in 0..h { for c in 0..w { if !inside(r, c) { continue; } let i = r * w + c; let dt = tv[i] - tv[n + i]; let gv = sys_still.gz[0][i] - g_diel; if gv > 0.0 { if own_cells.contains(&i) { via_w += gv * dt; } else { other_w += gv * dt; } } diel_w += g_diel * dt; air_w += sys_still.leak[0][i] * tv[i]; } }- chips.push(ChipResult { reference: reference.clone(), watts: *watts, tabNet: tab.clone(), x: *x, y: *y, riseC: (tv[r0 * w + c0] * 10.0).round() / 10.0, riseMovingC: (tm[r0 * w + c0] * 10.0).round() / 10.0, planeW: (plane * 1000.0).round() / 1000.0, viaW: (via_w * 1000.0).round() / 1000.0, vias: nv, otherViaW: (other_w * 1000.0).round() / 1000.0, otherVias: no, dielW: (diel_w * 1000.0).round() / 1000.0, airW: (air_w * 1000.0).round() / 1000.0, viasWanted: via_wanted(reference) });+ 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]);+ if plated { plated_w += flow; np += 1; }+ else if v.net == *tab { via_w += flow; nv += 1; }+ else { other_w += flow; no += 1; }+ } }+ }+ for r in 0..h { for c in 0..w { if !inside(r,c) { continue; } let i=r*w+c; diel_w += g_diel*(tv[i]-tv[n+i]); air_w += sys_still.leak[0][i]*tv[i]; } }+ chips.push(ChipResult { reference: reference.clone(), watts: *watts, tabNet: tab.clone(), x: *x, y: *y, riseC: (tv[r0 * w + c0] * 10.0).round() / 10.0, riseMovingC: (tm[r0 * w + c0] * 10.0).round() / 10.0, planeW: (plane * 1000.0).round() / 1000.0, viaW: (via_w * 1000.0).round() / 1000.0, vias: nv, otherViaW: (other_w * 1000.0).round() / 1000.0, otherVias: no, platedPadW: (plated_w*1000.0).round()/1000.0, platedPads: np, dielW: (diel_w * 1000.0).round() / 1000.0, airW: (air_w * 1000.0).round() / 1000.0, viasWanted: via_wanted(reference) }); } // ---- issues ------------------------------------------------------------------------------- let mut issues: Vec<Issue> = Vec::new();@@ -295,6 +307,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu /// The summary the app and the page read. pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value {+ let layers: Vec<&str> = b.layer_names.iter().map(String::as_str).collect(); let mut nets = f.nets.clone(); // relevance: amps first, then the peak density nets.sort_by(|a, bb| bb.amps.partial_cmp(&a.amps).unwrap().then(bb.peakJ.partial_cmp(&a.peakJ).unwrap()));@@ -306,23 +319,23 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value areas.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); let pour_nets: Vec<Value> = areas.into_iter().map(|(n, a)| json!({"net": n, "copperMm2": (a * 10.0).round() / 10.0})).collect(); json!({- "board": {"name": spec.board, "x0": b.x0, "y0": b.y0, "widthMm": b.width(), "heightMm": b.height(), "layers": LAYERS, "copperUm": spec.copperUm, "dielectricMm":if spec.stackupMm.len()==3 {spec.stackupMm.clone()} else {vec![0.2,1.065,0.2]}, "copperUmByLayer":LAYERS.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": if spec.stackupMm.len()==3 {spec.stackupMm.iter().sum::<f64>()} else {1.465} + 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},+ "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,- "signals": f.signals, "heatsinks": f.heatsinks, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual},+ "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<_>>(), "scales": {"jMax": f.j_all.iter().map(|g| g.max()).fold(0.0, f32::max), "qMax": f.q.iter().map(|g| g.max()).fold(0.0, f32::max), "tMax": tmax, "tMovingMax": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max)}, "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,- "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: four layers coupled through the dielectric and every via 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."+ "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." }) } #[cfg(test)] mod thermal_tests { use super::*; #[test] fn board_with_block_lowers_tab_rise_and_reports_heat_received() {- let mut b=Board{ x0:0.,y0:0.,x1:12.,y1:12., ..Board::default() };+ let mut b=Board{ layer_names: crate::board::LAYERS.iter().map(|l|l.to_string()).collect(), x0:0.,y0:0.,x1:12.,y1:12., ..Board::default() }; b.pads.push(crate::board::Pad{reference:"U1".into(),pad:"1".into(),net:"GND".into(),x:6.,y:6.,layers:vec!["F.Cu".into()],area:4.}); let mut spec:Spec=serde_json::from_value(json!({"copperUm":35,"hot":{"U1":{"watts":0.2,"tabNet":"GND"}}})).unwrap(); let baseline=analyze(&b,&spec,1.,60.,&mut |_|{});@@ -335,3 +348,72 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value assert!(cooled.chips[0].riseMovingC<cooled.chips[0].riseC); } }++/// Legacy four-layer defaults remain explicit; other stacks require measured/specification input.+pub fn dielectric_mm(spec: &Spec, copper_layers: usize) -> Result<Vec<f64>, String> {+ if spec.stackupMm.is_empty() && copper_layers == 4 { return Ok(vec![0.2, 1.065, 0.2]); }+ if copper_layers < 2 || spec.stackupMm.len() != copper_layers - 1 || spec.stackupMm.iter().any(|v| !v.is_finite() || *v <= 0.0) {+ return Err(format!("stackupMm requires {} positive dielectric thicknesses for {} copper layers", copper_layers.saturating_sub(1), copper_layers));+ }+ Ok(spec.stackupMm.clone())+}+fn dielectric_share(thickness: &[f64]) -> Vec<f64> {+ (0..=thickness.len()).map(|k| thickness.get(k).unwrap_or(&0.0)/2.0 + k.checked_sub(1).and_then(|i|thickness.get(i)).unwrap_or(&0.0)/2.0).collect()+}++#[cfg(test)]+mod stackup_tests {+ use super::*;+ #[test]+ fn two_layer_stack_requires_one_explicit_interval() {+ let mut spec: Spec = serde_json::from_value(json!({"copperUm":35})).unwrap();+ assert!(dielectric_mm(&spec, 2).is_err());+ assert_eq!(dielectric_mm(&spec, 4).unwrap(), [0.2, 1.065, 0.2]);+ spec.stackupMm = vec![1.53];+ assert_eq!(dielectric_mm(&spec, 2).unwrap(), [1.53]);+ assert!(dielectric_mm(&spec, 4).is_err());+ assert_eq!(dielectric_share(&spec.stackupMm), [0.765, 0.765]);+ let b = Board { x1: 8., y1: 8., layer_names: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+ let f = analyze(&b, &spec, 1., 60., &mut |_| {});+ assert_eq!(f.frac.len(), 2);+ assert_eq!(f.t.len(), 2);+ let report = summary(&f, &b, &spec, &[]);+ assert_eq!(report["board"]["layers"], json!(["F.Cu", "B.Cu"]));+ assert!((report["board"]["thicknessMm"].as_f64().unwrap() - 1.6).abs() < 1e-9);+ }+}++pub fn validate_current_residual(net: &str, residual: f64) -> Result<(), String> {+ if !residual.is_finite() || residual > 1e-6 || residual < 0.0 {+ return Err(format!("DC solve for {net} did not converge (residual {residual}); no current/thermal summary is accepted; inspect copper raster connectivity and mesh resolution"));+ }+ Ok(())+}+#[cfg(test)] mod convergence_tests {+ #[test] fn reject_divergent_or_nonfinite_current_before_accepting_heat() {+ for residual in [320.0, 1.1e-6, f64::NAN, f64::INFINITY, -1.0] {+ assert!(super::validate_current_residual("GND", residual).is_err());+ }+ assert!(super::validate_current_residual("GND", 9.4e-8).is_ok());+ }+}++#[cfg(test)] mod plated_connection_tests {+ use super::*;+ #[test] fn plated_pad_connects_top_source_to_bottom_sink_without_any_via() {+ 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 });+ 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);+ validate_current_residual("GND",f.nets[0].residual).unwrap();+ assert_eq!(f.nets[0].vias,0);+ assert_eq!(f.nets[0].platedPads,1);+ assert!(f.nets[0].jouleW<0.1);+ assert!(f.heat_residual<1e-6);+ }+}
src/main.rs+10−6@@ -57,15 +57,18 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck let spec: fields::Spec = serde_json::from_str(&std::fs::read_to_string(spec_path).map_err(|e| format!("{spec_path}: {e}"))?).map_err(|e| format!("{spec_path}: {e}"))?; if !pitch.is_finite() || pitch <= 0.0 || pitch > b.width().min(b.height())/2.0 { return Err("pitch must be positive and resolve at least two cells across the board".into()); } if !spec.copperUm.is_finite() || spec.copperUm<=0.0 {return Err("copperUm must be positive".into());}- if !spec.stackupMm.is_empty() && (spec.stackupMm.len()!=3 || spec.stackupMm.iter().any(|v|!v.is_finite() || *v<=0.0)) {return Err("stackupMm requires three positive dielectric thicknesses".into());}- for (layer,t) in &spec.copperUmByLayer { if !board::LAYERS.contains(&layer.as_str()) || !t.is_finite() || *t<=0.0 {return Err("copperUmByLayer requires known copper layers and positive thicknesses".into());} }- spec.signals.validate()?;+ if !(1..=100000).contains(&spec.dcMaxIterations) { return Err("dcMaxIterations must be between 1 and 100000; convergence tolerance is unchanged".into()); }+ 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());} }+ 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());}} for load in spec.loads.values() {if !load.amps.is_finite() || load.amps<0.0 {return Err("load amps must be finite and nonnegative".into());}} if !spec.signals.capacitance.is_empty() || !spec.signals.differential_pairs.is_empty() {- if spec.stackupMm.len()!=3 {return Err("signals requires explicit stackupMm: three dielectric thicknesses, consistent with each signal check".into());}+ if spec.stackupMm.len()!=layers.len()-1 {return Err("signals requires explicit stackupMm: one dielectric thickness per adjacent copper-layer interval, consistent with each signal check".into());} for (layer_a,layer_b,distance) in spec.signals.capacitance.iter().map(|c|(&c.layer_a,&c.layer_b,c.dielectric_mm)).chain(spec.signals.differential_pairs.iter().map(|p|(&p.layer,&p.reference_layer,p.dielectric_mm))) {- let a=board::LAYERS.iter().position(|l|*l==layer_a).unwrap();let b=board::LAYERS.iter().position(|l|*l==layer_b).unwrap();+ let a=layers.iter().position(|l|*l==layer_a).unwrap();let b=layers.iter().position(|l|*l==layer_b).unwrap(); if (spec.stackupMm[a.min(b)]-distance).abs()>1e-6 {return Err("signal dielectricMm disagrees with stackupMm for its adjacent layers".into());} } }@@ -81,13 +84,14 @@ 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 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"); let mut textures: Vec<String> = Vec::new(); let jhi = f.j_all.iter().map(|g| g.max()).fold(1.0, f32::max); let qhi = f.q.iter().map(|g| g.max()).fold(0.01, f32::max); let thi = f.t.iter().map(|g| g.max()).fold(1.0, f32::max);- for (k, l) in board::LAYERS.iter().enumerate() {+ for (k, l) in layers.iter().enumerate() { let ls = slug(l); tex::write_mask(&tex.join(format!("copper-{ls}.png")).display().to_string(), &f.frac[k], [214, 148, 90])?; textures.push(format!("copper-{ls}.png")); tex::write(&tex.join(format!("J-{ls}.png")).display().to_string(), &f.j_all[k], Some(&f.frac[k]), &tex::Scale::Log { lo: jhi / 300.0, hi: jhi }, false)?; textures.push(format!("J-{ls}.png"));
src/raster.rs+2−2@@ -1,5 +1,5 @@ //! Copper masks on a fine grid, pooled to the solve grid as copper fractions.-use crate::board::{Board, Poly, LAYERS};+use crate::board::{Board, Poly}; use std::collections::HashMap; pub struct Raster { pub w: usize, pub h: usize, pub res: f64, pub x0: f64, pub y0: f64, pub masks: HashMap<(String, String), Vec<u8>> }@@ -60,7 +60,7 @@ impl Raster { let polys: Vec<Poly> = b.polys.iter().chain(b.pad_polys.iter()).cloned().collect(); for p in &polys { self.fill_poly(&p.net, &p.layer, &p.pts); } for t in &b.tracks { let (net, layer, a, bb, w) = (t.net.clone(), t.layer.clone(), t.a, t.b, t.w); self.thick_line(&net, &layer, a, bb, w); }- for v in &b.vias { for l in LAYERS { let (net, x, y, s) = (v.net.clone(), v.x, v.y, v.size); self.disc(&net, l, x, y, s / 2.0); } }+ 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); }
src/signals.rs+19−5@@ -1,7 +1,8 @@ //! Explicit, limited signal screening. Not a frequency-domain field solver. use serde::{Deserialize,Serialize}; use serde_json::{json,Value};-use crate::board::{Board,Track,LAYERS};+use crate::board::{Board,Track};+#[cfg(test)] use crate::board::LAYERS; use crate::raster::Raster; use std::collections::{BTreeMap,BTreeSet}; #[derive(Clone,Default,Deserialize,Serialize)]@@ -16,12 +17,14 @@ pub struct Capacitance {pub name:String,pub net_a:String,pub layer_a:String,pub #[derive(Clone,Deserialize,Serialize)] #[serde(rename_all="camelCase",deny_unknown_fields)] pub struct Pair {pub name:String,pub positive:String,pub negative:String,pub layer:String,pub reference_net:String,pub reference_layer:String,pub dielectric_mm:f64,pub epsilon_r:f64,pub max_skew_mm:f64,pub target_ohms:f64,pub tolerance_percent:f64}-fn adjacent(a:&str,b:&str)->bool {match (LAYERS.iter().position(|l|*l==a),LAYERS.iter().position(|l|*l==b)) {(Some(a),Some(b))=>a.abs_diff(b)==1,_=>false}}+fn adjacent(layers:&[&str],a:&str,b:&str)->bool {match (layers.iter().position(|l|*l==a),layers.iter().position(|l|*l==b)) {(Some(a),Some(b))=>a.abs_diff(b)==1,_=>false}} fn material(h:f64,er:f64)->bool {h.is_finite()&&h>0.&&er.is_finite()&&er>=1.}-impl Config {pub fn validate(&self)->Result<(),String>{+impl Config {+ #[cfg(test)] pub fn validate(&self)->Result<(),String>{ self.validate_layers(&LAYERS) }+ pub fn validate_layers(&self,layers:&[&str])->Result<(),String>{ let mut names=BTreeSet::new();- for c in &self.capacitance {if c.name.trim().is_empty()||!names.insert(c.name.clone())||c.net_a==c.net_b||!adjacent(&c.layer_a,&c.layer_b)||!material(c.dielectric_mm,c.epsilon_r){return Err("capacitance needs unique names, different nets, adjacent known layers and positive dielectricMm/epsilonR >= 1".into());}}- for p in &self.differential_pairs {if p.name.trim().is_empty()||!names.insert(p.name.clone())||p.positive==p.negative||!matches!(p.layer.as_str(),"F.Cu"|"B.Cu")||!adjacent(&p.layer,&p.reference_layer)||!material(p.dielectric_mm,p.epsilon_r)||!p.max_skew_mm.is_finite()||p.max_skew_mm<0.||!p.target_ohms.is_finite()||p.target_ohms<=0.||!p.tolerance_percent.is_finite()||p.tolerance_percent<=0. {return Err("differentialPairs requires unique names, distinct nets, outer microstrip layer, adjacent reference, explicit dielectric and positive target/tolerance".into());}}+ for c in &self.capacitance {if c.name.trim().is_empty()||!names.insert(c.name.clone())||c.net_a==c.net_b||!adjacent(layers,&c.layer_a,&c.layer_b)||!material(c.dielectric_mm,c.epsilon_r){return Err("capacitance needs unique names, different nets, adjacent known layers and positive dielectricMm/epsilonR >= 1".into());}}+ for p in &self.differential_pairs {if p.name.trim().is_empty()||!names.insert(p.name.clone())||p.positive==p.negative||!matches!(p.layer.as_str(),"F.Cu"|"B.Cu")||!adjacent(layers,&p.layer,&p.reference_layer)||!material(p.dielectric_mm,p.epsilon_r)||!p.max_skew_mm.is_finite()||p.max_skew_mm<0.||!p.target_ohms.is_finite()||p.target_ohms<=0.||!p.tolerance_percent.is_finite()||p.tolerance_percent<=0. {return Err("differentialPairs requires unique names, distinct nets, outer microstrip layer, adjacent reference, explicit dielectric and positive target/tolerance".into());}} Ok(()) }} pub fn microstrip(w:f64,h:f64,t:f64,er:f64)->Option<f64>{@@ -102,3 +105,14 @@ pub fn analyze(b:&Board,r:&Raster,c:&Config,copper:&BTreeMap<String,f64>,fallbac assert_eq!(a["capacitance"][0]["status"],"missing-copper"); } }++#[cfg(test)]+mod layer_tests {+ use super::*;+ #[test]+ fn front_back_adjacency_depends_on_actual_stack() {+ let c: Config = serde_json::from_value(json!({"capacitance":[{"name":"planes","netA":"A","layerA":"F.Cu","netB":"B","layerB":"B.Cu","dielectricMm":1.53,"epsilonR":4}]})).unwrap();+ assert!(c.validate_layers(&["F.Cu", "B.Cu"]).is_ok());+ assert!(c.validate_layers(&LAYERS).is_err());+ }+}
src/solve.rs+3@@ -67,6 +67,9 @@ impl System { let beta = rz2 / rz; rz = rz2; for i in 0..n { p[i] = z[i] + beta * p[i]; } }+ // Report the actual equation residual, not just the recursively updated CG estimate.+ self.matvec(&x, &mut ap);+ res = b.iter().zip(&ap).map(|(rhs, ax)| (rhs - ax).powi(2)).sum::<f64>().sqrt() / bnorm; (x, it, res) } }
src/ui/app.js+8−3@@ -5,8 +5,8 @@ // every rotation runs at half the speed it did in 0.4: the flow plays these clips ten times faster, // and a sway that reads as calm live turned frenetic on the sped-up video const SLOW = 2;- const LAYERS = ['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'];- const Z = { 'F.Cu': 0.8, 'In1.Cu': 0.6, 'In2.Cu': -0.6, 'B.Cu': -0.8 };+ let LAYERS = [];+ const Z = {}; const slug = (s) => s.replace(/[^A-Za-z0-9]/g, '_'); // console to the server, so the AI reads what the UI does const post = (p, body) => fetch(p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }).then(r => r.json()).catch(() => null);@@ -40,6 +40,11 @@ function boardSpace(x, y, z = 0) { return new BAB.Vector3(x - board.x0 - board.widthMm / 2, board.y0 + board.heightMm / 2 - y, z); } function buildBoard(f) { board = f.board;+ LAYERS = [...board.layers];+ $('#layer').replaceChildren(...LAYERS.map(layer => {+ const button = document.createElement('button');+ button.dataset.l = layer; button.textContent = layer; return button;+ })); const cu = board.copperUmByLayer || Object.fromEntries(LAYERS.map(l=>[l,board.copperUm])); const dielectric = board.dielectricMm || [0.2,1.065,0.2]; Z['F.Cu']=board.thicknessMm/2-cu['F.Cu']/2000;@@ -279,7 +284,7 @@ for (const b of document.querySelectorAll('.views .chip')) b.classList.toggle('is-on', b.dataset.cam === v.camera); const jmax = Math.max(1, ...f.nets.map(n => n.peakJ)); $('#nets').innerHTML = f.nets.map(n => `<div class="row ${v.net === n.net ? 'is-on' : ''}" data-net="${n.net}" data-tip="${n.amps} A on ${n.layers.join(', ')}; ${n.copperMm2} mm² of copper, ${n.vias} vias, ${n.jouleW} W of Joule loss. Click to light this net alone."><div style="flex:1;min-width:0"><div class="name">${n.net}</div><div class="bar" style="width:${(n.peakJ / jmax * 100).toFixed(0)}%"></div></div><div class="num">${n.amps} A · ${n.peakJ.toFixed(0)} A/mm²</div></div>`).join('') || '<div class="empty">no loaded nets in the spec</div>';- $('#chips').innerHTML = f.chips.map(c => `<div class="row ${v.chip === c.reference ? 'is-on' : ''}" data-chip="${c.reference}" data-tip="${c.watts} W on ${c.tabNet}: ${(c.planeW / c.watts * 100).toFixed(0)}% spreads along F.Cu, ${(c.viaW / c.watts * 100).toFixed(0)}% goes down its ${c.vias} own via(s) on ${c.tabNet} (spec asks ${c.viasWanted}), ${(c.otherViaW / c.watts * 100).toFixed(0)}% through ${c.otherVias} other nets' barrel(s) nearby, ${(c.dielW / c.watts * 100).toFixed(0)}% through the bare dielectric. +${c.riseMovingC} °C in moving air."><div class="name">${c.reference} <span class="num">${c.watts} W</span></div><div class="num" style="color:${c.riseC > f.heat.budgetC ? 'var(--red)' : 'var(--text)'}">+${c.riseC.toFixed(0)} °C</div></div>`).join('') || '<div class="empty">no hot parts in the spec</div>';+ $('#chips').innerHTML = f.chips.map(c => `<div class="row ${v.chip === c.reference ? 'is-on' : ''}" data-chip="${c.reference}" data-tip="${c.watts} W on ${c.tabNet}: ${(c.planeW / c.watts * 100).toFixed(0)}% spreads along F.Cu, ${(c.viaW / c.watts * 100).toFixed(0)}% goes down its ${c.vias} own via(s) on ${c.tabNet} (spec asks ${c.viasWanted}), ${(c.otherViaW / c.watts * 100).toFixed(0)}% through ${c.otherVias} other nets' via barrel(s) nearby, ${((c.platedPadW || 0) / c.watts * 100).toFixed(0)}% through ${c.platedPads || 0} plated-pad barrel(s), ${(c.dielW / c.watts * 100).toFixed(0)}% through the bare dielectric. +${c.riseMovingC} °C in moving air."><div class="name">${c.reference} <span class="num">${c.watts} W</span></div><div class="num" style="color:${c.riseC > f.heat.budgetC ? 'var(--red)' : 'var(--text)'}">+${c.riseC.toFixed(0)} °C</div></div>`).join('') || '<div class="empty">no hot parts in the spec</div>'; $('#issues').innerHTML = f.issues.map((i, k) => `<div class="issue ${v.issue === k ? 'is-on' : ''}" data-issue="${k}"><span class="sev ${i.severity}">${i.severity}</span> <span class="num" style="font-family:JetBrains Mono;font-size:11px;color:var(--text-2)">${i.kind}</span><div class="what">${i.what}</div><div class="fix">${i.fix}</div></div>`).join('') || '<div class="empty">No current or thermal threshold findings in this solve. Signal checks are reported separately.</div>'; const signals = f.signals || {}; const signalPanel=$('#signals'); signalPanel.replaceChildren(); const addSignal=(text)=>{const row=document.createElement('div');row.className='empty';row.textContent=text;signalPanel.appendChild(row);};
src/ui/index.html+1−1@@ -95,7 +95,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", <div class="app-body"> <aside class="side" id="left"> <div class="sec"><h3>Field</h3><div class="seg" id="qty"><button data-q="current" data-tip="Current density on the copper, amps per square millimetre, every loaded net at its spec current">Current</button><button data-q="heat" data-tip="Heat flow in the copper, watts per millimetre of width, from the hot tabs and the Joule loss">Heat</button><button data-q="signals" data-tip="Copper overlap capacitance and differential-pair screening">Signals</button><button data-q="temperature" data-tip="Temperature rise over the board, the FR4 spreading">Temperature</button></div></div>- <div class="sec"><h3>Layer</h3><div class="seg" id="layer"><button data-l="F.Cu">F.Cu</button><button data-l="In1.Cu">In1</button><button data-l="In2.Cu">In2</button><button data-l="B.Cu">B.Cu</button></div></div>+ <div class="sec"><h3>Layer</h3><div class="seg" id="layer"></div></div> <div class="sec" id="airSec"><h3>Air</h3><div class="seg" id="air"><button data-a="still" data-tip="Still air, 10 W/m²K on both faces">Still</button><button data-a="moving" data-tip="Moving air, 25 W/m²K: a fan or a moving vehicle">Moving</button></div></div> <div class="sec"><h3>Nets, by what they carry</h3><div id="nets"></div></div> <div class="sec"><h3>Hot parts, hottest first</h3><div id="chips"></div></div>