app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
0.9.5: compliance prediction with bench calibration, certification readiness report
12 files changed
+442−27
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fields"-version = "0.9.4"+version = "0.9.5" dependencies = [ "clap", "png",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.9.4"+version = "0.9.5" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."
README.md+4@@ -165,6 +165,10 @@ Every solve now ends with a design review an AI flow can act on without looking `adom-fields verify --board board.kicad_pcb --spec spec.json --step 1,3 --refill --out verify` carries out fix-plan steps on a copy of the board, adding the vias and widening the traces. KiCad refills the zones and runs DRC on both boards, and both are solved. The report says which issues cleared, every number that moved and which way, and whether the fix introduced any DRC error. On the 4-layer ESC, four thermal vias under each of two FETs drop Q2 from +90.8 to +86.0 °C with no new DRC errors. Widening the boxed-in necks lowers every hot part by about 0.5 °C, but the issue stays: the report shows that a layout change is the real fix there. Part, move and reroute steps are refused with the reason, for the AI's layout pass. +### Will it pass the lab? `review.compliance` and `report`++Every solve predicts each emission test (radiated, conducted, a receiver's antenna port) with an uncertainty band. It ranks the fixes by the dB they buy, finds the cheapest set that gets every source to 6 dB, and writes a bench plan that says what a LISN, a current clamp or a near-field probe should read. Bench readings go back into the spec and move the prediction onto them. On the buck: radiated at risk on J2's cable, conducted a likely fail without an input filter; the cheapest set is an input filter, spread spectrum and a slower edge. `adom-fields report` folds that, Adom Safety's creepage check and Adom Mechanics' reliability screen into one readiness report: each area ready, needs work or blocked, with its findings.+ ## The app `adom-fields serve` puts the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, vendored and served by the binary: its camera, ViewCube, studio lighting, framing and Z-up frame, as 3d-viewer-design asks): the picked field on the picked layer, the other layers faded, the vias as dots, the hot parts labelled, the legend, the number under the cursor. On the left the nets by what they carry and the hot parts hottest first; on the right the issues and the method. Every click is a command: the state lives on the server and goes out over a live channel, so `adom-fields ui view --quantity heat --layer B.Cu` does exactly what the button does, and `adom-fields tour` walks the whole board for the AI Flow video.
SKILL.md+52@@ -159,6 +159,58 @@ After applying a step, run `analyze` again: the issue goes away or says how far - `--fields <dir>` reuses an existing solve of the same board for the plan and its step numbers. - **Adopting it:** with `--refill` and no `drc.new`, the fixed board is clean to open in KiCad and save over the original. Without `--refill`, refill and run DRC in KiCad first. +## Compliance prediction and bench calibration (0.9.5)++`fields.json` `review.compliance` puts every emission source the review models into one table against the chosen limit set (`emcLimit`):+- **The sources:** the switching hot loop (`loop`), each connector's cable (`cable:J2`), an oscillator's copper (`lo:<net>`), a receiver's oscillator leaking out of its antenna port (`lo-leak:<net>`, FCC 15.111), and the conducted emissions (`conducted`).+- **What each row carries:** level, limit, margin, an uncertainty band from the model's own error, and the basis:+ - loop ±6 dB;+ - cable 0 to +20 dB, because the common-mode model is an upper bound;+ - conducted ±4 dB;+ - antenna port ±3 dB.+- **Verdicts per test (`radiated`, `conducted`, `antenna`):** `likely fail` (even the band's best case is over), `at risk` (the band crosses the limit), `likely pass, thin margin` (under 6 dB), `likely pass`.+- **`fixes`, each with the dB it buys per source:**+ - slower switch edge (computed from the edge's envelope at each source's frequency);+ - spread spectrum (from the dither width over the receiver bandwidth: 9 kHz conducted, 120 kHz radiated);+ - a smaller hot loop (from the area ratio);+ - per cable: a clip-on ferrite (about 200 Ω), a board common-mode choke (about 1 kΩ), and moving the connector's ground to the converter's input-capacitor ground;+ - an input filter (sized to 6 dB);+ - for receivers: an RF amplifier's reverse isolation, or weaker LO coupling;+ - a shield can.+- **`toSixDb`:** the cheapest set of those fixes that gets every source to 6 dB of margin, found by searching every combination (free and cable fixes cost less than layout and parts), with the margins after it.+- **`benchPlan`:** how to measure each prediction, cheapest first:+ - conducted: LISN and analyzer, 150 kHz to 30 MHz;+ - each cable: a current clamp, with the predicted current and the current that puts the cable on the limit;+ - the hot loop: an H-field probe, which ranks sources and shows a fix working but gives no dBµV/m;+ - the antenna port: an analyzer.+ - Each step says what to expect and gives the `measurements` record to fill in.+- **Bench calibration:** spec `measurements: [{source, fMHz, value, unit}]`, with unit `dBuV` (LISN), `dBuA` (clamp), `dBuVm` (chamber or pre-scan at 3 m) or `dBm` (antenna port).+ - The source moves onto the reading, keeping the model's spectral shape and taking the bench's level. Its band narrows to ±2 dB at the measured frequency (±3 dB elsewhere).+ - A cable reading also moves the cables not measured by the same offset, ±4 dB.+ - `calibration` says what was used, by how much, and why a reading was not used.+- `analyze` prints a `Hint: Compliance` line: each verdict with its worst source and band, and the cheapest set.++On the TPS54202 buck (12 V to 5 V, 1 A, 1 m cables, FCC B):+- **Radiated** is at risk: J2's cable is −7.1 to +12.9 dB, while the loop itself is 55 dB under.+- **Conducted** is a likely fail: −33.5 dB with no input filter.+- **The cheapest fix set:** an input filter, spread spectrum and a 16 ns edge, which gets every source to 6 dB or more.+- **With a clamp reading** of 9 dBµA on J2 (9 dB under the bound), radiated becomes "likely pass, thin margin", and the set drops to the input filter plus spread spectrum.++## Certification readiness report (0.9.5)++`adom-fields report --fields <solve> [--safety <adom-safety out>] [--mechanics <adom-mechanics out>] --out readiness.md` writes one readiness report as Markdown, plus the same as `readiness.json`:+- **Areas:** thermal; current and copper; power delivery; signal integrity; EMC and ESD (with the compliance verdicts and the cheapest fix set); electrical safety (from Adom Safety); mechanical reliability (from Adom Mechanics).+- **Each area gets a status** with its key numbers and its first five findings:+ - **blocked:** a high finding, a likely-fail emission test, or a safety distance not met;+ - **needs work:** a medium finding, an at-risk test, no heat sources, or safety nets left on default voltages;+ - **ready:** low findings only;+ - **not checked:** that tool's output was not given, and the report says the command to run.+- **The overall status** is the worst area's.+- **The report also carries:**+ - what it does not cover: measurements, immunity, materials and flammability, DFM;+ - the evidence: tool versions and the board and spec hashes.+- **"Ready" is not "certified".** It means nothing found stands in the way of booking the lab.+ ## The ground view (0.7.6) `ui view --quantity ground`: the ground copper lifted by its solved millivolts (rings every step), each part's ground pin tagged with its offset, dashed arcs for the possible loops through the ground connectors' cables, floating ground islands in red, and the stitching fence: a ring per ground via (λ/40 reach) with the uncovered stretches of the edge in red.
docs/release-0.9.5.mdadded+16@@ -0,0 +1,16 @@+# adom-fields 0.9.5++- **Compliance prediction (`review.compliance`).**+ - **The sources:** every emission source the review models (the switching hot loop, each cable's common mode, an oscillator's copper, a receiver's oscillator leaking out of its antenna port, and the conducted emissions) in one table against the chosen limits.+ - **Bands and verdicts:** each source has an uncertainty band from its model's own error, and each test (radiated, conducted, antenna port) gets a verdict.+ - **Fixes:** ranked by the dB each buys per source. These cover a slower edge, spread spectrum, a smaller loop, a ferrite, a choke, moving a connector's ground, an input filter, RF isolation and a shield can. With them comes the cheapest combination that gets every source to 6 dB.+ - **A bench plan:** LISN, current clamp, near-field probe and antenna port, each with the level to expect.+ - **Bench calibration:** spec `measurements` move a prediction onto the bench's reading and narrow its band to ±2 dB. A cable reading also moves the other cables.+ - **On the TPS54202 buck:**+ - **Predictions:** radiated at risk (J2's cable −7 to +13 dB), conducted likely fail (−33.5 dB). The cheapest fix set is an input filter, spread spectrum and a 16 ns edge.+ - **With a 9 dBµA clamp reading on J2:** radiated becomes a thin pass, and the set drops to the input filter plus spread spectrum.+ - **On the AM/FM receiver:** the FM oscillator's leak out of the antenna port is 16 dB over FCC 15.111. An RF amplifier's isolation plus weaker LO coupling gets it to +9.6 dB.+- **Certification readiness report (`adom-fields report`).**+ - **Inputs:** the fields solve, Adom Safety's creepage and clearance check, and Adom Mechanics' reliability screen.+ - **Output:** one Markdown and JSON report, with each area ready, needs work, blocked or not checked, its numbers and findings, what the report does not cover, and the evidence hashes.+ - **The buck today:** blocked by conducted emissions. Mechanics needs work (an 0805 inside a mounting pin's keep-out). The rest is ready.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.9.4",+ "version": "0.9.5", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",
page.json+8−23@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.9.4",+ "version": "0.9.5", "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+52@@ -159,6 +159,58 @@ After applying a step, run `analyze` again: the issue goes away or says how far - `--fields <dir>` reuses an existing solve of the same board for the plan and its step numbers. - **Adopting it:** with `--refill` and no `drc.new`, the fixed board is clean to open in KiCad and save over the original. Without `--refill`, refill and run DRC in KiCad first. +## Compliance prediction and bench calibration (0.9.5)++`fields.json` `review.compliance` puts every emission source the review models into one table against the chosen limit set (`emcLimit`):+- **The sources:** the switching hot loop (`loop`), each connector's cable (`cable:J2`), an oscillator's copper (`lo:<net>`), a receiver's oscillator leaking out of its antenna port (`lo-leak:<net>`, FCC 15.111), and the conducted emissions (`conducted`).+- **What each row carries:** level, limit, margin, an uncertainty band from the model's own error, and the basis:+ - loop ±6 dB;+ - cable 0 to +20 dB, because the common-mode model is an upper bound;+ - conducted ±4 dB;+ - antenna port ±3 dB.+- **Verdicts per test (`radiated`, `conducted`, `antenna`):** `likely fail` (even the band's best case is over), `at risk` (the band crosses the limit), `likely pass, thin margin` (under 6 dB), `likely pass`.+- **`fixes`, each with the dB it buys per source:**+ - slower switch edge (computed from the edge's envelope at each source's frequency);+ - spread spectrum (from the dither width over the receiver bandwidth: 9 kHz conducted, 120 kHz radiated);+ - a smaller hot loop (from the area ratio);+ - per cable: a clip-on ferrite (about 200 Ω), a board common-mode choke (about 1 kΩ), and moving the connector's ground to the converter's input-capacitor ground;+ - an input filter (sized to 6 dB);+ - for receivers: an RF amplifier's reverse isolation, or weaker LO coupling;+ - a shield can.+- **`toSixDb`:** the cheapest set of those fixes that gets every source to 6 dB of margin, found by searching every combination (free and cable fixes cost less than layout and parts), with the margins after it.+- **`benchPlan`:** how to measure each prediction, cheapest first:+ - conducted: LISN and analyzer, 150 kHz to 30 MHz;+ - each cable: a current clamp, with the predicted current and the current that puts the cable on the limit;+ - the hot loop: an H-field probe, which ranks sources and shows a fix working but gives no dBµV/m;+ - the antenna port: an analyzer.+ - Each step says what to expect and gives the `measurements` record to fill in.+- **Bench calibration:** spec `measurements: [{source, fMHz, value, unit}]`, with unit `dBuV` (LISN), `dBuA` (clamp), `dBuVm` (chamber or pre-scan at 3 m) or `dBm` (antenna port).+ - The source moves onto the reading, keeping the model's spectral shape and taking the bench's level. Its band narrows to ±2 dB at the measured frequency (±3 dB elsewhere).+ - A cable reading also moves the cables not measured by the same offset, ±4 dB.+ - `calibration` says what was used, by how much, and why a reading was not used.+- `analyze` prints a `Hint: Compliance` line: each verdict with its worst source and band, and the cheapest set.++On the TPS54202 buck (12 V to 5 V, 1 A, 1 m cables, FCC B):+- **Radiated** is at risk: J2's cable is −7.1 to +12.9 dB, while the loop itself is 55 dB under.+- **Conducted** is a likely fail: −33.5 dB with no input filter.+- **The cheapest fix set:** an input filter, spread spectrum and a 16 ns edge, which gets every source to 6 dB or more.+- **With a clamp reading** of 9 dBµA on J2 (9 dB under the bound), radiated becomes "likely pass, thin margin", and the set drops to the input filter plus spread spectrum.++## Certification readiness report (0.9.5)++`adom-fields report --fields <solve> [--safety <adom-safety out>] [--mechanics <adom-mechanics out>] --out readiness.md` writes one readiness report as Markdown, plus the same as `readiness.json`:+- **Areas:** thermal; current and copper; power delivery; signal integrity; EMC and ESD (with the compliance verdicts and the cheapest fix set); electrical safety (from Adom Safety); mechanical reliability (from Adom Mechanics).+- **Each area gets a status** with its key numbers and its first five findings:+ - **blocked:** a high finding, a likely-fail emission test, or a safety distance not met;+ - **needs work:** a medium finding, an at-risk test, no heat sources, or safety nets left on default voltages;+ - **ready:** low findings only;+ - **not checked:** that tool's output was not given, and the report says the command to run.+- **The overall status** is the worst area's.+- **The report also carries:**+ - what it does not cover: measurements, immunity, materials and flammability, DFM;+ - the evidence: tool versions and the board and spec hashes.+- **"Ready" is not "certified".** It means nothing found stands in the way of booking the lab.+ ## The ground view (0.7.6) `ui view --quantity ground`: the ground copper lifted by its solved millivolts (rings every step), each part's ground pin tagged with its offset, dashed arcs for the possible loops through the ground connectors' cables, floating ground islands in red, and the stitching fence: a ring per ground via (λ/40 reach) with the uncovered stretches of the edge in red.
src/compliance.rsadded+169@@ -0,0 +1,169 @@+//! Compliance prediction (0.9.5): every emission source the review models, in one table against the chosen limits,+//! each with an uncertainty band, a verdict per test, the fixes ranked by the dB they buy, the cheapest set that gets+//! to a 6 dB margin, and a bench plan that measures each prediction. Bench numbers come back through spec+//! `measurements` and move the prediction onto them (the model's spectral shape, the bench's level).+//! Bands (the model's own error, not lab-to-lab spread):+//! hot loop (Ott differential mode) ±6 dB+//! cable common mode (an upper bound) 0 to +20 dB of margin: real cables see 35 to 350 Ω and often a ferrite+//! conducted (differential mode, LISN) ±4 dB: harmonic amplitude for quasi-peak errs safe, common mode is not modelled+//! oscillator loop ±6 dB+//! oscillator leak out of the antenna port ±3 dB (a nodal circuit solve; part tolerances)+//! a source calibrated by a measurement ±2 dB at the measured frequency, ±3 dB elsewhere+use crate::board::Board;+use crate::fields::Spec;+use serde::Deserialize;+use serde_json::{json, Value};+use std::f64::consts::PI;++/// A bench reading: `source` is `conducted`, `loop`, `cable:J2`, `lo:<net>` or `lo-leak:<net>` (the ids in review.compliance.sources);+/// `unit` is dBuV (LISN), dBuA (a current clamp on the cable), dBuVm (a chamber or OATS reading at 3 m) or dBm (the antenna port).+#[allow(non_snake_case)]+#[derive(Deserialize, Clone, Debug)]+pub struct Measurement { pub source: String, pub fMHz: f64, pub value: f64, #[serde(default = "dbuv")] pub unit: String, #[serde(default)] pub note: Option<String> }+fn dbuv() -> String { "dBuV".into() }++fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }+fn db(x: f64) -> f64 { 20.0 * x.max(1e-30).log10() }+fn sinc(x: f64) -> f64 { if x == 0.0 { 1.0 } else { x.sin() / x } }++struct Conv { fsw: f64, ipk: f64, duty: f64, tr: f64 }+impl Conv {+ fn of(c: &Value) -> Option<Conv> { let fsw = c["fswHz"].as_f64()?; Some(Conv { fsw, ipk: c["iout"].as_f64().unwrap_or(0.0) + c["rippleA"].as_f64().unwrap_or(0.0) / 2.0, duty: c["duty"].as_f64().unwrap_or(0.5), tr: c["riseNs"].as_f64().unwrap_or(8.0) * 1e-9 }) }+ /// the trapezoid's line amplitude at the harmonic nearest f, and that harmonic's frequency+ fn harmonic(&self, f: f64) -> (f64, f64) { let k = (f / self.fsw).round().max(1.0); let t = 1.0 / self.fsw; (2.0 * self.ipk * self.duty * sinc(PI * k * self.duty).abs() * sinc(PI * k * self.tr / t).abs(), k * self.fsw) }+ /// the envelope's edge term at f for rise time tr (1 below the corner 1/(π·tr))+ fn edge(tr: f64, f: f64) -> f64 { (1.0 / (PI * f * tr)).min(1.0) }+}++/// a cable's common-mode level at f: (E at 3 m in dBµV/m, the cable current in dBµA)+fn cable_level(c: &Conv, lg_nh: f64, len: f64, f: f64) -> (f64, f64) {+ let (ih, fr) = c.harmonic(f); let icm = ih * 2.0 * PI * fr * lg_nh * 1e-9 / 150.0;+ let leff = len.min(3e8 / fr / 4.0); (db(1.257e-6 * fr * icm * leff / 3.0 * 1e6), db(icm * 1e6))+}+/// the cable current that puts E at 3 m on the limit, dBµA+fn cable_limit_current(lim_dbuvm: f64, len: f64, f: f64) -> f64 { let leff = len.min(3e8 / f / 4.0); lim_dbuvm - db(1.257e-6 * f * leff / 3.0) }++pub fn review(b: &Board, spec: &Spec, rev: &Value) -> Value {+ let chosen = spec.emcLimit.clone().unwrap_or_else(|| "fcc-b".into());+ let label = crate::limits::label(&chosen);+ let conv = Conv::of(&rev["converter"]);+ let pos = |r: &str| { let p: Vec<_> = b.pads.iter().filter(|p| p.reference == r).collect(); if p.is_empty() { Value::Null } else { json!([r1(p.iter().map(|p| p.x).sum::<f64>() / p.len() as f64), r1(p.iter().map(|p| p.y).sum::<f64>() / p.len() as f64)]) } };+ let mut src: Vec<Value> = Vec::new();+ // ---- the sources ---------------------------------------------------------------------------------------------------+ let c = &rev["converter"];+ if let Some(m) = c["emissions"]["worstMarginDb"].as_f64() {+ let f = c["emissions"]["atMHz"].as_f64().unwrap_or(0.0); let lim = crate::limits::at(&chosen, &spec.emcLimits, f * 1e6).unwrap_or(0.0);+ let cap = c["hotLoop"]["cap"].as_str().unwrap_or("");+ src.push(json!({"id": "loop", "test": "radiated", "what": format!("switching hot loop ({} to {}), differential mode", c["switch"].as_str().unwrap_or("?"), cap), "at": pos(cap), "fMHz": f, "level": r1(lim - m), "unit": "dBuVm", "limit": lim, "marginDb": m, "band": [r1(m - 6.0), r1(m + 6.0)], "basis": "Ott small-loop model, ±6 dB"}));+ }+ for row in rev["cableEmissions"]["connectors"].as_array().into_iter().flatten() {+ let cj = row["connector"].as_str().unwrap_or(""); let m = row["worstMarginDb"].as_f64().unwrap_or(0.0); let f = row["atMHz"].as_f64().unwrap_or(0.0);+ let lim = crate::limits::at(&chosen, &spec.emcLimits, f * 1e6).unwrap_or(0.0); let len = row["cableM"].as_f64().unwrap_or(1.0);+ let icur = conv.as_ref().map(|cv| cable_level(cv, row["groundNh"].as_f64().unwrap_or(0.0), len, f * 1e6).1);+ src.push(json!({"id": format!("cable:{cj}"), "test": "radiated", "what": format!("{cj}'s {len} m cable, common mode from the switching loop's ground"), "at": pos(cj), "fMHz": f, "level": row["dBuVm"], "unit": "dBuVm", "limit": lim, "marginDb": m, "band": [r1(m), r1(m + 20.0)], "basis": "common-mode monopole, 150 Ω cable, no ferrite: an upper bound (0 to +20 dB)", "cableCurrentDbuA": icur.map(r1), "limitCurrentDbuA": r1(cable_limit_current(lim, len, f * 1e6))}));+ }+ for row in rev["rf"]["loEmission"].as_array().into_iter().flatten() {+ let m = row["marginDb"].as_f64().unwrap_or(0.0); let n = row["net"].as_str().unwrap_or("");+ src.push(json!({"id": format!("lo:{n}"), "test": "radiated", "what": format!("{n} oscillator copper as a loop"), "at": Value::Null, "fMHz": r1(row["atHz"].as_f64().unwrap_or(0.0) / 1e6), "level": row["dBuVm"], "unit": "dBuVm", "limit": row["limitDbuVm"], "marginDb": m, "band": [r1(m - 6.0), r1(m + 6.0)], "basis": "small-loop model of the oscillator copper, ±6 dB"}));+ }+ // a receiver's oscillator leaking out of its antenna port (FCC 15.111 / CISPR 32's antenna-port test)+ for row in rev["rf"]["loLeak"].as_array().into_iter().flatten() {+ let (w, lim) = (row["worstDbm"].as_f64().unwrap_or(-200.0), row["limitDbm"].as_f64().unwrap_or(-57.0)); let m = lim - w;+ let n = row["from"].as_str().unwrap_or(""); let ng = row["points"].as_array().map_or(false, |p| p.iter().all(|x| x["ngspiceDbm"].is_number()));+ src.push(json!({"id": format!("lo-leak:{n}"), "test": "antenna", "what": format!("{n} leaking out of {} (the receiver's antenna port)", row["to"].as_str().unwrap_or("the antenna")), "at": Value::Null, "fMHz": r1(row["worstHz"].as_f64().unwrap_or(0.0) / 1e6), "level": r1(w), "unit": "dBm", "limit": lim, "marginDb": r1(m), "band": [r1(m - 3.0), r1(m + 3.0)], "basis": if ng { "nodal solve of the front end's parts and parasitics, ngspice agrees; ±3 dB (part tolerances)" } else { "nodal solve of the front end's parts and parasitics; ±3 dB (part tolerances)" }}));+ }+ let ce = &rev["conductedEmissions"];+ if ce["ran"] == true {+ let m = ce["worstMarginDb"].as_f64().unwrap_or(0.0); let f = ce["atKHz"].as_f64().unwrap_or(0.0) / 1e3;+ src.push(json!({"id": "conducted", "test": "conducted", "what": format!("converter input ripple on {}'s supply cable (LISN)", c["inNet"].as_str().unwrap_or("the input")), "at": pos(c["inCaps"][0]["ref"].as_str().unwrap_or("")), "fMHz": f, "level": ce["dBuV"], "unit": "dBuV", "limit": r1(ce["dBuV"].as_f64().unwrap_or(0.0) + m), "marginDb": m, "band": [r1(m - 4.0), r1(m + 4.0)], "basis": "differential-mode LISN model, ±4 dB (common-mode conducted noise is not modelled)"}));+ }+ // ---- calibration from the bench -----------------------------------------------------------------------------------+ let mut cal = Vec::new();+ for ms in &spec.measurements {+ let Some(s) = src.iter_mut().find(|s| s["id"] == ms.source.as_str()) else { cal.push(json!({"source": ms.source, "used": false, "why": "no such source in this solve (ids: conducted, loop, cable:<J>, lo:<net>, lo-leak:<net>)"})); continue };+ let f = ms.fMHz * 1e6; let at_worst = (ms.fMHz - s["fMHz"].as_f64().unwrap_or(0.0)).abs() <= 0.1 * ms.fMHz.max(1e-9);+ // the model's level at the measured frequency, in the measured unit+ let pred: Option<f64> = match (s["id"].as_str().unwrap_or(""), ms.unit.as_str()) {+ ("conducted", "dBuV") => ce["spectrum"]["points"].as_array().and_then(|p| p.iter().min_by(|a, z| ((a[0].as_f64().unwrap_or(0.0) - ms.fMHz * 1e3).abs()).partial_cmp(&(z[0].as_f64().unwrap_or(0.0) - ms.fMHz * 1e3).abs()).unwrap()).and_then(|p| p[1].as_f64())),+ (id, u) if id.starts_with("cable:") && (u == "dBuA" || u == "dBuVm") => { let cj = &id[6..]; let row = rev["cableEmissions"]["connectors"].as_array().and_then(|a| a.iter().find(|r| r["connector"] == cj).cloned()); match (conv.as_ref(), row) { (Some(cv), Some(row)) => { let (e, i) = cable_level(cv, row["groundNh"].as_f64().unwrap_or(0.0), row["cableM"].as_f64().unwrap_or(1.0), f); Some(if u == "dBuA" { i } else { e }) } _ => None } }+ (_, "dBuVm") | (_, "dBm") if at_worst && s["unit"] == ms.unit.as_str() => s["level"].as_f64(),+ _ => None,+ };+ let Some(p) = pred else { cal.push(json!({"source": ms.source, "used": false, "why": format!("a {} reading at {} MHz does not map onto this source's model (conducted: dBuV; cable: dBuA or dBuVm; loop and lo: dBuVm, lo-leak: dBm, at the worst frequency ±10 %)", ms.unit, ms.fMHz)})); continue };+ let off = ms.value - p; let m0 = s["marginDb"].as_f64().unwrap_or(0.0); let m = m0 - off; let w = if at_worst { 2.0 } else { 3.0 };+ s["level"] = json!(r1(s["level"].as_f64().unwrap_or(0.0) + off)); s["marginDb"] = json!(r1(m)); s["band"] = json!([r1(m - w), r1(m + w)]);+ s["basis"] = json!(format!("calibrated: the bench read {:.1} {} at {} MHz against {:.1} predicted ({:+.1} dB); the model's shape, the bench's level, ±{w} dB", ms.value, ms.unit, ms.fMHz, p, off));+ cal.push(json!({"source": ms.source, "used": true, "offsetDb": r1(off), "marginBefore": r1(m0), "marginAfter": r1(m), "note": ms.note}));+ }+ // a cable reading says how far the common-mode model is off for this board: the cables not measured move by the same+ // offset (the mean of the measured ones), with ±4 dB for what differs between cables+ let offs: Vec<f64> = cal.iter().filter(|x| x["used"] == true && x["source"].as_str().unwrap_or("").starts_with("cable:")).filter_map(|x| x["offsetDb"].as_f64()).collect();+ if !offs.is_empty() { let off = offs.iter().sum::<f64>() / offs.len() as f64; let by: Vec<String> = cal.iter().filter(|x| x["used"] == true && x["source"].as_str().unwrap_or("").starts_with("cable:")).map(|x| x["source"].as_str().unwrap_or("")[6..].to_string()).collect();+ for s in src.iter_mut().filter(|s| s["id"].as_str().unwrap_or("").starts_with("cable:") && !s["basis"].as_str().unwrap_or("").starts_with("calibrated")) {+ let m = s["marginDb"].as_f64().unwrap_or(0.0) - off; s["marginDb"] = json!(r1(m)); s["level"] = json!(r1(s["level"].as_f64().unwrap_or(0.0) + off)); s["band"] = json!([r1(m - 4.0), r1(m + 4.0)]);+ s["basis"] = json!(format!("inferred from {}'s reading: the common-mode model moved by {:+.1} dB, ±4 dB", by.join(", "), off));+ } }+ // ---- fixes and their dB --------------------------------------------------------------------------------------------+ let mut fixes: Vec<Value> = Vec::new();+ let mut add = |id: &str, what: String, how: &str, cost: &str, gains: Vec<(String, f64)>| { let g: Vec<(String, f64)> = gains.into_iter().filter(|g| g.1 >= 0.5).collect(); if !g.is_empty() { fixes.push(json!({"id": id, "fix": what, "how": how, "cost": cost, "gains": g.iter().map(|(s, d)| json!({"source": s, "gainDb": r1(*d)})).collect::<Vec<_>>()})); } };+ let switching: Vec<(String, f64, &str)> = src.iter().filter(|s| s["id"] == "loop" || s["id"] == "conducted" || s["id"].as_str().unwrap_or("").starts_with("cable:")).map(|s| (s["id"].as_str().unwrap_or("").to_string(), s["fMHz"].as_f64().unwrap_or(0.0) * 1e6, if s["test"] == "conducted" { "c" } else { "r" })).collect();+ if let Some(cv) = &conv {+ add("slow-edge", format!("Slow the switch edge from {:.0} to {:.0} ns (a gate resistor; on a converter with its switch inside, a few ohms in series with the bootstrap capacitor; or the controller's slew setting)", cv.tr * 1e9, cv.tr * 2e9), "edge", "a resistor, a little efficiency", switching.iter().map(|(s, f, _)| (s.clone(), db(Conv::edge(cv.tr, *f) / Conv::edge(cv.tr * 2.0, *f)))).collect());+ add("spread-spectrum", "Turn on spread-spectrum (±5 % dither) if the controller has it".into(), "dither", "free if supported", switching.iter().map(|(s, f, t)| { let rbw = if *t == "c" { 9e3 } else { 120e3 }; (s.clone(), (10.0 * (0.1 * f / rbw).max(1.0).log10()).min(if *t == "c" { 8.0 } else { 10.0 })) }).collect());+ }+ if let (Some(a), Some(id)) = (c["hotLoop"]["mm2"].as_f64(), c["idealMm2"].as_f64()) { add("shrink-loop", format!("Shrink the hot loop from {a} to {id} mm² (the input capacitor tight against the switch, its ground via at the pad)"), "layout", "layout time", vec![("loop".into(), db(a / id))]); }+ for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("cable:")) {+ let id = s["id"].as_str().unwrap(); let cj = &id[6..];+ add(&format!("ferrite:{cj}"), format!("A clip-on ferrite on {cj}'s cable (about 200 Ω at {} MHz)", s["fMHz"]), "cable", "cents, no board change", vec![(id.into(), db(350.0 / 150.0))]);+ add(&format!("choke:{cj}"), format!("A common-mode choke on {cj} on the board (about 1 kΩ at {} MHz)", s["fMHz"]), "part", "a part and a re-layout", vec![(id.into(), db(1150.0 / 150.0))]);+ add(&format!("ground:{cj}"), format!("Tie {cj}'s ground next to the converter's input-capacitor ground and stitch between them (halves the shared ground inductance, or better)"), "layout", "layout time", vec![(id.into(), 6.0)]);+ }+ if ce["ran"] == true { let m = ce["worstMarginDb"].as_f64().unwrap_or(0.0); if m < 6.0 {+ let l = rev["conductedEmissions"]["inputFilter"].is_null();+ add("input-filter", format!("{} an input LC filter sized for 6 dB of margin (the conducted-emissions issue gives the inductor)", if l { "Add" } else { "Enlarge" }), "part", "an inductor, a bulk capacitor, re-layout", vec![("conducted".into(), 6.0 - m)]);+ } }+ for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("lo-leak:")) { let id = s["id"].as_str().unwrap(); add(&format!("isolate:{}", &id[8..]), "An RF amplifier (or a buffer) between the antenna and the mixer: its reverse isolation, about 20 dB, keeps the oscillator in".into(), "part", "a part and a re-layout", vec![(id.into(), 20.0)]); add(&format!("pad:{}", &id[8..]), "Weaken the oscillator's coupling to the mixer input (a smaller coupling capacitor, or the LO injected at the emitter), about 6 dB".into(), "layout", "a value change", vec![(id.into(), 6.0)]); }+ for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("lo:")) { add(&format!("shield:{}", &s["id"].as_str().unwrap()[3..]), "A shield can over the oscillator".into(), "part", "a can and its footprint", vec![(s["id"].as_str().unwrap().into(), 20.0)]); }+ // rank by what each buys on the worst source it touches+ let margin_of = |id: &str| src.iter().find(|s| s["id"] == id).and_then(|s| s["marginDb"].as_f64()).unwrap_or(99.0);+ fixes.sort_by(|a, z| { let k = |f: &Value| f["gains"].as_array().unwrap().iter().map(|g| (margin_of(g["source"].as_str().unwrap()), -g["gainDb"].as_f64().unwrap())).fold((99.0, 0.0), |acc, x| if x.0 < acc.0 || (x.0 == acc.0 && x.1 < acc.1) { x } else { acc }); let (ka, kz) = (k(a), k(z)); ka.0.partial_cmp(&kz.0).unwrap().then(ka.1.partial_cmp(&kz.1).unwrap()) });+ // ---- the cheapest set to 6 dB on every source: every subset of the fixes (a dozen or so), by cost then count ----------+ let cost = |f: &Value| match f["how"].as_str().unwrap_or("") { "dither" => 1.0, "cable" => 2.0, "edge" => 3.0, "layout" => 5.0, _ => 8.0 };+ let base: Vec<(String, f64)> = src.iter().map(|s| (s["id"].as_str().unwrap().to_string(), s["marginDb"].as_f64().unwrap_or(0.0))).collect();+ let gain = |f: &Value, id: &str| f["gains"].as_array().unwrap().iter().find(|g| g["source"] == id).and_then(|g| g["gainDb"].as_f64()).unwrap_or(0.0);+ let useful: Vec<&Value> = fixes.iter().filter(|f| base.iter().any(|(id, m)| *m < 6.0 && gain(f, id) > 0.0)).take(16).collect();+ let mut best: Option<(f64, f64, u32)> = None; // (shortfall, cost, mask)+ if base.iter().any(|b| b.1 < 6.0) { for mask in 0u32..(1 << useful.len()) {+ let mut short = 0.0; for (id, m) in &base { let g: f64 = useful.iter().enumerate().filter(|(k, _)| mask >> k & 1 == 1).map(|(_, f)| gain(f, id)).sum(); short += (6.0 - m - g).max(0.0); }+ let c: f64 = useful.iter().enumerate().filter(|(k, _)| mask >> k & 1 == 1).map(|(_, f)| cost(f)).sum();+ let key = (r1(short), c, mask); if best.map_or(true, |b| (key.0, key.1, mask.count_ones()) < (b.0, b.1, b.2.count_ones())) { best = Some(key); }+ } }+ let chosen_fixes: Vec<Value> = best.map(|(short, _, mask)| { let pick: Vec<&&Value> = useful.iter().enumerate().filter(|(k, _)| mask >> k & 1 == 1).map(|(_, f)| f).collect();+ vec![json!({"fixes": pick.iter().map(|f| json!({"id": f["id"], "fix": f["fix"], "cost": f["cost"]})).collect::<Vec<_>>(), "marginsAfter": base.iter().map(|(id, m)| json!({"source": id, "marginDb": r1(m + pick.iter().map(|f| gain(f, id)).sum::<f64>())})).collect::<Vec<_>>(), "reaches6Db": short == 0.0, "shortfallDb": short})] }).unwrap_or_default();+ // ---- verdicts -------------------------------------------------------------------------------------------------------+ let verdict = |test: &str| -> Value {+ let s: Vec<&Value> = src.iter().filter(|s| s["test"] == test).collect(); if s.is_empty() { return json!({"verdict": "not modelled"}); }+ let lo = s.iter().filter_map(|x| x["band"][0].as_f64()).fold(f64::MAX, f64::min); let hi_worst = s.iter().filter_map(|x| x["band"][1].as_f64()).fold(f64::MAX, f64::min);+ let w = s.iter().min_by(|a, z| a["band"][0].as_f64().partial_cmp(&z["band"][0].as_f64()).unwrap()).unwrap();+ json!({"verdict": if hi_worst < 0.0 { "likely fail" } else if lo < 0.0 { "at risk" } else if lo < 6.0 { "likely pass, thin margin" } else { "likely pass" }, "worst": w["id"], "worstBand": w["band"]})+ };+ // ---- the bench plan: cheapest and most telling first -------------------------------------------------------------+ let mut plan = Vec::new();+ if let Some(s) = src.iter().find(|s| s["id"] == "conducted") { plan.push(json!({"source": "conducted", "instrument": "LISN (50 Ω ∥ 50 µH) and a spectrum analyzer, peak detector", "setup": format!("the board powered through the LISN on {}, the load at its rated current", c["inNet"].as_str().unwrap_or("the input")), "span": "150 kHz to 30 MHz, RBW 9 kHz", "expect": format!("about {} dBuV at {} MHz (limit {} dBuV)", s["level"], s["fMHz"], s["limit"]), "record": json!({"source": "conducted", "fMHz": s["fMHz"], "value": "<reading>", "unit": "dBuV"})})); }+ for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("cable:")) { let cj = &s["id"].as_str().unwrap()[6..]; plan.push(json!({"source": s["id"], "instrument": "a common-mode current clamp (RF current probe) on the cable and a spectrum analyzer", "setup": format!("the clamp around the whole {cj} cable 5 cm from the connector, the board running at load"), "span": "30 to 300 MHz, RBW 120 kHz, peak", "expect": format!("at most {} dBuA at {} MHz (the model is an upper bound); {} dBuA puts the cable on the limit at 3 m", s["cableCurrentDbuA"], s["fMHz"], s["limitCurrentDbuA"]), "record": json!({"source": s["id"], "fMHz": s["fMHz"], "value": "<reading>", "unit": "dBuA"})})); }+ if let Some(s) = src.iter().find(|s| s["id"] == "loop") { plan.push(json!({"source": "loop", "instrument": "an H-field near-field probe (10 mm loop) and a spectrum analyzer", "setup": format!("the probe flat over the hot loop at {} about 5 mm up, then swept over the board", s["at"]), "span": format!("{} to 1000 MHz", (s["fMHz"].as_f64().unwrap_or(30.0) / 2.0).max(30.0).round()), "expect": "relative only: the loop should be the strongest spot; a near-field reading ranks sources and shows a fix working, it does not give dBuV/m (calibrate the loop with a chamber reading, unit dBuVm)", "record": Value::Null})); }+ for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("lo:")) { plan.push(json!({"source": s["id"], "instrument": "an H-field near-field probe, then a pre-scan antenna at 3 m", "setup": "the probe over the oscillator's tank and traces", "span": format!("{} MHz ± 5 %", s["fMHz"]), "expect": format!("about {} dBuV/m at 3 m (limit {})", s["level"], s["limit"]), "record": json!({"source": s["id"], "fMHz": s["fMHz"], "value": "<reading>", "unit": "dBuVm"})})); }+ for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("lo-leak:")) { plan.push(json!({"source": s["id"], "instrument": "a spectrum analyzer on the antenna port (50 Ω), through a DC block", "setup": "the antenna removed, the receiver tuned across its band", "span": format!("the oscillator's range around {} MHz", s["fMHz"]), "expect": format!("about {} dBm (limit {} dBm)", s["level"], s["limit"]), "record": json!({"source": s["id"], "fMHz": s["fMHz"], "value": "<reading>", "unit": "dBm"})})); }+ json!({"limit": label, "radiated": verdict("radiated"), "conducted": verdict("conducted"), "antenna": verdict("antenna"), "sources": src, "fixes": fixes, "toSixDb": chosen_fixes, "benchPlan": plan, "calibration": cal,+ "notes": ["Margins are dB under the limit (negative is over). The bands are the models' own error, not lab-to-lab spread.", "Fix gains add in dB, as if independent; a re-solve or a measurement confirms them.", "Put each bench reading in spec measurements ({source, fMHz, value, unit}) and re-run: the source moves onto it and its band narrows to ±2 dB."]})+}++#[cfg(test)] mod tests {+ use super::*;+ #[test] fn cable_current_on_the_limit() {+ // a current that makes the limit's field gives the limit back+ let (lim, len, f) = (43.5, 1.0, 100e6); let i = cable_limit_current(lim, len, f);+ let e = db(1.257e-6 * f * 10f64.powf(i / 20.0) * 1e-6 * len.min(0.75) / 3.0 * 1e6); assert!((e - lim).abs() < 1e-9);+ }+ #[test] fn edge_gain() { assert!((db(Conv::edge(8e-9, 100e6) / Conv::edge(16e-9, 100e6)) - 6.02).abs() < 0.01); assert_eq!(Conv::edge(8e-9, 1e6), 1.0); }+}
src/fields.rs+2@@ -45,6 +45,8 @@ pub struct Spec { /// impedance targets, Ω: a net's single-ended target by its name, a pair's differential target as "P/N" #[serde(default)] pub impedance: BTreeMap<String, f64>, /// groups of nets whose delays must match: {name: {nets, maxSkewPs | maxSkewMm | protocol}}+ /// bench readings that calibrate the compliance prediction: [{source: conducted | loop | cable:J2 | lo:<net>, fMHz, value, unit: dBuV | dBuA | dBuVm}]+ #[serde(default)] pub measurements: Vec<crate::compliance::Measurement>, #[serde(default)] pub lengthGroups: BTreeMap<String, crate::checks::LengthGroup>, /// 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>,
src/main.rs+17−1@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod caps; mod checks; mod verify; mod cm; mod conducted; mod xsec; mod limits; 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;+mod caps; mod checks; mod verify; mod compliance; mod report; mod cm; mod conducted; mod xsec; mod limits; 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;@@ -41,6 +41,10 @@ enum Cmd { #[arg(long, value_delimiter = ',')] step: Vec<u64>, #[arg(long, default_value_t = 0.2)] pitch: f64, /// an existing solve of the same board to use as "before" (skips solving it again) #[arg(long)] fields: Option<PathBuf>, /// refill the zones and run DRC with KiCad (kicad-cli: service-kicad or the desktop) on the original and the fixed copy, so other nets' pours make room for the fix and new DRC errors are reported #[arg(long)] refill: bool },+ /// Certification readiness: one report over the fields solve, Adom Safety's safety.json and Adom Mechanics' mechanics.json (each optional): every area ready, needs work, blocked or not checked, with its numbers and findings; writes <out> (Markdown) and the same as .json+ Report { #[arg(long, default_value = "fields")] fields: PathBuf, /// adom-safety's output folder or safety.json+ #[arg(long)] safety: Option<PathBuf>, /// adom-mechanics' output folder or mechanics.json+ #[arg(long)] mechanics: Option<PathBuf>, #[arg(long, default_value = "readiness.md")] out: PathBuf }, /// Is the server up Health { #[arg(long, default_value_t = 8874)] port: u16 }, /// Install the skill from the wiki page@@ -241,6 +245,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck review["cableEmissions"] = cmv; review["conductedEmissions"] = cev; review["impedance"] = xsv;+ review["compliance"] = compliance::review(&b, &spec, &review); summary["fragments"] = fragments; summary["review"] = review; summary["fixPlan"] = emc::fix_plan(&f.issues);@@ -277,6 +282,7 @@ fn main() { 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).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 c = &s["review"]["compliance"]; let v = |t: &str| { let x = &c[t.split(' ').next().unwrap_or(t)]; if x["verdict"] == "not modelled" { format!("{t} not modelled") } else { format!("{t} {} (worst {} {}…{} dB)", x["verdict"].as_str().unwrap_or(""), x["worst"].as_str().unwrap_or(""), x["worstBand"][0], x["worstBand"][1]) } }; let f: Vec<String> = c["toSixDb"][0]["fixes"].as_array().into_iter().flatten().map(|x| x["id"].as_str().unwrap_or("").to_string()).collect(); format!("Compliance ({}): {}; {}{}.{} review.compliance has every source, the fixes by dB and a bench plan.", c["limit"].as_str().unwrap_or(""), v("radiated"), v("conducted"), if c["antenna"]["verdict"] == "not modelled" { String::new() } else { format!("; {}", v("antenna port")) }, if f.is_empty() { String::new() } else { format!(" Cheapest set to {}: {}.", if c["toSixDb"][0]["reaches6Db"] == true { "6 dB everywhere" } else { "get closest to 6 dB" }, f.join(", ")) }) }, { 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 { "" }) }, { let mut v: Vec<String> = s["switchingLoads"].as_array().into_iter().flatten().map(|x| format!("{} (switching, {} A)", x["net"].as_str().unwrap_or(""), x["amps"])).collect(); if let Some(g) = s["groundReturn"].as_object() { v.push(format!("{} (return, {} A)", g["net"].as_str().unwrap_or(""), g["amps"])); } if v.is_empty() { "Loads: all from the spec.".to_string() } else { format!("Loads filled in (not in the spec): {}; set loads.<net> to change them.", v.join(", ")) } },@@ -307,6 +313,16 @@ fn main() { } Err(e) => err(&e, &["verify takes the same board and spec as analyze; `--step` picks fix-plan steps by number.".into()]), },+ Cmd::Report { fields, safety, mechanics, out } => match report::build(&fields, safety.as_deref(), mechanics.as_deref()) {+ Ok(r) => {+ let md = report::markdown(&r);+ if let Err(e) = std::fs::write(&out, &md).and_then(|_| std::fs::write(out.with_extension("json"), serde_json::to_string_pretty(&r).unwrap())) { err(&e.to_string(), &[]); }+ let mut hints: Vec<String> = r["areas"].as_array().into_iter().flatten().map(|a| format!("{}: {} ({})", a["title"].as_str().unwrap_or(""), a["status"].as_str().unwrap_or(""), a["why"].as_str().unwrap_or(""))).collect();+ hints.push(format!("{} and {} have every area's numbers and first findings; the fields solve's fixPlan, safety.json's and mechanics.json's fix plans have the actions.", out.display(), out.with_extension("json").display()));+ ok(&format!("{}: overall {}", r["board"].as_str().unwrap_or("board"), r["overall"].as_str().unwrap_or("")), &hints);+ }+ Err(e) => err(&e, &["--fields is an analyze output folder; --safety and --mechanics take those tools' output folders or json files.".into()]),+ }, Cmd::Serve { fields, port, board, spec } => { if let (Some(bd), Some(sp)) = (&board, &spec) { if let Err(e) = run_analyze(bd, sp, &fields, 0.2, 60.0, Some(&[]), false) { err(&e, &[]); } } serve::serve(&fields, port);
src/report.rsadded+119@@ -0,0 +1,119 @@+//! Certification readiness (0.9.5): one report over a board's solves. It reads the fields solve, plus Adom Safety's+//! safety.json and Adom Mechanics' mechanics.json when given. Each area (thermal, copper, power, signals, EMC,+//! electrical safety, mechanics) gets ready / needs work / blocked / not checked, its key numbers and its open+//! findings. A blocked area has a high finding, a likely fail or a safety error; needs work has a medium finding or an+//! at-risk test. The report says what it does not cover, so nobody reads "ready" as "certified".+use serde_json::{json, Value};+use std::path::Path;++const AREAS: &[(&str, &str, &[&str])] = &[+ ("thermal", "Thermal", &["tab-rise", "thermal-vias", "junction-temperature", "capacitor-temperature", "electro-thermal", "capacitor"]),+ ("copper", "Current and copper", &["current-neck", "ampacity", "ir-drop", "current-barrel", "pour-fragmented", "load-unsolved", "solve-unconverged"]),+ ("power", "Power delivery", &["pdn-impedance", "decoupling-far", "decoupling-missing", "output-ripple", "inductor-ripple", "input-filter-stability", "hot-plug-overshoot", "regulator", "stage-feed-unbypassed"]),+ ("signals", "Signal integrity", &["impedance", "diff-impedance", "length-skew", "crosstalk", "no-reference", "via-stub", "stackup-adjacent-signals", "stackup-far-reference", "plane", "cavity-resonance", "tone-in-band", "stage-isolation", "circuit-path"]),+ ("emc", "EMC and ESD", &["hot-loop", "emissions", "cable-emissions", "cable", "conducted-emissions", "lo-emission", "lo-leak", "esd-unprotected", "io-unfiltered", "input-filter-cutoff", "clock-outer-layer", "clock-near-connector", "edge-signal", "stitching-gap", "ground-offset", "ground-island", "ground-loop-risk", "antenna-ground-loop"]),+];++fn load(p: Option<&Path>, name: &str) -> Result<Option<Value>, String> {+ let Some(p) = p else { return Ok(None) };+ let f = if p.is_dir() { p.join(name) } else { p.to_path_buf() };+ let t = std::fs::read_to_string(&f).map_err(|e| format!("{}: {e}", f.display()))?;+ serde_json::from_str(&t).map(Some).map_err(|e| format!("{}: {e}", f.display()))+}+fn sev(s: &str) -> u8 { match s { "high" | "error" => 0, "medium" | "warning" => 1, _ => 2 } }+fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }++fn area(id: &str, title: &str, issues: Vec<Value>, numbers: Vec<(String, String)>, force: Option<(&str, String)>) -> Value {+ let (h, m, l) = (issues.iter().filter(|i| sev(i["severity"].as_str().unwrap_or("")) == 0).count(), issues.iter().filter(|i| sev(i["severity"].as_str().unwrap_or("")) == 1).count(), issues.iter().filter(|i| sev(i["severity"].as_str().unwrap_or("")) == 2).count());+ let mut status = if h > 0 { "blocked" } else if m > 0 { "needs work" } else { "ready" };+ let mut why = if h > 0 { format!("{h} high finding(s)") } else if m > 0 { format!("{m} medium finding(s)") } else if l > 0 { format!("{l} low finding(s) only") } else { "no findings".into() };+ if let Some((s, w)) = force { let rank = |s: &str| match s { "blocked" => 0, "needs work" => 1, _ => 2 }; if rank(s) < rank(status) { status = if s == "blocked" { "blocked" } else { "needs work" }; why = w; } else if rank(s) == rank(status) { why = format!("{why}; {w}"); } }+ let mut top = issues.clone(); top.sort_by_key(|i| sev(i["severity"].as_str().unwrap_or("")));+ json!({"id": id, "title": title, "status": status, "why": why, "counts": {"high": h, "medium": m, "low": l}, "numbers": numbers.iter().map(|(k, v)| json!({"what": k, "value": v})).collect::<Vec<_>>(), "top": top.iter().take(5).map(|i| json!({"severity": i["severity"], "kind": i["kind"], "what": i["what"], "fix": i["fix"]})).collect::<Vec<_>>()})+}++pub fn build(fields: &Path, safety: Option<&Path>, mechanics: Option<&Path>) -> Result<Value, String> {+ let f = load(Some(fields), "fields.json")?.unwrap();+ let sf = load(safety, "safety.json")?; let mc = load(mechanics, "mechanics.json")?;+ let issues = f["issues"].as_array().cloned().unwrap_or_default();+ let of = |kinds: &[&str]| -> Vec<Value> { issues.iter().filter(|i| kinds.contains(&i["kind"].as_str().unwrap_or(""))).cloned().collect() };+ let rev = &f["review"]; let mut areas = Vec::new();+ // ---- from the fields solve+ for (id, title, kinds) in AREAS {+ let mut nums: Vec<(String, String)> = Vec::new(); let mut force = None;+ match *id {+ "thermal" => {+ let h = &f["heat"]; nums.push(("Peak rise, still air".into(), format!("+{:.0} °C (budget +{:.0} °C)", h["peakRiseC"].as_f64().unwrap_or(0.0), h["budgetC"].as_f64().unwrap_or(0.0))));+ nums.push(("Peak rise, moving air".into(), format!("+{:.0} °C", h["peakRiseMovingC"].as_f64().unwrap_or(0.0))));+ if let Some(c) = f["chips"].as_array().and_then(|a| a.iter().max_by(|a, b| a["riseC"].as_f64().partial_cmp(&b["riseC"].as_f64()).unwrap())) { nums.push(("Hottest part".into(), format!("{} +{:.0} °C", c["reference"].as_str().unwrap_or(""), c["riseC"].as_f64().unwrap_or(0.0)))); }+ if h["sourcesModelled"] == 0 { force = Some(("needs work", "no heat sources in the spec: only the copper's own loss was solved".to_string())); }+ }+ "copper" => { if let Some(n) = f["nets"].as_array().and_then(|a| a.iter().max_by(|a, b| a["peakJ"].as_f64().partial_cmp(&b["peakJ"].as_f64()).unwrap())) { nums.push(("Densest copper".into(), format!("{} {:.0} A/mm² at ({}, {}) on {}", n["net"].as_str().unwrap_or(""), n["peakJ"].as_f64().unwrap_or(0.0), n["peakAt"][0], n["peakAt"][1], n["peakLayer"].as_str().unwrap_or("")))); }+ nums.push(("Copper loss".into(), format!("{} W", f["heat"]["jouleW"]))); }+ "power" => { for r in rev["pdn"]["rails"].as_array().into_iter().flatten().filter(|r| r["skipped"].is_null()) { nums.push((format!("{} impedance", r["net"].as_str().unwrap_or("")), { let hz = r["worst"]["hz"].as_f64().unwrap_or(0.0); format!("{:.2}× its target at worst, at {}", r["worst"]["ratio"].as_f64().unwrap_or(0.0), if hz >= 1e6 { format!("{:.1} MHz", hz / 1e6) } else { format!("{:.1} kHz", hz / 1e3) }) })); }+ if let Some(v) = rev["converter"]["rippleOutMv"].as_f64() { nums.push(("Converter output ripple".into(), format!("{:.1} mV", v))); } }+ "signals" => { let n = rev["impedance"]["nets"].as_array().map_or(0, |a| a.len()); if n > 0 { nums.push(("Nets with solved impedance".into(), format!("{n}"))); } }+ "emc" => {+ let c = &rev["compliance"];+ for t in ["radiated", "conducted", "antenna"] { let v = &c[t]; if v["verdict"].is_string() && v["verdict"] != "not modelled" { let lim = match t { "conducted" => rev["conductedEmissions"]["limit"].as_str().unwrap_or("").to_string(), "antenna" => "antenna port, FCC 15.111".to_string(), _ => c["limit"].as_str().unwrap_or("").to_string() };+ nums.push((format!("{} ({})", if t == "antenna" { "Oscillator leak" } else if t == "radiated" { "Radiated" } else { "Conducted" }, lim), format!("{}: worst {} {}…{} dB", v["verdict"].as_str().unwrap_or(""), v["worst"].as_str().unwrap_or(""), v["worstBand"][0], v["worstBand"][1]))); } }+ let fail: Vec<&str> = ["radiated", "conducted", "antenna"].into_iter().filter(|t| c[*t]["verdict"] == "likely fail").collect();+ let risk: Vec<&str> = ["radiated", "conducted", "antenna"].into_iter().filter(|t| c[*t]["verdict"] == "at risk").collect();+ if !fail.is_empty() { force = Some(("blocked", format!("{} likely to fail", fail.join(" and ")))); } else if !risk.is_empty() { force = Some(("needs work", format!("{} at risk", risk.join(" and ")))); }+ if let Some(set) = c["toSixDb"][0]["fixes"].as_array() { if !set.is_empty() { nums.push(("Cheapest fixes to 6 dB".into(), set.iter().map(|x| x["id"].as_str().unwrap_or("")).collect::<Vec<_>>().join(", "))); } }+ if let Some(g) = rev["ground"]["stitching"]["edgeCoveredPct"].as_f64() { nums.push(("Board edge stitched".into(), format!("{g} %"))); }+ }+ _ => {}+ }+ areas.push(area(id, title, of(kinds), nums, force));+ }+ let known: Vec<&str> = AREAS.iter().flat_map(|a| a.2.iter().copied()).collect();+ let rest: Vec<Value> = issues.iter().filter(|i| !known.contains(&i["kind"].as_str().unwrap_or(""))).cloned().collect();+ if !rest.is_empty() { areas.push(area("other", "Other findings", rest, vec![], None)); }+ // ---- electrical safety+ match &sf {+ Some(s) => {+ let iss: Vec<Value> = s["issues"].as_array().cloned().unwrap_or_default();+ let e = s["summary"]["errors"].as_u64().unwrap_or(0);+ let mut a = area("safety", "Electrical safety (creepage, clearance)", iss, vec![("Standard".into(), format!("{}, PD{}, group {}, OVC {}", s["standard"].as_str().unwrap_or(""), s["pollutionDegree"], s["materialGroup"].as_str().unwrap_or(""), s["overvoltageCategory"].as_str().unwrap_or(""))), ("Net pairs checked".into(), format!("{}: {} error(s), {} warning(s)", s["summary"]["pairs"], e, s["summary"]["warnings"])), ("Nets on default voltages".into(), format!("{}", s["defaultedNets"].as_array().map_or(0, |a| a.len())))], if e > 0 { Some(("blocked", format!("{e} required distance(s) not met"))) } else { None });+ if s["defaultedNets"].as_array().map_or(false, |a| !a.is_empty()) && a["status"] == "ready" { a["status"] = json!("needs work"); a["why"] = json!("nets on default voltages: give them their real ones"); }+ areas.push(a);+ }+ None => areas.push(json!({"id": "safety", "title": "Electrical safety (creepage, clearance)", "status": "not checked", "why": "no safety.json: run adom-safety check --board <board> --spec <spec> and pass --safety", "counts": {}, "numbers": [], "top": []})),+ }+ // ---- mechanics+ match &mc {+ Some(m) => {+ let iss: Vec<Value> = m["issues"].as_array().cloned().unwrap_or_default();+ let mut nums = vec![("First mode".into(), format!("{} Hz", m["vibration"]["firstModeHz"]))];+ for lc in m["loadCases"].as_array().into_iter().flatten().take(4) { nums.push((format!("Load case {}", lc["name"].as_str().unwrap_or("")), format!("{} mm, {} µε", lc["maxDeflectionMm"].as_f64().map_or("?".into(), |v| format!("{v:.3}")), lc["maxStrainUe"].as_f64().map(|v| v.round()).map_or("?".into(), |v| v.to_string())))); }+ areas.push(area("mechanics", "Mechanical reliability", iss, nums, None));+ }+ None => areas.push(json!({"id": "mechanics", "title": "Mechanical reliability", "status": "not checked", "why": "no mechanics.json: run adom-mechanics analyze --board <board> --fields <this solve> and pass --mechanics", "counts": {}, "numbers": [], "top": []})),+ }+ let rank = |s: &str| match s { "blocked" => 0, "needs work" => 1, "not checked" => 2, _ => 3 };+ let overall = areas.iter().map(|a| a["status"].as_str().unwrap_or("")).min_by_key(|s| rank(s)).unwrap_or("ready").to_string();+ Ok(json!({"board": f["board"]["name"], "description": f["board"]["description"], "overall": overall, "areas": areas,+ "evidence": {"fields": {"tool": "adom-fields", "solverVersion": f["inputIdentity"]["solverVersion"], "boardSha256": f["inputIdentity"]["board"], "specSha256": f["inputIdentity"]["spec"]}, "safety": sf.as_ref().map(|s| json!({"tool": s["tool"], "version": s["version"], "board": s["board"]})), "mechanics": mc.as_ref().map(|m| json!({"tool": m["tool"], "version": m["version"]}))},+ "notCovered": ["The emissions are predictions with bands, not measurements: pre-scan in a chamber (or with the bench plan in review.compliance) before booking the lab.", "Immunity (ESD discharge, radiated and conducted immunity, EFT, surge) is screened only as missing protection, not simulated.", "Safety covers spacing; it does not cover insulation materials' ratings, flammability (UL 94), the enclosure, touch current or the product standard's other clauses.", "Manufacturability (DFM) is the fab check's job (adom-aiflow fab), not this report's.", "Every number is a screening estimate from a published model with its inputs in the solve; qualify by test."]}))+}++pub fn markdown(r: &Value) -> String {+ let icon = |s: &str| match s { "blocked" => "🔴 Blocked", "needs work" => "🟠 Needs work", "not checked" => "⚪ Not checked", _ => "🟢 Ready" };+ let mut o = format!("# Certification readiness: {}\n\n{}\n\n**Overall: {}**\n\n| Area | Status | Why |\n|---|---|---|\n", r["board"].as_str().unwrap_or(""), r["description"].as_str().unwrap_or(""), icon(r["overall"].as_str().unwrap_or("")));+ for a in r["areas"].as_array().into_iter().flatten() { o += &format!("| {} | {} | {} |\n", a["title"].as_str().unwrap_or(""), icon(a["status"].as_str().unwrap_or("")), a["why"].as_str().unwrap_or("").replace('|', "/")); }+ for a in r["areas"].as_array().into_iter().flatten() {+ o += &format!("\n## {}: {}\n\n", a["title"].as_str().unwrap_or(""), icon(a["status"].as_str().unwrap_or("")));+ let nums = a["numbers"].as_array().cloned().unwrap_or_default();+ if !nums.is_empty() { for n in &nums { o += &format!("- **{}:** {}\n", n["what"].as_str().unwrap_or(""), n["value"].as_str().unwrap_or("")); } o += "\n"; }+ let top = a["top"].as_array().cloned().unwrap_or_default();+ if top.is_empty() { o += &format!("{}\n", a["why"].as_str().unwrap_or("")); continue; }+ o += &format!("Findings: {} high, {} medium, {} low. The first {}:\n\n", a["counts"]["high"], a["counts"]["medium"], a["counts"]["low"], top.len());+ for i in &top { o += &format!("- **{}** `{}`: {}\n - Fix: {}\n", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""), i["fix"].as_str().unwrap_or("")); }+ }+ o += "\n## Not covered\n\n"; for n in r["notCovered"].as_array().into_iter().flatten() { o += &format!("- {}\n", n.as_str().unwrap_or("")); }+ let e = &r["evidence"]; o += &format!("\n## Evidence\n\n- adom-fields {} on board sha256 `{}`, spec sha256 `{}`\n", e["fields"]["solverVersion"].as_str().unwrap_or(""), &e["fields"]["boardSha256"].as_str().unwrap_or("")[..12.min(e["fields"]["boardSha256"].as_str().unwrap_or("").len())], &e["fields"]["specSha256"].as_str().unwrap_or("")[..12.min(e["fields"]["specSha256"].as_str().unwrap_or("").len())]);+ if !e["safety"].is_null() { o += &format!("- {} {}\n", e["safety"]["tool"].as_str().unwrap_or("adom-safety"), e["safety"]["version"].as_str().unwrap_or("")); }+ if !e["mechanics"].is_null() { o += &format!("- {} {}\n", e["mechanics"]["tool"].as_str().unwrap_or("adom-mechanics"), e["mechanics"]["version"].as_str().unwrap_or("")); }+ o+}