main
Colby Knox AI Flow 0.2.73: neck-down, length tuning, grounded coplanar (#52) 2a6bec0
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402
//! Controlled impedance before routing (issue #52): closed-form trace impedance from the board's
//! stackup, a width (and pair gap) per layer for each spec target, and a check of landed copper.
//!
//! Formulas (quasi-static, lossless, no solder mask, no dispersion):
//! - Surface microstrip: Hammerstad and Jensen, "Accurate Models for Microstrip Computer-Aided
//!   Design", IEEE MTT-S 1980, with their strip-thickness correction (the model KiCad's calculator
//!   uses for a single microstrip).
//! - Symmetric stripline: Wheeler, "Transmission-Line Properties of a Strip Line Between Parallel
//!   Planes", IEEE Trans. MTT 26 (1978), as given in Wadell, "Transmission Line Design Handbook"
//!   (1991) sec. 3.5.1. Zero-thickness limit checked against Cohn's exact elliptic-integral result.
//! - Asymmetric (offset) stripline: the parallel combination of two symmetric striplines with plane
//!   spacings 2*h1+t and 2*h2+t (Wadell sec. 3.5.3; the form KiCad's stripline calculator uses).
//! - Edge-coupled stripline: Cohn, "Shielded Coupled-Strip Transmission Line", IRE Trans. MTT 3
//!   (1955): exact even/odd impedances for zero thickness, with Cohn's fringing-capacitance
//!   thickness correction (Wadell sec. 3.6.1). Offset coupled stripline uses the same parallel
//!   combination as the single offset line (an approximation, flagged in the table).
//! - Edge-coupled microstrip: Garg and Bahl, "Characteristics of Coupled Microstriplines", IEEE
//!   Trans. MTT 27 (1979), even/odd capacitances, with the single-line terms from Hammerstad and
//!   Jensen (thickness included) and a parallel-plate gap term 2*e0*t/s for the strip sidewalls in
//!   odd mode (Gupta, Garg, Bahl and Bhartia, "Microstrip Lines and Slotlines", 2nd ed., sec. 8.4).
//!   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:
//! microstrip and symmetric stripline within 0.2 %, offset stripline 1.4 % at 2:1 and 3.4 % at 3:1
//! (refused past 3:1), coupled microstrip 0.2 to 2.6 %, coupled stripline 0.9 %. Real boards add
//! solder mask (it lowers an outer trace's impedance by roughly 1 to 3 %; adom-fields' 2-D solve
//! models it), etch trapezoid, glass weave and Dk tolerance; a fab's own stackup calculation is the
//! reference for an impedance-controlled order.
use std::collections::BTreeMap;
use std::f64::consts::PI;

use aiflow_board::{Board, Track, Via};
use serde_json::{json, Value};

pub const ETA0: f64 = 376.730313668;
pub const C0: f64 = 299_792_458.0;
pub const EPS0: f64 = 8.8541878128e-12;

/// The default tolerance of a target, percent.
pub const DEFAULT_TOLERANCE: f64 = 10.0;
/// The default intra-pair length mismatch a pair may keep, mm.
pub const DEFAULT_MAX_SKEW_MM: f64 = 0.5;
/// The default length of a net that may sit outside its target (pad breakouts, escape stubs), mm.
pub const DEFAULT_MAX_UNCONTROLLED_MM: f64 = 3.0;

// -- the formulas ----------------------------------------------------------------------------

/// K(k)/K(k') with k' = sqrt(1 - k^2), the complete elliptic integral ratio, by the
/// arithmetic-geometric mean (exact to machine precision).
pub fn k_ratio(k: f64) -> f64 {
    fn kk(k: f64) -> f64 {
        let (mut a, mut b) = (1.0, (1.0 - k * k).max(0.0).sqrt());
        for _ in 0..40 {
            let (an, bn) = ((a + b) / 2.0, (a * b).sqrt());
            a = an;
            b = bn;
            if (a - b).abs() < 1e-15 { break; }
        }
        PI / (2.0 * a)
    }
    let kp = (1.0 - k * k).max(0.0).sqrt();
    kk(k) / kk(kp)
}

/// Hammerstad-Jensen air impedance of a zero-thickness microstrip of normalised width u = w/h.
fn hj_z01(u: f64) -> f64 {
    let f = 6.0 + (2.0 * PI - 6.0) * (-(30.666 / u).powf(0.7528)).exp();
    ETA0 / (2.0 * PI) * (f / u + (1.0 + 4.0 / (u * u)).sqrt()).ln()
}

/// Hammerstad-Jensen effective permittivity of a zero-thickness microstrip.
fn hj_eeff(u: f64, er: f64) -> f64 {
    let a = 1.0 + ((u.powi(4) + (u / 52.0).powi(2)) / (u.powi(4) + 0.432)).ln() / 49.0 + (1.0 + (u / 18.1).powi(3)).ln() / 18.7;
    let b = 0.564 * ((er - 0.9) / (er + 3.0)).powf(0.053);
    (er + 1.0) / 2.0 + (er - 1.0) / 2.0 * (1.0 + 10.0 / u).powf(-a * b)
}

/// Surface microstrip: (Z0 ohm, effective permittivity) for width w, dielectric height h and
/// copper thickness t (all mm) over a reference plane, relative permittivity er.
/// Hammerstad and Jensen (1980) with their thickness correction.
pub fn microstrip(w: f64, h: f64, t: f64, er: f64) -> (f64, f64) {
    let u = w / h;
    let (du1, dur) = if t > 0.0 {
        let th = t / h;
        let coth2 = 1.0 / (6.517 * u).sqrt().tanh().powi(2);
        let du1 = th / PI * (1.0 + 4.0 * std::f64::consts::E / (th * coth2)).ln();
        (du1, 0.5 * (1.0 + 1.0 / (er - 1.0).max(0.0).sqrt().cosh()) * du1)
    } else {
        (0.0, 0.0)
    };
    let (u1, ur) = (u + du1, u + dur);
    let eeff_r = hj_eeff(ur, er);
    let z0 = hj_z01(ur) / eeff_r.sqrt();
    let eeff = eeff_r * (hj_z01(u1) / hj_z01(ur)).powi(2);
    (z0, eeff)
}

/// Cohn's exact zero-thickness symmetric stripline (plane spacing b).
pub fn stripline_exact_t0(w: f64, b: f64, er: f64) -> f64 {
    let k = 1.0 / (PI * w / (2.0 * b)).cosh();
    30.0 * PI / er.sqrt() * k_ratio(k)
}

/// Symmetric stripline Z0: width w, thickness t, plane-to-plane spacing b (mm). Wheeler (1978).
pub fn stripline(w: f64, b: f64, t: f64, er: f64) -> f64 {
    let x = t / b;
    let bt = b - t;
    let dw = if t > 0.0 {
        let m = 2.0 / (1.0 + 2.0 / 3.0 * x / (1.0 - x));
        x / (PI * (1.0 - x)) * (1.0 - 0.5 * ((x / (2.0 - x)).powi(2) + (0.0796 * x / (w / b + 1.1 * x)).powf(m)).ln())
    } else {
        0.0
    };
    let mm = w / bt + dw;
    let a = 8.0 / (PI * mm);
    30.0 / er.sqrt() * (1.0 + 4.0 / (PI * mm) * (a + (a * a + 6.27).sqrt())).ln()
}

/// Stripline offset between two planes: h1 and h2 are the dielectric thicknesses from each face of
/// the strip to its plane (mm). Symmetric when h1 == h2. Wadell sec. 3.5.3.
pub fn offset_stripline(w: f64, h1: f64, h2: f64, t: f64, er: f64) -> f64 {
    let (za, zb) = (stripline(w, 2.0 * h1 + t, t, er), stripline(w, 2.0 * h2 + t, t, er));
    2.0 * za * zb / (za + zb)
}

/// Cohn's fringing capacitance per edge over permittivity, for a strip of thickness t in plane
/// spacing b (Cohn 1954; 2 ln 2 / pi at t = 0).
fn cohn_fringe(t: f64, b: f64) -> f64 {
    let x = t / b;
    let y = 1.0 / (1.0 - x);
    (2.0 * y * (y + 1.0).ln() - (y - 1.0) * (y * y - 1.0).max(1e-300).ln()) / PI
}

/// Edge-coupled symmetric stripline: (Z odd, Z even) ohm for width w, gap s, thickness t, plane
/// spacing b. Cohn (1955) exact at t = 0, with his thickness correction.
pub fn coupled_stripline(w: f64, s: f64, b: f64, t: f64, er: f64) -> (f64, f64) {
    let a = (PI * w / (2.0 * b)).tanh();
    let c = PI * (w + s) / (2.0 * b);
    let ke = a * c.tanh();
    let ko = a / c.tanh();
    let k0 = 30.0 * PI / er.sqrt();
    let (ze0, zo0) = (k0 / k_ratio(ke), k0 / k_ratio(ko));
    if t <= 0.0 {
        return (zo0, ze0);
    }
    let zs0 = stripline_exact_t0(w, b, er);
    let zst = stripline(w, b, t, er);
    let f = cohn_fringe(t, b) / cohn_fringe(0.0, b);
    let ze = 1.0 / (1.0 / zst - f * (1.0 / zs0 - 1.0 / ze0));
    let zo = if s >= 5.0 * t {
        1.0 / (1.0 / zst + f * (1.0 / zo0 - 1.0 / zs0))
    } else {
        // a gap under five strip thicknesses: the gap-side corners' fringing becomes the sidewall
        // parallel plate to the odd mode's virtual ground at s/2 (Cohn 1955; Wadell eq. 3.6.1.5)
        let y = er.sqrt() / ETA0;
        1.0 / (1.0 / zo0 + (1.0 / zst - 1.0 / zs0) - y * 2.0 * (cohn_fringe(t, b) - cohn_fringe(0.0, b)) + y * 2.0 * t / s)
    };
    (zo, ze)
}

/// Edge-coupled offset stripline: the parallel combination of two symmetric coupled striplines
/// (as for the single offset line). An approximation, about 5 %.
pub fn coupled_offset_stripline(w: f64, s: f64, h1: f64, h2: f64, t: f64, er: f64) -> (f64, f64) {
    let (a, b) = (coupled_stripline(w, s, 2.0 * h1 + t, t, er), coupled_stripline(w, s, 2.0 * h2 + t, t, er));
    (2.0 * a.0 * b.0 / (a.0 + b.0), 2.0 * a.1 * b.1 / (a.1 + b.1))
}

/// Garg-Bahl even and odd line capacitances (F/m) of an edge-coupled microstrip.
fn garg_bahl_caps(w: f64, s: f64, h: f64, t: f64, er: f64) -> (f64, f64) {
    let (z0, eeff) = microstrip(w, h, t, er);
    let cp = EPS0 * er * w / h;
    let cf = 0.5 * (eeff.sqrt() / (C0 * z0) - cp);
    let a = (-0.1 * (2.33 - 2.53 * w / h).exp()).exp();
    let cf2 = cf / (1.0 + a * (h / s) * (8.0 * s / h).tanh()) * (er / eeff).sqrt();
    let k = (s / h) / (s / h + 2.0 * w / h);
    let cga = EPS0 / k_ratio(k);
    let cgd = EPS0 * er / PI * (1.0 / (PI * s / (4.0 * h)).tanh()).ln() + 0.65 * cf * (0.02 * er.sqrt() / (s / h) + 1.0 - 1.0 / (er * er));
    let cgt = 2.0 * EPS0 * t / s;
    (cp + cf + cf2, cp + cf + cga + cgd + cgt)
}

/// Edge-coupled microstrip: (Z odd, Z even, eeff odd, eeff even) for width w, gap s, dielectric
/// height h, thickness t. Garg and Bahl (1979) with Hammerstad-Jensen single-line terms.
pub fn coupled_microstrip(w: f64, s: f64, h: f64, t: f64, er: f64) -> (f64, f64, f64, f64) {
    let (ce, co) = garg_bahl_caps(w, s, h, t, er);
    let (cea, coa) = garg_bahl_caps(w, s, h, t, 1.0);
    let z = |c: f64, ca: f64| 1.0 / (C0 * (c * ca).sqrt());
    (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> {
    let u = w / h;
    match (structure, s) {
        ("microstrip", None) => if (0.01..=100.0).contains(&u) { Ok(()) } else { Err(format!("w/h {u:.3} is outside Hammerstad-Jensen's 0.01..100")) },
        ("microstrip", Some(s)) => {
            if !(0.2..=2.0).contains(&u) { return Err(format!("w/h {u:.3} is outside Garg-Bahl's 0.2..2")); }
            // Garg and Bahl state 0.05..2; against the field solve the error grows past 4 % under 0.2
            let g = s / h;
            if (0.2..=2.0).contains(&g) { Ok(()) } else { Err(format!("s/h {g:.3} is outside 0.2..2, where Garg-Bahl stays within 4 % of a field solve")) }
        }
        (_, _) => if u > 0.02 && u < 20.0 { Ok(()) } else { Err(format!("w/b {u:.3} is outside the stripline formulas' range")) },
    }
}

/// The largest plane-distance ratio the offset stripline approximation is used at: against the
/// field solve it is 1.4 % off at 2:1 and 3.4 % at 3:1 (7 % at 5:1, refused).
pub const MAX_OFFSET_RATIO: f64 = 3.0;

// -- the stackup -----------------------------------------------------------------------------

/// One copper layer's cross-section for the formulas.
#[derive(Clone, Debug)]
pub struct LayerGeom {
    pub name: String,
    /// microstrip (outer), stripline (inner, symmetric) or offset stripline (inner)
    pub structure: String,
    /// dielectric between this layer and the reference plane under it (microstrip), or the two
    /// dielectrics either side (stripline), mm
    pub h1: f64,
    pub h2: Option<f64>,
    /// the layer the formulas treat as this layer's reference plane(s)
    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)]
pub struct Stackup {
    pub layers: Vec<LayerGeom>,
    /// where the dielectric thicknesses, the permittivity and the copper thickness came from
    pub sources: BTreeMap<String, String>,
}

/// The board's saved physical stackup (Board Setup > Physical Stackup): copper layer names in
/// order, the dielectric thickness between each consecutive pair, its thickness-weighted epsilon_r
/// (None when a dielectric names none), and each copper layer's thickness (mm). The same reading
/// adom-fields makes of a KiCad board.
pub fn board_stackup(board: &Board) -> Option<(Vec<String>, Vec<f64>, Vec<Option<f64>>, Vec<f64>)> {
    let st = board.root.find("setup")?.find("stackup")?;
    let (mut names, mut gaps, mut ers, mut cu) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
    let (mut acc, mut acc_er, mut er_all, mut seen) = (0.0, 0.0, true, false);
    for l in st.find_all("layer") {
        let name = l.atom(1).unwrap_or("");
        let t: f64 = l.find("thickness").and_then(|x| x.number(1)).unwrap_or(0.0);
        if name.ends_with(".Cu") {
            if seen {
                gaps.push(acc);
                ers.push(if er_all && acc > 0.0 { Some(acc_er / acc) } else { None });
            }
            names.push(name.to_string());
            cu.push(t);
            acc = 0.0;
            acc_er = 0.0;
            er_all = true;
            seen = true;
        } else if seen && l.find("type").and_then(|x| x.atom(1)).map(|ty| ty == "core" || ty == "prepreg").unwrap_or(name.starts_with("dielectric")) {
            acc += t;
            match l.find("epsilon_r").and_then(|x| x.number(1)) {
                Some(e) if e >= 1.0 => acc_er += e * t,
                _ => er_all = false,
            }
        }
    }
    if names.len() < 2 || gaps.iter().any(|g| !(*g > 0.0)) { return None; }
    Some((names, gaps, ers, cu))
}

fn copper_names(board: &Board) -> Vec<String> {
    let mut v: Vec<String> = board.copper_layers.clone();
    if v.is_empty() { v = vec!["F.Cu".into(), "B.Cu".into()]; }
    // F.Cu, In1.Cu.., B.Cu in stack order
    let key = |n: &String| match n.as_str() { "F.Cu" => 0, "B.Cu" => 10_000, s => s.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|k| k.parse::<usize>().ok()).unwrap_or(5000) };
    v.sort_by_key(key);
    v.dedup();
    v
}

/// The stackup the formulas use. Dielectric thicknesses: spec `stackupMm` (one per gap between
/// copper layers; three values on a two-layer board are summed, as adom-fields does), else the
/// board's saved physical stackup. Permittivity: the board's stackup epsilon_r, else spec
/// `epsilonR` (adom-fields' order). Copper: spec `copperUmByLayer`, else `copperUm`, else the
/// board stackup's copper thickness, else 35 um (1 oz, stated). No dielectric or permittivity is
/// ever guessed: without one the stackup is refused and the hint says which key to add.
pub fn stackup(board: &Board, spec: &Value) -> Result<Stackup, String> {
    let saved = board_stackup(board);
    let names = saved.as_ref().map(|s| s.0.clone()).unwrap_or_else(|| copper_names(board));
    let ngap = names.len() - 1;
    let mut sources = BTreeMap::new();
    let spec_gaps: Option<Vec<f64>> = spec.get("stackupMm").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect());
    let gaps: Vec<f64> = match (&spec_gaps, &saved) {
        (Some(g), _) if g.iter().any(|v| !(*v > 0.0) || !v.is_finite()) => return Err("spec stackupMm needs positive dielectric thicknesses (mm)".into()),
        (Some(g), _) if g.len() == ngap => { sources.insert("dielectric".into(), "spec stackupMm".into()); g.clone() }
        (Some(g), _) if ngap == 1 && g.len() == 3 => { sources.insert("dielectric".into(), "spec stackupMm (three values on a two-layer board, summed)".into()); vec![g.iter().sum()] }
        (Some(g), _) => return Err(format!("spec stackupMm has {} value(s) but the board has {} copper layers ({}): give one dielectric thickness per gap, {ngap} value(s)", g.len(), names.len(), names.join(", "))),
        (None, Some(s)) => { sources.insert("dielectric".into(), "board physical stackup".into()); s.1.clone() }
        (None, None) => return Err("no stackup: the spec has no stackupMm and the board file saves no physical stackup; add spec stackupMm (the fab's dielectric thickness per gap, mm) or save the stackup in KiCad's Board Setup".into()),
    };
    let spec_er = spec.get("epsilonR").and_then(|v| v.as_f64());
    if let Some(e) = spec_er { if !(e >= 1.0 && e.is_finite()) { return Err(format!("spec epsilonR {e} must be at least 1")); } }
    let board_er: Option<Vec<f64>> = saved.as_ref().filter(|s| s.2.len() == ngap).and_then(|s| s.2.iter().copied().collect::<Option<Vec<f64>>>());
    let ers: Vec<f64> = match (&board_er, spec_er) {
        (Some(e), _) => { sources.insert("epsilonR".into(), "board physical stackup epsilon_r".into()); e.clone() }
        (None, Some(e)) => { sources.insert("epsilonR".into(), "spec epsilonR".into()); vec![e; ngap] }
        (None, None) => return Err("no dielectric constant: the board stackup names no epsilon_r and the spec has no epsilonR; add spec epsilonR from the laminate datasheet (Dk near 1 GHz)".into()),
    };
    let by_layer = spec.get("copperUmByLayer").and_then(|v| v.as_object());
    let cu_spec = spec.get("copperUm").and_then(|v| v.as_f64());
    let mut cu_src = Vec::new();
    let t_of = |k: usize, name: &str, src: &mut Vec<String>| -> f64 {
        if let Some(v) = by_layer.and_then(|m| m.get(name)).and_then(|v| v.as_f64()) { src.push(format!("{name} spec copperUmByLayer")); return v / 1000.0; }
        if let Some(v) = cu_spec { return v / 1000.0; }
        if let Some(t) = saved.as_ref().and_then(|s| s.3.get(k)).copied().filter(|t| *t > 0.0) { return t; }
        0.035
    };
    let ts: Vec<f64> = names.iter().enumerate().map(|(k, n)| t_of(k, n, &mut cu_src)).collect();
    sources.insert("copper".into(), if cu_spec.is_some() { "spec copperUm".into() } else if saved.as_ref().map(|s| s.3.iter().all(|t| *t > 0.0)).unwrap_or(false) { "board physical stackup".into() } else { "35 um default (1 oz); declare copperUm".into() });
    if !cu_src.is_empty() { sources.insert("copperByLayer".into(), cu_src.join(", ")); }
    if ts.iter().any(|t| !(*t > 0.0) || *t > 0.5) { return Err("copper thickness must be positive micrometres (copperUm 35 for 1 oz)".into()); }
    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], 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], 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], coplanar: None }
        };
        layers.push(g);
    }
    Ok(Stackup { layers, sources })
}

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, 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, 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.
    fn h_norm(&self) -> f64 { match self.h2 { None => self.h1, Some(h2) => self.h1 + h2 + self.t } }
    fn kind(&self) -> &str { if self.h2.is_none() { "microstrip" } else { "stripline" } }
    pub fn valid(&self, w: f64, s: Option<f64>) -> Result<(), String> {
        if let Some(h2) = self.h2 {
            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())
    }
}

