Add shade profiles, 2D V×I sweep, meter format toggle, ON/OFF indicators, and GUI console

- Shade profile: CSV-driven irradiance/voltage sequences with load control
  (bench.run_shade_profile, CLI shade-profile command, GUI profile panel)
- 2D sweep: voltage × load current efficiency map with live graph updates
  (bench.sweep_vi, CLI sweep-vi command, GUI sweep panel with background thread)
- GUI: meter format selector (scientific/normal), supply/load ON/OFF indicators,
  console log with stdout redirect and color-coded messages
- Sample profiles: cloud_pass, partial_shade, intermittent_clouds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:27:48 +07:00
parent 74917e05f2
commit 956be4b77a
8 changed files with 953 additions and 14 deletions
+245
View File
@@ -417,6 +417,251 @@ class MPPTTestbench:
return results
# ── Load value helper ─────────────────────────────────────────────
def _apply_load_value(self, mode: str, value: float) -> None:
"""Set the load setpoint for the given mode."""
if mode == "CC":
self.load.set_cc_current(value)
elif mode == "CR":
self.load.set_cr_resistance(value)
elif mode == "CV":
self.load.set_cv_voltage(value)
elif mode == "CP":
self.load.set_cp_power(value)
# ── Shade Profile ────────────────────────────────────────────────
@staticmethod
def load_shade_profile(path: str) -> list[dict]:
"""Load a shade profile CSV.
Required columns: time, voltage, current_limit
Optional columns: load_mode, load_value
Returns:
Sorted list of step dicts.
"""
import csv as _csv
steps = []
with open(path, newline="") as f:
reader = _csv.DictReader(f)
for row in reader:
step = {
"time": float(row["time"]),
"voltage": float(row["voltage"]),
"current_limit": float(row["current_limit"]),
}
if "load_mode" in row and row["load_mode"].strip():
step["load_mode"] = row["load_mode"].strip().upper()
if "load_value" in row and row["load_value"].strip():
step["load_value"] = float(row["load_value"])
steps.append(step)
steps.sort(key=lambda s: s["time"])
return steps
def run_shade_profile(
self,
steps: list[dict],
settle_time: float = 2.0,
) -> list[SweepPoint]:
"""Run a shade / irradiance profile sequence.
Steps through the profile, applying voltage/current/load settings
at the scheduled times, recording a measurement at each step.
Args:
steps: Profile steps from :meth:`load_shade_profile`.
settle_time: Seconds to wait after applying each step before
recording a measurement.
Returns:
List of SweepPoint measurements, one per step.
"""
if not steps:
return []
# Apply first step and turn outputs on
first = steps[0]
self.supply.set_current(first["current_limit"])
self.supply.set_voltage(first["voltage"])
self.supply.output_on()
if "load_mode" in first:
self.load.set_mode(first["load_mode"])
if "load_value" in first:
self._apply_load_value(
first.get("load_mode", "CC"), first["load_value"]
)
self.load.load_on()
results: list[SweepPoint] = []
t0 = time.time()
try:
for i, step in enumerate(steps):
# Wait until scheduled time, keepalive supply while waiting
target = t0 + step["time"]
while True:
remaining = target - time.time()
if remaining <= 0:
break
if remaining > 2.0:
try:
self.supply.measure_voltage()
except Exception:
pass
time.sleep(min(2.0, remaining))
else:
time.sleep(remaining)
# Apply settings
self.supply.set_current(step["current_limit"])
self.supply.set_voltage(step["voltage"])
if "load_mode" in step:
self.load.set_mode(step["load_mode"])
if "load_value" in step:
self._apply_load_value(
step.get("load_mode", "CC"), step["load_value"]
)
time.sleep(settle_time)
# Record
load_val = step.get("load_value", 0.0) or 0.0
point = self._record_point(
step["voltage"], step["current_limit"], load_val
)
results.append(point)
elapsed = time.time() - t0
print(
f" [{i + 1}/{len(steps)}] t={elapsed:6.1f}s "
f"V={step['voltage']:.1f}V I_lim={step['current_limit']:.1f}A "
f"P_in={point.input_power:8.2f}W "
f"P_out={point.output_power:8.2f}W "
f"EFF={point.efficiency:6.2f}%"
)
finally:
self.load.load_off()
self.supply.set_voltage(IDLE_VOLTAGE)
print(
f"\n Profile complete. Load OFF. "
f"Supply returning to {IDLE_VOLTAGE:.0f}V (output stays ON)"
)
return results
# ── 2D Voltage × Current Sweep ─────────────────────────────────────
def sweep_vi(
self,
v_start: float,
v_stop: float,
v_step: float,
i_start: float,
i_stop: float,
i_step: float,
current_limit: float,
settle_time: float = 2.0,
) -> list[SweepPoint]:
"""2D sweep: voltage (outer) × load current (inner).
At each supply voltage, sweeps the load current through the full
range, recording efficiency at every (V, I) combination. Produces
a complete efficiency map of the DUT.
After the sweep the load turns OFF and the supply returns to
IDLE_VOLTAGE (75 V, output stays ON).
Args:
v_start: Starting supply voltage (V).
v_stop: Final supply voltage (V).
v_step: Voltage step size (V). Sign is auto-corrected.
i_start: Starting load current (A).
i_stop: Final load current (A).
i_step: Current step size (A). Sign is auto-corrected.
current_limit: Supply current limit (A) for the entire sweep.
settle_time: Seconds to wait after each setpoint change.
"""
if v_step == 0:
raise ValueError("v_step cannot be zero")
if i_step == 0:
raise ValueError("i_step cannot be zero")
# Auto-correct step directions
if v_start < v_stop and v_step < 0:
v_step = -v_step
elif v_start > v_stop and v_step > 0:
v_step = -v_step
if i_start < i_stop and i_step < 0:
i_step = -i_step
elif i_start > i_stop and i_step > 0:
i_step = -i_step
# Count steps for progress display
v_count = int(abs(v_stop - v_start) / abs(v_step)) + 1
i_count = int(abs(i_stop - i_start) / abs(i_step)) + 1
total = v_count * i_count
print(f" Grid: {v_count} voltage × {i_count} current = {total} points")
self.supply.set_current(current_limit)
self.supply.output_on()
self.load.set_mode("CC")
self.load.set_cc_current(i_start)
self.load.load_on()
results: list[SweepPoint] = []
n = 0
v = v_start
try:
while True:
if v_step > 0 and v > v_stop + v_step / 2:
break
if v_step < 0 and v < v_stop + v_step / 2:
break
self.supply.set_voltage(v)
i = i_start
while True:
if i_step > 0 and i > i_stop + i_step / 2:
break
if i_step < 0 and i < i_stop + i_step / 2:
break
self.load.set_cc_current(i)
time.sleep(settle_time)
point = self._record_point(v, current_limit, load_setpoint=i)
results.append(point)
n += 1
print(
f" [{n:>4d}/{total}] "
f"V={v:6.1f}V I_load={i:6.2f}A "
f"P_in={point.input_power:8.2f}W "
f"P_out={point.output_power:8.2f}W "
f"EFF={point.efficiency:6.2f}%"
)
i += i_step
v += v_step
finally:
self.load.load_off()
self.supply.set_voltage(IDLE_VOLTAGE)
print(
f"\n Load OFF. Supply returning to {IDLE_VOLTAGE:.0f}V "
f"(output stays ON)"
)
return results
# ── Efficiency at fixed operating point ───────────────────────────
def measure_efficiency(