app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
adom-fields 0.8.1: per-part maxRiseC (issue 16); terminals from named sources and per-part draws, balanced injection, terminalCurrents (issue 17)
8 files changed
+83−39
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.8.0"+version = "0.8.1" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."
SKILL.md+2−1@@ -14,7 +14,7 @@ One binary, three commands, one app. The AI does the analysis; the human explore adom-fields analyze --board <board.kicad_pcb | board.brd from Fusion> --spec <spec.json> --out fields ``` -The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC, maxDropMv), `hot` (ref: watts, tabNet), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.+The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC, maxDropMv, draws: {part: amps}), `sources` (net: [the parts that feed it]), `hot` (ref: watts, tabNet, maxRiseC: that part's own limit, else hotMaxRiseC), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper. What comes out, in `fields/fields.json` and `fields/tex/`: - `nets`, ordered by amps then by peak density: peak A/mm² and where, the layers, the copper mm², the Joule loss, the vias.@@ -177,6 +177,7 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Terminals** (0.8.1): without explicit `loads.<net>.terminals`, the solve picks them. The named `sources` feed the net, and every other connector and part on it is a sink, so a board-to-board connector that a rail feeds counts as a load. Otherwise connectors feed and parts draw. A part listed in `loads.<net>.draws` carries its own amps, the rest share what is left (sinks by pad area, sources evenly), and each part's share splits evenly over its pins. A pin with no copper near it is left out and its share goes to the other pins on its side, so the injection always balances. Every net in fields.json lists `terminalCurrents` (pin, amps, + into the copper) and `terminalBasis`, so a reviewer can check them. - **Solid bodies off the board** (0.8.0): `solids: {pitchMm, bodies: [{name, boxesMm: [[x0,y0,z0,x1,y1,z1]], conductivityWMk, watts | heatOf: "<hot ref>", hStillWM2k, hMovingWM2k, boardContactM2kW?, provenance}], contacts: [{bodies: [a, b], resistanceM2kW}], boardLinks: [{body, at, layer, resistanceKPerW, pad}], assumptions}`. Boxes are in board mm (x and y as KiCad, z up from the top copper). The bodies are voxelized and solved in the same heat system as the board, so the heat runs from the die through the tab, the pad and the heatsink fins to the air, and down the leads into the copper, in one energy balance. Bodies may touch only through a declared contact (0 means ideal), and they may not overlap. `fields.json` `solids` gives each body's peak rise, heat to air and to board, and peak flux. `solids.json` has every cell's temperature and flux vector, and the viewer paints them in the Heat and Temperature views. Calibrate a sink's h to its vendor rating rather than trusting a textbook h. See docs/solids.md. - **Fan levels** (0.8.0): `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level fully. h on every exposed face (board, bodies, a packageCooling sink) follows (h_still³ + (h_forced·sⁿ)³)^⅓, where s is the fraction of full speed, s = 1 gives the moving-air h, and n is `fanExponent` (0.5). `fields.json` `airStates` gives each level's h, chip rises and bodies. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together, on one colour scale, so the levels compare by eye. `ui view --air fan50` drives it. - **Package cooling** (PR 11): `packageCooling: [{reference, junctionCaseKPerW, caseSinkKPerW, sinkAmbientStillKPerW, sinkAmbientMovingKPerW, boardLinks, provenance}]` puts a hot part's watts at its junction and lets them split through the supplied case, sink and board links; junction, case and sink rises per air state and an energy balance in `packageCooling`. See docs/package-cooling.md.
docs/release-0.8.1.mdadded+10@@ -0,0 +1,10 @@+# adom-fields 0.8.1++- **Per-part rise limits (issue 16).** `hot.<ref>.maxRiseC` is read and applied. A part with its own limit is checked against it, not the board's `hotMaxRiseC`, so a part between its own limit and the board budget is no longer a silent pass. The issue text says which limit applied. A part without an override still uses `hotMaxRiseC`, and a non-positive or non-finite override is refused.+- **Terminals follow the design (issue 17):**+ - Named `sources` now choose the source side. Every other connector and part on the net is a sink, a board-to-board connector the rail feeds included.+ - `loads.<net>.draws` gives a part its own current, on either side. The rest share what is left: sinks by pad area, sources evenly.+ - A part's share splits evenly over its pins.+ - A pin with no copper near it gives its share to the other pins on its side. The injection always balances, so nothing drains through the reference pin as a false hot spot.+ - Every net reports `terminalCurrents` (pin, amps) and `terminalBasis`.+ - Boards without `sources` or `draws` solve exactly as before.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.8.0",+ "version": "0.8.1", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",
page.json+8−23@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.8.0",+ "version": "0.8.1", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",@@ -17,10 +17,7 @@ "vias", "3d", "analysis",- "eda",- "current flow animation",- "voltage",- "river"+ "eda" ], "status": "live", "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.",@@ -40,10 +37,11 @@ "fields", "flow of current", "river view",- "voltage drop in 3d",- "ground bounce",+ "energy flow on a board",+ "poynting vector",+ "speed of light on a pcb",+ "electron drift speed", "exploded layer stack",- "current through vias", "explain the view out loud", "narrate the fields", "energy cycle",@@ -85,20 +83,7 @@ "anti-resonance", "input filter stability", "middlebrook",- "ngspice pdn",- "signals view",- "copper capacitance",- "switch node capacitance",- "plane capacitance",- "differential pair skew",- "six layer board",- "receiver frequency plan",- "lo leakage",- "tone in band",- "rf crosstalk",- "return path over supply plane",- "antenna ground loop",- "stage feed bypass"+ "ngspice pdn" ], "sample_prompts": [ {@@ -139,4 +124,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-fields/SKILL.md+2−1@@ -14,7 +14,7 @@ One binary, three commands, one app. The AI does the analysis; the human explore adom-fields analyze --board <board.kicad_pcb> --spec <spec.json> --out fields ``` -The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC, maxDropMv), `hot` (ref: watts, tabNet), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.+The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC, maxDropMv, draws: {part: amps}), `sources` (net: [the parts that feed it]), `hot` (ref: watts, tabNet, maxRiseC: that part's own limit, else hotMaxRiseC), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper. What comes out, in `fields/fields.json` and `fields/tex/`: - `nets`, ordered by amps then by peak density: peak A/mm² and where, the layers, the copper mm², the Joule loss, the vias.@@ -177,6 +177,7 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Terminals** (0.8.1): without explicit `loads.<net>.terminals`, the solve picks them. The named `sources` feed the net, and every other connector and part on it is a sink, so a board-to-board connector that a rail feeds counts as a load. Otherwise connectors feed and parts draw. A part listed in `loads.<net>.draws` carries its own amps, the rest share what is left (sinks by pad area, sources evenly), and each part's share splits evenly over its pins. A pin with no copper near it is left out and its share goes to the other pins on its side, so the injection always balances. Every net in fields.json lists `terminalCurrents` (pin, amps, + into the copper) and `terminalBasis`, so a reviewer can check them. - **Solid bodies off the board** (0.8.0): `solids: {pitchMm, bodies: [{name, boxesMm: [[x0,y0,z0,x1,y1,z1]], conductivityWMk, watts | heatOf: "<hot ref>", hStillWM2k, hMovingWM2k, boardContactM2kW?, provenance}], contacts: [{bodies: [a, b], resistanceM2kW}], boardLinks: [{body, at, layer, resistanceKPerW, pad}], assumptions}`. Boxes are in board mm (x and y as KiCad, z up from the top copper). The bodies are voxelized and solved in the same heat system as the board, so the heat runs from the die through the tab, the pad and the heatsink fins to the air, and down the leads into the copper, in one energy balance. Bodies may touch only through a declared contact (0 means ideal), and they may not overlap. `fields.json` `solids` gives each body's peak rise, heat to air and to board, and peak flux. `solids.json` has every cell's temperature and flux vector, and the viewer paints them in the Heat and Temperature views. Calibrate a sink's h to its vendor rating rather than trusting a textbook h. See docs/solids.md. - **Fan levels** (0.8.0): `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level fully. h on every exposed face (board, bodies, a packageCooling sink) follows (h_still³ + (h_forced·sⁿ)³)^⅓, where s is the fraction of full speed, s = 1 gives the moving-air h, and n is `fanExponent` (0.5). `fields.json` `airStates` gives each level's h, chip rises and bodies. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together, on one colour scale, so the levels compare by eye. `ui view --air fan50` drives it. - **Package cooling** (PR 11): `packageCooling: [{reference, junctionCaseKPerW, caseSinkKPerW, sinkAmbientStillKPerW, sinkAmbientMovingKPerW, boardLinks, provenance}]` puts a hot part's watts at its junction and lets them split through the supplied case, sink and board links; junction, case and sink rises per air state and an energy balance in `packageCooling`. See docs/package-cooling.md.
src/fields.rs+56−10@@ -70,13 +70,13 @@ fn d35() -> f64 { 35.0 } fn d60() -> f64 { 60.0 } /// a loaded net: its current, and optionally its pins' signed currents (PR 6: positive enters the copper, negative leaves; /// each side sums to amps), instead of the connector-to-parts heuristic-#[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64>, /** the IR drop this net may have from its source to its farthest sink, mV (issue 14) */ #[serde(default)] pub maxDropMv: Option<f64>, #[serde(default)] pub terminals: Option<Vec<crate::terminals::Terminal>> }+#[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64>, /** the IR drop this net may have from its source to its farthest sink, mV (issue 14) */ #[serde(default)] pub maxDropMv: Option<f64>, /** each sink part's own draw on this net, amps, e.g. {"U3": 0.3, "J2": 4.0} (issue 17); the rest of the net's amps splits over the other sinks by pad area */ #[serde(default)] pub draws: BTreeMap<String, f64>, #[serde(default)] pub terminals: Option<Vec<crate::terminals::Terminal>> } /// a part's dissipation: through its exposed tab's net (tabNet), or, for a leaded part with no tab, through all its leads, /// shared by pad area (issue 8)-#[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, #[serde(default)] pub tabNet: String }+#[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, #[serde(default)] pub tabNet: String, /** this part's own rise limit, °C; spec hotMaxRiseC when not given (issue 16) */ #[serde(default)] pub maxRiseC: Option<f64> } #[derive(Serialize, Clone)]-pub struct NetResult { pub residual: f64, pub converged: bool, pub platedPads: usize, pub platedConnections: Vec<Value>, 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 { /** the current on every terminal pin the solve used: + into the copper, - out (issue 17) */ pub terminalCurrents: Vec<Value>, /** heuristic or explicit, and how the sources and sinks were chosen */ pub terminalBasis: String, pub residual: f64, pub converged: bool, pub platedPads: usize, pub platedConnections: Vec<Value>, 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 } #[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 } #[derive(Serialize, Clone)]@@ -188,6 +188,22 @@ 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 }; } }+ // named sources (spec `sources`) decide the source side: everything else that draws on the net is a sink, a+ // board-to-board connector included (issue 17)+ let mut basis = if load.terminals.is_some() { "explicit: spec loads.terminals".to_string() } else { "heuristic: connectors in, parts out by pad area".to_string() };+ if load.terminals.is_none() { if let Some(src) = spec.sources.get(net).filter(|l| !l.is_empty()) {+ let sp: Vec<&crate::board::Pad> = pads.iter().cloned().filter(|p| src.contains(&p.reference)).collect();+ if !sp.is_empty() {+ let passive = |p: &crate::board::Pad| p.reference.starts_with('C') || p.reference.starts_with("TP");+ let mut sinks: Vec<&crate::board::Pad> = pads.iter().cloned().filter(|p| !src.contains(&p.reference) && (load.draws.contains_key(&p.reference) || is_ext(p) || is_chip(p) || spec.hot.contains_key(&p.reference))).collect();+ if sinks.is_empty() { sinks = pads.iter().cloned().filter(|p| !src.contains(&p.reference) && !passive(p)).collect(); }+ ext = sp; chips = sinks; basis = format!("heuristic: sources {} (spec), every other connector and part as sinks", src.join(", "));+ }+ } }+ if load.terminals.is_none() && !load.draws.is_empty() {+ for r in load.draws.keys() { if !chips.iter().any(|p| p.reference == *r) { for p in pads.iter().filter(|p| p.reference == *r) { if !ext.iter().any(|e| e.reference == *r) { chips.push(*p); } } } }+ basis.push_str("; parts with spec loads.draws carry their own amps, the rest share what is left");+ } 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); }@@ -213,16 +229,43 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu let assignments: Vec<(&crate::board::Pad, f64)> = if let Some(rows) = &load.terminals { match crate::terminals::resolve(b, net, load.amps, rows) { Ok(r) => r, Err(e) => { log(&e); continue; } } } else {- let mut rows: Vec<(&crate::board::Pad, f64)> = 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)); }+ // the source side: a part with a spec draw feeds that much, the other source pins share the rest evenly+ let src_refs: std::collections::BTreeSet<String> = ext.iter().map(|p| p.reference.clone()).collect();+ let fed: f64 = src_refs.iter().filter_map(|r| load.draws.get(r)).sum();+ let free_pins = ext.iter().filter(|p| !load.draws.contains_key(&p.reference)).count() as f64;+ let fscale = if fed > load.amps + 1e-9 || (free_pins == 0.0 && fed > 0.0) { load.amps / fed } else { 1.0 };+ let mut rows: Vec<(&crate::board::Pad, f64)> = ext.iter().map(|p| { let pins = ext.iter().filter(|q| q.reference == p.reference).count() as f64;+ (*p, match load.draws.get(&p.reference) { Some(a) => a * fscale / pins, None => if free_pins > 0.0 { (load.amps - fed * fscale).max(0.0) / free_pins } else { 0.0 } }) }).collect();+ // each sink part's draw: the spec's (loads.draws), the rest split over the other parts by pad area; a part's+ // share splits evenly over its pins (issue 17)+ let drawn: f64 = chips.iter().map(|p| p.reference.clone()).collect::<std::collections::BTreeSet<_>>().iter().filter(|r| !src_refs.contains(*r)).filter_map(|r| load.draws.get(r)).sum();+ let rest_area: f64 = area_by_ref.iter().filter(|(r, _)| !load.draws.contains_key(*r)).map(|(_, a)| a).sum();+ let scale = if drawn > load.amps + 1e-9 || (rest_area <= 0.0 && drawn > 0.0) { log(&format!("{net}: loads.draws add up to {drawn:.3} A against the net's {} A; scaled to match", load.amps)); load.amps / drawn } else { 1.0 };+ let left = (load.amps - drawn * scale).max(0.0);+ for p in &chips { let pins = chips.iter().filter(|q| q.reference == p.reference).count() as f64;+ let part = match load.draws.get(&p.reference) { Some(a) => a * scale, None => if rest_area > 0.0 { left * area_by_ref[&p.reference] / rest_area } else { 0.0 } };+ rows.push((*p, -part / pins)); } rows }; let (mut ext_spots, mut chip_spots, mut terminals): (Vec<usize>, Vec<usize>, Vec<crate::flow::Terminal>) = (Vec::new(), Vec::new(), Vec::new()); let mut pinned = false; let mut pinned_at: Option<usize> = None; let mut missing = false;+ // a guessed pin with no copper is left out, and its share goes to the other pins on its side, so the sources and+ // sinks still balance; an unbalanced injection would drain through the reference pin as a false hot spot (issue 17)+ let mut kept: Vec<(&crate::board::Pad, f64, Vec<(usize, usize, usize)>)> = Vec::new(); for (p, amps) in &assignments { if *amps == 0.0 { continue; }- // an explicit pin with no copper refuses the net (PR 6); a guessed one is skipped, as before, and logged- let sp = spots_of(p); if sp.is_empty() { if load.terminals.is_some() { missing = true; break; } log(&format!("{net}: {}.{} has no copper within 1 mm, left out of the terminals", p.reference, p.pad)); continue; }+ let sp = spots_of(p);+ if sp.is_empty() { if load.terminals.is_some() { missing = true; break; } log(&format!("{net}: {}.{} has no copper within 1 mm, left out of the terminals (its share goes to the other pins on its side)", p.reference, p.pad)); continue; }+ kept.push((*p, *amps, sp));+ }+ if load.terminals.is_none() { for sign in [1.0f64, -1.0] {+ let sum: f64 = kept.iter().filter(|k| k.1 * sign > 0.0).map(|k| k.1.abs()).sum();+ if sum > 0.0 && (sum - load.amps).abs() > 1e-9 { for k in kept.iter_mut().filter(|k| k.1 * sign > 0.0) { k.1 *= load.amps / sum; } }+ } }+ let assignments: Vec<(&crate::board::Pad, f64)> = kept.iter().map(|k| (k.0, k.1)).collect();+ let spots: Vec<Vec<(usize, usize, usize)>> = kept.into_iter().map(|k| k.2).collect();+ for (ti, (p, amps)) in assignments.iter().enumerate() {+ let sp = spots[ti].clone(); 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 { ext_spots.push(a * n + r * w + c); } else { chip_spots.push(a * n + r * w + c); } } terminals.push(crate::flow::Terminal { reference: p.reference.clone(), pad: p.pad.clone(), x: p.x, y: p.y, amps: amps.abs(), side: if *amps > 0.0 { "ext".into() } else { "chip".into() } });@@ -306,7 +349,8 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu let mean = |s: &[usize]| { let r: Vec<f64> = s.iter().filter(|i| reach[**i]).map(|i| v[*i]).collect(); if r.is_empty() { 0.0 } else { r.iter().sum::<f64>() / r.len() as f64 } }; nf.v_ext = mean(&ext_spots); nf.v_chip = mean(&chip_spots); nf.terminals = terminals; nf.amps = load.amps; flow.insert(net.clone(), nf);- nets.push(NetResult { residual: res, converged: res <= 1e-4, platedConnections: connections, platedPads: b.plated_holes.iter().filter(|v| v.net == *net).count(), net: net.clone(), amps: load.amps, layers: live.iter().map(|k| LAYERS[*k].to_string()).collect(), peakJ: peak, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it });+ let term_currents: Vec<Value> = assignments.iter().map(|(p, a)| json!({"pin": format!("{}.{}", p.reference, p.pad), "amps": (a * 1e6).round() / 1e6})).collect();+ nets.push(NetResult { terminalCurrents: term_currents, terminalBasis: basis.clone(), residual: res, converged: res <= 1e-4, platedConnections: connections, platedPads: b.plated_holes.iter().filter(|v| v.net == *net).count(), net: net.clone(), amps: load.amps, layers: live.iter().map(|k| LAYERS[*k].to_string()).collect(), peakJ: peak, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it }); j_net.insert(net.clone(), grids); } let joule_total: f64 = nets.iter().map(|x| x.jouleW).sum();@@ -492,8 +536,10 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu } } for c in &chips {- if c.riseC > spec.hotMaxRiseC {- issues.push(Issue { severity: if c.riseMovingC > spec.hotMaxRiseC { "high".into() } else { "medium".into() }, kind: "tab-rise".into(), what: format!("{}: +{:.0} °C at its tab in still air (+{:.0} °C in moving air) against a {:.0} °C budget", c.reference, c.riseC, c.riseMovingC, spec.hotMaxRiseC), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: if c.riseMovingC > spec.hotMaxRiseC { "More copper under the tab and more vias to the other layers, or a heatsink; moving air alone does not bring it in.".into() } else { "Moving air brings it in; in still air it needs more copper under the tab, more vias, or a heatsink.".into() } , action: None });+ // the part's own limit (hot.<ref>.maxRiseC) when the spec gives one, else the global hotMaxRiseC (issue 16)+ let (limit, own) = match spec.hot.get(&c.reference).and_then(|x| x.maxRiseC) { Some(l) => (l, true), None => (spec.hotMaxRiseC, false) };+ if c.riseC > limit {+ issues.push(Issue { severity: if c.riseMovingC > limit { "high".into() } else { "medium".into() }, kind: "tab-rise".into(), what: format!("{}: +{:.0} °C at its tab in still air (+{:.0} °C in moving air) against {}", c.reference, c.riseC, c.riseMovingC, if own { format!("its own {limit:.0} °C limit (hot.{}.maxRiseC)", c.reference) } else { format!("the board's {limit:.0} °C budget (hotMaxRiseC)") }), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: if c.riseMovingC > limit { "More copper under the tab and more vias to the other layers, or a heatsink; moving air alone does not bring it in.".into() } else { "Moving air brings it in; in still air it needs more copper under the tab, more vias, or a heatsink.".into() } , action: None }); } if c.vias < c.viasWanted { issues.push(Issue { severity: "medium".into(), kind: "thermal-vias".into(), what: format!("{}: {} via(s) within {r_chip:.0} mm of its tab, the spec asks for {}", c.reference, c.vias, c.viasWanted), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: "Land the thermal vias under the tab pad; without them the tab's heat has only the top copper to spread on.".into() , action: None });
src/main.rs+3−2@@ -97,13 +97,13 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck if spec.loads.contains_key(&sw) || spec.loads.is_empty() { continue; } let across = b.pads.iter().filter(|p| p.net == sw && p.reference.starts_with('L')).find_map(|p| b.pads.iter().find(|q| q.reference == p.reference && q.net != sw).map(|q| q.net.clone())); let amps = across.as_ref().and_then(|n| spec.loads.get(n)).map(|l| l.amps).unwrap_or_else(|| spec.loads.values().map(|l| l.amps).fold(0.0, f64::max));- if amps > 0.0 { spec.loads.insert(sw.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, terminals: None }); auto_switch.push((sw, amps)); }+ if amps > 0.0 { spec.loads.insert(sw.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, draws: Default::default(), terminals: None }); auto_switch.push((sw, amps)); } } let mut auto_ground: Option<(String, f64)> = None; if let Some(gname) = b.pads.iter().map(|p| p.net.clone()).find(|n| crate::flow::is_ground(n)) { if !spec.loads.contains_key(&gname) && !spec.loads.is_empty() { let amps = spec.loads.values().map(|l| l.amps).fold(0.0, f64::max);- if amps > 0.0 { spec.loads.insert(gname.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, terminals: None }); auto_ground = Some((gname, amps)); }+ if amps > 0.0 { spec.loads.insert(gname.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, draws: Default::default(), terminals: None }); auto_ground = Some((gname, amps)); } } } 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()); }@@ -114,6 +114,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck spec.signals.validate(&spec.real_copper)?; 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());}} // a package cooled off the board (PR 11): one entry per hot part, its links on that part's own pads+ for (r, h) in &spec.hot { if let Some(l) = h.maxRiseC { if !l.is_finite() || l <= 0.0 { return Err(format!("hot.{r}.maxRiseC must be a positive number of °C, got {l}")); } } } if let Some(sd) = &spec.solids { let copper: Vec<String> = b.layer_names.clone(); sd.validate([b.x0, b.y0, b.x1, b.y1], &|l| copper.iter().any(|c| c == l) || l == "F.Cu" || l == "B.Cu", &|r| spec.hot.contains_key(r))?;