Merge branch 'dev/prot_pfc'
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
import csv
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common.config_loader import load_jig_registers
|
||||
from common.error_codes import RpuErrorCode, TpuErrorCode
|
||||
from drivers.eload import Eload
|
||||
from drivers.jig import Jig, Position
|
||||
from drivers.rpu import Rpu
|
||||
from drivers.tpu import Tpu
|
||||
|
||||
RESULTS_DIR = Path(__file__).resolve().parent.parent / "results"
|
||||
|
||||
# Vac_in (AC source input) is set manually by the test operator before running
|
||||
# this file - not controlled by the script.
|
||||
POSITION = Position(0, 0, 40)
|
||||
VELOAD = 48.0
|
||||
IOUT_REF = 30.0
|
||||
|
||||
DEFAULT_PFC_CURRENT_MAX = 30.0
|
||||
DEFAULT_PFC_VDC_BUS_MIN = 350.0
|
||||
DEFAULT_PFC_VDC_BUS_MAX = 425.0
|
||||
|
||||
FAULT_PFC_CURRENT_MAX = 8.0
|
||||
FAULT_PFC_VDC_BUS_MAX = 395.0
|
||||
FAULT_PFC_VDC_BUS_MIN = 445.0
|
||||
AC_VOLTAGE_CALI_INCREASE_PCT = 0.10
|
||||
|
||||
EXPECTED_TPU_OUT_OVERCURRENT = TpuErrorCode.PFC_IL_OVER_CURRENT
|
||||
EXPECTED_RPU_OUT_OVERCURRENT = RpuErrorCode.TPU_PFC_FAILURE
|
||||
|
||||
EXPECTED_TPU_DC_OVERVOLTAGE = TpuErrorCode.PFC_DC_OVER_VOLTAGE
|
||||
EXPECTED_RPU_DC_OVERVOLTAGE = RpuErrorCode.BLE_UART_LOSS
|
||||
|
||||
EXPECTED_TPU_DC_UNDERVOLTAGE = TpuErrorCode.PFC_DC_UNDER_VOLTAGE
|
||||
EXPECTED_RPU_DC_UNDERVOLTAGE = 0
|
||||
|
||||
EXPECTED_TPU_START_TIMEOUT = TpuErrorCode.PFC_START_TIMEOUT
|
||||
EXPECTED_RPU_START_TIMEOUT = 0
|
||||
|
||||
START_CHARGING_WAIT_S = 1
|
||||
CHARGING_RAMP_WAIT_S = 20 # wait for Iout to ramp up to the 30A target before recording
|
||||
STOP_CHARGING_SETTLE_WAIT_S = 3
|
||||
CONFIG_SETTLE_WAIT_S = 5 # settle wait for cases that don't need charging enabled
|
||||
|
||||
STABILITY_SAMPLE_DURATION_S = 5.0
|
||||
STABILITY_SAMPLE_INTERVAL_S = 0.2
|
||||
STABILITY_FIELDS = ["PFC_Vout", "PFC_iL_peak"]
|
||||
|
||||
|
||||
def _hex32(value):
|
||||
return f"0x{value:08X}"
|
||||
|
||||
|
||||
def _hex16(value):
|
||||
return f"0x{value:04X}"
|
||||
|
||||
|
||||
def _error_ok(actual, actual_shadow, expected):
|
||||
return actual == expected or actual_shadow == expected
|
||||
|
||||
|
||||
@dataclass
|
||||
class PfcBaseline:
|
||||
"""Shared connections + helpers for every PROT-PFC test case in this file."""
|
||||
jig: Jig
|
||||
tpu: Tpu
|
||||
rpu: Rpu
|
||||
eload: Eload
|
||||
writer: object
|
||||
csv_file: object
|
||||
|
||||
def sample_min_max(self, labels, duration=STABILITY_SAMPLE_DURATION_S, interval=STABILITY_SAMPLE_INTERVAL_S):
|
||||
"""Poll `labels` every `interval` seconds for `duration` seconds and
|
||||
return {label_min, label_max} - same pattern as vac_coil_estimation.py's
|
||||
sample_stability()."""
|
||||
samples = {label: [] for label in labels}
|
||||
start = time.time()
|
||||
while time.time() - start < duration:
|
||||
for label in labels:
|
||||
samples[label].append(self.tpu.read(label))
|
||||
time.sleep(interval)
|
||||
|
||||
stats = {}
|
||||
for label, vals in samples.items():
|
||||
stats[f"{label}_min"] = min(vals)
|
||||
stats[f"{label}_max"] = max(vals)
|
||||
return stats
|
||||
|
||||
def log_row(self, phase, setting_label, setting_value, stability_fields=STABILITY_FIELDS, pass_fail=""):
|
||||
# The 5s sampling window IS the "record" step for this row. Only the
|
||||
# fields relevant to this test case are sampled - others stay blank.
|
||||
stats = self.sample_min_max(stability_fields)
|
||||
self.writer.writerow([
|
||||
phase, setting_label, setting_value,
|
||||
stats.get("PFC_Vout_min", ""), stats.get("PFC_Vout_max", ""),
|
||||
stats.get("PFC_iL_peak_min", ""), stats.get("PFC_iL_peak_max", ""),
|
||||
self.tpu.read("RPU_output_voltage"), self.tpu.read("RPU_output_current"),
|
||||
self.tpu.read("PFC_state"), self.tpu.read("Main_state"), self.tpu.read("Main_pre_state"),
|
||||
_hex32(self.tpu.read_error_code()), _hex32(self.tpu.read_shadow_error_code()),
|
||||
_hex16(self.tpu.read("Issue code")),
|
||||
_hex32(self.rpu.read_error_code()), _hex32(self.rpu.read_shadow_error_code()),
|
||||
pass_fail,
|
||||
])
|
||||
self.csv_file.flush()
|
||||
|
||||
def start_charging(self):
|
||||
self.rpu.set_charging(True)
|
||||
time.sleep(START_CHARGING_WAIT_S)
|
||||
self.rpu.set_runtime_voltage(VELOAD)
|
||||
self.rpu.set_runtime_current(IOUT_REF)
|
||||
time.sleep(CHARGING_RAMP_WAIT_S)
|
||||
|
||||
def stop_charging_and_wait_config_writable(self):
|
||||
# PFC_Current_max (and other "Debug" dataset config) is only committed
|
||||
# by the firmware while TPU is NOT actively charging (TPU_RUN_INV is
|
||||
# excluded from the config-writable states).
|
||||
self.rpu.set_charging(False)
|
||||
time.sleep(STOP_CHARGING_SETTLE_WAIT_S)
|
||||
self.tpu.wait_until_config_writable()
|
||||
|
||||
def restore_config(self, label, value):
|
||||
self.stop_charging_and_wait_config_writable()
|
||||
self.tpu.write(label, value)
|
||||
self.tpu.rewrite_config()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pfc_baseline(jig_port, tpu_port, rpu_port, eload_resource):
|
||||
jig_registers = load_jig_registers()
|
||||
jig = Jig(jig_port, jig_registers)
|
||||
tpu = Tpu(tpu_port)
|
||||
rpu = Rpu(rpu_port)
|
||||
eload = Eload(eload_resource)
|
||||
|
||||
RESULTS_DIR.mkdir(exist_ok=True)
|
||||
csv_path = RESULTS_DIR / f"prot_pfc_{time.strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
csv_file = open(csv_path, "w", newline="", encoding="utf-8")
|
||||
writer = csv.writer(csv_file)
|
||||
writer.writerow([
|
||||
"phase", "setting_label", "setting_value", "pfc_vout_min", "pfc_vout_max",
|
||||
"pfc_il_peak_min", "pfc_il_peak_max", "rpu_output_voltage", "rpu_output_current",
|
||||
"pfc_state", "main_state", "main_pre_state", "tpu_error_code", "tpu_shadow_error_code",
|
||||
"tpu_issue_code", "rpu_error_code", "rpu_shadow_error_code", "pass_fail",
|
||||
])
|
||||
|
||||
baseline = PfcBaseline(jig=jig, tpu=tpu, rpu=rpu, eload=eload, writer=writer, csv_file=csv_file)
|
||||
|
||||
try:
|
||||
rpu.set_charging(False)
|
||||
jig.move_to_position(POSITION)
|
||||
|
||||
eload.set_remote()
|
||||
eload.set_mode_cv()
|
||||
eload.set_voltage(VELOAD)
|
||||
eload.output_on()
|
||||
|
||||
pfc_current_max = tpu.read("PFC_Current_max")
|
||||
pfc_vdc_bus_min = tpu.read("PFC_Vdc_bus_min")
|
||||
pfc_vdc_bus_max = tpu.read("PFC_Vdc_bus_max")
|
||||
assert pfc_current_max == pytest.approx(DEFAULT_PFC_CURRENT_MAX, abs=0.5), \
|
||||
f"PFC_Current_max not at default: {pfc_current_max}"
|
||||
assert pfc_vdc_bus_min == pytest.approx(DEFAULT_PFC_VDC_BUS_MIN, abs=1), \
|
||||
f"PFC_Vdc_bus_min not at default: {pfc_vdc_bus_min}"
|
||||
assert pfc_vdc_bus_max == pytest.approx(DEFAULT_PFC_VDC_BUS_MAX, abs=1), \
|
||||
f"PFC_Vdc_bus_max not at default: {pfc_vdc_bus_max}"
|
||||
|
||||
# Enable charging, wait for Iout to ramp up to 30A, then record for 5s
|
||||
# (both PFC_Vout and PFC_iL_peak are relevant as a healthy reference).
|
||||
baseline.start_charging()
|
||||
baseline.log_row("baseline", "PFC_Imax", pfc_current_max)
|
||||
|
||||
yield baseline
|
||||
|
||||
finally:
|
||||
rpu.set_charging(False)
|
||||
eload.output_off()
|
||||
eload.set_local()
|
||||
csv_file.close()
|
||||
jig.close()
|
||||
tpu.close()
|
||||
rpu.close()
|
||||
eload.disconnect()
|
||||
|
||||
|
||||
def test_pfc_out_overcurrent(pfc_baseline):
|
||||
tpu = pfc_baseline.tpu
|
||||
rpu = pfc_baseline.rpu
|
||||
|
||||
try:
|
||||
pfc_baseline.stop_charging_and_wait_config_writable()
|
||||
tpu.write("PFC_Current_max", FAULT_PFC_CURRENT_MAX)
|
||||
tpu.rewrite_config()
|
||||
|
||||
# This case needs charging active to push real output current above
|
||||
# the lowered threshold - wait for ramp-up same as baseline.
|
||||
pfc_baseline.start_charging()
|
||||
|
||||
tpu_error = tpu.read_error_code()
|
||||
tpu_shadow_error = tpu.read_shadow_error_code()
|
||||
rpu_error = rpu.read_error_code()
|
||||
rpu_shadow_error = rpu.read_shadow_error_code()
|
||||
|
||||
tpu_error_ok = _error_ok(tpu_error, tpu_shadow_error, EXPECTED_TPU_OUT_OVERCURRENT)
|
||||
rpu_error_ok = _error_ok(rpu_error, rpu_shadow_error, EXPECTED_RPU_OUT_OVERCURRENT)
|
||||
pass_fail = "PASS" if (tpu_error_ok and rpu_error_ok) else "FAIL"
|
||||
|
||||
# Current-protection case - only PFC_iL_peak is relevant, PFC_Vout stays blank.
|
||||
pfc_baseline.log_row("pfc_out_overcurrent", "PFC_Imax", FAULT_PFC_CURRENT_MAX,
|
||||
stability_fields=["PFC_iL_peak"], pass_fail=pass_fail)
|
||||
|
||||
assert tpu_error_ok, (
|
||||
f"TPU error code mismatch: got {_hex32(tpu_error)}/{_hex32(tpu_shadow_error)}, "
|
||||
f"expected {_hex32(EXPECTED_TPU_OUT_OVERCURRENT)}"
|
||||
)
|
||||
assert rpu_error_ok, (
|
||||
f"RPU error code mismatch: got {_hex32(rpu_error)}/{_hex32(rpu_shadow_error)}, "
|
||||
f"expected {_hex32(EXPECTED_RPU_OUT_OVERCURRENT)}"
|
||||
)
|
||||
|
||||
finally:
|
||||
pfc_baseline.restore_config("PFC_Current_max", DEFAULT_PFC_CURRENT_MAX)
|
||||
|
||||
|
||||
def test_pfc_dc_overvoltage(pfc_baseline):
|
||||
tpu = pfc_baseline.tpu
|
||||
rpu = pfc_baseline.rpu
|
||||
|
||||
try:
|
||||
pfc_baseline.stop_charging_and_wait_config_writable()
|
||||
tpu.write("PFC_Vdc_bus_max", FAULT_PFC_VDC_BUS_MAX)
|
||||
tpu.rewrite_config()
|
||||
|
||||
# No charging needed - the DC bus is already regulated by PFC before
|
||||
# RPU enables charging, just settle then sample.
|
||||
time.sleep(CONFIG_SETTLE_WAIT_S)
|
||||
|
||||
tpu_error = tpu.read_error_code()
|
||||
tpu_shadow_error = tpu.read_shadow_error_code()
|
||||
rpu_error = rpu.read_error_code()
|
||||
rpu_shadow_error = rpu.read_shadow_error_code()
|
||||
|
||||
tpu_error_ok = _error_ok(tpu_error, tpu_shadow_error, EXPECTED_TPU_DC_OVERVOLTAGE)
|
||||
rpu_error_ok = _error_ok(rpu_error, rpu_shadow_error, EXPECTED_RPU_DC_OVERVOLTAGE)
|
||||
pass_fail = "PASS" if (tpu_error_ok and rpu_error_ok) else "FAIL"
|
||||
|
||||
pfc_baseline.log_row("pfc_dc_overvoltage", "Vdc_bus_max", FAULT_PFC_VDC_BUS_MAX,
|
||||
stability_fields=["PFC_Vout"], pass_fail=pass_fail)
|
||||
|
||||
assert tpu_error_ok, (
|
||||
f"TPU error code mismatch: got {_hex32(tpu_error)}/{_hex32(tpu_shadow_error)}, "
|
||||
f"expected {_hex32(EXPECTED_TPU_DC_OVERVOLTAGE)}"
|
||||
)
|
||||
assert rpu_error_ok, (
|
||||
f"RPU error code mismatch: got {_hex32(rpu_error)}/{_hex32(rpu_shadow_error)}, "
|
||||
f"expected {_hex32(EXPECTED_RPU_DC_OVERVOLTAGE)}"
|
||||
)
|
||||
|
||||
finally:
|
||||
pfc_baseline.restore_config("PFC_Vdc_bus_max", DEFAULT_PFC_VDC_BUS_MAX)
|
||||
|
||||
|
||||
def test_pfc_dc_undervoltage(pfc_baseline):
|
||||
tpu = pfc_baseline.tpu
|
||||
rpu = pfc_baseline.rpu
|
||||
|
||||
try:
|
||||
pfc_baseline.stop_charging_and_wait_config_writable()
|
||||
tpu.write("PFC_Vdc_bus_min", FAULT_PFC_VDC_BUS_MIN)
|
||||
tpu.rewrite_config()
|
||||
|
||||
time.sleep(CONFIG_SETTLE_WAIT_S)
|
||||
|
||||
tpu_error = tpu.read_error_code()
|
||||
tpu_shadow_error = tpu.read_shadow_error_code()
|
||||
rpu_error = rpu.read_error_code()
|
||||
rpu_shadow_error = rpu.read_shadow_error_code()
|
||||
|
||||
tpu_error_ok = _error_ok(tpu_error, tpu_shadow_error, EXPECTED_TPU_DC_UNDERVOLTAGE)
|
||||
rpu_error_ok = _error_ok(rpu_error, rpu_shadow_error, EXPECTED_RPU_DC_UNDERVOLTAGE)
|
||||
pass_fail = "PASS" if (tpu_error_ok and rpu_error_ok) else "FAIL"
|
||||
|
||||
pfc_baseline.log_row("pfc_dc_undervoltage", "Vdc_bus_min", FAULT_PFC_VDC_BUS_MIN,
|
||||
stability_fields=["PFC_Vout"], pass_fail=pass_fail)
|
||||
|
||||
assert tpu_error_ok, (
|
||||
f"TPU error code mismatch: got {_hex32(tpu_error)}/{_hex32(tpu_shadow_error)}, "
|
||||
f"expected {_hex32(EXPECTED_TPU_DC_UNDERVOLTAGE)}"
|
||||
)
|
||||
assert rpu_error_ok, (
|
||||
f"RPU error code mismatch: got {_hex32(rpu_error)}/{_hex32(rpu_shadow_error)}, "
|
||||
f"expected {_hex32(EXPECTED_RPU_DC_UNDERVOLTAGE)}"
|
||||
)
|
||||
|
||||
finally:
|
||||
pfc_baseline.restore_config("PFC_Vdc_bus_min", DEFAULT_PFC_VDC_BUS_MIN)
|
||||
|
||||
|
||||
def test_pfc_start_timeout(pfc_baseline):
|
||||
tpu = pfc_baseline.tpu
|
||||
rpu = pfc_baseline.rpu
|
||||
|
||||
# PFC_AC_Voltage_Cali_A default varies per unit - read the current value
|
||||
# first (before the try block) so `finally` can always restore it exactly,
|
||||
# even if something below fails before the fault value is written.
|
||||
pfc_baseline.stop_charging_and_wait_config_writable()
|
||||
default_cali_a = tpu.read("PFC_AC_Voltage_Cali_A")
|
||||
|
||||
try:
|
||||
fault_cali_a = default_cali_a * (1 + AC_VOLTAGE_CALI_INCREASE_PCT)
|
||||
tpu.write("PFC_AC_Voltage_Cali_A", fault_cali_a)
|
||||
tpu.rewrite_config()
|
||||
|
||||
time.sleep(CONFIG_SETTLE_WAIT_S)
|
||||
|
||||
tpu_error = tpu.read_error_code()
|
||||
tpu_shadow_error = tpu.read_shadow_error_code()
|
||||
rpu_error = rpu.read_error_code()
|
||||
rpu_shadow_error = rpu.read_shadow_error_code()
|
||||
|
||||
tpu_error_ok = _error_ok(tpu_error, tpu_shadow_error, EXPECTED_TPU_START_TIMEOUT)
|
||||
rpu_error_ok = _error_ok(rpu_error, rpu_shadow_error, EXPECTED_RPU_START_TIMEOUT)
|
||||
pass_fail = "PASS" if (tpu_error_ok and rpu_error_ok) else "FAIL"
|
||||
|
||||
pfc_baseline.log_row("pfc_start_timeout", "PFC_AC_Voltage_Cali_A", fault_cali_a,
|
||||
stability_fields=["PFC_Vout"], pass_fail=pass_fail)
|
||||
|
||||
assert tpu_error_ok, (
|
||||
f"TPU error code mismatch: got {_hex32(tpu_error)}/{_hex32(tpu_shadow_error)}, "
|
||||
f"expected {_hex32(EXPECTED_TPU_START_TIMEOUT)}"
|
||||
)
|
||||
assert rpu_error_ok, (
|
||||
f"RPU error code mismatch: got {_hex32(rpu_error)}/{_hex32(rpu_shadow_error)}, "
|
||||
f"expected {_hex32(EXPECTED_RPU_START_TIMEOUT)}"
|
||||
)
|
||||
|
||||
finally:
|
||||
pfc_baseline.restore_config("PFC_AC_Voltage_Cali_A", default_cali_a)
|
||||
Reference in New Issue
Block a user