|
- #include "domain/hmi_model.h"
- #include "domain/project_limits.h"
- #include "domain/register_repository.h"
- #include "domain/virtual_register_repository.h"
- #include "services/hmi_runtime_service.h"
- #include "services/offline_simulation_service.h"
- #include "services/online_logic_monitor_service.h"
- #include "services/software_logic_executor.h"
- #include "support/test_support.h"
-
- #include <QCoreApplication>
-
- #include <array>
- #include <iostream>
- #include <stdexcept>
- #include <string>
- #include <vector>
-
- namespace {
-
- using TestSupport::require;
-
- LogicNode contact(const std::string &id, int address,
- ContactMode mode = ContactMode::NormallyOpen)
- {
- return {id, ContactNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true};
- }
-
- LogicNode edgeContact(const std::string &id, int address, EdgeMode mode)
- {
- return {id,
- EdgeContactNodeConfig{
- RegisterAddress{RegisterArea::M, address}, mode},
- true};
- }
-
- WordOperand constantOperand(std::int16_t value)
- {
- return {
- WordOperandKind::Constant,
- RegisterAddress{RegisterArea::D, 0},
- value};
- }
-
- WordOperand registerOperand(int address)
- {
- return {
- WordOperandKind::Register,
- RegisterAddress{RegisterArea::D, address},
- 0};
- }
-
- LogicNode move(
- const std::string &id,
- WordOperand source,
- int destination)
- {
- return {
- id,
- MoveNodeConfig{
- source,
- RegisterAddress{RegisterArea::D, destination}},
- true};
- }
-
- LogicNode arithmetic(
- const std::string &id,
- ArithmeticOperation operation,
- WordOperand left,
- WordOperand right,
- int destination)
- {
- return {
- id,
- ArithmeticNodeConfig{
- operation,
- left,
- right,
- RegisterAddress{RegisterArea::D, destination}},
- true};
- }
-
- LogicNode comparison(const std::string &id, int address,
- ComparisonOperator operation, std::int16_t value)
- {
- return {id,
- CompareNodeConfig{RegisterAddress{RegisterArea::D, address}, operation, value},
- true};
- }
-
- LogicNode coil(const std::string &id, int address,
- CoilMode mode = CoilMode::Normal)
- {
- return {id, CoilNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true};
- }
-
- LadderRung rung(const std::string &id,
- const std::vector<std::vector<LogicNode>> &stages,
- const LogicNode &output)
- {
- LadderRung result;
- result.id = id;
- result.name = id;
- result.cells.reserve(ProjectLimits::kMaximumConditionColumns);
- for (int column = 0;
- column < ProjectLimits::kMaximumConditionColumns;
- ++column)
- {
- LadderCell cell;
- cell.id = id + "-cell-" + std::to_string(column);
- cell.kind = LadderCellKind::Wire;
- if (column < static_cast<int>(stages.size())
- && !stages[static_cast<std::size_t>(column)].empty())
- {
- cell.kind = LadderCellKind::Node;
- cell.node = stages[static_cast<std::size_t>(column)].front();
- }
- result.cells.push_back(std::move(cell));
- }
- result.output = output;
- return result;
- }
-
- ControlLogic logic(const std::vector<LadderRung> &rungs)
- {
- return {"logic-1", "logic-1", rungs, true};
- }
-
- bool readBit(RegisterRepository &repository, int address)
- {
- const BitReadResult result = repository.readBit(
- RegisterAddress{RegisterArea::M, address});
- require(result.succeeded, "test register read must succeed");
- return result.value;
- }
-
- void writeBit(RegisterRepository &repository, int address, bool value)
- {
- require(repository.writeBit(RegisterAddress{RegisterArea::M, address}, value).succeeded,
- "test bit write must succeed");
- }
-
- void writeWord(RegisterRepository &repository, int address, std::int16_t value)
- {
- require(repository.writeWord(RegisterAddress{RegisterArea::D, address}, value).succeeded,
- "test word write must succeed");
- }
-
- std::int16_t readWord(RegisterRepository &repository, int address)
- {
- const WordReadResult result = repository.readWord(
- RegisterAddress{RegisterArea::D, address});
- require(result.succeeded, "test word read must succeed");
- return result.value;
- }
-
- void testParallelRowsAndColumnPropagation()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- LadderRung first = rung(
- "first", {{contact("a", 0)}},
- coil("first-output", 10));
- LadderRung second = rung(
- "second", {{contact("c", 2)}},
- coil("second-output", 11));
- second.output.reset();
- ControlLogic program = logic({first, second});
- program.verticalConnections = {
- {"left-bridge", "first", "second", 0},
- {"right-bridge", "first", "second", 1}};
-
- writeBit(repository, 0, true);
- LogicTraceSnapshot trace;
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "parallel row scan must succeed");
- require(readBit(repository, 10), "the first row must energize its output");
-
- writeBit(repository, 0, false);
- writeBit(repository, 2, true);
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "second parallel row scan must succeed");
- require(readBit(repository, 10),
- "the lower branch must feed the shared output through the right edge");
- require(trace.verticalConnectionValues.at("left-bridge"),
- "vertical connection trace must expose the boundary power");
-
- program.verticalConnections.pop_back();
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "the split network scan must succeed");
- require(!readBit(repository, 10),
- "deleting the right edge must split the branch from the output");
- }
-
- void testUnconditionalCoil()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- LadderRung unconditional;
- unconditional.id = "unconditional-rung";
- unconditional.name = "unconditional-rung";
- for (int column = 0;
- column < ProjectLimits::kMaximumConditionColumns;
- ++column)
- {
- unconditional.cells.push_back({
- "unconditional-cell-" + std::to_string(column),
- LadderCellKind::Wire,
- std::nullopt});
- }
- unconditional.output = coil("unconditional-coil", 10);
- const ControlLogic program = logic({unconditional});
-
- LogicTraceSnapshot trace;
- require(executor.validate({program}).succeeded,
- "a full-width wire network must pass runtime validation");
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "a full-width wire network scan must succeed");
- require(readBit(repository, 10),
- "a full-width wire network must energize its coil as a constant-true rung");
- require(trace.rungValues.at("unconditional-rung"),
- "an unconditional rung must report energized power flow");
- }
-
- void testEnabledEmptyRowIsRejectedBeforeScanning()
- {
- SoftwareLogicExecutor executor;
- LadderRung empty;
- empty.id = "empty-enabled-rung";
- empty.name = "Empty enabled rung";
- const ControlLogic program = logic({empty});
- const LogicScanResult validation = executor.validate({program});
- require(!validation.succeeded
- && validation.error == LogicScanError::InvalidLogic,
- "an enabled empty row must be rejected before fixed-grid scanning");
- }
-
- void testWirePassThroughAndPowerTrace()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- LadderRung wired_rung;
- wired_rung.id = "wired-rung";
- wired_rung.name = "wired-rung";
- for (int column = 0;
- column < ProjectLimits::kMaximumConditionColumns;
- ++column)
- {
- wired_rung.cells.push_back({
- "wire-cell-" + std::to_string(column),
- LadderCellKind::Gap,
- std::nullopt});
- }
- wired_rung.cells[0].kind = LadderCellKind::Node;
- wired_rung.cells[0].node = contact("wire-input", 0);
- wired_rung.cells[1].kind = LadderCellKind::Wire;
- wired_rung.cells[2].kind = LadderCellKind::Wire;
- wired_rung.cells[3].kind = LadderCellKind::Node;
- wired_rung.cells[3].node = contact("wire-output", 1);
- for (int column = 4;
- column < ProjectLimits::kMaximumConditionColumns;
- ++column)
- {
- wired_rung.cells[static_cast<std::size_t>(column)].kind =
- LadderCellKind::Wire;
- }
- wired_rung.output = coil("wired-coil", 10);
- const ControlLogic program = logic({wired_rung});
-
- writeBit(repository, 1, true);
- LogicTraceSnapshot trace;
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "wire expression scan must succeed");
- require(!readBit(repository, 10),
- "a horizontal wire must not bypass a false upstream series contact");
- require(trace.cellValues.at("wire-cell-1")
- && !trace.cellInputPowerValues.at("wire-cell-1")
- && !trace.cellPowerValues.at("wire-cell-1"),
- "a wire must remain logically true without showing false upstream power");
-
- writeBit(repository, 0, true);
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "powered wire expression scan must succeed");
- require(readBit(repository, 10)
- && trace.cellInputPowerValues.at("wire-cell-1")
- && trace.cellPowerValues.at("wire-cell-1"),
- "a powered horizontal wire must pass current to the downstream contact");
- }
-
- void testSeriesParallelContactsAndSequentialVisibility()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- LadderRung primary = rung(
- "rung-1",
- {{contact("start", 0)},
- {contact("stop-primary", 2, ContactMode::NormallyClosed)}},
- coil("run", 3));
- LadderRung alternate = rung(
- "rung-2",
- {{contact("alternate", 1)},
- {contact("stop-alternate", 2, ContactMode::NormallyClosed)}},
- coil("downstream", 4));
- ControlLogic program = logic({primary, alternate});
- program.verticalConnections = {
- {"parallel-left", "rung-1", "rung-2", 0},
- {"parallel-right", "rung-1", "rung-2", 2}};
-
- writeBit(repository, 1, true);
- require(executor.executeScan({program}, repository).succeeded,
- "parallel and series scan must succeed");
- require(readBit(repository, 3), "parallel OR and series AND must energize output");
- require(readBit(repository, 4),
- "a later rung must see an earlier rung write in the same scan");
-
- writeBit(repository, 2, true);
- require(executor.executeScan({program}, repository).succeeded,
- "normally closed scan must succeed");
- require(!readBit(repository, 3), "normally closed stop contact must open the rung");
- require(!readBit(repository, 4), "downstream normal coil must follow the new value");
- }
-
- void testMotorForwardReverseSelfHoldAndInterlockTruthTable()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- LadderRung forward = rung(
- "forward-rung",
- {{contact("forward-start", 0)},
- {contact("forward-stop", 2, ContactMode::NormallyClosed)},
- {contact("forward-interlock", 11, ContactMode::NormallyClosed)}},
- coil("forward-coil", 10));
- LadderRung forward_hold = rung(
- "forward-hold-rung",
- {{contact("forward-hold", 10)},
- {contact("forward-hold-stop", 2, ContactMode::NormallyClosed)},
- {contact("forward-hold-interlock", 11, ContactMode::NormallyClosed)}},
- coil("unused-forward-branch-output", 20));
- forward_hold.output.reset();
- LadderRung reverse = rung(
- "reverse-rung",
- {{contact("reverse-start", 1)},
- {contact("reverse-stop", 2, ContactMode::NormallyClosed)},
- {contact("reverse-interlock", 10, ContactMode::NormallyClosed)}},
- coil("reverse-coil", 11));
- LadderRung reverse_hold = rung(
- "reverse-hold-rung",
- {{contact("reverse-hold", 11)},
- {contact("reverse-hold-stop", 2, ContactMode::NormallyClosed)},
- {contact("reverse-hold-interlock", 10, ContactMode::NormallyClosed)}},
- coil("unused-reverse-branch-output", 21));
- reverse_hold.output.reset();
- ControlLogic program = logic({
- forward, forward_hold, reverse, reverse_hold});
- program.id = "motor-control-logic";
- program.verticalConnections = {
- {"forward-left", "forward-rung", "forward-hold-rung", 0},
- {"forward-right", "forward-rung", "forward-hold-rung", 10},
- {"reverse-left", "reverse-rung", "reverse-hold-rung", 0},
- {"reverse-right", "reverse-rung", "reverse-hold-rung", 10}};
-
- LogicTraceSnapshot trace;
- const auto scan = [&]
- {
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "the motor truth-table scan must succeed");
- };
-
- scan();
- require(!readBit(repository, 10) && !readBit(repository, 11),
- "both motor directions must be off in the stopped state");
- require(
- trace.cellInputPowerValues.at("forward-rung-cell-0")
- && !trace.cellPowerValues.at("forward-rung-cell-0")
- && !trace.rungValues.at("forward-rung"),
- "a false start contact must keep only its left terminal energized");
-
- writeBit(repository, 0, true);
- scan();
- require(readBit(repository, 10) && !readBit(repository, 11),
- "the forward start input must energize only M10");
- require(
- trace.cellInputPowerValues.at("forward-rung-cell-0")
- && trace.cellPowerValues.at("forward-rung-cell-0")
- && trace.verticalConnectionValues.at("forward-right")
- && trace.nodeValues.at("forward-coil"),
- "the forward trace must reach the output and connected branch edge");
-
- writeBit(repository, 0, false);
- scan();
- require(readBit(repository, 10) && !readBit(repository, 11),
- "M10 must remain energized through the forward self-hold branch");
-
- writeBit(repository, 1, true);
- scan();
- require(readBit(repository, 10) && !readBit(repository, 11),
- "the reverse start input must be blocked while forward is active");
-
- writeBit(repository, 2, true);
- scan();
- require(!readBit(repository, 10) && !readBit(repository, 11),
- "the stop input must release both direction outputs");
-
- writeBit(repository, 1, false);
- writeBit(repository, 2, false);
- scan();
- writeBit(repository, 1, true);
- scan();
- require(!readBit(repository, 10) && readBit(repository, 11),
- "the reverse start input must energize only M11 after stopping");
-
- writeBit(repository, 1, false);
- scan();
- require(!readBit(repository, 10) && readBit(repository, 11),
- "M11 must remain energized through the reverse self-hold branch");
-
- writeBit(repository, 0, true);
- scan();
- require(!readBit(repository, 10) && readBit(repository, 11),
- "the forward start input must be blocked while reverse is active");
- }
-
- void testAllComparisons()
- {
- const std::array<ComparisonOperator, 6> operations{
- ComparisonOperator::Equal, ComparisonOperator::NotEqual,
- ComparisonOperator::LessThan, ComparisonOperator::LessThanOrEqual,
- ComparisonOperator::GreaterThan, ComparisonOperator::GreaterThanOrEqual};
- const std::array<std::int16_t, 6> actual_values{10, 9, 9, 10, 11, 10};
-
- for (std::size_t index = 0; index < operations.size(); ++index)
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- writeWord(repository, 0, actual_values[index]);
- const ControlLogic program = logic({
- rung("compare-rung", {{comparison("compare", 0, operations[index], 10)},
- }, coil("result", 10))});
- require(executor.executeScan({program}, repository).succeeded,
- "comparison scan must succeed");
- require(readBit(repository, 10), "comparison operator must evaluate true");
- }
- }
-
- void testSetResetAndDisabledLogic()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- ControlLogic program = logic({
- rung("set-rung", {{contact("set-input", 0)}}, coil("set-output", 5, CoilMode::Set)),
- rung("reset-rung", {{contact("reset-input", 1)}},
- coil("reset-output", 6, CoilMode::Reset))});
-
- writeBit(repository, 0, true);
- require(executor.executeScan({program}, repository).succeeded, "set scan must succeed");
- require(readBit(repository, 5), "set coil must latch true");
- writeBit(repository, 0, false);
- require(executor.executeScan({program}, repository).succeeded,
- "inactive set scan must succeed");
- require(readBit(repository, 5), "inactive set coil must retain its value");
- writeBit(repository, 1, true);
- writeBit(repository, 6, true);
- require(executor.executeScan({program}, repository).succeeded,
- "reset scan must succeed");
- require(!readBit(repository, 6), "reset coil must write false");
-
- program.enabled = false;
- writeBit(repository, 5, true);
- require(executor.executeScan({program}, repository).succeeded,
- "disabled logic scan must be ignored successfully");
- require(readBit(repository, 5), "disabled logic must not change outputs");
- }
-
- void testMultipleLogicScanOrderAndTraceIsolation()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- ControlLogic first = logic({
- rung("rung-1", {{contact("input", 0)}}, coil("output", 1))});
- first.id = "logic-first";
- first.name = "First";
- ControlLogic second = logic({
- rung("rung-1", {{contact("input", 1)}}, coil("output", 2))});
- second.id = "logic-second";
- second.name = "Second";
-
- writeBit(repository, 0, true);
- LogicTraceSnapshot trace;
- require(executor.executeScan({first, second}, repository, &trace).succeeded,
- "all enabled logic modules must execute in project order");
- require(readBit(repository, 1) && readBit(repository, 2),
- "a later logic module must observe an earlier module write in one scan");
- require(trace.logicValues.size() == 2U
- && trace.forLogic(first.id).rungValues.at("rung-1")
- && trace.forLogic(second.id).rungValues.at("rung-1"),
- "runtime traces must be partitioned by logic id when node ids repeat");
-
- ControlLogic disabled_draft;
- disabled_draft.id = "logic-draft";
- disabled_draft.name = "Draft";
- disabled_draft.enabled = false;
- LadderRung draft_rung;
- draft_rung.id = "rung-1";
- draft_rung.name = "Draft";
- disabled_draft.rungs.push_back(std::move(draft_rung));
- require(executor.validate({first, disabled_draft}).succeeded,
- "a disabled incomplete logic module must not block offline execution");
- }
-
- void testEdgeContactsAreOneScanPulsesAndAreLogicScoped()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- const ControlLogic program = logic({
- rung("rising-rung", {{edgeContact("rising", 0, EdgeMode::Rising)}},
- coil("rising-output", 10)),
- rung("falling-rung", {{edgeContact("falling", 1, EdgeMode::Falling)}},
- coil("falling-output", 11))});
-
- writeBit(repository, 0, true);
- require(executor.executeScan({program}, repository).succeeded,
- "the first high scan must evaluate rising edge input");
- require(readBit(repository, 10), "rising edge must pulse on OFF to ON");
- require(!readBit(repository, 11), "falling edge must stay off without ON to OFF");
- require(executor.executeScan({program}, repository).succeeded,
- "a repeated high scan must succeed");
- require(!readBit(repository, 10), "rising edge must clear after one scan");
-
- writeBit(repository, 1, true);
- require(executor.executeScan({program}, repository).succeeded,
- "the falling edge input must establish its previous high state");
- writeBit(repository, 1, false);
- require(executor.executeScan({program}, repository).succeeded,
- "the first low scan after high must succeed");
- require(readBit(repository, 11), "falling edge must pulse on ON to OFF");
- require(executor.executeScan({program}, repository).succeeded,
- "a repeated low scan must succeed");
- require(!readBit(repository, 11), "falling edge must clear after one scan");
-
- ControlLogic first = logic({
- rung("shared-rung-first", {{edgeContact("shared-edge", 2, EdgeMode::Rising)}},
- coil("shared-output-first", 20))});
- first.id = "logic-first";
- first.name = "First";
- ControlLogic second = logic({
- rung("shared-rung-second", {{edgeContact("shared-edge", 3, EdgeMode::Rising)}},
- coil("shared-output-second", 21))});
- second.id = "logic-second";
- second.name = "Second";
- writeBit(repository, 2, true);
- require(executor.executeScan({first, second}, repository).succeeded,
- "logic-scoped edge history must support duplicate node ids");
- writeBit(repository, 3, true);
- require(executor.executeScan({first, second}, repository).succeeded,
- "each duplicate edge node must update its own history");
- require(!readBit(repository, 20) && readBit(repository, 21),
- "duplicate node ids in different logic modules must not share edge history");
-
- executor.resetRuntime();
- require(executor.executeScan({first, second}, repository).succeeded,
- "resetting runtime must clear edge history");
- require(readBit(repository, 20) && readBit(repository, 21),
- "a restarted runtime must treat current ON inputs as fresh rising edges");
- }
-
- void testMoveAndSaturatingArithmetic()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- const ControlLogic program = logic({
- rung(
- "move-rung",
- {{contact("execute", 0)}},
- move("move", constantOperand(7), 10)),
- rung(
- "add-rung",
- {{contact("execute-add", 0)}},
- arithmetic(
- "add",
- ArithmeticOperation::Add,
- registerOperand(10),
- constantOperand(32767),
- 11)),
- rung(
- "sub-rung",
- {{contact("execute-sub", 0)}},
- arithmetic(
- "sub",
- ArithmeticOperation::Subtract,
- constantOperand(-32768),
- constantOperand(1),
- 12))});
- LogicTraceSnapshot trace;
-
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "inactive data instructions must scan successfully");
- require(readWord(repository, 10) == 0,
- "MOVE must not write while its rung is false");
-
- writeBit(repository, 0, true);
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "active data instructions must scan successfully");
- require(readWord(repository, 10) == 7,
- "MOVE must copy a constant into the destination D register");
- require(readWord(repository, 11) == 32767
- && trace.wordValues.at("add").overflow,
- "ADD must saturate positive overflow and expose the overflow trace");
- require(readWord(repository, 12) == -32768
- && trace.wordValues.at("sub").overflow,
- "SUB must saturate negative overflow and expose the overflow trace");
-
- const ControlLogic repeated_add = logic({
- rung(
- "repeated-add-rung",
- {{contact("repeated-add-input", 1)}},
- arithmetic(
- "repeated-add",
- ArithmeticOperation::Add,
- registerOperand(20),
- constantOperand(1),
- 20))});
- writeBit(repository, 1, true);
- require(executor.executeScan({repeated_add}, repository).succeeded
- && executor.executeScan({repeated_add}, repository).succeeded,
- "ADD must execute on every scan while its rung remains true");
- require(readWord(repository, 20) == 2,
- "ADD with the same source and destination must accumulate by scan");
- }
-
- void testSetResetPairOnSameAddress()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- const ControlLogic program = logic({
- rung("low-level-rung",
- {{comparison("low-level", 0,
- ComparisonOperator::LessThanOrEqual, 30)}},
- coil("pump-set", 20, CoilMode::Set)),
- rung("high-level-rung",
- {{comparison("high-level", 0,
- ComparisonOperator::GreaterThanOrEqual, 80)}},
- coil("pump-reset", 20, CoilMode::Reset))});
-
- writeWord(repository, 0, 20);
- require(executor.executeScan({program}, repository).succeeded,
- "set/reset pair scan must succeed at the low limit");
- require(readBit(repository, 20), "low level must latch the pump on");
-
- writeWord(repository, 0, 50);
- require(executor.executeScan({program}, repository).succeeded,
- "set/reset pair scan must succeed inside the deadband");
- require(readBit(repository, 20), "deadband must retain the latched on state");
-
- writeWord(repository, 0, 90);
- require(executor.executeScan({program}, repository).succeeded,
- "set/reset pair scan must succeed at the high limit");
- require(!readBit(repository, 20), "high level must reset the pump");
-
- writeWord(repository, 0, 50);
- require(executor.executeScan({program}, repository).succeeded,
- "set/reset pair rescan must succeed inside the deadband");
- require(!readBit(repository, 20), "deadband must retain the reset state");
- }
-
- void testConflictingCoilsAreRejected()
- {
- SoftwareLogicExecutor executor;
- const ControlLogic normal_and_set = logic({
- rung("normal-rung", {{contact("normal-input", 0)}}, coil("normal", 8)),
- rung("set-rung", {{contact("set-input", 1)}}, coil("set", 8, CoilMode::Set))});
- const LogicScanResult set_result = executor.validate({normal_and_set});
- require(!set_result.succeeded
- && set_result.error == LogicScanError::ConflictingOutput,
- "normal and set coils for one address must be rejected");
- require(set_result.rungId == "set-rung" && set_result.nodeId == "set",
- "conflict error must identify the offending rung and node");
-
- const ControlLogic reset_and_normal = logic({
- rung("reset-rung", {{contact("reset-input", 0)}},
- coil("reset", 9, CoilMode::Reset)),
- rung("normal-rung", {{contact("normal-input", 1)}}, coil("normal", 9))});
- const LogicScanResult reset_result = executor.validate({reset_and_normal});
- require(!reset_result.succeeded
- && reset_result.error == LogicScanError::ConflictingOutput,
- "reset and normal coils for one address must be rejected");
- }
-
- void testHmiSimulationClosedLoop()
- {
- VirtualRegisterRepository repository;
- HmiRuntimeService hmi(repository);
- SoftwareLogicExecutor executor;
- HmiControl start;
- start.id = "start-button";
- start.type = HmiControlType::Button;
- start.bounds = {0, 0, 80, 30};
- start.text = "start";
- start.binding = RegisterAddress{RegisterArea::M, 0};
- start.buttonOperation = HmiButtonOperation::MomentaryOn;
- HmiControl indicator;
- indicator.id = "run-indicator";
- indicator.type = HmiControlType::Indicator;
- indicator.bounds = {0, 40, 80, 30};
- indicator.text = "run";
- indicator.binding = RegisterAddress{RegisterArea::M, 2};
- LadderRung start_path = rung(
- "hold-start",
- {{contact("stop", 1, ContactMode::NormallyClosed)},
- {contact("start", 0)}},
- coil("run", 2));
- LadderRung feedback_path = rung(
- "hold-feedback",
- {{contact("feedback-padding", 4000)}, {contact("feedback", 2)}},
- coil("unused-output", 4000));
- feedback_path.cells[0].kind = LadderCellKind::Gap;
- feedback_path.cells[0].node.reset();
- feedback_path.output.reset();
- ControlLogic program = logic({start_path, feedback_path});
- program.verticalConnections = {
- {"hold-left", "hold-start", "hold-feedback", 1},
- {"hold-right", "hold-start", "hold-feedback", 2}};
-
- require(hmi.operateButton(start, HmiButtonEvent::Pressed).succeeded,
- "HMI start button press must write virtual M");
- require(executor.executeScan({program}, repository).succeeded,
- "closed-loop scan must succeed");
- require(hmi.readControl(indicator).bit_value,
- "HMI indicator must observe the logic output");
- require(hmi.operateButton(start, HmiButtonEvent::Released).succeeded,
- "HMI start button release must write virtual M");
- require(executor.executeScan({program}, repository).succeeded, "holding scan must succeed");
- require(hmi.readControl(indicator).bit_value,
- "feedback contact must hold the output after start turns off");
- writeBit(repository, 1, true);
- require(executor.executeScan({program}, repository).succeeded, "stop scan must succeed");
- require(!hmi.readControl(indicator).bit_value,
- "stop contact must clear the HMI run indication");
- }
-
- void testSimulationLifecycleSnapshotAndFault()
- {
- VirtualRegisterRepository repository;
- OfflineSimulationService simulation(repository);
- ControlLogic program = logic({
- rung("snapshot-rung", {{contact("input", 0)}}, coil("output", 1))});
- writeBit(repository, 0, true);
- require(simulation.start({program}).succeeded, "simulation must start");
- require(simulation.state() == SimulationState::Running, "simulation must report running");
- require(!readBit(repository, 0), "starting a session must clear virtual registers");
- writeBit(repository, 0, true);
- program.rungs.front().output = coil("changed-output", 9);
- require(simulation.executeOnce().succeeded, "manual lifecycle scan must succeed");
- require(readBit(repository, 1), "simulation must use its start-time snapshot");
- require(simulation.successfulScanCount() == 1, "successful scans must be counted");
- simulation.stop();
- require(simulation.state() == SimulationState::Stopped, "simulation must stop");
- require(simulation.start({program}).succeeded, "stopped simulation must support restart");
- require(!readBit(repository, 1), "new session must clear prior output values");
- simulation.stop();
- }
-
- void testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs()
- {
- VirtualRegisterRepository repository;
- VirtualRegisterRepository initial_repository;
- OfflineSimulationService simulation(repository, &initial_repository);
- const ControlLogic program = logic({
- rung("initial-rung", {{contact("initial-input", 0)}}, coil("runtime-output", 1))});
-
- writeBit(initial_repository, 0, true);
- writeWord(initial_repository, 10, 321);
- const std::array<std::int16_t, 2> initial_int32 =
- Int32Codec::encode(0x12345678);
- const std::array<std::int16_t, 4> initial_double =
- Float64Codec::encode(1.25);
- initial_repository.writeWords(
- {RegisterArea::D, 20}, {initial_int32[0], initial_int32[1]});
- initial_repository.writeWords(
- {RegisterArea::D, 30},
- {initial_double[0], initial_double[1],
- initial_double[2], initial_double[3]});
- require(simulation.start({program}).succeeded,
- "simulation must start from the explicit initial repository");
- require(readBit(repository, 0)
- && readWord(repository, 10) == 321
- && repository.readWords({RegisterArea::D, 20}, 2).values
- == std::vector<std::int16_t>{
- initial_int32[0], initial_int32[1]}
- && repository.readWords({RegisterArea::D, 30}, 4).values
- == std::vector<std::int16_t>{
- initial_double[0], initial_double[1],
- initial_double[2], initial_double[3]},
- "simulation startup must copy single-word and multi-word initial values");
- require(simulation.executeOnce().succeeded && readBit(repository, 1),
- "simulation must execute against the copied initial values");
- repository.writeWords({RegisterArea::D, 20}, {0, 0});
- repository.writeWords({RegisterArea::D, 30}, {0, 0, 0, 0});
- simulation.stop();
- require(!readBit(repository, 1)
- && readBit(repository, 0)
- && readWord(repository, 10) == 321
- && repository.readWords({RegisterArea::D, 20}, 2).values
- == std::vector<std::int16_t>{
- initial_int32[0], initial_int32[1]}
- && repository.readWords({RegisterArea::D, 30}, 4).values
- == std::vector<std::int16_t>{
- initial_double[0], initial_double[1],
- initial_double[2], initial_double[3]},
- "stopping simulation must restore complete multi-word initial values");
-
- writeBit(initial_repository, 0, false);
- require(simulation.start({program}).succeeded,
- "simulation must support restart with changed initial values");
- require(!readBit(repository, 0) && !readBit(repository, 1),
- "restart must use the latest initial values and discard the previous output");
- simulation.stop();
- }
-
- class FailingVirtualRegisterRepository final : public VirtualRegisterRepository
- {
- public:
- BitReadResult readBit(const RegisterAddress &) const override
- {
- return {false, false, RegisterError::Unavailable};
- }
- };
-
- void testRepositoryFailureEntersFaultState()
- {
- FailingVirtualRegisterRepository repository;
- OfflineSimulationService simulation(repository);
- const ControlLogic program = logic({
- rung("fault-rung", {{contact("fault-input", 0)}}, coil("fault-output", 1))});
-
- require(simulation.start({program}).succeeded,
- "valid logic must pass simulation startup");
- const LogicScanResult result = simulation.executeOnce();
- require(!result.succeeded && result.error == LogicScanError::RegisterReadFailed,
- "repository read failure must fail the scan");
- require(simulation.state() == SimulationState::Faulted,
- "repository failure must enter the fault state");
- require(simulation.lastError().logicId == "logic-1"
- && simulation.lastError().rungId == "fault-rung"
- && simulation.lastError().nodeId == "fault-input",
- "fault feedback must retain logic, rung and node context");
- }
-
- void testOnlineMonitorUsesTemporaryRegistersWithoutWritingPlcSource()
- {
- VirtualRegisterRepository plc_source;
- OnlineLogicMonitorService monitor(plc_source);
- const ControlLogic program = logic({
- rung("online-rung-1", {{contact("online-input", 0)}},
- coil("online-output-1", 1)),
- rung("online-rung-2", {{contact("online-feedback", 1)}},
- coil("online-output-2", 2))});
-
- writeBit(plc_source, 0, true);
- require(monitor.start({program}).succeeded,
- "online monitor must start from the PLC source snapshot");
- const LogicTraceSnapshot &first_trace = monitor.traceSnapshot();
- require(first_trace.rungValues.at("online-rung-1")
- && first_trace.rungValues.at("online-rung-2"),
- "a local output must be visible to later rungs in the same temporary scan");
- require(!readBit(plc_source, 1) && !readBit(plc_source, 2),
- "local trace outputs must never change the PLC source repository");
-
- writeBit(plc_source, 0, false);
- require(monitor.executeOnce().succeeded,
- "a new PLC snapshot must support another local trace scan");
- const LogicTraceSnapshot &second_trace = monitor.traceSnapshot();
- require(!second_trace.rungValues.at("online-rung-1")
- && !second_trace.rungValues.at("online-rung-2"),
- "each local scan must restart from the latest PLC source values");
- require(!readBit(plc_source, 1) && !readBit(plc_source, 2),
- "repeated local scans must remain read-only toward the PLC source");
- }
-
- void testOnlineMonitorPreservesFaultAfterStopping()
- {
- FailingVirtualRegisterRepository plc_source;
- OnlineLogicMonitorService monitor(plc_source);
- const ControlLogic program = logic({
- rung("online-fault-rung", {{contact("online-fault-input", 0)}},
- coil("online-fault-output", 1))});
-
- const OnlineLogicMonitorStartResult start = monitor.start({program});
- require(!start.succeeded
- && start.detail.error == LogicScanError::RegisterReadFailed,
- "an online snapshot failure must be reported at startup");
- monitor.stop();
- require(monitor.state() == OnlineLogicMonitorState::Stopped,
- "stopping a failed online monitor must leave it stopped");
- require(!monitor.lastError().succeeded
- && monitor.lastError().error == LogicScanError::RegisterReadFailed,
- "stopping after a local trace fault must preserve its diagnostic");
- }
-
- } // namespace
-
- int main(int argc, char *argv[])
- {
- QCoreApplication application(argc, argv);
- return TestSupport::runTestSuite("offline simulation service tests", {
- {"testSeriesParallelContactsAndSequentialVisibility", testSeriesParallelContactsAndSequentialVisibility},
- {"testParallelRowsAndColumnPropagation", testParallelRowsAndColumnPropagation},
- {"testUnconditionalCoil", testUnconditionalCoil},
- {"testEnabledEmptyRowIsRejectedBeforeScanning", testEnabledEmptyRowIsRejectedBeforeScanning},
- {"testWirePassThroughAndPowerTrace", testWirePassThroughAndPowerTrace},
- {"testMotorForwardReverseSelfHoldAndInterlockTruthTable", testMotorForwardReverseSelfHoldAndInterlockTruthTable},
- {"testAllComparisons", testAllComparisons},
- {"testSetResetAndDisabledLogic", testSetResetAndDisabledLogic},
- {"testMultipleLogicScanOrderAndTraceIsolation", testMultipleLogicScanOrderAndTraceIsolation},
- {"testEdgeContactsAreOneScanPulsesAndAreLogicScoped", testEdgeContactsAreOneScanPulsesAndAreLogicScoped},
- {"testMoveAndSaturatingArithmetic", testMoveAndSaturatingArithmetic},
- {"testSetResetPairOnSameAddress", testSetResetPairOnSameAddress},
- {"testConflictingCoilsAreRejected", testConflictingCoilsAreRejected},
- {"testHmiSimulationClosedLoop", testHmiSimulationClosedLoop},
- {"testSimulationLifecycleSnapshotAndFault", testSimulationLifecycleSnapshotAndFault},
- {"testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs", testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs},
- {"testRepositoryFailureEntersFaultState", testRepositoryFailureEntersFaultState},
- {"testOnlineMonitorUsesTemporaryRegistersWithoutWritingPlcSource", testOnlineMonitorUsesTemporaryRegistersWithoutWritingPlcSource},
- {"testOnlineMonitorPreservesFaultAfterStopping", testOnlineMonitorPreservesFaultAfterStopping},
- });
- }
|