app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
Screen external package cooling with full-power heat balance and unchanged limits
29fec55
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
//! 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());
}
}