main
John Lauer Screen external package cooling with full-power heat balance and unchanged limits 29fec55
//! Explicit package-to-heatsink screening. This never establishes physical qualification.
use serde_json::{json, Value};
use std::collections::BTreeSet;

fn positive(v: &Value, key: &str) -> Result<f64, String> {
    v[key]
        .as_f64()
        .filter(|x| x.is_finite() && *x > 0.0 && (1.0 / x).is_finite())
        .ok_or_else(|| format!("packageCooling requires finite positive {key}"))
}

// JSON encoders may write an integer-valued physical quantity as 100 or 100.0.
// Compare numeric values exactly while preserving array order and object coverage.
fn same_definition(a: &Value, b: &Value) -> bool {
    match (a, b) {
        (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(),
        (Value::Array(x), Value::Array(y)) => {
            x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same_definition(a, b))
        }
        (Value::Object(x), Value::Object(y)) => {
            x.len() == y.len()
                && x.iter()
                    .all(|(k, a)| y.get(k).is_some_and(|b| same_definition(a, b)))
        }
        _ => a == b,
    }
}

pub fn validate(spec: &Value) -> Result<(), String> {
    let Some(packages) = spec.get("packageCooling") else {
        return Ok(());
    };
    let packages = packages
        .as_array()
        .ok_or("packageCooling must be an array")?;
    let mut refs = BTreeSet::new();
    for p in packages {
        let r = p["reference"]
            .as_str()
            .filter(|s| !s.trim().is_empty())
            .ok_or("packageCooling reference missing")?;
        if !refs.insert(r) || !spec["hot"].get(r).is_some_and(Value::is_object) {
            return Err("packageCooling needs one entry per existing hot reference".into());
        }
        positive(&spec["hot"][r], "watts")?;
        if !p["provenance"]
            .as_str()
            .is_some_and(|s| !s.trim().is_empty())
        {
            return Err(format!("{r}: packageCooling provenance missing"));
        }
        for k in [
            "junctionCaseKPerW",
            "caseSinkKPerW",
            "sinkAmbientStillKPerW",
            "sinkAmbientMovingKPerW",
        ] {
            positive(p, k)?;
        }
        let links = p["boardLinks"]
            .as_array()
            .filter(|a| !a.is_empty())
            .ok_or("packageCooling physical board links missing")?;
        let mut ids = BTreeSet::new();
        for l in links {
            positive(l, "resistanceKPerW")?;
            if !l["pad"].as_str().is_some_and(|s| !s.trim().is_empty())
                || !l["layer"].as_str().is_some_and(|s| s.ends_with(".Cu"))
                || !l["at"].as_array().is_some_and(|a| {
                    a.len() == 2 && a.iter().all(|n| n.as_f64().is_some_and(f64::is_finite))
                })
                || !ids.insert(format!("{}:{}:{}", l["pad"], l["layer"], l["at"]))
            {
                return Err(format!("{r}: invalid or duplicate physical board link"));
            }
        }
    }
    Ok(())
}

pub fn definition<'a>(spec: &'a Value, reference: &str) -> Option<&'a Value> {
    spec["packageCooling"]
        .as_array()?
        .iter()
        .find(|p| p["reference"].as_str() == Some(reference))
}

/// Full heat through the declared series path; no credit for board heat spreading or fan speed.
/// The independent coupled Fields screen remains necessary (the board can heat the package too).
pub fn preliminary(p: &Value, h: &Value, spec: &Value) -> Value {
    let watts = h["watts"].as_f64().unwrap();
    let theta = p["junctionCaseKPerW"].as_f64().unwrap()
        + p["caseSinkKPerW"].as_f64().unwrap()
        + p["sinkAmbientStillKPerW"]
            .as_f64()
            .unwrap()
            .max(p["sinkAmbientMovingKPerW"].as_f64().unwrap());
    let rise = theta * watts;
    let theta_limit = h.get("maxThetaCPerW").or_else(|| spec.get("maxThetaCPerW"));
    let rise_limit = h.get("maxRiseC").or_else(|| spec.get("hotMaxRiseC"));
    let mut pass = theta.is_finite() && rise.is_finite();
    for (limit, actual) in [(theta_limit, theta), (rise_limit, rise)] {
        if let Some(l) = limit {
            pass &= l
                .as_f64()
                .is_some_and(|n| n.is_finite() && n > 0.0 && actual <= n);
        }
    }
    if theta_limit.is_none() && rise_limit.is_none() {
        pass &= theta <= 60.0;
    }
    json!({"ref":p["reference"],"tabNet":h["tabNet"],"watts":watts,
        "status":if pass {"pass"} else {"fail"},"model":"declared-package-series-path",
        "thetaEstimateCPerW":theta,"riseEstimateC":rise,"limitCPerW":theta_limit,"riseLimitC":rise_limit,
        "how":format!("full {watts} W through declared junction/case/sink path: {rise:.3} C rise; coupled same-input Fields screen still required"),
        "hint":"Review the actual package, interface and heatsink parameters against the unchanged limits; do not reduce declared heat or invent cooling",
        "provenance":p["provenance"],"physicalQualification":false})
}

