Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
50 changes: 50 additions & 0 deletions src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ public static XDocument ApplyReportControlConfiguration(
if (modelControl is null)
continue;

// MMS discovery returns a concrete RCB object name. IEC 61850-6 defines
// ReportControl@indexed with a default value of true; if the attribute is
// omitted, an engineering tool appends another two-digit instance suffix.
// Therefore A_BRCB_1201 would become the invalid A_BRCB_120101. Preserve
// the proven live object exactly as one non-indexed instance.
element.SetAttributeValue("name", SafeXmlName(modelControl.Name));
element.SetAttributeValue("indexed", "false");
var rptEnabled = element.Element(Scl + "RptEnabled") ?? new XElement(Scl + "RptEnabled");
rptEnabled.SetAttributeValue("max", "1");
foreach (var clientLn in rptEnabled.Elements(Scl + "ClientLN").ToArray())
clientLn.Remove();
if (rptEnabled.Parent is null)
element.Add(rptEnabled);

var trigger = MmsReportControlFieldCodec.DecodeTriggerOptions(modelControl.TriggerOptions);
var triggerElement = element.Element(Scl + "TrgOps") ?? new XElement(Scl + "TrgOps");
triggerElement.SetAttributeValue("dchg", XmlBool(trigger.DataChange));
Expand Down Expand Up @@ -140,9 +154,45 @@ public static XDocument ApplyReportControlConfiguration(
element.Add(optionalElement);
}

var confReportControl = document.Descendants(Scl + "ConfReportControl").SingleOrDefault();
if (confReportControl is not null)
confReportControl.SetAttributeValue("max", reportControls.Length.ToString(System.Globalization.CultureInfo.InvariantCulture));

ValidateReportControlIdentity(document, reportControls);
return document;
}

private static void ValidateReportControlIdentity(
XDocument document,
IReadOnlyCollection<LiveIedReportControlModel> reportControls)
{
var exported = document.Descendants(Scl + "ReportControl").ToArray();
if (exported.Length != reportControls.Count)
{
throw new InvalidDataException(
$"Generated SCL contains {exported.Length} ReportControl element(s), but live discovery contains {reportControls.Count}.");
}

foreach (var modelControl in reportControls)
{
var matches = exported.Where(element =>
string.Equals((string?)element.Attribute("name"), SafeXmlName(modelControl.Name), StringComparison.Ordinal) &&
string.Equals((string?)element.Attribute("indexed"), "false", StringComparison.OrdinalIgnoreCase)).ToArray();
if (matches.Length != 1)
{
throw new InvalidDataException(
$"Live RCB '{modelControl.Name}' was not exported exactly once as indexed=false.");
}

var rptEnabled = matches[0].Element(Scl + "RptEnabled");
if (rptEnabled is null || !string.Equals((string?)rptEnabled.Attribute("max"), "1", StringComparison.Ordinal))
{
throw new InvalidDataException(
$"Live RCB '{modelControl.Name}' must be exported with RptEnabled max=1.");
}
}
}

private static string XmlBool(bool value) => value ? "true" : "false";

