|
- #!/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,))
- t35_seconds = 0.00175 if baud > 19_200 else (3.5 * 11.0 / baud)
- # This raw write bypasses RtuClient's normal transaction pacing. Leave a
- # complete inter-frame gap before it so it cannot be assembled with the
- # preceding diagnostic response.
- time.sleep(t35_seconds + 0.005)
- port.reset_input_buffer()
- port.write(malformed)
- port.flush()
- # A CRC failure intentionally has no response. Allow both T3.5 frame
- # finalization and several 1 ms application polls before issuing the next
- # valid request, even under four-axis 100 kHz interrupt pressure.
- time.sleep(max(t35_seconds + 0.020, 0.050))
-
-
- 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
- sample_index = 0
-
- 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
- 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
- physical_counts = ",".join(
- str(item["physical_pulses"]) for item in statuses
- )
- progress = min(elapsed / args.duration * 100.0, 100.0)
- print(
- f"\r已运行 {elapsed:7.1f}/{args.duration:.0f}s "
- f"({progress:5.1f}%);样本={sample_index};"
- f"四轴累计=[{physical_counts}];"
- f"Modbus有效帧={stats['valid_frames']}",
- end="",
- flush=True,
- )
- 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()
-
- if sample_index > 0:
- print()
- 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:
- if sample_index > 0:
- print()
- 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)
|