skill
EDA Bridge Standard: one way for an AI to drive every PCB tool
Public Made by Adomby adom
The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.
main
Colby Knox
standard 0.15.0: engine 0.12.0 adds assembly_check and net_lengths; altium 0.2.80
d55b329
12d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
//! Routed length per net from the board model (tracks and arcs, per layer, with the vias), the skew inside each
//! differential pair, and the spread of a matched-length group against its tolerance. From the Altium bridge's lengths.rs.
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }
fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }
/// Per net: total routed length (mm), length by layer, via count and segment count. Pure, for the tests.
pub fn lengths(state: &Value) -> BTreeMap<String, Value> {
let mut acc: BTreeMap<String, (f64, BTreeMap<String, f64>, usize, usize)> = BTreeMap::new();
let mut add = |net: &str, layer: &str, len: f64| {
if net.is_empty() { return; }
let e = acc.entry(net.to_string()).or_default();
e.0 += len;
*e.1.entry(layer.to_string()).or_insert(0.0) += len;
e.3 += 1;
};
for t in state["tracks"].as_array().cloned().unwrap_or_default() {
let len = ((f(&t["end"]["x"]) - f(&t["start"]["x"])).powi(2) + (f(&t["end"]["y"]) - f(&t["start"]["y"])).powi(2)).sqrt();
add(t["net"].as_str().unwrap_or(""), t["layer"].as_str().unwrap_or("?"), len);
}
for a in state["arcs"].as_array().cloned().unwrap_or_default() {
let mut span = f(&a["endAngle"]) - f(&a["startAngle"]);
if span <= 0.0 { span += 360.0; }
add(a["net"].as_str().unwrap_or(""), a["layer"].as_str().unwrap_or("?"), f(&a["radius"]) * span.to_radians());
}
for v in state["vias"].as_array().cloned().unwrap_or_default() {
if let Some(n) = v["net"].as_str().filter(|n| !n.is_empty()) { acc.entry(n.to_string()).or_default().2 += 1; }
}
acc.into_iter().map(|(n, (len, by, vias, segs))| {
let by: Map<String, Value> = by.into_iter().map(|(k, v)| (k, json!(r3(v)))).collect();
(n.clone(), json!({"net": n, "lengthMm": r3(len), "byLayer": by, "vias": vias, "segments": segs}))
}).collect()
}
fn len_of(l: &BTreeMap<String, Value>, net: &str) -> f64 {
l.iter().find(|(k, _)| k.eq_ignore_ascii_case(net)).map(|(_, v)| f(&v["lengthMm"])).unwrap_or(0.0)
}
/// Skew of each pair (pos minus neg). Pure, for the tests.
pub fn pair_skew(l: &BTreeMap<String, Value>, pairs: &[Value]) -> Vec<Value> {
pairs.iter().map(|p| {
let (pos, neg) = (p["pos"].as_str().unwrap_or(""), p["neg"].as_str().unwrap_or(""));
let (a, b) = (len_of(l, pos), len_of(l, neg));
json!({"name": p.get("name").cloned().unwrap_or(json!(format!("{pos}/{neg}"))), "pos": pos, "neg": neg, "posMm": r3(a), "negMm": r3(b), "skewMm": r3(a - b)})
}).collect()
}
/// A matched-length group: its shortest and longest net and whether the spread is within the tolerance. Pure.
pub fn group(l: &BTreeMap<String, Value>, nets: &[String], tol: f64) -> Value {
let vals: Vec<(String, f64)> = nets.iter().map(|n| (n.clone(), len_of(l, n))).collect();
let (min, max) = vals.iter().fold((f64::MAX, f64::MIN), |a, v| (a.0.min(v.1), a.1.max(v.1)));
let spread = if vals.is_empty() { 0.0 } else { max - min };
let longest = vals.iter().max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|v| v.0.clone());
json!({"nets": vals.iter().map(|(n, v)| json!({"net": n, "lengthMm": r3(*v), "shortByMm": r3(max - v)})).collect::<Vec<_>>(),
"minMm": r3(if vals.is_empty() { 0.0 } else { min }), "maxMm": r3(if vals.is_empty() { 0.0 } else { max }), "spreadMm": r3(spread),
"toleranceMm": tol, "withinTolerance": spread <= tol + 1e-9, "longest": longest})
}
/// Differential pairs by their net names: X_P/X_N, XP/XN (after a separator), X+/X-. Pure.
pub fn pairs_by_name(l: &BTreeMap<String, Value>) -> Vec<Value> {
let mut out = Vec::new();
for n in l.keys() {
let u = n.to_uppercase();
for (p, q) in [("_P", "_N"), ("+", "-"), ("_DP", "_DM"), ("_D+", "_D-")] {
if let Some(base) = u.strip_suffix(p) {
let want = format!("{base}{q}");
if let Some(neg) = l.keys().find(|k| k.to_uppercase() == want) {
out.push(json!({"name": &n[..base.len()], "pos": n, "neg": neg}));
}
}
}
}
out
}
/// net_lengths: input {board, args: {nets?, pairs?: [{name?, pos, neg}], groups?: [{nets, toleranceMm}]}}. Without
/// pairs, the pairs come from the net names (pairsFrom says which).
pub fn net_lengths(input: &Value) -> Value {
let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };
let args = &input["args"];
let all = lengths(&st);
let want: Vec<String> = args.get("nets").and_then(|n| n.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
let nets: Vec<Value> = if want.is_empty() { all.values().cloned().collect() } else {
want.iter().map(|n| all.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v.clone()).unwrap_or(json!({"net": n, "lengthMm": 0.0, "unrouted": true}))).collect()
};
let (pairs, from): (Vec<Value>, &str) = match args.get("pairs").and_then(|p| p.as_array()) {
Some(p) => (p.clone(), "args"),
None => (pairs_by_name(&all), "net names (_P/_N, +/-): pass pairs for the board's own differential pairs"),
};
let groups: Vec<Value> = args.get("groups").and_then(|g| g.as_array()).cloned().unwrap_or_default().iter().map(|g| {
let n: Vec<String> = g["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|x| x.as_str().map(str::to_string)).collect();
group(&all, &n, g.get("toleranceMm").and_then(|t| t.as_f64()).unwrap_or(0.5))
}).collect();
let out_of_tol = groups.iter().filter(|g| g["withinTolerance"] == json!(false)).count();
json!({"success": true, "units": "mm", "nets": nets, "pairs": pair_skew(&all, &pairs), "pairsFrom": from, "groups": groups, "groupsOutOfTolerance": out_of_tol,
"_hint": "Lengths are the routed copper of each net (tracks and arcs; vias counted, not added). A net with branches sums all of them, so read a multi-pin net as total copper, not pin to pin. Lengthen the short nets of a group (length_tune, or route_net with a detour) until spreadMm is within toleranceMm; add a matched-length rule with add_rule."})
}
#[cfg(test)]
mod tests {
use super::*;
fn board() -> Value {
json!({"tracks": [
{"start": {"x": 0.0, "y": 0.0}, "end": {"x": 10.0, "y": 0.0}, "layer": "F.Cu", "net": "USB_P"},
{"start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 5.0}, "layer": "B.Cu", "net": "USB_P"},
{"start": {"x": 0.0, "y": 1.0}, "end": {"x": 12.0, "y": 1.0}, "layer": "F.Cu", "net": "USB_N"}],
"arcs": [{"center": {"x": 0.0, "y": 0.0}, "radius": 2.0, "startAngle": 0.0, "endAngle": 90.0, "layer": "F.Cu", "net": "CLK"}],
"vias": [{"position": {"x": 10.0, "y": 0.0}, "net": "USB_P"}]})
}
#[test]
fn lengths_add_tracks_and_arcs_per_layer() {
let l = lengths(&board());
assert_eq!(l["USB_P"]["lengthMm"], json!(15.0));
assert_eq!(l["USB_P"]["byLayer"]["B.Cu"], json!(5.0));
assert_eq!(l["USB_P"]["vias"], json!(1));
assert_eq!(l["CLK"]["lengthMm"], json!(3.142));
}
#[test]
fn pairs_report_skew_and_groups_their_spread() {
let l = lengths(&board());
let s = pair_skew(&l, &[json!({"name": "USB", "pos": "USB_P", "neg": "USB_N"})]);
assert_eq!(s[0]["skewMm"], json!(3.0));
let g = group(&l, &["USB_P".into(), "usb_n".into()], 2.0);
assert_eq!((g["spreadMm"].clone(), g["withinTolerance"].clone(), g["longest"].clone()), (json!(3.0), json!(false), json!("USB_P")));
assert_eq!(g["nets"][1]["shortByMm"], json!(3.0));
// the verb finds USB_P/USB_N by name when no pairs are given
let r = net_lengths(&json!({"board": board(), "args": {}}));
assert_eq!((r["pairs"][0]["name"].clone(), r["pairs"][0]["skewMm"].clone()), (json!("USB"), json!(3.0)), "{r}");
}
}