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.
main
John Lauer
Merge PR #384: Bottom probing, plane-backed tabs, multi-part programming, macOS walkthroughs, BOM/silk/fingerprint fixes
5498d4b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
//! 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());
}
}