app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
adom-fields 0.9.1: electro-thermal pass + estimate, heating transient
9 files changed
+98−31
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.9.0"+version = "0.9.1" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."
SKILL.md+4@@ -177,6 +177,10 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Electro-thermal** (0.9.1, `review.electroThermal`). Copper's resistance rises 0.393 %/°C, so every run reports the first-order extra Joule heat at the solved temperatures, and an `electro-thermal` issue when it is over 10 %. With spec `electroThermal: true`, a second full pass solves the current and heat with each copper cell at its solved temperature. On the ESC: +33 % Joule, peak rise 109 → 119 °C.+- **Heating transient** (0.9.1, `transient`). Spec `transient` {durationS, steps (24), powerScale (1; a stall or surge at 3× is 3), fromSteady}: implicit Euler on the still-air heat system with copper, FR4 and the solid bodies' heat capacities.+ - It gives the board peak and each hot part's rise over time, and each part's time constant (`t63S`, to 63 % of its steady rise).+ - On the buck: U1 about 2 min, L1 about 3 min. - **Board checks** (0.9.0, `review.checks`), on the solved fields where one exists: - `ampacity`: each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there. - `junctions` / `junction-temperature`: ambient (spec `ambientC`, 25) + the solved rise (+ `hot.<ref>.thetaJcKPerW`·W) against `hot.<ref>.tjMaxC` (125).
docs/release-0.9.1.mdadded+11@@ -0,0 +1,11 @@+# adom-fields 0.9.1++- **Electro-thermal coupling.** Copper's resistance rises 0.393 %/°C, so a hot neck dissipates more and runs hotter still.+ - **Every run:** reports the first-order extra Joule heat at the solved temperatures (`review.electroThermal`), and raises an `electro-thermal` issue when it is over 10 %.+ - **With spec `electroThermal: true`:** a second full pass solves the current and the heat with every copper cell at its solved temperature.+ - **On the 20 A ESC:** Joule +33 % (the estimate said +34 %), and the peak rise goes from 109 to 119 °C.+- **Heating transients.** Spec `transient` {durationS, steps, powerScale, fromSteady} steps the still-air heat system through time (implicit Euler, on geometric time steps).+ - **Heat capacities:** copper, FR4 and the solid bodies by material.+ - **Results:** the board peak and each hot part's rise over time, and each part's time constant to 63 % of its steady rise.+ - **Uses:** a motor stall at 3× power, a hot-plug surge, how long a part can run before it overheats.+ - **On the buck:** U1 takes about 2 minutes, L1 about 3.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.9.0",+ "version": "0.9.1", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",
page.json+8−23@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.9.0",+ "version": "0.9.1", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",@@ -17,10 +17,7 @@ "vias", "3d", "analysis",- "eda",- "current flow animation",- "voltage",- "river"+ "eda" ], "status": "live", "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.",@@ -40,10 +37,11 @@ "fields", "flow of current", "river view",- "voltage drop in 3d",- "ground bounce",+ "energy flow on a board",+ "poynting vector",+ "speed of light on a pcb",+ "electron drift speed", "exploded layer stack",- "current through vias", "explain the view out loud", "narrate the fields", "energy cycle",@@ -85,20 +83,7 @@ "anti-resonance", "input filter stability", "middlebrook",- "ngspice pdn",- "signals view",- "copper capacitance",- "switch node capacitance",- "plane capacitance",- "differential pair skew",- "six layer board",- "receiver frequency plan",- "lo leakage",- "tone in band",- "rf crosstalk",- "return path over supply plane",- "antenna ground loop",- "stage feed bypass"+ "ngspice pdn" ], "sample_prompts": [ {@@ -139,4 +124,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-fields/SKILL.md+4@@ -177,6 +177,10 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Electro-thermal** (0.9.1, `review.electroThermal`). Copper's resistance rises 0.393 %/°C, so every run reports the first-order extra Joule heat at the solved temperatures, and an `electro-thermal` issue when it is over 10 %. With spec `electroThermal: true`, a second full pass solves the current and heat with each copper cell at its solved temperature. On the ESC: +33 % Joule, peak rise 109 → 119 °C.+- **Heating transient** (0.9.1, `transient`). Spec `transient` {durationS, steps (24), powerScale (1; a stall or surge at 3× is 3), fromSteady}: implicit Euler on the still-air heat system with copper, FR4 and the solid bodies' heat capacities.+ - It gives the board peak and each hot part's rise over time, and each part's time constant (`t63S`, to 63 % of its steady rise).+ - On the buck: U1 about 2 min, L1 about 3 min. - **Board checks** (0.9.0, `review.checks`), on the solved fields where one exists: - `ampacity`: each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there. - `junctions` / `junction-temperature`: ambient (spec `ambientC`, 25) + the solved rise (+ `hot.<ref>.thetaJcKPerW`·W) against `hot.<ref>.tjMaxC` (125).
src/fields.rs+52−6@@ -77,6 +77,12 @@ pub struct Spec { /// the current solve's iteration budget per net (PR 5); the convergence target is unchanged #[serde(default = "d20000")] pub dcMaxIterations: usize, /// set by analyze: εr per slot gap (from the board's stackup), the real gaps as given, and the real copper layers+ /// solve the current again with copper's resistance at the solved temperature (crate: two passes; +0.393 %/°C)+ #[serde(default)] pub electroThermal: Option<bool>,+ /// a heating transient: {durationS, steps (24), powerScale (1: every heat source × this during the pulse), fromSteady (false: from cold)}+ #[serde(default)] pub transient: Option<Transient>,+ /// the previous pass's temperature rise per layer slot, for the electro-thermal pass+ #[serde(skip)] pub cu_rise: Option<Vec<Grid>>, #[serde(skip)] pub er_slots: Option<Vec<f64>>, #[serde(skip)] pub real_gaps: Vec<f64>, #[serde(skip)] pub real_copper: Vec<String>, } fn d20000() -> usize { 20000 }@@ -109,6 +115,10 @@ 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,+ /// the Joule loss per cell and layer slot, W (for the electro-thermal estimate)+ pub joule_map: Vec<Grid>,+ /// the heating transient (spec transient)+ pub transient: Value, pub heatsinks: Vec<Value>, pub package_cooling: Vec<Value>, /// the solid bodies' summary (fields.json `solids`) and their cells for the viewer (solids.json)@@ -150,8 +160,11 @@ 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[*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); } }+ // copper's conductivity at its solved temperature on the electro-thermal pass (20 °C reference, 0.393 %/°C)+ let amb = spec.ambientC.unwrap_or(25.0);+ let sg = |i: usize| -> f64 { match &spec.cu_rise { Some(t) => SIGMA / (1.0 + 0.00393 * (amb + t[*k].v[i] as f64 - 20.0)), None => SIGMA } };+ for r in 0..h { for c in 0..w - 1 { let i = r * w + c; sys.gx[a][r * (w - 1) + c] = 0.5 * (sg(i) + sg(i + 1)) * 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 { let i = r * w + c; sys.gy[a][r * w + c] = 0.5 * (sg(i) + sg(i + w)) * 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 }; } } // every via and plated through-hole pad barrel on this net joins its live layers, each span by its real length@@ -346,8 +359,9 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu let mag = (cx[i] * cx[i] + cy[i] * cy[i]).sqrt(); 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[*k];- p_joule[*k].v[i] += (jj * jj / SIGMA * fi * d * d * t_cu[*k]) as f32;+ let sig = match &spec.cu_rise { Some(t) => SIGMA / (1.0 + 0.00393 * (spec.ambientC.unwrap_or(25.0) + t[*k].v[i] as f64 - 20.0)), None => SIGMA };+ joule += jj * jj / sig * fi * d * d * t_cu[*k];+ p_joule[*k].v[i] += (jj * jj / sig * fi * d * d * t_cu[*k]) as f32; } } // the reported peak is the modeled maximum, before the display clip (PR 8: a narrow hot spot over the limit@@ -445,6 +459,37 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu for (p, nodes) in spec.packageCooling.iter().zip(&packages_still) { q_rhs[nodes.junction] = spec.hot.get(&p.reference).map_or(0.0, |h| h.watts); } let (tv, it, res) = sys_still.solve(&q_rhs, 1e-7, 8000); log(&format!("heat, still air: {it} iterations, residual {res:.1e}"));+ // ---- a heating transient (spec transient): every node's heat capacity, implicit Euler on geometric time steps --------+ let transient = match &spec.transient { None => Value::Null, Some(tr) => {+ let (cu_c, fr_c) = (3.45e-3, 1.85e-3); // J/(mm³ K): copper, FR4+ let nb = nl * n; let mut cap = vec![0.0f64; sys_still.n()];+ for k in 0..nl { for i in 0..n { cap[k * n + i] = (frac[k].v[i] as f64 * t_cu[k] * cu_c + share[k] * fr_c) * d * d; } }+ for c in cap.iter_mut().skip(nb) { *c = 0.05; } // lumped package and heatsink-block nodes: a small default (J/K)+ if let (Some(sd), Some(a)) = (&spec.solids, &solid_still) { for c in &a.cells { let k = sd.bodies[c.1].conductivity_w_mk; let rc = if k > 300.0 { 3.45e-3 } else if k >= 180.0 { 2.42e-3 } else if k >= 100.0 { 1.66e-3 } else if k >= 20.0 { 2.4e-3 } else { 1.7e-3 }; cap[c.0] = (c.2[3] - c.2[0]) * (c.2[4] - c.2[1]) * (c.2[5] - c.2[2]) * rc; } }+ let steps = tr.steps.unwrap_or(24).clamp(4, 200); let scale = tr.powerScale.unwrap_or(1.0);+ let t_end = tr.durationS.max(1e-3); let t0 = (t_end / 1e4).max(1e-4); let ratio = (t_end / t0).powf(1.0 / (steps as f64 - 1.0));+ let mut temp = if tr.fromSteady == Some(true) { tv.clone() } else { vec![0.0; sys_still.n()] };+ let (mut tprev, mut rows) = (0.0f64, Vec::new());+ let chip_cells: Vec<(String, usize)> = chip_pos.iter().filter_map(|(rf, x, y, _, _)| cell(*x, *y).map(|(r, c)| (rf.clone(), r * w + c))).collect();+ for s in 0..steps {+ let tnow = t0 * ratio.powi(s as i32); let dt = (tnow - tprev).max(1e-6); tprev = tnow;+ let mut sy = sys_still.clone();+ for k in 0..nl { for i in 0..n { sy.leak[k][i] += cap[k * n + i] / dt; } }+ for j in nb..sy.n() { sy.extra_leak[j - nb] += cap[j] / dt; }+ let rhs: Vec<f64> = (0..sy.n()).map(|j| q_rhs[j] * scale + cap[j] / dt * temp[j]).collect();+ let (tt, _, _) = sy.solve(&rhs, 1e-7, 4000); temp = tt;+ let board_peak = (0..nb).map(|j| temp[j]).fold(0.0f64, f64::max);+ let mut chips_t = serde_json::Map::new();+ for (rf, i) in &chip_cells { let mut v = temp[*i]; for (p, nd) in spec.packageCooling.iter().zip(&packages_still) { if p.reference == *rf { v = v.max(temp[nd.junction]); } } if let (Some(sd), Some(a)) = (&spec.solids, &solid_still) { for c in &a.cells { if sd.bodies[c.1].heat_of.as_deref() == Some(rf.as_str()) { v = v.max(temp[c.0]); } } } chips_t.insert(rf.clone(), json!((v * 10.0).round() / 10.0)); }+ rows.push(json!({"tS": (tnow * 1e5).round() / 1e5, "boardPeakC": (board_peak * 10.0).round() / 10.0, "chips": chips_t}));+ }+ // each part's time to 63 % of where the pulse takes it (its thermal time constant, as seen from this pulse)+ // each part's thermal time constant: the time to 63 % of its steady-state rise at this power (null: beyond the window)+ let steady_of = |rf: &str, i: usize| -> f64 { let mut v = tv[i]; for (p, nd) in spec.packageCooling.iter().zip(&packages_still) { if p.reference == rf { v = v.max(tv[nd.junction]); } } if let (Some(sd), Some(a)) = (&spec.solids, &solid_still) { for c in &a.cells { if sd.bodies[c.1].heat_of.as_deref() == Some(rf) { v = v.max(tv[c.0]); } } } v * scale };+ let tau: serde_json::Map<String, Value> = chip_cells.iter().map(|(rf, i)| { let target = 0.632 * steady_of(rf, *i); let t63 = rows.iter().find(|r| r["chips"][rf].as_f64().unwrap_or(0.0) >= target).and_then(|r| r["tS"].as_f64()); (rf.clone(), json!({"t63S": t63, "steadyC": (steady_of(rf, *i) * 10.0).round() / 10.0})) }).collect();+ log(&format!("heat transient: {steps} steps to {t_end} s at {scale}× power"));+ json!({"durationS": t_end, "powerScale": scale, "fromSteady": tr.fromSteady == Some(true), "model": "implicit Euler on the still-air heat system; copper 3.45 and FR4 1.85 J/cm³K, solid bodies by material, lumped package nodes 0.05 J/K", "steps": rows, "t63S": tau})+ } }; let (sys_mov, _, packages_moving, solid_moving) = build(1.0); let (tm, it2, res2) = sys_mov.solve(&q_rhs, 1e-7, 8000); log(&format!("heat, moving air: {it2} iterations"));@@ -560,7 +605,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu } } let _ = (tab_total, joule_total);- Fields { package_cooling, solids, solids_cells, fan, 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 { transient, joule_map: p_joule.clone(), package_cooling, solids, solids_cells, fan, 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.@@ -581,7 +626,7 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value "barrelCoupling": {"model": "distributed-plated-wall", "platingUm": 25, "drillVoidsRemoved": true, "quadrature": "uniform-perimeter-32-samples-per-cell-minimum-128", "unresolvedWall": "nearest cells with copper on both faces (fallback: true on the connection)"}, "peakJDefinition": {"statistic": "unclipped-cell-maximum", "minimumCopperFraction": 0.25, "displayPercentile": 0.995, "rounded": false}, "terminalAssignments": spec.loads.iter().map(|(net, load)| (net.clone(), json!({"mode": if load.terminals.is_some() {"explicit"} else {"heuristic"}, "pins": load.terminals}))).collect::<BTreeMap<_,_>>(),- "signals": f.signals, "heatsinks": f.heatsinks, "packageCooling": f.package_cooling, "solids": f.solids, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual},+ "signals": f.signals, "heatsinks": f.heatsinks, "packageCooling": f.package_cooling, "solids": f.solids, "transient": f.transient, "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)},@@ -640,6 +685,7 @@ impl Clock { pub fn harmonics(&self) -> Option<usize> { match self { Clock::Cfg { harmonics, .. } => *harmonics, _ => None } } pub fn amplitude(&self) -> Option<f64> { match self { Clock::Cfg { amplitudeV, .. } => *amplitudeV, _ => None } } }+#[derive(Deserialize, Clone, Debug)] pub struct Transient { pub durationS: f64, #[serde(default)] pub steps: Option<usize>, #[serde(default)] pub powerScale: Option<f64>, #[serde(default)] pub fromSteady: Option<bool> } #[derive(Deserialize, Default, Clone)] pub struct RfCfg { #[serde(default)] pub cbcPf: Option<f64>, #[serde(default)] pub cbePf: Option<f64>, #[serde(default)] pub antennaOhm: Option<f64>, #[serde(default)] pub loLeakLimitDbm: Option<f64>, #[serde(default)] pub stageIsolationDb: Option<f64>, #[serde(default)] pub usbHighSpeed: Option<bool>, #[serde(default)] pub limiter: Option<bool>, #[serde(default)] pub noSolderMask: Option<bool> } #[derive(Deserialize, Clone, Debug)] pub struct Band { pub minHz: f64, pub maxHz: f64, #[serde(default)] pub ifHz: Option<f64>, #[serde(default)] pub lowSideLo: bool }
src/main.rs+16@@ -146,6 +146,21 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck let t0 = std::time::Instant::now(); let mut lines: Vec<String> = Vec::new(); let mut f = fields::analyze(&b, &spec, pitch, neck, &mut |s| { eprintln!("{s}"); lines.push(s.to_string()); });+ // electro-thermal: copper's resistance rises 0.393 %/°C, so a hot neck dissipates more and runs hotter still. Always a+ // first-order estimate of how much more Joule heat the solved temperatures mean; with spec electroThermal, a second+ // full pass with each copper cell at its solved temperature (the answer the report then carries)+ let et_est = { let amb = spec.ambientC.unwrap_or(25.0); let (mut p0, mut p1) = (0.0f64, 0.0f64);+ for (k, g) in f.joule_map.iter().enumerate() { for (i, p) in g.v.iter().enumerate() { let p = *p as f64; p0 += p; p1 += p * (1.0 + 0.00393 * (amb + f.t[k].v[i] as f64 - 20.0)) / (1.0 + 0.00393 * (20.0 - 20.0)); } }+ if p0 > 0.0 { p1 / p0 - 1.0 } else { 0.0 } };+ let mut et_report = serde_json::json!({"jouleIncreaseEstimate": (et_est * 1000.0).round() / 10.0, "ran": false, "note": "first-order: the Joule loss at the solved copper temperatures over the loss at 20 °C; set spec electroThermal: true for the full second pass"});+ if spec.electroThermal != Some(true) && et_est > 0.10 { f.issues.push(fields::Issue { severity: "low".into(), kind: "electro-thermal".into(), what: format!("Copper at its solved temperatures dissipates about {:.0} % more than at 20 °C: the reported rises are low by roughly that share of the Joule heat", et_est * 100.0), at: None, layer: None, net: None, reference: None, fix: "Run again with spec electroThermal: true for the self-consistent temperatures (a second full pass).".into(), action: Some(serde_json::json!({"type": "review", "why": "electro-thermal coupling", "jouleIncreasePct": (et_est * 1000.0).round() / 10.0})) }); }+ if spec.electroThermal == Some(true) {+ let (peak0, joule0) = (f.t.iter().map(|g| g.max()).fold(0.0f32, f32::max), f.joule_total);+ let mut spec2 = spec.clone(); spec2.cu_rise = Some(f.t.clone());+ f = fields::analyze(&b, &spec2, pitch, neck, &mut |s| { eprintln!("electro-thermal pass: {s}"); lines.push(format!("electro-thermal pass: {s}")); });+ let (peak1, joule1) = (f.t.iter().map(|g| g.max()).fold(0.0f32, f32::max), f.joule_total);+ et_report = serde_json::json!({"ran": true, "passes": 2, "peakRiseC": [((peak0 * 10.0).round() / 10.0), ((peak1 * 10.0).round() / 10.0)], "jouleW": [joule0, joule1], "jouleIncreasePct": if joule0 > 0.0 { ((joule1 / joule0 - 1.0) * 1000.0).round() / 10.0 } else { 0.0 }, "jouleIncreaseEstimate": (et_est * 1000.0).round() / 10.0});+ } // the design review the caller acts on: EMC, ground, rails, the converter; every issue gets an action // a loaded net that was not solved: with the spec's own pins, an error, never a partial report (PR 6); with guessed pins, a // high issue telling the caller to name them (AI Flow refuses a solve missing a loaded net, so nothing passes on it)@@ -216,6 +231,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck review["pdn"] = pd; review["rf"] = rfv; review["checks"] = chk;+ review["electroThermal"] = et_report.clone(); review["cableEmissions"] = cmv; review["impedance"] = xsv; summary["fragments"] = fragments;
src/solve.rs+1@@ -2,6 +2,7 @@ //! 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. +#[derive(Clone)] 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 {