ecu-tests/docs/14_power_supply.md
Hosam-Eldin Mostafa afd9da8206 docs: hardware test infrastructure, session-managed PSU, settle-then-validate
Documents the new layers introduced over the past several commits.

- docs/19_frame_io_and_alm_helpers.md (new): full reference for the
  FrameIO and AlmTester helpers — three access levels (high/mid/low),
  full API tables, fixture wiring, cookbook patterns, and §7
  describing the four-phase SETUP/PROCEDURE/ASSERT/TEARDOWN test
  pattern with the three template flavors plus a §7.4 link to the
  PSU+LIN template.

- docs/14_power_supply.md: rewritten and expanded.
    §3 cross-platform port resolution (Windows / WSL1 / WSL2 +
       usbipd-win / Linux native compatibility table)
    §4 auto-detection via idn_substr
    §5 session-managed power: contract for tests, must-not list,
       what changed in the existing tests
    §6 the settle-then-validate pattern: two-delays table (PSU
       bench-dependent vs ECU firmware-dependent), copy-paste
       example, tuning guidance for ECU_VALIDATION_TIME_S
    §6 PSU settling characterization (-m psu_settling)
    §7 library API reference table + safe_off_on_close
    §9 troubleshooting expanded with WSL2 usbipd-win + dialout

- docs/18_test_catalog.md: voltage-tolerance section refreshed for
  the settle-then-validate shape, new "Hardware – PSU settling
  (opt-in)" category, new §8 "Hardware-test infrastructure"
  documenting conftest.py, frame_io.py, alm_helpers.py,
  psu_helpers.py, and both templates.

- docs/05_architecture_overview.md: components list split into
  framework core / hardware test layer / artifacts. Mermaid diagram
  gained a Hardware-test helpers subgraph showing FrameIO,
  AlmTester, rgb_to_pwm, and the templates. Data/control flow
  summary describes the session-managed PSU and the helper layer.

- docs/15_report_properties_cheatsheet.md: PSU section split into
  per-test (function-scoped rp) and module-scoped (testsuite
  property) blocks; added psu_resolved_port, psu_resolved_idn,
  psu_settled_s, validation_time_s.

- docs/README.md: links to the new doc 19.

- README.md, TESTING_FRAMEWORK_GUIDE.md: project-structure trees
  expanded to show the full current layout — every file and
  directory under tests/hardware/ (conftest, helpers, templates,
  tests), tests/unit/, config/, docs/, scripts/, and vendor/.

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

19 KiB

Power Supply (Owon) — control, configuration, tests, and quick demo

This guide covers driving the Owon bench power supply via SCPI over a serial link, plus the cross-platform port resolver and the safety guarantees the controller class provides.

MUM users: the Melexis Universal Master has its own power output on power_out0 and the MUM adapter calls power_up() / power_down() in connect() / disconnect() automatically. The Owon PSU is not required for the standard MUM flow — leave power_supply.enabled: false. The Owon remains useful for over/under-voltage scenarios, separate-rail tests, or when running with the deprecated BabyLIN adapter (which has no built-in power).

Artifact Path
Controller library ecu_framework/power/owon_psu.py
Hardware test tests/hardware/test_owon_psu.py
Quick demo script vendor/Owon/owon_psu_quick_demo.py
Central config config/test_config.yamlpower_supply
Per-machine override config/owon_psu.yaml or env OWON_PSU_CONFIG

1. Install dependencies

pip install -r .\requirements.txt

pyserial is the only non-stdlib dep used by the controller.


2. Configure

Settings can live centrally in config/test_config.yaml or be peeled out into a machine-specific config/owon_psu.yaml (or any path set via OWON_PSU_CONFIG). The loader merges the per-machine file into the central power_supply section.

power_supply:
  enabled: true
  port: COM7           # see §3 for cross-platform behaviour
  baudrate: 115200
  timeout: 1.0
  eol: "\n"          # or "\r\n" if your device requires CRLF
  parity: N            # N|E|O
  stopbits: 1          # 1|1.5|2
  xonxoff: false
  rtscts: false
  dsrdtr: false
  idn_substr: OWON     # optional — see §4 (auto-detection)
  do_set: false
  set_voltage: 5.0
  set_current: 0.1

