|
- #include "domain/hmi_model.h"
- #include "domain/register_repository.h"
- #include "services/hmi_runtime_service.h"
- #include "services/offline_simulation_service.h"
- #include "services/software_logic_executor.h"
-
- #include <QCoreApplication>
-
- #include <array>
- #include <iostream>
- #include <stdexcept>
- #include <string>
- #include <vector>
-
- namespace {
-
- void require(bool condition, const std::string &message)
- {
- if (!condition)
- {
- throw std::runtime_error(message);
- }
- }
-
- 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};
- }
-
- LogicNode timerContact(
- const std::string &id,
- int timer_index,
- ContactMode mode = ContactMode::NormallyOpen)
- {
- return {id, TimerContactNodeConfig{TimerAddress{timer_index}, mode}, true};
- }
-
- LogicNode counterContact(
- const std::string &id,
- int counter_index,
- ContactMode mode = ContactMode::NormallyOpen)
- {
- return {id, CounterContactNodeConfig{CounterAddress{counter_index}, mode}, true};
- }
-
- LogicNode ton(const std::string &id, int timer_index, int preset_ms)
- {
- return {id, TonNodeConfig{TimerAddress{timer_index}, preset_ms}, 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 counter(
- const std::string &id,
- int counter_index,
- CounterMode mode,
- int current_address,
- WordOperand preset,
- int reset_address)
- {
- return {
- id,
- CounterNodeConfig{
- CounterAddress{counter_index},
- mode,
- RegisterAddress{RegisterArea::D, current_address},
- preset,
- RegisterAddress{RegisterArea::M, reset_address}},
- true};
- }
-
- 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;
- std::vector<ConditionExpression> series_children;
- for (std::size_t index = 0; index < stages.size(); ++index)
- {
- std::vector<ConditionExpression> parallel_children;
- for (const LogicNode &node : stages[index])
- {
- parallel_children.push_back(ConditionExpression::fromNode(node));
- }
- if (parallel_children.size() == 1U)
- {
- series_children.push_back(std::move(parallel_children.front()));
- }
- else
- {
- ConditionExpression parallel;
- parallel.id = id + "-parallel-" + std::to_string(index);
- parallel.kind = ConditionExpressionKind::Parallel;
- parallel.children = std::move(parallel_children);
- series_children.push_back(std::move(parallel));
- }
- }
- if (series_children.size() == 1U)
- {
- result.condition = std::move(series_children.front());
- }
- else
- {
- ConditionExpression series;
- series.id = id + "-series";
- series.kind = ConditionExpressionKind::Series;
- series.children = std::move(series_children);
- result.condition = std::move(series);
- }
- 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 testNestedSeriesParallelExpression()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- ConditionExpression nested_series;
- nested_series.id = "nested-series";
- nested_series.kind = ConditionExpressionKind::Series;
- nested_series.children = {
- ConditionExpression::fromNode(contact("b", 1)),
- ConditionExpression::fromNode(contact("c", 2))};
- ConditionExpression root;
- root.id = "root-parallel";
- root.kind = ConditionExpressionKind::Parallel;
- root.children = {
- ConditionExpression::fromNode(contact("a", 0)),
- nested_series};
- LadderRung nested_rung;
- nested_rung.id = "nested-rung";
- nested_rung.name = "nested-rung";
- nested_rung.condition = root;
- nested_rung.output = coil("nested-output", 10);
- const ControlLogic program = logic({nested_rung});
-
- writeBit(repository, 1, true);
- writeBit(repository, 2, true);
- LogicTraceSnapshot trace;
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "nested expression scan must succeed");
- require(readBit(repository, 10), "B AND C branch must energize A OR (B AND C)");
- require(trace.expressionValues.at("nested-series")
- && trace.expressionValues.at("root-parallel")
- && trace.rungValues.at("nested-rung"),
- "scan trace must expose active nested expression and rung values");
-
- writeBit(repository, 2, false);
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "nested false scan must succeed");
- require(!readBit(repository, 10), "incomplete B AND C branch must be false");
- writeBit(repository, 0, true);
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "alternate branch scan must succeed");
- require(readBit(repository, 10), "A branch must independently energize output");
- }
-
- void testUnconditionalCoil()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- LadderRung unconditional;
- unconditional.id = "unconditional-rung";
- unconditional.name = "unconditional-rung";
- unconditional.output = coil("unconditional-coil", 10);
- const ControlLogic program = logic({unconditional});
-
- LogicTraceSnapshot trace;
- require(executor.validate({program}).succeeded,
- "an output-only network must pass runtime validation");
- require(executor.executeScan({program}, repository, &trace).succeeded,
- "an output-only network scan must succeed");
- require(readBit(repository, 10),
- "an output-only network must energize its coil as a constant-true rung");
- require(trace.rungValues.at("unconditional-rung")
- && trace.nodePowerValues.at("unconditional-coil"),
- "an unconditional rung must report energized power flow");
- }
-
- void testWirePassThroughAndPowerTrace()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- ConditionExpression root;
- root.id = "wire-series";
- root.kind = ConditionExpressionKind::Series;
- root.children = {
- ConditionExpression::fromNode(contact("wire-input", 0)),
- ConditionExpression::fromWire("wire-segment", 2),
- ConditionExpression::fromNode(contact("wire-output", 1))};
- LadderRung wired_rung;
- wired_rung.id = "wired-rung";
- wired_rung.name = "wired-rung";
- wired_rung.condition = root;
- 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.expressionValues.at("wire-segment")
- && !trace.expressionInputValues.at("wire-segment")
- && !trace.expressionPowerValues.at("wire-segment"),
- "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.expressionInputValues.at("wire-segment")
- && trace.expressionPowerValues.at("wire-segment"),
- "a powered horizontal wire must pass current to the downstream contact");
- }
-
- void testSeriesParallelContactsAndSequentialVisibility()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- const ControlLogic program = logic({
- rung("rung-1",
- {{contact("start", 0), contact("alternate", 1)},
- {contact("stop", 2, ContactMode::NormallyClosed)}},
- coil("run", 3)),
- rung("rung-2", {{contact("run-feedback", 3)}}, coil("downstream", 4))});
-
- 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 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;
- disabled_draft.rungs.push_back(
- {"rung-1", "Draft", {}, std::nullopt, std::nullopt});
- 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 testTonUsesElapsedTimeAndResetsAsNonRetentive()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- const ControlLogic program = logic({
- rung("ton-rung", {{contact("ton-input", 0)}}, ton("ton-0", 0, 100)),
- rung("ton-feedback-rung", {{timerContact("timer-done", 0)}},
- coil("ton-output", 30))});
- const auto start = SoftwareLogicExecutor::TimePoint{};
- LogicTraceSnapshot trace;
-
- require(executor.executeScanAt({program}, repository, start, &trace).succeeded,
- "an inactive TON must scan successfully");
- require(!readBit(repository, 30)
- && trace.tonValues.at("ton-0").elapsedMs == 0
- && !trace.tonValues.at("ton-0").done,
- "an inactive TON must have Q false and ET zero");
-
- writeBit(repository, 0, true);
- require(executor.executeScanAt({program}, repository, start, &trace).succeeded,
- "the first active TON scan must start timing");
- require(!readBit(repository, 30) && trace.tonValues.at("ton-0").elapsedMs == 0,
- "TON must not complete on its starting scan");
- require(executor.executeScanAt(
- {program}, repository,
- start + std::chrono::milliseconds{99}, &trace)
- .succeeded,
- "TON must use elapsed monotonic time before the preset");
- require(!readBit(repository, 30)
- && trace.tonValues.at("ton-0").elapsedMs == 99,
- "TON must remain false before PT");
- require(executor.executeScanAt(
- {program}, repository,
- start + std::chrono::milliseconds{100}, &trace)
- .succeeded,
- "TON must complete at its preset time");
- require(readBit(repository, 30)
- && trace.tonValues.at("ton-0").done,
- "the T contact must observe TON Q in a later network of the same scan");
-
- writeBit(repository, 0, false);
- require(executor.executeScanAt(
- {program}, repository,
- start + std::chrono::milliseconds{150}, &trace)
- .succeeded,
- "disconnecting TON input must scan successfully");
- require(!readBit(repository, 30)
- && trace.tonValues.at("ton-0").elapsedMs == 0
- && !trace.tonValues.at("ton-0").done,
- "a non-retentive TON must reset ET and Q when input opens");
-
- writeBit(repository, 0, true);
- require(executor.executeScanAt(
- {program}, repository,
- start + std::chrono::milliseconds{200}, &trace)
- .succeeded,
- "TON must restart timing after an input reset");
- require(!readBit(repository, 30), "TON restart must not retain the old done state");
-
- executor.resetRuntime();
- require(executor.executeScanAt(
- {program}, repository,
- start + std::chrono::milliseconds{1000}, &trace)
- .succeeded,
- "resetRuntime must clear TON state");
- require(!readBit(repository, 30),
- "a restarted runtime must start a currently active TON from zero");
- }
-
- void testMultipleTimersAndNetworkOrder()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- const ControlLogic independent = logic({
- rung("ton-1-rung", {{contact("input-1", 1)}}, ton("ton-1", 1, 100)),
- rung("ton-2-rung", {{contact("input-2", 2)}}, ton("ton-2", 2, 200))});
- writeBit(repository, 1, true);
- writeBit(repository, 2, true);
- const auto start = SoftwareLogicExecutor::TimePoint{};
- LogicTraceSnapshot trace;
- require(executor.executeScanAt({independent}, repository, start, &trace).succeeded,
- "multiple TON resources must start independently");
- require(executor.executeScanAt(
- {independent}, repository,
- start + std::chrono::milliseconds{100}, &trace)
- .succeeded,
- "the first independent TON must reach PT without completing the second");
- require(trace.tonValues.at("ton-1").done
- && !trace.tonValues.at("ton-2").done,
- "different T resources must retain independent elapsed time");
-
- VirtualRegisterRepository ordered_repository;
- SoftwareLogicExecutor ordered_executor;
- const ControlLogic ordered = logic({
- rung("feedback-before-ton", {{timerContact("early-timer", 4)}},
- coil("early-output", 40)),
- rung("ton-after-feedback", {{contact("late-input", 4)}}, ton("ton-4", 4, 100))});
- writeBit(ordered_repository, 4, true);
- require(ordered_executor.executeScanAt(
- {ordered}, ordered_repository, start, nullptr)
- .succeeded,
- "network order test must start TON after its earlier T contact");
- require(!readBit(ordered_repository, 40),
- "an earlier T contact must see the previous scan state");
- require(ordered_executor.executeScanAt(
- {ordered}, ordered_repository,
- start + std::chrono::milliseconds{100}, nullptr)
- .succeeded,
- "network order test must complete TON on the next scan");
- require(!readBit(ordered_repository, 40),
- "the earlier T contact must remain false on the completion scan");
- require(ordered_executor.executeScanAt(
- {ordered}, ordered_repository,
- start + std::chrono::milliseconds{101}, nullptr)
- .succeeded,
- "network order test must expose TON Q on the following scan");
- require(readBit(ordered_repository, 40),
- "a later scan must observe the completed TON through T contact");
- }
-
- void testCountersUseRisingEdgesAndExternalDValues()
- {
- VirtualRegisterRepository repository;
- SoftwareLogicExecutor executor;
- const ControlLogic up_program = logic({
- rung(
- "ctu-rung",
- {{contact("count-input", 0)}},
- counter(
- "ctu-0",
- 0,
- CounterMode::Up,
- 0,
- constantOperand(3),
- 1)),
- rung(
- "ctu-done-rung",
- {{counterContact("ctu-done", 0)}},
- coil("ctu-done-output", 2))});
- LogicTraceSnapshot trace;
-
- require(executor.executeScan({up_program}, repository, &trace).succeeded,
- "an inactive CTU must scan successfully");
- require(readWord(repository, 0) == 0 && !readBit(repository, 2),
- "an inactive CTU must keep CV zero and Q false");
-
- writeBit(repository, 0, true);
- require(executor.executeScan({up_program}, repository, &trace).succeeded,
- "the first CTU rising edge must scan successfully");
- require(readWord(repository, 0) == 1,
- "CTU must increment the external D current value on a rising edge");
- require(executor.executeScan({up_program}, repository, &trace).succeeded,
- "a held CTU input must scan successfully");
- require(readWord(repository, 0) == 1,
- "a held CTU input must not count every scan");
-
- for (int expected = 2; expected <= 3; ++expected)
- {
- writeBit(repository, 0, false);
- require(executor.executeScan({up_program}, repository).succeeded,
- "CTU falling preparation scan must succeed");
- writeBit(repository, 0, true);
- require(executor.executeScan({up_program}, repository, &trace).succeeded,
- "CTU repeated rising edge must succeed");
- require(readWord(repository, 0) == expected,
- "CTU must increment exactly once for each rising edge");
- }
- require(readBit(repository, 2)
- && trace.counterValues.at("ctu-0").done,
- "the C contact must observe CTU completion in a later network");
-
- writeBit(repository, 1, true);
- require(executor.executeScan({up_program}, repository, &trace).succeeded,
- "CTU reset must scan successfully");
- require(readWord(repository, 0) == 0 && !readBit(repository, 2),
- "CTU reset must clear CV and the C completion state");
-
- executor.resetRuntime();
- repository.clear();
- const ControlLogic down_program = logic({
- rung(
- "ctd-rung",
- {{contact("down-input", 3)}},
- counter(
- "ctd-1",
- 1,
- CounterMode::Down,
- 1,
- constantOperand(2),
- 4)),
- rung(
- "ctd-done-rung",
- {{counterContact("ctd-done", 1)}},
- coil("ctd-done-output", 5))});
- writeBit(repository, 4, true);
- require(executor.executeScan({down_program}, repository).succeeded,
- "CTD load reset must scan successfully");
- require(readWord(repository, 1) == 2,
- "CTD reset must load PV into the external D current value");
- writeBit(repository, 4, false);
- for (int expected = 1; expected >= 0; --expected)
- {
- writeBit(repository, 3, false);
- require(executor.executeScan({down_program}, repository).succeeded,
- "CTD falling preparation scan must succeed");
- writeBit(repository, 3, true);
- require(executor.executeScan({down_program}, repository).succeeded,
- "CTD rising edge must scan successfully");
- require(readWord(repository, 1) == expected,
- "CTD must decrement exactly once for each rising edge");
- }
- require(readBit(repository, 5),
- "CTD completion contact must turn on when CV reaches zero");
- }
-
- 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};
- const ControlLogic program = logic({
- rung("hold-rung",
- {{contact("stop", 1, ContactMode::NormallyClosed)},
- {contact("start", 0), contact("feedback", 2)}},
- coil("run", 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");
- require(simulation.scanIntervalMs() == 50, "default scan interval must be 50 ms");
- 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();
- }
-
- 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");
- }
-
- } // namespace
-
- int main(int argc, char *argv[])
- {
- QCoreApplication application(argc, argv);
- try
- {
- testSeriesParallelContactsAndSequentialVisibility();
- testNestedSeriesParallelExpression();
- testUnconditionalCoil();
- testWirePassThroughAndPowerTrace();
- testAllComparisons();
- testSetResetAndDisabledLogic();
- testMultipleLogicScanOrderAndTraceIsolation();
- testEdgeContactsAreOneScanPulsesAndAreLogicScoped();
- testTonUsesElapsedTimeAndResetsAsNonRetentive();
- testMultipleTimersAndNetworkOrder();
- testCountersUseRisingEdgesAndExternalDValues();
- testMoveAndSaturatingArithmetic();
- testSetResetPairOnSameAddress();
- testConflictingCoilsAreRejected();
- testHmiSimulationClosedLoop();
- testSimulationLifecycleSnapshotAndFault();
- testRepositoryFailureEntersFaultState();
- }
- catch (const std::exception &error)
- {
- std::cerr << "offline simulation service tests failed: " << error.what() << '\n';
- return 1;
- }
- std::cout << "offline simulation service tests passed\n";
- return 0;
- }
|