app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
main
John Lauer
adom-fields 0.10.5: converter power ground and shunt values with ratings (issue 24)
7169a99
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
//! The switching converter (0.10.4, issue 24): its switch nodes, inductor, switch legs, input and output, and per
//! operating mode the duty, inductor ripple, capacitor ripple, hot loop and the hot loop's radiated emissions.
//! - **Topologies.** A buck (one switch node, the inductor from it to the output) and a 4-switch buck-boost (the
//! inductor between two switch nodes: the input leg on one, the output leg on the other).
//! - **The inductor** is found through low-value series parts on its path (a current-sense shunt, a ferrite, a 0 Ω
//! link), or named in spec `converter.inductor`.
//! - **A switch leg** is every part switching that node (spec `sources.<node>`, else the Q/U parts on it). A discrete
//! half-bridge's hot loop runs from the high side's input pin to the low side's ground pin.
//! - **Modes.** Buck mode when the input is above the output (at the spec's input voltage), boost mode when it is below
//! (a buck-boost at spec `converter.vinMin`). The hot loop is the input capacitors on the input leg in buck mode,
//! and the output capacitors on the output leg in boost mode. The top-level numbers are the buck mode's when it is
//! evaluated (the conducted test's case), else the boost mode's; `modes` has each one.
//! - **Unsupported:** a switch node that cannot be modelled returns `{"unsupported": why}` rather than nothing, so a caller
//! can tell "not modelled" from "missing data".
use crate::board::{Board, Pad};
use crate::emc::parse_value;
use crate::fields::{Fields, Issue, Spec};
use crate::flow::{is_ground, is_switch_name, rail_from_name};
use serde_json::{json, Value};
use std::f64::consts::PI;
fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }
fn shoelace(p: &[(f64, f64)]) -> f64 { let mut a = 0.0; for i in 0..p.len() { let j = (i + 1) % p.len(); a += p[i].0 * p[j].1 - p[j].0 * p[i].1; } (a / 2.0).abs() }
fn issue(sev: &str, kind: &str, what: String, at: Option<[f64; 2]>, net: Option<&str>, reference: Option<&str>, fix: String, action: Value) -> Issue {
Issue { severity: sev.into(), kind: kind.into(), what, at: at.map(|a| [r2(a[0]), r2(a[1])]), layer: Some("F.Cu".into()), net: net.map(str::to_string), reference: reference.map(str::to_string), fix, action: Some(action) }
}
/// A resistor value in ohms from its Value text: "3m", "3mR", "0R003", "0.003", "0", "10k", "4R7"
pub fn ohms(s: &str) -> Option<f64> {
// the Value often carries a rating or a size after the resistance ("3m 1W", "3mR 1%", "10k 0402", "3.0 0.75W"):
// drop rating tokens, then take the first token, joined with the next when that is a bare unit ("3 mΩ")
let rating = |w: &str| { let l = w.to_ascii_lowercase(); l.ends_with('w') || l.ends_with('v') || l.ends_with('%') || l.contains("ppm") };
let toks: Vec<&str> = s.split_whitespace().filter(|w| !rating(w)).collect();
let first = match toks.as_slice() { [] => return None, [a, u, ..] if u.chars().all(|c| c.is_alphabetic()) => format!("{a}{u}"), [a, ..] => a.to_string() };
let t = first.replace('Ω', "").replace("ohm", "").replace("Ohm", "");
let t = t.split(['/', '_', ',']).next().unwrap_or("").to_string();
if t.is_empty() { return None; }
// a multiplier or R in place of the decimal point: 4R7, 0R003, 4k7, 3m3
for (c, m) in [('R', 1.0), ('r', 1.0), ('k', 1e3), ('K', 1e3), ('M', 1e6), ('m', 1e-3)] {
if let Some(i) = t.find(c) { let (a, b) = (&t[..i], &t[i + c.len_utf8()..]); let b = b.trim_end_matches(['R', 'r']);
if a.chars().all(|x| x.is_ascii_digit() || x == '.') && b.chars().all(|x| x.is_ascii_digit()) && !(a.is_empty() && b.is_empty()) {
let v: f64 = format!("{}{}{}", if a.is_empty() { "0" } else { a }, if b.is_empty() { "" } else { "." }, b).parse().ok()?; return Some(v * m); } }
}
t.parse().ok()
}
/// The parts that join two nets with almost no impedance, for following an inductor's path: resistors of 1 Ω or less
/// (current shunts, 0 Ω links), ferrite beads, and 2-pad net ties and jumpers.
fn series(b: &Board, spec: &Spec, r: &str) -> bool {
if spec.converter.as_ref().is_some_and(|c| c.series.iter().any(|x| x == r)) { return true; }
let v = b.values.get(r).map(String::as_str).unwrap_or("");
(r.starts_with('R') && ohms(v).is_some_and(|x| x <= 1.0)) || r.starts_with("FB") || r.starts_with("NT") || r.starts_with("JP") || (r.starts_with('L') && v.to_ascii_lowercase().contains("ferrite"))
}
pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Value {
let rail = |n: &str| spec.voltages.get(n).copied().or_else(|| rail_from_name(n));
let grounds: Vec<String> = f.net_names.iter().filter(|n| is_ground(n)).cloned().collect();
if grounds.is_empty() { return Value::Null; }
let pads_of = |r: &str, n: &str| -> Vec<&Pad> { b.pads.iter().filter(|p| p.reference == r && p.net == n).collect() };
let nets_of = |r: &str| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().filter(|p| p.reference == r).map(|p| p.net.clone()).collect(); v.sort(); v.dedup(); v };
let refs: Vec<String> = { let mut v: Vec<String> = b.pads.iter().map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };
let near = |list: &[&Pad], x: f64, y: f64| list.iter().min_by(|a, c| ((a.x - x).hypot(a.y - y)).partial_cmp(&(c.x - x).hypot(c.y - y)).unwrap()).map(|p| (*p).clone());
// the switch nodes: the spec's, else a net named like one
let mut nodes: Vec<String> = spec.switching.keys().cloned().collect();
if nodes.is_empty() { if let Some(n) = f.net_names.iter().find(|n| is_switch_name(n)) { nodes.push(n.clone()); } }
if nodes.is_empty() { return Value::Null; }
let unsupported = |why: String| json!({"unsupported": why, "switchNodes": nodes});
// the nets a node reaches through series parts (two hops), and the parts on the way
let through = |start: &str| -> (Vec<String>, Vec<String>) {
let (mut nets, mut parts) = (vec![start.to_string()], Vec::new());
for _ in 0..2 { let cur = nets.clone(); for n in &cur { for r in refs.iter().filter(|r| series(b, spec, r) && nets_of(r).len() == 2 && nets_of(r).contains(n)) { for m in nets_of(r) { if !nets.contains(&m) && !grounds.contains(&m) { nets.push(m); if !parts.contains(r) { parts.push(r.clone()); } } } } } }
(nets, parts)
};
// the inductor: named, else an L (not a ferrite) with a pad on a node or on a net the node reaches through series parts
let named = spec.converter.as_ref().and_then(|c| c.inductor.clone());
let mut found: Option<(String, String, String, Vec<String>)> = None; // (inductor, node, its far net, series parts)
for n in &nodes {
let (reach, parts) = through(n);
let cand: Vec<&String> = match &named { Some(l) => refs.iter().filter(|r| *r == l).collect(), None => refs.iter().filter(|r| r.starts_with('L') && !series(b, spec, r) && nets_of(r).len() == 2).collect() };
if let Some(l) = cand.into_iter().find(|r| nets_of(r).iter().any(|x| reach.contains(x))) {
let ln = nets_of(l); let near_side = ln.iter().find(|x| reach.contains(*x)).cloned().unwrap(); let far = ln.into_iter().find(|x| *x != near_side).unwrap_or_default();
let used: Vec<String> = parts.into_iter().filter(|p| { let pn = nets_of(p); pn.contains(&near_side) || (near_side != *n && pn.contains(n)) }).collect();
found = Some((l.clone(), n.clone(), far, used)); break;
}
}
let Some((lref, node_a, far, mut via)) = found else { return unsupported(format!("no inductor on {} (directly or through a current shunt, ferrite or 0 Ω link); name it in spec converter.inductor", nodes.join(" or "))) };
// a second switch node at the inductor's far side (through series parts too): a 4-switch buck-boost
let node_b = nodes.iter().filter(|n| **n != node_a).find(|n| { let (reach, _) = through(n); reach.contains(&far) }).cloned();
if let Some(nb) = &node_b { let (_, p) = through(nb); for x in p { if nets_of(&x).contains(&far) && !via.contains(&x) { via.push(x); } } }
// a switch leg: the spec's sources for the node, else the Q and U parts on it
let leg = |n: &str| -> Vec<String> { spec.sources.get(n).cloned().filter(|v| !v.is_empty()).unwrap_or_else(|| refs.iter().filter(|r| (r.starts_with('Q') || r.starts_with('U')) && nets_of(r).contains(&n.to_string())).cloned().collect()) };
let leg_a = leg(&node_a); if leg_a.is_empty() { return unsupported(format!("no switch on {node_a}: give spec sources.{node_a}")); }
// a leg's rail: the highest-voltage net its parts reach besides the node and ground
let leg_rail = |parts: &[String], node: &str| -> Option<String> { let mut c: Vec<String> = parts.iter().flat_map(|p| nets_of(p)).filter(|n| n != node && !grounds.contains(n) && rail(n).is_some()).collect(); c.sort(); c.dedup(); c.sort_by(|a, z| rail(z).partial_cmp(&rail(a)).unwrap()); c.into_iter().next() };
let Some(in_net) = leg_rail(&leg_a, &node_a) else { return unsupported(format!("{}'s parts reach no rail with a known voltage: give its input in spec voltages", node_a)) };
let (topology, leg_b, out_net) = match &node_b {
Some(nb) => { let lb = leg(nb); let Some(o) = leg_rail(&lb, nb) else { return unsupported(format!("{nb}'s parts reach no rail with a known voltage: give the output in spec voltages")) }; ("buck-boost (4-switch)", lb, o) }
None => { // a buck's output: the inductor's far net, or the rail it reaches through series parts
let (reach, _) = through(&far); let o = if rail(&far).is_some() { far.clone() } else { reach.into_iter().find(|n| rail(n).is_some()).unwrap_or(far.clone()) }; ("buck", vec![], o) }
};
// the power ground (issue 24): the spec's, else the ground net the switch legs return to (a low-side switch's
// source), ties broken by the capacitors from the input and output to it; else the first ground net
let leg_parts: Vec<String> = leg_a.iter().chain(leg_b.iter()).cloned().collect();
let score = |g: &String| -> (usize, usize) { (leg_parts.iter().map(|p| pads_of(p, g).len()).sum(), refs.iter().filter(|r| r.starts_with('C') && { let n = nets_of(r); n.contains(g) && (n.contains(&in_net) || n.contains(&out_net)) }).count()) };
let named_gnd = spec.converter.as_ref().and_then(|c| c.ground.clone());
let (gnd, gnd_from) = match &named_gnd {
Some(g) => (g.clone(), "spec converter.ground"),
None => { let best = grounds.iter().max_by_key(|g| score(g)).cloned().unwrap(); if score(&best).0 > 0 { (best, "switch legs") } else if score(&best).1 > 0 { (best, "input and output capacitors") } else { (grounds[0].clone(), "first ground net") } }
};
if !f.net_names.contains(&gnd) { return unsupported(format!("spec converter.ground {gnd} is not a net on this board")); }
if node_b.is_none() && rail(&out_net).is_none() { return unsupported(format!("{lref}'s far side, {out_net}, has no known voltage: if {out_net} is a second switch node (a 4-switch buck-boost), add it to spec switching with its switches in spec sources; otherwise give the output's voltage in spec voltages")); }
let (Some(l), Some(vin), Some(vout)) = (b.values.get(&lref).and_then(|v| parse_value(v, 'H')), rail(&in_net), rail(&out_net)) else { return unsupported(format!("{lref}'s inductance, {in_net}'s or {out_net}'s voltage is unknown (part Value, spec voltages)")) };
let Some(iout) = spec.loads.get(&out_net).map(|x| x.amps) else { return unsupported(format!("no load on {out_net}: give spec loads.{out_net}")) };
if vout <= 0.0 { return unsupported(format!("{out_net} is not positive")); }
let vin_min = spec.converter.as_ref().and_then(|c| c.vinMin);
if topology == "buck" && vout >= vin { return unsupported(format!("{in_net} ({vin} V) is not above {out_net} ({vout} V): a buck steps down; for a boost or a buck-boost the model needs the second switch node in spec switching")); }
// capacitors at their DC bias, on a rail to ground
let lib = crate::caps::Library::load(spec.capCurves.as_deref());
let capm = |net: &str| -> Vec<(String, crate::caps::CapModel)> { let vr = rail(net).unwrap_or(0.0); refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&net.to_string()) && nets_of(r).contains(&gnd)).filter_map(|r| { let val = b.values.get(r)?; let c = parse_value(val, 'F')?; let pv = pads_of(r, net); let pg = pads_of(r, &gnd); let span = match (pv.first(), pg.first()) { (Some(a), Some(z)) => (a.x - z.x).hypot(a.y - z.y), _ => 1.5 }; Some((r.clone(), crate::caps::model(&lib, val, b.mpns.get(r).map(String::as_str).unwrap_or(""), b.footprints.get(r).map(String::as_str).unwrap_or(""), c, vr, span))) }).collect() };
let (cin_m, cout_m) = (capm(&in_net), capm(&out_net));
let esr_par = |ms: &[(String, crate::caps::CapModel)]| -> f64 { let g: f64 = ms.iter().map(|(_, m)| 1.0 / m.esr.max(1e-5)).sum(); if g > 0.0 { 1.0 / g } else { 0.0 } };
let (ccin, ccout): (f64, f64) = (cin_m.iter().map(|c| c.1.c_eff).sum(), cout_m.iter().map(|c| c.1.c_eff).sum());
let swcfg = |n: &str| spec.switching.get(n);
let fsw = swcfg(&node_a).and_then(|s| s.freqHz).unwrap_or(500e3); let t = 1.0 / fsw;
let tr = swcfg(&node_a).and_then(|s| s.riseNs).unwrap_or(8.0) * 1e-9;
let chosen = spec.emcLimit.clone().unwrap_or_else(|| "fcc-b".into());
// a hot loop: each capacitor on `rail_net` → the nearest leg pin on it → the nearest leg pin on ground → its ground pad
let hot = |caps: &[(String, crate::caps::CapModel)], rail_net: &str, parts: &[String]| -> (Vec<(String, f64, f64, [Pad; 4])>, Option<(f64, Pad, Pad)>) {
let rp: Vec<&Pad> = parts.iter().flat_map(|p| pads_of(p, rail_net)).collect(); let gp: Vec<&Pad> = parts.iter().flat_map(|p| pads_of(p, &gnd)).collect();
let mut loops = Vec::new();
if rp.is_empty() || gp.is_empty() { return (loops, None); }
for (r, m) in caps { let (cv, cg) = (pads_of(r, rail_net), pads_of(r, &gnd)); if cv.is_empty() || cg.is_empty() { continue; } let uv = near(&rp, cv[0].x, cv[0].y).unwrap(); let ug = near(&gp, cg[0].x, cg[0].y).unwrap(); let a = shoelace(&[(cv[0].x, cv[0].y), (uv.x, uv.y), (ug.x, ug.y), (cg[0].x, cg[0].y)]); loops.push((r.clone(), m.c_eff, a, [cv[0].clone(), uv, ug, cg[0].clone()])); }
// the smallest loop the leg allows: a capacitor straight across its nearest rail and ground pins
let ideal = rp.iter().flat_map(|a| gp.iter().map(move |z| ((a.x - z.x).hypot(a.y - z.y), (*a).clone(), (*z).clone()))).min_by(|a, z| a.0.partial_cmp(&z.0).unwrap()).map(|(d, a, z)| (d * 0.8, a, z));
(loops, ideal)
};
// a hot loop's radiated emissions (Ott small loop, trapezoid harmonics) against every limit set
let emissions = |area_mm2: f64, ipk: f64, duty: f64| -> (f64, f64, Vec<Value>) {
let sinc = |x: f64| if x == 0.0 { 1.0 } else { x.sin() / x };
let sets = crate::limits::sets(&chosen, &spec.emcLimits); let mut per: Vec<(f64, f64)> = vec![(f64::MAX, 0.0); sets.len()];
let lim = |fr: f64| crate::limits::at(&chosen, &spec.emcLimits, fr).unwrap_or(54.0);
let (mut worst, mut wf) = (f64::MAX, 0.0); let a = area_mm2 * 1e-6;
let mut n = (30e6 / fsw).ceil() as u64; let stepn = ((1e9 - 30e6) / fsw / 4000.0).max(1.0) as u64;
while (n as f64) * fsw <= 1e9 { let fr = n as f64 * fsw; let inn = 2.0 * ipk * duty * sinc(PI * n as f64 * duty).abs() * sinc(PI * n as f64 * tr / t).abs(); let e = 1.316e-14 * fr * fr * a * inn / 3.0; let db = 20.0 * (e * 1e6 + 1e-30).log10(); let m = lim(fr) - db; if m < worst { worst = m; wf = fr; } for (k, st) in sets.iter().enumerate() { if let Some(l) = crate::limits::at(st, &spec.emcLimits, fr) { if l - db < per[k].0 { per[k] = (l - db, fr); } } } n += stepn; }
(worst, wf, sets.iter().zip(&per).map(|(st, (m, fr))| json!({"limit": crate::limits::label(st), "set": st, "worstMarginDb": (m * 10.0).round() / 10.0, "atMHz": (fr / 1e6).round()})).collect())
};
let label = crate::limits::label(&chosen);
let capj = |ms: &[(String, crate::caps::CapModel)]| -> Value { json!(ms.iter().map(|(r, m)| json!({"ref": r, "type": m.kind, "uF": (m.c_nom * 1e9).round() / 1e3, "uFAtBias": (m.c_eff * 1e9).round() / 1e3, "esrMohm": (m.esr * 1e4).round() / 10.0, "biasModel": m.source})).collect::<Vec<_>>()) };
// ---- the operating modes ----------------------------------------------------------------------------------------
struct Mode { name: &'static str, vin: f64, duty: f64, di: f64, il: f64, rip_in: f64, rip_out: f64, loop_rail: String, leg: Vec<String>, node: String, caps_in: bool, ipk: f64, pulse_duty: f64 }
let mut modes: Vec<Mode> = Vec::new(); let mut notes: Vec<String> = Vec::new();
if vin > vout { // buck mode: the input leg switches, the input capacitors carry the pulsed current
let d = vout / vin; let di = (vin - vout) * d * t / l;
let rip_out = if ccout > 0.0 { di / (8.0 * fsw * ccout) + di * esr_par(&cout_m) } else { f64::NAN };
let rip_in = if ccin > 0.0 { iout * d * (1.0 - d) / (fsw * ccin) + iout * esr_par(&cin_m) } else { f64::NAN };
modes.push(Mode { name: "buck", vin, duty: d, di, il: iout, rip_in, rip_out, loop_rail: in_net.clone(), leg: leg_a.clone(), node: node_a.clone(), caps_in: true, ipk: iout + di / 2.0, pulse_duty: d });
}
if topology != "buck" {
let vb = if vin < vout { Some(vin) } else { vin_min.filter(|v| *v < vout) };
match vb {
Some(vb) => { // boost mode: the output leg switches, the output capacitors carry the pulsed current
let d = 1.0 - vb / vout; let il = iout / (1.0 - d); let di = vb * d * t / l;
let rip_out = if ccout > 0.0 { iout * d / (fsw * ccout) + esr_par(&cout_m) * (il + di / 2.0) } else { f64::NAN };
let rip_in = if ccin > 0.0 { di / (8.0 * fsw * ccin) + esr_par(&cin_m) * di } else { f64::NAN };
modes.push(Mode { name: "boost", vin: vb, duty: d, di, il, rip_in, rip_out, loop_rail: out_net.clone(), leg: leg_b.clone(), node: node_b.clone().unwrap(), caps_in: false, ipk: il + di / 2.0, pulse_duty: 1.0 - d });
}
None => notes.push(format!("boost mode not evaluated: give spec converter.vinMin (the lowest input) when it falls below {out_net} ({vout} V); in a buck-boost it is usually the worst case for ripple and heat")),
}
}
if modes.is_empty() { return unsupported(format!("{in_net} ({vin} V) equals {out_net}: no mode to evaluate (give converter.vinMin)")); }
let mut mode_out = Vec::new(); let mut top: Option<Value> = None;
for m in modes.iter() {
let caps = if m.caps_in { &cin_m } else { &cout_m };
let (loops, ideal) = hot(caps, &m.loop_rail, &m.leg);
let hf = loops.iter().min_by(|a, z| a.1.partial_cmp(&z.1).unwrap()).cloned();
let em = hf.as_ref().map(|h| emissions(h.2, m.ipk, m.pulse_duty));
let leg_name = m.leg.join("/");
// each mode's findings: its own hot loop, ripple and emissions
if let (Some(h), Some((ia, uv, ug))) = (&hf, &ideal) { if h.2 > (ia * 2.0).max(ia + 3.0) {
let gain = 20.0 * (h.2 / ia).log10();
issues.push(issue(if h.2 > 4.0 * ia { "medium" } else { "low" }, "hot-loop", format!("{}: the {} hot loop {}→{}→{} is {:.1} mm²; the switch{} allow{} {:.1} mm²", h.0, m.name, h.0, leg_name, gnd, h.2, if m.leg.len() > 1 { "es" } else { "" }, if m.leg.len() > 1 { "" } else { "s" }, ia), Some([(h.3[0].x + h.3[3].x) / 2.0, (h.3[0].y + h.3[3].y) / 2.0]), Some(&m.loop_rail), Some(&h.0),
format!("Move {} so its pads sit straight across {}.{} ({}) and {}.{} ({}), on the same layer with no vias in the loop: about {:.0} dB less radiated noise.", h.0, uv.reference, uv.pad, m.loop_rail, ug.reference, ug.pad, gnd, gain),
json!({"type": "move", "ref": h.0, "toPins": [format!("{}.{}", uv.reference, uv.pad), format!("{}.{}", ug.reference, ug.pad)], "targetMm2": (ia * 10.0).round() / 10.0, "currentMm2": (h.2 * 10.0).round() / 10.0, "gainDb": (gain * 10.0).round() / 10.0, "mode": m.name})));
} }
if let (Some(h), Some((worst, wf, _))) = (&hf, &em) { if *worst < 6.0 { issues.push(issue(if *worst < 0.0 { "high" } else { "medium" }, "emissions", format!("The {} hot loop's estimated emissions come within {:.1} dB of {} at {:.0} MHz (differential mode, 3 m)", m.name, worst, label, wf / 1e6), Some([h.3[1].x, h.3[1].y]), Some(&m.node), m.leg.first().map(String::as_str), "Shrink the hot loop (the capacitor across the switches' rail and ground pins), slow the edge if the part allows, and keep the switch node's copper small.".into(), json!({"type": "move", "ref": h.0, "toPins": ideal.as_ref().map(|(_, uv, ug)| vec![format!("{}.{}", uv.reference, uv.pad), format!("{}.{}", ug.reference, ug.pad)]), "why": "emissions margin", "mode": m.name}))); } }
let ratio = m.di / m.il;
let lfor30 = |il: f64| if m.name == "buck" { (m.vin - vout) * m.duty * t / (0.3 * il) } else { m.vin * m.duty * t / (0.3 * il) };
if !(0.15..=0.5).contains(&ratio) { issues.push(issue("low", "inductor-ripple", format!("{}: in {} mode the ripple is {:.0} % of the inductor current (typical design: 20 to 40 %)", lref, m.name, ratio * 100.0), b.pads.iter().find(|p| p.reference == lref).map(|p| [p.x, p.y]), Some(&m.node), Some(&lref), if ratio > 0.5 { format!("A larger inductance (about {:.0} µH for 30 %) or a higher switching frequency.", lfor30(m.il) * 1e6) } else { format!("A smaller inductance (about {:.0} µH for 30 %) responds faster and is smaller.", lfor30(m.il) * 1e6) }, json!({"type": "review", "what": "inductor value", "ref": lref, "suggestUh": (lfor30(m.il) * 1e7).round() / 10.0, "mode": m.name}))); }
if m.rip_out.is_finite() && m.rip_out > 0.01 * vout { let need = if m.name == "buck" { m.di / (8.0 * fsw * 0.01 * vout) } else { iout * m.duty / (fsw * 0.01 * vout) }; issues.push(issue("medium", "output-ripple", format!("{}: output ripple about {:.1} mV in {} mode, over 1 % of {} V", out_net, m.rip_out * 1e3, m.name, vout), None, Some(&out_net), None, format!("Add output capacitance (about {:.0} µF more for 1 %) or low-ESR ceramics close to {}.", ((need - ccout) * 1e6).max(0.0), if m.name == "buck" { lref.clone() } else { leg_name.clone() }), json!({"type": "add-part", "what": "output capacitance", "net": out_net, "uF": ((need - ccout) * 1e6).max(0.0).ceil(), "mode": m.name}))); }
let rec = json!({"mode": m.name, "vin": m.vin, "duty": m.duty, "inductorA": m.il, "rippleA": m.di, "rippleRatio": ratio, "rippleInMv": m.rip_in * 1e3, "rippleOutMv": m.rip_out * 1e3, "switchNet": m.node, "switches": m.leg, "loopCaps": if m.caps_in { "input" } else { "output" },
"hotLoops": loops.iter().map(|(r, c, a, _)| json!({"cap": r, "uF": c * 1e6, "mm2": r2(*a)})).collect::<Vec<_>>(), "hotLoop": hf.as_ref().map(|h| json!({"cap": h.0, "mm2": r2(h.2)})), "idealMm2": ideal.as_ref().map(|i| r2(i.0)),
"emissions": em.as_ref().map(|(m, fr, sets)| json!({"worstMarginDb": (m * 10.0).round() / 10.0, "atMHz": (fr / 1e6).round(), "limit": format!("{label}, 3 m"), "model": "Ott differential-mode loop; no cables", "allLimits": sets}))});
if top.is_none() {
top = Some(json!({"topology": topology, "switchNodes": if let Some(nb) = &node_b { json!([node_a, nb]) } else { json!([node_a]) }, "seriesParts": via.iter().map(|r| json!({"ref": r, "value": b.values.get(r)})).collect::<Vec<_>>(),
"ground": gnd, "groundFrom": gnd_from, "groundNets": grounds, "inCaps": capj(&cin_m), "outCaps": capj(&cout_m), "switchNet": m.node, "switch": m.leg.first(), "switches": m.leg, "inductor": lref, "inNet": in_net, "outNet": out_net, "vin": m.vin, "vout": vout, "iout": iout, "fswHz": fsw, "riseNs": tr * 1e9,
"duty": m.duty, "rippleA": m.di, "rippleRatio": ratio, "lUh": l * 1e6, "cinUf": ccin * 1e6, "coutUf": ccout * 1e6, "rippleInMv": m.rip_in * 1e3, "rippleOutMv": m.rip_out * 1e3, "inputPulsed": m.caps_in,
"hotLoops": rec["hotLoops"].clone(), "hotLoop": rec["hotLoop"].clone(), "idealMm2": rec["idealMm2"].clone(), "emissions": rec["emissions"].clone()}));
}
mode_out.push(rec);
}
let mut v = top.unwrap(); v["modes"] = json!(mode_out); if !notes.is_empty() { v["notes"] = json!(notes); }
v
}
#[cfg(test)] mod tests {
use super::*;
#[test] fn resistor_values() {
for (s, v) in [("3m", 0.003), ("3mR", 0.003), ("0R003", 0.003), ("0.003", 0.003), ("0", 0.0), ("10k", 10e3), ("4R7", 4.7), ("4k7", 4700.0), ("100", 100.0), ("3 mΩ", 0.003), ("3m 1W", 0.003), ("3mR 1%", 0.003), ("0R003 1W", 0.003), ("3.0 0.75W", 3.0), ("10k 0402", 10e3), ("0.003 1/2W", 0.003), ("10k 1% 50ppm", 10e3)] { assert!((ohms(s).unwrap() - v).abs() < 1e-12, "{s}"); }
assert!(ohms("DNP").is_none());
}
}