Sfoglia il codice sorgente

feat: 完成离线梯形图仿真闭环

main
suyu 1 mese fa
parent
commit
b6c70d78e6
21 ha cambiato i file con 1045 aggiunte e 15 eliminazioni
  1. +4
    -0
      app/integrated_platform.pro
  2. +2
    -1
      app/src/domain/runtime_state.h
  3. +4
    -1
      app/src/main.cpp
  4. +110
    -0
      app/src/services/offline_simulation_service.cpp
  5. +67
    -0
      app/src/services/offline_simulation_service.h
  6. +46
    -3
      app/src/services/runtime_mode_service.cpp
  7. +12
    -1
      app/src/services/runtime_mode_service.h
  8. +295
    -0
      app/src/services/software_logic_executor.cpp
  9. +46
    -0
      app/src/services/software_logic_executor.h
  10. +7
    -1
      app/src/ui/hmi_editor_widget.cpp
  11. +3
    -0
      app/src/ui/hmi_editor_widget.h
  12. +74
    -3
      app/src/ui/main_window.cpp
  13. +2
    -0
      app/src/ui/main_window.h
  14. +12
    -1
      app/tests/main_window_tests.cpp
  15. +4
    -0
      app/tests/main_window_tests.pro
  16. +286
    -0
      app/tests/offline_simulation_service_tests.cpp
  17. +29
    -0
      app/tests/offline_simulation_service_tests.pro
  18. +11
    -1
      app/tests/runtime_mode_service_tests.cpp
  19. +6
    -0
      app/tests/runtime_mode_service_tests.pro
  20. +14
    -3
      docs/ai/handoff.md
  21. +11
    -0
      docs/architecture.md

+ 4
- 0
app/integrated_platform.pro Vedi File

@@ -27,6 +27,8 @@ SOURCES += \
src/services/hmi_editor_service.cpp \
src/services/logic_editor_service.cpp \
src/services/hmi_runtime_service.cpp \
src/services/software_logic_executor.cpp \
src/services/offline_simulation_service.cpp \
src/services/runtime_mode_service.cpp \
src/infrastructure/json_project_storage.cpp \
src/ui/hmi_editor_widget.cpp \
@@ -45,6 +47,8 @@ HEADERS += \
src/services/hmi_editor_service.h \
src/services/logic_editor_service.h \
src/services/hmi_runtime_service.h \
src/services/software_logic_executor.h \
src/services/offline_simulation_service.h \
src/services/runtime_mode_service.h \
src/infrastructure/json_project_storage.h \
src/ui/hmi_editor_widget.h \


+ 2
- 1
app/src/domain/runtime_state.h Vedi File

@@ -75,7 +75,8 @@ enum class ModeTransitionError
AlreadyInRequestedMode, // 已经处于目标模式
MustReturnToEditing, // 必须先返回编辑态
InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取
ProjectNotReady // 工程存在未配置或未完成的运行项
ProjectNotReady, // 工程存在未配置或未完成的运行项
SimulationStartFailed // 离线执行器预检或启动失败
};

// 模式切换操作结果


+ 4
- 1
app/src/main.cpp Vedi File

