main
John Lauer Publish 0.2.3 6e27c4b
//! Reviewed acknowledgements of part-limited current-density peaks (issue #70).
//!
//! The Fields screen in `analyze current` refuses any net whose peak density is over the screen
//! (60 A/mm2 by default). Some peaks sit on a part's own land (a SOT-23 drain pad, a connector's
//! last millimetre) that the board cannot change. `analyze current --ack [email protected] --why ...` or
//! `--ack NET@x,y --why ...` records one reviewed point: the net, the place, the peak Fields
//! reported there, the reason and the ai thread. The number keeps being reported.
//!
//! An acknowledgement covers an over-screen point when all of these hold:
//! - same net (never a whole net: a location or a pad is required);
//! - the point lies inside the acknowledged pad's copper or within `TOLERANCE_MM` of it, or within
//!   `TOLERANCE_MM` of the acknowledged x,y;
//! - its density is not above the reviewed peak by more than `PEAK_SLACK` (1 percent);
//! - the board's copper fingerprint (the caller's: adom-aiflow passes copper_carry's, the same one
//!   that carries Fields evidence across a silkscreen-only adoption) and the Fields spec identity
//!   are the ones the review saw. Any copper or rules change, or any spec change, expires it.
//!
//! Fields reports one maximum per net. Once that point is acknowledged, a second over-screen spot on
//! the same net is not visible in the report; the reviewer checks the density map for it.

use aiflow_board::geom::{point_poly_distance, Pt};
use aiflow_board::Board;
use serde_json::{json, Value};

/// How far from the acknowledged pad copper or x,y an over-screen point may lie and still be
/// covered: a few Fields cells and a land's edge.
pub const TOLERANCE_MM: f64 = 0.5;
/// How much the density at an acknowledged point may exceed the reviewed peak (rounding, solver noise).
pub const PEAK_SLACK: f64 = 0.01;

/// An over-screen point Fields reported: a net's peak, or a current-neck finding.
#[derive(Debug, Clone)]
pub struct Point { pub net: String, pub at: Pt, pub layer: String, pub peak: f64 }

/// Every over-screen point in a Fields report: each loaded net whose peak is over `limit`, at its
/// peak location, and every `current-neck` finding with a location.
pub fn over_screen(fields: &Value, limit: f64) -> Vec<Point> {
    let mut out = Vec::new();
    for row in fields["nets"].as_array().into_iter().flatten() {
        let peak = row["peakJ"].as_f64().unwrap_or(0.0);
        if peak <= limit { continue; }
        if let (Some(net), Some(x), Some(y)) = (row["net"].as_str(), row["peakAt"][0].as_f64(), row["peakAt"][1].as_f64()) {
            out.push(Point { net: net.to_string(), at: (x, y), layer: row["peakLayer"].as_str().unwrap_or("").to_string(), peak });
        }
    }
    out
}

/// A parsed `--ack` target: [email protected] or NET@x,y. A bare NET (or NET@, NET@*) is refused.
pub enum Target { Pad { net: String, key: String }, At { net: String, at: Pt } }

pub fn parse_target(s: &str) -> Result<Target, String> {
    let blanket = || format!("--ack {s}: name the place, not the whole net: [email protected] (a pad of the part that limits it) or NET@x,y (board mm). A whole-net acknowledgement is never accepted");
    let Some(i) = s.rfind('@') else { return Err(blanket()) };
    let (net, loc) = (s[..i].trim(), s[i + 1..].trim());
    if net.is_empty() || loc.is_empty() || loc == "*" { return Err(blanket()); }
    let nums: Vec<&str> = loc.split(',').map(str::trim).collect();
    if nums.len() == 2 {
        if let (Ok(x), Ok(y)) = (nums[0].parse::<f64>(), nums[1].parse::<f64>()) {
            if x.is_finite() && y.is_finite() { return Ok(Target::At { net: net.into(), at: (x, y) }); }
        }
    }
    if !loc.contains('.') { return Err(format!("--ack {s}: the place is REF.PAD (for example U1.2) or x,y in board mm")); }
    Ok(Target::Pad { net: net.into(), key: loc.into() })
}

/// The region a stored acknowledgement covers, as polygons (a pad's copper) or one point.
fn region(a: &Value) -> (Vec<Vec<Pt>>, Option<Pt>) {
    let polys: Vec<Vec<Pt>> = a["padCopper"].as_array().into_iter().flatten()
        .map(|p| p.as_array().into_iter().flatten().filter_map(|q| Some((q[0].as_f64()?, q[1].as_f64()?))).collect()).collect();
    let at = match (a["location"][0].as_f64(), a["location"][1].as_f64()) { (Some(x), Some(y)) => Some((x, y)), _ => None };
    (polys, at)
}

