ecu-tests/tests/hardware/_test_case_template_psu_lin.py
Hosam-Eldin Mostafa 11b5402b14 tests/hardware: add copyable, heavily-commented test templates
Two starting-point files for new hardware tests. Leading underscore
in the filenames keeps pytest from collecting them.

- _test_case_template.py — for ALM_Node-touching MUM tests.
  Three flavors with full SETUP / PROCEDURE / ASSERT / TEARDOWN
  section markers:
    A) minimal: relies on the autouse _reset_to_off (LED OFF
       baseline) — no per-test setup/teardown
    B) with isolation: try/finally pattern for tests that mutate
       persistent ECU state (e.g. ConfigFrame)
    C) single-signal probe: fio.read_signal one-shot

  Inline comments explain pytest fundamentals (fixture, scope,
  autouse, yield, rp), the four-phase pattern, and the
  must/must-not contract.

- _test_case_template_psu_lin.py — for tests that drive the PSU
  AND observe the LIN bus (over/undervoltage tolerance, brown-out,
  supply transients). Three flavors:
    A) overvoltage: apply OV via apply_voltage_and_settle, single
       status read after validation hold, assert OverVoltage
    B) undervoltage: symmetric for UV
    C) parametrized voltage sweep
  Documents the three-layer safety guarantee (session
  safe_off_on_close / autouse _park_at_nominal / per-test
  try/finally) and the rule that tests never call set_output(False)
  or close() — the session fixture owns the PSU lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:02:13 +02:00

420 lines
21 KiB
Python

"""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_<feature>.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(...): ...