app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
adom-fields 0.7.16: the community's issues. #10 plated pads only on real copper, stackup from the board; #11 six-layer boards and a solver that refines to the true residual; #8 leaded-part heat and an honest no-heat-source status; #9 the RF review: cavities on every layer pair, a receiver frequency plan, crosstalk in the victim's impedance, circuit paths to the antenna, return paths for every net, stage feeds
22 files changed
+534−127
Cargo.lock+1−1Cargo.toml+1−1SKILL.md+7bin/adom-fieldsdocs/release-0.7.16.md+27package.json+10−2page.json+8−15skills/adom-fields/SKILL.md+7src/board.rs+86−3src/emc.rs+92−13src/fields.rs+77−15src/flow.rs+7src/main.rs+34−7src/noise.rs+100−42src/pdn.rs+4−3src/signals.rs+9−6src/solve.rs+19−1src/ui/app.js+13−4src/ui/flow.js+4−1src/ui/ground.js+1−1src/ui/index.html+1−1src/ui/noise.js+26−11Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fields"-version = "0.7.15"+version = "0.7.16" dependencies = [ "clap", "png",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.15"+version = "0.7.16" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."
SKILL.md+7@@ -163,6 +163,13 @@ After applying a step, run `analyze` again: the issue goes away or says how far `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. +## The copper stack, receivers and RF (0.7.16)++- Boards of two, four or six copper layers; plated pads only on real copper. `stackupMm` is one dielectric per real gap (else the board's stackup, else its thickness); `epsilonR` when the stackup has none. fields.json: `board.copperLayers`, `board.dielectricMm`, `board.stackupFrom`.+- `hot.<part>.tabNet` is optional: a leaded part's watts go through all its leads by pad area. With no `hot`, the temperature is only copper I²R and the output says it is not a thermal pass.+- `clocks`: `{net: Hz}` or `{net: {hz | minHz, maxHz, harmonics, amplitudeV}}` (a tuned LO). `bands`: `{name: {minHz, maxHz, ifHz, lowSideLo}}` for the frequency plan (`tone-in-band`, the band and its image). `sensitiveNets`: nets an oscillator must not reach (`circuit-path` issues with the parts on the way). `victimBudgetsMv`: a budget per victim net (µV for RF/IF inputs).+- Crosstalk is in the victim's impedance (its capacitors and resistors to ground or rails), sine for oscillators, step for switch nodes. Return path (`no-reference`) for every non-rail net on every layer: over ground, a supply plane or nothing. `antenna-ground-loop`, `stage-feed-unbypassed`, `solve-unconverged` are new kinds; `review.stageFeeds`, `review.noise.frequencyPlan`, `review.noise.circuitPaths`.+ ## 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.16.mdadded+27@@ -0,0 +1,27 @@+# adom-fields 0.7.16++Built from the community's issues: #10 (senthilkumaranms), #9 (Noah's RF review of the AM/FM receiver), #11 and #8.++**The copper stack (#10, #11, #8)**+- A plated pad goes only onto copper the board has: a two-layer board solves on F.Cu and B.Cu, never on phantom inner layers.+- Two-, four- and six-layer boards. The stack is checked (F.Cu, In1.Cu .. In<n>.Cu, B.Cu, contiguous); more than six layers is refused by name.+- The dielectric comes from the spec (one value per real gap; three values on a two-layer board are summed, the old form), else the board's own saved stackup, else its thickness. εr per gap comes from the stackup (spec `epsilonR` otherwise). fields.json reports `copperLayers`, `dielectricMm` (the real gaps) and `stackupFrom`.+- The solver refines its answer until the true residual meets the target (CG's own residual drifts on badly conditioned nets), and copper pieces with terminals but no path to the rest are referenced on their own instead of stalling the solve. A net that still does not converge is a `solve-unconverged` issue and its numbers are kept out of the review.++**Heat (#8)**+- `hot.<part>.tabNet` is optional: a leaded part with no exposed tab puts its watts through all its leads, by pad area.+- With no heat sources modelled, fields.json and the hints say the temperature is only the copper's I²R loss, not a thermal pass for any part.++**RF and receivers (#9)**+- Plane cavities on every adjacent layer pair in both directions, each with its own gap and εr (the inner planes were never examined before).+- `clocks` take a frequency or `{hz | minHz + maxHz, harmonics, amplitudeV}`: a tuned LO sweeps. A crystal or an LC oscillator counts to its 5th harmonic as a strong tone.+- **Frequency plan** (`bands`: `{name: {minHz, maxHz, ifHz, lowSideLo}}`): every harmonic of every clock, weak ones included, checked against each receive band and its image: `tone-in-band`.+- **Crosstalk** in the victim's real impedance: an oscillator is a sine at its amplitude and frequency, V = A·ωCm / |jω(Cm + Cg) + G|, with Cg including the capacitors on the victim net and G its resistors to ground or a rail; the victim's own plane gap; side coupling out to 3 mm with plane shielding; overlap on every adjacent layer pair. Per-net budgets (`victimBudgetsMv`) and µV reporting.+- **Circuit paths** (`sensitiveNets`): each path through the parts from an oscillator to a sensitive net (an antenna, an IF input) is reported, for a simulation.+- **Return path** for every net: over ground, over a supply plane (its return rides the bypass capacitors) or over nothing; switching, clock, signal-pair and sensitive nets medium.+- **Antenna ground loop**: a cable connector's ground on the board near the antenna connector, with the common-mode fix.+- **Stage feeds**: each transistor stage's supply resistor and its bypass capacitors (corner frequency, self-resonance); a feed with no bypass is an issue.+- Stitching counts plated ground pads and holes as fence posts, and sizes the fence from the board's own tones.+- An RF input's ESD advice carries a capacitance budget; per-layer lengths on differential pairs show why a pair is not one.+- Ground is solved even when the spec lists only the supplies (the largest supply load as the return current, stated); `+3.3VA` reads as 3.3 V.+- The Noise view uses the solve's budgets, µV units and converter-free wording; each view's tags and panel show only in that view, even from a background tab.
package.json+10−2@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.15",+ "version": "0.7.16", "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.",@@ -90,7 +90,15 @@ "copper capacitance", "switch node capacitance", "plane capacitance",- "differential pair skew"+ "differential pair skew",+ "six layer board",+ "receiver frequency plan",+ "lo leakage",+ "tone in band",+ "rf crosstalk",+ "return path over supply plane",+ "antenna ground loop",+ "stage feed bypass" ], "sample_prompts": [ {
page.json+8−15@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.15",+ "version": "0.7.16", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",@@ -17,10 +17,7 @@ "vias", "3d", "analysis",- "eda",- "current flow animation",- "voltage",- "river"+ "eda" ], "status": "live", "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.",@@ -40,10 +37,11 @@ "fields", "flow of current", "river view",- "voltage drop in 3d",- "ground bounce",+ "energy flow on a board",+ "poynting vector",+ "speed of light on a pcb",+ "electron drift speed", "exploded layer stack",- "current through vias", "explain the view out loud", "narrate the fields", "energy cycle",@@ -85,12 +83,7 @@ "anti-resonance", "input filter stability", "middlebrook",- "ngspice pdn",- "signals view",- "copper capacitance",- "switch node capacitance",- "plane capacitance",- "differential pair skew"+ "ngspice pdn" ], "sample_prompts": [ {@@ -131,4 +124,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-fields/SKILL.md+7@@ -163,6 +163,13 @@ After applying a step, run `analyze` again: the issue goes away or says how far `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. +## The copper stack, receivers and RF (0.7.16)++- Boards of two, four or six copper layers; plated pads only on real copper. `stackupMm` is one dielectric per real gap (else the board's stackup, else its thickness); `epsilonR` when the stackup has none. fields.json: `board.copperLayers`, `board.dielectricMm`, `board.stackupFrom`.+- `hot.<part>.tabNet` is optional: a leaded part's watts go through all its leads by pad area. With no `hot`, the temperature is only copper I²R and the output says it is not a thermal pass.+- `clocks`: `{net: Hz}` or `{net: {hz | minHz, maxHz, harmonics, amplitudeV}}` (a tuned LO). `bands`: `{name: {minHz, maxHz, ifHz, lowSideLo}}` for the frequency plan (`tone-in-band`, the band and its image). `sensitiveNets`: nets an oscillator must not reach (`circuit-path` issues with the parts on the way). `victimBudgetsMv`: a budget per victim net (µV for RF/IF inputs).+- Crosstalk is in the victim's impedance (its capacitors and resistors to ground or rails), sine for oscillators, step for switch nodes. Return path (`no-reference`) for every non-rail net on every layer: over ground, a supply plane or nothing. `antenna-ground-loop`, `stage-feed-unbypassed`, `solve-unconverged` are new kinds; `review.stageFeeds`, `review.noise.frequencyPlan`, `review.noise.circuitPaths`.+ ## 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/board.rs+86−3@@ -1,7 +1,8 @@ //! The board as the solver needs it: outline, filled copper per net and layer, tracks, pads, vias. use crate::sexp::{parse, Sx}; -pub const LAYERS: [&str; 4] = ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"];+/// the copper slots, top to bottom: boards up to six layers (a two- or four-layer board leaves the inner slots it lacks empty)+pub const LAYERS: [&str; 6] = ["F.Cu", "In1.Cu", "In2.Cu", "In3.Cu", "In4.Cu", "B.Cu"]; #[derive(Debug, Clone)] pub struct Poly { pub net: String, pub layer: String, pub pts: Vec<(f64, f64)> }@@ -41,6 +42,13 @@ pub struct Board { /// reads the technology from them: an electrolytic's Value is just "47uF 35V") pub mpns: std::collections::BTreeMap<String, String>, pub footprints: std::collections::BTreeMap<String, String>,+ /// the copper layers the board declares (a two-layer board has no In1/In2: pads never spread onto them)+ pub copper: Vec<String>,+ /// the dielectric between each pair of consecutive real copper layers, top down, from the board's own stackup+ /// (else its overall thickness), when the file carries one+ pub stack_mm: Option<Vec<f64>>,+ /// εr of each of those gaps (thickness-weighted over its dielectric sublayers), when the stackup says+ pub stack_er: Option<Vec<f64>>, } fn rot(x: f64, y: f64, deg: f64) -> (f64, f64) { let a = deg.to_radians(); (x * a.cos() - y * a.sin(), x * a.sin() + y * a.cos()) }@@ -48,10 +56,30 @@ fn rot(x: f64, y: f64, deg: f64) -> (f64, f64) { let a = deg.to_radians(); (x * impl Board { pub fn load(path: &str) -> Result<Board, String> { let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;- if crate::eagle::is_eagle(&text) { let mut b = crate::eagle::load(&text)?; b.source = "fusion".into(); return Ok(b); }+ if crate::eagle::is_eagle(&text) { let mut b = crate::eagle::load(&text)?; b.source = "fusion".into(); b.copper = LAYERS.iter().filter(|l| b.layer_names.iter().any(|n| n == *l)).map(|l| l.to_string()).collect(); return Ok(b); } let root = parse(&text); let mut b = Board::default(); b.source = "kicad".into(); b.layer_names = root.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| x.atom(1).map(str::to_string)).collect()).unwrap_or_default();+ b.copper = LAYERS.iter().filter(|l| b.layer_names.iter().any(|n| n == *l)).map(|l| l.to_string()).collect();+ // the saved stackup: the dielectric thickness between each pair of consecutive copper layers+ if let Some(st) = root.kid("setup").and_then(|s| s.kid("stackup")) {+ let mut gaps: Vec<f64> = Vec::new(); let mut ers: Vec<f64> = Vec::new(); let (mut acc, mut acc_er) = (0.0, 0.0); let mut seen_cu = false;+ for l in st.kids("layer") {+ let name = l.atom(1).unwrap_or("");+ let t = l.kid("thickness").and_then(|x| x.num(1)).unwrap_or(0.0);+ if name.ends_with(".Cu") { if seen_cu { gaps.push(acc); ers.push(if acc > 0.0 && acc_er > 0.0 { acc_er / acc } else { 0.0 }); } acc = 0.0; acc_er = 0.0; seen_cu = true; }+ else if seen_cu && name.starts_with("dielectric") { acc += t; if let Some(e) = l.kid("epsilon_r").and_then(|x| x.num(1)) { acc_er += e * t; } }+ }+ if ers.len() == gaps.len() && !ers.is_empty() && ers.iter().all(|e| *e >= 1.0) { b.stack_er = Some(ers.clone()); }+ if !gaps.is_empty() && gaps.iter().all(|g| *g > 0.0) { b.stack_mm = Some(gaps); }+ }+ // no stackup saved: the board's overall thickness, less its copper, split the usual way+ if b.stack_mm.is_none() {+ if let Some(t) = root.kid("general").and_then(|g| g.kid("thickness")).and_then(|t| t.num(1)) {+ let ncu = if b.copper.is_empty() { 4 } else { b.copper.len() }; let d = t - 0.035 * ncu as f64;+ if d > 0.2 { b.stack_mm = Some(match ncu { 2 => vec![d], 4 => vec![0.2, (d - 0.4).max(0.1), 0.2], _ => { let c = ((d - 0.4) / 3.0).max(0.05); vec![0.2, c, c, c, 0.2] } }); }+ }+ } let net_names: std::collections::BTreeMap<String,String> = root.kids("net").iter().filter_map(|n|Some((n.atom(1)?.to_string(),n.atom(2)?.to_string()))).collect(); let net_name = |n: &Sx| { let value=n.last_atom().unwrap_or(""); net_names.get(value).cloned().unwrap_or_else(||value.to_string()) }; // outline: the Edge.Cuts graphics' bounding box@@ -102,7 +130,9 @@ impl Board { let kind = p.atom(2).unwrap_or("smd"); let shape = p.atom(3).unwrap_or("rect"); let lay: Vec<String> = p.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| if let Sx::Atom(a) = x { Some(a.clone()) } else { None }).collect()).unwrap_or_default();- let on: Vec<String> = if kind == "thru_hole" { LAYERS.iter().map(|s| s.to_string()).collect() } else { LAYERS.iter().filter(|l| lay.iter().any(|x| x == *l || x == "*.Cu")).map(|s| s.to_string()).collect() };+ // only onto copper the board has: a plated pad on a two-layer board is on F.Cu and B.Cu, never In1/In2+ let real = |l: &&&str| b.copper.is_empty() || b.copper.iter().any(|c| c == **l);+ let on: Vec<String> = if kind == "thru_hole" { LAYERS.iter().filter(real).map(|s| s.to_string()).collect() } else { LAYERS.iter().filter(real).filter(|l| lay.iter().any(|x| x == *l || x == "*.Cu")).map(|s| s.to_string()).collect() }; let mut area = sw * sh; for l in &on { let mut shapes: Vec<Vec<(f64, f64)>> = Vec::new();@@ -143,3 +173,56 @@ fn stadium_poly(x: f64, y: f64, w: f64, h: f64, deg: f64) -> Vec<(f64, f64)> { let ang = if w >= h { deg } else { deg + 90.0 }; pts.into_iter().map(|(px, py)| { let (qx, qy) = rot(px, -py, ang); (x + qx, y - qy) }).collect() }++#[cfg(test)] mod layer_tests {+ use super::*;+ // issue 10 (senthilkumaranms): a plated pad on a two-layer board is on F.Cu and B.Cu only+ fn fixture(layers: &str, stackup: &str) -> Board {+ let txt = format!(r#"(kicad_pcb (version 20260206) (generator pcbnew)+ (general (thickness 1.6)) (layers {layers}) {stackup}+ (gr_rect (start 0 0) (end 10 10) (stroke (width 0.05) (type default)) (fill none) (layer "Edge.Cuts"))+ (footprint "Connector" (layer "F.Cu") (at 2 5)+ (property "Reference" "J1" (at 0 0) (layer "F.SilkS"))+ (pad "1" thru_hole circle (at 0 0) (size 2 2) (drill 0.8) (layers "*.Cu" "*.Mask") (net "3V3")))+ (segment (start 2 5) (end 8 5) (width 1) (layer "F.Cu") (net "3V3")))"#);+ let p = std::env::temp_dir().join(format!("adom-fields-layers-{}.kicad_pcb", layers.len() + stackup.len()));+ std::fs::write(&p, txt).unwrap(); let b = Board::load(p.to_str().unwrap()).unwrap(); let _ = std::fs::remove_file(&p); b+ }+ #[test] fn two_layer_pads_stay_on_real_copper() {+ let b = fixture(r#"(0 "F.Cu" signal) (2 "B.Cu" signal)"#, "");+ assert_eq!(b.pads[0].layers, vec!["F.Cu".to_string(), "B.Cu".to_string()]);+ let s = b.stack_mm.unwrap(); assert_eq!(s.len(), 1); assert!((s[0] - 1.53).abs() < 1e-6);+ }+ #[test] fn four_layer_pads_and_saved_stackup() {+ let st = r#"(setup (stackup (layer "F.Cu" (type "copper") (thickness 0.035)) (layer "dielectric 1" (type "prepreg") (thickness 0.21)) (layer "In1.Cu" (type "copper") (thickness 0.0152)) (layer "dielectric 2" (type "core") (thickness 1.065)) (layer "In2.Cu" (type "copper") (thickness 0.0152)) (layer "dielectric 3" (type "prepreg") (thickness 0.21)) (layer "B.Cu" (type "copper") (thickness 0.035))))"#;+ let b = fixture(r#"(0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (2 "B.Cu" signal)"#, st);+ assert_eq!(b.pads[0].layers.len(), 4);+ assert_eq!(b.stack_mm, Some(vec![0.21, 1.065, 0.21]));+ }+}++/// The copper layers in physical order (F.Cu, In1.Cu .. In<n>.Cu, B.Cu, contiguous) or an error naming what is wrong.+pub fn check_copper(copper: &[String], declared: &[String]) -> Result<(), String> {+ let extra: Vec<&String> = declared.iter().filter(|n| n.ends_with(".Cu") && !LAYERS.contains(&n.as_str())).collect();+ if !extra.is_empty() { return Err(format!("this board has copper layers past six ({}): boards up to six copper layers are supported", extra.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))); }+ if copper.is_empty() { return Ok(()); }+ let inner: Vec<usize> = copper.iter().filter_map(|c| c.strip_prefix("In").and_then(|x| x.strip_suffix(".Cu")).and_then(|x| x.parse().ok())).collect();+ if !copper.iter().any(|c| c == "F.Cu") || !copper.iter().any(|c| c == "B.Cu") || inner.iter().enumerate().any(|(i, n)| *n != i + 1) || copper.len() % 2 != 0 { return Err(format!("the copper layers {} are not a stack this solve supports: F.Cu, In1.Cu .. In<n>.Cu in order, B.Cu, an even count", copper.join(", "))); }+ Ok(())+}++#[cfg(test)] mod stack_tests {+ use super::*;+ fn v(x: &[&str]) -> Vec<String> { x.iter().map(|s| s.to_string()).collect() }+ // issue 11: six layers in order pass, a gap or an odd count fails, more than six is named+ #[test] fn copper_stacks() {+ assert!(check_copper(&v(&["F.Cu", "In1.Cu", "In2.Cu", "In3.Cu", "In4.Cu", "B.Cu"]), &v(&["F.Cu", "B.Cu", "In1.Cu", "In2.Cu", "In3.Cu", "In4.Cu"])).is_ok());+ assert!(check_copper(&v(&["F.Cu", "In2.Cu", "B.Cu"]), &[]).is_err());+ assert!(check_copper(&v(&["F.Cu", "In1.Cu", "B.Cu"]), &[]).is_err());+ assert!(check_copper(&v(&["F.Cu", "B.Cu"]), &v(&["F.Cu", "In5.Cu", "B.Cu"])).is_err());+ }+ #[test] fn four_layer_gaps_spread_over_slots() {+ let s = crate::fields::spread(&[0.2, 1.065, 0.21], &v(&["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]));+ assert_eq!(s.len(), 5); assert!((s[0] - 0.2).abs() < 1e-12 && (s[1] - 1.065).abs() < 1e-12 && (s[2] - 0.07).abs() < 1e-12 && (s[4] - 0.07).abs() < 1e-12);+ }+}
src/emc.rs+92−13@@ -110,22 +110,58 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va 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 gid = gnd.as_ref().and_then(|g| f.net_names.iter().position(|x| x == g)).map(|i| i as u16); let mut refs_out = Vec::new();- if let (Some(g), true) = (gid, real.len() >= 2) {- let (k0, k1) = (real[0], real[1]);- let mut per: std::collections::BTreeMap<u16, (usize, usize, f64, f64)> = Default::default();- for i in 0..n { let id = f.net_ids[k0][i]; if id == u16::MAX || id == g { continue; } let name = &f.net_names[id as usize]; if !(f.flow.contains_key(name) || spec.switching.contains_key(name)) { continue; } let e = per.entry(id).or_default(); if f.net_ids[k1][i] == g { e.0 += 1; } else { e.1 += 1; e.2 += f.x0 + ((i % w) as f64 + 0.5) * f.d; e.3 += f.y0 + ((i / w) as f64 + 0.5) * f.d; } }- for (id, (on, off, sx, sy)) in per {- let name = f.net_names[id as usize].clone(); let pct = off as f64 / (on + off).max(1) as f64 * 100.0; let mm2 = off as f64 * f.d * f.d;- let switching = spec.switching.contains_key(&name) || is_switch_name(&name);- refs_out.push(json!({"net": name, "unreferencedPct": (pct * 10.0).round() / 10.0, "unreferencedMm2": (mm2 * 10.0).round() / 10.0}));- if (switching && pct > 5.0) || pct > 20.0 { let at = [sx / off.max(1) as f64, sy / off.max(1) as f64]; issues.push(issue(if switching { "medium" } else { "low" }, "no-reference", format!("{}: {:.0} % of its top copper ({:.1} mm²) has no {} under it", name, pct, mm2, gnd.clone().unwrap_or_default()), Some(at), Some(&name), None, format!("Pour {} on {} under {} around ({:.1}, {:.1}), or route it over solid ground, so its return current does not detour.", gnd.clone().unwrap_or_default(), LAYERS[k1], name, at[0], at[1]), json!({"type": "pour", "net": gnd, "layer": LAYERS[k1], "under": name, "at": at}))); }+ // what each net's copper sits over (issue 9): on each layer, the nearest real copper layers; ground there is a return+ // path, a supply plane is not (the return rides it through bypass capacitors), nothing at all is a void. Every net+ // but ground and the rails is checked; switching nodes, clocks, signal pairs and sensitive nets are medium.+ if let Some(g) = gid {+ let gids: Vec<u16> = f.net_names.iter().enumerate().filter(|(_, x)| is_ground(x)).map(|(i, _)| i as u16).collect();+ let important = |name: &str| spec.switching.contains_key(name) || is_switch_name(name) || spec.clocks.contains_key(name) || spec.sensitiveNets.iter().any(|x| x == name) || spec.signals.differential_pairs.iter().any(|p| p.positive == name || p.negative == name);+ let mut per: std::collections::BTreeMap<u16, (usize, usize, usize, f64, f64, std::collections::BTreeMap<String, usize>, Vec<String>)> = Default::default();+ // a net's own pads and via rings sit in the planes' clearance holes: those cells are not its route+ let mut own: std::collections::BTreeMap<String, Vec<(f64, f64, f64)>> = Default::default();+ for p in &b.pads { own.entry(p.net.clone()).or_default().push((p.x, p.y, (p.area / std::f64::consts::PI).sqrt() + 0.35)); }+ for v in &b.vias { own.entry(v.net.clone()).or_default().push((v.x, v.y, v.size / 2.0 + 0.35)); }+ for (ri, &k) in real.iter().enumerate() {+ let nbrs: Vec<usize> = [ri.checked_sub(1), Some(ri + 1)].into_iter().flatten().filter_map(|j| real.get(j).copied()).collect();+ for i in 0..n {+ let id = f.net_ids[k][i]; if id == u16::MAX || gids.contains(&id) { continue; }+ let name = &f.net_names[id as usize]; if rail(name).is_some() && !spec.switching.contains_key(name) { continue; }+ let (cx, cy) = (f.x0 + ((i % w) as f64 + 0.5) * f.d, f.y0 + ((i / w) as f64 + 0.5) * f.d);+ if own.get(name).map_or(false, |v| v.iter().any(|(x, y, r)| (cx - x).hypot(cy - y) <= *r)) { continue; }+ let e = per.entry(id).or_insert((0, 0, 0, 0.0, 0.0, Default::default(), Vec::new()));+ let under: Vec<u16> = nbrs.iter().map(|&o| f.net_ids[o][i]).collect();+ if under.iter().any(|u| gids.contains(u)) { e.0 += 1; }+ else if let Some(sup) = under.iter().find(|u| **u != u16::MAX && rail(&f.net_names[**u as usize]).is_some()) { e.1 += 1; *e.5.entry(f.net_names[*sup as usize].clone()).or_default() += 1; e.3 += f.x0 + ((i % w) as f64 + 0.5) * f.d; e.4 += f.y0 + ((i / w) as f64 + 0.5) * f.d; if !e.6.contains(&LAYERS[k].to_string()) { e.6.push(LAYERS[k].to_string()); } }+ else { e.2 += 1; e.3 += f.x0 + ((i % w) as f64 + 0.5) * f.d; e.4 += f.y0 + ((i / w) as f64 + 0.5) * f.d; if !e.6.contains(&LAYERS[k].to_string()) { e.6.push(LAYERS[k].to_string()); } }+ }+ }+ let _ = g;+ for (id, (on, sup, void, sx, sy, sups, lays)) in per {+ let name = f.net_names[id as usize].clone(); let tot = (on + sup + void).max(1); let bad = sup + void;+ let mm = bad as f64 * f.d; // cells along a trace about one cell wide: millimetres of route+ let pct = bad as f64 / tot as f64 * 100.0;+ let sup_name = sups.iter().max_by_key(|x| x.1).map(|x| x.0.clone());+ refs_out.push(json!({"net": name, "unreferencedPct": (pct * 10.0).round() / 10.0, "unreferencedMm2": ((bad as f64 * f.d * f.d) * 10.0).round() / 10.0, "overSupplyMm": (sup as f64 * f.d * 10.0).round() / 10.0, "overVoidMm": (void as f64 * f.d * 10.0).round() / 10.0, "supplyPlane": sup_name, "layers": lays}));+ let imp = important(&name);+ // a poured net is copper area, not a route: say so, and flag it only when it matters (a switching or signal net)+ let poured = b.polys.iter().any(|p| p.net == name) || b.pours.iter().any(|p| p.net == name);+ let size = if poured { format!("{:.0} mm² of its copper", bad as f64 * f.d * f.d) } else { format!("about {:.0} mm of it", mm) };+ if (imp && (mm > 2.0 || poured)) || (!poured && mm > 15.0) {+ let at = [sx / bad.max(1) as f64, sy / bad.max(1) as f64];+ let what = match (&sup_name, sup >= void) { (Some(sp), true) => format!("{}: {} runs over the {} plane, not ground ({}), so its return current rides {} through the bypass capacitors", name, size, sp, lays.join(", "), sp), _ => format!("{}: {} runs over no plane at all ({}): its return current has no path under it", name, size, lays.join(", ")) };+ issues.push(issue(if imp { "medium" } else { "low" }, "no-reference", what, Some(at), Some(&name), None, format!("Route {} over solid ground: move it to the layer next to the ground plane, or pour ground under it; where it must change layers, put a ground via within 1 mm of the signal via.", name), json!({"type": "reroute", "net": name, "over": f.net_names.iter().find(|x| is_ground(x)), "why": "return path", "at": [r2(at[0]), r2(at[1])], "overSupplyMm": r2(sup as f64 * f.d), "overVoidMm": r2(void as f64 * f.d)})));+ } }+ refs_out.sort_by(|a, z| z["unreferencedMm2"].as_f64().partial_cmp(&a["unreferencedMm2"].as_f64()).unwrap()); } out["reference"] = json!(refs_out); // ---- ground: offset, islands, stitching, connectors to the outside ----------------------------------- if let (Some(gname), Some(g)) = (gnd.clone(), gid) { let mut gout = json!({"net": gname});- if let Some(nf) = f.flow.get(&gname) {+ // a ground solve that did not converge has no offset worth reporting (issue 9)+ let g_ok = f.nets.iter().find(|x| x.net == gname).map_or(true, |x| x.converged);+ if !g_ok { gout["offsetNote"] = json!("the ground current solve did not converge: no offset reported"); }+ if let Some(nf) = f.flow.get(&gname).filter(|_| g_ok) { let (mut lo, mut hi, mut at_hi) = (f64::MAX, f64::MIN, [0.0, 0.0]); for k in 0..LAYERS.len() { for i in 0..n { if nf.on[k].v[i] == 0.0 { continue; } let v = (nf.v_ext - nf.v[k].v[i] as f64).max(f64::MIN); if v < lo { lo = v; } if v > hi { hi = v; at_hi = [f.x0 + ((i % w) as f64 + 0.5) * f.d, f.y0 + ((i / w) as f64 + 0.5) * f.d]; } } } let span = (hi - lo).max(0.0);@@ -134,6 +170,16 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va if span * 1e3 > budget { issues.push(issue("medium", "ground-offset", format!("{}: {:.1} mV across the ground copper (budget {:.0} mV)", gname, span * 1e3, budget), Some(at_hi), Some(&gname), None, format!("Widen the ground path near ({:.1}, {:.1}) or add ground vias to the other layer there, so the return current has a lower-resistance way home.", at_hi[0], at_hi[1]), json!({"type": "add-vias", "net": gname, "at": at_hi, "why": "ground offset"}))); } let ext: Vec<&crate::flow::Terminal> = { let mut v: Vec<&crate::flow::Terminal> = nf.terminals.iter().filter(|t| t.side == "ext").collect(); v.dedup_by(|a, z| a.reference == z.reference); v }; gout["toOutside"] = json!(ext.iter().map(|t| t.reference.clone()).collect::<Vec<_>>());+ // the loop that matters on a receiver: a power or data cable's ground and the antenna's (issue 9)+ {+ let conns: Vec<String> = { let mut v: Vec<String> = b.pads.iter().filter(|p| p.reference.starts_with('J') || (p.reference.starts_with('P') && !p.reference.starts_with("PS"))).map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };+ let ant: Vec<&String> = conns.iter().filter(|r| b.pads.iter().any(|p| p.reference == **r && spec.sensitiveNets.iter().any(|s| *s == p.net))).collect();+ for aj in ant { let ap = b.pads.iter().find(|p| p.reference == *aj).unwrap();+ for cj in conns.iter().filter(|r| *r != aj && b.pads.iter().any(|p| p.reference == **r && rail(&p.net).map_or(false, |v| v > 0.0))) { let cp = b.pads.iter().find(|p| p.reference == *cj).unwrap(); let dist = (ap.x - cp.x).hypot(ap.y - cp.y);+ let data = b.pads.iter().any(|p| p.reference == *cj && rail(&p.net).is_none() && !is_ground(&p.net) && !p.net.is_empty() && !p.net.starts_with("unconnected-"));+ issues.push(issue("medium", "antenna-ground-loop", format!("{} brings a cable's ground onto the board {:.0} mm from the antenna connector {}: common-mode noise from that cable crosses the ground plane into the antenna's shield, which is part of the antenna", cj, dist, aj), Some([(ap.x + cp.x) / 2.0, (ap.y + cp.y) / 2.0]), Some(&gname), Some(cj), format!("Choke the cable at {}: a common-mode choke on its data lines{}, a ferrite on its supply, and its ground tied to the plane right at the connector; keep the antenna's ground return short and away from the {} return path.", cj, if data { "" } else { " (if it carries any)" }, cj), json!({"type": "add-part", "what": "common-mode choke and ferrite", "at": cj, "why": "cable common mode into the antenna ground", "antenna": aj, "distanceMm": r2(dist)}))); }+ }+ } if ext.len() > 1 { let (a, z) = (ext[0], ext[ext.len() - 1]); issues.push(issue("low", "ground-loop-risk", format!("{} connectors carry {} off the board ({}): cables that meet off the board close a loop", ext.len(), gname, ext.iter().map(|t| t.reference.clone()).collect::<Vec<_>>().join(", ")), Some([(a.x + z.x) / 2.0, (a.y + z.y) / 2.0]), Some(&gname), None, "Return each cable's ground on its own connector and route paired wires together (twisted or adjacent), or tie the grounds at one point.".into(), json!({"type": "review", "what": "ground cabling", "connectors": ext.iter().map(|t| t.reference.clone()).collect::<Vec<_>>()}))); } } // islands: ground copper with no via or pin to the main body@@ -151,8 +197,15 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va for (a, at) in &isl { issues.push(issue("low", "ground-island", format!("{}: a {:.1} mm² island of ground copper with no via or pin to the rest", gname, a), Some(*at), Some(&gname), None, format!("Add a ground via in the island at ({:.1}, {:.1}) to tie it to the plane, or remove it: floating copper acts as an antenna.", at[0], at[1]), json!({"type": "add-vias", "net": gname, "at": at, "count": 1, "why": "tie a floating island"}))); } } // the stitching fence along the board edge: lambda/20 at the highest frequency of interest- let fmax = spec.emcFmaxHz.unwrap_or(1e9); let er = 4.3; let lambda = 299.792458 / (fmax / 1e9) / ((er + 1.0) / 2.0f64).sqrt(); let pitch = lambda / 20.0; let reach = pitch / 2.0;- let gv: Vec<(f64, f64)> = b.vias.iter().filter(|v| v.net == gname).map(|v| (v.x, v.y)).collect();+ // the fence's top frequency: the spec's, else the board's own strongest tones (a converter's edge to 1 GHz; a+ // crystal or LC oscillator to its 5th harmonic, a logic clock to 1 GHz), else 1 GHz (issue 9)+ let fmax = spec.emcFmaxHz.unwrap_or_else(|| {+ if !spec.switching.is_empty() || spec.clocks.is_empty() { return 1e9; }+ spec.clocks.iter().map(|(net, c)| { let sine = b.pads.iter().any(|p| p.net == *net && (p.reference.starts_with('Y') || p.reference.starts_with('X'))) || !b.pads.iter().any(|p| p.net == *net && p.reference.starts_with('U')); if sine { 5.0 * c.top_hz() } else { 1e9 } }).fold(0.0, f64::max).min(1e9)+ });+ let er = crate::fields::gap_between(spec, b, 0, LAYERS.len() - 1).1; let lambda = 299.792458 / (fmax / 1e9) / ((er + 1.0) / 2.0f64).sqrt(); let pitch = lambda / 20.0; let reach = pitch / 2.0;+ // fence posts: ground vias, and plated through-hole ground pads (machine pins, mounting holes: issue 9)+ let gv: Vec<(f64, f64)> = b.vias.iter().filter(|v| v.net == gname).map(|v| (v.x, v.y)).chain(b.pads.iter().filter(|p| p.net == gname && p.layers.len() >= 2).map(|p| (p.x, p.y))).collect(); let (bw, bh) = (b.width(), b.height()); let per = 2.0 * (bw + bh); let step = 0.4; let mut s = 0.0; let mut gaps: Vec<Vec<(f64, f64)>> = Vec::new(); let mut run: Vec<(f64, f64)> = Vec::new(); let (mut cov, mut tot) = (0usize, 0usize); while s < per { let (x, y) = if s < bw { (b.x0 + s, b.y0) } else if s < bw + bh { (b.x0 + bw, b.y0 + s - bw) } else if s < 2.0 * bw + bh { (b.x0 + bw - (s - bw - bh), b.y0 + bh) } else { (b.x0, b.y0 + bh - (s - 2.0 * bw - bh)) }; let ok = gv.iter().any(|v| (v.0 - x).hypot(v.1 - y) <= reach + 1.0); tot += 1; if ok { cov += 1; if run.len() > 1 { gaps.push(std::mem::take(&mut run)); } run.clear(); } else { run.push((x, y)); } s += step; }@@ -189,6 +242,31 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va } } out["decoupling"] = json!(dec);+ // ---- discrete stage feeds (issue 9): a rail through a series resistor or inductor into a net that feeds a+ // transistor stage; its bypass capacitors to ground, the filter corner, and the caps' self-resonance+ let mut feeds = Vec::new();+ if let Some(gname) = gnd.clone() {+ let parse_ohm = |val: &str| -> Option<f64> { let u = val.to_ascii_uppercase().replace("OHM", "").replace('Ω', ""); let t = u.trim(); if let Some(i) = t.find(|c: char| c == 'K' || c == 'M' || c == 'R') { let m = match &t[i..i + 1] { "K" => 1e3, "M" => 1e6, _ => 1.0 }; format!("{}.{}", &t[..i], &t[i + 1..]).trim_end_matches('.').parse::<f64>().ok().map(|v| v * m) } else { t.parse().ok() } };+ for r in refs.iter().filter(|r| r.starts_with('R') || r.starts_with('L') || r.starts_with("FB")) {+ let ns = nets_of(r); if ns.len() != 2 { continue; }+ let (rl, feed) = match (rail(&ns[0]).filter(|v| *v > 0.0), rail(&ns[1]).filter(|v| *v > 0.0)) { (Some(_), None) => (ns[0].clone(), ns[1].clone()), (None, Some(_)) => (ns[1].clone(), ns[0].clone()), _ => continue };+ if is_ground(&feed) || feed.starts_with("unconnected-") { continue; }+ let val = b.values.get(r).cloned().unwrap_or_default();+ let ser = if r.starts_with('R') { match parse_ohm(&val) { Some(o) if o <= 470.0 => o, _ => continue } } else { 1.0 };+ // it feeds a stage: a transistor on this net, or an inductor from it to a transistor+ let stage = refs.iter().find(|q| q.starts_with('Q') && (nets_of(q).contains(&feed) || refs.iter().any(|l| l.starts_with('L') && nets_of(l).contains(&feed) && nets_of(l).iter().any(|n| nets_of(q).contains(n) && *n != feed)))).cloned();+ let Some(stage) = stage else { continue };+ let caps: Vec<(String, f64)> = refs.iter().filter(|c| c.starts_with('C') && nets_of(c).contains(&feed) && nets_of(c).contains(&gname)).filter_map(|c| b.values.get(c).and_then(|v| parse_value(v, 'F')).map(|v| (c.clone(), v))).collect();+ let ctot: f64 = caps.iter().map(|c| c.1).sum();+ let fc = if ctot > 0.0 && r.starts_with('R') { Some(1.0 / (2.0 * std::f64::consts::PI * ser * ctot)) } else { None };+ let srf: Vec<Value> = caps.iter().map(|(c, v)| { let span = { let pv = pads_of(c, &feed); let pg = pads_of(c, &gname); match (pv.first(), pg.first()) { (Some(a), Some(z)) => (a.x - z.x).hypot(a.y - z.y), _ => 1.0 } }; let esl = (0.15 + 0.3 * span + 0.5) * 1e-9; json!({"cap": c, "uF": (v * 1e9).round() / 1e3, "srfMHz": ((1.0 / (2.0 * std::f64::consts::PI * (v * esl).sqrt())) / 1e4).round() / 100.0}) }).collect();+ let at = pads_of(r, &feed).first().map(|p| [r2(p.x), r2(p.y)]);+ feeds.push(json!({"feed": feed, "rail": rl, "series": r, "seriesValue": val, "stage": stage, "caps": srf, "cornerHz": fc.map(|x| x.round())}));+ if caps.is_empty() { issues.push(issue("medium", "stage-feed-unbypassed", format!("{} feeds {} from {} through {} ({}) with no capacitor to {} on it: the stage's signal rides the rail into every other stage on {}", feed, stage, rl, r, val, gname, rl), at, Some(&feed), Some(r), format!("Add a 100 nF plus a small high-frequency ceramic (1 nF for an IF stage, 100 pF for a VHF stage) from {} to {} at {}'s end, each with its own ground via.", feed, gname, stage), json!({"type": "add-part", "what": "capacitor", "value": "100nF", "also": "1nF or 100pF for the stage frequency", "between": [feed, gname], "near": stage, "maxDistMm": 2.0, "why": "stage feed bypass"}))); }+ }+ }+ feeds.sort_by(|a, z| a["rail"].as_str().cmp(&z["rail"].as_str()));+ out["stageFeeds"] = json!(feeds); // ---- ESD: each connector pin to the outside, protected or not ---------------------------------------- let mut esd = Vec::new(); if let Some(gname) = gnd.clone() {@@ -201,7 +279,8 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va let prot = protector(&p.net); let sig = rail(&p.net).is_none(); esd.push(json!({"connector": jref, "pin": p.pad, "net": p.net, "at": [r2(p.x), r2(p.y)], "protectedBy": prot, "signal": sig}));- if prot.is_none() { issues.push(issue("low", "esd-unprotected", format!("{} pin {} ({}): no protection diode from {} to {}", jref, p.pad, p.net, p.net, gname), Some([p.x, p.y]), Some(&p.net), Some(jref), format!("If {} faces the outside world, add a TVS{} from {} to {} within 3 mm of the pin, before any chip.", jref, if sig { " (low capacitance, for a signal)" } else { " rated above the rail" }, p.net, gname), json!({"type": "add-part", "what": "TVS diode", "between": [p.net.clone(), gname.clone()], "nearPin": format!("{}.{}", jref, p.pad), "maxDistMm": 3.0, "signal": sig}))); }+ let rf = spec.sensitiveNets.iter().any(|x| *x == p.net);+ if prot.is_none() { issues.push(issue("low", "esd-unprotected", format!("{} pin {} ({}): no protection diode from {} to {}", jref, p.pad, p.net, p.net, gname), Some([p.x, p.y]), Some(&p.net), Some(jref), if rf { format!("{} is an RF input: protect it with a part of 0.5 pF or less at the band's top frequency (about 3 dB loss budget into 50 Ω), placed within 3 mm of {} and before the first stage; check its junction for intermodulation with strong stations, or use a gas discharge tube or a shunt inductor to ground where the band allows.", p.net, jref) } else { format!("If {} faces the outside world, add a TVS{} from {} to {} within 3 mm of the pin, before any chip.", jref, if sig { " (low capacitance, for a signal)" } else { " rated above the rail" }, p.net, gname) }, json!({"type": "add-part", "what": "TVS diode", "between": [p.net.clone(), gname.clone()], "nearPin": format!("{}.{}", jref, p.pad), "maxDistMm": 3.0, "signal": sig}))); } } } }
src/fields.rs+77−15@@ -34,19 +34,32 @@ pub struct Spec { /// the design review's budgets: ground offset (mV, 10), IR drop per rail (% of the rail, 1), the highest EMC frequency (Hz, 1 GHz) #[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>,+ /// each clock or oscillator: a frequency (Hz), or {hz | minHz + maxHz (a tuned LO), harmonics, amplitudeV}+ #[serde(default)] pub clocks: BTreeMap<String, Clock>, #[serde(default)] pub crosstalkMv: Option<f64>,+ /// a coupled-noise budget per victim net (mV), over crosstalkMv: an RF or IF input's is microvolts+ #[serde(default)] pub victimBudgetsMv: BTreeMap<String, f64>,+ /// the receive bands for the frequency plan: {name: {minHz, maxHz, ifHz (its image is the band + 2 IF), lowSideLo}}+ #[serde(default)] pub bands: BTreeMap<String, Band>,+ /// nets a stray tone must not reach (an antenna port, an IF input): circuit paths to them from each oscillator are reported+ #[serde(default)] pub sensitiveNets: Vec<String>, /// 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>, /// a folder of measured capacitor curves (Murata SimSurfing JSON: C-DC bias, |Z|, R) keyed by MPN; else ADOM_FIELDS_CAP_CURVES #[serde(default)] pub capCurves: Option<String>,+ /// the dielectric's εr when the board's stackup does not say (4.3)+ #[serde(default)] pub epsilonR: Option<f64>,+ /// set by analyze: εr per slot gap (from the board's stackup), the real gaps as given, and the real copper layers+ #[serde(skip)] pub er_slots: Option<Vec<f64>>, #[serde(skip)] pub real_gaps: Vec<f64>, #[serde(skip)] pub real_copper: Vec<String>, } fn d35() -> f64 { 35.0 } fn d60() -> f64 { 60.0 } #[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64> }-#[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, pub tabNet: String }+/// a part's dissipation: through its exposed tab's net (tabNet), or, for a leaded part with no tab, through all its leads,+/// shared by pad area (issue 8)+#[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, #[serde(default)] pub tabNet: String } #[derive(Serialize, Clone)]-pub struct NetResult { pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize }+pub struct NetResult { pub residual: f64, pub converged: bool, pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize } #[derive(Serialize, Clone)] pub struct ChipResult { pub reference: String, pub watts: f64, pub tabNet: String, pub x: f64, pub y: f64, pub riseC: f64, pub riseMovingC: f64, pub planeW: f64, pub viaW: f64, pub vias: usize, pub otherViaW: f64, pub otherVias: usize, pub dielW: f64, pub airW: f64, pub viasWanted: usize } #[derive(Serialize, Clone)]@@ -169,8 +182,21 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu if !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; pinned_at = Some(a * n + r * w + c); } } if hs == 0 || hk == 0 { log(&format!("{net}: terminals not on copper (sources {hs}, sinks {hk}), skipped")); continue; }- let (v, it, res) = sys.solve(&rhs, 1e-7, 6000);- log(&format!("{net}: {} A, {} layer(s), {it} iterations, residual {res:.1e}", load.amps, nl));+ // copper pieces with terminal current but no path to the pinned spot float on the tiny leak and stall the solve+ // (issue 9: GND 6000 iterations at 1.7e-1): give each such piece its own reference+ let mut islands = 0usize;+ {+ let nn = sys.n(); let mut par: Vec<usize> = (0..nn).collect();+ fn find(p: &mut Vec<usize>, mut x: usize) -> usize { while p[x] != x { p[x] = p[p[x]]; x = p[x]; } x }+ let mut uni = |p: &mut Vec<usize>, a: usize, b: usize| { let (ra, rb) = (find(p, a), find(p, b)); if ra != rb { p[ra] = rb; } };+ for k in 0..sys.nl { for r in 0..h { for c in 0..w - 1 { if sys.gx[k][r * (w - 1) + c] > 0.0 { uni(&mut par, k * n + r * w + c, k * n + r * w + c + 1); } } } for r in 0..h - 1 { for c in 0..w { if sys.gy[k][r * w + c] > 0.0 { uni(&mut par, k * n + r * w + c, k * n + (r + 1) * w + c); } } } }+ for k in 0..sys.nl.saturating_sub(1) { for i in 0..n { if sys.gz[k][i] > 0.0 { uni(&mut par, k * n + i, (k + 1) * n + i); } } }+ for &(a, b, g) in &sys.edges.clone() { if g > 0.0 { uni(&mut par, a, b); } }+ if let Some(pa) = pinned_at { let root = find(&mut par, pa); let mut done: std::collections::BTreeSet<usize> = Default::default(); done.insert(root);+ for &sp in ext_spots.iter().chain(chip_spots.iter()) { let rt = find(&mut par, sp); if done.insert(rt) { let (a, i) = (sp / n, sp % n); if a < sys.nl { sys.leak[a][i] = 1e3; islands += 1; } } } }+ }+ let (v, it, res) = sys.solve(&rhs, 1e-7, 20000);+ log(&format!("{net}: {} A, {} layer(s), {it} iterations, residual {res:.1e}{}{}", load.amps, nl, if islands > 0 { format!(", {islands} copper piece(s) with terminals but no path to the rest, each referenced on its own") } else { String::new() }, if res > 1e-4 { " NOT CONVERGED" } else { "" })); let mut grids: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect(); let (mut peak, mut peak_at, mut peak_layer) = (0.0f64, [0.0, 0.0], String::new()); let mut joule = 0.0;@@ -226,7 +252,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu let mean = |s: &[usize]| { let r: Vec<f64> = s.iter().filter(|i| reach[**i]).map(|i| v[*i]).collect(); if r.is_empty() { 0.0 } else { r.iter().sum::<f64>() / r.len() as f64 } }; nf.v_ext = mean(&ext_spots); nf.v_chip = mean(&chip_spots); nf.terminals = terminals; nf.amps = load.amps; flow.insert(net.clone(), nf);- nets.push(NetResult { net: net.clone(), amps: load.amps, layers: live.iter().map(|k| LAYERS[*k].to_string()).collect(), peakJ: (peak * 10.0).round() / 10.0, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it });+ nets.push(NetResult { residual: res, converged: res <= 1e-4, net: net.clone(), amps: load.amps, layers: live.iter().map(|k| LAYERS[*k].to_string()).collect(), peakJ: (peak * 10.0).round() / 10.0, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it }); j_net.insert(net.clone(), grids); } let joule_total: f64 = nets.iter().map(|x| x.jouleW).sum();@@ -251,8 +277,8 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu } } // ---- heat: four layers, the dielectric and the vias between them, the air on the faces -------- let nl = LAYERS.len();- let t_diel: Vec<f64> = if spec.stackupMm.len() == 3 { spec.stackupMm.clone() } else { vec![0.2, 1.065, 0.2] };- let share = [t_diel[0] / 2.0, t_diel[0] / 2.0 + t_diel[1] / 2.0, t_diel[1] / 2.0 + t_diel[2] / 2.0, t_diel[2] / 2.0];+ let t_diel: Vec<f64> = slot_gaps(spec);+ let share: Vec<f64> = (0..LAYERS.len()).map(|k| (if k > 0 { t_diel[k - 1] / 2.0 } else { 0.0 }) + (if k + 1 < LAYERS.len() { t_diel[k] / 2.0 } else { 0.0 })).collect(); let build = |h_air: f64| { let mut sys = System::new(nl, h, w); for k in 0..nl {@@ -275,16 +301,19 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu for k in 0..nl { for i in 0..n {q_rhs[k*n+i] += p_joule[k].v[i] as f64;} } let mut chip_pos: Vec<(String, f64, f64, f64, String)> = Vec::new(); for (reference, hot) in &spec.hot {- let cells: Vec<(f64, f64)> = b.pads.iter().filter(|p| p.reference == *reference && p.net == hot.tabNet).map(|p| (p.x, p.y)).collect();- if cells.is_empty() { log(&format!("{reference}: no pad on {} (tab net), its watts are not placed", hot.tabNet)); continue; }- let per = hot.watts / cells.len() as f64;- for (x, y) in &cells {+ let leads = hot.tabNet.is_empty();+ let pads: Vec<&crate::board::Pad> = b.pads.iter().filter(|p| p.reference == *reference && (leads || p.net == hot.tabNet)).collect();+ let cells: Vec<(f64, f64)> = pads.iter().map(|p| (p.x, p.y)).collect();+ if cells.is_empty() { log(&format!("{reference}: {}, its watts are not placed", if leads { "no pads".to_string() } else { format!("no pad on {} (tab net)", hot.tabNet) })); continue; }+ let area_sum: f64 = pads.iter().map(|p| p.area.max(1e-6)).sum();+ for (pi, (x, y)) in cells.iter().enumerate() {+ let per = hot.watts * pads[pi].area.max(1e-6) / area_sum; let Some((r0, c0)) = cell(*x, *y) else { continue }; let rad = (1.2 / d) as isize; let mut spots = Vec::new(); for dr in -rad..=rad { for dc in -rad..=rad { let (r, c) = (r0 as isize + dr, c0 as isize + dc); if r >= 0 && c >= 0 && (r as usize) < h && (c as usize) < w && dr * dr + dc * dc <= rad * rad { spots.push((r as usize, c as usize)); } } } for (r, c) in &spots { q_rhs[r * w + c] += per / spots.len() as f64; } }- chip_pos.push((reference.clone(), cells[0].0, cells[0].1, hot.watts, hot.tabNet.clone()));+ chip_pos.push((reference.clone(), cells[0].0, cells[0].1, hot.watts, if leads { "leads".to_string() } else { hot.tabNet.clone() })); } let tab_total: f64 = spec.hot.values().map(|h| h.watts).sum(); let (sys_still, sink_meshes) = build(H_STILL);@@ -368,13 +397,13 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value areas.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); let pour_nets: Vec<Value> = areas.into_iter().map(|(n, a)| json!({"net": n, "copperMm2": (a * 10.0).round() / 10.0})).collect(); json!({- "board": {"name": spec.board, "x0": b.x0, "y0": b.y0, "widthMm": b.width(), "heightMm": b.height(), "layers": LAYERS, "copperUm": spec.copperUm, "dielectricMm":if spec.stackupMm.len()==3 {spec.stackupMm.clone()} else {vec![0.2,1.065,0.2]}, "copperUmByLayer":LAYERS.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": if spec.stackupMm.len()==3 {spec.stackupMm.iter().sum::<f64>()} else {1.465} + LAYERS.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).sum::<f64>(), "cellMm": f.d, "cols": f.w, "rows": f.h},+ "board": {"name": spec.board, "x0": b.x0, "y0": b.y0, "widthMm": b.width(), "heightMm": b.height(), "layers": LAYERS, "copperUm": spec.copperUm, "dielectricMm":if spec.real_gaps.is_empty() {slot_gaps(spec)} else {spec.real_gaps.clone()}, "slotDielectricMm": slot_gaps(spec), "copperLayers": if spec.real_copper.is_empty() { LAYERS.iter().map(|l| l.to_string()).collect::<Vec<_>>() } else { spec.real_copper.clone() }, "copperUmByLayer":LAYERS.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": slot_gaps(spec).iter().sum::<f64>() + (if spec.real_copper.is_empty() { LAYERS.iter().map(|l| l.to_string()).collect::<Vec<_>>() } else { spec.real_copper.clone() }).iter().map(|l|spec.copperUmByLayer.get(l).copied().unwrap_or(spec.copperUm)/1000.0).sum::<f64>(), "cellMm": f.d, "cols": f.w, "rows": f.h}, "nets": nets, "chips": chips, "issues": f.issues, "signals": f.signals, "heatsinks": f.heatsinks, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual}, "vias": b.vias.iter().map(|v| json!({"net": v.net, "x": v.x, "y": v.y, "size": v.size, "drill": v.drill})).collect::<Vec<_>>(), "pads": b.pads.iter().filter(|p| spec.hot.contains_key(&p.reference) || spec.loads.contains_key(&p.net)).map(|p| json!({"ref": p.reference, "pad": p.pad, "net": p.net, "x": p.x, "y": p.y})).collect::<Vec<_>>(), "scales": {"jMax": f.j_all.iter().map(|g| g.max()).fold(0.0, f32::max), "qMax": f.q.iter().map(|g| g.max()).fold(0.0, f32::max), "tMax": tmax, "tMovingMax": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max)},- "heat": {"tabW": f.tab_total, "jouleW": f.joule_total, "peakRiseC": tmax, "peakRiseMovingC": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max), "budgetC": spec.hotMaxRiseC, "stillAirWPerM2K": H_STILL * 1e6, "movingAirWPerM2K": H_MOVING * 1e6},+ "heat": {"sourcesModelled": spec.hot.len(), "status": if spec.hot.is_empty() { "no heat sources modelled: the temperature is only the copper's own I²R loss, not a thermal pass for any part (give each dissipating part in spec hot: watts, and tabNet if it has an exposed tab, else its watts go through all its leads)" } else { "parts modelled" }, "tabW": f.tab_total, "jouleW": f.joule_total, "peakRiseC": tmax, "peakRiseMovingC": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max), "budgetC": spec.hotMaxRiseC, "stillAirWPerM2K": H_STILL * 1e6, "movingAirWPerM2K": H_MOVING * 1e6}, "textures": textures, "partValues": b.values, "pourNets": pour_nets,@@ -398,3 +427,36 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value assert!(cooled.chips[0].riseMovingC<cooled.chips[0].riseC); } }++/// The dielectric between two copper layers (LAYERS indices): its thickness (the spec's stackupMm, which the board's own+/// stackup fills when the spec has none) and its εr (the board's stackup, else the spec's epsilonR, else 4.3).+pub fn gap_between(spec: &Spec, _b: &crate::board::Board, ka: usize, kb: usize) -> (f64, f64) {+ let (lo, hi) = (ka.min(kb), ka.max(kb)); if lo == hi { return (0.0, spec.epsilonR.unwrap_or(4.3)); }+ let st = slot_gaps(spec);+ let h: f64 = st[lo..hi].iter().sum();+ let er = match &spec.er_slots { Some(e) => { let w: f64 = (lo..hi).map(|i| e[i] * st[i]).sum(); w / h } None => spec.epsilonR.unwrap_or(4.3) };+ (h, er)+}+/// The dielectric in each of the five slot gaps (between the six copper slots), as analyze normalised it.+pub fn slot_gaps(spec: &Spec) -> Vec<f64> { if spec.stackupMm.len() == LAYERS.len() - 1 { spec.stackupMm.clone() } else { spread(&[0.2, 1.065, 0.2], &["F.Cu".into(), "In1.Cu".into(), "In2.Cu".into(), "B.Cu".into()]) } }+/// Real gaps between consecutive real copper layers, spread over the slots between them (empty slots carry only dielectric).+pub fn spread(gaps: &[f64], copper: &[String]) -> Vec<f64> {+ let idx: Vec<usize> = copper.iter().filter_map(|c| LAYERS.iter().position(|l| *l == c)).collect();+ let mut out = vec![0.0; LAYERS.len() - 1];+ for (g, w) in gaps.iter().zip(idx.windows(2)) { let span = (w[1] - w[0]).max(1); for k in w[0]..w[1] { out[k] = g / span as f64; } }+ out+}++#[derive(Deserialize, Clone, Debug)]+#[serde(untagged)]+pub enum Clock { Hz(f64), Cfg { #[serde(default)] hz: Option<f64>, #[serde(default)] minHz: Option<f64>, #[serde(default)] maxHz: Option<f64>, #[serde(default)] harmonics: Option<usize>, #[serde(default)] amplitudeV: Option<f64> } }+impl Clock {+ pub fn lo_hz(&self) -> f64 { match self { Clock::Hz(h) => *h, Clock::Cfg { hz, minHz, maxHz, .. } => minHz.or(*hz).or(*maxHz).unwrap_or(0.0) } }+ pub fn top_hz(&self) -> f64 { match self { Clock::Hz(h) => *h, Clock::Cfg { hz, minHz, maxHz, .. } => maxHz.or(*hz).or(*minHz).unwrap_or(0.0) } }+ pub fn mid_hz(&self) -> f64 { (self.lo_hz() + self.top_hz()) / 2.0 }+ pub fn swept(&self) -> bool { self.top_hz() > self.lo_hz() * 1.001 }+ pub fn harmonics(&self) -> Option<usize> { match self { Clock::Cfg { harmonics, .. } => *harmonics, _ => None } }+ pub fn amplitude(&self) -> Option<f64> { match self { Clock::Cfg { amplitudeV, .. } => *amplitudeV, _ => None } }+}+#[derive(Deserialize, Clone, Debug)]+pub struct Band { pub minHz: f64, pub maxHz: f64, #[serde(default)] pub ifHz: Option<f64>, #[serde(default)] pub lowSideLo: bool }
src/flow.rs+7@@ -48,6 +48,8 @@ pub fn rail_from_name(name: &str) -> Option<f64> { let mut s = i; while s > 0 && u[s - 1].is_ascii_digit() { s -= 1; } // a digit run glued to letters on its left (VCC2V5 is fine, R12V is not a rail) if s > 0 && u[s - 1].is_ascii_alphabetic() && u[s - 1] != 'C' && u[s - 1] != 'D' && u[s - 1] != 'A' { continue; }+ // a decimal point before the V: +3.3VA is 3.3 V, not 3 (issue 9)+ if s >= 2 && u[s - 1] == '.' && u[s - 2].is_ascii_digit() { let mut s2 = s - 1; while s2 > 0 && u[s2 - 1].is_ascii_digit() { s2 -= 1; } let txt: String = u[s2..i].iter().collect(); if let Ok(v) = txt.parse::<f64>() { if v > 0.0 && v <= 1000.0 { return Some(v); } } } let whole: String = u[s..i].iter().collect(); let mut e = i + 1; while e < u.len() && u[e].is_ascii_digit() { e += 1; } let frac: String = u[i + 1..e].iter().collect();@@ -172,3 +174,8 @@ pub fn write(out: &Path, f: &Fields, b: &Board, spec: &Spec) -> Result<Value, St std::fs::remove_dir_all(&dir).ok(); } }++#[cfg(test)] mod rail_tests {+ use super::*;+ #[test] fn rail_names() { assert_eq!(rail_from_name("+3.3VA"), Some(3.3)); assert_eq!(rail_from_name("+3V3"), Some(3.3)); assert_eq!(rail_from_name("+5V_FM"), Some(5.0)); assert_eq!(rail_from_name("GND"), Some(0.0)); assert_eq!(rail_from_name("VCC2V5"), Some(2.5)); }+}
src/main.rs+34−7@@ -65,19 +65,42 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck let board_bytes = std::fs::read(board).map_err(|e|e.to_string())?; let spec_bytes = std::fs::read(spec_path).map_err(|e|e.to_string())?; let b = board::Board::load(board)?;- let spec: fields::Spec = serde_json::from_str(&std::fs::read_to_string(spec_path).map_err(|e| format!("{spec_path}: {e}"))?).map_err(|e| format!("{spec_path}: {e}"))?;+ let mut spec: fields::Spec = serde_json::from_str(&std::fs::read_to_string(spec_path).map_err(|e| format!("{spec_path}: {e}"))?).map_err(|e| format!("{spec_path}: {e}"))?;+ // the copper stack: the board's real layers (two, four or six; issue 10, 11) and the dielectric between each pair,+ // from the spec (one value per real gap; three on a two-layer board is the old form and is summed), else the board's+ // own stackup or thickness, else a default; spread over the six copper slots for the solve+ board::check_copper(&b.copper, &b.layer_names)?;+ let cu: Vec<String> = if b.copper.is_empty() { ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"].iter().map(|s| s.to_string()).collect() } else { b.copper.clone() };+ let ngap = cu.len() - 1;+ if spec.stackupMm.iter().any(|v| !v.is_finite() || *v <= 0.0) { return Err("stackupMm requires positive dielectric thicknesses".into()); }+ let (gaps, stack_from): (Vec<f64>, &str) = if !spec.stackupMm.is_empty() {+ if spec.stackupMm.len() == ngap { (spec.stackupMm.clone(), "spec") }+ else if ngap == 1 && spec.stackupMm.len() == 3 { (vec![spec.stackupMm.iter().sum()], "spec (three values on a two-layer board, summed)") }+ else { return Err(format!("stackupMm has {} value(s) but this board has {} copper layers ({}): give one dielectric thickness per gap, {} value(s)", spec.stackupMm.len(), cu.len(), cu.join(", "), ngap)); }+ } else if let Some(g) = b.stack_mm.clone().filter(|g| g.len() == ngap) { (g, "board file (its stackup, else its thickness)") }+ else { (match ngap { 1 => vec![1.465], 3 => vec![0.2, 1.065, 0.2], _ => vec![0.2, 0.3, 0.33, 0.3, 0.2] }, "default") };+ spec.er_slots = b.stack_er.clone().filter(|e| e.len() == ngap).map(|e| { let idx: Vec<usize> = cu.iter().filter_map(|c| board::LAYERS.iter().position(|l| *l == c)).collect(); let mut out = vec![spec.epsilonR.unwrap_or(4.3); board::LAYERS.len() - 1]; for (er, w) in e.iter().zip(idx.windows(2)) { for k in w[0]..w[1] { out[k] = *er; } } out });+ spec.stackupMm = fields::spread(&gaps, &cu); spec.real_gaps = gaps; spec.real_copper = cu.clone();+ // ground carries the return current even when the spec lists only the supplies (issue 9): solve it with the+ // largest supply load as its current, and say so+ let mut auto_ground: Option<(String, f64)> = None;+ if let Some(gname) = b.pads.iter().map(|p| p.net.clone()).find(|n| crate::flow::is_ground(n)) {+ if !spec.loads.contains_key(&gname) && !spec.loads.is_empty() {+ let amps = spec.loads.values().map(|l| l.amps).fold(0.0, f64::max);+ if amps > 0.0 { spec.loads.insert(gname.clone(), fields::Load { amps, maxRiseC: None }); auto_ground = Some((gname, amps)); }+ }+ } if !pitch.is_finite() || pitch <= 0.0 || pitch > b.width().min(b.height())/2.0 { return Err("pitch must be positive and resolve at least two cells across the board".into()); } if !spec.copperUm.is_finite() || spec.copperUm<=0.0 {return Err("copperUm must be positive".into());}- if !spec.stackupMm.is_empty() && (spec.stackupMm.len()!=3 || spec.stackupMm.iter().any(|v|!v.is_finite() || *v<=0.0)) {return Err("stackupMm requires three positive dielectric thicknesses".into());} for (layer,t) in &spec.copperUmByLayer { if !board::LAYERS.contains(&layer.as_str()) || !t.is_finite() || *t<=0.0 {return Err("copperUmByLayer requires known copper layers and positive thicknesses".into());} }- spec.signals.validate()?;+ spec.signals.validate(&spec.real_copper)?; for hot in spec.hot.values() {if !hot.watts.is_finite() || hot.watts<0.0 {return Err("hot watts must be finite and nonnegative".into());}} for load in spec.loads.values() {if !load.amps.is_finite() || load.amps<0.0 {return Err("load amps must be finite and nonnegative".into());}} if !spec.signals.capacitance.is_empty() || !spec.signals.differential_pairs.is_empty() {- if spec.stackupMm.len()!=3 {return Err("signals requires explicit stackupMm: three dielectric thicknesses, consistent with each signal check".into());} for (layer_a,layer_b,distance) in spec.signals.capacitance.iter().map(|c|(&c.layer_a,&c.layer_b,c.dielectric_mm)).chain(spec.signals.differential_pairs.iter().map(|p|(&p.layer,&p.reference_layer,p.dielectric_mm))) {- let a=board::LAYERS.iter().position(|l|*l==layer_a).unwrap();let b=board::LAYERS.iter().position(|l|*l==layer_b).unwrap();- if (spec.stackupMm[a.min(b)]-distance).abs()>1e-6 {return Err("signal dielectricMm disagrees with stackupMm for its adjacent layers".into());}+ let a=board::LAYERS.iter().position(|l|*l==layer_a).unwrap();let bb=board::LAYERS.iter().position(|l|*l==layer_b).unwrap();+ let h = fields::gap_between(&spec, &b, a, bb).0;+ if (h-distance).abs()>1e-3 {return Err(format!("signal dielectricMm {distance} disagrees with the stackup's {h:.4} mm between {layer_a} and {layer_b} ({stack_from})"));} } } for (i,block) in spec.heatsinks.iter().enumerate() {@@ -97,6 +120,8 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck 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()));+ // a current solve that did not converge is an issue, not a result (issue 9)+ for nr in f.nets.iter().filter(|x| !x.converged) { iss.push(fields::Issue { severity: "high".into(), kind: "solve-unconverged".into(), what: format!("{}: the current solve stopped at residual {:.1e} (target 1e-7); its current density and voltage drop are not reliable", nr.net, nr.residual), at: None, layer: None, net: Some(nr.net.clone()), reference: None, fix: "Re-run at a coarser --pitch, or name this net's terminals in the spec; copper islands with terminals are referenced on their own.".into(), action: Some(json!({"type": "review", "net": nr.net, "why": "solver did not converge"})) }); } 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));}@@ -136,6 +161,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck summary["review"] = review; summary["fixPlan"] = emc::fix_plan(&f.issues); summary["flow"] = flow::write(out, &f, &b, &spec)?;+ summary["board"]["stackupFrom"] = serde_json::json!(stack_from); if let Some((g, a)) = &auto_ground { summary["groundReturn"] = serde_json::json!({"net": g, "amps": a, "note": "not in the spec: solved with the largest supply load as the return current (set loads.<ground> to change it)"}); } summary["board"]["name"] = serde_json::json!(spec.board.split('(').next().unwrap_or(&spec.board).trim()); summary["board"]["description"] = serde_json::json!(spec.board); summary["log"] = serde_json::json!(lines);@@ -163,10 +189,11 @@ fn main() { ok(&format!("{} nets solved, {} hot parts, {} issue(s), in {} s: {}", nets, s["chips"].as_array().map(|a| a.len()).unwrap_or(0), issues.len(), s["seconds"], out.join("fields.json").display()), &[ format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["scales"]["jMax"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]), issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "),- { 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!("; {}{} island(s), edge stitched {} %", if g["offsetMv"].is_number() { format!("ground offset {} mV, ", g["offsetMv"]) } else { "ground offset not solved (ground is not a loaded net), ".to_string() }, 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 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!("; {}{} island(s), edge stitched {} %", if g["offsetMv"].is_number() { format!("ground offset {} mV, ", g["offsetMv"]) } else { "ground offset not solved (ground is not a loaded net), ".to_string() }, 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).take(5).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 { "" }) },+ if s["heat"]["sourcesModelled"] == 0 { "Thermal: no heat sources modelled, so the temperature is only the copper's own I²R loss: this is not a thermal pass for any part. Add each dissipating part to spec hot (watts; tabNet if it has an exposed tab, else the watts go through all its leads by pad area).".to_string() } else { format!("Thermal: {} part(s) modelled as heat sources.", s["heat"]["sourcesModelled"]) }, format!("Heatsinks: {}; signal capacitance checks: {}; differential pairs: {}. Signals are screening estimates; unsupported geometry is reported, never certified.", s["heatsinks"].as_array().map_or(0,|x|x.len()),s["signals"]["capacitance"].as_array().map_or(0,|x|x.len()),s["signals"]["differentialPairs"].as_array().map_or(0,|x|x.len())), format!("Next: `adom-fields serve --fields {}` and open it (pup or the browser); `adom-fields ui view --quantity current --net <net>` drives it; `adom-fields tour` walks it for a video.", out.display()), ]);
src/noise.rs+100−42@@ -56,19 +56,21 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va let rail = |nm: &str| spec.voltages.get(nm).copied().or_else(|| rail_from_name(nm)); 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 gid = f.net_names.iter().position(|x| is_ground(x)).map(|i| i as u16);- let er: f64 = 4.3;- // the strong tones: switching harmonics (trapezoid envelope within 40 dB of the fundamental) and named clocks- let mut tones: Vec<(f64, String)> = Vec::new();- for (net, sw) in &spec.switching { let fs = sw.freqHz.unwrap_or(500e3); let tr = sw.riseNs.unwrap_or(8.0) * 1e-9; let knee = 1.0 / (std::f64::consts::PI * tr); let top = (knee * 10.0).min(spec.emcFmaxHz.unwrap_or(1e9)); let mut k = 1.0; while k * fs <= top { tones.push((k * fs, format!("{net} harmonic {}", k as u64))); k += if k * fs < 100e6 { 1.0 } else { (k * 0.01).max(1.0).floor() }; } }- // a crystal's pins, or an LC oscillator no chip drives, swing nearly as a sine: only the first five harmonics count;- // a logic clock's run on- for (net, hz) in &spec.clocks { let xtal = b.pads.iter().any(|p| p.net == *net && (p.reference.starts_with('Y') || p.reference.starts_with('X'))) || !b.pads.iter().any(|p| p.net == *net && p.reference.starts_with('U')); let top = if xtal { 5.0 * hz } else { spec.emcFmaxHz.unwrap_or(1e9) * 2.0 }; let mut k = 1.0; while k * hz <= top * 1.0001 { tones.push((k * hz, format!("{net} {} harmonic {}", if xtal { "oscillator" } else { "clock" }, k as u64))); k += 1.0; } }+ // the strong tones, each a frequency range (a tuned LO sweeps): switching harmonics (trapezoid envelope within 40 dB+ // of the fundamental) and the named clocks: a crystal's pins, or an LC oscillator no chip drives, swing nearly as a+ // sine, so only the first five harmonics count as strong; a logic clock's run on (or the spec's harmonics)+ let sine_net = |net: &str| b.pads.iter().any(|p| p.net == net && (p.reference.starts_with('Y') || p.reference.starts_with('X'))) || !b.pads.iter().any(|p| p.net == net && p.reference.starts_with('U'));+ let mut tones: Vec<(f64, f64, String)> = Vec::new();+ for (net, sw) in &spec.switching { let fs = sw.freqHz.unwrap_or(500e3); let tr = sw.riseNs.unwrap_or(8.0) * 1e-9; let knee = 1.0 / (std::f64::consts::PI * tr); let top = (knee * 10.0).min(spec.emcFmaxHz.unwrap_or(1e9)); let mut k = 1.0; while k * fs <= top { tones.push((k * fs, k * fs, format!("{net} harmonic {}", k as u64))); k += if k * fs < 100e6 { 1.0 } else { (k * 0.01).max(1.0).floor() }; } }+ for (net, c) in &spec.clocks { let sine = sine_net(net); let nh = c.harmonics().unwrap_or(if sine { 5 } else { ((spec.emcFmaxHz.unwrap_or(1e9) * 2.0) / c.top_hz().max(1.0)) as usize }).min(4000); for k in 1..=nh.max(1) { let kf = k as f64; tones.push((kf * c.lo_hz(), kf * c.top_hz(), format!("{net} {} harmonic {k}", if sine { "oscillator" } else { "clock" }))); } }+ let hz_txt = |a: f64, z: f64| -> String { let one = |x: f64| if x >= 1e9 { format!("{:.2} GHz", x / 1e9) } else if x >= 1e6 { format!("{:.1} MHz", x / 1e6) } else { format!("{:.0} kHz", x / 1e3) }; if (z - a).abs() < a * 1e-3 { one(a) } else { format!("{}–{}", one(a), one(z)) } }; let mut out = json!({}); // ---- cavities -------------------------------------------------------------------------------------------- let mut cavities = Vec::new();- if let (Some(g), true) = (gid, real.len() >= 2) {- let (k0, k1) = (real[0], real[1]);- let hmm = { 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) } };+ // every pour facing ground on the next copper layer, in either direction (issue 9: not just the first two layers)+ let layer_pairs: Vec<(usize, usize)> = real.windows(2).flat_map(|w| [(w[0], w[1]), (w[1], w[0])]).collect();+ if let Some(g) = gid { for (k0, k1) in layer_pairs {+ let (hmm, er) = crate::fields::gap_between(spec, b, k0, k1); let mut pairs: std::collections::BTreeMap<u16, usize> = Default::default(); for i in 0..n { let a = f.net_ids[k0][i]; if a != u16::MAX && a != g && f.net_ids[k1][i] == g { *pairs.entry(a).or_default() += 1; } } for (id, cnt) in pairs {@@ -92,60 +94,116 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va let k = lam.sqrt() / hc; let fr = C_MM * k / (2.0 * std::f64::consts::PI * er.sqrt()); let (mut amax, mut at) = (0.0, 0usize); for (j, v) in vec.iter().enumerate() { if v.abs() > amax { amax = v.abs(); at = j; } } let atxy = [f.x0 + ((cells[at] % cw) as f64 + 0.5) * hc, f.y0 + ((cells[at] / cw) as f64 + 0.5) * hc];- let near = tones.iter().filter(|t| (t.0 - fr).abs() / fr < 0.05).min_by(|a, z| (a.0 - fr).abs().partial_cmp(&(z.0 - fr).abs()).unwrap());- ms.push(json!({"fHz": fr.round(), "antinode": [r2(atxy[0]), r2(atxy[1])], "near": near.map(|t| t.1.clone())}));- if let Some(t) = near { issues.push(Issue { severity: "medium".into(), kind: "cavity-resonance".into(), what: format!("{} over {}: a plane mode at {:.0} MHz sits within 5 % of the {} ({:.0} MHz)", name, f.net_names[g as usize], fr / 1e6, t.1, t.0 / 1e6), at: Some([r2(atxy[0]), r2(atxy[1])]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: None, fix: format!("Damp it where it rings loudest, ({:.1}, {:.1}): a 1 to 10 nF capacitor from {} to ground there, or ground vias through the pour.", atxy[0], atxy[1], name), action: Some(json!({"type": "add-part", "what": "capacitor", "value": "4.7nF", "between": [name, f.net_names[g as usize]], "at": [r2(atxy[0]), r2(atxy[1])], "why": "damp a plane resonance"})) }); }+ let near = tones.iter().filter(|t| fr >= t.0 * 0.95 && fr <= t.1 * 1.05).min_by(|a, z| ((a.0 + a.1) / 2.0 - fr).abs().partial_cmp(&((z.0 + z.1) / 2.0 - fr).abs()).unwrap());+ ms.push(json!({"fHz": fr.round(), "antinode": [r2(atxy[0]), r2(atxy[1])], "near": near.map(|t| t.2.clone())}));+ if let Some(t) = near { issues.push(Issue { severity: "medium".into(), kind: "cavity-resonance".into(), what: format!("{} on {} over {} on {}: a plane mode at {:.0} MHz sits within 5 % of the {} ({})", name, LAYERS[k0], f.net_names[g as usize], LAYERS[k1], fr / 1e6, t.2, hz_txt(t.0, t.1)), at: Some([r2(atxy[0]), r2(atxy[1])]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: None, fix: format!("Damp it where it rings loudest, ({:.1}, {:.1}): a 1 to 10 nF capacitor from {} to ground there, or ground vias through the pour.", atxy[0], atxy[1], name), action: Some(json!({"type": "add-part", "what": "capacitor", "value": "4.7nF", "between": [name, f.net_names[g as usize]], "at": [r2(atxy[0]), r2(atxy[1])], "why": "damp a plane resonance"})) }); } } // the shapes over the cavity's own bounding box, three decimals: the app animates them let (r0, r1) = (cells.iter().map(|c| c / cw).min().unwrap(), cells.iter().map(|c| c / cw).max().unwrap()); let (c0, c1) = (cells.iter().map(|c| c % cw).min().unwrap(), cells.iter().map(|c| c % cw).max().unwrap()); let (bw, bh) = (c1 - c0 + 1, r1 - r0 + 1); let shapes: Vec<Vec<f64>> = modes.iter().skip(1).take(3).map(|(_, v)| { let mx = v.iter().fold(0.0f64, |a, b| a.max(b.abs())).max(1e-12); let mut sub = vec![0.0f64; bw * bh]; for (j, c) in cells.iter().enumerate() { sub[(c / cw - r0) * bw + (c % cw - c0)] = (v[j] / mx * 1000.0).round() / 1000.0; } sub }).collect(); let mask_sub: Vec<u8> = (0..bw * bh).map(|i| { let c = (r0 + i / bw) * cw + c0 + i % bw; if comp[c] == best.0 { 1 } else { 0 } }).collect();- cavities.push(json!({"net": name, "over": f.net_names[g as usize], "areaMm2": r2(cnt as f64 * f.d * f.d), "gapMm": hmm, "grid": {"cols": bw, "rows": bh, "cellMm": hc, "x0": f.x0 + c0 as f64 * hc, "y0": f.y0 + r0 as f64 * hc, "mask": mask_sub}, "modes": ms, "shapes": shapes}));+ cavities.push(json!({"net": name, "layer": LAYERS[k0], "overLayer": LAYERS[k1], "er": r2(er), "over": f.net_names[g as usize], "areaMm2": r2(cnt as f64 * f.d * f.d), "gapMm": hmm, "grid": {"cols": bw, "rows": bh, "cellMm": hc, "x0": f.x0 + c0 as f64 * hc, "y0": f.y0 + r0 as f64 * hc, "mask": mask_sub}, "modes": ms, "shapes": shapes})); } }+ } out["cavities"] = json!(cavities); // ---- crosstalk ----------------------------------------------------------------------------------------------- let aggressors: Vec<(String, f64)> = spec.switching.iter().map(|(k, s)| (k.clone(), s.highV - s.lowV)).chain(f.net_names.iter().filter(|nm| is_switch_name(nm) && !spec.switching.contains_key(*nm)).map(|nm| (nm.clone(), f.net_names.iter().filter_map(|x| rail(x)).fold(0.0, f64::max)))).chain(spec.clocks.keys().map(|k| (k.clone(), rail("VCC").unwrap_or(3.3)))).collect();+ // aggressors: a switching node is a step of its swing; a clock or oscillator is a sine at its amplitude (the spec's,+ // else 1 V for a crystal or LC oscillator, the logic rail for a chip-driven clock) and its mid frequency+ struct Agg { net: String, amp: f64, hz: Option<f64> }+ let logic_v = rail("+3V3").or_else(|| rail("VCC")).unwrap_or(3.3);+ let mut aggressors: Vec<Agg> = spec.switching.iter().map(|(k, s)| Agg { net: k.clone(), amp: s.highV - s.lowV, hz: None }).collect();+ for nm in f.net_names.iter().filter(|nm| is_switch_name(nm) && !spec.switching.contains_key(*nm)) { aggressors.push(Agg { net: nm.clone(), amp: f.net_names.iter().filter_map(|x| rail(x)).fold(0.0, f64::max), hz: None }); }+ for (k, c) in &spec.clocks { aggressors.push(Agg { net: k.clone(), amp: c.amplitude().unwrap_or(if sine_net(k) { 1.0 } else { logic_v }), hz: Some(c.mid_hz()) }); } let mut xt = Vec::new(); let budget = spec.crosstalkMv.unwrap_or(50.0); let thick = |k: usize| spec.copperUmByLayer.get(LAYERS[k]).copied().unwrap_or(spec.copperUm) / 1000.0;- for (agg, dv) in &aggressors {+ // the nearest real copper layer to k: the plane a victim on k sits over+ let plane_gap = |k: usize| -> (f64, f64) { let i = real.iter().position(|x| *x == k).unwrap_or(0); let nb: Vec<usize> = [i.checked_sub(1), Some(i + 1)].into_iter().flatten().filter_map(|j| real.get(j).copied()).collect(); nb.iter().map(|&o| crate::fields::gap_between(spec, b, k, o)).min_by(|a, z| a.0.partial_cmp(&z.0).unwrap()).unwrap_or((1.465, 4.3)) };+ // a net's shunt to AC ground: the capacitors and resistors from it to ground or any rail (rails are AC ground)+ let shunt = |net: &str| -> (f64, f64) { let mut cs = 0.0; let mut g = 0.0; let mut refs: Vec<&str> = b.pads.iter().filter(|p| p.net == net).map(|p| p.reference.as_str()).collect(); refs.sort(); refs.dedup();+ for r in refs { let ns: Vec<&str> = b.pads.iter().filter(|p| p.reference == r).map(|p| p.net.as_str()).collect(); if ns.len() != 2 { continue; } let other = if ns[0] == net { ns[1] } else { ns[0] }; if !(is_ground(other) || rail(other).is_some()) { continue; }+ let val = b.values.get(r).map(String::as_str).unwrap_or("");+ if r.starts_with('C') { if let Some(c) = crate::emc::parse_value(val, 'F') { cs += c; } }+ else if r.starts_with('R') { let u = val.to_ascii_uppercase().replace("OHM", ""); let t = u.trim(); let ohm = if let Some(i) = t.find(|c: char| c == 'K' || c == 'M' || c == 'R') { let m = match &t[i..i + 1] { "K" => 1e3, "M" => 1e6, _ => 1.0 }; format!("{}.{}", &t[..i], &t[i + 1..]).trim_end_matches('.').parse::<f64>().unwrap_or(0.0) * m } else { t.parse::<f64>().unwrap_or(0.0) }; if ohm > 0.0 { g += 1.0 / ohm; } } }+ (cs, g) };+ for ag in &aggressors {+ let agg = &ag.net; let dv = ag.amp; let Some(aid) = f.net_names.iter().position(|x| x == agg) else { continue }; let aid = aid as u16;+ let is_clock = ag.hz.is_some();+ // one discrete part away is the aggressor's own circuit (a bootstrap capacitor, an oscillator's crystal or+ // transistor): coupled through that part far more than through stray copper; its circuit paths are reported below+ let disc = |r: &str| r.starts_with('C') || (is_clock && !r.starts_with('U') && !r.starts_with('J') && !r.starts_with("TP"));+ let one: std::collections::BTreeSet<String> = { let mut out = std::collections::BTreeSet::new(); for p in b.pads.iter().filter(|p| p.net == *agg && disc(&p.reference)) { for q in b.pads.iter().filter(|q| q.reference == p.reference && q.net != *agg) { out.insert(q.net.clone()); } } out }; for (vid, vname) in f.net_names.iter().enumerate() {- let vid = vid as u16; if vid == aid || Some(vid) == gid || f.flow.contains_key(vname) || rail(vname).is_some() || aggressors.iter().any(|a| a.0 == *vname) { continue; }- // a net joined to the aggressor through a capacitor rides on it on purpose (a bootstrap): not a victim- // and for a clock or an oscillator, a net sharing any discrete part with it (its crystal, its transistor,- // its bias resistor) is the oscillator's own circuit, coupled by design; a chip's other pins still count- let is_clock = spec.clocks.contains_key(agg);- let rides = { let disc = |r: &str| r.starts_with('C') || (is_clock && !r.starts_with('U') && !r.starts_with('J') && !r.starts_with("TP"));- // the nets one discrete part away from a net (never through a rail or ground)- let hop = |from: &str| -> std::collections::BTreeSet<String> { let mut out = std::collections::BTreeSet::new(); for p in b.pads.iter().filter(|p| p.net == from && disc(&p.reference)) { for q in b.pads.iter().filter(|q| q.reference == p.reference && q.net != from) { out.insert(q.net.clone()); } } out };- let one = hop(agg);- one.contains(vname.as_str()) || (is_clock && one.iter().filter(|n| rail(n).is_none() && !is_ground(n)).any(|n| hop(n).contains(vname.as_str()))) };- if rides { continue; }- // side coupling on each layer: victim cells within 1 mm of the aggressor, summed as C per length at that spacing- let (mut cm, mut closest, mut at, mut run) = (0.0f64, f64::MAX, [0.0, 0.0], 0usize); let mut varea = 0.0;- let rad = (1.0 / f.d).ceil() as i64;- for &k in &real { for i in 0..n {- if f.net_ids[k][i] != vid { continue; } varea += f.d * f.d;+ let vid = vid as u16; if vid == aid || Some(vid) == gid || f.flow.contains_key(vname) || rail(vname).is_some() || aggressors.iter().any(|a| a.net == *vname) || one.contains(vname.as_str()) { continue; }+ // side coupling within 3 mm on each layer; a plane h below shields it, roughly as 1 / (1 + (s / 2h)²)+ let (mut cm, mut closest, mut at, mut run) = (0.0f64, f64::MAX, [0.0, 0.0], 0usize); let mut cg_cu = 0.0;+ let rad = (3.0 / f.d).ceil() as i64;+ for &k in &real { let (hk, erk) = plane_gap(k); for i in 0..n {+ if f.net_ids[k][i] != vid { continue; } cg_cu += E0 * erk * f.d * f.d / hk.max(0.05); let (r, c) = ((i / w) as i64, (i % w) as i64); let mut dmin = f64::MAX; for dr in -rad..=rad { for dc in -rad..=rad { let (rr, cc) = (r + dr, c + dc); if rr < 0 || cc < 0 || rr >= h as i64 || cc >= w as i64 { continue; } if f.net_ids[k][rr as usize * w + cc as usize] == aid { dmin = dmin.min(((dr * dr + dc * dc) as f64).sqrt() * f.d); } } }- if dmin < f64::MAX { let s_ = dmin.max(0.05); let eff = (er + 1.0) / 2.0; cm += E0 * eff * (thick(k) / s_ + 0.6 * (1.0 + f.d / s_).ln()) * f.d; run += 1; if dmin < closest { closest = dmin; at = [f.x0 + (c as f64 + 0.5) * f.d, f.y0 + (r as f64 + 0.5) * f.d]; } }+ if dmin <= 3.0 { let s_ = dmin.max(0.05); let eff = (erk + 1.0) / 2.0; let shield = 1.0 / (1.0 + (s_ / (2.0 * hk)).powi(2)); cm += E0 * eff * (thick(k) / s_ + 0.6 * (1.0 + f.d / s_).ln()) * f.d * shield; if dmin <= 1.0 { run += 1; } if dmin < closest { closest = dmin; at = [f.x0 + (c as f64 + 0.5) * f.d, f.y0 + (r as f64 + 0.5) * f.d]; } } } }- // overlap with the aggressor on the next layer- let mut over = 0.0; if real.len() >= 2 { for i in 0..n { if (f.net_ids[real[0]][i] == vid && f.net_ids[real[1]][i] == aid) || (f.net_ids[real[1]][i] == vid && f.net_ids[real[0]][i] == aid) { over += f.d * f.d; } } }- let hgap = if spec.stackupMm.len() == 3 { spec.stackupMm.iter().sum::<f64>() } else { 1.465 };- cm += E0 * er * over / hgap;+ // overlap with the aggressor on each adjacent copper layer, at that pair's gap and εr+ let mut over = 0.0; for wpair in real.windows(2) { let (ga, gb) = (wpair[0], wpair[1]); let (hg, eg) = crate::fields::gap_between(spec, b, ga, gb); let mut o = 0.0; for i in 0..n { if (f.net_ids[ga][i] == vid && f.net_ids[gb][i] == aid) || (f.net_ids[gb][i] == vid && f.net_ids[ga][i] == aid) { o += f.d * f.d; } } over += o; cm += E0 * eg * o / hg; } if cm <= 0.0 { continue; }- let cg = E0 * er * varea / hgap + 3e-12; // the victim to ground: its copper over the plane, plus about 3 pF of pins- let mv = dv * cm / (cm + cg) * 1e3;- xt.push(json!({"aggressor": agg, "victim": vname, "cmPf": r2(cm * 1e12), "closestMm": if closest < f64::MAX { r2(closest) } else { -1.0 }, "runMm": r2(run as f64 * f.d), "overlapMm2": r2(over), "coupledMv": r2(mv), "at": [r2(at[0]), r2(at[1])]}));- if mv > budget { issues.push(Issue { severity: if mv > 4.0 * budget { "medium".into() } else { "low".into() }, kind: "crosstalk".into(), what: format!("{} picks up about {:.0} mV from each {} edge ({:.2} pF, closest {:.2} mm, {:.1} mm run)", vname, mv, agg, cm * 1e12, closest, run as f64 * f.d), at: Some([r2(at[0]), r2(at[1])]), layer: None, net: Some(vname.clone()), reference: None, fix: format!("Route {} at least {:.1} mm from {} (three times the spacing), or run a ground guard between them, and keep {} off the copper under {}.", vname, (closest * 3.0).max(1.0), agg, vname, agg), action: Some(json!({"type": "reroute", "net": vname, "awayFrom": agg, "minSpacingMm": r2((closest * 3.0).max(1.0)), "at": [r2(at[0]), r2(at[1])], "or": "ground guard between"})) }); }+ // the victim to ground: its copper over its nearest plane, about 3 pF of pins, and the parts on it (issue 9)+ let (cpart, gpart) = shunt(vname);+ let cg = cg_cu + 3e-12 + cpart;+ let v = match ag.hz { None => dv * cm / (cm + cg), Some(hz) => { let wv = 2.0 * std::f64::consts::PI * hz; dv * wv * cm / ((wv * (cm + cg)).powi(2) + gpart * gpart).sqrt() } };+ let mv = v * 1e3;+ let vb = spec.victimBudgetsMv.get(vname).copied().unwrap_or(budget);+ xt.push(json!({"aggressor": agg, "victim": vname, "kind": if is_clock { "sine" } else { "step" }, "atHz": ag.hz, "amplitudeV": dv, "cmPf": (cm * 1e15).round() / 1e3, "victimPf": r2(cg * 1e12), "victimShuntOhm": if gpart > 0.0 { json!(r2(1.0 / gpart)) } else { Value::Null }, "closestMm": if closest < f64::MAX { r2(closest) } else { -1.0 }, "runMm": r2(run as f64 * f.d), "overlapMm2": r2(over), "coupledMv": (mv * 1e4).round() / 1e4, "coupledUv": (mv * 1e3 * 10.0).round() / 10.0, "budgetMv": vb, "at": [r2(at[0]), r2(at[1])]}));+ if mv > vb {+ let amt = if mv >= 1.0 { format!("{:.0} mV", mv) } else { format!("{:.0} µV", mv * 1e3) };+ let what = if is_clock { format!("{} picks up about {} of {} ({:.0} MHz) through {:.0} fF (closest {:.2} mm; {:.0} pF to ground on the victim)", vname, amt, agg, ag.hz.unwrap_or(0.0) / 1e6, cm * 1e15, closest, cg * 1e12) } else { format!("{} picks up about {} from each {} edge ({:.2} pF, closest {:.2} mm, {:.1} mm run)", vname, amt, agg, cm * 1e12, closest, run as f64 * f.d) };+ issues.push(Issue { severity: if mv > 4.0 * vb { "medium".into() } else { "low".into() }, kind: "crosstalk".into(), what, at: Some([r2(at[0]), r2(at[1])]), layer: None, net: Some(vname.clone()), reference: None, fix: format!("Route {} at least {:.1} mm from {} (three times the spacing), or run a ground guard between them, and keep {} off the copper under {}.", vname, (closest * 3.0).max(1.0), agg, vname, agg), action: Some(json!({"type": "reroute", "net": vname, "awayFrom": agg, "minSpacingMm": r2((closest * 3.0).max(1.0)), "at": [r2(at[0]), r2(at[1])], "or": "ground guard between"})) });+ } } } xt.sort_by(|a, z| z["coupledMv"].as_f64().partial_cmp(&a["coupledMv"].as_f64()).unwrap()); out["crosstalk"] = json!(xt);- out["tones"] = json!(tones.iter().take(40).map(|t| json!({"fHz": t.0, "what": t.1})).collect::<Vec<_>>());+ out["budgetMv"] = json!(budget);+ out["victimBudgetsMv"] = json!(spec.victimBudgetsMv);+ // ---- the frequency plan: a tone or harmonic inside a receive band or its image (issue 9) --------------------------+ let mut plan = Vec::new();+ for (bn, bd) in &spec.bands {+ let image = bd.ifHz.map(|fi| if bd.lowSideLo { (bd.minHz - 2.0 * fi, bd.maxHz - 2.0 * fi) } else { (bd.minHz + 2.0 * fi, bd.maxHz + 2.0 * fi) });+ for (net, c) in &spec.clocks {+ // every harmonic up to the band, weak ones included: a receiver hears microvolts+ let nh = ((bd.maxHz.max(image.map_or(0.0, |x| x.1)) / c.lo_hz().max(1.0)).floor() as usize).min(c.harmonics().unwrap_or(400).max(1) * 1000).min(400);+ for k in 1..=nh.max(1) {+ if k == 1 && c.swept() { continue; } // a tuned LO sits beside its own band by design: its leakage is a circuit path+ let (a, z) = (k as f64 * c.lo_hz(), k as f64 * c.top_hz());+ for (what, lo, hi) in [("band", bd.minHz, bd.maxHz)].into_iter().chain(image.map(|x| ("image band", x.0, x.1))) {+ if z >= lo && a <= hi {+ let sev = if k <= 5 { "medium" } else { "low" };+ plan.push(json!({"tone": net, "harmonic": k, "fromHz": a, "toHz": z, "band": bn, "where": what, "bandHz": [lo, hi]}));+ issues.push(Issue { severity: sev.into(), kind: "tone-in-band".into(), what: format!("harmonic {} of {} ({}) lands in the {} {} ({})", k, net, hz_txt(a, z), bn, what, hz_txt(lo, hi)), at: None, layer: None, net: Some(net.clone()), reference: None, fix: format!("Keep {} and its traces away from the {} front end and shield or filter it, or move its frequency so no harmonic falls in {} ({}).", net, bn, hz_txt(lo, hi), what), action: Some(json!({"type": "review", "net": net, "why": "a tone in a receive band", "harmonic": k, "band": bn, "where": what})) });+ }+ }+ }+ }+ }+ out["frequencyPlan"] = json!(plan);+ // ---- circuit paths: from each oscillator through the parts to each sensitive net (issue 9: report, don't hide) -----+ let mut paths = Vec::new();+ for (net, _) in &spec.clocks { for target in &spec.sensitiveNets {+ // breadth-first through two-terminal and transistor parts, never through a rail or ground, at most 8 parts+ let mut prev: std::collections::BTreeMap<String, (String, String)> = Default::default(); let mut q = std::collections::VecDeque::new(); q.push_back((net.clone(), 0usize)); prev.insert(net.clone(), (String::new(), String::new()));+ let mut found = false;+ while let Some((cur, d)) = q.pop_front() { if cur == *target { found = true; break; } if d >= 8 { continue; }+ let mut refs: Vec<&str> = b.pads.iter().filter(|p| p.net == cur && !p.reference.starts_with('U') && !p.reference.starts_with('J') && !p.reference.starts_with("TP")).map(|p| p.reference.as_str()).collect(); refs.sort(); refs.dedup();+ for r in refs { for p in b.pads.iter().filter(|p| p.reference == r && p.net != cur) { if is_ground(&p.net) || rail(&p.net).is_some() || p.net.is_empty() || prev.contains_key(&p.net) { continue; } prev.insert(p.net.clone(), (cur.clone(), r.to_string())); q.push_back((p.net.clone(), d + 1)); } } }+ if found { let mut hops = Vec::new(); let mut cur = target.clone(); while let Some((p, r)) = prev.get(&cur).cloned() { if p.is_empty() { break; } hops.push(json!({"part": r, "value": b.values.get(&r), "from": p, "to": cur})); cur = p; } hops.reverse();+ let txt = hops.iter().map(|h| format!("{} {}", h["part"].as_str().unwrap_or(""), h["value"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" → ");+ issues.push(Issue { severity: "low".into(), kind: "circuit-path".into(), what: format!("{} reaches {} through {} part(s): {}", net, target, hops.len(), txt), at: None, layer: None, net: Some(target.clone()), reference: None, fix: format!("Simulate this path (ngspice AC with the stages' small-signal models) for the level of {} at {}; add isolation (a buffer, a lower-value coupling capacitor, a shield) if it is over the target.", net, target), action: Some(json!({"type": "review", "why": "circuit path from an oscillator to a sensitive net", "from": net, "to": target, "hops": hops})) });+ paths.push(json!({"from": net, "to": target, "hops": hops})); }+ } }+ out["circuitPaths"] = json!(paths);+ out["tones"] = json!(tones.iter().take(40).map(|t| json!({"fHz": t.0, "toHz": t.1, "what": t.2})).collect::<Vec<_>>()); let _ = b; out }
src/pdn.rs+4−3@@ -75,7 +75,8 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec 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) } };+ // the top copper's gap to the next real copper layer, and that dielectric's εr+ let (h, er_top) = if real.len() >= 2 { crate::fields::gap_between(spec, b, real[0], real[1]) } else { (1.465, spec.epsilonR.unwrap_or(4.3)) }; let gid = f.net_names.iter().position(|x| *x == gnd).map(|i| i as u16); let fs = freqs(); let lib = crate::caps::Library::load(spec.capCurves.as_deref());@@ -163,8 +164,8 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec // 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;+ let gap = h;+ let cplane = E0 * er_top * 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,
src/signals.rs+9−6@@ -16,12 +16,13 @@ pub struct Capacitance {pub name:String,pub net_a:String,pub layer_a:String,pub #[derive(Clone,Deserialize,Serialize)] #[serde(rename_all="camelCase",deny_unknown_fields)] pub struct Pair {pub name:String,pub positive:String,pub negative:String,pub layer:String,pub reference_net:String,pub reference_layer:String,pub dielectric_mm:f64,pub epsilon_r:f64,pub max_skew_mm:f64,pub target_ohms:f64,pub tolerance_percent:f64}-fn adjacent(a:&str,b:&str)->bool {match (LAYERS.iter().position(|l|*l==a),LAYERS.iter().position(|l|*l==b)) {(Some(a),Some(b))=>a.abs_diff(b)==1,_=>false}}+// adjacent in the board's real copper stack (a four-layer board's In2.Cu and B.Cu are adjacent)+fn adjacent(cu:&[String],a:&str,b:&str)->bool {let pos=|x:&str|cu.iter().position(|l|l==x).or_else(||if cu.is_empty(){LAYERS.iter().position(|l|*l==x)}else{None});match (pos(a),pos(b)) {(Some(a),Some(b))=>a.abs_diff(b)==1,_=>false}} fn material(h:f64,er:f64)->bool {h.is_finite()&&h>0.&&er.is_finite()&&er>=1.}-impl Config {pub fn validate(&self)->Result<(),String>{+impl Config {pub fn validate(&self,cu:&[String])->Result<(),String>{ let mut names=BTreeSet::new();- for c in &self.capacitance {if c.name.trim().is_empty()||!names.insert(c.name.clone())||c.net_a==c.net_b||!adjacent(&c.layer_a,&c.layer_b)||!material(c.dielectric_mm,c.epsilon_r){return Err("capacitance needs unique names, different nets, adjacent known layers and positive dielectricMm/epsilonR >= 1".into());}}- for p in &self.differential_pairs {if p.name.trim().is_empty()||!names.insert(p.name.clone())||p.positive==p.negative||!matches!(p.layer.as_str(),"F.Cu"|"B.Cu")||!adjacent(&p.layer,&p.reference_layer)||!material(p.dielectric_mm,p.epsilon_r)||!p.max_skew_mm.is_finite()||p.max_skew_mm<0.||!p.target_ohms.is_finite()||p.target_ohms<=0.||!p.tolerance_percent.is_finite()||p.tolerance_percent<=0. {return Err("differentialPairs requires unique names, distinct nets, outer microstrip layer, adjacent reference, explicit dielectric and positive target/tolerance".into());}}+ for c in &self.capacitance {if c.name.trim().is_empty()||!names.insert(c.name.clone())||c.net_a==c.net_b||!adjacent(cu,&c.layer_a,&c.layer_b)||!material(c.dielectric_mm,c.epsilon_r){return Err("capacitance needs unique names, different nets, adjacent known layers and positive dielectricMm/epsilonR >= 1".into());}}+ for p in &self.differential_pairs {if p.name.trim().is_empty()||!names.insert(p.name.clone())||p.positive==p.negative||!matches!(p.layer.as_str(),"F.Cu"|"B.Cu")||!adjacent(cu,&p.layer,&p.reference_layer)||!material(p.dielectric_mm,p.epsilon_r)||!p.max_skew_mm.is_finite()||p.max_skew_mm<0.||!p.target_ohms.is_finite()||p.target_ohms<=0.||!p.tolerance_percent.is_finite()||p.tolerance_percent<=0. {return Err("differentialPairs requires unique names, distinct nets, outer microstrip layer, adjacent reference, explicit dielectric and positive target/tolerance".into());}} Ok(()) }} pub fn microstrip(w:f64,h:f64,t:f64,er:f64)->Option<f64>{@@ -74,7 +75,9 @@ pub fn analyze(b:&Board,r:&Raster,c:&Config,copper:&BTreeMap<String,f64>,fallbac let valid=warnings.iter().all(|w|w.starts_with("length skew"))&&!zdiffs.is_empty(); let range=|v:&Vec<f64>|if v.is_empty(){Value::Null}else{json!([v.iter().copied().fold(f64::INFINITY,f64::min),v.iter().copied().fold(f64::NEG_INFINITY,f64::max)])}; if valid&&zdiffs.iter().any(|z|(*z-p.target_ohms).abs()>p.target_ohms*p.tolerance_percent/100.){warnings.push("estimated differential impedance exceeds target tolerance".into());}- json!({"name":p.name,"definition":p,"lengthMm":lengths,"skewMm":skew,"gapMm":range(&gaps),"singleEndedOhms":if valid{range(&z0s)}else{Value::Null},"differentialOhms":if valid{range(&zdiffs)}else{Value::Null},"status":if !valid{"unsupported-or-incomplete"}else if warnings.is_empty(){"screen-within-target"}else{"screen-outside-target"},"warnings":warnings})+ // each net's length per layer, so a refusal shows its reason (one side on another layer is not a pair; issue 9)+ let per_layer=|net:&str|->BTreeMap<String,f64>{let mut m=BTreeMap::new();for t in b.tracks.iter().filter(|t|t.net==net){*m.entry(t.layer.clone()).or_insert(0.0)+=len(t);}for v in m.values_mut(){*v=(*v*100.0).round()/100.0;}m};+ json!({"name":p.name,"definition":p,"lengthMm":lengths,"lengthByLayerMm":{p.positive.clone():per_layer(&p.positive),p.negative.clone():per_layer(&p.negative)},"skewMm":skew,"gapMm":range(&gaps),"singleEndedOhms":if valid{range(&z0s)}else{Value::Null},"differentialOhms":if valid{range(&zdiffs)}else{Value::Null},"status":if !valid{"unsupported-or-incomplete"}else if warnings.is_empty(){"screen-within-target"}else{"screen-outside-target"},"warnings":warnings}) }).collect(); json!({"capacitance":caps,"differentialPairs":pairs,"method":"Screening estimates, not signal-integrity signoff. Outer-layer equal-width microstrip only; sampled reference coverage and parallel partner gaps. No solder mask, pads, packages, loss, dispersion, crosstalk, S-parameters or eye diagrams.","sources":["https://www.ti.com/lit/an/slva531a/slva531a.pdf","https://www.ti.com/seclit/eb/slyw038c/slyw038c.pdf"],"sampleMm":r.res}) }@@ -93,7 +96,7 @@ pub fn analyze(b:&Board,r:&Raster,c:&Config,copper:&BTreeMap<String,f64>,fallbac b.polys.push(poly("GND","In1.Cu"));b.polys.push(poly("POWER","In2.Cu")); let mut r=Raster::new(&b,0.05);r.draw_board(&b); let c:Config=serde_json::from_value(json!({"capacitance":[{"name":"planes","netA":"GND","layerA":"In1.Cu","netB":"POWER","layerB":"In2.Cu","dielectricMm":0.2,"epsilonR":4}],"differentialPairs":[{"name":"pair","positive":"P","negative":"N","layer":"F.Cu","referenceNet":"GND","referenceLayer":"In1.Cu","dielectricMm":0.2,"epsilonR":4.2,"maxSkewMm":0.1,"targetOhms":90,"tolerancePercent":20}]})).unwrap();- c.validate().unwrap();let a=analyze(&b,&r,&c,&BTreeMap::new(),35.);+ c.validate(&[]).unwrap();let a=analyze(&b,&r,&c,&BTreeMap::new(),35.); assert!((a["capacitance"][0]["capacitancePf"].as_f64().unwrap()-17.7083756256).abs()<1e-8); assert_eq!(a["differentialPairs"][0]["status"],"screen-within-target"); assert_eq!(a["differentialPairs"][0]["skewMm"],0.0);
src/solve.rs+19−1@@ -40,8 +40,26 @@ impl System { for &(a,b,g) in &self.edges { d[a] += g; d[b] += g; } d }- /// Solve A x = b to a relative residual of `tol` (Jacobi-preconditioned CG). Returns (x, iterations, residual).+ /// Solve A x = b to a TRUE relative residual of `tol`: CG, then iterative refinement (A dx = b - Ax) while the+ /// recomputed residual is over tol, because CG's own recursive residual drifts on badly conditioned nets (issue 11).+ /// Returns (x, iterations, true residual). pub fn solve(&self, b: &[f64], tol: f64, max_iter: usize) -> (Vec<f64>, usize, f64) {+ let (mut x, mut its, _) = self.cg(b, tol, max_iter);+ let bnorm = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);+ let mut ax = vec![0.0; x.len()];+ let mut true_res = 0.0;+ for _ in 0..6 {+ self.matvec(&x, &mut ax);+ let r: Vec<f64> = b.iter().zip(&ax).map(|(bi, ai)| bi - ai).collect();+ true_res = r.iter().map(|v| v * v).sum::<f64>().sqrt() / bnorm;+ if true_res < tol || its >= max_iter { break; }+ let (dx, it2, _) = self.cg(&r, (tol * bnorm / r.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-300)).min(0.5), max_iter - its);+ for (xi, d) in x.iter_mut().zip(&dx) { *xi += d; }+ its += it2;+ }+ (x, its, true_res)+ }+ fn cg(&self, b: &[f64], tol: f64, max_iter: usize) -> (Vec<f64>, usize, f64) { let n = self.n(); let diag = self.diagonal(); let minv: Vec<f64> = diag.iter().map(|d| if *d > 0.0 { 1.0 / d } else { 0.0 }).collect();
src/ui/app.js+13−4@@ -40,10 +40,16 @@ function boardSpace(x, y, z = 0) { return new BAB.Vector3(x - board.x0 - board.widthMm / 2, board.y0 + board.heightMm / 2 - y, z); } function buildBoard(f) { board = f.board;+ // the copper slots from the solve (six since 0.7.16; older solves four), the board's real ones drawn+ if (Array.isArray(board.layers) && board.layers.length) LAYERS.splice(0, LAYERS.length, ...board.layers);+ const realCu = board.copperLayers || LAYERS; const cu = board.copperUmByLayer || Object.fromEntries(LAYERS.map(l=>[l,board.copperUm]));- const dielectric = board.dielectricMm || [0.2,1.065,0.2];- Z['F.Cu']=board.thicknessMm/2-cu['F.Cu']/2000;- for (let k=1;k<LAYERS.length;k++) Z[LAYERS[k]]=Z[LAYERS[k-1]]-dielectric[k-1]-(cu[LAYERS[k-1]]+cu[LAYERS[k]])/2000;+ const cuT = (L) => realCu.includes(L) ? (cu[L] || board.copperUm) : 0;+ const dielectric = (board.slotDielectricMm && board.slotDielectricMm.length === LAYERS.length - 1) ? board.slotDielectricMm : (board.dielectricMm || [0.2,1.065,0.2]);+ Z['F.Cu']=board.thicknessMm/2-cuT('F.Cu')/2000;+ for (let k=1;k<LAYERS.length;k++) Z[LAYERS[k]]=Z[LAYERS[k-1]]-dielectric[k-1]-(cuT(LAYERS[k-1])+cuT(LAYERS[k]))/2000;+ // the layer buttons: the board's own copper+ const seg = document.getElementById('layer'); if (seg) seg.innerHTML = realCu.map(L => `<button data-l="${L}">${L === 'F.Cu' || L === 'B.Cu' ? L : L.replace('.Cu', '')}</button>`).join(''); const W = board.widthMm, H = board.heightMm, T = board.thicknessMm; root = new BAB.TransformNode('fields-board', scene);@@ -54,6 +60,7 @@ plane.position.z = Z[L]; const m = new BAB.StandardMaterial('M-' + L, scene); m.disableLighting = true; m.backFaceCulling = false; m.emissiveColor = new BAB.Color3(1, 1, 1); m.useAlphaFromDiffuseTexture = true; plane.material = m; plane.isPickable = L === 'F.Cu' || L === 'B.Cu'; plane.parent = root;+ plane.setEnabled(realCu.includes(L)); // an empty slot (a two-layer board's inner ones) draws nothing state.layerMeshes[L] = plane; } for (const hs of f.heatsinks || []) {@@ -351,10 +358,12 @@ const m = state.layerMeshes[L].material; m.diffuseTexture = state.tex[key]; const selected = state.view.layer || 'F.Cu'; m.alpha = (L === selected) ? 1.0 : (state.view.quantity === 'temperature' ? 0.0 : 0.35);- if (L === 'In1.Cu' || L === 'In2.Cu') m.alpha = (L === selected) ? 1.0 : 0.0;+ if (L !== 'F.Cu' && L !== 'B.Cu') m.alpha = (L === selected) ? 1.0 : 0.0; } if (viaRoot) viaRoot.setEnabled(!['temperature', 'river', 'signals'].includes(state.view.quantity)); 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'; }+ // each view's tags and panel show only while it is the view, even if its animation loop is paused (a background tab)+ document.body.dataset.view = state.view.quantity; $('#signalsPanel').hidden = state.view.quantity !== 'signals'; $('#legend').hidden = state.view.quantity === 'signals'; $('#readout').hidden = state.view.quantity === 'signals';
src/ui/flow.js+4−1@@ -37,7 +37,10 @@ export async function attach(ctx) { // ---- the view's knobs, eased so nothing snaps ---------------------------------------------------- const knob = { explode: 0, explodeTo: 0, river: 0, riverTo: 0, flowOn: 0, flowOnTo: 0, energyOn: 0, energyTo: 0, lightOn: 0, lightTo: 0, chargeOn: 0, chargeTo: 0, electrons: false, speed: 1, time: 0, tide: 0 }; const gap = Math.max(3, 0.16 * Math.max(board.widthMm, board.heightMm));- const spread = [1.5, 0.5, -0.5, -1.5];+ // pulled apart, the real copper layers spread evenly; an empty slot sits between its neighbours+ const realCu = board.copperLayers || LAYERS;+ const spread = (() => { const ri = LAYERS.map(L => realCu.indexOf(L)); const n = Math.max(1, realCu.length - 1); const val = ri.map(i => i >= 0 ? 1.5 - 3 * i / n : null);+ for (let k = 0; k < val.length; k++) if (val[k] === null) { let a = k - 1; while (a >= 0 && val[a] === null) a--; let z = k + 1; while (z < val.length && val[z] === null) z++; const va = a >= 0 ? val[a] : 1.5, vz = z < val.length ? val[z] : -1.5; val[k] = va + (vz - va) * (k - a) / (z - a); } return val; })(); const zOf = (k) => Z[LAYERS[k]] + knob.explode * gap * spread[k]; // the river: the top rail rises a quarter of the board's short side; drops within a net are exaggerated // so a millivolt slope can be seen, and the factor is printed on the badge
src/ui/ground.js+1−1@@ -162,7 +162,7 @@ void main(void) { } function narration() { const u = term.filter(t => !outside.some(o => o.ref === t.ref)).map(t => ({ ref: t.ref, m: mvAt(t.x, t.y) })).filter(t => t.m != null).sort((a, b) => b.m - a.m)[0];- return `This is the Ground view. Ground is supposed to be zero volts everywhere, but it is copper too, and the current returning through it makes small voltage drops. The sheet is the ground copper lifted by its voltage, greatly exaggerated: across this board it spans ${(span * 1000).toFixed(1)} millivolts${u ? `, highest under ${u.ref}, ${u.m.toFixed(1)} millivolts above the connectors` : ''}. That difference is noise that every chip sees on its own ground. ${outside.length > 1 ? `${outside.length} connectors carry ground to the outside: ${outside.map(o => o.ref).join(', ')}. If their cables meet somewhere off the board, they close a ground loop, drawn as the dashed orange arcs, and that loop picks up magnetic noise. ` : ''}${islands.length ? `There ${islands.length === 1 ? 'is one floating island' : 'are ' + islands.length + ' floating islands'} of ground copper, in red, with no via tying ${islands.length === 1 ? 'it' : 'them'} to the rest; floating copper acts as an antenna. ` : 'Every piece of ground copper is tied to the rest, with no floating islands. '}The blue rings show how far each ground via stitches the layers together for frequencies up to one gigahertz; ${covered > 95 ? 'the board edge is fully covered.' : `about ${covered.toFixed(0)} percent of the board edge is covered, and the red stretches are gaps where noise can leak out.`}`;+ return `This is the Ground view. Ground is supposed to be zero volts everywhere, but it is copper too, and the current returning through it makes small voltage drops. The sheet is the ground copper lifted by its voltage, greatly exaggerated: across this board it spans ${(span * 1000).toFixed(1)} millivolts${u ? `, highest under ${u.ref}, ${u.m.toFixed(1)} millivolts above the connectors` : ''}. That difference is noise that every chip sees on its own ground. ${outside.length > 1 ? `${outside.length} connectors carry ground to the outside: ${outside.map(o => o.ref).join(', ')}. If their cables meet somewhere off the board, they close a ground loop, drawn as the dashed orange arcs, and that loop picks up magnetic noise. ` : ''}${islands.length ? `There ${islands.length === 1 ? 'is one floating island' : 'are ' + islands.length + ' floating islands'} of ground copper, in red, with no via tying ${islands.length === 1 ? 'it' : 'them'} to the rest; floating copper acts as an antenna. ` : 'Every piece of ground copper is tied to the rest, with no floating islands. '}The blue rings show how far each ground via or plated ground pin stitches the layers together for frequencies up to ${(() => { const fm = (((state.fields.review || {}).ground || {}).stitching || {}).fmaxHz || 1e9; return fm >= 1e9 ? 'one gigahertz' : Math.round(fm / 1e6) + ' megahertz'; })()}, this board's strongest tones; ${covered > 95 ? 'the board edge is fully covered.' : `about ${covered.toFixed(0)} percent of the board edge is covered, and the red stretches are gaps where noise can leak out.`}`; } console.log(`ground: ${G.name} spans ${(span * 1000).toFixed(3)} mV; ${outside.length} ground connectors (${outside.map(o => o.ref).join(' ')}); ${islands.length} floating island(s) ${islands.map(i => i.mm2.toFixed(1) + ' mm²').join(' ')}; edge stitched ${covered.toFixed(0)} % with ${gvias.length} ground vias, reach ${reach.toFixed(1)} mm, largest gap ${gaps[0] ? gaps[0].mm.toFixed(1) : 0} mm`); return { apply, describe, narration, readout };
src/ui/index.html+1−1@@ -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}-.cyclemeter.signalspanel{width:290px}.lbl.signals{border-color:rgba(90,200,255,.5)}.lbl.signals .v{color:#7fd4ff;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}+.cyclemeter.signalspanel{width:290px}.lbl.signals{border-color:rgba(90,200,255,.5)}.lbl.signals .v{color:#7fd4ff;font-weight:600}body:not([data-view="emc"]) .lbl.emc,body:not([data-view="emc"]) .cyclemeter.emcpanel{display:none!important}body:not([data-view="ground"]) .lbl.ground,body:not([data-view="ground"]) .cyclemeter.groundpanel{display:none!important}body:not([data-view="light"]) .lbl.light,body:not([data-view="light"]) .cyclemeter.lightpanel{display:none!important}body:not([data-view="noise"]) .lbl.noise,body:not([data-view="noise"]) .cyclemeter.noisepanel{display:none!important}body:not([data-view="pdn"]) .lbl.pdn,body:not([data-view="pdn"]) .cyclemeter.pdnpanel{display:none!important}body:not([data-view="river"]) .lbl.river,body:not([data-view="river"]) .cyclemeter.riverpanel{display:none!important}body:not([data-view="signals"]) .lbl.signals,body:not([data-view="signals"]) .cyclemeter.signalspanel{display:none!important}.cyclemeter{overflow-x:hidden}.cyclemeter .row{flex-wrap:wrap}.cyclemeter .row i{white-space:normal}.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}
src/ui/noise.js+26−11@@ -10,7 +10,12 @@ export async function attach(ctx) { const { lay, real, W, H, d, meta, nets, zOf, bx, by } = flow.data; const rv = (state.fields.review || {}).noise || { cavities: [], crosstalk: [] }; let shapes = { cavities: [] }; try { shapes = await fetch('noise.json').then(r => r.ok ? r.json() : { cavities: [] }); } catch (e) { void e; }- const budget = 50;+ // the solve's own budget (spec crosstalkMv), and each victim's (victimBudgetsMv) (issue 9)+ const budget = rv.budgetMv != null ? rv.budgetMv : 50;+ const vb = (x) => x.budgetMv != null ? x.budgetMv : budget;+ const amt = (mv) => mv >= 1 ? mv.toFixed(0) + ' mV' : mv >= 0.001 ? (mv * 1000).toFixed(0) + ' µV' : (mv * 1e6).toFixed(0) + ' nV';+ const sw = (state.fields.nets || []).some(n => n.switching) || Object.keys(((state.fields.review || {}).converter) || {}).length > 0;+ const plan = rv.frequencyPlan || [], paths = rv.circuitPaths || []; const group = new BAB.TransformNode('fields-noise', scene); group.parent = root; group.setEnabled(false); // ---- the drum: the largest cavity's modes as a flexing surface -------------------------------------- const cav = (rv.cavities || []).slice().sort((a, b) => b.areaMm2 - a.areaMm2)[0];@@ -27,11 +32,11 @@ export async function attach(ctx) { drum = { mesh, mat: m, pos, col, g, amp: Math.min(3.5, 0.2 * Math.max(cw, ch) * hc) }; } // ---- crosstalk: victim copper glow, arcs at the closest approach --------------------------------------- const xt = (rv.crosstalk || []).filter(x => x.coupledMv > 1);+ const xt = (rv.crosstalk || []).filter(x => x.coupledMv > vb(x) / 10); const vtex = new Uint8Array(W * H * 4); const k0 = real[0]; const idOf = (name) => nets.findIndex(n => n.name === name); const aggIds = new Set(xt.map(x => idOf(x.aggressor)));- for (const x of xt) { const id = idOf(x.victim); const t = Math.min(1, x.coupledMv / budget); for (const k of real) for (let i = 0; i < W * H; i++) if (lay[k].net[i] === id) { const o = i * 4; vtex[o] = 255; vtex[o + 1] = Math.round(200 - 140 * t); vtex[o + 2] = 40; vtex[o + 3] = Math.round(90 + 165 * t); } }+ for (const x of xt) { const id = idOf(x.victim); const t = Math.min(1, x.coupledMv / vb(x)); for (const k of real) for (let i = 0; i < W * H; i++) if (lay[k].net[i] === id) { const o = i * 4; vtex[o] = 255; vtex[o + 1] = Math.round(200 - 140 * t); vtex[o + 2] = 40; vtex[o + 3] = Math.round(90 + 165 * t); } } const atex = new Uint8Array(W * H * 4); for (const k of real) for (let i = 0; i < W * H; i++) if (aggIds.has(lay[k].net[i])) { const o = i * 4; atex[o] = 255; atex[o + 1] = 90; atex[o + 2] = 220; atex[o + 3] = 200; } const plane = (name, data) => { const t = new BAB.RawTexture(data, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.NEAREST_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE); const p = BAB.MeshBuilder.CreatePlane(name, { 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 m = new BAB.StandardMaterial(name + '-m', scene); m.diffuseTexture = t; t.hasAlpha = true; m.useAlphaFromDiffuseTexture = true; m.emissiveColor = new BAB.Color3(1, 1, 1); m.disableLighting = true; m.backFaceCulling = false; p.material = m; p.parent = group; p.renderingGroupId = 2; p.isPickable = false; return { p, m }; };@@ -49,7 +54,7 @@ export async function attach(ctx) { // tags const host = document.getElementById('labels'), tags = []; const tag = (html, x, y, z) => { const el = document.createElement('div'); el.className = 'lbl noise'; el.innerHTML = html; el.hidden = true; host.appendChild(el); tags.push({ el, x, y, z }); };- for (const x of xt.slice(0, 5)) tag(`<b>${x.victim}</b> ← ${x.aggressor} <span class="${x.coupledMv > budget ? 'v' : 'g'}">${x.coupledMv.toFixed(0)} mV</span> · ${x.closestMm} mm`, x.at[0], x.at[1], 1.3);+ for (const x of xt.slice(0, 5)) tag(`<b>${x.victim}</b> ← ${x.aggressor} <span class="${x.coupledMv > vb(x) ? 'v' : 'g'}">${amt(x.coupledMv)}</span> · ${x.closestMm} mm`, x.at[0], x.at[1], 1.3); const modeTag = { el: null }; if (cav) { const el = document.createElement('div'); el.className = 'lbl noise'; el.hidden = true; host.appendChild(el); modeTag.el = el; } // ---- the panel -------------------------------------------------------------------------------------------@@ -60,8 +65,10 @@ export async function attach(ctx) { <div class="row" style="color:var(--text-2)">Plane cavities (lowest mode)</div> ${(rv.cavities || []).map(c => `<div class="row"><span class="dot" style="background:${c.modes.some(m => m.near) ? '#ff5a4a' : '#5cf08f'}"></span>${c.net} over ${c.over}<i>${c.modes[0] ? ghz(c.modes[0].fHz) : '?'}${c.modes.some(m => m.near) ? ' · rings' : ''}</i></div>`).join('') || '<div class="row">none</div>'} <div class="row" style="color:var(--text-2)">Strong tones reach ${topTone ? ghz(topTone) : '—'}</div>- <div class="row" style="color:var(--text-2);margin-top:6px">Crosstalk per edge (budget ${budget} mV)</div>- ${xt.slice(0, 4).map(x => `<div class="row"><span class="dot" style="background:${x.coupledMv > budget ? '#ff5a4a' : '#5cf08f'}"></span>${x.victim} ← ${x.aggressor}<i>${x.coupledMv.toFixed(0)} mV</i></div>`).join('') || '<div class="row">no quiet net near a switching node</div>'}+ <div class="row" style="color:var(--text-2);margin-top:6px">Crosstalk${sw ? ' per edge' : ''} (budget ${amt(budget)})</div>+ ${xt.slice(0, 4).map(x => `<div class="row"><span class="dot" style="background:${x.coupledMv > vb(x) ? '#ff5a4a' : '#5cf08f'}"></span>${x.victim} ← ${x.aggressor}<i>${amt(x.coupledMv)}</i></div>`).join('') || `<div class="row">no quiet net near ${sw ? 'a switching node' : 'a clock or oscillator'} over a tenth of its budget</div>`}+ ${plan.length ? `<div class="row" style="color:var(--text-2);margin-top:6px">Frequency plan: tones in a receive band</div>` + plan.slice(0, 5).map(p => `<div class="row"><span class="dot" style="background:${p.harmonic <= 5 ? '#ff5a4a' : '#ffc04d'}"></span>${p.tone.split('/').pop()} ×${p.harmonic}<i>${p.band} ${p.where === 'band' ? '' : 'image'}</i></div>`).join('') : ''}+ ${paths.length ? `<div class="row" style="color:var(--text-2);margin-top:6px">Circuit paths to sensitive nets</div>` + paths.map(p => `<div class="row"><span class="dot" style="background:#ffc04d"></span>${p.from.split('/').pop()} → ${p.to}<i>${p.hops.length} parts</i></div>`).join('') : ''} </div>`; panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); }); // ---- frame ------------------------------------------------------------------------------------------------@@ -97,16 +104,24 @@ export async function attach(ctx) { const leg = document.getElementById('legend'); if (leg) leg.hidden = true; } function describe() {- return ['<b>Noise couplings</b>: where switching noise rings and spreads',+ return [`<b>Noise couplings</b>: where ${sw ? 'switching' : 'clock and oscillator'} noise rings and spreads`, '<b>The flexing sheet</b> = a power pour over the ground plane ringing like a drum: its real lowest modes, red and blue the two phases',- '<b>Pink copper</b> = the switching node, pulsing; <b>yellow to red copper</b> = quiet nets picking up its edges, brighter for more millivolts',+ `<b>Pink copper</b> = ${sw ? 'the switching node, pulsing' : 'the clocks and oscillators'}; <b>yellow to red copper</b> = quiet nets picking it up, brighter nearer their budget`, '<b>Arcs</b> = where the coupling is closest',- 'A mode near a strong harmonic, or a quiet net over ' + budget + ' mV, is in the fix plan; parallel-plate and side-coupling estimates'];+ 'A mode near a strong harmonic, a quiet net over its budget (' + amt(budget) + ' unless set per net), or a tone in a receive band is in the fix plan; parallel-plate and side-coupling estimates']; } function narration() { const c0 = (rv.cavities || []).slice().sort((a, b) => b.areaMm2 - a.areaMm2)[0], x0 = xt[0];- return `This is the Noise view: where switching noise can ring and spread. ${c0 ? `Any power pour sitting over the ground plane is a cavity, like a drum skin, and it rings at its own frequencies. The flexing sheet is the ${c0.net} pour, ringing in its real lowest modes. Its first mode is at ${(c0.modes[0].fHz / 1e9).toFixed(1)} gigahertz, ${c0.modes.some(m => m.near) ? 'and one of its modes lines up with a strong harmonic, so it is in the fix plan, with a small capacitor where it rings loudest.' : `well above where this converter's harmonics are still strong, so it will not ring.`} ` : ''}${x0 ? `The pink copper is the switch node, pulsing with every edge. The quiet nets near it pick up a little of each edge through stray capacitance: ${x0.victim} gets about ${x0.coupledMv.toFixed(0)} millivolts, ${x0.coupledMv > budget ? 'over the budget, so the fix plan reroutes it away or adds a ground guard.' : 'under the fifty millivolt budget.'}` : 'No quiet net runs close to the switch node.'} Both are estimates, a screen for what to look at, and every finding goes into the fix plan for the AI.`;+ const src = sw ? 'the switch node' : 'the clocks and oscillators';+ const tone = sw ? "this converter's harmonics" : 'the strongest harmonics of its clocks and oscillators';+ const say = (mv) => mv >= 1 ? `${Math.round(mv)} millivolts` : `${Math.round(mv * 1000)} microvolts`;+ let t = `This is the Noise view: where ${sw ? 'switching' : 'clock and oscillator'} noise can ring and spread. `;+ if (c0) t += `Any power pour sitting over a ground plane is a cavity, like a drum skin, and it rings at its own frequencies. The flexing sheet is the ${c0.net} pour on ${c0.layer || 'its layer'}, ringing in its real lowest modes. Its first mode is at ${c0.modes[0] ? (c0.modes[0].fHz / 1e6).toFixed(0) + ' megahertz' : 'an unknown frequency'}, ${c0.modes.some(m => m.near) ? 'and one of its modes lines up with a strong tone, so it is in the fix plan, with a small capacitor where it rings loudest.' : `away from ${tone}, so it will not ring.`} `;+ if (plan.length) t += `The frequency plan finds ${plan.length} harmonic${plan.length > 1 ? 's' : ''} landing in a receive band or its image, for example harmonic ${plan[0].harmonic} of ${plan[0].tone.split('/').pop()} in the ${plan[0].band} ${plan[0].where}. `;+ if (paths.length) t += `There ${paths.length > 1 ? 'are' : 'is'} ${paths.length} path${paths.length > 1 ? 's' : ''} through the circuit from an oscillator to a sensitive net, such as ${paths[0].from.split('/').pop()} to ${paths[0].to}; the fix plan asks for a simulation of each. `;+ t += x0 ? `${sw ? 'The pink copper is the switch node, pulsing with every edge.' : 'The pink copper is the clocks and oscillators.'} The quiet nets near ${src} pick up a little through stray capacitance: ${x0.victim} gets about ${say(x0.coupledMv)}, ${x0.coupledMv > vb(x0) ? 'over its budget, so the fix plan reroutes it away or adds a ground guard.' : 'under its budget of ' + say(vb(x0)) + '.'}` : `No quiet net near ${src} picks up enough to matter.`;+ return t + ' These are estimates, a screen for what to look at, and every finding goes into the fix plan for the AI.'; }- console.log(`noise: ${(rv.cavities || []).length} cavities${cav ? `, drum ${cav.net} ${cs ? cs.shapes.length : 0} modes` : ''}; crosstalk ${xt.map(x => x.victim + '←' + x.aggressor + ' ' + x.coupledMv + ' mV').join(', ')}`);+ console.log(`noise: ${(rv.cavities || []).length} cavities${cav ? `, drum ${cav.net} ${cs ? cs.shapes.length : 0} modes` : ''}; crosstalk ${xt.map(x => x.victim + '←' + x.aggressor + ' ' + amt(x.coupledMv)).join(', ')}`); return { apply, describe, narration }; }