ecu-tests/tests/hardware/psu_helpers.py
Hosam-Eldin Mostafa 29a7a44c8b 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>
2026-05-08 19:01:49 +02:00

183 lines
6.8 KiB
Python

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