Skip to content

Commit 4ef2497

Browse files
committed
fix: removeIED - prevent duplicate LNode's
resolves #121
1 parent fb544b6 commit 4ef2497

5 files changed

Lines changed: 302 additions & 17 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,8 @@ node_modules
22
coverage
33
dist
44
doc
5+
6+
.vscode
7+
.idea
8+
*.log
9+
*.tsbuildinfo

index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export { updateLnType } from "./tSubstation/updateLnType.js";
1111
export { InsertIedOptions, insertIed } from "./tIED/insertIED.js";
1212
export { updateIED } from "./tIED/updateIED.js";
1313
export { removeIED } from "./tIED/removeIED.js";
14+
export type { RemoveIedOptions } from "./tIED/removeIED.js";
1415

1516
export { findControlBlockSubscription } from "./tControl/findControlSubscription.js";
1617
export { controlBlockObjRef } from "./tControl/controlBlockObjRef.js";

tIED/removeIED.spec.ts

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { isRemove, isUpdate } from "@openscd/oscd-api/utils.js";
55

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

8-
import { scl } from "./removeIED.testfile.js";
8+
import { scl, sclDuplicateLNodes } from "./removeIED.testfile.js";
99

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

@@ -47,10 +47,10 @@ describe("Function to an remove the IED and its referenced elements", () => {
4747
expect(removeIED({ node: publi }).length).to.equal(0);
4848
});
4949

