Skip to content

Commit 50cb686

Browse files
committed
feat(iedsim): complete P0 SCL to MMS model materialization
1 parent 3625e0c commit 50cb686

8 files changed

Lines changed: 1221 additions & 228 deletions

File tree

‎.github/workflows/ied-simulator-qt.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ jobs:
5858
QT_QPA_PLATFORM: offscreen
5959
run: ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test
6060

61-
- name: Prove GUI value is visible through MMS
61+
- name: Prove full SCL model and GUI value are visible through MMS
6262
run: >-
6363
python3 apps/ied_simulator/test_gui_live_value.py
6464
--app ./build-ied-simulator-qt/arstack_ied_simulator
6565
--read-probe ./build-ied-simulator-qt
66-
--scl ./tests/fixtures/scl/minimal-station.scd
66+
--scl ./tests/fixtures/scl/iedsim-full-model.scd

‎apps/ied_simulator/src/IedSimulatorController.cpp‎

Lines changed: 280 additions & 161 deletions
Large diffs are not rendered by default.

‎apps/ied_simulator/src/IedSimulatorController.hpp‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class IedSimulatorController : public QObject {
108108
struct LoadedDocument final {
109109
QString path;
110110
ar::iec61850::scl::SclDocument document;
111+
QVariantList materializedValues;
111112
};
112113

113114
struct ValueSnapshot final {
@@ -163,4 +164,4 @@ class IedSimulatorController : public QObject {
163164
bool starting_{};
164165
bool gooseEnabled_{};
165166
bool fileServiceEnabled_{};
166-
};
167+
};

‎apps/ied_simulator/src/SclMmsMaterializer.hpp‎

Lines changed: 409 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/ied_simulator/test_gui_live_value.py‎

Lines changed: 159 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python3
2-
"""Launch the Qt simulator and prove GUI-applied state is visible over MMS."""
2+
"""Prove full SCL materialization and GUI-applied state are visible over MMS."""
33

44
from __future__ import annotations
55

@@ -29,14 +29,51 @@ def resolve_read_probe(argument: str) -> str:
2929
if path.is_dir():
3030
names = {"ariec61850_mms_read_probe", "ariec61850_mms_read_probe.exe"}
3131
matches = sorted(
32-
candidate for candidate in path.rglob("ariec61850_mms_read_probe*")
32+
candidate
33+
for candidate in path.rglob("ariec61850_mms_read_probe*")
3334
if candidate.is_file() and candidate.name in names
3435
)
3536
if matches:
3637
return str(matches[0])
3738
raise FileNotFoundError(f"MMS read probe not found under {path}")
3839

3940

41+
def run_probe(read_probe: str, port: int, item: str, *, type_only: bool = False) -> subprocess.CompletedProcess[str]:
42+
command = [
43+
read_probe,
44+
"127.0.0.1",
45+
str(port),
46+
"--domain",
47+
"MU01LD0",
48+
"--item",
49+
item,
50+
"--timeout-ms",
51+
"3000",
52+
]
53+
if type_only:
54+
command.append("--type")
55+
return subprocess.run(
56+
command,
57+
capture_output=True,
58+
text=True,
59+
timeout=6,
60+
check=False,
61+
creationflags=creation_flags(),
62+
)
63+
64+
65+
def require_probe(
66+
probe: subprocess.CompletedProcess[str],
67+
expected: str,
68+
description: str,
69+
) -> None:
70+
if probe.returncode != 0 or expected not in probe.stdout:
71+
raise RuntimeError(
72+
f"{description} failed: exit={probe.returncode} "
73+
f"stdout={probe.stdout} stderr={probe.stderr}"
74+
)
75+
76+
4077
def main() -> int:
4178
parser = argparse.ArgumentParser()
4279
parser.add_argument("--app", required=True)
@@ -60,7 +97,7 @@ def main() -> int:
6097
"--set-first-value",
6198
"42",
6299
"--exit-after-ms",
63-
"12000",
100+
"30000",
64101
],
65102
stdout=app_log,
66103
stderr=subprocess.STDOUT,
@@ -73,7 +110,7 @@ def main() -> int:
73110
manifest_path = (
74111
Path(tempfile.gettempdir()) / f"arstack-ied-simulator-{app.pid}.model"
75112
)
76-
manifest_deadline = time.monotonic() + 6.0
113+
manifest_deadline = time.monotonic() + 8.0
77114
while time.monotonic() < manifest_deadline:
78115
if app.poll() is not None:
79116
last_error = f"application exited early with code {app.returncode}"
@@ -82,64 +119,146 @@ def main() -> int:
82119
manifest_text = manifest_path.read_text(encoding="utf-8")
83120
except (FileNotFoundError, PermissionError, UnicodeDecodeError):
84121
manifest_text = ""
85-
if manifest_text.startswith("ARSTACK_IED_MODEL\t2\t2\n") and (
86-
"TCTR1$MX$Amp$instMag$i\tINT32\tNumber\t42" in manifest_text
122+
gui_value_present = (
123+
"TCTR1$MX$Amp$instMag$i\tINT16\tNumber\tinteger:16\t42"
124+
in manifest_text
125+
)
126+
ordered_range_present = (
127+
"TCTR1$MX$Amp$instMag$range\tINT8U\tNumber\tunsigned-integer:8\t0"
128+
in manifest_text
129+
)
130+
# XCBR1.Health is deliberately absent from every DataSet. Its presence
131+
# proves DataTypeTemplates materialization, not FCDA-driven projection.
132+
structural_only_leaf_present = (
133+
"XCBR1$ST$Health$stVal\tBOOLEAN\tBoolean\tboolean\tfalse"
134+
in manifest_text
135+
)
136+
data_set_present = (
137+
"DS\tMU01LD0\tLLN0$dsSV\tMU01LD0\tTCTR1$MX$Amp$instMag$i"
138+
in manifest_text
139+
)
140+
rcb_present = (
141+
"RCB\tMU01LD0\tLLN0$RP$urcb01\t0\tMU01_LD0_URCB01\t"
142+
"MU01LD0\tLLN0$dsSV\t7\t20\t1000\t0" in manifest_text
143+
)
144+
no_ln_scaffold = "\nLN\t" not in "\n" + manifest_text
145+
order_preserved = (
146+
"TCTR1$MX$Amp$instMag$i" in manifest_text
147+
and "TCTR1$MX$Amp$instMag$range" in manifest_text
148+
and manifest_text.index("TCTR1$MX$Amp$instMag$i")
149+
< manifest_text.index("TCTR1$MX$Amp$instMag$range")
150+
)
151+
if (
152+
manifest_text.startswith("ARSTACK_IED_MODEL\t4\t2\n")
153+
and gui_value_present
154+
and ordered_range_present
155+
and structural_only_leaf_present
156+
and data_set_present
157+
and rcb_present
158+
and no_ln_scaffold
159+
and order_preserved
87160
):
88161
break
89162
time.sleep(0.1)
90163
else:
91-
raise RuntimeError("GUI did not publish manifest revision 2 with value 42")
164+
raise RuntimeError(
165+
"GUI did not publish the complete v4 P0 model with exact types, "
166+
"DataSet, RCB, and structural-only leaves"
167+
)
92168

93-
deadline = time.monotonic() + 10.0
169+
deadline = time.monotonic() + 15.0
94170
while time.monotonic() < deadline:
95171
if app.poll() is not None:
96172
last_error = f"application exited early with code {app.returncode}"
97173
break
98174
time.sleep(0.25)
99-
probe = subprocess.run(
100-
[
101-
read_probe,
102-
"127.0.0.1",
103-
str(port),
104-
"--domain",
105-
"MU01LD0",
106-
"--item",
107-
"TCTR1$MX$Amp$instMag$i",
108-
"--timeout-ms",
109-
"3000",
110-
],
111-
capture_output=True,
112-
text=True,
113-
timeout=6,
114-
check=False,
115-
creationflags=creation_flags(),
116-
)
117-
if probe.returncode == 0 and "value=42" in probe.stdout:
118-
app.wait(timeout=14)
119-
print(
120-
"IEDSIM_GUI_LIVE_VALUE_PASS "
121-
"reference=MU01LD0/TCTR1$MX$Amp$instMag$i value=42"
175+
edited_probe = run_probe(
176+
read_probe, port, "TCTR1$MX$Amp$instMag$i"
177+
)
178+
if edited_probe.returncode != 0 or "value=42" not in edited_probe.stdout:
179+
last_error = (
180+
f"edited read exit={edited_probe.returncode} "
181+
f"stdout={edited_probe.stdout} stderr={edited_probe.stderr}"
122182
)
123-
return 0
124-
last_error = (
125-
f"exit={probe.returncode} stdout={probe.stdout} stderr={probe.stderr}"
183+
time.sleep(0.2)
184+
continue
185+
186+
# P0 exact TypeSpecification is proven on the wire with
187+
# GetVariableAccessAttributes, not inferred from manifest text.
188+
exact_type_probe = run_probe(
189+
read_probe,
190+
port,
191+
"TCTR1$MX$Amp$instMag$i",
192+
type_only=True,
126193
)
127-
time.sleep(0.2)
194+
require_probe(
195+
exact_type_probe,
196+
"kind=integer size=16",
197+
"GVAA INT16 evidence",
198+
)
199+
exact_unsigned_probe = run_probe(
200+
read_probe,
201+
port,
202+
"TCTR1$MX$Amp$instMag$range",
203+
type_only=True,
204+
)
205+
require_probe(
206+
exact_unsigned_probe,
207+
"kind=unsigned size=8",
208+
"GVAA INT8U evidence",
209+
)
210+
211+
structural_probe = run_probe(
212+
read_probe, port, "XCBR1$ST$Health$stVal"
213+
)
214+
require_probe(
215+
structural_probe,
216+
"value=false",
217+
"structural-only leaf read",
218+
)
219+
220+
# The SCL ReportControl is part of the P0 structural model even
221+
# though RptEna/GI/report emission are intentionally deferred to P2.
222+
rcb_probe = run_probe(
223+
read_probe, port, "LLN0$RP$urcb01$RptID"
224+
)
225+
require_probe(
226+
rcb_probe,
227+
"value=MU01_LD0_URCB01",
228+
"URCB RptID read",
229+
)
230+
rcb_dataset_probe = run_probe(
231+
read_probe, port, "LLN0$RP$urcb01$DatSet"
232+
)
233+
require_probe(
234+
rcb_dataset_probe,
235+
"value=MU01LD0/LLN0$dsSV",
236+
"URCB DataSet binding read",
237+
)
238+
239+
app.wait(timeout=32)
240+
print(
241+
"IEDSIM_P0_FULL_MODEL_PASS "
242+
"edited=MU01LD0/TCTR1$MX$Amp$instMag$i:42 "
243+
"gvaa=integer:16,unsigned:8 dataset=LLN0$dsSV "
244+
"rcb=LLN0$RP$urcb01 structural=MU01LD0/XCBR1$ST$Health$stVal:false"
245+
)
246+
return 0
128247
raise RuntimeError(last_error)
129-
except BaseException:
248+
except BaseException as exception:
249+
last_error = str(exception) or last_error
130250
if app.poll() is None:
131251
try:
132-
# Let Qt destroy its QProcess child and release the listener.
133252
app.wait(timeout=4)
134253
except subprocess.TimeoutExpired:
135254
app.kill()
136255
app.wait(timeout=5)
137256
app_log.seek(0)
138257
output = app_log.read().strip()
139258
raise RuntimeError(
140-
f"GUI live-value test failed: {last_error}; app_output={output}"
141-
)
259+
f"GUI full-model/live-value test failed: {last_error}; app_output={output}"
260+
) from exception
142261

143262

144263
if __name__ == "__main__":
145-
raise SystemExit(main())
264+
raise SystemExit(main())
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<SCL xmlns="http://www.iec.ch/61850/2003/SCL" version="2007" revision="B">
3+
<Header id="AR_IEDSIM_FULL_MODEL" version="1" revision="A" />
4+
<IED name="MU01" manufacturer="AR" type="Simulator" configVersion="1">
5+
<AccessPoint name="P1">
6+
<Server>
7+
<LDevice inst="LD0">
8+
<LN0 lnClass="LLN0" lnType="LLN0Type">
9+
<DataSet name="dsSV">
10+
<FCDA ldInst="LD0" lnClass="TCTR" lnInst="1" doName="Amp" daName="instMag.i" fc="MX" />
11+
</DataSet>
12+
<ReportControl name="urcb01" datSet="dsSV" rptID="MU01_LD0_URCB01" buffered="false" bufTime="20" intgPd="1000" confRev="7" indexed="false" />
13+
</LN0>
14+
<LN lnClass="TCTR" inst="1" lnType="TCTRType" />
15+
<LN lnClass="XCBR" inst="1" lnType="XCBRType" />
16+
</LDevice>
17+
</Server>
18+
</AccessPoint>
19+
</IED>
20+
<DataTypeTemplates>
21+
<LNodeType id="LLN0Type" lnClass="LLN0" />
22+
<LNodeType id="TCTRType" lnClass="TCTR">
23+
<DO name="Amp" type="AmpType" />
24+
</LNodeType>
25+
<DOType id="AmpType" cdc="SAV">
26+
<DA name="instMag" bType="Struct" type="AnalogueValue" fc="MX" />
27+
<DA name="q" bType="Quality" fc="MX" />
28+
</DOType>
29+
<DAType id="AnalogueValue">
30+
<BDA name="i" bType="INT16" />
31+
<BDA name="range" bType="INT8U" />
32+
</DAType>
33+
<LNodeType id="XCBRType" lnClass="XCBR">
34+
<DO name="Pos" type="PosType" />
35+
<DO name="Health" type="HealthType" />
36+
</LNodeType>
37+
<DOType id="PosType" cdc="DPC">
38+
<DA name="stVal" bType="BOOLEAN" fc="ST" />
39+
<DA name="q" bType="Quality" fc="ST" />
40+
<DA name="t" bType="Timestamp" fc="ST" />
41+
</DOType>
42+
<DOType id="HealthType" cdc="SPS">
43+
<DA name="stVal" bType="BOOLEAN" fc="ST" />
44+
<DA name="q" bType="Quality" fc="ST" />
45+
</DOType>
46+
</DataTypeTemplates>
47+
</SCL>

0 commit comments

Comments
 (0)