← All Pull Requests

Keep display clipping out of current-density acceptance #7

Closed opened by John Lauer
Merges unclipped-current-maxima → explicit-pin-current-boundaries

Current-density reports previously computed peakJ after clipping each layer at its 99.5th percentile for display, and rounded the result before the 60 A/mm² acceptance check. A narrow hotspot could therefore pass despite exceeding the limit. This preserves the display texture cap but reports the unclipped, unrounded modeled maximum and its matching coordinates. peakJDefinition declares the 25% copper-fraction cutoff and display-only percentile.

Stacked on PR #6 at ae29fa3e74903c2da1f8c708e28880e5ecd71a81, which itself depends on PR #5. Targets explicit-pin-current-boundaries. Main remains b8aecfc5de87ca3a8b09ebbd4a82a8c9b416988f. Only README.md, SKILL.md and src/fields.rs change relative to PR #6; no runtime replacement or self-merge.

Validation: production cargo check, 27 combined tests, release build, current-main combined preflight with no problems, and focused diff review. Regression tests preserve a one-cell 60.04 A/mm² violation even when the displayed texture clips it to 20, and retain the explicit copper-coverage cutoff. Existing terminal, stack, barrel, thermal and signal tests pass.

Correction to the PR #6 board diagnostic: its quoted 57.9 A/mm² was the clipped display statistic, not a true modeled maximum. Instrumenting the same saved board/spec at 0.2 mm reveals GND 76.36, LOAD_POS 68.60 and SHUNT_P 60.97 A/mm². The earlier isolated AIFlow pass is invalid for maximum-density acceptance. The actual project current gate was never passed; current, thermal and finish remain open. AIFlow is being updated to reject legacy peak semantics.

Limits: this remains a finite-cell screening model with a 25% copper-fraction cutoff, approximate 1 mm terminal injection, nominal materials, and no external MOSFET case cooling qualification. Owner review, merge, release, installation and board acceptance are separate.

Diff Skip to comments (2)