50-
it("updates LNode iedName attributes to None as well", () => {
50+
it("removes all bound LNodes", () => {
5151
const edits = removeIED({ node: subscriber1 });
5252

53-
expect(numberUpdates(edits, "LNode")).to.equal(1);
53+
expect(numberRemoves(edits, "LNode")).to.equal(1);
5454
});
5555

5656
it("removes ConnectedAPs as well", () => {
@@ -126,4 +126,136 @@ describe("Function to an remove the IED and its referenced elements", () => {
126126
// 1 supervised control block is not subscribed so is not removed
127127
expect(after.length).to.equal(1);
128128
});
129+
130+
describe("referenced LNode's", () => {
131+
/*
132+
* Here we need to test:
133+
* - Delete all LNode references found with matching iedName, BUT only inside the substation section (but not inside Private sections).
134+
* - 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.
135+
* The scope is defined as the nearest Bay, VL or Substation parent.
136+
*/
137+
describe("without 'preservveNodes' set (default)", () => {
138+
//TODO consider changing this into a forEach (Substation, VL and Bay) array test.
139+
["Bay", "VoltageLevel", "Substation"].forEach((scope) => {
140+
it(`deletes all LNodes found directly within a ${scope}`, () => {
141+
const sclDom = new DOMParser().parseFromString(
142+
sclDuplicateLNodes,
143+
"application/xml",
144+
);
145+
const iedA = sclDom.querySelector('IED[name="IED_A"]')!;
146+
const beforeSpec_LNodeCount = (
147+
sclDom.querySelectorAll(`${scope} > LNode[iedName='None']`) ?? []
148+
).length;
149+
150+
const edits = removeIED({ node: iedA });
151+
handleEdit(edits);
152+
const after_iedA_lNodes = Array.from(
153+
sclDom.querySelectorAll(`${scope} LNode[iedName="IED_A"]`),
154+
).length;
155+
const after_spec_LNodeCount = (
156+
sclDom.querySelectorAll(`${scope} > LNode[iedName='None']`) ?? []
157+
).length;
158+
expect(after_iedA_lNodes).to.equal(0);
159+
// The number of LNodes set to None should not have changed.
160+
expect(after_spec_LNodeCount).to.equal(beforeSpec_LNodeCount);
161+
162+
//
163+
});
164+
});
165+
});
166+
167+
describe.only("with preserveLNodes set", () => {
168+
// Broke this into 3 separate tests, so the scope of the failure "might" be narrower.
169+
// Do keep in mind however, the subject SCL has 2 of everything. E.g. S1 & S2
170+
["Bay", "VoltageLevel", "Substation"].forEach((scope) => {
171+
it(`Within a ${scope}, it sets all bound LNodes to None`, () => {
172+
//we're using the "duplicates" test file, but by only deleting 1 IED, no duplicates occur (yet).
173+
const sclDom = new DOMParser().parseFromString(
174+
sclDuplicateLNodes,
175+
"application/xml",
176+
);
177+
const iedA = sclDom.querySelector('IED[name="IED_A"]')!;
178+
const beforeSpec_LNodeCount = (
179+
sclDom.querySelectorAll(`${scope} > LNode[iedName='None']`) ?? []
180+
).length;
181+
const beforeIedA_LNodeCount = (
182+
sclDom.querySelectorAll(`${scope} > LNode[iedName='IED_A']`) ?? []
183+
).length;
184+
185+
const edits = removeIED({ node: iedA }, { preserveLNodes: true });
186+
handleEdit(edits);
187+
const lNodes = Array.from(
188+
sclDom.querySelectorAll(`${scope} > LNode[iedName="None"]`),
189+
);
190+
expect(lNodes.length).to.equal(
191+
beforeSpec_LNodeCount + beforeIedA_LNodeCount,
192+
);
193+
//
194+
});
195+
});
196+
197+
["Bay", "VoltageLevel", "Substation"].forEach((scope) => {
198+
it(`Within a ${scope}, it removes 'would-be' duplicates`, () => {
199+
//we're using the "duplicates" test file, but by only deleting 1 IED, no duplicates occur.
200+
const sclDom = new DOMParser().parseFromString(
201+
sclDuplicateLNodes,
202+
"application/xml",
203+
);
204+
const iedA = sclDom.querySelector('IED[name="IED_A"]')!;
205+
const iedB = sclDom.querySelector('IED[name="IED_B"]')!;
206+
207+
handleEdit(removeIED({ node: iedA }, { preserveLNodes: true }));
208+
const beforeSpec_LNodeCount = (
209+
sclDom.querySelectorAll(`${scope} > LNode[iedName='None']`) ?? []
210+
).length;
211+
// After the first wave of deletions the SCL already has LNodes(iedName=None),
212+
// which exactly match the LNodes we're about to remove.
213+
// So when IED_B is removed (with preserveLNodes set), the LNodes(None)
214+
// should not have changed.
215+
handleEdit(removeIED({ node: iedB }, { preserveLNodes: true }));
216+
const iedB_lNodesCount = Array.from(
217+
sclDom.querySelectorAll(`${scope} > LNode[iedName="IED_B"]`),
218+
).length;
219+
expect(iedB_lNodesCount).to.equal(0);
220+
// Although we've removed IED_A and IED_B, the count should remain unchanged after
221+
// removing IED_A, because both IED's are bound exactly the same.
222+
const afterSpec_LNodeCount = (
223+
sclDom.querySelectorAll(`${scope} > LNode[iedName='None']`) ?? []
224+
).length;
225+
expect(afterSpec_LNodeCount).to.equal(beforeSpec_LNodeCount);
226+
});
227+
});
228+
229+
it("does not create duplicate LNode keys when removing both IEDs", () => {
230+
const sclDom = new DOMParser().parseFromString(
231+
sclDuplicateLNodes,
232+
"application/xml",
233+
);
234+
const iedA = sclDom.querySelector('IED[name="IED_A"]')!;
235+
const iedB = sclDom.querySelector('IED[name="IED_B"]')!;
236+
237+
handleEdit(removeIED({ node: iedA }));
238+
handleEdit(removeIED({ node: iedB }));
239+
240+
const ce = sclDom.querySelector('ConductingEquipment[name="QA1"]')!;
241+
const lNodes = Array.from(ce.querySelectorAll(":scope > LNode"));
242+
const keys = lNodes.map(
243+
(ln) =>
244+
`${ln.getAttribute("ldInst")}|${ln.getAttribute(
245+
"lnClass",
246+
)}|${ln.getAttribute("lnInst")}|${ln.getAttribute(
247+
"prefix",
248+
)}|${ln.getAttribute("iedName")}`,
249+
);
250+
const uniqueKeys = new Set(keys);
251+
252+
expect(keys.length).to.equal(
253+
uniqueKeys.size,
254+
`Duplicate LNode keys found: ${keys
255+
.filter((k, i) => keys.indexOf(k) !== i)
256+
.join(", ")}`,
257+
);
258+
});
259+
});
260+
});
129261
});

tIED/removeIED.testfile.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,3 +575,65 @@ export const scl = `<SCL xmlns="http://www.iec.ch/61850/2003/SCL" xmlns:esld="ht
575575
</EnumType>
576576
</DataTypeTemplates>
577577
</SCL>`;
578+
579+
/** SCL with two IEDs that have LNodes in the same ConductingEquipment
580+
* with matching (ldInst, lnClass, lnInst, prefix). Removing both IEDs
581+
* sequentially should not create duplicate LNode key sequences. */
582+
export const sclDuplicateLNodes = `<SCL xmlns="http://www.iec.ch/61850/2003/SCL" version="2007" revision="B" release="4">
583+
<Header id="DuplicateLNodes"/>
584+
<Substation name="S1">
585+
<LNode iedName="IED_A" ldInst="CBSW" lnClass="XCBR" lnInst="1" prefix=""/>
586+
<LNode iedName="IED_B" ldInst="CBSW" lnClass="XCBR" lnInst="1" prefix=""/>
587+
<VoltageLevel name="V1">
588+
<LNode iedName="IED_A" ldInst="CBSW" lnClass="XCBR" lnInst="1" prefix=""/>
589+
<LNode iedName="IED_B" ldInst="CBSW" lnClass="XCBR" lnInst="1" prefix=""/>
590+
<Bay name="B1">
591+
<LNode iedName="IED_A" ldInst="CBSW" lnClass="XCBR" lnInst="1" prefix=""/>
592+
<LNode iedName="IED_B" ldInst="CBSW" lnClass="XCBR" lnInst="1" prefix=""/>
593+
<ConductingEquipment name="QA1" type="CBR">
594+
<LNode iedName="IED_A" ldInst="CBSW" lnClass="XCBR" lnInst="1" prefix=""/>
595+
<LNode iedName="IED_B" ldInst="CBSW" lnClass="XCBR" lnInst="1" prefix=""/>
596+
<LNode iedName="IED_A" ldInst="CBSW" lnClass="CSWI" lnInst="1" prefix=""/>
597+
<LNode iedName="IED_B" ldInst="CBSW" lnClass="CSWI" lnInst="1" prefix=""/>
598+
</ConductingEquipment>
599+
</Bay>
600+
</VoltageLevel>
601+
</Substation>
602+
603+
<Substation name="S2">
604+
<VoltageLevel name="V2">
605+
<Bay name="B2">
606+
<ConductingEquipment name="QA1" type="CBR">
607+
<LNode iedName="IED_A" ldInst="CBSW" lnClass="XCBR" lnInst="1" prefix=""/>
608+
<LNode iedName="IED_B" ldInst="CBSW" lnClass="XCBR" lnInst="1" prefix=""/>
609+
<LNode iedName="IED_A" ldInst="CBSW" lnClass="CSWI" lnInst="1" prefix=""/>
610+
<LNode iedName="IED_B" ldInst="CBSW" lnClass="CSWI" lnInst="1" prefix=""/>
611+
</ConductingEquipment>
612+
</Bay>
613+
</VoltageLevel>
614+
</Substation>
615+
<IED name="IED_A" manufacturer="Dummy">
616+
<AccessPoint name="AP1">
617+
<Server>
618+
<Authentication/>
619+
<LDevice inst="CBSW">
620+
<LN0 lnClass="LLN0" inst="" lnType="Dummy.LLN0"/>
621+
<LN lnClass="XCBR" inst="1" lnType="Dummy.XCBR"/>
622+
<LN lnClass="CSWI" inst="1" lnType="Dummy.CSWI"/>
623+
</LDevice>
624+
</Server>
625+
</AccessPoint>
626+
</IED>
627+
<IED name="IED_B" manufacturer="Dummy">
628+
<AccessPoint name="AP1">
629+
<Server>
630+
<Authentication/>
631+
<LDevice inst="CBSW">
632+
<LN0 lnClass="LLN0" inst="" lnType="Dummy.LLN0"/>
633+
<LN lnClass="XCBR" inst="1" lnType="Dummy.XCBR"/>
634+
<LN lnClass="CSWI" inst="1" lnType="Dummy.CSWI"/>
635+
</LDevice>
636+
</Server>
637+
</AccessPoint>
638+
</IED>
639+
</SCL>`;

tIED/removeIED.ts

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import { removeSubscriptionSupervision } from "../tLN/removeSubscriptionSupervis
66

77
const elementsToRemove = ["Association", "ClientLN", "ConnectedAP", "KDC"];
88

9-
const elementsToReplaceWithNone = ["LNode"];
10-
119
function removeIEDNameTextContent(ied: Element, iedName: string): Remove[] {
1210
return Array.from(ied.ownerDocument.getElementsByTagName("IEDName"))
1311
.filter(isPublic)
@@ -50,16 +48,94 @@ function removeIedSubscriptionsAndSupervisions(
5048
return [...extRefRemovals, ...supervisionRemovals];
5149
}
5250

53-
function updateIedNameToNone(ied: Element, iedName: string): SetAttributes[] {
54-
const selector = elementsToReplaceWithNone
55-
.map((iedNameElement) => `${iedNameElement}[iedName="${iedName}"]`)
56-
.join(",");
51+
const lNodeKey = (ln: Element): string =>
52+
["lnClass", "lnInst", "ldInst", "prefix"].map((a) => ln.getAttribute(a) ?? "").join("|");
5753

58-
return Array.from(ied.ownerDocument.querySelectorAll(selector))
59-
.filter(isPublic)
60-
.map((element) => {
61-
return { element, attributes: { iedName: "None" } };
62-
});
54+
const getLNodeScopeElement = (ln: Element): Element | null => {
55+
if (ln.closest("Private") === null) {
56+
return ln.closest("Bay, VoltageLevel, Substation");
57+
}
58+
return null;
59+
};
60+
61+
const removeLNode = (ln: Element): Remove => {
62+
return { node: ln };
63+
};
64+
65+
const setLNodeToNone = (ln: Element): SetAttributes => {
66+
return { element: ln, attributes: { iedName: "None" } };
67+
};
68+
69+
const getLNodesByIedName = (doc: XMLDocument, name: string): Element[] => {
70+
return Array.from(
71+
doc.querySelectorAll(`Substation LNode[iedName=${name}]`) ?? [],
72+
).filter(isPublic);
73+
};
74+
75+
/**
76+
* Default handling for LNodes - find any (public) matching LNodes and create a Remove edit for them.
77+
*/
78+
function removeBoundLNodes(ied: Element, name: string): Remove[] {
79+
return (getLNodesByIedName(ied.ownerDocument, name) ?? []).map(removeLNode);
80+
}
81+
82+
/**
83+
* Build the edits required to detach all public LNode bindings to `iedName`
84+
* from the substation model, preserving each as a specification entry with
85+
* (iedName="None"). A cache of all LNodes (with iedName="None") is first built
86+
* up (grouped by their scope/container). This is used to check if changing the
87+
* iedName of a bound LNode to "None" would create a duplicate binding within its
88+
* scope. If this would result in a duplicate, the LNode is simply removed instead.
89+
*/
90+
function detachLNodeBindings(
91+
ied: Element,
92+
name: string,
93+
): (SetAttributes | Remove)[] {
94+
const doc = ied.ownerDocument;
95+
const boundNodes = getLNodesByIedName(doc, name);
96+
97+
if (boundNodes.length === 0) {
98+
return [];
99+
}
100+
101+
const UnboundLNodesByScope = new Map<Element, Set<string>>();
102+
getLNodesByIedName(doc, "None").forEach((ln) => {
103+
const scope = getLNodeScopeElement(ln);
104+
if (scope !== null) {
105+
let keys = UnboundLNodesByScope.get(scope);
106+
if (!keys) {
107+
keys = new Set<string>();
108+
UnboundLNodesByScope.set(scope, keys);
109+
}
110+
keys.add(lNodeKey(ln));
111+
}
112+
});
113+
114+
return boundNodes
115+
.map((ln) => {
116+
const scope = getLNodeScopeElement(ln);
117+
if (!scope) {
118+
return;
119+
}
120+
121+
const keys = UnboundLNodesByScope.get(scope);
122+
123+
const key = lNodeKey(ln);
124+
if (keys && keys.has(key)) {
125+
return removeLNode(ln);
126+
} else {
127+
return setLNodeToNone(ln);
128+
}
129+
})
130+
.filter((edit): edit is SetAttributes | Remove => edit !== undefined);
131+
}
132+
133+
/** Options for the {@link removeIED} function. */
134+
export interface RemoveIedOptions {
135+
/** Flag to optionally set all bound LNodes to iedName="None". Defaults to `false`.
136+
* Note: If setting an LNode to "None" would result in two matching LNodes, the
137+
* LNode will be simply deleted instead.*/
138+
preserveLNodes?: boolean;
63139
}
64140

65141
/**
@@ -69,12 +145,19 @@ function updateIedNameToNone(ied: Element, iedName: string): SetAttributes[] {
69145
* 1. Remove all elements which should no longer exist including ClientLN,
70146
* KDC, Association, ConnectedAP and IEDName
71147
* 2. Remove subscriptions and supervisions
72-
* 2. Update LNodes to an iedName of None
148+
* 3. By default removes all LNodes bound to this IED.
149+
* 4. By setting the optional "preserveLNodes" option to true,
150+
* bound LNodes are set to iedName="None" and only removed if
151+
* it would result in two matching LNodes.
73152
* ```
74153
* @param remove - IED element as a Remove edit
154+
* @param options - Optional settings to control removal behavior
75155
* @returns - Set of additional edits to relevant SCL elements
76156
*/
77-
export function removeIED(remove: Remove): (SetAttributes | Remove)[] {
157+
export function removeIED(
158+
remove: Remove,
159+
options: RemoveIedOptions = { preserveLNodes: false },
160+
): (SetAttributes | Remove)[] {
78161
if (
79162
remove.node.nodeType !== Node.ELEMENT_NODE ||
80163
remove.node.nodeName !== "IED" ||
@@ -90,6 +173,8 @@ export function removeIED(remove: Remove): (SetAttributes | Remove)[] {
90173
...removeIEDNameTextContent(ied, name),
91174
...removeWithIedName(ied, name),
92175
...removeIedSubscriptionsAndSupervisions(ied, name),
93-
...updateIedNameToNone(ied, name),
176+
...(options.preserveLNodes
177+
? detachLNodeBindings(ied, name)
178+
: removeBoundLNodes(ied, name)),
94179
];
95180
}

0 commit comments

Comments
 (0)