main
Colby Knox adom-fields 0.10.7: the board's ground (issue 25); coincident lands as one terminal (issue 26); verify keeps via copper… 554eeec
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919
//! The fields: current density per loaded net, heat flow in the copper, temperature over the board.
use crate::board::{Board, LAYERS};
use crate::raster::{Grid, Raster};
use crate::solve::{harm, System};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;

pub const SIGMA: f64 = 5.8e4;          // S/mm, copper
pub const K_CU: f64 = 0.40;            // W/(mm K)
pub const K_FR4_XY: f64 = 0.0006;      // W/(mm K), in plane
pub const K_FR4_Z: f64 = 0.0003;       // W/(mm K), through
pub const H_STILL: f64 = 1.0e-5;       // W/(mm2 K): about 10 W/m2K, still air
pub const H_MOVING: f64 = 2.5e-5;      // moving air
/// h at fan level s (0 off, 1 full): natural and forced convection combined as (h0³ + (hf·sⁿ)³)^⅓, with hf chosen so
/// s = 1 gives h1 (Churchill's mixed-convection blend; n = 0.5 for laminar flow over plates and fins)
pub fn fan_mix(h0: f64, h1: f64, s: f64, n: f64) -> f64 { let hf = (h1.powi(3) - h0.powi(3)).max(0.0).cbrt(); (h0.powi(3) + (hf * s.clamp(0.0, 1.0).powf(n)).powi(3)).cbrt() }
pub struct FanLevel { pub level: f64, pub t: Vec<Grid>, pub q: Vec<Grid>, pub chips: Vec<(String, f64)>, pub solids: Value, pub solids_cells: Value, pub residual: f64, pub h_board_w_m2k: f64 }

#[derive(Deserialize, Default, Clone)]
pub struct Spec {
    #[serde(default = "d35")] pub copperUm: f64,
    #[serde(default)] pub loads: BTreeMap<String, Load>,
    #[serde(default)] pub hot: BTreeMap<String, Hot>,
    #[serde(default = "d60")] pub hotMaxRiseC: f64,
    #[serde(default)] pub thermalVias: Vec<Value>,
    #[serde(default)] pub board: String,
    /// the net that is the board's ground, when the names leave it open (issue 25)
    #[serde(default)] pub ground: Option<String>,
    #[serde(default)] pub copperUmByLayer: BTreeMap<String,f64>,
    #[serde(default)] pub signals: crate::signals::Config,
    #[serde(default)] pub heatsinks: Vec<crate::heatsink::Block>,
    /// a power device cooled off the board: junction, case and sink nodes with supplied resistances (PR 11)
    #[serde(default)] pub packageCooling: Vec<crate::package_cooling::Package>,
    /// solid bodies off the board (package, pad, finned sink) solved with the board's heat (crate::solids)
    #[serde(default)] pub solids: Option<crate::solids::Solids>,
    /// receiver screens (crate::rf, issue 9): junction capacitances, the antenna port, the LO leak limit, the stage isolation target
    #[serde(default)] pub rf: RfCfg,
    /// the radiated-emission limit the issues use: fcc-b (default), fcc-a, cispr32-b, cispr32-a, or a name from emcLimits
    #[serde(default)] pub emcLimit: Option<String>,
    /// extra limit lines (CISPR 25, MIL-STD-461 RE102, a customer's): {name, distanceM, points [[MHz, dBµV/m], ...]}
    #[serde(default)] pub emcLimits: Vec<crate::limits::Custom>,
    /// ambient for absolute temperatures, °C (25 by default)
    #[serde(default)] pub ambientC: Option<f64>,
    /// cable length on each connector, m (1 m when not given), for the common-mode radiation screen
    #[serde(default)] pub cables: BTreeMap<String, f64>,
    /// impedance targets, Ω: a net's single-ended target by its name, a pair's differential target as "P/N"
    #[serde(default)] pub impedance: BTreeMap<String, f64>,
    /// groups of nets whose delays must match: {name: {nets, maxSkewPs | maxSkewMm | protocol}}
    /// bench readings that calibrate the compliance prediction: [{source: conducted | loop | cable:J2 | lo:<net>, fMHz, value, unit: dBuV | dBuA | dBuVm}]
    #[serde(default)] pub measurements: Vec<crate::compliance::Measurement>,
    /// reflection / TDR settings per net: {driverOhm (25), riseNs (1, a clock 7 % of its period), loadPf (3), from: "U1.5", to: "J2.3"}
    #[serde(default)] pub tdr: BTreeMap<String, crate::tdr::TdrCfg>,
    /// the connectors that face the outside world (a user cable, a hot-plugged lead): the esd module checks only these when given
    #[serde(default)] pub external: Vec<String>,
    /// the switching converter, where the board alone does not say: {inductor: "L1", vinMin: 21} (vinMin evaluates a
    /// buck-boost's boost mode)
    #[serde(default)] pub converter: Option<ConverterCfg>,
    #[serde(default)] pub lengthGroups: BTreeMap<String, crate::checks::LengthGroup>,
    /// fan levels to solve besides still air (0) and full moving air (1), as fractions of full speed, e.g. [0.25, 0.5, 0.75]
    #[serde(default)] pub fanLevels: Vec<f64>,
    /// forced-convection h grows as speed^n (0.5 laminar, 0.8 turbulent); 0.5 when not given
    #[serde(default)] pub fanExponent: Option<f64>,
    #[serde(default)] pub stackupMm: Vec<f64>,
    /// each net's rail voltage for the river view; nets not named here are read from their names (GND 0, 3V3 3.3, +12V 12)
    #[serde(default)] pub voltages: BTreeMap<String, f64>,
    /// the parts a net's current flows OUT of, when that is not the connector side (a regulator output: "VOUT": ["L1"])
    #[serde(default)] pub sources: BTreeMap<String, Vec<String>>,
    /// switching nodes: the copper that swings between two rails ("SW": {"highV": 12, "lowV": 0, "freqHz": 500000})
    #[serde(default)] pub switching: BTreeMap<String, crate::flow::Switching>,
    /// the design review's budgets: ground offset (mV, 10), IR drop per rail (% of the rail, 1), the highest EMC frequency (Hz, 1 GHz)
    #[serde(default)] pub groundOffsetMv: Option<f64>, #[serde(default)] pub dropPct: Option<f64>, #[serde(default)] pub emcFmaxHz: Option<f64>,
    /// named clocks (net: Hz), for the resonance and crosstalk checks; the coupled-noise budget on a quiet net (mV, 50)
    /// each clock or oscillator: a frequency (Hz), or {hz | minHz + maxHz (a tuned LO), harmonics, amplitudeV}
    #[serde(default)] pub clocks: BTreeMap<String, Clock>, #[serde(default)] pub crosstalkMv: Option<f64>,
    /// a coupled-noise budget per victim net (mV), over crosstalkMv: an RF or IF input's is microvolts
    #[serde(default)] pub victimBudgetsMv: BTreeMap<String, f64>,
    /// the receive bands for the frequency plan: {name: {minHz, maxHz, ifHz (its image is the band + 2 IF), lowSideLo}}
    #[serde(default)] pub bands: BTreeMap<String, Band>,
    /// nets a stray tone must not reach (an antenna port, an IF input): circuit paths to them from each oscillator are reported
    #[serde(default)] pub sensitiveNets: Vec<String>,
    /// the power-delivery check per rail: target impedance or tolerance and step, band, source, load pin
    #[serde(default)] pub pdn: BTreeMap<String, crate::pdn::RailCfg>,
    /// a folder of measured capacitor curves (Murata SimSurfing JSON: C-DC bias, |Z|, R) keyed by MPN; else ADOM_FIELDS_CAP_CURVES
    #[serde(default)] pub capCurves: Option<String>,
    /// the dielectric's εr when the board's stackup does not say (4.3)
    #[serde(default)] pub epsilonR: Option<f64>,
    /// the current solve's iteration budget per net (PR 5); the convergence target is unchanged
    #[serde(default = "d20000")] pub dcMaxIterations: usize,
    /// set by analyze: εr per slot gap (from the board's stackup), the real gaps as given, and the real copper layers
    /// solve the current again with copper's resistance at the solved temperature (crate: two passes; +0.393 %/°C)
    #[serde(default)] pub electroThermal: Option<bool>,
    /// a heating transient: {durationS, steps (24), powerScale (1: every heat source × this during the pulse), fromSteady (false: from cold)}
    #[serde(default)] pub transient: Option<Transient>,
    /// the previous pass's temperature rise per layer slot, for the electro-thermal pass
    #[serde(skip)] pub cu_rise: Option<Vec<Grid>>,
    #[serde(skip)] pub er_slots: Option<Vec<f64>>, #[serde(skip)] pub real_gaps: Vec<f64>, #[serde(skip)] pub real_copper: Vec<String>,
}
fn d20000() -> usize { 20000 }
fn d35() -> f64 { 35.0 }
fn d60() -> f64 { 60.0 }
/// a loaded net: its current, and optionally its pins' signed currents (PR 6: positive enters the copper, negative leaves;
/// each side sums to amps), instead of the connector-to-parts heuristic
#[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64>, /** the IR drop this net may have from its source to its farthest sink, mV (issue 14) */ #[serde(default)] pub maxDropMv: Option<f64>, /** each sink part's own draw on this net, amps, e.g. {"U3": 0.3, "J2": 4.0} (issue 17); the rest of the net's amps splits over the other sinks by pad area */ #[serde(default)] pub draws: BTreeMap<String, f64>, #[serde(default)] pub terminals: Option<Vec<crate::terminals::Terminal>> }
/// a part's dissipation: through its exposed tab's net (tabNet), or, for a leaded part with no tab, through all its leads,
/// shared by pad area (issue 8)
#[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, #[serde(default)] pub tabNet: String, /** this part's own rise limit, °C; spec hotMaxRiseC when not given (issue 16) */ #[serde(default)] pub maxRiseC: Option<f64>, /** absolute maximum junction temperature, °C (125 when not given) */ #[serde(default)] pub tjMaxC: Option<f64>, /** junction-to-case (tab), K/W, added on top of the solved tab rise */ #[serde(default)] pub thetaJcKPerW: Option<f64>, /** the pad the part is read at (its exposed pad's number, e.g. "29"); the largest tab-net pad when not given */ #[serde(default)] pub tabPad: Option<String> }

