← Commit history

Require local copper coverage and consistent distance for thermal vias; preserve native cooling gates (#38)

John Lauer ·1bc0e48412 · ·parent 706d952
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"   ]-}\ No newline at end of file+}
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.