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
75 changes: 72 additions & 3 deletions tIED/removeIED.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { isRemove, isUpdate } from "@openscd/oscd-api/utils.js";

import { handleEdit } from "../foundation/helpers.test.js";

import { scl, sclDuplicateLNodes } from "./removeIED.testfile.js";
import {
scl,
sclDuplicateBoundLNodes,
sclDuplicateLNodes,
sclLNodesInSeparateContainers,
} from "./removeIED.testfile.js";

import { removeIED } from "./removeIED.js";

Expand Down Expand Up @@ -137,8 +142,8 @@ describe("Function to an remove the IED and its referenced elements", () => {
/*
* Here we need to test:
* - Delete all LNode references found with matching iedName, BUT only inside the substation section (but not inside Private sections).
* - Find all LNode references with matching iedName and either set them to None, or delete them if setting them to None would result in duplicate LNode keys within the same scope.
* The scope is defined as the nearest Bay, VL or Substation parent.
* - Find all LNode references with matching iedName and either set them to None, or delete them if setting them to None would result in duplicate LNode keys within the same container.
* The scope is defined as the LNode's immediate parent, matching the schema's uniqueness constraint.
*/
describe("without 'preserveNodes' set (default)", () => {
["Bay", "VoltageLevel", "Substation"].forEach((scope) => {
Expand Down Expand Up @@ -234,6 +239,70 @@ describe("Function to an remove the IED and its referenced elements", () => {
});
});

it("does not remove a bound LNode because an unbound copy exists in another container", () => {
const sclDom = new DOMParser().parseFromString(
sclLNodesInSeparateContainers,
"application/xml",
);
const ied = sclDom.querySelector('IED[name="IED1"]')!;
const bound = sclDom.querySelector(
'ConductingEquipment[name="QB1"] LNode',
)!;

const edits = removeIED({ node: ied }, { preserveLNodes: true });

expect(edits.some((edit) => isRemove(edit) && edit.node === bound)).to
.be.false;
expect(
edits.some(
(edit) =>
isUpdate(edit) &&
edit.element === bound &&
edit.attributes?.iedName === "None",
),
).to.be.true;

handleEdit(edits);

expect(
sclDom.querySelector(
'ConductingEquipment[name="QB1"] LNode[iedName="None"]',
),
).to.not.be.null;
expect(
sclDom.querySelector(
'ConductingEquipment[name="QA1"] LNode[iedName="None"]',
),
).to.not.be.null;
});

it("sets only one of two matching bound LNodes in the same container to None", () => {
const sclDom = new DOMParser().parseFromString(
sclDuplicateBoundLNodes,
"application/xml",
);
const ied = sclDom.querySelector('IED[name="IED1"]')!;

const edits = removeIED({ node: ied }, { preserveLNodes: true });

const lNodeEdits = edits.filter(
(edit) =>
(isRemove(edit) && edit.node.nodeName === "LNode") ||
(isUpdate(edit) && edit.element.nodeName === "LNode"),
);
expect(lNodeEdits.length).to.equal(2);
expect(lNodeEdits.filter(isRemove).length).to.equal(1);
expect(lNodeEdits.filter(isUpdate).length).to.equal(1);

handleEdit(edits);

const lNodes = sclDom.querySelectorAll(
'ConductingEquipment[name="QA1"] > LNode',
);
expect(lNodes.length).to.equal(1);
expect(lNodes[0].getAttribute("iedName")).to.equal("None");
});

it("does not create duplicate LNode keys when removing both IEDs", () => {
const sclDom = new DOMParser().parseFromString(
sclDuplicateLNodes,
Expand Down
37 changes: 37 additions & 0 deletions tIED/removeIED.testfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,43 @@ export const scl = `<SCL xmlns="http://www.iec.ch/61850/2003/SCL" xmlns:esld="ht
</DataTypeTemplates>
</SCL>`;

/** SCL with the same LNode key in two different ConductingEquipments within
* one Bay, where only one of them is an existing specification (iedName=None). */
export const sclLNodesInSeparateContainers = `<SCL xmlns="http://www.iec.ch/61850/2003/SCL" version="2007" revision="B" release="4">
<Substation name="S1">
<VoltageLevel name="V1">
<Bay name="B1">
<ConductingEquipment name="QA1">
<LNode iedName="None" ldInst="L" lnClass="XCBR" lnInst="1"/>
</ConductingEquipment>
<ConductingEquipment name="QB1">
<LNode iedName="IED1" ldInst="L" lnClass="XCBR" lnInst="1"/>
</ConductingEquipment>
</Bay>
</VoltageLevel>
</Substation>
<IED name="IED1">
<AccessPoint name="AP1"/>
</IED>
</SCL>`;

/** SCL with two bound LNodes sharing the same key in one container. */
export const sclDuplicateBoundLNodes = `<SCL xmlns="http://www.iec.ch/61850/2003/SCL" version="2007" revision="B" release="4">
<Substation name="S1">
<VoltageLevel name="V1">
<Bay name="B1">
<ConductingEquipment name="QA1">
<LNode iedName="IED1" ldInst="L" lnClass="XCBR" lnInst="1"/>
Comment thread
danyill marked this conversation as resolved.
Resolved
<LNode iedName="IED1" ldInst="L" lnClass="XCBR" lnInst="1"/>
</ConductingEquipment>
</Bay>
</VoltageLevel>
</Substation>
<IED name="IED1">
<AccessPoint name="AP1"/>
</IED>
</SCL>`;

/** SCL with two IEDs that have LNodes in the same ConductingEquipment
* with matching (ldInst, lnClass, lnInst, prefix). Removing both IEDs
* sequentially should not create duplicate LNode key sequences. */
Expand Down
46 changes: 28 additions & 18 deletions tIED/removeIED.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,12 @@ const lNodeKey = (ln: Element): string =>
.map((a) => ln.getAttribute(a) ?? "")
.join("|");

const getLNodeScopeElement = (ln: Element): Element => {
return ln.closest("Bay, VoltageLevel, Substation")!;
};
/**
* The SCL schema applies the LNode uniqueness constraint to the immediate
* parent container of the LNode (e.g. a ConductingEquipment or a Function),
* so that is also the scope in which duplicate keys have to be detected.
*/
const getLNodeScopeElement = (ln: Element): Element => ln.parentElement!;

const createRemoveEdit = (ln: Element): Remove => {
return { node: ln };
Expand All @@ -82,9 +85,11 @@ function removeBoundLNodes(ied: Element, name: string): Remove[] {
* Build the edits required to detach all public LNode bindings to `iedName`
* from the substation model, preserving each as a specification entry with
* (iedName="None"). A cache of all LNodes (with iedName="None") is first built
* up (grouped by their scope/container). This is used to check if changing the
* up (grouped by their parent container). This is used to check if changing the
* iedName of a bound LNode to "None" would create a duplicate binding within its
* scope. If this would result in a duplicate, the LNode is simply removed instead.
* container. If this would result in a duplicate, the LNode is simply removed
* instead. The cache is updated while processing, so duplicates within the set
* of bound LNodes removed in this call are handled as well.
*/
function detachLNodeBindings(
ied: Element,
Expand All @@ -108,19 +113,24 @@ function detachLNodeBindings(
keys.add(lNodeKey(ln));
});

return boundNodes
.map((ln) => {
const scope = getLNodeScopeElement(ln);
const keys = unboundLNodesByScope.get(scope);

const key = lNodeKey(ln);
if (keys && keys.has(key)) {
return createRemoveEdit(ln);
} else {
return setLNodeToNone(ln);
}
})
.filter((edit): edit is SetAttributes | Remove => edit !== undefined);
return boundNodes.map((ln) => {
const scope = getLNodeScopeElement(ln);
let keys = unboundLNodesByScope.get(scope);
if (!keys) {
keys = new Set<string>();
unboundLNodesByScope.set(scope, keys);
}

const key = lNodeKey(ln);
if (keys.has(key)) {
return createRemoveEdit(ln);
}

// Remember the key, so that a second bound LNode with the same key in the
// same container would not also be set to "None".
keys.add(key);
return setLNodeToNone(ln);
});
}

/** Options for the {@link removeIED} function. */
Expand Down
Loading