#[derive(Serialize, Clone)]
pub struct NetResult { /** the current on every terminal pin the solve used: + into the copper, - out (issue 17) */ pub terminalCurrents: Vec<Value>, /** heuristic or explicit, and how the sources and sinks were chosen */ pub terminalBasis: String, pub residual: f64, pub converged: bool, pub platedPads: usize, pub platedConnections: Vec<Value>, pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize, /** cells where two separate pieces of this net's copper came closer than a cell and were kept apart (issue 22) */ #[serde(skip_serializing_if = "Vec::is_empty")] pub piecesKeptApart: Vec<Value> }
#[derive(Serialize, Clone)]
pub struct ChipResult { pub reference: String, pub watts: f64, pub tabNet: String, pub x: f64, pub y: f64, pub riseC: f64, pub riseMovingC: f64, pub planeW: f64, pub viaW: f64, pub vias: usize, pub otherViaW: f64, pub otherVias: usize, pub dielW: f64, pub airW: f64, pub viasWanted: usize }
#[derive(Serialize, Clone)]
pub struct Issue { pub severity: String, pub kind: String, pub what: String, pub at: Option<[f64; 2]>, pub layer: Option<String>, pub net: Option<String>, pub reference: Option<String>, pub fix: String,
    /// what to change, in a form an AI can carry out (EMC and ground findings): {type: move|add-vias|pour|tie|review, ...}
    #[serde(skip_serializing_if = "Option::is_none")] pub action: Option<Value> }

pub struct Fields {
    pub d: f64, pub h: usize, pub w: usize, pub x0: f64, pub y0: f64,
    pub frac: Vec<Grid>,                 // all copper per layer
    pub j_net: BTreeMap<String, Vec<Grid>>, // A/mm2 per net per layer
    pub cu_net: BTreeMap<String, Vec<Grid>>, // copper fraction per net per layer, for isolating a pour
    pub j_all: Vec<Grid>,
    pub q: Vec<Grid>,                    // W/mm per layer
    pub t: Vec<Grid>,                    // C rise per layer, still air
    pub t_moving: Vec<Grid>,
    pub nets: Vec<NetResult>, pub chips: Vec<ChipResult>, pub issues: Vec<Issue>,
    pub joule_total: f64, pub tab_total: f64,
    /// the Joule loss per cell and layer slot, W (for the electro-thermal estimate)
    pub joule_map: Vec<Grid>,
    /// the heating transient (spec transient)
    pub transient: Value,
    pub heatsinks: Vec<Value>,
    pub package_cooling: Vec<Value>,
    /// the solid bodies' summary (fields.json `solids`) and their cells for the viewer (solids.json)
    pub solids: Value, pub solids_cells: Value,
    /// the extra fan levels (spec fanLevels), each a full solve
    pub fan: Vec<FanLevel>,
    pub signals: Value,
    pub heat_residual: f64, pub heat_moving_residual: f64,
    /// the signed current, the solved potential and the terminals of each loaded net, for the flow and river views
    pub flow: BTreeMap<String, crate::flow::NetFlow>,
    /// which net owns each cell, per layer (index into `net_names`, u16::MAX where there is no copper)
    pub net_ids: Vec<Vec<u16>>, pub net_names: Vec<String>,
}

pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMut(&str)) -> Fields {
    let fine = pitch / 2.0;
    let pool = 2;
    let mut ras = Raster::pooled(b, fine, pool);
    ras.draw_board(b);
    let (w, h) = (ras.w / pool, ras.h / pool);
    let d = pitch;
    let t_cu: Vec<f64> = LAYERS.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).collect();
    let n = w * h;
    let cell = |x: f64, y: f64| cell_at(x, y, b.x0, b.y0, d, w, h);
    // all copper per layer
    let mut frac: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();
    for (k, l) in LAYERS.iter().enumerate() { for net in ras.nets_on(l) { let g = ras.fraction(&net, l, pool); frac[k].max_with(&g); } }
    // ---- current, one DC solve per loaded net --------------------------------------------------
    let mut j_net: BTreeMap<String, Vec<Grid>> = BTreeMap::new();
    let mut j_all: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();
    let mut p_joule:Vec<Grid> = LAYERS.iter().map(|_|Grid::new(w,h)).collect();
    let mut nets: Vec<NetResult> = Vec::new();
    let mut flow: BTreeMap<String, crate::flow::NetFlow> = BTreeMap::new();
    for (net, load) in &spec.loads {
        // each layer's copper as pieces (crate::pieces, issue 22): a cell belongs to the piece with most copper in it, and
        // cells of different pieces do not conduct, so same-net copper closer than a cell is not joined where the board
        // does not join it
        let mut met: Vec<Value> = Vec::new();
        let (fr, piece): (Vec<Grid>, Vec<Vec<u32>>) = LAYERS.iter().map(|l| {
            match ras.masks.get(&(net.clone(), l.to_string())) {
                None => (Grid::new(w, h), vec![u32::MAX; w * h]),
                Some(m) => { let (f, c, mt) = crate::pieces::cells(b, net, l, m, ras.w, ras.h, ras.res, ras.x0, ras.y0, pool);
                    for (r, col) in mt.iter().take(20) { met.push(json!({"layer": l, "at": [((b.x0 + (*col as f64 + 0.5) * d) * 100.0).round() / 100.0, ((b.y0 + (*r as f64 + 0.5) * d) * 100.0).round() / 100.0]})); }
                    if !mt.is_empty() { log(&format!("{net} on {l}: {} cell(s) where two separate pieces of its copper came closer than one cell; kept apart (--pitch {:.2} resolves both)", mt.len(), d / 2.0)); }
                    let mut g = Grid::new(w, h); g.v.copy_from_slice(&f); (g, c) }
            } }).unzip();
        let live: Vec<usize> = (0..LAYERS.len()).filter(|k| fr[*k].sum() > 0.0).collect();
        if live.is_empty() { log(&format!("{net}: no copper on the board, skipped")); continue; }
        let nl = live.len();
        let mut sys = System::new(nl, h, w);
        for (a, k) in live.iter().enumerate() {
            let f = &fr[*k];
            // copper's conductivity at its solved temperature on the electro-thermal pass (20 °C reference, 0.393 %/°C)
            let amb = spec.ambientC.unwrap_or(25.0);
            let sg = |i: usize| -> f64 { match &spec.cu_rise { Some(t) => SIGMA / (1.0 + 0.00393 * (amb + t[*k].v[i] as f64 - 20.0)), None => SIGMA } };
            let pc = &piece[*k]; let same = |i: usize, j: usize| if pc[i] == pc[j] { 1.0 } else { 0.0 };
            for r in 0..h { for c in 0..w - 1 { let i = r * w + c; sys.gx[a][r * (w - 1) + c] = 0.5 * (sg(i) + sg(i + 1)) * t_cu[*k] * harm(f.at(r, c) as f64, f.at(r, c + 1) as f64) * same(i, i + 1); } }
            for r in 0..h - 1 { for c in 0..w { let i = r * w + c; sys.gy[a][r * w + c] = 0.5 * (sg(i) + sg(i + w)) * t_cu[*k] * harm(f.at(r, c) as f64, f.at(r + 1, c) as f64) * same(i, i + w); } }
            for i in 0..n { sys.leak[a][i] = if f.v[i] > 0.0 { 1e-9 } else { 1.0 }; }
        }
        // every via and plated through-hole pad barrel on this net joins its live layers, each span by its real length
        // through the stack (PR 5: a connector's plated pad joins top and bottom ground even with no via beside it)
        let nvias = b.vias.iter().filter(|v| v.net == *net).count();
        let slot = slot_gaps(spec);
        // the barrel's conductance spread around its plated wall, on cells with copper on both faces (PR 9); a wall the mesh
        // does not resolve (a small via's ring at a coarse pitch, issue 11) falls back to the nearest cells with copper on both
        // faces, so a plated hole never disconnects a net
        let barrel_cells = |v: &crate::board::Via, ka: usize, kb: usize| -> (Vec<(usize, f64)>, f64) {
            let ws = crate::barrel::weights(v, b.x0, b.y0, d, w, h);
            let ok = |i: usize| fr[ka].v[i] > 0.0 && fr[kb].v[i] > 0.0;
            let norm: f64 = ws.iter().filter(|(i, _)| ok(*i)).map(|(_, q)| q).sum();
            if norm > 0.0 { return (ws.into_iter().filter(|(i, _)| ok(*i)).map(|(i, q)| (i, q / norm)).collect(), norm.min(1.0)); }
            let Some((r0, c0)) = cell(v.x, v.y) else { return (Vec::new(), 0.0) };
            let reach = ((v.size.max(v.drill_long.unwrap_or(0.0)) / 2.0 + d) / d).ceil() as isize;
            let mut near: Vec<(usize, f64)> = Vec::new();
            for dr in -reach..=reach { for dc in -reach..=reach { let (r, c) = (r0 as isize + dr, c0 as isize + dc); if r < 0 || c < 0 || r as usize >= h || c as usize >= w { continue; } let i = r as usize * w + c as usize; if ok(i) { near.push((i, ((dr * dr + dc * dc) as f64).sqrt())); } } }
            near.sort_by(|a, z| a.1.partial_cmp(&z.1).unwrap()); near.truncate(4); let k = near.len().max(1) as f64;
            (near.into_iter().map(|(i, _)| (i, 1.0 / k)).collect(), 0.0)
        };
        for v in b.vias.iter().chain(b.plated_holes.iter()).filter(|v| v.net == *net) {
            let sa = SIGMA * v.barrel_area_mm2();
            for a in 0..nl.saturating_sub(1) { let (k0, k1) = (live[a], live[a + 1]); let span: f64 = slot[k0..k1].iter().sum::<f64>() + (t_cu[k0] + t_cu[k1]) / 2.0; for (i, q) in barrel_cells(v, k0, k1).0 { sys.gz[a][i] += sa / span.max(0.05) * q; } }
        }
        // terminals: the external pads (wire pads, connectors) on one side, the chips that draw the
        // current on the other, each chip's share by its pad area on the net (a gate driver's sense pin
        // takes almost nothing, a FET tab takes its part); the density does not care which way it flows
        let pads: Vec<&crate::board::Pad> = b.pads.iter().filter(|p| p.net == *net).collect();
        let is_ext = |p: &crate::board::Pad| p.reference.starts_with("MC") || p.reference.starts_with('J') || p.reference.starts_with('P');
        let is_chip = |p: &crate::board::Pad| p.reference.starts_with('Q') || p.reference.starts_with('U');
        let mut ext: Vec<&crate::board::Pad> = pads.iter().cloned().filter(|p| is_ext(p)).collect();
        // the parts that draw the current: the spec's hot parts when any sit on this net (a FET's
        // drain tab and its source pins both count), else the Q and U parts on the net
        let hot_pads: Vec<&crate::board::Pad> = pads.iter().cloned().filter(|p| spec.hot.contains_key(&p.reference)).collect();
        let mut chips: Vec<&crate::board::Pad> = if !hot_pads.is_empty() { hot_pads } else { pads.iter().cloned().filter(|p| is_chip(p)).collect() };
        if chips.is_empty() { chips = pads.iter().cloned().filter(|p| !is_ext(p)).collect(); }
        if ext.is_empty() {
            // no external pad (a regulator's output net): the part with the biggest pads on the net is
            // the source, the other Q and U parts (or whatever else is left) are the sinks
            let cands: Vec<&crate::board::Pad> = pads.iter().cloned().filter(|p| !p.reference.starts_with('C') && !p.reference.starts_with("TP")).collect();
            let mut by_ref: BTreeMap<String, f64> = BTreeMap::new();
            for p in &cands { *by_ref.entry(p.reference.clone()).or_default() += p.area; }
            if let Some((big, _)) = by_ref.iter().max_by(|a, bb| a.1.partial_cmp(bb.1).unwrap()) {
                let big = big.clone();
                ext = cands.iter().cloned().filter(|p| p.reference == big).collect();
                let rest: Vec<&crate::board::Pad> = cands.iter().cloned().filter(|p| p.reference != big && is_chip(p)).collect();
                chips = if rest.is_empty() { cands.iter().cloned().filter(|p| p.reference != big).collect() } else { rest };
            }
        }
        // named sources (spec `sources`) decide the source side: everything else that draws on the net is a sink, a
        // board-to-board connector included (issue 17)
        let mut basis = if load.terminals.is_some() { "explicit: spec loads.terminals".to_string() } else { "heuristic: connectors in, parts out by pad area".to_string() };
        if load.terminals.is_none() { if let Some(src) = spec.sources.get(net).filter(|l| !l.is_empty()) {
            let sp: Vec<&crate::board::Pad> = pads.iter().cloned().filter(|p| src.contains(&p.reference)).collect();
            if !sp.is_empty() {
                let passive = |p: &crate::board::Pad| p.reference.starts_with('C') || p.reference.starts_with("TP");
                let mut sinks: Vec<&crate::board::Pad> = pads.iter().cloned().filter(|p| !src.contains(&p.reference) && (load.draws.contains_key(&p.reference) || is_ext(p) || is_chip(p) || spec.hot.contains_key(&p.reference))).collect();
                if sinks.is_empty() { sinks = pads.iter().cloned().filter(|p| !src.contains(&p.reference) && !passive(p)).collect(); }
                ext = sp; chips = sinks; basis = format!("heuristic: sources {} (spec), every other connector and part as sinks", src.join(", "));
            }
        } }
        if load.terminals.is_none() && !load.draws.is_empty() {
            for r in load.draws.keys() { if !chips.iter().any(|p| p.reference == *r) { for p in pads.iter().filter(|p| p.reference == *r) { if !ext.iter().any(|e| e.reference == *r) { chips.push(*p); } } } }
            basis.push_str("; parts with spec loads.draws carry their own amps, the rest share what is left");
        }
        if load.terminals.is_none() && (ext.is_empty() || chips.is_empty()) { log(&format!("{net}: no terminals found (needs pads of two parts on the net), skipped")); continue; }
        let mut area_by_ref: BTreeMap<String, f64> = BTreeMap::new();
        for p in &chips { *area_by_ref.entry(p.reference.clone()).or_default() += p.area.max(0.05); }
        let area_total: f64 = area_by_ref.values().sum();
        let mut rhs = vec![0.0; nl * n];
        let mut spots_of = |p: &crate::board::Pad| -> Vec<(usize, usize, usize)> {
            // a pad centred on the board edge or just past it still reaches the copper inside the grid (issue 27)
            let (r0, c0) = (((p.y - b.y0) / d).floor() as isize, ((p.x - b.x0) / d).floor() as isize);
            // within 1 mm, or the pad's own ring when it is wider: a large plated pad's drill is a void (PR 9)
            let rad = ((1.0f64).max((p.area / std::f64::consts::PI).sqrt() + d) / d).ceil() as isize;
            let mut spots: Vec<(usize, usize, usize)> = Vec::new();
            for (a, k) in live.iter().enumerate() {
                if !p.layers.iter().any(|l| l == LAYERS[*k]) { continue; }
                for dr in -rad..=rad { for dc in -rad..=rad {
                    let (r, c) = (r0 + dr, c0 + dc);
                    if r < 0 || c < 0 || r as usize >= h || c as usize >= w || dr * dr + dc * dc > rad * rad { continue; }
                    if fr[*k].at(r as usize, c as usize) > 0.0 { spots.push((a, r as usize, c as usize)); }
                } }
            }
            spots
        };
        let (mut hs, mut hk) = (0usize, 0usize);
        // the pins and their signed currents: the spec's (PR 6), else the connectors in, the parts out by pad area
        let assignments: Vec<(&crate::board::Pad, f64)> = if let Some(rows) = &load.terminals {
            match crate::terminals::resolve(b, net, load.amps, rows) { Ok(r) => r, Err(e) => { log(&e); continue; } }
        } else {
            // the source side: a part with a spec draw feeds that much, the other source pins share the rest evenly
            let src_refs: std::collections::BTreeSet<String> = ext.iter().map(|p| p.reference.clone()).collect();
            let fed: f64 = src_refs.iter().filter_map(|r| load.draws.get(r)).sum();
            let free_pins = ext.iter().filter(|p| !load.draws.contains_key(&p.reference)).count() as f64;
            let fscale = if fed > load.amps + 1e-9 || (free_pins == 0.0 && fed > 0.0) { load.amps / fed } else { 1.0 };
            let mut rows: Vec<(&crate::board::Pad, f64)> = ext.iter().map(|p| { let pins = ext.iter().filter(|q| q.reference == p.reference).count() as f64;
                (*p, match load.draws.get(&p.reference) { Some(a) => a * fscale / pins, None => if free_pins > 0.0 { (load.amps - fed * fscale).max(0.0) / free_pins } else { 0.0 } }) }).collect();
            // each sink part's draw: the spec's (loads.draws), the rest split over the other parts by pad area; a part's
            // share splits evenly over its pins (issue 17)
            let drawn: f64 = chips.iter().map(|p| p.reference.clone()).collect::<std::collections::BTreeSet<_>>().iter().filter(|r| !src_refs.contains(*r)).filter_map(|r| load.draws.get(r)).sum();
            let rest_area: f64 = area_by_ref.iter().filter(|(r, _)| !load.draws.contains_key(*r)).map(|(_, a)| a).sum();
            let scale = if drawn > load.amps + 1e-9 || (rest_area <= 0.0 && drawn > 0.0) { log(&format!("{net}: loads.draws add up to {drawn:.3} A against the net's {} A; scaled to match", load.amps)); load.amps / drawn } else { 1.0 };
            let left = (load.amps - drawn * scale).max(0.0);
            for p in &chips { let pins = chips.iter().filter(|q| q.reference == p.reference).count() as f64;
                let part = match load.draws.get(&p.reference) { Some(a) => a * scale, None => if rest_area > 0.0 { left * area_by_ref[&p.reference] / rest_area } else { 0.0 } };
                rows.push((*p, -part / pins)); }
            rows
        };
        let (mut ext_spots, mut chip_spots, mut terminals): (Vec<usize>, Vec<usize>, Vec<crate::flow::Terminal>) = (Vec::new(), Vec::new(), Vec::new());
        let mut pinned = false; let mut pinned_at: Option<usize> = None; let mut missing = false;
        // a guessed pin with no copper is left out, and its share goes to the other pins on its side, so the sources and
        // sinks still balance; an unbalanced injection would drain through the reference pin as a false hot spot (issue 17)
        let mut kept: Vec<(&crate::board::Pad, f64, Vec<(usize, usize, usize)>)> = Vec::new();
        for (p, amps) in &assignments {
            if *amps == 0.0 { continue; }
            let sp = spots_of(p);
            if sp.is_empty() { if load.terminals.is_some() { missing = true; break; } log(&format!("{net}: {}.{} has no copper within 1 mm, left out of the terminals (its share goes to the other pins on its side)", p.reference, p.pad)); continue; }
            kept.push((*p, *amps, sp));
        }
        if load.terminals.is_none() { for sign in [1.0f64, -1.0] {
            let sum: f64 = kept.iter().filter(|k| k.1 * sign > 0.0).map(|k| k.1.abs()).sum();
            if sum > 0.0 && (sum - load.amps).abs() > 1e-9 { for k in kept.iter_mut().filter(|k| k.1 * sign > 0.0) { k.1 *= load.amps / sum; } }
        } }
        let assignments: Vec<(&crate::board::Pad, f64)> = kept.iter().map(|k| (k.0, k.1)).collect();
        let spots: Vec<Vec<(usize, usize, usize)>> = kept.into_iter().map(|k| k.2).collect();
        for (ti, (p, amps)) in assignments.iter().enumerate() {
            let sp = spots[ti].clone();
            if *amps > 0.0 { hs += 1; } else { hk += 1; }
            for (a, r, c) in &sp { rhs[a * n + r * w + c] += amps / sp.len() as f64; if *amps > 0.0 { ext_spots.push(a * n + r * w + c); } else { chip_spots.push(a * n + r * w + c); } }
            terminals.push(crate::flow::Terminal { reference: p.reference.clone(), pad: p.pad.clone(), x: p.x, y: p.y, amps: amps.abs(), side: if *amps > 0.0 { "ext".into() } else { "chip".into() } });
            if *amps < 0.0 && !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; pinned_at = Some(a * n + r * w + c); }
        }
        if missing { log(&format!("{net}: a terminal has no copper under it; no partial current injection")); continue; }
        if hs == 0 || hk == 0 { log(&format!("{net}: terminals not on copper (sources {hs}, sinks {hk}), skipped")); continue; }
        // copper pieces with terminal current but no path to the pinned spot float on the tiny leak and stall the solve
        // (issue 9: GND 6000 iterations at 1.7e-1): give each such piece its own reference
        let mut islands = 0usize;
        {
            let nn = sys.n(); let mut par: Vec<usize> = (0..nn).collect();
            fn find(p: &mut Vec<usize>, mut x: usize) -> usize { while p[x] != x { p[x] = p[p[x]]; x = p[x]; } x }
            let mut uni = |p: &mut Vec<usize>, a: usize, b: usize| { let (ra, rb) = (find(p, a), find(p, b)); if ra != rb { p[ra] = rb; } };
            for k in 0..sys.nl { for r in 0..h { for c in 0..w - 1 { if sys.gx[k][r * (w - 1) + c] > 0.0 { uni(&mut par, k * n + r * w + c, k * n + r * w + c + 1); } } } for r in 0..h - 1 { for c in 0..w { if sys.gy[k][r * w + c] > 0.0 { uni(&mut par, k * n + r * w + c, k * n + (r + 1) * w + c); } } } }
            for k in 0..sys.nl.saturating_sub(1) { for i in 0..n { if sys.gz[k][i] > 0.0 { uni(&mut par, k * n + i, (k + 1) * n + i); } } }
            for &(a, b, g) in &sys.edges.clone() { if g > 0.0 { uni(&mut par, a, b); } }
            if let Some(pa) = pinned_at { let root = find(&mut par, pa); let mut done: std::collections::BTreeSet<usize> = Default::default(); done.insert(root);
                for &sp in ext_spots.iter().chain(chip_spots.iter()) { let rt = find(&mut par, sp); if done.insert(rt) { let (a, i) = (sp / n, sp % n); if a < sys.nl { sys.leak[a][i] = 1e3; islands += 1; } } } }
        }
        let (v, it, res) = sys.solve(&rhs, 1e-7, spec.dcMaxIterations);
        log(&format!("{net}: {} A, {} layer(s), {it} iterations, residual {res:.1e}{}{}", load.amps, nl, if islands > 0 { format!(", {islands} copper piece(s) with terminals but no path to the rest, each referenced on its own") } else { String::new() }, if res > 1e-4 { " NOT CONVERGED" } else { "" }));
        // each plated hole's current between the layers it joins, and the mean density in its barrel (PR 9)
        let mut connections = Vec::new();
        for hole in b.vias.iter().chain(b.plated_holes.iter()).filter(|q| q.net == *net) {
            for a in 0..nl.saturating_sub(1) { let (k0, k1) = (live[a], live[a + 1]); let span: f64 = slot[k0..k1].iter().sum::<f64>() + (t_cu[k0] + t_cu[k1]) / 2.0; let (cells, resolved) = barrel_cells(hole, k0, k1);
                let amps = SIGMA * hole.barrel_area_mm2() / span.max(0.05) * cells.iter().map(|(i, q)| q * (v[a * n + i] - v[(a + 1) * n + i])).sum::<f64>();
                let area = hole.barrel_area_mm2();
                // resolvedPerimeterFraction: the share of the wall on cells with copper on both faces; a barrel that fell back to
                // its nearest copper cells says so and reports a token fraction rather than claiming a resolved wall
                connections.push(json!({"at": [hole.x, hole.y], "from": LAYERS[k0], "to": LAYERS[k1], "amps": amps, "meanBarrelJ": amps.abs() / area, "barrelAreaMm2": area, "resolvedPerimeterFraction": if resolved > 0.0 { resolved } else { 1e-3 }, "fallback": resolved <= 0.0, "unconnected": cells.is_empty(), "plated": b.plated_holes.iter().any(|p| p.x == hole.x && p.y == hole.y)})); }
        }
        let mut grids: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();
        let (mut peak, mut peak_at, mut peak_layer) = (0.0f64, [0.0, 0.0], String::new());
        let mut joule = 0.0;
        let mut nf = crate::flow::NetFlow::new(w, h, LAYERS.len());
        // the copper the terminals actually reach: an island of the net with no path to them floats in the
        // solve (its potential is whatever the solver left), so the flow and river views keep only this
        let mut reach = vec![false; nl * n];
        let mut stack: Vec<usize> = pinned_at.into_iter().collect();
        for i in &stack { reach[*i] = true; }
        while let Some(i) = stack.pop() {
            let (a, cell) = (i / n, i % n); let (r, c) = (cell / w, cell % w);
            let mut nb: Vec<(usize, f64)> = Vec::with_capacity(6);
            if c + 1 < w { nb.push((i + 1, sys.gx[a][r * (w - 1) + c])); }
            if c > 0 { nb.push((i - 1, sys.gx[a][r * (w - 1) + c - 1])); }
            if r + 1 < h { nb.push((i + w, sys.gy[a][r * w + c])); }
            if r > 0 { nb.push((i - w, sys.gy[a][(r - 1) * w + c])); }
            if a + 1 < nl { nb.push((i + n, sys.gz[a][cell])); }
            if a > 0 { nb.push((i - n, sys.gz[a - 1][cell])); }
            for (j, g) in nb { if g > 0.0 && !reach[j] { reach[j] = true; stack.push(j); } }
        }
        for (a, k) in live.iter().enumerate() {
            let f = &fr[*k];
            let mut cx = vec![0.0f64; n]; let mut cy = vec![0.0f64; n];
            for r in 0..h { for c in 0..w - 1 { let g = sys.gx[a][r * (w - 1) + c]; let i = g * (v[a * n + r * w + c] - v[a * n + r * w + c + 1]); cx[r * w + c] += i / 2.0; cx[r * w + c + 1] += i / 2.0; } }
            for r in 0..h - 1 { for c in 0..w { let g = sys.gy[a][r * w + c]; let i = g * (v[a * n + r * w + c] - v[a * n + (r + 1) * w + c]); cy[r * w + c] += i / 2.0; cy[(r + 1) * w + c] += i / 2.0; } }
            // the signed current density and the potential on this net's copper, in the solve's own sense
            // (from the connector side to the parts); flow.rs turns it into the physical sense and volts
            for i in 0..n { let fi = f.v[i] as f64; if fi > 0.05 && reach[a * n + i] { let s = d * fi.max(0.25) * t_cu[*k]; nf.jx[*k].v[i] = (cx[i] / s) as f32; nf.jy[*k].v[i] = (cy[i] / s) as f32; nf.v[*k].v[i] = v[a * n + i] as f32; nf.on[*k].v[i] = 1.0; } }
            for i in 0..n {
                let fi = f.v[i] as f64;
                if fi > 0.05 {
                    let mag = (cx[i] * cx[i] + cy[i] * cy[i]).sqrt();
                    let jj = mag / (d * fi.max(0.25) * t_cu[*k]);
                    grids[*k].v[i] = jj as f32;
                    let sig = match &spec.cu_rise { Some(t) => SIGMA / (1.0 + 0.00393 * (spec.ambientC.unwrap_or(25.0) + t[*k].v[i] as f64 - 20.0)), None => SIGMA };
                    joule += jj * jj / sig * fi * d * d * t_cu[*k];
                    p_joule[*k].v[i] += (jj * jj / sig * fi * d * d * t_cu[*k]) as f32;
                }
            }
            // the reported peak is the modeled maximum, before the display clip (PR 8: a narrow hot spot over the limit
            // used to pass because the peak was read after the 99.5th-percentile texture clip)
            if let Some((lp, idx)) = current_peak_and_clip(&mut grids[*k], f) { if lp > peak { peak = lp; peak_at = [b.x0 + (idx % w) as f64 * d + d / 2.0, b.y0 + (idx / w) as f64 * d + d / 2.0]; peak_layer = LAYERS[*k].to_string(); } }
            j_all[*k].max_with(&grids[*k]);
        }
        let copper: f64 = live.iter().map(|k| fr[*k].sum() * d * d).sum();
        // the current down each via barrel, between the live layers it joins (A, from the upper layer down)
        for a in 0..nl.saturating_sub(1) { for i in 0..n { let g = sys.gz[a][i]; if g > 0.0 && reach[a * n + i] { let amps = g * (v[a * n + i] - v[(a + 1) * n + i]); if amps.abs() > 1e-4 * load.amps { nf.vias.push((live[a], live[a + 1], i, amps)); } } } }
        // a terminal's spots on copper the pinned terminal cannot reach (a through-hole pad's ring on an inner
        // layer, before barrels are modelled) float: only the reached ones say the terminal's potential
        let mean = |s: &[usize]| { let r: Vec<f64> = s.iter().filter(|i| reach[**i]).map(|i| v[*i]).collect(); if r.is_empty() { 0.0 } else { r.iter().sum::<f64>() / r.len() as f64 } };
        nf.v_ext = mean(&ext_spots); nf.v_chip = mean(&chip_spots); nf.terminals = terminals; nf.amps = load.amps;
        flow.insert(net.clone(), nf);
        let term_currents: Vec<Value> = assignments.iter().map(|(p, a)| json!({"pin": format!("{}.{}", p.reference, p.pad), "amps": (a * 1e6).round() / 1e6})).collect();
        nets.push(NetResult { terminalCurrents: term_currents, terminalBasis: basis.clone(), residual: res, converged: res <= 1e-4, platedConnections: connections, platedPads: b.plated_holes.iter().filter(|v| v.net == *net).count(), net: net.clone(), amps: load.amps, layers: live.iter().map(|k| LAYERS[*k].to_string()).collect(), peakJ: peak, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it, piecesKeptApart: met });
        j_net.insert(net.clone(), grids);
    }
    let joule_total: f64 = nets.iter().map(|x| x.jouleW).sum();
    // which net owns each cell: the river lifts every net's copper to its own voltage
    let mut net_names: Vec<String> = Vec::new();
    let mut net_ids: Vec<Vec<u16>> = LAYERS.iter().map(|_| vec![u16::MAX; n]).collect();
    for (k, l) in LAYERS.iter().enumerate() {
        let mut best = vec![0.05f32; n];
        for net in ras.nets_on(l) {
            if net.is_empty() { continue; }
            let g = ras.fraction(&net, l, pool);
            let id = match net_names.iter().position(|x| *x == net) { Some(i) => i, None => { net_names.push(net.clone()); net_names.len() - 1 } } as u16;
            for i in 0..n { if g.v[i] > best[i] { best[i] = g.v[i]; net_ids[k][i] = id; } }
        }
    }
    // every net with real copper on the board, so the walkthrough can show one pour at a time
    let mut cu_net: BTreeMap<String, Vec<Grid>> = BTreeMap::new();
    for l in LAYERS { for net in ras.nets_on(l) { if cu_net.contains_key(&net) { continue; }
        let per: Vec<Grid> = LAYERS.iter().map(|ll| ras.fraction(&net, ll, pool)).collect();
        let area: f64 = per.iter().map(|g| g.sum()).sum::<f64>() * d * d;
        if area > 5.0 { cu_net.insert(net.clone(), per); }
    } }
    // ---- heat: four layers, the dielectric and the vias between them, the air on the faces --------
    let nl = LAYERS.len();
    let t_diel: Vec<f64> = slot_gaps(spec);
    let share: Vec<f64> = (0..LAYERS.len()).map(|k| (if k > 0 { t_diel[k - 1] / 2.0 } else { 0.0 }) + (if k + 1 < LAYERS.len() { t_diel[k] / 2.0 } else { 0.0 })).collect();
    let board_thick = board_thickness(spec);
    // where a top face counts as lying on B.Cu (issue 28): the board's thickness within 0.05 mm, which takes a nominal 1.6 mm
    // on a 1.57 mm stack, and down to the outer-copper-only sum that specs written before 0.10.6 had to use on 4 layers
    let under_face: (f64, f64) = { let older = slot_gaps(spec).iter().sum::<f64>() + 2.0 * spec.copperUm / 1000.0; (board_thick.min(older) - 0.05, board_thick.max(older) + 0.05) };
    let solid_grid = spec.solids.as_ref().map(|sd| sd.validate([b.x0, b.y0, b.x1, b.y1], &|_| true, &|r| spec.hot.contains_key(r)).expect("solids validated before the solve"));
    let solid_heat = |r: &str| spec.solids.as_ref().is_some_and(|sd| sd.bodies.iter().any(|x| x.heat_of.as_deref() == Some(r)));
    let fan_n = spec.fanExponent.unwrap_or(0.5);
    // the last column and row reach past the outline by up to a cell (issue 27): only the board's part of them meets the air
    let (cover_x, cover_y) = (((b.width() - (w - 1) as f64 * d) / d).clamp(0.0, 1.0), ((b.height() - (h - 1) as f64 * d) / d).clamp(0.0, 1.0));
    let on_board = |i: usize| (if i % w == w - 1 { cover_x } else { 1.0 }) * (if i / w == h - 1 { cover_y } else { 1.0 });
    let build = |level: f64| {
        let h_air = fan_mix(H_STILL, H_MOVING, level, fan_n);
        let mut sys = System::new(nl, h, w);
        for k in 0..nl {
            let kt: Vec<f64> = frac[k].v.iter().map(|f| *f as f64 * K_CU * t_cu[k] + K_FR4_XY * share[k]).collect();
            for r in 0..h { for c in 0..w - 1 { sys.gx[k][r * (w - 1) + c] = harm(kt[r * w + c], kt[r * w + c + 1]); } }
            for r in 0..h - 1 { for c in 0..w { sys.gy[k][r * w + c] = harm(kt[r * w + c], kt[(r + 1) * w + c]); } }
            let leak = if k == 0 || k == nl - 1 { h_air * d * d } else { 0.0 };
            for i in 0..n { sys.leak[k][i] = leak * on_board(i) + 1e-12; }
        }
        for k in 0..nl - 1 { let g = K_FR4_Z * d * d / t_diel[k]; for i in 0..n { sys.gz[k][i] = g; } }
        for v in b.vias.iter().chain(b.plated_holes.iter()) {
            let ws = crate::barrel::weights(v, b.x0, b.y0, d, w, h);
            let area = v.barrel_area_mm2();
            for k in 0..nl - 1 { for (i, q) in &ws { sys.gz[k][*i] += K_CU * area / t_diel[k].max(1e-3) * q; } }
        }
        let meshes:Vec<_> = spec.heatsinks.iter().map(|block| crate::heatsink::attach(&mut sys,block,b.x0,b.y0,d,h_air)).collect();
        let packages: Vec<_> = spec.packageCooling.iter().map(|p| {
            let nodes: Vec<_> = p.board_links.iter().filter_map(|link| { let k = LAYERS.iter().position(|l| *l == link.layer)?; let (r, c) = cell(link.at[0], link.at[1])?; Some(k * n + r * w + c) }).collect();
            p.attach(&mut sys, &nodes, level, fan_n)
        }).collect();
        let solid = match (&spec.solids, &solid_grid) { (Some(sd), Some(g)) => Some(crate::solids::attach(&mut sys, sd, g, level, fan_n, b.x0, b.y0, d, h_air, board_thick, under_face, &|r: &str| spec.hot.get(r).map_or(0.0, |x| x.watts)).expect("solids validated before the solve")), _ => None };
        (sys, meshes, packages, solid)
    };
    let mut q_rhs = vec![0.0; nl * n];
    for k in 0..nl { for i in 0..n {q_rhs[k*n+i] += p_joule[k].v[i] as f64;} }
    let mut chip_pos: Vec<(String, f64, f64, f64, String, Vec<(f64, f64)>)> = Vec::new();
    for (reference, hot) in &spec.hot {
        let leads = hot.tabNet.is_empty();
        let pads: Vec<&crate::board::Pad> = b.pads.iter().filter(|p| p.reference == *reference && (leads || p.net == hot.tabNet)).collect();
        let cells: Vec<(f64, f64)> = pads.iter().map(|p| (p.x, p.y)).collect();
        if cells.is_empty() { log(&format!("{reference}: {}, its watts are not placed", if leads { "no pads".to_string() } else { format!("no pad on {} (tab net)", hot.tabNet) })); continue; }
        let area_sum: f64 = pads.iter().map(|p| p.area.max(1e-6)).sum();
        for (pi, (x, y)) in cells.iter().enumerate() {
            // a package cooled off the board takes its watts at its junction node, not into the copper (PR 11)
            let per = if spec.packageCooling.iter().any(|p| p.reference == *reference) || solid_heat(reference) { 0.0 } else { hot.watts * pads[pi].area.max(1e-6) / area_sum };
            let Some((r0, c0)) = cell(*x, *y) else { continue };
            let rad = (1.2 / d) as isize; let mut spots = Vec::new();
            for dr in -rad..=rad { for dc in -rad..=rad { let (r, c) = (r0 as isize + dr, c0 as isize + dc); if r >= 0 && c >= 0 && (r as usize) < h && (c as usize) < w && dr * dr + dc * dc <= rad * rad { spots.push((r as usize, c as usize)); } } }
            for (r, c) in &spots { q_rhs[r * w + c] += per / spots.len() as f64; }
        }
        // the part is read at its tab: the named pad (hot.<ref>.tabPad), else the largest tab-net pad (an exposed pad
        // beats the corner pins that share its net, issue 20); its vias are counted under every tab-net pad
        let at = hot.tabPad.as_ref().and_then(|tp| pads.iter().position(|p| p.pad == *tp)).unwrap_or_else(|| (0..pads.len()).max_by(|a, z| pads[*a].area.partial_cmp(&pads[*z].area).unwrap()).unwrap_or(0));
        if let Some(tp) = &hot.tabPad { if !pads.iter().any(|p| p.pad == *tp) { log(&format!("{reference}: tabPad {tp} is not a pad on {}; read at the largest one", hot.tabNet)); } }
        chip_pos.push((reference.clone(), cells[at].0, cells[at].1, hot.watts, if leads { "leads".to_string() } else { hot.tabNet.clone() }, if leads { vec![cells[at]] } else { cells.clone() }));
    }
    let tab_total: f64 = spec.hot.values().map(|h| h.watts).sum();
    let (sys_still, sink_meshes, packages_still, solid_still) = build(0.0);
    q_rhs.resize(sys_still.n(),0.0);
    if let Some(a) = &solid_still { for (nd, wt) in &a.sources { q_rhs[*nd] += wt; } }
    for (p, nodes) in spec.packageCooling.iter().zip(&packages_still) { q_rhs[nodes.junction] = spec.hot.get(&p.reference).map_or(0.0, |h| h.watts); }
    let (tv, it, res) = sys_still.solve(&q_rhs, 1e-7, 8000);
    log(&format!("heat, still air: {it} iterations, residual {res:.1e}"));
    // ---- a heating transient (spec transient): every node's heat capacity, implicit Euler on geometric time steps --------
    let transient = match &spec.transient { None => Value::Null, Some(tr) => {
        let (cu_c, fr_c) = (3.45e-3, 1.85e-3); // J/(mm³ K): copper, FR4
        let nb = nl * n; let mut cap = vec![0.0f64; sys_still.n()];
        for k in 0..nl { for i in 0..n { cap[k * n + i] = (frac[k].v[i] as f64 * t_cu[k] * cu_c + share[k] * fr_c) * d * d; } }
        for c in cap.iter_mut().skip(nb) { *c = 0.05; } // lumped package and heatsink-block nodes: a small default (J/K)
        if let (Some(sd), Some(a)) = (&spec.solids, &solid_still) { for c in &a.cells { let k = sd.bodies[c.1].conductivity_w_mk; let rc = if k > 300.0 { 3.45e-3 } else if k >= 180.0 { 2.42e-3 } else if k >= 100.0 { 1.66e-3 } else if k >= 20.0 { 2.4e-3 } else { 1.7e-3 }; cap[c.0] = (c.2[3] - c.2[0]) * (c.2[4] - c.2[1]) * (c.2[5] - c.2[2]) * rc; } }
        let steps = tr.steps.unwrap_or(24).clamp(4, 200); let scale = tr.powerScale.unwrap_or(1.0);
        let t_end = tr.durationS.max(1e-3); let t0 = (t_end / 1e4).max(1e-4); let ratio = (t_end / t0).powf(1.0 / (steps as f64 - 1.0));
        let mut temp = if tr.fromSteady == Some(true) { tv.clone() } else { vec![0.0; sys_still.n()] };
        let (mut tprev, mut rows) = (0.0f64, Vec::new());
        let chip_cells: Vec<(String, usize)> = chip_pos.iter().filter_map(|(rf, x, y, _, _, _)| cell(*x, *y).map(|(r, c)| (rf.clone(), r * w + c))).collect();
        for s in 0..steps {
            let tnow = t0 * ratio.powi(s as i32); let dt = (tnow - tprev).max(1e-6); tprev = tnow;
            let mut sy = sys_still.clone();
            for k in 0..nl { for i in 0..n { sy.leak[k][i] += cap[k * n + i] / dt; } }
            for j in nb..sy.n() { sy.extra_leak[j - nb] += cap[j] / dt; }
            let rhs: Vec<f64> = (0..sy.n()).map(|j| q_rhs[j] * scale + cap[j] / dt * temp[j]).collect();
            let (tt, _, _) = sy.solve(&rhs, 1e-7, 4000); temp = tt;
            let board_peak = (0..nb).map(|j| temp[j]).fold(0.0f64, f64::max);
            let mut chips_t = serde_json::Map::new();
            for (rf, i) in &chip_cells { let mut v = temp[*i]; for (p, nd) in spec.packageCooling.iter().zip(&packages_still) { if p.reference == *rf { v = v.max(temp[nd.junction]); } } if let (Some(sd), Some(a)) = (&spec.solids, &solid_still) { for c in &a.cells { if sd.bodies[c.1].heat_of.as_deref() == Some(rf.as_str()) { v = v.max(temp[c.0]); } } } chips_t.insert(rf.clone(), json!((v * 10.0).round() / 10.0)); }
            rows.push(json!({"tS": (tnow * 1e5).round() / 1e5, "boardPeakC": (board_peak * 10.0).round() / 10.0, "chips": chips_t}));
        }
        // each part's time to 63 % of where the pulse takes it (its thermal time constant, as seen from this pulse)
        // each part's thermal time constant: the time to 63 % of its steady-state rise at this power (null: beyond the window)
        let steady_of = |rf: &str, i: usize| -> f64 { let mut v = tv[i]; for (p, nd) in spec.packageCooling.iter().zip(&packages_still) { if p.reference == rf { v = v.max(tv[nd.junction]); } } if let (Some(sd), Some(a)) = (&spec.solids, &solid_still) { for c in &a.cells { if sd.bodies[c.1].heat_of.as_deref() == Some(rf) { v = v.max(tv[c.0]); } } } v * scale };
        let tau: serde_json::Map<String, Value> = chip_cells.iter().map(|(rf, i)| { let target = 0.632 * steady_of(rf, *i); let t63 = rows.iter().find(|r| r["chips"][rf].as_f64().unwrap_or(0.0) >= target).and_then(|r| r["tS"].as_f64()); (rf.clone(), json!({"t63S": t63, "steadyC": (steady_of(rf, *i) * 10.0).round() / 10.0})) }).collect();
        log(&format!("heat transient: {steps} steps to {t_end} s at {scale}× power"));
        json!({"durationS": t_end, "powerScale": scale, "fromSteady": tr.fromSteady == Some(true), "model": "implicit Euler on the still-air heat system; copper 3.45 and FR4 1.85 J/cm³K, solid bodies by material, lumped package nodes 0.05 J/K", "steps": rows, "t63S": tau})
    } };
    let (sys_mov, _, packages_moving, solid_moving) = build(1.0);
    // moving air starts from the still-air answer: the same sources, only the faces' h differs (a warm start)
    let (tm, it2, res2) = sys_mov.solve_from(&q_rhs, &tv, 1e-7, 8000);
    log(&format!("heat, moving air: {it2} iterations"));
    let package_cooling: Vec<Value> = spec.packageCooling.iter().zip(&packages_still).zip(&packages_moving).map(|((p, a), bm)| {
        let watts = spec.hot.get(&p.reference).map_or(0.0, |h| h.watts);
        json!({"definition": p, "still": a.result(&tv, watts), "moving": bm.result(&tm, watts), "status": "supplied-parameter-steady-state-screen", "transientQualified": false, "chipRisePolicy": "maximum of board terminal region, case and junction; never hide a hot external node"})
    }).collect();
    let (solids, solids_cells) = match (&spec.solids, &solid_still, &solid_moving) {
        (Some(sd), Some(a), Some(bm)) => {
            let ((bs, cs), (bmv, cmv)) = (a.result(sd, &tv), bm.result(sd, &tm));
            let cells: Vec<Value> = a.cells.iter().map(|(_, bi, x)| json!([x[0], x[1], x[2], x[3], x[4], x[5], bi])).collect();
            (json!({"model": "voxel conduction in the bodies, solved with the board's heat; prescribed h on exposed faces (not CFD, no radiation)", "file": "solids.json", "cells": cells.len(), "pitchMm": sd.pitch_mm, "bodies": sd.bodies.iter().map(|x| json!({"name": x.name, "conductivityWMk": x.conductivity_w_mk, "hStillWM2k": x.h_still_w_m2k, "hMovingWM2k": x.h_moving_w_m2k, "heatOf": x.heat_of, "provenance": x.provenance})).collect::<Vec<_>>(), "contacts": sd.contacts, "boardLinks": sd.board_links, "assumptions": sd.assumptions, "still": bs, "moving": bmv}),
             json!({"bodies": sd.bodies.iter().map(|x| x.name.clone()).collect::<Vec<_>>(), "cells": cells, "still": cs, "moving": cmv}))
        }
        _ => (Value::Null, Value::Null),
    };
    let heatsinks: Vec<Value> = spec.heatsinks.iter().zip(&sink_meshes).map(|(block,mesh)| {
        let peak=|t:&[f64]|mesh.nodes.iter().map(|i|t[*i]).fold(0.0,f64::max);
        let power=|t:&[f64]|mesh.contacts.iter().map(|(a,b,g)|g*(t[*a]-t[*b])).sum::<f64>();
        json!({"definition":block,"contactAreaMm2":mesh.area_mm2,"nodes":mesh.nodes.len(),"peakRiseC":peak(&tv),"peakRiseMovingC":peak(&tm),"contactW":power(&tv),"contactMovingW":power(&tm)})
    }).collect();
    let mut t: Vec<Grid> = Vec::new(); let mut t_moving: Vec<Grid> = Vec::new(); let mut q: Vec<Grid> = Vec::new();
    let mut fx_all: Vec<Vec<f64>> = Vec::new(); let mut fy_all: Vec<Vec<f64>> = Vec::new();
    for k in 0..nl {
        let mut g = Grid::new(w, h); g.v.copy_from_slice(&tv[k * n..(k + 1) * n].iter().map(|x| *x as f32).collect::<Vec<_>>()); t.push(g);
        let mut g2 = Grid::new(w, h); g2.v.copy_from_slice(&tm[k * n..(k + 1) * n].iter().map(|x| *x as f32).collect::<Vec<_>>()); t_moving.push(g2);
        let mut cx = vec![0.0f64; n]; let mut cy = vec![0.0f64; n];
        let mut fx = vec![0.0f64; h * (w - 1)]; let mut fy = vec![0.0f64; (h - 1) * w];
        for r in 0..h { for c in 0..w - 1 { let f = sys_still.gx[k][r * (w - 1) + c] * (tv[k * n + r * w + c] - tv[k * n + r * w + c + 1]); fx[r * (w - 1) + c] = f; cx[r * w + c] += f / 2.0; cx[r * w + c + 1] += f / 2.0; } }
        for r in 0..h - 1 { for c in 0..w { let f = sys_still.gy[k][r * w + c] * (tv[k * n + r * w + c] - tv[k * n + (r + 1) * w + c]); fy[r * w + c] = f; cy[r * w + c] += f / 2.0; cy[(r + 1) * w + c] += f / 2.0; } }
        let mut qg = Grid::new(w, h);
        for i in 0..n { if frac[k].v[i] >= 0.25 { qg.v[i] = ((cx[i] * cx[i] + cy[i] * cy[i]).sqrt() / d) as f32; } }
        q.push(qg); fx_all.push(fx); fy_all.push(fy);
    }
    // the extra fan levels: each its own solve, its temperatures, its heat flow in the copper, its bodies
    let qgrids = |sys: &crate::solve::System, tt: &[f64]| -> Vec<Grid> { (0..nl).map(|k| {
        let mut cx = vec![0.0f64; n]; let mut cy = vec![0.0f64; n];
        for r in 0..h { for c in 0..w - 1 { let f = sys.gx[k][r * (w - 1) + c] * (tt[k * n + r * w + c] - tt[k * n + r * w + c + 1]); cx[r * w + c] += f / 2.0; cx[r * w + c + 1] += f / 2.0; } }
        for r in 0..h - 1 { for c in 0..w { let f = sys.gy[k][r * w + c] * (tt[k * n + r * w + c] - tt[k * n + (r + 1) * w + c]); cy[r * w + c] += f / 2.0; cy[(r + 1) * w + c] += f / 2.0; } }
        let mut qg = Grid::new(w, h); for i in 0..n { if frac[k].v[i] >= 0.25 { qg.v[i] = ((cx[i] * cx[i] + cy[i] * cy[i]).sqrt() / d) as f32; } } qg }).collect() };
    let grids_of = |tt: &[f64]| -> Vec<Grid> { (0..nl).map(|k| { let mut g = Grid::new(w, h); g.v.copy_from_slice(&tt[k * n..(k + 1) * n].iter().map(|x| *x as f32).collect::<Vec<_>>()); g }).collect() };
    let mut levels: Vec<f64> = spec.fanLevels.iter().cloned().filter(|l| l.is_finite() && *l >= 0.0 && *l <= 1.0).collect();
    if !levels.is_empty() { for x in [0.0, 1.0] { if !levels.iter().any(|l| (l - x).abs() < 1e-9) { levels.push(x); } } }
    levels.sort_by(|a, z| a.partial_cmp(z).unwrap()); levels.dedup_by(|a, z| (*a - *z).abs() < 1e-9);
    let mut fan: Vec<FanLevel> = Vec::new();
    for &lv in &levels {
        let (sys_l, _, pk_l, sol_l, tl, rl) = if lv == 0.0 { (None, None, None, None, tv.clone(), res) } else if lv == 1.0 { (None, None, None, None, tm.clone(), res2) } else {
            let (sy, _, pk, so) = build(lv); let guess: Vec<f64> = tv.iter().zip(&tm).map(|(a, z)| a + (z - a) * lv).collect(); let (tt, itl, rr) = sy.solve_from(&q_rhs, &guess, 1e-7, 8000); log(&format!("heat, fan {:.0} %: {itl} iterations", lv * 100.0)); (Some(sy), Some(()), Some(pk), so, tt, rr) };
        let _ = &sys_l; let _ = pk_l.as_ref(); let _ = sol_l.as_ref();
        let q_l = match &sys_l { Some(sy) => qgrids(sy, &tl), None => if lv == 0.0 { qgrids(&sys_still, &tl) } else { qgrids(&sys_mov, &tl) } };
        let sol_ref = match (&sol_l, lv) { (Some(a), _) => Some(a), (None, x) if x == 0.0 => solid_still.as_ref(), (None, _) => solid_moving.as_ref() };
        let (sv, sc) = match (&spec.solids, sol_ref) { (Some(sd), Some(a)) => a.result(sd, &tl), _ => (Value::Null, Value::Null) };
        let pk_ref = match &pk_l { Some(p) => p, None => if lv == 0.0 { &packages_still } else { &packages_moving } };
        let chips_l: Vec<(String, f64)> = chip_pos.iter().filter_map(|(rf, x, y, _, _, _)| { let (r0, c0) = cell(*x, *y)?; let mut v = tl[r0 * w + c0];
            for (p, nd) in spec.packageCooling.iter().zip(pk_ref.iter()) { if p.reference == *rf { v = v.max(tl[nd.junction]).max(tl[nd.case]); } }
            if let (Some(sd), Some(a)) = (&spec.solids, sol_ref) { for c in &a.cells { if sd.bodies[c.1].heat_of.as_deref() == Some(rf.as_str()) { v = v.max(tl[c.0]); } } }
            Some((rf.clone(), (v * 10.0).round() / 10.0)) }).collect();
        fan.push(FanLevel { level: lv, t: grids_of(&tl), q: q_l, chips: chips_l, solids: sv, solids_cells: sc, residual: rl, h_board_w_m2k: fan_mix(H_STILL, H_MOVING, lv, fan_n) * 1e6 });
    }
    // per chip: where its watts go within 3 mm of the tab
    let r_chip = 3.0;
    let via_wanted = |reference: &str| -> usize { spec.thermalVias.iter().filter(|v| v["pad"].as_str().map(|p| p.split('.').next() == Some(reference)).unwrap_or(false)).map(|v| v["count"].as_u64().unwrap_or(0) as usize).sum() };
    let mut chips: Vec<ChipResult> = Vec::new();
    for (reference, x, y, watts, tab, tab_pads) in &chip_pos {
        let Some((r0, c0)) = cell(*x, *y) else { continue };
        let inside = |r: usize, c: usize| -> bool { let (dr, dc) = (r as f64 - r0 as f64, c as f64 - c0 as f64); (dr * dr + dc * dc) * d * d <= r_chip * r_chip };
        let mut plane = 0.0;
        for r in 0..h { for c in 0..w - 1 { let (a, bb) = (inside(r, c), inside(r, c + 1)); if a && !bb { plane += fx_all[0][r * (w - 1) + c]; } else if !a && bb { plane -= fx_all[0][r * (w - 1) + c]; } } }
        for r in 0..h - 1 { for c in 0..w { let (a, bb) = (inside(r, c), inside(r + 1, c)); if a && !bb { plane += fy_all[0][r * w + c]; } else if !a && bb { plane -= fy_all[0][r * w + c]; } } }
        // the tab's own thermal vias are the ones on its net; other nets' barrels nearby still carry
        // heat (through the dielectric first) and are counted apart, never as the tab's provision
        let (mut via_w, mut other_w, mut diel_w, mut air_w, mut nv, mut no) = (0.0, 0.0, 0.0, 0.0, 0usize, 0usize);
        let g_diel = K_FR4_Z * d * d / t_diel[0];
        let mut own_cells: std::collections::HashSet<usize> = std::collections::HashSet::new();
        let near_tab = |vx: f64, vy: f64| tab_pads.iter().any(|(px, py)| (vx - px).hypot(vy - py) <= r_chip);
        for v in b.vias.iter().filter(|v| v.net == *tab) { if near_tab(v.x, v.y) { nv += 1; if let Some((r, c)) = cell(v.x, v.y) { if inside(r, c) { own_cells.insert(r * w + c); } } } }
        for v in b.vias.iter().filter(|v| v.net != *tab) { if near_tab(v.x, v.y) { no += 1; } }
        // a footprint's own thermal vias (plated holes in the exposed pad) count as the tab's vias too
        for v in b.plated_holes.iter().filter(|v| v.net == *tab && v.drill <= 0.5) { if near_tab(v.x, v.y) { nv += 1; if let Some((r, c)) = cell(v.x, v.y) { if inside(r, c) { own_cells.insert(r * w + c); } } } }
        for r in 0..h { for c in 0..w { if !inside(r, c) { continue; } let i = r * w + c; let dt = tv[i] - tv[n + i]; let gv = sys_still.gz[0][i] - g_diel; if gv > 0.0 { if own_cells.contains(&i) { via_w += gv * dt; } else { other_w += gv * dt; } } diel_w += g_diel * dt; air_w += sys_still.leak[0][i] * tv[i]; } }
        chips.push(ChipResult { reference: reference.clone(), watts: *watts, tabNet: tab.clone(), x: *x, y: *y, riseC: (tv[r0 * w + c0] * 10.0).round() / 10.0, riseMovingC: (tm[r0 * w + c0] * 10.0).round() / 10.0, planeW: (plane * 1000.0).round() / 1000.0, viaW: (via_w * 1000.0).round() / 1000.0, vias: nv, otherViaW: (other_w * 1000.0).round() / 1000.0, otherVias: no, dielW: (diel_w * 1000.0).round() / 1000.0, airW: (air_w * 1000.0).round() / 1000.0, viasWanted: via_wanted(reference) });
    }
    // ---- issues -------------------------------------------------------------------------------
    // a package's rise is the hottest of its board region, case and junction (PR 11)
    for (p, (a, bm)) in spec.packageCooling.iter().zip(packages_still.iter().zip(&packages_moving)) { if let Some(chip) = chips.iter_mut().find(|c| c.reference == p.reference) { chip.riseC = chip.riseC.max(tv[a.junction]).max(tv[a.case]); chip.riseMovingC = chip.riseMovingC.max(tm[bm.junction]).max(tm[bm.case]); } }
    if let (Some(sd), Some(a), Some(bm)) = (&spec.solids, &solid_still, &solid_moving) { for chip in chips.iter_mut() {
        let hot_cells = |at: &crate::solids::Attached, t: &[f64]| at.cells.iter().filter(|c| sd.bodies[c.1].heat_of.as_deref() == Some(chip.reference.as_str())).map(|c| t[c.0]).fold(f64::MIN, f64::max);
        let (sv, mv) = (hot_cells(a, &tv), hot_cells(bm, &tm)); if sv > f64::MIN { chip.riseC = chip.riseC.max((sv * 10.0).round() / 10.0); chip.riseMovingC = chip.riseMovingC.max((mv * 10.0).round() / 10.0); }
    } }
    let mut issues: Vec<Issue> = Vec::new();
    for x in &nets {
        // IPC-2221 external, 10 C rise: about 60 A/mm2 at 35 um; a pinch well past that is a hot neck
        // every loaded net over the threshold is listed; the severity follows the current, since half an
        // amp through a thin track is a warm trace and twenty amps through a neck is a fuse
        if x.peakJ > neck {
            let severity = if x.amps < 1.0 { "low" } else if x.peakJ > 2.5 * neck { "high" } else { "medium" };
            issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A through a neck about {:.2} mm wide at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)/1000.0), spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } , action: None });
        }
        // each plated connection's mean barrel density against the same threshold (PR 9)
        for c in &x.platedConnections {
            let j = c["meanBarrelJ"].as_f64().unwrap_or(0.0);
            if j > neck {
                let at = [c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0)];
                issues.push(Issue { severity: "medium".into(), kind: "current-barrel".into(), what: format!("{}: plated connection {}→{} at ({:.2}, {:.2}) carries {:.3} A, mean barrel density {j:.2} A/mm² exceeds {neck:.2} A/mm²", x.net, c["from"].as_str().unwrap_or(""), c["to"].as_str().unwrap_or(""), at[0], at[1], c["amps"].as_f64().unwrap_or(0.0)), at: Some(at), layer: c["from"].as_str().map(String::from), net: Some(x.net.clone()), reference: None, fix: "Review barrel plating, drill and current sharing; add a parallel via or a larger drill so the layers share the current.".into(), action: None });
            }
        }
    }
    for c in &chips {
        // the part's own limit (hot.<ref>.maxRiseC) when the spec gives one, else the global hotMaxRiseC (issue 16)
        let (limit, own) = match spec.hot.get(&c.reference).and_then(|x| x.maxRiseC) { Some(l) => (l, true), None => (spec.hotMaxRiseC, false) };
        if c.riseC > limit {
            issues.push(Issue { severity: if c.riseMovingC > limit { "high".into() } else { "medium".into() }, kind: "tab-rise".into(), what: format!("{}: +{:.0} °C at its tab in still air (+{:.0} °C in moving air) against {}", c.reference, c.riseC, c.riseMovingC, if own { format!("its own {limit:.0} °C limit (hot.{}.maxRiseC)", c.reference) } else { format!("the board's {limit:.0} °C budget (hotMaxRiseC)") }), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: if c.riseMovingC > limit { "More copper under the tab and more vias to the other layers, or a heatsink; moving air alone does not bring it in.".into() } else { "Moving air brings it in; in still air it needs more copper under the tab, more vias, or a heatsink.".into() } , action: None });
        }
        if c.vias < c.viasWanted {
            issues.push(Issue { severity: "medium".into(), kind: "thermal-vias".into(), what: format!("{}: {} via(s) within {r_chip:.0} mm of its tab, the spec asks for {}", c.reference, c.vias, c.viasWanted), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: "Land the thermal vias under the tab pad; without them the tab's heat has only the top copper to spread on.".into() , action: None });
        }
    }
    let _ = (tab_total, joule_total);
    if let (Some(sd), Some(a)) = (&spec.solids, &solid_still) { for (severity, body, what, fix) in a.unused(sd, board_thick) {
        log(&format!("solids: {what}"));
        let x = &sd.bodies[body];
        issues.push(Issue { severity: severity.into(), kind: "solid-uncoupled".into(), what, at: x.boxes_mm.first().map(|v| [(v[0] + v[3]) / 2.0, (v[1] + v[4]) / 2.0]), layer: None, net: None, reference: x.heat_of.clone(), fix, action: Some(json!({"type": "review", "what": "solid body placement", "body": x.name, "boardUndersideZMm": -((board_thick * 1000.0).round() / 1000.0)})) });
    } }
    Fields { transient, joule_map: p_joule.clone(), package_cooling, solids, solids_cells, fan, flow, net_ids, net_names, signals:crate::signals::analyze(b,&ras,&spec.signals,&spec.copperUmByLayer,spec.copperUm), heatsinks, heat_residual:res, heat_moving_residual:res2, d, h, w, x0: b.x0, y0: b.y0, frac, j_net, cu_net, j_all, q, t, t_moving, nets, chips, issues, joule_total: (joule_total * 1000.0).round() / 1000.0, tab_total }
}

