app
AI Flow: Reverse Engineering
Public Made by Adomby adom
Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.
main
Caleb
Publish 0.5.0
1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
//! The layer count, stated without routing. `layers state` writes layers.json from what is known and nothing else:
//!
//! - a count from outside evidence, given with `--count` and either `--source` (a document) or `--evidence <file> --kind ...`
//! (a measurement on the board itself: an edge micrograph, a cross-section, an X-ray or a CT scan);
//! - otherwise the count is `UNKNOWN`, with its test, and beside it the minimum the photos prove or suggest.
//!
//! Photos show two faces and never an inner layer, so they can only bound the count from below. Routing difficulty is no
//! evidence of a layer count either (see docs/method.md), which is why the default flow no longer routes to find one.
use crate::checks::{Outcome, Pass};
use crate::items;
use serde_json::{json, Value};
use std::path::Path;
/// A via counts as ending inside the board only when the other face shows no candidate of any kind this close to it, mm.
/// The via detectors place a dome within about 0.1 mm of its pair on the other face (`domes::pits_confirmed` uses 0.1 mm), so
/// three times that leaves no room for a pair that was read a little off. Policy, not fitted to a board.
const OTHER_FACE_CLEAR_MM: f64 = 0.3;
/// Fewer one-face vias than this prove nothing: a handful can be detector misses. Policy, not fitted to a board.
const ONE_FACE_MIN: usize = 10;
/// And the two faces must show at least this many vias in the same place, or the photos have not shown that a through via
/// appears on both. Policy, not fitted to a board.
const BOTH_FACES_MIN: usize = 10;
/// Kinds of outside evidence that are a measurement on the board itself. Anything else is a document.
pub const MEASURED_KINDS: [&str; 4] = ["edge-micrograph", "cross-section", "xray", "ct"];
fn st<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }
fn xy(v: &Value) -> Option<(f64, f64)> { Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)) }
/// What the photos say about the layer count: counts, and the minimum that follows.
pub fn photo_evidence(flow: &Path) -> Value {
let copper = items::read_json(&flow.join("copper.json")).unwrap_or(json!({}));
let on = |layer: &str| copper["zones"].as_array().into_iter().flatten().chain(copper["traces"].as_array().into_iter().flatten()).any(|z| st(z, "layer") == layer);
let faces = usize::from(on("F.Cu")) + usize::from(on("B.Cu"));
let vias: Vec<&Value> = copper["vias"].as_array().into_iter().flatten().collect();
let inner = vias.iter().filter(|v| st(v, "kind") == "inner").count();
let both = vias.iter().filter(|v| st(v, "seen") == "both-faces").count();
let hidden = vias.iter().filter(|v| st(v, "seen").contains("hidden")).count();
// every candidate either detector looked at, kept or not, by face
let mut cands: Vec<(String, f64, f64)> = vec![];
for f in ["detect/dome-candidates.json", "detect/pit-candidates.json"] {
if let Ok(d) = items::read_json(&flow.join(f)) { for c in d["candidates"].as_array().into_iter().flatten() { if let Some((x, y)) = xy(&c["pos_mm"]) { cands.push((st(c, "side").to_string(), x, y)); } } }
}
let have_cands = !cands.is_empty();
let (mut one, mut clear) = (0usize, 0usize);
for v in vias.iter().filter(|v| matches!(st(v, "seen"), "top" | "bottom")) {
one += 1;
let Some((x, y)) = xy(&v["pos_mm"]) else { continue };
let other = if st(v, "seen") == "top" { "bottom" } else { "top" };
if have_cands && !cands.iter().any(|c| c.0 == other && (c.1 - x).abs() <= OTHER_FACE_CLEAR_MM && (c.2 - y).abs() <= OTHER_FACE_CLEAR_MM) { clear += 1; }
}
let (layers, tag, why) = if inner > 0 {
(4, "MEASURED", format!("{inner} via(s) are ringed on both faces with no copper leaving on either, so they continue on an inner layer: at least 4 layers"))
} else if clear >= ONE_FACE_MIN && both >= BOTH_FACES_MIN {
(4, "INFERRED", format!("{clear} vias show on one face while the other face is in view there and shows no via candidate within {OTHER_FACE_CLEAR_MM} mm, and {both} other vias show on both faces, so the photos do show through vias: a via that ends inside the board needs an inner layer, so more than 2 layers (4 in practice) is likely. Inferred, not proven: a via plugged or tented flat on one face reads the same"))
} else if faces >= 2 {
(2, "SEEN", "copper was read on both faces; nothing in the photos shows a via ending inside the board".to_string())
} else {
(1, "SEEN", "copper was read on one face only: the other face was not photographed or shows none".to_string())
};
json!({"facesWithCopper": faces, "vias": vias.len(), "viasOnBothFaces": both, "viasWithTheOtherFaceHidden": hidden, "viasOnOneFaceOnly": one, "viasOnOneFaceOtherFaceClear": clear, "innerVias": inner,
"rule": {"otherFaceClearMm": OTHER_FACE_CLEAR_MM, "oneFaceMin": ONE_FACE_MIN, "bothFacesMin": BOTH_FACES_MIN},
"minimum": {"layers": layers, "tag": tag, "why": why}})
}
/// An outside file, by hash. It must not be one of the run's own photos or products.
fn outside_file(flow: &Path, path: &str) -> Result<Value, String> {
let p = items::resolve(flow, path).ok_or(format!("evidence file {path} is not there"))?;
let full = std::fs::canonicalize(&p).map_err(|e| e.to_string())?;
for d in ["photos", "board", "evidence", "audits", "detect"] {
if let Ok(x) = std::fs::canonicalize(flow.join(d)) { if full.starts_with(x) { return Err(format!("{path} is this run's own {d}: outside evidence means an edge micrograph, a cross-section, an X-ray, a CT scan or a document")); } }
}
Ok(json!({"file": full.display().to_string(), "sha256": crate::sha256(&full)}))
}
/// What `layers state` was asked: a count with where it comes from, or nothing.
pub struct Ask<'a> { pub count: Option<usize>, pub source: Option<&'a str>, pub evidence: Option<&'a str>, pub kind: Option<&'a str> }
/// Write layers.json as a statement. With no count asked, a count the file already holds from outside evidence is kept; a
/// count that came from routing is not, and the routing record moves under `routing`.
pub fn state(flow: &Path, ask: &Ask) -> Result<(Value, Vec<String>), String> {
let old = items::read_json(&flow.join("layers.json")).unwrap_or(json!({}));
let ev = photo_evidence(flow);
let min = ev["minimum"].clone();
let min_n = min["layers"].as_u64().unwrap_or(2) as usize;
let mut hints = vec![format!("photos: {}", st(&min, "why"))];
// what is stated: asked now, or kept from an earlier statement that rests on outside evidence
let kept = old["proposed"].as_u64().filter(|_| matches!(st(&old, "tag"), "DATASHEET" | "MEASURED") && (!st(&old, "source").is_empty() || old["evidenceFile"].is_object()));
let mut doc = json!({"method": "stated", "tests": ["T-layers"], "planes": [], "photoEvidence": ev, "minimum": min});
match (ask.count, kept) {
(Some(0), _) | (None, None) => {
doc["proposed"] = Value::Null; doc["tag"] = json!("UNKNOWN");
doc["why"] = json!(format!("The layer count is unknown: photos show two faces and never an inner layer, and no outside evidence was given. From the photos: {}. T-layers settles it: count the copper layers on the board edge under a microscope, or X-ray the board, then `layers state --count <n> --evidence <file> --kind edge-micrograph`", st(&min, "why")));
hints.push("layer count UNKNOWN, with test T-layers. Outside evidence states it: `layers state --count <n> --source \"<document>\"` or `--evidence <file> --kind edge-micrograph|cross-section|xray|ct`".into());
}
(Some(n), _) => {
if n % 2 == 1 && n > 1 { hints.push(format!("{n} is an odd layer count: unusual, check the source")); }
if n < min_n && st(&min, "tag") != "INFERRED" { return Err(format!("{n} layers stated, but the photos prove at least {min_n}: {}", st(&min, "why"))); }
if n < min_n { hints.push(format!("{n} layers stated, but the photos suggest at least {min_n}: {}", st(&min, "why"))); }
let file = match ask.evidence { Some(p) => Some(outside_file(flow, p)?), None => None };
let kind = ask.kind.unwrap_or(if file.is_some() { "" } else { "document" });
let measured = MEASURED_KINDS.contains(&kind);
if measured && file.is_none() { return Err(format!("--kind {kind} is a measurement on the board: attach it with --evidence <file>")); }
if file.is_some() && kind.is_empty() { return Err(format!("--evidence needs --kind: {} (a measurement on the board) or document", MEASURED_KINDS.join(", "))); }
let source = ask.source.unwrap_or("").trim();
if !measured && source.is_empty() { return Err("a count from a document needs --source \"<document, where in it, when fetched>\"".into()); }
doc["proposed"] = json!(n); doc["tag"] = json!(if measured { "MEASURED" } else { "DATASHEET" });
doc["source"] = json!(if source.is_empty() { format!("{kind} of this board") } else { source.to_string() });
if let Some(f) = &file { doc["evidenceFile"] = json!({"file": f["file"], "sha256": f["sha256"], "kind": kind}); doc["evidence"] = json!([f["file"]]); }
doc["why"] = json!(format!("{n} layers, stated from outside evidence ({}), not from the photos and not from routing. From the photos: {}", st(&doc, "source"), st(&min, "why")));
hints.push(format!("{n} layers stated, tag {}. The layers score stays capped until an independent verdict with that evidence attached: `audit draw --category layers`, then `audit verdict --category layers ... --evidence <file> --kind <kind>`", st(&doc, "tag")));
}
(None, Some(n)) => {
for k in ["proposed", "tag", "source", "evidenceFile", "evidence"] { if !old[k].is_null() { doc[k] = old[k].clone(); } }
doc["why"] = json!(format!("{n} layers, stated from outside evidence ({}), not from the photos and not from routing. From the photos: {}", st(&old, "source"), st(&min, "why")));
hints.push(format!("kept the count already stated from outside evidence: {n} layers ({}). `layers state --count 0` withdraws it", st(&old, "tag")));
}
}
// what an earlier optional routing recorded is kept, out of the statement's way
let had = old["candidates"].as_array().map(|a| !a.is_empty()).unwrap_or(false);
doc["routing"] = if had { json!({"ran": true, "note": "an earlier optional `connect route`; its stackups are kept here and no longer set the count", "candidates": old["candidates"], "trail": old["trail"], "escalation": old["escalation"], "said": if old["routingSaid"].is_null() { old["why"].clone() } else { old["routingSaid"].clone() }}) }
else if old["routing"]["ran"] == json!(true) { old["routing"].clone() }
else { json!({"ran": false, "note": "hidden connections are left unrouted (unrouted.json): `connect route` is optional and proves no layer count"}) };
std::fs::write(flow.join("layers.json"), serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?;
Ok((doc, hints))
}
/// Whether layers.json is a statement that still matches the photos' evidence.
pub fn current(flow: &Path) -> bool {
let Ok(d) = items::read_json(&flow.join("layers.json")) else { return false };
d["method"] == json!("stated") && d["photoEvidence"] == photo_evidence(flow)
}
/// `check layers` on a statement: honest about what is known, and nothing typed past it.
pub fn check(flow: &Path, d: &Value, pcb: Option<&Path>) -> Outcome {
let mut errs = vec![];
for it in items::items_of("layers.json", d) { items::validate(flow, &it, &mut errs); }
let ev = photo_evidence(flow);
if d["photoEvidence"] != ev { errs.push("the photos' evidence in layers.json is not what copper.json gives now: run `layers state` again (it keeps a count stated from outside evidence)".into()); }
let min_n = ev["minimum"]["layers"].as_u64().unwrap_or(2);
if st(d, "why").is_empty() { errs.push("`why` missing: run `layers state`".into()); }
match d["proposed"].as_u64() {
None => {
if st(d, "tag") != "UNKNOWN" { errs.push(format!("no layer count is stated, so the tag must be UNKNOWN, not {:?}: run `layers state`", st(d, "tag"))); }
if d["tests"].as_array().map(|a| a.is_empty()).unwrap_or(true) { errs.push("an unknown layer count needs its test (T-layers): run `layers state`".into()); }
}
Some(n) => {
match st(d, "tag") {
"DATASHEET" => if st(d, "source").is_empty() { errs.push(format!("{n} layers tagged DATASHEET with no source: `layers state --count {n} --source \"<document>\"`")); },
"MEASURED" => match d["evidenceFile"]["file"].as_str() {
Some(f) if crate::sha256(Path::new(f)) == st(&d["evidenceFile"], "sha256") => {}
Some(f) => errs.push(format!("the evidence file {f} is missing or changed since it was stated: `layers state --count {n} --evidence <file> --kind <kind>` again")),
None => errs.push(format!("{n} layers tagged MEASURED with no evidence file: `layers state --count {n} --evidence <file> --kind edge-micrograph|cross-section|xray|ct`")),
},
t => errs.push(format!("{n} layers stated with tag {t:?}: without routing a count comes from outside evidence only (DATASHEET or MEASURED). `layers state --count {n} --source ...`, or `layers state --count 0` for UNKNOWN")),
}
if n < min_n && st(&ev["minimum"], "tag") != "INFERRED" { errs.push(format!("{n} layers stated, but the photos prove at least {min_n}: {}", st(&ev["minimum"], "why"))); }
if let Some(p) = pcb {
match std::fs::read_to_string(p) {
Ok(t) => { let have = crate::kicad_in::copper_layers(&t); if have as u64 != n { errs.push(format!("{} has {have} copper layers, {n} stated: run `build` (it takes the stated count) and check again", p.display())); } }
Err(e) => errs.push(format!("{}: {e}", p.display())),
}
}
}
}
if !errs.is_empty() { return Err(errs); }
let nets: Vec<Value> = items::read_json(&flow.join("nets.json")).ok().and_then(|n| n["nets"].as_array().cloned()).unwrap_or_default();
let hidden = crate::unrouted::count(&nets);
let mut hints = vec![format!("photos: {}", st(&ev["minimum"], "why"))];
hints.push(match d["proposed"].as_u64() { Some(n) => format!("{n} layers stated ({}): {}", st(d, "tag"), st(d, "source")), None => "layer count UNKNOWN, settled by T-layers".to_string() });
hints.push(format!("{hidden} hidden connection(s) left unrouted as ratsnest, each with its evidence tag: unrouted.json. `connect route` is optional"));
Ok(Pass { summary: json!({"proposed": d["proposed"], "tag": d["tag"], "minimum": ev["minimum"], "routed": false, "hiddenConnections": hidden}), hints, files: vec!["layers.json".into(), "nets.json".into(), "copper.json".into()] })
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("layerstate-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("detect")).unwrap();
d
}
fn copper(d: &Path, vias: Value) {
std::fs::write(d.join("copper.json"), json!({"outline": {"points_mm": [[100, 100], [140, 100], [140, 140]], "tag": "MEASURED", "evidence": ["copper.json"]},
"zones": [{"id": "a", "layer": "F.Cu", "net": "GND"}, {"id": "b", "layer": "B.Cu", "net": "GND"}], "traces": [], "vias": vias}).to_string()).unwrap();
std::fs::write(d.join("nets.json"), json!({"nets": [{"name": "GND", "pins": ["R1.1", "R2.1"], "copperGroups": []}]}).to_string()).unwrap();
}
#[test]
fn with_nothing_given_the_count_is_unknown_with_its_test_and_the_check_passes() {
let d = dir("unknown");
copper(&d, json!([{"id": "v1", "pos_mm": [110, 110], "seen": "both-faces", "kind": "seen"}]));
let (doc, _) = state(&d, &Ask { count: None, source: None, evidence: None, kind: None }).unwrap();
assert!(doc["proposed"].is_null());
assert_eq!(doc["tag"], "UNKNOWN");
assert_eq!(doc["tests"], json!(["T-layers"]));
assert_eq!(doc["minimum"]["layers"], 2);
assert_eq!(doc["minimum"]["tag"], "SEEN");
let pass = check(&d, &doc, None).unwrap();
assert_eq!(pass.summary["routed"], json!(false));
assert_eq!(pass.summary["hiddenConnections"], json!(1));
// the layers item is open, so a test is generated for it
assert!(items::items_of("layers.json", &doc).iter().any(|i| i.id == "layers" && i.open()));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn a_count_needs_a_document_or_a_measurement_and_cannot_go_under_what_the_photos_prove() {
let d = dir("count");
copper(&d, json!([{"id": "v1", "pos_mm": [110, 110], "seen": "both-faces", "kind": "inner"}]));
assert!(state(&d, &Ask { count: Some(6), source: None, evidence: None, kind: None }).is_err());
assert!(state(&d, &Ask { count: Some(6), source: None, evidence: None, kind: Some("xray") }).is_err());
assert!(state(&d, &Ask { count: Some(2), source: Some("maker's page"), evidence: None, kind: None }).err().unwrap().contains("at least 4"));
let (doc, _) = state(&d, &Ask { count: Some(6), source: Some("maker's page, fetched today"), evidence: None, kind: None }).unwrap();
assert_eq!(doc["tag"], "DATASHEET");
assert_eq!(doc["minimum"]["layers"], 4);
assert!(check(&d, &doc, None).is_ok());
// stated again with nothing asked, the count from outside evidence is kept; `--count 0` withdraws it
let (again, _) = state(&d, &Ask { count: None, source: None, evidence: None, kind: None }).unwrap();
assert_eq!(again["proposed"], 6);
let (gone, _) = state(&d, &Ask { count: Some(0), source: None, evidence: None, kind: None }).unwrap();
assert!(gone["proposed"].is_null());
// a micrograph of the board is a measurement, held by hash
std::fs::write(d.join("edge.jpg"), "x").unwrap();
let (m, _) = state(&d, &Ask { count: Some(6), source: None, evidence: Some("edge.jpg"), kind: Some("edge-micrograph") }).unwrap();
assert_eq!(m["tag"], "MEASURED");
assert!(check(&d, &m, None).is_ok());
std::fs::write(d.join("edge.jpg"), "changed").unwrap();
assert!(check(&d, &m, None).is_err());
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn a_count_from_routing_is_not_kept_and_a_typed_one_fails_the_check() {
let d = dir("routed");
copper(&d, json!([]));
std::fs::write(d.join("layers.json"), json!({"proposed": 6, "tag": "INFERRED", "why": "routing", "tests": ["T-layers"], "candidates": [{"layers": 6, "open": 3, "run": "routes/L6"}]}).to_string()).unwrap();
let (doc, _) = state(&d, &Ask { count: None, source: None, evidence: None, kind: None }).unwrap();
assert!(doc["proposed"].is_null());
assert_eq!(doc["routing"]["ran"], json!(true));
assert_eq!(doc["routing"]["candidates"][0]["layers"], 6);
assert!(doc["candidates"].is_null());
let mut typed = doc.clone(); typed["proposed"] = json!(4); typed["tag"] = json!("INFERRED");
assert!(check(&d, &typed, None).is_err());
// and evidence that changed under the statement asks for a new one
copper(&d, json!([{"id": "v1", "pos_mm": [110, 110], "seen": "both-faces", "kind": "inner"}]));
assert!(!current(&d));
assert!(check(&d, &doc, None).err().unwrap()[0].contains("layers state"));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn vias_that_end_on_one_face_suggest_inner_layers_only_when_the_other_face_is_clear() {
let d = dir("oneface");
let mut vias: Vec<Value> = (0..12).map(|i| json!({"id": format!("b{i}"), "pos_mm": [101.0 + i as f64, 101.0], "seen": "both-faces", "kind": "seen"})).collect();
vias.extend((0..12).map(|i| json!({"id": format!("t{i}"), "pos_mm": [101.0 + i as f64, 110.0], "seen": "top", "kind": "seen"})));
vias.extend((0..5).map(|i| json!({"id": format!("h{i}"), "pos_mm": [101.0 + i as f64, 120.0], "seen": "bottom, top hidden by a part", "kind": "seen"})));
copper(&d, json!(vias));
// no candidate list at all: nothing can be called clear
assert_eq!(photo_evidence(&d)["minimum"]["layers"], 2);
// a rejected candidate on the bottom under every one-face via: the other face is not clear
let under: Vec<Value> = (0..12).map(|i| json!({"pos_mm": [101.1 + i as f64, 110.1], "side": "bottom"})).collect();
std::fs::write(d.join("detect/dome-candidates.json"), json!({"candidates": under}).to_string()).unwrap();
assert_eq!(photo_evidence(&d)["viasOnOneFaceOtherFaceClear"], 0);
assert_eq!(photo_evidence(&d)["minimum"]["layers"], 2);
// candidates elsewhere only: the bottom is clear under them, and the minimum is inferred, never measured
let away: Vec<Value> = (0..12).map(|i| json!({"pos_mm": [101.0 + i as f64, 130.0], "side": "bottom"})).collect();
std::fs::write(d.join("detect/dome-candidates.json"), json!({"candidates": away}).to_string()).unwrap();
let ev = photo_evidence(&d);
assert_eq!(ev["viasOnOneFaceOtherFaceClear"], 12);
assert_eq!(ev["viasWithTheOtherFaceHidden"], 5);
assert_eq!(ev["minimum"]["layers"], 4);
assert_eq!(ev["minimum"]["tag"], "INFERRED");
std::fs::remove_dir_all(&d).ok();
}
}