app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
Add conducting heatsinks and explicit signal screening with verified fixtures
18 files changed
+424−39
Cargo.lock+1−1Cargo.toml+1−1README.md+4SKILL.md+7−1docs/fixtures/conducting-block/board.kicad_pcb+8docs/fixtures/conducting-block/spec.json+59docs/heatsinks-and-signals.md+37package.json+1−1skills/adom-fields/SKILL.md+7−1src/board.rs+8−4src/fields.rs+50−19src/heatsink.rs+66src/main.rs+26−3src/serve.rs+1−1src/signals.rs+104src/solve.rs+9−4src/ui/app.js+32−2src/ui/index.html+3−1Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fields"-version = "0.3.1"+version = "0.4.0" dependencies = [ "clap", "png",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.3.1"+version = "0.4.0" 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."
README.md+4@@ -61,3 +61,7 @@ Every solve records SHA-256 identities of its exact board/spec and refuses if ei <video controls preload="metadata" src="https://wiki.adom.inc/api/pages/adom/adom-fields/files/docs/gentle-walkthrough.mp4" width="100%"></video> Recorded through AI Flow on AdomLapper, 129.8 seconds raw and 13.1 seconds at 10x. The contact sheet was inspected and the camera remained stable when drift was disabled. This is a viewer demonstration: the board still has current and thermal findings, not an independent qualification pass.++## Conducting heatsinks and signals in 0.4.0++Specify finite-conductivity rectangular blocks with contact resistance, inspect heat entering them, and compare still/moving air. The Signals view reports copper-overlap capacitance, differential-pair length skew, sampled reference coverage and conservative microstrip impedance estimates. Unsupported geometry is explicit. [Inputs, limitations, sources and reproducible fixture](docs/heatsinks-and-signals.md).
SKILL.md+7−1@@ -52,8 +52,14 @@ Record the pup window (Adom Bridge `desktop_record_window_start` on it) while `a ## Honest limits (0.1) -DC only (no skin effect, no switching loss on the copper); one copper thickness for all layers; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), no heatsink, no enclosure, no radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number.+DC only (no skin effect, no switching loss on the copper); per-layer thickness when copperUmByLayer is supplied; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), optional specified rectangular conducting blocks; no fins, enclosure or radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number. ## Evidence identity Each solve records SHA-256 identities of the board and spec in `fields.json.inputIdentity`, plus the solver version. If an input changes while solving, analysis refuses to publish the summary. AI Flow uses these identities to reject stale field screens, and requires every positive load and hot part to be represented. Existing summaries without these identities must be re-solved, not manually stamped. The identities establish which inputs were used; they do not establish the accuracy of assumed terminals, losses, materials or convection.++## Conducting blocks and signals (0.4.0)++Read `docs/heatsinks-and-signals.md` in the package before specifying these models. Use measured conductivity/contact resistance for `heatsinks`; contact is to the board face, not an automatically modeled chip lid. Never add a fictitious heatsink to make a gate pass. Inspect both air conditions and mesh sensitivity.++For signal screening, provide `signals.capacitance` or `signals.differentialPairs`, explicit consistent `stackupMm`, and justified epsilonR. Inspect Signals with `adom-fields ui --port 8874 view --quantity signals`. Unsupported geometry must stay unsupported; do not replace null estimates with guessed numbers. This is outer-layer microstrip and adjacent-layer overlap screening, not high-frequency signoff. The summary records assumptions and primary references.
docs/fixtures/conducting-block/board.kicad_pcbadded+8@@ -0,0 +1,8 @@+(kicad_pcb (version 20240108) (generator "adom-fields-fixture")+(layers (0 "F.Cu" signal) (1 "In1.Cu" power) (2 "In2.Cu" power) (31 "B.Cu" signal))+(gr_rect (start 0 0) (end 12 12) (layer "Edge.Cuts") (width 0.05))+(footprint "Test:Tab" (layer "F.Cu") (at 6 6) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 2 2) (layers "F.Cu") (net 1 "GND")))+(segment (start 2 5) (end 10 5) (width 0.3) (layer "F.Cu") (net "P"))+(segment (start 2 5.5) (end 10 5.5) (width 0.3) (layer "F.Cu") (net "N"))+(zone (net "GND") (net_name "GND") (layer "In1.Cu") (filled_polygon (layer "In1.Cu") (pts (xy 1 1) (xy 11 1) (xy 11 11) (xy 1 11))))+(zone (net "POWER") (net_name "POWER") (layer "In2.Cu") (filled_polygon (layer "In2.Cu") (pts (xy 1 1) (xy 11 1) (xy 11 11) (xy 1 11)))))
docs/fixtures/conducting-block/spec.jsonadded+59@@ -0,0 +1,59 @@+{+ "board": "Conducting-block and signals validation fixture",+ "copperUm": 35,+ "hot": {+ "U1": {+ "watts": 0.2,+ "tabNet": "GND"+ }+ },+ "heatsinks": [+ {+ "name": "Aluminium block",+ "layer": "F.Cu",+ "boundsMm": [+ 2,+ 2,+ 10,+ 10+ ],+ "thicknessMm": 3,+ "conductivityWPerMK": 200,+ "contactResistanceMm2KPerW": 100,+ "zCells": 3+ }+ ],+ "signals": {+ "capacitance": [+ {+ "name": "Plane overlap",+ "netA": "GND",+ "layerA": "In1.Cu",+ "netB": "POWER",+ "layerB": "In2.Cu",+ "dielectricMm": 0.2,+ "epsilonR": 4+ }+ ],+ "differentialPairs": [+ {+ "name": "Matched pair",+ "positive": "P",+ "negative": "N",+ "layer": "F.Cu",+ "referenceNet": "GND",+ "referenceLayer": "In1.Cu",+ "dielectricMm": 0.2,+ "epsilonR": 4.2,+ "maxSkewMm": 0.1,+ "targetOhms": 90,+ "tolerancePercent": 20+ }+ ]+ },+ "stackupMm": [+ 0.2,+ 0.2,+ 0.2+ ]+}
docs/heatsinks-and-signals.mdadded+37@@ -0,0 +1,37 @@+# Conducting heatsinks and signal screening++Fields 0.4.0 adds finite-volume conducting blocks and a Signals view. These are engineering screens with explicit input assumptions, not manufacturing or signal-integrity qualification. The existing ESC results are not repaired by adding this feature: a heatsink must actually exist, be mounted as specified, and have justified contact/material data.++## Conducting blocks++`heatsinks` is an optional list. Each entry supplies `name`, `layer` (`F.Cu` or `B.Cu`), `boundsMm` (`[xMin,yMin,xMax,yMax]` in board coordinates), `thicknessMm`, `conductivityWPerMK`, `contactResistanceMm2KPerW`, and optional `zCells` (default 3, range 1 to 20). Rectangles must lie within the board bounding rectangle, span at least one solve cell, and not overlap another block on the same face. Names must be unique.++The contact is to the board's outer thermal layer, not automatically to a package lid. Supply package/TIM resistance only if that board-face approximation represents the intended assembly. The current model does not resolve a package body, fins, fasteners, enclosure airflow, radiation, transient response or off-board overhangs.++Every block has finite conduction in X, Y and Z, with clipped boundary-cell areas. Contact resistance and half-cell block resistance are in series. Exposed top and side faces lose heat to still or moving air, including their half-cell conduction resistance. Board convection is removed beneath the contact footprint. The summary reports heat entering each block and peak block rise, separately for each air condition. The viewer renders the specified block as translucent geometry; it does not invent fins.++The network uses conductance `k A / L`, contact conductance `A / R_area`, and convection `h A`. See [COMSOL's Fourier-law description](https://doc.comsol.com/6.3/doc/com.comsol.help.heat/heat_ug_theory.07.004.html) and [thermal-contact explanation](https://www.comsol.com/blogs/thermal-contact-resistance-simulation). Copper and dielectric remain a four-layer board model. `copperUmByLayer` optionally overrides `copperUm` for each named layer; Joule loss is deposited on its source layer. A thermal solve that fails the residual threshold refuses publication.++## Signals++Select **Signals** or run `adom-fields ui --port 8874 view --quantity signals`. Add `signals.capacitance` and/or `signals.differentialPairs` to the spec. Explicit `stackupMm` is required; every check's `dielectricMm` must match the corresponding adjacent layer separation. Never guess dielectric properties for an approval claim.++A capacitance check supplies `name`, `netA`, `layerA`, `netB`, `layerB`, `dielectricMm`, and `epsilonR`. It counts actual overlapping fine-grid copper pixels on adjacent layers, then calculates parallel-plate capacitance. Missing copper is reported. This omits fringe fields and frequency dependence. Source: [TI Analog Engineer's Pocket Reference](https://www.ti.com/seclit/eb/slyw038c/slyw038c.pdf).++A differential-pair check supplies `name`, `positive`, `negative`, `layer`, `referenceNet`, `referenceLayer`, `dielectricMm`, `epsilonR`, `maxSkewMm`, `targetOhms`, and `tolerancePercent`. It checks connected, unbranched straight-segment chains, total segment lengths and skew, sampled reference copper under the trace and a one-dielectric-height margin, actual parallel partner gaps and equal widths. It estimates outer-layer microstrip impedance using [TI SLVA531A section 7.1](https://www.ti.com/lit/an/slva531a/slva531a.pdf). Screening range: width/height 0.1 to 3, thickness/height at most 0.25, width at least two fine-grid samples.++Vias, arcs, layer changes, branches, disconnected chains, insufficient sampling, missing reference copper or unmatched partner geometry refuse the impedance estimate. Bend transition samples can therefore make an otherwise useful pair unsupported. This conservative first version does not estimate inner-layer stripline, losses, dispersion, pads, packages, solder mask, crosstalk, eye diagrams or S-parameters. `screen-within-target` means only this approximation falls within the requested range.++## Reproduce the fixture++The [fixture spec](fixtures/conducting-block/spec.json) and [parser fixture board](fixtures/conducting-block/board.kicad_pcb) exercise both additions. The board is a solver fixture, not a manufacturing design.++```sh+adom-fields analyze --board docs/fixtures/conducting-block/board.kicad_pcb --spec docs/fixtures/conducting-block/spec.json --pitch 0.2 --out fixture-fields+adom-fields serve --fields fixture-fields --port 4874+adom-fields ui --port 4874 view --quantity signals+```++Expected signal results: 100 mm² overlap gives 17.7084 pF at epsilonR 4 and 0.2 mm; two 8 mm traces have zero length skew, 0.2 mm edge gap and approximately 88.14 ohms differential impedance. Removing the reference copper refuses the impedance result. Tests also cover a known series thermal network, energy conservation, finite block temperature gradient, and a board whose tab rise falls with the conducting block present. These tests validate the implementation against those cases, not a physical ESC assembly.++Validation on the shipped fixture at 0.2 mm pitch: baseline tab rise 86.8 C; conducting block 57.0 C at both 3 and 6 cells through its thickness. The existing ESC spec, with no added heatsinks or signal checks, still reports 16 findings and about 111 C / 67 C rise in still / moving air. The release does not certify that board.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.3.1",+ "version": "0.4.0", "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.",
skills/adom-fields/SKILL.md+7−1@@ -52,8 +52,14 @@ Record the pup window (Adom Bridge `desktop_record_window_start` on it) while `a ## Honest limits (0.1) -DC only (no skin effect, no switching loss on the copper); one copper thickness for all layers; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), no heatsink, no enclosure, no radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number.+DC only (no skin effect, no switching loss on the copper); per-layer thickness when copperUmByLayer is supplied; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), optional specified rectangular conducting blocks; no fins, enclosure or radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number. ## Evidence identity Each solve records SHA-256 identities of the board and spec in `fields.json.inputIdentity`, plus the solver version. If an input changes while solving, analysis refuses to publish the summary. AI Flow uses these identities to reject stale field screens, and requires every positive load and hot part to be represented. Existing summaries without these identities must be re-solved, not manually stamped. The identities establish which inputs were used; they do not establish the accuracy of assumed terminals, losses, materials or convection.++## Conducting blocks and signals (0.4.0)++Read `docs/heatsinks-and-signals.md` in the package before specifying these models. Use measured conductivity/contact resistance for `heatsinks`; contact is to the board face, not an automatically modeled chip lid. Never add a fictitious heatsink to make a gate pass. Inspect both air conditions and mesh sensitivity.++For signal screening, provide `signals.capacitance` or `signals.differentialPairs`, explicit consistent `stackupMm`, and justified epsilonR. Inspect Signals with `adom-fields ui --port 8874 view --quantity signals`. Unsupported geometry must stay unsupported; do not replace null estimates with guessed numbers. This is outer-layer microstrip and adjacent-layer overlap screening, not high-frequency signoff. The summary records assumptions and primary references.
src/board.rs+8−4@@ -18,6 +18,7 @@ pub struct Board { pub polys: Vec<Poly>, pub tracks: Vec<Track>, pub vias: Vec<Via>, pub pads: Vec<Pad>, /// pad copper shapes as polygons (already in board coordinates) pub pad_polys: Vec<Poly>,+ pub arc_nets: Vec<String>, pub layer_names: Vec<String>, } @@ -29,6 +30,8 @@ impl Board { let root = parse(&text); let mut b = Board::default(); 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();+ 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 let (mut xs, mut ys) = (Vec::new(), Vec::new()); for key in ["gr_line", "gr_rect", "gr_arc", "gr_poly", "gr_circle"] {@@ -42,27 +45,28 @@ impl Board { b.x0 = xs.iter().cloned().fold(f64::MAX, f64::min); b.x1 = xs.iter().cloned().fold(f64::MIN, f64::max); b.y0 = ys.iter().cloned().fold(f64::MAX, f64::min); b.y1 = ys.iter().cloned().fold(f64::MIN, f64::max); for z in root.kids("zone") {- let net = z.kid("net_name").and_then(|n| n.atom(1)).map(str::to_string).or_else(|| z.kid("net").and_then(|n| n.last_atom()).map(str::to_string)).unwrap_or_default();+ let net = z.kid("net_name").and_then(|n| n.atom(1)).map(str::to_string).or_else(|| z.kid("net").map(&net_name)).unwrap_or_default(); for fp in z.kids("filled_polygon") { let layer = fp.kid("layer").and_then(|l| l.atom(1)).unwrap_or("").to_string(); let pts: Vec<(f64, f64)> = fp.kid("pts").map(|p| p.kids("xy").iter().filter_map(|xy| Some((xy.num(1)?, xy.num(2)?))).collect()).unwrap_or_default(); if pts.len() > 2 { b.polys.push(Poly { net: net.clone(), layer, pts }); } } }+ b.arc_nets = root.kids("arc").iter().filter_map(|a|a.kid("net").map(&net_name)).collect(); for s in root.kids("segment") { let (Some(st), Some(en)) = (s.kid("start"), s.kid("end")) else { continue };- b.tracks.push(Track { net: s.kid("net").and_then(|n| n.last_atom()).unwrap_or("").to_string(), 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) });+ 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") { let Some(at) = v.kid("at") else { continue };- b.vias.push(Via { net: v.kid("net").and_then(|n| n.last_atom()).unwrap_or("").to_string(), 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) }); } 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(); let at = fp.kid("at"); let (fx, fy, fa) = (at.and_then(|a| a.num(1)).unwrap_or(0.0), at.and_then(|a| a.num(2)).unwrap_or(0.0), at.and_then(|a| a.num(3)).unwrap_or(0.0)); for p in fp.kids("pad") {- let Some(net) = p.kid("net").and_then(|n| n.last_atom()).map(str::to_string) else { continue };+ let Some(net) = p.kid("net").map(&net_name) else { continue }; let pat = p.kid("at"); let (dx, dy) = (pat.and_then(|a| a.num(1)).unwrap_or(0.0), pat.and_then(|a| a.num(2)).unwrap_or(0.0)); let pa = pat.and_then(|a| a.num(3)).unwrap_or(fa);
src/fields.rs+50−19@@ -21,6 +21,9 @@ pub struct Spec { #[serde(default = "d60")] pub hotMaxRiseC: f64, #[serde(default)] pub thermalVias: Vec<Value>, #[serde(default)] pub board: String,+ #[serde(default)] pub copperUmByLayer: BTreeMap<String,f64>,+ #[serde(default)] pub signals: crate::signals::Config,+ #[serde(default)] pub heatsinks: Vec<crate::heatsink::Block>, #[serde(default)] pub stackupMm: Vec<f64>, } fn d35() -> f64 { 35.0 }@@ -46,6 +49,9 @@ pub struct Fields { pub t_moving: Vec<Grid>, 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 signals: Value,+ pub heat_residual: f64, pub heat_moving_residual: f64, } pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMut(&str)) -> Fields {@@ -55,7 +61,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu let pool = 2; let (w, h) = (ras.w / pool, ras.h / pool); let d = pitch;- let t_cu = spec.copperUm / 1000.0;+ let t_cu: Vec<f64> = LAYERS.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).collect(); 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@@ -64,7 +70,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu // ---- 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 = Grid::new(w, h);+ 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();@@ -74,8 +80,8 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu let mut sys = System::new(nl, h, w); for (a, k) in live.iter().enumerate() { let f = &fr[*k];- for r in 0..h { for c in 0..w - 1 { sys.gx[a][r * (w - 1) + c] = SIGMA * t_cu * harm(f.at(r, c) as f64, f.at(r, c + 1) as f64); } }- for r in 0..h - 1 { for c in 0..w { sys.gy[a][r * w + c] = SIGMA * t_cu * harm(f.at(r, c) as f64, f.at(r + 1, c) as f64); } }+ for r in 0..h { for c in 0..w - 1 { sys.gx[a][r * (w - 1) + c] = SIGMA * t_cu[*k] * harm(f.at(r, c) as f64, f.at(r, c + 1) as f64); } }+ 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;@@ -155,10 +161,10 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu let fi = f.v[i] as f64; if fi > 0.05 { let mag = (cx[i] * cx[i] + cy[i] * cy[i]).sqrt();- let jj = mag / (d * fi.max(0.25) * t_cu);+ let jj = mag / (d * fi.max(0.25) * t_cu[*k]); grids[*k].v[i] = jj as f32;- joule += jj * jj / SIGMA * fi * d * d * t_cu;- p_joule.v[i] += (jj * jj / SIGMA * fi * d * d * t_cu) as f32;+ joule += jj * jj / SIGMA * fi * d * d * t_cu[*k];+ p_joule[*k].v[i] += (jj * jj / SIGMA * fi * d * d * t_cu[*k]) as f32; if fi >= 0.25 { vals.push(jj as f32); } } }@@ -185,10 +191,10 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu 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];- let build = |h_air: f64| -> System {+ let build = |h_air: f64| { let mut sys = System::new(nl, h, w); for k in 0..nl {- let kt: Vec<f64> = frac[k].v.iter().map(|f| *f as f64 * K_CU * t_cu + K_FR4_XY * share[k]).collect();+ let kt: Vec<f64> = frac[k].v.iter().map(|f| *f as f64 * K_CU * t_cu[k] + K_FR4_XY * share[k]).collect(); for r in 0..h { for c in 0..w - 1 { sys.gx[k][r * (w - 1) + c] = harm(kt[r * w + c], kt[r * w + c + 1]); } } for r in 0..h - 1 { for c in 0..w { sys.gy[k][r * w + c] = harm(kt[r * w + c], kt[(r + 1) * w + c]); } } let leak = if k == 0 || k == nl - 1 { h_air * d * d } else { 0.0 };@@ -200,10 +206,11 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu let area = std::f64::consts::PI * ((v.drill / 2.0 + 0.025).powi(2) - (v.drill / 2.0).powi(2)); for k in 0..nl - 1 { sys.gz[k][r * w + c] += K_CU * area / t_diel[k]; } }- sys+ 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 mut q_rhs = vec![0.0; nl * n];- for i in 0..n { q_rhs[i] += p_joule.v[i] as f64 / 2.0; q_rhs[(nl - 1) * n + i] += p_joule.v[i] as f64 / 2.0; }+ for k in 0..nl { for i in 0..n {q_rhs[k*n+i] += p_joule[k].v[i] as f64;} } let mut chip_pos: Vec<(String, f64, f64, f64, String)> = Vec::new(); 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();@@ -218,12 +225,18 @@ 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 = build(H_STILL);+ let (sys_still, sink_meshes) = build(H_STILL);+ q_rhs.resize(sys_still.n(),0.0); 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 (tm, it2, _) = sys_mov.solve(&q_rhs, 1e-7, 8000);+ let (sys_mov, _) = build(H_MOVING);+ let (tm, it2, res2) = sys_mov.solve(&q_rhs, 1e-7, 8000); log(&format!("heat, moving air: {it2} iterations"));+ 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>();+ json!({"definition":block,"contactAreaMm2":mesh.area_mm2,"nodes":mesh.nodes.len(),"peakRiseC":peak(&tv),"peakRiseMovingC":peak(&tm),"contactW":power(&tv),"contactMovingW":power(&tm)})+ }).collect(); let mut t: Vec<Grid> = Vec::new(); let mut t_moving: Vec<Grid> = Vec::new(); let mut q: Vec<Grid> = Vec::new(); let mut fx_all: Vec<Vec<f64>> = Vec::new(); let mut fy_all: Vec<Vec<f64>> = Vec::new(); for k in 0..nl {@@ -254,7 +267,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu 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 += H_STILL * d * d * tv[i]; } }+ 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) }); } // ---- issues -------------------------------------------------------------------------------@@ -265,7 +278,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu // amp through a thin track is a warm trace and twenty amps through a neck is a fuse if x.peakJ > neck { let severity = if x.amps < 1.0 { "low" } else if x.peakJ > 2.5 * neck { "high" } else { "medium" };- issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A through a neck about {:.2} mm wide at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * t_cu), spec.copperUm), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } });+ issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A through a neck about {:.2} mm wide at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)/1000.0), spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } }); } } for c in &chips {@@ -277,7 +290,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu } } let _ = (tab_total, joule_total);- Fields { 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 { 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.@@ -293,14 +306,32 @@ 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, "thicknessMm": 1.6, "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, "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}, "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."+ "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." }) }++#[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() };+ 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 |_|{});+ spec.heatsinks.push(serde_json::from_value(json!({"name":"block","layer":"F.Cu","boundsMm":[2,2,10,10],"thicknessMm":3,"conductivityWPerMK":200,"contactResistanceMm2KPerW":100,"zCells":3})).unwrap());+ let cooled=analyze(&b,&spec,1.,60.,&mut |_|{});+ assert!(cooled.chips[0].riseC<baseline.chips[0].riseC);+ assert!(cooled.heatsinks[0]["contactW"].as_f64().unwrap()>0.0);+ assert!(cooled.heatsinks[0]["contactW"].as_f64().unwrap()<0.201);+ assert!(cooled.heat_residual<1e-6);+ assert!(cooled.chips[0].riseMovingC<cooled.chips[0].riseC);+ }+}
src/heatsink.rsadded+66@@ -0,0 +1,66 @@+//! Finite-volume rectangular conducting blocks, attached to an outer board face.+//! SI inputs; network uses mm and W/K. No fins, radiation, enclosure or contact inference.+use serde::{Deserialize, Serialize};+use crate::solve::System;+#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all="camelCase", deny_unknown_fields)]+pub struct Block {+ pub name: String,+ pub layer: String,+ pub bounds_mm: [f64;4],+ pub thickness_mm: f64,+ pub conductivity_w_per_m_k: f64,+ pub contact_resistance_mm2_k_per_w: f64,+ #[serde(default="slices")] pub z_cells: usize,+}+fn slices()->usize { 3 }+impl Block {+ pub fn validate(&self, bounds:[f64;4], pitch:f64)->Result<(),String> {+ let [x0,y0,x1,y1]=self.bounds_mm;+ if self.name.trim().is_empty() || !matches!(self.layer.as_str(),"F.Cu"|"B.Cu") { return Err("heatsink needs a name and F.Cu or B.Cu contact layer".into()); }+ if !self.bounds_mm.iter().all(|x|x.is_finite()) || x1-x0 < pitch || y1-y0 < pitch || x0<bounds[0] || y0<bounds[1] || x1>bounds[2] || y1>bounds[3] { return Err(format!("{}: heatsink bounds must lie on the board and span at least one solve cell",self.name)); }+ if !self.thickness_mm.is_finite() || self.thickness_mm<=0.0 || !self.conductivity_w_per_m_k.is_finite() || self.conductivity_w_per_m_k<=0.0 || !self.contact_resistance_mm2_k_per_w.is_finite() || self.contact_resistance_mm2_k_per_w<0.0 || !(1..=20).contains(&self.z_cells) { return Err(format!("{}: invalid thickness, conductivity, contact resistance or zCells (1..20)",self.name)); }+ Ok(())+ }+}+pub struct Mesh { pub nodes:Vec<usize>, pub contacts:Vec<(usize,usize,f64)>, pub area_mm2:f64 }+/// Boundary cells are clipped exactly to the rectangular block; board contact is area weighted.+pub fn attach(sys:&mut System,b:&Block,x0:f64,y0:f64,d:f64,h_air:f64)->Mesh {+ let (w,h)=(sys.w,sys.h); let layer=if b.layer=="F.Cu" {0} else {sys.nl-1};+ let [bx0,by0,bx1,by1]=b.bounds_mm;+ let xs:Vec<_>=(0..w).filter_map(|c| { let a=(x0+c as f64*d).max(bx0); let z=(x0+(c+1) as f64*d).min(bx1); (z>a).then_some((c,a,z)) }).collect();+ let ys:Vec<_>=(0..h).filter_map(|r| { let a=(y0+r as f64*d).max(by0); let z=(y0+(r+1) as f64*d).min(by1); (z>a).then_some((r,a,z)) }).collect();+ let (nx,ny)=(xs.len(),ys.len()); let dz=b.thickness_mm/b.z_cells as f64; let k=b.conductivity_w_per_m_k/1000.0;+ let mut nodes=Vec::new(); let mut contacts=Vec::new(); let mut area=0.0;+ for z in 0..b.z_cells { for (iy,&(r,ya,yb)) in ys.iter().enumerate() { for (ix,&(c,xa,xb)) in xs.iter().enumerate() {+ let (dx,dy)=(xb-xa,yb-ya); let a=dx*dy;+ // Half-cell conduction in series with convection at each exposed face.+ let face=|ar:f64,dist:f64| ar/(1.0/h_air+dist/k);+ let leak=if z==b.z_cells-1 {face(a,dz/2.0)} else {0.0}+ + if ix==0 {face(dy*dz,dx/2.0)} else {0.0} + if ix+1==nx {face(dy*dz,dx/2.0)} else {0.0}+ + if iy==0 {face(dx*dz,dy/2.0)} else {0.0} + if iy+1==ny {face(dx*dz,dy/2.0)} else {0.0};+ let id=sys.add_node(leak); nodes.push(id);+ let pos=z*nx*ny+iy*nx+ix;+ if ix>0 { let prev=xs[ix-1]; let distance=(dx+prev.2-prev.1)/2.0; sys.edges.push((nodes[pos-1],id,k*dy*dz/distance)); }+ if iy>0 { let prev=ys[iy-1]; let distance=(dy+prev.2-prev.1)/2.0; sys.edges.push((nodes[pos-nx],id,k*dx*dz/distance)); }+ if z>0 {sys.edges.push((nodes[pos-nx*ny],id,k*a/dz));}+ else { let board=layer*w*h+r*w+c; let g=a/(b.contact_resistance_mm2_k_per_w+dz/(2.0*k)); sys.edges.push((board,id,g)); contacts.push((board,id,g)); sys.leak[layer][r*w+c]=(sys.leak[layer][r*w+c]-h_air*a).max(0.0); area+=a; }+ } } }+ Mesh { nodes,contacts,area_mm2:area }+}+#[cfg(test)] mod tests {+ use super::*;+ #[test] fn conducting_block_conserves_energy_and_has_temperature_gradient() {+ let mut s=System::new(1,1,2);s.leak[0]=vec![1e-5,1e-5];+ let b=Block{name:"test".into(),layer:"F.Cu".into(),bounds_mm:[0.,0.,2.,1.],thickness_mm:1.,conductivity_w_per_m_k:2.,contact_resistance_mm2_k_per_w:100.,z_cells:3};+ let m=attach(&mut s,&b,0.,0.,1.,1e-5);let mut rhs=vec![0.;s.n()];rhs[0]=1.;+ let (t,_,r)=s.solve(&rhs,1e-10,500);assert!(r<1e-9);assert!((m.area_mm2-2.).abs()<1e-12);+ let air:f64=s.extra_leak.iter().enumerate().map(|(i,g)|g*t[2+i]).sum();assert!((air-1.).abs()<1e-8);+ let contact:f64=m.contacts.iter().map(|(a,b,g)|g*(t[*a]-t[*b])).sum();assert!((contact-1.).abs()<1e-8);+ assert!(t[m.nodes[0]]>t[m.nodes[1]]);assert!(t[0]>t[m.nodes[0]]);+ }+ #[test] fn analytic_series_network() {+ let mut s=System::new(1,1,1);let sink=s.add_node(0.5);s.edges.push((0,sink,2.));+ let (t,_,r)=s.solve(&[3.,0.],1e-12,20);assert!(r<1e-10);assert!((t[0]-7.5).abs()<1e-10);assert!((t[1]-6.).abs()<1e-10);+ }+}
src/main.rs+26−3@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+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; @@ -16,7 +16,7 @@ enum Cmd { #[arg(long, default_value_t = 60.0)] neck: f64 }, /// Serve the app for a solved board (or for a board and spec, solving first): the 3D board with the fields on it, the nets by relevance, the issues, and the live channel the AI drives Serve { #[arg(long, default_value = "fields")] fields: PathBuf, #[arg(long, default_value_t = 8874)] port: u16, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String> },- /// Drive the running app: `ui view --quantity current|heat|temperature [--net X] [--layer F.Cu] [--camera top|iso|bottom|fit]`, `ui toast "..."`, `ui select --net X`, `ui issue --index 0`+ /// Drive the running app: `ui view --quantity current|heat|temperature|signals [--net X] [--layer F.Cu] [--camera top|iso|bottom|fit]`, `ui toast "..."`, `ui select --net X`, `ui issue --index 0` Ui { #[command(subcommand)] what: UiCmd, #[arg(long, default_value_t = 8874)] port: u16 }, /// A scripted walkthrough over the live channel: the nets by relevance, each field, the issues; for the AI's video step Tour { #[arg(long, default_value_t = 8874)] port: u16, /// milliseconds to hold each beat while the board drifts; gentle on purpose, the flow speeds the clip up ten times@@ -47,11 +47,33 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck let spec_bytes = std::fs::read(spec_path).map_err(|e|e.to_string())?; let b = board::Board::load(board)?; 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 spec.loads.is_empty() && spec.hot.is_empty() { return Err("the spec has no loads and no hot parts: nothing to solve".into()); }+ 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()?;+ 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());}+ 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();+ if (spec.stackupMm[a.min(b)]-distance).abs()>1e-6 {return Err("signal dielectricMm disagrees with stackupMm for its adjacent layers".into());}+ }+ }+ for (i,block) in spec.heatsinks.iter().enumerate() {+ block.validate([b.x0,b.y0,b.x0+b.width(),b.y0+b.height()],pitch)?;+ for other in &spec.heatsinks[..i] {+ let a=block.bounds_mm; let c=other.bounds_mm;+ if block.name==other.name || (block.layer==other.layer && a[0]<c[2] && a[2]>c[0] && a[1]<c[3] && a[3]>c[1]) {return Err("heatsinks require unique names and non-overlapping contact rectangles on each face".into());}+ }+ }+ if spec.loads.is_empty() && spec.hot.is_empty() && spec.signals.capacitance.is_empty() && spec.signals.differential_pairs.is_empty() { return Err("the spec has no loads, hot parts or signal checks: nothing to solve".into()); } std::fs::create_dir_all(out.join("tex")).map_err(|e| e.to_string())?; 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()); });+ 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);@@ -106,6 +128,7 @@ fn main() { ok(&format!("{} nets solved, {} hot parts, {} issue(s), in {} s: {}", nets, s["chips"].as_array().map(|a| a.len()).unwrap_or(0), issues.len(), s["seconds"], out.join("fields.json").display()), &[ format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["scales"]["jMax"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]), issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "),+ format!("Heatsinks: {}; signal capacitance checks: {}; differential pairs: {}. Signals are screening estimates; unsupported geometry is reported, never certified.", s["heatsinks"].as_array().map_or(0,|x|x.len()),s["signals"]["capacitance"].as_array().map_or(0,|x|x.len()),s["signals"]["differentialPairs"].as_array().map_or(0,|x|x.len())), format!("Next: `adom-fields serve --fields {}` and open it (pup or the browser); `adom-fields ui view --quantity current --net <net>` drives it; `adom-fields tour` walks it for a video.", out.display()), ]); }
src/serve.rs+1−1@@ -73,6 +73,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { ("POST", "/ui/view") | ("POST", "/ui/select") | ("POST", "/ui/issue") => { let v: Value = serde_json::from_str(&body).unwrap_or(json!({})); let mut a = app.lock().unwrap();+ let before = a.view.clone(); if path == "/ui/issue" { let i = v["index"].as_u64().unwrap_or(0) as usize; let Some(issue) = a.fields["issues"].get(i).cloned() else { drop(a); reply(req, 404, json!({"status": "error", "error": "no such issue", "hint": "GET /fields.json .issues lists them, 0-based"})); return; };@@ -86,7 +87,6 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { } // every command gets a sequence number and the keys it changed, so the page animates the // camera only when a command moved it, and never on a poll that merely re-read the view- let before = a.view.clone(); a.seq += 1; let seq = a.seq; let mut changed: Vec<String> = Vec::new();
src/signals.rsadded+104@@ -0,0 +1,104 @@+//! 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::raster::Raster;+use std::collections::{BTreeMap,BTreeSet};+#[derive(Clone,Default,Deserialize,Serialize)]+#[serde(rename_all="camelCase",deny_unknown_fields)]+pub struct Config {+ #[serde(default)] pub capacitance:Vec<Capacitance>,+ #[serde(default)] pub differential_pairs:Vec<Pair>,+}+#[derive(Clone,Deserialize,Serialize)]+#[serde(rename_all="camelCase",deny_unknown_fields)]+pub struct Capacitance {pub name:String,pub net_a:String,pub layer_a:String,pub net_b:String,pub layer_b:String,pub dielectric_mm:f64,pub epsilon_r:f64}+#[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 material(h:f64,er:f64)->bool {h.is_finite()&&h>0.&&er.is_finite()&&er>=1.}+impl Config {pub fn validate(&self)->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());}}+ Ok(())+}}+pub fn microstrip(w:f64,h:f64,t:f64,er:f64)->Option<f64>{+ // Conservative screening range; refuse extrapolation rather than issue negative impedance.+ if ![w,h,t,er].iter().all(|v|v.is_finite())||w<=0.||h<=0.||t<=0.||er<1.||w/h<0.1||w/h>3.||t/h>0.25{return None;}+ let z=87./(er+1.41).sqrt()*(5.98*h/(0.8*w+t)).ln();(z>0.).then_some(z)+}+fn len(t:&Track)->f64 {(t.b.0-t.a.0).hypot(t.b.1-t.a.1)}+fn chain(ts:&[&Track])->bool {+ type P=(i64,i64);let key=|p:(f64,f64)|->P{((p.0*1e6).round() as i64,(p.1*1e6).round() as i64)};+ let mut graph:BTreeMap<P,Vec<P>>=BTreeMap::new();+ for t in ts {if len(t)<1e-8{return false;}let (a,b)=(key(t.a),key(t.b));graph.entry(a).or_default().push(b);graph.entry(b).or_default().push(a);}+ if graph.values().filter(|x|x.len()==1).count()!=2 || graph.values().any(|x|x.len()>2){return false;}+ let mut seen=BTreeSet::new();let mut todo=vec![*graph.keys().next().unwrap()];while let Some(p)=todo.pop(){if seen.insert(p){todo.extend(graph[&p].iter().copied());}}+ seen.len()==graph.len()+}+fn covered(r:&Raster,net:&str,layer:&str,x:f64,y:f64)->bool {+ let c=((x-r.x0)/r.res).floor() as isize;let row=((y-r.y0)/r.res).floor() as isize;+ if c<0||row<0||c as usize>=r.w||row as usize>=r.h{return false;}+ r.masks.get(&(net.into(),layer.into())).map(|m|m[row as usize*r.w+c as usize]>0).unwrap_or(false)+}+pub fn analyze(b:&Board,r:&Raster,c:&Config,copper:&BTreeMap<String,f64>,fallback:f64)->Value {+ let caps:Vec<Value>=c.capacitance.iter().map(|q|{+ let a=r.masks.get(&(q.net_a.clone(),q.layer_a.clone()));let z=r.masks.get(&(q.net_b.clone(),q.layer_b.clone()));+ let overlap=match(a,z){(Some(a),Some(b))=>a.iter().zip(b).filter(|(x,y)|**x>0&&**y>0).count() as f64*r.res*r.res,_=>0.};+ json!({"name":q.name,"definition":q,"overlapMm2":overlap,"capacitancePf":0.0088541878128*q.epsilon_r*overlap/q.dielectric_mm,"status":if a.is_some()&&z.is_some(){"estimate"}else{"missing-copper"},"method":"adjacent-layer parallel-plate overlap; no fringe fields or frequency dependence"})+ }).collect();+ let pairs:Vec<Value>=c.differential_pairs.iter().map(|p|{+ let a:Vec<_>=b.tracks.iter().filter(|t|t.net==p.positive).collect();let z:Vec<_>=b.tracks.iter().filter(|t|t.net==p.negative).collect();+ let mut warnings=Vec::<String>::new();let lengths=[a.iter().map(|t|len(t)).sum::<f64>(),z.iter().map(|t|len(t)).sum::<f64>()];let skew=(lengths[0]-lengths[1]).abs();+ if a.is_empty()||z.is_empty()||!chain(&a)||!chain(&z){warnings.push("not two connected unbranched segment chains; length is total segment length, not endpoint delay".into());}+ if a.iter().chain(&z).any(|t|t.w<2.*r.res){warnings.push("trace width spans fewer than two sampling cells; solve at a finer pitch".into());}+ if a.iter().chain(&z).any(|t|t.layer!=p.layer)||b.vias.iter().any(|v|v.net==p.positive||v.net==p.negative){warnings.push("vias or layer changes: microstrip estimate refused".into());}+ if b.arc_nets.iter().any(|n|n==&p.positive||n==&p.negative){warnings.push("routed arcs are not supported by this screening calculation".into());}+ if skew>p.max_skew_mm {warnings.push(format!("length skew {skew:.4} mm exceeds {:.4} mm",p.max_skew_mm));}+ let mut gaps=Vec::new();let mut z0s=Vec::new();let mut zdiffs=Vec::new();let mut missing_reference=0;let mut uncoupled=0;+ let t=copper.get(&p.layer).copied().unwrap_or(fallback)/1000.;+ for (side,other) in [(&a,&z),(&z,&a)] {for tr in side.iter() {+ let l=len(tr);if l<=0.{continue;}let dx=(tr.b.0-tr.a.0)/l;let dy=(tr.b.1-tr.a.1)/l;let steps=(l/r.res).ceil().max(1.) as usize;+ for i in 0..steps {let f=(i as f64+0.5)/steps as f64;let x=tr.a.0+f*(tr.b.0-tr.a.0);let y=tr.a.1+f*(tr.b.1-tr.a.1);+ // Check beneath the strip and one dielectric height to either side. This is a sampled check, not return-current simulation.+ if [-1.,0.,1.].iter().any(|m|!covered(r,&p.reference_net,&p.reference_layer,x-dy*m*(tr.w/2.+p.dielectric_mm),y+dx*m*(tr.w/2.+p.dielectric_mm))){missing_reference+=1;}+ let partner=other.iter().filter_map(|q| {let ql=len(q);if ql<=0.{return None;}let ux=(q.b.0-q.a.0)/ql;let uy=(q.b.1-q.a.1)/ql;if (dx*ux+dy*uy).abs()<0.999{return None;}let along=(x-q.a.0)*ux+(y-q.a.1)*uy;if along<0.||along>ql{return None;}let distance=((x-q.a.0)*uy-(y-q.a.1)*ux).abs();Some((distance-(tr.w+q.w)/2.,q.w))}).min_by(|x,y|x.0.total_cmp(&y.0));+ if let Some((gap,width))=partner {if gap<=0.||(width-tr.w).abs()>1e-6{uncoupled+=1;continue;}gaps.push(gap);if let Some(z0)=microstrip(tr.w,p.dielectric_mm,t,p.epsilon_r){z0s.push(z0);zdiffs.push(2.*z0*(1.-0.48*(-0.96*gap/p.dielectric_mm).exp()));}else{uncoupled+=1;}}+ else{uncoupled+=1;}+ }+ }}+ if missing_reference>0{warnings.push(format!("{missing_reference} samples lack reference copper beneath the trace or its margin"));}+ if uncoupled>0{warnings.push(format!("{uncoupled} samples lack a parallel equal-width partner or are outside the approximation range"));}+ let valid=warnings.iter().all(|w|w.starts_with("length skew"))&&!zdiffs.is_empty();+ let range=|v:&Vec<f64>|if v.is_empty(){Value::Null}else{json!([v.iter().copied().fold(f64::INFINITY,f64::min),v.iter().copied().fold(f64::NEG_INFINITY,f64::max)])};+ if valid&&zdiffs.iter().any(|z|(*z-p.target_ohms).abs()>p.target_ohms*p.tolerance_percent/100.){warnings.push("estimated differential impedance exceeds target tolerance".into());}+ json!({"name":p.name,"definition":p,"lengthMm":lengths,"skewMm":skew,"gapMm":range(&gaps),"singleEndedOhms":if valid{range(&z0s)}else{Value::Null},"differentialOhms":if valid{range(&zdiffs)}else{Value::Null},"status":if !valid{"unsupported-or-incomplete"}else if warnings.is_empty(){"screen-within-target"}else{"screen-outside-target"},"warnings":warnings})+ }).collect();+ json!({"capacitance":caps,"differentialPairs":pairs,"method":"Screening estimates, not signal-integrity signoff. Outer-layer equal-width microstrip only; sampled reference coverage and parallel partner gaps. No solder mask, pads, packages, loss, dispersion, crosstalk, S-parameters or eye diagrams.","sources":["https://www.ti.com/lit/an/slva531a/slva531a.pdf","https://www.ti.com/seclit/eb/slyw038c/slyw038c.pdf"],"sampleMm":r.res})+}+#[cfg(test)]mod tests{+ use super::*;+ #[test]fn formula_reference_and_limits(){let z=microstrip(0.3,0.2,0.035,4.2).unwrap();assert!((z-53.966).abs()<0.1);assert!(microstrip(4.,0.2,0.035,4.2).is_none());}+ #[test]fn disconnected_lengths_refused(){let t=|a,b|Track{net:"p".into(),layer:"F.Cu".into(),a,b,w:0.3};let a=t((0.,0.),(1.,0.));let b=t((2.,0.),(3.,0.));assert!(!chain(&[&a,&b]));}+}++#[cfg(test)] mod integration_tests {+ use super::*;+ #[test] fn actual_overlap_and_pair_geometry_then_plane_gap_refusal() {+ let mut b=Board{x0:0.,y0:0.,x1:12.,y1:12.,..Board::default()};+ for (net,y) in [("P",5.),("N",5.5)] {b.tracks.push(Track{net:net.into(),layer:"F.Cu".into(),a:(2.,y),b:(10.,y),w:0.3});}+ let poly=|net:&str,layer:&str|crate::board::Poly{net:net.into(),layer:layer.into(),pts:vec![(1.,1.),(11.,1.),(11.,11.),(1.,11.)]};+ b.polys.push(poly("GND","In1.Cu"));b.polys.push(poly("POWER","In2.Cu"));+ let mut r=Raster::new(&b,0.05);r.draw_board(&b);+ let c:Config=serde_json::from_value(json!({"capacitance":[{"name":"planes","netA":"GND","layerA":"In1.Cu","netB":"POWER","layerB":"In2.Cu","dielectricMm":0.2,"epsilonR":4}],"differentialPairs":[{"name":"pair","positive":"P","negative":"N","layer":"F.Cu","referenceNet":"GND","referenceLayer":"In1.Cu","dielectricMm":0.2,"epsilonR":4.2,"maxSkewMm":0.1,"targetOhms":90,"tolerancePercent":20}]})).unwrap();+ c.validate().unwrap();let a=analyze(&b,&r,&c,&BTreeMap::new(),35.);+ assert!((a["capacitance"][0]["capacitancePf"].as_f64().unwrap()-17.7083756256).abs()<1e-8);+ assert_eq!(a["differentialPairs"][0]["status"],"screen-within-target");+ assert_eq!(a["differentialPairs"][0]["skewMm"],0.0);+ r.masks.remove(&("GND".into(),"In1.Cu".into()));let a=analyze(&b,&r,&c,&BTreeMap::new(),35.);+ assert!(a["differentialPairs"][0]["differentialOhms"].is_null());+ assert_eq!(a["capacitance"][0]["status"],"missing-copper");+ }+}
src/solve.rs+9−4@@ -2,13 +2,14 @@ //! Unknowns: one value per cell per layer. Edges: right and down neighbours on a layer (gx, gy), //! the cell below on the next layer (gz), and a leak from every cell to ground. -pub struct System { pub nl: usize, pub h: usize, pub w: usize, pub gx: Vec<Vec<f64>>, pub gy: Vec<Vec<f64>>, pub gz: Vec<Vec<f64>>, pub leak: Vec<Vec<f64>> }+pub struct System { pub nl: usize, pub h: usize, pub w: usize, pub gx: Vec<Vec<f64>>, pub gy: Vec<Vec<f64>>, pub gz: Vec<Vec<f64>>, pub leak: Vec<Vec<f64>>, pub extra_leak: Vec<f64>, pub edges: Vec<(usize, usize, f64)> } impl System { pub fn new(nl: usize, h: usize, w: usize) -> System {- System { nl, h, w, gx: vec![vec![0.0; h * (w - 1)]; nl], gy: vec![vec![0.0; (h - 1) * w]; nl], gz: vec![vec![0.0; h * w]; nl.saturating_sub(1)], leak: vec![vec![0.0; h * w]; nl] }+ System { nl, h, w, extra_leak: Vec::new(), edges: Vec::new(), gx: vec![vec![0.0; h * (w - 1)]; nl], gy: vec![vec![0.0; (h - 1) * w]; nl], gz: vec![vec![0.0; h * w]; nl.saturating_sub(1)], leak: vec![vec![0.0; h * w]; nl] } }- pub fn n(&self) -> usize { self.nl * self.h * self.w }+ pub fn n(&self) -> usize { self.nl * self.h * self.w + self.extra_leak.len() }+ pub fn add_node(&mut self, leak: f64) -> usize { let i = self.n(); self.extra_leak.push(leak); i } fn matvec(&self, x: &[f64], y: &mut [f64]) { let (h, w) = (self.h, self.w); let n = h * w; for k in 0..self.nl {@@ -23,6 +24,8 @@ impl System { let gz = &self.gz[k]; for i in 0..n { let g = gz[i]; if g != 0.0 { let (a, b) = (k * n + i, (k + 1) * n + i); let d = g * (x[a] - x[b]); y[a] += d; y[b] -= d; } } }+ for (i, g) in self.extra_leak.iter().enumerate() { let a = self.nl * n + i; y[a] = g * x[a]; }+ for &(a,b,g) in &self.edges { let q = g * (x[a]-x[b]); y[a] += q; y[b] -= q; } } fn diagonal(&self) -> Vec<f64> { let (h, w) = (self.h, self.w); let n = h * w;@@ -33,6 +36,8 @@ impl System { for r in 0..h - 1 { for c in 0..w { let g = self.gy[k][r * w + c]; d[k * n + r * w + c] += g; d[k * n + (r + 1) * w + c] += g; } } } for k in 0..self.nl.saturating_sub(1) { for i in 0..n { let g = self.gz[k][i]; d[k * n + i] += g; d[(k + 1) * n + i] += g; } }+ for (i,g) in self.extra_leak.iter().enumerate() { d[self.nl*n+i] += g; }+ for &(a,b,g) in &self.edges { d[a] += g; d[b] += g; } d } /// Solve A x = b to a relative residual of `tol` (Jacobi-preconditioned CG). Returns (x, iterations, residual).@@ -47,7 +52,7 @@ impl System { let mut ap = vec![0.0; n]; let bnorm = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30); let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();- let mut it = 0; let mut res = 1.0;+ let mut it = 0; let mut res = if b.iter().all(|v| *v == 0.0) { 0.0 } else { 1.0 }; while it < max_iter { self.matvec(&p, &mut ap); let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
src/ui/app.js+32−2@@ -37,6 +37,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;+ 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;+ for (let k=1;k<LAYERS.length;k++) Z[LAYERS[k]]=Z[LAYERS[k-1]]-dielectric[k-1]-(cu[LAYERS[k-1]]+cu[LAYERS[k]])/2000;+ const W = board.widthMm, H = board.heightMm, T = board.thicknessMm; root = new BAB.TransformNode('fields-board', scene); const box = BAB.MeshBuilder.CreateBox('fr4_board', { width: W, height: H, depth: T }, scene);@@ -48,6 +53,14 @@ plane.material = m; plane.isPickable = L === 'F.Cu' || L === 'B.Cu'; plane.parent = root; state.layerMeshes[L] = plane; }+ for (const hs of f.heatsinks || []) {+ const b = hs.definition, [x0,y0,x1,y1] = b.boundsMm;+ const mesh = BAB.MeshBuilder.CreateBox('heatsink-' + b.name, {width:x1-x0,height:y1-y0,depth:b.thicknessMm},scene);+ mesh.position = boardSpace((x0+x1)/2,(y0+y1)/2,(b.layer === 'F.Cu' ? 1 : -1)*(T/2+b.thicknessMm/2+0.01));+ const mat = new BAB.PBRMaterial('heatsink-material-' + b.name,scene);+ mat.albedoColor = new BAB.Color3(0.55,0.60,0.64); mat.metallic=0.85; mat.roughness=0.45; mat.alpha=0.65;+ mesh.material=mat; mesh.parent=root; mesh.isPickable=false;+ } // the vias: one merged mesh (the viewer's shadow setup has nothing to say to a merged mesh) const vm = new BAB.StandardMaterial('vm', scene); vm.emissiveColor = new BAB.Color3(0.5, 0.83, 0.94); vm.disableLighting = true; const cyls = f.vias.map(v => { const c = BAB.MeshBuilder.CreateCylinder('via', { diameter: v.size, height: T + 0.05, tessellation: 8 }, scene); c.rotation.x = Math.PI / 2; const p = boardSpace(v.x, v.y); c.position.set(p.x, p.y, 0); return c; });@@ -118,6 +131,7 @@ // textures function texName(L) { const q = state.view.quantity, ls = slug(L);+ if (q === 'signals') return `copper-${ls}.png`; if (q === 'current') return state.view.net ? `J-${slug(state.view.net)}-${ls}.png` : `J-${ls}.png`; if (q === 'heat') { const iso = state.view.net && (state.fields.pourNets || []).some(p => p.net === state.view.net); return iso ? `q-${slug(state.view.net)}-${ls}.png` : `q-${ls}.png`; } return (state.view.air === 'moving' ? 'Tm-' : 'T-') + ls + '.png';@@ -132,7 +146,10 @@ if (L === 'In1.Cu' || L === 'In2.Cu') m.alpha = (L === selected) ? 1.0 : 0.0; } if (viaRoot) viaRoot.setEnabled(state.view.quantity !== 'temperature');- for (const { el, c } of labels) { el.hidden = state.view.quantity === 'current'; el.textContent = c.reference + ' +' + (state.view.air === 'moving' ? c.riseMovingC : c.riseC).toFixed(0) + ' °C'; }+ for (const { el, c } of labels) { el.hidden = ['current','signals'].includes(state.view.quantity); el.textContent = c.reference + ' +' + (state.view.air === 'moving' ? c.riseMovingC : c.riseC).toFixed(0) + ' °C'; }+ $('#signalsPanel').hidden = state.view.quantity !== 'signals';+ $('#legend').hidden = state.view.quantity === 'signals';+ $('#readout').hidden = state.view.quantity === 'signals'; const sc = state.fields.scales, leg = $('#legend'); leg.classList.toggle('magma', state.view.quantity === 'temperature'); if (state.view.quantity === 'current') { $('#legendTitle').textContent = 'Current density, A/mm² (log)'; $('#legLo').textContent = (sc.jHi / 300).toFixed(1); $('#legHi').textContent = sc.jHi.toFixed(0); } else if (state.view.quantity === 'heat') { $('#legendTitle').textContent = 'Heat flow in the copper, W/mm (log)'; $('#legLo').textContent = (sc.qHi / 300).toExponential(1); $('#legHi').textContent = sc.qHi.toFixed(3); }@@ -170,7 +187,20 @@ 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>';- $('#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 issues found: every loaded net stays under 60 A/mm² and every hot tab within its budget.</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);};+ for (const c of signals.capacitance || []) addSignal(`${c.name}: ${c.capacitancePf.toFixed(2)} pF, ${c.overlapMm2.toFixed(2)} mm² overlap (${c.status}).`);+ for (const p of signals.differentialPairs || []) addSignal(`${p.name}: lengths ${p.lengthMm.map(x=>x.toFixed(3)).join(' / ')} mm; skew ${p.skewMm.toFixed(3)} mm. Differential ${p.differentialOhms ? p.differentialOhms.map(x=>x.toFixed(1)).join(' to ')+' Ω' : 'not estimated'}. ${p.status}. ${p.warnings.join('; ')}`);+ 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 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).`; } async function setView(patch) { const r = await post('ui/view', patch); if (!r) toast('The server did not answer', 'error', true); }
src/ui/index.html+3−1@@ -93,7 +93,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", </header> <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="temperature" data-tip="Temperature rise over the board, the FR4 spreading">Temperature</button></div></div>+ <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" 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>@@ -107,7 +107,9 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", <div class="legend" id="legend"><div id="legendTitle">Current, A/mm²</div><div class="ramp"></div><div class="ends"><span id="legLo">0</span><span id="legHi">0</span></div></div> </main> <aside class="side right" id="right">+ <div class="sec" id="signalsPanel" hidden><h3>Signals</h3><div id="signals"></div></div> <div class="sec"><h3>Issues</h3><div id="issues"></div></div>+ <div class="sec"><h3>Conducting heatsinks</h3><div id="heatsinks"></div></div> <div class="sec"><h3>How it was solved</h3><div class="empty" id="method"></div></div> </aside> </div>