@@ -12,6 +12,7 @@
#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/logic_editor_service.h"
#include "services/offline_simulation_service.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"
#include "ui/main_window.h"
@@ -30,7 +31,9 @@ int main(int argc, char *argv[])
// 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现
VirtualRegisterRepository virtual_register_repository;
HmiRuntimeService hmi_runtime_service(virtual_register_repository);
RuntimeModeService runtime_mode_service(project_service);
OfflineSimulationService offline_simulation_service(virtual_register_repository);
RuntimeModeService runtime_mode_service(
project_service, offline_simulation_service);
MainWindow main_window(
runtime_mode_service,
project_service,


+ 110
- 0
app/src/services/offline_simulation_service.cpp Vedi File

@@ -0,0 +1,110 @@
#include "offline_simulation_service.h"

#include <utility>

OfflineSimulationService::OfflineSimulationService(
VirtualRegisterRepository &repository,
QObject *parent)
: QObject(parent),
repository_(repository)
{
timer_.setInterval(kDefaultScanIntervalMs);
timer_.setTimerType(Qt::PreciseTimer);
connect(&timer_, &QTimer::timeout,
this, &OfflineSimulationService::handleTimeout);
}

SimulationStartResult OfflineSimulationService::start(
const std::vector<ControlLogic> &logics)
{
if (state_ == SimulationState::Running)
{
return {false, SimulationStartError::AlreadyRunning, {}};
}

std::vector<ControlLogic> snapshot = logics;
const LogicScanResult validation = executor_.validate(snapshot);
if (!validation.succeeded)
{
last_error_ = validation;
return {false, SimulationStartError::InvalidLogic, validation};
}

timer_.stop();
repository_.clear();
logic_snapshot_ = std::move(snapshot);
successful_scan_count_ = 0;
last_error_ = {true, LogicScanError::None, {}, {}, {}, {}};
state_ = SimulationState::Running;
timer_.start();
emit stateChanged();
return {true, SimulationStartError::None, {true, LogicScanError::None, {}, {}, {}, {}}};
}

void OfflineSimulationService::stop()
{
timer_.stop();
logic_snapshot_.clear();
if (state_ == SimulationState::Stopped)
{
return;
}
state_ = SimulationState::Stopped;
last_error_ = {true, LogicScanError::None, {}, {}, {}, {}};
emit stateChanged();
}

LogicScanResult OfflineSimulationService::executeOnce()
{
if (state_ != SimulationState::Running)
{
return {false,
LogicScanError::InvalidLogic,
"offline simulation is not running",
{},
{},
{}};
}
const LogicScanResult result = executor_.executeScan(
logic_snapshot_, repository_);
if (!result.succeeded)
{
enterFault(result);
return result;
}
++successful_scan_count_;
return result;
}

SimulationState OfflineSimulationService::state() const
{
return state_;
}

int OfflineSimulationService::scanIntervalMs() const
{
return timer_.interval();
}

std::uint64_t OfflineSimulationService::successfulScanCount() const
{
return successful_scan_count_;
}

const LogicScanResult &OfflineSimulationService::lastError() const
{
return last_error_;
}

void OfflineSimulationService::handleTimeout()
{
executeOnce();
}

void OfflineSimulationService::enterFault(const LogicScanResult &error)
{
timer_.stop();
last_error_ = error;
state_ = SimulationState::Faulted;
emit stateChanged();
}

+ 67
- 0
app/src/services/offline_simulation_service.h Vedi File

@@ -0,0 +1,67 @@
#pragma once

#include "software_logic_executor.h"

#include <QObject>
#include <QTimer>

#include <cstdint>
#include <vector>

enum class SimulationState
{
Stopped,
Running,
Faulted
};

enum class SimulationStartError
{
None,
AlreadyRunning,
InvalidLogic
};

struct SimulationStartResult
{
bool succeeded = false;
SimulationStartError error = SimulationStartError::None;
LogicScanResult detail;
};

// 管理离线逻辑快照、固定周期扫描和实际执行状态
class OfflineSimulationService final : public QObject
{
Q_OBJECT

public:
static constexpr int kDefaultScanIntervalMs = 50;

explicit OfflineSimulationService(
VirtualRegisterRepository &repository,
QObject *parent = nullptr);

SimulationStartResult start(const std::vector<ControlLogic> &logics);
void stop();
LogicScanResult executeOnce();

SimulationState state() const;
int scanIntervalMs() const;
std::uint64_t successfulScanCount() const;
const LogicScanResult &lastError() const;

signals:
void stateChanged();

private:
void handleTimeout();
void enterFault(const LogicScanResult &error);

VirtualRegisterRepository &repository_;
SoftwareLogicExecutor executor_;
QTimer timer_;
std::vector<ControlLogic> logic_snapshot_;
SimulationState state_ = SimulationState::Stopped;
std::uint64_t successful_scan_count_ = 0;
LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}};
};

+ 46
- 3
app/src/services/runtime_mode_service.cpp Vedi File

@@ -10,8 +10,11 @@

#include "project_service.h"

RuntimeModeService::RuntimeModeService(const ProjectService &project_service)
: project_service_(project_service)
RuntimeModeService::RuntimeModeService(
const ProjectService &project_service,
OfflineSimulationService &offline_simulation_service)
: project_service_(project_service),
offline_simulation_service_(offline_simulation_service)
{
}

@@ -27,17 +30,37 @@ ModePolicy RuntimeModeService::policy() const

ModeTransitionResult RuntimeModeService::enterEditing()
{
if (state_.mode() == ApplicationMode::OfflineRunning)
{
// 先停止扫描再开放编辑,防止运行快照继续写寄存器
offline_simulation_service_.stop();
}
return state_.enterEditing();
}

