综合平台编程器项目的远程存储
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

807 rivejä
33 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. std::vector<ConditionExpression> series_children;
  92. for (std::size_t index = 0; index < stages.size(); ++index)
  93. {
  94. std::vector<ConditionExpression> parallel_children;
  95. for (const LogicNode &node : stages[index])
  96. {
  97. parallel_children.push_back(ConditionExpression::fromNode(node));
  98. }
  99. if (parallel_children.size() == 1U)
  100. {
  101. series_children.push_back(std::move(parallel_children.front()));
  102. }
  103. else
  104. {
  105. ConditionExpression parallel;
  106. parallel.id = id + "-parallel-" + std::to_string(index);
  107. parallel.kind = ConditionExpressionKind::Parallel;
  108. parallel.children = std::move(parallel_children);
  109. series_children.push_back(std::move(parallel));
  110. }
  111. }
  112. if (series_children.size() == 1U)
  113. {
  114. result.condition = std::move(series_children.front());
  115. }
  116. else
  117. {
  118. ConditionExpression series;
  119. series.id = id + "-series";
  120. series.kind = ConditionExpressionKind::Series;
  121. series.children = std::move(series_children);
  122. result.condition = std::move(series);
  123. }
  124. const int occupied_columns = static_cast<int>(stages.size());
  125. if (occupied_columns < ProjectLimits::kMaximumConditionColumns)
  126. {
  127. ConditionExpression wire = ConditionExpression::fromWire(
  128. id + "-output-wire",
  129. ProjectLimits::kMaximumConditionColumns - occupied_columns);
  130. if (result.condition->kind == ConditionExpressionKind::Series)
  131. {
  132. result.condition->children.push_back(std::move(wire));
  133. }
  134. else
  135. {
  136. ConditionExpression series;
  137. series.id = id + "-explicit-series";
  138. series.kind = ConditionExpressionKind::Series;
  139. series.children.push_back(std::move(*result.condition));
  140. series.children.push_back(std::move(wire));
  141. result.condition = std::move(series);
  142. }
  143. }
  144. result.output = output;
  145. return result;
  146. }
  147. ControlLogic logic(const std::vector<LadderRung> &rungs)
  148. {
  149. return {"logic-1", "logic-1", rungs, true};
  150. }
  151. bool readBit(RegisterRepository &repository, int address)
  152. {
  153. const BitReadResult result = repository.readBit(
  154. RegisterAddress{RegisterArea::M, address});
  155. require(result.succeeded, "test register read must succeed");
  156. return result.value;
  157. }
  158. void writeBit(RegisterRepository &repository, int address, bool value)
  159. {
  160. require(repository.writeBit(RegisterAddress{RegisterArea::M, address}, value).succeeded,
  161. "test bit write must succeed");
  162. }
  163. void writeWord(RegisterRepository &repository, int address, std::int16_t value)
  164. {
  165. require(repository.writeWord(RegisterAddress{RegisterArea::D, address}, value).succeeded,
  166. "test word write must succeed");
  167. }
  168. std::int16_t readWord(RegisterRepository &repository, int address)
  169. {
  170. const WordReadResult result = repository.readWord(
  171. RegisterAddress{RegisterArea::D, address});
  172. require(result.succeeded, "test word read must succeed");
  173. return result.value;
  174. }
  175. void testNestedSeriesParallelExpression()
  176. {
  177. VirtualRegisterRepository repository;
  178. SoftwareLogicExecutor executor;
  179. ConditionExpression nested_series;
  180. nested_series.id = "nested-series";
  181. nested_series.kind = ConditionExpressionKind::Series;
  182. nested_series.children = {
  183. ConditionExpression::fromNode(contact("b", 1)),
  184. ConditionExpression::fromNode(contact("c", 2))};
  185. ConditionExpression padded_a;
  186. padded_a.id = "padded-a";
  187. padded_a.kind = ConditionExpressionKind::Series;
  188. padded_a.children = {
  189. ConditionExpression::fromNode(contact("a", 0)),
  190. ConditionExpression::fromWire("a-branch-wire", 1)};
  191. ConditionExpression parallel;
  192. parallel.id = "root-parallel";
  193. parallel.kind = ConditionExpressionKind::Parallel;
  194. parallel.children = {std::move(padded_a), nested_series};
  195. ConditionExpression root;
  196. root.id = "nested-output-series";
  197. root.kind = ConditionExpressionKind::Series;
  198. root.children = {
  199. std::move(parallel),
  200. ConditionExpression::fromWire("nested-output-wire", 8)};
  201. LadderRung nested_rung;
  202. nested_rung.id = "nested-rung";
  203. nested_rung.name = "nested-rung";
  204. nested_rung.condition = root;
  205. nested_rung.output = coil("nested-output", 10);
  206. const ControlLogic program = logic({nested_rung});
  207. writeBit(repository, 1, true);
  208. writeBit(repository, 2, true);
  209. LogicTraceSnapshot trace;
  210. require(executor.executeScan({program}, repository, &trace).succeeded,
  211. "nested expression scan must succeed");
  212. require(readBit(repository, 10), "B AND C branch must energize A OR (B AND C)");
  213. require(trace.expressionValues.at("nested-series")
  214. && trace.expressionValues.at("root-parallel")
  215. && trace.rungValues.at("nested-rung"),
  216. "scan trace must expose active nested expression and rung values");
  217. writeBit(repository, 2, false);
  218. require(executor.executeScan({program}, repository, &trace).succeeded,
  219. "nested false scan must succeed");
  220. require(!readBit(repository, 10), "incomplete B AND C branch must be false");
  221. writeBit(repository, 0, true);
  222. require(executor.executeScan({program}, repository, &trace).succeeded,
  223. "alternate branch scan must succeed");
  224. require(readBit(repository, 10), "A branch must independently energize output");
  225. }
  226. void testUnconditionalCoil()
  227. {
  228. VirtualRegisterRepository repository;
  229. SoftwareLogicExecutor executor;
  230. LadderRung unconditional;
  231. unconditional.id = "unconditional-rung";
  232. unconditional.name = "unconditional-rung";
  233. unconditional.condition = ConditionExpression::fromWire(
  234. "unconditional-wire", ProjectLimits::kMaximumConditionColumns);
  235. unconditional.output = coil("unconditional-coil", 10);
  236. const ControlLogic program = logic({unconditional});
  237. LogicTraceSnapshot trace;
  238. require(executor.validate({program}).succeeded,
  239. "a full-width wire network must pass runtime validation");
  240. require(executor.executeScan({program}, repository, &trace).succeeded,
  241. "a full-width wire network scan must succeed");
  242. require(readBit(repository, 10),
  243. "a full-width wire network must energize its coil as a constant-true rung");
  244. require(trace.rungValues.at("unconditional-rung")
  245. && trace.nodePowerValues.at("unconditional-coil"),
  246. "an unconditional rung must report energized power flow");
  247. }
  248. void testWirePassThroughAndPowerTrace()
  249. {
  250. VirtualRegisterRepository repository;
  251. SoftwareLogicExecutor executor;
  252. ConditionExpression root;
  253. root.id = "wire-series";
  254. root.kind = ConditionExpressionKind::Series;
  255. root.children = {
  256. ConditionExpression::fromNode(contact("wire-input", 0)),
  257. ConditionExpression::fromWire("wire-segment", 2),
  258. ConditionExpression::fromNode(contact("wire-output", 1)),
  259. ConditionExpression::fromWire("wire-output-padding", 6)};
  260. LadderRung wired_rung;
  261. wired_rung.id = "wired-rung";
  262. wired_rung.name = "wired-rung";
  263. wired_rung.condition = root;
  264. wired_rung.output = coil("wired-coil", 10);
  265. const ControlLogic program = logic({wired_rung});
  266. writeBit(repository, 1, true);
  267. LogicTraceSnapshot trace;
  268. require(executor.executeScan({program}, repository, &trace).succeeded,
  269. "wire expression scan must succeed");
  270. require(!readBit(repository, 10),
  271. "a horizontal wire must not bypass a false upstream series contact");
  272. require(trace.expressionValues.at("wire-segment")
  273. && !trace.expressionInputValues.at("wire-segment")
  274. && !trace.expressionPowerValues.at("wire-segment"),
  275. "a wire must remain logically true without showing false upstream power");
  276. writeBit(repository, 0, true);
  277. require(executor.executeScan({program}, repository, &trace).succeeded,
  278. "powered wire expression scan must succeed");
  279. require(readBit(repository, 10)
  280. && trace.expressionInputValues.at("wire-segment")
  281. && trace.expressionPowerValues.at("wire-segment"),
  282. "a powered horizontal wire must pass current to the downstream contact");
  283. }
  284. void testSeriesParallelContactsAndSequentialVisibility()
  285. {
  286. VirtualRegisterRepository repository;
  287. SoftwareLogicExecutor executor;
  288. const ControlLogic program = logic({
  289. rung("rung-1",
  290. {{contact("start", 0), contact("alternate", 1)},
  291. {contact("stop", 2, ContactMode::NormallyClosed)}},
  292. coil("run", 3)),
  293. rung("rung-2", {{contact("run-feedback", 3)}}, coil("downstream", 4))});
  294. writeBit(repository, 1, true);
  295. require(executor.executeScan({program}, repository).succeeded,
  296. "parallel and series scan must succeed");
  297. require(readBit(repository, 3), "parallel OR and series AND must energize output");
  298. require(readBit(repository, 4),
  299. "a later rung must see an earlier rung write in the same scan");
  300. writeBit(repository, 2, true);
  301. require(executor.executeScan({program}, repository).succeeded,
  302. "normally closed scan must succeed");
  303. require(!readBit(repository, 3), "normally closed stop contact must open the rung");
  304. require(!readBit(repository, 4), "downstream normal coil must follow the new value");
  305. }
  306. void testAllComparisons()
  307. {
  308. const std::array<ComparisonOperator, 6> operations{
  309. ComparisonOperator::Equal, ComparisonOperator::NotEqual,
  310. ComparisonOperator::LessThan, ComparisonOperator::LessThanOrEqual,
  311. ComparisonOperator::GreaterThan, ComparisonOperator::GreaterThanOrEqual};
  312. const std::array<std::int16_t, 6> actual_values{10, 9, 9, 10, 11, 10};
  313. for (std::size_t index = 0; index < operations.size(); ++index)
  314. {
  315. VirtualRegisterRepository repository;
  316. SoftwareLogicExecutor executor;
  317. writeWord(repository, 0, actual_values[index]);
  318. const ControlLogic program = logic({
  319. rung("compare-rung", {{comparison("compare", 0, operations[index], 10)},
  320. }, coil("result", 10))});
  321. require(executor.executeScan({program}, repository).succeeded,
  322. "comparison scan must succeed");
  323. require(readBit(repository, 10), "comparison operator must evaluate true");
  324. }
  325. }
  326. void testSetResetAndDisabledLogic()
  327. {
  328. VirtualRegisterRepository repository;
  329. SoftwareLogicExecutor executor;
  330. ControlLogic program = logic({
  331. rung("set-rung", {{contact("set-input", 0)}}, coil("set-output", 5, CoilMode::Set)),
  332. rung("reset-rung", {{contact("reset-input", 1)}},
  333. coil("reset-output", 6, CoilMode::Reset))});
  334. writeBit(repository, 0, true);
  335. require(executor.executeScan({program}, repository).succeeded, "set scan must succeed");
  336. require(readBit(repository, 5), "set coil must latch true");
  337. writeBit(repository, 0, false);
  338. require(executor.executeScan({program}, repository).succeeded,
  339. "inactive set scan must succeed");
  340. require(readBit(repository, 5), "inactive set coil must retain its value");
  341. writeBit(repository, 1, true);
  342. writeBit(repository, 6, true);
  343. require(executor.executeScan({program}, repository).succeeded,
  344. "reset scan must succeed");
  345. require(!readBit(repository, 6), "reset coil must write false");
  346. program.enabled = false;
  347. writeBit(repository, 5, true);
  348. require(executor.executeScan({program}, repository).succeeded,
  349. "disabled logic scan must be ignored successfully");
  350. require(readBit(repository, 5), "disabled logic must not change outputs");
  351. }
  352. void testMultipleLogicScanOrderAndTraceIsolation()
  353. {
  354. VirtualRegisterRepository repository;
  355. SoftwareLogicExecutor executor;
  356. ControlLogic first = logic({
  357. rung("rung-1", {{contact("input", 0)}}, coil("output", 1))});
  358. first.id = "logic-first";
  359. first.name = "First";
  360. ControlLogic second = logic({
  361. rung("rung-1", {{contact("input", 1)}}, coil("output", 2))});
  362. second.id = "logic-second";
  363. second.name = "Second";
  364. writeBit(repository, 0, true);
  365. LogicTraceSnapshot trace;
  366. require(executor.executeScan({first, second}, repository, &trace).succeeded,
  367. "all enabled logic modules must execute in project order");
  368. require(readBit(repository, 1) && readBit(repository, 2),
  369. "a later logic module must observe an earlier module write in one scan");
  370. require(trace.logicValues.size() == 2U
  371. && trace.forLogic(first.id).rungValues.at("rung-1")
  372. && trace.forLogic(second.id).rungValues.at("rung-1"),
  373. "runtime traces must be partitioned by logic id when node ids repeat");
  374. ControlLogic disabled_draft;
  375. disabled_draft.id = "logic-draft";
  376. disabled_draft.name = "Draft";
  377. disabled_draft.enabled = false;
  378. disabled_draft.rungs.push_back(
  379. {"rung-1", "Draft", {}, std::nullopt, std::nullopt});
  380. require(executor.validate({first, disabled_draft}).succeeded,
  381. "a disabled incomplete logic module must not block offline execution");
  382. }
  383. void testEdgeContactsAreOneScanPulsesAndAreLogicScoped()
  384. {
  385. VirtualRegisterRepository repository;
  386. SoftwareLogicExecutor executor;
  387. const ControlLogic program = logic({
  388. rung("rising-rung", {{edgeContact("rising", 0, EdgeMode::Rising)}},
  389. coil("rising-output", 10)),
  390. rung("falling-rung", {{edgeContact("falling", 1, EdgeMode::Falling)}},
  391. coil("falling-output", 11))});
  392. writeBit(repository, 0, true);
  393. require(executor.executeScan({program}, repository).succeeded,
  394. "the first high scan must evaluate rising edge input");
  395. require(readBit(repository, 10), "rising edge must pulse on OFF to ON");
  396. require(!readBit(repository, 11), "falling edge must stay off without ON to OFF");
  397. require(executor.executeScan({program}, repository).succeeded,
  398. "a repeated high scan must succeed");
  399. require(!readBit(repository, 10), "rising edge must clear after one scan");
  400. writeBit(repository, 1, true);
  401. require(executor.executeScan({program}, repository).succeeded,
  402. "the falling edge input must establish its previous high state");
  403. writeBit(repository, 1, false);
  404. require(executor.executeScan({program}, repository).succeeded,
  405. "the first low scan after high must succeed");
  406. require(readBit(repository, 11), "falling edge must pulse on ON to OFF");
  407. require(executor.executeScan({program}, repository).succeeded,
  408. "a repeated low scan must succeed");
  409. require(!readBit(repository, 11), "falling edge must clear after one scan");
  410. ControlLogic first = logic({
  411. rung("shared-rung-first", {{edgeContact("shared-edge", 2, EdgeMode::Rising)}},
  412. coil("shared-output-first", 20))});
  413. first.id = "logic-first";
  414. first.name = "First";
  415. ControlLogic second = logic({
  416. rung("shared-rung-second", {{edgeContact("shared-edge", 3, EdgeMode::Rising)}},
  417. coil("shared-output-second", 21))});
  418. second.id = "logic-second";
  419. second.name = "Second";
  420. writeBit(repository, 2, true);
  421. require(executor.executeScan({first, second}, repository).succeeded,
  422. "logic-scoped edge history must support duplicate node ids");
  423. writeBit(repository, 3, true);
  424. require(executor.executeScan({first, second}, repository).succeeded,
  425. "each duplicate edge node must update its own history");
  426. require(!readBit(repository, 20) && readBit(repository, 21),
  427. "duplicate node ids in different logic modules must not share edge history");
  428. executor.resetRuntime();
  429. require(executor.executeScan({first, second}, repository).succeeded,
  430. "resetting runtime must clear edge history");
  431. require(readBit(repository, 20) && readBit(repository, 21),
  432. "a restarted runtime must treat current ON inputs as fresh rising edges");
  433. }
  434. void testMoveAndSaturatingArithmetic()
  435. {
  436. VirtualRegisterRepository repository;
  437. SoftwareLogicExecutor executor;
  438. const ControlLogic program = logic({
  439. rung(
  440. "move-rung",
  441. {{contact("execute", 0)}},
  442. move("move", constantOperand(7), 10)),
  443. rung(
  444. "add-rung",
  445. {{contact("execute-add", 0)}},
  446. arithmetic(
  447. "add",
  448. ArithmeticOperation::Add,
  449. registerOperand(10),
  450. constantOperand(32767),
  451. 11)),
  452. rung(
  453. "sub-rung",
  454. {{contact("execute-sub", 0)}},
  455. arithmetic(
  456. "sub",
  457. ArithmeticOperation::Subtract,
  458. constantOperand(-32768),
  459. constantOperand(1),
  460. 12))});
  461. LogicTraceSnapshot trace;
  462. require(executor.executeScan({program}, repository, &trace).succeeded,
  463. "inactive data instructions must scan successfully");
  464. require(readWord(repository, 10) == 0,
  465. "MOVE must not write while its rung is false");
  466. writeBit(repository, 0, true);
  467. require(executor.executeScan({program}, repository, &trace).succeeded,
  468. "active data instructions must scan successfully");
  469. require(readWord(repository, 10) == 7,
  470. "MOVE must copy a constant into the destination D register");
  471. require(readWord(repository, 11) == 32767
  472. && trace.wordValues.at("add").overflow,
  473. "ADD must saturate positive overflow and expose the overflow trace");
  474. require(readWord(repository, 12) == -32768
  475. && trace.wordValues.at("sub").overflow,
  476. "SUB must saturate negative overflow and expose the overflow trace");
  477. const ControlLogic repeated_add = logic({
  478. rung(
  479. "repeated-add-rung",
  480. {{contact("repeated-add-input", 1)}},
  481. arithmetic(
  482. "repeated-add",
  483. ArithmeticOperation::Add,
  484. registerOperand(20),
  485. constantOperand(1),
  486. 20))});
  487. writeBit(repository, 1, true);
  488. require(executor.executeScan({repeated_add}, repository).succeeded
  489. && executor.executeScan({repeated_add}, repository).succeeded,
  490. "ADD must execute on every scan while its rung remains true");
  491. require(readWord(repository, 20) == 2,
  492. "ADD with the same source and destination must accumulate by scan");
  493. }
  494. void testSetResetPairOnSameAddress()
  495. {
  496. VirtualRegisterRepository repository;
  497. SoftwareLogicExecutor executor;
  498. const ControlLogic program = logic({
  499. rung("low-level-rung",
  500. {{comparison("low-level", 0,
  501. ComparisonOperator::LessThanOrEqual, 30)}},
  502. coil("pump-set", 20, CoilMode::Set)),
  503. rung("high-level-rung",
  504. {{comparison("high-level", 0,
  505. ComparisonOperator::GreaterThanOrEqual, 80)}},
  506. coil("pump-reset", 20, CoilMode::Reset))});
  507. writeWord(repository, 0, 20);
  508. require(executor.executeScan({program}, repository).succeeded,
  509. "set/reset pair scan must succeed at the low limit");
  510. require(readBit(repository, 20), "low level must latch the pump on");
  511. writeWord(repository, 0, 50);
  512. require(executor.executeScan({program}, repository).succeeded,
  513. "set/reset pair scan must succeed inside the deadband");
  514. require(readBit(repository, 20), "deadband must retain the latched on state");
  515. writeWord(repository, 0, 90);
  516. require(executor.executeScan({program}, repository).succeeded,
  517. "set/reset pair scan must succeed at the high limit");
  518. require(!readBit(repository, 20), "high level must reset the pump");
  519. writeWord(repository, 0, 50);
  520. require(executor.executeScan({program}, repository).succeeded,
  521. "set/reset pair rescan must succeed inside the deadband");
  522. require(!readBit(repository, 20), "deadband must retain the reset state");
  523. }
  524. void testConflictingCoilsAreRejected()
  525. {
  526. SoftwareLogicExecutor executor;
  527. const ControlLogic normal_and_set = logic({
  528. rung("normal-rung", {{contact("normal-input", 0)}}, coil("normal", 8)),
  529. rung("set-rung", {{contact("set-input", 1)}}, coil("set", 8, CoilMode::Set))});
  530. const LogicScanResult set_result = executor.validate({normal_and_set});
  531. require(!set_result.succeeded
  532. && set_result.error == LogicScanError::ConflictingOutput,
  533. "normal and set coils for one address must be rejected");
  534. require(set_result.rungId == "set-rung" && set_result.nodeId == "set",
  535. "conflict error must identify the offending rung and node");
  536. const ControlLogic reset_and_normal = logic({
  537. rung("reset-rung", {{contact("reset-input", 0)}},
  538. coil("reset", 9, CoilMode::Reset)),
  539. rung("normal-rung", {{contact("normal-input", 1)}}, coil("normal", 9))});
  540. const LogicScanResult reset_result = executor.validate({reset_and_normal});
  541. require(!reset_result.succeeded
  542. && reset_result.error == LogicScanError::ConflictingOutput,
  543. "reset and normal coils for one address must be rejected");
  544. }
  545. void testHmiSimulationClosedLoop()
  546. {
  547. VirtualRegisterRepository repository;
  548. HmiRuntimeService hmi(repository);
  549. SoftwareLogicExecutor executor;
  550. HmiControl start;
  551. start.id = "start-button";
  552. start.type = HmiControlType::Button;
  553. start.bounds = {0, 0, 80, 30};
  554. start.text = "start";
  555. start.binding = RegisterAddress{RegisterArea::M, 0};
  556. start.buttonOperation = HmiButtonOperation::MomentaryOn;
  557. HmiControl indicator;
  558. indicator.id = "run-indicator";
  559. indicator.type = HmiControlType::Indicator;
  560. indicator.bounds = {0, 40, 80, 30};
  561. indicator.text = "run";
  562. indicator.binding = RegisterAddress{RegisterArea::M, 2};
  563. const ControlLogic program = logic({
  564. rung("hold-rung",
  565. {{contact("stop", 1, ContactMode::NormallyClosed)},
  566. {contact("start", 0), contact("feedback", 2)}},
  567. coil("run", 2))});
  568. require(hmi.operateButton(start, HmiButtonEvent::Pressed).succeeded,
  569. "HMI start button press must write virtual M");
  570. require(executor.executeScan({program}, repository).succeeded,
  571. "closed-loop scan must succeed");
  572. require(hmi.readControl(indicator).bit_value,
  573. "HMI indicator must observe the logic output");
  574. require(hmi.operateButton(start, HmiButtonEvent::Released).succeeded,
  575. "HMI start button release must write virtual M");
  576. require(executor.executeScan({program}, repository).succeeded, "holding scan must succeed");
  577. require(hmi.readControl(indicator).bit_value,
  578. "feedback contact must hold the output after start turns off");
  579. writeBit(repository, 1, true);
  580. require(executor.executeScan({program}, repository).succeeded, "stop scan must succeed");
  581. require(!hmi.readControl(indicator).bit_value,
  582. "stop contact must clear the HMI run indication");
  583. }
  584. void testSimulationLifecycleSnapshotAndFault()
  585. {
  586. VirtualRegisterRepository repository;
  587. OfflineSimulationService simulation(repository);
  588. ControlLogic program = logic({
  589. rung("snapshot-rung", {{contact("input", 0)}}, coil("output", 1))});
  590. writeBit(repository, 0, true);
  591. require(simulation.start({program}).succeeded, "simulation must start");
  592. require(simulation.state() == SimulationState::Running, "simulation must report running");
  593. require(!readBit(repository, 0), "starting a session must clear virtual registers");
  594. require(simulation.scanIntervalMs() == 50, "default scan interval must be 50 ms");
  595. writeBit(repository, 0, true);
  596. program.rungs.front().output = coil("changed-output", 9);
  597. require(simulation.executeOnce().succeeded, "manual lifecycle scan must succeed");
  598. require(readBit(repository, 1), "simulation must use its start-time snapshot");
  599. require(simulation.successfulScanCount() == 1, "successful scans must be counted");
  600. simulation.stop();
  601. require(simulation.state() == SimulationState::Stopped, "simulation must stop");
  602. require(simulation.start({program}).succeeded, "stopped simulation must support restart");
  603. require(!readBit(repository, 1), "new session must clear prior output values");
  604. simulation.stop();
  605. }
  606. void testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs()
  607. {
  608. VirtualRegisterRepository repository;
  609. VirtualRegisterRepository initial_repository;
  610. OfflineSimulationService simulation(repository, &initial_repository);
  611. const ControlLogic program = logic({
  612. rung("initial-rung", {{contact("initial-input", 0)}}, coil("runtime-output", 1))});
  613. writeBit(initial_repository, 0, true);
  614. writeWord(initial_repository, 10, 321);
  615. require(simulation.start({program}).succeeded,
  616. "simulation must start from the explicit initial repository");
  617. require(readBit(repository, 0)
  618. && readWord(repository, 10) == 321,
  619. "simulation startup must copy initial M/D values into its runtime repository");
  620. require(simulation.executeOnce().succeeded && readBit(repository, 1),
  621. "simulation must execute against the copied initial values");
  622. simulation.stop();
  623. require(!readBit(repository, 1)
  624. && readBit(repository, 0)
  625. && readWord(repository, 10) == 321,
  626. "stopping simulation must restore initial values instead of runtime outputs");
  627. writeBit(initial_repository, 0, false);
  628. require(simulation.start({program}).succeeded,
  629. "simulation must support restart with changed initial values");
  630. require(!readBit(repository, 0) && !readBit(repository, 1),
  631. "restart must use the latest initial values and discard the previous output");
  632. simulation.stop();
  633. }
  634. class FailingVirtualRegisterRepository final : public VirtualRegisterRepository
  635. {
  636. public:
  637. BitReadResult readBit(const RegisterAddress &) const override
  638. {
  639. return {false, false, RegisterError::Unavailable};
  640. }
  641. };
  642. void testRepositoryFailureEntersFaultState()
  643. {
  644. FailingVirtualRegisterRepository repository;
  645. OfflineSimulationService simulation(repository);
  646. const ControlLogic program = logic({
  647. rung("fault-rung", {{contact("fault-input", 0)}}, coil("fault-output", 1))});
  648. require(simulation.start({program}).succeeded,
  649. "valid logic must pass simulation startup");
  650. const LogicScanResult result = simulation.executeOnce();
  651. require(!result.succeeded && result.error == LogicScanError::RegisterReadFailed,
  652. "repository read failure must fail the scan");
  653. require(simulation.state() == SimulationState::Faulted,
  654. "repository failure must enter the fault state");
  655. require(simulation.lastError().logicId == "logic-1"
  656. && simulation.lastError().rungId == "fault-rung"
  657. && simulation.lastError().nodeId == "fault-input",
  658. "fault feedback must retain logic, rung and node context");
  659. }
  660. void testOnlineMonitorUsesTemporaryRegistersWithoutWritingPlcSource()
  661. {
  662. VirtualRegisterRepository plc_source;
  663. OnlineLogicMonitorService monitor(plc_source);
  664. const ControlLogic program = logic({
  665. rung("online-rung-1", {{contact("online-input", 0)}},
  666. coil("online-output-1", 1)),
  667. rung("online-rung-2", {{contact("online-feedback", 1)}},
  668. coil("online-output-2", 2))});
  669. writeBit(plc_source, 0, true);
  670. require(monitor.start({program}).succeeded,
  671. "online monitor must start from the PLC source snapshot");
  672. const LogicTraceSnapshot &first_trace = monitor.traceSnapshot();
  673. require(first_trace.rungValues.at("online-rung-1")
  674. && first_trace.rungValues.at("online-rung-2"),
  675. "a local output must be visible to later rungs in the same temporary scan");
  676. require(!readBit(plc_source, 1) && !readBit(plc_source, 2),
  677. "local trace outputs must never change the PLC source repository");
  678. writeBit(plc_source, 0, false);
  679. require(monitor.executeOnce().succeeded,
  680. "a new PLC snapshot must support another local trace scan");
  681. const LogicTraceSnapshot &second_trace = monitor.traceSnapshot();
  682. require(!second_trace.rungValues.at("online-rung-1")
  683. && !second_trace.rungValues.at("online-rung-2"),
  684. "each local scan must restart from the latest PLC source values");
  685. require(!readBit(plc_source, 1) && !readBit(plc_source, 2),
  686. "repeated local scans must remain read-only toward the PLC source");
  687. }
  688. void testOnlineMonitorPreservesFaultAfterStopping()
  689. {
  690. FailingVirtualRegisterRepository plc_source;
  691. OnlineLogicMonitorService monitor(plc_source);
  692. const ControlLogic program = logic({
  693. rung("online-fault-rung", {{contact("online-fault-input", 0)}},
  694. coil("online-fault-output", 1))});
  695. const OnlineLogicMonitorStartResult start = monitor.start({program});
  696. require(!start.succeeded
  697. && start.detail.error == LogicScanError::RegisterReadFailed,
  698. "an online snapshot failure must be reported at startup");
  699. monitor.stop();
  700. require(monitor.state() == OnlineLogicMonitorState::Stopped,
  701. "stopping a failed online monitor must leave it stopped");
  702. require(!monitor.lastError().succeeded
  703. && monitor.lastError().error == LogicScanError::RegisterReadFailed,
  704. "stopping after a local trace fault must preserve its diagnostic");
  705. }
  706. } // namespace
  707. int main(int argc, char *argv[])
  708. {
  709. QCoreApplication application(argc, argv);
  710. try
  711. {
  712. testSeriesParallelContactsAndSequentialVisibility();
  713. testNestedSeriesParallelExpression();
  714. testUnconditionalCoil();
  715. testWirePassThroughAndPowerTrace();
  716. testAllComparisons();
  717. testSetResetAndDisabledLogic();
  718. testMultipleLogicScanOrderAndTraceIsolation();
  719. testEdgeContactsAreOneScanPulsesAndAreLogicScoped();
  720. testMoveAndSaturatingArithmetic();
  721. testSetResetPairOnSameAddress();
  722. testConflictingCoilsAreRejected();
  723. testHmiSimulationClosedLoop();
  724. testSimulationLifecycleSnapshotAndFault();
  725. testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs();
  726. testRepositoryFailureEntersFaultState();
  727. testOnlineMonitorUsesTemporaryRegistersWithoutWritingPlcSource();
  728. testOnlineMonitorPreservesFaultAfterStopping();
  729. }
  730. catch (const std::exception &error)
  731. {
  732. std::cerr << "offline simulation service tests failed: " << error.what() << '\n';
  733. return 1;
  734. }
  735. std::cout << "offline simulation service tests passed\n";
  736. return 0;
  737. }