// -- targets ---------------------------------------------------------------------------------

#[derive(Clone, Debug)]
pub struct Target {
    pub name: String,
    /// one net (single-ended) or two (a differential pair, positive first)
    pub nets: Vec<String>,
    pub ohms: f64,
    pub diff: bool,
    pub tolerance: f64,
    pub gap: Option<f64>,
    pub width: Option<f64>,
    pub layer: Option<String>,
    pub max_skew: f64,
    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 {
    pub fn label(&self) -> String { if self.diff { format!("{} ({} / {})", self.name, self.nets[0], self.nets[1]) } else if self.name == self.nets[0] { self.name.clone() } else { format!("{} ({})", self.name, self.nets[0]) } }
}

/// 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), `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 {
            let nets: Vec<String> = if let Some(p) = o.get("pair") {
                let a: Vec<String> = p.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
                if a.len() != 2 || a[0] == a[1] { return Err(format!("impedanceTargets.{name}.pair must name two different nets, positive first")); }
                a
            } else if let Some(n) = o.get("net").and_then(|v| v.as_str()) { vec![n.to_string()] } else { return Err(format!("impedanceTargets.{name} needs \"net\" (single-ended) or \"pair\": [P, N]")) };
            let diff = nets.len() == 2;
            let ohms = if diff { num(o, "targetDiffOhms") } else { num(o, "targetOhms") }
                .ok_or_else(|| format!("impedanceTargets.{name} needs {}", if diff { "targetDiffOhms (a pair)" } else { "targetOhms (a single net)" }))?;
            let tol = num(o, "tolerancePercent").unwrap_or(DEFAULT_TOLERANCE);
            if !(ohms > 0.0 && ohms < 500.0) || !(tol > 0.0 && tol < 100.0) { return Err(format!("impedanceTargets.{name}: target ohms and tolerancePercent must be positive (tolerance under 100)")); }
            for k in ["gapMm", "widthMm", "maxSkewMm", "maxUncontrolledMm", "maxWidthMm"] {
                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}"), coplanar });
        }
    }
    if let Some(m) = spec.get("impedance") {
        let m = m.as_object().ok_or("spec impedance is adom-fields' {net: ohms} map")?;
        for (k, v) in m {
            let Some(ohms) = v.as_f64() else {
                return Err(format!("spec impedance.{k} is not a number: spec impedance is adom-fields' {{net: ohms, \"P/N\": differential ohms}} map, and Fields refuses any other value. Put per-target objects under impedanceTargets"));
            };
            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}"), coplanar: ground.clone() });
        }
    }
    let mut seen: BTreeMap<String, String> = BTreeMap::new();
    for t in &out {
        for n in &t.nets {
            if let Some(o) = seen.insert(n.clone(), t.name.clone()) { return Err(format!("net {n} is in two impedance targets ({o} and {})", t.name)); }
        }
    }
    Ok(out)
}