ModeTransitionResult RuntimeModeService::enterOfflineRunning()
{
if (state_.mode() != ApplicationMode::Editing)
{
return state_.enterOfflineRunning();
}
std::string error;
if (!project_service_.project().validateForRunning(&error))
{
return {false, ModeTransitionError::ProjectNotReady};
}
return state_.enterOfflineRunning();
const SimulationStartResult start_result = offline_simulation_service_.start(
project_service_.project().controlLogics);
if (!start_result.succeeded)
{
return {false, ModeTransitionError::SimulationStartFailed};
}
const ModeTransitionResult transition = state_.enterOfflineRunning();
if (!transition.succeeded)
{
offline_simulation_service_.stop();
}
return transition;
}

ModeTransitionResult RuntimeModeService::enterOnlineRunning()
@@ -56,3 +79,23 @@ bool RuntimeModeService::initialPlcReadCompleted() const
{
return initial_plc_read_completed_;
}

SimulationState RuntimeModeService::simulationState() const
{
return offline_simulation_service_.state();
}

std::uint64_t RuntimeModeService::successfulScanCount() const
{
return offline_simulation_service_.successfulScanCount();
}

const LogicScanResult &RuntimeModeService::simulationError() const
{
return offline_simulation_service_.lastError();
}

OfflineSimulationService &RuntimeModeService::offlineSimulationService()
{
return offline_simulation_service_;
}

+ 12
- 1
app/src/services/runtime_mode_service.h Vedi File

@@ -9,6 +9,9 @@
#pragma once

#include "domain/runtime_state.h"
#include "offline_simulation_service.h"

#include <cstdint>

class ProjectService;

@@ -16,7 +19,9 @@ class ProjectService;
class RuntimeModeService
{
public:
explicit RuntimeModeService(const ProjectService &project_service);
RuntimeModeService(
const ProjectService &project_service,
OfflineSimulationService &offline_simulation_service);

ApplicationMode mode() const;
/**
@@ -54,8 +59,14 @@ public:
*/
bool initialPlcReadCompleted() const;

SimulationState simulationState() const;
std::uint64_t successfulScanCount() const;
const LogicScanResult &simulationError() const;
OfflineSimulationService &offlineSimulationService();

private:
const ProjectService &project_service_;
OfflineSimulationService &offline_simulation_service_;
RuntimeState state_;
// 表示 PLC 缓存是否已通过至少一次有效读取建立
bool initial_plc_read_completed_ = false;


+ 295
- 0
app/src/services/software_logic_executor.cpp Vedi File

@@ -0,0 +1,295 @@
#include "software_logic_executor.h"

#include <map>
#include <type_traits>

namespace {

LogicScanResult success()
{
return {true, LogicScanError::None, {}, {}, {}, {}};
}

LogicScanResult failure(
LogicScanError error,
const std::string &message,
const std::string &logic_id = {},
const std::string &rung_id = {},
const std::string &node_id = {})
{
return {false, error, message, logic_id, rung_id, node_id};
}

bool compareWord(
std::int16_t actual,
ComparisonOperator comparison,
std::int16_t expected)
{
switch (comparison)
{
case ComparisonOperator::Equal:
{
return actual == expected;
}
case ComparisonOperator::NotEqual:
{
return actual != expected;
}
case ComparisonOperator::LessThan:
{
return actual < expected;
}
case ComparisonOperator::LessThanOrEqual:
{
return actual <= expected;
}
case ComparisonOperator::GreaterThan:
{
return actual > expected;
}
case ComparisonOperator::GreaterThanOrEqual:
{
return actual >= expected;
}
default:
{
return false;
}
}
}

} // namespace

LogicScanResult SoftwareLogicExecutor::validate(
const std::vector<ControlLogic> &logics) const
{
std::map<int, CoilMode> output_modes;
for (const ControlLogic &logic : logics)
{
std::string validation_error;
if (!logic.validateForRunning(&validation_error))
{
return failure(
LogicScanError::InvalidLogic,
validation_error,
logic.id);
}
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.output.has_value())
{
continue;
}
const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config);
if (output == nullptr)
{
return failure(
LogicScanError::InvalidLogic,
"ladder output is not a coil",
logic.id,
rung.id,
rung.output->id);
}
const auto existing = output_modes.find(output->address.index());
if (existing != output_modes.end() && existing->second != output->mode)
{
return failure(
LogicScanError::ConflictingOutput,
"mixed coil modes target " + output->address.toString(),
logic.id,
rung.id,
rung.output->id);
}
output_modes[output->address.index()] = output->mode;
}
}
return success();
}

