app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← All Pull Requests
AI Flow 0.2.73: neck-down at the pins, length tuning, grounded coplanar lines #92
Merges
aiflow-0.2.73 → mainAI Flow 0.2.73: neck-down at the pins, length tuning, grounded coplanar lines (#52).
- neckdown.rs: impedance nets with pins narrower than the target also try the rule width;
widen_planwidens them back to the target where the router's clearance check passes;tune_pairsadds 45-degree bumps to a pair's shorter net until the skew is within maxSkewMm (narrow necks first; on the coupled run at the rule width with the wide copper split around it). ImpedanceRoute gains max_skew. - impedance.rs / impedance_cmd.rs: grounded coplanar line and pair (coplanarGapMm, spec coplanarGround), validated against the 2-D field solve (+0.6 to +4.1 %, 365 cross-sections in tests/fixtures), the table's coplanar suggestion when a width misses the pins, and analyze measuring the ground pour (or saying it is assumed).
- Docs, skill and release notes.
Tests: 715 workspace tests (new: neck-down routing on a fine-pitch pair, tuning, bump gain; 7 coplanar), impedance/route workflow suites and tool tests pass; preflight clean. Boards without impedance targets are untouched.
🤖 Generated with Claude Code
Diff Skip to comments
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.72"+version = "0.2.73" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.72"+version = "0.2.73" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.72"+version = "0.2.73" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.72"+version = "0.2.73" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.72"+version = "0.2.73" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.72"+version = "0.2.73" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.72"+version = "0.2.73" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.72"+version = "0.2.73" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.72"+version = "0.2.73" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.72"+version = "0.2.73" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.72"+version = "0.2.73" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+1−1@@ -75,7 +75,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- ### High-speed boards: your own router, inside the flow -Impedance targets go in the spec before routing: `"impedanceTargets": {"USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90}, "RF": {"net": "RF_OUT", "targetOhms": 50}}` (`tolerancePercent` 10, `gapMm`, `widthMm`, `layer`, `maxSkewMm` 0.5, `maxUncontrolledMm` 3 optional), with the stackup from spec `stackupMm` and `epsilonR` or the board's saved physical stackup. `impedance table` prints the width (and pair gap) per copper layer from closed-form formulas (Hammerstad-Jensen, Wheeler, Cohn, Garg-Bahl; within 0.2 to 3.4 % of a 2-D field solve, no solder mask) and refuses a layer the target cannot reach within the fab minimums. `route` then sizes those nets, holds each to one outer layer and routes a pair's second net along the first at the solved pitch; it prints the coupling it achieved and names any pair it could not keep together for `take route=ai`. `analyze impedance` judges the landed copper (and a same-input Fields Signals or `--with si` solve beside it); `finish` requires it. docs/impedance.md has the formulas, their measured accuracy and what the router really does.+Impedance targets go in the spec before routing: `"impedanceTargets": {"USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90}, "RF": {"net": "RF_OUT", "targetOhms": 50}}` (`tolerancePercent` 10, `gapMm`, `widthMm`, `layer`, `maxSkewMm` 0.5, `maxUncontrolledMm` 3 optional), with the stackup from spec `stackupMm` and `epsilonR` or the board's saved physical stackup. `impedance table` prints the width (and pair gap) per copper layer from closed-form formulas (Hammerstad-Jensen, Wheeler, Cohn, Garg-Bahl; within 0.2 to 3.4 % of a 2-D field solve, no solder mask) and refuses a layer the target cannot reach within the fab minimums. On a two-layer board, where a microstrip is wide (90 ohm over 1.51 mm FR4 is 1.38 mm), a ground pour on the same layer beside the line (`coplanarGapMm` on the target, or spec `"coplanarGround": {"net": "GND", "gapMm": 0.2}`) makes the outer rows grounded coplanar, a few tenths of a millimetre wide; the table suggests it when a microstrip width misses the pins, the pours stage must pour it at that clearance, and `analyze impedance` measures it once filled (and says when it is only assumed). `route` then sizes those nets, holds each to one outer layer and routes a pair's second net along the first at the solved pitch; it prints the coupling it achieved and names any pair it could not keep together for `take route=ai`. `analyze impedance` judges the landed copper (and a same-input Fields Signals or `--with si` solve beside it); `finish` requires it. docs/impedance.md has the formulas, their measured accuracy and what the router really does. A simple pair (one USB 2 pair, a 50 ohm feed) can go through `route` this way. A board with several pairs, length-matched lane groups or layer changes (PCIe, OCuLink, USB 3, Ethernet), or a pair `route` flagged, is beyond the grid router, so you route it yourself, but the work still happens inside the flow. Declare each step as you reach it (`step rules`, `step components`, ... `step routing`); your own schematic, placement or router scripts do the step's work, they do not replace the step. For routing:
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/impedance_cmd.rs+151−20@@ -69,7 +69,8 @@ pub fn table_lines(p: &Plan) -> Vec<String> { for r in &p.rows { let t = r["target"].as_str().unwrap_or(""); let what = if r["differential"] == true { format!("{} ohm diff", r["targetOhms"]) } else { format!("{} ohm", r["targetOhms"]) };- let geo = match &r["dielectricMm"] { Value::Array(a) => format!("{} between {} / {} mm", r["structure"].as_str().unwrap_or(""), a[0], a[1]), h => format!("{} over {h} mm", r["structure"].as_str().unwrap_or("")) };+ let mut geo = match &r["dielectricMm"] { Value::Array(a) => format!("{} between {} / {} mm", r["structure"].as_str().unwrap_or(""), a[0], a[1]), h => format!("{} over {h} mm", r["structure"].as_str().unwrap_or("")) };+ if !r["coplanarGapMm"].is_null() { geo.push_str(&format!(" with a {} pour {} mm beside it (assumed: pour it at that clearance, stitched to the plane)", r["coplanarNet"].as_str().unwrap_or("GND"), r["coplanarGapMm"])); } let size = if r["widthMm"].is_null() { String::new() } else if r["gapMm"].is_null() { format!("width {} mm", r["widthMm"]) } else { format!("width {} mm gap {} mm", r["widthMm"], r["gapMm"]) }; let state = if r["ok"] == true { format!("{size} -> {} ohm", r["ohms"]) } else { format!("REFUSED: {}", r["why"].as_str().unwrap_or("")) }; out.push(format!("{t} {what} on {} ({geo}, er {}, copper {} um): {state}", r["layer"].as_str().unwrap_or(""), r["epsilonR"], r["copperUm"]));@@ -106,7 +107,7 @@ pub fn for_route(board: &Board, spec: &Value, board_path: &str, rules: &mut aifl let layer = r["layer"].as_str().unwrap_or("F.Cu").to_string(); let w = r["widthMm"].as_f64().unwrap_or(0.25); for (k, n) in t.nets.iter().enumerate() {- rules.impedance.insert(n.clone(), aiflow_router::rules::ImpedanceRoute { target: t.name.clone(), width: w, layer: layer.clone(), partner: if t.diff { Some(t.nets[1 - k].clone()) } else { None }, pitch: if t.diff { r["widthMm"].as_f64().zip(r["gapMm"].as_f64()).map(|(a, b)| a + b) } else { None } });+ rules.impedance.insert(n.clone(), aiflow_router::rules::ImpedanceRoute { target: t.name.clone(), width: w, layer: layer.clone(), max_skew: t.max_skew, partner: if t.diff { Some(t.nets[1 - k].clone()) } else { None }, pitch: if t.diff { r["widthMm"].as_f64().zip(r["gapMm"].as_f64()).map(|(a, b)| a + b) } else { None } }); } chosen.push(r.clone()); }@@ -138,7 +139,7 @@ fn copper(board: &Board, plan: Option<&Value>, targets: &[Target]) -> (Vec<aiflo pub fn check(board: &Board, spec: &Value, board_path: &str, plan: Option<&Value>) -> Result<Option<(Plan, Vec<Value>)>, String> { let Some(p) = self::plan(board, spec, board_path)? else { return Ok(None) }; let (tracks, vias, from) = copper(board, plan, &p.targets);- let rows = p.targets.iter().map(|t| { let mut c = imp::check_target(t, &p.stack, &tracks, &vias, &board.pads); c["copperFrom"] = json!(t.nets.iter().map(|n| from.get(n).copied().unwrap_or("")).collect::<Vec<_>>()); c }).collect();+ let rows = p.targets.iter().map(|t| { let ground = t.coplanar.as_ref().map(|c| imp::GroundCopper::of(board, &c.net)).unwrap_or_default(); let mut c = imp::check_target(t, &p.stack, &tracks, &vias, &board.pads, &ground); c["copperFrom"] = json!(t.nets.iter().map(|n| from.get(n).copied().unwrap_or("")).collect::<Vec<_>>()); c }).collect(); Ok(Some((p, rows))) } @@ -165,6 +166,8 @@ pub fn compare_fields(rows: &mut [Value], targets: &[Target], fields: &Value) -> let tol = t.ohms * t.tolerance / 100.0; let mut f = serde_json::Map::new(); let mut fail: Vec<String> = Vec::new();+ // a coplanar target: Fields' Signals and its cross-section model a microstrip without the pour+ let compared = t.coplanar.is_none(); if t.diff { let sig = fields["signals"]["differentialPairs"].as_array().into_iter().flatten().find(|p| { let d = &p["definition"];@@ -174,7 +177,7 @@ pub fn compare_fields(rows: &mut [Value], targets: &[Target], fields: &Value) -> f.insert("signals".into(), json!({"name": s["name"], "status": s["status"], "differentialOhms": s["differentialOhms"], "gapMm": s["gapMm"], "skewMm": s["skewMm"], "warnings": s["warnings"]})); match s["differentialOhms"].as_array().and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))) { Some((lo, hi)) => {- if (lo - t.ohms).abs() > tol || (hi - t.ohms).abs() > tol { fail.push(format!("Fields Signals estimates {lo:.1} to {hi:.1} ohm differential, outside {} +-{} %", t.ohms, t.tolerance)); }+ if compared && ((lo - t.ohms).abs() > tol || (hi - t.ohms).abs() > tol) { fail.push(format!("Fields Signals estimates {lo:.1} to {hi:.1} ohm differential, outside {} +-{} %", t.ohms, t.tolerance)); } notes.push(format!("{}: closed form {} ohm, Fields Signals {lo:.1} to {hi:.1} ohm ({})", t.label(), row["ohms"], s["status"].as_str().unwrap_or(""))); } None => notes.push(format!("{}: Fields Signals did not estimate it ({}): {}", t.label(), s["status"].as_str().unwrap_or(""), s["warnings"].as_array().map(|w| w.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("; ")).unwrap_or_default())),@@ -187,7 +190,7 @@ pub fn compare_fields(rows: &mut [Value], targets: &[Target], fields: &Value) -> if let Some(x) = xs { f.insert("crossSection".into(), json!({"zdiff": x["zdiff"], "widthMm": x["widthMm"], "gapMm": x["gapMm"], "layer": x["layer"]})); if let Some(z) = x["zdiff"].as_f64() {- if (z - t.ohms).abs() > tol { fail.push(format!("Fields cross-section solve gives {z:.1} ohm differential, outside {} +-{} %", t.ohms, t.tolerance)); }+ if compared && (z - t.ohms).abs() > tol { fail.push(format!("Fields cross-section solve gives {z:.1} ohm differential, outside {} +-{} %", t.ohms, t.tolerance)); } notes.push(format!("{}: closed form {} ohm, Fields 2-D solve {z:.1} ohm (with solder mask)", t.label(), row["ohms"])); } }@@ -197,11 +200,15 @@ pub fn compare_fields(rows: &mut [Value], targets: &[Target], fields: &Value) -> // Fields lists every width the net has (escape stubs too): judge the longest segment group if let Some(main) = n["segments"].as_array().into_iter().flatten().max_by(|a, b| a["lengthMm"].as_f64().unwrap_or(0.0).total_cmp(&b["lengthMm"].as_f64().unwrap_or(0.0))) { let z0 = main["z0"].as_f64().unwrap_or(0.0);- if (z0 - t.ohms).abs() > tol { fail.push(format!("Fields cross-section solve gives {z0:.1} ohm on its longest run, outside {} +-{} %", t.ohms, t.tolerance)); }+ if compared && (z0 - t.ohms).abs() > tol { fail.push(format!("Fields cross-section solve gives {z0:.1} ohm on its longest run, outside {} +-{} %", t.ohms, t.tolerance)); } } notes.push(format!("{}: closed form {} ohm, Fields 2-D solve {:?} ohm", t.label(), row["ohms"], z)); } if f.is_empty() { continue; }+ if !compared {+ f.insert("notCompared".into(), json!("Fields models this line as microstrip without the same-layer pour"));+ notes.push(format!("{}: Fields' values model a microstrip without the {} pour beside it, so they are reported, not compared", t.label(), t.coplanar.as_ref().map(|c| c.net.as_str()).unwrap_or("GND")));+ } row["fields"] = Value::Object(f); if !fail.is_empty() { row["status"] = json!("fail");@@ -232,8 +239,14 @@ pub fn check_lines(rows: &[Value], targets: &[Target]) -> Vec<String> { let mut s = format!("{} {}: {} ohm on {}", t.label(), c["status"].as_str().unwrap_or(""), c["ohms"], c["lengthByLayerMm"]); if t.diff { s.push_str(&format!(", gap {} mm, coupled {}% of each net, skew {} mm", c["gapMm"], c["coupledPercent"], c["skewMm"])); } s.push_str(&format!(", uncontrolled {} mm (copper from the {})", c["uncontrolledMm"], c["copperFrom"].as_array().and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("board")));+ if let Some(cp) = c.get("coplanar") {+ let on = |k: &str| cp[k].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default();+ if !on("measuredOn").is_empty() { s.push_str(&format!(", {} pour beside it measured on {} at {} mm (target gap {} mm; {} mm of each net with none within reach)", cp["net"].as_str().unwrap_or(""), on("measuredOn"), cp["measuredGapMm"], cp["gapMm"], cp["withoutGroundMm"])); }+ if !on("assumedOn").is_empty() { s.push_str(&format!(", {} pour {} mm beside it ASSUMED on {}, not measured (no filled {} zone there yet)", cp["net"].as_str().unwrap_or(""), cp["gapMm"], on("assumedOn"), cp["net"].as_str().unwrap_or(""))); }+ } let why: Vec<&str> = c["reasons"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect(); if !why.is_empty() { s.push_str(&format!(": {}", why.join("; "))); }+ if let Some(n) = c["coplanar"]["todo"].as_str() { s.push_str(&format!(". {n}")); } s }).collect() }@@ -248,11 +261,14 @@ pub fn after_route(choice: &Option<Value>, board: &Board, spec: &Value, board_pa let ch = choice["routed"].as_array().into_iter().flatten().find(|x| x["target"] == r.name.as_str()).cloned().unwrap_or(Value::Null); let size = if r.diff { format!("width {} mm, gap {} mm (pitch {} mm)", ch["widthMm"], ch["gapMm"], ch["pitchMm"]) } else { format!("width {} mm", ch["widthMm"]) }; if c["status"] == "pass" {- lines.push(format!("impedance {}: routed on {} at {size}; the plan's copper gives {} ohm{}", r.label(), ch["layer"].as_str().unwrap_or(""), c["ohms"], if r.diff { format!(", coupled {}% of each net, skew {} mm", c["coupledPercent"], c["skewMm"]) } else { String::new() }));+ let pour = r.coplanar.as_ref().map(|cp| format!("; sized for a {} pour {} mm beside it, which is assumed until it is poured (pour it at that clearance, stitched to the plane, and run analyze impedance)", cp.net, cp.gap)).unwrap_or_default();+ lines.push(format!("impedance {}: routed on {} at {size}; the plan's copper gives {} ohm{}{pour}", r.label(), ch["layer"].as_str().unwrap_or(""), c["ohms"], if r.diff { format!(", coupled {}% of each net, skew {} mm", c["coupledPercent"], c["skewMm"]) } else { String::new() })); } else { let why: Vec<&str> = c["reasons"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect(); // issue #52: say it when the width is what kept the pair off its pins- if let Some(w) = ch["widthMm"].as_f64().and_then(|w| pin_reach(board, r, w)) { lines.push(format!("WARNING impedance {w}")); continue; }+ if why.iter().any(|x| x.starts_with("no copper on")) {+ if let Some(w) = ch["widthMm"].as_f64().filter(|w| pin_reach(board, r, *w, None).is_some()).and_then(|w| pin_reach(board, r, w, coplanar_suggestion(&p, r, board, spec).as_deref())) { lines.push(format!("WARNING impedance {w}")); continue; }+ } lines.push(format!("WARNING impedance {}: routed on {} at {size}, but the plan's copper misses the target: {}. {}", r.label(), ch["layer"].as_str().unwrap_or(""), why.join("; "), if r.diff { "The grid router routes the two nets one after the other (the second follows the first at the solved pitch where it can); here it could not keep them together. Route this pair yourself: `adom-aiflow take route=ai` (or move what splits it), and `analyze impedance` will judge the copper you land." } else { "Route it yourself (`take route=ai`) or move what forced the detour." })); }@@ -265,17 +281,78 @@ pub fn after_route(choice: &Option<Value>, board: &Board, spec: &Value, board_pa /// Issue #52 (a real board, StickHub: eight USB pairs, 2 layers, 1.6 mm): the width a target solves to can be far /// wider than the pins it has to reach (90 ohm over 1.51 mm needs 1.38 mm; the hub's pins are 0.3 mm), and then the /// grid router lands no copper on the pair at all. Names the narrowest pin and what would work, or None when it fits.-pub fn pin_reach(board: &Board, t: &Target, width: f64) -> Option<String> {- let pads: Vec<&aiflow_board::Pad> = board.pads.iter().filter(|p| t.nets.iter().any(|n| *n == p.net)).collect();- let narrow = pads.iter().min_by(|a, b| a.min_dim().total_cmp(&b.min_dim()))?;+pub fn pin_reach(board: &Board, t: &Target, width: f64, coplanar: Option<&str>) -> Option<String> {+ let narrow = narrowest_pin(board, t)?; if width <= narrow.min_dim() + 0.1 { return None; }- Some(format!("{}: the solved width {width:.2} mm is wider than the pins it must reach ({} pad {}: {:.2} mm), so the grid router cannot enter them at that width and may land no copper on it. What works: neck down to the pin over at most maxUncontrolledMm ({:.1} mm) at each end (route it yourself: `take route=ai`); a thinner dielectric under the outer layer (a 4-layer stackup's prepreg, about 0.2 mm, solves to a few tenths of a millimetre: put it in stackupMm and run the table again); or a ground pour beside the pair (coplanar), which these formulas do not model", t.label(), narrow.reference, narrow.name, narrow.min_dim(), t.max_uncontrolled))+ let pour = match (coplanar, &t.coplanar) {+ (_, Some(c)) => format!("or a smaller gap to the {} pour than {} mm, if the fab allows (it narrows the line)", c.net, c.gap),+ (Some(s), None) => format!("or a ground pour beside the {} (coplanar): {s}", if t.diff { "pair" } else { "trace" }),+ (None, None) => format!("or a ground pour beside the {} (coplanar: set coplanarGapMm on the target and run the table again)", if t.diff { "pair" } else { "trace" }),+ };+ Some(format!("{}: the solved width {width:.2} mm is wider than the pins it must reach ({} pad {}: {:.2} mm), so the grid router cannot enter them at that width and may land no copper on it. What works: `route` necks it down at the pins and widens it between where it clears (raise maxUncontrolledMm, {:.1} mm now, to cover the fan-out); a thinner dielectric under the outer layer (a 4-layer stackup's prepreg, about 0.2 mm, solves to a few tenths of a millimetre: put it in stackupMm and run the table again); {pour}", t.label(), narrow.reference, narrow.name, narrow.min_dim(), t.max_uncontrolled))+}++/// The narrowest pad of a target's nets (the pin its line must enter).+fn narrowest_pin<'a>(board: &'a Board, t: &Target) -> Option<&'a aiflow_board::Pad> {+ board.pads.iter().filter(|p| t.nets.iter().any(|n| *n == p.net)).min_by(|a, b| a.min_dim().total_cmp(&b.min_dim()))+}++/// Issue #52: for a target without a coplanar key whose microstrip width misses the pins or is+/// refused, the grounded coplanar line the same layer would take with a ground pour beside it at the+/// smallest gap the board's clearance allows (rounded up to 0.01 mm), sized the way `route` would+/// size it (a pair on the grid router's pitch), and the narrowest an AI router could take off that+/// pitch. Says whether it enters the narrowest pin and, if not, the smaller clearance at which it+/// would (a question for the fab), or why no coplanar row reaches it either. None when the target+/// already is coplanar or its layer is not an outer one.+pub fn coplanar_suggestion(p: &Plan, t: &Target, board: &Board, spec: &Value) -> Option<String> {+ if t.coplanar.is_some() { return None; }+ let smd: Vec<&aiflow_board::Pad> = board.pads.iter().filter(|x| t.nets.contains(&x.net) && !x.thru).collect();+ let layer = if let Some(l) = &t.layer { l.clone() } else if !smd.is_empty() && smd.iter().all(|x| x.on_b && !x.on_f) { "B.Cu".into() } else { "F.Cu".into() };+ let g = p.stack.layers.iter().find(|g| g.name == layer && g.h2.is_none())?;+ let gap = (p.fab.min_space * 100.0 - 1e-6).ceil() / 100.0;+ let net = g.reference.first().map(|r| reference_net(spec, r)).unwrap_or_else(|| "GND".into());+ let ct = Target { coplanar: Some(imp::Coplanar { net: net.clone(), gap, source: "suggested".into() }), ..t.clone() };+ let rules = aiflow_router::Rules::from_spec(spec);+ let grid = grid_of(&rules, &t.nets);+ let row = imp::solve_row(&ct, g, &p.fab, Some(&grid));+ let key = format!("coplanarGapMm {gap} on the target, or spec coplanarGround {{\"net\": \"{net}\", \"gapMm\": {gap}}}");+ if row["ok"] != true {+ return Some(format!("a {net} pour {gap} mm beside it on {layer} (the board clearance) does not reach it either: {}", row["why"].as_str().unwrap_or("")));+ }+ let w = row["widthMm"].as_f64()?;+ let size = |r: &Value| if t.diff { format!("width {} mm, gap {} mm", r["widthMm"], r["gapMm"]) } else { format!("width {} mm", r["widthMm"]) };+ let what = if t.diff { "pair" } else { "trace" };+ let mut out = format!("with a {net} pour {gap} mm beside the {what} on {layer} ({key}; stitch it to {} with vias): {} -> {} ohm", g.reference.join(" and "), size(&row), row["ohms"]);+ // an AI router may sit a pair off the grid pitch, at the narrowest gap the fab allows+ let free = if t.diff { Some(imp::solve_row(&ct, g, &p.fab, None)).filter(|r| r["ok"] == true && r["widthMm"].as_f64().map(|x| x < w - 1e-4).unwrap_or(false)) } else { None };+ if let Some(r) = &free { out.push_str(&format!(" on the grid router's pitch, or {} off it (an AI router: `take route=ai`)", size(r))); }+ let Some(pin) = narrowest_pin(board, t) else { return Some(out) };+ let fits = |r: &Value| r["widthMm"].as_f64().map(|x| x <= pin.min_dim() + 0.1).unwrap_or(false);+ if fits(&row) { out.push_str(&format!(", which enters the {:.2} mm pins", pin.min_dim())); return Some(out); }+ if free.as_ref().map(|r| fits(r)).unwrap_or(false) { out.push_str(&format!("; off the grid pitch it enters the {:.2} mm pins", pin.min_dim())); return Some(out); }+ // the largest smaller clearance (pour gap and pair gap alike) at which it would enter them, down to+ // the .kicad_dru clearance, else 0.1 mm, said as a question for the fab+ let dru = std::fs::read_to_string(Path::new(&board.path).with_extension("kicad_dru")).ok().and_then(|t| aiflow_bridge::eda::fab_numbers(&t)["clearance"].as_f64());+ let floor = dru.unwrap_or(0.1);+ let mut c = gap - 0.01;+ while c + 1e-9 >= floor {+ let fab = Fab { min_space: c, ..p.fab.clone() };+ let ct2 = Target { coplanar: Some(imp::Coplanar { net: net.clone(), gap: c, source: "suggested".into() }), ..t.clone() };+ let r = imp::solve_row(&ct2, g, &fab, None);+ if r["ok"] == true && fits(&r) {+ out.push_str(&format!("; still wider than the {:.2} mm pins: neck down at the pins over at most maxUncontrolledMm, or at a {c:.2} mm clearance for these nets and the pour, if the fab allows: {} -> {} ohm (an AI router)", pin.min_dim(), size(&r), r["ohms"]));+ return Some(out);+ }+ c = ((c - 0.01) * 100.0).round() / 100.0;+ }+ out.push_str(&format!("; still wider than the {:.2} mm pins, down to a {floor:.2} mm clearance: neck down at the pins over at most maxUncontrolledMm", pin.min_dim()));+ Some(out) } type Reply = Result<(String, Vec<String>), (String, Vec<String>)>; fn signals_hint(p: &Plan, rows: &[Value], spec: &Value) -> Option<String> {- let entries: Vec<Value> = p.targets.iter().zip(rows).filter(|(t, _)| t.diff).filter_map(|(t, c)| {+ let entries: Vec<Value> = p.targets.iter().zip(rows).filter(|(t, _)| t.diff && t.coplanar.is_none()).filter_map(|(t, c)| { let layers = c["lengthByLayerMm"].as_object()?; if layers.len() != 1 { return None; } signals_entry(t, &p.stack, layers.keys().next()?, spec)@@ -310,6 +387,7 @@ pub fn analyze_run(r: &mut aiflow_run::Run, dir: &Path, spec: &Value, board_file let mut lines = check_lines(&rows, &p.targets); lines.extend(notes); let mut hints = vec![format!("fields: {field_note}"), METHOD.to_string()];+ if p.targets.iter().any(|t| t.coplanar.is_some()) { hints.push(METHOD_COPLANAR.into()); } if let Some(h) = signals_hint(&p, &rows, spec) { hints.push(h); } if pass { Ok((format!("impedance analysis passed:\n{}", lines.join("\n")), hints)) } else { hints.insert(0, "A pair the grid router could not keep coupled is routed by you: `take route=ai`, route it (same layer, parallel at the gap `impedance table` gives, matched length), land it, analyze impedance again. A width that misses: widen or narrow it to the table's width. Never move the target or the stackup to meet the copper.".into());@@ -334,6 +412,8 @@ pub fn finish_check(board: &Board, spec: &Value, board_file: &str, outcome: Opti pub const METHOD: &str = "Closed forms (Hammerstad-Jensen microstrip, Wheeler and Cohn stripline, Garg-Bahl coupled microstrip), quasi-static, no solder mask (it lowers an outer trace by about 1 to 3 %), no etch trapezoid or Dk tolerance; within 0.2 % (microstrip, stripline), 3.4 % (offset stripline) and 2.6 % (pairs) of a 2-D field solve on the reference cases (docs/impedance.md). A screen for routing, not an impedance-controlled fab order: the fab's own stackup calculation is."; +pub const METHOD_COPLANAR: &str = "Grounded coplanar rows (coplanarGapMm, coplanarGround): the conformal mapping of Ghione and Naldi (Wadell 3.4.3), the pair by the same maps in odd and even mode, with the copper sidewalls' parallel-plate term e0 t / g; they read 0.6 to 4.1 % high against a 2-D field solve over g/h <= 1, s/h <= 2 and w >= 4 copper thicknesses (refused outside it; docs/impedance.md). They assume the pour runs beside the whole line at that gap and is stitched to the plane under it with vias; KiCad pours at the larger of the zone's clearance (spec pours[].clearance, default 0.2 mm) and the nets' own, so set it to the gap. `impedance check` measures the gap where the ground net has a filled zone on the layer and says when it only assumes it. Adom Fields models these lines as microstrip without the pour, so its values are reported, not compared.";+ /// `adom-aiflow impedance table|check`: on a run (its current board and spec) or on --board and --spec. pub fn command(action: &str, board_file: &str, spec: &Value, plan_path: Option<&str>, as_json: bool) -> Reply { let board = Board::load(board_file).map_err(|e| (e, vec![]))?;@@ -347,22 +427,28 @@ pub fn command(action: &str, board_file: &str, spec: &Value, plan_path: Option<& match router_layer(&p, t, &board) { Ok(r) => { route_lines.push(format!("route sizes {} on {}: width {} mm{}", t.label(), r["layer"].as_str().unwrap_or(""), r["widthMm"], if t.diff { format!(", gap {} mm", r["gapMm"]) } else { String::new() }));- if let Some(w) = r["widthMm"].as_f64().and_then(|w| pin_reach(&board, t, w)) { route_lines.push(format!("FINDING {w}")); }+ if let Some(w) = r["widthMm"].as_f64().filter(|w| pin_reach(&board, t, *w, None).is_some()).and_then(|w| pin_reach(&board, t, w, coplanar_suggestion(&p, t, &board, spec).as_deref())) { route_lines.push(format!("FINDING {w}")); }+ }+ Err(e) => {+ route_lines.push(format!("route refuses: {e}"));+ if let Some(s) = coplanar_suggestion(&p, t, &board, spec) { route_lines.push(format!("SUGGESTION {}: {s}", t.label())); } }- Err(e) => route_lines.push(format!("route refuses: {e}")), } } lines.extend(route_lines); let refused = p.rows.iter().filter(|r| r["ok"] != true).count();- Ok((format!("impedance table: {} target(s) on {} copper layer(s), {refused} row(s) refused (stackup: {}; fab: {})\n{}", p.targets.len(), p.stack.layers.len(), p.stack.sources.iter().map(|(k, v)| format!("{k} from {v}")).collect::<Vec<_>>().join(", "), p.fab.source, lines.join("\n")),- vec!["Outer-layer pair rows sit on the grid router's 0.1 mm pitch so `route` can follow them; an AI router may use any width and gap the formulas give (set gapMm or widthMm on the target to solve the other).".into(), METHOD.into()]))+ let mut hints: Vec<String> = vec!["Outer-layer pair rows sit on the grid router's 0.1 mm pitch so `route` can follow them; an AI router may use any width and gap the formulas give (set gapMm or widthMm on the target to solve the other).".into(), METHOD.into()];+ if p.targets.iter().any(|t| t.coplanar.is_some()) { hints.push(METHOD_COPLANAR.into()); }+ Ok((format!("impedance table: {} target(s) on {} copper layer(s), {refused} row(s) refused (stackup: {}; fab: {})\n{}", p.targets.len(), p.stack.layers.len(), p.stack.sources.iter().map(|(k, v)| format!("{k} from {v}")).collect::<Vec<_>>().join(", "), p.fab.source, lines.join("\n")), hints)) } "check" => { let plan = plan_path.map(|p| std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).ok_or_else(|| (format!("{p}: not a readable plan"), vec![]))).transpose()?; let (p, rows) = match check(&board, spec, board_file, plan.as_ref()) { Ok(Some(x)) => x, Ok(None) => return Err(("the spec names no impedance target".into(), vec![HINT_SPEC.into()])), Err(e) => return Err((format!("impedance: {e}"), vec![HINT_SPEC.into()])) }; if as_json { println!("{}", serde_json::to_string_pretty(&json!({"results": rows, "method": METHOD})).unwrap()); } let lines = check_lines(&rows, &p.targets);- if rows.iter().all(|c| c["status"] == "pass") { Ok((format!("impedance check passed:\n{}", lines.join("\n")), vec![METHOD.into()])) } else { Err((format!("impedance check not passed:\n{}", lines.join("\n")), vec![METHOD.into()])) }+ let mut hints: Vec<String> = vec![METHOD.into()];+ if p.targets.iter().any(|t| t.coplanar.is_some()) { hints.push(METHOD_COPLANAR.into()); }+ if rows.iter().all(|c| c["status"] == "pass") { Ok((format!("impedance check passed:\n{}", lines.join("\n")), hints)) } else { Err((format!("impedance check not passed:\n{}", lines.join("\n")), hints)) } } other => Err((format!("impedance {other}: use table or check"), vec![])), }@@ -384,9 +470,54 @@ mod tests { (footprint "QFN" (layer "F.Cu") (at 10 10) (property "Reference" "U1" (at 0 0 0) (layer "F.SilkS")) (pad "10" smd rect (at 0 0) (size 0.3 0.8) (layers "F.Cu") (net 1 "DP")) (pad "11" smd rect (at 0.5 0) (size 0.3 0.8) (layers "F.Cu") (net 2 "DN"))))"#.to_string()).unwrap(); let ts = imp::targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();- let w = pin_reach(&b, &ts[0], 1.38).unwrap();+ let w = pin_reach(&b, &ts[0], 1.38, None).unwrap(); assert!(w.contains("1.38 mm is wider than the pins it must reach (U1 pad 10: 0.30 mm)") && w.contains("maxUncontrolledMm") && w.contains("4-layer"), "{w}");- assert!(pin_reach(&b, &ts[0], 0.35).is_none(), "a width that fits the pins says nothing");+ assert!(pin_reach(&b, &ts[0], 0.35, None).is_none(), "a width that fits the pins says nothing");+ }++ /// Issue #52: when the microstrip width misses the pins, the table names the grounded coplanar line+ /// the pins can take: StickHub's 2-layer 1.51 mm board, 0.3 mm hub pins.+ #[test]+ fn a_coplanar_suggestion_names_the_pour_and_the_width() {+ let b = Board::from_text("t.kicad_pcb", r#"(kicad_pcb (version 20240108) (generator "t")+ (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))+ (net 0 "") (net 1 "DP") (net 2 "DN")+ (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts") (stroke (width 0.1) (type default)))+ (footprint "QFN" (layer "F.Cu") (at 10 10) (property "Reference" "U1" (at 0 0 0) (layer "F.SilkS"))+ (pad "10" smd rect (at 0 0) (size 0.3 0.8) (layers "F.Cu") (net 1 "DP")) (pad "11" smd rect (at 0.5 0) (size 0.3 0.8) (layers "F.Cu") (net 2 "DN"))))"#.to_string()).unwrap();+ let spec = json!({"stackupMm": [1.51], "epsilonR": 4.5, "impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}});+ let p = plan(&b, &spec, "t.kicad_pcb").unwrap().unwrap();+ let t = &p.targets[0];+ // the board clearance (the router's 0.2 mm): the pour at 0.2 mm, too wide for the pins even off the+ // grid pitch, so it names the clearance that would fit (0.15 mm, a question for the fab)+ let s = coplanar_suggestion(&p, t, &b, &spec).unwrap();+ assert!(s.contains("with a GND pour 0.2 mm beside the pair on F.Cu") && s.contains("coplanarGapMm 0.2") && s.contains("stitch it to B.Cu"), "{s}");+ assert!(s.contains("width 0.546 mm, gap 0.2 mm off it") && s.contains("at a 0.15 mm clearance") && s.contains("width 0.379 mm"), "{s}");+ let w = pin_reach(&b, t, 1.38, Some(&s)).unwrap();+ assert!(w.contains("or a ground pour beside the pair (coplanar): with a GND pour 0.2 mm") && !w.contains("do not model"), "{w}");+ // at a 0.15 mm board clearance the pins take it straight away+ let spec15 = json!({"clearance": 0.15, "stackupMm": [1.51], "epsilonR": 4.5, "impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}});+ let p15 = plan(&b, &spec15, "t.kicad_pcb").unwrap().unwrap();+ let s = coplanar_suggestion(&p15, &p15.targets[0], &b, &spec15).unwrap();+ assert!(s.contains("GND pour 0.15 mm") && s.contains("enters the 0.30 mm pins"), "{s}");+ // a target that already is coplanar gets none, and its pin finding points at the pour gap+ let specc = json!({"stackupMm": [1.51], "epsilonR": 4.5, "coplanarGround": {"net": "GND", "gapMm": 0.2}, "impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}});+ let pc = plan(&b, &specc, "t.kicad_pcb").unwrap().unwrap();+ assert!(coplanar_suggestion(&pc, &pc.targets[0], &b, &specc).is_none());+ assert!(pin_reach(&b, &pc.targets[0], 0.85, None).unwrap().contains("smaller gap to the GND pour than 0.2 mm"));+ // its table lines say the pour is assumed+ let lines = table_lines(&pc);+ assert!(lines[0].contains("grounded coplanar over 1.51 mm with a GND pour 0.2 mm beside it (assumed"), "{lines:?}");+ }++ #[test]+ fn fields_values_are_reported_not_compared_for_a_coplanar_target() {+ let ts = imp::targets(&json!({"coplanarGround": {"net": "GND", "gapMm": 0.2}, "impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();+ let mut rows = vec![json!({"status": "pass", "ohms": [89.0, 91.0], "reasons": []})];+ let fields = json!({"signals": {"differentialPairs": [{"name": "USB", "definition": {"positive": "DP", "negative": "DN"}, "status": "screen-outside-target", "differentialOhms": [120.0, 121.0], "warnings": []}]}});+ let notes = compare_fields(&mut rows, &ts, &fields);+ assert_eq!(rows[0]["status"], "pass");+ assert!(notes.iter().any(|n| n.contains("reported, not compared")), "{notes:?}"); } #[test]
crates/adom-aiflow/src/route_variants.rs+6@@ -39,6 +39,12 @@ pub fn route_once(b: Board, rules: &Rules, levers: &Levers, rip_per_net: usize, // issue #39: staircases to clean 45 degree runs before the gate (the router's own clearance check decides) let simp = aiflow_router::simplify::simplify_plan(&mut router, &mut plan); report["simplify"] = simp.clone();+ // issue #52: impedance nets laid at the rule width (pins narrower than the target) are widened where they clear+ if !rules.impedance.is_empty() {+ report["neckdown"] = aiflow_router::neckdown::widen_plan(&mut router, &mut plan);+ // and pairs tuned to their maxSkewMm+ report["lengthTuning"] = aiflow_router::neckdown::tune_pairs(&mut router, &mut plan);+ } let bc = report["boardCopper"].clone(); // filled zone islands count: a pour alone can join a net's pads (issue #84) let has_copper = bc["trackPieces"].as_u64().unwrap_or(0) + bc["vias"].as_u64().unwrap_or(0) + bc["zoneIslands"].as_u64().unwrap_or(0) > 0;
crates/aiflow-router/src/impedance.rs+487−33@@ -21,6 +21,12 @@ //! Garg and Bahl state about 3 % over 0.2 <= w/h <= 2 and 0.05 <= s/h <= 2; against the field //! solve the error passes 4 % below s/h 0.2, so this module uses 0.2 <= s/h <= 2 and refuses //! outside it rather than extrapolate.+//! - Grounded coplanar waveguide (an outer layer with a same-layer ground pour at gap g, stitched to+//! the plane under it; issue #52, two-layer boards): Ghione and Naldi's conformal mapping of the+//! conductor-backed CPW (IEEE Trans. MTT 35, 1987; Wadell sec. 3.4.3), the edge-coupled pair by the+//! same maps on its odd and even half structures, with the sidewalls' parallel-plate terms e0 t / g+//! (and 2 e0 t / s in odd mode). 0.6 to 4.1 % high against the field solve over g/h <= 1,+//! s/h <= 2, w >= 4 t (365 cross-sections, tests/fixtures/coplanar-reference.jsonl); refused outside. //! //! Measured against an independent 2-D finite-volume field solve (tools/impedance_reference.py, //! the same quasi-static problem, no solder mask) on the reference cases in the tests below:@@ -191,6 +197,79 @@ pub fn coupled_microstrip(w: f64, s: f64, h: f64, t: f64, er: f64) -> (f64, f64, (z(co, coa), z(ce, cea), co / coa, ce / cea) } +/// K(k)/K(k') from k^2, guarded at the ends (k^2 >= 1 is touching conductors: infinite).+fn kk2(k2: f64) -> f64 {+ if k2 >= 1.0 { return f64::INFINITY; }+ k_ratio(k2.max(1e-300).sqrt())+}++/// Conformal-mapping capacitances (over e0, per strip) of a strip with a coplanar ground beside it,+/// zero thickness, by the partial-capacitance method (the slots are magnetic walls). The strip runs+/// from x1 to x2 off the symmetry plane and the ground starts at x3; a pair's two strips are mirror+/// images about x = 0 (odd mode: an electric wall there, even mode: a magnetic wall), a single strip+/// is its own mirror half (x1 = 0, even). Returns (the air half-space above, the dielectric of+/// height h between the strip and the plane under it, before er).+///+/// Above: z -> z^2 maps the quarter plane to a half plane, and a strip between grounds in a half+/// plane is e0 K(k)/K(k') with k^2 the cross ratio of the four edge points. Below: z -> cosh(pi z / h)+/// maps the half strip between the copper and the plane (and the wall) to a half plane the same way.+/// A single strip reduces to the conductor-backed coplanar waveguide of Ghione and Naldi (1987),+/// Wadell sec. 3.4.3: k = a/b, k3 = tanh(pi a / 2h) / tanh(pi b / 2h).+fn cpw_parts(x1: f64, x2: f64, x3: f64, h: f64, odd: bool) -> (f64, f64) {+ let (a1, a2, a3) = (x1 * x1, x2 * x2, x3 * x3);+ let up = if odd { (a2 - a1) * a3 / ((a3 - a1) * a2) } else { (a2 - a1) / (a3 - a1) };+ let (u1, u2, u3) = (PI * x1 / h, PI * x2 / h, PI * x3 / h);+ // cosh(u) - cosh(v) = 2 sinh((u + v)/2) sinh((u - v)/2); cosh(u) -+ 1 = 2 sinh^2(u/2), 2 cosh^2(u/2)+ let d21 = ((u2 + u1) / 2.0).sinh() * ((u2 - u1) / 2.0).sinh();+ let d31 = ((u3 + u1) / 2.0).sinh() * ((u3 - u1) / 2.0).sinh();+ let lo = if odd { d21 * (u3 / 2.0).sinh().powi(2) / ((u2 / 2.0).sinh().powi(2) * d31) } else { d21 * (u3 / 2.0).cosh().powi(2) / ((u2 / 2.0).cosh().powi(2) * d31) };+ (kk2(up), kk2(lo))+}++/// Grounded (conductor-backed) coplanar waveguide on an outer layer: (Z0 ohm, effective+/// permittivity) of a strip of width w with a same-layer ground at gap g on both sides, over the+/// plane at dielectric height h (the pour stitched to it), copper thickness t, all mm. Ghione and+/// Naldi's conformal mapping (Wadell sec. 3.4.3), with the strip sidewalls' parallel-plate+/// capacitance e0 t / g across each gap (the term the coupled microstrip uses, Gupta et al. 8.4).+pub fn gcpw(w: f64, g: f64, h: f64, t: f64, er: f64) -> (f64, f64) {+ let (up, lo) = cpw_parts(0.0, w / 2.0, w / 2.0 + g, h, false);+ let side = if t > 0.0 { t / g } else { 0.0 };+ let (c, ca) = (2.0 * EPS0 * (up + side + er * lo), 2.0 * EPS0 * (up + side + lo));+ (1.0 / (C0 * (c * ca).sqrt()), c / ca)+}++/// Edge-coupled grounded coplanar waveguide (a pair, gap s between the strips, same-layer ground at+/// gap g outside each): (Z odd, Z even, eeff odd, eeff even). The odd and even half structures by+/// the same conformal maps as `gcpw`, plus the sidewall terms e0 t / g at the outer gap and, in odd+/// mode, 2 e0 t / s to the virtual ground between the strips.+pub fn coupled_gcpw(w: f64, s: f64, g: f64, h: f64, t: f64, er: f64) -> (f64, f64, f64, f64) {+ let (x1, x2, x3) = (s / 2.0, s / 2.0 + w, s / 2.0 + w + g);+ let mode = |odd: bool| {+ let (up, lo) = cpw_parts(x1, x2, x3, h, odd);+ let side = if t > 0.0 { t / g + if odd { 2.0 * t / s } else { 0.0 } } else { 0.0 };+ let (c, ca) = (EPS0 * (up + side + er * lo), EPS0 * (up + side + lo));+ (1.0 / (C0 * (c * ca).sqrt()), c / ca)+ };+ let ((zo, eo), (ze, ee)) = (mode(true), mode(false));+ (zo, ze, eo, ee)+}++/// The range the coplanar forms are used in. Against the field solve (tools/impedance_reference.py,+/// 365 cross-sections, er 4.4) they read 0.6 to 4.1 % high over 0.06 <= w/h <= 15, 0.06 <= g/h <= 1,+/// 0.06 <= s/h <= 2 and t <= w/4; the error grows past g/h 1 (the slots stop acting as magnetic walls:+/// 4.4 % at g/h 1.5, 6.8 % at 2, 16 % at 10) and for strips under four copper thicknesses wide (6.9 %).+pub fn coplanar_in_range(w: f64, s: Option<f64>, g: f64, h: f64, t: f64) -> Result<(), String> {+ let (u, gh) = (w / h, g / h);+ if !(0.06..=15.0).contains(&u) { return Err(format!("w/h {u:.3} is outside the coplanar form's validated 0.06..15")); }+ if !(0.06..=1.0).contains(&gh) { return Err(format!("the pour gap over the dielectric, g/h {gh:.3}, is outside 0.06..1, where the coplanar form stays within 4.1 % of a field solve (past 1 the dielectric under the gap carries the field and it reads high)")); }+ if t > w / 4.0 + 1e-12 { return Err(format!("the strip is {w:.3} mm wide, under four copper thicknesses ({:.3} mm), outside the coplanar form's validated range", 4.0 * t)); }+ if let Some(s) = s {+ let sh = s / h;+ if !(0.06..=2.0).contains(&sh) { return Err(format!("s/h {sh:.3} is outside the coupled coplanar form's validated 0.06..2")); }+ }+ Ok(())+}+ /// The range a formula is used in (normalised by the dielectric height); outside it the table /// refuses rather than extrapolates. pub fn in_range(structure: &str, w: f64, s: Option<f64>, h: f64) -> Result<(), String> {@@ -227,6 +306,9 @@ pub struct LayerGeom { pub reference: Vec<String>, pub er: f64, pub t: f64,+ /// an outer layer with a same-layer ground pour this far (mm) from the copper: grounded coplanar+ /// waveguide (a target's coplanarGapMm, never the stackup's own)+ pub coplanar: Option<f64>, } #[derive(Clone, Debug)]@@ -324,16 +406,16 @@ pub fn stackup(board: &Board, spec: &Value) -> Result<Stackup, String> { let mut layers = Vec::new(); for (k, n) in names.iter().enumerate() { let g = if k == 0 {- LayerGeom { name: n.clone(), structure: "microstrip".into(), h1: gaps[0], h2: None, reference: vec![names[1].clone()], er: ers[0], t: ts[k] }+ LayerGeom { name: n.clone(), structure: "microstrip".into(), h1: gaps[0], h2: None, reference: vec![names[1].clone()], er: ers[0], t: ts[k], coplanar: None } } else if k == ngap {- LayerGeom { name: n.clone(), structure: "microstrip".into(), h1: gaps[k - 1], h2: None, reference: vec![names[k - 1].clone()], er: ers[k - 1], t: ts[k] }+ LayerGeom { name: n.clone(), structure: "microstrip".into(), h1: gaps[k - 1], h2: None, reference: vec![names[k - 1].clone()], er: ers[k - 1], t: ts[k], coplanar: None } } else { let (a, b) = (gaps[k - 1], gaps[k]); // the dielectric between copper foils is measured foil to foil: the strip sits on one // foil plane, so each face's distance to its plane is that gap (thickness-weighted er) let er = (ers[k - 1] * a + ers[k] * b) / (a + b); let sym = (a - b).abs() <= 1e-6 * (a + b);- LayerGeom { name: n.clone(), structure: if sym { "stripline".into() } else { "offset stripline".into() }, h1: a, h2: Some(b), reference: vec![names[k - 1].clone(), names[k + 1].clone()], er, t: ts[k] }+ LayerGeom { name: n.clone(), structure: if sym { "stripline".into() } else { "offset stripline".into() }, h1: a, h2: Some(b), reference: vec![names[k - 1].clone(), names[k + 1].clone()], er, t: ts[k], coplanar: None } }; layers.push(g); }@@ -341,18 +423,24 @@ pub fn stackup(board: &Board, spec: &Value) -> Result<Stackup, String> { } impl LayerGeom {+ /// This outer layer with a same-layer ground pour at gap g beside the copper.+ pub fn with_coplanar(&self, g: f64) -> LayerGeom {+ LayerGeom { structure: "grounded coplanar".into(), coplanar: Some(g), ..self.clone() }+ } /// Single-ended Z0 and effective permittivity of a trace of width w on this layer. pub fn z0(&self, w: f64) -> (f64, f64) {- match self.h2 {- None => microstrip(w, self.h1, self.t, self.er),- Some(h2) => (offset_stripline(w, self.h1, h2, self.t, self.er), self.er),+ match (self.h2, self.coplanar) {+ (None, Some(g)) => gcpw(w, g, self.h1, self.t, self.er),+ (None, None) => microstrip(w, self.h1, self.t, self.er),+ (Some(h2), _) => (offset_stripline(w, self.h1, h2, self.t, self.er), self.er), } } /// (Zdiff, Zodd, Zeven, eeff odd) of a pair of width w and gap s on this layer. pub fn zdiff(&self, w: f64, s: f64) -> (f64, f64, f64, f64) {- match self.h2 {- None => { let (zo, ze, eo, _) = coupled_microstrip(w, s, self.h1, self.t, self.er); (2.0 * zo, zo, ze, eo) }- Some(h2) => { let (zo, ze) = coupled_offset_stripline(w, s, self.h1, h2, self.t, self.er); (2.0 * zo, zo, ze, self.er) }+ match (self.h2, self.coplanar) {+ (None, Some(g)) => { let (zo, ze, eo, _) = coupled_gcpw(w, s, g, self.h1, self.t, self.er); (2.0 * zo, zo, ze, eo) }+ (None, None) => { let (zo, ze, eo, _) = coupled_microstrip(w, s, self.h1, self.t, self.er); (2.0 * zo, zo, ze, eo) }+ (Some(h2), _) => { let (zo, ze) = coupled_offset_stripline(w, s, self.h1, h2, self.t, self.er); (2.0 * zo, zo, ze, self.er) } } } /// The height the formulas' ranges are normalised by.@@ -363,6 +451,7 @@ impl LayerGeom { let ratio = self.h1.max(h2) / self.h1.min(h2); if ratio > MAX_OFFSET_RATIO + 1e-9 { return Err(format!("the strip sits {ratio:.1}:1 between its planes; the offset stripline approximation is validated to {MAX_OFFSET_RATIO}:1")); } }+ if let (None, Some(g)) = (self.h2, self.coplanar) { return coplanar_in_range(w, s, g, self.h1, self.t); } in_range(self.kind(), w, s, self.h_norm()) } }@@ -384,6 +473,17 @@ pub struct Target { pub max_uncontrolled: f64, pub max_width: Option<f64>, pub source: String,+ /// a same-layer ground pour beside the copper on an outer layer (grounded coplanar waveguide)+ pub coplanar: Option<Coplanar>,+}++/// The same-layer ground a coplanar target assumes: its net and its gap from the copper (mm).+#[derive(Clone, Debug)]+pub struct Coplanar {+ pub net: String,+ pub gap: f64,+ /// the spec key it came from (the target's coplanarGapMm or the spec's coplanarGround)+ pub source: String, } impl Target {@@ -393,12 +493,24 @@ impl Target { /// The impedance targets of a spec. `impedanceTargets` is AI Flow's form, one object per target: /// `{"USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90}, "RF": {"net": "RF_OUT", /// "targetOhms": 50}}` with optional `tolerancePercent` (10), `gapMm`, `widthMm`, `layer`,-/// `maxSkewMm` (0.5), `maxUncontrolledMm` (3.0) and `maxWidthMm`. adom-fields' own+/// `maxSkewMm` (0.5), `maxUncontrolledMm` (3.0), `maxWidthMm` and `coplanarGapMm` (with+/// `coplanarNet`, default the spec's coplanarGround net, else GND): a same-layer ground pour at that+/// gap beside the copper on an outer layer. The spec's `coplanarGround` {"net", "gapMm"} gives every+/// target without its own coplanarGapMm that pour. adom-fields' own /// `impedance` map is read too, {net: ohms, "P/N": differential ohms}, at 10 % (Fields' own /// threshold), so one spec drives both; its values must stay numbers or Fields refuses the spec. pub fn targets(spec: &Value) -> Result<Vec<Target>, String> { let mut out: Vec<Target> = Vec::new(); let num = |o: &Value, k: &str| o.get(k).and_then(|v| v.as_f64());+ // spec coplanarGround {"net": "GND", "gapMm": g}: a same-layer pour beside every outer-layer target+ let ground: Option<Coplanar> = match spec.get("coplanarGround") {+ None => None,+ Some(o) => {+ let gap = o.get("gapMm").and_then(|v| v.as_f64()).filter(|g| *g > 0.0 && g.is_finite()).ok_or("spec coplanarGround needs gapMm, the gap (mm) from the copper to the same-layer ground pour: {\"net\": \"GND\", \"gapMm\": 0.2}")?;+ let net = match o.get("net") { None => "GND".to_string(), Some(v) => v.as_str().filter(|n| !n.is_empty()).ok_or("spec coplanarGround.net must be the pour's net name")?.to_string() };+ Some(Coplanar { net, gap, source: "coplanarGround".into() })+ }+ }; if let Some(m) = spec.get("impedanceTargets") { let m = m.as_object().ok_or("spec impedanceTargets must be an object of named targets")?; for (name, o) in m {@@ -416,8 +528,16 @@ pub fn targets(spec: &Value) -> Result<Vec<Target>, String> { if let Some(v) = o.get(k) { if !v.as_f64().map(|x| x >= 0.0 && x.is_finite()).unwrap_or(false) { return Err(format!("impedanceTargets.{name}.{k} must be a non-negative number (mm)")); } } } if !diff && o.get("gapMm").is_some() { return Err(format!("impedanceTargets.{name}: gapMm is for a pair")); }+ let coplanar = match o.get("coplanarGapMm") {+ None => ground.clone(),+ Some(v) => {+ let gap = v.as_f64().filter(|g| *g > 0.0 && g.is_finite()).ok_or_else(|| format!("impedanceTargets.{name}.coplanarGapMm must be a positive number: the gap (mm) from the copper to the same-layer ground pour"))?;+ let net = o.get("coplanarNet").and_then(|v| v.as_str()).map(str::to_string).or_else(|| ground.as_ref().map(|c| c.net.clone())).unwrap_or_else(|| "GND".into());+ Some(Coplanar { net, gap, source: format!("impedanceTargets.{name}.coplanarGapMm") })+ }+ }; out.push(Target { name: name.clone(), nets, ohms, diff, tolerance: tol, gap: num(o, "gapMm"), width: num(o, "widthMm"), layer: o.get("layer").and_then(|v| v.as_str()).map(str::to_string),- max_skew: num(o, "maxSkewMm").unwrap_or(DEFAULT_MAX_SKEW_MM), max_uncontrolled: num(o, "maxUncontrolledMm").unwrap_or(DEFAULT_MAX_UNCONTROLLED_MM), max_width: num(o, "maxWidthMm"), source: format!("impedanceTargets.{name}") });+ max_skew: num(o, "maxSkewMm").unwrap_or(DEFAULT_MAX_SKEW_MM), max_uncontrolled: num(o, "maxUncontrolledMm").unwrap_or(DEFAULT_MAX_UNCONTROLLED_MM), max_width: num(o, "maxWidthMm"), source: format!("impedanceTargets.{name}"), coplanar }); } } if let Some(m) = spec.get("impedance") {@@ -429,7 +549,7 @@ pub fn targets(spec: &Value) -> Result<Vec<Target>, String> { let nets: Vec<String> = k.split('/').map(str::to_string).collect(); if nets.len() > 2 || nets.iter().any(|n| n.is_empty()) { return Err(format!("spec impedance key {k:?}: a net, or \"P/N\" for a pair")); } if out.iter().any(|t| t.nets == nets) { continue; } // impedanceTargets carries the detail- out.push(Target { name: k.clone(), diff: nets.len() == 2, nets, ohms, tolerance: DEFAULT_TOLERANCE, gap: None, width: None, layer: None, max_skew: DEFAULT_MAX_SKEW_MM, max_uncontrolled: DEFAULT_MAX_UNCONTROLLED_MM, max_width: None, source: format!("impedance.{k}") });+ out.push(Target { name: k.clone(), diff: nets.len() == 2, nets, ohms, tolerance: DEFAULT_TOLERANCE, gap: None, width: None, layer: None, max_skew: DEFAULT_MAX_SKEW_MM, max_uncontrolled: DEFAULT_MAX_UNCONTROLLED_MM, max_width: None, source: format!("impedance.{k}"), coplanar: ground.clone() }); } } let mut seen: BTreeMap<String, String> = BTreeMap::new();@@ -496,11 +616,24 @@ fn r2(x: f64) -> f64 { (x * 100.0).round() / 100.0 } /// One row: the width (and gap) that meets a target on one layer, or why it cannot. pub fn solve_row(t: &Target, g: &LayerGeom, fab: &Fab, grid: Option<&GridPair>) -> Value {+ // a coplanar target on an outer layer: the same layer with the ground pour beside the copper+ let cg;+ let g = match (&t.coplanar, g.h2) { (Some(c), None) => { cg = g.with_coplanar(c.gap); &cg } _ => g }; let hn = g.h_norm(); let (lo, hi) = (hn * 0.02, hn * 20.0); let mut row = json!({"target": t.name, "nets": t.nets, "layer": g.name, "structure": g.structure, "reference": g.reference, "dielectricMm": match g.h2 { None => json!(r4(g.h1)), Some(h2) => json!([r4(g.h1), r4(h2)]) }, "epsilonR": r4(g.er), "copperUm": r2(g.t * 1000.0), "targetOhms": t.ohms, "differential": t.diff, "tolerancePercent": t.tolerance}); let refuse = |mut row: Value, why: String| { row["ok"] = json!(false); row["why"] = json!(why); row }; if let Some(l) = &t.layer { if l != &g.name { return refuse(row, format!("the target names layer {l}")); } }+ if let Some(c) = &t.coplanar {+ if g.coplanar.is_some() {+ row["coplanarGapMm"] = json!(c.gap);+ row["coplanarNet"] = json!(c.net);+ row["assumes"] = json!(format!("a {} pour on {} {} mm from the copper on each side{}, stitched to {} with vias", c.net, g.name, c.gap, if t.diff { " of the pair" } else { "" }, g.reference.join(" and ")));+ if c.gap + 1e-9 < fab.min_space { return refuse(row, format!("the coplanar gap {} mm ({}) is under the fab minimum space {:.3} mm ({})", c.gap, c.source, fab.min_space, fab.source)); }+ } else {+ row["coplanarNote"] = json!(format!("{} applies to outer layers: this {} row assumes no same-layer copper near the trace (a pour closer than about the dielectric lowers its impedance; not modelled)", c.source, g.structure));+ }+ } if !t.diff { let w = match t.width { Some(w) => Some(w), None => solve_decreasing(&|w| g.z0(w).0, t.ohms, lo, hi) }; let Some(w) = w else { return refuse(row, format!("{:.0} ohm is outside what a {} on this dielectric reaches between {:.3} and {:.2} mm wide", t.ohms, g.structure, lo, hi)); };@@ -619,8 +752,9 @@ pub fn plan_tracks(plan: &Value, board: &Board) -> (Vec<Track>, Vec<Via>) { fn len(t: &Track) -> f64 { (t.b.0 - t.a.0).hypot(t.b.1 - t.a.1) } -/// The nearest parallel partner segment at a point: (gap to its edge, its width).-fn partner_gap(x: f64, y: f64, dir: (f64, f64), w: f64, layer: usize, others: &[&Track]) -> Option<(f64, f64)> {+/// The nearest parallel partner segment at a point: (gap to its edge, its width, the side it is on:+/// +1 to the left of `dir`, -1 to the right).+fn partner_gap(x: f64, y: f64, dir: (f64, f64), w: f64, layer: usize, others: &[&Track]) -> Option<(f64, f64, f64)> { others.iter().filter(|q| q.layer == layer).filter_map(|q| { let l = len(q); if l < 1e-9 { return None; }@@ -629,18 +763,78 @@ fn partner_gap(x: f64, y: f64, dir: (f64, f64), w: f64, layer: usize, others: &[ let along = (x - q.a.0) * ux + (y - q.a.1) * uy; if along < -1e-6 || along > l + 1e-6 { return None; } let d = ((x - q.a.0) * uy - (y - q.a.1) * ux).abs();- Some((d - (w + q.width) / 2.0, q.width))+ let (cx, cy) = (q.a.0 + along * ux - x, q.a.1 + along * uy - y);+ Some((d - (w + q.width) / 2.0, q.width, if cx * -dir.1 + cy * dir.0 >= 0.0 { 1.0 } else { -1.0 })) }).min_by(|a, b| a.0.total_cmp(&b.0)) } +/// The copper of one ground net a coplanar target is judged against: its filled zone islands, its+/// pads and its tracks, as polygons per outer copper layer, and the layers it has a filled zone on.+/// Only on those is the pour beside a trace measured; elsewhere (not poured yet, or only a plan) it+/// is assumed, and the check says so.+#[derive(Clone, Debug, Default)]+pub struct GroundCopper {+ pub net: String,+ /// (copper layer index, polygon, its bounding box)+ pub polys: Vec<(usize, Vec<(f64, f64)>, (f64, f64, f64, f64))>,+ pub filled: Vec<usize>,+}++impl GroundCopper {+ pub fn of(board: &Board, net: &str) -> GroundCopper {+ let mut g = GroundCopper { net: net.to_string(), ..Default::default() };+ let mut add = |l: usize, p: Vec<(f64, f64)>| { if p.len() >= 3 { let b = aiflow_board::geom::bbox(&p); g.polys.push((l, p, b)); } };+ for (n, l, p) in board.copper_fills() {+ if n == net && l <= 1 { if !g.filled.contains(&l) { g.filled.push(l); } add(l, p); }+ }+ for p in board.pads.iter().filter(|p| p.net == net) {+ for l in [0usize, 1] { if p.on_copper(l) { for q in &p.polys { add(l, q.clone()); } } }+ }+ for t in board.copper_tracks().into_iter().filter(|t| t.net == net && t.layer <= 1) {+ add(t.layer, aiflow_board::geom::thick_line_poly(t.a, t.b, t.width / 2.0));+ }+ g+ }++ /// The distance from (x, y) along the unit direction d to this net's copper on `layer`, up to+ /// `reach` (0 when the point is inside it). None when none is that close.+ pub fn ray(&self, layer: usize, x: f64, y: f64, d: (f64, f64), reach: f64) -> Option<f64> {+ let (ex, ey) = (x + d.0 * reach, y + d.1 * reach);+ let (bx0, bx1, by0, by1) = (x.min(ex), x.max(ex), y.min(ey), y.max(ey));+ let mut best: Option<f64> = None;+ for (l, poly, b) in &self.polys {+ if *l != layer || b.2 < bx0 || b.0 > bx1 || b.3 < by0 || b.1 > by1 { continue; }+ if aiflow_board::geom::point_in_poly(x, y, poly) { return Some(0.0); }+ for k in 0..poly.len() {+ let (a, c) = (poly[k], poly[(k + 1) % poly.len()]);+ let (sx, sy) = (c.0 - a.0, c.1 - a.1);+ let den = d.0 * sy - d.1 * sx;+ if den.abs() < 1e-12 { continue; }+ let (qx, qy) = (a.0 - x, a.1 - y);+ let t = (qx * sy - qy * sx) / den;+ let u = (qx * d.1 - qy * d.0) / den;+ if (-1e-9..=reach).contains(&t) && (-1e-9..=1.0 + 1e-9).contains(&u) && best.map(|b| t < b).unwrap_or(true) { best = Some(t.max(0.0)); }+ }+ }+ best+ }+}+ /// Judge one target on its landed (or planned) copper. Each track is sampled every 0.05 mm; a /// sample inside one of the net's own pads is pad copper, not line, and is skipped. A sample is /// controlled when its line (single) or its line and the parallel partner beside it (pair) give /// the target within tolerance; the rest is the net's uncontrolled length, which may not exceed /// `maxUncontrolledMm` (pad breakouts and the bends of a pair).-pub fn check_target(t: &Target, st: &Stackup, tracks: &[Track], vias: &[Via], pads: &[aiflow_board::Pad]) -> Value {+///+/// A coplanar target on an outer layer where `ground` (its net) has a filled zone is measured: from+/// each sample the ground copper is looked for square to the line on each side (a pair: its outer+/// side), and the sample is controlled only when the gap found there gives the target within+/// tolerance (each side on its own, as if both sides had it). Where the net has no filled zone on+/// the layer (not poured yet, or a plan) the pour is assumed at the target's gap and the result says+/// so: assumed, not measured.+pub fn check_target(t: &Target, st: &Stackup, tracks: &[Track], vias: &[Via], pads: &[aiflow_board::Pad], ground: &GroundCopper) -> Value { let tol = t.ohms * t.tolerance / 100.0;- let geom = |l: usize| st.layers.iter().find(|g| g.name == layer_name(l));+ let geom = |l: usize| st.layers.iter().find(|g| g.name == layer_name(l)).map(|g| match (&t.coplanar, g.h2) { (Some(c), None) => g.with_coplanar(c.gap), _ => g.clone() }); let mine: Vec<Vec<&Track>> = t.nets.iter().map(|n| tracks.iter().filter(|x| &x.net == n && len(x) > 1e-9).collect()).collect(); let own_pads: Vec<Vec<&aiflow_board::Pad>> = t.nets.iter().map(|n| pads.iter().filter(|p| &p.net == n).collect()).collect(); let in_pad = |side: usize, layer: usize, x: f64, y: f64| own_pads[side].iter().any(|p| p.on_copper(layer) && p.polys.iter().any(|q| aiflow_board::geom::point_in_poly(x, y, q)));@@ -659,6 +853,10 @@ pub fn check_target(t: &Target, st: &Stackup, tracks: &[Track], vias: &[Via], pa let n = t.nets.len(); let (mut line, mut controlled) = (vec![0.0; n], vec![0.0; n]); let mut widths: Vec<f64> = Vec::new();+ // coplanar: where the pour is measured or assumed, the gaps measured, the length with no ground beside it+ let (mut measured_on, mut assumed_on): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new());+ let (mut cmin, mut cmax) = (f64::INFINITY, f64::NEG_INFINITY);+ let mut no_ground = vec![0.0; n]; for side in 0..n { for x in &mine[side] { widths.push(r4(x.width));@@ -666,18 +864,65 @@ pub fn check_target(t: &Target, st: &Stackup, tracks: &[Track], vias: &[Via], pa let dir = ((x.b.0 - x.a.0) / l, (x.b.1 - x.a.1) / l); let k_n = (l / step).ceil().max(1.0) as usize; let seg = l / k_n as f64;+ let g = geom(x.layer);+ let cop = g.as_ref().and_then(|g| g.coplanar).zip(t.coplanar.as_ref());+ let measured = cop.is_some() && ground.net == t.coplanar.as_ref().map(|c| c.net.as_str()).unwrap_or("") && ground.filled.contains(&x.layer);+ if cop.is_some() {+ let list = if measured { &mut measured_on } else { &mut assumed_on };+ let name = layer_name(x.layer);+ if !list.contains(&name) { list.push(name); }+ } for k in 0..k_n { let f = (k as f64 + 0.5) / k_n as f64; let (px, py) = (x.a.0 + f * (x.b.0 - x.a.0), x.a.1 + f * (x.b.1 - x.a.1)); if in_pad(side, x.layer, px, py) { continue; } line[side] += seg;- let z = geom(x.layer).and_then(|g| {- if !t.diff { return Some(g.z0(x.width).0); }- let (gap, w2) = partner_gap(px, py, dir, x.width, x.layer, &mine[1 - side])?;- if gap <= 0.0 || (w2 - x.width).abs() > 1e-3 || g.valid(x.width, Some(gap)).is_err() { return None; }- gmin = gmin.min(gap);- gmax = gmax.max(gap);- Some(g.zdiff(x.width, gap).0)+ let z = g.as_ref().and_then(|g| {+ // the ground gap on one side (+1 left of the line, -1 right), measured+ let mut lacks = false;+ let mut gap_on = |sgn: f64| -> Option<f64> {+ let nrm = (-dir.1 * sgn, dir.0 * sgn);+ let reach = (3.0 * g.coplanar.unwrap_or(0.0)).max(g.h1);+ let r = ground.ray(x.layer, px + nrm.0 * x.width / 2.0, py + nrm.1 * x.width / 2.0, nrm, reach);+ if r.is_none() { lacks = true; }+ r+ };+ let z = if !t.diff {+ if measured {+ let (a, b) = (gap_on(1.0), gap_on(-1.0));+ if lacks { no_ground[side] += seg; }+ let (a, b) = (a?, b?);+ cmin = cmin.min(a.min(b));+ cmax = cmax.max(a.max(b));+ let mut worst: Option<f64> = None;+ for gp in [a, b] {+ let gg = g.with_coplanar(gp);+ if gp <= 0.0 || gg.valid(x.width, None).is_err() { return None; }+ let zz = gg.z0(x.width).0;+ if worst.map(|w| (zz - t.ohms).abs() > (w - t.ohms).abs()).unwrap_or(true) { worst = Some(zz); }+ }+ worst?+ } else {+ g.z0(x.width).0+ }+ } else {+ let (gap, w2, pside) = partner_gap(px, py, dir, x.width, x.layer, &mine[1 - side])?;+ if gap <= 0.0 || (w2 - x.width).abs() > 1e-3 { return None; }+ let gg = if measured {+ let o = gap_on(-pside);+ if lacks { no_ground[side] += seg; }+ let o = o?;+ cmin = cmin.min(o);+ cmax = cmax.max(o);+ if o <= 0.0 { return None; }+ g.with_coplanar(o)+ } else { g.clone() };+ if gg.valid(x.width, Some(gap)).is_err() { return None; }+ gmin = gmin.min(gap);+ gmax = gmax.max(gap);+ gg.zdiff(x.width, gap).0+ };+ Some(z) }); if let Some(z) = z { if (z - t.ohms).abs() <= tol { controlled[side] += seg; zmin = zmin.min(z); zmax = zmax.max(z); }@@ -685,6 +930,18 @@ pub fn check_target(t: &Target, st: &Stackup, tracks: &[Track], vias: &[Via], pa } } }+ if let Some(c) = &t.coplanar {+ let mut cp = json!({"net": c.net, "gapMm": c.gap, "source": c.source, "measuredOn": measured_on, "assumedOn": assumed_on});+ if !measured_on.is_empty() {+ cp["measuredGapMm"] = if cmin.is_finite() { json!([r4(cmin), r4(cmax)]) } else { Value::Null };+ cp["withoutGroundMm"] = json!(no_ground.iter().map(|x| r2(*x)).collect::<Vec<_>>());+ }+ if !assumed_on.is_empty() {+ cp["note"] = json!(format!("the {} pour {} mm beside it on {} is assumed, not measured: no filled {} zone there{}", c.net, c.gap, assumed_on.join(", "), c.net, if ground.net == c.net { "" } else { " (no ground copper read)" }));+ cp["todo"] = json!(format!("Pour it with {} mm clearance to these nets, stitched to the plane under it, and check again", c.gap));+ }+ out["coplanar"] = cp;+ } let uncontrolled: Vec<f64> = line.iter().zip(&controlled).map(|(a, b)| (a - b).max(0.0)).collect(); out["lengthMm"] = json!(line.iter().map(|x| r2(*x)).collect::<Vec<_>>()); out["ohms"] = if zmin.is_finite() { json!([r2(zmin), r2(zmax)]) } else { Value::Null };@@ -694,7 +951,7 @@ pub fn check_target(t: &Target, st: &Stackup, tracks: &[Track], vias: &[Via], pa let skew = (line[0] - line[1]).abs(); out["skewMm"] = json!(r4(skew)); out["maxSkewMm"] = json!(t.max_skew);- if skew > t.max_skew + 1e-9 { reasons.push(format!("length skew {skew:.3} mm over maxSkewMm {} (the grid router does not length-tune)", t.max_skew)); }+ if skew > t.max_skew + 1e-9 { reasons.push(format!("length skew {skew:.3} mm over maxSkewMm {} (route tunes a pair's skew with bumps on its shorter net where they clear; tune the rest by hand)", t.max_skew)); } if by_layer.len() > 1 || via_count.iter().any(|v| *v > 0) { reasons.push(format!("the pair changes layer ({} vias): the closed forms do not model the via transition", via_count.iter().sum::<usize>())); } } if controlled.iter().all(|c| *c <= 0.0) { reasons.push(format!("no copper of {} meets {} ohm +-{} %", t.label(), t.ohms, t.tolerance)); }@@ -704,7 +961,13 @@ pub fn check_target(t: &Target, st: &Stackup, tracks: &[Track], vias: &[Via], pa out["uncontrolledMm"] = json!(uncontrolled.iter().map(|x| r2(*x)).collect::<Vec<_>>()); out["maxUncontrolledMm"] = json!(t.max_uncontrolled); for (net, u) in t.nets.iter().zip(&uncontrolled) {- if *u > t.max_uncontrolled + 1e-9 { reasons.push(format!("{net}: {u:.2} mm outside {} ohm +-{} % ({}; maxUncontrolledMm {} allows pad breakouts)", t.ohms, t.tolerance, if t.diff { "not coupled to its partner at a gap that gives it" } else { "at a width that misses it" }, t.max_uncontrolled)); }+ let why = match (&t.coplanar, t.diff) {+ (Some(c), true) => format!("not coupled to its partner, or without {} copper beside it, at gaps that give it", c.net),+ (Some(c), false) => format!("at a width, or a gap to the {} copper beside it, that misses it", c.net),+ (None, true) => "not coupled to its partner at a gap that gives it".to_string(),+ (None, false) => "at a width that misses it".to_string(),+ };+ if *u > t.max_uncontrolled + 1e-9 { reasons.push(format!("{net}: {u:.2} mm outside {} ohm +-{} % ({why}; maxUncontrolledMm {} allows pad breakouts)", t.ohms, t.tolerance, t.max_uncontrolled)); } } out["status"] = json!(if reasons.is_empty() { "pass" } else { "fail" }); out["reasons"] = json!(reasons);@@ -772,10 +1035,201 @@ mod tests { // tight gaps on thick dielectric are refused: s/h 0.125 is 5.7 % off the field solve (85.48 ohm) assert!(in_range("microstrip", 1.0, Some(0.2), 1.6).is_err()); // a 5:1 offset stripline (7 % off the field solve, 60.20 ohm) is refused too- let g = LayerGeom { name: "In1.Cu".into(), structure: "offset stripline".into(), h1: 0.2, h2: Some(1.03), reference: vec![], er: 4.3, t: 0.035 };+ let g = LayerGeom { name: "In1.Cu".into(), structure: "offset stripline".into(), h1: 0.2, h2: Some(1.03), reference: vec![], er: 4.3, t: 0.035, coplanar: None }; assert!(g.valid(0.15, None).is_err()); } + // Grounded coplanar (issue #52, StickHub): reference values from tools/impedance_reference.py+ // (the same field solve, a same-layer ground at the gap out to the box side, two grids+ // extrapolated). Inside the validated range the closed form reads 0.6 to 4.1 % high; each case+ // is held to its own error rounded up, plus 0.5 points.+ #[test]+ fn coplanar_lines_match_the_field_solve() {+ let cases = [+ ("50 ohm GCPW, 1.6 mm FR4 er 4.5, w 0.802 g 0.15", gcpw(0.802, 0.15, 1.6, 0.035, 4.5).0, 49.37, 2.0),+ ("StickHub 90 ohm pair, 1.51 er 4.5, w 0.546 s 0.2 g 0.2", 2.0 * coupled_gcpw(0.546, 0.2, 0.2, 1.51, 0.035, 4.5).0, 88.57, 2.5),+ ("pair 1.51 er 4.5, w 0.379 s 0.15 g 0.15", 2.0 * coupled_gcpw(0.379, 0.15, 0.15, 1.51, 0.035, 4.5).0, 88.46, 2.5),+ ("pair on the grid pitch, 1.51 er 4.5, w 0.8485 s 0.3515 g 0.2", 2.0 * coupled_gcpw(0.8485, 0.3515, 0.2, 1.51, 0.035, 4.5).0, 88.76, 2.0),+ ("50 ohm GCPW, JLC 7628 h 0.2104 er 4.4, w 0.3095 g 0.15", gcpw(0.3095, 0.15, 0.2104, 0.035, 4.4).0, 48.95, 3.0),+ ("pair, JLC 7628, w 0.2688 s 0.15 g 0.15", 2.0 * coupled_gcpw(0.2688, 0.15, 0.15, 0.2104, 0.035, 4.4).0, 88.09, 3.0),+ ("range edge g/h 1, h 1.0 w 0.2 g 1.0", gcpw(0.2, 1.0, 1.0, 0.035, 4.4).0, 114.86, 4.6),+ ("range edge pair, h 1.0 w 0.2 s 0.6 g 0.5", 2.0 * coupled_gcpw(0.2, 0.6, 0.5, 1.0, 0.035, 4.4).0, 172.26, 3.8),+ ("er 3.66, h 0.508 w 0.8 g 0.2", gcpw(0.8, 0.2, 0.508, 0.035, 3.66).0, 48.29, 2.0),+ ("er 3.66 pair, h 0.508 w 0.3 s 0.2 g 0.2", 2.0 * coupled_gcpw(0.3, 0.2, 0.2, 0.508, 0.035, 3.66).0, 111.77, 2.7),+ ("er 9.8, h 0.635 w 0.3 g 0.15, 18 um", gcpw(0.3, 0.15, 0.635, 0.018, 9.8).0, 46.60, 2.0),+ ];+ for (what, z, reference, allowed) in cases {+ eprintln!("{what}: closed form {z:.2} ohm, field solve {reference:.2} ({:+.2} %)", (z - reference) / reference * 100.0);+ assert!(pct(z, reference) < allowed, "{what}: {z:.2} vs {reference:.2}");+ }+ // eeff of the 1.6 mm line: field solve 2.590+ assert!(pct(gcpw(0.802, 0.15, 1.6, 0.035, 4.5).1, 2.590) < 2.0);+ // a pour 0.3 mm wide (not out to the board edge) moves it under 1.8 %: 50.25 and 88.81 ohm+ assert!(pct(gcpw(0.802, 0.15, 1.6, 0.035, 4.5).0, 50.25) < 1.0 && pct(2.0 * coupled_gcpw(0.546, 0.2, 0.2, 1.51, 0.035, 4.5).0, 88.81) < 2.0);+ }++ /// Every cross-section of the sweep the range was drawn from (tests/fixtures/coplanar-reference.jsonl,+ /// `tools/impedance_reference.py --coplanar-sweep`): inside the range within 4.1 % (and never+ /// low by more than 0.1 %), and what reads further off is outside it, so refused.+ #[test]+ fn coplanar_range_holds_over_the_sweep() {+ let (mut inside, mut worst) = (0, 0.0f64);+ for l in include_str!("../../../tests/fixtures/coplanar-reference.jsonl").lines().filter(|l| !l.trim().is_empty()) {+ let v: Value = serde_json::from_str(l).unwrap();+ let f = |k: &str| v[k].as_f64().unwrap();+ let (h, w, g, t, er, zref) = (f("h"), f("w"), f("g"), f("t"), f("er"), f("z"));+ let s = v["s"].as_f64();+ let z = match s { Some(s) => 2.0 * coupled_gcpw(w, s, g, h, t, er).0, None => gcpw(w, g, h, t, er).0 };+ let err = (z - zref) / zref * 100.0;+ if coplanar_in_range(w, s, g, h, t).is_ok() {+ inside += 1;+ worst = worst.max(err.abs());+ assert!((-0.1..=4.1).contains(&err), "in range but {err:.2} % off: {l}");+ } else if err.abs() > 4.1 {+ // fine: refused+ }+ }+ eprintln!("{inside} cross-sections in range, worst {worst:.2} %");+ assert!(inside >= 200, "{inside}");+ }++ #[test]+ fn coplanar_limits_and_identities() {+ // a single line at t = 0 is Ghione and Naldi's conductor-backed CPW (Wadell 3.4.3)+ let (w, g, h, er) = (0.4, 0.15, 1.6, 4.5);+ let (a, b) = (w / 2.0, w / 2.0 + g);+ let (kq, k3) = (k_ratio(a / b), k_ratio((PI * a / (2.0 * h)).tanh() / (PI * b / (2.0 * h)).tanh()));+ let eeff = (1.0 + er * k3 / kq) / (1.0 + k3 / kq);+ // (Wadell writes eta0/2 as 60 pi)+ let z = ETA0 / 2.0 / eeff.sqrt() / (kq + k3);+ assert!(pct(gcpw(w, g, h, 0.0, er).0, z) < 1e-9 && pct(gcpw(w, g, h, 0.0, er).1, eeff) < 1e-9);+ // with the plane far away it is the plain coplanar waveguide on a thick substrate+ let cpw = ETA0 / 4.0 / ((er + 1.0) / 2.0).sqrt() / k_ratio(a / b);+ assert!(pct(gcpw(w, g, 1000.0, 0.0, er).0, cpw) < 0.1);+ // a pair far apart decouples (odd = even), each strip with the pour on its outer side only, so+ // above the single line that has it on both sides+ let (zo, ze, _, _) = coupled_gcpw(0.3, 20.0, 0.15, 0.2, 0.0, 4.4);+ let z1 = gcpw(0.3, 0.15, 0.2, 0.0, 4.4).0;+ assert!(pct(zo, ze) < 0.5 && zo > z1, "{zo} {ze} {z1}");+ // odd below even; the pour pulls the even mode well under the same pair without it (the odd+ // mode, held between the strips, barely moves: within the two formulas' own errors); a nearer+ // pour lowers both+ let (zo, ze, _, _) = coupled_gcpw(0.3, 0.2, 0.2, 0.2104, 0.035, 4.4);+ let (mo, me, _, _) = coupled_microstrip(0.3, 0.2, 0.2104, 0.035, 4.4);+ assert!(zo < ze && ze < 0.99 * me && pct(zo, mo) < 1.0, "{zo} {ze} {mo} {me}");+ assert!(coupled_gcpw(0.3, 0.2, 0.15, 0.2104, 0.035, 4.4).0 < zo);+ // the range: g/h past 1, a strip under four copper thicknesses, s/h past 2 are refused+ assert!(coplanar_in_range(0.2, None, 1.0, 0.5, 0.035).unwrap_err().contains("g/h"));+ assert!(coplanar_in_range(0.1, None, 0.1, 1.0, 0.035).unwrap_err().contains("four copper thicknesses"));+ assert!(coplanar_in_range(0.3, Some(0.5), 0.15, 0.2104, 0.035).unwrap_err().contains("s/h"));+ assert!(coplanar_in_range(0.546, Some(0.2), 0.2, 1.51, 0.035).is_ok());+ }++ #[test]+ fn coplanar_targets_and_rows() {+ let t = targets(&json!({"coplanarGround": {"net": "GNDA", "gapMm": 0.2}, "impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90, "coplanarGapMm": 0.15}, "RF": {"net": "RF", "targetOhms": 50}}, "impedance": {"CLK": 50}})).unwrap();+ let by = |n: &str| t.iter().find(|x| x.name == n).unwrap().coplanar.clone().unwrap();+ assert_eq!((by("USB").net.as_str(), by("USB").gap), ("GNDA", 0.15));+ assert_eq!((by("RF").gap, by("CLK").gap, by("CLK").source.as_str()), (0.2, 0.2, "coplanarGround"));+ let t2 = targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90, "coplanarGapMm": 0.15}}})).unwrap();+ assert_eq!(t2[0].coplanar.as_ref().unwrap().net, "GND");+ assert!(targets(&json!({"coplanarGround": {"net": "GND"}})).unwrap_err().contains("gapMm"));+ assert!(targets(&json!({"impedanceTargets": {"RF": {"net": "RF", "targetOhms": 50, "coplanarGapMm": 0}}})).unwrap_err().contains("coplanarGapMm"));+ // no coplanar key: no coplanar (the microstrip rows stay as they were)+ assert!(targets(&json!({"impedanceTargets": {"RF": {"net": "RF", "targetOhms": 50}}})).unwrap()[0].coplanar.is_none());++ // StickHub: two layers, 1.51 mm er 4.5, the board clearance 0.2 mm+ let two = stackup(&two_layer(), &json!({})).unwrap();+ let fab = Fab { min_width: 0.127, min_space: 0.2, source: "test".into() };+ let grid = GridPair { res: 0.1, clearance: 0.2, slop: 0.0708 };+ let ts = targets(&json!({"coplanarGround": {"net": "GND", "gapMm": 0.2}, "impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}, "RF": {"net": "RF", "targetOhms": 50}}})).unwrap();+ let rows = table(&two, &ts, &fab, None);+ let usb = rows.iter().find(|r| r["target"] == "USB" && r["layer"] == "F.Cu").unwrap();+ assert!(usb["ok"] == true && usb["structure"] == "grounded coplanar" && usb["coplanarGapMm"] == 0.2, "{usb}");+ let (w, s) = (usb["widthMm"].as_f64().unwrap(), usb["gapMm"].as_f64().unwrap());+ assert!((s - 0.2).abs() < 1e-9 && (0.50..0.58).contains(&w), "the narrowest gap the fab allows, about 0.55 mm wide: {usb}");+ assert!(usb["assumes"].as_str().unwrap().contains("GND pour on F.Cu 0.2 mm"), "{usb}");+ let rf = rows.iter().find(|r| r["target"] == "RF" && r["layer"] == "F.Cu").unwrap();+ assert!(rf["ok"] == true && (1.0..1.1).contains(&rf["widthMm"].as_f64().unwrap()), "{rf}");+ // the router's row: the pitch on its grid+ let rows = table(&two, &ts, &fab, Some(&grid));+ let usb = rows.iter().find(|r| r["target"] == "USB" && r["layer"] == "F.Cu").unwrap();+ let (w, s) = (usb["widthMm"].as_f64().unwrap(), usb["gapMm"].as_f64().unwrap());+ assert!(usb["ok"] == true && grid.reachable(w, w + s) && pct(two.layers[0].with_coplanar(0.2).zdiff(w, s).0, 90.0) < 0.5, "{usb}");+ // a pour gap under the fab's space is refused, with the reason+ let ts = targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90, "coplanarGapMm": 0.15}}})).unwrap();+ let r = solve_row(&ts[0], &two.layers[0], &fab, None);+ assert!(r["ok"] == false && r["why"].as_str().unwrap().contains("under the fab minimum space"), "{r}");+ // at a 0.15 mm clearance the pair is 0.38 mm wide: the hub's 0.3 mm pins take it+ let r = solve_row(&ts[0], &two.layers[0], &Fab { min_space: 0.15, ..fab.clone() }, None);+ assert!(r["ok"] == true && (0.36..0.40).contains(&r["widthMm"].as_f64().unwrap()), "{r}");+ // a gap past the dielectric (0.3 mm over JLC's 0.2104 mm prepreg) is out of range: refused+ let four = stackup(&jlc_four_layer(), &json!({})).unwrap();+ let ts = targets(&json!({"impedanceTargets": {"RF": {"net": "RF", "targetOhms": 50, "coplanarGapMm": 0.3}}})).unwrap();+ let rows = table(&four, &ts, &fab, None);+ let f = rows.iter().find(|r| r["layer"] == "F.Cu").unwrap();+ assert!(f["ok"] == false && f["why"].as_str().unwrap().contains("g/h") && f["why"].as_str().unwrap().contains("not extrapolated"), "{f}");+ // inner layers keep their stripline rows and say the pour is not modelled there+ let inner = rows.iter().find(|r| r["layer"] == "In2.Cu").unwrap();+ assert!(inner["structure"] != "grounded coplanar" && inner["coplanarNote"].as_str().unwrap().contains("outer layers"), "{inner}");+ }++ /// A board with a pair (or one line) along x on F.Cu and, optionally, GND filled zone copper on+ /// F.Cu at `pour` mm from the outer copper edges.+ fn pour_board(w: f64, s: Option<f64>, pour: Option<f64>) -> (Board, Vec<Track>) {+ let half = match s { Some(s) => s / 2.0 + w, None => w / 2.0 };+ let zone = pour.map(|g| {+ let (y0, y1) = (half + g, half + g + 3.0);+ format!("(zone (net \"GND\") (layer \"F.Cu\") (polygon (pts (xy -1 -5) (xy 21 -5) (xy 21 5) (xy -1 5))) (filled_polygon (layer \"F.Cu\") (pts (xy -1 {}) (xy 21 {}) (xy 21 {}) (xy -1 {}))) (filled_polygon (layer \"F.Cu\") (pts (xy -1 {y0}) (xy 21 {y0}) (xy 21 {y1}) (xy -1 {y1}))))", -y1, -y1, -y0, -y0)+ }).unwrap_or_default();+ let t = format!("(kicad_pcb (version 20260206) (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)) (setup (stackup (layer \"F.Cu\" (type \"copper\") (thickness 0.035)) (layer \"dielectric 1\" (type \"core\") (thickness 1.51) (epsilon_r 4.5)) (layer \"B.Cu\" (type \"copper\") (thickness 0.035)))) {zone} (gr_rect (start -2 -6) (end 22 6) (layer \"Edge.Cuts\")))");+ let b = Board::from_text("p.kicad_pcb", t).unwrap();+ let tr = |net: &str, y: f64| Track { net: net.into(), layer: 0, a: (0.0, y), b: (20.0, y), width: w };+ let tracks = match s { Some(s) => vec![tr("DP", -(s + w) / 2.0), tr("DN", (s + w) / 2.0)], None => vec![tr("RF", 0.0)] };+ (b, tracks)+ }++ #[test]+ fn coplanar_check_measures_the_pour_or_says_it_is_assumed() {+ let fab = Fab { min_width: 0.127, min_space: 0.2, source: "t".into() };+ let ts = targets(&json!({"coplanarGround": {"net": "GND", "gapMm": 0.2}, "impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();+ let (b, _) = pour_board(0.5, Some(0.2), None);+ let st = stackup(&b, &json!({})).unwrap();+ let row = solve_row(&ts[0], &st.layers[0], &fab, None);+ let (w, s) = (row["widthMm"].as_f64().unwrap(), row["gapMm"].as_f64().unwrap());+ // the pour at the gap the row assumes, measured: pass+ let (b, tracks) = pour_board(w, Some(s), Some(0.2));+ let c = check_target(&ts[0], &st, &tracks, &[], &[], &GroundCopper::of(&b, "GND"));+ assert_eq!(c["status"], "pass", "{c}");+ assert_eq!(c["coplanar"]["measuredOn"], json!(["F.Cu"]));+ let m = c["coplanar"]["measuredGapMm"].as_array().unwrap();+ assert!((m[0].as_f64().unwrap() - 0.2).abs() < 1e-3 && (m[1].as_f64().unwrap() - 0.2).abs() < 1e-3, "{c}");+ assert!(c["coplanar"]["note"].is_null(), "{c}");+ // the pour 0.6 mm away: the pair runs 9 % high (98.3 ohm), outside a 5 % target+ let (b, tracks) = pour_board(w, Some(s), Some(0.6));+ let t5 = Target { tolerance: 5.0, ..ts[0].clone() };+ let c = check_target(&t5, &st, &tracks, &[], &[], &GroundCopper::of(&b, "GND"));+ assert_eq!(c["status"], "fail", "{c}");+ assert!(c["reasons"].to_string().contains("without GND copper beside it"), "{c}");+ // no pour on the layer: judged at the assumed gap, and said to be assumed, not measured+ let (b, tracks) = pour_board(w, Some(s), None);+ let c = check_target(&ts[0], &st, &tracks, &[], &[], &GroundCopper::of(&b, "GND"));+ assert_eq!(c["status"], "pass", "{c}");+ assert_eq!(c["coplanar"]["assumedOn"], json!(["F.Cu"]));+ assert!(c["coplanar"]["note"].as_str().unwrap().contains("assumed, not measured"), "{c}");+ // a single 50 ohm line: both sides measured+ let ts = targets(&json!({"impedanceTargets": {"RF": {"net": "RF", "targetOhms": 50, "coplanarGapMm": 0.2}}})).unwrap();+ let w50 = solve_row(&ts[0], &st.layers[0], &fab, None)["widthMm"].as_f64().unwrap();+ let (b, tracks) = pour_board(w50, None, Some(0.2));+ assert_eq!(check_target(&ts[0], &st, &tracks, &[], &[], &GroundCopper::of(&b, "GND"))["status"], "pass");+ // the pour on one side only: the other side has no ground within reach, so it fails+ let mut half = GroundCopper::of(&b, "GND");+ half.polys.retain(|p| p.2 .1 > 0.0);+ let c = check_target(&ts[0], &st, &tracks, &[], &[], &half);+ assert_eq!(c["status"], "fail", "{c}");+ assert!(c["coplanar"]["withoutGroundMm"][0].as_f64().unwrap() > 19.0, "{c}");+ }+ fn two_layer() -> Board { Board::from_text("t.kicad_pcb", "(kicad_pcb (version 20241229) (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)) (setup (stackup (layer \"F.Cu\" (type \"copper\") (thickness 0.035)) (layer \"dielectric 1\" (type \"core\") (thickness 1.51) (material \"FR4\") (epsilon_r 4.5) (loss_tangent 0.02)) (layer \"B.Cu\" (type \"copper\") (thickness 0.035)))) (gr_rect (start 0 0) (end 30 20) (layer \"Edge.Cuts\") (width 0.1)))".into()).unwrap() }@@ -862,20 +1316,20 @@ mod tests { let tr = |net: &str, y: f64, x1: f64| Track { net: net.into(), layer: 0, a: (0.0, y), b: (x1, y), width: w }; // coupled for 20 mm at the solved gap: pass let tracks = vec![tr("DP", 0.0, 20.0), tr("DN", w + s, 20.0)];- let c = check_target(&ts[0], &st, &tracks, &[], &[]);+ let c = check_target(&ts[0], &st, &tracks, &[], &[], &GroundCopper::default()); assert_eq!(c["status"], "pass", "{c}"); assert!(c["coupledPercent"][0].as_f64().unwrap() > 99.0); // the same lengths, apart by 1 mm for 10 of them: uncoupled length over the allowance let tracks = vec![tr("DP", 0.0, 20.0), tr("DN", w + s, 10.0), Track { net: "DN".into(), layer: 0, a: (10.0, w + s), b: (10.0, 1.0 + w), width: w }, Track { net: "DN".into(), layer: 0, a: (10.0, 1.0 + w), b: (20.0, 1.0 + w), width: w }];- let c = check_target(&ts[0], &st, &tracks, &[], &[]);+ let c = check_target(&ts[0], &st, &tracks, &[], &[], &GroundCopper::default()); assert_eq!(c["status"], "fail", "{c}"); assert!(c["reasons"].to_string().contains("not coupled"), "{c}"); // a single-ended 50 ohm net at the wrong width fails, at the right width passes let ts = targets(&json!({"impedanceTargets": {"RF": {"net": "RF", "targetOhms": 50}}})).unwrap(); let w50 = solve_row(&ts[0], &st.layers[0], &fab, None)["widthMm"].as_f64().unwrap(); let one = |w: f64| vec![Track { net: "RF".into(), layer: 0, a: (0.0, 0.0), b: (15.0, 0.0), width: w }];- assert_eq!(check_target(&ts[0], &st, &one(w50), &[], &[])["status"], "pass");- assert_eq!(check_target(&ts[0], &st, &one(0.15), &[], &[])["status"], "fail");+ assert_eq!(check_target(&ts[0], &st, &one(w50), &[], &[], &GroundCopper::default())["status"], "pass");+ assert_eq!(check_target(&ts[0], &st, &one(0.15), &[], &[], &GroundCopper::default())["status"], "fail"); } } @@ -905,14 +1359,14 @@ mod route_tests { rules.track = 0.2; rules.stub = 0.2; for (k, n) in ts[0].nets.iter().enumerate() {- rules.impedance.insert(n.clone(), crate::rules::ImpedanceRoute { target: "USB".into(), width: row["widthMm"].as_f64().unwrap(), layer: "F.Cu".into(), partner: Some(ts[0].nets[1 - k].clone()), pitch: row["pitchMm"].as_f64() });+ rules.impedance.insert(n.clone(), crate::rules::ImpedanceRoute { max_skew: 0.5, target: "USB".into(), width: row["widthMm"].as_f64().unwrap(), layer: "F.Cu".into(), partner: Some(ts[0].nets[1 - k].clone()), pitch: row["pitchMm"].as_f64() }); } let mut r = Router::new(b, rules, false); let (mut plan, _report, failed) = crate::drive::route_board(&mut r, 3, 8, 400); assert_eq!(failed, 0); crate::simplify::simplify_plan(&mut r, &mut plan); let (tracks, vias) = plan_tracks(&json!({"nets": plan}), &r.board);- (row, check_target(&ts[0], &st, &tracks, &vias, &r.board.pads))+ (row, check_target(&ts[0], &st, &tracks, &vias, &r.board.pads, &GroundCopper::default())) } #[test]
crates/aiflow-router/src/lib.rs+5@@ -20,6 +20,7 @@ pub mod board_copper; pub mod copper_path; pub mod decoupling; pub mod signal_priority;+pub mod neckdown; pub mod drive; pub mod impedance; pub mod plane_under;@@ -647,6 +648,10 @@ impl Router { } if let Some(ir) = self.rules.impedance.get(&name) { self.nets[net].widths = vec![ir.width];+ // issue #52: pins narrower than the target width: also try the rule width (a pair keeps its+ // solved pitch); neckdown::widen_plan widens it back wherever it clears+ let narrowest = self.nets[net].pads.iter().map(|&p| 2.0 * self.board.pads[p].half_short).fold(f64::INFINITY, f64::min);+ if narrowest + 0.1 < ir.width && self.rules.track < ir.width { self.nets[net].widths.push(self.rules.track); } } let count = self.nets[net].clusters.len(); if !self.comp_copper.is_empty() {
crates/aiflow-router/src/neckdown.rsadded+316@@ -0,0 +1,316 @@+//! Issue #52 (a real board: StickHub, eight USB pairs, 2 layers, 1.6 mm): an impedance target can solve to a+//! width far wider than the pins it has to reach (90 ohm diff over 1.51 mm FR4: 1.38 mm, against 0.3 mm hub+//! pins), and a net searched only at that width never leaves its pins. The router now also tries such a net at+//! the rule width (a pair still at its solved centre pitch), and this pass widens the routed copper back to the+//! target width wherever the router's own clearance check allows: the copper stays narrow only where it must+//! (near the pins, past an obstacle), and `analyze impedance` counts that as the net's uncontrolled length+//! against `maxUncontrolledMm`. Each widened run is its own plan path at the target width, joined end to end.+use crate::{Router, F_CU, B_CU};+use serde_json::{json, Value};+use std::collections::BTreeMap;++type Pt = (f64, f64);+/// The length of each piece the widening is judged on (mm).+const STEP: f64 = 0.1;++fn layer_index(name: &str) -> Option<usize> { match name { "F.Cu" => Some(F_CU), "B.Cu" => Some(B_CU), _ => None } }++fn point(board: &aiflow_board::Board, v: &Value) -> Option<Pt> {+ if let Some(k) = v.as_str() { let p = board.pad_by_key(k)?; return Some((p.x, p.y)); }+ if let Some(a) = v.as_array() { return Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)); }+ let o = v.as_object()?;+ if let Some(k) = o.get("pad").and_then(|p| p.as_str()) { let p = board.pad_by_key(k)?; return Some((p.x, p.y)); }+ Some((o.get("x")?.as_f64()?, o.get("y")?.as_f64()?))+}++fn dist(a: Pt, b: Pt) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }+fn lerp(a: Pt, b: Pt, t: f64) -> Pt { (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) }+fn r6(v: f64) -> f64 { (v * 1e6).round() / 1e6 }++/// A polyline cut into pieces of at most STEP, keeping its own vertices.+fn pieces(pts: &[Pt]) -> Vec<(Pt, Pt)> {+ let mut out = Vec::new();+ for w in pts.windows(2) {+ let (a, b) = (w[0], w[1]);+ let n = (dist(a, b) / STEP).ceil().max(1.0) as usize;+ for k in 0..n { out.push((lerp(a, b, k as f64 / n as f64), lerp(a, b, (k + 1) as f64 / n as f64))); }+ }+ out+}++/// Runs of consecutive pieces with the same verdict, as polylines (collinear points dropped).+fn runs(ps: &[(Pt, Pt)], ok: &[bool]) -> Vec<(bool, Vec<Pt>)> {+ let mut out: Vec<(bool, Vec<Pt>)> = Vec::new();+ for (k, (a, b)) in ps.iter().enumerate() {+ match out.last_mut() {+ Some((v, line)) if *v == ok[k] => line.push(*b),+ _ => out.push((ok[k], vec![*a, *b])),+ }+ }+ for (_, line) in out.iter_mut() {+ let mut keep: Vec<Pt> = Vec::new();+ for p in line.iter() {+ if keep.len() >= 2 {+ let (a, b) = (keep[keep.len() - 2], keep[keep.len() - 1]);+ let cross = (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0);+ if cross.abs() < 1e-6 { keep.pop(); }+ }+ keep.push(*p);+ }+ *line = keep;+ }+ out+}++/// Widen every impedance-controlled path the router laid at less than its target width. Returns the report:+/// per net the length widened to the target and the length left narrow.+pub fn widen_plan(router: &mut Router, plan: &mut Vec<Value>) -> Value {+ let rules: BTreeMap<String, (f64, String)> = router.rules.impedance.iter().map(|(n, ir)| (n.clone(), (ir.width, ir.layer.clone()))).collect();+ let mut report: BTreeMap<String, Value> = BTreeMap::new();+ let mut added: Vec<Value> = Vec::new();+ for e in plan.iter_mut() {+ let net = e["net"].as_str().unwrap_or("").to_string();+ let Some((target, layer_name)) = rules.get(&net).cloned() else { continue };+ let Some(layer) = layer_index(&layer_name) else { continue };+ let w = e["width"].as_f64().unwrap_or(target);+ if w + 1e-6 >= target || e.get("kelvin").is_some() || e.get("decoupling").is_some() { continue; }+ let paths: Vec<Value> = e["paths"].as_array().cloned().unwrap_or_default();+ let (mut keep_narrow, mut wide_paths) = (Vec::new(), Vec::new());+ let (mut wide_mm, mut narrow_mm) = (0.0, 0.0);+ for path in paths {+ let raw = path.as_array().cloned().unwrap_or_default();+ let pts: Vec<Pt> = raw.iter().filter_map(|v| point(&router.board, v)).collect();+ // a path that changes layer is not an impedance path the router laid (it holds them to one layer)+ if pts.len() < 2 || pts.len() != raw.len() || raw.iter().any(|v| v.get("layer").and_then(|l| l.as_str()).is_some_and(|l| l != layer_name)) { keep_narrow.push(path); continue; }+ let ps = pieces(&pts);+ let ok: Vec<bool> = ps.iter().map(|(a, b)| router.segment_clear(&net, layer, *a, *b, target)).collect();+ let rs = runs(&ps, &ok);+ // a wide run too short to matter stays narrow (no short bulges)+ let rs: Vec<(bool, Vec<Pt>)> = rs.into_iter().map(|(v, line)| { let l: f64 = line.windows(2).map(|x| dist(x[0], x[1])).sum(); (v && l >= 2.0 * target, line) }).collect();+ if !rs.iter().any(|r| r.0) { narrow_mm += ps.iter().map(|(a, b)| dist(*a, *b)).sum::<f64>(); keep_narrow.push(path); continue; }+ let at = |p: Pt| json!({"x": r6(p.0), "y": r6(p.1), "layer": layer_name});+ let n = rs.len();+ for (k, (wide, line)) in rs.into_iter().enumerate() {+ let len: f64 = line.windows(2).map(|x| dist(x[0], x[1])).sum();+ // the run's ends: the path's own first and last waypoints (pads keep their key), else points+ let mut wp: Vec<Value> = line.iter().map(|p| at(*p)).collect();+ if k == 0 { wp[0] = raw[0].clone(); }+ if k + 1 == n { *wp.last_mut().unwrap() = raw[raw.len() - 1].clone(); }+ if wide {+ wide_mm += len;+ for x in line.windows(2) { router.stamp_wide(&net, layer, x[0], x[1], target); }+ wide_paths.push(Value::Array(wp));+ } else { narrow_mm += len; keep_narrow.push(Value::Array(wp)); }+ }+ }+ if wide_paths.is_empty() { continue; }+ e["paths"] = Value::Array(keep_narrow);+ added.push(json!({"net": net, "width": target, "paths": wide_paths, "impedance": "widened to the target width (issue #52)"}));+ let r = report.entry(net).or_insert(json!({"wideMm": 0.0, "narrowMm": 0.0}));+ r["wideMm"] = json!(((r["wideMm"].as_f64().unwrap_or(0.0) + wide_mm) * 100.0).round() / 100.0);+ r["narrowMm"] = json!(((r["narrowMm"].as_f64().unwrap_or(0.0) + narrow_mm) * 100.0).round() / 100.0);+ }+ plan.retain(|e| e["paths"].as_array().is_none_or(|a| !a.is_empty()));+ plan.extend(added);+ json!({"nets": report, "rule": "an impedance net the router could only lay at the rule width is widened to its target width wherever its own clearance check passes; what stays narrow (near the pins, past an obstacle) is uncontrolled length, judged against maxUncontrolledMm"})+}++/// The extra length one 45-degree trapezoid bump of height h adds: two legs of h*sqrt2 replace 2h of line.+fn bump_gain(h: f64) -> f64 { 2.0 * h * (std::f64::consts::SQRT_2 - 1.0) }++/// A net's copper length in the plan, every path of every entry.+fn net_length(board: &aiflow_board::Board, plan: &[Value], net: &str) -> f64 {+ plan.iter().filter(|e| e["net"] == net).flat_map(|e| e["paths"].as_array().cloned().unwrap_or_default()).map(|p| {+ let pts: Vec<Pt> = p.as_array().into_iter().flatten().filter_map(|v| point(board, v)).collect();+ pts.windows(2).map(|w| dist(w[0], w[1])).sum::<f64>()+ }).sum()+}++/// Issue #52: length tuning. For each pair longer on one side than its maxSkewMm, add 45-degree trapezoid bumps+/// to the shorter net until the two match: on its narrow necks first (uncontrolled already), then on its coupled+/// run, on the side away from its partner, only where the router's own clearance check passes. A bump on the+/// coupled run is uncoupled length there, so it is the last resort; what cannot be tuned is reported.+pub fn tune_pairs(router: &mut Router, plan: &mut Vec<Value>) -> Value {+ let pairs: Vec<(String, String, f64, String)> = router.rules.impedance.iter().filter_map(|(n, ir)| {+ let p = ir.partner.clone()?;+ (n < &p).then(|| (n.clone(), p, if ir.max_skew > 0.0 { ir.max_skew } else { 0.5 }, ir.layer.clone()))+ }).collect();+ let mut report: BTreeMap<String, Value> = BTreeMap::new();+ for (a, b, max_skew, layer_name) in pairs {+ let Some(layer) = layer_index(&layer_name) else { continue };+ let skew_now = |plan: &[Value], board: &aiflow_board::Board| net_length(board, plan, &a) - net_length(board, plan, &b);+ let before = skew_now(plan, &router.board).abs();+ if before <= max_skew { continue; }+ let mut added = 0usize;+ let mut tuned: Vec<String> = Vec::new();+ for _round in 0..12 {+ let sk = skew_now(plan, &router.board);+ if sk.abs() <= max_skew { break; }+ let (short, partner) = if sk < 0.0 { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) };+ let partner_pts: Vec<Pt> = plan.iter().filter(|e| e["net"] == partner.as_str()).flat_map(|e| e["paths"].as_array().cloned().unwrap_or_default()).flat_map(|p| p.as_array().cloned().unwrap_or_default()).filter_map(|v| point(&router.board, &v)).collect();+ let near_partner = |p: Pt| partner_pts.windows(2).map(|w| seg_dist(p, w[0], w[1])).fold(f64::INFINITY, f64::min);+ // candidate segments of the shorter net: (entry, path, segment, width, length), narrow first, longest first+ let mut cands: Vec<(usize, usize, usize, f64, f64)> = Vec::new();+ for (ei, e) in plan.iter().enumerate() {+ if e["net"] != short.as_str() || e.get("kelvin").is_some() || e.get("decoupling").is_some() { continue; }+ let w = e["width"].as_f64().unwrap_or(0.2);+ for (pi, p) in e["paths"].as_array().into_iter().flatten().enumerate() {+ let pts: Vec<Pt> = p.as_array().into_iter().flatten().filter_map(|v| point(&router.board, v)).collect();+ for (si, sg) in pts.windows(2).enumerate() { cands.push((ei, pi, si, w, dist(sg[0], sg[1]))); }+ }+ }+ let narrowest = cands.iter().map(|c| c.3).fold(f64::INFINITY, f64::min);+ cands.sort_by(|x, y| ((x.3 - narrowest).abs() > 1e-6).cmp(&((y.3 - narrowest).abs() > 1e-6)).then(y.4.total_cmp(&x.4)));+ let need = sk.abs();+ let mut done = false;+ 'cand: for (ei, pi, si, w_seg, seglen) in cands {+ // a bump on a wide (coupled) run is drawn at the net's narrowest width, the wide copper split+ // around it: a short uncoupled stretch instead of a wide detour that uncouples both nets+ let w = narrowest.min(w_seg);+ let split = w_seg > w + 1e-6;+ // the bump's height: what the skew needs, at least the trace's own width; kept only if it helps+ let h_need = need / (2.0 * (std::f64::consts::SQRT_2 - 1.0));+ for h in [h_need, h_need / 2.0, h_need / 3.0].map(|h| h.max(w)) {+ if (need - bump_gain(h)).abs() >= need - 1e-6 { continue; }+ let base = 2.0 * h + 2.0 * w;+ if seglen < base + 2.0 * w { continue; }+ let path = plan[ei]["paths"][pi].as_array().cloned().unwrap_or_default();+ let pts: Vec<Pt> = path.iter().filter_map(|v| point(&router.board, v)).collect();+ if pts.len() != path.len() { continue 'cand; }+ let (p0, p1) = (pts[si], pts[si + 1]);+ let l = dist(p0, p1);+ let d = ((p1.0 - p0.0) / l, (p1.1 - p0.1) / l);+ let mid = lerp(p0, p1, 0.5);+ let s0 = (mid.0 - d.0 * base / 2.0, mid.1 - d.1 * base / 2.0);+ let n1 = (-d.1, d.0);+ let side = if near_partner((mid.0 + n1.0 * h, mid.1 + n1.1 * h)) >= near_partner((mid.0 - n1.0 * h, mid.1 - n1.1 * h)) { n1 } else { (d.1, -d.0) };+ let q1 = (s0.0 + d.0 * h + side.0 * h, s0.1 + d.1 * h + side.1 * h);+ let q2 = (q1.0 + d.0 * 2.0 * w, q1.1 + d.1 * 2.0 * w);+ let s1 = (q2.0 + d.0 * h - side.0 * h, q2.1 + d.1 * h - side.1 * h);+ if ![(s0, q1), (q1, q2), (q2, s1)].iter().all(|(x, y)| router.segment_clear(&short, layer, *x, *y, w)) { continue; }+ let at = |p: Pt| json!({"x": r6(p.0), "y": r6(p.1), "layer": layer_name});+ if split {+ // wide up to s0, the narrow bump s0..s1, wide again from s1+ let mut before: Vec<Value> = path[..=si].to_vec();+ before.push(at(s0));+ let mut after: Vec<Value> = vec![at(s1)];+ after.extend_from_slice(&path[si + 1..]);+ let mut paths = plan[ei]["paths"].as_array().cloned().unwrap_or_default();+ paths[pi] = Value::Array(before);+ paths.insert(pi + 1, Value::Array(after));+ plan[ei]["paths"] = Value::Array(paths);+ let bump = Value::Array(vec![at(s0), at(q1), at(q2), at(s1)]);+ match plan.iter().position(|e| e["net"] == short.as_str() && (e["width"].as_f64().unwrap_or(0.0) - w).abs() < 1e-6 && e.get("kelvin").is_none() && e.get("decoupling").is_none()) {+ Some(k) => { if let Some(a) = plan[k]["paths"].as_array_mut() { a.push(bump); } }+ None => plan.push(json!({"net": short, "width": w, "paths": [bump]})),+ }+ } else {+ let mut np = path.clone();+ np.splice(si + 1..si + 1, [at(s0), at(q1), at(q2), at(s1)]);+ plan[ei]["paths"][pi] = Value::Array(np);+ }+ for (x, y) in [(s0, q1), (q1, q2), (q2, s1)] { router.stamp_wide(&short, layer, x, y, w); }+ added += 1;+ if !tuned.contains(&short) { tuned.push(short.clone()); }+ done = true;+ break 'cand;+ }+ }+ if !done { break; }+ }+ let after = skew_now(plan, &router.board).abs();+ report.insert(format!("{a}/{b}"), json!({"skewBeforeMm": (before * 1000.0).round() / 1000.0, "skewAfterMm": (after * 1000.0).round() / 1000.0, "maxSkewMm": max_skew, "bumps": added, "tunedNets": tuned, "within": after <= max_skew + 1e-9}));+ }+ json!({"pairs": report, "rule": "the shorter net of a pair gets 45-degree trapezoid bumps until the skew is within maxSkewMm: on its narrow necks first, then its coupled run, away from its partner, where the router's clearance check passes"})+}++fn seg_dist(p: Pt, a: Pt, b: Pt) -> f64 {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 < 1e-12 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+ dist(p, (a.0 + t * dx, a.1 + t * dy))+}++impl Router {+ /// Mark a widened segment as this net's copper, so its partner and later nets see it.+ pub fn stamp_wide(&mut self, net_name: &str, l: usize, a: Pt, b: Pt, width: f64) {+ let Some(&k) = self.net_ids.get(net_name) else { return };+ let g = self.grid.clone();+ for c in g.segment_cells(a, b, width / 2.0, Some(1e-6)) { self.route_raw[l][c] = k as i16; }+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use crate::impedance::{targets, stackup, solve_row, plan_tracks, check_target, Fab, GridPair};+ use crate::Rules;+ use aiflow_board::Board;++ /// A 2-layer 1.6 mm board: a pair from a fine-pitch part (0.3 mm pins at 0.5 mm) to another 30 mm away.+ fn fine_pitch_pair() -> Board {+ let fp = |r: &str, x: f64| format!("(footprint \"T:{r}\" (layer \"F.Cu\") (at {x} 10) (property \"Reference\" \"{r}\") (pad \"1\" smd rect (at 0 -0.25) (size 1.0 0.3) (layers \"F.Cu\") (net \"DP\")) (pad \"2\" smd rect (at 0 0.25) (size 1.0 0.3) (layers \"F.Cu\") (net \"DN\")))");+ let t = format!("(kicad_pcb (version 20260206) (generator \"test\") (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal) (25 \"Edge.Cuts\" user)) {} {} (gr_rect (start 0 0) (end 40 20) (layer \"Edge.Cuts\")))", fp("U1", 4.0), fp("J1", 36.0));+ Board::from_text("np.kicad_pcb", t).unwrap()+ }++ /// Issue #52: 90 ohm on 1.6 mm FR4 is 1.38 mm wide against 0.3 mm pins. The pair is routed at the rule+ /// width and widened to the target between the pins: it routes at all, and the copper between is controlled.+ #[test]+ fn a_pair_too_wide_for_its_pins_is_routed_narrow_and_widened_between() {+ let b = fine_pitch_pair();+ let spec = serde_json::json!({"stackupMm": [1.51], "epsilonR": 4.5, "impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90, "maxUncontrolledMm": 8}}});+ let ts = targets(&spec).unwrap();+ let st = stackup(&b, &spec).unwrap();+ let fab = Fab { min_width: 0.127, min_space: 0.127, source: "test".into() };+ let grid = GridPair { res: aiflow_grid::RES, clearance: 0.2, slop: crate::slop() };+ let row = solve_row(&ts[0], &st.layers[0], &fab, Some(&grid));+ let w = row["widthMm"].as_f64().unwrap();+ assert!(w > 1.2, "the wide target this is about: {row}");+ let mut rules = Rules { clearance: 0.2, ..Rules::default() };+ rules.track = 0.2;+ rules.stub = 0.2;+ for (k, n) in ts[0].nets.iter().enumerate() {+ rules.impedance.insert(n.clone(), crate::rules::ImpedanceRoute { max_skew: 0.5, target: "USB".into(), width: w, layer: "F.Cu".into(), partner: Some(ts[0].nets[1 - k].clone()), pitch: row["pitchMm"].as_f64() });+ }+ let mut r = Router::new(b, rules, false);+ let (mut plan, _report, failed) = crate::drive::route_board(&mut r, 3, 8, 400);+ assert_eq!(failed, 0, "the pair routes at all (at the rule width)");+ crate::simplify::simplify_plan(&mut r, &mut plan);+ let rep = widen_plan(&mut r, &mut plan);+ let wide: f64 = ["DP", "DN"].iter().map(|n| rep["nets"][*n]["wideMm"].as_f64().unwrap_or(0.0)).sum();+ assert!(wide > 30.0, "most of the 30 mm run is at the target width: {rep}");+ assert!(plan.iter().any(|e| (e["width"].as_f64().unwrap_or(0.0) - w).abs() < 1e-9), "a plan entry at the target width");+ let (tracks, vias) = plan_tracks(&serde_json::json!({"nets": plan}), &r.board);+ let c = check_target(&ts[0], &st, &tracks, &vias, &r.board.pads, &crate::impedance::GroundCopper::default());+ eprintln!("{rep}\n{c}");+ assert!(c["uncontrolledMm"][0].as_f64().unwrap() < 8.0, "only the necks are uncontrolled: {c}");+ // the skew left by the fan-out is tuned out on the shorter net+ let before = c["skewMm"].as_f64().unwrap();+ let tune = tune_pairs(&mut r, &mut plan);+ let (tracks, vias) = plan_tracks(&serde_json::json!({"nets": plan}), &r.board);+ let c2 = check_target(&ts[0], &st, &tracks, &vias, &r.board.pads, &crate::impedance::GroundCopper::default());+ eprintln!("{tune}\n{c2}");+ assert!(before > 0.5, "the case needs tuning: {c}");+ assert!(c2["skewMm"].as_f64().unwrap() <= 0.5, "tuned within maxSkewMm: {tune} {c2}");+ assert!(c2["coupledPercent"][0].as_f64().unwrap() > 70.0 && c2["coupledPercent"][1].as_f64().unwrap() > 70.0, "still mostly coupled: {c2}");+ assert_eq!(c2["status"], "pass", "{c2}");+ }++ #[test]+ fn a_bump_adds_the_length_it_should() {+ assert!((bump_gain(1.0) - 0.8284).abs() < 1e-3);+ }++ #[test]+ fn runs_split_where_the_verdict_changes_and_drop_collinear_points() {+ let ps = pieces(&[(0.0, 0.0), (1.0, 0.0)]);+ assert_eq!(ps.len(), 10);+ let ok: Vec<bool> = (0..10).map(|k| (2..8).contains(&k)).collect();+ let r = runs(&ps, &ok);+ assert_eq!(r.len(), 3);+ assert_eq!((r[0].0, r[1].0, r[2].0), (false, true, false));+ assert_eq!(r[1].1.len(), 2, "a straight run is two points: {:?}", r[1].1);+ assert!((r[1].1[0].0 - 0.2).abs() < 1e-9 && (r[1].1[1].0 - 0.8).abs() < 1e-9);+ }+}
crates/aiflow-router/src/rules.rs+2@@ -62,6 +62,8 @@ pub struct ImpedanceRoute { pub layer: String, pub partner: Option<String>, pub pitch: Option<f64>,+ /// a pair's length mismatch budget (spec maxSkewMm, default 0.5): neckdown::tune_pairs meanders the shorter net+ pub max_skew: f64, } /// One `planeUnder` entry: `{"layer": "B.Cu", "net": "GND", "around": ["U1", "C2.1"], "margin": 1.0}`
docs/impedance.md+52−5@@ -18,6 +18,9 @@ Issue #52. A net or a differential pair can carry an impedance target. The binar - `maxSkewMm`: the length mismatch a pair may keep, default 0.5 mm. Set it from the interface's skew budget. - `maxUncontrolledMm`: the length of each net that may miss the target, default 3 mm. It covers pad breakouts and the bends of a pair. - `maxWidthMm`: refuse a layer that needs a wider trace.+- `coplanarGapMm`: a ground pour on the same outer layer this far (mm) from the copper, on both sides of a single trace and on the outer side of each net of a pair (grounded coplanar waveguide, below). `coplanarNet` names the pour's net (default the spec's `coplanarGround.net`, else `GND`).++`"coplanarGround": {"net": "GND", "gapMm": 0.2}` at the top of the spec gives every target without its own `coplanarGapMm` that pour (adom-fields' `impedance` map entries too). Both apply to outer layers only: an inner-layer row stays stripline and says the pour is not modelled there. The gap is held to the fab minimum space like a pair gap (below). adom-fields' own `impedance` map (`{"RF_OUT": 50, "USB_DP/USB_DN": 90}`) is read too, at 10 %. Its values must stay numbers: Fields refuses a spec with anything else there, so per-target objects go under `impedanceTargets`. A net may be in one target only. The targets are spec budgets: they freeze at placement like `loads` (`spec-amend` moves them, on the record). @@ -34,6 +37,22 @@ Outer layers are microstrip over the adjacent copper layer. Inner layers are str Widths and gaps are held to the board's `.kicad_dru` minimums (track_width, clearance), the spec's `minTrackWidth` and the board clearance (spec `clearance`, else the router's 0.2 mm). A layer where the target needs a narrower track or gap is refused, with the reason. +## Grounded coplanar: a ground pour beside the trace++On a two-layer 1.6 mm board a plain microstrip is wide: 90 ohm differential over KiCad's 1.51 mm FR4 (er 4.5) solves to 1.38 mm traces, far wider than a USB hub's 0.3 mm pins (StickHub, eight USB 2 pairs: nothing routed). The usual answer is a ground pour on the same layer beside the trace or pair at a small gap, stitched with vias to the plane under it. The field then closes mostly sideways onto the pour, and the line narrows to a few tenths of a millimetre.++With `coplanarGapMm` (or `coplanarGround`) the outer-layer rows are that structure, labelled `grounded coplanar ... with a GND pour 0.2 mm beside it (assumed: pour it at that clearance, stitched to the plane)`; the JSON row carries `coplanarGapMm`, `coplanarNet` and `assumes`. The pour has to be made: KiCad pours a zone at the larger of the zone's clearance (spec `pours[].clearance`, default 0.2 mm) and the nets' own clearance, so set it to the gap, and stitch the pour to the plane under it along the line.++StickHub on its own stackup (1.51 mm, er 4.5, 35 um, the router's 0.2 mm clearance), 90 ohm differential with a GND pour 0.2 mm beside the pair:++| Router | Width | Pair gap | Closed form | Field solve |+|---|---|---|---|---|+| Grid router (pitch 1.2 mm on its 0.1 mm grid) | 0.8485 | 0.3515 | 90.0 | 88.76 |+| AI router at the 0.2 mm clearance | 0.546 | 0.2 | 90.0 | 88.57 |+| AI router at a 0.15 mm clearance (pour and pair gap) | 0.379 | 0.15 | 90.0 | 88.46 |++Only the last enters the 0.3 mm pins without a neck-down (`pin_reach` allows 0.1 mm over the pad). With a 0.2 mm board clearance the table names it: `... or at a 0.15 mm clearance for these nets and the pour, if the fab allows: width 0.379 mm, gap 0.15 mm -> 90.0 ohm (an AI router)`.+ ## `adom-aiflow impedance table` ```@@ -44,6 +63,8 @@ adom-aiflow impedance check [--plan plan.json] # judge the board's copper (or One row per target and copper layer: the structure, dielectric, permittivity and copper, the width (and gap) and the impedance they give, or REFUSED and why. A layer is refused when the target is out of reach, the width or gap is under the fab minimum, or the geometry is outside the formula's validated range (it is never extrapolated). The last lines say which layer `route` will use. +When a target without a coplanar key solves to a microstrip width wider than the pins it must reach (the `FINDING` line), or no outer layer can reach it (`route refuses`, then a `SUGGESTION` line), the table names the grounded coplanar line the same layer would take with a pour beside it at the smallest gap the board clearance allows (rounded up to 0.01 mm): the grid router's width and gap, the narrowest an AI router could use off the grid pitch, whether that enters the pins, and if not, the largest smaller clearance (down to the `.kicad_dru` clearance, else 0.1 mm) at which it would, as a question for the fab. Nothing else in the table changes for a spec without coplanar keys.+ On an outer layer a pair's gap is chosen so its centre pitch sits on the grid router's 0.1 mm grid and is reachable at the board clearance on straight and 45 degree runs (pitch 0.5, 0.7, 1.0 ... mm). An AI router may use any width and gap: set `gapMm` or `widthMm` and the table solves the other. ## The formulas@@ -57,6 +78,8 @@ All quasi-static and lossless, without solder mask: | Offset stripline | parallel combination of two symmetric lines (b = 2h + t) | Wadell 3.5.3 | | Edge-coupled stripline | Cohn, exact at zero thickness, with his thickness correction | IRE Trans. MTT 3 (1955); Wadell 3.6.1 | | Edge-coupled microstrip | Garg and Bahl even and odd capacitances, Hammerstad-Jensen single-line terms, 2 e0 t / s sidewall term | IEEE Trans. MTT 27 (1979); Gupta et al. 8.4 |+| Grounded coplanar waveguide | conductor-backed CPW by conformal mapping (partial capacitances, slots as magnetic walls), sidewall term e0 t / g per gap | Ghione and Naldi, IEEE Trans. MTT 35 (1987); Wadell 3.4.3 |+| Edge-coupled grounded coplanar | the same maps on the odd and even half structures (z -> z^2 above, z -> cosh(pi z / h) in the dielectric), sidewall terms e0 t / g and, odd mode, 2 e0 t / s | as above; reduces to Ghione-Naldi for one strip | Wadell: Transmission Line Design Handbook, Artech House 1991. @@ -77,17 +100,39 @@ The reference is an independent 2-D finite-volume Laplace solve of the same cros | Pair, 1.6 mm er 4.5, w 1.0 s 0.5 (Zdiff) | 111.44 | 108.57 | 2.6 % | | Coupled stripline, b 0.5, w 0.15 s 0.15 (Zdiff) | 84.95 | 84.19 | 0.9 % | -Also checked: Wheeler at zero thickness against Cohn's exact stripline (under 0.5 %), Pozar's 50 ohm example (er 2.2, 1.27 mm: w 3.91 mm, eeff 1.87), and that 50 ohm on 1.6 mm FR4 at er 4.5 solves to 2.9 to 3.0 mm.+| Grounded coplanar, 1.6 mm FR4 er 4.5, w 0.802, pour gap 0.15 | 50.00 | 49.37 | 1.3 % |+| Grounded coplanar, JLC 7628 0.2104 mm er 4.4, w 0.3095, gap 0.15 | 50.00 | 48.95 | 2.1 % |+| Grounded coplanar, h 0.508 er 3.66, w 0.8, gap 0.2 | 48.93 | 48.29 | 1.3 % |+| Grounded coplanar, h 0.635 er 9.8, w 0.3, gap 0.15, 18 um | 47.21 | 46.60 | 1.3 % |+| Grounded coplanar, h 1.0 er 4.4, w 0.2, gap 1.0 (range edge, g/h 1) | 119.55 | 114.86 | 4.1 % |+| Coplanar pair, StickHub 1.51 mm er 4.5, w 0.546 s 0.2 gap 0.2 (Zdiff) | 90.00 | 88.57 | 1.6 % |+| Coplanar pair, 1.51 mm er 4.5, w 0.379 s 0.15 gap 0.15 (Zdiff) | 90.00 | 88.46 | 1.7 % |+| Coplanar pair, 1.51 mm er 4.5, w 0.8485 s 0.3515 gap 0.2 (Zdiff, grid pitch) | 90.00 | 88.76 | 1.4 % |+| Coplanar pair, JLC 7628, w 0.2688 s 0.15 gap 0.15 (Zdiff) | 90.00 | 88.09 | 2.2 % |+| Coplanar pair, h 0.508 er 3.66, w 0.3 s 0.2 gap 0.2 (Zdiff) | 114.18 | 111.77 | 2.2 % |+| Coplanar pair, h 1.0 er 4.4, w 0.2 s 0.6 gap 0.5 (Zdiff, range edge) | 177.82 | 172.26 | 3.2 % |++The coplanar range comes from a sweep of 365 cross-sections (`python3 tools/impedance_reference.py --coplanar-sweep`, kept in `tests/fixtures/coplanar-reference.jsonl`; er 4.4, h 0.1 to 1.6 mm, w 0.1 to 1.5 mm, gap 0.1 to 1.0 mm, pair gap 0.1 to 0.6 mm, copper 4 to 70 um). The closed form always reads high, by 0.6 to 4.1 % over 0.06 <= w/h <= 15, 0.06 <= g/h <= 1, 0.06 <= s/h <= 2 and w at least four copper thicknesses (200 of them; a unit test runs the whole file). Outside that it is refused, not extrapolated: past g/h 1 the dielectric under the gap carries the field the slot model leaves out (4.4 % at g/h 1.5, 6.8 % at 2, 16 % at 10), and a strip under four copper thicknesses wide reads up to 6.9 % high. Without the sidewall term the zero-thickness form reads 2.7 to 26 % high inside that range (18 to 70 um copper); Gupta's effective-width correction for thick coplanar lines instead swings from +4 % to -72 % there (it over-narrows small gaps), so it is not used. The field solve takes the pour out to the box side; a pour only 0.3 mm wide raises a single line 1.8 % (50.25 ohm for the first row) and the StickHub pair 0.3 %.++Also checked: Wheeler at zero thickness against Cohn's exact stripline (under 0.5 %), Pozar's 50 ohm example (er 2.2, 1.27 mm: w 3.91 mm, eeff 1.87), that 50 ohm on 1.6 mm FR4 at er 4.5 solves to 2.9 to 3.0 mm, and that the single coplanar line at zero thickness is exactly Wadell's conductor-backed CPW formula and, with the plane far away, the plain coplanar waveguide. Refused because the error grows: coupled microstrip with s/h under 0.2 (5.7 % at s/h 0.125) and offset stripline past 3:1 (7 % at 5:1, which is JLC's four-layer In1.Cu and In2.Cu; route those inner layers with the fab's calculator). -Not modelled: solder mask (it lowers an outer trace by roughly 1 to 3 %), etch trapezoid, glass weave, Dk tolerance and frequency dependence, plane voids. This is a routing screen. For an impedance-controlled order the fab's own stackup calculation is the reference.+Not modelled: solder mask (it lowers an outer trace by roughly 1 to 3 %; it also fills a coplanar gap, which lowers that line more), etch trapezoid, glass weave, Dk tolerance and frequency dependence, plane voids, and for a coplanar line the stitching vias (taken as an unbroken connection to the plane) and a pour narrower than about the gap. This is a routing screen. For an impedance-controlled order the fab's own stackup calculation is the reference. ## `route` With targets in the spec, `route` sizes each target net from the table, on the outer layer its SMD pads are on (else F.Cu, else the other outer layer), and holds it to that layer (no vias). It refuses before routing when no outer layer can reach a target, and says `take route=ai`. -A pair is routed as two nets, one after the other. The second follows the first: every grid step off the line at the solved centre pitch from its partner costs extra, so it runs beside it at the target gap wherever the obstacles allow. Escape stubs use the target width when the pads are at least that wide; narrower pads keep the rule stub and their breakout is uncontrolled length. The router does not length-tune.+A pair is routed as two nets, one after the other. The second follows the first: every grid step off the line at the solved centre pitch from its partner costs extra, so it runs beside it at the target gap wherever the obstacles allow. Escape stubs use the target width when the pads are at least that wide; narrower pads keep the rule stub and their breakout is uncontrolled length.++**Pins narrower than the target width (neck-down).** A target can solve far wider than the pins it reaches: 90 ohm differential on a 2-layer 1.6 mm board is 1.38 mm against a USB hub's 0.3 mm pins (StickHub). Such a net is also tried at the rule width (a pair at its solved centre pitch), and after the router a pass widens its copper back to the target width wherever the router's own clearance check passes, in 0.1 mm pieces. What stays narrow (the necks at the pins, a stretch past an obstacle) is the net's uncontrolled length, judged against `maxUncontrolledMm`; a fine-pitch fan-out often needs more than the 3 mm default, so set it from the interface's budget. `plan-route-report.json` `"neckdown"` gives each net's widened and narrow length.++**Length tuning.** When a pair's two nets differ by more than `maxSkewMm`, the shorter one gets 45-degree trapezoid bumps sized to the difference: on its narrow necks first, else on its coupled run, drawn at the rule width with the wide copper split around it (a short uncoupled stretch), on the side away from its partner, only where the clearance check passes. `"lengthTuning"` in the report gives the skew before and after and the bumps. A bump is uncoupled length; the check weighs both.++Measured on a fine-pitch pair (0.3 mm pins at 0.5 mm, 30 mm apart, 2 layers, 1.6 mm, 90 ohm): it routes (before: no copper), 2.6 mm of each net stays narrow at the pins, skew 0.99 mm is tuned to 0 with one bump, and the pair is 76 % coupled at 90.0 ohm with 7.8 mm uncontrolled per net.++A coplanar target is sized for its pour (the outer row is grounded coplanar); the route's line for it says the pour is assumed until it is poured. The router does not make the pour: the pours stage does, at its zone clearance. After routing it judges the plan's copper the way `analyze impedance` does and prints, per target, the width, gap, impedance, the coupled percentage of each net and the skew. When the pair was not kept together (an obstacle between the nets, a split around a part) it says so as a WARNING naming the pair for `take route=ai`. It does not claim coupling the copper does not have. @@ -97,6 +142,8 @@ Judges every target on the landed copper (the plan's for a net not landed yet). - A single net: a sample is controlled when its width on its layer gives the target within tolerance. - A pair: a sample is controlled when a parallel equal-width partner beside it at its real gap gives the target within tolerance.+- A coplanar target on an outer layer where its ground net has a filled zone: the pour is measured. From each sample the ground copper (the net's zone fills, pads and tracks on that layer) is looked for square to the line, on both sides of a single trace and on the outer side of each net of a pair, as far as the larger of three gaps and the dielectric height. The sample is controlled only when the gap found there gives the target within tolerance (each side on its own, as if both had it); no ground within reach is uncontrolled length. The result carries `coplanar.measuredOn`, `measuredGapMm` and `withoutGroundMm`.+- A coplanar target where the net has no filled zone on that layer (not poured yet, or a plan's copper): judged at the target's gap, and the line and `coplanar.note` say the pour is ASSUMED, not measured, and to pour it and check again. `finish` runs the check on the live board, where the pour is normally filled and so measured. - The target passes when each net's uncontrolled length is at most `maxUncontrolledMm`, a pair's skew is at most `maxSkewMm`, and a pair stays on one layer without vias. With a same-input Adom Fields report (`<run>/fields/fields.json` or `--fields`, its `inputIdentity` the saved board and spec), it compares:@@ -104,7 +151,7 @@ With a same-input Adom Fields report (`<run>/fields/fields.json` or `--fields`, - **Signals** (`signals.differentialPairs`, Fields 0.4+): its estimate of each pair. Its formula is the IPC-2141 approximation and it judges every sample, pad breakouts included; any sample without a parallel equal-width partner (a fan-out, a bend) makes it refuse to estimate. A refusal is reported, never replaced by the closed form. - **The 2-D cross-section solve** (`--with si`, `review.impedance`): Zdiff of each pair at its median gap, with solder mask. Fields finds pairs by name (`_P`/`_N`, `+`/`-`, `DP`/`DM`, `P`/`N`); `USB_DP`/`USB_DN` is not matched (it tries `USB_DM`), so such a pair has no cross-section row. -A Fields value outside the target's tolerance fails the target too. When no Signals entry exists, the hint prints the `signals.differentialPairs` entry to add (layer, reference layer and net, dielectricMm and epsilonR from this stackup).+A Fields value outside the target's tolerance fails the target too, except for a coplanar target: Fields' Signals and its cross-section solve model a microstrip without the same-layer pour, so for those its values are reported, not compared, and no Signals entry is suggested. When no Signals entry exists, the hint prints the `signals.differentialPairs` entry to add (layer, reference layer and net, dielectricMm and epsilonR from this stackup). ## What the router does today, measured @@ -119,4 +166,4 @@ On a four-layer JLC 7628 board (F.Cu over 0.2104 mm, er 4.4, 35 um), 90 ohm diff The Fields 2-D solve sits about 5 % under the closed form on this geometry; the solder mask it includes accounts for part of that. Both are inside 10 %. -Boards with several pairs crossing, length-matched lane groups, or pairs that must change layer are still the AI's to route (High-speed boards in SKILL.md); `impedance table` gives the widths and gaps for that work and `analyze impedance` judges it the same way.+Boards with several pairs crossing, length-matched lane groups (tuning matches the two nets of a pair, not a group), or pairs that must change layer are still the AI's to route (High-speed boards in SKILL.md); `impedance table` gives the widths and gaps for that work and `analyze impedance` judges it the same way.
docs/release-0.2.73.mdadded+22@@ -0,0 +1,22 @@+# AI Flow 0.2.73++Issue #52 (Drew), the three pieces a real board's run asked for (StickHub: eight USB 2 pairs, 2 layers, 1.6 mm,+where 90 ohm microstrip is 1.38 mm against 0.3 mm hub pins and no copper landed):++- **Neck-down at the pins.** A target net whose pins are narrower than its width is also routed at the rule+ width (a pair at its solved pitch), and a pass then widens its copper back to the target width wherever the+ router's own clearance check passes. Only the necks at the pins (and a stretch past an obstacle) stay narrow,+ judged as uncontrolled length against `maxUncontrolledMm`. Report: `"neckdown"` in plan-route-report.json.+- **Length tuning.** A pair whose nets differ by more than `maxSkewMm` gets 45-degree trapezoid bumps on its+ shorter net, sized to the difference: on its narrow necks first, else on its coupled run drawn at the rule+ width with the wide copper split around it, on the side away from its partner, where it clears. Report:+ `"lengthTuning"`. On a fine-pitch test pair (0.3 mm pins, 30 mm, 2 layers, 1.6 mm, 90 ohm): it routes, skew+ 0.99 mm is tuned to 0 with one bump, 76 % coupled at 90.0 ohm.+- **Grounded coplanar lines** (`coplanarGapMm` on a target, or spec `coplanarGround: {"net": "GND", "gapMm": g}`):+ conductor-backed coplanar formulas for a line and a pair (Ghione-Naldi conformal mapping with a sidewall+ thickness term), validated against the 2-D field solve to +0.6 to +4.1 % over 200 in-range cross-sections+ (365 in tests/fixtures/coplanar-reference.jsonl); outside the validated range a row is refused, never+ extrapolated. `impedance table` suggests the coplanar width when a microstrip width misses the pins (StickHub:+ 0.849 mm / 0.352 mm gap on the grid router's pitch with a 0.2 mm pour gap, 0.379 mm / 0.15 mm off it).+ `analyze impedance` measures the ground copper beside the line once poured, or says the pour is assumed.+ The pours stage's zone clearance still has to be set to the gap by hand.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.72",+ "version": "0.2.73", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.72",+ "version": "0.2.73", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+1−1@@ -65,7 +65,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- ### High-speed boards: your own router, inside the flow -Impedance targets go in the spec before routing: `"impedanceTargets": {"USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90}, "RF": {"net": "RF_OUT", "targetOhms": 50}}` (`tolerancePercent` 10, `gapMm`, `widthMm`, `layer`, `maxSkewMm` 0.5, `maxUncontrolledMm` 3 optional), with the stackup from spec `stackupMm` and `epsilonR` or the board's saved physical stackup. `impedance table` prints the width (and pair gap) per copper layer from closed-form formulas (Hammerstad-Jensen, Wheeler, Cohn, Garg-Bahl; within 0.2 to 3.4 % of a 2-D field solve, no solder mask) and refuses a layer the target cannot reach within the fab minimums. `route` then sizes those nets, holds each to one outer layer and routes a pair's second net along the first at the solved pitch; it prints the coupling it achieved and names any pair it could not keep together for `take route=ai`. `analyze impedance` judges the landed copper (and a same-input Fields Signals or `--with si` solve beside it); `finish` requires it. docs/impedance.md has the formulas, their measured accuracy and what the router really does.+Impedance targets go in the spec before routing: `"impedanceTargets": {"USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90}, "RF": {"net": "RF_OUT", "targetOhms": 50}}` (`tolerancePercent` 10, `gapMm`, `widthMm`, `layer`, `maxSkewMm` 0.5, `maxUncontrolledMm` 3 optional), with the stackup from spec `stackupMm` and `epsilonR` or the board's saved physical stackup. `impedance table` prints the width (and pair gap) per copper layer from closed-form formulas (Hammerstad-Jensen, Wheeler, Cohn, Garg-Bahl; within 0.2 to 3.4 % of a 2-D field solve, no solder mask) and refuses a layer the target cannot reach within the fab minimums. On a two-layer board, where a microstrip is wide (90 ohm over 1.51 mm FR4 is 1.38 mm), a ground pour on the same layer beside the line (`coplanarGapMm` on the target, or spec `"coplanarGround": {"net": "GND", "gapMm": 0.2}`) makes the outer rows grounded coplanar, a few tenths of a millimetre wide; the table suggests it when a microstrip width misses the pins, the pours stage must pour it at that clearance, and `analyze impedance` measures it once filled (and says when it is only assumed). `route` then sizes those nets, holds each to one outer layer and routes a pair's second net along the first at the solved pitch; it prints the coupling it achieved and names any pair it could not keep together for `take route=ai`. A target wider than its pins (90 ohm on a 2-layer 1.6 mm board is 1.38 mm) is routed at the rule width and widened back to the target where it clears, narrow only at the necks (set `maxUncontrolledMm` for a fine-pitch fan-out), and a pair's skew over `maxSkewMm` is tuned with bumps on its shorter net (docs/impedance.md). `analyze impedance` judges the landed copper (and a same-input Fields Signals or `--with si` solve beside it); `finish` requires it. docs/impedance.md has the formulas, their measured accuracy and what the router really does. A simple pair (one USB 2 pair, a 50 ohm feed) can go through `route` this way. A board with several pairs, length-matched lane groups or layer changes (PCIe, OCuLink, USB 3, Ethernet), or a pair `route` flagged, is beyond the grid router, so you route it yourself, but the work still happens inside the flow. Declare each step as you reach it (`step rules`, `step components`, ... `step routing`); your own schematic, placement or router scripts do the step's work, they do not replace the step. For routing:
tests/fixtures/coplanar-reference.jsonladded+365@@ -0,0 +1,365 @@+{"h": 0.1, "w": 0.1, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 57.123}+{"h": 0.1, "w": 0.1, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 60.464}+{"h": 0.1, "w": 0.1, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 62.537}+{"h": 0.1, "w": 0.1, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 63.302}+{"h": 0.1, "w": 0.1, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 63.426}+{"h": 0.1, "w": 0.2, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 41.478}+{"h": 0.1, "w": 0.2, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 43.412}+{"h": 0.1, "w": 0.2, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 44.653}+{"h": 0.1, "w": 0.2, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 45.151}+{"h": 0.1, "w": 0.2, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 45.241}+{"h": 0.1, "w": 0.4, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 27.409}+{"h": 0.1, "w": 0.4, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 28.322}+{"h": 0.1, "w": 0.4, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 28.925}+{"h": 0.1, "w": 0.4, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 29.191}+{"h": 0.1, "w": 0.4, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 29.246}+{"h": 0.1, "w": 0.8, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 16.621}+{"h": 0.1, "w": 0.8, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 16.977}+{"h": 0.1, "w": 0.8, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 17.218}+{"h": 0.1, "w": 0.8, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 17.332}+{"h": 0.1, "w": 0.8, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 17.36}+{"h": 0.1, "w": 1.5, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 9.95}+{"h": 0.1, "w": 1.5, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 10.082}+{"h": 0.1, "w": 1.5, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 10.173}+{"h": 0.1, "w": 1.5, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 10.219}+{"h": 0.1, "w": 1.5, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 10.231}+{"h": 0.2104, "w": 0.1, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 68.053}+{"h": 0.2104, "w": 0.1, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 75.702}+{"h": 0.2104, "w": 0.1, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 82.668}+{"h": 0.2104, "w": 0.1, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 86.757}+{"h": 0.2104, "w": 0.1, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 87.644}+{"h": 0.2104, "w": 0.2, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 54.066}+{"h": 0.2104, "w": 0.2, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 59.35}+{"h": 0.2104, "w": 0.2, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 64.103}+{"h": 0.2104, "w": 0.2, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 67.093}+{"h": 0.2104, "w": 0.2, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 67.719}+{"h": 0.2104, "w": 0.4, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 39.972}+{"h": 0.2104, "w": 0.4, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 43.003}+{"h": 0.2104, "w": 0.4, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 45.717}+{"h": 0.2104, "w": 0.4, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 47.479}+{"h": 0.2104, "w": 0.4, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 47.893}+{"h": 0.2104, "w": 0.8, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 27.04}+{"h": 0.2104, "w": 0.8, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 28.468}+{"h": 0.2104, "w": 0.8, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 29.732}+{"h": 0.2104, "w": 0.8, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 30.577}+{"h": 0.2104, "w": 0.8, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 30.801}+{"h": 0.2104, "w": 1.5, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 17.581}+{"h": 0.2104, "w": 1.5, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 18.197}+{"h": 0.2104, "w": 1.5, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 18.735}+{"h": 0.2104, "w": 1.5, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 19.101}+{"h": 0.2104, "w": 1.5, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 19.209}+{"h": 0.5, "w": 0.1, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 72.508}+{"h": 0.5, "w": 0.1, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 83.351}+{"h": 0.5, "w": 0.1, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 96.494}+{"h": 0.5, "w": 0.1, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 110.187}+{"h": 0.5, "w": 0.1, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 116.332}+{"h": 0.5, "w": 0.2, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 60.653}+{"h": 0.5, "w": 0.2, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 69.272}+{"h": 0.5, "w": 0.2, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 79.79}+{"h": 0.5, "w": 0.2, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 91.037}+{"h": 0.5, "w": 0.2, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 96.06}+{"h": 0.5, "w": 0.4, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 49.297}+{"h": 0.5, "w": 0.4, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 55.435}+{"h": 0.5, "w": 0.4, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 62.891}+{"h": 0.5, "w": 0.4, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 70.86}+{"h": 0.5, "w": 0.4, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 74.521}+{"h": 0.5, "w": 0.8, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 38.197}+{"h": 0.5, "w": 0.8, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 41.974}+{"h": 0.5, "w": 0.8, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 46.431}+{"h": 0.5, "w": 0.8, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 51.113}+{"h": 0.5, "w": 0.8, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 53.331}+{"h": 0.5, "w": 1.5, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 28.327}+{"h": 0.5, "w": 1.5, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 30.409}+{"h": 0.5, "w": 1.5, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 32.776}+{"h": 0.5, "w": 1.5, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 35.201}+{"h": 0.5, "w": 1.5, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 36.379}+{"h": 1.0, "w": 0.1, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 73.382}+{"h": 1.0, "w": 0.1, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 85.011}+{"h": 1.0, "w": 0.1, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 100.218}+{"h": 1.0, "w": 0.1, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 120.028}+{"h": 1.0, "w": 0.1, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 134.781}+{"h": 1.0, "w": 0.2, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 62.046}+{"h": 1.0, "w": 0.2, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 71.628}+{"h": 1.0, "w": 0.2, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 84.431}+{"h": 1.0, "w": 0.2, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 101.793}+{"h": 1.0, "w": 0.2, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 114.859}+{"h": 1.0, "w": 0.4, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 51.861}+{"h": 1.0, "w": 0.4, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 59.248}+{"h": 1.0, "w": 0.4, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 69.276}+{"h": 1.0, "w": 0.4, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 83.135}+{"h": 1.0, "w": 0.4, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 93.772}+{"h": 1.0, "w": 0.8, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 42.793}+{"h": 1.0, "w": 0.8, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 48.068}+{"h": 1.0, "w": 0.8, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 55.16}+{"h": 1.0, "w": 0.8, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 64.895}+{"h": 1.0, "w": 0.8, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 72.397}+{"h": 1.0, "w": 1.5, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 34.843}+{"h": 1.0, "w": 1.5, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 38.37}+{"h": 1.0, "w": 1.5, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 42.967}+{"h": 1.0, "w": 1.5, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 49.073}+{"h": 1.0, "w": 1.5, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 53.711}+{"h": 1.6, "w": 0.1, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 73.558}+{"h": 1.6, "w": 0.1, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 85.357}+{"h": 1.6, "w": 0.1, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 101.051}+{"h": 1.6, "w": 0.1, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 122.748}+{"h": 1.6, "w": 0.1, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 142.198}+{"h": 1.6, "w": 0.2, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 62.35}+{"h": 1.6, "w": 0.2, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 72.157}+{"h": 1.6, "w": 0.2, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 85.544}+{"h": 1.6, "w": 0.2, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 104.95}+{"h": 1.6, "w": 0.2, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 122.706}+{"h": 1.6, "w": 0.4, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 52.459}+{"h": 1.6, "w": 0.4, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 60.171}+{"h": 1.6, "w": 0.4, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 70.946}+{"h": 1.6, "w": 0.4, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 87.077}+{"h": 1.6, "w": 0.4, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 102.325}+{"h": 1.6, "w": 0.8, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 44.071}+{"h": 1.6, "w": 0.8, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 49.841}+{"h": 1.6, "w": 0.8, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 57.924}+{"h": 1.6, "w": 0.8, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 70.166}+{"h": 1.6, "w": 0.8, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 81.948}+{"h": 1.6, "w": 1.5, "g": 0.1, "s": null, "t": 0.035, "er": 4.4, "z": 37.254}+{"h": 1.6, "w": 1.5, "g": 0.15, "s": null, "t": 0.035, "er": 4.4, "z": 41.455}+{"h": 1.6, "w": 1.5, "g": 0.25, "s": null, "t": 0.035, "er": 4.4, "z": 47.232}+{"h": 1.6, "w": 1.5, "g": 0.5, "s": null, "t": 0.035, "er": 4.4, "z": 55.824}+{"h": 1.6, "w": 1.5, "g": 1.0, "s": null, "t": 0.035, "er": 4.4, "z": 64.007}+{"h": 0.1, "w": 0.1, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 97.461}+{"h": 0.1, "w": 0.1, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 100.203}+{"h": 0.1, "w": 0.1, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 100.767}+{"h": 0.1, "w": 0.1, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 105.686}+{"h": 0.1, "w": 0.1, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 109.06}+{"h": 0.1, "w": 0.1, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 109.79}+{"h": 0.1, "w": 0.1, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 114.851}+{"h": 0.1, "w": 0.1, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 119.163}+{"h": 0.1, "w": 0.1, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 120.194}+{"h": 0.1, "w": 0.1, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 118.533}+{"h": 0.1, "w": 0.1, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 123.411}+{"h": 0.1, "w": 0.1, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 124.692}+{"h": 0.1, "w": 0.2, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 72.505}+{"h": 0.1, "w": 0.2, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 74.286}+{"h": 0.1, "w": 0.2, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 74.72}+{"h": 0.1, "w": 0.2, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 77.323}+{"h": 0.1, "w": 0.2, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 79.405}+{"h": 0.1, "w": 0.2, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 79.931}+{"h": 0.1, "w": 0.2, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 82.816}+{"h": 0.1, "w": 0.2, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 85.331}+{"h": 0.1, "w": 0.2, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 86.011}+{"h": 0.1, "w": 0.2, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 85.274}+{"h": 0.1, "w": 0.2, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 88.063}+{"h": 0.1, "w": 0.2, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 88.872}+{"h": 0.1, "w": 0.4, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 49.346}+{"h": 0.1, "w": 0.4, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 50.291}+{"h": 0.1, "w": 0.4, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 50.567}+{"h": 0.1, "w": 0.4, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 51.676}+{"h": 0.1, "w": 0.4, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 52.73}+{"h": 0.1, "w": 0.4, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 53.043}+{"h": 0.1, "w": 0.4, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 54.379}+{"h": 0.1, "w": 0.4, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 55.582}+{"h": 0.1, "w": 0.4, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 55.953}+{"h": 0.1, "w": 0.4, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 55.729}+{"h": 0.1, "w": 0.4, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 57.029}+{"h": 0.1, "w": 0.4, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 57.449}+{"h": 0.1, "w": 0.8, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 30.82}+{"h": 0.1, "w": 0.8, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 31.23}+{"h": 0.1, "w": 0.8, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 31.369}+{"h": 0.1, "w": 0.8, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 31.761}+{"h": 0.1, "w": 0.8, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 32.2}+{"h": 0.1, "w": 0.8, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 32.351}+{"h": 0.1, "w": 0.8, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 32.862}+{"h": 0.1, "w": 0.8, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 33.34}+{"h": 0.1, "w": 0.8, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 33.507}+{"h": 0.1, "w": 0.8, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 33.464}+{"h": 0.1, "w": 0.8, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 33.968}+{"h": 0.1, "w": 0.8, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 34.148}+{"h": 0.2104, "w": 0.1, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 106.951}+{"h": 0.2104, "w": 0.1, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 112.511}+{"h": 0.2104, "w": 0.1, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 114.559}+{"h": 0.2104, "w": 0.1, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 119.377}+{"h": 0.2104, "w": 0.1, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 126.699}+{"h": 0.2104, "w": 0.1, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 129.51}+{"h": 0.2104, "w": 0.1, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 137.396}+{"h": 0.2104, "w": 0.1, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 148.016}+{"h": 0.2104, "w": 0.1, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 152.445}+{"h": 0.2104, "w": 0.1, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 147.698}+{"h": 0.2104, "w": 0.1, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 160.824}+{"h": 0.2104, "w": 0.1, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 166.766}+{"h": 0.2104, "w": 0.2, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 86.544}+{"h": 0.2104, "w": 0.2, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 90.893}+{"h": 0.2104, "w": 0.2, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 92.71}+{"h": 0.2104, "w": 0.2, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 95.286}+{"h": 0.2104, "w": 0.2, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 100.696}+{"h": 0.2104, "w": 0.2, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 103.02}+{"h": 0.2104, "w": 0.2, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 108.188}+{"h": 0.2104, "w": 0.2, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 115.503}+{"h": 0.2104, "w": 0.2, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 118.828}+{"h": 0.2104, "w": 0.2, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 115.815}+{"h": 0.2104, "w": 0.2, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 124.57}+{"h": 0.2104, "w": 0.2, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 128.798}+{"h": 0.2104, "w": 0.4, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 66.217}+{"h": 0.2104, "w": 0.4, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 69.048}+{"h": 0.2104, "w": 0.4, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 70.365}+{"h": 0.2104, "w": 0.4, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 71.501}+{"h": 0.2104, "w": 0.4, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 74.845}+{"h": 0.2104, "w": 0.4, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 76.427}+{"h": 0.2104, "w": 0.4, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 79.198}+{"h": 0.2104, "w": 0.4, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 83.406}+{"h": 0.2104, "w": 0.4, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 85.467}+{"h": 0.2104, "w": 0.4, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 83.947}+{"h": 0.2104, "w": 0.4, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 88.804}+{"h": 0.2104, "w": 0.4, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 91.279}+{"h": 0.2104, "w": 0.8, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 46.728}+{"h": 0.2104, "w": 0.8, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 48.235}+{"h": 0.2104, "w": 0.8, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 49.005}+{"h": 0.2104, "w": 0.8, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 49.394}+{"h": 0.2104, "w": 0.8, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 51.092}+{"h": 0.2104, "w": 0.8, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 51.967}+{"h": 0.2104, "w": 0.8, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 53.188}+{"h": 0.2104, "w": 0.8, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 55.185}+{"h": 0.2104, "w": 0.8, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 56.233}+{"h": 0.2104, "w": 0.8, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 55.606}+{"h": 0.2104, "w": 0.8, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 57.824}+{"h": 0.2104, "w": 0.8, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 59.013}+{"h": 1.0, "w": 0.1, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 108.644}+{"h": 1.0, "w": 0.1, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 115.474}+{"h": 1.0, "w": 0.1, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 119.572}+{"h": 1.0, "w": 0.1, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 122.289}+{"h": 1.0, "w": 0.1, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 131.661}+{"h": 1.0, "w": 0.1, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 137.685}+{"h": 1.0, "w": 0.1, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 144.791}+{"h": 1.0, "w": 0.1, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 160.044}+{"h": 1.0, "w": 0.1, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 171.391}+{"h": 1.0, "w": 0.1, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 163.613}+{"h": 1.0, "w": 0.1, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 186.001}+{"h": 1.0, "w": 0.1, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 205.565}+{"h": 1.0, "w": 0.2, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 90.724}+{"h": 1.0, "w": 0.2, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 96.98}+{"h": 1.0, "w": 0.2, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 101.558}+{"h": 1.0, "w": 0.2, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 101.41}+{"h": 1.0, "w": 0.2, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 109.579}+{"h": 1.0, "w": 0.2, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 115.841}+{"h": 1.0, "w": 0.2, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 120.114}+{"h": 1.0, "w": 0.2, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 132.651}+{"h": 1.0, "w": 0.2, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 143.287}+{"h": 1.0, "w": 0.2, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 137.14}+{"h": 1.0, "w": 0.2, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 155.103}+{"h": 1.0, "w": 0.2, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 172.258}+{"h": 1.0, "w": 0.4, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 75.539}+{"h": 1.0, "w": 0.4, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 80.897}+{"h": 1.0, "w": 0.4, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 85.588}+{"h": 1.0, "w": 0.4, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 83.601}+{"h": 1.0, "w": 0.4, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 90.308}+{"h": 1.0, "w": 0.4, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 96.351}+{"h": 1.0, "w": 0.4, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 98.209}+{"h": 1.0, "w": 0.4, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 107.904}+{"h": 1.0, "w": 0.4, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 117.2}+{"h": 1.0, "w": 0.4, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 112.577}+{"h": 1.0, "w": 0.4, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 126.015}+{"h": 1.0, "w": 0.4, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 139.906}+{"h": 1.0, "w": 0.8, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 63.109}+{"h": 1.0, "w": 0.8, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 67.343}+{"h": 1.0, "w": 0.8, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 71.524}+{"h": 1.0, "w": 0.8, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 68.988}+{"h": 1.0, "w": 0.8, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 74.102}+{"h": 1.0, "w": 0.8, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 79.237}+{"h": 1.0, "w": 0.8, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 79.72}+{"h": 1.0, "w": 0.8, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 86.697}+{"h": 1.0, "w": 0.8, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 93.952}+{"h": 1.0, "w": 0.8, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 90.666}+{"h": 1.0, "w": 0.8, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 99.923}+{"h": 1.0, "w": 0.8, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 109.953}+{"h": 1.6, "w": 0.1, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 108.647}+{"h": 1.6, "w": 0.1, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 115.482}+{"h": 1.6, "w": 0.1, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 119.606}+{"h": 1.6, "w": 0.1, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 122.296}+{"h": 1.6, "w": 0.1, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 131.678}+{"h": 1.6, "w": 0.1, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 137.747}+{"h": 1.6, "w": 0.1, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 144.82}+{"h": 1.6, "w": 0.1, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 160.106}+{"h": 1.6, "w": 0.1, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 171.593}+{"h": 1.6, "w": 0.1, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 163.753}+{"h": 1.6, "w": 0.1, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 186.281}+{"h": 1.6, "w": 0.1, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 206.337}+{"h": 1.6, "w": 0.2, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 90.743}+{"h": 1.6, "w": 0.2, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 97.015}+{"h": 1.6, "w": 0.2, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 101.653}+{"h": 1.6, "w": 0.2, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 101.442}+{"h": 1.6, "w": 0.2, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 109.636}+{"h": 1.6, "w": 0.2, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 115.993}+{"h": 1.6, "w": 0.2, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 120.2}+{"h": 1.6, "w": 0.2, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 132.806}+{"h": 1.6, "w": 0.2, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 143.674}+{"h": 1.6, "w": 0.2, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 137.428}+{"h": 1.6, "w": 0.2, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 155.605}+{"h": 1.6, "w": 0.2, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 173.418}+{"h": 1.6, "w": 0.4, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 75.628}+{"h": 1.6, "w": 0.4, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 81.034}+{"h": 1.6, "w": 0.4, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 85.874}+{"h": 1.6, "w": 0.4, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 83.729}+{"h": 1.6, "w": 0.4, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 90.507}+{"h": 1.6, "w": 0.4, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 96.762}+{"h": 1.6, "w": 0.4, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 98.479}+{"h": 1.6, "w": 0.4, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 108.324}+{"h": 1.6, "w": 0.4, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 118.046}+{"h": 1.6, "w": 0.4, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 113.251}+{"h": 1.6, "w": 0.4, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 127.053}+{"h": 1.6, "w": 0.4, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 141.907}+{"h": 1.6, "w": 0.8, "g": 0.1, "s": 0.1, "t": 0.035, "er": 4.4, "z": 63.573}+{"h": 1.6, "w": 0.8, "g": 0.2, "s": 0.1, "t": 0.035, "er": 4.4, "z": 67.958}+{"h": 1.6, "w": 0.8, "g": 0.5, "s": 0.1, "t": 0.035, "er": 4.4, "z": 72.498}+{"h": 1.6, "w": 0.8, "g": 0.1, "s": 0.15, "t": 0.035, "er": 4.4, "z": 69.589}+{"h": 1.6, "w": 0.8, "g": 0.2, "s": 0.15, "t": 0.035, "er": 4.4, "z": 74.904}+{"h": 1.6, "w": 0.8, "g": 0.5, "s": 0.15, "t": 0.035, "er": 4.4, "z": 80.513}+{"h": 1.6, "w": 0.8, "g": 0.1, "s": 0.3, "t": 0.035, "er": 4.4, "z": 80.72}+{"h": 1.6, "w": 0.8, "g": 0.2, "s": 0.3, "t": 0.035, "er": 4.4, "z": 88.049}+{"h": 1.6, "w": 0.8, "g": 0.5, "s": 0.3, "t": 0.035, "er": 4.4, "z": 96.102}+{"h": 1.6, "w": 0.8, "g": 0.1, "s": 0.6, "t": 0.035, "er": 4.4, "z": 92.505}+{"h": 1.6, "w": 0.8, "g": 0.2, "s": 0.6, "t": 0.035, "er": 4.4, "z": 102.428}+{"h": 1.6, "w": 0.8, "g": 0.5, "s": 0.6, "t": 0.035, "er": 4.4, "z": 113.904}+{"h": 0.2104, "w": 0.2, "g": 0.1, "s": null, "t": 0.018, "er": 4.4, "z": 57.366}+{"h": 0.2104, "w": 0.2, "g": 0.1, "s": 0.1, "t": 0.018, "er": 4.4, "z": 92.813}+{"h": 0.2104, "w": 0.2, "g": 0.1, "s": null, "t": 0.07, "er": 4.4, "z": 48.647}+{"h": 0.2104, "w": 0.2, "g": 0.1, "s": 0.1, "t": 0.07, "er": 4.4, "z": 76.476}+{"h": 0.2104, "w": 0.2, "g": 0.2, "s": null, "t": 0.018, "er": 4.4, "z": 64.852}+{"h": 0.2104, "w": 0.2, "g": 0.2, "s": 0.2, "t": 0.018, "er": 4.4, "z": 112.686}+{"h": 0.2104, "w": 0.2, "g": 0.2, "s": null, "t": 0.07, "er": 4.4, "z": 58.149}+{"h": 0.2104, "w": 0.2, "g": 0.2, "s": 0.2, "t": 0.07, "er": 4.4, "z": 98.634}+{"h": 0.2104, "w": 0.5, "g": 0.1, "s": null, "t": 0.018, "er": 4.4, "z": 37.023}+{"h": 0.2104, "w": 0.5, "g": 0.1, "s": 0.1, "t": 0.018, "er": 4.4, "z": 62.724}+{"h": 0.2104, "w": 0.5, "g": 0.1, "s": null, "t": 0.07, "er": 4.4, "z": 33.114}+{"h": 0.2104, "w": 0.5, "g": 0.1, "s": 0.1, "t": 0.07, "er": 4.4, "z": 54.644}+{"h": 0.2104, "w": 0.5, "g": 0.2, "s": null, "t": 0.018, "er": 4.4, "z": 40.366}+{"h": 0.2104, "w": 0.5, "g": 0.2, "s": 0.2, "t": 0.018, "er": 4.4, "z": 72.165}+{"h": 0.2104, "w": 0.5, "g": 0.2, "s": null, "t": 0.07, "er": 4.4, "z": 37.658}+{"h": 0.2104, "w": 0.5, "g": 0.2, "s": 0.2, "t": 0.07, "er": 4.4, "z": 66.078}+{"h": 1.6, "w": 0.2, "g": 0.1, "s": null, "t": 0.018, "er": 4.4, "z": 66.628}+{"h": 1.6, "w": 0.2, "g": 0.1, "s": 0.1, "t": 0.018, "er": 4.4, "z": 97.54}+{"h": 1.6, "w": 0.2, "g": 0.1, "s": null, "t": 0.07, "er": 4.4, "z": 55.324}+{"h": 1.6, "w": 0.2, "g": 0.1, "s": 0.1, "t": 0.07, "er": 4.4, "z": 79.709}+{"h": 1.6, "w": 0.2, "g": 0.2, "s": null, "t": 0.018, "er": 4.4, "z": 83.339}+{"h": 1.6, "w": 0.2, "g": 0.2, "s": 0.2, "t": 0.018, "er": 4.4, "z": 125.428}+{"h": 1.6, "w": 0.2, "g": 0.2, "s": null, "t": 0.07, "er": 4.4, "z": 73.215}+{"h": 1.6, "w": 0.2, "g": 0.2, "s": 0.2, "t": 0.07, "er": 4.4, "z": 108.635}+{"h": 1.6, "w": 0.5, "g": 0.1, "s": null, "t": 0.018, "er": 4.4, "z": 52.282}+{"h": 1.6, "w": 0.5, "g": 0.1, "s": 0.1, "t": 0.018, "er": 4.4, "z": 75.595}+{"h": 1.6, "w": 0.5, "g": 0.1, "s": null, "t": 0.07, "er": 4.4, "z": 45.055}+{"h": 1.6, "w": 0.5, "g": 0.1, "s": 0.1, "t": 0.07, "er": 4.4, "z": 64.512}+{"h": 1.6, "w": 0.5, "g": 0.2, "s": null, "t": 0.018, "er": 4.4, "z": 64.346}+{"h": 1.6, "w": 0.5, "g": 0.2, "s": 0.2, "t": 0.018, "er": 4.4, "z": 95.281}+{"h": 1.6, "w": 0.5, "g": 0.2, "s": null, "t": 0.07, "er": 4.4, "z": 58.293}+{"h": 1.6, "w": 0.5, "g": 0.2, "s": 0.2, "t": 0.07, "er": 4.4, "z": 85.452}+{"h": 0.2, "w": 0.1, "g": 0.1, "s": null, "t": 0.004, "er": 4.4, "z": 78.636}+{"h": 0.2, "w": 0.1, "g": 0.2, "s": null, "t": 0.004, "er": 4.4, "z": 88.12}+{"h": 0.2, "w": 0.1, "g": 0.4, "s": null, "t": 0.004, "er": 4.4, "z": 92.354}+{"h": 0.2, "w": 0.1, "g": 1.0, "s": null, "t": 0.004, "er": 4.4, "z": 93.369}+{"h": 0.2, "w": 0.4, "g": 0.1, "s": null, "t": 0.004, "er": 4.4, "z": 42.653}+{"h": 0.2, "w": 0.4, "g": 0.2, "s": null, "t": 0.004, "er": 4.4, "z": 46.066}+{"h": 0.2, "w": 0.4, "g": 0.4, "s": null, "t": 0.004, "er": 4.4, "z": 47.7}+{"h": 0.2, "w": 0.4, "g": 1.0, "s": null, "t": 0.004, "er": 4.4, "z": 48.185}+{"h": 1.0, "w": 0.1, "g": 0.1, "s": null, "t": 0.004, "er": 4.4, "z": 86.206}+{"h": 1.0, "w": 0.1, "g": 0.2, "s": null, "t": 0.004, "er": 4.4, "z": 105.067}+{"h": 1.0, "w": 0.1, "g": 0.4, "s": null, "t": 0.004, "er": 4.4, "z": 124.48}+{"h": 1.0, "w": 0.1, "g": 1.0, "s": null, "t": 0.004, "er": 4.4, "z": 144.233}+{"h": 1.0, "w": 0.4, "g": 0.1, "s": null, "t": 0.004, "er": 4.4, "z": 57.758}+{"h": 1.0, "w": 0.4, "g": 0.2, "s": null, "t": 0.004, "er": 4.4, "z": 69.717}+{"h": 1.0, "w": 0.4, "g": 0.4, "s": null, "t": 0.004, "er": 4.4, "z": 82.926}+{"h": 1.0, "w": 0.4, "g": 1.0, "s": null, "t": 0.004, "er": 4.4, "z": 97.107}
tests/impedance-workflow.py+21−2@@ -1,5 +1,6 @@ """Impedance targets end to end (issue #52): table, route sizes and couples a USB pair, analyze-impedance judges the copper, a refused layer and a split pair are reported, never claimed.+impedance judges the copper, a refused layer and a split pair are reported, never claimed, and a+grounded coplanar pair is sized for its pour, which is said to be assumed until it is poured. Usage: python3 tests/impedance-workflow.py <adom-aiflow binary>""" import json, os, pathlib, subprocess, sys, tempfile @@ -80,8 +81,26 @@ with tempfile.TemporaryDirectory(prefix='aiflow-impedance-') as td: r = call(run, 'analyze', 'impedance') assert r.returncode != 0 and 'not coupled' in r.stdout, r.stdout + # a same-layer GND pour beside the pair (grounded coplanar): the outer rows say the pour is assumed,+ # route sizes the pair for it, and analyze impedance says the pour is assumed, not measured, until+ # a filled GND zone is on the layer+ cpw = dict(spec, coplanarGround={"net": "GND", "gapMm": 0.15})+ (p / 'cpw.json').write_text(json.dumps(cpw))+ r = call(p / 'none', 'impedance', 'table', '--board', str(b), '--spec', str(p / 'cpw.json'), '--json')+ assert r.returncode == 0, r.stdout + r.stderr+ rows = {x['layer']: x for x in json.loads(r.stdout[:r.stdout.index('\nOK: ')])['rows']}+ f = rows['F.Cu']+ assert f['ok'] and f['structure'] == 'grounded coplanar' and f['coplanarGapMm'] == 0.15 and 'GND pour on F.Cu 0.15 mm' in f['assumes'], f+ assert 'coplanarNote' in rows['In1.Cu'] and 'with a GND pour 0.15 mm beside it (assumed' in r.stdout and 'Grounded coplanar rows' in r.stdout, r.stdout+ run = p / 'run-cpw'+ assert call(run, 'start', '--board', str(b), '--spec', str(p / 'cpw.json'), '--engine', 'test', '--prompt-time', '2026-10-27T00:00:00Z').returncode == 0+ r = call(run, 'route')+ assert r.returncode == 0 and f"routed on F.Cu at width {f['widthMm']} mm" in r.stdout and 'assumed until it is poured' in r.stdout, r.stdout+ r = call(run, 'analyze', 'impedance')+ assert r.returncode == 0 and 'GND pour 0.15 mm beside it ASSUMED on F.Cu, not measured' in r.stdout and 'signals.differentialPairs' not in r.stdout, r.stdout+ # adom-fields' own impedance map with an object value is refused with the right key named (p / 'bad.json').write_text(json.dumps({"impedance": {"USB": {"pair": ["USB_D+", "USB_D-"]}}})) r = call(p / 'none', 'impedance', 'table', '--board', str(b), '--spec', str(p / 'bad.json')) assert r.returncode != 0 and 'impedanceTargets' in r.stdout, r.stdout- print('PASS: table per layer with refusals, route refuses an unreachable target, sizes and couples a pair on one layer, analyze impedance passes it and fails a split pair, Fields map kept numeric.')+ print('PASS: table per layer with refusals, route refuses an unreachable target, sizes and couples a pair on one layer, analyze impedance passes it and fails a split pair, a coplanar pair is sized for its pour and the pour is said to be assumed, Fields map kept numeric.')
tools/impedance_reference.py+71−9@@ -10,8 +10,12 @@ capacitance per metre comes from the field energy, once with the dielectric and Pairs are solved in odd mode (+1 V / -1 V) and even mode (+1 V / +1 V); Zdiff = 2 * Zodd. No solder mask, no loss, no dispersion: the same quasi-static problem the closed forms solve.+With `cpw` a same-layer ground (0 V, the strip's thickness) starts that far beside the outer+copper edges and runs to the box side (or `pour` mm wide): grounded coplanar waveguide, the+pour stitched to the plane under it. python3 tools/impedance_reference.py # prints the table used by the Rust tests+ python3 tools/impedance_reference.py --coplanar-sweep > tests/fixtures/coplanar-reference.jsonl Each case is solved on two grids (finest cell f and f/2) and extrapolated to zero cell size (Richardson, first order: the strip corners dominate the grid error); the two grids differ by@@ -96,17 +100,24 @@ def solve(xs, ys, eps_cell, fixed): return energy -def section(w, t, h_below, h_above=None, er=4.3, gap=None, mode="single", fine=None):+def section(w, t, h_below, h_above=None, er=4.3, gap=None, mode="single", fine=None, cpw=None, pour=None): """Capacitance per metre of a strip (or pair) of width w, thickness t (mm) at h_below over a- ground plane; a second plane h_above over the top of the strip makes it a stripline."""+ ground plane; a second plane h_above over the top of the strip makes it a stripline.+ cpw: a same-layer ground (0 V, thickness t) at this gap beside the outer edge(s), out to the+ box side (or `pour` mm wide): grounded coplanar waveguide, single or edge-coupled.""" mm = 1e-3- fine = fine or min(t / 4, w / 20, h_below / 20, (gap / 8) if gap else 1)+ fine = fine or min(t / 4, w / 20, h_below / 20, (gap / 8) if gap else 1, (cpw / 8) if cpw else 1) strips = [(-w / 2, w / 2)] if gap is None else [(-gap / 2 - w, -gap / 2), (gap / 2, gap / 2 + w)] span = (w if gap is None else 2 * w + gap) hb = h_below top = (hb + t + h_above) if h_above is not None else hb + t + 25 * hb + 5 * span side = span / 2 + (8 * (hb + (h_above or 0)) + 3 * span if h_above is not None else 25 * hb + 5 * span)- xk = [-side, side] + [x for s in strips for x in s] + [0.0]+ grounds = []+ if cpw is not None:+ e = span / 2 + cpw+ far = side if pour is None else min(side, e + pour)+ grounds = [(-far, -e), (e, far)]+ xk = [-side, side] + [x for s in strips + grounds for x in s] + [0.0] yk = [0.0, hb, hb + t, top] coarse = max(span, hb) / 2 xs = graded(xk, fine, coarse)@@ -126,7 +137,7 @@ def section(w, t, h_below, h_above=None, er=4.3, gap=None, mode="single", fine=N for j in range(ny): fixed[j * nx] = 0.0 fixed[j * nx + nx - 1] = 0.0- for (x0, x1), v in zip(strips, potentials):+ for (x0, x1), v in list(zip(strips, potentials)) + [(g, 0.0) for g in grounds]: for j in range(ny): if hb - 1e-12 <= ys[j] <= hb + t + 1e-12: for i in range(nx):@@ -155,17 +166,68 @@ CASES = [ ("stripline offset 0.2/0.6 er4.3 w0.15 t35", dict(w=0.15, t=0.035, h_below=0.2, h_above=0.6, er=4.3)), ("stripline offset 0.2/1.03 er4.3 w0.15 t35 (5:1: refused)", dict(w=0.15, t=0.035, h_below=0.2, h_above=1.03, er=4.3)), ("pair stripline sym b0.5 er4.3 w0.15 s0.15 odd", dict(w=0.15, t=0.035, h_below=0.2325, h_above=0.2325, er=4.3, gap=0.15, mode="odd")),+ # grounded coplanar (a same-layer ground pour at gap cpw beside the copper, out to the box side)+ ("GCPW 1.6 mm FR4 er4.5 w0.802 g0.15 t35", dict(w=0.802, t=0.035, h_below=1.6, er=4.5, cpw=0.15)),+ ("pair GCPW 1.51 er4.5 w0.546 s0.2 g0.2 odd (StickHub)", dict(w=0.546, t=0.035, h_below=1.51, er=4.5, cpw=0.2, gap=0.2, mode="odd")),+ ("pair GCPW 1.51 er4.5 w0.379 s0.15 g0.15 odd", dict(w=0.379, t=0.035, h_below=1.51, er=4.5, cpw=0.15, gap=0.15, mode="odd")),+ ("pair GCPW 1.51 er4.5 w0.8485 s0.3515 g0.2 odd (grid pitch)", dict(w=0.8485, t=0.035, h_below=1.51, er=4.5, cpw=0.2, gap=0.3515, mode="odd")),+ ("GCPW JLC7628 h0.2104 er4.4 w0.3095 g0.15", dict(w=0.3095, t=0.035, h_below=0.2104, er=4.4, cpw=0.15)),+ ("pair GCPW JLC7628 w0.2688 s0.15 g0.15 odd", dict(w=0.2688, t=0.035, h_below=0.2104, er=4.4, cpw=0.15, gap=0.15, mode="odd")),+ ("GCPW h1.0 er4.4 w0.2 g1.0 (range edge, g/h 1)", dict(w=0.2, t=0.035, h_below=1.0, er=4.4, cpw=1.0)),+ ("pair GCPW h1.0 er4.4 w0.2 s0.6 g0.5 odd (range edge)", dict(w=0.2, t=0.035, h_below=1.0, er=4.4, cpw=0.5, gap=0.6, mode="odd")),+ ("GCPW h0.508 er3.66 w0.8 g0.2", dict(w=0.8, t=0.035, h_below=0.508, er=3.66, cpw=0.2)),+ ("pair GCPW h0.508 er3.66 w0.3 s0.2 g0.2 odd", dict(w=0.3, t=0.035, h_below=0.508, er=3.66, cpw=0.2, gap=0.2, mode="odd")),+ ("GCPW h0.635 er9.8 w0.3 g0.15 t18", dict(w=0.3, t=0.018, h_below=0.635, er=9.8, cpw=0.15)),+ ("GCPW h0.5 er4.4 w0.2 g1.0 (g/h 2: refused)", dict(w=0.2, t=0.035, h_below=0.5, er=4.4, cpw=1.0)),+ ("pair GCPW 1.51 er4.5 w0.546 s0.2 g0.2 odd, pour 0.3 mm wide", dict(w=0.546, t=0.035, h_below=1.51, er=4.5, cpw=0.2, gap=0.2, mode="odd", pour=0.3)),+ ("GCPW 1.6 mm FR4 er4.5 w0.802 g0.15, pour 0.3 mm wide", dict(w=0.802, t=0.035, h_below=1.6, er=4.5, cpw=0.15, pour=0.3)), ] ++def coplanar_sweep():+ """The grounded coplanar cross-sections the validated range in impedance.rs comes from+ (tests/fixtures/coplanar-reference.jsonl): single lines and odd-mode pairs, er 4.4."""+ import itertools+ out = []+ for h, w, g in itertools.product([0.1, 0.2104, 0.5, 1.0, 1.6], [0.1, 0.2, 0.4, 0.8, 1.5], [0.1, 0.15, 0.25, 0.5, 1.0]):+ out.append(dict(w=w, t=0.035, h_below=h, er=4.4, cpw=g))+ for h, w, s, g in itertools.product([0.1, 0.2104, 1.0, 1.6], [0.1, 0.2, 0.4, 0.8], [0.1, 0.15, 0.3, 0.6], [0.1, 0.2, 0.5]):+ out.append(dict(w=w, t=0.035, h_below=h, er=4.4, cpw=g, gap=s, mode="odd"))+ for h, w, g, t in itertools.product([0.2104, 1.6], [0.2, 0.5], [0.1, 0.2], [0.018, 0.07]):+ out.append(dict(w=w, t=t, h_below=h, er=4.4, cpw=g))+ out.append(dict(w=w, t=t, h_below=h, er=4.4, cpw=g, gap=g, mode="odd"))+ for h, w, g in itertools.product([0.2, 1.0], [0.1, 0.4], [0.1, 0.2, 0.4, 1.0]):+ out.append(dict(w=w, t=0.004, h_below=h, er=4.4, cpw=g))+ return out+++def extrapolated(kw):+ """Two grids (finest cell f and f/2), Richardson to zero cell size: (Z, eeff)."""+ f = min(kw["t"] / 4, kw["w"] / 20, kw["h_below"] / 20, (kw["gap"] / 8) if kw.get("gap") else 1, (kw["cpw"] / 8) if kw.get("cpw") else 1)+ z1, e1 = section(**kw, fine=f)+ z2, e2 = section(**kw, fine=f / 2)+ return 2 * z2 - z1, 2 * e2 - e1+++def _sweep_line(kw):+ import json+ z, _ = extrapolated(kw)+ odd = kw.get("mode") == "odd"+ return json.dumps({"h": kw["h_below"], "w": kw["w"], "g": kw["cpw"], "s": kw.get("gap"), "t": kw["t"], "er": kw["er"], "z": round(2 * z if odd else z, 3)})+ if __name__ == "__main__":+ if sys.argv[1:2] == ["--coplanar-sweep"]:+ # python3 tools/impedance_reference.py --coplanar-sweep > tests/fixtures/coplanar-reference.jsonl+ from multiprocessing import Pool+ with Pool() as p:+ for line in p.map(_sweep_line, coplanar_sweep()):+ print(line, flush=True)+ sys.exit(0) only = sys.argv[1:] for name, kw in CASES: if only and not any(o in name for o in only): continue # first-order grid error (the strip corners): extrapolate from two grids (Richardson)- f = min(kw["t"] / 4, kw["w"] / 20, kw["h_below"] / 20, (kw["gap"] / 8) if kw.get("gap") else 1)- z1, e1 = section(**kw, fine=f)- z2, e2 = section(**kw, fine=f / 2)- z, ee = 2 * z2 - z1, 2 * e2 - e1+ z, ee = extrapolated(kw) extra = f" Zdiff {2 * z:.2f}" if kw.get("mode") == "odd" else "" print(f"{name:52s} Z {z:7.2f} ohm eeff {ee:5.3f}{extra}", flush=True)
Comments
Log in to comment.
No comments yet.