main
John Lauer Publish 0.2.3 6e27c4b
//! Board-bottom heat sinks (issue #67): a pedestal or plate pressed against one face of the board
//! (usually B.Cu through a gap pad) that cools tabless parts (QFN/SON exposed pads) through their
//! thermal vias. `packageCooling` models a sink on the part's case; this models a sink on the board.
//!
//! spec.boardSink: [{name, region: [[x,y],...] | "outline", layer: "B.Cu" | "F.Cu",
//!   kPerW (sink to ambient) | hWm2K (sink area = region area), interfaceKPerW? (gap pad over the
//!   whole region), maskOpen?, parts: [REF...], note, fieldsHeatsink?}]
//!
//! Two ways to credit it, both recorded as assumptions in the analysis and the finish summary:
//!
//! 1. A conservative series estimate in `analyze thermal` for each named part whose heat path
//!    reaches the region: only tab-net vias within `VIA_REACH_MM` of the part's tab-net pads whose
//!    centres lie inside the region count. Each via: plated barrel (k 385 W/mK, 25 um wall, board
//!    thickness) plus a lateral copper strip from the pad edge to the via. Then solder mask (unless
//!    `maskOpen`) and the interface on a contact patch (the vias' box grown 1 mm when the tab net
//!    has copper on the sink face, else the via lands alone); the interface's K/W is spread over the
//!    region area, so a small patch pays proportionally more. Then the sink's own K/W, heated by the
//!    full power of every part declared on it. No board-copper spreading or air credit on this path;
//!    the part passes when this path or the ordinary copper path alone meets its limit.
//! 2. The same-input Adom Fields solve with a conducting block (spec.heatsinks, Fields 0.4+) named by
//!    `fieldsHeatsink`. When the field screen passes (every chip within its budget with the block in
//!    place) and the block carries heat, the named parts' heuristic failures are replaced by the
//!    solve's result, with the block's assumptions recorded.
//!
//! Parts not named on a declared sink keep the still-air copper check unchanged.

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

/// Tab-net vias farther than this from the part's tab-net pad copper are not on its heat path here.
pub const VIA_REACH_MM: f64 = 1.0;
const K_CU_W_PER_MM_K: f64 = 0.385;
/// Solder mask between the copper and the interface: about 20 um of LPI at 0.25 W/mK.
pub const MASK_KMM2_PER_W: f64 = 80.0;
/// How far the contact patch grows past the vias when the tab net has copper on the sink face.
const PATCH_GROW_MM: f64 = 1.0;

fn num(v: &Value) -> Option<f64> { v.as_f64().filter(|x| x.is_finite()) }

fn region_of(s: &Value, b: Option<&Board>) -> Result<Vec<Pt>, String> {
    if s["region"] == "outline" {
        return b.map(|b| b.outline.clone()).filter(|o| o.len() >= 3).ok_or_else(|| "region \"outline\" needs the board outline".into());
    }
    let pts: Option<Vec<Pt>> = s["region"].as_array().map(|a| a.iter().map(|p| Some((num(&p[0])?, num(&p[1])?))).collect()).unwrap_or(None);
    match pts { Some(p) if p.len() >= 3 && polygon_area(&p).abs() > 1e-6 => Ok(p), _ => Err("region must be \"outline\" or a polygon of at least three [x, y] points (board mm)".into()) }
}

/// Validate spec.boardSink. Returns the entries (empty when absent).
pub fn validate(spec: &Value) -> Result<Vec<Value>, String> {
    let Some(v) = spec.get("boardSink") else { return Ok(Vec::new()) };
    let list = v.as_array().ok_or("boardSink must be a list")?;
    let mut names = Vec::new();
    let mut seen_parts: Vec<String> = Vec::new();
    for s in list {
        let name = s["name"].as_str().filter(|n| !n.trim().is_empty()).ok_or("each boardSink needs a name")?;
        if names.contains(&name) { return Err(format!("boardSink {name} is declared twice")); }
        names.push(name);
        if !matches!(s["layer"].as_str(), Some("F.Cu") | Some("B.Cu")) { return Err(format!("boardSink {name}: layer must be F.Cu or B.Cu (the board face the sink presses on)")); }
        if s["region"] != "outline" { region_of(s, None).map_err(|e| format!("boardSink {name}: {e}"))?; }
        let k = s.get("kPerW").map(|x| num(x).filter(|x| *x > 0.0).ok_or(format!("boardSink {name}: kPerW must be finite and positive")));
        let h = s.get("hWm2K").map(|x| num(x).filter(|x| *x > 0.0).ok_or(format!("boardSink {name}: hWm2K must be finite and positive")));
        match (k, h) {
            (Some(k), None) => { k?; }
            (None, Some(h)) => { h?; }
            _ => return Err(format!("boardSink {name}: give exactly one of kPerW (sink to ambient) or hWm2K (with the region as the sink area)")),
        }
        if let Some(i) = s.get("interfaceKPerW") { num(i).filter(|x| *x >= 0.0).ok_or(format!("boardSink {name}: interfaceKPerW must be finite and not negative"))?; }
        if s["note"].as_str().map(|n| n.trim().len() < 8).unwrap_or(true) { return Err(format!("boardSink {name}: note must say what the sink is (part, gap pad, mounting) and where its numbers come from")); }
        let parts = s["parts"].as_array().filter(|p| !p.is_empty()).ok_or(format!("boardSink {name}: parts must name the hot parts it cools"))?;
        for p in parts {
            let r = p.as_str().ok_or(format!("boardSink {name}: parts are references"))?;
            if spec["hot"].get(r).is_none() { return Err(format!("boardSink {name}: {r} is not in spec.hot")); }
            if super::package_cooling::definition(spec, r).is_some() { return Err(format!("boardSink {name}: {r} already has a packageCooling path; declare one cooling model per part")); }
            if seen_parts.iter().any(|x| x == r) { return Err(format!("boardSink: {r} is named on two sinks")); }
            seen_parts.push(r.to_string());
        }
        if let Some(f) = s.get("fieldsHeatsink") { f.as_str().filter(|x| !x.is_empty()).ok_or(format!("boardSink {name}: fieldsHeatsink names a spec.heatsinks block"))?; }
    }
    Ok(list.clone())
}

fn sink_of<'a>(sinks: &'a [Value], reference: &str) -> Option<&'a Value> {
    sinks.iter().find(|s| s["parts"].as_array().is_some_and(|p| p.iter().any(|x| x.as_str() == Some(reference))))
}

/// Apply the conservative series estimate to the heuristic rows (analyze thermal on the board).
pub fn apply(results: &mut [Value], spec: &Value, board: &Board, zone_state: Option<&Value>) -> Result<(), String> {
    let sinks = validate(spec)?;
    if sinks.is_empty() { return Ok(()); }
    let thick = num(&spec["boardThicknessMm"]).or(num(&spec["branchCurrentModel"]["boardThicknessMm"])).unwrap_or(1.6);
    let plating = num(&spec["branchCurrentModel"]["barrelPlatingUm"]).unwrap_or(25.0) / 1000.0;
    let t_cu = num(&spec["copperUm"]).unwrap_or(35.0) / 1000.0;
    let pours = super::pours_by_net(zone_state);
    let vias = board.vias();
    for row in results.iter_mut() {
        let reference = row["ref"].as_str().unwrap_or("").to_string();
        let Some(s) = sink_of(&sinks, &reference) else { continue };
        let name = s["name"].as_str().unwrap_or("");
        let layer = s["layer"].as_str().unwrap_or("B.Cu");
        let region = region_of(s, Some(board)).map_err(|e| format!("boardSink {name}: {e}"))?;
        let region_area = polygon_area(&region).abs();
        let net = spec["hot"][&reference]["tabNet"].as_str().unwrap_or("").to_string();
        let watts = num(&spec["hot"][&reference]["watts"]).unwrap_or(0.0);
        let sink_watts: f64 = s["parts"].as_array().into_iter().flatten().filter_map(|p| p.as_str()).map(|p| num(&spec["hot"][p]["watts"]).unwrap_or(0.0)).sum();
        let pads: Vec<_> = board.pads.iter().filter(|p| p.reference == reference && p.net == net).collect();
        let dist = |x: f64, y: f64| pads.iter().flat_map(|p| p.polys.iter()).filter(|poly| poly.len() >= 3).map(|poly| point_poly_distance(x, y, poly)).fold(f64::INFINITY, f64::min);
        let on_path: Vec<(f64, f64, f64, f64)> = vias.iter().filter(|v| v.net == net && point_in_poly(v.x, v.y, &region))
            .map(|v| (v.x, v.y, v.size, v.drill)).filter(|v| dist(v.0, v.1) <= VIA_REACH_MM).collect();
        let note = s["note"].as_str().unwrap_or("");
        if on_path.is_empty() {
            row["boardSink"] = json!({"name": name, "used": false, "assumption": format!("{name} ({note}): no {net} via within {VIA_REACH_MM} mm of {reference}'s {net} pads lands inside the sink region, so the estimate gives it no sink credit")});
            if row["status"] != "pass" { row["hint"] = json!(format!("{}; board sink {name}: no {net} via within {VIA_REACH_MM} mm of {reference}'s tab lands in its region (add thermal vias in the pad over the sink, or a Fields solve with fieldsHeatsink)", row["hint"].as_str().unwrap_or(""))); }
            continue;
        }
        let r_vias = 1.0 / on_path.iter().map(|v| {
            let barrel = thick / (K_CU_W_PER_MM_K * std::f64::consts::PI * plating * (v.3 + plating));
            let lateral = dist(v.0, v.1) / (K_CU_W_PER_MM_K * t_cu * std::f64::consts::PI * v.2);
            1.0 / (barrel + lateral)
        }).sum::<f64>();
        let face_copper = pours.get(&net).map(|z| super::layers_of(z).iter().any(|l| l == layer)).unwrap_or(false);
        let patch = if face_copper {
            let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
            for v in &on_path { x0 = x0.min(v.0 - v.2 / 2.0); y0 = y0.min(v.1 - v.2 / 2.0); x1 = x1.max(v.0 + v.2 / 2.0); y1 = y1.max(v.1 + v.2 / 2.0); }
            (x1 - x0 + 2.0 * PATCH_GROW_MM) * (y1 - y0 + 2.0 * PATCH_GROW_MM)
        } else { on_path.iter().map(|v| std::f64::consts::PI * (v.2 / 2.0).powi(2)).sum() };
        let patch = patch.min(region_area);
        let r_mask = if s["maskOpen"] == true { 0.0 } else { MASK_KMM2_PER_W / patch };
        let r_if = num(&s["interfaceKPerW"]).unwrap_or(0.0) * region_area / patch;
        let r_sink = num(&s["kPerW"]).unwrap_or_else(|| 1e6 / (num(&s["hWm2K"]).unwrap_or(1.0) * region_area));
        let rise = watts * (r_vias + r_mask + r_if) + r_sink * sink_watts;
        let theta = if watts > 0.0 { rise / watts } else { 0.0 };
        let theta_limit = num(&row["limitCPerW"]);
        let rise_limit = num(&row["riseLimitC"]);
        let limit = theta_limit.unwrap_or(if rise_limit.is_some() { f64::INFINITY } else { 60.0 });
        let sink_pass = theta <= limit && rise_limit.map(|l| rise <= l).unwrap_or(true);
        let r1 = |x: f64| (x * 10.0).round() / 10.0;
        let assumption = format!("{name} on {layer} ({note}): heat leaves {reference} only through its {} {net} via(s) inside the sink region (plated barrel {:.0} um, {thick} mm board, lateral copper from the pad edge), {} and {} on a {:.1} mm2 patch, into a sink of {:.2} K/W carrying all {:.2} W of its parts; no board-spreading or air credit on this path",
            on_path.len(), plating * 1000.0, if s["maskOpen"] == true { "no solder mask (maskOpen)".to_string() } else { format!("solder mask at {MASK_KMM2_PER_W} K mm2/W") },
            match num(&s["interfaceKPerW"]) { Some(i) => format!("the interface at {i} K/W over the {:.0} mm2 region", region_area), None => "no interface resistance declared (direct contact assumed)".to_string() }, patch, r_sink, sink_watts);
        row["boardSink"] = json!({"name": name, "layer": layer, "used": sink_pass, "viasOnPath": on_path.len(), "patchMm2": r1(patch), "rViasKPerW": r1(r_vias), "rMaskKPerW": r1(r_mask), "rInterfaceKPerW": r1(r_if), "rSinkKPerW": (r_sink * 100.0).round() / 100.0, "sinkWatts": sink_watts, "riseEstimateC": r1(rise), "thetaEstimateCPerW": r1(theta), "assumption": assumption});
        if row["status"] == "pass" { continue; }
        if sink_pass {
            row["status"] = json!("pass");
            row["how"] = json!(format!("board sink {name}: about {:.0} C/W, {:.0} C at {watts:.2} W through {} vias into the {layer} sink (still-air copper path alone: {} C/W)", theta, rise, on_path.len(), row["thetaEstimateCPerW"]));
            if let Some(o) = row.as_object_mut() { o.remove("hint"); }
        } else {
            row["hint"] = json!(format!("{}; board sink {name}: about {:.0} C/W ({:.0} C) through {} vias (vias {:.0}, mask {:.0}, interface {:.0}, sink {:.1} K/W): more vias in the pad over the sink, or a Fields solve with fieldsHeatsink", row["hint"].as_str().unwrap_or(""), theta, rise, on_path.len(), r_vias, r_mask, r_if, r_sink));
        }
    }
    Ok(())
}

