py61850 0.2.0.dev1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {py61850-0.2.0.dev1 → py61850-0.3.0}/CONTRIBUTING.md +20 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/PKG-INFO +43 -1
- {py61850-0.2.0.dev1 → py61850-0.3.0}/README.md +42 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/ROADMAP.md +41 -3
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/__init__.py +27 -1
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/core/__init__.py +10 -2
- py61850-0.3.0/src/py61850/core/fc.py +121 -0
- py61850-0.3.0/src/py61850/core/refs.py +94 -0
- py61850-0.3.0/src/py61850/scl/__init__.py +98 -0
- py61850-0.3.0/src/py61850/scl/_xmlsafe.py +113 -0
- py61850-0.3.0/src/py61850/scl/communication.py +188 -0
- py61850-0.3.0/src/py61850/scl/controls.py +233 -0
- py61850-0.3.0/src/py61850/scl/document.py +326 -0
- py61850-0.3.0/src/py61850/scl/model.py +508 -0
- py61850-0.3.0/src/py61850/scl/templates.py +229 -0
- py61850-0.3.0/tests/unit/scl_fixtures.py +221 -0
- py61850-0.3.0/tests/unit/test_fc.py +109 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_public_api.py +3 -0
- py61850-0.3.0/tests/unit/test_refs.py +92 -0
- py61850-0.3.0/tests/unit/test_scl_api.py +67 -0
- py61850-0.3.0/tests/unit/test_scl_communication.py +144 -0
- py61850-0.3.0/tests/unit/test_scl_controls.py +153 -0
- py61850-0.3.0/tests/unit/test_scl_document.py +164 -0
- py61850-0.3.0/tests/unit/test_scl_model.py +416 -0
- py61850-0.3.0/tests/unit/test_scl_templates.py +154 -0
- py61850-0.3.0/tests/unit/test_xmlsafe.py +99 -0
- py61850-0.2.0.dev1/src/py61850/scl/__init__.py +0 -23
- {py61850-0.2.0.dev1 → py61850-0.3.0}/.gitignore +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/CLA.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/COMMERCIAL.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/LICENSE +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/01_connect_and_scan.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/02_read_values.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/03_file_transfer.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/04_error_handling.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/05_fleet_inventory.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/06_find_files.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/07_read_object_references.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/README.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/pyproject.toml +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/__main__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/cli/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/cli/__main__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/cli/files.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/cli/main.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/cli/scan.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/core/ber.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/core/data.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/core/quality.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/core/time.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/errors.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/goose/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/link/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/client.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/pdu.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/service_error.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/services/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/services/directory.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/services/files.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/services/read.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/types.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/acse.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/cotp.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/oids.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/presentation.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/session.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/stack.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/tpkt.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/py.typed +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/sv/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/BENCH.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/README.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/fixtures/README.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_ber.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_client.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_data.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_file_filter.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_logical_nodes.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_osi.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_pdu.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_types.py +0 -0
|
@@ -23,6 +23,26 @@ Contributions that need **no** CLA: issues, bug reports, feature requests,
|
|
|
23
23
|
questions, and recorded PDUs for `tests/fixtures/` (data captured from a device,
|
|
24
24
|
not authored code).
|
|
25
25
|
|
|
26
|
+
## What a patch is judged against
|
|
27
|
+
|
|
28
|
+
py61850 is a general IEC 61850 toolkit. A change is assessed against what any
|
|
29
|
+
61850 tool would need, not only against the use that prompted it:
|
|
30
|
+
|
|
31
|
+
- **Model the standard, not one file.** "The files I have do not use it" is a
|
|
32
|
+
reason to document the gap, not to design it out.
|
|
33
|
+
- **No vendor specifics.** Vendors extend SCL through `Private` elements and
|
|
34
|
+
through standard attributes carrying vendor value grammars. Expose both
|
|
35
|
+
faithfully; interpret neither. Vendor handling belongs in a library that
|
|
36
|
+
attaches to this one.
|
|
37
|
+
- **Derive from the file, do not hardcode a list.** A hardcoded set of names
|
|
38
|
+
standing in for a type the document already declares is wrong on some
|
|
39
|
+
document. If `DataTypeTemplates` can answer it, resolve it.
|
|
40
|
+
- **Say what you measured.** The commit body carries the numbers that
|
|
41
|
+
justified the change. "It is faster" is not a claim this project accepts;
|
|
42
|
+
"1406 ms to 670 ms on a 22 MB SCD" is.
|
|
43
|
+
- **Zero runtime dependencies, Python 3.9, Linux + Windows + macOS.** All
|
|
44
|
+
three are checked in CI and none is negotiable.
|
|
45
|
+
|
|
26
46
|
## What makes a good patch here
|
|
27
47
|
|
|
28
48
|
The repository conventions worth knowing before you start; the ones that come
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: py61850
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Pure-Python IEC 61850 toolkit — MMS (ISO 9506) client today; GOOSE and SV planned. Standard library only.
|
|
5
5
|
Project-URL: Homepage, https://github.com/GuilhermeMarini/py61850
|
|
6
6
|
Project-URL: Repository, https://github.com/GuilhermeMarini/py61850
|
|
@@ -40,6 +40,27 @@ TCP → TPKT (RFC1006) → COTP (ISO8073) → ISO Session → ISO Presentation
|
|
|
40
40
|
See [ROADMAP.md](ROADMAP.md) for what's next (an MMS sniffer, MMS simulation
|
|
41
41
|
from SCL, and a GOOSE sniff/publish interface).
|
|
42
42
|
|
|
43
|
+
## What this library is for
|
|
44
|
+
|
|
45
|
+
py61850 is a general IEC 61850 toolkit, not a support library for one
|
|
46
|
+
application. Every part of it is judged against what an open equivalent of
|
|
47
|
+
IEDScout or an IEC browser would need — and against what the *next* project to
|
|
48
|
+
use it will need, not only the one that prompted the code.
|
|
49
|
+
|
|
50
|
+
Concretely, that means three things for any change here:
|
|
51
|
+
|
|
52
|
+
- **Model the standard, not the file in front of you.** If IEC 61850 defines
|
|
53
|
+
it and a vendor-neutral tool would show it, the API should express it — even
|
|
54
|
+
when today's caller does not ask.
|
|
55
|
+
- **No vendor specifics in this library.** Vendors extend SCL through
|
|
56
|
+
`Private` elements and through standard attributes carrying their own value
|
|
57
|
+
grammars (`sAddr` is the usual one). py61850 exposes both faithfully and
|
|
58
|
+
interprets neither. A vendor library attaches to the model; it does not
|
|
59
|
+
live inside it.
|
|
60
|
+
- **Derive, do not hardcode.** A list of names that stands in for a type the
|
|
61
|
+
file already declares will be wrong on some file. Resolve
|
|
62
|
+
`DataTypeTemplates` and answer from what the document says.
|
|
63
|
+
|
|
43
64
|
## Install
|
|
44
65
|
|
|
45
66
|
```bash
|
|
@@ -114,6 +135,27 @@ with FileTransfer("192.0.2.22") as ft:
|
|
|
114
135
|
progress=lambda got, total: print(f"{got}/{total}"))
|
|
115
136
|
```
|
|
116
137
|
|
|
138
|
+
### Reading an SCL file
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from py61850.scl import SclDocument
|
|
142
|
+
|
|
143
|
+
doc = SclDocument.parse("station.scd")
|
|
144
|
+
|
|
145
|
+
for name, header in doc.ied_headers.items(): # cheap: no instance tree
|
|
146
|
+
print(name, header.manufacturer, header.config_version)
|
|
147
|
+
|
|
148
|
+
print(doc.communication.ip_by_ied()) # {iedName: IP}
|
|
149
|
+
|
|
150
|
+
ied = doc.ied("QPC1_TR1_UPC1") # built on demand, cached
|
|
151
|
+
for ln in ied.logical_nodes():
|
|
152
|
+
for attr in ln.walk():
|
|
153
|
+
print(attr.reference(), attr.mms_item(), attr.fc, attr.btype)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`py61850.scl` is imported explicitly and is never pulled in by `import
|
|
157
|
+
py61850`, so the MMS client keeps installing and running unprivileged.
|
|
158
|
+
|
|
117
159
|
Errors share one base, so a fleet job catches the whole family at once:
|
|
118
160
|
|
|
119
161
|
```python
|
|
@@ -13,6 +13,27 @@ TCP → TPKT (RFC1006) → COTP (ISO8073) → ISO Session → ISO Presentation
|
|
|
13
13
|
See [ROADMAP.md](ROADMAP.md) for what's next (an MMS sniffer, MMS simulation
|
|
14
14
|
from SCL, and a GOOSE sniff/publish interface).
|
|
15
15
|
|
|
16
|
+
## What this library is for
|
|
17
|
+
|
|
18
|
+
py61850 is a general IEC 61850 toolkit, not a support library for one
|
|
19
|
+
application. Every part of it is judged against what an open equivalent of
|
|
20
|
+
IEDScout or an IEC browser would need — and against what the *next* project to
|
|
21
|
+
use it will need, not only the one that prompted the code.
|
|
22
|
+
|
|
23
|
+
Concretely, that means three things for any change here:
|
|
24
|
+
|
|
25
|
+
- **Model the standard, not the file in front of you.** If IEC 61850 defines
|
|
26
|
+
it and a vendor-neutral tool would show it, the API should express it — even
|
|
27
|
+
when today's caller does not ask.
|
|
28
|
+
- **No vendor specifics in this library.** Vendors extend SCL through
|
|
29
|
+
`Private` elements and through standard attributes carrying their own value
|
|
30
|
+
grammars (`sAddr` is the usual one). py61850 exposes both faithfully and
|
|
31
|
+
interprets neither. A vendor library attaches to the model; it does not
|
|
32
|
+
live inside it.
|
|
33
|
+
- **Derive, do not hardcode.** A list of names that stands in for a type the
|
|
34
|
+
file already declares will be wrong on some file. Resolve
|
|
35
|
+
`DataTypeTemplates` and answer from what the document says.
|
|
36
|
+
|
|
16
37
|
## Install
|
|
17
38
|
|
|
18
39
|
```bash
|
|
@@ -87,6 +108,27 @@ with FileTransfer("192.0.2.22") as ft:
|
|
|
87
108
|
progress=lambda got, total: print(f"{got}/{total}"))
|
|
88
109
|
```
|
|
89
110
|
|
|
111
|
+
### Reading an SCL file
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from py61850.scl import SclDocument
|
|
115
|
+
|
|
116
|
+
doc = SclDocument.parse("station.scd")
|
|
117
|
+
|
|
118
|
+
for name, header in doc.ied_headers.items(): # cheap: no instance tree
|
|
119
|
+
print(name, header.manufacturer, header.config_version)
|
|
120
|
+
|
|
121
|
+
print(doc.communication.ip_by_ied()) # {iedName: IP}
|
|
122
|
+
|
|
123
|
+
ied = doc.ied("QPC1_TR1_UPC1") # built on demand, cached
|
|
124
|
+
for ln in ied.logical_nodes():
|
|
125
|
+
for attr in ln.walk():
|
|
126
|
+
print(attr.reference(), attr.mms_item(), attr.fc, attr.btype)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`py61850.scl` is imported explicitly and is never pulled in by `import
|
|
130
|
+
py61850`, so the MMS client keeps installing and running unprivileged.
|
|
131
|
+
|
|
90
132
|
Errors share one base, so a fleet job catches the whole family at once:
|
|
91
133
|
|
|
92
134
|
```python
|
|
@@ -105,15 +105,53 @@ Make the client dependable enough for unattended fleet jobs to build on.
|
|
|
105
105
|
|
|
106
106
|
---
|
|
107
107
|
|
|
108
|
+
## 0.3 — The SCL model ✅
|
|
109
|
+
|
|
110
|
+
- ✅ `core/fc.py` — the 13 functional constraints of 61850-7-2 and a
|
|
111
|
+
documented read-preference ranking. In `core` rather than `scl` because
|
|
112
|
+
a client matching items against `GetLogicalDeviceDirectory` needs the
|
|
113
|
+
same vocabulary as a reader walking a file.
|
|
114
|
+
- ✅ `core/fc.py` also carries the control model's own data attributes
|
|
115
|
+
(`Oper`, `SBOw`, `SBO`, `Cancel`) and `is_control_attribute`, which asks
|
|
116
|
+
"command or reading?" of an attribute NAME. Both directions are needed:
|
|
117
|
+
an attribute path is often in hand when its FC is not.
|
|
118
|
+
- ✅ `core/refs.py` — object reference ↔ MMS domain and item name (61850-8-1).
|
|
119
|
+
- ✅ `scl/` — see 1.0 below.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
108
123
|
## 1.0 — MMS simulation 🧭
|
|
109
124
|
|
|
110
125
|
**Goal:** read an SCL file (`.scd` / `.cid` / `.icd`) and stand up a virtual IED
|
|
111
126
|
on the network that a real client/HMI can associate to and browse — the mirror
|
|
112
127
|
image of today's client.
|
|
113
128
|
|
|
114
|
-
-
|
|
115
|
-
|
|
116
|
-
|
|
129
|
+
- ✅ **SCL parser** — `py61850.scl`: a general IEC 61850-6 object model.
|
|
130
|
+
`SclDocument` owns the file; `DataTypeTemplates` is resolved once per
|
|
131
|
+
document into a shared type pool; the instance tree
|
|
132
|
+
(IED → AccessPoint → Server → LDevice → LN → DO → DA, plus DataSets,
|
|
133
|
+
control blocks and ExtRefs) is built per IED on demand. Every node
|
|
134
|
+
exposes its `Private` elements, which is how a vendor library attaches
|
|
135
|
+
its own half without parsing the file again.
|
|
136
|
+
|
|
137
|
+
It landed ahead of the rest of 1.0 because it is the shared spine: the
|
|
138
|
+
MMS server answers GetNameList and GetVariableAccessAttributes out of
|
|
139
|
+
this model, and the GOOSE publisher builds frames from the GoCB and
|
|
140
|
+
dataset in the same model.
|
|
141
|
+
|
|
142
|
+
Not modelled, because no file in the reference corpus carries them: the
|
|
143
|
+
`Substation` section and `Log`.
|
|
144
|
+
|
|
145
|
+
**The vendor seam is proven by two unrelated vendors**, which is what
|
|
146
|
+
0.3.0 waited for rather than shipping on its author's word. Two
|
|
147
|
+
libraries outside this project were rebuilt on the model, sharing no
|
|
148
|
+
code with each other and neither written against it: one reads SEL's
|
|
149
|
+
`sAddr` value grammar (178,406 configured attributes in one station),
|
|
150
|
+
the other Siemens' `Private` elements (5,886 of a single type). Both
|
|
151
|
+
reproduce every field their own XML readers produced, on the same
|
|
152
|
+
files — one of which is a Siemens export containing SEL relays, read by
|
|
153
|
+
both without either opening it twice. Nothing was added to this package
|
|
154
|
+
for the second one.
|
|
117
155
|
- 🧭 **Model → MMS server** — a listening `MmsServer` on TCP 102 that answers the
|
|
118
156
|
confirmed services the client already speaks, driven by the SCL model:
|
|
119
157
|
- Initiate / association (server side of `associate.py`)
|
|
@@ -21,6 +21,18 @@ Public API
|
|
|
21
21
|
LogicalNode one LN returned by MmsClient.find_logical_nodes()
|
|
22
22
|
folder_of the folder part of an MMS file name, for grouping hits
|
|
23
23
|
|
|
24
|
+
FUNCTIONAL_CONSTRAINTS / fc_is_control / fc_read_rank
|
|
25
|
+
the 61850-7-2 functional constraints, shared by the SCL
|
|
26
|
+
reader and the live MMS path
|
|
27
|
+
|
|
28
|
+
CONTROL_DATA_ATTRIBUTES / fc_is_control_attribute
|
|
29
|
+
the control model's own data attributes -- the same
|
|
30
|
+
"command, not a reading" question asked of an attribute
|
|
31
|
+
name rather than of an FC
|
|
32
|
+
|
|
33
|
+
mms_item / object_reference / split_item / da_parts
|
|
34
|
+
61850-8-1 naming: object reference <-> MMS domain and item
|
|
35
|
+
|
|
24
36
|
Iec61850Error base of every error the library raises
|
|
25
37
|
TransportError TPKT/COTP/socket and OSI framing failures
|
|
26
38
|
MmsError MMS service errors, rejects, refused associations
|
|
@@ -68,8 +80,13 @@ from .mms.services.directory import LogicalNode
|
|
|
68
80
|
from .mms.services.files import folder_of
|
|
69
81
|
from .mms.service_error import decode_service_error
|
|
70
82
|
from .mms.types import decode_data_definition
|
|
83
|
+
from .core.fc import CONTROL_DATA_ATTRIBUTES, FUNCTIONAL_CONSTRAINTS, \
|
|
84
|
+
is_control as fc_is_control, \
|
|
85
|
+
is_control_attribute as fc_is_control_attribute, \
|
|
86
|
+
read_rank as fc_read_rank
|
|
87
|
+
from .core.refs import da_parts, mms_item, object_reference, split_item
|
|
71
88
|
|
|
72
|
-
__version__ = "0.
|
|
89
|
+
__version__ = "0.3.0"
|
|
73
90
|
|
|
74
91
|
__all__ = [
|
|
75
92
|
"MmsClient",
|
|
@@ -77,6 +94,15 @@ __all__ = [
|
|
|
77
94
|
"DirEntry",
|
|
78
95
|
"LogicalNode",
|
|
79
96
|
"folder_of",
|
|
97
|
+
"FUNCTIONAL_CONSTRAINTS",
|
|
98
|
+
"fc_is_control",
|
|
99
|
+
"fc_read_rank",
|
|
100
|
+
"CONTROL_DATA_ATTRIBUTES",
|
|
101
|
+
"fc_is_control_attribute",
|
|
102
|
+
"mms_item",
|
|
103
|
+
"object_reference",
|
|
104
|
+
"split_item",
|
|
105
|
+
"da_parts",
|
|
80
106
|
"Iec61850Error",
|
|
81
107
|
"TransportError",
|
|
82
108
|
"MmsError",
|
|
@@ -14,9 +14,17 @@ subscriber and a publisher (GOOSE/SV), and be unit-tested offline.
|
|
|
14
14
|
ber definite-length BER TLV encode/decode
|
|
15
15
|
data MMS ``Data`` values -- the CHOICE reused verbatim by GOOSE
|
|
16
16
|
``allData`` and inside the SV ``savPdu`` envelope
|
|
17
|
+
fc the IEC 61850-7-2 functional constraints, the control
|
|
18
|
+
model's own data attributes, and the read ranking over both
|
|
19
|
+
refs IEC 61850-8-1 object reference <-> MMS domain/item names
|
|
17
20
|
quality the IEC 61850 13-bit Quality bitstring
|
|
18
21
|
time MMS ``UtcTime`` / ``BinaryTime``
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
Most of this is internal and may change between releases. The exception is
|
|
24
|
+
what the top level re-exports -- ``FUNCTIONAL_CONSTRAINTS``,
|
|
25
|
+
``CONTROL_DATA_ATTRIBUTES``, ``fc_is_control``, ``fc_is_control_attribute``,
|
|
26
|
+
``fc_read_rank``, ``mms_item``, ``object_reference``, ``split_item`` and
|
|
27
|
+
``da_parts``. Those are public API and are reached as ``py61850.<name>``; the
|
|
28
|
+
rest is reached as ``py61850.core.<module>.<name>`` and carries no such
|
|
29
|
+
promise.
|
|
22
30
|
"""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Guilherme Marini
|
|
3
|
+
#
|
|
4
|
+
# This file is part of py61850. It is free software under the GNU Affero
|
|
5
|
+
# General Public License v3 or later; see LICENSE. A commercial licence,
|
|
6
|
+
# for use in software you do not wish to release under the AGPL, is
|
|
7
|
+
# available from the copyright holder -- see COMMERCIAL.md.
|
|
8
|
+
"""The IEC 61850-7-2 functional constraints.
|
|
9
|
+
|
|
10
|
+
An FC says what a data attribute *is for* -- a status, a measurement, a
|
|
11
|
+
setting, a command -- and it appears in two places that must agree: the
|
|
12
|
+
``fc`` attribute of a ``DA`` in an SCL file, and the middle segment of an
|
|
13
|
+
MMS item name (``LN$ST$Pos$stVal``). That is why this lives in ``core``
|
|
14
|
+
rather than under ``scl``: a client matching an item against
|
|
15
|
+
``GetLogicalDeviceDirectory`` needs the same vocabulary as a reader walking
|
|
16
|
+
a file, and must not have to import the SCL package to get it.
|
|
17
|
+
|
|
18
|
+
The same module answers the same question from the other direction. An FC
|
|
19
|
+
says a whole container is a command; the control model of 61850-7-2 says
|
|
20
|
+
which *attributes* of a controllable data object carry one. Both are needed,
|
|
21
|
+
because the two are not always available together -- see
|
|
22
|
+
:func:`is_control_attribute`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from .refs import da_parts
|
|
28
|
+
|
|
29
|
+
#: Every functional constraint IEC 61850-7-2 defines.
|
|
30
|
+
#:
|
|
31
|
+
#: The first twelve were measured across the three reference SCDs (7.1, 13.5
|
|
32
|
+
#: and 23.5 MB, SEL and Siemens): ``ST SR CF EX SE DC MX OR CO SV SP BL``.
|
|
33
|
+
#: ``SG`` (setting group) completes the standard's set and is kept even
|
|
34
|
+
#: though that corpus does not use it -- a vocabulary with a hole in it fails
|
|
35
|
+
#: silently on the first file that fills it.
|
|
36
|
+
FUNCTIONAL_CONSTRAINTS = (
|
|
37
|
+
"ST", # status
|
|
38
|
+
"MX", # measurand
|
|
39
|
+
"SP", # setpoint
|
|
40
|
+
"SV", # substitution
|
|
41
|
+
"CF", # configuration
|
|
42
|
+
"DC", # description
|
|
43
|
+
"SG", # setting group
|
|
44
|
+
"SE", # setting group editable
|
|
45
|
+
"SR", # service response / service tracking
|
|
46
|
+
"OR", # operate received
|
|
47
|
+
"BL", # blocking
|
|
48
|
+
"EX", # extended definition
|
|
49
|
+
"CO", # control
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
#: The FCs that carry a command rather than a reading.
|
|
53
|
+
CONTROL_FCS = frozenset({"CO"})
|
|
54
|
+
|
|
55
|
+
#: The data attributes of the 61850-7-2 control model: the ones through which
|
|
56
|
+
#: a controllable data object (SPC, DPC, INC, ENC, BSC, ISC, APC, BAC) is
|
|
57
|
+
#: COMMANDED rather than read. They are the attribute-name counterpart of
|
|
58
|
+
#: ``CONTROL_FCS``, and they are not redundant with it: an attribute path is
|
|
59
|
+
#: often in hand when its FC is not. A live client resolving a name against
|
|
60
|
+
#: ``GetLogicalDeviceDirectory``, or any consumer holding an item name whose
|
|
61
|
+
#: middle segment it has not yet parsed, has only the spelling to go on.
|
|
62
|
+
CONTROL_DATA_ATTRIBUTES = frozenset({"Oper", "SBOw", "SBO", "Cancel"})
|
|
63
|
+
|
|
64
|
+
# Ordered best-to-worst for "if this attribute is reachable under several FCs,
|
|
65
|
+
# which one should be read?". Status first, then measurand, then the settings
|
|
66
|
+
# and descriptive constraints. Controls are absent on purpose -- they are
|
|
67
|
+
# ranked by `read_rank` below, strictly last, and never by position here.
|
|
68
|
+
_READ_PREFERENCE = ("ST", "MX", "SP", "CF", "DC", "SR", "OR", "EX", "SV",
|
|
69
|
+
"SG", "SE", "BL")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _norm(value) -> str:
|
|
73
|
+
return value.upper() if isinstance(value, str) else ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_valid(fc) -> bool:
|
|
77
|
+
"""Is this one of the 13 functional constraints 7-2 defines?"""
|
|
78
|
+
return _norm(fc) in FUNCTIONAL_CONSTRAINTS
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_control(fc) -> bool:
|
|
82
|
+
"""Does this FC carry a command rather than a reading?"""
|
|
83
|
+
return _norm(fc) in CONTROL_FCS
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_rank(fc) -> tuple:
|
|
87
|
+
"""Sort key for one candidate FC; lower is a better thing to poll.
|
|
88
|
+
|
|
89
|
+
``(0, position)`` for anything that is not a control, ``(1, 0)`` for one
|
|
90
|
+
that is. The two tiers are deliberate: they keep a control strictly worse
|
|
91
|
+
than *every* reading, including an FC this table has never heard of. A
|
|
92
|
+
command SETS a point, so polling it returns what the device was last told
|
|
93
|
+
rather than what it sees, and that is worse than an unfamiliar reading.
|
|
94
|
+
"""
|
|
95
|
+
name = _norm(fc)
|
|
96
|
+
if name in CONTROL_FCS:
|
|
97
|
+
return (1, 0)
|
|
98
|
+
if name in _READ_PREFERENCE:
|
|
99
|
+
return (0, _READ_PREFERENCE.index(name))
|
|
100
|
+
return (0, len(_READ_PREFERENCE))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_control_attribute(path) -> bool:
|
|
104
|
+
"""Does this attribute path address a command rather than a reading?
|
|
105
|
+
|
|
106
|
+
Takes either spelling of the descent -- ``"Oper.ctlVal"`` as SCL writes
|
|
107
|
+
it, ``"Oper$ctlVal"`` as MMS does, or the parts already split.
|
|
108
|
+
|
|
109
|
+
Two rules, and the second is not covered by the first. The path is a
|
|
110
|
+
command when it descends through one of :data:`CONTROL_DATA_ATTRIBUTES`,
|
|
111
|
+
which is the control model's own vocabulary; and also when its leaf is a
|
|
112
|
+
``ctlVal``, because ``ctlVal`` appears only inside a control and a
|
|
113
|
+
consumer may hold the leaf without the root that carried it. The leaf
|
|
114
|
+
test is a prefix match: 7-3 spells the analogue control value
|
|
115
|
+
``ctlVal`` on its own but attaches the setpoint variants beside it, and
|
|
116
|
+
a name that begins ``ctlVal`` is a control value in every one of them.
|
|
117
|
+
"""
|
|
118
|
+
parts = da_parts(path)
|
|
119
|
+
if not parts:
|
|
120
|
+
return False
|
|
121
|
+
return parts[0] in CONTROL_DATA_ATTRIBUTES or parts[-1].startswith("ctlVal")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Guilherme Marini
|
|
3
|
+
#
|
|
4
|
+
# This file is part of py61850. It is free software under the GNU Affero
|
|
5
|
+
# General Public License v3 or later; see LICENSE. A commercial licence,
|
|
6
|
+
# for use in software you do not wish to release under the AGPL, is
|
|
7
|
+
# available from the copyright holder -- see COMMERCIAL.md.
|
|
8
|
+
"""IEC 61850-8-1 naming: object reference <-> MMS domain and item name.
|
|
9
|
+
|
|
10
|
+
The same data attribute has two spellings, and a tool that reads files and
|
|
11
|
+
talks to devices needs both::
|
|
12
|
+
|
|
13
|
+
object reference QPC1PRO/PTRC1.Op.general (61850-6, 61850-7-2)
|
|
14
|
+
MMS domain + item QPC1PRO PTRC1$ST$Op$general (61850-8-1)
|
|
15
|
+
|
|
16
|
+
The mapping is mechanical but not obvious: the functional constraint sits
|
|
17
|
+
*between* the logical node and the data object in the MMS spelling and does
|
|
18
|
+
not appear in the object reference at all, and the MMS domain is the LDevice's
|
|
19
|
+
``ldName`` when it has one, or ``iedName`` concatenated with ``inst`` when it
|
|
20
|
+
does not.
|
|
21
|
+
|
|
22
|
+
This is in ``core`` because it belongs to neither side exclusively: an SCL
|
|
23
|
+
reader builds these names from a file, and an MMS client parses them back out
|
|
24
|
+
of ``GetNameList``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ld_name(ied_name: str, ld_inst: str, ld_name_attr=None) -> str:
|
|
31
|
+
"""The MMS domain of one LDevice.
|
|
32
|
+
|
|
33
|
+
``ldName`` when the file gives one -- 61850-6 allows an LDevice to name
|
|
34
|
+
itself outright, and then that name IS the domain. Otherwise the 8-1
|
|
35
|
+
default: the IED's name with the LDevice's ``inst`` appended.
|
|
36
|
+
"""
|
|
37
|
+
explicit = (ld_name_attr or "").strip()
|
|
38
|
+
if explicit:
|
|
39
|
+
return explicit
|
|
40
|
+
return f"{ied_name or ''}{ld_inst or ''}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ln_name(prefix, ln_class, inst) -> str:
|
|
44
|
+
"""The MMS spelling of one logical node: prefix + class + instance.
|
|
45
|
+
|
|
46
|
+
``LN0`` is spelled ``LLN0`` by its ``lnClass``, so no special case is
|
|
47
|
+
needed here -- the caller passes what the element carries.
|
|
48
|
+
"""
|
|
49
|
+
return f"{prefix or ''}{ln_class or ''}{inst or ''}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def mms_item(ln: str, fc: str, path) -> str:
|
|
53
|
+
"""``PTRC1``, ``ST``, ``["Op", "general"]`` -> ``PTRC1$ST$Op$general``.
|
|
54
|
+
|
|
55
|
+
``path`` is the descent from the data object down to the leaf attribute,
|
|
56
|
+
one entry per level: ``["Pos", "Oper", "ctlVal"]`` for a control.
|
|
57
|
+
"""
|
|
58
|
+
return "$".join([ln, fc] + [str(p) for p in path])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def object_reference(ld: str, ln: str, path) -> str:
|
|
62
|
+
"""``QPC1PRO``, ``PTRC1``, ``["Op", "general"]``
|
|
63
|
+
-> ``QPC1PRO/PTRC1.Op.general``.
|
|
64
|
+
|
|
65
|
+
The functional constraint is absent by design: an object reference names
|
|
66
|
+
the object, and the FC says how it is being accessed.
|
|
67
|
+
"""
|
|
68
|
+
return f"{ld}/{ln}." + ".".join(str(p) for p in path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def split_item(item: str):
|
|
72
|
+
"""``PTRC1$ST$Op$general`` -> ``("PTRC1", "ST", ("Op", "general"))``.
|
|
73
|
+
|
|
74
|
+
``None`` for anything that is not an attribute item -- a bare logical
|
|
75
|
+
node name, or an empty string. Refusing is deliberate: inventing an FC
|
|
76
|
+
for a name that carries none produces an item no device serves, and the
|
|
77
|
+
failure then surfaces far downstream as a silent missing value.
|
|
78
|
+
"""
|
|
79
|
+
parts = [p for p in (item or "").split("$") if p]
|
|
80
|
+
if len(parts) < 3:
|
|
81
|
+
return None
|
|
82
|
+
return parts[0], parts[1], tuple(parts[2:])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def da_parts(da) -> tuple:
|
|
86
|
+
"""``"Oper.ctlVal"`` / ``"Oper$ctlVal"`` -> ``("Oper", "ctlVal")``.
|
|
87
|
+
|
|
88
|
+
SCL writes the descent through an SDI with ``.``; MMS spells every level
|
|
89
|
+
with ``$``. A tool reading both needs one canonical form, and empty
|
|
90
|
+
segments are dropped rather than preserved.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(da, str):
|
|
93
|
+
return tuple(da) if da else ()
|
|
94
|
+
return tuple(p for p in da.replace("$", ".").split(".") if p)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Guilherme Marini
|
|
3
|
+
#
|
|
4
|
+
# This file is part of py61850. It is free software under the GNU Affero
|
|
5
|
+
# General Public License v3 or later; see LICENSE. A commercial licence,
|
|
6
|
+
# for use in software you do not wish to release under the AGPL, is
|
|
7
|
+
# available from the copyright holder -- see COMMERCIAL.md.
|
|
8
|
+
"""SCL -- the substation configuration files (``.scd`` / ``.cid`` / ``.icd``).
|
|
9
|
+
|
|
10
|
+
from py61850.scl import SclDocument
|
|
11
|
+
|
|
12
|
+
doc = SclDocument.parse("station.scd")
|
|
13
|
+
for name in doc.ied_names:
|
|
14
|
+
ied = doc.ied(name)
|
|
15
|
+
for ln in ied.logical_nodes():
|
|
16
|
+
for attr in ln.walk():
|
|
17
|
+
print(attr.reference(), attr.mms_item(), attr.btype)
|
|
18
|
+
|
|
19
|
+
**What this package is for.** It is a general IEC 61850-6 implementation,
|
|
20
|
+
judged against what any SCL tool would need -- IEDScout, IEC Browser, OpenSCD
|
|
21
|
+
-- not against what one consumer happens to extract today. If a vendor-neutral
|
|
22
|
+
tool would show it or act on it, the model should express it.
|
|
23
|
+
|
|
24
|
+
**Vendor specifics never enter this package; they attach to it.** Two seams
|
|
25
|
+
carry them, and both are standard SCL:
|
|
26
|
+
|
|
27
|
+
- ``Private`` elements, exposed on every model node as ``.privates``, keyed by
|
|
28
|
+
``type``. One reference station carries 5,886 of a single vendor's type.
|
|
29
|
+
- Standard attributes whose VALUE holds a vendor grammar. ``sAddr`` is the
|
|
30
|
+
common one: this package hands over the string and takes no view on it,
|
|
31
|
+
because the grammar inside it belongs to whoever wrote the file.
|
|
32
|
+
|
|
33
|
+
A vendor library reads its own half off these model nodes. It never parses the
|
|
34
|
+
XML a second time, and no vendor concept is reflected here.
|
|
35
|
+
|
|
36
|
+
**Layers.** The granularity follows SCL's own sections rather than being
|
|
37
|
+
imposed on them:
|
|
38
|
+
|
|
39
|
+
SclDocument the file: load/parse, edition, header, privates
|
|
40
|
+
.templates DataTypeTemplates -- station-wide, built once, cached
|
|
41
|
+
.communication SubNetwork/ConnectedAP/Address/GSE/SMV -- shallow, cheap
|
|
42
|
+
.ied_headers identifying fields, no instance tree
|
|
43
|
+
.ied(name) the full instance tree for ONE IED, on demand and cached
|
|
44
|
+
|
|
45
|
+
Templates are document-scoped because they are shared by every IED; instance
|
|
46
|
+
trees are per IED because that is the unit a consumer works in. One reference
|
|
47
|
+
SCD carries 178,406 ``DAI`` elements across 30 IEDs.
|
|
48
|
+
|
|
49
|
+
This package is never imported by ``py61850`` itself, so the MMS client keeps
|
|
50
|
+
installing and running unprivileged on any OS. It is pure ``xml.etree`` over
|
|
51
|
+
the standard library: no network, no privileges, no dependencies.
|
|
52
|
+
|
|
53
|
+
**Not implemented yet**, because no file in the reference corpus carries them:
|
|
54
|
+
the ``Substation`` section (VoltageLevel/Bay/ConductingEquipment) and ``Log``.
|
|
55
|
+
Both are in the schema; neither has test material, and building a tree with
|
|
56
|
+
nothing to check it against is how a reader acquires confident wrong answers.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from ._xmlsafe import DtdNotAllowed, reject_dtd_in_bytes, reject_dtd_in_file
|
|
60
|
+
from .communication import (
|
|
61
|
+
Address,
|
|
62
|
+
Communication,
|
|
63
|
+
ConnectedAP,
|
|
64
|
+
ControlBlockAddress,
|
|
65
|
+
SubNetwork,
|
|
66
|
+
)
|
|
67
|
+
from .controls import ControlBlock, DataSet, ExtRef, FCDA, SettingControl
|
|
68
|
+
from .document import (
|
|
69
|
+
Header,
|
|
70
|
+
SclDocument,
|
|
71
|
+
children_local,
|
|
72
|
+
iter_local,
|
|
73
|
+
privates_of,
|
|
74
|
+
strip_ns,
|
|
75
|
+
)
|
|
76
|
+
from .model import (
|
|
77
|
+
AccessPoint,
|
|
78
|
+
DataAttribute,
|
|
79
|
+
DataObject,
|
|
80
|
+
Ied,
|
|
81
|
+
IedHeader,
|
|
82
|
+
LDevice,
|
|
83
|
+
LogicalNode,
|
|
84
|
+
Server,
|
|
85
|
+
)
|
|
86
|
+
from .templates import AttributeSpec, DoTypeSpec, LNodeTypeSpec, TemplatePool
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"SclDocument", "Header",
|
|
90
|
+
"DtdNotAllowed", "reject_dtd_in_file", "reject_dtd_in_bytes",
|
|
91
|
+
"TemplatePool", "LNodeTypeSpec", "DoTypeSpec", "AttributeSpec",
|
|
92
|
+
"Communication", "SubNetwork", "ConnectedAP", "Address",
|
|
93
|
+
"ControlBlockAddress",
|
|
94
|
+
"IedHeader", "Ied", "AccessPoint", "Server", "LDevice", "LogicalNode",
|
|
95
|
+
"DataObject", "DataAttribute",
|
|
96
|
+
"DataSet", "FCDA", "ControlBlock", "SettingControl", "ExtRef",
|
|
97
|
+
"strip_ns", "iter_local", "children_local", "privates_of",
|
|
98
|
+
]
|