// -- the table -------------------------------------------------------------------------------

/// Manufacturing limits a width or gap is held to (mm), and the router's own gap floor.
#[derive(Clone, Debug)]
pub struct Fab {
    pub min_width: f64,
    pub min_space: f64,
    pub source: String,
}

/// How the grid router can place a pair: its centre lines sit on the 0.1 mm grid, and the second
/// net keeps the board clearance plus the router's slop from the first net's stamped copper.
#[derive(Clone, Debug)]
pub struct GridPair {
    pub res: f64,
    pub clearance: f64,
    pub slop: f64,
}

impl GridPair {
    /// Is a centre pitch p reachable for width w on the grid (orthogonal runs)?
    pub fn reachable(&self, w: f64, pitch: f64) -> bool {
        // copper cells reach floor(w/2 / res) cells from the centre line; the partner's centre must
        // be further than clearance + w/2 + slop from them (lib.rs obstacle_maps)
        let reach = ((w / 2.0 + 1e-6) / self.res).floor() * self.res;
        let orth = pitch - reach > self.clearance + w / 2.0 + self.slop + 1e-9;
        // on a 45 degree run the offsets come in steps of res/sqrt 2 and a diagonal step needs a
        // further res/sqrt 2 (obs_d): the pitch must be one of those steps (to 0.02 mm) and clear
        let (dp, dr) = self.diagonal(pitch, w);
        orth && (dp - pitch).abs() <= 0.02 && dp - dr > self.clearance + w / 2.0 + self.slop + self.res * std::f64::consts::FRAC_1_SQRT_2 + 1e-9
    }
    /// The nearest 45 degree centre offset to `pitch` and the copper reach of a width-w diagonal.
    pub fn diagonal(&self, pitch: f64, w: f64) -> (f64, f64) {
        let d = self.res * std::f64::consts::FRAC_1_SQRT_2;
        ((pitch / d).round() * d, ((w / 2.0 + 1e-6) / d).floor() * d)
    }
}