/// The summary the app and the page read.
pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value {
    let mut nets = f.nets.clone();
    // relevance: amps first, then the peak density
    nets.sort_by(|a, bb| bb.amps.partial_cmp(&a.amps).unwrap().then(bb.peakJ.partial_cmp(&a.peakJ).unwrap()));
    let mut chips = f.chips.clone();
    chips.sort_by(|a, bb| bb.riseC.partial_cmp(&a.riseC).unwrap());
    let tmax = f.t[0].max().max(f.t[f.t.len() - 1].max());
    // the nets with real copper, biggest first: the walkthrough shows one pour at a time
    let mut areas: Vec<(String, f64)> = f.cu_net.iter().map(|(n, g)| (n.clone(), g.iter().map(|x| x.sum()).sum::<f64>() * f.d * f.d)).collect();
    areas.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
    let pour_nets: Vec<Value> = areas.into_iter().map(|(n, a)| json!({"net": n, "copperMm2": (a * 10.0).round() / 10.0})).collect();
    json!({
        "board": {"name": spec.board, "x0": b.x0, "y0": b.y0, "widthMm": b.width(), "heightMm": b.height(), "layers": LAYERS, "copperUm": spec.copperUm, "dielectricMm":if spec.real_gaps.is_empty() {slot_gaps(spec)} else {spec.real_gaps.clone()}, "slotDielectricMm": slot_gaps(spec), "copperLayers": if spec.real_copper.is_empty() { LAYERS.iter().map(|l| l.to_string()).collect::<Vec<_>>() } else { spec.real_copper.clone() }, "copperUmByLayer":LAYERS.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": board_thickness(spec), "cellMm": f.d, "cols": f.w, "rows": f.h},
        "nets": nets, "chips": chips, "issues": f.issues,
        "barrelCoupling": {"model": "distributed-plated-wall", "platingUm": 25, "drillVoidsRemoved": true, "quadrature": "uniform-perimeter-32-samples-per-cell-minimum-128", "unresolvedWall": "nearest cells with copper on both faces (fallback: true on the connection)"},
        "peakJDefinition": {"statistic": "unclipped-cell-maximum", "minimumCopperFraction": 0.25, "displayPercentile": 0.995, "rounded": false},
        "terminalAssignments": spec.loads.iter().map(|(net, load)| (net.clone(), json!({"mode": if load.terminals.is_some() {"explicit"} else {"heuristic"}, "pins": load.terminals}))).collect::<BTreeMap<_,_>>(),
        "signals": f.signals, "heatsinks": f.heatsinks, "packageCooling": f.package_cooling, "solids": f.solids, "transient": f.transient, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual},
        "vias": b.vias.iter().map(|v| json!({"net": v.net, "x": v.x, "y": v.y, "size": v.size, "drill": v.drill})).collect::<Vec<_>>(),
        "pads": b.pads.iter().filter(|p| spec.hot.contains_key(&p.reference) || spec.loads.contains_key(&p.net)).map(|p| json!({"ref": p.reference, "pad": p.pad, "net": p.net, "x": p.x, "y": p.y})).collect::<Vec<_>>(),
        "scales": {"jMax": f.j_all.iter().map(|g| g.max()).fold(0.0, f32::max), "qMax": f.q.iter().map(|g| g.max()).fold(0.0, f32::max), "tMax": tmax, "tMovingMax": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max)},
        "heat": {"sourcesModelled": spec.hot.len(), "status": if spec.hot.is_empty() { "no heat sources modelled: the temperature is only the copper's own I²R loss, not a thermal pass for any part (give each dissipating part in spec hot: watts, and tabNet if it has an exposed tab, else its watts go through all its leads)" } else { "parts modelled" }, "tabW": f.tab_total, "jouleW": f.joule_total, "peakRiseC": tmax, "peakRiseMovingC": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max), "budgetC": spec.hotMaxRiseC, "stillAirWPerM2K": H_STILL * 1e6, "movingAirWPerM2K": H_MOVING * 1e6},
        "textures": textures,
        "partValues": b.values,
        "pourNets": pour_nets,
        "method": format!("Copper fraction per cell from the board's filled polygons, tracks, pads and vias, drill holes left empty; DC conduction per loaded net between its terminals (σ 5.8e4 S/mm × thickness), each via and plated pad barrel joining its layers around its wall; heat: the board's {} copper layer(s) ({}) coupled through the dielectric and every via and plated pad barrel,", if spec.real_copper.is_empty() { 4 } else { spec.real_copper.len() }, if spec.real_copper.is_empty() { "F.Cu, In1.Cu, In2.Cu, B.Cu".to_string() } else { spec.real_copper.join(", ") }) + " copper 0.40 W/mm·K, FR4 0.3/0.6 W/m·K, 10 (still) or 25 (moving) W/m²K to the air on both faces; one steady-state solve each. Optional rectangular heatsinks use a 3D finite-volume conducting block with specified conductivity, contact resistance and air loss on exposed faces. Copper thickness may differ by layer; Joule heat is deposited on its source layer. This remains a screening model: rectangular board outline, prescribed chip watts and convection; no fins, radiation, airflow solve or junction-temperature qualification."
    })
}

