跳到正文
Learn-LAD
PLC AI 实战/不装博途先跑一遍:用云端仿真 MCP 把 SCL 需求跑成 PASS/FAIL

不装博途先跑一遍:用云端仿真 MCP 把 SCL 需求跑成 PASS/FAIL

把 L3 的 FB_Sorter 交给云端仿真 MCP:校验、逐扫描仿真、场景验收,抓到「编译 0/0 但 2 秒输出不成立」的行为缺口,修到 ALL PASS。

进阶 第 4 课 35 分钟 PLC 云仿真

学完你能:能在不装 TIA/PLCSIM 的机器上,用云端仿真 MCP 把一条 SCL 需求写成场景表并跑出 PASS/FAIL;能区分结构校验、云仿真、TIA 编译和虚拟 PLC 的边界。

本课不需要 TiaLink / 博途。 用 PLC 云仿真 在云端跑:先打开 PLC 云仿真,微信登录后创建 API Key,再把 MCP 端点填进客户端。

概述

前几课把「写 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、总线、驱动、安全回路都不在范围内。不要把客户工程贴到云端,用示例或脱敏代码。

你将学会

  • 用微信登录拿到 PLC 云仿真 API Key,把 https://tia.hubwiz.com/sim/mcp 配进任意支持 Streamable HTTP 的 MCP 客户端;
  • 知道 agent 在背后用了哪三个工具(scl_validate / scl_simulate / scl_run_scenario),以及每个结果能证明什么;
  • 写清 scenarios / sweep / fuzz / always 四种断言,特别是 ticks 和 scans 的区别;
  • 用扫描级时序解释 TON 和 TP 的差别,并知道 TP 为什么不能用 IN := FALSE 复位;
  • 分清「结构校验 → 云仿真 → TIA 编译 → 虚拟 PLC/现场」四层验证各自能证明什么、不能证明什么。

前置条件

  • 一个 PLC 云仿真账户:打开 PLC 云仿真,微信扫码登录,在控制台创建一个 sim_live_... API Key。新用户有 100 次免费调用;本课大约用 8–12 次,够跑很多遍。Key 只在创建时显示一次,别提交到仓库;
  • 一个支持 Streamable HTTP MCP 的客户端(Claude Code、Cursor、VS Code 等),不需要安装 TiaLink,也不需要 TIA Portal;
  • 本课的 SCL 源码(下面直接给出,和 L3 的最终版本一致);
  • 可选:一个 TIA Portal 沙盒工程 + TiaLink,用来做最后一步「导入编译」的对照。没有也不影响本课主线;
  • 三条纪律:只上传示例/脱敏代码;仿真通过不等于编译通过;云仿真不作为安全、联锁、急停的验收依据。

步骤

真实使用是一段主提示 + 几次确认:先把 SCL 和需求交给 agent,让它先做结构检查、再跑最小场景、最后做场景验收。你不需要在提示词里指定工具名——说清目标即可,工具是 agent 选的。下面每一步都给出可直接复制的提示词;JSON 是 agent 实际调用的参数,供你核对,不需要自己手写。

第一步:拿到 Key,配好 sim MCP

打开 PLC 云仿真,微信扫码登录后进入控制台,创建一个 API Key。然后把这个 Streamable HTTP 服务加进客户端:

