"""EMC analytical formulas — radiation, harmonics, cavity resonance, impedance. All functions are pure math with zero dependencies beyond Python stdlib. Units are SI unless noted otherwise in the docstring. References: - Henry Ott, "Electromagnetic Compatibility Engineering" - Clayton Paul, "Introduction to Electromagnetic Compatibility" - Eric Bogatin, "Signal and Power Integrity — Simplified" - LearnEMC.com calculators """ import math from typing import List, Tuple, Optional, Dict # 原版在此处把 ../../kicad/scripts 插进 sys.path 以共享 kicad_utils。 # 本项目不用 KiCad,该依赖已内联到下方的 E 系列小节,路径 hack 一并删除。 # Physical constants C_0 = 2.998e8 # Speed of light in vacuum (m/s) MU_0 = 4 * math.pi * 1e-7 # Permeability of free space (H/m) ETA_0 = 377.0 # Impedance of free space (ohms) EPSILON_0 = 8.854e-12 # Permittivity of free space (F/m) # --------------------------------------------------------------------------- # Emission limit tables # --------------------------------------------------------------------------- # FCC Part 15 radiated limits: (f_min_MHz, f_max_MHz, limit_dBuV_m, distance_m) FCC_CLASS_B_RADIATED = [ (30, 88, 40.0, 3), (88, 216, 43.5, 3), (216, 960, 46.0, 3), (960, 40000, 54.0, 3), ] FCC_CLASS_A_RADIATED = [ (30, 88, 39.1, 10), (88, 216, 43.5, 10), (216, 960, 46.4, 10), (960, 40000, 49.5, 10), ] CISPR_CLASS_B_RADIATED = [ (30, 230, 30.0, 10), (230, 1000, 37.0, 10), ] CISPR_CLASS_A_RADIATED = [ (30, 230, 40.0, 10), (230, 1000, 47.0, 10), ] # CISPR 25 Class 5 approximate limits (automotive) CISPR_25_CLASS5_RADIATED = [ (30, 68, 34.0, 1), (68, 108, 28.0, 1), (108, 230, 26.0, 1), (230, 1000, 32.0, 1), ] # MIL-STD-461G RE102 approximate narrowband limits MIL_STD_461_RE102 = [ (2, 30, 24.0, 1), (30, 1000, 24.0, 1), (1000, 18000, 24.0, 1), ] STANDARDS = { 'fcc-class-b': FCC_CLASS_B_RADIATED, 'fcc-class-a': FCC_CLASS_A_RADIATED, 'cispr-class-b': CISPR_CLASS_B_RADIATED, 'cispr-class-a': CISPR_CLASS_A_RADIATED, 'cispr-25': CISPR_25_CLASS5_RADIATED, 'mil-std-461': MIL_STD_461_RE102, } def get_emission_limit(freq_hz: float, standard: str = 'fcc-class-b') -> Optional[Tuple[float, float]]: """Get the emission limit for a frequency under a given standard. Returns (limit_dBuV_m, measurement_distance_m) or None if frequency is outside the standard's range. """ freq_mhz = freq_hz / 1e6 table = STANDARDS.get(standard, FCC_CLASS_B_RADIATED) for f_min, f_max, limit, dist in table: if f_min <= freq_mhz < f_max: return (limit, dist) return None # --------------------------------------------------------------------------- # Differential-mode (loop) radiation # --------------------------------------------------------------------------- def dm_radiation_v_m(freq_hz: float, area_m2: float, current_a: float, distance_m: float, ground_plane: bool = True) -> float: """Maximum far-field E from an electrically small current loop. E = K × f² × A × I / r where K = 1.316e-14 (free space) or 2.632e-14 (with ground plane image). Args: freq_hz: Frequency in Hz. area_m2: Loop area in m². current_a: Peak current in Amps. distance_m: Measurement distance in meters. ground_plane: If True, include ×2 ground plane image factor. Returns: Electric field in V/m. """ if freq_hz <= 0 or distance_m <= 0: return 0.0 # EQ-001: E = K × f² × A × I / r (differential-mode loop radiation) # Source: Ott "EMC Engineering" (Wiley, 2009) Eq. 6.4 # Source: Paul "Introduction to EMC" (Wiley, 2006) Eq. 10.12 # Verified: MSU EMC Lab Module 9 p.9-16 derives K=1.317e-14 (matches to 4 sig figs) # URL: https://www.egr.msu.edu/emrg/sites/default/files/content/module9_radiated_emissions.pdf k = 2.632e-14 if ground_plane else 1.316e-14 return k * freq_hz**2 * area_m2 * current_a / distance_m def dm_radiation_dbuv_m(freq_hz: float, area_m2: float, current_a: float, distance_m: float, ground_plane: bool = True) -> float: """Same as dm_radiation_v_m but returns dBµV/m.""" e = dm_radiation_v_m(freq_hz, area_m2, current_a, distance_m, ground_plane) if e <= 0: return -999.0 # EQ-002: dBµV/m = 20 × log₁₀(E × 10⁶) return 20 * math.log10(e * 1e6) def dm_max_loop_area_m2(freq_hz: float, current_a: float, limit_dbuv_m: float, distance_m: float, margin_db: float = 6.0, ground_plane: bool = True) -> float: """Maximum allowable loop area to stay under an emission limit. Solves E = K × f² × A × I / r for A, with margin. Args: margin_db: Design margin in dB (default 6 dB). Returns: Maximum loop area in m². """ # EQ-027: A = E_limit × r / (K × f² × I) (max allowable loop area) k = 2.632e-14 if ground_plane else 1.316e-14 e_limit = 10**((limit_dbuv_m - margin_db) / 20) * 1e-6 # V/m if freq_hz <= 0 or current_a <= 0: return float('inf') return e_limit * distance_m / (k * freq_hz**2 * current_a) # --------------------------------------------------------------------------- # Common-mode (cable) radiation # --------------------------------------------------------------------------- def cm_radiation_v_m(freq_hz: float, cable_length_m: float, cm_current_a: float, distance_m: float) -> float: """E-field from common-mode current on a cable (monopole over ground). E = µ₀ × f × L × I_CM / r = 1.257e-6 × f × L × I_CM / r The coefficient 1.257e-6 = µ₀ = 4π×10⁻⁷ includes the ×2 ground plane image factor (base short-dipole coefficient is 6.283e-7). Valid when cable length < λ/4 at the frequency of interest. Ref: Ott, "EMC Engineering" (Wiley, 2009), Chapter 6. Paul, "Introduction to EMC" (Wiley, 2006), Chapter 10. """ if freq_hz <= 0 or distance_m <= 0: return 0.0 # EQ-003: E = 1.257e-6 × f × L × I_CM / r (common-mode cable radiation) # Source: Ott "EMC Engineering" (Wiley, 2009) Ch. 6 # Source: Paul "Introduction to EMC" (Wiley, 2006) Ch. 10 # Verified: MSU EMC Lab Module 9 p.9-20 derives coefficient 1.257e-6 exactly # URL: https://www.egr.msu.edu/emrg/sites/default/files/content/module9_radiated_emissions.pdf return 1.257e-6 * freq_hz * cable_length_m * cm_current_a / distance_m def cm_radiation_dbuv_m(freq_hz: float, cable_length_m: float, cm_current_a: float, distance_m: float) -> float: """Same as cm_radiation_v_m but returns dBµV/m.""" # EQ-026: dBµV/m = 20 × log₁₀(E × 10⁶) (CM radiation in dBuV/m) e = cm_radiation_v_m(freq_hz, cable_length_m, cm_current_a, distance_m) if e <= 0: return -999.0 return 20 * math.log10(e * 1e6) def cm_max_current_a(freq_hz: float, cable_length_m: float, limit_dbuv_m: float, distance_m: float, margin_db: float = 6.0) -> float: """Maximum allowable CM current to stay under an emission limit. Returns current in Amps. """ # EQ-025: I_max = E_limit × r / (µ₀ × f × L) (max CM current) e_limit = 10**((limit_dbuv_m - margin_db) / 20) * 1e-6 # V/m denom = 1.257e-6 * freq_hz * cable_length_m if denom <= 0: return float('inf') return e_limit * distance_m / denom # --------------------------------------------------------------------------- # Switching regulator harmonics # --------------------------------------------------------------------------- def trapezoidal_harmonic_amplitude(n: int, v_peak: float, duty_cycle: float, rise_time_s: float, switching_freq_hz: float) -> float: """Amplitude of the nth harmonic of a trapezoidal switching waveform. Uses the standard trapezoidal Fourier envelope with sinc rolloffs. Args: n: Harmonic number (1 = fundamental). v_peak: Peak voltage of the switching node. duty_cycle: Duty cycle (0 to 1). rise_time_s: 10-90% rise time in seconds. switching_freq_hz: Switching frequency in Hz. Returns: Amplitude of the nth harmonic in Volts. """ if n <= 0: return 0.0 t_period = 1.0 / switching_freq_hz tau = duty_cycle * t_period # pulse width # Base amplitude a0 = 2 * v_peak * tau / t_period # First sinc rolloff (pulse width) x1 = n * math.pi * tau / t_period sinc1 = math.sin(x1) / x1 if abs(x1) > 1e-10 else 1.0 # Second sinc rolloff (rise time) x2 = n * math.pi * rise_time_s / t_period sinc2 = math.sin(x2) / x2 if abs(x2) > 1e-10 else 1.0 # EQ-004: A_n = |a₀ × sinc(nπτ/T) × sinc(nπt_r/T)| (trapezoidal harmonic) # Source: Ott "EMC Engineering" (Wiley, 2009) Ch. 2 return abs(a0 * sinc1 * sinc2) def trapezoidal_corner_frequencies(duty_cycle: float, rise_time_s: float, switching_freq_hz: float) -> Tuple[float, float]: """Corner frequencies of the trapezoidal harmonic envelope. Returns: (f1, f2) where: f1 = 1/(π × τ) — onset of -20 dB/decade rolloff f2 = 1/(π × t_r) — onset of -40 dB/decade rolloff """ t_period = 1.0 / switching_freq_hz tau = duty_cycle * t_period # EQ-005: f₁ = 1/(πτ), f₂ = 1/(πt_r) (trapezoidal envelope corners) # Source: Ott "EMC Engineering" (Wiley, 2009) Ch. 2 f1 = 1.0 / (math.pi * tau) if tau > 0 else float('inf') f2 = 1.0 / (math.pi * rise_time_s) if rise_time_s > 0 else float('inf') return (f1, f2) def switching_harmonics_in_band(switching_freq_hz: float, band_min_hz: float, band_max_hz: float) -> List[int]: """List harmonic numbers that fall within a frequency band. Args: switching_freq_hz: Fundamental switching frequency. band_min_hz: Lower edge of test band. band_max_hz: Upper edge of test band. Returns: List of harmonic numbers within the band. """ if switching_freq_hz <= 0: return [] n_min = max(1, int(math.ceil(band_min_hz / switching_freq_hz))) n_max = int(math.floor(band_max_hz / switching_freq_hz)) return list(range(n_min, n_max + 1)) def harmonic_spectrum(switching_freq_hz: float, v_peak: float, duty_cycle: float, rise_time_s: float, max_freq_hz: float = 1e9) -> List[Dict]: """Generate the full harmonic spectrum up to max_freq_hz. Returns: List of dicts with keys: harmonic, freq_hz, amplitude_v, amplitude_dbuv. """ if switching_freq_hz <= 0: return [] # EQ-028: Full harmonic spectrum using EQ-004 per harmonic results = [] n = 1 while True: f = n * switching_freq_hz if f > max_freq_hz: break amp = trapezoidal_harmonic_amplitude(n, v_peak, duty_cycle, rise_time_s, switching_freq_hz) amp_dbuv = 20 * math.log10(amp * 1e6) if amp > 0 else -999.0 results.append({ 'harmonic': n, 'freq_hz': f, 'amplitude_v': amp, 'amplitude_dbuv': amp_dbuv, }) n += 1 return results # --------------------------------------------------------------------------- # Board cavity resonance # --------------------------------------------------------------------------- def cavity_resonance_hz(length_m: float, width_m: float, epsilon_r: float, m: int, n: int) -> float: """Resonant frequency of a PCB parallel-plate cavity. f_mn = (c / (2√εr)) × √((m/L)² + (n/W)²) Args: length_m: Board length in meters. width_m: Board width in meters. epsilon_r: Relative permittivity of dielectric. m, n: Mode indices (integers, not both zero). Returns: Resonant frequency in Hz. """ if m == 0 and n == 0: return 0.0 v = C_0 / math.sqrt(epsilon_r) # EQ-006: f_mn = (c/2√εr) × √((m/L)² + (n/W)²) (PCB cavity resonance) # Source: Pozar "Microwave Engineering" (Wiley, 2011) Ch. 6 # URL: https://learnemc.com/ext/calculators/cavity_resonance/pcb-res.html return (v / 2) * math.sqrt((m / length_m)**2 + (n / width_m)**2) def board_cavity_resonances(length_m: float, width_m: float, epsilon_r: float = 4.4, max_freq_hz: float = 3e9, max_modes: int = 10) -> List[Dict]: """Calculate all cavity resonance modes below max_freq_hz. Returns: List of dicts with keys: mode (m,n), freq_hz, freq_mhz. Sorted by frequency. """ results = [] for m in range(0, max_modes + 1): for n in range(0, max_modes + 1): if m == 0 and n == 0: continue f = cavity_resonance_hz(length_m, width_m, epsilon_r, m, n) if f <= max_freq_hz: results.append({ 'mode': (m, n), 'freq_hz': f, 'freq_mhz': f / 1e6, }) results.sort(key=lambda x: x['freq_hz']) return results # --------------------------------------------------------------------------- # Signal bandwidth and wavelength # --------------------------------------------------------------------------- def bandwidth_from_rise_time(rise_time_s: float) -> float: """3 dB bandwidth from 10-90% rise time. BW = 0.35 / t_r Args: rise_time_s: 10-90% rise time in seconds. Returns: Bandwidth in Hz. """ if rise_time_s <= 0: return float('inf') # EQ-007: BW = 0.35/t_r (3dB bandwidth from 10-90% rise time) # Source: Bogatin "Signal Integrity - Simplified" (Prentice Hall, 2004) Ch. 1 # URL: https://www.edn.com/rule-of-thumb-1-bandwidth-of-a-signal-from-its-rise-time/ return 0.35 / rise_time_s def knee_frequency(rise_time_s: float) -> float: """EMC knee frequency — significant spectral content up to this point. f_knee = 0.5 / t_r More conservative than the 3 dB bandwidth. """ if rise_time_s <= 0: return float('inf') # EQ-008: f_knee = 0.5/t_r (EMC spectral content upper bound) # Source: Paul "Introduction to EMC" (Wiley, 2006) Ch. 7 return 0.5 / rise_time_s def wavelength_in_pcb(freq_hz: float, epsilon_r: float = 4.4) -> float: """Wavelength in PCB dielectric at a given frequency. λ = c / (f × √εr) Returns: Wavelength in meters. """ if freq_hz <= 0: return float('inf') # EQ-009: λ = c/(f × √εr) (wavelength in PCB dielectric) return C_0 / (freq_hz * math.sqrt(epsilon_r)) def lambda_over_20(freq_hz: float, epsilon_r: float = 4.4) -> float: """λ/20 spacing rule for via stitching. Returns: Maximum via stitching spacing in meters. """ return wavelength_in_pcb(freq_hz, epsilon_r) / 20.0 # --------------------------------------------------------------------------- # Trace inductance and capacitance # --------------------------------------------------------------------------- def trace_inductance_nh_per_mm(width_mm: float, height_mm: float) -> float: """Loop inductance per mm for a microstrip trace over a ground plane. Computes Z0 via the Wheeler formula, then derives L from Z0/v_phase. Typical values: 0.3-0.8 nH/mm for microstrip over solid ground. The common "~1 nH/mm" rule of thumb is an upper bound for thin traces with distant return paths. Ref: Bogatin Rule of Thumb #6 — 50Ω FR4 microstrip ≈ 8.3 nH/inch (≈ 0.33 nH/mm). Wider or narrower traces scale with Z0. Args: width_mm: Trace width in mm. height_mm: Dielectric height to reference plane in mm. Returns: Inductance in nH per mm of trace length. """ # EQ-033: L/mm = Z₀/v_phase (Wheeler microstrip inductance per mm) if width_mm <= 0 or height_mm <= 0: return 0.7 # fallback: mid-range for typical microstrip # Simplified Wheeler formula for microstrip inductance w_h = width_mm / height_mm if w_h >= 1: z0 = (120 * math.pi) / (w_h + 1.393 + 0.667 * math.log(w_h + 1.444)) else: z0 = (60 * math.log(8 * height_mm / width_mm + width_mm / (4 * height_mm))) # L per unit length = Z0 / v_phase # For FR4 effective epsilon ~3.3 for typical microstrip v_phase = C_0 / math.sqrt(3.3) l_per_m = z0 / v_phase # H/m return l_per_m * 1e6 # nH/mm def via_inductance_nh(length_mm: float, drill_mm: float) -> float: """Approximate inductance of a single via. L = (µ₀ / 2π) × h × [ln(4h/d) + 1] In practical units with h and d in mm: L ≈ 0.2 × h × (ln(4h/d) + 1) nH Equivalent to the Goldfarb/Grover formula L = 5.08 × h × (ln(4h/d) + 1) nH when h and d are in inches (0.2 × 25.4 = 5.08). Ref: Goldfarb & Pucha, IEEE Microwave and Guided Wave Letters, Vol 1 No 6, 1991. Also: Howard Johnson, "High-Speed Signal Propagation" (2003). """ if length_mm <= 0 or drill_mm <= 0: return 0.5 # typical fallback h = length_mm d = drill_mm # EQ-010: L = 0.2h(ln(4h/d)+1) nH (via self-inductance) # Source: Goldfarb & Pucel, IEEE MGWL Vol.1 No.6 pp.135-137, June 1991 # URL: https://www.semanticscholar.org/paper/Modeling-via-hole-grounds-in-microstrip-Goldfarb-Pucel/a3f4614877b750e7b7eec1dfa5924d5ce8b99301 return 0.2 * h * (math.log(4 * h / d) + 1) # nH # --------------------------------------------------------------------------- # Interplane capacitance # --------------------------------------------------------------------------- def interplane_capacitance_pf_per_cm2(dielectric_thickness_mm: float, epsilon_r: float = 4.4) -> float: """Capacitance per cm² between two parallel copper planes. C = ε₀ × εr / d Args: dielectric_thickness_mm: Spacing between planes in mm. epsilon_r: Relative permittivity. Returns: Capacitance in pF per cm². """ if dielectric_thickness_mm <= 0: return 0.0 d_m = dielectric_thickness_mm / 1000.0 # EQ-011: C = ε₀εr/d (interplane capacitance per unit area) c_per_m2 = EPSILON_0 * epsilon_r / d_m # F/m² return c_per_m2 * 1e-4 * 1e12 # pF/cm² # --------------------------------------------------------------------------- # Decoupling effectiveness # --------------------------------------------------------------------------- def cap_self_resonant_freq(capacitance_f: float, esl_h: float) -> float: """Self-resonant frequency of a capacitor. f_SRF = 1 / (2π√(LC)) Args: capacitance_f: Capacitance in Farads. esl_h: Equivalent series inductance in Henries. Returns: Self-resonant frequency in Hz. """ if capacitance_f <= 0 or esl_h <= 0: return float('inf') # EQ-012: f_SRF = 1/(2π√(LC)) (capacitor self-resonant frequency) return 1.0 / (2 * math.pi * math.sqrt(esl_h * capacitance_f)) def cap_value_for_srf(target_srf_hz: float, esl_h: float) -> float: """Compute capacitance needed for a target self-resonant frequency. # EQ-089: C = 1 / (4π² × f_SRF² × ESL) (inverse SRF for cap selection) # Source: Inverse of EQ-012 (cap SRF formula) Inverse of cap_self_resonant_freq: C = 1 / (4π² × f_SRF² × ESL) Args: target_srf_hz: Desired SRF in Hz. esl_h: ESL in Henries (use estimate_esl() for package-based estimate). Returns: Capacitance in Farads. """ if target_srf_hz <= 0 or esl_h <= 0: return 0.0 return 1.0 / (4 * math.pi**2 * target_srf_hz**2 * esl_h) # E12 standard capacitor values — imported from shared kicad_utils # --------------------------------------------------------------------------- # E 系列标准值 —— 原版从 kicad_utils 导入,这里内联,去掉对 KiCad 侧的依赖。 # 摘自 aklofas/kicad-happy (MIT, Copyright (c) 2025 Andrew Klofas) # skills/kicad/scripts/kicad_utils.py # --------------------------------------------------------------------------- E12_DECADE = [1.0, 1.2, 1.5, 1.8, 2.2, 2.7, 3.3, 3.9, 4.7, 5.6, 6.8, 8.2] E24_DECADE = [1.0, 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 2.0, 2.2, 2.4, 2.7, 3.0, 3.3, 3.6, 3.9, 4.3, 4.7, 5.1, 5.6, 6.2, 6.8, 7.5, 8.2, 9.1] E96_DECADE = [1.00, 1.02, 1.05, 1.07, 1.10, 1.13, 1.15, 1.18, 1.21, 1.24, 1.27, 1.30, 1.33, 1.37, 1.40, 1.43, 1.47, 1.50, 1.54, 1.58, 1.62, 1.65, 1.69, 1.74, 1.78, 1.82, 1.87, 1.91, 1.96, 2.00, 2.05, 2.10, 2.15, 2.21, 2.26, 2.32, 2.37, 2.43, 2.49, 2.55, 2.61, 2.67, 2.74, 2.80, 2.87, 2.94, 3.01, 3.09, 3.16, 3.24, 3.32, 3.40, 3.48, 3.57, 3.65, 3.74, 3.83, 3.92, 4.02, 4.12, 4.22, 4.32, 4.42, 4.53, 4.64, 4.75, 4.87, 4.99, 5.11, 5.23, 5.36, 5.49, 5.62, 5.76, 5.90, 6.04, 6.19, 6.34, 6.49, 6.65, 6.81, 6.98, 7.15, 7.32, 7.50, 7.68, 7.87, 8.06, 8.25, 8.45, 8.66, 8.87, 9.09, 9.31, 9.53, 9.76] _E_SERIES = {"E12": E12_DECADE, "E24": E24_DECADE, "E96": E96_DECADE} _E12_DECADE = E12_DECADE def snap_to_e_series(value: float, series: str = "E96") -> tuple: """Snap a value to the nearest standard E-series value. Returns (snapped_value, error_pct) where error_pct = (snapped - value) / value * 100. Works across any decade (mOhm to MOhm, pF to uF, etc.). """ if value <= 0: return (0.0, 0.0) # EQ-107: Log-decade snapping (IEC 60063), nearest neighbour on log scale. decade = 10 ** math.floor(math.log10(value)) normalized = value / decade decade_list = _E_SERIES.get(series, E96_DECADE) best = min(decade_list, key=lambda e: abs(e - normalized)) snapped = best * decade error_pct = (snapped - value) / value * 100 if value != 0 else 0.0 return (snapped, round(error_pct, 2)) def round_to_e12(value: float) -> float: """Round a value to the nearest E12 standard value. # EQ-090: E12 decade normalization via log10 (standard component value selection) # Source: IEC 60063 E-series standard values Works across any decade (pF, nF, µF, etc.). """ if value <= 0: return 0.0 snapped, _ = snap_to_e_series(value, "E12") return snapped def cap_impedance_at_freq(freq_hz: float, capacitance_f: float, esr_ohm: float, esl_h: float) -> float: """Impedance magnitude of a capacitor (series RLC model) at frequency. |Z| = √(ESR² + (2πfL - 1/(2πfC))²) """ if freq_hz <= 0: return float('inf') omega = 2 * math.pi * freq_hz x_l = omega * esl_h x_c = 1.0 / (omega * capacitance_f) if capacitance_f > 0 else float('inf') x_net = x_l - x_c # EQ-013: |Z| = √(ESR² + (ωL - 1/ωC)²) (series RLC impedance) return math.sqrt(esr_ohm**2 + x_net**2) # --------------------------------------------------------------------------- # ESR/ESL lookup by package size (typical MLCC values) # --------------------------------------------------------------------------- # Typical ESL values for MLCC by package (Henries) MLCC_ESL = { '0201': 0.3e-9, '0402': 0.5e-9, '0603': 0.7e-9, '0805': 0.9e-9, '1206': 1.1e-9, '1210': 1.2e-9, '1812': 1.5e-9, '2220': 1.8e-9, } # Typical ESR values for MLCC by package (Ohms) — X5R/X7R at 1 MHz MLCC_ESR = { '0201': 0.5, '0402': 0.3, '0603': 0.1, '0805': 0.05, '1206': 0.03, '1210': 0.02, '1812': 0.02, '2220': 0.015, } def pdn_target_impedance(v_rail: float, ripple_pct: float = 5.0, i_transient_a: float = 0.5) -> float: """Target impedance for a power distribution network. Z_target = V_rail × ripple% / I_transient Ref: Bogatin, "Signal and Power Integrity — Simplified", Ch. 10. Ref: Smith, "Decoupling Capacitor Calculations for ASICs." Args: v_rail: Rail voltage in Volts. ripple_pct: Allowable voltage ripple as percentage (default 5%). i_transient_a: Transient current demand in Amps. Returns: Target impedance in Ohms. """ if i_transient_a <= 0: return float('inf') return v_rail * (ripple_pct / 100) / i_transient_a def parallel_cap_impedance(freq_hz: float, caps: List[Dict]) -> float: """Impedance of multiple capacitors in parallel at a given frequency. Each cap dict should have: farads (float), esr_ohm (float), esl_h (float). Missing fields use defaults from package lookup. The parallel impedance: 1/Z_total = sum(1/Z_i) """ # EQ-029: 1/Z_total = Σ(1/Z_i) (parallel impedance combination) if freq_hz <= 0 or not caps: return float('inf') sum_admittance = 0.0 for cap in caps: c = cap.get('farads', 0) if c <= 0: continue esr = cap.get('esr_ohm', 0.1) esl = cap.get('esl_h', 1e-9) z = cap_impedance_at_freq(freq_hz, c, esr, esl) if z > 0: sum_admittance += 1.0 / z if sum_admittance <= 0: return float('inf') return 1.0 / sum_admittance def pdn_impedance_sweep(caps: List[Dict], plane_cap_f: float = 0, freq_start: float = 1e3, freq_stop: float = 1e9, points_per_decade: int = 50) -> List[Dict]: """Sweep frequency and compute PDN impedance at each point. Args: caps: List of capacitor dicts with farads, esr_ohm, esl_h. plane_cap_f: Interplane capacitance in Farads (parallel with caps). freq_start: Start frequency in Hz. freq_stop: Stop frequency in Hz. points_per_decade: Resolution. Returns: List of {freq_hz, impedance_ohm} dicts. """ # EQ-030: Z(f) = parallel cap impedance swept over log frequency if not caps and plane_cap_f <= 0: return [] all_caps = list(caps) if plane_cap_f > 0: # Model interplane capacitance as a low-ESR, low-ESL cap all_caps.append({ 'farads': plane_cap_f, 'esr_ohm': 0.001, # Very low ESR for plane cap 'esl_h': 0.05e-9, # Very low ESL }) results = [] decades = math.log10(freq_stop / freq_start) n_points = max(10, int(decades * points_per_decade)) for i in range(n_points + 1): f = freq_start * (10 ** (i * decades / n_points)) z = parallel_cap_impedance(f, all_caps) results.append({'freq_hz': f, 'impedance_ohm': z}) return results def find_anti_resonances(sweep: List[Dict], z_target: float = None) -> List[Dict]: """Find anti-resonance peaks (local maxima) in a PDN impedance sweep. Args: sweep: Output from pdn_impedance_sweep(). z_target: Target impedance. If provided, only peaks exceeding it are returned. Returns: List of {freq_hz, impedance_ohm, exceeds_target} dicts for each peak. """ peaks = [] if len(sweep) < 3: return peaks for i in range(1, len(sweep) - 1): z_prev = sweep[i - 1]['impedance_ohm'] z_curr = sweep[i]['impedance_ohm'] z_next = sweep[i + 1]['impedance_ohm'] if z_curr > z_prev and z_curr > z_next: exceeds = z_curr > z_target if z_target else False if z_target is None or exceeds: peaks.append({ 'freq_hz': sweep[i]['freq_hz'], 'freq_mhz': sweep[i]['freq_hz'] / 1e6, 'impedance_ohm': z_curr, 'exceeds_target': exceeds, }) return peaks def estimate_esl(package: str) -> float: """Estimate ESL for an MLCC package. Returns Henries.""" return MLCC_ESL.get(package, 1.0e-9) def estimate_esr(package: str) -> float: """Estimate ESR for an MLCC package. Returns Ohms.""" return MLCC_ESR.get(package, 0.1) # --------------------------------------------------------------------------- # Differential pair formulas # --------------------------------------------------------------------------- # Protocol-specific differential pair parameters. # max_skew_ps: maximum allowed intra-pair skew (picoseconds) # v_diff: differential signal amplitude (Volts, peak) # rise_time_ns: typical 10-90% rise time (nanoseconds) # z_ohm: differential impedance target (Ohms) # # Sources: # USB: USB 2.0 spec §7.1.2, USB 3.x spec §6.8 # Ethernet: IEEE 802.3 §40.6.1 # HDMI: HDMI 2.1 spec §4.2.1.1 # LVDS: TIA/EIA-644 §4.2 # PCIe: PCI Express Base Spec §4.3.3 # CAN: ISO 11898-2 DIFF_PAIR_PROTOCOLS = { 'USB': {'max_skew_ps': 25, 'v_diff': 0.4, 'rise_time_ns': 0.5, 'z_ohm': 90}, # HS params (legacy key) 'USB-HS': {'max_skew_ps': 25, 'v_diff': 0.4, 'rise_time_ns': 0.5, 'z_ohm': 90}, 'USB-FS': {'max_skew_ps': 10000, 'v_diff': 0.4, 'rise_time_ns': 15.0, 'z_ohm': 90}, 'USB3': {'max_skew_ps': 5, 'v_diff': 0.4, 'rise_time_ns': 0.1, 'z_ohm': 90}, 'Ethernet': {'max_skew_ps': 50, 'v_diff': 1.0, 'rise_time_ns': 4.0, 'z_ohm': 100}, 'HDMI': {'max_skew_ps': 20, 'v_diff': 0.4, 'rise_time_ns': 0.2, 'z_ohm': 100}, 'LVDS': {'max_skew_ps': 25, 'v_diff': 0.35, 'rise_time_ns': 0.5, 'z_ohm': 100}, 'PCIe': {'max_skew_ps': 5, 'v_diff': 0.4, 'rise_time_ns': 0.1, 'z_ohm': 85}, 'SATA': {'max_skew_ps': 10, 'v_diff': 0.4, 'rise_time_ns': 0.1, 'z_ohm': 100}, 'MIPI': {'max_skew_ps': 15, 'v_diff': 0.2, 'rise_time_ns': 0.2, 'z_ohm': 100}, 'CAN': {'max_skew_ps': 500, 'v_diff': 2.0, 'rise_time_ns': 50, 'z_ohm': 120}, 'RS-485': {'max_skew_ps': 200, 'v_diff': 2.0, 'rise_time_ns': 10, 'z_ohm': 120}, } def propagation_delay_ps_per_mm(epsilon_r: float = 4.4) -> float: """Propagation delay for microstrip in ps/mm. Uses effective dielectric constant for microstrip: εeff ≈ (εr + 1) / 2. Delay = 1 / v_phase = √εeff / c. For FR4 (εr=4.4): εeff ≈ 2.7, delay ≈ 5.48 ps/mm. For stripline (fully embedded): εeff = εr, delay ≈ 6.99 ps/mm. """ # EQ-032: delay = √((εr+1)/2)/c (microstrip propagation delay) eps_eff = (epsilon_r + 1) / 2 # microstrip approximation v_phase = C_0 / math.sqrt(eps_eff) # m/s return 1e12 / (v_phase * 1e3) # ps/mm def diff_pair_skew_ps(length_diff_mm: float, epsilon_r: float = 4.4) -> float: """Time skew from differential pair length mismatch. Args: length_diff_mm: Absolute length difference in mm. epsilon_r: Board dielectric constant. Returns: Time skew in picoseconds. """ return abs(length_diff_mm) * propagation_delay_ps_per_mm(epsilon_r) def diff_pair_cm_voltage(v_diff: float, skew_ps: float, rise_time_ps: float) -> float: """Common-mode voltage generated by differential pair skew. V_CM = V_diff × Δt / (2 × T_rise) Ref: Ott, "EMC Engineering" Ch. 19; Johnson, "High-Speed Signal Propagation" Ch. 11. Args: v_diff: Differential signal amplitude in Volts. skew_ps: Intra-pair time skew in picoseconds. rise_time_ps: Signal rise time (10-90%) in picoseconds. Returns: Common-mode voltage in Volts. """ if rise_time_ps <= 0: return 0.0 return v_diff * skew_ps / (2 * rise_time_ps) # --------------------------------------------------------------------------- # Geometry helpers # --------------------------------------------------------------------------- def polygon_area(points: List[Tuple[float, float]]) -> float: """Area of a polygon from a list of (x, y) vertices (shoelace formula). Works for any simple (non-self-intersecting) polygon. Returns area in the same units squared as the input coordinates. """ n = len(points) if n < 3: return 0.0 area = 0.0 for i in range(n): j = (i + 1) % n area += points[i][0] * points[j][1] area -= points[j][0] * points[i][1] return abs(area) / 2.0 def point_to_segment_distance(px: float, py: float, x1: float, y1: float, x2: float, y2: float) -> float: """Minimum distance from point (px, py) to line segment (x1,y1)-(x2,y2). Returns the perpendicular distance if the projection falls on the segment, otherwise the distance to the nearest endpoint. """ # EQ-031: d = perpendicular distance from point to line segment dx = x2 - x1 dy = y2 - y1 seg_len_sq = dx * dx + dy * dy if seg_len_sq < 1e-12: # Degenerate segment (zero length) return math.sqrt((px - x1)**2 + (py - y1)**2) # Parameter t of the projection onto the line t = ((px - x1) * dx + (py - y1) * dy) / seg_len_sq t = max(0.0, min(1.0, t)) # Closest point on segment cx = x1 + t * dx cy = y1 + t * dy return math.sqrt((px - cx)**2 + (py - cy)**2) # --------------------------------------------------------------------------- # Market-to-standards mapping for regulatory gap analysis # --------------------------------------------------------------------------- MARKET_STANDARDS = { 'us': [ {'name': 'FCC Part 15 Class B', 'type': 'radiated', 'standard_key': 'fcc-class-b'}, {'name': 'FCC Part 15 Class B', 'type': 'conducted', 'standard_key': 'fcc-class-b'}, ], 'eu': [ {'name': 'EN 55032 Class B (CISPR 32)', 'type': 'radiated', 'standard_key': 'cispr-class-b'}, {'name': 'EN 55032 Class B (CISPR 32)', 'type': 'conducted', 'standard_key': 'cispr-class-b'}, {'name': 'IEC 61000-4-2', 'type': 'esd', 'standard_key': None}, {'name': 'IEC 61000-4-4', 'type': 'eft', 'standard_key': None}, {'name': 'IEC 61000-4-5', 'type': 'surge', 'standard_key': None}, ], 'automotive': [ {'name': 'CISPR 25 Class 5', 'type': 'radiated', 'standard_key': 'cispr-25'}, {'name': 'CISPR 25 Class 5', 'type': 'conducted', 'standard_key': 'cispr-25'}, {'name': 'ISO 10605', 'type': 'esd', 'standard_key': None}, {'name': 'ISO 7637-2', 'type': 'transient', 'standard_key': None}, ], 'medical': [ {'name': 'EN 55032 + EN 60601-1-2', 'type': 'radiated', 'standard_key': 'cispr-class-b'}, {'name': 'EN 55032 + EN 60601-1-2', 'type': 'conducted', 'standard_key': 'cispr-class-b'}, {'name': 'IEC 61000-4-2 Level 4', 'type': 'esd', 'standard_key': None}, {'name': 'IEC 61000-4-3 (10 V/m)', 'type': 'radiated_immunity', 'standard_key': None}, {'name': 'IEC 61000-4-5 Level 4', 'type': 'surge', 'standard_key': None}, ], 'military': [ {'name': 'MIL-STD-461G RE102', 'type': 'radiated', 'standard_key': 'mil-std-461'}, {'name': 'MIL-STD-461G CE102', 'type': 'conducted', 'standard_key': 'mil-std-461'}, {'name': 'MIL-STD-461G CS118', 'type': 'esd', 'standard_key': None}, {'name': 'MIL-STD-461G CS114/CS116', 'type': 'conducted_susceptibility', 'standard_key': None}, ], } # --------------------------------------------------------------------------- # Full-Board PDN: Power Tree, Trace Parasitics, Distributed Impedance # --------------------------------------------------------------------------- # EQ-042: R_trace = rho * L / (W * T) — DC trace resistance COPPER_RESISTIVITY_OHM_MM = 1.724e-5 # ohm·mm at 20°C def trace_resistance_ohm(length_mm: float, width_mm: float, thickness_mm: float = 0.035) -> float: """DC resistance of a copper trace. R = ρ × L / (W × T) Args: length_mm: Trace length in mm. width_mm: Trace width in mm. thickness_mm: Copper thickness in mm (default: 1 oz = 0.035mm). Returns: Resistance in Ohms. """ if width_mm <= 0 or thickness_mm <= 0 or length_mm <= 0: return 0.0 return COPPER_RESISTIVITY_OHM_MM * length_mm / (width_mm * thickness_mm) def trace_inductance_h(length_mm: float, width_mm: float, height_mm: float = 0.2) -> float: """Total inductance of a microstrip trace over a reference plane. # EQ-091: L = Z₀/v_phase × length (Wheeler microstrip inductance) # Delegates to trace_inductance_nh_per_mm() for the per-mm value, # which uses the Wheeler formula (typical: 0.3-0.8 nH/mm). Args: length_mm: Trace length in mm. width_mm: Trace width in mm. height_mm: Height above reference plane in mm (default: 0.2mm). Returns: Inductance in Henries. """ if length_mm <= 0: return 0.0 l_nh_per_mm = trace_inductance_nh_per_mm(width_mm, height_mm) return l_nh_per_mm * length_mm * 1e-9 def build_power_tree(regulators: list, power_budget: dict, decoupling_analysis: list) -> dict: """Build a power tree graph from schematic data. Each node represents a power rail produced by a regulator. Nodes link via input_rail/output_rail relationships. Args: regulators: findings filtered by detector=='detect_power_regulators'. power_budget: power_budget dict with 'rails' sub-dict. decoupling_analysis: findings filtered by detector=='detect_decoupling'. Returns: Dict of {rail_name: node_dict}. """ tree = {} rails = power_budget.get('rails', {}) if power_budget else {} # Build decoupling cap lookup: rail_net -> [cap dicts] decoup_by_rail = {} if isinstance(decoupling_analysis, list): for entry in decoupling_analysis: if not isinstance(entry, dict): continue rail_net = entry.get('rail_net', '') caps = entry.get('capacitors', []) if rail_net and isinstance(caps, list): decoup_by_rail.setdefault(rail_net, []).extend(caps) for reg in regulators: if not isinstance(reg, dict): continue output_rail = reg.get('output_rail', '') if not output_rail: continue # Get voltage vout = reg.get('estimated_vout') if not vout or not isinstance(vout, (int, float)): continue # Load ICs from power budget rail_data = rails.get(output_rail, {}) load_ics = [] if isinstance(rail_data, dict): for ic in rail_data.get('ics', []): if isinstance(ic, dict): load_ics.append({ 'ref': ic.get('ref', ''), 'estimated_mA': ic.get('estimated_mA', 100), 'decoupling_caps': [], 'trace_r_ohm': 0.0, 'trace_l_h': 0.0, }) # Output caps with ESR/ESL output_caps = [] for cap in reg.get('output_capacitors', []): if not isinstance(cap, dict): continue c = cap.get('farads', 0) if c <= 0: continue pkg = cap.get('package', cap.get('footprint', '')) output_caps.append({ 'ref': cap.get('ref', ''), 'farads': c, 'esr_ohm': cap.get('esr_ohm', estimate_esr(pkg)), 'esl_h': cap.get('esl_h', estimate_esl(pkg)), 'package': pkg, 'location': 'regulator_output', }) # Decoupling caps on this rail (from decoupling_analysis) for dc in decoup_by_rail.get(output_rail, []): if not isinstance(dc, dict): continue c = dc.get('farads', 0) if c <= 0: continue # Skip if already in output_caps (same ref) dc_ref = dc.get('ref', '') if dc_ref and any(oc.get('ref') == dc_ref for oc in output_caps): continue pkg = dc.get('package', dc.get('footprint', '')) output_caps.append({ 'ref': dc_ref, 'farads': c, 'esr_ohm': dc.get('esr_ohm', estimate_esr(pkg)), 'esl_h': dc.get('esl_h', estimate_esl(pkg)), 'package': pkg, 'location': 'ic_decoupling', }) # Estimate total load current total_load_mA = 0 if isinstance(rail_data, dict): total_load_mA = rail_data.get('estimated_load_mA', 0) if total_load_mA <= 0: total_load_mA = sum(ic.get('estimated_mA', 100) for ic in load_ics) if total_load_mA <= 0: total_load_mA = 100 # Conservative default # Switching frequency for cross-rail analysis sw_freq = None topology = reg.get('topology', '').lower() if topology not in ('ldo', 'linear', ''): # Try to get from value/part number from emc_rules import _estimate_switching_freq sw_freq = _estimate_switching_freq(reg.get('value', '')) tree[output_rail] = { 'rail': output_rail, 'voltage': vout, 'regulator': { 'ref': reg.get('ref', ''), 'topology': topology, 'input_rail': reg.get('input_rail', ''), 'switching_freq_hz': sw_freq, 'efficiency': 0.87 if topology in ('buck', 'switching') else 0.85 if topology == 'boost' else 0.83 if topology == 'buck-boost' else 1.0, }, 'output_caps': output_caps, 'load_ics': load_ics, 'total_load_mA': total_load_mA, 'trace_r_total_ohm': 0.0, 'trace_l_total_h': 0.0, } # Link downstream regulators for rail_name, node in tree.items(): downstream = [] for other_name, other_node in tree.items(): if other_node['regulator']['input_rail'] == rail_name: downstream.append(other_node['regulator']['ref']) node['downstream_regulators'] = downstream return tree def enrich_power_tree_with_pcb(tree: dict, pcb: dict) -> None: """Add PCB trace parasitics to power tree nodes (mutates tree in place). Uses net_lengths[].trace_segments if available (--full PCB mode), otherwise falls back to total_length_mm with default width. """ if not pcb: return # Build net_name -> net_lengths entry lookup nl_map = {} for nl in pcb.get('net_lengths', []): if isinstance(nl, dict) and 'net_name' in nl: nl_map[nl['net_name']] = nl # Get default copper thickness from stackup cu_thickness = 0.035 # 1 oz default stackup = pcb.get('setup', {}).get('stackup', []) for layer in stackup: if isinstance(layer, dict) and layer.get('type') == 'copper': t = layer.get('thickness') if t: try: cu_thickness = float(t) except (ValueError, TypeError): pass break for rail_name, node in tree.items(): nl = nl_map.get(rail_name) if not nl: continue segments = nl.get('trace_segments', []) if segments: # Full mode: per-segment R and L total_r = 0.0 total_l = 0.0 for seg in segments: if not isinstance(seg, dict): continue length = seg.get('length_mm', 0) width = seg.get('width_mm', 0.3) total_r += trace_resistance_ohm(length, width, cu_thickness) total_l += trace_inductance_h(length, width) node['trace_r_total_ohm'] = total_r node['trace_l_total_h'] = total_l else: # Fallback: total length with default width total_length = nl.get('total_length', nl.get('total_length_mm', 0)) if total_length > 0: node['trace_r_total_ohm'] = trace_resistance_ohm( total_length, 0.3, cu_thickness) node['trace_l_total_h'] = trace_inductance_h( total_length, 0.3) # Via inductance contribution via_count = nl.get('via_count', 0) if via_count > 0: # Typical via inductance: ~0.5-1.0 nH per via node['trace_l_total_h'] += via_count * 0.7e-9 def distributed_pdn_impedance_sweep( node: dict, plane_cap_f: float = 0, freq_start: float = 1e3, freq_stop: float = 1e9, points_per_decade: int = 50) -> dict: """Compute PDN impedance at regulator output AND at worst-case IC. # EQ-092: Z_IC = Z_local || (Z_reg + Z_trace) (distributed PDN impedance) # Z_trace = R_trace + jωL_trace (trace parasitic series impedance) # Source: Novak "Power Distribution Network Design Methodologies" (IPC, 2008) Ch. 4 The impedance at the IC includes trace R+L in series with the regulator-side decoupling, in parallel with local IC decoupling. Returns: { 'sweep_at_regulator': [{freq_hz, impedance_ohm}], 'sweep_at_worst_ic': [{freq_hz, impedance_ohm}], 'worst_ic_ref': str, 'trace_r_ohm': float, 'trace_l_h': float, } """ # Regulator-side caps (all output caps) reg_caps = node.get('output_caps', []) # Regulator-side sweep (existing logic) reg_sweep = pdn_impedance_sweep( reg_caps, plane_cap_f=plane_cap_f, freq_start=freq_start, freq_stop=freq_stop, points_per_decade=points_per_decade) # Trace parasitics r_trace = node.get('trace_r_total_ohm', 0) l_trace = node.get('trace_l_total_h', 0) if r_trace <= 0 and l_trace <= 0: # No trace data — IC sweep is same as regulator sweep return { 'sweep_at_regulator': reg_sweep, 'sweep_at_worst_ic': reg_sweep, 'worst_ic_ref': '', 'trace_r_ohm': 0, 'trace_l_h': 0, } # Find IC decoupling caps (caps with location 'ic_decoupling') ic_caps = [c for c in reg_caps if c.get('location') == 'ic_decoupling'] reg_only_caps = [c for c in reg_caps if c.get('location') != 'ic_decoupling'] # Compute impedance at worst-case IC # Z_at_IC = Z_local || (Z_reg + Z_trace) # where Z_reg = impedance of regulator-side caps # Z_trace = R_trace + j*omega*L_trace # Z_local = impedance of local IC decoupling caps ic_sweep = [] decades = math.log10(freq_stop / freq_start) n_points = int(decades * points_per_decade) for i in range(n_points + 1): f = freq_start * (10 ** (i * decades / n_points)) omega = 2 * math.pi * f # Regulator-side impedance (all reg_only_caps + plane cap) all_reg = list(reg_only_caps) if plane_cap_f > 0: all_reg.append({'farads': plane_cap_f, 'esr_ohm': 0.001, 'esl_h': 0.05e-9}) z_reg = parallel_cap_impedance(f, all_reg) if all_reg else float('inf') # Trace impedance (series R + jωL) z_trace_complex = complex(r_trace, omega * l_trace) # Remote impedance: Z_reg (cap network magnitude, modeled as real) + Z_trace (complex) z_remote = abs(z_reg + z_trace_complex) # Local IC decoupling impedance if ic_caps: z_local = parallel_cap_impedance(f, ic_caps) else: z_local = float('inf') # Parallel combination: Z_at_IC = Z_local || Z_remote if z_local == float('inf') and z_remote == float('inf'): z_ic = float('inf') elif z_local == float('inf'): z_ic = z_remote elif z_remote == float('inf'): z_ic = z_local else: z_ic = (z_local * z_remote) / (z_local + z_remote) ic_sweep.append({'freq_hz': f, 'impedance_ohm': z_ic}) # Identify worst IC (highest current draw) worst_ic = '' load_ics = node.get('load_ics', []) if load_ics: worst = max(load_ics, key=lambda ic: ic.get('estimated_mA', 0)) worst_ic = worst.get('ref', '') return { 'sweep_at_regulator': reg_sweep, 'sweep_at_worst_ic': ic_sweep, 'worst_ic_ref': worst_ic, 'trace_r_ohm': r_trace, 'trace_l_h': l_trace, } def cross_rail_transient_current(downstream_node: dict, upstream_voltage: float) -> tuple: """Compute reflected transient current on upstream rail from downstream switcher. A switching regulator draws pulsed current from its input rail at its switching frequency. The peak input current depends on topology. Returns: (i_transient_a, switching_freq_hz) or (0, 0) if not applicable. """ reg = downstream_node.get('regulator', {}) topology = reg.get('topology', '') sw_freq = reg.get('switching_freq_hz') eta = reg.get('efficiency', 0.85) if topology in ('ldo', 'linear', '') or not sw_freq: return (0.0, 0) vout = downstream_node.get('voltage', 0) i_out_a = downstream_node.get('total_load_mA', 100) / 1000.0 if upstream_voltage <= 0 or vout <= 0: return (0.0, 0) # Input power = output power / efficiency p_out = vout * i_out_a i_in_avg = p_out / (upstream_voltage * eta) if eta > 0 else 0 # Peak transient depends on topology if topology == 'buck': # Buck duty cycle D = Vout/Vin # Input current is pulsed: I_in_peak = I_out / D = I_out * Vin / Vout # But the transient seen by the input PDN is the AC component d = vout / upstream_voltage i_peak = i_out_a / d if d > 0 else i_out_a # AC component ≈ I_peak × (1 - D) i_transient = i_peak * (1 - d) elif topology == 'boost': # Boost input current is continuous, lower transient i_transient = i_in_avg * 0.3 else: # Generic switching: assume 50% AC ripple i_transient = i_in_avg * 0.5 return (max(i_transient, 0.01), sw_freq) # --------------------------------------------------------------------------- # Inductor near-field H-field estimation # --------------------------------------------------------------------------- def estimate_inductor_h_field(peak_current_a, distance_m, inductor_size_mm=5.0): """Estimate peak H-field from a power inductor at a given distance. Uses a magnetic dipole approximation for the near-field region. The inductor is modeled as a small current loop with area derived from the package size. This is a worst-case estimate — actual fields depend on core material, winding geometry, and shielding. H = (m * sin(theta)) / (4 * pi * r^3) [near-field dipole] where m = N * I * A (magnetic moment), N=1 for single-turn approximation, I = peak current, A = effective loop area. For a typical power inductor, the effective radiating area is roughly (package_size/2)^2, much smaller than the full footprint because the magnetic circuit is partially closed by the core. Args: peak_current_a: Peak inductor current in amperes distance_m: Distance from inductor center in meters inductor_size_mm: Package dimension in mm (e.g., 5.0 for a 5x5mm inductor) Returns: Estimated H-field in A/m. Returns 0.0 if inputs are invalid. Reference: Ott, "Electromagnetic Compatibility Engineering", Ch. 2. Paul, "Introduction to Electromagnetic Compatibility", near-field model. """ if peak_current_a <= 0 or distance_m <= 0 or inductor_size_mm <= 0: return 0.0 # Effective loop area: half the package dimension squared (conservative) # Accounts for core partially containing the flux a_eff = ((inductor_size_mm / 2.0) * 1e-3) ** 2 # m^2 # Magnetic moment (single-turn approximation) m = peak_current_a * a_eff # A*m^2 # EQ-105: H = (m · sin θ) / (4π · r³), with m = N · I · A (magnetic moment) # and A ≈ (package_mm / 2)² (effective loop area ≈ 25% of footprint). # Source: Jackson, "Classical Electrodynamics" 3rd ed. §5.6 (magnetic # dipole near-field); Ott, "Electromagnetic Compatibility Engineering" # Ch. 11 (switching-regulator near-field coupling). # Near-field H at broadside (theta=90, sin=1, worst case) import math h = m / (4.0 * math.pi * distance_m ** 3) return h