#[cfg(test)] mod thermal_tests {
    use super::*;
    #[test] fn board_with_block_lowers_tab_rise_and_reports_heat_received() {
        let mut b=Board{ x0:0.,y0:0.,x1:12.,y1:12., ..Board::default() };
        b.pads.push(crate::board::Pad{reference:"U1".into(),pad:"1".into(),net:"GND".into(),x:6.,y:6.,layers:vec!["F.Cu".into()],area:4.,func:String::new()});
        let mut spec:Spec=serde_json::from_value(json!({"copperUm":35,"hot":{"U1":{"watts":0.2,"tabNet":"GND"}}})).unwrap();
        let baseline=analyze(&b,&spec,1.,60.,&mut |_|{});
        spec.heatsinks.push(serde_json::from_value(json!({"name":"block","layer":"F.Cu","boundsMm":[2,2,10,10],"thicknessMm":3,"conductivityWPerMK":200,"contactResistanceMm2KPerW":100,"zCells":3})).unwrap());
        let cooled=analyze(&b,&spec,1.,60.,&mut |_|{});
        assert!(cooled.chips[0].riseC<baseline.chips[0].riseC);
        assert!(cooled.heatsinks[0]["contactW"].as_f64().unwrap()>0.0);
        assert!(cooled.heatsinks[0]["contactW"].as_f64().unwrap()<0.201);
        assert!(cooled.heat_residual<1e-6);
        assert!(cooled.chips[0].riseMovingC<cooled.chips[0].riseC);
    }
}

