← Commit history

Screen external package cooling with full-power heat balance and unchanged limits

John Lauer ·29fec550c0 · ·parent d985e4a
5 files changed +378−2
crates/adom-aiflow/src/main.rs+2−2
@@ -1568,7 +1568,7 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {     let later: f64 = (r.active_minutes_total() - r.delivered_active_minutes().unwrap_or(0.0)).max(0.0);     let status = match (delivered, finished) {         (Some(d), _) => format!("**Delivered** at {d}: **{:.1} min of AI time** from the prompt to \"done, here is your video\" ({:.1} min on the wall clock). {}", r.delivered_active_minutes().unwrap_or(0.0), r.delivered_minutes().unwrap_or(0.0), if ss.len() > 1 { format!("Since then {later:.1} min more of AI time over {} follow-up prompt(s), listed under Sessions; none of it is in the delivery number.", ss.len() - 1) } else { String::new() }),-        (None, Some(f)) => format!("**Finished** (qualified board) at {f}, {:.1} min of AI time from the prompt; the video is being cut.", r.active_minutes_total()),+        (None, Some(f)) => format!("**Finished** (board design checks) at {f}, {:.1} min of AI time from the prompt; the video is being cut.", r.active_minutes_total()),         _ => format!("**Running**: step `{cur}`, {:.1} min of AI time since the prompt ({:.1} min on the wall clock). This page updates at every step change.", r.active_minutes_total(), now_min),     };     md.push_str(&format!("{status}\n\nPrompt at {prompt} (UTC). Flow: {}. Every number here is from the run's append-only ledger (run.jsonl). AI time is the time the AI was working on a prompt: from the human's prompt to the AI's done, with any gap over {} min where no command ran (unobserved, not proven idle) cut out.\n\n", r.data["flow"]["name"].as_str().unwrap_or("board"), r.idle_minutes()));@@ -4214,7 +4214,7 @@ fn main() {                 err(&format!("not delivered: {} clip(s) filmed nothing usable:\n{}", suspects.len(), suspects.iter().map(|x| format!("  {x}")).collect::<Vec<_>>().join("\n")), &["Look at each clip's contact sheet (<clip>-sheet.png) and re-record the step (`step <name> --back --why ...`), or `deliver --accept-suspect` and say why in the message.".into()]);             }             if r.data["clock"]["finishTime"].is_null() {-                err("not finished: `finish` has not passed, so there is nothing to deliver", &["Deliver comes after finish: the qualified board first, then the video and your message.".into()]);+                err("not finished: `finish` has not passed, so there is nothing to deliver", &["Deliver comes after finish: the checked board design first, then the video and your message. Physical qualification is a separate claim.".into()]);             }             let vp = Path::new(video);             if !vp.is_file() {
crates/aiflow-analyze/src/lib.rs+11
@@ -5,6 +5,7 @@ //! obvious under-sized conductor and every hot tab has copper"; FAIL names the net or the part and //! the fix. 0.2 computes cross-sections through the filled polygons and IR drop per net. pub mod branches;+pub mod package_cooling; use std::collections::BTreeMap;  use serde_json::{json, Value};@@ -152,11 +153,20 @@ pub fn analyze_current_on_board(plan: &Value, spec: &Value, zone_state: Option<&  /// spec.hot: {ref: {watts, tabNet, maxThetaCPerW?}}. 0.1: copper presence and vias at the tab. pub fn analyze_thermal(plan: &Value, spec: &Value, zone_state: Option<&Value>) -> (bool, Vec<Value>) {+    if let Err(error) = package_cooling::validate(spec) {+        return (false, vec![json!({"ref":"packageCooling","status":"fail","hint":error})]);+    }     let widths = plan_widths(plan);     let pours = pours_by_net(zone_state);     let mut results = Vec::new();     let mut ok = true;     for (reference, h) in spec.get("hot").and_then(|l| l.as_object()).cloned().unwrap_or_default() {+        if let Some(package) = package_cooling::definition(spec, &reference) {+            let result = package_cooling::preliminary(package, &h, spec);+            ok &= result["status"] == "pass";+            results.push(result);+            continue;+        }         let net = h.get("tabNet").and_then(|n| n.as_str()).unwrap_or("").to_string();         let watts = h.get("watts").and_then(|v| v.as_f64()).unwrap_or(0.0);         let zs = pours.get(&net).cloned().unwrap_or_default();@@ -233,6 +243,7 @@ pub fn field_screen(fields: &Value, spec: &Value, board_bytes: &[u8], spec_bytes         }     }     if kind == "thermal" {+        package_cooling::screen(fields, spec)?;         for key in ["stillResidual", "movingResidual"] {             if !fields["thermalConvergence"][key].as_f64().is_some_and(|v| v.is_finite() && v >= 0.0 && v <= 1e-6) {                 return Err(format!("Fields thermal solve is missing or unconverged: {key}"));
crates/aiflow-analyze/src/package_cooling.rsadded+348
@@ -0,0 +1,348 @@+//! 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());+    }+}
docs/package-cooling-screen.mdadded+13
@@ -0,0 +1,13 @@+# Package and heatsink design screening++A MOSFET bolted to an external heatsink does not dissipate all its heat through the PCB tab copper. A `packageCooling` entry declares the alternative path explicitly; it is not a waiver or an external pass flag.++AIFlow first validates positive finite resistances, full declared heat, provenance and unique physical pad links. Its preliminary series estimate uses junction-to-case plus case-to-sink plus the worse of the supplied still/moving sink-to-ambient resistances. It gives no credit for board heat spreading. Existing per-part and global rise/theta limits still apply. Parts without this declaration retain their PCB-copper check.++The independent, same-board/same-spec Fields screen is also required. It must contain every package definition exactly once, full source power, converged solutions in both air conditions, balanced sink/board heat and node temperatures consistent with each declared resistance. Chip rise must include the hottest package or board terminal node. The ordinary current, thermal limit, convergence and identity checks remain in force.++These checks establish only a design screen under supplied parameters. Read the source for each resistance: catalog test conditions, mounting, temperature rise, orientation and airflow may differ from the intended assembly. Record assumptions and sensitivity studies; do not describe a nominal catalog calculation as an installed worst-case guarantee. Never adjust coefficients merely to pass, remove heat, or claim a fan's RPM establishes airflow.++Physical calibration, airflow blockage, clamp preload, assembly tolerances and measured fault behavior belong to a separately tracked qualification phase when the user scopes that phase later. Do not demand construction of a probing simulator just to finish the board-design workflow. Equally, phase separation cannot turn a failing calculation into a pass. Keep the prototype unreleased/unqualified until those later checks are complete.++Required fields for each `packageCooling` entry: `reference`, `provenance`, `junctionCaseKPerW`, `caseSinkKPerW`, `sinkAmbientStillKPerW`, `sinkAmbientMovingKPerW`, and nonempty `boardLinks` containing `pad`, `at` (board XY mm), `layer` and `resistanceKPerW`. Use the reviewed Fields solver that implements these nodes; old board-only reports must fail rather than silently ignore the declaration.
skills/adom-aiflow/SKILL.md+4
@@ -386,3 +386,7 @@ The server uses one native filesystem watcher per run and a shared state cache. ### Optional Codex continuation supervisor  When the user explicitly authorizes automatic continuation, read `docs/codex-supervisor.md` from the source repository. The experimental source helper `tools/continuation/supervisor.py` can watch an exact existing Codex thread and queue a continuation after a completed turn while the finish guard says work remains. Detect runtime capabilities, not an Astra-specific personality. It is not installed or enabled automatically by the current release. Do not claim a running supervisor without verifying its PID, exact thread and status receipt. On any user pause/cancellation, pause the supervisor immediately; on resume inspect the stop reason before restarting. Preserve approvals, evidence gates, user queues and other threads. A source-only helper is not universal runtime enforcement.++## External heatsinks and phase scope++For a part cooled directly through its case and heatsink, read `docs/package-cooling-screen.md` and declare `packageCooling` with sourced or explicitly documented design assumptions. AIFlow checks the full-power series path and requires the same-input coupled Fields report, including all package nodes and energy balance. The PCB-only heuristic must not be used as the physical model for that external path. Preserve every power/temperature limit and retain sensitivity failures. A design-screen pass is not physical qualification. If the user schedules the probing simulator and bench tests after board design, track those as later qualification work rather than requiring them inside the design phase.