Commit abe22eec authored by fenghen777's avatar fenghen777

feat(hil): 显式控制变量映射 + 分离HIL/仿真结果按钮 + FMI2 VR去重修复

parent 0fb78cc4
...@@ -53,6 +53,7 @@ src/ ...@@ -53,6 +53,7 @@ src/
5. 大改动优先新增类/文件,而非修改现有文件 5. 大改动优先新增类/文件,而非修改现有文件
6. `docs/` 目录下所有文档**必须使用中文命名** 6. `docs/` 目录下所有文档**必须使用中文命名**
7. 禁止自动提交 Git 7. 禁止自动提交 Git
8. **禁止编写带有猜测逻辑的代码** — 参数和变量名必须明确,不得通过关键词匹配、模式猜测等方式推断语义。遇到不确定的地方必须向用户提问确认,而非假设默认行为
## 编译验证 ## 编译验证
......
...@@ -83,7 +83,8 @@ ...@@ -83,7 +83,8 @@
3. 解析 modelDescription.xml: 3. 解析 modelDescription.xml:
- 提取 GUID 和 modelIdentifier - 提取 GUID 和 modelIdentifier
- 块级解析 <ScalarVariable> 发现所有变量 - 块级解析 <ScalarVariable> 发现所有变量
- 按 valueReference 去重,排除 parameter - 按 `{数据类型}:{VR}` 组合键去重 (FMI2 不同类型 VR 独立)
- 保留实物节点的 parameter 变量 (如 idealSwitch_1.closed)
4. fmi2Instantiate → setupExperiment → enterInit → exitInit 4. fmi2Instantiate → setupExperiment → enterInit → exitInit
5. 启动 ROS2 wall timer (周期 = step_size) 5. 启动 ROS2 wall timer (周期 = step_size)
...@@ -166,3 +167,201 @@ ...@@ -166,3 +167,201 @@
- **采样率:** 每 1ms 记录一点,可通过 `sample_rate_` 调整 - **采样率:** 每 1ms 记录一点,可通过 `sample_rate_` 调整
- **步长建议:** 100ms 步长完全满足 ROS2 timer 精度要求 - **步长建议:** 100ms 步长完全满足 ROS2 timer 精度要求
- **10s/1ms 仿真:** 约 10000 步,实时运行 ~10s,CSV ~1MB - **10s/1ms 仿真:** 约 10000 步,实时运行 ~10s,CSV ~1MB
## FmuBridgeNode 实现细节
### FMU 加载流程
```
1. unzip .fmu → /tmp/fmu_extract_xxx/
├── binaries/linux64/*.so (FMU 共享库)
├── modelDescription.xml (变量/GUID 描述)
└── resources/ (FMU 资源)
2. 解析 modelDescription.xml (纯字符串解析, 无外部 XML 依赖)
├── <fmiModelDescription guid="xxx"> → GUID
├── <CoSimulation modelIdentifier="xxx"> → 库名
└── <ScalarVariable name="..." valueReference="..." causality="...">
└── <Real/Boolean/Integer> → 数据类型
3. dlopen(binaries/linux64/xxx.so) → dlsym() 获取函数指针:
fn_instantiate_ fmi2Instantiate
fn_setup_exp_ fmi2SetupExperiment
fn_enter_init_ fmi2EnterInitializationMode
fn_exit_init_ fmi2ExitInitializationMode
fn_do_step_ fmi2DoStep
fn_get_real_ fmi2GetReal
fn_set_real_ fmi2SetReal
fn_get_boolean_ fmi2GetBoolean
fn_set_boolean_ fmi2SetBoolean
fn_terminate_ fmi2Terminate
fn_free_instance_ fmi2FreeInstance
```
### 硬件变量匹配机制
FmuBridgeNode 通过 `hw_var_names` 参数(逗号分隔的实物节点 Modelica 实例名)匹配 FMU 变量:
```
hw_var_names = "idealSwitch_1,resistor_1"
hw_control_vars = "idealSwitch_1.closed,resistor_1.R"
FMU 变量 匹配结果
──────────────────────────────── ──────────
idealSwitch_1.closed ✓ 控制输入 (hw_control_vars 显式指定)
idealSwitch_1.v ✓ 输出 (hw_prefix 匹配, 非控制变量)
idealSwitch_1.i ✓ 输出
resistor_1.R ✓ 控制输入 (hw_control_vars 显式指定)
resistor_1.v ✓ 输出
capacitor_1.p.v ✗ 不匹配任何 hw_prefix
```
**控制变量识别规则(显式映射,无猜测):**
控制变量名由前端 `modelMapping.js``controlVar` 字段定义,全链路传递到 FmuBridgeNode:
```
modelMapping.js modelicaExporter.js api.js HilService.cpp FmuBridgeNode.cpp
controlVar: 'closed' → controlVar: 'x_1.closed' → control_var → hw_control_vars → 精确匹配 VR
```
| 组件类型 | controlVar | 数据类型 | 来源 |
|----------|------------|----------|------|
| ideal_switch | closed | Boolean | IdealSwitch.mo |
| resistor | R | Real | Resistor.mo |
| capacitor | C | Real | Capacitor.mo |
| inductor | L | Real | Inductor.mo |
| voltage_source | V0 | Real | VoltageSource.mo |
### Boolean 变量支持
FMU 中的 Boolean 变量(如开关的 `closed` 参数)通过 `HwLink.input_types` 记录数据类型,
使用 `fmi2SetBoolean/GetBoolean` 操作。硬件节点发送的 Float64 值通过阈值 0.5 转换:
`hw_value > 0.5 → fmi2True, 否则 fmi2False`
> **注意:** FMI2 中不同数据类型的 valueReference 是独立命名空间,
> 即 Real 的 `vr=0` 和 Boolean 的 `vr=0` 是不同的变量。
> FmuBridgeNode 使用 `{type}:{vr}` 组合键去重,并在 `HwLink` 中存储每个变量的类型,
> 避免按 VR 扫描时的跨类型混淆。
### ROS2 话题命名
每个实物节点对应两个话题:
| 话题 | 方向 | 说明 |
|------|------|------|
| `/hil/{instanceName}/fmu_out` | FmuBridge → HardwareSimNode | FMU 输出值发给硬件节点 |
| `/hil/{instanceName}/hw_out` | HardwareSimNode → FmuBridge | 硬件节点响应写回 FMU |
### ROS2 参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `fmu_path` | string | "" | .fmu 文件路径 |
| `step_size` | double | 0.001 | 仿真步长 (秒) |
| `duration` | double | 10.0 | 仿真时长 (秒) |
| `csv_output` | string | "" | CSV 输出文件路径 |
| `hw_var_names` | string | "" | 逗号分隔的实物节点实例名前缀 |
| `hw_control_vars` | string | "" | 逗号分隔的控制变量全名 (由前端提供) |
## Switch 演示场景
### 演示目标
将前端画布中的 `switch` 节点标记为半实物,ROS2 `HardwareSimNode` 以固定周期(100ms 开 / 100ms 关)切换开关状态,验证从硬件控制信号到 FMU 仿真结果的完整链路。
### 数据流
```
HardwareSimNode (switch 模式) FmuBridgeNode FMU
┌──────────────────────┐ hw_out ┌───────────────────┐ ┌───────┐
│ 计时 → 100ms翻转 │ ──────────→ │ setBoolean(control)│ ───────→ │ │
│ output: 1.0 / 0.0 │ │ doStep() │ │ 求解 │
│ │ fmu_out │ getReal(v, i...) │ ←─────── │ │
│ │ ←────────── │ → CSV 记录 │ │ │
└──────────────────────┘ └───────────────────┘ └───────┘
```
### 关键配置
`HilService` 启动 `hardware_sim_node` 时自动配置:
```cpp
// 当 sim_mode == "switch" 时
params.push_back({"period_ms", "100.0"}); // 半周期 100ms
```
**效果:** 开关以 200ms 为全周期(100ms 闭合 + 100ms 断开)反复切换。
### 预期结果
仿真 CSV 中可观察到:
- `idealSwitch_1.closed` 变量在 0/1 之间以 200ms 周期交替
- 电路中的电压/电流随开关状态变化呈方波响应
## 调试记录
### 问题 1: FMU Bridge 启动即崩溃
**现象:** `results` 接口返回 404,`fmu_bridge.log` 显示 `RCLInvalidROSArgsError`
**原因:** ROS2 不接受空值参数 (`-p hw_control_vars:=`)。当 `hw_control_vars` 为空字符串时,
`LaunchRos2Node` 构造了非法的命令行参数。
**修复:** `LaunchRos2Node` 中跳过空值参数:
```cpp
for (const auto& [key, val] : params) {
if (!val.empty()) {
cmd += " -p " + key + ":=" + val;
}
}
```
### 问题 2: 控制变量未被识别 (inputs=0)
**现象:** 日志显示 `HW 节点 [idealSwitch_1]: inputs=0 outputs=4`,开关从未被切换。
**原因(三层叠加):**
1. **`api.js` 遗漏字段:** `hardwarePorts` 映射时没有包含 `control_var` 字段,
后端从未收到控制变量名。
2. **VR 跨类型去重错误:** `idealSwitch_1.closed` (Boolean, vr=0) 与 `ground_1.p.i` (Real, vr=0)
在 FMI2 中属于不同类型的独立命名空间,但代码使用了全局 `seen_vrs` 按 VR 编号去重,
导致 Boolean 变量被 Real 变量「占位」后跳过。
3. **StepCallback 类型判断脆弱:** 在仿真循环中通过 VR 扫描 `variables_` 判断 Boolean/Real 类型,
同样因跨类型 VR 重叠导致误判。
**修复:**
- `api.js` 新增 `control_var: p.controlVar || ''` 字段映射
- VR 去重改为 `{type}:{vr}` 组合键
- `HwLink` 新增 `input_types`/`output_types` 向量,直接存储数据类型
### 问题 3: 控制变量被 parameter 过滤规则跳过
**现象:** `idealSwitch_1.closed``causality="parameter"`,被 `parameter` 跳过规则过滤。
**原因:** 旧代码无条件跳过所有 `causality == "parameter"` 的变量。
但自定义 Modelica 组件的控制参数(如 `closed`, `R`, `C`)在 FMU 中就是 parameter。
**修复:** 仅跳过非实物节点的 parameter:
```cpp
if (var.causality == "parameter" && !var.is_hw_related) continue;
```
### FMI2 VR 命名空间说明
FMI2 标准中 `valueReference` 按数据类型独立编号:
```
Real vr=0 ground_1.p.i ← Real 的 vr=0
Real vr=1 ground_1.p.v
Real vr=2 idealSwitch_1.i ← 多个 Real 变量共享 vr=2 (Modelica 别名优化)
Real vr=2 idealSwitch_1.p.i
Real vr=2 resistor_1.n.i
Boolean vr=0 idealSwitch_1.closed ← Boolean 的 vr=0, 与 Real vr=0 无冲突
```
同类型的 VR 重复是 OpenModelica 编译器的别名优化(物理上同一个值),去重是正确的。
跨类型的 VR 重复是 FMI2 规范行为,不应去重。
...@@ -3191,7 +3191,7 @@ ...@@ -3191,7 +3191,7 @@
}, },
"node_modules/recharts": { "node_modules/recharts": {
"version": "3.8.0", "version": "3.8.0",
"resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.8.0.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
......
...@@ -53,7 +53,7 @@ export default function ProjectPanel() { ...@@ -53,7 +53,7 @@ export default function ProjectPanel() {
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState('');
const [showCompileLog, setShowCompileLog] = useState(false); const [showCompileLog, setShowCompileLog] = useState(false);
const [showExecuteLog, setShowExecuteLog] = useState(false); const [showExecuteLog, setShowExecuteLog] = useState(false);
const [showResults, setShowResults] = useState(false); const [showResultsMode, setShowResultsMode] = useState(null); // null | 'sim' | 'hil'
const [hilDuration, setHilDuration] = useState(10); const [hilDuration, setHilDuration] = useState(10);
const activeProject = projects.find(p => p.id === activeProjectId) || null; const activeProject = projects.find(p => p.id === activeProjectId) || null;
...@@ -65,7 +65,7 @@ export default function ProjectPanel() { ...@@ -65,7 +65,7 @@ export default function ProjectPanel() {
const cur = activeProject?.executeStatus; const cur = activeProject?.executeStatus;
prevExecStatusRef.current = cur; prevExecStatusRef.current = cur;
if (prev === 'running' && cur === 'success' && activeProject?.executeResult?.csvData) { if (prev === 'running' && cur === 'success' && activeProject?.executeResult?.csvData) {
setShowResults(true); setShowResultsMode('sim');
} }
}, [activeProject?.executeStatus]); }, [activeProject?.executeStatus]);
...@@ -342,7 +342,7 @@ export default function ProjectPanel() { ...@@ -342,7 +342,7 @@ export default function ProjectPanel() {
{(p.hilStatus === 'done' || p.hilStatus === 'stopped') && p.hilResult?.csvData && ( {(p.hilStatus === 'done' || p.hilStatus === 'stopped') && p.hilResult?.csvData && (
<button <button
className={`${styles.simBtn} ${styles.viewResults}`} className={`${styles.simBtn} ${styles.viewResults}`}
onClick={(e) => { e.stopPropagation(); setShowResults(true); }} onClick={(e) => { e.stopPropagation(); setShowResultsMode('hil'); }}
style={{ width: '100%', marginBottom: 4 }} style={{ width: '100%', marginBottom: 4 }}
>📊 查看 HIL 结果</button> >📊 查看 HIL 结果</button>
)} )}
...@@ -352,16 +352,16 @@ export default function ProjectPanel() { ...@@ -352,16 +352,16 @@ export default function ProjectPanel() {
className={`${styles.simBtn}`} className={`${styles.simBtn}`}
style={{ width: '100%', marginBottom: 4, color: '#60a5fa' }} style={{ width: '100%', marginBottom: 4, color: '#60a5fa' }}
onClick={(e) => { e.stopPropagation(); fetchHilResults(p.id); }} onClick={(e) => { e.stopPropagation(); fetchHilResults(p.id); }}
>📥 获取仿真结果</button> >📲 获取 HIL 结果</button>
)} )}
{/* 查看结果 */} {/* 查看普通仿真结果 */}
{p.executeStatus === 'success' && p.executeResult?.csvData && ( {p.executeStatus === 'success' && p.executeResult?.csvData && (
<button <button
className={`${styles.simBtn} ${styles.viewResults}`} className={`${styles.simBtn} ${styles.viewResults}`}
onClick={(e) => { e.stopPropagation(); setShowResults(true); }} onClick={(e) => { e.stopPropagation(); setShowResultsMode('sim'); }}
style={{ width: '100%', marginBottom: 4 }} style={{ width: '100%', marginBottom: 4 }}
>📊 查看结果</button> >📊 查看仿真结果</button>
)} )}
{/* 编译日志 */} {/* 编译日志 */}
...@@ -401,11 +401,15 @@ export default function ProjectPanel() { ...@@ -401,11 +401,15 @@ export default function ProjectPanel() {
</div> </div>
{/* 仿真结果弹窗 */} {/* 仿真结果弹窗 */}
{showResults && (activeProject?.executeResult?.csvData || activeProject?.hilResult?.csvData) && ( {showResultsMode && (
<SimResultsModal <SimResultsModal
csvData={activeProject.executeResult?.csvData || activeProject.hilResult?.csvData} csvData={
modelName={activeProject.modelName || 'Circuit'} showResultsMode === 'hil'
onClose={() => setShowResults(false)} ? activeProject?.hilResult?.csvData
: activeProject?.executeResult?.csvData
}
modelName={activeProject?.modelName || 'Circuit'}
onClose={() => setShowResultsMode(null)}
/> />
)} )}
</div> </div>
......
...@@ -80,6 +80,7 @@ export async function startHilSession({ moCode, modelName, hardwarePorts, stepSi ...@@ -80,6 +80,7 @@ export async function startHilSession({ moCode, modelName, hardwarePorts, stepSi
sim_mode: p.simMode || 'linear', sim_mode: p.simMode || 'linear',
ports: p.ports || [], ports: p.ports || [],
fmu_var_prefix: p.fmuVarPrefix || '', fmu_var_prefix: p.fmuVarPrefix || '',
control_var: p.controlVar || '',
topic: p.topic || '', topic: p.topic || '',
hw_label: p.hwNodeLabel || '', hw_label: p.hwNodeLabel || '',
hw_node_id: p.hwNodeId || '', hw_node_id: p.hwNodeId || '',
......
...@@ -16,14 +16,17 @@ const MODEL_MAP = { ...@@ -16,14 +16,17 @@ const MODEL_MAP = {
resistor: { resistor: {
modelName: 'Resistor', modelName: 'Resistor',
portMap: { 'p1': 'p', 'p2': 'n' }, portMap: { 'p1': 'p', 'p2': 'n' },
controlVar: 'R', // Resistor.mo: parameter Real R
}, },
capacitor: { capacitor: {
modelName: 'Capacitor', modelName: 'Capacitor',
portMap: { 'p1': 'p', 'p2': 'n' }, portMap: { 'p1': 'p', 'p2': 'n' },
controlVar: 'C', // Capacitor.mo: parameter Real C
}, },
inductor: { inductor: {
modelName: 'Inductor', modelName: 'Inductor',
portMap: { 'p1': 'p', 'p2': 'n' }, portMap: { 'p1': 'p', 'p2': 'n' },
controlVar: 'L', // Inductor.mo: parameter Real L
}, },
diode: { diode: {
modelName: 'Diode', modelName: 'Diode',
...@@ -32,6 +35,7 @@ const MODEL_MAP = { ...@@ -32,6 +35,7 @@ const MODEL_MAP = {
voltage_source: { voltage_source: {
modelName: 'VoltageSource', modelName: 'VoltageSource',
portMap: { 'p-pos': 'p', 'p-neg': 'n' }, portMap: { 'p-pos': 'p', 'p-neg': 'n' },
controlVar: 'V0', // VoltageSource.mo: parameter Real V0
}, },
ground: { ground: {
modelName: 'Ground', modelName: 'Ground',
...@@ -40,6 +44,7 @@ const MODEL_MAP = { ...@@ -40,6 +44,7 @@ const MODEL_MAP = {
ideal_switch: { ideal_switch: {
modelName: 'IdealSwitch', modelName: 'IdealSwitch',
portMap: { 'p-in': 'p', 'p-out': 'n' }, portMap: { 'p-in': 'p', 'p-out': 'n' },
controlVar: 'closed', // IdealSwitch.mo: parameter Boolean closed
}, },
// ===== 电气控制 ===== // ===== 电气控制 =====
......
...@@ -403,6 +403,7 @@ export function exportToModelicaHIL(data, modelName = 'Circuit') { ...@@ -403,6 +403,7 @@ export function exportToModelicaHIL(data, modelName = 'Circuit') {
simMode: simModeMap[type] || 'linear', simMode: simModeMap[type] || 'linear',
ports: portConnectors, ports: portConnectors,
fmuVarPrefix: instanceName, fmuVarPrefix: instanceName,
controlVar: mapping?.controlVar ? `${instanceName}.${mapping.controlVar}` : '',
topic: `/${toMoId(modelName)}/${instanceName}`, topic: `/${toMoId(modelName)}/${instanceName}`,
}); });
} }
......
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