Skip to content

Commit c72d953

Browse files
authored
Merge pull request #342 from com-pas/feat/334-104-export-menu-plugin
feat: 104 Export menu plugin closes #334
2 parents b5c0740 + 8f1b555 commit c72d953

File tree

7 files changed

+1211
-0
lines changed

7 files changed

+1211
-0
lines changed

packages/compas-open-scd/public/js/plugins.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,13 @@ export const officialPlugins = [
336336
requireDoc: true,
337337
position: 'middle',
338338
},
339+
{
340+
name: 'Export IEC 104 CSV',
341+
src: '/plugins/src/menu/Export104.js',
342+
icon: 'sim_card_download',
343+
default: false,
344+
kind: 'menu',
345+
requireDoc: true,
346+
position: 'middle',
347+
},
339348
];

packages/compas-open-scd/src/translations/de.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,18 @@ export const de: Translations = {
585585
scaleMultiplierHelper: '???',
586586
scaleOffsetHelper: '???',
587587
},
588+
export: {
589+
noSignalsFound: 'Export 104 hat keine Signale gefunden',
590+
invalidSignalWarning: 'Export 104 hat ein ungültiges Signal gefunden',
591+
errors: {
592+
tiOrIoaInvalid: 'ti or ioa fehlen oder ioa hat weniger als 4 Zeichen, ti: "{{ ti }}", ioa: "{{ ioa }}"',
593+
unknownSignalType: 'Unbekannter Signaltyp für ti: "{{ ti }}", ioa: "{{ ioa }}"',
594+
noDoi: 'Es wurde kein Eltern DOI Element gefunden für ioa: "{{ ioa }}"',
595+
noBay: 'Es wurde kein Bay Element mit dem Namen "{{ bayName }}" für ioa: "{{ ioa }}" gefunden',
596+
noVoltageLevel: 'Es wurde kein VoltageLevel Element für Bay "{{ bayName }}" gefunden für ioa "{{ ioa }}"',
597+
noSubstation: 'Es wurde kein Substation Element gefunden für VoltageLevel "{{ voltageLevelName }}" für ioa "{{ ioa }}"'
598+
}
599+
}
588600
},
589601
'compare-ied': {
590602
selectProjectTitle: 'Lade IEDs aus Vorlage',

packages/compas-open-scd/src/translations/en.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,18 @@ export const en = {
582582
scaleMultiplierHelper: 'Scale Multiplier',
583583
scaleOffsetHelper: 'Scale Offset',
584584
},
585+
export: {
586+
noSignalsFound: 'Export 104 found no signals',
587+
invalidSignalWarning: 'Export 104 found invalid signal',
588+
errors: {
589+
tiOrIoaInvalid: 'ti or ioa are missing or ioa is less than 4 digits, ti: "{{ ti }}", ioa: "{{ ioa }}"',
590+
unknownSignalType: 'Unknown signal type for ti: "{{ ti }}", ioa: "{{ ioa }}"',
591+
noDoi: 'No parent DOI found for address with ioa: "{{ ioa }}"',
592+
noBay: 'No Bay found bayname: "{{ bayName }}" for address with ioa: "{{ ioa }}"',
593+
noVoltageLevel: 'No parent voltage level found for bay "{{ bayName }}" for ioa "{{ ioa }}"',
594+
noSubstation: 'No parent substation found for voltage level "{{ voltageLevelName }}" for ioa "{{ ioa }}"'
595+
}
596+
}
585597
},
586598
'compare-ied': {
587599
selectProjectTitle: 'Select template project to Compare IED with',
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { LitElement, property } from 'lit-element';
2+
import { stringify } from 'csv-stringify/browser/esm/sync';
3+
import { newLogEvent } from '@openscd/core/foundation/deprecated/history.js';
4+
5+
import { extractAllSignal104Data, Signal104 } from './export104/foundation.js';
6+
import { get } from 'lit-translate';
7+
8+
9+
10+
export default class Export104 extends LitElement {
11+
@property({ attribute: false }) doc!: XMLDocument;
12+
@property() docName!: string;
13+
14+
private readonly csvHeaders = [
15+
'Id',
16+
'Name',
17+
'Signal Number',
18+
'mIOA',
19+
'cIOA'
20+
];
21+
22+
async run(): Promise<void> {
23+
const { signals, errors } = extractAllSignal104Data(this.doc);
24+
25+
errors.forEach((error) => this.logWarning(error));
26+
27+
if (signals.length === 0) {
28+
this.dispatchEvent(newLogEvent({
29+
kind: 'info',
30+
title: get('protocol104.export.noSignalsFound'),
31+
}));
32+
return;
33+
}
34+
35+
const csvLines = this.generateCsvLines(signals);
36+
37+
const csvContent = stringify(csvLines, {
38+
header: true,
39+
columns: this.csvHeaders,
40+
});
41+
const csvBlob = new Blob([csvContent], {
42+
type: 'text/csv',
43+
});
44+
45+
this.downloadCsv(csvBlob);
46+
}
47+
48+
private logWarning(errorMessage: string): void {
49+
this.dispatchEvent(newLogEvent({
50+
kind: 'warning',
51+
title: get('protocol104.export.invalidSignalWarning'),
52+
message: errorMessage,
53+
}));
54+
}
55+
56+
private generateCsvLines(allSignal104Data: Signal104[]): string[][] {
57+
const lines: string[][] = [];
58+
59+
for(const signal104Data of allSignal104Data) {
60+
const line = [
61+
'',
62+
signal104Data.name ?? '',
63+
signal104Data.signalNumber ?? '',
64+
];
65+
66+
if (signal104Data.isMonitorSignal) {
67+
line.push(signal104Data.ioa ?? '', '');
68+
} else {
69+
line.push('', signal104Data.ioa ?? '');
70+
}
71+
72+
lines.push(line);
73+
}
74+
75+
return lines;
76+
}
77+
78+
private downloadCsv(csvBlob: Blob): void {
79+
const a = document.createElement('a');
80+
a.download = this.docName + '-104-signals.csv';
81+
a.href = URL.createObjectURL(csvBlob);
82+
a.dataset.downloadurl = ['text/csv', a.download, a.href].join(':');
83+
a.style.display = 'none';
84+
85+
document.body.appendChild(a);
86+
a.click();
87+
document.body.removeChild(a);
88+
URL.revokeObjectURL(a.href);
89+
}
90+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { get } from "lit-translate";
2+
3+
export const PROTOCOL_104_PRIVATE = 'IEC_60870_5_104';
4+
5+
export interface Signal104 {
6+
name: string | null;
7+
signalNumber: string | null;
8+
isMonitorSignal: boolean;
9+
ioa: string | null;
10+
ti: string | null;
11+
}
12+
13+
interface ExtractSignal104Result {
14+
signal: Signal104 | null;
15+
error?: string;
16+
}
17+
18+
enum SignalType {
19+
Monitor,
20+
Control,
21+
Unknown
22+
}
23+
24+
const private104Selector = `Private[type="${PROTOCOL_104_PRIVATE}"]`;
25+
26+
export function extractAllSignal104Data(doc: XMLDocument): { signals: Signal104[], errors: string[] } {
27+
const signals: Signal104[] = [];
28+
const errors: string[] = [];
29+
const address104Elements = doc.querySelectorAll(`${private104Selector} > Address`);
30+
31+
address104Elements.forEach((addressElement) => {
32+
const signal104Result = extractSignal104Data(addressElement, doc);
33+
34+
if (signal104Result.error) {
35+
errors.push(signal104Result.error);
36+
} else {
37+
signals.push(signal104Result.signal!);
38+
}
39+
});
40+
41+
return { signals, errors };
42+
}
43+
44+
function extractSignal104Data(addressElement: Element, doc: XMLDocument): ExtractSignal104Result {
45+
const ti = addressElement.getAttribute('ti');
46+
const ioa = addressElement.getAttribute('ioa');
47+
48+
// By convention the last four digits of the ioa are the signalnumber, see https://github.com/com-pas/compas-open-scd/issues/334
49+
if (ti === null || ioa === null || ioa.length < 4) {
50+
return { signal: null, error: get('protocol104.export.errors.tiOrIoaInvalid', { ti: ti ?? '', ioa: ioa ?? '' }) };
51+
}
52+
const { signalNumber, bayName } = splitIoa(ioa);
53+
54+
const signalType = getSignalType(ti);
55+
if (signalType === SignalType.Unknown) {
56+
return { signal: null, error: get('protocol104.export.errors.unknownSignalType', { ti: ti ?? '', ioa: ioa ?? '' }) };
57+
}
58+
const isMonitorSignal = signalType === SignalType.Monitor;
59+
60+
addressElement.parentElement;
61+
const parentDOI = addressElement.closest('DOI');
62+
63+
if (!parentDOI) {
64+
return { signal: null, error: get('protocol104.export.errors.noDoi', { ioa: ioa ?? '' }) };
65+
}
66+
67+
const doiDesc = parentDOI.getAttribute('desc');
68+
69+
const parentBayQuery = `:root > Substation > VoltageLevel > Bay[name="${bayName}"]`;
70+
const parentBay = doc.querySelector(parentBayQuery);
71+
72+
if (!parentBay) {
73+
return { signal: null, error: get('protocol104.export.errors.noBay', { bayName, ioa: ioa ?? '' }) };
74+
}
75+
76+
const parentVoltageLevel = parentBay.closest('VoltageLevel');
77+
78+
if (!parentVoltageLevel) {
79+
return { signal: null, error: get('protocol104.export.errors.noVoltageLevel', { bayName, ioa: ioa ?? '' }) };
80+
}
81+
82+
const voltageLevelName = parentVoltageLevel.getAttribute('name');
83+
const parentSubstation = parentVoltageLevel.closest('Substation');
84+
85+
if (!parentSubstation) {
86+
return { signal: null, error: get('protocol104.export.errors.noSubstation', { voltageLevelName: voltageLevelName ?? '', ioa: ioa ?? '' }) };
87+
}
88+
89+
const substationName = parentSubstation.getAttribute('name');
90+
91+
const name = `${substationName}${voltageLevelName}${bayName}${doiDesc}`;
92+
93+
return {
94+
signal: {
95+
name,
96+
signalNumber,
97+
isMonitorSignal,
98+
ti,
99+
ioa
100+
}
101+
}
102+
}
103+
104+
// For signal classification details see https://github.com/com-pas/compas-open-scd/issues/334
105+
function getSignalType(tiString: string): SignalType {
106+
const ti = parseInt(tiString);
107+
108+
if (isNaN(ti)) {
109+
return SignalType.Unknown;
110+
}
111+
112+
if ((ti >= 1 && ti <= 21) || (ti >= 30 && ti <= 40)) {
113+
return SignalType.Monitor;
114+
} else if ((ti >= 45 && ti <= 51) || (ti >= 58 && ti <= 64)) {
115+
return SignalType.Control;
116+
} else {
117+
return SignalType.Unknown;
118+
}
119+
}
120+
121+
// By Alliander convention the last four digits of the ioa are the signalnumber and the rest is the bay number
122+
// And every bay name consists of "V" + bay number
123+
function splitIoa(ioa: string): { signalNumber: string, bayName: string } {
124+
const signalNumber = ioa.slice(-4);
125+
const bayName = `V${ioa.slice(0, -4)}`;
126+
127+
return { signalNumber, bayName };
128+
}

0 commit comments

Comments
 (0)