fn near(a: &Value, p: Pt) -> bool {
    let (polys, at) = region(a);
    if !polys.is_empty() { return polys.iter().any(|poly| poly.len() >= 3 && point_poly_distance(p.0, p.1, poly) <= TOLERANCE_MM); }
    at.map(|q| (q.0 - p.0).hypot(q.1 - p.1) <= TOLERANCE_MM).unwrap_or(false)
}

/// Does this live acknowledgement cover the point?
pub fn covers(a: &Value, p: &Point) -> bool {
    a["net"].as_str() == Some(p.net.as_str())
        && a["peakJ"].as_f64().is_some_and(|v| p.peak <= v * (1.0 + PEAK_SLACK))
        && near(a, p.at)
}

/// Split stored acknowledgements into live and expired for this copper and spec identity.
pub fn split_live(acks: &Value, copper: &str, spec_identity: &str) -> (Vec<Value>, Vec<Value>) {
    let (mut live, mut expired) = (Vec::new(), Vec::new());
    for a in acks.as_array().into_iter().flatten() {
        if a["copperHash"].as_str() == Some(copper) && a["fieldsSpecIdentity"].as_str() == Some(spec_identity) { live.push(a.clone()); } else { expired.push(a.clone()); }
    }
    (live, expired)
}

/// Build one acknowledgement record from the current Fields report. Refuses when nothing over the
/// screen lies at that place (there is nothing to review), or when the pad is not on the net.
pub fn record(target: &str, why: &str, ai_thread: &str, fields: &Value, board: &Board, copper: &str, limit: f64, recorded_at: &str) -> Result<Value, String> {
    if why.trim().chars().count() < 12 { return Err("--why needs the review: what limits the peak (the part's land, a connector pin) and why the board cannot change it".into()); }
    let t = parse_target(target)?;
    let (net, pad_copper, location, pad_key) = match &t {
        Target::Pad { net, key } => {
            let pad = board.pad_by_key(key).ok_or_else(|| format!("--ack {target}: no pad {key} on the board"))?;
            if pad.net != *net { return Err(format!("--ack {target}: pad {key} is on net {}, not {net}", if pad.net.is_empty() { "(none)" } else { &pad.net })); }
            (net.clone(), pad.polys.clone(), None, Some(key.clone()))
        }
        Target::At { net, at } => (net.clone(), Vec::new(), Some(*at), None),
    };
    let mut a = json!({"net": net, "target": target, "pad": pad_key, "padCopper": pad_copper.iter().map(|p| p.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>()).collect::<Vec<_>>(), "location": location.map(|p| json!([p.0, p.1])), "toleranceMm": TOLERANCE_MM});
    let pts = over_screen(fields, limit);
    let hit = pts.iter().find(|p| p.net == net && near(&a, p.at));
    let Some(p) = hit else {
        let on_net: Vec<String> = pts.iter().filter(|p| p.net == net).map(|p| format!("{:.1} A/mm2 at ({:.2}, {:.2}) on {}", p.peak, p.at.0, p.at.1, p.layer)).collect();
        return Err(if on_net.is_empty() { format!("--ack {target}: {net} has no point over the {limit} A/mm2 screen in this Fields report; nothing to acknowledge") } else { format!("--ack {target}: the over-screen point of {net} is not within {TOLERANCE_MM} mm of that place ({})", on_net.join("; ")) });
    };
    a["peakJ"] = json!(p.peak);
    a["peakAt"] = json!([p.at.0, p.at.1]);
    a["peakLayer"] = json!(p.layer);
    a["screenAperMm2"] = json!(limit);
    a["why"] = json!(why.trim());
    a["aiThread"] = json!(ai_thread);
    a["recordedAt"] = json!(recorded_at);
    a["copperHash"] = json!(copper);
    a["fieldsSpecIdentity"] = json!(fields["inputIdentity"]["spec"].as_str().unwrap_or(""));
    a["fieldsBoardIdentity"] = json!(fields["inputIdentity"]["board"].as_str().unwrap_or(""));
    Ok(a)
}

