25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

929 lines
36 KiB

  1. #!/usr/bin/env python3
  2. """Long-duration four-axis PLSR/Modbus/optional USB concurrency test.
  3. Requires the dedicated P18 firmware configuration (K4, soft limits disabled).
  4. The tool creates one long PULSE/DIR segment on every axis, continuously reads
  5. generation-protected status snapshots, alternates the live frequency using one
  6. FC16 transaction per 32-bit value, and writes CSV plus JSON evidence. Optional
  7. bad-CRC and planned-disconnect probes are disabled unless explicitly requested.
  8. No persistence SAVE command is issued by this script.
  9. """
  10. from __future__ import annotations
  11. import argparse
  12. import csv
  13. import json
  14. import math
  15. import struct
  16. import threading
  17. import time
  18. from datetime import datetime, timezone
  19. from pathlib import Path
  20. from typing import Any
  21. import serial
  22. from plsr_modbus_frequency_test import RtuClient, add_crc, signed_dword_words
  23. CONTROL_BASE = 1200
  24. CONTROL_WINDOW_WORDS = 338
  25. PERFORMANCE_VERSION = 7
  26. CALL_REQUEST = CONTROL_BASE + 8
  27. CALL_RESPONSE = CONTROL_BASE + 24
  28. COMMAND_REQUEST = CONTROL_BASE + 40
  29. COMMAND_RESPONSE = CONTROL_BASE + 48
  30. AXIS_STATUS_BASE = CONTROL_BASE + 64
  31. AXIS_STATUS_WORDS = 48
  32. USB_DIAGNOSTICS_BASE = CONTROL_BASE + 316
  33. USB_DIAGNOSTICS_WORDS = 22
  34. USB_DIAGNOSTICS_VERSION = 1
  35. S0_BASES = (1600, 1800, 2000, 2200)
  36. S1_BASES = (1700, 1900, 2100, 2300)
  37. RUNTIME_DIAGNOSTICS_FUNCTION = 0x47
  38. RUNTIME_DIAGNOSTICS_SIGNATURE = 0x4D42
  39. RUNTIME_DIAGNOSTICS_VERSION = 1
  40. RUNTIME_DIAGNOSTICS_WORDS = 40
  41. RESULT_OK = 0
  42. RESULT_QUEUED = 1
  43. RESULT_INVALID_STATE = 4
  44. STATE_IDLE = 1
  45. STATE_ACCEL = 2
  46. STATE_RUN = 3
  47. STATE_DECEL = 4
  48. STATE_COMPLETED = 7
  49. STATE_STOPPED = 8
  50. STATE_ERROR = 9
  51. CALL_COMMIT = 1
  52. CALL_START = 2
  53. CMD_STOP_IMMEDIATE = 2
  54. CMD_SET_POSITION = 5
  55. CSV_FIELDS = (
  56. "host_time_utc",
  57. "elapsed_s",
  58. "sample",
  59. "axis",
  60. "state",
  61. "flags",
  62. "error",
  63. "last_result",
  64. "counter_mode",
  65. "current_frequency",
  66. "target_frequency",
  67. "logical_position",
  68. "task_pulses",
  69. "physical_pulses",
  70. "snapshot_retries",
  71. "modbus_valid_frames",
  72. "modbus_tx_frames",
  73. "modbus_crc_errors",
  74. "modbus_dropped_frames",
  75. "modbus_uart_errors",
  76. "modbus_restart_failures",
  77. "modbus_last_uart_error",
  78. )
  79. def put_u32(words: list[int], offset: int, value: int) -> None:
  80. words[offset : offset + 2] = signed_dword_words(value)
  81. def put_u64(words: list[int], offset: int, value: int) -> None:
  82. raw = value & 0xFFFFFFFFFFFFFFFF
  83. words[offset : offset + 4] = [
  84. (raw >> shift) & 0xFFFF for shift in (0, 16, 32, 48)
  85. ]
  86. def get_u32(words: list[int], offset: int) -> int:
  87. return words[offset] | (words[offset + 1] << 16)
  88. def get_u64(words: list[int], offset: int, *, signed: bool = False) -> int:
  89. value = sum(words[offset + index] << (16 * index) for index in range(4))
  90. if signed and value & (1 << 63):
  91. value -= 1 << 64
  92. return value
  93. def wait_response(
  94. client: RtuClient,
  95. address: int,
  96. quantity: int,
  97. sequence: int,
  98. timeout: float = 3.0,
  99. ) -> list[int]:
  100. deadline = time.monotonic() + timeout
  101. latest: list[int] = []
  102. while time.monotonic() < deadline:
  103. latest = client.read_holding(address, quantity)
  104. if get_u32(latest, 0) == sequence:
  105. return latest
  106. raise RuntimeError(f"等待命令序号 {sequence} 应答超时;最后应答={latest}")
  107. def send_command(
  108. client: RtuClient,
  109. sequence: int,
  110. axis: int,
  111. opcode: int,
  112. argument: int = 0,
  113. ) -> list[int]:
  114. request = [0] * 8
  115. put_u32(request, 0, sequence)
  116. request[2] = opcode
  117. request[3] = axis
  118. put_u64(request, 4, argument)
  119. client.write_multiple(COMMAND_REQUEST, request)
  120. return wait_response(client, COMMAND_RESPONSE, 8, sequence)
  121. def send_call(
  122. client: RtuClient,
  123. sequence: int,
  124. axis: int,
  125. operation: int,
  126. ) -> list[int]:
  127. request = [0] * 16
  128. put_u32(request, 0, sequence)
  129. request[2] = 0 # S0 is D
  130. put_u32(request, 3, S0_BASES[axis])
  131. request[5] = 0 # S1 is D
  132. put_u32(request, 6, S1_BASES[axis])
  133. request[8] = 0 # S2 is constant K4 (dedicated P18 long-stress setup)
  134. put_u32(request, 10, 4)
  135. request[12] = axis
  136. request[13] = 0 # PULSE/DIR
  137. request[14] = operation
  138. client.write_multiple(CALL_REQUEST, request)
  139. return wait_response(client, CALL_RESPONSE, 12, sequence)
  140. def check_result(
  141. response: list[int], offset: int, expected: int, label: str
  142. ) -> None:
  143. if response[offset] != expected:
  144. raise RuntimeError(
  145. f"{label} 返回 {response[offset]},期望 {expected};应答={response}"
  146. )
  147. def read_axis_status(
  148. client: RtuClient, axis: int, attempts: int = 4
  149. ) -> tuple[dict[str, int], int]:
  150. """Read one coherent generation-guarded axis status snapshot."""
  151. address = AXIS_STATUS_BASE + axis * AXIS_STATUS_WORDS
  152. for retry in range(attempts):
  153. words = client.read_holding(address, AXIS_STATUS_WORDS)
  154. generation_begin = get_u32(words, 0)
  155. generation_end = get_u32(words, 46)
  156. if generation_begin == generation_end and not generation_begin & 1:
  157. return (
  158. {
  159. "generation": generation_begin,
  160. "state": words[2],
  161. "flags": get_u32(words, 3),
  162. "error": words[6],
  163. "stop_reason": words[7],
  164. "last_result": words[8],
  165. "last_sequence": get_u32(words, 10),
  166. "logical_position": get_u64(words, 16, signed=True),
  167. "task_pulses": get_u64(words, 20, signed=True),
  168. "physical_pulses": get_u64(words, 28),
  169. "counter_mode": words[37],
  170. "current_frequency": get_u32(words, 38),
  171. "target_frequency": get_u32(words, 40),
  172. },
  173. retry,
  174. )
  175. raise RuntimeError(f"轴{axis}状态快照连续 {attempts} 次版本不一致")
  176. def read_runtime_diagnostics(client: RtuClient) -> dict[str, Any]:
  177. pdu = bytes((RUNTIME_DIAGNOSTICS_FUNCTION,)) + struct.pack(
  178. ">HH", 0, RUNTIME_DIAGNOSTICS_WORDS
  179. )
  180. response = client.exchange(pdu, 5 + RUNTIME_DIAGNOSTICS_WORDS * 2)
  181. if (
  182. response[1] != RUNTIME_DIAGNOSTICS_FUNCTION
  183. or response[2] != RUNTIME_DIAGNOSTICS_WORDS * 2
  184. ):
  185. raise RuntimeError(f"0x47 诊断应答格式错误:{response.hex(' ')}")
  186. words = list(
  187. struct.unpack(f">{RUNTIME_DIAGNOSTICS_WORDS}H", response[3:-2])
  188. )
  189. if words[:3] != [
  190. RUNTIME_DIAGNOSTICS_SIGNATURE,
  191. RUNTIME_DIAGNOSTICS_VERSION,
  192. RUNTIME_DIAGNOSTICS_WORDS,
  193. ]:
  194. raise RuntimeError(f"Modbus 运行诊断版本不匹配:{words[:3]}")
  195. stat_names = (
  196. "rx_events",
  197. "valid_frames",
  198. "tx_frames",
  199. "crc_errors",
  200. "ignored_addresses",
  201. "illegal_functions",
  202. "illegal_addresses",
  203. "illegal_values",
  204. "dropped_frames",
  205. "uart_errors",
  206. )
  207. statistics = {
  208. name: get_u32(words, 20 + index * 2)
  209. for index, name in enumerate(stat_names)
  210. }
  211. return {
  212. "flags": words[3],
  213. "initialized": bool(words[3] & (1 << 0)),
  214. "connected": bool(words[3] & (1 << 2)),
  215. "tx_busy": bool(words[3] & (1 << 3)),
  216. "rx_restart_ok": bool(words[3] & (1 << 6)),
  217. "current_tick": get_u32(words, 4),
  218. "last_valid_frame_tick": get_u32(words, 6),
  219. "last_inter_frame_gap_cycles": get_u32(words, 8),
  220. "restart_attempts": get_u32(words, 10),
  221. "restart_failures": get_u32(words, 12),
  222. "last_uart_error": get_u32(words, 14),
  223. "last_receive_start_status": words[16],
  224. "rx_assembly_length": words[17],
  225. "rx_frame_length": words[18],
  226. "statistics": statistics,
  227. }
  228. def read_usb_diagnostics(
  229. client: RtuClient, attempts: int = 4
  230. ) -> dict[str, Any]:
  231. """Read one coherent generation-guarded USB CDC diagnostic block."""
  232. counter_names = (
  233. "rx_packet_count",
  234. "rx_byte_count",
  235. "rx_rearm_failure_count",
  236. "tx_request_count",
  237. "tx_byte_count",
  238. "tx_busy_count",
  239. "tx_failure_count",
  240. "tx_complete_count",
  241. )
  242. for retry in range(attempts):
  243. words = client.read_holding(USB_DIAGNOSTICS_BASE, USB_DIAGNOSTICS_WORDS)
  244. generation_begin = get_u32(words, 0)
  245. generation_end = get_u32(words, 20)
  246. if generation_begin == generation_end and not generation_begin & 1:
  247. if words[2] != USB_DIAGNOSTICS_VERSION:
  248. raise RuntimeError(
  249. "USB CDC 诊断版本不匹配:"
  250. f"读取={words[2]},要求={USB_DIAGNOSTICS_VERSION}"
  251. )
  252. return {
  253. "generation": generation_begin,
  254. "version": words[2],
  255. "initialized": bool(words[3]),
  256. "snapshot_retries": retry,
  257. "counters": {
  258. name: get_u32(words, 4 + index * 2)
  259. for index, name in enumerate(counter_names)
  260. },
  261. }
  262. raise RuntimeError(f"USB CDC 诊断块连续 {attempts} 次版本不一致")
  263. def inject_bad_crc(port: serial.Serial, slave: int, baud: int) -> None:
  264. """Send a read-only request with a deliberately invalid CRC."""
  265. valid = add_crc(bytes((slave, 0x03)) + struct.pack(">HH", CONTROL_BASE, 1))
  266. malformed = valid[:-1] + bytes((valid[-1] ^ 0x01,))
  267. t35_seconds = 0.00175 if baud > 19_200 else (3.5 * 11.0 / baud)
  268. # This raw write bypasses RtuClient's normal transaction pacing. Leave a
  269. # complete inter-frame gap before it so it cannot be assembled with the
  270. # preceding diagnostic response.
  271. time.sleep(t35_seconds + 0.005)
  272. port.reset_input_buffer()
  273. port.write(malformed)
  274. port.flush()
  275. # A CRC failure intentionally has no response. Allow both T3.5 frame
  276. # finalization and several 1 ms application polls before issuing the next
  277. # valid request, even under four-axis 100 kHz interrupt pressure.
  278. time.sleep(max(t35_seconds + 0.020, 0.050))
  279. def open_modbus(args: argparse.Namespace) -> tuple[serial.Serial, RtuClient]:
  280. port = serial.Serial(
  281. port=args.port,
  282. baudrate=args.baud,
  283. bytesize=serial.EIGHTBITS,
  284. parity=serial.PARITY_EVEN,
  285. stopbits=serial.STOPBITS_ONE,
  286. timeout=args.timeout,
  287. write_timeout=args.timeout,
  288. )
  289. return port, RtuClient(port, args.slave)
  290. def reopen_modbus(args: argparse.Namespace) -> tuple[serial.Serial, RtuClient]:
  291. """Reopen a planned/lost link with a bounded number of host retries."""
  292. last_error: BaseException | None = None
  293. attempts = max(1, args.max_communication_errors)
  294. for _ in range(attempts):
  295. try:
  296. return open_modbus(args)
  297. except (OSError, serial.SerialException) as error:
  298. last_error = error
  299. time.sleep(args.reconnect_delay)
  300. raise RuntimeError(
  301. f"连续 {attempts} 次无法重新打开 {args.port}:{last_error}"
  302. ) from last_error
  303. class UsbOutPressure:
  304. def __init__(self, port: str | None, bytes_per_second: int) -> None:
  305. self.port = port
  306. self.bytes_per_second = bytes_per_second
  307. self.bytes_written = 0
  308. self.write_errors = 0
  309. self.last_error = ""
  310. self._stop = threading.Event()
  311. self._thread: threading.Thread | None = None
  312. def start(self) -> None:
  313. if self.port is None:
  314. return
  315. self._thread = threading.Thread(target=self._run, daemon=True)
  316. self._thread.start()
  317. def stop(self) -> None:
  318. self._stop.set()
  319. if self._thread is not None:
  320. self._thread.join(timeout=3.0)
  321. def _run(self) -> None:
  322. payload = (b"PLSR-USB-CDC-OUT-STRESS-" * 3)[:64]
  323. try:
  324. with serial.Serial(
  325. self.port,
  326. baudrate=115200,
  327. timeout=0.2,
  328. write_timeout=1.0,
  329. ) as usb:
  330. next_send = time.monotonic()
  331. while not self._stop.is_set():
  332. usb.write(payload)
  333. usb.flush()
  334. self.bytes_written += len(payload)
  335. if self.bytes_per_second > 0:
  336. next_send += len(payload) / self.bytes_per_second
  337. delay = next_send - time.monotonic()
  338. if delay > 0:
  339. self._stop.wait(delay)
  340. elif delay < -1.0:
  341. next_send = time.monotonic()
  342. except (OSError, serial.SerialException) as error:
  343. self.write_errors += 1
  344. self.last_error = str(error)
  345. def summary(self) -> dict[str, Any]:
  346. return {
  347. "port": self.port,
  348. "target_bytes_per_second": self.bytes_per_second,
  349. "bytes_written": self.bytes_written,
  350. "write_errors": self.write_errors,
  351. "last_error": self.last_error,
  352. }
  353. def prepare_jobs(client: RtuClient, frequency: int, pulses: int) -> None:
  354. for axis in range(4):
  355. s0 = [0] * 20
  356. put_u32(s0, 0, 1)
  357. put_u32(s0, 10, frequency)
  358. put_u32(s0, 12, pulses)
  359. client.write_multiple(S0_BASES[axis], s0)
  360. client.write_multiple(S1_BASES[axis], [0] * 4)
  361. def stop_all_axes(client: RtuClient, sequence: int) -> int:
  362. send_failures: list[str] = []
  363. for axis in range(4):
  364. try:
  365. response = send_command(
  366. client, sequence, axis, CMD_STOP_IMMEDIATE, argument=0
  367. )
  368. if response[4] not in {
  369. RESULT_OK,
  370. RESULT_QUEUED,
  371. RESULT_INVALID_STATE,
  372. }:
  373. send_failures.append(
  374. f"轴{axis} STOP_IMMEDIATE 返回 {response[4]}"
  375. )
  376. except (RuntimeError, serial.SerialException, OSError) as error:
  377. # Never let one failed/already-stopped axis prevent stop attempts
  378. # for the remaining axes.
  379. send_failures.append(f"轴{axis} STOP_IMMEDIATE 异常:{error}")
  380. sequence += 1
  381. deadline = time.monotonic() + 8.0
  382. latest: dict[int, dict[str, int]] = {}
  383. terminal_states = {STATE_IDLE, STATE_COMPLETED, STATE_STOPPED, STATE_ERROR}
  384. while time.monotonic() < deadline:
  385. for axis in range(4):
  386. try:
  387. latest[axis] = read_axis_status(client, axis)[0]
  388. except (RuntimeError, serial.SerialException, OSError) as error:
  389. send_failures.append(f"轴{axis}停止状态读取异常:{error}")
  390. if len(latest) == 4 and all(
  391. latest[axis]["state"] in terminal_states
  392. and (latest[axis]["flags"] & (1 << 1)) == 0
  393. for axis in range(4)
  394. ):
  395. return sequence
  396. raise RuntimeError(
  397. "STOP_IMMEDIATE 后仍有轴未确认安全停止:"
  398. f"status={latest};发送/读取异常={send_failures}"
  399. )
  400. def delta32(end: int, start: int) -> int:
  401. return (end - start) & 0xFFFFFFFF
  402. def is_retryable_communication_error(error: BaseException) -> bool:
  403. if isinstance(error, serial.SerialException):
  404. return True
  405. if not isinstance(error, RuntimeError):
  406. return False
  407. message = str(error)
  408. return message.startswith("响应超时:") or message.startswith("响应 CRC 错误:")
  409. def parse_args() -> argparse.Namespace:
  410. parser = argparse.ArgumentParser(description="PLSR Modbus/USB 长稳并发测试")
  411. parser.add_argument("--port", default="COM5", help="Modbus RTU 串口,默认 COM5")
  412. parser.add_argument("--baud", type=int, default=9600)
  413. parser.add_argument("--slave", type=int, default=1)
  414. parser.add_argument("--timeout", type=float, default=1.5)
  415. parser.add_argument("--duration", type=float, default=1800.0, help="运行秒数")
  416. parser.add_argument("--frequency", type=int, default=100_000)
  417. parser.add_argument(
  418. "--low-frequency",
  419. type=int,
  420. help="动态频率低值,默认主频率的一半",
  421. )
  422. parser.add_argument("--status-period", type=float, default=1.0)
  423. parser.add_argument(
  424. "--frequency-period",
  425. type=float,
  426. default=10.0,
  427. help="动态频率切换周期;0 表示禁用",
  428. )
  429. parser.add_argument(
  430. "--bad-crc-period",
  431. type=float,
  432. default=0.0,
  433. help="坏 CRC 注入周期;默认 0(禁用)",
  434. )
  435. parser.add_argument(
  436. "--disconnect-at",
  437. type=float,
  438. default=0.0,
  439. help="运行到指定秒数时主动断开串口;默认 0(禁用)",
  440. )
  441. parser.add_argument("--disconnect-duration", type=float, default=3.0)
  442. parser.add_argument("--usb-port", help="可选 USB CDC 虚拟串口,例如 COM8")
  443. parser.add_argument("--usb-rate", type=int, default=64_000, help="USB OUT B/s")
  444. parser.add_argument("--max-communication-errors", type=int, default=5)
  445. parser.add_argument("--reconnect-delay", type=float, default=1.0)
  446. parser.add_argument(
  447. "--output-dir",
  448. type=Path,
  449. default=Path("HostComputer/long_stress_logs"),
  450. )
  451. return parser.parse_args()
  452. def main() -> int:
  453. args = parse_args()
  454. if args.baud <= 0:
  455. raise RuntimeError("baud 必须大于 0")
  456. if not 1 <= args.slave <= 247:
  457. raise RuntimeError("slave 必须为 1~247")
  458. if args.timeout <= 0:
  459. raise RuntimeError("timeout 必须大于 0")
  460. if args.duration <= 0 or args.status_period <= 0:
  461. raise RuntimeError("duration 和 status-period 必须大于 0")
  462. if args.frequency_period < 0 or args.bad_crc_period < 0:
  463. raise RuntimeError("frequency-period 和 bad-crc-period 不得为负数")
  464. if args.disconnect_at < 0 or args.disconnect_duration < 0:
  465. raise RuntimeError("disconnect-at 和 disconnect-duration 不得为负数")
  466. if args.disconnect_at > 0 and args.disconnect_duration <= 0:
  467. raise RuntimeError("启用计划断线时 disconnect-duration 必须大于 0")
  468. if args.usb_rate < 0:
  469. raise RuntimeError("usb-rate 不得为负数")
  470. if args.usb_port and args.usb_rate == 0:
  471. raise RuntimeError("启用 USB 压力时 usb-rate 必须大于 0")
  472. if args.max_communication_errors < 0:
  473. raise RuntimeError("max-communication-errors 不得为负数")
  474. if args.reconnect_delay < 0:
  475. raise RuntimeError("reconnect-delay 不得为负数")
  476. if not 1 <= args.frequency <= 100_000:
  477. raise RuntimeError("frequency 必须为 1~100000Hz(K4/P18 配置上限)")
  478. low_frequency = args.low_frequency or max(1, args.frequency // 2)
  479. if not 1 <= low_frequency <= args.frequency:
  480. raise RuntimeError("low-frequency 必须为 1~frequency")
  481. if args.usb_port and args.usb_port.upper() == args.port.upper():
  482. raise RuntimeError("USB CDC 串口不能与 Modbus 串口相同")
  483. pulse_target = math.ceil(
  484. (args.duration + max(0.0, args.disconnect_duration) + 120.0)
  485. * args.frequency
  486. )
  487. if pulse_target > 2_000_000_000:
  488. raise RuntimeError("测试时长/频率使单段脉冲超过 20 亿;请降低时长或频率")
  489. args.output_dir.mkdir(parents=True, exist_ok=True)
  490. run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
  491. csv_path = args.output_dir / f"plsr_long_stress_{run_id}.csv"
  492. json_path = args.output_dir / f"plsr_long_stress_{run_id}.json"
  493. events: list[dict[str, Any]] = []
  494. summary: dict[str, Any] = {
  495. "started_utc": datetime.now(timezone.utc).isoformat(),
  496. "arguments": {
  497. key: str(value) if isinstance(value, Path) else value
  498. for key, value in vars(args).items()
  499. },
  500. "pulse_target": pulse_target,
  501. "low_frequency": low_frequency,
  502. "events": events,
  503. "result": "FAIL",
  504. }
  505. usb_pressure = UsbOutPressure(args.usb_port, args.usb_rate)
  506. uart: serial.Serial | None = None
  507. client: RtuClient | None = None
  508. sequence = 1000
  509. failure: BaseException | None = None
  510. last_status: list[dict[str, int]] = []
  511. axes_stopped = False
  512. sample_index = 0
  513. with csv_path.open("w", newline="", encoding="utf-8-sig") as csv_file:
  514. writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
  515. writer.writeheader()
  516. try:
  517. uart, client = open_modbus(args)
  518. header = client.read_holding(CONTROL_BASE, 8)
  519. if (
  520. header[0:3] != [0x504C, 0x5352, 0x0100]
  521. or header[3] != CONTROL_WINDOW_WORDS
  522. or header[7] != PERFORMANCE_VERSION
  523. ):
  524. raise RuntimeError(f"PLSR 控制窗口未就绪:{header}")
  525. diagnostics_start = read_runtime_diagnostics(client)
  526. summary["diagnostics_start"] = diagnostics_start
  527. usb_diagnostics_start: dict[str, Any] | None = None
  528. if args.usb_port:
  529. usb_diagnostics_start = read_usb_diagnostics(client)
  530. summary["usb_device_diagnostics_start"] = usb_diagnostics_start
  531. prepare_jobs(client, args.frequency, pulse_target)
  532. for axis in range(4):
  533. response = send_command(client, sequence, axis, CMD_SET_POSITION, 0)
  534. check_result(response, 4, RESULT_QUEUED, f"轴{axis} SET_POSITION")
  535. if axis == 0:
  536. replay = send_command(client, sequence, axis, CMD_SET_POSITION, 0)
  537. if replay != response:
  538. raise RuntimeError(
  539. f"重复命令序号未回放同一应答:首次={response},重复={replay}"
  540. )
  541. events.append({"elapsed_s": 0.0, "event": "idempotency_pass"})
  542. sequence += 1
  543. for axis in range(4):
  544. response = send_call(client, sequence, axis, CALL_COMMIT)
  545. check_result(response, 3, RESULT_OK, f"轴{axis} COMMIT")
  546. if response[11] != 1:
  547. raise RuntimeError(f"轴{axis} COMMIT 未建立有效快照")
  548. sequence += 1
  549. physical_baseline = [
  550. read_axis_status(client, axis)[0]["physical_pulses"]
  551. for axis in range(4)
  552. ]
  553. for axis in range(4):
  554. response = send_call(client, sequence, axis, CALL_START)
  555. check_result(response, 3, RESULT_QUEUED, f"轴{axis} START")
  556. sequence += 1
  557. usb_pressure.start()
  558. started = time.monotonic()
  559. next_sample = started
  560. next_frequency = (
  561. started + args.frequency_period
  562. if args.frequency_period > 0
  563. else float("inf")
  564. )
  565. next_bad_crc = (
  566. started + args.bad_crc_period
  567. if args.bad_crc_period > 0
  568. else float("inf")
  569. )
  570. disconnected = False
  571. communication_errors = 0
  572. dynamic_frequency = args.frequency
  573. previous_physical = physical_baseline[:]
  574. while time.monotonic() - started < args.duration:
  575. now = time.monotonic()
  576. elapsed = now - started
  577. if (
  578. args.disconnect_at > 0
  579. and not disconnected
  580. and elapsed >= args.disconnect_at
  581. ):
  582. before_disconnect = previous_physical[:]
  583. assert uart is not None
  584. uart.close()
  585. events.append(
  586. {"elapsed_s": elapsed, "event": "planned_disconnect_start"}
  587. )
  588. time.sleep(args.disconnect_duration)
  589. uart, client = reopen_modbus(args)
  590. disconnected = True
  591. after_disconnect = [
  592. read_axis_status(client, axis)[0]["physical_pulses"]
  593. for axis in range(4)
  594. ]
  595. if any(
  596. after_disconnect[axis] <= before_disconnect[axis]
  597. for axis in range(4)
  598. ):
  599. raise RuntimeError(
  600. "计划断线期间存在轴脉冲未继续增长:"
  601. f"before={before_disconnect}, after={after_disconnect}"
  602. )
  603. previous_physical = after_disconnect
  604. events.append(
  605. {
  606. "elapsed_s": time.monotonic() - started,
  607. "event": "planned_disconnect_recovered",
  608. "physical_pulses": after_disconnect,
  609. }
  610. )
  611. next_sample = time.monotonic()
  612. continue
  613. try:
  614. assert client is not None and uart is not None
  615. if now >= next_frequency:
  616. dynamic_frequency = (
  617. low_frequency
  618. if dynamic_frequency == args.frequency
  619. else args.frequency
  620. )
  621. for axis in range(4):
  622. # FC16 writes both words of the signed INT32 atomically.
  623. client.write_multiple(
  624. S0_BASES[axis] + 10,
  625. signed_dword_words(dynamic_frequency),
  626. )
  627. events.append(
  628. {
  629. "elapsed_s": elapsed,
  630. "event": "frequency_change",
  631. "frequency_hz": dynamic_frequency,
  632. }
  633. )
  634. next_frequency += args.frequency_period
  635. if now >= next_bad_crc:
  636. before_crc = read_runtime_diagnostics(client)["statistics"][
  637. "crc_errors"
  638. ]
  639. inject_bad_crc(uart, args.slave, args.baud)
  640. after_crc = read_runtime_diagnostics(client)["statistics"][
  641. "crc_errors"
  642. ]
  643. if delta32(after_crc, before_crc) < 1:
  644. raise RuntimeError("注入坏 CRC 后 crcErrorCount 未增长")
  645. events.append(
  646. {
  647. "elapsed_s": elapsed,
  648. "event": "bad_crc_rejected",
  649. "crc_count": after_crc,
  650. }
  651. )
  652. next_bad_crc += args.bad_crc_period
  653. if now < next_sample:
  654. time.sleep(min(next_sample - now, 0.05))
  655. continue
  656. diagnostics = read_runtime_diagnostics(client)
  657. if not diagnostics["connected"]:
  658. raise RuntimeError(f"Modbus connected 标志丢失:{diagnostics}")
  659. statuses: list[dict[str, int]] = []
  660. snapshot_retries: list[int] = []
  661. for axis in range(4):
  662. status, retries = read_axis_status(client, axis)
  663. statuses.append(status)
  664. snapshot_retries.append(retries)
  665. if status["error"] != 0 or status["last_result"] != RESULT_OK:
  666. raise RuntimeError(f"轴{axis}进入错误状态:{status}")
  667. if status["state"] not in {STATE_ACCEL, STATE_RUN, STATE_DECEL}:
  668. raise RuntimeError(f"轴{axis}意外离开运行态:{status}")
  669. if status["physical_pulses"] < previous_physical[axis]:
  670. raise RuntimeError(
  671. f"轴{axis}物理累计计数回退:"
  672. f"{previous_physical[axis]} -> {status['physical_pulses']}"
  673. )
  674. previous_physical[axis] = status["physical_pulses"]
  675. timestamp = datetime.now(timezone.utc).isoformat()
  676. stats = diagnostics["statistics"]
  677. for axis, status in enumerate(statuses):
  678. writer.writerow(
  679. {
  680. "host_time_utc": timestamp,
  681. "elapsed_s": f"{elapsed:.6f}",
  682. "sample": sample_index,
  683. "axis": axis,
  684. "state": status["state"],
  685. "flags": status["flags"],
  686. "error": status["error"],
  687. "last_result": status["last_result"],
  688. "counter_mode": status["counter_mode"],
  689. "current_frequency": status["current_frequency"],
  690. "target_frequency": status["target_frequency"],
  691. "logical_position": status["logical_position"],
  692. "task_pulses": status["task_pulses"],
  693. "physical_pulses": status["physical_pulses"],
  694. "snapshot_retries": snapshot_retries[axis],
  695. "modbus_valid_frames": stats["valid_frames"],
  696. "modbus_tx_frames": stats["tx_frames"],
  697. "modbus_crc_errors": stats["crc_errors"],
  698. "modbus_dropped_frames": stats["dropped_frames"],
  699. "modbus_uart_errors": stats["uart_errors"],
  700. "modbus_restart_failures": diagnostics[
  701. "restart_failures"
  702. ],
  703. "modbus_last_uart_error": diagnostics[
  704. "last_uart_error"
  705. ],
  706. }
  707. )
  708. csv_file.flush()
  709. last_status = statuses
  710. sample_index += 1
  711. physical_counts = ",".join(
  712. str(item["physical_pulses"]) for item in statuses
  713. )
  714. progress = min(elapsed / args.duration * 100.0, 100.0)
  715. print(
  716. f"\r已运行 {elapsed:7.1f}/{args.duration:.0f}s "
  717. f"({progress:5.1f}%);样本={sample_index};"
  718. f"四轴累计=[{physical_counts}];"
  719. f"Modbus有效帧={stats['valid_frames']}",
  720. end="",
  721. flush=True,
  722. )
  723. next_sample = max(
  724. next_sample + args.status_period, time.monotonic()
  725. )
  726. communication_errors = 0
  727. except (RuntimeError, serial.SerialException) as error:
  728. if not is_retryable_communication_error(error):
  729. raise
  730. communication_errors += 1
  731. events.append(
  732. {
  733. "elapsed_s": time.monotonic() - started,
  734. "event": "communication_error",
  735. "count": communication_errors,
  736. "message": str(error),
  737. }
  738. )
  739. if communication_errors > args.max_communication_errors:
  740. raise
  741. if uart is not None:
  742. uart.close()
  743. time.sleep(args.reconnect_delay)
  744. uart, client = reopen_modbus(args)
  745. next_sample = time.monotonic()
  746. if sample_index > 0:
  747. print()
  748. assert client is not None
  749. sequence = stop_all_axes(client, sequence)
  750. axes_stopped = True
  751. usb_pressure.stop()
  752. last_status = [read_axis_status(client, axis)[0] for axis in range(4)]
  753. diagnostics_end = read_runtime_diagnostics(client)
  754. summary["diagnostics_end"] = diagnostics_end
  755. summary["diagnostics_delta"] = {
  756. name: delta32(
  757. diagnostics_end["statistics"][name],
  758. diagnostics_start["statistics"][name],
  759. )
  760. for name in diagnostics_end["statistics"]
  761. }
  762. summary["restart_failure_delta"] = delta32(
  763. diagnostics_end["restart_failures"],
  764. diagnostics_start["restart_failures"],
  765. )
  766. usb_diagnostics_end: dict[str, Any] | None = None
  767. usb_device_delta: dict[str, int] = {}
  768. if args.usb_port:
  769. assert usb_diagnostics_start is not None
  770. usb_diagnostics_end = read_usb_diagnostics(client)
  771. summary["usb_device_diagnostics_end"] = usb_diagnostics_end
  772. usb_device_delta = {
  773. name: delta32(
  774. usb_diagnostics_end["counters"][name],
  775. usb_diagnostics_start["counters"][name],
  776. )
  777. for name in usb_diagnostics_end["counters"]
  778. }
  779. summary["usb_device_diagnostics_delta"] = usb_device_delta
  780. injected_bad_crc = sum(
  781. event.get("event") == "bad_crc_rejected" for event in events
  782. )
  783. unhealthy = {
  784. "uart_errors": summary["diagnostics_delta"]["uart_errors"],
  785. "dropped_frames": summary["diagnostics_delta"]["dropped_frames"],
  786. "restart_failures": summary["restart_failure_delta"],
  787. "unexpected_crc_errors": (
  788. summary["diagnostics_delta"]["crc_errors"]
  789. - injected_bad_crc
  790. ),
  791. "usb_write_errors": usb_pressure.write_errors,
  792. }
  793. if args.usb_port:
  794. assert usb_diagnostics_start is not None
  795. assert usb_diagnostics_end is not None
  796. unhealthy.update(
  797. {
  798. "usb_target_not_initialized": not usb_diagnostics_end[
  799. "initialized"
  800. ],
  801. "usb_target_rx_packets_no_increment": (
  802. usb_device_delta["rx_packet_count"] == 0
  803. ),
  804. "usb_target_rx_bytes_no_increment": (
  805. usb_device_delta["rx_byte_count"] == 0
  806. ),
  807. "usb_target_rx_rearm_failures": usb_device_delta[
  808. "rx_rearm_failure_count"
  809. ],
  810. }
  811. )
  812. unhealthy = {name: value for name, value in unhealthy.items() if value}
  813. if unhealthy:
  814. raise RuntimeError(f"长稳运行诊断出现异常增量:{unhealthy}")
  815. summary["samples"] = sample_index
  816. summary["final_status"] = last_status
  817. summary["result"] = "PASS"
  818. except (RuntimeError, serial.SerialException, OSError, KeyboardInterrupt) as error:
  819. if sample_index > 0:
  820. print()
  821. failure = error
  822. summary["failure"] = str(error)
  823. if client is not None and not axes_stopped:
  824. try:
  825. sequence = stop_all_axes(client, sequence)
  826. except Exception as stop_error: # best-effort safety cleanup
  827. summary["stop_cleanup_failure"] = str(stop_error)
  828. finally:
  829. usb_pressure.stop()
  830. summary["usb_host_pressure"] = usb_pressure.summary()
  831. summary["ended_utc"] = datetime.now(timezone.utc).isoformat()
  832. if uart is not None and uart.is_open:
  833. uart.close()
  834. json_path.write_text(
  835. json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
  836. )
  837. print(f"CSV 证据:{csv_path}")
  838. print(f"JSON 汇总:{json_path}")
  839. if failure is not None:
  840. if isinstance(failure, KeyboardInterrupt):
  841. raise RuntimeError("用户中止测试,已尝试停止四轴") from failure
  842. raise RuntimeError(str(failure)) from failure
  843. print(
  844. f"长稳 PASS:{summary['samples']} 个四轴一致性样本;"
  845. f"最终计数={[item['physical_pulses'] for item in last_status]}"
  846. )
  847. return 0
  848. if __name__ == "__main__":
  849. try:
  850. raise SystemExit(main())
  851. except (RuntimeError, serial.SerialException) as error:
  852. print(f"测试失败:{error}")
  853. raise SystemExit(1)