Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(iedsim): complete P0 SCL to MMS model materialization
  • Loading branch information
masarray authored and github-actions[bot] committed Aug 12, 2026
commit a5447dc28a705e4ece4701e3aed18385912e59e5
4 changes: 2 additions & 2 deletions .github/workflows/ied-simulator-qt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ jobs:
QT_QPA_PLATFORM: offscreen
run: ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test

- name: Prove GUI value is visible through MMS
- name: Prove full SCL model and GUI value are visible through MMS
run: >-
python3 apps/ied_simulator/test_gui_live_value.py
--app ./build-ied-simulator-qt/arstack_ied_simulator
--read-probe ./build-ied-simulator-qt
--scl ./tests/fixtures/scl/minimal-station.scd
--scl ./tests/fixtures/scl/iedsim-full-model.scd
441 changes: 280 additions & 161 deletions apps/ied_simulator/src/IedSimulatorController.cpp

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion apps/ied_simulator/src/IedSimulatorController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ class IedSimulatorController : public QObject {
struct LoadedDocument final {
QString path;
ar::iec61850::scl::SclDocument document;
QVariantList materializedValues;
};

struct ValueSnapshot final {
Expand Down Expand Up @@ -163,4 +164,4 @@ class IedSimulatorController : public QObject {
bool starting_{};
bool gooseEnabled_{};
bool fileServiceEnabled_{};
};
};
409 changes: 409 additions & 0 deletions apps/ied_simulator/src/SclMmsMaterializer.hpp

Large diffs are not rendered by default.

199 changes: 159 additions & 40 deletions apps/ied_simulator/test_gui_live_value.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Launch the Qt simulator and prove GUI-applied state is visible over MMS."""
"""Prove full SCL materialization and GUI-applied state are visible over MMS."""

from __future__ import annotations

Expand Down Expand Up @@ -29,14 +29,51 @@ def resolve_read_probe(argument: str) -> str:
if path.is_dir():
names = {"ariec61850_mms_read_probe", "ariec61850_mms_read_probe.exe"}
matches = sorted(
candidate for candidate in path.rglob("ariec61850_mms_read_probe*")
candidate
for candidate in path.rglob("ariec61850_mms_read_probe*")
if candidate.is_file() and candidate.name in names
)
if matches:
return str(matches[0])
raise FileNotFoundError(f"MMS read probe not found under {path}")


def run_probe(read_probe: str, port: int, item: str, *, type_only: bool = False) -> subprocess.CompletedProcess[str]:
command = [
read_probe,
"127.0.0.1",
str(port),
"--domain",
"MU01LD0",
"--item",
item,
"--timeout-ms",
"3000",
]
if type_only:
command.append("--type")
return subprocess.run(
command,
capture_output=True,
text=True,
timeout=6,
check=False,
creationflags=creation_flags(),
)


def require_probe(
probe: subprocess.CompletedProcess[str],
expected: str,
description: str,
) -> None:
if probe.returncode != 0 or expected not in probe.stdout:
raise RuntimeError(
f"{description} failed: exit={probe.returncode} "
f"stdout={probe.stdout} stderr={probe.stderr}"
)


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--app", required=True)
Expand All @@ -60,7 +97,7 @@ def main() -> int:
"--set-first-value",
"42",
"--exit-after-ms",
"12000",
"30000",
],
stdout=app_log,
stderr=subprocess.STDOUT,
Expand All @@ -73,7 +110,7 @@ def main() -> int:
manifest_path = (
Path(tempfile.gettempdir()) / f"arstack-ied-simulator-{app.pid}.model"
)
manifest_deadline = time.monotonic() + 6.0
manifest_deadline = time.monotonic() + 8.0
while time.monotonic() < manifest_deadline:
if app.poll() is not None:
last_error = f"application exited early with code {app.returncode}"
Expand All @@ -82,64 +119,146 @@ def main() -> int:
manifest_text = manifest_path.read_text(encoding="utf-8")
except (FileNotFoundError, PermissionError, UnicodeDecodeError):
manifest_text = ""
if manifest_text.startswith("ARSTACK_IED_MODEL\t2\t2\n") and (
"TCTR1$MX$Amp$instMag$i\tINT32\tNumber\t42" in manifest_text
gui_value_present = (
"TCTR1$MX$Amp$instMag$i\tINT16\tNumber\tinteger:16\t42"
in manifest_text
)
ordered_range_present = (
"TCTR1$MX$Amp$instMag$range\tINT8U\tNumber\tunsigned-integer:8\t0"
in manifest_text
)
# XCBR1.Health is deliberately absent from every DataSet. Its presence
# proves DataTypeTemplates materialization, not FCDA-driven projection.
structural_only_leaf_present = (
"XCBR1$ST$Health$stVal\tBOOLEAN\tBoolean\tboolean\tfalse"
in manifest_text
)
data_set_present = (
"DS\tMU01LD0\tLLN0$dsSV\tMU01LD0\tTCTR1$MX$Amp$instMag$i"
in manifest_text
)
rcb_present = (
"RCB\tMU01LD0\tLLN0$RP$urcb01\t0\tMU01_LD0_URCB01\t"
"MU01LD0\tLLN0$dsSV\t7\t20\t1000\t0" in manifest_text
)
no_ln_scaffold = "\nLN\t" not in "\n" + manifest_text
order_preserved = (
"TCTR1$MX$Amp$instMag$i" in manifest_text
and "TCTR1$MX$Amp$instMag$range" in manifest_text
and manifest_text.index("TCTR1$MX$Amp$instMag$i")
< manifest_text.index("TCTR1$MX$Amp$instMag$range")
)
if (
manifest_text.startswith("ARSTACK_IED_MODEL\t4\t2\n")
and gui_value_present
and ordered_range_present
and structural_only_leaf_present
and data_set_present
and rcb_present
and no_ln_scaffold
and order_preserved
):
break
time.sleep(0.1)
else:
raise RuntimeError("GUI did not publish manifest revision 2 with value 42")
raise RuntimeError(
"GUI did not publish the complete v4 P0 model with exact types, "
"DataSet, RCB, and structural-only leaves"
)

deadline = time.monotonic() + 10.0
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
if app.poll() is not None:
last_error = f"application exited early with code {app.returncode}"
break
time.sleep(0.25)
probe = subprocess.run(
[
read_probe,
"127.0.0.1",
str(port),
"--domain",
"MU01LD0",
"--item",
"TCTR1$MX$Amp$instMag$i",
"--timeout-ms",
"3000",
],
capture_output=True,
text=True,
timeout=6,
check=False,
creationflags=creation_flags(),
)
if probe.returncode == 0 and "value=42" in probe.stdout:
app.wait(timeout=14)
print(
"IEDSIM_GUI_LIVE_VALUE_PASS "
"reference=MU01LD0/TCTR1$MX$Amp$instMag$i value=42"
edited_probe = run_probe(
read_probe, port, "TCTR1$MX$Amp$instMag$i"
)
if edited_probe.returncode != 0 or "value=42" not in edited_probe.stdout:
last_error = (
f"edited read exit={edited_probe.returncode} "
f"stdout={edited_probe.stdout} stderr={edited_probe.stderr}"
)
return 0
last_error = (
f"exit={probe.returncode} stdout={probe.stdout} stderr={probe.stderr}"
time.sleep(0.2)
continue

# P0 exact TypeSpecification is proven on the wire with
# GetVariableAccessAttributes, not inferred from manifest text.
exact_type_probe = run_probe(
read_probe,
port,
"TCTR1$MX$Amp$instMag$i",
type_only=True,
)
time.sleep(0.2)
require_probe(
exact_type_probe,
"kind=integer size=16",
"GVAA INT16 evidence",
)
exact_unsigned_probe = run_probe(
read_probe,
port,
"TCTR1$MX$Amp$instMag$range",
type_only=True,
)
require_probe(
exact_unsigned_probe,
"kind=unsigned size=8",
"GVAA INT8U evidence",
)

structural_probe = run_probe(
read_probe, port, "XCBR1$ST$Health$stVal"
)
require_probe(
structural_probe,
"value=false",
"structural-only leaf read",
)

# The SCL ReportControl is part of the P0 structural model even
# though RptEna/GI/report emission are intentionally deferred to P2.
rcb_probe = run_probe(
read_probe, port, "LLN0$RP$urcb01$RptID"
)
require_probe(
rcb_probe,
"value=MU01_LD0_URCB01",
"URCB RptID read",
)
rcb_dataset_probe = run_probe(
read_probe, port, "LLN0$RP$urcb01$DatSet"
)
require_probe(
rcb_dataset_probe,
"value=MU01LD0/LLN0$dsSV",
"URCB DataSet binding read",
)

app.wait(timeout=32)
print(
"IEDSIM_P0_FULL_MODEL_PASS "
"edited=MU01LD0/TCTR1$MX$Amp$instMag$i:42 "
"gvaa=integer:16,unsigned:8 dataset=LLN0$dsSV "
"rcb=LLN0$RP$urcb01 structural=MU01LD0/XCBR1$ST$Health$stVal:false"
)
return 0
raise RuntimeError(last_error)
except BaseException:
except BaseException as exception:
last_error = str(exception) or last_error
if app.poll() is None:
try:
# Let Qt destroy its QProcess child and release the listener.
app.wait(timeout=4)
except subprocess.TimeoutExpired:
app.kill()
app.wait(timeout=5)
app_log.seek(0)
output = app_log.read().strip()
raise RuntimeError(
f"GUI live-value test failed: {last_error}; app_output={output}"
)
f"GUI full-model/live-value test failed: {last_error}; app_output={output}"
) from exception


if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
47 changes: 47 additions & 0 deletions tests/fixtures/scl/iedsim-full-model.scd
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<SCL xmlns="http://www.iec.ch/61850/2003/SCL" version="2007" revision="B">
<Header id="AR_IEDSIM_FULL_MODEL" version="1" revision="A" />
<IED name="MU01" manufacturer="AR" type="Simulator" configVersion="1">
<AccessPoint name="P1">
<Server>
<LDevice inst="LD0">
<LN0 lnClass="LLN0" lnType="LLN0Type">
<DataSet name="dsSV">
<FCDA ldInst="LD0" lnClass="TCTR" lnInst="1" doName="Amp" daName="instMag.i" fc="MX" />
</DataSet>
<ReportControl name="urcb01" datSet="dsSV" rptID="MU01_LD0_URCB01" buffered="false" bufTime="20" intgPd="1000" confRev="7" indexed="false" />
</LN0>
<LN lnClass="TCTR" inst="1" lnType="TCTRType" />
<LN lnClass="XCBR" inst="1" lnType="XCBRType" />
</LDevice>
</Server>
</AccessPoint>
</IED>
<DataTypeTemplates>
<LNodeType id="LLN0Type" lnClass="LLN0" />
<LNodeType id="TCTRType" lnClass="TCTR">
<DO name="Amp" type="AmpType" />
</LNodeType>
<DOType id="AmpType" cdc="SAV">
<DA name="instMag" bType="Struct" type="AnalogueValue" fc="MX" />
<DA name="q" bType="Quality" fc="MX" />
</DOType>
<DAType id="AnalogueValue">
<BDA name="i" bType="INT16" />
<BDA name="range" bType="INT8U" />
</DAType>
<LNodeType id="XCBRType" lnClass="XCBR">
<DO name="Pos" type="PosType" />
<DO name="Health" type="HealthType" />
</LNodeType>
<DOType id="PosType" cdc="DPC">
<DA name="stVal" bType="BOOLEAN" fc="ST" />
<DA name="q" bType="Quality" fc="ST" />
<DA name="t" bType="Timestamp" fc="ST" />
</DOType>
<DOType id="HealthType" cdc="SPS">
<DA name="stVal" bType="BOOLEAN" fc="ST" />
<DA name="q" bType="Quality" fc="ST" />
</DOType>
</DataTypeTemplates>
</SCL>
Loading