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
9 changes: 9 additions & 0 deletions packages/compas-open-scd/public/js/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
];
12 changes: 12 additions & 0 deletions packages/compas-open-scd/src/translations/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 12 additions & 0 deletions packages/compas-open-scd/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
90 changes: 90 additions & 0 deletions packages/plugins/src/menu/Export104.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
Comment on lines +79 to +88
Copy link

@trusz trusz Oct 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nice hack. I assume there is no other way to do this

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pretty much copied the code from here https://github.com/com-pas/compas-open-scd/blob/main/packages/compas-open-scd/src/menu/ExportIEDParams.ts#L296

After googling for a while the hidden <a> element seems to be the only way, we might want to look for a way to make this function reuseable, because at the moment it has been copied at least three times between different plugins.

}
}
128 changes: 128 additions & 0 deletions packages/plugins/src/menu/export104/foundation.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading