main
John Lauer Publish 0.2.24 5b1c5bf
//! Issue #107: with a same-input Fields solve, the solved rise at each net's narrowest neck judges the
//! whole-net IPC-2221 screen.
//!
//! The whole-net screen takes the narrowest track over 0.2 mm anywhere on a loaded net and judges it at
//! the full load. Side strips, short branches and necks into small pins never carry the full load, so
//! a careful design can fail that approximation. adom-fields (0.9+) reports `review.checks.ampacity`:
//! per loaded net, the neck where the solved current density peaks, its width and layer, and the
//! solved temperature rise there with every heat source on the board. When that solve has already
//! passed the same-input field screen (exact board and spec hashes, converged at the full declared
//! current, density and barrel limits), its solved rise replaces the approximation for the nets whose
//! only failure was the IPC width (or a pour that does not cover the narrow copper). The heuristic
//! stays the verdict without a solve.
use serde_json::{json, Value};

/// Fields rounds the solved rise to 0.1 C; reserve half a display step.
const ROUNDING_C: f64 = 0.05;

/// Rewrite the failing whole-net width results that the solve can judge. Call only after the field
/// screen of the same report passed. Returns one line per net it judged.
pub fn accept_solve(results: &mut [Value], spec: &Value, fields: &Value) -> Vec<String> {
    let rows = fields["review"]["checks"]["ampacity"].as_array().cloned().unwrap_or_default();
    let max_rise_default = spec["maxRiseC"].as_f64().unwrap_or(10.0);
    let mut notes = Vec::new();
    for r in results.iter_mut() {
        if r["status"] != "fail" || !matches!(r["failure"].as_str(), Some("bottleneck" | "pour-coverage")) { continue; }
        let net = r["net"].as_str().unwrap_or("").to_string();
        let load = &spec["loads"][&net];
        let amps = load["amps"].as_f64().unwrap_or(0.0);
        let limit = load["maxRiseC"].as_f64().unwrap_or(max_rise_default);
        let Some(row) = rows.iter().find(|x| x["net"].as_str() == Some(net.as_str())) else {
            r["hint"] = json!(format!("{}; the Fields report has no review.checks.ampacity row for {net} (adom-fields 0.9 or later reports the solved rise at its narrowest neck)", r["hint"].as_str().unwrap_or("")));
            continue;
        };
        let row_amps = row["amps"].as_f64().unwrap_or(f64::NAN);
        let rise = row["solvedRiseC"].as_f64().filter(|v| v.is_finite() && *v >= 0.0);
        if !row_amps.is_finite() || (row_amps - amps).abs() > 1e-8 * amps.max(1.0) || rise.is_none() {
            r["hint"] = json!(format!("{}; the Fields ampacity row for {net} is not a finite solved rise at the full {amps} A", r["hint"].as_str().unwrap_or("")));
            continue;
        }
        let rise = rise.unwrap();
        let neck = format!("{:.2} mm on {} at ({:.2}, {:.2})", row["neckMm"].as_f64().unwrap_or(f64::NAN), row["layer"].as_str().unwrap_or("?"), row["at"][0].as_f64().unwrap_or(f64::NAN), row["at"][1].as_f64().unwrap_or(f64::NAN));
        let fields_judgement = json!({"solvedRiseC": rise, "limitC": limit, "neck": row.clone()});
        if rise + ROUNDING_C <= limit {
            let ipc = r.clone();
            r["status"] = json!("pass");
            r["judgedBy"] = json!("fields-solve");
            r["fieldsAmpacity"] = fields_judgement;
            r["ipcWholeNetScreen"] = ipc;
            r["how"] = json!(format!("judged by the Fields solve: {rise:.1} C solved rise at the narrowest neck (about {neck}) at {amps:.1} A, within {limit:.0} C; the whole-net IPC-2221 approximation alone failed it"));
            if let Some(o) = r.as_object_mut() { o.remove("hint"); }
            notes.push(format!("{net}: judged by the Fields solve ({rise:.1} C at its narrowest neck, limit {limit:.0} C)"));
        } else {
            r["fieldsAmpacity"] = fields_judgement;
            r["hint"] = json!(format!("{}; the same-input Fields solve agrees: {rise:.1} C at its narrowest neck (about {neck}), limit {limit:.0} C", r["hint"].as_str().unwrap_or("")));
        }
    }
    notes
}

#[cfg(test)]
mod tests {
    use super::*;
    fn spec() -> Value { json!({"maxRiseC": 10, "loads": {"VBUS": {"amps": 1.5}, "VSYS": {"amps": 1.2, "maxRiseC": 5}}}) }
    fn fields(rise: Value) -> Value {
        json!({"review": {"checks": {"ampacity": [
            {"net": "VBUS", "amps": 1.5, "neckMm": 0.31, "layer": "F.Cu", "copperUm": 35, "ipc2221_10C_A": 1.1, "solvedRiseC": rise, "at": [12.0, 4.0]},
            {"net": "VSYS", "amps": 1.2, "neckMm": 0.25, "layer": "F.Cu", "copperUm": 35, "ipc2221_10C_A": 0.9, "solvedRiseC": 6.0, "at": [3.0, 4.0]}]}}})
    }
    fn failing() -> Vec<Value> {
        vec![json!({"net": "VBUS", "amps": 1.5, "status": "fail", "failure": "bottleneck", "hint": "VBUS carries 1.5 A on a 0.30 mm bottleneck"}),
             json!({"net": "VSYS", "amps": 1.2, "status": "fail", "failure": "bottleneck", "hint": "VSYS ..."})]
    }
    #[test]
    fn a_solved_rise_within_the_limit_judges_the_net() {
        let mut r = failing();
        let notes = accept_solve(&mut r, &spec(), &fields(json!(3.5)));
        assert_eq!(r[0]["status"], "pass");
        assert_eq!(r[0]["judgedBy"], "fields-solve");
        assert!(r[0]["how"].as_str().unwrap().contains("judged by the Fields solve"));
        assert_eq!(r[0]["ipcWholeNetScreen"]["status"], "fail");
        // VSYS: 6.0 C solved against its own 5 C limit still fails, and says so
        assert_eq!(r[1]["status"], "fail");
        assert!(r[1]["hint"].as_str().unwrap().contains("6.0 C"));
        assert_eq!(notes.len(), 1);
    }
    #[test]
    fn rounding_allowance_missing_rows_and_other_failures_stay_failed() {
        let mut r = failing();
        accept_solve(&mut r, &spec(), &fields(json!(9.96)));
        assert_eq!(r[0]["status"], "fail");
        for rise in [Value::Null, json!(-1.0), json!("3")] {
            let mut r = failing();
            accept_solve(&mut r, &spec(), &fields(rise));
            assert_eq!(r[0]["status"], "fail");
        }
        let mut r = failing();
        accept_solve(&mut r, &spec(), &json!({"review": {}}));
        assert!(r[0]["hint"].as_str().unwrap().contains("no review.checks.ampacity row"));
        // a solve at another current does not judge the declared load
        let mut f = fields(json!(3.5)); f["review"]["checks"]["ampacity"][0]["amps"] = json!(1.0);
        let mut r = failing();
        accept_solve(&mut r, &spec(), &f);
        assert_eq!(r[0]["status"], "fail");
        // via stitching and missing copper are not the width approximation
        let mut r = vec![json!({"net": "VBUS", "status": "fail", "failure": "via-stitching", "hint": "x"})];
        accept_solve(&mut r, &spec(), &fields(json!(1.0)));
        assert_eq!(r[0]["status"], "fail");
    }
}