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:
Hosam-Eldin Mostafa 2026-05-08 19:01:49 +02:00
parent eac662b139
commit 29a7a44c8b
4 changed files with 817 additions and 0 deletions

View File

@ -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

View 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,
}

View 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,
)

View 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)