Commit 0fb78cc4 authored by Developer's avatar Developer

feat: 半实物仿真 FMU Bridge 数据采集与结果查看

- FmuBridgeNode: 真实 FMU 加载(dlopen) + doStep 仿真循环 + CSV 数据采集
- FMU 变量发现: 块级 XML 解析 modelDescription.xml, 按 valueReference 去重
- SIGTERM 优雅停止: 收到信号后写入已采集数据再退出
- 新增 GET /api/hil/results 接口返回 CSV 仿真结果
- 前端: 仿真时长输入框、状态轮询(3s)、自动获取结果、完成通知
- 停止后自动获取部分数据
- 新增 api-sync skill 和半实物仿真方案文档
- 接口文档同步更新
parent 1683fc76
---
name: api-sync
description: 前后端 API 接口变更时,必须同步更新接口文档
---
# API 接口文档同步规则
## 核心原则
**凡是涉及前后端 API 接口修改的变更,都必须同步更新接口文档。**
## 文档位置
| 文档 | 路径 | 说明 |
|------|------|------|
| 前端接口文档 | `/home/cloud/code/eplanvisualizer/docs/接口文档.md` | 面向前端开发者,描述所有后端 API |
| 前端 API 封装 | `/home/cloud/code/eplanvisualizer/src/utils/api.js` | 前端请求函数,JSDoc 注释即文档 |
| 后端控制器 | `/home/cloud/code/openmodelica/sim_backend/src/controllers/` | 实际的 API 实现 |
| 后端服务头文件 | `/home/cloud/code/openmodelica/sim_backend/src/services/` | 数据结构定义 |
## 触发条件
以下任意变更都**必须**同步文档:
1. **新增接口** — 在 `接口文档.md` 中新增对应的接口段落
2. **修改请求字段** — 更新请求参数表
3. **修改响应字段** — 更新响应示例和字段说明
4. **修改数据结构** — 同步更新 C++ struct 和前端 JS 的 JSDoc
5. **删除接口** — 从文档中移除对应段落
## 文档格式规范
每个接口段落格式如下:
```markdown
## METHOD /api/path
简要说明。
**请求:**
\```json
{ "field_name": "value" }
\```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| field_name | string | ✅ | 说明 |
**成功响应** (`code: 0`):
\```json
{ "code": 0, "message": "ok", "data": { ... } }
\```
**失败响应** (`code: 1001`):
\```json
{ "code": 1001, "message": "...", "data": { ... } }
\```
```
## 检查清单
修改 API 时,确认以下所有地方都已同步:
- [ ] `docs/接口文档.md` — 接口文档
- [ ] `src/utils/api.js` — 前端封装 + JSDoc
- [ ] 后端 Controller `.cpp` — 请求解析
- [ ] 后端 Service `.h` — 数据结构 (struct)
# 半实物仿真 (HIL) 技术方案
## 概述
半实物仿真 (Hardware-in-the-Loop) 将 Modelica 电路模型编译为 FMU (Functional Mock-up Unit),
通过 ROS2 节点架构实现仿真模型与实物/模拟硬件的实时交互。
## 架构
```
┌─────────────────────────────────────────────────────────────────┐
│ 前端 (React) │
│ ┌────────────────┐ ┌──────────────┐ ┌────────────────────┐ │
│ │ 画布: 标记实物 │→│ 导出 Modelica │→│ 调用 /api/hil/start │ │
│ │ isHardware=true │ │ + 半实物配置 │ │ duration/stepSize │ │
│ └────────────────┘ └──────────────┘ └────────────────────┘ │
│ ↕ 轮询 status │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 结果展示: SimResultsModal (CSV 表格 + 图表) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
▼ HTTP
┌─────────────────────────────────────────────────────────────────┐
│ 后端 (Drogon C++) │
│ ┌────────────┐ ┌───────────┐ ┌──────────────────────────┐ │
│ │ HilCtrl │→│ HilService │→│ 1. CompileModel (omc) │ │
│ │ start/stop │ │ 会话管理 │ │ 2. ExportFMU (buildFMU) │ │
│ │ status │ │ │ │ 3. LaunchRos2Node (fork) │ │
│ │ results │ │ │ └──────────────────────────┘ │
│ └────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘
▼ fork + ros2 run
┌─────────────────────────────────────────────────────────────────┐
│ ROS2 节点 (Humble) │
│ │
│ ┌──────────────────┐ ROS2 Topic ┌─────────────────────┐ │
│ │ fmu_bridge_node │ ←─────────────→ │ hardware_sim_node │ │
│ │ │ /hil/X/fmu_out │ (每个实物节点一个) │ │
│ │ ┌─FMU─────────┐ │ /hil/X/hw_out │ │ │
│ │ │ dlopen .so │ │ │ 模式: switch/ │ │
│ │ │ doStep() │ │ │ resistor/capacitor │ │
│ │ │ getReal() │ │ │ /motor/linear... │ │
│ │ │ setReal() │ │ └─────────────────────┘ │
│ │ └─────────────┘ │ │
│ │ CSV 内存缓冲 │ │
│ │ → 完成后写文件 │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## 流程详解
### 1. 前端导出
用户在画布中将节点标记为「实物」(`isHardware: true`),设置仿真时长后点击启动。
`exportToModelicaHIL()` 生成:
- 完整的 Modelica `.mo` 代码
- `hardwarePorts` 数组:每个实物节点的 Modelica 实例名、模式、端口列表
### 2. 后端编译与 FMU 导出
`HilService::StartSession()` 流程:
| 步骤 | 操作 | 状态 |
|------|------|------|
| 1 | 写 `.mo` 文件 → `omc compile_hil.mos` 检查语法 | `compiling` |
| 2 | `omc export_fmu.mos` → 生成 FMI 2.0 Co-Simulation `.fmu` | `exporting_fmu` |
| 3 | fork 启动 `fmu_bridge_node` + N 个 `hardware_sim_node` | `launching_ros2` |
| 4 | 仿真运行中 | `running` |
| 5 | 仿真完成 / 手动停止 | `done` / `stopped` |
### 3. FMU Bridge 节点
`FmuBridgeNode.cpp` 是核心仿真驱动:
```
启动流程:
1. 解压 .fmu (unzip) → 找 binaries/linux64/*.so
2. dlopen() 加载共享库 → 获取 FMI2 函数指针
3. 解析 modelDescription.xml:
- 提取 GUID 和 modelIdentifier
- 块级解析 <ScalarVariable> 发现所有变量
- 按 valueReference 去重,排除 parameter
4. fmi2Instantiate → setupExperiment → enterInit → exitInit
5. 启动 ROS2 wall timer (周期 = step_size)
仿真循环 (每步):
├ 检查 g_stop_requested (SIGTERM)
├ 检查 sim_time + 0.5*step >= duration (完成)
├ doStep(sim_time, step_size)
├ getReal() 读取所有输出变量
├ 发布到 ROS2 话题
└ 追加到 csv_rows_ 内存缓冲
结束:
├ WriteCSV() → 一次性写入文件
├ 发布 /hil/done 消息
└ rclcpp::shutdown()
```
**SIGTERM 处理:** 收到停止信号时立即写入已采集数据再退出。
### 4. Hardware Sim 节点
`HardwareSimNode.cpp` 模拟实物设备行为:
| 模式 | 行为 |
|------|------|
| `resistor` | V_in → I_out = V/R |
| `capacitor` | V_in → I_out = C*dV/dt |
| `inductor` | I_in → V_out = L*dI/dt |
| `switch` | 周期开关 |
| `voltage_src` | 恒定电压输出 |
| `motor` | V_in → 速度 (含惯性) |
| `linear` | output = gain * input (默认) |
### 5. 结果获取
仿真完成后 CSV 数据通过 `GET /api/hil/results?session_id=xxx` 返回。
前端每 3 秒轮询状态,`done` 后自动获取结果并弹出图表视图。
## API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/hil/start` | 启动 HIL 会话 |
| GET | `/api/hil/status?session_id=` | 查询状态 |
| POST | `/api/hil/stop` | 停止会话 |
| GET | `/api/hil/results?session_id=` | 获取 CSV 结果 |
详细请求/响应格式见 [接口文档.md](接口文档.md)
## 文件清单
### 前端
| 文件 | 职责 |
|------|------|
| `utils/modelicaExporter.js` | `exportToModelicaHIL()` 导出模型 + 实物配置 |
| `utils/api.js` | `startHilSession`, `stopHilSession`, `getHilResults`, `getHilStatus` |
| `hooks/useProjectStore.js` | `startHil`, `stopHil`, `fetchHilResults` + 轮询逻辑 |
| `components/ProjectPanel/ProjectPanel.jsx` | 仿真时长输入、启动/停止按钮、结果查看 |
### 后端
| 文件 | 职责 |
|------|------|
| `controllers/HilCtrl.h/cpp` | HTTP 端点: start, status, stop, results |
| `services/HilService.h/cpp` | 编译、FMU 导出、ROS2 进程管理、会话状态 |
### ROS2 节点
| 文件 | 职责 |
|------|------|
| `ros2_nodes/src/FmuBridgeNode.cpp` | FMU 加载/步进/数据采集/CSV 输出 |
| `ros2_nodes/src/HardwareSimNode.cpp` | 多模式实物设备模拟 |
| `ros2_nodes/src/fmi2*.h` | FMI 2.0 标准头文件 |
| `ros2_nodes/CMakeLists.txt` | 编译配置 (链接 dl) |
## 性能特性
- **数据缓冲:** CSV 数据在内存 `vector<string>` 中累积,仿真结束一次性写入文件
- **采样率:** 每 1ms 记录一点,可通过 `sample_rate_` 调整
- **步长建议:** 100ms 步长完全满足 ROS2 timer 精度要求
- **10s/1ms 仿真:** 约 10000 步,实时运行 ~10s,CSV ~1MB
......@@ -118,3 +118,177 @@
- 后端需安装 OpenModelica,确保 `omc` 命令可用
- 编译产物存放在 `/tmp/simuflow_workspace/`
---
## POST /api/hil/start
启动半实物仿真会话。编译**完整模型**为 FMU,并为每个实物节点启动 ROS2 仿真进程。
**请求:**
```json
{
"mo_code": "model Circuit...",
"model_name": "Circuit_1",
"hardware_ports": [
{
"name": "idealSwitch_1",
"instance_name": "idealSwitch_1",
"model_type": "ideal_switch",
"sim_mode": "switch",
"ports": ["p", "n"],
"fmu_var_prefix": "idealSwitch_1",
"topic": "/Circuit_1/idealSwitch_1",
"hw_label": "开关",
"hw_node_id": "new-device-3"
}
],
"step_size": 0.001,
"duration": 10.0
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| mo_code | string | ✅ | 完整 .mo 源代码(含实物节点) |
| model_name | string | ✅ | 模型名称 |
| hardware_ports | array | ✅ | 实物节点描述列表 |
| hardware_ports[].name | string | ✅ | Modelica 实例名 |
| hardware_ports[].instance_name | string | ✅ | 同 name |
| hardware_ports[].model_type | string | - | 设备类型,如 `ideal_switch`, `resistor` |
| hardware_ports[].sim_mode | string | - | 仿真模式,如 `switch`, `linear`, `resistor` |
| hardware_ports[].ports | string[] | - | Modelica 端口列表,如 `["p", "n"]` |
| hardware_ports[].fmu_var_prefix | string | - | FMU 变量前缀 |
| hardware_ports[].topic | string | - | ROS2 话题前缀 |
| hardware_ports[].hw_label | string | - | 节点显示名称 |
| hardware_ports[].hw_node_id | string | - | 前端节点 ID |
| step_size | number | - | 仿真步长(秒),默认 0.001 |
| duration | number | - | 仿真时长(秒),默认 10.0 |
**成功响应** (`code: 0`):
```json
{
"code": 0,
"message": "半实物仿真已启动",
"data": {
"session_id": "uuid-string",
"status": "running",
"fmu_path": "/tmp/.../Circuit_1.fmu",
"ports": [
{
"name": "idealSwitch_1",
"instance_name": "idealSwitch_1",
"model_type": "ideal_switch",
"sim_mode": "switch",
"topic": "/Circuit_1/idealSwitch_1",
"hw_label": "开关"
}
],
"hw_processes": [
{
"hw_label": "开关",
"hw_node_id": "new-device-3",
"input_topic": "/Circuit_1/idealSwitch_1/fmu_out",
"output_topic": "/Circuit_1/idealSwitch_1/hw_out",
"sim_mode": "switch",
"pid": 12345
}
]
}
}
```
**失败响应** (`code: 1001`):
```json
{
"code": 1001,
"message": "半实物仿真启动失败",
"data": {
"session_id": "uuid-string",
"error_detail": "编译错误详情..."
}
}
```
---
## GET /api/hil/status
查询半实物仿真会话状态。
**请求参数:**
| 参数 | 位置 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| session_id | query | string | ✅ | 会话 ID |
**成功响应** (`code: 0`):
```json
{
"code": 0,
"message": "ok",
"data": {
"session_id": "uuid-string",
"status": "running",
"model_name": "Circuit_1",
"fmu_path": "/tmp/.../Circuit_1.fmu",
"error_detail": "",
"hw_processes": [...]
}
}
```
---
## POST /api/hil/stop
停止半实物仿真会话,终止所有 ROS2 进程。
**请求:**
```json
{ "session_id": "uuid-string" }
```
**成功响应** (`code: 0`):
```json
{
"code": 0,
"message": "会话已停止",
"data": { "session_id": "uuid-string" }
}
```
---
## GET /api/hil/results
获取半实物仿真结果 CSV 数据。仿真完成后可调用。
**请求参数:**
| 参数 | 位置 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| session_id | query | string | ✅ | 会话 ID |
**成功响应** (`code: 0`):
```json
{
"code": 0,
"message": "ok",
"data": {
"session_id": "uuid-string",
"model_name": "Circuit_1",
"csv_data": "time,var1,var2\n0.001,1.23,4.56\n...",
"csv_path": "/tmp/.../results.csv"
}
}
```
**失败响应** (`code: 404`):
```json
{
"code": 404,
"message": "结果文件未就绪",
"data": {}
}
```
......@@ -29,7 +29,7 @@ function sideToPosition(side) {
}
function CustomDeviceNode({ data, selected }) {
const { color = '#6366f1', templateData } = data;
const { color = '#6366f1', templateData, isHardware } = data;
if (!templateData) return null;
const { name, icon, ports, width: tW, height: tH } = templateData;
......@@ -43,9 +43,13 @@ function CustomDeviceNode({ data, selected }) {
position: 'relative',
background: '#1e1e2e',
borderRadius: 8,
outline: `2px solid ${selected ? '#fff' : color}`,
outline: isHardware
? '2px dashed #fb923c'
: `2px solid ${selected ? '#fff' : color}`,
outlineOffset: -1,
boxShadow: selected ? `0 0 12px ${color}88` : '0 4px 12px rgba(0,0,0,0.3)',
boxShadow: isHardware
? '0 0 14px rgba(251,146,60,0.35)'
: selected ? `0 0 12px ${color}88` : '0 4px 12px rgba(0,0,0,0.3)',
overflow: 'visible',
transform: rotation ? `rotate(${rotation}deg)` : undefined,
fontFamily: "'Segoe UI', sans-serif",
......@@ -64,9 +68,12 @@ function CustomDeviceNode({ data, selected }) {
<span style={{ fontSize: 14 }}>{icon}</span>
<span style={{
fontSize: 11, fontWeight: 600, color: '#fff',
letterSpacing: 0.5,
letterSpacing: 0.5, flex: 1,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{name}</span>
{isHardware && (
<span style={{ fontSize: 12, flexShrink: 0 }} title="实物设备">🔧</span>
)}
</div>
{/* 编辑器中绘制的形状 - body 区域 */}
......@@ -168,6 +175,7 @@ function CustomDeviceNode({ data, selected }) {
...handleStyle,
background: 'transparent',
border: 'none',
zIndex: 10,
}}
/>
{/* source Handle - 可见端点圆,发起连接 */}
......@@ -180,6 +188,7 @@ function CustomDeviceNode({ data, selected }) {
background: portColor,
border: '2px solid #1e1e2e',
cursor: 'crosshair',
zIndex: 10,
}}
/>
......
......@@ -20,6 +20,8 @@ function StatusBadge({ status, labels }) {
none: { cls: styles.statusNone, text: labels?.none || '未编译' },
compiling: { cls: styles.statusCompiling, text: '编译中…' },
running: { cls: styles.statusRunning, text: '执行中…' },
stopped: { cls: styles.statusSuccess, text: labels?.stopped || '已停止' },
done: { cls: styles.statusSuccess, text: '已完成' },
success: { cls: styles.statusSuccess, text: labels?.success || '成功' },
error: { cls: styles.statusError, text: labels?.error || '失败' },
};
......@@ -41,6 +43,9 @@ export default function ProjectPanel() {
updateModelName,
compileProject,
executeProject,
startHil,
stopHil,
fetchHilResults,
} = useProjectStore();
const nodes = useFlowStore(s => s.nodes);
......@@ -49,6 +54,7 @@ export default function ProjectPanel() {
const [showCompileLog, setShowCompileLog] = useState(false);
const [showExecuteLog, setShowExecuteLog] = useState(false);
const [showResults, setShowResults] = useState(false);
const [hilDuration, setHilDuration] = useState(10);
const activeProject = projects.find(p => p.id === activeProjectId) || null;
......@@ -63,6 +69,17 @@ export default function ProjectPanel() {
}
}, [activeProject?.executeStatus]);
// HIL 仿真完成通知
const prevHilStatusRef = useRef(activeProject?.hilStatus);
useEffect(() => {
const prev = prevHilStatusRef.current;
const curr = activeProject?.hilStatus;
prevHilStatusRef.current = curr;
if (prev === 'running' && curr === 'done') {
alert('✅ 半实物仿真已完成!点击「📊 查看 HIL 结果」可查看数据。');
}
}, [activeProject?.hilStatus]);
// Ctrl+S 快捷键
useEffect(() => {
const handler = (e) => {
......@@ -238,6 +255,106 @@ export default function ProjectPanel() {
</button>
</div>
{/* 半实物仿真按钮 */}
{(() => {
const hwCount = (p.nodes || []).filter(n => n.data?.isHardware).length;
return (
<>
{/* 仿真时长输入 */}
{hwCount > 0 && p.hilStatus !== 'running' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<label style={{ fontSize: 11, color: '#888', whiteSpace: 'nowrap' }}>仿真时长:</label>
<input
type="number"
min="1" max="3600" step="1"
value={hilDuration}
onChange={(e) => setHilDuration(Number(e.target.value) || 10)}
onClick={(e) => e.stopPropagation()}
style={{
flex: 1, padding: '3px 6px', fontSize: 11,
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 4, color: '#ccc', outline: 'none',
}}
/>
<span style={{ fontSize: 11, color: '#666' }}></span>
</div>
)}
<button
className={`${styles.simBtn} ${styles.execute}`}
style={{
width: '100%', marginBottom: 4,
background: hwCount > 0 ? 'rgba(251,146,60,0.08)' : undefined,
borderColor: hwCount > 0 ? 'rgba(251,146,60,0.3)' : undefined,
color: hwCount > 0 ? '#fb923c' : undefined,
}}
disabled={hwCount === 0 || p.hilStatus === 'starting' || p.hilStatus === 'running'}
title={hwCount === 0 ? '请先在属性面板中将节点标记为实物' : `${hwCount} 个实物节点`}
onClick={(e) => { e.stopPropagation(); startHil(p.id, { duration: hilDuration }); }}
>
{p.hilStatus === 'starting' ? (
<><span className={styles.spinner}></span> 启动中…</>
) : (
<>🔧 半实物仿真 {hwCount > 0 && <span style={{ opacity: 0.7 }}>({hwCount})</span>}</>
)}
</button>
</>
);
})()}
{/* HIL 状态显示 */}
{p.hilStatus && p.hilStatus !== 'none' && (
<div className={styles.statusRow} style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 2 }}>
<span style={{ fontSize: 11, color: '#888' }}>半实物仿真:</span>
<StatusBadge status={p.hilStatus === 'starting' ? 'compiling' : p.hilStatus === 'running' ? 'running' : p.hilStatus}
labels={{ none: '未启动', success: '就绪', error: '失败' }} />
{p.hilResult?.message && (
<span style={{ fontSize: 10, color: p.hilStatus === 'error' ? '#ef4444' : '#aaa' }}>
{p.hilResult.message}
</span>
)}
{p.hilResult?.fmuPath && (
<span style={{ fontSize: 9, color: '#666', wordBreak: 'break-all' }}>
FMU: {p.hilResult.fmuPath}
</span>
)}
{p.hilResult?.errorDetail && (
<span style={{ fontSize: 9, color: '#ef4444', wordBreak: 'break-all' }}>
{p.hilResult.errorDetail}
</span>
)}
{/* 停止按钮 */}
{p.hilStatus === 'running' && (
<button
className={`${styles.simBtn}`}
style={{
width: '100%', marginTop: 4,
background: 'rgba(239,68,68,0.08)',
borderColor: 'rgba(239,68,68,0.3)',
color: '#ef4444',
}}
onClick={(e) => { e.stopPropagation(); stopHil(p.id); }}
>⏹ 停止仿真</button>
)}
</div>
)}
{/* 查看 HIL 结果 */}
{(p.hilStatus === 'done' || p.hilStatus === 'stopped') && p.hilResult?.csvData && (
<button
className={`${styles.simBtn} ${styles.viewResults}`}
onClick={(e) => { e.stopPropagation(); setShowResults(true); }}
style={{ width: '100%', marginBottom: 4 }}
>📊 查看 HIL 结果</button>
)}
{/* 获取结果按钮(运行中/停止后可尝试获取) */}
{(p.hilStatus === 'running' || p.hilStatus === 'stopped') && !p.hilResult?.csvData && (
<button
className={`${styles.simBtn}`}
style={{ width: '100%', marginBottom: 4, color: '#60a5fa' }}
onClick={(e) => { e.stopPropagation(); fetchHilResults(p.id); }}
>📥 获取仿真结果</button>
)}
{/* 查看结果 */}
{p.executeStatus === 'success' && p.executeResult?.csvData && (
<button
......@@ -284,9 +401,9 @@ export default function ProjectPanel() {
</div>
{/* 仿真结果弹窗 */}
{showResults && activeProject?.executeResult?.csvData && (
{showResults && (activeProject?.executeResult?.csvData || activeProject?.hilResult?.csvData) && (
<SimResultsModal
csvData={activeProject.executeResult.csvData}
csvData={activeProject.executeResult?.csvData || activeProject.hilResult?.csvData}
modelName={activeProject.modelName || 'Circuit'}
onClose={() => setShowResults(false)}
/>
......
......@@ -5,6 +5,7 @@
*/
import { useCallback, useState, useRef } from 'react';
import useFlowStore from '../../hooks/useFlowStore';
import { getModelMapping } from '../../utils/modelMapping';
import styles from './PropertiesPanel.module.css';
export default function PropertiesPanel() {
......@@ -13,6 +14,7 @@ export default function PropertiesPanel() {
selectedEdge,
updateNodeData,
updateNodeParam,
toggleHardware,
removeSelectedNode,
removeSelectedEdge,
} = useFlowStore();
......@@ -102,6 +104,42 @@ export default function PropertiesPanel() {
))}
</div>
{/* 半实物仿真: 实物设备标记(流程节点不显示) */}
{!getModelMapping(selectedNode.data?.templateData?.type)?.isFlowNode && (
<>
<label className={styles.label}>半实物仿真</label>
<div
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 10px', borderRadius: 6,
background: selectedNode.data?.isHardware ? 'rgba(251,146,60,0.12)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${selectedNode.data?.isHardware ? 'rgba(251,146,60,0.4)' : '#2a2a3a'}`,
cursor: 'pointer', transition: 'all 0.2s',
}}
onClick={() => toggleHardware(selectedNode.id)}
>
<div style={{
width: 36, height: 20, borderRadius: 10, position: 'relative',
background: selectedNode.data?.isHardware ? '#fb923c' : '#333',
transition: 'background 0.2s',
}}>
<div style={{
width: 16, height: 16, borderRadius: '50%', background: '#fff',
position: 'absolute', top: 2,
left: selectedNode.data?.isHardware ? 18 : 2,
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}} />
</div>
<span style={{
fontSize: 12, fontWeight: 600,
color: selectedNode.data?.isHardware ? '#fb923c' : '#666',
}}>
{selectedNode.data?.isHardware ? '🔧 实物设备' : '软件仿真'}
</span>
</div>
</>
)}
{/* 模型参数 */}
{selectedNode.data?.templateData?.params?.length > 0 && (
<>
......
......@@ -185,6 +185,33 @@ const useFlowStore = create((set, get) => ({
});
},
/** 清理孤儿边 — 删除 handle 指向不存在端口的连线 */
cleanStaleEdges: () => {
const { nodes, edges } = get();
const nodeMap = {};
nodes.forEach(n => { nodeMap[n.id] = n; });
const validEdges = edges.filter(edge => {
const srcNode = nodeMap[edge.source];
const tgtNode = nodeMap[edge.target];
if (!srcNode || !tgtNode) return false;
const srcPorts = srcNode.data?.templateData?.ports || [];
const tgtPorts = tgtNode.data?.templateData?.ports || [];
const srcOk = srcPorts.some(p => p.id === edge.sourceHandle);
const tgtOk = tgtPorts.some(p => p.id === edge.targetHandle);
return srcOk && tgtOk;
});
const removed = edges.length - validEdges.length;
if (removed > 0) {
console.warn(`cleanStaleEdges: 移除 ${removed} 条无效连线`);
set({ edges: validEdges });
}
return removed;
},
updateNodeData: (nodeId, newData) => {
set({
nodes: get().nodes.map(n =>
......@@ -193,6 +220,18 @@ const useFlowStore = create((set, get) => ({
});
},
/** 切换节点的「实物」标记 */
toggleHardware: (nodeId) => {
const nodes = get().nodes.map(n => {
if (n.id !== nodeId) return n;
return { ...n, data: { ...n.data, isHardware: !n.data.isHardware } };
});
const selectedNode = get().selectedNode;
const updatedSelected = selectedNode?.id === nodeId
? nodes.find(n => n.id === nodeId) : selectedNode;
set({ nodes, selectedNode: updatedSelected });
},
clearAll: () => {
set({ nodes: [], edges: [], selectedNode: null, selectedEdge: null });
},
......
......@@ -5,8 +5,8 @@
*/
import { create } from 'zustand';
import useFlowStore from './useFlowStore';
import { compileModel, executeModel } from '../utils/api';
import { exportToModelica } from '../utils/modelicaExporter';
import { compileModel, executeModel, startHilSession, stopHilSession, getHilResults, getHilStatus } from '../utils/api';
import { exportToModelica, exportToModelicaHIL } from '../utils/modelicaExporter';
const STORAGE_KEY = 'eplan_projects';
......@@ -159,6 +159,8 @@ const useProjectStore = create((set, get) => ({
if (!project) return;
// 使用当前画布最新数据生成 .mo(与导出按钮一致)
// 先清理指向不存在端口的孤儿边
useFlowStore.getState().cleanStaleEdges();
const { nodes, edges } = useFlowStore.getState();
const setStatus = (updates) => {
......@@ -244,6 +246,165 @@ const useProjectStore = create((set, get) => ({
}
},
/** 半实物仿真 - 轮询定时器 */
_hilPollingTimer: null,
/** 启动半实物仿真 */
startHil: async (id, { duration = 10, stepSize = 0.001 } = {}) => {
get().saveProject();
const { projects } = get();
const project = projects.find(p => p.id === id);
if (!project) return;
const { nodes, edges } = useFlowStore.getState();
const setStatus = (updates) => {
const updated = get().projects.map(p =>
p.id === id ? { ...p, ...updates } : p
);
persistProjects(updated);
set({ projects: updated });
};
setStatus({ hilStatus: 'starting', hilResult: null });
try {
// 用 HIL 导出生成拆分模型
const modelName = project.modelName || 'Circuit';
const exported = exportToModelicaHIL({ nodes, edges }, modelName);
if (exported.errors && exported.errors.length > 0) {
setStatus({
hilStatus: 'error',
hilResult: { message: '模型导出失败', errors: exported.errors.join('\n') },
});
return;
}
if (exported.hardwarePorts.length === 0) {
setStatus({
hilStatus: 'error',
hilResult: { message: '未标记实物节点,请先标记' },
});
return;
}
// 调用后端启动 HIL
const resp = await startHilSession({
moCode: exported.code,
modelName,
hardwarePorts: exported.hardwarePorts,
duration,
stepSize,
});
const isOk = resp.code === 0;
setStatus({
hilStatus: isOk ? 'running' : 'error',
hilResult: {
message: resp.message,
sessionId: resp.data?.session_id,
fmuPath: resp.data?.fmu_path,
ports: resp.data?.ports,
status: resp.data?.status,
errorDetail: resp.data?.error_detail,
},
});
// 启动状态轮询 (每 3s 检查一次)
if (isOk && resp.data?.session_id) {
const sessionId = resp.data.session_id;
// 清除旧的轮询
if (get()._hilPollingTimer) clearInterval(get()._hilPollingTimer);
const timer = setInterval(async () => {
try {
const statusResp = await getHilStatus(sessionId);
if (statusResp.code !== 0) return;
const st = statusResp.data?.status;
// 检查 CSV 文件是否就绪
if (st === 'done' || st === 'stopped' || st === 'error') {
clearInterval(timer);
set({ _hilPollingTimer: null });
// 自动获取结果
await get().fetchHilResults(id);
}
} catch (e) { /* 静默 */ }
}, 3000);
set({ _hilPollingTimer: timer });
}
} catch (err) {
setStatus({
hilStatus: 'error',
hilResult: { message: '请求失败', errors: err.message },
});
}
},
stopHil: async (id) => {
const { projects } = get();
const project = projects.find(p => p.id === id);
if (!project || !project.hilResult?.sessionId) return;
const setStatus = (updates) => {
const updated = get().projects.map(p =>
p.id === id ? { ...p, ...updates } : p
);
persistProjects(updated);
set({ projects: updated });
};
try {
await stopHilSession(project.hilResult.sessionId);
setStatus({
hilStatus: 'stopped',
hilResult: { ...project.hilResult, message: '仿真已停止' },
});
// 清除轮询
if (get()._hilPollingTimer) {
clearInterval(get()._hilPollingTimer);
set({ _hilPollingTimer: null });
}
// 延迟 2s 后获取已有数据
setTimeout(() => get().fetchHilResults(id), 2000);
} catch (err) {
setStatus({
hilStatus: 'error',
hilResult: { ...project.hilResult, message: '停止失败: ' + err.message },
});
}
},
fetchHilResults: async (id) => {
const { projects } = get();
const project = projects.find(p => p.id === id);
if (!project || !project.hilResult?.sessionId) return;
const setStatus = (updates) => {
const updated = get().projects.map(p =>
p.id === id ? { ...p, ...updates } : p
);
persistProjects(updated);
set({ projects: updated });
};
try {
const resp = await getHilResults(project.hilResult.sessionId);
if (resp.code === 0 && resp.data?.csv_data) {
setStatus({
hilStatus: 'done',
hilResult: {
...project.hilResult,
message: '仿真完成',
csvData: resp.data.csv_data,
},
});
}
} catch (err) {
// 结果未就绪,静默失败
}
},
/** 关闭当前项目(不清画布) */
closeProject: () => set({ activeProjectId: null }),
......
......@@ -54,3 +54,70 @@ export async function checkHealth() {
const resp = await fetch(`${API_BASE}/health`);
return resp.json();
}
// ===== 半实物仿真 (HIL) API =====
/**
* 启动半实物仿真会话
* @param {Object} params
* @param {string} params.moCode - 拆分后的软件子模型 .mo 代码
* @param {string} params.modelName - 模型名称
* @param {Array} params.hardwarePorts - 硬件端口描述
* @param {number} params.stepSize - 仿真步长 (秒)
* @param {number} params.duration - 仿真时长 (秒)
* @returns {Promise<{code, message, data: {session_id, status, fmu_path, ports}}>}
*/
export async function startHilSession({ moCode, modelName, hardwarePorts, stepSize = 0.001, duration = 10.0 }) {
return request('/hil/start', {
method: 'POST',
body: JSON.stringify({
mo_code: moCode,
model_name: modelName,
hardware_ports: hardwarePorts.map(p => ({
name: p.name,
instance_name: p.instanceName,
model_type: p.modelType || '',
sim_mode: p.simMode || 'linear',
ports: p.ports || [],
fmu_var_prefix: p.fmuVarPrefix || '',
topic: p.topic || '',
hw_label: p.hwNodeLabel || '',
hw_node_id: p.hwNodeId || '',
})),
step_size: stepSize,
duration,
}),
});
}
/**
* 查询半实物仿真会话状态
* @param {string} sessionId
* @returns {Promise<{code, message, data: {session_id, status, fmu_path, error_detail}}>}
*/
export async function getHilStatus(sessionId) {
const resp = await fetch(`${API_BASE}/hil/status?session_id=${encodeURIComponent(sessionId)}`);
return resp.json();
}
/**
* 停止半实物仿真会话
* @param {string} sessionId
* @returns {Promise<{code, message, data}>}
*/
export async function stopHilSession(sessionId) {
return request('/hil/stop', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
});
}
/**
* 获取半实物仿真结果
* @param {string} sessionId
* @returns {Promise<{code, message, data: {session_id, model_name, csv_data, csv_path}}>}
*/
export async function getHilResults(sessionId) {
const resp = await fetch(`${API_BASE}/hil/results?session_id=${encodeURIComponent(sessionId)}`);
return resp.json();
}
......@@ -177,30 +177,21 @@ export function getAllMappings() {
export function resolvePortName(handleId, type, ports) {
const mapping = MODEL_MAP[type];
// 先尝试从端口列表中找到匹配的 portId
// 1. 从节点端口列表中查找 — 端口的 connector 字段是唯一真相
if (ports && ports.length > 0) {
const port = ports.find(p => p.id === handleId);
if (port) {
// 优先使用 connector 字段
if (port.connector) return port.connector;
const originalId = port.portId || port.id;
// 查映射表
if (mapping && mapping.portMap[originalId]) {
return mapping.portMap[originalId];
return port.connector || port.name || handleId;
}
// 用端口 name 作为回退
return port.name || originalId;
}
}
// 回退:尝试从 handleId 中提取原始 portId(去掉 -counter 后缀)
const match = handleId?.match(/^(.+?)-\d+$/);
const rawId = match ? match[1] : handleId;
if (mapping && mapping.portMap[rawId]) {
return mapping.portMap[rawId];
// 2. 用 portMap 作为回退(端口列表为空的极端情况)
if (mapping && mapping.portMap[handleId]) {
return mapping.portMap[handleId];
}
return rawId || handleId;
// 找不到 → 返回 null,而不是猜测
return null;
}
export default MODEL_MAP;
......@@ -155,6 +155,11 @@ export function exportToModelica(data, modelName = 'Circuit') {
(td?.params || []).forEach(p => {
const rawVal = pv[p.key];
if (rawVal != null && rawVal !== '') {
// 布尔值直接透传 (Modelica 支持 true/false)
const lower = String(rawVal).toLowerCase();
if (lower === 'true' || lower === 'false') {
paramParts.push(`${p.key}=${lower}`);
} else {
const numVal = parseEngValue(rawVal);
if (numVal != null) {
paramParts.push(`${p.key}=${formatMoValue(numVal)}`);
......@@ -163,6 +168,7 @@ export function exportToModelica(data, modelName = 'Circuit') {
warnings.push(`"${label}".${p.key} = "${rawVal}" 无法解析为数值`);
}
}
}
});
const paramStr = paramParts.length > 0 ? `(${paramParts.join(', ')})` : '';
......@@ -212,6 +218,10 @@ export function exportToModelica(data, modelName = 'Circuit') {
if (tgtInfo.isFlowNode) {
const tgtPort = resolvePortName(edge.targetHandle, tgtInfo.type, tgtInfo.ports);
const srcPort = resolvePortName(edge.sourceHandle, srcInfo.type, srcInfo.ports);
if (!tgtPort || !srcPort) {
warnings.push(`连接 ${edge.id} 端口无法解析: src=${edge.sourceHandle}${srcPort}, tgt=${edge.targetHandle}${tgtPort}`);
return;
}
flowInputMap[`${edge.target}:${tgtPort}`] = `${srcInfo.instanceName}.${srcPort}`;
}
});
......@@ -275,10 +285,20 @@ export function exportToModelica(data, modelName = 'Circuit') {
// 流程节点的连接已在表达式中处理,跳过
if (srcInfo.isFlowNode || tgtInfo.isFlowNode) return;
// 通过映射模块解析端口名
// 通过节点端口的 connector 字段解析 Modelica 端口名
const srcPort = resolvePortName(edge.sourceHandle, srcInfo.type, srcInfo.ports);
const tgtPort = resolvePortName(edge.targetHandle, tgtInfo.type, tgtInfo.ports);
// 端口解析失败 → 跳过此连接(边数据与节点端口不一致)
if (!srcPort) {
warnings.push(`连接被跳过: ${srcInfo.instanceName} 的端口 "${edge.sourceHandle}" 在节点上不存在`);
return;
}
if (!tgtPort) {
warnings.push(`连接被跳过: ${tgtInfo.instanceName} 的端口 "${edge.targetHandle}" 在节点上不存在`);
return;
}
lines.push(` connect(${srcInfo.instanceName}.${srcPort}, ${tgtInfo.instanceName}.${tgtPort});`);
});
......@@ -315,3 +335,83 @@ export function downloadModelicaFile(data, modelName = 'Circuit') {
return { errors: [], warnings, downloaded: true };
}
/**
* 半实物仿真 HIL 导出
*
* 策略:编译**完整模型**为 FMU,不拆分模型。
* 原因:电气元件(Pin 连接器,含 v 和 i)不能简单替换为信号接口。
*
* 实物节点的行为由 ROS2 hardware_sim_node 模拟,
* FMU bridge 在每个仿真步中通过 FMI API 覆盖实物节点的变量。
*
* @param {Object} data - { nodes: [], edges: [] }
* @param {string} modelName - 模型名称
* @returns {{ code: string, hardwarePorts: Array, warnings: string[], errors: string[] }}
*/
export function exportToModelicaHIL(data, modelName = 'Circuit') {
const { nodes = [], edges = [] } = data;
// 找出实物节点
const hwNodes = nodes.filter(n => n.data?.isHardware);
if (hwNodes.length === 0) {
return { ...exportToModelica(data, modelName), hardwarePorts: [] };
}
// 编译完整模型(包含实物节点)— 不拆分
const result = exportToModelica(data, modelName);
// 为每个实物节点生成元数据,供后端启动 ROS2 节点使用
const hardwarePorts = [];
const instanceCounter = {};
// 按和 exportToModelica 相同的逻辑构建实例名
nodes.forEach(node => {
const td = node.data?.templateData;
const type = td?.type;
const mapping = getModelMapping(type);
if (mapping?.isFlowNode) return;
let baseName;
if (mapping && mapping.modelName) {
baseName = mapping.modelName.charAt(0).toLowerCase() + mapping.modelName.slice(1);
} else {
const safeLabel = (node.data?.label || type || 'comp');
baseName = safeLabel.replace(/[-_]([a-zA-Z])/g, (_, c) => c.toUpperCase());
baseName = toMoId(baseName);
}
if (!instanceCounter[baseName]) instanceCounter[baseName] = 0;
instanceCounter[baseName]++;
const instanceName = baseName + '_' + instanceCounter[baseName];
if (node.data?.isHardware) {
// 自动匹配仿真模式
const simModeMap = {
resistor: 'resistor', capacitor: 'capacitor', inductor: 'inductor',
voltage_source: 'voltage_src', diode: 'diode',
ideal_switch: 'switch', motor: 'motor', switch: 'switch',
};
const portConnectors = (td?.ports || []).map(p => p.connector || p.name);
hardwarePorts.push({
name: instanceName,
hwNodeId: node.id,
hwNodeLabel: node.data?.label || '',
instanceName,
modelType: type,
simMode: simModeMap[type] || 'linear',
ports: portConnectors,
fmuVarPrefix: instanceName,
topic: `/${toMoId(modelName)}/${instanceName}`,
});
}
});
return {
code: result.code,
hardwarePorts,
warnings: result.warnings,
errors: result.errors,
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment