main
John Lauer adom-fields 0.7.16: the community's issues. #10 plated pads only on real copper, stackup from the board; #11 six-layer… ee2efa2
//! Explicit, limited signal screening. Not a frequency-domain field solver.
use serde::{Deserialize,Serialize};
use serde_json::{json,Value};
use crate::board::{Board,Track,LAYERS};
use crate::raster::Raster;
use std::collections::{BTreeMap,BTreeSet};
#[derive(Clone,Default,Deserialize,Serialize)]
#[serde(rename_all="camelCase",deny_unknown_fields)]
pub struct Config {
 #[serde(default)] pub capacitance:Vec<Capacitance>,
 #[serde(default)] pub differential_pairs:Vec<Pair>,
}
#[derive(Clone,Deserialize,Serialize)]
#[serde(rename_all="camelCase",deny_unknown_fields)]
pub struct Capacitance {pub name:String,pub net_a:String,pub layer_a:String,pub net_b:String,pub layer_b:String,pub dielectric_mm:f64,pub epsilon_r:f64}
#[derive(Clone,Deserialize,Serialize)]
#[serde(rename_all="camelCase",deny_unknown_fields)]
pub struct Pair {pub name:String,pub positive:String,pub negative:String,pub layer:String,pub reference_net:String,pub reference_layer:String,pub dielectric_mm:f64,pub epsilon_r:f64,pub max_skew_mm:f64,pub target_ohms:f64,pub tolerance_percent:f64}
// adjacent in the board's real copper stack (a four-layer board's In2.Cu and B.Cu are adjacent)
fn adjacent(cu:&[String],a:&str,b:&str)->bool {let pos=|x:&str|cu.iter().position(|l|l==x).or_else(||if cu.is_empty(){LAYERS.iter().position(|l|*l==x)}else{None});match (pos(a),pos(b)) {(Some(a),Some(b))=>a.abs_diff(b)==1,_=>false}}
fn material(h:f64,er:f64)->bool {h.is_finite()&&h>0.&&er.is_finite()&&er>=1.}
impl Config {pub fn validate(&self,cu:&[String])->Result<(),String>{
 let mut names=BTreeSet::new();
 for c in &self.capacitance {if c.name.trim().is_empty()||!names.insert(c.name.clone())||c.net_a==c.net_b||!adjacent(cu,&c.layer_a,&c.layer_b)||!material(c.dielectric_mm,c.epsilon_r){return Err("capacitance needs unique names, different nets, adjacent known layers and positive dielectricMm/epsilonR >= 1".into());}}
 for p in &self.differential_pairs {if p.name.trim().is_empty()||!names.insert(p.name.clone())||p.positive==p.negative||!matches!(p.layer.as_str(),"F.Cu"|"B.Cu")||!adjacent(cu,&p.layer,&p.reference_layer)||!material(p.dielectric_mm,p.epsilon_r)||!p.max_skew_mm.is_finite()||p.max_skew_mm<0.||!p.target_ohms.is_finite()||p.target_ohms<=0.||!p.tolerance_percent.is_finite()||p.tolerance_percent<=0. {return Err("differentialPairs requires unique names, distinct nets, outer microstrip layer, adjacent reference, explicit dielectric and positive target/tolerance".into());}}
 Ok(())
}}
pub fn microstrip(w:f64,h:f64,t:f64,er:f64)->Option<f64>{
 // Conservative screening range; refuse extrapolation rather than issue negative impedance.
 if ![w,h,t,er].iter().all(|v|v.is_finite())||w<=0.||h<=0.||t<=0.||er<1.||w/h<0.1||w/h>3.||t/h>0.25{return None;}
 let z=87./(er+1.41).sqrt()*(5.98*h/(0.8*w+t)).ln();(z>0.).then_some(z)
}
fn len(t:&Track)->f64 {(t.b.0-t.a.0).hypot(t.b.1-t.a.1)}
fn chain(ts:&[&Track])->bool {
 type P=(i64,i64);let key=|p:(f64,f64)|->P{((p.0*1e6).round() as i64,(p.1*1e6).round() as i64)};
 let mut graph:BTreeMap<P,Vec<P>>=BTreeMap::new();
 for t in ts {if len(t)<1e-8{return false;}let (a,b)=(key(t.a),key(t.b));graph.entry(a).or_default().push(b);graph.entry(b).or_default().push(a);}
 if graph.values().filter(|x|x.len()==1).count()!=2 || graph.values().any(|x|x.len()>2){return false;}
 let mut seen=BTreeSet::new();let mut todo=vec![*graph.keys().next().unwrap()];while let Some(p)=todo.pop(){if seen.insert(p){todo.extend(graph[&p].iter().copied());}}
 seen.len()==graph.len()
}
fn covered(r:&Raster,net:&str,layer:&str,x:f64,y:f64)->bool {
 let c=((x-r.x0)/r.res).floor() as isize;let row=((y-r.y0)/r.res).floor() as isize;
 if c<0||row<0||c as usize>=r.w||row as usize>=r.h{return false;}
 r.masks.get(&(net.into(),layer.into())).map(|m|m[row as usize*r.w+c as usize]>0).unwrap_or(false)
}
pub fn analyze(b:&Board,r:&Raster,c:&Config,copper:&BTreeMap<String,f64>,fallback:f64)->Value {
 let caps:Vec<Value>=c.capacitance.iter().map(|q|{
 let a=r.masks.get(&(q.net_a.clone(),q.layer_a.clone()));let z=r.masks.get(&(q.net_b.clone(),q.layer_b.clone()));
 let overlap=match(a,z){(Some(a),Some(b))=>a.iter().zip(b).filter(|(x,y)|**x>0&&**y>0).count() as f64*r.res*r.res,_=>0.};
 json!({"name":q.name,"definition":q,"overlapMm2":overlap,"capacitancePf":0.0088541878128*q.epsilon_r*overlap/q.dielectric_mm,"status":if a.is_some()&&z.is_some(){"estimate"}else{"missing-copper"},"method":"adjacent-layer parallel-plate overlap; no fringe fields or frequency dependence"})
 }).collect();
 let pairs:Vec<Value>=c.differential_pairs.iter().map(|p|{
 let a:Vec<_>=b.tracks.iter().filter(|t|t.net==p.positive).collect();let z:Vec<_>=b.tracks.iter().filter(|t|t.net==p.negative).collect();
 let mut warnings=Vec::<String>::new();let lengths=[a.iter().map(|t|len(t)).sum::<f64>(),z.iter().map(|t|len(t)).sum::<f64>()];let skew=(lengths[0]-lengths[1]).abs();
 if a.is_empty()||z.is_empty()||!chain(&a)||!chain(&z){warnings.push("not two connected unbranched segment chains; length is total segment length, not endpoint delay".into());}
 if a.iter().chain(&z).any(|t|t.w<2.*r.res){warnings.push("trace width spans fewer than two sampling cells; solve at a finer pitch".into());}
 if a.iter().chain(&z).any(|t|t.layer!=p.layer)||b.vias.iter().any(|v|v.net==p.positive||v.net==p.negative){warnings.push("vias or layer changes: microstrip estimate refused".into());}
 if b.arc_nets.iter().any(|n|n==&p.positive||n==&p.negative){warnings.push("routed arcs are not supported by this screening calculation".into());}
 if skew>p.max_skew_mm {warnings.push(format!("length skew {skew:.4} mm exceeds {:.4} mm",p.max_skew_mm));}
 let mut gaps=Vec::new();let mut z0s=Vec::new();let mut zdiffs=Vec::new();let mut missing_reference=0;let mut uncoupled=0;
 let t=copper.get(&p.layer).copied().unwrap_or(fallback)/1000.;
 for (side,other) in [(&a,&z),(&z,&a)] {for tr in side.iter() {
  let l=len(tr);if l<=0.{continue;}let dx=(tr.b.0-tr.a.0)/l;let dy=(tr.b.1-tr.a.1)/l;let steps=(l/r.res).ceil().max(1.) as usize;
  for i in 0..steps {let f=(i as f64+0.5)/steps as f64;let x=tr.a.0+f*(tr.b.0-tr.a.0);let y=tr.a.1+f*(tr.b.1-tr.a.1);
   // Check beneath the strip and one dielectric height to either side. This is a sampled check, not return-current simulation.
   if [-1.,0.,1.].iter().any(|m|!covered(r,&p.reference_net,&p.reference_layer,x-dy*m*(tr.w/2.+p.dielectric_mm),y+dx*m*(tr.w/2.+p.dielectric_mm))){missing_reference+=1;}
   let partner=other.iter().filter_map(|q| {let ql=len(q);if ql<=0.{return None;}let ux=(q.b.0-q.a.0)/ql;let uy=(q.b.1-q.a.1)/ql;if (dx*ux+dy*uy).abs()<0.999{return None;}let along=(x-q.a.0)*ux+(y-q.a.1)*uy;if along<0.||along>ql{return None;}let distance=((x-q.a.0)*uy-(y-q.a.1)*ux).abs();Some((distance-(tr.w+q.w)/2.,q.w))}).min_by(|x,y|x.0.total_cmp(&y.0));
   if let Some((gap,width))=partner {if gap<=0.||(width-tr.w).abs()>1e-6{uncoupled+=1;continue;}gaps.push(gap);if let Some(z0)=microstrip(tr.w,p.dielectric_mm,t,p.epsilon_r){z0s.push(z0);zdiffs.push(2.*z0*(1.-0.48*(-0.96*gap/p.dielectric_mm).exp()));}else{uncoupled+=1;}}
   else{uncoupled+=1;}
  }
 }}
 if missing_reference>0{warnings.push(format!("{missing_reference} samples lack reference copper beneath the trace or its margin"));}
 if uncoupled>0{warnings.push(format!("{uncoupled} samples lack a parallel equal-width partner or are outside the approximation range"));}
 let valid=warnings.iter().all(|w|w.starts_with("length skew"))&&!zdiffs.is_empty();
 let range=|v:&Vec<f64>|if v.is_empty(){Value::Null}else{json!([v.iter().copied().fold(f64::INFINITY,f64::min),v.iter().copied().fold(f64::NEG_INFINITY,f64::max)])};
 if valid&&zdiffs.iter().any(|z|(*z-p.target_ohms).abs()>p.target_ohms*p.tolerance_percent/100.){warnings.push("estimated differential impedance exceeds target tolerance".into());}
 // each net's length per layer, so a refusal shows its reason (one side on another layer is not a pair; issue 9)
 let per_layer=|net:&str|->BTreeMap<String,f64>{let mut m=BTreeMap::new();for t in b.tracks.iter().filter(|t|t.net==net){*m.entry(t.layer.clone()).or_insert(0.0)+=len(t);}for v in m.values_mut(){*v=(*v*100.0).round()/100.0;}m};
 json!({"name":p.name,"definition":p,"lengthMm":lengths,"lengthByLayerMm":{p.positive.clone():per_layer(&p.positive),p.negative.clone():per_layer(&p.negative)},"skewMm":skew,"gapMm":range(&gaps),"singleEndedOhms":if valid{range(&z0s)}else{Value::Null},"differentialOhms":if valid{range(&zdiffs)}else{Value::Null},"status":if !valid{"unsupported-or-incomplete"}else if warnings.is_empty(){"screen-within-target"}else{"screen-outside-target"},"warnings":warnings})
 }).collect();
 json!({"capacitance":caps,"differentialPairs":pairs,"method":"Screening estimates, not signal-integrity signoff. Outer-layer equal-width microstrip only; sampled reference coverage and parallel partner gaps. No solder mask, pads, packages, loss, dispersion, crosstalk, S-parameters or eye diagrams.","sources":["https://www.ti.com/lit/an/slva531a/slva531a.pdf","https://www.ti.com/seclit/eb/slyw038c/slyw038c.pdf"],"sampleMm":r.res})
}
#[cfg(test)]mod tests{
 use super::*;
 #[test]fn formula_reference_and_limits(){let z=microstrip(0.3,0.2,0.035,4.2).unwrap();assert!((z-53.966).abs()<0.1);assert!(microstrip(4.,0.2,0.035,4.2).is_none());}
 #[test]fn disconnected_lengths_refused(){let t=|a,b|Track{net:"p".into(),layer:"F.Cu".into(),a,b,w:0.3};let a=t((0.,0.),(1.,0.));let b=t((2.,0.),(3.,0.));assert!(!chain(&[&a,&b]));}
}

