Extends ``tests/hardware/alm_helpers.py`` into the full surface that
hardware tests use, so contributors write intent (``alm.send_color``,
``alm.read_led_state``, ``alm.wait_for_led_on``) and never touch
``fio.send("ALM_Req_A", AmbLight…=…)`` or LDF schema details.
What landed:
- AlmTester gains ~16 methods:
read_nad, read_voltage_status, read_thermal_status, read_nvm_status,
read_sig_comm_err, read_ntc_kelvin, read_ntc_celsius, read_pwm,
read_pwm_wo_comp, send_color, send_color_broadcast, save_color,
apply_saved_color, discard_saved_color, send_config, plus
wait_for_led_on / wait_for_led_off / wait_for_animating wrappers.
- The six IntEnum classes that ALM tests need (LedState, Mode, Update,
NVMStatus, VoltageStatus, ThermalStatus) are defined directly in
alm_helpers.py — tests get them via `from alm_helpers import …`.
- All ALM test files migrated:
test_mum_alm_animation.py, test_mum_alm_cases.py, test_overvolt.py,
swe5/test_anm_management.py, swe5/test_com_management.py
each now go through AlmTester for every common pattern.
- swe6/test_com_management.py: stays on `fio` (these tests probe
schema features not in the current production LDF and skip when
the LDF doesn't declare them) — change limited to LedState enum.
- test_mum_alm_animation_generated.py deleted — its "no-AlmTester"
demonstration loses its point now that AlmTester is the
recommended path.
- docs/19_frame_io_and_alm_helpers.md reframed: AlmTester is the
contributor surface; FrameIO is implementation detail. New API
reference + Cookbook examples + a note that the maintenance pact
is "LDF changes → AlmTester updates".
Verified: pytest --collect-only collects 87 tests cleanly; 40 unit
+ mock smoke tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
371 lines
19 KiB
Python
371 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 alm_helpers import AlmTester, VoltageStatus
|
|
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 come from the typed VoltageStatus
|
|
# enum re-exported by alm_helpers (LDF Signal_encoding_types: VoltageStatus).
|
|
# Use the enum members directly in assertions for self-explanatory failures.
|
|
|
|
# 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, 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 = alm.read_voltage_status()
|
|
rp("baseline_voltage_status", baseline)
|
|
assert baseline == VoltageStatus.NORMAL_VOLTAGE, (
|
|
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 = alm.read_voltage_status()
|
|
|
|
# ── 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", status)
|
|
rp("voltage_trace", downsample_trace(result["trace"]))
|
|
assert status == VoltageStatus.POWER_OVERVOLTAGE, (
|
|
f"ALMVoltageStatus = {status!r} after applying "
|
|
f"{OVERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
|
|
f"held {result['validation_s']} s). Expected "
|
|
f"VoltageStatus.POWER_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 = alm.read_voltage_status()
|
|
rp("voltage_status_recovery", recovery_status)
|
|
assert recovery_status == VoltageStatus.NORMAL_VOLTAGE, (
|
|
f"ECU did not return to Normal after restoring nominal supply. "
|
|
f"Got {recovery_status!r}."
|
|
)
|
|
|
|
|
|
# ╔══════════════════════════════════════════════════════════════════════╗
|
|
# ║ TEST FLAVOR B — undervoltage detection ║
|
|
# ╚══════════════════════════════════════════════════════════════════════╝
|
|
|
|
|
|
def test_template_undervoltage_status(psu: OwonPSU, 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 = alm.read_voltage_status()
|
|
rp("baseline_voltage_status", baseline)
|
|
assert baseline == VoltageStatus.NORMAL_VOLTAGE, (
|
|
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 = alm.read_voltage_status()
|
|
|
|
# ── 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", status)
|
|
rp("voltage_trace", downsample_trace(result["trace"]))
|
|
assert status == VoltageStatus.POWER_UNDERVOLTAGE, (
|
|
f"ALMVoltageStatus = {status!r} after applying "
|
|
f"{UNDERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
|
|
f"held {result['validation_s']} s). Expected "
|
|
f"VoltageStatus.POWER_UNDERVOLTAGE. "
|
|
f"If status is None 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 = alm.read_voltage_status()
|
|
rp("voltage_status_recovery", recovery_status)
|
|
assert recovery_status == VoltageStatus.NORMAL_VOLTAGE, (
|
|
f"ECU did not return to Normal after restoring nominal supply. "
|
|
f"Got {recovery_status!r}."
|
|
)
|
|
|
|
|
|
# ╔══════════════════════════════════════════════════════════════════════╗
|
|
# ║ 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, VoltageStatus.NORMAL_VOLTAGE, "nominal"),
|
|
(OVERVOLTAGE_V, VoltageStatus.POWER_OVERVOLTAGE, "overvoltage"),
|
|
(UNDERVOLTAGE_V, VoltageStatus.POWER_UNDERVOLTAGE, "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,
|
|
alm: AlmTester,
|
|
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 = alm.read_voltage_status()
|
|
|
|
# ── 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", status)
|
|
assert status == expected, (
|
|
f"[{label}] ALMVoltageStatus = {status!r} after "
|
|
f"applying {voltage} V (settled in {result['settled_s']:.3f} s, "
|
|
f"held {result['validation_s']} s). Expected {expected!r}."
|
|
)
|
|
|
|
finally:
|
|
# ── TEARDOWN ──────────────────────────────────────────────────
|
|
apply_voltage_and_settle(
|
|
psu, NOMINAL_VOLTAGE,
|
|
validation_time=ECU_VALIDATION_TIME_S,
|
|
)
|