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
Merge PR #384: Bottom probing, plane-backed tabs, multi-part programming, macOS walkthroughs, BOM/silk/fingerprint fixes
12 files changed
+466−22
crates/adom-aiflow/src/copper_carry.rs+24crates/adom-aiflow/src/export.rs+19−2crates/adom-aiflow/src/main.rs+29−5crates/adom-aiflow/src/programming.rs+117−4crates/adom-aiflow/src/silkproof.rs+18−3crates/adom-aiflow/src/testplan.rs+48−8crates/aiflow-analyze/src/lib.rs+5crates/aiflow-analyze/src/plane_backed.rs+158docs/plane-backed-tabs.md+20skills/aiflow-molecule/SKILL.md+2skills/aiflow-test-plan/SKILL.md+5tools/project-page.py+21crates/adom-aiflow/src/copper_carry.rs+24@@ -37,6 +37,8 @@ pub fn sha256_file(p: &Path) -> Result<String, String> { /// Footprint children and top-level items that never carry copper, whatever their layer. const NEVER_COPPER: [&str; 13] = ["model", "descr", "tags", "path", "sheetname", "sheetfile", "embedded_fonts", "embedded_files", "group", "generator", "generator_version", "paper", "title_block"];+/// Footprint flags that only steer the BOM and the pick-and-place file.+const ASSEMBLY_FLAGS: [&str; 3] = ["exclude_from_bom", "exclude_from_pos_files", "dnp"]; /// Items whose relevance depends on their layer (copper or the board edge count). const LAYERED: [&str; 7] = ["property", "dimension", "image", "target", "table", "barcode", "text"]; @@ -88,6 +90,15 @@ fn footprint(fp: &Node) -> Value { let t = c.tag.as_str(); if NEVER_COPPER.contains(&t) { continue; } if graphic(t) && !layered_relevant(c) { continue; }+ // BOM and position-file flags (exclude_from_bom, exclude_from_pos_files, dnp) change the+ // assembly files, never copper or DRC: setting them must not discard the copper evidence+ if ASSEMBLY_FLAGS.contains(&t) { continue; }+ if t == "attr" {+ let mut v = vec![json!("attr")];+ for a in atoms(c) { if !ASSEMBLY_FLAGS.contains(&a) { v.push(json!(a)); } }+ parts.push(Value::Array(v).to_string());+ continue;+ } parts.push(canon(c).to_string()); } parts.sort();@@ -721,3 +732,16 @@ mod tests { let _ = std::fs::remove_dir_all(d); } }++#[cfg(test)]+mod assembly_flag_tests {+ #[test]+ fn bom_and_position_flags_are_not_copper() {+ let a = r#"(kicad_pcb (version 20260101) (footprint "TP" (layer "B.Cu") (at 1 2) (property "Reference" "TP1" (at 0 1) (layer "B.Fab")) (attr smd exclude_from_bom) (pad "1" smd circle (at 0 0) (size 0.5 0.5) (layers "B.Cu"))))"#;+ let b = a.replace("(attr smd exclude_from_bom)", "(attr smd exclude_from_pos_files exclude_from_bom) (dnp yes)");+ assert_eq!(super::canonical_text(a).unwrap(), super::canonical_text(&b).unwrap());+ let c = a.replace("(attr smd exclude_from_bom)", "(attr through_hole exclude_from_bom)");+ assert_ne!(super::canonical_text(a).unwrap(), super::canonical_text(&c).unwrap(), "the mounting type still counts");+ }+}+
crates/adom-aiflow/src/export.rs+19−2@@ -34,13 +34,30 @@ pub struct Inventory { pub npth_pad_holes: usize, } +/// The first non-empty property among `names`, in the order of `names`: library footprints often carry an+/// empty `Manufacturer_Part_Number` beside the filled `MPN`, and an empty field must not hide a filled one. fn prop(fp: &sx::Node, names: &[&str]) -> String {- for p in fp.find_all("property") {- if let Some(k) = p.atom(1) { if names.iter().any(|n| n.eq_ignore_ascii_case(k)) { return p.atom(2).unwrap_or("").to_string(); } }+ for n in names {+ for p in fp.find_all("property") {+ if let Some(k) = p.atom(1) {+ if n.eq_ignore_ascii_case(k) { let v = p.atom(2).unwrap_or("").trim(); if !v.is_empty() { return v.to_string(); } }+ }+ } } String::new() } +#[cfg(test)]+mod prop_tests {+ #[test]+ fn an_empty_alias_does_not_hide_a_filled_one() {+ let fp = aiflow_board::sx::parse(r#"(footprint "C" (property "Reference" "C10") (property "Manufacturer_Part_Number" "") (property "MPN" "CL21A226MAQNNNE"))"#).unwrap();+ assert_eq!(super::prop(&fp, &["MPN", "Manufacturer_Part_Number"]), "CL21A226MAQNNNE");+ assert_eq!(super::prop(&fp, &["Manufacturer_Part_Number", "MPN"]), "CL21A226MAQNNNE");+ assert_eq!(super::prop(&fp, &["Value"]), "");+ }+}+ /// A footprint flag in either spelling: an atom of `(attr ...)` or its own `(flag yes)` node. fn flag(fp: &sx::Node, name: &str) -> bool { let in_attr = fp.find("attr").map(|a| a.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == name))).unwrap_or(false);
crates/adom-aiflow/src/main.rs+29−5@@ -1336,6 +1336,18 @@ fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option< /// on the footage (issue #48: with no ffprobe every clip read as "no frames" and compose dropped them all). const MEDIA_TOOLS: &[&str] = &["ffprobe", "ffmpeg"]; +/// The macOS KiCad bridge's window screenshots carry no `canvas` analysis: count the distinct colours+/// (4 bits per channel) in the middle 60 % of the saved screenshot, where the 3D canvas is. A white or+/// black unpainted canvas gives one or two; a rendered board gives dozens.+fn screenshot_distinct_colors(path: &str) -> Option<u64> {+ if path.is_empty() || !std::path::Path::new(path).exists() { return None; }+ let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-i", path, "-frames:v", "1", "-vf", "crop=iw*0.6:ih*0.6,scale=96:54", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]).output().ok()?;+ if !out.status.success() || out.stdout.len() < 3 { return None; }+ let set: std::collections::BTreeSet<u16> = out.stdout.chunks(3).map(|p| ((p[0] as u16 >> 4) << 8) | ((p[1] as u16 >> 4) << 4) | (p[2] as u16 >> 4)).collect();+ Some(set.len() as u64)+}++ /// Err names the first tool of `tools` that cannot be started. fn media_tools_missing(tools: &[&str]) -> Result<(), String> { for t in tools {@@ -4232,7 +4244,7 @@ fn main() { } else { Vec::new() }; let s = copper_carry::field_screen_with_acks(&r.data,&f,&spec,Path::new(&solved_board),&spec_bytes,what,&live_acks).map(|(n, c)| { if let Some(n) = n { eprintln!("{n}"); } c }).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e }); // issue #67: the same-input solve with a declared board-sink block stands in for the estimate- if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); sink_notes.extend(aiflow_analyze::lead_cooling::accept_solve(&mut res, &f)); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }+ if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); sink_notes.extend(aiflow_analyze::lead_cooling::accept_solve(&mut res, &f)); sink_notes.extend(aiflow_analyze::plane_backed::accept_solve(&mut res, &f)); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); } // issue #107: the passed solve's rise at each net's narrowest neck judges the whole-net width screen if what == "current" && s.is_ok() { aiflow_analyze::solved_ampacity::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); } s@@ -5439,7 +5451,7 @@ fn main() { let find_viewer = || -> Option<i64> { let w = br.call("kicad_window_info", &json!({})); let d = w.get("data").cloned().unwrap_or(w.clone());- d["editors"].as_array().and_then(|a| a.iter().find(|e| e["kind"].as_str() == Some("3d_viewer"))).and_then(|e| e["hwnd"].as_i64())+ d["editors"].as_array().and_then(|a| a.iter().find(|e| e["kind"].as_str() == Some("3d_viewer") || (e["kind"].is_null() && e["title"].as_str().is_some_and(|t| t.starts_with("3D Viewer"))))).and_then(|e| e["hwnd"].as_i64()) }; let viewer = op.get("hwnd").and_then(|h| h.as_i64()).or_else(find_viewer).unwrap_or_else(|| err("the 3D Viewer did not open", &["kicad_open_3d_viewer {editor:\"pcb\"} by hand and look at kicad_screenshot_all.".into()])); // a viewer opened in the background never paints its GL canvas (a white window with a@@ -5452,7 +5464,7 @@ fn main() { std::thread::sleep(std::time::Duration::from_secs(3)); let sh = br.call("kicad_screenshot_all", &json!({})); let d = sh.get("data").cloned().unwrap_or(sh.clone());- if d["screenshots"].as_array().map(|a| a.iter().any(|s| s["hwnd"].as_str().map(|h| h == viewer.to_string()).unwrap_or(s["hwnd"].as_i64() == Some(viewer)) && s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8)).unwrap_or(false) {+ if d["screenshots"].as_array().map(|a| a.iter().any(|s| s["hwnd"].as_str().map(|h| h == viewer.to_string()).unwrap_or(s["hwnd"].as_i64() == Some(viewer)) && (if s["canvas"].is_object() { s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8 } else { screenshot_distinct_colors(s["localPath"].as_str().or(s["savedTo"].as_str()).unwrap_or("")).unwrap_or(0) >= 8 }))).unwrap_or(false) { painted = true; break; }@@ -5598,9 +5610,21 @@ fn main() { let sessions = lw["sessions"].as_array().or_else(|| inner["sessions"].as_array()); // Only the session created by this invocation owns the recording target. // A title match can select another user's or another run's Fields window.- hwnd = sessions.and_then(|rows| rows.iter().find(|w| w["sessionId"].as_str() == Some(sid.as_str())))- .and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())))+ let mine = sessions.and_then(|rows| rows.iter().find(|w| w["sessionId"].as_str() == Some(sid.as_str())));+ hwnd = mine.and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok()))) .filter(|h| *h > 0);+ // macOS pup lists its sessions without a window handle: this session's window, once it+ // has its title, is found by the desktop's window list, and only when exactly one+ // "Adom Fields" window exists (a second one could be another run's)+ if hwnd.is_none() && mine.is_some_and(|w| w["title"].as_str() == Some("Adom Fields")) {+ // pup keeps its window off-screen in the background, so off-screen windows are listed too+ let fw = br.call("desktop_list_windows", &json!({"includeOffscreen": true}));+ let inner: Value = fw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);+ let rows = inner["data"]["windows"].as_array().or_else(|| inner["windows"].as_array()).cloned().unwrap_or_default();+ let named: Vec<_> = rows.iter().filter(|w| w["title"].as_str().is_some_and(|t| t.starts_with("Adom Fields"))).collect();+ let all_ours = sessions.map(|r| r.iter().filter(|w| w["title"].as_str() == Some("Adom Fields")).count()).unwrap_or(0) == 1;+ if named.len() == 1 && all_ours { hwnd = named[0]["hwnd"].as_i64().filter(|h| *h > 0); }+ } if hwnd.is_some() { break; } } if hwnd.is_some() { break; }
crates/adom-aiflow/src/programming.rs+117−4@@ -55,8 +55,8 @@ pub fn targets(b: &Board) -> Vec<(String, String)> { /// The `programming` record: board-release.json beside the board first, then the spec. pub fn record(board_path: &str, spec: &Value) -> Option<Value> { let rel = Path::new(board_path).parent().unwrap_or(Path::new(".")).join("board-release.json");- let from_rel = std::fs::read_to_string(rel).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v["programming"].clone()).filter(|v| v.is_object());- from_rel.or_else(|| Some(spec["programming"].clone()).filter(|v| v.is_object()))+ let from_rel = std::fs::read_to_string(rel).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v["programming"].clone()).filter(|v| v.is_object() || v.is_array());+ from_rel.or_else(|| Some(spec["programming"].clone()).filter(|v| v.is_object() || v.is_array())) } /// Where the workcell can reach a reference: a machine contact or pin, or a test pad with a label.@@ -118,7 +118,79 @@ pub fn check(board_path: &str, b: &Board, spec: &Value) -> Result<(), Vec<String if t.is_empty() { return Ok(()); } let names = t.iter().map(|(r, m)| format!("{r} {m}")).collect::<Vec<_>>().join(", "); let Some(rec) = record(board_path, spec) else { return Err(vec![format!("no `programming` entry in board-release.json for {names}")]) };- if rec["interface"] == "usb-serial-jtag" && rec["access"] == "usb-cable" { return cable_usb_check(b, &rec); }+ if let Some(list) = rec.as_array() { return check_list(b, &t, list); }+ check_one(b, &rec)+}++/// The reference(s) a record programs: `targetRef`, or `targetRefs` for one interface repeated per part.+fn refs_of(rec: &Value) -> Vec<String> {+ let mut v: Vec<String> = rec["targetRefs"].as_array().into_iter().flatten().filter_map(|r| r.as_str().map(str::to_string)).collect();+ if let Some(r) = rec["targetRef"].as_str() { v.push(r.to_string()); }+ v+}++/// A board with several programmable parts (four STM32s on SWD and an ESP32 on its own USB): `programming`+/// is a list, each record names the part(s) it programs (`targetRef` / `targetRefs`, whose part number+/// must be its `target`), every programmable part is named by one, and each record passes on its own.+fn check_list(b: &Board, t: &[(String, String)], list: &[Value]) -> Result<(), Vec<String>> {+ let mut p = Vec::new();+ for (i, rec) in list.iter().enumerate() {+ let refs = refs_of(rec);+ if refs.is_empty() { p.push(format!("programming[{i}] names no targetRef / targetRefs")); continue; }+ for r in &refs {+ match t.iter().find(|(x, _)| x == r) {+ Some((_, m)) if Some(m.as_str()) == rec["target"].as_str() => {}+ Some((_, m)) => p.push(format!("programming[{i}]: {r} is {m}, not target {}", rec["target"])),+ None => p.push(format!("programming[{i}]: {r} is not a programmable part on the board")),+ }+ // a per-part check sees the one reference it covers+ let mut one = rec.clone();+ if let Some(o) = one.as_object_mut() { o.remove("targetRefs"); o.insert("targetRef".into(), Value::String(r.clone())); }+ if let Err(e) = check_one(b, &one) { p.extend(e.into_iter().map(|x| format!("programming[{i}] {r}: {x}"))); }+ }+ }+ for (r, m) in t {+ if !list.iter().any(|rec| refs_of(rec).iter().any(|x| x == r)) { p.push(format!("no programming record names {r} {m}")); }+ }+ if p.is_empty() { Ok(()) } else { Err(p) }+}++/// A Tag-Connect footprint (TC2030 / TC2050) is a pad set made for a spring-pin cable, as reachable as a USB+/// connector for a cable: the declared connector pads must carry the SWD nets of the part it programs.+fn tag_connect_check(b: &Board, rec: &Value) -> Result<(), Vec<String>> {+ let mut problems = Vec::new();+ let connector = rec["connector"].as_str().unwrap_or("");+ match b.footprints.get(connector) {+ Some(fp) if fp.lib.contains("Tag-Connect") || fp.lib.contains("TC2030") || fp.lib.contains("TC2050") => {}+ Some(fp) => problems.push(format!("connector {connector} ({}) is not a Tag-Connect footprint", fp.lib.rsplit(':').next().unwrap_or(&fp.lib))),+ None => problems.push(format!("connector {connector} is not on the board")),+ }+ let target_ref = rec["targetRef"].as_str().unwrap_or("");+ if !targets(b).iter().any(|(r, m)| r == target_ref && Some(m.as_str()) == rec["target"].as_str()) {+ problems.push("targetRef/target must match a programmable part on the board".into());+ }+ let mut nets = Vec::new();+ for signal in ["SWDIO", "SWCLK", "NRST", "GND"] {+ let pin = &rec["connectorPins"][signal];+ let net = pin["net"].as_str().unwrap_or("");+ let names = pin["pads"].as_array();+ if net.is_empty() || names.map_or(true, |a| a.is_empty()) { problems.push(format!("connectorPins.{signal} needs nonempty pads and net")); continue; }+ for name in names.unwrap() {+ let pad = name.as_str().unwrap_or("");+ if !b.pads.iter().any(|p| p.reference == connector && p.name == pad && p.net == net) { problems.push(format!("{signal}: {connector}.{pad} does not carry declared net {net}")); }+ }+ // the SWD nets must reach the part it programs (GND aside)+ if signal != "GND" && !b.pads.iter().any(|p| p.reference == target_ref && p.net == net) { problems.push(format!("{signal}: net {net} does not reach {target_ref}")); }+ nets.push(net);+ }+ nets.sort(); nets.dedup();+ if nets.len() != 4 { problems.push("SWDIO, SWCLK, NRST and GND must be four distinct nets".into()); }+ if problems.is_empty() { Ok(()) } else { Err(problems) }+}++fn check_one(b: &Board, rec: &Value) -> Result<(), Vec<String>> {+ if rec["interface"] == "usb-serial-jtag" && rec["access"] == "usb-cable" { return cable_usb_check(b, rec); }+ if rec["interface"] == "swd" && rec["access"] == "tag-connect" { return tag_connect_check(b, rec); } let mut p = Vec::new(); let iface = rec["interface"].as_str().unwrap_or(""); let pins = rec["pins"].as_object().cloned().unwrap_or_default();@@ -142,7 +214,10 @@ pub fn summary(board_path: &str, b: &Board, spec: &Value, result: &Result<(), Ve if t.is_empty() { return "programming: no programmable part: programming not required".into(); } let names = t.iter().map(|(r, m)| format!("{r} {m}")).collect::<Vec<_>>().join(", "); match result {- Ok(()) => format!("programming: {names}, interface {} with declared access checked", record(board_path, spec).and_then(|r| r["interface"].as_str().map(str::to_string)).unwrap_or_default()),+ Ok(()) => format!("programming: {names}, interface {} with declared access checked", record(board_path, spec).map(|r| match r.as_array() {+ Some(a) => a.iter().map(|x| format!("{} ({})", x["interface"].as_str().unwrap_or(""), refs_of(x).join(" "))).collect::<Vec<_>>().join(", "),+ None => r["interface"].as_str().unwrap_or("").to_string(),+ }).unwrap_or_default()), Err(p) => format!("programming: {names}, {} problem(s) (the gate below)", p.len()), } }@@ -186,6 +261,44 @@ mod tests { (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 3 "NRST"))) )"#; + const TC_BOARD: &str = r#"(kicad_pcb (version 20241229)+ (net 0 "") (net 1 "SWDIO1") (net 2 "SWCLK1") (net 3 "NRST1") (net 4 "GND") (net 5 "+3V3") (net 6 "SWDIO2")+ (gr_rect (start 0 0) (end 50 20) (layer "Edge.Cuts") (width 0.1))+ (footprint "Package_QFP:LQFP-48_7x7mm_P0.5mm" (layer "F.Cu") (at 25 10)+ (property "Reference" "U1" (at 0 -5) (layer "F.SilkS")) (property "Value" "STM32G474CBT6" (at 0 5) (layer "F.Fab"))+ (pad "34" smd rect (at -3 0) (size 1 0.3) (layers "F.Cu") (net 1 "SWDIO1"))+ (pad "37" smd rect (at -3 1) (size 1 0.3) (layers "F.Cu") (net 2 "SWCLK1"))+ (pad "7" smd rect (at -3 2) (size 1 0.3) (layers "F.Cu") (net 3 "NRST1")))+ (footprint "Package_QFP:LQFP-48_7x7mm_P0.5mm" (layer "F.Cu") (at 40 10)+ (property "Reference" "U2" (at 0 -5) (layer "F.SilkS")) (property "Value" "STM32G474CBT6" (at 0 5) (layer "F.Fab"))+ (pad "34" smd rect (at -3 0) (size 1 0.3) (layers "F.Cu") (net 6 "SWDIO2")))+ (footprint "Connector:Tag-Connect_TC2030-IDC-NL_2x03_P1.27mm_Vertical" (layer "F.Cu") (at 8 8)+ (property "Reference" "J7" (at 0 -3) (layer "F.SilkS"))+ (pad "1" connect circle (at 0 0) (size 0.8 0.8) (layers "F.Cu") (net 5 "+3V3"))+ (pad "2" connect circle (at 0 1.27) (size 0.8 0.8) (layers "F.Cu") (net 1 "SWDIO1"))+ (pad "3" connect circle (at 1.27 0) (size 0.8 0.8) (layers "F.Cu") (net 3 "NRST1"))+ (pad "4" connect circle (at 1.27 1.27) (size 0.8 0.8) (layers "F.Cu") (net 2 "SWCLK1"))+ (pad "5" connect circle (at 2.54 0) (size 0.8 0.8) (layers "F.Cu") (net 4 "GND")))+)"#;++ #[test]+ fn a_list_covers_every_part_and_tag_connect_pads_are_checked() {+ let b = Board::from_text("t.kicad_pcb", TC_BOARD.to_string()).unwrap();+ let tc = |r: &str, j: &str| json!({"interface": "swd", "access": "tag-connect", "connector": j, "targetRef": r, "target": "STM32G474CBT6",+ "connectorPins": {"SWDIO": {"pads": ["2"], "net": "SWDIO1"}, "SWCLK": {"pads": ["4"], "net": "SWCLK1"}, "NRST": {"pads": ["3"], "net": "NRST1"}, "GND": {"pads": ["5"], "net": "GND"}}});+ // U1 on its Tag-Connect passes on its own+ assert!(check("t.kicad_pcb", &b, &json!({"programming": [tc("U1", "J7")]})).unwrap_err().iter().all(|e| e.contains("U2")), "only U2 is uncovered");+ // a wrong pad is named+ let mut bad = tc("U1", "J7"); bad["connectorPins"]["SWCLK"]["pads"] = json!(["3"]);+ assert!(check("t.kicad_pcb", &b, &json!({"programming": [bad]})).unwrap_err().iter().any(|e| e.contains("J7.3 does not carry declared net SWCLK1")));+ // U2's SWD nets do not reach J7: refused for U2+ let both = json!({"programming": [tc("U1", "J7"), tc("U2", "J7")]});+ assert!(check("t.kicad_pcb", &b, &both).unwrap_err().iter().any(|e| e.contains("U2") && e.contains("does not reach U2")));+ // a non-Tag-Connect footprint is not a cable landing+ let mut notc = tc("U1", "U2"); notc["connector"] = json!("U2");+ assert!(check("t.kicad_pcb", &b, &json!({"programming": [notc]})).unwrap_err().iter().any(|e| e.contains("not a Tag-Connect footprint")));+ }+ #[test] fn programmable_boards_bring_their_interface_to_the_workcell() { let b = Board::from_text("/nonexistent/b.kicad_pcb", BOARD.to_string()).unwrap();
crates/adom-aiflow/src/silkproof.rs+18−3@@ -43,11 +43,15 @@ pub fn proof(audit: &Value, input: &Value, board: &Board) -> Result<Value, Vec<S /// How far a supplied box may fall short of the board's own geometry (mm). const TOL: f64 = 0.05; +/// Every box of the matching obstacles: the schema allows one `box` or a `boxes` list per obstacle. fn boxes_for<'a>(obstacles: &'a [Value], f: impl Fn(&Value) -> bool + 'a) -> impl Iterator<Item = (f64, f64, f64, f64)> + 'a {- obstacles.iter().filter(move |o| f(o)).filter_map(|o| {- let b = o["box"].as_array()?;- let v: Vec<f64> = b.iter().filter_map(|x| x.as_f64()).collect();+ let one = |b: &Value| {+ let v: Vec<f64> = b.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); (v.len() == 4).then(|| (v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])))+ };+ obstacles.iter().filter(move |o| f(o)).flat_map(move |o| match o["boxes"].as_array() {+ Some(a) => a.iter().filter_map(one).collect::<Vec<_>>(),+ None => one(&o["box"]).into_iter().collect(), }) } @@ -257,6 +261,17 @@ mod tests { assert!(why.contains("1 of the board's vias"), "{why}"); } + #[test]+ fn boxes_lists_count_like_single_boxes() {+ // the schema allows "box" | "boxes"; a footprint given as a boxes list (body plus pads) is covered+ let b = Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap();+ let single = json!({"obstacles": [{"id": "R1", "kind": "body", "box": [0.0, 0.0, 4.0, 4.0]}, {"id": "C1", "kind": "body", "box": [4.0, 0.0, 8.0, 4.0]}, {"id": "v1", "kind": "via", "box": [4.0, 4.0, 6.0, 6.0]}]});+ let listed = json!({"obstacles": [{"id": "R1", "kind": "body", "boxes": [[0.0, 0.0, 2.0, 4.0], [2.0, 0.0, 4.0, 4.0]]}, {"id": "C1", "kind": "body", "boxes": [[4.0, 0.0, 8.0, 4.0]]}, {"id": "v1", "kind": "via", "boxes": [[4.0, 4.0, 6.0, 6.0]]}]});+ let short = |w: Vec<String>| w.iter().any(|x| x.contains("do not cover their own pads") || x.contains("vias have no"));+ assert!(!short(geometry_gaps(&single, &b)), "{:?}", geometry_gaps(&single, &b));+ assert!(!short(geometry_gaps(&listed, &b)), "{:?}", geometry_gaps(&listed, &b));+ }+ #[test] fn label_boxes_smaller_than_the_board_text_are_refused() { // R1's reference is 1 mm text at (2, 1); C1's value "1u" at (6, 3); a 0.6 mm square cannot hold either
crates/adom-aiflow/src/testplan.rs+48−8@@ -27,7 +27,18 @@ pub const REACH: f64 = 50.0; pub fn needs_spot(kind: &str) -> bool { !matches!(kind, "camera") } pub fn tests_of(v: &Value) -> Vec<Value> {- v.as_array().cloned().or_else(|| v["tests"].as_array().cloned()).unwrap_or_default()+ let mut list = v.as_array().cloned().or_else(|| v["tests"].as_array().cloned()).unwrap_or_default();+ // a plan-wide probe face ({"probeFace": "bottom", "tests": [...]}) applies to every test without its own+ if let Some(face) = v.get("probeFace").and_then(|f| f.as_str()) {+ for t in list.iter_mut() { if t.get("face").is_none() { t["face"] = json!(face); } }+ }+ list+}++/// The faces a test may be probed from: `face` "top" (the default: the workcell lands from the front),+/// "bottom" (the board is probed from below, e.g. bottom-side test points on a crowded top face) or "both".+pub fn faces_of(t: &Value) -> Vec<bool> {+ match t["face"].as_str().unwrap_or("top") { "bottom" | "back" => vec![false], "both" => vec![true, false], _ => vec![true] } } /// A netlist's nets as (name, pins), from either shape in use: a list of [name, [pins]] or the Fusion@@ -137,8 +148,11 @@ fn in_box(p: (f64, f64), b: &(f64, f64, f64, f64), grow: f64) -> bool { p.0 > b. /// Board mode: for each measured net, the best landing (x, y, on which pad) or why there is none. pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec<String>) {- // probes land from the front: only front-side bodies can cover a front pad (issue #46)- let bodies: Vec<(String, (f64, f64, f64, f64))> = sided_bodies(b).into_iter().filter(|(_, front, _)| *front).map(|(r, _, bb)| (r, bb)).collect();+ // a body covers a pad only on its own side (issue #46): front bodies for front probing, back bodies for+ // a test probed from below (`face`: "bottom")+ let sided = sided_bodies(b);+ let bodies_on = |front: bool| -> Vec<(String, (f64, f64, f64, f64))> { sided.iter().filter(|(_, f, _)| *f == front).map(|(r, _, bb)| (r.clone(), *bb)).collect() };+ let (front_bodies, back_bodies) = (bodies_on(true), bodies_on(false)); let grounds: Vec<(f64, f64, String)> = b.footprints.iter().filter(|(_, fp)| fp.lib.to_ascii_lowercase().contains("machinecontact")) .flat_map(|(r, _)| b.pads_of(r).into_iter().filter(|p| p.net.eq_ignore_ascii_case("GND")).map(move |p| (p.x, p.y, r.clone()))).collect(); let (mut rows, mut gaps, mut warn) = (Vec::new(), Vec::new(), Vec::new());@@ -147,9 +161,12 @@ pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec< let net = t["net"].as_str().unwrap_or(""); let kind = t["kind"].as_str().unwrap_or("dc"); if !needs_spot(kind) { rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": "camera"})); continue; }- let mut best: Option<(f64, f64, String, f64)> = None;+ let mut best: Option<(f64, f64, String, f64, bool)> = None; let mut why = "no exposed copper on this net".to_string();- for p in b.pads.iter().filter(|p| p.net == net && p.on_f) {+ let faces = faces_of(t);+ for &front in &faces {+ let bodies = if front { &front_bodies } else { &back_bodies };+ for p in b.pads.iter().filter(|p| p.net == net && if front { p.on_f } else { p.on_b }) { // sample landing centres inside the pad, shrunk by the landing radius let (hx, hy) = (p.size.0 / 2.0 - LANDING_R, p.size.1 / 2.0 - LANDING_R); if hx < -1e-9 || hy < -1e-9 { why = format!("pads too small for a 0.5 mm landing (e.g. {} {:.2}x{:.2} mm)", p.key, p.size.0, p.size.1); continue; }@@ -162,18 +179,19 @@ pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec< if let Some((r, _)) = bodies.iter().find(|(_, bb)| in_box(c, bb, LANDING_R)) { why = format!("every landing on {net} is under a part body (e.g. {} under {r})", p.key); continue; }- if b.pads.iter().any(|q| q.net != net && (q.x - c.0).hypot(q.y - c.1) < q.half_long + LANDING_R + SHORT_GAP) {+ if b.pads.iter().any(|q| q.net != net && (if front { q.on_f } else { q.on_b }) && (q.x - c.0).hypot(q.y - c.1) < q.half_long + LANDING_R + SHORT_GAP) { why = format!("every landing on {net} is within {SHORT_GAP} mm of another net's pad (short risk)"); continue; } let reach = grounds.iter().map(|g| (g.0 - c.0).hypot(g.1 - c.1)).fold(f64::MAX, f64::min); if !grounds.is_empty() && reach > REACH { why = format!("{net}'s landings are {reach:.0} mm from the nearest ground contact (tether reach {REACH} mm)"); continue; } // prefer test points, then the landing nearest a ground contact let score = if p.reference.starts_with("TP") { -1000.0 } else { 0.0 } + if reach.is_finite() { reach } else { 0.0 };- if best.as_ref().map(|b| score < b.3).unwrap_or(true) { best = Some((c.0, c.1, p.key.clone(), score)); }+ if best.as_ref().map(|b| score < b.3).unwrap_or(true) { best = Some((c.0, c.1, p.key.clone(), score, front)); } } } }+ } match best {- Some((x, y, pad, _)) => rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": if pad.starts_with("TP") { "test point" } else { "exposed pad" }, "spot": {"pad": pad, "x": (x * 100.0).round() / 100.0, "y": (y * 100.0).round() / 100.0}})),+ Some((x, y, pad, _, front)) => rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": if pad.starts_with("TP") { "test point" } else { "exposed pad" }, "face": if front { "top" } else { "bottom" }, "spot": {"pad": pad, "x": (x * 100.0).round() / 100.0, "y": (y * 100.0).round() / 100.0}})), None => { if !gaps.contains(&net.to_string()) { gaps.push(net.to_string()); } rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": "gap", "why": why})); } } }@@ -226,6 +244,28 @@ mod tests { assert_eq!(gaps, vec!["VCC".to_string()]); assert!(rows[0]["why"].as_str().unwrap().contains("under U2"), "{rows:?}"); }+ #[test]+ fn bottom_pad_probed_from_below() {+ // a back-side test point under a front-side body: a gap from the top, a landing from below+ let b = Board::from_text("t.kicad_pcb", r#"(kicad_pcb (version 20240108) (generator "pcbnew")+ (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+ (net 0 "") (net 1 "VCC")+ (gr_rect (start 0 0) (end 12 12) (layer "Edge.Cuts"))+ (footprint "test:Pad" (layer "B.Cu") (at 5 5) (property "Reference" "TP1")+ (pad "1" smd circle (at 0 0) (size 1 1) (layers "B.Cu") (net 1 "VCC")))+ (footprint "test:Body" (layer "F.Cu") (at 5 5) (property "Reference" "U2")+ (fp_rect (start -3 -3) (end 3 3) (layer "F.Fab"))+ (pad "1" smd rect (at 4 4) (size 0.5 0.5) (layers "F.Cu"))))"#.to_string()).unwrap();+ let top = vec![json!({"id": "v", "net": "VCC", "kind": "dc"})];+ let (_, gaps, _) = check_board(&top, &b);+ assert_eq!(gaps, vec!["VCC".to_string()], "probed from the top, a back pad is no landing");+ let plan = json!({"probeFace": "bottom", "tests": [{"id": "v", "net": "VCC", "kind": "dc"}]});+ let (rows, gaps, _) = check_board(&tests_of(&plan), &b);+ assert!(gaps.is_empty(), "{rows:?}");+ assert_eq!(rows[0]["face"], "bottom");+ assert_eq!(rows[0]["spot"]["pad"], "TP1.1");+ }+ #[test] fn netlist_as_object_like_the_fusion_steps() { // the Fusion steps' shape: nets {name: [pins]}; read the same, written back the same
crates/aiflow-analyze/src/lib.rs+5@@ -10,6 +10,7 @@ pub mod current_acks; pub mod fields_report; pub mod lead_cooling; pub mod package_cooling;+pub mod plane_backed; pub mod pour_cover; pub mod solved_ampacity; use std::collections::BTreeMap;@@ -191,6 +192,8 @@ pub fn analyze_thermal_on_board(plan: &Value, spec: &Value, zone_state: Option<& for r in results.iter_mut().filter(|r| r["cooling"] == lead_cooling::LEADS) { if let Err(e) = lead_cooling::check_footprint(board, r["ref"].as_str().unwrap_or("")) { r["status"] = json!("fail"); r["hint"] = json!(e); } }+ // a tab pour over a solid plane on the next copper layer: the single-layer estimate does not apply+ plane_backed::classify(&mut results, board); // issue #67: a declared board-face sink, for the parts it names only if let Err(e) = board_sink::apply(&mut results, spec, board, zone_state) { results.push(json!({"ref": "boardSink", "status": "fail", "hint": e})); } (results.iter().all(|r| r["status"] == "pass"), results)@@ -238,10 +241,12 @@ fn thermal_with_vias(plan: &Value, spec: &Value, zone_state: Option<&Value>, boa ok = false; } else if theta > limit { r["status"] = json!("fail");+ if theta_limit.is_none() { r["estimateFailed"] = json!(true); } r["hint"] = json!(format!("{reference}: {area:.0} mm2 of copper on {net} gives about {theta:.0} C/W (limit {limit:.0}): widen the pour or add the other outer layer with vias")); ok = false; } else if rise_limit.is_some_and(|l| rise > l) { r["status"] = json!("fail");+ r["estimateFailed"] = json!(true); r["hint"] = json!(format!("{reference}: {watts:.2} W into {area:.0} mm2 of {net} copper is about {rise:.0} C above ambient (budget {:.0} C): widen the pour or add the other outer layer with vias", rise_limit.unwrap())); ok = false; } else if vias == 0 && layers.len() >= 2 {
crates/aiflow-analyze/src/plane_backed.rsadded+158@@ -0,0 +1,158 @@+//! A tab part whose tab pour sits over a solid plane on the next copper layer (a 4-layer board with+//! an inner ground plane a prepreg's thickness under the hot loop). The tab estimate in+//! `thermal_with_vias` is the single-layer rule (40 C/W per square inch of the tab net's own+//! copper); it gives no credit for the heat that crosses the thin dielectric into the plane and+//! spreads over the whole board, so on such a board it can read three or four times the solved+//! rise. A part found plane-backed here is not passed on that estimate either: like a lead-cooled+//! part (#116) it waits for the same-input Fields solve, which models the stack, the plane and+//! every neighbour's heat, and which the field screen already holds to the part's rise budget.+use aiflow_board::geom::point_in_poly;+use aiflow_board::{copper_depth, copper_layer, Board, Pt};+use serde_json::{json, Value};++/// The share of the tab pour (the fill islands of the tab net that hold the tab pad) that must have+/// solid copper of another net directly under it on the next copper layer.+pub const MIN_BACKED_FRACTION: f64 = 0.6;+const STEP_MM: f64 = 0.2;++/// The copper layer next to `layer` in the stack, among the board's copper layers.+fn adjacent(board: &Board, layer: usize) -> Option<usize> {+ let d = copper_depth(layer);+ let mut layers: Vec<usize> = board.copper_layers.iter().filter_map(|n| copper_layer(n)).collect();+ layers.sort_by_key(|l| copper_depth(*l));+ let i = layers.iter().position(|l| *l == layer)?;+ if d == 0 { layers.get(i + 1).copied() } else { i.checked_sub(1).map(|j| layers[j]) }+}++fn bbox(poly: &[Pt]) -> (f64, f64, f64, f64) {+ poly.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)))+}++/// Measure how much of `reference`'s tab pour on `net` has another net's solid fill under it.+/// Returns (tab layer name, plane layer name, plane nets, pour mm2, backed mm2), or None when the+/// tab pad is not in a fill of its net on an outer layer.+pub fn measure(board: &Board, reference: &str, net: &str) -> Option<(String, String, Vec<String>, f64, f64)> {+ let fills = board.copper_fills();+ let pads: Vec<_> = board.pads.iter().filter(|p| p.reference == reference && p.net == net).collect();+ for layer in [aiflow_board::F_CU, aiflow_board::B_CU] {+ let on: Vec<_> = pads.iter().filter(|p| p.on_copper(layer)).collect();+ if on.is_empty() { continue; }+ let tab: Vec<&Vec<Pt>> = fills.iter().filter(|(n, l, poly)| n == net && *l == layer && on.iter().any(|p| point_in_poly(p.x, p.y, poly))).map(|(_, _, poly)| poly).collect();+ if tab.is_empty() { continue; }+ let adj = adjacent(board, layer)?;+ let plane: Vec<(&str, &Vec<Pt>)> = fills.iter().filter(|(n, l, _)| n != net && !n.is_empty() && *l == adj).map(|(n, _, poly)| (n.as_str(), poly)).collect();+ let (mut inside, mut backed) = (0usize, 0usize);+ let mut nets: Vec<String> = Vec::new();+ for poly in &tab {+ let (x0, y0, x1, y1) = bbox(poly);+ let mut y = y0 + STEP_MM / 2.0;+ while y < y1 {+ let mut x = x0 + STEP_MM / 2.0;+ while x < x1 {+ if point_in_poly(x, y, poly) {+ inside += 1;+ if let Some((n, _)) = plane.iter().find(|(_, q)| point_in_poly(x, y, q)) {+ backed += 1;+ if !nets.iter().any(|m| m == n) { nets.push(n.to_string()); }+ }+ }+ x += STEP_MM;+ }+ y += STEP_MM;+ }+ }+ let cell = STEP_MM * STEP_MM;+ let name = |l: usize| board.copper_layers.iter().find(|n| copper_layer(n) == Some(l)).cloned().unwrap_or_default();+ return Some((name(layer), name(adj), nets, inside as f64 * cell, backed as f64 * cell));+ }+ None+}++/// Rows that failed only the single-layer tab estimate and are plane-backed become pending on the+/// Fields solve. Other failures (no pour, no vias between the pour's layers, a theta ceiling the+/// spec set) are left as they are.+pub fn classify(results: &mut [Value], board: &Board) {+ for r in results.iter_mut().filter(|r| r["status"] == "fail" && r["estimateFailed"] == true) {+ let reference = r["ref"].as_str().unwrap_or("").to_string();+ let net = r["tabNet"].as_str().unwrap_or("").to_string();+ let Some((tab_layer, plane_layer, nets, pour, backed)) = measure(board, &reference, &net) else { continue };+ let fraction = if pour > 0.0 { backed / pour } else { 0.0 };+ r["planeBacked"] = json!({"tabLayer": tab_layer, "planeLayer": plane_layer, "planeNets": nets, "tabPourMm2": (pour * 10.0).round() / 10.0, "backedMm2": (backed * 10.0).round() / 10.0, "fraction": (fraction * 100.0).round() / 100.0, "minFraction": MIN_BACKED_FRACTION});+ if fraction >= MIN_BACKED_FRACTION {+ let estimate = r["riseEstimateC"].clone();+ r["status"] = json!("pending-fields");+ r["hint"] = json!(format!("{reference}: {:.0} % of its {net} tab pour on {tab_layer} lies over solid {} on {plane_layer}; the single-layer estimate (+{estimate} C) leaves that plane out, so it passes only on the same-input Fields solve within its budget", fraction * 100.0, nets.join("+")));+ }+ }+}++/// After the same-input field screen passed (which holds every chip to its rise budget): each+/// pending plane-backed row takes its solved rise.+pub fn accept_solve(results: &mut [Value], fields: &Value) -> Vec<String> {+ let mut notes = Vec::new();+ for r in results.iter_mut().filter(|r| r["status"] == "pending-fields" && r["planeBacked"].is_object()) {+ let reference = r["ref"].as_str().unwrap_or("").to_string();+ let row = fields["chips"].as_array().into_iter().flatten().find(|c| c["reference"] == reference.as_str() && c["tabNet"] == r["tabNet"]);+ match row {+ Some(c) => {+ r["status"] = json!("pass");+ r["judgedBy"] = json!("fields-solve");+ r["riseC"] = c["riseC"].clone();+ r["riseMovingC"] = c["riseMovingC"].clone();+ r["how"] = json!(format!("plane-backed tab ({} of the {} pour over {} on {}): the same-input Fields solve puts it at +{} C still air, +{} C moving air, within its budget; the single-layer estimate was +{} C", r["planeBacked"]["fraction"], r["tabNet"].as_str().unwrap_or(""), r["planeBacked"]["planeNets"], r["planeBacked"]["planeLayer"].as_str().unwrap_or(""), c["riseC"], c["riseMovingC"], r["riseEstimateC"]));+ if let Some(o) = r.as_object_mut() { o.remove("hint"); }+ notes.push(format!("{reference}: plane-backed tab, judged by the Fields solve"));+ }+ None => {+ r["status"] = json!("fail");+ r["hint"] = json!(format!("{reference}: the Fields report has no row for it on {}; solve again on this saved board and spec", r["tabNet"].as_str().unwrap_or("")));+ }+ }+ }+ notes+}++#[cfg(test)]+mod tests {+ use super::*;++ fn board(plane: bool) -> Board {+ let gnd = if plane { r#"(zone (net 2) (net_name "GND") (layer "In1.Cu") (polygon (pts (xy 0 0) (xy 30 0) (xy 30 20) (xy 0 20))) (filled_polygon (layer "In1.Cu") (pts (xy 0 0) (xy 30 0) (xy 30 20) (xy 0 20))))"# } else { "" };+ Board::from_text("t.kicad_pcb", format!(r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (2 "B.Cu" signal)) (net 0 "") (net 1 "SW") (net 2 "GND")+ (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.05))+ (footprint "D" (layer "F.Cu") (at 10 10) (property "Reference" "D1") (pad "3" smd rect (at 0 0) (size 3 3) (layers "F.Cu") (net 1 "SW")))+ (zone (net 1) (net_name "SW") (layer "F.Cu") (polygon (pts (xy 5 5) (xy 15 5) (xy 15 15) (xy 5 15))) (filled_polygon (layer "F.Cu") (pts (xy 5 5) (xy 15 5) (xy 15 15) (xy 5 15))))+ {gnd})"#)).unwrap()+ }++ #[test]+ fn plane_under_the_tab_pour_hands_the_part_to_the_solve() {+ let (tab, adj, nets, pour, backed) = measure(&board(true), "D1", "SW").unwrap();+ assert_eq!((tab.as_str(), adj.as_str()), ("F.Cu", "In1.Cu"));+ assert_eq!(nets, vec!["GND".to_string()]);+ assert!((pour - 100.0).abs() < 2.0 && (backed - pour).abs() < 1e-9, "{pour} {backed}");+ let mut rows = vec![json!({"ref": "D1", "tabNet": "SW", "status": "fail", "estimateFailed": true, "riseEstimateC": 200.0})];+ classify(&mut rows, &board(true));+ assert_eq!(rows[0]["status"], "pending-fields");+ // no solve row: it fails; a solve row: it takes the solved rise+ let mut none = rows.clone();+ accept_solve(&mut none, &json!({"chips": []}));+ assert_eq!(none[0]["status"], "fail");+ accept_solve(&mut rows, &json!({"chips": [{"reference": "D1", "tabNet": "SW", "riseC": 60.0, "riseMovingC": 40.0}]}));+ assert_eq!(rows[0]["status"], "pass");+ assert_eq!(rows[0]["judgedBy"], "fields-solve");+ }++ #[test]+ fn no_plane_keeps_the_estimate_failure() {+ let mut rows = vec![json!({"ref": "D1", "tabNet": "SW", "status": "fail", "estimateFailed": true, "riseEstimateC": 200.0})];+ classify(&mut rows, &board(false));+ assert_eq!(rows[0]["status"], "fail");+ assert_eq!(rows[0]["planeBacked"]["fraction"], 0.0);+ // other failures are never reclassified+ let mut other = vec![json!({"ref": "D1", "tabNet": "SW", "status": "fail", "riseEstimateC": 200.0})];+ classify(&mut other, &board(true));+ assert_eq!(other[0]["status"], "fail");+ assert!(other[0]["planeBacked"].is_null());+ }+}
docs/plane-backed-tabs.mdadded+20@@ -0,0 +1,20 @@+# Plane-backed tabs++`analyze thermal` estimates a tab part with the single-layer rule: about 40 C/W per square inch of the tab+net's own copper. On a multilayer board with a solid plane one prepreg under the hot loop (In1.Cu ground+under a converter's switch node, the layout aiflow-power-layout recommends) that rule leaves out the heat+that crosses the 0.1 to 0.2 mm dielectric into the plane and spreads over the whole board, so it can read+three or four times the solved rise.++A tab part whose single-layer estimate fails is measured on the saved board: the fill islands of its tab+net that hold the tab pad, on the tab's outer layer, are sampled on a 0.2 mm grid against the solid fills+of other nets on the next copper layer. When at least 60 % of the tab pour lies over such a plane, the+row is `pending-fields` with a `planeBacked` record (layers, plane nets, areas, fraction) and passes only+on the same-input Fields solve, which models the stack, the plane and every neighbour's heat and is held+to the part's rise budget by the field screen, exactly as a lead-cooled part (`lead-cooled-parts.md`).+A part with no plane under its pour, a missing pour, a theta ceiling set in the spec, or two pour layers+with no via keeps its failure.++Worked case (fan controller, 2026-11-02): an SBR10U45SP5 freewheel diode at 0.82 W on 107 mm2 of SW+copper read +197 C single-layer; the pour lies 100 % over In1.Cu ground and the Fields solve puts it at++63 C still air, +40 C moving air.
skills/aiflow-molecule/SKILL.md+2@@ -199,3 +199,5 @@ Record it as `programming` in board-release.json beside the board (or `programmi ``` `interface` is `swd`, `uart-boot` or `usb-dfu`; `pins` maps each interface signal to the reference that carries it; `target` is the MPN being programmed. A board programmed over its own USB connector instead (an ESP32-S3 or C3 with native USB Serial/JTAG) declares `"interface": "usb-serial-jtag", "access": "usb-cable"` with `connector` (the USB connector's reference), `targetRef` and `target` (the ESP32), `boot` (how to force download mode) and `connectorPins` mapping `D+`, `D-`, `GND` and `VBUS` to `{"pads": [...], "net": "..."}`; the check confirms those pads carry those four distinct nets, and no robot contacts are needed. `step schematic` and `step placement` print the Hint; `finish` refuses the board when a programmable part has no `programming` record, a required pin is missing, or a pin is neither a machine contact nor a labelled test pad.++A board with several programmable parts (four MCUs, or an MCU beside an ESP32 on its own USB) records `programming` as a list, one record per interface, each naming the part(s) it programs with `targetRef` or `targetRefs` (their part number must be its `target`); `finish` refuses a programmable part no record names. SWD over a Tag-Connect footprint (TC2030 / TC2050, a pad set made for a spring-pin cable) declares `"access": "tag-connect"` with `connector` (the Tag-Connect footprint), `targetRef`, `target` and `connectorPins` mapping `SWDIO`, `SWCLK`, `NRST` and `GND` to `{"pads": [...], "net": "..."}`; the check confirms the pads carry those nets and that the SWD nets reach the part, like the USB-cable record.
skills/aiflow-test-plan/SKILL.md+5@@ -32,6 +32,11 @@ Kinds: `dc`, `ripple`, `waveform` need a probe landing; `camera` (an LED, a disp `ground` is the tether's machine contact. Typical for a regulator: the input at the board, the output, the switch node, both inductor ends, feedback, enable, power-good, the bulk caps' ripple, every LED. +Probe face: a test is probed from the top unless it says otherwise. `"face": "bottom"` (or `"back"`) on a+test, or `"probeFace": "bottom"` beside `"tests"` for the whole plan, lands it on bottom-side copper only (a+board whose top is wall-to-wall parts, probed from below); `"both"` accepts either face. `testplan check`+reports the face each landing was found on.+ ## 2. Give every measurement a spot (before the schematic) `adom-aiflow testplan check --tests tests.json --netlist <netlist.json> --add` maps each measurement to
tools/project-page.py+21@@ -311,6 +311,26 @@ def normalise_sym_header(text, version): return re.sub(r"(\(kicad_symbol_lib\s*\(version\s+)(\d+)(\))", lambda m: f"{m.group(1)}{version}{m.group(3)}", text, count=1) +def unit_names_follow_parent(text):+ """KiCad names a symbol's units <parent>_<unit>_<style> and refuses a library whose units carry another name+ (a wiki symbol 'Lib:C_1nF_0402' renamed to its MPN keeps units 'C_1nF_0402_0_1'). Rename every top-level+ symbol's units to follow the symbol."""+ root = sx_parse(text)+ edits = []+ for l in root[1:]:+ if not (isinstance(l, list) and l[:1] == ["symbol"] and len(l) > 1 and isinstance(l[1], str)):+ continue+ parent = l[1]+ s, e = l.span+ for m in re.finditer(r'\(symbol\s+"([^"]+)_(\d+)_(\d+)"', text[s:e]):+ if m.start() == 0 or m.group(1) == parent:+ continue+ edits.append((s + m.start(1), s + m.end(1), parent))+ for a, b, parent in sorted(edits, reverse=True):+ text = text[:a] + parent + text[b:]+ return text++ def top_symbols(text): """{name: (start, end)} of the library's top-level symbols.""" root = sx_parse(text)@@ -547,6 +567,7 @@ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts # one header for every symbol: the newest syntax any of them uses, at least KiCad 9's header = max([SYM_VERSION] + versions) t = normalise_sym_header(t, header)+ t = unit_names_follow_parent(t) # the symbols point at the footprints by library nickname: PCM installs this library as PCM_<lib> t = re.sub(r'(\(property\s+"Footprint"\s+")([^":]*):', lambda mm: f'{mm.group(1)}PCM_{lib}:', t) open(sp, "w").write(t)