gw_data 0.3.0__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.
- gw_data/__init__.py +4 -0
- gw_data/config.py +19 -0
- gw_data/db/__init__.py +0 -0
- gw_data/db/models/__init__.py +37 -0
- gw_data/db/models/_base.py +5 -0
- gw_data/db/models/connectivity_edge.py +80 -0
- gw_data/db/models/customer.py +18 -0
- gw_data/db/models/g_node.py +83 -0
- gw_data/db/models/installation.py +52 -0
- gw_data/db/models/installer.py +17 -0
- gw_data/db/models/message.py +51 -0
- gw_data/db/models/position_point.py +46 -0
- gw_data/db/models/reading.py +55 -0
- gw_data/db/models/reading_channel.py +62 -0
- gw_data/db/models/user.py +35 -0
- gw_data/db/models/user_installation_role.py +38 -0
- gw_data/db/scripts/0_db_create.psql +4 -0
- gw_data/db/scripts/1_db_user_setup.psql +16 -0
- gw_data/db/scripts/2_db_schema_setup.sql +11 -0
- gw_data/db/scripts/3_db_alembic_upgrade.sh +2 -0
- gw_data/db/scripts/4_db_seed.py +129 -0
- gw_data/db/scripts/_XX_drop_all.sql +13 -0
- gw_data/db/scripts/seed_data/homes.csv +6 -0
- gw_data/db/session.py +0 -0
- gw_data/sema/__init__.py +15 -0
- gw_data/sema/base.py +184 -0
- gw_data/sema/codec.py +168 -0
- gw_data/sema/definitions/enums/base.g.node.class/000.yaml +55 -0
- gw_data/sema/definitions/enums/g.node.status/000.yaml +35 -0
- gw_data/sema/definitions/formats/left.right.dot.yaml +27 -0
- gw_data/sema/definitions/formats/uuid4.str.yaml +29 -0
- gw_data/sema/definitions/registry.yaml +77 -0
- gw_data/sema/definitions/types/g.node.gt/004.yaml +169 -0
- gw_data/sema/definitions/types/position.point.gt/000.yaml +86 -0
- gw_data/sema/enums/__init__.py +7 -0
- gw_data/sema/enums/base_g_node_class.py +29 -0
- gw_data/sema/enums/g_node_status.py +28 -0
- gw_data/sema/enums/gw_str_enum.py +97 -0
- gw_data/sema/enums/old_versions/__init__.py +0 -0
- gw_data/sema/indexes/dependency_closure.yaml +19 -0
- gw_data/sema/indexes/local_names.yaml +6 -0
- gw_data/sema/indexes/lookup.yaml +35 -0
- gw_data/sema/indexes/reverse_dependencies.yaml +21 -0
- gw_data/sema/indexes/seed_expanded.yaml +29 -0
- gw_data/sema/indexes/versions.yaml +31 -0
- gw_data/sema/property_format.py +56 -0
- gw_data/sema/tests/test_property_format.py +47 -0
- gw_data/sema/types/__init__.py +7 -0
- gw_data/sema/types/g_node_gt.py +106 -0
- gw_data/sema/types/old_versions/__init__.py +0 -0
- gw_data/sema/types/position_point_gt.py +31 -0
- gw_data-0.3.0.dist-info/METADATA +189 -0
- gw_data-0.3.0.dist-info/RECORD +55 -0
- gw_data-0.3.0.dist-info/WHEEL +4 -0
- gw_data-0.3.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import dotenv
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import create_engine
|
|
8
|
+
from sqlalchemy.orm import sessionmaker
|
|
9
|
+
from getpass import getpass
|
|
10
|
+
from passlib.context import CryptContext
|
|
11
|
+
|
|
12
|
+
from gw_data.config import Settings
|
|
13
|
+
from gw_data.db.models.user import UserSql
|
|
14
|
+
from gw_data.db.models.user_installation_role import UserInstallationRoleSql
|
|
15
|
+
from gw_data.sema.enums import BaseGNodeClass, GNodeStatus
|
|
16
|
+
from gw_data.db.models import GNodeSql, CustomerSql, InstallerSql, InstallationSql
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
dotenv.load_dotenv()
|
|
20
|
+
settings = Settings()
|
|
21
|
+
engine = create_engine(settings.db_url.get_secret_value(), echo=True)
|
|
22
|
+
|
|
23
|
+
db_sessionmaker = sessionmaker(bind=engine)
|
|
24
|
+
db_session = db_sessionmaker()
|
|
25
|
+
|
|
26
|
+
default_installer = InstallerSql(
|
|
27
|
+
id = uuid.uuid4(),
|
|
28
|
+
info = json.dumps({
|
|
29
|
+
"company_name": "Millinocket Heating Installers"
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
db_session.add(default_installer)
|
|
33
|
+
|
|
34
|
+
beech_id = None
|
|
35
|
+
file_path = os.path.join(os.path.dirname(__file__), './seed_data/homes.csv')
|
|
36
|
+
with open(file_path, newline='') as csvfile:
|
|
37
|
+
csv_reader = csv.reader(csvfile, delimiter=',', quotechar='"')
|
|
38
|
+
next(csv_reader) # Skip the header row
|
|
39
|
+
for row in csv_reader:
|
|
40
|
+
# "short_alias","address","primary_contact","secondary_contact","hardware_layout","unique_id","g_node_alias","alert_status","representation_status","scada_ip_address","scada_git_commit","house_parameters","created_at"
|
|
41
|
+
(short_alias, address_json, primary_contact_json, secondary_contact_json, hardware_layout_json, unique_id, g_node_alias, alert_status_json, representation_status_json, scada_ip_address, scada_git_commit, house_parameters_json, created_at) = row
|
|
42
|
+
|
|
43
|
+
g_node = GNodeSql(
|
|
44
|
+
id = uuid.uuid4(),
|
|
45
|
+
alias = g_node_alias,
|
|
46
|
+
base_class = None,
|
|
47
|
+
g_node_class = BaseGNodeClass.LeafTransactiveNode,
|
|
48
|
+
status = GNodeStatus.Active,
|
|
49
|
+
display_name = None,
|
|
50
|
+
created_at = created_at
|
|
51
|
+
)
|
|
52
|
+
db_session.add(g_node)
|
|
53
|
+
|
|
54
|
+
customer = CustomerSql(
|
|
55
|
+
id = uuid.uuid4(),
|
|
56
|
+
primary_contact = json.loads(primary_contact_json),
|
|
57
|
+
secondary_contact = json.loads(secondary_contact_json),
|
|
58
|
+
)
|
|
59
|
+
db_session.add(customer)
|
|
60
|
+
|
|
61
|
+
installation = InstallationSql(
|
|
62
|
+
id = uuid.uuid4(),
|
|
63
|
+
g_node_id = g_node.id,
|
|
64
|
+
display_name = short_alias,
|
|
65
|
+
customer_id = customer.id,
|
|
66
|
+
installer_id = default_installer.id,
|
|
67
|
+
address = json.loads(address_json),
|
|
68
|
+
alert_status = json.loads(alert_status_json),
|
|
69
|
+
hardware_layout = json.loads(hardware_layout_json),
|
|
70
|
+
representation_status = json.loads(representation_status_json),
|
|
71
|
+
house_parameters = json.loads(house_parameters_json),
|
|
72
|
+
scada_ip_address = scada_ip_address,
|
|
73
|
+
scada_git_commit = scada_git_commit
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if installation.display_name == 'beech':
|
|
77
|
+
beech_id = installation.id
|
|
78
|
+
|
|
79
|
+
db_session.add(installation)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
gbo_pwd_context = CryptContext(
|
|
83
|
+
schemes=["bcrypt"],
|
|
84
|
+
deprecated="auto",
|
|
85
|
+
bcrypt__rounds=12,
|
|
86
|
+
bcrypt__ident="2b"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
admin_pw = None
|
|
91
|
+
while admin_pw is None:
|
|
92
|
+
admin_pw = getpass('Enter a password for the user "admin":')
|
|
93
|
+
admin_pw_confirmed = getpass('Enter again to confirm:')
|
|
94
|
+
if admin_pw != admin_pw_confirmed:
|
|
95
|
+
admin_pw = None
|
|
96
|
+
|
|
97
|
+
admin = UserSql(
|
|
98
|
+
id = uuid.uuid4(),
|
|
99
|
+
username = "admin",
|
|
100
|
+
hashed_password = gbo_pwd_context.hash(admin_pw)
|
|
101
|
+
)
|
|
102
|
+
db_session.add(admin)
|
|
103
|
+
|
|
104
|
+
beech_user_pw = None
|
|
105
|
+
while beech_user_pw is None:
|
|
106
|
+
beech_user_pw = getpass('Enter a password for the user "beech-user":')
|
|
107
|
+
beech_user_pw_confirmed = getpass('Enter again to confirm:')
|
|
108
|
+
if beech_user_pw != beech_user_pw_confirmed:
|
|
109
|
+
beech_user_pw = None
|
|
110
|
+
|
|
111
|
+
beech_user = UserSql(
|
|
112
|
+
id = uuid.uuid4(),
|
|
113
|
+
username = "beech-user",
|
|
114
|
+
hashed_password = gbo_pwd_context.hash(beech_user_pw)
|
|
115
|
+
)
|
|
116
|
+
db_session.add(beech_user)
|
|
117
|
+
|
|
118
|
+
db_session.add(UserInstallationRoleSql(
|
|
119
|
+
user_id = admin.id,
|
|
120
|
+
role = "admin"
|
|
121
|
+
))
|
|
122
|
+
|
|
123
|
+
db_session.add(UserInstallationRoleSql(
|
|
124
|
+
user_id = beech_user.id,
|
|
125
|
+
role = "owner",
|
|
126
|
+
installation_id = beech_id
|
|
127
|
+
))
|
|
128
|
+
|
|
129
|
+
db_session.commit()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
--DROP DATABASE gridworks WITH (FORCE);
|
|
2
|
+
|
|
3
|
+
-- This line may have to be done by gw_admin
|
|
4
|
+
DROP SCHEMA gridworks;
|
|
5
|
+
|
|
6
|
+
REVOKE CONNECT ON DATABASE tsdb FROM gw_visualizer;
|
|
7
|
+
DROP USER gw_visualizer;
|
|
8
|
+
|
|
9
|
+
REVOKE CONNECT ON DATABASE tsdb FROM gw_journalkeeper;
|
|
10
|
+
DROP USER gw_journalkeeper;
|
|
11
|
+
|
|
12
|
+
REVOKE ALL ON DATABASE tsdb FROM gw_admin;
|
|
13
|
+
DROP USER gw_admin;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"short_alias","address","primary_contact","secondary_contact","hardware_layout","unique_id","g_node_alias","alert_status","representation_status","scada_ip_address","scada_git_commit","house_parameters","created_at"
|
|
2
|
+
"beech","{""street"": ""123 Beech Street"", ""city"": ""Millinocket"", ""state"": ""ME"", ""zip"": ""04462"", ""country"": ""USA"", ""latitude"": 45.65100, ""longitude"": -68.69}","{""first_name"": ""Bradley"", ""last_name"": ""Beech"", ""phone"": ""555-987-6543"", ""email"": null}","null","null",1,"hw1.isone.me.versant.keene.beech","{""status"": ""ok"", ""message"": null, ""acked"": null, ""acked_by"": null, ""acked_at"": null}","""NotListeningToAtn""","127.0.0.1","unknown","{""alpha"": 8.6, ""beta"": -0.14, ""gamma"": 0.0029, ""intermediate_power_kw"": 3.6, ""intermediate_rswt"": 105.0, ""dd_power_kw"": 8.6, ""dd_rswt"": 140.0, ""dd_delta_t"": 20.0}","2026-01-23 07:04:32.477627-06"
|
|
3
|
+
"oak","{""street"": ""456 Oak Street"", ""city"": ""Millinocket"", ""state"": ""ME"", ""zip"": ""04462"", ""country"": ""USA"", ""latitude"": 45.65200, ""longitude"": -68.65}","{""first_name"": ""Brennan"", ""last_name"": ""Oak"", ""phone"": ""828-674-6164"", ""email"": null}","{""first_name"": ""Carla"", ""last_name"": null, ""phone"": null, ""email"": null}","null",2,"hw1.isone.me.versant.keene.oak","{""status"": ""ok"", ""message"": null, ""acked"": null, ""acked_by"": null, ""acked_at"": null}","""NotListeningToAtn""","127.0.0.1","unknown","{""alpha"": 7.6, ""beta"": -0.13, ""gamma"": 0.0024, ""intermediate_power_kw"": 1.5, ""intermediate_rswt"": 100.0, ""dd_power_kw"": 7.6, ""dd_rswt"": 140.0, ""dd_delta_t"": 20.0}","2026-01-23 07:04:32.477627-06"
|
|
4
|
+
"fir","{""street"": ""7890 Fir Road"", ""city"": ""Millinocket"", ""state"": ""ME"", ""zip"": ""00130"", ""country"": ""USA"", ""latitude"": 45.64500, ""longitude"": -68.72}","{""first_name"": ""Renee"", ""last_name"": ""Fir"", ""phone"": ""207-754-3256"", ""email"": ""reneefir@example.com""}","{""first_name"": ""Kim"", ""last_name"": ""FirFir"", ""phone"": ""207-210-0452"", ""email"": null}","null",3,"hw1.isone.me.versant.keene.fir","{""status"": ""ok"", ""message"": null, ""acked"": null, ""acked_by"": null, ""acked_at"": null}","""NotListeningToAtn""","127.0.0.1","unknown","{""alpha"": 7.5, ""beta"": -0.13, ""gamma"": 0.0022, ""intermediate_power_kw"": 1.5, ""intermediate_rswt"": 94.0, ""dd_power_kw"": 7.5, ""dd_rswt"": 150.0, ""dd_delta_t"": 20.0}","2026-01-23 07:04:32.477627-06"
|
|
5
|
+
"maple","{""street"": ""101 Maple Road"", ""city"": ""Millinocket"", ""state"": ""ME"", ""zip"": ""04462"", ""country"": ""USA"", ""latitude"": 45.64400, ""longitude"": -68.73}","{""first_name"": ""Herman"", ""last_name"": ""Maple"", ""phone"": ""555-444-3333"", ""email"": null}","null","null",4,"hw1.isone.me.versant.keene.maple","{""status"": ""ok"", ""message"": null, ""acked"": null, ""acked_by"": null, ""acked_at"": null}","""NotListeningToAtn""","127.0.0.1","unknown","{""alpha"": 6.3, ""beta"": -0.09, ""gamma"": 0.0007, ""intermediate_power_kw"": 1.5, ""intermediate_rswt"": 118.0, ""dd_power_kw"": 6.3, ""dd_rswt"": 160.0, ""dd_delta_t"": 20.0}","2026-01-23 07:04:32.477627-06"
|
|
6
|
+
"elm","{""street"": ""202 Elm Lane"", ""city"": ""Millinocket"", ""state"": ""ME"", ""zip"": ""04462"", ""country"": ""USA"", ""latitude"": 45.6800, ""longitude"": -68.71}","{""first_name"": ""Mike"", ""last_name"": ""Elm"", ""phone"": ""555-666-7777"", ""email"": null}","{""first_name"": ""Michelle"", ""last_name"": ""Elm"", ""phone"": null, ""email"": null}","null",5,"hw1.isone.me.versant.keene.elm","{""status"": ""ok"", ""message"": null, ""acked"": null, ""acked_by"": null, ""acked_at"": null}","""NotListeningToAtn""","127.0.0.1","unknown","{""alpha"": 7.0, ""beta"": -0.13, ""gamma"": 0.001, ""intermediate_power_kw"": 1.5, ""intermediate_rswt"": 120.0, ""dd_power_kw"": 7.0, ""dd_rswt"": 150.0, ""dd_delta_t"": 20.0}","2026-01-23 07:04:32.477627-06"
|
gw_data/db/session.py
ADDED
|
File without changes
|
gw_data/sema/__init__.py
ADDED
gw_data/sema/base.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Self, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# ============================================================================
|
|
9
|
+
# UTILITY FUNCTIONS
|
|
10
|
+
# ============================================================================
|
|
11
|
+
|
|
12
|
+
snake_add_underscore_to_camel_pattern = re.compile(r"(?<!^)(?=[A-Z])")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_pascal_case(s: str) -> bool:
|
|
16
|
+
return re.match(r"^[A-Z][a-zA-Z0-9]*$", s) is not None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def recursively_pascal(d: dict) -> bool:
|
|
20
|
+
if isinstance(d, dict):
|
|
21
|
+
for key, value in d.items():
|
|
22
|
+
if key and key[0].isalpha() and not is_pascal_case(key):
|
|
23
|
+
return False
|
|
24
|
+
if not recursively_pascal(value):
|
|
25
|
+
return False
|
|
26
|
+
elif isinstance(d, list):
|
|
27
|
+
for item in d:
|
|
28
|
+
if not recursively_pascal(item):
|
|
29
|
+
return False
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pascal_to_snake(name: str) -> str:
|
|
34
|
+
return snake_add_underscore_to_camel_pattern.sub("_", name).lower()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def snake_to_pascal(word: str) -> str:
|
|
38
|
+
return "".join(x.capitalize() or "_" for x in word.split("_"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ============================================================================
|
|
42
|
+
# BASE EXCEPTIONS
|
|
43
|
+
# ============================================================================
|
|
44
|
+
|
|
45
|
+
class SemaError(Exception):
|
|
46
|
+
"""Base exception for Sema-related errors."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
T = TypeVar("T", bound="SemaType")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ============================================================================
|
|
53
|
+
# STRICT SEMA TYPE
|
|
54
|
+
# ============================================================================
|
|
55
|
+
|
|
56
|
+
class SemaType(BaseModel):
|
|
57
|
+
"""
|
|
58
|
+
Base class for strict Sema types.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
type_name: str
|
|
62
|
+
version: str | None = None
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(
|
|
65
|
+
alias_generator=snake_to_pascal,
|
|
66
|
+
frozen=True,
|
|
67
|
+
populate_by_name=True,
|
|
68
|
+
extra="forbid",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------------
|
|
72
|
+
# Serialization
|
|
73
|
+
# ------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def to_bytes(self) -> bytes:
|
|
76
|
+
return self.model_dump_json(exclude_none=True, by_alias=True).encode()
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
return self.model_dump(exclude_none=True, by_alias=True)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_bytes(cls, json_bytes: bytes) -> Self:
|
|
83
|
+
try:
|
|
84
|
+
d = json.loads(json_bytes)
|
|
85
|
+
except TypeError as e:
|
|
86
|
+
raise SemaError("Type must be string or bytes!") from e
|
|
87
|
+
return cls.from_dict(d)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_dict(cls, d: dict) -> Self:
|
|
91
|
+
if not recursively_pascal(d):
|
|
92
|
+
raise SemaError("Dictionary must be recursively PascalCase")
|
|
93
|
+
try:
|
|
94
|
+
return cls.model_validate(d)
|
|
95
|
+
except ValidationError as e:
|
|
96
|
+
raise SemaError(f"Validation failed: {e}") from e
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------------
|
|
99
|
+
# Introspection
|
|
100
|
+
# ------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def type_name_value(cls) -> str:
|
|
104
|
+
return cls.model_fields["type_name"].default
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def version_value(cls) -> str | None:
|
|
108
|
+
return cls.model_fields["version"].default
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------------
|
|
111
|
+
# Versioning
|
|
112
|
+
# ------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def upgrade(self) -> "SemaType":
|
|
115
|
+
raise NotImplementedError(
|
|
116
|
+
f"{self.__class__.__name__} does not implement upgrade()"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def to_latest(self, registry: dict[str, type["SemaType"]]) -> "SemaType":
|
|
120
|
+
current = self
|
|
121
|
+
type_name = self.type_name_value()
|
|
122
|
+
|
|
123
|
+
if type_name not in registry:
|
|
124
|
+
raise SemaError(f"No registry entry for {type_name}")
|
|
125
|
+
|
|
126
|
+
latest_cls = registry[type_name]
|
|
127
|
+
latest_version_str = latest_cls.version_value()
|
|
128
|
+
|
|
129
|
+
if current.version is None or latest_version_str is None:
|
|
130
|
+
raise SemaError(f"Version missing for {type_name}")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
current_version_int = int(current.version)
|
|
134
|
+
latest_version_int = int(latest_version_str)
|
|
135
|
+
except ValueError:
|
|
136
|
+
raise SemaError(f"Invalid version format for {type_name}")
|
|
137
|
+
|
|
138
|
+
if current_version_int > latest_version_int:
|
|
139
|
+
raise SemaError(
|
|
140
|
+
f"Current version {current.version} is greater than latest {latest_version_str}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
max_steps = latest_version_int - current_version_int
|
|
144
|
+
steps = 0
|
|
145
|
+
|
|
146
|
+
while current.version != latest_version_str:
|
|
147
|
+
if steps >= max_steps:
|
|
148
|
+
raise SemaError(
|
|
149
|
+
f"Upgrade loop detected for {type_name}: exceeded {max_steps} steps"
|
|
150
|
+
)
|
|
151
|
+
current = current.upgrade()
|
|
152
|
+
steps += 1
|
|
153
|
+
|
|
154
|
+
return current
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ============================================================================
|
|
158
|
+
# DEGRADED TYPE
|
|
159
|
+
# ============================================================================
|
|
160
|
+
|
|
161
|
+
class DegradedSemaType:
|
|
162
|
+
"""
|
|
163
|
+
Best-effort decoded Sema-like object.
|
|
164
|
+
|
|
165
|
+
This is NOT a valid SemaType and MUST NOT be used for control logic.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
type_name: str,
|
|
172
|
+
version: str | None,
|
|
173
|
+
raw: dict[str, Any],
|
|
174
|
+
known_fields: dict[str, Any],
|
|
175
|
+
unknown_fields: dict[str, Any],
|
|
176
|
+
):
|
|
177
|
+
self.type_name = type_name
|
|
178
|
+
self.version = version
|
|
179
|
+
self.raw = raw
|
|
180
|
+
self.known_fields = known_fields
|
|
181
|
+
self.unknown_fields = unknown_fields
|
|
182
|
+
|
|
183
|
+
def to_dict(self) -> dict[str, Any]:
|
|
184
|
+
return self.raw
|
gw_data/sema/codec.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from gw_data.sema.base import (
|
|
10
|
+
DegradedSemaType,
|
|
11
|
+
SemaType,
|
|
12
|
+
pascal_to_snake,
|
|
13
|
+
recursively_pascal,
|
|
14
|
+
snake_to_pascal,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SemaCodec:
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.registry = get_current_types()
|
|
24
|
+
self.old_versions = get_old_versions()
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------------
|
|
27
|
+
# Decode
|
|
28
|
+
# ------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def from_dict(
|
|
31
|
+
self,
|
|
32
|
+
data: dict,
|
|
33
|
+
mode: Literal["strict", "degraded"] = "strict",
|
|
34
|
+
auto_upgrade: bool = True
|
|
35
|
+
) -> SemaType | DegradedSemaType:
|
|
36
|
+
|
|
37
|
+
if not isinstance(data, dict):
|
|
38
|
+
raise ValueError("Input must be dict")
|
|
39
|
+
|
|
40
|
+
if "TypeName" not in data:
|
|
41
|
+
raise ValueError("Missing TypeName")
|
|
42
|
+
|
|
43
|
+
if not recursively_pascal(data := dict(data)):
|
|
44
|
+
raise ValueError("Input must be PascalCase")
|
|
45
|
+
|
|
46
|
+
type_name = data["TypeName"]
|
|
47
|
+
version = data.get("Version")
|
|
48
|
+
|
|
49
|
+
if type_name not in self.registry:
|
|
50
|
+
if mode == "degraded":
|
|
51
|
+
return DegradedSemaType(
|
|
52
|
+
type_name=type_name,
|
|
53
|
+
version=version,
|
|
54
|
+
raw=data,
|
|
55
|
+
known_fields={},
|
|
56
|
+
unknown_fields=data,
|
|
57
|
+
)
|
|
58
|
+
raise ValueError(f"Unknown type {type_name}")
|
|
59
|
+
|
|
60
|
+
current_cls = self.registry[type_name]
|
|
61
|
+
current_version = current_cls.version_value()
|
|
62
|
+
|
|
63
|
+
# Fast path
|
|
64
|
+
if version == current_version:
|
|
65
|
+
return current_cls.from_dict(data)
|
|
66
|
+
|
|
67
|
+
# Old version
|
|
68
|
+
if (
|
|
69
|
+
type_name in self.old_versions
|
|
70
|
+
and version in self.old_versions[type_name]
|
|
71
|
+
):
|
|
72
|
+
old_cls = self.old_versions[type_name][version]
|
|
73
|
+
old_instance = old_cls.from_dict(data)
|
|
74
|
+
return old_instance.to_latest(self.registry) if auto_upgrade else old_instance
|
|
75
|
+
|
|
76
|
+
# Unknown version
|
|
77
|
+
if mode == "strict":
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"Unsupported version {version} for {type_name}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------
|
|
83
|
+
# DEGRADED MODE
|
|
84
|
+
# --------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
logger.warning(
|
|
87
|
+
"Degraded decode for %s v%s (current v%s)",
|
|
88
|
+
type_name,
|
|
89
|
+
version,
|
|
90
|
+
current_version,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
valid_fields = set()
|
|
94
|
+
for field_name, field_info in current_cls.model_fields.items():
|
|
95
|
+
valid_fields.add(snake_to_pascal(field_name))
|
|
96
|
+
valid_fields.add(field_name)
|
|
97
|
+
if field_info.alias:
|
|
98
|
+
valid_fields.add(field_info.alias)
|
|
99
|
+
|
|
100
|
+
known = {}
|
|
101
|
+
unknown = {}
|
|
102
|
+
|
|
103
|
+
for key, value in data.items():
|
|
104
|
+
if key in valid_fields or pascal_to_snake(key) in valid_fields:
|
|
105
|
+
known[key] = value
|
|
106
|
+
else:
|
|
107
|
+
unknown[key] = value
|
|
108
|
+
|
|
109
|
+
return DegradedSemaType(
|
|
110
|
+
type_name=type_name,
|
|
111
|
+
version=version,
|
|
112
|
+
raw=data,
|
|
113
|
+
known_fields=known,
|
|
114
|
+
unknown_fields=unknown,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def from_bytes(
|
|
118
|
+
self,
|
|
119
|
+
data: bytes,
|
|
120
|
+
mode: Literal["strict", "degraded"] = "strict",
|
|
121
|
+
) -> SemaType | DegradedSemaType:
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
d = json.loads(data.decode("utf-8"))
|
|
125
|
+
except Exception as e:
|
|
126
|
+
raise ValueError(f"Invalid JSON: {e}") from e
|
|
127
|
+
|
|
128
|
+
return self.from_dict(d, mode=mode)
|
|
129
|
+
|
|
130
|
+
def to_bytes(self, msg: SemaType) -> bytes:
|
|
131
|
+
return msg.to_bytes()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ============================================================================
|
|
135
|
+
# AUTO-DISCOVERY
|
|
136
|
+
# ============================================================================
|
|
137
|
+
|
|
138
|
+
def get_current_types() -> dict[str, type[SemaType]]:
|
|
139
|
+
from gw_data.sema import types
|
|
140
|
+
return {
|
|
141
|
+
getattr(types, name).type_name_value(): getattr(types, name)
|
|
142
|
+
for name in types.__all__
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_old_versions() -> dict[str, dict[str | None, type[SemaType]]]:
|
|
147
|
+
registry: dict[str, dict[str | None, type[SemaType]]] = defaultdict(dict)
|
|
148
|
+
old_versions_dir = Path(__file__).resolve().parent / "types" / "old_versions"
|
|
149
|
+
|
|
150
|
+
for path in sorted(old_versions_dir.glob("*.py")):
|
|
151
|
+
if path.stem == "__init__":
|
|
152
|
+
continue
|
|
153
|
+
module = import_module(f"gw_data.sema.types.old_versions.{path.stem}")
|
|
154
|
+
for name in dir(module):
|
|
155
|
+
obj = getattr(module, name)
|
|
156
|
+
if (
|
|
157
|
+
isinstance(obj, type)
|
|
158
|
+
and issubclass(obj, SemaType)
|
|
159
|
+
and obj is not SemaType
|
|
160
|
+
):
|
|
161
|
+
version = obj.version_value()
|
|
162
|
+
if version is not None:
|
|
163
|
+
registry[obj.type_name_value()][version] = obj
|
|
164
|
+
|
|
165
|
+
return registry
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
default_codec = SemaCodec()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
$schema: "https://json-schema.org/draft/2020-12/schema"
|
|
2
|
+
$id: "https://schemas.electricity.works/enums/base.g.node.class/000"
|
|
3
|
+
|
|
4
|
+
title: "base.g.node.class"
|
|
5
|
+
type: "string"
|
|
6
|
+
description: >
|
|
7
|
+
Ontology classification for Grid Nodes (GNodes) used to describe their
|
|
8
|
+
structural relationship to the physical electric grid. Values identify
|
|
9
|
+
whether a node represents a physical metered boundary, a physical
|
|
10
|
+
topological structure, a market coordination constraint, or a purely
|
|
11
|
+
logical entity.
|
|
12
|
+
|
|
13
|
+
Every GNode SHALL declare exactly one base.g.node.class value.
|
|
14
|
+
|
|
15
|
+
enum:
|
|
16
|
+
- "TerminalAsset"
|
|
17
|
+
- "LeafTransactiveNode"
|
|
18
|
+
- "ConnectivityNode"
|
|
19
|
+
- "MarketMaker"
|
|
20
|
+
- "Logical"
|
|
21
|
+
|
|
22
|
+
default: "Logical"
|
|
23
|
+
|
|
24
|
+
x-gridworks:
|
|
25
|
+
owner: "gridworks-energy"
|
|
26
|
+
version: "000"
|
|
27
|
+
value_descriptions:
|
|
28
|
+
"TerminalAsset": >
|
|
29
|
+
A physical transactive asset such as a heat pump, hot water heater, residential battery,
|
|
30
|
+
electric vehicle, or any other end-use device located behind an atomic metered point.
|
|
31
|
+
|
|
32
|
+
"LeafTransactiveNode": >
|
|
33
|
+
The atomic metered unit of the grid. Represents the smallest indivisible metering
|
|
34
|
+
boundary capable of participating in markets or entering Dispatch Contracts on
|
|
35
|
+
behalf of a TerminalAsset. Every TerminalAsset is associated with exactly one
|
|
36
|
+
LeafTransactiveNode.
|
|
37
|
+
|
|
38
|
+
"ConnectivityNode": >
|
|
39
|
+
A physical topological node in the electric power system where conductors join,
|
|
40
|
+
split, or change configuration. Conceptually aligned with the ConnectivityNode
|
|
41
|
+
in the IEC 61970/61968 CIM (Common Information Model), but simplified for
|
|
42
|
+
distribution-level modeling and OPF applications.
|
|
43
|
+
|
|
44
|
+
"MarketMaker": >
|
|
45
|
+
A physical constraint point in the conductor topology that requires localized market
|
|
46
|
+
coordination. Identified as a grid location (e.g., feeder constraint, transformer limit)
|
|
47
|
+
where a MarketMaker actor computes local prices for balancing and constraint compliance.
|
|
48
|
+
See [market-maker](https://gridworks.readthedocs.io/en/latest/market-maker.html).
|
|
49
|
+
|
|
50
|
+
"Logical": >
|
|
51
|
+
A non-physical Grid Node whose identity carries no inherent conductor-topology or
|
|
52
|
+
metering semantics. Used for purely logical or service-level nodes such as SCADA,
|
|
53
|
+
forecasting services, market-maker actors, simulation nodes, or organizational
|
|
54
|
+
microservices. Logical nodes may coordinate with or operate on physical nodes, but do
|
|
55
|
+
not themselves represent physical grid structure.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
$schema: "https://json-schema.org/draft/2020-12/schema"
|
|
2
|
+
$id: "https://schemas.electricity.works/enums/g.node.status/000"
|
|
3
|
+
|
|
4
|
+
title: "g.node.status"
|
|
5
|
+
type: string
|
|
6
|
+
description: >
|
|
7
|
+
Lifecycle status of a Grid Node (GNode) within coordinated systems.
|
|
8
|
+
|
|
9
|
+
enum:
|
|
10
|
+
- "Pending"
|
|
11
|
+
- "Active"
|
|
12
|
+
- "Suspended"
|
|
13
|
+
- "PermanentlyDeactivated"
|
|
14
|
+
|
|
15
|
+
default: "Pending"
|
|
16
|
+
|
|
17
|
+
x-gridworks:
|
|
18
|
+
owner: "gridworks-energy"
|
|
19
|
+
version: "000"
|
|
20
|
+
|
|
21
|
+
value_descriptions:
|
|
22
|
+
"Pending": >
|
|
23
|
+
The GNode has been created but is not yet authorized to participate
|
|
24
|
+
in system coordination.
|
|
25
|
+
|
|
26
|
+
"Active": >
|
|
27
|
+
The GNode is authorized to participate in system coordination.
|
|
28
|
+
|
|
29
|
+
"Suspended": >
|
|
30
|
+
The GNode is temporarily disabled. It retains identity but does not
|
|
31
|
+
participate in active coordination. A Suspended GNode MAY transition back to Active.
|
|
32
|
+
|
|
33
|
+
"PermanentlyDeactivated": >
|
|
34
|
+
The GNode has been permanently retired and does not participate
|
|
35
|
+
in active coordination. It may not change back to another state.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
$schema: "https://json-schema.org/draft/2020-12/schema"
|
|
2
|
+
$id: "https://schemas.electricity.works/formats/left.right.dot"
|
|
3
|
+
|
|
4
|
+
title: "left.right.dot"
|
|
5
|
+
description: |
|
|
6
|
+
Dot-separated hierarchical identifier composed of lowercase
|
|
7
|
+
alphanumeric segments. The first segment must begin with a
|
|
8
|
+
lowercase alphabetic character. Subsequent segments may begin
|
|
9
|
+
with either a lowercase letter or digit. Hierarchy is expressed
|
|
10
|
+
from left to right, with the most significant component appearing first.
|
|
11
|
+
|
|
12
|
+
type: string
|
|
13
|
+
minLength: 1
|
|
14
|
+
pattern: "^[a-z][a-z0-9]*(\\.[a-z0-9]+)*$"
|
|
15
|
+
|
|
16
|
+
examples:
|
|
17
|
+
- "beech.1"
|
|
18
|
+
- "hw1.isone.me.versant.keene.beech"
|
|
19
|
+
|
|
20
|
+
counterexamples:
|
|
21
|
+
- "scada.multipurpose-sensor" # hyphen not allowed
|
|
22
|
+
- "2be.or.not" # cannot start with a number
|
|
23
|
+
- "a..b" # empty segment not allowed
|
|
24
|
+
- "" # empty string invalid
|
|
25
|
+
|
|
26
|
+
x-gridworks:
|
|
27
|
+
owner: "gridworks-energy"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
$schema: "https://json-schema.org/draft/2020-12/schema"
|
|
2
|
+
$id: "https://schemas.electricity.works/formats/uuid4.str"
|
|
3
|
+
|
|
4
|
+
title: "uuid4.str"
|
|
5
|
+
description: >
|
|
6
|
+
Canonical lowercase UUID version 4 string in standard hyphenated
|
|
7
|
+
8-4-4-4-12 format. Enforces version (4) and variant (RFC 4122)
|
|
8
|
+
and requires lowercase for consistency.
|
|
9
|
+
|
|
10
|
+
type: string
|
|
11
|
+
pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
12
|
+
minLength: 36
|
|
13
|
+
maxLength: 36
|
|
14
|
+
|
|
15
|
+
examples:
|
|
16
|
+
- "9cff2689-eadc-4577-94ea-6d86d0d23e9e"
|
|
17
|
+
- "4e5a6b1c-2d3e-4f5a-8b9c-1d2e3f4a5b6c"
|
|
18
|
+
- "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
|
19
|
+
|
|
20
|
+
counterexamples:
|
|
21
|
+
- "not-a-uuid" # invalid format
|
|
22
|
+
- "6ba7b810-9dad-11d1-80b4-00c04fd430c" # wrong group length (last group too short)
|
|
23
|
+
- "6ba7b810-9dad-11d1-80b4-00c04fd430c88" # wrong group length (last group too long)
|
|
24
|
+
- "6ba7b810-9dad-21d1-80b4-00c04fd430c8" # wrong version (2 instead of 4)
|
|
25
|
+
- "6ba7b810-9dad-11d1-70b4-00c04fd430c8" # wrong variant (7 not allowed)
|
|
26
|
+
- "6BA7B810-9DAD-11D1-80B4-00C04FD430C8" # uppercase not allowed
|
|
27
|
+
|
|
28
|
+
x-gridworks:
|
|
29
|
+
owner: "gridworks-energy"
|