/// Solve f(x) = target for a decreasing f on [lo, hi] (log bisection). None when out of range.
fn solve_decreasing(f: &dyn Fn(f64) -> f64, target: f64, lo: f64, hi: f64) -> Option<f64> {
    let (flo, fhi) = (f(lo), f(hi));
    if !(flo >= target && fhi <= target) { return None; }
    let (mut a, mut b) = (lo.ln(), hi.ln());
    for _ in 0..80 {
        let m = (a + b) / 2.0;
        if f(m.exp()) > target { a = m; } else { b = m; }
    }
    Some(((a + b) / 2.0).exp())
}

fn r4(x: f64) -> f64 { (x * 10000.0).round() / 10000.0 }
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)); };
        let (z, ee) = g.z0(w);
        row["widthMm"] = json!(r4(w));
        row["ohms"] = json!(r2(z));
        row["eeff"] = json!(r4(ee));
        row["psPerMm"] = json!(r2(ee.sqrt() / C0 * 1e9));
        if let Err(e) = g.valid(w, None) { return refuse(row, format!("{e}; not extrapolated")); }
        if (z - t.ohms).abs() > t.ohms * t.tolerance / 100.0 { return refuse(row, format!("widthMm {w} gives {z:.1} ohm, outside {} %", t.tolerance)); }
        if w + 1e-9 < fab.min_width { return refuse(row, format!("needs {w:.3} mm, under the fab minimum track {:.3} mm ({})", fab.min_width, fab.source)); }
        if let Some(m) = t.max_width { if w > m { return refuse(row, format!("needs {w:.3} mm, over the target's maxWidthMm {m}")); } }
        row["ok"] = json!(true);
        return row;
    }
    // a pair: a fixed gap (spec gapMm), a fixed width (spec widthMm, solve the gap), or the
    // narrowest gap the fab (and, on the router's layers, the grid) allows
    let floor_s = fab.min_space;
    let pick = |w: f64, s: f64, row: &mut Value| { let (zd, zo, ze, eo) = g.zdiff(w, s); row["widthMm"] = json!(r4(w)); row["gapMm"] = json!(r4(s)); row["ohms"] = json!(r2(zd)); row["zOdd"] = json!(r2(zo)); row["zEven"] = json!(r2(ze)); row["eeffOdd"] = json!(r4(eo)); row["psPerMm"] = json!(r2(eo.sqrt() / C0 * 1e9)); zd };
    let solved: Option<(f64, f64, String)> = if let Some(s) = t.gap {
        solve_decreasing(&|w| g.zdiff(w, s).0, t.ohms, lo, hi).map(|w| (w, s, "spec gapMm".to_string()))
    } else if let Some(w) = t.width {
        // Zdiff rises with the gap: solve on its negative
        solve_decreasing(&|s| -g.zdiff(w, s).0, -t.ohms, hn * 0.01, hn * 10.0).map(|s| (w, s, "gap solved for spec widthMm".to_string()))
    } else if let Some(gp) = grid {
        // the router's pair: centre pitch on its grid, gap at least the fab minimum and reachable
        let mut found = None;
        for k in 1..400 {
            let p = k as f64 * gp.res;
            let Some(w) = solve_decreasing(&|w| g.zdiff(w, (p - w).max(1e-4)).0, t.ohms, lo.min(p * 0.05), p * 0.999) else { continue };
            if p - w + 1e-9 >= floor_s && w + 1e-9 >= fab.min_width && gp.reachable(w, p) { found = Some((w, p - w, format!("smallest centre pitch on the router's {:.1} mm grid the board clearance {:.3} mm allows", gp.res, gp.clearance))); break; }
        }
        found
    } else {
        // the smallest gap the fab allows that leaves a manufacturable width
        let mut found = None;
        let mut s = floor_s;
        for _ in 0..200 {
            if let Some(w) = solve_decreasing(&|w| g.zdiff(w, s).0, t.ohms, lo, hi) { if w + 1e-9 >= fab.min_width { found = Some((w, s, "narrowest gap at the fab minimum space that leaves a manufacturable width".to_string())); break; } }
            s += 0.005;
        }
        found
    };
    let Some((w, s, how)) = solved else { return refuse(row, format!("no width and gap reach {:.0} ohm differential on this {} within the fab limits (track {:.3}, space {:.3} mm) and the formula's range", t.ohms, g.structure, fab.min_width, fab.min_space)); };
    let zd = pick(w, s, &mut row);
    row["gapFrom"] = json!(how);
    if grid.is_some() && t.gap.is_none() && t.width.is_none() { row["pitchMm"] = json!(r4(w + s)); }
    if let Err(e) = g.valid(w, Some(s)) { return refuse(row, format!("{e}; not extrapolated")); }
    if (zd - t.ohms).abs() > t.ohms * t.tolerance / 100.0 { return refuse(row, format!("{zd:.1} ohm is outside {} %", t.tolerance)); }
    if w + 1e-9 < fab.min_width { return refuse(row, format!("needs {w:.3} mm, under the fab minimum track {:.3} mm ({})", fab.min_width, fab.source)); }
    if s + 1e-9 < fab.min_space { return refuse(row, format!("needs a {s:.3} mm gap, under the fab minimum space {:.3} mm ({})", fab.min_space, fab.source)); }
    if let Some(m) = t.max_width { if w > m { return refuse(row, format!("needs {w:.3} mm, over the target's maxWidthMm {m}")); } }
    if g.h2.is_some() && (g.h1 - g.h2.unwrap()).abs() > 1e-6 { row["note"] = json!("offset coupled stripline: parallel-combination approximation, about 5 %"); }
    row["ok"] = json!(true);
    row
}

