diff --git a/emikit/VALIDATION.md b/emikit/VALIDATION.md index b76d29b..c02ca59 100644 --- a/emikit/VALIDATION.md +++ b/emikit/VALIDATION.md @@ -1,118 +1,98 @@ # emikit validation -Three checks ship with the code. +## 1. loop formula -## 1. Loop formula calibration - -`emikit/tests/calibration_test.cpp` -- pins `loop_e_field` against the -closed form in Henry Ott, *Electromagnetic Compatibility Engineering* -(Wiley 2009) Eq 11-2 (same constants in Paul Eq 8.62): +`emikit/tests/calibration_test.cpp` pins `loop_e_field` to Ott Eq 11-2 +(also Paul Eq 8.62): E (V/m) = (eta0 * pi * I * A * f^2) / (c^2 * r) -Five test cases. Reference point: 1 mA, 1 cm^2 loop, 100 MHz, 3 m -> -12.85 dBuV/m. Tolerance 0.05 dB. Tag `[calibration]`. +Reference point: 1 mA, 1 cm^2 loop, 100 MHz, 3 m -> 12.85 dBuV/m. Plus +three scaling-derived points. Tolerance 0.05 dB. Tag `[calibration]`. -## 2. Cable common-mode formula calibration +## 2. cable common-mode + estimator -`emikit/tests/cable_cm_test.cpp` -- pins `cable_cm_e_field` against the -Hockanson 1996 short-electric-dipole form (also Paul Eq 11.5): +`emikit/tests/cable_cm_test.cpp` pins `cable_cm_e_field` to Hockanson +1996 / Paul Eq 11.5: E (V/m) = (eta0 / c) * I_cm * L * f / r Reference point: 20 uA, 30 cm, 100 MHz, 10 m -> 37.55 dBuV/m. Plus the -TI ADS8686S working point used in section 3 below. Tag `[cable]`. - -## 3. Real-board comparison: TI ADS8686S EVM - -`emikit/tools/validate_ti.cpp` reconstructs the TI ADS8686SEVM-PDK setup -and compares emikit's prediction to chamber data published in -**TI SBAA548A**, "EMC Compliance Testing for Precision ADC Systems" -(April 2022, rev May 2022). - -### TI's measurements - -CISPR 11 Class A radiated emissions at 10 m, three SCLK rates: - -| Test | SCLK | Peak freq | Margin | Measured E | -|------|---------|--------------|--------|--------------| -| 1 | 10 MHz | 600.05 MHz | 22.83 | 34.67 dBuV/m | -| 2 | 50 MHz | 479.96 MHz | 2.77 | 54.73 dBuV/m | -| 3 | 10 MHz | 479.83 MHz | 6.44 | 51.06 dBuV/m | - -### Geometry (from SBAU319 EVM User's Guide, Section 7.2) - -- 4-layer FR-4, solid GND on Layer 2 -- SCLK trace ~30 mm from PHI connector to ADC pin -- Top-to-GND prepreg ~0.2 mm -- Trace width ~0.15 mm -- Drive: PHI MSP430-class 3.3 V CMOS, I_peak ~6 mA, t_r ~2 ns -- USB cable from PHI to host PC ~30 cm - -### Result - -Single 10 uA cable common-mode current applied across all three tests -(not per-test fitting -- this value comes from a few mV of estimated -ground bounce divided by ~200 ohm typical cable CM impedance): - - == Test 1 (SCLK=10.0 MHz) == - measured chamber: 34.67 dBuV/m at 600.0 MHz - emikit loop only: -320.28 dBuV/m (gap -354.95 dB) - cable CM (10.0 uA): 47.09 dBuV/m - combined (power-sum): 47.09 dBuV/m (gap +12.42 dB) - - == Test 2 (SCLK=50.0 MHz) == - measured chamber: 54.73 dBuV/m at 480.0 MHz - emikit loop only: -635.71 dBuV/m (gap -690.44 dB) - cable CM (10.0 uA): 45.15 dBuV/m - combined (power-sum): 45.15 dBuV/m (gap -9.58 dB) - - == Test 3 (SCLK=10.0 MHz) == - measured chamber: 51.06 dBuV/m at 479.8 MHz - emikit loop only: -335.15 dBuV/m (gap -386.21 dB) - cable CM (10.0 uA): 45.15 dBuV/m - combined (power-sum): 45.15 dBuV/m (gap -5.91 dB) - -Loop-only is 350+ dB low (the trapezoidal sinc lands in a null at -TI's measured peak frequency). With cable CM added the combined -prediction lands within **+12 / -10 dB** of the chamber peak across -all three operating points, using a single 10 uA assumption. - -### What this validates - -* The differential-mode loop physics (LoopEmissions) matches the - textbook formula (section 1). -* The cable CM physics (CableCommonMode) matches the textbook formula - (section 2). -* When combined, the model lands within pre-compliance accuracy of - published chamber data for a representative digital board, **with - one un-tuned CM-current parameter**. Per-test tuning of I_cm by - activity factor would close the gap further but would be fitting. - -### What this does NOT prove - -* The 10 uA CM current was reverse-engineered from "what would close - the gap". emikit does not yet estimate CM current from first - principles -- the user supplies it. A future revision will derive it - from the per-net signal current and ground-plane impedance. -* The 30 mm SCLK trace length is reconstructed from a PCB layout - figure. Real number is probably 25-40 mm. The loop-only contribution - is so far below the cable contribution that this uncertainty does - not affect the combined result. -* Only one board, one cable, one frequency band tested. More reference - pairs would strengthen the claim. - -### Reproducing +ground-bounce estimator: + + I_cm(f) = (2 * L_gnd / (L_cable_per_m * cable_length)) * I_signal(f) + +Hand-checked at 5 nH / 1 uH/m / 30 cm: ratio 3.33e-2. Tag `[cable]`. + +## 3. TI ADS8686S EVM + +`emikit/tools/validate_ti.cpp` compares against chamber data in TI +SBAA548A (April 2022) for the ADS8686SEVM-PDK at three SCLK rates. + +Reconstructed setup (geometry from SBAU319): + +| param | value | source | +|---------------|------------|-------------------------------------| +| trace length | 30 mm | EVM layer 1 figure, midpoint est | +| trace width | 0.15 mm | typical digital trace | +| loop height | 0.2 mm | typical TI 4-layer prepreg | +| drive current | 6 mA peak | PHI MSP430 3.3 V CMOS | +| rise time | 2 ns | nominal for that part class | +| cable length | 30 cm | PHI USB to host | +| L_gnd | 15 nH | mid-range "real digital board" | +| L_cable_per_m | 1.0 uH/m | typical unshielded USB | + +Same parameters for all three tests. + +### result + + test 1 (SCLK=10 MHz, 9.7 kSPS) + chamber: 34.67 dBuV/m at 600.0 MHz + emikit: 31.64 dBuV/m (gap -3.0 dB) + estimated I_cm: 1.7 uA + + test 2 (SCLK=50 MHz, 769 kSPS) + chamber: 54.73 dBuV/m at 480.0 MHz + emikit: 47.61 dBuV/m (gap -7.1 dB) + estimated I_cm: 13.4 uA + + test 3 (SCLK=10 MHz, 232 kSPS) + chamber: 51.06 dBuV/m at 479.8 MHz + emikit: 33.63 dBuV/m (gap -17.4 dB) + estimated I_cm: 2.7 uA + +Tests 1 and 2 land within +-7 dB of chamber across one set of +engineering parameters. Test 3 uses the same SCLK as test 1 but the +chamber measured 17 dB more because the higher sample rate drives more +activity on the data lines and SCLK bursts. The single-trace +continuous-clock input gives the same prediction for both runs. + +## todo + +- activity factor / burst modelling (would close the test 3 gap) +- multi-net summing +- empirical L_gnd from pdnkit plane impedance once that ships +- more reference pairs (Hockanson 1996 test board) +- finish openEMS cross-check (PR #62) +- cap on `cable_cm_e_field` for L > lambda/2 is conservative, not a + full-wave answer + +## reproduce cmake --build build --target emikit_validate_ti ./build/emikit/emikit_validate_ti -References: +Or from the CLI: + + emikit check board.kicad_pcb --mask "CISPR 32 Class A (10 m)" \ + --clock-hz 50e6 --rise-ns 2 --i-peak-ma 6 \ + --cable-length-cm 30 --ground-inductance-nh 15 + +## refs + - TI SBAA548A: https://www.ti.com/lit/an/sbaa548a/sbaa548a.pdf -- TI SBAU319 (ADS8686SEVM-PDK User's Guide): https://www.ti.com/lit/pdf/SBAU319 -- Hockanson, Drewniak, Hubing, Van Doren, "Investigation of fundamental - EMI source mechanisms driving common-mode radiation from printed - circuit boards with attached cables", IEEE Trans EMC 38(4), 1996. -- Paul, "Introduction to Electromagnetic Compatibility" 2nd ed., - Wiley 2006, Ch 11.3 (Eq 11.5). -- Ott, "Electromagnetic Compatibility Engineering", Wiley 2009, Ch 11.6. +- TI SBAU319: https://www.ti.com/lit/pdf/SBAU319 +- Hockanson, Drewniak, Hubing, Van Doren, IEEE Trans EMC 38(4), 1996 +- Paul, Intro to EMC 2nd ed., Wiley 2006, Ch 11.3 +- Ott, EMC Engineering, Wiley 2009, Ch 11.6 +- Montrose, EMC and the Printed Circuit Board, Wiley 1999, Ch 5 diff --git a/emikit/emi/BoardAnalysis.cpp b/emikit/emi/BoardAnalysis.cpp index f767dc1..fb73406 100644 --- a/emikit/emi/BoardAnalysis.cpp +++ b/emikit/emi/BoardAnalysis.cpp @@ -61,7 +61,11 @@ AnalysisResult analyze_board( } // Per-frequency drive current envelope -- shared across all nets in v1. - const auto drive_a = spectrum_sweep(config.drive, freqs); + // Use the worst-case envelope (Montrose form) rather than the + // exact harmonic magnitudes. Real EMI receivers see the envelope + // -- their IF bandwidth catches multiple harmonics and real + // signals have edge jitter that fills in the sinc nulls. + const auto drive_a = envelope_sweep(config.drive, freqs); // Initialize worst-case envelope to -inf. R.worst_case_dbuv.assign(freqs.size(), -1000.0); @@ -95,7 +99,50 @@ AnalysisResult analyze_board( R.nets.push_back(std::move(ne)); } - if (R.nets.empty()) { + // Cables. Each cable carries a common-mode current derived from + // the shared drive spectrum (or supplied explicitly) and radiates + // independently of the loop. The two contributions are summed in + // power per frequency. + for (const auto& cable : config.cables) { + if (cable.length_m <= 0.0) continue; + CableEmission ce; + ce.length_m = cable.length_m; + ce.cm_current_a = estimate_cm_current(cable, drive_a); + ce.e_dbuv.assign(freqs.size(), -1000.0); + + for (std::size_t k = 0; k < freqs.size(); ++k) { + CableSpec instant = cable; + instant.cm_current_a = ce.cm_current_a[k]; + const double e_v = + cable_cm_e_field(instant, freqs[k], config.test_distance_m); + if (e_v <= 0.0) continue; + ce.e_dbuv[k] = 20.0 * std::log10(e_v * 1.0e6); + + // Power-sum into the worst-case envelope. + const double loop_dbuv = R.worst_case_dbuv[k]; + const double loop_v = (loop_dbuv > -900.0) + ? std::pow(10.0, loop_dbuv / 20.0) + : 0.0; + const double cable_v = ce.e_dbuv[k] > -900.0 + ? std::pow(10.0, ce.e_dbuv[k] / 20.0) + : 0.0; + const double total_v = std::sqrt(loop_v * loop_v + + cable_v * cable_v); + R.worst_case_dbuv[k] = 20.0 * std::log10(total_v); + + if (R.worst_case_dbuv[k] > worst_overall_value) { + worst_overall_value = R.worst_case_dbuv[k]; + // Tag cables in the verdict with their length to + // distinguish from nets. + worst_overall_net = "(cable.length_m * 100.0)) + + " cm>"; + } + } + R.cables.push_back(std::move(ce)); + } + + if (R.nets.empty() && R.cables.empty()) { // Nothing matched -- absence of routed nets is not a PASS. R.verdict.status = Verdict::Status::NoData; return R; diff --git a/emikit/emi/BoardAnalysis.h b/emikit/emi/BoardAnalysis.h index ce4dca9..c7df8f8 100644 --- a/emikit/emi/BoardAnalysis.h +++ b/emikit/emi/BoardAnalysis.h @@ -20,6 +20,7 @@ #include "circuitcore/board/Board.h" #include "emi/LoopEmissions.h" #include "emi/Masks.h" +#include "emi/CableCommonMode.h" #include "emi/Spectrum.h" namespace emikit::emi { @@ -44,6 +45,13 @@ struct AnalysisConfig { // Only analyze nets whose names match any of these substrings // (case-insensitive). Empty -> all nets with at least one segment. std::vector net_filter; + + // Optional cables whose common-mode emission gets summed into the + // worst-case envelope. Each cable contributes via CableCommonMode; + // I_cm is either explicit (CableSpec::cm_current_a) or estimated + // from CableSpec::ground_inductance_h driven by the shared drive + // spectrum. + std::vector cables; }; struct NetEmission { @@ -56,6 +64,14 @@ struct NetEmission { std::vector e_dbuv; }; +struct CableEmission { + double length_m = 0.0; + // Estimated or explicit common-mode current per frequency (A). + std::vector cm_current_a; + // Resulting far-field E in dBuV/m per frequency. + std::vector e_dbuv; +}; + struct Verdict { // NoData: no routed nets matched the filter, so nothing was scored. // Distinct from Pass -- absence of emissions data is not the same @@ -69,8 +85,10 @@ struct Verdict { struct AnalysisResult { std::vector nets; - // For each freq_hz entry: the max E-field across all evaluated - // nets. This is the curve that gets compared to the mask. + std::vector cables; + // For each freq_hz entry: the power-sum of all per-net loop + // contributions and all per-cable CM contributions. This is the + // curve that gets compared to the mask. std::vector worst_case_dbuv; Verdict verdict; }; diff --git a/emikit/emi/CableCommonMode.cpp b/emikit/emi/CableCommonMode.cpp index 1596d13..d7aed80 100644 --- a/emikit/emi/CableCommonMode.cpp +++ b/emikit/emi/CableCommonMode.cpp @@ -19,10 +19,9 @@ double cable_cm_e_field(const CableSpec& cable, return 0.0; } - const double wavelength = kC / freq_hz; - const double L = cable.length_m; - const double I = std::abs(cable.cm_current_a); - const double r = distance_m; + const double L = cable.length_m; + const double I = std::abs(cable.cm_current_a); + const double r = distance_m; // Short electric dipole over a ground plane (Hockanson eq 1): // E = (eta0 / c) * I * L * f / r @@ -30,7 +29,6 @@ double cable_cm_e_field(const CableSpec& cable, // formula overestimates (real cables develop nodes in the current // distribution that the short-dipole approximation does not capture) // -- for pre-compliance work overestimation is the safe side. - (void)wavelength; return (kEta0 / kC) * I * L * freq_hz / r; } @@ -42,4 +40,31 @@ double cable_cm_e_field_dbuv(const CableSpec& cable, return 20.0 * std::log10(e * 1.0e6); } +std::vector estimate_cm_current( + const CableSpec& cable, + const std::vector& signal_current_a_per_freq) { + std::vector i_cm(signal_current_a_per_freq.size(), 0.0); + + // Explicit override takes precedence. + if (cable.cm_current_a > 0.0) { + for (auto& v : i_cm) v = cable.cm_current_a; + return i_cm; + } + + // Estimator needs both inductances to be set. + if (cable.ground_inductance_h <= 0.0 || + cable.cable_cm_inductance_per_m_h <= 0.0 || + cable.length_m <= 0.0) { + return i_cm; + } + + // Ratio is frequency-independent: I_cm/I_sig = 2*L_gnd / (L_cable*length). + const double ratio = (2.0 * cable.ground_inductance_h) / + (cable.cable_cm_inductance_per_m_h * cable.length_m); + for (std::size_t k = 0; k < signal_current_a_per_freq.size(); ++k) { + i_cm[k] = ratio * std::abs(signal_current_a_per_freq[k]); + } + return i_cm; +} + } // namespace emikit::emi diff --git a/emikit/emi/CableCommonMode.h b/emikit/emi/CableCommonMode.h index 26076ae..06437a7 100644 --- a/emikit/emi/CableCommonMode.h +++ b/emikit/emi/CableCommonMode.h @@ -1,53 +1,58 @@ -// Cable common-mode radiated emissions. -// -// On most real digital boards the dominant source of radiated emissions -// between 30 MHz and 1 GHz is not the differential signal loop, it is -// the common-mode current driven onto attached cables (USB, Ethernet, -// power, ribbon) by the noisy ground reference. See: -// * Henry Ott, "Electromagnetic Compatibility Engineering" Ch 11.6 -// * Clayton Paul, "Introduction to EMC" 2nd ed. Ch 11.3 / Eq 11.5 -// * Hockanson, Drewniak, Hubing, Van Doren et al. "Investigation of -// fundamental EMI source mechanisms driving common-mode radiation -// from printed circuit boards with attached cables." IEEE TEMC 1996. -// -// LoopEmissions models differential-mode radiation from the trace+return -// loop. This file adds the common-mode-on-cable contribution. The two -// are independent mechanisms; the chamber sees both summed in power. -// -// V1 takes the CM current as an explicit user input. Estimating it from -// scratch requires the ground-plane impedance at frequency and the -// cable's common-mode termination -- both highly board-specific. A -// future revision can add a heuristic estimator driven by the per-net -// signal current the analyzer already computes. +// cable common-mode radiated emissions. +// +// on most digital boards the dominant emission between 30 MHz and 1 GHz +// is common-mode current pushed onto attached cables by ground bounce, +// not the differential signal loop. +// +// refs: +// * Ott, EMC Engineering, Ch 11.6 +// * Paul, Intro to EMC 2nd ed., Ch 11.3 / Eq 11.5 +// * Hockanson et al., IEEE Trans EMC 38(4), 1996 +// +// LoopEmissions covers the differential-mode loop. CableSpec carries +// either an explicit cm_current_a or a ground_inductance_h that +// estimate_cm_current() uses to derive I_cm from the signal spectrum. #pragma once +#include + namespace emikit::emi { struct CableSpec { // Physical length of the cable from the PCB connector outward. - // Used both for the electrical-length transition and for the - // short-dipole length factor. double length_m = 0.0; - // Common-mode current on the cable, A. Magnitude only -- this - // model takes peak-envelope CM current and assumes it is flat - // across frequency. Per-frequency variation should be supplied - // through the cm_current_per_freq overload below. + // -------- Explicit CM current path -------- + // If > 0, this value is used as I_cm at every frequency and the + // estimator below is ignored. double cm_current_a = 0.0; + + // -------- Ground-bounce estimator path -------- + // Partial inductance of the ground return between the noisy + // signal source and the cable shield connection point. Typical + // values: + // * solid plane, short return: 0.5 - 2 nH + // * one via penalty: ~5 nH + // * crossing a slot in the plane: 10 - 30 nH + // * connector pin without dedicated GND: 20 - 50 nH + double ground_inductance_h = 0.0; + + // Per-unit-length common-mode inductance of the cable. Defaults to + // 1 uH/m which is representative of unshielded ribbon or zip cord; + // shielded coax / twisted-pair runs lower (~0.3 uH/m). + double cable_cm_inductance_per_m_h = 1.0e-6; }; // Far-field E magnitude (V/m) from a cable carrying common-mode // current I_cm at the broadside direction (theta = pi/2). Implements // the standard short-electric-dipole formula extended for image-in- -// ground-plane: +// ground-plane (Hockanson 1996 eq 1, Paul Eq 11.5): // -// |E| = (eta0 / c) * I_cm * L * f / r for L <= lambda / 4 +// |E| = (eta0 / c) * I_cm * L * f / r // -// For longer cables the short-dipole formula overestimates; we cap -// at the half-wave-monopole value (E = 60 * I_cm / r) once the -// cable goes resonant. This is consistent with the Hockanson 1996 -// fit used throughout the EMC literature. +// Accurate within a factor of ~2 for L < lambda/2; overestimates at +// longer lengths (conservative for pre-compliance). double cable_cm_e_field(const CableSpec& cable, double freq_hz, double distance_m); @@ -57,4 +62,21 @@ double cable_cm_e_field_dbuv(const CableSpec& cable, double freq_hz, double distance_m); +// Estimate CM current spectrum from the signal current spectrum, +// using the Hockanson ground-bounce model: +// +// I_cm(f) = (2 * L_gnd / (L_cable_per_m * cable_length)) * I_signal(f) +// +// Derivation: V_gb = j*omega*L_gnd * I_signal, and the cable's CM +// impedance is dominated by j*omega*L_cable/2 -- the frequency +// dependence cancels, leaving a flat ratio. See Hockanson 1996 +// Section III for the full derivation. +// +// If cable.cm_current_a > 0 the explicit value is returned at every +// frequency and the estimator is bypassed. If neither cm_current_a nor +// ground_inductance_h is set, returns all zeros. +std::vector estimate_cm_current( + const CableSpec& cable, + const std::vector& signal_current_a_per_freq); + } // namespace emikit::emi diff --git a/emikit/emi/LoopEmissions.h b/emikit/emi/LoopEmissions.h index 6a0c99b..c5700c3 100644 --- a/emikit/emi/LoopEmissions.h +++ b/emikit/emi/LoopEmissions.h @@ -12,9 +12,8 @@ // broadside value; a real trace is rarely oriented for that maximum, // but pre-compliance always assumes worst case. // -// We don't model the resonant transition (loop perimeter ~= lambda) -// because by that point the device is well into "hire a test house" -// territory anyway. v1 is honest about being pre-compliance. +// We don't model the resonant transition (loop perimeter ~= lambda). +// Past that you want a full-wave solver. #pragma once diff --git a/emikit/emi/Spectrum.cpp b/emikit/emi/Spectrum.cpp index a28a340..e76a5aa 100644 --- a/emikit/emi/Spectrum.cpp +++ b/emikit/emi/Spectrum.cpp @@ -1,5 +1,6 @@ #include "emi/Spectrum.h" +#include #include #include @@ -43,6 +44,28 @@ std::vector spectrum_sweep(const TrapezoidalSpec& spec, return out; } +double envelope_magnitude(const TrapezoidalSpec& spec, double freq_hz) { + if (spec.period_s <= 0.0 || freq_hz <= 0.0) return 0.0; + const double tau = spec.duty_cycle * spec.period_s; + const double dc = 2.0 * spec.i_peak_a * spec.duty_cycle; + const double pi = std::numbers::pi; + const double a = (tau > 0.0) + ? std::min(1.0, 1.0 / (pi * freq_hz * tau)) + : 1.0; + const double b = (spec.rise_time_s > 0.0) + ? std::min(1.0, 1.0 / (pi * freq_hz * spec.rise_time_s)) + : 1.0; + return dc * a * b; +} + +std::vector envelope_sweep(const TrapezoidalSpec& spec, + const std::vector& freq_hz) { + std::vector out; + out.reserve(freq_hz.size()); + for (double f : freq_hz) out.push_back(envelope_magnitude(spec, f)); + return out; +} + EnvelopeCorners envelope_corners(const TrapezoidalSpec& spec) { EnvelopeCorners e; const double tau = spec.duty_cycle * spec.period_s; diff --git a/emikit/emi/Spectrum.h b/emikit/emi/Spectrum.h index 8b9ef02..aab1a9d 100644 --- a/emikit/emi/Spectrum.h +++ b/emikit/emi/Spectrum.h @@ -40,6 +40,16 @@ double harmonic_magnitude(const TrapezoidalSpec& spec, int n); std::vector spectrum_sweep(const TrapezoidalSpec& spec, const std::vector& freq_hz); +// Worst-case envelope across all harmonics near f. Strips the sinc +// nulls of harmonic_magnitude. Use this for pre-compliance prediction +// -- real EMI receivers see the envelope across their IF bandwidth +// and real signals have edge jitter that fills in the nulls. +// +// |I_env(f)| = 2 * I_peak * d * min(1, 1/(pi*f*tau)) * min(1, 1/(pi*f*t_r)) +double envelope_magnitude(const TrapezoidalSpec& spec, double freq_hz); +std::vector envelope_sweep(const TrapezoidalSpec& spec, + const std::vector& freq_hz); + // The two corner frequencies of the envelope. Useful for sanity-checking // the rise-time-vs-bit-rate trade-off the user is making. struct EnvelopeCorners { diff --git a/emikit/main.cpp b/emikit/main.cpp index f466013..23110c5 100644 --- a/emikit/main.cpp +++ b/emikit/main.cpp @@ -16,6 +16,7 @@ #include "circuitcore/board/Board.h" #include "circuitcore/formats/kicad/PcbParser.h" #include "emi/BoardAnalysis.h" +#include "emi/CableCommonMode.h" #include "emi/Masks.h" #include "emi/Spectrum.h" @@ -45,6 +46,10 @@ int main(int argc, char** argv) { double rise_ns = 1.0; double i_peak_ma = 20.0; double loop_height_mm = 0.2; + double cable_length_cm = 0.0; + double ground_inductance_nh = 0.0; + double cable_cm_uh_per_m = 1.0; + double cable_cm_ua = 0.0; check->add_option("pcb", pcb_path, ".kicad_pcb file") ->required()->check(CLI::ExistingFile); check->add_option("--mask", mask_name, @@ -62,6 +67,18 @@ int main(int argc, char** argv) { check->add_option("--loop-height-mm", loop_height_mm, "Vertical distance trace to reference plane " "(default 0.2 mm)"); + check->add_option("--cable-length-cm", cable_length_cm, + "Attached cable length in cm. >0 enables " + "common-mode contribution."); + check->add_option("--ground-inductance-nh", ground_inductance_nh, + "Partial GND return inductance, used by the " + "common-mode estimator (typ 1-30 nH)."); + check->add_option("--cable-cm-uh-per-m", cable_cm_uh_per_m, + "Cable per-meter common-mode inductance " + "(default 1.0 uH/m, USB-like)."); + check->add_option("--cable-cm-ua", cable_cm_ua, + "Explicit cable CM current in uA. Overrides " + "the ground-inductance estimator."); auto* list_masks_cmd = app.add_subcommand( "list-masks", "List built-in regulatory masks"); @@ -95,6 +112,22 @@ int main(int argc, char** argv) { cfg.loop_height_m = loop_height_mm * 1e-3; cfg.test_distance_m = mask->test_distance_m; cfg.net_filter = nets_filter; + + if (cable_length_cm > 0.0) { + emikit::emi::CableSpec cable; + cable.length_m = cable_length_cm * 1e-2; + cable.cable_cm_inductance_per_m_h = cable_cm_uh_per_m * 1e-6; + if (cable_cm_ua > 0.0) { + cable.cm_current_a = cable_cm_ua * 1e-6; + } else if (ground_inductance_nh > 0.0) { + cable.ground_inductance_h = ground_inductance_nh * 1e-9; + } else { + std::fprintf(stderr, "emikit: --cable-length-cm given but no " + "--ground-inductance-nh or --cable-cm-ua, " + "cable will contribute nothing\n"); + } + cfg.cables.push_back(cable); + } // Leave cfg.freq_hz empty -> defaults to 30 MHz - 1 GHz log grid. auto R = emikit::emi::analyze_board(board_r.value(), *mask, cfg); diff --git a/emikit/tests/analysis_test.cpp b/emikit/tests/analysis_test.cpp index 826cd1b..9d130bc 100644 --- a/emikit/tests/analysis_test.cpp +++ b/emikit/tests/analysis_test.cpp @@ -112,3 +112,70 @@ TEST_CASE("analysis: default freq grid covers 30 MHz to 1 GHz", "[emi]") { REQUIRE(g.front() == Approx(30.0e6)); REQUIRE(g.back() == Approx(1.0e9)); } +// Append to analysis_test.cpp -- tests for cables-in-AnalysisConfig. + +TEST_CASE("analysis: cable contributes to envelope alongside loop", + "[emi]") { + auto b = one_trace_board(50e-3); + AnalysisConfig cfg; + emikit::emi::CableSpec cable; + cable.length_m = 0.5; + cable.cm_current_a = 50.0e-6; // explicit + cfg.cables.push_back(cable); + + auto R = analyze_board(b, cispr32_class_b(), cfg); + REQUIRE(R.cables.size() == 1); + REQUIRE(R.cables[0].length_m == Approx(0.5)); + // Cable's E should dominate over the small SCLK loop in this band. + REQUIRE(R.cables[0].e_dbuv.size() == R.worst_case_dbuv.size()); + // Cable contribution at every freq should push the envelope above + // the loop-only level. + bool any_cable_lifted = false; + for (std::size_t k = 0; k < R.worst_case_dbuv.size(); ++k) { + if (R.cables[0].e_dbuv[k] > R.nets[0].e_dbuv[k]) { + any_cable_lifted = true; break; + } + } + REQUIRE(any_cable_lifted); +} + +TEST_CASE("analysis: ground-bounce estimator drives cable contribution", + "[emi]") { + auto b = one_trace_board(50e-3); + AnalysisConfig cfg; + cfg.drive.i_peak_a = 10.0e-3; + cfg.drive.rise_time_s = 1.0e-9; + cfg.drive.period_s = 1.0e-8; // 100 MHz + + emikit::emi::CableSpec cable; + cable.length_m = 0.3; + cable.ground_inductance_h = 10.0e-9; // 10 nH, realistic for a slot + cable.cable_cm_inductance_per_m_h = 1.0e-6; + cfg.cables.push_back(cable); + + auto R = analyze_board(b, cispr32_class_b(), cfg); + REQUIRE(R.cables.size() == 1); + // I_cm spectrum should be non-zero where signal current is non-zero. + bool any_nonzero = false; + for (auto i : R.cables[0].cm_current_a) { + if (i > 0.0) { any_nonzero = true; break; } + } + REQUIRE(any_nonzero); +} + +TEST_CASE("analysis: cable alone (no segments) still produces a verdict", + "[emi]") { + circuitcore::board::Board b; // no nets, no segments + AnalysisConfig cfg; + emikit::emi::CableSpec cable; + cable.length_m = 1.0; + cable.cm_current_a = 100.0e-6; + cfg.cables.push_back(cable); + + auto R = analyze_board(b, cispr32_class_b(), cfg); + REQUIRE(R.nets.empty()); + REQUIRE(R.cables.size() == 1); + // With a real cable contribution, this is not NoData -- we have + // data to score against the mask. + REQUIRE(R.verdict.status != Verdict::Status::NoData); +} diff --git a/emikit/tests/cable_cm_test.cpp b/emikit/tests/cable_cm_test.cpp index 416a9a6..1f517f3 100644 --- a/emikit/tests/cable_cm_test.cpp +++ b/emikit/tests/cable_cm_test.cpp @@ -75,3 +75,56 @@ TEST_CASE("cable: TI ADS8686S working point sanity check", REQUIRE(cable_cm_e_field_dbuv(c, 480e6, 10.0) == Approx(54.71).margin(0.1)); } +// Append to cable_cm_test.cpp -- tests for estimate_cm_current and the +// BoardAnalysis cable integration. + +TEST_CASE("estimator: explicit I_cm overrides L_gnd path", "[cable]") { + CableSpec c; + c.length_m = 0.3; + c.cm_current_a = 5.0e-6; + c.ground_inductance_h = 100.0e-9; // would estimate much higher + + auto out = estimate_cm_current(c, {1.0e-3, 2.0e-3, 3.0e-3}); + REQUIRE(out.size() == 3); + for (auto v : out) REQUIRE(v == Approx(5.0e-6)); +} + +TEST_CASE("estimator: ground-bounce ratio matches hand calc", "[cable]") { + // L_gnd = 5 nH, cable 30 cm of 1 uH/m -> total cable L = 0.3 uH + // ratio = 2 * 5e-9 / 0.3e-6 = 3.33e-2 + CableSpec c; + c.length_m = 0.3; + c.ground_inductance_h = 5.0e-9; + c.cable_cm_inductance_per_m_h = 1.0e-6; + + auto out = estimate_cm_current(c, {1.0e-3, 5.0e-3, 10.0e-3}); + REQUIRE(out[0] == Approx(3.333e-5).epsilon(0.001)); + REQUIRE(out[1] == Approx(1.667e-4).epsilon(0.001)); + REQUIRE(out[2] == Approx(3.333e-4).epsilon(0.001)); +} + +TEST_CASE("estimator: returns zeros when no model fields set", "[cable]") { + CableSpec c; + c.length_m = 0.3; // length alone is not enough + auto out = estimate_cm_current(c, {1.0e-3, 2.0e-3}); + for (auto v : out) REQUIRE(v == 0.0); +} + +TEST_CASE("estimator: hand-computed cable contribution for TI working point", + "[cable][calibration]") { + // 30 cm USB cable, signal current 1 mA at 480 MHz, 5 nH ground bounce + // I_cm = (2 * 5e-9 / (1e-6 * 0.3)) * 1e-3 = 3.33e-2 * 1e-3 = 33.3 uA + // E = (eta0/c) * 33.3e-6 * 0.3 * 4.8e8 / 10 = 6.03e-4 V/m -> 55.6 dBuV/m + CableSpec c; + c.length_m = 0.3; + c.ground_inductance_h = 5.0e-9; + c.cable_cm_inductance_per_m_h = 1.0e-6; + + auto i_cm = estimate_cm_current(c, {1.0e-3}); + REQUIRE(i_cm[0] == Approx(33.33e-6).epsilon(0.001)); + + CableSpec instant = c; + instant.cm_current_a = i_cm[0]; + REQUIRE(cable_cm_e_field_dbuv(instant, 480e6, 10.0) == + Approx(55.59).margin(0.1)); +} diff --git a/emikit/tests/calibration_test.cpp b/emikit/tests/calibration_test.cpp index 10eaac4..3447dfd 100644 --- a/emikit/tests/calibration_test.cpp +++ b/emikit/tests/calibration_test.cpp @@ -1,27 +1,11 @@ -// Absolute-value calibration against the textbook closed form. -// -// loop_e_field implements the small magnetic-dipole far-field formula -// from Henry Ott, "Electromagnetic Compatibility Engineering" (Wiley -// 2009) Eq 11-2 -- also Clayton Paul "Introduction to Electromagnetic -// Compatibility" 2nd ed. Eq 8.62 with identical leading constants: +// pins loop_e_field to Ott Eq 11-2 (also Paul Eq 8.62) absolute +// values. scaling tests in loop_test.cpp would silently pass if eta0 +// or pi got corrupted; these wouldn't. // // E (V/m) = (eta0 * pi * I * A * f^2) / (c^2 * r) // -// The other emikit tests verify scaling (f^2, 1/r, linear in I and A). -// This file pins down the absolute value -- if someone fat-fingers -// eta0 or drops the pi, scaling tests still pass but these don't. -// -// Reference points were computed by hand against -// eta0 = 376.730313668 ohm -// c = 2.99792458e8 m/s -// to ~5 sig figs. Tolerance below is 0.05 dB which is well under the -// +/- 6 dB pre-compliance accuracy floor the tool claims. -// -// NOT validated here: the connection from a real PCB layout to the -// (I, A) inputs. That is the small-loop approximation, which is -// honest only when loop perimeter << wavelength and the trace has a -// nearby reference plane. Above ~1 GHz on a typical FR-4 board the -// approximation degrades; see Ott 11.3 and Paul 8.4 for the bounds. +// reference points hand-computed against eta0 = 376.730313668 ohm, +// c = 2.99792458e8 m/s to ~5 sig figs. #include #include diff --git a/emikit/tools/validate_ti.cpp b/emikit/tools/validate_ti.cpp index 5ce91c9..120bcb5 100644 --- a/emikit/tools/validate_ti.cpp +++ b/emikit/tools/validate_ti.cpp @@ -1,20 +1,15 @@ -// Run emikit against TI's published ADS8686SEVM-PDK chamber data -// (SBAA548A, April 2022). With the cable common-mode model added, -// see if predicted emissions land within pre-compliance accuracy of -// the chamber numbers across all three test conditions. +// Compares emikit against TI SBAA548A (April 2022) chamber data for +// the ADS8686SEVM-PDK at three SCLK rates. Uses the ground-bounce +// estimator for cable CM current: // -// Two contributions are summed in power: -// 1. Differential-mode loop radiation from the SCLK trace (existing -// LoopEmissions code; small-loop magnetic-dipole far-field). -// 2. Common-mode radiation from the USB cable connecting the PHI -// controller to the host PC (new CableCommonMode; short electric -// dipole over a ground plane, per Hockanson 1996). +// I_cm = (2 * L_gnd / (L_cable_per_m * cable_length)) * I_signal(f) // -// A single CM-current estimate is used across all three test cases -- -// 10 uA, representative of "moderately noisy digital ground" on a USB -// cable. This is the parameter to defend in real validation work; for -// this comparison we want to see how much of the gap closes with a -// single, non-tuned value. +// inputs: +// * cable length 0.3 m -- PHI USB to host PC +// * L_cable_per_m 1.0 uH/m -- typical unshielded USB CM mode +// * L_gnd 15 nH -- mid-range for a real digital board (Ott Ch 11 +// quotes 5-30 nH); EVM route crosses a layer transition and the +// PHI connector pinout is not optimal for return #include #include @@ -44,20 +39,10 @@ cb::Board sclk_board(double length_m) { return b; } -// Power-sum two dBuV/m contributions: E_total^2 = E_loop^2 + E_cable^2. -double sum_dbuv(double a_dbuv, double b_dbuv) { - const double a_v = std::pow(10.0, a_dbuv / 20.0); - const double b_v = std::pow(10.0, b_dbuv / 20.0); - const double s = std::sqrt(a_v * a_v + b_v * b_v); - return 20.0 * std::log10(s); -} - void run_test(const char* label, double sclk_hz, double measured_freq_hz, - double measured_dbuv, - double cable_length_m, - double cm_current_a) { + double measured_dbuv) { auto board = sclk_board(30.0e-3); ee::AnalysisConfig cfg; @@ -68,62 +53,49 @@ void run_test(const char* label, cfg.test_distance_m = 10.0; cfg.freq_hz = ee::default_cispr_freq_grid(200); + ee::CableSpec usb; + usb.length_m = 0.30; + usb.ground_inductance_h = 15.0e-9; // see header comment + usb.cable_cm_inductance_per_m_h = 1.0e-6; + cfg.cables.push_back(usb); + auto R = ee::analyze_board(board, ee::cispr32_class_a(), cfg); - // Find the analyzer's predicted loop-only value at the chamber's - // measured peak frequency. + // Pull out the envelope value at the measurement frequency. double best_df = 1e30; - double loop_dbuv_at_peak = -1000.0; + double pred_dbuv = -1000.0; + double pred_i_cm = 0.0; for (std::size_t k = 0; k < cfg.freq_hz.size(); ++k) { const double df = std::abs(cfg.freq_hz[k] - measured_freq_hz); if (df < best_df) { best_df = df; - loop_dbuv_at_peak = R.worst_case_dbuv[k]; + pred_dbuv = R.worst_case_dbuv[k]; + if (!R.cables.empty()) pred_i_cm = R.cables[0].cm_current_a[k]; } } - // Cable contribution at the same frequency. - const ee::CableSpec cable{cable_length_m, cm_current_a}; - const double cable_dbuv_at_peak = - ee::cable_cm_e_field_dbuv(cable, measured_freq_hz, cfg.test_distance_m); - - // Combined. - const double total_dbuv = sum_dbuv(loop_dbuv_at_peak, cable_dbuv_at_peak); - std::printf("\n== %s (SCLK=%.1f MHz) ==\n", label, sclk_hz / 1e6); std::printf(" measured chamber: %6.2f dBuV/m at %.1f MHz\n", measured_dbuv, measured_freq_hz / 1e6); - std::printf(" emikit loop only: %6.2f dBuV/m (gap %+.2f dB)\n", - loop_dbuv_at_peak, loop_dbuv_at_peak - measured_dbuv); - std::printf(" cable CM (%4.1f uA): %6.2f dBuV/m\n", - cm_current_a * 1e6, cable_dbuv_at_peak); - std::printf(" combined (power-sum): %6.2f dBuV/m (gap %+.2f dB)\n", - total_dbuv, total_dbuv - measured_dbuv); + std::printf(" emikit total envelope: %6.2f dBuV/m (gap %+.2f dB)\n", + pred_dbuv, pred_dbuv - measured_dbuv); + std::printf(" estimated I_cm here: %.2f uA\n", + pred_i_cm * 1e6); } } // namespace int main() { - std::printf("emikit TI ADS8686S validation -- loop + cable CM\n"); - std::printf("------------------------------------------------\n"); - std::printf("Reference: TI SBAA548A 'EMC Compliance Testing for " - "Precision ADC Systems'\n"); - std::printf("Loop: 30 mm SCLK trace, 0.15 mm wide, 0.2 mm above GND,\n"); - std::printf(" I = 6 mA peak, rise time 2 ns\n"); - std::printf("Cable: 30 cm USB cable, CM current 10 uA assumed\n"); - std::printf(" (single value across all three tests)\n"); - - // PHI USB cable length ~30 cm, CM current ~10 uA estimated from a - // few mV of ground bounce on the EVM divided by ~200 ohm typical - // cable CM impedance. Same value used across all tests so we are - // not fitting -- showing what one physically motivated estimate - // does to the prediction. - const double cable_L = 0.30; - const double cable_I = 10.0e-6; - - run_test("Test 1", 10.0e6, 600.05e6, 34.67, cable_L, cable_I); - run_test("Test 2", 50.0e6, 479.96e6, 54.73, cable_L, cable_I); - run_test("Test 3", 10.0e6, 479.83e6, 51.06, cable_L, cable_I); + std::printf("emikit TI ADS8686S validation -- estimator-driven\n"); + std::printf("--------------------------------------------------\n"); + std::printf("Loop: 30 mm SCLK, 0.15 mm wide, 0.2 mm above GND,\n"); + std::printf(" I = 6 mA peak, rise time 2 ns\n"); + std::printf("Cable: 30 cm USB, L_gnd = 15 nH (estimator-driven)\n"); + std::printf(" I_cm derived from drive spectrum via Hockanson 1996\n"); + + run_test("Test 1", 10.0e6, 600.05e6, 34.67); + run_test("Test 2", 50.0e6, 479.96e6, 54.73); + run_test("Test 3", 10.0e6, 479.83e6, 51.06); return 0; }