"""Voltage-tolerance tests: drive the PSU and observe the LIN bus. WHAT THIS FILE COVERS --------------------- Voltage-tolerance, brown-out, over-voltage, and "supply transient" behaviour. Tests perturb the bench supply (Owon PSU) and observe the ECU's reaction on the LIN bus, using the SETUP / PROCEDURE / ASSERT / TEARDOWN pattern so each case stays independent of the others even when it raises mid-flight. PATTERN — settle-then-validate ------------------------------ The Owon PSU does NOT slew instantaneously, and the slew time depends on the bench (PSU model, load, cable drop). The test_psu_voltage_settling characterization showed e.g. up-step ≠ down-step time. Instead of guessing a fixed sleep, every voltage change in this file goes through :func:`apply_voltage_and_settle` from ``psu_helpers``, which: 1. Issues the setpoint. 2. **Polls** ``measure_voltage_v()`` until the rail is actually at the target (within tolerance, or raises on timeout). 3. Holds for ``ECU_VALIDATION_TIME_S`` so the firmware-side voltage monitor can detect and republish status. After that, a **single read** of ``ALM_Status.ALMVoltageStatus`` gives an unambiguous answer — no polling-on-the-bus race. THREE FLAVORS ------------- A) ``test_template_overvoltage_status`` — over-voltage detection. B) ``test_template_undervoltage_status`` — under-voltage detection. C) ``test_template_voltage_status_parametrized`` — sweep. SAFETY — three layers keep the bench safe ----------------------------------------- 1. The session-scoped ``psu`` fixture (in ``tests/hardware/conftest.py``) parks the supply at nominal voltage with output ON at session start, and closes with ``output 0`` at session end (``safe_off_on_close=True``). 2. The autouse ``_park_at_nominal`` fixture in this file restores nominal voltage before AND after every test in this module (using the same settle helper, so the next test starts steady). 3. Every test wraps its voltage change in ``try``/``finally`` so an assertion failure cannot leave the bench at an over/undervoltage rail. NEVER call ``psu.set_output(False)`` or ``psu.close()`` from a test — the Owon PSU powers the ECU on this bench, so toggling output kills LIN communication for every test that follows in the same session. The session fixture owns the PSU lifecycle. """ from __future__ import annotations import pytest from ecu_framework.config import EcuTestConfig from ecu_framework.lin.base import LinInterface from ecu_framework.power import OwonPSU from frame_io import FrameIO from alm_helpers import AlmTester from psu_helpers import apply_voltage_and_settle, downsample_trace # ╔══════════════════════════════════════════════════════════════════════╗ # ║ MODULE MARKERS ║ # ╚══════════════════════════════════════════════════════════════════════╝ # ``hardware`` excludes from default mock-only runs; ``mum`` selects the # Melexis Universal Master adapter for the LIN side. pytestmark = [pytest.mark.hardware, pytest.mark.mum] # ╔══════════════════════════════════════════════════════════════════════╗ # ║ CONSTANTS ║ # ╚══════════════════════════════════════════════════════════════════════╝ # # ALM_Status.ALMVoltageStatus values, taken verbatim from the LDF's # Signal_encoding_types: VoltageStatus block. Hard-coding them as named # constants makes the assertions self-explanatory and gives readers # something to grep for. VOLTAGE_STATUS_NORMAL = 0x00 # 'Normal Voltage' VOLTAGE_STATUS_UNDER = 0x01 # 'Power UnderVoltage' VOLTAGE_STATUS_OVER = 0x02 # 'Power OverVoltage' # Bench voltage profile. **TUNE THESE TO YOUR ECU'S DATASHEET** before # running the test on real hardware. Values shown are conservative # automotive ranges; many ECUs trip earlier. NOMINAL_VOLTAGE = 13.0 # V — typical 12 V automotive nominal OVERVOLTAGE_V = 19.0 # V — comfortably above the OV threshold UNDERVOLTAGE_V = 7.0 # V — below most brown-out points # Time we hold the rail steady AFTER the PSU has reached the target, # before reading ``ALMVoltageStatus``. This is the firmware-dependent # budget — the ECU's voltage monitor needs to sample, debounce, and # republish on its 10 ms LIN cycle. **Tune to your firmware spec.** # 1.0 s is a conservative starting point. ECU_VALIDATION_TIME_S = 1.0 # ╔══════════════════════════════════════════════════════════════════════╗ # ║ FIXTURES ║ # ╚══════════════════════════════════════════════════════════════════════╝ # # ``psu`` is provided by ``tests/hardware/conftest.py`` at SESSION # scope (autouse) — the bench is powered up once at session start and # stays on. Tests in this file just READ the psu fixture and perturb # voltage; they MUST NOT close it or toggle output. # # ``fio`` and ``alm`` are module-scoped here. As soon as a third test # file needs them, move both to ``tests/hardware/conftest.py``. @pytest.fixture(scope="module") def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO: """Generic LDF-driven LIN I/O for any frame in the project's LDF.""" if config.interface.type != "mum": pytest.skip("interface.type must be 'mum' for this suite") return FrameIO(lin, ldf) @pytest.fixture(scope="module") def alm(fio: FrameIO) -> AlmTester: """ALM_Node domain helper bound to the live NAD reported by ALM_Status.""" decoded = fio.receive("ALM_Status", timeout=1.0) if decoded is None: pytest.skip("ECU not responding on ALM_Status — check wiring/power") nad = int(decoded["ALMNadNo"]) if not (0x01 <= nad <= 0xFE): pytest.skip(f"ECU reports invalid NAD {nad:#x} — auto-addressing first") return AlmTester(fio, nad) @pytest.fixture(autouse=True) def _park_at_nominal(psu: OwonPSU, alm: AlmTester): """Per-test baseline: PSU voltage at NOMINAL_VOLTAGE + LED off. Uses :func:`apply_voltage_and_settle` so the rail is *measurably* at nominal before the test body runs — and afterwards, even on assertion failure. Validation time is short here: we just need the rail steady, not the ECU to react to it (the test body will do its own settle+validation in the PROCEDURE). """ # SETUP — nominal voltage, then LED off apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2) alm.force_off() yield # TEARDOWN — back to nominal even on test failure apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2) alm.force_off() # ╔══════════════════════════════════════════════════════════════════════╗ # ║ TEST FLAVOR A — overvoltage detection ║ # ╚══════════════════════════════════════════════════════════════════════╝ def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp): """ Title: ECU reports OverVoltage when supply exceeds the threshold Description: Drive the PSU above the firmware's overvoltage threshold, wait for the rail to actually be there, give the ECU ``ECU_VALIDATION_TIME_S`` to detect and republish, then read ``ALM_Status.ALMVoltageStatus`` once and assert it equals ``VOLTAGE_STATUS_OVER`` (0x02). Requirements: REQ-OVP-001 Test Steps: 1. SETUP: confirm baseline ALMVoltageStatus == Normal (the autouse fixture parked us at nominal and waited for the rail to settle) 2. PROCEDURE: apply OVERVOLTAGE_V, wait until measured rail reaches it, hold ECU_VALIDATION_TIME_S 3. ASSERT: single read of ALMVoltageStatus == OverVoltage 4. TEARDOWN: restore NOMINAL_VOLTAGE via the same helper, then verify the ECU returns to Normal Expected Result: - Baseline status is Normal - After settle + validation hold at OVERVOLTAGE_V, ALMVoltageStatus reads OverVoltage - After settle + validation hold at NOMINAL_VOLTAGE again, ALMVoltageStatus reads Normal """ # ── SETUP ───────────────────────────────────────────────────────── # Sanity-check the baseline. If the ECU isn't reporting Normal at # nominal supply, our test premise is broken — fail fast rather # than hunt the wrong issue later. baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1) rp("baseline_voltage_status", int(baseline)) assert int(baseline) == VOLTAGE_STATUS_NORMAL, ( f"Expected Normal at nominal supply but got {baseline!r}; " f"check PSU output and ECU power rail before continuing." ) try: # ── PROCEDURE ───────────────────────────────────────────────── # Apply the OV setpoint and wait for the rail to actually be # there, then hold for ECU_VALIDATION_TIME_S so the firmware # can sample, debounce, and republish ALM_Status. result = apply_voltage_and_settle( psu, OVERVOLTAGE_V, validation_time=ECU_VALIDATION_TIME_S, ) # Single, deterministic read after the rail is steady AND the # ECU has had its validation budget. status = fio.read_signal( "ALM_Status", "ALMVoltageStatus", default=-1, ) # ── ASSERT ──────────────────────────────────────────────────── rp("psu_setpoint_v", OVERVOLTAGE_V) rp("psu_settled_s", round(result["settled_s"], 4)) rp("psu_final_v", result["final_v"]) rp("validation_time_s", result["validation_s"]) rp("voltage_status_after", int(status)) rp("voltage_trace", downsample_trace(result["trace"])) assert int(status) == VOLTAGE_STATUS_OVER, ( f"ALMVoltageStatus = 0x{int(status):02X} after applying " f"{OVERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, " f"held {result['validation_s']} s). Expected " f"0x{VOLTAGE_STATUS_OVER:02X} (OverVoltage)." ) finally: # ── TEARDOWN ────────────────────────────────────────────────── # ALWAYS runs, even on assertion failure. Belt-and-suspenders: # the autouse fixture also restores nominal on the way out. apply_voltage_and_settle( psu, NOMINAL_VOLTAGE, validation_time=ECU_VALIDATION_TIME_S, ) # Regression check: after restoring nominal supply and validation # hold, status returns to Normal. Outside the try/finally so a # failure here doesn't mask the primary OV assertion. recovery_status = fio.read_signal( "ALM_Status", "ALMVoltageStatus", default=-1, ) rp("voltage_status_recovery", int(recovery_status)) assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, ( f"ECU did not return to Normal after restoring nominal supply. " f"Got 0x{int(recovery_status):02X}." ) # ╔══════════════════════════════════════════════════════════════════════╗ # ║ TEST FLAVOR B — undervoltage detection ║ # ╚══════════════════════════════════════════════════════════════════════╝ def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp): """ Title: ECU reports UnderVoltage when supply drops below the threshold Description: Symmetric counterpart to flavor A — drop the supply below the firmware's brown-out threshold, wait for the rail to be there, hold for the ECU validation window, then assert ``ALMVoltageStatus = 0x01`` (Power UnderVoltage). Note that at very low voltages the ECU may stop publishing ALM_Status entirely (full brown-out). Pick UNDERVOLTAGE_V high enough to keep the LIN node alive but low enough to trip the UV flag — your firmware spec defines the right value. Test Steps: 1. SETUP: confirm baseline ALMVoltageStatus == Normal 2. PROCEDURE: apply UNDERVOLTAGE_V via apply_voltage_and_settle 3. ASSERT: single read of ALMVoltageStatus == UnderVoltage 4. TEARDOWN: restore NOMINAL_VOLTAGE and verify recovery Expected Result: - Baseline status is Normal - After settle + validation hold at UNDERVOLTAGE_V, ALMVoltageStatus reads UnderVoltage - After restoring nominal, ALMVoltageStatus returns to Normal """ # ── SETUP ───────────────────────────────────────────────────────── baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1) rp("baseline_voltage_status", int(baseline)) assert int(baseline) == VOLTAGE_STATUS_NORMAL, ( f"Expected Normal at nominal supply but got {baseline!r}" ) try: # ── PROCEDURE ───────────────────────────────────────────────── result = apply_voltage_and_settle( psu, UNDERVOLTAGE_V, validation_time=ECU_VALIDATION_TIME_S, ) status = fio.read_signal( "ALM_Status", "ALMVoltageStatus", default=-1, ) # ── ASSERT ──────────────────────────────────────────────────── rp("psu_setpoint_v", UNDERVOLTAGE_V) rp("psu_settled_s", round(result["settled_s"], 4)) rp("psu_final_v", result["final_v"]) rp("validation_time_s", result["validation_s"]) rp("voltage_status_after", int(status)) rp("voltage_trace", downsample_trace(result["trace"])) assert int(status) == VOLTAGE_STATUS_UNDER, ( f"ALMVoltageStatus = 0x{int(status):02X} after applying " f"{UNDERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, " f"held {result['validation_s']} s). Expected " f"0x{VOLTAGE_STATUS_UNDER:02X} (UnderVoltage). " f"If status == -1 the slave likely browned out — raise " f"UNDERVOLTAGE_V toward the trip point so the node stays alive." ) finally: # ── TEARDOWN ────────────────────────────────────────────────── apply_voltage_and_settle( psu, NOMINAL_VOLTAGE, validation_time=ECU_VALIDATION_TIME_S, ) recovery_status = fio.read_signal( "ALM_Status", "ALMVoltageStatus", default=-1, ) rp("voltage_status_recovery", int(recovery_status)) assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, ( f"ECU did not return to Normal after restoring nominal supply. " f"Got 0x{int(recovery_status):02X}." ) # ╔══════════════════════════════════════════════════════════════════════╗ # ║ TEST FLAVOR C — parametrized voltage sweep ║ # ╚══════════════════════════════════════════════════════════════════════╝ # # A single function that walks several (voltage, expected_status) # pairs. ``@pytest.mark.parametrize`` repeats the body once per tuple, # generating one independent test per row in the report. Each # invocation goes through the autouse fixture again, so they remain # isolated from each other. _VOLTAGE_SCENARIOS = [ # (psu_voltage, expected_alm_status, label) (NOMINAL_VOLTAGE, VOLTAGE_STATUS_NORMAL, "nominal"), (OVERVOLTAGE_V, VOLTAGE_STATUS_OVER, "overvoltage"), (UNDERVOLTAGE_V, VOLTAGE_STATUS_UNDER, "undervoltage"), ] @pytest.mark.parametrize( "voltage,expected,label", _VOLTAGE_SCENARIOS, ids=[s[2] for s in _VOLTAGE_SCENARIOS], ) def test_template_voltage_status_parametrized( psu: OwonPSU, fio: FrameIO, rp, voltage: float, expected: int, label: str, ): """ Title: ECU voltage status tracks the supply (sweep) Description: Walks a small matrix of supply levels and asserts the ECU reports the corresponding ``ALMVoltageStatus``. Each row uses :func:`apply_voltage_and_settle` so the supply is *measurably* at the target before the validation hold and the status read. Expected Result: For each (voltage, expected) tuple: a single ALMVoltageStatus read after settle + validation equals ``expected``. """ try: # ── PROCEDURE ───────────────────────────────────────────────── result = apply_voltage_and_settle( psu, voltage, validation_time=ECU_VALIDATION_TIME_S, ) status = fio.read_signal( "ALM_Status", "ALMVoltageStatus", default=-1, ) # ── ASSERT ──────────────────────────────────────────────────── rp("scenario", label) rp("psu_setpoint_v", voltage) rp("expected_status", expected) rp("psu_settled_s", round(result["settled_s"], 4)) rp("psu_final_v", result["final_v"]) rp("validation_time_s", result["validation_s"]) rp("voltage_status_after", int(status)) assert int(status) == expected, ( f"[{label}] ALMVoltageStatus = 0x{int(status):02X} after " f"applying {voltage} V (settled in {result['settled_s']:.3f} s, " f"held {result['validation_s']} s). Expected 0x{expected:02X}." ) finally: # ── TEARDOWN ────────────────────────────────────────────────── apply_voltage_and_settle( psu, NOMINAL_VOLTAGE, validation_time=ECU_VALIDATION_TIME_S, )