/// Every target on every copper layer. Outer layers carry the router's row (`grid`) since the grid
/// router routes F.Cu and B.Cu only; inner layers are for an AI-routed board.
pub fn table(st: &Stackup, ts: &[Target], fab: &Fab, grid: Option<&GridPair>) -> Vec<Value> {
    let mut rows = Vec::new();
    for t in ts {
        for g in &st.layers {
            let outer = g.h2.is_none();
            let mut r = solve_row(t, g, fab, if outer { grid } else { None });
            r["router"] = json!(if outer { "grid router layer" } else { "inner layer: AI routing only (the grid router routes F.Cu and B.Cu)" });
            rows.push(r);
        }
    }
    rows
}

// -- landed copper ---------------------------------------------------------------------------

/// A copper layer index (aiflow_board::copper_layer) by name.
pub fn layer_name(l: usize) -> String {
    match l { 0 => "F.Cu".into(), 1 => "B.Cu".into(), k => format!("In{}.Cu", k - 1) }
}

/// The plan's tracks (docs/plan-schema.md): pads by key, points, layer changes as vias.
pub fn plan_tracks(plan: &Value, board: &Board) -> (Vec<Track>, Vec<Via>) {
    let entries = plan.get("nets").and_then(|v| v.as_array()).cloned().or_else(|| plan.as_array().cloned()).unwrap_or_default();
    let (mut tracks, mut vias) = (Vec::new(), Vec::new());
    for e in entries {
        let Some(net) = e["net"].as_str() else { continue };
        if e["source"] == "board" { continue; }
        let w = e["width"].as_f64().unwrap_or(0.25);
        let paths: Vec<Value> = e["paths"].as_array().cloned().or_else(|| e.get("points").map(|p| vec![p.clone()])).unwrap_or_default();
        for p in paths {
            let mut layer = 0usize;
            let mut prev: Option<(f64, f64)> = None;
            for (k, pt) in p.as_array().into_iter().flatten().enumerate() {
                let (xy, l): (Option<(f64, f64)>, Option<usize>) = if let Some(key) = pt.as_str() {
                    (board.pad_by_key(key).map(|p| (p.x, p.y)), None)
                } else if let Some(a) = pt.as_array() {
                    (a.first().and_then(|x| x.as_f64()).zip(a.get(1).and_then(|y| y.as_f64())), None)
                } else {
                    let l = pt["layer"].as_str().and_then(aiflow_board::copper_layer);
                    let xy = if let Some(key) = pt["pad"].as_str() { board.pad_by_key(key).map(|p| (p.x, p.y)) } else { pt["x"].as_f64().zip(pt["y"].as_f64()) };
                    (xy, l)
                };
                let Some(xy) = xy else { continue };
                if let Some(a) = prev {
                    if (a.0 - xy.0).hypot(a.1 - xy.1) > 1e-9 { tracks.push(Track { net: net.to_string(), layer, a, b: xy, width: w }); }
                }
                if let Some(l) = l {
                    if k > 0 && l != layer { vias.push(Via { net: net.to_string(), x: xy.0, y: xy.1, size: e["viaSize"].as_f64().unwrap_or(0.6), drill: e["viaDrill"].as_f64().unwrap_or(0.3), span: (0, aiflow_board::COPPER_BOTTOM) }); }
                    layer = l;
                }
                prev = Some(xy);
            }
        }
    }
    (tracks, vias)
}

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, 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; }
        let (ux, uy) = ((q.b.0 - q.a.0) / l, (q.b.1 - q.a.1) / l);
        if (dir.0 * uy - dir.1 * ux).abs() > 0.02 { return None; }
        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();
        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).
