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
Publish 0.2.3
6e27c4b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
//! 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(®ion).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, ®ion))
.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(®);
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");
}
}