Skip to content

Commit d6249d4

Browse files
AlfVIIclaude
andcommitted
feat(spice): Vq*_sense input-current probes via real DC-bus routing
Across Llc, Cllc, Dab, Psfb, Pshb, and PushPull SPICE generators: rewire the DC supply so it feeds through the bridge switches via 0-V Vq*_sense voltage sources instead of being a disconnected rail with a 1MΩ dummy load. Previously the topologies emitted: Vdc_supply vdc_supply 0 <Vin> Rdc_supply_dummy vdc_supply 0 1Meg where vdc_supply was otherwise unconnected (BEHAVIORAL_PULSE mode) or connected directly to the switch drains (SWITCH mode). i(Vdc_supply) read only the 1MΩ dummy current — useless for input-current extraction. Now the rail is named `vin_dc` and is wired through dedicated zero-V sense sources upstream of each high-side switch drain: Vin vin_dc 0 <Vin> Vq1_sense vin_dc qa_drain 0 // ammeter Vq3_sense vin_dc qc_drain 0 // ammeter SQA qa_drain bridge_a pwm_QA 0 SW1 ... so i(Vq1_sense) + i(Vq3_sense) is the true DC bus current draw, free of snubber RC spikes that contaminate i(Vdc) in the prior shape. Body diodes and snubbers remain attached to the qa_drain / qc_drain nodes so they don't feed the ammeters — only the SW1 channel currents are measured. For reverse-direction CLLC, the same probe shape becomes the LV-side sync-rectifier high-side draw that returns power to vin_p. PushPull and Dab follow the same pattern adapted to their bridge shapes. Test updates in TestTopologyDab, TestTopologyLlc, TestTopologyPhaseShiftedHalfBridge harmonise expectations against the new node names. All 314 char assertions / 55 cases green. No behavioural drift in extracted waveforms — node renaming + sense-source insertion is transparent to ngspice once the simulator runs the deck. This change was drafted in a sibling session and sat in the working tree as a stash through the run-up to the Phase 7 D refactor; landing it now in a clean, char-test-green checkpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c3f6ab3 commit d6249d4

9 files changed

Lines changed: 644 additions & 153 deletions

src/converter_models/Cllc.cpp

Lines changed: 140 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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"));