#[cfg(test)] mod integration_tests {
 use super::*;
 #[test] fn actual_overlap_and_pair_geometry_then_plane_gap_refusal() {
  let mut b=Board{x0:0.,y0:0.,x1:12.,y1:12.,..Board::default()};
  for (net,y) in [("P",5.),("N",5.5)] {b.tracks.push(Track{net:net.into(),layer:"F.Cu".into(),a:(2.,y),b:(10.,y),w:0.3});}
  let poly=|net:&str,layer:&str|crate::board::Poly{net:net.into(),layer:layer.into(),pts:vec![(1.,1.),(11.,1.),(11.,11.),(1.,11.)]};
  b.polys.push(poly("GND","In1.Cu"));b.polys.push(poly("POWER","In2.Cu"));
  let mut r=Raster::new(&b,0.05);r.draw_board(&b);
  let c:Config=serde_json::from_value(json!({"capacitance":[{"name":"planes","netA":"GND","layerA":"In1.Cu","netB":"POWER","layerB":"In2.Cu","dielectricMm":0.2,"epsilonR":4}],"differentialPairs":[{"name":"pair","positive":"P","negative":"N","layer":"F.Cu","referenceNet":"GND","referenceLayer":"In1.Cu","dielectricMm":0.2,"epsilonR":4.2,"maxSkewMm":0.1,"targetOhms":90,"tolerancePercent":20}]})).unwrap();
  c.validate(&[]).unwrap();let a=analyze(&b,&r,&c,&BTreeMap::new(),35.);
  assert!((a["capacitance"][0]["capacitancePf"].as_f64().unwrap()-17.7083756256).abs()<1e-8);
  assert_eq!(a["differentialPairs"][0]["status"],"screen-within-target");
  assert_eq!(a["differentialPairs"][0]["skewMm"],0.0);
  r.masks.remove(&("GND".into(),"In1.Cu".into()));let a=analyze(&b,&r,&c,&BTreeMap::new(),35.);
  assert!(a["differentialPairs"][0]["differentialOhms"].is_null());
  assert_eq!(a["capacitance"][0]["status"],"missing-copper");
 }
}