Files
ovp-test/BE-RCU-ovp_test_csv.py
T

1984 lines
81 KiB
Python

#!/usr/bin/env python
"""
BE OVP (over-voltage protection) cycling test.
Coordinates four Modbus RTU devices, each on its own COM port:
RCU - Beyond receiver unit (a.k.a. RPU). Charging command + telemetry.
TPU - Beyond transmitter unit. Telemetry (mirrors RCU values over RF).
RELAY - Phoenix/Fenghuang-family board carrying the PFC relay used to
create the over-voltage condition (coil 12 = PFC_relay_enable).
JIG - XYZ positioner.
The RCU is powered THROUGH the relay: with the relay open it is dead and
answers nothing. So the relay is closed and given RELAY_SETTLE_S to boot the
RCU before any RCU access anywhere in this script - the link test included -
and while the relay is open the RCU telemetry columns are left blank.
Two scenarios, picked with --scenario. Both establish charging and then cut
the relay; they differ only in when the cut lands.
static (default), jig optional:
1. Relay control = 1, wait RELAY_SETTLE_S for the RCU to power up.
2. Jig -> NEAR_POSITION, settle.
3. RCU charging cmd = 1, wait for RCU main_state == CHARGING(5) and output
current above the threshold held continuously for the dwell.
4. Relay control = 0 (this also cuts RCU power).
5. Jig -> FAR_POSITION, keep monitoring for --observe seconds.
6. RCU charging cmd = 0, settle, next cycle.
move, jig required - the cut lands while the coupling is still changing:
1-3. As above, but parked at (0, 0, --move-from).
4. Jig -> (0, 0, --move-to), relay cut at half the travel time.
5. RCU charging cmd = 0, settle.
6. Jig back to (0, 0, --move-from), last, so the whole post-cut window is
observed at a standstill.
A background monitor thread samples RCU and TPU input registers every
MONITOR_INTERVAL_S for the whole run and appends each sample to the CSV.
Usage:
python BE-RCU-ovp_test_csv.py -r COM4 -t COM10 -l COM13 -j COM9 --link-test
python BE-RCU-ovp_test_csv.py -r COM4 -t COM10 -l COM13 -n 1
python BE-RCU-ovp_test_csv.py -r COM4 -t COM10 -l COM13 -j COM9 \
--scenario move --move-from 20 --move-to 0 -n 1
"""
import argparse
import csv
import datetime
import logging
import os
import sys
import threading
import time
import minimalmodbus
# ---------------------------------------------------------------------------
# Serial / Modbus link
# ---------------------------------------------------------------------------
SLAVE_ID = 0x10 # all four devices share slave id 16
BAUDRATE = 9600
SERIAL_TIMEOUT = 2.0 # seconds
CLOSE_TIMEOUT_S = 3.0 # give up on a port that will not close
# ---------------------------------------------------------------------------
# Register map
#
# Sources (in order of authority):
# [1] be-rssi-auto-en/charging_test.py - RCU/TPU "beyond" family classes
# [2] be-rssi-auto-en/base-definition.md - BE system register spec
# [3] be-rssi-auto-en/be_corner_test.py - working BE jig/charging test
# [4] pegasus/config-fenghuang/config.json - phoenix-family board (relay)
# [5] df-code/main.cpp - firmware-side main_state enum
#
# All telemetry below is read with function code 4 (input registers); commands
# are coils (function code 5) and jig positions are holding registers (fc 6).
# ---------------------------------------------------------------------------
# --- RCU / RPU, "beyond" family [1][2] -------------------------------------
RCU_CHARGING_CMD_ADDRESS = 0 # coil - 1 = start charging, 0 = stop
RCU_RANGE_CHECK_CMD_ADDRESS = 2 # coil - not used by this test
RCU_SHADOW_ERROR_LO_ADDRESS = 0 # input
RCU_SHADOW_ERROR_HI_ADDRESS = 1 # input
RCU_DC_BUS_VOLTAGE_ADDRESS = 30 # input, Q128 (128 counts = 1 V)
RCU_OUTPUT_CURRENT_ADDRESS = 31 # input, Q128 (128 counts = 1 A)
RCU_MAIN_PRE_STATE_ADDRESS = 50 # input
RCU_MAIN_STATE_ADDRESS = 51 # input
RCU_ERROR_CODE_LO_ADDRESS = 52 # input
RCU_ERROR_CODE_HI_ADDRESS = 53 # input
RCU_OUTPUT_VOLTAGE_ADDRESS = 106 # input, Q128 (phoenix family uses 32)
RCU_VF_STATUS_ADDRESS = 41 # input - 0 = RUN_PI_1, 1 = RUN_PI_3, 2 = RUN_VF_2
RCU_VF_FREQ_ADDRESS = 42 # input - TX switching frequency
RCU_RUNTIME_CURRENT_ADDRESS = 9 # holding, Q128 - the current actually in use.
# Only writable once charging is running: before
# that the app overwrites it from current_setting
# (reg 14), so an early write is lost.
Q128_DIVISOR = 128.0
# --- TPU, "beyond" family [1][2] -------------------------------------------
TPU_PRI_ENABLE_RF_ADDRESS = 30 # coil - not used by this test
TPU_DC_BUS_VOLTAGE_ADDRESS = 38 # input, float32 (2 regs, big-endian)
TPU_OUTPUT_CURRENT_ADDRESS = 40 # input, float32
TPU_MAIN_PRE_STATE_ADDRESS = 49 # input
TPU_MAIN_STATE_ADDRESS = 50 # input
TPU_ERROR_CODE_LO_ADDRESS = 61 # input
TPU_ERROR_CODE_HI_ADDRESS = 62 # input
TPU_SHADOW_ERROR_LO_ADDRESS = 63 # input
TPU_SHADOW_ERROR_HI_ADDRESS = 64 # input
TPU_OUTPUT_VOLTAGE_ADDRESS = 74 # input, float32
TPU_RCU_ERROR_CODE_HI_ADDRESS = 134 # input (note: hi is the LOWER address)
TPU_RCU_ERROR_CODE_LO_ADDRESS = 135 # input
# --- RELAY board, phoenix/fenghuang family [4] -----------------------------
RELAY_CONTROL_CMD_ADDRESS = 12 # coil - PFC_relay_enable, 1 = on
RELAY_MAIN_PRE_STATE_ADDRESS = 60 # input
RELAY_MAIN_STATE_ADDRESS = 61 # input
# --- JIG [1][2][3] ---------------------------------------------------------
JIG_CMD_ADDRESS = 30 # coil - 1 = move to position
JIG_X_ADDRESS = 90 # holding - mm, signed
JIG_Y_ADDRESS = 91 # holding - mm, signed
JIG_Z_ADDRESS = 92 # holding - mm, signed
# --- RCU main_state enum [5] -----------------------------------------------
RCU_STATE_IDLE = 1
RCU_STATE_HANDSHAKE = 2
RCU_STATE_PAIRED = 3
RCU_STATE_PRE_CHARGING = 4
RCU_STATE_CHARGING = 5
RCU_STATE_STOP = 6
RCU_STATE_ERROR = 238
RCU_STATE_CONFIGURATION = 240
# ---------------------------------------------------------------------------
# Test profile
# ---------------------------------------------------------------------------
NEAR_POSITION = (-30, 0, 20) # (x, y, z) mm - charging position
FAR_POSITION = (30, 0, 20) # (x, y, z) mm - move-away position
JIG_SETTLE_S = 5.0 # after the near move, before the relay
CHARGING_TIMEOUT_S = 60.0 # max wait for the charging criteria
CHARGING_DWELL_S = 10.0 # current must stay above threshold this long
CHARGING_CURRENT_THRESHOLD_A = 3.0
FAR_OBSERVE_S = 2.0 # keep monitoring after the cut
CYCLE_GAP_S = 5.0 # after charging cmd = 0
MONITOR_INTERVAL_S = 2.0 # telemetry sampling / CSV row period
RELAY_SETTLE_S = 2.0 # time to wait for relay to settle
RAMP_TIMEOUT_S = 30.0 # max wait for the current to reach the cut point
RAMP_TRIGGER_PCT = 50.0 # how far between the two setpoints the cut lands
# --- Scenario "move": cut the relay midway through a jig move --------------
MOVE_X_MM = 0 # held for the whole scenario; only Z moves
MOVE_Y_MM = 0
MOVE_Z_FROM_MM = 20 # where the move starts - the charging spot
MOVE_Z_TO_MM = 0 # where it ends
JIG_SPEED_MM_S = 10.0 # override with --jig-speed
JIG_MOVE_MAX_S = 5.0 # time allowed for one move, in or out
RESULTS_DIR = "results"
logger = logging.getLogger("ovp")
# ---------------------------------------------------------------------------
# Modbus plumbing
# ---------------------------------------------------------------------------
class Device:
"""One Modbus RTU device on its own serial port.
Every access takes the per-device lock: the monitor thread reads telemetry
while the main thread writes commands, and minimalmodbus instruments are
not safe for concurrent use.
"""
def __init__(self, port, name, slave_id=SLAVE_ID):
self.name = name
self.port = port
self._lock = threading.Lock()
self.inst = minimalmodbus.Instrument(
port=port, slaveaddress=slave_id, mode='rtu')
self.inst.serial.baudrate = BAUDRATE
self.inst.serial.timeout = SERIAL_TIMEOUT
self.inst.close_port_after_each_call = False
logger.info("%s connected on %s (slave id %d)", name, port, slave_id)
# Registers read by the link test, as (label, address, kind).
PROBE_REGISTERS = ()
# --- raw accessors -----------------------------------------------------
# The rig switches relays and transfers power right next to the RS485
# pair, so a corrupted frame now and then is expected: a single flipped
# bit shows up as a CRC error. Retrying is safe because every transaction
# here is idempotent - a read, or a write of an absolute value. Without
# this one bit of line noise ends a 100-cycle run.
IO_RETRIES = 4
IO_RETRY_DELAY = 0.15
def _io(self, fn, *args, **kwargs):
# retries=1 for a transaction whose failure is the expected outcome:
# retrying a device known to be unpowered only burns serial timeouts.
tries = kwargs.pop('retries', None) or self.IO_RETRIES
last = None
for attempt in range(tries):
try:
return fn(*args, **kwargs)
except minimalmodbus.ModbusException as exc:
last = exc
if attempt + 1 < tries:
logger.debug("%s retry %d/%d: %s", self.name,
attempt + 1, tries - 1, exc)
time.sleep(self.IO_RETRY_DELAY)
raise last
def read_u16(self, address):
with self._lock:
return self._io(self.inst.read_register, address, functioncode=4)
def read_holding_u16(self, address):
with self._lock:
return self._io(self.inst.read_register, address, functioncode=3)
def read_f32(self, address):
"""Read a big-endian IEEE-754 float from two input registers."""
with self._lock:
return self._io(self.inst.read_float, address,
functioncode=4, number_of_registers=2)
def write_coil(self, address, value, retries=None):
with self._lock:
self._io(self.inst.write_bit, address,
1 if value else 0, functioncode=5, retries=retries)
def write_holding(self, address, value):
# Jig coordinates may be negative; Modbus registers are unsigned.
with self._lock:
self._io(self.inst.write_register, address,
value & 0xFFFF, functioncode=6)
def probe(self):
"""Read PROBE_REGISTERS one at a time to prove the link is alive.
Returns a list of (label, address, kind, value, error) where exactly
one of value/error is None.
"""
reads = []
for label, address, kind in self.PROBE_REGISTERS:
try:
if kind == 'input':
value = self.read_u16(address)
elif kind == 'holding':
value = self.read_holding_u16(address)
elif kind == 'holding_signed':
raw = self.read_holding_u16(address)
value = raw - 0x10000 if raw > 0x7FFF else raw
elif kind == 'float':
value = round(self.read_f32(address), 3)
else:
raise ValueError("unknown probe kind: %s" % kind)
reads.append((label, address, kind, value, None))
except Exception as exc:
reads.append((label, address, kind, None, str(exc)))
return reads
def close(self):
# A USB adapter unplugged mid-run wedges serial.close() forever, and
# Ctrl+C cannot break a blocking Windows call. Close on a daemon
# thread so a dead port can never hold up the exit.
closer = threading.Thread(target=self._close_serial, daemon=True)
closer.start()
closer.join(CLOSE_TIMEOUT_S)
if closer.is_alive():
logger.warning("%s: %s did not close in %.0fs, abandoning it",
self.name, self.port, CLOSE_TIMEOUT_S)
def _close_serial(self):
try:
self.inst.serial.close()
logger.info("%s disconnected", self.name)
except Exception as exc:
logger.warning("%s: close failed: %s", self.name, exc)
def _safe(fn, *args):
"""Run a read and return None instead of raising, so one bad register
never kills the monitor thread mid-run."""
try:
return fn(*args)
except Exception as exc:
logger.debug("read failed: %s", exc)
return None
def _combine_error(lo, hi):
"""Join the lo/hi register halves into one 32-bit code as 8 hex digits.
Format matches be-rssi-auto-en/charging_test.py and the Beyond error code
map, so a value can be looked up there directly. An unreadable register
gives "" rather than 00000000, which would read as "no error".
"""
if lo is None or hi is None:
return ""
return "%08X" % ((hi << 16) | lo)
def _q128(raw):
return None if raw is None else round(raw / Q128_DIVISOR, 3)
def _round(value, digits=3):
return None if value is None else round(value, digits)
# ---------------------------------------------------------------------------
# Devices
# ---------------------------------------------------------------------------
# What a sample looks like when the RCU is unpowered (relay open).
BLANK_RCU_DATA = {
'main_pre_state': None,
'main_state': None,
'output_current': None,
'output_voltage': None,
'dc_bus_voltage': None,
'error_code': '',
'shadow_error': '',
'vf_status': None,
'vf_freq': None,
}
class RCU(Device):
PROBE_REGISTERS = (
('main_state', RCU_MAIN_STATE_ADDRESS, 'input'),
('main_pre_state', RCU_MAIN_PRE_STATE_ADDRESS, 'input'),
('output_current_q128', RCU_OUTPUT_CURRENT_ADDRESS, 'input'),
('output_voltage_q128', RCU_OUTPUT_VOLTAGE_ADDRESS, 'input'),
)
def __init__(self, port):
super().__init__(port, "RCU")
def set_charging(self, enable, retries=None):
logger.info("RCU charging cmd = %d", 1 if enable else 0)
self.write_coil(RCU_CHARGING_CMD_ADDRESS, enable, retries=retries)
def set_current(self, amps):
"""Runtime charging current, amps. Call only once charging is running."""
logger.info("RCU runtime current = %.1f A", amps)
self.write_holding(RCU_RUNTIME_CURRENT_ADDRESS, int(round(amps * 128)))
def read_data(self):
# Probe once: a dead RCU costs 9 x SERIAL_TIMEOUT otherwise.
try:
main_pre_state = self.read_u16(RCU_MAIN_PRE_STATE_ADDRESS)
except Exception as exc:
logger.debug("RCU did not answer: %s", exc)
return dict(BLANK_RCU_DATA)
return {
'main_pre_state': main_pre_state,
'main_state': _safe(self.read_u16, RCU_MAIN_STATE_ADDRESS),
'output_current': _q128(
_safe(self.read_u16, RCU_OUTPUT_CURRENT_ADDRESS)),
'output_voltage': _q128(
_safe(self.read_u16, RCU_OUTPUT_VOLTAGE_ADDRESS)),
'dc_bus_voltage': _q128(
_safe(self.read_u16, RCU_DC_BUS_VOLTAGE_ADDRESS)),
'error_code': _combine_error(
_safe(self.read_u16, RCU_ERROR_CODE_LO_ADDRESS),
_safe(self.read_u16, RCU_ERROR_CODE_HI_ADDRESS)),
'shadow_error': _combine_error(
_safe(self.read_u16, RCU_SHADOW_ERROR_LO_ADDRESS),
_safe(self.read_u16, RCU_SHADOW_ERROR_HI_ADDRESS)),
# Which power zone the TX is in. The SR swaps its duty and its phase
# compensation on this, so a trip in VF is not the same event as a
# trip in PI - without this column the run cannot be interpreted.
'vf_status': _safe(self.read_u16, RCU_VF_STATUS_ADDRESS),
'vf_freq': _safe(self.read_u16, RCU_VF_FREQ_ADDRESS),
}
class TPU(Device):
PROBE_REGISTERS = (
('main_state', TPU_MAIN_STATE_ADDRESS, 'input'),
('main_pre_state', TPU_MAIN_PRE_STATE_ADDRESS, 'input'),
('rcu_output_current', TPU_OUTPUT_CURRENT_ADDRESS, 'float'),
('rcu_output_voltage', TPU_OUTPUT_VOLTAGE_ADDRESS, 'float'),
)
def __init__(self, port):
super().__init__(port, "TPU")
def read_data(self):
return {
'main_pre_state': _safe(self.read_u16, TPU_MAIN_PRE_STATE_ADDRESS),
'main_state': _safe(self.read_u16, TPU_MAIN_STATE_ADDRESS),
'output_current': _round(
_safe(self.read_f32, TPU_OUTPUT_CURRENT_ADDRESS)),
'output_voltage': _round(
_safe(self.read_f32, TPU_OUTPUT_VOLTAGE_ADDRESS)),
'dc_bus_voltage': _round(
_safe(self.read_f32, TPU_DC_BUS_VOLTAGE_ADDRESS)),
'error_code': _combine_error(
_safe(self.read_u16, TPU_ERROR_CODE_LO_ADDRESS),
_safe(self.read_u16, TPU_ERROR_CODE_HI_ADDRESS)),
'shadow_error': _combine_error(
_safe(self.read_u16, TPU_SHADOW_ERROR_LO_ADDRESS),
_safe(self.read_u16, TPU_SHADOW_ERROR_HI_ADDRESS)),
'rcu_error_code': _combine_error(
_safe(self.read_u16, TPU_RCU_ERROR_CODE_LO_ADDRESS),
_safe(self.read_u16, TPU_RCU_ERROR_CODE_HI_ADDRESS)),
}
class Relay(Device):
PROBE_REGISTERS = (
('main_state', RELAY_MAIN_STATE_ADDRESS, 'input'),
('main_pre_state', RELAY_MAIN_PRE_STATE_ADDRESS, 'input'),
)
def __init__(self, port):
super().__init__(port, "RELAY")
def set_control(self, enable):
logger.info("RELAY control cmd = %d", 1 if enable else 0)
self.write_coil(RELAY_CONTROL_CMD_ADDRESS, enable)
def read_data(self):
return {'main_state': _safe(self.read_u16, RELAY_MAIN_STATE_ADDRESS)}
class Jig(Device):
# The jig exposes no input registers, so the link test reads its position
# holding registers back instead - harmless, and it proves the link.
PROBE_REGISTERS = (
('x_position_mm', JIG_X_ADDRESS, 'holding_signed'),
('y_position_mm', JIG_Y_ADDRESS, 'holding_signed'),
('z_position_mm', JIG_Z_ADDRESS, 'holding_signed'),
)
def __init__(self, port):
super().__init__(port, "JIG")
def move_to(self, x, y, z):
"""Command a move. Returns the perf_counter() of the go command."""
logger.info("JIG move to (%d, %d, %d) mm", x, y, z)
self.write_holding(JIG_X_ADDRESS, x)
self.write_holding(JIG_Y_ADDRESS, y)
self.write_holding(JIG_Z_ADDRESS, z)
self.write_coil(JIG_CMD_ADDRESS, True)
return time.perf_counter()
# ---------------------------------------------------------------------------
# RCU power
# ---------------------------------------------------------------------------
def power_up_rcu(relay, settle_s=None):
"""Close the relay and wait for the RCU to boot.
The RCU is fed through the relay, so it is dead - and will not answer a
single Modbus request - until the relay is on. Every RCU access, reads
included, has to come after this.
"""
# Read the constant here, not as a default, so it stays overridable.
settle_s = RELAY_SETTLE_S if settle_s is None else settle_s
relay.set_control(True)
logger.info("Relay closed - waiting %.0fs for the RCU to power up...",
settle_s)
time.sleep(settle_s)
# ---------------------------------------------------------------------------
# Link test
# ---------------------------------------------------------------------------
def link_test(devices):
"""Read a handful of registers from every device and report per port.
Returns (all_ok, any_dead): all_ok is False if any single register could
not be read, any_dead is True if some device answered nothing at all -
which usually means a wrong or swapped COM port.
"""
logger.info("-" * 62)
logger.info("LINK TEST")
all_ok = True
any_dead = False
for dev in devices:
reads = dev.probe()
good = sum(1 for r in reads if r[4] is None)
if good == len(reads):
status = "OK"
elif good:
status = "PARTIAL"
all_ok = False
else:
status = "NO REPLY"
all_ok = False
any_dead = True
logger.info(" %-6s %-10s %-9s %d/%d registers",
dev.name, dev.port, status, good, len(reads))
for label, address, kind, value, err in reads:
if err is None:
logger.info(" %-21s %-14s @%-4d = %s",
label, kind, address, value)
else:
logger.warning(" %-21s %-14s @%-4d ! %s",
label, kind, address, err)
logger.info("-" * 62)
return all_ok, any_dead
def run_link_test(rcu_port, tpu_port, relay_port, jig_port):
"""Stand-alone link test: open every port, probe it, close it.
Returns True only if all four devices answered every probe.
"""
devices = []
connect_ok = True
wanted = [(RCU, rcu_port), (TPU, tpu_port), (Relay, relay_port)]
if jig_port:
wanted.append((Jig, jig_port))
else:
logger.info("No jig port given - skipping the jig.")
for cls, port in wanted:
try:
devices.append(cls(port))
except Exception as exc:
logger.error("%s on %s: connect failed: %s",
cls.__name__.upper(), port, exc)
connect_ok = False
relay = next((d for d in devices if isinstance(d, Relay)), None)
try:
# The RCU has no power until the relay is on, so it could not answer
# a probe before this.
if relay is not None:
power_up_rcu(relay)
else:
logger.warning("No relay - the RCU is unpowered, so its probe "
"will fail regardless of wiring.")
all_ok, _any_dead = link_test(devices) if devices else (False, True)
finally:
# Leave the rig de-energised again.
if relay is not None:
try:
relay.set_control(False)
except Exception as exc:
logger.warning("Could not switch relay off: %s", exc)
for dev in devices:
dev.close()
result = connect_ok and all_ok
logger.info("Link test: %s", "PASS" if result else "FAIL")
return result
# ---------------------------------------------------------------------------
# CSV
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Scope
#
# One capture per cycle, taken at the relay cut: armed single-shot just before
# the cut and read back once it has triggered.
#
# The gap between SR2H turning off and SR2L turning on is computed here from
# the raw waveform, not from a scope parameter. A parameter reports whichever
# edge pair it finds and that is almost always an ordinary switching cycle
# rather than the trip. After the trip both gates stay latched, so the LAST
# transition of each is unambiguously the trip.
#
# Channels: C1 = SR2H, C2 = SR2L, C3 = SR1H, C4 = SR1L.
# A negative gap means the two gates of leg 2 overlap.
# ---------------------------------------------------------------------------
SCOPE_LAN_RESOURCE = "TCPIP0::%s::inst0::INSTR" # --scope-ip fills in the host
# One entry per instrument on the bench. Everything here is a property of that
# particular scope and of the PC driving it - the VISA address, the folder it
# writes captures to, the share it can copy them to - so moving the test to
# another scope is a matter of picking a profile, not of editing constants.
SCOPE_PROFILES = {
# HDO8108A over USBTMC, on the PC it has always lived on.
"hdo": {
"resource": "USB0::0x05FF::0x1023::3808N60334::INSTR",
"dir": r"D:\vanminh\gm ovp",
"copy_dir": r"Z:\vanminh\be semicon\GM OVP\auto test",
},
# WaveRunner 8038HD over USBTMC. USB rather than LAN on purpose: the
# instrument has one Ethernet port, and a cable straight to the PC takes it
# away from the network, which is how it reaches the share below. For LAN
# instead, pass --scope-ip; the captures then stay on the scope's own disk.
"wr8038": {
"resource": "USB0::0x05FF::0x1023::5002N60075::INSTR",
"dir": r"D:\vanminh\gm ovp",
"copy_dir": r"Z:\vanminh\be semicon\GM OVP\auto test",
},
}
DEFAULT_SCOPE_PROFILE = "hdo"
SCOPE_RESOURCE = SCOPE_PROFILES[DEFAULT_SCOPE_PROFILE]["resource"]
SCOPE_DIR = SCOPE_PROFILES[DEFAULT_SCOPE_PROFILE]["dir"]
SCOPE_COPY_DIR = SCOPE_PROFILES[DEFAULT_SCOPE_PROFILE]["copy_dir"]
SCOPE_PARAMS = list(range(1, 10)) # P1..P9
SCOPE_OPEN_RETRIES = 4 # a just-closed VXI-11 session blocks the next
SCOPE_OPEN_DELAY_S = 3.0
WAVEFORM_TAIL_S = 50e-6 # of the record, enough for several periods
SCOPE_ARM_TIMEOUT_S = 20.0
SCOPE_IMAGE_TIMEOUT_S = 20.0
SAVE_WF_BELOW_NS = 150.0 # keep the raw traces when the gap is this tight
# Gate thresholds, volts. The gate lines ring by several volts after the trip
# latches, so a single-threshold edge search locks onto the ringing instead.
GATE_V_TH = 6.0
GATE_V_HI = 9.0
GATE_V_LO = 3.0
_TRUE = ("-1", "1", "true")
class Scope:
"""Teledyne LeCroy over VISA. Best-effort throughout: a scope problem must
not abort a run that is otherwise producing good telemetry."""
def __init__(self, resource, image_dir, copy_dir, params):
import pyvisa
self.rm = pyvisa.ResourceManager()
# The scope serves one remote session at a time and does not always
# release it the instant the previous one closes, so an open refused
# seconds after a finished run usually succeeds on the next try.
self.ins = None
last = None
for attempt in range(SCOPE_OPEN_RETRIES):
try:
self.ins = self.rm.open_resource(resource)
break
except Exception as exc:
last = exc
if attempt + 1 < SCOPE_OPEN_RETRIES:
logger.info("scope open failed (%s) - retrying in %.0fs "
"(%d/%d)", exc, SCOPE_OPEN_DELAY_S,
attempt + 1, SCOPE_OPEN_RETRIES - 1)
time.sleep(SCOPE_OPEN_DELAY_S)
if self.ins is None:
raise last
self.ins.timeout = 20000
# A session that was killed mid-transaction leaves unread data on the
# scope, and the next open then fails with VI_ERROR_INP_PROT_VIOL -
# which looks exactly like the scope having vanished. Clear it first.
try:
self.ins.clear()
time.sleep(0.3)
except Exception as exc:
logger.debug("device clear: %s", exc)
self.ins.write("COMM_HEADER OFF")
self.ins.write("COMM_FORMAT DEF9,WORD,BIN")
self.idn = self.ins.query("*IDN?").strip()
self.image_dir = image_dir.rstrip("\\")
self.copy_dir = copy_dir.rstrip("\\") if copy_dir else None
self.param_ids = list(params)
self.images = []
self.ext = self._hardcopy_ext()
self.labels = []
for i in self.param_ids:
eng = self._vbs("app.Measure.P%d.ParamEngine" % i) or ("P%d" % i)
src = self._vbs("app.Measure.P%d.Source1" % i) or ""
self.labels.append("P%d_%s_%s" % (i, eng, src))
if not self.folder_exists(self.image_dir):
raise IOError("scope cannot see %s" % self.image_dir)
if self.copy_dir and not self.folder_exists(self.copy_dir):
logger.warning("scope cannot see %s - images stay on its disk",
self.copy_dir)
self.copy_dir = None
# --- low level ---
def _vbs(self, expr):
try:
return self.ins.query("VBS? 'return=%s'" % expr).strip()
except Exception:
return ""
def _fso(self, expr):
return self._vbs('CreateObject("Scripting.FileSystemObject").%s' % expr)
def folder_exists(self, path):
return self._fso('FolderExists("%s")' % path).strip().lower() in _TRUE
def file_size(self, path):
if self._fso('FileExists("%s")' % path).strip().lower() not in _TRUE:
return None
try:
return float(self._fso('GetFile("%s").Size' % path))
except ValueError:
return None
def _hardcopy_ext(self):
"""Follow the format the operator set; do not assume jpg."""
try:
setup = self.ins.query("HARDCOPY_SETUP?").strip().upper()
except Exception:
return "png"
for dev, ext in (("JPEG", "jpg"), ("JPG", "jpg"), ("PNG", "png"),
("BMP", "bmp"), ("TIFF", "tif")):
if "DEV," + dev in setup:
return ext
return "png"
# --- acquisition ---
def run(self):
"""Free-run. The previous cycle left the scope stopped on its capture;
leaving it there means flying blind through the whole charge."""
self.ins.write("TRMD AUTO")
self.ins.query("*OPC?")
def arm(self):
self.ins.write("TRMD SINGLE")
self.ins.query("*OPC?")
def wait_triggered(self, timeout_s=SCOPE_ARM_TIMEOUT_S):
deadline = time.time() + timeout_s
while time.time() < deadline:
try:
if self.ins.query("TRMD?").strip().upper().startswith("STOP"):
return True
except Exception:
return False
time.sleep(0.05)
return False
def stop(self):
self.ins.write("STOP")
self.ins.query("*OPC?")
def read_params(self):
out = []
for i in self.param_ids:
raw = self._vbs("app.Measure.P%d.Out.Result.Value" % i)
try:
out.append(float(raw))
except ValueError:
out.append("")
return out
# --- waveform ---
def _wave(self, ch):
"""Raw samples of the tail of the record, with the scaling to read them.
Only the tail is fetched: _fall and _rise both scan backwards from the
end, so everything before the last few periods is transferred and then
thrown away. On a deep-memory scope over LAN that is the difference
between seconds and half a minute per cycle, and it leaves the scope's
own record untouched for the LabNotebook.
"""
def insp(name):
text = self.ins.query('%s:INSPECT? "%s"' % (ch, name))
return float(text.split(":")[-1].strip().rstrip('"'))
# WAVE_ARRAY_COUNT reports what the current setup would transfer, so
# the full length has to be read with the setup reset to "everything".
self.ins.write("WAVEFORM_SETUP SP,0,NP,0,FP,0,SN,0")
dt = insp("HORIZ_INTERVAL")
total = int(insp("WAVE_ARRAY_COUNT"))
points = total
if dt > 0:
points = min(total, max(1000, int(WAVEFORM_TAIL_S / dt)))
if points < total:
self.ins.write("WAVEFORM_SETUP SP,0,NP,%d,FP,%d,SN,0"
% (points, total - points))
raw = self.ins.query_binary_values("%s:WAVEFORM? DAT1" % ch,
datatype="h", is_big_endian=False,
container=list)
return raw, insp("VERTICAL_GAIN"), insp("VERTICAL_OFFSET"), dt
@staticmethod
def _code(volts, gain, offset):
return (volts + offset) / gain
def _fall(self, raw, gain, offset):
hi = self._code(GATE_V_HI, gain, offset)
th = self._code(GATE_V_TH, gain, offset)
k = len(raw) - 1
while k >= 0 and raw[k] <= hi:
k -= 1
if k < 0:
return None
i = k
while i < len(raw) and raw[i] >= th:
i += 1
return i if i < len(raw) else None
def _rise(self, raw, gain, offset):
lo = self._code(GATE_V_LO, gain, offset)
th = self._code(GATE_V_TH, gain, offset)
k = len(raw) - 1
while k >= 0 and raw[k] >= lo:
k -= 1
if k < 0:
return None
i = k
while i < len(raw) and raw[i] <= th:
i += 1
return i if i < len(raw) else None
def trip_gap_ns(self):
"""SR2H off -> SR2L on at the trip, in ns. None when the record holds
no latched trip, which is what a trip arriving while SR2L was already
on looks like."""
try:
r1, g1, o1, dt = self._wave("C1")
r2, g2, o2, _ = self._wave("C2")
except Exception as exc:
logger.warning("scope waveform read failed: %s", exc)
return None
if (r1[-1] * g1 - o1) > GATE_V_TH or (r2[-1] * g2 - o2) < GATE_V_TH:
return None
i1 = self._fall(r1, g1, o1)
i2 = self._rise(r2, g2, o2)
if i1 is None or i2 is None:
return None
return round((i2 - i1) * dt * 1e9, 1)
# --- image ---
def capture(self, name):
filename = name + "." + self.ext
path = self.image_dir + "\\" + filename
self.ins.write('HARDCOPY_SETUP DEST,FILE,DIR,"%s\\",FILE,"%s"'
% (self.image_dir, name))
self.ins.write("VBS 'app.Hardcopy.Print'")
deadline = time.time() + SCOPE_IMAGE_TIMEOUT_S
last = None
while time.time() < deadline:
size = self.file_size(path)
if size and size == last:
self.images.append(filename)
return filename
last = size
time.sleep(0.2)
logger.warning("scope image %s not written", path)
return ""
def save_labnotebook(self, name):
"""Write a LabNotebook beside the capture: same folder, same base name.
One .lnb holds the screen image, the setup and a trace per displayed
waveform, and Flashback restores the instrument from it - which is what
makes a tight cycle debuggable later, where the image alone is not.
Requires "Annotate Screen Image Before Saving" to be OFF in the scope's
File > Save LabNotebook dialog. With it on, Save opens the drawing
toolbar and blocks until someone presses Done on the instrument, which
hangs the remote session.
"""
target = "%s\\%s.lnb" % (self.image_dir, name)
try:
self.ins.write('VBS \'app.LabNotebook.SaveFileName = "%s"\'' % target)
self.ins.write("VBS 'app.LabNotebook.Save'")
self.ins.query("*OPC?")
return True
except Exception as exc:
logger.warning("saving LabNotebook for %s failed: %s", name, exc)
return False
def copy_images(self):
if not (self.copy_dir and self.images):
return
logger.info("copying %d scope images to %s", len(self.images),
self.copy_dir)
copied = 0
fso = 'CreateObject("Scripting.FileSystemObject")'
for name in self.images:
src = self.image_dir + "\\" + name
dst = self.copy_dir + "\\" + name
try:
self.ins.write("VBS '%s.CopyFile \"%s\", \"%s\", False'"
% (fso, src, dst))
self.ins.query("*OPC?")
copied += 1
except Exception as exc:
logger.warning(" %s: %s", name, exc)
logger.info("copied %d/%d images", copied, len(self.images))
def close(self):
try:
self.ins.close()
except Exception:
pass
class ScopeCSV:
"""One row per cycle: the gap, the operating point at the cut, P1..P9."""
# P1..P9 sit between the operating point and the two columns the run is
# read by, so Image and Trip_Gap_ns stay together at the far right.
HEADERS = ['Cycle', 'Timestamp', 'Result',
'RCU_VF_Status', 'RCU_VF_Freq', 'RCU_Output_Current_A',
'RCU_Output_Voltage_V', 'Current_Setpoint_A']
TAIL = ['Image', 'Trip_Gap_ns']
def __init__(self, path, labels):
self.path = path
self._file = open(path, 'w', newline='', encoding='utf-8')
self._writer = csv.writer(self._file)
self._writer.writerow(self.HEADERS + list(labels) + self.TAIL)
self._file.flush()
def write(self, row):
self._writer.writerow(row)
self._file.flush()
def close(self):
try:
self._file.close()
except Exception:
pass
class CSVRecorder:
HEADERS = [
'Timestamp', 'Elapsed_s', 'Cycle', 'Phase',
'Jig_X_mm', 'Jig_Y_mm', 'Jig_Z_mm',
'Relay_Cmd', 'RCU_Charging_Cmd', 'RCU_Powered',
'RCU_Main_Pre_State', 'RCU_Main_State',
'RCU_Output_Current_A', 'RCU_Output_Voltage_V', 'RCU_DC_Bus_Voltage_V',
'RCU_Error_Code', 'RCU_Shadow_Error',
'RCU_VF_Status', 'RCU_VF_Freq',
'TPU_Main_Pre_State', 'TPU_Main_State',
'TPU_Output_Current_A', 'TPU_Output_Voltage_V', 'TPU_DC_Bus_Voltage_V',
'TPU_Error_Code', 'TPU_Shadow_Error', 'TPU_RCU_Error_Code',
'Relay_Main_State', 'Charging_Dwell_s', 'Note',
]
def __init__(self, path, extra=()):
self.path = path
self.headers = list(self.HEADERS) + list(extra)
self._lock = threading.Lock()
self._file = open(path, 'w', newline='', encoding='utf-8')
self._writer = csv.writer(self._file)
self._writer.writerow(self.headers)
self._file.flush()
def write(self, row):
row = list(row)
if len(row) < len(self.headers):
row += [''] * (len(self.headers) - len(row))
with self._lock:
self._writer.writerow(row)
self._file.flush()
def close(self):
with self._lock:
self._file.close()
# ---------------------------------------------------------------------------
# Monitor thread
# ---------------------------------------------------------------------------
class Monitor(threading.Thread):
"""Samples RCU/TPU/relay telemetry on a fixed interval for the whole run.
It also tracks how long the charging criteria have held continuously, so
the main thread never has to touch the serial ports while it waits.
"""
def __init__(self, rcu, tpu, relay, recorder, t0,
interval=MONITOR_INTERVAL_S,
current_threshold=CHARGING_CURRENT_THRESHOLD_A):
super().__init__(name="monitor", daemon=True)
self.rcu = rcu
self.tpu = tpu
self.relay = relay
self.recorder = recorder
self.t0 = t0
self.interval = interval
self.current_threshold = current_threshold
self._stop = threading.Event()
self._state_lock = threading.Lock()
self._cycle = 0
self._phase = "startup"
self._note = ""
self._jig = (None, None, None)
self._relay_cmd = 0
self._charging_cmd = 0
self._rcu_powered = False
self._ok_since = None
self._latest = {}
# --- context set by the main thread ------------------------------------
def set_context(self, cycle=None, phase=None, jig=None,
relay_cmd=None, charging_cmd=None, note=None,
rcu_powered=None):
with self._state_lock:
if cycle is not None:
self._cycle = cycle
if phase is not None:
self._phase = phase
if jig is not None:
self._jig = tuple(jig)
if relay_cmd is not None:
self._relay_cmd = int(bool(relay_cmd))
if charging_cmd is not None:
self._charging_cmd = int(bool(charging_cmd))
if note is not None:
self._note = note
if rcu_powered is not None:
self._rcu_powered = bool(rcu_powered)
def reset_dwell(self):
with self._state_lock:
self._ok_since = None
def dwell_seconds(self):
"""Seconds the charging criteria have held continuously (0 if not)."""
with self._state_lock:
if self._ok_since is None:
return 0.0
return time.time() - self._ok_since
def latest(self):
with self._state_lock:
return dict(self._latest)
def write_event(self, phase, note, extra=None):
"""Append one row now, telemetry blank. set_context() only paints the
next periodic sample, which is too late to record an instant."""
with self._state_lock:
cycle = self._cycle
jig_x, jig_y, jig_z = self._jig
relay_cmd, charging_cmd = self._relay_cmd, self._charging_cmd
powered = self._rcu_powered
blank = dict(BLANK_RCU_DATA)
self.recorder.write([
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3],
round(time.time() - self.t0, 1), cycle, phase,
jig_x, jig_y, jig_z, relay_cmd, charging_cmd, int(powered),
blank['main_pre_state'], blank['main_state'],
blank['output_current'], blank['output_voltage'],
blank['dc_bus_voltage'], blank['error_code'],
blank['shadow_error'],
blank['vf_status'], blank['vf_freq'],
None, None, None, None, None, '', '', '',
None, 0.0, note,
] + list(extra or []))
def stop(self):
self._stop.set()
# --- loop --------------------------------------------------------------
def run(self):
while not self._stop.is_set():
started = time.time()
self._sample(started)
# Keep the cadence steady regardless of how long the reads took.
self._stop.wait(max(0.0, self.interval - (time.time() - started)))
def _sample(self, now):
with self._state_lock:
rcu_powered = self._rcu_powered
# With the relay open the RCU is dead: every read would burn a full
# serial timeout and stall the sampling cadence, so skip it entirely
# and log the row with the RCU columns blank.
rcu = self.rcu.read_data() if rcu_powered else dict(BLANK_RCU_DATA)
tpu = self.tpu.read_data()
relay = self.relay.read_data()
criteria_met = (
rcu['main_state'] == RCU_STATE_CHARGING
and rcu['output_current'] is not None
and rcu['output_current'] > self.current_threshold
)
with self._state_lock:
if criteria_met:
if self._ok_since is None:
self._ok_since = now
else:
self._ok_since = None
dwell = 0.0 if self._ok_since is None else now - self._ok_since
cycle, phase, note = self._cycle, self._phase, self._note
jig_x, jig_y, jig_z = self._jig
relay_cmd, charging_cmd = self._relay_cmd, self._charging_cmd
powered = self._rcu_powered
self._latest = {'rcu': rcu, 'tpu': tpu, 'relay': relay}
self.recorder.write([
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3],
round(now - self.t0, 1), cycle, phase,
jig_x, jig_y, jig_z, relay_cmd, charging_cmd, int(powered),
rcu['main_pre_state'], rcu['main_state'],
rcu['output_current'], rcu['output_voltage'], rcu['dc_bus_voltage'],
rcu['error_code'], rcu['shadow_error'],
rcu['vf_status'], rcu['vf_freq'],
tpu['main_pre_state'], tpu['main_state'],
tpu['output_current'], tpu['output_voltage'], tpu['dc_bus_voltage'],
tpu['error_code'], tpu['shadow_error'], tpu['rcu_error_code'],
relay['main_state'], round(dwell, 1), note,
])
logger.info(
" [%5.1fs] RCU state=%s I=%s A V=%s V | TPU state=%s I=%s A "
"| relay state=%s | dwell=%.0fs",
now - self.t0, rcu['main_state'], rcu['output_current'],
rcu['output_voltage'], tpu['main_state'], tpu['output_current'],
relay['main_state'], dwell)
# ---------------------------------------------------------------------------
# Test sequence
# ---------------------------------------------------------------------------
def _wait(seconds, stop_when=None, poll=0.2):
"""Sleep while the monitor keeps logging. Returns True if stop_when fired."""
deadline = time.time() + seconds
while time.time() < deadline:
if stop_when is not None and stop_when():
return True
time.sleep(poll)
return False
def _cut_relay(relay, retry=True):
"""Open the relay, tolerating one lost frame. Returns True if it landed."""
try:
relay.set_control(False)
return True
except Exception as exc:
logger.warning("Relay cut not acknowledged: %s", exc)
if not retry:
return False
try:
relay.set_control(False)
logger.info("Relay cut acknowledged on retry.")
return True
except Exception as exc:
logger.error("Relay cut failed twice: %s", exc)
return False
def _has_current(monitor):
"""True once the RCU reports any output current at all."""
value = monitor.latest().get('rcu', {}).get('output_current')
return value is not None and value > 0.0
def _apply_runtime_current(rcu, monitor, cfg):
"""Set the runtime current (reg 9) now that charging has started.
It cannot be set any earlier: while the charger is off the app keeps
overwriting reg 9 from current_setting (reg 14), so an early write is lost.
Current flowing is the signal that charging has actually begun.
"""
if cfg.get('current') is None:
return
if _wait(cfg['timeout'], stop_when=lambda: _has_current(monitor)):
_safe(rcu.set_current, cfg['current'])
else:
logger.warning("No output current within %.0fs - runtime current not "
"applied.", cfg['timeout'])
def _scope_live(cfg):
"""Put the scope back to free-running at the start of a cycle."""
scope = cfg.get('scope')
if scope is not None:
_safe(scope.run)
def _arm_scope(cfg):
"""Sample the RCU operating point and arm the scope, both just before the
cut: the relay also cuts RCU power, so afterwards it answers nothing."""
pre_cut = dict(cfg['monitor'].latest().get('rcu', {}))
scope = cfg.get('scope')
if scope is not None:
try:
scope.arm()
except Exception as exc:
# Silently armed-or-not was the worst of both: the run carried on
# and the missing capture only showed up at the end.
logger.warning("scope arm failed, this cycle has no capture: %s",
exc)
return pre_cut
def _record_scope(cfg, cycle, result, pre_cut):
"""Read the scope after the cut and append one row to the scope CSV."""
scope = cfg.get('scope')
if scope is None:
return
if not scope.wait_triggered():
logger.warning("Cycle %d: scope did not trigger.", cycle)
_safe(scope.stop)
gap = scope.trip_gap_ns()
name = "ovp_c%03d_%s" % (cycle, cfg['stamp'])
image = scope.capture(name)
params = scope.read_params()
cfg['scope_csv'].write(
[cycle,
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
result,
pre_cut.get('vf_status'), pre_cut.get('vf_freq'),
pre_cut.get('output_current'), pre_cut.get('output_voltage'),
"" if cfg.get('current') is None else cfg['current']]
+ params
+ [image, "" if gap is None else gap])
# Keep a full recall point for the cycles that are actually interesting.
# It stays on the scope only - it is for Flashback there, not for the run
# folder, so it is never queued for the copy to the network drive.
if (gap is not None and cfg.get('save_wf_below')
and gap < cfg['save_wf_below']):
if scope.save_labnotebook(name):
logger.info("Cycle %d: gap %.1f ns - LabNotebook saved as %s.lnb",
cycle, gap, name)
cfg['monitor'].write_event(
"scope", "trip gap %s" % ("n/a" if gap is None else "%.1f ns" % gap),
extra=params + [image, "" if gap is None else gap])
cfg.setdefault('gaps', []).append(gap)
logger.info("Cycle %d: trip gap %s, image %s", cycle,
"not latched" if gap is None else ("%.1f ns" % gap),
image or "none")
def run_cycle(cycle, rcu, relay, jig, monitor, cfg):
"""Run one OVP cycle with the jig standing still.
Returns 'PASS', 'TIMEOUT' or 'CUT_FAILED'.
"""
logger.info("=" * 62)
logger.info("CYCLE %d/%d", cycle, cfg['cycles'])
_scope_live(cfg)
# Step 1: close the relay and let the RCU boot. The previous cycle left it
# open, so until this lands there is nothing on the other end to talk to.
# rcu_powered is only raised once the settle is over: the monitor would
# otherwise burn a full serial timeout per register on a booting RCU.
monitor.set_context(cycle=cycle, phase="relay_on", relay_cmd=1, note="")
power_up_rcu(relay)
monitor.set_context(rcu_powered=True)
# Step 2: move to the charging position and let it settle. Without a jig
# the pads are already fixed at the charging distance, so only wait.
if jig is not None:
monitor.set_context(phase="jig_near", jig=NEAR_POSITION)
jig.move_to(*NEAR_POSITION)
else:
monitor.set_context(phase="settle")
_wait(JIG_SETTLE_S)
# Step 3: start charging and wait for the criteria.
monitor.reset_dwell()
monitor.set_context(phase="charging", charging_cmd=1)
rcu.set_charging(True)
_apply_runtime_current(rcu, monitor, cfg)
logger.info(
"Waiting for RCU main_state=%d and output current > %.1f A held for "
"%.0fs (timeout %.0fs)...",
RCU_STATE_CHARGING, cfg['current_threshold'], cfg['dwell'],
cfg['timeout'])
met = _wait(cfg['timeout'],
stop_when=lambda: monitor.dwell_seconds() >= cfg['dwell'])
result = "PASS" if met else "TIMEOUT"
if met:
logger.info("Cycle %d: criteria met after %.1fs of dwell.",
cycle, monitor.dwell_seconds())
else:
latest = monitor.latest().get('rcu', {})
logger.warning(
"Cycle %d: TIMEOUT after %.0fs (last RCU state=%s, current=%s A).",
cycle, cfg['timeout'], latest.get('main_state'),
latest.get('output_current'))
monitor.set_context(note=result)
pre_cut = _arm_scope(cfg)
# Step 4: open the relay. This also cuts the RCU's power, so from here on
# the RCU is dead and its telemetry columns stay blank.
if not _cut_relay(relay):
result = "CUT_FAILED"
monitor.set_context(phase="relay_off", relay_cmd=0, rcu_powered=False,
note=result)
_record_scope(cfg, cycle, result, pre_cut)
# Step 5: move away, keep monitoring. Without a jig there is nothing to
# move, so this becomes a plain observation window after the relay opens.
if jig is not None:
monitor.set_context(phase="jig_far", jig=FAR_POSITION)
jig.move_to(*FAR_POSITION)
else:
monitor.set_context(phase="observe")
_wait(cfg['observe'])
# Step 6: stop charging and settle before the next cycle. The relay is
# already open, so this write is best-effort housekeeping - an unpowered
# RCU cannot acknowledge it, and that is not a test failure.
try:
# One attempt: this is the belt-and-braces stop for a relay that failed
# to open. If it did open the RCU is dead and cannot answer, so the
# further attempts only cost a serial timeout each.
rcu.set_charging(False, retries=1)
except Exception as exc:
logger.info("Charging cmd = 0 not acknowledged, as expected with the "
"relay open and the RCU unpowered (%s).", exc)
monitor.set_context(phase="charging_off", charging_cmd=0)
_wait(CYCLE_GAP_S)
monitor.set_context(note="")
logger.info("Cycle %d finished: %s", cycle, result)
return result
def run_cycle_ramp(cycle, rcu, relay, jig, monitor, cfg):
"""One OVP cycle with the relay cut while the current is moving between
two setpoints.
Charging settles at --current, the setpoint is then moved to --current2,
and the cut lands the moment the measured current has covered --ramp-at
percent of the distance between the two: 25 A -> 15 A at the default 50
percent cuts at 20 A.
Returns 'PASS', 'TIMEOUT', 'RAMP_TIMEOUT' or 'CUT_FAILED'.
"""
i1, i2 = cfg['current'], cfg['current2']
trip_at = i1 + (i2 - i1) * cfg['ramp_at'] / 100.0
falling = i2 < i1
logger.info("=" * 62)
logger.info("CYCLE %d/%d (ramp scenario)", cycle, cfg['cycles'])
_scope_live(cfg)
# Step 1: close the relay and let the RCU boot.
monitor.set_context(cycle=cycle, phase="relay_on", relay_cmd=1, note="")
power_up_rcu(relay)
monitor.set_context(rcu_powered=True)
# Step 2: charging position, then settle.
if jig is not None:
monitor.set_context(phase="jig_near", jig=NEAR_POSITION)
jig.move_to(*NEAR_POSITION)
else:
monitor.set_context(phase="settle")
_wait(JIG_SETTLE_S)
# Step 3: charge at the first setpoint and let it establish.
monitor.reset_dwell()
monitor.set_context(phase="charging", charging_cmd=1)
rcu.set_charging(True)
_apply_runtime_current(rcu, monitor, cfg)
logger.info(
"Waiting for RCU main_state=%d and output current > %.1f A held for "
"%.0fs (timeout %.0fs)...",
RCU_STATE_CHARGING, cfg['current_threshold'], cfg['dwell'],
cfg['timeout'])
met = _wait(cfg['timeout'],
stop_when=lambda: monitor.dwell_seconds() >= cfg['dwell'])
result = "PASS" if met else "TIMEOUT"
if met:
logger.info("Cycle %d: criteria met after %.1fs of dwell.",
cycle, monitor.dwell_seconds())
else:
latest = monitor.latest().get('rcu', {})
logger.warning(
"Cycle %d: TIMEOUT after %.0fs (last RCU state=%s, current=%s A).",
cycle, cfg['timeout'], latest.get('main_state'),
latest.get('output_current'))
monitor.set_context(note=result)
# Arm before the setpoint moves: once the ramp starts there is no time.
pre_cut = _arm_scope(cfg)
# Step 4: move the setpoint and cut partway through the ramp.
monitor.set_context(phase="ramp")
logger.info("Cycle %d: ramp %.1f A -> %.1f A, cutting at %.1f A",
cycle, i1, i2, trip_at)
_safe(rcu.set_current, i2)
# The monitor samples far too slowly to catch the crossing, so poll the
# current directly. One Modbus read is ~20 ms at 9600 baud, and that is
# the time resolution of the cut.
reached = False
measured = None
deadline = time.time() + cfg['ramp_timeout']
while time.time() < deadline:
try:
value = _q128(rcu.read_u16(RCU_OUTPUT_CURRENT_ADDRESS))
except Exception:
continue
if value is None:
continue
measured = value
if (value <= trip_at) if falling else (value >= trip_at):
reached = True
break
# write_coil, not set_control(): that helper logs before it writes, and a
# Windows console write blocks while text is selected in the terminal -
# which here would swallow the cut itself. Log afterwards.
try:
relay.write_coil(RELAY_CONTROL_CMD_ADDRESS, False)
cut_ok = True
except Exception as exc:
logger.warning("Relay cut not acknowledged: %s", exc)
cut_ok = _cut_relay(relay, retry=False)
monitor.set_context(phase="relay_off", relay_cmd=0, rcu_powered=False)
if result == "PASS" and not reached:
result = "RAMP_TIMEOUT"
if not cut_ok:
result = "CUT_FAILED"
note = "cut at %s A (target %.1f A, %.1f -> %.1f)" % (
"n/a" if measured is None else "%.2f" % measured, trip_at, i1, i2)
if not reached:
note = "RAMP_TIMEOUT: " + note
logger.info("RELAY %s", note)
monitor.write_event("relay_cut", note)
monitor.set_context(note=result)
# The polled value is fresher than anything the monitor has, and it is the
# current the cut actually landed on.
if measured is not None:
pre_cut['output_current'] = measured
_record_scope(cfg, cycle, result, pre_cut)
# Step 5: observation window with the relay open.
if jig is not None:
monitor.set_context(phase="jig_far", jig=FAR_POSITION)
jig.move_to(*FAR_POSITION)
else:
monitor.set_context(phase="observe")
_wait(cfg['observe'])
# Step 6: stop charging and settle. Best-effort: the RCU is unpowered.
try:
# One attempt: this is the belt-and-braces stop for a relay that failed
# to open. If it did open the RCU is dead and cannot answer, so the
# further attempts only cost a serial timeout each.
rcu.set_charging(False, retries=1)
except Exception as exc:
logger.info("Charging cmd = 0 not acknowledged, as expected with the "
"relay open and the RCU unpowered (%s).", exc)
monitor.set_context(phase="charging_off", charging_cmd=0)
_wait(CYCLE_GAP_S)
monitor.set_context(note="")
logger.info("Cycle %d finished: %s", cycle, result)
return result
def move_timing(cfg):
"""(distance_mm, travel_s, trigger_s). --trigger-at only pulls it earlier."""
distance = abs(cfg['move_from'] - cfg['move_to'])
travel = distance / cfg['jig_speed']
trigger = travel / 2.0
if cfg.get('trigger_at') is not None:
trigger = min(trigger, cfg['trigger_at'])
return distance, travel, trigger
def run_cycle_move(cycle, rcu, relay, jig, monitor, cfg):
"""One OVP cycle with the relay cut midway through a jig move.
Returns 'PASS', 'TIMEOUT' or 'CUT_FAILED'.
"""
distance, travel_s, trigger_s = move_timing(cfg)
home = (cfg['move_x'], cfg['move_y'], cfg['move_from'])
target = (cfg['move_x'], cfg['move_y'], cfg['move_to'])
logger.info("=" * 62)
logger.info("CYCLE %d/%d (move scenario)", cycle, cfg['cycles'])
_scope_live(cfg)
# Step 1: close the relay and let the RCU boot.
monitor.set_context(cycle=cycle, phase="relay_on", relay_cmd=1, note="")
power_up_rcu(relay)
monitor.set_context(rcu_powered=True)
# Step 2: park at the charging position.
monitor.set_context(phase="jig_home", jig=home)
jig.move_to(*home)
_wait(JIG_MOVE_MAX_S)
# Step 3: charge, and wait for it to be established - the cut means
# nothing until then.
monitor.reset_dwell()
monitor.set_context(phase="charging", charging_cmd=1)
rcu.set_charging(True)
_apply_runtime_current(rcu, monitor, cfg)
logger.info(
"Waiting for RCU main_state=%d and output current > %.1f A held for "
"%.0fs (timeout %.0fs)...",
RCU_STATE_CHARGING, cfg['current_threshold'], cfg['dwell'],
cfg['timeout'])
met = _wait(cfg['timeout'],
stop_when=lambda: monitor.dwell_seconds() >= cfg['dwell'])
result = "PASS" if met else "TIMEOUT"
if met:
logger.info("Cycle %d: criteria met after %.1fs of dwell.",
cycle, monitor.dwell_seconds())
else:
latest = monitor.latest().get('rcu', {})
logger.warning(
"Cycle %d: TIMEOUT after %.0fs (last RCU state=%s, current=%s A).",
cycle, cfg['timeout'], latest.get('main_state'),
latest.get('output_current'))
monitor.set_context(note=result)
pre_cut = _arm_scope(cfg)
# Step 4: the move, with the relay cut partway through it.
logger.info("Moving %d mm (%.2fs), cutting relay at %.2fs",
distance, travel_s, trigger_s)
monitor.set_context(phase="jig_move", jig=target)
t_go = jig.move_to(*target)
# Sleep straight through, not via _wait(): its 0.2s poll is far too coarse
# for a sub-second target.
remaining = (t_go + trigger_s) - time.perf_counter()
if remaining > 0:
time.sleep(remaining)
# write_coil, not set_control(): that helper logs before it writes, and a
# Windows console write blocks while text is selected in the terminal -
# which here would swallow the cut itself. Log afterwards.
try:
relay.write_coil(RELAY_CONTROL_CMD_ADDRESS, False)
achieved = time.perf_counter() - t_go
cut_ok = True
except Exception as exc:
achieved = time.perf_counter() - t_go
logger.warning("Relay cut not acknowledged at %.3fs: %s", achieved, exc)
cut_ok = _cut_relay(relay, retry=False)
monitor.set_context(phase="relay_cut", relay_cmd=0, rcu_powered=False)
note = "cut at %.3fs of %.2fs travel" % (achieved, travel_s)
if not cut_ok:
result = "CUT_FAILED"
note = "CUT_FAILED: " + note
logger.info("RELAY %s", note)
monitor.write_event("relay_cut", note)
monitor.set_context(note=result)
_record_scope(cfg, cycle, result, pre_cut)
_wait(max(0.0, JIG_MOVE_MAX_S - achieved))
# Step 5: stop charging. Best-effort - the RCU lost power at the cut.
try:
# One attempt: this is the belt-and-braces stop for a relay that failed
# to open. If it did open the RCU is dead and cannot answer, so the
# further attempts only cost a serial timeout each.
rcu.set_charging(False, retries=1)
except Exception as exc:
logger.info("Charging cmd = 0 not acknowledged, as expected with the "
"relay open and the RCU unpowered (%s).", exc)
monitor.set_context(phase="charging_off", charging_cmd=0)
_wait(CYCLE_GAP_S)
# Step 6: back to the charging position, last thing in the cycle, so the
# whole post-cut window is observed at a standstill.
monitor.set_context(phase="jig_return", jig=home)
jig.move_to(*home)
_wait(JIG_MOVE_MAX_S)
monitor.set_context(note="")
logger.info("Cycle %d finished: %s", cycle, result)
return result
def ovp_test(rcu_port, tpu_port, relay_port, jig_port, cycles, cfg, stamp):
os.makedirs(RESULTS_DIR, exist_ok=True)
csv_path = os.path.join(RESULTS_DIR, "ovp_test_%s.csv" % stamp)
scope_csv_path = os.path.join(RESULTS_DIR, "ovp_scope_%s.csv" % stamp)
rcu = tpu = relay = jig = None
monitor = None
recorder = None
results = []
cfg['stamp'] = stamp
cfg['scope'] = None
cfg['scope_csv'] = None
if cfg.get('scope_resource'):
try:
cfg['scope'] = Scope(cfg['scope_resource'], cfg['scope_dir'],
cfg['scope_copy_dir'], SCOPE_PARAMS)
cfg['scope_csv'] = ScopeCSV(scope_csv_path, cfg['scope'].labels)
logger.info("Scope: %s", cfg['scope'].idn)
logger.info("Scope images -> %s (.%s)", cfg['scope'].image_dir,
cfg['scope'].ext)
except Exception as exc:
logger.warning("Scope unavailable, continuing without it: %s", exc)
cfg['scope'] = None
try:
rcu = RCU(rcu_port)
tpu = TPU(tpu_port)
relay = Relay(relay_port)
jig = Jig(jig_port) if jig_port else None
if jig is None:
logger.info("No jig port given - running without the jig; keep "
"the pads fixed at the charging distance.")
# The RCU is powered through the relay, so close it and let the RCU
# boot before anything - the pre-flight probe included - talks to it.
power_up_rcu(relay)
# Pre-flight: prove every port talks before committing to a long run.
if cfg['link_test']:
probe_devices = [rcu, tpu, relay]
if jig is not None:
probe_devices.append(jig)
all_ok, any_dead = link_test(probe_devices)
if any_dead:
logger.error("A device did not answer at all - aborting. "
"Check the -r/-t/-l/-j port assignments.")
return
if all_ok:
logger.info("Link test: PASS")
else:
logger.warning("Link test: PARTIAL - some registers could not "
"be read; continuing anyway.")
# Known-safe starting point before any logging begins. The relay stays
# closed: cycle 1 re-asserts it and waits out the settle again.
rcu.set_charging(False)
scope_cols = []
if cfg.get('scope') is not None:
scope_cols = list(cfg['scope'].labels) + ['Scope_Image', 'Trip_Gap_ns']
recorder = CSVRecorder(csv_path, scope_cols)
logger.info("Logging telemetry to: %s", os.path.abspath(csv_path))
monitor = Monitor(rcu, tpu, relay, recorder, t0=time.time(),
interval=cfg['interval'],
current_threshold=cfg['current_threshold'])
monitor.set_context(cycle=0, phase="startup",
jig=(0, 0, 0) if jig is not None else None,
relay_cmd=1, charging_cmd=0, rcu_powered=True)
monitor.start()
cfg['monitor'] = monitor
runner = {'move': run_cycle_move,
'ramp': run_cycle_ramp}.get(cfg['scenario'], run_cycle)
for cycle in range(1, cycles + 1):
results.append(runner(cycle, rcu, relay, jig, monitor, cfg))
except KeyboardInterrupt:
logger.warning("Interrupted by user.")
except minimalmodbus.ModbusException as exc:
logger.error("Modbus error: %s", exc)
except Exception as exc:
logger.exception("Unexpected error: %s", exc)
finally:
if monitor is not None:
monitor.stop()
monitor.join(timeout=cfg['interval'] + SERIAL_TIMEOUT * 4)
# Always leave the rig de-energised.
if rcu is not None:
try:
rcu.set_charging(False)
except Exception as exc:
# Expected when we stop with the relay already open.
logger.info("Charging cmd = 0 not acknowledged (RCU likely "
"unpowered): %s", exc)
if relay is not None:
try:
relay.set_control(False)
except Exception as exc:
logger.warning("Could not switch relay off: %s", exc)
if cfg.get('scope') is not None:
_safe(cfg['scope'].copy_images)
cfg['scope'].close()
if cfg.get('scope_csv') is not None:
cfg['scope_csv'].close()
logger.info("Scope CSV: %s", scope_csv_path)
for dev in (rcu, tpu, relay, jig):
if dev is not None:
dev.close()
if recorder is not None:
recorder.close()
# Nothing to summarise if we bailed out before logging started.
if recorder is not None:
# CUT_FAILED is counted apart from TIMEOUT: the relay never opened,
# so the cycle proves nothing either way about the RCU.
logger.info("=" * 62)
logger.info("Cycles run: %d/%d | PASS: %d | TIMEOUT: %d "
"| CUT_FAILED: %d",
len(results), cycles, results.count("PASS"),
results.count("TIMEOUT"), results.count("CUT_FAILED"))
for i, res in enumerate(results, start=1):
logger.info(" cycle %d: %s", i, res)
# The gap floor is the number the run exists to produce: the
# smallest separation seen between SR2H turning off and SR2L
# turning on. Negative means the two gates overlapped.
gaps = [g for g in cfg.get('gaps', []) if g is not None]
if cfg.get('gaps'):
logger.info("-" * 62)
logger.info("Trip gap readings: %d valid / %d cycles",
len(gaps), len(cfg['gaps']))
if gaps:
worst = min(gaps)
logger.info("Smallest trip gap: %+.1f ns%s", worst,
" <-- OVERLAP" if worst < 0 else "")
else:
logger.info("No valid gap: every trip arrived while SR2L "
"was already on")
logger.info("Results saved to: %s", os.path.abspath(csv_path))
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="BE OVP cycling test across RCU, TPU, relay and jig.")
parser.add_argument("-r", "--rcu", required=True,
help="COM port for the RCU (e.g. COM3)")
parser.add_argument("-t", "--tpu", required=True,
help="COM port for the TPU")
parser.add_argument("-l", "--relay", required=True,
help="COM port for the relay board")
parser.add_argument("-j", "--jig", default=None,
help="COM port for the jig. Omit to run without one: "
"the pads must then already be fixed at the "
"charging distance, and the move-away phase "
"becomes a plain observation window.")
parser.add_argument("-n", "--cycles", type=int, default=None,
help="Number of test cycles "
"(not needed with --link-test)")
parser.add_argument("--link-test", action="store_true",
help="Read a few registers from every port, report "
"per port, then exit without running any cycles")
parser.add_argument("--no-link-test", action="store_true",
help="Skip the pre-flight link test that normally "
"runs before the first cycle")
parser.add_argument("--current", type=float, default=None, metavar="A",
help="runtime charging current, amps. Written to RCU "
"holding reg 9 once current starts flowing - it "
"cannot be set before that, the charger overwrites "
"it from current_setting. Keep --current-threshold "
"below this value or the dwell never completes.")
parser.add_argument("--scope-profile", choices=sorted(SCOPE_PROFILES),
default=DEFAULT_SCOPE_PROFILE,
help="which scope on the bench: " + ", ".join(
"%s = %s" % (k, v["resource"])
for k, v in sorted(SCOPE_PROFILES.items()))
+ " (default: %(default)s)")
parser.add_argument("--scope", dest="scope_resource", metavar="VISA",
help="scope VISA resource, overriding the profile's")
parser.add_argument("--scope-ip", metavar="IP",
help="reach the scope over LAN at this address instead "
"of USB, as " + SCOPE_LAN_RESOURCE % "IP" +
". Needs the scope's Utilities > Utilities Setup > "
"Remote set to TCPIP (VXI11). Overrides --scope")
parser.add_argument("--no-scope", action="store_true",
help="run without the scope")
parser.add_argument("--save-wf-below", type=float, default=SAVE_WF_BELOW_NS,
metavar="NS",
help="also save a LabNotebook (.lnb), into the scope "
"folder and under the same base name as the image, "
"whenever the trip gap is below this. 0 disables. "
"Needs 'Annotate Screen Image Before Saving' off on "
"the scope (default: %(default)s)")
parser.add_argument("--scope-dir", metavar="DIR",
help="folder on the SCOPE for the captures, overriding "
"the profile's")
parser.add_argument("--scope-copy-dir", metavar="DIR",
help="share the scope copies the captures to at the "
"end, overriding the profile's. Empty to keep them "
"on the scope")
parser.add_argument("--current-threshold", type=float,
default=CHARGING_CURRENT_THRESHOLD_A,
help="Output current the RCU must exceed, in amps "
"(default: %(default)s)")
parser.add_argument("--dwell", type=float, default=CHARGING_DWELL_S,
help="Seconds the criteria must hold "
"(default: %(default)s)")
parser.add_argument("--observe", type=float, default=FAR_OBSERVE_S,
help="Seconds to keep sampling after the cut with the "
"relay open, which is what shows whether the RCU "
"really went down. Scenarios static and ramp only "
"- move waits out the jig travel instead "
"(default: %(default)s)")
parser.add_argument("--timeout", type=float, default=CHARGING_TIMEOUT_S,
help="Seconds before a cycle is abandoned "
"(default: %(default)s)")
parser.add_argument("--interval", type=float, default=MONITOR_INTERVAL_S,
help="Telemetry sampling interval in seconds "
"(default: %(default)s)")
parser.add_argument("--scenario", choices=("static", "move", "ramp"),
default="static",
help="static: cut the relay with the jig still. "
"move: cut it partway through a jig move, which "
"needs a jig. ramp: cut it partway through a "
"change of current setpoint, which needs "
"--current and --current2 (default: %(default)s)")
parser.add_argument("--current2", type=float, default=None, metavar="A",
help="second current setpoint for --scenario ramp. "
"Charging settles at --current, then moves here.")
parser.add_argument("--ramp-at", type=float, default=RAMP_TRIGGER_PCT,
metavar="PCT",
help="how far between the two setpoints the cut lands, "
"percent. 25 A -> 15 A at 50 cuts at 20 A "
"(default: %(default)s)")
parser.add_argument("--ramp-timeout", type=float, default=RAMP_TIMEOUT_S,
metavar="S",
help="give up waiting for the ramp to reach the cut "
"point after this long (default: %(default)s)")
# int, not float: Device.write_holding does `value & 0xFFFF`.
parser.add_argument("--move-x", type=int, default=MOVE_X_MM, metavar="X",
help="Scenario move: X in mm, held for the whole "
"scenario (default: %(default)s)")
parser.add_argument("--move-y", type=int, default=MOVE_Y_MM, metavar="Y",
help="Scenario move: Y in mm, held for the whole "
"scenario (default: %(default)s)")
parser.add_argument("--move-from", type=int, default=MOVE_Z_FROM_MM,
metavar="Z",
help="Scenario move: Z in mm where the move starts, "
"also the charging position "
"(default: %(default)s)")
parser.add_argument("--move-to", type=int, default=MOVE_Z_TO_MM,
metavar="Z",
help="Scenario move: Z in mm where the move ends "
"(default: %(default)s)")
parser.add_argument("--trigger-at", type=float, default=None,
metavar="SECONDS",
help="Scenario move: seconds after the go command to "
"cut the relay (default: half the travel time). "
"Only pulls the cut earlier")
parser.add_argument("--jig-speed", type=float, default=JIG_SPEED_MM_S,
metavar="MM_S",
help="Scenario move: jig speed in mm/s. Decides where "
"the cut lands, and the jig cannot confirm it - "
"measure it (default: %(default)s)")
args = parser.parse_args()
if not args.link_test:
if args.cycles is None:
parser.error("-n/--cycles is required unless --link-test is given")
if args.cycles < 1:
parser.error("--cycles must be at least 1")
if args.dwell > args.timeout:
parser.error("--dwell cannot exceed --timeout")
# The profile supplies the three scope settings; each explicit flag then
# overrides its own, and --scope-ip overrides the address whichever way it
# arrived. Only what is passed is overridden, so a profile stays usable when
# a single value needs changing for one run.
profile = SCOPE_PROFILES[args.scope_profile]
scope_resource = args.scope_resource or profile["resource"]
if args.scope_ip:
scope_resource = SCOPE_LAN_RESOURCE % args.scope_ip
scope_dir = profile["dir"] if args.scope_dir is None else args.scope_dir
scope_copy_dir = (profile["copy_dir"] if args.scope_copy_dir is None
else args.scope_copy_dir)
if args.scenario == "move" and not args.link_test:
if not args.jig:
parser.error("--scenario move needs -j/--jig")
if args.jig_speed <= 0:
parser.error("--jig-speed must be positive")
if args.trigger_at is not None and args.trigger_at <= 0:
parser.error("--trigger-at must be positive")
distance = abs(args.move_from - args.move_to)
if distance == 0:
parser.error("--move-from and --move-to are the same")
travel = distance / args.jig_speed
# The whole travel, not half: step 5 re-commands the jig after
# JIG_MOVE_MAX_S, and a new target mid-move is undocumented behaviour.
if travel > JIG_MOVE_MAX_S:
parser.error("%d mm at %.1f mm/s takes %.1fs, over the %.1fs "
"move window (JIG_MOVE_MAX_S)"
% (distance, args.jig_speed, travel, JIG_MOVE_MAX_S))
if travel / 2.0 < 0.2:
print("WARNING: only %.0f ms before the cut; Modbus latency is "
"15-35 ms" % (travel / 2.0 * 1000), file=sys.stderr)
os.makedirs(RESULTS_DIR, exist_ok=True)
# One log per run, stamped like its CSV so the two pair up by name. A
# link test writes its own file with a _linktest suffix, so a quick probe
# never lands in the middle of a real run's log.
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
log_name = "ovp_test_%s%s.log" % (stamp,
"_linktest" if args.link_test else "")
log_path = os.path.join(RESULTS_DIR, log_name)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[logging.FileHandler(log_path, encoding="utf-8"),
logging.StreamHandler(sys.stdout)])
logger.info("Logging this run to: %s", os.path.abspath(log_path))
if args.link_test:
ok = run_link_test(args.rcu, args.tpu, args.relay, args.jig)
sys.exit(0 if ok else 1)
cfg = {
'cycles': args.cycles,
'current': args.current,
'current2': args.current2,
'ramp_at': args.ramp_at,
'ramp_timeout': args.ramp_timeout,
'scope_resource': None if args.no_scope else scope_resource,
'scope_dir': scope_dir,
'save_wf_below': args.save_wf_below,
'scope_copy_dir': scope_copy_dir,
'current_threshold': args.current_threshold,
'dwell': args.dwell,
'observe': args.observe,
'timeout': args.timeout,
'interval': args.interval,
'link_test': not args.no_link_test,
'scenario': args.scenario,
'move_x': args.move_x,
'move_y': args.move_y,
'move_from': args.move_from,
'move_to': args.move_to,
'jig_speed': args.jig_speed,
'trigger_at': args.trigger_at,
}
if args.scenario == "ramp":
if args.current is None or args.current2 is None:
parser.error("--scenario ramp needs both --current and --current2")
trip_at = args.current + (args.current2 - args.current) * args.ramp_at / 100.0
logger.info("Scenario ramp: %.1f A -> %.1f A, cut at %.0f%% = %.1f A",
args.current, args.current2, args.ramp_at, trip_at)
if args.scenario == "move":
distance, travel, trigger = move_timing(cfg)
logger.info("Scenario move: (%d,%d,%d) -> (%d,%d,%d), %d mm at "
"%.1f mm/s = %.2fs travel, cut at %.2fs",
args.move_x, args.move_y, args.move_from,
args.move_x, args.move_y, args.move_to,
distance, args.jig_speed, travel, trigger)
ovp_test(args.rcu, args.tpu, args.relay, args.jig, args.cycles, cfg,
stamp)
if __name__ == "__main__":
main()