cimprofiles 0.1.0a2__py3-none-any.whl
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.
- cimprofiles/README.md +291 -0
- cimprofiles/__init__.py +36 -0
- cimprofiles/build_merged_profiles.py +261 -0
- cimprofiles/cim16/cim16v33/__init__.py +789 -0
- cimprofiles/cim16/cim16v33/cim16v33.py +39396 -0
- cimprofiles/cim16/cim16v33/cim16v33.rdfs-t2b.md +26221 -0
- cimprofiles/cim17/cgmes_3_0_0/__init__.py +2696 -0
- cimprofiles/cim17/cgmes_3_0_0/core_equipment/__init__.py +250 -0
- cimprofiles/cim17/cgmes_3_0_0/core_equipment/core_equipment.py +12615 -0
- cimprofiles/cim17/cgmes_3_0_0/diagram/__init__.py +29 -0
- cimprofiles/cim17/cgmes_3_0_0/diagram/diagram_layout.py +815 -0
- cimprofiles/cim17/cgmes_3_0_0/dynamics/__init__.py +9 -0
- cimprofiles/cim17/cgmes_3_0_0/dynamics/dynamics.py +18 -0
- cimprofiles/cim17/cgmes_3_0_0/equipment_boundary/__init__.py +34 -0
- cimprofiles/cim17/cgmes_3_0_0/equipment_boundary/equipment_boundary.py +1088 -0
- cimprofiles/cim17/cgmes_3_0_0/geographical_location/__init__.py +22 -0
- cimprofiles/cim17/cgmes_3_0_0/geographical_location/geographical_location.py +824 -0
- cimprofiles/cim17/cgmes_3_0_0/operation/__init__.py +67 -0
- cimprofiles/cim17/cgmes_3_0_0/operation/operation.py +2447 -0
- cimprofiles/cim17/cgmes_3_0_0/short_circuit/__init__.py +65 -0
- cimprofiles/cim17/cgmes_3_0_0/short_circuit/short_circuit.py +3769 -0
- cimprofiles/cim17/cgmes_3_0_0/state_variables/__init__.py +42 -0
- cimprofiles/cim17/cgmes_3_0_0/state_variables/state_variables.py +2286 -0
- cimprofiles/cim17/cgmes_3_0_0/steady_state_hypothesis/__init__.py +111 -0
- cimprofiles/cim17/cgmes_3_0_0/steady_state_hypothesis/steady_state_hypothesis.py +4112 -0
- cimprofiles/cim17/cgmes_3_0_0/topology/__init__.py +32 -0
- cimprofiles/cim17/cgmes_3_0_0/topology/topology.py +712 -0
- cimprofiles/cim17/cim17v40/__init__.py +1719 -0
- cimprofiles/cim17/cim17v40/cim17v40.py +177479 -0
- cimprofiles/cim17/cimhub_2023/__init__.py +621 -0
- cimprofiles/cim17/cimhub_2023/cimhub_2023.py +28616 -0
- cimprofiles/cim17/cimhub_2023/cimhub_2023.rdfs-t2b.md +18888 -0
- cimprofiles/cim17/gridappsd_rc4_2021/__init__.py +831 -0
- cimprofiles/cim17/gridappsd_rc4_2021/rc4_2021.py +49017 -0
- cimprofiles/cim18/cim18gad/__init__.py +2054 -0
- cimprofiles/cim18/cim18gad/cim18v15.py +198592 -0
- cimprofiles/cim18/cim18gmdm/__init__.py +1133 -0
- cimprofiles/cim18/cim18gmdm/asset/Asset.rdfs-t2b.md +781 -0
- cimprofiles/cim18/cim18gmdm/asset/__init__.py +41 -0
- cimprofiles/cim18/cim18gmdm/asset/asset.py +1427 -0
- cimprofiles/cim18/cim18gmdm/canonical/__init__.py +928 -0
- cimprofiles/cim18/cim18gmdm/canonical/canonical.py +65313 -0
- cimprofiles/cim18/cim18gmdm/connectivity/UnbalancedConnectivity.rdfs-t2b.md +2541 -0
- cimprofiles/cim18/cim18gmdm/connectivity/__init__.py +76 -0
- cimprofiles/cim18/cim18gmdm/connectivity/connectivity.py +2791 -0
- cimprofiles/cim18/cim18gmdm/diagram/__init__.py +26 -0
- cimprofiles/cim18/cim18gmdm/diagram/diagram.py +758 -0
- cimprofiles/cim18/cim18gmdm/electrical/UnbalancedElectrical.rdfs-t2b.md +3044 -0
- cimprofiles/cim18/cim18gmdm/electrical/__init__.py +140 -0
- cimprofiles/cim18/cim18gmdm/electrical/electrical.py +4107 -0
- cimprofiles/cim18/cim18gmdm/location/GeographicalLocation.rdfs-t2b.md +969 -0
- cimprofiles/cim18/cim18gmdm/location/__init__.py +69 -0
- cimprofiles/cim18/cim18gmdm/location/location.py +1103 -0
- cimprofiles/cim18/cim18gmdm/marketnode/__init__.py +15 -0
- cimprofiles/cim18/cim18gmdm/marketnode/marketnode.py +180 -0
- cimprofiles/cim18/cimhub_2026/__init__.py +550 -0
- cimprofiles/cim18/cimhub_2026/cimhub_2026.py +33382 -0
- cimprofiles/dedupe_profile_fields.py +150 -0
- cimprofiles/env_vars.py +48 -0
- cimprofiles/identity.py +453 -0
- cimprofiles/merge.py +472 -0
- cimprofiles/merge_releases.yaml +33 -0
- cimprofiles/units/__init__.py +2 -0
- cimprofiles/units/cim_units/__init__.py +2 -0
- cimprofiles/units/cim_units/unit_multiplier.py +113 -0
- cimprofiles/units/cim_units/unit_symbol.py +660 -0
- cimprofiles/units/cim_units/units.txt +95 -0
- cimprofiles/units/units.py +67 -0
- cimprofiles-0.1.0a2.dist-info/METADATA +128 -0
- cimprofiles-0.1.0a2.dist-info/RECORD +72 -0
- cimprofiles-0.1.0a2.dist-info/WHEEL +4 -0
- cimprofiles-0.1.0a2.dist-info/licenses/LICENSE +29 -0
cimprofiles/README.md
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# CIM Data Profiles
|
|
2
|
+
|
|
3
|
+
This package contains the Python data profile modules used by cim-graph
|
|
4
|
+
(and any other tool that wants the CIM classes without a database stack).
|
|
5
|
+
Each profile is a package of dataclasses generated from a CIM UML export
|
|
6
|
+
(via CIMTool or Enterprise Architect Schema Composer).
|
|
7
|
+
|
|
8
|
+
## How profiles are selected
|
|
9
|
+
|
|
10
|
+
In cim-graph, set `CIMG_CIM_PROFILE` to the dotted module path of the profile you want:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
export CIMG_CIM_PROFILE="cimhub_2023"
|
|
14
|
+
# or fully-qualified:
|
|
15
|
+
export CIMG_CIM_PROFILE="cimprofiles.cim17.cimhub_2023"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Profiles live under their CIM version (`cimprofiles.cim17.cimhub_2023`). A short name,
|
|
19
|
+
optionally with a sub-profile (`cim18gmdm.connectivity`), is looked up in
|
|
20
|
+
`cimprofiles.PROFILE_PACKAGES` by `cimprofiles.profile_module`. The pre-0.6
|
|
21
|
+
`cimgraph.data_profile.<name>` paths still work and resolve the same way.
|
|
22
|
+
|
|
23
|
+
### Multi-profile merge — new in 0.5
|
|
24
|
+
|
|
25
|
+
Comma-separate two or more sub-profile paths to merge them at runtime:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export CIMG_CIM_PROFILE="cimprofiles.cim18.cim18gmdm.connectivity,\
|
|
29
|
+
cimprofiles.cim18.cim18gmdm.electrical,\
|
|
30
|
+
cimprofiles.cim18.cim18gmdm.asset"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`get_cim_profile()` calls `merge_profiles()` once and memoises the result.
|
|
34
|
+
Every caller in the same process — `XMLFile`, `FeederModel`, any utility
|
|
35
|
+
function — receives the **same module object**, so class identity is
|
|
36
|
+
maintained across all graph key lookups.
|
|
37
|
+
|
|
38
|
+
The merged module satisfies the same contract as any single profile: it has
|
|
39
|
+
an `__all__` list and each class is accessible via `getattr(merged, name)`.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Available profiles
|
|
44
|
+
|
|
45
|
+
### Monolithic profiles
|
|
46
|
+
|
|
47
|
+
These are single-package profiles — set `CIMG_CIM_PROFILE` to the package
|
|
48
|
+
name and use the classes directly.
|
|
49
|
+
|
|
50
|
+
| Profile | Classes | Description |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `cimhub_2023` | 397 | CIMHub 2023 distribution feeder profile (CIM100) |
|
|
53
|
+
| `cimhub_2026` | 449 | CIMHub 2026 distribution feeder profile (CIM100) |
|
|
54
|
+
| `cim16v33` | 533 | IEC CIM16 v33 |
|
|
55
|
+
| `cim17v40` | 1930 | IEC CIM17 v40 |
|
|
56
|
+
| `cim18gad` | 1348 | CIM18 GridAPPS-D profile |
|
|
57
|
+
| `rc4_2021` | 571 | RC4 2021 profile |
|
|
58
|
+
|
|
59
|
+
### cim18gmdm — EPRI GMDM split profile
|
|
60
|
+
|
|
61
|
+
The GMDM model is split into sub-profiles by functional area. Use the
|
|
62
|
+
pre-baked merged package (`cim18gmdm`) for a single-import experience, or
|
|
63
|
+
compose the sub-profiles you need at runtime via the comma-spec.
|
|
64
|
+
|
|
65
|
+
#### Pre-baked merged package
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
export CIMG_CIM_PROFILE="cimprofiles.cim18.cim18gmdm"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This is a committed `__init__.py` generated by `python -m cimprofiles.build_merged_profiles`.
|
|
72
|
+
It re-exports all classes from all five sub-profiles (978 classes total) and
|
|
73
|
+
is the equivalent of setting the comma-spec to all five sub-profiles.
|
|
74
|
+
Use it when you need every profile class available statically (e.g. for
|
|
75
|
+
Pylance completion on the full model).
|
|
76
|
+
|
|
77
|
+
#### Sub-profiles
|
|
78
|
+
|
|
79
|
+
Compose only the sub-profiles you need to keep the merged class count small
|
|
80
|
+
and avoid loading profile classes irrelevant to your task.
|
|
81
|
+
|
|
82
|
+
| Sub-profile | Classes | Key classes |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `cim18gmdm.connectivity` | 56 | `ACLineSegment`, `ConnectivityNode`, `Terminal`, `Breaker`, `EnergyConsumer`, `PowerTransformer` |
|
|
85
|
+
| `cim18gmdm.electrical` | 77 | `PerLengthPhaseImpedance`, `PhaseImpedanceData`, `TransformerTankInfo`, `ACLineSegment.length`, `ACLineSegment.PerLengthImpedance` |
|
|
86
|
+
| `cim18gmdm.asset` | 24 | `Asset`, `AssetInfo`, `TransformerEndInfo`, `NoLoadTest`, `ShortCircuitTest` |
|
|
87
|
+
| `cim18gmdm.location` | 39 | `Location`, `PositionPoint`, `CoordinateSystem` |
|
|
88
|
+
| `cim18gmdm.marketnode` | 5 | `IndividualPnode`, `AggregateNode`, `Pnode` |
|
|
89
|
+
| `cim18gmdm.diagram` | 12 | `Diagram`, `DiagramObject`, `DiagramObjectPoint` |
|
|
90
|
+
| `cim18gmdm.canonical` | 765 | All classes in one file — legacy, prefer the pre-baked merged package |
|
|
91
|
+
|
|
92
|
+
**Common combinations:**
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Topology + electrical parameters (most common for power flow):
|
|
96
|
+
export CIMG_CIM_PROFILE="cimprofiles.cim18.cim18gmdm.connectivity,\
|
|
97
|
+
cimprofiles.cim18.cim18gmdm.electrical"
|
|
98
|
+
|
|
99
|
+
# Add asset catalog (transformer nameplate data, wire info):
|
|
100
|
+
export CIMG_CIM_PROFILE="cimprofiles.cim18.cim18gmdm.connectivity,\
|
|
101
|
+
cimprofiles.cim18.cim18gmdm.electrical,\
|
|
102
|
+
cimprofiles.cim18.cim18gmdm.asset"
|
|
103
|
+
|
|
104
|
+
# Full five-part merge (equivalent to using the pre-baked cim18gmdm package):
|
|
105
|
+
export CIMG_CIM_PROFILE="cimprofiles.cim18.cim18gmdm.connectivity,\
|
|
106
|
+
cimprofiles.cim18.cim18gmdm.electrical,\
|
|
107
|
+
cimprofiles.cim18.cim18gmdm.asset,\
|
|
108
|
+
cimprofiles.cim18.cim18gmdm.location,\
|
|
109
|
+
cimprofiles.cim18.cim18gmdm.marketnode"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### cgmes_3_0_0 — IEC CGMES 3.0 split profile
|
|
113
|
+
|
|
114
|
+
CGMES 3.0 is split into sub-profiles by exchange type, following the IEC
|
|
115
|
+
61970-600 document structure.
|
|
116
|
+
|
|
117
|
+
| Sub-profile | Classes | Description |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `cgmes_3_0_0.core_equipment` | 203 | EQ — equipment definitions |
|
|
120
|
+
| `cgmes_3_0_0.equipment_boundary` | 22 | EQBD — boundary equipment |
|
|
121
|
+
| `cgmes_3_0_0.operation` | 39 | OP — operational limits |
|
|
122
|
+
| `cgmes_3_0_0.short_circuit` | 48 | SC — short-circuit parameters |
|
|
123
|
+
| `cgmes_3_0_0.steady_state_hypothesis` | 85 | SSH — schedule/setpoints |
|
|
124
|
+
| `cgmes_3_0_0.state_variables` | 29 | SV — solved state |
|
|
125
|
+
| `cgmes_3_0_0.topology` | 15 | TP — bus-branch topology |
|
|
126
|
+
| `cgmes_3_0_0.geographical_location` | 12 | GL — geographic coordinates |
|
|
127
|
+
| `cgmes_3_0_0.diagram` | 12 | DL — diagram layout |
|
|
128
|
+
|
|
129
|
+
**Typical CGMES merge (EQ + SSH + SV):**
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
export CIMG_CIM_PROFILE="cimprofiles.cim17.cgmes_3_0_0.core_equipment,\
|
|
133
|
+
cimprofiles.cim17.cgmes_3_0_0.steady_state_hypothesis,\
|
|
134
|
+
cimprofiles.cim17.cgmes_3_0_0.state_variables"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Loading split exports
|
|
140
|
+
|
|
141
|
+
For models spread across multiple part files, use an IEC 61970-552 metadata
|
|
142
|
+
header (`.cimx` file) to load all parts in one call:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
import os
|
|
146
|
+
from cimgraph.databases import XMLFile
|
|
147
|
+
from cimgraph.models import FeederModel
|
|
148
|
+
|
|
149
|
+
os.environ['CIMG_CIM_PROFILE'] = (
|
|
150
|
+
'cimprofiles.cim18.cim18gmdm.connectivity,'
|
|
151
|
+
'cimprofiles.cim18.cim18gmdm.electrical,'
|
|
152
|
+
'cimprofiles.cim18.cim18gmdm.asset'
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# 9500node.cimx lists the individual part files as dcat:Distribution entries.
|
|
156
|
+
# XMLFile resolves each accessURL relative to the .cimx file and loads all
|
|
157
|
+
# parts into one graph in a single two-pass read.
|
|
158
|
+
file = XMLFile(metadata='profile_parts/9500node.cimx')
|
|
159
|
+
network = FeederModel(container=None, connection=file)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The `.cimx` format follows IEC 61970-552 §8. Each part file is a
|
|
163
|
+
`dcat:Distribution` with an `accessURL` (relative path) and a
|
|
164
|
+
`dcterms:conformsTo` URI naming the sub-profile it was written against.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Adding a custom profile
|
|
169
|
+
|
|
170
|
+
1. Export your UML profile from CIMTool or Schema Composer as `.xsd`.
|
|
171
|
+
2. Run `uv run xsdata generate` (or the equivalent CIMTool builder) to
|
|
172
|
+
produce the dataclass package.
|
|
173
|
+
3. Place the generated package under `src/cimprofiles/` (or any importable package).
|
|
174
|
+
4. Set `CIMG_CIM_PROFILE` to the dotted path of the package.
|
|
175
|
+
|
|
176
|
+
For split profiles, generate one sub-package per functional area and use
|
|
177
|
+
the comma-spec to merge them at runtime.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Generating a pre-baked `__init__.py` for a split profile
|
|
182
|
+
|
|
183
|
+
A pre-baked `__init__.py` lets users `import cimprofiles.<name> as cim`
|
|
184
|
+
without knowing the sub-profile names, and lets Pylance/mypy resolve the full
|
|
185
|
+
merged class surface statically.
|
|
186
|
+
|
|
187
|
+
### 1. Register the release in `merge_releases.yaml`
|
|
188
|
+
|
|
189
|
+
Add an entry to `src/cimprofiles/merge_releases.yaml`:
|
|
190
|
+
|
|
191
|
+
```yaml
|
|
192
|
+
releases:
|
|
193
|
+
- name: my_profile # must match the package directory name
|
|
194
|
+
package: cimprofiles.my_profile
|
|
195
|
+
parts:
|
|
196
|
+
- cimprofiles.my_profile.connectivity
|
|
197
|
+
- cimprofiles.my_profile.electrical
|
|
198
|
+
- cimprofiles.my_profile.asset
|
|
199
|
+
description: "My profile — merged from connectivity + electrical + asset"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`parts` is an ordered list. First-seen order wins for classes that appear in
|
|
203
|
+
multiple sub-profiles, so put the most complete sub-profile first.
|
|
204
|
+
|
|
205
|
+
**Prerequisite — base class consistency:** `merge_profiles` fails fast if two
|
|
206
|
+
sub-profiles declare the same class with *different* direct base classes. This
|
|
207
|
+
happens when a partial sub-profile uses `Identity` as a stub base for a class
|
|
208
|
+
that a fuller sub-profile roots at `IdentifiedObject` or deeper. Fix by
|
|
209
|
+
regenerating the offending sub-profile from CIMTool with the full inheritance
|
|
210
|
+
chain before running the build script.
|
|
211
|
+
|
|
212
|
+
To diagnose conflicts before registering:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import importlib
|
|
216
|
+
from collections import defaultdict
|
|
217
|
+
from cimprofiles.merge import merge_profiles
|
|
218
|
+
|
|
219
|
+
parts = ['cimprofiles.my_profile.connectivity',
|
|
220
|
+
'cimprofiles.my_profile.electrical']
|
|
221
|
+
mods = [importlib.import_module(p) for p in parts]
|
|
222
|
+
|
|
223
|
+
class_defs = defaultdict(list)
|
|
224
|
+
for mod in mods:
|
|
225
|
+
for name in getattr(mod, '__all__', []):
|
|
226
|
+
obj = getattr(mod, name, None)
|
|
227
|
+
if obj and isinstance(obj, type):
|
|
228
|
+
class_defs[name].append(obj)
|
|
229
|
+
|
|
230
|
+
for name, defs in sorted(class_defs.items()):
|
|
231
|
+
bases = set()
|
|
232
|
+
for cls in defs:
|
|
233
|
+
for base in cls.__mro__[1:]:
|
|
234
|
+
if base is not object:
|
|
235
|
+
bases.add(base.__name__)
|
|
236
|
+
break
|
|
237
|
+
if len(bases) > 1:
|
|
238
|
+
print(f'{name}: conflicting bases {sorted(bases)}')
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### 2. Run the build script
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
uv run python -m cimprofiles.build_merged_profiles
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
This writes a committed `__init__.py` into each package listed in
|
|
248
|
+
`merge_releases.yaml` and smoke-tests the import. Re-run after adding or
|
|
249
|
+
changing sub-profile parts.
|
|
250
|
+
|
|
251
|
+
The generated file is a normal Python module — Pylance reads it directly.
|
|
252
|
+
It re-exports every class from the appropriate sub-profile, so the source
|
|
253
|
+
of each class is traceable and static analysis works without the runtime
|
|
254
|
+
merge machinery.
|
|
255
|
+
|
|
256
|
+
### Current status
|
|
257
|
+
|
|
258
|
+
| Profile | `__init__.py` | Notes |
|
|
259
|
+
|---|---|---|
|
|
260
|
+
| `cim18gmdm` | ✅ generated | 116 classes from 5 sub-profiles |
|
|
261
|
+
| `cgmes_3_0_0` | ✅ generated | 275 classes from 9 sub-profiles (EQ+EQBD+OP+SC+SSH+SV+TP+GL+DL) |
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Runtime merge internals
|
|
266
|
+
|
|
267
|
+
`cimprofiles.merge.merge_profiles(*modules)` builds a synthetic
|
|
268
|
+
`types.ModuleType` by:
|
|
269
|
+
|
|
270
|
+
1. Collecting every name from each module's `__all__`.
|
|
271
|
+
2. Topologically sorting by inheritance (parents before children).
|
|
272
|
+
3. For classes that appear in more than one sub-profile: creating a new
|
|
273
|
+
dataclass with the **union of direct fields**, reparented onto the
|
|
274
|
+
already-merged base class.
|
|
275
|
+
4. For classes that appear in only one sub-profile: adopting the class
|
|
276
|
+
as-is but reparenting it onto the merged base if the base was rebuilt.
|
|
277
|
+
5. Enums, CIMUnit subclasses, and the shared `Identity` root are taken from
|
|
278
|
+
the first profile that defines them.
|
|
279
|
+
|
|
280
|
+
The merge fails fast if two sub-profiles declare the same class name with
|
|
281
|
+
**different base classes** — that would silently drop a lineage.
|
|
282
|
+
|
|
283
|
+
To generate a static type-stub file for Pylance/mypy (the replacement for
|
|
284
|
+
the hand-maintained `canonical.py`):
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
from cimprofiles.cim18.cim18gmdm import connectivity, electrical
|
|
288
|
+
from cimprofiles.merge import generate_type_stubs
|
|
289
|
+
|
|
290
|
+
generate_type_stubs(connectivity, electrical, output_path='cim_merged.pyi')
|
|
291
|
+
```
|
cimprofiles/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CIM_PROFILE(Enum):
|
|
5
|
+
RC4_2021 = 'rc4_2021'
|
|
6
|
+
CIMHUB_2023 = 'cimhub_2023'
|
|
7
|
+
CIMHUB_2026 = 'cimhub_2026'
|
|
8
|
+
CIM16V33 = 'cim16v33'
|
|
9
|
+
CIM17V40 = 'cim17v40'
|
|
10
|
+
CIM18GMDM = 'cim18gmdm'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Short profile names, as used by CIMG_CIM_PROFILE and cimgraph.data_profile -> package
|
|
14
|
+
PROFILE_PACKAGES = {
|
|
15
|
+
'cim16v33': 'cimprofiles.cim16.cim16v33',
|
|
16
|
+
'cgmes_3_0_0': 'cimprofiles.cim17.cgmes_3_0_0',
|
|
17
|
+
'cim17v40': 'cimprofiles.cim17.cim17v40',
|
|
18
|
+
'cimhub_2023': 'cimprofiles.cim17.cimhub_2023',
|
|
19
|
+
'rc4_2021': 'cimprofiles.cim17.gridappsd_rc4_2021',
|
|
20
|
+
'cim18gad': 'cimprofiles.cim18.cim18gad',
|
|
21
|
+
'cim18gmdm': 'cimprofiles.cim18.cim18gmdm',
|
|
22
|
+
'cimhub_2026': 'cimprofiles.cim18.cimhub_2026',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def profile_module(name: str) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Returns the module path of a profile given by short name, optionally with a
|
|
29
|
+
sub-profile: 'cimhub_2023' -> 'cimprofiles.cim17.cimhub_2023',
|
|
30
|
+
'cim18gmdm.connectivity' -> 'cimprofiles.cim18.cim18gmdm.connectivity'.
|
|
31
|
+
"""
|
|
32
|
+
short_name, dot, sub_profile = name.partition('.')
|
|
33
|
+
if short_name not in PROFILE_PACKAGES:
|
|
34
|
+
raise ValueError(f'Unknown CIM profile {name!r}; known profiles: '
|
|
35
|
+
f'{", ".join(PROFILE_PACKAGES)}')
|
|
36
|
+
return PROFILE_PACKAGES[short_name] + dot + sub_profile
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Build pre-baked merged profile __init__.py files from merge_releases.yaml.
|
|
2
|
+
|
|
3
|
+
Run with the development dependencies installed (needs pyyaml):
|
|
4
|
+
uv run python -m cimprofiles.build_merged_profiles
|
|
5
|
+
uv run python -m cimprofiles.build_merged_profiles --config path/to/releases.yaml
|
|
6
|
+
|
|
7
|
+
``--config`` builds releases for a profile package outside this repo (e.g.
|
|
8
|
+
cim-vfm-profiles). Each release's ``package`` must be importable; its
|
|
9
|
+
``__init__.py`` is written wherever that package lives.
|
|
10
|
+
|
|
11
|
+
Each entry in merge_releases.yaml produces a committed __init__.py that
|
|
12
|
+
re-exports the merged class surface. The file is a normal Python module —
|
|
13
|
+
Pylance reads it directly, and `import cimprofiles.<name> as cim`
|
|
14
|
+
resolves to the merged class set with no runtime merge machinery needed.
|
|
15
|
+
|
|
16
|
+
For classes that appear in only one sub-profile, the output is a simple
|
|
17
|
+
re-import. For classes that overlap across sub-profiles (same name, different
|
|
18
|
+
fields in each), the output is an inline @dataclass definition that carries the
|
|
19
|
+
union of all direct fields — this is the case for CGMES where e.g. ACLineSegment
|
|
20
|
+
appears in both EQ (r, x, bch) and SC (r0, x0, b0ch) sub-profiles.
|
|
21
|
+
|
|
22
|
+
Add this to the PyPI release process for any profile listed in the config.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import dataclasses
|
|
28
|
+
import importlib
|
|
29
|
+
import importlib.util
|
|
30
|
+
import sys
|
|
31
|
+
import textwrap
|
|
32
|
+
from collections import defaultdict
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
_CONFIG = Path(__file__).parent / 'merge_releases.yaml'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_config(config: Path) -> list[dict]:
|
|
41
|
+
with open(config) as f:
|
|
42
|
+
data = yaml.safe_load(f)
|
|
43
|
+
return data['releases']
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _package_dir(package: str) -> Path:
|
|
47
|
+
"""Where *package* lives — resolved by import, so external packages work too."""
|
|
48
|
+
spec = importlib.util.find_spec(package)
|
|
49
|
+
if spec is None or not spec.submodule_search_locations:
|
|
50
|
+
raise FileNotFoundError(
|
|
51
|
+
f'Package not importable: {package}\n'
|
|
52
|
+
f'Create it and add sub-profile packages before running this script.'
|
|
53
|
+
)
|
|
54
|
+
return Path(next(iter(spec.submodule_search_locations)))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sanitize_metadata(meta: dict) -> dict:
|
|
58
|
+
"""Strip newlines from string metadata values so repr() is safe in source."""
|
|
59
|
+
return {
|
|
60
|
+
k: v.replace('\n', ' ').strip() if isinstance(v, str) else v
|
|
61
|
+
for k, v in meta.items()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _field_source(f: dataclasses.Field) -> str:
|
|
66
|
+
"""Emit a field(...) expression that recreates the field descriptor."""
|
|
67
|
+
parts: list[str] = []
|
|
68
|
+
if f.default is not dataclasses.MISSING:
|
|
69
|
+
parts.append(f'default={f.default!r}')
|
|
70
|
+
elif f.default_factory is not dataclasses.MISSING:
|
|
71
|
+
factory = f.default_factory
|
|
72
|
+
if factory is list:
|
|
73
|
+
parts.append('default_factory=list')
|
|
74
|
+
elif factory is dict:
|
|
75
|
+
parts.append('default_factory=dict')
|
|
76
|
+
else:
|
|
77
|
+
parts.append(f'default_factory={factory.__name__}')
|
|
78
|
+
meta = _sanitize_metadata(dict(f.metadata))
|
|
79
|
+
parts.append(f'metadata={meta!r}')
|
|
80
|
+
return f'field({", ".join(parts)})'
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _emit_inline_class(name: str, merged_cls: type, merged_base_name: str) -> list[str]:
|
|
84
|
+
"""Emit an inline @dataclass definition for an overlapping class."""
|
|
85
|
+
from cimprofiles.merge import _get_direct_fields
|
|
86
|
+
direct = _get_direct_fields(merged_cls)
|
|
87
|
+
lines: list[str] = []
|
|
88
|
+
lines.append('@dataclass(repr=False, eq=False)')
|
|
89
|
+
lines.append(f'class {name}({merged_base_name}):')
|
|
90
|
+
|
|
91
|
+
# Preserve the original docstring — make_dataclass overwrites __doc__ with
|
|
92
|
+
# the signature string, so we must emit it explicitly.
|
|
93
|
+
doc = merged_cls.__doc__
|
|
94
|
+
if doc and not doc.startswith(f'{name}('):
|
|
95
|
+
# Indent each line of the docstring body by 4 spaces.
|
|
96
|
+
indented = '\n'.join(' ' + l for l in doc.split('\n'))
|
|
97
|
+
lines.append(f' """{indented.strip()}\n """')
|
|
98
|
+
|
|
99
|
+
if not direct:
|
|
100
|
+
lines.append(' pass')
|
|
101
|
+
else:
|
|
102
|
+
for fname, f in direct.items():
|
|
103
|
+
ftype = f.type if isinstance(f.type, str) else str(f.type)
|
|
104
|
+
field_expr = _field_source(f)
|
|
105
|
+
# Never wrap — the metadata repr contains string literals that
|
|
106
|
+
# textwrap would break mid-literal, producing a SyntaxError.
|
|
107
|
+
lines.append(f' {fname}: {ftype} = {field_expr}')
|
|
108
|
+
return lines
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_init(release: dict) -> str:
|
|
112
|
+
"""Return the generated __init__.py content for one release entry."""
|
|
113
|
+
from cimprofiles.identity import Identity
|
|
114
|
+
from cimprofiles.merge import _get_direct_fields, merge_profiles
|
|
115
|
+
|
|
116
|
+
parts = release['parts']
|
|
117
|
+
description = release.get('description', '')
|
|
118
|
+
package = release['package']
|
|
119
|
+
|
|
120
|
+
modules = [importlib.import_module(p) for p in parts]
|
|
121
|
+
merged = merge_profiles(*modules)
|
|
122
|
+
|
|
123
|
+
names = sorted(merged.__all__)
|
|
124
|
+
|
|
125
|
+
# ── Determine which classes are overlapping vs single-source ──────────
|
|
126
|
+
# A class is "overlapping" if the same name with *different field sets*
|
|
127
|
+
# appears in more than one sub-profile. For those we emit an inline
|
|
128
|
+
# @dataclass. For single-source (or Identity/enum/cimunit), we re-import.
|
|
129
|
+
name_to_parts: dict[str, list[str]] = defaultdict(list)
|
|
130
|
+
name_to_first_part: dict[str, str] = {}
|
|
131
|
+
for part_path, mod in zip(parts, modules):
|
|
132
|
+
for n in getattr(mod, '__all__', []):
|
|
133
|
+
if n not in name_to_first_part:
|
|
134
|
+
name_to_first_part[n] = part_path
|
|
135
|
+
name_to_parts[n].append(part_path)
|
|
136
|
+
|
|
137
|
+
# Collect the merged module's class objects
|
|
138
|
+
merged_classes: dict[str, type] = {}
|
|
139
|
+
for n in names:
|
|
140
|
+
obj = getattr(merged, n, None)
|
|
141
|
+
if obj is not None and isinstance(obj, type):
|
|
142
|
+
merged_classes[n] = obj
|
|
143
|
+
|
|
144
|
+
# An overlapping dataclass is one where the merged object is NOT the same
|
|
145
|
+
# object as in the first-seen sub-profile (merge_profiles rebuilt it).
|
|
146
|
+
from cimprofiles.merge import _is_profile_dataclass
|
|
147
|
+
overlapping: set[str] = set()
|
|
148
|
+
for n, cls in merged_classes.items():
|
|
149
|
+
if not _is_profile_dataclass(cls) or n == 'Identity':
|
|
150
|
+
continue
|
|
151
|
+
first_mod = importlib.import_module(name_to_first_part[n])
|
|
152
|
+
first_cls = getattr(first_mod, n, None)
|
|
153
|
+
if first_cls is not None and cls is not first_cls:
|
|
154
|
+
overlapping.add(n)
|
|
155
|
+
|
|
156
|
+
# ── Topological order for the inline dataclass section ─────────────────
|
|
157
|
+
# Only overlapping dataclasses need topo sorting (parents before children).
|
|
158
|
+
from cimprofiles.merge import _topo_sort
|
|
159
|
+
class_defs_for_sort: dict[str, list[type]] = {
|
|
160
|
+
n: [merged_classes[n]] for n in overlapping if n in merged_classes
|
|
161
|
+
}
|
|
162
|
+
topo_overlapping = _topo_sort(list(overlapping), class_defs_for_sort)
|
|
163
|
+
|
|
164
|
+
# ── Imports for non-overlapping names ──────────────────────────────────
|
|
165
|
+
part_to_simple: dict[str, list[str]] = defaultdict(list)
|
|
166
|
+
for n in sorted(names):
|
|
167
|
+
if n not in overlapping:
|
|
168
|
+
src = name_to_first_part.get(n, parts[0])
|
|
169
|
+
part_to_simple[src].append(n)
|
|
170
|
+
|
|
171
|
+
import_lines: list[str] = []
|
|
172
|
+
for part_path in parts:
|
|
173
|
+
part_names = part_to_simple.get(part_path, [])
|
|
174
|
+
if not part_names:
|
|
175
|
+
continue
|
|
176
|
+
header = f'from {part_path} import ('
|
|
177
|
+
joined = ', '.join(part_names)
|
|
178
|
+
wrapped_names = textwrap.fill(
|
|
179
|
+
joined,
|
|
180
|
+
width=88,
|
|
181
|
+
initial_indent=' ',
|
|
182
|
+
subsequent_indent=' ',
|
|
183
|
+
break_long_words=False,
|
|
184
|
+
break_on_hyphens=False,
|
|
185
|
+
)
|
|
186
|
+
import_lines.append(f'{header}\n{wrapped_names})')
|
|
187
|
+
|
|
188
|
+
# ── Inline @dataclass section ──────────────────────────────────────────
|
|
189
|
+
# We need 'dataclass' and 'field' in scope for the generated code.
|
|
190
|
+
inline_lines: list[str] = []
|
|
191
|
+
for n in topo_overlapping:
|
|
192
|
+
cls = merged_classes[n]
|
|
193
|
+
# Base: the first real base in the merged class's MRO
|
|
194
|
+
base = cls.__bases__[0]
|
|
195
|
+
base_name = base.__name__
|
|
196
|
+
inline_lines.append('')
|
|
197
|
+
inline_lines.extend(_emit_inline_class(n, cls, base_name))
|
|
198
|
+
|
|
199
|
+
# ── __all__ ────────────────────────────────────────────────────────────
|
|
200
|
+
all_entries = '\n'.join(f" '{n}'," for n in names)
|
|
201
|
+
parts_comment = '\n'.join(f'# {p}' for p in parts)
|
|
202
|
+
|
|
203
|
+
has_inline = bool(inline_lines)
|
|
204
|
+
# Inline @dataclass fields use 'Optional[...]' and 'UUID' in their type
|
|
205
|
+
# annotations. Without these imports Pylance resolves them as Any.
|
|
206
|
+
inline_imports = (
|
|
207
|
+
'from dataclasses import dataclass, field\n'
|
|
208
|
+
'from typing import Optional\n'
|
|
209
|
+
'from uuid import UUID\n'
|
|
210
|
+
if has_inline else ''
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return f'''\
|
|
214
|
+
"""Merged profile: {package}
|
|
215
|
+
|
|
216
|
+
{description}
|
|
217
|
+
|
|
218
|
+
Parts:
|
|
219
|
+
{parts_comment}
|
|
220
|
+
|
|
221
|
+
Generated by scripts/build_merged_profiles.py — do not edit by hand.
|
|
222
|
+
Re-run the script after adding or updating sub-profile parts.
|
|
223
|
+
"""
|
|
224
|
+
from __future__ import annotations
|
|
225
|
+
|
|
226
|
+
{inline_imports}{chr(10).join(import_lines)}
|
|
227
|
+
{''.join(line + chr(10) for line in inline_lines)}
|
|
228
|
+
__all__ = [
|
|
229
|
+
{all_entries}
|
|
230
|
+
]
|
|
231
|
+
'''
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main() -> None:
|
|
235
|
+
parser = argparse.ArgumentParser(description='Build pre-baked merged profile packages.')
|
|
236
|
+
parser.add_argument('--config', type=Path, default=_CONFIG,
|
|
237
|
+
help=f'releases yaml (default: {_CONFIG.name})')
|
|
238
|
+
args = parser.parse_args()
|
|
239
|
+
|
|
240
|
+
releases = _load_config(args.config)
|
|
241
|
+
for release in releases:
|
|
242
|
+
package = release['package']
|
|
243
|
+
|
|
244
|
+
print(f'Building {package} ...')
|
|
245
|
+
content = _build_init(release)
|
|
246
|
+
|
|
247
|
+
# Write to the package __init__.py.
|
|
248
|
+
init_path = _package_dir(package) / '__init__.py'
|
|
249
|
+
init_path.write_text(content, encoding='utf-8')
|
|
250
|
+
print(f' wrote {init_path}')
|
|
251
|
+
|
|
252
|
+
# Smoke-test: the written file must be importable and expose __all__.
|
|
253
|
+
if package in sys.modules:
|
|
254
|
+
del sys.modules[package]
|
|
255
|
+
mod = importlib.import_module(package)
|
|
256
|
+
assert hasattr(mod, '__all__'), f'{package}.__all__ missing after write'
|
|
257
|
+
print(f' ok — {len(mod.__all__)} classes exported')
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == '__main__':
|
|
261
|
+
main()
|