← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.9.8"+version = "0.10.0" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.9.8"+version = "0.10.0" 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+6
@@ -157,6 +157,12 @@ How stiff each rail is, from slow changes to fast ones: each rail's impedance se  [Download the power video](docs/videos/pdn.mp4) +## Lean by default, more when the board needs it++`analyze` runs the core on every board: current and IR drop, heat, necks and hot tabs, part temperatures, ground offset, decoupling, the converter's hot loop and ripple. Six optional modules add EMC and compliance (`emc`), connector protection (`esd`), power delivery (`pdn`), signal integrity (`si`), noise (`noise`) and receivers (`rf`). Turn them on with `--with emc,pdn` or `--with all`.++A module that does not run is never silent. The output reads the board and says why each one may matter here. For example: "U1 switches at 500 kHz and 7 connectors can carry cables: a switcher's noise riding out on a cable is the most common reason a board fails FCC/CISPR radiated". It then says what `--with emc` would add, so the AI calling it can decide. Repetitive findings come grouped, and `fields.json` holds what an AI needs to read, with the bulk the app draws in `detail.json`.+ ## What to fix: the review for the AI  Every solve now ends with a design review an AI flow can act on without looking at a screen: each finding (a current neck, a hot tab, the switching hot loop, copper with no ground under it, ground offset, islands, gaps in the stitching fence, IR drop, converter ripple, a rail's impedance over its target) comes with a plain fix and an action to carry out (move this capacitor to these pins, add ground vias at these points, widen this net here), and `fixPlan` orders them. On the buck: the hot loop is fine (2.7 mm² against a best of 1.5), but only a third of the board edge is stitched, so the plan adds eight ground vias at the exact points it lists.
SKILL.md+44−320
@@ -2,350 +2,74 @@ name: adom-fields description: >-   Adom Fields: solve where the current and the heat go on a KiCad or Fusion board (current density,-  heat flow through pours and vias, temperature rise) and show it in 3D, driven live by the AI. Use-  when asked for current density, a board heat map, thermal analysis, IR drop or thermal vias.+  heat flow through pours and vias, temperature rise), review the design with a fix plan, and show it+  in 3D, driven live by the AI; optional EMC/compliance, ESD, power-delivery, signal-integrity, noise+  and RF checks the output tells you when to turn on. Use when asked for current density, a board heat+  map, thermal analysis, IR drop, thermal vias, EMC or FCC risk, PDN impedance or trace impedance. ---  # Adom Fields -One binary, three commands, one app. The AI does the analysis; the human explores the board; the AI drives the same view on camera.+One Rust binary. `analyze` solves the board's current and heat from the board file, reviews the design and prints what to fix; optional modules add EMC, ESD, power delivery, signal integrity, noise and RF checks; `serve` shows it all in 3D for a person. The output talks to you: read its `Hint:` lines, they say what to do next, including which optional module this board needs and why.  ## Solve  ```-adom-fields analyze --board <board.kicad_pcb | board.brd from Fusion> --spec <spec.json> --out fields+adom-fields analyze --board <board.kicad_pcb | board.brd from Fusion> --spec <spec.json> --out fields [--with emc,pdn,...|all] ``` -The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC, maxDropMv, draws: {part: amps}), `sources` (net: [the parts that feed it]), `hot` (ref: watts, tabNet, maxRiseC: that part's own limit, else hotMaxRiseC), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.--What comes out, in `fields/fields.json` and `fields/tex/`:-- `nets`, ordered by amps then by peak density: peak A/mm² and where, the layers, the copper mm², the Joule loss, the vias.-- `chips`, hottest first: the rise at the tab in still and moving air, and where its watts go within 3 mm (along its layer, down its vias, through the bare dielectric, to the air), with how many vias the spec asked for.-- `issues`: `current-neck` (a loaded net over 60 A/mm², about what IPC-2221 allows at 35 µm for 10 °C), `tab-rise` (over the budget), `thermal-vias` (fewer than the spec asks), each with `at`, `layer`, `net`, `reference` and a `fix`.-- textures per layer: `J-<layer>.png` (all nets), `J-<net>-<layer>.png`, `q-<layer>.png` (heat flow, W/mm), `T-<layer>.png` and `Tm-<layer>.png` (rise, still and moving air), `copper-<layer>.png`, plus `.raw.png` 16-bit values for the readout.--Terminals per net: the wire pads and connectors (MC*, J*, P*) on one side; on the other the spec's hot parts on the net (a FET's tab and its source pins both count), else the Q and U parts, else the parts with real pads, each part's share by its pad area. A net with neither is skipped and says so.--## Serve and show--```-adom-fields serve --fields fields --port 8874 &-```--Open `https://<slug>.adom.cloud/proxy/8874/` in pup, the browser, or a Hydrogen webview (never localhost from outside the container). The app: a 44 px header, the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, embedded; ViewCube, studio lighting, zoom to the cursor), the field painted on the picked layer with the other layers faded, the vias as dots, the hot parts labelled with their rise, the legend, the readout under the cursor, the nets and hot parts on the left, the issues and the method on the right. Phone: the two side panels open from the header buttons.--## The parts over the fields (0.5.0)--`analyze` now also places every footprint's own 3D model on the board, so the chip stands in its hot zone and the cause of a hot spot is plain. The models come from the board file: each footprint's `(model ...)` entries, resolved as KiCad does (`${KIPRJMOD}`, `${KICAD*_3DMODEL_DIR}`, then `--models-dir <folder>` by file name, then a stock KiCad library model fetched by name), converted once to GLB on the Adom STEP service (`step2glb`) and cached by content in `~/.cache/adom-fields/models/`. They land in `<out>/parts/` and the `parts` block of fields.json. A footprint with no model gets a 1 mm box from its courtyard; test points and board-only footprints stay bare copper. A failed conversion never fails the solve: it is logged and the part becomes a box.--On a Fusion board (`.brd`) the parts are found by name, since Fusion binds its 3D packages by cloud urn: each element's MPN, then its deviceset, then its package, as `<name>.step` in the `--models-dir` folders, then in `3d/` or `3dmodels/` beside the board. AI Flow's `fusion library` writes the checked Z-up models to `<run>/3d/<MPN>.step`, so a solve of `<run>/board-live.brd` finds them with no flag. The conversion needs `step2glb` on the PATH (a declared dependency since 0.6.1; `adom-wiki pkg install adom/adom-step2glb`); without it every model is logged as a failure and the parts stay bare.--```-adom-fields analyze --board run/board-2.kicad_pcb --spec spec.json --out fields --models-dir kicad/3d   # a snapshot outside the project folder-adom-fields parts --board board.kicad_pcb --fields fields      # add or refresh the parts of an existing solve-adom-fields ui view --parts ghost|solid|off|auto              # auto: see-through on every field but signals-adom-fields analyze ... --no-parts                            # skip them (no STEP service needed)-```--`ghost` draws the bodies see-through so the field glows through them; `solid` is the plain assembly; the header's cube button cycles them. The hot-part labels ride on top of their part.--## Calmer motion (0.5.0)--Every camera move and the idle sway run at half the 0.4 speed (the sway period is about 40 s), because the flow plays the clip ten times faster. `tour --pause-ms` defaults to 20000 so a beat settles before the next. For frame-exact video, the page exposes `window.fieldsCapture.begin(1/30)` and `.step()` (one frame of page time per call; the live loop stops and the orbit pivot marker hides); `ui view --camera zoom --at x,y` flies to any board point.--## Drive it (the AI's wheel)--```-adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise|pdn [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off]-adom-fields ui select --net +VBAT        # one net lit alone (current)-adom-fields ui select --chip Q3          # fly to a hot part, heat view-adom-fields ui issue --index 0           # fly to an issue-adom-fields ui toast "20 A through a 1.5 mm neck"-adom-fields ui state | console-adom-fields tour --pause-ms 20000        # the scripted walkthrough: nets by relevance, each field, the hot parts, the issues-```--The server: `GET /state`, `GET /console` (the UI's log), `GET /events` (SSE), `GET /fields.json`, `POST /ui/view|select|issue|toast`. State lives on the server; the UI, the CLI and the AI see the same view.--## In the AI Flow video--Record the pup window (Adom Bridge `desktop_record_window_start` on it) while `adom-fields tour` runs: the clip is the fields step, the toasts are the captions, the AI's own analysis on the board in 3D.--## Flow, river and the exploded stack (0.7.0)--`analyze` also writes `flow.bin` (the signed current, the voltage and the owning net of every cell) and `fields.json` `flow` (each net's rail, sense, drop, terminals as source or sink, and the current down each barrel). Two views use it:--- `ui view --quantity flow`: the current as charge, streaks whose speed and glow follow |J|, dropping through the barrels by their solved share. `--explode 1` pulls the layers apart (0 puts them back); the camera backs off to keep the stack in frame.-- `ui view --quantity river`: each net lifted to its voltage, water over it, loads as waterfalls, sources as fountains, a switching node as a slowed tide. Signs give each net's voltage and drop; the readout gives the volts under the cursor.-- `--speed 0.1..4` slows or quickens both. The badge states the scale and the exaggeration; say it when you narrate.--Tell the solve the voltages it cannot read from names, in the spec:+Save the board with its zones filled first (the filled polygons are the copper). The spec, at its smallest:  ```json-"voltages": {"VIN": 12, "VOUT": 5},-"sources": {"VOUT": ["L1"], "SW": ["U1"]},-"switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000}}+{"loads": {"VOUT": {"amps": 1.0}}, "hot": {"U1": {"watts": 0.25, "tabNet": "GND"}}, "copperUm": 35, "hotMaxRiseC": 40,+ "voltages": {"VIN": 12, "VOUT": 5}, "switching": {"SW": {"highV": 12, "freqHz": 500000, "riseNs": 8}}} ``` -Names give GND/PGND/AGND/VSS 0, 3V3 3.3, +12V 12, 5V_AUX 5; SW, LX and PH are switching nodes between the highest rail and 0 unless the spec says otherwise. Default sense: a supply flows from its connector to its parts, a ground from its parts back to its connector; `sources` flips a net whose feed is a part (a regulator or inductor output). A net with no rail stays at its drop and says "rail not given". The voltages are DC: the rail plus the solved IR drop. Copper the pinned terminal cannot reach (a floating island) sits still at the rail.--Developing the page: `ADOM_FIELDS_UI_DIR=<repo>/src/ui adom-fields serve ...` serves index.html, app.js and flow.js from disk, so a reload shows a change without a rebuild.--## Energy and light--**Energy** (`ui view --quantity energy --explode 0.6`) shows where the energy really travels: pink particles in the board material between the copper layers, along the Poynting vector S = E × H. Between two layers a gap h apart, E is (V_top − V_bottom)/h and the two current sheets give H, so S = ½ (V_top − V_bottom)/h · (K_top − K_bottom) in W/mm²; over a supply and its return that integrates to V × I. Pink rising into a part is that part taking its energy. The blue dots are the electrons in the copper at their real drift speed (J / n e, 0.073 mm/s per A/mm²), moving against the current. DC, inside the board only: part of the energy travels in the air around it.--**Light** (`ui view --quantity light`) colours each piece of copper by how fast a change travels in its field, as a % of the speed of light: 1/√εr_eff, all FR4 between planes (about 48 %), Hammerstad's εr_eff for a trace over a plane (wider is slower), mostly air with no plane under it. A switch-on front sweeps out from each fed net's source, and each load is tagged with its arrival time in picoseconds (the fastest path, the sum of length over speed along the way). εr comes from the spec's signals epsilonR, else 4.3. A switching node's DC average (`switching.SW.meanV`) sets its level in the energy view.--## Explained out loud (0.7.1)--Every view can explain itself by voice: the speaker button in the header, `adom-fields ui explain [--quantity q]` (`--stop` silences it), or automatically. The left panel's "Explain each view out loud" sets the rule per viewer: **First time** (the default: each kind of view speaks once, the first time this viewer opens it, so a newcomer hears what they are looking at), **Always**, or **On ask**. The page writes each explanation from a template with this board's own numbers (the peak density and its net, the hottest part, the rails, the electrons' speed, the arrival times), so no AI tokens are spent; the server voices it with adom-tts and keeps the mp3 under `<fields>/narration/`, named by the text's hash, so it is voiced once. A one-line subtitle follows the voice, sentence by sentence, sitting above the legend and readout so neither is covered. Without adom-tts, a toast says so.--## The energy cycle (0.7.2)--`ui view --quantity cycle`: one switching period of a buck converter, slowed about a million times (the meter states the factor). Pink energy packets run from the input capacitors through the switch into the inductor while the switch is on, and from the inductor to the output capacitors and the load all the time; blue particles ride the inductor's magnetic field lines, brightening and swelling as its current and stored energy grow, collapsing on the off-time; red arches over each capacitor brighten as its stored energy rises; orange heat leaves the switch and the inductor. A meter (bottom left, click its title to fold) gives the switch state, the inductor current waveform with a cursor, each store's voltage or current and energy, the heat and the energy delivered per cycle.--The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin, inductor ripple ΔI = (Vin − Vout)·D·T/L, the capacitor charge integrated over the period. It finds the converter from the solve: the switching node, the inductor on it (L*), the switch (the node's source), the input rail (the switch's highest rail), the output (the inductor's other net), the input and output capacitors (C* between each rail and ground). L and C come from the parts' values on the board (KiCad Value, Fusion element value; `fields.json` `partValues`), fsw from the spec's `switching.freqHz`, the load from the output net's amps, the heat from the hot parts' watts. A board it cannot model says why.--## The silkscreen (0.7.2)+- `loads`: each net's current (`maxDropMv`, `maxRiseC`, `draws`, explicit `terminals` optional). `hot`: each dissipating part's watts (`tabNet` for an exposed pad, `tabPad`, `tjMaxC`, `thetaJcKPerW`). Without `hot` the temperature is only the copper's own loss, and the output says so.+- A few seconds for a small board, one to five minutes for a dense 65 × 75 mm four-layer one at the default 0.2 mm cell. Every other key (stackup, solids, package cooling, fans, transients, terminals, Fusion boards): [spec.md](spec.md). -`analyze` exports the board's F.SilkS and B.SilkS with kicad-cli as SVG (`silk-front.svg`, `silk-back.svg`, fields.json `silk`); `adom-fields silk --board <pcb> --fields <dir>` adds them to an existing solve. The page draws each once into a texture on one plane per side (two quads, no geometry), riding its copper layer and the exploded stack, each side shown only from its own side of the board, hidden in the river. The header's T button or `ui view --silk on|off` toggles it. `--no-silk` skips it; a Fusion board has none yet.+## Read the result -## Charge: the board as capacitors (0.7.3)+- **The printed lines** (`OK:` then `Hint:`): peak density and rises, the top findings, the review, the inputs it had to assume, the first fix-plan steps, then **`Not run:` lines**, one per optional module this board may need, each with the reason found on this board and what the module adds.+- **`fields/fields.json`** (lean, made for you to read): `nets` (peak A/mm² and where), `chips` (each hot part's rise still and moving air, and where its heat goes), `heat`, `issues`, `fixPlan`, `review` (the numbers behind the findings), `review.inputs` (each assumed input with the datasheet field that replaces it and `specPatch` to fill), `modules` (what ran, what did not and why it may matter).+- **`issues`**: `severity`, `kind`, `what`, `at`, `net`, `reference`, `fix`, and an `action` to carry out (`widen`, `add-vias`, `move`, `add-part`, `add-copper`, `reroute`, `review`). Three or more low findings of one kind come as one finding whose action is `{"type": "group", "items": [...]}` with every item's own action; per-via barrel warnings come as one per net. Every kind and its module: [review.md](review.md).+- `detail.json` holds the bulk the app draws (river data, per-via currents, mode grids, PDN curves, parts): you never need to read it. -`ui view --quantity charge` (the Charge button pulls the layers apart): wherever copper at different voltages faces copper across the board material, it is a capacitor. Each face holds σ = ε0·εr·ΔV/h, red on the higher voltage, blue on the lower, brighter for more charge; particles cross the gap from the + face to the − face along the field; a switching node's copper charges and empties with its tide every cycle (the stray capacitance switching noise couples through). A panel compares the copper's own capacitance between net pairs (pF, pC) with the capacitor parts (µF, µC, the energy ½CV²); the readout gives the charge per mm² and the field under the cursor. Parallel plates only: fringing and the air above are not counted.+## Optional modules: when to turn them on -## Magnetic: the field of every current (0.7.4)+The core always runs: current, heat, necks and ampacity, hot tabs and thermal vias, part and capacitor temperatures, IR drop, ground offset and islands, decoupling distance, the converter's hot loop and ripple, the inputs assumed. The rest runs with `--with <module>` (several: `--with emc,pdn`; everything: `--with all`), or by itself when the spec already uses that module's keys. Each costs seconds, not minutes. -`ui view --quantity magnetic [--probe 2]`: the Biot–Savart field of the solved currents (every copper cell's current summed onto a half-millimetre grid of current elements, plus the current down each via). Glowing blue field lines loop around the current paths, brightest where the field is strongest, their dashes running the way the field points; a sheet at `--probe` mm above the top copper (the slider beside the view buttons, 0.5 to 15 mm) glows where the field is strong, with a white ring at every tenfold step, the way a near-field probe would see it. The readout gives µT, the share of Earth's field (about 50 µT) and H in A/m. DC: the switching field comes with the EMC loop views.+| Module | Turn it on when | It adds | On by itself when the spec has |+|---|---|---|---|+| `emc` | a switching converter, clocks or cables, and the product will be FCC/CISPR tested | hot-loop and cable (common-mode) radiated emissions, conducted emissions, return paths and stitching, a compliance prediction with the cheapest fixes by dB and a bench plan ([emc.md](emc.md)) | `emcLimit`, `emcLimits`, `cables`, `measurements` |+| `esd` | a connector faces the outside world (user cable, hot-plugged lead) | a TVS within 3 mm and a filter for each outside-facing pin; list those connectors in `external` ([emc.md](emc.md)) | `external` |+| `pdn` | a rail feeds an MCU, FPGA, RF chip or anything with fast current edges; a converter input; a connector-fed rail | each rail's impedance against its target, the cheapest decoupling set, hot-plug overshoot, input-filter stability ([pdn.md](pdn.md)) | `pdn` |+| `si` | differential pairs (USB, Ethernet, LVDS, MIPI), clocks or fast buses, traces over about 50 mm | trace impedance from a 2-D field solve, TDR and overshoot with the series resistor, length matching, via stubs, reference planes ([si.md](si.md)) | `impedance`, `tdr`, `lengthGroups` |+| `noise` | a switch node or clock near quiet or sensitive nets; power pours over ground | plane-cavity resonances against the harmonics, crosstalk into quiet nets ([noise-rf.md](noise-rf.md)) | `crosstalkMv`, `victimBudgetsMv` |+| `rf` | a receiver: antenna, oscillator, mixer, IF | LO leak out of the antenna (FCC 15.111), stage isolation, in-band tones ([noise-rf.md](noise-rf.md)) | `rf`, `bands`, `sensitiveNets` | -## The EMC screen (0.7.5)+How to decide:+- Run the core first. Then read the `Not run:` lines: they name what on this board points to each module (the converter and its frequency, the connectors, the rails and their chips, the pairs, the RF nets). If the reason applies to this product, rerun with that module.+- Before a board goes to fab, or before an FCC/CISPR pre-scan, run `--with all` once.+- When the user asks about a topic (emissions, ESD, decoupling, impedance, crosstalk, an antenna), turn that module on even if no line suggested it. -`ui view --quantity emc` on a board with a modelled switching converter (see the energy cycle): three checks drawn on the board.-- **The hot loop**: input capacitor → the switch's input pin → its ground pin → back to the capacitor, for every input capacitor; the smallest (fastest-edged) capacitor's loop is the red patch, pulsing with the switching, drawn over the parts; a green dashed outline is the smallest loop the switch allows (a capacitor straight across its pins). Areas in mm², in the board's plane between the pads.-- **The reference plane**: copper of the loaded and switching nets on the top layer, green where ground copper lies on the next layer, red where it does not (the return current has to detour); the share and mm² per net in the panel.-- **Radiated emissions**: the hot loop as a small loop antenna (Ott: E = 1.316·10⁻¹⁴ f² A I / r), its current's harmonics from the trapezoid (duty from the cycle, edge time from the spec's `switching.<net>.riseNs`, 8 ns by default), 30 MHz to 1 GHz at 3 m against the FCC Part 15 class B limit, with the worst margin and what the smallest loop would gain.-A screen, not a chamber: differential mode only; cables (common mode), which often dominate, are not modelled.+## Fix it -## For the AI that called the solve: the review and the fix plan (0.7.6)+1. Work down `fixPlan` (worst first, concrete edits before reviews). Fill `review.inputs.specPatch` from the parts' datasheets when it lists inputs that matter.+2. Copper edits can be applied and proven for you: `adom-fields verify --board b.kicad_pcb --spec s.json --step 1,3 --refill --out verify` adds the vias and widens the traces on a copy, has KiCad refill and DRC it, re-solves and reports what moved. Part, move and reroute steps are yours to make in the layout; then `analyze` again. [verify-report.md](verify-report.md)+3. `adom-fields probe --fields fields --from J1.1 --to U1.3` reads the IR drop between two pads without re-solving.+4. `adom-fields report --fields fields [--safety <adom-safety out>] [--mechanics <adom-mechanics out>]` writes a readiness report: each area ready, needs work, blocked or not checked. -`analyze` reviews the design as well as solving it, and says what to change, without anyone looking at the app:-- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop` (over `dropPct` of the rail, or over the net's own `loads.<net>.maxDropMv`), `pour-fragmented` (a poured net's fill on one layer broke into pieces: the nearest gap, the pad in it, and a `move` (`reference`, `orRotateDeg`, `byMm`) or `reroute` action; `fields.json` `fragments` lists every pour's pieces per layer), `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `cavity-resonance`, `crosstalk`, `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside).-- **`fields.json` `fixPlan`**: the actions, worst first and concrete edits before reviews, numbered for the caller to work down. `analyze` prints the first five as a `Hint: Fix plan` line and the review as a `Hint: Review` line.-- **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail), `decoupling` (pin, cap, distance, loop), `esd` (each connector pin and what protects it).-- **IR-drop probe**: `adom-fields probe --fields <dir> --from J1.1 --to U1.3` (or `--to <net>` for its farthest point, `--json` for the record) reads the drop between two pads from the solve, no re-solve; `adom-fields ui probe --from J1.1 --to U1.3` shows it in the running app's river view with a toast.-- Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `loads.<net>.maxDropMv` (none; `review.drops` lists every loaded net's drop and its two ends), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).+## Show it -After applying a step, run `analyze` again: the issue goes away or says how far it still is. In the app, clicking an issue opens the view that shows it (EMC, Ground, River, Cycle).--## Fix and verify (0.9.3)--`adom-fields verify --board b.kicad_pcb --spec s.json [--step 1,3] [--refill] [--fields <solve>] --out verify` carries out fix-plan steps on a copy of the board, solves the copy, and says what the fix did:-- **Steps it applies (copper edits only):**-  - `add-vias`: a through via, at the board's most common via size, at each point of the action (else `count` of them near `at`). A via is placed only where it lands on the net's own copper on two or more layers and keeps 0.2 mm from other nets. A blocked point moves up to 1 mm (2.5 mm around `at`) to the nearest spot that fits, and `movedMm` says how far.-  - `widen`: the net's segments within 2.5 mm of `at` (on `layer` when given) go to `toMm` (else twice as wide), each only as far as 0.2 mm clearance allows; `short: true` marks one that could not reach the goal.-  - **Each edit sees the earlier ones**, so two neighbouring nets widened in one run keep their clearance to each other.-- **Steps it refuses**, with the reason: `add-part`, `move`, `reroute`, `pour`, `add-copper`, `review`, an impedance width change, and a neck inside a pour (that needs the pour reshaped). Make those in the layout, then `verify` (or `analyze`) the edited file.-- **`--refill`:** KiCad refills the zones and runs DRC (errors only) on both the original and the fixed copy, through `kicad-cli` (service-kicad, else KiCad on the desktop through Adom Bridge). With it:-  - other nets' pours retreat from new copper, as a real refill would;-  - both solves start from refilled boards, so they differ only by the fix;-  - `drc.new` lists the errors the fix introduced.-  - Without `--refill`, other nets' fills count as fixed copper and the copy keeps the fills as saved.-- **Output:**-  - `verify/<board>.kicad_pcb`: the fixed board.-  - `before/` and `after/`: the two solves.-  - `verify.json`: per step, what was applied or why not, and `issueCleared`. Also the issues cleared and new, the metrics that moved (each net's peak density and Joule loss, peak rise still and moving air, each part's rise, ground offset, edge stitching, hot-loop, cable and conducted emissions margins) with `better` true or false, and the DRC result.-- **Reading it:**-  - A peak current density is one cell: widening can move it to a new corner where wide meets narrow. The Joule loss, temperatures and drops are the robust measures.-  - A step can be applied and its issue still be there (a neck at 300 A/mm² against a 60 A/mm² threshold). Read the numbers: they say how far it moved.-  - `--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 inputs a solve assumed (0.9.6)--`review.inputs` lists every default or estimate the results lean on, most important first (`matters`: high, medium, low). Each item gives:-- `what` and `used`;-- `from`: default, estimate or missing;-- `part`: its ref and MPN;-- `datasheetField`: the exact datasheet entry that replaces it;-- `specKey`: where the number goes;-- `affects`: what moves, with a sensitivity where one can be computed. For example: "half the edge time costs 6 dB on cable:J2".--The items covered:-- **The converter:** the switch edge and frequency, each capacitor's capacitance at its DC bias and an electrolytic's ESR.-- **Heat:** junction or operating temperature limits, θJC (asked for semiconductors only), and dissipating parts with no watts.-- **Components:** capacitor temperature ratings, and a TDR driver's output resistance and edge.-- **The board:** stackup, laminate Dk, copper weight.-- **The product's world:** ambient, cable lengths, PDN load steps, and a receiver's transistor capacitances.--`specPatch` is the spec fragment to fill in, each value a placeholder naming its source (`<TPS54202DDCR datasheet: switch-node rise time, ns>`). An AI reads the datasheets (the MPN tells it which), fills the patch, and re-runs; an item leaves the list once the spec gives it. `analyze` prints a `Hint: Inputs assumed` line with the high ones.--## Reflections and TDR (0.9.6)--`review.reflections` checks the nets where edges matter: spec `impedance` targets, `clocks`, `lengthGroups` members, and any net in spec `tdr: {net: {driverOhm, riseNs, loadPf, from, to}}`.-- **The model:**-  - **The path:** the net's copper is walked from the driver pad (`from`, else the first IC pin) to the receiver (`to`, else the farthest pad), segment by segment and through vias.-  - **The line:** each segment is a lossless line with the cross-section solve's Z0 and delay, and each via adds its pad capacitance (Johnson & Graham).-  - **What is not modelled:** branches off the path are counted, not simulated.-- **Two ngspice runs:**-  - **TDR:** a 50 Ω, 35 ps step gives `tdr.points` (ps, Ω), the impedance profile along the path.-  - **In use:** the real driver (25 Ω and 1 ns by default; a clock's edge is 7 % of its period) into a 3 pF receiver gives `inUse`: overshoot, undershoot, settling time, and the series termination (line Z0 less the driver's resistance).-- **Short nets:** a path shorter than a sixth of its edge is `electricallyShort` and only says so.-- **Issues:** overshoot over 15 % raises a `reflection` issue, with an `add-part` action for the series resistor at the driver pin.-- **On the ESC's SWD clock** (73 mm, 449 ps of 58 Ω line, a 0.5 ns edge from 20 Ω): 40 % overshoot, fixed by a 38 Ω series resistor. The DShot input (2 ns edge) is electrically short.-- `impedance` segments' `psPerMm` is now in ps/mm (it read a million times too high before 0.9.6).--## Hot parts read at their tab (0.9.8)--A hot part with a tab net is read at its tab: the pad named in `hot.<ref>.tabPad` (the exposed pad's number, e.g. `"29"`), else the largest tab-net pad, so an exposed pad wins over corner pins that share its net. `vias` counts the tab net's vias near every tab-net pad, including a footprint's own thermal-via holes. The watts are still spread over all tab-net pads by area.--## 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.--## Noise: plane resonance and crosstalk (0.7.8)--`analyze` also screens noise couplings, each finding with an action:-- **Cavities**: every power or switching pour facing the ground plane is a parallel-plate cavity; its lowest modes are solved on the real overlap (the Neumann Laplacian's eigenpairs by Lanczos with full reorthogonalisation, then Jacobi; f = c·k/(2π√εr); checked against the rectangle's c/(2a√εr) in the tests). A mode within 5 % of a strong tone (the switching harmonics up to ten times the edge's knee, `clocks` from the spec: a crystal or an LC oscillator no chip drives counts to its 5th harmonic, a logic clock further) is `cavity-resonance`, with an `add-part` capacitor at the mode's loudest point.-- **Crosstalk**: each quiet net (not a rail, not loaded, not tied to the aggressor through a capacitor, like a bootstrap) next to a switching node or a clock: closest approach, run within 1 mm, side and overlap capacitance, and the coupled step ΔV·Cm/(Cm+Cg). Over `crosstalkMv` (50) is `crosstalk`, with a `reroute` action (spacing, or a ground guard).-- `review.noise` holds the cavities' modes, the tones and the crosstalk table; `noise.json` holds the mode shapes for the app.--`ui view --quantity noise`: the largest cavity rings in 3D (its real modes, red and blue phases, white nodal lines), the switching copper pulses, and quiet nets glow by the millivolts they pick up, with arcs at the closest approach.--`ui view --quantity signals` (the Signals button pulls the layers apart): each `signals.capacitance` check lights its real overlap on both layers as two glowing plates with the electric field crossing between them as sparks, one check at a time (seven seconds each, or the one picked in the panel), tagged with its picofarads, overlap and dielectric; differential pairs glow red and blue, tagged with skew and impedance. The via barrels hide so the plates read.--## Power delivery: rail impedance (0.7.9)--`analyze` also checks each rail's impedance seen from its load pin (a pin of the chip with the most pins on the rail, else an outgoing connector; `pdn.<net>.load` names another), 1 kHz to 1 GHz. What feeds the rail comes from the spec's `sources`, the modelled converter, an on-board regulator (named like one: TLV7…, LDO, AMS1117…, bridging the rail and a higher one), a chip whose pin on the rail is named as an output (VOUT, OUT: KiCad's pin function), or the connector the solve found the current entering by; a rail with none of these is listed `skipped` with the reason, never guessed:-- **The model**: branches in parallel at the load pin. The source: a modelled converter's output is flat (5 mΩ) below its loop crossover (fsw/10, `crossoverHz`), then an inductance that resonates with the output capacitors there, damped to Q 1 (about a 50° phase margin), fading out a decade above; a linear regulator the same with a 100 kHz crossover; a connector-fed rail is about a metre of supply leads (30 mΩ, 0.7 µH; `sourceOhm`, `sourceNh`). Each capacitor: its board value (nominal, no DC-bias derating), ESR by type, body inductance from its pad span, and the mounting inductance of a microstrip over the plane from it to the load pin. The rail's copper over ground as a plate capacitor.-- **The target**: V·tolPct/100 / stepA (5 %; the step is half the rail's spec load, or the converter's output current on its input rail), over a band: up to the switching edge's knee on a converter input, 100 MHz at a chip pin, 1 MHz at a connector (`fminHz`, `fmaxHz`, or `targetOhm` outright).-- **The fix**: a peak over the target is `pdn-impedance`, with an `add-part` capacitor (value, package, pin) chosen by re-solving with each candidate, up to three, each with the ratio it reaches; still over after three is a `pour` action (a plane pair). A converter's input rail also gets Middlebrook's check: under 10 dB below the converter's negative input resistance V²/P is `input-filter-stability`, with a damping capacitor and its ESR.-- **Capacitors as they sit on the rail** (0.7.13, `src/caps.rs`, used by the PDN check and the converter's ripple): the technology comes from the Value, the MPN and the footprint (EEE-F…/CP_Elec_… electrolytic, ECAS…/EEF-…/APX… polymer, T520/T530 polymer tantalum, CP_Tantalum_… tantalum, else ceramic; C0G/NP0 by Value or vendor code), with ESR by technology. Class-II ceramics are taken at the rail's DC bias: from a measured curve when one matches the MPN (a folder of Murata SimSurfing JSON, spec `capCurves` or ADOM_FIELDS_CAP_CURVES: C at the bias, ESR at self-resonance, ESL from |Z| a decade above), else an estimate, retained = 1/(1+(V/V0)^1.6), V0 = 9.1 V·(case mm³ / µF)^0.38 (a fit to Murata's 0603 to 1206 X5R/X7R curves, about ±0.05). Each branch reports `uF`, `uFAtBias`, `type` and `biasModel`; new ceramics in the fix search are derated too, and polymer bulk parts are candidates. Ripple adds each side's ESR term.-- **Hot-plug** (0.7.14): on a connector-fed rail, the supply leads (R, L; `pdn.<net>.sourceOhm`/`sourceNh`, default 30 mΩ and 0.7 µH) are switched onto every capacitor branch at its rail bias and solved in time (backward Euler, ESL ignored), with the same step run in ngspice as a cross-check (`hotPlug.spicePeakV`). The peak is checked against the lowest voltage rating on the rail (a "35V" in a Value, or Murata's code in an ECAS/GRM MPN). Over that rating is high, over 50 % overshoot is medium: `hot-plug-overshoot`, whose action is a damping bulk capacitor (value, ESR) or a TVS. It catches a low-ESR bulk part with no damping, which an impedance peak under its target does not show.-- **Filtered rails** (0.7.15): a rail fed through a ferrite, fuse, OR-ing diode, resistor or transistor switch from another rail is followed up to its connector or regulator (by pin function or part number) and modelled as one source branch; the upstream rails' capacitors are left out (conservative at high frequency).-- `review.pdn` holds each rail's branches, |Z(f)| (`fHz` is shared), the target and band, the fixes and the curve after them; `pdn-<net>.cir` is the same circuit as an ngspice deck, run as a cross-check when ngspice is installed (`spice.maxErrPct`).--`ui view --quantity pdn` (the Power button): the frequency sweeps; each branch stands a column at its place, its height its share of the load's current (over 100 % is two branches ringing); arcs carry the shares to the load pin; at the load pin |Z| stands in a glass sleeve, the target (green under the rim, red over, steel blue outside the band); the panel draws |Z(f)| per rail with its target, band and fix curve, and a frequency slider.--## The copper stack, receivers and RF (0.7.16)--- Boards of two, four or six copper layers; plated pads only on real copper. `stackupMm` is one dielectric per real gap (else the board's stackup, else its thickness); `epsilonR` when the stackup has none. fields.json: `board.copperLayers`, `board.dielectricMm`, `board.stackupFrom`.-- `hot.<part>.tabNet` is optional: a leaded part's watts go through all its leads by pad area. With no `hot`, the temperature is only copper I²R and the output says it is not a thermal pass.-- `clocks`: `{net: Hz}` or `{net: {hz | minHz, maxHz, harmonics, amplitudeV}}` (a tuned LO). `bands`: `{name: {minHz, maxHz, ifHz, lowSideLo}}` for the frequency plan (`tone-in-band`, the band and its image). `sensitiveNets`: nets an oscillator must not reach (`circuit-path` issues with the parts on the way). `victimBudgetsMv`: a budget per victim net (µV for RF/IF inputs).-- Crosstalk is in the victim's impedance (its capacitors and resistors to ground or rails), sine for oscillators, step for switch nodes. Return path (`no-reference`) for every non-rail net on every layer: over ground, a supply plane or nothing. `antenna-ground-loop`, `stage-feed-unbypassed`, `solve-unconverged` are new kinds; `review.stageFeeds`, `review.noise.frequencyPlan`, `review.noise.circuitPaths`.--## Merged from the solver PRs (0.7.17)--- **Plated holes** (PR 5, 9): every via and plated through-hole pad barrel joins its copper layers in the electrical and thermal models (25 µm plating, circular or slotted, `platedPads` counted apart from vias). Drill holes are voids on every layer; each barrel's conductance is spread around its plated wall on cells with copper on both faces, and a wall the mesh does not resolve falls back to the nearest such cells, so a plated hole never disconnects a net. `nets[].platedConnections` gives each hole's current and mean barrel density. Blind, buried and micro vias are refused; Fusion boards read their declared `layerSetup`. See docs/plated-wall-coupling.md.-- **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.-- **Decoupling optimiser** (0.9.2, PDN rail `decapPlan`).-  - A beam search over sets of up to six new capacitors at the load pin (MLCC values in 0402/0603/0805 at their DC bias, polymer) for the cheapest set by estimated cost, then area, that meets the rail's target (`cheapest`, `optionsMeetingTarget`).-  - When none can, the lowest-|Z| set (`bestPossible`).-  - On a rail that already passes, the existing capacitors it can lose (`removable`; never the converter's own input or output capacitors).-- **Conducted emissions** (0.9.2, `review.conductedEmissions`, `conducted-emissions`).-  - The converter's trapezoid input current split between its input capacitors (at bias) and any input filter into a LISN (50 Ω ∥ 50 µH), against CISPR 32 / FCC 15.107 class B (or A with `emcLimit` *-a), 150 kHz to 30 MHz.-  - The fix sizes the input-filter inductor for the attenuation needed.-  - Differential mode only.-- **Electro-thermal** (0.9.1, `review.electroThermal`). Copper's resistance rises 0.393 %/°C, so every run reports the first-order extra Joule heat at the solved temperatures, and an `electro-thermal` issue when it is over 10 %. With spec `electroThermal: true`, a second full pass solves the current and heat with each copper cell at its solved temperature. On the ESC: +33 % Joule, peak rise 109 → 119 °C.-- **Heating transient** (0.9.1, `transient`). Spec `transient` {durationS, steps (24), powerScale (1; a stall or surge at 3× is 3), fromSteady}: implicit Euler on the still-air heat system with copper, FR4 and the solid bodies' heat capacities.-  - It gives the board peak and each hot part's rise over time, and each part's time constant (`t63S`, to 63 % of its steady rise).-  - On the buck: U1 about 2 min, L1 about 3 min.-- **Board checks** (0.9.0, `review.checks`), on the solved fields where one exists:-  - `ampacity`: each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there.-  - `junctions` / `junction-temperature`: ambient (spec `ambientC`, 25) + the solved rise (+ `hot.<ref>.thetaJcKPerW`·W) against `hot.<ref>.tjMaxC` (125).-  - `capacitors` / `capacitor-temperature`: each capacitor's solved temperature against its type's rating (read from value or part number; electrolytic life by the 10 °C rule; ceramics with only an assumed rating grouped into one finding).-  - `emcRules`: `clock-outer-layer`, `clock-near-connector`, `edge-signal`, `io-unfiltered`, `input-filter-cutoff` (a converter's input LC against fsw/10).-  - `stackup`: which layers are planes, plus `stackup-far-reference`, `stackup-adjacent-signals` and plane-pair pF/cm².-  - `viaStubs` / `via-stub`: the quarter-wave resonance of each signal via's unused barrel against its clock.-  - `lengthGroups` / `length-skew`: spec `lengthGroups` {name: {nets, maxSkewPs | maxSkewMm | protocol: usb2|usb3|pcie|ethernet|ddr|hdmi|mipi}}.-- **Emission limits** (0.9.0): spec `emcLimit` picks the set the issues use: `fcc-b` (default), `fcc-a`, `cispr32-b`, `cispr32-a`, or a custom name. Spec `emcLimits` adds lines (CISPR 25, MIL-STD-461 RE102, a customer's) as {name, distanceM, points [[MHz, dBµV/m]]}. Every emission estimate also reports its margin against every set.-- **Cable emissions** (0.9.0, `review.cableEmissions`, `cable-emissions`): common mode, the usual chamber failure.-  - The switching loop's harmonic current across the ground copper's inductance between the loop and each connector's ground pin (μ0·h × the solved squares) drives that connector's cable (spec `cables` {J1: metres}, 1 m) as a monopole.-  - It is an upper bound with no ferrite.-- **Trace impedance** (0.9.0, `review.impedance`): a 2-D Laplace field solve of every signal net's cross-section on its own layer (microstrip or stripline, solder mask unless `rf.noSolderMask`).-  - It gives Z0, εeff, ps/mm, pF/m and nH/m, plus Zodd, Zeven and Zdiff for pairs found by name (_P/_N, +/-, DP/DM).-  - Targets come from spec `impedance` {net: Ω, "P/N": Ω differential} or by protocol (USB 90, PCIe 85, Ethernet/LVDS/HDMI/MIPI 100).-  - It is validated within about 2 % of Hammerstad-Jensen and Cohn.-- **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.-- **Fan levels** (0.8.0): `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level fully. h on every exposed face (board, bodies, a packageCooling sink) follows (h_still³ + (h_forced·sⁿ)³)^⅓, where s is the fraction of full speed, s = 1 gives the moving-air h, and n is `fanExponent` (0.5). `fields.json` `airStates` gives each level's h, chip rises and bodies. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together, on one colour scale, so the levels compare by eye. `ui view --air fan50` drives it.-- **Package cooling** (PR 11): `packageCooling: [{reference, junctionCaseKPerW, caseSinkKPerW, sinkAmbientStillKPerW, sinkAmbientMovingKPerW, boardLinks, provenance}]` puts a hot part's watts at its junction and lets them split through the supplied case, sink and board links; junction, case and sink rises per air state and an energy balance in `packageCooling`. See docs/package-cooling.md.-- **`adom-fields thermal3d --spec --out`** (PR 12): axis-aligned 3D solids with explicit materials, contacts and convection for an off-board package and finned sink, with a GLB of the surface temperatures. Not CFD. See docs/thermal3d.md.--## Honest limits (0.1)--DC only (no skin effect, no switching loss on the copper); per-layer thickness when copperUmByLayer is supplied; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), optional specified rectangular conducting blocks; no fins, enclosure or radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number.--## Evidence identity--Each solve records SHA-256 identities of the board and spec in `fields.json.inputIdentity`, plus the solver version. If an input changes while solving, analysis refuses to publish the summary. AI Flow uses these identities to reject stale field screens, and requires every positive load and hot part to be represented. Existing summaries without these identities must be re-solved, not manually stamped. The identities establish which inputs were used; they do not establish the accuracy of assumed terminals, losses, materials or convection.--## Conducting blocks and signals (0.4.0)--Read `docs/heatsinks-and-signals.md` in the package before specifying these models. Use measured conductivity/contact resistance for `heatsinks`; contact is to the board face, not an automatically modeled chip lid. Never add a fictitious heatsink to make a gate pass. Inspect both air conditions and mesh sensitivity.--For signal screening, provide `signals.capacitance` or `signals.differentialPairs`, explicit consistent `stackupMm`, and justified epsilonR. Inspect Signals with `adom-fields ui --port 8874 view --quantity signals`. Unsupported geometry must stay unsupported; do not replace null estimates with guessed numbers. This is outer-layer microstrip and adjacent-layer overlap screening, not high-frequency signoff. The summary records assumptions and primary references.+```+adom-fields serve --fields fields --port 8874 &      # the 3D app; open it in a Hydrogen webview or pup+adom-fields ui view --quantity current|heat|temperature|emc|pdn|... [--net X] [--explode 0.6]+adom-fields tour                                      # the walkthrough, for a video+``` -## Fusion boards (0.6.0+)+The views (flow, river, energy, cycle, charge, magnetic, ground, noise, PDN) are for people and videos; they add no findings. Everything about them: [views.md](views.md). -A Fusion Electronics board works as `--board <board>.brd` (EAGLE source: `fusion_export_eagle_source`-on the Fusion bridge). Run RATSNEST in Fusion first so the pours are filled: the reader then uses-Fusion's own fill. Put the parts' STEP files, named by MPN, in a folder and pass `--models-dir` so the-chips sit over the fields.+## Honest limits +DC current and steady heat with flat convection (no CFD, no radiation); emissions, impedance and PDN are screening models with stated bands, not measurements. Use the numbers to rank risks and compare layouts; qualify by test.
docs/release-0.10.0.mdadded+31
@@ -0,0 +1,31 @@+# adom-fields 0.10.0: lean by default, more when the board needs it++- **Optional modules.**+  - **The core always runs:** current and IR drop, heat, necks and ampacity, hot tabs and thermal vias, part and capacitor temperatures, ground offset, decoupling, the converter's hot loop and ripple, and the inputs assumed.+  - **Six modules run on request** with `--with <module>`, `--with emc,pdn` or `--with all`, or when the spec already uses their keys:+    - `emc`: emissions and compliance;+    - `esd`: connector protection, checking only the connectors in the new spec `external` list when it is given;+    - `pdn`: power delivery;+    - `si`: signal integrity;+    - `noise`: noise;+    - `rf`: receivers.+  - **A module that did not run is never silent.** The output reads the board for the reasons each may matter (the converter and its frequency, the connectors, the rails and their chips, pairs and fast nets, RF nets) and says what the module would add. `fields.json` `modules` records the same.+  - **verify** runs every module.+- **Fewer, sharper findings.**+  - Three or more low findings of one kind become one, with every item and its action kept (`action.type: group`); `verify` carries out the items one by one.+  - Per-via barrel warnings become one per net, and ampacity folds into the neck finding on the same net.+  - The fix-plan line names each step's net or part.+- **A lean fields.json.**+  - The bulk the app draws moves to `detail.json`: the river data, each plated connection's current, the cavity grids, the PDN curves, the parts and the vias.+  - The server, `probe` and `parts` merge it back, so the app and `GET /fields.json` see everything.+  - The file is written one key, finding or net per line.+- **Faster.**+  - The moving-air and fan-level heat passes start from the still-air answer.+  - The return current on ground is no longer solved when the largest load is under 0.2 A (name the ground in `loads` to solve it anyway).+  - The ESC (default modules) runs in 105 s instead of 138 s, the e-load in 155 s instead of 216 s, and the AM/FM receiver in 245 s instead of 457 s.+- **The skill is split.**+  - SKILL.md is the core, about 7 KB instead of 49 KB, with a table of when to turn each module on.+  - Views, the full spec, EMC, PDN, signal integrity, noise and RF, verify and report, and every finding kind are in topic files beside it, read when needed.+- **The app:** the Power and Noise views say which module to run when the solve has none.++Across four real boards (buck, ESC, e-load, AM/FM), the default run gives 12 to 50 findings where 0.9.8 gave 12 to 155. The 52 high and medium findings with a concrete edit are all still there. `fields.json` is 24 KB to 240 KB where it was 0.3 MB to 5.1 MB.
install.sh+3−2
@@ -4,8 +4,9 @@ HERE="$(cd "$(dirname "$0")" && pwd)" mkdir -p "$HOME/.local/bin" "$HOME/.claude/skills/adom-fields" install -m 755 "$HERE/bin/adom-fields" "$HOME/.local/bin/.adom-fields-new" mv -f "$HOME/.local/bin/.adom-fields-new" "$HOME/.local/bin/adom-fields"-cp "$HERE/skills/adom-fields/SKILL.md" "$HOME/.claude/skills/adom-fields/SKILL.md"+# the skill is SKILL.md plus its topic files (read on demand): copy the whole folder+cp "$HERE"/skills/adom-fields/*.md "$HOME/.claude/skills/adom-fields/" echo "OK: adom-fields installed at ~/.local/bin/adom-fields. Run 'adom-fields --version'."  mkdir -p "$HOME/.codex/skills/adom-fields"-cp "$HERE/skills/adom-fields/SKILL.md" "$HOME/.codex/skills/adom-fields/SKILL.md"+cp "$HERE"/skills/adom-fields/*.md "$HOME/.codex/skills/adom-fields/"
package.json+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.9.8",-  "changelog": "0.9.8: a hot part is read at its exposed pad (hot.<ref>.tabPad, else the largest tab-net pad), and its thermal vias include the footprint's own (issue 20).",+  "version": "0.10.0",+  "changelog": "0.10.0: lean by default: the core always runs, six optional modules (emc, esd, pdn, si, noise, rf) run with --with and the output says when each matters on this board; grouped findings, a lean fields.json with detail.json for the app, faster heat passes, and the skill split into a short core plus topic files.",   "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+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.9.8",-  "changelog": "0.9.8: a hot part is read at its exposed pad (hot.<ref>.tabPad, else the largest tab-net pad), and its thermal vias include the footprint's own (issue 20).",+  "version": "0.10.0",+  "changelog": "0.10.0: lean by default: the core always runs, six optional modules (emc, esd, pdn, si, noise, rf) run with --with and the output says when each matters on this board; grouped findings, a lean fields.json with detail.json for the app, faster heat passes, and the skill split into a short core plus topic files.",   "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.",
skills/adom-fields/SKILL.md+44−320
@@ -2,350 +2,74 @@ name: adom-fields description: >-   Adom Fields: solve where the current and the heat go on a KiCad or Fusion board (current density,-  heat flow through pours and vias, temperature rise) and show it in 3D, driven live by the AI. Use-  when asked for current density, a board heat map, thermal analysis, IR drop or thermal vias.+  heat flow through pours and vias, temperature rise), review the design with a fix plan, and show it+  in 3D, driven live by the AI; optional EMC/compliance, ESD, power-delivery, signal-integrity, noise+  and RF checks the output tells you when to turn on. Use when asked for current density, a board heat+  map, thermal analysis, IR drop, thermal vias, EMC or FCC risk, PDN impedance or trace impedance. ---  # Adom Fields -One binary, three commands, one app. The AI does the analysis; the human explores the board; the AI drives the same view on camera.+One Rust binary. `analyze` solves the board's current and heat from the board file, reviews the design and prints what to fix; optional modules add EMC, ESD, power delivery, signal integrity, noise and RF checks; `serve` shows it all in 3D for a person. The output talks to you: read its `Hint:` lines, they say what to do next, including which optional module this board needs and why.  ## Solve  ```-adom-fields analyze --board <board.kicad_pcb | board.brd from Fusion> --spec <spec.json> --out fields+adom-fields analyze --board <board.kicad_pcb | board.brd from Fusion> --spec <spec.json> --out fields [--with emc,pdn,...|all] ``` -The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC, maxDropMv, draws: {part: amps}), `sources` (net: [the parts that feed it]), `hot` (ref: watts, tabNet, maxRiseC: that part's own limit, else hotMaxRiseC), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.--What comes out, in `fields/fields.json` and `fields/tex/`:-- `nets`, ordered by amps then by peak density: peak A/mm² and where, the layers, the copper mm², the Joule loss, the vias.-- `chips`, hottest first: the rise at the tab in still and moving air, and where its watts go within 3 mm (along its layer, down its vias, through the bare dielectric, to the air), with how many vias the spec asked for.-- `issues`: `current-neck` (a loaded net over 60 A/mm², about what IPC-2221 allows at 35 µm for 10 °C), `tab-rise` (over the budget), `thermal-vias` (fewer than the spec asks), each with `at`, `layer`, `net`, `reference` and a `fix`.-- textures per layer: `J-<layer>.png` (all nets), `J-<net>-<layer>.png`, `q-<layer>.png` (heat flow, W/mm), `T-<layer>.png` and `Tm-<layer>.png` (rise, still and moving air), `copper-<layer>.png`, plus `.raw.png` 16-bit values for the readout.--Terminals per net: the wire pads and connectors (MC*, J*, P*) on one side; on the other the spec's hot parts on the net (a FET's tab and its source pins both count), else the Q and U parts, else the parts with real pads, each part's share by its pad area. A net with neither is skipped and says so.--## Serve and show--```-adom-fields serve --fields fields --port 8874 &-```--Open `https://<slug>.adom.cloud/proxy/8874/` in pup, the browser, or a Hydrogen webview (never localhost from outside the container). The app: a 44 px header, the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, embedded; ViewCube, studio lighting, zoom to the cursor), the field painted on the picked layer with the other layers faded, the vias as dots, the hot parts labelled with their rise, the legend, the readout under the cursor, the nets and hot parts on the left, the issues and the method on the right. Phone: the two side panels open from the header buttons.--## The parts over the fields (0.5.0)--`analyze` now also places every footprint's own 3D model on the board, so the chip stands in its hot zone and the cause of a hot spot is plain. The models come from the board file: each footprint's `(model ...)` entries, resolved as KiCad does (`${KIPRJMOD}`, `${KICAD*_3DMODEL_DIR}`, then `--models-dir <folder>` by file name, then a stock KiCad library model fetched by name), converted once to GLB on the Adom STEP service (`step2glb`) and cached by content in `~/.cache/adom-fields/models/`. They land in `<out>/parts/` and the `parts` block of fields.json. A footprint with no model gets a 1 mm box from its courtyard; test points and board-only footprints stay bare copper. A failed conversion never fails the solve: it is logged and the part becomes a box.--On a Fusion board (`.brd`) the parts are found by name, since Fusion binds its 3D packages by cloud urn: each element's MPN, then its deviceset, then its package, as `<name>.step` in the `--models-dir` folders, then in `3d/` or `3dmodels/` beside the board. AI Flow's `fusion library` writes the checked Z-up models to `<run>/3d/<MPN>.step`, so a solve of `<run>/board-live.brd` finds them with no flag. The conversion needs `step2glb` on the PATH (a declared dependency since 0.6.1; `adom-wiki pkg install adom/adom-step2glb`); without it every model is logged as a failure and the parts stay bare.--```-adom-fields analyze --board run/board-2.kicad_pcb --spec spec.json --out fields --models-dir kicad/3d   # a snapshot outside the project folder-adom-fields parts --board board.kicad_pcb --fields fields      # add or refresh the parts of an existing solve-adom-fields ui view --parts ghost|solid|off|auto              # auto: see-through on every field but signals-adom-fields analyze ... --no-parts                            # skip them (no STEP service needed)-```--`ghost` draws the bodies see-through so the field glows through them; `solid` is the plain assembly; the header's cube button cycles them. The hot-part labels ride on top of their part.--## Calmer motion (0.5.0)--Every camera move and the idle sway run at half the 0.4 speed (the sway period is about 40 s), because the flow plays the clip ten times faster. `tour --pause-ms` defaults to 20000 so a beat settles before the next. For frame-exact video, the page exposes `window.fieldsCapture.begin(1/30)` and `.step()` (one frame of page time per call; the live loop stops and the orbit pivot marker hides); `ui view --camera zoom --at x,y` flies to any board point.--## Drive it (the AI's wheel)--```-adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise|pdn [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off]-adom-fields ui select --net +VBAT        # one net lit alone (current)-adom-fields ui select --chip Q3          # fly to a hot part, heat view-adom-fields ui issue --index 0           # fly to an issue-adom-fields ui toast "20 A through a 1.5 mm neck"-adom-fields ui state | console-adom-fields tour --pause-ms 20000        # the scripted walkthrough: nets by relevance, each field, the hot parts, the issues-```--The server: `GET /state`, `GET /console` (the UI's log), `GET /events` (SSE), `GET /fields.json`, `POST /ui/view|select|issue|toast`. State lives on the server; the UI, the CLI and the AI see the same view.--## In the AI Flow video--Record the pup window (Adom Bridge `desktop_record_window_start` on it) while `adom-fields tour` runs: the clip is the fields step, the toasts are the captions, the AI's own analysis on the board in 3D.--## Flow, river and the exploded stack (0.7.0)--`analyze` also writes `flow.bin` (the signed current, the voltage and the owning net of every cell) and `fields.json` `flow` (each net's rail, sense, drop, terminals as source or sink, and the current down each barrel). Two views use it:--- `ui view --quantity flow`: the current as charge, streaks whose speed and glow follow |J|, dropping through the barrels by their solved share. `--explode 1` pulls the layers apart (0 puts them back); the camera backs off to keep the stack in frame.-- `ui view --quantity river`: each net lifted to its voltage, water over it, loads as waterfalls, sources as fountains, a switching node as a slowed tide. Signs give each net's voltage and drop; the readout gives the volts under the cursor.-- `--speed 0.1..4` slows or quickens both. The badge states the scale and the exaggeration; say it when you narrate.--Tell the solve the voltages it cannot read from names, in the spec:+Save the board with its zones filled first (the filled polygons are the copper). The spec, at its smallest:  ```json-"voltages": {"VIN": 12, "VOUT": 5},-"sources": {"VOUT": ["L1"], "SW": ["U1"]},-"switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000}}+{"loads": {"VOUT": {"amps": 1.0}}, "hot": {"U1": {"watts": 0.25, "tabNet": "GND"}}, "copperUm": 35, "hotMaxRiseC": 40,+ "voltages": {"VIN": 12, "VOUT": 5}, "switching": {"SW": {"highV": 12, "freqHz": 500000, "riseNs": 8}}} ``` -Names give GND/PGND/AGND/VSS 0, 3V3 3.3, +12V 12, 5V_AUX 5; SW, LX and PH are switching nodes between the highest rail and 0 unless the spec says otherwise. Default sense: a supply flows from its connector to its parts, a ground from its parts back to its connector; `sources` flips a net whose feed is a part (a regulator or inductor output). A net with no rail stays at its drop and says "rail not given". The voltages are DC: the rail plus the solved IR drop. Copper the pinned terminal cannot reach (a floating island) sits still at the rail.--Developing the page: `ADOM_FIELDS_UI_DIR=<repo>/src/ui adom-fields serve ...` serves index.html, app.js and flow.js from disk, so a reload shows a change without a rebuild.--## Energy and light--**Energy** (`ui view --quantity energy --explode 0.6`) shows where the energy really travels: pink particles in the board material between the copper layers, along the Poynting vector S = E × H. Between two layers a gap h apart, E is (V_top − V_bottom)/h and the two current sheets give H, so S = ½ (V_top − V_bottom)/h · (K_top − K_bottom) in W/mm²; over a supply and its return that integrates to V × I. Pink rising into a part is that part taking its energy. The blue dots are the electrons in the copper at their real drift speed (J / n e, 0.073 mm/s per A/mm²), moving against the current. DC, inside the board only: part of the energy travels in the air around it.--**Light** (`ui view --quantity light`) colours each piece of copper by how fast a change travels in its field, as a % of the speed of light: 1/√εr_eff, all FR4 between planes (about 48 %), Hammerstad's εr_eff for a trace over a plane (wider is slower), mostly air with no plane under it. A switch-on front sweeps out from each fed net's source, and each load is tagged with its arrival time in picoseconds (the fastest path, the sum of length over speed along the way). εr comes from the spec's signals epsilonR, else 4.3. A switching node's DC average (`switching.SW.meanV`) sets its level in the energy view.--## Explained out loud (0.7.1)--Every view can explain itself by voice: the speaker button in the header, `adom-fields ui explain [--quantity q]` (`--stop` silences it), or automatically. The left panel's "Explain each view out loud" sets the rule per viewer: **First time** (the default: each kind of view speaks once, the first time this viewer opens it, so a newcomer hears what they are looking at), **Always**, or **On ask**. The page writes each explanation from a template with this board's own numbers (the peak density and its net, the hottest part, the rails, the electrons' speed, the arrival times), so no AI tokens are spent; the server voices it with adom-tts and keeps the mp3 under `<fields>/narration/`, named by the text's hash, so it is voiced once. A one-line subtitle follows the voice, sentence by sentence, sitting above the legend and readout so neither is covered. Without adom-tts, a toast says so.--## The energy cycle (0.7.2)--`ui view --quantity cycle`: one switching period of a buck converter, slowed about a million times (the meter states the factor). Pink energy packets run from the input capacitors through the switch into the inductor while the switch is on, and from the inductor to the output capacitors and the load all the time; blue particles ride the inductor's magnetic field lines, brightening and swelling as its current and stored energy grow, collapsing on the off-time; red arches over each capacitor brighten as its stored energy rises; orange heat leaves the switch and the inductor. A meter (bottom left, click its title to fold) gives the switch state, the inductor current waveform with a cursor, each store's voltage or current and energy, the heat and the energy delivered per cycle.--The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin, inductor ripple ΔI = (Vin − Vout)·D·T/L, the capacitor charge integrated over the period. It finds the converter from the solve: the switching node, the inductor on it (L*), the switch (the node's source), the input rail (the switch's highest rail), the output (the inductor's other net), the input and output capacitors (C* between each rail and ground). L and C come from the parts' values on the board (KiCad Value, Fusion element value; `fields.json` `partValues`), fsw from the spec's `switching.freqHz`, the load from the output net's amps, the heat from the hot parts' watts. A board it cannot model says why.--## The silkscreen (0.7.2)+- `loads`: each net's current (`maxDropMv`, `maxRiseC`, `draws`, explicit `terminals` optional). `hot`: each dissipating part's watts (`tabNet` for an exposed pad, `tabPad`, `tjMaxC`, `thetaJcKPerW`). Without `hot` the temperature is only the copper's own loss, and the output says so.+- A few seconds for a small board, one to five minutes for a dense 65 × 75 mm four-layer one at the default 0.2 mm cell. Every other key (stackup, solids, package cooling, fans, transients, terminals, Fusion boards): [spec.md](spec.md). -`analyze` exports the board's F.SilkS and B.SilkS with kicad-cli as SVG (`silk-front.svg`, `silk-back.svg`, fields.json `silk`); `adom-fields silk --board <pcb> --fields <dir>` adds them to an existing solve. The page draws each once into a texture on one plane per side (two quads, no geometry), riding its copper layer and the exploded stack, each side shown only from its own side of the board, hidden in the river. The header's T button or `ui view --silk on|off` toggles it. `--no-silk` skips it; a Fusion board has none yet.+## Read the result -## Charge: the board as capacitors (0.7.3)+- **The printed lines** (`OK:` then `Hint:`): peak density and rises, the top findings, the review, the inputs it had to assume, the first fix-plan steps, then **`Not run:` lines**, one per optional module this board may need, each with the reason found on this board and what the module adds.+- **`fields/fields.json`** (lean, made for you to read): `nets` (peak A/mm² and where), `chips` (each hot part's rise still and moving air, and where its heat goes), `heat`, `issues`, `fixPlan`, `review` (the numbers behind the findings), `review.inputs` (each assumed input with the datasheet field that replaces it and `specPatch` to fill), `modules` (what ran, what did not and why it may matter).+- **`issues`**: `severity`, `kind`, `what`, `at`, `net`, `reference`, `fix`, and an `action` to carry out (`widen`, `add-vias`, `move`, `add-part`, `add-copper`, `reroute`, `review`). Three or more low findings of one kind come as one finding whose action is `{"type": "group", "items": [...]}` with every item's own action; per-via barrel warnings come as one per net. Every kind and its module: [review.md](review.md).+- `detail.json` holds the bulk the app draws (river data, per-via currents, mode grids, PDN curves, parts): you never need to read it. -`ui view --quantity charge` (the Charge button pulls the layers apart): wherever copper at different voltages faces copper across the board material, it is a capacitor. Each face holds σ = ε0·εr·ΔV/h, red on the higher voltage, blue on the lower, brighter for more charge; particles cross the gap from the + face to the − face along the field; a switching node's copper charges and empties with its tide every cycle (the stray capacitance switching noise couples through). A panel compares the copper's own capacitance between net pairs (pF, pC) with the capacitor parts (µF, µC, the energy ½CV²); the readout gives the charge per mm² and the field under the cursor. Parallel plates only: fringing and the air above are not counted.+## Optional modules: when to turn them on -## Magnetic: the field of every current (0.7.4)+The core always runs: current, heat, necks and ampacity, hot tabs and thermal vias, part and capacitor temperatures, IR drop, ground offset and islands, decoupling distance, the converter's hot loop and ripple, the inputs assumed. The rest runs with `--with <module>` (several: `--with emc,pdn`; everything: `--with all`), or by itself when the spec already uses that module's keys. Each costs seconds, not minutes. -`ui view --quantity magnetic [--probe 2]`: the Biot–Savart field of the solved currents (every copper cell's current summed onto a half-millimetre grid of current elements, plus the current down each via). Glowing blue field lines loop around the current paths, brightest where the field is strongest, their dashes running the way the field points; a sheet at `--probe` mm above the top copper (the slider beside the view buttons, 0.5 to 15 mm) glows where the field is strong, with a white ring at every tenfold step, the way a near-field probe would see it. The readout gives µT, the share of Earth's field (about 50 µT) and H in A/m. DC: the switching field comes with the EMC loop views.+| Module | Turn it on when | It adds | On by itself when the spec has |+|---|---|---|---|+| `emc` | a switching converter, clocks or cables, and the product will be FCC/CISPR tested | hot-loop and cable (common-mode) radiated emissions, conducted emissions, return paths and stitching, a compliance prediction with the cheapest fixes by dB and a bench plan ([emc.md](emc.md)) | `emcLimit`, `emcLimits`, `cables`, `measurements` |+| `esd` | a connector faces the outside world (user cable, hot-plugged lead) | a TVS within 3 mm and a filter for each outside-facing pin; list those connectors in `external` ([emc.md](emc.md)) | `external` |+| `pdn` | a rail feeds an MCU, FPGA, RF chip or anything with fast current edges; a converter input; a connector-fed rail | each rail's impedance against its target, the cheapest decoupling set, hot-plug overshoot, input-filter stability ([pdn.md](pdn.md)) | `pdn` |+| `si` | differential pairs (USB, Ethernet, LVDS, MIPI), clocks or fast buses, traces over about 50 mm | trace impedance from a 2-D field solve, TDR and overshoot with the series resistor, length matching, via stubs, reference planes ([si.md](si.md)) | `impedance`, `tdr`, `lengthGroups` |+| `noise` | a switch node or clock near quiet or sensitive nets; power pours over ground | plane-cavity resonances against the harmonics, crosstalk into quiet nets ([noise-rf.md](noise-rf.md)) | `crosstalkMv`, `victimBudgetsMv` |+| `rf` | a receiver: antenna, oscillator, mixer, IF | LO leak out of the antenna (FCC 15.111), stage isolation, in-band tones ([noise-rf.md](noise-rf.md)) | `rf`, `bands`, `sensitiveNets` | -## The EMC screen (0.7.5)+How to decide:+- Run the core first. Then read the `Not run:` lines: they name what on this board points to each module (the converter and its frequency, the connectors, the rails and their chips, the pairs, the RF nets). If the reason applies to this product, rerun with that module.+- Before a board goes to fab, or before an FCC/CISPR pre-scan, run `--with all` once.+- When the user asks about a topic (emissions, ESD, decoupling, impedance, crosstalk, an antenna), turn that module on even if no line suggested it. -`ui view --quantity emc` on a board with a modelled switching converter (see the energy cycle): three checks drawn on the board.-- **The hot loop**: input capacitor → the switch's input pin → its ground pin → back to the capacitor, for every input capacitor; the smallest (fastest-edged) capacitor's loop is the red patch, pulsing with the switching, drawn over the parts; a green dashed outline is the smallest loop the switch allows (a capacitor straight across its pins). Areas in mm², in the board's plane between the pads.-- **The reference plane**: copper of the loaded and switching nets on the top layer, green where ground copper lies on the next layer, red where it does not (the return current has to detour); the share and mm² per net in the panel.-- **Radiated emissions**: the hot loop as a small loop antenna (Ott: E = 1.316·10⁻¹⁴ f² A I / r), its current's harmonics from the trapezoid (duty from the cycle, edge time from the spec's `switching.<net>.riseNs`, 8 ns by default), 30 MHz to 1 GHz at 3 m against the FCC Part 15 class B limit, with the worst margin and what the smallest loop would gain.-A screen, not a chamber: differential mode only; cables (common mode), which often dominate, are not modelled.+## Fix it -## For the AI that called the solve: the review and the fix plan (0.7.6)+1. Work down `fixPlan` (worst first, concrete edits before reviews). Fill `review.inputs.specPatch` from the parts' datasheets when it lists inputs that matter.+2. Copper edits can be applied and proven for you: `adom-fields verify --board b.kicad_pcb --spec s.json --step 1,3 --refill --out verify` adds the vias and widens the traces on a copy, has KiCad refill and DRC it, re-solves and reports what moved. Part, move and reroute steps are yours to make in the layout; then `analyze` again. [verify-report.md](verify-report.md)+3. `adom-fields probe --fields fields --from J1.1 --to U1.3` reads the IR drop between two pads without re-solving.+4. `adom-fields report --fields fields [--safety <adom-safety out>] [--mechanics <adom-mechanics out>]` writes a readiness report: each area ready, needs work, blocked or not checked. -`analyze` reviews the design as well as solving it, and says what to change, without anyone looking at the app:-- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop` (over `dropPct` of the rail, or over the net's own `loads.<net>.maxDropMv`), `pour-fragmented` (a poured net's fill on one layer broke into pieces: the nearest gap, the pad in it, and a `move` (`reference`, `orRotateDeg`, `byMm`) or `reroute` action; `fields.json` `fragments` lists every pour's pieces per layer), `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `cavity-resonance`, `crosstalk`, `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside).-- **`fields.json` `fixPlan`**: the actions, worst first and concrete edits before reviews, numbered for the caller to work down. `analyze` prints the first five as a `Hint: Fix plan` line and the review as a `Hint: Review` line.-- **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail), `decoupling` (pin, cap, distance, loop), `esd` (each connector pin and what protects it).-- **IR-drop probe**: `adom-fields probe --fields <dir> --from J1.1 --to U1.3` (or `--to <net>` for its farthest point, `--json` for the record) reads the drop between two pads from the solve, no re-solve; `adom-fields ui probe --from J1.1 --to U1.3` shows it in the running app's river view with a toast.-- Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `loads.<net>.maxDropMv` (none; `review.drops` lists every loaded net's drop and its two ends), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).+## Show it -After applying a step, run `analyze` again: the issue goes away or says how far it still is. In the app, clicking an issue opens the view that shows it (EMC, Ground, River, Cycle).--## Fix and verify (0.9.3)--`adom-fields verify --board b.kicad_pcb --spec s.json [--step 1,3] [--refill] [--fields <solve>] --out verify` carries out fix-plan steps on a copy of the board, solves the copy, and says what the fix did:-- **Steps it applies (copper edits only):**-  - `add-vias`: a through via, at the board's most common via size, at each point of the action (else `count` of them near `at`). A via is placed only where it lands on the net's own copper on two or more layers and keeps 0.2 mm from other nets. A blocked point moves up to 1 mm (2.5 mm around `at`) to the nearest spot that fits, and `movedMm` says how far.-  - `widen`: the net's segments within 2.5 mm of `at` (on `layer` when given) go to `toMm` (else twice as wide), each only as far as 0.2 mm clearance allows; `short: true` marks one that could not reach the goal.-  - **Each edit sees the earlier ones**, so two neighbouring nets widened in one run keep their clearance to each other.-- **Steps it refuses**, with the reason: `add-part`, `move`, `reroute`, `pour`, `add-copper`, `review`, an impedance width change, and a neck inside a pour (that needs the pour reshaped). Make those in the layout, then `verify` (or `analyze`) the edited file.-- **`--refill`:** KiCad refills the zones and runs DRC (errors only) on both the original and the fixed copy, through `kicad-cli` (service-kicad, else KiCad on the desktop through Adom Bridge). With it:-  - other nets' pours retreat from new copper, as a real refill would;-  - both solves start from refilled boards, so they differ only by the fix;-  - `drc.new` lists the errors the fix introduced.-  - Without `--refill`, other nets' fills count as fixed copper and the copy keeps the fills as saved.-- **Output:**-  - `verify/<board>.kicad_pcb`: the fixed board.-  - `before/` and `after/`: the two solves.-  - `verify.json`: per step, what was applied or why not, and `issueCleared`. Also the issues cleared and new, the metrics that moved (each net's peak density and Joule loss, peak rise still and moving air, each part's rise, ground offset, edge stitching, hot-loop, cable and conducted emissions margins) with `better` true or false, and the DRC result.-- **Reading it:**-  - A peak current density is one cell: widening can move it to a new corner where wide meets narrow. The Joule loss, temperatures and drops are the robust measures.-  - A step can be applied and its issue still be there (a neck at 300 A/mm² against a 60 A/mm² threshold). Read the numbers: they say how far it moved.-  - `--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 inputs a solve assumed (0.9.6)--`review.inputs` lists every default or estimate the results lean on, most important first (`matters`: high, medium, low). Each item gives:-- `what` and `used`;-- `from`: default, estimate or missing;-- `part`: its ref and MPN;-- `datasheetField`: the exact datasheet entry that replaces it;-- `specKey`: where the number goes;-- `affects`: what moves, with a sensitivity where one can be computed. For example: "half the edge time costs 6 dB on cable:J2".--The items covered:-- **The converter:** the switch edge and frequency, each capacitor's capacitance at its DC bias and an electrolytic's ESR.-- **Heat:** junction or operating temperature limits, θJC (asked for semiconductors only), and dissipating parts with no watts.-- **Components:** capacitor temperature ratings, and a TDR driver's output resistance and edge.-- **The board:** stackup, laminate Dk, copper weight.-- **The product's world:** ambient, cable lengths, PDN load steps, and a receiver's transistor capacitances.--`specPatch` is the spec fragment to fill in, each value a placeholder naming its source (`<TPS54202DDCR datasheet: switch-node rise time, ns>`). An AI reads the datasheets (the MPN tells it which), fills the patch, and re-runs; an item leaves the list once the spec gives it. `analyze` prints a `Hint: Inputs assumed` line with the high ones.--## Reflections and TDR (0.9.6)--`review.reflections` checks the nets where edges matter: spec `impedance` targets, `clocks`, `lengthGroups` members, and any net in spec `tdr: {net: {driverOhm, riseNs, loadPf, from, to}}`.-- **The model:**-  - **The path:** the net's copper is walked from the driver pad (`from`, else the first IC pin) to the receiver (`to`, else the farthest pad), segment by segment and through vias.-  - **The line:** each segment is a lossless line with the cross-section solve's Z0 and delay, and each via adds its pad capacitance (Johnson & Graham).-  - **What is not modelled:** branches off the path are counted, not simulated.-- **Two ngspice runs:**-  - **TDR:** a 50 Ω, 35 ps step gives `tdr.points` (ps, Ω), the impedance profile along the path.-  - **In use:** the real driver (25 Ω and 1 ns by default; a clock's edge is 7 % of its period) into a 3 pF receiver gives `inUse`: overshoot, undershoot, settling time, and the series termination (line Z0 less the driver's resistance).-- **Short nets:** a path shorter than a sixth of its edge is `electricallyShort` and only says so.-- **Issues:** overshoot over 15 % raises a `reflection` issue, with an `add-part` action for the series resistor at the driver pin.-- **On the ESC's SWD clock** (73 mm, 449 ps of 58 Ω line, a 0.5 ns edge from 20 Ω): 40 % overshoot, fixed by a 38 Ω series resistor. The DShot input (2 ns edge) is electrically short.-- `impedance` segments' `psPerMm` is now in ps/mm (it read a million times too high before 0.9.6).--## Hot parts read at their tab (0.9.8)--A hot part with a tab net is read at its tab: the pad named in `hot.<ref>.tabPad` (the exposed pad's number, e.g. `"29"`), else the largest tab-net pad, so an exposed pad wins over corner pins that share its net. `vias` counts the tab net's vias near every tab-net pad, including a footprint's own thermal-via holes. The watts are still spread over all tab-net pads by area.--## 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.--## Noise: plane resonance and crosstalk (0.7.8)--`analyze` also screens noise couplings, each finding with an action:-- **Cavities**: every power or switching pour facing the ground plane is a parallel-plate cavity; its lowest modes are solved on the real overlap (the Neumann Laplacian's eigenpairs by Lanczos with full reorthogonalisation, then Jacobi; f = c·k/(2π√εr); checked against the rectangle's c/(2a√εr) in the tests). A mode within 5 % of a strong tone (the switching harmonics up to ten times the edge's knee, `clocks` from the spec: a crystal or an LC oscillator no chip drives counts to its 5th harmonic, a logic clock further) is `cavity-resonance`, with an `add-part` capacitor at the mode's loudest point.-- **Crosstalk**: each quiet net (not a rail, not loaded, not tied to the aggressor through a capacitor, like a bootstrap) next to a switching node or a clock: closest approach, run within 1 mm, side and overlap capacitance, and the coupled step ΔV·Cm/(Cm+Cg). Over `crosstalkMv` (50) is `crosstalk`, with a `reroute` action (spacing, or a ground guard).-- `review.noise` holds the cavities' modes, the tones and the crosstalk table; `noise.json` holds the mode shapes for the app.--`ui view --quantity noise`: the largest cavity rings in 3D (its real modes, red and blue phases, white nodal lines), the switching copper pulses, and quiet nets glow by the millivolts they pick up, with arcs at the closest approach.--`ui view --quantity signals` (the Signals button pulls the layers apart): each `signals.capacitance` check lights its real overlap on both layers as two glowing plates with the electric field crossing between them as sparks, one check at a time (seven seconds each, or the one picked in the panel), tagged with its picofarads, overlap and dielectric; differential pairs glow red and blue, tagged with skew and impedance. The via barrels hide so the plates read.--## Power delivery: rail impedance (0.7.9)--`analyze` also checks each rail's impedance seen from its load pin (a pin of the chip with the most pins on the rail, else an outgoing connector; `pdn.<net>.load` names another), 1 kHz to 1 GHz. What feeds the rail comes from the spec's `sources`, the modelled converter, an on-board regulator (named like one: TLV7…, LDO, AMS1117…, bridging the rail and a higher one), a chip whose pin on the rail is named as an output (VOUT, OUT: KiCad's pin function), or the connector the solve found the current entering by; a rail with none of these is listed `skipped` with the reason, never guessed:-- **The model**: branches in parallel at the load pin. The source: a modelled converter's output is flat (5 mΩ) below its loop crossover (fsw/10, `crossoverHz`), then an inductance that resonates with the output capacitors there, damped to Q 1 (about a 50° phase margin), fading out a decade above; a linear regulator the same with a 100 kHz crossover; a connector-fed rail is about a metre of supply leads (30 mΩ, 0.7 µH; `sourceOhm`, `sourceNh`). Each capacitor: its board value (nominal, no DC-bias derating), ESR by type, body inductance from its pad span, and the mounting inductance of a microstrip over the plane from it to the load pin. The rail's copper over ground as a plate capacitor.-- **The target**: V·tolPct/100 / stepA (5 %; the step is half the rail's spec load, or the converter's output current on its input rail), over a band: up to the switching edge's knee on a converter input, 100 MHz at a chip pin, 1 MHz at a connector (`fminHz`, `fmaxHz`, or `targetOhm` outright).-- **The fix**: a peak over the target is `pdn-impedance`, with an `add-part` capacitor (value, package, pin) chosen by re-solving with each candidate, up to three, each with the ratio it reaches; still over after three is a `pour` action (a plane pair). A converter's input rail also gets Middlebrook's check: under 10 dB below the converter's negative input resistance V²/P is `input-filter-stability`, with a damping capacitor and its ESR.-- **Capacitors as they sit on the rail** (0.7.13, `src/caps.rs`, used by the PDN check and the converter's ripple): the technology comes from the Value, the MPN and the footprint (EEE-F…/CP_Elec_… electrolytic, ECAS…/EEF-…/APX… polymer, T520/T530 polymer tantalum, CP_Tantalum_… tantalum, else ceramic; C0G/NP0 by Value or vendor code), with ESR by technology. Class-II ceramics are taken at the rail's DC bias: from a measured curve when one matches the MPN (a folder of Murata SimSurfing JSON, spec `capCurves` or ADOM_FIELDS_CAP_CURVES: C at the bias, ESR at self-resonance, ESL from |Z| a decade above), else an estimate, retained = 1/(1+(V/V0)^1.6), V0 = 9.1 V·(case mm³ / µF)^0.38 (a fit to Murata's 0603 to 1206 X5R/X7R curves, about ±0.05). Each branch reports `uF`, `uFAtBias`, `type` and `biasModel`; new ceramics in the fix search are derated too, and polymer bulk parts are candidates. Ripple adds each side's ESR term.-- **Hot-plug** (0.7.14): on a connector-fed rail, the supply leads (R, L; `pdn.<net>.sourceOhm`/`sourceNh`, default 30 mΩ and 0.7 µH) are switched onto every capacitor branch at its rail bias and solved in time (backward Euler, ESL ignored), with the same step run in ngspice as a cross-check (`hotPlug.spicePeakV`). The peak is checked against the lowest voltage rating on the rail (a "35V" in a Value, or Murata's code in an ECAS/GRM MPN). Over that rating is high, over 50 % overshoot is medium: `hot-plug-overshoot`, whose action is a damping bulk capacitor (value, ESR) or a TVS. It catches a low-ESR bulk part with no damping, which an impedance peak under its target does not show.-- **Filtered rails** (0.7.15): a rail fed through a ferrite, fuse, OR-ing diode, resistor or transistor switch from another rail is followed up to its connector or regulator (by pin function or part number) and modelled as one source branch; the upstream rails' capacitors are left out (conservative at high frequency).-- `review.pdn` holds each rail's branches, |Z(f)| (`fHz` is shared), the target and band, the fixes and the curve after them; `pdn-<net>.cir` is the same circuit as an ngspice deck, run as a cross-check when ngspice is installed (`spice.maxErrPct`).--`ui view --quantity pdn` (the Power button): the frequency sweeps; each branch stands a column at its place, its height its share of the load's current (over 100 % is two branches ringing); arcs carry the shares to the load pin; at the load pin |Z| stands in a glass sleeve, the target (green under the rim, red over, steel blue outside the band); the panel draws |Z(f)| per rail with its target, band and fix curve, and a frequency slider.--## The copper stack, receivers and RF (0.7.16)--- Boards of two, four or six copper layers; plated pads only on real copper. `stackupMm` is one dielectric per real gap (else the board's stackup, else its thickness); `epsilonR` when the stackup has none. fields.json: `board.copperLayers`, `board.dielectricMm`, `board.stackupFrom`.-- `hot.<part>.tabNet` is optional: a leaded part's watts go through all its leads by pad area. With no `hot`, the temperature is only copper I²R and the output says it is not a thermal pass.-- `clocks`: `{net: Hz}` or `{net: {hz | minHz, maxHz, harmonics, amplitudeV}}` (a tuned LO). `bands`: `{name: {minHz, maxHz, ifHz, lowSideLo}}` for the frequency plan (`tone-in-band`, the band and its image). `sensitiveNets`: nets an oscillator must not reach (`circuit-path` issues with the parts on the way). `victimBudgetsMv`: a budget per victim net (µV for RF/IF inputs).-- Crosstalk is in the victim's impedance (its capacitors and resistors to ground or rails), sine for oscillators, step for switch nodes. Return path (`no-reference`) for every non-rail net on every layer: over ground, a supply plane or nothing. `antenna-ground-loop`, `stage-feed-unbypassed`, `solve-unconverged` are new kinds; `review.stageFeeds`, `review.noise.frequencyPlan`, `review.noise.circuitPaths`.--## Merged from the solver PRs (0.7.17)--- **Plated holes** (PR 5, 9): every via and plated through-hole pad barrel joins its copper layers in the electrical and thermal models (25 µm plating, circular or slotted, `platedPads` counted apart from vias). Drill holes are voids on every layer; each barrel's conductance is spread around its plated wall on cells with copper on both faces, and a wall the mesh does not resolve falls back to the nearest such cells, so a plated hole never disconnects a net. `nets[].platedConnections` gives each hole's current and mean barrel density. Blind, buried and micro vias are refused; Fusion boards read their declared `layerSetup`. See docs/plated-wall-coupling.md.-- **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.-- **Decoupling optimiser** (0.9.2, PDN rail `decapPlan`).-  - A beam search over sets of up to six new capacitors at the load pin (MLCC values in 0402/0603/0805 at their DC bias, polymer) for the cheapest set by estimated cost, then area, that meets the rail's target (`cheapest`, `optionsMeetingTarget`).-  - When none can, the lowest-|Z| set (`bestPossible`).-  - On a rail that already passes, the existing capacitors it can lose (`removable`; never the converter's own input or output capacitors).-- **Conducted emissions** (0.9.2, `review.conductedEmissions`, `conducted-emissions`).-  - The converter's trapezoid input current split between its input capacitors (at bias) and any input filter into a LISN (50 Ω ∥ 50 µH), against CISPR 32 / FCC 15.107 class B (or A with `emcLimit` *-a), 150 kHz to 30 MHz.-  - The fix sizes the input-filter inductor for the attenuation needed.-  - Differential mode only.-- **Electro-thermal** (0.9.1, `review.electroThermal`). Copper's resistance rises 0.393 %/°C, so every run reports the first-order extra Joule heat at the solved temperatures, and an `electro-thermal` issue when it is over 10 %. With spec `electroThermal: true`, a second full pass solves the current and heat with each copper cell at its solved temperature. On the ESC: +33 % Joule, peak rise 109 → 119 °C.-- **Heating transient** (0.9.1, `transient`). Spec `transient` {durationS, steps (24), powerScale (1; a stall or surge at 3× is 3), fromSteady}: implicit Euler on the still-air heat system with copper, FR4 and the solid bodies' heat capacities.-  - It gives the board peak and each hot part's rise over time, and each part's time constant (`t63S`, to 63 % of its steady rise).-  - On the buck: U1 about 2 min, L1 about 3 min.-- **Board checks** (0.9.0, `review.checks`), on the solved fields where one exists:-  - `ampacity`: each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there.-  - `junctions` / `junction-temperature`: ambient (spec `ambientC`, 25) + the solved rise (+ `hot.<ref>.thetaJcKPerW`·W) against `hot.<ref>.tjMaxC` (125).-  - `capacitors` / `capacitor-temperature`: each capacitor's solved temperature against its type's rating (read from value or part number; electrolytic life by the 10 °C rule; ceramics with only an assumed rating grouped into one finding).-  - `emcRules`: `clock-outer-layer`, `clock-near-connector`, `edge-signal`, `io-unfiltered`, `input-filter-cutoff` (a converter's input LC against fsw/10).-  - `stackup`: which layers are planes, plus `stackup-far-reference`, `stackup-adjacent-signals` and plane-pair pF/cm².-  - `viaStubs` / `via-stub`: the quarter-wave resonance of each signal via's unused barrel against its clock.-  - `lengthGroups` / `length-skew`: spec `lengthGroups` {name: {nets, maxSkewPs | maxSkewMm | protocol: usb2|usb3|pcie|ethernet|ddr|hdmi|mipi}}.-- **Emission limits** (0.9.0): spec `emcLimit` picks the set the issues use: `fcc-b` (default), `fcc-a`, `cispr32-b`, `cispr32-a`, or a custom name. Spec `emcLimits` adds lines (CISPR 25, MIL-STD-461 RE102, a customer's) as {name, distanceM, points [[MHz, dBµV/m]]}. Every emission estimate also reports its margin against every set.-- **Cable emissions** (0.9.0, `review.cableEmissions`, `cable-emissions`): common mode, the usual chamber failure.-  - The switching loop's harmonic current across the ground copper's inductance between the loop and each connector's ground pin (μ0·h × the solved squares) drives that connector's cable (spec `cables` {J1: metres}, 1 m) as a monopole.-  - It is an upper bound with no ferrite.-- **Trace impedance** (0.9.0, `review.impedance`): a 2-D Laplace field solve of every signal net's cross-section on its own layer (microstrip or stripline, solder mask unless `rf.noSolderMask`).-  - It gives Z0, εeff, ps/mm, pF/m and nH/m, plus Zodd, Zeven and Zdiff for pairs found by name (_P/_N, +/-, DP/DM).-  - Targets come from spec `impedance` {net: Ω, "P/N": Ω differential} or by protocol (USB 90, PCIe 85, Ethernet/LVDS/HDMI/MIPI 100).-  - It is validated within about 2 % of Hammerstad-Jensen and Cohn.-- **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.-- **Fan levels** (0.8.0): `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level fully. h on every exposed face (board, bodies, a packageCooling sink) follows (h_still³ + (h_forced·sⁿ)³)^⅓, where s is the fraction of full speed, s = 1 gives the moving-air h, and n is `fanExponent` (0.5). `fields.json` `airStates` gives each level's h, chip rises and bodies. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together, on one colour scale, so the levels compare by eye. `ui view --air fan50` drives it.-- **Package cooling** (PR 11): `packageCooling: [{reference, junctionCaseKPerW, caseSinkKPerW, sinkAmbientStillKPerW, sinkAmbientMovingKPerW, boardLinks, provenance}]` puts a hot part's watts at its junction and lets them split through the supplied case, sink and board links; junction, case and sink rises per air state and an energy balance in `packageCooling`. See docs/package-cooling.md.-- **`adom-fields thermal3d --spec --out`** (PR 12): axis-aligned 3D solids with explicit materials, contacts and convection for an off-board package and finned sink, with a GLB of the surface temperatures. Not CFD. See docs/thermal3d.md.--## Honest limits (0.1)--DC only (no skin effect, no switching loss on the copper); per-layer thickness when copperUmByLayer is supplied; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), optional specified rectangular conducting blocks; no fins, enclosure or radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number.--## Evidence identity--Each solve records SHA-256 identities of the board and spec in `fields.json.inputIdentity`, plus the solver version. If an input changes while solving, analysis refuses to publish the summary. AI Flow uses these identities to reject stale field screens, and requires every positive load and hot part to be represented. Existing summaries without these identities must be re-solved, not manually stamped. The identities establish which inputs were used; they do not establish the accuracy of assumed terminals, losses, materials or convection.--## Conducting blocks and signals (0.4.0)--Read `docs/heatsinks-and-signals.md` in the package before specifying these models. Use measured conductivity/contact resistance for `heatsinks`; contact is to the board face, not an automatically modeled chip lid. Never add a fictitious heatsink to make a gate pass. Inspect both air conditions and mesh sensitivity.--For signal screening, provide `signals.capacitance` or `signals.differentialPairs`, explicit consistent `stackupMm`, and justified epsilonR. Inspect Signals with `adom-fields ui --port 8874 view --quantity signals`. Unsupported geometry must stay unsupported; do not replace null estimates with guessed numbers. This is outer-layer microstrip and adjacent-layer overlap screening, not high-frequency signoff. The summary records assumptions and primary references.+```+adom-fields serve --fields fields --port 8874 &      # the 3D app; open it in a Hydrogen webview or pup+adom-fields ui view --quantity current|heat|temperature|emc|pdn|... [--net X] [--explode 0.6]+adom-fields tour                                      # the walkthrough, for a video+``` -## Fusion boards (0.6.0+)+The views (flow, river, energy, cycle, charge, magnetic, ground, noise, PDN) are for people and videos; they add no findings. Everything about them: [views.md](views.md). -A Fusion Electronics board works as `--board <board>.brd` (EAGLE source: `fusion_export_eagle_source`-on the Fusion bridge). Run RATSNEST in Fusion first so the pours are filled: the reader then uses-Fusion's own fill. Put the parts' STEP files, named by MPN, in a folder and pass `--models-dir` so the-chips sit over the fields.+## Honest limits +DC current and steady heat with flat convection (no CFD, no radiation); emissions, impedance and PDN are screening models with stated bands, not measurements. Use the numbers to rank risks and compare layouts; qualify by test.
skills/adom-fields/emc.mdadded+59
@@ -0,0 +1,59 @@+# Adom Fields: EMC, compliance and ESD (`--with emc`, `--with esd`)++Turn on `emc` when the board has a switching converter, clocks or cables and the product will face FCC/CISPR testing; `esd` when a connector faces the outside world (name those connectors in spec `external`).++## The EMC screen (0.7.5)++`ui view --quantity emc` on a board with a modelled switching converter (see the energy cycle): three checks drawn on the board.+- **The hot loop**: input capacitor → the switch's input pin → its ground pin → back to the capacitor, for every input capacitor; the smallest (fastest-edged) capacitor's loop is the red patch, pulsing with the switching, drawn over the parts; a green dashed outline is the smallest loop the switch allows (a capacitor straight across its pins). Areas in mm², in the board's plane between the pads.+- **The reference plane**: copper of the loaded and switching nets on the top layer, green where ground copper lies on the next layer, red where it does not (the return current has to detour); the share and mm² per net in the panel.+- **Radiated emissions**: the hot loop as a small loop antenna (Ott: E = 1.316·10⁻¹⁴ f² A I / r), its current's harmonics from the trapezoid (duty from the cycle, edge time from the spec's `switching.<net>.riseNs`, 8 ns by default), 30 MHz to 1 GHz at 3 m against the FCC Part 15 class B limit, with the worst margin and what the smallest loop would gain.+A screen, not a chamber: differential mode only; cables (common mode), which often dominate, are not modelled.++## 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.++## Also in this area++- **Conducted emissions** (0.9.2, `review.conductedEmissions`, `conducted-emissions`).+  - The converter's trapezoid input current split between its input capacitors (at bias) and any input filter into a LISN (50 Ω ∥ 50 µH), against CISPR 32 / FCC 15.107 class B (or A with `emcLimit` *-a), 150 kHz to 30 MHz.+  - The fix sizes the input-filter inductor for the attenuation needed.+  - Differential mode only.+- **Emission limits** (0.9.0): spec `emcLimit` picks the set the issues use: `fcc-b` (default), `fcc-a`, `cispr32-b`, `cispr32-a`, or a custom name. Spec `emcLimits` adds lines (CISPR 25, MIL-STD-461 RE102, a customer's) as {name, distanceM, points [[MHz, dBµV/m]]}. Every emission estimate also reports its margin against every set.+- **Cable emissions** (0.9.0, `review.cableEmissions`, `cable-emissions`): common mode, the usual chamber failure.+  - The switching loop's harmonic current across the ground copper's inductance between the loop and each connector's ground pin (μ0·h × the solved squares) drives that connector's cable (spec `cables` {J1: metres}, 1 m) as a monopole.+  - It is an upper bound with no ferrite.
skills/adom-fields/noise-rf.mdadded+25
@@ -0,0 +1,25 @@+# Adom Fields: noise and receivers (`--with noise`, `--with rf`)++Turn on `noise` when a switch node or a clock runs near quiet or sensitive nets, or power pours face the ground plane (cavities). Turn on `rf` on a receiver: an antenna, an oscillator, a mixer, an IF.++## Noise: plane resonance and crosstalk (0.7.8)++`analyze` also screens noise couplings, each finding with an action:+- **Cavities**: every power or switching pour facing the ground plane is a parallel-plate cavity; its lowest modes are solved on the real overlap (the Neumann Laplacian's eigenpairs by Lanczos with full reorthogonalisation, then Jacobi; f = c·k/(2π√εr); checked against the rectangle's c/(2a√εr) in the tests). A mode within 5 % of a strong tone (the switching harmonics up to ten times the edge's knee, `clocks` from the spec: a crystal or an LC oscillator no chip drives counts to its 5th harmonic, a logic clock further) is `cavity-resonance`, with an `add-part` capacitor at the mode's loudest point.+- **Crosstalk**: each quiet net (not a rail, not loaded, not tied to the aggressor through a capacitor, like a bootstrap) next to a switching node or a clock: closest approach, run within 1 mm, side and overlap capacitance, and the coupled step ΔV·Cm/(Cm+Cg). Over `crosstalkMv` (50) is `crosstalk`, with a `reroute` action (spacing, or a ground guard).+- `review.noise` holds the cavities' modes, the tones and the crosstalk table; `noise.json` holds the mode shapes for the app.++`ui view --quantity noise`: the largest cavity rings in 3D (its real modes, red and blue phases, white nodal lines), the switching copper pulses, and quiet nets glow by the millivolts they pick up, with arcs at the closest approach.++`ui view --quantity signals` (the Signals button pulls the layers apart): each `signals.capacitance` check lights its real overlap on both layers as two glowing plates with the electric field crossing between them as sparks, one check at a time (seven seconds each, or the one picked in the panel), tagged with its picofarads, overlap and dielectric; differential pairs glow red and blue, tagged with skew and impedance. The via barrels hide so the plates read.++## The copper stack, receivers and RF (0.7.16)++- Boards of two, four or six copper layers; plated pads only on real copper. `stackupMm` is one dielectric per real gap (else the board's stackup, else its thickness); `epsilonR` when the stackup has none. fields.json: `board.copperLayers`, `board.dielectricMm`, `board.stackupFrom`.+- `hot.<part>.tabNet` is optional: a leaded part's watts go through all its leads by pad area. With no `hot`, the temperature is only copper I²R and the output says it is not a thermal pass.+- `clocks`: `{net: Hz}` or `{net: {hz | minHz, maxHz, harmonics, amplitudeV}}` (a tuned LO). `bands`: `{name: {minHz, maxHz, ifHz, lowSideLo}}` for the frequency plan (`tone-in-band`, the band and its image). `sensitiveNets`: nets an oscillator must not reach (`circuit-path` issues with the parts on the way). `victimBudgetsMv`: a budget per victim net (µV for RF/IF inputs).+- Crosstalk is in the victim's impedance (its capacitors and resistors to ground or rails), sine for oscillators, step for switch nodes. Return path (`no-reference`) for every non-rail net on every layer: over ground, a supply plane or nothing. `antenna-ground-loop`, `stage-feed-unbypassed`, `solve-unconverged` are new kinds; `review.stageFeeds`, `review.noise.frequencyPlan`, `review.noise.circuitPaths`.++## Also in this area++- **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.
skills/adom-fields/pdn.mdadded+23
@@ -0,0 +1,23 @@+# Adom Fields: power delivery (`--with pdn`)++Turn on `pdn` when a rail feeds a digital or RF chip with fast current edges, or the board has a converter input or a connector-fed rail (hot-plug overshoot, input-filter stability).++## Power delivery: rail impedance (0.7.9)++`analyze` also checks each rail's impedance seen from its load pin (a pin of the chip with the most pins on the rail, else an outgoing connector; `pdn.<net>.load` names another), 1 kHz to 1 GHz. What feeds the rail comes from the spec's `sources`, the modelled converter, an on-board regulator (named like one: TLV7…, LDO, AMS1117…, bridging the rail and a higher one), a chip whose pin on the rail is named as an output (VOUT, OUT: KiCad's pin function), or the connector the solve found the current entering by; a rail with none of these is listed `skipped` with the reason, never guessed:+- **The model**: branches in parallel at the load pin. The source: a modelled converter's output is flat (5 mΩ) below its loop crossover (fsw/10, `crossoverHz`), then an inductance that resonates with the output capacitors there, damped to Q 1 (about a 50° phase margin), fading out a decade above; a linear regulator the same with a 100 kHz crossover; a connector-fed rail is about a metre of supply leads (30 mΩ, 0.7 µH; `sourceOhm`, `sourceNh`). Each capacitor: its board value (nominal, no DC-bias derating), ESR by type, body inductance from its pad span, and the mounting inductance of a microstrip over the plane from it to the load pin. The rail's copper over ground as a plate capacitor.+- **The target**: V·tolPct/100 / stepA (5 %; the step is half the rail's spec load, or the converter's output current on its input rail), over a band: up to the switching edge's knee on a converter input, 100 MHz at a chip pin, 1 MHz at a connector (`fminHz`, `fmaxHz`, or `targetOhm` outright).+- **The fix**: a peak over the target is `pdn-impedance`, with an `add-part` capacitor (value, package, pin) chosen by re-solving with each candidate, up to three, each with the ratio it reaches; still over after three is a `pour` action (a plane pair). A converter's input rail also gets Middlebrook's check: under 10 dB below the converter's negative input resistance V²/P is `input-filter-stability`, with a damping capacitor and its ESR.+- **Capacitors as they sit on the rail** (0.7.13, `src/caps.rs`, used by the PDN check and the converter's ripple): the technology comes from the Value, the MPN and the footprint (EEE-F…/CP_Elec_… electrolytic, ECAS…/EEF-…/APX… polymer, T520/T530 polymer tantalum, CP_Tantalum_… tantalum, else ceramic; C0G/NP0 by Value or vendor code), with ESR by technology. Class-II ceramics are taken at the rail's DC bias: from a measured curve when one matches the MPN (a folder of Murata SimSurfing JSON, spec `capCurves` or ADOM_FIELDS_CAP_CURVES: C at the bias, ESR at self-resonance, ESL from |Z| a decade above), else an estimate, retained = 1/(1+(V/V0)^1.6), V0 = 9.1 V·(case mm³ / µF)^0.38 (a fit to Murata's 0603 to 1206 X5R/X7R curves, about ±0.05). Each branch reports `uF`, `uFAtBias`, `type` and `biasModel`; new ceramics in the fix search are derated too, and polymer bulk parts are candidates. Ripple adds each side's ESR term.+- **Hot-plug** (0.7.14): on a connector-fed rail, the supply leads (R, L; `pdn.<net>.sourceOhm`/`sourceNh`, default 30 mΩ and 0.7 µH) are switched onto every capacitor branch at its rail bias and solved in time (backward Euler, ESL ignored), with the same step run in ngspice as a cross-check (`hotPlug.spicePeakV`). The peak is checked against the lowest voltage rating on the rail (a "35V" in a Value, or Murata's code in an ECAS/GRM MPN). Over that rating is high, over 50 % overshoot is medium: `hot-plug-overshoot`, whose action is a damping bulk capacitor (value, ESR) or a TVS. It catches a low-ESR bulk part with no damping, which an impedance peak under its target does not show.+- **Filtered rails** (0.7.15): a rail fed through a ferrite, fuse, OR-ing diode, resistor or transistor switch from another rail is followed up to its connector or regulator (by pin function or part number) and modelled as one source branch; the upstream rails' capacitors are left out (conservative at high frequency).+- `review.pdn` holds each rail's branches, |Z(f)| (`fHz` is shared), the target and band, the fixes and the curve after them; `pdn-<net>.cir` is the same circuit as an ngspice deck, run as a cross-check when ngspice is installed (`spice.maxErrPct`).++`ui view --quantity pdn` (the Power button): the frequency sweeps; each branch stands a column at its place, its height its share of the load's current (over 100 % is two branches ringing); arcs carry the shares to the load pin; at the load pin |Z| stands in a glass sleeve, the target (green under the rim, red over, steel blue outside the band); the panel draws |Z(f)| per rail with its target, band and fix curve, and a frequency slider.++## Also in this area++- **Decoupling optimiser** (0.9.2, PDN rail `decapPlan`).+  - A beam search over sets of up to six new capacitors at the load pin (MLCC values in 0402/0603/0805 at their DC bias, polymer) for the cheapest set by estimated cost, then area, that meets the rail's target (`cheapest`, `optionsMeetingTarget`).+  - When none can, the lowest-|Z| set (`bestPossible`).+  - On a rail that already passes, the existing capacitors it can lose (`removable`; never the converter's own input or output capacitors).
skills/adom-fields/review.mdadded+44
@@ -0,0 +1,44 @@+# Adom Fields: the findings, the review and the fix plan++Every finding kind, which module it comes from (`core` always runs; the rest need `--with <module>`), and the review blocks behind them.++## Finding kinds by module++`core` always runs; the others need `--with <module>` (or the spec keys listed in SKILL.md). Kinds not listed are core: `current-neck`, `current-barrel`, `ampacity`, `ir-drop`, `pour-fragmented`, `tab-rise`, `thermal-vias`, `junction-temperature`, `capacitor-temperature`, `electro-thermal`, `ground-offset`, `ground-island`, `decoupling-far`, `decoupling-missing`, `hot-loop`, `inductor-ripple`, `output-ripple`, `solve-unconverged`, `load-unsolved`.++| Module | Kinds |+|---|---|+| `emc` | `emissions`, `cable-emissions`, `conducted-emissions`, `stitching-gap`, `ground-loop-risk`, `no-reference`, `clock-outer-layer`, `clock-near-connector`, `edge-signal`, `input-filter-cutoff` |+| `esd` | `esd-unprotected`, `io-unfiltered` |+| `pdn` | `pdn-impedance`, `input-filter-stability`, `hot-plug-overshoot`, `regulator` |+| `si` | `impedance`, `diff-impedance`, `reflection`, `length-skew`, `via-stub`, `stackup-adjacent-signals`, `stackup-far-reference`, `plane` |+| `noise` | `cavity-resonance`, `crosstalk`, `tone-in-band` |+| `rf` | `lo-leak`, `stage-isolation`, `lo-emission`, `stage-feed-unbypassed`, `circuit-path`, `antenna-ground-loop` |++Grouping: three or more low findings of one kind become one finding (`action.type: group`, every item with its own action in `action.items`); per-via `current-barrel` warnings become one per net; `ampacity` on a net that already has a `current-neck` folds into it. `verify` carries out a group's items one by one. With spec `external: ["J1", ...]`, `esd-unprotected` and `io-unfiltered` cover only those connectors.++## For the AI that called the solve: the review and the fix plan (0.7.6)++`analyze` reviews the design as well as solving it, and says what to change, without anyone looking at the app:+- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop` (over `dropPct` of the rail, or over the net's own `loads.<net>.maxDropMv`), `pour-fragmented` (a poured net's fill on one layer broke into pieces: the nearest gap, the pad in it, and a `move` (`reference`, `orRotateDeg`, `byMm`) or `reroute` action; `fields.json` `fragments` lists every pour's pieces per layer), `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `cavity-resonance`, `crosstalk`, `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside).+- **`fields.json` `fixPlan`**: the actions, worst first and concrete edits before reviews, numbered for the caller to work down. `analyze` prints the first five as a `Hint: Fix plan` line and the review as a `Hint: Review` line.+- **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail), `decoupling` (pin, cap, distance, loop), `esd` (each connector pin and what protects it).+- **IR-drop probe**: `adom-fields probe --fields <dir> --from J1.1 --to U1.3` (or `--to <net>` for its farthest point, `--json` for the record) reads the drop between two pads from the solve, no re-solve; `adom-fields ui probe --from J1.1 --to U1.3` shows it in the running app's river view with a toast.+- Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `loads.<net>.maxDropMv` (none; `review.drops` lists every loaded net's drop and its two ends), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).++After applying a step, run `analyze` again: the issue goes away or says how far it still is. In the app, clicking an issue opens the view that shows it (EMC, Ground, River, Cycle).++## Hot parts read at their tab (0.9.8)++A hot part with a tab net is read at its tab: the pad named in `hot.<ref>.tabPad` (the exposed pad's number, e.g. `"29"`), else the largest tab-net pad, so an exposed pad wins over corner pins that share its net. `vias` counts the tab net's vias near every tab-net pad, including a footprint's own thermal-via holes. The watts are still spread over all tab-net pads by area.++## Also in this area++- **Board checks** (0.9.0, `review.checks`), on the solved fields where one exists:+  - `ampacity`: each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there.+  - `junctions` / `junction-temperature`: ambient (spec `ambientC`, 25) + the solved rise (+ `hot.<ref>.thetaJcKPerW`·W) against `hot.<ref>.tjMaxC` (125).+  - `capacitors` / `capacitor-temperature`: each capacitor's solved temperature against its type's rating (read from value or part number; electrolytic life by the 10 °C rule; ceramics with only an assumed rating grouped into one finding).+  - `emcRules`: `clock-outer-layer`, `clock-near-connector`, `edge-signal`, `io-unfiltered`, `input-filter-cutoff` (a converter's input LC against fsw/10).+  - `stackup`: which layers are planes, plus `stackup-far-reference`, `stackup-adjacent-signals` and plane-pair pF/cm².+  - `viaStubs` / `via-stub`: the quarter-wave resonance of each signal via's unused barrel against its clock.+  - `lengthGroups` / `length-skew`: spec `lengthGroups` {name: {nets, maxSkewPs | maxSkewMm | protocol: usb2|usb3|pcie|ethernet|ddr|hdmi|mipi}}.
skills/adom-fields/si.mdadded+31
@@ -0,0 +1,31 @@+# Adom Fields: signal integrity (`--with si`)++Turn on `si` when the board has differential pairs (USB, Ethernet, LVDS, MIPI), clocks or fast buses (SPI, SDIO, QSPI), traces over about 50 mm, or impedance targets.++## Reflections and TDR (0.9.6)++`review.reflections` checks the nets where edges matter: spec `impedance` targets, `clocks`, `lengthGroups` members, and any net in spec `tdr: {net: {driverOhm, riseNs, loadPf, from, to}}`.+- **The model:**+  - **The path:** the net's copper is walked from the driver pad (`from`, else the first IC pin) to the receiver (`to`, else the farthest pad), segment by segment and through vias.+  - **The line:** each segment is a lossless line with the cross-section solve's Z0 and delay, and each via adds its pad capacitance (Johnson & Graham).+  - **What is not modelled:** branches off the path are counted, not simulated.+- **Two ngspice runs:**+  - **TDR:** a 50 Ω, 35 ps step gives `tdr.points` (ps, Ω), the impedance profile along the path.+  - **In use:** the real driver (25 Ω and 1 ns by default; a clock's edge is 7 % of its period) into a 3 pF receiver gives `inUse`: overshoot, undershoot, settling time, and the series termination (line Z0 less the driver's resistance).+- **Short nets:** a path shorter than a sixth of its edge is `electricallyShort` and only says so.+- **Issues:** overshoot over 15 % raises a `reflection` issue, with an `add-part` action for the series resistor at the driver pin.+- **On the ESC's SWD clock** (73 mm, 449 ps of 58 Ω line, a 0.5 ns edge from 20 Ω): 40 % overshoot, fixed by a 38 Ω series resistor. The DShot input (2 ns edge) is electrically short.+- `impedance` segments' `psPerMm` is now in ps/mm (it read a million times too high before 0.9.6).++## Conducting blocks and signals (0.4.0)++Read `docs/heatsinks-and-signals.md` in the package before specifying these models. Use measured conductivity/contact resistance for `heatsinks`; contact is to the board face, not an automatically modeled chip lid. Never add a fictitious heatsink to make a gate pass. Inspect both air conditions and mesh sensitivity.++For signal screening, provide `signals.capacitance` or `signals.differentialPairs`, explicit consistent `stackupMm`, and justified epsilonR. Inspect Signals with `adom-fields ui --port 8874 view --quantity signals`. Unsupported geometry must stay unsupported; do not replace null estimates with guessed numbers. This is outer-layer microstrip and adjacent-layer overlap screening, not high-frequency signoff. The summary records assumptions and primary references.++## Also in this area++- **Trace impedance** (0.9.0, `review.impedance`): a 2-D Laplace field solve of every signal net's cross-section on its own layer (microstrip or stripline, solder mask unless `rf.noSolderMask`).+  - It gives Z0, εeff, ps/mm, pF/m and nH/m, plus Zodd, Zeven and Zdiff for pairs found by name (_P/_N, +/-, DP/DM).+  - Targets come from spec `impedance` {net: Ω, "P/N": Ω differential} or by protocol (USB 90, PCIe 85, Ethernet/LVDS/HDMI/MIPI 100).+  - It is validated within about 2 % of Hammerstad-Jensen and Cohn.
skills/adom-fields/spec.mdadded+50
@@ -0,0 +1,50 @@+# Adom Fields: the spec and the solver, in full++Every spec key and what the solver does with it. The core keys are in SKILL.md; read this for terminals, solids, package cooling, fans, transients, stackups and Fusion boards.++## What a solve writes++```+adom-fields analyze --board <board.kicad_pcb | board.brd from Fusion> --spec <spec.json> --out fields+```++The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC, maxDropMv, draws: {part: amps}), `sources` (net: [the parts that feed it]), `hot` (ref: watts, tabNet, maxRiseC: that part's own limit, else hotMaxRiseC), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.++What comes out, in `fields/fields.json` and `fields/tex/`:+- `nets`, ordered by amps then by peak density: peak A/mm² and where, the layers, the copper mm², the Joule loss, the vias.+- `chips`, hottest first: the rise at the tab in still and moving air, and where its watts go within 3 mm (along its layer, down its vias, through the bare dielectric, to the air), with how many vias the spec asked for.+- `issues`: `current-neck` (a loaded net over 60 A/mm², about what IPC-2221 allows at 35 µm for 10 °C), `tab-rise` (over the budget), `thermal-vias` (fewer than the spec asks), each with `at`, `layer`, `net`, `reference` and a `fix`.+- textures per layer: `J-<layer>.png` (all nets), `J-<net>-<layer>.png`, `q-<layer>.png` (heat flow, W/mm), `T-<layer>.png` and `Tm-<layer>.png` (rise, still and moving air), `copper-<layer>.png`, plus `.raw.png` 16-bit values for the readout.++Terminals per net: the wire pads and connectors (MC*, J*, P*) on one side; on the other the spec's hot parts on the net (a FET's tab and its source pins both count), else the Q and U parts, else the parts with real pads, each part's share by its pad area. A net with neither is skipped and says so.++## Honest limits (0.1)++DC only (no skin effect, no switching loss on the copper); per-layer thickness when copperUmByLayer is supplied; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), optional specified rectangular conducting blocks; no fins, enclosure or radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number.++## Evidence identity++Each solve records SHA-256 identities of the board and spec in `fields.json.inputIdentity`, plus the solver version. If an input changes while solving, analysis refuses to publish the summary. AI Flow uses these identities to reject stale field screens, and requires every positive load and hot part to be represented. Existing summaries without these identities must be re-solved, not manually stamped. The identities establish which inputs were used; they do not establish the accuracy of assumed terminals, losses, materials or convection.++## Fusion boards (0.6.0+)++A Fusion Electronics board works as `--board <board>.brd` (EAGLE source: `fusion_export_eagle_source`+on the Fusion bridge). Run RATSNEST in Fusion first so the pours are filled: the reader then uses+Fusion's own fill. Put the parts' STEP files, named by MPN, in a folder and pass `--models-dir` so the+chips sit over the fields.++## The solver: holes, terminals, heat models, fans, transients++- **Plated holes** (PR 5, 9): every via and plated through-hole pad barrel joins its copper layers in the electrical and thermal models (25 µm plating, circular or slotted, `platedPads` counted apart from vias). Drill holes are voids on every layer; each barrel's conductance is spread around its plated wall on cells with copper on both faces, and a wall the mesh does not resolve falls back to the nearest such cells, so a plated hole never disconnects a net. `nets[].platedConnections` gives each hole's current and mean barrel density. Blind, buried and micro vias are refused; Fusion boards read their declared `layerSetup`. See docs/plated-wall-coupling.md.+- **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net.+- **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`).+- **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Electro-thermal** (0.9.1, `review.electroThermal`). Copper's resistance rises 0.393 %/°C, so every run reports the first-order extra Joule heat at the solved temperatures, and an `electro-thermal` issue when it is over 10 %. With spec `electroThermal: true`, a second full pass solves the current and heat with each copper cell at its solved temperature. On the ESC: +33 % Joule, peak rise 109 → 119 °C.+- **Heating transient** (0.9.1, `transient`). Spec `transient` {durationS, steps (24), powerScale (1; a stall or surge at 3× is 3), fromSteady}: implicit Euler on the still-air heat system with copper, FR4 and the solid bodies' heat capacities.+  - It gives the board peak and each hot part's rise over time, and each part's time constant (`t63S`, to 63 % of its steady rise).+  - On the buck: U1 about 2 min, L1 about 3 min.+- **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.+- **Fan levels** (0.8.0): `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level fully. h on every exposed face (board, bodies, a packageCooling sink) follows (h_still³ + (h_forced·sⁿ)³)^⅓, where s is the fraction of full speed, s = 1 gives the moving-air h, and n is `fanExponent` (0.5). `fields.json` `airStates` gives each level's h, chip rises and bodies. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together, on one colour scale, so the levels compare by eye. `ui view --air fan50` drives it.+- **Package cooling** (PR 11): `packageCooling: [{reference, junctionCaseKPerW, caseSinkKPerW, sinkAmbientStillKPerW, sinkAmbientMovingKPerW, boardLinks, provenance}]` puts a hot part's watts at its junction and lets them split through the supplied case, sink and board links; junction, case and sink rises per air state and an energy balance in `packageCooling`. See docs/package-cooling.md.+- **`adom-fields thermal3d --spec --out`** (PR 12): axis-aligned 3D solids with explicit materials, contacts and convection for an off-board package and finned sink, with a GLB of the surface temperatures. Not CFD. See docs/thermal3d.md.
skills/adom-fields/verify-report.mdadded+58
@@ -0,0 +1,58 @@+# Adom Fields: fix and verify, the readiness report, the inputs assumed++## Fix and verify (0.9.3)++`adom-fields verify --board b.kicad_pcb --spec s.json [--step 1,3] [--refill] [--fields <solve>] --out verify` carries out fix-plan steps on a copy of the board, solves the copy, and says what the fix did:+- **Steps it applies (copper edits only):**+  - `add-vias`: a through via, at the board's most common via size, at each point of the action (else `count` of them near `at`). A via is placed only where it lands on the net's own copper on two or more layers and keeps 0.2 mm from other nets. A blocked point moves up to 1 mm (2.5 mm around `at`) to the nearest spot that fits, and `movedMm` says how far.+  - `widen`: the net's segments within 2.5 mm of `at` (on `layer` when given) go to `toMm` (else twice as wide), each only as far as 0.2 mm clearance allows; `short: true` marks one that could not reach the goal.+  - **Each edit sees the earlier ones**, so two neighbouring nets widened in one run keep their clearance to each other.+- **Steps it refuses**, with the reason: `add-part`, `move`, `reroute`, `pour`, `add-copper`, `review`, an impedance width change, and a neck inside a pour (that needs the pour reshaped). Make those in the layout, then `verify` (or `analyze`) the edited file.+- **`--refill`:** KiCad refills the zones and runs DRC (errors only) on both the original and the fixed copy, through `kicad-cli` (service-kicad, else KiCad on the desktop through Adom Bridge). With it:+  - other nets' pours retreat from new copper, as a real refill would;+  - both solves start from refilled boards, so they differ only by the fix;+  - `drc.new` lists the errors the fix introduced.+  - Without `--refill`, other nets' fills count as fixed copper and the copy keeps the fills as saved.+- **Output:**+  - `verify/<board>.kicad_pcb`: the fixed board.+  - `before/` and `after/`: the two solves.+  - `verify.json`: per step, what was applied or why not, and `issueCleared`. Also the issues cleared and new, the metrics that moved (each net's peak density and Joule loss, peak rise still and moving air, each part's rise, ground offset, edge stitching, hot-loop, cable and conducted emissions margins) with `better` true or false, and the DRC result.+- **Reading it:**+  - A peak current density is one cell: widening can move it to a new corner where wide meets narrow. The Joule loss, temperatures and drops are the robust measures.+  - A step can be applied and its issue still be there (a neck at 300 A/mm² against a 60 A/mm² threshold). Read the numbers: they say how far it moved.+  - `--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.++## 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 inputs a solve assumed (0.9.6)++`review.inputs` lists every default or estimate the results lean on, most important first (`matters`: high, medium, low). Each item gives:+- `what` and `used`;+- `from`: default, estimate or missing;+- `part`: its ref and MPN;+- `datasheetField`: the exact datasheet entry that replaces it;+- `specKey`: where the number goes;+- `affects`: what moves, with a sensitivity where one can be computed. For example: "half the edge time costs 6 dB on cable:J2".++The items covered:+- **The converter:** the switch edge and frequency, each capacitor's capacitance at its DC bias and an electrolytic's ESR.+- **Heat:** junction or operating temperature limits, θJC (asked for semiconductors only), and dissipating parts with no watts.+- **Components:** capacitor temperature ratings, and a TDR driver's output resistance and edge.+- **The board:** stackup, laminate Dk, copper weight.+- **The product's world:** ambient, cable lengths, PDN load steps, and a receiver's transistor capacitances.++`specPatch` is the spec fragment to fill in, each value a placeholder naming its source (`<TPS54202DDCR datasheet: switch-node rise time, ns>`). An AI reads the datasheets (the MPN tells it which), fills the patch, and re-runs; an item leaves the list once the spec gives it. `analyze` prints a `Hint: Inputs assumed` line with the high ones.
skills/adom-fields/views.mdadded+104
@@ -0,0 +1,104 @@+# Adom Fields: the app and its views++Read this when you show the board to a person or make a video. None of it changes a finding: the views draw what `analyze` solved.++## Serve and show++```+adom-fields serve --fields fields --port 8874 &+```++Open `https://<slug>.adom.cloud/proxy/8874/` in pup, the browser, or a Hydrogen webview (never localhost from outside the container). The app: a 44 px header, the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, embedded; ViewCube, studio lighting, zoom to the cursor), the field painted on the picked layer with the other layers faded, the vias as dots, the hot parts labelled with their rise, the legend, the readout under the cursor, the nets and hot parts on the left, the issues and the method on the right. Phone: the two side panels open from the header buttons.++## Drive it (the AI's wheel)++```+adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise|pdn [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off]+adom-fields ui select --net +VBAT        # one net lit alone (current)+adom-fields ui select --chip Q3          # fly to a hot part, heat view+adom-fields ui issue --index 0           # fly to an issue+adom-fields ui toast "20 A through a 1.5 mm neck"+adom-fields ui state | console+adom-fields tour --pause-ms 20000        # the scripted walkthrough: nets by relevance, each field, the hot parts, the issues+```++The server: `GET /state`, `GET /console` (the UI's log), `GET /events` (SSE), `GET /fields.json`, `POST /ui/view|select|issue|toast`. State lives on the server; the UI, the CLI and the AI see the same view.++## The parts over the fields (0.5.0)++`analyze` now also places every footprint's own 3D model on the board, so the chip stands in its hot zone and the cause of a hot spot is plain. The models come from the board file: each footprint's `(model ...)` entries, resolved as KiCad does (`${KIPRJMOD}`, `${KICAD*_3DMODEL_DIR}`, then `--models-dir <folder>` by file name, then a stock KiCad library model fetched by name), converted once to GLB on the Adom STEP service (`step2glb`) and cached by content in `~/.cache/adom-fields/models/`. They land in `<out>/parts/` and the `parts` block of fields.json. A footprint with no model gets a 1 mm box from its courtyard; test points and board-only footprints stay bare copper. A failed conversion never fails the solve: it is logged and the part becomes a box.++On a Fusion board (`.brd`) the parts are found by name, since Fusion binds its 3D packages by cloud urn: each element's MPN, then its deviceset, then its package, as `<name>.step` in the `--models-dir` folders, then in `3d/` or `3dmodels/` beside the board. AI Flow's `fusion library` writes the checked Z-up models to `<run>/3d/<MPN>.step`, so a solve of `<run>/board-live.brd` finds them with no flag. The conversion needs `step2glb` on the PATH (a declared dependency since 0.6.1; `adom-wiki pkg install adom/adom-step2glb`); without it every model is logged as a failure and the parts stay bare.++```+adom-fields analyze --board run/board-2.kicad_pcb --spec spec.json --out fields --models-dir kicad/3d   # a snapshot outside the project folder+adom-fields parts --board board.kicad_pcb --fields fields      # add or refresh the parts of an existing solve+adom-fields ui view --parts ghost|solid|off|auto              # auto: see-through on every field but signals+adom-fields analyze ... --no-parts                            # skip them (no STEP service needed)+```++`ghost` draws the bodies see-through so the field glows through them; `solid` is the plain assembly; the header's cube button cycles them. The hot-part labels ride on top of their part.++## Calmer motion (0.5.0)++Every camera move and the idle sway run at half the 0.4 speed (the sway period is about 40 s), because the flow plays the clip ten times faster. `tour --pause-ms` defaults to 20000 so a beat settles before the next. For frame-exact video, the page exposes `window.fieldsCapture.begin(1/30)` and `.step()` (one frame of page time per call; the live loop stops and the orbit pivot marker hides); `ui view --camera zoom --at x,y` flies to any board point.++## In the AI Flow video++Record the pup window (Adom Bridge `desktop_record_window_start` on it) while `adom-fields tour` runs: the clip is the fields step, the toasts are the captions, the AI's own analysis on the board in 3D.++## Flow, river and the exploded stack (0.7.0)++`analyze` also writes `flow.bin` (the signed current, the voltage and the owning net of every cell) and `fields.json` `flow` (each net's rail, sense, drop, terminals as source or sink, and the current down each barrel). Two views use it:++- `ui view --quantity flow`: the current as charge, streaks whose speed and glow follow |J|, dropping through the barrels by their solved share. `--explode 1` pulls the layers apart (0 puts them back); the camera backs off to keep the stack in frame.+- `ui view --quantity river`: each net lifted to its voltage, water over it, loads as waterfalls, sources as fountains, a switching node as a slowed tide. Signs give each net's voltage and drop; the readout gives the volts under the cursor.+- `--speed 0.1..4` slows or quickens both. The badge states the scale and the exaggeration; say it when you narrate.++Tell the solve the voltages it cannot read from names, in the spec:++```json+"voltages": {"VIN": 12, "VOUT": 5},+"sources": {"VOUT": ["L1"], "SW": ["U1"]},+"switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000}}+```++Names give GND/PGND/AGND/VSS 0, 3V3 3.3, +12V 12, 5V_AUX 5; SW, LX and PH are switching nodes between the highest rail and 0 unless the spec says otherwise. Default sense: a supply flows from its connector to its parts, a ground from its parts back to its connector; `sources` flips a net whose feed is a part (a regulator or inductor output). A net with no rail stays at its drop and says "rail not given". The voltages are DC: the rail plus the solved IR drop. Copper the pinned terminal cannot reach (a floating island) sits still at the rail.++Developing the page: `ADOM_FIELDS_UI_DIR=<repo>/src/ui adom-fields serve ...` serves index.html, app.js and flow.js from disk, so a reload shows a change without a rebuild.++## Energy and light++**Energy** (`ui view --quantity energy --explode 0.6`) shows where the energy really travels: pink particles in the board material between the copper layers, along the Poynting vector S = E × H. Between two layers a gap h apart, E is (V_top − V_bottom)/h and the two current sheets give H, so S = ½ (V_top − V_bottom)/h · (K_top − K_bottom) in W/mm²; over a supply and its return that integrates to V × I. Pink rising into a part is that part taking its energy. The blue dots are the electrons in the copper at their real drift speed (J / n e, 0.073 mm/s per A/mm²), moving against the current. DC, inside the board only: part of the energy travels in the air around it.++**Light** (`ui view --quantity light`) colours each piece of copper by how fast a change travels in its field, as a % of the speed of light: 1/√εr_eff, all FR4 between planes (about 48 %), Hammerstad's εr_eff for a trace over a plane (wider is slower), mostly air with no plane under it. A switch-on front sweeps out from each fed net's source, and each load is tagged with its arrival time in picoseconds (the fastest path, the sum of length over speed along the way). εr comes from the spec's signals epsilonR, else 4.3. A switching node's DC average (`switching.SW.meanV`) sets its level in the energy view.++## Explained out loud (0.7.1)++Every view can explain itself by voice: the speaker button in the header, `adom-fields ui explain [--quantity q]` (`--stop` silences it), or automatically. The left panel's "Explain each view out loud" sets the rule per viewer: **First time** (the default: each kind of view speaks once, the first time this viewer opens it, so a newcomer hears what they are looking at), **Always**, or **On ask**. The page writes each explanation from a template with this board's own numbers (the peak density and its net, the hottest part, the rails, the electrons' speed, the arrival times), so no AI tokens are spent; the server voices it with adom-tts and keeps the mp3 under `<fields>/narration/`, named by the text's hash, so it is voiced once. A one-line subtitle follows the voice, sentence by sentence, sitting above the legend and readout so neither is covered. Without adom-tts, a toast says so.++## The energy cycle (0.7.2)++`ui view --quantity cycle`: one switching period of a buck converter, slowed about a million times (the meter states the factor). Pink energy packets run from the input capacitors through the switch into the inductor while the switch is on, and from the inductor to the output capacitors and the load all the time; blue particles ride the inductor's magnetic field lines, brightening and swelling as its current and stored energy grow, collapsing on the off-time; red arches over each capacitor brighten as its stored energy rises; orange heat leaves the switch and the inductor. A meter (bottom left, click its title to fold) gives the switch state, the inductor current waveform with a cursor, each store's voltage or current and energy, the heat and the energy delivered per cycle.++The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin, inductor ripple ΔI = (Vin − Vout)·D·T/L, the capacitor charge integrated over the period. It finds the converter from the solve: the switching node, the inductor on it (L*), the switch (the node's source), the input rail (the switch's highest rail), the output (the inductor's other net), the input and output capacitors (C* between each rail and ground). L and C come from the parts' values on the board (KiCad Value, Fusion element value; `fields.json` `partValues`), fsw from the spec's `switching.freqHz`, the load from the output net's amps, the heat from the hot parts' watts. A board it cannot model says why.++## The silkscreen (0.7.2)++`analyze` exports the board's F.SilkS and B.SilkS with kicad-cli as SVG (`silk-front.svg`, `silk-back.svg`, fields.json `silk`); `adom-fields silk --board <pcb> --fields <dir>` adds them to an existing solve. The page draws each once into a texture on one plane per side (two quads, no geometry), riding its copper layer and the exploded stack, each side shown only from its own side of the board, hidden in the river. The header's T button or `ui view --silk on|off` toggles it. `--no-silk` skips it; a Fusion board has none yet.++## Charge: the board as capacitors (0.7.3)++`ui view --quantity charge` (the Charge button pulls the layers apart): wherever copper at different voltages faces copper across the board material, it is a capacitor. Each face holds σ = ε0·εr·ΔV/h, red on the higher voltage, blue on the lower, brighter for more charge; particles cross the gap from the + face to the − face along the field; a switching node's copper charges and empties with its tide every cycle (the stray capacitance switching noise couples through). A panel compares the copper's own capacitance between net pairs (pF, pC) with the capacitor parts (µF, µC, the energy ½CV²); the readout gives the charge per mm² and the field under the cursor. Parallel plates only: fringing and the air above are not counted.++## Magnetic: the field of every current (0.7.4)++`ui view --quantity magnetic [--probe 2]`: the Biot–Savart field of the solved currents (every copper cell's current summed onto a half-millimetre grid of current elements, plus the current down each via). Glowing blue field lines loop around the current paths, brightest where the field is strongest, their dashes running the way the field points; a sheet at `--probe` mm above the top copper (the slider beside the view buttons, 0.5 to 15 mm) glows where the field is strong, with a white ring at every tenfold step, the way a near-field probe would see it. The readout gives µT, the share of Earth's field (about 50 µT) and H in A/m. DC: the switching field comes with the EMC loop views.++## 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.++## Also in this area++- **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.
src/fields.rs+5−2
@@ -49,6 +49,8 @@ pub struct Spec {     #[serde(default)] pub measurements: Vec<crate::compliance::Measurement>,     /// reflection / TDR settings per net: {driverOhm (25), riseNs (1, a clock 7 % of its period), loadPf (3), from: "U1.5", to: "J2.3"}     #[serde(default)] pub tdr: BTreeMap<String, crate::tdr::TdrCfg>,+    /// the connectors that face the outside world (a user cable, a hot-plugged lead): the esd module checks only these when given+    #[serde(default)] pub external: Vec<String>,     #[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>,@@ -499,7 +501,8 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         json!({"durationS": t_end, "powerScale": scale, "fromSteady": tr.fromSteady == Some(true), "model": "implicit Euler on the still-air heat system; copper 3.45 and FR4 1.85 J/cm³K, solid bodies by material, lumped package nodes 0.05 J/K", "steps": rows, "t63S": tau})     } };     let (sys_mov, _, packages_moving, solid_moving) = build(1.0);-    let (tm, it2, res2) = sys_mov.solve(&q_rhs, 1e-7, 8000);+    // moving air starts from the still-air answer: the same sources, only the faces' h differs (a warm start)+    let (tm, it2, res2) = sys_mov.solve_from(&q_rhs, &tv, 1e-7, 8000);     log(&format!("heat, moving air: {it2} iterations"));     let package_cooling: Vec<Value> = spec.packageCooling.iter().zip(&packages_still).zip(&packages_moving).map(|((p, a), bm)| {         let watts = spec.hot.get(&p.reference).map_or(0.0, |h| h.watts);@@ -545,7 +548,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu     let mut fan: Vec<FanLevel> = Vec::new();     for &lv in &levels {         let (sys_l, _, pk_l, sol_l, tl, rl) = if lv == 0.0 { (None, None, None, None, tv.clone(), res) } else if lv == 1.0 { (None, None, None, None, tm.clone(), res2) } else {-            let (sy, _, pk, so) = build(lv); let (tt, itl, rr) = sy.solve(&q_rhs, 1e-7, 8000); log(&format!("heat, fan {:.0} %: {itl} iterations", lv * 100.0)); (Some(sy), Some(()), Some(pk), so, tt, rr) };+            let (sy, _, pk, so) = build(lv); let guess: Vec<f64> = tv.iter().zip(&tm).map(|(a, z)| a + (z - a) * lv).collect(); let (tt, itl, rr) = sy.solve_from(&q_rhs, &guess, 1e-7, 8000); log(&format!("heat, fan {:.0} %: {itl} iterations", lv * 100.0)); (Some(sy), Some(()), Some(pk), so, tt, rr) };         let _ = &sys_l; let _ = pk_l.as_ref(); let _ = sol_l.as_ref();         let q_l = match &sys_l { Some(sy) => qgrids(sy, &tl), None => if lv == 0.0 { qgrids(&sys_still, &tl) } else { qgrids(&sys_mov, &tl) } };         let sol_ref = match (&sol_l, lv) { (Some(a), _) => Some(a), (None, x) if x == 0.0 => solid_still.as_ref(), (None, _) => solid_moving.as_ref() };
src/main.rs+44−24
@@ -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 compliance; mod report; mod inputs; mod tdr; 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 inputs; mod tdr; mod modules; mod slim; 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;@@ -21,7 +21,8 @@ enum Cmd {     #[arg(long)] no_parts: bool,     /// extra folders to look in for the footprints' 3D models (by file name), besides the board's ${KIPRJMOD} and ./3d     #[arg(long = "models-dir")] models_dir: Vec<PathBuf>, /// skip the silkscreen (it needs kicad-cli)-    #[arg(long)] no_silk: bool },+    #[arg(long)] no_silk: bool, /// optional modules to run besides the core, comma-separated: emc, esd, pdn, si, noise, rf, or all (the output says which would matter on this board and why)+    #[arg(long, value_delimiter = ',')] with: Vec<String> },     /// (Re)build only the silkscreen of a solved board: KiCad's F.SilkS and B.SilkS as SVG, into <fields>/ and fields.json `silk`     Silk { #[arg(long)] board: String, #[arg(long, default_value = "fields")] fields: PathBuf },     /// (Re)build only the parts of a solved board: each footprint's 3D model, converted and placed, into <fields>/fields.json and <fields>/parts/@@ -78,10 +79,11 @@ pub fn err(msg: &str, hints: &[String]) -> ! { println!("ERROR: {msg}"); for h i  fn slug(s: &str) -> String { s.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect() } -pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck: f64, parts: Option<&[PathBuf]>, no_silk: bool) -> Result<serde_json::Value, String> {+pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck: f64, parts: Option<&[PathBuf]>, no_silk: bool, with: &[String]) -> Result<serde_json::Value, String> {     let board_bytes = std::fs::read(board).map_err(|e|e.to_string())?;     let spec_bytes = std::fs::read(spec_path).map_err(|e|e.to_string())?;     let b = board::Board::load(board)?;+    let mods = modules::Mods::choose(with, &serde_json::from_slice::<Value>(&spec_bytes).unwrap_or(Value::Null))?;     let mut spec: fields::Spec = serde_json::from_str(&std::fs::read_to_string(spec_path).map_err(|e| format!("{spec_path}: {e}"))?).map_err(|e| format!("{spec_path}: {e}"))?;     // the copper stack: the board's real layers (two, four or six; issue 10, 11) and the dielectric between each pair,     // from the spec (one value per real gap; three on a two-layer board is the old form and is summed), else the board's@@ -113,7 +115,10 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     if let Some(gname) = b.pads.iter().map(|p| p.net.clone()).find(|n| crate::flow::is_ground(n)) {         if !spec.loads.contains_key(&gname) && !spec.loads.is_empty() {             let amps = spec.loads.values().map(|l| l.amps).fold(0.0, f64::max);-            if amps > 0.0 { spec.loads.insert(gname.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, draws: Default::default(), terminals: None }); auto_ground = Some((gname, amps)); }+            // under 0.2 A the return current's drop is microvolts and its heat nothing: not worth a solve (name the ground+            // in spec loads to have it solved anyway)+            if amps >= 0.2 { spec.loads.insert(gname.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, draws: Default::default(), terminals: None }); auto_ground = Some((gname, amps)); }+            else if amps > 0.0 { eprintln!("{gname}: return current not solved: the largest load is {amps} A (under 0.2 A, its drop is negligible); name {gname} in spec loads to solve it"); }         }     }     if !pitch.is_finite() || pitch <= 0.0 || pitch > b.width().min(b.height())/2.0 { return Err("pitch must be positive and resolve at least two cells across the board".into()); }@@ -182,17 +187,23 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     iss.extend(unsolved);     let mut review = emc::review(&f, &b, &spec, &mut iss);     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);+    // the optional modules run only when asked (crate::modules); a skipped one leaves a note, never a guess+    let off = |m: &str| json!({"ran": false, "module": m, "turnOn": format!("--with {m}")});+    let mut nz = if mods.has("noise") || mods.has("rf") { noise::review(&f, &b, &spec, &mut iss) } else { off("noise") };+    let pd = if mods.has("pdn") { pdn::review(&f, &b, &spec, &review["converter"], &mut iss, Some(out.as_path())) } else { off("pdn") };+    let rfv = if mods.has("rf") { rf::review(&b, &spec, &json!({"noise": nz.clone(), "stageFeeds": review["stageFeeds"].clone()}), out.as_path(), &mut iss) } else { off("rf") };     let chk = checks::review(&f, &b, &spec, &review, &mut iss);-    let cmv = cm::review(&f, &b, &spec, &review, &mut iss);-    let cev = conducted::review(&b, &spec, &review["converter"], &mut iss);-    let xsv = xsec::review(&b, &spec, &chk["stackup"], &mut iss);-    let tdv = tdr::review(&b, &spec, &xsv, out.as_path(), &mut iss);+    let cmv = if mods.has("emc") { cm::review(&f, &b, &spec, &review, &mut iss) } else { off("emc") };+    let cev = if mods.has("emc") { conducted::review(&b, &spec, &review["converter"], &mut iss) } else { off("emc") };+    let xsv = if mods.has("si") { xsec::review(&b, &spec, &chk["stackup"], &mut iss) } else { off("si") };+    let tdv = if mods.has("si") { tdr::review(&b, &spec, &xsv, out.as_path(), &mut iss) } else { off("si") };     // 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);+    // keep the findings of the modules that ran, then group the repetitive ones+    iss.retain(|i| mods.has(modules::module_of(&i.kind)));+    let iss = modules::group(iss, &spec.external);+    let module_why = modules::detect(&b, &spec, &review);     f.issues = iss;     for package in &f.package_cooling { for state in ["still", "moving"] { let r = &package[state]; let residual = r["energyResidualW"].as_f64().unwrap_or(f64::INFINITY); let scale = 1.0 + r["sourceW"].as_f64().unwrap_or(0.0).abs() + r["boardCouplingW"].as_f64().unwrap_or(0.0).abs(); if !residual.is_finite() || residual.abs() > 1e-5 * scale { return Err(format!("package cooling energy balance failed for {} ({state})", package["definition"]["reference"])); } } }     if !f.heat_residual.is_finite() || f.heat_residual>1e-6 || !f.heat_moving_residual.is_finite() || f.heat_moving_residual>1e-6 {return Err(format!("thermal solve did not converge: still {}, moving {}; refine the model or increase the solve budget",f.heat_residual,f.heat_moving_residual));}@@ -247,7 +258,9 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     review["conductedEmissions"] = cev;     review["impedance"] = xsv;     review["reflections"] = tdv;-    review["compliance"] = compliance::review(&b, &spec, &review);+    review["compliance"] = if mods.has("emc") || mods.has("rf") { compliance::review(&b, &spec, &review) } else { off("emc") };+    let (module_lines, module_rec) = modules::report(&mods, &module_why);+    summary["modules"] = module_rec; summary["modules"]["lines"] = json!(module_lines);     summary["fragments"] = fragments;     summary["review"] = review;     summary["fixPlan"] = emc::fix_plan(&f.issues);@@ -268,7 +281,10 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     if let Some(dirs) = parts { summary["parts"] = parts::build(board, out, dirs, &mut |s| eprintln!("{s}")); }     if !no_silk { summary["silk"] = silk::build(board, out, &mut |s| eprintln!("{s}")); }     summary["source"] = serde_json::json!({"board": std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.to_string()), "spec": spec_path});-    std::fs::write(out.join("fields.json"), serde_json::to_string_pretty(&summary).unwrap()).map_err(|e| e.to_string())?;+    // the AI's fields.json stays lean; the bulk the app draws goes to detail.json (crate::slim), merged back on serve+    let (lean, detail) = slim::split(summary.clone());+    std::fs::write(out.join(slim::DETAIL), serde_json::to_string(&detail).unwrap()).map_err(|e| e.to_string())?;+    std::fs::write(out.join("fields.json"), slim::to_text(&lean)).map_err(|e| e.to_string())?;     Ok(summary) } @@ -276,8 +292,8 @@ fn main() {     let cli = Cli::parse();     match cli.cmd {         Cmd::Thermal3d { spec, out } => match thermal3d::run(&spec, &out) { Ok(()) => ok("3D solid thermal solve written", &[]), Err(e) => err(&e, &[]) },-        Cmd::Analyze { board, spec, out, pitch, neck, no_parts, models_dir, no_silk } => {-            match run_analyze(&board, &spec, &out, pitch, neck, if no_parts { None } else { Some(&models_dir) }, no_silk) {+        Cmd::Analyze { board, spec, out, pitch, neck, no_parts, models_dir, no_silk, with } => {+            match run_analyze(&board, &spec, &out, pitch, neck, if no_parts { None } else { Some(&models_dir) }, no_silk, &with) {                 Ok(s) => {                     let nets = s["nets"].as_array().map(|a| a.len()).unwrap_or(0);                     let issues = s["issues"].as_array().cloned().unwrap_or_default();@@ -285,16 +301,16 @@ fn main() {                         format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["nets"].as_array().into_iter().flatten().filter_map(|n| n["peakJ"].as_f64()).fold(0.0, f64::max), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]),                         issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "),                         { let r = &s["review"]; let c = &r["converter"]; let g = &r["ground"]; format!("Review: {}{}{}.", if c.is_null() { "no switching converter modelled".to_string() } else { format!("hot loop {} {} mm² (best {} mm²), emissions margin {} dB at {} MHz (FCC B, loop only), output ripple {:.1} mV", c["hotLoop"]["cap"].as_str().unwrap_or("?"), c["hotLoop"]["mm2"], c["idealMm2"], c["emissions"]["worstMarginDb"], c["emissions"]["atMHz"], c["rippleOutMv"].as_f64().unwrap_or(0.0)) }, if g.is_null() { String::new() } else { format!("; {}{} island(s), edge stitched {} %", if g["offsetMv"].is_number() { format!("ground offset {} mV, ", g["offsetMv"]) } else { "ground offset not solved (ground is not a loaded net), ".to_string() }, g["islands"].as_array().map_or(0, |x| x.len()), g["stitching"]["edgeCoveredPct"]) }, r["reference"].as_array().map(|a| a.iter().filter(|x| x["unreferencedPct"].as_f64().unwrap_or(0.0) > 5.0).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(", ")) }) },+                        if s["review"]["pdn"]["ran"] == false { String::new() } else { 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 }) } },+                        if s["review"]["compliance"]["ran"] == false { String::new() } else { 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 i = &s["review"]["inputs"]; let top: Vec<String> = i["items"].as_array().into_iter().flatten().filter(|x| x["matters"] == "high").take(4).map(|x| format!("{}{}", x["what"].as_str().unwrap_or(""), x["part"]["mpn"].as_str().filter(|m| !m.is_empty()).map(|m| format!(" ({m})")).unwrap_or_default())).collect(); format!("Inputs assumed: {} that matter most, {} medium, {} low{}. review.inputs names each datasheet field and spec key; specPatch is the fragment to fill.", i["counts"]["high"], i["counts"]["medium"], i["counts"]["low"], if top.is_empty() { String::new() } else { format!(": {}", top.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(" ")) } },+                        { 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| { let who = x["action"]["net"].as_str().or(x["action"]["ref"].as_str()).or(x["action"]["reference"].as_str()).map(|w| format!(" {w}")).unwrap_or_default(); format!("{}) [{}{}] {}", x["step"], x["kind"].as_str().unwrap_or(""), who, 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(", ")) } },                         if s["heat"]["sourcesModelled"] == 0 { "Thermal: no heat sources modelled, so the temperature is only the copper's own I²R loss: this is not a thermal pass for any part. Add each dissipating part to spec hot (watts; tabNet if it has an exposed tab, else the watts go through all its leads by pad area).".to_string() } else { format!("Thermal: {} part(s) modelled as heat sources.", s["heat"]["sourcesModelled"]) },-                        format!("Heatsinks: {}; signal capacitance checks: {}; differential pairs: {}. Signals are screening estimates; unsupported geometry is reported, never certified.", s["heatsinks"].as_array().map_or(0,|x|x.len()),s["signals"]["capacitance"].as_array().map_or(0,|x|x.len()),s["signals"]["differentialPairs"].as_array().map_or(0,|x|x.len())),+                        { let (h, c, d) = (s["heatsinks"].as_array().map_or(0,|x|x.len()), s["signals"]["capacitance"].as_array().map_or(0,|x|x.len()), s["signals"]["differentialPairs"].as_array().map_or(0,|x|x.len())); if h + c + d == 0 { String::new() } else { format!("Heatsinks: {h}; signal capacitance checks: {c}; differential pairs: {d}. Signals are screening estimates; unsupported geometry is reported, never certified.") } },                         format!("Next: `adom-fields serve --fields {}` and open it (pup or the browser); `adom-fields ui view --quantity current --net <net>` drives it; `adom-fields tour` walks it for a video.", out.display()),-                    ]);+                    ].into_iter().chain(s["modules"]["lines"].as_array().into_iter().flatten().filter_map(|l| l.as_str().map(String::from))).collect::<Vec<_>>());                 }                 Err(e) => err(&e, &["The board must be a .kicad_pcb saved with its zones filled; the spec is the aiflow spec (loads, hot, copperUm, hotMaxRiseC, thermalVias).".into()]),             }@@ -329,7 +345,7 @@ fn main() {             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, &[]); } }+            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);         }         Cmd::Silk { board, fields } => {@@ -337,7 +353,7 @@ fn main() {             let mut f: serde_json::Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| err(&format!("no fields.json in {}", fields.display()), &["Run `adom-fields analyze` first.".into()]));             let s = silk::build(&board, &fields, &mut |s| eprintln!("{s}"));             let none = s.is_null(); f["silk"] = s;-            std::fs::write(&path, serde_json::to_string_pretty(&f).unwrap()).unwrap_or_else(|e| err(&e.to_string(), &[]));+            std::fs::write(&path, slim::to_text(&f)).unwrap_or_else(|e| err(&e.to_string(), &[]));             if none { err("no silkscreen exported", &["It needs a .kicad_pcb and kicad-cli (service-kicad, or KiCad through Adom Bridge).".into()]); }             ok(&format!("silkscreen added: {}", path.display()), &["A running `serve` shows it on the next page load; `adom-fields ui view --silk off` hides it.".into()]);         }@@ -346,8 +362,12 @@ fn main() {             let mut f: serde_json::Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| err(&format!("no fields.json in {}", fields.display()), &["Run `adom-fields analyze` first.".into()]));             let p = parts::build(&board, &fields, &models_dir, &mut |s| eprintln!("{s}"));             let msg = format!("{} parts with 3D models, {} as boxes, {} bare: {}", p["modelled"], p["boxed"], p["bare"], path.display());-            f["parts"] = p;-            std::fs::write(&path, serde_json::to_string_pretty(&f).unwrap()).unwrap_or_else(|e| err(&e.to_string(), &[]));+            // the parts' placements live in detail.json since 0.10.0 (a solve from before keeps them in fields.json)+            let dpath = fields.join(slim::DETAIL);+            match std::fs::read_to_string(&dpath).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()) {+                Some(mut d) => { d["parts"] = p; std::fs::write(&dpath, serde_json::to_string(&d).unwrap()).unwrap_or_else(|e| err(&e.to_string(), &[])); }+                None => { f["parts"] = p; std::fs::write(&path, slim::to_text(&f)).unwrap_or_else(|e| err(&e.to_string(), &[])); }+            }             ok(&msg, &["A running `serve` picks them up on the next page load; `adom-fields ui view --parts ghost|solid|off` switches them.".into()]);         }         Cmd::Ui { what, port } => serve::ui(what, port),
src/modules.rsadded+168
@@ -0,0 +1,168 @@+//! Optional analysis modules (0.10.0). The core always runs: current, heat, the copper and thermal findings, ground+//! offset, decoupling, the converter's hot loop and ripple, the inputs assumed. Each optional module runs when the+//! caller asks (`--with emc,pdn` or `--with all`) or when the spec already uses that module's own keys. A module that+//! did not run is never silent: `detect` reads the board for the reasons it may matter here, and the output says what+//! it would add and how to turn it on, so the calling AI can decide.+use crate::board::Board;+use crate::fields::{Issue, Spec};+use serde_json::{json, Value};+use std::collections::{BTreeMap, BTreeSet};++pub const OPTIONAL: &[&str] = &["emc", "esd", "pdn", "si", "noise", "rf"];++/// what each module adds, in one line (the hint's second half)+pub fn adds(m: &str) -> &'static str {+    match m {+        "emc" => "radiated emissions of the hot loop and of each cable (common mode, the usual chamber failure), conducted emissions through a LISN, the stitching fence and return paths, and a compliance prediction (FCC/CISPR) with the cheapest fixes by dB and a bench plan",+        "esd" => "each connector pin's protection: a TVS within 3 mm and a filter on every line that faces the outside world",+        "pdn" => "each rail's impedance from its load pin against a target (1 kHz to 1 GHz, ngspice-checked), the cheapest decoupling set, hot-plug overshoot and input-filter stability",+        "si" => "every signal trace's impedance (2-D field solve), TDR and overshoot with the series resistor that fixes it, length matching, via stubs and the stackup's reference planes",+        "noise" => "plane-cavity resonances against the switching and clock harmonics, and crosstalk into quiet nets",+        "rf" => "a receiver's oscillator leaking out of the antenna (FCC 15.111), isolation between stages, the frequency plan's in-band tones and the oscillator's own emission",+        _ => "",+    }+}++/// the spec keys that switch a module on by themselves (the caller already asked for that analysis)+fn spec_keys(m: &str) -> &'static [&'static str] {+    match m { "emc" => &["emcLimit", "emcLimits", "cables", "measurements"], "esd" => &["external"], "pdn" => &["pdn"], "si" => &["impedance", "tdr", "lengthGroups"], "noise" => &["crosstalkMv", "victimBudgetsMv"], "rf" => &["rf", "bands", "sensitiveNets"], _ => &[] }+}++/// The module a finding belongs to; anything not listed is core.+pub fn module_of(kind: &str) -> &'static str {+    match kind {+        "emissions" | "cable-emissions" | "conducted-emissions" | "stitching-gap" | "ground-loop-risk" | "no-reference" | "clock-outer-layer" | "clock-near-connector" | "edge-signal" | "input-filter-cutoff" => "emc",+        "esd-unprotected" | "io-unfiltered" => "esd",+        "pdn-impedance" | "input-filter-stability" | "hot-plug-overshoot" | "regulator" => "pdn",+        "impedance" | "diff-impedance" | "reflection" | "length-skew" | "via-stub" | "stackup-adjacent-signals" | "stackup-far-reference" | "plane" => "si",+        "cavity-resonance" | "crosstalk" | "tone-in-band" => "noise",+        "lo-leak" | "stage-isolation" | "lo-emission" | "stage-feed-unbypassed" | "circuit-path" | "antenna-ground-loop" => "rf",+        _ => "core",+    }+}++pub struct Mods { pub on: BTreeMap<String, String> }+impl Mods {+    pub fn has(&self, m: &str) -> bool { m == "core" || self.on.contains_key(m) }+    /// `--with` (comma list, `all`) and the spec's own keys+    pub fn choose(with: &[String], raw: &Value) -> Result<Mods, String> {+        let mut on = BTreeMap::new();+        for w in with.iter().flat_map(|w| w.split(',')).map(|w| w.trim().to_ascii_lowercase()).filter(|w| !w.is_empty()) {+            if w == "all" { for m in OPTIONAL { on.insert(m.to_string(), "--with all".to_string()); } }+            else if OPTIONAL.contains(&w.as_str()) { on.insert(w.clone(), format!("--with {w}")); }+            else if w != "core" { return Err(format!("--with {w}: no such module; the optional modules are {} (or all)", OPTIONAL.join(", "))); }+        }+        for m in OPTIONAL { if on.contains_key(*m) { continue; } if let Some(k) = spec_keys(m).iter().find(|k| !raw[**k].is_null()) { on.insert(m.to_string(), format!("the spec gives {k}")); } }+        Ok(Mods { on })+    }+}++/// Why each module that did not run may matter on this board: read from the board and the core review, cheaply.+pub fn detect(b: &Board, spec: &Spec, rev: &Value) -> BTreeMap<String, String> {+    let mut why = BTreeMap::new();+    let refs_with = |pre: &[&str]| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().map(|p| p.reference.clone()).filter(|r| pre.iter().any(|p| r.starts_with(p)) && !r.starts_with("PS") && !r.starts_with("MP")).collect(); v.sort(); v.dedup(); v };+    let conns = refs_with(&["J", "P", "USB", "CN"]);+    let list = |v: &[String], n: usize| -> String { if v.len() <= n { v.join(", ") } else { format!("{} and {} more", v[..n].join(", "), v.len() - n) } };+    let nets: BTreeSet<String> = b.pads.iter().map(|p| p.net.clone()).filter(|n| !n.is_empty() && !n.starts_with("unconnected-")).collect();+    let up = |n: &str| n.to_ascii_uppercase();+    let c = &rev["converter"];+    // emc+    if let Some(fsw) = c["fswHz"].as_f64() {+        why.insert("emc".into(), format!("{} switches at {:.0} kHz{}: a switcher's noise riding out on a cable is the most common reason a board fails FCC/CISPR radiated, and its input ripple is what the conducted test measures", c["switch"].as_str().unwrap_or("a converter"), fsw / 1e3, if conns.is_empty() { String::new() } else { format!(" and {} connector(s) can carry cables ({})", conns.len(), list(&conns, 4)) }));+    } else if !spec.clocks.is_empty() || nets.iter().any(|n| up(n).contains("CLK") || up(n).contains("XTAL")) {+        why.insert("emc".into(), format!("clock nets ({}) with {} connector(s): a clock edge near a connector or on an outer layer is a common radiated-emissions failure", list(&nets.iter().filter(|n| up(n).contains("CLK") || up(n).contains("XTAL") || spec.clocks.contains_key(*n)).cloned().collect::<Vec<_>>(), 3), conns.len()));+    } else if !conns.is_empty() { why.insert("emc".into(), format!("{} connector(s) ({}): only if the product goes through FCC/CISPR testing", conns.len(), list(&conns, 4))); }+    // esd+    if !conns.is_empty() { why.insert("esd".into(), format!("{} connector(s) ({}): a pin that reaches the outside world (a user cable, a hot-plugged lead) needs a TVS within 3 mm; name the outside-facing ones in spec external: [\"J1\", ...] so only those are checked", conns.len(), list(&conns, 4))); }+    // pdn: rails that feed a chip with many pins+    let chips_on = |n: &str| -> Vec<(String, usize)> { let mut by: BTreeMap<String, usize> = BTreeMap::new(); for p in b.pads.iter().filter(|p| p.reference.starts_with('U')) { *by.entry(p.reference.clone()).or_default() += 1; } let mut v: Vec<(String, usize)> = b.pads.iter().filter(|p| p.net == n && p.reference.starts_with('U')).map(|p| (p.reference.clone(), by[&p.reference])).collect(); v.sort(); v.dedup(); v };+    let rails: Vec<String> = nets.iter().filter(|n| !crate::flow::is_ground(n) && (crate::flow::rail_from_name(n).is_some() || spec.voltages.contains_key(*n))).filter_map(|n| { let cs = chips_on(n); let big = cs.iter().filter(|c| c.1 >= 8).map(|c| format!("{} ({} pins)", c.0, c.1)).collect::<Vec<_>>(); if big.is_empty() { None } else { Some(format!("{n} → {}", big.join(", "))) } }).collect();+    let mut pdn_why = Vec::new();+    if !rails.is_empty() { pdn_why.push(format!("rails feeding chips: {}: a chip's fast current edges need low rail impedance up to its switching frequencies, or it browns out and radiates", list(&rails, 3))); }+    if let Some(inn) = c["inNet"].as_str() { pdn_why.push(format!("the converter's input {inn}: plugging in a supply rings its low-ESR capacitors (hot-plug overshoot past their rating), and an input filter can oscillate with the converter (Middlebrook)")); }+    if !pdn_why.is_empty() { why.insert("pdn".into(), pdn_why.join("; ")); }+    // si+    let pairs: Vec<String> = nets.iter().filter_map(|n| { for (a, z) in [("_P", "_N"), ("+", "-"), ("DP", "DM"), ("_H", "_L")] { if let Some(s) = n.strip_suffix(a) { if nets.contains(&format!("{s}{z}")) { return Some(format!("{n}/{s}{z}")); } } } None }).collect();+    let fast: Vec<String> = nets.iter().filter(|n| { let u = up(n); ["CLK", "SCK", "USB", "SDIO", "MOSI", "MISO", "QSPI", "ETH", "MII", "LVDS", "MIPI", "HDMI", "DDR"].iter().any(|k| u.contains(k)) }).cloned().collect();+    let long: usize = { let mut by: BTreeMap<&str, f64> = BTreeMap::new(); for t in &b.tracks { if crate::flow::rail_from_name(&t.net).is_none() && !crate::flow::is_ground(&t.net) { *by.entry(t.net.as_str()).or_default() += (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1); } } by.values().filter(|l| **l > 50.0).count() };+    if !pairs.is_empty() || !fast.is_empty() || long > 0 {+        let mut parts = Vec::new(); if !pairs.is_empty() { parts.push(format!("pairs {}", list(&pairs, 3))); } if !fast.is_empty() { parts.push(format!("fast nets {}", list(&fast, 4))); } if long > 0 { parts.push(format!("{long} signal net(s) over 50 mm")); }+        why.insert("si".into(), format!("{}: an edge faster than the trace's round trip rings, and a pair off its impedance fails its eye", parts.join("; ")));+    }+    // noise+    let sw = c["switchNet"].as_str().map(String::from).or_else(|| spec.switching.keys().next().cloned());+    let pours: usize = { let mut s: BTreeSet<&str> = BTreeSet::new(); for p in &b.polys { if !crate::flow::is_ground(&p.net) && !p.net.is_empty() { s.insert(&p.net); } } s.len() };+    if sw.is_some() || !spec.clocks.is_empty() || pours > 0 {+        let mut parts = Vec::new(); if let Some(s) = &sw { parts.push(format!("switch node {s} next to quiet nets")); } if !spec.clocks.is_empty() { parts.push(format!("{} clock(s)", spec.clocks.len())); } if pours > 0 { parts.push(format!("{pours} power pour(s) over ground, each a cavity that can ring")); }+        why.insert("noise".into(), format!("{}: a plane resonance near a switching harmonic, or a few mV coupled into a sensitive input, is a classic board bug", parts.join("; ")));+    }+    // rf+    let rfn: Vec<String> = nets.iter().filter(|n| { let u = up(n); ["ANT", "RF", "LNA", "MIX", "_LO", "LO_", "IF_", "_IF", "VCO", "TUNER"].iter().any(|k| u.contains(k)) }).cloned().collect();+    if !rfn.is_empty() || !spec.bands.is_empty() { why.insert("rf".into(), format!("RF nets ({}): a receiver's oscillator leaking out of its antenna, or a tone landing in its band, fails FCC 15.111 or deafens it", list(&rfn, 4))); }+    why+}++/// The lines that tell the caller what did not run and why it may matter here; and the record for fields.json.+pub fn report(mods: &Mods, why: &BTreeMap<String, String>) -> (Vec<String>, Value) {+    let mut lines = Vec::new(); let mut skipped = Vec::new();+    for m in OPTIONAL { if mods.has(m) { continue; }+        let reason = why.get(*m).cloned();+        if let Some(r) = &reason { lines.push(format!("Not run: {m}. On this board: {r}. `--with {m}` adds {}.", adds(m))); }+        skipped.push(json!({"module": m, "whyItMayMatter": reason, "adds": adds(m), "turnOn": format!("--with {m}")}));+    }+    let quiet: Vec<&str> = OPTIONAL.iter().copied().filter(|m| !mods.has(m) && !why.contains_key(*m)).collect();+    if !quiet.is_empty() { lines.push(format!("Not run, nothing on this board points to them: {} (`--with all` runs every module for a sign-off pass).", quiet.join(", "))); }+    else if !skipped.is_empty() { lines.push("`--with all` runs every module for a sign-off pass; each adds seconds, not minutes.".into()); }+    let ran: Vec<Value> = mods.on.iter().map(|(m, w)| json!({"module": m, "because": w})).collect();+    (lines, json!({"core": "always: current, heat, copper and thermal findings, ground offset, decoupling, the converter's hot loop and ripple, the inputs assumed", "ran": ran, "skipped": skipped}))+}++/// Fewer, sharper findings: one barrel finding per net, ampacity folded into a neck on the same net, and three or more+/// low findings of one kind grouped into one with every item kept (the action's `items`).+pub fn group(iss: Vec<Issue>, external: &[String]) -> Vec<Issue> {+    let mut out: Vec<Issue> = Vec::new();+    // ESD and filtering only on the connectors the spec says face outside, when it says+    let iss: Vec<Issue> = iss.into_iter().filter(|i| { if external.is_empty() || module_of(&i.kind) != "esd" { return true; }+        let r = i.reference.clone().or_else(|| i.action.as_ref().and_then(|a| a["nearPin"].as_str()).map(|p| p.split('.').next().unwrap_or("").to_string())).unwrap_or_default(); external.iter().any(|e| *e == r) }).collect();+    let necks: BTreeSet<String> = iss.iter().filter(|i| i.kind == "current-neck").filter_map(|i| i.net.clone()).collect();+    let mut barrels: BTreeMap<String, Vec<Issue>> = BTreeMap::new();+    let mut lows: BTreeMap<String, Vec<Issue>> = BTreeMap::new();+    for i in iss {+        if i.kind == "ampacity" && i.net.as_ref().is_some_and(|n| necks.contains(n)) { continue; }+        if i.kind == "current-barrel" { barrels.entry(i.net.clone().unwrap_or_default()).or_default().push(i); continue; }+        if i.severity == "low" && !matches!(i.kind.as_str(), "current-neck" | "tab-rise" | "thermal-vias") { lows.entry(i.kind.clone()).or_default().push(i); continue; }+        out.push(i);+    }+    let item = |i: &Issue| json!({"what": i.what, "at": i.at, "net": i.net, "reference": i.reference, "action": i.action});+    for (net, v) in barrels {+        if v.len() == 1 { out.extend(v); continue; }+        let mut g = v[0].clone(); g.what = format!("{net}: {} plated connections carry more than the density threshold; the first: {}", v.len(), v[0].what); g.action = Some(json!({"type": "group", "of": "review", "items": v.iter().map(item).collect::<Vec<_>>()})); out.push(g);+    }+    for (kind, v) in lows {+        if v.len() < 3 { out.extend(v); continue; }+        let names: Vec<String> = v.iter().filter_map(|i| i.net.clone().or(i.reference.clone())).collect::<BTreeSet<_>>().into_iter().collect();+        let mut g = v[0].clone();+        g.what = format!("{} {kind} findings, grouped ({}{}); the first: {}", v.len(), names.iter().take(8).cloned().collect::<Vec<_>>().join(", "), if names.len() > 8 { format!(" and {} more", names.len() - 8) } else { String::new() }, v[0].what);+        g.action = Some(json!({"type": "group", "of": v[0].action.as_ref().and_then(|a| a["type"].as_str()).unwrap_or("review"), "items": v.iter().map(item).collect::<Vec<_>>()}));+        out.push(g);+    }+    out+}++#[cfg(test)] mod tests {+    use super::*;+    fn iss(kind: &str, sev: &str, net: &str) -> Issue { Issue { severity: sev.into(), kind: kind.into(), what: format!("{kind} on {net}"), at: None, layer: None, net: Some(net.into()), reference: None, fix: String::new(), action: Some(json!({"type": "review"})) } }+    #[test] fn choose_and_group() {+        let m = Mods::choose(&["pdn,si".into()], &json!({"cables": {"J1": 2}})).unwrap();+        assert!(m.has("pdn") && m.has("si") && m.has("emc") && !m.has("rf") && m.has("core"));+        assert!(Mods::choose(&["bogus".into()], &json!({})).is_err());+        assert_eq!(Mods::choose(&["all".into()], &json!({})).unwrap().on.len(), OPTIONAL.len());+        let g = group(vec![iss("current-neck", "high", "A"), iss("ampacity", "medium", "A"), iss("ampacity", "medium", "B"), iss("current-barrel", "medium", "A"), iss("current-barrel", "medium", "A"), iss("no-reference", "low", "X"), iss("no-reference", "low", "Y"), iss("no-reference", "low", "Z"), iss("crosstalk", "low", "Q")], &[]);+        let kinds: Vec<&str> = g.iter().map(|i| i.kind.as_str()).collect();+        assert_eq!(kinds.iter().filter(|k| **k == "ampacity").count(), 1); // B kept, A folded into its neck+        assert_eq!(kinds.iter().filter(|k| **k == "current-barrel").count(), 1);+        assert_eq!(kinds.iter().filter(|k| **k == "no-reference").count(), 1);+        assert!(kinds.contains(&"crosstalk") && kinds.contains(&"current-neck"));+        let nr = g.iter().find(|i| i.kind == "no-reference").unwrap(); assert_eq!(nr.action.as_ref().unwrap()["items"].as_array().unwrap().len(), 3);+    }+}
src/probe.rs+1−1
@@ -8,7 +8,7 @@ fn r(x: f64, k: f64) -> f64 { (x * k).round() / k } /// `from` and `to` are REF.PAD (J1.1); `to` may instead be a net name, meaning the point of that net farthest in voltage /// from `from`. pub fn compute(dir: &Path, from: &str, to: &str) -> Result<Value, String> {-    let f: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("fields.json")).map_err(|e| format!("{}: {e}", dir.join("fields.json").display()))?).map_err(|e| e.to_string())?;+    let f: Value = crate::slim::load(dir).ok_or_else(|| format!("{}: no readable fields.json", dir.join("fields.json").display()))?;     let bin = std::fs::read(dir.join("flow.bin")).map_err(|_| "no flow.bin next to fields.json: re-run `adom-fields analyze` with 0.7.20 or later".to_string())?;     let fl = &f["flow"];     let (w, h) = (fl["cols"].as_u64().unwrap_or(0) as usize, fl["rows"].as_u64().unwrap_or(0) as usize);
src/serve.rs+6−3
@@ -30,7 +30,7 @@ fn ui_file(name: &str, built_in: &'static str) -> String { fn now() -> String { let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); format!("{t}") }  pub fn serve(dir: &PathBuf, port: u16) {-    let fields: Value = std::fs::read_to_string(dir.join("fields.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| crate::err(&format!("no fields.json in {}", dir.display()), &["Run `adom-fields analyze --board <pcb> --spec <json> --out <dir>` first, or pass --board and --spec to serve.".into()]));+    let fields: Value = crate::slim::load(dir).unwrap_or_else(|| crate::err(&format!("no fields.json in {}", dir.display()), &["Run `adom-fields analyze --board <pcb> --spec <json> --out <dir>` first, or pass --board and --spec to serve.".into()]));     let app = Arc::new(Mutex::new(App { dir: dir.clone(), fields, view: json!({"quantity": "current", "net": null, "layer": null, "camera": "iso", "air": "still", "issue": null, "chip": null, "drift": true, "parts": "auto", "explode": 0, "speed": 1, "seq": 0, "changed": []}), console: Vec::new(), clients: Vec::new(), started: now(), seq: 0 }));     let server = tiny_http::Server::http(("0.0.0.0", port)).unwrap_or_else(|e| crate::err(&format!("cannot listen on {port}: {e}"), &[format!("Another adom-fields may be up: `adom-fields health --port {port}`; or pick --port.")]));     crate::ok(&format!("adom-fields serving on http://127.0.0.1:{port}/ ({})", app.lock().unwrap().fields["board"]["name"].as_str().unwrap_or("")), &["Open it from outside the container through the proxy: https://<slug>.adom.cloud/proxy/".to_string() + &port.to_string() + "/ (pup, the browser, or a Hydrogen webview).", "Drive it: `adom-fields ui view --quantity heat --layer F.Cu`, `ui select --net +VBAT`, `ui issue --index 0`, `ui toast \"...\"`; `adom-fields tour` walks the whole thing.".into(), "GET /state, GET /console, GET /events (SSE) for the AI; POST /ui/view, /ui/toast, /ui/select, /ui/issue.".into()]);@@ -83,7 +83,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {         ("GET", "/fields.json") => {             // re-read: `adom-fields parts` may have added the 3D models since the server started             let mut a = app.lock().unwrap();-            if let Some(f) = std::fs::read_to_string(a.dir.join("fields.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) { a.fields = f; }+            if let Some(f) = crate::slim::load(&a.dir) { a.fields = f; }             reply(req, 200, a.fields.clone());         }         ("GET", "/console") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "lines": a.console})); }@@ -105,6 +105,9 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {         ("POST", "/ui/view") | ("POST", "/ui/select") | ("POST", "/ui/issue") => {             let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));             let mut a = app.lock().unwrap();+            // the Power and Noise views draw an optional module's results: say how to get them rather than show nothing+            if path == "/ui/view" { if let Some(q) = v["quantity"].as_str() { let m = match q { "pdn" => Some(("pdn", "pdn")), "noise" => Some(("noise", "noise")), _ => None };+                if let Some((key, module)) = m { if a.fields["review"][key]["ran"] == false { drop(a); reply(req, 409, json!({"status": "error", "error": format!("the {q} view needs the {module} module, which this solve did not run"), "hint": format!("Re-run `adom-fields analyze ... --with {module}` (or --with all) on this board, then reload the app.")})); return; } } } }             let before = a.view.clone();             if path == "/ui/issue" {                 let i = v["index"].as_u64().unwrap_or(0) as usize;@@ -211,7 +214,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { pub fn ui(what: UiCmd, port: u16) {     let up = |p: &str, v: &Value| -> Value { post(port, p, v).unwrap_or_else(|e| crate::err(&format!("the app did not answer on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")])) };     match what {-        UiCmd::View { quantity, net, layer, camera, air, parts, at, explode, speed, ghost, silk, probe, keep_camera } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } if let Some(q) = &quantity { if !["current", "heat", "temperature", "signals", "flow", "river", "energy", "light", "cycle", "charge", "magnetic", "emc", "ground", "noise", "pdn"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge, magnetic, emc, ground, noise or pdn"), &[]); } } let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air, "parts": parts, "focus": focus, "explode": explode, "speed": speed, "ghost": ghost, "silk": silk.as_deref().map(|s| s != "off" && s != "false"), "probe": probe, "keepCamera": if keep_camera { Some(true) } else { None }})); crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); }+        UiCmd::View { quantity, net, layer, camera, air, parts, at, explode, speed, ghost, silk, probe, keep_camera } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } if let Some(q) = &quantity { if !["current", "heat", "temperature", "signals", "flow", "river", "energy", "light", "cycle", "charge", "magnetic", "emc", "ground", "noise", "pdn"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge, magnetic, emc, ground, noise or pdn"), &[]); } } let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air, "parts": parts, "focus": focus, "explode": explode, "speed": speed, "ghost": ghost, "silk": silk.as_deref().map(|s| s != "off" && s != "false"), "probe": probe, "keepCamera": if keep_camera { Some(true) } else { None }})); if r["status"] == "error" { crate::err(r["error"].as_str().unwrap_or("the app refused the view"), &[r["hint"].as_str().unwrap_or("").to_string()]); } crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); }         UiCmd::Explain { quantity, stop } => { let r = up("/ui/explain", &json!({"quantity": quantity, "stop": stop})); crate::ok(&format!("{} to {} page(s)", if stop { "stop sent" } else { "explain sent" }, r["clients"]), &["The page composes the explanation from this board's numbers and plays it with a one-line subtitle; a browser may hold the first audio until the viewer has clicked the page once.".into()]); }         UiCmd::Toast { text, kind } => { up("/ui/toast", &json!({"text": text, "kind": kind})); crate::ok("toast shown", &[]); }         UiCmd::Select { net, chip } => { let r = up("/ui/select", &json!({"net": net, "chip": chip})); crate::ok(&format!("selected: {}", r["view"]), &[]); }
src/slim.rsadded+69
@@ -0,0 +1,69 @@+//! A lean fields.json (0.10.0): what an AI reads stays in fields.json; the bulk the app draws (the river's per-net+//! flow, every plated connection's current, the cavities' mode grids, the PDN curves, the parts' placements and the via+//! list) goes to detail.json beside it. `merge` puts them back: the server, probe and every reader that draws use it,+//! so GET /fields.json is the whole document, as before.+use serde_json::{json, Value};+use std::path::Path;++pub const DETAIL: &str = "detail.json";++/// Split a summary into the lean part and the detail.+pub fn split(mut f: Value) -> (Value, Value) {+    let mut d = json!({});+    if let Some(n) = f["flow"].get_mut("nets") { d["flow"] = json!({"nets": n.take()}); f["flow"]["nets"] = json!("detail.json"); }+    let mut pc = serde_json::Map::new();+    for n in f["nets"].as_array_mut().into_iter().flatten() { let name = n["net"].as_str().unwrap_or("").to_string(); if let Some(v) = n.get_mut("platedConnections") { if v.as_array().map_or(0, |a| a.len()) > 0 { pc.insert(name, v.take()); n["platedConnections"] = json!("detail.json"); } } }+    if !pc.is_empty() { d["platedConnections"] = Value::Object(pc); }+    let mut grids = Vec::new();+    for c in f["review"]["noise"]["cavities"].as_array_mut().into_iter().flatten() { let name = c["net"].clone(); if let Some(g) = c.get_mut("grid") { grids.push(json!({"net": name, "grid": g.take()})); c.as_object_mut().map(|o| o.remove("grid")); } }+    if !grids.is_empty() { d["cavityGrids"] = json!(grids); }+    if f["review"]["pdn"]["fHz"].is_array() {+        let mut curves = serde_json::Map::new();+        for r in f["review"]["pdn"]["rails"].as_array_mut().into_iter().flatten() { let k = r["net"].as_str().unwrap_or("").to_string(); let mut c = serde_json::Map::new(); for key in ["zOhm", "zAfterOhm"] { if let Some(v) = r.get_mut(key) { if v.is_array() { c.insert(key.into(), v.take()); r.as_object_mut().map(|o| o.remove(key)); } } } if !c.is_empty() { curves.insert(k, Value::Object(c)); } }+        d["pdn"] = json!({"fHz": f["review"]["pdn"]["fHz"].take(), "curves": curves}); f["review"]["pdn"].as_object_mut().map(|o| o.remove("fHz"));+    }+    for key in ["parts", "vias"] { if !f[key].is_null() { d[key] = f[key].take(); f.as_object_mut().map(|o| o.remove(key)); } }+    f["detail"] = json!({"file": DETAIL, "holds": "the river's per-net flow, each plated connection's current, cavity mode grids, PDN |Z(f)| curves, the parts' 3D placements and the via list: for the app; GET /fields.json on the server returns everything merged"});+    (f, d)+}++/// The lean file's layout: one top-level key per line, and one element per line for the lists an AI scans (findings,+/// fix-plan steps, nets, parts): compact, still readable line by line, a third the size of pretty-printed JSON.+pub fn to_text(f: &Value) -> String {+    let Some(o) = f.as_object() else { return f.to_string() };+    let mut out = String::from("{\n"); let n = o.len();+    for (i, (k, v)) in o.iter().enumerate() {+        let body = match v { Value::Array(a) if a.len() > 1 && a.iter().all(|x| x.is_object()) => format!("[\n  {}\n ]", a.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(",\n  ")), _ => v.to_string() };+        out.push_str(&format!(" {}: {}{}\n", Value::String(k.clone()), body, if i + 1 < n { "," } else { "" }));+    }+    out.push('}'); out.push('\n'); out+}++/// Put the detail back into a lean summary (the inverse of split).+pub fn merge(mut f: Value, mut d: Value) -> Value {+    if let Some(n) = d["flow"].get_mut("nets") { f["flow"]["nets"] = n.take(); }+    if let Some(pc) = d["platedConnections"].as_object_mut() { for n in f["nets"].as_array_mut().into_iter().flatten() { if let Some(v) = pc.remove(n["net"].as_str().unwrap_or("")) { n["platedConnections"] = v; } } }+    for g in d["cavityGrids"].as_array().into_iter().flatten() { for c in f["review"]["noise"]["cavities"].as_array_mut().into_iter().flatten() { if c["net"] == g["net"] && c.get("grid").is_none() { c["grid"] = g["grid"].clone(); break; } } }+    if d["pdn"]["fHz"].is_array() { f["review"]["pdn"]["fHz"] = d["pdn"]["fHz"].take(); for r in f["review"]["pdn"]["rails"].as_array_mut().into_iter().flatten() { if let Some(c) = d["pdn"]["curves"].get(r["net"].as_str().unwrap_or("")) { for (k, v) in c.as_object().into_iter().flatten() { r[k.as_str()] = v.clone(); } } } }+    for key in ["parts", "vias"] { if !d[key].is_null() { f[key] = d[key].take(); } }+    f+}++/// fields.json with its detail merged in, from a solve folder (a solve from before 0.10.0 has no detail.json and reads as is)+pub fn load(dir: &Path) -> Option<Value> {+    let f: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("fields.json")).ok()?).ok()?;+    match std::fs::read_to_string(dir.join(DETAIL)).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) { Some(d) => Some(merge(f, d)), None => Some(f) }+}++#[cfg(test)] mod tests {+    use super::*;+    #[test] fn round_trip() {+        let f = json!({"flow": {"cellMm": 0.2, "nets": [{"net": "A", "x": 1}]}, "nets": [{"net": "A", "platedConnections": [{"amps": 1}]}, {"net": "B", "platedConnections": []}],+            "review": {"noise": {"cavities": [{"net": "V", "grid": [1, 2], "modes": [3]}]}, "pdn": {"fHz": [1, 2], "rails": [{"net": "V", "zOhm": [0.1, 0.2], "worst": 1}]}}, "parts": {"list": [1]}, "vias": [[0, 0]], "issues": []});+        let (lean, d) = split(f.clone());+        assert!(lean["parts"].is_null() && lean["vias"].is_null() && lean["review"]["pdn"]["fHz"].is_null() && lean["review"]["noise"]["cavities"][0].get("grid").is_none());+        let mut back = merge(lean, d); back.as_object_mut().unwrap().remove("detail");+        assert_eq!(back, f);+        let t = to_text(&json!({"a": [{"x": 1}, {"x": 2}], "b": {"c": [1, 2]}})); assert_eq!(serde_json::from_str::<Value>(&t).unwrap(), json!({"a": [{"x": 1}, {"x": 2}], "b": {"c": [1, 2]}})); assert_eq!(t.lines().count(), 7);+    }+}
src/solve.rs+15
@@ -60,6 +60,21 @@ impl System {         }         (x, its, true_res)     }+    /// The same solve started from a guess (the still-air temperatures for the moving-air pass): only the correction+    /// A·dx = b − A·x0 is solved, to the same true relative residual against b. Returns (x, iterations, true residual).+    pub fn solve_from(&self, b: &[f64], x0: &[f64], tol: f64, max_iter: usize) -> (Vec<f64>, usize, f64) {+        if x0.len() != b.len() { return self.solve(b, tol, max_iter); }+        let bnorm = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);+        let mut ax = vec![0.0; b.len()]; self.matvec(x0, &mut ax);+        let r: Vec<f64> = b.iter().zip(&ax).map(|(bi, ai)| bi - ai).collect();+        let rn = r.iter().map(|v| v * v).sum::<f64>().sqrt();+        if rn / bnorm < tol { return (x0.to_vec(), 0, rn / bnorm); }+        let (dx, its, _) = self.solve(&r, (tol * bnorm / rn).min(0.5), max_iter);+        let x: Vec<f64> = x0.iter().zip(&dx).map(|(a, d)| a + d).collect();+        self.matvec(&x, &mut ax);+        let res = b.iter().zip(&ax).map(|(bi, ai)| (bi - ai) * (bi - ai)).sum::<f64>().sqrt() / bnorm;+        (x, its, res)+    }     fn cg(&self, b: &[f64], tol: f64, max_iter: usize) -> (Vec<f64>, usize, f64) {         let n = self.n();         let diag = self.diagonal();
src/ui/app.js+1−1
@@ -527,7 +527,7 @@     if (!(f.heatsinks || []).length && !(f.packageCooling || []).length) hsPanel.textContent='No heatsinks specified';     $('#method').textContent = f.method + ` Solved in ${f.seconds} s on a ${f.board.cellMm} mm grid (${f.board.cols} x ${f.board.rows} cells x ${(f.board.copperLayers || f.board.layers).length} copper layers).`;   }-  async function setView(patch) { const r = await post('ui/view', patch); if (!r) toast('The server did not answer', 'error', true); }+  async function setView(patch) { const r = await post('ui/view', patch); if (!r) toast('The server did not answer', 'error', true); else if (r.status === 'error') toast(r.error + '. ' + (r.hint || ''), 'info', true); }   document.addEventListener('click', e => {     const q = e.target.closest('#qty button, #qty2 button'); if (q) return setView(q.dataset.q === 'energy' || q.dataset.q === 'charge' || q.dataset.q === 'signals' ? { quantity: q.dataset.q, issue: null, explode: Math.max(+state.view.explode || 0, 0.6) } : { quantity: q.dataset.q, issue: null });     const l = e.target.closest('#layer button'); if (l) return setView({ layer: l.dataset.l });
src/verify.rs+7−5
@@ -92,7 +92,9 @@ pub fn apply(text: &str, b0: &Board, plan: &[Value], soft: bool) -> (String, Vec     let mut new_vias = String::new();     let mut widths: Vec<(usize, usize, String)> = Vec::new();     let mut edits = Vec::new();-    for st in plan {+    // a grouped finding carries each item's own action: carry them out one by one under the group's step number+    let plan: Vec<Value> = plan.iter().flat_map(|st| if st["action"]["type"] == "group" { st["action"]["items"].as_array().cloned().unwrap_or_default().into_iter().filter(|it| it["action"].is_object()).map(|it| json!({"step": st["step"], "kind": st["kind"], "action": it["action"], "at": it["at"]})).collect::<Vec<_>>() } else { vec![st.clone()] }).collect();+    for st in &plan {         let a = &st["action"]; let ty = a["type"].as_str().unwrap_or("").to_string();         let mut e = Edit { step: st["step"].as_u64().unwrap_or(0), kind: st["kind"].as_str().unwrap_or("").into(), net: a["net"].as_str().unwrap_or("").into(), kind_action: ty.clone(), applied: Vec::new(), refused: None };         let net = a["net"].as_str().unwrap_or("").to_string();@@ -222,12 +224,12 @@ pub fn run(board: &str, spec: &str, out: &Path, steps: &[u64], pitch: f64, reuse     let name = Path::new(board).file_name().unwrap().to_string_lossy().to_string();     let orig_path = out.join(format!("original-{name}"));     let mut before = match &given { Some(v) if !refill => v.clone(), _ => Value::Null };-    if before.is_null() && !refill { before = crate::run_analyze(board, spec, &out.join("before"), pitch, 60.0, None, true)?; }+    if before.is_null() && !refill { before = crate::run_analyze(board, spec, &out.join("before"), pitch, 60.0, None, true, &["all".to_string()])?; }     let plan_src = given.clone().unwrap_or_else(|| before.clone());     let b = Board::load(board)?;     // with refill and no plan yet, the plan comes from the refilled original's solve (below); edits need it first, so     // solve the board as saved for the plan when nothing else gives one-    let plan_src = if plan_src.is_null() { crate::run_analyze(board, spec, &out.join("plan"), pitch, 60.0, None, true)? } else { plan_src };+    let plan_src = if plan_src.is_null() { crate::run_analyze(board, spec, &out.join("plan"), pitch, 60.0, None, true, &["all".to_string()])? } else { plan_src };     let plan: Vec<Value> = plan_src["fixPlan"].as_array().cloned().unwrap_or_default().into_iter().filter(|s| steps.is_empty() || steps.contains(&s["step"].as_u64().unwrap_or(0))).collect();     if plan.is_empty() { return Err(if steps.is_empty() { "the fix plan is empty: nothing to verify".into() } else { format!("no fix-plan step {:?} (the plan has {})", steps, plan_src["fixPlan"].as_array().map_or(0, |a| a.len())) }); }     let (fixed, edits) = apply(&text, &b, &plan, refill);@@ -246,9 +248,9 @@ pub fn run(board: &str, spec: &str, out: &Path, steps: &[u64], pitch: f64, reuse         let kb: Vec<String> = vb.iter().map(drc_key).collect();         let new: Vec<Value> = va.iter().filter(|v| !kb.contains(&drc_key(v))).map(|v| json!({"type": v["type"], "description": v["description"], "items": v["items"].as_array().into_iter().flatten().map(|i| json!({"what": i["description"], "at": [i["pos"]["x"], i["pos"]["y"]]})).collect::<Vec<_>>()})).collect();         drc = json!({"errorsBefore": vb.len(), "errorsAfter": va.len(), "new": new});-        before = crate::run_analyze(orig_path.to_str().unwrap(), spec, &out.join("before"), pitch, 60.0, None, true)?;+        before = crate::run_analyze(orig_path.to_str().unwrap(), spec, &out.join("before"), pitch, 60.0, None, true, &["all".to_string()])?;     }-    let after = crate::run_analyze(fixed_path.to_str().unwrap(), spec, &out.join("after"), pitch, 60.0, None, true)?;+    let after = crate::run_analyze(fixed_path.to_str().unwrap(), spec, &out.join("after"), pitch, 60.0, None, true, &["all".to_string()])?;     let report = compare(&before, &after, &edits);     let v = json!({"applied": edits.iter().filter(|e| !e.applied.is_empty()).count(), "board": fixed_path.display().to_string(), "refilled": refill, "drc": drc, "before": out.join("before").display().to_string(), "after": out.join("after").display().to_string(),         "note": if refill { "both boards refilled by KiCad before solving; `drc.new` lists errors the fix introduced (none: it is clean to adopt)" } else { "zone fills are as saved: refill the zones and run DRC in KiCad before adopting the fixed board (or run verify with --refill)" }, "report": report});