diff --git a/packages/compas-open-scd/public/js/plugins.js b/packages/compas-open-scd/public/js/plugins.js index eecda32d5c..748fad2f6b 100644 --- a/packages/compas-open-scd/public/js/plugins.js +++ b/packages/compas-open-scd/public/js/plugins.js @@ -336,4 +336,13 @@ export const officialPlugins = [ requireDoc: true, position: 'middle', }, + { + name: 'Export IEC 104 CSV', + src: '/plugins/src/menu/Export104.js', + icon: 'sim_card_download', + default: false, + kind: 'menu', + requireDoc: true, + position: 'middle', + }, ]; diff --git a/packages/compas-open-scd/src/translations/de.ts b/packages/compas-open-scd/src/translations/de.ts index bb5b98ec90..bf6f53f4a3 100644 --- a/packages/compas-open-scd/src/translations/de.ts +++ b/packages/compas-open-scd/src/translations/de.ts @@ -585,6 +585,18 @@ export const de: Translations = { scaleMultiplierHelper: '???', scaleOffsetHelper: '???', }, + export: { + noSignalsFound: 'Export 104 hat keine Signale gefunden', + invalidSignalWarning: 'Export 104 hat ein ungültiges Signal gefunden', + errors: { + tiOrIoaInvalid: 'ti or ioa fehlen oder ioa hat weniger als 4 Zeichen, ti: "{{ ti }}", ioa: "{{ ioa }}"', + unknownSignalType: 'Unbekannter Signaltyp für ti: "{{ ti }}", ioa: "{{ ioa }}"', + noDoi: 'Es wurde kein Eltern DOI Element gefunden für ioa: "{{ ioa }}"', + noBay: 'Es wurde kein Bay Element mit dem Namen "{{ bayName }}" für ioa: "{{ ioa }}" gefunden', + noVoltageLevel: 'Es wurde kein VoltageLevel Element für Bay "{{ bayName }}" gefunden für ioa "{{ ioa }}"', + noSubstation: 'Es wurde kein Substation Element gefunden für VoltageLevel "{{ voltageLevelName }}" für ioa "{{ ioa }}"' + } + } }, 'compare-ied': { selectProjectTitle: 'Lade IEDs aus Vorlage', diff --git a/packages/compas-open-scd/src/translations/en.ts b/packages/compas-open-scd/src/translations/en.ts index dcb08571e8..9cfca636a0 100644 --- a/packages/compas-open-scd/src/translations/en.ts +++ b/packages/compas-open-scd/src/translations/en.ts @@ -582,6 +582,18 @@ export const en = { scaleMultiplierHelper: 'Scale Multiplier', scaleOffsetHelper: 'Scale Offset', }, + export: { + noSignalsFound: 'Export 104 found no signals', + invalidSignalWarning: 'Export 104 found invalid signal', + errors: { + tiOrIoaInvalid: 'ti or ioa are missing or ioa is less than 4 digits, ti: "{{ ti }}", ioa: "{{ ioa }}"', + unknownSignalType: 'Unknown signal type for ti: "{{ ti }}", ioa: "{{ ioa }}"', + noDoi: 'No parent DOI found for address with ioa: "{{ ioa }}"', + noBay: 'No Bay found bayname: "{{ bayName }}" for address with ioa: "{{ ioa }}"', + noVoltageLevel: 'No parent voltage level found for bay "{{ bayName }}" for ioa "{{ ioa }}"', + noSubstation: 'No parent substation found for voltage level "{{ voltageLevelName }}" for ioa "{{ ioa }}"' + } + } }, 'compare-ied': { selectProjectTitle: 'Select template project to Compare IED with', diff --git a/packages/plugins/src/menu/Export104.ts b/packages/plugins/src/menu/Export104.ts new file mode 100644 index 0000000000..9203cf503a --- /dev/null +++ b/packages/plugins/src/menu/Export104.ts @@ -0,0 +1,90 @@ +import { LitElement, property } from 'lit-element'; +import { stringify } from 'csv-stringify/browser/esm/sync'; +import { newLogEvent } from '@openscd/core/foundation/deprecated/history.js'; + +import { extractAllSignal104Data, Signal104 } from './export104/foundation.js'; +import { get } from 'lit-translate'; + + + +export default class Export104 extends LitElement { + @property({ attribute: false }) doc!: XMLDocument; + @property() docName!: string; + + private readonly csvHeaders = [ + 'Id', + 'Name', + 'Signal Number', + 'mIOA', + 'cIOA' + ]; + + async run(): Promise { + const { signals, errors } = extractAllSignal104Data(this.doc); + + errors.forEach((error) => this.logWarning(error)); + + if (signals.length === 0) { + this.dispatchEvent(newLogEvent({ + kind: 'info', + title: get('protocol104.export.noSignalsFound'), + })); + return; + } + + const csvLines = this.generateCsvLines(signals); + + const csvContent = stringify(csvLines, { + header: true, + columns: this.csvHeaders, + }); + const csvBlob = new Blob([csvContent], { + type: 'text/csv', + }); + + this.downloadCsv(csvBlob); + } + + private logWarning(errorMessage: string): void { + this.dispatchEvent(newLogEvent({ + kind: 'warning', + title: get('protocol104.export.invalidSignalWarning'), + message: errorMessage, + })); + } + + private generateCsvLines(allSignal104Data: Signal104[]): string[][] { + const lines: string[][] = []; + + for(const signal104Data of allSignal104Data) { + const line = [ + '', + signal104Data.name ?? '', + signal104Data.signalNumber ?? '', + ]; + + if (signal104Data.isMonitorSignal) { + line.push(signal104Data.ioa ?? '', ''); + } else { + line.push('', signal104Data.ioa ?? ''); + } + + lines.push(line); + } + + return lines; + } + + private downloadCsv(csvBlob: Blob): void { + const a = document.createElement('a'); + a.download = this.docName + '-104-signals.csv'; + a.href = URL.createObjectURL(csvBlob); + a.dataset.downloadurl = ['text/csv', a.download, a.href].join(':'); + a.style.display = 'none'; + + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(a.href); + } +} \ No newline at end of file diff --git a/packages/plugins/src/menu/export104/foundation.ts b/packages/plugins/src/menu/export104/foundation.ts new file mode 100644 index 0000000000..7198161ba2 --- /dev/null +++ b/packages/plugins/src/menu/export104/foundation.ts @@ -0,0 +1,128 @@ +import { get } from "lit-translate"; + +export const PROTOCOL_104_PRIVATE = 'IEC_60870_5_104'; + +export interface Signal104 { + name: string | null; + signalNumber: string | null; + isMonitorSignal: boolean; + ioa: string | null; + ti: string | null; +} + +interface ExtractSignal104Result { + signal: Signal104 | null; + error?: string; +} + +enum SignalType { + Monitor, + Control, + Unknown +} + +const private104Selector = `Private[type="${PROTOCOL_104_PRIVATE}"]`; + +export function extractAllSignal104Data(doc: XMLDocument): { signals: Signal104[], errors: string[] } { + const signals: Signal104[] = []; + const errors: string[] = []; + const address104Elements = doc.querySelectorAll(`${private104Selector} > Address`); + + address104Elements.forEach((addressElement) => { + const signal104Result = extractSignal104Data(addressElement, doc); + + if (signal104Result.error) { + errors.push(signal104Result.error); + } else { + signals.push(signal104Result.signal!); + } + }); + + return { signals, errors }; +} + +function extractSignal104Data(addressElement: Element, doc: XMLDocument): ExtractSignal104Result { + const ti = addressElement.getAttribute('ti'); + const ioa = addressElement.getAttribute('ioa'); + + // By convention the last four digits of the ioa are the signalnumber, see https://github.com/com-pas/compas-open-scd/issues/334 + if (ti === null || ioa === null || ioa.length < 4) { + return { signal: null, error: get('protocol104.export.errors.tiOrIoaInvalid', { ti: ti ?? '', ioa: ioa ?? '' }) }; + } + const { signalNumber, bayName } = splitIoa(ioa); + + const signalType = getSignalType(ti); + if (signalType === SignalType.Unknown) { + return { signal: null, error: get('protocol104.export.errors.unknownSignalType', { ti: ti ?? '', ioa: ioa ?? '' }) }; + } + const isMonitorSignal = signalType === SignalType.Monitor; + + addressElement.parentElement; + const parentDOI = addressElement.closest('DOI'); + + if (!parentDOI) { + return { signal: null, error: get('protocol104.export.errors.noDoi', { ioa: ioa ?? '' }) }; + } + + const doiDesc = parentDOI.getAttribute('desc'); + + const parentBayQuery = `:root > Substation > VoltageLevel > Bay[name="${bayName}"]`; + const parentBay = doc.querySelector(parentBayQuery); + + if (!parentBay) { + return { signal: null, error: get('protocol104.export.errors.noBay', { bayName, ioa: ioa ?? '' }) }; + } + + const parentVoltageLevel = parentBay.closest('VoltageLevel'); + + if (!parentVoltageLevel) { + return { signal: null, error: get('protocol104.export.errors.noVoltageLevel', { bayName, ioa: ioa ?? '' }) }; + } + + const voltageLevelName = parentVoltageLevel.getAttribute('name'); + const parentSubstation = parentVoltageLevel.closest('Substation'); + + if (!parentSubstation) { + return { signal: null, error: get('protocol104.export.errors.noSubstation', { voltageLevelName: voltageLevelName ?? '', ioa: ioa ?? '' }) }; + } + + const substationName = parentSubstation.getAttribute('name'); + + const name = `${substationName}${voltageLevelName}${bayName}${doiDesc}`; + + return { + signal: { + name, + signalNumber, + isMonitorSignal, + ti, + ioa + } + } +} + +// For signal classification details see https://github.com/com-pas/compas-open-scd/issues/334 +function getSignalType(tiString: string): SignalType { + const ti = parseInt(tiString); + + if (isNaN(ti)) { + return SignalType.Unknown; + } + + if ((ti >= 1 && ti <= 21) || (ti >= 30 && ti <= 40)) { + return SignalType.Monitor; + } else if ((ti >= 45 && ti <= 51) || (ti >= 58 && ti <= 64)) { + return SignalType.Control; + } else { + return SignalType.Unknown; + } +} + +// By Alliander convention the last four digits of the ioa are the signalnumber and the rest is the bay number +// And every bay name consists of "V" + bay number +function splitIoa(ioa: string): { signalNumber: string, bayName: string } { + const signalNumber = ioa.slice(-4); + const bayName = `V${ioa.slice(0, -4)}`; + + return { signalNumber, bayName }; +} diff --git a/packages/plugins/test/testfiles/export104/export104.scd b/packages/plugins/test/testfiles/export104/export104.scd new file mode 100644 index 0000000000..1941595a3f --- /dev/null +++ b/packages/plugins/test/testfiles/export104/export104.scd @@ -0,0 +1,897 @@ + + +
+ + + 110 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

192.128.0.1

+

255.255.255.0

+

8

+

12

+

30

+

15

+

10

+

20

+

192.128.0.2

+

255.255.255.0

+

8

+

12

+

30

+

15

+

10

+

20

+

controlled-station

+
+
+ +
+

192.128.0.10

+

255.255.255.0

+

8

+

12

+

30

+

15

+

10

+

20

+

controlled-station

+
+
+ +
+

192.128.0.11

+

255.255.255.0

+

8

+

12

+

30

+

15

+

10

+

20

+

controlled-station

+
+
+
+ + +
+

192.128.0.1

+

255.255.255.0

+

8

+

12

+

30

+

15

+

10

+

20

+

192.128.0.2

+

255.255.255.0

+

8

+

12

+

30

+

15

+

10

+

20

+

controlled-station

+
+
+ +
+

192.128.0.10

+

255.255.255.0

+

8

+

12

+

30

+

15

+

10

+

20

+

controlled-station

+
+
+ +
+

192.128.0.11

+

255.255.255.0

+

8

+

12

+

30

+

15

+

10

+

20

+

controlled-station

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + direct-with-normal-security + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IEC 61850-7-4:2007B2 + + + + + MyExt:2022A1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + status-only + direct-with-normal-security + sbo-with-normal-security + direct-with-enhanced-security + sbo-with-enhanced-security + + + unknown + forward + backward + both + + + unknown + forward + backward + + + y + z + a + f + p + n + µ + m + c + d + + da + h + k + M + G + T + P + E + Z + Y + + + not-supported + bay-control + station-control + remote-control + automatic-bay + automatic-station + automatic-remote + maintenance + process + + + pos-neg-zero + dir-quad-zero + + + MS + PER_CYCLE + CYCLE + DAY + WEEK + MONTH + YEAR + EXTERNAL + + + unknown + critical + major + minor + warning + + + + m + kg + s + A + K + mol + cd + deg + rad + sr + Gy + Bq + °C + Sv + F + C + S + H + V + ohm + J + N + Hz + lx + Lm + Wb + T + W + Pa + + + m/s + m/s² + m³/s + m/m³ + M + kg/m³ + m²/s + W/m K + J/K + ppm + 1/s + rad/s + W/m² + J/m² + S/m + K/s + Pa/s + J/kg K + VA + Watts + VAr + phi + cos(phi) + Vs + + As + + A²t + VAh + Wh + VArh + V/Hz + Hz/s + char + char/s + kgm² + dB + J/Wh + W/s + l/s + dBm + h + min + Ohm/m + percent/s + + + on + blocked + test + test/blocked + off + + + Ok + Warning + Alarm + + + diff --git a/packages/plugins/test/unit/menu/104Export/foundation.test.ts b/packages/plugins/test/unit/menu/104Export/foundation.test.ts new file mode 100644 index 0000000000..ee1411f294 --- /dev/null +++ b/packages/plugins/test/unit/menu/104Export/foundation.test.ts @@ -0,0 +1,63 @@ +import { expect } from '@open-wc/testing'; + +import { extractAllSignal104Data } from '../../../../src/menu/Export104/foundation.js'; + +describe('Export104 foundation', () => { + let doc: XMLDocument; + + beforeEach(async () => { + doc = await fetch('/test/testfiles/export104/export104.scd') + .then(response => response.text()) + .then(str => new DOMParser().parseFromString(str, 'application/xml')); + }); + + it('should extract all signal 104 data', () => { + const { signals, errors } = extractAllSignal104Data(doc); + + const expectedSignals = [ + { + name: 'S1F2V105Control1', + signalNumber: '3001', + isMonitorSignal: false, + ioa: '1053001', + ti: '50' + }, + { + name: 'S1F2V105Control2', + signalNumber: '3002', + isMonitorSignal: false, + ioa: '1053002', + ti: '64' + }, + { + name: 'S1F1V104Behavior', + signalNumber: '2001', + isMonitorSignal: true, + ioa: '1042001', + ti: '35' + }, + { + name: 'S1F1V104Behavior', + signalNumber: '2002', + isMonitorSignal: true, + ioa: '1042002', + ti: '35' + }, + { + name: 'S1F1V103Behavior', + signalNumber: '1003', + isMonitorSignal: true, + ioa: '1031003', + ti: '35' + }, + ]; + + const expectedErrors = [ + '[protocol104.export.errors.unknownSignalType]', + '[protocol104.export.errors.tiOrIoaInvalid]', + ]; + + expect(errors).to.deep.equal(expectedErrors); + expect(signals).to.deep.equal(expectedSignals); + }); +});