private static string MatchMmsDomain(
Expand Down
133 changes: 133 additions & 0 deletions src/AR.Iec61850/Scl/Export/LiveRcbSclInstanceNormalizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System.Text;
using System.Xml;
using System.Xml.Linq;

namespace AR.Iec61850.Scl.Export;

/// <summary>
/// Normalizes a selected live MMS RCB instance into a one-instance SCL ReportControl.
/// Live discovery already exposes the concrete MMS object name (for example
/// A_BRCB_1201). In SCL, ReportControl@indexed defaults to true; leaving it omitted
/// causes an importer to append a second two-digit instance suffix and incorrectly
/// address A_BRCB_120101. A concrete live instance is therefore represented with
/// its exact name, indexed=false and RptEnabled max=1.
/// </summary>
public static class LiveRcbSclInstanceNormalizer
{
private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL";

public static LiveRcbSclInstanceNormalizationResult NormalizeFile(
string sclPath,
string exactRuntimeRcbName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sclPath);
using var input = File.OpenRead(sclPath);
var document = XDocument.Load(input, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
var result = Normalize(document, exactRuntimeRcbName);

using var output = File.Create(sclPath);
using var writer = XmlWriter.Create(output, new XmlWriterSettings
{
Encoding = new UTF8Encoding(false),
Indent = true,
OmitXmlDeclaration = false
});
document.Save(writer);
return result with { SclPath = Path.GetFullPath(sclPath) };
}

public static LiveRcbSclInstanceNormalizationResult Normalize(
XDocument document,
string exactRuntimeRcbName)
{
ArgumentNullException.ThrowIfNull(document);
exactRuntimeRcbName = (exactRuntimeRcbName ?? string.Empty).Trim();
if (exactRuntimeRcbName.Length == 0)
throw new ArgumentException("The exact runtime RCB name is empty.", nameof(exactRuntimeRcbName));

try
{
XmlConvert.VerifyNCName(exactRuntimeRcbName);
}
catch (XmlException ex)
{
throw new InvalidDataException($"Runtime RCB name '{exactRuntimeRcbName}' is not a valid SCL name.", ex);
}

var reportControls = document
.Descendants()
.Where(element => element.Name.LocalName == "ReportControl")
.ToArray();
if (reportControls.Length != 1)
{
throw new InvalidDataException(
$"Selected-RCB CID normalization requires exactly one ReportControl; found {reportControls.Length}.");
}

var reportControl = reportControls[0];
var previousName = ((string?)reportControl.Attribute("name") ?? string.Empty).Trim();
reportControl.SetAttributeValue("name", exactRuntimeRcbName);
reportControl.SetAttributeValue("indexed", "false");

var rptEnabled = reportControl.Elements().FirstOrDefault(element => element.Name.LocalName == "RptEnabled");
if (rptEnabled is null)
{
rptEnabled = new XElement(Scl + "RptEnabled");
reportControl.Add(rptEnabled);
}
rptEnabled.SetAttributeValue("max", "1");
foreach (var clientLn in rptEnabled.Elements().Where(element => element.Name.LocalName == "ClientLN").ToArray())
clientLn.Remove();

var services = document.Descendants().FirstOrDefault(element => element.Name.LocalName == "ConfReportControl");
services?.SetAttributeValue("max", "1");

Validate(document, exactRuntimeRcbName);
return new LiveRcbSclInstanceNormalizationResult
{
PreviousReportControlName = previousName,
ExactRuntimeReportControlName = exactRuntimeRcbName,
Indexed = false,
InstanceCount = 1
};
}

private static void Validate(XDocument document, string exactRuntimeRcbName)
{
var reportControl = document.Descendants().Single(element => element.Name.LocalName == "ReportControl");
var actualName = ((string?)reportControl.Attribute("name") ?? string.Empty).Trim();
if (!actualName.Equals(exactRuntimeRcbName, StringComparison.Ordinal))
{
throw new InvalidDataException(
$"Selected-RCB CID changed runtime name '{exactRuntimeRcbName}' to '{actualName}'.");
}

if (!string.Equals((string?)reportControl.Attribute("indexed"), "false", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"Concrete runtime RCB '{exactRuntimeRcbName}' must be exported with indexed=false.");
}

var rptEnabled = reportControl.Elements().SingleOrDefault(element => element.Name.LocalName == "RptEnabled");
if (rptEnabled is null || !string.Equals((string?)rptEnabled.Attribute("max"), "1", StringComparison.Ordinal))
{
throw new InvalidDataException(
$"Concrete runtime RCB '{exactRuntimeRcbName}' must expose exactly one instance through RptEnabled max=1.");
}

if (rptEnabled.Elements().Any(element => element.Name.LocalName == "ClientLN"))
{
throw new InvalidDataException(
$"Concrete runtime RCB '{exactRuntimeRcbName}' must not contain indexed ClientLN assignments.");
}
}
}

public sealed record LiveRcbSclInstanceNormalizationResult
{
public string SclPath { get; init; } = string.Empty;
public string PreviousReportControlName { get; init; } = string.Empty;
public string ExactRuntimeReportControlName { get; init; } = string.Empty;
public bool Indexed { get; init; }
public int InstanceCount { get; init; }
}
59 changes: 59 additions & 0 deletions tests/AR.Iec61850.Tests/Scl/AuthoritativeLiveRcbNameTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Xml.Linq;
using AR.Iec61850.Discovery;
using AR.Iec61850.Scl.Export;

namespace AR.Iec61850.Tests.Scl;

public sealed class AuthoritativeLiveRcbNameTests
{
private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL";

[Fact]
public void ApplyReportControlConfiguration_Preserves_A_BRCB_1201_Without_Second_Index()
{
var source = XDocument.Parse(
"""
<SCL xmlns="http://www.iec.ch/61850/2003/SCL">
<IED name="BCU7SL">
<Services><ConfReportControl max="57" /></Services>
<AccessPoint name="AP1"><Server><LDevice inst="CTRL"><LN0 lnClass="LLN0" lnType="LN0_TYPE">
<DataSet name="ARIED_8550BE6C"><FCDA ldInst="CTRL" lnClass="CSWI" lnInst="1" doName="Pos" daName="stVal" fc="ST" /></DataSet>
<ReportControl name="A_BRCB_1201" buffered="true" datSet="ARIED_8550BE6C" confRev="4">
<TrgOps dchg="false" qchg="false" dupd="false" period="false" />
<OptFields seqNum="false" timeStamp="false" reasonCode="false" dataSet="false" dataRef="false" entryID="false" configRef="false" />
<RptEnabled max="1" />
</ReportControl>
</LN0></LDevice></Server></AccessPoint>
</IED>
<DataTypeTemplates><LNodeType id="LN0_TYPE" lnClass="LLN0" /></DataTypeTemplates>
</SCL>
""");
var model = new LiveIedModelDiscoveryDocument
{
ReportControls =
[
new LiveIedReportControlModel
{
Reference = "BCU7SLCTRL/LLN0.BR.A_BRCB_1201",
Domain = "BCU7SLCTRL",
LogicalNode = "LLN0",
Name = "A_BRCB_1201",
Buffered = true,
DataSetReference = "BCU7SLCTRL/LLN0.ARIED_8550BE6C",
ConfRev = "4"
}
]
};

var result = AuthoritativeLiveIedSclExporter.ApplyReportControlConfiguration(
source,
model,
SclSchemaProfiles.Get(SclSchemaProfile.Edition1V16));

var reportControl = Assert.Single(result.Descendants(Scl + "ReportControl"));
Assert.Equal("A_BRCB_1201", (string?)reportControl.Attribute("name"));
Assert.Equal("false", (string?)reportControl.Attribute("indexed"));
Assert.Equal("1", (string?)Assert.Single(reportControl.Elements(Scl + "RptEnabled")).Attribute("max"));
Assert.DoesNotContain("A_BRCB_120101", result.ToString(SaveOptions.DisableFormatting), StringComparison.Ordinal);
}
}
68 changes: 68 additions & 0 deletions tests/AR.Iec61850.Tests/Scl/LiveRcbSclInstanceNormalizerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Xml.Linq;
using AR.Iec61850.Scl.Export;

namespace AR.Iec61850.Tests.Scl;

public sealed class LiveRcbSclInstanceNormalizerTests
{
private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL";

[Fact]
public void Normalize_Exports_Exact_Runtime_Name_Without_Appending_Another_01()
{
var document = XDocument.Parse(
"""
<SCL xmlns="http://www.iec.ch/61850/2003/SCL">
<IED name="BCU7SL">
<Services><ConfReportControl max="57" /></Services>
<AccessPoint name="AP1"><Server><LDevice inst="CTRL"><LN0 lnClass="LLN0" lnType="LN0_TYPE">
<DataSet name="ARIED_8550BE6C"><FCDA ldInst="CTRL" lnClass="CSWI" lnInst="1" doName="Pos" daName="stVal" fc="ST" /></DataSet>
<ReportControl name="A_BRCB_1201" buffered="true" datSet="ARIED_8550BE6C" confRev="4">
<TrgOps dchg="false" qchg="false" dupd="false" period="false" />
<OptFields seqNum="false" timeStamp="false" reasonCode="false" dataSet="false" dataRef="false" entryID="false" configRef="false" />
<RptEnabled max="1" />
</ReportControl>
</LN0></LDevice></Server></AccessPoint>
</IED>
<DataTypeTemplates><LNodeType id="LN0_TYPE" lnClass="LLN0" /></DataTypeTemplates>
</SCL>
""");

var result = LiveRcbSclInstanceNormalizer.Normalize(document, "A_BRCB_1201");

var reportControl = Assert.Single(document.Descendants(Scl + "ReportControl"));
Assert.Equal("A_BRCB_1201", (string?)reportControl.Attribute("name"));
Assert.Equal("false", (string?)reportControl.Attribute("indexed"));
Assert.Equal("1", (string?)Assert.Single(reportControl.Elements(Scl + "RptEnabled")).Attribute("max"));
Assert.Equal("1", (string?)Assert.Single(document.Descendants(Scl + "ConfReportControl")).Attribute("max"));
Assert.Equal("A_BRCB_1201", result.ExactRuntimeReportControlName);

var importedRuntimeName = string.Equals(
(string?)reportControl.Attribute("indexed"),
"true",
StringComparison.OrdinalIgnoreCase)
? $"{(string?)reportControl.Attribute("name")}01"
: (string?)reportControl.Attribute("name");
Assert.Equal("A_BRCB_1201", importedRuntimeName);
Assert.DoesNotContain("A_BRCB_120101", document.ToString(SaveOptions.DisableFormatting), StringComparison.Ordinal);
}

[Fact]
public void Normalize_Rejects_Multiple_ReportControl_Definitions()
{
var document = XDocument.Parse(
"""
<SCL xmlns="http://www.iec.ch/61850/2003/SCL">
<IED name="IED"><AccessPoint name="AP"><Server><LDevice inst="LD"><LN0 lnClass="LLN0" lnType="T">
<ReportControl name="A" buffered="true" datSet="DS" confRev="1" />
<ReportControl name="B" buffered="true" datSet="DS" confRev="1" />
</LN0></LDevice></Server></AccessPoint></IED>
</SCL>
""");

var error = Assert.Throws<InvalidDataException>(() =>
LiveRcbSclInstanceNormalizer.Normalize(document, "A_BRCB_1201"));

Assert.Contains("exactly one ReportControl", error.Message, StringComparison.OrdinalIgnoreCase);
}
}
Loading