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
Require local copper coverage and consistent distance for thermal vias; preserve native cooling gates (#38)
7 files changed
+100−16
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.70"+version = "0.1.71" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.70"+version = "0.1.71" dependencies = [ "serde", "serde_json",@@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.70"+version = "0.1.71" dependencies = [ "roxmltree", "serde",@@ -42,7 +42,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.70"+version = "0.1.71" dependencies = [ "aiflow-board", "serde",@@ -51,7 +51,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.70"+version = "0.1.71" dependencies = [ "aiflow-board", "aiflow-grid",@@ -61,7 +61,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.70"+version = "0.1.71" dependencies = [ "aiflow-board", "serde",@@ -70,7 +70,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.70"+version = "0.1.71" dependencies = [ "aiflow-board", "serde",@@ -79,7 +79,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.70"+version = "0.1.71" dependencies = [ "aiflow-board", "aiflow-copper",@@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.70"+version = "0.1.71" dependencies = [ "aiflow-board", "aiflow-grid",@@ -99,7 +99,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.70"+version = "0.1.71" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.70"+version = "0.1.71" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+5@@ -328,3 +328,8 @@ For high-speed nets, explicitly define capacitance or differential-pair checks w `land pours` delegates the complete owned-zone plan to KiCad Bridge in one native transaction. Never replace another tool's zones or infer ownership from names, areas or absence in the intake board. Use Bridge 1.0.24+; no legacy delete/create fallback. An unchanged plan is not a DRC-clean certificate. Refill acknowledgement is asynchronous. Request it once, then read without refill until two delayed revision/area snapshots agree. This proves observed stability, not a native completion event. Do not use the acknowledgement's old geometry; do not replay a timed-out or post-commit-failed mutation. Inspect the saved response, exact board and current revision. Measurement failure must remain a failed outcome. See docs/native-zone-landing.md.+++## Local thermal-via planning++Thermal-via candidates and existing-via counts must be within 3 mm of the specified heat pads and fit completely inside a planned same-net copper region on each outer layer. A remote same-net region does not satisfy this requirement. Missing polygon coordinates use the board outline; malformed or missing geometry refuses coverage. This is a conservative geometric prerequisite, not proof of native filled connectivity or thermal capacity: preflight with native refill/DRC and verify the actual heat path before landing. A TO-220 case-mounted heatsink requires an explicit package/interface/sink/airflow analysis; adding board vias cannot replace that path. Shortfalls remain visible and do not justify changing temperature limits or acknowledging unverified cooling.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/thermalvias.rs+77−3@@ -33,6 +33,26 @@ fn seg_dist(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 { (p.0 - a.0 - t * dx).hypot(p.1 - a.1 - t * dy) } +/// Planned region coverage is a necessary condition, not proof of native filled+/// connectivity. Keep the entire via inside one region on each outer layer.+fn local_pours(pours: &[Value], net: &str, c: (f64, f64), radius: f64, outline: &[(f64, f64)]) -> bool {+ ["F.Cu", "B.Cu"].iter().all(|layer| pours.iter().any(|p| {+ if p["net"].as_str() != Some(net)+ || !p["layers"].as_array().is_some_and(|ls| ls.iter().any(|l| l.as_str() == Some(*layer))) {+ return false;+ }+ let poly: Vec<_> = match p.get("polygon") {+ Some(value) => match value.as_array().and_then(|points| points.iter().map(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect::<Option<Vec<_>>>()) {+ Some(points) => points,+ None => return false,+ },+ None => outline.to_vec(),+ };+ poly.len() >= 3 && point_in(&poly, c)+ && (0..poly.len()).all(|i| seg_dist(c, poly[i], poly[(i + 1) % poly.len()]) >= radius)+ }))+}+ pub struct Part { pub reference: String, pub net: String, pub target: usize, pub existing: usize, pub planned: Vec<(f64, f64)>, pub note: Option<String> } /// Plan the vias. `poured_both` says whether a net has copper on both outer layers (a via only moves@@ -70,7 +90,8 @@ pub fn plan(spec: &Value, b: &Board, sw_nets: &[String]) -> (Vec<Part>, Value) { if !poured_both(&net) { part.note = Some(format!("{net} has no pour on both layers: a via would carry the heat nowhere (pour {net} on B.Cu under {reference}, or ack)")); parts.push(part); continue; } // vias of the net already within 3 mm of the part's heat pads let near = |x: f64, y: f64| pads.iter().any(|p| (p.x - x).hypot(p.y - y) < 3.0);- part.existing = vias.iter().filter(|v| v.net == net && near(v.x, v.y)).count();+ part.existing = vias.iter().filter(|v| v.net == net && near(v.x, v.y)+ && local_pours(&pours, &net, (v.x, v.y), v.size / 2.0, &b.outline)).count(); let need = tgt.saturating_sub(part.existing); // candidate spots on rings around each heat pad, nearest first let mut cands: Vec<(f64, (f64, f64))> = Vec::new();@@ -88,6 +109,9 @@ pub fn plan(spec: &Value, b: &Board, sw_nets: &[String]) -> (Vec<Part>, Value) { let rr = size / 2.0; for (_, c) in cands { if part.planned.len() >= need { break; }+ // Use the same neighbourhood as the post-landing count. A remote+ // same-net pour, or a farther ring, cannot satisfy this heat pad.+ if !near(c.0, c.1) || !local_pours(&pours, &net, c, rr, &b.outline) { continue; } // every edge of the outline, the closing one too (a rounded corner is many short ones) let on = b.outline.len(); if on > 0 && (!point_in(&b.outline, c) || (0..on).any(|k| seg_dist(c, b.outline[k], b.outline[(k + 1) % on]) < rr + edge)) { continue; }@@ -100,7 +124,7 @@ pub fn plan(spec: &Value, b: &Board, sw_nets: &[String]) -> (Vec<Part>, Value) { placed.push(c); part.planned.push(c); holes.push((c.0, c.1, size, drill, net.clone())); entries.push(json!({"net": net, "paths": [[[c.0, c.1], {"layer": "B.Cu", "x": c.0, "y": c.1}]], "viaSize": size, "viaDrill": drill, "width": size, "why": format!("heatsink via for {reference}")})); }- if part.existing + part.planned.len() < tgt { part.note = Some(format!("room for {} of {} vias near {reference}: move neighbours or widen its {net} copper", part.existing + part.planned.len(), tgt)); }+ if part.existing + part.planned.len() < tgt { part.note = Some(format!("room for {} of {} vias within 3 mm of {reference} and inside both planned {net} copper regions: review local copper and cooling; native refill/connectivity still required", part.existing + part.planned.len(), tgt)); } parts.push(part); } (parts, json!({"engine": "adom-aiflow thermal-vias plan", "nets": entries}))@@ -110,6 +134,7 @@ pub fn plan(spec: &Value, b: &Board, sw_nets: &[String]) -> (Vec<Part>, Value) { pub fn short(spec: &Value, b: &Board, acked: &[String]) -> Vec<(String, usize, usize)> { let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0); let vias = b.vias();+ let pours = spec["pours"].as_array().cloned().unwrap_or_default(); let mut out = Vec::new(); for (reference, h) in spec["hot"].as_object().cloned().unwrap_or_default() { if acked.contains(&reference) { continue; }@@ -117,7 +142,8 @@ pub fn short(spec: &Value, b: &Board, acked: &[String]) -> Vec<(String, usize, u let tgt = target(h["watts"].as_f64().unwrap_or(0.0), h["maxRiseC"].as_f64().unwrap_or(max_rise)); let pads: Vec<_> = b.pads.iter().filter(|p| p.reference == reference && p.net == net).collect(); if pads.is_empty() { continue; }- let have = vias.iter().filter(|v| v.net == net && pads.iter().any(|p| (p.x - v.x).hypot(p.y - v.y) < 3.0)).count();+ let have = vias.iter().filter(|v| v.net == net && pads.iter().any(|p| (p.x - v.x).hypot(p.y - v.y) < 3.0)+ && local_pours(&pours, net, (v.x, v.y), v.size / 2.0, &b.outline)).count(); if have < tgt { out.push((reference, have, tgt)); } } out@@ -125,6 +151,54 @@ pub fn short(spec: &Value, b: &Board, acked: &[String]) -> Vec<(String, usize, u #[cfg(test)] mod tests {+ #[test]+ fn local_regions_reject_remote_disjoint_and_edge_candidates() {+ let pours = serde_json::json!([+ {"net":"LOAD", "layers":["F.Cu","B.Cu"], "polygon":[[0,0],[4,0],[4,4],[0,4]]}+ ]);+ let p = pours.as_array().unwrap();+ assert!(super::local_pours(p, "LOAD", (2.0, 2.0), 0.3, &[]));+ assert!(!super::local_pours(p, "LOAD", (10.0, 10.0), 0.3, &[]));+ assert!(!super::local_pours(p, "LOAD", (0.1, 2.0), 0.3, &[]));+ assert!(!super::local_pours(p, "OTHER", (2.0, 2.0), 0.3, &[]));+ let disjoint = serde_json::json!([+ {"net":"LOAD","layers":["F.Cu"],"polygon":[[0,0],[4,0],[4,4],[0,4]]},+ {"net":"LOAD","layers":["B.Cu"],"polygon":[[8,8],[12,8],[12,12],[8,12]]}+ ]);+ assert!(!super::local_pours(disjoint.as_array().unwrap(), "LOAD", (2.0, 2.0), 0.3, &[]));+ let malformed = serde_json::json!([{"net":"LOAD","layers":["F.Cu","B.Cu"],"polygon":[[0,0]]}]);+ assert!(!super::local_pours(malformed.as_array().unwrap(), "LOAD", (2.0, 2.0), 0.3, &[]));+ }++ #[test]+ fn planner_and_short_use_same_local_neighbourhood() {+ let text = r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal))+ (gr_rect (start 0 0)(end 30 30)(layer "Edge.Cuts"))+ (footprint "x" (layer "F.Cu") (at 10 10) (property "Reference" "U1")+ (pad "1" smd rect (at 0 0)(size 1 1)(layers "F.Cu")(net 1 "GND"))))"#;+ let b = aiflow_board::Board::from_text("fixture", text.into()).unwrap();+ let mut spec = serde_json::json!({"hot":{"U1":{"watts":2.0,"tabNet":"GND"}},+ "pours":[{"net":"GND","layers":["F.Cu","B.Cu"],"polygon":[[8,8],[12,8],[12,12],[8,12]]}]});+ let (parts, _) = super::plan(&spec, &b, &[]);+ assert!(!parts[0].planned.is_empty());+ let mut landed = text[..text.len()-1].to_string();+ for &(x,y) in &parts[0].planned {+ assert!((x-10.0).hypot(y-10.0) < 3.0);+ landed.push_str(&format!("(via (at {x} {y})(size 0.6)(drill 0.3)(layers \"F.Cu\" \"B.Cu\")(net 1))"));+ }+ landed.push(')');+ let saved = aiflow_board::Board::from_text("landed", landed).unwrap();+ let short = super::short(&spec, &saved, &[]);+ if parts[0].planned.len() < parts[0].target {+ assert_eq!(short[0].1, parts[0].planned.len());+ } else { assert!(short.is_empty()); }+ spec["pours"][0]["polygon"] = serde_json::json!([[20,20],[25,20],[25,25],[20,25]]);+ let (remote, plan) = super::plan(&spec, &saved, &[]);+ assert_eq!(remote[0].existing, 0);+ assert!(plan["nets"].as_array().unwrap().is_empty());+ assert_eq!(super::short(&spec, &saved, &[])[0].1, 0);+ }+ #[test] fn targets_scale_with_power() { assert_eq!(super::target(0.25, 10.0), 6);
package.json+2−2@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.70",+ "version": "0.1.71", "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.",@@ -86,4 +86,4 @@ "aiflow-simulate", "aiflow-test-plan" ]-}+}
skills/aiflow-power-layout/SKILL.md+5@@ -83,3 +83,8 @@ Adopted boards: `route` re-plans every trace of an adopted, already-routed board ## Worked example: TPS54202 molecule Returns in the ledger tell the story: routing (VOUT sense slicing the plane), pours twice (SW neck and U1 heat path, then the patch throat). State at the time of writing (before `finish`): gate at 0 new DRC errors and 0 unconnected, current pass at 50 A/mm2 peak, thermal pass at U1 +44 C and L1 +41 C still air against a human-approved 55 C junction-based budget.+++## Local thermal-via planning++Thermal-via candidates and existing-via counts must be within 3 mm of the specified heat pads and fit completely inside a planned same-net copper region on each outer layer. A remote same-net region does not satisfy this requirement. Missing polygon coordinates use the board outline; malformed or missing geometry refuses coverage. This is a conservative geometric prerequisite, not proof of native filled connectivity or thermal capacity: preflight with native refill/DRC and verify the actual heat path before landing. A TO-220 case-mounted heatsink requires an explicit package/interface/sink/airflow analysis; adding board vias cannot replace that path. Shortfalls remain visible and do not justify changing temperature limits or acknowledging unverified cooling.