MCP
{
  "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 语法——不要让它猜字段名。

四层验证:越往下越贵,越接近现场
结构校验、云仿真、TIA 编译、虚拟 PLC/现场:每层能证明的东西不同

第二步:把 L3 的 SCL 原样交给 agent,先做结构检查

先复制 L3 的最终版本(这就是那个在 TIA 里编译 0/0 的块):

SCL
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 自锁

在写大场景表之前,先确认「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 秒输出。

第四步:把需求交给 agent,让它写场景表并跑出第一个 FAIL

你不需要自己写场景表或 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 秒才输出」——这是两件完全不同的事。

TON、TP、锁存+TON 的时序差别
SensorA 只来一个扫描周期:TON 永不输出;TP 输出 2 秒但复位后可能复亮;锁存+TON 复位后不再出现

「触发」到底是什么?如果现场传感器是一个电平信号,会持续为 1 几秒,那 TON 的语义可能正是你要的;如果是「来一个脉冲就算触发」,就必须用边沿 + 脉冲。场景表的第一价值,是逼你把这句话写清楚。

第五步:改成 TP,基础场景过了,但新场景又抓到一个

对 agent 说:

先解释每个失败的原因,再给我一个最小修复方案,不要直接改代码。我确认后你再改;改完先重跑 S1–S3。通过后,再加一条「输出过程中按复位」的边界场景并跑一次。

agent 的第一版修复提案,是把 TON 换成 TP(生成固定脉宽),并在上升沿触发。下面是它改动的部分:

SCL
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,输出就又亮了。你把「复位」写在了输出上,没有写进状态里。

第六步:锁存 + TON,跑到 ALL PASS

对 agent 说:

TP 方案在复位场景下不成立。先不要直接换实现:说明为什么「只在输出上 AND NOT Reset」不够,再给我一个能在复位时清掉脉冲状态的方案;我确认后再改。改完跑完整场景(scenarios + sweep + fuzz + always),把 ALL PASS 报告和 scans 数贴给我。

agent 的最终方案不再依赖 TP 的脉冲,而是把「正在输出」做成显式状态 ActiveA/ActiveB,用 TON 计时结束它;Reset 直接把状态清掉:

SCL
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))
展开完整场景文件(8 个 case + 16 组 sweep + 5000 扫描 fuzz + 4 条 always)
{
  "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 没想到的瞬间。

同一份场景,从 FAIL 到 ALL PASS
第一版 2 个失败、109 个扫描;最终 8 个场景 + sweep + fuzz,全部通过,共 5735 个扫描

如果你要的语义就是「复位只屏蔽输出、不取消已经在跑的脉冲」,那 TP 版本也成立——但请把这条写进场景表。场景表就是你和需求方之间的合同,不是给代码补的装饰。

第七步(可选):再进 TIA 编译,别把云仿真当编译器

云仿真通过后,可以再用 TiaLink 把最终 SCL 导入沙盒工程,编译一次。注意两件事:

  1. 云仿真不是 TIA 编译器。scl_validate 只做解析和结构校验;TIA 的「PT 必须使用」「输出可能未初始化」这类规则,仍要在 TIA 里编译才知道。L3 的第一版就是 scl_validate 能过、TIA 编译 2 错误 2 警告。
  2. TIA 编译 0/0 也不是行为正确。L3 的最终版编译 0/0,但本课的场景一跑就发现 2 秒输出不成立。编译、云仿真、虚拟 PLC 是三件不同的事,别用其中一件替代另一件。

验证清单

  • 客户端 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 数;
  • 配额消耗约 8–12 次(initialize / tools/list / resources/read 不计费);100 次免费额度足够;
  • 可选:用 TiaLink 导入 TIA 编译;如果没做,明确知道这一步还没验证;
  • 上传的只有示例/脱敏代码,没有客户工程、真实地址或配方。

常见坑

  • 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 里编译。
  • API Key / 额度:401 是 Key 缺失或无效;额度用完返回 QUOTA_EXHAUSTED,不会执行仿真。Key 只显示一次,别写进仓库或截图。
  • 云仿真不是安全验收:它不覆盖硬件、HMI、总线、驱动、F-CPU 安全程序;安全、联锁、急停必须人工评审。
  • LAD 课不适用:lad_* 工具要的是 LadDoc JSON,不是 SimaticML;该格式产品上暂未公开。本课只做 SCL。想用 LAD,需要产品侧先补一个公开的 LadDoc 入口(当前课程不覆盖)。

下一步

本课工具

PLC 云仿真

把 SCL 放到云端逐扫描执行:校验、仿真、场景验收,不需要博途或 PLCSIM。

打开 PLC 云仿真