/// The dielectric between two copper layers (LAYERS indices): its thickness (the spec's stackupMm, which the board's own
/// stackup fills when the spec has none) and its εr (the board's stackup, else the spec's epsilonR, else 4.3).
pub fn gap_between(spec: &Spec, _b: &crate::board::Board, ka: usize, kb: usize) -> (f64, f64) {
    let (lo, hi) = (ka.min(kb), ka.max(kb)); if lo == hi { return (0.0, spec.epsilonR.unwrap_or(4.3)); }
    let st = slot_gaps(spec);
    let h: f64 = st[lo..hi].iter().sum();
    let er = match &spec.er_slots { Some(e) => { let w: f64 = (lo..hi).map(|i| e[i] * st[i]).sum(); w / h } None => spec.epsilonR.unwrap_or(4.3) };
    (h, er)
}
/// The dielectric in each of the five slot gaps (between the six copper slots), as analyze normalised it.
/// The board's ground and how it was chosen (issue 25): spec `ground`; else the net named GND; else the ground-named net
/// most pads are on, so a separate return such as BRIDGE_GND or a small AGND never wins by its name's place in the
/// alphabet. `names` are the nets to choose from.
pub fn board_ground(spec: &Spec, b: &Board, names: &[String]) -> Option<(String, &'static str)> {
    if let Some(g) = &spec.ground { return Some((g.clone(), "spec ground")); }
    let mut grounds: Vec<&String> = names.iter().filter(|n| crate::flow::is_ground(n)).collect(); grounds.sort(); grounds.dedup();
    if let Some(g) = grounds.iter().find(|n| n.trim_start_matches(['+', '-', '/']).eq_ignore_ascii_case("GND")) { return Some(((*g).clone(), "the net named GND")); }
    let pads = |n: &String| b.pads.iter().filter(|p| p.net == *n).count();
    let mut best: Option<&String> = None;
    for g in grounds { if best.is_none_or(|x| pads(g) > pads(x)) { best = Some(g); } }
    best.map(|g| (g.clone(), "the ground net with the most pads"))
}