/// Accept the same-input Fields solve with the named conducting block for the sink's parts whose
/// estimate failed. Call only after the field screen passed on this board and spec. Returns notes.
pub fn accept_solve(results: &mut [Value], spec: &Value, fields: &Value) -> Vec<String> {
    let Ok(sinks) = validate(spec) else { return Vec::new() };
    let mut notes = Vec::new();
    for row in results.iter_mut() {
        if row["status"] == "pass" { continue; }
        let reference = row["ref"].as_str().unwrap_or("").to_string();
        let Some(s) = sink_of(&sinks, &reference) else { continue };
        let Some(block_name) = s["fieldsHeatsink"].as_str() else { continue };
        let name = s["name"].as_str().unwrap_or("");
        let block = fields["heatsinks"].as_array().into_iter().flatten().find(|h| h["definition"]["name"].as_str() == Some(block_name));
        let chip = fields["chips"].as_array().into_iter().flatten().find(|c| c["reference"].as_str() == Some(reference.as_str()));
        let why_not = match (block, chip) {
            (None, _) => Some(format!("the Fields report has no heatsink block {block_name}")),
            (_, None) => Some(format!("the Fields report has no chip {reference}")),
            (Some(b), Some(_)) => {
                let d = &b["definition"];
                let bounds: Option<Vec<f64>> = d["boundsMm"].as_array().map(|a| a.iter().filter_map(num).collect());
                let inside = match (region_of(s, None).ok(), bounds) {
                    (Some(reg), Some(bb)) if bb.len() == 4 => {
                        let (x0, y0, x1, y1) = aiflow_board::geom::bbox(&reg);
                        bb[0] >= x0 - 0.05 && bb[1] >= y0 - 0.05 && bb[2] <= x1 + 0.05 && bb[3] <= y1 + 0.05
                    }
                    (None, Some(bb)) if bb.len() == 4 => s["region"] == "outline",
                    _ => false,
                };
                if d["layer"].as_str() != s["layer"].as_str() { Some(format!("block {block_name} is on {}, the sink on {}", d["layer"], s["layer"])) }
                else if !inside { Some(format!("block {block_name} lies outside the {name} region")) }
                else if !num(&b["contactW"]).is_some_and(|w| w > 0.0) || !num(&b["contactMovingW"]).is_some_and(|w| w > 0.0) { Some(format!("block {block_name} carries no heat in the solve")) }
                else { None }
            }
        };
        if let Some(w) = why_not { notes.push(format!("{reference}: Fields evidence for board sink {name} not accepted: {w}")); continue; }
        let (b, c) = (block.unwrap(), chip.unwrap());
        let d = &b["definition"];
        let assumption = format!("{reference} on board sink {name} ({}) is judged by the same-input Adom Fields solve with conducting block {block_name}: {} mm thick, {} W/mK, contact {} K mm2/W on {}, losing heat to still or moving air on its exposed faces; the sink's declared K/W is not part of that solve. Fields: {} C still, {} C moving at {} W; block takes {} W still",
            s["note"].as_str().unwrap_or(""), d["thicknessMm"], d["conductivityWPerMK"], d["contactResistanceMm2KPerW"], d["layer"].as_str().unwrap_or(""), c["riseC"], c["riseMovingC"], c["watts"], b["contactW"]);
        row["status"] = json!("pass");
        row["how"] = json!(format!("board sink {name}: the same-input Fields solve with block {block_name} keeps {reference} at {} C (still) / {} C (moving); the copper-path estimate alone did not", c["riseC"], c["riseMovingC"]));
        row["boardSink"]["evidence"] = json!({"kind": "fields-heatsink", "block": block_name, "riseC": c["riseC"], "riseMovingC": c["riseMovingC"], "contactW": b["contactW"]});
        row["boardSink"]["used"] = json!(true);
        row["boardSink"]["assumption"] = json!(assumption);
        if let Some(o) = row.as_object_mut() { o.remove("hint"); }
        notes.push(format!("{reference}: accepted on the Fields solve with block {block_name}"));
    }
    notes
}

