tests/hardware: settle-then-validate PSU helpers + voltage-tolerance tests
Voltage-changing tests can't sleep a fixed amount and assume the
rail is there — Owon settling is bench-dependent and typically
asymmetric (up-step ≠ down-step). New shared helpers and tests use
the rail's measured value to drive timing.
- tests/hardware/psu_helpers.py:
wait_until_settled(psu, target_v, ...)
polls measure_voltage_v() until within tol, returns
(elapsed_s, trace) or (None, trace) on timeout
apply_voltage_and_settle(psu, target_v, validation_time, ...)
composite: set setpoint → wait until measured matches →
sleep validation_time so the firmware-side observer can
detect and republish status. Raises on settle timeout.
downsample_trace, plus DEFAULT_VOLTAGE_TOL_V (0.10),
DEFAULT_POLL_INTERVAL_S (0.05), DEFAULT_SETTLE_TIMEOUT_S (10.0),
DEFAULT_VALIDATION_TIME_S (1.0).
- test_overvolt.py: voltage-tolerance suite. Each test (over,
under, parametrized sweep) uses apply_voltage_and_settle for the
procedure, the autouse _park_at_nominal fixture (also via the
helper), and a single deterministic ALM_Status read after the
validation hold instead of polling-the-bus.
- test_psu_voltage_settling.py: characterization test, opt-in via
the new psu_settling marker. Walks four (start_v, target_v)
transitions and records settling_time_s + voltage_trace per case.
Values feed directly into test_overvolt's ECU_VALIDATION_TIME_S
budgeting.
- pytest.ini:
junit_family = legacy → record_property() entries now actually
appear in reports/junit.xml (the default xunit2 silently
dropped them with a collect-time warning, breaking the
conftest plugin's metadata round-trip)
psu_settling marker registered
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eac662b139
commit
29a7a44c8b
@ -27,6 +27,12 @@ markers =
|
||||
smoke: Basic functionality validation tests
|
||||
boundary: Boundary condition and edge case tests
|
||||
slow: Slow tests (>5s typical); selectable via -m "slow" or excludable via -m "not slow"
|
||||
psu_settling: Owon PSU voltage settling-time characterization (opt-in via -m psu_settling)
|
||||
|
||||
# testpaths: Where pytest looks for tests by default.
|
||||
testpaths = tests
|
||||
|
||||
# junit_family: 'legacy' is required for record_property() entries to appear in
|
||||
# the JUnit XML. The default 'xunit2' silently drops them and warns at collect
|
||||
# time, which breaks the conftest plugin's metadata round-trip.
|
||||
junit_family = legacy
|
||||
|
||||
182
tests/hardware/psu_helpers.py
Normal file
182
tests/hardware/psu_helpers.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""Shared PSU helpers for hardware tests.
|
||||
|
||||
The Owon PSU does not slew instantaneously, and the slew time depends on
|
||||
the bench (PSU model, load, cable drop). Tests that change supply
|
||||
voltage must therefore *measure* the rail before assuming the new
|
||||
voltage is present, instead of waiting a fixed sleep.
|
||||
|
||||
This module provides two layers:
|
||||
|
||||
- :func:`wait_until_settled` — primitive: poll
|
||||
``psu.measure_voltage_v()`` until it falls within a tolerance band
|
||||
of the target. Returns the elapsed time and the full poll trace.
|
||||
|
||||
- :func:`apply_voltage_and_settle` — composite: write a setpoint,
|
||||
wait for the rail to actually be there, then hold for a
|
||||
configurable ``validation_time`` so any downstream observer (an
|
||||
ECU monitoring its supply rail and reporting status over LIN) has
|
||||
time to detect and react. Returns a structured dict that callers
|
||||
record to the report.
|
||||
|
||||
The pattern in tests is:
|
||||
|
||||
apply_voltage_and_settle(psu, OVERVOLTAGE_V,
|
||||
validation_time=ECU_VALIDATION_TIME_S)
|
||||
status = fio.read_signal("ALM_Status", "ALMVoltageStatus")
|
||||
assert status == VOLTAGE_STATUS_OVER
|
||||
|
||||
— a single deterministic status read instead of polling the bus
|
||||
hoping the ECU has caught up.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from ecu_framework.power import OwonPSU
|
||||
|
||||
|
||||
# ── tunable defaults (override per call when needed) ─────────────────────
|
||||
# Tolerance band for "the PSU has reached the target". 100 mV is well
|
||||
# within typical Owon regulation accuracy and tight enough that we're
|
||||
# really measuring the slewed voltage, not loop noise.
|
||||
DEFAULT_VOLTAGE_TOL_V = 0.10
|
||||
|
||||
# Polling interval. The serial round-trip is ~10 ms; 50 ms gives clean
|
||||
# samples without saturating the link.
|
||||
DEFAULT_POLL_INTERVAL_S = 0.05
|
||||
|
||||
# Maximum time to wait for the PSU to settle. Owon settling on small
|
||||
# steps is sub-second; on big steps a few seconds. 10 s is a generous
|
||||
# fence that surfaces a real bench problem if exceeded.
|
||||
DEFAULT_SETTLE_TIMEOUT_S = 10.0
|
||||
|
||||
# Default time to hold after the PSU settles before the test reads any
|
||||
# downstream status. This is the **firmware-dependent** budget — how
|
||||
# long the ECU needs to detect the new voltage and republish status.
|
||||
# Tune to your firmware spec.
|
||||
DEFAULT_VALIDATION_TIME_S = 1.0
|
||||
|
||||
|
||||
# ── primitive: poll until settled ────────────────────────────────────────
|
||||
|
||||
|
||||
def wait_until_settled(
|
||||
psu: OwonPSU,
|
||||
target_v: float,
|
||||
*,
|
||||
tol: float = DEFAULT_VOLTAGE_TOL_V,
|
||||
interval: float = DEFAULT_POLL_INTERVAL_S,
|
||||
timeout: float = DEFAULT_SETTLE_TIMEOUT_S,
|
||||
) -> tuple[Optional[float], list[tuple[float, Optional[float]]]]:
|
||||
"""Poll ``psu.measure_voltage_v()`` until within ``tol`` of ``target_v``.
|
||||
|
||||
The caller is responsible for issuing the setpoint **just before**
|
||||
calling this — the timer starts on the function's first instruction
|
||||
so the recorded duration includes the bus latency of the setpoint
|
||||
being applied.
|
||||
|
||||
Returns ``(elapsed_seconds, trace)`` when settled, or
|
||||
``(None, trace)`` if ``timeout`` expired. ``trace`` is the full list
|
||||
of ``(elapsed_seconds, measured_voltage)`` tuples; the
|
||||
``measured_voltage`` may be ``None`` for samples that failed to
|
||||
parse (rare; surfaces firmware response anomalies).
|
||||
"""
|
||||
trace: list[tuple[float, Optional[float]]] = []
|
||||
start = time.monotonic()
|
||||
deadline = start + timeout
|
||||
while time.monotonic() < deadline:
|
||||
v = psu.measure_voltage_v()
|
||||
elapsed = time.monotonic() - start
|
||||
trace.append((round(elapsed, 4), v))
|
||||
if v is not None and abs(v - target_v) <= tol:
|
||||
return elapsed, trace
|
||||
time.sleep(interval)
|
||||
return None, trace
|
||||
|
||||
|
||||
def downsample_trace(
|
||||
trace: list[tuple[float, Optional[float]]],
|
||||
max_samples: int = 30,
|
||||
) -> list[tuple[float, Optional[float]]]:
|
||||
"""Reduce a trace to at most ``max_samples`` evenly-spaced entries.
|
||||
|
||||
Keeps the first and last samples so the start/end of the curve are
|
||||
always visible, then strides through the middle. Useful for
|
||||
attaching a poll trace to a JUnit/HTML report without bloating it.
|
||||
"""
|
||||
n = len(trace)
|
||||
if n <= max_samples:
|
||||
return list(trace)
|
||||
step = max(1, n // max_samples)
|
||||
sampled = trace[::step]
|
||||
if sampled[-1] != trace[-1]:
|
||||
sampled.append(trace[-1])
|
||||
return sampled
|
||||
|
||||
|
||||
# ── composite: apply, wait for rail, hold for ECU ───────────────────────
|
||||
|
||||
|
||||
def apply_voltage_and_settle(
|
||||
psu: OwonPSU,
|
||||
target_v: float,
|
||||
*,
|
||||
validation_time: float = DEFAULT_VALIDATION_TIME_S,
|
||||
tol: float = DEFAULT_VOLTAGE_TOL_V,
|
||||
interval: float = DEFAULT_POLL_INTERVAL_S,
|
||||
settle_timeout: float = DEFAULT_SETTLE_TIMEOUT_S,
|
||||
) -> dict:
|
||||
"""Set ``target_v``, wait for the rail to actually be there, then hold.
|
||||
|
||||
Steps:
|
||||
1. ``psu.set_voltage(1, target_v)`` — issue the setpoint.
|
||||
2. :func:`wait_until_settled` — poll the PSU meter until measured
|
||||
voltage is within ``tol`` of ``target_v`` (or raise on timeout).
|
||||
3. ``time.sleep(validation_time)`` — give the firmware-side
|
||||
observer (e.g. ECU voltage monitor) time to detect the new
|
||||
voltage and update its status frame.
|
||||
|
||||
By the time this function returns the rail is at ``target_v`` and
|
||||
the ECU has had ``validation_time`` to react. A single status read
|
||||
afterwards is unambiguous — no polling-on-the-bus race.
|
||||
|
||||
Returns a dict with diagnostic data:
|
||||
|
||||
{
|
||||
"settled_s": float, # PSU slewing time to within tol
|
||||
"validation_s": float, # validation_time as passed
|
||||
"final_v": float, # last measured voltage
|
||||
"trace": list, # full (elapsed_s, v) trace
|
||||
}
|
||||
|
||||
Raises:
|
||||
AssertionError: PSU did not reach ``target_v`` within
|
||||
``settle_timeout`` seconds (last measured voltage and
|
||||
tolerance band included in the message).
|
||||
"""
|
||||
psu.set_voltage(1, target_v)
|
||||
elapsed, trace = wait_until_settled(
|
||||
psu, target_v,
|
||||
tol=tol, interval=interval, timeout=settle_timeout,
|
||||
)
|
||||
|
||||
final_v = trace[-1][1] if trace else None
|
||||
if elapsed is None:
|
||||
raise AssertionError(
|
||||
f"PSU did not settle to {target_v} V within {settle_timeout} s "
|
||||
f"(last measured: {final_v} V, ±{tol} V tolerance). "
|
||||
f"Either the PSU can't slew this far, the load is misbehaving, "
|
||||
f"or the timeout is too tight for this transition."
|
||||
)
|
||||
|
||||
# Hold the rail steady so the ECU can detect and republish status.
|
||||
if validation_time > 0:
|
||||
time.sleep(validation_time)
|
||||
|
||||
return {
|
||||
"settled_s": elapsed,
|
||||
"validation_s": validation_time,
|
||||
"final_v": final_v,
|
||||
"trace": trace,
|
||||
}
|
||||
404
tests/hardware/test_overvolt.py
Normal file
404
tests/hardware/test_overvolt.py
Normal file
@ -0,0 +1,404 @@
|
||||
"""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,
|
||||
)
|
||||
225
tests/hardware/test_psu_voltage_settling.py
Normal file
225
tests/hardware/test_psu_voltage_settling.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""PSU voltage settling-time characterization.
|
||||
|
||||
WHAT THIS TEST DOES
|
||||
-------------------
|
||||
Measures how long the bench Owon PSU actually takes to deliver a new
|
||||
voltage at its output terminals after a setpoint change. Other tests
|
||||
(notably ``test_overvolt.py``) rely on a settle delay before they read
|
||||
``ALMVoltageStatus``; this characterization gives you the real number
|
||||
to budget for instead of guessing.
|
||||
|
||||
HOW IT WORKS
|
||||
------------
|
||||
For each parametrized transition ``start_v → target_v``:
|
||||
|
||||
1. SETUP — park the PSU at ``start_v`` and wait, *un-timed*, until
|
||||
the measured voltage actually settles there. This step
|
||||
isolates the timer from any leftover state from the
|
||||
previous test.
|
||||
2. PROCEDURE — issue ``set_voltage(target_v)`` and immediately begin
|
||||
polling ``psu.measure_voltage_v()`` at
|
||||
``POLL_INTERVAL_S``; the timer starts the moment the
|
||||
setpoint is sent.
|
||||
3. ASSERT — record ``settling_time_s`` (and the full voltage
|
||||
trace) as report properties; assert that the PSU
|
||||
actually reached the target within
|
||||
``MAX_SETTLE_TIME_S``.
|
||||
4. TEARDOWN — set the supply back to ``NOMINAL_V`` so subsequent
|
||||
tests start from the bench's normal state.
|
||||
|
||||
WHY THIS DESERVES ITS OWN MARKER (``psu_settling``)
|
||||
---------------------------------------------------
|
||||
The test takes tens of seconds (4 transitions × several seconds each)
|
||||
and is only useful occasionally — typically when changing the bench
|
||||
PSU model or when other voltage-tolerance tests start failing on the
|
||||
detect timeout. Selecting it explicitly with ``-m psu_settling``
|
||||
keeps everyday MUM/PSU runs fast.
|
||||
|
||||
It's also marked ``slow`` so default ``-m hardware`` runs that pass
|
||||
``-m "not slow"`` skip it without the user having to know it exists.
|
||||
|
||||
REPORT PROPERTIES (per case)
|
||||
----------------------------
|
||||
- ``transition`` — the parametrize label, e.g. ``13_to_18_OV``
|
||||
- ``start_voltage_v`` — the requested start voltage (SETUP target)
|
||||
- ``target_voltage_v`` — the final target voltage (PROCEDURE target)
|
||||
- ``settling_time_s`` — headline result: seconds from setpoint
|
||||
to first within-tolerance sample. ``None``
|
||||
if the timeout was reached
|
||||
- ``final_voltage_v`` — last measured voltage (whether settled or not)
|
||||
- ``sample_count`` — number of measurements taken
|
||||
- ``voltage_trace`` — list of (elapsed_s, measured_v) tuples
|
||||
(downsampled to ~30 entries to keep the
|
||||
report readable)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from ecu_framework.power import OwonPSU
|
||||
|
||||
from psu_helpers import (
|
||||
DEFAULT_POLL_INTERVAL_S,
|
||||
DEFAULT_SETTLE_TIMEOUT_S,
|
||||
DEFAULT_VOLTAGE_TOL_V,
|
||||
downsample_trace,
|
||||
wait_until_settled,
|
||||
)
|
||||
|
||||
|
||||
# ── markers ───────────────────────────────────────────────────────────────
|
||||
# `hardware` — needs the bench
|
||||
# `psu_settling` — opt-in marker: run with `pytest -m psu_settling`
|
||||
# `slow` — excludable from quick runs via `-m "not slow"`
|
||||
pytestmark = [
|
||||
pytest.mark.hardware,
|
||||
pytest.mark.psu_settling,
|
||||
pytest.mark.slow,
|
||||
]
|
||||
|
||||
|
||||
# ── characterization knobs ────────────────────────────────────────────────
|
||||
# These are the defaults from psu_helpers, re-exported here so the
|
||||
# numbers used in the report properties match the tunables visible at
|
||||
# the top of the test file.
|
||||
VOLTAGE_TOL_V = DEFAULT_VOLTAGE_TOL_V
|
||||
POLL_INTERVAL_S = DEFAULT_POLL_INTERVAL_S
|
||||
MAX_SETTLE_TIME_S = DEFAULT_SETTLE_TIMEOUT_S
|
||||
|
||||
# How long to let the PSU settle during the un-timed SETUP step. We want
|
||||
# this comfortably longer than typical settling so we never start the
|
||||
# timer with the rail still moving.
|
||||
SETUP_SETTLE_TIMEOUT_S = MAX_SETTLE_TIME_S
|
||||
SETUP_SETTLE_GRACE_S = 0.3 # extra hold once within tolerance, just in case
|
||||
|
||||
# Voltage we leave the bench at on TEARDOWN, so the next test starts
|
||||
# from a known state. Matches the value used by test_overvolt.py.
|
||||
NOMINAL_V = 13.0
|
||||
|
||||
# Trace size cap for report properties.
|
||||
TRACE_MAX_SAMPLES = 30
|
||||
|
||||
|
||||
# ── parameter matrix ──────────────────────────────────────────────────────
|
||||
# Cover the four transitions actually used by test_overvolt.py so the
|
||||
# extracted timings translate directly into wait budgets there.
|
||||
_TRANSITIONS = [
|
||||
# (start_v, target_v, label)
|
||||
(13.0, 18.0, "13_to_18_OV"),
|
||||
(18.0, 13.0, "18_to_13_back"),
|
||||
(13.0, 7.0, "13_to_7_UV"),
|
||||
( 7.0, 13.0, "7_to_13_back"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start_v,target_v,label",
|
||||
_TRANSITIONS,
|
||||
ids=[t[2] for t in _TRANSITIONS],
|
||||
)
|
||||
def test_psu_voltage_settling_time(
|
||||
psu: OwonPSU,
|
||||
rp,
|
||||
start_v: float,
|
||||
target_v: float,
|
||||
label: str,
|
||||
):
|
||||
"""
|
||||
Title: PSU voltage settling time — {label}
|
||||
|
||||
Description:
|
||||
Measures how long the Owon PSU actually takes to deliver
|
||||
``target_v`` after a setpoint change from ``start_v``.
|
||||
Records the settling time and a downsampled voltage trace
|
||||
as report properties so other tests (e.g. test_overvolt)
|
||||
can size their detect timeouts from real data instead of
|
||||
guesses.
|
||||
|
||||
Test Steps:
|
||||
1. SETUP: park PSU at start_v and wait *un-timed* until
|
||||
the measured voltage falls within VOLTAGE_TOL_V
|
||||
2. PROCEDURE: set_voltage(target_v), immediately start the
|
||||
timer, poll measure_voltage_v() every
|
||||
POLL_INTERVAL_S
|
||||
3. ASSERT: measured voltage reached target_v within
|
||||
MAX_SETTLE_TIME_S
|
||||
4. TEARDOWN: restore NOMINAL_V
|
||||
|
||||
Expected Result:
|
||||
- The PSU reaches target_v within MAX_SETTLE_TIME_S
|
||||
- settling_time_s is recorded for downstream tuning
|
||||
"""
|
||||
# ── SETUP ─────────────────────────────────────────────────────────
|
||||
# Park at the starting voltage and wait, un-timed, for the rail to
|
||||
# actually be there. This isolates the PROCEDURE timer from any
|
||||
# voltage left over from a previous test.
|
||||
psu.set_voltage(1, start_v)
|
||||
settled_to_start, _setup_trace = wait_until_settled(
|
||||
psu, start_v,
|
||||
timeout=SETUP_SETTLE_TIMEOUT_S,
|
||||
)
|
||||
assert settled_to_start is not None, (
|
||||
f"SETUP: PSU never reached start voltage {start_v} V within "
|
||||
f"{SETUP_SETTLE_TIMEOUT_S} s — bench may be unable to slew "
|
||||
f"to that point or measurement is not parsing correctly."
|
||||
)
|
||||
# A short hold so we're sampling a *steady* voltage, not the tail
|
||||
# of a slew, when the PROCEDURE timer starts.
|
||||
time.sleep(SETUP_SETTLE_GRACE_S)
|
||||
|
||||
try:
|
||||
# ── PROCEDURE ─────────────────────────────────────────────────
|
||||
# The setpoint write happens here; ``wait_until_settled`` starts
|
||||
# polling immediately so the recorded duration captures bus
|
||||
# latency + slew time.
|
||||
psu.set_voltage(1, target_v)
|
||||
elapsed, trace = wait_until_settled(psu, target_v)
|
||||
final_v = trace[-1][1] if trace else None
|
||||
sample_count = len(trace)
|
||||
|
||||
# ── ASSERT ────────────────────────────────────────────────────
|
||||
# Record headline numbers first so they're in the report even
|
||||
# on assertion failure.
|
||||
rp("transition", label)
|
||||
rp("start_voltage_v", start_v)
|
||||
rp("target_voltage_v", target_v)
|
||||
rp("settling_time_s", round(elapsed, 4) if elapsed is not None else None)
|
||||
rp("final_voltage_v", final_v)
|
||||
rp("sample_count", sample_count)
|
||||
rp("voltage_trace", downsample_trace(trace, max_samples=TRACE_MAX_SAMPLES))
|
||||
rp("voltage_tol_v", VOLTAGE_TOL_V)
|
||||
rp("poll_interval_s", POLL_INTERVAL_S)
|
||||
|
||||
# Print a human-readable summary so the timing shows up
|
||||
# immediately in `pytest -s` runs.
|
||||
if elapsed is not None:
|
||||
print(
|
||||
f"\n[psu_settling] {label}: {start_v} V → {target_v} V "
|
||||
f"settled in {elapsed:.3f} s (final={final_v} V, "
|
||||
f"samples={sample_count})"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"\n[psu_settling] {label}: {start_v} V → {target_v} V "
|
||||
f"DID NOT SETTLE within {MAX_SETTLE_TIME_S} s "
|
||||
f"(final={final_v} V, samples={sample_count})"
|
||||
)
|
||||
|
||||
assert elapsed is not None, (
|
||||
f"PSU did not reach {target_v} V within {MAX_SETTLE_TIME_S} s "
|
||||
f"(last measurement: {final_v} V, ±{VOLTAGE_TOL_V} V tolerance). "
|
||||
f"Either the PSU can't slew this far, the load is misbehaving, "
|
||||
f"or MAX_SETTLE_TIME_S is too tight."
|
||||
)
|
||||
|
||||
finally:
|
||||
# ── TEARDOWN ──────────────────────────────────────────────────
|
||||
# Always restore nominal so the next test starts cleanly.
|
||||
# Don't time this — it runs for safety, not measurement.
|
||||
psu.set_voltage(1, NOMINAL_V)
|
||||
# A short pause so any later-running test that polls
|
||||
# immediately sees a voltage near nominal rather than the tail
|
||||
# of this teardown's slew.
|
||||
time.sleep(0.5)
|
||||
Loading…
x
Reference in New Issue
Block a user