///
/// 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)).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)));
    let via_count: Vec<usize> = t.nets.iter().map(|n| vias.iter().filter(|v| &v.net == n).count()).collect();
    let mut by_layer: BTreeMap<String, f64> = BTreeMap::new();
    for v in &mine { for x in v { *by_layer.entry(layer_name(x.layer)).or_insert(0.0) += len(x); } }
    let mut out = json!({"target": t.name, "nets": t.nets, "differential": t.diff, "targetOhms": t.ohms, "tolerancePercent": t.tolerance, "lengthByLayerMm": by_layer.iter().map(|(k, v)| (k.clone(), json!(r2(*v)))).collect::<serde_json::Map<_, _>>(), "vias": via_count});
    if mine.iter().any(|v| v.is_empty()) {
        out["status"] = json!("fail");
        out["reasons"] = json!([format!("no copper on {}", t.nets.iter().zip(&mine).filter(|(_, v)| v.is_empty()).map(|(n, _)| n.clone()).collect::<Vec<_>>().join(", "))]);
        return out;
    }
    let mut reasons: Vec<String> = Vec::new();
    let step = 0.05;
    let (mut zmin, mut zmax, mut gmin, mut gmax) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
    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));
            let l = len(x);
            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 = 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); }
                }
            }
        }
    }
    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 };
    if t.diff {
        out["gapMm"] = if gmin.is_finite() { json!([r4(gmin), r4(gmax)]) } else { Value::Null };
        out["coupledPercent"] = json!(controlled.iter().zip(&line).map(|(c, l)| if *l > 0.0 { (c / l * 1000.0).round() / 10.0 } else { 0.0 }).collect::<Vec<_>>());
        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 {} (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)); }
    widths.sort_by(|a, b| a.total_cmp(b));
    widths.dedup();
    out["widthsMm"] = json!(widths);
    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) {
        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);
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    fn pct(a: f64, b: f64) -> f64 { (a - b).abs() / b * 100.0 }

    // Reference values: tools/impedance_reference.py (2-D finite-volume Laplace solve, two grids,
    // Richardson-extrapolated; no solder mask). Each is (closed form, field solve, allowed %).
    #[test]
    fn single_lines_match_the_field_solve() {
        let cases = [
            ("50 ohm microstrip, 1.6 mm FR4 er 4.5, w 2.95", microstrip(2.95, 1.6, 0.035, 4.5).0, 50.08, 1.5),
            ("JLC 7628 prepreg h 0.2104 er 4.4, w 0.35", microstrip(0.35, 0.2104, 0.035, 4.4).0, 51.67, 1.5),
            ("h 0.2 er 4.2, w 0.30", microstrip(0.30, 0.2, 0.035, 4.2).0, 55.61, 1.5),
            ("stripline b 0.5 er 4.3, w 0.2", stripline(0.2, 0.5, 0.035, 4.3), 46.82, 1.5),
            ("offset stripline 0.2 / 0.4 er 4.3, w 0.15", offset_stripline(0.15, 0.2, 0.4, 0.035, 4.3), 55.95, 1.5),
            ("offset stripline 0.2 / 0.6 er 4.3, w 0.15", offset_stripline(0.15, 0.2, 0.6, 0.035, 4.3), 58.39, 3.5),
        ];
        for (what, z, reference, allowed) in cases {
            eprintln!("{what}: closed form {z:.2} ohm, field solve {reference:.2} ({:.2} %)", pct(z, reference));
            assert!(pct(z, reference) < allowed, "{what}: {z:.2} vs {reference:.2}");
        }
        // eeff of the 1.6 mm line: field solve 3.362
        assert!(pct(microstrip(2.95, 1.6, 0.035, 4.5).1, 3.362) < 1.5);
    }

    #[test]
    fn pairs_match_the_field_solve() {
        let cases = [
            ("JLC 7628 w 0.2 s 0.2", 2.0 * coupled_microstrip(0.2, 0.2, 0.2104, 0.035, 4.4).0, 110.29, 4.0),
            ("JLC 7628 w 0.25 s 0.15", 2.0 * coupled_microstrip(0.25, 0.15, 0.2104, 0.035, 4.4).0, 94.58, 4.0),
            ("1.6 mm er 4.5 w 1.0 s 0.5", 2.0 * coupled_microstrip(1.0, 0.5, 1.6, 0.035, 4.5).0, 108.57, 3.0),
            ("stripline b 0.5 w 0.15 s 0.15", 2.0 * coupled_stripline(0.15, 0.15, 0.5, 0.035, 4.3).0, 84.19, 3.0),
        ];
        for (what, z, reference, allowed) in cases {
            eprintln!("{what}: closed form Zdiff {z:.2} ohm, field solve {reference:.2} ({:.2} %)", pct(z, reference));
            assert!(pct(z, reference) < allowed, "{what}: {z:.2} vs {reference:.2}");
        }
    }

    #[test]
    fn limits_and_identities() {
        // Wheeler at t = 0 against Cohn's exact elliptic-integral stripline (Wheeler states 0.5 %)
        for (w, b) in [(0.1, 0.5), (0.3, 0.5), (1.0, 0.5), (0.2, 1.2)] {
            assert!(pct(stripline(w, b, 0.0, 4.3), stripline_exact_t0(w, b, 4.3)) < 0.5, "{w} {b}");
        }
        // an offset stripline centred between its planes is the symmetric one
        assert!(pct(offset_stripline(0.2, 0.2325, 0.2325, 0.035, 4.3), stripline(0.2, 0.5, 0.035, 4.3)) < 1e-9);
        // far apart, a coupled line's odd and even impedances both become the single line's
        let (zo, ze) = coupled_stripline(0.2, 5.0, 0.5, 0.0, 4.3);
        let z = stripline_exact_t0(0.2, 0.5, 4.3);
        assert!(pct(zo, z) < 0.1 && pct(ze, z) < 0.1);
        let (zo, ze, _, _) = coupled_microstrip(0.3, 0.4, 0.2, 0.035, 4.2);
        let z = microstrip(0.3, 0.2, 0.035, 4.2).0;
        assert!(zo < z && ze > z, "odd below, even above the single line: {zo} {z} {ze}");
        // Pozar (Microwave Engineering, example 3.7): 50 ohm on er 2.2, d 1.27 mm is w 3.91 mm, eeff 1.87 (t = 0)
        let (z, ee) = microstrip(3.91, 1.27, 0.0, 2.2);
        assert!(pct(z, 50.0) < 2.0 && pct(ee, 1.87) < 1.0, "{z} {ee}");
        assert!((k_ratio(std::f64::consts::FRAC_1_SQRT_2) - 1.0).abs() < 1e-12);
        // 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, 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()
    }
    pub(crate) fn jlc_four_layer() -> Board {
        Board::from_text("t.kicad_pcb", "(kicad_pcb (version 20241229) (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (4 \"In1.Cu\" signal) (6 \"In2.Cu\" signal) (2 \"B.Cu\" signal)) (setup (stackup (layer \"F.Mask\" (type \"Top Solder Mask\") (thickness 0.01)) (layer \"F.Cu\" (type \"copper\") (thickness 0.035)) (layer \"dielectric 1\" (type \"prepreg\") (thickness 0.2104) (material \"7628\") (epsilon_r 4.4)) (layer \"In1.Cu\" (type \"copper\") (thickness 0.0152)) (layer \"dielectric 2\" (type \"core\") (thickness 1.065) (material \"FR4\") (epsilon_r 4.6)) (layer \"In2.Cu\" (type \"copper\") (thickness 0.0152)) (layer \"dielectric 3\" (type \"prepreg\") (thickness 0.2104) (material \"7628\") (epsilon_r 4.4)) (layer \"B.Cu\" (type \"copper\") (thickness 0.035)))) (gr_rect (start 0 0) (end 30 20) (layer \"Edge.Cuts\") (width 0.1)))".into()).unwrap()
    }

    #[test]
    fn stackup_sources_and_refusals() {
        let b = jlc_four_layer();
        let s = stackup(&b, &json!({})).unwrap();
        assert_eq!(s.layers.len(), 4);
        assert_eq!(s.layers[0].structure, "microstrip");
        assert!((s.layers[0].h1 - 0.2104).abs() < 1e-9 && (s.layers[0].er - 4.4).abs() < 1e-9);
        assert_eq!(s.layers[1].structure, "offset stripline");
        assert_eq!(s.sources["dielectric"], "board physical stackup");
        let s = stackup(&b, &json!({"stackupMm": [0.1, 1.2, 0.1], "copperUm": 70})).unwrap();
        assert!((s.layers[3].h1 - 0.1).abs() < 1e-9 && (s.layers[0].t - 0.07).abs() < 1e-9);
        assert!(stackup(&b, &json!({"stackupMm": [0.1, 1.2]})).unwrap_err().contains("one dielectric thickness per gap"));
        // two layers, three spec values: summed as adom-fields does
        let s = stackup(&two_layer(), &json!({"stackupMm": [0.2, 1.065, 0.2]})).unwrap();
        assert!((s.layers[0].h1 - 1.465).abs() < 1e-9);
        // no stackup and no permittivity are refused, never guessed
        let bare = Board::from_text("t.kicad_pcb", "(kicad_pcb (version 20241229) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)) (gr_rect (start 0 0) (end 30 20) (layer \"Edge.Cuts\") (width 0.1)))".into()).unwrap();
        assert!(stackup(&bare, &json!({})).unwrap_err().contains("stackupMm"));
        assert!(stackup(&bare, &json!({"stackupMm": [1.5]})).unwrap_err().contains("epsilonR"));
        assert!(stackup(&bare, &json!({"stackupMm": [1.5], "epsilonR": 4.3})).is_ok());
    }

    #[test]
    fn targets_read_both_spellings() {
        let t = targets(&json!({"impedanceTargets": {"USB_D": {"pair": ["USB_DP", "USB_DN"], "targetDiffOhms": 90}, "RF": {"net": "RF_OUT", "targetOhms": 50, "tolerancePercent": 5}}, "impedance": {"CLK": 50, "USB_DP/USB_DN": 90}})).unwrap();
        assert_eq!(t.len(), 3);
        assert!(t.iter().any(|x| x.name == "CLK" && !x.diff && x.tolerance == 10.0));
        assert!(t.iter().any(|x| x.name == "RF" && x.tolerance == 5.0));
        assert!(targets(&json!({"impedance": {"USB": {"pair": ["a", "b"]}}})).unwrap_err().contains("impedanceTargets"));
        assert!(targets(&json!({"impedanceTargets": {"X": {"pair": ["a"], "targetDiffOhms": 90}}})).is_err());
        assert!(targets(&json!({"impedanceTargets": {"X": {"net": "a", "targetOhms": 50}, "Y": {"net": "a", "targetOhms": 60}}})).unwrap_err().contains("two impedance targets"));
    }

    #[test]
    fn table_widths_and_refusals() {
        let fab = Fab { min_width: 0.127, min_space: 0.127, source: "test".into() };
        let ts = targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}, "RF": {"net": "RF", "targetOhms": 50}}})).unwrap();
        // the issue's case: 50 ohm on 1.6 mm FR4 er 4.5 is about 2.9 to 3.0 mm
        let two = stackup(&two_layer(), &json!({"stackupMm": [1.6]})).unwrap();
        let rows = table(&two, &ts, &fab, None);
        let rf = rows.iter().find(|r| r["target"] == "RF" && r["layer"] == "F.Cu").unwrap();
        let w = rf["widthMm"].as_f64().unwrap();
        assert!(rf["ok"] == true && (2.9..=3.0).contains(&w), "{rf}");
        // KiCad's default two-layer stackup is a 1.51 mm core between the foils: narrower
        let rows = table(&stackup(&two_layer(), &json!({})).unwrap(), &ts, &fab, None);
        let w = rows.iter().find(|r| r["target"] == "RF" && r["layer"] == "B.Cu").unwrap()["widthMm"].as_f64().unwrap();
        assert!((2.75..2.85).contains(&w), "{w}");
        // JLC four-layer 7628: a USB pair on F.Cu, and the router's grid pitch row
        let four = stackup(&jlc_four_layer(), &json!({})).unwrap();
        let grid = GridPair { res: 0.1, clearance: 0.127, slop: 0.0708 };
        let rows = table(&four, &ts, &fab, Some(&grid));
        let usb = rows.iter().find(|r| r["target"] == "USB" && r["layer"] == "F.Cu").unwrap();
        assert!(usb["ok"] == true, "{usb}");
        let (w, s) = (usb["widthMm"].as_f64().unwrap(), usb["gapMm"].as_f64().unwrap());
        assert!(((w + s) * 10.0 - ((w + s) * 10.0).round()).abs() < 1e-6, "pitch on the grid: {usb}");
        assert!(grid.reachable(w, w + s) && s >= 0.127);
        assert!(pct(four.layers[0].zdiff(w, s).0, 90.0) < 0.5);
        // a fab too coarse for the 50 ohm stripline width is refused on that layer, with the reason
        let coarse = Fab { min_width: 0.3, min_space: 0.3, source: "test".into() };
        let even = stackup(&jlc_four_layer(), &json!({"stackupMm": [0.2, 0.2, 0.2]})).unwrap();
        let rows = table(&even, &ts, &coarse, None);
        let inner = rows.iter().find(|r| r["target"] == "RF" && r["layer"] == "In1.Cu").unwrap();
        assert!(inner["ok"] == false && inner["why"].as_str().unwrap().contains("fab minimum"), "{inner}");
        // JLC's In1.Cu sits 5:1 between F.Cu and In2.Cu: past the offset approximation, refused
        let rows = table(&four, &ts, &fab, None);
        let inner = rows.iter().find(|r| r["target"] == "RF" && r["layer"] == "In1.Cu").unwrap();
        assert!(inner["ok"] == false && inner["why"].as_str().unwrap().contains("3:1"), "{inner}");
    }

    #[test]
    fn landed_pair_is_judged_on_its_real_gap() {
        let st = stackup(&jlc_four_layer(), &json!({})).unwrap();
        let ts = targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();
        let fab = Fab { min_width: 0.127, min_space: 0.127, source: "t".into() };
        let row = solve_row(&ts[0], &st.layers[0], &fab, None);
        let (w, s) = (row["widthMm"].as_f64().unwrap(), row["gapMm"].as_f64().unwrap());
        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, &[], &[], &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, &[], &[], &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), &[], &[], &GroundCopper::default())["status"], "pass");
        assert_eq!(check_target(&ts[0], &st, &one(0.15), &[], &[], &GroundCopper::default())["status"], "fail");
    }
}

