Skip to content

Commit 0ef6919

Browse files
authored
Add generation of CGMES profile class (#35)
- Add generation of CGMESProfile class (including profile URIs) for python - Improve generation of CGMESProfile class for javascript - Add cim namespace to the generated CGMESProfile class - Add method Profile.uris() to CGMESProfile.py to get the list of profile URIs - Add recommended class profile to all generated classes (currently only used for python: sogno-platform/cimpy#39)
2 parents f6969d3 + 42a07c2 commit 0ef6919

9 files changed

+376
-190
lines changed

cimgen/cimgen.py

+116-16
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import textwrap
44
import warnings
5+
import re
56
from time import time
67

78
import xmltodict
@@ -338,7 +339,7 @@ def wrap_and_clean(txt: str, width: int = 120, initial_indent="", subsequent_ind
338339

339340
short_package_name = {}
340341
package_listed_by_short_name = {}
341-
342+
cim_namespace = ""
342343
profiles = {}
343344

344345

@@ -372,7 +373,7 @@ def _entry_types_version_2(rdfs_entry: RDFSEntry) -> list:
372373
entry_types.append("profile_name_v2_4")
373374
if (
374375
rdfs_entry.stereotype() == "http://iec.ch/TC57/NonStandard/UML#attribute" # NOSONAR
375-
and rdfs_entry.label()[0:7] == "baseURI"
376+
and rdfs_entry.label().startswith("entsoeURI")
376377
):
377378
entry_types.append("profile_iri_v2_4")
378379
if rdfs_entry.label() == "shortName":
@@ -402,27 +403,31 @@ def _add_class(classes_map, rdfs_entry):
402403
classes_map[rdfs_entry.label()] = CIMComponentDefinition(rdfs_entry)
403404

404405

405-
def _add_profile_to_packages(profile_name, short_profile_name, profile_iri):
406+
def _add_profile_to_packages(profile_name, short_profile_name, profile_uri_list):
406407
"""
407-
Add or append profile_iri
408+
Add profile_uris
408409
"""
409-
if profile_name not in profiles and profile_iri:
410-
profiles[profile_name] = [profile_iri]
410+
if profile_name not in profiles and profile_uri_list:
411+
profiles[profile_name] = profile_uri_list
411412
else:
412-
profiles[profile_name].append(profile_iri)
413-
if short_profile_name not in package_listed_by_short_name and profile_iri:
414-
package_listed_by_short_name[short_profile_name] = [profile_iri]
413+
profiles[profile_name].extend(profile_uri_list)
414+
if short_profile_name not in package_listed_by_short_name and profile_uri_list:
415+
package_listed_by_short_name[short_profile_name] = profile_uri_list
415416
else:
416-
package_listed_by_short_name[short_profile_name].append(profile_iri)
417+
package_listed_by_short_name[short_profile_name].extend(profile_uri_list)
417418

418419

419420
def _parse_rdf(input_dic, version, lang_pack):
420421
classes_map = {}
421422
profile_name = ""
422-
profile_iri = None
423+
profile_uri_list = []
423424
attributes = []
424425
instances = []
425426

427+
global cim_namespace
428+
if not cim_namespace:
429+
cim_namespace = input_dic["rdf:RDF"].get("$xmlns:cim")
430+
426431
# Generates list with dictionaries as elements
427432
descriptions = input_dic["rdf:RDF"]["rdf:Description"]
428433

@@ -447,13 +452,13 @@ def _parse_rdf(input_dic, version, lang_pack):
447452
if "short_profile_name_v3" in rdfs_entry_types:
448453
short_profile_name = rdfsEntry.keyword()
449454
if "profile_iri_v2_4" in rdfs_entry_types and rdfsEntry.fixed():
450-
profile_iri = rdfsEntry.fixed()
455+
profile_uri_list.append(rdfsEntry.fixed())
451456
if "profile_iri_v3" in rdfs_entry_types:
452-
profile_iri = rdfsEntry.version_iri()
457+
profile_uri_list.append(rdfsEntry.version_iri())
453458

454459
short_package_name[profile_name] = short_profile_name
455460
package_listed_by_short_name[short_profile_name] = []
456-
_add_profile_to_packages(profile_name, short_profile_name, profile_iri)
461+
_add_profile_to_packages(profile_name, short_profile_name, profile_uri_list)
457462
# Add attributes to corresponding class
458463
for attribute in attributes:
459464
clarse = attribute["domain"]
@@ -478,6 +483,9 @@ def _parse_rdf(input_dic, version, lang_pack):
478483
# chevron
479484
def _write_python_files(elem_dict, lang_pack, output_path, version):
480485

486+
# Setup called only once: make output directory, create base class, create profile class, etc.
487+
lang_pack.setup(output_path, _get_profile_details(package_listed_by_short_name), cim_namespace)
488+
481489
float_classes = {}
482490
enum_classes = {}
483491

@@ -491,6 +499,8 @@ def _write_python_files(elem_dict, lang_pack, output_path, version):
491499
lang_pack.set_float_classes(float_classes)
492500
lang_pack.set_enum_classes(enum_classes)
493501

502+
recommended_class_profiles = _get_recommended_class_profiles(elem_dict)
503+
494504
for class_name in elem_dict.keys():
495505

496506
class_details = {
@@ -504,6 +514,7 @@ def _write_python_files(elem_dict, lang_pack, output_path, version):
504514
"langPack": lang_pack,
505515
"sub_class_of": elem_dict[class_name].superClass(),
506516
"sub_classes": elem_dict[class_name].subClasses(),
517+
"recommended_class_profile": recommended_class_profiles[class_name],
507518
}
508519

509520
# extract comments
@@ -547,8 +558,6 @@ def format_class(_range, _dataType):
547558

548559

549560
def _write_files(class_details, output_path, version):
550-
class_details["langPack"].setup(output_path, package_listed_by_short_name)
551-
552561
if class_details["sub_class_of"] is None:
553562
# If class has no subClassOf key it is a subclass of the Base class
554563
class_details["sub_class_of"] = class_details["langPack"].base["base_class"]
@@ -762,3 +771,94 @@ def cim_generate(directory, output_path, version, lang_pack):
762771
lang_pack.resolve_headers(output_path)
763772

764773
logger.info("Elapsed Time: {}s\n\n".format(time() - t0))
774+
775+
776+
def _get_profile_details(cgmes_profile_uris):
777+
profile_details = []
778+
sorted_profile_keys = _get_sorted_profile_keys(cgmes_profile_uris.keys())
779+
for index, profile in enumerate(sorted_profile_keys):
780+
profile_info = {
781+
"index": index,
782+
"short_name": profile,
783+
"long_name": _extract_profile_long_name(cgmes_profile_uris[profile]),
784+
"uris": [{"uri": uri} for uri in cgmes_profile_uris[profile]],
785+
}
786+
profile_details.append(profile_info)
787+
return profile_details
788+
789+
790+
def _extract_profile_long_name(profile_uris):
791+
# Extract name from uri, e.g. "Topology" from "http://iec.ch/TC57/2013/61970-456/Topology/4"
792+
# Examples of other possible uris: "http://entsoe.eu/CIM/Topology/4/1", "http://iec.ch/TC57/ns/CIM/Topology-EU/3.0"
793+
# If more than one uri given, extract common part (e.g. "Equipment" from "EquipmentCore" and "EquipmentOperation")
794+
long_name = ""
795+
for uri in profile_uris:
796+
match = re.search(r"/([^/-]*)(-[^/]*)?(/\d+)?/[\d.]+?$", uri)
797+
if match:
798+
name = match.group(1)
799+
if long_name:
800+
for idx in range(1, len(long_name)):
801+
if idx >= len(name) or long_name[idx] != name[idx]:
802+
long_name = long_name[:idx]
803+
break
804+
else:
805+
long_name = name
806+
return long_name
807+
808+
809+
def _get_sorted_profile_keys(profile_key_list):
810+
"""Sort profiles alphabetically, but "EQ" to the first place.
811+
812+
Profiles should be always used in the same order when they are written into the enum class Profile.
813+
The same order should be used if one of several possible profiles is to be selected.
814+
815+
:param profile_key_list: List of short profile names.
816+
:return: Sorted list of short profile names.
817+
"""
818+
return sorted(profile_key_list, key=lambda x: x == "EQ" and "0" or x)
819+
820+
821+
def _get_recommended_class_profiles(elem_dict):
822+
"""Get the recommended profiles for all classes.
823+
824+
This function searches for the recommended profile of each class.
825+
If the class contains attributes for different profiles not all data of the object could be written into one file.
826+
To write the data to as few as possible files the class profile should be that with most of the attributes.
827+
But some classes contain a lot of rarely used special attributes, i.e. attributes for a special profile
828+
(e.g. TopologyNode has many attributes for TopologyBoundary, but the class profile should be Topology).
829+
That's why attributes that only belong to one profile are skipped in the search algorithm.
830+
831+
:param elem_dict: Information about all classes.
832+
Used are here possible class profiles (elem_dict[class_name].origins()),
833+
possible attribute profiles (elem_dict[class_name].attributes()[*]["attr_origin"])
834+
and the superclass of each class (elem_dict[class_name].superClass()).
835+
:return: Mapping of class to profile.
836+
"""
837+
recommended_class_profiles = {}
838+
for class_name in elem_dict.keys():
839+
class_origin = elem_dict[class_name].origins()
840+
class_profiles = [origin["origin"] for origin in class_origin]
841+
if len(class_profiles) == 1:
842+
recommended_class_profiles[class_name] = class_profiles[0]
843+
continue
844+
845+
# Count profiles of all attributes of this class and its superclasses
846+
profile_count_map = {}
847+
name = class_name
848+
while name:
849+
for attribute in _find_multiple_attributes(elem_dict[name].attributes()):
850+
profiles = [origin["origin"] for origin in attribute["attr_origin"]]
851+
ambiguous_profile = len(profiles) > 1
852+
for profile in profiles:
853+
if ambiguous_profile and profile in class_profiles:
854+
profile_count_map.setdefault(profile, []).append(attribute["label"])
855+
name = elem_dict[name].superClass()
856+
857+
# Set the profile with most attributes as recommended profile for this class
858+
if profile_count_map:
859+
max_count = max(len(v) for v in profile_count_map.values())
860+
filtered_profiles = [k for k, v in profile_count_map.items() if len(v) == max_count]
861+
recommended_class_profiles[class_name] = _get_sorted_profile_keys(filtered_profiles)[0]
862+
else:
863+
recommended_class_profiles[class_name] = _get_sorted_profile_keys(class_profiles)[0]
864+
return recommended_class_profiles

cimgen/languages/cpp/lang_pack.py

+28-20
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ def location(version):
88
return "BaseClass.hpp"
99

1010

11+
# Setup called only once: make output directory, create base class, create profile class, etc.
1112
# This just makes sure we have somewhere to write the classes.
12-
# cgmes_profile_info details which uri belongs in each profile.
13+
# cgmes_profile_details contains index, names and uris for each profile.
1314
# We don't use that here because we aren't exporting into
1415
# separate profiles.
15-
def setup(version_path, cgmes_profile_info):
16-
if not os.path.exists(version_path):
17-
os.makedirs(version_path)
16+
def setup(output_path: str, cgmes_profile_details: list, cim_namespace: str):
17+
if not os.path.exists(output_path):
18+
os.makedirs(output_path)
19+
else:
20+
for filename in os.listdir(output_path):
21+
os.remove(os.path.join(output_path, filename))
1822

1923

2024
base = {"base_class": "BaseClass", "class_location": location}
@@ -53,7 +57,7 @@ def get_class_location(class_name, class_map, version):
5357

5458

5559
# This is the function that runs the template.
56-
def run_template(outputPath, class_details):
60+
def run_template(output_path, class_details):
5761

5862
if class_details["is_a_float"]:
5963
templates = float_template_files
@@ -72,18 +76,22 @@ def run_template(outputPath, class_details):
7276
return
7377

7478
for template_info in templates:
75-
class_file = os.path.join(outputPath, class_details["class_name"] + template_info["ext"])
76-
if not os.path.exists(class_file):
77-
with open(class_file, "w", encoding="utf-8") as file:
78-
templates = files("cimgen.languages.cpp.templates")
79-
with templates.joinpath(template_info["filename"]).open(encoding="utf-8") as f:
80-
args = {
81-
"data": class_details,
82-
"template": f,
83-
"partials_dict": partials,
84-
}
85-
output = chevron.render(**args)
86-
file.write(output)
79+
class_file = os.path.join(output_path, class_details["class_name"] + template_info["ext"])
80+
_write_templated_file(class_file, class_details, template_info["filename"])
81+
82+
83+
def _write_templated_file(class_file, class_details, template_filename):
84+
with open(class_file, "w", encoding="utf-8") as file:
85+
class_details["setDefault"] = _set_default
86+
templates = files("cimgen.languages.cpp.templates")
87+
with templates.joinpath(template_filename).open(encoding="utf-8") as f:
88+
args = {
89+
"data": class_details,
90+
"template": f,
91+
"partials_dict": partials,
92+
}
93+
output = chevron.render(**args)
94+
file.write(output)
8795

8896

8997
# This function just allows us to avoid declaring a variable called 'switch',
@@ -491,7 +499,7 @@ def _create_header_include_file(directory, header_include_filename, header, foot
491499
f.writelines(header)
492500

493501

494-
def resolve_headers(outputPath):
502+
def resolve_headers(output_path):
495503
class_list_header = [
496504
"#ifndef CIMCLASSLIST_H\n",
497505
"#define CIMCLASSLIST_H\n",
@@ -505,7 +513,7 @@ def resolve_headers(outputPath):
505513
]
506514

507515
_create_header_include_file(
508-
outputPath,
516+
output_path,
509517
"CIMClassList.hpp",
510518
class_list_header,
511519
class_list_footer,
@@ -518,7 +526,7 @@ def resolve_headers(outputPath):
518526
iec61970_footer = ['#include "UnknownType.hpp"\n', "#endif"]
519527

520528
_create_header_include_file(
521-
outputPath,
529+
output_path,
522530
"IEC61970.hpp",
523531
iec61970_header,
524532
iec61970_footer,

cimgen/languages/java/lang_pack.py

+28-21
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,22 @@ def location(version):
88
return "BaseClass"
99

1010

11+
# Setup called only once: make output directory, create base class, create profile class, etc.
1112
# This just makes sure we have somewhere to write the classes.
12-
# cgmes_profile_info details which uri belongs in each profile.
13+
# cgmes_profile_details contains index, names und uris for each profile.
1314
# We don't use that here because we aren't exporting into
1415
# separate profiles.
15-
def setup(version_path, cgmes_profile_info):
16-
if not os.path.exists(version_path):
17-
os.makedirs(version_path)
16+
def setup(output_path: str, cgmes_profile_details: list, cim_namespace: str):
17+
if not os.path.exists(output_path):
18+
os.makedirs(output_path)
19+
else:
20+
for filename in os.listdir(output_path):
21+
os.remove(os.path.join(output_path, filename))
1822

1923

2024
base = {"base_class": "BaseClass", "class_location": location}
2125

22-
# These are the files that are used to generate the header and object files.
26+
# These are the files that are used to generate the java files.
2327
# There is a template set for the large number of classes that are floats. They
2428
# have unit, multiplier and value attributes in the schema, but only appear in
2529
# the file as a float string.
@@ -41,7 +45,7 @@ def get_class_location(class_name, class_map, version):
4145

4246

4347
# This is the function that runs the template.
44-
def run_template(outputPath, class_details):
48+
def run_template(output_path, class_details):
4549

4650
class_details["primitives"] = []
4751
for attr in class_details["attributes"]:
@@ -64,19 +68,22 @@ def run_template(outputPath, class_details):
6468
return
6569

6670
for template_info in templates:
67-
class_file = os.path.join(outputPath, class_details["class_name"] + template_info["ext"])
68-
if not os.path.exists(class_file):
69-
with open(class_file, "w", encoding="utf-8") as file:
70-
class_details["setDefault"] = _set_default
71-
templates = files("cimgen.languages.java.templates")
72-
with templates.joinpath(template_info["filename"]).open(encoding="utf-8") as f:
73-
args = {
74-
"data": class_details,
75-
"template": f,
76-
"partials_dict": partials,
77-
}
78-
output = chevron.render(**args)
79-
file.write(output)
71+
class_file = os.path.join(output_path, class_details["class_name"] + template_info["ext"])
72+
_write_templated_file(class_file, class_details, template_info["filename"])
73+
74+
75+
def _write_templated_file(class_file, class_details, template_filename):
76+
with open(class_file, "w", encoding="utf-8") as file:
77+
class_details["setDefault"] = _set_default
78+
templates = files("cimgen.languages.java.templates")
79+
with templates.joinpath(template_filename).open(encoding="utf-8") as f:
80+
args = {
81+
"data": class_details,
82+
"template": f,
83+
"partials_dict": partials,
84+
}
85+
output = chevron.render(**args)
86+
file.write(output)
8087

8188

8289
# This function just allows us to avoid declaring a variable called 'switch',
@@ -416,7 +423,7 @@ def _create_header_include_file(directory, header_include_filename, header, foot
416423
f.writelines(header)
417424

418425

419-
def resolve_headers(outputPath):
426+
def resolve_headers(output_path):
420427
class_list_header = [
421428
"package cim4j;\n",
422429
"import java.util.Map;\n",
@@ -432,7 +439,7 @@ def resolve_headers(outputPath):
432439
class_list_footer = [" );\n", "}\n"]
433440

434441
_create_header_include_file(
435-
outputPath,
442+
output_path,
436443
"CIMClassMap.java",
437444
class_list_header,
438445
class_list_footer,

0 commit comments

Comments
 (0)