/// Reject a stale/omitted external-node report even if its board-only chip summary looks cool.
pub fn screen(fields: &Value, spec: &Value) -> Result<(), String> {
    validate(spec)?;
    let wanted = spec["packageCooling"]
        .as_array()
        .map(Vec::as_slice)
        .unwrap_or(&[]);
    if wanted.is_empty() {
        return Ok(());
    }
    let reports = fields["packageCooling"]
        .as_array()
        .ok_or("Fields omitted packageCooling nodes")?;
    if reports.len() != wanted.len() {
        return Err("Fields packageCooling coverage mismatch".into());
    }
    for p in wanted {
        let r = p["reference"].as_str().unwrap();
        let matches: Vec<_> = reports
            .iter()
            .filter(|x| x["definition"]["reference"] == p["reference"])
            .collect();
        if matches.len() != 1 {
            return Err(format!("Fields {r} package report missing or duplicated"));
        }
        let report = matches[0];
        for key in [
            "reference",
            "provenance",
            "junctionCaseKPerW",
            "caseSinkKPerW",
            "sinkAmbientStillKPerW",
            "sinkAmbientMovingKPerW",
            "boardLinks",
        ] {
            if !same_definition(&report["definition"][key], &p[key]) {
                return Err(format!("Fields {r} package definition differs: {key}"));
            }
        }
        let chip = fields["chips"]
            .as_array()
            .and_then(|a| a.iter().find(|c| c["reference"] == p["reference"]))
            .ok_or("Fields package chip missing")?;
        let watts = spec["hot"][r]["watts"].as_f64().unwrap();
        for (mode, rise_key) in [("still", "riseC"), ("moving", "riseMovingC")] {
            let v = &report[mode];
            let n = |k: &str| {
                v[k].as_f64()
                    .filter(|x| x.is_finite())
                    .ok_or_else(|| format!("Fields {r} {mode} missing finite {k}"))
            };
            let source = n("sourceW")?;
            let residual = n("energyResidualW")?;
            if (source - watts).abs() > 1e-8
                || residual.abs() > 1e-6 * watts.max(1.0)
                || (source - n("boardCouplingW")? - n("sinkToAmbientW")?).abs()
                    > 1e-6 * watts.max(1.0)
            {
                return Err(format!("Fields {r} package heat is missing or unbalanced"));
            }
            let mut highest = 0.0_f64;
            for k in ["junctionRiseC", "caseRiseC", "sinkRiseC"] {
                let temperature = n(k)?;
                if temperature < 0.0 {
                    return Err(format!("Fields {r} package node has negative rise"));
                }
                highest = highest.max(temperature);
            }
            let close = |a: f64, b: f64| (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0);
            let ambient_key = if mode == "still" {
                "sinkAmbientStillKPerW"
            } else {
                "sinkAmbientMovingKPerW"
            };
            if !close(
                n("junctionRiseC")? - n("caseRiseC")?,
                watts * p["junctionCaseKPerW"].as_f64().unwrap(),
            ) || !close(
                n("caseRiseC")? - n("sinkRiseC")?,
                n("sinkToAmbientW")? * p["caseSinkKPerW"].as_f64().unwrap(),
            ) || !close(
                n("sinkRiseC")?,
                n("sinkToAmbientW")? * p[ambient_key].as_f64().unwrap(),
            ) {
                return Err(format!(
                    "Fields {r} package node temperatures disagree with declared resistances"
                ));
            }
            let links = v["boardLinks"]
                .as_array()
                .ok_or("Fields package board-link report missing")?;
            if links.len() != p["boardLinks"].as_array().unwrap().len() {
                return Err("Fields package board-link coverage mismatch".into());
            }
            let mut sum = 0.0;
            for (l, definition) in links.iter().zip(p["boardLinks"].as_array().unwrap()) {
                let board_rise = l["boardRiseC"]
                    .as_f64()
                    .filter(|x| x.is_finite() && *x >= 0.0)
                    .ok_or("Fields package board temperature missing")?;
                let link_w = l["caseToBoardW"]
                    .as_f64()
                    .filter(|x| x.is_finite())
                    .ok_or("Fields package board heat missing")?;
                if !close(
                    n("caseRiseC")? - board_rise,
                    link_w * definition["resistanceKPerW"].as_f64().unwrap(),
                ) {
                    return Err(
                        "Fields package board link disagrees with its thermal resistance".into(),
                    );
                }
                highest = highest.max(board_rise);
                sum += link_w;
            }
            if (sum - n("boardCouplingW")?).abs() > 1e-6 * watts.max(1.0) {
                return Err("Fields package link heat sum mismatch".into());
            }
            if !chip[rise_key]
                .as_f64()
                .is_some_and(|x| x.is_finite() && x + 1e-8 >= highest)
            {
                return Err(format!(
                    "Fields {r} chip summary hides a hotter package node"
                ));
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    fn spec() -> Value {
        json!({"hot":{"Q1":{"watts":5.5,"tabNet":"D","maxRiseC":15}},"packageCooling":[{
            "reference":"Q1","provenance":"Test fixture, not a component rating","junctionCaseKPerW":0.42,
            "caseSinkKPerW":0.53,"sinkAmbientStillKPerW":1.52,"sinkAmbientMovingKPerW":1.52,
            "boardLinks":[{"pad":"2","at":[1,2],"layer":"F.Cu","resistanceKPerW":100}]}]})
    }
    fn report(s: &Value) -> Value {
        let mode = json!({"sourceW":5.5,"energyResidualW":0.0,"boardCouplingW":0.01,"sinkToAmbientW":5.49,
            "junctionRiseC":13.5645,"caseRiseC":11.2545,"sinkRiseC":8.3448,
            "boardLinks":[{"boardRiseC":10.2545,"caseToBoardW":0.01}]});
        json!({"chips":[{"reference":"Q1","riseC":13.58,"riseMovingC":13.58}],
            "packageCooling":[{"definition":s["packageCooling"][0],"still":mode,"moving":mode}]})
    }
    #[test]
    fn numeric_serialization_preserves_physical_definition() {
        let s = spec();
        let mut f = report(&s);
        f["packageCooling"][0]["definition"]["boardLinks"][0]["resistanceKPerW"] = json!(100.0);
        f["packageCooling"][0]["definition"]["boardLinks"][0]["at"] = json!([1.0, 2.0]);
        assert!(screen(&f, &s).is_ok());
        f["packageCooling"][0]["definition"]["boardLinks"][0]["at"] = json!([1.0, 2.001]);
        assert!(screen(&f, &s).is_err());
    }
    #[test]
    fn explicit_series_path_uses_full_power_and_both_conditions() {
        let mut s = spec();
        let (ok, r) = crate::analyze_thermal(&json!({}), &s, None);
        assert!(ok);
        assert_eq!(r[0]["watts"], 5.5);
        assert!((r[0]["riseEstimateC"].as_f64().unwrap() - 13.585).abs() < 1e-8);
        s["packageCooling"][0]["sinkAmbientMovingKPerW"] = json!(6);
        assert!(!crate::analyze_thermal(&json!({}), &s, None).0);
        s["packageCooling"][0]["sinkAmbientMovingKPerW"] = json!(1.52);
        s["hot"]["Q1"]["watts"] = json!(10);
        assert!(!crate::analyze_thermal(&json!({}), &s, None).0);
    }
    #[test]
    fn missing_duplicate_and_bad_parameters_fail_closed() {
        for k in ["provenance", "junctionCaseKPerW", "boardLinks"] {
            let mut s = spec();
            s["packageCooling"][0].as_object_mut().unwrap().remove(k);
            assert!(validate(&s).is_err());
        }
        let mut s = spec();
        let p = s["packageCooling"][0].clone();
        s["packageCooling"].as_array_mut().unwrap().push(p);
        assert!(validate(&s).is_err());
        let mut s = spec();
        s["packageCooling"][0]["caseSinkKPerW"] = json!(-1);
        assert!(validate(&s).is_err());
        let mut s = spec();
        s["packageCooling"][0]["reference"] = json!("Q2");
        assert!(validate(&s).is_err());
    }
    #[test]
    fn board_only_path_is_not_waived() {
        let mut s = spec();
        s.as_object_mut().unwrap().remove("packageCooling");
        assert!(!crate::analyze_thermal(&json!({}), &s, None).0);
    }
    #[test]
    fn reports_must_match_the_spec_and_account_for_heat() {
        let s = spec();
        let f = report(&s);
        assert!(screen(&f, &s).is_ok());
        let mut f = report(&s);
        f["packageCooling"][0]["definition"]["caseSinkKPerW"] = json!(0.1);
        assert!(screen(&f, &s).is_err());
        for key in [
            "sourceW",
            "energyResidualW",
            "boardCouplingW",
            "sinkToAmbientW",
        ] {
            let mut f = report(&s);
            f["packageCooling"][0]["still"][key] = json!(1);
            assert!(screen(&f, &s).is_err(), "{key}");
        }
        let mut f = report(&s);
        f.as_object_mut().unwrap().remove("packageCooling");
        assert!(screen(&f, &s).is_err());
    }
    #[test]
    fn hot_nodes_cannot_hide_in_a_board_only_summary() {
        let s = spec();
        for node in ["caseRiseC", "junctionRiseC", "sinkRiseC"] {
            let mut f = report(&s);
            f["packageCooling"][0]["moving"][node] = json!(40);
            assert!(screen(&f, &s).is_err());
        }
        let mut f = report(&s);
        f["packageCooling"][0]["still"]["boardLinks"][0]["boardRiseC"] = json!(40);
        assert!(screen(&f, &s).is_err());
    }
}