#[cfg(test)]
mod route_tests {
    use super::*;
    use crate::{Router, Rules};

    /// A JLC four-layer board with a connector pair (J1) and a receiver pair (U1) offset in y,
    /// pads 0.35 x 1.0 mm on F.Cu at `pitch` mm.
    pub(crate) fn pair_board(pitch: f64, dy: f64) -> Board { pair_board_with(pitch, dy, "") }
    pub(crate) fn pair_board_with(pitch: f64, dy: f64, extra: &str) -> Board {
        let stack = "(setup (stackup (layer \"F.Cu\" (type \"copper\") (thickness 0.035)) (layer \"dielectric 1\" (type \"prepreg\") (thickness 0.2104) (epsilon_r 4.4)) (layer \"In1.Cu\" (type \"copper\") (thickness 0.0152)) (layer \"dielectric 2\" (type \"core\") (thickness 1.065) (epsilon_r 4.6)) (layer \"In2.Cu\" (type \"copper\") (thickness 0.0152)) (layer \"dielectric 3\" (type \"prepreg\") (thickness 0.2104) (epsilon_r 4.4)) (layer \"B.Cu\" (type \"copper\") (thickness 0.035))))";
        let fp = |r: &str, x: f64, y: f64| format!("(footprint \"T:{r}\" (layer \"F.Cu\") (at {x} {y}) (property \"Reference\" \"{r}\") (pad \"1\" smd rect (at 0 {}) (size 1.0 0.35) (layers \"F.Cu\") (net \"DP\")) (pad \"2\" smd rect (at 0 {}) (size 1.0 0.35) (layers \"F.Cu\") (net \"DN\")))", -pitch / 2.0, pitch / 2.0);
        let t = format!("(kicad_pcb (version 20260206) (generator \"test\") (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (4 \"In1.Cu\" signal) (6 \"In2.Cu\" signal) (2 \"B.Cu\" signal) (25 \"Edge.Cuts\" user)) {stack} {} {} {extra} (gr_rect (start 0 0) (end 40 20) (layer \"Edge.Cuts\")))", fp("J1", 4.0, 6.0), fp("U1", 36.0, 6.0 + dy));
        Board::from_text("pair.kicad_pcb", t).unwrap()
    }

