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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
//! Nets from the parts first: what a part is, which way round it sits, then its pinout, and from the pinout its nets.
//!
//! The chain, per part (`chain`, written to detect/pinout.json):
//! - identity: its marking was read, or only its package is known;
//! - orientation: a pin 1 or polarity mark was seen, or it is not needed (a two-pad part with no polarity, a connector whose
//! pinout reads the same turned round), or it is not determinable, or it is not settled;
//! - pinout: the datasheet's pin table (`datasheets/<ref>.pins.json`), the standard pinout of a connector whose type is seen
//! (USB Type-C, by the pad names of its footprint), names typed into the part's `pins` (silkscreen), or none.
//! A pinout on a chip turned the wrong way is worse than none, so a chip with a pinout and no settled orientation gives no
//! nets and is asked about (`unsettled`): pin1.tag SEEN with the mark, or UNKNOWN with why it cannot be told.
//!
//! The nets, by rule (`build`, run as `connect pinout`, before attribution). Each pin's record says which rule put it there,
//! from which source, and how it is tagged. Nothing here is SEEN or MEASURED: a net is DATASHEET when every pin on it is
//! there because a document states it, INFERRED with a test otherwise.
//! - `ground`: every ground pin of every pinout joins GND.
//! - `pin function`: every other pin with a function is on a net named for it, `<ref>_<pin name>`, alone or with the
//! pads of the same name on the same part. Rails of different parts are not merged here: copper, or a name both carry, does that.
//! - `datasheet tie`: pins the datasheet says must be tied (`tied_to_pin`, `tied_to_ground`) share a net.
//! - `claimed part` (INFERRED): a pin the datasheet says is decoupled, biased or bridged takes the nearest free capacitor or
//! resistor within reach, its near pad on the pin's net and its far pad on ground or on the other pin's net; a crystal pin
//! takes the nearest crystal, whose case pads go to ground and whose terminals take their load capacitors.
//! - pins a datasheet leaves unconnected go to nets.json `noConnect`.
//! Attribution then holds these nets against the copper as it does any stated net: seen joined, the net becomes MEASURED;
//! contradicted, it becomes a finding with a test, and neither side is overruled silently.
use crate::dspins::{self, Sheet};
use crate::{items, kicad_out, sch};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
pub const FILE: &str = "detect/pinout.json";
/// How far from a pin's pad a part may sit and still be taken for the one the datasheet puts at that pin. Policy, not fitted
/// to any board: a decoupling capacitor or bias resistor is placed within a few millimetres of its pin.
const CLAIM_MM: f64 = 3.5;
/// The same for a crystal, which sits a little further off.
const CRYSTAL_MM: f64 = 8.0;
const USB_C: &str = "USB Type-C receptacle pin assignment, by the pad names of the footprint (KiCad Connector:USB_C_Receptacle_USB2.0_16P)";
fn text(v: &Value) -> String { v.as_str().unwrap_or("").trim().to_string() }
fn part_of(pin: &str) -> &str { pin.rsplit_once('.').map(|(r, _)| r).unwrap_or(pin) }
fn dist(a: (f64, f64), b: (f64, f64)) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }
/// One pin's function, from whichever pinout the part has.
#[derive(Clone, Debug)]
pub struct Func { pub pin: String, pub name: String, pub ty: String, pub net: Option<String>, pub source: String, pub kind: &'static str, pub expects: Vec<Value>, pub note: String }
/// One part's place in the chain.
#[derive(Clone, Debug)]
pub struct Link { pub part: String, pub identity: &'static str, pub orientation: &'static str, pub pinout: &'static str, pub source: String, pub usable: bool, pub ask: Option<String> }
/// The standard signal of a USB Type-C receptacle pad, and its type.
fn usb_c(pad: &str) -> Option<(&'static str, &'static str)> {
Some(match pad { "A1" | "A12" | "B1" | "B12" => ("GND", "ground"), "A4" | "A9" | "B4" | "B9" => ("VBUS", "passive"), "A5" => ("CC1", "bidirectional"), "B5" => ("CC2", "bidirectional"),
"A6" | "B6" => ("DP", "bidirectional"), "A7" | "B7" => ("DN", "bidirectional"), "A8" => ("SBU1", "bidirectional"), "B8" => ("SBU2", "bidirectional"), "SH" | "S1" | "SHIELD" => ("SHIELD", "passive"), _ => return None })
}
fn settled(p: &Value) -> bool { matches!(p["pin1"]["tag"].as_str(), Some("SEEN" | "MEASURED" | "DATASHEET")) }
/// The AI looked and said it cannot be told: an answer, and no pinout nets for the part.
fn undeterminable(p: &Value) -> bool { p["pin1"]["tag"].as_str() == Some("UNKNOWN") && !text(&p["pin1"]["mark"]).is_empty() }
/// The chain for every part, and every pin function of the parts whose chain holds.
pub fn chain(parts: &[Value], sheets: &BTreeMap<String, Sheet>, pads_of: &BTreeMap<String, Vec<String>>) -> (Vec<Link>, Vec<Func>) {
let (mut links, mut funcs) = (vec![], vec![]);
for p in parts {
let r = text(&p["ref"]);
let fp = text(&p["footprint"]);
let kind = sch::kind_of(&r, &fp);
let pads: Vec<String> = pads_of.get(&r).cloned().unwrap_or_default();
let identity = if dspins::identified(p) { "marking read" } else { "package only" };
let two_pad = pads.len() == 2;
let polar = matches!(kind, sch::Kind::D | sch::Kind::Led) || p["polarised"] == json!(true);
let mut link = Link { part: r.clone(), identity, orientation: "", pinout: "none", source: String::new(), usable: false, ask: None };
let is_usb_c = fp.contains("USB_C_Receptacle") && pads.iter().any(|q| q == "A1");
link.orientation = if settled(p) { if polar && two_pad { "polarity mark seen" } else { "pin 1 mark seen" } } else if undeterminable(p) { "not determinable" } else if two_pad && !polar { "not needed: two pads, no polarity" } else if pads.len() <= 1 { "not needed: one pad" } else { "not settled" };
if let Some(s) = sheets.get(&r) {
link.pinout = "datasheet pin table"; link.source = s.cite.clone();
if settled(p) || s.pins.len() <= 1 {
link.usable = true;
for q in &s.pins { funcs.push(Func { pin: format!("{r}.{}", q.pad), name: q.name.clone(), ty: q.ty.clone(), net: q.net.clone(), source: s.cite.clone(), kind: "datasheet", expects: q.expects.clone(), note: String::new() }); }
} else if !undeterminable(p) {
let at = p["pos_mm"].as_array().map(|a| format!(" at ({:.1}, {:.1}) mm on the {} face", a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0), if text(&p["side"]).is_empty() { "top".to_string() } else { text(&p["side"]) })).unwrap_or_default();
let crops: Vec<String> = match &p["evidence"] { Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(|s| format!("reverse-flow/{s}"))).collect(), Value::String(s) => vec![format!("reverse-flow/{s}")], _ => vec![] };
link.ask = Some(format!("{r} (package {}, {}{at}): its pinout is known ({}), but which way round it sits is not settled (pin1: {}). Look for {} (crops: {}). Set parts.json pin1 to {{\"mark\": \"<the mark you see and at which corner or end>\", \"tag\": \"SEEN\", \"evidence\": \"<crop>\"}} and `rot` to match, or, when no mark can be seen, {{\"mark\": \"<what you looked for>\", \"tag\": \"UNKNOWN\"}}: then its pins get no nets from the pinout", text(&p["footprint"]).rsplit(':').next().unwrap_or(""), text(&p["mpn"]).is_empty().then(|| text(&p["value"])).unwrap_or_else(|| text(&p["mpn"])), s.cite, if p["pin1"].is_null() { "missing".to_string() } else if text(&p["pin1"]["tag"]).is_empty() { format!("a mark is recorded, \"{}\", with no tag: if that mark was seen on the photo, adding \"tag\": \"SEEN\" settles it", text(&p["pin1"]["mark"])) } else { format!("tagged {}", text(&p["pin1"]["tag"])) }, mark_hint(p), if crops.is_empty() { "reverse-flow/evidence/parts/".to_string() } else { crops.join(", ") }));
}
} else if is_usb_c {
// GND, VBUS and the data pair read the same with the receptacle turned half round; only which CC and SBU pad is 1 or 2 turns with it
link.pinout = "standard: USB Type-C receptacle"; link.source = USB_C.into(); link.usable = true;
if !settled(p) { link.orientation = "not needed for ground, VBUS and data: the pinout reads the same turned half round; CC and SBU numbering not settled"; }
for q in &pads { if let Some((name, ty)) = usb_c(q) {
let turned = !settled(p) && (name.starts_with("CC") || name.starts_with("SBU"));
funcs.push(Func { pin: format!("{r}.{q}"), name: name.into(), ty: ty.into(), net: None, source: USB_C.into(), kind: if turned { "standard, numbering not settled" } else { "standard" }, expects: vec![], note: if turned { "which of the two is 1 and which is 2 turns with the receptacle, whose orientation is not settled".into() } else { String::new() } });
} }
} else if let Some(o) = p["pins"].as_object().filter(|_| p["pinsSource"].is_null()) {
// names somebody typed, from silkscreen beside the pads or from what the part is
link.pinout = "names typed in parts.json `pins`"; link.source = "parts.json `pins`, as typed (silkscreen or the part's kind)".into(); link.usable = true;
for (pad, v) in o {
let name = match v { Value::String(s) => s.clone(), _ => text(&v["name"]) };
if name.is_empty() || name == "?" { continue; }
let ty = match sch::rail_of(&name) { sch::Rail::Ground => "ground", sch::Rail::Supply => "power_in", sch::Rail::No => "passive" };
funcs.push(Func { pin: format!("{r}.{pad}"), name, ty: ty.into(), net: None, source: link.source.clone(), kind: "typed", expects: vec![], note: String::new() });
}
} else if matches!(kind, sch::Kind::Q) && pads.len() == 3 { link.pinout = "package family only (three-lead transistor): pin names, no nets";
} else if polar && two_pad { link.pinout = "package family only (a polarised two-pad part: its two ends by its polarity mark): no nets"; }
links.push(link);
}
(links, funcs)
}
/// One pin's place on a net, and why.
#[derive(Clone, Debug)]
pub struct Placed { pub net: String, pub rule: &'static str, pub tag: &'static str, pub source: String, pub why: String }
fn clean(name: &str) -> String { name.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '_') { c } else { '_' }).collect::<String>().trim_matches('_').to_string() }
/// The net a function names by itself.
fn own_net(f: &Func) -> Option<String> {
if f.ty == "no_connect" { return None; }
if let Some(n) = &f.net { return Some(n.clone()); }
if f.ty == "ground" { return Some("GND".into()); }
// a supply name typed beside a header pad is the rail's own name on this board: pads that carry it share it
if f.kind == "typed" && f.ty == "power_in" { return Some(f.name.clone()); }
let n = clean(&f.name);
if n.is_empty() { None } else { Some(format!("{}_{n}", part_of(&f.pin))) }
}
/// Rails and signals of different parts are one net when the copper shows them joined: pinout nets with pins on one conductor
/// are merged, under the name that says most (GND, then a rail name typed from silkscreen, then the net of the pin that drives).
/// A merge that would put ground with a supply or an output, a supply with an output, or two outputs on one net is refused:
/// that is a disagreement between the copper and the pinout, which attribution and the witness raise as a finding.
/// Returns what was merged and what was refused.
pub fn merge_on_copper(placed: &mut BTreeMap<String, Placed>, funcs: &[Func], conductors: &[Vec<String>]) -> (Vec<Value>, Vec<Value>) {
let ty: BTreeMap<&str, (&str, &str)> = funcs.iter().map(|f| (f.pin.as_str(), (f.ty.as_str(), f.kind))).collect();
// what a net is, from the functions of the pins the rules put on it: (ground, supply, outputs, a rail name somebody typed)
let nature = |net: &str, placed: &BTreeMap<String, Placed>| -> (bool, bool, usize, bool) {
let pins: Vec<&str> = placed.iter().filter(|(_, p)| p.net == net).map(|(q, _)| q.as_str()).collect();
let t = |q: &&str| ty.get(*q).map(|x| x.0).unwrap_or("");
(net == "GND" || pins.iter().any(|q| t(q) == "ground"), pins.iter().any(|q| matches!(t(q), "power_in" | "power_out")), pins.iter().filter(|q| matches!(t(q), "output" | "power_out")).count(), pins.iter().any(|q| ty.get(*q).is_some_and(|x| x.1 == "typed" && x.0 == "power_in")))
};
let (mut merged, mut refused) = (vec![], vec![]);
for c in conductors {
let mut nets: Vec<String> = c.iter().filter_map(|q| placed.get(q).map(|p| p.net.clone())).collect::<BTreeSet<_>>().into_iter().collect();
if nets.len() < 2 { continue; }
// the name that says most comes first
nets.sort_by_key(|n| { let k = nature(n, placed); (!k.0, !k.3, !k.1, k.2 == 0, n.clone()) });
let keep = nets[0].clone();
for other in &nets[1..] {
let (a, b) = (nature(&keep, placed), nature(other, placed));
let seen: Vec<&String> = c.iter().filter(|q| placed.get(*q).is_some_and(|p| p.net == keep || &p.net == other)).collect();
// two differently named pins of one part on one conductor: neighbouring pads read as joined far more often than a real tie
let parts_of = |net: &str| -> BTreeSet<String> { placed.iter().filter(|(q, p)| p.net == net && ty.contains_key(q.as_str())).map(|(q, _)| part_of(q).to_string()).collect() };
// an input strapped to its own chip's ground or supply is ordinary; anything else of one part on one conductor is not
let strap = |net: &str, part: &str| placed.iter().filter(|(q, p)| p.net == net && part_of(q) == part).all(|(q, _)| ty.get(q.as_str()).is_some_and(|x| x.0 == "input" && x.1 == "datasheet"));
let same_part: Vec<String> = parts_of(&keep).intersection(&parts_of(other)).filter(|r| !(strap(other, r) || strap(&keep, r))).cloned().collect();
if !same_part.is_empty() { refused.push(json!({"nets": [keep, other], "seenJoinedAt": seen, "why": format!("the copper joins them, but both are pins of {} under different names, which its pinout does not tie: left apart, as pads read as joined or a real fault, for attribution to raise", same_part.join(", "))})); continue; }
let clash = if (a.0 && (b.1 || b.2 > 0)) || (b.0 && (a.1 || a.2 > 0)) { Some("ground with a supply or an output") } else if a.2 + b.2 >= 2 { Some("two driving pins") } else if (a.1 && b.2 > 0 && !b.1) || (b.1 && a.2 > 0 && !a.1) { Some("a supply with an output") } else { None };
if let Some(why) = clash { refused.push(json!({"nets": [keep, other], "seenJoinedAt": seen, "why": format!("the copper joins them, but that would put {why} on one net: left apart, for attribution and the witness to raise")})); continue; }
for p in placed.values_mut().filter(|p| &p.net == other) { p.net = keep.clone(); p.why = format!("{}; {other} and {keep} are one net: the copper joins {}", p.why, seen.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")); }
merged.push(json!({"into": keep, "net": other, "seenJoinedAt": seen}));
}
}
(merged, refused)
}
/// Every pin the rules place, the pins left unconnected by a datasheet, and what was claimed. `pos`: pad centres by pin.
/// `kinds`: part kinds. `taken`: pins somebody already stated a net for, which the rules leave alone.
pub fn place(funcs: &[Func], pos: &BTreeMap<String, (f64, f64)>, kinds: &BTreeMap<String, sch::Kind>, taken: &BTreeSet<String>) -> (BTreeMap<String, Placed>, Vec<(String, String)>, Vec<Value>) {
let mut placed: BTreeMap<String, Placed> = BTreeMap::new();
let mut nc = vec![];
// 1. each pin's own net; 2. ties the datasheet states, by renaming one net to the other
let mut alias: BTreeMap<String, String> = BTreeMap::new();
let by_pin: BTreeMap<&str, &Func> = funcs.iter().map(|f| (f.pin.as_str(), f)).collect();
let pads_named = |part: &str, what: &str| -> Vec<&Func> { let v: Vec<&Func> = funcs.iter().filter(|f| part_of(&f.pin) == part && f.name.eq_ignore_ascii_case(what)).collect(); if !v.is_empty() { v } else { funcs.iter().filter(|f| f.pin == format!("{part}.{what}")).collect() } };
for f in funcs { for x in &f.expects {
let Some(mine) = own_net(f) else { continue };
let target = match x["kind"].as_str() { Some("tied_to_ground") => Some("GND".to_string()), Some("tied_to_pin") => pads_named(part_of(&f.pin), x["pin"].as_str().unwrap_or("")).first().and_then(|q| own_net(q)), _ => None };
if let Some(t) = target { if t != mine && mine != "GND" { alias.insert(mine, t); } }
} }
let resolve = |n: String| -> String { let mut n = n; for _ in 0..8 { match alias.get(&n) { Some(t) => n = t.clone(), None => break } } n };
for f in funcs {
if taken.contains(&f.pin) { continue; }
if f.ty == "no_connect" { nc.push((f.pin.clone(), format!("{}: {} is not connected", f.source, f.name))); continue; }
let Some(own) = own_net(f) else { continue };
let net = resolve(own.clone());
let tied = net != own;
let (rule, why) = if tied { ("datasheet tie", format!("{} ({}) must be tied to {}: the datasheet states the connection", f.name, f.ty.replace('_', " "), net)) }
else if f.ty == "ground" { ("ground", format!("{} is a ground pin", f.name)) }
else { ("pin function", format!("{} ({}){}", f.name, f.ty.replace('_', " "), if f.note.is_empty() { String::new() } else { format!(": {}", f.note) })) };
// a document states the pin's function; names typed from silkscreen and a numbering that turns with the part are inferences
let tag = if f.kind == "datasheet" || f.kind == "standard" { "DATASHEET" } else { "INFERRED" };
placed.insert(f.pin.clone(), Placed { net, rule, tag, source: f.source.clone(), why });
}
// 3. parts the datasheet puts at a pin: the nearest free one within reach, nearest claims first
let two_pads = |r: &str| -> Option<[(String, (f64, f64)); 2]> { let (a, b) = (format!("{r}.1"), format!("{r}.2")); Some([(a.clone(), *pos.get(&a)?), (b.clone(), *pos.get(&b)?)]) };
let of_kind = |k: sch::Kind| -> Vec<&String> { kinds.iter().filter(|(_, v)| **v == k).map(|(r, _)| r).collect() };
struct Claim { d: f64, part: String, puts: Vec<(String, String)>, why: String, pin: String }
let mut claims: Vec<Claim> = vec![];
let net_of = |pin: &str, placed: &BTreeMap<String, Placed>| placed.get(pin).map(|p| p.net.clone());
for f in funcs {
let (Some(at), Some(mine)) = (pos.get(&f.pin).copied(), net_of(&f.pin, &placed)) else { continue };
for x in &f.expects {
let kind = x["kind"].as_str().unwrap_or("");
let other: Option<(String, Option<(f64, f64)>)> = match kind {
"capacitor_to_ground" | "resistor_to_ground" => Some(("GND".into(), None)),
"capacitor_to_pin" | "resistor_to_pin" => pads_named(part_of(&f.pin), x["pin"].as_str().unwrap_or("")).first().and_then(|q| Some((net_of(&q.pin, &placed)?, if q.ty == "ground" { None } else { pos.get(&q.pin).copied() }))),
_ => None,
};
let Some((far_net, far_at)) = other else { continue };
if far_net == mine { continue; }
let k = if kind.starts_with("capacitor") { sch::Kind::C } else { sch::Kind::R };
for r in of_kind(k) {
let Some(p2) = two_pads(r) else { continue };
let (near, far) = if dist(p2[0].1, at) <= dist(p2[1].1, at) { (&p2[0], &p2[1]) } else { (&p2[1], &p2[0]) };
let d = dist(near.1, at);
if d > CLAIM_MM || far_at.is_some_and(|q| dist(far.1, q) > CLAIM_MM + 1.0) { continue; }
claims.push(Claim { d: d + far_at.map(|q| dist(far.1, q)).unwrap_or(0.0), part: r.clone(), puts: vec![(near.0.clone(), mine.clone()), (far.0.clone(), far_net.clone())], pin: f.pin.clone(),
why: format!("{} ({}) expects a {} to {}: {r} is the nearest free one, {:.1} mm from the pin's pad", f.pin, f.name, if k == sch::Kind::C { "capacitor" } else { "resistor" }, if far_net == "GND" { "ground".to_string() } else { format!("{}", x["pin"].as_str().unwrap_or("")) }, d) });
}
}
}
// crystals: the pins that expect one, each on the nearest terminal of the nearest crystal
let xtal_pins: Vec<&Func> = funcs.iter().filter(|f| f.expects.iter().any(|x| x["kind"] == json!("crystal")) && placed.contains_key(&f.pin)).collect();
let mut claimed_log = vec![];
let mut used: BTreeSet<String> = BTreeSet::new();
let put = |pin: &str, net: &str, why: &str, placed: &mut BTreeMap<String, Placed>, src: &str| { if !taken.contains(pin) && !placed.contains_key(pin) { placed.insert(pin.to_string(), Placed { net: net.to_string(), rule: "claimed part", tag: "INFERRED", source: src.to_string(), why: why.to_string() }); true } else { false } };
let mut xtal_terms: Vec<(String, String)> = vec![];
if let Some(first) = xtal_pins.first() {
let at = pos.get(&first.pin).copied().unwrap_or((0.0, 0.0));
let best = of_kind(sch::Kind::Xtal).into_iter().filter_map(|r| { let pads: Vec<(String, (f64, f64))> = pos.iter().filter(|(k, _)| part_of(k) == r.as_str()).map(|(k, v)| (k.clone(), *v)).collect(); let d = pads.iter().map(|q| dist(q.1, at)).fold(f64::MAX, f64::min); (d <= CRYSTAL_MM).then_some((d, r.clone(), pads)) }).min_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
if let Some((d, y, pads)) = best {
used.insert(y.clone());
// four pads: 1 and 3 are the crystal, 2 and 4 its case (KiCad Device:Crystal_GND24); two pads: both are the crystal
let four = pads.len() >= 4;
let mut free: Vec<&(String, (f64, f64))> = pads.iter().filter(|q| !four || q.0.ends_with(".1") || q.0.ends_with(".3")).collect();
for f in &xtal_pins {
let Some(pa) = pos.get(&f.pin) else { continue };
free.sort_by(|a, b| dist(a.1, *pa).partial_cmp(&dist(b.1, *pa)).unwrap());
if free.is_empty() { break; }
let t = free.remove(0);
let net = placed[&f.pin].net.clone();
if put(&t.0, &net, &format!("{} ({}) expects a crystal: {y} is the nearest, {d:.1} mm away, and this is its terminal nearest the pin", f.pin, f.name), &mut placed, &f.source) { xtal_terms.push((t.0.clone(), net.clone())); claimed_log.push(json!({"part": y, "for": f.pin, "pad": t.0, "net": net})); }
}
if four { for q in pads.iter().filter(|q| q.0.ends_with(".2") || q.0.ends_with(".4")) { put(&q.0, "GND", &format!("case pad of the four-pad crystal {y}: pads 2 and 4 are the case, which is grounded"), &mut placed, "four-pad crystal convention (KiCad Device:Crystal_GND24)"); } }
// each terminal's load capacitor: the nearest free capacitor, its far pad to ground
for (t, net) in &xtal_terms { let Some(ta) = pos.get(t) else { continue };
for r in of_kind(sch::Kind::C) { let Some(p2) = two_pads(r) else { continue };
let (near, far) = if dist(p2[0].1, *ta) <= dist(p2[1].1, *ta) { (&p2[0], &p2[1]) } else { (&p2[1], &p2[0]) };
let d = dist(near.1, *ta);
if d <= CLAIM_MM { claims.push(Claim { d, part: r.clone(), puts: vec![(near.0.clone(), net.clone()), (far.0.clone(), "GND".into())], pin: t.clone(), why: format!("load capacitor of the crystal terminal {t}: {r} is the nearest free capacitor, {d:.1} mm away") }); }
} }
}
}
claims.sort_by(|a, b| a.d.partial_cmp(&b.d).unwrap().then(a.part.cmp(&b.part)));
let mut served: BTreeSet<(String, String)> = BTreeSet::new();
for c in claims {
// one part serves one expectation, and one expectation takes one part
let key = (c.pin.clone(), c.puts[1].1.clone());
if used.contains(&c.part) || served.contains(&key) || c.puts.iter().any(|(p, _)| taken.contains(p) || placed.contains_key(p)) { continue; }
let src = by_pin.get(c.pin.as_str()).map(|f| f.source.clone()).unwrap_or_else(|| "crystal load capacitor, by place".into());
for (p, n) in &c.puts { put(p, n, &c.why, &mut placed, &src); }
used.insert(c.part.clone()); served.insert(key);
claimed_log.push(json!({"part": c.part, "for": c.pin, "mm": (c.d * 10.0).round() / 10.0, "why": c.why}));
}
(placed, nc, claimed_log)
}
struct Inputs { parts: Vec<Value>, sheets: BTreeMap<String, Sheet>, pads: BTreeMap<String, Vec<String>>, pos: BTreeMap<String, (f64, f64)>, kinds: BTreeMap<String, sch::Kind> }
fn inputs(flow: &Path) -> Inputs {
let doc = items::read_json(&flow.join("parts.json")).unwrap_or(Value::Null);
let parts: Vec<Value> = doc["parts"].as_array().cloned().unwrap_or_default();
let sheets = dspins::status(flow).sheets;
let (mut pads, mut pos): (BTreeMap<String, Vec<String>>, BTreeMap<String, (f64, f64)>) = (BTreeMap::new(), BTreeMap::new());
// a footprint file that is no footprint (the build says so by itself) must not take the plan down with it
let cache = kicad_out::cache_dir(flow);
let sound: Vec<Value> = parts.iter().filter(|p| kicad_out::footprint_text(&cache, p["footprint"].as_str().unwrap_or(""), p).ok().and_then(|t| crate::sexp::parse(&t).ok()).is_some_and(|n| matches!(n, crate::sexp::N::L(_)))).cloned().collect();
for pd in kicad_out::pads_of(&cache, &sound) {
if pd.num.is_empty() { continue; }
let v = pads.entry(pd.part.clone()).or_default(); if !v.contains(&pd.num) { v.push(pd.num.clone()); }
pos.entry(format!("{}.{}", pd.part, pd.num)).or_insert((pd.x, pd.y));
}
let kinds = parts.iter().map(|p| { let r = text(&p["ref"]); let k = sch::kind_of(&r, p["footprint"].as_str().unwrap_or("")); (r, k) }).collect();
Inputs { parts, sheets, pads, pos, kinds }
}
/// Changes whenever what the nets are built from changes: the functions, the parts' places. Empty when no part has a pinout.
pub fn key(flow: &Path) -> String {
let i = inputs(flow);
let (_, funcs) = chain(&i.parts, &i.sheets, &i.pads);
if funcs.is_empty() { return String::new(); }
let mut lines: Vec<String> = funcs.iter().map(|f| format!("{}|{}|{}|{:?}|{}|{}", f.pin, f.name, f.ty, f.net, f.kind, serde_json::to_string(&f.expects).unwrap_or_default())).collect();
for (k, v) in &i.pos { if matches!(i.kinds.get(part_of(k)), Some(sch::Kind::C | sch::Kind::R | sch::Kind::Xtal)) || funcs.iter().any(|f| &f.pin == k) { lines.push(format!("{k}@{:.2},{:.2}", v.0, v.1)); } }
// what the copper shows joined, without names: nets are merged on it, so they are built again when it changes
let mut joined: Vec<String> = crate::witness::conductors(flow).into_iter().map(|c| c.join(",")).collect(); joined.sort();
lines.push(format!("copper:{}", joined.join(";")));
format!("3:{}", crate::confidence::sha256_hex(lines.join("\n").as_bytes()))
}
/// The pinout nets in nets.json are older than what they are built from.
pub fn stale(flow: &Path) -> bool {
let have = items::read_json(&flow.join("nets.json")).ok().map(|d| text(&d["pinoutKey"])).unwrap_or_default();
key(flow) != have
}
/// Chips with a pinout whose orientation is not settled: what `auto` asks for, once each.
pub fn unsettled(flow: &Path) -> Vec<String> {
let i = inputs(flow);
chain(&i.parts, &i.sheets, &i.pads).0.into_iter().filter_map(|l| l.ask).collect()
}
/// `connect pinout`: the chain to detect/pinout.json, and the nets the rules give into nets.json. Nets somebody stated by hand
/// stay; the rules' own nets from an earlier run are built again; copper-made nets lose the pins the rules now place.
pub fn build(flow: &Path) -> Result<Value, String> {
let i = inputs(flow);
let (links, funcs) = chain(&i.parts, &i.sheets, &i.pads);
let mut doc = items::read_json(&flow.join("nets.json")).unwrap_or(json!({"nets": []}));
let old: Vec<Value> = doc["nets"].as_array().cloned().unwrap_or_default();
let (before_nets, before_pins) = (old.len(), old.iter().map(|n| n["pins"].as_array().map(|a| a.len()).unwrap_or(0)).sum::<usize>());
let stated: Vec<Value> = old.iter().filter(|n| n["fromPinout"] != json!(true) && n["fromCopper"] != json!(true)).cloned().collect();
let taken: BTreeSet<String> = stated.iter().flat_map(|n| n[if n["inferredPins"].is_array() { "inferredPins" } else { "pins" }].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string))).collect();
let (mut placed, nc, claimed) = place(&funcs, &i.pos, &i.kinds, &taken);
// once attribution has read the copper, nets it shows joined are one net; `auto` builds the nets again after its first run
let (merged, refused) = merge_on_copper(&mut placed, &funcs, &crate::witness::conductors(flow));
let mut by_net: BTreeMap<String, Vec<(&String, &Placed)>> = BTreeMap::new();
for (pin, p) in &placed { by_net.entry(p.net.clone()).or_default().push((pin, p)); }
let mut nets = stated.clone();
let (mut n_ds, mut n_inf, mut p_ds, mut p_inf) = (0, 0, 0, 0);
let fn_ty: BTreeMap<&str, &str> = funcs.iter().map(|f| (f.pin.as_str(), f.ty.as_str())).collect();
for (name, pins) in &by_net {
let names: Vec<&String> = pins.iter().map(|(q, _)| *q).collect();
// a net somebody stated under this name takes the rule's pins as its own claim, and stays theirs
if let Some(n) = nets.iter_mut().find(|n| n["name"].as_str() == Some(name.as_str())) {
let key = if n["inferredPins"].is_array() { "inferredPins" } else { "pins" };
let mut have: Vec<Value> = n[key].as_array().cloned().unwrap_or_default();
for q in &names { if !have.iter().any(|h| h.as_str() == Some(q.as_str())) { have.push(json!(q)); } }
n[key] = json!(have); if key == "inferredPins" { let mut ps = n["pins"].as_array().cloned().unwrap_or_default(); for q in &names { if !ps.iter().any(|h| h.as_str() == Some(q.as_str())) { ps.push(json!(q)); } } n["pins"] = json!(ps); }
n["pinout"] = json!({"pins": pins.iter().map(|(q, p)| ((*q).clone(), json!({"rule": p.rule, "tag": p.tag, "source": p.source, "why": p.why}))).collect::<serde_json::Map<_, _>>()});
continue;
}
let all_doc = pins.iter().all(|(_, p)| p.tag == "DATASHEET");
let tag = if all_doc { "DATASHEET" } else { "INFERRED" };
if all_doc { n_ds += 1; } else { n_inf += 1; }
for (_, p) in pins { if p.tag == "DATASHEET" { p_ds += 1; } else { p_inf += 1; } }
let sources: Vec<String> = pins.iter().map(|(_, p)| p.source.clone()).collect::<BTreeSet<_>>().into_iter().collect();
let rules: Vec<&str> = pins.iter().map(|(_, p)| p.rule).collect::<BTreeSet<_>>().into_iter().collect();
let rail = if name == "GND" { Some("ground") } else if pins.iter().any(|(q, _)| matches!(fn_ty.get(q.as_str()), Some(&"power_in" | &"power_out"))) { Some("supply") } else { None };
let mut n = json!({"name": name, "pins": names, "inferredPins": names, "tag": tag, "statedTag": tag, "fromPinout": true, "source": sources.join("; "), "nameSource": sources.first().cloned().unwrap_or_default(),
"why": format!("from the pinout, by rule ({}): no copper is claimed here; attribution holds it against the copper", rules.join(", ")),
"pinout": {"rules": rules, "datasheetPins": pins.iter().filter(|(_, p)| p.tag == "DATASHEET").count(), "inferredPins": pins.iter().filter(|(_, p)| p.tag == "INFERRED").count(),
"pins": pins.iter().map(|(q, p)| ((*q).clone(), json!({"rule": p.rule, "tag": p.tag, "source": p.source, "why": p.why}))).collect::<serde_json::Map<_, _>>()}});
if !all_doc { n["tests"] = json!([format!("T-net-{name}")]); }
if let Some(r) = rail { n["rail"] = json!(r); }
nets.push(n);
}
// nets made from copper keep the pins no rule places; attribution rebuilds them from the copper in any case
for n in old.iter().filter(|n| n["fromCopper"] == json!(true)) {
let keep: Vec<Value> = n["pins"].as_array().into_iter().flatten().filter(|p| p.as_str().is_some_and(|q| !placed.contains_key(q))).cloned().collect();
if !keep.is_empty() { let mut m = n.clone(); m["pins"] = json!(keep); nets.push(m); }
}
let mut ncs: Vec<Value> = doc["noConnect"].as_array().into_iter().flatten().filter(|x| x["fromPinout"] != json!(true)).cloned().collect();
for (pin, why) in &nc { if !ncs.iter().any(|x| x.as_str() == Some(pin.as_str()) || x["pin"].as_str() == Some(pin.as_str())) { ncs.push(json!({"pin": pin, "why": why, "fromPinout": true})); } }
let (after_nets, after_pins) = (nets.len(), nets.iter().map(|n| n["pins"].as_array().map(|a| a.len()).unwrap_or(0)).sum::<usize>());
doc["nets"] = json!(nets);
if ncs.is_empty() { if let Some(o) = doc.as_object_mut() { o.remove("noConnect"); } } else { doc["noConnect"] = json!(ncs); }
doc["pinoutKey"] = json!(key(flow));
let wr = |p: &Path, v: &Value| { if let Some(d) = p.parent() { std::fs::create_dir_all(d).ok(); } std::fs::write(p, serde_json::to_string_pretty(v).unwrap_or_default() + "\n").map_err(|e| format!("{}: {e}", p.display())) };
wr(&flow.join("nets.json"), &doc)?;
let asks: Vec<&String> = links.iter().filter_map(|l| l.ask.as_ref()).collect();
let count = |f: &dyn Fn(&Link) -> bool| links.iter().filter(|l| f(l)).count();
let summary = json!({"parts": links.len(), "identityRead": count(&|l| l.identity == "marking read"), "withPinout": count(&|l| l.pinout != "none" && !l.pinout.starts_with("package family")), "pinoutUsed": count(&|l| l.usable), "orientationNotSettled": asks.len(),
"netsBefore": before_nets, "pinsOnANetBefore": before_pins, "netsAfter": after_nets, "pinsOnANetAfter": after_pins,
"pinoutNets": n_ds + n_inf, "pinoutNetsDatasheet": n_ds, "pinoutNetsInferred": n_inf, "pinoutPins": placed.len(), "pinoutPinsDatasheet": p_ds, "pinoutPinsInferred": p_inf, "noConnect": nc.len(), "partsClaimed": claimed.len(), "netsMergedOnCopper": merged.len(), "mergesRefused": refused.len()});
wr(&flow.join(FILE), &json!({"summary": summary, "ask": asks,
"chain": links.iter().map(|l| json!({"part": l.part, "identity": l.identity, "orientation": l.orientation, "pinout": l.pinout, "source": l.source, "netsBuilt": l.usable})).collect::<Vec<_>>(),
"claimed": claimed, "mergedOnCopper": merged, "mergesRefused": refused, "rules": {"claimReachMm": CLAIM_MM, "crystalReachMm": CRYSTAL_MM, "note": "the reaches are policy, not fitted to a board"}}))?;
Ok(summary)
}
/// After attribution: a pin the datasheet leaves unconnected that the copper put on a net is on that net, not unconnected.
/// Its `noConnect` entry (one the pinout wrote) goes, so the schematic draws what nets.json says, and the net names the pin:
/// the witness raises it as a disagreement. Returns the pins.
pub fn reconcile(flow: &Path) -> Result<Vec<String>, String> {
let mut doc = items::read_json(&flow.join("nets.json"))?;
let on_net: BTreeMap<String, String> = doc["nets"].as_array().into_iter().flatten().flat_map(|n| { let name = text(&n["name"]); n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).map(move |p| (p, name.clone())).collect::<Vec<_>>() }).collect();
let ncs: Vec<Value> = doc["noConnect"].as_array().cloned().unwrap_or_default();
let hit: Vec<String> = ncs.iter().filter(|x| x["fromPinout"] == json!(true)).filter_map(|x| x["pin"].as_str()).filter(|p| on_net.contains_key(*p)).map(str::to_string).collect();
if hit.is_empty() { return Ok(hit); }
let keep: Vec<Value> = ncs.into_iter().filter(|x| !x["pin"].as_str().is_some_and(|p| hit.iter().any(|h| h == p))).collect();
if keep.is_empty() { if let Some(o) = doc.as_object_mut() { o.remove("noConnect"); } } else { doc["noConnect"] = json!(keep); }
for n in doc["nets"].as_array_mut().into_iter().flatten() {
let mine: Vec<&String> = hit.iter().filter(|h| on_net.get(*h).map(|s| s.as_str()) == n["name"].as_str()).collect();
if !mine.is_empty() { n["unconnectedPinsOnCopper"] = json!({"pins": mine, "finding": "the datasheet leaves these pins unconnected and the copper joins them to this net: the copper is misread there, or the part is not the one in parts.json"}); }
}
std::fs::write(flow.join("nets.json"), serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n").map_err(|e| format!("nets.json: {e}"))?;
Ok(hit)
}
/// What to look for on a package to find pin 1.
fn mark_hint(p: &Value) -> &'static str {
let k = format!("{} {}", text(&p["footprint"]), text(&p["package"])).to_uppercase();
if ["QFN", "QFP", "DFN", "LGA", "BGA", "SON", "PLCC"].iter().any(|x| k.contains(x)) { "a dot or dimple moulded in one corner of the package top, a chamfered corner, or a silkscreen dot, triangle or corner line beside one corner of its outline" }
else if ["SOIC", "SOP", "SSOP", "DIP", "MSOP", "TO-263", "TO-252"].iter().any(|x| k.contains(x)) { "a notch or a dot at one end of the package, a bevelled long edge (pin 1 is on that side), or a silkscreen dot or a thicker line at the pin 1 end" }
else if k.contains("SOT") || k.contains("SC-70") { "a dot on the package, a silkscreen dot or triangle beside one lead; the lead count per side gives the two possible ways, and a marking read upright is a guess, not a mark" }
else { "a dot, notch or bevel on the package, or a silkscreen dot, triangle or line beside one pin" }
}
/// The report's table of nets by how each is known: seen in copper, from a pinout and since confirmed, or from a pinout only.
pub fn report_section(flow: &Path) -> String {
let Ok(doc) = items::read_json(&flow.join("nets.json")) else { return String::new() };
let nets: Vec<&Value> = doc["nets"].as_array().into_iter().flatten().collect();
if !nets.iter().any(|n| n["fromPinout"] == json!(true)) { return String::new(); }
let class = |n: &Value| -> &'static str { let seen = n["tag"] == json!("MEASURED"); if n["fromPinout"] == json!(true) { if seen { "pinout, confirmed by copper" } else if n["contradiction"].is_object() { "pinout, contradicted by copper" } else if n["statedTag"] == json!("DATASHEET") { "pinout only, DATASHEET" } else { "pinout only, INFERRED" } } else if n["fromCopper"] == json!(true) { "copper only" } else if seen { "stated, confirmed by copper" } else { "stated" } };
let mut count: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
for n in &nets { let e = count.entry(class(n)).or_default(); e.0 += 1; e.1 += n["pins"].as_array().map(|a| a.len()).unwrap_or(0); }
let mut md = String::from("\n## Nets by how they are known\n\nNets are built from the parts first (identity, orientation, pinout: `detect/pinout.json`), then held against the copper. A net known only from a pinout is no measurement: DATASHEET means a document states every pin's place on it, INFERRED means a rule put a part there by its place and a test settles it.\n\n| How known | Nets | Pins |\n|---|---|---|\n");
for (k, (a, b)) in &count { md += &format!("| {k} | {a} | {b} |\n"); }
md += "\n| Net | Pins | How known | Tag now | Rules | Copper could not separate |\n|---|---|---|---|---|---|\n";
for n in nets.iter().filter(|n| n["fromPinout"] == json!(true)) {
md += &format!("| {} | {} | {} | {} | {} | {} |\n", text(&n["name"]), n["pins"].as_array().map(|a| a.len()).unwrap_or(0), class(n), text(&n["tag"]), n["pinout"]["rules"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "),
n["datasheetWitness"]["copperCouldNotSeparate"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "));
}
md
}
/// What `connect pinout` prints.
pub fn one_line(s: &Value) -> Vec<String> {
let mut h = vec![format!("the chain: {} parts, {} with a marking read, {} with a pinout, {} used for nets, {} waiting for their orientation (reverse-flow/{FILE})", s["parts"], s["identityRead"], s["withPinout"], s["pinoutUsed"], s["orientationNotSettled"]),
format!("nets.json: {} nets and {} pins on a net before, {} and {} after; {} nets from the pinout ({} DATASHEET, {} INFERRED with a test), {} pins ({} by a document, {} inferred), {} parts claimed by place, {} pins marked not connected, {} net(s) merged where the copper joins them, {} merge(s) refused as a disagreement", s["netsBefore"], s["pinsOnANetBefore"], s["netsAfter"], s["pinsOnANetAfter"], s["pinoutNets"], s["pinoutNetsDatasheet"], s["pinoutNetsInferred"], s["pinoutPins"], s["pinoutPinsDatasheet"], s["pinoutPinsInferred"], s["partsClaimed"], s["noConnect"], s["netsMergedOnCopper"], s["mergesRefused"]),
"none of these nets is seen: `connect attribute` holds each against the copper next, raises it to MEASURED where every pin is seen joined, and writes a contradiction with a test where the copper says otherwise".to_string()];
if s["orientationNotSettled"].as_u64().unwrap_or(0) > 0 { h.push("a chip whose orientation is not settled gets no nets from its pinout: `next` says which".into()); }
h
}
#[cfg(test)]
mod tests {
use super::*;
fn sheet() -> Sheet {
dspins::parse(&json!({"part": "U1", "document": {"name": "XY1234 hub", "revision": "B", "page": 9}, "pins": [
{"pad": "1", "name": "VDD", "type": "power_in", "expects": [{"kind": "capacitor_to_ground"}]},
{"pad": "2", "name": "VDDA", "type": "power_in", "expects": [{"kind": "tied_to_pin", "pin": "VDD"}]},
{"pad": "3", "name": "XI", "type": "input", "expects": [{"kind": "crystal"}]}, {"pad": "4", "name": "XO", "type": "passive", "expects": [{"kind": "crystal"}]},
{"pad": "5", "name": "RBIAS", "type": "passive", "expects": [{"kind": "resistor_to_ground"}]}, {"pad": "6", "name": "NC", "type": "no_connect"},
{"pad": "7", "name": "TX", "type": "output"}, {"pad": "8", "name": "VSS", "type": "ground"}]}), "U1", "f", None).unwrap()
}
fn part(r: &str, fp: &str, extra: Value) -> Value { let mut p = json!({"ref": r, "footprint": fp}); for (k, v) in extra.as_object().unwrap() { p[k] = v.clone(); } p }
fn pads(v: &[(&str, &[&str])]) -> BTreeMap<String, Vec<String>> { v.iter().map(|(r, p)| (r.to_string(), p.iter().map(|s| s.to_string()).collect())).collect() }
#[test]
fn the_chain_holds_only_with_identity_orientation_and_pinout_and_asks_for_exactly_what_is_missing() {
let eight: Vec<&str> = vec!["1", "2", "3", "4", "5", "6", "7", "8"];
let sheets: BTreeMap<String, Sheet> = [("U1".to_string(), sheet()), ("U2".to_string(), { let mut s = sheet(); s.part = "U2".into(); s }), ("U3".to_string(), { let mut s = sheet(); s.part = "U3".into(); s })].into_iter().collect();
let parts = vec![
part("U1", "QFN", json!({"mpn": "XY1234", "marking": "XY1234", "fieldTags": {"mpn": "SEEN"}, "pin1": {"mark": "dot top left", "tag": "SEEN"}})),
part("U2", "QFN", json!({"mpn": "XY1234", "marking": "XY1234", "fieldTags": {"mpn": "SEEN"}, "pin1": {"mark": "taken from the reading direction", "tag": "INFERRED"}})),
part("U3", "QFN", json!({"mpn": "XY1234", "marking": "XY1234", "fieldTags": {"mpn": "SEEN"}, "pin1": {"mark": "looked for a dot and a bevel: none", "tag": "UNKNOWN"}})),
part("J1", "Connector_USB:USB_C_Receptacle_X", json!({})), part("J2", "PinHeader_1x03", json!({"pins": {"1": "GND", "2": "RX", "3": "3V3"}})),
part("C1", "C_0402", json!({})), part("D1", "LED_0603", json!({"pin1": {"mark": "green dot", "tag": "SEEN"}})), part("Q1", "SOT-23", json!({}))];
let pd = pads(&[("U1", &eight), ("U2", &eight), ("U3", &eight), ("J1", &["A1", "A4", "A5", "A6", "A7", "B5", "B6", "B7", "B12", "SH"]), ("J2", &["1", "2", "3"]), ("C1", &["1", "2"]), ("D1", &["1", "2"]), ("Q1", &["1", "2", "3"])]);
let (links, funcs) = chain(&parts, &sheets, &pd);
let l = |r: &str| links.iter().find(|l| l.part == r).unwrap();
assert!(l("U1").usable && l("U1").orientation == "pin 1 mark seen" && l("U1").pinout == "datasheet pin table" && l("U1").identity == "marking read");
// an inferred orientation: asked for, and no function of that chip is used
assert!(!l("U2").usable && l("U2").ask.as_ref().unwrap().contains("which way round it sits is not settled (pin1: tagged INFERRED)"));
assert!(!funcs.iter().any(|f| f.pin.starts_with("U2.") || f.pin.starts_with("U3.")));
// looked and cannot be told: an answer, not asked again, still no nets
assert!(!l("U3").usable && l("U3").ask.is_none() && l("U3").orientation == "not determinable");
// a USB-C receptacle needs no orientation for ground, VBUS and data; its CC numbering is marked as not settled
assert!(l("J1").usable && l("J1").pinout.starts_with("standard: USB Type-C"));
assert_eq!(funcs.iter().find(|f| f.pin == "J1.A6").unwrap().name, "DP");
assert_eq!(funcs.iter().find(|f| f.pin == "J1.A5").unwrap().kind, "standard, numbering not settled");
assert!(l("J2").usable && l("J2").identity == "package only");
assert_eq!(l("C1").orientation, "not needed: two pads, no polarity"); assert_eq!(l("D1").orientation, "polarity mark seen"); assert!(l("Q1").pinout.starts_with("package family only"));
assert_eq!(links.iter().filter(|l| l.ask.is_some()).count(), 1);
}
#[test]
fn an_unconnected_pin_the_copper_put_on_a_net_is_drawn_on_that_net_and_named_there() {
let d = std::env::temp_dir().join(format!("pinnets-test-nc-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("nets.json"), json!({"nets": [{"name": "U1_XO", "pins": ["U1.4", "U1.6", "Y1.3"]}, {"name": "GND", "pins": ["U1.8"]}],
"noConnect": [{"pin": "U1.6", "why": "datasheet: NC", "fromPinout": true}, {"pin": "U1.9", "why": "datasheet: NC", "fromPinout": true}, {"pin": "U1.8", "why": "typed by hand"}]}).to_string()).unwrap();
assert_eq!(reconcile(&d).unwrap(), vec!["U1.6".to_string()]);
let n = items::read_json(&d.join("nets.json")).unwrap();
// the pinout's entry for the joined pin goes; one still unconnected and one somebody typed stay
assert_eq!(n["noConnect"].as_array().unwrap().len(), 2);
assert_eq!(n["nets"][0]["unconnectedPinsOnCopper"]["pins"], json!(["U1.6"]));
assert!(n["nets"][1]["unconnectedPinsOnCopper"].is_null());
assert!(reconcile(&d).unwrap().is_empty());
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn nets_come_from_pin_functions_by_named_rules_and_only_a_document_makes_a_pin_datasheet() {
let eight: Vec<&str> = vec!["1", "2", "3", "4", "5", "6", "7", "8"];
let sheets: BTreeMap<String, Sheet> = [("U1".to_string(), sheet())].into_iter().collect();
let parts = vec![part("U1", "QFN", json!({"mpn": "XY1234", "marking": "XY1234", "fieldTags": {"mpn": "SEEN"}, "pin1": {"mark": "dot", "tag": "SEEN"}})), part("J1", "Connector_USB:USB_C_Receptacle_X", json!({})), part("J2", "PinHeader_1x03", json!({"pins": {"1": "GND", "2": "RX", "3": "3V3"}}))];
let pd = pads(&[("U1", &eight), ("J1", &["A1", "A4", "A5", "A6", "B6", "B9"]), ("J2", &["1", "2", "3"])]);
let (_, funcs) = chain(&parts, &sheets, &pd);
// U1's pads in a row 0.5 mm apart at y 0; C1 beside VDD, C2 far away, R1 beside RBIAS, Y1 (four pads) near XI and XO with C3 and C4 as its loads
let mut pos: BTreeMap<String, (f64, f64)> = (1..=8).map(|k| (format!("U1.{k}"), (k as f64 * 0.5, 0.0))).collect();
for (k, v) in [("C1.1", (0.5, 1.0)), ("C1.2", (0.5, 2.0)), ("C2.1", (20.0, 20.0)), ("C2.2", (21.0, 20.0)), ("R1.1", (2.5, -1.0)), ("R1.2", (2.5, -2.0)),
("Y1.1", (1.4, 3.0)), ("Y1.2", (1.4, 4.0)), ("Y1.3", (2.6, 4.0)), ("Y1.4", (2.6, 3.0)), ("C3.1", (0.4, 3.0)), ("C3.2", (-0.6, 3.0)), ("C4.1", (3.6, 4.0)), ("C4.2", (4.6, 4.0))] { pos.insert(k.to_string(), v); }
let kinds: BTreeMap<String, sch::Kind> = [("U1", sch::Kind::Ic), ("C1", sch::Kind::C), ("C2", sch::Kind::C), ("C3", sch::Kind::C), ("C4", sch::Kind::C), ("R1", sch::Kind::R), ("Y1", sch::Kind::Xtal)].iter().map(|(r, k)| (r.to_string(), *k)).collect();
let (placed, nc, claimed) = place(&funcs, &pos, &kinds, &BTreeSet::new());
let at = |pin: &str| { let p = &placed[pin]; (p.net.as_str(), p.rule, p.tag) };
// ground pins of every pinout on GND; the datasheet's tie puts VDDA on VDD's net; both DATASHEET
assert_eq!(at("U1.8"), ("GND", "ground", "DATASHEET")); assert_eq!(at("J1.A1"), ("GND", "ground", "DATASHEET"));
assert_eq!(at("U1.1"), ("U1_VDD", "pin function", "DATASHEET")); assert_eq!(at("U1.2"), ("U1_VDD", "datasheet tie", "DATASHEET"));
assert_eq!(at("U1.7"), ("U1_TX", "pin function", "DATASHEET"));
// both D+ pads of the receptacle on one net; VBUS of this connector its own rail; CC numbering inferred
assert_eq!(at("J1.A6").0, "J1_DP"); assert_eq!(at("J1.B6").0, "J1_DP"); assert_eq!(at("J1.A4").0, "J1_VBUS"); assert_eq!(at("J1.B9").0, "J1_VBUS");
assert_eq!(at("J1.A5"), ("J1_CC1", "pin function", "INFERRED"));
// silkscreen names: ground joins GND, a rail keeps its name, a signal is the header's own; all inferred
assert_eq!(at("J2.1"), ("GND", "ground", "INFERRED")); assert_eq!(at("J2.3"), ("3V3", "pin function", "INFERRED")); assert_eq!(at("J2.2").0, "J2_RX");
// the decoupling capacitor beside VDD: near pad on the rail, far pad to ground, inferred; the far capacitor is left alone
assert_eq!(at("C1.1"), ("U1_VDD", "claimed part", "INFERRED")); assert_eq!(at("C1.2"), ("GND", "claimed part", "INFERRED"));
assert!(!placed.contains_key("C2.1"));
assert_eq!(at("R1.1").0, "U1_RBIAS"); assert_eq!(at("R1.2").0, "GND");
// the crystal: terminals 1 and 3 on XI and XO by nearness, case pads grounded, a load capacitor on each terminal
assert_eq!(at("Y1.1").0, "U1_XI"); assert_eq!(at("Y1.3").0, "U1_XO"); assert_eq!(at("Y1.2").0, "GND"); assert_eq!(at("Y1.4").0, "GND");
assert_eq!(at("C3.1").0, "U1_XI"); assert_eq!(at("C3.2").0, "GND"); assert_eq!(at("C4.1").0, "U1_XO"); assert_eq!(at("C4.2").0, "GND");
assert_eq!(nc.len(), 1); assert_eq!(nc[0].0, "U1.6");
assert!(claimed.len() >= 5);
// the copper joins the regulator rail to the header's 3V3 and the transmit output to the header's RX: one net each, named by the rail and by the driver;
// it also joins ground to the rail (a misread): refused, and left for attribution to raise
let mut m = placed.clone();
let cs = vec![vec!["U1.1".to_string(), "J2.3".to_string()], vec!["U1.7".to_string(), "J2.2".to_string()], vec!["U1.8".to_string(), "C1.1".to_string()]];
let (merged, refused) = merge_on_copper(&mut m, &funcs, &cs);
assert_eq!(m["U1.1"].net, "3V3"); assert_eq!(m["U1.2"].net, "3V3"); assert_eq!(m["C1.1"].net, "3V3"); assert_eq!(m["J2.2"].net, "U1_TX");
assert_eq!(merged.len(), 2); assert_eq!(refused.len(), 1); assert_eq!(m["U1.8"].net, "GND");
assert!(refused[0]["why"].as_str().unwrap().contains("under different names"));
// ground of one part and the rail of another on one conductor: refused for what it would join
let mut m = placed.clone();
let (_, refused) = merge_on_copper(&mut m, &funcs, &[vec!["J1.A1".to_string(), "J1.A4".to_string()]]);
assert!(refused[0]["why"].as_str().unwrap().contains("under different names") && m["J1.A4"].net == "J1_VBUS");
// an input of the chip on the chip's own ground copper is a strap: merged
let mut m = placed.clone();
let (merged, refused) = merge_on_copper(&mut m, &funcs, &[vec!["U1.8".to_string(), "U1.3".to_string()]]);
assert!(merged.len() == 1 && refused.is_empty() && m["U1.3"].net == "GND");
// a pin somebody already stated a net for is left to them
let taken: BTreeSet<String> = ["U1.7".to_string(), "C1.1".to_string()].into_iter().collect();
let (placed, _, _) = place(&funcs, &pos, &kinds, &taken);
assert!(!placed.contains_key("U1.7") && !placed.contains_key("C1.1") && !placed.contains_key("C1.2"));
}
}