main
Caleb Publish 0.5.0 1984b40
//! How sure each part candidate is, so the list can be read from the top instead of all at once. `detect parts` writes every
//! candidate; this gives each a `confidence` from 0 to 1 and, where something counts against it, a `doubt`.
//!
//! The ratings are rules, not measured probabilities. They were laid out on the left half of board 10's top photo (2026-11-10),
//! where 67 of 158 candidates sat on a real part: of the candidates each rule covers, what share did. A chip passive read body
//! first brings its own rating (`passives::Passive::confidence`).
use crate::geom;
use serde_json::{json, Value};

/// At or above this a candidate is taken into parts.json by `parts init`; below it, it waits in `unassigned`, most sure first.
pub const SURE: f64 = 0.5;
/// A row of plated holes at a connector pitch, and a large square pad with a hole: measured from holes, which print and solder do not imitate.
const HOLE_ROW: f64 = 0.9; const LUG: f64 = 0.8;
/// A dark or tan body that matches a package; one that does not, by its longer side: 3 mm and over (a moulding too large for the
/// package table), 0.45 mm and over (often a piece of a real chip part), smaller (texture).
const BODY_FITS: f64 = 0.7; const BODY_LARGE: f64 = 0.6; const BODY_SMALL: f64 = 0.5; const BODY_SPECK: f64 = 0.25;
/// Yellow or bright green plastic: a connector when it fits or is large, else a fragment.
const PLASTIC: f64 = 0.5; const PLASTIC_PIECE: f64 = 0.3;
/// A pale body is print, bare tin, a lead or a pad far more often than a part: on that half board 18 of 80 sat on one.
const PALE_FITS: f64 = 0.4; const PALE_LARGE: f64 = 0.3; const PALE: f64 = 0.15;
/// What counts against a candidate, as a factor: its centre on a plated hole's pad, within `EDGE_MM` of the board edge, or inside
/// a body five times its own area (a marking, a lead or a pin of that part).
const ON_HOLE: f64 = 0.3; const AT_EDGE: f64 = 0.4; const INSIDE: f64 = 0.5;
const EDGE_MM: f64 = 0.8; const BIGGER: f64 = 5.0;

fn f(v: &Value, k: &str) -> f64 { v[k].as_f64().unwrap_or(0.0) }
fn size(c: &Value) -> (f64, f64) { (c["size_mm"][0].as_f64().unwrap_or(0.0), c["size_mm"][1].as_f64().unwrap_or(0.0)) }
fn at(c: &Value) -> (f64, f64) { (c["pos_mm"][0].as_f64().unwrap_or(0.0), c["pos_mm"][1].as_f64().unwrap_or(0.0)) }
fn from_holes(c: &Value) -> bool { matches!(c["source"].as_str(), Some("hole row") | Some("large square pad")) }

