"""Copyable template for tests that drive the PSU and observe the LIN bus. WHEN TO USE THIS TEMPLATE ------------------------- Voltage-tolerance, brown-out, over-voltage, and "supply transient" tests can't be done from either side alone — you need to *perturb* the bench supply (Owon PSU) and *observe* the ECU's reaction on the LIN bus. This template wires both ends together with the SETUP / PROCEDURE / ASSERT / TEARDOWN pattern so the test stays independent of the others even when it raises mid-flight. THE CANONICAL PATTERN — settle then validate -------------------------------------------- The Owon PSU does NOT slew instantaneously, and the slew time is **bench-dependent** (PSU model, load, cable drop). Don't sleep a fixed amount and assume the rail is there — *measure*. Every voltage change in this template 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 ``DEFAULT_VOLTAGE_TOL_V``, or raises on timeout). 3. Holds for ``ECU_VALIDATION_TIME_S`` so the firmware-side voltage monitor can detect, debounce, and republish status. After that, a **single read** of ``ALM_Status.ALMVoltageStatus`` gives an unambiguous answer — no polling-on-the-bus race. THREE FLAVORS PROVIDED ---------------------- A) ``test_template_overvoltage_status`` — overvoltage detection. B) ``test_template_undervoltage_status`` — undervoltage detection. C) ``test_template_voltage_status_parametrized`` — sweep. WHY THE NAME STARTS WITH AN UNDERSCORE -------------------------------------- pytest only collects ``test_*.py``; this file's leading underscore keeps the example bodies out of the suite. Copy to ``test_.py`` and edit. 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 — also via ``apply_voltage_and_settle`` so the rail is *measurably* back at nominal before the next test runs. 3. Every test wraps its voltage change in ``try``/``finally`` that restores nominal so an assertion failure cannot leave the bench at an over/undervoltage rail. WHY ``set_output`` IS NEVER CALLED HERE --------------------------------------- On this bench the Owon PSU **powers the ECU**. Calling ``psu.set_output(False)`` mid-session would brown out the ECU and break every test that runs afterwards. The session fixture enables the output once at session start; tests perturb voltage but never toggle the output state. """ 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. Named constants make the # assertions self-explanatory and give 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 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 = 18.0 # V — comfortably above the OV threshold UNDERVOLTAGE_V = 7.0 # V — below most brown-out points # Time to 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 does its own settle+validation in PROCEDURE). """ # SETUP — nominal voltage (measured), 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: Apply OVERVOLTAGE_V via :func:`apply_voltage_and_settle`, hold for ECU_VALIDATION_TIME_S, then read ALM_Status.ALMVoltageStatus once and assert it equals VOLTAGE_STATUS_OVER (0x02). Restore nominal supply on the way out. Requirements: REQ-OVP-001 Test Steps: 1. SETUP: confirm baseline ALMVoltageStatus == Normal 2. PROCEDURE: apply OVERVOLTAGE_V, wait for the rail to be there, hold ECU_VALIDATION_TIME_S 3. ASSERT: single read of ALMVoltageStatus == OverVoltage 4. TEARDOWN: restore NOMINAL_VOLTAGE via the same helper and verify recovery to Normal Expected Result: - Baseline status is Normal - After settle + validation hold at OVERVOLTAGE_V, ALMVoltageStatus reads OverVoltage - 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}; " f"check PSU output and ECU power rail before continuing." ) try: # ── PROCEDURE ───────────────────────────────────────────────── result = apply_voltage_and_settle( psu, OVERVOLTAGE_V, validation_time=ECU_VALIDATION_TIME_S, ) # Single read after the rail is steady AND the ECU has had its # validation budget. No polling, no race. 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. apply_voltage_and_settle( psu, NOMINAL_VOLTAGE, validation_time=ECU_VALIDATION_TIME_S, ) # Regression check after the try/finally: status returned to Normal. 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 — apply UNDERVOLTAGE_V via :func:`apply_voltage_and_settle`, hold for the validation window, then assert ALMVoltageStatus = 0x01. 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 """ # ── 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], # nice IDs in the report ) 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, ) # ╔══════════════════════════════════════════════════════════════════════╗ # ║ APPENDIX — patterns you'll reach for ║ # ╚══════════════════════════════════════════════════════════════════════╝ # # Read the parsed measured voltage / current at any time: # v = psu.measure_voltage_v() # float | None # i = psu.measure_current_a() # float | None # rp("psu_measured_v", v) # # Apply a setpoint and just settle (no firmware-side wait): # from psu_helpers import apply_voltage_and_settle # apply_voltage_and_settle(psu, 13.0, validation_time=0.2) # # Decode the entire ALM_Status frame (all signals at once): # decoded = fio.receive("ALM_Status") # # decoded → {'ALMNadNo': 1, 'ALMVoltageStatus': 0, # # 'ALMThermalStatus': 0, 'ALMNVMStatus': 0, # # 'ALMLEDState': 0, 'SigCommErr': 0} # # Verify the LED also turns OFF in undervoltage (some firmwares do): # reached, _, hist = alm.wait_for_state(LED_STATE_OFF, timeout=2.0) # assert reached, hist # # Add a per-test marker for the requirements matrix: # @pytest.mark.req_007 # def test_xxx(...): ...