app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Publish 0.2.25
32 files changed
+3877−196
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+17−3bin/adom-aiflowcrates/adom-aiflow/src/copper_carry.rs+42crates/adom-aiflow/src/gate_eval.rs+58crates/adom-aiflow/src/impedance_cmd.rs+392crates/adom-aiflow/src/main.rs+249−113crates/adom-aiflow/src/preboard.rs+209−25crates/adom-aiflow/src/reviews.rs+500crates/adom-aiflow/src/route_variants.rs+269crates/adom-aiflow/src/skill_hints.rs+1crates/adom-aiflow/src/spec_freeze.rs+1crates/aiflow-router/src/drive.rs+13−5crates/aiflow-router/src/impedance.rs+948crates/aiflow-router/src/lib.rs+81−3crates/aiflow-router/src/rules.rs+16−1crates/aiflow-router/src/variants.rs+207docs/impedance.md+122docs/mechanical-wiring-reviews.md+136flows/board.json+48package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+17−3skills/aiflow-comparison-video/SKILL.md+4skills/aiflow-power-layout/SKILL.md+1skills/aiflow-sourcing/SKILL.md+21−6tests/impedance-workflow.py+87tests/review-workflow.py+87tests/route-variants-workflow.py+87tests/sourcing-suggestions.py+80−24tools/impedance_reference.py+171Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.24"+version = "0.2.25" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.24"+version = "0.2.25" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.24"+version = "0.2.25" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.24"+version = "0.2.25" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.24"+version = "0.2.25" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.24"+version = "0.2.25" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.24"+version = "0.2.25" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.24"+version = "0.2.25" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.24"+version = "0.2.25" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.24"+version = "0.2.25" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.24"+version = "0.2.25" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+17−3@@ -50,6 +50,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via or trace already on the live board (same net, same points) is not landed twice, so a rerun after a stop resumes where it stopped (`--from <n>` starts at trace n), a reply lost to the transport is answered by reading the board and sending that one trace once more only if it did not land, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. A via whose copper would touch another net's track, via or pad (planned or on the board) is refused before anything lands, and so is a via within the spec's hole-to-hole minimum (`holeToHole`, default 0.2 mm) of any via or hole, or within `clearance` of another via's copper, whatever its net (the ERROR names the clash), and every landed via is read back: one that came back on another net than planned is taken back and the command errors with both nets. 4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. Filled zones are that copper too: a pad inside a fill of its net on its layer (a through-hole pad on either layer) is joined, so a net its pours already join is skipped and named in the note. A net with a zone that has no fill in the board file is still tracked, with a WARNING to refill the zones and route again. With `planes`, a pad on the plane's own outer layer (an F.Cu pad on an F.Cu plane) sits in the pour and gets no stub or via; through-hole pads reach any plane. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+ **`route --variants N`** (issue #55, at most 8): route the same board N times with different levers and let the gates pick. The levers are the ones kicad-place-route-loop lists, in a fixed order so a rerun gives the same variants: v1 is a plain `route` (shortest nets first, the spec's vias, `--passes`), then longest first, signal vias at the spec's power via size (left out when the spec's two vias are the same), most pads first, seeded orders (seed 1, 2), shortest first with 4 more passes, longest first with power-size vias and 4 more passes; more than that are more seeds. Each variant writes `plan-route-v<k>.json`, `plan-route-v<k>-report.json` and `router-v<k>.log`, and is gated with the checks `gate` runs (the same function, routed board `board-routed-v<k>.kicad_pcb`). The ranking uses the gates alone, in order: unrouted connections; gate issues (new DRC errors, unconnected, planeUnder, switch node, courtyards and bodies, decoupling errors); copper length of the plan; via count; then the failed current and thermal screens, only when a Fields solve of that exact routed board is on the run (a Fields solve per variant is not run: it takes minutes per board, so in practice this column is empty and `analyze` runs on the winner as usual); the lower variant number breaks a full tie. Without kicad-cli the variants are ranked without the gate column and the table says so. The winner becomes `plan-route.json` exactly as a plain route would, and every variant (levers, scores, plan paths, rank) is on the route outcome under `variants`, in the ledger as `route-variants`, and in the run README as a table. **`route --pick K --why "..."`** makes variant K the route plan instead (the board must be unchanged); the reason, the thread and the gates' own pick stay on the record. Then `gate` as usual. A plain `route` is unchanged. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again.@@ -57,12 +58,14 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- ### High-speed boards: your own router, inside the flow -A board with differential pairs, impedance control or length matching (PCIe, OCuLink, USB 3, Ethernet) is beyond the grid router, so you route it yourself, but the work still happens inside the flow. Declare each step as you reach it (`step rules`, `step components`, ... `step routing`); your own schematic, placement or router scripts do the step's work, they do not replace the step. For routing:+Impedance targets go in the spec before routing: `"impedanceTargets": {"USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90}, "RF": {"net": "RF_OUT", "targetOhms": 50}}` (`tolerancePercent` 10, `gapMm`, `widthMm`, `layer`, `maxSkewMm` 0.5, `maxUncontrolledMm` 3 optional), with the stackup from spec `stackupMm` and `epsilonR` or the board's saved physical stackup. `impedance table` prints the width (and pair gap) per copper layer from closed-form formulas (Hammerstad-Jensen, Wheeler, Cohn, Garg-Bahl; within 0.2 to 3.4 % of a 2-D field solve, no solder mask) and refuses a layer the target cannot reach within the fab minimums. `route` then sizes those nets, holds each to one outer layer and routes a pair's second net along the first at the solved pitch; it prints the coupling it achieved and names any pair it could not keep together for `take route=ai`. `analyze impedance` judges the landed copper (and a same-input Fields Signals or `--with si` solve beside it); `finish` requires it. docs/impedance.md has the formulas, their measured accuracy and what the router really does.++A simple pair (one USB 2 pair, a 50 ohm feed) can go through `route` this way. A board with several pairs, length-matched lane groups or layer changes (PCIe, OCuLink, USB 3, Ethernet), or a pair `route` flagged, is beyond the grid router, so you route it yourself, but the work still happens inside the flow. Declare each step as you reach it (`step rules`, `step components`, ... `step routing`); your own schematic, placement or router scripts do the step's work, they do not replace the step. For routing: 1. `take route=ai`, then `step routing` and `stage start route`. 2. Run your own router (wrap it as `adom-aiflow exec -- python3 my_router.py ...` so its time is measured), write its result in the plan JSON shape, then `gate --plan <plan.json>` and `land route --plan <plan.json>`; `land route` is the routing step's proof. 3. `stage end route`.-4. Record the numbers: `evidence add --kind calcs --file design/impedance.json` with the stackup, the trace width and gap per layer for the target impedance, the propagation delay per layer, and the skew per pair and per lane group after routing.+4. Record the numbers: `evidence add --kind calcs --file design/impedance.json` with the stackup, the trace width and gap per layer for the target impedance (`impedance table --json` gives them), the propagation delay per layer, and the skew per pair and per lane group after routing. With `impedanceTargets` in the spec, `analyze impedance` judges your landed pairs the same way it judges the router's. The plan JSON shape (full reference: docs/plan-schema.md): @@ -200,7 +203,7 @@ wrong part at its component page, not just on this board. The clock starts at the prompt even when there is no board yet. - **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug> [--page-dir <folder>]]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.-- **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). A profile states preferences, never a gate: any distributor is fine on any profile (DigiKey, Arrow, LCSC, ...), because every fab takes consigned (customer-supplied) parts. `fab` (the 3rd party fab) prefers Mouser or Adom stock; `jlcpcb` prefers JLCPCB/LCSC parts with their LCSC number. Rows from anywhere else pass with one warning that lists them as consigned parts, and under it one suggestion per row: the check looks the exact MPN up at the preferred source with `adom-parts-search search` (one search per distinct MPN, at most 25 per check, cached in the run folder as sourcing-search-cache.json) and says to switch the source when it is in stock there, or how to look for an equivalent. `AIFLOW_NO_PART_SEARCH=1` skips the searches. The outcome records `consignedRows` and `suggestions`. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff.+- **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). A profile states preferences, never a gate: any distributor is fine on any profile (DigiKey, Arrow, LCSC, ...), because every fab takes consigned (customer-supplied) parts. `fab` (the 3rd party fab) prefers Mouser or Adom stock; `jlcpcb` prefers JLCPCB/LCSC parts with their LCSC number. Rows from anywhere else pass with one warning that lists them as consigned parts, and under it one suggestion per row naming the preferred source's number and stock, and where each came from. Per distinct MPN it reads the wiki component page first (`component.distributor_links` in `adom/<mpn>` page.json), then the pnp-inventory vendor stock cache (when that CLI is installed), and only then runs a live `adom-parts-search search` for what is still missing (at most 25 live searches per check). Page and cache answers are cached in the run folder as sourcing-lookup-cache.json, live results as sourcing-search-cache.json. A stock count from a page or the cache shows its date; with no date it says "stock not recorded" and how to check it live. `AIFLOW_NO_PART_SEARCH=1` skips the live searches; `AIFLOW_NO_PART_LOOKUP=1` skips every lookup. The outcome records `consignedRows`, `suggestions` and `suggestionLookups` (page reads, cache hits and live searches, counted apart). The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`. @@ -295,6 +298,7 @@ AI Flow's binary is the instrument, the gates and the hands; the judgement for e | a vendor login, account or verification code | `aiflow-credentials` | | two-layer power layout: hot loop, SW node, Kelvin taps, thermal vias | `aiflow-power-layout` | | silkscreen | `aiflow-silkscreen` |+| service fasteners, access holes, harnesses and connectors (declared `mechanicalReview` / `wiringReview`) | `aiflow-sourcing` (declare), `aiflow-power-layout` (placement), docs/mechanical-wiring-reviews.md | | after every step: the clip goes on the project page | `aiflow-live-clips` | | after `finish`: the molecule on a scaffold wired to the control panel | `aiflow-scaffold-probe` | | metrics and comparisons | `aiflow-measurement`, `aiflow-comparison-video` |@@ -609,3 +613,13 @@ Every LED on the board is described in `led-states.json` beside the board, and l `silkscreen-audit --record` and `finish` print one line per label check on every run, a pass included (`power inputs: 0 found`, `power inputs: 1 (J1 VIN), all labelled with a voltage`, `test points: ...`), and record `powerInputs` and `testPoints` (`found`, `labelled`, `unlabelled`, `mismatched`, `items`) in the audit's result JSON, the `silkscreen` outcome and the `finish-checks` outcome. `finish` also prints `LED states: no LEDs on the board: led-states.json not required` (or what it found) and `programming: no programmable part: programming not required` (or the parts and interface). For each LED part, add `led.json` to its wiki component page when you may edit that page: `{"$schema": "adom/led-part@1", "colour": {"name", "dominant_nm", "hex"}, "vf_V": {"typ", "max", "at_mA"}, "if_max_mA", "viewing_deg", "glb_lens_material": "<material or mesh name of the lens in the part's GLB>"}`. When you may not edit the page, open an issue on it carrying that record. Never edit someone else's page.++## Mechanical service-access and wiring reviews (issue #60)++Optional steps, required only when the spec (or requirements) declares them: `"mechanicalReview": {"schemaVersion": 1, "input": "<review.json>", "refs": ["H1", "J3"]}` and `"wiringReview": {"schemaVersion": 1, "input": "<wiring.json>", "refs": ["J9"], "glb": "<assembly.glb>"}`. Declare them during component selection (aiflow-sourcing) and keep the review file current during placement (aiflow-power-layout). The run then has `mechanical-review` / `wiring-review` just before `finish`.++- `step mechanical-review`, then `adom-aiflow review mechanical` (or `review wiring`, `--input` and `--glb` to override). It runs `adom-aiflow-mechanical lint` / `adom-aiflow-wiring lint` (plus `audit-glb` when a GLB is declared) and records the passing result with the sha256 of the review file, the spec and the board, the bound connectors and holes, and the tool's identity. A failing run records nothing and lists the findings. The input file carries `"schemaVersion": 1`.+- The tools are unreleased MIT prototypes with no wiki package: `adom-wiki repo clone adom/adom-aiflow-mechanical --dir <d>` (and adom/adom-aiflow-wiring), then `cargo build --release` and put `target/release` on PATH, or set `AIFLOW_MECHANICAL_SRC` / `AIFLOW_WIRING_SRC=<d>`. Without one, `review` refuses with this hint and finish stays blocked.+- `finish` refuses a declared review that is missing, failed, or stale (the review file, spec or GLB changed, or the board is not the reviewed one unless the copper carry records prove the copper is unchanged).+- `adopt-board` and `place check` compare the named connectors and every NPTH or mounting hole between the old and new board. A change removes the review outcomes and the test plan outcome (`serviceInvalidated` in run.json); routing, gate, landings, measure, current and thermal are already voided by the copper change. Run `testplan check` and the reviews again.+- The tools check declared completeness, dimensions and lengths. They are not swept-volume or collision solvers; a pass is not physical-fit evidence. Tool animations are optional. Not declared: nothing changes.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/copper_carry.rs+42@@ -198,6 +198,8 @@ pub fn adopt(r: &mut Run, dest: &str) -> (bool, String) { } } };+ // issue #60: a moved connector or hole also voids the declared mechanical/wiring reviews and the test plan+ let note = match crate::reviews::void_on_service_change(r, Path::new(&old), Path::new(dest)) { Some(s) => format!("{note}; {s}"), None => note }; let mut dropped = Vec::new(); if let Some(o) = r.data["outcomes"].as_object_mut() { for k in SILK_OUTCOMES {@@ -419,6 +421,46 @@ pub fn field_screen_with_acks(data: &Value, report: &Value, spec: &Value, board: screen(&r).map(|c| (Some(chain_note(&format!("Fields {kind} report"), &chain)), c)) } +/// Issue #60: the footprints a mechanical or wiring review depends on, each as its canonical+/// copper-relevant form (the same `footprint` canon the copper fingerprint hashes: placement, side,+/// pads with drill and nets, outline-relevant graphics), hashed per footprint. A footprint counts+/// when its reference is in `refs`, starts with one of `prefixes` followed by a digit, has an NPTH+/// pad, or is a mounting hole (library name containing "MountingHole"). Sorted by reference.+pub fn service_features(board: &Path, refs: &[String], prefixes: &[String]) -> Result<Vec<Value>, String> {+ let mut text = std::fs::read_to_string(board).map_err(|e| format!("{}: {e}", board.display()))?;+ if aiflow_board::eagle::is_eagle(&text) { text = aiflow_board::eagle::to_kicad(&text)?; }+ let root = sx::parse(&text)?;+ if root.tag != "kicad_pcb" { return Err(format!("{} is not a kicad_pcb", board.display())); }+ let mut out = Vec::new();+ for fp in root.find_all("footprint") {+ let reference = aiflow_board::reference_of(fp).unwrap_or_default();+ let lib = fp.atom(1).unwrap_or("").to_string();+ let npth = fp.find_all("pad").any(|p| p.atom(2) == Some("np_thru_hole"));+ let hole = npth || lib.to_ascii_lowercase().contains("mountinghole");+ let named = refs.iter().any(|r| *r == reference);+ let prefixed = prefixes.iter().any(|p| reference.strip_prefix(p.as_str()).is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())));+ if !(hole || named || prefixed) { continue; }+ let canon = footprint(fp).to_string();+ let at: Vec<f64> = fp.find("at").map(|a| (1..=3).filter_map(|i| a.number(i)).collect()).unwrap_or_default();+ out.push(json!({"ref": reference, "lib": lib, "hole": hole, "named": named || prefixed, "at": at,+ "layer": fp.find("layer").and_then(|l| l.value()).unwrap_or(""), "sha256": format!("{:x}", Sha256::digest(canon.as_bytes()))}));+ }+ out.sort_by(|a, b| (a["ref"].as_str(), a["lib"].as_str(), a["sha256"].as_str()).cmp(&(b["ref"].as_str(), b["lib"].as_str(), b["sha256"].as_str())));+ Ok(out)+}++/// The references whose service feature differs between two `service_features` lists: moved,+/// rotated, re-sided, re-padded, added or removed. Unreferenced holes are named by library and position.+pub fn service_changes(old: &[Value], new: &[Value]) -> Vec<String> {+ let name = |f: &Value| { let r = f["ref"].as_str().unwrap_or(""); if r.is_empty() { format!("{} at {}", f["lib"].as_str().unwrap_or("?"), f["at"]) } else { r.to_string() } };+ let mut out: Vec<String> = Vec::new();+ for f in old.iter().filter(|f| !new.iter().any(|n| n["sha256"] == f["sha256"] && n["ref"] == f["ref"])) { out.push(name(f)); }+ for f in new.iter().filter(|f| !old.iter().any(|o| o["sha256"] == f["sha256"] && o["ref"] == f["ref"])) { out.push(name(f)); }+ out.sort();+ out.dedup();+ out+}+ /// A silkscreen proof was recorded and then dropped by an adoption: finish needs it again. pub fn silk_dropped(data: &Value) -> bool { !data["outcomeAt"]["silkscreen"].is_null() && data["outcomes"].get("silkscreen").is_none()
crates/adom-aiflow/src/gate_eval.rsadded+58@@ -0,0 +1,58 @@+//! The offline gate's checks on one plan, as a function: `gate` runs it on the run's plan and+//! `route --variants` (route_variants.rs) runs the same checks on every variant's plan, so a+//! variant is scored by exactly what `gate` would say about it.+use std::path::Path;++use aiflow_board::Board;+use aiflow_run::Run;+use serde_json::{json, Value};++use crate::{decoupling_check, mechanical, silk_policy, switchnode};++pub struct GateEval {+ /// gate.json as `gate` writes it (before the run-level notes)+ pub s: Value,+ pub pu: Vec<aiflow_router::rules::PlaneUnder>,+ pub pu_issues: Vec<String>,+ pub sw_issues: Vec<String>,+ pub dec: Option<decoupling_check::Review>,+ pub mech: Option<mechanical::Report>,+ pub stairs: Vec<String>,+}++/// Apply `plan_v` (already normalised) to the run's board `b`, write the routed board to `routed`+/// with the project rules, run KiCad DRC on it against `base` (the DRC of the run's current board)+/// and the layer, switch-node, decoupling, courtyard and staircase checks. `notes` prints the+/// switch-node note as `gate` does.+pub fn evaluate(r: &Run, b: &Board, plan_v: &Value, routed: &Path, base: &Value, notes: bool) -> Result<GateEval, String> {+ let spec_g = crate::spec_of(r);+ // an empty plan adds no copper: the board's own zone fills stand, a raster refill would only invent errors+ let adds_copper = plan_v["nets"].as_array().map(|a| !a.is_empty()).unwrap_or(true);+ let (text, stats) = aiflow_copper::apply(b, plan_v, adds_copper && !aiflow_copper::native_drc_available()).map_err(|e| format!("plan does not apply to the board: {e}"))?;+ std::fs::write(routed, &text).map_err(|e| e.to_string())?;+ let routed_s = routed.display().to_string();+ aiflow_copper::copy_project_rules(&r.current_board(), &routed_s)?;+ let data = crate::run_drc_for(r, &routed_s)?;+ let mut s = aiflow_copper::summarize_with(&data, Some(base), silk_policy::blocking(&spec_g, &r.current_board()));+ s["copper"] = stats;+ s["file"] = json!(routed_s);+ let rb = || Board::from_text(&routed_s, text.clone());+ // the layer policy: other-net copper inside a planeUnder region is an issue like a DRC error+ let pu = aiflow_router::rules::PlaneUnder::from_spec(&spec_g);+ let pu_issues: Vec<String> = if pu.is_empty() { Vec::new() } else {+ let rb = rb()?;+ match aiflow_router::plane_under::regions(&rb, &pu) { Ok(regs) => aiflow_router::plane_under::issues(&rb, ®s), Err(e) => vec![format!("spec.planeUnder: {e}")] }+ };+ s["planeUnderIssues"] = json!(pu_issues);+ // the switch node: short, and never under another part (switchnode.rs)+ let sw_issues = { let rb = rb()?; if notes { if let Some(n) = switchnode::note(&spec_g, &rb) { eprintln!("{n}"); } } switchnode::findings(&spec_g, &rb) };+ s["switchNodeIssues"] = json!(sw_issues);+ let dec = decoupling_check::review_text("gate", &spec_g, &routed_s, &text, !adds_copper, if adds_copper && !aiflow_copper::native_drc_available() { "an approximate raster fill by the gate, not a native refill" } else { "the zone fills saved in the board file" }, plan_v, None, r.outcomes().get("pour"));+ if let Some(d) = &dec { s["decoupling"] = d.value.clone(); }+ // issue #39: courtyards and bodies (aiflow's own geometry), staircases left in the copper (a warning)+ let mech = if mechanical::applies(&r.current_board()) { let rb = rb()?; Some(mechanical::check_board(&rb, &routed_s, &crate::project_dir_of(r), &spec_g)) } else { None };+ let stairs = rb().map(|rb| aiflow_router::simplify::board_staircases(&rb)).unwrap_or_default();+ s["mechanical"] = mech.as_ref().map(|m| m.to_json()).unwrap_or(json!("not checked: Fusion board"));+ s["staircases"] = json!(stairs);+ Ok(GateEval { s, pu, pu_issues, sw_issues, dec, mech, stairs })+}
crates/adom-aiflow/src/impedance_cmd.rsadded+392@@ -0,0 +1,392 @@+//! Impedance targets in the flow (issue #52): `impedance table`, the router's sizing of target nets,+//! `analyze impedance` and the finish check. The formulas, the table and the copper check live in+//! aiflow_router::impedance; this file reads the spec, the board's fab rules and an Adom Fields+//! report, and says what it found.+use std::collections::BTreeMap;+use std::path::Path;++use aiflow_board::Board;+use aiflow_router::impedance::{self as imp, Fab, GridPair, Stackup, Target};+use serde_json::{json, Value};++/// The manufacturing floor a width or gap is held to: the board's `.kicad_dru` (its track_width+/// and clearance minimums), the spec's `minTrackWidth`, and the board clearance the router and the+/// DRC hold every gap to (spec `clearance`, else the router's 0.2 mm default).+pub fn fab_of(spec: &Value, board_path: &str, rules: &aiflow_router::Rules) -> Fab {+ let dru = std::fs::read_to_string(Path::new(board_path).with_extension("kicad_dru")).ok().map(|t| aiflow_bridge::eda::fab_numbers(&t));+ let dru_w = dru.as_ref().and_then(|d| d["trackWidth"]["min"].as_f64());+ let dru_s = dru.as_ref().and_then(|d| d["clearance"].as_f64());+ let spec_w = spec.get("minTrackWidth").and_then(|v| v.as_f64());+ let mut src = Vec::new();+ let min_width = match (dru_w, spec_w) {+ (Some(a), Some(b)) => { src.push("kicad_dru track_width and spec minTrackWidth"); a.max(b) }+ (Some(a), None) => { src.push("kicad_dru track_width"); a }+ (None, Some(b)) => { src.push("spec minTrackWidth"); b }+ (None, None) => { src.push("0.127 mm track default (no .kicad_dru, no minTrackWidth)"); 0.127 }+ };+ let min_space = dru_s.unwrap_or(0.0).max(rules.clearance);+ src.push(if dru_s.map(|d| d > rules.clearance).unwrap_or(false) { "kicad_dru clearance" } else { "board clearance (spec clearance or the router default)" });+ Fab { min_width, min_space, source: src.join("; ") }+}++/// The router's pair geometry for the nets of a target (the larger of the board and net clearances).+pub fn grid_of(rules: &aiflow_router::Rules, nets: &[String]) -> GridPair {+ let clr = nets.iter().filter_map(|n| rules.net_clearance.get(n)).fold(rules.clearance, |a, b| a.max(*b));+ GridPair { res: aiflow_grid::RES, clearance: clr, slop: aiflow_router::slop() }+}++pub struct Plan {+ pub stack: Stackup,+ pub targets: Vec<Target>,+ pub fab: Fab,+ pub rows: Vec<Value>,+}++/// The stackup, the targets and the table for a board and spec. Ok(None) when the spec names no+/// impedance target.+pub fn plan(board: &Board, spec: &Value, board_path: &str) -> Result<Option<Plan>, String> {+ let targets = imp::targets(spec)?;+ if targets.is_empty() { return Ok(None); }+ for t in &targets {+ for n in &t.nets {+ if !board.nets.contains_key(n) { return Err(format!("impedance target {}: net {n} is not on the board", t.name)); }+ }+ }+ let stack = imp::stackup(board, spec)?;+ let rules = aiflow_router::Rules::from_spec(spec);+ let fab = fab_of(spec, board_path, &rules);+ let mut rows = Vec::new();+ for t in &targets {+ let grid = grid_of(&rules, &t.nets);+ rows.extend(imp::table(&stack, std::slice::from_ref(t), &fab, Some(&grid)));+ }+ Ok(Some(Plan { stack, targets, fab, rows }))+}++/// The table as text: one line per target and layer.+pub fn table_lines(p: &Plan) -> Vec<String> {+ let mut out = Vec::new();+ for r in &p.rows {+ let t = r["target"].as_str().unwrap_or("");+ let what = if r["differential"] == true { format!("{} ohm diff", r["targetOhms"]) } else { format!("{} ohm", r["targetOhms"]) };+ let geo = match &r["dielectricMm"] { Value::Array(a) => format!("{} between {} / {} mm", r["structure"].as_str().unwrap_or(""), a[0], a[1]), h => format!("{} over {h} mm", r["structure"].as_str().unwrap_or("")) };+ let size = if r["widthMm"].is_null() { String::new() } else if r["gapMm"].is_null() { format!("width {} mm", r["widthMm"]) } else { format!("width {} mm gap {} mm", r["widthMm"], r["gapMm"]) };+ let state = if r["ok"] == true { format!("{size} -> {} ohm", r["ohms"]) } else { format!("REFUSED: {}", r["why"].as_str().unwrap_or("")) };+ out.push(format!("{t} {what} on {} ({geo}, er {}, copper {} um): {state}", r["layer"].as_str().unwrap_or(""), r["epsilonR"], r["copperUm"]));+ }+ out+}++/// The outer layer the grid router routes a target on: the target's `layer`, else the side every+/// SMD pad of its nets is on, else F.Cu, falling back to the other outer layer when the first is+/// refused. Err names the target when neither outer layer can hit it.+pub fn router_layer<'a>(p: &'a Plan, t: &Target, board: &Board) -> Result<&'a Value, String> {+ let row = |l: &str| p.rows.iter().find(|r| r["target"] == t.name.as_str() && r["layer"] == l);+ let smd: Vec<&aiflow_board::Pad> = board.pads.iter().filter(|x| t.nets.contains(&x.net) && !x.thru).collect();+ let first = if let Some(l) = &t.layer { l.clone() } else if !smd.is_empty() && smd.iter().all(|x| x.on_b && !x.on_f) { "B.Cu".into() } else { "F.Cu".into() };+ if !matches!(first.as_str(), "F.Cu" | "B.Cu") {+ return Err(format!("{}: layer {first} is an inner layer; the grid router routes F.Cu and B.Cu only", t.label()));+ }+ let order: Vec<String> = if t.layer.is_some() { vec![first] } else { vec![first.clone(), if first == "F.Cu" { "B.Cu".into() } else { "F.Cu".into() }] };+ let mut why = Vec::new();+ for l in &order {+ match row(l) { Some(r) if r["ok"] == true => return Ok(r), Some(r) => why.push(format!("{l}: {}", r["why"].as_str().unwrap_or(""))), None => why.push(format!("{l}: not a copper layer of this board")) }+ }+ Err(format!("{} cannot be routed at {} ohm{} on an outer layer: {}", t.label(), t.ohms, if t.diff { " differential" } else { "" }, why.join("; ")))+}++/// `route`: size every target net from the table and hold it to its layer. Returns the routing+/// choices for the route report (None when the spec has no target).+pub fn for_route(board: &Board, spec: &Value, board_path: &str, rules: &mut aiflow_router::Rules) -> Result<Option<Value>, (String, Vec<String>)> {+ let p = match plan(board, spec, board_path) { Ok(Some(p)) => p, Ok(None) => return Ok(None), Err(e) => return Err((format!("impedance: {e}"), vec![HINT_SPEC.into()])) };+ let mut chosen = Vec::new();+ for t in &p.targets {+ let r = router_layer(&p, t, board).map_err(|e| (format!("impedance: {e}"), vec![+ "`adom-aiflow impedance table` shows every layer and why it is refused. Change the target's tolerance, gapMm or widthMm, the stackup (stackupMm) or the fab rules, or route this board yourself: `adom-aiflow take route=ai` (High-speed boards in SKILL.md).".into()]))?;+ let layer = r["layer"].as_str().unwrap_or("F.Cu").to_string();+ let w = r["widthMm"].as_f64().unwrap_or(0.25);+ for (k, n) in t.nets.iter().enumerate() {+ rules.impedance.insert(n.clone(), aiflow_router::rules::ImpedanceRoute { target: t.name.clone(), width: w, layer: layer.clone(), partner: if t.diff { Some(t.nets[1 - k].clone()) } else { None }, pitch: if t.diff { r["widthMm"].as_f64().zip(r["gapMm"].as_f64()).map(|(a, b)| a + b) } else { None } });+ }+ chosen.push(r.clone());+ }+ Ok(Some(json!({"routed": chosen, "fab": {"minWidthMm": p.fab.min_width, "minSpaceMm": p.fab.min_space, "source": p.fab.source}, "stackup": p.stack.sources})))+}++/// Tracks and vias of the target nets: the board's own copper for a net that has any (landed),+/// else the plan's.+fn copper(board: &Board, plan: Option<&Value>, targets: &[Target]) -> (Vec<aiflow_board::Track>, Vec<aiflow_board::Via>, BTreeMap<String, &'static str>) {+ let bt = board.copper_tracks();+ let bv = board.vias();+ let (pt, pv) = plan.map(|p| imp::plan_tracks(p, board)).unwrap_or_default();+ let (mut tracks, mut vias, mut from) = (Vec::new(), Vec::new(), BTreeMap::new());+ for n in targets.iter().flat_map(|t| t.nets.iter()) {+ if bt.iter().any(|t| &t.net == n) {+ tracks.extend(bt.iter().filter(|t| &t.net == n).cloned());+ vias.extend(bv.iter().filter(|v| &v.net == n).cloned());+ from.insert(n.clone(), "board");+ } else {+ tracks.extend(pt.iter().filter(|t| &t.net == n).cloned());+ vias.extend(pv.iter().filter(|v| &v.net == n).cloned());+ from.insert(n.clone(), "plan");+ }+ }+ (tracks, vias, from)+}++/// Judge every target on its copper.+pub fn check(board: &Board, spec: &Value, board_path: &str, plan: Option<&Value>) -> Result<Option<(Plan, Vec<Value>)>, String> {+ let Some(p) = self::plan(board, spec, board_path)? else { return Ok(None) };+ let (tracks, vias, from) = copper(board, plan, &p.targets);+ let rows = p.targets.iter().map(|t| { let mut c = imp::check_target(t, &p.stack, &tracks, &vias, &board.pads); c["copperFrom"] = json!(t.nets.iter().map(|n| from.get(n).copied().unwrap_or("")).collect::<Vec<_>>()); c }).collect();+ Ok(Some((p, rows)))+}++/// The plane net on a layer (spec `planes` {net: layer}), else GND, for a Fields Signals definition.+fn reference_net(spec: &Value, layer: &str) -> String {+ spec.get("planes").and_then(|v| v.as_object()).and_then(|m| m.iter().find(|(_, l)| l.as_str() == Some(layer)).map(|(n, _)| n.clone())).unwrap_or_else(|| "GND".into())+}++/// The `signals.differentialPairs` entry adom-fields needs to screen a pair on the layer it was+/// routed on (outer microstrip only, as Fields' Signals requires).+pub fn signals_entry(t: &Target, st: &Stackup, layer: &str, spec: &Value) -> Option<Value> {+ let g = st.layers.iter().find(|g| g.name == layer && g.h2.is_none())?;+ let rl = g.reference.first()?.clone();+ Some(json!({"name": t.name, "positive": t.nets[0], "negative": t.nets[1], "layer": layer, "referenceNet": reference_net(spec, &rl), "referenceLayer": rl, "dielectricMm": g.h1, "epsilonR": g.er, "maxSkewMm": t.max_skew, "targetOhms": t.ohms, "tolerancePercent": t.tolerance}))+}++/// Compare with a same-input Adom Fields report: Signals' screening estimate of each pair+/// (`signals.differentialPairs`) and, with `--with si`, the 2-D cross-section solve+/// (`review.impedance`). A Fields value outside the target's tolerance fails the target too; a+/// pair Fields refused to estimate is reported, never replaced by the closed form.+pub fn compare_fields(rows: &mut [Value], targets: &[Target], fields: &Value) -> Vec<String> {+ let mut notes = Vec::new();+ for (row, t) in rows.iter_mut().zip(targets) {+ let tol = t.ohms * t.tolerance / 100.0;+ let mut f = serde_json::Map::new();+ let mut fail: Vec<String> = Vec::new();+ if t.diff {+ let sig = fields["signals"]["differentialPairs"].as_array().into_iter().flatten().find(|p| {+ let d = &p["definition"];+ (d["positive"] == t.nets[0].as_str() && d["negative"] == t.nets[1].as_str()) || (d["positive"] == t.nets[1].as_str() && d["negative"] == t.nets[0].as_str())+ });+ if let Some(s) = sig {+ f.insert("signals".into(), json!({"name": s["name"], "status": s["status"], "differentialOhms": s["differentialOhms"], "gapMm": s["gapMm"], "skewMm": s["skewMm"], "warnings": s["warnings"]}));+ match s["differentialOhms"].as_array().and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))) {+ Some((lo, hi)) => {+ if (lo - t.ohms).abs() > tol || (hi - t.ohms).abs() > tol { fail.push(format!("Fields Signals estimates {lo:.1} to {hi:.1} ohm differential, outside {} +-{} %", t.ohms, t.tolerance)); }+ notes.push(format!("{}: closed form {} ohm, Fields Signals {lo:.1} to {hi:.1} ohm ({})", t.label(), row["ohms"], s["status"].as_str().unwrap_or("")));+ }+ None => notes.push(format!("{}: Fields Signals did not estimate it ({}): {}", t.label(), s["status"].as_str().unwrap_or(""), s["warnings"].as_array().map(|w| w.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("; ")).unwrap_or_default())),+ }+ }+ let xs = fields["review"]["impedance"]["pairs"].as_array().into_iter().flatten().find(|p| {+ let a: Vec<&str> = p["pair"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect()).unwrap_or_default();+ a.len() == 2 && a.contains(&t.nets[0].as_str()) && a.contains(&t.nets[1].as_str())+ });+ if let Some(x) = xs {+ f.insert("crossSection".into(), json!({"zdiff": x["zdiff"], "widthMm": x["widthMm"], "gapMm": x["gapMm"], "layer": x["layer"]}));+ if let Some(z) = x["zdiff"].as_f64() {+ if (z - t.ohms).abs() > tol { fail.push(format!("Fields cross-section solve gives {z:.1} ohm differential, outside {} +-{} %", t.ohms, t.tolerance)); }+ notes.push(format!("{}: closed form {} ohm, Fields 2-D solve {z:.1} ohm (with solder mask)", t.label(), row["ohms"]));+ }+ }+ } else if let Some(n) = fields["review"]["impedance"]["nets"].as_array().into_iter().flatten().find(|n| n["net"] == t.nets[0].as_str()) {+ let z: Vec<f64> = n["segments"].as_array().into_iter().flatten().filter_map(|s| s["z0"].as_f64()).collect();+ f.insert("crossSection".into(), json!({"segments": n["segments"]}));+ // Fields lists every width the net has (escape stubs too): judge the longest segment group+ if let Some(main) = n["segments"].as_array().into_iter().flatten().max_by(|a, b| a["lengthMm"].as_f64().unwrap_or(0.0).total_cmp(&b["lengthMm"].as_f64().unwrap_or(0.0))) {+ let z0 = main["z0"].as_f64().unwrap_or(0.0);+ if (z0 - t.ohms).abs() > tol { fail.push(format!("Fields cross-section solve gives {z0:.1} ohm on its longest run, outside {} +-{} %", t.ohms, t.tolerance)); }+ }+ notes.push(format!("{}: closed form {} ohm, Fields 2-D solve {:?} ohm", t.label(), row["ohms"], z));+ }+ if f.is_empty() { continue; }+ row["fields"] = Value::Object(f);+ if !fail.is_empty() {+ row["status"] = json!("fail");+ let mut r: Vec<Value> = row["reasons"].as_array().cloned().unwrap_or_default();+ r.extend(fail.into_iter().map(Value::from));+ row["reasons"] = json!(r);+ }+ }+ notes+}++/// The Fields report when it is a solve of exactly these board and spec bytes.+pub fn same_input_fields(path: &Path, board_file: &str, spec_file: &str) -> Result<Value, String> {+ use sha2::{Digest, Sha256};+ let f = aiflow_analyze::fields_report::load(path)?;+ for (key, file) in [("board", board_file), ("spec", spec_file)] {+ let bytes = std::fs::read(file).map_err(|e| format!("{file}: {e}"))?;+ if f["inputIdentity"][key].as_str() != Some(format!("{:x}", Sha256::digest(&bytes)).as_str()) {+ return Err(format!("{} is not a solve of this {key} (its inputIdentity differs): solve again on the saved board and spec", path.display()));+ }+ }+ Ok(f)+}++/// One line per checked target.+pub fn check_lines(rows: &[Value], targets: &[Target]) -> Vec<String> {+ rows.iter().zip(targets).map(|(c, t)| {+ let mut s = format!("{} {}: {} ohm on {}", t.label(), c["status"].as_str().unwrap_or(""), c["ohms"], c["lengthByLayerMm"]);+ if t.diff { s.push_str(&format!(", gap {} mm, coupled {}% of each net, skew {} mm", c["gapMm"], c["coupledPercent"], c["skewMm"])); }+ s.push_str(&format!(", uncontrolled {} mm (copper from the {})", c["uncontrolledMm"], c["copperFrom"].as_array().and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("board")));+ let why: Vec<&str> = c["reasons"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+ if !why.is_empty() { s.push_str(&format!(": {}", why.join("; "))); }+ s+ }).collect()+}++/// After `route`: judge the plan's copper and record it in the route report. Returns lines for the+/// OK (or WARNING) message: a pair the grid router did not keep coupled is named for `take route=ai`.+pub fn after_route(choice: &Option<Value>, board: &Board, spec: &Value, board_path: &str, plan_doc: &Value, report: &mut Value) -> Vec<String> {+ let Some(choice) = choice else { return Vec::new() };+ let Ok(Some((p, rows))) = check(board, spec, board_path, Some(plan_doc)) else { return Vec::new() };+ let mut lines = Vec::new();+ for (r, c) in p.targets.iter().zip(&rows) {+ let ch = choice["routed"].as_array().into_iter().flatten().find(|x| x["target"] == r.name.as_str()).cloned().unwrap_or(Value::Null);+ let size = if r.diff { format!("width {} mm, gap {} mm (pitch {} mm)", ch["widthMm"], ch["gapMm"], ch["pitchMm"]) } else { format!("width {} mm", ch["widthMm"]) };+ if c["status"] == "pass" {+ lines.push(format!("impedance {}: routed on {} at {size}; the plan's copper gives {} ohm{}", r.label(), ch["layer"].as_str().unwrap_or(""), c["ohms"], if r.diff { format!(", coupled {}% of each net, skew {} mm", c["coupledPercent"], c["skewMm"]) } else { String::new() }));+ } else {+ let why: Vec<&str> = c["reasons"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+ lines.push(format!("WARNING impedance {}: routed on {} at {size}, but the plan's copper misses the target: {}. {}", r.label(), ch["layer"].as_str().unwrap_or(""), why.join("; "),+ if r.diff { "The grid router routes the two nets one after the other (the second follows the first at the solved pitch where it can); here it could not keep them together. Route this pair yourself: `adom-aiflow take route=ai` (or move what splits it), and `analyze impedance` will judge the copper you land." } else { "Route it yourself (`take route=ai`) or move what forced the detour." }));+ }+ }+ report["impedance"] = json!({"choice": choice, "check": rows});+ lines+}+++type Reply = Result<(String, Vec<String>), (String, Vec<String>)>;++fn signals_hint(p: &Plan, rows: &[Value], spec: &Value) -> Option<String> {+ let entries: Vec<Value> = p.targets.iter().zip(rows).filter(|(t, _)| t.diff).filter_map(|(t, c)| {+ let layers = c["lengthByLayerMm"].as_object()?;+ if layers.len() != 1 { return None; }+ signals_entry(t, &p.stack, layers.keys().next()?, spec)+ }).filter(|e| !spec["signals"]["differentialPairs"].as_array().into_iter().flatten().any(|x| x["positive"] == e["positive"])).collect();+ (!entries.is_empty()).then(|| format!("For the Adom Fields Signals check of the landed pair, add to the spec's signals.differentialPairs: {} (dielectricMm and epsilonR from this stackup; check referenceNet), solve again (`adom-fields analyze --board <board> --spec <spec> --out <run>/fields`) and analyze impedance compares both.", serde_json::to_string(&entries).unwrap_or_default()))+}++/// `analyze impedance` in a run: judge every target on the landed copper (the plan's for a net+/// not landed yet), compare with a same-input Fields report when there is one, record+/// `analyze-impedance`.+pub fn analyze_run(r: &mut aiflow_run::Run, dir: &Path, spec: &Value, board_file: &str, solved_board: &str, plan_path: Option<&str>, fields: Option<&Path>) -> Reply {+ let board = Board::load(board_file).map_err(|e| (e, vec![]))?;+ let plan = plan_path.map(|p| std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).ok_or_else(|| (format!("{p}: not a readable plan"), vec![]))).transpose()?;+ let (p, mut rows) = match check(&board, spec, solved_board, plan.as_ref()) {+ Ok(Some(x)) => x,+ Ok(None) => return Err(("the spec names no impedance target".into(), vec![HINT_SPEC.into()])),+ Err(e) => return Err((format!("impedance: {e}"), vec![HINT_SPEC.into()])),+ };+ r.stage_start("analyze-impedance", Some("binary"), None);+ let fields_file = fields.map(Path::to_path_buf).unwrap_or_else(|| dir.join("fields/fields.json"));+ let spec_file = r.data["spec"].as_str().unwrap_or("").to_string();+ let (notes, field_note) = if fields_file.is_file() {+ match same_input_fields(&fields_file, solved_board, &spec_file) {+ Ok(f) => { let n = compare_fields(&mut rows, &p.targets, &f); let empty = n.is_empty(); (n, if empty { "the same-input Fields report has no Signals pair or cross-section result for these nets".to_string() } else { "compared with the same-input Fields report".to_string() }) }+ Err(e) => (Vec::new(), format!("Fields not compared: {e}")),+ }+ } else { (Vec::new(), "no Fields report: closed form only".to_string()) };+ let pass = rows.iter().all(|c| c["status"] == "pass");+ r.stage_end("analyze-impedance", Some(json!({"pass": pass})), None);+ r.outcome("analyze-impedance", json!({"pass": pass, "results": rows, "fieldReport": fields_file, "fieldNote": field_note, "stackup": p.stack.sources, "method": METHOD}));+ let _ = r.save();+ let mut lines = check_lines(&rows, &p.targets);+ lines.extend(notes);+ let mut hints = vec![format!("fields: {field_note}"), METHOD.to_string()];+ if let Some(h) = signals_hint(&p, &rows, spec) { hints.push(h); }+ if pass { Ok((format!("impedance analysis passed:\n{}", lines.join("\n")), hints)) } else {+ hints.insert(0, "A pair the grid router could not keep coupled is routed by you: `take route=ai`, route it (same layer, parallel at the gap `impedance table` gives, matched length), land it, analyze impedance again. A width that misses: widen or narrow it to the table's width. Never move the target or the stackup to meet the copper.".into());+ Err((format!("impedance analysis not passed:\n{}", lines.join("\n")), hints))+ }+}++/// finish: every target still passes on the live board's copper (None when the spec has none).+pub fn finish_check(board: &Board, spec: &Value, board_file: &str, outcome: Option<&Value>) -> Option<String> {+ let targets = imp::targets(spec).ok()?;+ if targets.is_empty() { return None; }+ if outcome.and_then(|o| o["pass"].as_bool()) != Some(true) { return Some("analyze-impedance (pass): the spec names impedance targets; run `adom-aiflow analyze impedance`".into()); }+ match check(board, spec, board_file, None) {+ Ok(Some((p, rows))) => {+ let bad: Vec<String> = rows.iter().zip(&p.targets).filter(|(c, _)| c["status"] != "pass").map(|(c, t)| format!("{}: {}", t.label(), c["reasons"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("; "))).collect();+ (!bad.is_empty()).then(|| format!("impedance on the live board: {}", bad.join(" | ")))+ }+ Ok(None) => None,+ Err(e) => Some(format!("impedance: {e}")),+ }+}++pub const METHOD: &str = "Closed forms (Hammerstad-Jensen microstrip, Wheeler and Cohn stripline, Garg-Bahl coupled microstrip), quasi-static, no solder mask (it lowers an outer trace by about 1 to 3 %), no etch trapezoid or Dk tolerance; within 0.2 % (microstrip, stripline), 3.4 % (offset stripline) and 2.6 % (pairs) of a 2-D field solve on the reference cases (docs/impedance.md). A screen for routing, not an impedance-controlled fab order: the fab's own stackup calculation is.";++/// `adom-aiflow impedance table|check`: on a run (its current board and spec) or on --board and --spec.+pub fn command(action: &str, board_file: &str, spec: &Value, plan_path: Option<&str>, as_json: bool) -> Reply {+ let board = Board::load(board_file).map_err(|e| (e, vec![]))?;+ match action {+ "table" => {+ let p = match plan(&board, spec, board_file) { Ok(Some(p)) => p, Ok(None) => return Err(("the spec names no impedance target".into(), vec![HINT_SPEC.into()])), Err(e) => return Err((format!("impedance: {e}"), vec![HINT_SPEC.into()])) };+ if as_json { println!("{}", serde_json::to_string_pretty(&json!({"stackup": p.stack.sources, "fab": {"minWidthMm": p.fab.min_width, "minSpaceMm": p.fab.min_space, "source": p.fab.source}, "rows": p.rows, "method": METHOD})).unwrap()); }+ let mut lines = table_lines(&p);+ let mut route_lines = Vec::new();+ for t in &p.targets {+ match router_layer(&p, t, &board) {+ Ok(r) => route_lines.push(format!("route sizes {} on {}: width {} mm{}", t.label(), r["layer"].as_str().unwrap_or(""), r["widthMm"], if t.diff { format!(", gap {} mm", r["gapMm"]) } else { String::new() })),+ Err(e) => route_lines.push(format!("route refuses: {e}")),+ }+ }+ lines.extend(route_lines);+ let refused = p.rows.iter().filter(|r| r["ok"] != true).count();+ Ok((format!("impedance table: {} target(s) on {} copper layer(s), {refused} row(s) refused (stackup: {}; fab: {})\n{}", p.targets.len(), p.stack.layers.len(), p.stack.sources.iter().map(|(k, v)| format!("{k} from {v}")).collect::<Vec<_>>().join(", "), p.fab.source, lines.join("\n")),+ vec!["Outer-layer pair rows sit on the grid router's 0.1 mm pitch so `route` can follow them; an AI router may use any width and gap the formulas give (set gapMm or widthMm on the target to solve the other).".into(), METHOD.into()]))+ }+ "check" => {+ let plan = plan_path.map(|p| std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).ok_or_else(|| (format!("{p}: not a readable plan"), vec![]))).transpose()?;+ let (p, rows) = match check(&board, spec, board_file, plan.as_ref()) { Ok(Some(x)) => x, Ok(None) => return Err(("the spec names no impedance target".into(), vec![HINT_SPEC.into()])), Err(e) => return Err((format!("impedance: {e}"), vec![HINT_SPEC.into()])) };+ if as_json { println!("{}", serde_json::to_string_pretty(&json!({"results": rows, "method": METHOD})).unwrap()); }+ let lines = check_lines(&rows, &p.targets);+ if rows.iter().all(|c| c["status"] == "pass") { Ok((format!("impedance check passed:\n{}", lines.join("\n")), vec![METHOD.into()])) } else { Err((format!("impedance check not passed:\n{}", lines.join("\n")), vec![METHOD.into()])) }+ }+ other => Err((format!("impedance {other}: use table or check"), vec![])),+ }+}++pub const HINT_SPEC: &str = "Targets go under spec impedanceTargets: {\"USB_D\": {\"pair\": [\"USB_DP\", \"USB_DN\"], \"targetDiffOhms\": 90}, \"RF\": {\"net\": \"RF_OUT\", \"targetOhms\": 50}} (tolerancePercent 10, gapMm, widthMm, layer, maxSkewMm 0.5, maxUncontrolledMm 3 optional); the stackup from spec stackupMm and epsilonR or the board's saved physical stackup; copper from copperUm. docs/impedance.md.";++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn fields_values_are_compared_and_can_fail_the_target() {+ let ts = imp::targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();+ let mut rows = vec![json!({"status": "pass", "ohms": [89.0, 91.0], "reasons": []})];+ let fields = json!({"signals": {"differentialPairs": [{"name": "USB", "definition": {"positive": "DP", "negative": "DN"}, "status": "screen-within-target", "differentialOhms": [92.0, 93.5], "warnings": []}]}});+ let notes = compare_fields(&mut rows, &ts, &fields);+ assert_eq!(rows[0]["status"], "pass");+ assert!(notes[0].contains("Fields Signals 92.0 to 93.5"), "{notes:?}");+ let fields = json!({"signals": {"differentialPairs": [{"name": "USB", "definition": {"positive": "DP", "negative": "DN"}, "status": "screen-outside-target", "differentialOhms": [101.0, 104.0], "warnings": []}]}});+ compare_fields(&mut rows, &ts, &fields);+ assert_eq!(rows[0]["status"], "fail");+ // a refused estimate is reported, not replaced+ let mut rows = vec![json!({"status": "pass", "ohms": [89.0, 91.0], "reasons": []})];+ let fields = json!({"signals": {"differentialPairs": [{"name": "USB", "definition": {"positive": "DP", "negative": "DN"}, "status": "unsupported-or-incomplete", "differentialOhms": null, "warnings": ["vias or layer changes: microstrip estimate refused"]}]}});+ let notes = compare_fields(&mut rows, &ts, &fields);+ assert_eq!(rows[0]["status"], "pass");+ assert!(notes[0].contains("did not estimate"), "{notes:?}");+ }++ #[test]+ fn signals_entry_matches_fields_validation() {+ let b = Board::from_text("t.kicad_pcb", "(kicad_pcb (version 20241229) (layers (0 \"F.Cu\" signal) (4 \"In1.Cu\" signal) (6 \"In2.Cu\" signal) (2 \"B.Cu\" signal)) (setup (stackup (layer \"F.Cu\" (type \"copper\") (thickness 0.035)) (layer \"dielectric 1\" (type \"prepreg\") (thickness 0.2104) (epsilon_r 4.4)) (layer \"In1.Cu\" (type \"copper\") (thickness 0.0152)) (layer \"dielectric 2\" (type \"core\") (thickness 1.065) (epsilon_r 4.6)) (layer \"In2.Cu\" (type \"copper\") (thickness 0.0152)) (layer \"dielectric 3\" (type \"prepreg\") (thickness 0.2104) (epsilon_r 4.4)) (layer \"B.Cu\" (type \"copper\") (thickness 0.035)))) (gr_rect (start 0 0) (end 30 20) (layer \"Edge.Cuts\") (width 0.1)))".into()).unwrap();+ let st = imp::stackup(&b, &json!({})).unwrap();+ let ts = imp::targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();+ let e = signals_entry(&ts[0], &st, "F.Cu", &json!({"planes": {"GND": "In1.Cu"}})).unwrap();+ assert_eq!(e["referenceLayer"], "In1.Cu");+ assert_eq!(e["referenceNet"], "GND");+ assert_eq!(e["dielectricMm"], 0.2104);+ assert!(signals_entry(&ts[0], &st, "In1.Cu", &json!({})).is_none(), "Signals screens outer microstrip only");+ }+}
crates/adom-aiflow/src/main.rs+249−113@@ -50,6 +50,10 @@ mod demo; mod boardkind; mod pagedir; mod zone_ownership;+mod gate_eval;+mod route_variants;+mod impedance_cmd;+mod reviews; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -141,6 +145,9 @@ enum Cmd { /// --port N` serves <run>/demo/timeline/; every `demo at` updates an open page in place. Demo { #[arg(value_parser = ["at", "serve"])] action: String, at: Option<String>, #[arg(long, default_value_t = 8894)] port: u16, /// the header's project title in the demo (the run's own header otherwise) #[arg(long)] title: Option<String> },+ /// `review mechanical|wiring [--input review.json] [--glb assembly.glb]`: run the mechanical service-access or wiring review tool (adom-aiflow-mechanical / adom-aiflow-wiring, unreleased MIT prototypes: on PATH, or a source clone named by AIFLOW_MECHANICAL_SRC / AIFLOW_WIRING_SRC) and record its passing result bound to the input, spec and board hashes. A spec `mechanicalReview` / `wiringReview` ({"schemaVersion": 1, "input": ...}) makes it a required step before finish (docs/mechanical-wiring-reviews.md).+ Review { #[arg(value_parser = ["mechanical", "wiring"])] kind: String, #[arg(long)] input: Option<String>, /// wiring only: also run audit-glb on the exported assembly+ #[arg(long)] glb: Option<String> }, Testplan { #[arg(value_parser = ["check"])] action: String, #[arg(long)] tests: Option<String>, #[arg(long)] netlist: Option<String>, #[arg(long)] add: bool }, Rules { #[arg(value_parser = ["use", "install", "check", "list"])] action: String, #[arg(long)] profile: Option<String>, #[arg(long)] board: Option<String>, /// replace an existing .kicad_dru #[arg(long)] force: bool, /// copper layers (default: the profile's -<n>L suffix, else the board's copper layer count, else 2)@@ -195,7 +202,12 @@ enum Cmd { #[arg(long, default_value_t = 600)] pause_ms: u64, /// parts whose body may overhang the outline (an edge connector; also spec placement.edgeOverhang); their pads stay inside #[arg(long = "allow-outside", value_delimiter = ',')] allow_outside: Vec<String> }, /// The grid router on the current board- Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize },+ Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize,+ /// route N variants (at most 8) with different levers (net order, via size, passes, seed), gate each one and keep the best by the gates alone; every variant stays on the route outcome+ #[arg(long, conflicts_with = "pick")] variants: Option<usize>,+ /// make variant K of the last `route --variants` the route plan instead of the gates' pick (needs --why)+ #[arg(long)] pick: Option<usize>, /// the reason for --pick, recorded with the variants+ #[arg(long)] why: Option<String> }, /// Pours, keepouts and vias from the spec (and the routing plan for Kelvin keepouts) Pour { #[arg(long)] plan: Option<String> }, /// The Fusion side of a project: `library` (one project library with real 3D and part numbers), `reference` (placement and spec from a reference KiCad board, in the Fusion board's frame), `schematic` (the design started with the fab's rules, the schematic drawn live in groups), `export` (Gerbers, pick and place, BOM). `adom-aiflow fusion <step> --help` for each.@@ -238,10 +250,12 @@ enum Cmd { #[arg(long)] why: Option<String> }, /// Filled copper per layer from the live board Measure,- /// current or thermal. `analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited peak over the Fields density screen (repeat --ack for more); it covers only that place (the pad or 0.5 mm), keeps the number reported and expires when the copper fingerprint or the spec changes. spec.boardSink declares a board-face heat sink for named parts (thermal).+ /// current, thermal or impedance (spec impedanceTargets: each target's impedance from the landed copper, compared with a same-input Fields Signals solve when there is one). `analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited peak over the Fields density screen (repeat --ack for more); it covers only that place (the pad or 0.5 mm), keeps the number reported and expires when the copper fingerprint or the spec changes. spec.boardSink declares a board-face heat sink for named parts (thermal). Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf>, #[arg(long)] ack: Vec<String>, #[arg(long)] why: Option<String> }, /// The finish line; refuses until every check passes Finish,+ /// Impedance targets (spec impedanceTargets, or adom-fields' impedance map): `table` prints the trace width (and pair gap) for each target on every copper layer from the stackup, refusing a layer the target cannot reach within the fab's minimums; `check` judges the board's copper (or --plan) against them. On the run's board and spec, or on --board and --spec.+ Impedance { #[arg(value_parser = ["table", "check"])] action: String, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String>, #[arg(long)] plan: Option<String>, #[arg(long)] json: bool }, /// Record a change to the spec's budgets (loads, hot, rise limits, switch-node length, net widths, fab) after placement began. Budgets are frozen at placement; this is the only way to move one, and it stays on the record. SpecAmend { #[arg(long)] why: String }, /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.@@ -388,80 +402,196 @@ fn board_step_hints(r: &Run, step: &str) -> Vec<String> { out } -/// Suggestions for the consigned rows of a sourcing check: one `adom-parts-search search <MPN>`-/// per distinct MPN (at most 25 per run, no retries), the exact MPN looked up at the profile's-/// preferred source. Results are cached in the run folder (sourcing-search-cache.json), so a rerun-/// searches nothing it has already searched. Skipped with one note when adom-parts-search is not-/// installed or AIFLOW_NO_PART_SEARCH=1. Returns (suggestions, note).-fn part_suggestions(dir: &Path, prof: &str, consigned: &Value) -> (Vec<Value>, Option<String>) {+fn on_path(program: &str) -> bool {+ std::env::var_os("PATH").map(|p| std::env::split_paths(&p).any(|d| d.join(program).is_file())).unwrap_or(false)+}++fn env_on(name: &str) -> bool { std::env::var(name).map(|v| v == "1").unwrap_or(false) }++/// Suggestions for the consigned rows of a sourcing check (issue #111): for each distinct MPN, the+/// data Adom already has first, then a live search only for what is still missing.+/// 1. the wiki component page (`adom-wiki repo show adom/<slug> page.json`, the slug candidates of+/// the MPN): its `component.distributor_links` number for the preferred source, and stock with+/// its date when the page records them;+/// 2. the pnp-inventory vendor stock cache (`pnp-inventory vendor-stock --mpn <MPN>`, when that+/// CLI is installed; skipped quietly otherwise);+/// 3. `adom-parts-search search <MPN>` (at most 25 per check, no retries, shared quota) for an MPN+/// still without a number or a dated stock count.+/// Page and cache answers are kept in the run folder (sourcing-lookup-cache.json), live results in+/// sourcing-search-cache.json, so a rerun reads and searches nothing it already has. The 25 cap+/// counts live searches only. AIFLOW_NO_PART_SEARCH=1 skips the live searches;+/// AIFLOW_NO_PART_LOOKUP=1 skips every lookup. Returns (suggestions, note, counts).+fn part_suggestions(dir: &Path, prof: &str, consigned: &Value) -> (Vec<Value>, Option<String>, Value) { const LIMIT: usize = 25; let rows = consigned.as_array().cloned().unwrap_or_default();- if rows.is_empty() { return (Vec::new(), None); }- if std::env::var("AIFLOW_NO_PART_SEARCH").map(|v| v == "1").unwrap_or(false) {- return (Vec::new(), Some("part suggestions skipped (AIFLOW_NO_PART_SEARCH=1)".into()));- }- let installed = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).any(|d| d.join("adom-parts-search").is_file())).unwrap_or(false);- if !installed {- return (Vec::new(), Some("part suggestions skipped: adom-parts-search is not installed (`adom-wiki discover find \"adom-parts-search\"` installs it), so the consigned rows were not looked up at the preferred source".into()));+ if rows.is_empty() { return (Vec::new(), None, Value::Null); }+ if env_on("AIFLOW_NO_PART_LOOKUP") {+ return (Vec::new(), Some("part suggestions skipped (AIFLOW_NO_PART_LOOKUP=1)".into()), Value::Null); }- let cache_path = dir.join("sourcing-search-cache.json");- let mut cache: Value = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).filter(Value::is_object).unwrap_or_else(|| json!({}));- let (mut searched, mut over_limit, mut changed) = (0usize, Vec::new(), false);+ let no_search = env_on("AIFLOW_NO_PART_SEARCH");+ let (wiki_ok, pnp_ok, search_ok) = (on_path("adom-wiki"), on_path("pnp-inventory"), !no_search && on_path("adom-parts-search"));+ let lookup_path = dir.join("sourcing-lookup-cache.json");+ let search_path = dir.join("sourcing-search-cache.json");+ let load = |p: &Path| std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).filter(Value::is_object).unwrap_or_else(|| json!({}));+ let (mut lookups, mut cache) = (load(&lookup_path), load(&search_path));+ let (mut lookups_changed, mut cache_changed) = (false, false);+ let mut n = json!({"wikiReads": 0, "wikiFromCache": 0, "wikiHits": 0, "pnpReads": 0, "pnpFromCache": 0, "pnpHits": 0, "liveSearches": 0, "liveFromCache": 0});+ let bump = |n: &mut Value, k: &str| { n[k] = json!(n[k].as_u64().unwrap_or(0) + 1); };+ let (mut over_limit, mut no_live): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new());+ let mut done: BTreeMap<String, (Option<preboard::Offer>, Option<Result<Option<preboard::Offer>, String>>, &'static str)> = BTreeMap::new(); let mut out = Vec::new(); for row in &rows { let mpn = row["mpn"].as_str().unwrap_or("").trim().to_string(); if mpn.is_empty() { continue; } let key = mpn.to_ascii_uppercase();- let (result, from) = if let Some(c) = cache.get(&key).filter(|c| c["result"].is_object()) {- (c["result"].clone(), "cache")- } else if searched >= LIMIT {- if !over_limit.contains(&mpn) { over_limit.push(mpn.clone()); }- continue;- } else {- searched += 1;- match parts_search_once(&mpn) {- Ok(v) => {- // a vendor that failed is not cached, so the next check can look again- if v["mouser"]["ok"] != false && v["jlcpcb"]["ok"] != false {- cache[&key] = json!({"checkedAt": now(), "result": {"query": v["query"], "mouser": v["mouser"], "jlcpcb": v["jlcpcb"]}});- changed = true;+ if !done.contains_key(&key) {+ // 1. the wiki component page+ let mut known: Option<preboard::Offer> = None;+ let mut from = "";+ let wiki = if lookups[&key]["wiki"].is_object() { bump(&mut n, "wikiFromCache"); Some(lookups[&key]["wiki"].clone()) }+ else if wiki_ok {+ bump(&mut n, "wikiReads");+ match wiki_component_page(&mpn) {+ Ok(found) => {+ let entry = match found {+ Some((page_ref, page)) => json!({"checkedAt": now(), "page": page_ref, "component": {"mpn": page["component"]["mpn"], "distributor_links": page["component"]["distributor_links"]}}),+ None => json!({"checkedAt": now(), "page": null}),+ };+ if !lookups[&key].is_object() { lookups[&key] = json!({}); }+ lookups[&key]["wiki"] = entry.clone();+ lookups_changed = true;+ Some(entry)+ }+ Err(_) => None, // not cached: the next check reads it again }- (v, "search")+ } else { None };+ if let Some(w) = wiki.as_ref().filter(|w| w["page"].is_string()) {+ if let Some(o) = preboard::page_offer(prof, &mpn, w, w["page"].as_str().unwrap_or("")) { bump(&mut n, "wikiHits"); known = Some(o); from = "wiki"; }+ }+ // 2. the pnp-inventory vendor stock cache, for a number or a dated stock count still missing+ if !known.as_ref().map(preboard::Offer::complete).unwrap_or(false) {+ let pnp = if lookups[&key]["pnp"].is_object() { bump(&mut n, "pnpFromCache"); Some(lookups[&key]["pnp"]["result"].clone()) }+ else if pnp_ok {+ bump(&mut n, "pnpReads");+ let args = ["vendor-stock", "--mpn", mpn.as_str()];+ match command_once("pnp-inventory", &args, 20) {+ Ok((true, body, _)) => {+ let v: Value = serde_json::from_str(&body).unwrap_or(Value::Null);+ if !lookups[&key].is_object() { lookups[&key] = json!({}); }+ lookups[&key]["pnp"] = json!({"checkedAt": now(), "result": v});+ lookups_changed = true;+ Some(v)+ }+ _ => None, // not in the inventory, or the service is down: skipped quietly+ }+ } else { None };+ if let Some(o) = pnp.as_ref().and_then(|v| preboard::pnp_offer(prof, v)) {+ bump(&mut n, "pnpHits");+ if from.is_empty() { from = "pnp-inventory"; }+ match known.as_mut() { Some(k) => k.merge(&o), None => known = Some(o) } }- Err(e) => {- let failed = json!({"ok": false, "error": e});- (json!({"mouser": failed, "jlcpcb": failed}), "search")+ }+ // 3. a live search only for what the page and the cache did not answer+ let mut live = None;+ if !known.as_ref().map(preboard::Offer::complete).unwrap_or(false) {+ if let Some(c) = cache.get(&key).filter(|c| c["result"].is_object()) {+ bump(&mut n, "liveFromCache");+ live = Some(preboard::live_offer(prof, &mpn, &c["result"], c["checkedAt"].as_str().unwrap_or("")));+ from = "cache";+ } else if !search_ok {+ no_live.push(mpn.clone());+ } else if n["liveSearches"].as_u64().unwrap_or(0) as usize >= LIMIT {+ over_limit.push(mpn.clone());+ } else {+ bump(&mut n, "liveSearches");+ let checked = now();+ let result = match parts_search_once(&mpn) {+ Ok(v) => {+ // a vendor that failed is not cached, so the next check can look again+ if v["mouser"]["ok"] != false && v["jlcpcb"]["ok"] != false {+ cache[&key] = json!({"checkedAt": checked, "result": {"query": v["query"], "mouser": v["mouser"], "jlcpcb": v["jlcpcb"]}});+ cache_changed = true;+ }+ v+ }+ Err(e) => { let failed = json!({"ok": false, "error": e}); json!({"mouser": failed, "jlcpcb": failed}) }+ };+ live = Some(preboard::live_offer(prof, &mpn, &result, &checked));+ from = "search"; } }- };- out.push(json!({"refs": row["refs"], "mpn": mpn, "preferred": preboard::preferred_source(prof), "from": from, "suggestion": preboard::part_suggestion(prof, row, &result)}));+ done.insert(key.clone(), (known, live, from));+ }+ let (known, live, from) = &done[&key];+ if let Some(text) = preboard::row_suggestion(prof, row, known.as_ref(), live.as_ref()) {+ out.push(json!({"refs": row["refs"], "mpn": mpn, "preferred": preboard::preferred_source(prof), "from": from, "suggestion": text}));+ }+ }+ if lookups_changed { let _ = std::fs::write(&lookup_path, serde_json::to_string_pretty(&lookups).unwrap_or_default()); }+ if cache_changed { let _ = std::fs::write(&search_path, serde_json::to_string_pretty(&cache).unwrap_or_default()); }+ let mut notes = Vec::new();+ if !no_live.is_empty() {+ let why = if no_search { "AIFLOW_NO_PART_SEARCH=1".to_string() } else { "adom-parts-search is not installed (`adom-wiki discover find \"adom-parts-search\"` installs it)".to_string() };+ notes.push(format!("live part searches skipped ({why}) for {} MPN(s) the wiki pages and the pnp-inventory cache did not fully answer: {}", no_live.len(), no_live.join(", "))); }- if changed { let _ = std::fs::write(&cache_path, serde_json::to_string_pretty(&cache).unwrap_or_default()); }- let note = (!over_limit.is_empty()).then(|| format!("{} more MPN(s) not looked up ({LIMIT} searches per check, shared quota): {}; check again to look them up, or `adom-parts-search show <MPN>` one at a time", over_limit.len(), over_limit.join(", ")));- (out, note)+ if !over_limit.is_empty() {+ notes.push(format!("{} more MPN(s) not looked up live ({LIMIT} searches per check, shared quota): {}; check again to look them up, or `adom-parts-search show <MPN>` one at a time", over_limit.len(), over_limit.join(", ")));+ }+ let note = (!notes.is_empty()).then(|| notes.join("; "));+ (out, note, n) } -/// One `adom-parts-search search <MPN>`, 60 s at most, no retry. Its stdout is the unified JSON.-fn parts_search_once(mpn: &str) -> Result<Value, String> {+/// The wiki component page for an MPN: `adom-wiki repo show adom/<slug> page.json` for each slug+/// candidate (the lowercase MPN, then the wiki's slug form) until one is the page for this MPN.+/// Ok(None): no such page; Err: the wiki did not answer (not cached).+fn wiki_component_page(mpn: &str) -> Result<Option<(String, Value)>, String> {+ for slug in preboard::part_slug_candidates(mpn) {+ let page_ref = format!("adom/{slug}");+ let (ok, body, errs) = command_once("adom-wiki", &["repo", "show", &page_ref, "page.json"], 30)?;+ if ok {+ let v: Value = serde_json::from_str(&body).map_err(|_| format!("{page_ref} page.json is not JSON"))?;+ if v["component"]["mpn"].as_str().map(|m| m.trim().eq_ignore_ascii_case(mpn.trim())).unwrap_or(false) { return Ok(Some((page_ref, v))); }+ continue;+ }+ let said = format!("{body}\n{errs}");+ if said.contains("NOT_FOUND") || said.contains("404") { continue; }+ return Err(format!("adom-wiki repo show {page_ref} failed"));+ }+ Ok(None)+}++/// Runs a program once (no retry, stdin closed) for at most `secs` seconds: (exited 0, stdout, stderr).+fn command_once(program: &str, args: &[&str], secs: u64) -> Result<(bool, String, String), String> { use std::process::{Command, Stdio};- let tmp = std::env::temp_dir().join(format!("aiflow-parts-search-{}-{}.json", std::process::id(), mpn.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>()));- let f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;- let mut child = Command::new("adom-parts-search").args(["search", mpn]).stdin(Stdio::null()).stdout(f).stderr(Stdio::null()).spawn().map_err(|e| e.to_string())?;+ let tag: String = args.iter().flat_map(|a| a.chars()).filter(|c| c.is_ascii_alphanumeric()).take(40).collect();+ let tmp = std::env::temp_dir().join(format!("aiflow-{program}-{}-{tag}", std::process::id()));+ let (out_p, err_p) = (tmp.with_extension("out"), tmp.with_extension("err"));+ let f = std::fs::File::create(&out_p).map_err(|e| e.to_string())?;+ let g = std::fs::File::create(&err_p).map_err(|e| e.to_string())?;+ let mut child = Command::new(program).args(args).stdin(Stdio::null()).stdout(f).stderr(g).spawn().map_err(|e| e.to_string())?; let start = std::time::Instant::now(); let status = loop { if let Some(s) = child.try_wait().map_err(|e| e.to_string())? { break s; }- if start.elapsed() >= std::time::Duration::from_secs(60) {+ if start.elapsed() >= std::time::Duration::from_secs(secs) { let _ = child.kill(); let _ = child.wait();- let _ = std::fs::remove_file(&tmp);- return Err("adom-parts-search timed out after 60 s".into());+ let _ = std::fs::remove_file(&out_p);+ let _ = std::fs::remove_file(&err_p);+ return Err(format!("{program} timed out after {secs} s")); } std::thread::sleep(std::time::Duration::from_millis(50)); };- let body = std::fs::read_to_string(&tmp).unwrap_or_default();- let _ = std::fs::remove_file(&tmp);- let v: Value = serde_json::from_str(&body).map_err(|_| format!("adom-parts-search exited {status} without JSON"))?;- Ok(v)+ let body = std::fs::read_to_string(&out_p).unwrap_or_default();+ let errs = std::fs::read_to_string(&err_p).unwrap_or_default();+ let _ = std::fs::remove_file(&out_p);+ let _ = std::fs::remove_file(&err_p);+ Ok((status.success(), body, errs))+}++/// One `adom-parts-search search <MPN>`, 60 s at most, no retry. Its stdout is the unified JSON.+fn parts_search_once(mpn: &str) -> Result<Value, String> {+ let (ok, body, _) = command_once("adom-parts-search", &["search", mpn], 60)?;+ serde_json::from_str(&body).map_err(|_| format!("adom-parts-search exited {} without JSON", if ok { "0" } else { "non-zero" })) } fn thread(cli: &Cli) -> String {@@ -2057,6 +2187,7 @@ fn build_report(r: &Run, dir: &Path, page: Option<(&str, Option<&str>)>) -> (Pat let o = r.outcomes(); md.push_str("## Where the board stands\n\n"); if let Some(x) = o.get("route") { md.push_str(&format!("- routing: {} unrouted after {} passes\n", x["unrouted"], x["passes"])); }+ if let Some(v) = o.get("route").map(|x| &x["variants"]).filter(|v| v.is_object()) { md.push_str(&format!("- route variants (issue #55), ranked by the gates alone:\n\n```\n{}\n```\n", route_variants::table(v))); } if let Some(x) = o.get("gate") { md.push_str(&format!("- gate: {} new DRC errors, {} unconnected, {} inherited\n", x["newErrors"], x["unconnected"], x["inheritedErrors"])); } if let Some(x) = o.get("land-route") { md.push_str(&format!("- landed live: {} of {} route entries\n", x["accepted"], x["entries"])); } if let Some(x) = o.get("land-pours") { md.push_str(&format!("- pours ({}): {} groups, failed {}\n", pourverify::mode_label(x), x["groups"].as_array().map(|a| a.len()).unwrap_or(0), x["failed"])); }@@ -2979,8 +3110,9 @@ fn main() { out["checkedAt"] = json!(now()); out["profileNamedBy"] = json!(if profile.is_some() { "--profile" } else { named.as_ref().map(|n| n.1).unwrap_or("default") }); out["qtySource"] = json!(qty_src);- let (suggestions, suggestion_note) = part_suggestions(&r.dir, &prof, &out["consignedRows"]["list"]);+ let (suggestions, suggestion_note, lookup_counts) = part_suggestions(&r.dir, &prof, &out["consignedRows"]["list"]); out["suggestions"] = json!(suggestions);+ if !lookup_counts.is_null() { out["suggestionLookups"] = lookup_counts; } if let Some(n) = &suggestion_note { out["suggestionsNote"] = json!(n); } r.outcome("sourcing", out.clone()); r.save().unwrap();@@ -3488,6 +3620,8 @@ fn main() { if !mech.errors().is_empty() { err(&format!("the moved board fails the courtyard and body checks:\n {}", mech.errors().join("\n ")), &[mech.hint(), format!("The run's current board is unchanged; see {}.", outp.display())]); } format!("; courtyards on all {} footprint(s) with pads and clear, {} part bodies clear", mech.with_courtyard, mech.bodies_checked) } else { "; courtyards and bodies not checked (Fusion board: check them in Fusion's DRC)".to_string() };+ let old_board = r.current_board();+ if let Some(n) = reviews::void_on_service_change(&mut r, Path::new(&old_board), &outp) { eprintln!("{n}"); } r.add_board(&outp.display().to_string()); r.save().unwrap(); let lines: Vec<String> = lines.into_iter().chain(overhang_lines).collect();@@ -3523,8 +3657,8 @@ fn main() { other => err(&format!("place {other}: use pack, check, land or verify"), &[]), } }- Cmd::Route { passes, rip_per_net, rip_global } => {- thread(&cli);+ Cmd::Route { passes, rip_per_net, rip_global, variants, pick, why } => {+ let ai_thread = thread(&cli); let mut r = load_run(&cli); if r.engine_for("route") == "ai" { err("route is taken by the AI in this run (take route=ai)", &["Write your plan in the plan JSON shape and run `adom-aiflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.".into()]);@@ -3544,7 +3678,9 @@ fn main() { let b = board_of(&r); let missing=aiflow_pours::missing_planes(&b,&spec); if !missing.is_empty(){err(&format!("declared reference planes do not exist: {}",missing.join(", ")), &["Create the listed net/layer zones through the EDA bridge, fill and save them, then `adom-aiflow adopt-board --board <saved.kicad_pcb> --evidence <native review notes>` before route. A spec.planes declaration alone is not copper.".into()]);}- let rules = aiflow_router::Rules::from_spec(&spec);+ let mut rules = aiflow_router::Rules::from_spec(&spec);+ // issue #52: impedance targets set their nets' width (and pair pitch) and hold them to one layer+ let imp_choice = impedance_cmd::for_route(&b, &spec, &r.current_board(), &mut rules).unwrap_or_else(|(m, h)| err(&m, &h)); let regions = aiflow_router::plane_under::regions(&b, &rules.plane_under).unwrap_or_else(|e| err(&format!("spec.planeUnder: {e}"), &["Each entry needs a layer (F.Cu or B.Cu), the net that owns the region, and \"around\" (parts or pads on this board, grown by \"margin\", default 1.0 mm) or a \"polygon\".".into()])); if spec.get("planeUnder").is_some() && regions.len() != spec["planeUnder"].as_array().map(|a| a.len()).unwrap_or(1) { err("spec.planeUnder has an entry that does not parse", &["Use {\"layer\": \"B.Cu\", \"net\": \"GND\", \"around\": [\"U1\", \"C2\"], \"margin\": 1.0} or {\"layer\": ..., \"net\": ..., \"polygon\": [[x, y], ...]}.".into()]);@@ -3552,39 +3688,32 @@ fn main() { let preexisting = aiflow_router::plane_under::issues(&b, ®ions); decoupling_check::refuse_bad_spec(&spec, &b); if spec["stub"].as_f64().map(|s|s<rules.stub).unwrap_or(false){eprintln!("Hint: escape width raised to {} mm to satisfy minTrackWidth (default: track width). Declare a smaller manufacturing minimum only when the board process supports it.",rules.stub);}- r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));- let mut router = aiflow_router::Router::new(b, rules, true);- let (mut plan, mut report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);- // issue #39: staircases to clean 45 degree runs before the gate (the router's own clearance check decides)- let simp = aiflow_router::simplify::simplify_plan(&mut router, &mut plan);- report["simplify"] = simp.clone();- let simp_line = format!("simplified: {} staircase(s) replaced, {} kept (no clear 45 degree path), {} collinear point(s) merged; vertices {} -> {} (per net in plan-route-report.json \"simplify\")", simp["staircasesReplaced"], simp["staircasesKept"], simp["collinearMerged"], simp["verticesBefore"], simp["verticesAfter"]);- std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();+ r.stage_start("route", Some("binary"), Some(&match (variants, pick) { (Some(n), _) => format!("variants={n} passes={passes}"), (_, Some(k)) => format!("pick=v{k}"), _ => format!("passes={passes}") }));+ let labels: Vec<String> = regions.iter().map(|k| k.label.clone()).collect();+ // issue #55: --variants routes several and the gates pick; --pick takes one of them; plain route is the baseline levers+ let (rt, variants_rec) = match (variants, pick) {+ (_, Some(k)) => route_variants::pick(&r, *k, why.as_deref(), &ai_thread),+ (Some(n), None) => route_variants::run(&r, &dir, &rules, &labels, *n, *passes, *rip_per_net, *rip_global),+ _ => (route_variants::route_once(b, &rules, &aiflow_router::variants::Levers::baseline(*passes), *rip_per_net, *rip_global, &labels, true), Value::Null),+ };+ let route_variants::Routed { plan_doc, mut report, failed, simp_line, log, board: routed_on, has_copper, bc } = rt;+ let plan: Vec<Value> = plan_doc["nets"].as_array().cloned().unwrap_or_default();+ std::fs::write(dir.join("router.log"), log.join("\n")).ok(); let planp = dir.join("plan-route.json");- let bc = report["boardCopper"].clone();- // filled zone islands count: a pour alone can join a net's pads (issue #84)- let has_copper = bc["trackPieces"].as_u64().unwrap_or(0) + bc["vias"].as_u64().unwrap_or(0) + bc["zoneIslands"].as_u64().unwrap_or(0) > 0; for w in bc["warnings"].as_array().into_iter().flatten().filter_map(|w| w.as_str()) { eprintln!("WARNING {w}"); }- let mut plan_doc = json!({"engine": "adom-aiflow router", "nets": plan});- if has_copper {- let complete = bc["netsComplete"].as_array().map(|a| a.len()).unwrap_or(0);- plan_doc["boardCopper"] = bc.clone();- plan_doc["note"] = json!(if plan.is_empty() && failed == 0 {- format!("nothing to route: the board's own copper ({} track pieces, {} vias, {} filled zone islands) already joins every net ({complete} complete); this empty plan is the route result, and gate, land route and finish accept it", bc["trackPieces"], bc["vias"], bc["zoneIslands"])- } else {- format!("the board's own copper is kept as fixed copper ({} track pieces, {} vias, {} filled zone islands); {complete} nets are complete on it ({}) and the plan carries only the {} connection(s) it left open", bc["trackPieces"], bc["vias"], bc["zoneIslands"], bc["netsComplete"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default(), bc["openConnections"])- });- }- if !regions.is_empty() {- plan_doc["planeUnder"] = json!(regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>());- }+ // issue #52: measure the chosen plan's impedance targets (coupling, skew) before it is written+ let imp_lines = impedance_cmd::after_route(&imp_choice, &routed_on, &spec, &r.current_board(), &plan_doc, &mut report);+ for l in imp_lines.iter().filter(|l| l.starts_with("WARNING")) { eprintln!("{l}"); } write_json(&planp, &plan_doc); write_json(&dir.join("plan-route-report.json"), &report);- let dec = decoupling_check::review_route(&spec, &router.board, &plan_doc, &report, r.outcomes().get("pour"));+ let dec = decoupling_check::review_route(&spec, &routed_on, &plan_doc, &report, r.outcomes().get("pour")); decoupling_check::record(&mut r, &dir, &dec); let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default(); r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);- r.outcome("route", json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>(), "boardCopper": if has_copper { bc.clone() } else { Value::Null }, "planeUnder": regions.len()}));+ let mut route_o = json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>(), "boardCopper": if has_copper { bc.clone() } else { Value::Null }, "planeUnder": regions.len()});+ if !variants_rec.is_null() { route_o["variants"] = variants_rec; }+ if !report["impedance"].is_null() { route_o["impedance"] = report["impedance"].clone(); }+ r.outcome("route", route_o); r.save().unwrap(); let policy_hint = if regions.is_empty() { String::new() } else if preexisting.is_empty() { format!("planeUnder: {} kept free of every other net's copper.", regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>().join("; "))@@ -3614,7 +3743,8 @@ fn main() { ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias, {} filled zone islands); empty plan {}", bc["trackPieces"], bc["vias"], bc["zoneIslands"], planp.display()), &["Next: adom-aiflow gate (DRC on the board as it is), then land route (lands nothing and records it), pour, land vias, land pours.".into(), policy_hint]); } else { let copper_line = if has_copper { format!("; the board's own copper was kept (nets already complete on it, skipped: {}) and {} connection(s) it left open were planned", bc["netsComplete"].as_array().filter(|a| !a.is_empty()).map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_else(|| "none".into()), bc["openConnections"]) } else { String::new() };- ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}\n{simp_line}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint]);+ let imp_text = if imp_lines.is_empty() { String::new() } else { format!("\n{}", imp_lines.join("\n")) };+ ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}\n{simp_line}{imp_text}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint, if imp_lines.is_empty() { String::new() } else { "After landing: `adom-aiflow analyze impedance` judges the landed copper (and a same-input Fields Signals solve); finish requires it.".into() }]); } } Cmd::Pour { plan } => {@@ -3702,35 +3832,10 @@ fn main() { if !f.errors.is_empty() { err(&format!("gate refused: the board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); } } r.stage_start("gate", Some("binary"), None);- // an empty plan adds no copper: the board's own zone fills stand, a raster refill would only invent errors- let adds_copper = plan_v["nets"].as_array().map(|a| !a.is_empty()).unwrap_or(true);- let (text, stats) = aiflow_copper::apply(&b, &plan_v, adds_copper && !aiflow_copper::native_drc_available()).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[]));- let routed = dir.join("board-routed.kicad_pcb");- std::fs::write(&routed, &text).unwrap();- aiflow_copper::copy_project_rules(&r.current_board(), &routed.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+ // the checks themselves are gate_eval::evaluate, shared with `route --variants` (issue #55) let base = run_drc_for(&r, &r.current_board()).unwrap_or_else(|e| err(&e, &[]));- let data = run_drc_for(&r, &routed.display().to_string()).unwrap_or_else(|e| err(&e, &[]));- let mut s = aiflow_copper::summarize_with(&data, Some(&base), silk_policy::blocking(&spec_g, &r.current_board()));- s["copper"] = stats;- s["file"] = json!(routed.display().to_string());- // the layer policy: other-net copper inside a planeUnder region is an issue like a DRC error- let spec = spec_of(&r);- let pu = aiflow_router::rules::PlaneUnder::from_spec(&spec);- let pu_issues: Vec<String> = if pu.is_empty() { Vec::new() } else {- let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[]));- match aiflow_router::plane_under::regions(&rb, &pu) { Ok(regs) => aiflow_router::plane_under::issues(&rb, ®s), Err(e) => vec![format!("spec.planeUnder: {e}")] }- };- s["planeUnderIssues"] = json!(pu_issues);- // the switch node: short, and never under another part (switchnode.rs)- let sw_issues = { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); if let Some(n) = switchnode::note(&spec_g, &rb) { eprintln!("{n}"); } switchnode::findings(&spec_g, &rb) };- s["switchNodeIssues"] = json!(sw_issues);- let dec = decoupling_check::review_text("gate", &spec_g, &routed.display().to_string(), &text, !adds_copper, if adds_copper && !aiflow_copper::native_drc_available() { "an approximate raster fill by the gate, not a native refill" } else { "the zone fills saved in the board file" }, &plan_v, None, r.outcomes().get("pour"));- if let Some(d) = &dec { s["decoupling"] = d.value.clone(); }- // issue #39: courtyards and bodies (aiflow's own geometry), staircases left in the copper (a warning)- let mech = mechanical::applies(&r.current_board()).then(|| { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); mechanical::check_board(&rb, &routed.display().to_string(), &project_dir_of(&r), &spec_g) });- let stairs = Board::from_text(&routed.display().to_string(), text.clone()).map(|rb| aiflow_router::simplify::board_staircases(&rb)).unwrap_or_default();- s["mechanical"] = mech.as_ref().map(|m| m.to_json()).unwrap_or(json!("not checked: Fusion board"));- s["staircases"] = json!(stairs);+ let routed = dir.join("board-routed.kicad_pcb");+ let gate_eval::GateEval { mut s, pu, pu_issues, sw_issues, dec, mech, stairs, .. } = gate_eval::evaluate(&r, &b, &plan_v, &routed, &base, true).unwrap_or_else(|e| err(&e, &[])); decoupling_check::record(&mut r, &dir, &dec); if plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) { s["note"] = plan_v.get("note").cloned().unwrap_or(json!("empty plan: the board is gated as it is")); } if !plan_notes.is_empty() { s["planNormalised"] = json!(plan_notes); }@@ -4028,6 +4133,16 @@ fn main() { let t = thread(&cli); if !ack.is_empty() && what != "current" { err("--ack acknowledges a current-density peak: `analyze current --ack [email protected] --why ...`", &[]); } let mut r = load_run(&cli);+ if what == "impedance" {+ // issue #52: closed-form impedance of the landed copper, Fields Signals beside it+ let live = live_board(&r, &cli, &dir);+ let copper = live.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board());+ let solved = live.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());+ let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o["plan"].as_str()).map(str::to_string));+ let spec = spec_of(&r);+ match impedance_cmd::analyze_run(&mut r, &dir, &spec, &copper, &solved, planp.as_deref(), fields.as_deref()) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ return;+ } let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan", &["Run route, or pass --plan.".into()])); // a Fusion run analyzes the board as it is in Fusion now, not the placement snapshot let live = live_board(&r, &cli, &dir);@@ -4039,7 +4154,7 @@ fn main() { let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None }; let name = format!("analyze-{what}"); r.stage_start(&name, Some("binary"), None);- let (mut heuristic_passed, mut res) = match what.as_str() { "current" => aiflow_analyze::analyze_current_on_board(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now)), &board_now), "thermal" => aiflow_analyze::analyze_thermal_on_board(&plan_v, &spec, zs.as_ref(), &board_now), other => err(&format!("analyze {other}: use current or thermal"), &[]) };+ let (mut heuristic_passed, mut res) = match what.as_str() { "current" => aiflow_analyze::analyze_current_on_board(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now)), &board_now), "thermal" => aiflow_analyze::analyze_thermal_on_board(&plan_v, &spec, zs.as_ref(), &board_now), other => err(&format!("analyze {other}: use current, thermal or impedance"), &[]) }; let mut fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json")); // provenance: a fields.json is evidence only when it is the output of a solve aiflow ran or watched; // any other file (copied, edited, or written to look like one) is solved again here, same inputs@@ -4125,6 +4240,14 @@ fn main() { hints.extend(converter_hints); ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &hints); }+ Cmd::Impedance { action, board, spec, plan, json: as_json } => {+ let (board_file, spec_v, plan_p) = match (board, spec) {+ (Some(b), Some(s)) => (b.clone(), read_json(s), plan.clone()),+ (None, None) => { let r = load_run(&cli); let p = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o["plan"].as_str()).map(str::to_string)); (r.current_board(), spec_of(&r), p) }+ _ => err("give both --board and --spec, or neither (the run's board and spec)", &[]),+ };+ match impedance_cmd::command(action, &board_file, &spec_v, plan_p.as_deref(), *as_json) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ } Cmd::SpecAmend { why } => { let t = thread(&cli); let r = load_run(&cli);@@ -4217,6 +4340,8 @@ fn main() { missing.push(format!("fields: {msg}. {}", hints.join(" "))); } }+ // issue #52: impedance targets hold on the live board's copper+ if !pattern { if let Some(m) = impedance_cmd::finish_check(&board_now(), &spec_of(&r), &solved_board, o.get("analyze-impedance")) { missing.push(m); } } if let Err(m) = netlist_gate(&r) { missing.push(m); } if let Err(m) = schematic_symbols::gate(&r) { missing.push(m); } let silk_warnings = silk_polarity::warnings(&board_now());@@ -4372,6 +4497,9 @@ fn main() { } // every required step before the finish line, done in order and proven let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ // issue #60: declared mechanical and wiring reviews, bound to this board, spec and review file+ reviews::sync_sequence(&mut r.data);+ for g in reviews::finish_gaps(&r.data, Path::new(&r.current_board())) { missing.push(g); } let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); if !seq.is_empty() { let st = sequence::states(&dir, &r.data, &flow_full, &seq);@@ -4912,6 +5040,13 @@ fn main() { ok(&format!("demo at {t}: step {} of {}, {} done, {} loop back(s), current {}; written to {}", tl["position"], tl["total"], tl["done"], tl["loops"].as_array().map(|a| a.len()).unwrap_or(0), tl["current"], d.display()), &["`demo serve --port 8894` serves it (the run's live dashboard is untouched); an open page updates in place on the next `demo at`.".into()]); }+ Cmd::Review { kind, input, glb } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let res = reviews::run(&mut r, kind, input.as_deref(), glb.as_deref());+ r.save().unwrap();+ match res { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ } Cmd::Testplan { action: _, tests, netlist, add } => { thread(&cli); let mut r = load_run(&cli);@@ -5479,7 +5614,7 @@ fn main() { thread(&cli); let mut r = load_run(&cli); let mut known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());- for s in preboard::PREBOARD_STEPS { if !known.iter().any(|k| k == s) { known.push(s.to_string()); } }+ for s in preboard::PREBOARD_STEPS.iter().chain(reviews::STEPS.iter()) { if !known.iter().any(|k| k == s) { known.push(s.to_string()); } } let known: Vec<&str> = known.iter().map(String::as_str).collect(); let mut clip_stopped = false; let prev = r.data["currentStep"].as_str().map(str::to_string);@@ -5527,6 +5662,7 @@ fn main() { { let want = sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)); if sequence::may_resequence(&r.data, &want) && r.data["sequence"] != json!(want) { r.data["sequence"] = json!(want); }+ for s in reviews::sync_sequence(&mut r.data) { eprintln!("{s}: declared in the spec or requirements, required before finish (`review {}`)", s.trim_end_matches("-review")); } } // the pattern lane is for boards with nothing to connect (issue #84): refuse it for one that needs routing if boardkind::is_pattern(&r.data) && !r.board_pending() && sequence::sequence_key(&dir, &r.data) == boardkind::PATTERN {
crates/adom-aiflow/src/preboard.rs+209−25@@ -215,32 +215,181 @@ fn equivalent_query(row: &Value) -> String { if parts.is_empty() { row["mpn"].as_str().unwrap_or("").to_string() } else { parts.join(" ") } } -/// One consigned row's suggestion from an `adom-parts-search search <MPN>` result (its unified-/// JSON): the exact MPN, in stock, at the profile's preferred source, or a pointer to an-/// equivalent. Pure, so the wording is tested offline.-pub fn part_suggestion(profile: &str, row: &Value, search: &Value) -> String {- let mpn = row["mpn"].as_str().unwrap_or("").trim();- let who = format!("{} ({mpn})", row["refs"].as_str().unwrap_or(""));- let pref = preferred_source(profile);- let vendor = if profile == "jlcpcb" { "jlcpcb" } else { "mouser" };- let v = &search[vendor];- let fallback = |why: String| format!("{who}: {why}; `adom-parts-search show \"{}\"` to look for an equivalent, or send this part as consigned", equivalent_query(row));- if v["ok"] == false {- return fallback(format!("the {pref} search failed ({})", v["error"].as_str().unwrap_or("no reason given")));+/// What is known about one MPN at the preferred source: its number and stock, each with where it+/// came from (`wiki page adom/<slug>`, `pnp-inventory cache`, `live search`) and the stock's date.+/// A stock count without a date is never shown as a count: it is "stock not recorded".+#[derive(Clone, Debug, Default, PartialEq)]+pub struct Offer {+ pub pn: Option<String>,+ pub pn_from: String,+ pub tier: Option<String>,+ pub stock: Option<u64>,+ pub stock_date: Option<String>,+ pub stock_from: String,+}++impl Offer {+ /// A stock count with its date: the only kind the suggestion states as a number.+ pub fn dated_stock(&self) -> Option<(u64, &str)> {+ Some((self.stock?, self.stock_date.as_deref().filter(|d| !d.is_empty())?))+ }+ /// Everything the suggestion needs (a number and a dated stock count), so no further lookup.+ pub fn complete(&self) -> bool { self.pn.is_some() && self.dated_stock().is_some() }+ /// `other` fills what this one lacks: the number, and a dated stock count (the newer date wins).+ pub fn merge(&mut self, other: &Offer) {+ if self.pn.is_none() && other.pn.is_some() { self.pn = other.pn.clone(); self.pn_from = other.pn_from.clone(); }+ if self.tier.is_none() { self.tier = other.tier.clone(); }+ let newer = match (self.dated_stock(), other.dated_stock()) {+ (_, None) => false,+ (None, Some(_)) => true,+ (Some((_, a)), Some((_, b))) => b > a,+ };+ if newer || (self.stock.is_none() && other.stock.is_some() && self.dated_stock().is_none()) {+ self.stock = other.stock;+ self.stock_date = other.stock_date.clone();+ self.stock_from = other.stock_from.clone();+ }+ }+ fn is_empty(&self) -> bool { self.pn.is_none() && self.stock.is_none() }+}++/// YYYY-MM-DD from a date or timestamp: an ISO string, or Unix seconds (pnp-inventory) or milliseconds.+fn day_of(v: &Value) -> Option<String> {+ if let Some(s) = v.as_str() { return date_in(s).or_else(|| s.trim().parse::<u64>().ok().and_then(|n| day_of(&json!(n)))); }+ let n = v.as_u64().or_else(|| v.as_f64().filter(|f| *f > 0.0).map(|f| f as u64))?;+ let secs = if n > 100_000_000_000 { n / 1000 } else { n };+ (secs > 0).then(|| aiflow_run::iso(secs)[..10].to_string())+}++fn count_of(v: &Value) -> Option<u64> {+ v.as_u64().or_else(|| v.as_f64().filter(|f| *f >= 0.0).map(|f| f as u64)).or_else(|| v.as_str().and_then(|s| s.trim().replace(',', "").parse().ok()))+}++fn first<'a>(v: &'a Value, keys: &[&str]) -> &'a Value {+ keys.iter().map(|k| &v[*k]).find(|x| !x.is_null()).unwrap_or(&Value::Null)+}++fn same_mpn(a: &str, b: &str) -> bool {+ let n = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_uppercase();+ !a.trim().is_empty() && n(a) == n(b)+}++/// The preferred source's number (and stock with its date, when the page records them) on a wiki+/// component page: page.json `component.distributor_links`, keyed `mouser` (fab) or `lcsc` /+/// `jlcpcb` (jlcpcb). Each link is a part-number string, `{part, url, stock, checked}`, or `false`+/// (the distributor does not stock it). The page must be for this MPN (`component.mpn`).+pub fn page_offer(profile: &str, mpn: &str, page: &Value, page_ref: &str) -> Option<Offer> {+ let c = &page["component"];+ if !c["mpn"].as_str().map(|m| same_mpn(m, mpn)).unwrap_or(false) { return None; }+ let keys: &[&str] = if profile == "jlcpcb" { &["lcsc", "jlcpcb"] } else { &["mouser"] };+ let link = first(&c["distributor_links"], keys);+ let from = format!("wiki page {page_ref}");+ let mut o = Offer { pn_from: from.clone(), stock_from: from, ..Default::default() };+ if let Some(s) = link.as_str() {+ o.pn = Some(s.trim().to_string()).filter(|s| !s.is_empty());+ } else if link.is_object() {+ o.pn = first(link, &["part", "pn", "number", "sku"]).as_str().map(|s| s.trim().to_string()).filter(|s| !s.is_empty());+ o.stock = count_of(first(link, &["stock", "qty", "in_stock"]));+ o.stock_date = day_of(first(link, &["checked", "stock_date", "checked_at", "date"]));+ o.tier = tier_of(link); }+ (!o.is_empty()).then_some(o)+}++/// basic / extended, when a record says (`tier`, `type`, `library_type`, `basic`, or `flags`).+fn tier_of(v: &Value) -> Option<String> {+ if let Some(b) = v["basic"].as_bool() { return Some(if b { "basic" } else { "extended" }.into()); }+ let words: Vec<String> = [&v["tier"], &v["type"], &v["library_type"]].iter().filter_map(|x| x.as_str().map(str::to_ascii_lowercase))+ .chain(v["flags"].as_array().into_iter().flatten().filter_map(|f| f.as_str().map(str::to_ascii_lowercase))).collect();+ if words.iter().any(|w| w.contains("basic")) { Some("basic".into()) } else if words.iter().any(|w| w.contains("extended")) { Some("extended".into()) } else { None }+}++/// The preferred source's row in a `pnp-inventory vendor-stock --mpn <MPN>` answer (the service's+/// hourly Mouser / DigiKey / LCSC stock cache): rows in an array (or under `vendors`, `vendor_stock`,+/// `rows`, `data`), or an object keyed by vendor. Timestamps are Unix seconds.+pub fn pnp_offer(profile: &str, answer: &Value) -> Option<Offer> {+ let want = |name: &str| { let n = name.to_ascii_lowercase(); if profile == "jlcpcb" { n.contains("lcsc") || n.contains("jlc") } else { n.contains("mouser") } };+ let rows: Vec<(String, Value)> = match first(answer, &["vendors", "vendor_stock", "vendorStock", "rows", "data"]) {+ Value::Array(a) => a.iter().map(|r| (String::new(), r.clone())).collect(),+ _ => match answer {+ Value::Array(a) => a.iter().map(|r| (String::new(), r.clone())).collect(),+ Value::Object(m) => m.iter().filter(|(_, v)| v.is_object()).map(|(k, v)| (k.clone(), v.clone())).collect(),+ _ => Vec::new(),+ },+ };+ let (_, r) = rows.into_iter().find(|(k, r)| {+ let name = first(r, &["vendor", "supplier", "source", "distributor"]).as_str().map(str::to_string).unwrap_or_else(|| k.clone());+ want(&name)+ })?;+ let mut o = Offer { pn_from: "pnp-inventory cache".into(), stock_from: "pnp-inventory cache".into(), ..Default::default() };+ o.pn = first(&r, &["vendor_pn", "supplier_pn", "part_number", "pn", "sku", "vendor_part_number"]).as_str().map(|s| s.trim().to_string()).filter(|s| !s.is_empty());+ o.stock = count_of(first(&r, &["stock", "qty", "quantity", "available", "in_stock", "qty_available"]));+ o.stock_date = day_of(first(&r, &["fetched_at", "cached_at", "updated_at", "checked_at", "as_of", "checked"]));+ o.tier = tier_of(&r);+ (!o.is_empty()).then_some(o)+}++/// The best live hit for the exact MPN at the profile's preferred source in an+/// `adom-parts-search search <MPN>` result (its unified JSON), searched on `date`:+/// Ok(Some) in stock, Ok(None) no exact match in stock, Err the vendor's failure.+pub fn live_offer(profile: &str, mpn: &str, search: &Value, date: &str) -> Result<Option<Offer>, String> {+ let v = &search[if profile == "jlcpcb" { "jlcpcb" } else { "mouser" }];+ if v["ok"] == false { return Err(v["error"].as_str().unwrap_or("no reason given").to_string()); } let best = v["hits"].as_array().into_iter().flatten()- .filter(|h| h["mpn"].as_str().map(|m| m.trim().eq_ignore_ascii_case(mpn)).unwrap_or(false))+ .filter(|h| h["mpn"].as_str().map(|m| m.trim().eq_ignore_ascii_case(mpn.trim())).unwrap_or(false)) .filter(|h| h["stock"].as_u64().unwrap_or(0) > 0) .max_by_key(|h| h["stock"].as_u64().unwrap_or(0));- let Some(h) = best else { return fallback(format!("no exact match at {pref}")) };- let (pn, qty) = (h["vendor_pn"].as_str().unwrap_or("?"), h["stock"].as_u64().unwrap_or(0));- if profile == "jlcpcb" {- let flags: Vec<String> = h["flags"].as_array().into_iter().flatten().filter_map(|f| f.as_str().map(|s| s.to_ascii_lowercase())).collect();- let tier = if flags.iter().any(|f| f == "basic") { "basic, " } else if flags.iter().any(|f| f == "extended") { "extended, " } else { "" };- format!("{who}: LCSC has {mpn} ({pn}, {tier}{qty} in stock): add it to the lcsc column")+ Ok(best.map(|h| Offer {+ pn: h["vendor_pn"].as_str().map(str::to_string), pn_from: "live search".into(),+ tier: if profile == "jlcpcb" { tier_of(h) } else { None },+ stock: h["stock"].as_u64(), stock_date: Some(date.get(..10).unwrap_or(date).to_string()), stock_from: "live search".into(),+ }))+}++/// One consigned row's suggestion: `known` is what the wiki page and the pnp-inventory cache say+/// (merged), `live` the live search when one ran (Ok(None) no exact match in stock, Err a failed+/// search). Always says where the number and the stock came from, and the stock's date; a stock+/// count with no date is "stock not recorded" with a live check to run. None: nothing known and no+/// live search. Pure, so the wording is tested offline.+pub fn row_suggestion(profile: &str, row: &Value, known: Option<&Offer>, live: Option<&Result<Option<Offer>, String>>) -> Option<String> {+ let mpn = row["mpn"].as_str().unwrap_or("").trim();+ let who = match row["source"].as_str().map(str::trim).filter(|s| !s.is_empty()) {+ Some(src) => format!("{} ({mpn}, {src})", row["refs"].as_str().unwrap_or("")),+ None => format!("{} ({mpn})", row["refs"].as_str().unwrap_or("")),+ };+ let pref = preferred_source(profile);+ let gain = if profile == "jlcpcb" { "add it to the lcsc column to save a consignment shipment" } else { "sourcing it there saves a consignment shipment" };+ let equivalent = format!("`adom-parts-search show \"{}\"` to look for an equivalent, or send this part as consigned", equivalent_query(row));+ let live_check = format!("`adom-parts-search show \"{mpn}\"` checks it live");+ let known = known.filter(|k| k.pn.is_some());+ // the live search found it in stock: that is the freshest answer+ let offer = match (live, known) {+ (Some(Ok(Some(l))), k) => { let mut o = l.clone(); if let Some(k) = k { if o.tier.is_none() { o.tier = k.tier.clone(); } } o }+ (Some(Ok(None)), Some(k)) => {+ let pn = k.pn.as_deref().unwrap_or("");+ return Some(format!("{who}: {pref} lists it as {pn} ({}), but the live search found none in stock; {equivalent}", k.pn_from));+ }+ (Some(Ok(None)), None) => return Some(format!("{who}: no exact match at {pref} (live search); {equivalent}")),+ (Some(Err(e)), None) => return Some(format!("{who}: the {pref} search failed ({e}); {equivalent}")),+ (Some(Err(_)), Some(k)) | (None, Some(k)) => k.clone(),+ (None, None) => return None,+ };+ let pn = offer.pn.clone().unwrap_or_default();+ let tier = offer.tier.as_deref().map(|t| format!("{t}, ")).unwrap_or_default();+ // the number with its source in brackets (and basic/extended on LCSC)+ let pn_sourced = format!("{pn} ({tier}{})", offer.pn_from);+ let Some((qty, date)) = offer.dated_stock() else {+ return Some(format!("{who}: {pref} has it, {pn_sourced}, stock not recorded: {live_check}; {gain}"));+ };+ let stock_text = if offer.pn_from == offer.stock_from {+ let tier = offer.tier.as_deref().map(|t| format!(" ({t})")).unwrap_or_default();+ format!("{pn}{tier}, {qty} in stock ({}, checked {date})", offer.stock_from) } else {- format!("{who}: Mouser has {mpn} ({pn}, {qty} in stock): set the source to Mouser to skip a consignment shipment")- }+ format!("{pn_sourced}, {qty} in stock ({}, checked {date})", offer.stock_from)+ };+ if qty == 0 { return Some(format!("{who}: {pref} lists it as {stock_text}; {equivalent}")); }+ let fresh = if offer.stock_from == "live search" { String::new() } else { format!(" ({live_check} before ordering)") };+ Some(format!("{who}: {pref} has it, {stock_text}; {gain}{fresh}")) } /// Pass/fail fields of an evidence file: a top-level `pass`, and `checks` rows with@@ -540,13 +689,48 @@ mod tests { {"mpn": "TPS54202DDCR", "vendor_pn": "595-TPS54202DDCR", "stock": 1200}]}, "jlcpcb": {"ok": true, "hits": [{"mpn": "TPS54202DDCR", "vendor_pn": "C191884", "stock": 299988, "flags": ["extended"]}]}}); let row = json!({"refs": "U1", "mpn": "TPS54202DDCR", "source": "DigiKey", "value": "", "package": ""});- assert_eq!(part_suggestion("fab", &row, &search), "U1 (TPS54202DDCR): Mouser has TPS54202DDCR (595-TPS54202DDCR, 1200 in stock): set the source to Mouser to skip a consignment shipment");- assert_eq!(part_suggestion("jlcpcb", &row, &search), "U1 (TPS54202DDCR): LCSC has TPS54202DDCR (C191884, extended, 299988 in stock): add it to the lcsc column");+ let live = |p: &str, s: &Value| live_offer(p, "TPS54202DDCR", s, "2026-10-27T01:02:03Z");+ assert_eq!(row_suggestion("fab", &row, None, Some(&live("fab", &search))).unwrap(), "U1 (TPS54202DDCR, DigiKey): Mouser has it, 595-TPS54202DDCR, 1200 in stock (live search, checked 2026-10-27); sourcing it there saves a consignment shipment");+ assert_eq!(row_suggestion("jlcpcb", &row, None, Some(&live("jlcpcb", &search))).unwrap(), "U1 (TPS54202DDCR, DigiKey): LCSC has it, C191884 (extended), 299988 in stock (live search, checked 2026-10-27); add it to the lcsc column to save a consignment shipment"); let out_of_stock = json!({"mouser": {"ok": true, "hits": [{"mpn": "TPS54202DDCR", "vendor_pn": "595-TPS54202DDCR", "stock": 0}]}}); let row = json!({"refs": "U1", "mpn": "TPS54202DDCR", "source": "DigiKey", "value": "buck 2A", "package": "SOT-23-6"});- assert_eq!(part_suggestion("fab", &row, &out_of_stock), "U1 (TPS54202DDCR): no exact match at Mouser; `adom-parts-search show \"buck 2A SOT-23-6\"` to look for an equivalent, or send this part as consigned");+ assert_eq!(row_suggestion("fab", &row, None, Some(&live("fab", &out_of_stock))).unwrap(), "U1 (TPS54202DDCR, DigiKey): no exact match at Mouser (live search); `adom-parts-search show \"buck 2A SOT-23-6\"` to look for an equivalent, or send this part as consigned"); let failed = json!({"jlcpcb": {"ok": false, "error": "timeout"}});- assert!(part_suggestion("jlcpcb", &row, &failed).contains("the LCSC search failed (timeout)"));+ assert!(row_suggestion("jlcpcb", &row, None, Some(&live("jlcpcb", &failed))).unwrap().contains("the LCSC search failed (timeout)"));+ assert_eq!(row_suggestion("fab", &row, None, None), None);+ }++ #[test]+ fn page_and_cache_numbers_say_where_they_came_from_and_when() {+ // the shapes real component pages use: a string, {part, url, stock, checked}, or false+ let page = json!({"component": {"mpn": "TPS54202DDCR", "distributor_links": {+ "mouser": {"part": "595-TPS54202DDCR", "url": "https://mouser.com/x", "stock": 160, "checked": "2026-10-20"},+ "digikey": "296-TPS54202DDCRTR-ND", "lcsc": "C191884"}}});+ let row = json!({"refs": "U2", "mpn": "TPS54202DDCR", "source": "DigiKey", "value": "", "package": ""});+ let w = page_offer("fab", "tps54202ddcr", &page, "adom/tps54202ddcr").unwrap();+ assert!(w.complete());+ assert_eq!(row_suggestion("fab", &row, Some(&w), None).unwrap(), "U2 (TPS54202DDCR, DigiKey): Mouser has it, 595-TPS54202DDCR, 160 in stock (wiki page adom/tps54202ddcr, checked 2026-10-20); sourcing it there saves a consignment shipment (`adom-parts-search show \"TPS54202DDCR\"` checks it live before ordering)");+ // a number with no stock on the page is never shown as stock+ let l = page_offer("jlcpcb", "TPS54202DDCR", &page, "adom/tps54202ddcr").unwrap();+ assert!(!l.complete());+ assert_eq!(row_suggestion("jlcpcb", &row, Some(&l), None).unwrap(), "U2 (TPS54202DDCR, DigiKey): LCSC has it, C191884 (wiki page adom/tps54202ddcr), stock not recorded: `adom-parts-search show \"TPS54202DDCR\"` checks it live; add it to the lcsc column to save a consignment shipment");+ // a stock count with no date is not recorded stock either+ let undated = json!({"component": {"mpn": "TPS54202DDCR", "distributor_links": {"mouser": {"part": "595-TPS54202DDCR", "stock": 160}}}});+ assert!(row_suggestion("fab", &row, page_offer("fab", "TPS54202DDCR", &undated, "adom/x").as_ref(), None).unwrap().contains("stock not recorded"));+ // a page for another MPN, or a distributor marked false, gives nothing+ assert_eq!(page_offer("fab", "TPS54202DDCT", &page, "adom/tps54202ddcr"), None);+ assert_eq!(page_offer("fab", "X1", &json!({"component": {"mpn": "X1", "distributor_links": {"mouser": false}}}), "adom/x1"), None);+ // the pnp-inventory cache fills the stock with its date; the number keeps its own source+ let pnp = json!([{"vendor": "Mouser", "vendor_pn": "595-TPS54202DDCR", "stock": 175, "fetched_at": 1793059200}, {"vendor": "DigiKey", "stock": 0}]);+ let p = pnp_offer("fab", &pnp).unwrap();+ assert_eq!((p.stock, p.stock_date.as_deref()), (Some(175), Some("2026-10-27")));+ let mut m = l.clone();+ m.merge(&pnp_offer("jlcpcb", &json!({"lcsc": {"stock": 900, "updated_at": "2026-10-26T10:00:00Z", "flags": ["basic"]}})).unwrap());+ assert_eq!(row_suggestion("jlcpcb", &row, Some(&m), None).unwrap(), "U2 (TPS54202DDCR, DigiKey): LCSC has it, C191884 (basic, wiki page adom/tps54202ddcr), 900 in stock (pnp-inventory cache, checked 2026-10-26); add it to the lcsc column to save a consignment shipment (`adom-parts-search show \"TPS54202DDCR\"` checks it live before ordering)");+ // the page has the number but the live search found none: say both+ assert!(row_suggestion("fab", &row, Some(&l), Some(&Ok(None))).is_some());+ let lw = page_offer("fab", "TPS54202DDCR", &page, "adom/tps54202ddcr");+ assert_eq!(row_suggestion("fab", &row, lw.as_ref(), Some(&Ok(None))).unwrap(), "U2 (TPS54202DDCR, DigiKey): Mouser lists it as 595-TPS54202DDCR (wiki page adom/tps54202ddcr), but the live search found none in stock; `adom-parts-search show \"TPS54202DDCR\"` to look for an equivalent, or send this part as consigned"); } #[test]
crates/adom-aiflow/src/reviews.rsadded+500@@ -0,0 +1,500 @@+//! Mechanical service-access and wiring reviews (issue #60).+//!+//! Two MIT prototypes, adom/adom-aiflow-mechanical and adom/adom-aiflow-wiring, lint a declared+//! review file (`lint <review.json>`: exit 0 pass, 1 findings, 2 input error, JSON on stdout with+//! `schemaVersion: 1` and `passed`). They check completeness and declared dimensions and lengths;+//! they are not geometry solvers and are not released as wiki packages yet.+//!+//! AI Flow's side: a spec (or requirements) declaration makes the review a required step before+//! finish, `review mechanical|wiring` runs the tool and records its passing result bound to the+//! input, spec and board hashes, finish refuses a missing, failing or stale review, and a board+//! adoption that moves a named connector or any hole voids the reviews and the test plan. A run+//! whose spec declares nothing is unchanged.+use crate::copper_carry::{self, sha256_file};+use aiflow_run::Run;+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++/// The AI Flow review contract version (the declaration, the input file and the recorded outcome).+pub const SCHEMA_VERSION: u64 = 1;+/// The optional review steps, inserted before `finish` when declared.+pub const STEPS: [&str; 2] = ["mechanical-review", "wiring-review"];++pub struct Kind {+ pub name: &'static str,+ /// the spec / requirements key that declares it+ pub key: &'static str,+ pub step: &'static str,+ pub tool: &'static str,+ /// a clone of the tool's source repo, run with `cargo run`+ pub src_env: &'static str,+ pub repo: &'static str,+ /// the input file's inventory list+ pub list: &'static str,+}++pub const KINDS: [Kind; 2] = [+ Kind { name: "mechanical", key: "mechanicalReview", step: "mechanical-review", tool: "adom-aiflow-mechanical", src_env: "AIFLOW_MECHANICAL_SRC", repo: "adom/adom-aiflow-mechanical", list: "fasteners" },+ Kind { name: "wiring", key: "wiringReview", step: "wiring-review", tool: "adom-aiflow-wiring", src_env: "AIFLOW_WIRING_SRC", repo: "adom/adom-aiflow-wiring", list: "harnesses" },+];++pub fn kind(name: &str) -> Option<&'static Kind> { KINDS.iter().find(|k| k.name == name) }++/// A review the spec or requirements declare.+pub struct Declared {+ pub kind: &'static Kind,+ pub decl: Value,+ /// the file that declares it (its directory resolves relative paths)+ pub source: PathBuf,+ pub in_requirements: bool,+}++fn read(p: &str) -> Option<Value> { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()) }++fn requirements_path(data: &Value) -> Option<String> {+ data["intake"]["requirements"].as_str().or(data["requirements"].as_str()).filter(|s| !s.is_empty()).map(str::to_string)+}++fn declared_value(v: &Value) -> bool { !(v.is_null() || *v == Value::Bool(false)) }++/// The declared reviews: the spec's `mechanicalReview` / `wiringReview`, else the requirements'.+pub fn declared(data: &Value) -> Vec<Declared> {+ let spec = data["spec"].as_str().filter(|s| !s.is_empty()).map(str::to_string);+ let req = requirements_path(data);+ let sv = spec.as_deref().and_then(read).unwrap_or(Value::Null);+ let rv = req.as_deref().and_then(read).unwrap_or(Value::Null);+ let mut out = Vec::new();+ for k in &KINDS {+ if declared_value(&sv[k.key]) {+ out.push(Declared { kind: k, decl: sv[k.key].clone(), source: PathBuf::from(spec.clone().unwrap_or_default()), in_requirements: false });+ } else if declared_value(&rv[k.key]) {+ out.push(Declared { kind: k, decl: rv[k.key].clone(), source: PathBuf::from(req.clone().unwrap_or_default()), in_requirements: true });+ }+ }+ out+}++/// What is wrong with a declaration (schemaVersion, input).+pub fn decl_problems(d: &Declared) -> Vec<String> {+ let mut out = Vec::new();+ let where_ = if d.in_requirements { "requirements" } else { "spec" };+ if !d.decl.is_object() { return vec![format!("{where_} {} must be an object {{\"schemaVersion\": 1, \"input\": \"<review.json>\", \"refs\": [...]}}", d.kind.key)]; }+ if d.decl["schemaVersion"].as_u64() != Some(SCHEMA_VERSION) { out.push(format!("{where_} {}.schemaVersion must be {SCHEMA_VERSION} (found {})", d.kind.key, d.decl["schemaVersion"])); }+ if d.decl["input"].as_str().map_or(true, |s| s.trim().is_empty()) { out.push(format!("{where_} {}.input names no review file", d.kind.key)); }+ out+}++fn resolve(base: &Path, p: &str) -> PathBuf {+ let pb = PathBuf::from(p);+ if pb.is_absolute() { pb } else { base.parent().map(|d| d.join(&pb)).unwrap_or(pb) }+}++fn strings(v: &Value) -> Vec<String> { v.as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() }++/// The board references a review names: the declaration's `refs`, each fastener's `ref` and each+/// harness's `connectors` (AI Flow fields the tools ignore). Prefixes come from `refPrefixes`.+pub fn refs_of(decl: &Value, input: &Value) -> (Vec<String>, Vec<String>) {+ let mut refs = strings(&decl["refs"]);+ for f in input["fasteners"].as_array().into_iter().flatten() { if let Some(r) = f["ref"].as_str() { refs.push(r.to_string()); } }+ for h in input["harnesses"].as_array().into_iter().flatten() { refs.extend(strings(&h["connectors"])); }+ refs.retain(|r| !r.trim().is_empty());+ refs.sort();+ refs.dedup();+ (refs, strings(&decl["refPrefixes"]))+}++/// Insert the declared review steps before `finish` in the run's recorded sequence. Returns what was added.+pub fn sync_sequence(data: &mut Value) -> Vec<String> {+ let want: Vec<&str> = declared(data).iter().map(|d| d.kind.step).collect();+ let Some(seq) = data["sequence"].as_array_mut() else { return vec![] };+ let mut added = Vec::new();+ for step in STEPS.iter().filter(|s| want.contains(s)) {+ if seq.iter().any(|s| s == step) { continue; }+ let at = seq.iter().position(|s| s == "finish").unwrap_or(seq.len());+ seq.insert(at, json!(step));+ added.push(step.to_string());+ }+ added+}++/// Where the tool comes from.+#[derive(Debug, Clone)]+pub enum Tool { Exe(PathBuf), Cargo(PathBuf) }++impl Tool {+ fn identity(&self) -> Value {+ match self {+ Tool::Exe(p) => json!({"mode": "path", "path": p.display().to_string(), "sha256": sha256_file(p).ok()}),+ Tool::Cargo(d) => {+ let mut h = String::new();+ for f in ["Cargo.toml", "Cargo.lock", "src/main.rs"] { h.push_str(&sha256_file(&d.join(f)).unwrap_or_default()); h.push('\n'); }+ use sha2::{Digest, Sha256};+ json!({"mode": "cargo", "source": d.display().to_string(), "sourceSha256": format!("{:x}", Sha256::digest(h.as_bytes()))})+ }+ }+ }+}++pub fn not_installed_hints(k: &Kind) -> Vec<String> {+ vec![+ format!("{} is an unreleased MIT prototype: there is no wiki package to install yet (`adom-wiki pkg info {}` has no release). Its source is the wiki repo {}.", k.tool, k.repo, k.repo),+ format!("Get it: `adom-wiki repo clone {} --dir <dir>`, then either `cargo build --release` there and put <dir>/target/release on PATH, or set {}=<dir> so AI Flow runs it with `cargo run --release`.", k.repo, k.src_env),+ format!("The declared review stays required: finish refuses until `review {}` passes on this board. Removing the declaration from the spec is a design decision for the human, not a way past the check.", k.name),+ ]+}++fn on_path(name: &str) -> Option<PathBuf> {+ std::env::var_os("PATH").and_then(|p| std::env::split_paths(&p).map(|d| d.join(name)).find(|f| f.is_file()))+}++/// The tool: a source clone named by the env variable (explicit), else the executable on PATH.+pub fn find_tool(k: &Kind) -> Result<Tool, String> {+ if let Some(src) = std::env::var_os(k.src_env).filter(|s| !s.is_empty()) {+ let d = PathBuf::from(src);+ if !d.join("Cargo.toml").is_file() { return Err(format!("{}={} has no Cargo.toml (not a clone of {})", k.src_env, d.display(), k.repo)); }+ if on_path("cargo").is_none() { return Err(format!("{} is set but cargo is not on PATH", k.src_env)); }+ return Ok(Tool::Cargo(d));+ }+ on_path(k.tool).map(Tool::Exe).ok_or_else(|| format!("{} is not installed (not on PATH and {} is not set)", k.tool, k.src_env))+}++/// Run `tool <args>`: (exit code, stdout JSON or null, stderr).+pub fn run_tool(tool: &Tool, args: &[&str]) -> Result<(i32, Value, String), String> {+ let mut c = match tool {+ Tool::Exe(p) => std::process::Command::new(p),+ Tool::Cargo(d) => { let mut c = std::process::Command::new("cargo"); c.args(["run", "--release", "--quiet", "--manifest-path"]).arg(d.join("Cargo.toml")).arg("--"); c }+ };+ let o = c.args(args).stdin(std::process::Stdio::null()).output().map_err(|e| format!("could not run the review tool: {e}"))?;+ let v = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);+ Ok((o.status.code().unwrap_or(-1), v, String::from_utf8_lossy(&o.stderr).trim().to_string()))+}++/// The tool's verdict: Ok when exit 0 with `schemaVersion: 1` and `passed: true`; Err(why) otherwise.+pub fn verdict(code: i32, out: &Value, stderr: &str) -> Result<(), String> {+ if code == 2 { return Err(format!("the tool refused its input (exit 2): {stderr}")); }+ if out["schemaVersion"].as_u64() != Some(SCHEMA_VERSION) { return Err(format!("the tool's output is not schemaVersion {SCHEMA_VERSION} (exit {code}); AI Flow speaks review contract {SCHEMA_VERSION} only")); }+ match (code, out["passed"].as_bool()) {+ (0, Some(true)) => Ok(()),+ (1, Some(false)) => Err(format!("{} unresolved finding(s)", out["findings"].as_array().map_or(0, |a| a.len()))),+ (c, p) => Err(format!("inconsistent result (exit {c}, passed {p:?})")),+ }+}++fn finding_lines(out: &Value) -> Vec<String> {+ out["findings"].as_array().into_iter().flatten().take(15)+ .map(|f| format!("{} {}: {}", f["id"].as_str().unwrap_or("?"), f["check"].as_str().unwrap_or(""), f["hint"].as_str().unwrap_or(""))).collect()+}++fn board_refs(board: &Path) -> Vec<String> {+ let text = std::fs::read_to_string(board).unwrap_or_default();+ let text = if aiflow_board::eagle::is_eagle(&text) { aiflow_board::eagle::to_kicad(&text).unwrap_or_default() } else { text };+ aiflow_board::sx::parse(&text).map(|root| root.find_all("footprint").filter_map(aiflow_board::reference_of).collect()).unwrap_or_default()+}++/// `review <kind> [--input f] [--glb f]`: run the tool and record a passing result in the run.+/// Ok((message, hints)) or Err((message, hints)); the caller saves the run either way.+pub fn run(r: &mut Run, name: &str, input: Option<&str>, glb: Option<&str>) -> Result<(String, Vec<String>), (String, Vec<String>)> {+ let k = kind(name).ok_or_else(|| (format!("review {name}: use mechanical or wiring"), vec![]))?;+ let added = sync_sequence(&mut r.data);+ let decls = declared(&r.data);+ let d = decls.iter().find(|d| d.kind.name == k.name);+ if let Some(d) = d { let p = decl_problems(d); if !p.is_empty() { return Err((p.join("; "), vec![contract_hint(k)])); } }+ let input = match (input, d) {+ (Some(i), _) => PathBuf::from(i),+ (None, Some(d)) => resolve(&d.source, d.decl["input"].as_str().unwrap_or("")),+ (None, None) => return Err((format!("review {name} needs --input <review.json> (the spec declares no {})", k.key), vec![contract_hint(k)])),+ };+ let input = std::fs::canonicalize(&input).unwrap_or(input);+ let iv: Value = std::fs::read_to_string(&input).map_err(|e| format!("{}: {e}", input.display())).and_then(|t| serde_json::from_str(&t).map_err(|e| format!("{}: {e}", input.display())))+ .map_err(|e| (e, vec![contract_hint(k)]))?;+ if iv["schemaVersion"].as_u64() != Some(SCHEMA_VERSION) { return Err((format!("{} has schemaVersion {} (AI Flow review contract {SCHEMA_VERSION} needs \"schemaVersion\": {SCHEMA_VERSION} at the top level)", input.display(), iv["schemaVersion"]), vec![contract_hint(k)])); }+ if !iv[k.list].is_array() { return Err((format!("{} has no \"{}\" list", input.display(), k.list), vec![contract_hint(k)])); }+ let board = r.current_board();+ if board.is_empty() || !Path::new(&board).is_file() { return Err(("no board yet: the review is bound to the run's current board".into(), vec!["Attach the board first (start --board, or adopt-board).".into()])); }+ let bp = PathBuf::from(&board);+ let null = Value::Null;+ let (refs, prefixes) = refs_of(d.map(|d| &d.decl).unwrap_or(&null), &iv);+ let on_board = board_refs(&bp);+ let absent: Vec<&String> = refs.iter().filter(|x| !on_board.contains(x)).collect();+ if !absent.is_empty() { return Err((format!("the review names {} that the current board does not have", absent.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")), vec!["Bring the review file up to date with the board (or the board with the review) and run it again: a review of parts the board no longer has is stale.".into()])); }+ let glb = glb.map(PathBuf::from).or_else(|| d.and_then(|d| d.decl["glb"].as_str().map(|g| resolve(&d.source, g))));+ if glb.is_some() && k.name != "wiring" { return Err(("--glb is the wiring tool's audit-glb; the mechanical review has none".into(), vec![])); }+ let tool = find_tool(k).map_err(|e| (e, not_installed_hints(k)))?;+ let input_s = input.display().to_string();+ let (code, out, stderr) = run_tool(&tool, &["lint", &input_s]).map_err(|e| (e, not_installed_hints(k)))?;+ let mut audit = Value::Null;+ let mut result = verdict(code, &out, &stderr);+ if let (Ok(()), Some(g)) = (&result, &glb) {+ let gs = g.display().to_string();+ let (c2, o2, e2) = run_tool(&tool, &["audit-glb", &gs]).map_err(|e| (e, not_installed_hints(k)))?;+ result = verdict(c2, &o2, &e2).map_err(|e| format!("audit-glb {gs}: {e}"));+ audit = json!({"glb": gs, "glbSha256": sha256_file(g).ok(), "exitCode": c2, "result": o2});+ }+ let features = copper_carry::service_features(&bp, &refs, &prefixes).map_err(|e| (format!("cannot read the board's connectors and holes: {e}"), vec![]))?;+ let spec = r.data["spec"].as_str().unwrap_or("").to_string();+ let rec = json!({+ "schemaVersion": SCHEMA_VERSION, "kind": k.name, "declared": d.is_some(), "tool": tool.identity(),+ "input": input_s, "inputSha256": sha256_file(&input).ok(),+ "spec": spec, "specSha256": if spec.is_empty() { Value::Null } else { json!(sha256_file(Path::new(&spec)).ok()) },+ "requirementsSha256": d.filter(|d| d.in_requirements).and_then(|d| sha256_file(&d.source).ok()),+ "board": board, "boardSha256": sha256_file(&bp).ok(), "refs": refs, "refPrefixes": prefixes, "serviceFeatures": features,+ "exitCode": code, "result": out, "audit": audit, "at": aiflow_run::now(),+ "qualification": "declared review completeness and dimensions only; not a swept-volume or collision solver, not physical-fit evidence",+ });+ match result {+ Ok(()) => {+ let mut passed = rec.clone();+ passed["pass"] = json!(true);+ r.outcome(k.step, passed);+ let mut hints = vec!["This proves the declared review is complete and its stated dimensions and lengths fit; it does not compute swept volumes or collisions. Keep the CAD check reports the review file links to.".to_string()];+ if !added.is_empty() { hints.push(format!("Added {} to this run's steps before finish.", added.join(" and "))); }+ if d.is_none() { hints.push(format!("Not declared: finish does not require it. Declare spec {} {{\"schemaVersion\": 1, \"input\": ...}} to make it a required step.", k.key)); }+ else if r.data["currentStep"].as_str() != Some(k.step) { hints.push(format!("Record it during `step {}` so the step is proven (run the review again after the step starts).", k.step)); }+ hints.push("A board adoption that moves a named connector or any hole voids it; run it again then.".into());+ Ok((format!("{name} review passed on board {} ({} connector/hole feature(s) bound)", Path::new(&board).file_name().and_then(|f| f.to_str()).unwrap_or(&board), rec["serviceFeatures"].as_array().map_or(0, |a| a.len())), hints))+ }+ Err(why) => {+ // a failing run supersedes an earlier pass+ let had = r.data["outcomes"].as_object_mut().and_then(|o| o.remove(k.step)).is_some();+ r.log("review-failed", json!({"kind": k.name, "why": why, "record": rec, "supersededPass": had}));+ let mut hints = finding_lines(&out);+ hints.push(format!("Exit 1 means unresolved review work, not permission to waive it. Fix the design or the review file ({}), then `review {name}` again. Never set a Boolean true just to satisfy the linter.", input.display()));+ Err((format!("{name} review did not pass: {why}"), hints))+ }+ }+}++pub fn contract_hint(k: &Kind) -> String {+ format!("Contract (docs/mechanical-wiring-reviews.md): spec {} {{\"schemaVersion\": 1, \"input\": \"<file relative to the spec>\", \"refs\": [\"J1\", \"H1\"], \"refPrefixes\": [\"J\"]}}; the input file has \"schemaVersion\": 1 and a \"{}\" list in the {} tool's format.", k.key, k.list, k.tool)+}++/// Whether the recorded review is still bound to this board: the same file, or one reached through the+/// run's copper carry records (copper fingerprint unchanged, so every footprint is where it was).+fn board_bound(data: &Value, recorded: &str, board: &Path) -> Result<(), String> {+ if sha256_file(board)? == recorded { return Ok(()); }+ copper_carry::carried_from(data, recorded, board).map(|_| ()).or_else(|e| copper_carry::carried_without_zones(data, recorded, board).map(|_| ()).map_err(|_| e))+}++/// Why a declared review does not count at finish (empty when it does).+pub fn binding_gaps(data: &Value, d: &Declared, board: &Path) -> Vec<String> {+ let k = d.kind;+ let p = decl_problems(d);+ if !p.is_empty() { return p; }+ let o = &data["outcomes"][k.step];+ let again = format!("`step {}`, then `review {}`", k.step, k.name);+ if o.is_null() { return vec![format!("{} review not recorded on this board ({again})", k.name)]; }+ if o["pass"] != true || o["schemaVersion"].as_u64() != Some(SCHEMA_VERSION) { return vec![format!("{} review did not pass ({again})", k.name)]; }+ let mut out = Vec::new();+ let input = resolve(&d.source, d.decl["input"].as_str().unwrap_or(""));+ let input = std::fs::canonicalize(&input).unwrap_or(input);+ if o["input"].as_str() != Some(input.display().to_string().as_str()) { out.push(format!("{} review was run on {} but the spec declares {} ({again})", k.name, o["input"].as_str().unwrap_or("?"), input.display())); }+ else if o["inputSha256"].as_str() != sha256_file(&input).ok().as_deref() { out.push(format!("{} review file changed since the review ({again})", k.name)); }+ if let Some(s) = data["spec"].as_str().filter(|s| !s.is_empty()) {+ if o["specSha256"].as_str() != sha256_file(Path::new(s)).ok().as_deref() { out.push(format!("{} review: the spec changed since the review ({again})", k.name)); }+ }+ if d.in_requirements && o["requirementsSha256"].as_str() != sha256_file(&d.source).ok().as_deref() { out.push(format!("{} review: the requirements changed since the review ({again})", k.name)); }+ if let Some(g) = o["audit"]["glb"].as_str() {+ if o["audit"]["glbSha256"].as_str() != sha256_file(Path::new(g)).ok().as_deref() { out.push(format!("{} review: {g} changed since audit-glb ({again})", k.name)); }+ }+ match o["boardSha256"].as_str() {+ None => out.push(format!("{} review has no board hash ({again})", k.name)),+ Some(h) => if let Err(e) = board_bound(data, h, board) { out.push(format!("{} review is of another board ({e}; {again})", k.name)); },+ }+ out+}++/// Everything finish refuses for the reviews: each declared review's gaps, and a test plan voided by a+/// connector or hole move (issue #60) that has not been checked again.+pub fn finish_gaps(data: &Value, board: &Path) -> Vec<String> {+ let mut out: Vec<String> = declared(data).iter().flat_map(|d| binding_gaps(data, d, board)).collect();+ if data["outcomes"]["testplan"].is_null() && data["serviceInvalidated"].as_array().is_some_and(|a| a.iter().any(|e| strings(&e["outcomes"]).iter().any(|k| k == "testplan"))) {+ out.push("test plan: connectors or holes moved since it was checked (`testplan check` again)".into());+ }+ out+}++/// The outcomes voided when a review's connectors or holes change: the reviews and the probing test plan.+/// The routing and thermal evidence (route, gate, land-*, measure, analyze-current, analyze-thermal) is+/// already voided by `add_board` because a moved footprint always changes the copper fingerprint.+pub const VOIDED: [&str; 3] = ["mechanical-review", "wiring-review", "testplan"];++/// Called on adopt-board and place check before the new board becomes current. Only for runs that+/// declare or recorded a review (others are unchanged). Compares the service features of `old` and `new`+/// for the declared and recorded references plus every hole; on a change (or when either board cannot+/// be read) removes `VOIDED` outcomes and logs `service-evidence-invalidated`. Returns a sentence.+pub fn void_on_service_change(r: &mut Run, old: &Path, new: &Path) -> Option<String> {+ let decls = declared(&r.data);+ let recorded: Vec<&Value> = STEPS.iter().map(|s| &r.data["outcomes"][*s]).filter(|o| !o.is_null()).collect();+ if decls.is_empty() && recorded.is_empty() { return None; }+ if old.as_os_str().is_empty() { return None; }+ let mut refs: Vec<String> = Vec::new();+ let mut prefixes: Vec<String> = Vec::new();+ for d in &decls {+ let iv = read(&resolve(&d.source, d.decl["input"].as_str().unwrap_or("")).display().to_string()).unwrap_or(Value::Null);+ let (a, b) = refs_of(&d.decl, &iv);+ refs.extend(a);+ prefixes.extend(b);+ }+ for o in &recorded { refs.extend(strings(&o["refs"])); prefixes.extend(strings(&o["refPrefixes"])); }+ refs.sort(); refs.dedup(); prefixes.sort(); prefixes.dedup();+ let changed = match (copper_carry::service_features(old, &refs, &prefixes), copper_carry::service_features(new, &refs, &prefixes)) {+ (Ok(a), Ok(b)) => copper_carry::service_changes(&a, &b),+ (Err(e), _) | (_, Err(e)) => vec![format!("unreadable board ({e})")],+ };+ if changed.is_empty() { return None; }+ let mut voided = Vec::new();+ if let Some(o) = r.data["outcomes"].as_object_mut() { for k in VOIDED { if o.remove(k).is_some() { voided.push(k); } } }+ let rec = json!({"newBoard": new.display().to_string(), "changed": changed, "outcomes": voided, "at": aiflow_run::now(),+ "reason": "a reviewed connector or a hole moved, was added or was removed; routing and thermal evidence is voided by the copper change itself"});+ r.log("service-evidence-invalidated", rec.clone());+ if !r.data["serviceInvalidated"].is_array() { r.data["serviceInvalidated"] = json!([]); }+ r.data["serviceInvalidated"].as_array_mut().unwrap().push(rec);+ Some(format!("connectors or holes changed ({}): {} must be recorded again", changed.join(", "), if voided.is_empty() { "the reviews and the test plan".to_string() } else { voided.join(", ") }))+}++#[cfg(test)]+mod tests {+ use super::*;++ const HEAD: &str = "(kicad_pcb (version 20240108) (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)) (gr_rect (start 0 0) (end 40 30) (layer \"Edge.Cuts\"))";+ fn board(jx: f64, hx: f64, rx: f64) -> String {+ format!("{HEAD} (footprint \"Conn:J\" (layer \"F.Cu\") (at {jx} 10) (property \"Reference\" \"J9\") (pad \"1\" thru_hole circle (at 0 0) (size 2 2) (drill 1) (layers \"*.Cu\") (net 1 \"GND\")))\+ (footprint \"MountingHole:MountingHole_3.2mm_M3\" (layer \"F.Cu\") (at {hx} 25) (property \"Reference\" \"H1\") (pad \"\" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers \"*.Cu\" \"*.Mask\")))\+ (footprint \"R:R0603\" (layer \"F.Cu\") (at {rx} 5) (property \"Reference\" \"R1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 1 \"GND\"))))\n")+ }+ struct T { dir: PathBuf }+ impl T {+ fn new(name: &str) -> T { let dir = std::env::temp_dir().join(format!("aiflow-reviews-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); T { dir } }+ fn write(&self, n: &str, t: &str) -> PathBuf { let p = self.dir.join(n); std::fs::write(&p, t).unwrap(); p }+ }+ impl Drop for T { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.dir); } }++ fn run_with(t: &T, spec: &Value, board_text: &str) -> Run {+ let b = t.write("board.kicad_pcb", board_text);+ let s = t.write("spec.json", &spec.to_string());+ let mut r = Run::create(&t.dir.join("run"), &b.display().to_string(), &s.display().to_string(), "test", "2026-10-27T00:00:00Z", "reviews-test", None, None).unwrap();+ r.data["sequence"] = json!(["intake", "placement", "3d", "finish", "export"]);+ r+ }+ fn decl() -> Value { json!({"mechanicalReview": {"schemaVersion": 1, "input": "mech.json", "refs": ["J9"]}}) }+ fn mech_input() -> String { json!({"schemaVersion": 1, "fasteners": [{"id": "s1", "ref": "H1"}]}).to_string() }++ fn record_pass(r: &mut Run, t: &T) {+ let input = std::fs::canonicalize(t.dir.join("mech.json")).unwrap();+ let spec = r.data["spec"].as_str().unwrap().to_string();+ let board = r.current_board();+ r.outcome("mechanical-review", json!({"schemaVersion": 1, "pass": true, "input": input.display().to_string(), "inputSha256": sha256_file(&input).unwrap(),+ "specSha256": sha256_file(Path::new(&spec)).unwrap(), "boardSha256": sha256_file(Path::new(&board)).unwrap(), "refs": ["H1", "J9"]}));+ }++ #[test]+ fn not_declared_changes_nothing() {+ let t = T::new("none");+ let mut r = run_with(&t, &json!({}), &board(10.0, 30.0, 20.0));+ let before = r.data["sequence"].clone();+ assert!(sync_sequence(&mut r.data).is_empty());+ assert_eq!(r.data["sequence"], before);+ assert!(finish_gaps(&r.data, Path::new(&r.current_board())).is_empty());+ let moved = t.write("moved.kicad_pcb", &board(12.0, 32.0, 20.0));+ let cur = PathBuf::from(r.current_board());+ assert!(void_on_service_change(&mut r, &cur, &moved).is_none());+ }++ #[test]+ fn declared_review_is_a_step_before_finish_and_missing_blocks_finish() {+ let t = T::new("missing");+ t.write("mech.json", &mech_input());+ let mut r = run_with(&t, &decl(), &board(10.0, 30.0, 20.0));+ assert_eq!(sync_sequence(&mut r.data), vec!["mechanical-review"]);+ assert_eq!(r.data["sequence"], json!(["intake", "placement", "3d", "mechanical-review", "finish", "export"]));+ assert!(sync_sequence(&mut r.data).is_empty(), "idempotent");+ let g = finish_gaps(&r.data, Path::new(&r.current_board()));+ assert!(g.len() == 1 && g[0].contains("mechanical review not recorded"), "{g:?}");+ }++ #[test]+ fn passing_review_with_matching_hashes_satisfies_finish_and_stale_inputs_do_not() {+ let t = T::new("pass");+ t.write("mech.json", &mech_input());+ let mut r = run_with(&t, &decl(), &board(10.0, 30.0, 20.0));+ record_pass(&mut r, &t);+ let b = PathBuf::from(r.current_board());+ assert!(finish_gaps(&r.data, &b).is_empty(), "{:?}", finish_gaps(&r.data, &b));+ // a failing record does not count+ let mut failed = r.data.clone();+ failed["outcomes"]["mechanical-review"]["pass"] = json!(false);+ assert!(!finish_gaps(&failed, &b).is_empty());+ // the review file edited after the review+ t.write("mech.json", &json!({"schemaVersion": 1, "fasteners": []}).to_string());+ assert!(finish_gaps(&r.data, &b).iter().any(|g| g.contains("review file changed")));+ t.write("mech.json", &mech_input());+ // the spec edited after the review+ let spec = r.data["spec"].as_str().unwrap().to_string();+ let mut sv = decl(); sv["title"] = json!("x");+ std::fs::write(&spec, sv.to_string()).unwrap();+ assert!(finish_gaps(&r.data, &b).iter().any(|g| g.contains("spec changed")));+ // a wrong contract version is refused+ let mut bad = decl(); bad["mechanicalReview"]["schemaVersion"] = json!(2);+ std::fs::write(&spec, bad.to_string()).unwrap();+ assert!(finish_gaps(&r.data, &b).iter().any(|g| g.contains("schemaVersion must be 1")));+ }++ #[test]+ fn a_different_board_voids_the_review() {+ let t = T::new("board");+ t.write("mech.json", &mech_input());+ let mut r = run_with(&t, &decl(), &board(10.0, 30.0, 20.0));+ record_pass(&mut r, &t);+ let other = t.write("other.kicad_pcb", &board(10.0, 30.0, 21.0));+ r.add_board(&other.display().to_string());+ let g = finish_gaps(&r.data, &other);+ assert!(g.iter().any(|g| g.contains("another board")), "{g:?}");+ }++ #[test]+ fn moving_a_named_connector_or_a_hole_voids_reviews_and_test_plan_but_other_parts_do_not() {+ let t = T::new("void");+ t.write("mech.json", &mech_input());+ let mut r = run_with(&t, &decl(), &board(10.0, 30.0, 20.0));+ let old = PathBuf::from(r.current_board());+ record_pass(&mut r, &t);+ r.outcome("testplan", json!({"mode": "board"}));+ // R1 moved: not a service feature+ let r1 = t.write("r1.kicad_pcb", &board(10.0, 30.0, 22.0));+ assert!(void_on_service_change(&mut r, &old, &r1).is_none());+ assert!(!r.data["outcomes"]["mechanical-review"].is_null());+ // the mounting hole moved+ let h1 = t.write("h1.kicad_pcb", &board(10.0, 31.0, 20.0));+ let s = void_on_service_change(&mut r, &old, &h1).unwrap();+ assert!(s.contains("H1"), "{s}");+ assert!(r.data["outcomes"]["mechanical-review"].is_null() && r.data["outcomes"]["testplan"].is_null());+ assert!(finish_gaps(&r.data, &h1).iter().any(|g| g.contains("test plan")));+ // the named connector moved+ record_pass(&mut r, &t);+ let j9 = t.write("j9.kicad_pcb", &board(11.0, 30.0, 20.0));+ assert!(void_on_service_change(&mut r, &old, &j9).unwrap().contains("J9"));+ assert_eq!(r.data["serviceInvalidated"].as_array().unwrap().len(), 2);+ }++ #[test]+ fn tool_verdicts() {+ assert!(verdict(0, &json!({"schemaVersion": 1, "passed": true}), "").is_ok());+ assert!(verdict(1, &json!({"schemaVersion": 1, "passed": false, "findings": [{}]}), "").unwrap_err().contains("1 unresolved"));+ assert!(verdict(2, &Value::Null, "bad json").unwrap_err().contains("exit 2"));+ assert!(verdict(0, &json!({"schemaVersion": 2, "passed": true}), "").is_err());+ assert!(verdict(0, &json!({"schemaVersion": 1, "passed": false}), "").is_err());+ }++ #[test]+ fn refs_come_from_declaration_fasteners_and_harness_connectors() {+ let (r, p) = refs_of(&json!({"refs": ["J1"], "refPrefixes": ["CN"]}), &json!({"fasteners": [{"ref": "H2"}], "harnesses": [{"connectors": ["J9", "J1"]}]}));+ assert_eq!(r, vec!["H2", "J1", "J9"]);+ assert_eq!(p, vec!["CN"]);+ }++ #[test]+ fn missing_tool_names_the_repo_and_says_it_is_unreleased() {+ let h = not_installed_hints(kind("wiring").unwrap()).join(" ");+ assert!(h.contains("adom/adom-aiflow-wiring") && h.contains("unreleased") && h.contains("AIFLOW_WIRING_SRC"));+ }+}
crates/adom-aiflow/src/route_variants.rsadded+269@@ -0,0 +1,269 @@+//! `route --variants N` and `route --pick K --why` (issue #55). A plain `route` is `route_once`+//! with the baseline levers; variants route the same board N times with the levers of+//! `aiflow_router::variants::plan`, gate every plan with the checks `gate` runs+//! (gate_eval::evaluate), and rank them by the gates alone. The winner then goes through the same+//! steps as a plain route (main.rs), and every variant stays on the route outcome and the ledger.+use std::path::Path;++use aiflow_board::Board;+use aiflow_router::variants::{self, Levers};+use aiflow_router::Rules;+use aiflow_run::Run;+use serde_json::{json, Value};++use crate::{err, gate_eval, read_json, write_json};++/// One route's result, as the rest of `route` needs it.+pub struct Routed {+ pub plan_doc: Value,+ pub report: Value,+ pub failed: usize,+ pub simp_line: String,+ pub log: Vec<String>,+ pub board: Board,+ pub has_copper: bool,+ pub bc: Value,+}++/// The issue #39 simplify line, from the report's "simplify" block.+pub fn simp_line(simp: &Value) -> String {+ format!("simplified: {} staircase(s) replaced, {} kept (no clear 45 degree path), {} collinear point(s) merged; vertices {} -> {} (per net in plan-route-report.json \"simplify\")", simp["staircasesReplaced"], simp["staircasesKept"], simp["collinearMerged"], simp["verticesBefore"], simp["verticesAfter"])+}++/// One route of `b` with `levers` (rules already carry the spec): the router, the 45 degree+/// simplify pass and the plan document a plain `route` writes.+pub fn route_once(b: Board, rules: &Rules, levers: &Levers, rip_per_net: usize, rip_global: usize, plane_under: &[String], verbose: bool) -> Routed {+ let mut router = aiflow_router::Router::new(b, levers.apply(rules), verbose);+ router.order = levers.order;+ let (mut plan, mut report, failed) = aiflow_router::drive::route_board(&mut router, levers.passes, rip_per_net, rip_global);+ // issue #39: staircases to clean 45 degree runs before the gate (the router's own clearance check decides)+ let simp = aiflow_router::simplify::simplify_plan(&mut router, &mut plan);+ report["simplify"] = simp.clone();+ let bc = report["boardCopper"].clone();+ // filled zone islands count: a pour alone can join a net's pads (issue #84)+ let has_copper = bc["trackPieces"].as_u64().unwrap_or(0) + bc["vias"].as_u64().unwrap_or(0) + bc["zoneIslands"].as_u64().unwrap_or(0) > 0;+ let mut plan_doc = json!({"engine": "adom-aiflow router", "nets": plan});+ if has_copper {+ let complete = bc["netsComplete"].as_array().map(|a| a.len()).unwrap_or(0);+ plan_doc["boardCopper"] = bc.clone();+ plan_doc["note"] = json!(if plan.is_empty() && failed == 0 {+ format!("nothing to route: the board's own copper ({} track pieces, {} vias, {} filled zone islands) already joins every net ({complete} complete); this empty plan is the route result, and gate, land route and finish accept it", bc["trackPieces"], bc["vias"], bc["zoneIslands"])+ } else {+ format!("the board's own copper is kept as fixed copper ({} track pieces, {} vias, {} filled zone islands); {complete} nets are complete on it ({}) and the plan carries only the {} connection(s) it left open", bc["trackPieces"], bc["vias"], bc["zoneIslands"], bc["netsComplete"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default(), bc["openConnections"])+ });+ }+ if !plane_under.is_empty() {+ plan_doc["planeUnder"] = json!(plane_under);+ }+ Routed { plan_doc, report, failed, simp_line: simp_line(&simp), log: std::mem::take(&mut router.log_lines), board: router.board, has_copper, bc }+}++/// The rank key of one variant: lower is better, compared in this order.+/// unrouted, gate issues (None when DRC did not run for every variant: then all equal),+/// copper length in 0.01 mm, vias, failed Fields screens (0 when no same-input solve), variant number.+pub fn rank_key(v: &Value) -> (u64, u64, i64, u64, u64, u64) {+ (+ v["unrouted"].as_u64().unwrap_or(u64::MAX),+ if v["gate"].is_null() { if v["gateError"].is_null() { 0 } else { u64::MAX } } else { v["gate"]["issues"].as_u64().unwrap_or(u64::MAX) },+ (v["copperLengthMm"].as_f64().unwrap_or(f64::MAX / 1e3) * 100.0).round() as i64,+ v["vias"].as_u64().unwrap_or(u64::MAX),+ v["fields"]["failed"].as_u64().unwrap_or(0),+ v["variant"].as_u64().unwrap_or(u64::MAX),+ )+}++pub const SCORING: [&str; 6] = [+ "unrouted connections (the router)",+ "gate issues: new DRC errors + unconnected + planeUnder + switch node + courtyard/body + decoupling errors (the checks `gate` runs)",+ "copper length of the plan, mm",+ "via count of the plan",+ "failed current/thermal screens, only from a same-input Fields solve of that routed board",+ "variant number (lower wins a full tie)",+];++/// The Fields screens for a routed board: only a field report whose input identity is this exact+/// board file counts (a solve per variant is not run here; see SKILL.md).+fn fields_for(r: &Run, routed: &str) -> Value {+ if !Path::new(routed).is_file() { return Value::Null; }+ let sha = crate::sha256_file(routed);+ let mut found = Vec::new();+ let mut failed = 0;+ for k in ["analyze-current", "analyze-thermal"] {+ let Some(o) = r.outcomes().get(k) else { continue };+ let Some(p) = o["fieldReport"].as_str().filter(|p| Path::new(p).is_file()) else { continue };+ let rep = read_json(p);+ if rep["inputIdentity"]["board"].as_str() == Some(sha.as_str()) {+ let pass = o["pass"].as_bool() == Some(true);+ if !pass { failed += 1; }+ found.push(json!({"screen": k, "pass": pass, "report": p}));+ }+ }+ if found.is_empty() { Value::Null } else { json!({"screens": found, "failed": failed}) }+}++fn gate_summary(g: &gate_eval::GateEval) -> Value {+ let n = |k: &str| g.s[k].as_u64().unwrap_or(0);+ let mech = g.mech.as_ref().map(|m| m.errors().len() as u64).unwrap_or(0);+ let dec = g.dec.as_ref().map(|d| d.errors).unwrap_or(0);+ let issues = n("newErrors") + n("unconnected") + g.pu_issues.len() as u64 + g.sw_issues.len() as u64 + mech + dec;+ json!({"newErrors": n("newErrors"), "unconnected": n("unconnected"), "inheritedErrors": n("inheritedErrors"), "planeUnderIssues": g.pu_issues.len(), "switchNodeIssues": g.sw_issues.len(), "mechanicalErrors": mech, "decouplingErrors": dec, "staircases": g.stairs.len(), "issues": issues, "newErrorTypes": g.s["newErrorTypes"]})+}++/// The compact table `route --variants` prints and the run page shows.+pub fn table(rec: &Value) -> String {+ let mut out = format!("{:<4} {:<44} {:>8} {:>6} {:>10} {:>5} {:>7} {:>4}\n", "v", "levers", "unrouted", "gate", "length mm", "vias", "fields", "rank");+ for v in rec["variants"].as_array().into_iter().flatten() {+ let k = v["variant"].as_u64().unwrap_or(0);+ let mark = if rec["chosen"].as_u64() == Some(k) { "*" } else { " " };+ let gate = if v["gate"].is_null() { if v["gateError"].is_null() { "-".to_string() } else { "error".into() } } else { v["gate"]["issues"].to_string() };+ let fields = if v["fields"].is_null() { "-".to_string() } else { format!("{} fail", v["fields"]["failed"]) };+ out.push_str(&format!("{:<4} {:<44} {:>8} {:>6} {:>10.2} {:>5} {:>7} {:>4}{}\n", format!("{k}{mark}"), v["label"].as_str().unwrap_or(""), v["unrouted"].to_string(), gate, v["copperLengthMm"].as_f64().unwrap_or(0.0), v["vias"].to_string(), fields, v["rank"].to_string(), v["sameCopperAs"].as_u64().map(|j| format!(" (same copper as v{j})")).unwrap_or_default()));+ }+ out.push_str(&format!("chosen: v{} by {}{}", rec["chosen"], rec["chosenBy"].as_str().unwrap_or("the gates"), rec["why"].as_str().map(|w| format!(" ({w})")).unwrap_or_default()));+ if let Some(d) = rec["drc"].as_str().filter(|d| d.starts_with("not run")) { out.push_str(&format!("\nDRC {}; ranked without the gate column", aiflow_run::clip_text(d, 140))); }+ out+}++/// Run `n` variants on the run's current board, gate and rank them; returns the winner's route+/// result and the record (written into the route outcome by main.rs).+#[allow(clippy::too_many_arguments)]+pub fn run(r: &Run, dir: &Path, rules: &Rules, plane_under: &[String], n: usize, passes: usize, rip_per_net: usize, rip_global: usize) -> (Routed, Value) {+ if n == 0 { err("--variants needs at least 1", &["`route --variants 2` routes the board twice with different levers and keeps the better one by the gates.".into()]); }+ if n > variants::MAX_VARIANTS { eprintln!("Hint: --variants {n} capped at {}", variants::MAX_VARIANTS); }+ let levers = variants::plan(n, passes, rules);+ let board_path = r.current_board();+ // the base DRC is the same for every variant: run it once+ let base = crate::run_drc_for(r, &board_path);+ let drc = match &base { Ok(_) => "kicad-cli DRC per variant against the current board".to_string(), Err(e) => format!("not run: {e}") };+ if let Err(e) = &base { eprintln!("Warning: KiCad DRC is not available ({e}); the variants are ranked without new DRC errors"); }+ let mut recs: Vec<Value> = Vec::new();+ let mut shas: Vec<String> = Vec::new();+ let mut winner: Option<(usize, Routed)> = None;+ for (i, lv) in levers.iter().enumerate() {+ let k = i + 1;+ eprintln!("variant {k} of {}: {}", levers.len(), lv.label());+ let t0 = std::time::Instant::now();+ let rt = route_once(crate::board_of(r), rules, lv, rip_per_net, rip_global, plane_under, false);+ let planp = dir.join(format!("plan-route-v{k}.json"));+ let repp = dir.join(format!("plan-route-v{k}-report.json"));+ write_json(&planp, &rt.plan_doc);+ write_json(&repp, &rt.report);+ std::fs::write(dir.join(format!("router-v{k}.log")), rt.log.join("\n")).ok();+ let route_s = t0.elapsed().as_secs_f64();+ let sha = crate::sha256_file(&planp.display().to_string());+ let same = shas.iter().position(|s| *s == sha).map(|j| j + 1);+ shas.push(sha.clone());+ // the gate reads the plan as `gate` does: the same normalisation, the same board+ let b = crate::board_of(r);+ let (plan_v, _) = crate::read_plan_normalised(&planp.display().to_string(), &b);+ let routed = dir.join(format!("board-routed-v{k}.kicad_pcb"));+ let (gate, gate_error, stats, routed_s) = match &base {+ Ok(base) => match gate_eval::evaluate(r, &b, &plan_v, &routed, base, false) {+ Ok(g) => (gate_summary(&g), Value::Null, g.s["copper"].clone(), json!(routed.display().to_string())),+ Err(e) => (Value::Null, json!(e), aiflow_copper::apply(&b, &plan_v, false).map(|x| x.1).unwrap_or(Value::Null), Value::Null),+ },+ Err(_) => (Value::Null, Value::Null, aiflow_copper::apply(&b, &plan_v, false).map(|x| x.1).unwrap_or(Value::Null), Value::Null),+ };+ let fields = routed_s.as_str().map(|p| fields_for(r, p)).unwrap_or(Value::Null);+ let v = json!({+ "variant": k, "label": lv.label(), "levers": lv.to_json(rules),+ "plan": planp.display().to_string(), "report": repp.display().to_string(), "planSha256": sha, "sameCopperAs": same,+ "routedBoard": routed_s, "unrouted": rt.failed, "gate": gate, "gateError": gate_error,+ "copperLengthMm": stats["copperLengthMm"], "vias": stats["vias"], "segments": stats["segments"],+ "fields": fields, "simplify": rt.simp_line, "routeSeconds": (route_s * 10.0).round() / 10.0, "seconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0,+ });+ eprintln!(" unrouted {}, gate {}, {} mm, {} vias, {:.1} s", rt.failed, if v["gate"].is_null() { "-".into() } else { v["gate"]["issues"].to_string() }, v["copperLengthMm"], v["vias"], t0.elapsed().as_secs_f64());+ let better = winner.as_ref().map(|(w, _)| rank_key(&v) < rank_key(&recs[*w])).unwrap_or(true);+ recs.push(v);+ if better { winner = Some((i, rt)); }+ }+ let mut order: Vec<usize> = (0..recs.len()).collect();+ order.sort_by_key(|&i| rank_key(&recs[i]));+ for (rank, &i) in order.iter().enumerate() { recs[i]["rank"] = json!(rank + 1); }+ let (wi, rt) = winner.expect("at least one variant");+ let rec = json!({+ "count": recs.len(), "requested": n, "board": board_path, "boardSha256": crate::sha256_file(&board_path),+ "scoring": SCORING, "drc": drc, "gatePick": wi + 1, "chosen": wi + 1, "chosenBy": "the gates", "why": Value::Null,+ "variants": recs,+ });+ r.log("route-variants", rec.clone());+ eprintln!("{}", table(&rec));+ (rt, rec)+}++/// `route --pick K --why`: make variant K of the last `route --variants` the run's route plan.+pub fn pick(r: &Run, k: usize, why: Option<&str>, thread: &str) -> (Routed, Value) {+ let why = why.map(str::trim).filter(|w| !w.is_empty()).unwrap_or_else(|| err("--pick needs --why \"...\"", &["Say why the gates' pick is overridden; the reason is recorded with the variants on the route outcome and the ledger.".into()]));+ let mut rec = r.outcomes().get("route").map(|o| o["variants"].clone()).filter(|v| v.is_object()).unwrap_or_else(|| err("no route variants on this run to pick from", &["Run `adom-aiflow route --variants N` first (the board must not change in between).".into()]));+ let board_path = r.current_board();+ if rec["board"].as_str() != Some(board_path.as_str()) || rec["boardSha256"].as_str() != Some(crate::sha256_file(&board_path).as_str()) {+ err("the board changed since `route --variants`", &["Route the variants again on this board, then pick.".into()]);+ }+ let Some(v) = rec["variants"].as_array().and_then(|a| a.iter().find(|v| v["variant"].as_u64() == Some(k as u64))).cloned() else {+ err(&format!("no variant {k}: the last `route --variants` ran {}", rec["count"]), &[])+ };+ let plan = v["plan"].as_str().unwrap_or("");+ if crate::sha256_file(plan) != v["planSha256"].as_str().unwrap_or("") { err(&format!("{plan} changed since it was routed"), &["Route the variants again.".into()]); }+ let report = read_json(v["report"].as_str().unwrap_or(""));+ let log = std::fs::read_to_string(Path::new(plan).with_file_name(format!("router-v{k}.log"))).unwrap_or_default();+ let plan_doc = read_json(plan);+ let bc = report["boardCopper"].clone();+ let has_copper = plan_doc.get("boardCopper").is_some();+ rec["chosen"] = json!(k);+ rec["chosenBy"] = json!("the AI");+ rec["why"] = json!(why);+ rec["pickedBy"] = json!(thread);+ r.log("route-pick", json!({"variant": k, "gatePick": rec["gatePick"], "why": why, "aiThread": thread}));+ eprintln!("{}", table(&rec));+ let routed = Routed { failed: v["unrouted"].as_u64().unwrap_or(0) as usize, simp_line: simp_line(&report["simplify"]), log: log.lines().map(str::to_string).collect(), board: crate::board_of(r), has_copper, bc, plan_doc, report };+ (routed, rec)+}++#[cfg(test)]+mod tests {+ use super::*;++ fn v(k: u64, unrouted: u64, gate: Option<u64>, len: f64, vias: u64) -> Value {+ json!({"variant": k, "unrouted": unrouted, "gate": gate.map(|g| json!({"issues": g})), "gateError": null, "copperLengthMm": len, "vias": vias, "fields": null})+ }++ #[test]+ fn ranks_by_the_gates_in_order() {+ // unrouted beats everything+ assert!(rank_key(&v(2, 0, Some(5), 900.0, 90)) < rank_key(&v(1, 1, Some(0), 10.0, 0)));+ // then gate issues+ assert!(rank_key(&v(2, 0, Some(0), 900.0, 90)) < rank_key(&v(1, 0, Some(1), 10.0, 0)));+ // then copper length, then vias+ assert!(rank_key(&v(2, 0, Some(0), 10.0, 90)) < rank_key(&v(1, 0, Some(0), 10.5, 0)));+ assert!(rank_key(&v(2, 0, Some(0), 10.0, 3)) < rank_key(&v(1, 0, Some(0), 10.0, 4)));+ // a full tie: the lower variant number+ assert!(rank_key(&v(1, 0, Some(0), 10.0, 3)) < rank_key(&v(2, 0, Some(0), 10.0, 3)));+ // DRC not run for anyone: the gate column ties; a gate that errored ranks last in it+ assert!(rank_key(&v(2, 0, None, 10.0, 3)) < rank_key(&v(1, 0, None, 11.0, 3)));+ let mut e = v(1, 0, None, 1.0, 0);+ e["gateError"] = json!("plan does not apply");+ assert!(rank_key(&v(2, 0, Some(3), 10.0, 3)) < rank_key(&e));+ }++ #[test]+ fn fields_screens_count_only_after_vias() {+ let mut a = v(1, 0, Some(0), 10.0, 3);+ a["fields"] = json!({"failed": 1});+ assert!(rank_key(&v(2, 0, Some(0), 10.0, 3)) < rank_key(&a));+ assert!(rank_key(&a) < rank_key(&v(2, 0, Some(0), 10.0, 4)));+ }++ #[test]+ fn table_marks_the_chosen_variant_and_the_override() {+ let mut a = v(1, 0, Some(0), 10.0, 3);+ a["label"] = json!("shortest first, spec vias, 5 passes");+ a["rank"] = json!(2);+ let mut b = v(2, 0, Some(0), 9.0, 3);+ b["label"] = json!("longest first, spec vias, 5 passes");+ b["rank"] = json!(1);+ let rec = json!({"variants": [a, b], "chosen": 1, "chosenBy": "the AI", "why": "shorter SW node", "drc": "kicad-cli"});+ let t = table(&rec);+ assert!(t.contains("1*"), "{t}");+ assert!(t.contains("chosen: v1 by the AI (shorter SW node)"), "{t}");+ }+}
crates/adom-aiflow/src/skill_hints.rs+1@@ -38,6 +38,7 @@ pub const COMMANDS: &[(&str, &str, &str, &str)] = &[ ("schematic", "aiflow-schematic-to-board", "Schematic check: symbols, pin types, readability", ""), ("netlist", "aiflow-schematic-to-board", "6. Verify: ERC and netlist equivalence", ""), ("export", "aiflow-schematic-to-board", "5. kicad-cli from the container", ""),+ ("review", "aiflow-sourcing", "Mechanical and wiring reviews: declare them while choosing parts", "docs/mechanical-wiring-reviews.md"), ("step-export", "aiflow-molecule", "5. Export, convert, publish", ""), ("board-glb", "aiflow-molecule", "5. Export, convert, publish", ""), ("molecule", "aiflow-molecule", "4. Conformance check before placement", ""),
crates/adom-aiflow/src/spec_freeze.rs+1@@ -14,6 +14,7 @@ use std::path::Path; pub const BUDGET_KEYS: &[&str] = &[ "loads", "hot", "hotMaxRiseC", "maxRiseC", "switchNodeMaxLengthMm", "switchNodes", "wideNets", "midNets", "fab", "copperUm", "copperUmByLayer", "viaPower", "drillPower", "branchCurrentModel", "packageCooling", "decoupling",+ "impedanceTargets", "impedance", ]; pub const FILE: &str = "spec-budgets.json";
crates/aiflow-router/src/drive.rs+13−5@@ -7,7 +7,7 @@ use aiflow_board::{geom, B_CU, F_CU}; use aiflow_grid::RES; use serde_json::{json, Value}; -use crate::{slop, End, NetState, Route, Router, Snapshot, Term, COST_TURN, COST_VIA, DIRS, HIST_COST, INF, LAYER_MULT, SOFT_EXTRA, STEP, VIA_WALL, ZONE_COST};+use crate::{slop, End, NetState, Route, Router, Snapshot, Term, COST_TURN, COST_VIA, DIRS, HIST_COST, INF, LAYER_MULT, PAIR_OFF, SOFT_EXTRA, STEP, VIA_WALL, ZONE_COST}; pub type Win = (i64, i64, i64, i64); @@ -48,6 +48,7 @@ impl Router { let hw = (j1 - j0 + 1) as usize; let nl = ww * hw; let (obs_o, obs_d, softm, via_ok) = self.obstacle_maps(net, width, s.win, s.soft);+ let guide = self.pair_guide(net, s.win); let g = self.grid.clone(); // zone, wall, history windows let mut zone = vec![false; nl];@@ -178,6 +179,11 @@ impl Router { if hist[l][nm] > 0 { c += HIST_COST * hist[l][nm]; }+ if let Some((gl, gm)) = &guide {+ if *gl == l && !gm[nm] {+ c += PAIR_OFF;+ }+ } let ng = gn + c; if ng < gcost[nn] { gcost[nn] = ng;@@ -510,7 +516,7 @@ impl Router { let l = t.layer(); let a = self.board.pads[self.nets[net].clusters[cluster].primary].polys_centre; let b = g.to_xy(t.i as i64, t.j as i64);- let mut checks = vec![(a, b, self.rules.stub / 2.0 + self.rules.clearance)];+ let mut checks = vec![(a, b, self.stub_for(net) / 2.0 + self.rules.clearance)]; if let Some(k) = self.nets[net].clusters[cluster].corridors.get(&t) { checks.push((b, *k, self.rules.track / 2.0 + self.rules.clearance)); }@@ -884,12 +890,14 @@ impl Router { self.nets[n].mst_len = l; } signal.sort_by(|a, b| self.nets[*a].mst_len.partial_cmp(&self.nets[*b].mst_len).unwrap());+ // `route --variants`: another order of the same nets (the default keeps shortest first untouched)+ crate::variants::reorder(&mut signal, &self.nets, self.order); let mut first: Vec<usize> = signal.iter().copied().filter(|n| priority.contains(&self.nets[*n].name)).collect(); let mut rest: Vec<usize> = signal.iter().copied().filter(|n| !priority.contains(&self.nets[*n].name)).collect(); let mut queue: Vec<usize> = Vec::new(); queue.append(&mut first); queue.append(&mut rest);- self.log(format!("routing {} signal nets, shortest first{}", queue.len(), if priority.is_empty() { String::new() } else { format!(", {} nets that failed last pass go first: {:?}", priority.len(), priority) }));+ self.log(format!("routing {} signal nets, {}{}", queue.len(), self.order.describe(), if priority.is_empty() { String::new() } else { format!(", {} nets that failed last pass go first: {:?}", priority.len(), priority) })); let mut order_no = 0; let mut best_state: Option<(usize, Snapshot)> = None; while !queue.is_empty() {@@ -1156,7 +1164,7 @@ impl Router { let t = (p.0 - pad.x) * ux + (p.1 - pad.y) * uy; let mid = (((pad.x + ux * t) * 1000.0).round() / 1000.0, ((pad.y + uy * t) * 1000.0).round() / 1000.0); let jog = (p.0 - mid.0).hypot(p.1 - mid.1);- if jog > 1e-6 && jog < self.rules.stub && t.abs() > 1e-6 {+ if jog > 1e-6 && jog < self.stub_for(n) && t.abs() > 1e-6 { pts.push(json!([mid.0, mid.1])); } }@@ -1164,7 +1172,7 @@ impl Router { stubs.push(Value::Array(pts)); } if !stubs.is_empty() {- entries.push(json!({"net": net.name, "width": self.rules.stub, "viaSize": net.via, "viaDrill": net.drill, "paths": stubs}));+ entries.push(json!({"net": net.name, "width": self.stub_for(n), "viaSize": net.via, "viaDrill": net.drill, "paths": stubs})); } by_width.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap()); for (w, paths) in by_width {
crates/aiflow-router/src/impedance.rsadded+948@@ -0,0 +1,948 @@+//! Controlled impedance before routing (issue #52): closed-form trace impedance from the board's+//! stackup, a width (and pair gap) per layer for each spec target, and a check of landed copper.+//!+//! Formulas (quasi-static, lossless, no solder mask, no dispersion):+//! - Surface microstrip: Hammerstad and Jensen, "Accurate Models for Microstrip Computer-Aided+//! Design", IEEE MTT-S 1980, with their strip-thickness correction (the model KiCad's calculator+//! uses for a single microstrip).+//! - Symmetric stripline: Wheeler, "Transmission-Line Properties of a Strip Line Between Parallel+//! Planes", IEEE Trans. MTT 26 (1978), as given in Wadell, "Transmission Line Design Handbook"+//! (1991) sec. 3.5.1. Zero-thickness limit checked against Cohn's exact elliptic-integral result.+//! - Asymmetric (offset) stripline: the parallel combination of two symmetric striplines with plane+//! spacings 2*h1+t and 2*h2+t (Wadell sec. 3.5.3; the form KiCad's stripline calculator uses).+//! - Edge-coupled stripline: Cohn, "Shielded Coupled-Strip Transmission Line", IRE Trans. MTT 3+//! (1955): exact even/odd impedances for zero thickness, with Cohn's fringing-capacitance+//! thickness correction (Wadell sec. 3.6.1). Offset coupled stripline uses the same parallel+//! combination as the single offset line (an approximation, flagged in the table).+//! - Edge-coupled microstrip: Garg and Bahl, "Characteristics of Coupled Microstriplines", IEEE+//! Trans. MTT 27 (1979), even/odd capacitances, with the single-line terms from Hammerstad and+//! Jensen (thickness included) and a parallel-plate gap term 2*e0*t/s for the strip sidewalls in+//! odd mode (Gupta, Garg, Bahl and Bhartia, "Microstrip Lines and Slotlines", 2nd ed., sec. 8.4).+//! Garg and Bahl state about 3 % over 0.2 <= w/h <= 2 and 0.05 <= s/h <= 2; against the field+//! solve the error passes 4 % below s/h 0.2, so this module uses 0.2 <= s/h <= 2 and refuses+//! outside it rather than extrapolate.+//!+//! Measured against an independent 2-D finite-volume field solve (tools/impedance_reference.py,+//! the same quasi-static problem, no solder mask) on the reference cases in the tests below:+//! microstrip and symmetric stripline within 0.2 %, offset stripline 1.4 % at 2:1 and 3.4 % at 3:1+//! (refused past 3:1), coupled microstrip 0.2 to 2.6 %, coupled stripline 0.9 %. Real boards add+//! solder mask (it lowers an outer trace's impedance by roughly 1 to 3 %; adom-fields' 2-D solve+//! models it), etch trapezoid, glass weave and Dk tolerance; a fab's own stackup calculation is the+//! reference for an impedance-controlled order.+use std::collections::BTreeMap;+use std::f64::consts::PI;++use aiflow_board::{Board, Track, Via};+use serde_json::{json, Value};++pub const ETA0: f64 = 376.730313668;+pub const C0: f64 = 299_792_458.0;+pub const EPS0: f64 = 8.8541878128e-12;++/// The default tolerance of a target, percent.+pub const DEFAULT_TOLERANCE: f64 = 10.0;+/// The default intra-pair length mismatch a pair may keep, mm.+pub const DEFAULT_MAX_SKEW_MM: f64 = 0.5;+/// The default length of a net that may sit outside its target (pad breakouts, escape stubs), mm.+pub const DEFAULT_MAX_UNCONTROLLED_MM: f64 = 3.0;++// -- the formulas ----------------------------------------------------------------------------++/// K(k)/K(k') with k' = sqrt(1 - k^2), the complete elliptic integral ratio, by the+/// arithmetic-geometric mean (exact to machine precision).+pub fn k_ratio(k: f64) -> f64 {+ fn kk(k: f64) -> f64 {+ let (mut a, mut b) = (1.0, (1.0 - k * k).max(0.0).sqrt());+ for _ in 0..40 {+ let (an, bn) = ((a + b) / 2.0, (a * b).sqrt());+ a = an;+ b = bn;+ if (a - b).abs() < 1e-15 { break; }+ }+ PI / (2.0 * a)+ }+ let kp = (1.0 - k * k).max(0.0).sqrt();+ kk(k) / kk(kp)+}++/// Hammerstad-Jensen air impedance of a zero-thickness microstrip of normalised width u = w/h.+fn hj_z01(u: f64) -> f64 {+ let f = 6.0 + (2.0 * PI - 6.0) * (-(30.666 / u).powf(0.7528)).exp();+ ETA0 / (2.0 * PI) * (f / u + (1.0 + 4.0 / (u * u)).sqrt()).ln()+}++/// Hammerstad-Jensen effective permittivity of a zero-thickness microstrip.+fn hj_eeff(u: f64, er: f64) -> f64 {+ let a = 1.0 + ((u.powi(4) + (u / 52.0).powi(2)) / (u.powi(4) + 0.432)).ln() / 49.0 + (1.0 + (u / 18.1).powi(3)).ln() / 18.7;+ let b = 0.564 * ((er - 0.9) / (er + 3.0)).powf(0.053);+ (er + 1.0) / 2.0 + (er - 1.0) / 2.0 * (1.0 + 10.0 / u).powf(-a * b)+}++/// Surface microstrip: (Z0 ohm, effective permittivity) for width w, dielectric height h and+/// copper thickness t (all mm) over a reference plane, relative permittivity er.+/// Hammerstad and Jensen (1980) with their thickness correction.+pub fn microstrip(w: f64, h: f64, t: f64, er: f64) -> (f64, f64) {+ let u = w / h;+ let (du1, dur) = if t > 0.0 {+ let th = t / h;+ let coth2 = 1.0 / (6.517 * u).sqrt().tanh().powi(2);+ let du1 = th / PI * (1.0 + 4.0 * std::f64::consts::E / (th * coth2)).ln();+ (du1, 0.5 * (1.0 + 1.0 / (er - 1.0).max(0.0).sqrt().cosh()) * du1)+ } else {+ (0.0, 0.0)+ };+ let (u1, ur) = (u + du1, u + dur);+ let eeff_r = hj_eeff(ur, er);+ let z0 = hj_z01(ur) / eeff_r.sqrt();+ let eeff = eeff_r * (hj_z01(u1) / hj_z01(ur)).powi(2);+ (z0, eeff)+}++/// Cohn's exact zero-thickness symmetric stripline (plane spacing b).+pub fn stripline_exact_t0(w: f64, b: f64, er: f64) -> f64 {+ let k = 1.0 / (PI * w / (2.0 * b)).cosh();+ 30.0 * PI / er.sqrt() * k_ratio(k)+}++/// Symmetric stripline Z0: width w, thickness t, plane-to-plane spacing b (mm). Wheeler (1978).+pub fn stripline(w: f64, b: f64, t: f64, er: f64) -> f64 {+ let x = t / b;+ let bt = b - t;+ let dw = if t > 0.0 {+ let m = 2.0 / (1.0 + 2.0 / 3.0 * x / (1.0 - x));+ x / (PI * (1.0 - x)) * (1.0 - 0.5 * ((x / (2.0 - x)).powi(2) + (0.0796 * x / (w / b + 1.1 * x)).powf(m)).ln())+ } else {+ 0.0+ };+ let mm = w / bt + dw;+ let a = 8.0 / (PI * mm);+ 30.0 / er.sqrt() * (1.0 + 4.0 / (PI * mm) * (a + (a * a + 6.27).sqrt())).ln()+}++/// Stripline offset between two planes: h1 and h2 are the dielectric thicknesses from each face of+/// the strip to its plane (mm). Symmetric when h1 == h2. Wadell sec. 3.5.3.+pub fn offset_stripline(w: f64, h1: f64, h2: f64, t: f64, er: f64) -> f64 {+ let (za, zb) = (stripline(w, 2.0 * h1 + t, t, er), stripline(w, 2.0 * h2 + t, t, er));+ 2.0 * za * zb / (za + zb)+}++/// Cohn's fringing capacitance per edge over permittivity, for a strip of thickness t in plane+/// spacing b (Cohn 1954; 2 ln 2 / pi at t = 0).+fn cohn_fringe(t: f64, b: f64) -> f64 {+ let x = t / b;+ let y = 1.0 / (1.0 - x);+ (2.0 * y * (y + 1.0).ln() - (y - 1.0) * (y * y - 1.0).max(1e-300).ln()) / PI+}++/// Edge-coupled symmetric stripline: (Z odd, Z even) ohm for width w, gap s, thickness t, plane+/// spacing b. Cohn (1955) exact at t = 0, with his thickness correction.+pub fn coupled_stripline(w: f64, s: f64, b: f64, t: f64, er: f64) -> (f64, f64) {+ let a = (PI * w / (2.0 * b)).tanh();+ let c = PI * (w + s) / (2.0 * b);+ let ke = a * c.tanh();+ let ko = a / c.tanh();+ let k0 = 30.0 * PI / er.sqrt();+ let (ze0, zo0) = (k0 / k_ratio(ke), k0 / k_ratio(ko));+ if t <= 0.0 {+ return (zo0, ze0);+ }+ let zs0 = stripline_exact_t0(w, b, er);+ let zst = stripline(w, b, t, er);+ let f = cohn_fringe(t, b) / cohn_fringe(0.0, b);+ let ze = 1.0 / (1.0 / zst - f * (1.0 / zs0 - 1.0 / ze0));+ let zo = if s >= 5.0 * t {+ 1.0 / (1.0 / zst + f * (1.0 / zo0 - 1.0 / zs0))+ } else {+ // a gap under five strip thicknesses: the gap-side corners' fringing becomes the sidewall+ // parallel plate to the odd mode's virtual ground at s/2 (Cohn 1955; Wadell eq. 3.6.1.5)+ let y = er.sqrt() / ETA0;+ 1.0 / (1.0 / zo0 + (1.0 / zst - 1.0 / zs0) - y * 2.0 * (cohn_fringe(t, b) - cohn_fringe(0.0, b)) + y * 2.0 * t / s)+ };+ (zo, ze)+}++/// Edge-coupled offset stripline: the parallel combination of two symmetric coupled striplines+/// (as for the single offset line). An approximation, about 5 %.+pub fn coupled_offset_stripline(w: f64, s: f64, h1: f64, h2: f64, t: f64, er: f64) -> (f64, f64) {+ let (a, b) = (coupled_stripline(w, s, 2.0 * h1 + t, t, er), coupled_stripline(w, s, 2.0 * h2 + t, t, er));+ (2.0 * a.0 * b.0 / (a.0 + b.0), 2.0 * a.1 * b.1 / (a.1 + b.1))+}++/// Garg-Bahl even and odd line capacitances (F/m) of an edge-coupled microstrip.+fn garg_bahl_caps(w: f64, s: f64, h: f64, t: f64, er: f64) -> (f64, f64) {+ let (z0, eeff) = microstrip(w, h, t, er);+ let cp = EPS0 * er * w / h;+ let cf = 0.5 * (eeff.sqrt() / (C0 * z0) - cp);+ let a = (-0.1 * (2.33 - 2.53 * w / h).exp()).exp();+ let cf2 = cf / (1.0 + a * (h / s) * (8.0 * s / h).tanh()) * (er / eeff).sqrt();+ let k = (s / h) / (s / h + 2.0 * w / h);+ let cga = EPS0 / k_ratio(k);+ let cgd = EPS0 * er / PI * (1.0 / (PI * s / (4.0 * h)).tanh()).ln() + 0.65 * cf * (0.02 * er.sqrt() / (s / h) + 1.0 - 1.0 / (er * er));+ let cgt = 2.0 * EPS0 * t / s;+ (cp + cf + cf2, cp + cf + cga + cgd + cgt)+}++/// Edge-coupled microstrip: (Z odd, Z even, eeff odd, eeff even) for width w, gap s, dielectric+/// height h, thickness t. Garg and Bahl (1979) with Hammerstad-Jensen single-line terms.+pub fn coupled_microstrip(w: f64, s: f64, h: f64, t: f64, er: f64) -> (f64, f64, f64, f64) {+ let (ce, co) = garg_bahl_caps(w, s, h, t, er);+ let (cea, coa) = garg_bahl_caps(w, s, h, t, 1.0);+ let z = |c: f64, ca: f64| 1.0 / (C0 * (c * ca).sqrt());+ (z(co, coa), z(ce, cea), co / coa, ce / cea)+}++/// The range a formula is used in (normalised by the dielectric height); outside it the table+/// refuses rather than extrapolates.+pub fn in_range(structure: &str, w: f64, s: Option<f64>, h: f64) -> Result<(), String> {+ let u = w / h;+ match (structure, s) {+ ("microstrip", None) => if (0.01..=100.0).contains(&u) { Ok(()) } else { Err(format!("w/h {u:.3} is outside Hammerstad-Jensen's 0.01..100")) },+ ("microstrip", Some(s)) => {+ if !(0.2..=2.0).contains(&u) { return Err(format!("w/h {u:.3} is outside Garg-Bahl's 0.2..2")); }+ // Garg and Bahl state 0.05..2; against the field solve the error grows past 4 % under 0.2+ let g = s / h;+ if (0.2..=2.0).contains(&g) { Ok(()) } else { Err(format!("s/h {g:.3} is outside 0.2..2, where Garg-Bahl stays within 4 % of a field solve")) }+ }+ (_, _) => if u > 0.02 && u < 20.0 { Ok(()) } else { Err(format!("w/b {u:.3} is outside the stripline formulas' range")) },+ }+}++/// The largest plane-distance ratio the offset stripline approximation is used at: against the+/// field solve it is 1.4 % off at 2:1 and 3.4 % at 3:1 (7 % at 5:1, refused).+pub const MAX_OFFSET_RATIO: f64 = 3.0;++// -- the stackup -----------------------------------------------------------------------------++/// One copper layer's cross-section for the formulas.+#[derive(Clone, Debug)]+pub struct LayerGeom {+ pub name: String,+ /// microstrip (outer), stripline (inner, symmetric) or offset stripline (inner)+ pub structure: String,+ /// dielectric between this layer and the reference plane under it (microstrip), or the two+ /// dielectrics either side (stripline), mm+ pub h1: f64,+ pub h2: Option<f64>,+ /// the layer the formulas treat as this layer's reference plane(s)+ pub reference: Vec<String>,+ pub er: f64,+ pub t: f64,+}++#[derive(Clone, Debug)]+pub struct Stackup {+ pub layers: Vec<LayerGeom>,+ /// where the dielectric thicknesses, the permittivity and the copper thickness came from+ pub sources: BTreeMap<String, String>,+}++/// The board's saved physical stackup (Board Setup > Physical Stackup): copper layer names in+/// order, the dielectric thickness between each consecutive pair, its thickness-weighted epsilon_r+/// (None when a dielectric names none), and each copper layer's thickness (mm). The same reading+/// adom-fields makes of a KiCad board.+pub fn board_stackup(board: &Board) -> Option<(Vec<String>, Vec<f64>, Vec<Option<f64>>, Vec<f64>)> {+ let st = board.root.find("setup")?.find("stackup")?;+ let (mut names, mut gaps, mut ers, mut cu) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());+ let (mut acc, mut acc_er, mut er_all, mut seen) = (0.0, 0.0, true, false);+ for l in st.find_all("layer") {+ let name = l.atom(1).unwrap_or("");+ let t: f64 = l.find("thickness").and_then(|x| x.number(1)).unwrap_or(0.0);+ if name.ends_with(".Cu") {+ if seen {+ gaps.push(acc);+ ers.push(if er_all && acc > 0.0 { Some(acc_er / acc) } else { None });+ }+ names.push(name.to_string());+ cu.push(t);+ acc = 0.0;+ acc_er = 0.0;+ er_all = true;+ seen = true;+ } else if seen && l.find("type").and_then(|x| x.atom(1)).map(|ty| ty == "core" || ty == "prepreg").unwrap_or(name.starts_with("dielectric")) {+ acc += t;+ match l.find("epsilon_r").and_then(|x| x.number(1)) {+ Some(e) if e >= 1.0 => acc_er += e * t,+ _ => er_all = false,+ }+ }+ }+ if names.len() < 2 || gaps.iter().any(|g| !(*g > 0.0)) { return None; }+ Some((names, gaps, ers, cu))+}++fn copper_names(board: &Board) -> Vec<String> {+ let mut v: Vec<String> = board.copper_layers.clone();+ if v.is_empty() { v = vec!["F.Cu".into(), "B.Cu".into()]; }+ // F.Cu, In1.Cu.., B.Cu in stack order+ let key = |n: &String| match n.as_str() { "F.Cu" => 0, "B.Cu" => 10_000, s => s.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|k| k.parse::<usize>().ok()).unwrap_or(5000) };+ v.sort_by_key(key);+ v.dedup();+ v+}++/// The stackup the formulas use. Dielectric thicknesses: spec `stackupMm` (one per gap between+/// copper layers; three values on a two-layer board are summed, as adom-fields does), else the+/// board's saved physical stackup. Permittivity: the board's stackup epsilon_r, else spec+/// `epsilonR` (adom-fields' order). Copper: spec `copperUmByLayer`, else `copperUm`, else the+/// board stackup's copper thickness, else 35 um (1 oz, stated). No dielectric or permittivity is+/// ever guessed: without one the stackup is refused and the hint says which key to add.+pub fn stackup(board: &Board, spec: &Value) -> Result<Stackup, String> {+ let saved = board_stackup(board);+ let names = saved.as_ref().map(|s| s.0.clone()).unwrap_or_else(|| copper_names(board));+ let ngap = names.len() - 1;+ let mut sources = BTreeMap::new();+ let spec_gaps: Option<Vec<f64>> = spec.get("stackupMm").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect());+ let gaps: Vec<f64> = match (&spec_gaps, &saved) {+ (Some(g), _) if g.iter().any(|v| !(*v > 0.0) || !v.is_finite()) => return Err("spec stackupMm needs positive dielectric thicknesses (mm)".into()),+ (Some(g), _) if g.len() == ngap => { sources.insert("dielectric".into(), "spec stackupMm".into()); g.clone() }+ (Some(g), _) if ngap == 1 && g.len() == 3 => { sources.insert("dielectric".into(), "spec stackupMm (three values on a two-layer board, summed)".into()); vec![g.iter().sum()] }+ (Some(g), _) => return Err(format!("spec stackupMm has {} value(s) but the board has {} copper layers ({}): give one dielectric thickness per gap, {ngap} value(s)", g.len(), names.len(), names.join(", "))),+ (None, Some(s)) => { sources.insert("dielectric".into(), "board physical stackup".into()); s.1.clone() }+ (None, None) => return Err("no stackup: the spec has no stackupMm and the board file saves no physical stackup; add spec stackupMm (the fab's dielectric thickness per gap, mm) or save the stackup in KiCad's Board Setup".into()),+ };+ let spec_er = spec.get("epsilonR").and_then(|v| v.as_f64());+ if let Some(e) = spec_er { if !(e >= 1.0 && e.is_finite()) { return Err(format!("spec epsilonR {e} must be at least 1")); } }+ let board_er: Option<Vec<f64>> = saved.as_ref().filter(|s| s.2.len() == ngap).and_then(|s| s.2.iter().copied().collect::<Option<Vec<f64>>>());+ let ers: Vec<f64> = match (&board_er, spec_er) {+ (Some(e), _) => { sources.insert("epsilonR".into(), "board physical stackup epsilon_r".into()); e.clone() }+ (None, Some(e)) => { sources.insert("epsilonR".into(), "spec epsilonR".into()); vec![e; ngap] }+ (None, None) => return Err("no dielectric constant: the board stackup names no epsilon_r and the spec has no epsilonR; add spec epsilonR from the laminate datasheet (Dk near 1 GHz)".into()),+ };+ let by_layer = spec.get("copperUmByLayer").and_then(|v| v.as_object());+ let cu_spec = spec.get("copperUm").and_then(|v| v.as_f64());+ let mut cu_src = Vec::new();+ let t_of = |k: usize, name: &str, src: &mut Vec<String>| -> f64 {+ if let Some(v) = by_layer.and_then(|m| m.get(name)).and_then(|v| v.as_f64()) { src.push(format!("{name} spec copperUmByLayer")); return v / 1000.0; }+ if let Some(v) = cu_spec { return v / 1000.0; }+ if let Some(t) = saved.as_ref().and_then(|s| s.3.get(k)).copied().filter(|t| *t > 0.0) { return t; }+ 0.035+ };+ let ts: Vec<f64> = names.iter().enumerate().map(|(k, n)| t_of(k, n, &mut cu_src)).collect();+ sources.insert("copper".into(), if cu_spec.is_some() { "spec copperUm".into() } else if saved.as_ref().map(|s| s.3.iter().all(|t| *t > 0.0)).unwrap_or(false) { "board physical stackup".into() } else { "35 um default (1 oz); declare copperUm".into() });+ if !cu_src.is_empty() { sources.insert("copperByLayer".into(), cu_src.join(", ")); }+ if ts.iter().any(|t| !(*t > 0.0) || *t > 0.5) { return Err("copper thickness must be positive micrometres (copperUm 35 for 1 oz)".into()); }+ let mut layers = Vec::new();+ for (k, n) in names.iter().enumerate() {+ let g = if k == 0 {+ LayerGeom { name: n.clone(), structure: "microstrip".into(), h1: gaps[0], h2: None, reference: vec![names[1].clone()], er: ers[0], t: ts[k] }+ } else if k == ngap {+ LayerGeom { name: n.clone(), structure: "microstrip".into(), h1: gaps[k - 1], h2: None, reference: vec![names[k - 1].clone()], er: ers[k - 1], t: ts[k] }+ } else {+ let (a, b) = (gaps[k - 1], gaps[k]);+ // the dielectric between copper foils is measured foil to foil: the strip sits on one+ // foil plane, so each face's distance to its plane is that gap (thickness-weighted er)+ let er = (ers[k - 1] * a + ers[k] * b) / (a + b);+ let sym = (a - b).abs() <= 1e-6 * (a + b);+ LayerGeom { name: n.clone(), structure: if sym { "stripline".into() } else { "offset stripline".into() }, h1: a, h2: Some(b), reference: vec![names[k - 1].clone(), names[k + 1].clone()], er, t: ts[k] }+ };+ layers.push(g);+ }+ Ok(Stackup { layers, sources })+}++impl LayerGeom {+ /// Single-ended Z0 and effective permittivity of a trace of width w on this layer.+ pub fn z0(&self, w: f64) -> (f64, f64) {+ match self.h2 {+ None => microstrip(w, self.h1, self.t, self.er),+ Some(h2) => (offset_stripline(w, self.h1, h2, self.t, self.er), self.er),+ }+ }+ /// (Zdiff, Zodd, Zeven, eeff odd) of a pair of width w and gap s on this layer.+ pub fn zdiff(&self, w: f64, s: f64) -> (f64, f64, f64, f64) {+ match self.h2 {+ None => { let (zo, ze, eo, _) = coupled_microstrip(w, s, self.h1, self.t, self.er); (2.0 * zo, zo, ze, eo) }+ Some(h2) => { let (zo, ze) = coupled_offset_stripline(w, s, self.h1, h2, self.t, self.er); (2.0 * zo, zo, ze, self.er) }+ }+ }+ /// The height the formulas' ranges are normalised by.+ fn h_norm(&self) -> f64 { match self.h2 { None => self.h1, Some(h2) => self.h1 + h2 + self.t } }+ fn kind(&self) -> &str { if self.h2.is_none() { "microstrip" } else { "stripline" } }+ pub fn valid(&self, w: f64, s: Option<f64>) -> Result<(), String> {+ if let Some(h2) = self.h2 {+ let ratio = self.h1.max(h2) / self.h1.min(h2);+ if ratio > MAX_OFFSET_RATIO + 1e-9 { return Err(format!("the strip sits {ratio:.1}:1 between its planes; the offset stripline approximation is validated to {MAX_OFFSET_RATIO}:1")); }+ }+ in_range(self.kind(), w, s, self.h_norm())+ }+}++// -- targets ---------------------------------------------------------------------------------++#[derive(Clone, Debug)]+pub struct Target {+ pub name: String,+ /// one net (single-ended) or two (a differential pair, positive first)+ pub nets: Vec<String>,+ pub ohms: f64,+ pub diff: bool,+ pub tolerance: f64,+ pub gap: Option<f64>,+ pub width: Option<f64>,+ pub layer: Option<String>,+ pub max_skew: f64,+ pub max_uncontrolled: f64,+ pub max_width: Option<f64>,+ pub source: String,+}++impl Target {+ pub fn label(&self) -> String { if self.diff { format!("{} ({} / {})", self.name, self.nets[0], self.nets[1]) } else if self.name == self.nets[0] { self.name.clone() } else { format!("{} ({})", self.name, self.nets[0]) } }+}++/// The impedance targets of a spec. `impedanceTargets` is AI Flow's form, one object per target:+/// `{"USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90}, "RF": {"net": "RF_OUT",+/// "targetOhms": 50}}` with optional `tolerancePercent` (10), `gapMm`, `widthMm`, `layer`,+/// `maxSkewMm` (0.5), `maxUncontrolledMm` (3.0) and `maxWidthMm`. adom-fields' own+/// `impedance` map is read too, {net: ohms, "P/N": differential ohms}, at 10 % (Fields' own+/// threshold), so one spec drives both; its values must stay numbers or Fields refuses the spec.+pub fn targets(spec: &Value) -> Result<Vec<Target>, String> {+ let mut out: Vec<Target> = Vec::new();+ let num = |o: &Value, k: &str| o.get(k).and_then(|v| v.as_f64());+ if let Some(m) = spec.get("impedanceTargets") {+ let m = m.as_object().ok_or("spec impedanceTargets must be an object of named targets")?;+ for (name, o) in m {+ let nets: Vec<String> = if let Some(p) = o.get("pair") {+ let a: Vec<String> = p.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+ if a.len() != 2 || a[0] == a[1] { return Err(format!("impedanceTargets.{name}.pair must name two different nets, positive first")); }+ a+ } else if let Some(n) = o.get("net").and_then(|v| v.as_str()) { vec![n.to_string()] } else { return Err(format!("impedanceTargets.{name} needs \"net\" (single-ended) or \"pair\": [P, N]")) };+ let diff = nets.len() == 2;+ let ohms = if diff { num(o, "targetDiffOhms") } else { num(o, "targetOhms") }+ .ok_or_else(|| format!("impedanceTargets.{name} needs {}", if diff { "targetDiffOhms (a pair)" } else { "targetOhms (a single net)" }))?;+ let tol = num(o, "tolerancePercent").unwrap_or(DEFAULT_TOLERANCE);+ if !(ohms > 0.0 && ohms < 500.0) || !(tol > 0.0 && tol < 100.0) { return Err(format!("impedanceTargets.{name}: target ohms and tolerancePercent must be positive (tolerance under 100)")); }+ for k in ["gapMm", "widthMm", "maxSkewMm", "maxUncontrolledMm", "maxWidthMm"] {+ if let Some(v) = o.get(k) { if !v.as_f64().map(|x| x >= 0.0 && x.is_finite()).unwrap_or(false) { return Err(format!("impedanceTargets.{name}.{k} must be a non-negative number (mm)")); } }+ }+ if !diff && o.get("gapMm").is_some() { return Err(format!("impedanceTargets.{name}: gapMm is for a pair")); }+ out.push(Target { name: name.clone(), nets, ohms, diff, tolerance: tol, gap: num(o, "gapMm"), width: num(o, "widthMm"), layer: o.get("layer").and_then(|v| v.as_str()).map(str::to_string),+ max_skew: num(o, "maxSkewMm").unwrap_or(DEFAULT_MAX_SKEW_MM), max_uncontrolled: num(o, "maxUncontrolledMm").unwrap_or(DEFAULT_MAX_UNCONTROLLED_MM), max_width: num(o, "maxWidthMm"), source: format!("impedanceTargets.{name}") });+ }+ }+ if let Some(m) = spec.get("impedance") {+ let m = m.as_object().ok_or("spec impedance is adom-fields' {net: ohms} map")?;+ for (k, v) in m {+ let Some(ohms) = v.as_f64() else {+ return Err(format!("spec impedance.{k} is not a number: spec impedance is adom-fields' {{net: ohms, \"P/N\": differential ohms}} map, and Fields refuses any other value. Put per-target objects under impedanceTargets"));+ };+ let nets: Vec<String> = k.split('/').map(str::to_string).collect();+ if nets.len() > 2 || nets.iter().any(|n| n.is_empty()) { return Err(format!("spec impedance key {k:?}: a net, or \"P/N\" for a pair")); }+ if out.iter().any(|t| t.nets == nets) { continue; } // impedanceTargets carries the detail+ out.push(Target { name: k.clone(), diff: nets.len() == 2, nets, ohms, tolerance: DEFAULT_TOLERANCE, gap: None, width: None, layer: None, max_skew: DEFAULT_MAX_SKEW_MM, max_uncontrolled: DEFAULT_MAX_UNCONTROLLED_MM, max_width: None, source: format!("impedance.{k}") });+ }+ }+ let mut seen: BTreeMap<String, String> = BTreeMap::new();+ for t in &out {+ for n in &t.nets {+ if let Some(o) = seen.insert(n.clone(), t.name.clone()) { return Err(format!("net {n} is in two impedance targets ({o} and {})", t.name)); }+ }+ }+ Ok(out)+}++// -- the table -------------------------------------------------------------------------------++/// Manufacturing limits a width or gap is held to (mm), and the router's own gap floor.+#[derive(Clone, Debug)]+pub struct Fab {+ pub min_width: f64,+ pub min_space: f64,+ pub source: String,+}++/// How the grid router can place a pair: its centre lines sit on the 0.1 mm grid, and the second+/// net keeps the board clearance plus the router's slop from the first net's stamped copper.+#[derive(Clone, Debug)]+pub struct GridPair {+ pub res: f64,+ pub clearance: f64,+ pub slop: f64,+}++impl GridPair {+ /// Is a centre pitch p reachable for width w on the grid (orthogonal runs)?+ pub fn reachable(&self, w: f64, pitch: f64) -> bool {+ // copper cells reach floor(w/2 / res) cells from the centre line; the partner's centre must+ // be further than clearance + w/2 + slop from them (lib.rs obstacle_maps)+ let reach = ((w / 2.0 + 1e-6) / self.res).floor() * self.res;+ let orth = pitch - reach > self.clearance + w / 2.0 + self.slop + 1e-9;+ // on a 45 degree run the offsets come in steps of res/sqrt 2 and a diagonal step needs a+ // further res/sqrt 2 (obs_d): the pitch must be one of those steps (to 0.02 mm) and clear+ let (dp, dr) = self.diagonal(pitch, w);+ orth && (dp - pitch).abs() <= 0.02 && dp - dr > self.clearance + w / 2.0 + self.slop + self.res * std::f64::consts::FRAC_1_SQRT_2 + 1e-9+ }+ /// The nearest 45 degree centre offset to `pitch` and the copper reach of a width-w diagonal.+ pub fn diagonal(&self, pitch: f64, w: f64) -> (f64, f64) {+ let d = self.res * std::f64::consts::FRAC_1_SQRT_2;+ ((pitch / d).round() * d, ((w / 2.0 + 1e-6) / d).floor() * d)+ }+}++/// Solve f(x) = target for a decreasing f on [lo, hi] (log bisection). None when out of range.+fn solve_decreasing(f: &dyn Fn(f64) -> f64, target: f64, lo: f64, hi: f64) -> Option<f64> {+ let (flo, fhi) = (f(lo), f(hi));+ if !(flo >= target && fhi <= target) { return None; }+ let (mut a, mut b) = (lo.ln(), hi.ln());+ for _ in 0..80 {+ let m = (a + b) / 2.0;+ if f(m.exp()) > target { a = m; } else { b = m; }+ }+ Some(((a + b) / 2.0).exp())+}++fn r4(x: f64) -> f64 { (x * 10000.0).round() / 10000.0 }+fn r2(x: f64) -> f64 { (x * 100.0).round() / 100.0 }++/// One row: the width (and gap) that meets a target on one layer, or why it cannot.+pub fn solve_row(t: &Target, g: &LayerGeom, fab: &Fab, grid: Option<&GridPair>) -> Value {+ let hn = g.h_norm();+ let (lo, hi) = (hn * 0.02, hn * 20.0);+ let mut row = json!({"target": t.name, "nets": t.nets, "layer": g.name, "structure": g.structure, "reference": g.reference, "dielectricMm": match g.h2 { None => json!(r4(g.h1)), Some(h2) => json!([r4(g.h1), r4(h2)]) }, "epsilonR": r4(g.er), "copperUm": r2(g.t * 1000.0), "targetOhms": t.ohms, "differential": t.diff, "tolerancePercent": t.tolerance});+ let refuse = |mut row: Value, why: String| { row["ok"] = json!(false); row["why"] = json!(why); row };+ if let Some(l) = &t.layer { if l != &g.name { return refuse(row, format!("the target names layer {l}")); } }+ if !t.diff {+ let w = match t.width { Some(w) => Some(w), None => solve_decreasing(&|w| g.z0(w).0, t.ohms, lo, hi) };+ let Some(w) = w else { return refuse(row, format!("{:.0} ohm is outside what a {} on this dielectric reaches between {:.3} and {:.2} mm wide", t.ohms, g.structure, lo, hi)); };+ let (z, ee) = g.z0(w);+ row["widthMm"] = json!(r4(w));+ row["ohms"] = json!(r2(z));+ row["eeff"] = json!(r4(ee));+ row["psPerMm"] = json!(r2(ee.sqrt() / C0 * 1e9));+ if let Err(e) = g.valid(w, None) { return refuse(row, format!("{e}; not extrapolated")); }+ if (z - t.ohms).abs() > t.ohms * t.tolerance / 100.0 { return refuse(row, format!("widthMm {w} gives {z:.1} ohm, outside {} %", t.tolerance)); }+ if w + 1e-9 < fab.min_width { return refuse(row, format!("needs {w:.3} mm, under the fab minimum track {:.3} mm ({})", fab.min_width, fab.source)); }+ if let Some(m) = t.max_width { if w > m { return refuse(row, format!("needs {w:.3} mm, over the target's maxWidthMm {m}")); } }+ row["ok"] = json!(true);+ return row;+ }+ // a pair: a fixed gap (spec gapMm), a fixed width (spec widthMm, solve the gap), or the+ // narrowest gap the fab (and, on the router's layers, the grid) allows+ let floor_s = fab.min_space;+ let pick = |w: f64, s: f64, row: &mut Value| { let (zd, zo, ze, eo) = g.zdiff(w, s); row["widthMm"] = json!(r4(w)); row["gapMm"] = json!(r4(s)); row["ohms"] = json!(r2(zd)); row["zOdd"] = json!(r2(zo)); row["zEven"] = json!(r2(ze)); row["eeffOdd"] = json!(r4(eo)); row["psPerMm"] = json!(r2(eo.sqrt() / C0 * 1e9)); zd };+ let solved: Option<(f64, f64, String)> = if let Some(s) = t.gap {+ solve_decreasing(&|w| g.zdiff(w, s).0, t.ohms, lo, hi).map(|w| (w, s, "spec gapMm".to_string()))+ } else if let Some(w) = t.width {+ // Zdiff rises with the gap: solve on its negative+ solve_decreasing(&|s| -g.zdiff(w, s).0, -t.ohms, hn * 0.01, hn * 10.0).map(|s| (w, s, "gap solved for spec widthMm".to_string()))+ } else if let Some(gp) = grid {+ // the router's pair: centre pitch on its grid, gap at least the fab minimum and reachable+ let mut found = None;+ for k in 1..400 {+ let p = k as f64 * gp.res;+ let Some(w) = solve_decreasing(&|w| g.zdiff(w, (p - w).max(1e-4)).0, t.ohms, lo.min(p * 0.05), p * 0.999) else { continue };+ if p - w + 1e-9 >= floor_s && w + 1e-9 >= fab.min_width && gp.reachable(w, p) { found = Some((w, p - w, format!("smallest centre pitch on the router's {:.1} mm grid the board clearance {:.3} mm allows", gp.res, gp.clearance))); break; }+ }+ found+ } else {+ // the smallest gap the fab allows that leaves a manufacturable width+ let mut found = None;+ let mut s = floor_s;+ for _ in 0..200 {+ if let Some(w) = solve_decreasing(&|w| g.zdiff(w, s).0, t.ohms, lo, hi) { if w + 1e-9 >= fab.min_width { found = Some((w, s, "narrowest gap at the fab minimum space that leaves a manufacturable width".to_string())); break; } }+ s += 0.005;+ }+ found+ };+ let Some((w, s, how)) = solved else { return refuse(row, format!("no width and gap reach {:.0} ohm differential on this {} within the fab limits (track {:.3}, space {:.3} mm) and the formula's range", t.ohms, g.structure, fab.min_width, fab.min_space)); };+ let zd = pick(w, s, &mut row);+ row["gapFrom"] = json!(how);+ if grid.is_some() && t.gap.is_none() && t.width.is_none() { row["pitchMm"] = json!(r4(w + s)); }+ if let Err(e) = g.valid(w, Some(s)) { return refuse(row, format!("{e}; not extrapolated")); }+ if (zd - t.ohms).abs() > t.ohms * t.tolerance / 100.0 { return refuse(row, format!("{zd:.1} ohm is outside {} %", t.tolerance)); }+ if w + 1e-9 < fab.min_width { return refuse(row, format!("needs {w:.3} mm, under the fab minimum track {:.3} mm ({})", fab.min_width, fab.source)); }+ if s + 1e-9 < fab.min_space { return refuse(row, format!("needs a {s:.3} mm gap, under the fab minimum space {:.3} mm ({})", fab.min_space, fab.source)); }+ if let Some(m) = t.max_width { if w > m { return refuse(row, format!("needs {w:.3} mm, over the target's maxWidthMm {m}")); } }+ if g.h2.is_some() && (g.h1 - g.h2.unwrap()).abs() > 1e-6 { row["note"] = json!("offset coupled stripline: parallel-combination approximation, about 5 %"); }+ row["ok"] = json!(true);+ row+}++/// Every target on every copper layer. Outer layers carry the router's row (`grid`) since the grid+/// router routes F.Cu and B.Cu only; inner layers are for an AI-routed board.+pub fn table(st: &Stackup, ts: &[Target], fab: &Fab, grid: Option<&GridPair>) -> Vec<Value> {+ let mut rows = Vec::new();+ for t in ts {+ for g in &st.layers {+ let outer = g.h2.is_none();+ let mut r = solve_row(t, g, fab, if outer { grid } else { None });+ r["router"] = json!(if outer { "grid router layer" } else { "inner layer: AI routing only (the grid router routes F.Cu and B.Cu)" });+ rows.push(r);+ }+ }+ rows+}++// -- landed copper ---------------------------------------------------------------------------++/// A copper layer index (aiflow_board::copper_layer) by name.+pub fn layer_name(l: usize) -> String {+ match l { 0 => "F.Cu".into(), 1 => "B.Cu".into(), k => format!("In{}.Cu", k - 1) }+}++/// The plan's tracks (docs/plan-schema.md): pads by key, points, layer changes as vias.+pub fn plan_tracks(plan: &Value, board: &Board) -> (Vec<Track>, Vec<Via>) {+ let entries = plan.get("nets").and_then(|v| v.as_array()).cloned().or_else(|| plan.as_array().cloned()).unwrap_or_default();+ let (mut tracks, mut vias) = (Vec::new(), Vec::new());+ for e in entries {+ let Some(net) = e["net"].as_str() else { continue };+ if e["source"] == "board" { continue; }+ let w = e["width"].as_f64().unwrap_or(0.25);+ let paths: Vec<Value> = e["paths"].as_array().cloned().or_else(|| e.get("points").map(|p| vec![p.clone()])).unwrap_or_default();+ for p in paths {+ let mut layer = 0usize;+ let mut prev: Option<(f64, f64)> = None;+ for (k, pt) in p.as_array().into_iter().flatten().enumerate() {+ let (xy, l): (Option<(f64, f64)>, Option<usize>) = if let Some(key) = pt.as_str() {+ (board.pad_by_key(key).map(|p| (p.x, p.y)), None)+ } else if let Some(a) = pt.as_array() {+ (a.first().and_then(|x| x.as_f64()).zip(a.get(1).and_then(|y| y.as_f64())), None)+ } else {+ let l = pt["layer"].as_str().and_then(aiflow_board::copper_layer);+ let xy = if let Some(key) = pt["pad"].as_str() { board.pad_by_key(key).map(|p| (p.x, p.y)) } else { pt["x"].as_f64().zip(pt["y"].as_f64()) };+ (xy, l)+ };+ let Some(xy) = xy else { continue };+ if let Some(a) = prev {+ if (a.0 - xy.0).hypot(a.1 - xy.1) > 1e-9 { tracks.push(Track { net: net.to_string(), layer, a, b: xy, width: w }); }+ }+ if let Some(l) = l {+ if k > 0 && l != layer { vias.push(Via { net: net.to_string(), x: xy.0, y: xy.1, size: e["viaSize"].as_f64().unwrap_or(0.6), drill: e["viaDrill"].as_f64().unwrap_or(0.3), span: (0, aiflow_board::COPPER_BOTTOM) }); }+ layer = l;+ }+ prev = Some(xy);+ }+ }+ }+ (tracks, vias)+}++fn len(t: &Track) -> f64 { (t.b.0 - t.a.0).hypot(t.b.1 - t.a.1) }++/// The nearest parallel partner segment at a point: (gap to its edge, its width).+fn partner_gap(x: f64, y: f64, dir: (f64, f64), w: f64, layer: usize, others: &[&Track]) -> Option<(f64, f64)> {+ others.iter().filter(|q| q.layer == layer).filter_map(|q| {+ let l = len(q);+ if l < 1e-9 { return None; }+ let (ux, uy) = ((q.b.0 - q.a.0) / l, (q.b.1 - q.a.1) / l);+ if (dir.0 * uy - dir.1 * ux).abs() > 0.02 { return None; }+ let along = (x - q.a.0) * ux + (y - q.a.1) * uy;+ if along < -1e-6 || along > l + 1e-6 { return None; }+ let d = ((x - q.a.0) * uy - (y - q.a.1) * ux).abs();+ Some((d - (w + q.width) / 2.0, q.width))+ }).min_by(|a, b| a.0.total_cmp(&b.0))+}++/// Judge one target on its landed (or planned) copper. Each track is sampled every 0.05 mm; a+/// sample inside one of the net's own pads is pad copper, not line, and is skipped. A sample is+/// controlled when its line (single) or its line and the parallel partner beside it (pair) give+/// the target within tolerance; the rest is the net's uncontrolled length, which may not exceed+/// `maxUncontrolledMm` (pad breakouts and the bends of a pair).+pub fn check_target(t: &Target, st: &Stackup, tracks: &[Track], vias: &[Via], pads: &[aiflow_board::Pad]) -> Value {+ let tol = t.ohms * t.tolerance / 100.0;+ let geom = |l: usize| st.layers.iter().find(|g| g.name == layer_name(l));+ let mine: Vec<Vec<&Track>> = t.nets.iter().map(|n| tracks.iter().filter(|x| &x.net == n && len(x) > 1e-9).collect()).collect();+ let own_pads: Vec<Vec<&aiflow_board::Pad>> = t.nets.iter().map(|n| pads.iter().filter(|p| &p.net == n).collect()).collect();+ let in_pad = |side: usize, layer: usize, x: f64, y: f64| own_pads[side].iter().any(|p| p.on_copper(layer) && p.polys.iter().any(|q| aiflow_board::geom::point_in_poly(x, y, q)));+ let via_count: Vec<usize> = t.nets.iter().map(|n| vias.iter().filter(|v| &v.net == n).count()).collect();+ let mut by_layer: BTreeMap<String, f64> = BTreeMap::new();+ for v in &mine { for x in v { *by_layer.entry(layer_name(x.layer)).or_insert(0.0) += len(x); } }+ let mut out = json!({"target": t.name, "nets": t.nets, "differential": t.diff, "targetOhms": t.ohms, "tolerancePercent": t.tolerance, "lengthByLayerMm": by_layer.iter().map(|(k, v)| (k.clone(), json!(r2(*v)))).collect::<serde_json::Map<_, _>>(), "vias": via_count});+ if mine.iter().any(|v| v.is_empty()) {+ out["status"] = json!("fail");+ out["reasons"] = json!([format!("no copper on {}", t.nets.iter().zip(&mine).filter(|(_, v)| v.is_empty()).map(|(n, _)| n.clone()).collect::<Vec<_>>().join(", "))]);+ return out;+ }+ let mut reasons: Vec<String> = Vec::new();+ let step = 0.05;+ let (mut zmin, mut zmax, mut gmin, mut gmax) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);+ let n = t.nets.len();+ let (mut line, mut controlled) = (vec![0.0; n], vec![0.0; n]);+ let mut widths: Vec<f64> = Vec::new();+ for side in 0..n {+ for x in &mine[side] {+ widths.push(r4(x.width));+ let l = len(x);+ let dir = ((x.b.0 - x.a.0) / l, (x.b.1 - x.a.1) / l);+ let k_n = (l / step).ceil().max(1.0) as usize;+ let seg = l / k_n as f64;+ for k in 0..k_n {+ let f = (k as f64 + 0.5) / k_n as f64;+ let (px, py) = (x.a.0 + f * (x.b.0 - x.a.0), x.a.1 + f * (x.b.1 - x.a.1));+ if in_pad(side, x.layer, px, py) { continue; }+ line[side] += seg;+ let z = geom(x.layer).and_then(|g| {+ if !t.diff { return Some(g.z0(x.width).0); }+ let (gap, w2) = partner_gap(px, py, dir, x.width, x.layer, &mine[1 - side])?;+ if gap <= 0.0 || (w2 - x.width).abs() > 1e-3 || g.valid(x.width, Some(gap)).is_err() { return None; }+ gmin = gmin.min(gap);+ gmax = gmax.max(gap);+ Some(g.zdiff(x.width, gap).0)+ });+ if let Some(z) = z {+ if (z - t.ohms).abs() <= tol { controlled[side] += seg; zmin = zmin.min(z); zmax = zmax.max(z); }+ }+ }+ }+ }+ let uncontrolled: Vec<f64> = line.iter().zip(&controlled).map(|(a, b)| (a - b).max(0.0)).collect();+ out["lengthMm"] = json!(line.iter().map(|x| r2(*x)).collect::<Vec<_>>());+ out["ohms"] = if zmin.is_finite() { json!([r2(zmin), r2(zmax)]) } else { Value::Null };+ if t.diff {+ out["gapMm"] = if gmin.is_finite() { json!([r4(gmin), r4(gmax)]) } else { Value::Null };+ out["coupledPercent"] = json!(controlled.iter().zip(&line).map(|(c, l)| if *l > 0.0 { (c / l * 1000.0).round() / 10.0 } else { 0.0 }).collect::<Vec<_>>());+ let skew = (line[0] - line[1]).abs();+ out["skewMm"] = json!(r4(skew));+ out["maxSkewMm"] = json!(t.max_skew);+ if skew > t.max_skew + 1e-9 { reasons.push(format!("length skew {skew:.3} mm over maxSkewMm {} (the grid router does not length-tune)", t.max_skew)); }+ if by_layer.len() > 1 || via_count.iter().any(|v| *v > 0) { reasons.push(format!("the pair changes layer ({} vias): the closed forms do not model the via transition", via_count.iter().sum::<usize>())); }+ }+ if controlled.iter().all(|c| *c <= 0.0) { reasons.push(format!("no copper of {} meets {} ohm +-{} %", t.label(), t.ohms, t.tolerance)); }+ widths.sort_by(|a, b| a.total_cmp(b));+ widths.dedup();+ out["widthsMm"] = json!(widths);+ out["uncontrolledMm"] = json!(uncontrolled.iter().map(|x| r2(*x)).collect::<Vec<_>>());+ out["maxUncontrolledMm"] = json!(t.max_uncontrolled);+ for (net, u) in t.nets.iter().zip(&uncontrolled) {+ if *u > t.max_uncontrolled + 1e-9 { reasons.push(format!("{net}: {u:.2} mm outside {} ohm +-{} % ({}; maxUncontrolledMm {} allows pad breakouts)", t.ohms, t.tolerance, if t.diff { "not coupled to its partner at a gap that gives it" } else { "at a width that misses it" }, t.max_uncontrolled)); }+ }+ out["status"] = json!(if reasons.is_empty() { "pass" } else { "fail" });+ out["reasons"] = json!(reasons);+ out+}++#[cfg(test)]+mod tests {+ use super::*;+ fn pct(a: f64, b: f64) -> f64 { (a - b).abs() / b * 100.0 }++ // Reference values: tools/impedance_reference.py (2-D finite-volume Laplace solve, two grids,+ // Richardson-extrapolated; no solder mask). Each is (closed form, field solve, allowed %).+ #[test]+ fn single_lines_match_the_field_solve() {+ let cases = [+ ("50 ohm microstrip, 1.6 mm FR4 er 4.5, w 2.95", microstrip(2.95, 1.6, 0.035, 4.5).0, 50.08, 1.5),+ ("JLC 7628 prepreg h 0.2104 er 4.4, w 0.35", microstrip(0.35, 0.2104, 0.035, 4.4).0, 51.67, 1.5),+ ("h 0.2 er 4.2, w 0.30", microstrip(0.30, 0.2, 0.035, 4.2).0, 55.61, 1.5),+ ("stripline b 0.5 er 4.3, w 0.2", stripline(0.2, 0.5, 0.035, 4.3), 46.82, 1.5),+ ("offset stripline 0.2 / 0.4 er 4.3, w 0.15", offset_stripline(0.15, 0.2, 0.4, 0.035, 4.3), 55.95, 1.5),+ ("offset stripline 0.2 / 0.6 er 4.3, w 0.15", offset_stripline(0.15, 0.2, 0.6, 0.035, 4.3), 58.39, 3.5),+ ];+ for (what, z, reference, allowed) in cases {+ eprintln!("{what}: closed form {z:.2} ohm, field solve {reference:.2} ({:.2} %)", pct(z, reference));+ assert!(pct(z, reference) < allowed, "{what}: {z:.2} vs {reference:.2}");+ }+ // eeff of the 1.6 mm line: field solve 3.362+ assert!(pct(microstrip(2.95, 1.6, 0.035, 4.5).1, 3.362) < 1.5);+ }++ #[test]+ fn pairs_match_the_field_solve() {+ let cases = [+ ("JLC 7628 w 0.2 s 0.2", 2.0 * coupled_microstrip(0.2, 0.2, 0.2104, 0.035, 4.4).0, 110.29, 4.0),+ ("JLC 7628 w 0.25 s 0.15", 2.0 * coupled_microstrip(0.25, 0.15, 0.2104, 0.035, 4.4).0, 94.58, 4.0),+ ("1.6 mm er 4.5 w 1.0 s 0.5", 2.0 * coupled_microstrip(1.0, 0.5, 1.6, 0.035, 4.5).0, 108.57, 3.0),+ ("stripline b 0.5 w 0.15 s 0.15", 2.0 * coupled_stripline(0.15, 0.15, 0.5, 0.035, 4.3).0, 84.19, 3.0),+ ];+ for (what, z, reference, allowed) in cases {+ eprintln!("{what}: closed form Zdiff {z:.2} ohm, field solve {reference:.2} ({:.2} %)", pct(z, reference));+ assert!(pct(z, reference) < allowed, "{what}: {z:.2} vs {reference:.2}");+ }+ }++ #[test]+ fn limits_and_identities() {+ // Wheeler at t = 0 against Cohn's exact elliptic-integral stripline (Wheeler states 0.5 %)+ for (w, b) in [(0.1, 0.5), (0.3, 0.5), (1.0, 0.5), (0.2, 1.2)] {+ assert!(pct(stripline(w, b, 0.0, 4.3), stripline_exact_t0(w, b, 4.3)) < 0.5, "{w} {b}");+ }+ // an offset stripline centred between its planes is the symmetric one+ assert!(pct(offset_stripline(0.2, 0.2325, 0.2325, 0.035, 4.3), stripline(0.2, 0.5, 0.035, 4.3)) < 1e-9);+ // far apart, a coupled line's odd and even impedances both become the single line's+ let (zo, ze) = coupled_stripline(0.2, 5.0, 0.5, 0.0, 4.3);+ let z = stripline_exact_t0(0.2, 0.5, 4.3);+ assert!(pct(zo, z) < 0.1 && pct(ze, z) < 0.1);+ let (zo, ze, _, _) = coupled_microstrip(0.3, 0.4, 0.2, 0.035, 4.2);+ let z = microstrip(0.3, 0.2, 0.035, 4.2).0;+ assert!(zo < z && ze > z, "odd below, even above the single line: {zo} {z} {ze}");+ // Pozar (Microwave Engineering, example 3.7): 50 ohm on er 2.2, d 1.27 mm is w 3.91 mm, eeff 1.87 (t = 0)+ let (z, ee) = microstrip(3.91, 1.27, 0.0, 2.2);+ assert!(pct(z, 50.0) < 2.0 && pct(ee, 1.87) < 1.0, "{z} {ee}");+ assert!((k_ratio(std::f64::consts::FRAC_1_SQRT_2) - 1.0).abs() < 1e-12);+ // tight gaps on thick dielectric are refused: s/h 0.125 is 5.7 % off the field solve (85.48 ohm)+ assert!(in_range("microstrip", 1.0, Some(0.2), 1.6).is_err());+ // a 5:1 offset stripline (7 % off the field solve, 60.20 ohm) is refused too+ let g = LayerGeom { name: "In1.Cu".into(), structure: "offset stripline".into(), h1: 0.2, h2: Some(1.03), reference: vec![], er: 4.3, t: 0.035 };+ assert!(g.valid(0.15, None).is_err());+ }++ fn two_layer() -> Board {+ Board::from_text("t.kicad_pcb", "(kicad_pcb (version 20241229) (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)) (setup (stackup (layer \"F.Cu\" (type \"copper\") (thickness 0.035)) (layer \"dielectric 1\" (type \"core\") (thickness 1.51) (material \"FR4\") (epsilon_r 4.5) (loss_tangent 0.02)) (layer \"B.Cu\" (type \"copper\") (thickness 0.035)))) (gr_rect (start 0 0) (end 30 20) (layer \"Edge.Cuts\") (width 0.1)))".into()).unwrap()+ }+ pub(crate) fn jlc_four_layer() -> Board {+ Board::from_text("t.kicad_pcb", "(kicad_pcb (version 20241229) (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (4 \"In1.Cu\" signal) (6 \"In2.Cu\" signal) (2 \"B.Cu\" signal)) (setup (stackup (layer \"F.Mask\" (type \"Top Solder Mask\") (thickness 0.01)) (layer \"F.Cu\" (type \"copper\") (thickness 0.035)) (layer \"dielectric 1\" (type \"prepreg\") (thickness 0.2104) (material \"7628\") (epsilon_r 4.4)) (layer \"In1.Cu\" (type \"copper\") (thickness 0.0152)) (layer \"dielectric 2\" (type \"core\") (thickness 1.065) (material \"FR4\") (epsilon_r 4.6)) (layer \"In2.Cu\" (type \"copper\") (thickness 0.0152)) (layer \"dielectric 3\" (type \"prepreg\") (thickness 0.2104) (material \"7628\") (epsilon_r 4.4)) (layer \"B.Cu\" (type \"copper\") (thickness 0.035)))) (gr_rect (start 0 0) (end 30 20) (layer \"Edge.Cuts\") (width 0.1)))".into()).unwrap()+ }++ #[test]+ fn stackup_sources_and_refusals() {+ let b = jlc_four_layer();+ let s = stackup(&b, &json!({})).unwrap();+ assert_eq!(s.layers.len(), 4);+ assert_eq!(s.layers[0].structure, "microstrip");+ assert!((s.layers[0].h1 - 0.2104).abs() < 1e-9 && (s.layers[0].er - 4.4).abs() < 1e-9);+ assert_eq!(s.layers[1].structure, "offset stripline");+ assert_eq!(s.sources["dielectric"], "board physical stackup");+ let s = stackup(&b, &json!({"stackupMm": [0.1, 1.2, 0.1], "copperUm": 70})).unwrap();+ assert!((s.layers[3].h1 - 0.1).abs() < 1e-9 && (s.layers[0].t - 0.07).abs() < 1e-9);+ assert!(stackup(&b, &json!({"stackupMm": [0.1, 1.2]})).unwrap_err().contains("one dielectric thickness per gap"));+ // two layers, three spec values: summed as adom-fields does+ let s = stackup(&two_layer(), &json!({"stackupMm": [0.2, 1.065, 0.2]})).unwrap();+ assert!((s.layers[0].h1 - 1.465).abs() < 1e-9);+ // no stackup and no permittivity are refused, never guessed+ let bare = Board::from_text("t.kicad_pcb", "(kicad_pcb (version 20241229) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)) (gr_rect (start 0 0) (end 30 20) (layer \"Edge.Cuts\") (width 0.1)))".into()).unwrap();+ assert!(stackup(&bare, &json!({})).unwrap_err().contains("stackupMm"));+ assert!(stackup(&bare, &json!({"stackupMm": [1.5]})).unwrap_err().contains("epsilonR"));+ assert!(stackup(&bare, &json!({"stackupMm": [1.5], "epsilonR": 4.3})).is_ok());+ }++ #[test]+ fn targets_read_both_spellings() {+ let t = targets(&json!({"impedanceTargets": {"USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90}, "RF": {"net": "RF_OUT", "targetOhms": 50, "tolerancePercent": 5}}, "impedance": {"CLK": 50, "USB_DP/USB_DN": 90}})).unwrap();+ assert_eq!(t.len(), 3);+ assert!(t.iter().any(|x| x.name == "CLK" && !x.diff && x.tolerance == 10.0));+ assert!(t.iter().any(|x| x.name == "RF" && x.tolerance == 5.0));+ assert!(targets(&json!({"impedance": {"USB": {"pair": ["a", "b"]}}})).unwrap_err().contains("impedanceTargets"));+ assert!(targets(&json!({"impedanceTargets": {"X": {"pair": ["a"], "targetDiffOhms": 90}}})).is_err());+ assert!(targets(&json!({"impedanceTargets": {"X": {"net": "a", "targetOhms": 50}, "Y": {"net": "a", "targetOhms": 60}}})).unwrap_err().contains("two impedance targets"));+ }++ #[test]+ fn table_widths_and_refusals() {+ let fab = Fab { min_width: 0.127, min_space: 0.127, source: "test".into() };+ let ts = targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}, "RF": {"net": "RF", "targetOhms": 50}}})).unwrap();+ // the issue's case: 50 ohm on 1.6 mm FR4 er 4.5 is about 2.9 to 3.0 mm+ let two = stackup(&two_layer(), &json!({"stackupMm": [1.6]})).unwrap();+ let rows = table(&two, &ts, &fab, None);+ let rf = rows.iter().find(|r| r["target"] == "RF" && r["layer"] == "F.Cu").unwrap();+ let w = rf["widthMm"].as_f64().unwrap();+ assert!(rf["ok"] == true && (2.9..=3.0).contains(&w), "{rf}");+ // KiCad's default two-layer stackup is a 1.51 mm core between the foils: narrower+ let rows = table(&stackup(&two_layer(), &json!({})).unwrap(), &ts, &fab, None);+ let w = rows.iter().find(|r| r["target"] == "RF" && r["layer"] == "B.Cu").unwrap()["widthMm"].as_f64().unwrap();+ assert!((2.75..2.85).contains(&w), "{w}");+ // JLC four-layer 7628: a USB pair on F.Cu, and the router's grid pitch row+ let four = stackup(&jlc_four_layer(), &json!({})).unwrap();+ let grid = GridPair { res: 0.1, clearance: 0.127, slop: 0.0708 };+ let rows = table(&four, &ts, &fab, Some(&grid));+ let usb = rows.iter().find(|r| r["target"] == "USB" && r["layer"] == "F.Cu").unwrap();+ assert!(usb["ok"] == true, "{usb}");+ let (w, s) = (usb["widthMm"].as_f64().unwrap(), usb["gapMm"].as_f64().unwrap());+ assert!(((w + s) * 10.0 - ((w + s) * 10.0).round()).abs() < 1e-6, "pitch on the grid: {usb}");+ assert!(grid.reachable(w, w + s) && s >= 0.127);+ assert!(pct(four.layers[0].zdiff(w, s).0, 90.0) < 0.5);+ // a fab too coarse for the 50 ohm stripline width is refused on that layer, with the reason+ let coarse = Fab { min_width: 0.3, min_space: 0.3, source: "test".into() };+ let even = stackup(&jlc_four_layer(), &json!({"stackupMm": [0.2, 0.2, 0.2]})).unwrap();+ let rows = table(&even, &ts, &coarse, None);+ let inner = rows.iter().find(|r| r["target"] == "RF" && r["layer"] == "In1.Cu").unwrap();+ assert!(inner["ok"] == false && inner["why"].as_str().unwrap().contains("fab minimum"), "{inner}");+ // JLC's In1.Cu sits 5:1 between F.Cu and In2.Cu: past the offset approximation, refused+ let rows = table(&four, &ts, &fab, None);+ let inner = rows.iter().find(|r| r["target"] == "RF" && r["layer"] == "In1.Cu").unwrap();+ assert!(inner["ok"] == false && inner["why"].as_str().unwrap().contains("3:1"), "{inner}");+ }++ #[test]+ fn landed_pair_is_judged_on_its_real_gap() {+ let st = stackup(&jlc_four_layer(), &json!({})).unwrap();+ let ts = targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();+ let fab = Fab { min_width: 0.127, min_space: 0.127, source: "t".into() };+ let row = solve_row(&ts[0], &st.layers[0], &fab, None);+ let (w, s) = (row["widthMm"].as_f64().unwrap(), row["gapMm"].as_f64().unwrap());+ let tr = |net: &str, y: f64, x1: f64| Track { net: net.into(), layer: 0, a: (0.0, y), b: (x1, y), width: w };+ // coupled for 20 mm at the solved gap: pass+ let tracks = vec![tr("DP", 0.0, 20.0), tr("DN", w + s, 20.0)];+ let c = check_target(&ts[0], &st, &tracks, &[], &[]);+ assert_eq!(c["status"], "pass", "{c}");+ assert!(c["coupledPercent"][0].as_f64().unwrap() > 99.0);+ // the same lengths, apart by 1 mm for 10 of them: uncoupled length over the allowance+ let tracks = vec![tr("DP", 0.0, 20.0), tr("DN", w + s, 10.0), Track { net: "DN".into(), layer: 0, a: (10.0, w + s), b: (10.0, 1.0 + w), width: w }, Track { net: "DN".into(), layer: 0, a: (10.0, 1.0 + w), b: (20.0, 1.0 + w), width: w }];+ let c = check_target(&ts[0], &st, &tracks, &[], &[]);+ assert_eq!(c["status"], "fail", "{c}");+ assert!(c["reasons"].to_string().contains("not coupled"), "{c}");+ // a single-ended 50 ohm net at the wrong width fails, at the right width passes+ let ts = targets(&json!({"impedanceTargets": {"RF": {"net": "RF", "targetOhms": 50}}})).unwrap();+ let w50 = solve_row(&ts[0], &st.layers[0], &fab, None)["widthMm"].as_f64().unwrap();+ let one = |w: f64| vec![Track { net: "RF".into(), layer: 0, a: (0.0, 0.0), b: (15.0, 0.0), width: w }];+ assert_eq!(check_target(&ts[0], &st, &one(w50), &[], &[])["status"], "pass");+ assert_eq!(check_target(&ts[0], &st, &one(0.15), &[], &[])["status"], "fail");+ }+}++#[cfg(test)]+mod route_tests {+ use super::*;+ use crate::{Router, Rules};++ /// A JLC four-layer board with a connector pair (J1) and a receiver pair (U1) offset in y,+ /// pads 0.35 x 1.0 mm on F.Cu at `pitch` mm.+ pub(crate) fn pair_board(pitch: f64, dy: f64) -> Board { pair_board_with(pitch, dy, "") }+ pub(crate) fn pair_board_with(pitch: f64, dy: f64, extra: &str) -> Board {+ let stack = "(setup (stackup (layer \"F.Cu\" (type \"copper\") (thickness 0.035)) (layer \"dielectric 1\" (type \"prepreg\") (thickness 0.2104) (epsilon_r 4.4)) (layer \"In1.Cu\" (type \"copper\") (thickness 0.0152)) (layer \"dielectric 2\" (type \"core\") (thickness 1.065) (epsilon_r 4.6)) (layer \"In2.Cu\" (type \"copper\") (thickness 0.0152)) (layer \"dielectric 3\" (type \"prepreg\") (thickness 0.2104) (epsilon_r 4.4)) (layer \"B.Cu\" (type \"copper\") (thickness 0.035))))";+ let fp = |r: &str, x: f64, y: f64| format!("(footprint \"T:{r}\" (layer \"F.Cu\") (at {x} {y}) (property \"Reference\" \"{r}\") (pad \"1\" smd rect (at 0 {}) (size 1.0 0.35) (layers \"F.Cu\") (net \"DP\")) (pad \"2\" smd rect (at 0 {}) (size 1.0 0.35) (layers \"F.Cu\") (net \"DN\")))", -pitch / 2.0, pitch / 2.0);+ let t = format!("(kicad_pcb (version 20260206) (generator \"test\") (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (4 \"In1.Cu\" signal) (6 \"In2.Cu\" signal) (2 \"B.Cu\" signal) (25 \"Edge.Cuts\" user)) {stack} {} {} {extra} (gr_rect (start 0 0) (end 40 20) (layer \"Edge.Cuts\")))", fp("J1", 4.0, 6.0), fp("U1", 36.0, 6.0 + dy));+ Board::from_text("pair.kicad_pcb", t).unwrap()+ }++ pub(crate) fn route_pair(b: Board, clearance: f64) -> (Value, Value) {+ let ts = targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();+ let st = stackup(&b, &json!({})).unwrap();+ let fab = Fab { min_width: 0.127, min_space: 0.127, source: "test".into() };+ let grid = GridPair { res: aiflow_grid::RES, clearance, slop: crate::slop() };+ let row = solve_row(&ts[0], &st.layers[0], &fab, Some(&grid));+ assert_eq!(row["ok"], true, "{row}");+ let mut rules = Rules { clearance, ..Rules::default() };+ rules.track = 0.2;+ rules.stub = 0.2;+ for (k, n) in ts[0].nets.iter().enumerate() {+ rules.impedance.insert(n.clone(), crate::rules::ImpedanceRoute { target: "USB".into(), width: row["widthMm"].as_f64().unwrap(), layer: "F.Cu".into(), partner: Some(ts[0].nets[1 - k].clone()), pitch: row["pitchMm"].as_f64() });+ }+ let mut r = Router::new(b, rules, false);+ let (mut plan, _report, failed) = crate::drive::route_board(&mut r, 3, 8, 400);+ assert_eq!(failed, 0);+ crate::simplify::simplify_plan(&mut r, &mut plan);+ let (tracks, vias) = plan_tracks(&json!({"nets": plan}), &r.board);+ (row, check_target(&ts[0], &st, &tracks, &vias, &r.board.pads))+ }++ #[test]+ fn router_keeps_a_pair_coupled_on_one_layer() {+ let (row, c) = route_pair(pair_board(0.7, 3.0), 0.127);+ eprintln!("row {row}\ncheck {c}");+ assert_eq!(c["vias"], json!([0, 0]), "held to F.Cu: {c}");+ assert!(c["coupledPercent"][0].as_f64().unwrap() > 90.0, "{c}");+ assert_eq!(c["status"], "pass", "{c}");+ // 0.5 mm pads (a USB connector's pitch, under the router's 0.7 mm pair pitch): the pair fans+ // out at each end and runs coupled between+ let (_, c) = route_pair(pair_board(0.5, 5.0), 0.127);+ eprintln!("0.5 pitch {c}");+ assert_eq!(c["status"], "pass", "{c}");+ }++ #[test]+ fn a_split_pair_is_reported_not_claimed() {+ // a through-hole pad of another net on the straight line between the pairs: one net goes+ // round each side or both go round, but the check says what the copper really does+ let post = "(footprint \"T:P\" (layer \"F.Cu\") (at 20 6) (property \"Reference\" \"TP1\") (pad \"1\" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 0.8) (layers \"*.Cu\") (net \"X\")))";+ let (_, c) = route_pair(pair_board_with(0.7, 0.0, post), 0.127);+ eprintln!("split {c}");+ // today the grid router sends the two nets round opposite sides of the post: not coupled there+ assert_eq!(c["status"], "fail", "{c}");+ assert_eq!(c["vias"], json!([0, 0]));+ // whatever the router found, the verdict comes from the measured coupling+ let coupled = c["coupledPercent"][0].as_f64().unwrap();+ let unc = c["uncontrolledMm"][0].as_f64().unwrap();+ assert_eq!(c["status"] == "pass", unc <= 3.0 && c["skewMm"].as_f64().unwrap() <= 0.5, "{c}");+ assert!(coupled > 0.0);+ }+}
crates/aiflow-router/src/lib.rs+81−3@@ -20,9 +20,11 @@ pub mod board_copper; pub mod copper_path; pub mod decoupling; pub mod drive;+pub mod impedance; pub mod plane_under; pub mod rules; pub mod simplify;+pub mod variants; use std::collections::{HashMap, HashSet}; @@ -40,6 +42,8 @@ pub const SOFT_EXTRA: i64 = 80; pub const ZONE_COST: i64 = 20; pub const VIA_WALL: i64 = 300; pub const HIST_COST: i64 = 12;+/// Extra cost per step for the second net of an impedance pair off its partner's coupling line.+pub const PAIR_OFF: i64 = 10; pub const DIRS: [(i32, i32); 8] = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)]; pub const STEP: [i64; 8] = [10, 10, 10, 10, 14, 14, 14, 14]; pub const INF: i64 = 1 << 40;@@ -206,6 +210,8 @@ pub struct Router { pub keep_raw: [Vec<i16>; 2], /// Spec decoupling pairs resolved on this board (decoupling.rs). pub pairs: Vec<decoupling::PairState>,+ /// The signal-net order (`route --variants`, variants.rs); the default is shortest first.+ pub order: variants::NetOrder, } impl Router {@@ -259,7 +265,7 @@ impl Router { pad_cells: HashMap::new(), inside, edge_dist, inside_w: HashMap::new(), rip_total: 0, stats: HashMap::new(), kelvin_owner: HashMap::new(), kelvin_clusters: HashSet::new(), body_owner: vec![-1; n], ic_zone: grid.mask(), ic_nets: HashMap::new(), log_lines: Vec::new(), fixed_raw: [vec![-1; n], vec![-1; n]], fixed_tracks: Vec::new(), fixed_vias: Vec::new(), pad_comp: Vec::new(), comp_copper: HashMap::new(), fixed_targets: HashMap::new(),- keep: Vec::new(), keep_raw: [vec![-1; n], vec![-1; n]], pairs: Vec::new(), board,+ keep: Vec::new(), keep_raw: [vec![-1; n], vec![-1; n]], pairs: Vec::new(), order: variants::NetOrder::default(), board, }; r.big_clr_val = r.rules.clearance; let t0 = std::time::Instant::now();@@ -457,7 +463,7 @@ impl Router { let b = g.to_xy(term.i as i64, term.j as i64); let nid = if sign > 0 { self.nets[net].idx as i16 } else { -1 }; let fpi = self.fp_index[&p.reference] as i16;- for c in g.segment_cells(a, b, self.rules.stub / 2.0, Some(1e-6)) {+ for c in g.segment_cells(a, b, self.stub_for(net) / 2.0, Some(1e-6)) { self.pad_raw[l][c] = nid; if sign > 0 { self.pad_owner[l][c] = fpi;@@ -576,6 +582,9 @@ impl Router { } else if let Some(w) = self.rules.mid.get(&name) { self.nets[net].widths = w.clone(); }+ if let Some(ir) = self.rules.impedance.get(&name) {+ self.nets[net].widths = vec![ir.width];+ } let count = self.nets[net].clusters.len(); if !self.comp_copper.is_empty() { let plane = self.nets[net].plane.is_some();@@ -734,7 +743,7 @@ impl Router { } let b = g.to_xy(i, j); let a = p.polys_centre;- let mut checks = vec![(a, b, self.rules.stub / 2.0)];+ let mut checks = vec![(a, b, self.stub_for(net) / 2.0)]; if (b.0 - corridor_end.0).hypot(b.1 - corridor_end.1) > 1e-6 { let (ki, kj) = g.to_cell(corridor_end.0, corridor_end.1); if !g.in_bounds(ki, kj) || !self.inside_for(track).get(ki as usize, kj as usize) {@@ -929,8 +938,77 @@ impl Router { for b in via_ok.bits.iter_mut() { *b = !*b; }+ // an impedance-controlled net stays on the one layer its width was solved for: no vias+ if let Some(l) = self.impedance_layer(net) {+ for b in obs_o[1 - l].bits.iter_mut() {+ *b = true;+ }+ for b in via_ok.bits.iter_mut() {+ *b = false;+ }+ } (obs_o, obs_d, softm, via_ok) }++ /// The width of a net's escape stubs: the rule stub, or an impedance-controlled net's own width+ /// when every one of its pads is at least that wide (so a pair leaves its pads already at the+ /// solved width and gap; a narrower pad keeps the rule stub and its breakout stays uncontrolled).+ pub fn stub_for(&self, net: usize) -> f64 {+ match self.rules.impedance.get(&self.nets[net].name) {+ Some(ir) if ir.width > self.rules.stub && self.nets[net].pads.iter().all(|&p| 2.0 * self.board.pads[p].half_short + 1e-9 >= ir.width) => ir.width,+ _ => self.rules.stub,+ }+ }++ /// The layer an impedance-controlled net is held to (impedance::table chose it).+ pub fn impedance_layer(&self, net: usize) -> Option<usize> {+ self.rules.impedance.get(&self.nets[net].name).and_then(|ir| match ir.layer.as_str() { "F.Cu" => Some(F_CU), "B.Cu" => Some(B_CU), _ => None })+ }++ /// For the second net of an impedance pair: the cells of the search window whose distance from+ /// the partner's routed centre line is the pair's centre pitch (within 0.045 mm, so one 45 degree offset line qualifies).+ /// The search charges PAIR_OFF for every step off this line, so the net follows its partner at+ /// the solved gap wherever the obstacles allow. Returns (layer, window mask).+ pub fn pair_guide(&self, net: usize, win: (i64, i64, i64, i64)) -> Option<(usize, Vec<bool>)> {+ let ir = self.rules.impedance.get(&self.nets[net].name)?;+ let (partner, pitch) = (ir.partner.as_ref()?, ir.pitch?);+ let layer = self.impedance_layer(net)?;+ let &pid = self.net_ids.get(partner)?;+ let cells: Vec<Term> = self.nets[pid].routes.iter().flat_map(|r| r.cells.iter().copied()).filter(|t| t.layer() == layer).collect();+ if cells.is_empty() {+ return None;+ }+ let (i0, j0, i1, j1) = win;+ let ww = (i1 - i0 + 1) as usize;+ let hw = (j1 - j0 + 1) as usize;+ let band = 0.045;+ let reach = ((pitch + band) / RES).ceil() as i64;+ let mut mind = vec![f64::INFINITY; ww * hw];+ for t in &cells {+ let (ci, cj) = (t.i as i64, t.j as i64);+ if ci < i0 - reach || ci > i1 + reach || cj < j0 - reach || cj > j1 + reach {+ continue;+ }+ for dj in -reach..=reach {+ let j = cj + dj;+ if j < j0 || j > j1 {+ continue;+ }+ for di in -reach..=reach {+ let i = ci + di;+ if i < i0 || i > i1 {+ continue;+ }+ let d = ((di * di + dj * dj) as f64).sqrt() * RES;+ let k = (j - j0) as usize * ww + (i - i0) as usize;+ if d < mind[k] {+ mind[k] = d;+ }+ }+ }+ }+ Some((layer, mind.iter().map(|d| (d - pitch).abs() <= band).collect()))+ } } /// Crop a window-sized mask (row stride ww) to the inclusive sub-window.
crates/aiflow-router/src/rules.rs+16−1@@ -33,6 +33,21 @@ pub struct Rules { pub plane_under: Vec<PlaneUnder>, /// Assigned-pin decoupling pairs (spec "decoupling"), routed before the rail (decoupling.rs). pub decoupling: Vec<crate::decoupling::Pair>,+ /// Impedance-controlled nets (issue #52): the width from the impedance table, the one layer the+ /// net is routed on (no vias), and for a pair its partner and centre pitch. Filled by `route`+ /// from impedance::table, never from the spec directly.+ pub impedance: BTreeMap<String, ImpedanceRoute>,+}++/// How the router routes one impedance-controlled net.+#[derive(Clone, Debug, Default, Serialize, Deserialize)]+#[serde(default)]+pub struct ImpedanceRoute {+ pub target: String,+ pub width: f64,+ pub layer: String,+ pub partner: Option<String>,+ pub pitch: Option<f64>, } /// One `planeUnder` entry: `{"layer": "B.Cu", "net": "GND", "around": ["U1", "C2.1"], "margin": 1.0}`@@ -53,7 +68,7 @@ impl Default for Rules { Rules { track: 0.25, clearance: 0.2, net_clearance: Default::default(), via: 0.8, drill: 0.4, via_small: 0.6, drill_small: 0.3, edge: 0.5, stub: 0.25, narrow: 0.5, escape: 0.45, corridor: 0.5, ic_zone: 2.0, channel: 2.5,- planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(), plane_under: Vec::new(), decoupling: Vec::new(),+ planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(), plane_under: Vec::new(), decoupling: Vec::new(), impedance: BTreeMap::new(), } } }
crates/aiflow-router/src/variants.rsadded+207@@ -0,0 +1,207 @@+//! `route --variants N` (issue #55): the levers one variant pulls, chosen in a fixed order so the+//! same board, spec and N always give the same variants. The levers are the ones the+//! kicad-place-route-loop skill lists: the signal-net order, the via size (within the spec's own+//! sizes), the pass count and a seed for a shuffled order. Variant 1 is always a plain `route`.+use serde_json::{json, Value};++use crate::{NetState, Rules};++/// The most variants one `route --variants` runs.+pub const MAX_VARIANTS: usize = 8;++/// The order the signal nets are routed in (planes and stubs always go first).+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]+pub enum NetOrder {+ /// Shortest spanning length first: the router's own order and a plain `route`.+ #[default]+ Shortest,+ /// Longest first: the long nets take the open corridors before the short ones box them in.+ Longest,+ /// Most pads (clusters) first, shortest first among equals.+ Fanout,+ /// A fixed shuffle keyed by the seed and the net name.+ Seeded(u64),+}++impl NetOrder {+ pub fn describe(&self) -> String {+ match self {+ NetOrder::Shortest => "shortest first".into(),+ NetOrder::Longest => "longest first".into(),+ NetOrder::Fanout => "most pads first".into(),+ NetOrder::Seeded(s) => format!("seeded order (seed {s})"),+ }+ }+ pub fn name(&self) -> &'static str {+ match self {+ NetOrder::Shortest => "shortest-first",+ NetOrder::Longest => "longest-first",+ NetOrder::Fanout => "fanout-first",+ NetOrder::Seeded(_) => "seeded",+ }+ }+}++/// The via the signal nets take.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum ViaChoice {+ /// The spec's sizes: signal nets the signal via, power and wide nets the power via.+ Spec,+ /// Signal nets take the spec's power via too (a larger hole, still a size the spec allows).+ PowerSize,+}++/// One variant's levers.+#[derive(Clone, Debug, PartialEq)]+pub struct Levers {+ pub order: NetOrder,+ pub via: ViaChoice,+ pub passes: usize,+}++impl Levers {+ /// A plain `route --passes P`.+ pub fn baseline(passes: usize) -> Levers {+ Levers { order: NetOrder::Shortest, via: ViaChoice::Spec, passes }+ }+ /// The rules this variant routes to: the spec's, with the via lever applied.+ pub fn apply(&self, rules: &Rules) -> Rules {+ let mut r = rules.clone();+ if self.via == ViaChoice::PowerSize {+ r.via_small = r.via;+ r.drill_small = r.drill;+ }+ r+ }+ pub fn to_json(&self, rules: &Rules) -> Value {+ let r = self.apply(rules);+ json!({+ "order": self.order.name(),+ "seed": match self.order { NetOrder::Seeded(s) => json!(s), _ => Value::Null },+ "via": match self.via { ViaChoice::Spec => "spec", ViaChoice::PowerSize => "power-size" },+ "signalVia": [r.via_small, r.drill_small],+ "powerVia": [r.via, r.drill],+ "passes": self.passes,+ })+ }+ /// One short line for the table.+ pub fn label(&self) -> String {+ let via = match self.via { ViaChoice::Spec => "spec vias", ViaChoice::PowerSize => "power-size vias" };+ format!("{}, {via}, {} passes", self.order.describe(), self.passes)+ }+}++/// The first `n` variants (at most MAX_VARIANTS) for a run whose plain route takes `passes`.+/// The order is fixed; a via variant is left out when the spec's signal and power vias are the+/// same size (it would route the same copper), and seeded orders fill the list up to `n`.+pub fn plan(n: usize, passes: usize, rules: &Rules) -> Vec<Levers> {+ let n = n.clamp(1, MAX_VARIANTS);+ let same_via = (rules.via - rules.via_small).abs() < 1e-9 && (rules.drill - rules.drill_small).abs() < 1e-9;+ let l = |order, via, passes| Levers { order, via, passes };+ let mut out: Vec<Levers> = [+ l(NetOrder::Shortest, ViaChoice::Spec, passes),+ l(NetOrder::Longest, ViaChoice::Spec, passes),+ l(NetOrder::Shortest, ViaChoice::PowerSize, passes),+ l(NetOrder::Fanout, ViaChoice::Spec, passes),+ l(NetOrder::Seeded(1), ViaChoice::Spec, passes),+ l(NetOrder::Seeded(2), ViaChoice::Spec, passes),+ l(NetOrder::Shortest, ViaChoice::Spec, passes + 4),+ l(NetOrder::Longest, ViaChoice::PowerSize, passes + 4),+ ]+ .into_iter()+ .filter(|v| !(same_via && v.via == ViaChoice::PowerSize))+ .collect();+ let mut seed = 3;+ while out.len() < n {+ out.push(l(NetOrder::Seeded(seed), ViaChoice::Spec, passes));+ seed += 1;+ }+ out.truncate(n);+ out+}++/// FNV-1a of the name, mixed with the seed (splitmix64): the same key on every machine.+fn key(name: &str, seed: u64) -> u64 {+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;+ for b in name.bytes() {+ h ^= b as u64;+ h = h.wrapping_mul(0x0100_0000_01b3);+ }+ let mut z = h ^ seed.wrapping_mul(0x9e37_79b9_7f4a_7c15);+ z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);+ z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);+ z ^ (z >> 31)+}++/// Reorder the signal nets (already sorted shortest first) for `order`; Shortest leaves them as they are.+pub fn reorder(signal: &mut [usize], nets: &[NetState], order: NetOrder) {+ match order {+ NetOrder::Shortest => {}+ NetOrder::Longest => signal.sort_by(|a, b| nets[*b].mst_len.partial_cmp(&nets[*a].mst_len).unwrap_or(std::cmp::Ordering::Equal)),+ NetOrder::Fanout => signal.sort_by_key(|&n| std::cmp::Reverse(nets[n].clusters.len())),+ NetOrder::Seeded(s) => signal.sort_by_key(|&n| (key(&nets[n].name, s), nets[n].name.clone())),+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn plan_is_fixed_capped_and_starts_with_a_plain_route() {+ let r = Rules::default();+ let a = plan(3, 5, &r);+ assert_eq!(a, plan(3, 5, &r));+ assert_eq!(a[0], Levers::baseline(5));+ assert_eq!(a[1].order, NetOrder::Longest);+ assert_eq!(a[2].via, ViaChoice::PowerSize);+ assert_eq!(plan(50, 5, &r).len(), MAX_VARIANTS);+ assert_eq!(plan(0, 5, &r).len(), 1);+ }++ #[test]+ fn same_vias_drop_the_via_variant_and_seeds_fill_in() {+ let mut r = Rules::default();+ r.via_small = r.via;+ r.drill_small = r.drill;+ let a = plan(8, 5, &r);+ assert_eq!(a.len(), 8);+ assert!(a.iter().all(|v| v.via == ViaChoice::Spec));+ assert_eq!((a[6].order, a[7].order), (NetOrder::Seeded(3), NetOrder::Seeded(4)));+ }++ #[test]+ fn power_size_via_applies_to_signal_nets_only_in_the_rules() {+ let r = Rules::default();+ let v = Levers { order: NetOrder::Shortest, via: ViaChoice::PowerSize, passes: 5 }.apply(&r);+ assert_eq!((v.via_small, v.drill_small), (r.via, r.drill));+ assert_eq!(Levers::baseline(5).apply(&r).via_small, r.via_small);+ }++ #[test]+ fn seeded_keys_are_stable_and_differ_by_seed() {+ assert_eq!(key("GND", 1), key("GND", 1));+ assert_ne!(key("GND", 1), key("GND", 2));+ let names = ["A", "B", "C", "D", "E", "F"];+ let o1: Vec<u64> = names.iter().map(|n| key(n, 1)).collect();+ let o2: Vec<u64> = names.iter().map(|n| key(n, 2)).collect();+ let rank = |o: &Vec<u64>| { let mut i: Vec<usize> = (0..o.len()).collect(); i.sort_by_key(|&k| o[k]); i };+ assert_ne!(rank(&o1), rank(&o2));+ }++ #[test]+ fn the_order_lever_changes_which_net_goes_first_and_default_is_unchanged() {+ let pads = [("J1", "A", 2.0, 2.0, true), ("J2", "A", 5.0, 2.0, true), ("J3", "B", 2.0, 8.0, true), ("J4", "B", 18.0, 8.0, true)];+ let first = |order: NetOrder| {+ let b = crate::board_copper::tests::board(&pads, "");+ let mut r = crate::Router::new(b, Rules::default(), false);+ r.order = order;+ let (_, _, failed) = crate::drive::route_board(&mut r, 2, 8, 400);+ assert_eq!(failed, 0);+ r.nets.iter().find(|n| n.routed_order == Some(1)).map(|n| n.name.clone()).unwrap()+ };+ assert_eq!(first(NetOrder::default()), "A");+ assert_eq!(first(NetOrder::Shortest), "A");+ assert_eq!(first(NetOrder::Longest), "B");+ }+}
docs/impedance.mdadded+122@@ -0,0 +1,122 @@+# Impedance targets before routing++Issue #52. A net or a differential pair can carry an impedance target. The binary turns the board's stackup into a trace width (and pair gap) per layer, `route` routes those nets at that width on one layer, and `analyze impedance` judges the copper that landed. `finish` requires it to pass.++## The spec++```json+"impedanceTargets": {+ "USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90},+ "RF": {"net": "RF_OUT", "targetOhms": 50, "tolerancePercent": 5}+}+```++- `pair` (positive first) with `targetDiffOhms`, or `net` with `targetOhms`.+- `tolerancePercent`: default 10.+- `gapMm` (a pair): solve the width for this gap. `widthMm`: solve the gap (a pair) or use this width (a single net).+- `layer`: hold the target to one layer.+- `maxSkewMm`: the length mismatch a pair may keep, default 0.5 mm. Set it from the interface's skew budget.+- `maxUncontrolledMm`: the length of each net that may miss the target, default 3 mm. It covers pad breakouts and the bends of a pair.+- `maxWidthMm`: refuse a layer that needs a wider trace.++adom-fields' own `impedance` map (`{"RF_OUT": 50, "USB_DP/USB_DN": 90}`) is read too, at 10 %. Its values must stay numbers: Fields refuses a spec with anything else there, so per-target objects go under `impedanceTargets`. A net may be in one target only. The targets are spec budgets: they freeze at placement like `loads` (`spec-amend` moves them, on the record).++## The stackup++- Dielectric thickness per gap between copper layers: spec `stackupMm` (one value per gap; three values on a two-layer board are summed, as adom-fields does), else the board's saved physical stackup (KiCad Board Setup > Physical Stackup).+- Permittivity: the board stackup's `epsilon_r` (thickness-weighted per gap), else spec `epsilonR`.+- Copper: spec `copperUmByLayer`, else `copperUm`, else the board stackup's copper thickness, else 35 um (said in the output).+- With no dielectric thickness or no permittivity the command refuses and names the key to add. Nothing is guessed.++Outer layers are microstrip over the adjacent copper layer. Inner layers are stripline between the two adjacent layers (offset stripline when the dielectrics differ). The adjacent layers are taken to be solid reference planes under the trace. Keep them so.++## The fab limits++Widths and gaps are held to the board's `.kicad_dru` minimums (track_width, clearance), the spec's `minTrackWidth` and the board clearance (spec `clearance`, else the router's 0.2 mm). A layer where the target needs a narrower track or gap is refused, with the reason.++## `adom-aiflow impedance table`++```+adom-aiflow impedance table # the run's board and spec+adom-aiflow impedance table --board B.kicad_pcb --spec spec.json [--json]+adom-aiflow impedance check [--plan plan.json] # judge the board's copper (or a plan)+```++One row per target and copper layer: the structure, dielectric, permittivity and copper, the width (and gap) and the impedance they give, or REFUSED and why. A layer is refused when the target is out of reach, the width or gap is under the fab minimum, or the geometry is outside the formula's validated range (it is never extrapolated). The last lines say which layer `route` will use.++On an outer layer a pair's gap is chosen so its centre pitch sits on the grid router's 0.1 mm grid and is reachable at the board clearance on straight and 45 degree runs (pitch 0.5, 0.7, 1.0 ... mm). An AI router may use any width and gap: set `gapMm` or `widthMm` and the table solves the other.++## The formulas++All quasi-static and lossless, without solder mask:++| Structure | Formula | Source |+|---|---|---|+| Surface microstrip | Hammerstad and Jensen, thickness-corrected | IEEE MTT-S 1980; KiCad's calculator |+| Symmetric stripline | Wheeler | IEEE Trans. MTT 26 (1978); Wadell 3.5.1 |+| Offset stripline | parallel combination of two symmetric lines (b = 2h + t) | Wadell 3.5.3 |+| Edge-coupled stripline | Cohn, exact at zero thickness, with his thickness correction | IRE Trans. MTT 3 (1955); Wadell 3.6.1 |+| Edge-coupled microstrip | Garg and Bahl even and odd capacitances, Hammerstad-Jensen single-line terms, 2 e0 t / s sidewall term | IEEE Trans. MTT 27 (1979); Gupta et al. 8.4 |++Wadell: Transmission Line Design Handbook, Artech House 1991.++### Accuracy++The reference is an independent 2-D finite-volume Laplace solve of the same cross-section (`tools/impedance_reference.py`, numpy and scipy, two grids extrapolated to zero cell size). The unit tests hold the formulas to these numbers:++| Case | Closed form | Field solve | Error |+|---|---|---|---|+| Microstrip, 1.6 mm FR4, er 4.5, w 2.95 mm | 50.16 | 50.08 | 0.16 % |+| Microstrip, JLC 7628 prepreg 0.2104 mm, er 4.4, w 0.35 | 51.71 | 51.67 | 0.08 % |+| Microstrip, h 0.2, er 4.2, w 0.30 | 55.64 | 55.61 | 0.05 % |+| Stripline, b 0.5, er 4.3, w 0.2 | 46.80 | 46.82 | 0.04 % |+| Offset stripline 0.2 / 0.4 mm, w 0.15 | 56.74 | 55.95 | 1.4 % |+| Offset stripline 0.2 / 0.6 mm, w 0.15 | 60.36 | 58.39 | 3.4 % |+| Pair, JLC 7628, w 0.2 s 0.2 (Zdiff) | 110.55 | 110.29 | 0.24 % |+| Pair, JLC 7628, w 0.25 s 0.15 (Zdiff) | 94.77 | 94.58 | 0.21 % |+| Pair, 1.6 mm er 4.5, w 1.0 s 0.5 (Zdiff) | 111.44 | 108.57 | 2.6 % |+| Coupled stripline, b 0.5, w 0.15 s 0.15 (Zdiff) | 84.95 | 84.19 | 0.9 % |++Also checked: Wheeler at zero thickness against Cohn's exact stripline (under 0.5 %), Pozar's 50 ohm example (er 2.2, 1.27 mm: w 3.91 mm, eeff 1.87), and that 50 ohm on 1.6 mm FR4 at er 4.5 solves to 2.9 to 3.0 mm.++Refused because the error grows: coupled microstrip with s/h under 0.2 (5.7 % at s/h 0.125) and offset stripline past 3:1 (7 % at 5:1, which is JLC's four-layer In1.Cu and In2.Cu; route those inner layers with the fab's calculator).++Not modelled: solder mask (it lowers an outer trace by roughly 1 to 3 %), etch trapezoid, glass weave, Dk tolerance and frequency dependence, plane voids. This is a routing screen. For an impedance-controlled order the fab's own stackup calculation is the reference.++## `route`++With targets in the spec, `route` sizes each target net from the table, on the outer layer its SMD pads are on (else F.Cu, else the other outer layer), and holds it to that layer (no vias). It refuses before routing when no outer layer can reach a target, and says `take route=ai`.++A pair is routed as two nets, one after the other. The second follows the first: every grid step off the line at the solved centre pitch from its partner costs extra, so it runs beside it at the target gap wherever the obstacles allow. Escape stubs use the target width when the pads are at least that wide; narrower pads keep the rule stub and their breakout is uncontrolled length. The router does not length-tune.++After routing it judges the plan's copper the way `analyze impedance` does and prints, per target, the width, gap, impedance, the coupled percentage of each net and the skew. When the pair was not kept together (an obstacle between the nets, a split around a part) it says so as a WARNING naming the pair for `take route=ai`. It does not claim coupling the copper does not have.++## `analyze impedance`++Judges every target on the landed copper (the plan's for a net not landed yet). Each track is sampled every 0.05 mm; samples inside the net's own pads are skipped.++- A single net: a sample is controlled when its width on its layer gives the target within tolerance.+- A pair: a sample is controlled when a parallel equal-width partner beside it at its real gap gives the target within tolerance.+- The target passes when each net's uncontrolled length is at most `maxUncontrolledMm`, a pair's skew is at most `maxSkewMm`, and a pair stays on one layer without vias.++With a same-input Adom Fields report (`<run>/fields/fields.json` or `--fields`, its `inputIdentity` the saved board and spec), it compares:++- **Signals** (`signals.differentialPairs`, Fields 0.4+): its estimate of each pair. Its formula is the IPC-2141 approximation and it judges every sample, pad breakouts included; any sample without a parallel equal-width partner (a fan-out, a bend) makes it refuse to estimate. A refusal is reported, never replaced by the closed form.+- **The 2-D cross-section solve** (`--with si`, `review.impedance`): Zdiff of each pair at its median gap, with solder mask. Fields finds pairs by name (`_P`/`_N`, `+`/`-`, `DP`/`DM`, `P`/`N`); `USB_DP`/`USB_DN` is not matched (it tries `USB_DM`), so such a pair has no cross-section row.++A Fields value outside the target's tolerance fails the target too. When no Signals entry exists, the hint prints the `signals.differentialPairs` entry to add (layer, reference layer and net, dielectricMm and epsilonR from this stackup).++## What the router does today, measured++On a four-layer JLC 7628 board (F.Cu over 0.2104 mm, er 4.4, 35 um), 90 ohm differential, clearance 0.127 mm: the table gives w 0.3689 mm, gap 0.3311 mm (pitch 0.7).++| Layout | Coupled | Skew | Closed form | Fields Signals | Fields 2-D solve |+|---|---|---|---|---|---|+| Straight 30 mm, pads on the pair pitch | 100 % | 0 | 90.0 | 86.4 (within target) | 85.2 |+| 5 mm jog, 0.5 mm pads | 92 % | 0.26 mm | 90.0 to 90.2 | refused (fan-out, bends) | 85.2 |+| 5 mm jog, pads on the pair pitch | 96 % | 0.18 mm | 90.0 to 90.2 | refused (bends) | 85.2 |+| A through-hole post between the pair's straight path | 77 % | 0.75 mm | fails | not run | not run |++The Fields 2-D solve sits about 5 % under the closed form on this geometry; the solder mask it includes accounts for part of that. Both are inside 10 %.++Boards with several pairs crossing, length-matched lane groups, or pairs that must change layer are still the AI's to route (High-speed boards in SKILL.md); `impedance table` gives the widths and gaps for that work and `analyze impedance` judges it the same way.
docs/mechanical-wiring-reviews.mdadded+136@@ -0,0 +1,136 @@+# Mechanical service-access and wiring reviews (issue #60)++Two review tools check what a board needs after it is built: the service fasteners and access holes+(adom/adom-aiflow-mechanical), and the installed harnesses and connectors (adom/adom-aiflow-wiring).+AI Flow makes a declared review a required step before finish, runs the tool, binds its passing+result to this board, and voids it when a reviewed connector or a hole moves. All existing gates+stay as they are. A spec that declares no review behaves exactly as before.++## What the tools are, and are not++- Both are MIT Rust prototypes in their own wiki repos. They are **not released as wiki packages**:+ there is no `adom-wiki pkg install` for them yet.+- `adom-aiflow-mechanical lint <review.json>` checks that every fastener names its tool, approach,+ removal sequence and retention, that an access hole passes the tool and the extracted part with+ explicit radial clearance, that an angled tool is verified, and that swept-volume and board+ revision checks are declared.+- `adom-aiflow-wiring lint <wiring.json>` checks each harness for pin/colour, terminal fit, release,+ force (newtons), swept clearance, bend, retention, neighbouring connector service clearance and+ revision evidence, per-conductor IDs and endpoints (duplicates fail), the conductor count against+ the pin inventory, and required cut length against the minimum supplied length.+ `adom-aiflow-wiring audit-glb <assembly.glb>` rejects duplicate `extras.physicalConductorId` roots.+- Exit codes: 0 pass, 1 unresolved findings, 2 input error. Stdout is JSON with `schemaVersion: 1`,+ `passed` and `findings` (`id`, `check`, `severity`, `hint`).+- They check declared completeness, dimensions and lengths. They do **not** compute swept volumes or+ collisions, and a pass is not physical-fit evidence. Tool animation is optional; the declared+ service clearance is always checked.++## Getting the tools++AI Flow looks for the tool in this order:++1. `AIFLOW_MECHANICAL_SRC` / `AIFLOW_WIRING_SRC` set to a source clone: AI Flow runs+ `cargo run --release --quiet --manifest-path <clone>/Cargo.toml -- lint <file>`.+2. `adom-aiflow-mechanical` / `adom-aiflow-wiring` on PATH.++```+adom-wiki repo clone adom/adom-aiflow-mechanical --dir ~/src/adom-aiflow-mechanical+cd ~/src/adom-aiflow-mechanical && cargo build --release # then put target/release on PATH+# or: export AIFLOW_MECHANICAL_SRC=~/src/adom-aiflow-mechanical+```++When neither is found, `review` refuses with these instructions. The declared review stays required.++## The contract (schemaVersion 1)++### Declaration, in spec.json (or requirements.json when the spec has none)++```json+"mechanicalReview": {"schemaVersion": 1, "input": "reviews/assembly-access.json", "refs": ["H1", "H2", "J3"], "refPrefixes": ["H"]},+"wiringReview": {"schemaVersion": 1, "input": "reviews/wiring.json", "refs": ["J9"], "glb": "export/assembly.glb"}+```++- `schemaVersion` must be 1. Anything else is refused.+- `input` is relative to the declaring file.+- `refs` are the board references the review depends on (connectors, mounting holes, fastened parts).+ `refPrefixes` adds every reference with that prefix followed by a digit. Both are optional.+- `glb` (wiring only, optional) also runs `audit-glb` on the exported assembly.+- An absent key, `null` or `false` means not declared.++### Input file++The tool's own format, plus `"schemaVersion": 1` at the top level. The tools ignore AI Flow's+extra fields:++```json+{"schemaVersion": 1, "fasteners": [{"id": "sensor-screw", "ref": "H3", "tool": "2.5 mm hex", "approach": "underside",+ "removalSequence": "loosen, capture, extract", "retention": "magnetic tray", "requiresAccessHole": true,+ "holeDiameterMm": 6.8, "toolEnvelopeDiameterMm": 4.0, "extractedPartDiameterMm": 5.5, "radialClearanceMm": 0.5,+ "sweptVolumeVerified": true, "boardRevisionVerified": true}]}+```++```json+{"schemaVersion": 1, "harnesses": [{"id": "fan", "connectors": ["J9"], "expectedConductorCount": 2,+ "requiredLengthMm": 120, "minimumAvailableLengthMm": 150, "pinColorEvidence": "...", "...": "...",+ "wires": [{"physicalId": "fan-red", "net": "12V_FAN", "color": "red", "sourcePin": "fan.red", "destinationPin": "J9.1", "...": "..."}]}]}+```++- `fasteners[].ref` and `harnesses[].connectors` add to the bound references.+- Every bound reference must be on the current board, or `review` refuses the review as stale.++### Recorded outcome (`outcomes.mechanical-review` / `outcomes.wiring-review`)++`schemaVersion` 1, `pass: true`, `kind`, `declared`, `tool` (`mode` path with the executable's sha256,+or cargo with the source clone's hash), `input` and `inputSha256`, `spec` and `specSha256`,+`requirementsSha256` (when the requirements declare it), `board` and `boardSha256`, `refs`, `refPrefixes`,+`serviceFeatures` (one entry per bound footprint and per hole: `ref`, `lib`, `at`, `layer`, and the+sha256 of its canonical copper form from copper_carry.rs), `exitCode`, `result` (the tool's JSON),+`audit` (audit-glb, with the GLB's sha256), `at` and `qualification`.++A failing run records nothing as an outcome: it removes an earlier pass and logs `review-failed`+with the findings.++## In the flow++- When a review is declared, `mechanical-review` / `wiring-review` are inserted into the run's+ sequence just before `finish` (at `step`, `review` and `finish`). The step's proof is the outcome,+ recorded during the step's latest visit: `step mechanical-review`, then `review mechanical`.+- `finish` refuses while a declared review is missing, failed, from another contract version, or stale:+ - the review file is not the declared one or its sha256 changed;+ - the spec (or the declaring requirements) changed since the review;+ - the audited GLB changed;+ - the board is not the reviewed one. A later board counts only through the run's copper carry+ records (`carried_from`: copper fingerprint unchanged, so every footprint is where it was), or+ a confirmed zones-only carry.+- Not declared: no step is added and finish checks nothing new. `review` still runs on `--input`+ and records an advisory outcome.++## What a connector or hole change invalidates++On `adopt-board` and `place check` (the commands that make a new board current from a placement+change), for runs that declare or recorded a review only, AI Flow compares the service features of+the old and new boards: every footprint a review names (refs and prefixes) and every footprint with+an NPTH pad or a library name containing `MountingHole`. When any moved, rotated, changed side or+pads, appeared or disappeared (or a board cannot be read), it:++- removes the outcomes `mechanical-review`, `wiring-review` and `testplan` (the probing test plan);+- logs `service-evidence-invalidated` and appends to `serviceInvalidated` (`newBoard`, `changed`,+ `outcomes`);+- makes finish require `testplan check` again (finish also re-checks every probe landing on the board,+ as before).++Routing and thermal evidence (`route`, `gate`, `land-route`, `land-vias`, `land-pours`, `measure`,+`analyze-current`, `analyze-thermal`) and the Fields report are voided by the existing copper+fingerprint machinery, because a moved footprint always changes the copper fingerprint. Silkscreen+and export proofs are dropped by every adoption, as before. A Fusion `route` that copies the live+board does not run this comparison; the board binding at finish still refuses a review of another+board.++## Not done yet (issue #60 follow-ups)++- No geometry evidence binding beyond footprint positions: the tools do not read the board or the+ enclosure, and the `sweptVolumeVerified` style fields are declarations.+- No swept-volume or collision solver adapter.+- No package release of either tool, so no install verification; AI Flow does not ship them.+- No default skill discovery from the wiki for the two prototype skills; AI Flow's own skills+ (aiflow-sourcing, aiflow-power-layout) say when to declare the reviews.
flows/board.json+48@@ -271,6 +271,38 @@ "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences." ] },+ {+ "name": "mechanical-review",+ "who": "ai",+ "optional": "only when the spec or requirements declare mechanicalReview",+ "does": "Only when the spec or requirements declare mechanicalReview: the mechanical service-access review before assembly export: every service fastener with its real tool, approach, retention and removal order, and every access hole sized for the tool and the extracted part with explicit clearance. The binary runs adom-aiflow-mechanical lint on the declared review file and records the passing result bound to the review file, spec and board hashes.",+ "binary": [+ "review mechanical"+ ],+ "record": "the review file and the tool's PASS in the terminal; an optional tool animation, labelled illustrative",+ "workflow": [+ "Declare it during component selection or placement when the board has service fasteners, underside screws, mounting holes a tool must reach, or parts that must come out after assembly: spec mechanicalReview {\"schemaVersion\": 1, \"input\": \"<review.json>\", \"refs\": [\"H1\", \"J3\"]}. The input has \"schemaVersion\": 1 and a fasteners list in the adom-aiflow-mechanical format (docs/mechanical-wiring-reviews.md).",+ "`adom-aiflow review mechanical` runs the tool (on PATH, or AIFLOW_MECHANICAL_SRC=<clone> with cargo). Exit 1 is unresolved review work, not permission to waive it. Never set a Boolean true to satisfy the linter: link the CAD check report and review it.",+ "The tool checks declared completeness and dimensions only. It does not solve swept volumes or collisions and is not physical-fit evidence; say so when you report.",+ "Tool animation is optional; required service clearance is always checked. A board adoption that moves a named part or any hole voids this review and the test plan: run both again."+ ]+ },+ {+ "name": "wiring-review",+ "who": "ai",+ "optional": "only when the spec or requirements declare wiringReview",+ "does": "Only when the spec or requirements declare wiringReview: the wiring review before assembly export: every installed harness and factory lead once, its pin and colour map from supplier evidence, terminal fit, latch release, mating force in newtons, bend radius, retention, neighbouring connector service corridors and cut length. The binary runs adom-aiflow-wiring lint (and audit-glb on the exported assembly when declared) and records the passing result bound to the review file, spec and board hashes.",+ "binary": [+ "review wiring"+ ],+ "record": "the harness table and the tool's PASS in the terminal; an optional mate/unmate animation, labelled illustrative",+ "workflow": [+ "Declare it when the board takes harnesses, fan or sensor leads, or wire-to-board connectors: spec wiringReview {\"schemaVersion\": 1, \"input\": \"<wiring.json>\", \"refs\": [\"J9\"], \"glb\": \"<assembly.glb>\" (optional)}. The input has \"schemaVersion\": 1 and a harnesses list in the adom-aiflow-wiring format; each harness may name its board connectors in \"connectors\".",+ "`adom-aiflow review wiring` runs the tool (on PATH, or AIFLOW_WIRING_SRC=<clone> with cargo). Unknown force, overlength leads, duplicate conductors or endpoints, a pin count mismatch and missing neighbouring service clearance are findings to fix, not to waive.",+ "Prefer moving the connector near the wire exit over a long detour. A moved connector voids this review, the test plan, and (through the copper change) routing, DRC, current and thermal evidence: redo them.",+ "The tool checks declared completeness and lengths only; it is not a collision solver. Inspect the live viewer for stale wire meshes as well."+ ]+ }, { "name": "capture", "who": "binary",@@ -496,6 +528,12 @@ "3d": [ "tour-3d" ],+ "mechanical-review": [+ "mechanical-review"+ ],+ "wiring-review": [+ "wiring-review"+ ], "simulation": [ "evidence-simulation" ],@@ -540,6 +578,8 @@ "nets": "Pour walkthrough", "silkscreen": "Silkscreen", "3d": "3D board",+ "mechanical-review": "Mechanical review",+ "wiring-review": "Wiring review", "finish": "Finish line", "export": "Gerbers, BOM, CPL" },@@ -568,6 +608,8 @@ "nets": "each pour net lit in turn", "silkscreen": "labels, polarity, pinouts, both faces", "3d": "the finished board in 3D",+ "mechanical-review": "service fasteners, tools and access holes (declared reviews only)",+ "wiring-review": "harnesses, connectors and service corridors (declared reviews only)", "finish": "0 unconnected, 0 errors, every check passed", "export": "manufacturing files written and checked" },@@ -597,6 +639,8 @@ "nets": 10, "silkscreen": 30, "3d": 15,+ "mechanical-review": 20,+ "wiring-review": 20, "finish": 20, "export": 20 },@@ -627,6 +671,8 @@ "nets": ["aiflow-power-layout", "aiflow-live-clips"], "silkscreen": ["aiflow-silkscreen"], "3d": ["aiflow-molecule"],+ "mechanical-review": ["aiflow-sourcing", "aiflow-power-layout"],+ "wiring-review": ["aiflow-sourcing", "aiflow-power-layout"], "capture": ["aiflow-process-video", "aiflow-live-clips"], "finish": ["aiflow-measurement", "aiflow-process-video"], "export": ["aiflow-schematic-to-board", "aiflow-molecule"]@@ -669,6 +715,8 @@ "Finish": [ "silkscreen", "3d",+ "mechanical-review",+ "wiring-review", "finish", "export" ]
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.24",+ "version": "0.2.25", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.24",+ "version": "0.2.25", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+17−3@@ -40,6 +40,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via or trace already on the live board (same net, same points) is not landed twice, so a rerun after a stop resumes where it stopped (`--from <n>` starts at trace n), a reply lost to the transport is answered by reading the board and sending that one trace once more only if it did not land, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. A via whose copper would touch another net's track, via or pad (planned or on the board) is refused before anything lands, and so is a via within the spec's hole-to-hole minimum (`holeToHole`, default 0.2 mm) of any via or hole, or within `clearance` of another via's copper, whatever its net (the ERROR names the clash), and every landed via is read back: one that came back on another net than planned is taken back and the command errors with both nets. 4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. Filled zones are that copper too: a pad inside a fill of its net on its layer (a through-hole pad on either layer) is joined, so a net its pours already join is skipped and named in the note. A net with a zone that has no fill in the board file is still tracked, with a WARNING to refill the zones and route again. With `planes`, a pad on the plane's own outer layer (an F.Cu pad on an F.Cu plane) sits in the pour and gets no stub or via; through-hole pads reach any plane. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+ **`route --variants N`** (issue #55, at most 8): route the same board N times with different levers and let the gates pick. The levers are the ones kicad-place-route-loop lists, in a fixed order so a rerun gives the same variants: v1 is a plain `route` (shortest nets first, the spec's vias, `--passes`), then longest first, signal vias at the spec's power via size (left out when the spec's two vias are the same), most pads first, seeded orders (seed 1, 2), shortest first with 4 more passes, longest first with power-size vias and 4 more passes; more than that are more seeds. Each variant writes `plan-route-v<k>.json`, `plan-route-v<k>-report.json` and `router-v<k>.log`, and is gated with the checks `gate` runs (the same function, routed board `board-routed-v<k>.kicad_pcb`). The ranking uses the gates alone, in order: unrouted connections; gate issues (new DRC errors, unconnected, planeUnder, switch node, courtyards and bodies, decoupling errors); copper length of the plan; via count; then the failed current and thermal screens, only when a Fields solve of that exact routed board is on the run (a Fields solve per variant is not run: it takes minutes per board, so in practice this column is empty and `analyze` runs on the winner as usual); the lower variant number breaks a full tie. Without kicad-cli the variants are ranked without the gate column and the table says so. The winner becomes `plan-route.json` exactly as a plain route would, and every variant (levers, scores, plan paths, rank) is on the route outcome under `variants`, in the ledger as `route-variants`, and in the run README as a table. **`route --pick K --why "..."`** makes variant K the route plan instead (the board must be unchanged); the reason, the thread and the gates' own pick stay on the record. Then `gate` as usual. A plain `route` is unchanged. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again.@@ -47,12 +48,14 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- ### High-speed boards: your own router, inside the flow -A board with differential pairs, impedance control or length matching (PCIe, OCuLink, USB 3, Ethernet) is beyond the grid router, so you route it yourself, but the work still happens inside the flow. Declare each step as you reach it (`step rules`, `step components`, ... `step routing`); your own schematic, placement or router scripts do the step's work, they do not replace the step. For routing:+Impedance targets go in the spec before routing: `"impedanceTargets": {"USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90}, "RF": {"net": "RF_OUT", "targetOhms": 50}}` (`tolerancePercent` 10, `gapMm`, `widthMm`, `layer`, `maxSkewMm` 0.5, `maxUncontrolledMm` 3 optional), with the stackup from spec `stackupMm` and `epsilonR` or the board's saved physical stackup. `impedance table` prints the width (and pair gap) per copper layer from closed-form formulas (Hammerstad-Jensen, Wheeler, Cohn, Garg-Bahl; within 0.2 to 3.4 % of a 2-D field solve, no solder mask) and refuses a layer the target cannot reach within the fab minimums. `route` then sizes those nets, holds each to one outer layer and routes a pair's second net along the first at the solved pitch; it prints the coupling it achieved and names any pair it could not keep together for `take route=ai`. `analyze impedance` judges the landed copper (and a same-input Fields Signals or `--with si` solve beside it); `finish` requires it. docs/impedance.md has the formulas, their measured accuracy and what the router really does.++A simple pair (one USB 2 pair, a 50 ohm feed) can go through `route` this way. A board with several pairs, length-matched lane groups or layer changes (PCIe, OCuLink, USB 3, Ethernet), or a pair `route` flagged, is beyond the grid router, so you route it yourself, but the work still happens inside the flow. Declare each step as you reach it (`step rules`, `step components`, ... `step routing`); your own schematic, placement or router scripts do the step's work, they do not replace the step. For routing: 1. `take route=ai`, then `step routing` and `stage start route`. 2. Run your own router (wrap it as `adom-aiflow exec -- python3 my_router.py ...` so its time is measured), write its result in the plan JSON shape, then `gate --plan <plan.json>` and `land route --plan <plan.json>`; `land route` is the routing step's proof. 3. `stage end route`.-4. Record the numbers: `evidence add --kind calcs --file design/impedance.json` with the stackup, the trace width and gap per layer for the target impedance, the propagation delay per layer, and the skew per pair and per lane group after routing.+4. Record the numbers: `evidence add --kind calcs --file design/impedance.json` with the stackup, the trace width and gap per layer for the target impedance (`impedance table --json` gives them), the propagation delay per layer, and the skew per pair and per lane group after routing. With `impedanceTargets` in the spec, `analyze impedance` judges your landed pairs the same way it judges the router's. The plan JSON shape (full reference: docs/plan-schema.md): @@ -238,7 +241,7 @@ everything drawn on the design. The lane's steps are the Fusion lane's, with `al The clock starts at the prompt even when there is no board yet. - **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug> [--page-dir <folder>]] [--eda kicad|fusion|altium]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.-- **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). A profile states preferences, never a gate: any distributor is fine on any profile (DigiKey, Arrow, LCSC, ...), because every fab takes consigned (customer-supplied) parts. `fab` (the 3rd party fab) prefers Mouser or Adom stock; `jlcpcb` prefers JLCPCB/LCSC parts with their LCSC number. Rows from anywhere else pass with one warning that lists them as consigned parts, and under it one suggestion per row: the check looks the exact MPN up at the preferred source with `adom-parts-search search` (one search per distinct MPN, at most 25 per check, cached in the run folder as sourcing-search-cache.json) and says to switch the source when it is in stock there, or how to look for an equivalent. `AIFLOW_NO_PART_SEARCH=1` skips the searches. The outcome records `consignedRows` and `suggestions`. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff.+- **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). A profile states preferences, never a gate: any distributor is fine on any profile (DigiKey, Arrow, LCSC, ...), because every fab takes consigned (customer-supplied) parts. `fab` (the 3rd party fab) prefers Mouser or Adom stock; `jlcpcb` prefers JLCPCB/LCSC parts with their LCSC number. Rows from anywhere else pass with one warning that lists them as consigned parts, and under it one suggestion per row naming the preferred source's number and stock, and where each came from. Per distinct MPN it reads the wiki component page first (`component.distributor_links` in `adom/<mpn>` page.json), then the pnp-inventory vendor stock cache (when that CLI is installed), and only then runs a live `adom-parts-search search` for what is still missing (at most 25 live searches per check). Page and cache answers are cached in the run folder as sourcing-lookup-cache.json, live results as sourcing-search-cache.json. A stock count from a page or the cache shows its date; with no date it says "stock not recorded" and how to check it live. `AIFLOW_NO_PART_SEARCH=1` skips the live searches; `AIFLOW_NO_PART_LOOKUP=1` skips every lookup. The outcome records `consignedRows`, `suggestions` and `suggestionLookups` (page reads, cache hits and live searches, counted apart). The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`. @@ -348,6 +351,7 @@ AI Flow's binary is the instrument, the gates and the hands; the judgement for e | a vendor login, account or verification code | `aiflow-credentials` | | two-layer power layout: hot loop, SW node, Kelvin taps, thermal vias | `aiflow-power-layout` | | silkscreen | `aiflow-silkscreen` |+| service fasteners, access holes, harnesses and connectors (declared `mechanicalReview` / `wiringReview`) | `aiflow-sourcing` (declare), `aiflow-power-layout` (placement), docs/mechanical-wiring-reviews.md | | after every step: the clip goes on the project page | `aiflow-live-clips` | | after `finish`: the molecule on a scaffold wired to the control panel | `aiflow-scaffold-probe` | | metrics and comparisons | `aiflow-measurement`, `aiflow-comparison-video` |@@ -636,3 +640,13 @@ Every LED on the board is described in `led-states.json` beside the board, and l `silkscreen-audit --record` and `finish` print one line per label check on every run, a pass included (`power inputs: 0 found`, `power inputs: 1 (J1 VIN), all labelled with a voltage`, `test points: ...`), and record `powerInputs` and `testPoints` (`found`, `labelled`, `unlabelled`, `mismatched`, `items`) in the audit's result JSON, the `silkscreen` outcome and the `finish-checks` outcome. `finish` also prints `LED states: no LEDs on the board: led-states.json not required` (or what it found) and `programming: no programmable part: programming not required` (or the parts and interface). For each LED part, add `led.json` to its wiki component page when you may edit that page: `{"$schema": "adom/led-part@1", "colour": {"name", "dominant_nm", "hex"}, "vf_V": {"typ", "max", "at_mA"}, "if_max_mA", "viewing_deg", "glb_lens_material": "<material or mesh name of the lens in the part's GLB>"}`. When you may not edit the page, open an issue on it carrying that record. Never edit someone else's page.++## Mechanical service-access and wiring reviews (issue #60)++Optional steps, required only when the spec (or requirements) declares them: `"mechanicalReview": {"schemaVersion": 1, "input": "<review.json>", "refs": ["H1", "J3"]}` and `"wiringReview": {"schemaVersion": 1, "input": "<wiring.json>", "refs": ["J9"], "glb": "<assembly.glb>"}`. Declare them during component selection (aiflow-sourcing) and keep the review file current during placement (aiflow-power-layout). The run then has `mechanical-review` / `wiring-review` just before `finish`.++- `step mechanical-review`, then `adom-aiflow review mechanical` (or `review wiring`, `--input` and `--glb` to override). It runs `adom-aiflow-mechanical lint` / `adom-aiflow-wiring lint` (plus `audit-glb` when a GLB is declared) and records the passing result with the sha256 of the review file, the spec and the board, the bound connectors and holes, and the tool's identity. A failing run records nothing and lists the findings. The input file carries `"schemaVersion": 1`.+- The tools are unreleased MIT prototypes with no wiki package: `adom-wiki repo clone adom/adom-aiflow-mechanical --dir <d>` (and adom/adom-aiflow-wiring), then `cargo build --release` and put `target/release` on PATH, or set `AIFLOW_MECHANICAL_SRC` / `AIFLOW_WIRING_SRC=<d>`. Without one, `review` refuses with this hint and finish stays blocked.+- `finish` refuses a declared review that is missing, failed, or stale (the review file, spec or GLB changed, or the board is not the reviewed one unless the copper carry records prove the copper is unchanged).+- `adopt-board` and `place check` compare the named connectors and every NPTH or mounting hole between the old and new board. A change removes the review outcomes and the test plan outcome (`serviceInvalidated` in run.json); routing, gate, landings, measure, current and thermal are already voided by the copper change. Run `testplan check` and the reviews again.+- The tools check declared completeness, dimensions and lengths. They are not swept-volume or collision solvers; a pass is not physical-fit evidence. Tool animations are optional. Not declared: nothing changes.
skills/aiflow-comparison-video/SKILL.md+4@@ -44,6 +44,10 @@ adom-aiflow compare --with <other run dir> --page <owner/slug> --push --refresh `compare` writes `docs/comparisons/<a>-vs-<b>/README.md` on the page: the video first with one paragraph, the number (AI time to delivery, returns, turns, thinking and binary minutes for both), the per-step table, the drawings side by side with each AI's caption, and the links to both run pages and the flow. Link it from the page's README so people find it. A re-cut is `compose --with` again, then `compare --push` again; the video gets a new name each time so no browser shows the old one. +## Route variants++When a run routed with `route --variants N`, the routing step has more than one candidate: the run's route outcome carries `variants` (each variant's levers, unrouted count, gate issues, copper length, vias, rank, plan file, and who chose the winner and why), and the run README shows the same table. Say it in the routing words: how many variants the router tried, what changed between them (net order, via size, passes), which one the gates kept and by which number, and an AI override with its reason when there was one. To show the copper, gate or render each variant's `plan-route-v<k>.json` (its routed board is `board-routed-v<k>.kicad_pcb` in the run) next to the winner; only the winner is landed.+ ## What the numbers mean AI time is the sum of the run's sessions, prompt to done, idle cut out (docs/time.md). The delivery number is the first session; the walkthroughs and drawings added later are follow-ups and are not in it. Per-step minutes are the AI's active time under that step. Returns are the `--back` visits before delivery.
skills/aiflow-power-layout/SKILL.md+1@@ -36,6 +36,7 @@ Write `aiflow/spec.json` from the schematic before `start`, with a note on every - Input caps, IC VIN and IC GND form the hot loop: smallest possible loop on F.Cu, the high-frequency cap closest to the pins. - Inductor right after SW; output caps right after the inductor; the feedback network on the quiet side, away from SW. - **Series chains get ONE rotation.** Before `spec.chains`, `place pack` picked 90/270 per part independently and knotted a chain: R1/R2/C9 came out VOUT-bottom, FBM-top, forcing FBM to loop round and a via into an 0402 pad. Turned 180 together, the column read VOUT, R1, FBM, R2, FB top to bottom and FBM became a 1 mm straight link. Now declare the chain in electrical order, `"chains": [{"refs": ["R1", "R2", "R3"], "direction": "down"}]` (VOUT-R1-FBM-R2-FB-R3-GND), give every member a wish, and `place pack` lays them on one line from the first member's wish, each turned so the shared-net pads face (a part numbered the other way round turns 180), and reports `chains: N of M honoured` with the facing pads. A chain not honoured says why (a member with no spot, no wish, or no common net). Then `place check --moves` and `place land --moves`.+- **Connectors and holes are service features.** Place a harness connector near where its wire leaves the fan or sensor before choosing a long lead, keep each connector's mating, withdrawal and latch-release corridor clear of neighbouring wires and parts, and give every service screw a straight tool path. When the spec declares `mechanicalReview` or `wiringReview`, update the review file as you move these parts and run `review mechanical` / `review wiring` in its step before finish. Moving a named connector or any hole after a review voids the reviews and the test plan (and, through the copper change, routing, DRC, current and thermal evidence): run them again. See docs/mechanical-wiring-reviews.md. ## 3. Routing: keep B.Cu a ground plane under the hot loop
skills/aiflow-sourcing/SKILL.md+21−6@@ -62,13 +62,20 @@ adom-aiflow --ai-thread "<thread>" sourcing check --bom design/bom.csv [--profil It reads the profile from the spec or requirements.json (`fab` by default), refuses a row without an MPN, a reference listed twice, a distributor row without a stock check, and a stock column without its check date; it warns on stock under twice the build quantity. A source is never an error: rows not from the profile's preferred source (Mouser or Adom stock on `fab`, a row with an LCSC number on `jlcpcb`) pass with one warning that lists them as consigned parts, recorded as `consignedRows` in the outcome. A customer-supplied row (`customer supplied`, `consigned`) with no stock count is a warning too. -Under that warning it makes one suggestion per consigned row. It runs `adom-parts-search search <MPN>` once per distinct MPN (at most 25 per check, no retries, shared quota) and looks for that exact MPN in stock at the preferred source:+Under that warning it makes one suggestion per consigned row, using the data Adom already has before spending vendor quota. Per distinct MPN, in this order: -- `fab`: `Mouser has <MPN> (<Mouser PN>, <qty> in stock): set the source to Mouser to skip a consignment shipment`-- `jlcpcb`: `LCSC has <MPN> (<C-number>, basic|extended, <qty> in stock): add it to the lcsc column`-- otherwise: `no exact match at <Mouser|LCSC>; adom-parts-search show "<value package>" to look for an equivalent, or send this part as consigned`+1. **The wiki component page** (`adom-wiki repo show adom/<mpn-lowercase> page.json`, then the wiki slug form, e.g. adom/ap2112k-3-3trg1 for AP2112K-3.3TRG1). It reads `component.distributor_links`: `mouser` on `fab`, `lcsc` (or `jlcpcb`) on `jlcpcb`. A link is a part number string, `{part, url, stock, checked}` or `false` (that distributor does not stock it). One read per MPN.+2. **The pnp-inventory vendor stock cache** (`pnp-inventory vendor-stock --mpn <MPN>`, the service's hourly Mouser / DigiKey / LCSC cache), when the page gave no number or no dated stock and the CLI is installed. Skipped quietly otherwise.+3. **Only then a live `adom-parts-search search <MPN>`**, for an MPN still without a number or a dated stock count (at most 25 live searches per check, no retries, shared quota). Page and cache reads do not count toward the 25. -The results are cached in the run folder (sourcing-search-cache.json), so checking again searches only new MPNs. The suggestions are stored as `suggestions` in the outcome. With adom-parts-search not installed, or `AIFLOW_NO_PART_SEARCH=1`, the searches are skipped with one note. With a board it diffs the BOM against every electrical reference (missing and extra); run it again after `start --board`, because `finish` requires the diffed pass when a profile is named. On the TPS54202 molecule the check found the contacts and machine pins (J1 to J6, MP1 to MP4) missing from the BOM.+Every suggestion says where the number and the stock came from, and a stock count from a page or the cache always carries its date:++- `U2 (TPS54202DDCR, DigiKey): Mouser has it, 595-TPS54202DDCR, 160 in stock (wiki page adom/tps54202ddcr, checked 2026-10-20); sourcing it there saves a consignment shipment (`adom-parts-search show "TPS54202DDCR"` checks it live before ordering)`+- `U4 (AP2112K-3.3TRG1, DigiKey): Mouser has it, 621-AP2112K-3.3TRG1 (wiki page adom/ap2112k-3-3trg1), stock not recorded: `adom-parts-search show "AP2112K-3.3TRG1"` checks it live; sourcing it there saves a consignment shipment`+- `jlcpcb`: `U1 (TPS54202DDCR, DigiKey): LCSC has it, C191884 (extended), 299988 in stock (live search, checked 2026-10-27); add it to the lcsc column to save a consignment shipment`+- otherwise: `no exact match at <Mouser|LCSC> (live search); adom-parts-search show "<value package>" to look for an equivalent, or send this part as consigned`++Page and cache answers are kept in the run folder (sourcing-lookup-cache.json), live results in sourcing-search-cache.json, so checking again reads and searches only new MPNs. The outcome stores `suggestions` (each with `from`: wiki, pnp-inventory, search or cache) and `suggestionLookups` (page reads, cache hits and live searches, counted apart). `AIFLOW_NO_PART_SEARCH=1`, or adom-parts-search not installed, skips the live searches with one note (pages and the cache are still read); `AIFLOW_NO_PART_LOOKUP=1` skips every lookup (offline tests set it). With a board it diffs the BOM against every electrical reference (missing and extra); run it again after `start --board`, because `finish` requires the diffed pass when a profile is named. On the TPS54202 molecule the check found the contacts and machine pins (J1 to J6, MP1 to MP4) missing from the BOM. ## 4. Never write a package dimension without its source @@ -80,7 +87,7 @@ Every footprint needs its model (`adom-aiflow models` refuses otherwise). Take t 1. **The manufacturer's own site.** Symbol, footprint and STEP. Search by the **series** as well as the full MPN: ordering codes often do not match CAD file names, and many families publish one STEP per case size, not per value. 2. **Distributor CAD links.** Mouser and DigiKey part pages link SamacSys (Component Search Engine) and Ultra Librarian downloads. Treat them as reference CAD: check them like any other, and do not republish them on a global wiki page without permission.-3. **The Adom wiki component page.** `adom-wiki page get component/<mpn-lowercase>`. Read its provenance: a file recorded as manufacturer-supplied is the manufacturer's file and can be reused as such.+3. **The Adom wiki component page.** `adom-wiki repo show adom/<mpn slug> page.json` (vendor numbers under `component.distributor_links`; the slug is the MPN lowercased, or with each run of other characters turned into `-`). Read its provenance: a file recorded as manufacturer-supplied is the manufacturer's file and can be reused as such. 4. **KiCad.** KiCad's library 3D model, when every printed dimension is inside the datasheet limits (or within 0.05 mm of a typical-only value) and its leads are realistic (gull-wing feet bent like the real part, not square folds or straight blocks). Otherwise regenerate the package with KiCad's own package generator (kicad-packages3D-generator and kicad-footprint-generator, the CadQuery scripts) at the datasheet's values. Either way, record the measurement you compared (for a gull-wing lead, the JEDEC gauge-plane foot length) and both numbers, the datasheet's and the model's. Never replace a realistic model with a square-fold one over a micron-level miss: regenerate it with the generator instead. The reference implementation is `provenance/tools/kicad-generator/` on adom/stm32g431cbt6 (`kgen.py` runs the generator, `measure.py` measures the result against the datasheet, `materials.py` and the LOD script finish the model). 5. **Only then, draw your own.** Label it AI-generated in the file name and a provenance JSON beside it: datasheet URL, revision, page, figure, retrieval date, PDF sha256, every dimension with its tolerance, simplifications, the generator script, and which OCCT path made it. Create and check the geometry on the shared step2glb OCCT service; install OCCT locally only when `step2glb health` says the service is unreachable, and record that in the provenance. @@ -103,6 +110,14 @@ step2glb thumbnail part.step # look at it - Then look in KiCad's native 3D viewer. A rotation is not done until the part sits on its pads there. - `adom-aiflow models` (or `models --offline`) repeats this for every model bound on the board: step2glb bbox, the footprint's rotate/offset applied, a warning when a flat chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding, or `models --ack <refs> --why "<what the 3D viewer showed>"`; `finish` refuses open warnings. +## Mechanical and wiring reviews: declare them while choosing parts++When a part brings service hardware or wiring, declare the review in the spec at this step, so it is a required step before finish (docs/mechanical-wiring-reviews.md):++- Screws, standoffs, underside sensor screws, mounting holes a tool must reach, a part that must come out after assembly: `"mechanicalReview": {"schemaVersion": 1, "input": "reviews/assembly-access.json", "refs": ["H1", "J3"]}`. Record the real tool (drive type, shaft and handle envelope), head and nut sizes and the removal order from the hardware's datasheet.+- Fans, sensors, factory leads, wire-to-board connectors: `"wiringReview": {"schemaVersion": 1, "input": "reviews/wiring.json", "refs": ["J9"]}`. Record the exact mating housing, contacts, crimp tool, wire colours from the supplier drawing (not a convention), lead length and mating force in newtons. Unknown stays unknown.+- The tools are unreleased prototypes (adom/adom-aiflow-mechanical, adom/adom-aiflow-wiring): clone and build them, or set AIFLOW_MECHANICAL_SRC / AIFLOW_WIRING_SRC. They check declared completeness and dimensions, not geometry.+ ## 7. Hand off `design/bom.csv` with the stock column, `design/ps_*.txt`, the CAD files with their provenance, and a short note to the human listing thin-stock parts. Next: aiflow-circuit-design.
tests/impedance-workflow.pyadded+87@@ -0,0 +1,87 @@+"""Impedance targets end to end (issue #52): table, route sizes and couples a USB pair, analyze+impedance judges the copper, a refused layer and a split pair are reported, never claimed.+Usage: python3 tests/impedance-workflow.py <adom-aiflow binary>"""+import json, os, pathlib, subprocess, sys, tempfile++binary = str(pathlib.Path(sys.argv[1]).resolve())+STACK = ('(setup (stackup (layer "F.Cu" (type "copper") (thickness 0.035)) (layer "dielectric 1" (type "prepreg") (thickness 0.2104) (epsilon_r 4.4)) '+ '(layer "In1.Cu" (type "copper") (thickness 0.0152)) (layer "dielectric 2" (type "core") (thickness 1.065) (epsilon_r 4.6)) '+ '(layer "In2.Cu" (type "copper") (thickness 0.0152)) (layer "dielectric 3" (type "prepreg") (thickness 0.2104) (epsilon_r 4.4)) '+ '(layer "B.Cu" (type "copper") (thickness 0.035))))')+++def board(path, y1, post=""):+ def fp(r, x, y, g):+ return (f'(footprint "T:{r}" (layer "F.Cu") (at {x} {y}) (property "Reference" "{r}") '+ f'(pad "1" smd rect (at 0 -0.35) (size 1.0 0.4) (layers "F.Cu") (net 1 "USB_D+")) '+ f'(pad "2" smd rect (at 0 0.35) (size 1.0 0.4) (layers "F.Cu") (net 2 "USB_D-")) '+ f'(pad "3" thru_hole circle (at {g} 0) (size 0.8 0.8) (drill 0.4) (layers "*.Cu") (net 3 "GND")))')+ path.write_text(f'(kicad_pcb (version 20241229) (generator "test") (general (thickness 1.6)) (layers (0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user)) {STACK} '+ f'(net 0 "") (net 1 "USB_D+") (net 2 "USB_D-") (net 3 "GND") (net 4 "X") {fp("J1", 4.0, 6.05, -1.5)} {fp("U1", 36.0, y1, 1.5)} {post} '+ '(gr_rect (start 0 0) (end 40 20) (layer "Edge.Cuts") (stroke (width 0.1) (type solid))))')+++with tempfile.TemporaryDirectory(prefix='aiflow-impedance-') as td:+ p = pathlib.Path(td)+ env = dict(os.environ, AIFLOW_DASHBOARD='off')++ def call(run, *args):+ return subprocess.run([binary, '--run', str(run), '--ai-thread', 'impedance-regression', *args], env=env, capture_output=True, text=True, timeout=120)++ spec = {"clearance": 0.127, "minTrackWidth": 0.127, "track": 0.2, "copperUm": 35,+ "impedanceTargets": {"USB": {"pair": ["USB_D+", "USB_D-"], "targetDiffOhms": 90}}}+ (p / 'spec.json').write_text(json.dumps(spec))+ b = p / 'usb.kicad_pcb'+ board(b, 11.05)++ # the table: both outer layers solve on the router's grid pitch, JLC's 5:1 inner layers are refused+ r = call(p / 'none', 'impedance', 'table', '--board', str(b), '--spec', str(p / 'spec.json'), '--json')+ assert r.returncode == 0, r.stdout + r.stderr+ doc = json.loads(r.stdout[:r.stdout.index('\nOK: ')])+ rows = {x['layer']: x for x in doc['rows']}+ assert rows['F.Cu']['ok'] and abs(rows['F.Cu']['ohms'] - 90) < 0.5, rows['F.Cu']+ assert abs(rows['F.Cu']['widthMm'] + rows['F.Cu']['gapMm'] - 0.7) < 1e-6, rows['F.Cu']+ assert not rows['In1.Cu']['ok'] and '3:1' in rows['In1.Cu']['why']+ assert 'route sizes USB' in r.stdout++ # a fab too coarse for any outer-layer solution: route refuses before routing and names take route=ai+ coarse = dict(spec, minTrackWidth=0.6)+ (p / 'coarse.json').write_text(json.dumps(coarse))+ run = p / 'run-coarse'+ assert call(run, 'start', '--board', str(b), '--spec', str(p / 'coarse.json'), '--engine', 'test', '--prompt-time', '2026-10-27T00:00:00Z').returncode == 0+ r = call(run, 'route')+ assert r.returncode != 0 and 'cannot be routed at 90' in r.stdout and 'take route=ai' in r.stdout, r.stdout++ # the binary alone: route sizes and couples the pair, analyze impedance passes on its copper+ run = p / 'run'+ assert call(run, 'start', '--board', str(b), '--spec', str(p / 'spec.json'), '--engine', 'test', '--prompt-time', '2026-10-27T00:00:00Z').returncode == 0+ r = call(run, 'route')+ assert r.returncode == 0, r.stdout + r.stderr+ assert 'impedance USB (USB_D+ / USB_D-): routed on F.Cu at width 0.3689 mm' in r.stdout, r.stdout+ rep = json.loads((run / 'plan-route-report.json').read_text())['impedance']['check'][0]+ assert rep['status'] == 'pass' and rep['vias'] == [0, 0] and min(rep['coupledPercent']) > 90, rep+ widths = {e['width'] for e in json.loads((run / 'plan-route.json').read_text())['nets'] if e['net'].startswith('USB')}+ assert widths == {0.3689}, widths # 0.4 mm pads: the escape stubs leave at the solved width too+ r = call(run, 'analyze', 'impedance')+ assert r.returncode == 0 and 'impedance analysis passed' in r.stdout and 'signals.differentialPairs' in r.stdout, r.stdout+ o = json.loads((run / 'run.json').read_text())['outcomes']['analyze-impedance']+ assert o['pass'] and o['results'][0]['copperFrom'] == ['plan', 'plan']+ g = json.loads(call(run, 'finish-guard', '--json').stdout)+ assert not any('impedance' in x for x in g['remaining']), g['remaining']++ # a post between the nets: the router splits the pair, says so, and analyze fails it+ post = '(footprint "T:P" (layer "F.Cu") (at 20 6.05) (property "Reference" "TP1") (pad "1" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 0.8) (layers "*.Cu") (net 4 "X")))'+ b2 = p / 'split.kicad_pcb'+ board(b2, 6.05, post)+ run = p / 'run-split'+ assert call(run, 'start', '--board', str(b2), '--spec', str(p / 'spec.json'), '--engine', 'test', '--prompt-time', '2026-10-27T00:00:00Z').returncode == 0+ r = call(run, 'route')+ assert r.returncode == 0 and 'WARNING impedance USB' in r.stdout + r.stderr and 'take route=ai' in r.stdout, r.stdout+ r = call(run, 'analyze', 'impedance')+ assert r.returncode != 0 and 'not coupled' in r.stdout, r.stdout++ # adom-fields' own impedance map with an object value is refused with the right key named+ (p / 'bad.json').write_text(json.dumps({"impedance": {"USB": {"pair": ["USB_D+", "USB_D-"]}}}))+ r = call(p / 'none', 'impedance', 'table', '--board', str(b), '--spec', str(p / 'bad.json'))+ assert r.returncode != 0 and 'impedanceTargets' in r.stdout, r.stdout+ print('PASS: table per layer with refusals, route refuses an unreachable target, sizes and couples a pair on one layer, analyze impedance passes it and fails a split pair, Fields map kept numeric.')
tests/review-workflow.pyadded+87@@ -0,0 +1,87 @@+"""Issue #60 through the CLI: a declared mechanical review becomes a step before finish, finish refuses+it missing, a missing tool is refused with how to get it, a failing review records nothing, a passing+review (a fake adom-aiflow-mechanical on PATH speaking the prototype's contract) satisfies finish, a+different board voids it, and a spec that declares nothing is unchanged. No CAD, desktop or dashboard."""+import json, os, pathlib, subprocess, sys, tempfile+binary = str(pathlib.Path(sys.argv[1]).resolve())+HEAD = '(kicad_pcb (version 20240108) (net 0 "") (layers (0 "F.Cu" signal) (31 "B.Cu" signal)) (gr_circle (center 10 10) (end 19 10) (layer "Edge.Cuts")) (gr_circle (center 10 10) (end 14 10) (layer "F.Cu") (width 0.5))'+def hole(x):+ return f' (footprint "MountingHole:MountingHole_3.2mm_M3" (layer "F.Cu") (at {x} 10) (property "Reference" "H1") (pad "" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers "*.Cu" "*.Mask")))'+FAKE = '''#!/usr/bin/env python3+import json, sys+if len(sys.argv) != 3 or sys.argv[1] != "lint": sys.exit(2)+v = json.load(open(sys.argv[2]))+bad = [f for f in v.get("fasteners", []) if not f.get("tool")]+print(json.dumps({"schemaVersion": 1, "passed": not bad and bool(v.get("fasteners")), "findings": [{"severity": "error", "id": f.get("id", "?"), "check": "tool", "hint": "Specify the actual tool."} for f in bad], "animationRequired": False}))+sys.exit(1 if bad or not v.get("fasteners") else 0)+'''+with tempfile.TemporaryDirectory(prefix='aiflow-review-') as td:+ p = pathlib.Path(td)+ tools = p / 'tools'; tools.mkdir(); fake = tools / 'adom-aiflow-mechanical'; fake.write_text(FAKE); fake.chmod(0o755)+ base = {k: v for k, v in os.environ.items() if k not in ('AIFLOW_MECHANICAL_SRC', 'AIFLOW_WIRING_SRC')}+ bare = dict(base, AIFLOW_DASHBOARD='off', PATH='/usr/bin:/bin')+ with_tool = dict(bare, PATH=f'{tools}:/usr/bin:/bin')+ assert not any((pathlib.Path(d) / 'adom-aiflow-mechanical').exists() for d in ('/usr/bin', '/bin'))+ def call(run, *args, env=bare):+ return subprocess.run([binary, '--run', str(run), '--ai-thread', 'review-test', *args], env=env, capture_output=True, text=True, timeout=60)+ def attach(run, board, spec):+ state = run / 'run.json'; d = json.loads(state.read_text()); d['boards'] = [str(board)]; d['spec'] = str(spec); state.write_text(json.dumps(d))+ def data(run): return json.loads((run / 'run.json').read_text())+ board = p / 'plate.kicad_pcb'; board.write_text(HEAD + hole(15) + ')\n')+ spec = p / 'spec.json'; spec.write_text(json.dumps({'mechanicalReview': {'schemaVersion': 1, 'input': 'mech.json', 'refs': ['H1']}}))+ mech = p / 'mech.json'++ run = p / 'declared'+ assert call(run, 'intake', '--prompt-time', '2026-10-10T00:00:00Z', '--engine', 'test', '--kind', 'pattern').returncode == 0+ attach(run, board, spec)+ r = call(run, 'step', 'rules'); assert r.returncode == 0, (r.stdout, r.stderr)+ seq = data(run)['sequence']; assert seq.index('mechanical-review') == seq.index('finish') - 1 and 'wiring-review' not in seq, seq+ r = call(run, 'finish'); out = r.stdout + r.stderr+ assert r.returncode != 0 and 'mechanical review not recorded' in out, out++ # tool not installed: a clear refusal that says it is unreleased and names the repo+ mech.write_text(json.dumps({'schemaVersion': 1, 'fasteners': [{'id': 'H1-screw', 'ref': 'H1', 'tool': '2.5 mm hex'}]}))+ r = call(run, 'review', 'mechanical'); out = r.stdout + r.stderr+ assert r.returncode != 0 and 'not installed' in out and 'adom/adom-aiflow-mechanical' in out and 'unreleased' in out and 'AIFLOW_MECHANICAL_SRC' in out, out+ # a review file without the contract version is refused before the tool runs+ mech.write_text(json.dumps({'fasteners': [{'id': 'H1-screw', 'tool': 'x'}]}))+ r = call(run, 'review', 'mechanical', env=with_tool); assert r.returncode != 0 and 'schemaVersion' in r.stdout + r.stderr, r.stdout + r.stderr+ # a review naming a part the board does not have is stale+ mech.write_text(json.dumps({'schemaVersion': 1, 'fasteners': [{'id': 's', 'ref': 'H7', 'tool': 'x'}]}))+ r = call(run, 'review', 'mechanical', env=with_tool); assert r.returncode != 0 and 'H7' in r.stdout + r.stderr, r.stdout + r.stderr+ # a failing review records nothing+ mech.write_text(json.dumps({'schemaVersion': 1, 'fasteners': [{'id': 'H1-screw', 'ref': 'H1'}]}))+ r = call(run, 'review', 'mechanical', env=with_tool); out = r.stdout + r.stderr+ assert r.returncode != 0 and 'did not pass' in out and 'Specify the actual tool' in out, out+ assert 'mechanical-review' not in data(run)['outcomes']+ # a passing review, during its step, bound to the review file, spec and board+ mech.write_text(json.dumps({'schemaVersion': 1, 'fasteners': [{'id': 'H1-screw', 'ref': 'H1', 'tool': '2.5 mm hex'}]}))+ d = data(run); d['steps']['mechanical-review'] = {'visits': [{'at': '2026-10-10T00:00:01Z'}]}; d['currentStep'] = 'mechanical-review'; (run / 'run.json').write_text(json.dumps(d))+ r = call(run, 'review', 'mechanical', env=with_tool); assert r.returncode == 0, (r.stdout, r.stderr)+ o = data(run)['outcomes']['mechanical-review']+ assert o['pass'] and o['schemaVersion'] == 1 and o['boardSha256'] and o['inputSha256'] and o['specSha256'] and o['refs'] == ['H1'], o+ assert [f['ref'] for f in o['serviceFeatures']] == ['H1'] and o['tool']['mode'] == 'path', o+ r = call(run, 'finish'); out = r.stdout + r.stderr+ assert r.returncode != 0 and 'mechanical review' not in out and 'native DRC' in out, out+ # the review file edited afterwards: stale+ mech.write_text(json.dumps({'schemaVersion': 1, 'fasteners': [{'id': 'H1-screw', 'ref': 'H1', 'tool': '3 mm hex'}]}))+ r = call(run, 'finish'); assert 'review file changed' in r.stdout + r.stderr, r.stdout + r.stderr+ mech.write_text(json.dumps({'schemaVersion': 1, 'fasteners': [{'id': 'H1-screw', 'ref': 'H1', 'tool': '2.5 mm hex'}]}))+ r = call(run, 'finish'); assert 'mechanical review' not in r.stdout + r.stderr, r.stdout + r.stderr+ # a different board (the hole moved): the review no longer counts+ moved = p / 'moved.kicad_pcb'; moved.write_text(HEAD + hole(16) + ')\n')+ d = data(run); d['boards'].append(str(moved)); (run / 'run.json').write_text(json.dumps(d))+ r = call(run, 'finish'); out = r.stdout + r.stderr+ assert r.returncode != 0 and 'mechanical review is of another board' in out, out++ # not declared: no review step, finish says nothing about reviews+ plain = p / 'plain'; empty = p / 'empty.json'; empty.write_text('{}')+ assert call(plain, 'intake', '--prompt-time', '2026-10-10T00:00:00Z', '--engine', 'test', '--kind', 'pattern').returncode == 0+ attach(plain, board, empty)+ assert call(plain, 'step', 'rules').returncode == 0+ assert data(plain)['sequence'] == ['intake', 'rules', 'models', 'outline', 'silkscreen', '3d', 'finish', 'export'], data(plain)['sequence']+ r = call(plain, 'finish'); assert 'review' not in (r.stdout + r.stderr).lower(), r.stdout + r.stderr+ # an undeclared review can still be run (advisory), it says finish does not need it+ r = call(plain, 'review', 'mechanical', '--input', str(mech), env=with_tool); assert r.returncode == 0 and 'Not declared' in r.stdout + r.stderr, r.stdout + r.stderr+ assert data(plain)['sequence'] == ['intake', 'rules', 'models', 'outline', 'silkscreen', '3d', 'finish', 'export']+print('PASS: declared review is a step before finish, missing/failing/stale reviews and a missing tool are refused, a passing bound review satisfies finish, nothing changes when undeclared')
tests/route-variants-workflow.pyadded+87@@ -0,0 +1,87 @@+"""route --variants N and route --pick K --why on a small synthetic board (issue #55). A fake+kicad-cli answers DRC with no violations, so the gate column is exercised without CAD; no+dashboard, no bridge. Usage: python3 tests/route-variants-workflow.py <adom-aiflow binary>"""+import hashlib, json, os, pathlib, subprocess, sys, tempfile++binary = str(pathlib.Path(sys.argv[1]).resolve())+++def board_text():+ pads = [("J1", "A", 2, 2), ("J2", "A", 6, 2), ("J3", "B", 2, 8), ("J4", "B", 18, 8), ("J5", "C", 10, 2), ("J6", "C", 14, 6)]+ t = '(kicad_pcb\n\t(version 20260206)\n\t(generator "test")\n\t(layers\n\t\t(0 "F.Cu" signal)\n\t\t(2 "B.Cu" signal)\n\t\t(25 "Edge.Cuts" user)\n\t\t(31 "F.CrtYd" user)\n\t)\n'+ for r, net, x, y in pads:+ t += f'\t(footprint "T:{r}"\n\t\t(layer "F.Cu")\n\t\t(at {x} {y})\n\t\t(property "Reference" "{r}")\n\t\t(fp_rect\n\t\t\t(start -0.9 -0.9)\n\t\t\t(end 0.9 0.9)\n\t\t\t(stroke\n\t\t\t\t(width 0.05)\n\t\t\t\t(type solid)\n\t\t\t)\n\t\t\t(fill no)\n\t\t\t(layer "F.CrtYd")\n\t\t)\n\t\t(pad "1" thru_hole circle\n\t\t\t(at 0 0)\n\t\t\t(size 1.2 1.2)\n\t\t\t(drill 0.6)\n\t\t\t(layers "*.Cu")\n\t\t\t(net "{net}")\n\t\t)\n\t)\n'+ t += '\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 20 10)\n\t\t(layer "Edge.Cuts")\n\t)\n)\n'+ return t+++with tempfile.TemporaryDirectory(prefix='aiflow-variants-') as td:+ p = pathlib.Path(td)+ (p / 'b.kicad_pcb').write_text(board_text())+ (p / 'spec.json').write_text(json.dumps({"board": "variants fixture", "clearance": 0.2}))+ fake = p / 'kicad-cli'+ fake.write_text('#!/usr/bin/env python3\nimport sys, json\na = sys.argv[1:]\n'+ 'if "--help" in a: print("--refill-zones"); sys.exit(0)\n'+ 'out = a[a.index("--output") + 1]\njson.dump({"violations": [], "unconnected_items": []}, open(out, "w"))\n')+ fake.chmod(0o755)+ run = p / 'run'+ env = dict(os.environ, AIFLOW_DASHBOARD='off', ADOM_AIFLOW_KICAD_CLI=str(fake))+ env.pop('KICAD_REMOTE_TARGET', None)++ def call(*args):+ return subprocess.run([binary, '--run', str(run), '--ai-thread', 'variants-test', *args], env=env, capture_output=True, text=True, timeout=120)++ def sha(f):+ return hashlib.sha256(pathlib.Path(f).read_bytes()).hexdigest()++ r = call('start', '--board', str(p / 'b.kicad_pcb'), '--spec', str(p / 'spec.json'), '--engine', 'test')+ assert r.returncode == 0, r.stderr+ # a plain route: no variants on the outcome+ r = call('route'); assert r.returncode == 0, r.stdout + r.stderr+ plain = sha(run / 'plan-route.json')+ o = json.loads((run / 'run.json').read_text())['outcomes']['route']+ assert 'variants' not in o, o+ # --pick before any variants is refused+ assert call('route', '--pick', '1', '--why', 'x').returncode != 0+ # three variants: files, scores, a pick by the gates, the table+ r = call('route', '--variants', '3'); assert r.returncode == 0, r.stdout + r.stderr+ if os.environ.get('SHOW'): print(r.stderr)+ assert 'chosen: v' in r.stderr and 'levers' in r.stderr, r.stderr+ o = json.loads((run / 'run.json').read_text())['outcomes']['route']+ v = o['variants']+ assert v['count'] == 3 and v['chosenBy'] == 'the gates' and v['drc'].startswith('kicad-cli'), v+ for k, x in enumerate(v['variants'], 1):+ assert x['variant'] == k and (run / f'plan-route-v{k}.json').is_file() and (run / f'plan-route-v{k}-report.json').is_file()+ assert x['gate'] is not None and x['gate']['issues'] == 0, x+ assert x['levers']['order'] and x['copperLengthMm'] > 0 and x['rank'] >= 1+ assert sorted(x['rank'] for x in v['variants']) == [1, 2, 3]+ win = v['chosen']+ assert [x for x in v['variants'] if x['rank'] == 1][0]['variant'] == win+ assert sha(run / 'plan-route.json') == sha(run / f'plan-route-v{win}.json')+ # variant 1 is a plain route: the same plan+ assert sha(run / 'plan-route-v1.json') == plain+ # deterministic: the same variants again give the same plans+ before = [sha(run / f'plan-route-v{k}.json') for k in (1, 2, 3)]+ assert call('route', '--variants', '3').returncode == 0+ assert before == [sha(run / f'plan-route-v{k}.json') for k in (1, 2, 3)]+ # the ledger holds every variant+ led = [json.loads(l) for l in (run / 'run.jsonl').read_text().splitlines()]+ assert any(e.get('event') == 'route-variants' and len(e['variants']) == 3 for e in led)+ # --pick needs --why, and records it+ other = 1 if win != 1 else 2+ assert call('route', '--pick', str(other)).returncode != 0+ assert call('route', '--pick', '9', '--why', 'none such').returncode != 0+ r = call('route', '--pick', str(other), '--why', 'the shorter loop'); assert r.returncode == 0, r.stdout + r.stderr+ o = json.loads((run / 'run.json').read_text())['outcomes']['route']+ assert o['variants']['chosen'] == other and o['variants']['chosenBy'] == 'the AI' and o['variants']['why'] == 'the shorter loop'+ assert o['variants']['gatePick'] == win+ assert sha(run / 'plan-route.json') == sha(run / f'plan-route-v{other}.json')+ led = [json.loads(l) for l in (run / 'run.jsonl').read_text().splitlines()]+ assert any(e.get('event') == 'route-pick' and e['why'] == 'the shorter loop' for e in led)+ # the gate takes the picked plan as usual+ r = call('gate'); assert r.returncode == 0, r.stdout + r.stderr+ # a plain route again: the baseline plan, no variants+ assert call('route').returncode == 0+ assert sha(run / 'plan-route.json') == plain+ assert 'variants' not in json.loads((run / 'run.json').read_text())['outcomes']['route']+ print('PASS: route --variants writes, gates and ranks every variant, the winner is the route plan, v1 equals a plain route, reruns are identical, --pick needs --why and is recorded, plain route unchanged')
tests/sourcing-suggestions.py+80−24@@ -1,70 +1,126 @@ """`sourcing check` through the CLI: any distributor passes on any profile as consigned parts, with one-warning and a suggestion per row from adom-parts-search. A fake adom-parts-search on PATH returns fixed-JSON and logs each call, so the test never spends the shared vendor quota; it proves one search per-distinct MPN, the cache in the run folder (a rerun searches nothing), and the AIFLOW_NO_PART_SEARCH=1-skip. No CAD, desktop or dashboard."""-import json, os, pathlib, subprocess, sys, tempfile+warning and a suggestion per row. The lookup order per distinct MPN (issue #111): the wiki component+page first, then the pnp-inventory vendor stock cache, and a live adom-parts-search only for what those+did not answer. A fake adom-wiki (two component pages) and a fake adom-parts-search on PATH return+fixed JSON and log each call, so the test never touches the wiki or spends the shared vendor quota. It+proves: a page with the number and a dated stock count answers with no live search; a page without the+preferred source's number falls back to the live search; a number with no stock is "stock not+recorded"; both caches in the run folder (a rerun reads and searches nothing); AIFLOW_NO_PART_SEARCH=1+(no live search, pages still read) and AIFLOW_NO_PART_LOOKUP=1 (nothing). pnp-inventory is not on PATH,+so that step is skipped quietly. No CAD, desktop or dashboard."""+import json, os, pathlib, subprocess, sys, tempfile, datetime binary = str(pathlib.Path(sys.argv[1]).resolve()) -FAKE = r'''#!/usr/bin/env python3+SEARCH = r'''#!/usr/bin/env python3 import json, os, sys open(os.environ["FAKE_LOG"], "a").write(" ".join(sys.argv[1:]) + "\n") mpn = sys.argv[2]-mouser = {"TPS54202DDCR": [{"mpn": "TPS54202DDCR", "vendor_pn": "595-TPS54202DDCR", "stock": 1200}]}.get(mpn, [])+mouser = {"TPS54202DDCR": [{"mpn": "TPS54202DDCR", "vendor_pn": "595-TPS54202DDCR", "stock": 1200}],+ "AP2112K-3.3TRG1": [{"mpn": "AP2112K-3.3TRG1", "vendor_pn": "621-AP2112K-3.3TRG1", "stock": 5000}]}.get(mpn, []) jlc = {"TPS54202DDCR": [{"mpn": "TPS54202DDCR", "vendor_pn": "C191884", "stock": 299988, "flags": ["extended"]}]}.get(mpn, []) print(json.dumps({"query": mpn, "mouser": {"ok": True, "hits": mouser}, "digikey": {"ok": True, "hits": []}, "jlcpcb": {"ok": True, "hits": jlc}})) ''' +# page.json the way real component pages store distributor numbers: a string, {part, url, stock, checked}, or false+WIKI = r'''#!/usr/bin/env python3+import json, os, sys+open(os.environ["FAKE_WIKI_LOG"], "a").write(" ".join(sys.argv[1:]) + "\n")+pages = {+ "adom/tps54202ddcr": {"type": "component", "component": {"mpn": "TPS54202DDCR", "distributor_links": {+ "mouser": {"part": "595-TPS54202DDCR", "url": "https://www.mouser.com/x", "stock": 160, "checked": "2026-10-20"},+ "digikey": "296-TPS54202DDCRTR-ND", "lcsc": False}}},+ "adom/ap2112k-3-3trg1": {"type": "component", "component": {"mpn": "AP2112K-3.3TRG1", "distributor_links": {+ "mouser": "621-AP2112K-3.3TRG1",+ "lcsc": {"part": "C51118", "url": "https://www.lcsc.com/x", "stock": 12535, "checked": "2026-10-19", "tier": "basic"}}}},+}+if sys.argv[1:3] == ["repo", "show"] and sys.argv[4:] == ["page.json"] and sys.argv[3] in pages:+ print(json.dumps(pages[sys.argv[3]])); sys.exit(0)+print(json.dumps({"http_status": 404, "response": {"error": "Page not found", "code": "PAGE_NOT_FOUND"}}))+print("error [PAGE_NOT_FOUND]: no such page", file=sys.stderr); sys.exit(1)+'''++today = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')+ with tempfile.TemporaryDirectory(prefix='aiflow-sourcing-') as td: p = pathlib.Path(td) fakebin = p / 'bin'; fakebin.mkdir()- fake = fakebin / 'adom-parts-search'; fake.write_text(FAKE); fake.chmod(0o755)- log = p / 'searches.log'- env = dict(os.environ, AIFLOW_DASHBOARD='off', PATH=f"{fakebin}{os.pathsep}{os.environ.get('PATH', '')}", FAKE_LOG=str(log))- env.pop('AIFLOW_NO_PART_SEARCH', None)+ for name, body in (('adom-parts-search', SEARCH), ('adom-wiki', WIKI)):+ f = fakebin / name; f.write_text(body); f.chmod(0o755)+ log, wlog = p / 'searches.log', p / 'wiki.log'+ # PATH: the fakes, then only what python3 needs (no real adom-wiki or pnp-inventory)+ py = pathlib.Path(sys.executable).parent+ env = dict(os.environ, AIFLOW_DASHBOARD='off', PATH=f"{fakebin}{os.pathsep}{py}{os.pathsep}/usr/bin{os.pathsep}/bin", FAKE_LOG=str(log), FAKE_WIKI_LOG=str(wlog))+ env.pop('AIFLOW_NO_PART_SEARCH', None); env.pop('AIFLOW_NO_PART_LOOKUP', None) def call(run, *args, extra=None): return subprocess.run([binary, '--run', str(run), '--ai-thread', 'sourcing-test', *args], env=dict(env, **(extra or {})), capture_output=True, text=True, timeout=120) def outcome(run): return json.loads((run / 'run.json').read_text())['outcomes']['sourcing'] def searches(): return log.read_text().splitlines() if log.exists() else []+ def reads(): return wlog.read_text().splitlines() if wlog.exists() else [] bom = p / 'bom.csv' bom.write_text('ref,value,mpn,manufacturer,source,vendor_pn,package,stock_checked_2026-10-27\n' 'U1,buck,TPS54202DDCR,TI,DigiKey,296-TPS54202DDCRTR-ND,SOT-23-6,900\n' 'U2,buck,TPS54202DDCR,TI,DigiKey,296-TPS54202DDCRTR-ND,SOT-23-6,900\n' 'U3,op amp,ABC123,Acme,Arrow,ABC123,SOIC-8,800\n'+ 'U4,LDO,AP2112K-3.3TRG1,Diodes,DigiKey,AP2112K-3.3TRG1DITR-ND,SOT-23-5,700\n' 'R1,10k,CR0402-FX-1002GLF,Bourns,Adom stock,,0402,5000\n') run = p / 'run' r = call(run, 'intake', '--prompt-time', '2026-10-27T00:00:00Z', '--engine', 'test'); assert r.returncode == 0, r.stdout + r.stderr - # the opt-out: no search, one note, still a pass+ # AIFLOW_NO_PART_LOOKUP=1: no page read, no search, one note, still a pass+ r = call(run, 'sourcing', 'check', '--bom', str(bom), '--qty', '1', extra={'AIFLOW_NO_PART_LOOKUP': '1'})+ assert r.returncode == 0, r.stdout + r.stderr+ assert 'part suggestions skipped (AIFLOW_NO_PART_LOOKUP=1)' in r.stdout and not searches() and not reads(), r.stdout+ o = outcome(run); assert o['consignedRows']['count'] == 4 and o['suggestions'] == [], o++ # AIFLOW_NO_PART_SEARCH=1: the wiki pages still answer, no live search r = call(run, 'sourcing', 'check', '--bom', str(bom), '--qty', '1', extra={'AIFLOW_NO_PART_SEARCH': '1'}) assert r.returncode == 0, r.stdout + r.stderr- assert 'part suggestions skipped (AIFLOW_NO_PART_SEARCH=1)' in r.stdout and not searches(), r.stdout- o = outcome(run); assert o['consignedRows']['count'] == 3 and o['suggestions'] == [], o+ assert not searches(), searches()+ # one read per distinct MPN: AP2112K-3.3TRG1 tries ap2112k-3.3trg1 first, then the wiki slug form+ assert reads() == ['repo show adom/tps54202ddcr page.json', 'repo show adom/abc123 page.json',+ 'repo show adom/ap2112k-3.3trg1 page.json', 'repo show adom/ap2112k-3-3trg1 page.json'], reads()+ assert 'suggestion: U1 (TPS54202DDCR, DigiKey): Mouser has it, 595-TPS54202DDCR, 160 in stock (wiki page adom/tps54202ddcr, checked 2026-10-20); sourcing it there saves a consignment shipment' in r.stdout, r.stdout+ assert 'suggestion: U2 (TPS54202DDCR, DigiKey): Mouser has it, 595-TPS54202DDCR, 160 in stock (wiki page adom/tps54202ddcr' in r.stdout, r.stdout+ assert 'suggestion: U4 (AP2112K-3.3TRG1, DigiKey): Mouser has it, 621-AP2112K-3.3TRG1 (wiki page adom/ap2112k-3-3trg1), stock not recorded: `adom-parts-search show "AP2112K-3.3TRG1"` checks it live; sourcing it there saves a consignment shipment' in r.stdout, r.stdout+ assert 'live part searches skipped (AIFLOW_NO_PART_SEARCH=1) for 2 MPN(s) the wiki pages and the pnp-inventory cache did not fully answer: ABC123, AP2112K-3.3TRG1' in r.stdout, r.stdout+ assert 'suggestion: U3' not in r.stdout, r.stdout # nothing known, nothing claimed+ o = outcome(run)+ assert o['suggestionLookups']['wikiReads'] == 3 and o['suggestionLookups']['wikiHits'] == 2 and o['suggestionLookups']['liveSearches'] == 0, o['suggestionLookups']+ assert (run / 'sourcing-lookup-cache.json').is_file() - # fab: DigiKey and Arrow pass with one consigned warning; one search per distinct MPN+ # fab with live search: the page answers TPS54202DDCR, so only the rest is searched r = call(run, 'sourcing', 'check', '--bom', str(bom), '--qty', '1') assert r.returncode == 0, r.stdout + r.stderr+ assert len(reads()) == 4, reads() # the page answers came from the run's cache assert sum('warning:' in l and 'consigned parts' in l for l in r.stdout.splitlines()) == 1, r.stdout- assert searches() == ['search TPS54202DDCR', 'search ABC123'], searches()- assert 'suggestion: U1 (TPS54202DDCR): Mouser has TPS54202DDCR (595-TPS54202DDCR, 1200 in stock): set the source to Mouser to skip a consignment shipment' in r.stdout, r.stdout- assert 'suggestion: U3 (ABC123): no exact match at Mouser; `adom-parts-search show "op amp SOIC-8"` to look for an equivalent, or send this part as consigned' in r.stdout, r.stdout+ assert searches() == ['search ABC123', 'search AP2112K-3.3TRG1'], searches()+ assert 'suggestion: U1 (TPS54202DDCR, DigiKey): Mouser has it, 595-TPS54202DDCR, 160 in stock (wiki page adom/tps54202ddcr, checked 2026-10-20)' in r.stdout, r.stdout+ assert f'suggestion: U4 (AP2112K-3.3TRG1, DigiKey): Mouser has it, 621-AP2112K-3.3TRG1, 5000 in stock (live search, checked {today}); sourcing it there saves a consignment shipment' in r.stdout, r.stdout+ assert 'suggestion: U3 (ABC123, Arrow): no exact match at Mouser (live search); `adom-parts-search show "op amp SOIC-8"` to look for an equivalent, or send this part as consigned' in r.stdout, r.stdout lines = r.stdout.splitlines() w = next(i for i, l in enumerate(lines) if 'warning:' in l and 'consigned parts' in l) assert 'suggestion:' in lines[w + 1], lines[w:w + 3] o = outcome(run)- assert o['pass'] and o['consignedRows']['count'] == 3 and len(o['suggestions']) == 3, o+ assert o['pass'] and o['consignedRows']['count'] == 4 and len(o['suggestions']) == 4, o+ assert o['suggestionLookups']['wikiFromCache'] == 3 and o['suggestionLookups']['liveSearches'] == 2, o['suggestionLookups']+ assert {s['refs']: s['from'] for s in o['suggestions']} == {'U1': 'wiki', 'U2': 'wiki', 'U3': 'search', 'U4': 'search'}, o['suggestions'] assert (run / 'sourcing-search-cache.json').is_file() - # a rerun reads the cache: no new searches; jlcpcb from the same cache+ # a rerun reads both caches: no page read, no search r = call(run, 'sourcing', 'check', '--bom', str(bom), '--qty', '1')- assert r.returncode == 0 and len(searches()) == 2, searches()+ assert r.returncode == 0 and len(searches()) == 2 and len(reads()) == 4, (searches(), reads())++ # jlcpcb: the TPS54202DDCR page has no LCSC number (false), so it falls back to the live search;+ # the AP2112K page has its LCSC number and dated stock, so no search for it r = call(run, 'sourcing', 'check', '--bom', str(bom), '--qty', '1', '--profile', 'jlcpcb') assert r.returncode == 0, r.stdout + r.stderr- assert searches()[2:] == ['search CR0402-FX-1002GLF'], searches() # only the MPN not in the cache yet+ assert searches()[2:] == ['search TPS54202DDCR', 'search CR0402-FX-1002GLF'], searches()+ assert reads()[4:] == ['repo show adom/cr0402-fx-1002glf page.json'], reads() assert 'are not JLCPCB/LCSC parts' in r.stdout, r.stdout- assert 'LCSC has TPS54202DDCR (C191884, extended, 299988 in stock): add it to the lcsc column' in r.stdout, r.stdout- assert outcome(run)['consignedRows']['count'] == 4 # the Adom stock row is consigned on jlcpcb+ assert f'U1 (TPS54202DDCR, DigiKey): LCSC has it, C191884 (extended), 299988 in stock (live search, checked {today}); add it to the lcsc column to save a consignment shipment' in r.stdout, r.stdout+ assert 'U4 (AP2112K-3.3TRG1, DigiKey): LCSC has it, C51118 (basic), 12535 in stock (wiki page adom/ap2112k-3-3trg1, checked 2026-10-19); add it to the lcsc column to save a consignment shipment' in r.stdout, r.stdout+ assert outcome(run)['consignedRows']['count'] == 5 # the Adom stock row is consigned on jlcpcb # a real problem still fails: no MPN bad = p / 'bad.csv'; bad.write_text('ref,mpn,source,stock_checked_2026-10-27\nU1,,DigiKey,10\n')
tools/impedance_reference.pyadded+171@@ -0,0 +1,171 @@+#!/usr/bin/env python3+"""Independent reference values for crates/aiflow-router/src/impedance.rs.++A 2-D finite-volume Laplace solve of a trace cross-section on a graded rectilinear grid+(numpy + scipy). Conductors are fixed-potential node sets, the ground planes are rows at 0 V,+the open sides and top of a microstrip are a 0 V box about 25 dielectric heights away. The+capacitance per metre comes from the field energy, once with the dielectric and once in air:++ Z0 = 1 / (c * sqrt(C * C_air)), eeff = C / C_air++Pairs are solved in odd mode (+1 V / -1 V) and even mode (+1 V / +1 V); Zdiff = 2 * Zodd.+No solder mask, no loss, no dispersion: the same quasi-static problem the closed forms solve.++ python3 tools/impedance_reference.py # prints the table used by the Rust tests++Each case is solved on two grids (finest cell f and f/2) and extrapolated to zero cell size+(Richardson, first order: the strip corners dominate the grid error); the two grids differ by+under 0.5 %.+"""+import math+import sys++import numpy as np+import scipy.sparse as sp+import scipy.sparse.linalg as spla++EPS0 = 8.8541878128e-12+C0 = 299792458.0+++def graded(points, fine, coarse, growth=1.15):+ """Node coordinates through every point in `points` (sorted), at most `fine` apart near+ them and growing by `growth` up to `coarse` away from them."""+ pts = sorted(set(points))+ out = [pts[0]]+ for a, b in zip(pts, pts[1:]):+ span = b - a+ if span <= fine * 1.0001:+ out.append(b)+ continue+ # grow from both ends toward the middle+ left, right = [a], [b]+ step = fine+ while True:+ if left[-1] + step >= right[-1] - step:+ break+ left.append(left[-1] + step)+ right.append(right[-1] - step)+ step = min(step * growth, coarse)+ mid = left + right[::-1]+ out.extend(mid[1:])+ return np.array(sorted(set(round(x, 9) for x in out)))+++def solve(xs, ys, eps_cell, fixed):+ """Node potentials and energy. eps_cell[j, i] is the relative permittivity of the cell+ between nodes (i, j) and (i+1, j+1); fixed maps node index -> potential."""+ nx, ny = len(xs), len(ys)+ dx, dy = np.diff(xs), np.diff(ys)+ idx = lambda i, j: j * nx + i+ rows, cols, vals = [], [], []+ edges = [] # (a, b, coefficient)+ for j in range(ny):+ for i in range(nx):+ # east edge+ if i + 1 < nx:+ c = 0.0+ if j - 1 >= 0:+ c += eps_cell[j - 1, i] * dy[j - 1] / 2+ if j < ny - 1:+ c += eps_cell[j, i] * dy[j] / 2+ edges.append((idx(i, j), idx(i + 1, j), c / dx[i]))+ if j + 1 < ny:+ c = 0.0+ if i - 1 >= 0:+ c += eps_cell[j, i - 1] * dx[i - 1] / 2+ if i < nx - 1:+ c += eps_cell[j, i] * dx[i] / 2+ edges.append((idx(i, j), idx(i, j + 1), c / dy[j]))+ n = nx * ny+ e = np.array(edges)+ a, b, c = e[:, 0].astype(int), e[:, 1].astype(int), e[:, 2]+ diag = np.zeros(n)+ np.add.at(diag, a, c)+ np.add.at(diag, b, c)+ K = sp.coo_matrix((np.concatenate([-c, -c, diag]), (np.concatenate([a, b, np.arange(n)]), np.concatenate([b, a, np.arange(n)]))), shape=(n, n)).tocsr()+ phi = np.zeros(n)+ is_fixed = np.zeros(n, bool)+ for k, v in fixed.items():+ phi[k] = v+ is_fixed[k] = True+ free = ~is_fixed+ rhs = -K[free][:, is_fixed] @ phi[is_fixed]+ phi[free] = spla.spsolve(K[free][:, free].tocsc(), rhs)+ energy = 0.5 * EPS0 * np.sum(c * (phi[a] - phi[b]) ** 2)+ return energy+++def section(w, t, h_below, h_above=None, er=4.3, gap=None, mode="single", fine=None):+ """Capacitance per metre of a strip (or pair) of width w, thickness t (mm) at h_below over a+ ground plane; a second plane h_above over the top of the strip makes it a stripline."""+ mm = 1e-3+ fine = fine or min(t / 4, w / 20, h_below / 20, (gap / 8) if gap else 1)+ strips = [(-w / 2, w / 2)] if gap is None else [(-gap / 2 - w, -gap / 2), (gap / 2, gap / 2 + w)]+ span = (w if gap is None else 2 * w + gap)+ hb = h_below+ top = (hb + t + h_above) if h_above is not None else hb + t + 25 * hb + 5 * span+ side = span / 2 + (8 * (hb + (h_above or 0)) + 3 * span if h_above is not None else 25 * hb + 5 * span)+ xk = [-side, side] + [x for s in strips for x in s] + [0.0]+ yk = [0.0, hb, hb + t, top]+ coarse = max(span, hb) / 2+ xs = graded(xk, fine, coarse)+ ys = graded(yk, fine, coarse)+ nx, ny = len(xs), len(ys)+ def energy(er_d, potentials):+ eps = np.ones((ny - 1, nx - 1))+ ymid = (ys[:-1] + ys[1:]) / 2+ if h_above is None:+ eps[ymid < hb, :] = er_d+ else:+ eps[:, :] = er_d+ fixed = {}+ for i in range(nx):+ fixed[i] = 0.0 # bottom plane+ fixed[(ny - 1) * nx + i] = 0.0 # top plane or box+ for j in range(ny):+ fixed[j * nx] = 0.0+ fixed[j * nx + nx - 1] = 0.0+ for (x0, x1), v in zip(strips, potentials):+ for j in range(ny):+ if hb - 1e-12 <= ys[j] <= hb + t + 1e-12:+ for i in range(nx):+ if x0 - 1e-12 <= xs[i] <= x1 + 1e-12:+ fixed[j * nx + i] = v+ return solve(xs * mm, ys * mm, eps, fixed)+ pots = {"single": [1.0], "odd": [1.0, -1.0], "even": [1.0, 1.0]}[mode]+ e_d, e_a = energy(er, pots), energy(1.0, pots)+ # per-line capacitance: single C = 2E; odd and even (two lines at +-1 V) C_line = E+ k = 2.0 if mode == "single" else 1.0+ cd, ca = k * e_d, k * e_a+ return 1 / (C0 * math.sqrt(cd * ca)), cd / ca+++CASES = [+ # name, kwargs+ ("microstrip 1.6 mm FR4 er4.5 w2.95 t35", dict(w=2.95, t=0.035, h_below=1.6, er=4.5)),+ ("microstrip JLC7628 h0.2104 er4.4 w0.35 t35", dict(w=0.35, t=0.035, h_below=0.2104, er=4.4)),+ ("microstrip h0.2 er4.2 w0.3 t35", dict(w=0.30, t=0.035, h_below=0.2, er=4.2)),+ ("pair microstrip JLC7628 w0.2 s0.2 odd", dict(w=0.2, t=0.035, h_below=0.2104, er=4.4, gap=0.2, mode="odd")),+ ("pair microstrip JLC7628 w0.25 s0.15 odd", dict(w=0.25, t=0.035, h_below=0.2104, er=4.4, gap=0.15, mode="odd")),+ ("pair microstrip 1.6 mm er4.5 w1.0 s0.5 odd", dict(w=1.0, t=0.035, h_below=1.6, er=4.5, gap=0.5, mode="odd")),+ ("pair microstrip 1.6 mm er4.5 w1.0 s0.2 odd (s/h 0.125: refused)", dict(w=1.0, t=0.035, h_below=1.6, er=4.5, gap=0.2, mode="odd")),+ ("stripline sym b0.5 er4.3 w0.2 t35", dict(w=0.2, t=0.035, h_below=0.2325, h_above=0.2325, er=4.3)),+ ("stripline offset 0.2/0.4 er4.3 w0.15 t35", dict(w=0.15, t=0.035, h_below=0.2, h_above=0.4, er=4.3)),+ ("stripline offset 0.2/0.6 er4.3 w0.15 t35", dict(w=0.15, t=0.035, h_below=0.2, h_above=0.6, er=4.3)),+ ("stripline offset 0.2/1.03 er4.3 w0.15 t35 (5:1: refused)", dict(w=0.15, t=0.035, h_below=0.2, h_above=1.03, er=4.3)),+ ("pair stripline sym b0.5 er4.3 w0.15 s0.15 odd", dict(w=0.15, t=0.035, h_below=0.2325, h_above=0.2325, er=4.3, gap=0.15, mode="odd")),+]++if __name__ == "__main__":+ only = sys.argv[1:]+ for name, kw in CASES:+ if only and not any(o in name for o in only):+ continue+ # first-order grid error (the strip corners): extrapolate from two grids (Richardson)+ f = min(kw["t"] / 4, kw["w"] / 20, kw["h_below"] / 20, (kw["gap"] / 8) if kw.get("gap") else 1)+ z1, e1 = section(**kw, fine=f)+ z2, e2 = section(**kw, fine=f / 2)+ z, ee = 2 * z2 - z1, 2 * e2 - e1+ extra = f" Zdiff {2 * z:.2f}" if kw.get("mode") == "odd" else ""+ print(f"{name:52s} Z {z:7.2f} ohm eeff {ee:5.3f}{extra}", flush=True)