/// Rate every candidate in place. `holes` are the plated holes of the top and the bottom face (board mm, as detect/holes-*.json has
/// them); `board_mm` the board's width and height when the scale is known.
pub fn rate(cands: &mut [Value], holes: (&[Value], &[Value]), board_mm: Option<(f64, f64)>) {
    let o = crate::board::ORIGIN;
    for c in cands.iter_mut() {
        let (l, w) = size(c); let long = l.max(w); let fits = c["fits"] == json!(true); let colour = c["colour"].as_str().unwrap_or("");
        let mut conf = if c["method"] == json!("body") { c["confidence"].as_f64().unwrap_or(BODY_SMALL) }
            else if c["source"] == json!("hole row") { HOLE_ROW } else if c["source"] == json!("large square pad") { LUG }
            else { match colour {
                "black" | "tan" => if fits { BODY_FITS } else if long >= 3.0 { BODY_LARGE } else if long >= 0.45 { BODY_SMALL } else { BODY_SPECK },
                "yellow" | "bright-green" => if fits || long >= 3.0 { PLASTIC } else { PLASTIC_PIECE },
                _ => if fits { PALE_FITS } else if long >= 3.0 { PALE_LARGE } else { PALE },
            } };
        let mut doubt: Vec<String> = vec![]; let p = at(c);
        if !from_holes(c) {
            let face = if c["side"] == json!("bottom") { holes.1 } else { holes.0 };
            if face.iter().any(|h| geom::dist(p, (f(h, "x"), f(h, "y"))) <= f(h, "outer_mm").max(f(h, "hole_mm")) / 2.0 + 0.1) { conf *= ON_HOLE; doubt.push("on a plated hole's pad".into()); }
        }
        if let Some((bw, bh)) = board_mm { if p.0 - o < EDGE_MM || p.1 - o < EDGE_MM || o + bw - p.0 < EDGE_MM || o + bh - p.1 < EDGE_MM { conf *= AT_EDGE; doubt.push(format!("within {EDGE_MM} mm of the board edge")); } }
        c["confidence"] = json!((conf * 100.0).round() / 100.0);
        if !doubt.is_empty() { c["doubt"] = json!(doubt); }
    }
    // inside a bigger body of the same face: that body's marking, lead or pin. A pale body is not a host (print boxes hold real
    // parts' labels, not parts), nor is a chip passive.
    let hosts: Vec<(String, String, (f64, f64), (f64, f64), f64)> = cands.iter().filter(|b| !from_holes(b) && b["method"] != json!("body") && !matches!(b["colour"].as_str(), Some("white") | None))
        .map(|b| (b["id"].as_str().unwrap_or("").to_string(), b["side"].as_str().unwrap_or("").to_string(), at(b), size(b), f(b, "rot"))).filter(|h| h.3 .0 * h.3 .1 > 0.0).collect();
    for c in cands.iter_mut() {
        if from_holes(c) { continue; }
        let (l, w) = size(c); let area = (l * w).max(0.05); let p = at(c); let side = c["side"].as_str().unwrap_or("").to_string(); let id = c["id"].as_str().unwrap_or("").to_string();
        let host = hosts.iter().find(|h| h.0 != id && h.1 == side && h.3 .0 * h.3 .1 >= BIGGER * area && {
            // the candidate's rot is KiCad's, counter-clockwise on screen; the board frame's y runs down
            let (co, si) = ((-h.4).to_radians().cos(), (-h.4).to_radians().sin()); let (dx, dy) = (p.0 - h.2 .0, p.1 - h.2 .1);
            (dx * co + dy * si).abs() <= h.3 .0 / 2.0 && (-dx * si + dy * co).abs() <= h.3 .1 / 2.0 });
        if let Some(h) = host {
            c["confidence"] = json!((c["confidence"].as_f64().unwrap_or(0.0) * INSIDE * 100.0).round() / 100.0);
            let mut d: Vec<Value> = c["doubt"].as_array().cloned().unwrap_or_default(); d.push(json!(format!("inside {}", h.0))); c["doubt"] = json!(d);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    fn cand(id: &str, x: f64, y: f64, l: f64, w: f64, colour: &str, fits: bool) -> Value { json!({"id": id, "side": "top", "pos_mm": [x, y], "size_mm": [l, w], "rot": 0.0, "colour": colour, "fits": fits, "source": "body 1"}) }
    #[test]
    fn print_and_fragments_rate_below_parts() {
        let mut c = vec![
            cand("P001", 110.0, 110.0, 6.0, 6.0, "black", true),      // an IC
            cand("P002", 111.0, 111.0, 0.8, 0.3, "white", false),     // its marking
            cand("P003", 120.0, 105.0, 1.6, 0.8, "white", true),      // print the size of an 0603
            cand("P004", 120.0, 110.0, 1.1, 0.6, "black", false),     // a piece of a resistor
            cand("P005", 130.0, 110.0, 1.0, 0.5, "tan", true),        // on a hole's pad
            cand("P006", 100.3, 115.0, 1.6, 0.8, "black", true),      // at the board edge
            json!({"id": "P007", "side": "top", "pos_mm": [125.0, 125.0], "size_mm": [1.0, 0.5], "rot": 0.0, "kind": "tan", "colour": "tan", "fits": true, "method": "body", "confidence": 0.93}),
            json!({"id": "P008", "side": "top", "pos_mm": [130.0, 110.0], "rot": 0.0, "fits": true, "source": "hole row"}),
        ];
        let holes = [json!({"x": 130.0, "y": 110.0, "outer_mm": 1.6, "hole_mm": 0.9})];
        rate(&mut c, (&holes, &[]), Some((40.0, 40.0)));
        let conf: Vec<f64> = c.iter().map(|x| x["confidence"].as_f64().unwrap()).collect();
        assert_eq!(conf, vec![0.7, 0.08, 0.4, 0.5, 0.21, 0.28, 0.93, 0.9]);
        assert_eq!(c[1]["doubt"], json!(["inside P001"]));
        assert_eq!(c[4]["doubt"], json!(["on a plated hole's pad"]));
        assert!(c[5]["doubt"][0].as_str().unwrap().contains("board edge"));
        // what parts init takes: the IC, the resistor piece, the passive, the header
        assert_eq!(c.iter().filter(|x| x["confidence"].as_f64().unwrap() >= SURE).count(), 4);
    }
}