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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
//! How well each net is proven: its weakest link, not its average.
//!
//! `connect attribute` grades every piece of copper on a ladder (`attrib.rs`). This module reads what that wrote and
//! grades each net. It reads no photo and measures nothing new. The question for a net is not how much of its copper
//! was seen but whether its pins are seen joined, so the level is defined on the connection graph:
//!
//! - **Vertices.** The net's pins, and the conductors (`node` in detect/connect-graph.json) that carry its name.
//! - **Links, strongest first.**
//! 1. `copper`: a pad joined to a conductor of the net with no gap (a solid contact). Copper stitched into one
//! conductor is one vertex already.
//! 2. `via`: two conductors of the net joined through verified vias (a link of tier 2).
//! 3. `inferred`: one via track to track (tier 3); a pad whose edge does not settle what it touches (a weak contact);
//! one unconfirmed via onto a pour, which proves nothing by itself (tier 4); and last the pin map alone, which
//! joins whatever nothing seen joins.
//! - **The path set.** The links are added strongest first until every pin of the net is in one piece (a spanning
//! tree that keeps the strongest links it can). The net's level is the rung of the weakest link in that set, and that
//! link is reported with its place. A pin with no solid contact to the net's copper is a member by inference only,
//! so a net with one such pin is `inferred` however much of its copper was seen.
//! - **One pin.** Nothing is there to join, so the net is graded by how that pin meets its copper and marked `singlePin`.
//!
//! Places where the net appears shorted to another (`shorts` in net-evidence.json) are no link between its own pins.
//! They are listed as `disputes` and do not lower the level; the copper in dispute carries no net and is drawn as
//! `conflict`. A piece of copper is drawn at the weaker of its own rung and its net's level.
use crate::items::read_json;
use serde_json::{json, Value};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
pub const FILE: &str = "net-confidence.json";
/// The ladder, strongest first: the same names `attrib.rs` writes.
pub const LADDER: [&str; 6] = ["copper", "via", "inferred", "group", "none", "conflict"];
pub fn rank(rung: &str) -> usize { LADDER.iter().position(|r| *r == rung).unwrap_or(4) }
/// The weaker of two rungs.
pub fn weaker<'a>(a: &'a str, b: &'a str) -> &'a str { if rank(a) >= rank(b) { a } else { b } }
/// Copper that carries no net of its own, by the name attribution gave it. None for a named net.
pub fn unnamed_class(net: &str) -> Option<&'static str> {
if !net.starts_with("UNK") && !net.is_empty() { return None; }
Some(if net.starts_with("UNK_CONFLICT") { "conflict" } else if net.starts_with("UNK_G") { "group" } else { "none" })
}
/// Names in the order a person reads them: C2 before C10.
pub fn nat_cmp(a: &str, b: &str) -> Ordering {
let (x, y) = (a.as_bytes(), b.as_bytes());
let (mut i, mut j) = (0, 0);
while i < x.len() && j < y.len() {
if x[i].is_ascii_digit() && y[j].is_ascii_digit() {
let (s, t) = (i, j);
while i < x.len() && x[i].is_ascii_digit() { i += 1; }
while j < y.len() && y[j].is_ascii_digit() { j += 1; }
let (p, q) = (a[s..i].trim_start_matches('0'), b[t..j].trim_start_matches('0'));
let o = p.len().cmp(&q.len()).then(p.cmp(q));
if o != Ordering::Equal { return o; }
} else {
let o = x[i].to_ascii_uppercase().cmp(&y[j].to_ascii_uppercase());
if o != Ordering::Equal { return o; }
i += 1; j += 1;
}
}
(x.len() - i).cmp(&(y.len() - j)).then(a.cmp(b))
}
#[derive(Clone, Debug)]
pub struct Contact { pub pin: String, pub node: usize, pub solid: bool, pub kind: String, pub side: String }
#[derive(Clone, Debug)]
pub struct Link { pub a: usize, pub b: usize, pub tier: u8, pub vias: Vec<String> }
/// The connection graph as attribution left it.
#[derive(Default)]
pub struct Graph { pub node_net: HashMap<usize, String>, pub contacts: Vec<Contact>, pub links: Vec<Link> }
/// One link of a net's path set.
#[derive(Clone, Debug, PartialEq)]
pub struct Step { pub strength: u8, pub kind: &'static str, pub pins: Vec<String>, pub nodes: Vec<usize>, pub vias: Vec<String>, pub contact: String, pub side: String }
impl Step {
pub fn rung(&self) -> &'static str { if self.strength >= 50 { "copper" } else if self.strength >= 40 { "via" } else { "inferred" } }
}
#[derive(Clone, Debug)]
pub struct NetLevel {
pub level: &'static str,
/// The weakest link of the path set. None for a net with no pins.
pub weakest: Option<Step>,
/// How many links of the path set are as weak as that one.
pub weakest_count: usize,
/// Links of the path set per rung: copper, via, inferred.
pub steps: [usize; 3],
/// Pins with a solid contact to copper of this net.
pub seen: Vec<String>,
/// Pins on the net by inference only.
pub inferred: Vec<String>,
/// Sets of pins seen joined to each other, largest first.
pub groups: Vec<Vec<String>>,
}
const PIN_MAP: u8 = 30;
fn solid_strength(kind: &str) -> u8 { match kind { "lug" => 53, "pour" => 52, "track-end" => 51, _ => 50 } }
/// Grade one net on the graph. `pins` are the net's pins as nets.json lists them.
pub fn net_level(name: &str, pins: &[String], g: &Graph) -> NetLevel {
let mut pins: Vec<String> = pins.to_vec();
pins.sort_by(|a, b| nat_cmp(a, b)); pins.dedup();
let pin_ix: HashMap<&str, usize> = pins.iter().enumerate().map(|(i, p)| (p.as_str(), i)).collect();
let mut node_ix: BTreeMap<usize, usize> = BTreeMap::new();
let on_net = |n: usize| g.node_net.get(&n).map(|x| x == name).unwrap_or(false);
let mut steps: Vec<(Step, usize, usize)> = vec![];
let n_p = pins.len();
let vertex = |m: &mut BTreeMap<usize, usize>, n: usize| -> usize { let k = m.len(); n_p + *m.entry(n).or_insert(k) };
for c in &g.contacts {
let (Some(&pi), true) = (pin_ix.get(c.pin.as_str()), on_net(c.node)) else { continue };
let v = vertex(&mut node_ix, c.node);
let (strength, kind) = if c.solid { (solid_strength(&c.kind), "pad-on-copper") } else { (32, "pad-edge-unsettled") };
steps.push((Step { strength, kind, pins: vec![c.pin.clone()], nodes: vec![c.node], vias: vec![], contact: c.kind.clone(), side: c.side.clone() }, pi, v));
}
for l in &g.links {
if l.a == l.b || !on_net(l.a) || !on_net(l.b) { continue; }
let (va, vb) = (vertex(&mut node_ix, l.a), vertex(&mut node_ix, l.b));
let (strength, kind) = match l.tier { 0..=2 => (40, "verified-vias"), 3 => (33, "single-via-track-to-track"), _ => (31, "single-via-onto-pour") };
steps.push((Step { strength, kind, pins: vec![], nodes: vec![l.a, l.b], vias: l.vias.clone(), contact: String::new(), side: String::new() }, va, vb));
}
// strongest first; among equals an order that does not depend on how the files were written
steps.sort_by(|a, b| b.0.strength.cmp(&a.0.strength).then_with(|| a.0.pins.first().map(String::as_str).unwrap_or("").cmp(b.0.pins.first().map(String::as_str).unwrap_or("")).then(a.0.nodes.cmp(&b.0.nodes))));
let n_v = n_p + node_ix.len();
let mut parent: Vec<usize> = (0..n_v).collect();
fn root(p: &mut [usize], x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let t = p[y]; p[y] = r; y = t; } r }
let mut kept: Vec<(Step, usize, usize)> = vec![];
let mut seen_parent: Option<Vec<usize>> = None;
for (s, a, b) in steps.iter() {
// what is joined by copper and verified vias alone: the groups a person can see
if s.strength < 40 && seen_parent.is_none() { seen_parent = Some(parent.clone()); }
let (x, y) = (root(&mut parent, *a), root(&mut parent, *b));
if x != y { parent[x] = y; kept.push((s.clone(), *a, *b)); }
}
// only links on the way from one pin to another count: copper of the net that hangs off the side, with no pin on
// it, joins no pins, so however weakly it hangs it does not lower the level
let mut degree = vec![0usize; n_v];
for (_, a, b) in &kept { degree[*a] += 1; degree[*b] += 1; }
loop {
let before = kept.len();
kept.retain(|(_, a, b)| { let dead = (*a >= n_p && degree[*a] == 1) || (*b >= n_p && degree[*b] == 1); if dead { degree[*a] -= 1; degree[*b] -= 1; } !dead });
if kept.len() == before { break; }
}
let mut tree: Vec<Step> = kept.into_iter().map(|k| k.0).collect();
let mut seen_parent = seen_parent.unwrap_or_else(|| parent.clone());
let seen: Vec<String> = pins.iter().filter(|p| g.contacts.iter().any(|c| c.solid && c.pin == **p && on_net(c.node))).cloned().collect();
let seen_set: BTreeSet<&String> = seen.iter().collect();
let inferred: Vec<String> = pins.iter().filter(|p| !seen_set.contains(p)).cloned().collect();
let mut by_root: BTreeMap<usize, Vec<String>> = BTreeMap::new();
for (i, p) in pins.iter().enumerate() { if seen_set.contains(p) { by_root.entry(root(&mut seen_parent, i)).or_default().push(p.clone()); } }
let mut groups: Vec<Vec<String>> = by_root.into_values().collect();
groups.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| nat_cmp(&a[0], &b[0])));
// what the links leave apart, the pin map joins: every piece but the largest hangs on it
let mut comps: BTreeMap<usize, Vec<String>> = BTreeMap::new();
for (i, p) in pins.iter().enumerate() { comps.entry(root(&mut parent, i)).or_default().push(p.clone()); }
let mut comps: Vec<Vec<String>> = comps.into_values().collect();
comps.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| nat_cmp(&a[0], &b[0])));
for c in comps.iter().skip(1) { tree.push(Step { strength: PIN_MAP, kind: "pin-map-only", pins: c.clone(), nodes: vec![], vias: vec![], contact: String::new(), side: String::new() }); }
if pins.len() == 1 {
// nothing to join: the pin's best meeting with the net's copper stands for the net
let best = steps.iter().filter(|s| s.1 == 0).map(|s| s.0.clone()).max_by_key(|s| s.strength)
.unwrap_or(Step { strength: PIN_MAP, kind: "pin-map-only", pins: pins.clone(), nodes: vec![], vias: vec![], contact: String::new(), side: String::new() });
tree = vec![best];
}
let mut count = [0usize; 3];
for s in &tree { count[rank(s.rung())] += 1; }
let weakest = tree.iter().min_by_key(|s| s.strength).cloned();
let weakest_count = weakest.as_ref().map(|w| tree.iter().filter(|s| s.strength == w.strength && s.kind == w.kind).count()).unwrap_or(0);
let level = match &weakest { Some(w) => w.rung(), None => "none" };
NetLevel { level, weakest, weakest_count, steps: count, seen, inferred, groups }
}
fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }
fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }
/// A file name for a net's picture: letters, digits, dot, dash and underscore, with `+` spelled out, and never two
/// nets on one name whatever the case.
pub fn file_stem(net: &str, taken: &mut BTreeSet<String>) -> String {
let mut s = String::new();
for ch in net.chars() { if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' { s.push(ch); } else if ch == '+' { s.push_str("plus"); } else { s.push('_'); } }
if s.is_empty() { s.push_str("net"); }
let mut out = s.clone(); let mut k = 2;
while !taken.insert(out.to_lowercase()) { out = format!("{s}-{k}"); k += 1; }
out
}
/// The graph from detect/connect-graph.json and detect/attribution.json.
pub fn load_graph(flow: &Path) -> Result<Graph, String> {
let gr = read_json(&flow.join("detect/connect-graph.json")).map_err(|e| format!("{e}: run `connect measure` and `connect attribute` first"))?;
let at = read_json(&flow.join("detect/attribution.json")).map_err(|e| format!("{e}: run `connect attribute` first"))?;
let mut g = Graph::default();
for p in at["pieces"].as_array().into_iter().flatten() { if let (Some(n), Some(net)) = (p["node"].as_u64(), p["net"].as_str()) { g.node_net.insert(n as usize, net.to_string()); } }
for c in gr["contacts"].as_array().ok_or("detect/connect-graph.json has no contacts: it is from an older version, run `connect attribute` again")? {
g.contacts.push(Contact { pin: c["pin"].as_str().unwrap_or("").to_string(), node: c["node"].as_u64().unwrap_or(0) as usize, solid: c["tier"].as_u64() == Some(1), kind: c["kind"].as_str().unwrap_or("").to_string(), side: c["side"].as_str().unwrap_or("").to_string() });
}
for l in gr["links"].as_array().into_iter().flatten() {
g.links.push(Link { a: l["pieces"][0].as_u64().unwrap_or(0) as usize, b: l["pieces"][1].as_u64().unwrap_or(0) as usize, tier: l["tier"].as_u64().unwrap_or(9) as u8, vias: l["ids"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect() });
}
Ok(g)
}
/// Where each pin's pad is, and on which faces: from what `connect measure` read.
pub fn pin_places(flow: &Path) -> HashMap<String, (f64, f64, Vec<&'static str>)> {
let mut out: HashMap<String, (f64, f64, Vec<&'static str>)> = HashMap::new();
let Ok(ms) = read_json(&flow.join("detect/attrib/measure.json")) else { return out };
for p in ms["pads"].as_array().into_iter().flatten() {
let num = p["num"].as_str().unwrap_or(""); if num.is_empty() { continue; }
let layers: Vec<&str> = p["layers"].as_array().into_iter().flatten().filter_map(|l| l.as_str()).collect();
let mut sides = vec![];
if layers.iter().any(|l| *l == "F.Cu" || *l == "*.Cu" || *l == "F&B.Cu") { sides.push("top"); }
if layers.iter().any(|l| *l == "B.Cu" || *l == "*.Cu" || *l == "F&B.Cu") { sides.push("bottom"); }
if sides.is_empty() { continue; }
let e = out.entry(format!("{}.{}", p["part"].as_str().unwrap_or(""), num)).or_insert((p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0), vec![]));
for s in sides { if !e.2.contains(&s) { e.2.push(s); } }
}
out
}
fn sentence(s: &Step, status: &HashMap<String, String>, single: bool) -> String {
let list = |v: &[String]| { let shown: Vec<&str> = v.iter().take(4).map(String::as_str).collect(); if v.len() > 4 { format!("{} and {} more", shown.join(", "), v.len() - 4) } else { shown.join(", ") } };
match s.kind {
"pad-on-copper" => { let how = match s.contact.as_str() { "lug" => "a lug bolted to its pour", "pour" => "a pad joined to a pour", "track-end" => "a track ending on the pad", "via-in-pad" => "vias through the pad", _ => "a pad on the copper" };
if single { format!("{} meets its copper with no gap ({how}); the net has one pin, so nothing is there to join", s.pins[0]) } else { format!("every pin meets this net's copper with no gap; the weakest of those meetings is {} ({how})", s.pins[0]) } }
"verified-vias" => format!("two pieces of this net's copper are joined through verified vias ({})", list(&s.vias)),
"single-via-track-to-track" => format!("two pieces of this net's copper are joined through one via, track to track ({})", list(&s.vias)),
"single-via-onto-pour" => format!("two pieces of this net's copper are joined by one unconfirmed via onto a pour ({}), which proves nothing by itself, and by the pin map", list(&s.vias)),
"pad-edge-unsettled" => format!("{} is at this net's copper, but the edge of its pad does not settle whether they join", s.pins[0]),
_ if s.pins.len() == 1 => { let why = match status.get(&s.pins[0]).map(String::as_str) { Some("unsettled") => "the edge of its pad does not settle what it touches", Some("touches unnamed copper") => "the copper it touches carries no name", Some("clash") => "the copper it touches is named for another net", _ => "no copper is seen joining this pin" };
format!("{} is on this net by the pin map only: {why}", s.pins[0]) }
_ => format!("{} are seen joined to each other, but nothing seen joins them to the rest of the net: the pin map alone does", list(&s.pins)),
}
}
/// Grade every net and write net-confidence.json. `sections` maps a net to its section id, `pictures` to its picture.
pub fn compute(flow: &Path) -> Result<Value, String> {
let g = load_graph(flow)?;
let nets = read_json(&flow.join("nets.json"))?;
let copper = read_json(&flow.join("copper.json"))?;
let ev = read_json(&flow.join("net-evidence.json")).unwrap_or(json!({}));
let places = pin_places(flow);
let via_at: HashMap<String, (f64, f64)> = copper["vias"].as_array().into_iter().flatten().filter_map(|v| Some((v["id"].as_str()?.to_string(), (v["pos_mm"][0].as_f64()?, v["pos_mm"][1].as_f64()?)))).collect();
let mut status: HashMap<String, HashMap<String, String>> = HashMap::new();
for n in ev["nets"].as_array().into_iter().flatten() { status.insert(n["net"].as_str().unwrap_or("").to_string(), n["pins"].as_object().map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default()); }
// disputes: every place attribution found this net meeting another
let mut disputes: BTreeMap<String, Vec<Value>> = BTreeMap::new();
for s in ev["shorts"].as_array().into_iter().flatten() {
let names: Vec<String> = match &s["nets"] { Value::Object(m) => m.keys().cloned().collect(), Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(), _ => vec![] };
let at = s["vias"].as_array().and_then(|v| v.iter().filter_map(|x| x.as_str()).find_map(|id| via_at.get(id))).map(|p| json!([r3(p.0), r3(p.1)]))
.or_else(|| s["pins"].as_array().and_then(|v| v.iter().filter_map(|x| x.as_str()).find_map(|p| places.get(p))).map(|p| json!([r3(p.0), r3(p.1)]))).unwrap_or(s["atMm"].clone());
for n in &names { disputes.entry(n.clone()).or_default().push(json!({"kind": s["kind"], "with": names.iter().filter(|x| *x != n).collect::<Vec<_>>(), "pins": s["pins"], "vias": s["vias"], "at": at})); }
}
// each net's level first, since a piece of copper is drawn at the weaker of its own rung and its net's level
let mut rows: Vec<(String, Vec<String>, NetLevel)> = vec![];
for n in nets["nets"].as_array().into_iter().flatten() {
let name = n["name"].as_str().unwrap_or("").to_string();
let pins: Vec<String> = n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect();
let lv = net_level(&name, &pins, &g);
rows.push((name, pins, lv));
}
let level_of: HashMap<&str, &'static str> = rows.iter().map(|r| (r.0.as_str(), r.2.level)).collect();
// a cross-check against attribution's own tag: a net of two or more pins is seen joined exactly when it is tagged MEASURED
let tag_differs: Vec<&str> = nets["nets"].as_array().into_iter().flatten().filter(|n| n["pins"].as_array().map(|p| p.len() >= 2).unwrap_or(false))
.filter(|n| (n["tag"].as_str() == Some("MEASURED")) != matches!(level_of.get(n["name"].as_str().unwrap_or("")).copied(), Some("copper") | Some("via"))).filter_map(|n| n["name"].as_str()).collect();
// copper per net and rung, as drawn: zone area, track length and area at its drawn width, vias
let mut per: BTreeMap<String, BTreeMap<&'static str, [f64; 4]>> = BTreeMap::new();
let mut drawn: BTreeMap<&'static str, [f64; 4]> = LADDER.iter().map(|r| (*r, [0.0; 4])).collect();
let mut by_own: BTreeMap<&'static str, [f64; 4]> = LADDER.iter().map(|r| (*r, [0.0; 4])).collect();
let mut unnamed: BTreeMap<&'static str, (BTreeSet<String>, [f64; 4])> = BTreeMap::new();
let mut tally = |net: &str, rung: &str, add: [f64; 4]| {
let own = LADDER[rank(rung)];
{ let o = by_own.get_mut(own).unwrap(); for k in 0..4 { o[k] += add[k]; } }
let shown = match unnamed_class(net) { Some(c) => { let e = unnamed.entry(c).or_insert((BTreeSet::new(), [0.0; 4])); e.0.insert(net.to_string()); for k in 0..4 { e.1[k] += add[k]; } c } None => { let e = per.entry(net.to_string()).or_default().entry(own).or_insert([0.0; 4]); for k in 0..4 { e[k] += add[k]; } LADDER[rank(weaker(own, level_of.get(net).copied().unwrap_or("none")))] } };
let d = drawn.get_mut(shown).unwrap(); for k in 0..4 { d[k] += add[k]; }
};
// copper on a face that failed `check copper` is in no figure here: it was not read, so it is neither proven nor "isolated"
let unread = crate::notread::faces(flow);
let read = |v: &Value| !crate::notread::skips_layer(&unread, v["layer"].as_str().unwrap_or(""));
for z in copper["zones"].as_array().into_iter().flatten().filter(|z| read(z)) { tally(z["net"].as_str().unwrap_or(""), z["netEvidence"]["rung"].as_str().unwrap_or("none"), [z["areaMm2"].as_f64().unwrap_or(0.0), 0.0, 0.0, 1.0]); }
for t in copper["traces"].as_array().into_iter().flatten().filter(|t| read(t)) {
let pts: Vec<(f64, f64)> = t["path_mm"].as_array().into_iter().flatten().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect();
let len: f64 = pts.windows(2).map(|q| crate::geom::dist(q[0], q[1])).sum();
tally(t["net"].as_str().unwrap_or(""), t["netEvidence"]["rung"].as_str().unwrap_or("none"), [len * t["width_mm"].as_f64().unwrap_or(0.0), len, 0.0, 1.0]);
}
for v in copper["vias"].as_array().into_iter().flatten() { tally(v["net"].as_str().unwrap_or(""), v["netEvidence"]["rung"].as_str().unwrap_or("none"), [0.0, 0.0, 1.0, 1.0]); }
let cu = |v: &[f64; 4]| json!({"areaMm2": r2(v[0]), "trackLengthMm": r2(v[1]), "vias": v[2], "items": v[3]});
let mut out = vec![];
let mut taken = BTreeSet::new();
let (mut levels, mut multi, mut single): (BTreeMap<&str, usize>, BTreeMap<&str, usize>, BTreeMap<&str, usize>) = (BTreeMap::new(), BTreeMap::new(), BTreeMap::new());
let mut disputed = 0;
for (name, pins, lv) in &rows {
let st = status.get(name).cloned().unwrap_or_default();
let is_single = pins.len() == 1;
*levels.entry(lv.level).or_default() += 1;
*(if is_single { &mut single } else { &mut multi }).entry(lv.level).or_default() += 1;
let weakest = lv.weakest.as_ref().map(|w| {
let at = w.vias.iter().find_map(|id| via_at.get(id)).map(|p| (p.0, p.1, None)).or_else(|| w.pins.first().and_then(|p| places.get(p)).map(|p| (p.0, p.1, Some(if !w.side.is_empty() { w.side.clone() } else { p.2.first().copied().unwrap_or("top").to_string() }))));
json!({"rung": w.rung(), "kind": w.kind, "what": sentence(w, &st, is_single), "pins": w.pins, "vias": w.vias, "at": at.as_ref().map(|a| json!([r3(a.0), r3(a.1)])), "side": at.and_then(|a| a.2), "sameKindOnPath": lv.weakest_count})
});
let cop = per.get(name).cloned().unwrap_or_default();
let total: f64 = cop.values().map(|v| v[0]).sum();
let by_rung: serde_json::Map<String, Value> = LADDER.iter().filter_map(|r| cop.get(r).map(|v| { let mut c = cu(v); c["share"] = json!(if total > 0.0 { r3(v[0] / total) } else { 0.0 }); (r.to_string(), c) })).collect();
let members: Vec<Value> = lv.inferred.iter().map(|p| json!({"pin": p, "why": st.get(p).cloned().unwrap_or_else(|| "no copper seen".into()), "at": places.get(p).map(|q| json!([r3(q.0), r3(q.1)])), "sides": places.get(p).map(|q| q.2.clone())})).collect();
let dis = disputes.get(name).cloned().unwrap_or_default();
if !dis.is_empty() { disputed += 1; }
// the datasheet witness for this net, as `connect witness` recorded it: shown beside the level, never part of it
let witness = ev["nets"].as_array().into_iter().flatten().find(|r| r["net"].as_str() == Some(name.as_str())).map(|r| &r["datasheet"]).filter(|d| d.is_object()).map(|d| json!({"witness": d["witness"], "agree": d["agree"], "disagree": d["disagree"], "notVisible": d["notVisible"], "netList": d["netList"], "findings": d["findings"]})).unwrap_or(json!({"witness": "none", "netList": "none"}));
// how the net is known when no copper says it: built from a pinout by rule (and how that is tagged), or stated by somebody
let nrec = nets["nets"].as_array().into_iter().flatten().find(|n| n["name"].as_str() == Some(name.as_str()));
let seen = matches!(lv.level, "copper" | "via");
let known = match nrec { Some(n) if n["fromPinout"] == json!(true) => json!({"from": if seen { "pinout, confirmed by copper" } else { "pinout only" }, "tag": n["statedTag"], "rules": n["pinout"]["rules"], "datasheetPins": n["pinout"]["datasheetPins"], "inferredPins": n["pinout"]["inferredPins"]}),
Some(n) if n["fromCopper"] == json!(true) => json!({"from": "copper"}), _ => json!({"from": if seen { "stated, confirmed by copper" } else { "stated" }}) };
out.push(json!({"net": name, "knownFrom": known, "datasheetWitness": witness, "level": lv.level, "singlePin": is_single, "pins": pins.len(), "pinsSeen": lv.seen.len(), "pinsSeenShare": if pins.is_empty() { 0.0 } else { r3(lv.seen.len() as f64 / pins.len() as f64) },
"seenGroups": lv.groups, "weakestLink": weakest, "pathSet": {"copper": lv.steps[0], "via": lv.steps[1], "inferred": lv.steps[2]},
"seenPins": lv.seen, "inferredMembers": members, "copperByRung": by_rung, "disputes": dis, "file": file_stem(name, &mut taken)}));
}
let counts = |m: &BTreeMap<&str, usize>| LADDER.iter().take(3).chain(["none"].iter()).map(|r| (r.to_string(), json!(m.get(r).copied().unwrap_or(0)))).collect::<serde_json::Map<_, _>>();
let un: serde_json::Map<String, Value> = ["group", "none", "conflict"].iter().map(|c| { let e = unnamed.get(c); let mut v = cu(&e.map(|x| x.1).unwrap_or([0.0; 4])); v["names"] = json!(e.map(|x| x.0.len()).unwrap_or(0)); (c.to_string(), v) }).collect();
let islands = unnamed.get("none").map(|e| e.0.iter().filter(|n| n.starts_with("UNK_ISO")).count()).unwrap_or(0);
let doc = json!({
"what": "How well each net is proven. A net's level is the rung of the weakest link in the set of links that joins its pins, strongest links first; docs/method.md, \"A net's confidence is its weakest link\".",
"ladder": LADDER, "from": ["detect/connect-graph.json", "detect/attribution.json", "nets.json", "copper.json", "net-evidence.json"],
"copperSha": crate::attrib::zones_key(&copper),
"summary": {"copperNotRead": unread.iter().map(|f| f.side.clone()).collect::<Vec<_>>(), "nets": rows.len(), "levels": counts(&levels), "netsOfTwoOrMorePins": counts(&multi), "netsOfOnePin": counts(&single), "netsDisputed": disputed,
"copperAsDrawn": LADDER.iter().map(|r| (r.to_string(), cu(&drawn[r]))).collect::<serde_json::Map<_, _>>(),
"copperByOwnRung": LADDER.iter().map(|r| (r.to_string(), cu(&by_own[r]))).collect::<serde_json::Map<_, _>>(),
"unnamed": un, "unnamedIslands": islands, "differsFromMeasuredTag": tag_differs, "datasheetWitness": ev["datasheetWitness"]},
"nets": out,
});
std::fs::write(flow.join(FILE), serde_json::to_string_pretty(&doc).unwrap() + "\n").map_err(|e| format!("{FILE}: {e}"))?;
Ok(doc)
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(pin: &str, node: usize, kind: &str) -> Contact { Contact { pin: pin.into(), node, solid: true, kind: kind.into(), side: "top".into() } }
fn weak(pin: &str, node: usize) -> Contact { Contact { pin: pin.into(), node, solid: false, kind: "pour-neck".into(), side: "top".into() } }
fn link(a: usize, b: usize, tier: u8) -> Link { Link { a, b, tier, vias: vec![format!("v{a}{b}")] } }
fn graph(names: &[(usize, &str)], contacts: Vec<Contact>, links: Vec<Link>) -> Graph { Graph { node_net: names.iter().map(|(k, n)| (*k, n.to_string())).collect(), contacts, links } }
fn pins(v: &[&str]) -> Vec<String> { v.iter().map(|s| s.to_string()).collect() }
#[test]
fn pins_on_one_conductor_are_copper() {
let g = graph(&[(0, "A")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 0, "track-end")], vec![]);
let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);
assert_eq!(l.level, "copper"); assert_eq!(l.steps, [2, 0, 0]); assert!(l.inferred.is_empty());
// the weakest of equal rungs is the meeting that rests on least: a track end, not a pour
assert_eq!(l.weakest.unwrap().pins, vec!["U1.3"]);
}
#[test]
fn a_verified_via_between_two_conductors_makes_the_net_via() {
let g = graph(&[(0, "A"), (1, "A")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 1, "pour")], vec![link(0, 1, 2)]);
let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);
assert_eq!(l.level, "via"); assert_eq!(l.steps, [2, 1, 0]); assert_eq!(l.weakest.unwrap().kind, "verified-vias");
assert_eq!(l.groups.len(), 1, "verified vias join what is seen");
}
#[test]
fn one_inferred_step_makes_the_whole_net_inferred_whatever_else_was_seen() {
// nineteen pins on one pour, the twentieth joined only by one via track to track
let mut c: Vec<Contact> = (1..=19).map(|k| solid(&format!("C{k}.1"), 0, "pour")).collect();
c.push(solid("U9.1", 1, "track-end"));
let g = graph(&[(0, "A"), (1, "A")], c, vec![link(0, 1, 3)]);
let p: Vec<String> = (1..=19).map(|k| format!("C{k}.1")).chain(["U9.1".to_string()]).collect();
let l = net_level("A", &p, &g);
assert_eq!(l.level, "inferred"); assert_eq!(l.steps, [20, 0, 1]);
assert_eq!(l.weakest.as_ref().unwrap().kind, "single-via-track-to-track"); assert_eq!(l.weakest_count, 1);
assert_eq!(l.seen.len(), 20); assert_eq!(l.groups.len(), 2, "an inferred via does not make two groups one");
}
#[test]
fn a_pin_with_no_copper_is_a_member_by_inference_only() {
let g = graph(&[(0, "A")], vec![solid("R1.1", 0, "pour"), solid("R2.1", 0, "pour")], vec![]);
let l = net_level("A", &pins(&["R1.1", "R2.1", "U1.7"]), &g);
assert_eq!(l.level, "inferred"); assert_eq!(l.inferred, vec!["U1.7"]);
let w = l.weakest.unwrap(); assert_eq!((w.kind, w.pins), ("pin-map-only", vec!["U1.7".to_string()]));
}
#[test]
fn copper_no_pin_path_crosses_does_not_lower_the_level() {
// both pins on one pour; a second piece of the net hangs off it by one via, with no pin on it
let g = graph(&[(0, "A"), (1, "A"), (2, "A")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 0, "pour")], vec![link(0, 1, 3), link(1, 2, 4)]);
let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);
assert_eq!(l.level, "copper"); assert_eq!(l.steps, [2, 0, 0]);
// the same piece between two pins is on the path, and counts
let g = graph(&[(0, "A"), (1, "A"), (2, "A")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 2, "pour")], vec![link(0, 1, 2), link(1, 2, 3)]);
let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);
assert_eq!(l.level, "inferred"); assert_eq!(l.steps, [2, 1, 1]);
}
#[test]
fn a_stronger_path_is_preferred_to_a_weaker_one() {
// two conductors joined both by verified vias and by a single via: the path set keeps the verified one
let g = graph(&[(0, "A"), (1, "A")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 1, "pour")], vec![link(0, 1, 4), link(0, 1, 2)]);
assert_eq!(net_level("A", &pins(&["R1.1", "U1.3"]), &g).level, "via");
}
#[test]
fn copper_of_another_net_joins_nothing() {
let g = graph(&[(0, "A"), (1, "B")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 1, "pour")], vec![link(0, 1, 2)]);
let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);
assert_eq!(l.level, "inferred"); assert_eq!(l.seen, vec!["R1.1"]);
}
#[test]
fn an_unsettled_pad_edge_is_inferred_and_two_seen_groups_hang_on_the_pin_map() {
let g = graph(&[(0, "A")], vec![solid("R1.1", 0, "pour"), weak("U1.3", 0)], vec![]);
let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);
assert_eq!((l.level, l.weakest.unwrap().kind), ("inferred", "pad-edge-unsettled"));
let g = graph(&[(0, "A"), (1, "A")], vec![solid("R1.1", 0, "pour"), solid("R2.1", 0, "pour"), solid("U1.3", 1, "pour"), solid("U1.4", 1, "pour"), solid("U1.5", 1, "pour")], vec![]);
let l = net_level("A", &pins(&["R1.1", "R2.1", "U1.3", "U1.4", "U1.5"]), &g);
let w = l.weakest.unwrap();
assert_eq!((l.level, w.kind), ("inferred", "pin-map-only")); assert_eq!(w.pins, vec!["R1.1", "R2.1"], "the smaller group hangs on the larger");
assert_eq!(l.groups.len(), 2);
}
#[test]
fn a_net_of_one_pin_is_graded_by_how_the_pin_meets_its_copper() {
let g = graph(&[(0, "A")], vec![solid("J1.4", 0, "track-end")], vec![]);
assert_eq!(net_level("A", &pins(&["J1.4"]), &g).level, "copper");
assert_eq!(net_level("A", &pins(&["J1.5"]), &g).level, "inferred");
assert_eq!(net_level("A", &[], &g).level, "none");
}
#[test]
fn names_sort_the_way_a_person_reads_them_and_files_never_collide() {
let mut v = vec!["C10.1", "C2.1", "C1.2", "c1.1"]; v.sort_by(|a, b| nat_cmp(a, b));
assert_eq!(v, vec!["c1.1", "C1.2", "C2.1", "C10.1"]);
let mut t = BTreeSet::new();
assert_eq!(file_stem("+3V3", &mut t), "plus3V3"); assert_eq!(file_stem("USB3-0-RX_N", &mut t), "USB3-0-RX_N");
assert_eq!(file_stem("SOA", &mut t), "SOA"); assert_eq!(file_stem("soa", &mut t), "soa-2"); assert_eq!(file_stem("A/B", &mut t), "A_B");
}
#[test]
fn a_piece_is_drawn_at_the_weaker_of_its_rung_and_its_nets_level() {
assert_eq!(weaker("copper", "inferred"), "inferred"); assert_eq!(weaker("via", "copper"), "via"); assert_eq!(weaker("conflict", "copper"), "conflict");
assert_eq!(unnamed_class("UNK_ISO12"), Some("none")); assert_eq!(unnamed_class("UNK_G3"), Some("group")); assert_eq!(unnamed_class("UNK_CONFLICT_v4"), Some("conflict")); assert_eq!(unnamed_class("GND"), None);
}
}