ecu-tests/tests/hardware/test_owon_psu.py
Hosam-Eldin Mostafa eac662b139 tests/hardware: session-scoped PSU fixture so the bench stays powered
On benches where the Owon PSU powers the ECU, every per-file PSU
fixture that closed the port (sending 'output 0' on close) browned
out the bench between modules — every MUM test that ran after a
closed PSU connection failed with "ECU not responding".

New tests/hardware/conftest.py provides three session-scoped
fixtures:

- _psu_or_none: tolerant. Opens the Owon PSU once via resolve_port,
  parks at config.power_supply.set_voltage / set_current, enables
  output. Yields the live OwonPSU or None. Closes (with
  safe_off_on_close=True) at session end — the bench ends safely
  de-energized.

- _psu_powers_bench: autouse=True. Realizes _psu_or_none so even
  tests that don't request `psu` by name benefit from the
  session-level power-up. No-op if PSU isn't configured.

- psu: public. Skips cleanly when the PSU isn't reachable.

Contract for tests:
  - request `psu` if you need to read measurements or change voltage
  - restore nominal voltage in your finally block
  - MUST NOT call psu.set_output(False) (would brown out the bench)
  - MUST NOT call psu.close() (the session fixture owns it)

test_owon_psu.py becomes read-only:
  - removed the local module-scoped psu fixture
  - removed the set_output toggle (would have killed the session)
  - now validates IDN, output_is_on(), and parsed measurements
    against the always-on PSU. Renamed to
    test_owon_psu_idn_and_measurements to reflect the new shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:01:01 +02:00

123 lines
5.1 KiB
Python

"""Hardware test for the Owon serial PSU.
Validates basic SCPI control via :class:`OwonPSU` against the
**session-managed** PSU (see :mod:`tests.hardware.conftest`):
- identification (`*IDN?`)
- decoded output state (`output?`)
- parsed measurement queries (`MEAS:VOLT?`, `MEAS:CURR?`)
The session-scoped autouse fixture in ``conftest.py`` opens the PSU
once at session start, parks it at the configured nominal voltage,
enables output, and leaves it that way for the whole session. This
test therefore does **not** toggle the output — calling
``set_output(False)`` would brown out the ECU and break every MUM
test that runs afterwards.
The four-phase template (SETUP / PROCEDURE / ASSERT / TEARDOWN) still
applies, but TEARDOWN is empty: the test reads-only and leaves the
bench exactly as it found it.
"""
from __future__ import annotations
import pytest
from ecu_framework.config import EcuTestConfig
from ecu_framework.power import OwonPSU
pytestmark = [pytest.mark.hardware]
def test_owon_psu_idn_and_measurements(config: EcuTestConfig, psu: OwonPSU, rp):
"""
Title: Owon PSU — IDN, output state, and parsed measurements
Description:
Read-only smoke test for the Owon PSU controller. Confirms the
bench PSU responds to ``*IDN?``, reports an enabled output
(the session fixture parked it there), and returns parseable
floats for ``MEAS:VOLT?`` and ``MEAS:CURR?``. Optionally
verifies the IDN matches the configured substring.
Requirements: REQ-PSU-001
Test Steps:
1. SETUP: none — the session fixture opened the port,
parked the PSU at nominal, and enabled output
before any test in this run started
2. PROCEDURE: query *IDN?, output?, MEAS:VOLT?, MEAS:CURR?
3. ASSERT: IDN is non-empty (and contains ``idn_substr`` if
configured); output is reported ON; both
measurements parse to floats
4. TEARDOWN: none — this test does not mutate bench state
Expected Result:
- IDN is non-empty (and contains ``idn_substr`` when set)
- ``output_is_on()`` returns True (bench is powered)
- ``measure_voltage_v()`` returns a float close to nominal
- ``measure_current_a()`` returns a float ≥ 0
"""
psu_cfg = config.power_supply
want_substr = psu_cfg.idn_substr
expected_v = float(psu_cfg.set_voltage) if psu_cfg.set_voltage else None
# ── PROCEDURE ─────────────────────────────────────────────────────
# All four queries are reads — they don't change the bench.
idn = psu.idn()
is_on = psu.output_is_on()
measured_v = psu.measure_voltage_v()
measured_i = psu.measure_current_a()
print(f"PSU IDN: {idn}")
print(f"Output ON: {is_on}")
print(f"Measured: V={measured_v}V, I={measured_i}A "
f"(nominal setpoint: {expected_v}V)")
# ── ASSERT ────────────────────────────────────────────────────────
# Record diagnostics before assertions so failure investigations
# have the captured values.
rp("psu_idn", idn)
rp("output_is_on", bool(is_on))
rp("measured_voltage_v", measured_v)
rp("measured_current_a", measured_i)
rp("expected_voltage_v", expected_v)
assert isinstance(idn, str) and idn, "*IDN? returned empty response"
if want_substr:
assert str(want_substr).lower() in idn.lower(), (
f"IDN does not contain expected substring: {want_substr!r}. "
f"Got: {idn!r}"
)
# The session fixture parked the PSU with output enabled. If this
# comes back False the bench is in an unexpected state — likely
# something in a preceding test mistakenly turned the output off.
assert is_on is True, (
f"PSU output is not ON ({is_on=!r}). The session fixture parks "
f"output=ON at start; some earlier test or the fixture itself "
f"may have disabled it. Tests must NOT call psu.set_output(False)."
)
# Measurements must parse — surfaces firmware-level response
# format mismatches as a clear failure.
assert measured_v is not None, (
"measure_voltage_v() returned no number; "
"check the firmware's MEAS:VOLT? response format"
)
assert measured_i is not None, (
"measure_current_a() returned no number; "
"check the firmware's MEAS:CURR? response format"
)
# Sanity: measured voltage should be within ±10% of the nominal
# setpoint when the bench is steady. Loose tolerance because PSU
# accuracy + meter noise + cable drop all stack up.
if expected_v is not None:
tol = 0.10 * expected_v
assert abs(measured_v - expected_v) <= tol, (
f"Measured {measured_v}V is outside ±10% of nominal {expected_v}V "
f"(tolerance ±{tol:.2f}V). Bench supply may be drifting or the "
f"PSU isn't connected to its measure points."
)