Files Branches

Comparing main ← jlcpcb-order

Changes on jlcpcb-order that are not yet on main (three-dot, from the merge base).

12 files changed, 750 insertions(+), 19 deletions(-)
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.50"+version = "0.1.51" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.50"+version = "0.1.51" dependencies = [  "serde",  "serde_json",@@ -33,7 +33,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.50"+version = "0.1.51" dependencies = [  "roxmltree",  "serde",@@ -42,7 +42,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.50"+version = "0.1.51" dependencies = [  "aiflow-board",  "serde",@@ -51,7 +51,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.50"+version = "0.1.51" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -61,7 +61,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.50"+version = "0.1.51" dependencies = [  "aiflow-board",  "serde",@@ -70,7 +70,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.50"+version = "0.1.51" dependencies = [  "aiflow-board",  "serde",@@ -79,7 +79,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.50"+version = "0.1.51" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -89,7 +89,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.50"+version = "0.1.51" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -99,7 +99,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.50"+version = "0.1.51" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.50"+version = "0.1.51" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
crates/adom-aiflow/src/main.rs+91−6
@@ -77,7 +77,16 @@ enum Cmd {     /// `sourcing check --bom <csv>`: every electrical board reference in the BOM with an MPN, sources that fit the profile (fab: Mouser or Adom stock, no LCSC/JLCPCB parts; jlcpcb: LCSC numbers), a dated stock check, thin stock warned. finish requires a pass when the spec or requirements name a profile.     Sourcing { what: String, #[arg(long)] bom: String, #[arg(long, value_parser = ["fab", "jlcpcb"])] profile: Option<String>, /// boards to build (default: requirements build.quantity, else 10)     #[arg(long)] qty: Option<u64>, /// the board to diff against (default: the run's current board)-    #[arg(long)] board: Option<String>, #[arg(long)] requirements: Option<String> },+    #[arg(long)] board: Option<String>, #[arg(long)] requirements: Option<String>,+    /// `sourcing swap`: the reference whose part is being substituted+    #[arg(long = "ref")] reference: Option<String>, /// `sourcing swap`: the facts JSON (both datasheets read, with sources; see tools/sourcing-swap.py)+    #[arg(long)] facts: Option<String>, /// `sourcing swap`: rewrite the BOM rows when every check passes+    #[arg(long)] apply: bool },+    /// `fab export --bom <csv> --out <dir> [--profile jlcpcb] [--target <desktop>] [--rotations rot.json]`: the manufacturing package a fab's upload page takes (gerber zip with paste layers and drill, the fab's BOM and CPL, a manifest with sha256), from the run's board through the desktop's native kicad-cli; parts with fit=post stay out of the BOM and CPL. On the jlcpcb profile every placed part's rotation is then checked against JLCPCB's own footprint (pad-by-pad fit) and the CPL corrected; --rotations is the hand-set fallback for parts JLCPCB publishes no footprint for. `fab rotation-publish --record <out>/<board>-jlc-rotation.json --ref U1 --page <owner/slug> [--screenshot preview.png] [--boards N --order <id>] [--dry-run]`: merge that part's correction and evidence into jlcpcb-rotation.json on its wiki component page.+    Fab { what: String, #[arg(long)] bom: Option<String>, #[arg(long)] out: Option<String>, #[arg(long, default_value = "jlcpcb")] profile: String, #[arg(long)] target: Option<String>, #[arg(long)] rotations: Option<String>, #[arg(long)] board: Option<String>,+          /// skip the JLCPCB rotation check (not recommended: a part turned 90 degrees is the most common assembly failure)+          #[arg(long)] no_rotation_check: bool,+          #[arg(long)] record: Option<String>, #[arg(long = "ref")] reference: Option<String>, #[arg(long)] page: Option<String>, #[arg(long)] screenshot: Option<String>, #[arg(long)] boards: Option<u32>, #[arg(long)] order: Option<String>, #[arg(long)] dry_run: bool },     /// `evidence add --kind calcs|simulation|vendor-model --file <json|md>`: the design record in the run and the ledger, with its sha256; a failing simulation is refused. finish requires the kinds the spec lists in requireEvidence.     Evidence { what: String, #[arg(long, value_parser = ["calcs", "simulation", "vendor-model"])] kind: String, #[arg(long)] file: String, #[arg(long)] note: Option<String> },     /// The live clips page: rebuild docs/clips.md (every clip: step, visit, time, the 10x cut, the action cut, the contact sheet, the suspect state, the note from clip-notes.json) and with --push put it and the clip media on the page. Once a page is set, every clip stop does this by itself.@@ -115,7 +124,8 @@ enum Cmd {     /// The finish line; refuses until every check passes     Finish,     /// 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.-    Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },+    Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String>, /// film this window (hwnd on the target box) for this step and the next ones, instead of the PCB editor: a pup window driving a vendor site, a datasheet, an ordering page+    #[arg(long)] hwnd: Option<i64> },     /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.     Deliver { #[arg(long)] video: String, #[arg(long)] message: String, /// deliver even though a clip is flagged as having filmed nothing (say why in --message)     #[arg(long)] accept_suspect: bool },@@ -2085,10 +2095,85 @@ fn main() {                 if r.data["page"].is_null() { "Name the run's page once (`clips --page <owner/slug>` or `report --page ...`) so every clip lands on docs/clips.md as it stops.".into() } else { String::new() },             ]);         }-        Cmd::Sourcing { what, bom, profile, qty, board, requirements } => {+        Cmd::Fab { what, bom, out, profile, target, rotations, board, no_rotation_check, record, reference, page, screenshot, boards, order, dry_run } => {+            thread(&cli);+            let rot_py = include_str!("../../../tools/jlc-rotation.py");+            if what == "rotation-publish" {+                let (Some(rec), Some(rf), Some(pg)) = (record, reference, page) else { err("fab rotation-publish: needs --record <jlc-rotation.json> --ref <REF> --page <owner/slug>", &["The record is the <board>-jlc-rotation.json fab export wrote beside the CPL; the page is the part's wiki component page (yours, or one you were asked to update).".into()]) };+                let mut c = std::process::Command::new(aiflow_python());+                c.arg("-c").arg(rot_py).arg("publish").arg("--record").arg(rec).arg("--ref").arg(rf).arg("--page").arg(pg);+                if let Some(x) = screenshot { c.arg("--screenshot").arg(x); }+                if let Some(n) = boards { c.arg("--boards").arg(n.to_string()); }+                if let Some(o) = order { c.arg("--order").arg(o); }+                if *dry_run { c.arg("--dry-run"); }+                let o = c.output().unwrap_or_else(|e| err(&format!("fab rotation-publish needs python3: {e}"), &[]));+                print!("{}", String::from_utf8_lossy(&o.stdout)); eprint!("{}", String::from_utf8_lossy(&o.stderr));+                if !o.status.success() { err("fab rotation-publish failed (see above)", &[]); }+                if !*dry_run { if let Some(mut r) = Run::open(&run_dir(&cli)) { r.log("jlc-rotation-publish", json!({"ref": rf, "page": pg, "screenshot": screenshot, "boards": boards, "at": now()})); r.save().unwrap(); } }+                return;+            }+            let mut r = load_run(&cli);+            if what != "export" { err(&format!("fab {what}: use `fab export --bom <csv> --out <dir>` or `fab rotation-publish`"), &[]); }+            let (Some(bom), Some(out)) = (bom, out) else { err("fab export: needs --bom <csv> --out <dir>", &[]) };+            let board_path = board.clone().or_else(|| (!r.board_pending()).then(|| r.current_board())).unwrap_or_else(|| err("fab export: no board in the run; pass --board", &[]));+            let tgt = target.clone().or_else(|| r.data["target"].as_str().map(str::to_string));+            let thread_name = cli.ai_thread.clone().unwrap_or_else(|| "adom-aiflow".into());+            let mut c = std::process::Command::new(aiflow_python());+            c.arg("-c").arg(include_str!("../../../tools/fab-export.py")).arg("--board").arg(&board_path).arg("--bom").arg(bom).arg("--out").arg(out).arg("--profile").arg(profile);+            if let Some(t) = &tgt { c.arg("--target").arg(t); }+            if let Some(x) = rotations { c.arg("--rotations").arg(x); }+            c.env("AIFLOW_THREAD", &thread_name);+            let o = c.output().unwrap_or_else(|e| err(&format!("fab export needs python3: {e}"), &[]));+            print!("{}", String::from_utf8_lossy(&o.stdout)); eprint!("{}", String::from_utf8_lossy(&o.stderr));+            if !o.status.success() { err("fab export failed (see above)", &["The export runs the desktop's native kicad-cli through Adom Bridge: check the target box has KiCad 10 and is connected.".into()]); }+            let base = Path::new(&board_path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+            let man_path = Path::new(out).join(format!("{base}-fab-manifest.json"));+            let rot_path = Path::new(out).join(format!("{base}-jlc-rotation.json"));+            let mut rot_ok = true;+            if profile == "jlcpcb" && !*no_rotation_check {+                let mut c = std::process::Command::new(aiflow_python());+                c.arg("-c").arg(rot_py).arg("--board").arg(&board_path).arg("--bom").arg(bom).arg("--cpl").arg(Path::new(out).join(format!("{base}-cpl-jlcpcb.csv")))+                    .arg("--out").arg(&rot_path).arg("--manifest").arg(&man_path).arg("--apply");+                if let Some(t) = &tgt { c.arg("--target").arg(t); }+                if let Some(x) = rotations { c.arg("--overrides").arg(x); }+                c.env("AIFLOW_THREAD", &thread_name);+                let o = c.output().unwrap_or_else(|e| err(&format!("rotation check needs python3: {e}"), &[]));+                print!("{}", String::from_utf8_lossy(&o.stdout)); eprint!("{}", String::from_utf8_lossy(&o.stderr));+                rot_ok = o.status.success();+            }+            let man: Value = std::fs::read_to_string(&man_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+            r.outcome("fab", json!({"profile": profile, "manifest": man_path.display().to_string(), "gerbers": man["gerbers"]["sha256"], "bom": man["bomJlcpcb"]["sha256"], "cpl": man["cplJlcpcb"]["sha256"], "parts": man["cplJlcpcb"]["parts"], "leftOut": man["leftOut"], "rotationCheck": man["rotationCheck"], "at": now()}));+            r.log("fab-export", json!({"manifest": man_path.display().to_string()}));+            r.save().unwrap();+            if !rot_ok { err("fab export: the JLCPCB rotation check failed (see above); the CPL was NOT corrected", &["A part with no rotation that lands its pads on JLCPCB's footprint has the wrong footprint or LCSC number, or is mirrored: fix the board or the BOM, never the CPL by hand.".into()]); }+            let corrected = man["rotationCheck"]["corrected"].as_object().map(|m| m.len()).unwrap_or(0);+            println!("Hint: upload the gerber zip, then (assembly) the BOM and CPL. JLCPCB's placement preview is the last word: screenshot it with pin 1 visible on every corrected part.");+            if corrected > 0 || man["rotationCheck"]["noJlcFootprint"].as_array().map(|a| !a.is_empty()).unwrap_or(false) {+                println!("Hint: share what you learned: for each corrected or hand-set part with a wiki component page, `adom-aiflow fab rotation-publish --record {} --ref <REF> --page <owner/slug> [--screenshot <preview.png>]`, and after the boards come back working add `--boards <N> --order <id>`. Pages you do not own: open an issue on them with the record instead.", rot_path.display());+            }+        }+        Cmd::Sourcing { what, bom, profile, qty, board, requirements, reference, facts, apply } => {             thread(&cli);             let mut r = load_run(&cli);-            if what != "check" { err(&format!("sourcing {what}: use `sourcing check --bom <csv>`"), &[]); }+            if what == "swap" {+                let (Some(rf), Some(fx)) = (reference, facts) else { err("sourcing swap: needs --ref <REF> and --facts <json>", &["Write the facts first: both datasheets read (value, tolerance, ratings, losses, temperature, terminals on the board's pads, polarity) with where each number came from; tools/sourcing-swap.py documents the shape.".into()]) };+                let board_path = board.clone().or_else(|| (!r.board_pending()).then(|| r.current_board()));+                let mut c = std::process::Command::new(aiflow_python());+                c.arg("-c").arg(include_str!("../../../tools/sourcing-swap.py")).arg("--bom").arg(bom).arg("--ref").arg(rf).arg("--facts").arg(fx);+                if let Some(b) = &board_path { c.arg("--board").arg(b); }+                if *apply { c.arg("--apply"); }+                let o = c.output().unwrap_or_else(|e| err(&format!("sourcing swap needs python3: {e}"), &[]));+                print!("{}", String::from_utf8_lossy(&o.stdout)); eprint!("{}", String::from_utf8_lossy(&o.stderr));+                let res_path = Path::new(fx).parent().unwrap_or(Path::new(".")).join(format!("{rf}.result.json"));+                let res: Value = std::fs::read_to_string(&res_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+                r.outcome(&format!("swap:{rf}"), json!({"verdict": res["verdict"], "original": res["original"], "candidate": res["candidate"], "lcsc": res["lcsc"], "applied": *apply && o.status.success(), "evidence": res_path.display().to_string(), "checkedAt": now()}));+                r.log("swap", json!({"ref": rf, "verdict": res["verdict"], "candidate": res["candidate"]}));+                r.save().unwrap();+                if !o.status.success() { err(&format!("swap {rf}: {}", if res["verdict"] == "FAIL" { "FAIL, the candidate is not equivalent (see the checks above)" } else { "the checker did not finish" }), &["Pick another candidate (aiflow-sourcing: search by spec, in stock), or change the design on purpose and say why; never apply a failing swap.".into()]); }+                println!("{}", if *apply { "swap applied: run `sourcing check --profile jlcpcb` next; new parts need their component pages and CAD (aiflow-sourcing 5)." } else { "swap verified: re-run with --apply to rewrite the BOM rows." });+                return;+            }+            if what != "check" { err(&format!("sourcing {what}: use `sourcing check --bom <csv>` or `sourcing swap --ref <REF> --facts <json>`"), &[]); }             if let Some(q) = requirements {                 if !Path::new(q).is_file() { err(&format!("not found: {q}"), &[]); }                 r.data["requirements"] = json!(std::fs::canonicalize(q).map(|p| p.display().to_string()).unwrap_or(q.clone()));@@ -3568,7 +3653,7 @@ fn main() {             }             println!("{}", step_table(&r));         }-        Cmd::Step { name, back, why } => {+        Cmd::Step { name, back, why, hwnd: hwnd_arg } => {             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());@@ -3606,7 +3691,7 @@ fn main() {                     clip_stopped = true;                 }                 // the fields step films the app's window through `tour fields`: no editor clip for it-                let hwnd = if name == "fields" { None } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()) };+                let hwnd = if let Some(h) = hwnd_arg { Some(*h) } else if name == "fields" { None } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()) };                 let disk = disk_check(&dir, &br, name);                 if let Err(e) = &disk {                     r.log("clip-refused", json!({"step": name, "why": e}));
crates/adom-aiflow/src/preboard.rs+12
@@ -103,6 +103,9 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri     let c_src = col(header, &["source", "supplier", "distributor", "vendor"]);     let c_vpn = col(header, &["vendor_pn", "supplier_pn", "distributor_pn", "mouser_pn", "vendor_part_number", "supplier_part_number"]);     let c_lcsc = col(header, &["lcsc", "lcsc_pn", "lcsc_part", "lcsc_part_number", "jlcpcb_part", "jlc_pn"]);+    // fit: "post" (or dnp, hand, after) = fitted after the fab's assembly (molecule contacts, machine pins, hand-soldered parts):+    // the row keeps its MPN and source, needs no LCSC number on the jlcpcb profile, and `fab export` leaves it out of the BOM and CPL+    let c_fit = col(header, &["fit", "dnp", "assembly"]);     // the dated stock column: a header carrying the date (stock_checked_2026-09-29), or a stock     // column beside a stock_date / checked column that carries the date per row     let c_stock_dated = header.iter().position(|h| h.to_ascii_lowercase().contains("stock") && date_in(h).is_some());@@ -142,6 +145,8 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri         if adom { adom_rows += 1; } else { distributor_rows += 1; }         match profile {             "jlcpcb" => {+                let fit = get(row, c_fit).to_ascii_lowercase();+                if ["post", "dnp", "hand", "after", "no"].iter().any(|k| fit == *k || fit.starts_with(&format!("{k} "))) { continue; }                 if lcsc.is_empty() && !is_lcsc_number(&vpn) { errors.push(format!("{label} ({mpn}): no LCSC number on the jlcpcb profile (add an lcsc column, e.g. C12345)")); }             }             _ => {@@ -390,6 +395,13 @@ mod tests {         assert_eq!(out["boardChecked"], false);     } +    #[test]+    fn jlcpcb_profile_skips_rows_fitted_after_assembly() {+        let bom = "ref,mpn,source,lcsc,fit,stock_checked_2026-10-05\nU1,TPS,JLCPCB,C123456,,5000\nJ1,MachineContactMedium,Adom stock,,post,\n";+        let out = sourcing_check(bom, None, "jlcpcb", 2);+        assert!(!out["errors"].to_string().contains("J1"), "{}", out["errors"]);+    }+     #[test]     fn jlcpcb_profile_needs_lcsc_numbers() {         let bom = "ref,mpn,source,lcsc,stock_checked_2026-09-29\nU1,TPS,JLCPCB,C123456,5000\nU2,ABC,JLCPCB,,5000\n";
docs/release-0.1.51.mdadded+11
@@ -0,0 +1,11 @@+# AI Flow 0.1.51++Ordering from JLCPCB: substitute parts proven from both datasheets, the manufacturing package from the desktop's own KiCad, and every part's placement rotation checked against JLCPCB's own footprint before the order goes in.++- New `sourcing swap --ref <REF> --facts <json> --bom <csv> [--apply]`: proves a substitute (a stocked part, or a JLCPCB basic part in place of an extended one) against the original. Values and tolerance, every voltage, current and power rating (a design limit in the facts decides over the original's rating), dielectric, temperature range, height, land fit on the board's pads, polarity and LED current; a "basic" claim must match JLCPCB's library. `--apply` rewrites the BOM rows with a dated note.+- New `fab export --bom <csv> --out <dir>`: gerber zip with paste layers and drill, JLCPCB BOM and CPL, and a manifest with sha256 of each, made by the target desktop's native kicad-cli. Rows with `fit=post` (connectors and hardware fitted after assembly) stay out of the BOM and CPL.+- JLCPCB rotation check, on by default in `fab export --profile jlcpcb`: for every placed part it reads JLCPCB's footprint for the LCSC number, finds the rotation (0, 90, 180, 270) that lands its pads on the KiCad pads pad for pad, and writes the corrected rotation and centroid into the CPL. A part that fits no rotation fails the export (wrong footprint, wrong LCSC number or mirrored). Parts JLCPCB has no footprint for borrow one from a part with the same KiCad footprint, then fall back to `--rotations` (hand-set) and are flagged for the placement preview. Record: `<board>-jlc-rotation.json`.+- New `fab rotation-publish`: shares a part's correction as `jlcpcb-rotation.json` (schema `adom/jlcpcb-rotation@1`) on its wiki component page, one entry per LCSC number and KiCad footprint, with evidence counted by kind (pad fit, JLCPCB preview screenshot, boards delivered) and by user. Re-publishing the same evidence does not inflate the counts; a disagreeing correction is refused.+- `sourcing check` on the jlcpcb profile skips rows fitted after assembly (`fit` column: post, dnp, hand).+- `step --hwnd <h>` records a named window when the step is not about the PCB editor (a browser, Hydrogen's Parts Search).+- Proven on the 12 V to 5 V buck molecule: seven swaps (five to basic parts), U1 (TPS54202, TSOT-23-6) found at -90 degrees with 0.012 mm residual, matching JLCPCB's preview; first record published on adom/tps54202ddcr.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.50",+  "version": "0.1.51",   "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 today; Altium, Fusion and 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.1.50",+  "version": "0.1.51",   "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 today; Altium, Fusion and 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+1
@@ -38,6 +38,7 @@ 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>]`** 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 records a clip of the desktop until a PCB editor is open. `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). On `fab` every row is from Mouser or Adom stock and no row is an LCSC/JLCPCB part; on `jlcpcb` every row carries its LCSC number. 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.+- **`sourcing swap --ref R --facts f.json --bom bom.csv [--apply]`** proves a substitute part against the original from both datasheets (ratings, tolerance, land fit, polarity, basic-part claim) and rewrites the BOM rows; **`fab export --bom bom.csv --out dir`** makes the gerber zip, JLCPCB BOM and CPL with every part's rotation checked against JLCPCB's own footprint, and **`fab rotation-publish`** shares a correction as `jlcpcb-rotation.json` on the part's wiki page (aiflow-sourcing sections 8 and 9). - **`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"]`.  ## The fields step (adom-fields)
skills/aiflow-sourcing/SKILL.md+53
@@ -98,6 +98,59 @@ step2glb thumbnail part.step       # look at it  `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. +## 8. Swapping a part for a stocked or basic one: `sourcing swap`++When the fab profile's stock forces a substitute (an unstocked part, or a JLCPCB extended part with a basic+equivalent), never swap by eye. Search in Hydrogen (`adom-parts-search show "<spec>" --basic` on JLCPCB) so the+search is on screen, then write a facts file with both datasheets read and every number sourced, and run:++```bash+adom-aiflow sourcing swap --ref C6 --facts design/swaps/C6.json --bom design/bom-jlcpcb.csv [--apply]+```++It checks value and tolerance, every voltage, current and power rating (a design `requires` limit decides over the+original's rating), dielectric, temperature range, height, land fit (at least 85 percent of each terminal on the+board's pad), polarity and LED current, and refuses a "basic" claim the candidate's JLCPCB library does not carry.+`--apply` rewrites the BOM rows (LCSC number, dated stock column, a note naming the original). A `waive` entry in the+facts records a deliberate, explained exception (a wider tolerance on a decoupling cap); the result lists it.++## 9. Ordering from JLCPCB: `fab export` and rotations++```bash+adom-aiflow fab export --bom design/bom-jlcpcb.csv --out order/fab [--rotations order/jlc-rotations.json]+```++Gerber zip (with paste layers and drill), JLCPCB BOM and CPL, and a manifest with sha256 of each, made by the+desktop's native kicad-cli. Rows with `fit=post` (connectors and hardware fitted after assembly) stay out of the BOM+and CPL.++Rotation is the classic JLCPCB failure: KiCad and JLCPCB's (EasyEDA) footprints often disagree on a package's zero+rotation (SOT-23, TSOT, SOIC, QFN, electrolytics), and a part placed 90 degrees off is a dead board. On the jlcpcb+profile `fab export` checks every placed part by geometry: it reads JLCPCB's footprint for the LCSC number (through a+pup window, the API answers browsers only), finds the rotation of 0, 90, 180 or 270 degrees that lands its pads on+the KiCad pads pad for pad, and writes the corrected rotation (and centroid, when the origins differ) into the CPL.+The record goes to `<board>-jlc-rotation.json` beside the CPL. A part whose pads fit no rotation fails the export:+the footprint or the LCSC number is wrong, or the part is mirrored. A part JLCPCB publishes no footprint for falls+back to another part on the board with the same KiCad footprint, then to `--rotations` (hand-set, keyed by reference+or footprint), and is flagged for the preview check.++JLCPCB's placement preview is still the last word: after upload, screenshot the preview with pin 1 visible on every+corrected or hand-set part, and keep the screenshots with the order.++**Share the correction.** Each component page can carry `jlcpcb-rotation.json` (schema `adom/jlcpcb-rotation@1`): one+entry per LCSC number and KiCad footprint pair, the correction in degrees, the centroid offset, both pad sets, and the+evidence with counts (`padFit` runs, `jlcPreview` screenshots, `boardsDelivered`, distinct `users`). Read it before an+order; add to it after one:++```bash+adom-aiflow fab rotation-publish --record order/fab/<board>-jlc-rotation.json --ref U1 --page <owner/slug> \+    [--screenshot order/U1-preview.png] [--boards 5 --order <JLC order id>] [--dry-run]+```++Re-publishing the same evidence does not inflate the counts, and a correction that disagrees with the page refuses+until the preview settles it. It is a data file, not a widget. Only publish to pages you own or were asked to update;+on anyone else's page, open an issue with the record.+ ## Worked example: TPS54202 molecule (stock checked 2026-09-29)  - Remembered first picks had zero Mouser stock: Coilcraft XAL5050-153MEC (280-day lead) and Murata GRM31CR71E106KA12L (182-day lead). The spec search found Abracon AMPLH5030S-150MT (1577 in stock) and Yageo CC1206KKX7R8BB106 (18168) at once.
tools/fab-export.pyadded+122
@@ -0,0 +1,122 @@+#!/usr/bin/env python3+"""adom-aiflow fab export: the manufacturing package a fab's upload page takes, from the board and its BOM.++  python3 fab-export.py --board B.kicad_pcb --bom design/bom-jlcpcb.csv --out order/fab [--profile jlcpcb]+                        [--target <desktop>] [--rotations rot.json]++Runs the desktop's native kicad-cli (KiCad 10) through Adom Bridge, the way kicad-cli-remote does, because the shared+KiCad service exports no paste layers and no placement file:+  gerbers  F/B.Cu, F/B.Paste, F/B.Silkscreen, F/B.Mask, Edge.Cuts (plus inner copper on a 4+ layer board), Protel names+  drill    one Excellon file, mm, decimal, absolute origin+  pos      placement CSV, both sides, mm+Then writes, for --profile jlcpcb (JLCPCB's own column names):+  <board>-gerbers.zip          what "Add gerber file" takes+  <board>-bom-jlcpcb.csv       Comment, Designator, Footprint, LCSC Part #   (one row per LCSC number)+  <board>-cpl-jlcpcb.csv       Designator, Mid X, Mid Y, Layer, Rotation+  <board>-fab-manifest.json    sha256 of every file, the parts left out and why, the rotations applied+Parts whose BOM row says fit=post (molecule contacts, machine pins, hand-fitted parts) are left out of the BOM and the+CPL; every other placed reference must have an LCSC number, or the export refuses. --rotations is a JSON map+{"<footprint name or ref>": degrees} added to KiCad's rotation for parts the fab's assembly preview shows turned (set+it from the preview, never from memory)."""+import argparse, csv, datetime, glob, hashlib, json, os, re, shutil, subprocess, sys, tempfile, zipfile++TH = os.environ.get("AIFLOW_THREAD", "adom-aiflow-fab")+++def bridge(target, verb, args):+    r = subprocess.run(["adom-bridge", "--target", target, verb, json.dumps(args), "--ai-thread", TH], capture_output=True, text=True)+    try: return json.loads(r.stdout)+    except Exception: return {"raw": r.stdout[-600:], "err": r.stderr[-400:]}+++def kicad_cli(target):+    st = bridge(target, "kicad_status", {"reason": "find the native kicad-cli for adom-aiflow fab export"})+    cli = (st.get("install") or {}).get("kicadCli") or os.environ.get("KICAD_REMOTE_CLI", "")+    if not cli: sys.exit("fab export: no KiCad on %s (set KICAD_REMOTE_CLI)" % target)+    return cli+++def sh_remote(target, cmd, why, timeout=240):+    r = bridge(target, "shell_execute", {"command": cmd, "timeoutSeconds": timeout, "reason": why})+    return r+++def main():+    ap = argparse.ArgumentParser(); ap.add_argument("--board", required=True); ap.add_argument("--bom", required=True)+    ap.add_argument("--out", required=True); ap.add_argument("--profile", default="jlcpcb")+    ap.add_argument("--target", default=os.environ.get("KICAD_REMOTE_TARGET", "ConfRoomROG")); ap.add_argument("--rotations")+    a = ap.parse_args()+    board = os.path.abspath(a.board); base = os.path.splitext(os.path.basename(board))[0]; os.makedirs(a.out, exist_ok=True)+    cli = kicad_cli(a.target)+    tmpw = sh_remote(a.target, "echo %TEMP%", "temp folder for the fab export").get("stdout", "").strip().replace("\\", "/")+    dest = "%s/aiflow-fab-%s" % (tmpw, datetime.datetime.now().strftime("%Y%m%d%H%M%S"))+    send = [board] + [p for p in glob.glob(os.path.join(os.path.dirname(board), base + ".kicad_pro")) + glob.glob(os.path.join(os.path.dirname(board), "*.kicad_dru"))]+    r = bridge(a.target, "send_files", {"filePaths": send, "dest": dest, "reason": "fab export for adom-aiflow (native kicad-cli on the desktop)"})+    wb = "%s/%s.kicad_pcb" % (dest, base); g = dest + "/gerbers"+    n_cu = len(set(re.findall(r'\(\d+ "(In\d+\.Cu)"', open(board, encoding="utf-8").read())))+    layers = ["F.Cu", "B.Cu"] + ["In%d.Cu" % i for i in range(1, n_cu + 1)] + ["F.Paste", "B.Paste", "F.Silkscreen", "B.Silkscreen", "F.Mask", "B.Mask", "Edge.Cuts"]+    q = lambda p: '"%s"' % p.replace("/", "\\")+    cmds = [+        ("%s pcb export gerbers --output %s --layers %s %s" % (q(cli), q(g), ",".join(layers), q(wb)), "gerbers"),+        ("%s pcb export drill --output %s --format excellon --drill-origin absolute --excellon-zeros-format decimal --excellon-units mm %s" % (q(cli), q(g), q(wb)), "drill"),+        ("%s pcb export pos --output %s --format csv --units mm --side both %s" % (q(cli), q(dest + "/pos.csv"), q(wb)), "placement"),+        ('powershell -NoProfile -Command "Compress-Archive -Path \'%s\\*\' -DestinationPath \'%s\' -Force"' % (g.replace("/", "\\"), (dest + "/gerbers.zip").replace("/", "\\")), "zip"),+    ]+    log = []+    for c, what in cmds:+        res = sh_remote(a.target, c, "fab export: %s" % what)+        log.append({"step": what, "exit": res.get("exitCode"), "out": (res.get("stdout", "") + res.get("stderr", ""))[-300:]})+    tmp = tempfile.mkdtemp()+    bridge(a.target, "pull_file", {"filePaths": [dest + "/gerbers.zip", dest + "/pos.csv"], "saveTo": tmp, "reason": "bring the fab package back"})+    sh_remote(a.target, 'rmdir /s /q %s' % q(dest), "clean up the fab export temp folder")+    gz, pos = os.path.join(tmp, "gerbers.zip"), os.path.join(tmp, "pos.csv")+    if not (os.path.isfile(gz) and os.path.isfile(pos)): sys.exit("fab export: files did not come back: %s" % json.dumps(log)[-900:])+    names = zipfile.ZipFile(gz).namelist()+    need = ["F_Cu", "B_Cu", "F_Mask", "B_Mask", "F_Silkscreen", "Edge_Cuts", "F_Paste"]+    missing = [n for n in need if not any(n in x for x in names)] + ([] if any(x.lower().endswith(".drl") for x in names) else ["drill"])+    if missing: sys.exit("fab export: the gerber zip lacks %s" % ", ".join(missing))+    out_g = os.path.join(a.out, base + "-gerbers.zip"); shutil.copy(gz, out_g)++    # BOM and CPL from the BOM rows and the placement file+    rows = list(csv.DictReader(open(a.bom, encoding="utf-8")))+    fitpost, lcsc_of, comment_of, mpn_of = set(), {}, {}, {}+    for r in rows:+        refs = [x for x in re.split(r"[ ,;]+", r.get("ref", "")) if x]+        fit = (r.get("fit") or "").strip().lower()+        for rf in refs:+            if fit in ("post", "dnp", "hand", "after", "no"): fitpost.add(rf); continue+            lcsc_of[rf] = (r.get("lcsc") or r.get("vendor_pn") or "").strip(); comment_of[rf] = r.get("value") or r.get("mpn"); mpn_of[rf] = r.get("mpn")+    rot = json.load(open(a.rotations)) if a.rotations else {}+    placed = list(csv.DictReader(open(pos, encoding="utf-8")))+    cpl, fp_of, applied, nolcsc = [], {}, {}, []+    for p in placed:+        rf = p["Ref"]+        if rf in fitpost: continue+        if not lcsc_of.get(rf): nolcsc.append(rf); continue+        fp_of[rf] = p["Package"]+        add = rot.get(rf, rot.get(p["Package"], 0)); r0 = float(p["Rot"])+        if add: applied[rf] = add+        cpl.append({"Designator": rf, "Mid X": "%.4fmm" % float(p["PosX"]), "Mid Y": "%.4fmm" % float(p["PosY"]),+                    "Layer": "Top" if p["Side"].lower().startswith("top") else "Bottom", "Rotation": "%g" % ((r0 + add) % 360)})+    if nolcsc: sys.exit("fab export: no LCSC number for %s (add it to the BOM, or fit=post for parts fitted after assembly)" % ", ".join(sorted(nolcsc)))+    groups = {}+    for rf in sorted(fp_of, key=lambda s: (re.sub(r"\d+", "", s), int(re.sub(r"\D", "", s) or 0))):+        groups.setdefault(lcsc_of[rf], []).append(rf)+    bom_out = os.path.join(a.out, base + "-bom-jlcpcb.csv"); cpl_out = os.path.join(a.out, base + "-cpl-jlcpcb.csv")+    with open(bom_out, "w", newline="", encoding="utf-8") as f:+        w = csv.writer(f); w.writerow(["Comment", "Designator", "Footprint", "LCSC Part #"])+        for lc, refs in groups.items(): w.writerow(["%s (%s)" % (comment_of[refs[0]], mpn_of[refs[0]]), ",".join(refs), fp_of[refs[0]], lc])+    with open(cpl_out, "w", newline="", encoding="utf-8") as f:+        w = csv.DictWriter(f, fieldnames=["Designator", "Mid X", "Mid Y", "Layer", "Rotation"]); w.writeheader(); w.writerows(cpl)+    sha = lambda p: hashlib.sha256(open(p, "rb").read()).hexdigest()+    man = {"profile": a.profile, "board": board, "boardSha256": sha(board), "bom": os.path.abspath(a.bom), "made": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),+           "kicadCli": cli, "target": a.target, "gerbers": {"file": out_g, "sha256": sha(out_g), "files": names},+           "bomJlcpcb": {"file": bom_out, "sha256": sha(bom_out), "lines": len(groups)}, "cplJlcpcb": {"file": cpl_out, "sha256": sha(cpl_out), "parts": len(cpl)},+           "leftOut": {"fitPost": sorted(fitpost)}, "rotationsApplied": applied, "log": log}+    json.dump(man, open(os.path.join(a.out, base + "-fab-manifest.json"), "w"), indent=1)+    print("OK: %s (%d gerber/drill files), %s (%d lines), %s (%d parts); left out (fitted after assembly): %s" % (+        os.path.basename(out_g), len(names), os.path.basename(bom_out), len(groups), os.path.basename(cpl_out), len(cpl), " ".join(sorted(fitpost)) or "none"))+++if __name__ == "__main__":+    main()
tools/jlc-rotation.pyadded+262
@@ -0,0 +1,262 @@+#!/usr/bin/env python3+"""adom-aiflow JLCPCB rotation check: prove every placed part lands pin 1 on pad 1 before the order goes in.++JLCPCB places each part from the footprint attached to its LCSC number (EasyEDA's library), at the rotation and+centroid in our CPL. KiCad's footprint for the same package often has a different zero rotation (SOT-23 / TSOT,+SOIC, QFN, electrolytics...), so the CPL needs a per-part correction. This finds it by geometry, not by a table:++  for each placed part with an LCSC number:+    E = JLCPCB's pads (EasyEDA footprint for that C-number, pad numbers, mm, footprint frame)+    K = the board footprint's pads (same numbers, footprint frame)+    find the rotation d in {0, 90, 180, 270} and offset t with R(d)E + t == K pad for pad (max error <= tol)+  CPL rotation = KiCad rotation + d;  CPL centroid shifted by t rotated into the board frame++  python3 jlc-rotation.py --board B.kicad_pcb --bom bom.csv --cpl cpl.csv [--target AdomLapper] [--out rot.json]+                          [--apply] [--tol 0.15]++Writes <out> (adom/jlcpcb-rotation-run@1): one record per part with the correction, residual, both pad sets (for the+wiki widget's overlay) and the source footprint names. With --apply it rewrites the CPL's Rotation (and Mid X/Y when+the centroid differs). Exits 1 when any part has no fitting rotation (wrong footprint, mirrored, missing pads).+JLCPCB's footprint API answers browsers only, so it is read through a pup window on the target desktop."""+import argparse, csv, datetime, hashlib, json, math, os, re, subprocess, sys++MIL = 0.254   # EasyEDA units are 10 mil+++def bridge(target, verb, args, thread):+    r = subprocess.run(["adom-bridge", "--target", target, verb, json.dumps(args), "--ai-thread", thread], capture_output=True, text=True)+    try:+        d = json.loads(r.stdout); o = d.get("output")+        return json.loads(o) if isinstance(o, str) and o.startswith("{") else d+    except Exception:+        return {"raw": r.stdout[-400:]}+++def jlc_footprint(lcsc, target, thread, window="aiflow-jlc"):+    url = "https://easyeda.com/api/products/%s/components?version=6.4.19.5" % lcsc+    w = bridge(target, "pup_list_windows", {"reason": "find the JLCPCB footprint window"}, thread)+    have = any(s.get("window") == window for s in (w.get("sessions") or []))+    if not have:+        bridge(target, "pup_open_window", {"window": window, "url": url, "reason": "read JLCPCB footprints for the rotation check"}, thread)+    else:+        bridge(target, "pup_navigate", {"window": window, "url": url, "reason": "JLCPCB footprint for %s" % lcsc}, thread)+    expr = ("(async()=>{for(let i=0;i<40;i++){try{if(!location.href.includes('/%s/'))throw 0;const d=JSON.parse(document.body.innerText);const p=(d.result||{}).packageDetail||{};"+            "const ds=p.dataStr||{};if(ds.head)return JSON.stringify({title:p.title,head:ds.head,pads:(ds.shape||[]).filter(s=>s.startsWith('PAD'))});"+            "if(d.success===false||d.code===404||!d.result)return JSON.stringify({notFound:String(d.message||d.code||'no result')})}catch(e){}"+            "await new Promise(r=>setTimeout(r,500))}return JSON.stringify({error:location.href+' '+document.body.innerText.slice(0,200)})})()") % lcsc+    d = {}+    for attempt in range(3):   # the page must be THIS part's (never the previous part's answer)+        r = bridge(target, "pup_eval", {"window": window, "expression": expr, "reason": "parse the JLCPCB footprint for %s" % lcsc}, thread)+        res = r.get("result") if isinstance(r, dict) else None+        d = json.loads(res) if isinstance(res, str) else (res or {})+        if d.get("head") or d.get("notFound"): break+        bridge(target, "pup_navigate", {"window": window, "url": url, "reason": "retry JLCPCB footprint for %s" % lcsc}, thread)+    if os.environ.get("AIFLOW_DEBUG") and not d.get("head"): print("debug", lcsc, str(d)[:300], file=sys.stderr)+    if d.get("error") or not d.get("head"): return None+    hx, hy = float(d["head"]["x"]), float(d["head"]["y"]); pads = {}+    for s in d["pads"]:+        f = s.split("~")      # PAD~shape~x~y~w~h~layer~net~number~...+        try: pads.setdefault(f[8], ((float(f[2]) - hx) * MIL, (float(f[3]) - hy) * MIL, float(f[4]) * MIL, float(f[5]) * MIL))+        except (IndexError, ValueError): pass+    return {"title": d.get("title"), "pads": pads}+++def board_parts(board):+    t = open(board, encoding="utf-8").read(); out = {}; i = 0+    while True:+        j = t.find("(footprint ", i)+        if j < 0: return out+        depth, k = 0, j+        while True:+            c = t[k]; depth += c == "("; depth -= c == ")"; k += 1+            if depth == 0: break+        b = t[j:k]; i = k+        ref = re.search(r'\(property "Reference" "([^"]+)"', b)+        if not ref: continue+        at = re.search(r'\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', b)+        pads = {}+        for m in re.finditer(r'\(pad "([^"]*)" (smd|thru_hole) \w+.*?\(at ([-\d.]+) ([-\d.]+)(?: [-\d.]+)?\).*?\(size ([-\d.]+) ([-\d.]+)\)', b, re.S):+            pads.setdefault(m.group(1), (float(m.group(3)), float(m.group(4)), float(m.group(5)), float(m.group(6))))+        out[ref.group(1)] = {"footprint": re.search(r'\(footprint "([^"]+)"', b).group(1), "rot": float(at.group(3) or 0),+                             "x": float(at.group(1)), "y": float(at.group(2)), "pads": pads, "layer": "Bottom" if '(layer "B.Cu")' in b[:400] else "Top"}+++def R(p, deg):   # rotate CCW-positive (as seen y-up) in y-down coordinates+    a = math.radians(deg); x, y = p+    return (x * math.cos(a) + y * math.sin(a), -x * math.sin(a) + y * math.cos(a))+++def fit(E, K):+    common = [n for n in K if n in E and n]+    if len(common) < 2: return None+    best = None+    for d in (0, 90, 180, 270):+        rp = {n: R(E[n][:2], d) for n in common}+        t = (sum(K[n][0] - rp[n][0] for n in common) / len(common), sum(K[n][1] - rp[n][1] for n in common) / len(common))+        err = max(math.hypot(rp[n][0] + t[0] - K[n][0], rp[n][1] + t[1] - K[n][1]) for n in common)+        if best is None or err < best[1]: best = (d, err, t)+    return {"delta": best[0], "residual_mm": round(best[1], 4), "offset_mm": [round(best[2][0], 4), round(best[2][1], 4)], "pads_compared": len(common)}+++def main():+    ap = argparse.ArgumentParser(); ap.add_argument("--board", required=True); ap.add_argument("--bom", required=True); ap.add_argument("--cpl", required=True)+    ap.add_argument("--target", default=os.environ.get("KICAD_REMOTE_TARGET", "AdomLapper")); ap.add_argument("--out")+    ap.add_argument("--apply", action="store_true"); ap.add_argument("--tol", type=float, default=0.15)+    ap.add_argument("--overrides", help="the hand-set corrections fab export applied (ref or footprint -> degrees); used only where no geometry answer exists")+    ap.add_argument("--manifest", help="fab manifest to update with the check and the new CPL sha256")+    a = ap.parse_args()+    ov = {k: v for k, v in json.load(open(a.overrides)).items() if not k.startswith("_")} if a.overrides else {}; thread = os.environ.get("AIFLOW_THREAD", "adom-aiflow-fab")+    parts = board_parts(a.board)+    rows = list(csv.DictReader(open(a.bom, encoding="utf-8")))+    lcsc = {}; mpn = {}+    for r in rows:+        for rf in [x for x in re.split(r"[ ,;]+", r.get("ref", "")) if x]:+            if (r.get("fit") or "").strip().lower() in ("post", "dnp", "hand", "after", "no"): continue+            c = (r.get("lcsc") or r.get("vendor_pn") or "").strip()+            if re.match(r"^C\d+$", c): lcsc[rf] = c; mpn[rf] = r.get("mpn", "")+    cache = {}; recs = []; failed = []; warned = []+    order = sorted([r for r in lcsc if r in parts], key=lambda s: (re.sub(r"\d+", "", s), int(re.sub(r"\D", "", s) or 0)))+    for rf in order:+        if lcsc[rf] not in cache: cache[lcsc[rf]] = jlc_footprint(lcsc[rf], a.target, thread)+    by_fp = {}+    for rf in order:+        if cache.get(lcsc[rf]): by_fp.setdefault(parts[rf]["footprint"], lcsc[rf])+    for rf in order:+        c = lcsc[rf]; p = parts[rf]; jf = cache.get(c); src = c+        if not jf and by_fp.get(p["footprint"]):+            src = by_fp[p["footprint"]]; jf = cache[src]       # EasyEDA has no footprint for this part: same KiCad footprint elsewhere on the board+        if not jf:+            warned.append((rf, "JLCPCB publishes no footprint for %s and no part on this board shares %s: confirm it in JLCPCB's placement preview" % (c, p["footprint"])))+            rec = {"ref": rf, "mpn": mpn[rf], "lcsc": c, "kicadFootprint": p["footprint"], "kicadRotation": p["rot"], "status": "no-jlc-footprint"}+            m = ov.get(rf, ov.get(p["footprint"]))+            if m is not None: rec.update(status="manual", delta=int(m) % 360, jlcRotation=(p["rot"] + int(m)) % 360); warned[-1] = (rf, "no JLCPCB footprint; hand-set correction %+d kept (confirm it in JLCPCB's placement preview)" % int(m))+            recs.append(rec); continue+        f = fit(jf["pads"], p["pads"])+        rec = {"ref": rf, "mpn": mpn[rf], "lcsc": c, "kicadFootprint": p["footprint"], "jlcFootprint": jf["title"], "kicadRotation": p["rot"]}+        if src != c: rec["jlcFootprintFrom"] = src+        if not f: failed.append((rf, "no common pad numbers between %s and %s" % (p["footprint"], jf["title"]))); rec["status"] = "no-match"; recs.append(rec); continue+        rec.update(f)+        rec["jlcRotation"] = (p["rot"] + f["delta"]) % 360+        # centroid: JLC places its footprint origin; shift so its pads land on ours (offset is in the footprint frame)+        ox, oy = R(tuple(f["offset_mm"]), p["rot"])      # into the board frame (y down)+        rec["centroidShiftBoard_mm"] = [round(-ox, 4), round(-oy, 4)]+        rec["status"] = "ok" if f["residual_mm"] <= a.tol else "mismatch"+        rec["pads"] = {"kicad": {n: [round(v, 4) for v in p["pads"][n]] for n in p["pads"]},+                       "jlc": {n: [round(v, 4) for v in jf["pads"][n]] for n in jf["pads"]}}+        m = ov.get(rf, ov.get(p["footprint"]))+        if m is not None and rec["status"] == "ok" and int(m) % 360 != f["delta"]:+            warned.append((rf, "hand-set correction %+d disagrees with the pad fit (%+d); the pad fit wins" % (int(m), ((f["delta"] + 180) % 360) - 180)))+        if rec["status"] != "ok": failed.append((rf, "best rotation leaves %.3f mm error (> %.2f): wrong footprint or mirrored part" % (f["residual_mm"], a.tol)))+        recs.append(rec)+    out = {"schema": "adom/jlcpcb-rotation-run@1", "board": os.path.abspath(a.board), "boardSha256": hashlib.sha256(open(a.board, "rb").read()).hexdigest(),+           "checked": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), "method": "pad-by-pad fit of JLCPCB's (EasyEDA) footprint to the board footprint",+           "tolerance_mm": a.tol, "parts": recs, "failed": [{"ref": r, "why": w} for r, w in failed], "warnings": [{"ref": r, "why": w} for r, w in warned]}+    outp = a.out or os.path.join(os.path.dirname(os.path.abspath(a.cpl)), "jlc-rotation.json")+    json.dump(out, open(outp, "w"), indent=1)+    if a.apply and not failed:+        cpl = list(csv.DictReader(open(a.cpl, encoding="utf-8"))); by = {r["ref"]: r for r in recs}+        for row in cpl:+            r = by.get(row["Designator"])+            if not r or "jlcRotation" not in r: continue   # no footprint and no hand-set value: fab export's rotation stands+            row["Rotation"] = "%g" % r["jlcRotation"]+            sx, sy = r.get("centroidShiftBoard_mm", (0, 0))+            if abs(sx) > 0.005 or abs(sy) > 0.005:   # CPL Y is the board Y negated (KiCad exports y up)+                row["Mid X"] = "%.4fmm" % (float(row["Mid X"][:-2]) + sx); row["Mid Y"] = "%.4fmm" % (float(row["Mid Y"][:-2]) - sy)+        with open(a.cpl, "w", newline="", encoding="utf-8") as f:+            w = csv.DictWriter(f, fieldnames=list(cpl[0].keys())); w.writeheader(); w.writerows(cpl)+    if a.manifest and os.path.exists(a.manifest):+        man = json.load(open(a.manifest))+        man["rotationCheck"] = {"file": os.path.abspath(outp), "applied": bool(a.apply and not failed), "parts": len(recs),+                                "corrected": {r["ref"]: ((r["delta"] + 180) % 360) - 180 for r in recs if r.get("delta")},+                                "failed": [r for r, _ in failed], "noJlcFootprint": [r["ref"] for r in recs if r["status"] in ("no-jlc-footprint", "manual")]}+        man["cplJlcpcb"]["sha256"] = hashlib.sha256(open(a.cpl, "rb").read()).hexdigest()+        json.dump(man, open(a.manifest, "w"), indent=1)+    turned = [r for r in recs if r.get("delta")]+    print("rotation check: %d parts, %d need a JLCPCB rotation correction (%s), %d failed%s" % (+        len(recs), len(turned), ", ".join("%s %+d" % (r["ref"], ((r["delta"] + 180) % 360) - 180) for r in turned) or "none", len(failed),+        "; CPL updated" if a.apply and not failed else ""))+    for r, w in failed: print("  FAIL %s: %s" % (r, w))+    for r, w in warned: print("  WARN %s: %s" % (r, w))+    sys.exit(1 if failed else 0)+++PAGE_FILE = "jlcpcb-rotation.json"+++def wiki(*args):+    r = subprocess.run(["adom-wiki", *args], capture_output=True, text=True)+    return r.returncode, r.stdout, r.stderr+++def publish():+    """Merge one part's rotation into its wiki component page's jlcpcb-rotation.json (schema adom/jlcpcb-rotation@1).++      jlc-rotation.py publish --record <run json> --ref U1 --page <owner/slug> [--screenshot preview.png]+                              [--boards N --order <id>] [--dry-run]++    One entry per (LCSC number, KiCad footprint) pairing, since the correction belongs to that pair. Each publish appends+    evidence and recounts it: pad-fit (the geometry check passed on a board), jlc-preview (JLCPCB's own placement preview+    showed pin 1 right; the screenshot goes on the page beside the file), boards-delivered (assembled boards came back+    working with this correction). Evidence is deduplicated, so re-publishing the same run does not inflate the counts."""+    ap = argparse.ArgumentParser(prog="jlc-rotation.py publish"); ap.add_argument("--record", required=True); ap.add_argument("--ref", required=True)+    ap.add_argument("--page", required=True); ap.add_argument("--screenshot"); ap.add_argument("--boards", type=int); ap.add_argument("--order")+    ap.add_argument("--dry-run", action="store_true")+    a = ap.parse_args(sys.argv[2:])+    run = json.load(open(a.record)); rec = next((r for r in run["parts"] if r["ref"] == a.ref), None)+    if not rec: sys.exit("publish: %s is not in %s" % (a.ref, a.record))+    if rec["status"] not in ("ok", "manual"): sys.exit("publish: %s has status %s; only a fitted (ok) or hand-set (manual) correction is published" % (a.ref, rec["status"]))+    if rec["status"] == "manual" and not (a.screenshot or a.boards): sys.exit("publish: %s is hand-set (no JLCPCB footprint to fit); publish it only with --screenshot of JLCPCB's preview or --boards delivered" % a.ref)+    rc, who, _ = wiki("whoami", "--json"); by = "unknown"+    try: w = json.loads(who); by = (w.get("data") or w)["user"]["username"]+    except Exception: pass+    rc, cur, _ = wiki("repo", "show", a.page, PAGE_FILE)+    try: doc = json.loads(cur) if rc == 0 else None+    except ValueError: doc = None+    doc = doc or {"schema": "adom/jlcpcb-rotation@1", "part": rec.get("mpn") or "", "entries": [],+                  "about": "Rotation to add to the KiCad footprint's rotation in a JLCPCB CPL so JLCPCB places this part pin 1 on pad 1. "+                           "correction_deg is CCW-positive as seen from the top; centroidOffset_mm is in the KiCad footprint frame (mm, y down). "+                           "Written by adom-aiflow fab export (pad-by-pad fit of JLCPCB's EasyEDA footprint to the KiCad footprint)."}+    key = (rec["lcsc"], rec["kicadFootprint"])+    e = next((x for x in doc["entries"] if (x.get("lcsc"), x.get("kicadFootprint")) == key), None)+    if not e:+        e = {"lcsc": rec["lcsc"], "kicadFootprint": rec["kicadFootprint"], "evidence": []}; doc["entries"].append(e)+    corr = ((rec["delta"] + 180) % 360) - 180+    if "correction_deg" in e and e["correction_deg"] != corr:+        sys.exit("publish: the page says %+d for %s on %s but this run found %+d; resolve it (JLCPCB preview) before publishing" % (e["correction_deg"], rec["lcsc"], rec["kicadFootprint"], corr))+    e["correction_deg"] = corr; e["method"] = "pad-fit" if rec["status"] == "ok" else "hand-set"+    for k in ("jlcFootprint", "jlcFootprintFrom", "residual_mm", "pads_compared"):+        if k in rec: e[k] = rec[k]+    if "offset_mm" in rec: e["centroidOffset_mm"] = rec["offset_mm"]+    if "pads" in rec: e["pads"] = rec["pads"]+    day = run.get("checked", "")[:10] or datetime.date.today().isoformat()+    new = []+    if rec["status"] == "ok": new.append({"kind": "pad-fit", "date": day, "by": by, "boardSha256": run.get("boardSha256"), "residual_mm": rec.get("residual_mm")})+    shot = None+    if a.screenshot:+        shot = "docs/jlcpcb-rotation/%s-%s-preview%s" % (rec["lcsc"], hashlib.sha256(open(a.screenshot, "rb").read()).hexdigest()[:8], os.path.splitext(a.screenshot)[1] or ".png")+        new.append({"kind": "jlc-preview", "date": datetime.date.fromtimestamp(os.path.getmtime(a.screenshot)).isoformat(), "by": by, "screenshot": shot, "boardSha256": run.get("boardSha256")})+    if a.boards: new.append({"kind": "boards-delivered", "date": datetime.date.today().isoformat(), "by": by, "count": a.boards, "order": a.order, "boardSha256": run.get("boardSha256")})+    sig = lambda v: (v["kind"], v.get("boardSha256"), v.get("screenshot"), v.get("order"), v.get("by"))+    have = {sig(v) for v in e["evidence"]}+    e["evidence"] += [v for v in new if sig(v) not in have]+    ev = e["evidence"]+    e["verified"] = {"padFit": sum(v["kind"] == "pad-fit" for v in ev), "jlcPreview": sum(v["kind"] == "jlc-preview" for v in ev),+                     "boardsDelivered": sum(v.get("count", 0) for v in ev if v["kind"] == "boards-delivered"),+                     "users": len({v.get("by") for v in ev})}+    doc["updated"] = datetime.date.today().isoformat()+    text = json.dumps(doc, indent=1, ensure_ascii=False) + "\n"+    if a.dry_run: print(text); return+    import tempfile, shutil+    d = tempfile.mkdtemp(prefix="jlcrot-"); open(os.path.join(d, PAGE_FILE), "w").write(text); files = [PAGE_FILE]+    if shot:+        os.makedirs(os.path.join(d, os.path.dirname(shot)), exist_ok=True); shutil.copy(a.screenshot, os.path.join(d, shot)); files.append(shot)+    r = subprocess.run(["adom-wiki", "repo", "push", a.page, "--files", *files, "-m", "jlcpcb-rotation: %s %+d on %s (%s)" % (rec["lcsc"], corr, rec["kicadFootprint"], ", ".join(v["kind"] for v in new) or "no new evidence")],+                       cwd=d, capture_output=True, text=True)+    print(r.stdout.strip() or r.stderr.strip())+    if r.returncode: sys.exit(1)+    rc, back, _ = wiki("repo", "show", a.page, PAGE_FILE)+    print("published %s %+d to %s/%s (%s); read back %s" % (rec["lcsc"], corr, a.page, PAGE_FILE, json.dumps(e["verified"]), "ok" if rc == 0 and json.loads(back) == doc else "MISMATCH"))+++if __name__ == "__main__":+    publish() if sys.argv[1:2] == ["publish"] else main()
tools/sourcing-swap.pyadded+185
@@ -0,0 +1,185 @@+#!/usr/bin/env python3+"""adom-aiflow sourcing swap: verify a substitute part against the one the design chose, and record it.++The common case: a BOM part is not stocked where the board is being built (a JLCPCB build needs LCSC numbers), so a+different MPN goes in. A swap is a design decision, so it is checked against facts, not names:++  python3 sourcing-swap.py --bom design/bom.csv --ref L1 --facts design/swaps/L1.json [--board B.kicad_pcb]+                           [--requirements design/calcs.json] [--apply] [--profile jlcpcb]++--facts is the AI's reading of BOTH datasheets, with where each number came from:++  { "ref": "L1", "kind": "inductor" | "resistor" | "capacitor" | "electrolytic" | "led" | "ic" | "other",+    "original":   { "mpn": "...", "manufacturer": "...", "datasheet": "url (rev, page)", "params": {...}, "terminals": [...], "polarity": {...},+                    "lcscSearch": { "queries": ["AMPLH5030S-150MT", "AMPLH5030S150MT"], "found": false, "checked": "2026-10-05" } },+    "candidate":  { "mpn": "...", "manufacturer": "...", "lcsc": "C123", "stock": 1234, "stockChecked": "2026-10-05",+                    "datasheet": "url (rev, page)", "params": {...}, "terminals": [...], "polarity": {...} },+    "requires":   { "isat_A": [">=", 2.5, "calcs.json inductor.Isat_required_A"], ... },+    "notes": "why this candidate" }++params (any subset; the original's set says what must be compared):+  value      L_uH | C_uF | R_ohm          equal (candidate nominal within the original's tolerance band)+  tol_pct                                   candidate <= original+  ratings    voltage_V isat_A irms_A power_W ripple_mA if_max_mA tc_max_C   candidate >= original+  losses     dcr_mohm_max esr_ohm tcr_ppm leakage_uA                         candidate <= original x (1 + slack, default 0)+  dielectric C0G > X7R > X5R > X6S > Y5V    candidate ranks >= original+  temp_C     [min, max]                     candidate range contains the original's+  package    "0402" | "SMD 6.3x5.4" ...     informational, the land check decides fit+  body_mm    {L, W, H}                      H <= original H + 0.3 (height under keep-outs), noted otherwise+  vf_V / wavelength_nm / iv_mcd             LED: same colour band (dominant within 15 nm), drive current re-derived+terminals: the candidate's metal footprint on the board, from its datasheet drawing, in the board footprint's frame+  (mm, origin at the footprint origin, pad numbers): [{"pad": "1", "x": -2.0, "y": 0, "w": 1.5, "h": 3.0}, ...]+  Each terminal must sit on its own pad: >= 85 % of its area inside the pad copper, and the pad mapping must keep+  polarity ({"pad": "1", "is": "cathode"} on both parts must agree for LEDs, electrolytics, diodes).+reason: "basic-part" when the original is orderable but a JLCPCB basic part avoids the per-type extended fee; the+  candidate then needs "jlcLibrary": "Basic" read off JLCPCB's live part page (the parts snapshot's is_basic is not trusted).+waive: {"<check name>": "why this failure is acceptable here"} turns one failing check into a recorded note.+requires: hard limits from the design record (calcs.json), checked against the candidate alone. A design limit on a+  quantity replaces the like-for-like comparison with the original for that quantity (shown as a note, not a failure).++Exit 0 and verdict PASS only when every check passes. --apply then rewrites the BOM row (mpn, manufacturer, source,+vendor_pn, the dated stock column, note "substitute for <old mpn>: design/swaps/<ref>.json") for every reference that+carried the original MPN, and writes design/swaps/<ref>.result.json as the evidence."""+import argparse, csv, datetime, io, json, math, os, re, sys++RANK = {"C0G": 5, "NP0": 5, "X8R": 4, "X7R": 4, "X7S": 3, "X6S": 3, "X5R": 3, "X7T": 2, "Y5V": 1, "Z5U": 1}+GE = ("voltage_V", "isat_A", "irms_A", "power_W", "ripple_mA", "if_max_mA", "tc_max_C")+LE = ("dcr_mohm_max", "esr_ohm", "tcr_ppm", "leakage_uA")+VALUE = ("L_uH", "C_uF", "R_ohm")+++def pads_of(board, ref):+    """Pads of one footprint in the footprint's own frame: {number: (x, y, w, h)} (rect bound of each pad)."""+    t = open(board, encoding="utf-8").read()+    i = 0+    while True:+        j = t.find("(footprint ", i)+        if j < 0: return {}+        depth, k = 0, j+        while True:+            c = t[k]+            depth += c == "("; depth -= c == ")"; k += 1+            if depth == 0: break+        blk = t[j:k]; i = k+        if re.search(r'\(property "Reference" "%s"' % re.escape(ref), blk) or re.search(r'\(fp_text reference "%s"' % re.escape(ref), blk):+            out = {}+            for m in re.finditer(r'\(pad "([^"]*)" smd \w+.*?\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\).*?\(size ([-\d.]+) ([-\d.]+)\)', blk, re.S):+                n, x, y, rot, w, h = m.group(1), float(m.group(2)), float(m.group(3)), float(m.group(4) or 0), float(m.group(5)), float(m.group(6))+                if round(rot) % 180 == 90: w, h = h, w+                out.setdefault(n, (x, y, w, h))+            return out+++def overlap(a, b):+    ax0, ax1, ay0, ay1 = a[0] - a[2] / 2, a[0] + a[2] / 2, a[1] - a[3] / 2, a[1] + a[3] / 2+    bx0, bx1, by0, by1 = b[0] - b[2] / 2, b[0] + b[2] / 2, b[1] - b[3] / 2, b[1] + b[3] / 2+    return max(0, min(ax1, bx1) - max(ax0, bx0)) * max(0, min(ay1, by1) - max(ay0, by0))+++def check(f, board):+    o, c = f["original"], f["candidate"]; po, pc = o.get("params", {}), c.get("params", {}); rows = []+    srch = o.get("lcscSearch")+    add = lambda name, ok, detail, info=False: rows.append({"check": name, "pass": bool(ok), "detail": detail, "info": info})+    req = f.get("requires", {})+    for k in VALUE:+        if k in po:+            if k not in pc: add(k, False, "candidate datasheet value missing"); continue+            tol = max(po.get("tol_pct", 0), 0.5) / 100+            add(k, abs(pc[k] - po[k]) <= tol * po[k] + 1e-12, "%g vs %g (original +/-%g %%)" % (pc[k], po[k], tol * 100))+    if "tol_pct" in po: add("tolerance", pc.get("tol_pct", 1e9) <= po["tol_pct"], "%s %% vs %s %%" % (pc.get("tol_pct"), po["tol_pct"]))+    for k in GE:   # a design limit for the same quantity decides; the original is then shown, not required+        if k in po: add(k, k in pc and pc[k] >= po[k], "%s vs original %s%s" % (pc.get(k), po[k], " (the design limit below decides)" if k in req else " (needs >=)"), info=k in req)+    slack = f.get("slack", {})+    for k in LE:+        if k in po:+            lim = po[k] * (1 + slack.get(k, 0))+            add(k, k in pc and pc[k] <= lim, "%s vs original %s%s%s" % (pc.get(k), po[k], " +%d %% allowed" % round(100 * slack[k]) if k in slack else "", " (the design limit below decides)" if k in req else " (needs <=)"), info=k in req)+    if "dielectric" in po:+        add("dielectric", RANK.get(str(pc.get("dielectric", "")).upper(), 0) >= RANK.get(str(po["dielectric"]).upper(), 0), "%s vs %s" % (pc.get("dielectric"), po["dielectric"]))+    if "temp_C" in po:+        t0, t1 = po["temp_C"]; tc = pc.get("temp_C", [1e9, -1e9])+        add("temperature range", tc[0] <= t0 and tc[1] >= t1, "%s covers %s" % (tc, po["temp_C"]))+    if "wavelength_nm" in po:+        add("colour", "wavelength_nm" in pc and abs(pc["wavelength_nm"] - po["wavelength_nm"]) <= 15, "dominant %s nm vs %s nm (within 15)" % (pc.get("wavelength_nm"), po["wavelength_nm"]))+    bo, bc = o.get("body_mm") or po.get("body_mm"), c.get("body_mm") or pc.get("body_mm")+    if bo and bc:+        h0, h1 = bo.get("H"), bc.get("H")+        if h0 and h1: add("height", h1 <= h0 + 0.3, "%s mm vs %s mm" % (h1, h0))+    for k, (op, lim, src) in f.get("requires", {}).items():+        v = pc.get(k)+        ok = v is not None and {">=": v >= lim, "<=": v <= lim, "==": abs(v - lim) <= 1e-9 * max(1, abs(lim))}[op]+        add("design: %s %s %s" % (k, op, lim), ok, "%s (from %s)" % (v, src))+    # land pattern: every candidate terminal on its own pad+    if board and c.get("terminals"):+        pads = pads_of(board, f["ref"])+        if not pads: add("land fit", False, "no footprint %s on the board" % f["ref"])+        for t in c["terminals"]:+            p = pads.get(str(t["pad"]))+            if not p: add("land fit pad %s" % t["pad"], False, "no pad %s on %s" % (t["pad"], f["ref"])); continue+            a = t["w"] * t["h"]; inside = overlap((t["x"], t["y"], t["w"], t["h"]), p) / a if a else 0+            add("land fit pad %s" % t["pad"], inside >= 0.85, "%.0f %% of the terminal on pad %s (%.2f x %.2f at %.2f, %.2f)" % (100 * inside, t["pad"], p[2], p[3], p[0], p[1]))+    elif c.get("terminals") is None and f.get("kind") not in ("resistor", "capacitor"):+        add("land fit", False, "candidate terminals not given: read them off its datasheet drawing")+    pol_o, pol_c = o.get("polarity"), c.get("polarity")+    if pol_o or pol_c:+        add("polarity", pol_o == pol_c, "original %s, candidate %s" % (pol_o, pol_c))+    if f.get("kind") == "led" and "vf_V" in pc and f.get("drive"):+        d = f["drive"]; i = (d["supply_V"] - pc["vf_V"]) / d["R_ohm"] * 1000+        add("LED current", d.get("min_mA", 0.5) <= i <= pc.get("if_max_mA", 1e9), "%.2f mA through %g ohm from %g V (Vf %g V)" % (i, d["R_ohm"], d["supply_V"], pc["vf_V"]))+    if not c.get("lcsc") and f.get("profile", "jlcpcb") == "jlcpcb": add("LCSC number", False, "jlcpcb profile needs the candidate's LCSC number")+    # the exact part first: a swap is only justified when the original MPN is really not orderable there+    if f.get("profile", "jlcpcb") == "jlcpcb" and f.get("reason") == "basic-part":+        # the original is orderable; the swap exists to drop JLCPCB's per-type extended-part fee+        lib = c.get("jlcLibrary"); add("candidate is a JLCPCB basic part", lib == "Basic", "%s (checked on %s)" % (lib or "not checked", c.get("jlcLibraryChecked", "?")))+    elif f.get("profile", "jlcpcb") == "jlcpcb":+        if not srch: add("original not on LCSC", False, "search LCSC for the original MPN first (with and without hyphens: Panasonic EEE-FK1V470P is listed as EEEFK1V470P) and record original.lcscSearch {queries, found, checked}")+        else: add("original not on LCSC", srch.get("found") is False, "searched %s on %s: %s" % (", ".join(srch.get("queries", [])), srch.get("checked", "?"), "not found" if srch.get("found") is False else "FOUND, order the original instead"))+    return rows+++def apply(bom, f, result):+    rows = list(csv.reader(open(bom, encoding="utf-8")))+    head = rows[0]; idx = {h: i for i, h in enumerate(head)}+    stock_col = next((h for h in head if h.startswith("stock_checked")), None)+    old, c = f["original"]["mpn"], f["candidate"]; changed = []+    for r in rows[1:]:+        if r and r[idx["mpn"]] == old and (r[idx["ref"]] == f["ref"] or f.get("all_refs", True)):+            r[idx["mpn"]] = c["mpn"]; r[idx["manufacturer"]] = c.get("manufacturer", "")+            r[idx["source"]] = "JLCPCB" if c.get("lcsc") else r[idx["source"]]+            if "lcsc" in idx: r[idx["lcsc"]] = c.get("lcsc", "")+            elif "vendor_pn" in idx: r[idx["vendor_pn"]] = c.get("lcsc", "")+            if stock_col: r[idx[stock_col]] = "%s (checked %s)" % (c.get("stock", "?"), c.get("stockChecked", datetime.date.today().isoformat()))+            if "note" in idx: r[idx["note"]] = ("substitute for %s, verified: design/swaps/%s.result.json; " % (old, f["ref"]) + r[idx["note"]]).strip("; ")+            changed.append(r[idx["ref"]])+    buf = io.StringIO(); csv.writer(buf, lineterminator="\n").writerows(rows); open(bom, "w", encoding="utf-8").write(buf.getvalue())+    return changed+++def main():+    ap = argparse.ArgumentParser(); ap.add_argument("--bom", required=True); ap.add_argument("--ref", required=True)+    ap.add_argument("--facts", required=True); ap.add_argument("--board"); ap.add_argument("--apply", action="store_true")+    a = ap.parse_args()+    f = json.load(open(a.facts)); f.setdefault("ref", a.ref)+    rows = check(f, a.board)+    for r in rows:   # waivers: a named check that fails, accepted on purpose with the reason recorded+        w = f.get("waive", {}).get(r["check"])+        if w and not r["pass"]: r.update(info=True, detail=r["detail"] + "; WAIVED: " + w)+    ok = all(r["pass"] for r in rows if not r.get("info")) and bool(rows)+    res = dict(ref=a.ref, original=f["original"].get("mpn"), candidate=f["candidate"].get("mpn"), lcsc=f["candidate"].get("lcsc"),+               verdict="PASS" if ok else "FAIL", checks=rows, checkedAt=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),+               facts=os.path.relpath(a.facts), notes=f.get("notes", ""))+    w = max(len(r["check"]) for r in rows) if rows else 10+    print("%s -> %s  (%s)" % (res["original"], res["candidate"], res["lcsc"] or "no LCSC"))+    for r in rows: print("  %s  %-*s  %s" % (("note" if r.get("info") else "PASS") if r["pass"] or r.get("info") else "FAIL", w, r["check"], r["detail"]))+    print("verdict: %s" % res["verdict"])+    out = os.path.join(os.path.dirname(os.path.abspath(a.facts)), "%s.result.json" % a.ref)+    json.dump(res, open(out, "w"), indent=1); res["evidence"] = out+    if a.apply:+        if not ok: print("not applied: the swap failed its checks"); sys.exit(1)+        res["bomRows"] = apply(a.bom, f, res); print("BOM rows now %s: %s" % (f["candidate"]["mpn"], ", ".join(res["bomRows"])))+        json.dump(res, open(out, "w"), indent=1)+    sys.exit(0 if ok else 1)+++if __name__ == "__main__":+    main()