/// The solve cell (row, column) under a point. A point on the board's far edge or within one cell past any edge snaps to
/// the edge cell, so a pad centred on the outline has a cell on all four sides (issue 27).
pub fn cell_at(x: f64, y: f64, x0: f64, y0: f64, d: f64, w: usize, h: usize) -> Option<(usize, usize)> {
    let (c, r) = (((x - x0) / d).floor(), ((y - y0) / d).floor());
    if c < -1.0 || r < -1.0 || c > w as f64 || r > h as f64 { return None; }
    Some((r.clamp(0.0, h as f64 - 1.0) as usize, c.clamp(0.0, w as f64 - 1.0) as usize))
}

/// The board's thickness, from the top copper face to the bottom one: every dielectric and every copper layer. The one
/// number for the report, the viewer and the solid bodies (issue 28).
pub fn board_thickness(spec: &Spec) -> f64 {
    let copper = if spec.real_copper.is_empty() { LAYERS.iter().map(|l| l.to_string()).collect::<Vec<_>>() } else { spec.real_copper.clone() };
    slot_gaps(spec).iter().sum::<f64>() + copper.iter().map(|l| spec.copperUmByLayer.get(l).copied().unwrap_or(spec.copperUm) / 1000.0).sum::<f64>()
}

pub fn slot_gaps(spec: &Spec) -> Vec<f64> { if spec.stackupMm.len() == LAYERS.len() - 1 { spec.stackupMm.clone() } else { spread(&[0.2, 1.065, 0.2], &["F.Cu".into(), "In1.Cu".into(), "In2.Cu".into(), "B.Cu".into()]) } }
/// Real gaps between consecutive real copper layers, spread over the slots between them (empty slots carry only dielectric).
pub fn spread(gaps: &[f64], copper: &[String]) -> Vec<f64> {
    let idx: Vec<usize> = copper.iter().filter_map(|c| LAYERS.iter().position(|l| *l == c)).collect();
    let mut out = vec![0.0; LAYERS.len() - 1];
    for (g, w) in gaps.iter().zip(idx.windows(2)) { let span = (w[1] - w[0]).max(1); for k in w[0]..w[1] { out[k] = g / span as f64; } }
    out
}

