#!/usr/bin/env python3 """Long-duration four-axis PLSR/Modbus/optional USB concurrency test. Requires the dedicated P18 firmware configuration (K4, soft limits disabled). The tool creates one long PULSE/DIR segment on every axis, continuously reads generation-protected status snapshots, alternates the live frequency using one FC16 transaction per 32-bit value, and writes CSV plus JSON evidence. Optional bad-CRC and planned-disconnect probes are disabled unless explicitly requested. No persistence SAVE command is issued by this script. """ from __future__ import annotations import argparse import csv import json import math import struct import threading import time from datetime import datetime, timezone from pathlib import Path from typing import Any import serial from plsr_modbus_frequency_test import RtuClient, add_crc, signed_dword_words CONTROL_BASE = 1200 CONTROL_WINDOW_WORDS = 338 PERFORMANCE_VERSION = 7 CALL_REQUEST = CONTROL_BASE + 8 CALL_RESPONSE = CONTROL_BASE + 24 COMMAND_REQUEST = CONTROL_BASE + 40 COMMAND_RESPONSE = CONTROL_BASE + 48 AXIS_STATUS_BASE = CONTROL_BASE + 64 AXIS_STATUS_WORDS = 48 USB_DIAGNOSTICS_BASE = CONTROL_BASE + 316 USB_DIAGNOSTICS_WORDS = 22 USB_DIAGNOSTICS_VERSION = 1 S0_BASES = (1600, 1800, 2000, 2200) S1_BASES = (1700, 1900, 2100, 2300) RUNTIME_DIAGNOSTICS_FUNCTION = 0x47 RUNTIME_DIAGNOSTICS_SIGNATURE = 0x4D42 RUNTIME_DIAGNOSTICS_VERSION = 1 RUNTIME_DIAGNOSTICS_WORDS = 40 RESULT_OK = 0 RESULT_QUEUED = 1 RESULT_INVALID_STATE = 4 STATE_IDLE = 1 STATE_ACCEL = 2 STATE_RUN = 3 STATE_DECEL = 4 STATE_COMPLETED = 7 STATE_STOPPED = 8 STATE_ERROR = 9 CALL_COMMIT = 1 CALL_START = 2 CMD_STOP_IMMEDIATE = 2 CMD_SET_POSITION = 5 CSV_FIELDS = ( "host_time_utc", "elapsed_s", "sample", "axis", "state", "flags", "error", "last_result", "counter_mode", "current_frequency", "target_frequency", "logical_position", "task_pulses", "physical_pulses", "snapshot_retries", "modbus_valid_frames", "modbus_tx_frames", "modbus_crc_errors", "modbus_dropped_frames", "modbus_uart_errors", "modbus_restart_failures", "modbus_last_uart_error", ) def put_u32(words: list[int], offset: int, value: int) -> None: words[offset : offset + 2] = signed_dword_words(value) def put_u64(words: list[int], offset: int, value: int) -> None: raw = value & 0xFFFFFFFFFFFFFFFF words[offset : offset + 4] = [ (raw >> shift) & 0xFFFF for shift in (0, 16, 32, 48) ] def get_u32(words: list[int], offset: int) -> int: return words[offset] | (words[offset + 1] << 16) def get_u64(words: list[int], offset: int, *, signed: bool = False) -> int: value = sum(words[offset + index] << (16 * index) for index in range(4)) if signed and value & (1 << 63): value -= 1 << 64 return value def wait_response( client: RtuClient, address: int, quantity: int, sequence: int, timeout: float = 3.0, ) -> list[int]: deadline = time.monotonic() + timeout latest: list[int] = [] while time.monotonic() < deadline: latest = client.read_holding(address, quantity) if get_u32(latest, 0) == sequence: return latest raise RuntimeError(f"等待命令序号 {sequence} 应答超时;最后应答={latest}") def send_command( client: RtuClient, sequence: int, axis: int, opcode: int, argument: int = 0, ) -> list[int]: request = [0] * 8 put_u32(request, 0, sequence) request[2] = opcode request[3] = axis put_u64(request, 4, argument) client.write_multiple(COMMAND_REQUEST, request) return wait_response(client, COMMAND_RESPONSE, 8, sequence) def send_call( client: RtuClient, sequence: int, axis: int, operation: int, ) -> list[int]: request = [0] * 16 put_u32(request, 0, sequence) request[2] = 0 # S0 is D put_u32(request, 3, S0_BASES[axis]) request[5] = 0 # S1 is D put_u32(request, 6, S1_BASES[axis]) request[8] = 0 # S2 is constant K4 (dedicated P18 long-stress setup) put_u32(request, 10, 4) request[12] = axis request[13] = 0 # PULSE/DIR request[14] = operation client.write_multiple(CALL_REQUEST, request) return wait_response(client, CALL_RESPONSE, 12, sequence) def check_result( response: list[int], offset: int, expected: int, label: str ) -> None: if response[offset] != expected: raise RuntimeError( f"{label} 返回 {response[offset]},期望 {expected};应答={response}" ) def read_axis_status( client: RtuClient, axis: int, attempts: int = 4 ) -> tuple[dict[str, int], int]: """Read one coherent generation-guarded axis status snapshot.""" address = AXIS_STATUS_BASE + axis * AXIS_STATUS_WORDS for retry in range(attempts): words = client.read_holding(address, AXIS_STATUS_WORDS) generation_begin = get_u32(words, 0) generation_end = get_u32(words, 46) if generation_begin == generation_end and not generation_begin & 1: return ( { "generation": generation_begin, "state": words[2], "flags": get_u32(words, 3), "error": words[6], "stop_reason": words[7], "last_result": words[8], "last_sequence": get_u32(words, 10), "logical_position": get_u64(words, 16, signed=True), "task_pulses": get_u64(words, 20, signed=True), "physical_pulses": get_u64(words, 28), "counter_mode": words[37], "current_frequency": get_u32(words, 38), "target_frequency": get_u32(words, 40), }, retry, ) raise RuntimeError(f"轴{axis}状态快照连续 {attempts} 次版本不一致") def read_runtime_diagnostics(client: RtuClient) -> dict[str, Any]: pdu = bytes((RUNTIME_DIAGNOSTICS_FUNCTION,)) + struct.pack( ">HH", 0, RUNTIME_DIAGNOSTICS_WORDS ) response = client.exchange(pdu, 5 + RUNTIME_DIAGNOSTICS_WORDS * 2) if ( response[1] != RUNTIME_DIAGNOSTICS_FUNCTION or response[2] != RUNTIME_DIAGNOSTICS_WORDS * 2 ): raise RuntimeError(f"0x47 诊断应答格式错误:{response.hex(' ')}") words = list( struct.unpack(f">{RUNTIME_DIAGNOSTICS_WORDS}H", response[3:-2]) ) if words[:3] != [ RUNTIME_DIAGNOSTICS_SIGNATURE, RUNTIME_DIAGNOSTICS_VERSION, RUNTIME_DIAGNOSTICS_WORDS, ]: raise RuntimeError(f"Modbus 运行诊断版本不匹配:{words[:3]}") stat_names = ( "rx_events", "valid_frames", "tx_frames", "crc_errors", "ignored_addresses", "illegal_functions", "illegal_addresses", "illegal_values", "dropped_frames", "uart_errors", ) statistics = { name: get_u32(words, 20 + index * 2) for index, name in enumerate(stat_names) } return { "flags": words[3], "initialized": bool(words[3] & (1 << 0)), "connected": bool(words[3] & (1 << 2)), "tx_busy": bool(words[3] & (1 << 3)), "rx_restart_ok": bool(words[3] & (1 << 6)), "current_tick": get_u32(words, 4), "last_valid_frame_tick": get_u32(words, 6), "last_inter_frame_gap_cycles": get_u32(words, 8), "restart_attempts": get_u32(words, 10), "restart_failures": get_u32(words, 12), "last_uart_error": get_u32(words, 14), "last_receive_start_status": words[16], "rx_assembly_length": words[17], "rx_frame_length": words[18], "statistics": statistics, } def read_usb_diagnostics( client: RtuClient, attempts: int = 4 ) -> dict[str, Any]: """Read one coherent generation-guarded USB CDC diagnostic block.""" counter_names = ( "rx_packet_count", "rx_byte_count", "rx_rearm_failure_count", "tx_request_count", "tx_byte_count", "tx_busy_count", "tx_failure_count", "tx_complete_count", ) for retry in range(attempts): words = client.read_holding(USB_DIAGNOSTICS_BASE, USB_DIAGNOSTICS_WORDS) generation_begin = get_u32(words, 0) generation_end = get_u32(words, 20) if generation_begin == generation_end and not generation_begin & 1: if words[2] != USB_DIAGNOSTICS_VERSION: raise RuntimeError( "USB CDC 诊断版本不匹配:" f"读取={words[2]},要求={USB_DIAGNOSTICS_VERSION}" ) return { "generation": generation_begin, "version": words[2], "initialized": bool(words[3]), "snapshot_retries": retry, "counters": { name: get_u32(words, 4 + index * 2) for index, name in enumerate(counter_names) }, } raise RuntimeError(f"USB CDC 诊断块连续 {attempts} 次版本不一致") def inject_bad_crc(port: serial.Serial, slave: int, baud: int) -> None: """Send a read-only request with a deliberately invalid CRC.""" valid = add_crc(bytes((slave, 0x03)) + struct.pack(">HH", CONTROL_BASE, 1)) malformed = valid[:-1] + bytes((valid[-1] ^ 0x01,)) port.reset_input_buffer() port.write(malformed) port.flush() # Match the firmware's Modbus RTU timing rule and leave a small host margin. t35_seconds = 0.00175 if baud > 19_200 else (3.5 * 11.0 / baud) time.sleep(t35_seconds + 0.005) def open_modbus(args: argparse.Namespace) -> tuple[serial.Serial, RtuClient]: port = serial.Serial( port=args.port, baudrate=args.baud, bytesize=serial.EIGHTBITS, parity=serial.PARITY_EVEN, stopbits=serial.STOPBITS_ONE, timeout=args.timeout, write_timeout=args.timeout, ) return port, RtuClient(port, args.slave) def reopen_modbus(args: argparse.Namespace) -> tuple[serial.Serial, RtuClient]: """Reopen a planned/lost link with a bounded number of host retries.""" last_error: BaseException | None = None attempts = max(1, args.max_communication_errors) for _ in range(attempts): try: return open_modbus(args) except (OSError, serial.SerialException) as error: last_error = error time.sleep(args.reconnect_delay) raise RuntimeError( f"连续 {attempts} 次无法重新打开 {args.port}:{last_error}" ) from last_error class UsbOutPressure: def __init__(self, port: str | None, bytes_per_second: int) -> None: self.port = port self.bytes_per_second = bytes_per_second self.bytes_written = 0 self.write_errors = 0 self.last_error = "" self._stop = threading.Event() self._thread: threading.Thread | None = None def start(self) -> None: if self.port is None: return self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() def stop(self) -> None: self._stop.set() if self._thread is not None: self._thread.join(timeout=3.0) def _run(self) -> None: payload = (b"PLSR-USB-CDC-OUT-STRESS-" * 3)[:64] try: with serial.Serial( self.port, baudrate=115200, timeout=0.2, write_timeout=1.0, ) as usb: next_send = time.monotonic() while not self._stop.is_set(): usb.write(payload) usb.flush() self.bytes_written += len(payload) if self.bytes_per_second > 0: next_send += len(payload) / self.bytes_per_second delay = next_send - time.monotonic() if delay > 0: self._stop.wait(delay) elif delay < -1.0: next_send = time.monotonic() except (OSError, serial.SerialException) as error: self.write_errors += 1 self.last_error = str(error) def summary(self) -> dict[str, Any]: return { "port": self.port, "target_bytes_per_second": self.bytes_per_second, "bytes_written": self.bytes_written, "write_errors": self.write_errors, "last_error": self.last_error, } def prepare_jobs(client: RtuClient, frequency: int, pulses: int) -> None: for axis in range(4): s0 = [0] * 20 put_u32(s0, 0, 1) put_u32(s0, 10, frequency) put_u32(s0, 12, pulses) client.write_multiple(S0_BASES[axis], s0) client.write_multiple(S1_BASES[axis], [0] * 4) def stop_all_axes(client: RtuClient, sequence: int) -> int: send_failures: list[str] = [] for axis in range(4): try: response = send_command( client, sequence, axis, CMD_STOP_IMMEDIATE, argument=0 ) if response[4] not in { RESULT_OK, RESULT_QUEUED, RESULT_INVALID_STATE, }: send_failures.append( f"轴{axis} STOP_IMMEDIATE 返回 {response[4]}" ) except (RuntimeError, serial.SerialException, OSError) as error: # Never let one failed/already-stopped axis prevent stop attempts # for the remaining axes. send_failures.append(f"轴{axis} STOP_IMMEDIATE 异常:{error}") sequence += 1 deadline = time.monotonic() + 8.0 latest: dict[int, dict[str, int]] = {} terminal_states = {STATE_IDLE, STATE_COMPLETED, STATE_STOPPED, STATE_ERROR} while time.monotonic() < deadline: for axis in range(4): try: latest[axis] = read_axis_status(client, axis)[0] except (RuntimeError, serial.SerialException, OSError) as error: send_failures.append(f"轴{axis}停止状态读取异常:{error}") if len(latest) == 4 and all( latest[axis]["state"] in terminal_states and (latest[axis]["flags"] & (1 << 1)) == 0 for axis in range(4) ): return sequence raise RuntimeError( "STOP_IMMEDIATE 后仍有轴未确认安全停止:" f"status={latest};发送/读取异常={send_failures}" ) def delta32(end: int, start: int) -> int: return (end - start) & 0xFFFFFFFF def is_retryable_communication_error(error: BaseException) -> bool: if isinstance(error, serial.SerialException): return True if not isinstance(error, RuntimeError): return False message = str(error) return message.startswith("响应超时:") or message.startswith("响应 CRC 错误:") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="PLSR Modbus/USB 长稳并发测试") parser.add_argument("--port", default="COM5", help="Modbus RTU 串口,默认 COM5") parser.add_argument("--baud", type=int, default=9600) parser.add_argument("--slave", type=int, default=1) parser.add_argument("--timeout", type=float, default=1.5) parser.add_argument("--duration", type=float, default=1800.0, help="运行秒数") parser.add_argument("--frequency", type=int, default=100_000) parser.add_argument( "--low-frequency", type=int, help="动态频率低值,默认主频率的一半", ) parser.add_argument("--status-period", type=float, default=1.0) parser.add_argument( "--frequency-period", type=float, default=10.0, help="动态频率切换周期;0 表示禁用", ) parser.add_argument( "--bad-crc-period", type=float, default=0.0, help="坏 CRC 注入周期;默认 0(禁用)", ) parser.add_argument( "--disconnect-at", type=float, default=0.0, help="运行到指定秒数时主动断开串口;默认 0(禁用)", ) parser.add_argument("--disconnect-duration", type=float, default=3.0) parser.add_argument("--usb-port", help="可选 USB CDC 虚拟串口,例如 COM8") parser.add_argument("--usb-rate", type=int, default=64_000, help="USB OUT B/s") parser.add_argument("--max-communication-errors", type=int, default=5) parser.add_argument("--reconnect-delay", type=float, default=1.0) parser.add_argument( "--output-dir", type=Path, default=Path("HostComputer/long_stress_logs"), ) return parser.parse_args() def main() -> int: args = parse_args() if args.baud <= 0: raise RuntimeError("baud 必须大于 0") if not 1 <= args.slave <= 247: raise RuntimeError("slave 必须为 1~247") if args.timeout <= 0: raise RuntimeError("timeout 必须大于 0") if args.duration <= 0 or args.status_period <= 0: raise RuntimeError("duration 和 status-period 必须大于 0") if args.frequency_period < 0 or args.bad_crc_period < 0: raise RuntimeError("frequency-period 和 bad-crc-period 不得为负数") if args.disconnect_at < 0 or args.disconnect_duration < 0: raise RuntimeError("disconnect-at 和 disconnect-duration 不得为负数") if args.disconnect_at > 0 and args.disconnect_duration <= 0: raise RuntimeError("启用计划断线时 disconnect-duration 必须大于 0") if args.usb_rate < 0: raise RuntimeError("usb-rate 不得为负数") if args.usb_port and args.usb_rate == 0: raise RuntimeError("启用 USB 压力时 usb-rate 必须大于 0") if args.max_communication_errors < 0: raise RuntimeError("max-communication-errors 不得为负数") if args.reconnect_delay < 0: raise RuntimeError("reconnect-delay 不得为负数") if not 1 <= args.frequency <= 100_000: raise RuntimeError("frequency 必须为 1~100000Hz(K4/P18 配置上限)") low_frequency = args.low_frequency or max(1, args.frequency // 2) if not 1 <= low_frequency <= args.frequency: raise RuntimeError("low-frequency 必须为 1~frequency") if args.usb_port and args.usb_port.upper() == args.port.upper(): raise RuntimeError("USB CDC 串口不能与 Modbus 串口相同") pulse_target = math.ceil( (args.duration + max(0.0, args.disconnect_duration) + 120.0) * args.frequency ) if pulse_target > 2_000_000_000: raise RuntimeError("测试时长/频率使单段脉冲超过 20 亿;请降低时长或频率") args.output_dir.mkdir(parents=True, exist_ok=True) run_id = datetime.now().strftime("%Y%m%d_%H%M%S") csv_path = args.output_dir / f"plsr_long_stress_{run_id}.csv" json_path = args.output_dir / f"plsr_long_stress_{run_id}.json" events: list[dict[str, Any]] = [] summary: dict[str, Any] = { "started_utc": datetime.now(timezone.utc).isoformat(), "arguments": { key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items() }, "pulse_target": pulse_target, "low_frequency": low_frequency, "events": events, "result": "FAIL", } usb_pressure = UsbOutPressure(args.usb_port, args.usb_rate) uart: serial.Serial | None = None client: RtuClient | None = None sequence = 1000 failure: BaseException | None = None last_status: list[dict[str, int]] = [] axes_stopped = False with csv_path.open("w", newline="", encoding="utf-8-sig") as csv_file: writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS) writer.writeheader() try: uart, client = open_modbus(args) header = client.read_holding(CONTROL_BASE, 8) if ( header[0:3] != [0x504C, 0x5352, 0x0100] or header[3] != CONTROL_WINDOW_WORDS or header[7] != PERFORMANCE_VERSION ): raise RuntimeError(f"PLSR 控制窗口未就绪:{header}") diagnostics_start = read_runtime_diagnostics(client) summary["diagnostics_start"] = diagnostics_start usb_diagnostics_start: dict[str, Any] | None = None if args.usb_port: usb_diagnostics_start = read_usb_diagnostics(client) summary["usb_device_diagnostics_start"] = usb_diagnostics_start prepare_jobs(client, args.frequency, pulse_target) for axis in range(4): response = send_command(client, sequence, axis, CMD_SET_POSITION, 0) check_result(response, 4, RESULT_QUEUED, f"轴{axis} SET_POSITION") if axis == 0: replay = send_command(client, sequence, axis, CMD_SET_POSITION, 0) if replay != response: raise RuntimeError( f"重复命令序号未回放同一应答:首次={response},重复={replay}" ) events.append({"elapsed_s": 0.0, "event": "idempotency_pass"}) sequence += 1 for axis in range(4): response = send_call(client, sequence, axis, CALL_COMMIT) check_result(response, 3, RESULT_OK, f"轴{axis} COMMIT") if response[11] != 1: raise RuntimeError(f"轴{axis} COMMIT 未建立有效快照") sequence += 1 physical_baseline = [ read_axis_status(client, axis)[0]["physical_pulses"] for axis in range(4) ] for axis in range(4): response = send_call(client, sequence, axis, CALL_START) check_result(response, 3, RESULT_QUEUED, f"轴{axis} START") sequence += 1 usb_pressure.start() started = time.monotonic() next_sample = started next_frequency = ( started + args.frequency_period if args.frequency_period > 0 else float("inf") ) next_bad_crc = ( started + args.bad_crc_period if args.bad_crc_period > 0 else float("inf") ) disconnected = False communication_errors = 0 sample_index = 0 dynamic_frequency = args.frequency previous_physical = physical_baseline[:] while time.monotonic() - started < args.duration: now = time.monotonic() elapsed = now - started if ( args.disconnect_at > 0 and not disconnected and elapsed >= args.disconnect_at ): before_disconnect = previous_physical[:] assert uart is not None uart.close() events.append( {"elapsed_s": elapsed, "event": "planned_disconnect_start"} ) time.sleep(args.disconnect_duration) uart, client = reopen_modbus(args) disconnected = True after_disconnect = [ read_axis_status(client, axis)[0]["physical_pulses"] for axis in range(4) ] if any( after_disconnect[axis] <= before_disconnect[axis] for axis in range(4) ): raise RuntimeError( "计划断线期间存在轴脉冲未继续增长:" f"before={before_disconnect}, after={after_disconnect}" ) previous_physical = after_disconnect events.append( { "elapsed_s": time.monotonic() - started, "event": "planned_disconnect_recovered", "physical_pulses": after_disconnect, } ) next_sample = time.monotonic() continue try: assert client is not None and uart is not None if now >= next_frequency: dynamic_frequency = ( low_frequency if dynamic_frequency == args.frequency else args.frequency ) for axis in range(4): # FC16 writes both words of the signed INT32 atomically. client.write_multiple( S0_BASES[axis] + 10, signed_dword_words(dynamic_frequency), ) events.append( { "elapsed_s": elapsed, "event": "frequency_change", "frequency_hz": dynamic_frequency, } ) next_frequency += args.frequency_period if now >= next_bad_crc: before_crc = read_runtime_diagnostics(client)["statistics"][ "crc_errors" ] inject_bad_crc(uart, args.slave, args.baud) after_crc = read_runtime_diagnostics(client)["statistics"][ "crc_errors" ] if delta32(after_crc, before_crc) < 1: raise RuntimeError("注入坏 CRC 后 crcErrorCount 未增长") events.append( { "elapsed_s": elapsed, "event": "bad_crc_rejected", "crc_count": after_crc, } ) next_bad_crc += args.bad_crc_period if now < next_sample: time.sleep(min(next_sample - now, 0.05)) continue diagnostics = read_runtime_diagnostics(client) if not diagnostics["connected"]: raise RuntimeError(f"Modbus connected 标志丢失:{diagnostics}") statuses: list[dict[str, int]] = [] snapshot_retries: list[int] = [] for axis in range(4): status, retries = read_axis_status(client, axis) statuses.append(status) snapshot_retries.append(retries) if status["error"] != 0 or status["last_result"] != RESULT_OK: raise RuntimeError(f"轴{axis}进入错误状态:{status}") if status["state"] not in {STATE_ACCEL, STATE_RUN, STATE_DECEL}: raise RuntimeError(f"轴{axis}意外离开运行态:{status}") if status["physical_pulses"] < previous_physical[axis]: raise RuntimeError( f"轴{axis}物理累计计数回退:" f"{previous_physical[axis]} -> {status['physical_pulses']}" ) previous_physical[axis] = status["physical_pulses"] timestamp = datetime.now(timezone.utc).isoformat() stats = diagnostics["statistics"] for axis, status in enumerate(statuses): writer.writerow( { "host_time_utc": timestamp, "elapsed_s": f"{elapsed:.6f}", "sample": sample_index, "axis": axis, "state": status["state"], "flags": status["flags"], "error": status["error"], "last_result": status["last_result"], "counter_mode": status["counter_mode"], "current_frequency": status["current_frequency"], "target_frequency": status["target_frequency"], "logical_position": status["logical_position"], "task_pulses": status["task_pulses"], "physical_pulses": status["physical_pulses"], "snapshot_retries": snapshot_retries[axis], "modbus_valid_frames": stats["valid_frames"], "modbus_tx_frames": stats["tx_frames"], "modbus_crc_errors": stats["crc_errors"], "modbus_dropped_frames": stats["dropped_frames"], "modbus_uart_errors": stats["uart_errors"], "modbus_restart_failures": diagnostics[ "restart_failures" ], "modbus_last_uart_error": diagnostics[ "last_uart_error" ], } ) csv_file.flush() last_status = statuses sample_index += 1 if sample_index % 30 == 0: print( f"{elapsed:8.1f}s:样本 {sample_index}," f"Q0物理累计={statuses[0]['physical_pulses']}," f"Modbus有效帧={stats['valid_frames']}" ) next_sample = max( next_sample + args.status_period, time.monotonic() ) communication_errors = 0 except (RuntimeError, serial.SerialException) as error: if not is_retryable_communication_error(error): raise communication_errors += 1 events.append( { "elapsed_s": time.monotonic() - started, "event": "communication_error", "count": communication_errors, "message": str(error), } ) if communication_errors > args.max_communication_errors: raise if uart is not None: uart.close() time.sleep(args.reconnect_delay) uart, client = reopen_modbus(args) next_sample = time.monotonic() assert client is not None sequence = stop_all_axes(client, sequence) axes_stopped = True usb_pressure.stop() last_status = [read_axis_status(client, axis)[0] for axis in range(4)] diagnostics_end = read_runtime_diagnostics(client) summary["diagnostics_end"] = diagnostics_end summary["diagnostics_delta"] = { name: delta32( diagnostics_end["statistics"][name], diagnostics_start["statistics"][name], ) for name in diagnostics_end["statistics"] } summary["restart_failure_delta"] = delta32( diagnostics_end["restart_failures"], diagnostics_start["restart_failures"], ) usb_diagnostics_end: dict[str, Any] | None = None usb_device_delta: dict[str, int] = {} if args.usb_port: assert usb_diagnostics_start is not None usb_diagnostics_end = read_usb_diagnostics(client) summary["usb_device_diagnostics_end"] = usb_diagnostics_end usb_device_delta = { name: delta32( usb_diagnostics_end["counters"][name], usb_diagnostics_start["counters"][name], ) for name in usb_diagnostics_end["counters"] } summary["usb_device_diagnostics_delta"] = usb_device_delta injected_bad_crc = sum( event.get("event") == "bad_crc_rejected" for event in events ) unhealthy = { "uart_errors": summary["diagnostics_delta"]["uart_errors"], "dropped_frames": summary["diagnostics_delta"]["dropped_frames"], "restart_failures": summary["restart_failure_delta"], "unexpected_crc_errors": ( summary["diagnostics_delta"]["crc_errors"] - injected_bad_crc ), "usb_write_errors": usb_pressure.write_errors, } if args.usb_port: assert usb_diagnostics_start is not None assert usb_diagnostics_end is not None unhealthy.update( { "usb_target_not_initialized": not usb_diagnostics_end[ "initialized" ], "usb_target_rx_packets_no_increment": ( usb_device_delta["rx_packet_count"] == 0 ), "usb_target_rx_bytes_no_increment": ( usb_device_delta["rx_byte_count"] == 0 ), "usb_target_rx_rearm_failures": usb_device_delta[ "rx_rearm_failure_count" ], } ) unhealthy = {name: value for name, value in unhealthy.items() if value} if unhealthy: raise RuntimeError(f"长稳运行诊断出现异常增量:{unhealthy}") summary["samples"] = sample_index summary["final_status"] = last_status summary["result"] = "PASS" except (RuntimeError, serial.SerialException, OSError, KeyboardInterrupt) as error: failure = error summary["failure"] = str(error) if client is not None and not axes_stopped: try: sequence = stop_all_axes(client, sequence) except Exception as stop_error: # best-effort safety cleanup summary["stop_cleanup_failure"] = str(stop_error) finally: usb_pressure.stop() summary["usb_host_pressure"] = usb_pressure.summary() summary["ended_utc"] = datetime.now(timezone.utc).isoformat() if uart is not None and uart.is_open: uart.close() json_path.write_text( json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8" ) print(f"CSV 证据:{csv_path}") print(f"JSON 汇总:{json_path}") if failure is not None: if isinstance(failure, KeyboardInterrupt): raise RuntimeError("用户中止测试,已尝试停止四轴") from failure raise RuntimeError(str(failure)) from failure print( f"长稳 PASS:{summary['samples']} 个四轴一致性样本;" f"最终计数={[item['physical_pulses'] for item in last_status]}" ) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except (RuntimeError, serial.SerialException) as error: print(f"测试失败:{error}") raise SystemExit(1)