    pub(crate) fn route_pair(b: Board, clearance: f64) -> (Value, Value) {
        let ts = targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();
        let st = stackup(&b, &json!({})).unwrap();
        let fab = Fab { min_width: 0.127, min_space: 0.127, source: "test".into() };
        let grid = GridPair { res: aiflow_grid::RES, clearance, slop: crate::slop() };
        let row = solve_row(&ts[0], &st.layers[0], &fab, Some(&grid));
        assert_eq!(row["ok"], true, "{row}");
        let mut rules = Rules { clearance, ..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: 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, &GroundCopper::default()))
    }

    #[test]
    fn router_keeps_a_pair_coupled_on_one_layer() {
        let (row, c) = route_pair(pair_board(0.7, 3.0), 0.127);
        eprintln!("row {row}\ncheck {c}");
        assert_eq!(c["vias"], json!([0, 0]), "held to F.Cu: {c}");
        assert!(c["coupledPercent"][0].as_f64().unwrap() > 90.0, "{c}");
        assert_eq!(c["status"], "pass", "{c}");
        // 0.5 mm pads (a USB connector's pitch, under the router's 0.7 mm pair pitch): the pair fans
        // out at each end and runs coupled between
        let (_, c) = route_pair(pair_board(0.5, 5.0), 0.127);
        eprintln!("0.5 pitch {c}");
        assert_eq!(c["status"], "pass", "{c}");
    }

    #[test]
    fn a_split_pair_is_reported_not_claimed() {
        // a through-hole pad of another net on the straight line between the pairs: one net goes
        // round each side or both go round, but the check says what the copper really does
        let post = "(footprint \"T:P\" (layer \"F.Cu\") (at 20 6) (property \"Reference\" \"TP1\") (pad \"1\" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 0.8) (layers \"*.Cu\") (net \"X\")))";
        let (_, c) = route_pair(pair_board_with(0.7, 0.0, post), 0.127);
        eprintln!("split {c}");
        // today the grid router sends the two nets round opposite sides of the post: not coupled there
        assert_eq!(c["status"], "fail", "{c}");
        assert_eq!(c["vias"], json!([0, 0]));
        // whatever the router found, the verdict comes from the measured coupling
        let coupled = c["coupledPercent"][0].as_f64().unwrap();
        let unc = c["uncontrolledMm"][0].as_f64().unwrap();
        assert_eq!(c["status"] == "pass", unc <= 3.0 && c["skewMm"].as_f64().unwrap() <= 0.5, "{c}");
        assert!(coupled > 0.0);
    }
}