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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
//! What `check copper` asks of the traced copper as a whole: that no island has swallowed a face.
//!
//! On the USB hub test board (2026-11-10) the top face's laminate read as copper, so one island held 192 of the 246 joined pins, among them every pin of the 36-pin
//! hub chip, and `check copper` passed because each item on its own was well formed. These tests read what `copper trace` wrote (`detect/copper-measured.json`,
//! `detect/copper-island-accounting.json`) and `nets.json`, and fail with a sentence that says what to look at.
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
/// A face needs this many joined pins before a share of them means anything.
pub const MIN_PINS: usize = 30;
/// One island with more than this share of a face's joined pins has swallowed it. Two thirds: the hub board's top read 0.78 when its laminate read as copper; the
/// largest honest share on the boards run so far is the Driver bottom's ground pour at 0.51 (43 of 85, and several of those joins are the weak ring test's), then
/// 0.20 and under on the Driver top and both CM5 faces.
pub const SWALLOW_SHARE: f64 = 2.0 / 3.0;
/// A part with at least `BIG_PART` pins never has more than `PART_SHARE` of them on one net: the worst on the boards run so far is 4 of a 16-pin header (0.25) and
/// 9 of a 49-pad chip (0.18), where the hub chip had all 37 on the one island.
pub const BIG_PART: usize = 16;
pub const PART_SHARE: f64 = 2.0 / 3.0;
/// More copper than this over the part of a face that can be seen is not a board: the fullest face run so far, the Driver bottom, reads 0.80.
pub const MAX_COPPER_SHARE: f64 = 0.95;
/// From this share of a face's pins, an island that also joins `HINT_NETS` or more named nets is worth a look, without failing: the Driver bottom's ground pour is
/// one (0.51 of the pins, 5 nets), and what is wrong there is pad joins, which `connect measure` replaces.
const HINT_SHARE: f64 = 1.0 / 3.0;
const HINT_NETS: usize = 3;
pub struct FaceStat { pub side: String, pub copper_share: Option<f64>, pub both_copper: Option<f64>, pub both_laminate: Option<f64>, pub edges: usize }
/// `joins`: (side, island, pin) for every pin the trace joined to an island. `part_pins`: how many pins each part has. `pin_net`: the nets named so far.
/// Returns what fails and what is only worth a look.
pub fn judge(joins: &[(String, u64, String)], part_pins: &HashMap<String, usize>, pin_net: &HashMap<String, String>, faces: &[FaceStat]) -> (Vec<String>, Vec<String>) {
let (mut errs, mut hints) = (vec![], vec![]);
let mut by_face: BTreeMap<&str, BTreeMap<u64, Vec<&str>>> = BTreeMap::new();
for (side, isl, pin) in joins { by_face.entry(side.as_str()).or_default().entry(*isl).or_default().push(pin.as_str()); }
for (side, islands) in &by_face {
let total: usize = islands.values().map(|v| v.len()).sum();
let see = format!("evidence/copper-islands-{side}.jpg");
if let Some((isl, pins)) = islands.iter().max_by_key(|x| x.1.len()) {
let share = pins.len() as f64 / total.max(1) as f64;
let mut nets: BTreeMap<&str, usize> = BTreeMap::new();
for p in pins { if let Some(n) = pin_net.get(*p) { *nets.entry(n.as_str()).or_default() += 1; } }
let named = if nets.len() >= 2 { let mut v: Vec<(&str, usize)> = nets.iter().map(|x| (*x.0, *x.1)).collect(); v.sort_by(|a, b| b.1.cmp(&a.1)); format!(", named on {} different nets ({})", v.len(), v.iter().take(5).map(|x| format!("{} x{}", x.0, x.1)).collect::<Vec<_>>().join(", ")) } else { String::new() };
if total >= MIN_PINS && share > SWALLOW_SHARE {
errs.push(format!("{side} face: island {isl} holds {} of the {total} pins joined on this face ({:.0} percent){named}. No one conductor holds two thirds of a face's pins: laminate has been read as copper here, or a gap was lost, and everything joined through it. Look at {see} against the photo, and at the levels in the run log of `copper trace`.", pins.len(), share * 100.0));
} else if total >= MIN_PINS && share > HINT_SHARE && nets.len() >= HINT_NETS {
hints.push(format!("{side} face: island {isl} holds {} of the {total} joined pins ({:.0} percent){named}. Look at it in {see}: either pads were joined to a pour they only sit in, or the pour is two conductors.", pins.len(), share * 100.0));
}
}
// one island with most of a large part's pins
for (isl, pins) in islands {
let mut per: BTreeMap<&str, usize> = BTreeMap::new();
for p in pins { *per.entry(p.split('.').next().unwrap_or("")).or_default() += 1; }
for (part, n) in per { let all = part_pins.get(part).copied().unwrap_or(0);
if all >= BIG_PART && n as f64 > PART_SHARE * all as f64 { errs.push(format!("{side} face: island {isl} joins {n} of the {all} pins of {part}. A part that size has its pins on many nets, so this island is several conductors read as one. Look at {part} in {see}.")); } }
}
}
for f in faces {
if let Some(s) = f.copper_share { if s > MAX_COPPER_SHARE { errs.push(format!("{} face: {:.0} percent of what can be seen read as copper. No board is that full: the laminate level was not found. Look at evidence/copper-islands-{}.jpg.", f.side, s * 100.0, f.side)); } }
if let (Some(a), Some(b)) = (f.both_copper, f.both_laminate) { if f.edges >= 500 && a.max(b) > 0.5 {
errs.push(format!("{} face: {:.0} percent of the photo's copper-sized step edges lie wholly in the {} class, after the levels were taken from those edges. The copper and laminate levels do not fit this photo, so the islands on this face cannot be trusted.", f.side, a.max(b) * 100.0, if a > b { "copper" } else { "laminate" })); } }
}
(errs, hints)
}
/// Read what the trace wrote and judge it. A run traced before these figures existed has only the pin tests applied.
pub fn of_run(flow: &Path) -> (Vec<String>, Vec<String>) {
let rd = |p: &str| -> Value { std::fs::read_to_string(flow.join(p)).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(Value::Null) };
let m = rd("detect/copper-measured.json");
if m.is_null() { return (vec![], vec![]); }
let joins: Vec<(String, u64, String)> = m["joins"].as_array().into_iter().flatten().filter_map(|j| Some((j["side"].as_str()?.to_string(), j["island"].as_u64()?, j["pin"].as_str()?.to_string()))).collect();
let mut part_pins: HashMap<String, usize> = HashMap::new();
for p in m["profile"].as_array().into_iter().flatten() { if let Some(pin) = p["pin"].as_str() { *part_pins.entry(pin.split('.').next().unwrap_or("").to_string()).or_default() += 1; } }
let mut pin_net: HashMap<String, String> = HashMap::new();
for n in rd("nets.json")["nets"].as_array().into_iter().flatten() { for q in n["pins"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) { pin_net.insert(q.to_string(), n["name"].as_str().unwrap_or("").to_string()); } }
// a face the reader gave up on is said so, not failed again (`faceread`)
let acc = rd("detect/copper-island-accounting.json");
let unread: Vec<String> = acc["perFace"].as_array().into_iter().flatten().filter(|f| f["unread"].is_string()).map(|f| format!("{} face was NOT READ: {}. Nothing is drawn from it and its pins join nothing there: their nets must come from the other face, a datasheet or the bench.", f["side"].as_str().unwrap_or(""), f["unread"].as_str().unwrap_or(""))).collect();
let faces: Vec<FaceStat> = acc["perFace"].as_array().into_iter().flatten().filter(|f| !f["unread"].is_string()).map(|f| FaceStat { side: f["side"].as_str().unwrap_or("").to_string(), copper_share: f["copperShare"].as_f64(), both_copper: f["levelsAgainstEdges"]["bothCopper"].as_f64(), both_laminate: f["levelsAgainstEdges"]["bothLaminate"].as_f64(), edges: f["levelsAgainstEdges"]["edges"].as_u64().unwrap_or(0) as usize }).collect();
let (errs, mut hints) = judge(&joins, &part_pins, &pin_net, &faces);
hints.extend(unread);
for f in acc["perFace"].as_array().into_iter().flatten().filter(|f| f["warning"].is_string() && !f["unread"].is_string()) { hints.push(format!("{} face, worth a look: {}", f["side"].as_str().unwrap_or(""), f["warning"].as_str().unwrap_or(""))); }
for r in m["partPinsRefused"].as_array().into_iter().flatten() { hints.push(format!("{}: island {} on the {} face held {} of its {} pins, so none of them was joined to it. A part that size has its pins on many nets: the island is several conductors read as one. Look at {} in evidence/copper-islands-{}.jpg.", r["part"].as_str().unwrap_or(""), r["island"], r["side"].as_str().unwrap_or(""), r["pins"], r["of"], r["part"].as_str().unwrap_or(""), r["side"].as_str().unwrap_or(""))); }
for r in m["padPairsRefused"].as_array().into_iter().flatten() { hints.push(format!("{}: both pads lay on one island ({}), so neither was joined to it. Look at the part in evidence/copper-islands-*.jpg: a gap beside it was lost.", r["part"].as_str().unwrap_or(""), r["islands"])); }
(errs, hints)
}
#[cfg(test)]
mod tests {
use super::*;
fn joins(spec: &[(&str, u64, &str, usize)]) -> Vec<(String, u64, String)> { spec.iter().flat_map(|(side, isl, part, n)| (1..=*n).map(move |k| (side.to_string(), *isl, format!("{part}.{k}")))).collect() }
#[test]
fn one_island_with_most_of_a_faces_pins_fails_and_says_which() {
// the hub board's baseline: island 157 with 192 pins, the rest spread thin
let mut j = joins(&[("top", 157, "U1", 37), ("top", 157, "R", 155)]);
j.extend(joins(&[("top", 9, "C", 30), ("top", 12, "J", 24)]));
let parts = HashMap::from([("U1".to_string(), 37usize)]);
let (errs, _) = judge(&j, &parts, &HashMap::new(), &[]);
assert!(errs.iter().any(|e| e.contains("island 157 holds 192 of the 246")), "{errs:?}");
assert!(errs.iter().any(|e| e.contains("37 of the 37 pins of U1")), "{errs:?}");
}
#[test]
fn a_ground_pour_with_half_the_pins_passes_with_a_hint() {
// the Driver bottom: 43 of 85 on the ground pour, five nets named among them
let mut j = joins(&[("bottom", 211, "J7", 4), ("bottom", 211, "H", 39)]);
j.extend(joins(&[("bottom", 3, "K", 42)]));
let mut nets = HashMap::new();
for (k, n) in ["GND", "GND", "+3V3", "CAN_H"].iter().enumerate() { nets.insert(format!("J7.{}", k + 1), n.to_string()); }
let parts = HashMap::from([("J7".to_string(), 16usize), ("H".to_string(), 100), ("K".to_string(), 100)]);
let (errs, hints) = judge(&j, &parts, &nets, &[]);
assert!(errs.is_empty(), "{errs:?}");
assert!(hints.iter().any(|h| h.contains("island 211") && h.contains("3 different nets")), "{hints:?}");
}
#[test]
fn a_face_that_is_all_copper_or_whose_levels_miss_its_edges_fails() {
let f = [FaceStat { side: "top".into(), copper_share: Some(0.97), both_copper: Some(0.1), both_laminate: Some(0.1), edges: 9000 }, FaceStat { side: "bottom".into(), copper_share: Some(0.5), both_copper: Some(0.73), both_laminate: Some(0.06), edges: 9000 }];
let (errs, _) = judge(&[], &HashMap::new(), &HashMap::new(), &f);
assert_eq!(errs.len(), 2, "{errs:?}");
assert!(errs[0].contains("97 percent") && errs[1].contains("73 percent"));
}
}