Field reference

Field Default Meaning
enabled false Master gate. Tests/utilities skip when false.
port null Bench port name. See §3 — works for COM7 or /dev/ttyUSB0 and translates between them.
baudrate 115200 Serial bit rate.
timeout 1.0 Read timeout in seconds.
eol "\n" Line terminator appended to every command and expected on every response.
parity "N" One of N, E, O. Translated to pyserial constants by SerialParams.from_config().
stopbits 1 One of 1, 1.5, 2.
xonxoff / rtscts / dsrdtr false Flow control flags.
idn_substr null Optional substring (case-insensitive) the device's *IDN? must contain to be accepted. Used as the filter when scanning ports for auto-detection.
do_set false If true, the hardware test runs the set/measure cycle (sets V/I, enables output briefly, measures, disables).
set_voltage / set_current 5.0 / 0.1 Setpoints used when do_set: true.

3. Cross-platform port resolution

A bench config typically names the port the way Windows sees it (COM7). The resolver lets the same config work on Windows, Linux, and WSL by trying multiple candidates in priority order.

What the resolver does

resolve_port(configured, *, idn_substr, params) walks four phases and returns the first port whose *IDN? response is non-empty (filtered by idn_substr if given):

Phase What's tried Use case
1 configured verbatim Windows native — COM7 opens directly.
2 Cross-platform translation COM7/dev/ttyS6 on WSL1; /dev/ttyS6COM7 on Windows.
3 Linux USB-serial paths /dev/ttyUSB* and /dev/ttyACM* — covers WSL2 with usbipd-win plus generic Linux USB adapters. Linux/WSL only.
4 Full scan_ports() Last resort — probes every serial port pyserial reports.

Linux device files that don't exist on disk are skipped without an open attempt, so the resolver is fast even on machines with many phantom ttyS* entries.

What works on each platform with port: COM7

Host What happens
Windows native Phase 1 hits COM7 directly.
WSL1 Phase 1 fails on COM7, Phase 2 finds /dev/ttyS6 (the COM7 mapping).
WSL2 + usbipd-win Phase 1+2 fail, Phase 3 finds the attached adapter at /dev/ttyUSB0.
Linux native (USB adapter) Phases 1+2 fail, Phase 3 finds /dev/ttyUSB0.

The resolved port is recorded in the JUnit testsuite properties as psu_resolved_port (and the IDN as psu_resolved_idn), so report viewers can see which path was used.

Translation helpers

Useful as building blocks if you need to do the mapping yourself:

from ecu_framework.power import (
    windows_com_to_linux, linux_serial_to_windows,
    candidate_ports, resolve_port,
)

windows_com_to_linux("COM7")          # → "/dev/ttyS6"
windows_com_to_linux("com10")         # → "/dev/ttyS9"
linux_serial_to_windows("/dev/ttyS6") # → "COM7"

# What resolve_port will try, in order, for port="COM7" on Linux:
candidate_ports("COM7")
# → ['COM7', '/dev/ttyS6', '/dev/ttyUSB0', '/dev/ttyUSB1', '/dev/ttyACM0', ...]

4. Auto-detection

Leave port empty and set idn_substr to let the resolver scan:

power_supply:
  enabled: true
  port:                # ← empty
  idn_substr: OWON     # ← required so we don't grab a different SCPI device
  ...

With no port, Phase 1 and Phase 2 short-circuit; Phase 3 (Linux USB paths) and Phase 4 (full scan) do the work. The first port whose IDN contains OWON (case-insensitive) wins.

Tip: without idn_substr, any device that responds to *IDN? on any port is accepted — fine when the PSU is the only SCPI thing attached, risky otherwise. Always set idn_substr if your bench has other SCPI hardware.


5. Session-managed power (the bench powers the ECU through the PSU)

