openadr3-client-gac-compliance 0.0.1__tar.gz

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.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.3
2
+ Name: openadr3-client-gac-compliance
3
+ Version: 0.0.1
4
+ Summary:
5
+ Author: Nick van der Burgt
6
+ Author-email: nick.van.der.burgt@elaad.nl
7
+ Requires-Python: >=3.12, <4
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Dist: openadr3-client (>=0.0.1,<0.0.2)
12
+ Requires-Dist: pydantic (>=2.11.2,<3.0.0)
13
+ Description-Content-Type: text/markdown
14
+
15
+ # OpenADR3 client
16
+
17
+ This repository contains a plugin for the [OpenADR3-client](https://github.com/ElaadNL/openadr3-client) library that adds additional pydantic validators to the OpenADR3 domain models to ensure GAC compliance. Since GAC compliance is a superset of OpenADR3, adding validation rules on top of the OpenADR3 models is sufficient to ensure compliance.
18
+
19
+ To use this plugin, the package must be imported once globally. We recommend doing this in your root directories `__init__.py` file.
20
+
21
+ ```python
22
+ import openadr3_client_gac_compliance # noqa: F401 (in case you use ruff)
23
+ ```
@@ -0,0 +1,9 @@
1
+ # OpenADR3 client
2
+
3
+ This repository contains a plugin for the [OpenADR3-client](https://github.com/ElaadNL/openadr3-client) library that adds additional pydantic validators to the OpenADR3 domain models to ensure GAC compliance. Since GAC compliance is a superset of OpenADR3, adding validation rules on top of the OpenADR3 models is sufficient to ensure compliance.
4
+
5
+ To use this plugin, the package must be imported once globally. We recommend doing this in your root directories `__init__.py` file.
6
+
7
+ ```python
8
+ import openadr3_client_gac_compliance # noqa: F401 (in case you use ruff)
9
+ ```
@@ -0,0 +1,2 @@
1
+ import openadr3_client_gac_compliance.program_gac_compliant # noqa: F401
2
+ import openadr3_client_gac_compliance.event_gac_compliant # noqa: F401
@@ -0,0 +1,204 @@
1
+ """Module which implements GAC compliance validators for the event OpenADR3 types.
2
+
3
+ This module validates all the object constraints and requirements on the OpenADR3 events resource
4
+ as specified in the Grid aware charging (GAC) specification.
5
+
6
+ There is one requirement that is not validated here, as it cannot be validated through the scope of the
7
+ pydantic validators. Namely, the requirement that a safe mode event MUST be present in a program.
8
+
9
+ As the pydantic validator works on the scope of a single Event Object, it is not possible to validate
10
+ that a safe mode event is present in a program. And it cannot be validated on the Program object,
11
+ as the program object does not contain the events, these are stored seperately in the VTN.
12
+ """
13
+
14
+ from itertools import pairwise
15
+ import re
16
+ from openadr3_client.models.model import ValidatorRegistry, Model as ValidatorModel
17
+ from openadr3_client.models.event.event import Event
18
+ from openadr3_client.models.event.event_payload import EventPayloadType
19
+
20
+
21
+ def _continuous_or_seperated(self: Event) -> Event:
22
+ """Enforces that events either have consistent interval definitions compliant with GAC.
23
+
24
+ the Grid aware charging (GAC) specification allows for two types of (mutually exclusive)
25
+ interval definitions:
26
+
27
+ 1. Continuous
28
+ 2. Seperated
29
+
30
+ The continious implementation can be used when all intervals have the same duration.
31
+ In this case, only the top-level intervalPeriod of the event can be used, and the intervalPeriods
32
+ of the individual intervals must be None.
33
+
34
+ In the seperated intervalDefinition approach, the intervalPeriods must be set on each individual intervals,
35
+ and the top-level intervalPeriod of the event must be None. This seperated approach is used when events have differing
36
+ durations.
37
+ """
38
+ intervals = self.intervals or ()
39
+
40
+ if self.interval_period is None:
41
+ # interval period not set at top level of the event.
42
+ # Ensure that all intervals have the interval_period defined, to comply with the GAC specification.
43
+ undefined_intervals_period = [i for i in intervals if i.interval_period is None]
44
+ if undefined_intervals_period:
45
+ raise ValueError(
46
+ "Either 'interval_period' must be set on the event once, or every interval must have its own 'interval_period'."
47
+ )
48
+ else:
49
+ # interval period set at top level of the event.
50
+ # Ensure that all intervals do not have the interval_period defined, to comply with the GAC specification.
51
+ duplicate_interval_period = [
52
+ i for i in intervals if i.interval_period is not None
53
+ ]
54
+ if duplicate_interval_period:
55
+ raise ValueError(
56
+ "Either 'interval_period' must be set on the event once, or every interval must have its own 'interval_period'."
57
+ )
58
+
59
+ return self
60
+
61
+
62
+ def _targets_compliant(self: Event) -> Event:
63
+ """Enforces that the targets of the event are compliant with GAC.
64
+
65
+ GAC enforces the following constraints for targets:
66
+
67
+ - The event must contain a POWER_SERVICE_LOCATIONS target.
68
+ - The POWER_SERVICE_LOCATIONS target value must be a list of 'EAN18' values.
69
+ - The event must contain a VEN_NAME target.
70
+ - The VEN_NAME target value must be a list of 'ven object name' values (between 1 and 128 characters).
71
+ """
72
+ targets = self.targets or ()
73
+
74
+ power_service_locations = [
75
+ t for t in targets if t.type == "POWER_SERVICE_LOCATIONS"
76
+ ]
77
+ ven_names = [t for t in targets if t.type == "VEN_NAME"]
78
+
79
+ if not power_service_locations:
80
+ raise ValueError("The event must contain a POWER_SERVICE_LOCATIONS target.")
81
+
82
+ if not ven_names:
83
+ raise ValueError("The event must contain a VEN_NAME target.")
84
+
85
+ if len(power_service_locations) > 1:
86
+ raise ValueError(
87
+ "The event must contain exactly one POWER_SERVICE_LOCATIONS target."
88
+ )
89
+
90
+ if len(ven_names) > 1:
91
+ raise ValueError("The event must contain only one VEN_NAME target.")
92
+
93
+ power_service_location = power_service_locations[0]
94
+ ven_name = ven_names[0]
95
+
96
+ if len(power_service_location.values) == 0:
97
+ raise ValueError("The POWER_SERVICE_LOCATIONS target value cannot be empty.")
98
+
99
+ if not all(re.fullmatch(r"\d{18}", v) for v in power_service_location.values):
100
+ raise ValueError(
101
+ "The POWER_SERVICE_LOCATIONS target value must be a list of 'EAN18' values."
102
+ )
103
+
104
+ if len(ven_name.values) == 0:
105
+ raise ValueError("The VEN_NAME target value cannot be empty.")
106
+
107
+ if not all(1 <= len(v) <= 128 for v in ven_name.values):
108
+ raise ValueError(
109
+ "The VEN_NAME target value must be a list of 'ven object name' values (between 1 and 128 characters)."
110
+ )
111
+
112
+ return self
113
+
114
+
115
+ def _payload_descriptor_gac_compliant(self: Event) -> Event:
116
+ """Enforces that the payload descriptor is GAC compliant.
117
+
118
+ GAC enforces the following constraints for payload descriptors:
119
+
120
+ - The event interval must exactly one payload descriptor.
121
+ - The payload descriptor must have a payload type of 'IMPORT_CAPACITY_LIMIT'
122
+ - The payload descriptor must have a units of 'KW' (case sensitive).
123
+ """
124
+ if self.payload_descriptor is None:
125
+ raise ValueError("The event must have a payload descriptor.")
126
+
127
+ if len(self.payload_descriptor) != 1:
128
+ raise ValueError("The event must have exactly one payload descriptor.")
129
+
130
+ payload_descriptor = self.payload_descriptor[0]
131
+
132
+ if payload_descriptor.payload_type != EventPayloadType.IMPORT_CAPACITY_LIMIT:
133
+ raise ValueError(
134
+ "The payload descriptor must have a payload type of 'IMPORT_CAPACITY_LIMIT'."
135
+ )
136
+
137
+ if payload_descriptor.units != "KW":
138
+ raise ValueError(
139
+ "The payload descriptor must have a units of 'KW' (case sensitive)."
140
+ )
141
+
142
+ return self
143
+
144
+
145
+ def _event_interval_gac_compliant(self: Event) -> Event:
146
+ """Enforces that the event interval is GAC compliant.
147
+
148
+ GAC enforces the following constraints for event intervals:
149
+
150
+ - The event interval must have an id value that is strictly increasing.
151
+ - The event interval must have exactly one payload.
152
+ - The payload of the event interval must have a type of 'IMPORT_CAPACITY_LIMIT'
153
+ """
154
+ if not self.intervals:
155
+ raise ValueError("The event must have at least one interval.")
156
+
157
+ if not all(curr.id > prev.id for prev, curr in pairwise(self.intervals)):
158
+ raise ValueError(
159
+ "The event interval must have an id value that is strictly increasing."
160
+ )
161
+
162
+ for interval in self.intervals:
163
+ if interval.payloads is None:
164
+ raise ValueError("The event interval must have a payload.")
165
+
166
+ if len(interval.payloads) != 1:
167
+ raise ValueError("The event interval must have exactly one payload.")
168
+
169
+ payload = interval.payloads[0]
170
+
171
+ if payload.type != EventPayloadType.IMPORT_CAPACITY_LIMIT:
172
+ raise ValueError(
173
+ "The event interval payload must have a payload type of 'IMPORT_CAPACITY_LIMIT'."
174
+ )
175
+
176
+ return self
177
+
178
+
179
+ @ValidatorRegistry.register(Event, ValidatorModel())
180
+ def event_gac_compliant(self: Event) -> Event:
181
+ """Enforces that events are GAC compliant.
182
+
183
+ GAC enforces the following constraints for events:
184
+
185
+ - The event must have a priority set.
186
+ - The priority must be greater than or equal to 0 and less than or equal to 999.
187
+ - The event must have either a continuous or seperated interval definition.
188
+ """
189
+ if self.priority is None:
190
+ raise ValueError("The event must have a priority set.")
191
+
192
+ if self.priority > 999:
193
+ raise ValueError(
194
+ "The priority must be greater than or equal to 0 and less than or equal to 999."
195
+ )
196
+
197
+ interval_periods_validated = _continuous_or_seperated(self)
198
+ targets_validated = _targets_compliant(interval_periods_validated)
199
+ payload_descriptor_validated = _payload_descriptor_gac_compliant(targets_validated)
200
+ event_interval_validated = _event_interval_gac_compliant(
201
+ payload_descriptor_validated
202
+ )
203
+
204
+ return event_interval_validated
@@ -0,0 +1,38 @@
1
+ """Module which implements GAC compliance validators for the program OpenADR3 types."""
2
+
3
+ from openadr3_client.models.program.program import Program
4
+ from openadr3_client.models.model import ValidatorRegistry, Model as ValidatorModel
5
+
6
+ import re
7
+
8
+
9
+ @ValidatorRegistry.register(Program, ValidatorModel())
10
+ def program_gac_compliant(self: Program) -> Program:
11
+ """Enforces that the program is GAC compliant.
12
+
13
+ GAC enforces the following constraints for programs:
14
+ - The program must have a retailer name
15
+ - The retailer name must be an EAN13 identifier.
16
+ - The program MUST have a programType.
17
+ - The programType MUST equal "DSO_CPO_INTERFACE-x.x.x, where x.x.x is the version as defined in the GAC specification.
18
+ - The program MUST have bindingEvents set to True.
19
+ are allowed there.
20
+ """
21
+ program_type_regex = r"^DSO_CPO_INTERFACE-(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
22
+
23
+ if self.retailer_name is None:
24
+ raise ValueError("The program must have a retailer name.")
25
+ if not re.fullmatch(r"\d{13}", self.retailer_name):
26
+ raise ValueError("The retailer name must be an EAN13 identifier.")
27
+
28
+ if self.program_type is None:
29
+ raise ValueError("The program must have a program type.")
30
+ if not re.fullmatch(program_type_regex, self.program_type):
31
+ raise ValueError(
32
+ "The program type must follow the format DSO_CPO_INTERFACE-x.x.x."
33
+ )
34
+
35
+ if self.binding_events is False:
36
+ raise ValueError("The program must have bindingEvents set to True.")
37
+
38
+ return self
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "openadr3-client-gac-compliance"
3
+ version = "0.0.1"
4
+ description = ""
5
+ authors = [
6
+ {name = "Nick van der Burgt", email = "nick.van.der.burgt@elaad.nl"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12, <4"
10
+ dependencies = [
11
+ "pydantic (>=2.11.2,<3.0.0)",
12
+ "openadr3-client (>=0.0.1,<0.0.2)",
13
+ ]
14
+
15
+ # TODO: remove local openadr3-client dependency when the openadr3-client package is published to pypi
16
+
17
+ [build-system]
18
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
19
+ build-backend = "poetry.core.masonry.api"
20
+
21
+ [tool.poetry.group.dev.dependencies]
22
+ mypy = "^1.15.0"
23
+ ruff = "^0.11.4"
24
+ pytest = "^8.3.5"
25
+ pytest-cov = "^6.1.1"
26
+
27
+ [[tool.mypy.overrides]]
28
+ module = ["openadr3_client"]
29
+ ignore_missing_imports = true