#[derive(Deserialize, Clone, Debug)]
#[serde(untagged)]
pub enum Clock { Hz(f64), Cfg { #[serde(default)] hz: Option<f64>, #[serde(default)] minHz: Option<f64>, #[serde(default)] maxHz: Option<f64>, #[serde(default)] harmonics: Option<usize>, #[serde(default)] amplitudeV: Option<f64> } }
impl Clock {
    pub fn lo_hz(&self) -> f64 { match self { Clock::Hz(h) => *h, Clock::Cfg { hz, minHz, maxHz, .. } => minHz.or(*hz).or(*maxHz).unwrap_or(0.0) } }
    pub fn top_hz(&self) -> f64 { match self { Clock::Hz(h) => *h, Clock::Cfg { hz, minHz, maxHz, .. } => maxHz.or(*hz).or(*minHz).unwrap_or(0.0) } }
    pub fn mid_hz(&self) -> f64 { (self.lo_hz() + self.top_hz()) / 2.0 }
    pub fn swept(&self) -> bool { self.top_hz() > self.lo_hz() * 1.001 }
    pub fn harmonics(&self) -> Option<usize> { match self { Clock::Cfg { harmonics, .. } => *harmonics, _ => None } }
    pub fn amplitude(&self) -> Option<f64> { match self { Clock::Cfg { amplitudeV, .. } => *amplitudeV, _ => None } }
}
#[derive(Deserialize, Clone, Debug)] pub struct Transient { pub durationS: f64, #[serde(default)] pub steps: Option<usize>, #[serde(default)] pub powerScale: Option<f64>, #[serde(default)] pub fromSteady: Option<bool> }
#[derive(Deserialize, Default, Clone)] pub struct RfCfg { #[serde(default)] pub cbcPf: Option<f64>, #[serde(default)] pub cbePf: Option<f64>, #[serde(default)] pub antennaOhm: Option<f64>, #[serde(default)] pub loLeakLimitDbm: Option<f64>, #[serde(default)] pub stageIsolationDb: Option<f64>, #[serde(default)] pub usbHighSpeed: Option<bool>, #[serde(default)] pub limiter: Option<bool>, #[serde(default)] pub noSolderMask: Option<bool> }
#[derive(Deserialize, Clone, Debug)]
pub struct Band { pub minHz: f64, pub maxHz: f64, #[serde(default)] pub ifHz: Option<f64>, #[serde(default)] pub lowSideLo: bool }

#[cfg(test)] mod terminal_tests {
    use super::*;
    // PR 6: an explicit fuse/diode path solves the same whichever way the current runs, and a sink with no copper is not silently dropped
    #[test] fn explicit_terminals() {
        let mut b = Board { x1: 10., y1: 6., layer_names: vec!["F.Cu".into(), "B.Cu".into()], copper: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };
        b.polys.push(crate::board::Poly { net: "V".into(), layer: "F.Cu".into(), pts: vec![(0., 0.), (10., 0.), (10., 6.), (0., 6.)] });
        for (r, x) in [("F1", 1.5), ("D1", 8.5), ("R24", 5.)] { b.pads.push(crate::board::Pad { reference: r.into(), pad: "1".into(), net: "V".into(), x, y: 3., layers: vec!["F.Cu".into()], area: 1., func: String::new() }); }
        let mut spec: Spec = serde_json::from_value(json!({"copperUm": 35, "loads": {"V": {"amps": 3, "terminals": [{"reference": "F1", "pad": "1", "amps": 3}, {"reference": "D1", "pad": "1", "amps": -2.9999}, {"reference": "R24", "pad": "1", "amps": -0.0001}]}}})).unwrap();
        let f = analyze(&b, &spec, 0.5, 60., &mut |_| {});
        assert_eq!(f.nets.len(), 1); assert!(f.nets[0].converged); assert!(f.nets[0].jouleW > 0.);
        for row in spec.loads.get_mut("V").unwrap().terminals.as_mut().unwrap() { row.amps = -row.amps; }
        let rev = analyze(&b, &spec, 0.5, 60., &mut |_| {});
        assert!((f.nets[0].jouleW - rev.nets[0].jouleW).abs() < 1e-5);
        b.pads[1].x = 50.;
        assert!(analyze(&b, &spec, 0.5, 60., &mut |_| {}).nets.is_empty(), "a sink off the copper must not be silently dropped");
    }
    fn edge_board(w: f64, h: f64, pad: (f64, f64)) -> (Board, Spec) {
        let mut b = Board { x1: w, y1: h, layer_names: vec!["F.Cu".into(), "B.Cu".into()], copper: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };
        b.polys.push(crate::board::Poly { net: "V".into(), layer: "F.Cu".into(), pts: vec![(0., 0.), (w, 0.), (w, h), (0., h)] });
        for (r, x, y) in [("J1", 1., h / 2.), ("J2", pad.0, pad.1)] { b.pads.push(crate::board::Pad { reference: r.into(), pad: "1".into(), net: "V".into(), x, y, layers: vec!["F.Cu".into()], area: 1., func: String::new() }); }
        let spec = serde_json::from_value(json!({"copperUm": 35, "loads": {"V": {"amps": 3, "terminals": [{"reference": "J1", "pad": "1", "amps": 3}, {"reference": "J2", "pad": "1", "amps": -3}]}}})).unwrap();
        (b, spec)
    }
    // issue 27: a pad centred on the far edge is modelled, whether or not the board is a whole number of cells
    #[test] fn terminal_on_the_far_edge_has_copper() {
        for (w, h, cols, rows) in [(9.53, 6.0, 48, 30), (10.0, 6.0, 50, 30), (9.41, 6.03, 48, 31)] {
            for pad in [(w, h / 2.), (w / 2., h), (w + 0.1, h / 2.)] {
                let (b, spec) = edge_board(w, h, pad);
                let f = analyze(&b, &spec, 0.2, 60., &mut |_| {});
                assert_eq!((f.w, f.h), (cols, rows), "the grid covers {w} x {h}");
                assert_eq!(f.nets.len(), 1, "{w} x {h}, pad at {pad:?}"); assert!(f.nets[0].converged);
                assert!(f.nets[0].terminalCurrents.iter().any(|t| t["pin"] == "J2.1"));
            }
        }
        // past the 1 mm reach it is still no copper, not a guess
        let (b, spec) = edge_board(10.0, 6.0, (12.5, 3.0));
        assert!(analyze(&b, &spec, 0.2, 60., &mut |_| {}).nets.is_empty());
    }
    #[test] fn a_point_on_or_just_past_the_outline_snaps_to_the_edge_cell() {
        let at = |x, y| cell_at(x, y, 100., 50., 0.2, 50, 30);
        assert_eq!(at(100.0, 50.0), Some((0, 0))); assert_eq!(at(110.0, 56.0), Some((29, 49))); assert_eq!(at(110.1, 56.1), Some((29, 49))); assert_eq!(at(99.9, 49.9), Some((0, 0)));
        assert_eq!(at(104.3, 53.1), Some((15, 21)));
        assert_eq!(at(110.5, 53.0), None); assert_eq!(at(99.5, 53.0), None); assert_eq!(at(105.0, 56.5), None);
    }
    // the extra column reaches past the outline, but the air only sees the board's part of it
    #[test] fn a_sliver_of_a_cell_adds_no_air() {
        let rise = |w: f64| {
            let mut b = Board { x1: w, y1: 6., layer_names: vec!["F.Cu".into(), "B.Cu".into()], copper: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };
            b.pads.push(crate::board::Pad { reference: "U1".into(), pad: "1".into(), net: "GND".into(), x: 3., y: 3., layers: vec!["F.Cu".into()], area: 4., func: String::new() });
            let spec: Spec = serde_json::from_value(json!({"copperUm": 35, "hot": {"U1": {"watts": 0.2, "tabNet": "GND"}}})).unwrap();
            analyze(&b, &spec, 0.5, 60., &mut |_| {}).chips[0].riseC
        };
        let (fit, sliver) = (rise(10.0), rise(10.01));
        assert!((fit - sliver).abs() / fit < 0.005, "10.00 mm: {fit}, 10.01 mm: {sliver}");
    }
}

