PLC 云仿真
把 SCL 放到云端逐扫描执行:校验、仿真、场景验收,不需要博途或 PLCSIM。
打开 PLC 云仿真把 L3 的 FB_Sorter 交给云端仿真 MCP:校验、逐扫描仿真、场景验收,抓到「编译 0/0 但 2 秒输出不成立」的行为缺口,修到 ALL PASS。
学完你能:能在不装 TIA/PLCSIM 的机器上,用云端仿真 MCP 把一条 SCL 需求写成场景表并跑出 PASS/FAIL;能区分结构校验、云仿真、TIA 编译和虚拟 PLC 的边界。
前几课把「写 SCL → 导入 → 编译 0 错误 0 警告」走通了。但编译 0/0 只证明块能在 TIA 里通过编译,不证明它按需求动作。这一课换一条更轻的路:把同一段 SCL 交给 PLC 云仿真 MCP,在云端逐扫描执行,用一张场景表把它跑成 PASS/FAIL——不装 TIA、不装 PLCSIM、不需要 Windows。
练习对象就是 L3《写一段 SCL》 里那个已经编译 0/0 的 FB_Sorter。本课会先跑出一个真实缺口:需求写的是「SensorA 触发后 DiverterA 输出 2 秒」,实现用的却是 TON——传感器只来一个扫描周期时,TON 永远到不了 2 秒,输出一次都不会亮。这个缺口编译查不出、读回接口也查不出,只有把程序跑起来、按扫描周期断言行为才会暴露。
修复过程也不是一次到位:改成 TP 后基础场景通过了,但再加一条「复位时正在输出」,又发现 TP 的脉冲不会被 IN := FALSE 掐断,复位一松开输出又冒出来。最终用「锁存 + TON 计时结束脉冲」才跑到 ALL PASS(8 个场景 + 16 组 sweep + 5000 扫描 fuzz,共 5735 个扫描周期)。
本课报告已在本地
scl-sim 0.1.0和线上https://tia.hubwiz.com/sim/mcp上回放(2026-10-19):scl_validate、scl_simulate、scl_run_scenario的返回一致;同一份 SCL + 同一份场景得到相同的 PASS/FAIL 和scans数。
口径:云仿真不是 TIA 编译器,也不是现场验收。它只跑 SCL 的扫描语义;硬件、HMI、总线、驱动、安全回路都不在范围内。不要把客户工程贴到云端,用示例或脱敏代码。
https://tia.hubwiz.com/sim/mcp 配进任意支持 Streamable HTTP 的 MCP 客户端;scl_validate / scl_simulate / scl_run_scenario),以及每个结果能证明什么;scenarios / sweep / fuzz / always 四种断言,特别是 ticks 和 scans 的区别;TON 和 TP 的差别,并知道 TP 为什么不能用 IN := FALSE 复位;sim_live_... API Key。新用户有 100 次免费调用;本课大约用 8–12 次,够跑很多遍。Key 只在创建时显示一次,别提交到仓库;真实使用是一段主提示 + 几次确认:先把 SCL 和需求交给 agent,让它先做结构检查、再跑最小场景、最后做场景验收。你不需要在提示词里指定工具名——说清目标即可,工具是 agent 选的。下面每一步都给出可直接复制的提示词;JSON 是 agent 实际调用的参数,供你核对,不需要自己手写。
打开 PLC 云仿真,微信扫码登录后进入控制台,创建一个 API Key。然后把这个 Streamable HTTP 服务加进客户端:
{
"mcpServers": {
"sim-cloud": {
"url": "https://tia.hubwiz.com/sim/mcp",
"headers": { "Authorization": "Bearer <你的 PLC 云仿真 API Key>" }
}
}
}看到什么:客户端列出 6 个工具——lad_validate / lad_simulate / lad_run_scenario / scl_validate / scl_simulate / scl_run_scenario。本课只用后三个。
计费规则先记住:initialize、tools/list、resources/read 不扣额度;每次被接受的 tools/call 扣 1 次。写场景前让 agent 先读免费资源 sim://docs/scenario-runner,里面有完整的 scenarios / sweep / fuzz / always 语法——不要让它猜字段名。
先复制 L3 的最终版本(这就是那个在 TIA 里编译 0/0 的块):
1FUNCTION_BLOCK "FB_Sorter"2VAR_INPUT3 Start : Bool;4 SensorA : Bool;5 SensorB : Bool;6 Reset : Bool;7END_VAR8VAR_OUTPUT9 DiverterA : Bool;10 DiverterB : Bool;11 Running : Bool;12END_VAR13VAR14 PulseA : TON_TIME;15 PulseB : TON_TIME;16 StartEdge : R_TRIG;17 RunningState : Bool := FALSE;18END_VAR19BEGIN20 #StartEdge(CLK := #Start);21 IF #Reset THEN22 #RunningState := FALSE;23 ELSIF #StartEdge.Q THEN24 #RunningState := TRUE;25 END_IF;2627 #PulseA(IN := #RunningState AND #SensorA AND NOT #PulseB.Q AND NOT #Reset, PT := T#2s);28 #DiverterA := #PulseA.Q AND NOT #Reset;2930 #PulseB(IN := #RunningState AND #SensorB AND NOT #DiverterA AND NOT #Reset, PT := T#2s);31 #DiverterB := #PulseB.Q AND NOT #Reset;3233 #Running := #RunningState;34END_FUNCTION_BLOCK对 agent 说:
先别改代码,也别写场景。只帮我检查这份 SCL:它能不能被解析、结构上有没有问题?把检查结果和依据告诉我。即使通过,也只说「结构通过」,不要替我判断行为是否正确。
agent 实际调用:scl_validate。看到什么:
{
"ok": true,
"blocks": [
{
"name": "source[0]",
"block": "FB_Sorter",
"parseIssues": [],
"validationIssues": []
}
]
}
ok: true 只说明 AST 能解析、结构合法。它不检查 TIA 特有的编译规则,也不检查行为。一个很现实的例子:把定时器调用写成 #PulseA(IN := FALSE);(漏掉 PT),scl_validate 仍可能通过,但 TIA 编译会报 Parameter 'PT' has to be used.——L3 里就踩过这个坑。
在写大场景表之前,先确认「Start 自锁」这一条。你不需要自己拼 JSON:把下面这段发给 agent 即可(它会把第二步的 SCL 放进 sources):
不要改代码,先跑一个最小场景确认 Start 自锁:初始四个输入都为 false;第 1 个扫描把
Start置 true,然后把它放回 false,再跑 3 个扫描;只回读Running、DiverterA、DiverterB。把扫描数、诊断和这三个值原样贴给我,不要只给结论。
agent 实际调用:scl_simulate。它发出的参数是:
{
"sources": ["<上面那份 FB_Sorter>"],
"block": "FB_Sorter",
"seed": { "Start": false, "SensorA": false, "SensorB": false, "Reset": false },
"steps": [
{ "set": { "Start": true }, "scans": 1 },
{ "set": { "Start": false }, "scans": 3 }
],
"read": ["Running", "DiverterA", "DiverterB"]
}
如果你要手动调,只需把 <上面那份 FB_Sorter> 换成第二步的源码;只有一个源时 block 也可以省略。
看到什么:
{
"block": "FB_Sorter",
"tickMs": 10,
"scans": 4,
"diagnostics": [],
"values": { "Running": true, "DiverterA": false, "DiverterB": false }
}
tickMs 默认 10 ms,所以 100 个 scan = 1 秒。steps 里每次 set 是增量写入,scans 是这一步跑几个扫描周期;read 只回读你关心的标签,别让 agent 把整个状态都拉回来。scl_simulate 的步骤键是 scans,到了 scl_run_scenario 会变成 ticks——这是最容易写错的地方。
Start 自锁通过,但这只是「程序能跑」。真正要验的是下面那条 2 秒输出。
你不需要自己写场景表或 JSON。把验收需求说清楚即可:
按这段 SCL 的验收需求做一次行为验收:
Start上升沿后Running置位;SensorA触发后DiverterA输出 2 秒;SensorB触发后DiverterB输出 2 秒;两路互锁;Reset复位所有输出。这里的「触发」按上升沿算,只来一个扫描周期也算一次。先不要改代码:先把需求整理成场景表,再跑一次验收。失败的话逐条告诉我:哪个场景、第几步、第几个扫描、哪个标签、期望值和实际值。不要只给结论。
agent 会先把需求翻译成一张可核对的场景表:
| # | 场景 | 输入 | 期望 |
|---|---|---|---|
| S1 | 启动自锁、复位清除 | Start 一个扫描,再放开;随后 Reset 一个扫描 |
Running 先 1 后 0 |
| S2 | 传感器 A 触发后输出 2 秒 | SensorA 只来一个扫描,然后保持 0 |
1 秒时 DiverterA=1;2.5 秒时 DiverterA=0 |
| S3 | 两路同时触发 | SensorA、SensorB 同一扫描为 1 |
只允许一路输出,A 优先 |
你要做的是核对这张表:有没有漏需求、有没有把「触发」理解错、期望值是否符合你的现场定义。确认后,agent 再把它写成 scenario JSON,调用 scl_run_scenario。它实际发出的 scenario 是(先看精简版):
{
"block": "FB_Sorter",
"tickMs": 10,
"seed": { "Start": false, "SensorA": false, "SensorB": false, "Reset": false },
"always": ["!(DiverterA && DiverterB)"],
"scenarios": [
{
"name": "start latches, reset clears",
"steps": [
{ "set": { "Start": true }, "ticks": 1, "expect": { "Running": true } },
{ "set": { "Start": false }, "ticks": 3, "expect": { "Running": true } },
{ "set": { "Reset": true }, "ticks": 1, "expect": { "Running": false } }
]
},
{
"name": "sensor A pulse gives a 2 s diverter pulse",
"steps": [
{ "set": { "Start": true }, "ticks": 1 },
{ "set": { "Start": false, "SensorA": true }, "ticks": 1 },
{ "set": { "SensorA": false }, "ticks": 100, "expect": { "DiverterA": true } },
{ "ticks": 150, "expect": { "DiverterA": false } }
]
},
{
"name": "both sensors: A wins, only one output",
"steps": [
{ "set": { "Start": true }, "ticks": 1 },
{ "set": { "Start": false, "SensorA": true, "SensorB": true }, "ticks": 1, "expect": { "DiverterA": true, "DiverterB": false } }
]
}
]
}
agent 会把源码放进 sources、把上面这个对象放进 scenario(不需要 docs 路径),然后调用 scl_run_scenario。看到什么:
PASS start latches, reset clears (3 step(s), 5 scan(s))
FAIL sensor A pulse gives a 2 s diverter pulse: step 3 scan 100: DiverterA expected true, got false
FAIL both sensors: A wins, only one output: step 2 scan 1: DiverterA expected true, got false
RESULT: FAIL (2 failure(s), 109 scan(s))
S1 过了,S2 和 S3 都卡在同一个根因:TON 是延时接通,IN 必须连续为 1 满 PT 才把 Q 置 1。SensorA 只来一个扫描周期,PulseA.ET 永远到不了 2000 ms,Q 就永远是 0。需求里的「触发后输出 2 秒」被实现成了「持续压住 2 秒才输出」——这是两件完全不同的事。
「触发」到底是什么?如果现场传感器是一个电平信号,会持续为 1 几秒,那
TON的语义可能正是你要的;如果是「来一个脉冲就算触发」,就必须用边沿 + 脉冲。场景表的第一价值,是逼你把这句话写清楚。
对 agent 说:
先解释每个失败的原因,再给我一个最小修复方案,不要直接改代码。我确认后你再改;改完先重跑 S1–S3。通过后,再加一条「输出过程中按复位」的边界场景并跑一次。
agent 的第一版修复提案,是把 TON 换成 TP(生成固定脉宽),并在上升沿触发。下面是它改动的部分:
1// 原有 Start/Reset 逻辑不变,这里只列改动2VAR3 PulseA : TP_TIME;4 PulseB : TP_TIME;5 SensorAEdge : R_TRIG;6 SensorBEdge : R_TRIG;7END_VAR8BEGIN9 #SensorAEdge(CLK := #SensorA);10 #SensorBEdge(CLK := #SensorB);1112 #PulseA(IN := #RunningState AND #SensorAEdge.Q AND NOT #PulseB.Q AND NOT #Reset, PT := T#2s);13 #DiverterA := #PulseA.Q AND NOT #Reset;1415 #PulseB(IN := #RunningState AND #SensorBEdge.Q AND NOT #DiverterA AND NOT #Reset, PT := T#2s);16 #DiverterB := #PulseB.Q AND NOT #Reset;17END_FUNCTION_BLOCK你确认后,agent 改完代码,再跑 S1–S3,PASS:
PASS start latches, reset clears (3 step(s), 5 scan(s))
PASS sensor A pulse gives a 2 s diverter pulse (4 step(s), 252 scan(s))
PASS both sensors: A wins, only one output (2 step(s), 2 scan(s))
RESULT: ALL PASS (259 scan(s))
但别急着收工——补一条「输出过程中按复位」:
{
"name": "reset during pulse must not reappear",
"steps": [
{ "set": { "Start": true }, "ticks": 1 },
{ "set": { "Start": false, "SensorA": true }, "ticks": 1 },
{ "set": { "SensorA": false }, "ticks": 50, "expect": { "DiverterA": true } },
{ "set": { "Reset": true }, "ticks": 1, "expect": { "DiverterA": false } },
{ "set": { "Reset": false }, "ticks": 10, "expect": { "DiverterA": false } }
]
}
看到什么:
FAIL reset during pulse must not reappear: step 5 scan 10: DiverterA expected false, got true
RESULT: FAIL (1 failure(s), 63 scan(s))
原因:TP 的 Q 是一个固定宽度的脉冲,IN 变 0 不会把它掐断;#DiverterA := #PulseA.Q AND NOT #Reset 只是在复位期间把输出遮住,脉冲还在后台跑。复位一松开,PulseA.Q 还是 1,输出就又亮了。你把「复位」写在了输出上,没有写进状态里。
对 agent 说:
TP方案在复位场景下不成立。先不要直接换实现:说明为什么「只在输出上AND NOT Reset」不够,再给我一个能在复位时清掉脉冲状态的方案;我确认后再改。改完跑完整场景(scenarios + sweep + fuzz + always),把 ALL PASS 报告和scans数贴给我。
agent 的最终方案不再依赖 TP 的脉冲,而是把「正在输出」做成显式状态 ActiveA/ActiveB,用 TON 计时结束它;Reset 直接把状态清掉:
1FUNCTION_BLOCK "FB_Sorter"2VAR_INPUT3 Start : Bool;4 SensorA : Bool;5 SensorB : Bool;6 Reset : Bool;7END_VAR8VAR_OUTPUT9 DiverterA : Bool;10 DiverterB : Bool;11 Running : Bool;12END_VAR13VAR14 StartEdge : R_TRIG;15 SensorAEdge : R_TRIG;16 SensorBEdge : R_TRIG;17 TimerA : TON_TIME;18 TimerB : TON_TIME;19 ActiveA : Bool := FALSE;20 ActiveB : Bool := FALSE;21 RunningState : Bool := FALSE;22END_VAR23BEGIN24 #StartEdge(CLK := #Start);25 #SensorAEdge(CLK := #SensorA);26 #SensorBEdge(CLK := #SensorB);2728 IF #Reset THEN29 #RunningState := FALSE;30 #ActiveA := FALSE;31 #ActiveB := FALSE;32 ELSE33 IF #StartEdge.Q THEN34 #RunningState := TRUE;35 END_IF;3637 IF #RunningState AND #SensorAEdge.Q AND NOT #ActiveB THEN38 #ActiveA := TRUE;39 END_IF;4041 IF #RunningState AND #SensorBEdge.Q AND NOT #ActiveA THEN42 #ActiveB := TRUE;43 END_IF;44 END_IF;4546 #TimerA(IN := #ActiveA, PT := T#2s);47 IF #TimerA.Q THEN48 #ActiveA := FALSE;49 END_IF;50 #DiverterA := #ActiveA AND NOT #Reset;5152 #TimerB(IN := #ActiveB, PT := T#2s);53 IF #TimerB.Q THEN54 #ActiveB := FALSE;55 END_IF;56 #DiverterB := #ActiveB AND NOT #Reset;5758 #Running := #RunningState;59END_FUNCTION_BLOCK这段的关键点:
ActiveA 是「A 路正在输出」的状态,不是输出本身;DiverterA 只是它的投影;TimerA(IN := ActiveA, PT := T#2s) 在 ActiveA 为 1 时开始计时,TimerA.Q 到 2 秒变 1,然后在同一个扫描里把 ActiveA 清 0——下一个扫描 TimerA 复位,输出保持 2 秒;Reset 清 ActiveA/ActiveB/RunningState,所以复位后不会再「复亮」;Active 状态,不是对方的输出,避免输出被复位遮住时互锁失效。把完整场景表(8 个 case + sweep + fuzz)跑一遍:
PASS start latches, reset clears (3 step(s), 5 scan(s))
PASS sensor A pulse gives a 2 s diverter pulse (4 step(s), 252 scan(s))
PASS sensor B pulse gives a 2 s diverter pulse (3 step(s), 102 scan(s))
PASS both sensors: A wins, only one output (2 step(s), 2 scan(s))
PASS reset during pulse must not reappear (5 step(s), 63 scan(s))
PASS B is ignored while A is active, then works after A ends (5 step(s), 254 scan(s))
PASS sensor pulses before start are ignored (3 step(s), 5 scan(s))
PASS reset wins over start in the same scan (2 step(s), 4 scan(s))
PASS sweep: 16 combination(s), 48 scan(s)
PASS fuzz: seed 7, 5000 scan(s)
RESULT: ALL PASS (5735 scan(s))
{
"block": "FB_Sorter",
"tickMs": 10,
"seed": {
"Start": false,
"SensorA": false,
"SensorB": false,
"Reset": false
},
"always": [
"!(DiverterA && DiverterB)",
"Reset -> (!DiverterA && !DiverterB && !Running)",
"DiverterA -> Running",
"DiverterB -> Running"
],
"scenarios": [
{
"name": "start latches, reset clears",
"steps": [
{
"set": {
"Start": true
},
"ticks": 1,
"expect": {
"Running": true
}
},
{
"set": {
"Start": false
},
"ticks": 3,
"expect": {
"Running": true
}
},
{
"set": {
"Reset": true
},
"ticks": 1,
"expect": {
"Running": false
}
}
]
},
{
"name": "sensor A pulse gives a 2 s diverter pulse",
"steps": [
{
"set": {
"Start": true
},
"ticks": 1
},
{
"set": {
"Start": false,
"SensorA": true
},
"ticks": 1
},
{
"set": {
"SensorA": false
},
"ticks": 100,
"expect": {
"DiverterA": true
}
},
{
"ticks": 150,
"expect": {
"DiverterA": false
}
}
]
},
{
"name": "sensor B pulse gives a 2 s diverter pulse",
"steps": [
{
"set": {
"Start": true
},
"ticks": 1
},
{
"set": {
"Start": false,
"SensorB": true
},
"ticks": 1
},
{
"set": {
"SensorB": false
},
"ticks": 100,
"expect": {
"DiverterB": true
}
}
]
},
{
"name": "both sensors: A wins, only one output",
"steps": [
{
"set": {
"Start": true
},
"ticks": 1
},
{
"set": {
"Start": false,
"SensorA": true,
"SensorB": true
},
"ticks": 1,
"expect": {
"DiverterA": true,
"DiverterB": false
}
}
]
},
{
"name": "reset during pulse must not reappear",
"steps": [
{
"set": {
"Start": true
},
"ticks": 1
},
{
"set": {
"Start": false,
"SensorA": true
},
"ticks": 1
},
{
"set": {
"SensorA": false
},
"ticks": 50,
"expect": {
"DiverterA": true
}
},
{
"set": {
"Reset": true
},
"ticks": 1,
"expect": {
"DiverterA": false
}
},
{
"set": {
"Reset": false
},
"ticks": 10,
"expect": {
"DiverterA": false
}
}
]
},
{
"name": "B is ignored while A is active, then works after A ends",
"steps": [
{
"set": {
"Start": true
},
"ticks": 1
},
{
"set": {
"Start": false,
"SensorA": true
},
"ticks": 1
},
{
"set": {
"SensorA": false,
"SensorB": true
},
"ticks": 1,
"expect": {
"DiverterA": true,
"DiverterB": false
}
},
{
"set": {
"SensorB": false
},
"ticks": 250,
"expect": {
"DiverterA": false,
"DiverterB": false
}
},
{
"set": {
"SensorB": true
},
"ticks": 1,
"expect": {
"DiverterB": true
}
}
]
},
{
"name": "sensor pulses before start are ignored",
"steps": [
{
"set": {
"SensorA": true
},
"ticks": 1,
"expect": {
"DiverterA": false
}
},
{
"set": {
"SensorA": false
},
"ticks": 3,
"expect": {
"DiverterA": false
}
},
{
"set": {
"Start": true
},
"ticks": 1,
"expect": {
"Running": true
}
}
]
},
{
"name": "reset wins over start in the same scan",
"steps": [
{
"set": {
"Start": true,
"Reset": true
},
"ticks": 1,
"expect": {
"Running": false
}
},
{
"set": {
"Start": false,
"Reset": false
},
"ticks": 3,
"expect": {
"Running": false
}
}
]
}
],
"sweep": {
"vars": {
"Start": [
false,
true
],
"SensorA": [
false,
true
],
"SensorB": [
false,
true
],
"Reset": [
false,
true
]
},
"ticks": 3
},
"fuzz": {
"seed": 7,
"scans": 5000,
"vars": {
"Start": 0.2,
"SensorA": 0.2,
"SensorB": 0.2,
"Reset": 0.05
}
}
}
三个模式 + always 各管一件事:
scenarios:手写时间线,适合验收需求和复现 bug;sweep:把 Start/SensorA/SensorB/Reset 的 16 种组合全跑一遍(每个组合一个全新状态),适合查「某个组合下输出不对」;fuzz:固定随机种子、连续 5000 个扫描,每个扫描重新掷输入,适合查长时间运行下的状态泄漏;always:每个扫描后都检查不变量。完整场景用了四条:!(DiverterA && DiverterB)(两路永不同时为 1)、Reset -> (!DiverterA && !DiverterB && !Running)、DiverterA -> Running、DiverterB -> Running。它不替代显式 expect,但能抓住手写 case 没想到的瞬间。
如果你要的语义就是「复位只屏蔽输出、不取消已经在跑的脉冲」,那
TP版本也成立——但请把这条写进场景表。场景表就是你和需求方之间的合同,不是给代码补的装饰。
云仿真通过后,可以再用 TiaLink 把最终 SCL 导入沙盒工程,编译一次。注意两件事:
scl_validate 只做解析和结构校验;TIA 的「PT 必须使用」「输出可能未初始化」这类规则,仍要在 TIA 里编译才知道。L3 的第一版就是 scl_validate 能过、TIA 编译 2 错误 2 警告。tools/list 能看到 6 个工具;sim://docs/scenario-runner 能免费读;scl_validate 对 L3 的 FB_Sorter 返回 ok:true,并知道它不代表 TIA 编译通过;scl_simulate 复现 Start 自锁(Running:true),知道 scans 和 ticks 不是同一个键;scl_run_scenario 复现第一版 FAIL,能指出根因是 TON 的延时接通语义;TP 后基础场景 PASS,并能用「复位时正在输出」场景复现 DiverterA 复亮;scl_run_scenario 返回 passed:true,包含 scenarios + sweep + fuzz + always;记下 scans 数;initialize / tools/list / resources/read 不计费);100 次免费额度足够;ticks 写成 scans:scl_simulate 的步骤用 scans,scl_run_scenario 的步骤用 ticks。写错会被当成未知键拒绝,不会静默忽略。TON 当脉冲用:TON 是延时接通,IN 必须持续为 1 满 PT;「来一个脉冲就输出 2 秒」要用边沿 + TP,或锁存 + TON。TP 能被 IN := FALSE 复位:TP 的脉宽是固定的,IN 掉 0 不会掐断;复位必须清状态,不能只在输出上 AND NOT Reset。SensorA 是电平信号时,直接进 TP 的 IN 只会在第一次上升沿触发;如果传感器持续为 1,下一次触发要等它先变 0。要按「每次触发」计数,就显式用 R_TRIG。always 不是万能的:它只检查每个扫描后的状态;如果某个错误只在扫描内部短暂出现,它看不到。关键需求仍要写显式 expect。sweep 和 fuzz 的状态语义不同:sweep 每个组合都是全新状态;fuzz 是一条连续时间线。别拿 sweep 的结果推断长时间运行行为。Start、Timer.ET)是目标块的局部变量;全局/DB 用 G:<path>;实例成员在场景里可以直接写 Timer.ET。读一个从没 seed/写过的 G: 标签会失败,不是静默给 false。scl_validate 不是 TIA 编译:漏 PT、输出未初始化这类 TIA 规则它不一定查;最终仍要在 TIA 里编译。QUOTA_EXHAUSTED,不会执行仿真。Key 只显示一次,别写进仓库或截图。lad_* 工具要的是 LadDoc JSON,不是 SimaticML;该格式产品上暂未公开。本课只做 SCL。想用 LAD,需要产品侧先补一个公开的 LadDoc 入口(当前课程不覆盖)。FB_Sorter 换成示例里的逻辑,看看你的场景表能不能抓到别的问题;把 SCL 放到云端逐扫描执行:校验、仿真、场景验收,不需要博途或 PLCSIM。
打开 PLC 云仿真