diff --git a/index.ts b/index.ts index 04c9ff5..4e64511 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ export { removeProcessElement } from "./tSubstation/removeProcessElement.js"; export { InsertIedOptions, insertIed } from "./tIED/insertIED.js"; export { updateIED } from "./tIED/updateIED.js"; +export { removeIED } from "./tIED/removeIED.js"; export { findControlBlockSubscription } from "./tControl/findControlSubscription.js"; export { controlBlockObjRef } from "./tControl/controlBlockObjRef.js"; diff --git a/tExtRef/unsubscribe.ts b/tExtRef/unsubscribe.ts index b011b2b..da00e65 100644 --- a/tExtRef/unsubscribe.ts +++ b/tExtRef/unsubscribe.ts @@ -16,7 +16,7 @@ export type UnsubscribeOptions = { * references - unsubscribing. * ```md * 1. Unsubscribes external references itself: - * -Update `ExtRef` in case later binging is used (existing `intAddr` attribute) + * -Update `ExtRef` in case later binding is used (existing `intAddr` attribute) * -Remove `ExtRef` in case `intAddr` is missing * * 2. Removes leaf `Input` elements as well diff --git a/tIED/removeIED.spec.ts b/tIED/removeIED.spec.ts new file mode 100644 index 0000000..afb5163 --- /dev/null +++ b/tIED/removeIED.spec.ts @@ -0,0 +1,128 @@ +import { expect } from "chai"; + +import { handleEdit, isRemove, isUpdate } from "@openscd/open-scd-core"; + +import { Edit } from "../foundation/utils.js"; + +import { scl } from "./removeIED.testfile.js"; + +import { removeIED } from "./removeIED.js"; + +function numberRemoves(edits: Edit[], tag: string): number { + return edits.filter((edit) => isRemove(edit) && edit.node.nodeName === tag) + .length; +} + +function numberUpdates(edits: Edit[], tag: string): number { + return edits.filter((edit) => isUpdate(edit) && edit.element.nodeName === tag) + .length; +} + +const publisher = new DOMParser() + .parseFromString(scl, "application/xml") + .querySelector('IED[name="Publisher"]')!; + +const subscriber1 = new DOMParser() + .parseFromString(scl, "application/xml") + .querySelector('IED[name="GOOSE_Subscriber1"]')!; + +const client = new DOMParser() + .parseFromString(scl, "application/xml") + .querySelector('IED[name="Client"]')!; + +const substation = new DOMParser() + .parseFromString(scl, "application/xml") + .querySelector("Substation")!; + +describe("Function to an remove the IED and its referenced elements", () => { + it("returns empty array with non-IED update", () => + expect(removeIED({ node: substation }).length).to.equal(0)); + + it("returns just the IED element with missing IED name", () => { + const sclDom = new DOMParser().parseFromString(scl, "application/xml"); + const publi = sclDom.querySelector('IED[name="Publisher"]')!; + publi.removeAttribute("name"); + + expect(removeIED({ node: publi }).length).to.equal(0); + }); + + it("updates LNode iedName attributes to None as well", () => { + const edits = removeIED({ node: subscriber1 }); + + expect(numberUpdates(edits, "LNode")).to.equal(1); + }); + + it("removes ConnectedAPs as well", () => { + const edits = removeIED({ node: publisher }); + + expect(numberRemoves(edits, "ConnectedAP")).to.equal(1); + }); + + it("removes non-later-binding ExtRefs as well", () => { + const edits = removeIED({ node: publisher }); + + expect(numberRemoves(edits, "ExtRef")).to.equal(4); + }); + + it("updates ExtRef iedName attributes as well", () => { + const edits = removeIED({ node: publisher }); + + expect(numberUpdates(edits, "ExtRef")).to.equal(7); + }); + + it("removes empty Inputs elements as well", () => { + const edits = removeIED({ node: publisher }); + + expect(numberRemoves(edits, "Inputs")).to.equal(1); + }); + + it("removes the KDC iedName attributes as well", () => { + const edits = removeIED({ node: publisher }); + + expect(numberRemoves(edits, "KDC")).to.equal(1); + }); + + it("removes Associations as well", () => { + const edits = removeIED({ node: publisher }); + + expect(numberRemoves(edits, "Association")).to.equal(1); + }); + + it("removes ClientLNs as well", () => { + const edits = removeIED({ + node: client, + }); + + expect(numberRemoves(edits, "ClientLN")).to.equal(2); + }); + + it("removes IEDName elements as well", () => { + const edits = removeIED({ + node: subscriber1, + }); + + expect(numberRemoves(edits, "IEDName")).to.equal(1); + }); + + it("removes LGOS/LSVS object reference", () => { + const sclDom = new DOMParser().parseFromString(scl, "application/xml"); + const publisher = sclDom.querySelector('IED[name="Publisher"]')!; + + const before = Array.from( + sclDom.querySelectorAll('LN[lnClass="LGOS"] Val, LN[lnClass="LSVS"] Val'), + ).filter((iedName) => iedName.textContent?.startsWith("Publisher")); + expect(before.length).to.equal(3); + + const edits = removeIED({ + node: publisher, + }); + + handleEdit(edits); + + const after = Array.from( + sclDom.querySelectorAll('LN[lnClass="LGOS"] Val, LN[lnClass="LSVS"] Val'), + ).filter((iedName) => iedName.textContent?.startsWith("Publisher")); + // 1 supervised control block is not subscribed so is not removed + expect(after.length).to.equal(1); + }); +}); diff --git a/tIED/removeIED.testfile.ts b/tIED/removeIED.testfile.ts new file mode 100644 index 0000000..eb1c5c3 --- /dev/null +++ b/tIED/removeIED.testfile.ts @@ -0,0 +1,577 @@ +export const scl = ` +
+ + + + + + + + 110 + + + + + + 100 + + + + + + +
+

01-0C-CD-01-00-01

+

0002

+
+ 10 + 1000 +
+ +
+

01-0C-CD-01-00-00

+

0001

+
+ 10 + 1000 +
+
+ + +
+

01-0C-CD-01-00-03

+

0003

+
+ 10 + 1000 +
+ +
+

01-0C-CD-01-00-04

+

0004

+
+ 10 + 1000 +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PublisherQB2_Disconnector/LLN0.GOOSE2 + + + + + + + + + + + + + + + + PublisherQB2_Disconnector/LLN0.GOOSE1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PublisherSampledValue/LLN0.someSmv + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GOOSE_Subscriber1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + status-only + + + + + + + + status-only + + + + + + + + + + + + + + + sbo-with-enhanced-security + + + 30000 + + + 600 + + + + + + + + + + + + + + + + + + + + + + + + + IEC 61850-7-4:2007B4 + + + + + + + + + + + + + + + + + + + sbo-with-enhanced-security + + + 30000 + + + 600 + + + + + + + + + + + + + + + + + + + + + + IEC 61850-8-1:2003 + + + + + + + + + IEC 61850-8-1:2003 + + + + + + + + + + + + + + IEC 61850-8-1:2003 + + + + + + + + + IEC 61850-8-1:2003 + + + + + + + Load Break + Disconnector + Earthing Switch + High Speed Earthing Switch + + + status-only + + + pulse + persistent + persistent-feedback + + + Ok + Warning + Alarm + + + status-only + direct-with-normal-security + sbo-with-normal-security + direct-with-enhanced-security + sbo-with-enhanced-security + + + on + blocked + test + test/blocked + off + + + not-supported + bay-control + station-control + remote-control + automatic-bay + automatic-station + automatic-remote + maintenance + process + + +`; diff --git a/tIED/removeIED.ts b/tIED/removeIED.ts new file mode 100644 index 0000000..1d227da --- /dev/null +++ b/tIED/removeIED.ts @@ -0,0 +1,95 @@ +import { isPublic } from "../tBaseElement/isPublic.js"; +import { unsubscribe } from "../tExtRef/unsubscribe.js"; +import { removeSubscriptionSupervision } from "../tLN/removeSubscriptionSupervision.js"; + +import type { Remove, Update } from "../foundation/utils.js"; + +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) + .filter((iedNameElement) => iedNameElement.textContent === iedName) + .map((iedNameElement) => { + return { node: iedNameElement }; + }); +} + +function removeWithIedName(ied: Element, iedName: string): Remove[] { + const selector = elementsToRemove + .map((iedNameElement) => `${iedNameElement}[iedName="${iedName}"]`) + .join(","); + + return Array.from(ied.ownerDocument.querySelectorAll(selector)) + .filter(isPublic) + .map((element) => { + return { node: element }; + }); +} + +function removeIedSubscriptionsAndSupervisions( + ied: Element, + iedName: string, +): (Update | Remove)[] { + const extRefs = Array.from(ied.ownerDocument.querySelectorAll(":root > IED")) + .filter((ied) => ied.getAttribute("name") !== iedName) + .flatMap((ied) => + Array.from( + ied.querySelectorAll( + `:scope > AccessPoint > Server > LDevice > LN0 > Inputs > ExtRef[iedName="${iedName}"], + :scope > AccessPoint > Server > LDevice > LN > Inputs > ExtRef[iedName="${iedName}"]`, + ), + ), + ); + + const supervisionRemovals = removeSubscriptionSupervision(extRefs); + const extRefRemovals = unsubscribe(extRefs, { ignoreSupervision: true }); + + return [...extRefRemovals, ...supervisionRemovals]; +} + +function updateIedNameToNone(ied: Element, iedName: string): Update[] { + const selector = elementsToReplaceWithNone + .map((iedNameElement) => `${iedNameElement}[iedName="${iedName}"]`) + .join(","); + + return Array.from(ied.ownerDocument.querySelectorAll(selector)) + .filter(isPublic) + .map((element) => { + return { element, attributes: { iedName: "None", ldInst: null } }; + }); +} + +/** + * Function to remove an IED. + * ```md + * The function makes sure to also: + * 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 + * ``` + * @param remove - IED element as a Remove edit + * @returns - Set of additional edits to relevant SCL elements + */ +export function removeIED(remove: Remove): (Update | Remove)[] { + if ( + remove.node.nodeType !== Node.ELEMENT_NODE || + remove.node.nodeName !== "IED" || + !(remove.node as Element).hasAttribute("name") + ) + return []; + + const ied = remove.node as Element; + const name = ied.getAttribute("name")!; + + return [ + remove, + ...removeIEDNameTextContent(ied, name), + ...removeWithIedName(ied, name), + ...removeIedSubscriptionsAndSupervisions(ied, name), + ...updateIedNameToNone(ied, name), + ]; +}