app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
Comparing main ← explicit-pin-current-boundaries
Changes on explicit-pin-current-boundaries that are not yet on main (three-dot, from the merge base).
12 files changed, 441 insertions(+), 77 deletions(-)
README.md+22@@ -98,3 +98,25 @@ 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.++## Explicit pin currents (source candidate)++Each `loads.<net>` may supply `terminals`, an array of `{ "reference": "J1", "pad": "1", "amps": 3.0 }` rows. Positive current enters the board copper; negative current leaves it. Positive and negative totals must each equal that net's `amps`. Include sense-branch current separately instead of assigning the entire load to every narrow track. Pin identifiers must resolve uniquely on the specified net; duplicate, missing, wrong-net and unbalanced assignments are rejected. Pads omitted from this explicit boundary condition draw zero modeled current, so this is an engineering assumption to review against the circuit, including fixture return paths and auxiliary loads. Repeated pad numbers currently require a unique physical pad and are rejected when ambiguous.++Reports retain the declared pins in `terminalAssignments` and label every net as `explicit` or `heuristic`. Legacy loads without the array retain the existing terminal heuristic. A loaded net that cannot be solved now prevents publishing a partial result. No partial current injection is accepted if any nonzero terminal has no resolved copper.++This change does not replace the current injection's existing 1 mm neighborhood approximation with an exact package contact model. Refine the mesh and review pad geometry and boundary conditions before accepting density values. It also does not model the external MOSFET heatsink or certify thermal performance. This candidate is stacked on physical-stack PR #5; neither change is a deployed shared release yet.
SKILL.md+22@@ -90,3 +90,25 @@ 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.++## Explicit pin currents (source candidate)++Each `loads.<net>` may supply `terminals`, an array of `{ "reference": "J1", "pad": "1", "amps": 3.0 }` rows. Positive current enters the board copper; negative current leaves it. Positive and negative totals must each equal that net's `amps`. Include sense-branch current separately instead of assigning the entire load to every narrow track. Pin identifiers must resolve uniquely on the specified net; duplicate, missing, wrong-net and unbalanced assignments are rejected. Pads omitted from this explicit boundary condition draw zero modeled current, so this is an engineering assumption to review against the circuit, including fixture return paths and auxiliary loads. Repeated pad numbers currently require a unique physical pad and are rejected when ambiguous.++Reports retain the declared pins in `terminalAssignments` and label every net as `explicit` or `heuristic`. Legacy loads without the array retain the existing terminal heuristic. A loaded net that cannot be solved now prevents publishing a partial result. No partial current injection is accepted if any nonzero terminal has no resolved copper.++This change does not replace the current injection's existing 1 mm neighborhood approximation with an exact package contact model. Refine the mesh and review pad geometry and boundary conditions before accepting density values. It also does not model the external MOSFET heatsink or certify thermal performance. This candidate is stacked on physical-stack PR #5; neither change is a deployed shared release yet.
src/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+169−47@@ -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 Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64>, #[serde(default)] pub terminals: Option<Vec<crate::terminals::Terminal>> } #[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, pub tabNet: String } #[derive(Serialize, Clone)]-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@@ -116,7 +123,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu chips = if rest.is_empty() { cands.iter().cloned().filter(|p| p.reference != big).collect() } else { rest }; } }- if ext.is_empty() || chips.is_empty() { log(&format!("{net}: no terminals found (needs pads of two parts on the net), skipped")); continue; }+ if load.terminals.is_none() && (ext.is_empty() || chips.is_empty()) { log(&format!("{net}: no terminals found (needs pads of two parts on the net), skipped")); continue; } let mut area_by_ref: BTreeMap<String, f64> = BTreeMap::new(); for p in &chips { *area_by_ref.entry(p.reference.clone()).or_default() += p.area.max(0.05); } let area_total: f64 = area_by_ref.values().sum();@@ -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; }@@ -136,19 +143,34 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu spots }; let (mut hs, mut hk) = (0usize, 0usize);- let per_ext = load.amps / ext.len() as f64;- for p in &ext { let sp = spots_of(p); if sp.is_empty() { continue; } hs += 1; for (a, r, c) in &sp { rhs[a * n + r * w + c] += per_ext / sp.len() as f64; } }+ let assignments: Vec<(&crate::board::Pad, f64)> = if let Some(rows) = &load.terminals {+ match crate::terminals::resolve(b, net, load.amps, rows) {+ Ok(rows) => rows,+ Err(error) => { log(&error); continue; }+ }+ } else {+ let mut rows: Vec<_> = ext.iter().map(|p| (*p, load.amps / ext.len() as f64)).collect();+ for p in &chips {+ let share = load.amps * area_by_ref[&p.reference] / area_total / chips.iter().filter(|q| q.reference == p.reference).count() as f64;+ rows.push((*p, -share));+ }+ rows+ }; let mut pinned = false;- for p in &chips {- let share = load.amps * area_by_ref[&p.reference] / area_total / chips.iter().filter(|q| q.reference == p.reference).count() as f64;- let sp = spots_of(p); if sp.is_empty() { continue; } hk += 1;- for (a, r, c) in &sp { rhs[a * n + r * w + c] -= share / sp.len() as f64; }- if !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; }+ let mut missing = false;+ for (p, amps) in &assignments {+ if *amps == 0.0 { continue; }+ let sp = spots_of(p);+ if sp.is_empty() { missing = true; break; }+ if *amps > 0.0 { hs += 1; } else { hk += 1; }+ for (a, r, c) in &sp { rhs[a * n + r * w + c] += amps / sp.len() as f64; }+ if *amps < 0.0 && !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; } }+ if missing { log(&format!("{net}: a terminal has no resolved copper; refusing partial current injection")); continue; } if hs == 0 || hk == 0 { log(&format!("{net}: terminals not on copper (sources {hs}, sinks {hk}), skipped")); continue; }- let (v, it, res) = sys.solve(&rhs, 1e-7, 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 +193,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 +222,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 +283,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 +322,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 +334,48 @@ 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},+ "terminalAssignments": spec.loads.iter().map(|(net, load)| (net.clone(), json!({"mode": if load.terminals.is_some() {"explicit"} else {"heuristic"}, "pins": load.terminals}))).collect::<BTreeMap<_,_>>(),+ "signals": f.signals, "heatsinks": f.heatsinks, "dcMaxIterations":spec.dcMaxIterations, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual}, "vias": b.vias.iter().map(|v| json!({"net": v.net, "x": v.x, "y": v.y, "size": v.size, "drill": v.drill})).collect::<Vec<_>>(), "pads": b.pads.iter().filter(|p| spec.hot.contains_key(&p.reference) || spec.loads.contains_key(&p.net)).map(|p| json!({"ref": p.reference, "pad": p.pad, "net": p.net, "x": p.x, "y": p.y})).collect::<Vec<_>>(), "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 terminal_tests {+ use super::*;+ #[test] fn explicit_fuse_diode_path_is_independent_of_designator_and_current_direction() {+ let mut b = Board { x1: 10., y1: 6., layer_names: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+ b.polys.push(crate::board::Poly { net:"V".into(), layer:"F.Cu".into(), pts:vec![(0.,0.),(10.,0.),(10.,6.),(0.,6.)] });+ for (reference,x) in [("F1",1.5),("D1",8.5),("R24",5.)] {+ b.pads.push(crate::board::Pad { reference:reference.into(), pad:"1".into(), net:"V".into(), x,y:3., layers:vec!["F.Cu".into()],area:1. });+ }+ let mut spec:Spec=serde_json::from_value(json!({"copperUm":35,"stackupMm":[1.53],"loads":{"V":{"amps":3,"terminals":[{"reference":"F1","pad":"1","amps":3},{"reference":"D1","pad":"1","amps":-2.9999},{"reference":"R24","pad":"1","amps":-0.0001}]}}})).unwrap();+ let f=analyze(&b,&spec,0.5,60.,&mut |_|{});+ assert_eq!(f.nets.len(),1);+ validate_current_residual("V",f.nets[0].residual).unwrap();+ assert!(f.nets[0].jouleW > 0.);+ for row in spec.loads.get_mut("V").unwrap().terminals.as_mut().unwrap() { row.amps = -row.amps; }+ let reversed=analyze(&b,&spec,0.5,60.,&mut |_|{});+ assert_eq!(reversed.nets.len(),1);+ assert!((f.nets[0].jouleW-reversed.nets[0].jouleW).abs()<1e-5);+ assert!((f.nets[0].peakJ-reversed.nets[0].peakJ).abs()<0.1);+ b.pads[1].x = 50.;+ let missing=analyze(&b,&spec,0.5,60.,&mut |_|{});+ assert!(missing.nets.is_empty(), "a missing sink must not be silently omitted");+ }+}+ #[cfg(test)] mod thermal_tests { use super::*; #[test] fn board_with_block_lowers_tab_rise_and_reports_heat_received() {- 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 +388,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+13−6@@ -1,5 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};+mod terminals; mod eagle; mod parts; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use std::path::PathBuf;@@ -57,15 +58,19 @@ 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());} }+ for (net, load) in &spec.loads { if let Some(rows) = &load.terminals { terminals::resolve(&b, net, load.amps, rows)?; } }+ let layers: Vec<&str> = b.layer_names.iter().map(String::as_str).collect();+ spec.signals.validate_layers(&layers)?; for hot in spec.hot.values() {if !hot.watts.is_finite() || hot.watts<0.0 {return Err("hot watts must be finite and nonnegative".into());}} 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 +86,15 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck let t0 = std::time::Instant::now(); let mut lines: Vec<String> = Vec::new(); let f = fields::analyze(&b, &spec, pitch, neck, &mut |s| { eprintln!("{s}"); lines.push(s.to_string()); });+ for (net, load) in &spec.loads { if load.amps > 0.0 && !f.nets.iter().any(|r| r.net == *net) { return Err(format!("{net}: loaded net was not solved; no partial report will be published")); } }+ for net in &f.nets { fields::validate_current_residual(&net.net, net.residual)?; } if !f.heat_residual.is_finite() || f.heat_residual>1e-6 || !f.heat_moving_residual.is_finite() || f.heat_moving_residual>1e-6 {return Err(format!("thermal solve did not converge: still {}, moving {}; refine the model or increase the solve budget",f.heat_residual,f.heat_moving_residual));} let tex = out.join("tex"); 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/terminals.rsadded+62@@ -0,0 +1,62 @@+//! Explicit signed pin currents. Positive enters copper; negative leaves it.+use crate::board::{Board, Pad};+use serde::{Deserialize, Serialize};+use std::collections::BTreeSet;++#[derive(Clone, Debug, Deserialize, Serialize)]+#[serde(deny_unknown_fields)]+pub struct Terminal {+ pub reference: String,+ pub pad: String,+ pub amps: f64,+}++pub fn resolve<'a>(board: &'a Board, net: &str, amps: f64, rows: &[Terminal])+ -> Result<Vec<(&'a Pad, f64)>, String>+{+ if rows.is_empty() { return Err(format!("{net}: explicit terminals must not be empty")); }+ let mut seen = BTreeSet::new();+ let mut result = Vec::new();+ let (mut positive, mut negative) = (0.0, 0.0);+ for t in rows {+ if !t.amps.is_finite() || !seen.insert((&t.reference, &t.pad)) {+ return Err(format!("{net}: terminal currents must be finite and pin assignments unique"));+ }+ let pads: Vec<_> = board.pads.iter().filter(|p| p.reference == t.reference && p.pad == t.pad).collect();+ if pads.len() != 1 || pads[0].net != net {+ return Err(format!("{net}: {}.{} must identify exactly one pad on this net", t.reference, t.pad));+ }+ positive += t.amps.max(0.0);+ negative += (-t.amps).max(0.0);+ result.push((pads[0], t.amps));+ }+ let tolerance = 1e-9_f64.max(amps.abs() * 1e-8);+ if !amps.is_finite() || amps < 0.0 || (positive - amps).abs() > tolerance || (negative - amps).abs() > tolerance {+ return Err(format!("{net}: sources ({positive} A) and sinks ({negative} A) must each equal load ({amps} A)"));+ }+ Ok(result)+}++#[cfg(test)]+mod tests {+ use super::*;+ fn board() -> Board {+ Board { pads: [("J1", "1", "V"), ("F1", "1", "V"), ("R1", "1", "V"), ("J2", "1", "GND")].into_iter().map(|(r,p,n)| Pad { reference:r.into(), pad:p.into(), net:n.into(), x:0., y:0., layers:vec!["F.Cu".into()], area:1. }).collect(), ..Board::default() }+ }+ fn row(reference: &str, amps: f64) -> Terminal { Terminal { reference:reference.into(), pad:"1".into(), amps } }+ #[test] fn main_and_sense_branches_preserve_assigned_currents() {+ let b=board();+ let r=resolve(&b,"V",3.,&[row("J1",3.),row("F1",-2.9999),row("R1",-0.0001)]).unwrap();+ assert_eq!(r[1].0.reference,"F1"); assert_eq!(r[2].1,-0.0001);+ }+ #[test] fn reject_wrong_net_missing_duplicate_and_unbalanced_terminals() {+ let b=board();+ for rows in [vec![],vec![row("J1",3.),row("J2",-3.)],vec![row("J1",3.),row("MISSING",-3.)],vec![row("J1",3.),row("F1",-2.)],vec![row("J1",3.),row("F1",-1.5),row("F1",-1.5)],vec![row("J1",f64::NAN),row("F1",-3.)]] {+ assert!(resolve(&b,"V",3.,&rows).is_err());+ }+ }+ #[test] fn reject_ambiguous_repeated_pad_number() {+ let mut b=board(); b.pads.push(b.pads[0].clone());+ assert!(resolve(&b,"V",3.,&[row("J1",3.),row("F1",-3.)]).is_err());+ }+}
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>