On benches where the Owon PSU powers the ECU (the MUM only carries LIN traffic), the PSU output must stay on for the entire test session — not just the duration of an individual PSU test. Otherwise every test that runs after a closed PSU connection would brown out the ECU and fail.

The hardware-suite conftest (tests/hardware/conftest.py) implements this with three session-scoped fixtures:

Fixture Scope Role
_psu_or_none session Tolerant: opens the PSU once, parks at set_voltage / set_current, enables output. Yields the live OwonPSU or None if unreachable. Closes (with output 0) at session end.
_psu_powers_bench session, autouse Realizes _psu_or_none. Every hardware test triggers PSU power-up at session start, even tests that don't request psu by name.
psu session Public fixture for tests that read measurements or perturb voltage. Skips cleanly when the PSU isn't available.

What this means for tests

Tests should:

  • Request psu if they need to read measurements or change the supply voltage.
  • Always restore nominal voltage in their finally block — the session fixture won't restore it between tests.

Tests must not:

  • Call psu.set_output(False) — this kills ECU power for every later test in the same session.
  • Call psu.close() — the session fixture owns the lifecycle.

What changed in the existing tests

  • tests/hardware/test_owon_psu.py is now read-only: it queries *IDN?, output?, and the parsed measurement helpers, but doesn't toggle the output. The previous toggle-and-restore cycle has been deleted because it would brown out the bench mid-session.
  • tests/hardware/_test_case_template_psu_lin.py drops its local psu fixture and uses the conftest's. Its autouse _park_at_nominal only restores voltage between tests — it never toggles output.

6. Run the hardware test

Skips cleanly unless power_supply.enabled is true, a port can be resolved, and the device responds to *IDN?.

pytest -k test_owon_psu_idn_and_optional_set -m hardware -q

What it does:

  1. Resolves a working port via resolve_port(...) (cross-platform, IDN-verified).
  2. Queries *IDN? and the initial output? state.
  3. If do_set is true: sets V/I, enables output, waits, measures, disables output. The measure/disable pair lives in an inner try/finally so the disable runs even if measurement raises.
  4. Records IDN, before/after output state, setpoints, and parsed measurements as report properties.
  5. The fixture's safe_off_on_close=True is a backstop — it will send output 0 once more when the port closes.

The test follows the four-phase SETUP / PROCEDURE / ASSERT / TEARDOWN pattern from the template because it mutates real bench state.

Voltage changes go through two delays — and confusing them is the single most common source of flaky tests:

Delay Source Bench-dependent?
PSU settling Owon needs time to slew its output to the new setpoint Yes — depends on PSU model, load, cable drop. Different up-step / down-step times in practice.
ECU validation Firmware samples its supply rail, debounces, and republishes status on its 10 ms LIN cycle No (firmware-dependent, but constant for a given build)

The shared helper tests/hardware/psu_helpers.py exposes apply_voltage_and_settle() which separates the two cleanly:

from psu_helpers import apply_voltage_and_settle

result = apply_voltage_and_settle(
    psu, OVERVOLTAGE_V,
    validation_time=ECU_VALIDATION_TIME_S,    # firmware budget
)
# By here:
#   - PSU output is measurably at OVERVOLTAGE_V (within ±0.10 V)
#   - validation_time has elapsed since the rail settled
# So a single status read is unambiguous:
status = fio.read_signal("ALM_Status", "ALMVoltageStatus")
assert status == VOLTAGE_STATUS_OVER

What apply_voltage_and_settle does internally:

  1. psu.set_voltage(1, target_v) — issue the setpoint.
  2. Polls measure_voltage_v() every 50 ms until the rail is within ±100 mV of target (or raises AssertionError on timeout).
  3. time.sleep(validation_time) — hold the steady rail.
  4. Returns {settled_s, validation_s, final_v, trace} for reporting.

The poll-the-meter approach means the function works on any bench without re-tuning sleeps. Up-step and down-step are handled identically — each waits as long as that specific transition takes.

