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.
Files changed (85) hide show
  1. {py61850-0.2.0.dev1 → py61850-0.3.0}/CONTRIBUTING.md +20 -0
  2. {py61850-0.2.0.dev1 → py61850-0.3.0}/PKG-INFO +43 -1
  3. {py61850-0.2.0.dev1 → py61850-0.3.0}/README.md +42 -0
  4. {py61850-0.2.0.dev1 → py61850-0.3.0}/ROADMAP.md +41 -3
  5. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/__init__.py +27 -1
  6. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/core/__init__.py +10 -2
  7. py61850-0.3.0/src/py61850/core/fc.py +121 -0
  8. py61850-0.3.0/src/py61850/core/refs.py +94 -0
  9. py61850-0.3.0/src/py61850/scl/__init__.py +98 -0
  10. py61850-0.3.0/src/py61850/scl/_xmlsafe.py +113 -0
  11. py61850-0.3.0/src/py61850/scl/communication.py +188 -0
  12. py61850-0.3.0/src/py61850/scl/controls.py +233 -0
  13. py61850-0.3.0/src/py61850/scl/document.py +326 -0
  14. py61850-0.3.0/src/py61850/scl/model.py +508 -0
  15. py61850-0.3.0/src/py61850/scl/templates.py +229 -0
  16. py61850-0.3.0/tests/unit/scl_fixtures.py +221 -0
  17. py61850-0.3.0/tests/unit/test_fc.py +109 -0
  18. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_public_api.py +3 -0
  19. py61850-0.3.0/tests/unit/test_refs.py +92 -0
  20. py61850-0.3.0/tests/unit/test_scl_api.py +67 -0
  21. py61850-0.3.0/tests/unit/test_scl_communication.py +144 -0
  22. py61850-0.3.0/tests/unit/test_scl_controls.py +153 -0
  23. py61850-0.3.0/tests/unit/test_scl_document.py +164 -0
  24. py61850-0.3.0/tests/unit/test_scl_model.py +416 -0
  25. py61850-0.3.0/tests/unit/test_scl_templates.py +154 -0
  26. py61850-0.3.0/tests/unit/test_xmlsafe.py +99 -0
  27. py61850-0.2.0.dev1/src/py61850/scl/__init__.py +0 -23
  28. {py61850-0.2.0.dev1 → py61850-0.3.0}/.gitignore +0 -0
  29. {py61850-0.2.0.dev1 → py61850-0.3.0}/CLA.md +0 -0
  30. {py61850-0.2.0.dev1 → py61850-0.3.0}/COMMERCIAL.md +0 -0
  31. {py61850-0.2.0.dev1 → py61850-0.3.0}/LICENSE +0 -0
  32. {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/01_connect_and_scan.py +0 -0
  33. {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/02_read_values.py +0 -0
  34. {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/03_file_transfer.py +0 -0
  35. {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/04_error_handling.py +0 -0
  36. {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/05_fleet_inventory.py +0 -0
  37. {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/06_find_files.py +0 -0
  38. {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/07_read_object_references.py +0 -0
  39. {py61850-0.2.0.dev1 → py61850-0.3.0}/examples/README.md +0 -0
  40. {py61850-0.2.0.dev1 → py61850-0.3.0}/pyproject.toml +0 -0
  41. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/__main__.py +0 -0
  42. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/cli/__init__.py +0 -0
  43. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/cli/__main__.py +0 -0
  44. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/cli/files.py +0 -0
  45. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/cli/main.py +0 -0
  46. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/cli/scan.py +0 -0
  47. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/core/ber.py +0 -0
  48. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/core/data.py +0 -0
  49. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/core/quality.py +0 -0
  50. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/core/time.py +0 -0
  51. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/errors.py +0 -0
  52. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/goose/__init__.py +0 -0
  53. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/link/__init__.py +0 -0
  54. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/__init__.py +0 -0
  55. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/client.py +0 -0
  56. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/pdu.py +0 -0
  57. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/service_error.py +0 -0
  58. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/services/__init__.py +0 -0
  59. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/services/directory.py +0 -0
  60. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/services/files.py +0 -0
  61. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/services/read.py +0 -0
  62. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/mms/types.py +0 -0
  63. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/__init__.py +0 -0
  64. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/acse.py +0 -0
  65. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/cotp.py +0 -0
  66. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/oids.py +0 -0
  67. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/presentation.py +0 -0
  68. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/session.py +0 -0
  69. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/stack.py +0 -0
  70. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/osi/tpkt.py +0 -0
  71. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/py.typed +0 -0
  72. {py61850-0.2.0.dev1 → py61850-0.3.0}/src/py61850/sv/__init__.py +0 -0
  73. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/BENCH.md +0 -0
  74. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/README.md +0 -0
  75. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/__init__.py +0 -0
  76. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/fixtures/README.md +0 -0
  77. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/__init__.py +0 -0
  78. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_ber.py +0 -0
  79. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_client.py +0 -0
  80. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_data.py +0 -0
  81. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_file_filter.py +0 -0
  82. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_logical_nodes.py +0 -0
  83. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_osi.py +0 -0
  84. {py61850-0.2.0.dev1 → py61850-0.3.0}/tests/unit/test_pdu.py +0 -0
  85. {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.2.0.dev1
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
- - 🧭 **SCL parser** — `py61850.scl` (package reserved): parse IEDs, LDs, LNs, DOs, DAs,
115
- DataSets, and Report/Setting control blocks from an SCL XML file into an
116
- in-memory object model. (Shared by MMS-sim and GOOSE-sim below.)
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.2.0.dev1"
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
- Import these from ``py61850.core.<module>``; they are internal to the package
21
- and may change between releases.
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
+ ]