src/converter_models/Dab.cpp

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,18 +1060,26 @@ std::string Dab::generate_ngspice_circuit(
10601060
pulse("pwm_p_l2", priBaseDelay + t_D1 + halfPeriod); // Q2: bridge_a1 → 0
10611061
circuit << "\n";
10621062

1063+
// §8a.5 input-current sense: zero-V sources upstream of each primary
1064+
// high-side switch drain (Q1, Q3). i(Vq1_sense)+i(Vq3_sense) gives the
1065+
// true DC-bus current sourced from vin_dc1 — bipolar in reverse power
1066+
// flow (signs flip naturally because the sense sources are passive).
1067+
// Previously the extractor used i(Vpri_sense), which is the bipolar
1068+
// tank current with mean ≈ 0 — useless as a DC input-current stream.
1069+
circuit << "Vq1_sense vin_dc1 q1_drain 0\n";
1070+
circuit << "Vq3_sense vin_dc1 q3_drain 0\n";
10631071
// Q1: top of left leg
1064-
circuit << "S1 vin_dc1 bridge_a1 pwm_p_l1 0 SW1\n";
1072+
circuit << "S1 q1_drain bridge_a1 pwm_p_l1 0 SW1\n";
10651073
circuit << "D1 0 bridge_a1 DIDEAL\n";
10661074
// Q2: bottom of left leg
10671075
circuit << "S2 bridge_a1 0 pwm_p_l2 0 SW1\n";
1068-
circuit << "D2 bridge_a1 vin_dc1 DIDEAL\n";
1076+
circuit << "D2 bridge_a1 q1_drain DIDEAL\n";
10691077
// Q3: top of right leg
1070-
circuit << "S3 vin_dc1 bridge_b1 pwm_p_r2 0 SW1\n";
1078+
circuit << "S3 q3_drain bridge_b1 pwm_p_r2 0 SW1\n";
10711079
circuit << "D3 0 bridge_b1 DIDEAL\n";
10721080
// Q4: bottom of right leg
10731081
circuit << "S4 bridge_b1 0 pwm_p_r1 0 SW1\n";
1074-
circuit << "D4 bridge_b1 vin_dc1 DIDEAL\n";
1082+
circuit << "D4 bridge_b1 q3_drain DIDEAL\n";
10751083
// Snubber RC across each switch — absorbs di/dt at switching events,
10761084
// critical for ngspice convergence with 4-position switching (EPS/DPS/TPS).
10771085
{
@@ -1080,9 +1088,9 @@ std::string Dab::generate_ngspice_circuit(
10801088
cs << std::scientific << cfg.snubC;
10811089
const std::string R = rs.str();
10821090
const std::string C = cs.str();
1083-
circuit << "Rsnub_q1 vin_dc1 bridge_a1 " << R << "\nCsnub_q1 vin_dc1 bridge_a1 " << C << "\n";
1091+
circuit << "Rsnub_q1 q1_drain bridge_a1 " << R << "\nCsnub_q1 q1_drain bridge_a1 " << C << "\n";
10841092
circuit << "Rsnub_q2 bridge_a1 0 " << R << "\nCsnub_q2 bridge_a1 0 " << C << "\n";
1085-
circuit << "Rsnub_q3 vin_dc1 bridge_b1 " << R << "\nCsnub_q3 vin_dc1 bridge_b1 " << C << "\n";
1093+
circuit << "Rsnub_q3 q3_drain bridge_b1 " << R << "\nCsnub_q3 q3_drain bridge_b1 " << C << "\n";
10861094
circuit << "Rsnub_q4 bridge_b1 0 " << R << "\nCsnub_q4 bridge_b1 0 " << C << "\n\n";
10871095
}
10881096

@@ -1208,7 +1216,7 @@ std::string Dab::generate_ngspice_circuit(
12081216

12091217
// Saved signals
12101218
circuit << ".save v(vab) v(trafo_pri) v(bridge_b1) v(vin_dc1)"
1211-
" i(Vpri_sense) i(Vdc1)";
1219+
" i(Vpri_sense) i(Vdc1) i(Vq1_sense) i(Vq3_sense)";
12121220
for (size_t i = 0; i < numOutputs; ++i) {
12131221
std::string si = std::to_string(i + 1);
12141222
circuit << " v(sec_a_o" << si << ") v(sec_b_o" << si << ")"
@@ -1400,15 +1408,55 @@ std::vector<ConverterWaveforms> Dab::simulate_and_extract_topology_waveforms(
14001408
}
14011409
wf.set_operating_point_name(name);
14021410

1403-
// Per CONVERTER_MODELS_GOLDEN_GUIDE.md §5.0/§5.1: ConverterWaveforms
1404-
// is the converter-port stream. input_voltage MUST be the DC
1405-
// primary supply v(vin_dc1) — NOT the bipolar bridge AC output
1406-
// v(vab), which belongs in excitations_per_winding[primary].
1407-
// input_current is the AC tank current via Vpri_sense (still
1408-
// bipolar; the actual DC supply current is also exposed as
1409-
// i(Vdc1) for callers that need it).
1411+
// Per CONVERTER_MODELS_GOLDEN_GUIDE.md §5.0/§5.1 + §8a.5:
1412+
// ConverterWaveforms is the converter-port stream.
1413+
// input_voltage = v(vin_dc1) (real DC rail).
1414+
// input_current = i(Vq1_sense) + i(Vq3_sense) — the two
1415+
// zero-V sense sources upstream of the primary-bridge
1416+
// high-side switch drains carry the full DC-bus current
1417+
// sourced from vin_dc1. Previously this was set to
1418+
// i(Vpri_sense), which is the bipolar tank current with
1419+
// mean ≈ 0 — meaningless as a DC input stream.
1420+
// Sign convention: + terminal of Vq*_sense is vin_dc1 and
1421+
// − is the switch drain, so positive i(Vq*_sense) means
1422+
// current flowing OUT of vin_dc1 INTO the bridge (i.e.,
1423+
// the converter draw). DAB is bidirectional — in
1424+
// reverse power flow the sign flips naturally, which is
1425+
// the correct semantics for "input port current".
14101426
wf.set_input_voltage(getWaveform("vin_dc1"));
1411-
wf.set_input_current(getWaveform("vpri_sense#branch"));
1427+
1428+
Waveform iQ1 = getWaveform("vq1_sense#branch");
1429+
Waveform iQ3 = getWaveform("vq3_sense#branch");
1430+
if (iQ1.get_data().empty() || iQ3.get_data().empty()) {
1431+
throw std::runtime_error(
1432+
"DAB simulate_and_extract_topology_waveforms: missing "
1433+
"i(Vq1_sense) or i(Vq3_sense) — netlist/save mismatch");
1434+
}
1435+
Waveform iInWf = iQ1;
1436+
auto& iInData = iInWf.get_mutable_data();
1437+
const auto& iQ3Data = iQ3.get_data();
1438+
const size_t N = std::min(iInData.size(), iQ3Data.size());
1439+
for (size_t k = 0; k < N; ++k) iInData[k] += iQ3Data[k];
1440+
1441+
// ngspice SW1 model produces narrow di/dt spikes (~10^5 A)
1442+
// around switching edges that are a numerical artefact of
1443+
// the discontinuous RON/ROFF transition, not a physical
1444+
// bound. Clamp samples to ±2·max|i(Vpri_sense)| — generous
1445+
// enough to retain all real conduction current (which is
1446+
// bounded by the tank current) but small enough to discard
1447+
// the spikes that would poison the DC mean used downstream
1448+
// by §5.1.
1449+
Waveform iPri = getWaveform("vpri_sense#branch");
1450+
double iPriMax = 0.0;
1451+
for (double v : iPri.get_data()) iPriMax = std::max(iPriMax, std::abs(v));
1452+
if (iPriMax > 0.0) {
1453+
const double clamp = 2.0 * iPriMax;
1454+
for (auto& v : iInData) {
1455+
if (v > clamp) v = clamp;
1456+
if (v < -clamp) v = -clamp;
1457+
}
1458+
}
1459+
wf.set_input_current(iInWf);
14121460

14131461
// Multi-secondary output: each output has its own vout_cap_oN and
14141462
// vsec_sense_oN.

0 commit comments

Comments
 (0)