From 4ef2497e695dc0fa0ab53d333167767d117ea3db Mon Sep 17 00:00:00 2001 From: Stephen Renwick Date: Wed, 27 May 2026 14:06:04 +0200 Subject: [PATCH] fix: removeIED - prevent duplicate LNode's resolves #121 --- .gitignore | 5 ++ index.ts | 1 + tIED/removeIED.spec.ts | 138 ++++++++++++++++++++++++++++++++++++- tIED/removeIED.testfile.ts | 62 +++++++++++++++++ tIED/removeIED.ts | 113 ++++++++++++++++++++++++++---- 5 files changed, 302 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 085e065..0b23660 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ node_modules coverage dist doc + +.vscode +.idea +*.log +*.tsbuildinfo diff --git a/index.ts b/index.ts index 3910195..70d2699 100644 --- a/index.ts +++ b/index.ts @@ -11,6 +11,7 @@ export { updateLnType } from "./tSubstation/updateLnType.js"; export { InsertIedOptions, insertIed } from "./tIED/insertIED.js"; export { updateIED } from "./tIED/updateIED.js"; export { removeIED } from "./tIED/removeIED.js"; +export type { RemoveIedOptions } from "./tIED/removeIED.js"; export { findControlBlockSubscription } from "./tControl/findControlSubscription.js"; export { controlBlockObjRef } from "./tControl/controlBlockObjRef.js"; diff --git a/tIED/removeIED.spec.ts b/tIED/removeIED.spec.ts index 4510d65..af56ebd 100644 --- a/tIED/removeIED.spec.ts +++ b/tIED/removeIED.spec.ts @@ -5,7 +5,7 @@ import { isRemove, isUpdate } from "@openscd/oscd-api/utils.js"; import { handleEdit } from "../foundation/helpers.test.js"; -import { scl } from "./removeIED.testfile.js"; +import { scl, sclDuplicateLNodes } from "./removeIED.testfile.js"; import { removeIED } from "./removeIED.js"; @@ -47,10 +47,10 @@ describe("Function to an remove the IED and its referenced elements", () => { expect(removeIED({ node: publi }).length).to.equal(0); }); - it("updates LNode iedName attributes to None as well", () => { + it("removes all bound LNodes", () => { const edits = removeIED({ node: subscriber1 }); - expect(numberUpdates(edits, "LNode")).to.equal(1); + expect(numberRemoves(edits, "LNode")).to.equal(1); }); it("removes ConnectedAPs as well", () => { @@ -126,4 +126,136 @@ describe("Function to an remove the IED and its referenced elements", () => { // 1 supervised control block is not subscribed so is not removed expect(after.length).to.equal(1); }); + + describe("referenced LNode's", () => { + /* + * 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. + */ + describe("without 'preservveNodes' set (default)", () => { + //TODO consider changing this into a forEach (Substation, VL and Bay) array test. + ["Bay", "VoltageLevel", "Substation"].forEach((scope) => { + it(`deletes all LNodes found directly within a ${scope}`, () => { + const sclDom = new DOMParser().parseFromString( + sclDuplicateLNodes, + "application/xml", + ); + const iedA = sclDom.querySelector('IED[name="IED_A"]')!; + const beforeSpec_LNodeCount = ( + sclDom.querySelectorAll(`${scope} > LNode[iedName='None']`) ?? [] + ).length; + + const edits = removeIED({ node: iedA }); + handleEdit(edits); + const after_iedA_lNodes = Array.from( + sclDom.querySelectorAll(`${scope} LNode[iedName="IED_A"]`), + ).length; + const after_spec_LNodeCount = ( + sclDom.querySelectorAll(`${scope} > LNode[iedName='None']`) ?? [] + ).length; + expect(after_iedA_lNodes).to.equal(0); + // The number of LNodes set to None should not have changed. + expect(after_spec_LNodeCount).to.equal(beforeSpec_LNodeCount); + + // + }); + }); + }); + + describe.only("with preserveLNodes set", () => { + // Broke this into 3 separate tests, so the scope of the failure "might" be narrower. + // Do keep in mind however, the subject SCL has 2 of everything. E.g. S1 & S2 + ["Bay", "VoltageLevel", "Substation"].forEach((scope) => { + it(`Within a ${scope}, it sets all bound LNodes to None`, () => { + //we're using the "duplicates" test file, but by only deleting 1 IED, no duplicates occur (yet). + const sclDom = new DOMParser().parseFromString( + sclDuplicateLNodes, + "application/xml", + ); + const iedA = sclDom.querySelector('IED[name="IED_A"]')!; + const beforeSpec_LNodeCount = ( + sclDom.querySelectorAll(`${scope} > LNode[iedName='None']`) ?? [] + ).length; + const beforeIedA_LNodeCount = ( + sclDom.querySelectorAll(`${scope} > LNode[iedName='IED_A']`) ?? [] + ).length; + + const edits = removeIED({ node: iedA }, { preserveLNodes: true }); + handleEdit(edits); + const lNodes = Array.from( + sclDom.querySelectorAll(`${scope} > LNode[iedName="None"]`), + ); + expect(lNodes.length).to.equal( + beforeSpec_LNodeCount + beforeIedA_LNodeCount, + ); + // + }); + }); + + ["Bay", "VoltageLevel", "Substation"].forEach((scope) => { + it(`Within a ${scope}, it removes 'would-be' duplicates`, () => { + //we're using the "duplicates" test file, but by only deleting 1 IED, no duplicates occur. + const sclDom = new DOMParser().parseFromString( + sclDuplicateLNodes, + "application/xml", + ); + const iedA = sclDom.querySelector('IED[name="IED_A"]')!; + const iedB = sclDom.querySelector('IED[name="IED_B"]')!; + + handleEdit(removeIED({ node: iedA }, { preserveLNodes: true })); + const beforeSpec_LNodeCount = ( + sclDom.querySelectorAll(`${scope} > LNode[iedName='None']`) ?? [] + ).length; + // After the first wave of deletions the SCL already has LNodes(iedName=None), + // which exactly match the LNodes we're about to remove. + // So when IED_B is removed (with preserveLNodes set), the LNodes(None) + // should not have changed. + handleEdit(removeIED({ node: iedB }, { preserveLNodes: true })); + const iedB_lNodesCount = Array.from( + sclDom.querySelectorAll(`${scope} > LNode[iedName="IED_B"]`), + ).length; + expect(iedB_lNodesCount).to.equal(0); + // Although we've removed IED_A and IED_B, the count should remain unchanged after + // removing IED_A, because both IED's are bound exactly the same. + const afterSpec_LNodeCount = ( + sclDom.querySelectorAll(`${scope} > LNode[iedName='None']`) ?? [] + ).length; + expect(afterSpec_LNodeCount).to.equal(beforeSpec_LNodeCount); + }); + }); + + it("does not create duplicate LNode keys when removing both IEDs", () => { + const sclDom = new DOMParser().parseFromString( + sclDuplicateLNodes, + "application/xml", + ); + const iedA = sclDom.querySelector('IED[name="IED_A"]')!; + const iedB = sclDom.querySelector('IED[name="IED_B"]')!; + + handleEdit(removeIED({ node: iedA })); + handleEdit(removeIED({ node: iedB })); + + const ce = sclDom.querySelector('ConductingEquipment[name="QA1"]')!; + const lNodes = Array.from(ce.querySelectorAll(":scope > LNode")); + const keys = lNodes.map( + (ln) => + `${ln.getAttribute("ldInst")}|${ln.getAttribute( + "lnClass", + )}|${ln.getAttribute("lnInst")}|${ln.getAttribute( + "prefix", + )}|${ln.getAttribute("iedName")}`, + ); + const uniqueKeys = new Set(keys); + + expect(keys.length).to.equal( + uniqueKeys.size, + `Duplicate LNode keys found: ${keys + .filter((k, i) => keys.indexOf(k) !== i) + .join(", ")}`, + ); + }); + }); + }); }); diff --git a/tIED/removeIED.testfile.ts b/tIED/removeIED.testfile.ts index eb1c5c3..3189bc5 100644 --- a/tIED/removeIED.testfile.ts +++ b/tIED/removeIED.testfile.ts @@ -575,3 +575,65 @@ export const scl = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/tIED/removeIED.ts b/tIED/removeIED.ts index 7930c00..80e257d 100644 --- a/tIED/removeIED.ts +++ b/tIED/removeIED.ts @@ -6,8 +6,6 @@ import { removeSubscriptionSupervision } from "../tLN/removeSubscriptionSupervis const elementsToRemove = ["Association", "ClientLN", "ConnectedAP", "KDC"]; -const elementsToReplaceWithNone = ["LNode"]; - function removeIEDNameTextContent(ied: Element, iedName: string): Remove[] { return Array.from(ied.ownerDocument.getElementsByTagName("IEDName")) .filter(isPublic) @@ -50,16 +48,94 @@ function removeIedSubscriptionsAndSupervisions( return [...extRefRemovals, ...supervisionRemovals]; } -function updateIedNameToNone(ied: Element, iedName: string): SetAttributes[] { - const selector = elementsToReplaceWithNone - .map((iedNameElement) => `${iedNameElement}[iedName="${iedName}"]`) - .join(","); +const lNodeKey = (ln: Element): string => + ["lnClass", "lnInst", "ldInst", "prefix"].map((a) => ln.getAttribute(a) ?? "").join("|"); - return Array.from(ied.ownerDocument.querySelectorAll(selector)) - .filter(isPublic) - .map((element) => { - return { element, attributes: { iedName: "None" } }; - }); +const getLNodeScopeElement = (ln: Element): Element | null => { + if (ln.closest("Private") === null) { + return ln.closest("Bay, VoltageLevel, Substation"); + } + return null; +}; + +const removeLNode = (ln: Element): Remove => { + return { node: ln }; +}; + +const setLNodeToNone = (ln: Element): SetAttributes => { + return { element: ln, attributes: { iedName: "None" } }; +}; + +const getLNodesByIedName = (doc: XMLDocument, name: string): Element[] => { + return Array.from( + doc.querySelectorAll(`Substation LNode[iedName=${name}]`) ?? [], + ).filter(isPublic); +}; + +/** + * Default handling for LNodes - find any (public) matching LNodes and create a Remove edit for them. + */ +function removeBoundLNodes(ied: Element, name: string): Remove[] { + return (getLNodesByIedName(ied.ownerDocument, name) ?? []).map(removeLNode); +} + +/** + * 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 + * 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. + */ +function detachLNodeBindings( + ied: Element, + name: string, +): (SetAttributes | Remove)[] { + const doc = ied.ownerDocument; + const boundNodes = getLNodesByIedName(doc, name); + + if (boundNodes.length === 0) { + return []; + } + + const UnboundLNodesByScope = new Map>(); + getLNodesByIedName(doc, "None").forEach((ln) => { + const scope = getLNodeScopeElement(ln); + if (scope !== null) { + let keys = UnboundLNodesByScope.get(scope); + if (!keys) { + keys = new Set(); + UnboundLNodesByScope.set(scope, keys); + } + keys.add(lNodeKey(ln)); + } + }); + + return boundNodes + .map((ln) => { + const scope = getLNodeScopeElement(ln); + if (!scope) { + return; + } + + const keys = UnboundLNodesByScope.get(scope); + + const key = lNodeKey(ln); + if (keys && keys.has(key)) { + return removeLNode(ln); + } else { + return setLNodeToNone(ln); + } + }) + .filter((edit): edit is SetAttributes | Remove => edit !== undefined); +} + +/** Options for the {@link removeIED} function. */ +export interface RemoveIedOptions { + /** Flag to optionally set all bound LNodes to iedName="None". Defaults to `false`. + * Note: If setting an LNode to "None" would result in two matching LNodes, the + * LNode will be simply deleted instead.*/ + preserveLNodes?: boolean; } /** @@ -69,12 +145,19 @@ function updateIedNameToNone(ied: Element, iedName: string): SetAttributes[] { * 1. Remove all elements which should no longer exist including ClientLN, * KDC, Association, ConnectedAP and IEDName * 2. Remove subscriptions and supervisions - * 2. Update LNodes to an iedName of None + * 3. By default removes all LNodes bound to this IED. + * 4. By setting the optional "preserveLNodes" option to true, + * bound LNodes are set to iedName="None" and only removed if + * it would result in two matching LNodes. * ``` * @param remove - IED element as a Remove edit + * @param options - Optional settings to control removal behavior * @returns - Set of additional edits to relevant SCL elements */ -export function removeIED(remove: Remove): (SetAttributes | Remove)[] { +export function removeIED( + remove: Remove, + options: RemoveIedOptions = { preserveLNodes: false }, +): (SetAttributes | Remove)[] { if ( remove.node.nodeType !== Node.ELEMENT_NODE || remove.node.nodeName !== "IED" || @@ -90,6 +173,8 @@ export function removeIED(remove: Remove): (SetAttributes | Remove)[] { ...removeIEDNameTextContent(ied, name), ...removeWithIedName(ied, name), ...removeIedSubscriptionsAndSupervisions(ied, name), - ...updateIedNameToNone(ied, name), + ...(options.preserveLNodes + ? detachLNodeBindings(ied, name) + : removeBoundLNodes(ied, name)), ]; }