Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 
 
 

913 řádky
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. port.reset_input_buffer()
  268. port.write(malformed)
  269. port.flush()
  270. # Match the firmware's Modbus RTU timing rule and leave a small host margin.
  271. t35_seconds = 0.00175 if baud > 19_200 else (3.5 * 11.0 / baud)
  272. time.sleep(t35_seconds + 0.005)
  273. def open_modbus(args: argparse.Namespace) -> tuple[serial.Serial, RtuClient]:
  274. port = serial.Serial(
  275. port=args.port,
  276. baudrate=args.baud,
  277. bytesize=serial.EIGHTBITS,
  278. parity=serial.PARITY_EVEN,
  279. stopbits=serial.STOPBITS_ONE,
  280. timeout=args.timeout,
  281. write_timeout=args.timeout,
  282. )
  283. return port, RtuClient(port, args.slave)
  284. def reopen_modbus(args: argparse.Namespace) -> tuple[serial.Serial, RtuClient]:
  285. """Reopen a planned/lost link with a bounded number of host retries."""
  286. last_error: BaseException | None = None
  287. attempts = max(1, args.max_communication_errors)
  288. for _ in range(attempts):
  289. try:
  290. return open_modbus(args)
  291. except (OSError, serial.SerialException) as error:
  292. last_error = error
  293. time.sleep(args.reconnect_delay)
  294. raise RuntimeError(
  295. f"连续 {attempts} 次无法重新打开 {args.port}:{last_error}"
  296. ) from last_error
  297. class UsbOutPressure:
  298. def __init__(self, port: str | None, bytes_per_second: int) -> None:
  299. self.port = port
  300. self.bytes_per_second = bytes_per_second
  301. self.bytes_written = 0
  302. self.write_errors = 0
  303. self.last_error = ""
  304. self._stop = threading.Event()
  305. self._thread: threading.Thread | None = None
  306. def start(self) -> None:
  307. if self.port is None:
  308. return
  309. self._thread = threading.Thread(target=self._run, daemon=True)
  310. self._thread.start()
  311. def stop(self) -> None:
  312. self._stop.set()
  313. if self._thread is not None:
  314. self._thread.join(timeout=3.0)
  315. def _run(self) -> None:
  316. payload = (b"PLSR-USB-CDC-OUT-STRESS-" * 3)[:64]
  317. try:
  318. with serial.Serial(
  319. self.port,
  320. baudrate=115200,
  321. timeout=0.2,
  322. write_timeout=1.0,
  323. ) as usb:
  324. next_send = time.monotonic()
  325. while not self._stop.is_set():
  326. usb.write(payload)
  327. usb.flush()
  328. self.bytes_written += len(payload)
  329. if self.bytes_per_second > 0:
  330. next_send += len(payload) / self.bytes_per_second
  331. delay = next_send - time.monotonic()
  332. if delay > 0:
  333. self._stop.wait(delay)
  334. elif delay < -1.0:
  335. next_send = time.monotonic()
  336. except (OSError, serial.SerialException) as error:
  337. self.write_errors += 1
  338. self.last_error = str(error)
  339. def summary(self) -> dict[str, Any]:
  340. return {
  341. "port": self.port,
  342. "target_bytes_per_second": self.bytes_per_second,
  343. "bytes_written": self.bytes_written,
  344. "write_errors": self.write_errors,
  345. "last_error": self.last_error,
  346. }
  347. def prepare_jobs(client: RtuClient, frequency: int, pulses: int) -> None:
  348. for axis in range(4):
  349. s0 = [0] * 20
  350. put_u32(s0, 0, 1)
  351. put_u32(s0, 10, frequency)
  352. put_u32(s0, 12, pulses)
  353. client.write_multiple(S0_BASES[axis], s0)
  354. client.write_multiple(S1_BASES[axis], [0] * 4)
  355. def stop_all_axes(client: RtuClient, sequence: int) -> int:
  356. send_failures: list[str] = []
  357. for axis in range(4):
  358. try:
  359. response = send_command(
  360. client, sequence, axis, CMD_STOP_IMMEDIATE, argument=0
  361. )
  362. if response[4] not in {
  363. RESULT_OK,
  364. RESULT_QUEUED,
  365. RESULT_INVALID_STATE,
  366. }:
  367. send_failures.append(
  368. f"轴{axis} STOP_IMMEDIATE 返回 {response[4]}"
  369. )
  370. except (RuntimeError, serial.SerialException, OSError) as error:
  371. # Never let one failed/already-stopped axis prevent stop attempts
  372. # for the remaining axes.
  373. send_failures.append(f"轴{axis} STOP_IMMEDIATE 异常:{error}")
  374. sequence += 1
  375. deadline = time.monotonic() + 8.0
  376. latest: dict[int, dict[str, int]] = {}
  377. terminal_states = {STATE_IDLE, STATE_COMPLETED, STATE_STOPPED, STATE_ERROR}
  378. while time.monotonic() < deadline:
  379. for axis in range(4):
  380. try:
  381. latest[axis] = read_axis_status(client, axis)[0]
  382. except (RuntimeError, serial.SerialException, OSError) as error:
  383. send_failures.append(f"轴{axis}停止状态读取异常:{error}")
  384. if len(latest) == 4 and all(
  385. latest[axis]["state"] in terminal_states
  386. and (latest[axis]["flags"] & (1 << 1)) == 0
  387. for axis in range(4)
  388. ):
  389. return sequence
  390. raise RuntimeError(
  391. "STOP_IMMEDIATE 后仍有轴未确认安全停止:"
  392. f"status={latest};发送/读取异常={send_failures}"
  393. )
  394. def delta32(end: int, start: int) -> int:
  395. return (end - start) & 0xFFFFFFFF
  396. def is_retryable_communication_error(error: BaseException) -> bool:
  397. if isinstance(error, serial.SerialException):
  398. return True
  399. if not isinstance(error, RuntimeError):
  400. return False
  401. message = str(error)
  402. return message.startswith("响应超时:") or message.startswith("响应 CRC 错误:")
  403. def parse_args() -> argparse.Namespace:
  404. parser = argparse.ArgumentParser(description="PLSR Modbus/USB 长稳并发测试")
  405. parser.add_argument("--port", default="COM5", help="Modbus RTU 串口,默认 COM5")
  406. parser.add_argument("--baud", type=int, default=9600)
  407. parser.add_argument("--slave", type=int, default=1)
  408. parser.add_argument("--timeout", type=float, default=1.5)
  409. parser.add_argument("--duration", type=float, default=1800.0, help="运行秒数")
  410. parser.add_argument("--frequency", type=int, default=100_000)
  411. parser.add_argument(
  412. "--low-frequency",
  413. type=int,
  414. help="动态频率低值,默认主频率的一半",
  415. )
  416. parser.add_argument("--status-period", type=float, default=1.0)
  417. parser.add_argument(
  418. "--frequency-period",
  419. type=float,
  420. default=10.0,
  421. help="动态频率切换周期;0 表示禁用",
  422. )
  423. parser.add_argument(
  424. "--bad-crc-period",
  425. type=float,
  426. default=0.0,
  427. help="坏 CRC 注入周期;默认 0(禁用)",
  428. )
  429. parser.add_argument(
  430. "--disconnect-at",
  431. type=float,
  432. default=0.0,
  433. help="运行到指定秒数时主动断开串口;默认 0(禁用)",
  434. )
  435. parser.add_argument("--disconnect-duration", type=float, default=3.0)
  436. parser.add_argument("--usb-port", help="可选 USB CDC 虚拟串口,例如 COM8")
  437. parser.add_argument("--usb-rate", type=int, default=64_000, help="USB OUT B/s")
  438. parser.add_argument("--max-communication-errors", type=int, default=5)
  439. parser.add_argument("--reconnect-delay", type=float, default=1.0)
  440. parser.add_argument(
  441. "--output-dir",
  442. type=Path,
  443. default=Path("HostComputer/long_stress_logs"),
  444. )
  445. return parser.parse_args()
  446. def main() -> int:
  447. args = parse_args()
  448. if args.baud <= 0:
  449. raise RuntimeError("baud 必须大于 0")
  450. if not 1 <= args.slave <= 247:
  451. raise RuntimeError("slave 必须为 1~247")
  452. if args.timeout <= 0:
  453. raise RuntimeError("timeout 必须大于 0")
  454. if args.duration <= 0 or args.status_period <= 0:
  455. raise RuntimeError("duration 和 status-period 必须大于 0")
  456. if args.frequency_period < 0 or args.bad_crc_period < 0:
  457. raise RuntimeError("frequency-period 和 bad-crc-period 不得为负数")
  458. if args.disconnect_at < 0 or args.disconnect_duration < 0:
  459. raise RuntimeError("disconnect-at 和 disconnect-duration 不得为负数")
  460. if args.disconnect_at > 0 and args.disconnect_duration <= 0:
  461. raise RuntimeError("启用计划断线时 disconnect-duration 必须大于 0")
  462. if args.usb_rate < 0:
  463. raise RuntimeError("usb-rate 不得为负数")
  464. if args.usb_port and args.usb_rate == 0:
  465. raise RuntimeError("启用 USB 压力时 usb-rate 必须大于 0")
  466. if args.max_communication_errors < 0:
  467. raise RuntimeError("max-communication-errors 不得为负数")
  468. if args.reconnect_delay < 0:
  469. raise RuntimeError("reconnect-delay 不得为负数")
  470. if not 1 <= args.frequency <= 100_000:
  471. raise RuntimeError("frequency 必须为 1~100000Hz(K4/P18 配置上限)")
  472. low_frequency = args.low_frequency or max(1, args.frequency // 2)
  473. if not 1 <= low_frequency <= args.frequency:
  474. raise RuntimeError("low-frequency 必须为 1~frequency")
  475. if args.usb_port and args.usb_port.upper() == args.port.upper():
  476. raise RuntimeError("USB CDC 串口不能与 Modbus 串口相同")
  477. pulse_target = math.ceil(
  478. (args.duration + max(0.0, args.disconnect_duration) + 120.0)
  479. * args.frequency
  480. )
  481. if pulse_target > 2_000_000_000:
  482. raise RuntimeError("测试时长/频率使单段脉冲超过 20 亿;请降低时长或频率")
  483. args.output_dir.mkdir(parents=True, exist_ok=True)
  484. run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
  485. csv_path = args.output_dir / f"plsr_long_stress_{run_id}.csv"
  486. json_path = args.output_dir / f"plsr_long_stress_{run_id}.json"
  487. events: list[dict[str, Any]] = []
  488. summary: dict[str, Any] = {
  489. "started_utc": datetime.now(timezone.utc).isoformat(),
  490. "arguments": {
  491. key: str(value) if isinstance(value, Path) else value
  492. for key, value in vars(args).items()
  493. },
  494. "pulse_target": pulse_target,
  495. "low_frequency": low_frequency,
  496. "events": events,
  497. "result": "FAIL",
  498. }
  499. usb_pressure = UsbOutPressure(args.usb_port, args.usb_rate)
  500. uart: serial.Serial | None = None
  501. client: RtuClient | None = None
  502. sequence = 1000
  503. failure: BaseException | None = None
  504. last_status: list[dict[str, int]] = []
  505. axes_stopped = False
  506. with csv_path.open("w", newline="", encoding="utf-8-sig") as csv_file:
  507. writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
  508. writer.writeheader()
  509. try:
  510. uart, client = open_modbus(args)
  511. header = client.read_holding(CONTROL_BASE, 8)
  512. if (
  513. header[0:3] != [0x504C, 0x5352, 0x0100]
  514. or header[3] != CONTROL_WINDOW_WORDS
  515. or header[7] != PERFORMANCE_VERSION
  516. ):
  517. raise RuntimeError(f"PLSR 控制窗口未就绪:{header}")
  518. diagnostics_start = read_runtime_diagnostics(client)
  519. summary["diagnostics_start"] = diagnostics_start
  520. usb_diagnostics_start: dict[str, Any] | None = None
  521. if args.usb_port:
  522. usb_diagnostics_start = read_usb_diagnostics(client)
  523. summary["usb_device_diagnostics_start"] = usb_diagnostics_start
  524. prepare_jobs(client, args.frequency, pulse_target)
  525. for axis in range(4):
  526. response = send_command(client, sequence, axis, CMD_SET_POSITION, 0)
  527. check_result(response, 4, RESULT_QUEUED, f"轴{axis} SET_POSITION")
  528. if axis == 0:
  529. replay = send_command(client, sequence, axis, CMD_SET_POSITION, 0)
  530. if replay != response:
  531. raise RuntimeError(
  532. f"重复命令序号未回放同一应答:首次={response},重复={replay}"
  533. )
  534. events.append({"elapsed_s": 0.0, "event": "idempotency_pass"})
  535. sequence += 1
  536. for axis in range(4):
  537. response = send_call(client, sequence, axis, CALL_COMMIT)
  538. check_result(response, 3, RESULT_OK, f"轴{axis} COMMIT")
  539. if response[11] != 1:
  540. raise RuntimeError(f"轴{axis} COMMIT 未建立有效快照")
  541. sequence += 1
  542. physical_baseline = [
  543. read_axis_status(client, axis)[0]["physical_pulses"]
  544. for axis in range(4)
  545. ]
  546. for axis in range(4):
  547. response = send_call(client, sequence, axis, CALL_START)
  548. check_result(response, 3, RESULT_QUEUED, f"轴{axis} START")
  549. sequence += 1
  550. usb_pressure.start()
  551. started = time.monotonic()
  552. next_sample = started
  553. next_frequency = (
  554. started + args.frequency_period
  555. if args.frequency_period > 0
  556. else float("inf")
  557. )
  558. next_bad_crc = (
  559. started + args.bad_crc_period
  560. if args.bad_crc_period > 0
  561. else float("inf")
  562. )
  563. disconnected = False
  564. communication_errors = 0
  565. sample_index = 0
  566. dynamic_frequency = args.frequency
  567. previous_physical = physical_baseline[:]
  568. while time.monotonic() - started < args.duration:
  569. now = time.monotonic()
  570. elapsed = now - started
  571. if (
  572. args.disconnect_at > 0
  573. and not disconnected
  574. and elapsed >= args.disconnect_at
  575. ):
  576. before_disconnect = previous_physical[:]
  577. assert uart is not None
  578. uart.close()
  579. events.append(
  580. {"elapsed_s": elapsed, "event": "planned_disconnect_start"}
  581. )
  582. time.sleep(args.disconnect_duration)
  583. uart, client = reopen_modbus(args)
  584. disconnected = True
  585. after_disconnect = [
  586. read_axis_status(client, axis)[0]["physical_pulses"]
  587. for axis in range(4)
  588. ]
  589. if any(
  590. after_disconnect[axis] <= before_disconnect[axis]
  591. for axis in range(4)
  592. ):
  593. raise RuntimeError(
  594. "计划断线期间存在轴脉冲未继续增长:"
  595. f"before={before_disconnect}, after={after_disconnect}"
  596. )
  597. previous_physical = after_disconnect
  598. events.append(
  599. {
  600. "elapsed_s": time.monotonic() - started,
  601. "event": "planned_disconnect_recovered",
  602. "physical_pulses": after_disconnect,
  603. }
  604. )
  605. next_sample = time.monotonic()
  606. continue
  607. try:
  608. assert client is not None and uart is not None
  609. if now >= next_frequency:
  610. dynamic_frequency = (
  611. low_frequency
  612. if dynamic_frequency == args.frequency
  613. else args.frequency
  614. )
  615. for axis in range(4):
  616. # FC16 writes both words of the signed INT32 atomically.
  617. client.write_multiple(
  618. S0_BASES[axis] + 10,
  619. signed_dword_words(dynamic_frequency),
  620. )
  621. events.append(
  622. {
  623. "elapsed_s": elapsed,
  624. "event": "frequency_change",
  625. "frequency_hz": dynamic_frequency,
  626. }
  627. )
  628. next_frequency += args.frequency_period
  629. if now >= next_bad_crc:
  630. before_crc = read_runtime_diagnostics(client)["statistics"][
  631. "crc_errors"
  632. ]
  633. inject_bad_crc(uart, args.slave, args.baud)
  634. after_crc = read_runtime_diagnostics(client)["statistics"][
  635. "crc_errors"
  636. ]
  637. if delta32(after_crc, before_crc) < 1:
  638. raise RuntimeError("注入坏 CRC 后 crcErrorCount 未增长")
  639. events.append(
  640. {
  641. "elapsed_s": elapsed,
  642. "event": "bad_crc_rejected",
  643. "crc_count": after_crc,
  644. }
  645. )
  646. next_bad_crc += args.bad_crc_period
  647. if now < next_sample:
  648. time.sleep(min(next_sample - now, 0.05))
  649. continue
  650. diagnostics = read_runtime_diagnostics(client)
  651. if not diagnostics["connected"]:
  652. raise RuntimeError(f"Modbus connected 标志丢失:{diagnostics}")
  653. statuses: list[dict[str, int]] = []
  654. snapshot_retries: list[int] = []
  655. for axis in range(4):
  656. status, retries = read_axis_status(client, axis)
  657. statuses.append(status)
  658. snapshot_retries.append(retries)
  659. if status["error"] != 0 or status["last_result"] != RESULT_OK:
  660. raise RuntimeError(f"轴{axis}进入错误状态:{status}")
  661. if status["state"] not in {STATE_ACCEL, STATE_RUN, STATE_DECEL}:
  662. raise RuntimeError(f"轴{axis}意外离开运行态:{status}")
  663. if status["physical_pulses"] < previous_physical[axis]:
  664. raise RuntimeError(
  665. f"轴{axis}物理累计计数回退:"
  666. f"{previous_physical[axis]} -> {status['physical_pulses']}"
  667. )
  668. previous_physical[axis] = status["physical_pulses"]
  669. timestamp = datetime.now(timezone.utc).isoformat()
  670. stats = diagnostics["statistics"]
  671. for axis, status in enumerate(statuses):
  672. writer.writerow(
  673. {
  674. "host_time_utc": timestamp,
  675. "elapsed_s": f"{elapsed:.6f}",
  676. "sample": sample_index,
  677. "axis": axis,
  678. "state": status["state"],
  679. "flags": status["flags"],
  680. "error": status["error"],
  681. "last_result": status["last_result"],
  682. "counter_mode": status["counter_mode"],
  683. "current_frequency": status["current_frequency"],
  684. "target_frequency": status["target_frequency"],
  685. "logical_position": status["logical_position"],
  686. "task_pulses": status["task_pulses"],
  687. "physical_pulses": status["physical_pulses"],
  688. "snapshot_retries": snapshot_retries[axis],
  689. "modbus_valid_frames": stats["valid_frames"],
  690. "modbus_tx_frames": stats["tx_frames"],
  691. "modbus_crc_errors": stats["crc_errors"],
  692. "modbus_dropped_frames": stats["dropped_frames"],
  693. "modbus_uart_errors": stats["uart_errors"],
  694. "modbus_restart_failures": diagnostics[
  695. "restart_failures"
  696. ],
  697. "modbus_last_uart_error": diagnostics[
  698. "last_uart_error"
  699. ],
  700. }
  701. )
  702. csv_file.flush()
  703. last_status = statuses
  704. sample_index += 1
  705. if sample_index % 30 == 0:
  706. print(
  707. f"{elapsed:8.1f}s:样本 {sample_index},"
  708. f"Q0物理累计={statuses[0]['physical_pulses']},"
  709. f"Modbus有效帧={stats['valid_frames']}"
  710. )
  711. next_sample = max(
  712. next_sample + args.status_period, time.monotonic()
  713. )
  714. communication_errors = 0
  715. except (RuntimeError, serial.SerialException) as error:
  716. if not is_retryable_communication_error(error):
  717. raise
  718. communication_errors += 1
  719. events.append(
  720. {
  721. "elapsed_s": time.monotonic() - started,
  722. "event": "communication_error",
  723. "count": communication_errors,
  724. "message": str(error),
  725. }
  726. )
  727. if communication_errors > args.max_communication_errors:
  728. raise
  729. if uart is not None:
  730. uart.close()
  731. time.sleep(args.reconnect_delay)
  732. uart, client = reopen_modbus(args)
  733. next_sample = time.monotonic()
  734. assert client is not None
  735. sequence = stop_all_axes(client, sequence)
  736. axes_stopped = True
  737. usb_pressure.stop()
  738. last_status = [read_axis_status(client, axis)[0] for axis in range(4)]
  739. diagnostics_end = read_runtime_diagnostics(client)
  740. summary["diagnostics_end"] = diagnostics_end
  741. summary["diagnostics_delta"] = {
  742. name: delta32(
  743. diagnostics_end["statistics"][name],
  744. diagnostics_start["statistics"][name],
  745. )
  746. for name in diagnostics_end["statistics"]
  747. }
  748. summary["restart_failure_delta"] = delta32(
  749. diagnostics_end["restart_failures"],
  750. diagnostics_start["restart_failures"],
  751. )
  752. usb_diagnostics_end: dict[str, Any] | None = None
  753. usb_device_delta: dict[str, int] = {}
  754. if args.usb_port:
  755. assert usb_diagnostics_start is not None
  756. usb_diagnostics_end = read_usb_diagnostics(client)
  757. summary["usb_device_diagnostics_end"] = usb_diagnostics_end
  758. usb_device_delta = {
  759. name: delta32(
  760. usb_diagnostics_end["counters"][name],
  761. usb_diagnostics_start["counters"][name],
  762. )
  763. for name in usb_diagnostics_end["counters"]
  764. }
  765. summary["usb_device_diagnostics_delta"] = usb_device_delta
  766. injected_bad_crc = sum(
  767. event.get("event") == "bad_crc_rejected" for event in events
  768. )
  769. unhealthy = {
  770. "uart_errors": summary["diagnostics_delta"]["uart_errors"],
  771. "dropped_frames": summary["diagnostics_delta"]["dropped_frames"],
  772. "restart_failures": summary["restart_failure_delta"],
  773. "unexpected_crc_errors": (
  774. summary["diagnostics_delta"]["crc_errors"]
  775. - injected_bad_crc
  776. ),
  777. "usb_write_errors": usb_pressure.write_errors,
  778. }
  779. if args.usb_port:
  780. assert usb_diagnostics_start is not None
  781. assert usb_diagnostics_end is not None
  782. unhealthy.update(
  783. {
  784. "usb_target_not_initialized": not usb_diagnostics_end[
  785. "initialized"
  786. ],
  787. "usb_target_rx_packets_no_increment": (
  788. usb_device_delta["rx_packet_count"] == 0
  789. ),
  790. "usb_target_rx_bytes_no_increment": (
  791. usb_device_delta["rx_byte_count"] == 0
  792. ),
  793. "usb_target_rx_rearm_failures": usb_device_delta[
  794. "rx_rearm_failure_count"
  795. ],
  796. }
  797. )
  798. unhealthy = {name: value for name, value in unhealthy.items() if value}
  799. if unhealthy:
  800. raise RuntimeError(f"长稳运行诊断出现异常增量:{unhealthy}")
  801. summary["samples"] = sample_index
  802. summary["final_status"] = last_status
  803. summary["result"] = "PASS"
  804. except (RuntimeError, serial.SerialException, OSError, KeyboardInterrupt) as error:
  805. failure = error
  806. summary["failure"] = str(error)
  807. if client is not None and not axes_stopped:
  808. try:
  809. sequence = stop_all_axes(client, sequence)
  810. except Exception as stop_error: # best-effort safety cleanup
  811. summary["stop_cleanup_failure"] = str(stop_error)
  812. finally:
  813. usb_pressure.stop()
  814. summary["usb_host_pressure"] = usb_pressure.summary()
  815. summary["ended_utc"] = datetime.now(timezone.utc).isoformat()
  816. if uart is not None and uart.is_open:
  817. uart.close()
  818. json_path.write_text(
  819. json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
  820. )
  821. print(f"CSV 证据:{csv_path}")
  822. print(f"JSON 汇总:{json_path}")
  823. if failure is not None:
  824. if isinstance(failure, KeyboardInterrupt):
  825. raise RuntimeError("用户中止测试,已尝试停止四轴") from failure
  826. raise RuntimeError(str(failure)) from failure
  827. print(
  828. f"长稳 PASS:{summary['samples']} 个四轴一致性样本;"
  829. f"最终计数={[item['physical_pulses'] for item in last_status]}"
  830. )
  831. return 0
  832. if __name__ == "__main__":
  833. try:
  834. raise SystemExit(main())
  835. except (RuntimeError, serial.SerialException) as error:
  836. print(f"测试失败:{error}")
  837. raise SystemExit(1)