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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"test:required-elements": "vitest run test/RequiredElements/",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/validate.ts\" \"src/ValidationIssue.ts\" \"src/index.ts\" \"src/RequiredElements/**/*.ts\" \"test/validate*.ts\"",
"format": "prettier --write \"src/validate.ts\" \"src/ValidationIssue.ts\" \"src/index.ts\" \"src/RequiredElements/**/*.ts\" \"src/SemanticConstraints/**/*.ts\" \"test/validate*.ts\"",
"format:check": "prettier --check \"src/validate.ts\" \"src/ValidationIssue.ts\" \"src/index.ts\" \"src/RequiredElements/**/*.ts\" \"test/validate*.ts\"",
"prepare": "npm run build"
},
Expand Down
105 changes: 89 additions & 16 deletions src/Helpers/BaseSimpleTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,32 +48,105 @@ export function areUnique<T>(elements: NodeListOf<Element> | HTMLCollectionOf<El
* as used by Association, ClientLN, ...) to an LN0 or LN element.
* Returns null when the reference cannot be resolved.
*/
export function findReferencedLN(scl: XMLDocument, ref: Element): Element | null {
export function findReferencedServerLN(scl: XMLDocument, ref: Element): Element | null {

const iedName = getAttributeValue(ref, 'iedName');
const ldInst = getAttributeValue(ref, 'ldInst');
const prefix = getAttributeValue(ref, 'prefix');
const lnClass = getAttributeValue(ref, 'lnClass');
const lnInst = getAttributeValue(ref, 'lnInst');

const scope = iedName ? scl.querySelector(`IED[name="${iedName}"]`) : ref.closest('IED');
if (!scope) return null;
const base = `:root > IED[name="${iedName}"] > AccessPoint > Server > LDevice[inst="${ldInst}"] > `;
const selectorAnyLN = `${base}LN0[lnClass="${lnClass}"], ${base}LN[lnClass="${lnClass}"]`;

return Array.from(scl.querySelectorAll(selectorAnyLN)).find(ln => {
if (prefix !== getAttributeValue(ln, 'prefix')) return false;
if (lnInst !== getAttributeValue(ln, 'inst')) return false;
return true;
}) || null;
}

/**
* Resolves an LN reference (iedName/ldInst/prefix/lnClass/lnInst attributes,
* as used by Association, ClientLN, ...) to an LN0 or LN element.
* Returns null when the reference cannot be resolved.
*/
export function findReferencedClientLN(scl: XMLDocument, ref: Element): Element | null {

const iedName = getAttributeValue(ref, 'iedName');
const apRef = getAttributeValue(ref, 'apRef');
const prefix = getAttributeValue(ref, 'prefix');
const lnClass = getAttributeValue(ref, 'lnClass');
const lnInst = getAttributeValue(ref, 'lnInst');

const selectorAnyLN = `IED[name="${iedName}"] > AccessPoint[name="${apRef}"] > Server > LDevice > LN0[lnClass="${lnClass}"], IED[name="${iedName}"] > AccessPoint[name="${apRef}"] > Server > LDevice > LN[lnClass="${lnClass}"]`;

const lDevices = scope.querySelectorAll('LDevice');
for (let i = 0; i < lDevices.length; i++) {
if (getAttributeValue(lDevices[i], 'inst') !== ldInst) continue;
return Array.from(scl.querySelectorAll(selectorAnyLN)).find(ln => {
if (prefix !== getAttributeValue(ln, 'prefix')) return false;
if (lnInst !== getAttributeValue(ln, 'inst')) return false;
return true;
}) || null;
}

/**
* Resolves a DO, SDO, DA, BDA reference from DOI, SDI, DAI element in IED.
* The function follows the path: LN/LN0 > DOI > (SDI*) > DAI
* and finds corresponding: LNodeType > DO > DOType > DO(SDO*) > DOType > DA (or nested in DAType > BDA)
* Returns null when the reference cannot be resolved.
*/
export function findReferencedDataType(scl: XMLDocument, doiOrSdiOrDaiOrFcda: Element): Element | null {

const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates');
if (!dataTypeTemplates) return null;

const reference: string[] = [];
let lnType: string = '';
if (doiOrSdiOrDaiOrFcda.tagName === 'FCDA') {
const fcda = doiOrSdiOrDaiOrFcda;
const iedName = getAttributeValue(fcda, 'iedName');
const ldInst = getAttributeValue(fcda, 'ldInst');
const prefix = getAttributeValue(fcda, 'prefix') || '';
const lnClass = getAttributeValue(fcda, 'lnClass');
const lnInst = getAttributeValue(fcda, 'lnInst') || '';
const doName = getAttributeValue(fcda, 'doName');
const daName = getAttributeValue(fcda, 'daName');

const selector = [
`:root > IED[name="${iedName}"] > AccessPoint > Server > LDevice[inst="${ldInst}"] > LN0[lnClass="${lnClass}"]`,
`:root > IED[name="${iedName}"] > AccessPoint > Server > LDevice[inst="${ldInst}"] > LN[lnClass="${lnClass}"]`
];

const lns = lDevices[i].querySelectorAll('LN0, LN');
for (let j = 0; j < lns.length; j++) {
const ln = lns[j];
const isLN0 = ln.tagName === 'LN0';
const classMatches = isLN0 ? lnClass === 'LLN0' : getAttributeValue(ln, 'lnClass') === lnClass;
const instMatches = isLN0 ? lnInst === '' : getAttributeValue(ln, 'inst') === lnInst;
const prefixMatches = getAttributeValue(ln, 'prefix') === prefix;
lnType = Array.from(scl.querySelectorAll(selector.join(', '))).find(ln => {
if (prefix !== getAttributeValue(ln, 'prefix')) return false;
if (lnInst !== getAttributeValue(ln, 'inst')) return false;
return true;
})?.getAttribute('lnType') || '';

if (classMatches && instMatches && prefixMatches) {
return ln;
}
reference.push(...doName.split('.'));
if (!!daName) reference.push(...daName.split('.'));

} else {
const doiOrSdiOrDai = doiOrSdiOrDaiOrFcda;
reference.push(doiOrSdiOrDai.getAttribute('name') || '');
let parent: Element | null = doiOrSdiOrDai.parentElement;
while (parent && !(parent.tagName === 'LN' || parent.tagName === 'LN0')) {
reference.unshift(parent.getAttribute('name') || '');
parent = parent.parentElement;
}

lnType = doiOrSdiOrDai.closest('LN, LN0')?.getAttribute('lnType') || '';
}

let dataType: Element | null = dataTypeTemplates?.querySelector(`:scope > LNodeType[id="${lnType}"]`) || null;
for (const ref of reference) {
if (!dataType) return null;

let child: Element | null = dataType.querySelector(`:scope > DO[name="${ref}"], :scope > SDO[name="${ref}"], :scope > DA[name="${ref}"], :scope > BDA[name="${ref}"]`) || null;

if (!child) return null;
if (child && reference[reference.length - 1] === ref) return child;

dataType = dataTypeTemplates?.querySelector(`:scope >DOType[id="${child.getAttribute('type')}"], :scope > DAType[id="${child.getAttribute('type')}"]`) || null;
}

return null;
Expand Down
26 changes: 26 additions & 0 deletions src/SemanticConstraints/AbstractConductingEquipment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ValidationIssue } from '../ValidationIssue';

/**
* The SubEquipment in AbstractConductingEquipment shall have unique child name (from SCL_Substation.xsd)
* ConductingEquipment and TransformerWinding are concrete implementations of AbstractConductingEquipment
* Rule: AbstractConductingEquipment_unique_name_of_SubEquipment
*/
export function AbstractConductingEquipment_unique_name_of_SubEquipment(
scl: XMLDocument,
docName: string
): ValidationIssue[] {
return Array.from(scl.querySelectorAll(':root ConductingEquipment, :root TransformerWinding'))
.filter((ace) => {
const names = Array.from(ace.querySelectorAll(':scope > SubEquipment')).map(
(se) => se.getAttribute('name') || ''
);
return new Set(names).size !== names.length;
})
.map((ace): ValidationIssue => ({
severity: 'ERROR',
id: 'AbstractConductingEquipment_unique_name_of_SubEquipment',
filename: docName,
message: 'Unique child name of SubEquipment in AbstractConductingEquipment',
element: ace,
}));
}
23 changes: 23 additions & 0 deletions src/SemanticConstraints/AbstractDataObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ValidationIssue } from '../ValidationIssue';

// Rule: AbstractDataObject_RefersToDOType
export function abstractDataObject_RefersToDOType(scl: XMLDocument, docName: string): ValidationIssue[] {
// AbstractDataObject is the common base type of DO and SDO
return Array.from(
scl.querySelectorAll(':root DataTypeTemplates > LNodeType > DO, :root DataTypeTemplates > DOType > SDO')
)
.filter((dataObject) => {
const type = dataObject.getAttribute('type');
return type && !scl.querySelector(`DataTypeTemplates DOType[id="${type}"]`);
})
.map((dataObject) => {
const name = dataObject.getAttribute('name') ?? '';
return {
severity: 'ERROR',
id: 'AbstractDataObject_RefersToDOType',
filename: docName,
message: `DO or SDO (name=${name}) does not refer an existing DOType in DataTypeTemplates section`,
element: dataObject,
};
});
}
122 changes: 122 additions & 0 deletions src/SemanticConstraints/AnyLN.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { areNamesUnique } from '../Helpers/BaseSimpleTypes';
import { ValidationIssue } from '../ValidationIssue';

const selector =
':root > IED > AccessPoint > LN, :root > IED > AccessPoint > Server > LDevice >LN0, :root > IED > AccessPoint > Server > LDevice > LN';
// Rule: AnyLN_LogControl_name_unique
export function AnyLN_LogControl_name_unique(scl: XMLDocument, docName: string): ValidationIssue[] {
return Array.from(scl.querySelectorAll(selector))
.filter((anyLN) => !areNamesUnique(anyLN.querySelectorAll(':scope > LogControl')))
.map((anyLN) => ({
severity: 'ERROR',
id: 'AnyLN_LogControl_name_unique',
filename: docName,
message: 'name of the log control block shall be unique within the AnyLN',
element: anyLN,
}));
}

// Rule: AnyLN_ReportControl_name_unique
export function AnyLN_ReportControl_name_unique(scl: XMLDocument, docName: string): ValidationIssue[] {
return Array.from(scl.querySelectorAll(selector))
.filter((anyLN) => !areNamesUnique(anyLN.querySelectorAll(':scope > ReportControl')))
.map((anyLN) => ({
severity: 'ERROR',
id: 'AnyLN_ReportControl_name_unique',
filename: docName,
message: 'name of the report control block shall be unique within the AnyLN',
element: anyLN,
}));
}

// Rule: AnyLN_Log_name_unique
export function AnyLN_Log_name_unique(scl: XMLDocument, docName: string): ValidationIssue[] {
return Array.from(scl.querySelectorAll(selector))
.filter((anyLN) => !areNamesUnique(anyLN.querySelectorAll(':scope > Log')))
.map((anyLN) => ({
severity: 'ERROR',
id: 'AnyLN_Log_name_unique',
filename: docName,
message: 'name of the Log shall be unique within the AnyLN',
element: anyLN,
}));
}

// Rule: AnyLN_DOI_name_unique
export function AnyLN_DOI_name_unique(scl: XMLDocument, docName: string): ValidationIssue[] {
return Array.from(scl.querySelectorAll(selector))
.filter((anyLN) => {
const dois = anyLN.querySelectorAll(':scope > DOI');
return dois.length > 0 && !areNamesUnique(dois);
})
.map((anyLN) => ({
severity: 'ERROR',
id: 'AnyLN_DOI_name_unique',
filename: docName,
message: 'name of the DOI shall be unique within the AnyLN',
element: anyLN,
}));
}

// Rule: AnyLN_LNodeType_lnClass_consistent
export function AnyLN_LNodeType_lnClass_consistent(scl: XMLDocument, docName: string): ValidationIssue[] {
return Array.from(scl.querySelectorAll(selector))
.filter((anyLN) => {
const refersToLNodeType = anyLN.getAttribute('lnType');
const lnClass = anyLN.getAttribute('lnClass');

if (!refersToLNodeType) return false;

// Find the referenced LNodeType
const lNodeType = scl.querySelector(`:root > DataTypeTemplates > LNodeType[id="${refersToLNodeType}"]`);
if (!lNodeType) return false;

const lNodeTypeLnClass = lNodeType.getAttribute('lnClass');
return lNodeTypeLnClass !== lnClass;
})
.map((anyLN) => ({
severity: 'ERROR',
id: 'AnyLN_LNodeType_lnClass_consistent',
filename: docName,
message: 'lnClass shall be the same as the referenced LNodeType lnClass',
element: anyLN,
}));
}

// Rule: AnyLN_RefersToLNodeType
export function AnyLN_RefersToLNodeType(scl: XMLDocument, docName: string): ValidationIssue[] {
const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates');

if (!dataTypeTemplates) {
// If no DataTypeTemplates section, all logical nodes must not reference LNodeTypes
return Array.from(scl.querySelectorAll(selector))
.filter((anyLN) => anyLN.getAttribute('lnType') !== null)
.map((anyLN) => {
const lnType = anyLN.getAttribute('lnType') ?? '';
return {
severity: 'ERROR',
id: 'AnyLN_RefersToLNodeType',
filename: docName,
message: `AnyLN (lnType=${lnType}) does not refer an existing LNodeType in DataTypeTemplates section`,
element: anyLN,
};
});
}

return Array.from(scl.querySelectorAll(selector))
.filter((anyLN) => {
const refersToLNodeType = anyLN.getAttribute('lnType');
if (!refersToLNodeType) return false;
return !dataTypeTemplates.querySelector(`:root > DataTypeTemplates > LNodeType[id="${refersToLNodeType}"]`);
})
.map((anyLN) => {
const lnType = anyLN.getAttribute('lnType') ?? '';
return {
severity: 'ERROR',
id: 'AnyLN_RefersToLNodeType',
filename: docName,
message: `AnyLN (lnType=${lnType}) does not refer an existing LNodeType in DataTypeTemplates section`,
element: anyLN,
};
});
}
21 changes: 21 additions & 0 deletions src/SemanticConstraints/Association.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { findReferencedServerLN, getAttributeValue } from '../Helpers/BaseSimpleTypes';
import { ValidationIssue } from '../ValidationIssue';

// Rule: Association_RefersToAnyLN
export function Association_RefersToAnyLN(scl: XMLDocument, docName: string): ValidationIssue[] {
return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association'))
.filter((association) => {
const associationID = getAttributeValue(association, 'associationID');
return associationID && !findReferencedServerLN(scl, association);
})
.map((association) => {
const associationID = getAttributeValue(association, 'associationID');
return {
severity: 'ERROR',
id: 'Association_RefersToAnyLN',
filename: docName,
message: `Association (ID=${associationID}) does not refer an existing LN`,
element: association,
};
});
}
49 changes: 49 additions & 0 deletions src/SemanticConstraints/BDA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { getAttributeValue } from '../Helpers/BaseSimpleTypes';
import { ValidationIssue } from '../ValidationIssue';

// Rule: BDA_BDAType_allowedWhen_Enum
export function BDA_BDAType_allowedWhen_Enum(scl: XMLDocument, docName: string): ValidationIssue[] {
return Array.from(scl.querySelectorAll('BDA'))
.filter((bda) => {
const bType = getAttributeValue(bda, 'bType');
const type = getAttributeValue(bda, 'type');
return bType !== 'Struct' && bType !== 'Enum' && type !== '';
})
.map((bda) => ({
severity: 'ERROR',
id: 'BDA_BDAType_allowedWhen_Enum',
filename: docName,
message: 'BDA.type is allowed only if bType = Struct or Enum',
element: bda,
}));
}

// Rule: BDA_RefersToDATypeorEnumType
export function BDA_RefersToDATypeorEnumType(scl: XMLDocument, docName: string): ValidationIssue[] {
const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates');
return Array.from(scl.querySelectorAll(':root > DataTypeTemplates > DAType > BDA'))
.filter((bda) => {
const bType = getAttributeValue(bda, 'bType');

if (bType === 'Struct') {
const refersToDAType = getAttributeValue(bda, 'type');
return refersToDAType && !dataTypeTemplates?.querySelector(`:scope > DAType[id="${refersToDAType}"]`);
} else if (bType === 'Enum') {
const refersToEnumType = getAttributeValue(bda, 'type');
return (
refersToEnumType && !dataTypeTemplates?.querySelector(`:scope > EnumType[id="${refersToEnumType}"]`)
);
}
return false;
})
.map((bda) => {
const name = getAttributeValue(bda, 'name');
return {
severity: 'ERROR',
id: 'BDA_RefersToDATypeorEnumType',
filename: docName,
message: `BDA (name=${name}) does not refer an existing DAType or EnumType in DataTypeTemplates section`,
element: bda,
};
});
}
Loading
Loading