← All Pull Requests

Add validated package-to-sink thermal paths and power balance #11

Closed opened by John Lauer
Merges load-package-cooling → main

A heat-sink-mounted power device currently sends all prescribed watts into the PCB. Add optional packageCooling junction/case/sink nodes with validated physical-pad links, so power splits through supplied resistances and is injected exactly once. Report both air states, signed board exchange and energy balance; preserve conservative legacy thermal screening and the full-power still-air finding.

Base: 8493c9196dc8d8d3ab857659162bde6c912ab77b (0.7.7), contextually rebased from 0.7.6. Existing EMC, flow, signals, heatsink and module registrations are preserved. Seven focused files; immutable-base preflight passes and every diff hunk reviewed.

Validation: production cargo check --workspace --bins, 20 workspace tests, release build, JS syntax check and CLI analytical fixture pass. Fixture gives junction/case/sink rises 9/8/6 C still and 7/6/4 C moving, with zero energy residual. New tests cover single power injection, parallel and reverse heat flow, conservative junction screening, physical-pad identity and invalid inputs.

Native viewer: selected-air values/order, package panel and layer count visually checked in a separate combined two-layer review build, with a decoded recorded clip. That build also contains the pending PR9/PR10 stack. This current-main patch does not incorporate those dependencies; they are still required for the electronic load's actual two-layer analysis. The current-main CLI fixture is independently verified.

Related: issue #8, PRs #9 and #10. Parameters do not qualify an assembly, installed airflow or fan-fault transient. No release, shared runtime replacement or self-merge. See docs/package-cooling.md and docs/package-cooling-review.md. Owner review requested; electronic load remains at its Thermal gate.

Diff Skip to comments (1)

docs/fixtures/package-cooling/spec.jsonadded+21
@@ -0,0 +1,21 @@+{+  "board": "Package-path analytical fixture, not electronic-load acceptance",+  "copperUm": 35,+  "hot": {+    "U1": {+      "watts": 2.0,+      "tabNet": "GND"+    }+  },+  "packageCooling": [+    {+      "reference": "U1",+      "junctionCaseKPerW": 0.5,+      "caseSinkKPerW": 1.0,+      "sinkAmbientStillKPerW": 3.0,+      "sinkAmbientMovingKPerW": 2.0,+      "boardLinks": [],+      "provenance": "Analytical validation fixture: uncoupled 0.5 + 1 + 3 K/W series network, not manufacturer data"+    }+  ]+}
docs/package-cooling-review.mdadded+13
@@ -0,0 +1,13 @@+# Package thermal-node review evidence++Base: `8493c9196dc8d8d3ab857659162bde6c912ab77b` (Fields 0.7.7). New code is review-only; no registry release, installation or shared runtime replacement.++- 20 source tests pass, including six new analytical/integration cases.+- Production `cargo check --bins` and release build pass; existing naming warnings remain.+- `node --check src/ui/app.js` passes.+- CLI fixture: still-air junction/case/sink rises 9/8/6 C; moving-air 7/6/4 C; 2 W source, 2 W sink dissipation, zero energy residual and zero thermal equation residual.+- The existing package-free conducting-block and signal tests pass unchanged.+- The optional schema requires finite positive resistances, parameter provenance and exact physical-pad coordinates/layer membership. Duplicate sources/links and unresolved pads fail validation.+- Every package node's temperature is included conservatively in the legacy hot-component screening result. The full-power still-air case remains present; no fault-state waiver.++The app lists package temperatures and signed exchanged power using textContent. Native viewer inspection passed in the isolated combined build: still/moving values, selected-air hot-part ordering and the actual two-layer method count were visually verified; a 14.733-second native window clip was decoded and reviewed. The current-main binary has source/CLI fixture verification; shared runtime deployment remains untouched. The isolated electronic-load sensitivity build additionally contains the separate PR10 stack (actual two-layer stack, plated-wall and physical-terminal work); that private combined build is not this current-main source patch and is not a release. Its inputs retain unqualified interface, airflow and case-to-pad resistance assumptions. The actual load remains at the Thermal gate.
docs/package-cooling.mdadded+40
@@ -0,0 +1,40 @@+# Package-to-heatsink thermal paths++`packageCooling` adds explicit junction, case and sink nodes for a named `hot` component. It is a steady-state network supplied by the caller. It does not calculate fin airflow, fan pressure, mounting pressure, contact quality or fault transients.++The component's `hot.watts` enters its junction once. That component's old board heat injection is removed, while all other chip heat and PCB Joule losses remain. The junction connects to the case; the case connects to the sink and any declared board terminal links. The sink loses heat through separate supplied still-air and moving-air resistances. Board heat may flow back into the case and sink. Negative `boardCouplingW` is valid; it must not be clamped away.++```json+{+  "hot": {"Q1": {"watts": 5.5, "tabNet": "LOAD_POS"}},+  "packageCooling": [{+    "reference": "Q1",+    "junctionCaseKPerW": 0.42,+    "caseSinkKPerW": 0.53,+    "sinkAmbientStillKPerW": 8.0,+    "sinkAmbientMovingKPerW": 1.5,+    "boardLinks": [],+    "provenance": "Illustrative only: replace every parameter with justified assembly data; empty boardLinks explicitly assumes no package-to-board conduction"+  }]+}+```++These example numbers are not an approved electronic-load cooling design. Do not infer an installed fan velocity from a sink's catalog rating. An empty `boardLinks` list is explicit isolation, not an automatic approximation for TO-220 leads. To model a terminal, add a link such as `{"pad":"2","at":[127.46,80.8],"layer":"F.Cu","resistanceKPerW":20.0}` using the **actual** physical pad coordinates and a justified package-to-pad thermal resistance. The example coordinate/resistance is illustrative. Every link must resolve to exactly one physical pad on the named copper layer and inside the solve grid. Repeated logical pin numbers need distinct coordinates. Links are point couplings to solve cells; assess mesh sensitivity and contact-model suitability.++Every resistance must be finite and positive. References must exist in `hot` and occur once in `packageCooling`. Provenance is required. Typos within a package/link object, duplicate links, wrong layers and missing/ambiguous pads are rejected.++## Results and acceptance boundary++`packageCooling[]` in the result includes the definition and, for both air states, junction/case/sink rises, source watts, signed case-to-board watts, sink-to-ambient watts and an energy residual. The command refuses an unbalanced result. The viewer lists these values separately from board-face blocks; it does not invent a heatsink mesh.++For a package-mode component, the legacy chip `riseC`/`riseMovingC` fields are conservatively the maximum of the board terminal-region temperature, case and junction. Existing thermal checks therefore cannot hide an overheated external node behind a cool PCB pad. Board texture scales remain board temperatures. The package temperatures have their own result fields. Nearby copper/via fluxes still describe the PCB region; they are not fractions of the total package dissipation.++Still-air remains a full-power steady-state case. A tach-controlled shutdown does not justify changing it to zero watts or waiving it. Fault-dependent transient acceptance requires a separate supported model, measured detection timing and appropriate thermal capacity data. This feature supplies none of those.++## Validation++Analytical tests exercise an uncoupled series network, equal parallel board/sink paths, reverse heat flow from a hotter board, energy conservation and invalid parameters. Integration tests ensure package power is not also deposited on the PCB and the legacy chip result still sees a hot junction. Physical-pad identity and wrong-layer checks are exercised.++Run the CLI fixture against `docs/fixtures/conducting-block/board.kicad_pcb` with `docs/fixtures/package-cooling/spec.json`, `--pitch 0.2 --no-parts --no-silk`. Its 2 W source and 0.5/1/3 K/W series path give +9 C junction, +8 C case and +6 C sink in still air; moving air gives +7/+6/+4 C. This validates arithmetic and integration, not an assembled board. The fixture intentionally isolates the package from the board.++This source feature is independent of the pending actual-layer/barrel/physical-terminal improvements. Those changes remain necessary before applying the current shared Fields base to the electronic load's two-layer board. Do not replace another owner's shared runtime or call a review build a release.
src/fields.rs+70−8
@@ -23,6 +23,7 @@ pub struct Spec {     #[serde(default)] pub board: String,     #[serde(default)] pub copperUmByLayer: BTreeMap<String,f64>,     #[serde(default)] pub signals: crate::signals::Config,+    #[serde(default)] pub packageCooling: Vec<crate::package_cooling::Package>,     #[serde(default)] pub heatsinks: Vec<crate::heatsink::Block>,     #[serde(default)] pub stackupMm: Vec<f64>,     /// each net's rail voltage for the river view; nets not named here are read from their names (GND 0, 3V3 3.3, +12V 12)@@ -60,6 +61,7 @@ pub struct Fields {     pub nets: Vec<NetResult>, pub chips: Vec<ChipResult>, pub issues: Vec<Issue>,     pub joule_total: f64, pub tab_total: f64,     pub heatsinks: Vec<Value>,+    pub package_cooling: Vec<Value>,     pub signals: Value,     pub heat_residual: f64, pub heat_moving_residual: f64,     /// the signed current, the solved potential and the terminals of each loaded net, for the flow and river views@@ -263,7 +265,15 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             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();-        (sys, meshes)+        let packages: Vec<_> = spec.packageCooling.iter().map(|p| {+            let nodes: Vec<_> = p.board_links.iter().map(|link| {+                let k = LAYERS.iter().position(|l| *l == link.layer).expect("validated package layer");+                let (r,c) = cell(link.at[0],link.at[1]).expect("validated package location");+                k*n+r*w+c+            }).collect();+            p.attach(&mut sys, &nodes, h_air == H_MOVING)+        }).collect();+        (sys, meshes, packages)     };     let mut q_rhs = vec![0.0; nl * n];     for k in 0..nl { for i in 0..n {q_rhs[k*n+i] += p_joule[k].v[i] as f64;} }@@ -271,7 +281,8 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu     for (reference, hot) in &spec.hot {         let cells: Vec<(f64, f64)> = b.pads.iter().filter(|p| p.reference == *reference && p.net == hot.tabNet).map(|p| (p.x, p.y)).collect();         if cells.is_empty() { log(&format!("{reference}: no pad on {} (tab net), its watts are not placed", hot.tabNet)); continue; }-        let per = hot.watts / cells.len() as f64;+        let external_package = spec.packageCooling.iter().any(|p| p.reference == *reference);+        let per = if external_package { 0.0 } else { hot.watts / cells.len() as f64 };         for (x, y) in &cells {             let Some((r0, c0)) = cell(*x, *y) else { continue };             let rad = (1.2 / d) as isize; let mut spots = Vec::new();@@ -281,13 +292,22 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         chip_pos.push((reference.clone(), cells[0].0, cells[0].1, hot.watts, hot.tabNet.clone()));     }     let tab_total: f64 = spec.hot.values().map(|h| h.watts).sum();-    let (sys_still, sink_meshes) = build(H_STILL);+    let (sys_still, sink_meshes, packages_still) = build(H_STILL);     q_rhs.resize(sys_still.n(),0.0);+    for (p,nodes) in spec.packageCooling.iter().zip(&packages_still) {+        q_rhs[nodes.junction] = spec.hot[&p.reference].watts;+    }     let (tv, it, res) = sys_still.solve(&q_rhs, 1e-7, 8000);     log(&format!("heat, still air: {it} iterations, residual {res:.1e}"));-    let (sys_mov, _) = build(H_MOVING);+    let (sys_mov, _, packages_moving) = build(H_MOVING);     let (tm, it2, res2) = sys_mov.solve(&q_rhs, 1e-7, 8000);     log(&format!("heat, moving air: {it2} iterations"));+    let package_cooling: Vec<Value> = spec.packageCooling.iter().zip(&packages_still).zip(&packages_moving).map(|((p,a),b)| {+        let watts = spec.hot[&p.reference].watts;+        json!({"definition":p, "still":a.result(&tv,watts), "moving":b.result(&tm,watts),+            "status":"supplied-parameter-steady-state-screen", "transientQualified":false,+            "chipRisePolicy":"maximum of board terminal region, case and junction; never hide a hot external node"})+    }).collect();     let heatsinks: Vec<Value> = spec.heatsinks.iter().zip(&sink_meshes).map(|(block,mesh)| {         let peak=|t:&[f64]|mesh.nodes.iter().map(|i|t[*i]).fold(0.0,f64::max);         let power=|t:&[f64]|mesh.contacts.iter().map(|(a,b,g)|g*(t[*a]-t[*b])).sum::<f64>();@@ -327,6 +347,12 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         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) });     }     // ---- issues -------------------------------------------------------------------------------+    for (p,(a,b)) in spec.packageCooling.iter().zip(packages_still.iter().zip(&packages_moving)) {+        if let Some(chip) = chips.iter_mut().find(|c| c.reference == p.reference) {+            chip.riseC = chip.riseC.max(tv[a.junction]).max(tv[a.case]);+            chip.riseMovingC = chip.riseMovingC.max(tm[b.junction]).max(tm[b.case]);+        }+    }     let mut issues: Vec<Issue> = Vec::new();     for x in &nets {         // IPC-2221 external, 10 C rise: about 60 A/mm2 at 35 um; a pinch well past that is a hot neck@@ -339,14 +365,14 @@ 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 });+            issues.push(Issue { severity: if c.riseMovingC > spec.hotMaxRiseC { "high".into() } else { "medium".into() }, kind: "tab-rise".into(), what: format!("{}: +{:.0} °C at its thermal screening node 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 });         }         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 });         }     }     let _ = (tab_total, joule_total);-    Fields { flow, net_ids, net_names, signals:crate::signals::analyze(b,&ras,&spec.signals,&spec.copperUmByLayer,spec.copperUm), heatsinks, heat_residual:res, heat_moving_residual:res2, d, h, w, x0: b.x0, y0: b.y0, frac, j_net, cu_net, j_all, q, t, t_moving, nets, chips, issues, joule_total: (joule_total * 1000.0).round() / 1000.0, tab_total }+    Fields { package_cooling, flow, net_ids, net_names, signals:crate::signals::analyze(b,&ras,&spec.signals,&spec.copperUmByLayer,spec.copperUm), heatsinks, heat_residual:res, heat_moving_residual:res2, d, h, w, x0: b.x0, y0: b.y0, frac, j_net, cu_net, j_all, q, t, t_moving, nets, chips, issues, joule_total: (joule_total * 1000.0).round() / 1000.0, tab_total } }  /// The summary the app and the page read.@@ -364,7 +390,7 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value     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},         "nets": nets, "chips": chips, "issues": f.issues,-        "signals": f.signals, "heatsinks": f.heatsinks, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual},+        "signals": f.signals, "heatsinks": f.heatsinks, "packageCooling": f.package_cooling, "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)},@@ -372,7 +398,7 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value         "textures": textures,         "partValues": b.values,         "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: 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. Optional packageCooling routes a hot part through explicit junction, case and sink nodes, with declared physical-pad thermal links. Its source power is injected once, at the junction. Package chip rise is the maximum of board terminal region, case and junction, so existing thermal limits cannot hide a hot external node. Supplied sink resistances are conditions, not an airflow calculation. 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."     }) } @@ -391,4 +417,40 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value         assert!(cooled.heat_residual<1e-6);         assert!(cooled.chips[0].riseMovingC<cooled.chips[0].riseC);     }+    #[test]+    fn package_source_is_not_duplicated_on_board_and_hot_junction_remains_screened() {+        let mut b=Board{ x0:0.,y0:0.,x1:12.,y1:12., ..Board::default() };+        b.pads.push(crate::board::Pad{reference:"Q1".into(),pad:"2".into(),net:"DRAIN".into(),x:6.,y:6.,layers:vec!["F.Cu".into()],area:4.});+        let spec:Spec=serde_json::from_value(json!({"copperUm":35,"hot":{"Q1":{"watts":2.0,"tabNet":"DRAIN"}},+          "packageCooling":[{"reference":"Q1","junctionCaseKPerW":0.5,"caseSinkKPerW":1.0,+          "sinkAmbientStillKPerW":3.0,"sinkAmbientMovingKPerW":2.0,"boardLinks":[],"provenance":"Analytical series-network fixture"}]})).unwrap();+        let f=analyze(&b,&spec,1.,60.,&mut |_|{});+        assert!(f.t.iter().all(|g| g.max().abs()<1e-7), "an uncoupled board must not receive the package watts");+        assert!((f.chips[0].riseC-9.0).abs()<1e-5, "the old gate must still see the hot junction");+        assert!((f.chips[0].riseMovingC-7.0).abs()<1e-5);+        assert!((f.package_cooling[0]["still"]["sourceW"].as_f64().unwrap()-2.0).abs()<1e-8);+        assert!(f.package_cooling[0]["still"]["energyResidualW"].as_f64().unwrap().abs()<1e-6);+    }++    #[test]+    fn package_can_exchange_heat_with_its_verified_physical_pad() {+        let mut b=Board{ x0:0.,y0:0.,x1:12.,y1:12., ..Board::default() };+        b.pads.push(crate::board::Pad{reference:"Q1".into(),pad:"2".into(),net:"DRAIN".into(),x:6.,y:6.,layers:vec!["F.Cu".into()],area:4.});+        let spec:Spec=serde_json::from_value(json!({"copperUm":35,"hot":{"Q1":{"watts":2.0,"tabNet":"DRAIN"}},+          "packageCooling":[{"reference":"Q1","junctionCaseKPerW":0.5,"caseSinkKPerW":1.0,+          "sinkAmbientStillKPerW":3.0,"sinkAmbientMovingKPerW":2.0,+          "boardLinks":[{"pad":"2","at":[6,6],"layer":"F.Cu","resistanceKPerW":2.0}],+          "provenance":"Analytical coupled-network fixture"}]})).unwrap();+        spec.packageCooling[0].validate_board(&b,&LAYERS,1.0).unwrap();+        let f=analyze(&b,&spec,1.,60.,&mut |_|{});+        let r=&f.package_cooling[0]["still"];+        assert!(r["boardCouplingW"].as_f64().unwrap()>0.0);+        assert!(r["energyResidualW"].as_f64().unwrap().abs()<1e-5);+        assert!(f.chips[0].riseC<9.0);+        let mut invalid=spec.packageCooling[0].clone();invalid.board_links[0].at[0]=6.1;+        assert!(invalid.validate_board(&b,&LAYERS,1.0).is_err());+        invalid=spec.packageCooling[0].clone();invalid.board_links[0].layer="B.Cu".into();+        assert!(invalid.validate_board(&b,&LAYERS,1.0).is_err());+    }+ }
src/main.rs+20
@@ -1,5 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};+mod package_cooling; mod eagle; mod emc; mod flow; mod parts; mod silk; 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;@@ -71,6 +72,15 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     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()?;     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 (i, package) in spec.packageCooling.iter().enumerate() {+        package.validate_board(&b, &board::LAYERS, pitch)?;+        if !spec.hot.contains_key(&package.reference) || spec.packageCooling[..i].iter().any(|p| p.reference == package.reference) {+            return Err("packageCooling requires one entry per existing hot reference".into());+        }+        if !b.pads.iter().any(|p| p.reference == package.reference && p.net == spec.hot[&package.reference].tabNet) {+            return Err("packageCooling hot reference has no board pad on its tab net".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());}@@ -97,6 +107,16 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     emc::act_on_core(&mut iss, &f);     f.issues = iss;     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));}+    for package in &f.package_cooling {+        for state in ["still", "moving"] {+            let r=&package[state];+            let residual=r["energyResidualW"].as_f64().unwrap_or(f64::INFINITY);+            let scale=1.0+r["sourceW"].as_f64().unwrap_or(0.0).abs()+r["boardCouplingW"].as_f64().unwrap_or(0.0).abs();+            if !residual.is_finite() || residual.abs()>1e-5*scale {+                return Err(format!("package cooling energy balance failed for {} ({state})",package["definition"]["reference"]));+            }+        }+    }     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);
src/package_cooling.rsadded+269
@@ -0,0 +1,269 @@+//! Explicit steady-state package nodes. Heat is injected once at the junction;+//! board terminals and the external sink share it through declared resistances.+//! This is a supplied-parameter screen, not an airflow or assembly qualification.+use crate::solve::System;+use serde::{Deserialize, Serialize};+use serde_json::{json, Value};++#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase", deny_unknown_fields)]+pub struct Package {+    pub reference: String,+    pub junction_case_k_per_w: f64,+    pub case_sink_k_per_w: f64,+    pub sink_ambient_still_k_per_w: f64,+    pub sink_ambient_moving_k_per_w: f64,+    pub board_links: Vec<BoardLink>,+    /// Cite the part/TIM/sink and conditions, and identify assumptions.+    pub provenance: String,+}++#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase", deny_unknown_fields)]+pub struct BoardLink {+    pub pad: String,+    pub at: [f64; 2],+    pub layer: String,+    pub resistance_k_per_w: f64,+}++pub struct Nodes {+    pub junction: usize,+    pub case: usize,+    pub sink: usize,+    pub links: Vec<(usize, f64)>,+    pub sink_conductance: f64,+}++impl Package {+    pub fn validate_board(+        &self,+        board: &crate::board::Board,+        layers: &[&str],+        pitch: f64,+    ) -> Result<(), String> {+        self.validate()?;+        if !pitch.is_finite() || pitch <= 0.0 {+            return Err("invalid package grid pitch".into());+        }+        let width = ((board.width() / (pitch / 2.0)).round() as usize).max(1) / 2;+        let height = ((board.height() / (pitch / 2.0)).round() as usize).max(1) / 2;+        for link in &self.board_links {+            if !layers.contains(&link.layer.as_str()) {+                return Err(format!(+                    "{}: package link names an unsupported layer",+                    self.reference+                ));+            }+            let matches: Vec<_> = board+                .pads+                .iter()+                .filter(|p| {+                    p.reference == self.reference+                        && p.pad == link.pad+                        && (p.x - link.at[0]).abs() < 1e-6+                        && (p.y - link.at[1]).abs() < 1e-6+                        && p.layers.contains(&link.layer)+                })+                .collect();+            if matches.len() != 1 {+                return Err(format!(+                    "{}: package link must identify exactly one physical pad on its copper layer",+                    self.reference+                ));+            }+            if link.at[0] < board.x0+                || link.at[0] >= board.x0 + width as f64 * pitch+                || link.at[1] < board.y0+                || link.at[1] >= board.y0 + height as f64 * pitch+            {+                return Err(format!(+                    "{}: package link lies outside thermal grid",+                    self.reference+                ));+            }+        }+        Ok(())+    }++    pub fn validate(&self) -> Result<(), String> {+        if self.reference.trim().is_empty() || self.provenance.trim().is_empty() {+            return Err("packageCooling requires reference and parameter provenance".into());+        }+        for r in [+            self.junction_case_k_per_w,+            self.case_sink_k_per_w,+            self.sink_ambient_still_k_per_w,+            self.sink_ambient_moving_k_per_w,+        ]+        .into_iter()+        .chain(self.board_links.iter().map(|l| l.resistance_k_per_w))+        {+            if !r.is_finite() || r <= 0.0 || !(1.0 / r).is_finite() {+                return Err(format!(+                    "{}: package resistances must be finite and positive",+                    self.reference+                ));+            }+        }+        for (i, link) in self.board_links.iter().enumerate() {+            if link.pad.trim().is_empty() || !link.at.iter().all(|x| x.is_finite()) {+                return Err(format!(+                    "{}: every board link needs a pad and finite coordinates",+                    self.reference+                ));+            }+            if self.board_links[..i]+                .iter()+                .any(|p| p.pad == link.pad && p.at == link.at && p.layer == link.layer)+            {+                return Err(format!("{}: duplicate package board link", self.reference));+            }+        }+        Ok(())+    }++    /// Caller resolves and validates physical pads and layer indices first.+    pub fn attach(&self, sys: &mut System, board_nodes: &[usize], moving: bool) -> Nodes {+        assert_eq!(board_nodes.len(), self.board_links.len());+        let board_count = sys.nl * sys.h * sys.w;+        assert!(board_nodes.iter().all(|i| *i < board_count));+        let sink_conductance = 1.0+            / if moving {+                self.sink_ambient_moving_k_per_w+            } else {+                self.sink_ambient_still_k_per_w+            };+        let junction = sys.add_node(0.0);+        let case = sys.add_node(0.0);+        let sink = sys.add_node(sink_conductance);+        sys.edges+            .push((junction, case, 1.0 / self.junction_case_k_per_w));+        sys.edges.push((case, sink, 1.0 / self.case_sink_k_per_w));+        let links = board_nodes+            .iter()+            .zip(&self.board_links)+            .map(|(node, link)| {+                let conductance = 1.0 / link.resistance_k_per_w;+                sys.edges.push((case, *node, conductance));+                (*node, conductance)+            })+            .collect();+        Nodes {+            junction,+            case,+            sink,+            links,+            sink_conductance,+        }+    }+}++impl Nodes {+    pub fn result(&self, temperatures: &[f64], watts: f64) -> Value {+        let board_w: f64 = self+            .links+            .iter()+            .map(|(i, g)| g * (temperatures[self.case] - temperatures[*i]))+            .sum();+        let ambient_w = temperatures[self.sink] * self.sink_conductance;+        json!({+            "junctionRiseC": temperatures[self.junction], "caseRiseC": temperatures[self.case],+            "sinkRiseC": temperatures[self.sink], "boardCouplingW": board_w,+            "sinkToAmbientW": ambient_w, "sourceW": watts,+            "energyResidualW": watts - board_w - ambient_w,+            "boardLinks": self.links.iter().map(|(i,g)| json!({+                "boardRiseC": temperatures[*i],+                "caseToBoardW": g*(temperatures[self.case]-temperatures[*i])+            })).collect::<Vec<_>>(),+        })+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    fn package() -> Package {+        Package {+            reference: "Q1".into(),+            junction_case_k_per_w: 0.5,+            case_sink_k_per_w: 1.0,+            sink_ambient_still_k_per_w: 3.0,+            sink_ambient_moving_k_per_w: 2.0,+            board_links: vec![],+            provenance: "Analytical test resistances, not physical qualification".into(),+        }+    }+    fn close(a: f64, b: f64) {+        assert!((a - b).abs() < 1e-8, "{a} != {b}");+    }+    #[test]+    fn isolated_series_path_has_exact_temperatures_and_conserves_power() {+        let p = package();+        let mut s = System::new(1, 1, 1);+        s.leak[0][0] = 1.0;+        let n = p.attach(&mut s, &[], false);+        let mut q = vec![0.0; s.n()];+        q[n.junction] = 2.0;+        let (t, _, res) = s.solve(&q, 1e-12, 100);+        assert!(res < 1e-10);+        close(t[n.sink], 6.0);+        close(t[n.case], 8.0);+        close(t[n.junction], 9.0);+        close(n.result(&t, 2.0)["energyResidualW"].as_f64().unwrap(), 0.0);+    }+    #[test]+    fn parallel_board_path_splits_power_without_double_injection() {+        let mut p = package();+        p.board_links.push(BoardLink {+            pad: "2".into(),+            at: [0.0, 0.0],+            layer: "F.Cu".into(),+            resistance_k_per_w: 2.0,+        });+        let mut s = System::new(1, 1, 1);+        s.leak[0][0] = 0.5;+        let n = p.attach(&mut s, &[0], false);+        let mut q = vec![0.0; s.n()];+        q[n.junction] = 2.0;+        let (t, _, _) = s.solve(&q, 1e-12, 100);+        let r = n.result(&t, 2.0);+        close(t[n.case], 4.0);+        close(t[n.junction], 5.0);+        close(t[0], 2.0);+        close(r["boardCouplingW"].as_f64().unwrap(), 1.0);+        close(r["sinkToAmbientW"].as_f64().unwrap(), 1.0);+    }+    #[test]+    fn hotter_board_can_send_heat_back_into_external_sink() {+        let mut p = package();+        p.board_links.push(BoardLink {+            pad: "2".into(),+            at: [0.0, 0.0],+            layer: "F.Cu".into(),+            resistance_k_per_w: 2.0,+        });+        let mut s = System::new(1, 1, 1);+        s.leak[0][0] = 0.5;+        let n = p.attach(&mut s, &[0], false);+        let mut q = vec![0.0; s.n()];+        q[0] = 10.0;+        q[n.junction] = 1.0;+        let (t, _, _) = s.solve(&q, 1e-12, 100);+        let r = n.result(&t, 1.0);+        assert!(r["boardCouplingW"].as_f64().unwrap() < 0.0);+        close(r["energyResidualW"].as_f64().unwrap(), 0.0);+        close(t[0] * 0.5 + r["sinkToAmbientW"].as_f64().unwrap(), 11.0);+    }+    #[test]+    fn invalid_or_untraceable_resistances_are_rejected() {+        for r in [0.0, -1.0, f64::NAN, f64::INFINITY] {+            let mut p = package();+            p.case_sink_k_per_w = r;+            assert!(p.validate().is_err());+        }+        let mut p = package();+        p.provenance.clear();+        assert!(p.validate().is_err());+    }+}
src/ui/app.js+15−4
@@ -192,7 +192,7 @@     if (q === 'emc') return window.fieldsEmc ? window.fieldsEmc.narration() : '';     if (q === 'ground') return window.fieldsGround ? window.fieldsGround.narration() : '';     if (window.fieldsFlow && ['flow', 'river', 'energy', 'light', 'charge'].includes(q)) return window.fieldsFlow.narration(q);-    const n0 = (f.nets || [])[0], c0 = (f.chips || [])[0];+    const n0 = (f.nets || [])[0], c0 = [...(f.chips || [])].sort((a,b) => state.view.air === 'moving' ? b.riseMovingC-a.riseMovingC : b.riseC-a.riseC)[0];     if (q === 'current') return `This is current density: how many amps flow through each square millimetre of copper. Dark is quiet copper, yellow is where the current crowds.${n0 ? ` The busiest spot is on ${n0.net}, at about ${Math.round(n0.peakJ)} amps per square millimetre, on the ${n0.peakLayer === 'F.Cu' ? 'top' : n0.peakLayer === 'B.Cu' ? 'bottom' : 'inner'} copper.` : ''} Above about sixty amps per square millimetre, a narrow neck starts to heat up, and the issues list calls it out. Click a net on the left to see it alone.`;     if (q === 'heat') return `This is heat flow in the copper: where the watts travel away from the hot parts. The brighter the copper, the more heat is moving through it.${c0 ? ` ${c0.reference} puts out ${c0.watts} watts: about ${Math.round(c0.planeW / c0.watts * 100)} percent spreads along its copper, and ${Math.round(c0.viaW / c0.watts * 100)} percent goes down its ${c0.vias} vias to the other side.` : ''} More copper and more vias under a hot part give its heat more ways out.`;     if (q === 'temperature') return `This is the temperature rise over the whole board, in ${state.view.air === 'moving' ? 'moving' : 'still'} air: how many degrees above the room each spot sits.${c0 ? ` The hottest part is ${c0.reference}, at ${Math.round(state.view.air === 'moving' ? c0.riseMovingC : c0.riseC)} degrees above ambient, against a budget of ${f.heat.budgetC}.` : ''} Switch between still and moving air on the left to see what a fan does.`;@@ -408,7 +408,8 @@     for (const b of document.querySelectorAll('.views .chip[data-cam]')) 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>';+    const chipRise = c => v.air === 'moving' ? c.riseMovingC : c.riseC;+    $('#chips').innerHTML = [...f.chips].sort((a,b) => chipRise(b)-chipRise(a)).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:${chipRise(c) > f.heat.budgetC ? 'var(--red)' : 'var(--text)'}">+${chipRise(c).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);};@@ -417,13 +418,23 @@     if (!(signals.capacitance || []).length && !(signals.differentialPairs || []).length) addSignal('No signal checks specified. Add signals.capacitance or signals.differentialPairs with measured stackup and dielectric data, then analyze again.');     addSignal(signals.method || 'No signal results in this solve.');     const hsPanel = $('#heatsinks'); hsPanel.replaceChildren();+    for (const p of f.packageCooling || []) {+      const result = p[v.air === 'moving' ? 'moving' : 'still'];+      const row = document.createElement('div'); row.className = 'empty';+      row.textContent = `${p.definition.reference} supplied package model (${v.air}): junction +${result.junctionRiseC.toFixed(1)} °C, case +${result.caseRiseC.toFixed(1)} °C, sink +${result.sinkRiseC.toFixed(1)} °C. ${result.sinkToAmbientW.toFixed(3)} W to ambient; ${result.boardCouplingW.toFixed(3)} W to board (negative means board cools through package). Steady-state screen; fan-fault transients not qualified.`;+      row.title = p.definition.provenance;+      hsPanel.appendChild(row);+      const chipRow = [...$('#chips').querySelectorAll('[data-chip]')].find(e => e.dataset.chip === p.definition.reference);+      if (chipRow) chipRow.dataset.tip = row.textContent + ' Chip temperature uses the hottest of the board region, case and junction.';+    }+     for (const hs of f.heatsinks || []) {       const row=document.createElement('div'); row.className='empty';       row.textContent=`${hs.definition.name} (${hs.definition.layer}): ${hs.contactW.toFixed(3)} W into block, peak +${hs.peakRiseC.toFixed(1)} °C still / +${hs.peakRiseMovingC.toFixed(1)} °C moving. Contact ${hs.contactAreaMm2.toFixed(1)} mm².`;       hsPanel.appendChild(row);     }-    if (!(f.heatsinks || []).length) hsPanel.textContent='No heatsinks specified';-    $('#method').textContent = f.method + ` Solved in ${f.seconds} s on a ${f.board.cellMm} mm grid (${f.board.cols} x ${f.board.rows} cells x 4 layers).`;+    if (!(f.heatsinks || []).length && !(f.packageCooling || []).length) hsPanel.textContent='No heatsinks specified';+    $('#method').textContent = f.method + ` Solved in ${f.seconds} s on a ${f.board.cellMm} mm grid (${f.board.cols} x ${f.board.rows} cells x ${f.board.layers.length} layers).`;   }   async function setView(patch) { const r = await post('ui/view', patch); if (!r) toast('The server did not answer', 'error', true); }   document.addEventListener('click', e => {

Comments

John Lauer ·

Merged into main as adom-fields 0.7.17: packageCooling (junction/case/sink nodes, board links, energy balance, per-air-state results), the docs and the fixture. The fixture reproduces exactly: +9/+8/+6 °C still, +7/+6/+4 °C moving, zero energy residual. Links are validated against the board's real copper layers.

Log in to comment.