To pick ECU_VALIDATION_TIME_S, run the characterization in §6.1 to learn your PSU's slew time, then add a margin for the firmware's detection-and-debounce window. Default 1.0 s is conservative for most automotive ECUs. Tests that change voltage many times should use the smallest validation time their firmware tolerates.

Characterizing PSU settling time

Voltage-tolerance tests need to wait long enough after a setpoint change for the PSU's output to actually reach the new voltage. The right wait depends on the PSU model and the load. To extract real numbers, run the dedicated characterization test:

pytest -m psu_settling -s

tests/hardware/test_psu_voltage_settling.py walks four transitions (13 V↔18 V, 13 V↔7 V), polls measure_voltage_v() every 50 ms until the rail is within ±100 mV of target, and records settling_time_s plus a downsampled voltage trace per case. The test is marked psu_settling + slow so it doesn't run on every -m hardware invocation — it's meant for periodic re-tuning, not every CI run.

Use the recorded settling times to size constants like VOLTAGE_DETECT_TIMEOUT in test_overvolt.py: the timeout has to exceed both the PSU's settling time and the ECU's detection delay, so add a margin to the larger of the two.

Writing a PSU+LIN test (over/undervoltage etc.)

For tests that combine PSU control with LIN observation — e.g. overvoltage / undervoltage tolerance — there's a dedicated copy-paste-ready template at tests/hardware/_test_case_template_psu_lin.py. It contains:

  • The three module-scoped fixtures (fio, alm, psu) wired with cross-platform port resolution and safe_off_on_close=True.
  • An autouse _park_at_nominal fixture that parks the PSU at NOMINAL_VOLTAGE and the LED OFF before AND after every test, so failures don't leak supply state between tests.
  • A wait_for_voltage_status(fio, target, …) helper that polls ALM_Status.ALMVoltageStatus until it matches.
  • Three flavors:
    Flavor Demonstrates
    A Overvoltage detection — drive PSU above OV threshold, expect ALMVoltageStatus = 0x02, restore.
    B Undervoltage detection — symmetric for UV (0x01).
    C Parametrized voltage sweep walking (V, expected_status) tuples.

Tune the four constants at the top of the file (NOMINAL_VOLTAGE, OVERVOLTAGE_V, UNDERVOLTAGE_V, SET_CURRENT_A) to your ECU's datasheet before running on real hardware. The defaults are conservative automotive ranges.


7. Library API

from ecu_framework.power import (
    SerialParams, OwonPSU, resolve_port,
    scan_ports, auto_detect, try_idn_on_port,
)

SerialParams

Plain dataclass for serial-port settings. Build directly, or from the project's PSU config:

params = SerialParams(baudrate=115200, timeout=1.0)
# or
params = SerialParams.from_config(config.power_supply)   # translates 'N'/'1' → pyserial constants

OwonPSU

Context-managed controller. Two construction paths:

# Manual:
psu = OwonPSU(port="COM4", params=params, eol="\n")

# From central config (recommended):
psu = OwonPSU.from_config(config.power_supply)

Then either use as a context manager or call open() / close() by hand. Both forms send output 0 before closing the port if safe_off_on_close=True (the default).

with OwonPSU.from_config(cfg) as psu:
    print(psu.idn())                      # *IDN?
    psu.set_voltage(1, 5.0)               # SOUR:VOLT 5.000
    psu.set_current(1, 0.1)               # SOUR:CURR 0.100
    psu.set_output(True)                  # output 1
    v = psu.measure_voltage_v()           # MEAS:VOLT? → float
    i = psu.measure_current_a()           # MEAS:CURR? → float
    is_on = psu.output_is_on()            # output? → True/False/None
# safe_off_on_close=True turned the output OFF before the port closed

Method reference