#[cfg(test)] mod ground_pick_tests {
    use super::*;
    fn pick(ground: Option<&str>, nets: &[(&str, usize)]) -> Option<(String, &'static str)> {
        let mut b = Board::default();
        for (n, count) in nets { for i in 0..*count { b.pads.push(crate::board::Pad { reference: format!("U{i}"), pad: "1".into(), net: (*n).into(), x: 0., y: 0., layers: vec!["F.Cu".into()], area: 1., func: String::new() }); } }
        let mut spec: Spec = serde_json::from_value(json!({"copperUm": 35})).unwrap(); spec.ground = ground.map(str::to_string);
        board_ground(&spec, &b, &nets.iter().map(|n| n.0.to_string()).collect::<Vec<_>>())
    }
    // issue 25
    #[test] fn the_board_ground_is_not_the_first_name_in_the_alphabet() {
        // the reported case: the separate return sorts first and has more pads, and GND is still the ground
        assert_eq!(pick(None, &[("BRIDGE_GND", 20), ("GND", 3), ("+3V3", 40)]).unwrap().0, "GND");
        assert_eq!(pick(None, &[("AGND", 30), ("gnd", 2)]).unwrap().0, "gnd");
        assert_eq!(pick(None, &[("AGND", 30), ("/GND", 2)]).unwrap().0, "/GND");
        // no net named GND: the one most parts are on
        assert_eq!(pick(None, &[("AGND", 2), ("PGND", 28)]).unwrap(), ("PGND".to_string(), "the ground net with the most pads"));
        assert_eq!(pick(None, &[("AGND", 28), ("PGND", 2)]).unwrap().0, "AGND");
        // a tie stays alphabetical, the same on every run
        assert_eq!(pick(None, &[("PGND", 4), ("AGND", 4)]).unwrap().0, "AGND");
        // the spec's word beats all of it, even for a net the names don't call a ground
        assert_eq!(pick(Some("BRIDGE_GND"), &[("BRIDGE_GND", 1), ("GND", 9)]).unwrap(), ("BRIDGE_GND".to_string(), "spec ground"));
        assert_eq!(pick(Some("RTN"), &[("RTN", 1), ("GND", 9)]).unwrap().0, "RTN");
        // one ground, and none
        assert_eq!(pick(None, &[("VSS", 1), ("+5V", 9)]).unwrap().0, "VSS");
        assert!(pick(None, &[("+5V", 9)]).is_none());
    }
}

#[cfg(test)] mod solid_board_tests {
    use super::*;
    // issue 28: a 4-layer board, 17.5 µm copper and 1.5 mm of dielectric: 1.57 mm in all, 1.535 mm counting the outer copper only
    fn solve(bodies: Value) -> Fields {
        let mut b = Board { x1: 20., y1: 20., ..Board::default() };
        b.pads.push(crate::board::Pad { reference: "Q1".into(), pad: "1".into(), net: "GND".into(), x: 10., y: 10., layers: vec!["F.Cu".into()], area: 16., func: String::new() });
        for l in ["F.Cu", "B.Cu"] { b.polys.push(crate::board::Poly { net: "GND".into(), layer: l.into(), pts: vec![(1., 1.), (19., 1.), (19., 19.), (1., 19.)] }); }
        let spec: Spec = serde_json::from_value(json!({"copperUm": 17.5, "stackupMm": [0.2, 1.1, 0.2], "hot": {"Q1": {"watts": 1, "tabNet": "GND"}}, "solids": {"pitchMm": 4, "bodies": bodies}})).unwrap();
        assert!((board_thickness(&spec) - 1.57).abs() < 1e-9);
        analyze(&b, &spec, 1., 60., &mut |_| {})
    }
    fn carrier(top: f64) -> Value { json!({"name": "carrier", "boxesMm": [[2, 2, top - 2.0, 18, 18, top]], "conductivityWMk": 50, "hStillWM2k": 5, "hMovingWM2k": 20, "boardContactM2kW": 1e-4}) }
    fn warnings(f: &Fields) -> Vec<&Issue> { f.issues.iter().filter(|i| i.kind == "solid-uncoupled").collect() }
    #[test] fn a_body_under_the_board_takes_the_same_heat_at_either_thickness() {
        let base = solve(json!([carrier(-1.57)]));
        let w = -base.solids["still"][0]["toBoardW"].as_f64().unwrap(); assert!(w > 0.05, "{w}"); assert!(warnings(&base).is_empty());
        assert_eq!(base.solids["still"][0]["onCopper"], true);
        // the sum older specs had to use, and a nominal board thickness
        for top in [-1.535, -1.6, -1.5] {
            let f = solve(json!([carrier(top)]));
            assert!((-f.solids["still"][0]["toBoardW"].as_f64().unwrap() - w).abs() < 0.03 * w, "z = {top}"); assert!(warnings(&f).is_empty(), "z = {top}");
        }
    }
    #[test] fn a_declared_contact_that_misses_the_copper_is_reported() {
        // well under the board, and at the right height beside it
        let beside = json!({"name": "carrier", "boxesMm": [[30, 2, -3.57, 40, 18, -1.57]], "conductivityWMk": 50, "hStillWM2k": 5, "hMovingWM2k": 20, "boardContactM2kW": 1e-4});
        for body in [carrier(-2.0), beside, carrier(-1.4)] {
            let f = solve(json!([body]));
            assert_eq!(f.solids["still"][0]["toBoardW"].as_f64().unwrap().abs(), 0.0);
            let w = warnings(&f); assert_eq!(w.len(), 1); assert_eq!(w[0].severity, "high"); assert!(w[0].what.contains("-1.570"), "{}", w[0].what);
        }
        // a miss is reported for a body with heat of its own too: the heat stays out of the board
        let mut hot = carrier(-2.0); hot["watts"] = json!(1.0);
        assert_eq!(warnings(&solve(json!([hot])))[0].severity, "high");
    }
    #[test] fn a_body_carrying_nothing_is_reported_and_a_working_one_is_not() {
        let plain = |name: &str, bx: [f64; 6]| json!({"name": name, "boxesMm": [bx], "conductivityWMk": 50, "hStillWM2k": 5, "hMovingWM2k": 20});
        // no contact, no link, no heat: 0 W
        let w = solve(json!([plain("block", [2., 2., -3.57, 18., 18., -1.57])])); let w = warnings(&w);
        assert_eq!(w.len(), 1); assert_eq!(w[0].severity, "medium"); assert!(w[0].what.contains("0 W"));
        // a sink with its own heat and no board contact is fine
        let mut own = plain("sink", [2., 2., 5., 18., 18., 8.]); own["watts"] = json!(1.0);
        assert!(warnings(&solve(json!([own]))).is_empty());
    }
}

/// The modeled maximum (cells at least a quarter copper) before clipping the displayed texture at its 99.5th percentile (PR 8).
fn current_peak_and_clip(grid: &mut Grid, fraction: &Grid) -> Option<(f64, usize)> {
    let mut eligible: Vec<(usize, f32)> = grid.v.iter().copied().enumerate().filter(|(i, _)| fraction.v[*i] >= 0.25).collect();
    if eligible.is_empty() { return None; }
    eligible.sort_by(|a, b| a.1.total_cmp(&b.1));
    let (index, maximum) = *eligible.last().unwrap();
    let cap = eligible[((eligible.len() as f64 * 0.995) as usize).min(eligible.len() - 1)].1;
    for v in &mut grid.v { if *v > cap { *v = cap; } }
    Some((maximum as f64, index))
}
#[cfg(test)] mod peak_tests {
    use super::*;
    #[test] fn narrow_hotspot_is_not_hidden_by_display_clip() {
        let (mut dens, mut frac) = (Grid::new(1000, 1), Grid::new(1000, 1)); dens.v.fill(20.0); frac.v.fill(1.0); dens.v[731] = 60.04;
        let (m, i) = current_peak_and_clip(&mut dens, &frac).unwrap(); assert!(m > 60.0); assert_eq!(i, 731); assert_eq!(dens.v[731], 20.0);
    }
    #[test] fn copper_fraction_cutoff() {
        let (mut dens, mut frac) = (Grid::new(2, 1), Grid::new(2, 1)); dens.v = vec![900.0, 12.0]; frac.v = vec![0.24, 0.25];
        assert_eq!(current_peak_and_clip(&mut dens, &frac), Some((12.0, 1))); frac.v.fill(0.0); assert_eq!(current_peak_and_clip(&mut dens, &frac), None);
    }
}

#[allow(non_snake_case)]
#[derive(Deserialize, Clone, Debug, Default)]
pub struct ConverterCfg { #[serde(default)] pub inductor: Option<String>, #[serde(default)] pub vinMin: Option<f64>, #[serde(default)] pub ground: Option<String>, #[serde(default)] pub series: Vec<String> }