README.md+4
@@ -120,3 +120,7 @@ Each `loads.<net>` may supply `terminals`, an array of `{ "reference": "J1", "pa Reports retain the declared pins in `terminalAssignments` and label every net as `explicit` or `heuristic`. Legacy loads without the array retain the existing terminal heuristic. A loaded net that cannot be solved now prevents publishing a partial result. No partial current injection is accepted if any nonzero terminal has no resolved copper.  This change does not replace the current injection's existing 1 mm neighborhood approximation with an exact package contact model. Refine the mesh and review pad geometry and boundary conditions before accepting density values. It also does not model the external MOSFET heatsink or certify thermal performance. This candidate is stacked on physical-stack PR #5; neither change is a deployed shared release yet.++## Current maxima and display clipping (source candidate)++`nets[].peakJ` now reports the unclipped, unrounded modeled cell maximum among cells with at least 25% copper coverage. `peakAt` and `peakLayer` locate that same maximum. The viewer texture still caps its 99.5th percentile for legibility; that display cap must never be used as an acceptance maximum. `peakJDefinition` records these semantics. Earlier reports lack the marker and their reported peaks may be clipped; rerun them before making a maximum-density acceptance claim. The 25% coverage cutoff, finite mesh and terminal injection approximation remain screening limitations, so this is not an exact geometric singularity or physical qualification.
SKILL.md+4
@@ -112,3 +112,7 @@ Each `loads.<net>` may supply `terminals`, an array of `{ "reference": "J1", "pa Reports retain the declared pins in `terminalAssignments` and label every net as `explicit` or `heuristic`. Legacy loads without the array retain the existing terminal heuristic. A loaded net that cannot be solved now prevents publishing a partial result. No partial current injection is accepted if any nonzero terminal has no resolved copper.  This change does not replace the current injection's existing 1 mm neighborhood approximation with an exact package contact model. Refine the mesh and review pad geometry and boundary conditions before accepting density values. It also does not model the external MOSFET heatsink or certify thermal performance. This candidate is stacked on physical-stack PR #5; neither change is a deployed shared release yet.++## Current maxima and display clipping (source candidate)++`nets[].peakJ` now reports the unclipped, unrounded modeled cell maximum among cells with at least 25% copper coverage. `peakAt` and `peakLayer` locate that same maximum. The viewer texture still caps its 99.5th percentile for legibility; that display cap must never be used as an acceptance maximum. `peakJDefinition` records these semantics. Earlier reports lack the marker and their reported peaks may be clipped; rerun them before making a maximum-density acceptance claim. The 25% coverage cutoff, finite mesh and terminal injection approximation remain screening limitations, so this is not an exact geometric singularity or physical qualification.
src/fields.rs+44−7
@@ -178,7 +178,6 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             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; } }-            let mut vals: Vec<f32> = Vec::new();             for i in 0..n {                 let fi = f.v[i] as f64;                 if fi > 0.05 {@@ -187,18 +186,19 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu                     grids[*k].v[i] = jj as f32;                     joule += jj * jj / SIGMA * fi * d * d * t_cu[*k];                     p_joule[*k].v[i] += (jj * jj / SIGMA * fi * d * d * t_cu[*k]) as f32;-                    if fi >= 0.25 { vals.push(jj as f32); }                 }             }-            if !vals.is_empty() {-                vals.sort_by(|a, b2| a.partial_cmp(b2).unwrap());-                let cap = vals[((vals.len() as f64 * 0.995) as usize).min(vals.len() - 1)] as f64;-                for i in 0..n { if grids[*k].v[i] as f64 > cap { grids[*k].v[i] = cap as f32; } if f.v[i] >= 0.25 && (grids[*k].v[i] as f64) > peak { peak = grids[*k].v[i] as f64; peak_at = [b.x0 + (i % w) as f64 * d + d / 2.0, b.y0 + (i / w) as f64 * d + d / 2.0]; peak_layer = layers[*k].to_string(); } }+            if let Some((layer_peak, index)) = current_peak_and_clip(&mut grids[*k], f) {+                if layer_peak > peak {+                    peak = layer_peak;+                    peak_at = [b.x0 + (index % w) as f64 * d + d / 2.0, b.y0 + (index / 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();-        nets.push(NetResult { net: net.clone(), amps: load.amps, layers: live.iter().map(|k| layers[*k].to_string()).collect(), peakJ: (peak * 10.0).round() / 10.0, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it, residual: res, platedPads: plated_pads });+        nets.push(NetResult { 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, residual: res, platedPads: plated_pads });         j_net.insert(net.clone(), grids);     }     let joule_total: f64 = nets.iter().map(|x| x.jouleW).sum();@@ -336,6 +336,7 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value     json!({         "board": {"name": spec.board, "x0": b.x0, "y0": b.y0, "widthMm": b.width(), "heightMm": b.height(), "layers": layers, "copperUm": spec.copperUm, "stackupSource":if spec.stackupMm.is_empty() {"legacy-four-layer-assumption"} else {"explicit-spec"}, "dielectricMm":dielectric_mm(spec, layers.len()).expect("validated stackup"), "copperUmByLayer":layers.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": dielectric_mm(spec, layers.len()).expect("validated stackup").iter().sum::<f64>() + layers.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).sum::<f64>(), "cellMm": f.d, "cols": f.w, "rows": f.h},         "nets": nets, "chips": chips, "issues": f.issues,+        "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, "dcMaxIterations":spec.dcMaxIterations, "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<_>>(),@@ -457,3 +458,39 @@ pub fn validate_current_residual(net: &str, residual: f64) -> Result<(), String>         assert!(f.heat_residual<1e-6);     } }++/// Preserve the modeled maximum before clipping only the displayed texture.+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 value in &mut grid.v { if *value > cap { *value = cap; } }+    Some((maximum as f64, index))+}++#[cfg(test)]+mod peak_reporting_tests {+    use super::*;+    #[test]+    fn narrow_hotspot_is_not_hidden_by_display_percentile() {+        let mut density = Grid::new(1000, 1);+        let mut fraction = Grid::new(1000, 1);+        density.v.fill(20.0); fraction.v.fill(1.0);+        density.v[731] = 60.04;+        let (maximum, index) = current_peak_and_clip(&mut density, &fraction).unwrap();+        assert!(maximum > 60.0, "a real threshold violation must not round away");+        assert_eq!(index, 731);+        assert_eq!(density.v[731], 20.0, "display clipping remains independent");+    }+    #[test]+    fn copper_fraction_cutoff_is_explicit_and_preserved() {+        let mut density = Grid::new(2, 1); let mut fraction = Grid::new(2, 1);+        density.v = vec![900.0, 12.0]; fraction.v = vec![0.24, 0.25];+        assert_eq!(current_peak_and_clip(&mut density, &fraction), Some((12.0, 1)));+        fraction.v.fill(0.0);+        assert_eq!(current_peak_and_clip(&mut density, &fraction), None);+    }+}

Comments

John Lauer ·

Superseded by a replacement PR from the same branch because the stored PR diff and original description do not refresh after branch pushes, and this CLI has no PR edit command. The final reviewed scope also updates CLI and viewer summaries; all five files reconstruct from the live branch diff. No owner review, merge or shared deployment has occurred.

John Lauer ·

Replacement with the complete five-file diff and current description: https://wiki.adom.inc/adom/adom-fields/prs/8 .

Log in to comment.