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