ecu-tests/tests/hardware/mum/test_overvolt.py
Hosam-Eldin Mostafa 8fa4cf0be1 refactor(tests): layer fixtures by adapter type (mum/psu/babylin)
Restructures tests/hardware/ so that fixture access is controlled by
directory layout — pytest only walks upward through conftest.py files,
so a PSU test physically cannot request fio/alm/nad.

Layout:
- tests/hardware/conftest.py           (unchanged: PSU fixtures)
- tests/hardware/mum/conftest.py       NEW: _require_mum (session autouse),
                                       fio (session), nad (session),
                                       alm (session), _reset_to_off
                                       (function autouse)
- tests/hardware/mum/**                MUM tests + swe5/ + swe6/
- tests/hardware/psu/**                PSU-only tests
- tests/hardware/babylin/**            deprecated BabyLIN E2E

What this removes (was duplicated before):
- 7 verbatim copies of the `fio` fixture
- 6 copies of the `alm` fixture
- 6 copies of the `_reset_to_off` autouse
- 9 inline `if config.interface.type != "mum": pytest.skip(...)` gates

What this changes by design:
- fio / alm / nad scope: module → session. NAD discovery happens once
  per run instead of once per module. The helpers are immutable beyond
  their constructor args, so sharing them is safe; per-test state is
  reset by the autouse `_reset_to_off`.
- test_overvolt.py: `_park_at_nominal` is now `_reset_to_off`, which
  cleanly overrides the conftest's LED-only version (PSU + LED reset).
- test_mum_alm_animation_generated.py keeps a local `_reset_to_off` +
  `_force_off` so its "no AlmTester anywhere" demonstration is preserved
  via fixture override; the local `nad` is also retained because it
  uses the typed `AlmStatus.receive` API.

Docs:
- docs/24_test_wiring.md NEW — describes the three-layer fixture
  topology, lifecycle sequence diagram, helper class wiring, and the
  playbook for adding a new framework component.
- docs/05_architecture_overview.md: add MCF (mum conftest) node to the
  Mermaid diagram + mention it in the components list.
- docs/19_frame_io_and_alm_helpers.md: replace the per-module
  fixture-wiring example with a request-fixtures-by-name snippet plus
  the override pattern.
- Path references swept across docs/02, docs/14, docs/18, docs/20,
  docs/README to point at the new locations.

Verified: pytest --collect-only collects 93 tests with no errors;
30 unit tests and 10 mock-only smoke tests pass; fixture-per-test
output shows PSU tests cannot see fio/alm/nad.

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

386 lines
19 KiB
Python

"""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.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`` come from ``tests/hardware/mum/conftest.py``.
# This module overrides ``_reset_to_off`` because parking the PSU at the
# nominal voltage is part of every test's baseline here, not just the
# LED state — see the docstring below.
@pytest.fixture(autouse=True)
def _reset_to_off(psu: OwonPSU, alm: AlmTester):
"""Per-test baseline: PSU voltage at NOMINAL_VOLTAGE + LED off.
Overrides the conftest's LED-only ``_reset_to_off`` because over/under-
voltage tests need both the rail and the LED restored. 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,
)