/// The recorded assumptions of every sink that carried a part, for the run summary and finish.
pub fn assumptions(results: &[Value]) -> Vec<String> {
    results.iter().filter(|r| r["boardSink"]["used"] == true).filter_map(|r| r["boardSink"]["assumption"].as_str().map(str::to_string)).collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    // a 4 mm QFN exposed pad on GND at (20, 15), five in-pad vias, B.Cu poured; a sink under it
    fn board(vias: &[(f64, f64)]) -> Board {
        let mut t = String::from(r#"(kicad_pcb (version 20241229) (generator "pcbnew") (net 0 "") (net 1 "GND")
 (gr_rect (start 0 0) (end 40 30) (layer "Edge.Cuts"))
 (footprint "QFN-24" (layer "F.Cu") (at 20 15) (property "Reference" "U1")
  (pad "25" smd rect (at 0 0) (size 4 4) (layers "F.Cu") (net 1 "GND")))
 (footprint "QFN-24" (layer "F.Cu") (at 5 5) (property "Reference" "U2")
  (pad "25" smd rect (at 0 0) (size 3 3) (layers "F.Cu") (net 1 "GND")))
"#);
        for v in vias { t += &format!(" (via (at {} {}) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net 1))\n", v.0, v.1); }
        t += ")";
        Board::from_text("t.kicad_pcb", t).unwrap()
    }
    fn zs() -> Value { json!({"zones":[{"type":"copper","net":"GND","layers":["F.Cu"],"fills":[{"areaMm2":20.0}]},{"type":"copper","net":"GND","layers":["B.Cu"],"fills":[{"areaMm2":20.0}]}]}) }
    fn spec(sink: Value) -> Value {
        json!({"hot":{"U1":{"watts":1.5,"tabNet":"GND"},"U2":{"watts":1.5,"tabNet":"GND"}},"hotMaxRiseC":50,"boardSink":[sink]})
    }
    fn sink() -> Value { json!({"name":"pedestal","region":[[15,10],[25,10],[25,20],[15,20]],"layer":"B.Cu","kPerW":2.0,"interfaceKPerW":1.0,"parts":["U1"],"note":"Al pedestal, 1 mm gap pad 3 W/mK, datasheet values"}) }
    fn rows(b: &Board, sp: &Value) -> Vec<Value> {
        let plan = json!({"nets":[]});
        let (_, mut r) = super::super::analyze_thermal_on_board(&plan, sp, Some(&zs()), b);
        apply(&mut r, sp, b, Some(&zs())).unwrap();
        r
    }

    #[test]
    fn a_tabless_part_on_a_declared_sink_passes_through_its_vias() {
        let b = board(&[(19.0, 14.0), (21.0, 14.0), (20.0, 15.0), (19.0, 16.0), (21.0, 16.0), (19.0, 15.0), (21.0, 15.0), (20.0, 14.0), (20.0, 16.0)]);
        let sp = spec(sink());
        let r = rows(&b, &sp);
        let u1 = r.iter().find(|x| x["ref"] == "U1").unwrap();
        let u2 = r.iter().find(|x| x["ref"] == "U2").unwrap();
        assert_eq!(u1["status"], "pass", "{u1}");
        assert_eq!(u1["boardSink"]["viasOnPath"], 9);
        assert!(u1["boardSink"]["assumption"].as_str().unwrap().contains("no board-spreading"));
        // the part not on the sink keeps its still-air copper check: 40 mm2 of copper fails at 1.5 W
        assert_eq!(u2["status"], "fail", "{u2}");
        assert!(u2.get("boardSink").is_none());
        assert_eq!(assumptions(&r).len(), 1);
    }

    #[test]
    fn no_credit_without_vias_into_the_region_or_with_a_weak_sink() {
        // vias beside the part but outside the region
        let b = board(&[(19.0, 14.0), (21.0, 14.0)]);
        let mut s = sink(); s["region"] = json!([[30,25],[39,25],[39,29],[30,29]]);
        let r = rows(&b, &spec(s));
        let u1 = r.iter().find(|x| x["ref"] == "U1").unwrap();
        assert_eq!(u1["status"], "fail"); assert_eq!(u1["boardSink"]["used"], false);
        // a sink of 30 K/W cannot carry 1.5 W inside a 50 C budget
        let b = board(&[(19.0, 14.0), (21.0, 14.0), (20.0, 15.0)]);
        let mut s = sink(); s["kPerW"] = json!(30.0);
        let r = rows(&b, &spec(s));
        assert_eq!(r.iter().find(|x| x["ref"] == "U1").unwrap()["status"], "fail");
        // h and area instead of K/W: 10 W/m2K on 100 mm2 is 1000 K/W
        let mut s = sink(); s.as_object_mut().unwrap().remove("kPerW"); s["hWm2K"] = json!(10.0);
        let r = rows(&b, &spec(s));
        assert_eq!(r.iter().find(|x| x["ref"] == "U1").unwrap()["boardSink"]["rSinkKPerW"], 1000.0);
    }

    #[test]
    fn declarations_are_validated() {
        for (k, v) in [("layer", json!("In1.Cu")), ("kPerW", json!(-1)), ("parts", json!(["U9"])), ("note", json!("")), ("region", json!([[0,0],[1,1]]))] {
            let mut s = sink(); s[k] = v;
            assert!(validate(&spec(s)).is_err(), "{k}");
        }
        let mut both = sink(); both["hWm2K"] = json!(10.0);
        assert!(validate(&spec(both)).is_err());
        let mut sp = spec(sink()); sp["boardSink"].as_array_mut().unwrap().push({ let mut s = sink(); s["name"] = json!("other"); s });
        assert!(validate(&sp).unwrap_err().contains("two sinks"));
    }

    #[test]
    fn a_fields_solve_with_the_named_block_is_accepted_only_when_it_matches() {
        let b = board(&[(20.0, 15.0)]);
        let mut s = sink(); s["kPerW"] = json!(30.0); s["fieldsHeatsink"] = json!("pedestal-block");
        let sp = spec(s);
        let mut r = rows(&b, &sp);
        assert_eq!(r.iter().find(|x| x["ref"] == "U1").unwrap()["status"], "fail");
        let f = json!({"heatsinks":[{"definition":{"name":"pedestal-block","layer":"B.Cu","boundsMm":[16,11,24,19],"thicknessMm":5,"conductivityWPerMK":200,"contactResistanceMm2KPerW":300},"contactW":1.2,"contactMovingW":1.1}],
            "chips":[{"reference":"U1","watts":1.5,"riseC":31.0,"riseMovingC":22.0}]});
        let mut wrong = f.clone(); wrong["heatsinks"][0]["definition"]["layer"] = json!("F.Cu");
        let mut r2 = r.clone();
        accept_solve(&mut r2, &sp, &wrong);
        assert_eq!(r2.iter().find(|x| x["ref"] == "U1").unwrap()["status"], "fail");
        let mut outside = f.clone(); outside["heatsinks"][0]["definition"]["boundsMm"] = json!([0, 0, 8, 8]);
        let mut r3 = r.clone();
        assert!(accept_solve(&mut r3, &sp, &outside)[0].contains("outside"));
        accept_solve(&mut r, &sp, &f);
        let u1 = r.iter().find(|x| x["ref"] == "U1").unwrap();
        assert_eq!(u1["status"], "pass");
        assert_eq!(u1["boardSink"]["evidence"]["block"], "pedestal-block");
        assert!(assumptions(&r)[0].contains("declared K/W is not part of that solve"));
        // U2 is not on the sink: the solve does not touch its row
        assert_eq!(r.iter().find(|x| x["ref"] == "U2").unwrap()["status"], "fail");
    }
}