| @@ -353,6 +353,13 @@ std::string LogicEditorService::rungIdForNode( | |||
| return {}; | |||
| } | |||
| std::string LogicEditorService::registerCommentFor( | |||
| const RegisterAddress &address) const | |||
| { | |||
| const RegisterComment *comment = project_service_.project().findRegisterComment(address); | |||
| return comment == nullptr ? std::string{} : comment->text; | |||
| } | |||
| LogicEditorResult LogicEditorService::ensureDefaultLogic() | |||
| { | |||
| if (!project_service_.project().controlLogics.empty()) | |||
| @@ -362,7 +369,7 @@ LogicEditorResult LogicEditorService::ensureDefaultLogic() | |||
| ControlLogic logic; | |||
| logic.id = "logic-1"; | |||
| logic.name = "控制逻辑 1"; | |||
| logic.rungs.push_back({"rung-1", "网络 1", std::nullopt, std::nullopt}); | |||
| logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt, std::nullopt}); | |||
| Project &project = project_service_.editProject(); | |||
| project.controlLogics.push_back(std::move(logic)); | |||
| return {true, LogicEditorError::None, {}, project.controlLogics.back().id}; | |||
| @@ -386,7 +393,7 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name) | |||
| ControlLogic logic; | |||
| logic.id = makeUniqueLogicId(current); | |||
| logic.name = name; | |||
| logic.rungs.push_back({"rung-1", "网络 1", std::nullopt, std::nullopt}); | |||
| logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt, std::nullopt}); | |||
| Project &project = project_service_.editProject(); | |||
| project.controlLogics.push_back(std::move(logic)); | |||
| return {true, LogicEditorError::None, {}, project.controlLogics.back().id}; | |||
| @@ -539,6 +546,22 @@ LogicEditorResult LogicEditorService::removeRung( | |||
| return {true, LogicEditorError::None, {}, rung_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::updateRungComment( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &comment) | |||
| { | |||
| const LadderRung *rung = findRung(logic_id, rung_id); | |||
| if (rung == nullptr) | |||
| { | |||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | |||
| } | |||
| LadderRung &editable = *findEditableRung( | |||
| project_service_.editProject(), logic_id, rung_id); | |||
| editable.comment = comment; | |||
| return {true, LogicEditorError::None, {}, rung_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::appendCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| @@ -692,7 +715,9 @@ LogicEditorResult LogicEditorService::setOutput( | |||
| const LadderRung *existing_rung = findRung(logic_id, rung_id); | |||
| if (logic == nullptr || existing_rung == nullptr || !isOutputConfig(config)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, "梯形图输出必须使用有效线圈"); | |||
| return failure( | |||
| LogicEditorError::InvalidNode, | |||
| "梯形图输出必须使用有效线圈或 TON 指令"); | |||
| } | |||
| const std::string node_id = existing_rung->output.has_value() | |||
| ? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| @@ -793,12 +818,16 @@ LogicEditorResult LogicEditorService::removeExpression( | |||
| bool LogicEditorService::isConditionConfig(const LogicNodeConfig &config) | |||
| { | |||
| return !std::holds_alternative<CoilNodeConfig>(config); | |||
| return std::holds_alternative<ContactNodeConfig>(config) | |||
| || std::holds_alternative<EdgeContactNodeConfig>(config) | |||
| || std::holds_alternative<TimerContactNodeConfig>(config) | |||
| || std::holds_alternative<CompareNodeConfig>(config); | |||
| } | |||
| bool LogicEditorService::isOutputConfig(const LogicNodeConfig &config) | |||
| { | |||
| return std::holds_alternative<CoilNodeConfig>(config); | |||
| return std::holds_alternative<CoilNodeConfig>(config) | |||
| || std::holds_alternative<TonNodeConfig>(config); | |||
| } | |||
| std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config) | |||
| @@ -811,10 +840,22 @@ std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config) | |||
| { | |||
| return "contact"; | |||
| } | |||
| else if constexpr (std::is_same_v<Config, EdgeContactNodeConfig>) | |||
| { | |||
| return "edge"; | |||
| } | |||
| else if constexpr (std::is_same_v<Config, TimerContactNodeConfig>) | |||
| { | |||
| return "timer-contact"; | |||
| } | |||
| else if constexpr (std::is_same_v<Config, CoilNodeConfig>) | |||
| { | |||
| return "coil"; | |||
| } | |||
| else if constexpr (std::is_same_v<Config, TonNodeConfig>) | |||
| { | |||
| return "ton"; | |||
| } | |||
| return "compare"; | |||
| }, | |||
| config); | |||
| @@ -47,6 +47,7 @@ public: | |||
| std::string firstRungId(const std::string &logic_id) const; | |||
| std::string rungIdForNode( | |||
| const std::string &logic_id, const std::string &node_id) const; | |||
| std::string registerCommentFor(const RegisterAddress &address) const; | |||
| LogicEditorResult ensureDefaultLogic(); | |||
| LogicEditorResult addLogic(const std::string &name); | |||
| @@ -58,6 +59,10 @@ public: | |||
| LogicEditorResult addRung(const std::string &logic_id); | |||
| LogicEditorResult removeRung( | |||
| const std::string &logic_id, const std::string &rung_id); | |||
| LogicEditorResult updateRungComment( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &comment); | |||
| LogicEditorResult appendCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| @@ -31,6 +31,7 @@ SimulationStartResult OfflineSimulationService::start( | |||
| } | |||
| timer_.stop(); | |||
| executor_.resetRuntime(); | |||
| repository_.clear(); | |||
| logic_snapshot_ = std::move(snapshot); | |||
| successful_scan_count_ = 0; | |||
| @@ -45,6 +46,7 @@ SimulationStartResult OfflineSimulationService::start( | |||
| void OfflineSimulationService::stop() | |||
| { | |||
| timer_.stop(); | |||
| executor_.resetRuntime(); | |||
| logic_snapshot_.clear(); | |||
| if (state_ == SimulationState::Stopped) | |||
| { | |||
| @@ -111,6 +113,7 @@ void OfflineSimulationService::handleTimeout() | |||
| void OfflineSimulationService::enterFault(const LogicScanResult &error) | |||
| { | |||
| timer_.stop(); | |||
| executor_.resetRuntime(); | |||
| last_error_ = error; | |||
| state_ = SimulationState::Faulted; | |||
| emit stateChanged(); | |||
| @@ -238,9 +238,12 @@ void RuntimeModeService::refreshPlcPollAddresses() | |||
| } | |||
| for (const LogicNode *node : nodes) | |||
| { | |||
| std::visit( | |||
| [&addresses](const auto &config) { addresses.push_back(config.address); }, | |||
| node->config); | |||
| const std::optional<RegisterAddress> address = | |||
| registerAddressForLogicNode(node->config); | |||
| if (address.has_value()) | |||
| { | |||
| addresses.push_back(*address); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -75,6 +75,7 @@ void LogicTraceValues::clear() | |||
| nodeValues.clear(); | |||
| expressionValues.clear(); | |||
| rungValues.clear(); | |||
| tonValues.clear(); | |||
| } | |||
| void LogicTraceSnapshot::clear() | |||
| @@ -93,6 +94,7 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||
| projection.nodeValues = values->second.nodeValues; | |||
| projection.expressionValues = values->second.expressionValues; | |||
| projection.rungValues = values->second.rungValues; | |||
| projection.tonValues = values->second.tonValues; | |||
| } | |||
| return projection; | |||
| } | |||
| @@ -131,9 +133,13 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||
| const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config); | |||
| if (output == nullptr) | |||
| { | |||
| if (std::holds_alternative<TonNodeConfig>(rung.output->config)) | |||
| { | |||
| continue; | |||
| } | |||
| return failure( | |||
| LogicScanError::InvalidLogic, | |||
| "梯形图输出不是线圈节点", | |||
| "梯形图输出不是线圈或 TON 指令", | |||
| logic.id, | |||
| rung.id, | |||
| rung.output->id); | |||
| @@ -152,13 +158,33 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||
| output_modes[output->address.index()] = output->mode; | |||
| } | |||
| } | |||
| std::string timer_error; | |||
| if (!validateTimerReferencesForRunning(logics, &timer_error)) | |||
| { | |||
| return failure(LogicScanError::InvalidLogic, timer_error); | |||
| } | |||
| return success(); | |||
| } | |||
| void SoftwareLogicExecutor::resetRuntime() | |||
| { | |||
| previous_edge_inputs_.clear(); | |||
| ton_states_.clear(); | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| LogicTraceSnapshot *trace) const | |||
| LogicTraceSnapshot *trace) | |||
| { | |||
| return executeScanAt(logics, repository, Clock::now(), trace); | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| TimePoint now, | |||
| LogicTraceSnapshot *trace) | |||
| { | |||
| const LogicScanResult validation = validate(logics); | |||
| if (!validation.succeeded) | |||
| @@ -187,7 +213,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| bool rung_value = false; | |||
| LogicScanResult result = evaluateExpression( | |||
| *rung.condition, repository, logic_trace, &rung_value); | |||
| logic.id, *rung.condition, repository, logic_trace, &rung_value); | |||
| if (!result.succeeded) | |||
| { | |||
| result.logicId = logic.id; | |||
| @@ -199,14 +225,24 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| logic_trace->rungValues[rung.id] = rung_value; | |||
| logic_trace->nodeValues[rung.output->id] = rung_value; | |||
| } | |||
| result = writeOutput( | |||
| *rung.output, rung_value, repository); | |||
| bool output_value = false; | |||
| result = executeOutput( | |||
| *rung.output, | |||
| rung_value, | |||
| repository, | |||
| now, | |||
| logic_trace, | |||
| &output_value); | |||
| if (!result.succeeded) | |||
| { | |||
| result.logicId = logic.id; | |||
| result.rungId = rung.id; | |||
| return result; | |||
| } | |||
| if (logic_trace != nullptr) | |||
| { | |||
| logic_trace->nodeValues[rung.output->id] = output_value; | |||
| } | |||
| } | |||
| } | |||
| if (trace != nullptr) | |||
| @@ -222,6 +258,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| trace->nodeValues = values->second.nodeValues; | |||
| trace->expressionValues = values->second.expressionValues; | |||
| trace->rungValues = values->second.rungValues; | |||
| trace->tonValues = values->second.tonValues; | |||
| } | |||
| } | |||
| } | |||
| @@ -229,10 +266,11 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||
| const std::string &logic_id, | |||
| const ConditionExpression &expression, | |||
| RegisterRepository &repository, | |||
| LogicTraceValues *trace, | |||
| bool *value) const | |||
| bool *value) | |||
| { | |||
| if (value == nullptr) | |||
| { | |||
| @@ -240,7 +278,8 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| LogicScanResult result = evaluateCondition(*expression.node, repository, value); | |||
| LogicScanResult result = evaluateCondition( | |||
| logic_id, *expression.node, repository, value); | |||
| if (result.succeeded && trace != nullptr) | |||
| { | |||
| trace->nodeValues[expression.node->id] = *value; | |||
| @@ -253,7 +292,8 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| bool child_value = false; | |||
| LogicScanResult result = evaluateExpression(child, repository, trace, &child_value); | |||
| LogicScanResult result = evaluateExpression( | |||
| logic_id, child, repository, trace, &child_value); | |||
| if (!result.succeeded) | |||
| { | |||
| return result; | |||
| @@ -270,9 +310,10 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| const std::string &logic_id, | |||
| const LogicNode &node, | |||
| RegisterRepository &repository, | |||
| bool *value) const | |||
| bool *value) | |||
| { | |||
| if (value == nullptr) | |||
| { | |||
| @@ -285,7 +326,8 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| } | |||
| return std::visit( | |||
| [&repository, value, &node](const auto &config) -> LogicScanResult | |||
| [this, &logic_id, &repository, value, &node]( | |||
| const auto &config) -> LogicScanResult | |||
| { | |||
| using Config = std::decay_t<decltype(config)>; | |||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | |||
| @@ -304,6 +346,31 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| ? read.value : !read.value; | |||
| return success(); | |||
| } | |||
| else if constexpr (std::is_same_v<Config, EdgeContactNodeConfig>) | |||
| { | |||
| const BitReadResult read = repository.readBit(config.address); | |||
| if (!read.succeeded) | |||
| { | |||
| return failure( | |||
| LogicScanError::RegisterReadFailed, | |||
| "读取寄存器失败:" + config.address.toString(), | |||
| {}, | |||
| {}, | |||
| node.id); | |||
| } | |||
| const auto runtime_key = std::make_pair(logic_id, node.id); | |||
| const bool previous = previous_edge_inputs_[runtime_key]; | |||
| *value = config.mode == EdgeMode::Rising | |||
| ? read.value && !previous : !read.value && previous; | |||
| previous_edge_inputs_[runtime_key] = read.value; | |||
| return success(); | |||
| } | |||
| else if constexpr (std::is_same_v<Config, TimerContactNodeConfig>) | |||
| { | |||
| const bool done = ton_states_[config.address.index()].done; | |||
| *value = config.mode == ContactMode::NormallyOpen ? done : !done; | |||
| return success(); | |||
| } | |||
| else if constexpr (std::is_same_v<Config, CompareNodeConfig>) | |||
| { | |||
| const WordReadResult read = repository.readWord(config.address); | |||
| @@ -323,7 +390,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| { | |||
| return failure( | |||
| LogicScanError::InvalidLogic, | |||
| "条件节点不能包含输出线圈", | |||
| "条件节点不能包含输出节点", | |||
| {}, | |||
| {}, | |||
| node.id); | |||
| @@ -332,24 +399,68 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| node.config); | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::writeOutput( | |||
| LogicScanResult SoftwareLogicExecutor::executeOutput( | |||
| const LogicNode &node, | |||
| bool rung_value, | |||
| RegisterRepository &repository) const | |||
| RegisterRepository &repository, | |||
| TimePoint now, | |||
| LogicTraceValues *trace, | |||
| bool *output_value) | |||
| { | |||
| if (output_value == nullptr) | |||
| { | |||
| return failure( | |||
| LogicScanError::InvalidLogic, | |||
| "缺少输出指令结果接收对象", | |||
| {}, | |||
| {}, | |||
| node.id); | |||
| } | |||
| if (const auto *ton = std::get_if<TonNodeConfig>(&node.config)) | |||
| { | |||
| TonRuntimeState &state = ton_states_[ton->address.index()]; | |||
| if (!rung_value) | |||
| { | |||
| state = TonRuntimeState{}; | |||
| } | |||
| else if (!state.timing) | |||
| { | |||
| state.timing = true; | |||
| state.startedAt = now; | |||
| state.elapsed = std::chrono::milliseconds{0}; | |||
| state.done = false; | |||
| } | |||
| else | |||
| { | |||
| state.elapsed = std::chrono::duration_cast<std::chrono::milliseconds>( | |||
| now >= state.startedAt ? now - state.startedAt : TimePoint::duration::zero()); | |||
| state.done = state.elapsed.count() >= ton->presetMs; | |||
| } | |||
| *output_value = state.done; | |||
| if (trace != nullptr) | |||
| { | |||
| trace->tonValues[node.id] = { | |||
| rung_value, | |||
| state.done, | |||
| state.elapsed.count(), | |||
| ton->presetMs}; | |||
| } | |||
| return success(); | |||
| } | |||
| const auto *config = std::get_if<CoilNodeConfig>(&node.config); | |||
| if (config == nullptr) | |||
| { | |||
| return failure( | |||
| LogicScanError::InvalidLogic, | |||
| "梯形图输出不是线圈节点", | |||
| "梯形图输出不是线圈或 TON 指令", | |||
| {}, | |||
| {}, | |||
| node.id); | |||
| } | |||
| bool should_write = true; | |||
| bool output_value = rung_value; | |||
| bool register_value = rung_value; | |||
| switch (config->mode) | |||
| { | |||
| case CoilMode::Normal: | |||
| @@ -359,13 +470,13 @@ LogicScanResult SoftwareLogicExecutor::writeOutput( | |||
| case CoilMode::Set: | |||
| { | |||
| should_write = rung_value; | |||
| output_value = true; | |||
| register_value = true; | |||
| break; | |||
| } | |||
| case CoilMode::Reset: | |||
| { | |||
| should_write = rung_value; | |||
| output_value = false; | |||
| register_value = false; | |||
| break; | |||
| } | |||
| default: | |||
| @@ -381,10 +492,11 @@ LogicScanResult SoftwareLogicExecutor::writeOutput( | |||
| if (!should_write) | |||
| { | |||
| *output_value = rung_value; | |||
| return success(); | |||
| } | |||
| const RegisterWriteResult write = repository.writeBit( | |||
| config->address, output_value); | |||
| config->address, register_value); | |||
| if (!write.succeeded) | |||
| { | |||
| return failure( | |||
| @@ -394,5 +506,6 @@ LogicScanResult SoftwareLogicExecutor::writeOutput( | |||
| {}, | |||
| node.id); | |||
| } | |||
| *output_value = rung_value; | |||
| return success(); | |||
| } | |||
| @@ -3,8 +3,12 @@ | |||
| #include "domain/control_logic_model.h" | |||
| #include "domain/register_repository.h" | |||
| #include <chrono> | |||
| #include <cstdint> | |||
| #include <map> | |||
| #include <string> | |||
| #include <unordered_map> | |||
| #include <utility> | |||
| #include <vector> | |||
| enum class LogicScanError | |||
| @@ -26,11 +30,20 @@ struct LogicScanResult | |||
| std::string nodeId; | |||
| }; | |||
| struct TonTraceValue | |||
| { | |||
| bool input = false; | |||
| bool done = false; | |||
| std::int64_t elapsedMs = 0; | |||
| int presetMs = 0; | |||
| }; | |||
| struct LogicTraceValues | |||
| { | |||
| std::unordered_map<std::string, bool> nodeValues; | |||
| std::unordered_map<std::string, bool> expressionValues; | |||
| std::unordered_map<std::string, bool> rungValues; | |||
| std::unordered_map<std::string, TonTraceValue> tonValues; | |||
| void clear(); | |||
| }; | |||
| @@ -47,24 +60,49 @@ struct LogicTraceSnapshot : LogicTraceValues | |||
| class SoftwareLogicExecutor | |||
| { | |||
| public: | |||
| using Clock = std::chrono::steady_clock; | |||
| using TimePoint = Clock::time_point; | |||
| LogicScanResult validate(const std::vector<ControlLogic> &logics) const; | |||
| void resetRuntime(); | |||
| LogicScanResult executeScan( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| LogicTraceSnapshot *trace = nullptr) const; | |||
| LogicTraceSnapshot *trace = nullptr); | |||
| LogicScanResult executeScanAt( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| TimePoint now, | |||
| LogicTraceSnapshot *trace = nullptr); | |||
| private: | |||
| struct TonRuntimeState | |||
| { | |||
| bool timing = false; | |||
| bool done = false; | |||
| TimePoint startedAt{}; | |||
| std::chrono::milliseconds elapsed{0}; | |||
| }; | |||
| LogicScanResult evaluateCondition( | |||
| const std::string &logic_id, | |||
| const LogicNode &node, | |||
| RegisterRepository &repository, | |||
| bool *value) const; | |||
| bool *value); | |||
| LogicScanResult evaluateExpression( | |||
| const std::string &logic_id, | |||
| const ConditionExpression &expression, | |||
| RegisterRepository &repository, | |||
| LogicTraceValues *trace, | |||
| bool *value) const; | |||
| LogicScanResult writeOutput( | |||
| bool *value); | |||
| LogicScanResult executeOutput( | |||
| const LogicNode &node, | |||
| bool rung_value, | |||
| RegisterRepository &repository) const; | |||
| RegisterRepository &repository, | |||
| TimePoint now, | |||
| LogicTraceValues *trace, | |||
| bool *output_value); | |||
| std::map<std::pair<std::string, std::string>, bool> previous_edge_inputs_; | |||
| std::unordered_map<int, TonRuntimeState> ton_states_; | |||
| }; | |||
| @@ -165,6 +165,67 @@ void testLogicLifecycleAndOrdering() | |||
| "the project must retain at least one control logic module"); | |||
| } | |||
| void testEdgeTimerNodesAndRungComments() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService service(project_service); | |||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||
| const std::string rung_id = service.firstRungId(logic_id); | |||
| const LogicEditorResult rising = service.appendCondition( | |||
| logic_id, | |||
| rung_id, | |||
| EdgeContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 3}, EdgeMode::Rising}); | |||
| require(rising.succeeded && rising.id == "edge-1", | |||
| "the editor must create rising edge nodes with a stable prefix"); | |||
| const LogicEditorResult timer = service.addParallelBranch( | |||
| logic_id, | |||
| rung_id, | |||
| {rising.id}, | |||
| TimerContactNodeConfig{TimerAddress{2}, ContactMode::NormallyOpen}); | |||
| require(timer.succeeded && timer.id == "timer-contact-1", | |||
| "the editor must insert T contacts as conditions"); | |||
| const LogicEditorResult ton = service.setOutput( | |||
| logic_id, rung_id, TonNodeConfig{TimerAddress{2}, 500}); | |||
| require(ton.succeeded && ton.id == "ton-1", | |||
| "the editor must create TON outputs with a stable prefix"); | |||
| require(service.updateNodeConfig( | |||
| logic_id, | |||
| rising.id, | |||
| EdgeContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 4}, EdgeMode::Falling}) | |||
| .succeeded, | |||
| "the editor must apply edge mode and M address properties"); | |||
| require(service.updateNodeConfig( | |||
| logic_id, | |||
| timer.id, | |||
| TimerContactNodeConfig{TimerAddress{4}, ContactMode::NormallyClosed}) | |||
| .succeeded, | |||
| "the editor must apply T contact mode and address properties"); | |||
| require(service.updateNodeConfig( | |||
| logic_id, | |||
| ton.id, | |||
| TonNodeConfig{TimerAddress{4}, 750}) | |||
| .succeeded, | |||
| "the editor must apply TON preset properties"); | |||
| require(service.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded, | |||
| "the editor must update a network comment by stable rung id"); | |||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||
| require(rung != nullptr && rung->comment == "延时启动网络" | |||
| && std::get<EdgeContactNodeConfig>( | |||
| rung->condition->children.front().node->config).mode | |||
| == EdgeMode::Falling | |||
| && std::get<TimerContactNodeConfig>( | |||
| rung->condition->children.at(1).node->config).address | |||
| == TimerAddress{4} | |||
| && std::get<TonNodeConfig>(rung->output->config).presetMs == 750, | |||
| "edge, T contact, TON and rung comment updates must remain in the model"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| @@ -174,6 +235,7 @@ int main() | |||
| testStructuredEditingAndNormalization(); | |||
| testRangeParallelInsertion(); | |||
| testLogicLifecycleAndOrdering(); | |||
| testEdgeTimerNodesAndRungComments(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||
| @@ -28,6 +28,27 @@ LogicNode contact(const std::string &id, int address, | |||
| 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 ton(const std::string &id, int timer_index, int preset_ms) | |||
| { | |||
| return {id, TonNodeConfig{TimerAddress{timer_index}, preset_ms}, true}; | |||
| } | |||
| LogicNode comparison(const std::string &id, int address, | |||
| ComparisonOperator operation, std::int16_t value) | |||
| { | |||
| @@ -260,11 +281,187 @@ void testMultipleLogicScanOrderAndTraceIsolation() | |||
| disabled_draft.name = "Draft"; | |||
| disabled_draft.enabled = false; | |||
| disabled_draft.rungs.push_back( | |||
| {"rung-1", "Draft", std::nullopt, std::nullopt}); | |||
| {"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 testSetResetPairOnSameAddress() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| @@ -428,6 +625,9 @@ int main(int argc, char *argv[]) | |||
| testAllComparisons(); | |||
| testSetResetAndDisabledLogic(); | |||
| testMultipleLogicScanOrderAndTraceIsolation(); | |||
| testEdgeContactsAreOneScanPulsesAndAreLogicScoped(); | |||
| testTonUsesElapsedTimeAndResetsAsNonRetentive(); | |||
| testMultipleTimersAndNetworkOrder(); | |||
| testSetResetPairOnSameAddress(); | |||
| testConflictingCoilsAreRejected(); | |||
| testHmiSimulationClosedLoop(); | |||
| @@ -123,6 +123,69 @@ void testModeTransitions() | |||
| {"alarm-m", {RegisterArea::M, 12}, AlarmCondition::MOn, 0, "M alarm"}); | |||
| project.alarmDefinitions.push_back( | |||
| {"alarm-d", {RegisterArea::D, 34}, AlarmCondition::DHigh, 100, "D alarm"}); | |||
| project.registerComments = { | |||
| {RegisterAddress{RegisterArea::M, 3999}, "只用于说明"}, | |||
| {RegisterAddress{RegisterArea::D, 3999}, "只用于说明"}}; | |||
| ControlLogic logic; | |||
| logic.id = "poll-logic"; | |||
| logic.name = "Poll logic"; | |||
| LogicNode edge; | |||
| edge.id = "poll-edge"; | |||
| edge.config = EdgeContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 20}, EdgeMode::Rising}; | |||
| LogicNode edge_coil; | |||
| edge_coil.id = "poll-edge-coil"; | |||
| edge_coil.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 21}, CoilMode::Normal}; | |||
| LadderRung edge_rung; | |||
| edge_rung.id = "poll-edge-rung"; | |||
| edge_rung.name = "Poll edge"; | |||
| edge_rung.condition = ConditionExpression::fromNode(edge); | |||
| edge_rung.output = edge_coil; | |||
| logic.rungs.push_back(edge_rung); | |||
| LogicNode comparison; | |||
| comparison.id = "poll-comparison"; | |||
| comparison.config = CompareNodeConfig{ | |||
| RegisterAddress{RegisterArea::D, 35}, ComparisonOperator::GreaterThan, 0}; | |||
| LogicNode comparison_coil; | |||
| comparison_coil.id = "poll-comparison-coil"; | |||
| comparison_coil.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 22}, CoilMode::Normal}; | |||
| LadderRung comparison_rung; | |||
| comparison_rung.id = "poll-comparison-rung"; | |||
| comparison_rung.name = "Poll comparison"; | |||
| comparison_rung.condition = ConditionExpression::fromNode(comparison); | |||
| comparison_rung.output = comparison_coil; | |||
| logic.rungs.push_back(comparison_rung); | |||
| LogicNode timer_input; | |||
| timer_input.id = "poll-timer-input"; | |||
| timer_input.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 23}, ContactMode::NormallyOpen}; | |||
| LogicNode ton; | |||
| ton.id = "poll-ton"; | |||
| ton.config = TonNodeConfig{TimerAddress{4}, 100}; | |||
| LadderRung ton_rung; | |||
| ton_rung.id = "poll-ton-rung"; | |||
| ton_rung.name = "Poll TON"; | |||
| ton_rung.condition = ConditionExpression::fromNode(timer_input); | |||
| ton_rung.output = ton; | |||
| logic.rungs.push_back(ton_rung); | |||
| LogicNode timer_contact; | |||
| timer_contact.id = "poll-timer-contact"; | |||
| timer_contact.config = TimerContactNodeConfig{ | |||
| TimerAddress{4}, ContactMode::NormallyOpen}; | |||
| LogicNode timer_coil; | |||
| timer_coil.id = "poll-timer-coil"; | |||
| timer_coil.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 24}, CoilMode::Normal}; | |||
| LadderRung timer_rung; | |||
| timer_rung.id = "poll-timer-rung"; | |||
| timer_rung.name = "Poll timer contact"; | |||
| timer_rung.condition = ConditionExpression::fromNode(timer_contact); | |||
| timer_rung.output = timer_coil; | |||
| logic.rungs.push_back(timer_rung); | |||
| project.controlLogics.push_back(logic); | |||
| require(service.mode() == ApplicationMode::Editing, | |||
| "service must start in editing mode"); | |||
| @@ -152,8 +215,11 @@ void testModeTransitions() | |||
| "PLC connection must be established before its initial read can complete"); | |||
| require(gateway.pollAddresses() | |||
| == std::vector<RegisterAddress>({ | |||
| {RegisterArea::M, 12}, {RegisterArea::D, 34}}), | |||
| "M and D alarm addresses must be included in PLC polling"); | |||
| {RegisterArea::M, 12}, {RegisterArea::M, 20}, | |||
| {RegisterArea::M, 21}, {RegisterArea::M, 22}, | |||
| {RegisterArea::M, 23}, {RegisterArea::M, 24}, | |||
| {RegisterArea::D, 34}, {RegisterArea::D, 35}}), | |||
| "M/D logic and alarm addresses must be polled while T and comments stay offline-only"); | |||
| gateway.completeInitialRead(); | |||
| require(service.initialPlcReadCompleted(), | |||
| "service must retain the initial PLC read state"); | |||