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.8.8: receiver screens (issue 9)
10 files changed
+285−26
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.8.7"+version = "0.8.8" 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+1@@ -177,6 +177,7 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Receiver screens** (0.8.8, `review.rf`): the LO level at the antenna (each circuit path from a `clocks` oscillator to a `sensitiveNets` net, solved as an AC network with transistors as their junction capacitances, cross-checked in ngspice, against FCC 15.111's -57 dBm: `lo-leak`), isolation between stages through a shared rail at the oscillator's frequency (`stage-isolation`, target 40 dB), the LO's own radiated emission against FCC class B at 3 m (`lo-emission`), and USB edge and FM IF-limiter odd harmonics in the frequency plan (`tone-in-band`). Tune with spec `rf`: `cbcPf`, `cbePf`, `antennaOhm`, `loLeakLimitDbm`, `stageIsolationDb`, `usbHighSpeed`, `limiter`. Screening estimates, not measurements. - **Field views frame themselves** (0.8.6): entering Energy (stack open 0.8, parts off, low front camera), Flow (0.6, parts ghosted, low side) or Magnetic (parts off, low side) sets the stack, parts and camera so the field shows; values the command sets win, `ui view --keep-camera` keeps everything as it is, and leaving for another view restores what was there. Camera presets: top, iso, front, low, bottom, fit, zoom. - **Terminals** (0.8.1): without explicit `loads.<net>.terminals`, the solve picks them. The named `sources` feed the net, and every other connector and part on it is a sink, so a board-to-board connector that a rail feeds counts as a load. Otherwise connectors feed and parts draw. A part listed in `loads.<net>.draws` carries its own amps, the rest share what is left (sinks by pad area, sources evenly), and each part's share splits evenly over its pins. A pin with no copper near it is left out and its share goes to the other pins on its side, so the injection always balances. Every net in fields.json lists `terminalCurrents` (pin, amps, + into the copper) and `terminalBasis`, so a reviewer can check them. - **Solid bodies off the board** (0.8.0): `solids: {pitchMm, bodies: [{name, boxesMm: [[x0,y0,z0,x1,y1,z1]], conductivityWMk, watts | heatOf: "<hot ref>", hStillWM2k, hMovingWM2k, boardContactM2kW?, provenance}], contacts: [{bodies: [a, b], resistanceM2kW}], boardLinks: [{body, at, layer, resistanceKPerW, pad}], assumptions}`. Boxes are in board mm (x and y as KiCad, z up from the top copper). The bodies are voxelized and solved in the same heat system as the board, so the heat runs from the die through the tab, the pad and the heatsink fins to the air, and down the leads into the copper, in one energy balance. Bodies may touch only through a declared contact (0 means ideal), and they may not overlap. `fields.json` `solids` gives each body's peak rise, heat to air and to board, and peak flux. `solids.json` has every cell's temperature and flux vector, and the viewer paints them in the Heat and Temperature views. Calibrate a sink's h to its vendor rating rather than trusting a textbook h. See docs/solids.md.
docs/release-0.8.8.mdadded+20@@ -0,0 +1,20 @@+# adom-fields 0.8.8++These are the receiver checks left open after the issue 9 review. Each is a screening estimate, in `review.rf`.++- **LO level at the antenna (`lo-leak`).**+ - **The circuit:** the circuit path from an oscillator to a sensitive net is solved as an AC network. It includes every part touching the path and each transistor's junction capacitances (Cbc, Cbe). The antenna is its port resistance.+ - **The result:** dBm at the bottom, middle and top of a swept LO, cross-checked in ngspice, against FCC 15.111's 2 nW (-57 dBm).+ - **On the AM/FM receiver:** about -41 dBm through C321, C313, Q301's Cbc and the HPF/BPF. ngspice agrees to 0.1 dB.+- **Isolation between stages through a shared rail (`stage-isolation`).**+ - **The circuit:** an oscillator stage's feed resistor, the rail with its capacitors and source, and each other stage's feed filter on that rail, at the oscillator's frequency, against 40 dB.+ - **On the AM/FM receiver:** V_LO1 to V_RF1 on +5V_FM is 64.5 dB.+- **LO radiated emission (`lo-emission`).**+ - **The estimate:** the LO's copper is a small loop over its reference plane, carrying the current the LO voltage pushes into the copper and the parts, checked against FCC class B at 3 m.+ - **On the AM/FM receiver:** 13.4 dBµV/m, 30 dB under.+- **USB and limiter harmonics in the frequency plan (`tone-in-band`).**+ - **The tones:** a full-speed USB pair's 12 MHz edge harmonics (480 MHz with `rf.usbHighSpeed`), and an FM IF limiter's odd harmonics of the IF.+ - **On the AM/FM receiver:**+ - USB's 8th and 9th harmonics land in FM, and its 10th in the FM image.+ - The limiter's 9th harmonic of 10.7 MHz lands at 96.3 MHz, inside FM.+- **Spec `rf`:** `cbcPf`, `cbePf`, `antennaOhm`, `loLeakLimitDbm`, `stageIsolationDb`, `usbHighSpeed`, `limiter`.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.8.7",+ "version": "0.8.8", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",
page.json+8−23@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.8.7",+ "version": "0.8.8", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",@@ -17,10 +17,7 @@ "vias", "3d", "analysis",- "eda",- "current flow animation",- "voltage",- "river"+ "eda" ], "status": "live", "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.",@@ -40,10 +37,11 @@ "fields", "flow of current", "river view",- "voltage drop in 3d",- "ground bounce",+ "energy flow on a board",+ "poynting vector",+ "speed of light on a pcb",+ "electron drift speed", "exploded layer stack",- "current through vias", "explain the view out loud", "narrate the fields", "energy cycle",@@ -85,20 +83,7 @@ "anti-resonance", "input filter stability", "middlebrook",- "ngspice pdn",- "signals view",- "copper capacitance",- "switch node capacitance",- "plane capacitance",- "differential pair skew",- "six layer board",- "receiver frequency plan",- "lo leakage",- "tone in band",- "rf crosstalk",- "return path over supply plane",- "antenna ground loop",- "stage feed bypass"+ "ngspice pdn" ], "sample_prompts": [ {@@ -139,4 +124,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-fields/SKILL.md+1@@ -177,6 +177,7 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Receiver screens** (0.8.8, `review.rf`): the LO level at the antenna (each circuit path from a `clocks` oscillator to a `sensitiveNets` net, solved as an AC network with transistors as their junction capacitances, cross-checked in ngspice, against FCC 15.111's -57 dBm: `lo-leak`), isolation between stages through a shared rail at the oscillator's frequency (`stage-isolation`, target 40 dB), the LO's own radiated emission against FCC class B at 3 m (`lo-emission`), and USB edge and FM IF-limiter odd harmonics in the frequency plan (`tone-in-band`). Tune with spec `rf`: `cbcPf`, `cbePf`, `antennaOhm`, `loLeakLimitDbm`, `stageIsolationDb`, `usbHighSpeed`, `limiter`. Screening estimates, not measurements. - **Field views frame themselves** (0.8.6): entering Energy (stack open 0.8, parts off, low front camera), Flow (0.6, parts ghosted, low side) or Magnetic (parts off, low side) sets the stack, parts and camera so the field shows; values the command sets win, `ui view --keep-camera` keeps everything as it is, and leaving for another view restores what was there. Camera presets: top, iso, front, low, bottom, fit, zoom. - **Terminals** (0.8.1): without explicit `loads.<net>.terminals`, the solve picks them. The named `sources` feed the net, and every other connector and part on it is a sink, so a board-to-board connector that a rail feeds counts as a load. Otherwise connectors feed and parts draw. A part listed in `loads.<net>.draws` carries its own amps, the rest share what is left (sinks by pad area, sources evenly), and each part's share splits evenly over its pins. A pin with no copper near it is left out and its share goes to the other pins on its side, so the injection always balances. Every net in fields.json lists `terminalCurrents` (pin, amps, + into the copper) and `terminalBasis`, so a reviewer can check them. - **Solid bodies off the board** (0.8.0): `solids: {pitchMm, bodies: [{name, boxesMm: [[x0,y0,z0,x1,y1,z1]], conductivityWMk, watts | heatOf: "<hot ref>", hStillWM2k, hMovingWM2k, boardContactM2kW?, provenance}], contacts: [{bodies: [a, b], resistanceM2kW}], boardLinks: [{body, at, layer, resistanceKPerW, pad}], assumptions}`. Boxes are in board mm (x and y as KiCad, z up from the top copper). The bodies are voxelized and solved in the same heat system as the board, so the heat runs from the die through the tab, the pad and the heatsink fins to the air, and down the leads into the copper, in one energy balance. Bodies may touch only through a declared contact (0 means ideal), and they may not overlap. `fields.json` `solids` gives each body's peak rise, heat to air and to board, and peak flux. `solids.json` has every cell's temperature and flux vector, and the viewer paints them in the Heat and Temperature views. Calibrate a sink's h to its vendor rating rather than trusting a textbook h. See docs/solids.md.
src/fields.rs+3@@ -32,6 +32,8 @@ pub struct Spec { #[serde(default)] pub packageCooling: Vec<crate::package_cooling::Package>, /// solid bodies off the board (package, pad, finned sink) solved with the board's heat (crate::solids) #[serde(default)] pub solids: Option<crate::solids::Solids>,+ /// receiver screens (crate::rf, issue 9): junction capacitances, the antenna port, the LO leak limit, the stage isolation target+ #[serde(default)] pub rf: RfCfg, /// fan levels to solve besides still air (0) and full moving air (1), as fractions of full speed, e.g. [0.25, 0.5, 0.75] #[serde(default)] pub fanLevels: Vec<f64>, /// forced-convection h grows as speed^n (0.5 laminar, 0.8 turbulent); 0.5 when not given@@ -626,6 +628,7 @@ impl Clock { pub fn harmonics(&self) -> Option<usize> { match self { Clock::Cfg { harmonics, .. } => *harmonics, _ => None } } pub fn amplitude(&self) -> Option<f64> { match self { Clock::Cfg { amplitudeV, .. } => *amplitudeV, _ => None } } }+#[derive(Deserialize, Default, Clone)] pub struct RfCfg { #[serde(default)] pub cbcPf: Option<f64>, #[serde(default)] pub cbePf: Option<f64>, #[serde(default)] pub antennaOhm: Option<f64>, #[serde(default)] pub loLeakLimitDbm: Option<f64>, #[serde(default)] pub stageIsolationDb: Option<f64>, #[serde(default)] pub usbHighSpeed: Option<bool>, #[serde(default)] pub limiter: Option<bool> } #[derive(Deserialize, Clone, Debug)] pub struct Band { pub minHz: f64, pub maxHz: f64, #[serde(default)] pub ifHz: Option<f64>, #[serde(default)] pub lowSideLo: bool }
src/main.rs+3−1@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod caps; mod solids; mod pours; mod probe; mod terminals; mod barrel; mod package_cooling; mod thermal3d; mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+mod caps; mod rf; mod solids; mod pours; mod probe; mod terminals; mod barrel; mod package_cooling; mod thermal3d; mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use std::path::PathBuf;@@ -160,6 +160,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck let fragments = pours::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()));+ let rfv = rf::review(&b, &spec, &json!({"noise": nz.clone(), "stageFeeds": review["stageFeeds"].clone()}), out.as_path(), &mut iss); // 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);@@ -210,6 +211,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck if let Some(cav) = nz["cavities"].as_array_mut() { let shapes: Vec<Value> = cav.iter_mut().map(|c| json!({"net": c["net"].clone(), "grid": c["grid"].clone(), "shapes": c["shapes"].take()})).collect(); for c in cav.iter_mut() { c.as_object_mut().map(|o| o.remove("shapes")); } std::fs::write(out.join("noise.json"), serde_json::to_string(&json!({"cavities": shapes})).unwrap()).map_err(|e| e.to_string())?; } review["noise"] = nz; review["pdn"] = pd;+ review["rf"] = rfv; summary["fragments"] = fragments; summary["review"] = review; summary["fixPlan"] = emc::fix_plan(&f.issues);
src/noise.rs+23@@ -187,6 +187,29 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va } } }+ // aggressors with no oscillator net of their own (issue 9): a full-speed USB pair's 12 MHz edges and harmonics, and an FM+ // IF limiter's odd harmonics of the IF (a hard limiter squares the IF, so 3x, 5x, 7x... are strong)+ let mut extra: Vec<(String, f64, bool)> = Vec::new();+ let usb = f.net_names.iter().any(|n| { let u = n.to_ascii_uppercase(); u.contains("USB_D") || u.ends_with("D+") || u.ends_with("D-") || u.ends_with("USB_DP") || u.ends_with("USB_DM") });+ if usb { extra.push(("USB full-speed edges (12 MHz)".into(), 12e6, false)); if spec.rf.usbHighSpeed == Some(true) { extra.push(("USB high-speed (480 MHz)".into(), 480e6, false)); } }+ for (bn, bd) in &spec.bands { if let Some(fi) = bd.ifHz { if spec.rf.limiter.unwrap_or_else(|| bn.to_ascii_uppercase().contains("FM")) { extra.push((format!("{bn} IF limiter ({:.1} MHz, odd harmonics)", fi / 1e6), fi, true)); } } }+ for (name, base, odd) in &extra {+ 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) });+ let top = bd.maxHz.max(image.map_or(0.0, |x| x.1));+ let mut k = if *odd { 3 } else { 1 };+ while (k as f64) * base <= top && k <= 400 {+ let fk = k as f64 * base;+ for (what, lo, hi) in [("band", bd.minHz, bd.maxHz)].into_iter().chain(image.map(|x| ("image band", x.0, x.1))) {+ if fk >= lo && fk <= hi {+ plan.push(json!({"tone": name, "harmonic": k, "fromHz": fk, "toHz": fk, "band": bn, "where": what, "bandHz": [lo, hi]}));+ issues.push(Issue { severity: if k <= 5 { "medium".into() } else { "low".into() }, kind: "tone-in-band".into(), what: format!("harmonic {} of {} ({:.2} MHz) lands in the {} {} ({})", k, name, fk / 1e6, bn, what, hz_txt(lo, hi)), at: None, layer: None, net: None, reference: None, fix: if name.starts_with("USB") { "Keep the USB pair and its connector away from the front end and over unbroken ground; a common-mode choke on the pair cuts the edge harmonics.".into() } else { "Filter and shield the limiter's output, keep its traces away from the front end, and consider an IF that keeps its odd harmonics out of the band.".into() }, action: None });+ }+ }+ k += if *odd { 2 } else { 1 };+ }+ }+ } 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();
src/rf.rsadded+224@@ -0,0 +1,224 @@+//! Receiver screens left open by the issue 9 review (Noah's AM/FM superhet), each a screening estimate, never a+//! measurement:+//! - the LO level at the antenna port: the circuit path an LO takes to a sensitive net (found by the noise review), as a+//! small AC network (every part touching the path, transistors as their junction capacitances) solved for the voltage+//! on the antenna's port resistance, against FCC 15.111's 2 nW (-57 dBm) receiver limit; cross-checked in ngspice when+//! it is installed;+//! - the isolation between stages through a shared rail: an oscillator stage's feed driving the rail through its series+//! resistor, the rail's capacitors and source, and each other stage's feed filter, at the oscillator's frequency, in dB;+//! - the LO's own radiated emission: its copper as a small loop over the reference plane, carrying the current its+//! voltage pushes into the copper's and the parts' capacitance, against FCC class B at 3 m.+//! The fourth open item (USB and limiter harmonics as aggressors) lives in the noise review's frequency plan.+use crate::board::Board;+use crate::fields::{Issue, Spec};+use crate::flow::{is_ground, rail_from_name};+use serde_json::{json, Value};+use std::f64::consts::PI;++#[derive(Clone, Copy, Debug)]+pub struct Cx(pub f64, pub f64);+impl Cx {+ fn add(self, o: Cx) -> Cx { Cx(self.0 + o.0, self.1 + o.1) }+ fn sub(self, o: Cx) -> Cx { Cx(self.0 - o.0, self.1 - o.1) }+ fn mul(self, o: Cx) -> Cx { Cx(self.0 * o.0 - self.1 * o.1, self.0 * o.1 + self.1 * o.0) }+ fn inv(self) -> Cx { let d = self.0 * self.0 + self.1 * self.1; Cx(self.0 / d, -self.1 / d) }+ fn div(self, o: Cx) -> Cx { self.mul(o.inv()) }+ pub fn abs(self) -> f64 { self.0.hypot(self.1) }+}+fn par(a: Cx, b: Cx) -> Cx { a.mul(b).div(a.add(b)) }+fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }++/// a part's value in its base unit: 47, 4.7k, 4k7, 2.2p, 120n, 10u, 1M, 0R+pub fn value(s: &str) -> Option<f64> {+ let t = s.trim().replace(',', ".").replace('µ', "u").replace('Ω', "");+ let t = t.split_whitespace().next()?.to_string();+ let mul = |c: char| match c { 'p' => Some(1e-12), 'n' => Some(1e-9), 'u' => Some(1e-6), 'm' => Some(1e-3), 'k' | 'K' => Some(1e3), 'M' => Some(1e6), 'R' | 'r' => Some(1.0), _ => None };+ let chars: Vec<char> = t.chars().collect();+ // 4k7 / 2p2 / 0R1: the multiplier stands in for the decimal point+ if let Some(i) = chars.iter().position(|c| mul(*c).is_some()) {+ let (a, z): (String, String) = (chars[..i].iter().collect(), chars[i + 1..].iter().take_while(|c| c.is_ascii_digit()).collect());+ if !a.is_empty() && a.chars().all(|c| c.is_ascii_digit() || c == '.') {+ let v: f64 = if z.is_empty() { a.parse().ok()? } else { format!("{a}.{z}").parse().ok()? };+ return Some(v * mul(chars[i])?);+ }+ }+ let num: String = chars.iter().take_while(|c| c.is_ascii_digit() || **c == '.').collect();+ num.parse().ok()+}++/// the network: node 0 is AC ground (ground and every rail), each element an admittance between two nodes+struct Net { names: Vec<String>, el: Vec<(usize, usize, String, f64, char)> }+impl Net {+ fn node(&mut self, n: &str) -> usize { if is_ground(n) || rail_from_name(n).is_some() { return 0; } if let Some(i) = self.names.iter().position(|x| x == n) { return i + 1; } self.names.push(n.to_string()); self.names.len() }+ fn y(kind: char, v: f64, w: f64) -> Cx { match kind { 'R' => Cx(1.0 / v.max(1e-6), 0.0), 'C' => Cx(0.0, w * v), 'L' => Cx(0.0, -1.0 / (w * v.max(1e-15))), _ => Cx(0.0, 0.0) } }+ /// node voltages with node `src` held at `vs`, a resistive port `port` to ground; a 1 GΩ leak keeps floating nodes defined+ fn solve(&self, w: f64, src: usize, vs: f64, port: usize, port_ohm: f64) -> Vec<Cx> {+ let n = self.names.len();+ let mut a = vec![vec![Cx(0.0, 0.0); n + 1]; n];+ for (i, row) in a.iter_mut().enumerate() { row[i] = Cx(1e-9, 0.0); }+ a[port - 1][port - 1] = a[port - 1][port - 1].add(Cx(1.0 / port_ohm, 0.0));+ for &(p, q, _, v, k) in &self.el { let y = Self::y(k, v, w); for (u, x) in [(p, q), (q, p)] { if u == 0 { continue; } a[u - 1][u - 1] = a[u - 1][u - 1].add(y); if x != 0 { a[u - 1][x - 1] = a[u - 1][x - 1].sub(y); } } }+ // the source node held: its row becomes v = vs+ for c in 0..=n { a[src - 1][c] = Cx(0.0, 0.0); } a[src - 1][src - 1] = Cx(1.0, 0.0); a[src - 1][n] = Cx(vs, 0.0);+ // Gaussian elimination with partial pivoting+ for c in 0..n {+ let p = (c..n).max_by(|x, y| a[*x][c].abs().partial_cmp(&a[*y][c].abs()).unwrap()).unwrap(); a.swap(c, p);+ let d = a[c][c]; if d.abs() < 1e-30 { continue; }+ for r in 0..n { if r != c { let f = a[r][c].div(d); if f.abs() == 0.0 { continue; } for k in c..=n { a[r][k] = a[r][k].sub(f.mul(a[c][k])); } } }+ }+ let mut v = vec![Cx(0.0, 0.0)]; for r in 0..n { v.push(a[r][n].div(a[r][r])); } v+ }+}++fn kind_of(reference: &str, val: &str) -> Option<char> {+ let p: String = reference.chars().take_while(|c| c.is_ascii_alphabetic()).collect();+ match p.as_str() { "R" => Some('R'), "C" => Some('C'), "L" => Some('L'), "FB" => Some('B'), "D" => Some('D'), "Q" if !val.to_ascii_uppercase().contains("FET") => Some('Q'), _ => None }+}++pub fn review(b: &Board, spec: &Spec, rev: &Value, work: &std::path::Path, issues: &mut Vec<Issue>) -> Value {+ let rf = &spec.rf;+ let cbc = rf.cbcPf.unwrap_or(0.7) * 1e-12; let cbe = rf.cbePf.unwrap_or(2.0) * 1e-12; let port = rf.antennaOhm.unwrap_or(50.0);+ let limit_dbm = rf.loLeakLimitDbm.unwrap_or(-57.0);+ let mut out = json!({"assumptions": [+ format!("Transistors on a leakage path are their junction capacitances only (Cbc {:.1} pF, Cbe {:.1} pF; spec rf.cbcPf / rf.cbePf): reverse isolation of an unbiased stage, no gain", cbc * 1e12, cbe * 1e12),+ format!("The sensitive net's port is {port:.0} Ω (spec rf.antennaOhm); rails and ground are AC ground"),+ "The LO amplitude is the spec's clocks.<net>.amplitudeV, taken as peak, held at the LO node".to_string(),+ "Rail isolation: regulator 0.1 Ω + 50 nH, each capacitor with 30 mΩ ESR and 1 nH ESL (estimates)".to_string()]});+ // ---- 1. the LO level at the antenna, along each circuit path the noise review found ---------------------------------+ let pads_of = |r: &str| -> Vec<&crate::board::Pad> { b.pads.iter().filter(|p| p.reference == r).collect() };+ let mut leaks = Vec::new();+ for path in rev["noise"]["circuitPaths"].as_array().cloned().unwrap_or_default() {+ let (from, to) = (path["from"].as_str().unwrap_or("").to_string(), path["to"].as_str().unwrap_or("").to_string());+ let Some(clock) = spec.clocks.get(&from) else { continue };+ let Some(amp) = clock.amplitude() else { continue };+ let mut set: Vec<String> = vec![from.clone()];+ for h in path["hops"].as_array().cloned().unwrap_or_default() { for k in ["from", "to"] { if let Some(s) = h[k].as_str() { if !set.iter().any(|x| x == s) { set.push(s.to_string()); } } } }+ // a transistor on the path brings all its pins' nets+ let parts_on = |nets: &[String]| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().filter(|p| nets.iter().any(|n| *n == p.net)).map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };+ for r in parts_on(&set) { if kind_of(&r, b.values.get(&r).map(String::as_str).unwrap_or("")) == Some('Q') && path["hops"].as_array().is_some_and(|hs| hs.iter().any(|h| h["part"] == r.as_str())) { for p in pads_of(&r) { if !set.contains(&p.net) && !p.net.is_empty() { set.push(p.net.clone()); } } } }+ let mut net = Net { names: Vec::new(), el: Vec::new() };+ for s in &set { net.node(s); }+ let mut deck = format!("* adom-fields LO leakage: {from} to {to}\n");+ for r in parts_on(&set) {+ let val = b.values.get(&r).cloned().unwrap_or_default();+ let Some(k) = kind_of(&r, &val) else { continue };+ let ps = pads_of(&r);+ match k {+ 'Q' => {+ // the pins: by name (B, C, E), by the net's suffix, else SOT-23 order (1 B, 2 E, 3 C)+ let pin = |role: char| -> Option<String> { ps.iter().find(|p| p.func.eq_ignore_ascii_case(&role.to_string())).or_else(|| ps.iter().find(|p| p.net.to_ascii_uppercase().ends_with(&format!("_{role}")))).or_else(|| ps.iter().find(|p| p.pad == match role { 'B' => "1", 'E' => "2", _ => "3" })).map(|p| p.net.clone()) };+ if let (Some(bn), Some(cn), Some(en)) = (pin('B'), pin('C'), pin('E')) { let (nb, nc, ne) = (net.node(&bn), net.node(&cn), net.node(&en)); net.el.push((nb, nc, format!("{r}_cbc"), cbc, 'C')); net.el.push((nb, ne, format!("{r}_cbe"), cbe, 'C')); }+ }+ _ => {+ if ps.len() != 2 { continue; }+ let (v, kk) = match k { 'B' => (100.0, 'R'), 'D' => (1e-12, 'C'), _ => match value(&val) { Some(v) => (v, k), None => continue } };+ let (p, q) = (net.node(&ps[0].net), net.node(&ps[1].net)); if p == q { continue; }+ net.el.push((p, q, r.clone(), v, kk));+ }+ }+ }+ let (si, pi) = (net.node(&from), net.node(&to));+ let n_of = |i: usize| if i == 0 { "0".to_string() } else { format!("n{i}") };+ for (k, (p, q, name, v, kk)) in net.el.iter().enumerate() { deck.push_str(&format!("{}{}_{} {} {} {:e}\n", kk, k, name.replace(|c: char| !c.is_ascii_alphanumeric(), "_"), n_of(*p), n_of(*q), v)); }+ for i in 1..=net.names.len() { deck.push_str(&format!("Rleak{i} {} 0 1e9\n", n_of(i))); }+ deck.push_str(&format!("Rport {} 0 {port}\nVlo {} 0 AC {amp}\n", n_of(pi), n_of(si)));+ let freqs: Vec<f64> = if clock.swept() { vec![clock.lo_hz(), clock.mid_hz(), clock.top_hz()] } else { vec![clock.mid_hz()] };+ let mut pts = Vec::new(); let mut worst: (f64, f64) = (f64::MIN, 0.0);+ for &fq in &freqs {+ let v = net.solve(2.0 * PI * fq, si, amp, pi, port)[pi].abs();+ let dbm = 10.0 * ((v * v / 2.0 / port) / 1e-3).max(1e-30).log10();+ let spice = spice_leak(&deck, fq, pi, work);+ if dbm > worst.0 { worst = (dbm, fq); }+ pts.push(json!({"hz": fq, "portV": v, "dBm": r1(dbm), "ngspiceDbm": spice.map(|vs| r1(10.0 * ((vs * vs / 2.0 / port) / 1e-3).max(1e-30).log10()))}));+ }+ leaks.push(json!({"from": from, "to": to, "amplitudeV": amp, "nodes": net.names.len(), "elements": net.el.len(), "points": pts, "worstDbm": r1(worst.0), "worstHz": worst.1, "limitDbm": limit_dbm}));+ if worst.0 > limit_dbm - 6.0 {+ issues.push(Issue { severity: if worst.0 > limit_dbm { "medium".into() } else { "low".into() }, kind: "lo-leak".into(), what: format!("{from} reaches {to} at about {:.0} dBm ({:.1} MHz) through the circuit path, against {limit_dbm:.0} dBm (FCC 15.111: 2 nW at a receiver's antenna terminals)", worst.0, worst.1 / 1e6), at: None, layer: None, net: Some(to.clone()), reference: None, fix: format!("Raise the isolation between the LO and the front end: a smaller coupling capacitor into the mixer, a buffer stage, or an RF stage with more reverse isolation (cascode); then measure the LO at {to} with a spectrum analyser."), action: Some(json!({"type": "review", "net": to, "why": "LO leakage to the antenna", "dBm": r1(worst.0)})) });+ }+ }+ out["loLeak"] = json!(leaks);+ // ---- 2. isolation between stages through a shared rail, at the oscillator's frequency ---------------------------------+ let feeds = rev["stageFeeds"].as_array().cloned().unwrap_or_default();+ let target = rf.stageIsolationDb.unwrap_or(40.0);+ let zcap = |c: f64, w: f64| Cx(0.03, w * 1e-9 - 1.0 / (w * c));+ let feed_r = |f: &Value| f["seriesValue"].as_str().and_then(value).unwrap_or(10.0);+ let feed_z = |f: &Value, w: f64| -> Cx { let mut z = Cx(1e9, 0.0); for c in f["caps"].as_array().cloned().unwrap_or_default() { if let Some(u) = c["uF"].as_f64() { z = par(z, zcap(u * 1e-6, w)); } } z };+ let mut iso = Vec::new();+ for a in &feeds {+ let Some(stage) = a["stage"].as_str() else { continue };+ // an oscillator stage: its transistor sits on a clock net+ let Some((cnet, clock)) = pads_of(stage).iter().find_map(|p| spec.clocks.get_key_value(&p.net)) else { continue };+ let f = clock.mid_hz(); let w = 2.0 * PI * f; let rail = a["rail"].as_str().unwrap_or("");+ // the rail: its source and its own capacitors (those not inside a feed)+ let mut zr = Cx(0.1, w * 50e-9);+ for r in b.values.keys().filter(|r| r.starts_with('C')) {+ let ps = pads_of(r); if ps.len() != 2 { continue; }+ let on = ps.iter().any(|p| p.net == rail) && ps.iter().any(|p| is_ground(&p.net));+ if on { if let Some(c) = b.values.get(r).and_then(|v| value(v)) { zr = par(zr, zcap(c, w)); } }+ }+ for o in feeds.iter().filter(|o| o["rail"] == rail && o["feed"] != a["feed"]) { zr = par(zr, Cx(feed_r(o), 0.0).add(feed_z(o, w))); }+ let v_rail = zr.div(Cx(feed_r(a), 0.0).add(zr));+ for v in feeds.iter().filter(|o| o["rail"] == rail && o["feed"] != a["feed"]) {+ let zv = feed_z(v, w); let vv = v_rail.mul(zv.div(Cx(feed_r(v), 0.0).add(zv))).abs();+ let db = -20.0 * vv.max(1e-30).log10();+ iso.push(json!({"aggressor": a["feed"], "oscillator": cnet, "atHz": f, "victim": v["feed"], "victimStage": v["stage"], "rail": rail, "isolationDb": r1(db), "targetDb": target}));+ if db < target {+ issues.push(Issue { severity: if db < target - 10.0 { "medium".into() } else { "low".into() }, kind: "stage-isolation".into(), what: format!("{} ({}, {:.1} MHz) reaches {} through {} with only {:.0} dB of isolation (target {target:.0} dB)", a["feed"].as_str().unwrap_or(""), stage, f / 1e6, v["feed"].as_str().unwrap_or(""), rail, db), at: None, layer: None, net: v["feed"].as_str().map(String::from), reference: v["stage"].as_str().map(String::from), fix: format!("Raise the series element in {}'s or {}'s feed (a ferrite bead or a larger resistor), add a capacitor that is low at {:.0} MHz right at each feed, or give the oscillator its own regulator.", a["feed"].as_str().unwrap_or(""), v["feed"].as_str().unwrap_or(""), f / 1e6), action: Some(json!({"type": "add-part", "what": "ferrite bead or larger series resistor", "net": a["feed"], "why": "stage isolation through a shared rail", "isolationDb": r1(db), "targetDb": target})) });+ }+ }+ }+ iso.sort_by(|x, y| x["isolationDb"].as_f64().partial_cmp(&y["isolationDb"].as_f64()).unwrap());+ out["stageIsolation"] = json!(iso);+ // ---- 3. the LO's own radiated emission: its copper as a small loop over the plane ---------------------------------------+ let gaps = if spec.real_gaps.is_empty() { crate::fields::slot_gaps(spec) } else { spec.real_gaps.clone() };+ let er = spec.epsilonR.unwrap_or(4.3); let e0 = 8.854e-15; // F/mm+ let lim = |f: f64| if f < 88e6 { 40.0 } else if f < 216e6 { 43.5 } else if f < 960e6 { 46.0 } else { 54.0 };+ let mut rad = Vec::new();+ for (cnet, clock) in &spec.clocks {+ let Some(amp) = clock.amplitude() else { continue };+ let f = clock.top_hz(); if f < 30e6 { continue; }+ let (mut len, mut area, mut cplate) = (0.0, 0.0, 0.0);+ for t in b.tracks.iter().filter(|t| t.net == *cnet) {+ let l = (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1);+ let h = if t.layer == "F.Cu" { gaps.first().copied().unwrap_or(0.2) } else if t.layer == "B.Cu" { gaps.last().copied().unwrap_or(0.2) } else { gaps.iter().cloned().fold(f64::MAX, f64::min) };+ len += l; area += l * h; cplate += e0 * er * l * t.w / h;+ }+ let cparts: f64 = b.values.iter().filter(|(r, _)| r.starts_with('C')).filter(|(r, _)| { let ps = pads_of(r); ps.iter().any(|p| p.net == *cnet) }).filter_map(|(_, v)| value(v)).map(|c| c.min(10e-12)).sum();+ let i = amp * 2.0 * PI * f * (cplate + cparts);+ let e = 1.316e-14 * i * area * 1e-6 * f * f / 3.0;+ let db = 20.0 * (e * 1e6).max(1e-30).log10(); let margin = lim(f) - db;+ rad.push(json!({"net": cnet, "atHz": f, "copperMm": r1(len), "loopMm2": r1(area), "currentMa": (i * 1e6).round() / 1e3, "dBuVm": r1(db), "limitDbuVm": lim(f), "marginDb": r1(margin)}));+ if margin < 6.0 {+ issues.push(Issue { severity: if margin < 0.0 { "medium".into() } else { "low".into() }, kind: "lo-emission".into(), what: format!("{cnet} at {:.1} MHz is estimated to radiate {:.1} dBµV/m at 3 m from {:.0} mm² of loop, {:.1} dB under FCC class B", f / 1e6, db, area, margin), at: None, layer: None, net: Some(cnet.clone()), reference: None, fix: "Shorten the oscillator's traces, keep them on the layer next to the ground plane, and keep the tank's loop small; then measure with a near-field probe.".into(), action: Some(json!({"type": "review", "net": cnet, "why": "LO radiated emission", "marginDb": r1(margin)})) });+ }+ }+ out["loEmission"] = json!(rad);+ out+}++/// the same network in ngspice, when it is installed: |V| at the port at one frequency+fn spice_leak(deck: &str, f: f64, port: usize, work: &std::path::Path) -> Option<f64> {+ let dir = work.join("rf"); let _ = std::fs::create_dir_all(&dir);+ let cir = dir.join(format!("leak-{:.0}.cir", f)); let outp = dir.join(format!("leak-{:.0}.txt", f));+ let d = format!("{deck}.ac lin 1 {f:e} {f:e}\n.control\nset wr_singlescale\nrun\nwrdata {} vm(n{port})\n.endc\n.end\n", outp.display());+ std::fs::write(&cir, d).ok()?;+ let r = std::process::Command::new("timeout").args(["30", "ngspice", "-b"]).arg(&cir).output().ok()?;+ if !r.status.success() { return None; }+ let txt = std::fs::read_to_string(&outp).ok()?;+ txt.lines().filter_map(|l| { let v: Vec<f64> = l.split_whitespace().filter_map(|x| x.parse().ok()).collect(); if v.len() >= 2 { Some(v[1]) } else { None } }).last()+}++#[cfg(test)] mod tests {+ use super::*;+ #[test] fn values() {+ assert_eq!(value("47"), Some(47.0)); assert!((value("4k7").unwrap() - 4700.0).abs() < 1e-9); assert!((value("2.2p").unwrap() - 2.2e-12).abs() < 1e-24);+ assert!((value("120n").unwrap() - 120e-9).abs() < 1e-18); assert!((value("10u").unwrap() - 10e-6).abs() < 1e-15); assert_eq!(value("0R"), Some(0.0)); assert!((value("1M").unwrap() - 1e6).abs() < 1e-6);+ }+ #[test] fn divider_matches_hand() {+ // 1 V through a 1 pF series cap into 50 Ω at 100 MHz: |V| = 50 / |50 + 1/(jωC)|+ let mut n = Net { names: Vec::new(), el: Vec::new() }; let (s, p) = (n.node("lo"), n.node("ant")); n.el.push((s, p, "c".into(), 1e-12, 'C'));+ let w = 2.0 * PI * 100e6; let v = n.solve(w, s, 1.0, p, 50.0)[p].abs();+ let xc = 1.0 / (w * 1e-12); let exact = 50.0 / (50.0f64.hypot(xc));+ assert!((v - exact).abs() / exact < 1e-6, "{v} vs {exact}");+ }+}