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