Method SCPI sent Returns
idn() *IDN? str
set_voltage(channel, volts) SOUR:VOLT <V> None. channel is currently ignored — placeholder for multi-channel firmware.
set_current(channel, amps) SOUR:CURR <A> None
set_output(on) output 1/output 0 None. Note: dialect uses lowercase output, not OUTP ON.
output_status() output? Raw str ('ON'/'OFF'/'1'/'0').
output_is_on() output? bool (or None if unparseable).
measure_voltage() MEAS:VOLT? Raw str.
measure_voltage_v() MEAS:VOLT? float (V) or None.
measure_current() MEAS:CURR? Raw str.
measure_current_a() MEAS:CURR? float (A) or None.
query(s) s Single-line str response (with newline stripped).
write(s) s None. No response read.

Safety: safe_off_on_close

OwonPSU(safe_off_on_close=True) (the default) sends output 0 before the serial port closes. This protects against leaving the bench powered on after an aborted test, an exception in user code, or a forgotten manual close. Errors during the safe-off attempt are swallowed so the close itself always completes.

Pass safe_off_on_close=False only when you specifically need the output to stay enabled across context-manager boundaries. The discovery helper try_idn_on_port opts out by default since it shouldn't drive the bench in either direction.

Discovery helpers

# Probe one port, return its IDN (or "" on failure):
try_idn_on_port("COM7", params)

# Scan every serial port; returns [(port, idn), ...] for responders:
scan_ports(params)

# Pick the first responder matching idn_substr (or first responder if no substring):
auto_detect(params, idn_substr="OWON")

# Cross-platform resolver (recommended): tries the configured port,
# its translation, USB-serial paths, then a full scan. Returns
# (port, idn) or None.
resolve_port("COM7", idn_substr="OWON", params=params)

8. Quick demo script

The quick demo reads OWON_PSU_CONFIG or config/owon_psu.yaml and performs a short sequence using the same library.

python .\vendor\Owon\owon_psu_quick_demo.py

It also scans ports with *IDN? via scan_ports() to help confirm which port the device is on before you commit it to the YAML.


9. Troubleshooting

Empty *IDN? / timeouts

  • Verify the port and exclusivity — no other program may hold it open.
  • Try eol: "\r\n" if your firmware revision expects CRLF.
  • Adjust parity and stopbits per your device manual.
  • Power-cycle the PSU and re-attempt — some firmware revisions need a fresh boot before they accept SCPI.

Could not find a working PSU port

The fixture skips with this message when resolve_port returns None. Things to check, in order:

  1. Is the device powered and connected?
  2. Does another process (Putty, Owon's own tool, an old test session) still hold the port?
  3. Does your user have permission to open the device file? On Debian-style systems: sudo usermod -aG dialout $USER and re-login.
  4. WSL2 specifically: USB-serial adapters need usbipd-win to bind the device into the Linux side. Once attached they appear at /dev/ttyUSB0 and the resolver's Phase 3 picks them up automatically.
  5. WSL1: COMx → /dev/ttySn mapping is automatic. If /dev/ttyS6 doesn't exist for COM7, the bench probably has Windows COM port numbering you weren't expecting — list with ls /dev/ttyS* and try linux_serial_to_windows() to confirm.

Windows COM > 9

Most Python tooling (including pyserial) accepts COM10 directly. If a third-party tool needs the long form, use \\.\COM10. The translator in this repo accepts any positive integer.

Flow control

Keep xonxoff, rtscts, dsrdtr set to false unless your specific PSU model requires otherwise — the Owon family used in this project doesn't.


File Purpose
ecu_framework/power/owon_psu.py Controller library (SerialParams, OwonPSU, resolver helpers).
tests/hardware/test_owon_psu.py Hardware test wired to central config.
vendor/Owon/owon_psu_quick_demo.py Quick demo runner.
config/owon_psu.example.yaml Example per-machine YAML.
tests/hardware/_test_case_template.py Copyable starting point for new hardware tests.
docs/19_frame_io_and_alm_helpers.md The four-phase test pattern and the FrameIO / AlmTester helpers.
docs/15_report_properties_cheatsheet.md Standard rp(...) keys including the PSU ones (psu_idn, psu_resolved_port, …).