LogicScanResult SoftwareLogicExecutor::executeScan(
const std::vector<ControlLogic> &logics,
RegisterRepository &repository) const
{
const LogicScanResult validation = validate(logics);
if (!validation.succeeded)
{
return validation;
}

for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (rung.stages.empty() && !rung.output.has_value())
{
continue;
}

bool rung_value = true;
for (const LadderStage &stage : rung.stages)
{
bool stage_value = false;
for (const LogicNode &node : stage.branches)
{
bool condition_value = false;
LogicScanResult result = evaluateCondition(
node, repository, &condition_value);
if (!result.succeeded)
{
result.logicId = logic.id;
result.rungId = rung.id;
return result;
}
stage_value = stage_value || condition_value;
}
rung_value = rung_value && stage_value;
}

LogicScanResult result = writeOutput(
*rung.output, rung_value, repository);
if (!result.succeeded)
{
result.logicId = logic.id;
result.rungId = rung.id;
return result;
}
}
}
return success();
}

LogicScanResult SoftwareLogicExecutor::evaluateCondition(
const LogicNode &node,
RegisterRepository &repository,
bool *value) const
{
if (value == nullptr)
{
return failure(
LogicScanError::InvalidLogic,
"condition result target is missing",
{},
{},
node.id);
}

return std::visit(
[&repository, value, &node](const auto &config) -> LogicScanResult
{
using Config = std::decay_t<decltype(config)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>)
{
const BitReadResult read = repository.readBit(config.address);
if (!read.succeeded)
{
return failure(
LogicScanError::RegisterReadFailed,
"failed to read " + config.address.toString(),
{},
{},
node.id);
}
*value = config.mode == ContactMode::NormallyOpen
? read.value : !read.value;
return success();
}
else if constexpr (std::is_same_v<Config, CompareNodeConfig>)
{
const WordReadResult read = repository.readWord(config.address);
if (!read.succeeded)
{
return failure(
LogicScanError::RegisterReadFailed,
"failed to read " + config.address.toString(),
{},
{},
node.id);
}
*value = compareWord(read.value, config.comparison, config.value);
return success();
}
else
{
return failure(
LogicScanError::InvalidLogic,
"condition node contains an output coil",
{},
{},
node.id);
}
},
node.config);
}

LogicScanResult SoftwareLogicExecutor::writeOutput(
const LogicNode &node,
bool rung_value,
RegisterRepository &repository) const
{
const auto *config = std::get_if<CoilNodeConfig>(&node.config);
if (config == nullptr)
{
return failure(
LogicScanError::InvalidLogic,
"ladder output is not a coil",
{},
{},
node.id);
}

bool should_write = true;
bool output_value = rung_value;
switch (config->mode)
{
case CoilMode::Normal:
{
break;
}
case CoilMode::Set:
{
should_write = rung_value;
output_value = true;
break;
}
case CoilMode::Reset:
{
should_write = rung_value;
output_value = false;
break;
}
default:
{
return failure(
LogicScanError::InvalidLogic,
"unsupported coil mode",
{},
{},
node.id);
}
}

if (!should_write)
{
return success();
}
const RegisterWriteResult write = repository.writeBit(
config->address, output_value);
if (!write.succeeded)
{
return failure(
LogicScanError::RegisterWriteFailed,
"failed to write " + config->address.toString(),
{},
{},
node.id);
}
return success();
}

+ 46
- 0
app/src/services/software_logic_executor.h Vedi File

@@ -0,0 +1,46 @@
#pragma once

#include "domain/control_logic_model.h"
#include "domain/register_repository.h"

#include <string>
#include <vector>

enum class LogicScanError
{
None,
InvalidLogic,
ConflictingOutput,
RegisterReadFailed,
RegisterWriteFailed
};

struct LogicScanResult
{
bool succeeded = false;
LogicScanError error = LogicScanError::None;
std::string message;
std::string logicId;
std::string rungId;
std::string nodeId;
};

// 按工程顺序执行受限梯形图的一次确定性扫描
class SoftwareLogicExecutor
{
public:
LogicScanResult validate(const std::vector<ControlLogic> &logics) const;
LogicScanResult executeScan(
const std::vector<ControlLogic> &logics,
RegisterRepository &repository) const;

private:
LogicScanResult evaluateCondition(
const LogicNode &node,
RegisterRepository &repository,
bool *value) const;
LogicScanResult writeOutput(
const LogicNode &node,
bool rung_value,
RegisterRepository &repository) const;
};

+ 7
- 1
app/src/ui/hmi_editor_widget.cpp Vedi File

@@ -309,6 +309,7 @@ void HmiEditorWidget::setRuntimeActive(bool active)
runtime_active_ = active;
if (!runtime_active_)
{
runtime_write_enabled_ = false;
for (QGraphicsItem *item : scene_->items())
{
HmiGraphicsItem *control_item = asHmiItem(item);
@@ -320,6 +321,11 @@ void HmiEditorWidget::setRuntimeActive(bool active)
}
}