/// One line per acknowledgement, for analyze and finish.
pub fn describe(a: &Value, state: &str) -> String {
    format!("{} at {} ({:.1} A/mm2 at ({:.2}, {:.2}) {}, screen {} A/mm2), {state}: \"{}\" by {} {}",
        a["net"].as_str().unwrap_or("?"), a["pad"].as_str().map(str::to_string).unwrap_or_else(|| format!("({}, {})", a["location"][0], a["location"][1])),
        a["peakJ"].as_f64().unwrap_or(0.0), a["peakAt"][0].as_f64().unwrap_or(0.0), a["peakAt"][1].as_f64().unwrap_or(0.0), a["peakLayer"].as_str().unwrap_or(""),
        a["screenAperMm2"], a["why"].as_str().unwrap_or(""), a["aiThread"].as_str().unwrap_or("?"), a["recordedAt"].as_str().unwrap_or(""))
}

#[cfg(test)]
mod tests {
    use super::*;
    const PCB: &str = r#"(kicad_pcb (version 20241229) (generator "pcbnew") (net 0 "") (net 1 "VBUS") (net 2 "GND")
 (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts"))
 (footprint "SOT-23" (layer "F.Cu") (at 10 10) (property "Reference" "Q1")
  (pad "3" smd rect (at 0 -1) (size 0.6 0.8) (layers "F.Cu") (net 1 "VBUS"))
  (pad "1" smd rect (at -1 1) (size 0.6 0.8) (layers "F.Cu") (net 2 "GND")))
 (segment (start 10 9) (end 20 9) (width 0.6) (layer "F.Cu") (net 1)))"#;
    fn board(text: &str) -> Board { Board::from_text("t.kicad_pcb", text.to_string()).unwrap() }
    fn fields(peak: f64, at: [f64; 2]) -> Value { json!({"inputIdentity": {"board": "b", "spec": "s"}, "nets": [{"net": "VBUS", "peakJ": peak, "peakAt": at, "peakLayer": "F.Cu"}]}) }

    #[test]
    fn whole_net_acknowledgements_are_refused() {
        for t in ["VBUS", "VBUS@", "VBUS@*", "@Q1.3"] { assert!(parse_target(t).is_err(), "{t}"); }
        assert!(matches!(parse_target("[email protected],9").unwrap(), Target::At { .. }));
        assert!(matches!(parse_target("[email protected]").unwrap(), Target::Pad { .. }));
    }

    #[test]
    fn a_pad_ack_records_the_peak_and_covers_only_its_land() {
        let b = board(PCB);
        let f = fields(67.0, [10.1, 9.1]);
        let a = record("[email protected]", "SOT-23 drain land is 0.6 mm wide; part limited", "astra", &f, &b, "cu", 60.0, "t").unwrap();
        assert_eq!(a["peakJ"], 67.0);
        assert_eq!(a["aiThread"], "astra");
        let p = |x: f64, y: f64, j: f64| Point { net: "VBUS".into(), at: (x, y), layer: "F.Cu".into(), peak: j };
        assert!(covers(&a, &p(10.1, 9.1, 67.0)));
        assert!(covers(&a, &p(10.0, 8.2, 67.0)), "within the tolerance of the land's edge");
        assert!(!covers(&a, &p(15.0, 9.0, 67.0)), "a spot along the track is not the land");
        assert!(!covers(&a, &p(10.1, 9.1, 70.0)), "a higher peak than the one reviewed");
        let mut other = p(10.1, 9.1, 67.0); other.net = "GND".into();
        assert!(!covers(&a, &other));
        // the wrong net's pad, a point with nothing over the screen, a missing reason
        assert!(record("[email protected]", "a reason long enough", "t", &f, &b, "cu", 60.0, "t").unwrap_err().contains("on net GND"));
        assert!(record("VBUS@20,15", "a reason long enough", "t", &f, &b, "cu", 60.0, "t").unwrap_err().contains("not within"));
        assert!(record("[email protected]", "a reason long enough", "t", &fields(50.0, [10.1, 9.1]), &b, "cu", 60.0, "t").unwrap_err().contains("nothing to acknowledge"));
        assert!(record("[email protected]", "ok", "t", &f, &b, "cu", 60.0, "t").is_err());
    }

    #[test]
    fn acknowledgements_expire_when_the_copper_fingerprint_or_the_fields_spec_changes() {
        let b = board(PCB);
        let a = record("[email protected],9.1", "connector shield tab limits the last mm", "t", &fields(67.0, [10.1, 9.1]), &b, "cu", 60.0, "t").unwrap();
        let acks = json!([a]);
        assert_eq!(split_live(&acks, "cu", "s").0.len(), 1);
        assert_eq!(split_live(&acks, "cu", "other spec").1.len(), 1);
        assert_eq!(split_live(&acks, "changed copper", "s").1.len(), 1);
    }
}