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.
Files changed (55) hide show
  1. gw_data/__init__.py +4 -0
  2. gw_data/config.py +19 -0
  3. gw_data/db/__init__.py +0 -0
  4. gw_data/db/models/__init__.py +37 -0
  5. gw_data/db/models/_base.py +5 -0
  6. gw_data/db/models/connectivity_edge.py +80 -0
  7. gw_data/db/models/customer.py +18 -0
  8. gw_data/db/models/g_node.py +83 -0
  9. gw_data/db/models/installation.py +52 -0
  10. gw_data/db/models/installer.py +17 -0
  11. gw_data/db/models/message.py +51 -0
  12. gw_data/db/models/position_point.py +46 -0
  13. gw_data/db/models/reading.py +55 -0
  14. gw_data/db/models/reading_channel.py +62 -0
  15. gw_data/db/models/user.py +35 -0
  16. gw_data/db/models/user_installation_role.py +38 -0
  17. gw_data/db/scripts/0_db_create.psql +4 -0
  18. gw_data/db/scripts/1_db_user_setup.psql +16 -0
  19. gw_data/db/scripts/2_db_schema_setup.sql +11 -0
  20. gw_data/db/scripts/3_db_alembic_upgrade.sh +2 -0
  21. gw_data/db/scripts/4_db_seed.py +129 -0
  22. gw_data/db/scripts/_XX_drop_all.sql +13 -0
  23. gw_data/db/scripts/seed_data/homes.csv +6 -0
  24. gw_data/db/session.py +0 -0
  25. gw_data/sema/__init__.py +15 -0
  26. gw_data/sema/base.py +184 -0
  27. gw_data/sema/codec.py +168 -0
  28. gw_data/sema/definitions/enums/base.g.node.class/000.yaml +55 -0
  29. gw_data/sema/definitions/enums/g.node.status/000.yaml +35 -0
  30. gw_data/sema/definitions/formats/left.right.dot.yaml +27 -0
  31. gw_data/sema/definitions/formats/uuid4.str.yaml +29 -0
  32. gw_data/sema/definitions/registry.yaml +77 -0
  33. gw_data/sema/definitions/types/g.node.gt/004.yaml +169 -0
  34. gw_data/sema/definitions/types/position.point.gt/000.yaml +86 -0
  35. gw_data/sema/enums/__init__.py +7 -0
  36. gw_data/sema/enums/base_g_node_class.py +29 -0
  37. gw_data/sema/enums/g_node_status.py +28 -0
  38. gw_data/sema/enums/gw_str_enum.py +97 -0
  39. gw_data/sema/enums/old_versions/__init__.py +0 -0
  40. gw_data/sema/indexes/dependency_closure.yaml +19 -0
  41. gw_data/sema/indexes/local_names.yaml +6 -0
  42. gw_data/sema/indexes/lookup.yaml +35 -0
  43. gw_data/sema/indexes/reverse_dependencies.yaml +21 -0
  44. gw_data/sema/indexes/seed_expanded.yaml +29 -0
  45. gw_data/sema/indexes/versions.yaml +31 -0
  46. gw_data/sema/property_format.py +56 -0
  47. gw_data/sema/tests/test_property_format.py +47 -0
  48. gw_data/sema/types/__init__.py +7 -0
  49. gw_data/sema/types/g_node_gt.py +106 -0
  50. gw_data/sema/types/old_versions/__init__.py +0 -0
  51. gw_data/sema/types/position_point_gt.py +31 -0
  52. gw_data-0.3.0.dist-info/METADATA +189 -0
  53. gw_data-0.3.0.dist-info/RECORD +55 -0
  54. gw_data-0.3.0.dist-info/WHEEL +4 -0
  55. 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
@@ -0,0 +1,15 @@
1
+ from gw_data.sema.base import (
2
+ SemaError,
3
+ SemaType,
4
+ )
5
+ from gw_data.sema.codec import (
6
+ SemaCodec,
7
+ get_current_types,
8
+ )
9
+
10
+ __all__ = [
11
+ "SemaType",
12
+ "SemaCodec",
13
+ "SemaError",
14
+ "get_current_types",
15
+ ]
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"