diff --git a/src/gwproto/data_classes/sh_node.py b/src/gwproto/data_classes/sh_node.py index 903e0734..6b44846b 100644 --- a/src/gwproto/data_classes/sh_node.py +++ b/src/gwproto/data_classes/sh_node.py @@ -80,3 +80,7 @@ def __repr__(self) -> str: @property def has_actor(self) -> bool: return self.actor_class != ActorClass.NoActor + + def to_gt(self) -> SpaceheatNodeGt: + # Copy the current instance excluding the extra fields + return SpaceheatNodeGt(**self.model_dump(exclude={"component"})) diff --git a/src/gwproto/named_types/__init__.py b/src/gwproto/named_types/__init__.py index b43e231e..197973b4 100644 --- a/src/gwproto/named_types/__init__.py +++ b/src/gwproto/named_types/__init__.py @@ -26,6 +26,7 @@ from gwproto.named_types.fsm_full_report import FsmFullReport from gwproto.named_types.fsm_trigger_from_atn import FsmTriggerFromAtn from gwproto.named_types.go_dormant import GoDormant +from gwproto.named_types.ha1_params import Ha1Params from gwproto.named_types.heartbeat_b import HeartbeatB from gwproto.named_types.hubitat_component_gt import HubitatComponentGt from gwproto.named_types.hubitat_poller_component_gt import HubitatPollerComponentGt @@ -84,6 +85,7 @@ "FsmFullReport", "FsmTriggerFromAtn", "GoDormant", + "Ha1Params", "HeartbeatB", "HubitatComponentGt", "HubitatPollerComponentGt", diff --git a/src/gwproto/named_types/ha1_params.py b/src/gwproto/named_types/ha1_params.py new file mode 100644 index 00000000..d15af6de --- /dev/null +++ b/src/gwproto/named_types/ha1_params.py @@ -0,0 +1,19 @@ +"""Type ha1.params, version 000""" + +from typing import Literal + +from pydantic import BaseModel, StrictInt + + +class Ha1Params(BaseModel): + AlphaTimes10: StrictInt + BetaTimes100: StrictInt + GammaEx6: StrictInt + IntermediatePowerKw: float + IntermediateRswtF: StrictInt + DdPowerKw: float + DdRswtF: StrictInt + DdDeltaTF: StrictInt + HpMaxKwTh: float + TypeName: Literal["ha1.params"] = "ha1.params" + Version: Literal["000"] = "000" diff --git a/src/gwproto/named_types/layout_lite.py b/src/gwproto/named_types/layout_lite.py index a1393509..979a42b9 100644 --- a/src/gwproto/named_types/layout_lite.py +++ b/src/gwproto/named_types/layout_lite.py @@ -1,12 +1,15 @@ -"""Type layout.lite, version 000""" +"""Type layout.lite, version 001""" from typing import List, Literal -from pydantic import BaseModel, PositiveInt +from pydantic import BaseModel, PositiveInt, model_validator +from typing_extensions import Self +from gwproto.enums import ActorClass from gwproto.named_types.data_channel_gt import DataChannelGt from gwproto.named_types.pico_flow_module_component_gt import PicoFlowModuleComponentGt from gwproto.named_types.pico_tank_module_component_gt import PicoTankModuleComponentGt +from gwproto.named_types.spaceheat_node_gt import SpaceheatNodeGt from gwproto.property_format import ( LeftRightDotStr, UTCMilliseconds, @@ -15,13 +18,6 @@ class LayoutLite(BaseModel): - """ - Layout Lite. - - A light-weight version of the layout for a Spaceheat Node, with key parameters about how - the SCADA operates. - """ - FromGNodeAlias: LeftRightDotStr FromGNodeInstanceId: UUID4Str MessageCreatedMs: UTCMilliseconds @@ -29,8 +25,59 @@ class LayoutLite(BaseModel): Strategy: str ZoneList: List[str] TotalStoreTanks: PositiveInt + ShNodes: List[SpaceheatNodeGt] DataChannels: List[DataChannelGt] TankModuleComponents: List[PicoTankModuleComponentGt] FlowModuleComponents: List[PicoFlowModuleComponentGt] TypeName: Literal["layout.lite"] = "layout.lite" - Version: Literal["000"] = "000" + Version: Literal["001"] = "001" + + @model_validator(mode="after") + def check_axiom_1(self) -> Self: + """ + Axiom 1: Dc Node Consistency. Every AboutNodeName and CapturedByNodeName in a + DataChannel belongs to an ShNode, and in addition every CapturedByNodeName does + not have ActorClass NoActor. + """ + for dc in self.DataChannels: + if dc.AboutNodeName not in [n.Name for n in self.ShNodes]: + raise ValueError( + f"dc {dc.Name} AboutNodeName {dc.AboutNodeName} not in ShNodes!" + ) + captured_by_node = next( + (n for n in self.ShNodes if n.Name == dc.CapturedByNodeName), None + ) + if not captured_by_node: + raise ValueError( + f"dc {dc.Name} CapturedByNodeName {dc.CapturedByNodeName} not in ShNodes!" + ) + if captured_by_node.ActorClass == ActorClass.NoActor: + raise ValueError( + f"dc {dc.Name}'s CatpuredByNode cannot have ActorClass NoActor!" + ) + return self + + @model_validator(mode="after") + def check_axiom_2(self) -> Self: + """ + Node Handle Hierarchy Consistency. Every ShNode with a handle containing at least + two words (separated by '.') has an immediate boss: another ShNode whose handle + matches the original handle minus its last word. + """ + existing_handles = {get_handle(node) for node in self.ShNodes} + for node in self.ShNodes: + handle = get_handle(node) + if "." in handle: + boss_handle = ".".join(handle.split(".")[:-1]) + if boss_handle not in existing_handles: + raise ValueError( + f"node {node.Name} with handle {handle} missing" + " its immediate boss!" + ) + return self + + +def get_handle(node: SpaceheatNodeGt) -> str: + if node.Handle: + return node.Handle + return node.Name diff --git a/src/gwproto/named_types/scada_params.py b/src/gwproto/named_types/scada_params.py index abc23ea8..c08f2481 100644 --- a/src/gwproto/named_types/scada_params.py +++ b/src/gwproto/named_types/scada_params.py @@ -1,9 +1,10 @@ -"""Type scada.params, version 000""" +"""Type scada.params, version 001""" -from typing import Literal +from typing import Literal, Optional from pydantic import BaseModel, ConfigDict +from gwproto.named_types.ha1_params import Ha1Params from gwproto.property_format import ( LeftRightDotStr, SpaceheatName, @@ -18,7 +19,9 @@ class ScadaParams(BaseModel): ToName: SpaceheatName UnixTimeMs: UTCMilliseconds MessageId: UUID4Str + NewParams: Optional[Ha1Params] = None + OldParams: Optional[Ha1Params] = None TypeName: Literal["scada.params"] = "scada.params" - Version: Literal["000"] = "000" + Version: Literal["001"] = "001" model_config = ConfigDict(extra="allow") diff --git a/tests/named_types/test_ha1_params.py b/tests/named_types/test_ha1_params.py new file mode 100644 index 00000000..d2303135 --- /dev/null +++ b/tests/named_types/test_ha1_params.py @@ -0,0 +1,23 @@ +"""Tests ha1.params type, version 000""" + +from gwproto.named_types import Ha1Params + + +def test_ha1_params_generated() -> None: + d = { + "AlphaTimes10": 120, + "BetaTimes100": -22, + "GammaEx6": 0, + "IntermediatePowerKw": 1.5, + "IntermediateRswtF": 100, + "DdPowerKw": 12, + "DdRswtF": 160, + "DdDeltaTF": 20, + "HpMaxKwTh": 6, + "TypeName": "ha1.params", + "Version": "000", + } + + d2 = Ha1Params.model_validate(d).model_dump(exclude_none=True) + + assert d2 == d diff --git a/tests/named_types/test_layout_lite.py b/tests/named_types/test_layout_lite.py index 73eca967..988e5294 100644 --- a/tests/named_types/test_layout_lite.py +++ b/tests/named_types/test_layout_lite.py @@ -1,6 +1,4 @@ -"""Tests layout.lite type, version 000""" - -import json +"""Tests layout.lite type, version 001""" from gwproto.named_types import LayoutLite @@ -11,14 +9,44 @@ def test_layout_lite_generated() -> None: "FromGNodeInstanceId": "98542a17-3180-4f2a-a929-6023f0e7a106", "MessageCreatedMs": 1728651445746, "MessageId": "1302c0f8-1983-43b2-90d2-61678d731db3", - "ZoneList": ["Down", "Up"], "Strategy": "House0", + "ZoneList": ["Down", "Up"], "TotalStoreTanks": 3, + "ShNodes": [ + { + "ActorClass": "Scada", + "DisplayName": "Keene Beech Scada", + "Name": "s", + "ShNodeId": "da9a0427-d6c0-44c0-b51c-492c1e580dc5", + "TypeName": "spaceheat.node.gt", + "Version": "200", + }, + { + "ActorClass": "PowerMeter", + "ActorHierarchyName": "s.power-meter", + "ComponentId": "9633adef-2373-422d-8a0e-dfbd16ae081c", + "DisplayName": "Primary Power Meter", + "Name": "power-meter", + "ShNodeId": "6c0563b7-5171-4b1c-bba3-de156bea4b95", + "TypeName": "spaceheat.node.gt", + "Version": "200", + }, + { + "ActorClass": "NoActor", + "DisplayName": "Hp Idu", + "InPowerMetering": True, + "Name": "hp-idu", + "NameplatePowerW": 4000, + "ShNodeId": "07b8ca98-12c4-4510-8d0f-14fda2331215", + "TypeName": "spaceheat.node.gt", + "Version": "200", + }, + ], "DataChannels": [ { "Name": "hp-idu-pwr", "DisplayName": "Hp IDU", - "AboutNodeName": "hp-idu-pwr", + "AboutNodeName": "hp-idu", "CapturedByNodeName": "power-meter", "TelemetryName": "PowerW", "TerminalAssetAlias": "hw1.isone.me.versant.keene.beech.ta", @@ -120,9 +148,9 @@ def test_layout_lite_generated() -> None: "PicoKOhms": 30, "Samples": 1000, "SendMicroVolts": True, - "AsyncCaptureDeltaMicroVolts": 2000, "TempCalcMethod": "SimpleBetaForPico", "ThermistorBeta": 3977, + "AsyncCaptureDeltaMicroVolts": 2000, "TypeName": "pico.tank.module.component.gt", "Version": "000", } @@ -173,9 +201,9 @@ def test_layout_lite_generated() -> None: } ], "TypeName": "layout.lite", - "Version": "000", + "Version": "001", } - t2 = LayoutLite.model_validate(d).model_dump_json(exclude_none=True) - d2 = json.loads(t2) + d2 = LayoutLite.model_validate(d).model_dump(exclude_none=True) + assert d2 == d diff --git a/tests/named_types/test_scada_params.py b/tests/named_types/test_scada_params.py index f468eca4..4d3d37d1 100644 --- a/tests/named_types/test_scada_params.py +++ b/tests/named_types/test_scada_params.py @@ -1,4 +1,4 @@ -"""Tests scada.params type, version 000""" +"""Tests scada.params type, version 001""" from gwproto.named_types import ScadaParams @@ -10,8 +10,21 @@ def test_scada_params_generated() -> None: "ToName": "h", "UnixTimeMs": 1731637846788, "MessageId": "37b64437-f5b2-4a80-b5fc-3d5a9f6b5b59", + "NewParams": { + "AlphaTimes10": 120, + "BetaTimes100": -22, + "GammaEx6": 0, + "IntermediatePowerKw": 1.5, + "IntermediateRswtF": 100, + "DdPowerKw": 12, + "DdRswtF": 160, + "DdDeltaTF": 20, + "HpMaxKwTh": 6, + "TypeName": "ha1.params", + "Version": "000", + }, "TypeName": "scada.params", - "Version": "000", + "Version": "001", } d2 = ScadaParams.model_validate(d).model_dump(exclude_none=True)