综合平台编程器项目的远程存储
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

926 行
38 KiB

  1. #include "domain/hmi_model.h"
  2. #include "domain/project_limits.h"
  3. #include "domain/register_repository.h"
  4. #include "domain/virtual_register_repository.h"
  5. #include "services/hmi_runtime_service.h"
  6. #include "services/offline_simulation_service.h"
  7. #include "services/online_logic_monitor_service.h"
  8. #include "services/software_logic_executor.h"
  9. #include "support/test_support.h"
  10. #include <QCoreApplication>
  11. #include <array>
  12. #include <iostream>
  13. #include <stdexcept>
  14. #include <string>
  15. #include <vector>
  16. namespace {
  17. using TestSupport::require;
  18. LogicNode contact(const std::string &id, int address,
  19. ContactMode mode = ContactMode::NormallyOpen)
  20. {
  21. return {id, ContactNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true};
  22. }
  23. LogicNode edgeContact(const std::string &id, int address, EdgeMode mode)
  24. {
  25. return {id,
  26. EdgeContactNodeConfig{
  27. RegisterAddress{RegisterArea::M, address}, mode},
  28. true};
  29. }
  30. WordOperand constantOperand(std::int16_t value)
  31. {
  32. return {
  33. WordOperandKind::Constant,
  34. RegisterAddress{RegisterArea::D, 0},
  35. value};
  36. }
  37. WordOperand registerOperand(int address)
  38. {
  39. return {
  40. WordOperandKind::Register,
  41. RegisterAddress{RegisterArea::D, address},
  42. 0};
  43. }
  44. LogicNode move(
  45. const std::string &id,
  46. WordOperand source,
  47. int destination)
  48. {
  49. return {
  50. id,
  51. MoveNodeConfig{
  52. source,
  53. RegisterAddress{RegisterArea::D, destination}},
  54. true};
  55. }
  56. LogicNode arithmetic(
  57. const std::string &id,
  58. ArithmeticOperation operation,
  59. WordOperand left,
  60. WordOperand right,
  61. int destination)
  62. {
  63. return {
  64. id,
  65. ArithmeticNodeConfig{
  66. operation,
  67. left,
  68. right,
  69. RegisterAddress{RegisterArea::D, destination}},
  70. true};
  71. }
  72. LogicNode comparison(const std::string &id, int address,
  73. ComparisonOperator operation, std::int16_t value)
  74. {
  75. return {id,
  76. CompareNodeConfig{RegisterAddress{RegisterArea::D, address}, operation, value},
  77. true};
  78. }
  79. LogicNode coil(const std::string &id, int address,
  80. CoilMode mode = CoilMode::Normal)
  81. {
  82. return {id, CoilNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true};
  83. }
  84. LadderRung rung(const std::string &id,
  85. const std::vector<std::vector<LogicNode>> &stages,
  86. const LogicNode &output)
  87. {
  88. LadderRung result;
  89. result.id = id;
  90. result.name = id;
  91. result.cells.reserve(ProjectLimits::kMaximumConditionColumns);
  92. for (int column = 0;
  93. column < ProjectLimits::kMaximumConditionColumns;
  94. ++column)
  95. {
  96. LadderCell cell;
  97. cell.id = id + "-cell-" + std::to_string(column);
  98. cell.kind = LadderCellKind::Wire;
  99. if (column < static_cast<int>(stages.size())
  100. && !stages[static_cast<std::size_t>(column)].empty())
  101. {
  102. cell.kind = LadderCellKind::Node;
  103. cell.node = stages[static_cast<std::size_t>(column)].front();
  104. }
  105. result.cells.push_back(std::move(cell));
  106. }
  107. result.output = output;
  108. return result;
  109. }
  110. ControlLogic logic(const std::vector<LadderRung> &rungs)
  111. {
  112. return {"logic-1", "logic-1", rungs, true};
  113. }
  114. bool readBit(RegisterRepository &repository, int address)
  115. {
  116. const BitReadResult result = repository.readBit(
  117. RegisterAddress{RegisterArea::M, address});
  118. require(result.succeeded, "test register read must succeed");
  119. return result.value;
  120. }
  121. void writeBit(RegisterRepository &repository, int address, bool value)
  122. {
  123. require(repository.writeBit(RegisterAddress{RegisterArea::M, address}, value).succeeded,
  124. "test bit write must succeed");
  125. }
  126. void writeWord(RegisterRepository &repository, int address, std::int16_t value)
  127. {
  128. require(repository.writeWord(RegisterAddress{RegisterArea::D, address}, value).succeeded,
  129. "test word write must succeed");
  130. }
  131. std::int16_t readWord(RegisterRepository &repository, int address)
  132. {
  133. const WordReadResult result = repository.readWord(
  134. RegisterAddress{RegisterArea::D, address});
  135. require(result.succeeded, "test word read must succeed");
  136. return result.value;
  137. }
  138. void testParallelRowsAndColumnPropagation()
  139. {
  140. VirtualRegisterRepository repository;
  141. SoftwareLogicExecutor executor;
  142. LadderRung first = rung(
  143. "first", {{contact("a", 0)}},
  144. coil("first-output", 10));
  145. LadderRung second = rung(
  146. "second", {{contact("c", 2)}},
  147. coil("second-output", 11));
  148. second.output.reset();
  149. ControlLogic program = logic({first, second});
  150. program.verticalConnections = {
  151. {"left-bridge", "first", "second", 0},
  152. {"right-bridge", "first", "second", 1}};
  153. writeBit(repository, 0, true);
  154. LogicTraceSnapshot trace;
  155. require(executor.executeScan({program}, repository, &trace).succeeded,
  156. "parallel row scan must succeed");
  157. require(readBit(repository, 10), "the first row must energize its output");
  158. writeBit(repository, 0, false);
  159. writeBit(repository, 2, true);
  160. require(executor.executeScan({program}, repository, &trace).succeeded,
  161. "second parallel row scan must succeed");
  162. require(readBit(repository, 10),
  163. "the lower branch must feed the shared output through the right edge");
  164. require(trace.verticalConnectionValues.at("left-bridge"),
  165. "vertical connection trace must expose the boundary power");
  166. program.verticalConnections.pop_back();
  167. require(executor.executeScan({program}, repository, &trace).succeeded,
  168. "the split network scan must succeed");
  169. require(!readBit(repository, 10),
  170. "deleting the right edge must split the branch from the output");
  171. }
  172. void testUnconditionalCoil()
  173. {
  174. VirtualRegisterRepository repository;
  175. SoftwareLogicExecutor executor;
  176. LadderRung unconditional;
  177. unconditional.id = "unconditional-rung";
  178. unconditional.name = "unconditional-rung";
  179. for (int column = 0;
  180. column < ProjectLimits::kMaximumConditionColumns;
  181. ++column)
  182. {
  183. unconditional.cells.push_back({
  184. "unconditional-cell-" + std::to_string(column),
  185. LadderCellKind::Wire,
  186. std::nullopt});
  187. }
  188. unconditional.output = coil("unconditional-coil", 10);
  189. const ControlLogic program = logic({unconditional});
  190. LogicTraceSnapshot trace;
  191. require(executor.validate({program}).succeeded,
  192. "a full-width wire network must pass runtime validation");
  193. require(executor.executeScan({program}, repository, &trace).succeeded,
  194. "a full-width wire network scan must succeed");
  195. require(readBit(repository, 10),
  196. "a full-width wire network must energize its coil as a constant-true rung");
  197. require(trace.rungValues.at("unconditional-rung"),
  198. "an unconditional rung must report energized power flow");
  199. }
  200. void testEnabledEmptyRowIsRejectedBeforeScanning()
  201. {
  202. SoftwareLogicExecutor executor;
  203. LadderRung empty;
  204. empty.id = "empty-enabled-rung";
  205. empty.name = "Empty enabled rung";
  206. const ControlLogic program = logic({empty});
  207. const LogicScanResult validation = executor.validate({program});
  208. require(!validation.succeeded
  209. && validation.error == LogicScanError::InvalidLogic,
  210. "an enabled empty row must be rejected before fixed-grid scanning");
  211. }
  212. void testWirePassThroughAndPowerTrace()
  213. {
  214. VirtualRegisterRepository repository;
  215. SoftwareLogicExecutor executor;
  216. LadderRung wired_rung;
  217. wired_rung.id = "wired-rung";
  218. wired_rung.name = "wired-rung";
  219. for (int column = 0;
  220. column < ProjectLimits::kMaximumConditionColumns;
  221. ++column)
  222. {
  223. wired_rung.cells.push_back({
  224. "wire-cell-" + std::to_string(column),
  225. LadderCellKind::Gap,
  226. std::nullopt});
  227. }
  228. wired_rung.cells[0].kind = LadderCellKind::Node;
  229. wired_rung.cells[0].node = contact("wire-input", 0);
  230. wired_rung.cells[1].kind = LadderCellKind::Wire;
  231. wired_rung.cells[2].kind = LadderCellKind::Wire;
  232. wired_rung.cells[3].kind = LadderCellKind::Node;
  233. wired_rung.cells[3].node = contact("wire-output", 1);
  234. for (int column = 4;
  235. column < ProjectLimits::kMaximumConditionColumns;
  236. ++column)
  237. {
  238. wired_rung.cells[static_cast<std::size_t>(column)].kind =
  239. LadderCellKind::Wire;
  240. }
  241. wired_rung.output = coil("wired-coil", 10);
  242. const ControlLogic program = logic({wired_rung});
  243. writeBit(repository, 1, true);
  244. LogicTraceSnapshot trace;
  245. require(executor.executeScan({program}, repository, &trace).succeeded,
  246. "wire expression scan must succeed");
  247. require(!readBit(repository, 10),
  248. "a horizontal wire must not bypass a false upstream series contact");
  249. require(trace.cellValues.at("wire-cell-1")
  250. && !trace.cellInputPowerValues.at("wire-cell-1")
  251. && !trace.cellPowerValues.at("wire-cell-1"),
  252. "a wire must remain logically true without showing false upstream power");
  253. writeBit(repository, 0, true);
  254. require(executor.executeScan({program}, repository, &trace).succeeded,
  255. "powered wire expression scan must succeed");
  256. require(readBit(repository, 10)
  257. && trace.cellInputPowerValues.at("wire-cell-1")
  258. && trace.cellPowerValues.at("wire-cell-1"),
  259. "a powered horizontal wire must pass current to the downstream contact");
  260. }
  261. void testSeriesParallelContactsAndSequentialVisibility()
  262. {
  263. VirtualRegisterRepository repository;
  264. SoftwareLogicExecutor executor;
  265. LadderRung primary = rung(
  266. "rung-1",
  267. {{contact("start", 0)},
  268. {contact("stop-primary", 2, ContactMode::NormallyClosed)}},
  269. coil("run", 3));
  270. LadderRung alternate = rung(
  271. "rung-2",
  272. {{contact("alternate", 1)},
  273. {contact("stop-alternate", 2, ContactMode::NormallyClosed)}},
  274. coil("downstream", 4));
  275. ControlLogic program = logic({primary, alternate});
  276. program.verticalConnections = {
  277. {"parallel-left", "rung-1", "rung-2", 0},
  278. {"parallel-right", "rung-1", "rung-2", 2}};
  279. writeBit(repository, 1, true);
  280. require(executor.executeScan({program}, repository).succeeded,
  281. "parallel and series scan must succeed");
  282. require(readBit(repository, 3), "parallel OR and series AND must energize output");
  283. require(readBit(repository, 4),
  284. "a later rung must see an earlier rung write in the same scan");
  285. writeBit(repository, 2, true);
  286. require(executor.executeScan({program}, repository).succeeded,
  287. "normally closed scan must succeed");
  288. require(!readBit(repository, 3), "normally closed stop contact must open the rung");
  289. require(!readBit(repository, 4), "downstream normal coil must follow the new value");
  290. }
  291. void testMotorForwardReverseSelfHoldAndInterlockTruthTable()
  292. {
  293. VirtualRegisterRepository repository;
  294. SoftwareLogicExecutor executor;
  295. LadderRung forward = rung(
  296. "forward-rung",
  297. {{contact("forward-start", 0)},
  298. {contact("forward-stop", 2, ContactMode::NormallyClosed)},
  299. {contact("forward-interlock", 11, ContactMode::NormallyClosed)}},
  300. coil("forward-coil", 10));
  301. LadderRung forward_hold = rung(
  302. "forward-hold-rung",
  303. {{contact("forward-hold", 10)},
  304. {contact("forward-hold-stop", 2, ContactMode::NormallyClosed)},
  305. {contact("forward-hold-interlock", 11, ContactMode::NormallyClosed)}},
  306. coil("unused-forward-branch-output", 20));
  307. forward_hold.output.reset();
  308. LadderRung reverse = rung(
  309. "reverse-rung",
  310. {{contact("reverse-start", 1)},
  311. {contact("reverse-stop", 2, ContactMode::NormallyClosed)},
  312. {contact("reverse-interlock", 10, ContactMode::NormallyClosed)}},
  313. coil("reverse-coil", 11));
  314. LadderRung reverse_hold = rung(
  315. "reverse-hold-rung",
  316. {{contact("reverse-hold", 11)},
  317. {contact("reverse-hold-stop", 2, ContactMode::NormallyClosed)},
  318. {contact("reverse-hold-interlock", 10, ContactMode::NormallyClosed)}},
  319. coil("unused-reverse-branch-output", 21));
  320. reverse_hold.output.reset();
  321. ControlLogic program = logic({
  322. forward, forward_hold, reverse, reverse_hold});
  323. program.id = "motor-control-logic";
  324. program.verticalConnections = {
  325. {"forward-left", "forward-rung", "forward-hold-rung", 0},
  326. {"forward-right", "forward-rung", "forward-hold-rung", 10},
  327. {"reverse-left", "reverse-rung", "reverse-hold-rung", 0},
  328. {"reverse-right", "reverse-rung", "reverse-hold-rung", 10}};
  329. LogicTraceSnapshot trace;
  330. const auto scan = [&]
  331. {
  332. require(executor.executeScan({program}, repository, &trace).succeeded,
  333. "the motor truth-table scan must succeed");
  334. };
  335. scan();
  336. require(!readBit(repository, 10) && !readBit(repository, 11),
  337. "both motor directions must be off in the stopped state");
  338. require(
  339. trace.cellInputPowerValues.at("forward-rung-cell-0")
  340. && !trace.cellPowerValues.at("forward-rung-cell-0")
  341. && !trace.rungValues.at("forward-rung"),
  342. "a false start contact must keep only its left terminal energized");
  343. writeBit(repository, 0, true);
  344. scan();
  345. require(readBit(repository, 10) && !readBit(repository, 11),
  346. "the forward start input must energize only M10");
  347. require(
  348. trace.cellInputPowerValues.at("forward-rung-cell-0")
  349. && trace.cellPowerValues.at("forward-rung-cell-0")
  350. && trace.verticalConnectionValues.at("forward-right")
  351. && trace.nodeValues.at("forward-coil"),
  352. "the forward trace must reach the output and connected branch edge");
  353. writeBit(repository, 0, false);
  354. scan();
  355. require(readBit(repository, 10) && !readBit(repository, 11),
  356. "M10 must remain energized through the forward self-hold branch");
  357. writeBit(repository, 1, true);
  358. scan();
  359. require(readBit(repository, 10) && !readBit(repository, 11),
  360. "the reverse start input must be blocked while forward is active");
  361. writeBit(repository, 2, true);
  362. scan();
  363. require(!readBit(repository, 10) && !readBit(repository, 11),
  364. "the stop input must release both direction outputs");
  365. writeBit(repository, 1, false);
  366. writeBit(repository, 2, false);
  367. scan();
  368. writeBit(repository, 1, true);
  369. scan();
  370. require(!readBit(repository, 10) && readBit(repository, 11),
  371. "the reverse start input must energize only M11 after stopping");
  372. writeBit(repository, 1, false);
  373. scan();
  374. require(!readBit(repository, 10) && readBit(repository, 11),
  375. "M11 must remain energized through the reverse self-hold branch");
  376. writeBit(repository, 0, true);
  377. scan();
  378. require(!readBit(repository, 10) && readBit(repository, 11),
  379. "the forward start input must be blocked while reverse is active");
  380. }
  381. void testAllComparisons()
  382. {
  383. const std::array<ComparisonOperator, 6> operations{
  384. ComparisonOperator::Equal, ComparisonOperator::NotEqual,
  385. ComparisonOperator::LessThan, ComparisonOperator::LessThanOrEqual,
  386. ComparisonOperator::GreaterThan, ComparisonOperator::GreaterThanOrEqual};
  387. const std::array<std::int16_t, 6> actual_values{10, 9, 9, 10, 11, 10};
  388. for (std::size_t index = 0; index < operations.size(); ++index)
  389. {
  390. VirtualRegisterRepository repository;
  391. SoftwareLogicExecutor executor;
  392. writeWord(repository, 0, actual_values[index]);
  393. const ControlLogic program = logic({
  394. rung("compare-rung", {{comparison("compare", 0, operations[index], 10)},
  395. }, coil("result", 10))});
  396. require(executor.executeScan({program}, repository).succeeded,
  397. "comparison scan must succeed");
  398. require(readBit(repository, 10), "comparison operator must evaluate true");
  399. }
  400. }
  401. void testSetResetAndDisabledLogic()
  402. {
  403. VirtualRegisterRepository repository;
  404. SoftwareLogicExecutor executor;
  405. ControlLogic program = logic({
  406. rung("set-rung", {{contact("set-input", 0)}}, coil("set-output", 5, CoilMode::Set)),
  407. rung("reset-rung", {{contact("reset-input", 1)}},
  408. coil("reset-output", 6, CoilMode::Reset))});
  409. writeBit(repository, 0, true);
  410. require(executor.executeScan({program}, repository).succeeded, "set scan must succeed");
  411. require(readBit(repository, 5), "set coil must latch true");
  412. writeBit(repository, 0, false);
  413. require(executor.executeScan({program}, repository).succeeded,
  414. "inactive set scan must succeed");
  415. require(readBit(repository, 5), "inactive set coil must retain its value");
  416. writeBit(repository, 1, true);
  417. writeBit(repository, 6, true);
  418. require(executor.executeScan({program}, repository).succeeded,
  419. "reset scan must succeed");
  420. require(!readBit(repository, 6), "reset coil must write false");
  421. program.enabled = false;
  422. writeBit(repository, 5, true);
  423. require(executor.executeScan({program}, repository).succeeded,
  424. "disabled logic scan must be ignored successfully");
  425. require(readBit(repository, 5), "disabled logic must not change outputs");
  426. }
  427. void testMultipleLogicScanOrderAndTraceIsolation()
  428. {
  429. VirtualRegisterRepository repository;
  430. SoftwareLogicExecutor executor;
  431. ControlLogic first = logic({
  432. rung("rung-1", {{contact("input", 0)}}, coil("output", 1))});
  433. first.id = "logic-first";
  434. first.name = "First";
  435. ControlLogic second = logic({
  436. rung("rung-1", {{contact("input", 1)}}, coil("output", 2))});
  437. second.id = "logic-second";
  438. second.name = "Second";
  439. writeBit(repository, 0, true);
  440. LogicTraceSnapshot trace;
  441. require(executor.executeScan({first, second}, repository, &trace).succeeded,
  442. "all enabled logic modules must execute in project order");
  443. require(readBit(repository, 1) && readBit(repository, 2),
  444. "a later logic module must observe an earlier module write in one scan");
  445. require(trace.logicValues.size() == 2U
  446. && trace.forLogic(first.id).rungValues.at("rung-1")
  447. && trace.forLogic(second.id).rungValues.at("rung-1"),
  448. "runtime traces must be partitioned by logic id when node ids repeat");
  449. ControlLogic disabled_draft;
  450. disabled_draft.id = "logic-draft";
  451. disabled_draft.name = "Draft";
  452. disabled_draft.enabled = false;
  453. LadderRung draft_rung;
  454. draft_rung.id = "rung-1";
  455. draft_rung.name = "Draft";
  456. disabled_draft.rungs.push_back(std::move(draft_rung));
  457. require(executor.validate({first, disabled_draft}).succeeded,
  458. "a disabled incomplete logic module must not block offline execution");
  459. }
  460. void testEdgeContactsAreOneScanPulsesAndAreLogicScoped()
  461. {
  462. VirtualRegisterRepository repository;
  463. SoftwareLogicExecutor executor;
  464. const ControlLogic program = logic({
  465. rung("rising-rung", {{edgeContact("rising", 0, EdgeMode::Rising)}},
  466. coil("rising-output", 10)),
  467. rung("falling-rung", {{edgeContact("falling", 1, EdgeMode::Falling)}},
  468. coil("falling-output", 11))});
  469. writeBit(repository, 0, true);
  470. require(executor.executeScan({program}, repository).succeeded,
  471. "the first high scan must evaluate rising edge input");
  472. require(readBit(repository, 10), "rising edge must pulse on OFF to ON");
  473. require(!readBit(repository, 11), "falling edge must stay off without ON to OFF");
  474. require(executor.executeScan({program}, repository).succeeded,
  475. "a repeated high scan must succeed");
  476. require(!readBit(repository, 10), "rising edge must clear after one scan");
  477. writeBit(repository, 1, true);
  478. require(executor.executeScan({program}, repository).succeeded,
  479. "the falling edge input must establish its previous high state");
  480. writeBit(repository, 1, false);
  481. require(executor.executeScan({program}, repository).succeeded,
  482. "the first low scan after high must succeed");
  483. require(readBit(repository, 11), "falling edge must pulse on ON to OFF");
  484. require(executor.executeScan({program}, repository).succeeded,
  485. "a repeated low scan must succeed");
  486. require(!readBit(repository, 11), "falling edge must clear after one scan");
  487. ControlLogic first = logic({
  488. rung("shared-rung-first", {{edgeContact("shared-edge", 2, EdgeMode::Rising)}},
  489. coil("shared-output-first", 20))});
  490. first.id = "logic-first";
  491. first.name = "First";
  492. ControlLogic second = logic({
  493. rung("shared-rung-second", {{edgeContact("shared-edge", 3, EdgeMode::Rising)}},
  494. coil("shared-output-second", 21))});
  495. second.id = "logic-second";
  496. second.name = "Second";
  497. writeBit(repository, 2, true);
  498. require(executor.executeScan({first, second}, repository).succeeded,
  499. "logic-scoped edge history must support duplicate node ids");
  500. writeBit(repository, 3, true);
  501. require(executor.executeScan({first, second}, repository).succeeded,
  502. "each duplicate edge node must update its own history");
  503. require(!readBit(repository, 20) && readBit(repository, 21),
  504. "duplicate node ids in different logic modules must not share edge history");
  505. executor.resetRuntime();
  506. require(executor.executeScan({first, second}, repository).succeeded,
  507. "resetting runtime must clear edge history");
  508. require(readBit(repository, 20) && readBit(repository, 21),
  509. "a restarted runtime must treat current ON inputs as fresh rising edges");
  510. }
  511. void testMoveAndSaturatingArithmetic()
  512. {
  513. VirtualRegisterRepository repository;
  514. SoftwareLogicExecutor executor;
  515. const ControlLogic program = logic({
  516. rung(
  517. "move-rung",
  518. {{contact("execute", 0)}},
  519. move("move", constantOperand(7), 10)),
  520. rung(
  521. "add-rung",
  522. {{contact("execute-add", 0)}},
  523. arithmetic(
  524. "add",
  525. ArithmeticOperation::Add,
  526. registerOperand(10),
  527. constantOperand(32767),
  528. 11)),
  529. rung(
  530. "sub-rung",
  531. {{contact("execute-sub", 0)}},
  532. arithmetic(
  533. "sub",
  534. ArithmeticOperation::Subtract,
  535. constantOperand(-32768),
  536. constantOperand(1),
  537. 12))});
  538. LogicTraceSnapshot trace;
  539. require(executor.executeScan({program}, repository, &trace).succeeded,
  540. "inactive data instructions must scan successfully");
  541. require(readWord(repository, 10) == 0,
  542. "MOVE must not write while its rung is false");
  543. writeBit(repository, 0, true);
  544. require(executor.executeScan({program}, repository, &trace).succeeded,
  545. "active data instructions must scan successfully");
  546. require(readWord(repository, 10) == 7,
  547. "MOVE must copy a constant into the destination D register");
  548. require(readWord(repository, 11) == 32767
  549. && trace.wordValues.at("add").overflow,
  550. "ADD must saturate positive overflow and expose the overflow trace");
  551. require(readWord(repository, 12) == -32768
  552. && trace.wordValues.at("sub").overflow,
  553. "SUB must saturate negative overflow and expose the overflow trace");
  554. const ControlLogic repeated_add = logic({
  555. rung(
  556. "repeated-add-rung",
  557. {{contact("repeated-add-input", 1)}},
  558. arithmetic(
  559. "repeated-add",
  560. ArithmeticOperation::Add,
  561. registerOperand(20),
  562. constantOperand(1),
  563. 20))});
  564. writeBit(repository, 1, true);
  565. require(executor.executeScan({repeated_add}, repository).succeeded
  566. && executor.executeScan({repeated_add}, repository).succeeded,
  567. "ADD must execute on every scan while its rung remains true");
  568. require(readWord(repository, 20) == 2,
  569. "ADD with the same source and destination must accumulate by scan");
  570. }
  571. void testSetResetPairOnSameAddress()
  572. {
  573. VirtualRegisterRepository repository;
  574. SoftwareLogicExecutor executor;
  575. const ControlLogic program = logic({
  576. rung("low-level-rung",
  577. {{comparison("low-level", 0,
  578. ComparisonOperator::LessThanOrEqual, 30)}},
  579. coil("pump-set", 20, CoilMode::Set)),
  580. rung("high-level-rung",
  581. {{comparison("high-level", 0,
  582. ComparisonOperator::GreaterThanOrEqual, 80)}},
  583. coil("pump-reset", 20, CoilMode::Reset))});
  584. writeWord(repository, 0, 20);
  585. require(executor.executeScan({program}, repository).succeeded,
  586. "set/reset pair scan must succeed at the low limit");
  587. require(readBit(repository, 20), "low level must latch the pump on");
  588. writeWord(repository, 0, 50);
  589. require(executor.executeScan({program}, repository).succeeded,
  590. "set/reset pair scan must succeed inside the deadband");
  591. require(readBit(repository, 20), "deadband must retain the latched on state");
  592. writeWord(repository, 0, 90);
  593. require(executor.executeScan({program}, repository).succeeded,
  594. "set/reset pair scan must succeed at the high limit");
  595. require(!readBit(repository, 20), "high level must reset the pump");
  596. writeWord(repository, 0, 50);
  597. require(executor.executeScan({program}, repository).succeeded,
  598. "set/reset pair rescan must succeed inside the deadband");
  599. require(!readBit(repository, 20), "deadband must retain the reset state");
  600. }
  601. void testConflictingCoilsAreRejected()
  602. {
  603. SoftwareLogicExecutor executor;
  604. const ControlLogic normal_and_set = logic({
  605. rung("normal-rung", {{contact("normal-input", 0)}}, coil("normal", 8)),
  606. rung("set-rung", {{contact("set-input", 1)}}, coil("set", 8, CoilMode::Set))});
  607. const LogicScanResult set_result = executor.validate({normal_and_set});
  608. require(!set_result.succeeded
  609. && set_result.error == LogicScanError::ConflictingOutput,
  610. "normal and set coils for one address must be rejected");
  611. require(set_result.rungId == "set-rung" && set_result.nodeId == "set",
  612. "conflict error must identify the offending rung and node");
  613. const ControlLogic reset_and_normal = logic({
  614. rung("reset-rung", {{contact("reset-input", 0)}},
  615. coil("reset", 9, CoilMode::Reset)),
  616. rung("normal-rung", {{contact("normal-input", 1)}}, coil("normal", 9))});
  617. const LogicScanResult reset_result = executor.validate({reset_and_normal});
  618. require(!reset_result.succeeded
  619. && reset_result.error == LogicScanError::ConflictingOutput,
  620. "reset and normal coils for one address must be rejected");
  621. }
  622. void testHmiSimulationClosedLoop()
  623. {
  624. VirtualRegisterRepository repository;
  625. HmiRuntimeService hmi(repository);
  626. SoftwareLogicExecutor executor;
  627. HmiControl start;
  628. start.id = "start-button";
  629. start.type = HmiControlType::Button;
  630. start.bounds = {0, 0, 80, 30};
  631. start.text = "start";
  632. start.binding = RegisterAddress{RegisterArea::M, 0};
  633. start.buttonOperation = HmiButtonOperation::MomentaryOn;
  634. HmiControl indicator;
  635. indicator.id = "run-indicator";
  636. indicator.type = HmiControlType::Indicator;
  637. indicator.bounds = {0, 40, 80, 30};
  638. indicator.text = "run";
  639. indicator.binding = RegisterAddress{RegisterArea::M, 2};
  640. LadderRung start_path = rung(
  641. "hold-start",
  642. {{contact("stop", 1, ContactMode::NormallyClosed)},
  643. {contact("start", 0)}},
  644. coil("run", 2));
  645. LadderRung feedback_path = rung(
  646. "hold-feedback",
  647. {{contact("feedback-padding", 4000)}, {contact("feedback", 2)}},
  648. coil("unused-output", 4000));
  649. feedback_path.cells[0].kind = LadderCellKind::Gap;
  650. feedback_path.cells[0].node.reset();
  651. feedback_path.output.reset();
  652. ControlLogic program = logic({start_path, feedback_path});
  653. program.verticalConnections = {
  654. {"hold-left", "hold-start", "hold-feedback", 1},
  655. {"hold-right", "hold-start", "hold-feedback", 2}};
  656. require(hmi.operateButton(start, HmiButtonEvent::Pressed).succeeded,
  657. "HMI start button press must write virtual M");
  658. require(executor.executeScan({program}, repository).succeeded,
  659. "closed-loop scan must succeed");
  660. require(hmi.readControl(indicator).bit_value,
  661. "HMI indicator must observe the logic output");
  662. require(hmi.operateButton(start, HmiButtonEvent::Released).succeeded,
  663. "HMI start button release must write virtual M");
  664. require(executor.executeScan({program}, repository).succeeded, "holding scan must succeed");
  665. require(hmi.readControl(indicator).bit_value,
  666. "feedback contact must hold the output after start turns off");
  667. writeBit(repository, 1, true);
  668. require(executor.executeScan({program}, repository).succeeded, "stop scan must succeed");
  669. require(!hmi.readControl(indicator).bit_value,
  670. "stop contact must clear the HMI run indication");
  671. }
  672. void testSimulationLifecycleSnapshotAndFault()
  673. {
  674. VirtualRegisterRepository repository;
  675. OfflineSimulationService simulation(repository);
  676. ControlLogic program = logic({
  677. rung("snapshot-rung", {{contact("input", 0)}}, coil("output", 1))});
  678. writeBit(repository, 0, true);
  679. require(simulation.start({program}).succeeded, "simulation must start");
  680. require(simulation.state() == SimulationState::Running, "simulation must report running");
  681. require(!readBit(repository, 0), "starting a session must clear virtual registers");
  682. writeBit(repository, 0, true);
  683. program.rungs.front().output = coil("changed-output", 9);
  684. require(simulation.executeOnce().succeeded, "manual lifecycle scan must succeed");
  685. require(readBit(repository, 1), "simulation must use its start-time snapshot");
  686. require(simulation.successfulScanCount() == 1, "successful scans must be counted");
  687. simulation.stop();
  688. require(simulation.state() == SimulationState::Stopped, "simulation must stop");
  689. require(simulation.start({program}).succeeded, "stopped simulation must support restart");
  690. require(!readBit(repository, 1), "new session must clear prior output values");
  691. simulation.stop();
  692. }
  693. void testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs()
  694. {
  695. VirtualRegisterRepository repository;
  696. VirtualRegisterRepository initial_repository;
  697. OfflineSimulationService simulation(repository, &initial_repository);
  698. const ControlLogic program = logic({
  699. rung("initial-rung", {{contact("initial-input", 0)}}, coil("runtime-output", 1))});
  700. writeBit(initial_repository, 0, true);
  701. writeWord(initial_repository, 10, 321);
  702. const std::array<std::int16_t, 2> initial_int32 =
  703. Int32Codec::encode(0x12345678);
  704. const std::array<std::int16_t, 4> initial_double =
  705. Float64Codec::encode(1.25);
  706. initial_repository.writeWords(
  707. {RegisterArea::D, 20}, {initial_int32[0], initial_int32[1]});
  708. initial_repository.writeWords(
  709. {RegisterArea::D, 30},
  710. {initial_double[0], initial_double[1],
  711. initial_double[2], initial_double[3]});
  712. require(simulation.start({program}).succeeded,
  713. "simulation must start from the explicit initial repository");
  714. require(readBit(repository, 0)
  715. && readWord(repository, 10) == 321
  716. && repository.readWords({RegisterArea::D, 20}, 2).values
  717. == std::vector<std::int16_t>{
  718. initial_int32[0], initial_int32[1]}
  719. && repository.readWords({RegisterArea::D, 30}, 4).values
  720. == std::vector<std::int16_t>{
  721. initial_double[0], initial_double[1],
  722. initial_double[2], initial_double[3]},
  723. "simulation startup must copy single-word and multi-word initial values");
  724. require(simulation.executeOnce().succeeded && readBit(repository, 1),
  725. "simulation must execute against the copied initial values");
  726. repository.writeWords({RegisterArea::D, 20}, {0, 0});
  727. repository.writeWords({RegisterArea::D, 30}, {0, 0, 0, 0});
  728. simulation.stop();
  729. require(!readBit(repository, 1)
  730. && readBit(repository, 0)
  731. && readWord(repository, 10) == 321
  732. && repository.readWords({RegisterArea::D, 20}, 2).values
  733. == std::vector<std::int16_t>{
  734. initial_int32[0], initial_int32[1]}
  735. && repository.readWords({RegisterArea::D, 30}, 4).values
  736. == std::vector<std::int16_t>{
  737. initial_double[0], initial_double[1],
  738. initial_double[2], initial_double[3]},
  739. "stopping simulation must restore complete multi-word initial values");
  740. writeBit(initial_repository, 0, false);
  741. require(simulation.start({program}).succeeded,
  742. "simulation must support restart with changed initial values");
  743. require(!readBit(repository, 0) && !readBit(repository, 1),
  744. "restart must use the latest initial values and discard the previous output");
  745. simulation.stop();
  746. }
  747. class FailingVirtualRegisterRepository final : public VirtualRegisterRepository
  748. {
  749. public:
  750. BitReadResult readBit(const RegisterAddress &) const override
  751. {
  752. return {false, false, RegisterError::Unavailable};
  753. }
  754. };
  755. void testRepositoryFailureEntersFaultState()
  756. {
  757. FailingVirtualRegisterRepository repository;
  758. OfflineSimulationService simulation(repository);
  759. const ControlLogic program = logic({
  760. rung("fault-rung", {{contact("fault-input", 0)}}, coil("fault-output", 1))});
  761. require(simulation.start({program}).succeeded,
  762. "valid logic must pass simulation startup");
  763. const LogicScanResult result = simulation.executeOnce();
  764. require(!result.succeeded && result.error == LogicScanError::RegisterReadFailed,
  765. "repository read failure must fail the scan");
  766. require(simulation.state() == SimulationState::Faulted,
  767. "repository failure must enter the fault state");
  768. require(simulation.lastError().logicId == "logic-1"
  769. && simulation.lastError().rungId == "fault-rung"
  770. && simulation.lastError().nodeId == "fault-input",
  771. "fault feedback must retain logic, rung and node context");
  772. }
  773. void testOnlineMonitorUsesTemporaryRegistersWithoutWritingPlcSource()
  774. {
  775. VirtualRegisterRepository plc_source;
  776. OnlineLogicMonitorService monitor(plc_source);
  777. const ControlLogic program = logic({
  778. rung("online-rung-1", {{contact("online-input", 0)}},
  779. coil("online-output-1", 1)),
  780. rung("online-rung-2", {{contact("online-feedback", 1)}},
  781. coil("online-output-2", 2))});
  782. writeBit(plc_source, 0, true);
  783. require(monitor.start({program}).succeeded,
  784. "online monitor must start from the PLC source snapshot");
  785. const LogicTraceSnapshot &first_trace = monitor.traceSnapshot();
  786. require(first_trace.rungValues.at("online-rung-1")
  787. && first_trace.rungValues.at("online-rung-2"),
  788. "a local output must be visible to later rungs in the same temporary scan");
  789. require(!readBit(plc_source, 1) && !readBit(plc_source, 2),
  790. "local trace outputs must never change the PLC source repository");
  791. writeBit(plc_source, 0, false);
  792. require(monitor.executeOnce().succeeded,
  793. "a new PLC snapshot must support another local trace scan");
  794. const LogicTraceSnapshot &second_trace = monitor.traceSnapshot();
  795. require(!second_trace.rungValues.at("online-rung-1")
  796. && !second_trace.rungValues.at("online-rung-2"),
  797. "each local scan must restart from the latest PLC source values");
  798. require(!readBit(plc_source, 1) && !readBit(plc_source, 2),
  799. "repeated local scans must remain read-only toward the PLC source");
  800. }
  801. void testOnlineMonitorPreservesFaultAfterStopping()
  802. {
  803. FailingVirtualRegisterRepository plc_source;
  804. OnlineLogicMonitorService monitor(plc_source);
  805. const ControlLogic program = logic({
  806. rung("online-fault-rung", {{contact("online-fault-input", 0)}},
  807. coil("online-fault-output", 1))});
  808. const OnlineLogicMonitorStartResult start = monitor.start({program});
  809. require(!start.succeeded
  810. && start.detail.error == LogicScanError::RegisterReadFailed,
  811. "an online snapshot failure must be reported at startup");
  812. monitor.stop();
  813. require(monitor.state() == OnlineLogicMonitorState::Stopped,
  814. "stopping a failed online monitor must leave it stopped");
  815. require(!monitor.lastError().succeeded
  816. && monitor.lastError().error == LogicScanError::RegisterReadFailed,
  817. "stopping after a local trace fault must preserve its diagnostic");
  818. }
  819. } // namespace
  820. int main(int argc, char *argv[])
  821. {
  822. QCoreApplication application(argc, argv);
  823. return TestSupport::runTestSuite("offline simulation service tests", {
  824. {"testSeriesParallelContactsAndSequentialVisibility", testSeriesParallelContactsAndSequentialVisibility},
  825. {"testParallelRowsAndColumnPropagation", testParallelRowsAndColumnPropagation},
  826. {"testUnconditionalCoil", testUnconditionalCoil},
  827. {"testEnabledEmptyRowIsRejectedBeforeScanning", testEnabledEmptyRowIsRejectedBeforeScanning},
  828. {"testWirePassThroughAndPowerTrace", testWirePassThroughAndPowerTrace},
  829. {"testMotorForwardReverseSelfHoldAndInterlockTruthTable", testMotorForwardReverseSelfHoldAndInterlockTruthTable},
  830. {"testAllComparisons", testAllComparisons},
  831. {"testSetResetAndDisabledLogic", testSetResetAndDisabledLogic},
  832. {"testMultipleLogicScanOrderAndTraceIsolation", testMultipleLogicScanOrderAndTraceIsolation},
  833. {"testEdgeContactsAreOneScanPulsesAndAreLogicScoped", testEdgeContactsAreOneScanPulsesAndAreLogicScoped},
  834. {"testMoveAndSaturatingArithmetic", testMoveAndSaturatingArithmetic},
  835. {"testSetResetPairOnSameAddress", testSetResetPairOnSameAddress},
  836. {"testConflictingCoilsAreRejected", testConflictingCoilsAreRejected},
  837. {"testHmiSimulationClosedLoop", testHmiSimulationClosedLoop},
  838. {"testSimulationLifecycleSnapshotAndFault", testSimulationLifecycleSnapshotAndFault},
  839. {"testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs", testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs},
  840. {"testRepositoryFailureEntersFaultState", testRepositoryFailureEntersFaultState},
  841. {"testOnlineMonitorUsesTemporaryRegistersWithoutWritingPlcSource", testOnlineMonitorUsesTemporaryRegistersWithoutWritingPlcSource},
  842. {"testOnlineMonitorPreservesFaultAfterStopping", testOnlineMonitorPreservesFaultAfterStopping},
  843. });
  844. }