Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 
 
 

2864 строки
100 KiB

  1. """Minimal board test for the 2026 PLSR Modbus product register map.
  2. The script is inert unless --run is supplied. Motion phases additionally
  3. require --allow-motion because they drive the configured pulse output.
  4. """
  5. import argparse
  6. import dataclasses
  7. import io
  8. import math
  9. from pathlib import Path
  10. import re
  11. import subprocess
  12. import sys
  13. import time
  14. import serial
  15. CONFIG_BASE = 0x1000
  16. COMMON_WORDS = 0x14
  17. SEGMENT_1_BASE = 0x1100
  18. SEGMENT_2_BASE = 0x1110
  19. SEGMENT_WORDS = 8
  20. STATUS_BASE = 0x2000
  21. STATUS_WORDS = 7
  22. CONTROL = 0x3000
  23. PERSISTENCE_LIVE_CONFIG_ADDRESS = CONFIG_BASE + 0x05
  24. PERSISTENCE_LIVE_MIN_DURATION_SECONDS = 2.5
  25. PERSISTENCE_LIVE_DEFAULT_DURATION_SECONDS = 2.5
  26. PERSISTENCE_LIVE_DEFAULT_INTERVAL_SECONDS = 0.05
  27. PERSISTENCE_LIVE_MAX_INTERVAL_SECONDS = 0.1
  28. PERSISTENCE_LIVE_REQUEST_TIMEOUT_SECONDS = 0.25
  29. OUTPUT_MODE = 0x1200
  30. DIAGNOSTIC_BASE = 0x2100
  31. DIAGNOSTIC_WORDS = 0x1A
  32. DIAGNOSTIC_CONTROL = 0x3100
  33. OUTPUT_PULSE_DIR = 0
  34. OUTPUT_AB = 1
  35. DIAGNOSTIC_CLEAR = 0x0001
  36. DIAG_MONITORING = 0x0001
  37. DIAG_COUNT_CHECKED = 0x0002
  38. DIAG_COUNT_PASS = 0x0004
  39. DIAG_FREQUENCY_CHECKED = 0x0008
  40. DIAG_FREQUENCY_PASS = 0x0010
  41. DIAG_CURVE_CHECKED = 0x0020
  42. DIAG_CURVE_PASS = 0x0040
  43. DIAG_FAULT_LATCHED = 0x0080
  44. DIAG_COMPLETE_PASS = (
  45. DIAG_COUNT_CHECKED
  46. | DIAG_COUNT_PASS
  47. | DIAG_FREQUENCY_CHECKED
  48. | DIAG_FREQUENCY_PASS
  49. | DIAG_CURVE_CHECKED
  50. | DIAG_CURVE_PASS
  51. )
  52. COMMAND_START = 0x0001
  53. COMMAND_STOP = 0x0002
  54. COMMAND_CLEAR = 0x0004
  55. STATUS_IDLE = 1
  56. STATUS_ACCELERATING = 2
  57. STATUS_RUNNING = 3
  58. STATUS_DECELERATING = 4
  59. STATUS_WAITING = 5
  60. STATUS_COMPLETED = 7
  61. STATUS_STOPPED = 8
  62. STATUS_ERROR = 9
  63. ERROR_NONE = 0
  64. WAIT_TIME = 0
  65. WAIT_SIGNAL = 1
  66. ACT_TIME = 2
  67. EXT_SIGNAL = 3
  68. EXT_OR_COMPLETE = 4
  69. SEND_COMPLETE = 0
  70. SEND_SUBSEQUENT = 1
  71. DEFAULT_STLINK_CLI = r"F:\ST-LINK Utility\ST-LINK_CLI.exe"
  72. DEFAULT_IAR_MAP = r"EWARM\Modbus\List\Modbus.map"
  73. IRQ_CYCLE_BUDGET_100KHZ = 1680
  74. GPIOE_CLOCK_BIT = 0x42470610
  75. GPIOI_CLOCK_BIT = 0x42470620
  76. GPIOE_PIN6_OUTPUT_BIT = 0x42420298
  77. GPIOI_PIN8_OUTPUT_BIT = 0x424402A0
  78. GPIOE_PIN6_MODE_LOW_BIT = 0x42420030
  79. GPIOE_PIN6_MODE_HIGH_BIT = 0x42420034
  80. GPIOI_PIN8_MODE_LOW_BIT = 0x42440040
  81. GPIOI_PIN8_MODE_HIGH_BIT = 0x42440044
  82. GPIOE_PIN6_PULL_LOW_BIT = 0x424201B0
  83. GPIOE_PIN6_PULL_HIGH_BIT = 0x424201B4
  84. GPIOI_PIN8_PULL_LOW_BIT = 0x424401C0
  85. GPIOI_PIN8_PULL_HIGH_BIT = 0x424401C4
  86. X4_INPUT_BIT = 0x42408214
  87. X5_INPUT_BIT = 0x42430230
  88. OUTPUT_OFF = 1
  89. OUTPUT_ON = 0
  90. EX_ILLEGAL_FUNCTION = 0x01
  91. EX_ILLEGAL_ADDRESS = 0x02
  92. EX_ILLEGAL_VALUE = 0x03
  93. EX_DEVICE_FAILURE = 0x04
  94. EX_DEVICE_BUSY = 0x06
  95. ACTIVE_STATES = {
  96. STATUS_ACCELERATING,
  97. STATUS_RUNNING,
  98. STATUS_DECELERATING,
  99. STATUS_WAITING,
  100. }
  101. TERMINAL_STATES = {STATUS_COMPLETED, STATUS_STOPPED, STATUS_ERROR}
  102. class TestFailure(RuntimeError):
  103. pass
  104. class RtuTimeout(TestFailure):
  105. pass
  106. class ModbusException(RuntimeError):
  107. def __init__(self, function, code):
  108. super().__init__(
  109. "Modbus exception: function=0x%02X code=0x%02X"
  110. % (function, code)
  111. )
  112. self.function = function
  113. self.code = code
  114. def parse_stlink_word(output, address):
  115. return parse_stlink_words(output, address, 1)[0]
  116. def parse_iar_map_symbol_address(map_text, symbol):
  117. pattern = (
  118. r"(?m)^" + re.escape(symbol)
  119. + r"\s+0x([0-9a-fA-F]+)'([0-9a-fA-F]+)\s+"
  120. )
  121. match = re.search(pattern, map_text)
  122. require(match is not None, "IAR map does not contain %s" % symbol)
  123. return (int(match.group(1), 16) << 16) | int(match.group(2), 16)
  124. def parse_stlink_words(output, address, count):
  125. require(count > 0, "ST-LINK parse count must be positive")
  126. observed = {}
  127. pattern = re.compile(
  128. r"(?im)^\s*0x([0-9a-f]{8})\s*:\s*"
  129. r"((?:[0-9a-f]{8}(?:\s+|$))+)",
  130. )
  131. for match in pattern.finditer(output):
  132. line_address = int(match.group(1), 16)
  133. for offset, value in enumerate(re.findall(
  134. r"[0-9a-f]{8}", match.group(2), flags=re.IGNORECASE)):
  135. observed[line_address + offset * 4] = int(value, 16)
  136. words = []
  137. for offset in range(count):
  138. word_address = address + offset * 4
  139. require(word_address in observed,
  140. "ST-LINK output did not contain address 0x%08X"
  141. % word_address)
  142. words.append(observed[word_address])
  143. return words
  144. class X45Fixture:
  145. """Drive Y4/Y5 through SWD without adding product test registers."""
  146. def __init__(self, executable, probe_id, timeout):
  147. self.executable = executable
  148. self.probe_id = probe_id
  149. self.timeout = timeout
  150. def _run(self, *arguments):
  151. command_line = [
  152. self.executable,
  153. "-c",
  154. "ID=%d" % self.probe_id,
  155. "SWD",
  156. "HOTPLUG",
  157. ] + list(arguments) + ["-Q", "-NoPrompt"]
  158. try:
  159. result = subprocess.run(
  160. command_line,
  161. stdout=subprocess.PIPE,
  162. stderr=subprocess.STDOUT,
  163. text=True,
  164. errors="replace",
  165. timeout=self.timeout,
  166. check=False,
  167. )
  168. except (OSError, subprocess.SubprocessError) as error:
  169. raise TestFailure("ST-LINK command failed: %s" % error) from error
  170. require(
  171. result.returncode == 0,
  172. "ST-LINK command returned %d:\n%s"
  173. % (result.returncode, result.stdout.strip()),
  174. )
  175. return result.stdout
  176. def read_words(self, address, count):
  177. require(count > 0, "ST-LINK read count must be positive")
  178. output = self._run("-r32", hex(address), str(count * 4))
  179. return parse_stlink_words(output, address, count)
  180. def write_words(self, address, values):
  181. require(values, "ST-LINK write values must not be empty")
  182. arguments = []
  183. for offset, value in enumerate(values):
  184. arguments.extend((
  185. "-w32",
  186. hex(address + offset * 4),
  187. hex(value & 0xFFFFFFFF),
  188. ))
  189. self._run(*arguments)
  190. def prepare(self):
  191. self._run(
  192. "-w32", hex(GPIOE_CLOCK_BIT), "1",
  193. "-w32", hex(GPIOI_CLOCK_BIT), "1",
  194. "-w32", hex(GPIOI_PIN8_OUTPUT_BIT), str(OUTPUT_OFF),
  195. "-w32", hex(GPIOE_PIN6_OUTPUT_BIT), str(OUTPUT_OFF),
  196. "-w32", hex(GPIOI_PIN8_MODE_HIGH_BIT), "0",
  197. "-w32", hex(GPIOI_PIN8_MODE_LOW_BIT), "1",
  198. "-w32", hex(GPIOE_PIN6_MODE_HIGH_BIT), "0",
  199. "-w32", hex(GPIOE_PIN6_MODE_LOW_BIT), "1",
  200. )
  201. time.sleep(0.050)
  202. def drive(self, input_selection, active):
  203. require(input_selection in (0, 1), "input selection must be X4 or X5")
  204. if input_selection == 0:
  205. address = GPIOI_PIN8_OUTPUT_BIT
  206. else:
  207. address = GPIOE_PIN6_OUTPUT_BIT
  208. value = OUTPUT_ON if active else OUTPUT_OFF
  209. self._run("-w32", hex(address), str(value))
  210. time.sleep(0.050)
  211. def read(self, input_selection):
  212. require(input_selection in (0, 1), "input selection must be X4 or X5")
  213. address = X4_INPUT_BIT if input_selection == 0 else X5_INPUT_BIT
  214. output = self._run("-r32", hex(address), "1")
  215. return parse_stlink_word(output, address) & 1
  216. def all_off(self):
  217. self._run(
  218. "-w32", hex(GPIOI_PIN8_OUTPUT_BIT), str(OUTPUT_OFF),
  219. "-w32", hex(GPIOE_PIN6_OUTPUT_BIT), str(OUTPUT_OFF),
  220. )
  221. time.sleep(0.050)
  222. def release(self):
  223. self._run(
  224. "-w32", hex(GPIOI_PIN8_OUTPUT_BIT), str(OUTPUT_OFF),
  225. "-w32", hex(GPIOE_PIN6_OUTPUT_BIT), str(OUTPUT_OFF),
  226. "-w32", hex(GPIOI_PIN8_MODE_LOW_BIT), "0",
  227. "-w32", hex(GPIOI_PIN8_MODE_HIGH_BIT), "0",
  228. "-w32", hex(GPIOI_PIN8_PULL_LOW_BIT), "0",
  229. "-w32", hex(GPIOI_PIN8_PULL_HIGH_BIT), "0",
  230. "-w32", hex(GPIOE_PIN6_MODE_LOW_BIT), "0",
  231. "-w32", hex(GPIOE_PIN6_MODE_HIGH_BIT), "0",
  232. "-w32", hex(GPIOE_PIN6_PULL_LOW_BIT), "0",
  233. "-w32", hex(GPIOE_PIN6_PULL_HIGH_BIT), "0",
  234. )
  235. def require(condition, message):
  236. if not condition:
  237. raise TestFailure(message)
  238. def crc16(data):
  239. value = 0xFFFF
  240. for byte in data:
  241. value ^= byte
  242. for _ in range(8):
  243. if value & 1:
  244. value = (value >> 1) ^ 0xA001
  245. else:
  246. value >>= 1
  247. return value
  248. def append_crc(payload):
  249. checksum = crc16(payload)
  250. return bytes(payload) + bytes((checksum & 0xFF, checksum >> 8))
  251. def split_u32(value):
  252. value &= 0xFFFFFFFF
  253. return [value & 0xFFFF, value >> 16]
  254. def split_i32(value):
  255. require(-(1 << 31) <= value < (1 << 31), "signed value is not int32")
  256. return split_u32(value)
  257. def join_u32(low_word, high_word):
  258. return low_word | (high_word << 16)
  259. def join_i32(low_word, high_word):
  260. value = join_u32(low_word, high_word)
  261. return value - (1 << 32) if value & 0x80000000 else value
  262. def frequency_matches(actual_hz, requested_hz):
  263. tolerance_hz = max(1, requested_hz // 1000)
  264. return abs(actual_hz - requested_hz) <= tolerance_hz
  265. @dataclasses.dataclass
  266. class Status:
  267. position: int
  268. frequency_hz: int
  269. state: int
  270. segment: int
  271. error: int
  272. @dataclasses.dataclass
  273. class Diagnostic:
  274. flags: int
  275. reason: int
  276. segment: int
  277. output_mode: int
  278. direction_positive: bool
  279. expected_pulses: int
  280. actual_pulses: int
  281. count_error: int
  282. requested_hz: int
  283. expected_timer_hz: int
  284. active_timer_hz: int
  285. request_error_hz: int
  286. sample_count: int
  287. mismatch_count: int
  288. maximum_frequency_error_hz: int
  289. first_mismatch_sample: int
  290. @dataclasses.dataclass
  291. class PersistenceLiveStats:
  292. attempts: int
  293. successful: int
  294. timeouts: int
  295. maximum_attempt_seconds: float
  296. elapsed_seconds: float
  297. @dataclasses.dataclass(frozen=True)
  298. class IrqCycleStats:
  299. count: tuple
  300. last_cycles: tuple
  301. maximum_cycles: tuple
  302. @dataclasses.dataclass(frozen=True)
  303. class ProducerCycleStats:
  304. item_count: int
  305. total_cycles: int
  306. maximum_item_cycles: int
  307. @property
  308. def average_cycles(self):
  309. require(self.item_count > 0,
  310. "producer timing did not record any generated item")
  311. return self.total_cycles / float(self.item_count)
  312. @dataclasses.dataclass(frozen=True)
  313. class FinalArmCycleStats:
  314. queue_count: tuple
  315. job_last_cycles: tuple
  316. job_maximum_cycles: tuple
  317. queue_to_stop_last_cycles: tuple
  318. queue_to_stop_maximum_cycles: tuple
  319. class TimingFixture:
  320. IRQ_SYMBOLS = (
  321. "PlsrIrqCount",
  322. "PlsrIrqLastCycles",
  323. "PlsrIrqMaxCycles",
  324. )
  325. PRODUCER_SYMBOLS = (
  326. "PlsrProfileProducerItemCount",
  327. "PlsrProfileProducerTotalCycles",
  328. "PlsrProfileProducerMaxItemCycles",
  329. )
  330. FINAL_ARM_SYMBOLS = (
  331. "PlsrFinalArmQueueCount",
  332. "PlsrFinalArmJobLastCycles",
  333. "PlsrFinalArmJobMaxCycles",
  334. "PlsrFinalArmQueueToStopLastCycles",
  335. "PlsrFinalArmQueueToStopMaxCycles",
  336. )
  337. def __init__(self, probe, map_path):
  338. self.probe = probe
  339. try:
  340. map_text = Path(map_path).read_text(
  341. encoding="utf-8", errors="replace")
  342. except OSError as error:
  343. raise TestFailure("could not read IAR map %s: %s"
  344. % (map_path, error)) from error
  345. self.addresses = {
  346. symbol: parse_iar_map_symbol_address(map_text, symbol)
  347. for symbol in (self.IRQ_SYMBOLS + self.PRODUCER_SYMBOLS
  348. + self.FINAL_ARM_SYMBOLS)
  349. }
  350. def reset(self):
  351. for symbol in self.IRQ_SYMBOLS:
  352. self.probe.write_words(self.addresses[symbol], [0] * 4)
  353. for symbol in self.PRODUCER_SYMBOLS:
  354. self.probe.write_words(self.addresses[symbol], [0])
  355. for symbol in self.FINAL_ARM_SYMBOLS:
  356. self.probe.write_words(self.addresses[symbol], [0] * 4)
  357. def read(self):
  358. irq = IrqCycleStats(
  359. count=tuple(self.probe.read_words(
  360. self.addresses["PlsrIrqCount"], 4)),
  361. last_cycles=tuple(self.probe.read_words(
  362. self.addresses["PlsrIrqLastCycles"], 4)),
  363. maximum_cycles=tuple(self.probe.read_words(
  364. self.addresses["PlsrIrqMaxCycles"], 4)),
  365. )
  366. producer = ProducerCycleStats(
  367. item_count=self.probe.read_words(
  368. self.addresses["PlsrProfileProducerItemCount"], 1)[0],
  369. total_cycles=self.probe.read_words(
  370. self.addresses["PlsrProfileProducerTotalCycles"], 1)[0],
  371. maximum_item_cycles=self.probe.read_words(
  372. self.addresses["PlsrProfileProducerMaxItemCycles"], 1)[0],
  373. )
  374. final_arm = FinalArmCycleStats(
  375. queue_count=tuple(self.probe.read_words(
  376. self.addresses["PlsrFinalArmQueueCount"], 4)),
  377. job_last_cycles=tuple(self.probe.read_words(
  378. self.addresses["PlsrFinalArmJobLastCycles"], 4)),
  379. job_maximum_cycles=tuple(self.probe.read_words(
  380. self.addresses["PlsrFinalArmJobMaxCycles"], 4)),
  381. queue_to_stop_last_cycles=tuple(self.probe.read_words(
  382. self.addresses["PlsrFinalArmQueueToStopLastCycles"], 4)),
  383. queue_to_stop_maximum_cycles=tuple(self.probe.read_words(
  384. self.addresses["PlsrFinalArmQueueToStopMaxCycles"], 4)),
  385. )
  386. return irq, producer, final_arm
  387. class RtuClient:
  388. def __init__(self, port, baud, slave, timeout):
  389. self.port_name = port
  390. self.baud = baud
  391. self.slave = slave
  392. self.timeout = timeout
  393. self.character_seconds = 11.0 / baud
  394. if baud > 19200:
  395. self.frame_gap_seconds = 0.00175
  396. else:
  397. self.frame_gap_seconds = 3.5 * self.character_seconds
  398. self.serial_port = None
  399. self.last_request_finished = 0.0
  400. def __enter__(self):
  401. self.serial_port = serial.Serial(
  402. port=self.port_name,
  403. baudrate=self.baud,
  404. bytesize=serial.EIGHTBITS,
  405. parity=serial.PARITY_EVEN,
  406. stopbits=serial.STOPBITS_ONE,
  407. timeout=0,
  408. write_timeout=1,
  409. )
  410. self.serial_port.reset_input_buffer()
  411. self.serial_port.reset_output_buffer()
  412. return self
  413. def __exit__(self, exc_type, exc_value, traceback):
  414. if self.serial_port is not None:
  415. self.serial_port.close()
  416. self.serial_port = None
  417. def _exchange(self, pdu):
  418. request = append_crc(bytes((self.slave,)) + bytes(pdu))
  419. now = time.monotonic()
  420. delay = self.frame_gap_seconds - (now - self.last_request_finished)
  421. if delay > 0:
  422. time.sleep(delay)
  423. self.serial_port.reset_input_buffer()
  424. self.serial_port.write(request)
  425. self.serial_port.flush()
  426. response = bytearray()
  427. deadline = time.monotonic() + self.timeout
  428. expected_length = None
  429. while time.monotonic() < deadline:
  430. waiting = self.serial_port.in_waiting
  431. if waiting:
  432. response.extend(self.serial_port.read(waiting))
  433. if len(response) >= 2 and response[1] == (pdu[0] | 0x80):
  434. expected_length = 5
  435. elif pdu[0] == 0x03 and len(response) >= 3:
  436. expected_length = response[2] + 5
  437. elif pdu[0] in (0x06, 0x10):
  438. expected_length = 8
  439. if expected_length is not None and len(response) >= expected_length:
  440. break
  441. if not waiting:
  442. time.sleep(0.0005)
  443. waiting = self.serial_port.in_waiting
  444. if waiting:
  445. response.extend(self.serial_port.read(waiting))
  446. if len(response) >= 2 and response[1] == (pdu[0] | 0x80):
  447. expected_length = 5
  448. elif pdu[0] == 0x03 and len(response) >= 3:
  449. expected_length = response[2] + 5
  450. elif pdu[0] in (0x06, 0x10):
  451. expected_length = 8
  452. self.last_request_finished = time.monotonic()
  453. if not response:
  454. raise RtuTimeout("no Modbus response to %s" % request.hex(" "))
  455. if expected_length is None:
  456. raise RtuTimeout(
  457. "Modbus response header timed out: %s" % response.hex(" ")
  458. )
  459. if len(response) < expected_length:
  460. raise RtuTimeout(
  461. "Modbus response timed out after %d/%d bytes: %s"
  462. % (len(response), expected_length, response.hex(" "))
  463. )
  464. require(len(response) == expected_length,
  465. "oversized Modbus response: %s" % response.hex(" "))
  466. require(len(response) >= 5, "short Modbus response: %s" % response.hex(" "))
  467. received_crc = response[-2] | (response[-1] << 8)
  468. require(
  469. received_crc == crc16(response[:-2]),
  470. "bad response CRC: %s" % response.hex(" "),
  471. )
  472. require(response[0] == self.slave, "response slave address mismatch")
  473. requested_function = pdu[0]
  474. if response[1] == (requested_function | 0x80):
  475. require(len(response) == 5, "invalid exception response length")
  476. raise ModbusException(requested_function, response[2])
  477. require(response[1] == requested_function, "response function mismatch")
  478. return bytes(response)
  479. def read_holding(self, address, quantity):
  480. require(1 <= quantity <= 125, "FC03 quantity must be 1..125")
  481. pdu = bytes(
  482. (
  483. 0x03,
  484. address >> 8,
  485. address & 0xFF,
  486. quantity >> 8,
  487. quantity & 0xFF,
  488. )
  489. )
  490. response = self._exchange(pdu)
  491. byte_count = response[2]
  492. require(byte_count == quantity * 2, "FC03 byte count mismatch")
  493. require(len(response) == byte_count + 5, "FC03 response length mismatch")
  494. return [
  495. (response[3 + index * 2] << 8) | response[4 + index * 2]
  496. for index in range(quantity)
  497. ]
  498. def write_single(self, address, value):
  499. value &= 0xFFFF
  500. pdu = bytes(
  501. (
  502. 0x06,
  503. address >> 8,
  504. address & 0xFF,
  505. value >> 8,
  506. value & 0xFF,
  507. )
  508. )
  509. response = self._exchange(pdu)
  510. require(response[:6] == bytes((self.slave,)) + pdu, "FC06 echo mismatch")
  511. def write_multiple(self, address, values):
  512. require(1 <= len(values) <= 123, "FC10 quantity must be 1..123")
  513. data = bytearray()
  514. for value in values:
  515. value &= 0xFFFF
  516. data.extend((value >> 8, value & 0xFF))
  517. quantity = len(values)
  518. pdu = bytes(
  519. (
  520. 0x10,
  521. address >> 8,
  522. address & 0xFF,
  523. quantity >> 8,
  524. quantity & 0xFF,
  525. len(data),
  526. )
  527. ) + bytes(data)
  528. response = self._exchange(pdu)
  529. expected = bytes(
  530. (
  531. self.slave,
  532. 0x10,
  533. address >> 8,
  534. address & 0xFF,
  535. quantity >> 8,
  536. quantity & 0xFF,
  537. )
  538. )
  539. require(response[:6] == expected, "FC10 acknowledgement mismatch")
  540. def read_status(client):
  541. words = client.read_holding(STATUS_BASE, STATUS_WORDS)
  542. return Status(
  543. position=join_i32(words[0], words[1]),
  544. frequency_hz=join_u32(words[2], words[3]),
  545. state=words[4],
  546. segment=words[5],
  547. error=words[6],
  548. )
  549. def parse_diagnostic(words):
  550. require(len(words) == DIAGNOSTIC_WORDS,
  551. "diagnostic register count must be %d" % DIAGNOSTIC_WORDS)
  552. mode_direction = words[3]
  553. return Diagnostic(
  554. flags=words[0],
  555. reason=words[1],
  556. segment=words[2],
  557. output_mode=mode_direction & 0xFF,
  558. direction_positive=bool(mode_direction & 0x0100),
  559. expected_pulses=join_u32(words[4], words[5]),
  560. actual_pulses=join_u32(words[6], words[7]),
  561. count_error=join_i32(words[8], words[9]),
  562. requested_hz=join_u32(words[10], words[11]),
  563. expected_timer_hz=join_u32(words[12], words[13]),
  564. active_timer_hz=join_u32(words[14], words[15]),
  565. request_error_hz=join_i32(words[16], words[17]),
  566. sample_count=join_u32(words[18], words[19]),
  567. mismatch_count=join_u32(words[20], words[21]),
  568. maximum_frequency_error_hz=join_u32(words[22], words[23]),
  569. first_mismatch_sample=join_u32(words[24], words[25]),
  570. )
  571. def read_diagnostic(client):
  572. return parse_diagnostic(
  573. client.read_holding(DIAGNOSTIC_BASE, DIAGNOSTIC_WORDS)
  574. )
  575. def clear_diagnostic(client):
  576. client.write_single(DIAGNOSTIC_CONTROL, DIAGNOSTIC_CLEAR)
  577. diagnostic = read_diagnostic(client)
  578. require(diagnostic.flags == 0, "diagnostic CLEAR did not clear flags")
  579. require(diagnostic.reason == 0, "diagnostic CLEAR did not clear reason")
  580. require(diagnostic.first_mismatch_sample == 0xFFFFFFFF,
  581. "diagnostic CLEAR first mismatch sentinel is invalid")
  582. def require_diagnostic_pass(diagnostic, expected_pulses, expected_mode,
  583. direction_positive, label, expected_segment=1):
  584. require((diagnostic.flags & DIAG_COMPLETE_PASS) == DIAG_COMPLETE_PASS,
  585. "%s diagnostic checks incomplete or failed: flags=0x%04X"
  586. % (label, diagnostic.flags))
  587. require(not (diagnostic.flags & DIAG_MONITORING),
  588. "%s diagnostic remained active" % label)
  589. require(not (diagnostic.flags & DIAG_FAULT_LATCHED),
  590. "%s diagnostic latched fault %d" % (label, diagnostic.reason))
  591. require(diagnostic.reason == 0, "%s diagnostic reason is %d"
  592. % (label, diagnostic.reason))
  593. require(diagnostic.segment == expected_segment,
  594. "%s diagnostic segment expected %d, got %d"
  595. % (label, expected_segment, diagnostic.segment))
  596. require(diagnostic.output_mode == expected_mode,
  597. "%s diagnostic output mode expected %d, got %d"
  598. % (label, expected_mode, diagnostic.output_mode))
  599. require(diagnostic.direction_positive == direction_positive,
  600. "%s diagnostic direction mismatch" % label)
  601. require(diagnostic.expected_pulses == expected_pulses,
  602. "%s diagnostic expected pulse count %d, got %d"
  603. % (label, expected_pulses, diagnostic.expected_pulses))
  604. require(diagnostic.actual_pulses == expected_pulses,
  605. "%s diagnostic actual pulse count %d, got %d"
  606. % (label, expected_pulses, diagnostic.actual_pulses))
  607. require(diagnostic.count_error == 0,
  608. "%s diagnostic count error %d" % (label, diagnostic.count_error))
  609. require(diagnostic.requested_hz > 0,
  610. "%s diagnostic requested frequency is zero" % label)
  611. require(diagnostic.expected_timer_hz > 0,
  612. "%s diagnostic expected timer frequency is zero" % label)
  613. require(diagnostic.active_timer_hz == diagnostic.expected_timer_hz,
  614. "%s diagnostic active/expected frequency %d/%d"
  615. % (label, diagnostic.active_timer_hz,
  616. diagnostic.expected_timer_hz))
  617. require(diagnostic.sample_count > 0,
  618. "%s diagnostic did not capture curve samples" % label)
  619. require(diagnostic.mismatch_count == 0,
  620. "%s diagnostic curve mismatch count %d"
  621. % (label, diagnostic.mismatch_count))
  622. require(diagnostic.maximum_frequency_error_hz == 0,
  623. "%s diagnostic maximum active frequency error %d Hz"
  624. % (label, diagnostic.maximum_frequency_error_hz))
  625. require(diagnostic.first_mismatch_sample == 0xFFFFFFFF,
  626. "%s diagnostic first mismatch sample is %d"
  627. % (label, diagnostic.first_mismatch_sample))
  628. def wait_for_status(client, predicate, timeout, description):
  629. deadline = time.monotonic() + timeout
  630. last_status = None
  631. while time.monotonic() < deadline:
  632. last_status = read_status(client)
  633. if last_status.state == STATUS_ERROR:
  634. raise TestFailure(
  635. "%s entered ERROR (code=%d)" % (description, last_status.error)
  636. )
  637. if predicate(last_status):
  638. return last_status
  639. time.sleep(0.005)
  640. raise TestFailure("timeout waiting for %s; last=%r" % (description, last_status))
  641. def wait_terminal(client, timeout):
  642. status = wait_for_status(
  643. client,
  644. lambda item: item.state in TERMINAL_STATES,
  645. timeout,
  646. "terminal state",
  647. )
  648. require(status.state != STATUS_ERROR, "motion ended in ERROR %d" % status.error)
  649. return status
  650. def expect_exception(operation, expected_code, label):
  651. try:
  652. operation()
  653. except ModbusException as error:
  654. require(
  655. error.code == expected_code,
  656. "%s expected exception 0x%02X, got 0x%02X"
  657. % (label, expected_code, error.code),
  658. )
  659. return
  660. raise TestFailure("%s did not return a Modbus exception" % label)
  661. def expect_test_failure(operation, label):
  662. try:
  663. operation()
  664. except TestFailure:
  665. return
  666. raise TestFailure("%s unexpectedly passed" % label)
  667. def make_common(
  668. position_mode=0,
  669. segment_count=1,
  670. start_segment=1,
  671. send_mode=SEND_COMPLETE,
  672. curve_mode=0,
  673. default_hz=1000,
  674. start_hz=500,
  675. stop_hz=100,
  676. acceleration_ms=0,
  677. deceleration_ms=0,
  678. ):
  679. words = [0] * COMMON_WORDS
  680. words[0x00] = 0
  681. words[0x01] = 0
  682. words[0x02] = 0
  683. words[0x03] = 0
  684. words[0x04] = send_mode
  685. words[0x05] = 0
  686. words[0x06] = 0
  687. words[0x07] = curve_mode
  688. words[0x08] = position_mode
  689. words[0x09] = segment_count
  690. words[0x0A] = start_segment
  691. words[0x0B:0x0D] = split_u32(default_hz)
  692. words[0x0D:0x0F] = split_u32(start_hz)
  693. words[0x0F] = 0
  694. words[0x10:0x12] = split_u32(stop_hz)
  695. words[0x12] = acceleration_ms
  696. words[0x13] = deceleration_ms
  697. return words
  698. def make_segment(
  699. frequency_hz,
  700. pulses,
  701. wait_type=EXT_OR_COMPLETE,
  702. wait_time_ms=0,
  703. act_time_ms=0,
  704. jump_segment=0,
  705. ):
  706. return (
  707. split_u32(frequency_hz)
  708. + split_i32(pulses)
  709. + [wait_type, wait_time_ms, act_time_ms, jump_segment]
  710. )
  711. def configure(client, common, segment_1, segment_2=None):
  712. client.write_multiple(CONFIG_BASE, common)
  713. client.write_multiple(SEGMENT_1_BASE, segment_1)
  714. if segment_2 is not None:
  715. client.write_multiple(SEGMENT_2_BASE, segment_2)
  716. def configure_segments(client, common, segments):
  717. require(1 <= len(segments) <= 10,
  718. "segment configuration count must be 1..10")
  719. client.write_multiple(CONFIG_BASE, common)
  720. for index, segment in enumerate(segments):
  721. client.write_multiple(SEGMENT_1_BASE + index * 0x10, segment)
  722. def configure_pulse_dir(client, common, segment_1, segment_2=None):
  723. client.write_single(OUTPUT_MODE, OUTPUT_PULSE_DIR)
  724. require(client.read_holding(OUTPUT_MODE, 1) == [OUTPUT_PULSE_DIR],
  725. "PULSE/DIR output mode readback failed")
  726. configure(client, common, segment_1, segment_2)
  727. clear_diagnostic(client)
  728. def restore_default_configuration(client):
  729. client.write_single(OUTPUT_MODE, OUTPUT_PULSE_DIR)
  730. common = make_common(
  731. default_hz=1000,
  732. start_hz=100,
  733. stop_hz=100,
  734. acceleration_ms=100,
  735. deceleration_ms=100,
  736. )
  737. common[0x05] = 10
  738. client.write_multiple(CONFIG_BASE, common)
  739. for index in range(10):
  740. base = SEGMENT_1_BASE + index * 0x10
  741. pulses = 1000 if index == 0 else 0
  742. client.write_multiple(base, make_segment(1000, pulses))
  743. def command(client, value):
  744. client.write_single(CONTROL, value)
  745. def clear_position(client):
  746. command(client, COMMAND_CLEAR)
  747. status = read_status(client)
  748. require(status.state == STATUS_IDLE, "CLEAR did not enter IDLE")
  749. require(status.position == 0, "CLEAR did not zero logical position")
  750. def run_case(name, function):
  751. started = time.monotonic()
  752. print("RUN %s" % name)
  753. function()
  754. print("PASS %s (%.3fs)" % (name, time.monotonic() - started))
  755. def test_fixed_map_and_exceptions(client):
  756. common = client.read_holding(CONFIG_BASE, COMMON_WORDS)
  757. segment = client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS)
  758. status = read_status(client)
  759. control = client.read_holding(CONTROL, 1)
  760. output_mode = client.read_holding(OUTPUT_MODE, 1)
  761. diagnostic = read_diagnostic(client)
  762. diagnostic_control = client.read_holding(DIAGNOSTIC_CONTROL, 1)
  763. require(len(common) == COMMON_WORDS, "common map read failed")
  764. require(len(segment) == SEGMENT_WORDS, "segment map read failed")
  765. require(0 <= status.state <= STATUS_ERROR, "status enum is out of range")
  766. require(control == [0], "control register must read as zero")
  767. require(output_mode[0] in (OUTPUT_PULSE_DIR, OUTPUT_AB),
  768. "output mode is out of range")
  769. require(diagnostic.output_mode in (OUTPUT_PULSE_DIR, OUTPUT_AB),
  770. "diagnostic output mode is out of range")
  771. require(diagnostic_control == [0],
  772. "diagnostic control register must read as zero")
  773. require(client.read_holding(0x100F, 1) == [0], "reserved word is not zero")
  774. require(client.read_holding(0x1108, 1) == [0], "segment padding is not zero")
  775. expect_exception(
  776. lambda: client.read_holding(0x0000, 1),
  777. EX_ILLEGAL_ADDRESS,
  778. "FC03 address 0",
  779. )
  780. expect_exception(
  781. lambda: client._exchange(bytes((0x01, 0x00, 0x00, 0x00, 0x01))),
  782. EX_ILLEGAL_FUNCTION,
  783. "FC01 unsupported function",
  784. )
  785. expect_exception(
  786. lambda: client.read_holding(0x1197, 2),
  787. EX_ILLEGAL_ADDRESS,
  788. "range crossing 0x1197",
  789. )
  790. expect_exception(
  791. lambda: client.write_single(0x100F, 1),
  792. EX_ILLEGAL_VALUE,
  793. "nonzero reserved write",
  794. )
  795. expect_exception(
  796. lambda: client.write_single(0x100B, 1000),
  797. EX_ILLEGAL_ADDRESS,
  798. "single half of a DWORD",
  799. )
  800. expect_exception(
  801. lambda: client.write_single(STATUS_BASE, 0),
  802. EX_ILLEGAL_ADDRESS,
  803. "status write",
  804. )
  805. expect_exception(
  806. lambda: client.write_single(CONTROL, 3),
  807. EX_ILLEGAL_VALUE,
  808. "combined control command",
  809. )
  810. expect_exception(
  811. lambda: client.read_holding(0x11FF, 2),
  812. EX_ILLEGAL_ADDRESS,
  813. "range crossing into output mode",
  814. )
  815. expect_exception(
  816. lambda: client.read_holding(0x2119, 2),
  817. EX_ILLEGAL_ADDRESS,
  818. "range crossing diagnostic end",
  819. )
  820. expect_exception(
  821. lambda: client.write_single(DIAGNOSTIC_BASE, 0),
  822. EX_ILLEGAL_ADDRESS,
  823. "diagnostic write",
  824. )
  825. expect_exception(
  826. lambda: client.write_single(DIAGNOSTIC_CONTROL, 2),
  827. EX_ILLEGAL_VALUE,
  828. "invalid diagnostic command",
  829. )
  830. expect_exception(
  831. lambda: client.write_single(OUTPUT_MODE, 2),
  832. EX_ILLEGAL_VALUE,
  833. "invalid output mode",
  834. )
  835. def test_positive_negative_and_absolute_zero(client):
  836. clear_position(client)
  837. common = make_common(position_mode=0, start_hz=500)
  838. configure_pulse_dir(client, common, make_segment(500, 25))
  839. command(client, COMMAND_START)
  840. status = wait_terminal(client, 3.0)
  841. require(status.state == STATUS_COMPLETED, "positive move did not complete")
  842. require(status.position == 25, "positive accumulation expected 25")
  843. require_diagnostic_pass(
  844. read_diagnostic(client),
  845. 25,
  846. OUTPUT_PULSE_DIR,
  847. True,
  848. "positive move",
  849. )
  850. configure_pulse_dir(client, common, make_segment(500, -10))
  851. command(client, COMMAND_START)
  852. status = wait_terminal(client, 3.0)
  853. require(status.state == STATUS_COMPLETED, "negative move did not complete")
  854. require(status.position == 15, "negative accumulation expected 15")
  855. require_diagnostic_pass(
  856. read_diagnostic(client),
  857. 10,
  858. OUTPUT_PULSE_DIR,
  859. False,
  860. "negative move",
  861. )
  862. absolute = make_common(position_mode=1, start_hz=500)
  863. configure_pulse_dir(client, absolute, make_segment(500, 15))
  864. before = read_status(client)
  865. command(client, COMMAND_START)
  866. status = wait_terminal(client, 1.0)
  867. require(status.state == STATUS_COMPLETED, "absolute zero move did not complete")
  868. require(status.position == before.position, "absolute zero changed position")
  869. def test_ten_segments_in_sequence(client):
  870. clear_position(client)
  871. common = make_common(
  872. segment_count=10,
  873. default_hz=1000,
  874. start_hz=1000,
  875. stop_hz=1000,
  876. )
  877. pulse_counts = [100 + index for index in range(10)]
  878. segments = [make_segment(1000, pulses) for pulses in pulse_counts]
  879. client.write_single(OUTPUT_MODE, OUTPUT_PULSE_DIR)
  880. configure_segments(client, common, segments)
  881. clear_diagnostic(client)
  882. require(client.read_holding(CONFIG_BASE + 0x09, 2) == [10, 1],
  883. "ten-segment count/start readback mismatch")
  884. command(client, COMMAND_START)
  885. for segment_number in range(1, 11):
  886. wait_for_status(
  887. client,
  888. lambda item, expected=segment_number:
  889. item.segment == expected and item.state in ACTIVE_STATES,
  890. 2.0,
  891. "sequential segment %d" % segment_number,
  892. )
  893. status = wait_terminal(client, 3.0)
  894. expected_position = sum(pulse_counts)
  895. require(status.state == STATUS_COMPLETED,
  896. "ten-segment sequence did not complete")
  897. require(status.position == expected_position,
  898. "ten-segment sequence position expected %d, got %d"
  899. % (expected_position, status.position))
  900. require_diagnostic_pass(
  901. read_diagnostic(client),
  902. pulse_counts[-1],
  903. OUTPUT_PULSE_DIR,
  904. True,
  905. "ten-segment sequence final segment",
  906. expected_segment=10,
  907. )
  908. def test_start_segment_ten(client):
  909. clear_position(client)
  910. common = make_common(
  911. segment_count=10,
  912. start_segment=10,
  913. default_hz=1000,
  914. start_hz=1000,
  915. stop_hz=1000,
  916. )
  917. segment_ten_pulses = 200
  918. segments = [make_segment(1000, 11 + index) for index in range(9)]
  919. segments.append(make_segment(1000, segment_ten_pulses))
  920. client.write_single(OUTPUT_MODE, OUTPUT_PULSE_DIR)
  921. configure_segments(client, common, segments)
  922. clear_diagnostic(client)
  923. require(client.read_holding(CONFIG_BASE + 0x09, 2) == [10, 10],
  924. "startSegment=10 readback mismatch")
  925. command(client, COMMAND_START)
  926. wait_for_status(
  927. client,
  928. lambda item: item.segment == 10 and item.state in ACTIVE_STATES,
  929. 2.0,
  930. "startSegment=10 active segment",
  931. )
  932. status = wait_terminal(client, 3.0)
  933. require(status.state == STATUS_COMPLETED,
  934. "startSegment=10 move did not complete")
  935. require(status.position == segment_ten_pulses,
  936. "startSegment=10 executed an earlier segment: position=%d"
  937. % status.position)
  938. require_diagnostic_pass(
  939. read_diagnostic(client),
  940. segment_ten_pulses,
  941. OUTPUT_PULSE_DIR,
  942. True,
  943. "startSegment=10",
  944. expected_segment=10,
  945. )
  946. def test_direction_output_and_polarity_matrix(client):
  947. pulse_count = 25
  948. direction_delay_ms = 500
  949. for direction_output in range(4):
  950. for negative_logic in range(2):
  951. for direction in (1, -1):
  952. label = "Y%d DIR %s %s logic" % (
  953. direction_output + 12,
  954. "positive" if direction > 0 else "negative",
  955. "negative" if negative_logic else "positive",
  956. )
  957. clear_position(client)
  958. common = make_common(
  959. default_hz=500,
  960. start_hz=500,
  961. stop_hz=500,
  962. )
  963. common[0x01] = direction_output
  964. common[0x05] = direction_delay_ms
  965. common[0x06] = negative_logic
  966. target = direction * pulse_count
  967. segment = make_segment(500, target)
  968. configure_pulse_dir(client, common, segment)
  969. common_readback = client.read_holding(CONFIG_BASE, COMMON_WORDS)
  970. segment_readback = client.read_holding(
  971. SEGMENT_1_BASE, SEGMENT_WORDS)
  972. require(common_readback[0x01] == direction_output,
  973. "%s direction output readback mismatch" % label)
  974. require(common_readback[0x05] == direction_delay_ms,
  975. "%s direction delay readback mismatch" % label)
  976. require(common_readback[0x06] == negative_logic,
  977. "%s direction polarity readback mismatch" % label)
  978. require(join_i32(segment_readback[2], segment_readback[3])
  979. == target,
  980. "%s signed target readback mismatch" % label)
  981. command(client, COMMAND_START)
  982. before_pulses = wait_for_status(
  983. client,
  984. lambda item: item.state == STATUS_ACCELERATING
  985. and item.segment == 1,
  986. 0.25,
  987. "%s direction-delay state" % label,
  988. )
  989. require(before_pulses.segment == 1,
  990. "%s direction delay selected wrong segment" % label)
  991. require(before_pulses.position == 0,
  992. "%s counted a logical pulse during direction delay"
  993. % label)
  994. require(before_pulses.frequency_hz == 0,
  995. "%s reported a pulse frequency during direction delay"
  996. % label)
  997. status = wait_terminal(client, 3.0)
  998. require(status.state == STATUS_COMPLETED,
  999. "%s did not complete" % label)
  1000. require(status.position == target,
  1001. "%s position expected %d, got %d"
  1002. % (label, target, status.position))
  1003. require_diagnostic_pass(
  1004. read_diagnostic(client),
  1005. pulse_count,
  1006. OUTPUT_PULSE_DIR,
  1007. direction > 0,
  1008. label,
  1009. )
  1010. def test_all_outputs_at_100khz(client):
  1011. for output in range(4):
  1012. clear_position(client)
  1013. common = make_common(
  1014. default_hz=100000,
  1015. start_hz=100000,
  1016. stop_hz=100000,
  1017. )
  1018. common[0x00] = output
  1019. common[0x01] = output
  1020. configure_pulse_dir(client, common, make_segment(100000, 1000))
  1021. command(client, COMMAND_START)
  1022. status = wait_terminal(client, 2.0)
  1023. require(status.state == STATUS_COMPLETED,
  1024. "output %d did not complete" % output)
  1025. require(status.position == 1000,
  1026. "output %d count expected 1000, got %d"
  1027. % (output, status.position))
  1028. require_diagnostic_pass(
  1029. read_diagnostic(client),
  1030. 1000,
  1031. OUTPUT_PULSE_DIR,
  1032. True,
  1033. "output %d 100 kHz" % output,
  1034. )
  1035. def test_short_final_profiles(client):
  1036. for curve_mode in range(3):
  1037. clear_position(client)
  1038. common = make_common(
  1039. curve_mode=curve_mode,
  1040. default_hz=100000,
  1041. start_hz=100000,
  1042. stop_hz=100,
  1043. deceleration_ms=1000,
  1044. )
  1045. configure_pulse_dir(client, common, make_segment(100000, 10))
  1046. command(client, COMMAND_START)
  1047. status = wait_terminal(client, 2.0)
  1048. require(status.state == STATUS_COMPLETED,
  1049. "short curve %d did not complete" % curve_mode)
  1050. require(status.error == ERROR_NONE,
  1051. "short curve %d reported error %d"
  1052. % (curve_mode, status.error))
  1053. require(status.position == 10,
  1054. "short curve %d count expected 10, got %d"
  1055. % (curve_mode, status.position))
  1056. require_diagnostic_pass(
  1057. read_diagnostic(client),
  1058. 10,
  1059. OUTPUT_PULSE_DIR,
  1060. True,
  1061. "short curve %d" % curve_mode,
  1062. )
  1063. def test_long_millisecond_ramp(client):
  1064. pulses = 65535
  1065. label = "65,535-pulse 1 ms ramp"
  1066. clear_position(client)
  1067. common = make_common(
  1068. curve_mode=2,
  1069. default_hz=100000,
  1070. start_hz=1000,
  1071. stop_hz=1000,
  1072. acceleration_ms=100,
  1073. deceleration_ms=100,
  1074. )
  1075. configure_pulse_dir(client, common, make_segment(100000, pulses))
  1076. command(client, COMMAND_START)
  1077. status = wait_terminal(client, 5.0)
  1078. require(status.state == STATUS_COMPLETED,
  1079. "%s did not complete" % label)
  1080. require(status.position == pulses,
  1081. "%s expected %d pulses, got %d"
  1082. % (label, pulses, status.position))
  1083. diagnostic = read_diagnostic(client)
  1084. require(frequency_matches(diagnostic.requested_hz, 1000),
  1085. "%s final requested frequency expected 1000 Hz, got %d Hz"
  1086. % (label, diagnostic.requested_hz))
  1087. require_diagnostic_pass(
  1088. diagnostic,
  1089. pulses,
  1090. OUTPUT_PULSE_DIR,
  1091. True,
  1092. label,
  1093. )
  1094. def require_irq_cycle_budget(stats, expected_outputs, label):
  1095. for output in expected_outputs:
  1096. require(stats.count[output] > 0,
  1097. "%s output Y%d recorded no IRQ samples" % (label, output))
  1098. maximum = stats.maximum_cycles[output]
  1099. require(maximum > 0,
  1100. "%s output Y%d recorded a zero IRQ maximum"
  1101. % (label, output))
  1102. require(maximum < IRQ_CYCLE_BUDGET_100KHZ,
  1103. "%s output Y%d IRQ maximum %d cycles exceeds < %d budget"
  1104. % (label, output, maximum, IRQ_CYCLE_BUDGET_100KHZ))
  1105. print("INFO %s Y%d irq_count=%d last=%d max=%d cycles"
  1106. % (label, output, stats.count[output],
  1107. stats.last_cycles[output], maximum))
  1108. def require_producer_cycle_budget(stats, label):
  1109. average = stats.average_cycles
  1110. require(stats.total_cycles >= stats.maximum_item_cycles,
  1111. "%s producer timing counters are inconsistent" % label)
  1112. require(average < IRQ_CYCLE_BUDGET_100KHZ,
  1113. "%s producer average %.2f cycles/item exceeds < %d budget"
  1114. % (label, average, IRQ_CYCLE_BUDGET_100KHZ))
  1115. print("INFO %s producer_items=%d total=%d average=%.2f max=%d cycles"
  1116. % (label, stats.item_count, stats.total_cycles,
  1117. average, stats.maximum_item_cycles))
  1118. def require_no_producer_activity(stats, label):
  1119. require(stats.item_count == 0,
  1120. "%s unexpectedly generated %d short-profile items"
  1121. % (label, stats.item_count))
  1122. require(stats.total_cycles == 0,
  1123. "%s unexpectedly recorded %d producer cycles"
  1124. % (label, stats.total_cycles))
  1125. require(stats.maximum_item_cycles == 0,
  1126. "%s unexpectedly recorded a %d-cycle producer maximum"
  1127. % (label, stats.maximum_item_cycles))
  1128. print("INFO %s producer_items=0 total=0 max=0 cycles" % label)
  1129. def require_final_arm_cycle_budget(stats, expected_outputs, label):
  1130. for output in expected_outputs:
  1131. require(stats.queue_count[output] > 0,
  1132. "%s output Y%d queued no final-arm jobs" % (label, output))
  1133. job_maximum = stats.job_maximum_cycles[output]
  1134. latency_maximum = stats.queue_to_stop_maximum_cycles[output]
  1135. require(job_maximum > 0,
  1136. "%s output Y%d recorded a zero final-arm job maximum"
  1137. % (label, output))
  1138. require(latency_maximum > 0,
  1139. "%s output Y%d recorded a zero queue-to-stop maximum"
  1140. % (label, output))
  1141. require(job_maximum < IRQ_CYCLE_BUDGET_100KHZ,
  1142. "%s output Y%d final-arm job maximum %d cycles exceeds < %d budget"
  1143. % (label, output, job_maximum,
  1144. IRQ_CYCLE_BUDGET_100KHZ))
  1145. require(latency_maximum < IRQ_CYCLE_BUDGET_100KHZ,
  1146. "%s output Y%d queue-to-stop maximum %d cycles exceeds < %d budget"
  1147. % (label, output, latency_maximum,
  1148. IRQ_CYCLE_BUDGET_100KHZ))
  1149. print(
  1150. "INFO %s Y%d final_arm_count=%d job_last=%d job_max=%d "
  1151. "queue_to_stop_last=%d queue_to_stop_max=%d cycles"
  1152. % (label, output, stats.queue_count[output],
  1153. stats.job_last_cycles[output], job_maximum,
  1154. stats.queue_to_stop_last_cycles[output], latency_maximum))
  1155. def test_ab_100khz_timing_paths(client):
  1156. pulses_per_case = 100000
  1157. for output, direction in ((0, 1), (2, -1)):
  1158. target = direction * pulses_per_case
  1159. label = "AB Y%d/Y%d %s timing" % (
  1160. output,
  1161. output + 1,
  1162. "positive" if direction > 0 else "negative",
  1163. )
  1164. clear_position(client)
  1165. configure_ab(
  1166. client,
  1167. output,
  1168. make_common(
  1169. default_hz=100000,
  1170. start_hz=100000,
  1171. stop_hz=100000,
  1172. ),
  1173. make_segment(100000, target),
  1174. )
  1175. clear_diagnostic(client)
  1176. command(client, COMMAND_START)
  1177. wait_for_status(
  1178. client,
  1179. lambda item: item.state in ACTIVE_STATES
  1180. and abs(item.position) >= 100
  1181. and frequency_matches(item.frequency_hz, 100000),
  1182. 2.0,
  1183. "%s initial 100 kHz" % label,
  1184. )
  1185. # Both writes are committed at an AB zero boundary. The first measures
  1186. # PlsrAbLoadAndStart while the incoming cycle is still 100 kHz; the
  1187. # second returns the final gate path to the worst-case 10 us period.
  1188. client.write_multiple(SEGMENT_1_BASE, split_u32(80000))
  1189. wait_for_status(
  1190. client,
  1191. lambda item: item.state in ACTIVE_STATES
  1192. and frequency_matches(item.frequency_hz, 80000),
  1193. 2.0,
  1194. "%s dynamic 80 kHz" % label,
  1195. )
  1196. client.write_multiple(SEGMENT_1_BASE, split_u32(100000))
  1197. wait_for_status(
  1198. client,
  1199. lambda item: item.state in ACTIVE_STATES
  1200. and frequency_matches(item.frequency_hz, 100000),
  1201. 2.0,
  1202. "%s restored 100 kHz" % label,
  1203. )
  1204. status = wait_terminal(client, 3.0)
  1205. require(status.state == STATUS_COMPLETED,
  1206. "%s did not complete" % label)
  1207. require(status.position == target,
  1208. "%s position expected %d, got %d"
  1209. % (label, target, status.position))
  1210. require_diagnostic_pass(
  1211. read_diagnostic(client),
  1212. pulses_per_case,
  1213. OUTPUT_AB,
  1214. direction > 0,
  1215. label,
  1216. )
  1217. def run_timing(client, timing, keep_config):
  1218. snapshot = (
  1219. client.read_holding(OUTPUT_MODE, 1)[0],
  1220. client.read_holding(CONFIG_BASE, COMMON_WORDS),
  1221. client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS),
  1222. client.read_holding(SEGMENT_2_BASE, SEGMENT_WORDS),
  1223. )
  1224. try:
  1225. timing.reset()
  1226. run_case("timing_constant_100khz_four_outputs",
  1227. lambda: test_all_outputs_at_100khz(client))
  1228. irq, _, _ = timing.read()
  1229. require_irq_cycle_budget(irq, range(4), "constant 100 kHz")
  1230. timing.reset()
  1231. run_case("timing_short_final_profiles",
  1232. lambda: test_short_final_profiles(client))
  1233. irq, producer, _ = timing.read()
  1234. require_irq_cycle_budget(irq, (0,), "10-pulse profiles")
  1235. require_producer_cycle_budget(producer, "10-pulse profiles")
  1236. timing.reset()
  1237. run_case("timing_long_millisecond_ramp",
  1238. lambda: test_long_millisecond_ramp(client))
  1239. irq, producer, _ = timing.read()
  1240. require_irq_cycle_budget(irq, (0,), "65,535-pulse 1 ms ramp")
  1241. require_no_producer_activity(producer, "65,535-pulse 1 ms ramp")
  1242. timing.reset()
  1243. run_case("timing_ab_100khz_reload_and_fast_gate",
  1244. lambda: test_ab_100khz_timing_paths(client))
  1245. irq, _, final_arm = timing.read()
  1246. require_irq_cycle_budget(
  1247. irq, (0, 2), "AB 100 kHz reload and fast gate")
  1248. require_final_arm_cycle_budget(
  1249. final_arm, (0, 2), "AB 100 kHz final arm")
  1250. finally:
  1251. if keep_config:
  1252. cleanup_preserving_primary_error(
  1253. lambda: safe_stop(client),
  1254. "timing motion stop",
  1255. )
  1256. else:
  1257. cleanup_preserving_primary_error(
  1258. lambda: restore_configuration(client, snapshot),
  1259. "timing configuration restore",
  1260. )
  1261. def test_wait_time(client):
  1262. clear_position(client)
  1263. configure_pulse_dir(
  1264. client,
  1265. make_common(start_hz=500),
  1266. make_segment(500, 10, wait_type=WAIT_TIME, wait_time_ms=120),
  1267. )
  1268. command(client, COMMAND_START)
  1269. waiting = wait_for_status(
  1270. client,
  1271. lambda item: item.state == STATUS_WAITING,
  1272. 2.0,
  1273. "WAIT_TIME state",
  1274. )
  1275. waiting_at = time.monotonic()
  1276. require(waiting.position == 10, "WAIT_TIME position expected 10")
  1277. status = wait_terminal(client, 2.0)
  1278. observed_wait = time.monotonic() - waiting_at
  1279. require(status.state == STATUS_COMPLETED, "WAIT_TIME did not complete")
  1280. require(observed_wait >= 0.050, "WAIT_TIME completed implausibly early")
  1281. require_diagnostic_pass(
  1282. read_diagnostic(client),
  1283. 10,
  1284. OUTPUT_PULSE_DIR,
  1285. True,
  1286. "WAIT_TIME",
  1287. )
  1288. def test_act_time(client):
  1289. clear_position(client)
  1290. configure_pulse_dir(
  1291. client,
  1292. make_common(start_hz=1000),
  1293. make_segment(1000, 2000, wait_type=ACT_TIME, act_time_ms=80),
  1294. )
  1295. command(client, COMMAND_START)
  1296. status = wait_terminal(client, 3.0)
  1297. require(status.state == STATUS_COMPLETED, "ACT_TIME did not complete")
  1298. require(0 < status.position < 2000, "ACT_TIME did not cut the segment")
  1299. def test_dynamic_frequency_and_repeated_stop(client):
  1300. clear_position(client)
  1301. configure_pulse_dir(
  1302. client,
  1303. make_common(start_hz=500, deceleration_ms=150),
  1304. make_segment(500, 3000),
  1305. )
  1306. command(client, COMMAND_START)
  1307. wait_for_status(
  1308. client,
  1309. lambda item: item.segment == 1 and item.frequency_hz == 500,
  1310. 2.0,
  1311. "initial frequency",
  1312. )
  1313. client.write_multiple(SEGMENT_1_BASE, split_u32(1200))
  1314. dynamic = wait_for_status(
  1315. client,
  1316. lambda item: item.segment == 1
  1317. and frequency_matches(item.frequency_hz, 1200),
  1318. 2.0,
  1319. "dynamic frequency",
  1320. )
  1321. print("INFO dynamic frequency requested=1200 actual=%d" %
  1322. dynamic.frequency_hz)
  1323. expect_exception(
  1324. lambda: client.write_single(CONFIG_BASE, 1),
  1325. EX_DEVICE_BUSY,
  1326. "pulse output write while busy",
  1327. )
  1328. command(client, COMMAND_STOP)
  1329. command(client, COMMAND_STOP)
  1330. status = wait_terminal(client, 3.0)
  1331. require(status.state == STATUS_STOPPED, "repeated STOP did not stop")
  1332. require(status.position < 3000, "STOP did not cut the active move")
  1333. def test_future_segment_frequency_applies_on_arrival(client):
  1334. clear_position(client)
  1335. common = make_common(segment_count=2, start_hz=400)
  1336. segment_1 = make_segment(400, 200)
  1337. segment_2 = make_segment(700, 200)
  1338. configure_pulse_dir(client, common, segment_1, segment_2)
  1339. command(client, COMMAND_START)
  1340. wait_for_status(
  1341. client,
  1342. lambda item: item.segment == 1 and item.frequency_hz == 400,
  1343. 2.0,
  1344. "segment 1",
  1345. )
  1346. client.write_multiple(SEGMENT_2_BASE, split_u32(900))
  1347. status = read_status(client)
  1348. require(status.segment == 1, "future write changed the current segment")
  1349. require(status.frequency_hz == 400, "future write changed current frequency")
  1350. wait_for_status(
  1351. client,
  1352. lambda item: item.segment == 2
  1353. and frequency_matches(item.frequency_hz, 900),
  1354. 3.0,
  1355. "updated segment 2 frequency",
  1356. )
  1357. status = wait_terminal(client, 3.0)
  1358. require(status.state == STATUS_COMPLETED, "two-segment move did not complete")
  1359. require_diagnostic_pass(
  1360. read_diagnostic(client),
  1361. 200,
  1362. OUTPUT_PULSE_DIR,
  1363. True,
  1364. "updated segment 2 frequency",
  1365. expected_segment=2,
  1366. )
  1367. def test_subsequent_future_frequency_rebuilds_handoff(client):
  1368. clear_position(client)
  1369. common = make_common(
  1370. segment_count=2,
  1371. send_mode=SEND_SUBSEQUENT,
  1372. start_hz=400,
  1373. )
  1374. segment_1 = make_segment(400, 200)
  1375. segment_2 = make_segment(700, 200)
  1376. configure_pulse_dir(client, common, segment_1, segment_2)
  1377. command(client, COMMAND_START)
  1378. wait_for_status(
  1379. client,
  1380. lambda item: item.segment == 1
  1381. and frequency_matches(item.frequency_hz, 400),
  1382. 2.0,
  1383. "subsequent segment 1",
  1384. )
  1385. client.write_multiple(SEGMENT_2_BASE, split_u32(900))
  1386. status = read_status(client)
  1387. require(status.segment == 1, "future write changed the current segment")
  1388. require(
  1389. frequency_matches(status.frequency_hz, 400),
  1390. "future write changed current frequency",
  1391. )
  1392. wait_for_status(
  1393. client,
  1394. lambda item: item.segment == 2
  1395. and frequency_matches(item.frequency_hz, 900),
  1396. 3.0,
  1397. "rebuilt subsequent handoff frequency",
  1398. )
  1399. status = wait_terminal(client, 3.0)
  1400. require(status.state == STATUS_COMPLETED,
  1401. "subsequent two-segment move did not complete")
  1402. require(status.position == 400,
  1403. "subsequent two-segment position expected 400")
  1404. require_diagnostic_pass(
  1405. read_diagnostic(client),
  1406. 200,
  1407. OUTPUT_PULSE_DIR,
  1408. True,
  1409. "subsequent segment 2 frequency",
  1410. expected_segment=2,
  1411. )
  1412. def require_fixture_level(fixture, input_selection, expected, label):
  1413. actual = fixture.read(input_selection)
  1414. require(
  1415. actual == expected,
  1416. "%s expected X%d=%d, got %d"
  1417. % (label, input_selection + 4, expected, actual),
  1418. )
  1419. def test_x45_electrical_scan(fixture):
  1420. fixture.all_off()
  1421. require_fixture_level(fixture, 0, 0, "both outputs off")
  1422. require_fixture_level(fixture, 1, 0, "both outputs off")
  1423. for input_selection in range(2):
  1424. other = 1 - input_selection
  1425. fixture.drive(input_selection, True)
  1426. require_fixture_level(fixture, input_selection, 1, "output on")
  1427. require_fixture_level(fixture, other, 0, "cross-channel isolation")
  1428. fixture.drive(input_selection, False)
  1429. require_fixture_level(fixture, input_selection, 0, "output off")
  1430. def test_wait_signal_input(client, fixture, input_selection):
  1431. fixture.drive(input_selection, False)
  1432. clear_position(client)
  1433. common = make_common(
  1434. default_hz=1000,
  1435. start_hz=1000,
  1436. stop_hz=1000,
  1437. )
  1438. common[0x02] = input_selection
  1439. configure_pulse_dir(
  1440. client,
  1441. common,
  1442. make_segment(1000, 50, wait_type=WAIT_SIGNAL),
  1443. )
  1444. command(client, COMMAND_START)
  1445. waiting = wait_for_status(
  1446. client,
  1447. lambda item: item.state == STATUS_WAITING,
  1448. 2.0,
  1449. "X%d WAIT_SIGNAL" % (input_selection + 4),
  1450. )
  1451. require(waiting.position == 50, "WAIT_SIGNAL pulse count expected 50")
  1452. time.sleep(0.050)
  1453. require(read_status(client).state == STATUS_WAITING,
  1454. "WAIT_SIGNAL advanced while input was off")
  1455. fixture.drive(input_selection, True)
  1456. status = wait_terminal(client, 2.0)
  1457. require(status.state == STATUS_COMPLETED, "WAIT_SIGNAL did not complete")
  1458. require(status.position == 50, "WAIT_SIGNAL changed completed position")
  1459. require_diagnostic_pass(
  1460. read_diagnostic(client),
  1461. 50,
  1462. OUTPUT_PULSE_DIR,
  1463. True,
  1464. "X%d WAIT_SIGNAL" % (input_selection + 4),
  1465. )
  1466. fixture.drive(input_selection, False)
  1467. def test_ext_signal_input(client, fixture, input_selection, wait_type):
  1468. fixture.drive(input_selection, False)
  1469. clear_position(client)
  1470. common = make_common(
  1471. default_hz=1000,
  1472. start_hz=1000,
  1473. stop_hz=1000,
  1474. )
  1475. common[0x03] = input_selection
  1476. configure_pulse_dir(
  1477. client,
  1478. common,
  1479. make_segment(1000, 5000, wait_type=wait_type),
  1480. )
  1481. command(client, COMMAND_START)
  1482. wait_for_status(
  1483. client,
  1484. lambda item: item.state in ACTIVE_STATES and item.position >= 10,
  1485. 2.0,
  1486. "X%d external-signal active move" % (input_selection + 4),
  1487. )
  1488. fixture.drive(input_selection, True)
  1489. status = wait_terminal(client, 2.0)
  1490. require(status.state == STATUS_COMPLETED,
  1491. "external-signal move did not complete")
  1492. require(0 < status.position < 5000,
  1493. "external signal did not cut the active segment")
  1494. fixture.drive(input_selection, False)
  1495. def test_ext_or_complete_natural(client, fixture, input_selection):
  1496. fixture.drive(input_selection, False)
  1497. clear_position(client)
  1498. common = make_common(
  1499. default_hz=1000,
  1500. start_hz=1000,
  1501. stop_hz=1000,
  1502. )
  1503. common[0x03] = input_selection
  1504. configure_pulse_dir(
  1505. client,
  1506. common,
  1507. make_segment(1000, 50, wait_type=EXT_OR_COMPLETE),
  1508. )
  1509. command(client, COMMAND_START)
  1510. status = wait_terminal(client, 2.0)
  1511. require(status.state == STATUS_COMPLETED,
  1512. "EXT_OR_COMPLETE natural branch did not complete")
  1513. require(status.position == 50,
  1514. "EXT_OR_COMPLETE natural branch expected 50 pulses")
  1515. require_diagnostic_pass(
  1516. read_diagnostic(client),
  1517. 50,
  1518. OUTPUT_PULSE_DIR,
  1519. True,
  1520. "X%d EXT_OR_COMPLETE natural" % (input_selection + 4),
  1521. )
  1522. def run_x45(client, fixture, keep_config):
  1523. snapshot = (
  1524. client.read_holding(OUTPUT_MODE, 1)[0],
  1525. client.read_holding(CONFIG_BASE, COMMON_WORDS),
  1526. client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS),
  1527. client.read_holding(SEGMENT_2_BASE, SEGMENT_WORDS),
  1528. )
  1529. try:
  1530. client.write_single(OUTPUT_MODE, OUTPUT_PULSE_DIR)
  1531. fixture.prepare()
  1532. run_case("x45_electrical_scan",
  1533. lambda: test_x45_electrical_scan(fixture))
  1534. for input_selection in range(2):
  1535. name = "X%d" % (input_selection + 4)
  1536. run_case(
  1537. "%s_wait_signal" % name,
  1538. lambda selection=input_selection:
  1539. test_wait_signal_input(client, fixture, selection),
  1540. )
  1541. run_case(
  1542. "%s_ext_signal" % name,
  1543. lambda selection=input_selection:
  1544. test_ext_signal_input(
  1545. client, fixture, selection, EXT_SIGNAL),
  1546. )
  1547. run_case(
  1548. "%s_ext_or_complete_natural" % name,
  1549. lambda selection=input_selection:
  1550. test_ext_or_complete_natural(client, fixture, selection),
  1551. )
  1552. run_case(
  1553. "%s_ext_or_complete_trigger" % name,
  1554. lambda selection=input_selection:
  1555. test_ext_signal_input(
  1556. client, fixture, selection, EXT_OR_COMPLETE),
  1557. )
  1558. finally:
  1559. try:
  1560. cleanup_preserving_primary_error(
  1561. fixture.all_off,
  1562. "X4/X5 output shutdown",
  1563. )
  1564. finally:
  1565. try:
  1566. if keep_config:
  1567. cleanup_preserving_primary_error(
  1568. lambda: safe_stop(client),
  1569. "X4/X5 motion stop",
  1570. )
  1571. else:
  1572. cleanup_preserving_primary_error(
  1573. lambda: restore_configuration(client, snapshot),
  1574. "X4/X5 configuration restore",
  1575. )
  1576. finally:
  1577. cleanup_preserving_primary_error(
  1578. fixture.release,
  1579. "X4/X5 fixture release",
  1580. )
  1581. def safe_stop(client):
  1582. status = read_status(client)
  1583. if status.state in ACTIVE_STATES:
  1584. command(client, COMMAND_STOP)
  1585. wait_terminal(client, 3.0)
  1586. def cleanup_preserving_primary_error(cleanup, label, warning_stream=None):
  1587. primary_error = sys.exc_info()[1]
  1588. try:
  1589. cleanup()
  1590. except Exception as cleanup_error:
  1591. if primary_error is None:
  1592. raise
  1593. message = "%s failed during error cleanup: %s: %s" % (
  1594. label,
  1595. type(cleanup_error).__name__,
  1596. cleanup_error,
  1597. )
  1598. if hasattr(primary_error, "add_note"):
  1599. primary_error.add_note(message)
  1600. if warning_stream is None:
  1601. warning_stream = sys.stderr
  1602. print("WARN: %s" % message, file=warning_stream)
  1603. def restore_configuration(client, snapshot):
  1604. safe_stop(client)
  1605. command(client, COMMAND_CLEAR)
  1606. client.write_single(OUTPUT_MODE, OUTPUT_PULSE_DIR)
  1607. client.write_multiple(CONFIG_BASE, snapshot[1])
  1608. segments = snapshot[2] if len(snapshot) == 3 else snapshot[2:]
  1609. for index, segment in enumerate(segments):
  1610. client.write_multiple(SEGMENT_1_BASE + index * 0x10, segment)
  1611. client.write_single(OUTPUT_MODE, snapshot[0])
  1612. def snapshot_ab_configuration(client):
  1613. return (
  1614. client.read_holding(OUTPUT_MODE, 1)[0],
  1615. client.read_holding(CONFIG_BASE, COMMON_WORDS),
  1616. client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS),
  1617. )
  1618. def restore_ab_configuration(client, snapshot):
  1619. safe_stop(client)
  1620. command(client, COMMAND_CLEAR)
  1621. # PULSE/DIR accepts every pulse output, so use it while restoring a
  1622. # possible Y1/Y3 configuration that would be invalid in AB mode.
  1623. client.write_single(OUTPUT_MODE, OUTPUT_PULSE_DIR)
  1624. client.write_multiple(CONFIG_BASE, snapshot[1])
  1625. client.write_multiple(SEGMENT_1_BASE, snapshot[2])
  1626. client.write_single(OUTPUT_MODE, snapshot[0])
  1627. clear_diagnostic(client)
  1628. def configure_ab(client, output, common, segment):
  1629. require(output in (0, 2), "AB output must select Y0/Y1 or Y2/Y3")
  1630. common = list(common)
  1631. common[0x00] = output
  1632. client.write_multiple(CONFIG_BASE, common)
  1633. client.write_multiple(SEGMENT_1_BASE, segment)
  1634. client.write_single(OUTPUT_MODE, OUTPUT_AB)
  1635. require(client.read_holding(OUTPUT_MODE, 1) == [OUTPUT_AB],
  1636. "AB output mode readback failed")
  1637. def run_isolated_ab_case(client, name, function):
  1638. def isolated():
  1639. snapshot = snapshot_ab_configuration(client)
  1640. try:
  1641. function()
  1642. finally:
  1643. cleanup_preserving_primary_error(
  1644. lambda: restore_ab_configuration(client, snapshot),
  1645. "%s configuration restore" % name,
  1646. )
  1647. run_case(name, isolated)
  1648. def test_ab_completed_motion(client, output, frequency_hz, pulses, curve_mode,
  1649. start_hz, stop_hz, acceleration_ms,
  1650. deceleration_ms, timeout, label):
  1651. clear_position(client)
  1652. configure_ab(
  1653. client,
  1654. output,
  1655. make_common(
  1656. curve_mode=curve_mode,
  1657. default_hz=frequency_hz,
  1658. start_hz=start_hz,
  1659. stop_hz=stop_hz,
  1660. acceleration_ms=acceleration_ms,
  1661. deceleration_ms=deceleration_ms,
  1662. ),
  1663. make_segment(frequency_hz, pulses),
  1664. )
  1665. clear_diagnostic(client)
  1666. command(client, COMMAND_START)
  1667. status = wait_terminal(client, timeout)
  1668. require(status.state == STATUS_COMPLETED,
  1669. "%s did not complete" % label)
  1670. require(status.position == pulses,
  1671. "%s position expected %d, got %d"
  1672. % (label, pulses, status.position))
  1673. diagnostic = read_diagnostic(client)
  1674. require_diagnostic_pass(
  1675. diagnostic,
  1676. abs(pulses),
  1677. OUTPUT_AB,
  1678. pulses >= 0,
  1679. label,
  1680. )
  1681. print(
  1682. "INFO %s pair=Y%d/Y%d direction=%s requested=%dHz "
  1683. "timer=%dHz pulses=%d samples=%d"
  1684. % (
  1685. label,
  1686. output,
  1687. output + 1,
  1688. "positive" if pulses >= 0 else "negative",
  1689. frequency_hz,
  1690. diagnostic.active_timer_hz,
  1691. diagnostic.actual_pulses,
  1692. diagnostic.sample_count,
  1693. )
  1694. )
  1695. def test_ab_stop(client):
  1696. expected_pulses = 50000
  1697. clear_position(client)
  1698. configure_ab(
  1699. client,
  1700. 0,
  1701. make_common(
  1702. default_hz=2000,
  1703. start_hz=500,
  1704. stop_hz=500,
  1705. acceleration_ms=100,
  1706. deceleration_ms=100,
  1707. ),
  1708. make_segment(2000, expected_pulses),
  1709. )
  1710. clear_diagnostic(client)
  1711. command(client, COMMAND_START)
  1712. wait_for_status(
  1713. client,
  1714. lambda item: item.state in ACTIVE_STATES and item.position >= 10,
  1715. 3.0,
  1716. "AB STOP active motion",
  1717. )
  1718. expect_exception(
  1719. lambda: client.write_single(OUTPUT_MODE, OUTPUT_PULSE_DIR),
  1720. EX_DEVICE_BUSY,
  1721. "AB output mode write while busy",
  1722. )
  1723. command(client, COMMAND_STOP)
  1724. status = wait_terminal(client, 4.0)
  1725. require(status.state == STATUS_STOPPED, "AB STOP did not enter STOPPED")
  1726. require(0 < status.position < expected_pulses,
  1727. "AB STOP did not cut the active move")
  1728. diagnostic = read_diagnostic(client)
  1729. require(not (diagnostic.flags & DIAG_MONITORING),
  1730. "AB STOP diagnostic remained active")
  1731. require(not (diagnostic.flags & DIAG_FAULT_LATCHED),
  1732. "AB STOP latched diagnostic fault %d" % diagnostic.reason)
  1733. require(not (diagnostic.flags & DIAG_COUNT_CHECKED),
  1734. "AB STOP incorrectly marked incomplete count as checked")
  1735. require(diagnostic.expected_pulses == expected_pulses,
  1736. "AB STOP diagnostic target mismatch")
  1737. require(0 < diagnostic.actual_pulses < expected_pulses,
  1738. "AB STOP diagnostic actual count is implausible")
  1739. require(diagnostic.output_mode == OUTPUT_AB,
  1740. "AB STOP diagnostic mode mismatch")
  1741. require(diagnostic.direction_positive,
  1742. "AB STOP diagnostic direction mismatch")
  1743. def test_ab_dynamic_frequency(client):
  1744. expected_pulses = 6000
  1745. clear_position(client)
  1746. configure_ab(
  1747. client,
  1748. 2,
  1749. make_common(
  1750. default_hz=800,
  1751. start_hz=800,
  1752. stop_hz=800,
  1753. ),
  1754. make_segment(800, -expected_pulses),
  1755. )
  1756. clear_diagnostic(client)
  1757. command(client, COMMAND_START)
  1758. wait_for_status(
  1759. client,
  1760. lambda item: item.state in ACTIVE_STATES and item.position <= -10
  1761. and frequency_matches(item.frequency_hz, 800),
  1762. 3.0,
  1763. "AB dynamic initial frequency",
  1764. )
  1765. client.write_multiple(SEGMENT_1_BASE, split_u32(3200))
  1766. dynamic = wait_for_status(
  1767. client,
  1768. lambda item: item.state in ACTIVE_STATES
  1769. and frequency_matches(item.frequency_hz, 3200),
  1770. 3.0,
  1771. "AB dynamic updated frequency",
  1772. )
  1773. print("INFO AB dynamic frequency requested=3200 actual=%d"
  1774. % dynamic.frequency_hz)
  1775. status = wait_terminal(client, 6.0)
  1776. require(status.state == STATUS_COMPLETED,
  1777. "AB dynamic-frequency move did not complete")
  1778. require(status.position == -expected_pulses,
  1779. "AB dynamic-frequency position mismatch")
  1780. require_diagnostic_pass(
  1781. read_diagnostic(client),
  1782. expected_pulses,
  1783. OUTPUT_AB,
  1784. False,
  1785. "AB dynamic frequency",
  1786. )
  1787. def run_ab(client):
  1788. cases = (
  1789. (
  1790. "ab_y0_y1_positive_1hz_linear_short",
  1791. lambda: test_ab_completed_motion(
  1792. client, 0, 1, 1, 0, 1, 1, 0, 0, 5.0,
  1793. "AB Y0/Y1 positive 1 Hz linear short",
  1794. ),
  1795. ),
  1796. (
  1797. "ab_y0_y1_negative_typical_s_curve",
  1798. lambda: test_ab_completed_motion(
  1799. client, 0, 2500, -1200, 1, 250, 250, 80, 80, 5.0,
  1800. "AB Y0/Y1 negative typical S curve",
  1801. ),
  1802. ),
  1803. (
  1804. "ab_y2_y3_positive_100khz_sine",
  1805. lambda: test_ab_completed_motion(
  1806. client, 2, 100000, 5000, 2, 1000, 1000, 20, 20, 4.0,
  1807. "AB Y2/Y3 positive 100 kHz sine",
  1808. ),
  1809. ),
  1810. (
  1811. "ab_y2_y3_negative_typical_linear",
  1812. lambda: test_ab_completed_motion(
  1813. client, 2, 5000, -600, 0, 500, 500, 20, 20, 4.0,
  1814. "AB Y2/Y3 negative typical linear",
  1815. ),
  1816. ),
  1817. )
  1818. for name, function in cases:
  1819. run_isolated_ab_case(client, name, function)
  1820. run_isolated_ab_case(client, "ab_stop", lambda: test_ab_stop(client))
  1821. run_isolated_ab_case(
  1822. client,
  1823. "ab_dynamic_frequency",
  1824. lambda: test_ab_dynamic_frequency(client),
  1825. )
  1826. def run_smoke(client):
  1827. run_case("fixed_map_and_exceptions", lambda: test_fixed_map_and_exceptions(client))
  1828. def run_all(client, keep_config):
  1829. snapshot = (
  1830. client.read_holding(OUTPUT_MODE, 1)[0],
  1831. client.read_holding(CONFIG_BASE, COMMON_WORDS),
  1832. [client.read_holding(SEGMENT_1_BASE + index * 0x10, SEGMENT_WORDS)
  1833. for index in range(10)],
  1834. )
  1835. try:
  1836. client.write_single(OUTPUT_MODE, OUTPUT_PULSE_DIR)
  1837. run_smoke(client)
  1838. run_case(
  1839. "positive_negative_absolute_zero",
  1840. lambda: test_positive_negative_and_absolute_zero(client),
  1841. )
  1842. run_case("ten_segments_sequence",
  1843. lambda: test_ten_segments_in_sequence(client))
  1844. run_case("start_segment_ten",
  1845. lambda: test_start_segment_ten(client))
  1846. run_case("direction_output_polarity_matrix",
  1847. lambda: test_direction_output_and_polarity_matrix(client))
  1848. run_case("all_outputs_100khz",
  1849. lambda: test_all_outputs_at_100khz(client))
  1850. run_case("short_final_profiles",
  1851. lambda: test_short_final_profiles(client))
  1852. run_case("wait_time", lambda: test_wait_time(client))
  1853. run_case("act_time", lambda: test_act_time(client))
  1854. run_case(
  1855. "dynamic_frequency_repeated_stop",
  1856. lambda: test_dynamic_frequency_and_repeated_stop(client),
  1857. )
  1858. run_case(
  1859. "future_segment_frequency_on_arrival",
  1860. lambda: test_future_segment_frequency_applies_on_arrival(client),
  1861. )
  1862. run_case(
  1863. "subsequent_future_frequency_handoff",
  1864. lambda: test_subsequent_future_frequency_rebuilds_handoff(client),
  1865. )
  1866. finally:
  1867. if keep_config:
  1868. cleanup_preserving_primary_error(
  1869. lambda: safe_stop(client),
  1870. "motion stop",
  1871. )
  1872. else:
  1873. cleanup_preserving_primary_error(
  1874. lambda: restore_configuration(client, snapshot),
  1875. "configuration restore",
  1876. )
  1877. def validate_persistence_live_parameters(duration_seconds, interval_seconds):
  1878. require(math.isfinite(duration_seconds),
  1879. "persistence-live duration must be finite")
  1880. require(duration_seconds >= PERSISTENCE_LIVE_MIN_DURATION_SECONDS,
  1881. "persistence-live duration must be at least %.1f seconds"
  1882. % PERSISTENCE_LIVE_MIN_DURATION_SECONDS)
  1883. require(math.isfinite(interval_seconds),
  1884. "persistence-live interval must be finite")
  1885. require(0.0 < interval_seconds <= PERSISTENCE_LIVE_MAX_INTERVAL_SECONDS,
  1886. "persistence-live interval must be > 0 and <= %.3f seconds"
  1887. % PERSISTENCE_LIVE_MAX_INTERVAL_SECONDS)
  1888. def require_persistence_live_stats(stats, duration_seconds, label):
  1889. require(stats.attempts > 0, "%s made no FC03 attempts" % label)
  1890. require(stats.successful >= 0 and stats.timeouts >= 0,
  1891. "%s contains a negative counter" % label)
  1892. require(stats.attempts == stats.successful + stats.timeouts,
  1893. "%s counters are inconsistent" % label)
  1894. require(stats.timeouts == 0,
  1895. "%s had %d FC03 timeout(s)" % (label, stats.timeouts))
  1896. require(stats.successful == stats.attempts,
  1897. "%s did not complete every FC03 request" % label)
  1898. require(math.isfinite(stats.maximum_attempt_seconds)
  1899. and stats.maximum_attempt_seconds >= 0.0,
  1900. "%s maximum attempt latency is invalid" % label)
  1901. require(stats.elapsed_seconds >= duration_seconds,
  1902. "%s probe ran %.3f s, expected at least %.3f s"
  1903. % (label, stats.elapsed_seconds, duration_seconds))
  1904. def probe_persistence_live_window(client, duration_seconds, interval_seconds):
  1905. validate_persistence_live_parameters(duration_seconds, interval_seconds)
  1906. start = time.monotonic()
  1907. deadline = start + duration_seconds
  1908. next_request = start
  1909. attempts = 0
  1910. successful = 0
  1911. timeouts = 0
  1912. maximum_attempt_seconds = 0.0
  1913. reads = (
  1914. (STATUS_BASE, STATUS_WORDS),
  1915. (PERSISTENCE_LIVE_CONFIG_ADDRESS, 1),
  1916. )
  1917. while True:
  1918. now = time.monotonic()
  1919. if now >= deadline:
  1920. break
  1921. if now < next_request:
  1922. time.sleep(min(next_request - now, deadline - now))
  1923. now = time.monotonic()
  1924. if now >= deadline:
  1925. break
  1926. address, quantity = reads[attempts % len(reads)]
  1927. request_started = time.monotonic()
  1928. attempts += 1
  1929. try:
  1930. client.read_holding(address, quantity)
  1931. except RtuTimeout:
  1932. timeouts += 1
  1933. else:
  1934. successful += 1
  1935. attempt_seconds = time.monotonic() - request_started
  1936. maximum_attempt_seconds = max(
  1937. maximum_attempt_seconds,
  1938. attempt_seconds,
  1939. )
  1940. next_request += interval_seconds
  1941. if next_request < time.monotonic():
  1942. next_request = time.monotonic()
  1943. elapsed_seconds = time.monotonic() - start
  1944. return PersistenceLiveStats(
  1945. attempts=attempts,
  1946. successful=successful,
  1947. timeouts=timeouts,
  1948. maximum_attempt_seconds=maximum_attempt_seconds,
  1949. elapsed_seconds=elapsed_seconds,
  1950. )
  1951. def print_persistence_live_result(label, stats, config_value):
  1952. print(
  1953. "INFO persistence-live window=%s attempts=%d successful=%d "
  1954. "timeouts=%d max_attempt_ms=%.3f elapsed_s=%.3f "
  1955. "config_0x%04X=%d"
  1956. % (
  1957. label,
  1958. stats.attempts,
  1959. stats.successful,
  1960. stats.timeouts,
  1961. stats.maximum_attempt_seconds * 1000.0,
  1962. stats.elapsed_seconds,
  1963. PERSISTENCE_LIVE_CONFIG_ADDRESS,
  1964. config_value,
  1965. )
  1966. )
  1967. def persistence_live(client, duration_seconds, interval_seconds):
  1968. validate_persistence_live_parameters(duration_seconds, interval_seconds)
  1969. original = client.read_holding(PERSISTENCE_LIVE_CONFIG_ADDRESS, 1)[0]
  1970. changed = (original + 1) & 0xFFFF
  1971. original_timeout = client.timeout
  1972. client.timeout = min(
  1973. original_timeout,
  1974. PERSISTENCE_LIVE_REQUEST_TIMEOUT_SECONDS,
  1975. )
  1976. print(
  1977. "INFO persistence-live duration_s=%.3f interval_ms=%.3f "
  1978. "request_timeout_ms=%.3f"
  1979. % (
  1980. duration_seconds,
  1981. interval_seconds * 1000.0,
  1982. client.timeout * 1000.0,
  1983. )
  1984. )
  1985. def restore_original_configuration():
  1986. client.write_single(PERSISTENCE_LIVE_CONFIG_ADDRESS, original)
  1987. restore_stats = probe_persistence_live_window(
  1988. client,
  1989. duration_seconds,
  1990. interval_seconds,
  1991. )
  1992. restore_readback = client.read_holding(
  1993. PERSISTENCE_LIVE_CONFIG_ADDRESS,
  1994. 1,
  1995. )[0]
  1996. print_persistence_live_result(
  1997. "restore",
  1998. restore_stats,
  1999. restore_readback,
  2000. )
  2001. require_persistence_live_stats(
  2002. restore_stats,
  2003. duration_seconds,
  2004. "persistence-live restore window",
  2005. )
  2006. require(restore_readback == original,
  2007. "persistence-live original config was not restored")
  2008. try:
  2009. try:
  2010. client.write_single(PERSISTENCE_LIVE_CONFIG_ADDRESS, changed)
  2011. change_stats = probe_persistence_live_window(
  2012. client,
  2013. duration_seconds,
  2014. interval_seconds,
  2015. )
  2016. change_readback = client.read_holding(
  2017. PERSISTENCE_LIVE_CONFIG_ADDRESS,
  2018. 1,
  2019. )[0]
  2020. print_persistence_live_result(
  2021. "change",
  2022. change_stats,
  2023. change_readback,
  2024. )
  2025. require_persistence_live_stats(
  2026. change_stats,
  2027. duration_seconds,
  2028. "persistence-live change window",
  2029. )
  2030. require(change_readback == changed,
  2031. "persistence-live changed config readback mismatch")
  2032. finally:
  2033. cleanup_preserving_primary_error(
  2034. restore_original_configuration,
  2035. "persistence-live configuration restore",
  2036. )
  2037. finally:
  2038. client.timeout = original_timeout
  2039. def persistence_prepare(client, motion_timeout=3.0, settle_seconds=1.2,
  2040. announce=True):
  2041. snapshot = snapshot_ab_configuration(client)
  2042. prepared = False
  2043. try:
  2044. clear_position(client)
  2045. configure_ab(
  2046. client,
  2047. 0,
  2048. make_common(start_hz=500),
  2049. make_segment(500, 7),
  2050. )
  2051. command(client, COMMAND_START)
  2052. status = wait_terminal(client, motion_timeout)
  2053. require(status.state == STATUS_COMPLETED,
  2054. "persistence move did not complete")
  2055. require(status.position == 7, "persistence position expected 7")
  2056. time.sleep(settle_seconds)
  2057. require(read_status(client).position == 7,
  2058. "position changed before reset")
  2059. prepared = True
  2060. finally:
  2061. if not prepared:
  2062. cleanup_preserving_primary_error(
  2063. lambda: restore_ab_configuration(client, snapshot),
  2064. "persistence preparation restore",
  2065. )
  2066. if announce:
  2067. print("PREPARED: release COM, reset/power-cycle the MCU, then run verify phase")
  2068. def persistence_verify(client, cleanup):
  2069. status = read_status(client)
  2070. require(status.state == STATUS_IDLE, "post-reset state must be IDLE")
  2071. require(status.position == 7, "post-reset position expected 7")
  2072. require(client.read_holding(OUTPUT_MODE, 1) == [OUTPUT_AB],
  2073. "AB output mode was not restored after reset")
  2074. segment = client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS)
  2075. require(join_u32(segment[0], segment[1]) == 500, "frequency was not restored")
  2076. require(join_i32(segment[2], segment[3]) == 7, "pulse target was not restored")
  2077. if cleanup:
  2078. command(client, COMMAND_CLEAR)
  2079. require(read_status(client).position == 0, "cleanup CLEAR failed")
  2080. restore_default_configuration(client)
  2081. time.sleep(1.2)
  2082. common = client.read_holding(CONFIG_BASE, COMMON_WORDS)
  2083. segment = client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS)
  2084. require(join_u32(common[0x0B], common[0x0C]) == 1000,
  2085. "cleanup default speed was not restored")
  2086. require(join_u32(segment[0], segment[1]) == 1000,
  2087. "cleanup segment frequency was not restored")
  2088. require(join_i32(segment[2], segment[3]) == 1000,
  2089. "cleanup segment pulses were not restored")
  2090. def defaults_verify(client):
  2091. status = read_status(client)
  2092. require(status.state == STATUS_IDLE, "default state must be IDLE")
  2093. require(status.position == 0, "default position must be zero")
  2094. require(client.read_holding(OUTPUT_MODE, 1) == [OUTPUT_PULSE_DIR],
  2095. "default output mode must be PULSE/DIR")
  2096. common = client.read_holding(CONFIG_BASE, COMMON_WORDS)
  2097. require(common[0x05] == 10, "default direction delay expected 10 ms")
  2098. require(join_u32(common[0x0B], common[0x0C]) == 1000,
  2099. "default speed expected 1000 Hz")
  2100. require(join_u32(common[0x0D], common[0x0E]) == 100,
  2101. "start speed expected 100 Hz")
  2102. require(join_u32(common[0x10], common[0x11]) == 100,
  2103. "stop speed expected 100 Hz")
  2104. for index in range(10):
  2105. base = SEGMENT_1_BASE + index * 0x10
  2106. segment = client.read_holding(base, SEGMENT_WORDS)
  2107. expected_pulses = 1000 if index == 0 else 0
  2108. require(join_u32(segment[0], segment[1]) == 1000,
  2109. "default segment %d frequency mismatch" % (index + 1))
  2110. require(join_i32(segment[2], segment[3]) == expected_pulses,
  2111. "default segment %d pulse count mismatch" % (index + 1))
  2112. class _PersistenceSelfTestClient:
  2113. def __init__(self, complete_on_start):
  2114. self.complete_on_start = complete_on_start
  2115. self.output_mode = OUTPUT_PULSE_DIR
  2116. self.common = make_common(default_hz=1234)
  2117. self.segment = make_segment(2345, -17)
  2118. self.state = STATUS_IDLE
  2119. self.position = 41
  2120. self.calls = []
  2121. def read_holding(self, address, quantity):
  2122. self.calls.append(("read", address, quantity))
  2123. if address == OUTPUT_MODE and quantity == 1:
  2124. return [self.output_mode]
  2125. if address == CONFIG_BASE and quantity == COMMON_WORDS:
  2126. return list(self.common)
  2127. if address == SEGMENT_1_BASE and quantity == SEGMENT_WORDS:
  2128. return list(self.segment)
  2129. if address == STATUS_BASE and quantity == STATUS_WORDS:
  2130. return (
  2131. split_i32(self.position)
  2132. + split_u32(500)
  2133. + [self.state, 1 if self.state in ACTIVE_STATES else 0, ERROR_NONE]
  2134. )
  2135. if address == DIAGNOSTIC_BASE and quantity == DIAGNOSTIC_WORDS:
  2136. words = [0] * DIAGNOSTIC_WORDS
  2137. words[24:26] = [0xFFFF, 0xFFFF]
  2138. return words
  2139. raise AssertionError("unexpected self-test read 0x%04X/%d"
  2140. % (address, quantity))
  2141. def write_single(self, address, value):
  2142. self.calls.append(("single", address, value))
  2143. if address == OUTPUT_MODE:
  2144. self.output_mode = value
  2145. elif address == DIAGNOSTIC_CONTROL:
  2146. require(value == DIAGNOSTIC_CLEAR,
  2147. "self-test diagnostic command mismatch")
  2148. elif address == CONTROL:
  2149. if value == COMMAND_CLEAR:
  2150. self.state = STATUS_IDLE
  2151. self.position = 0
  2152. elif value == COMMAND_START:
  2153. if self.complete_on_start:
  2154. self.state = STATUS_COMPLETED
  2155. self.position = 7
  2156. else:
  2157. self.state = STATUS_RUNNING
  2158. self.position = 1
  2159. elif value == COMMAND_STOP:
  2160. self.state = STATUS_STOPPED
  2161. else:
  2162. raise AssertionError("unexpected self-test control command")
  2163. else:
  2164. raise AssertionError("unexpected self-test single write 0x%04X"
  2165. % address)
  2166. def write_multiple(self, address, values):
  2167. self.calls.append(("multiple", address, list(values)))
  2168. if address == CONFIG_BASE:
  2169. self.common = list(values)
  2170. elif address == SEGMENT_1_BASE:
  2171. self.segment = list(values)
  2172. else:
  2173. raise AssertionError("unexpected self-test multiple write 0x%04X"
  2174. % address)
  2175. def self_test():
  2176. def raise_error(error):
  2177. raise error
  2178. payload = bytes.fromhex("01 01 00 00 00 01")
  2179. require(crc16(payload) == 0xCAFD, "CRC reference vector failed")
  2180. require(append_crc(payload) == bytes.fromhex("01 01 00 00 00 01 FD CA"),
  2181. "wire CRC order failed")
  2182. for value in (0, 1, 0x7FFFFFFF, -1, -123456, -0x80000000):
  2183. words = split_i32(value)
  2184. require(join_i32(words[0], words[1]) == value, "int32 word round trip failed")
  2185. require(frequency_matches(1199, 1200), "frequency tolerance rejected 1 Hz")
  2186. require(not frequency_matches(1198, 1200),
  2187. "frequency tolerance accepted excessive error")
  2188. sample = (
  2189. "\n0x42408214 : 00000001 00000002 00000003 00000004 \n"
  2190. "0x42408224 : 00000005 \n"
  2191. )
  2192. require(parse_stlink_word(sample, X4_INPUT_BIT) == 1,
  2193. "ST-LINK read parser failed")
  2194. require(parse_stlink_words(sample, X4_INPUT_BIT, 5)
  2195. == [1, 2, 3, 4, 5],
  2196. "ST-LINK multi-word parser failed")
  2197. map_sample = (
  2198. "PlsrIrqCount 0x2000'4d80 0x10 Data Gb object.o\n"
  2199. "PlsrProfileProducerItemCount\n"
  2200. " 0x2000'61c0 0x4 Data Gb object.o\n"
  2201. )
  2202. require(parse_iar_map_symbol_address(map_sample, "PlsrIrqCount")
  2203. == 0x20004D80,
  2204. "IAR map symbol parser failed")
  2205. require(parse_iar_map_symbol_address(
  2206. map_sample, "PlsrProfileProducerItemCount") == 0x200061C0,
  2207. "IAR wrapped map symbol parser failed")
  2208. passing_irq_stats = IrqCycleStats(
  2209. count=(1, 2, 3, 4),
  2210. last_cycles=(100, 200, 300, 400),
  2211. maximum_cycles=(1679, 1200, 900, 800),
  2212. )
  2213. require_irq_cycle_budget(
  2214. passing_irq_stats, range(4), "timing self-test pass")
  2215. expect_test_failure(
  2216. lambda: require_irq_cycle_budget(
  2217. dataclasses.replace(
  2218. passing_irq_stats,
  2219. maximum_cycles=(IRQ_CYCLE_BUDGET_100KHZ, 1200, 900, 800),
  2220. ),
  2221. (0,),
  2222. "timing self-test reject",
  2223. ),
  2224. "IRQ cycle budget equality",
  2225. )
  2226. passing_final_arm_stats = FinalArmCycleStats(
  2227. queue_count=(2, 0, 2, 0),
  2228. job_last_cycles=(700, 0, 800, 0),
  2229. job_maximum_cycles=(900, 0, 950, 0),
  2230. queue_to_stop_last_cycles=(1000, 0, 1100, 0),
  2231. queue_to_stop_maximum_cycles=(1200, 0, 1300, 0),
  2232. )
  2233. require_final_arm_cycle_budget(
  2234. passing_final_arm_stats, (0, 2), "final-arm timing self-test pass")
  2235. for invalid_stats, label in (
  2236. (dataclasses.replace(
  2237. passing_final_arm_stats,
  2238. job_maximum_cycles=(IRQ_CYCLE_BUDGET_100KHZ, 0, 950, 0)),
  2239. "final-arm job budget equality"),
  2240. (dataclasses.replace(
  2241. passing_final_arm_stats,
  2242. queue_to_stop_maximum_cycles=(1200, 0,
  2243. IRQ_CYCLE_BUDGET_100KHZ, 0)),
  2244. "final-arm latency budget equality"),
  2245. (dataclasses.replace(
  2246. passing_final_arm_stats, queue_count=(0, 0, 2, 0)),
  2247. "final-arm missing job"),
  2248. ):
  2249. expect_test_failure(
  2250. lambda item=invalid_stats: require_final_arm_cycle_budget(
  2251. item, (0, 2), "final-arm timing self-test reject"),
  2252. label,
  2253. )
  2254. passing_producer_stats = ProducerCycleStats(
  2255. item_count=4,
  2256. total_cycles=6719,
  2257. maximum_item_cycles=1679,
  2258. )
  2259. require_producer_cycle_budget(
  2260. passing_producer_stats, "producer timing self-test pass")
  2261. expect_test_failure(
  2262. lambda: require_producer_cycle_budget(
  2263. dataclasses.replace(
  2264. passing_producer_stats,
  2265. total_cycles=IRQ_CYCLE_BUDGET_100KHZ * 4,
  2266. maximum_item_cycles=IRQ_CYCLE_BUDGET_100KHZ,
  2267. ),
  2268. "producer timing self-test reject",
  2269. ),
  2270. "producer cycle budget equality",
  2271. )
  2272. no_producer_stats = ProducerCycleStats(
  2273. item_count=0,
  2274. total_cycles=0,
  2275. maximum_item_cycles=0,
  2276. )
  2277. require_no_producer_activity(
  2278. no_producer_stats, "long-ramp producer self-test pass")
  2279. for invalid_stats, label in (
  2280. (dataclasses.replace(no_producer_stats, item_count=1),
  2281. "long-ramp producer item count"),
  2282. (dataclasses.replace(no_producer_stats, total_cycles=1),
  2283. "long-ramp producer total cycles"),
  2284. (dataclasses.replace(no_producer_stats, maximum_item_cycles=1),
  2285. "long-ramp producer maximum cycles"),
  2286. ):
  2287. expect_test_failure(
  2288. lambda item=invalid_stats: require_no_producer_activity(
  2289. item, "long-ramp producer self-test reject"),
  2290. label,
  2291. )
  2292. diagnostic_words = [
  2293. DIAG_COMPLETE_PASS, 0, 3, 0x0101,
  2294. 0x5678, 0x1234, 0x5678, 0x1234,
  2295. 0, 0, 0x86A0, 1, 0x869F, 1, 0x869F, 1,
  2296. 0xFFFF, 0xFFFF, 0x4321, 0x0002, 0, 0, 0, 0,
  2297. 0xFFFF, 0xFFFF,
  2298. ]
  2299. diagnostic = parse_diagnostic(diagnostic_words)
  2300. require(DIAGNOSTIC_BASE == 0x2100 and DIAGNOSTIC_WORDS == 26,
  2301. "diagnostic address or length changed")
  2302. require(OUTPUT_MODE == 0x1200 and DIAGNOSTIC_CONTROL == 0x3100,
  2303. "enhanced configuration/control address changed")
  2304. require(diagnostic.output_mode == OUTPUT_AB,
  2305. "diagnostic mode parser failed")
  2306. require(diagnostic.direction_positive,
  2307. "diagnostic direction parser failed")
  2308. require(diagnostic.expected_pulses == 0x12345678,
  2309. "diagnostic expected count parser failed")
  2310. require(diagnostic.actual_pulses == 0x12345678,
  2311. "diagnostic actual count parser failed")
  2312. require(diagnostic.requested_hz == 100000,
  2313. "diagnostic requested frequency parser failed")
  2314. require(diagnostic.request_error_hz == -1,
  2315. "diagnostic signed frequency error parser failed")
  2316. require(diagnostic.sample_count == 0x00024321,
  2317. "diagnostic sample count parser failed")
  2318. require(diagnostic.first_mismatch_sample == 0xFFFFFFFF,
  2319. "diagnostic sentinel parser failed")
  2320. require((DIAG_COMPLETE_PASS & DIAG_FAULT_LATCHED) == 0,
  2321. "diagnostic pass mask includes fault bit")
  2322. for expected_error in (
  2323. ModbusException(0x03, EX_DEVICE_BUSY),
  2324. serial.SerialException("self-test serial failure"),
  2325. ):
  2326. class ReadFailureClient:
  2327. def read_holding(self, address, quantity):
  2328. raise expected_error
  2329. try:
  2330. safe_stop(ReadFailureClient())
  2331. except Exception as actual_error:
  2332. require(actual_error is expected_error,
  2333. "safe_stop replaced the original communication error")
  2334. else:
  2335. raise TestFailure("safe_stop swallowed a communication error")
  2336. primary_error = TestFailure("primary board-test failure")
  2337. cleanup_error = ModbusException(0x06, EX_DEVICE_BUSY)
  2338. cleanup_warning = io.StringIO()
  2339. caught_error = None
  2340. try:
  2341. try:
  2342. raise primary_error
  2343. finally:
  2344. cleanup_preserving_primary_error(
  2345. lambda: raise_error(cleanup_error),
  2346. "self-test configuration restore",
  2347. cleanup_warning,
  2348. )
  2349. except TestFailure as error:
  2350. caught_error = error
  2351. require(caught_error is primary_error,
  2352. "cleanup failure replaced the primary board-test failure")
  2353. require("self-test configuration restore failed during error cleanup"
  2354. in cleanup_warning.getvalue(),
  2355. "cleanup failure warning omitted its operation context")
  2356. if hasattr(primary_error, "add_note"):
  2357. require(any("self-test configuration restore" in note
  2358. for note in getattr(primary_error, "__notes__", ())),
  2359. "cleanup failure was not attached to the primary exception")
  2360. cleanup_error = serial.SerialException("standalone cleanup failure")
  2361. try:
  2362. cleanup_preserving_primary_error(
  2363. lambda: raise_error(cleanup_error),
  2364. "self-test standalone cleanup",
  2365. io.StringIO(),
  2366. )
  2367. except serial.SerialException as error:
  2368. require(error is cleanup_error,
  2369. "standalone cleanup failure was replaced")
  2370. else:
  2371. raise TestFailure("standalone cleanup failure was swallowed")
  2372. validate_persistence_live_parameters(
  2373. PERSISTENCE_LIVE_MIN_DURATION_SECONDS,
  2374. PERSISTENCE_LIVE_MAX_INTERVAL_SECONDS,
  2375. )
  2376. invalid_live_parameters = (
  2377. (PERSISTENCE_LIVE_MIN_DURATION_SECONDS - 0.001, 0.05,
  2378. "short persistence-live duration"),
  2379. (float("nan"), 0.05, "NaN persistence-live duration"),
  2380. (float("inf"), 0.05, "infinite persistence-live duration"),
  2381. (PERSISTENCE_LIVE_MIN_DURATION_SECONDS, 0.0,
  2382. "zero persistence-live interval"),
  2383. (PERSISTENCE_LIVE_MIN_DURATION_SECONDS,
  2384. PERSISTENCE_LIVE_MAX_INTERVAL_SECONDS + 0.001,
  2385. "long persistence-live interval"),
  2386. (PERSISTENCE_LIVE_MIN_DURATION_SECONDS, float("nan"),
  2387. "NaN persistence-live interval"),
  2388. )
  2389. for duration_seconds, interval_seconds, label in invalid_live_parameters:
  2390. expect_test_failure(
  2391. lambda duration=duration_seconds, interval=interval_seconds:
  2392. validate_persistence_live_parameters(duration, interval),
  2393. label,
  2394. )
  2395. passing_live_stats = PersistenceLiveStats(
  2396. attempts=50,
  2397. successful=50,
  2398. timeouts=0,
  2399. maximum_attempt_seconds=0.012,
  2400. elapsed_seconds=PERSISTENCE_LIVE_MIN_DURATION_SECONDS,
  2401. )
  2402. require_persistence_live_stats(
  2403. passing_live_stats,
  2404. PERSISTENCE_LIVE_MIN_DURATION_SECONDS,
  2405. "persistence-live self-test pass",
  2406. )
  2407. failing_live_stats = (
  2408. (dataclasses.replace(passing_live_stats, successful=49, timeouts=1),
  2409. "persistence-live timeout stats"),
  2410. (dataclasses.replace(passing_live_stats, successful=49),
  2411. "persistence-live inconsistent stats"),
  2412. (dataclasses.replace(passing_live_stats, attempts=0, successful=0),
  2413. "persistence-live empty stats"),
  2414. (dataclasses.replace(passing_live_stats, elapsed_seconds=2.499),
  2415. "persistence-live short elapsed stats"),
  2416. (dataclasses.replace(passing_live_stats,
  2417. maximum_attempt_seconds=float("nan")),
  2418. "persistence-live invalid latency stats"),
  2419. )
  2420. for stats, label in failing_live_stats:
  2421. expect_test_failure(
  2422. lambda item=stats: require_persistence_live_stats(
  2423. item,
  2424. PERSISTENCE_LIVE_MIN_DURATION_SECONDS,
  2425. "persistence-live self-test reject",
  2426. ),
  2427. label,
  2428. )
  2429. failed_prepare = _PersistenceSelfTestClient(complete_on_start=False)
  2430. original_common = list(failed_prepare.common)
  2431. original_segment = list(failed_prepare.segment)
  2432. try:
  2433. persistence_prepare(
  2434. failed_prepare,
  2435. motion_timeout=0.0,
  2436. settle_seconds=0.0,
  2437. announce=False,
  2438. )
  2439. except TestFailure:
  2440. pass
  2441. else:
  2442. raise TestFailure("failed persistence prepare unexpectedly succeeded")
  2443. controls = [call[2] for call in failed_prepare.calls
  2444. if call[:2] == ("single", CONTROL)]
  2445. require(COMMAND_STOP in controls,
  2446. "failed persistence prepare did not issue STOP")
  2447. require(failed_prepare.output_mode == OUTPUT_PULSE_DIR,
  2448. "failed persistence prepare did not restore output mode")
  2449. require(failed_prepare.common == original_common,
  2450. "failed persistence prepare did not restore common config")
  2451. require(failed_prepare.segment == original_segment,
  2452. "failed persistence prepare did not restore segment config")
  2453. successful_prepare = _PersistenceSelfTestClient(complete_on_start=True)
  2454. persistence_prepare(
  2455. successful_prepare,
  2456. motion_timeout=0.1,
  2457. settle_seconds=0.0,
  2458. announce=False,
  2459. )
  2460. controls = [call[2] for call in successful_prepare.calls
  2461. if call[:2] == ("single", CONTROL)]
  2462. require(COMMAND_STOP not in controls,
  2463. "successful persistence prepare issued STOP")
  2464. require(successful_prepare.output_mode == OUTPUT_AB,
  2465. "successful persistence prepare did not retain AB mode")
  2466. require(join_u32(successful_prepare.segment[0],
  2467. successful_prepare.segment[1]) == 500,
  2468. "successful persistence prepare did not retain frequency")
  2469. require(join_i32(successful_prepare.segment[2],
  2470. successful_prepare.segment[3]) == 7,
  2471. "successful persistence prepare did not retain pulse target")
  2472. print("Self-test passed; no serial port was opened")
  2473. def print_preflight(args):
  2474. print("No serial port opened. Board-test preconditions:")
  2475. print(" - Firmware containing the PLSR 0x1000/0x2000/0x3000 map is flashed.")
  2476. print(" - RS-485 is %d baud, 8E1, slave %d on %s." %
  2477. (args.baud, args.slave, args.port))
  2478. if args.phase == "ab":
  2479. print(" - Y0/Y1 and Y2/Y3 AB output pairs are safe to toggle.")
  2480. else:
  2481. print(" - Configured pulse, direction, and AB-pair outputs are safe to toggle.")
  2482. print(" - No other program has the COM port open.")
  2483. print("Run smoke only:")
  2484. print(" py -B HostComputer\\plsr_modbus_product_test.py --run --phase smoke")
  2485. print("Run motion matrix:")
  2486. print(" py -B HostComputer\\plsr_modbus_product_test.py --run --allow-motion")
  2487. print("Run X4/X5 loopback tests:")
  2488. print(" py -B HostComputer\\plsr_modbus_product_test.py --run --phase x45 --allow-motion")
  2489. print("Run AB output and diagnostic matrix:")
  2490. print(" py -B HostComputer\\plsr_modbus_product_test.py --run --phase ab --allow-motion")
  2491. print("Run DWT ISR/producer timing acceptance:")
  2492. print(" py -B HostComputer\\plsr_modbus_product_test.py --run --phase timing --allow-motion")
  2493. print("Run persistence communication-window test:")
  2494. print(" py -B HostComputer\\plsr_modbus_product_test.py --run --phase persistence-live")
  2495. print("Run offline checks:")
  2496. print(" py -B HostComputer\\plsr_modbus_product_test.py --self-test")
  2497. def parse_arguments():
  2498. parser = argparse.ArgumentParser(description=__doc__)
  2499. parser.add_argument("--port", default="COM5")
  2500. parser.add_argument("--baud", type=int, default=9600)
  2501. parser.add_argument("--slave", type=int, default=1)
  2502. parser.add_argument("--timeout", type=float, default=1.5)
  2503. parser.add_argument("--stlink-cli", default=DEFAULT_STLINK_CLI)
  2504. parser.add_argument("--stlink-id", type=int, default=0)
  2505. parser.add_argument("--stlink-timeout", type=float, default=20.0)
  2506. parser.add_argument("--iar-map", default=DEFAULT_IAR_MAP)
  2507. parser.add_argument(
  2508. "--phase",
  2509. choices=("smoke", "all", "ab", "x45", "timing", "persistence-prepare",
  2510. "persistence-verify", "persistence-live",
  2511. "defaults-verify"),
  2512. default="all",
  2513. )
  2514. parser.add_argument("--run", action="store_true",
  2515. help="open the serial port and execute the selected phase")
  2516. parser.add_argument("--allow-motion", action="store_true",
  2517. help="confirm that configured motion outputs may toggle")
  2518. parser.add_argument("--keep-config", action="store_true",
  2519. help="retain matrix config; AB phase always restores each case")
  2520. parser.add_argument("--cleanup", action="store_true",
  2521. help="CLEAR position after persistence verification")
  2522. parser.add_argument(
  2523. "--persistence-duration",
  2524. type=float,
  2525. default=PERSISTENCE_LIVE_DEFAULT_DURATION_SECONDS,
  2526. help="seconds to probe each persistence-live window (minimum 2.5)",
  2527. )
  2528. parser.add_argument(
  2529. "--persistence-interval",
  2530. type=float,
  2531. default=PERSISTENCE_LIVE_DEFAULT_INTERVAL_SECONDS,
  2532. help="seconds between persistence-live FC03 requests (maximum 0.1)",
  2533. )
  2534. parser.add_argument("--self-test", action="store_true",
  2535. help="run offline CRC/word tests without opening a port")
  2536. return parser.parse_args()
  2537. def main():
  2538. args = parse_arguments()
  2539. require(1 <= args.slave <= 247, "slave must be 1..247")
  2540. require(args.baud > 0, "baud must be positive")
  2541. require(args.timeout > 0, "timeout must be positive")
  2542. require(args.stlink_id >= 0, "ST-LINK ID must not be negative")
  2543. require(args.stlink_timeout > 0, "ST-LINK timeout must be positive")
  2544. if args.self_test:
  2545. self_test()
  2546. return 0
  2547. if not args.run:
  2548. print_preflight(args)
  2549. return 0
  2550. motion_phase = args.phase in (
  2551. "all", "ab", "x45", "timing", "persistence-prepare")
  2552. if motion_phase and not args.allow_motion:
  2553. raise TestFailure("motion phase requires --allow-motion")
  2554. print("Opening %s at %d 8E1, slave %d" %
  2555. (args.port, args.baud, args.slave))
  2556. with RtuClient(args.port, args.baud, args.slave, args.timeout) as client:
  2557. if args.phase == "smoke":
  2558. run_smoke(client)
  2559. elif args.phase == "all":
  2560. run_all(client, args.keep_config)
  2561. elif args.phase == "ab":
  2562. run_ab(client)
  2563. elif args.phase == "x45":
  2564. fixture = X45Fixture(
  2565. args.stlink_cli,
  2566. args.stlink_id,
  2567. args.stlink_timeout,
  2568. )
  2569. run_x45(client, fixture, args.keep_config)
  2570. elif args.phase == "timing":
  2571. probe = X45Fixture(
  2572. args.stlink_cli,
  2573. args.stlink_id,
  2574. args.stlink_timeout,
  2575. )
  2576. timing = TimingFixture(probe, args.iar_map)
  2577. run_timing(client, timing, args.keep_config)
  2578. elif args.phase == "persistence-prepare":
  2579. persistence_prepare(client)
  2580. elif args.phase == "persistence-verify":
  2581. persistence_verify(client, args.cleanup)
  2582. elif args.phase == "persistence-live":
  2583. persistence_live(
  2584. client,
  2585. args.persistence_duration,
  2586. args.persistence_interval,
  2587. )
  2588. else:
  2589. defaults_verify(client)
  2590. print("PASS phase=%s" % args.phase)
  2591. return 0
  2592. if __name__ == "__main__":
  2593. try:
  2594. sys.exit(main())
  2595. except (TestFailure, ModbusException, serial.SerialException) as error:
  2596. print("FAIL: %s" % error, file=sys.stderr)
  2597. sys.exit(1)