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>
This commit is contained in:
parent
f5a4ba532b
commit
eac662b139
154
tests/hardware/conftest.py
Normal file
154
tests/hardware/conftest.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""Session-scoped fixtures for the hardware test suite.
|
||||
|
||||
WHY THIS FILE EXISTS
|
||||
--------------------
|
||||
On this bench the Owon PSU **powers the ECU** — the MUM only carries
|
||||
LIN traffic. So the PSU output must stay on for the **entire** test
|
||||
session, not just for the duration of an individual PSU test. If
|
||||
each test file opened/closed its own PSU connection (which by
|
||||
default sends ``output 0`` on close) the bench would brown out
|
||||
between modules and every subsequent MUM test would fail.
|
||||
|
||||
WHAT THIS FILE PROVIDES
|
||||
-----------------------
|
||||
- ``_psu_or_none`` : session-scoped, tolerant. 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. Yields the live ``OwonPSU`` or
|
||||
``None`` if the PSU isn't reachable.
|
||||
- ``_psu_powers_bench`` : session-scoped, ``autouse=True``. Realizes
|
||||
``_psu_or_none`` so even tests that don't
|
||||
request the PSU by name benefit from the
|
||||
power-up. No-op when PSU isn't configured.
|
||||
- ``psu`` : session-scoped, public. Tests that read
|
||||
measurements or perturb voltage request
|
||||
this fixture; it skips cleanly when the
|
||||
PSU isn't available.
|
||||
|
||||
CONTRACT FOR TESTS
|
||||
------------------
|
||||
Tests SHOULD:
|
||||
- request ``psu`` if they need to read measurements or change voltage
|
||||
- restore the bench to nominal voltage in their ``finally`` block
|
||||
(the session fixture will not restore between tests)
|
||||
|
||||
Tests MUST NOT:
|
||||
- call ``psu.set_output(False)`` — this kills the ECU power for
|
||||
every test that follows in the same session
|
||||
- call ``psu.close()`` — the session fixture owns the lifecycle
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from serial import SerialException
|
||||
|
||||
from ecu_framework.config import EcuTestConfig
|
||||
from ecu_framework.power import OwonPSU, SerialParams, resolve_port
|
||||
|
||||
|
||||
# ── nominal supply settings used at session start ─────────────────────────
|
||||
# Sourced from ``config.power_supply.set_voltage`` / ``set_current`` so the
|
||||
# bench operator controls them via YAML rather than via Python edits.
|
||||
# These constants are fallbacks if the YAML omits them.
|
||||
_FALLBACK_NOMINAL_VOLTAGE = 13.0 # V
|
||||
_FALLBACK_NOMINAL_CURRENT = 1.0 # A
|
||||
_PSU_PARK_SETTLE_SECONDS = 0.5 # let the rails stabilize before tests run
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def _psu_or_none(config: EcuTestConfig):
|
||||
"""Open the Owon PSU once per session, park at nominal, leave output ON.
|
||||
|
||||
Returns the live :class:`OwonPSU` instance, or ``None`` if the PSU
|
||||
isn't enabled / configured / reachable. Always yields exactly once
|
||||
(no exceptions propagate out of this fixture for the unavailable
|
||||
cases) so tests that don't request it directly can proceed.
|
||||
|
||||
The session-end teardown closes the port; with
|
||||
``safe_off_on_close=True`` that also sends ``output 0`` — the
|
||||
session ends with the bench safely de-energized.
|
||||
"""
|
||||
cfg = config.power_supply
|
||||
|
||||
if not cfg.enabled or not cfg.port:
|
||||
# PSU not configured. Yield None so the autouse fixture and
|
||||
# the public ``psu`` fixture can both decide what to do.
|
||||
yield None
|
||||
return
|
||||
|
||||
params = SerialParams.from_config(cfg)
|
||||
resolved = resolve_port(cfg.port, idn_substr=cfg.idn_substr, params=params)
|
||||
if resolved is None:
|
||||
# Configured but not reachable. Treat the same as not present —
|
||||
# tests that need it will skip via the public ``psu`` fixture.
|
||||
yield None
|
||||
return
|
||||
|
||||
port, idn = resolved
|
||||
p = OwonPSU(
|
||||
port=port,
|
||||
params=params,
|
||||
eol=cfg.eol or "\n",
|
||||
safe_off_on_close=True, # session-end safety net
|
||||
)
|
||||
try:
|
||||
p.open()
|
||||
except SerialException:
|
||||
# Race: another process grabbed the port between resolve and
|
||||
# open. Tests that need the PSU will skip cleanly.
|
||||
yield None
|
||||
return
|
||||
|
||||
# Park at the configured nominal supply and enable output. Stays
|
||||
# this way for the whole session unless individual tests perturb
|
||||
# it (and restore in finally).
|
||||
nominal_v = float(cfg.set_voltage) if cfg.set_voltage else _FALLBACK_NOMINAL_VOLTAGE
|
||||
nominal_i = float(cfg.set_current) if cfg.set_current else _FALLBACK_NOMINAL_CURRENT
|
||||
p.set_voltage(1, nominal_v)
|
||||
p.set_current(1, nominal_i)
|
||||
p.set_output(True)
|
||||
time.sleep(_PSU_PARK_SETTLE_SECONDS)
|
||||
|
||||
print(
|
||||
f"\n[psu] session power on: port={port!r} idn={idn!r} "
|
||||
f"V={nominal_v} I_lim={nominal_i}"
|
||||
)
|
||||
try:
|
||||
yield p
|
||||
finally:
|
||||
# Session-end: close() sends ``output 0`` first because of
|
||||
# safe_off_on_close=True, then releases the port.
|
||||
p.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _psu_powers_bench(_psu_or_none):
|
||||
"""Autouse: realizes :func:`_psu_or_none` so the PSU comes up at
|
||||
session start even for tests that don't request ``psu`` by name.
|
||||
|
||||
Without this, a MUM-only test (which never references ``psu``)
|
||||
would never trigger PSU setup, and on a bench where the Owon
|
||||
powers the ECU the test would fail with "ECU not responding".
|
||||
|
||||
No assertions, no skips — purely a lifecycle hook.
|
||||
"""
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def psu(_psu_or_none) -> OwonPSU:
|
||||
"""Public PSU fixture for tests that read measurements or perturb voltage.
|
||||
|
||||
Skips cleanly when the PSU isn't configured / reachable so tests
|
||||
targeting the PSU stay portable across benches that don't have one
|
||||
wired up.
|
||||
"""
|
||||
if _psu_or_none is None:
|
||||
pytest.skip(
|
||||
"PSU not available (config.power_supply.enabled=false, "
|
||||
"no port configured, or port not reachable)."
|
||||
)
|
||||
return _psu_or_none
|
||||
@ -1,102 +1,122 @@
|
||||
import time
|
||||
"""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
|
||||
import serial
|
||||
|
||||
from ecu_framework.power import OwonPSU, SerialParams
|
||||
from ecu_framework.config import EcuTestConfig
|
||||
from ecu_framework.power import OwonPSU
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.hardware]
|
||||
|
||||
|
||||
def test_owon_psu_idn_and_optional_set(config: EcuTestConfig, rp):
|
||||
def test_owon_psu_idn_and_measurements(config: EcuTestConfig, psu: OwonPSU, rp):
|
||||
"""
|
||||
Title: Owon PSU - IDN, Output Status, Set/Measure Verification
|
||||
Title: Owon PSU — IDN, output state, and parsed measurements
|
||||
|
||||
Description:
|
||||
Validates serial SCPI control of an Owon PSU: IDN retrieval, output status query,
|
||||
and optional set/measure cycle using values from central configuration.
|
||||
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. Load PSU config from EcuTestConfig.power_supply
|
||||
2. Open serial connection and query *IDN?
|
||||
3. Query output status (output?) and record initial state
|
||||
4. If configured, set voltage/current, enable output briefly, measure V/I, then disable output
|
||||
5. Record IDN, output status before/after, set values, and measured values in the report
|
||||
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? returns a non-empty string (containing idn_substr if configured), serial operations succeed,
|
||||
and, when enabled, the output toggles on then off with measurements returned.
|
||||
- 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
|
||||
if not psu_cfg.enabled:
|
||||
pytest.skip("Power supply tests disabled in config.power_supply.enabled")
|
||||
if not psu_cfg.port:
|
||||
pytest.skip("No power supply 'port' configured (config.power_supply.port)")
|
||||
want_substr = psu_cfg.idn_substr
|
||||
expected_v = float(psu_cfg.set_voltage) if psu_cfg.set_voltage else None
|
||||
|
||||
# Serial params (with sensible defaults via central config)
|
||||
baud = int(psu_cfg.baudrate)
|
||||
timeout = float(psu_cfg.timeout)
|
||||
parity = psu_cfg.parity or "N"
|
||||
stopbits = psu_cfg.stopbits or 1
|
||||
xonxoff = bool(psu_cfg.xonxoff)
|
||||
rtscts = bool(psu_cfg.rtscts)
|
||||
dsrdtr = bool(psu_cfg.dsrdtr)
|
||||
eol = psu_cfg.eol or "\n"
|
||||
# ── 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()
|
||||
|
||||
ps = SerialParams(
|
||||
baudrate=baud,
|
||||
timeout=timeout,
|
||||
parity={"N": serial.PARITY_NONE, "E": serial.PARITY_EVEN, "O": serial.PARITY_ODD}.get(str(parity).upper(), serial.PARITY_NONE),
|
||||
stopbits={1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO}.get(int(float(stopbits)), serial.STOPBITS_ONE),
|
||||
xonxoff=xonxoff,
|
||||
rtscts=rtscts,
|
||||
dsrdtr=dsrdtr,
|
||||
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}"
|
||||
)
|
||||
|
||||
want_substr = psu_cfg.idn_substr
|
||||
do_set = bool(psu_cfg.do_set)
|
||||
set_v = float(psu_cfg.set_voltage)
|
||||
set_i = float(psu_cfg.set_current)
|
||||
# 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)."
|
||||
)
|
||||
|
||||
port = str(psu_cfg.port).strip()
|
||||
# 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"
|
||||
)
|
||||
|
||||
with OwonPSU(port, ps, eol=eol) as psu:
|
||||
# Step 2: IDN
|
||||
idn = psu.idn()
|
||||
rp("psu_idn", idn)
|
||||
print(f"PSU IDN: {idn}")
|
||||
assert isinstance(idn, str)
|
||||
assert 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}. Got: {idn}"
|
||||
|
||||
# Step 3: Output status before
|
||||
out_before = psu.output_status()
|
||||
rp("output_status_before", str(out_before))
|
||||
print(f"Output status (before): {out_before}")
|
||||
|
||||
if do_set:
|
||||
# Step 4: Set and measure
|
||||
rp("set_voltage", set_v)
|
||||
rp("set_current", set_i)
|
||||
print(f"Setting: voltage={set_v}V, current={set_i}A")
|
||||
|
||||
psu.set_voltage(1, set_v)
|
||||
psu.set_current(1, set_i)
|
||||
psu.set_output(True)
|
||||
time.sleep(1.0) # allow settling
|
||||
|
||||
try:
|
||||
mv = psu.measure_voltage()
|
||||
mi = psu.measure_current()
|
||||
rp("measured_voltage", mv)
|
||||
rp("measured_current", mi)
|
||||
print(f"Measured: voltage={mv}V, current={mi}A")
|
||||
finally:
|
||||
psu.set_output(False)
|
||||
|
||||
out_after = psu.output_status()
|
||||
rp("output_status_after", str(out_after))
|
||||
print(f"Output status (after): {out_after}")
|
||||
# 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."
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user