@@ -964,4 +964,204 @@ TEST_CASE("Test_Pshb_FrontendDefaults_TimingDiagnostic",
964964}
965965
966966
967+ // =========================================================================
968+ // Dumps the actual secondary waveform shape produced by the PSHB wizard
969+ // defaults so we can see why the front-end plot "looks like crap".
970+ //
971+ // Investigation strategy:
972+ // 1. Replay wizard defaults verbatim.
973+ // 2. Walk the simulated operating point, isolate the secondary
974+ // voltage + current waveforms.
975+ // 3. Print samples / stats and check the shape against expected:
976+ // Secondary voltage: bipolar ±Vin/n square (≈±33.3 V),
977+ // zero during freewheel (1 - Deff) interval.
978+ // Secondary current: trapezoidal ramps matching primary/n,
979+ // approximately Io = 16.67 A peak.
980+ //
981+ // If V or I show NaN / Inf / mostly-zero / wrong-magnitude /
982+ // wrong-shape, we know which probe is wrong.
983+ // =========================================================================
984+ TEST_CASE (" Test_Pshb_FrontendDefaults_SecondaryWaveformShape" ,
985+ " [converter-model][pshb-topology][advanced][secondary-shape]" ) {
986+ using namespace OpenMagnetics ;
987+
988+ json advJson;
989+ {
990+ json inputVoltage;
991+ inputVoltage[" nominal" ] = 400.0 ;
992+ inputVoltage[" minimum" ] = 360.0 ;
993+ inputVoltage[" maximum" ] = 440.0 ;
994+ advJson[" inputVoltage" ] = inputVoltage;
995+ }
996+ advJson[" rectifierType" ] = " fullBridge" ;
997+ advJson[" useLeakageInductance" ] = true ;
998+ advJson[" maximumPhaseShift" ] = 144.0 ;
999+ // Half-bridge primary sees ±Vin/2 (DC-blocking cap mid-point sits at
1000+ // Vin/2). Correct turns ratio for Vout = 12 V is therefore
1001+ // (Vin/2)/Vout = 400/(2·12) = 16.67. PshbWizard.vue currently sends
1002+ // Vin/Vout = 33.33 (the FULL-bridge formula); with n=33.33 the
1003+ // reflected secondary peak is Vin/(2·n) = 6 V, *below* Vout = 12 V,
1004+ // so the secondary diodes never forward-bias and the simulated
1005+ // secondary current is ~0. This test uses the CORRECT half-bridge
1006+ // ratio to confirm the netlist itself is fine — the bug is purely
1007+ // in the wizard's buildParams formula.
1008+ // Replicate PshbWizard.vue.buildParams() in "Help me with the design"
1009+ // mode VERBATIM so this test will catch any future regression of the
1010+ // same shape. The fixed formula (post-bug) is `Vin / (4·Vout)` —
1011+ // half-bridge with ~50 % duty headroom. The buggy historical formula
1012+ // was `Vin / Vout`, which uses the full-bridge formula on a half-
1013+ // bridge primary and is the bug this test was created to catch.
1014+ const double n_from_wizard = 400.0 / (4.0 * 12.0 );
1015+ advJson[" desiredTurnsRatios" ] = { n_from_wizard };
1016+ advJson[" desiredMagnetizingInductance" ] = 1e-3 ;
1017+ advJson[" operatingPoints" ] = json::array ();
1018+ {
1019+ json op;
1020+ op[" ambientTemperature" ] = 25.0 ;
1021+ op[" outputVoltages" ] = {12.0 };
1022+ op[" outputCurrents" ] = {200.0 / 12.0 };
1023+ op[" switchingFrequency" ] = 100000.0 ;
1024+ op[" phaseShift" ] = 72.0 ;
1025+ advJson[" operatingPoints" ].push_back (op);
1026+ }
1027+
1028+ AdvancedPshb advPshb (advJson);
1029+ auto inputs = advPshb.process ();
1030+
1031+ std::vector<double > turnsRatios;
1032+ for (const auto & tr : inputs.get_design_requirements ().get_turns_ratios ()) {
1033+ if (tr.get_nominal ()) turnsRatios.push_back (tr.get_nominal ().value ());
1034+ }
1035+ double Lm = inputs.get_design_requirements ().get_magnetizing_inductance ()
1036+ .get_nominal ().value_or (1e-3 );
1037+
1038+ advPshb.set_num_periods_to_extract (2 );
1039+ advPshb.set_num_steady_state_periods (5 );
1040+
1041+ NgspiceRunner runner;
1042+ if (!runner.is_available ()) {
1043+ WARN (" ngspice not available — skipping shape check" );
1044+ return ;
1045+ }
1046+
1047+ auto operatingPoints = advPshb.simulate_and_extract_operating_points (turnsRatios, Lm);
1048+ REQUIRE (!operatingPoints.empty ());
1049+
1050+ auto dumpStats = [](const std::string& label,
1051+ const std::vector<double >& xs) {
1052+ double mn = std::numeric_limits<double >::infinity ();
1053+ double mx = -std::numeric_limits<double >::infinity ();
1054+ double sum = 0.0 ;
1055+ size_t nan = 0 , inf = 0 , nonzero = 0 ;
1056+ for (double v : xs) {
1057+ if (std::isnan (v)) { nan++; continue ; }
1058+ if (std::isinf (v)) { inf++; continue ; }
1059+ mn = std::min (mn, v);
1060+ mx = std::max (mx, v);
1061+ sum += v;
1062+ if (std::abs (v) > 1e-9 ) nonzero++;
1063+ }
1064+ double mean = xs.empty () ? 0.0 : sum / xs.size ();
1065+ std::cout << " " << std::left << std::setw (34 ) << label
1066+ << " N=" << std::setw (6 ) << xs.size ()
1067+ << " min=" << std::setw (14 ) << mn
1068+ << " max=" << std::setw (14 ) << mx
1069+ << " mean=" << std::setw (14 ) << mean
1070+ << " nonzero=" << nonzero
1071+ << " nan=" << nan << " inf=" << inf << std::endl;
1072+ };
1073+
1074+ auto dumpFirstAndLast = [](const std::string& label,
1075+ const std::vector<double >& xs, size_t k = 8 ) {
1076+ std::cout << " " << label << " first " << k << " :" ;
1077+ for (size_t i = 0 ; i < std::min (k, xs.size ()); ++i) std::cout << " " << xs[i];
1078+ std::cout << std::endl;
1079+ std::cout << " " << label << " last " << k << " :" ;
1080+ size_t start = (xs.size () > k) ? xs.size () - k : 0 ;
1081+ for (size_t i = start; i < xs.size (); ++i) std::cout << " " << xs[i];
1082+ std::cout << std::endl;
1083+ };
1084+
1085+ std::cout << std::scientific << std::setprecision (4 );
1086+ for (size_t opi = 0 ; opi < operatingPoints.size (); ++opi) {
1087+ const auto & op = operatingPoints[opi];
1088+ std::cout << " \n ========== OP " << opi << " : " << op.get_name ().value_or (" ?" ) << " ==========" << std::endl;
1089+
1090+ const auto & exc = op.get_excitations_per_winding ();
1091+ for (size_t w = 0 ; w < exc.size (); ++w) {
1092+ std::cout << " --- winding " << w << " ---" << std::endl;
1093+
1094+ if (exc[w].get_voltage ()) {
1095+ auto V = exc[w].get_voltage ().value ();
1096+ if (V.get_waveform ()) {
1097+ auto wf = V.get_waveform ().value ();
1098+ dumpStats (" Voltage data" , wf.get_data ());
1099+ if (wf.get_time ()) dumpStats (" Voltage time" , wf.get_time ().value ());
1100+ if (w == 1 ) dumpFirstAndLast (" Voltage data" , wf.get_data (), 12 );
1101+ } else {
1102+ std::cout << " Voltage waveform: <missing>" << std::endl;
1103+ }
1104+ } else {
1105+ std::cout << " Voltage: <none>" << std::endl;
1106+ }
1107+
1108+ if (exc[w].get_current ()) {
1109+ auto I = exc[w].get_current ().value ();
1110+ if (I.get_waveform ()) {
1111+ auto wf = I.get_waveform ().value ();
1112+ dumpStats (" Current data" , wf.get_data ());
1113+ if (wf.get_time ()) dumpStats (" Current time" , wf.get_time ().value ());
1114+ if (w == 1 ) dumpFirstAndLast (" Current data" , wf.get_data (), 12 );
1115+ } else {
1116+ std::cout << " Current waveform: <missing>" << std::endl;
1117+ }
1118+ } else {
1119+ std::cout << " Current: <none>" << std::endl;
1120+ }
1121+ }
1122+ }
1123+ std::cout.unsetf (std::ios::scientific);
1124+
1125+ // Sanity gates on the secondary waveform at the wizard's default
1126+ // scenario. These are the cheapest assertions that would have caught
1127+ // the original "looks like crap" bug at HEAD time:
1128+ //
1129+ // (1) Secondary voltage |peak| must EXCEED the rectifier-output
1130+ // voltage by enough headroom for diode forward-bias. We chose
1131+ // 1.3·Vout as the lower gate (200 V / 8.3 ≈ 24 V vs Vout = 12 V,
1132+ // so plenty of slack — but 200 V / 33.3 ≈ 6 V (the bug) would
1133+ // fall below 1.3·12 = 15.6 V and trip this gate immediately).
1134+ // (2) Secondary current |peak| must be a non-trivial fraction of
1135+ // Iout. With the wizard's bug, current was 0.37 A on a 16.7 A
1136+ // nominal — 2 % of design. A 20 % floor (3.3 A here) is well
1137+ // above the 2 % bug and well below the ~30 % steady-state value
1138+ // we see post-fix, leaving healthy margin in both directions.
1139+ const double Vout_nominal = 12.0 ;
1140+ const double Iout_nominal = 200.0 / 12.0 ;
1141+ for (size_t opi = 0 ; opi < operatingPoints.size (); ++opi) {
1142+ const auto & exc = operatingPoints[opi].get_excitations_per_winding ();
1143+ REQUIRE (exc.size () >= 2 );
1144+
1145+ if (exc[1 ].get_voltage () && exc[1 ].get_voltage ()->get_waveform ()) {
1146+ auto wf = exc[1 ].get_voltage ()->get_waveform ().value ();
1147+ double absMax = 0.0 ;
1148+ for (double v : wf.get_data ()) absMax = std::max (absMax, std::abs (v));
1149+ INFO (" OP " << opi << " secondary |V|max = " << absMax
1150+ << " vs gate 1.3*Vout = " << (1.3 * Vout_nominal));
1151+ CHECK (absMax > 1.3 * Vout_nominal);
1152+ CHECK (absMax < 100.0 );
1153+ }
1154+
1155+ if (exc[1 ].get_current () && exc[1 ].get_current ()->get_waveform ()) {
1156+ auto wf = exc[1 ].get_current ()->get_waveform ().value ();
1157+ double absMax = 0.0 ;
1158+ for (double v : wf.get_data ()) absMax = std::max (absMax, std::abs (v));
1159+ INFO (" OP " << opi << " secondary |I|max = " << absMax
1160+ << " vs gate 0.2*Iout = " << (0.2 * Iout_nominal));
1161+ CHECK (absMax > 0.2 * Iout_nominal);
1162+ }
1163+ }
1164+ }
1165+
1166+
9671167} // anonymous namespace
0 commit comments