Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
Keep display clipping out of current-density acceptance and summaries #8
unclipped-current-maxima → explicit-pin-current-boundariesCurrent-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. CLI and viewer summary text use that raw maximum too. 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. README.md, SKILL.md, src/fields.rs, src/main.rs and src/ui/app.js 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 (1)
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);+ }+}
src/main.rs+1−1@@ -142,7 +142,7 @@ fn main() { let nets = s["nets"].as_array().map(|a| a.len()).unwrap_or(0); let issues = s["issues"].as_array().cloned().unwrap_or_default(); ok(&format!("{} nets solved, {} hot parts, {} issue(s), in {} s: {}", nets, s["chips"].as_array().map(|a| a.len()).unwrap_or(0), issues.len(), s["seconds"], out.join("fields.json").display()), &[- format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["scales"]["jMax"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]),+ format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["nets"].as_array().into_iter().flatten().filter_map(|n| n["peakJ"].as_f64()).fold(0.0, f64::max), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]), issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "), if s["parts"].is_null() { "Parts: skipped (--no-parts).".to_string() } else { format!("Parts: {} with 3D models, {} as boxes, {} bare{}.", s["parts"]["modelled"], s["parts"]["boxed"], s["parts"]["bare"], if s["parts"]["failed"].as_array().map_or(false, |a| !a.is_empty()) { "; some models did not convert (see the log above; --models-dir adds a folder to search)" } else { "" }) }, format!("Heatsinks: {}; signal capacitance checks: {}; differential pairs: {}. Signals are screening estimates; unsupported geometry is reported, never certified.", s["heatsinks"].as_array().map_or(0,|x|x.len()),s["signals"]["capacitance"].as_array().map_or(0,|x|x.len()),s["signals"]["differentialPairs"].as_array().map_or(0,|x|x.len())),
src/ui/app.js+1−1@@ -342,7 +342,7 @@ state.fields = f; $('#boardName').textContent = f.board.name || 'board'; $('#boardMeta').textContent = `${f.board.widthMm.toFixed(0)} x ${f.board.heightMm.toFixed(0)} mm · ${f.board.copperUm} µm · ${f.nets.length} nets · ${f.chips.length} hot parts · ${f.issues.length} issues`;- $('#solveInfo').textContent = `peak ${f.scales.jMax.toFixed(0)} A/mm² · +${f.heat.peakRiseC.toFixed(0)} °C still, +${f.heat.peakRiseMovingC.toFixed(0)} °C moving · budget ${f.heat.budgetC} °C`;+ $('#solveInfo').textContent = `peak ${Math.max(0, ...f.nets.map(n => n.peakJ)).toFixed(0)} A/mm² · +${f.heat.peakRiseC.toFixed(0)} °C still, +${f.heat.peakRiseMovingC.toFixed(0)} °C moving · budget ${f.heat.budgetC} °C`; $('#ver').textContent = ''; buildBoard(f); const st = await fetch('state').then(r => r.json()).catch(() => null);
Comments
Log in to comment.