← Commit history

adom-fields 0.7.9: power delivery. Each rail's impedance from its load pin, 1 kHz to 1 GHz, against V·tolerance/step; the capacitor that brings a peak under, chosen by re-solving; Middlebrook's input stability check; an ngspice cross-check of the same circuit; the Power view and its narrated video

John Lauer ·c6429ba2e0 · ·parent 1cf1a86
19 files changed +575−27
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.7.8"+version = "0.7.9" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.8"+version = "0.7.9" 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+9−1
@@ -129,9 +129,17 @@ Where switching noise can ring and spread: each power pour over the ground plane  [Download the noise video](docs/videos/noise.mp4) +### Power++How stiff each rail is, from slow changes to fast ones: each rail's impedance seen from its load pin, 1 kHz to 1 GHz, against its target, while the frequency sweeps. Each part's column is its share of the load's current at that frequency (the regulator or the supply leads at the low end, the bulk capacitors, then the small ones nearest the pin); the glass sleeve at the load pin is the target. ngspice runs the same circuit as a cross-check.++<video controls preload="metadata" width="1080" poster="docs/videos/pdn-poster.jpg" src="docs/videos/pdn.mp4"></video>++[Download the power video](docs/videos/pdn.mp4)+ ## What to fix: the review for the AI -Every solve now ends with a design review an AI flow can act on without looking at a screen: each finding (a current neck, a hot tab, the switching hot loop, copper with no ground under it, ground offset, islands, gaps in the stitching fence, IR drop, converter ripple) comes with a plain fix and an action to carry out (move this capacitor to these pins, add ground vias at these points, widen this net here), and `fixPlan` orders them. On the buck: the hot loop is fine (2.7 mm² against a best of 1.5), but only a third of the board edge is stitched, so the plan adds eight ground vias at the exact points it lists.+Every solve now ends with a design review an AI flow can act on without looking at a screen: each finding (a current neck, a hot tab, the switching hot loop, copper with no ground under it, ground offset, islands, gaps in the stitching fence, IR drop, converter ripple, a rail's impedance over its target) comes with a plain fix and an action to carry out (move this capacitor to these pins, add ground vias at these points, widen this net here), and `fixPlan` orders them. On the buck: the hot loop is fine (2.7 mm² against a best of 1.5), but only a third of the board edge is stitched, so the plan adds eight ground vias at the exact points it lists.  ## The app 
SKILL.md+11−1
@@ -54,7 +54,7 @@ Every camera move and the idle sway run at half the 0.4 speed (the sway period i ## Drive it (the AI's wheel)  ```-adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off]+adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise|pdn [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off] adom-fields ui select --net +VBAT        # one net lit alone (current) adom-fields ui select --chip Q3          # fly to a hot part, heat view adom-fields ui issue --index 0           # fly to an issue@@ -148,6 +148,16 @@ After applying a step, run `analyze` again: the issue goes away or says how far  `ui view --quantity noise`: the largest cavity rings in 3D (its real modes, red and blue phases, white nodal lines), the switching copper pulses, and quiet nets glow by the millivolts they pick up, with arcs at the closest approach. +## Power delivery: rail impedance (0.7.9)++`analyze` also checks each rail's impedance seen from its load pin (a pin of the chip with the most pins on the rail, else an outgoing connector; `pdn.<net>.load` names another), 1 kHz to 1 GHz. What feeds the rail comes from the spec's `sources`, the modelled converter, an on-board regulator (named like one: TLV7…, LDO, AMS1117…, bridging the rail and a higher one), or the connector the solve found the current entering by; a rail with none of these is listed `skipped` with the reason, never guessed:+- **The model**: branches in parallel at the load pin. The source: a modelled converter's output is flat (5 mΩ) below its loop crossover (fsw/10, `crossoverHz`), then an inductance that resonates with the output capacitors there, damped to Q 1 (about a 50° phase margin), fading out a decade above; a linear regulator the same with a 100 kHz crossover; a connector-fed rail is about a metre of supply leads (30 mΩ, 0.7 µH; `sourceOhm`, `sourceNh`). Each capacitor: its board value (nominal, no DC-bias derating), ESR by type, body inductance from its pad span, and the mounting inductance of a microstrip over the plane from it to the load pin. The rail's copper over ground as a plate capacitor.+- **The target**: V·tolPct/100 / stepA (5 %; the step is half the rail's spec load, or the converter's output current on its input rail), over a band: up to the switching edge's knee on a converter input, 100 MHz at a chip pin, 1 MHz at a connector (`fminHz`, `fmaxHz`, or `targetOhm` outright).+- **The fix**: a peak over the target is `pdn-impedance`, with an `add-part` capacitor (value, package, pin) chosen by re-solving with each candidate, up to three, each with the ratio it reaches; still over after three is a `pour` action (a plane pair). A converter's input rail also gets Middlebrook's check: under 10 dB below the converter's negative input resistance V²/P is `input-filter-stability`, with a damping capacitor and its ESR.+- `review.pdn` holds each rail's branches, |Z(f)| (`fHz` is shared), the target and band, the fixes and the curve after them; `pdn-<net>.cir` is the same circuit as an ngspice deck, run as a cross-check when ngspice is installed (`spice.maxErrPct`).++`ui view --quantity pdn` (the Power button): the frequency sweeps; each branch stands a column at its place, its height its share of the load's current (over 100 % is two branches ringing); arcs carry the shares to the load pin; at the load pin |Z| stands in a glass sleeve, the target (green under the rim, red over, steel blue outside the band); the panel draws |Z(f)| per rail with its target, band and fix curve, and a frequency slider.+ ## Honest limits (0.1)  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.
bin/adom-fields
⋯ 1 unchanged line ⋯
docs/release-0.7.9.mdadded+8
@@ -0,0 +1,8 @@+# adom-fields 0.7.9++- **Power delivery.** Each rail's impedance seen from its load pin, 1 kHz to 1 GHz: the regulator or the supply leads, every capacitor (value, ESR, body and mounting inductance) and the plane in parallel, against V·tolerance / step current over the rail's band. A peak over the target is `pdn-impedance`, with the capacitor (value, package, pin) that brings it under, chosen by re-solving; a converter's input rail also gets Middlebrook's stability check (`input-filter-stability`).+- **What feeds each rail** comes from the spec's sources, the modelled converter, an on-board regulator, or the connector the solve found the current entering by. A rail with none of these is listed as not checked, with the reason, rather than guessed.+- **ngspice cross-check.** Each rail is written as `pdn-<net>.cir` and run when ngspice is installed: on the buck it agrees within 0.02 % at all 241 points.+- **The Power view** sweeps the frequency: each part's share of the current as a column, the target as a glass sleeve at the load pin, |Z(f)| per rail in the panel. A narrated video on the page.+- Narration: an automatic first-run explanation no longer races a requested one (the subtitles stuck on the first sentence).+- Spec: `pdn.<net>` with `targetOhm`, `tolPct`, `stepA`, `fminHz`, `fmaxHz`, `sourceOhm`, `sourceNh`, `crossoverHz`, `load`.
docs/videos/pdn-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/pdn.mp4added
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.8",+  "version": "0.7.9",   "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+17−10
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.8",+  "version": "0.7.9",   "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",@@ -76,7 +74,16 @@     "plane resonance",     "cavity modes",     "crosstalk",-    "switching noise coupling"+    "switching noise coupling",+    "power delivery network",+    "pdn impedance",+    "rail impedance",+    "target impedance",+    "decoupling capacitor selection",+    "anti-resonance",+    "input filter stability",+    "middlebrook",+    "ngspice pdn"   ],   "sample_prompts": [     {@@ -117,4 +124,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
skills/adom-fields/SKILL.md+11−1
@@ -54,7 +54,7 @@ Every camera move and the idle sway run at half the 0.4 speed (the sway period i ## Drive it (the AI's wheel)  ```-adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off]+adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise|pdn [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off] adom-fields ui select --net +VBAT        # one net lit alone (current) adom-fields ui select --chip Q3          # fly to a hot part, heat view adom-fields ui issue --index 0           # fly to an issue@@ -148,6 +148,16 @@ After applying a step, run `analyze` again: the issue goes away or says how far  `ui view --quantity noise`: the largest cavity rings in 3D (its real modes, red and blue phases, white nodal lines), the switching copper pulses, and quiet nets glow by the millivolts they pick up, with arcs at the closest approach. +## Power delivery: rail impedance (0.7.9)++`analyze` also checks each rail's impedance seen from its load pin (a pin of the chip with the most pins on the rail, else an outgoing connector; `pdn.<net>.load` names another), 1 kHz to 1 GHz. What feeds the rail comes from the spec's `sources`, the modelled converter, an on-board regulator (named like one: TLV7…, LDO, AMS1117…, bridging the rail and a higher one), or the connector the solve found the current entering by; a rail with none of these is listed `skipped` with the reason, never guessed:+- **The model**: branches in parallel at the load pin. The source: a modelled converter's output is flat (5 mΩ) below its loop crossover (fsw/10, `crossoverHz`), then an inductance that resonates with the output capacitors there, damped to Q 1 (about a 50° phase margin), fading out a decade above; a linear regulator the same with a 100 kHz crossover; a connector-fed rail is about a metre of supply leads (30 mΩ, 0.7 µH; `sourceOhm`, `sourceNh`). Each capacitor: its board value (nominal, no DC-bias derating), ESR by type, body inductance from its pad span, and the mounting inductance of a microstrip over the plane from it to the load pin. The rail's copper over ground as a plate capacitor.+- **The target**: V·tolPct/100 / stepA (5 %; the step is half the rail's spec load, or the converter's output current on its input rail), over a band: up to the switching edge's knee on a converter input, 100 MHz at a chip pin, 1 MHz at a connector (`fminHz`, `fmaxHz`, or `targetOhm` outright).+- **The fix**: a peak over the target is `pdn-impedance`, with an `add-part` capacitor (value, package, pin) chosen by re-solving with each candidate, up to three, each with the ratio it reaches; still over after three is a `pour` action (a plane pair). A converter's input rail also gets Middlebrook's check: under 10 dB below the converter's negative input resistance V²/P is `input-filter-stability`, with a damping capacitor and its ESR.+- `review.pdn` holds each rail's branches, |Z(f)| (`fHz` is shared), the target and band, the fixes and the curve after them; `pdn-<net>.cir` is the same circuit as an ngspice deck, run as a cross-check when ngspice is installed (`spice.maxErrPct`).++`ui view --quantity pdn` (the Power button): the frequency sweeps; each branch stands a column at its place, its height its share of the load's current (over 100 % is two branches ringing); arcs carry the shares to the load pin; at the load pin |Z| stands in a glass sleeve, the target (green under the rim, red over, steel blue outside the band); the panel draws |Z(f)| per rail with its target, band and fix curve, and a frequency slider.+ ## Honest limits (0.1)  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.
src/emc.rs+1−1
@@ -9,7 +9,7 @@ use serde_json::{json, Value};  const MU: f64 = 1.0; fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }-fn parse_value(s: &str, unit: char) -> Option<f64> {+pub fn parse_value(s: &str, unit: char) -> Option<f64> {     for tok in s.replace(',', ".").split_whitespace() {         let t: Vec<char> = tok.chars().collect();         let mut i = 0; while i < t.len() && (t[i].is_ascii_digit() || t[i] == '.') { i += 1; }
src/fields.rs+2
@@ -35,6 +35,8 @@ pub struct Spec {     #[serde(default)] pub groundOffsetMv: Option<f64>, #[serde(default)] pub dropPct: Option<f64>, #[serde(default)] pub emcFmaxHz: Option<f64>,     /// named clocks (net: Hz), for the resonance and crosstalk checks; the coupled-noise budget on a quiet net (mV, 50)     #[serde(default)] pub clocks: BTreeMap<String, f64>, #[serde(default)] pub crosstalkMv: Option<f64>,+    /// the power-delivery check per rail: target impedance or tolerance and step, band, source, load pin+    #[serde(default)] pub pdn: BTreeMap<String, crate::pdn::RailCfg>, } fn d35() -> f64 { 35.0 } fn d60() -> f64 { 60.0 }
src/main.rs+4−1
@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod eagle; mod emc; mod flow; mod noise; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use std::path::PathBuf;@@ -96,6 +96,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     let mut iss = std::mem::take(&mut f.issues);     let mut review = emc::review(&f, &b, &spec, &mut iss);     let mut nz = noise::review(&f, &b, &spec, &mut iss);+    let pd = pdn::review(&f, &b, &spec, &review["converter"], &mut iss, Some(out.as_path()));     emc::act_on_core(&mut iss, &f);     f.issues = iss;     if !f.heat_residual.is_finite() || f.heat_residual>1e-6 || !f.heat_moving_residual.is_finite() || f.heat_moving_residual>1e-6 {return Err(format!("thermal solve did not converge: still {}, moving {}; refine the model or increase the solve budget",f.heat_residual,f.heat_moving_residual));}@@ -131,6 +132,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     // the cavities' mode shapes are for the app: their own file, so fields.json stays readable for an AI caller     if let Some(cav) = nz["cavities"].as_array_mut() { let shapes: Vec<Value> = cav.iter_mut().map(|c| json!({"net": c["net"].clone(), "grid": c["grid"].clone(), "shapes": c["shapes"].take()})).collect(); for c in cav.iter_mut() { c.as_object_mut().map(|o| o.remove("shapes")); } std::fs::write(out.join("noise.json"), serde_json::to_string(&json!({"cavities": shapes})).unwrap()).map_err(|e| e.to_string())?; }     review["noise"] = nz;+    review["pdn"] = pd;     summary["review"] = review;     summary["fixPlan"] = emc::fix_plan(&f.issues);     summary["flow"] = flow::write(out, &f, &b, &spec)?;@@ -162,6 +164,7 @@ fn main() {                         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(" | "),                         { let r = &s["review"]; let c = &r["converter"]; let g = &r["ground"]; format!("Review: {}{}{}.", if c.is_null() { "no switching converter modelled".to_string() } else { format!("hot loop {} {} mm² (best {} mm²), emissions margin {} dB at {} MHz (FCC B, loop only), output ripple {:.1} mV", c["hotLoop"]["cap"].as_str().unwrap_or("?"), c["hotLoop"]["mm2"], c["idealMm2"], c["emissions"]["worstMarginDb"], c["emissions"]["atMHz"], c["rippleOutMv"].as_f64().unwrap_or(0.0)) }, if g.is_null() { String::new() } else { format!("; ground offset {} mV, {} island(s), edge stitched {} %", g["offsetMv"], g["islands"].as_array().map_or(0, |x| x.len()), g["stitching"]["edgeCoveredPct"]) }, r["reference"].as_array().map(|a| a.iter().filter(|x| x["unreferencedPct"].as_f64().unwrap_or(0.0) > 5.0).map(|x| format!("; {} {}% without ground under it", x["net"].as_str().unwrap_or(""), x["unreferencedPct"])).collect::<String>()).unwrap_or_default()) },+                        { let all = s["review"]["pdn"]["rails"].as_array().cloned().unwrap_or_default(); let rs: Vec<Value> = all.iter().filter(|r| r["skipped"].is_null()).cloned().collect(); let sk: String = all.iter().filter(|r| !r["skipped"].is_null()).map(|r| format!("; {} not checked: {}", r["net"].as_str().unwrap_or(""), r["skipped"].as_str().unwrap_or(""))).collect(); if rs.is_empty() { format!("PDN: no rail checked{}.", sk) } else { format!("PDN (|Z| seen from each load pin against V·tol/step): {}{}.", rs.iter().map(|r| format!("{} {}× target at {} Hz (from {})", r["net"].as_str().unwrap_or(""), r["worst"]["ratio"], r["worst"]["hz"], r["load"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "), if rs.iter().all(|r| r["spice"]["ran"] == true) { format!("; ngspice agrees within {} %{}", rs.iter().map(|r| r["spice"]["maxErrPct"].as_f64().unwrap_or(0.0)).fold(0.0, f64::max), sk) } else { sk }) } },                         { let p = s["fixPlan"].as_array().cloned().unwrap_or_default(); if p.is_empty() { "Fix plan: nothing to change.".to_string() } else { format!("Fix plan ({} step(s), fields.json fixPlan has the actions): {}", p.len(), p.iter().take(5).map(|x| format!("{}) [{}] {}", x["step"], x["kind"].as_str().unwrap_or(""), x["do"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" ")) } },                         if s["parts"].is_null() { "Parts: skipped (--no-parts).".to_string() } else { format!("Parts: {} with 3D models, {} as boxes, {} bare{}.", s["parts"]["modelled"], s["parts"]["boxed"], s["parts"]["bare"], if s["parts"]["failed"].as_array().map_or(false, |a| !a.is_empty()) { "; some models did not convert (see the log above; --models-dir adds a folder to search)" } else { "" }) },                         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())),
src/pdn.rsadded+299
@@ -0,0 +1,299 @@+//! Power delivery: each rail's impedance seen from its load pin, 1 kHz to 1 GHz, against a target, with+//! the capacitors that would bring it under. The rail is a set of branches in parallel at the load pin:+//!  - the source: a regulator output (flat below its loop crossover, then an inductance that resonates with+//!    the output capacitors, damped to Q about 1, a phase margin near 50°), or a cable from a connector;+//!  - every capacitor from the rail to ground: its value from the board, ESR by type, its body inductance+//!    from its pad span, and the mounting inductance of the copper between it and the load pin (a+//!    microstrip over the ground plane, so it grows with distance);+//!  - the rail's own copper over the ground plane, a parallel-plate capacitor.+//! |Z(f)| is the exact parallel sum; the target is V·tolerance / step current. A peak over the target becomes+//! an issue whose action is the capacitor (value, package, pin) that brings it down, chosen by re-solving+//! with each candidate. The same branches are written as an ngspice netlist and, when ngspice is there,+//! run as a cross-check. A converter's input rail also gets Middlebrook's check: its input network has to+//! stay well under the converter's negative input resistance, V²/P, or the loop can oscillate.+use crate::board::{Board, Pad, LAYERS};+use crate::fields::{Fields, Issue, Spec};+use crate::flow::{is_ground, rail_from_name};+use serde_json::{json, Value};+use std::f64::consts::PI;++const E0: f64 = 8.854e-15; // F/mm+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }+fn sig(v: f64) -> f64 { if v == 0.0 || !v.is_finite() { return v; } let p = 10f64.powi(3 - v.abs().log10().floor() as i32); (v * p).round() / p }++#[derive(Clone, Copy)] struct Cx(f64, f64);+impl Cx {+    fn add(self, o: Cx) -> Cx { Cx(self.0 + o.0, self.1 + o.1) }+    fn mul(self, o: Cx) -> Cx { Cx(self.0 * o.0 - self.1 * o.1, self.0 * o.1 + self.1 * o.0) }+    fn inv(self) -> Cx { let d = self.0 * self.0 + self.1 * self.1; Cx(self.0 / d, -self.1 / d) }+    fn abs(self) -> f64 { self.0.hypot(self.1) }+}++/// One branch from the rail to ground: R, then L, then (Lp ∥ Rp), then C (0 = no such element).+#[derive(Clone)]+pub struct Branch { pub name: String, pub kind: &'static str, pub r: f64, pub l: f64, pub lp: f64, pub rp: f64, pub c: f64, pub at: Option<[f64; 2]>, pub extra: Value }+impl Branch {+    fn z(&self, w: f64) -> Cx {+        let mut z = Cx(self.r, w * self.l);+        if self.lp > 0.0 { let zl = Cx(0.0, w * self.lp); z = z.add(if self.rp > 0.0 { zl.mul(Cx(self.rp, 0.0)).mul(zl.add(Cx(self.rp, 0.0)).inv()) } else { zl }); }+        if self.c > 0.0 { z = z.add(Cx(0.0, -1.0 / (w * self.c))); }+        z+    }+}+fn zrail(bs: &[Branch], f: f64) -> Cx { let w = 2.0 * PI * f; bs.iter().fold(Cx(0.0, 0.0), |y, b| y.add(b.z(w).inv())).inv() }+pub fn freqs() -> Vec<f64> { (0..=240).map(|k| 10f64.powf(3.0 + k as f64 / 40.0)).collect() }++/// A microstrip's inductance per mm (Hammerstad), w wide at h over the plane: Z0·√εeff / c.+fn l_per_mm(w: f64, h: f64) -> f64 {+    let u = w / h; let er = 4.3;+    let ee = (er + 1.0) / 2.0 + (er - 1.0) / 2.0 / (1.0 + 12.0 / u).sqrt();+    let z0 = if u <= 1.0 { 60.0 / ee.sqrt() * (8.0 / u + u / 4.0).ln() } else { 120.0 * PI / (ee.sqrt() * (u + 1.393 + 0.667 * (u + 1.444).ln())) };+    z0 * ee.sqrt() / 2.99792458e8 * 1e-3+}+/// A capacitor's ESR by value and kind (MLCC unless the value says electrolytic, tantalum or polymer).+fn esr_of(c: f64, val: &str) -> (f64, &'static str) {+    let v = val.to_ascii_lowercase();+    if v.contains("poly") { return (0.015, "polymer"); }+    if v.contains("el") || v.contains("alu") { return (0.15, "electrolytic"); }+    if v.contains("tant") { return (0.1, "tantalum"); }+    (if c >= 10e-6 { 0.003 } else if c >= 1e-6 { 0.006 } else if c >= 100e-9 { 0.015 } else if c >= 10e-9 { 0.04 } else { 0.1 }, "ceramic")+}+fn fmt_c(c: f64) -> String { if c >= 1e-6 { format!("{}uF", sig(c * 1e6)) } else if c >= 1e-9 { format!("{}nF", sig(c * 1e9)) } else { format!("{}pF", sig(c * 1e12)) } }+fn fmt_hz(f: f64) -> String { if f >= 1e9 { format!("{:.2} GHz", f / 1e9) } else if f >= 1e6 { format!("{:.1} MHz", f / 1e6) } else { format!("{:.0} kHz", f / 1e3) } }+fn fmt_ohm(z: f64) -> String { if z >= 1.0 { format!("{:.2} Ω", z) } else { format!("{:.0} mΩ", z * 1e3) } }++#[derive(serde::Deserialize, Clone, Default)]+#[allow(non_snake_case)]+pub struct RailCfg {+    /// the target impedance (Ω); else V·tolPct/100 / stepA+    #[serde(default)] pub targetOhm: Option<f64>, #[serde(default)] pub tolPct: Option<f64>, #[serde(default)] pub stepA: Option<f64>,+    /// the band the target holds over (Hz)+    #[serde(default)] pub fminHz: Option<f64>, #[serde(default)] pub fmaxHz: Option<f64>,+    /// the source, when it is not a modelled converter: a cable's R (Ω) and L (nH); a regulator's loop crossover (Hz)+    #[serde(default)] pub sourceOhm: Option<f64>, #[serde(default)] pub sourceNh: Option<f64>, #[serde(default)] pub crossoverHz: Option<f64>,+    /// the load pin ("U3.7"), when the first chip pin on the rail is not it+    #[serde(default)] pub load: Option<String>,+}++pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec<Issue>, out_dir: Option<&std::path::Path>) -> Value {+    let rail = |n: &str| spec.voltages.get(n).copied().or_else(|| rail_from_name(n));+    let Some(gnd) = f.net_names.iter().find(|n| is_ground(n)).cloned() else { return json!({"rails": [], "note": "no ground net"}) };+    let refs: Vec<String> = { let mut v: Vec<String> = b.pads.iter().map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };+    let nets_of = |r: &str| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().filter(|p| p.reference == r).map(|p| p.net.clone()).collect(); v.sort(); v.dedup(); v };+    let pads_of = |r: &str, n: &str| -> Vec<&Pad> { b.pads.iter().filter(|p| p.reference == r && p.net == n).collect() };+    let n = f.w * f.h;+    let real: Vec<usize> = (0..LAYERS.len()).filter(|k| f.net_ids[*k].iter().filter(|x| **x != u16::MAX).count() > n * 15 / 100).collect();+    let h = { let t: f64 = if spec.stackupMm.len() == 3 { spec.stackupMm.iter().sum() } else { 1.465 }; if real.len() <= 2 { t } else { spec.stackupMm.first().copied().unwrap_or(0.2) } };+    let gid = f.net_names.iter().position(|x| *x == gnd).map(|i| i as u16);+    let fs = freqs();+    let mut rails = Vec::new();+    for (name, nid) in f.net_names.iter().enumerate().map(|(i, x)| (x.clone(), i as u16)) {+        let Some(v) = rail(&name) else { continue }; if v <= 0.0 || is_ground(&name) || spec.switching.contains_key(&name) { continue; }+        let cfg = spec.pdn.get(&name).cloned().unwrap_or_default();+        let is_out = conv["outNet"].as_str() == Some(&name); let is_in = conv["inNet"].as_str() == Some(&name);+        let srcs: Vec<String> = spec.sources.get(&name).cloned().unwrap_or_default();+        let caps: Vec<String> = refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&name) && nets_of(r).contains(&gnd)).cloned().collect();+        if caps.is_empty() && !is_out { continue; }+        // what feeds the rail: the spec's sources, the modelled converter, an on-board regulator (a part named+        // like one, bridging this rail, ground and a higher rail), or the connector the solve found the+        // current entering by; nothing else is guessed: a rail with no known source gets no verdict+        let bridges = |r: &str| { let ns = nets_of(r); ns.contains(&name) && ns.contains(&gnd) && ns.iter().any(|x| *x != name && rail(x).map_or(false, |vx| vx > v)) };+        const REG: [&str; 26] = ["ldo", "reg", "tlv7", "tps7", "lp59", "lp29", "lp38", "ap21", "ap7", "mcp17", "xc62", "ams1117", "lm1117", "ld11", "ld39", "ldl", "lm317", "lm78", "78l", "ncp1", "ht7", "rt9", "sgm2", "me62", "adp1", "max88"];+        let looks_reg = |r: &str| b.values.get(r).map_or(false, |v| { let l = v.to_ascii_lowercase(); REG.iter().any(|k| l.contains(k)) });+        let reg: Option<String> = if is_out { None } else { srcs.iter().find(|r| r.starts_with('U')).cloned().or_else(|| refs.iter().find(|r| r.starts_with('U') && bridges(r) && looks_reg(r)).cloned()) };+        let entry: Vec<String> = if !srcs.is_empty() { srcs.iter().filter(|r| r.starts_with('J') || r.starts_with('P')).cloned().collect() } else { f.flow.get(&name).map(|nf| { let mut v: Vec<String> = nf.terminals.iter().filter(|t| t.side == "ext" && (t.reference.starts_with('J') || t.reference.starts_with('P'))).map(|t| t.reference.clone()).collect(); v.dedup(); v }).unwrap_or_default() };+        if !is_out && reg.is_none() && entry.is_empty() && cfg.sourceOhm.is_none() {+            let maybe: Vec<String> = refs.iter().filter(|r| r.starts_with('U') && bridges(r) && b.pads.iter().filter(|p| p.reference == **r).count() <= 8).cloned().collect();+            rails.push(json!({"net": name, "railV": v, "skipped": format!("no known source{}: name the regulator or the connector in spec sources.{}, or give pdn.{}.sourceOhm and sourceNh", if maybe.is_empty() { String::new() } else { format!(" ({} bridges {} and a higher rail: if it is the regulator, say so)", maybe.join(", "), name) }, name, name)}));+            continue;+        }+        // the load pin: named, else the chip with the most pins on the rail, else a connector pin that is not the entry+        let skip = |r: &str| srcs.iter().any(|x| x == r) || reg.as_deref() == Some(r) || entry.iter().any(|x| x == r);+        let mut chips: Vec<(String, usize)> = refs.iter().filter(|r| r.starts_with('U') && !skip(r)).map(|r| (r.clone(), b.pads.iter().filter(|p| p.reference == *r && p.net == name).count())).filter(|x| x.1 > 0).collect();+        chips.sort_by(|a, z| z.1.cmp(&a.1));+        let conn_pins: Vec<&Pad> = b.pads.iter().filter(|p| p.net == name && (p.reference.starts_with('J') || (p.reference.starts_with('P') && !p.reference.starts_with("PS")))).collect();+        let load: Option<&Pad> = cfg.load.as_ref().and_then(|l| { let (r, p) = l.split_once('.')?; b.pads.iter().find(|x| x.reference == r && x.pad == p) })+            .or_else(|| chips.first().and_then(|c| b.pads.iter().find(|p| p.reference == c.0 && p.net == name)))+            .or_else(|| conn_pins.iter().find(|p| !entry.contains(&p.reference)).copied());+        let Some(load) = load else { rails.push(json!({"net": name, "railV": v, "skipped": "no load pin: no chip or outgoing connector on the rail (pdn.<net>.load names one)"})); continue };+        let at_chip = load.reference.starts_with('U');+        // the copper's own geometry: a pour is wider than a trace+        let k0 = real.first().copied().unwrap_or(0);+        let area_top = f.net_ids[k0].iter().filter(|x| **x == nid).count() as f64 * f.d * f.d;+        let wmm = if area_top > 15.0 { 2.0 } else { 0.8 };+        let lpm = l_per_mm(wmm, h);+        let mut bs: Vec<Branch> = Vec::new();+        let mut cap_total = 0.0;+        for r in &caps {+            let Some(val) = b.values.get(r) else { continue }; let Some(c) = crate::emc::parse_value(val, 'F') else { continue };+            let cv = pads_of(r, &name); let cg = pads_of(r, &gnd); let (Some(cv), Some(cg)) = (cv.first(), cg.first()) else { continue };+            let span = (cv.x - cg.x).hypot(cv.y - cg.y);+            let (esr, kind) = esr_of(c, val);+            let esl = (0.15 + 0.3 * span) * 1e-9;+            let dist = (cv.x - load.x).hypot(cv.y - load.y);+            let mount = (lpm * dist + 0.2e-9).max(0.2e-9);+            cap_total += c;+            bs.push(Branch { name: r.clone(), kind: "capacitor", r: esr, l: esl + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(cv.x), r2(cv.y)]), extra: json!({"value": val, "type": kind, "uF": sig(c * 1e6), "esrMohm": sig(esr * 1e3), "eslNh": sig(esl * 1e9), "mountNh": sig(mount * 1e9), "distMm": r2(dist), "srfHz": sig(1.0 / (2.0 * PI * (c * (esl + mount)).sqrt()))}) });+        }+        // the plane: this rail's copper facing ground on the next real layer+        let mut over = 0usize;+        if let Some(g) = gid { for w in real.windows(2) { for i in 0..n { let (a, z) = (f.net_ids[w[0]][i], f.net_ids[w[1]][i]); if (a == nid && z == g) || (a == g && z == nid) { over += 1; } } } }+        let gap = if real.len() <= 2 { h } else { spec.stackupMm.first().copied().unwrap_or(0.2) };+        let cplane = E0 * 4.3 * over as f64 * f.d * f.d / gap;+        if cplane > 0.0 { bs.push(Branch { name: "plane".into(), kind: "plane", r: 0.002, l: 0.0, lp: 0.0, rp: 0.0, c: cplane, at: Some([r2(load.x), r2(load.y)]), extra: json!({"pF": sig(cplane * 1e12), "mm2": r2(over as f64 * f.d * f.d), "gapMm": gap}) }); }+        // the source+        // a regulator loop: flat below its crossover, then L_eq resonating with the rail's capacitors there,+        // damped to Q 1; well above the crossover the loop gives up (a converter's output is its power+        // inductor), so a series L a decade above keeps the damping resistor from looking like a source+        let loop_branch = |nm: String, fc: f64, at: Option<[f64; 2]>, path: f64, what: &str| -> Branch {+            let ctot = (cap_total + cplane).max(1e-9); let wc = 2.0 * PI * fc;+            let leq = 1.0 / (wc * wc * ctot); let rd = (leq / ctot).sqrt(); let lhf = rd / (2.0 * PI * 10.0 * fc);+            Branch { name: nm, kind: "regulator", r: 0.005, l: path + lhf, lp: leq, rp: rd, c: 0.0, at, extra: json!({"crossoverHz": sig(fc), "lEqNh": sig(leq * 1e9), "dampOhm": sig(rd), "lHfNh": sig(lhf * 1e9), "rFlatMohm": 5.0, "model": format!("{what}: flat 5 mΩ, then L_eq resonating with the rail's capacitors at the loop crossover, damped to Q 1, fading out a decade above it (pdn.<net>.crossoverHz)")}) }+        };+        let path_from = |p: Option<&Pad>| p.map(|p| lpm * (p.x - load.x).hypot(p.y - load.y)).unwrap_or(0.0);+        let src: Option<Branch> = if let Some(r) = cfg.sourceOhm {+            let l = cfg.sourceNh.unwrap_or(700.0) * 1e-9; let feed = entry.first().and_then(|e| b.pads.iter().find(|p| p.reference == *e && p.net == name));+            Some(Branch { name: entry.first().map(|e| format!("{e} cable")).unwrap_or("source".into()), kind: "cable", r, l: l + path_from(feed), lp: 0.0, rp: 0.0, c: 0.0, at: feed.map(|p| [r2(p.x), r2(p.y)]), extra: json!({"rOhm": r, "nH": sig(l * 1e9), "model": "the spec's source: pdn.<net>.sourceOhm/sourceNh"}) })+        } else if is_out {+            let fsw = conv["fswHz"].as_f64().unwrap_or(500e3);+            let lp = pads_of(conv["inductor"].as_str().unwrap_or("L1"), &name).first().copied();+            Some(loop_branch(format!("{} loop", conv["switch"].as_str().unwrap_or("regulator")), cfg.crossoverHz.unwrap_or(fsw / 10.0), lp.map(|p| [r2(p.x), r2(p.y)]), path_from(lp), "the converter's output"))+        } else if let Some(u) = &reg {+            let up = pads_of(u, &name).first().copied();+            Some(loop_branch(format!("{u} loop"), cfg.crossoverHz.unwrap_or(100e3), up.map(|p| [r2(p.x), r2(p.y)]), path_from(up), "a linear regulator, crossover about 100 kHz assumed"))+        } else {+            // a connector feeding the rail: about a metre of supply leads+            let feed = entry.iter().find_map(|e| b.pads.iter().find(|p| p.reference == *e && p.net == name));+            let (r, l) = (0.03, cfg.sourceNh.unwrap_or(700.0) * 1e-9);+            Some(Branch { name: format!("{} cable", entry[0]), kind: "cable", r, l: l + path_from(feed), lp: 0.0, rp: 0.0, c: 0.0, at: feed.map(|p| [r2(p.x), r2(p.y)]), extra: json!({"rOhm": r, "nH": sig(l * 1e9), "model": "about a metre of supply leads: 30 mΩ, 0.7 µH (spec pdn.<net>.sourceOhm/sourceNh)"}) })+        };+        if let Some(s) = src { bs.insert(0, s); }+        if bs.is_empty() { continue; }+        // the target and its band+        let step = cfg.stepA.unwrap_or_else(|| if is_in { conv["iout"].as_f64().unwrap_or(1.0) } else { spec.loads.get(&name).map(|l| l.amps * 0.5).unwrap_or(0.1) });+        let tol = cfg.tolPct.unwrap_or(5.0);+        let zt = cfg.targetOhm.unwrap_or(v * tol / 100.0 / step.max(1e-6));+        let flo = cfg.fminHz.unwrap_or(1e3);+        let fhi = cfg.fmaxHz.unwrap_or_else(|| if is_in { 0.35 / (conv["riseNs"].as_f64().unwrap_or(8.0) * 1e-9) } else if at_chip { 100e6 } else { 1e6 });+        let band = |x: f64| x >= flo * 0.999 && x <= fhi * 1.001;+        let curve = |bs: &[Branch]| -> Vec<f64> { fs.iter().map(|x| zrail(bs, *x).abs()).collect() };+        let worst = |z: &[f64]| -> (usize, f64) { let mut wi = 0; let mut wr = 0.0; for (i, x) in fs.iter().enumerate() { if band(*x) && z[i] / zt > wr { wr = z[i] / zt; wi = i; } } (wi, wr) };+        let z0 = curve(&bs);+        let (wi, wr) = worst(&z0);+        // which branches make the peak: the inductive one and the capacitive one with the largest admittance+        let blame = |bs: &[Branch], fq: f64| -> (Option<String>, Option<String>) { let w = 2.0 * PI * fq; let (mut li, mut ci) = ((None, 0.0), (None, 0.0)); for x in bs { let z = x.z(w); let y = 1.0 / z.abs(); if z.1 > 0.0 { if y > li.1 { li = (Some(x.name.clone()), y); } } else if y > ci.1 { ci = (Some(x.name.clone()), y); } } (li.0, ci.0) };+        let (bl, bc) = blame(&bs, fs[wi]);+        let load_name = format!("{}.{}", load.reference, load.pad);+        // the fix: add the capacitor that lowers the worst peak most, re-solve, up to three+        let mut fixes: Vec<Value> = Vec::new(); let mut cur = bs.clone(); let mut cur_r = wr; let mut zf = z0.clone();+        if wr > 1.0 {+            let mlcc = [1e-9, 2.2e-9, 4.7e-9, 10e-9, 22e-9, 47e-9, 100e-9, 220e-9, 470e-9, 1e-6, 2.2e-6, 4.7e-6, 10e-6, 22e-6];+            let mount = lpm * 1.0 + 0.2e-9;+            for _ in 0..3 {+                let mut best: Option<(f64, Branch)> = None;+                let mut cands: Vec<Branch> = mlcc.iter().map(|&c| { let (pkg, span) = if c <= 1e-6 { ("0402", 1.0) } else if c <= 4.7e-6 { ("0603", 1.6) } else { ("0805", 2.0) }; let (esr, _) = esr_of(c, ""); let esl = (0.15 + 0.3 * span) * 1e-9; Branch { name: format!("new {}", fmt_c(c)), kind: "capacitor", r: esr, l: esl + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": pkg, "type": "ceramic", "new": true}) } }).collect();+                // a damping bulk capacitor for a low resonance: its ESR soaks up the LC ring+                for c in [47e-6, 100e-6, 220e-6] { cands.push(Branch { name: format!("new {}", fmt_c(c)), kind: "capacitor", r: 0.15, l: 3e-9 + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": "SMD can", "type": "electrolytic", "esrOhm": 0.15, "new": true}) }); }+                for cand in cands { let mut t = cur.clone(); t.push(cand.clone()); let z = curve(&t); let (_, r) = worst(&z); if best.as_ref().map_or(true, |b| r < b.0 * 0.98) { best = Some((r, cand)); } }+                let Some((r, cand)) = best else { break };+                if r > cur_r * 0.97 { break; }+                let before = cur_r; cur.push(cand.clone()); zf = curve(&cur); let (wi2, _) = worst(&zf); cur_r = r;+                fixes.push(json!({"value": cand.extra["value"], "package": cand.extra["package"], "type": cand.extra["type"], "nearPin": load_name, "ratioBefore": sig(before), "ratioAfter": sig(r), "worstAfterHz": sig(fs[wi2]), "worstAfterOhm": sig(zf[wi2])}));+                if r <= 1.0 { break; }+            }+            let peak = format!("{}: |Z| {} at {} seen from {}, {:.1}× its {} target ({} V, ±{} % for a {} A step){}", name, fmt_ohm(z0[wi]), fmt_hz(fs[wi]), load_name, wr, fmt_ohm(zt), v, tol, sig(step), match (&bl, &bc) { (Some(l), Some(c)) if wr > 1.0 => format!(": {} going inductive against {}", l, c), _ => String::new() });+            let sev = if wr > 2.0 { "medium" } else { "low" };+            if fixes.is_empty() {+                issues.push(Issue { severity: sev.into(), kind: "pdn-impedance".into(), what: peak.clone(), at: Some([r2(load.x), r2(load.y)]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: Some(load.reference.clone()), fix: format!("No single capacitor brings it under: widen {} and its ground into planes, and move the existing capacitors next to {}.", name, load_name), action: Some(json!({"type": "review", "net": name, "why": "PDN impedance over target"})) });+            }+            for (k, fx) in fixes.iter().enumerate() {+                let what = if k == 0 { peak.clone() } else { format!("{}: still {:.1}× the target after the capacitor(s) above", name, fixes[k - 1]["ratioAfter"].as_f64().unwrap_or(0.0)) };+                let ok = fx["ratioAfter"].as_f64().unwrap_or(9.0) <= 1.0;+                issues.push(Issue { severity: sev.into(), kind: "pdn-impedance".into(), what, at: Some([r2(load.x), r2(load.y)]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: Some(load.reference.clone()),+                    fix: format!("Add a {} {} {} from {} to {} within 1 mm of {}: the worst peak drops to {:.2}× the target{}.", fx["value"].as_str().unwrap_or(""), fx["package"].as_str().unwrap_or(""), fx["type"].as_str().unwrap_or(""), name, gnd, load_name, fx["ratioAfter"].as_f64().unwrap_or(0.0), if ok { ", under it" } else { "" }),+                    action: Some(json!({"type": "add-part", "what": "capacitor", "value": fx["value"], "package": fx["package"], "dielectric": fx["type"], "between": [name, gnd], "nearPin": load_name, "maxDistMm": 1.0, "why": "PDN impedance", "ratioAfter": fx["ratioAfter"]})) });+            }+            if !fixes.is_empty() && cur_r > 1.0 {+                issues.push(Issue { severity: sev.into(), kind: "pdn-impedance".into(), what: format!("{}: still {:.1}× the target after {} added capacitor(s): the copper's own inductance sets the floor", name, cur_r, fixes.len()), at: Some([r2(load.x), r2(load.y)]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: Some(load.reference.clone()),+                    fix: format!("Pour {} as a plane right over {} on the next layer (a thinner dielectric lowers the inductance in proportion), or relax the target for {} (spec pdn.{}).", name, gnd, name, name),+                    action: Some(json!({"type": "pour", "net": name, "over": gnd, "why": "PDN impedance floor: mounting inductance", "ratioAfterParts": sig(cur_r)})) });+            }+        }+        // Middlebrook: a converter's input network against its negative input resistance+        let mut mb = Value::Null;+        if is_in {+            let (vin, vout, io) = (conv["vin"].as_f64().unwrap_or(v), conv["vout"].as_f64().unwrap_or(0.0), conv["iout"].as_f64().unwrap_or(0.0));+            let p = vout * io / 0.9;+            if p > 0.0 {+                let rneg = vin * vin / p; let fsw = conv["fswHz"].as_f64().unwrap_or(500e3);+                // the negative resistance holds only inside the converter's loop bandwidth: up to fsw at most+                let (mi, mz) = z0.iter().enumerate().filter(|(i, _)| fs[*i] <= fsw).fold((0, 0.0), |a, (i, z)| if *z > a.1 { (i, *z) } else { a });+                let margin = 20.0 * (rneg / mz).log10();+                mb = json!({"negInputOhm": sig(rneg), "peakOhm": sig(mz), "atHz": sig(fs[mi]), "marginDb": r2(margin), "ok": margin >= 10.0});+                if margin < 10.0 {+                    let cin: f64 = bs.iter().filter(|x| x.kind == "capacitor").map(|x| x.c).sum(); let lsrc = bs.first().map(|x| x.l).unwrap_or(0.7e-6);+                    let cd = (4.0 * cin).max(10e-6); let rd = (lsrc / cin.max(1e-9)).sqrt();+                    issues.push(Issue { severity: if margin < 0.0 { "high".into() } else { "medium".into() }, kind: "input-filter-stability".into(), what: format!("{}: the input network peaks at {} near {}, only {:.0} dB under the converter's negative input resistance ({}): the input can ring with the regulator's loop", name, fmt_ohm(mz), fmt_hz(fs[mi]), margin, fmt_ohm(rneg)), at: Some([r2(load.x), r2(load.y)]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: Some(load.reference.clone()),+                        fix: format!("Add a damping bulk capacitor on {}: at least {} with about {} of ESR (an electrolytic or a ceramic plus a series resistor), near {}.", name, fmt_c(cd), fmt_ohm(rd), load_name),+                        action: Some(json!({"type": "add-part", "what": "capacitor", "value": fmt_c(cd), "dielectric": "electrolytic", "esrOhm": sig(rd), "between": [name, gnd], "nearPin": load_name, "maxDistMm": 10.0, "why": "input filter damping (Middlebrook)"})) });+                }+            }+        }+        // the ngspice cross-check of the same branches+        let mut spice = Value::Null;+        if let Some(dir) = out_dir {+            let net_txt = netlist(&name, &load_name, &bs);+            let file = dir.join(format!("pdn-{}.cir", name.replace(|c: char| !c.is_ascii_alphanumeric(), "_")));+            if std::fs::write(&file, &net_txt).is_ok() { spice = run_spice(&file, &fs, &z0); spice["netlist"] = json!(file.file_name().map(|x| x.to_string_lossy().to_string())); }+        }+        let br: Vec<Value> = bs.iter().map(|x| json!({"name": x.name, "kind": x.kind, "at": x.at, "r": x.r, "l": x.l, "lp": x.lp, "rp": x.rp, "c": x.c, "info": x.extra})).collect();+        let added: Vec<Value> = cur.iter().skip(bs.len()).map(|x| json!({"name": x.name, "kind": x.kind, "at": x.at, "r": x.r, "l": x.l, "lp": 0.0, "rp": 0.0, "c": x.c, "info": x.extra})).collect();+        rails.push(json!({"net": name, "railV": v, "load": load_name, "loadAt": [r2(load.x), r2(load.y)], "targetOhm": sig(zt), "stepA": sig(step), "tolPct": tol, "bandHz": [flo, sig(fhi)],+            "worst": {"hz": sig(fs[wi]), "ohm": sig(z0[wi]), "ratio": sig(wr), "inductive": bl, "capacitive": bc}, "traceWidthMm": wmm, "nHPerMm": sig(lpm * 1e9),+            "branches": br, "added": added, "fixes": fixes, "zOhm": z0.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "zAfterOhm": if fixes.is_empty() { Value::Null } else { json!(zf.iter().map(|x| sig(*x)).collect::<Vec<_>>()) }, "middlebrook": mb, "spice": spice}));+    }+    json!({"fHz": fs.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "rails": rails, "model": "each branch R + L + (Lp ∥ Rp) + C in parallel at the load pin; nominal capacitor values (no DC-bias derating); ESR by type; mounting inductance from a microstrip over the plane"})+}++/// The same branches as an ngspice deck: a 1 A AC current into the rail, so |V| is |Z|.+pub fn netlist(net: &str, load: &str, bs: &[Branch]) -> String {+    let mut s = format!("* adom-fields PDN: {} seen from {}\n* each branch R, L, (Lp || Rp), C to ground; 1 A AC in, so V(rail) is Z\nIin 0 rail AC 1\n", net, load);+    for (k, b) in bs.iter().enumerate() {+        let mut node = "rail".to_string(); let mut m = 0; let mut nx = |s: &mut String, kind: &str, val: f64| -> () { let next = format!("b{}_{}", k, m); m += 1; s.push_str(&format!("{}{}_{} {} {} {:e}\n", kind, k, m, node, next, val)); node = next; };+        s.push_str(&format!("* {} ({})\n", b.name, b.kind));+        nx(&mut s, "R", b.r.max(1e-6)); if b.l > 0.0 { nx(&mut s, "L", b.l); }+        if b.lp > 0.0 { let next = format!("b{}_p", k); s.push_str(&format!("Lp{} {} {} {:e}\n", k, node, next, b.lp)); if b.rp > 0.0 { s.push_str(&format!("Rp{} {} {} {:e}\n", k, node, next, b.rp)); } node = next; }+        if b.c > 0.0 { s.push_str(&format!("C{} {} 0 {:e}\n", k, node, b.c)); } else { s.push_str(&format!("Rg{} {} 0 1e-6\n", k, node)); }+    }+    s.push_str(".ac dec 40 1k 1g\n.end\n");+    s+}++fn run_spice(file: &std::path::Path, fs: &[f64], z: &[f64]) -> Value {+    let out = file.with_extension("ac.txt");+    let ctl = file.with_extension("ctl.cir");+    let deck = std::fs::read_to_string(file).unwrap_or_default().replace(".end\n", &format!(".control\nset wr_singlescale\nrun\nwrdata {} vm(rail)\n.endc\n.end\n", out.display()));+    if std::fs::write(&ctl, deck).is_err() { return json!({"ran": false}); }+    let r = std::process::Command::new("timeout").args(["30", "ngspice", "-b"]).arg(&ctl).output();+    let _ = std::fs::remove_file(&ctl);+    let Ok(r) = r else { return json!({"ran": false, "why": "ngspice not installed"}) };+    if !r.status.success() { return json!({"ran": false, "why": "ngspice failed"}); }+    let txt = std::fs::read_to_string(&out).unwrap_or_default(); let _ = std::fs::remove_file(&out);+    let pts: Vec<(f64, f64)> = txt.lines().filter_map(|l| { let v: Vec<f64> = l.split_whitespace().filter_map(|x| x.parse().ok()).collect(); if v.len() >= 2 { Some((v[0], v[1])) } else { None } }).collect();+    if pts.is_empty() { return json!({"ran": false, "why": "no ngspice output"}); }+    let mut err: f64 = 0.0; let mut cmp = 0;+    for (i, fq) in fs.iter().enumerate() { if let Some(p) = pts.iter().find(|p| (p.0 / fq - 1.0).abs() < 1e-3) { err = err.max((p.1 / z[i] - 1.0).abs()); cmp += 1; } }+    json!({"ran": true, "points": cmp, "maxErrPct": sig(err * 100.0)})+}++#[cfg(test)] mod tests {+    use super::*;+    #[test] fn rlc_series_resonance() {+        // a lone 100 nF with 1 nH and 10 mΩ: |Z| is the ESR at its SRF, 15.9 MHz+        let b = vec![Branch { name: "c".into(), kind: "capacitor", r: 0.01, l: 1e-9, lp: 0.0, rp: 0.0, c: 100e-9, at: None, extra: Value::Null }];+        let f0 = 1.0 / (2.0 * PI * (1e-9f64 * 100e-9).sqrt());+        assert!((zrail(&b, f0).abs() - 0.01).abs() < 1e-6);+        assert!((zrail(&b, 1e3).abs() - 1.0 / (2.0 * PI * 1e3 * 100e-9)).abs() / 1591.5 < 1e-3);+    }+    #[test] fn microstrip() { let l = l_per_mm(0.8, 1.465) * 1e9; assert!(l > 0.5 && l < 0.9, "{l}"); }+}
src/serve.rs+5−3
@@ -59,6 +59,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {         ("GET", "/app.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("app.js", include_str!("ui/app.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/flow.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("flow.js", include_str!("ui/flow.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/noise.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("noise.js", include_str!("ui/noise.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }+        ("GET", "/pdn.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("pdn.js", include_str!("ui/pdn.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/ground.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("ground.js", include_str!("ui/ground.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/emc.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("emc.js", include_str!("ui/emc.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/magnetic.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("magnetic.js", include_str!("ui/magnetic.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }@@ -70,7 +71,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {             match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(tiny_http::Header::from_bytes("Content-Type", "application/octet-stream").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no flow.bin: this solve predates the flow views", "hint": "Run `adom-fields analyze` again with this version."})) }         }         ("GET", "/health") => reply(req, 200, json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")})),-        ("GET", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity (current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise), probe mm (the magnetic sheet's height), explode 0..1, speed 0.1..4, net, layer, camera (top|iso|bottom|fit|zoom), air, parts: auto|ghost|solid|off, drift, focus: [x, y] mm for camera zoom}; /ui/select {net|chip}; /ui/issue {index}; /ui/toast {text, kind}; GET /events is the SSE channel; GET /fields.json is everything solved."})); }+        ("GET", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity (current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise|pdn), probe mm (the magnetic sheet's height), explode 0..1, speed 0.1..4, net, layer, camera (top|iso|bottom|fit|zoom), air, parts: auto|ghost|solid|off, drift, focus: [x, y] mm for camera zoom}; /ui/select {net|chip}; /ui/issue {index}; /ui/toast {text, kind}; GET /events is the SSE channel; GET /fields.json is everything solved."})); }         ("GET", "/fields.json") => {             // re-read: `adom-fields parts` may have added the 3D models since the server started             let mut a = app.lock().unwrap();@@ -102,7 +103,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {                 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; };                 a.view["issue"] = json!(i); a.view["net"] = issue["net"].clone(); if let Some(l) = issue["layer"].as_str() { a.view["layer"] = json!(l); }                 // each finding opens the view that shows it-                a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") => "current", Some("hot-loop") | Some("emissions") | Some("no-reference") => "emc", Some("ground-offset") | Some("ground-island") | Some("stitching-gap") | Some("ground-loop-risk") => "ground", Some("cavity-resonance") | Some("crosstalk") => "noise", Some("decoupling-far") | Some("decoupling-missing") | Some("esd-unprotected") => "emc", Some("ir-drop") => "river", Some("output-ripple") | Some("inductor-ripple") => "cycle", _ => "heat" });+                a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") => "current", Some("hot-loop") | Some("emissions") | Some("no-reference") => "emc", Some("ground-offset") | Some("ground-island") | Some("stitching-gap") | Some("ground-loop-risk") => "ground", Some("cavity-resonance") | Some("crosstalk") => "noise", Some("pdn-impedance") | Some("input-filter-stability") => "pdn", Some("decoupling-far") | Some("decoupling-missing") | Some("esd-unprotected") => "emc", Some("ir-drop") => "river", Some("output-ripple") | Some("inductor-ripple") => "cycle", _ => "heat" });                 a.view["camera"] = json!("zoom"); a.view["focus"] = issue["at"].clone();             } else {                 for k in ["quantity", "net", "layer", "camera", "air", "chip", "drift", "parts", "focus", "explode", "speed", "ghost", "silk", "probe"] { if !v[k].is_null() { a.view[k] = v[k].clone(); } }@@ -183,7 +184,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { pub fn ui(what: UiCmd, port: u16) {     let up = |p: &str, v: &Value| -> Value { post(port, p, v).unwrap_or_else(|e| crate::err(&format!("the app did not answer on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")])) };     match what {-        UiCmd::View { quantity, net, layer, camera, air, parts, at, explode, speed, ghost, silk, probe } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } if let Some(q) = &quantity { if !["current", "heat", "temperature", "signals", "flow", "river", "energy", "light", "cycle", "charge", "magnetic", "emc", "ground", "noise"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge, magnetic, emc, ground or noise"), &[]); } } let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air, "parts": parts, "focus": focus, "explode": explode, "speed": speed, "ghost": ghost, "silk": silk.as_deref().map(|s| s != "off" && s != "false"), "probe": probe})); crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); }+        UiCmd::View { quantity, net, layer, camera, air, parts, at, explode, speed, ghost, silk, probe } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } if let Some(q) = &quantity { if !["current", "heat", "temperature", "signals", "flow", "river", "energy", "light", "cycle", "charge", "magnetic", "emc", "ground", "noise", "pdn"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge, magnetic, emc, ground, noise or pdn"), &[]); } } let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air, "parts": parts, "focus": focus, "explode": explode, "speed": speed, "ghost": ghost, "silk": silk.as_deref().map(|s| s != "off" && s != "false"), "probe": probe})); crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); }         UiCmd::Explain { quantity, stop } => { let r = up("/ui/explain", &json!({"quantity": quantity, "stop": stop})); crate::ok(&format!("{} to {} page(s)", if stop { "stop sent" } else { "explain sent" }, r["clients"]), &["The page composes the explanation from this board's numbers and plays it with a one-line subtitle; a browser may hold the first audio until the viewer has clicked the page once.".into()]); }         UiCmd::Toast { text, kind } => { up("/ui/toast", &json!({"text": text, "kind": kind})); crate::ok("toast shown", &[]); }         UiCmd::Select { net, chip } => { let r = up("/ui/select", &json!({"net": net, "chip": chip})); crate::ok(&format!("selected: {}", r["view"]), &[]); }@@ -226,6 +227,7 @@ pub fn tour(port: u16, hold_ms: u64) {         toast("EMC: the hot loop, the return path and the estimated emissions"); step("/ui/view", json!({"quantity": "emc", "camera": "top"}), settle); steps += 1;         toast("Ground: its own voltage drop, the loops, the stitching fence"); step("/ui/view", json!({"quantity": "ground", "camera": "iso"}), settle); steps += 1;         toast("Noise: the planes ringing, the switching edges reaching quiet nets"); step("/ui/view", json!({"quantity": "noise", "camera": "iso"}), settle); steps += 1;+        toast("Power: each rail's impedance across frequency, and which capacitor carries it"); step("/ui/view", json!({"quantity": "pdn", "camera": "iso"}), settle); steps += 1;         step("/ui/view", json!({"explode": 0}), 0);     }     toast("Back to the current density"); step("/ui/view", json!({"quantity": "current", "air": "still", "camera": "iso"}), settle); steps += 1;
src/ui/app.js+14−4
@@ -192,6 +192,7 @@     if (q === 'emc') return window.fieldsEmc ? window.fieldsEmc.narration() : '';     if (q === 'ground') return window.fieldsGround ? window.fieldsGround.narration() : '';     if (q === 'noise') return window.fieldsNoise ? window.fieldsNoise.narration() : '';+    if (q === 'pdn') return window.fieldsPdn ? window.fieldsPdn.narration() : '';     if (window.fieldsFlow && ['flow', 'river', 'energy', 'light', 'charge'].includes(q)) return window.fieldsFlow.narration(q);     const n0 = (f.nets || [])[0], c0 = (f.chips || [])[0];     if (q === 'current') return `This is current density: how many amps flow through each square millimetre of copper. Dark is quiet copper, yellow is where the current crowds.${n0 ? ` The busiest spot is on ${n0.net}, at about ${Math.round(n0.peakJ)} amps per square millimetre, on the ${n0.peakLayer === 'F.Cu' ? 'top' : n0.peakLayer === 'B.Cu' ? 'bottom' : 'inner'} copper.` : ''} Above about sixty amps per square millimetre, a narrow neck starts to heat up, and the issues list calls it out. Click a net on the left to see it alone.`;@@ -221,12 +222,16 @@     a.ontimeupdate = () => { if (!a.duration) return; const at = a.currentTime / a.duration * total; let acc = 0, k = 0; for (; k < ss.length - 1; k++) { acc += ss[k].length; if (acc > at) break; } if (k !== shown) { shown = k; caption(ss[k]); } };     caption(ss[0]);   }-  function stopNarration() { if (narr.audio) { narr.audio.pause(); narr.audio = null; } caption(''); const b = $('#btnExplain'); if (b) { b.classList.remove('is-on'); b.setAttribute('aria-pressed', false); } }+  function stopNarration() { narr.seq = (narr.seq || 0) + 1; if (narr.audio) { narr.audio.pause(); narr.audio = null; } caption(''); const b = $('#btnExplain'); if (b) { b.classList.remove('is-on'); b.setAttribute('aria-pressed', false); } }   async function explain(q, auto) {     q = q || state.view.quantity; const text = narrationText(q); if (!text) return;     stopNarration(); narr.busy = true; const b = $('#btnExplain'); if (b) { b.classList.add('is-on'); b.setAttribute('aria-pressed', true); }     caption(sentences(text)[0]);+    // only the latest request plays: an automatic first-run explain still fetching its voice must not start a+    // second voice over this one (the two fought over the subtitles)+    const my = narr.seq = (narr.seq || 0) + 1;     const r = await post('narrate', { view: q, text });+    if (my !== narr.seq) return;     narr.busy = false;     narr.heard[q] = true; try { localStorage.setItem('fields.narrated', JSON.stringify(narr.heard)); } catch (e) { void e; }     if (!r || r.status !== 'ok') { caption(''); toast(((r && r.error) || 'No voice right now') + ' (the Key button has the written version)', 'info'); if (b) b.classList.remove('is-on'); return; }@@ -330,7 +335,7 @@   function texName(L) {     const q = state.view.quantity, ls = slug(L);     if (q === 'signals') return `copper-${ls}.png`;-    if (q === 'river' || q === 'energy' || q === 'light' || q === 'cycle' || q === 'charge' || q === 'magnetic' || q === 'emc' || q === 'ground' || q === 'noise') return `copper-${ls}.png`;+    if (q === 'river' || q === 'energy' || q === 'light' || q === 'cycle' || q === 'charge' || q === 'magnetic' || q === 'emc' || q === 'ground' || q === 'noise' || q === 'pdn') return `copper-${ls}.png`;     if (q === 'current' || q === 'flow') 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';@@ -345,7 +350,7 @@       if (L === 'In1.Cu' || L === 'In2.Cu') m.alpha = (L === selected) ? 1.0 : 0.0;     }     if (viaRoot) viaRoot.setEnabled(!['temperature', 'river'].includes(state.view.quantity));-    for (const { el, c } of labels) { el.hidden = ['current','signals','flow','river','energy','light','cycle','charge','magnetic','emc','ground','noise'].includes(state.view.quantity); 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','flow','river','energy','light','cycle','charge','magnetic','emc','ground','noise','pdn'].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';@@ -362,6 +367,10 @@       window.fieldsNoise.apply(state.view);       if (state.view.quantity === 'noise') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsNoise.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }     }+    if (window.fieldsPdn) {+      window.fieldsPdn.apply(state.view);+      if (state.view.quantity === 'pdn') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsPdn.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }+    }     if (window.fieldsGround) {       window.fieldsGround.apply(state.view);       if (state.view.quantity === 'ground') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsGround.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }@@ -489,7 +498,8 @@           .then(() => import('./magnetic.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(mg => { window.fieldsMagnetic = mg; applyTextures(); }).catch(e => console.error('magnetic: ' + (e && e.stack || e)))           .then(() => import('./emc.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api, cycle: window.fieldsCycle })).then(em => { window.fieldsEmc = em; applyTextures(); }).catch(e => console.error('emc: ' + (e && e.stack || e)))           .then(() => import('./ground.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(gr => { window.fieldsGround = gr; applyTextures(); }).catch(e => console.error('ground: ' + (e && e.stack || e)))-          .then(() => import('./noise.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(nz => { window.fieldsNoise = nz; applyTextures(); }).catch(e => console.error('noise: ' + (e && e.stack || e))); })+          .then(() => import('./noise.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(nz => { window.fieldsNoise = nz; applyTextures(); }).catch(e => console.error('noise: ' + (e && e.stack || e)))+          .then(() => import('./pdn.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(pd => { window.fieldsPdn = pd; applyTextures(); }).catch(e => console.error('pdn: ' + (e && e.stack || e))); })       .catch(e => { flowNote = 'The flow view did not load: ' + (e && e.message || e); console.error('flow: ' + (e && e.stack || e)); applyTextures(); });     const st = await fetch('state').then(r => r.json()).catch(() => null);     applyView(st && st.view ? st.view : {}, true);
src/ui/index.html+2−2
@@ -53,7 +53,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", .labels{position:absolute;inset:0;pointer-events:none;overflow:hidden} .lbl{position:absolute;transform:translate(-50%,-140%);font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--text);background:rgba(13,17,23,.75);border:1px solid var(--border);border-radius:6px;padding:2px 7px;white-space:nowrap} .lbl.chip{font-size:11px;padding:2px 6px;border-color:rgba(255,255,255,.18);background:rgba(13,17,23,.82)}.lbl.chip b{color:#fff;margin-right:2px}.lbl.chip .in{color:#ffb86b;margin-left:4px}.lbl.chip .out{color:#7ee0ff;margin-left:4px}-.lbl.noise{border-color:rgba(255,200,80,.5)}.lbl.noise .v{color:#ff8a6e;font-weight:600}.lbl.noise .g{color:#5cf08f;font-weight:600}+.lbl.pdn{border-color:rgba(92,240,143,.45)}.lbl.pdn .v{color:#ff8a6e;font-weight:600}.lbl.pdn .g{color:#5cf08f;font-weight:600}.lbl.pdn i{color:#5cf08f;font-style:normal}.lbl.pdn.big{font-size:12.5px}.cyclemeter.pdnpanel{width:280px}.cyclemeter.pdnpanel canvas{height:auto;aspect-ratio:460/210}.cyclemeter .pdnbtn{background:rgba(255,255,255,.08);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px;padding:1px 7px;cursor:pointer}.lbl.noise{border-color:rgba(255,200,80,.5)}.lbl.noise .v{color:#ff8a6e;font-weight:600}.lbl.noise .g{color:#5cf08f;font-weight:600} .lbl.ground{border-color:rgba(252,166,120,.5)}.lbl.ground .v{color:#fca678;font-weight:600} .lbl.emc{border-color:rgba(255,120,90,.5)}.lbl.emc .v{color:#ff8a6e;font-weight:600}.lbl.emc .g{color:#5cf08f;font-weight:600}.cyclemeter.emcpanel canvas{height:auto;aspect-ratio:460/190} .lbl.river{border-color:rgba(89,217,250,.45);color:#c8f3ff}@@ -113,7 +113,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="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 class="seg" id="qty2" style="margin-top:4px"><button data-q="flow" data-tip="The solved current as charge streaming through the copper: speed and heat follow the current density, the vias take their share">Flow</button><button data-q="river" data-tip="Every net lifted to its voltage, the current running downhill as water: the loads fall to ground, the sources lift it back">River</button><button data-q="energy" data-tip="Where the energy really travels: through the fields between the copper layers, while the electrons crawl at millimetres per second">Energy</button><button data-q="light" data-tip="How fast a change travels: the local speed of light in the copper's field, and when a switch-on reaches each part">Light</button><button data-q="cycle" data-tip="One switching period: energy from the input capacitors into the inductor's magnetic field, then out to the output; the stores and the heat as they change">Cycle</button><button data-q="charge" data-tip="The board as capacitors: charge on facing copper, the electric field between, the switch node charging every cycle, and the capacitor parts">Charge</button><button data-q="magnetic" data-tip="The magnetic field of every current: field lines wrapping the copper, and the field strength at a height like a near-field probe">Magnetic</button><button data-q="emc" data-tip="Where the board could radiate: the hot loop and the smallest it could be, copper with no ground under it, and the estimated emissions against the FCC limit">EMC</button><button data-q="ground" data-tip="The return path: ground's own voltage drop, possible ground loops through the cables, floating ground islands, and the stitching-via fence along the edge">Ground</button><button data-q="noise" data-tip="Where switching noise rings and spreads: the planes ringing like drums at their own frequencies, and the switching edges reaching quiet nets">Noise</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 class="seg" id="qty2" style="margin-top:4px"><button data-q="flow" data-tip="The solved current as charge streaming through the copper: speed and heat follow the current density, the vias take their share">Flow</button><button data-q="river" data-tip="Every net lifted to its voltage, the current running downhill as water: the loads fall to ground, the sources lift it back">River</button><button data-q="energy" data-tip="Where the energy really travels: through the fields between the copper layers, while the electrons crawl at millimetres per second">Energy</button><button data-q="light" data-tip="How fast a change travels: the local speed of light in the copper's field, and when a switch-on reaches each part">Light</button><button data-q="cycle" data-tip="One switching period: energy from the input capacitors into the inductor's magnetic field, then out to the output; the stores and the heat as they change">Cycle</button><button data-q="charge" data-tip="The board as capacitors: charge on facing copper, the electric field between, the switch node charging every cycle, and the capacitor parts">Charge</button><button data-q="magnetic" data-tip="The magnetic field of every current: field lines wrapping the copper, and the field strength at a height like a near-field probe">Magnetic</button><button data-q="emc" data-tip="Where the board could radiate: the hot loop and the smallest it could be, copper with no ground under it, and the estimated emissions against the FCC limit">EMC</button><button data-q="ground" data-tip="The return path: ground's own voltage drop, possible ground loops through the cables, floating ground islands, and the stitching-via fence along the edge">Ground</button><button data-q="noise" data-tip="Where switching noise rings and spreads: the planes ringing like drums at their own frequencies, and the switching edges reaching quiet nets">Noise</button><button data-q="pdn" data-tip="Power delivery: each rail's impedance seen from its load pin, 1 kHz to 1 GHz, against its target; which capacitor carries the current at each frequency, and the ones that would bring a peak under">Power</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>Explain each view out loud</h3><div class="seg" id="narrMode"><button data-n="first" data-tip="Each kind of view explains itself the first time you open it">First time</button><button data-n="always" data-tip="Every view explains itself whenever you open it">Always</button><button data-n="ask" data-tip="Only when you click the speaker in the header">On ask</button></div></div>
src/ui/pdn.jsadded+189
@@ -0,0 +1,189 @@+// Adom Fields: power delivery. Each rail's impedance seen from its load pin, from the solve's own review+// (fields.json review.pdn: the branches, |Z(f)|, the target, the fixes), drawn on the board while the+// frequency sweeps from 1 kHz to 1 GHz:+//  - every branch (the regulator or the cable, each capacitor, the plane) stands a column at its place, its+//    height its share of the load's current at this frequency: the bulk capacitors carry the low end, the+//    small ceramics the high end, the source only the slow part. At an anti-resonance two branches swap+//    current with each other and their columns overshoot 100 %: the rail rings;+//  - arcs carry each branch's share to the load pin;+//  - at the load pin, the rail's wobble for the step current stands inside a glass sleeve, the tolerance:+//    under the rim it is green, over it red. That is the impedance against its target;+//  - the rail's copper glows the same colour.+// The panel draws |Z(f)| per rail on log axes with its target and band, the fix curve dashed, and a slider.+export async function attach(ctx) {+  const { BAB, scene, V, camera, state, LAYERS, frameDt, root, flow } = ctx;+  if (!flow || !flow.data) return null;+  const { lay, real, W, H, d, meta, nets, zOf, bx, by } = flow.data;+  const pd = (state.fields.review || {}).pdn || { rails: [], fHz: [] };+  const rails = (pd.rails || []).filter(r => r.branches && r.branches.length);+  const fHz = pd.fHz || [];+  const group = new BAB.TransformNode('fields-pdn', scene); group.parent = root; group.setEnabled(false);+  const k0 = real[0];+  // ---- the model, the same as the solve: R + jωL + (jωLp ∥ Rp) + 1/(jωC) per branch, all in parallel ----+  const cx = { add: (a, b) => [a[0] + b[0], a[1] + b[1]], mul: (a, b) => [a[0] * b[0] - a[1] * b[1], a[0] * b[1] + a[1] * b[0]], inv: (a) => { const q = a[0] * a[0] + a[1] * a[1]; return [a[0] / q, -a[1] / q]; }, abs: (a) => Math.hypot(a[0], a[1]) };+  function zb(b, f) { const w = 2 * Math.PI * f; let z = [b.r, w * b.l]; if (b.lp > 0) { const zl = [0, w * b.lp]; z = cx.add(z, b.rp > 0 ? cx.mul(cx.mul(zl, [b.rp, 0]), cx.inv(cx.add(zl, [b.rp, 0]))) : zl); } if (b.c > 0) z = cx.add(z, [0, -1 / (w * b.c)]); return z; }+  function solve(bs, f) { const zs = bs.map(b => zb(b, f)); const y = zs.reduce((a, z) => cx.add(a, cx.inv(z)), [0, 0]); const zt = cx.inv(y); return { z: cx.abs(zt), share: zs.map(z => cx.abs(cx.mul(zt, cx.inv(z)))) }; }+  // ---- one set of meshes per rail ------------------------------------------------------------------------+  const colOf = (kind) => kind === 'regulator' || kind === 'cable' ? [0.35, 0.6, 1] : kind === 'plane' ? [0.75, 0.45, 1] : [0.3, 1, 0.75];+  const host = document.getElementById('labels');+  const mkTag = (cls) => { const el = document.createElement('div'); el.className = 'lbl pdn ' + (cls || ''); el.hidden = true; host.appendChild(el); return el; };+  const idOf = (name) => nets.findIndex(n => n.name === name);+  const R = rails.map((r, ri) => {+    const g = new BAB.TransformNode('pdn-rail-' + r.net, scene); g.parent = group; g.setEnabled(false);+    const [lx, ly] = r.loadAt;+    const cols = r.branches.concat(r.added || []).map((b, bi) => {+      const added = bi >= r.branches.length;+      const [x, y] = b.at || r.loadAt;+      // a branch sitting on the load pin (the plane, a new capacitor) steps aside so it does not hide it+      const off = Math.hypot(x - lx, y - ly) < 0.6 ? (b.kind === 'plane' ? [1.4, -1.1] : [-1.4, -1.1 - 0.9 * bi % 3]) : [0, 0];+      const px = x + off[0], py = y + off[1];+      const c = BAB.MeshBuilder.CreateCylinder('pdn-col-' + r.net + bi, { height: 1, diameter: b.kind === 'plane' ? 0.9 : 0.7, tessellation: 18 }, scene);+      c.rotation.x = Math.PI / 2; c.position.x = bx(px); c.position.y = by(py);+      const m = new BAB.StandardMaterial('pdn-col-m' + r.net + bi, scene); m.emissiveColor = new BAB.Color3(...colOf(b.kind)); m.disableLighting = true; m.alpha = added ? 0.45 : 0.85;+      c.material = m; c.parent = g; c.renderingGroupId = 2; c.isPickable = false;+      // the arc from the branch to the load pin+      const pts = []; const span = Math.hypot(px - lx, py - ly);+      for (let a = 0; a <= 20; a++) { const t = a / 20; pts.push(bx(px + (lx - px) * t), by(py + (ly - py) * t), 0.2 + Math.min(3, 0.35 * span) * Math.sin(Math.PI * t)); }+      let arc = null;+      if (span > 0.3) try { arc = BAB.CreateGreasedLine('pdn-arc-' + r.net + bi, { points: pts }, { color: new BAB.Color3(...colOf(b.kind)), width: 0.16, useDash: true, dashCount: 10, dashRatio: 0.45, sizeAttenuation: true }, scene); arc.parent = g; arc.renderingGroupId = 2; arc.isPickable = false; } catch (e) { void e; }+      const tag = mkTag(); const nm = b.kind === 'capacitor' ? `${added ? '<i>+</i> ' : ''}${b.name.replace(/^new /, '')}` : b.kind === 'plane' ? 'plane' : b.name;+      const val = b.kind === 'capacitor' ? (b.info.value || '').split(' ')[0] : b.kind === 'plane' ? (b.info.pF + ' pF') : '';+      return { b, c, m, arc, tag, px, py, nm, val, added, h: 0 };+    });+    // the load pin: the wobble column in its tolerance sleeve+    const sleeveH = 3.2;+    const sleeve = BAB.MeshBuilder.CreateCylinder('pdn-sleeve-' + r.net, { height: sleeveH, diameter: 1.6, tessellation: 32, cap: BAB.Mesh.NO_CAP, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);+    sleeve.rotation.x = Math.PI / 2; sleeve.position.x = bx(lx); sleeve.position.y = by(ly); sleeve.position.z = sleeveH / 2;+    const sm = new BAB.StandardMaterial('pdn-sleeve-m' + r.net, scene); sm.emissiveColor = new BAB.Color3(0.85, 0.9, 1); sm.disableLighting = true; sm.alpha = 0.16; sm.backFaceCulling = false; sleeve.material = sm; sleeve.parent = g; sleeve.renderingGroupId = 2; sleeve.isPickable = false;+    const rim = BAB.MeshBuilder.CreateTorus('pdn-rim-' + r.net, { diameter: 1.6, thickness: 0.07, tessellation: 40 }, scene); rim.rotation.x = Math.PI / 2; rim.position.x = bx(lx); rim.position.y = by(ly); rim.position.z = sleeveH;+    const rmm = new BAB.StandardMaterial('pdn-rim-m' + r.net, scene); rmm.emissiveColor = new BAB.Color3(1, 1, 1); rmm.disableLighting = true; rim.material = rmm; rim.parent = g; rim.renderingGroupId = 2; rim.isPickable = false;+    const lc = BAB.MeshBuilder.CreateCylinder('pdn-load-' + r.net, { height: 1, diameter: 1.1, tessellation: 28 }, scene); lc.rotation.x = Math.PI / 2; lc.position.x = bx(lx); lc.position.y = by(ly);+    const lm = new BAB.StandardMaterial('pdn-load-m' + r.net, scene); lm.disableLighting = true; lm.alpha = 0.92; lc.material = lm; lc.parent = g; lc.renderingGroupId = 2; lc.isPickable = false;+    const loadTag = mkTag('big');+    // the rail's copper, tinted by how it stands against the target+    const id = idOf(r.net), tex = new Uint8Array(W * H * 4);+    for (const k of real) for (let i = 0; i < W * H; i++) if (lay[k].net[i] === id) { tex[i * 4] = tex[i * 4 + 1] = tex[i * 4 + 2] = 255; tex[i * 4 + 3] = 255; }+    const rt = new BAB.RawTexture(tex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.NEAREST_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);+    const p = BAB.MeshBuilder.CreatePlane('pdn-cu-' + r.net, { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);+    { const uvs = p.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; p.setVerticesData(BAB.VertexBuffer.UVKind, uvs); }+    p.position.x = bx(meta.x0 + W * d / 2); p.position.y = by(meta.y0 + H * d / 2);+    const pm = new BAB.StandardMaterial('pdn-cu-m' + r.net, scene); pm.diffuseTexture = rt; rt.hasAlpha = true; pm.useAlphaFromDiffuseTexture = true; pm.disableLighting = true; pm.backFaceCulling = false; pm.diffuseColor = new BAB.Color3(0, 0, 0);+    p.material = pm; p.parent = g; p.renderingGroupId = 1; p.isPickable = false;+    const hue = ['#5cc8ff', '#ffb84d', '#c38bff', '#7dff9a'][ri % 4];+    return { r, g, cols, sleeveH, lc, lm, loadTag, pm, hue, all: r.branches.concat(r.added || []) };+  });+  // ---- the panel ----------------------------------------------------------------------------------------+  const fmtHz = (f) => f >= 1e9 ? (f / 1e9).toFixed(2) + ' GHz' : f >= 1e6 ? (f / 1e6).toFixed(f >= 1e8 ? 0 : 1) + ' MHz' : (f / 1e3).toFixed(f >= 1e5 ? 0 : 1) + ' kHz';+  const fmtO = (z) => z >= 1 ? z.toFixed(2) + ' Ω' : (z * 1e3).toFixed(z >= 0.01 ? 0 : 1) + ' mΩ';+  const panel = document.createElement('div'); panel.className = 'cyclemeter pdnpanel'; panel.hidden = true; document.querySelector('.stage').appendChild(panel);+  const sel = { i: rails.length ? rails.reduce((b, r, i) => r.worst.ratio > rails[b].worst.ratio ? i : b, 0) : 0, sweep: true, lf: 3, picked: false };+  if (!rails.length) panel.innerHTML = `<div class="ch">Power delivery</div><div class="body"><div class="row">No rail with a load pin and capacitors to check.</div></div>`;+  else {+    panel.innerHTML = `<div class="ch" title="Click to fold">Power delivery</div><div class="body">+      ${rails.map((r, i) => `<div class="row rail" data-i="${i}" style="cursor:pointer"><span class="dot" style="background:${R[i].hue}"></span>${r.net} from ${r.load}<i style="color:${r.worst.ratio > 1 ? '#ff6b5e' : '#5cf08f'}">${r.worst.ratio.toFixed(2)}× target</i></div>`).join('')}+      ${(pd.rails || []).filter(r => r.skipped).map(r => `<div class="row" title="${String(r.skipped).replace(/"/g, '&quot;')}" style="color:var(--text-2)"><span class="dot" style="background:#6e7681"></span>${r.net}<i>not checked: no known source</i></div>`).join('')}+      <canvas id="pdnChart" width="460" height="210"></canvas>+      <div class="row"><button id="pdnPlay" class="pdnbtn" title="Sweep the frequency">❚❚</button><input id="pdnF" type="range" min="3" max="9" step="0.005" value="3" style="flex:1"><i id="pdnFv">1 kHz</i></div>+      <div id="pdnNow" style="color:var(--text-2);margin-top:5px;font-family:'JetBrains Mono';font-size:11px;line-height:1.45"></div>+      <div class="row" style="color:var(--text-2)">${rails.every(r => r.spice && r.spice.ran) ? `ngspice agrees within ${Math.max(...rails.map(r => r.spice.maxErrPct)).toFixed(2)} %` : 'ngspice cross-check not run'}</div>+    </div>`;+    panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); const row = e.target.closest('.rail'); if (row) { sel.i = +row.dataset.i; sel.picked = true; } });+    panel.querySelector('#pdnPlay').addEventListener('click', () => { sel.sweep = !sel.sweep; panel.querySelector('#pdnPlay').textContent = sel.sweep ? '❚❚' : '▶'; });+    panel.querySelector('#pdnF').addEventListener('input', (e) => { sel.lf = +e.target.value; sel.sweep = false; panel.querySelector('#pdnPlay').textContent = '▶'; });+  }+  function chart(f) {+    const cv = panel.querySelector('#pdnChart'); if (!cv) return; const g = cv.getContext('2d'), w = cv.width, h = cv.height, L = 40, B = 22, T = 8, Rr = 8;+    g.clearRect(0, 0, w, h);+    const zs = rails.flatMap(r => r.zOhm.concat([r.targetOhm])); const zlo = Math.pow(10, Math.floor(Math.log10(Math.max(1e-4, Math.min(...zs))))), zhi = Math.pow(10, Math.ceil(Math.log10(Math.max(...zs))));+    const X = (fq) => L + (Math.log10(fq) - 3) / 6 * (w - L - Rr), Y = (z) => T + (1 - (Math.log10(z) - Math.log10(zlo)) / (Math.log10(zhi) - Math.log10(zlo))) * (h - T - B);+    g.font = '10px JetBrains Mono'; g.fillStyle = '#8b949e'; g.strokeStyle = 'rgba(255,255,255,.08)'; g.lineWidth = 1;+    for (let e = 3; e <= 9; e++) { g.beginPath(); g.moveTo(X(10 ** e), T); g.lineTo(X(10 ** e), h - B); g.stroke(); g.fillText(['1k', '10k', '100k', '1M', '10M', '100M', '1G'][e - 3], X(10 ** e) - 8, h - 7); }+    for (let e = Math.log10(zlo); e <= Math.log10(zhi) + 1e-9; e++) { const z = 10 ** e; g.beginPath(); g.moveTo(L, Y(z)); g.lineTo(w - Rr, Y(z)); g.stroke(); g.fillText(z >= 1 ? z + 'Ω' : (z * 1e3) + 'm', 2, Y(z) + 3); }+    rails.forEach((r, i) => {+      const on = i === sel.i, col = R[i].hue;+      // the band where the target holds, and the target+      if (on) { g.fillStyle = 'rgba(255,255,255,.04)'; g.fillRect(X(r.bandHz[0]), T, X(r.bandHz[1]) - X(r.bandHz[0]), h - T - B); }+      g.strokeStyle = col; g.globalAlpha = on ? 0.9 : 0.35; g.setLineDash([5, 4]); g.beginPath(); g.moveTo(X(r.bandHz[0]), Y(r.targetOhm)); g.lineTo(X(r.bandHz[1]), Y(r.targetOhm)); g.stroke(); g.setLineDash([]);+      g.lineWidth = on ? 2.2 : 1.2; g.beginPath(); fHz.forEach((fq, k) => { const p = [X(fq), Y(Math.max(zlo, r.zOhm[k]))]; k ? g.lineTo(...p) : g.moveTo(...p); }); g.stroke();+      if (on && r.zAfterOhm) { g.setLineDash([2, 3]); g.strokeStyle = '#5cf08f'; g.beginPath(); fHz.forEach((fq, k) => { const p = [X(fq), Y(Math.max(zlo, r.zAfterOhm[k]))]; k ? g.lineTo(...p) : g.moveTo(...p); }); g.stroke(); g.setLineDash([]); }+      g.lineWidth = 1; g.globalAlpha = 1;+    });+    // the cursor+    const r = rails[sel.i], z = solve(R[sel.i].r.branches, f).z;+    g.strokeStyle = '#fff'; g.beginPath(); g.moveTo(X(f), T); g.lineTo(X(f), h - B); g.stroke();+    g.fillStyle = z > r.targetOhm ? '#ff6b5e' : '#5cf08f'; g.beginPath(); g.arc(X(f), Y(Math.max(zlo, z)), 4, 0, 7); g.fill();+  }+  // ---- frame ------------------------------------------------------------------------------------------+  const knob = { on: 0, to: 0, t: 0 };+  let lastChart = 0;+  scene.onBeforeRenderObservable.add(() => {+    const dt = frameDt();+    knob.on += (knob.to - knob.on) * (1 - Math.exp(-dt * 2.5)); if (Math.abs(knob.to - knob.on) < 1e-3) knob.on = knob.to;+    const live = knob.on > 0.01 && rails.length; group.setEnabled(!!live); group.position.z = zOf(k0) + 0.1;+    if (!live) { for (const x of R) { x.loadTag.hidden = true; for (const c of x.cols) c.tag.hidden = true; } return; }+    const sp = +state.view.speed || 1; knob.t += dt * sp;+    // one decade every 2.5 s, rail after rail unless one was picked+    if (sel.sweep) { sel.lf += dt * sp / 2.5; if (sel.lf > 9) { sel.lf = 3; if (!sel.picked && rails.length > 1) sel.i = (sel.i + 1) % rails.length; } const s = panel.querySelector('#pdnF'); if (s) s.value = sel.lf; }+    const f = Math.pow(10, sel.lf);+    const e = V.getEngine(), w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host'), wm = group.getWorldMatrix();+    const place = (el, x, y, z) => { const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(new BAB.Vector3(bx(x), by(y), z), wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h)); el.hidden = p.z > 1 || p.z < 0; el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px'; };+    R.forEach((x, i) => {+      const on = i === sel.i; x.g.setEnabled(on);+      if (!on) { x.loadTag.hidden = true; for (const c of x.cols) c.tag.hidden = true; return; }+      // with the fixes added only when they exist; the columns of new parts stay ghosted+      const s = solve(x.all, f), base = solve(x.r.branches, f), ratio = base.z / x.r.targetOhm;+      x.cols.forEach((c, bi) => {+        const share = c.added ? 0 : base.share[bi]; const hh = Math.min(9, 0.15 + 3.2 * share);+        c.h += (hh - c.h) * (1 - Math.exp(-dt * 10));+        c.c.scaling.y = c.h; c.c.position.z = c.h / 2; c.m.alpha = c.added ? 0.25 : (0.35 + 0.6 * Math.min(1, share)) * knob.on;+        if (c.arc) { c.arc.visibility = c.added ? 0 : Math.min(1, 0.1 + share) * knob.on; if (c.arc.greasedLineMaterial) c.arc.greasedLineMaterial.dashOffset = -knob.t * (0.2 + 1.6 * Math.min(1.5, share)); }+        c.tag.innerHTML = `<b>${c.nm}</b> ${c.val} <span class="${share > 1.05 ? 'v' : 'g'}">${c.added ? 'fix' : (share * 100).toFixed(0) + ' %'}</span>`;+        place(c.tag, c.px, c.py, c.h + 0.5); if (!c.added && share < 0.03) c.tag.hidden = true;+      });+      // the wobble in its sleeve: under the rim green, over it red, bobbing with the frequency (slowed)+      const lh = Math.min(x.sleeveH * 3, x.sleeveH * ratio) * (0.88 + 0.12 * Math.sin(knob.t * 6));+      x.lc.scaling.y = Math.max(0.05, lh); x.lc.position.z = Math.max(0.05, lh) / 2;+      // the target holds only inside the rail's band: outside it the column turns a neutral steel blue+      const inBand = f >= x.r.bandHz[0] * 0.999 && f <= x.r.bandHz[1] * 1.001;+      const over = inBand && ratio > 1, t = Math.min(1, ratio);+      x.lm.emissiveColor = !inBand ? new BAB.Color3(0.45, 0.55, 0.7) : over ? new BAB.Color3(1, 0.3, 0.25) : new BAB.Color3(0.25 + 0.6 * t, 1 - 0.2 * t, 0.45 - 0.3 * t);+      x.pm.emissiveColor = x.lm.emissiveColor; x.pm.alpha = knob.on * (0.25 + 0.35 * Math.min(1.5, ratio) / 1.5);+      x.loadTag.innerHTML = `<b>${x.r.net}</b> at ${x.r.load} · ${fmtHz(f)} ${inBand ? `<span class="${over ? 'v' : 'g'}">${fmtO(base.z)} · ${ratio.toFixed(2)}×</span>` : `${fmtO(base.z)} · outside its band`}${x.r.zAfterOhm ? ` <span class="g">→ ${(s.z / x.r.targetOhm).toFixed(2)}× fixed</span>` : ''}`;+      place(x.loadTag, x.r.loadAt[0], x.r.loadAt[1], Math.max(x.sleeveH, lh) + 1.0);+      // tags over clustered parts would stack: push each one below the one it collides with+      const vis = [x.loadTag, ...x.cols.map(c => c.tag)].filter(el => !el.hidden).map(el => ({ el, x: parseFloat(el.style.left), y: parseFloat(el.style.top), w: el.offsetWidth, h: el.offsetHeight })).sort((a, b) => a.y - b.y);+      for (let a = 0; a < vis.length; a++) for (let b = 0; b < a; b++) { const A = vis[a], B = vis[b]; if (Math.abs(A.x - B.x) < (A.w + B.w) / 2 && A.y < B.y + B.h + 2) { A.y = B.y + B.h + 2; A.el.style.top = A.y + 'px'; } }+      if (performance.now() - lastChart > 90) {+        lastChart = performance.now(); chart(f);+        const fv = panel.querySelector('#pdnFv'); if (fv) fv.textContent = fmtHz(f);+        const top = x.cols.map((c, bi) => ({ c, s: c.added ? 0 : base.share[bi] })).sort((a, b) => b.s - a.s)[0];+        const now = panel.querySelector('#pdnNow'); if (now && top) now.innerHTML = `${fmtHz(f)}: ${fmtO(base.z)} ${inBand ? 'against ' + fmtO(x.r.targetOhm) : '(outside the band the target covers)'}; <b style="color:var(--text)">${top.c.nm}</b> carries ${(top.s * 100).toFixed(0)} %${top.s > 1.05 ? ' (ringing)' : ''}`;+      }+    });+  });+  function apply(view) {+    knob.to = view.quantity === 'pdn' ? 1 : 0; panel.hidden = view.quantity !== 'pdn';+    if (view.quantity !== 'pdn') return;+    if (view.net) { const i = rails.findIndex(r => r.net === view.net); if (i >= 0) { sel.i = i; sel.picked = true; } }+    for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = Lname === 'F.Cu' ? 0.3 : 0; }+    const leg = document.getElementById('legend'); if (leg) leg.hidden = true;+  }+  function describe() {+    return ['<b>Power delivery</b>: each rail\'s impedance from its load pin, the frequency sweeping 1 kHz to 1 GHz',+      '<b>Columns</b> = each branch\'s share of the load\'s current at this frequency: <span style="color:#5cf08f">green</span> capacitors, <span style="color:#5a9aff">blue</span> the regulator or the supply cable, <span style="color:#c38bff">violet</span> the plane',+      'Over 100 % = two branches ringing against each other (an anti-resonance)',+      '<b>The glass sleeve</b> at the load pin = the target impedance (the rail\'s tolerance for its step current); the column inside is |Z|, green under the rim, red over',+      'A peak over the target is in the fix plan with the capacitor that brings it under; ngspice runs the same circuit'];+  }+  function narration() {+    if (!rails.length) return 'This is the Power view, but this board has no rail with a load pin and capacitors to check.';+    const say = (z) => z >= 1 ? `${z.toFixed(1)} ohms` : `${Math.round(z * 1000)} milliohms`;+    const hz = (f) => f >= 1e6 ? `${(f / 1e6).toFixed(f >= 1e7 ? 0 : 1)} megahertz` : `${Math.round(f / 1e3)} kilohertz`;+    const parts = rails.map(r => `${r.net}, seen from ${r.load.replace('.', ' pin ')}, peaks at ${say(r.worst.ohm)} near ${hz(r.worst.hz)}, ${r.worst.ratio > 1 ? `${r.worst.ratio.toFixed(1)} times its target, so the fix plan adds ${r.fixes.map(x => x.value.replace('uF', ' microfarad').replace('nF', ' nanofarad')).join(' and ')} right at that pin` : `${Math.round(r.worst.ratio * 100)} percent of its target of ${say(r.targetOhm)}`}${r.worst.inductive && r.worst.capacitive ? `; that peak is ${who(r.worst.inductive, r, r.worst.capacitive)}` : ''}.`);+    const mb = rails.find(r => r.middlebrook);+    function who(name, r, cap) { const b = r.branches.find(x => x.name === name); if (b && b.kind === 'cable') return `the supply leads into ${name.replace(' cable', '')} ringing against ${cap}`; if (b && b.kind === 'regulator') return `where the regulator's control loop hands the rail over to ${cap}`; return `${name} ringing against ${cap}`; }+    return `This is the Power view: how stiff each rail is, from slow changes to fast ones. A chip pulls current in steps, and the rail's impedance decides how far its voltage wobbles. Watch the frequency sweep: at the low end the regulator or the supply holds the rail, then the big capacitors take over, then the small ones nearest the pin. Each column is one part's share of the current. When a column shoots past a hundred percent, two parts are passing current back and forth: the rail is ringing. The glass sleeve at the load pin is the target; the column inside it is the impedance, green under the rim, red over it. ${parts.join(' ')}${mb && mb.middlebrook ? (mb.middlebrook.ok ? ` The input also passes the stability check: its network stays ${Math.round(mb.middlebrook.marginDb)} decibels under the converter's negative input resistance, so the converter cannot ring with its own input.` : ` The input fails the stability check: its network comes within ${Math.round(mb.middlebrook.marginDb)} decibels of the converter's negative input resistance, so the fix plan adds a damping capacitor.`) : ''} ngspice runs the same circuit as a cross-check, and every finding goes into the fix plan for the AI.`;+  }+  console.log(`pdn: ${rails.map(r => `${r.net} ${r.worst.ratio}× at ${r.worst.hz} Hz`).join(', ')}`);+  return { apply, describe, narration };+}