void HmiEditorWidget::setRuntimeWriteEnabled(bool enabled)
{
runtime_write_enabled_ = runtime_active_ && enabled;
}

// 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形
void HmiEditorWidget::reloadPage()
{
@@ -474,7 +480,7 @@ void HmiEditorWidget::handleControlMoved(

void HmiEditorWidget::handleControlActivated(const std::string &control_id)
{
if (!runtime_active_)
if (!runtime_active_ || !runtime_write_enabled_)
{
return;
}


+ 3
- 0
app/src/ui/hmi_editor_widget.h Vedi File

@@ -52,6 +52,8 @@ public:
* @param active 为 true 时允许按钮和数值输入调用运行时服务
*/
void setRuntimeActive(bool active);
// 故障态保持运行值可见,但禁止按钮和数值输入继续写入
void setRuntimeWriteEnabled(bool enabled);
/**
* @brief 使用当前页面模型完全重建场景
*
@@ -115,4 +117,5 @@ private:
bool editing_enabled_ = true;
// 控制是否刷新运行值并响应运行态控件操作
bool runtime_active_ = false;
bool runtime_write_enabled_ = false;
};

+ 74
- 3
app/src/ui/main_window.cpp Vedi File

@@ -78,6 +78,10 @@ QString transitionErrorText(ModeTransitionError error)
{
return MainWindow::tr("工程存在未配置或未完成的 HMI/梯形图节点,暂不能进入离线运行态");
}
case ModeTransitionError::SimulationStartFailed:
{
return MainWindow::tr("离线逻辑执行器预检或启动失败");
}
case ModeTransitionError::None:
default:
{
@@ -367,8 +371,10 @@ void MainWindow::configureAppearance()
mode_status_label_->setMinimumWidth(88);
mode_status_label_->setAlignment(Qt::AlignCenter);
register_status_label_ = new QLabel(this);
register_status_label_->setObjectName(QStringLiteral("registerStatusLabel"));
register_status_label_->setMinimumWidth(132);
executor_status_label_ = new QLabel(this);
executor_status_label_->setObjectName(QStringLiteral("executorStatusLabel"));
executor_status_label_->setMinimumWidth(132);
statusBar()->addPermanentWidget(mode_status_label_);
statusBar()->addPermanentWidget(register_status_label_);
@@ -426,9 +432,15 @@ void MainWindow::configureHmiEditor()
if (runtime_mode_service_.mode() != ApplicationMode::Editing)
{
hmi_editor_widget_->refreshRuntimeValues();
updateSimulationUi(false);
}
});
runtime_refresh_timer_->start();
connect(&runtime_mode_service_.offlineSimulationService(),
&OfflineSimulationService::stateChanged,
this,
[this] { updateSimulationUi(true); },
Qt::QueuedConnection);
}

void MainWindow::configureLogicEditor()
@@ -1036,7 +1048,16 @@ void MainWindow::requestMode(ApplicationMode requested_mode)
if (!result.succeeded)
{
restoreCurrentModeAction();
statusBar()->showMessage(transitionErrorText(result.error), 5000);
QString message = transitionErrorText(result.error);
if (result.error == ModeTransitionError::SimulationStartFailed)
{
const LogicScanResult &error = runtime_mode_service_.simulationError();
if (!error.message.empty())
{
message += QStringLiteral(": ") + fromUtf8(error.message);
}
}
statusBar()->showMessage(message, 5000);
return;
}
updateModeUi(tr("已进入%1").arg(modeText(runtime_mode_service_.mode())));
@@ -1091,13 +1112,63 @@ void MainWindow::updateModeUi(const QString &message)
register_status_label_->setText(
policy.usesVirtualRegisters ? tr("数据源:虚拟 M/D")
: policy.usesPlcRegisters ? tr("数据源:PLC 缓存") : tr("数据源:未启用"));
executor_status_label_->setText(
policy.runsLogicExecutor ? tr("逻辑执行器:运行") : tr("逻辑执行器:停止"));
updateSimulationUi(false);
statusBar()->showMessage(message, 4000);
ui_->outputList->addItem(message);
ui_->outputList->scrollToBottom();
}

void MainWindow::updateSimulationUi(bool report_fault)
{
const bool offline = runtime_mode_service_.mode()
== ApplicationMode::OfflineRunning;
const SimulationState state = runtime_mode_service_.simulationState();
hmi_editor_widget_->setRuntimeWriteEnabled(
offline && state == SimulationState::Running);

switch (state)
{
case SimulationState::Running:
{
executor_status_label_->setText(
tr("逻辑执行器:运行(%1 次)")
.arg(runtime_mode_service_.successfulScanCount()));
break;
}
case SimulationState::Faulted:
{
executor_status_label_->setText(tr("逻辑执行器:故障"));
if (report_fault)
{
const LogicScanResult &error = runtime_mode_service_.simulationError();
QString message = tr("离线仿真异常:%1").arg(fromUtf8(error.message));
if (!error.logicId.empty())
{
message += tr(",逻辑 %1").arg(fromUtf8(error.logicId));
}
if (!error.rungId.empty())
{
message += tr(",网络 %1").arg(fromUtf8(error.rungId));
}
if (!error.nodeId.empty())
{
message += tr(",节点 %1").arg(fromUtf8(error.nodeId));
}
statusBar()->showMessage(message);
ui_->outputList->addItem(message);
ui_->outputList->scrollToBottom();
}
break;
}
case SimulationState::Stopped:
default:
{
executor_status_label_->setText(tr("逻辑执行器:停止"));
break;
}
}
}

// 根据当前真实运行模式,同步更新模式工具栏单选按钮选中状态
void MainWindow::restoreCurrentModeAction()
{


+ 2
- 0
app/src/ui/main_window.h Vedi File

@@ -126,6 +126,8 @@ private:
* @param message 要显示在状态栏和输出区的模式结果消息
*/
void updateModeUi(const QString &message);
// 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限
void updateSimulationUi(bool report_fault);

/**
* @brief 将互斥模式动作同步到服务层当前状态


+ 12
- 1
app/tests/main_window_tests.cpp Vedi File

@@ -3,6 +3,7 @@
#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/logic_editor_service.h"
#include "services/offline_simulation_service.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"
#include "ui/hmi_editor_widget.h"
@@ -62,7 +63,8 @@ void testModeActionsControlEditingAvailability()
LogicEditorService logic_editor_service(project_service);
VirtualRegisterRepository repository;
HmiRuntimeService runtime_service(repository);
RuntimeModeService mode_service(project_service);
OfflineSimulationService simulation_service(repository);
RuntimeModeService mode_service(project_service, simulation_service);
MainWindow window(
mode_service,
project_service,
@@ -90,6 +92,7 @@ void testModeActionsControlEditingAvailability()
QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit");
HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
window, "hmiEditorWidget");
QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel");

const qreal compact_scale = hmi_editor->transform().m11();
window.resize(1600, 900);
@@ -153,6 +156,10 @@ void testModeActionsControlEditingAvailability()
offline_action->trigger();
require(mode_service.mode() == ApplicationMode::OfflineRunning,
"offline action must enter offline running");
require(simulation_service.state() == SimulationState::Running,
"offline action must start the actual simulation service");
require(executor_status->text().contains(QStringLiteral("运行")),
"executor status label must report the actual running state");
require(!project_dock->isEnabled(),
"project dock must be disabled while running");
require(!properties_dock->isEnabled(),
@@ -171,6 +178,10 @@ void testModeActionsControlEditingAvailability()
editing_action->trigger();
require(mode_service.mode() == ApplicationMode::Editing,
"editing action must return to editing");
require(simulation_service.state() == SimulationState::Stopped,
"editing action must stop the simulation service");
require(executor_status->text() == QStringLiteral("逻辑执行器:停止"),
"executor status label must report the actual stopped state");
require(project_dock->isEnabled(),
"project dock must be restored after returning to editing");
require(properties_dock->isEnabled(),


+ 4
- 0
app/tests/main_window_tests.pro Vedi File

@@ -23,6 +23,8 @@ SOURCES += \
../src/services/hmi_editor_service.cpp \
../src/services/logic_editor_service.cpp \
../src/services/hmi_runtime_service.cpp \
../src/services/software_logic_executor.cpp \
../src/services/offline_simulation_service.cpp \
../src/services/runtime_mode_service.cpp

HEADERS += \
@@ -40,6 +42,8 @@ HEADERS += \
../src/services/hmi_editor_service.h \
../src/services/logic_editor_service.h \
../src/services/hmi_runtime_service.h \
../src/services/software_logic_executor.h \
../src/services/offline_simulation_service.h \
../src/services/runtime_mode_service.h

FORMS += \


+ 286
- 0
app/tests/offline_simulation_service_tests.cpp Vedi File

@@ -0,0 +1,286 @@
#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 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;
for (std::size_t index = 0; index < stages.size(); ++index)
{
result.stages.push_back({id + "-stage-" + std::to_string(index), stages[index]});
}
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");
}

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 testConflictingCoilsAreRejected()
{
SoftwareLogicExecutor executor;
const ControlLogic program = 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 result = executor.validate({program});
require(!result.succeeded && result.error == LogicScanError::ConflictingOutput,
"mixed output modes for one address must be rejected");
require(result.rungId == "set-rung" && result.nodeId == "set",
"conflict error must identify the offending rung and node");
}

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};
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.toggleButton(start).succeeded, "HMI start button 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.toggleButton(start).succeeded, "HMI start button must toggle off");
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();
testAllComparisons();
testSetResetAndDisabledLogic();
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;
}

+ 29
- 0
app/tests/offline_simulation_service_tests.pro Vedi File

@@ -0,0 +1,29 @@
QT += core
QT -= gui

TEMPLATE = app
TARGET = offline_simulation_service_tests

CONFIG += console c++17 warn_on
CONFIG -= app_bundle

INCLUDEPATH += ../src

SOURCES += \
offline_simulation_service_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/services/hmi_runtime_service.cpp \
../src/services/software_logic_executor.cpp \
../src/services/offline_simulation_service.cpp

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/services/hmi_runtime_service.h \
../src/services/software_logic_executor.h \
../src/services/offline_simulation_service.h

+ 11
- 1
app/tests/runtime_mode_service_tests.cpp Vedi File

@@ -1,6 +1,8 @@
#include "services/runtime_mode_service.h"
#include "services/offline_simulation_service.h"
#include "services/project_service.h"
#include "domain/project_storage.h"
#include "domain/register_repository.h"

#include <iostream>
#include <stdexcept>
@@ -35,7 +37,9 @@ void testModeTransitions()
// 验证服务将 PLC 首读状态与领域模式切换规则正确组合
TestProjectStorage storage;
ProjectService project_service(storage);
RuntimeModeService service(project_service);
VirtualRegisterRepository repository;
OfflineSimulationService simulation_service(repository);
RuntimeModeService service(project_service, simulation_service);

require(service.mode() == ApplicationMode::Editing,
"service must start in editing mode");
@@ -44,6 +48,8 @@ void testModeTransitions()

require(service.enterOfflineRunning().succeeded,
"editing mode must enter offline running");
require(service.simulationState() == SimulationState::Running,
"offline mode must start the software executor");
require(service.policy().usesVirtualRegisters,
"offline running must use virtual registers");
require(service.enterOnlineRunning().error
@@ -52,6 +58,8 @@ void testModeTransitions()

require(service.enterEditing().succeeded,
"offline running must return to editing");
require(service.simulationState() == SimulationState::Stopped,
"returning to editing must stop the software executor first");
require(service.enterOnlineRunning().error
== ModeTransitionError::InitialPlcReadRequired,
"online running must require an initial PLC read");
@@ -61,6 +69,8 @@ void testModeTransitions()
"service must retain the initial PLC read state");
require(service.enterOnlineRunning().succeeded,
"online running must start after an initial PLC read");
require(service.simulationState() == SimulationState::Stopped,
"online running must never start the software executor");
require(service.policy().usesPlcRegisters,
"online running must use PLC registers");
require(!service.policy().runsLogicExecutor,


+ 6
- 0
app/tests/runtime_mode_service_tests.pro Vedi File

@@ -11,19 +11,25 @@ INCLUDEPATH += ../src
SOURCES += \
runtime_mode_service_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/domain/runtime_state.cpp \
../src/services/project_service.cpp \
../src/services/software_logic_executor.cpp \
../src/services/offline_simulation_service.cpp \
../src/services/runtime_mode_service.cpp

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/project_storage.h \
../src/domain/runtime_state.h \
../src/services/project_service.h \
../src/services/software_logic_executor.h \
../src/services/offline_simulation_service.h \
../src/services/runtime_mode_service.h

+ 14
- 3
docs/ai/handoff.md Vedi File

@@ -2,7 +2,7 @@

- Goal: 完成综合平台编程器的工程管理和后续编辑功能。
- Branch: `main`。
- Current status: 已完成开发顺序第 7 步,控制逻辑图形编辑、领域校验和工程持久化闭环已完成。
- Current status: 已完成开发顺序第 8 步,HMI、虚拟 M/D 和固定周期软件逻辑执行器的离线闭环已完成。
- Changed files:
- `app/integrated_platform.pro`
- `app/src/main.cpp`
@@ -47,6 +47,12 @@
- `app/tests/hmi_editor_service_tests.pro`
- `app/tests/hmi_editor_service_tests.cpp`
- `app/tests/runtime_mode_service_tests.pro`
- `app/src/services/software_logic_executor.h`
- `app/src/services/software_logic_executor.cpp`
- `app/src/services/offline_simulation_service.h`
- `app/src/services/offline_simulation_service.cpp`
- `app/tests/offline_simulation_service_tests.pro`
- `app/tests/offline_simulation_service_tests.cpp`
- `app/tests/runtime_mode_service_tests.cpp`
- `app/tests/main_window_tests.pro`
- `app/tests/main_window_tests.cpp`
@@ -98,6 +104,10 @@
- `RuntimeModeService` 依赖 `ProjectService` 的只读工程,在进入离线运行态前执行完整性校验,拒绝未配置或未完成的工程。
- JSON 节点新增 `configured` 字段;缺失字段的旧版 `1.0` 工程按历史逻辑视为已配置,保持兼容读取。
- 梯形图 UI 已按 LAD 可读性优化:普通线圈采用标准 `( )` 形状,导线连接元件端子而不穿透符号,比较条件显示为带数据类型和上下操作数的指令块。
- 软件逻辑执行器按逻辑、网络顺序扫描,级内 OR、级间 AND,前面网络写入对后面网络同周期可见。
- 普通线圈每周期写结果,置位/复位线圈只在网络成立时写入;同一地址混用不同线圈模式会在启动前被拒绝。
- 离线仿真以 50 ms 为目标扫描周期,启动时复制逻辑快照并清空虚拟 M/D,退出时先停止执行器再返回编辑态。
- 仿真状态使用 `Stopped`、`Running`、`Faulted`,记录扫描次数和逻辑/网络/节点错误上下文;故障后 HMI 保持最终值可见但禁止写入。
- Validation run and results:
- 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。
- 已启动 `integrated_platform.exe` 并正常退出。
@@ -123,6 +133,7 @@
- 启动 Debug 应用确认主窗口创建成功,窗口标题为“综合平台编程器”。
- 梯形图结构重构后,领域、逻辑编辑服务、工程管理和主窗口测试重新构建并全部通过。
- 待配置节点改造后,Release 应用构建成功;领域、工程管理、逻辑编辑服务、运行模式和主窗口测试全部通过。
- Remaining work: 进入开发顺序第 8 步,实现离线软件逻辑执行器和 HMI 闭环。
- 离线仿真实现后,Release 应用构建成功;离线仿真、领域、工程管理、HMI 编辑、逻辑编辑、运行模式和主窗口测试全部通过。
- Remaining work: 进入开发顺序第 9 步,实现 Modbus RTU 真机通信。
- Known risks / blockers: 无。
- Suggested next command: 为控制逻辑建立固定周期的软件执行器,按网络顺序执行并计算级间 AND 与级内 OR
- Suggested next command: 建立异步 Modbus RTU 通信服务,先完成串口配置、连接和 M0/D0 安全读写

+ 11
- 0
docs/architecture.md Vedi File

@@ -86,6 +86,17 @@ main.cpp -> UI + Services + Infrastructure
数值显示读取 D 字,数值输入写入 D 字。它不依赖虚拟寄存器实现、串口或 Modbus,因此离线和真机
运行可替换数据源而无需改变 HMI 模型或画布代码。

`services/SoftwareLogicExecutor` 负责一次确定性梯形图扫描。执行顺序为控制逻辑、网络、串联级,
同级并联条件执行 OR,不同串联级执行 AND;普通线圈每周期写入网络结果,置位和复位线圈只在
网络成立时写入。前面网络的写入对后面网络在同一扫描周期立即可见。启动前拒绝同一 M 地址混用
不同线圈模式,避免运行结果依赖隐藏顺序。

`services/OfflineSimulationService` 使用 `Qt::PreciseTimer` 以 50 ms 目标周期驱动扫描器。服务启动时
先校验并复制控制逻辑快照,再清空虚拟 M/D 并开始扫描;退出离线态时先停止扫描再开放工程编辑。
该周期不提供硬实时保证。服务记录 `Stopped`、`Running`、`Faulted` 实际状态、成功扫描次数和最近
错误上下文。发生仓库读写或执行错误时停止定时器、保留最终寄存器值和逻辑/网络/节点定位;HMI
仍可读取最终值,但按钮和数值输入进入只读状态。

控制逻辑节点使用 `std::variant` 组合独立配置类型。初版只包含 M 区触点、M 区线圈和 D 值与
常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续
添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。


Caricamento…
Annulla
Salva