@@ -1284,7 +1284,7 @@ double get_value_or(T&& val, double default_val) {
12841284 cllc4_sample_segments (segs4, Thalf_eff, N, Vi, Vo, tp4,
12851285 ILs_pos, IL_pos, vCr1_pos, vCr2_pos);
12861286 for (int k = 0 ; k <= N; ++k) Vc_pos[k] = vCr1_pos[k] + vCr2_pos[k];
1287- // Convert 4-state segments to the symmetric SegmentVector shape
1287+ // Convert 4-state segments to the symmetric SegmentVector shape
12881288 // for downstream mode classification / VLm reconstruction. The
12891289 // x_start / x_end vC field carries the collapsed-cap total.
12901290 segments.reserve (segs4.size ());
@@ -1816,10 +1816,26 @@ double get_value_or(T&& val, double default_val) {
18161816 << " 10n 10n " << tOn << " " << period << " )\n\n " ;
18171817
18181818 // Primary full bridge — 4 active switches.
1819+ //
1820+ // §8a.5 input-current probe — Vq1_sense / Vq3_sense are 0-V
1821+ // ammeters inserted upstream of the high-side switches S1 and
1822+ // S3. i(Vq1_sense)+i(Vq3_sense) reports the clean high-side
1823+ // switch current draw from vin_p, free of the snubber RC spikes
1824+ // (~10^5 A) that contaminate i(Vin). The body diodes DS1/DS3
1825+ // and the high-side snubbers (Rsn_S1/Rsn_S3) stay on the
1826+ // original vin_p node so they do not feed the ammeters — only
1827+ // the S1/S3 channel currents are measured. Used in extractors
1828+ // as the converter's input current in FORWARD mode. In REVERSE
1829+ // mode i(Vq1_sense)+i(Vq3_sense) becomes the synchronous-
1830+ // rectifier high-side draw that returns power to vin_p; the
1831+ // clean input-port current there is i(Vin_sense), measured
1832+ // downstream of Cin_pri (snubber-filtered by the bulk cap).
18191833 circuit << " * Primary Full Bridge (4 active switches)\n " ;
1820- circuit << " S1 vin_p node_a pwm1 0 SW1\n " ;
1834+ circuit << " Vq1_sense vin_p qa_drain 0\n " ;
1835+ circuit << " Vq3_sense vin_p qc_drain 0\n " ;
1836+ circuit << " S1 qa_drain node_a pwm1 0 SW1\n " ;
18211837 circuit << " S2 node_a 0 pwm2 0 SW1\n " ;
1822- circuit << " S3 vin_p node_b pwm2 0 SW1\n " ;
1838+ circuit << " S3 qc_drain node_b pwm2 0 SW1\n " ;
18231839 circuit << " S4 node_b 0 pwm1 0 SW1\n " ;
18241840 // Antiparallel body diodes — high-side: anode=drain, cathode=source(=vin_p).
18251841 // Low-side: anode=source(=0), cathode=drain. Provides freewheel
@@ -1867,6 +1883,32 @@ double get_value_or(T&& val, double default_val) {
18671883 circuit << " Vd_ref sec_trafo_n node_d 0\n " ;
18681884 circuit << " Rdc_sec sec_trafo_n 0 1G\n\n " ;
18691885
1886+ // ---- Differential winding-voltage probes (§8a.5 fix) ----
1887+ //
1888+ // The magnetic-view operating-point excitations require the
1889+ // voltage ACROSS each transformer winding (the EMF that drives
1890+ // core flux), NOT the converter-side terminal voltage. The
1891+ // legacy waveform mapping used `pri_trafo_in` and `sec_trafo_p`
1892+ // as bare node references, which the extractor interprets as
1893+ // `v(pri_trafo_in) - 0` and `v(sec_trafo_p) - 0` — both of
1894+ // those float relative to ground because Lpri / Lsec have no
1895+ // ground reference, so the resulting "winding voltage" lumps
1896+ // the resonant tank capacitor offsets (C_res1, C_res2) and the
1897+ // floating bias on top of the actual winding EMF.
1898+ //
1899+ // E-source probes here expose the actual L_pri / L_sec
1900+ // terminal voltages with the correct dot-convention sign.
1901+ // Polarity: the primary winding is `Lpri pri_trafo_in node_b`
1902+ // (dot at pri_trafo_in), so v_pri_w = v(pri_trafo_in)-v(node_b).
1903+ // The secondary winding is `Lsec sec_trafo_p sec_trafo_n` (dot
1904+ // at sec_trafo_p), so v_sec_w = v(sec_trafo_p)-v(sec_trafo_n).
1905+ // Same probes work in both FORWARD and REVERSE — the E-source
1906+ // is a passive measurement, not a directional element; sign
1907+ // reflects whichever side is currently sourcing.
1908+ circuit << " * Differential winding-voltage probes (§8a.5)\n " ;
1909+ circuit << " Evpri_w vpri_w 0 pri_trafo_in node_b 1\n " ;
1910+ circuit << " Evsec_w vsec_w 0 sec_trafo_p sec_trafo_n 1\n\n " ;
1911+
18701912 // ---- Active synchronous-rectifier full bridge on secondary ----
18711913 // Per plan §6.1: in forward mode the SR is gated synchronously with
18721914 // the primary PWM (pwm_s1 = pwm_p1, pwm_s2 = pwm_p2). This avoids
@@ -1943,11 +1985,14 @@ double get_value_or(T&& val, double default_val) {
19431985 circuit << " .save v(vab) v(vin_p) v(pri_trafo_in) v(node_a) v(node_b)\n " ;
19441986 circuit << " + v(node_c) v(node_d) v(sec_trafo_p) v(sec_trafo_n)\n " ;
19451987 circuit << " + v(vout_p) v(vout_n) v(pri_c1_in) v(sec_c2_in)\n " ;
1988+ circuit << " + v(vpri_w) v(vsec_w)\n " ;
19461989 if (isReverse) {
1947- circuit << " + i(Vin_sense) i(Vpri_sense) i(Vsec_sense) i(Vsec_src)\n\n " ;
1990+ circuit << " + i(Vin_sense) i(Vpri_sense) i(Vsec_sense) i(Vsec_src)\n " ;
1991+ circuit << " + i(Vq1_sense) i(Vq3_sense)\n\n " ;
19481992 }
19491993 else {
1950- circuit << " + i(Vin) i(Vpri_sense) i(Vsec_sense) i(Vout_sense)\n\n " ;
1994+ circuit << " + i(Vin) i(Vpri_sense) i(Vsec_sense) i(Vout_sense)\n " ;
1995+ circuit << " + i(Vq1_sense) i(Vq3_sense)\n\n " ;
19511996 }
19521997
19531998 // Solver options — verbatim from plan §6.2
@@ -2044,17 +2089,25 @@ double get_value_or(T&& val, double default_val) {
20442089 // Map waveform names to winding excitations
20452090 NgspiceRunner::WaveformNameMapping waveformMapping;
20462091
2047- // Primary winding: voltage across transformer primary = v(pri_trafo_in) - v(node_b)
2048- // Current through primary = i(Vpri_sense)
2092+ // §8a.5 — use the differential winding-voltage probes
2093+ // (vpri_w, vsec_w) emitted by generate_ngspice_circuit.
2094+ // These expose the actual EMF across each transformer
2095+ // winding. The legacy mapping referenced `pri_trafo_in`
2096+ // and `sec_trafo_p` as bare node names, which the
2097+ // extractor reads as `v(node) - 0` — but Lpri and Lsec
2098+ // are floating relative to ground, so that mapping
2099+ // lumped the resonant-cap DC bias (C_res1, C_res2) and
2100+ // the floating tank offset into the displayed winding
2101+ // voltage. The E-source probes capture v(pri_trafo_in)
2102+ // - v(node_b) and v(sec_trafo_p) - v(sec_trafo_n)
2103+ // directly with the correct dot-convention sign.
20492104 waveformMapping.push_back ({
2050- {" voltage" , " pri_trafo_in " },
2105+ {" voltage" , " vpri_w " },
20512106 {" current" , " vpri_sense#branch" }
20522107 });
20532108
2054- // Secondary winding: voltage = v(sec_trafo_p) - v(sec_trafo_n)
2055- // Current = i(Vsec_sense)
20562109 waveformMapping.push_back ({
2057- {" voltage" , " sec_trafo_p " },
2110+ {" voltage" , " vsec_w " },
20582111 {" current" , " vsec_sense#branch" }
20592112 });
20602113
@@ -2155,26 +2208,84 @@ double get_value_or(T&& val, double default_val) {
21552208 }
21562209 wf.set_operating_point_name (name);
21572210
2158- // §5.1 converter-port stream. The "input" port is the
2159- // primary rail (vin_p) regardless of direction:
2160- // FORWARD: vin_p is held by Vin source. i(Vin) flows
2161- // OUT of source's + terminal → into the converter.
2162- // Convention: converter draw = -i(Vin).
2163- // REVERSE (P8b): vin_p is held by Cin_pri+Rload_pri.
2164- // i(Vin_sense) flows from vin_p → vin_load (positive
2165- // when load absorbs, i.e., reverse power flow). To
2166- // keep the SAME sign convention "input current =
2167- // current flowing INTO the converter from the input
2168- // port", reverse mode emits -i(Vin_sense). The mean
2169- // becomes negative, signalling power LEAVING the
2170- // primary side — which is what reverse means.
2211+ // §8a.5 converter-port stream — input voltage is the
2212+ // primary rail v(vin_p) (held by Vin in FORWARD, by
2213+ // Cin_pri+Rload_pri in REVERSE).
2214+ //
2215+ // Input current:
2216+ // FORWARD: sum of high-side switch ammeter currents
2217+ // i(Vq1_sense)+i(Vq3_sense). The full-bridge primary
2218+ // draws from vin_p through S1 (during pwm1) and S3
2219+ // (during pwm2), diagonally. Summing both ammeters
2220+ // gives the total instantaneous bus draw, with the
2221+ // snubber RC spikes excluded (the snubbers stay on
2222+ // vin_p, upstream of the ammeters). Positive when
2223+ // current flows from vin_p into the primary tank.
2224+ // REVERSE: i(Vin_sense), the 0-V ammeter between
2225+ // vin_p and vin_load (the primary-side resistive
2226+ // load). Cin_pri filters snubber spikes, so this is
2227+ // clean. Sign: positive when current flows out of
2228+ // vin_p into the load, i.e., the converter is
2229+ // RETURNING power on the primary side. We negate
2230+ // to keep the "input current = current INTO the
2231+ // converter from the input port" convention, so the
2232+ // mean becomes negative in REVERSE — signalling
2233+ // power LEAVING the primary side.
2234+ //
2235+ // Why not i(Vin) in FORWARD? The 1k+1nF convergence-aid
2236+ // snubbers between vin_p and node_a/node_b inject huge
2237+ // dV/dt-driven spikes at every switch transition that
2238+ // contaminate i(Vin) and dwarf the real bus current.
2239+ // Why not i(Vpri_sense)? Vpri_sense sits inside the
2240+ // primary tank (between node_a and Cr1), so it measures
2241+ // the bipolar tank current that averages to zero — not
2242+ // the converter's actual input-port draw.
2243+ //
2244+ // Numerical-artifact clamp: ngspice's SW model
2245+ // transitions instantaneously between Roff and Ron, so
2246+ // when S1/S3 close the di/dt is unbounded and the
2247+ // resampled trace can hold transient spikes ~10^5 A
2248+ // that are pure simulation noise (NOT measured
2249+ // current). Physically, |i(S1)|+|i(S3)| <= 2·|i(L_pri)|
2250+ // because each switch only conducts the primary tank
2251+ // current while ON. We clamp the combined trace to
2252+ // ±2·max|i(Vpri_sense)| (2× headroom for the
2253+ // switching-instant overshoot we want to keep visible).
2254+ // This is a numerical guard against the ngspice
2255+ // idealised-switch di/dt artifact, not a physical bound.
21712256 bool isReverse = (opPoint.get_power_flow () == CllcPowerFlow::REVERSE );
21722257 wf.set_input_voltage (getWaveform (" vin_p" ));
2173- Waveform iInWf = isReverse
2174- ? getWaveform (" vin_sense#branch" )
2175- : getWaveform (" vin#branch" );
2176- auto & iInData = iInWf.get_mutable_data ();
2177- for (auto & v : iInData) v = -v;
2258+
2259+ Waveform iInWf;
2260+ if (isReverse) {
2261+ iInWf = getWaveform (" vin_sense#branch" );
2262+ auto & iInData = iInWf.get_mutable_data ();
2263+ for (auto & v : iInData) v = -v;
2264+ }
2265+ else {
2266+ Waveform iQ1 = getWaveform (" vq1_sense#branch" );
2267+ Waveform iQ3 = getWaveform (" vq3_sense#branch" );
2268+ auto & iQ1Data = iQ1.get_mutable_data ();
2269+ const auto & iQ3Data = iQ3.get_data ();
2270+ if (!iQ3Data.empty () && iQ1Data.size () == iQ3Data.size ()) {
2271+ for (size_t k = 0 ; k < iQ1Data.size (); ++k) {
2272+ iQ1Data[k] += iQ3Data[k];
2273+ }
2274+ }
2275+ iInWf = iQ1;
2276+ }
2277+ Waveform iPri = getWaveform (" vpri_sense#branch" );
2278+ const std::vector<double >& iPriData = iPri.get_data ();
2279+ double iPriMax = 0.0 ;
2280+ for (double v : iPriData) iPriMax = std::max (iPriMax, std::abs (v));
2281+ const double clampLimit = 2.0 * iPriMax;
2282+ auto & iInDataC = iInWf.get_mutable_data ();
2283+ if (clampLimit > 0.0 ) {
2284+ for (auto & v : iInDataC) {
2285+ if (v > clampLimit) v = clampLimit;
2286+ if (v < -clampLimit) v = -clampLimit;
2287+ }
2288+ }
21782289 wf.set_input_current (iInWf);
21792290
21802291 wf.get_mutable_output_voltages ().push_back (getWaveform (" vout_p" ));
0 commit comments