diff --git a/pytest.ini b/pytest.ini index 0161775..de48648 100644 --- a/pytest.ini +++ b/pytest.ini @@ -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 diff --git a/tests/hardware/psu_helpers.py b/tests/hardware/psu_helpers.py new file mode 100644 index 0000000..d9e263d --- /dev/null +++ b/tests/hardware/psu_helpers.py @@ -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, + } diff --git a/tests/hardware/test_overvolt.py b/tests/hardware/test_overvolt.py new file mode 100644 index 0000000..5a85945 --- /dev/null +++ b/tests/hardware/test_overvolt.py @@ -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, + ) diff --git a/tests/hardware/test_psu_voltage_settling.py b/tests/hardware/test_psu_voltage_settling.py new file mode 100644 index 0000000..4efaa6a --- /dev/null +++ b/tests/hardware/test_psu_voltage_settling.py @@ -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)