This commit is contained in:
2026-07-21 00:14:43 +08:00
commit 4e3400ab87
3 changed files with 840 additions and 0 deletions
+590
View File
@@ -0,0 +1,590 @@
"""
Modbus Range Limiter Test Script (RPU + TPU)
=============================================
Auto-detects device type by reading HARDWARE_REV_HI (holding reg 163):
0x0021 → TPU (fenghuang-dsp)
0x0022 → RPU (garuda-dsp)
RPU trigger: write MAIN_FORCE_NORMAL_MODE password (addrs 48/49).
Firmware calls modbus_to_config() → LimitModbusRange() → write_all_flash() → soft_reset().
TPU trigger: write holding coil bit 49 (MODBUS_ALL_CFG_CMD_BIT, FC5).
update_modbus_input() detects write_flash==1 → modbus_to_config() → LimitModbusRange() → write_all_flash() → soft_reset().
Both write_all_flash() implementations call DINT then soft_reset() — the device
reboots immediately; the Modbus ACK for the trigger write may never arrive.
NoResponseError on the trigger write is expected and means the rewrite WAS triggered.
Data types:
'uint16' — single holding register, unsigned 16-bit (FC3 read / FC6 write)
'int16' — single holding register, signed 16-bit
'float32' — two consecutive holding registers, IEEE 754 single-precision,
stored big-endian (high word first) via EndianConvert32Bits.
Read with FC3 / write with FC16. Comparison uses FLOAT_EPSILON tolerance.
TPU float calibration limits are fixed constants from fenghuang-dsp/app/main/global_variables.h:
Gain limits = [nominal × 0.9, nominal × 1.1]
Offset limits = [nominal × 0.8, nominal × 1.2]
(For negative offset defaults this gives an inverted range; firmware clamping is verified as-is.)
PFC_current_rms_cali_B excluded (offset nominal = 0.0 → trivial limits).
Test phases (batch approach):
Phase 0: Read and save all defaults.
Phase 1: Write ALL below-min values → single trigger → read all back.
Phase 2: Write ALL above-max values → single trigger → read all back.
Restore: Write all defaults back → trigger.
Output: timestamped CSV report.
"""
import csv
import os
import time
import minimalmodbus
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
SLAVE_ID = 16 # DEFAULT_SLAVE_ID = 0x10
BAUD = 9600
TIMEOUT = 2 # seconds
REWRITE_WAIT = 10.0 # write_all_flash() → soft_reset() → full reboot; allow time
FLOAT_EPSILON = 1e-4 # relative tolerance for float readback comparison (0.01%)
# RPU trigger: MAIN_FORCE_NORMAL_MODE password (garuda-dsp modbus_mapping.h)
ADDR_PASSWORD_LO = 48 # MODBUS_FORCE_PASSWORD_LO
ADDR_PASSWORD_HI = 49 # MODBUS_FORCE_PASSWORD_HI
PASSWORD_LO = 0xFEED
PASSWORD_HI = 0xCEE5
# TPU trigger: holding coil bit 49 (fenghuang-dsp modbus_mapping.h)
TPU_BIT_WRITE_FLASH = 49 # MODBUS_ALL_CFG_CMD_BIT → main_app.write_flash
# Auto-detection register (holding reg 163, same address for both RPU and TPU)
ADDR_HARDWARE_REV_HI = 163 # MODBUS_HARDWARE_REVISION_HI
HW_REV_TPU = 0x0021
HW_REV_RPU = 0x0022
# ---------------------------------------------------------------------------
# RPU register test list (garuda-dsp)
# ---------------------------------------------------------------------------
# Columns: (label, addr, data_type, min_val, max_val, below_min_input, above_max_input)
#
# Limits source: garuda-dsp app/app_modbus/app_modbus.h + global_variables.h
RPU_TESTS = [
# ----- VI operation settings -----
('Voltage_setting_Q128', 12, 'uint16', 0, 8320, None, 8321),
('Current_setting_Q128', 14, 'uint16', 0, 3840, None, 3841),
('Power_limit_ref', 15, 'uint16', 0, 1500, None, 1501),
('No_Battery_Voltage_ref_Q128', 141, 'uint16', 0, 8320, None, 8321),
# ----- Protection thresholds -----
('Cutoff_voltage_Q128', 50, 'uint16', 0, 8320, None, 8321),
('OVP_setting_Q128', 11, 'uint16', 0, 8320, None, 8321),
('Cutoff_over_current_Q128', 10, 'uint16', 0, 4480, None, 4481),
('Cutoff_minimum_current_Q128', 51, 'uint16', 0, 640, None, 641),
# ----- Precharge / CV current -----
('Precharge_current', 6, 'int16', 0, 1280, None, 1281),
('CV_current_limit', 7, 'int16', 0, 1280, None, 1281),
# ----- SR control -----
('SR_limit_lo', 16, 'uint16', 0, 1280, None, 1281),
('SR_limit_hi', 17, 'uint16', 0, 1280, None, 1281),
('SR_duty_ref', 89, 'uint16', 20, 50, 19, 51),
('SR_delay_ref', 91, 'int16', 0, 100, None, 101),
# ----- ADC calibration -----
('Adc_cali_a_Iout', 20, 'int16', 950, 1100, 949, 1101),
('Adc_cali_b_Iout', 21, 'int16', -32767, 32767, -32768, None),
('Adc_cali_a_Vdcbus', 22, 'int16', 950, 1100, 949, 1101),
('Adc_cali_b_Vdcbus', 23, 'int16', -32767, 32767, -32768, None),
('Adc_cali_a_Vbatt', 24, 'int16', 950, 1100, 949, 1101),
('Adc_cali_b_Vbatt', 25, 'int16', -32767, 32767, -32768, None),
# ----- CAN / Comm -----
('CAN_bit_rate_kbps', 100, 'uint16', 100, 1000, 99, 1001),
('CAN_protocol_select', 125, 'uint16', 0, 101, None, 102),
('comm_protocol', 126, 'uint16', 0, 1, None, 2),
('CAN_node_ID', 138, 'uint16', 1, 127, 0, 128),
# ----- BLE -----
('BLE_Conn_mode', 142, 'uint16', 0, 2, None, 3),
('RSSI_auto_threshold', 105, 'int16', -120, 0, -121, 1),
('RSSI_max_voltage_Q128', 106, 'uint16', 0, 8320, None, 8321),
# ----- Temperature thresholds — dev -----
('Dev_temp_thres_stage_1', 42, 'int16', -40, 150, -41, 151),
('Dev_temp_thres_stage_2', 43, 'int16', -40, 150, -41, 151),
('Dev_temp_thres_stage_3', 44, 'int16', -40, 150, -41, 151),
# ----- Temperature thresholds — coil -----
('Coil_temp_thres_stage_1', 107, 'int16', -40, 150, -41, 151),
('Coil_temp_thres_stage_2', 108, 'int16', -40, 150, -41, 151),
('Coil_temp_thres_stage_3', 109, 'int16', -40, 150, -41, 151),
# ----- Temperature thresholds — MCU -----
('MCU_temp_thres_stage_1', 92, 'int16', -40, 150, -41, 151),
('MCU_temp_thres_stage_2', 93, 'int16', -40, 150, -41, 151),
('MCU_temp_thres_stage_3', 94, 'int16', -40, 150, -41, 151),
# ----- Current derating (%) -----
('Current_derating_1', 45, 'uint16', 0, 50, None, 51),
('Current_derating_2', 46, 'uint16', 0, 50, None, 51),
# ----- Ramp control -----
('Ramp_control_mA_per_sec', 111, 'uint16', 0, 50000, None, 50001),
('Ramp_slow_mA_per_sec', 136, 'uint16', 0, 50000, None, 50001),
('Ramp_current_dV', 137, 'uint16', 0, 1280, None, 1281),
# ----- Discovery / height -----
('Discovery_Timeout_sec', 128, 'uint16', 0, 120, None, 121),
('RPU_Height_mm', 98, 'uint16', 0, 1500, None, 1501),
]
# ---------------------------------------------------------------------------
# TPU register test list (fenghuang-dsp)
# ---------------------------------------------------------------------------
# Limits source: fenghuang-dsp app/app_modbus/app_modbus.h, global_variables.h,
# sec_control.h, fan.h, app_inv.h
#
# float32: two consecutive holding registers (EndianConvert32Bits → BYTEORDER_BIG).
# Written via FC16 (write multiple registers).
#
# Calibration gain (A): limits = [nominal × 0.9, nominal × 1.1]
# Calibration offset (B) with positive nominal: limits = [nominal × 0.8, nominal × 1.2]
# Calibration offset (B) with negative nominal: limits are inverted in firmware
# (lo_limit = nominal×0.8, up_limit = nominal×1.2 where lo > up numerically).
# min_val = lo_limit (expected clamp when src too negative),
# max_val = up_limit (expected clamp when src too positive/less negative).
# PFC_current_rms_cali_B excluded (nominal = 0.0).
TPU_TESTS = [
# ----- PFC power (uint16) -----
('Power_limit', 47, 'uint16', 0, 1900, None, 1901),
# ----- Coil voltage max (uint16) -----
('Coil_voltage_max', 78, 'uint16', 0, 1000, None, 1001),
# ----- RPU setpoints (uint16) -----
('RPU_current_set_Q128', 98, 'uint16', 0, 3840, None, 3841),
('RPU_voltage_set_Q128', 99, 'uint16', 0, 8320, None, 8321),
# ----- Fan control (uint16 / int16) -----
('Fan_mode', 69, 'uint16', 0, 255, None, 256),
('Fan_duty_set', 70, 'uint16', 0, 100, None, 101),
('Fan_temp_lo', 71, 'int16', -40, 80, -41, 81),
('Fan_temp_hi', 72, 'int16', -40, 80, -41, 81),
# ----- Moving-away detection (uint16) -----
('Moving_away_time_ms', 3, 'uint16', 50, 500, 49, 501),
('Move_away_iout_thres', 84, 'uint16', 0, 50, None, 51),
# ----- OVP (uint16) -----
('OVP_iinv_pk_threshold', 75, 'uint16', 0, 50, None, 51),
('OVP_phase_threshold', 76, 'uint16', 0, 500, None, 501),
('OVP_count_threshold', 77, 'uint16', 0, 10, None, 11),
# ----- Phase command min (uint16) -----
('Phase_cmd_min', 101, 'uint16', 0, 750, None, 751),
# ----- No-batt coupling ramp (uint16) -----
('No_batt_coupling_ramp_phase', 109, 'uint16', 0, 300, None, 301),
# ----- BLE (uint16) -----
('BLE_discon_timeout_ms', 2, 'uint16', 0, 60000, None, 60001),
('BLE_mode', 5, 'uint16', 0, 2, None, 3),
# ----- Current derating (uint16, stored as integer cast from float) -----
('Current_derating_1', 107, 'uint16', 0, 50, None, 51),
('Current_derating_2', 108, 'uint16', 0, 50, None, 51),
# ----- INV temperature thresholds (int16) -----
('INV_temp_thres_1', 60, 'int16', -40, 150, -41, 151),
('INV_temp_thres_2', 61, 'int16', -40, 150, -41, 151),
('INV_temp_thres_3', 62, 'int16', -40, 150, -41, 151),
# ----- PFC temperature thresholds (int16) -----
('PFC_temp_thres_1', 63, 'int16', -40, 150, -41, 151),
('PFC_temp_thres_2', 64, 'int16', -40, 150, -41, 151),
('PFC_temp_thres_3', 65, 'int16', -40, 150, -41, 151),
# ----- Coil temperature thresholds (int16) -----
('Coil_temp_thres_1', 66, 'int16', -40, 150, -41, 151),
('Coil_temp_thres_2', 67, 'int16', -40, 150, -41, 151),
('Coil_temp_thres_3', 68, 'int16', -40, 150, -41, 151),
# ----- MCU temperature thresholds (int16) -----
('MCU_temp_thres_1', 104, 'int16', -40, 150, -41, 151),
('MCU_temp_thres_2', 105, 'int16', -40, 150, -41, 151),
('MCU_temp_thres_3', 106, 'int16', -40, 150, -41, 151),
# ----- Coupling estimation frequencies (float32) -----
# INV_FREQ_MIN = 60.0 Hz, INV_FREQ_MAX = 150.0 Hz (app_inv.h)
('Coupling_est_f_lo', 6, 'float32', 60.0, 150.0, 59.0, 151.0),
('Coupling_est_f_hi', 8, 'float32', 60.0, 150.0, 59.0, 151.0),
('Coupling_est_f3', 102, 'float32', 60.0, 150.0, 59.0, 151.0),
('Coupling_est_iinv_ref', 10, 'float32', 0.0, 40.0, None, 41.0),
# ----- INV frequency settings (float32) -----
('INV_freq_set', 28, 'float32', 60.0, 150.0, 59.0, 151.0),
('INV_freq_min', 30, 'float32', 60.0, 150.0, 59.0, 151.0),
('INV_freq_max', 32, 'float32', 60.0, 150.0, 59.0, 151.0),
('INV_current_limit', 34, 'float32', 0.0, 40.0, None, 41.0),
# ----- PFC voltage / current (float32) -----
# MODBUS_LIMIT_PFC_DC_BUS_VOLTAGE_MAX_CEIL = 450.0, PFC_CURRENT_MAX_CEIL = 40.0
('PFC_vdc_ref', 40, 'float32', 0.0, 450.0, None, 451.0),
('PFC_voltage_limit', 42, 'float32', 0.0, 450.0, None, 451.0),
('PFC_current_limit', 44, 'float32', 0.0, 40.0, None, 41.0),
# ----- Protection current / voltage limits (float32) -----
('PFC_current_max', 48, 'float32', 0.0, 40.0, None, 41.0),
('INV_current_max', 50, 'float32', 0.0, 40.0, None, 41.0),
('INV_current_min', 170, 'float32', 0.0, 40.0, None, 41.0),
('DC_bus_voltage_min', 52, 'float32', 0.0, 450.0, None, 451.0),
('DC_bus_voltage_max', 54, 'float32', 0.0, 450.0, None, 451.0),
# MODBUS_LIMIT_AC_IN_VOLTAGE_MAX_CEIL = 400.0
('AC_in_voltage_min', 56, 'float32', 0.0, 400.0, None, 401.0),
('AC_in_voltage_max', 58, 'float32', 0.0, 400.0, None, 401.0),
# ----- No-batt coupling INV current (float32) -----
('No_batt_coupling_inv_current', 168, 'float32', 0.0, 40.0, None, 41.0),
# ----- ADC calibration gain (A) registers (float32) -----
# Limits = [nominal × 0.9, nominal × 1.1] (compile-time constants in firmware)
# Nominal values from global_variables.h:
# ANA_PFC_CURRENT_GAIN = 0.022127795
# ANA_INV_CURRENT_GAIN = 0.024940704
# ANA_DC_BUS_VOLTAGE_GAIN = 0.118472142
# ANA_AC_IN_VOLTAGE_GAIN = 0.118678347
# ANA_COIL_VOLTAGE_GAIN = 0.191968026
# ANA_PFC_CURRENT_RMS_GAIN = 1.0
('PFC_current_cali_A', 12, 'float32', 0.019915, 0.024341, 0.018809, 0.025447),
('INV_current_cali_A', 16, 'float32', 0.022447, 0.027435, 0.021200, 0.028683),
('DC_bus_voltage_cali_A', 20, 'float32', 0.106625, 0.130319, 0.100701, 0.136243),
('AC_in_voltage_cali_A', 24, 'float32', 0.106811, 0.130546, 0.100877, 0.136480),
('Coil_voltage_cali_A', 80, 'float32', 0.172771, 0.211165, 0.163173, 0.220763),
('PFC_current_rms_cali_A', 110, 'float32', 0.9, 1.1, 0.85, 1.15),
# ----- ADC calibration offset (B) registers (float32) -----
# Limits = [nominal × 0.8, nominal × 1.2]
# Nominal values from global_variables.h:
# ANA_PFC_CURRENT_OFFSET = -0.469129801 (negative → inverted range in firmware)
# ANA_INV_CURRENT_OFFSET = -51.38759228 (negative → inverted range in firmware)
# ANA_DC_BUS_VOLTAGE_OFFSET = 0.391120149 (positive)
# ANA_AC_IN_VOLTAGE_OFFSET = 0.639814597 (positive)
# ANA_COIL_VOLTAGE_OFFSET = -7.280746292 (negative → inverted range in firmware)
# ANA_PFC_CURRENT_RMS_OFFSET = 0.0 (excluded: trivial limits)
#
# For negative nominals: min_val = lo_limit = nominal×0.8 (less negative),
# max_val = up_limit = nominal×1.2 (more negative).
# Phase 1 writes a more-negative value (<min_val) → expect clamp to min_val.
# Phase 2 writes a less-negative value (>max_val) → expect clamp to max_val.
('PFC_current_cali_B', 14, 'float32', -0.375304, -0.562956, -0.65, -0.30),
('INV_current_cali_B', 18, 'float32', -41.11007, -61.66511, -70.0, -30.0),
('DC_bus_voltage_cali_B', 22, 'float32', 0.312896, 0.469344, 0.293, 0.490),
('AC_in_voltage_cali_B', 26, 'float32', 0.511852, 0.767778, 0.480, 0.800),
('Coil_voltage_cali_B', 82, 'float32', -5.824597, -8.736896, -10.0, -4.0),
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _is_float(data_type: str) -> bool:
return data_type == 'float32'
def read_reg(instr: minimalmodbus.Instrument, addr: int, data_type: str):
if _is_float(data_type):
return instr.read_float(addr, functioncode=3, number_of_registers=2,
byteorder=minimalmodbus.BYTEORDER_BIG)
signed = (data_type == 'int16')
return instr.read_register(addr, number_of_decimals=0, functioncode=3, signed=signed)
def write_reg(instr: minimalmodbus.Instrument, addr: int, value, data_type: str) -> None:
if _is_float(data_type):
instr.write_float(addr, float(value), functioncode=16, number_of_registers=2,
byteorder=minimalmodbus.BYTEORDER_BIG)
return
signed = (data_type == 'int16')
instr.write_register(addr, int(value), number_of_decimals=0, functioncode=6, signed=signed)
def _check_result(readback, expected, data_type: str) -> str:
if readback == 'ERR':
return 'ERR'
if _is_float(data_type):
if abs(expected) < 1e-9:
ok = abs(readback - expected) < FLOAT_EPSILON
else:
ok = abs(readback - expected) / abs(expected) < FLOAT_EPSILON
return 'PASS' if ok else 'FAIL'
return 'PASS' if readback == expected else 'FAIL'
def _wait_for_device(instr: minimalmodbus.Instrument, poll_addr: int) -> None:
deadline = time.monotonic() + REWRITE_WAIT
while time.monotonic() < deadline:
time.sleep(0.5)
try:
instr.read_register(poll_addr, number_of_decimals=0, functioncode=3, signed=False)
return
except minimalmodbus.ModbusException:
pass
print(" (trigger: device did not respond within REWRITE_WAIT — may not have triggered)")
def trigger_rewrite_rpu(instr: minimalmodbus.Instrument) -> None:
"""Write MAIN_FORCE_NORMAL_MODE password → modbus_to_config() + write_all_flash() + soft_reset()."""
instr.write_register(ADDR_PASSWORD_LO, PASSWORD_LO, 0, functioncode=6)
try:
instr.write_register(ADDR_PASSWORD_HI, PASSWORD_HI, 0, functioncode=6)
print(" (trigger: PASSWORD_HI ack received)")
except minimalmodbus.NoResponseError:
print(" (trigger: NoResponseError — device started flash write immediately)")
_wait_for_device(instr, ADDR_PASSWORD_LO)
def trigger_rewrite_tpu(instr: minimalmodbus.Instrument) -> None:
"""Write coil bit 49 (MODBUS_ALL_CFG_CMD_BIT) → modbus_to_config() + write_all_flash() + soft_reset()."""
try:
instr.write_bit(TPU_BIT_WRITE_FLASH, 1, functioncode=5)
print(" (trigger: coil bit write ack received)")
except minimalmodbus.NoResponseError:
print(" (trigger: NoResponseError — device started flash write immediately)")
_wait_for_device(instr, ADDR_HARDWARE_REV_HI)
def detect_device_type(instr: minimalmodbus.Instrument) -> str:
rev_hi = instr.read_register(ADDR_HARDWARE_REV_HI, number_of_decimals=0, functioncode=3, signed=False)
print(f" HARDWARE_REV_HI = 0x{rev_hi:04X} ({rev_hi})")
if rev_hi == HW_REV_TPU:
return 'TPU'
elif rev_hi == HW_REV_RPU:
return 'RPU'
else:
print(f" WARNING: unknown HW_REV_HI — defaulting to RPU")
return 'RPU'
# ---------------------------------------------------------------------------
# Main test logic
# ---------------------------------------------------------------------------
def run_tests(port: str) -> tuple[str, list[dict]]:
instr = minimalmodbus.Instrument(port=port, slaveaddress=SLAVE_ID, mode='rtu')
instr.serial.baudrate = BAUD
instr.serial.timeout = TIMEOUT
print(f"Connected to {port} slave=0x{SLAVE_ID:02X} ({SLAVE_ID}) baud={BAUD}")
# -------------------------------------------------------------------------
# Auto-detect device type
# -------------------------------------------------------------------------
print("\n[Detection] Reading HARDWARE_REV_HI...")
device_type = detect_device_type(instr)
if device_type == 'TPU':
TESTS = TPU_TESTS
trigger_fn = trigger_rewrite_tpu
else:
TESTS = RPU_TESTS
trigger_fn = trigger_rewrite_rpu
print(f" Device type: {device_type}{len(TESTS)} registers in test list")
# -------------------------------------------------------------------------
# Phase 0: Save all defaults
# -------------------------------------------------------------------------
print("\n[Phase 0] Saving default values...")
defaults: dict[int, any] = {}
for _, addr, data_type, *_ in TESTS:
if addr not in defaults:
try:
defaults[addr] = read_reg(instr, addr, data_type)
val = defaults[addr]
print(f" addr {addr:3d}: {val:.6g}" if _is_float(data_type) else f" addr {addr:3d}: {val}")
except minimalmodbus.ModbusException as e:
print(f" addr {addr:3d}: ERROR — {e}")
defaults[addr] = None
# -------------------------------------------------------------------------
# Phase 1: Write ALL below-min values → single rewrite → read all back
# -------------------------------------------------------------------------
print("\n[Phase 1] Writing all below-min values...")
for (label, addr, data_type, min_val, max_val, below_input, above_input) in TESTS:
if below_input is not None and defaults.get(addr) is not None:
try:
write_reg(instr, addr, below_input, data_type)
fmt = f"{below_input:.6g}" if _is_float(data_type) else str(below_input)
print(f" addr {addr:3d} ({label}): wrote {fmt}")
except minimalmodbus.ModbusException as e:
print(f" addr {addr:3d} ({label}): write ERROR — {e}")
print("\n[Phase 1] Triggering rewrite...")
trigger_fn(instr)
print("[Phase 1] Reading back...")
below_readbacks: dict[int, any] = {}
for (_, addr, data_type, *_) in TESTS:
if addr not in below_readbacks:
try:
below_readbacks[addr] = read_reg(instr, addr, data_type)
except minimalmodbus.ModbusException:
below_readbacks[addr] = 'ERR'
# -------------------------------------------------------------------------
# Phase 2: Write ALL above-max values → single rewrite → read all back
# -------------------------------------------------------------------------
print("\n[Phase 2] Writing all above-max values...")
for (label, addr, data_type, min_val, max_val, below_input, above_input) in TESTS:
if above_input is not None and defaults.get(addr) is not None:
try:
write_reg(instr, addr, above_input, data_type)
fmt = f"{above_input:.6g}" if _is_float(data_type) else str(above_input)
print(f" addr {addr:3d} ({label}): wrote {fmt}")
except minimalmodbus.ModbusException as e:
print(f" addr {addr:3d} ({label}): write ERROR — {e}")
print("\n[Phase 2] Triggering rewrite...")
trigger_fn(instr)
print("[Phase 2] Reading back...")
above_readbacks: dict[int, any] = {}
for (_, addr, data_type, *_) in TESTS:
if addr not in above_readbacks:
try:
above_readbacks[addr] = read_reg(instr, addr, data_type)
except minimalmodbus.ModbusException:
above_readbacks[addr] = 'ERR'
# -------------------------------------------------------------------------
# Restore: write all defaults → single rewrite
# -------------------------------------------------------------------------
print("\n[Restore] Writing all defaults...")
for (_, addr, data_type, *_) in TESTS:
default = defaults.get(addr)
if default is not None:
try:
write_reg(instr, addr, default, data_type)
except minimalmodbus.ModbusException as e:
print(f" addr {addr:3d}: restore write ERROR — {e}")
trigger_fn(instr)
print("[Restore] Done.")
# -------------------------------------------------------------------------
# Compile results
# -------------------------------------------------------------------------
results = []
for (label, addr, data_type, min_val, max_val, below_input, above_input) in TESTS:
default = defaults.get(addr)
def _fmt(v):
if isinstance(v, float):
return f"{v:.6g}"
return v
row = {
'label': label,
'data_type': data_type,
'default': _fmt(default) if default is not None else 'ERR',
'min': _fmt(min_val),
'max': _fmt(max_val),
'below_input': 'N/A',
'below_readback': 'N/A',
'below_result': 'N/A',
'above_input': 'N/A',
'above_readback': 'N/A',
'above_result': 'N/A',
}
if below_input is not None and default is not None:
rb = below_readbacks.get(addr, 'ERR')
row['below_input'] = _fmt(below_input)
row['below_readback'] = _fmt(rb) if rb != 'ERR' else 'ERR'
row['below_result'] = _check_result(rb, min_val, data_type)
if above_input is not None and default is not None:
rb = above_readbacks.get(addr, 'ERR')
row['above_input'] = _fmt(above_input)
row['above_readback'] = _fmt(rb) if rb != 'ERR' else 'ERR'
row['above_result'] = _check_result(rb, max_val, data_type)
results.append(row)
print(f"\n{''*60}")
print("All tests complete.")
return device_type, results
# ---------------------------------------------------------------------------
# CSV report
# ---------------------------------------------------------------------------
def write_csv(device_type: str, results: list[dict]) -> None:
import datetime
script_dir = os.path.dirname(os.path.abspath(__file__))
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
prefix = device_type.lower()
csv_path = os.path.join(script_dir, f'{prefix}_modbus_limiter_report_{ts}.csv')
with open(csv_path, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f)
writer.writerow([
'Register',
'Data type',
'Default',
'Min',
'Max',
'Below-min input',
'Readback (below-min)',
'Below-min result',
'Above-max input',
'Readback (above-max)',
'Above-max result',
])
for row in results:
writer.writerow([
row['label'],
row['data_type'],
row['default'],
row['min'],
row['max'],
row['below_input'],
row['below_readback'],
row['below_result'],
row['above_input'],
row['above_readback'],
row['above_result'],
])
total = pass_count = fail_count = na_count = err_count = 0
for row in results:
for key in ('below_result', 'above_result'):
v = row[key]
if v == 'N/A':
na_count += 1
continue
total += 1
if v == 'PASS':
pass_count += 1
elif v == 'FAIL':
fail_count += 1
else:
err_count += 1
print(f"\nReport saved: {csv_path}")
print(f"Results — PASS: {pass_count} FAIL: {fail_count} ERROR: {err_count} N/A: {na_count} (total tests: {total})")
# ---------------------------------------------------------------------------
if __name__ == '__main__':
port_num = input("Enter COM port number (e.g. 73 for COM73): ").strip()
port = f"COM{port_num}"
print(f"Using port: {port}")
device_type, results = run_tests(port)
if results:
write_csv(device_type, results)