power-grid-model 1.12.57__py3-none-win_amd64.whl → 1.12.59__py3-none-win_amd64.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.

Potentially problematic release.


This version of power-grid-model might be problematic. Click here for more details.

Files changed (59) hide show
  1. power_grid_model/__init__.py +54 -54
  2. power_grid_model/_core/__init__.py +3 -3
  3. power_grid_model/_core/buffer_handling.py +493 -493
  4. power_grid_model/_core/data_handling.py +141 -141
  5. power_grid_model/_core/data_types.py +132 -132
  6. power_grid_model/_core/dataset_definitions.py +109 -109
  7. power_grid_model/_core/enum.py +226 -226
  8. power_grid_model/_core/error_handling.py +206 -206
  9. power_grid_model/_core/errors.py +130 -130
  10. power_grid_model/_core/index_integer.py +17 -17
  11. power_grid_model/_core/options.py +71 -71
  12. power_grid_model/_core/power_grid_core.py +563 -563
  13. power_grid_model/_core/power_grid_dataset.py +535 -535
  14. power_grid_model/_core/power_grid_meta.py +243 -243
  15. power_grid_model/_core/power_grid_model.py +686 -686
  16. power_grid_model/_core/power_grid_model_c/__init__.py +3 -3
  17. power_grid_model/_core/power_grid_model_c/bin/power_grid_model_c.dll +0 -0
  18. power_grid_model/_core/power_grid_model_c/get_pgm_dll_path.py +63 -63
  19. power_grid_model/_core/power_grid_model_c/include/power_grid_model_c/basics.h +255 -255
  20. power_grid_model/_core/power_grid_model_c/include/power_grid_model_c/buffer.h +108 -108
  21. power_grid_model/_core/power_grid_model_c/include/power_grid_model_c/dataset.h +316 -316
  22. power_grid_model/_core/power_grid_model_c/include/power_grid_model_c/dataset_definitions.h +1052 -1052
  23. power_grid_model/_core/power_grid_model_c/include/power_grid_model_c/handle.h +99 -99
  24. power_grid_model/_core/power_grid_model_c/include/power_grid_model_c/meta_data.h +189 -189
  25. power_grid_model/_core/power_grid_model_c/include/power_grid_model_c/model.h +125 -125
  26. power_grid_model/_core/power_grid_model_c/include/power_grid_model_c/options.h +142 -142
  27. power_grid_model/_core/power_grid_model_c/include/power_grid_model_c/serialization.h +118 -118
  28. power_grid_model/_core/power_grid_model_c/include/power_grid_model_c.h +36 -36
  29. power_grid_model/_core/power_grid_model_c/include/power_grid_model_cpp/basics.hpp +65 -65
  30. power_grid_model/_core/power_grid_model_c/include/power_grid_model_cpp/buffer.hpp +61 -61
  31. power_grid_model/_core/power_grid_model_c/include/power_grid_model_cpp/dataset.hpp +220 -220
  32. power_grid_model/_core/power_grid_model_c/include/power_grid_model_cpp/handle.hpp +108 -108
  33. power_grid_model/_core/power_grid_model_c/include/power_grid_model_cpp/meta_data.hpp +84 -84
  34. power_grid_model/_core/power_grid_model_c/include/power_grid_model_cpp/model.hpp +63 -63
  35. power_grid_model/_core/power_grid_model_c/include/power_grid_model_cpp/options.hpp +52 -52
  36. power_grid_model/_core/power_grid_model_c/include/power_grid_model_cpp/serialization.hpp +124 -124
  37. power_grid_model/_core/power_grid_model_c/include/power_grid_model_cpp/utils.hpp +81 -81
  38. power_grid_model/_core/power_grid_model_c/include/power_grid_model_cpp.hpp +19 -19
  39. power_grid_model/_core/power_grid_model_c/lib/cmake/power_grid_model/power_grid_modelConfigVersion.cmake +3 -3
  40. power_grid_model/_core/serialization.py +317 -317
  41. power_grid_model/_core/typing.py +20 -20
  42. power_grid_model/_core/utils.py +798 -798
  43. power_grid_model/data_types.py +321 -321
  44. power_grid_model/enum.py +27 -27
  45. power_grid_model/errors.py +37 -37
  46. power_grid_model/typing.py +43 -43
  47. power_grid_model/utils.py +473 -473
  48. power_grid_model/validation/__init__.py +25 -25
  49. power_grid_model/validation/_rules.py +1171 -1171
  50. power_grid_model/validation/_validation.py +1172 -1172
  51. power_grid_model/validation/assertions.py +93 -93
  52. power_grid_model/validation/errors.py +602 -602
  53. power_grid_model/validation/utils.py +313 -313
  54. {power_grid_model-1.12.57.dist-info → power_grid_model-1.12.59.dist-info}/METADATA +1 -1
  55. power_grid_model-1.12.59.dist-info/RECORD +65 -0
  56. power_grid_model-1.12.57.dist-info/RECORD +0 -65
  57. {power_grid_model-1.12.57.dist-info → power_grid_model-1.12.59.dist-info}/WHEEL +0 -0
  58. {power_grid_model-1.12.57.dist-info → power_grid_model-1.12.59.dist-info}/entry_points.txt +0 -0
  59. {power_grid_model-1.12.57.dist-info → power_grid_model-1.12.59.dist-info}/licenses/LICENSE +0 -0
@@ -1,1171 +1,1171 @@
1
- # SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
2
- #
3
- # SPDX-License-Identifier: MPL-2.0
4
-
5
- """
6
- This module contains a set of comparison rules. They all share the same (or similar) logic and interface.
7
-
8
- In general each function checks the values in a single 'column' (i.e. field) of a numpy structured array and it
9
- returns an error object containing the component, the field and the ids of the records that did not match the rule.
10
- E.g. all_greater_than_zero(data, 'node', 'u_rated') returns a NotGreaterThanError if any of the node's `u_rated`
11
- values are 0 or less.
12
-
13
- In general, the rules are designed to ignore NaN values, except for none_missing() which explicitly checks for NaN
14
- values in the entire data set. It is important to understand that np.less_equal(x) yields different results than
15
- np.logical_not(np.greater(x)) as a NaN comparison always results in False. The most extreme example is that even
16
- np.nan == np.nan yields False.
17
-
18
- np.less_equal( [0.1, 0.2, 0.3, np.nan], 0.0) = [False, False, False, False] -> OK
19
- np.logical_not(np.greater([0.1, 0.2, 0.3, np.nan], 0.0)) = [False, False, False, True] -> Error (false positive)
20
-
21
- Input data:
22
-
23
- data: SingleDataset
24
- The entire input/update data set
25
-
26
- component: ComponentType
27
- The name of the component, which should be an existing key in the data
28
-
29
- field: str
30
- The name of the column, which should be an field in the component data (numpy structured array)
31
-
32
- Output data:
33
- errors: list[ValidationError]
34
- A list containing errors; in case of success, `errors` is the empty list: [].
35
-
36
- """
37
-
38
- from collections.abc import Callable
39
- from enum import Enum
40
- from typing import Any, TypeVar
41
-
42
- import numpy as np
43
-
44
- from power_grid_model._core.dataset_definitions import ComponentType, DatasetType
45
- from power_grid_model._core.enum import AngleMeasurementType, FaultPhase, FaultType, WindingType
46
- from power_grid_model._core.utils import get_comp_size, is_nan_or_default
47
- from power_grid_model.data_types import SingleDataset
48
- from power_grid_model.validation.errors import (
49
- ComparisonError,
50
- FaultPhaseError,
51
- IdNotInDatasetError,
52
- InfinityError,
53
- InvalidAssociatedEnumValueError,
54
- InvalidEnumValueError,
55
- InvalidIdError,
56
- MissingValueError,
57
- MissingVoltageAngleMeasurementError,
58
- MixedCurrentAngleMeasurementTypeError,
59
- MixedPowerCurrentSensorError,
60
- MultiComponentNotUniqueError,
61
- MultiFieldValidationError,
62
- NotBetweenError,
63
- NotBetweenOrAtError,
64
- NotBooleanError,
65
- NotGreaterOrEqualError,
66
- NotGreaterThanError,
67
- NotIdenticalError,
68
- NotLessOrEqualError,
69
- NotLessThanError,
70
- NotUniqueError,
71
- PQSigmaPairError,
72
- SameValueError,
73
- TransformerClockError,
74
- TwoValuesZeroError,
75
- UnsupportedMeasuredTerminalType,
76
- ValidationError,
77
- )
78
- from power_grid_model.validation.utils import _eval_expression, _get_mask, _get_valid_ids, _nan_type, _set_default_value
79
-
80
- Error = TypeVar("Error", bound=ValidationError)
81
- CompError = TypeVar("CompError", bound=ComparisonError)
82
-
83
-
84
- def all_greater_than_zero(data: SingleDataset, component: ComponentType, field: str) -> list[NotGreaterThanError]:
85
- """
86
- Check that for all records of a particular type of component, the values in the 'field' column are greater than
87
- zero. Returns an empty list on success, or a list containing a single error object on failure.
88
-
89
- Args:
90
- data (SingleDataset): The input/update data set for all components
91
- component (ComponentType): The component of interest
92
- field (str): The field of interest
93
-
94
- Returns:
95
- A list containing zero or one NotGreaterThanErrors, listing all ids where the value in the field of interest
96
- was zero or less.
97
- """
98
- return all_greater_than(data, component, field, 0.0)
99
-
100
-
101
- def all_greater_than_or_equal_to_zero(
102
- data: SingleDataset,
103
- component: ComponentType,
104
- field: str,
105
- default_value: np.ndarray | int | float | None = None,
106
- ) -> list[NotGreaterOrEqualError]:
107
- """
108
- Check that for all records of a particular type of component, the values in the 'field' column are greater than,
109
- or equal to zero. Returns an empty list on success, or a list containing a single error object on failure.
110
-
111
- Args:
112
- data (SingleDataset): The input/update data set for all components
113
- component (ComponentType) The component of interest
114
- field (str): The field of interest
115
- default_value (np.ndarray | int | float | None, optional): Some values are not required, but will
116
- receive a default value in the C++ core. To do a proper input validation, these default values should be
117
- included in the validation. It can be a fixed value for the entire column (int/float) or be different for
118
- each element (np.ndarray).
119
-
120
- Returns:
121
- A list containing zero or one NotGreaterOrEqualErrors, listing all ids where the value in the field of
122
- interest was less than zero.
123
- """
124
- return all_greater_or_equal(data, component, field, 0.0, default_value)
125
-
126
-
127
- def all_greater_than(
128
- data: SingleDataset, component: ComponentType, field: str, ref_value: int | float | str
129
- ) -> list[NotGreaterThanError]:
130
- """
131
- Check that for all records of a particular type of component, the values in the 'field' column are greater than
132
- the reference value. Returns an empty list on success, or a list containing a single error object on failure.
133
-
134
- Args:
135
- data: The input/update data set for all components
136
- component: The component of interest
137
- field: The field of interest
138
- ref_value: The reference value against which all values in the 'field' column are compared. If the reference
139
- value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a ratio between
140
- two fields (e.g. 'field_x / field_y')
141
-
142
- Returns:
143
- A list containing zero or one NotGreaterThanErrors, listing all ids where the value in the field of interest
144
- was less than, or equal to, the ref_value.
145
- """
146
-
147
- def not_greater(val: np.ndarray, *ref: np.ndarray):
148
- return np.less_equal(val, *ref)
149
-
150
- return none_match_comparison(data, component, field, not_greater, ref_value, NotGreaterThanError)
151
-
152
-
153
- def all_greater_or_equal(
154
- data: SingleDataset,
155
- component: ComponentType,
156
- field: str,
157
- ref_value: int | float | str,
158
- default_value: np.ndarray | int | float | None = None,
159
- ) -> list[NotGreaterOrEqualError]:
160
- """
161
- Check that for all records of a particular type of component, the values in the 'field' column are greater than,
162
- or equal to the reference value. Returns an empty list on success, or a list containing a single error object on
163
- failure.
164
-
165
- Args:
166
- data: The input/update data set for all components
167
- component: The component of interest
168
- field: The field of interest
169
- ref_value: The reference value against which all values in the 'field' column are compared. If the reference
170
- value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a ratio between
171
- two fields (e.g. 'field_x / field_y')
172
- default_value: Some values are not required, but will receive a default value in the C++ core. To do a proper
173
- input validation, these default values should be included in the validation. It can be a fixed value for the
174
- entire column (int/float) or be different for each element (np.ndarray).
175
-
176
- Returns:
177
- A list containing zero or one NotGreaterOrEqualErrors, listing all ids where the value in the field of
178
- interest was less than the ref_value.
179
-
180
- """
181
-
182
- def not_greater_or_equal(val: np.ndarray, *ref: np.ndarray):
183
- return np.less(val, *ref)
184
-
185
- return none_match_comparison(
186
- data, component, field, not_greater_or_equal, ref_value, NotGreaterOrEqualError, default_value
187
- )
188
-
189
-
190
- def all_less_than(
191
- data: SingleDataset, component: ComponentType, field: str, ref_value: int | float | str
192
- ) -> list[NotLessThanError]:
193
- """
194
- Check that for all records of a particular type of component, the values in the 'field' column are less than the
195
- reference value. Returns an empty list on success, or a list containing a single error object on failure.
196
-
197
- Args:
198
- data: The input/update data set for all components
199
- component: The component of interest
200
- field: The field of interest
201
- ref_value: The reference value against which all values in the 'field' column are compared. If the reference
202
- value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a ratio between
203
- two fields (e.g. 'field_x / field_y')
204
-
205
- Returns:
206
- A list containing zero or one NotLessThanErrors, listing all ids where the value in the field of interest was
207
- greater than, or equal to, the ref_value.
208
- """
209
-
210
- def not_less(val: np.ndarray, *ref: np.ndarray):
211
- return np.greater_equal(val, *ref)
212
-
213
- return none_match_comparison(data, component, field, not_less, ref_value, NotLessThanError)
214
-
215
-
216
- def all_less_or_equal(
217
- data: SingleDataset, component: ComponentType, field: str, ref_value: int | float | str
218
- ) -> list[NotLessOrEqualError]:
219
- """
220
- Check that for all records of a particular type of component, the values in the 'field' column are less than,
221
- or equal to the reference value. Returns an empty list on success, or a list containing a single error object on
222
- failure.
223
-
224
- Args:
225
- data: The input/update data set for all components
226
- component: The component of interest
227
- field: The field of interest
228
- ref_value: The reference value against which all values in the 'field' column are compared. If the reference
229
- value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a ratio between
230
- two fields (e.g. 'field_x / field_y')
231
-
232
- Returns:
233
- A list containing zero or one NotLessOrEqualErrors, listing all ids where the value in the field of interest was
234
- greater than the ref_value.
235
-
236
- """
237
-
238
- def not_less_or_equal(val: np.ndarray, *ref: np.ndarray):
239
- return np.greater(val, *ref)
240
-
241
- return none_match_comparison(data, component, field, not_less_or_equal, ref_value, NotLessOrEqualError)
242
-
243
-
244
- def all_between( # noqa: PLR0913
245
- data: SingleDataset,
246
- component: ComponentType,
247
- field: str,
248
- ref_value_1: int | float | str,
249
- ref_value_2: int | float | str,
250
- default_value: np.ndarray | int | float | None = None,
251
- ) -> list[NotBetweenError]:
252
- """
253
- Check that for all records of a particular type of component, the values in the 'field' column are (exclusively)
254
- between reference value 1 and 2. Value 1 may be smaller, but also larger than value 2. Returns an empty list on
255
- success, or a list containing a single error object on failure.
256
-
257
- Args:
258
- data: The input/update data set for all components
259
- component: The component of interest
260
- field: The field of interest
261
- ref_value_1: The first reference value against which all values in the 'field' column are compared. If the
262
- reference value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a
263
- ratio between two fields (e.g. 'field_x / field_y')
264
- ref_value_2: The second reference value against which all values in the 'field' column are compared. If the
265
- reference value is a string, it is assumed to be another field (e.g. 'field_x') of the same component,
266
- or a ratio between two fields (e.g. 'field_x / field_y')
267
- default_value: Some values are not required, but will receive a default value in the C++ core. To do a proper
268
- input validation, these default values should be included in the validation. It can be a fixed value for the
269
- entire column (int/float) or be different for each element (np.ndarray).
270
-
271
- Returns:
272
- A list containing zero or one NotBetweenErrors, listing all ids where the value in the field of interest was
273
- outside the range defined by the reference values.
274
- """
275
-
276
- def outside(val: np.ndarray, *ref: np.ndarray) -> np.ndarray:
277
- return np.logical_or(np.less_equal(val, np.minimum(*ref)), np.greater_equal(val, np.maximum(*ref)))
278
-
279
- return none_match_comparison(
280
- data, component, field, outside, (ref_value_1, ref_value_2), NotBetweenError, default_value
281
- )
282
-
283
-
284
- def all_between_or_at( # noqa: PLR0913
285
- data: SingleDataset,
286
- component: ComponentType,
287
- field: str,
288
- ref_value_1: int | float | str,
289
- ref_value_2: int | float | str,
290
- default_value_1: np.ndarray | int | float | None = None,
291
- default_value_2: np.ndarray | int | float | None = None,
292
- ) -> list[NotBetweenOrAtError]:
293
- """
294
- Check that for all records of a particular type of component, the values in the 'field' column are inclusively
295
- between reference value 1 and 2. Value 1 may be smaller, but also larger than value 2. Returns an empty list on
296
- success, or a list containing a single error object on failure.
297
-
298
- Args:
299
- data: The input/update data set for all components
300
- component: The component of interest
301
- field: The field of interest
302
- ref_value_1: The first reference value against which all values in the 'field' column are compared. If the
303
- reference value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a
304
- ratio between two fields (e.g. 'field_x / field_y')
305
- ref_value_2: The second reference value against which all values in the 'field' column are compared. If the
306
- reference value is a string, it is assumed to be another field (e.g. 'field_x') of the same component,
307
- or a ratio between two fields (e.g. 'field_x / field_y')
308
- default_value: Some values are not required, but will receive a default value in the C++ core. To do a proper
309
- input validation, these default values should be included in the validation. It can be a fixed value for the
310
- entire column (int/float) or be different for each element (np.ndarray).
311
- default_value_2: Some values can have a double default: the default will be set to another attribute of the
312
- component, but if that attribute is missing, the default will be set to a fixed value.
313
-
314
- Returns:
315
- A list containing zero or one NotBetweenOrAtErrors, listing all ids where the value in the field of interest was
316
- outside the range defined by the reference values.
317
- """
318
-
319
- def outside(val: np.ndarray, *ref: np.ndarray) -> np.ndarray:
320
- return np.logical_or(np.less(val, np.minimum(*ref)), np.greater(val, np.maximum(*ref)))
321
-
322
- return none_match_comparison(
323
- data,
324
- component,
325
- field,
326
- outside,
327
- (ref_value_1, ref_value_2),
328
- NotBetweenOrAtError,
329
- default_value_1,
330
- default_value_2,
331
- )
332
-
333
-
334
- def none_match_comparison( # noqa: PLR0913
335
- data: SingleDataset,
336
- component: ComponentType,
337
- field: str,
338
- compare_fn: Callable,
339
- ref_value: ComparisonError.RefType,
340
- error: type[CompError] = ComparisonError, # type: ignore
341
- default_value_1: np.ndarray | int | float | None = None,
342
- default_value_2: np.ndarray | int | float | None = None,
343
- ) -> list[CompError]:
344
- """
345
- For all records of a particular type of component, check if the value in the 'field' column match the comparison.
346
- Returns an empty list if none of the value match the comparison, or a list containing a single error object when at
347
- the value in 'field' of at least one record matches the comparison.
348
-
349
- Args:
350
- data: The input/update data set for all components
351
- component: The component of interest
352
- field: The field of interest
353
- compare_fn: A function that takes the data in the 'field' column, and any number of reference values
354
- ref_value: A reference value, or a tuple of reference values, against which all values in the 'field' column
355
- are compared using the compare_fn. If a reference value is a string, it is assumed to be another field
356
- (e.g. 'field_x') of the same component, or a ratio between two fields (e.g. 'field_x / field_y')
357
- error: The type (class) of error that should be returned in case any of the values match the comparison.
358
- default_value: Some values are not required, but will receive a default value in the C++ core. To do a proper
359
- input validation, these default values should be included in the validation. It can be a fixed value for the
360
- entire column (int/float) or be different for each element (np.ndarray).
361
- default_value_2: Some values can have a double default: the default will be set to another attribute of the
362
- component, but if that attribute is missing, the default will be set to a fixed value.
363
-
364
- Returns:
365
- A list containing zero or one comparison errors (should be a subclass of ComparisonError), listing all ids
366
- where the value in the field of interest matched the comparison.
367
- """
368
- if default_value_1 is not None:
369
- _set_default_value(data=data, component=component, field=field, default_value=default_value_1)
370
- if default_value_2 is not None:
371
- _set_default_value(data=data, component=component, field=field, default_value=default_value_2)
372
- component_data = data[component]
373
- if not isinstance(component_data, np.ndarray):
374
- raise NotImplementedError # TODO(mgovers): add support for columnar data
375
-
376
- if isinstance(ref_value, tuple):
377
- ref = tuple(_eval_expression(component_data, v) for v in ref_value)
378
- else:
379
- ref = (_eval_expression(component_data, ref_value),)
380
- matches = compare_fn(component_data[field], *ref)
381
- if matches.any():
382
- if matches.ndim > 1:
383
- matches = matches.any(axis=1)
384
- ids = component_data["id"][matches].flatten().tolist()
385
- return [error(component, field, ids, ref_value)]
386
- return []
387
-
388
-
389
- def all_identical(data: SingleDataset, component: ComponentType, field: str) -> list[NotIdenticalError]:
390
- """
391
- Check that for all records of a particular type of component, the values in the 'field' column are identical.
392
-
393
- Args:
394
- data (SingleDataset): The input/update data set for all components
395
- component (ComponentType): The component of interest
396
- field (str): The field of interest
397
-
398
- Returns:
399
- A list containing zero or one NotIdenticalError, listing all ids of that component if the value in the field
400
- of interest was not identical across all components, all values for those ids, the set of unique values in
401
- that field and the number of unique values in that field.
402
- """
403
- field_data = data[component][field]
404
- if len(field_data) > 0:
405
- first = field_data[0]
406
- if np.any(field_data != first):
407
- return [NotIdenticalError(component, field, data[component]["id"], list(field_data))]
408
-
409
- return []
410
-
411
-
412
- def all_enabled_identical(
413
- data: SingleDataset, component: ComponentType, field: str, status_field: str
414
- ) -> list[NotIdenticalError]:
415
- """
416
- Check that for all records of a particular type of component, the values in the 'field' column are identical.
417
- Only entries are checked where the 'status' field is not 0.
418
-
419
- Args:
420
- data (SingleDataset): The input/update data set for all components
421
- component (ComponentType): The component of interest
422
- field (str): The field of interest
423
- status_field (str): The status field based on which to decide whether a component is enabled
424
-
425
- Returns:
426
- A list containing zero or one NotIdenticalError, listing:
427
-
428
- - all ids of enabled components if the value in the field of interest was not identical across all enabled
429
- components
430
- - all values of the 'field' column for enabled components (including duplications)
431
- - the set of unique such values
432
- - the amount of unique such values.
433
- """
434
- return all_identical(
435
- {key: (value if key is not component else value[value[status_field] != 0]) for key, value in data.items()},
436
- component,
437
- field,
438
- )
439
-
440
-
441
- def all_unique(data: SingleDataset, component: ComponentType, field: str) -> list[NotUniqueError]:
442
- """
443
- Check that for all records of a particular type of component, the values in the 'field' column are unique within
444
- the 'field' column of that component.
445
-
446
- Args:
447
- data (SingleDataset): The input/update data set for all components
448
- component (ComponentType): The component of interest
449
- field (str): The field of interest
450
-
451
- Returns:
452
- A list containing zero or one NotUniqueError, listing all ids where the value in the field of interest was
453
- not unique. If the field name was 'id' (a very common check), the id is added as many times as it occurred in
454
- the 'id' column, to maintain object counts.
455
- """
456
- field_data = data[component][field]
457
- _, inverse, counts = np.unique(field_data, return_inverse=True, return_counts=True)
458
- if any(counts != 1):
459
- ids = data[component]["id"][(counts != 1)[inverse]].flatten().tolist()
460
- return [NotUniqueError(component, field, ids)]
461
- return []
462
-
463
-
464
- def all_cross_unique(
465
- data: SingleDataset, fields: list[tuple[ComponentType, str]], cross_only=True
466
- ) -> list[MultiComponentNotUniqueError]:
467
- """
468
- Check that for all records of a particular type of component, the values in the 'field' column are unique within
469
- the 'field' column of that component.
470
-
471
- Args:
472
- data (SingleDataset): The input/update data set for all components
473
- fields (list[tuple[str, str]]): The fields of interest, formatted as
474
- [(component_1, field_1), (component_2, field_2)]
475
- cross_only (bool, optional): Do not include duplicates within a single field. It is advised that you use
476
- all_unique() to explicitly check uniqueness within a single field.
477
-
478
- Returns:
479
- A list containing zero or one MultiComponentNotUniqueError, listing all fields and ids where the value was not
480
- unique between the fields.
481
- """
482
- all_values: dict[int, list[tuple[tuple[ComponentType, str], int]]] = {}
483
- duplicate_ids = set()
484
- for component, field in fields:
485
- for obj_id, value in zip(data[component]["id"], data[component][field]):
486
- component_id = ((component, field), obj_id)
487
- if value not in all_values:
488
- all_values[value] = []
489
- elif not cross_only or not all(f == (component, field) for f, _ in all_values[value]):
490
- duplicate_ids.update(all_values[value])
491
- duplicate_ids.add(component_id)
492
- all_values[value].append(component_id)
493
- if duplicate_ids:
494
- fields_with_duplicated_ids = {f for f, _ in duplicate_ids}
495
- ids_with_duplicated_ids = {(c, i) for (c, _), i in duplicate_ids}
496
- return [MultiComponentNotUniqueError(list(fields_with_duplicated_ids), list(ids_with_duplicated_ids))]
497
- return []
498
-
499
-
500
- def all_in_valid_values(
501
- data: SingleDataset, component: ComponentType, field: str, values: list
502
- ) -> list[UnsupportedMeasuredTerminalType]:
503
- """
504
- Check that for all records of a particular type of component, the values in the 'field' column are valid values for
505
- the supplied enum class. Returns an empty list on success, or a list containing a single error object on failure.
506
-
507
- Args:
508
- data (SingleDataset): The input/update data set for all components
509
- component (ComponentType): The component of interest
510
- field (str): The field of interest
511
- values (list | tuple): The values to validate against
512
-
513
- Returns:
514
- A list containing zero or one UnsupportedMeasuredTerminalType, listing all ids where the value in the field of
515
- interest was not a valid value and the sequence of supported values.
516
- """
517
- valid = {_nan_type(component, field)}
518
- valid.update(values)
519
-
520
- invalid = np.isin(data[component][field], np.array(list(valid)), invert=True)
521
- if invalid.any():
522
- ids = data[component]["id"][invalid].flatten().tolist()
523
- return [UnsupportedMeasuredTerminalType(component, field, ids, values)]
524
- return []
525
-
526
-
527
- def all_valid_enum_values(
528
- data: SingleDataset, component: ComponentType, field: str, enum: type[Enum] | list[type[Enum]]
529
- ) -> list[InvalidEnumValueError]:
530
- """
531
- Check that for all records of a particular type of component, the values in the 'field' column are valid values for
532
- the supplied enum class. Returns an empty list on success, or a list containing a single error object on failure.
533
-
534
- Args:
535
- data (SingleDataset): The input/update data set for all components
536
- component (ComponentType): The component of interest
537
- field (str): The field of interest
538
- enum (Type[Enum] | list[Type[Enum]]): The enum type to validate against, or a list of such enum types
539
-
540
- Returns:
541
- A list containing zero or one InvalidEnumValueError, listing all ids where the value in the field of interest
542
- was not a valid value in the supplied enum type.
543
- """
544
- enums: list[type[Enum]] = enum if isinstance(enum, list) else [enum]
545
-
546
- valid = {_nan_type(component, field)}
547
- for enum_type in enums:
548
- valid.update(list(enum_type))
549
-
550
- invalid = np.isin(data[component][field], np.array(list(valid), dtype=np.int8), invert=True)
551
- if invalid.any():
552
- ids = data[component]["id"][invalid].flatten().tolist()
553
- return [InvalidEnumValueError(component, field, ids, enum)]
554
- return []
555
-
556
-
557
- def all_valid_associated_enum_values( # noqa: PLR0913
558
- data: SingleDataset,
559
- component: ComponentType,
560
- field: str,
561
- ref_object_id_field: str,
562
- ref_components: list[ComponentType],
563
- enum: type[Enum] | list[type[Enum]],
564
- **filters: Any,
565
- ) -> list[InvalidAssociatedEnumValueError]:
566
- """
567
- Args:
568
- data (SingleDataset): The input/update data set for all components
569
- component (ComponentType): The component of interest
570
- field (str): The field of interest
571
- ref_object_id_field (str): The field that contains the referenced component ids
572
- ref_components (list[ComponentType]): The component or components in which we want to look for ids
573
- enum (Type[Enum] | list[Type[Enum]]): The enum type to validate against, or a list of such enum types
574
- **filters: One or more filters on the dataset. E.g. regulated_object="transformer".
575
-
576
- Returns:
577
- A list containing zero or one InvalidAssociatedEnumValueError, listing all ids where the value in the field
578
- of interest was not a valid value in the supplied enum type.
579
- """
580
- enums: list[type[Enum]] = enum if isinstance(enum, list) else [enum]
581
-
582
- valid_ids = _get_valid_ids(data=data, ref_components=ref_components)
583
- mask = np.logical_and(
584
- _get_mask(data=data, component=component, field=field, **filters),
585
- np.isin(data[component][ref_object_id_field], valid_ids),
586
- )
587
-
588
- valid = {_nan_type(component, field)}
589
- for enum_type in enums:
590
- valid.update(list(enum_type))
591
-
592
- invalid = np.isin(data[component][field][mask], np.array(list(valid), dtype=np.int8), invert=True)
593
- if invalid.any():
594
- ids = data[component]["id"][mask][invalid].flatten().tolist()
595
- return [InvalidAssociatedEnumValueError(component, [field, ref_object_id_field], ids, enum)]
596
- return []
597
-
598
-
599
- def all_valid_ids(
600
- data: SingleDataset,
601
- component: ComponentType,
602
- field: str,
603
- ref_components: ComponentType | list[ComponentType],
604
- **filters: Any,
605
- ) -> list[InvalidIdError]:
606
- """
607
- For a column which should contain object identifiers (ids), check if the id exists in the data, for a specific set
608
- of reference component types. E.g. is the from_node field of each line referring to an existing node id?
609
-
610
- Args:
611
- data: The input/update data set for all components
612
- component: The component of interest
613
- field: The field of interest
614
- ref_components: The component or components in which we want to look for ids
615
- **filters: One or more filters on the dataset. E.g. measured_terminal_type=MeasuredTerminalType.source.
616
-
617
- Returns:
618
- A list containing zero or one InvalidIdError, listing all ids where the value in the field of interest
619
- was not a valid object identifier.
620
- """
621
- valid_ids = _get_valid_ids(data=data, ref_components=ref_components)
622
- mask = _get_mask(data=data, component=component, field=field, **filters)
623
-
624
- # Find any values that can't be found in the set of ids
625
- invalid = np.logical_and(mask, np.isin(data[component][field], valid_ids, invert=True))
626
- if invalid.any():
627
- ids = data[component]["id"][invalid].flatten().tolist()
628
- return [InvalidIdError(component, field, ids, ref_components, filters)]
629
- return []
630
-
631
-
632
- def all_boolean(data: SingleDataset, component: ComponentType, field: str) -> list[NotBooleanError]:
633
- """
634
- Check that for all records of a particular type of component, the values in the 'field' column are valid boolean
635
- values, i.e. 0 or 1. Returns an empty list on success, or a list containing a single error object on failure.
636
-
637
- Args:
638
- data: The input/update data set for all components
639
- component: The component of interest
640
- field: The field of interest
641
-
642
- Returns:
643
- A list containing zero or one NotBooleanError, listing all ids where the value in the field of interest was not
644
- a valid boolean value.
645
- """
646
- invalid = np.isin(data[component][field], [0, 1], invert=True)
647
- if invalid.any():
648
- ids = data[component]["id"][invalid].flatten().tolist()
649
- return [NotBooleanError(component, field, ids)]
650
- return []
651
-
652
-
653
- def all_not_two_values_zero(
654
- data: SingleDataset, component: ComponentType, field_1: str, field_2: str
655
- ) -> list[TwoValuesZeroError]:
656
- """
657
- Check that for all records of a particular type of component, the values in the 'field_1' and 'field_2' column are
658
- not both zero. Returns an empty list on success, or a list containing a single error object on failure.
659
-
660
- Args:
661
- data: The input/update data set for all components
662
- component: The component of interest
663
- field_1: The first field of interest
664
- field_2: The second field of interest
665
-
666
- Returns:
667
- A list containing zero or one TwoValuesZeroError, listing all ids where the value in the two fields of interest
668
- were both zero.
669
- """
670
- invalid = np.logical_and(np.equal(data[component][field_1], 0.0), np.equal(data[component][field_2], 0.0))
671
- if invalid.any():
672
- if invalid.ndim > 1:
673
- invalid = invalid.any(axis=1)
674
- ids = data[component]["id"][invalid].flatten().tolist()
675
- return [TwoValuesZeroError(component, [field_1, field_2], ids)]
676
- return []
677
-
678
-
679
- def all_not_two_values_equal(
680
- data: SingleDataset, component: ComponentType, field_1: str, field_2: str
681
- ) -> list[SameValueError]:
682
- """
683
- Check that for all records of a particular type of component, the values in the 'field_1' and 'field_2' column are
684
- not both the same value. E.g. from_node and to_node of a line. Returns an empty list on success, or a list
685
- containing a single error object on failure.
686
-
687
- Args:
688
- data: The input/update data set for all components
689
- component: The component of interest
690
- field_1: The first field of interest
691
- field_2: The second field of interest
692
-
693
- Returns:
694
- A list containing zero or one SameValueError, listing all ids where the value in the two fields of interest
695
- were both the same.
696
- """
697
- invalid = np.equal(data[component][field_1], data[component][field_2])
698
- if invalid.any():
699
- if invalid.ndim > 1:
700
- invalid = invalid.any(axis=1)
701
- ids = data[component]["id"][invalid].flatten().tolist()
702
- return [SameValueError(component, [field_1, field_2], ids)]
703
- return []
704
-
705
-
706
- def ids_valid_in_update_data_set(
707
- update_data: SingleDataset, ref_data: SingleDataset, component: ComponentType, ref_name: DatasetType
708
- ) -> list[IdNotInDatasetError | InvalidIdError]:
709
- """
710
- Check that for all records of a particular type of component, whether the ids:
711
- - exist and match those in the reference data set
712
- - are not present but qualifies for optional id
713
-
714
- Args:
715
- update_data: The update data set for all components
716
- ref_data: The reference (input) data set for all components
717
- component: The component of interest
718
- ref_name: The name of the reference data set type
719
-
720
- Returns:
721
- A list containing zero or one IdNotInDatasetError, listing all ids of the objects in the data set which do not
722
- exist in the reference data set.
723
- """
724
- component_data = update_data[component]
725
- component_ref_data = ref_data[component]
726
- if component_ref_data["id"].size == 0:
727
- return [InvalidIdError(component=component, field="id", ids=None)]
728
- id_field_is_nan = np.array(is_nan_or_default(component_data["id"]))
729
- # check whether id qualify for optional
730
- if component_data["id"].size == 0 or np.all(id_field_is_nan):
731
- # check if the dimension of the component_data is the same as the component_ref_data
732
- if get_comp_size(component_data) != get_comp_size(component_ref_data):
733
- return [InvalidIdError(component=component, field="id", ids=None)]
734
- return [] # supported optional id
735
-
736
- if np.all(id_field_is_nan) and not np.all(~id_field_is_nan):
737
- return [InvalidIdError(component=component, field="id", ids=None)]
738
-
739
- # normal check: exist and match with input
740
- invalid = np.isin(component_data["id"], component_ref_data["id"], invert=True)
741
- if invalid.any():
742
- ids = component_data["id"][invalid].flatten().tolist()
743
- return [IdNotInDatasetError(component, ids, ref_name)]
744
- return []
745
-
746
-
747
- def all_finite(data: SingleDataset, exceptions: dict[ComponentType, list[str]] | None = None) -> list[InfinityError]:
748
- """
749
- Check that for all records in all component, the values in all columns are finite value, i.e. float values other
750
- than inf, or -inf. Nan values are ignored, as in all other comparison functions. You can use non_missing() to
751
- check for missing/nan values. Returns an empty list on success, or a list containing an error object for each
752
- component/field combination where.
753
-
754
- Args:
755
- data: The input/update data set for all components
756
- exceptions:
757
- A dictionary of fields per component type for which infinite values are supported. Defaults to empty.
758
-
759
- Returns:
760
- A list containing zero or one NotBooleanError, listing all ids where the value in the field of interest was not
761
- a valid boolean value.
762
- """
763
- errors = []
764
- for component, array in data.items():
765
- if not isinstance(array, np.ndarray):
766
- raise NotImplementedError # TODO(mgovers): add support for columnar data
767
-
768
- for field, (dtype, _) in array.dtype.fields.items():
769
- if not np.issubdtype(dtype, np.floating):
770
- continue
771
-
772
- if exceptions and field in exceptions.get(component, []):
773
- continue
774
-
775
- invalid = np.isinf(array[field])
776
- if invalid.any():
777
- ids = array["id"][invalid].flatten().tolist()
778
- errors.append(InfinityError(component, field, ids))
779
- return errors
780
-
781
-
782
- def no_strict_subset_missing(data: SingleDataset, fields: list[str], component_type: ComponentType):
783
- """
784
- Helper function that generates multi field validation errors if a subset of the supplied fields is missing.
785
- If for an instance of component type all fields are missing or all fields are not missing then,
786
- no error is returned for that instance.
787
- In any other case an error for that id is returned.
788
-
789
- Args:
790
- data: SingleDataset, pgm data
791
- fields: List of fields
792
- component_type: component type to check
793
- """
794
- errors = []
795
- if component_type in data:
796
- component_data = data[component_type]
797
- instances_with_nan_data = np.full_like([], fill_value=False, shape=(len(component_data),), dtype=bool)
798
- instances_with_non_nan_data = np.full_like([], fill_value=False, shape=(len(component_data),), dtype=bool)
799
- for field in fields:
800
- nan_value = _nan_type(component_type, field)
801
- asym_axes = tuple(range(component_data.ndim, component_data[field].ndim))
802
- instances_with_nan_data = np.logical_or(
803
- instances_with_nan_data,
804
- np.any(
805
- (
806
- np.isnan(component_data[field])
807
- if np.any(np.isnan(nan_value))
808
- else np.equal(component_data[field], nan_value)
809
- ),
810
- axis=asym_axes,
811
- ),
812
- )
813
- instances_with_non_nan_data = np.logical_or(
814
- instances_with_non_nan_data,
815
- np.any(
816
- (
817
- np.logical_not(np.isnan(component_data[field]))
818
- if np.any(np.isnan(nan_value))
819
- else np.logical_not(np.equal(component_data[field], nan_value))
820
- ),
821
- axis=asym_axes,
822
- ),
823
- )
824
-
825
- instances_with_invalid_data = np.logical_and(instances_with_nan_data, instances_with_non_nan_data)
826
-
827
- ids = component_data["id"][instances_with_invalid_data]
828
- if len(ids) > 0:
829
- errors.append(MultiFieldValidationError(component_type, fields, ids))
830
-
831
- return errors
832
-
833
-
834
- def not_all_missing(data: SingleDataset, fields: list[str], component_type: ComponentType):
835
- """
836
- Helper function that generates a multi field validation error if:
837
- all values specified by the fields parameters are missing.
838
-
839
- Args:
840
- data: SingleDataset, pgm data
841
- fields: List of fields
842
- component_type: component type to check
843
- """
844
- min_fields = 2
845
- if len(fields) < min_fields:
846
- raise ValueError(
847
- "The fields parameter must contain at least 2 fields. Otherwise use the none_missing function."
848
- )
849
-
850
- errors = []
851
- if component_type in data:
852
- component_data = data[component_type]
853
- instances_with_all_nan_data = np.full_like([], fill_value=True, shape=(len(component_data),), dtype=bool)
854
-
855
- for field in fields:
856
- nan_value = _nan_type(component_type, field)
857
- asym_axes = tuple(range(component_data.ndim, component_data[field].ndim))
858
- instances_with_all_nan_data = np.logical_and(
859
- instances_with_all_nan_data,
860
- np.any(
861
- (
862
- np.isnan(component_data[field])
863
- if np.any(np.isnan(nan_value))
864
- else np.equal(component_data[field], nan_value)
865
- ),
866
- axis=asym_axes,
867
- ),
868
- )
869
-
870
- ids = component_data["id"][instances_with_all_nan_data].flatten().tolist()
871
- if len(ids) > 0:
872
- errors.append(MultiFieldValidationError(component_type, fields, ids))
873
-
874
- return errors
875
-
876
-
877
- def none_missing(data: SingleDataset, component: ComponentType, fields: str | list[str]) -> list[MissingValueError]:
878
- """
879
- Check that for all records of a particular type of component, the values in the 'fields' columns are not NaN.
880
- Returns an empty list on success, or a list containing a single error object on failure.
881
-
882
- Args:
883
- data: The input/update data set for all components
884
- component: The component of interest
885
- fields: The fields of interest
886
-
887
- Returns:
888
- A list containing zero or more MissingValueError; one for each field, listing all ids where the value in the
889
- field was NaN.
890
- """
891
- errors = []
892
- if isinstance(fields, str):
893
- fields = [fields]
894
- for field in fields:
895
- nan = _nan_type(component, field)
896
- invalid = np.isnan(data[component][field]) if np.isnan(nan) else np.equal(data[component][field], nan)
897
-
898
- if invalid.any():
899
- # handle both symmetric and asymmetric values
900
- invalid = np.any(invalid, axis=tuple(range(1, invalid.ndim)))
901
- ids = data[component]["id"][invalid].flatten().tolist()
902
- errors.append(MissingValueError(component, field, ids))
903
- return errors
904
-
905
-
906
- def valid_p_q_sigma(data: SingleDataset, component: ComponentType) -> list[PQSigmaPairError]:
907
- """
908
- Check validity of the pair `(p_sigma, q_sigma)` for 'sym_power_sensor' and 'asym_power_sensor'.
909
-
910
- Args:
911
- data: The input/update data set for all components
912
- component: The component of interest, in this case only 'sym_power_sensor' or 'asym_power_sensor'
913
-
914
- Returns:
915
- A list containing zero or one PQSigmaPairError, listing the p_sigma and q_sigma mismatch.
916
- Note that with asymetric power sensors, partial assignment of p_sigma and q_sigma is also considered mismatch.
917
- """
918
- errors = []
919
- p_sigma = data[component]["p_sigma"]
920
- q_sigma = data[component]["q_sigma"]
921
- p_nan = np.isnan(p_sigma)
922
- q_nan = np.isnan(q_sigma)
923
- mis_match = p_nan != q_nan
924
- if p_sigma.ndim > 1: # if component == 'asym_power_sensor':
925
- mis_match = mis_match.any(axis=-1)
926
- mis_match |= np.logical_xor(p_nan.any(axis=-1), p_nan.all(axis=-1))
927
- mis_match |= np.logical_xor(q_nan.any(axis=-1), q_nan.all(axis=-1))
928
-
929
- if mis_match.any():
930
- ids = data[component]["id"][mis_match].flatten().tolist()
931
- errors.append(PQSigmaPairError(component, ["p_sigma", "q_sigma"], ids))
932
- return errors
933
-
934
-
935
- def all_valid_clocks(
936
- data: SingleDataset, component: ComponentType, clock_field: str, winding_from_field: str, winding_to_field: str
937
- ) -> list[TransformerClockError]:
938
- """
939
- Custom validation rule: Odd clock number is only allowed for Dy(n) or Y(N)d configuration.
940
-
941
- Args:
942
- data: The input/update data set for all components
943
- component: The component of interest
944
- clock_field: The clock field
945
- winding_from_field: The winding from field
946
- winding_to_field: The winding to field
947
-
948
- Returns:
949
- A list containing zero or more TransformerClockErrors; listing all the ids of transformers where the clock was
950
- invalid, given the winding type.
951
- """
952
-
953
- clk = data[component][clock_field]
954
- wfr = data[component][winding_from_field]
955
- wto = data[component][winding_to_field]
956
- wfr_is_wye = np.isin(wfr, [WindingType.wye, WindingType.wye_n])
957
- wto_is_wye = np.isin(wto, [WindingType.wye, WindingType.wye_n])
958
- odd = clk % 2 == 1
959
- # even number is not possible if one side is wye winding and the other side is not wye winding.
960
- # odd number is not possible, if both sides are wye winding or both sides are not wye winding.
961
- err = (~odd & (wfr_is_wye != wto_is_wye)) | (odd & (wfr_is_wye == wto_is_wye))
962
- if err.any():
963
- return [
964
- TransformerClockError(
965
- component=component,
966
- fields=[clock_field, winding_from_field, winding_to_field],
967
- ids=data[component]["id"][err].flatten().tolist(),
968
- )
969
- ]
970
- return []
971
-
972
-
973
- def all_same_current_angle_measurement_type_on_terminal(
974
- data: SingleDataset,
975
- component: ComponentType,
976
- measured_object_field: str,
977
- measured_terminal_type_field: str,
978
- angle_measurement_type_field: str,
979
- ) -> list[MixedCurrentAngleMeasurementTypeError]:
980
- """
981
- Custom validation rule: All current angle measurement types on a terminal must be the same.
982
-
983
- Args:
984
- data (SingleDataset): The input/update data set for all components
985
- component (ComponentType): The component of interest
986
- measured_object_field (str): The measured object field
987
- measured_terminal_type_field (str): The terminal field
988
- angle_measurement_type_field (str): The angle measurement type field
989
-
990
- Returns:
991
- A list containing zero or more MixedCurrentAngleMeasurementTypeErrors; listing all the ids of
992
- components where the current angle measurement type was not the same for the same terminal.
993
- """
994
- sorted_indices = np.argsort(data[component][[measured_object_field, measured_terminal_type_field]])
995
- sorted_values = data[component][sorted_indices]
996
-
997
- unique_current_measurements, measurement_sorted_indices = np.unique(
998
- sorted_values[[measured_object_field, measured_terminal_type_field, angle_measurement_type_field]],
999
- return_inverse=True,
1000
- )
1001
- _, terminal_sorted_indices = np.unique(
1002
- unique_current_measurements[[measured_object_field, measured_terminal_type_field]], return_inverse=True
1003
- )
1004
-
1005
- mixed_sorted_indices = np.setdiff1d(measurement_sorted_indices, terminal_sorted_indices)
1006
- mixed_terminals = np.unique(
1007
- sorted_values[mixed_sorted_indices][[measured_object_field, measured_terminal_type_field]]
1008
- )
1009
-
1010
- err = np.isin(data[component][[measured_object_field, measured_terminal_type_field]], mixed_terminals)
1011
- if err.any():
1012
- return [
1013
- MixedCurrentAngleMeasurementTypeError(
1014
- component=component,
1015
- fields=[measured_object_field, measured_terminal_type_field, angle_measurement_type_field],
1016
- ids=data[component]["id"][err].flatten().tolist(),
1017
- )
1018
- ]
1019
- return []
1020
-
1021
-
1022
- def all_same_sensor_type_on_same_terminal(
1023
- data: SingleDataset,
1024
- power_sensor_type: ComponentType,
1025
- current_sensor_type: ComponentType,
1026
- measured_object_field: str,
1027
- measured_terminal_type_field: str,
1028
- ) -> list[MixedPowerCurrentSensorError]:
1029
- """
1030
- Custom validation rule: All sensors on a terminal must be of the same type.
1031
-
1032
- E.g. mixing sym_power_sensor and asym_power_sensor on the same terminal is allowed, but mixing
1033
- sym_power_sensor and sym_current_sensor is not allowed.
1034
-
1035
- Args:
1036
- data (SingleDataset): The input/update data set for all components
1037
- power_sensor_type (ComponentType): The power sensor component
1038
- current_sensor_type (ComponentType): The current sensor component
1039
- measured_object_field (str): The measured object field
1040
- measured_terminal_type_field (str): The measured terminal type field
1041
-
1042
- Returns:
1043
- A list containing zero or more MixedPowerCurrentSensorError; listing all the ids of
1044
- components that measure the same terminal of the same component in different, unsupported ways.
1045
- """
1046
- power_sensor_data = data[power_sensor_type]
1047
- current_sensor_data = data[current_sensor_type]
1048
- power_sensor_measured_terminals = power_sensor_data[[measured_object_field, measured_terminal_type_field]]
1049
- current_sensor_measured_terminals = current_sensor_data[[measured_object_field, measured_terminal_type_field]]
1050
-
1051
- mixed_terminals = np.intersect1d(power_sensor_measured_terminals, current_sensor_measured_terminals)
1052
- if mixed_terminals.size != 0:
1053
- mixed_power_sensor_ids = power_sensor_data["id"][np.isin(power_sensor_measured_terminals, mixed_terminals)]
1054
- mixed_current_sensor_ids = current_sensor_data["id"][
1055
- np.isin(current_sensor_measured_terminals, mixed_terminals)
1056
- ]
1057
-
1058
- return [
1059
- MixedPowerCurrentSensorError(
1060
- fields=[
1061
- (power_sensor_type, measured_object_field),
1062
- (power_sensor_type, measured_terminal_type_field),
1063
- (current_sensor_type, measured_object_field),
1064
- (current_sensor_type, measured_terminal_type_field),
1065
- ],
1066
- ids=[(power_sensor_type, s) for s in mixed_power_sensor_ids.flatten().tolist()]
1067
- + [(current_sensor_type, s) for s in mixed_current_sensor_ids.flatten().tolist()],
1068
- )
1069
- ]
1070
- return []
1071
-
1072
-
1073
- def all_valid_fault_phases(
1074
- data: SingleDataset, component: ComponentType, fault_type_field: str, fault_phase_field: str
1075
- ) -> list[FaultPhaseError]:
1076
- """
1077
- Custom validation rule: Only a subset of fault_phases is supported for each fault type.
1078
-
1079
- Args:
1080
- data (SingleDataset): The input/update data set for all components
1081
- component (ComponentType): The component of interest
1082
- fault_type_field (str): The fault type field
1083
- fault_phase_field (str): The fault phase field
1084
-
1085
- Returns:
1086
- A list containing zero or more FaultPhaseErrors; listing all the ids of faults where the fault phase was
1087
- invalid, given the fault phase.
1088
- """
1089
- fault_types = data[component][fault_type_field]
1090
- fault_phases = data[component][fault_phase_field]
1091
-
1092
- supported_combinations: dict[FaultType, list[FaultPhase]] = {
1093
- FaultType.three_phase: [FaultPhase.abc, FaultPhase.default_value, FaultPhase.nan],
1094
- FaultType.single_phase_to_ground: [
1095
- FaultPhase.a,
1096
- FaultPhase.b,
1097
- FaultPhase.c,
1098
- FaultPhase.default_value,
1099
- FaultPhase.nan,
1100
- ],
1101
- FaultType.two_phase: [FaultPhase.ab, FaultPhase.ac, FaultPhase.bc, FaultPhase.default_value, FaultPhase.nan],
1102
- FaultType.two_phase_to_ground: [
1103
- FaultPhase.ab,
1104
- FaultPhase.ac,
1105
- FaultPhase.bc,
1106
- FaultPhase.default_value,
1107
- FaultPhase.nan,
1108
- ],
1109
- FaultType.nan: [],
1110
- }
1111
-
1112
- def _fault_phase_unsupported(fault_type: FaultType, fault_phase: FaultPhase):
1113
- return fault_phase not in supported_combinations.get(fault_type, [])
1114
-
1115
- err = np.vectorize(_fault_phase_unsupported)(fault_type=fault_types, fault_phase=fault_phases)
1116
- if err.any():
1117
- return [
1118
- FaultPhaseError(
1119
- component=component,
1120
- fields=[fault_type_field, fault_phase_field],
1121
- ids=data[component]["id"][err].flatten().tolist(),
1122
- )
1123
- ]
1124
- return []
1125
-
1126
-
1127
- def any_voltage_angle_measurement_if_global_current_measurement(
1128
- data: SingleDataset,
1129
- component: ComponentType,
1130
- angle_measurement_type_filter: tuple[str, AngleMeasurementType],
1131
- voltage_sensor_u_angle_measured: dict[ComponentType, str],
1132
- ) -> list[MissingVoltageAngleMeasurementError]:
1133
- """Require a voltage angle measurement if a global angle current measurement is present.
1134
-
1135
- Args:
1136
- data (SingleDataset): The input/update data set for all components
1137
- component (ComponentType): The component of interest
1138
- angle_measurement_type_filter (tuple[str, AngleMeasurementType]):
1139
- The angle measurement type field and value to check for
1140
- voltage_sensor_u_angle_measured (dict[ComponentType, str]):
1141
- The voltage angle measure field for each voltage sensor type
1142
-
1143
- Returns:
1144
- A list containing zero or more MissingVoltageAngleMeasurementError; listing all the ids of global angle current
1145
- sensors that require at least one voltage angle measurement.
1146
- """
1147
- angle_measurement_type_field, angle_measurement_type = angle_measurement_type_filter
1148
-
1149
- current_sensors = data[component]
1150
- if np.all(current_sensors[angle_measurement_type_field] != angle_measurement_type):
1151
- return []
1152
-
1153
- for voltage_sensor_type, voltage_angle_field in voltage_sensor_u_angle_measured.items():
1154
- if (np.isfinite(data[voltage_sensor_type][voltage_angle_field])).any():
1155
- return []
1156
-
1157
- voltage_and_current_sensor_ids = {sensor: data[sensor]["id"] for sensor in voltage_sensor_u_angle_measured}
1158
- voltage_and_current_sensor_ids[component] = current_sensors[
1159
- current_sensors[angle_measurement_type_field] == angle_measurement_type
1160
- ]["id"]
1161
-
1162
- return [
1163
- MissingVoltageAngleMeasurementError(
1164
- fields=[(component, angle_measurement_type_field), *list(voltage_sensor_u_angle_measured.items())],
1165
- ids=[
1166
- (sensor_type, id_)
1167
- for sensor_type, sensor_data in voltage_and_current_sensor_ids.items()
1168
- for id_ in sensor_data.flatten().tolist()
1169
- ],
1170
- )
1171
- ]
1
+ # SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
2
+ #
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ """
6
+ This module contains a set of comparison rules. They all share the same (or similar) logic and interface.
7
+
8
+ In general each function checks the values in a single 'column' (i.e. field) of a numpy structured array and it
9
+ returns an error object containing the component, the field and the ids of the records that did not match the rule.
10
+ E.g. all_greater_than_zero(data, 'node', 'u_rated') returns a NotGreaterThanError if any of the node's `u_rated`
11
+ values are 0 or less.
12
+
13
+ In general, the rules are designed to ignore NaN values, except for none_missing() which explicitly checks for NaN
14
+ values in the entire data set. It is important to understand that np.less_equal(x) yields different results than
15
+ np.logical_not(np.greater(x)) as a NaN comparison always results in False. The most extreme example is that even
16
+ np.nan == np.nan yields False.
17
+
18
+ np.less_equal( [0.1, 0.2, 0.3, np.nan], 0.0) = [False, False, False, False] -> OK
19
+ np.logical_not(np.greater([0.1, 0.2, 0.3, np.nan], 0.0)) = [False, False, False, True] -> Error (false positive)
20
+
21
+ Input data:
22
+
23
+ data: SingleDataset
24
+ The entire input/update data set
25
+
26
+ component: ComponentType
27
+ The name of the component, which should be an existing key in the data
28
+
29
+ field: str
30
+ The name of the column, which should be an field in the component data (numpy structured array)
31
+
32
+ Output data:
33
+ errors: list[ValidationError]
34
+ A list containing errors; in case of success, `errors` is the empty list: [].
35
+
36
+ """
37
+
38
+ from collections.abc import Callable
39
+ from enum import Enum
40
+ from typing import Any, TypeVar
41
+
42
+ import numpy as np
43
+
44
+ from power_grid_model._core.dataset_definitions import ComponentType, DatasetType
45
+ from power_grid_model._core.enum import AngleMeasurementType, FaultPhase, FaultType, WindingType
46
+ from power_grid_model._core.utils import get_comp_size, is_nan_or_default
47
+ from power_grid_model.data_types import SingleDataset
48
+ from power_grid_model.validation.errors import (
49
+ ComparisonError,
50
+ FaultPhaseError,
51
+ IdNotInDatasetError,
52
+ InfinityError,
53
+ InvalidAssociatedEnumValueError,
54
+ InvalidEnumValueError,
55
+ InvalidIdError,
56
+ MissingValueError,
57
+ MissingVoltageAngleMeasurementError,
58
+ MixedCurrentAngleMeasurementTypeError,
59
+ MixedPowerCurrentSensorError,
60
+ MultiComponentNotUniqueError,
61
+ MultiFieldValidationError,
62
+ NotBetweenError,
63
+ NotBetweenOrAtError,
64
+ NotBooleanError,
65
+ NotGreaterOrEqualError,
66
+ NotGreaterThanError,
67
+ NotIdenticalError,
68
+ NotLessOrEqualError,
69
+ NotLessThanError,
70
+ NotUniqueError,
71
+ PQSigmaPairError,
72
+ SameValueError,
73
+ TransformerClockError,
74
+ TwoValuesZeroError,
75
+ UnsupportedMeasuredTerminalType,
76
+ ValidationError,
77
+ )
78
+ from power_grid_model.validation.utils import _eval_expression, _get_mask, _get_valid_ids, _nan_type, _set_default_value
79
+
80
+ Error = TypeVar("Error", bound=ValidationError)
81
+ CompError = TypeVar("CompError", bound=ComparisonError)
82
+
83
+
84
+ def all_greater_than_zero(data: SingleDataset, component: ComponentType, field: str) -> list[NotGreaterThanError]:
85
+ """
86
+ Check that for all records of a particular type of component, the values in the 'field' column are greater than
87
+ zero. Returns an empty list on success, or a list containing a single error object on failure.
88
+
89
+ Args:
90
+ data (SingleDataset): The input/update data set for all components
91
+ component (ComponentType): The component of interest
92
+ field (str): The field of interest
93
+
94
+ Returns:
95
+ A list containing zero or one NotGreaterThanErrors, listing all ids where the value in the field of interest
96
+ was zero or less.
97
+ """
98
+ return all_greater_than(data, component, field, 0.0)
99
+
100
+
101
+ def all_greater_than_or_equal_to_zero(
102
+ data: SingleDataset,
103
+ component: ComponentType,
104
+ field: str,
105
+ default_value: np.ndarray | int | float | None = None,
106
+ ) -> list[NotGreaterOrEqualError]:
107
+ """
108
+ Check that for all records of a particular type of component, the values in the 'field' column are greater than,
109
+ or equal to zero. Returns an empty list on success, or a list containing a single error object on failure.
110
+
111
+ Args:
112
+ data (SingleDataset): The input/update data set for all components
113
+ component (ComponentType) The component of interest
114
+ field (str): The field of interest
115
+ default_value (np.ndarray | int | float | None, optional): Some values are not required, but will
116
+ receive a default value in the C++ core. To do a proper input validation, these default values should be
117
+ included in the validation. It can be a fixed value for the entire column (int/float) or be different for
118
+ each element (np.ndarray).
119
+
120
+ Returns:
121
+ A list containing zero or one NotGreaterOrEqualErrors, listing all ids where the value in the field of
122
+ interest was less than zero.
123
+ """
124
+ return all_greater_or_equal(data, component, field, 0.0, default_value)
125
+
126
+
127
+ def all_greater_than(
128
+ data: SingleDataset, component: ComponentType, field: str, ref_value: int | float | str
129
+ ) -> list[NotGreaterThanError]:
130
+ """
131
+ Check that for all records of a particular type of component, the values in the 'field' column are greater than
132
+ the reference value. Returns an empty list on success, or a list containing a single error object on failure.
133
+
134
+ Args:
135
+ data: The input/update data set for all components
136
+ component: The component of interest
137
+ field: The field of interest
138
+ ref_value: The reference value against which all values in the 'field' column are compared. If the reference
139
+ value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a ratio between
140
+ two fields (e.g. 'field_x / field_y')
141
+
142
+ Returns:
143
+ A list containing zero or one NotGreaterThanErrors, listing all ids where the value in the field of interest
144
+ was less than, or equal to, the ref_value.
145
+ """
146
+
147
+ def not_greater(val: np.ndarray, *ref: np.ndarray):
148
+ return np.less_equal(val, *ref)
149
+
150
+ return none_match_comparison(data, component, field, not_greater, ref_value, NotGreaterThanError)
151
+
152
+
153
+ def all_greater_or_equal(
154
+ data: SingleDataset,
155
+ component: ComponentType,
156
+ field: str,
157
+ ref_value: int | float | str,
158
+ default_value: np.ndarray | int | float | None = None,
159
+ ) -> list[NotGreaterOrEqualError]:
160
+ """
161
+ Check that for all records of a particular type of component, the values in the 'field' column are greater than,
162
+ or equal to the reference value. Returns an empty list on success, or a list containing a single error object on
163
+ failure.
164
+
165
+ Args:
166
+ data: The input/update data set for all components
167
+ component: The component of interest
168
+ field: The field of interest
169
+ ref_value: The reference value against which all values in the 'field' column are compared. If the reference
170
+ value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a ratio between
171
+ two fields (e.g. 'field_x / field_y')
172
+ default_value: Some values are not required, but will receive a default value in the C++ core. To do a proper
173
+ input validation, these default values should be included in the validation. It can be a fixed value for the
174
+ entire column (int/float) or be different for each element (np.ndarray).
175
+
176
+ Returns:
177
+ A list containing zero or one NotGreaterOrEqualErrors, listing all ids where the value in the field of
178
+ interest was less than the ref_value.
179
+
180
+ """
181
+
182
+ def not_greater_or_equal(val: np.ndarray, *ref: np.ndarray):
183
+ return np.less(val, *ref)
184
+
185
+ return none_match_comparison(
186
+ data, component, field, not_greater_or_equal, ref_value, NotGreaterOrEqualError, default_value
187
+ )
188
+
189
+
190
+ def all_less_than(
191
+ data: SingleDataset, component: ComponentType, field: str, ref_value: int | float | str
192
+ ) -> list[NotLessThanError]:
193
+ """
194
+ Check that for all records of a particular type of component, the values in the 'field' column are less than the
195
+ reference value. Returns an empty list on success, or a list containing a single error object on failure.
196
+
197
+ Args:
198
+ data: The input/update data set for all components
199
+ component: The component of interest
200
+ field: The field of interest
201
+ ref_value: The reference value against which all values in the 'field' column are compared. If the reference
202
+ value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a ratio between
203
+ two fields (e.g. 'field_x / field_y')
204
+
205
+ Returns:
206
+ A list containing zero or one NotLessThanErrors, listing all ids where the value in the field of interest was
207
+ greater than, or equal to, the ref_value.
208
+ """
209
+
210
+ def not_less(val: np.ndarray, *ref: np.ndarray):
211
+ return np.greater_equal(val, *ref)
212
+
213
+ return none_match_comparison(data, component, field, not_less, ref_value, NotLessThanError)
214
+
215
+
216
+ def all_less_or_equal(
217
+ data: SingleDataset, component: ComponentType, field: str, ref_value: int | float | str
218
+ ) -> list[NotLessOrEqualError]:
219
+ """
220
+ Check that for all records of a particular type of component, the values in the 'field' column are less than,
221
+ or equal to the reference value. Returns an empty list on success, or a list containing a single error object on
222
+ failure.
223
+
224
+ Args:
225
+ data: The input/update data set for all components
226
+ component: The component of interest
227
+ field: The field of interest
228
+ ref_value: The reference value against which all values in the 'field' column are compared. If the reference
229
+ value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a ratio between
230
+ two fields (e.g. 'field_x / field_y')
231
+
232
+ Returns:
233
+ A list containing zero or one NotLessOrEqualErrors, listing all ids where the value in the field of interest was
234
+ greater than the ref_value.
235
+
236
+ """
237
+
238
+ def not_less_or_equal(val: np.ndarray, *ref: np.ndarray):
239
+ return np.greater(val, *ref)
240
+
241
+ return none_match_comparison(data, component, field, not_less_or_equal, ref_value, NotLessOrEqualError)
242
+
243
+
244
+ def all_between( # noqa: PLR0913
245
+ data: SingleDataset,
246
+ component: ComponentType,
247
+ field: str,
248
+ ref_value_1: int | float | str,
249
+ ref_value_2: int | float | str,
250
+ default_value: np.ndarray | int | float | None = None,
251
+ ) -> list[NotBetweenError]:
252
+ """
253
+ Check that for all records of a particular type of component, the values in the 'field' column are (exclusively)
254
+ between reference value 1 and 2. Value 1 may be smaller, but also larger than value 2. Returns an empty list on
255
+ success, or a list containing a single error object on failure.
256
+
257
+ Args:
258
+ data: The input/update data set for all components
259
+ component: The component of interest
260
+ field: The field of interest
261
+ ref_value_1: The first reference value against which all values in the 'field' column are compared. If the
262
+ reference value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a
263
+ ratio between two fields (e.g. 'field_x / field_y')
264
+ ref_value_2: The second reference value against which all values in the 'field' column are compared. If the
265
+ reference value is a string, it is assumed to be another field (e.g. 'field_x') of the same component,
266
+ or a ratio between two fields (e.g. 'field_x / field_y')
267
+ default_value: Some values are not required, but will receive a default value in the C++ core. To do a proper
268
+ input validation, these default values should be included in the validation. It can be a fixed value for the
269
+ entire column (int/float) or be different for each element (np.ndarray).
270
+
271
+ Returns:
272
+ A list containing zero or one NotBetweenErrors, listing all ids where the value in the field of interest was
273
+ outside the range defined by the reference values.
274
+ """
275
+
276
+ def outside(val: np.ndarray, *ref: np.ndarray) -> np.ndarray:
277
+ return np.logical_or(np.less_equal(val, np.minimum(*ref)), np.greater_equal(val, np.maximum(*ref)))
278
+
279
+ return none_match_comparison(
280
+ data, component, field, outside, (ref_value_1, ref_value_2), NotBetweenError, default_value
281
+ )
282
+
283
+
284
+ def all_between_or_at( # noqa: PLR0913
285
+ data: SingleDataset,
286
+ component: ComponentType,
287
+ field: str,
288
+ ref_value_1: int | float | str,
289
+ ref_value_2: int | float | str,
290
+ default_value_1: np.ndarray | int | float | None = None,
291
+ default_value_2: np.ndarray | int | float | None = None,
292
+ ) -> list[NotBetweenOrAtError]:
293
+ """
294
+ Check that for all records of a particular type of component, the values in the 'field' column are inclusively
295
+ between reference value 1 and 2. Value 1 may be smaller, but also larger than value 2. Returns an empty list on
296
+ success, or a list containing a single error object on failure.
297
+
298
+ Args:
299
+ data: The input/update data set for all components
300
+ component: The component of interest
301
+ field: The field of interest
302
+ ref_value_1: The first reference value against which all values in the 'field' column are compared. If the
303
+ reference value is a string, it is assumed to be another field (e.g. 'field_x') of the same component, or a
304
+ ratio between two fields (e.g. 'field_x / field_y')
305
+ ref_value_2: The second reference value against which all values in the 'field' column are compared. If the
306
+ reference value is a string, it is assumed to be another field (e.g. 'field_x') of the same component,
307
+ or a ratio between two fields (e.g. 'field_x / field_y')
308
+ default_value: Some values are not required, but will receive a default value in the C++ core. To do a proper
309
+ input validation, these default values should be included in the validation. It can be a fixed value for the
310
+ entire column (int/float) or be different for each element (np.ndarray).
311
+ default_value_2: Some values can have a double default: the default will be set to another attribute of the
312
+ component, but if that attribute is missing, the default will be set to a fixed value.
313
+
314
+ Returns:
315
+ A list containing zero or one NotBetweenOrAtErrors, listing all ids where the value in the field of interest was
316
+ outside the range defined by the reference values.
317
+ """
318
+
319
+ def outside(val: np.ndarray, *ref: np.ndarray) -> np.ndarray:
320
+ return np.logical_or(np.less(val, np.minimum(*ref)), np.greater(val, np.maximum(*ref)))
321
+
322
+ return none_match_comparison(
323
+ data,
324
+ component,
325
+ field,
326
+ outside,
327
+ (ref_value_1, ref_value_2),
328
+ NotBetweenOrAtError,
329
+ default_value_1,
330
+ default_value_2,
331
+ )
332
+
333
+
334
+ def none_match_comparison( # noqa: PLR0913
335
+ data: SingleDataset,
336
+ component: ComponentType,
337
+ field: str,
338
+ compare_fn: Callable,
339
+ ref_value: ComparisonError.RefType,
340
+ error: type[CompError] = ComparisonError, # type: ignore
341
+ default_value_1: np.ndarray | int | float | None = None,
342
+ default_value_2: np.ndarray | int | float | None = None,
343
+ ) -> list[CompError]:
344
+ """
345
+ For all records of a particular type of component, check if the value in the 'field' column match the comparison.
346
+ Returns an empty list if none of the value match the comparison, or a list containing a single error object when at
347
+ the value in 'field' of at least one record matches the comparison.
348
+
349
+ Args:
350
+ data: The input/update data set for all components
351
+ component: The component of interest
352
+ field: The field of interest
353
+ compare_fn: A function that takes the data in the 'field' column, and any number of reference values
354
+ ref_value: A reference value, or a tuple of reference values, against which all values in the 'field' column
355
+ are compared using the compare_fn. If a reference value is a string, it is assumed to be another field
356
+ (e.g. 'field_x') of the same component, or a ratio between two fields (e.g. 'field_x / field_y')
357
+ error: The type (class) of error that should be returned in case any of the values match the comparison.
358
+ default_value: Some values are not required, but will receive a default value in the C++ core. To do a proper
359
+ input validation, these default values should be included in the validation. It can be a fixed value for the
360
+ entire column (int/float) or be different for each element (np.ndarray).
361
+ default_value_2: Some values can have a double default: the default will be set to another attribute of the
362
+ component, but if that attribute is missing, the default will be set to a fixed value.
363
+
364
+ Returns:
365
+ A list containing zero or one comparison errors (should be a subclass of ComparisonError), listing all ids
366
+ where the value in the field of interest matched the comparison.
367
+ """
368
+ if default_value_1 is not None:
369
+ _set_default_value(data=data, component=component, field=field, default_value=default_value_1)
370
+ if default_value_2 is not None:
371
+ _set_default_value(data=data, component=component, field=field, default_value=default_value_2)
372
+ component_data = data[component]
373
+ if not isinstance(component_data, np.ndarray):
374
+ raise NotImplementedError # TODO(mgovers): add support for columnar data
375
+
376
+ if isinstance(ref_value, tuple):
377
+ ref = tuple(_eval_expression(component_data, v) for v in ref_value)
378
+ else:
379
+ ref = (_eval_expression(component_data, ref_value),)
380
+ matches = compare_fn(component_data[field], *ref)
381
+ if matches.any():
382
+ if matches.ndim > 1:
383
+ matches = matches.any(axis=1)
384
+ ids = component_data["id"][matches].flatten().tolist()
385
+ return [error(component, field, ids, ref_value)]
386
+ return []
387
+
388
+
389
+ def all_identical(data: SingleDataset, component: ComponentType, field: str) -> list[NotIdenticalError]:
390
+ """
391
+ Check that for all records of a particular type of component, the values in the 'field' column are identical.
392
+
393
+ Args:
394
+ data (SingleDataset): The input/update data set for all components
395
+ component (ComponentType): The component of interest
396
+ field (str): The field of interest
397
+
398
+ Returns:
399
+ A list containing zero or one NotIdenticalError, listing all ids of that component if the value in the field
400
+ of interest was not identical across all components, all values for those ids, the set of unique values in
401
+ that field and the number of unique values in that field.
402
+ """
403
+ field_data = data[component][field]
404
+ if len(field_data) > 0:
405
+ first = field_data[0]
406
+ if np.any(field_data != first):
407
+ return [NotIdenticalError(component, field, data[component]["id"], list(field_data))]
408
+
409
+ return []
410
+
411
+
412
+ def all_enabled_identical(
413
+ data: SingleDataset, component: ComponentType, field: str, status_field: str
414
+ ) -> list[NotIdenticalError]:
415
+ """
416
+ Check that for all records of a particular type of component, the values in the 'field' column are identical.
417
+ Only entries are checked where the 'status' field is not 0.
418
+
419
+ Args:
420
+ data (SingleDataset): The input/update data set for all components
421
+ component (ComponentType): The component of interest
422
+ field (str): The field of interest
423
+ status_field (str): The status field based on which to decide whether a component is enabled
424
+
425
+ Returns:
426
+ A list containing zero or one NotIdenticalError, listing:
427
+
428
+ - all ids of enabled components if the value in the field of interest was not identical across all enabled
429
+ components
430
+ - all values of the 'field' column for enabled components (including duplications)
431
+ - the set of unique such values
432
+ - the amount of unique such values.
433
+ """
434
+ return all_identical(
435
+ {key: (value if key is not component else value[value[status_field] != 0]) for key, value in data.items()},
436
+ component,
437
+ field,
438
+ )
439
+
440
+
441
+ def all_unique(data: SingleDataset, component: ComponentType, field: str) -> list[NotUniqueError]:
442
+ """
443
+ Check that for all records of a particular type of component, the values in the 'field' column are unique within
444
+ the 'field' column of that component.
445
+
446
+ Args:
447
+ data (SingleDataset): The input/update data set for all components
448
+ component (ComponentType): The component of interest
449
+ field (str): The field of interest
450
+
451
+ Returns:
452
+ A list containing zero or one NotUniqueError, listing all ids where the value in the field of interest was
453
+ not unique. If the field name was 'id' (a very common check), the id is added as many times as it occurred in
454
+ the 'id' column, to maintain object counts.
455
+ """
456
+ field_data = data[component][field]
457
+ _, inverse, counts = np.unique(field_data, return_inverse=True, return_counts=True)
458
+ if any(counts != 1):
459
+ ids = data[component]["id"][(counts != 1)[inverse]].flatten().tolist()
460
+ return [NotUniqueError(component, field, ids)]
461
+ return []
462
+
463
+
464
+ def all_cross_unique(
465
+ data: SingleDataset, fields: list[tuple[ComponentType, str]], cross_only=True
466
+ ) -> list[MultiComponentNotUniqueError]:
467
+ """
468
+ Check that for all records of a particular type of component, the values in the 'field' column are unique within
469
+ the 'field' column of that component.
470
+
471
+ Args:
472
+ data (SingleDataset): The input/update data set for all components
473
+ fields (list[tuple[str, str]]): The fields of interest, formatted as
474
+ [(component_1, field_1), (component_2, field_2)]
475
+ cross_only (bool, optional): Do not include duplicates within a single field. It is advised that you use
476
+ all_unique() to explicitly check uniqueness within a single field.
477
+
478
+ Returns:
479
+ A list containing zero or one MultiComponentNotUniqueError, listing all fields and ids where the value was not
480
+ unique between the fields.
481
+ """
482
+ all_values: dict[int, list[tuple[tuple[ComponentType, str], int]]] = {}
483
+ duplicate_ids = set()
484
+ for component, field in fields:
485
+ for obj_id, value in zip(data[component]["id"], data[component][field]):
486
+ component_id = ((component, field), obj_id)
487
+ if value not in all_values:
488
+ all_values[value] = []
489
+ elif not cross_only or not all(f == (component, field) for f, _ in all_values[value]):
490
+ duplicate_ids.update(all_values[value])
491
+ duplicate_ids.add(component_id)
492
+ all_values[value].append(component_id)
493
+ if duplicate_ids:
494
+ fields_with_duplicated_ids = {f for f, _ in duplicate_ids}
495
+ ids_with_duplicated_ids = {(c, i) for (c, _), i in duplicate_ids}
496
+ return [MultiComponentNotUniqueError(list(fields_with_duplicated_ids), list(ids_with_duplicated_ids))]
497
+ return []
498
+
499
+
500
+ def all_in_valid_values(
501
+ data: SingleDataset, component: ComponentType, field: str, values: list
502
+ ) -> list[UnsupportedMeasuredTerminalType]:
503
+ """
504
+ Check that for all records of a particular type of component, the values in the 'field' column are valid values for
505
+ the supplied enum class. Returns an empty list on success, or a list containing a single error object on failure.
506
+
507
+ Args:
508
+ data (SingleDataset): The input/update data set for all components
509
+ component (ComponentType): The component of interest
510
+ field (str): The field of interest
511
+ values (list | tuple): The values to validate against
512
+
513
+ Returns:
514
+ A list containing zero or one UnsupportedMeasuredTerminalType, listing all ids where the value in the field of
515
+ interest was not a valid value and the sequence of supported values.
516
+ """
517
+ valid = {_nan_type(component, field)}
518
+ valid.update(values)
519
+
520
+ invalid = np.isin(data[component][field], np.array(list(valid)), invert=True)
521
+ if invalid.any():
522
+ ids = data[component]["id"][invalid].flatten().tolist()
523
+ return [UnsupportedMeasuredTerminalType(component, field, ids, values)]
524
+ return []
525
+
526
+
527
+ def all_valid_enum_values(
528
+ data: SingleDataset, component: ComponentType, field: str, enum: type[Enum] | list[type[Enum]]
529
+ ) -> list[InvalidEnumValueError]:
530
+ """
531
+ Check that for all records of a particular type of component, the values in the 'field' column are valid values for
532
+ the supplied enum class. Returns an empty list on success, or a list containing a single error object on failure.
533
+
534
+ Args:
535
+ data (SingleDataset): The input/update data set for all components
536
+ component (ComponentType): The component of interest
537
+ field (str): The field of interest
538
+ enum (Type[Enum] | list[Type[Enum]]): The enum type to validate against, or a list of such enum types
539
+
540
+ Returns:
541
+ A list containing zero or one InvalidEnumValueError, listing all ids where the value in the field of interest
542
+ was not a valid value in the supplied enum type.
543
+ """
544
+ enums: list[type[Enum]] = enum if isinstance(enum, list) else [enum]
545
+
546
+ valid = {_nan_type(component, field)}
547
+ for enum_type in enums:
548
+ valid.update(list(enum_type))
549
+
550
+ invalid = np.isin(data[component][field], np.array(list(valid), dtype=np.int8), invert=True)
551
+ if invalid.any():
552
+ ids = data[component]["id"][invalid].flatten().tolist()
553
+ return [InvalidEnumValueError(component, field, ids, enum)]
554
+ return []
555
+
556
+
557
+ def all_valid_associated_enum_values( # noqa: PLR0913
558
+ data: SingleDataset,
559
+ component: ComponentType,
560
+ field: str,
561
+ ref_object_id_field: str,
562
+ ref_components: list[ComponentType],
563
+ enum: type[Enum] | list[type[Enum]],
564
+ **filters: Any,
565
+ ) -> list[InvalidAssociatedEnumValueError]:
566
+ """
567
+ Args:
568
+ data (SingleDataset): The input/update data set for all components
569
+ component (ComponentType): The component of interest
570
+ field (str): The field of interest
571
+ ref_object_id_field (str): The field that contains the referenced component ids
572
+ ref_components (list[ComponentType]): The component or components in which we want to look for ids
573
+ enum (Type[Enum] | list[Type[Enum]]): The enum type to validate against, or a list of such enum types
574
+ **filters: One or more filters on the dataset. E.g. regulated_object="transformer".
575
+
576
+ Returns:
577
+ A list containing zero or one InvalidAssociatedEnumValueError, listing all ids where the value in the field
578
+ of interest was not a valid value in the supplied enum type.
579
+ """
580
+ enums: list[type[Enum]] = enum if isinstance(enum, list) else [enum]
581
+
582
+ valid_ids = _get_valid_ids(data=data, ref_components=ref_components)
583
+ mask = np.logical_and(
584
+ _get_mask(data=data, component=component, field=field, **filters),
585
+ np.isin(data[component][ref_object_id_field], valid_ids),
586
+ )
587
+
588
+ valid = {_nan_type(component, field)}
589
+ for enum_type in enums:
590
+ valid.update(list(enum_type))
591
+
592
+ invalid = np.isin(data[component][field][mask], np.array(list(valid), dtype=np.int8), invert=True)
593
+ if invalid.any():
594
+ ids = data[component]["id"][mask][invalid].flatten().tolist()
595
+ return [InvalidAssociatedEnumValueError(component, [field, ref_object_id_field], ids, enum)]
596
+ return []
597
+
598
+
599
+ def all_valid_ids(
600
+ data: SingleDataset,
601
+ component: ComponentType,
602
+ field: str,
603
+ ref_components: ComponentType | list[ComponentType],
604
+ **filters: Any,
605
+ ) -> list[InvalidIdError]:
606
+ """
607
+ For a column which should contain object identifiers (ids), check if the id exists in the data, for a specific set
608
+ of reference component types. E.g. is the from_node field of each line referring to an existing node id?
609
+
610
+ Args:
611
+ data: The input/update data set for all components
612
+ component: The component of interest
613
+ field: The field of interest
614
+ ref_components: The component or components in which we want to look for ids
615
+ **filters: One or more filters on the dataset. E.g. measured_terminal_type=MeasuredTerminalType.source.
616
+
617
+ Returns:
618
+ A list containing zero or one InvalidIdError, listing all ids where the value in the field of interest
619
+ was not a valid object identifier.
620
+ """
621
+ valid_ids = _get_valid_ids(data=data, ref_components=ref_components)
622
+ mask = _get_mask(data=data, component=component, field=field, **filters)
623
+
624
+ # Find any values that can't be found in the set of ids
625
+ invalid = np.logical_and(mask, np.isin(data[component][field], valid_ids, invert=True))
626
+ if invalid.any():
627
+ ids = data[component]["id"][invalid].flatten().tolist()
628
+ return [InvalidIdError(component, field, ids, ref_components, filters)]
629
+ return []
630
+
631
+
632
+ def all_boolean(data: SingleDataset, component: ComponentType, field: str) -> list[NotBooleanError]:
633
+ """
634
+ Check that for all records of a particular type of component, the values in the 'field' column are valid boolean
635
+ values, i.e. 0 or 1. Returns an empty list on success, or a list containing a single error object on failure.
636
+
637
+ Args:
638
+ data: The input/update data set for all components
639
+ component: The component of interest
640
+ field: The field of interest
641
+
642
+ Returns:
643
+ A list containing zero or one NotBooleanError, listing all ids where the value in the field of interest was not
644
+ a valid boolean value.
645
+ """
646
+ invalid = np.isin(data[component][field], [0, 1], invert=True)
647
+ if invalid.any():
648
+ ids = data[component]["id"][invalid].flatten().tolist()
649
+ return [NotBooleanError(component, field, ids)]
650
+ return []
651
+
652
+
653
+ def all_not_two_values_zero(
654
+ data: SingleDataset, component: ComponentType, field_1: str, field_2: str
655
+ ) -> list[TwoValuesZeroError]:
656
+ """
657
+ Check that for all records of a particular type of component, the values in the 'field_1' and 'field_2' column are
658
+ not both zero. Returns an empty list on success, or a list containing a single error object on failure.
659
+
660
+ Args:
661
+ data: The input/update data set for all components
662
+ component: The component of interest
663
+ field_1: The first field of interest
664
+ field_2: The second field of interest
665
+
666
+ Returns:
667
+ A list containing zero or one TwoValuesZeroError, listing all ids where the value in the two fields of interest
668
+ were both zero.
669
+ """
670
+ invalid = np.logical_and(np.equal(data[component][field_1], 0.0), np.equal(data[component][field_2], 0.0))
671
+ if invalid.any():
672
+ if invalid.ndim > 1:
673
+ invalid = invalid.any(axis=1)
674
+ ids = data[component]["id"][invalid].flatten().tolist()
675
+ return [TwoValuesZeroError(component, [field_1, field_2], ids)]
676
+ return []
677
+
678
+
679
+ def all_not_two_values_equal(
680
+ data: SingleDataset, component: ComponentType, field_1: str, field_2: str
681
+ ) -> list[SameValueError]:
682
+ """
683
+ Check that for all records of a particular type of component, the values in the 'field_1' and 'field_2' column are
684
+ not both the same value. E.g. from_node and to_node of a line. Returns an empty list on success, or a list
685
+ containing a single error object on failure.
686
+
687
+ Args:
688
+ data: The input/update data set for all components
689
+ component: The component of interest
690
+ field_1: The first field of interest
691
+ field_2: The second field of interest
692
+
693
+ Returns:
694
+ A list containing zero or one SameValueError, listing all ids where the value in the two fields of interest
695
+ were both the same.
696
+ """
697
+ invalid = np.equal(data[component][field_1], data[component][field_2])
698
+ if invalid.any():
699
+ if invalid.ndim > 1:
700
+ invalid = invalid.any(axis=1)
701
+ ids = data[component]["id"][invalid].flatten().tolist()
702
+ return [SameValueError(component, [field_1, field_2], ids)]
703
+ return []
704
+
705
+
706
+ def ids_valid_in_update_data_set(
707
+ update_data: SingleDataset, ref_data: SingleDataset, component: ComponentType, ref_name: DatasetType
708
+ ) -> list[IdNotInDatasetError | InvalidIdError]:
709
+ """
710
+ Check that for all records of a particular type of component, whether the ids:
711
+ - exist and match those in the reference data set
712
+ - are not present but qualifies for optional id
713
+
714
+ Args:
715
+ update_data: The update data set for all components
716
+ ref_data: The reference (input) data set for all components
717
+ component: The component of interest
718
+ ref_name: The name of the reference data set type
719
+
720
+ Returns:
721
+ A list containing zero or one IdNotInDatasetError, listing all ids of the objects in the data set which do not
722
+ exist in the reference data set.
723
+ """
724
+ component_data = update_data[component]
725
+ component_ref_data = ref_data[component]
726
+ if component_ref_data["id"].size == 0:
727
+ return [InvalidIdError(component=component, field="id", ids=None)]
728
+ id_field_is_nan = np.array(is_nan_or_default(component_data["id"]))
729
+ # check whether id qualify for optional
730
+ if component_data["id"].size == 0 or np.all(id_field_is_nan):
731
+ # check if the dimension of the component_data is the same as the component_ref_data
732
+ if get_comp_size(component_data) != get_comp_size(component_ref_data):
733
+ return [InvalidIdError(component=component, field="id", ids=None)]
734
+ return [] # supported optional id
735
+
736
+ if np.all(id_field_is_nan) and not np.all(~id_field_is_nan):
737
+ return [InvalidIdError(component=component, field="id", ids=None)]
738
+
739
+ # normal check: exist and match with input
740
+ invalid = np.isin(component_data["id"], component_ref_data["id"], invert=True)
741
+ if invalid.any():
742
+ ids = component_data["id"][invalid].flatten().tolist()
743
+ return [IdNotInDatasetError(component, ids, ref_name)]
744
+ return []
745
+
746
+
747
+ def all_finite(data: SingleDataset, exceptions: dict[ComponentType, list[str]] | None = None) -> list[InfinityError]:
748
+ """
749
+ Check that for all records in all component, the values in all columns are finite value, i.e. float values other
750
+ than inf, or -inf. Nan values are ignored, as in all other comparison functions. You can use non_missing() to
751
+ check for missing/nan values. Returns an empty list on success, or a list containing an error object for each
752
+ component/field combination where.
753
+
754
+ Args:
755
+ data: The input/update data set for all components
756
+ exceptions:
757
+ A dictionary of fields per component type for which infinite values are supported. Defaults to empty.
758
+
759
+ Returns:
760
+ A list containing zero or one NotBooleanError, listing all ids where the value in the field of interest was not
761
+ a valid boolean value.
762
+ """
763
+ errors = []
764
+ for component, array in data.items():
765
+ if not isinstance(array, np.ndarray):
766
+ raise NotImplementedError # TODO(mgovers): add support for columnar data
767
+
768
+ for field, (dtype, _) in array.dtype.fields.items():
769
+ if not np.issubdtype(dtype, np.floating):
770
+ continue
771
+
772
+ if exceptions and field in exceptions.get(component, []):
773
+ continue
774
+
775
+ invalid = np.isinf(array[field])
776
+ if invalid.any():
777
+ ids = array["id"][invalid].flatten().tolist()
778
+ errors.append(InfinityError(component, field, ids))
779
+ return errors
780
+
781
+
782
+ def no_strict_subset_missing(data: SingleDataset, fields: list[str], component_type: ComponentType):
783
+ """
784
+ Helper function that generates multi field validation errors if a subset of the supplied fields is missing.
785
+ If for an instance of component type all fields are missing or all fields are not missing then,
786
+ no error is returned for that instance.
787
+ In any other case an error for that id is returned.
788
+
789
+ Args:
790
+ data: SingleDataset, pgm data
791
+ fields: List of fields
792
+ component_type: component type to check
793
+ """
794
+ errors = []
795
+ if component_type in data:
796
+ component_data = data[component_type]
797
+ instances_with_nan_data = np.full_like([], fill_value=False, shape=(len(component_data),), dtype=bool)
798
+ instances_with_non_nan_data = np.full_like([], fill_value=False, shape=(len(component_data),), dtype=bool)
799
+ for field in fields:
800
+ nan_value = _nan_type(component_type, field)
801
+ asym_axes = tuple(range(component_data.ndim, component_data[field].ndim))
802
+ instances_with_nan_data = np.logical_or(
803
+ instances_with_nan_data,
804
+ np.any(
805
+ (
806
+ np.isnan(component_data[field])
807
+ if np.any(np.isnan(nan_value))
808
+ else np.equal(component_data[field], nan_value)
809
+ ),
810
+ axis=asym_axes,
811
+ ),
812
+ )
813
+ instances_with_non_nan_data = np.logical_or(
814
+ instances_with_non_nan_data,
815
+ np.any(
816
+ (
817
+ np.logical_not(np.isnan(component_data[field]))
818
+ if np.any(np.isnan(nan_value))
819
+ else np.logical_not(np.equal(component_data[field], nan_value))
820
+ ),
821
+ axis=asym_axes,
822
+ ),
823
+ )
824
+
825
+ instances_with_invalid_data = np.logical_and(instances_with_nan_data, instances_with_non_nan_data)
826
+
827
+ ids = component_data["id"][instances_with_invalid_data]
828
+ if len(ids) > 0:
829
+ errors.append(MultiFieldValidationError(component_type, fields, ids))
830
+
831
+ return errors
832
+
833
+
834
+ def not_all_missing(data: SingleDataset, fields: list[str], component_type: ComponentType):
835
+ """
836
+ Helper function that generates a multi field validation error if:
837
+ all values specified by the fields parameters are missing.
838
+
839
+ Args:
840
+ data: SingleDataset, pgm data
841
+ fields: List of fields
842
+ component_type: component type to check
843
+ """
844
+ min_fields = 2
845
+ if len(fields) < min_fields:
846
+ raise ValueError(
847
+ "The fields parameter must contain at least 2 fields. Otherwise use the none_missing function."
848
+ )
849
+
850
+ errors = []
851
+ if component_type in data:
852
+ component_data = data[component_type]
853
+ instances_with_all_nan_data = np.full_like([], fill_value=True, shape=(len(component_data),), dtype=bool)
854
+
855
+ for field in fields:
856
+ nan_value = _nan_type(component_type, field)
857
+ asym_axes = tuple(range(component_data.ndim, component_data[field].ndim))
858
+ instances_with_all_nan_data = np.logical_and(
859
+ instances_with_all_nan_data,
860
+ np.any(
861
+ (
862
+ np.isnan(component_data[field])
863
+ if np.any(np.isnan(nan_value))
864
+ else np.equal(component_data[field], nan_value)
865
+ ),
866
+ axis=asym_axes,
867
+ ),
868
+ )
869
+
870
+ ids = component_data["id"][instances_with_all_nan_data].flatten().tolist()
871
+ if len(ids) > 0:
872
+ errors.append(MultiFieldValidationError(component_type, fields, ids))
873
+
874
+ return errors
875
+
876
+
877
+ def none_missing(data: SingleDataset, component: ComponentType, fields: str | list[str]) -> list[MissingValueError]:
878
+ """
879
+ Check that for all records of a particular type of component, the values in the 'fields' columns are not NaN.
880
+ Returns an empty list on success, or a list containing a single error object on failure.
881
+
882
+ Args:
883
+ data: The input/update data set for all components
884
+ component: The component of interest
885
+ fields: The fields of interest
886
+
887
+ Returns:
888
+ A list containing zero or more MissingValueError; one for each field, listing all ids where the value in the
889
+ field was NaN.
890
+ """
891
+ errors = []
892
+ if isinstance(fields, str):
893
+ fields = [fields]
894
+ for field in fields:
895
+ nan = _nan_type(component, field)
896
+ invalid = np.isnan(data[component][field]) if np.isnan(nan) else np.equal(data[component][field], nan)
897
+
898
+ if invalid.any():
899
+ # handle both symmetric and asymmetric values
900
+ invalid = np.any(invalid, axis=tuple(range(1, invalid.ndim)))
901
+ ids = data[component]["id"][invalid].flatten().tolist()
902
+ errors.append(MissingValueError(component, field, ids))
903
+ return errors
904
+
905
+
906
+ def valid_p_q_sigma(data: SingleDataset, component: ComponentType) -> list[PQSigmaPairError]:
907
+ """
908
+ Check validity of the pair `(p_sigma, q_sigma)` for 'sym_power_sensor' and 'asym_power_sensor'.
909
+
910
+ Args:
911
+ data: The input/update data set for all components
912
+ component: The component of interest, in this case only 'sym_power_sensor' or 'asym_power_sensor'
913
+
914
+ Returns:
915
+ A list containing zero or one PQSigmaPairError, listing the p_sigma and q_sigma mismatch.
916
+ Note that with asymetric power sensors, partial assignment of p_sigma and q_sigma is also considered mismatch.
917
+ """
918
+ errors = []
919
+ p_sigma = data[component]["p_sigma"]
920
+ q_sigma = data[component]["q_sigma"]
921
+ p_nan = np.isnan(p_sigma)
922
+ q_nan = np.isnan(q_sigma)
923
+ mis_match = p_nan != q_nan
924
+ if p_sigma.ndim > 1: # if component == 'asym_power_sensor':
925
+ mis_match = mis_match.any(axis=-1)
926
+ mis_match |= np.logical_xor(p_nan.any(axis=-1), p_nan.all(axis=-1))
927
+ mis_match |= np.logical_xor(q_nan.any(axis=-1), q_nan.all(axis=-1))
928
+
929
+ if mis_match.any():
930
+ ids = data[component]["id"][mis_match].flatten().tolist()
931
+ errors.append(PQSigmaPairError(component, ["p_sigma", "q_sigma"], ids))
932
+ return errors
933
+
934
+
935
+ def all_valid_clocks(
936
+ data: SingleDataset, component: ComponentType, clock_field: str, winding_from_field: str, winding_to_field: str
937
+ ) -> list[TransformerClockError]:
938
+ """
939
+ Custom validation rule: Odd clock number is only allowed for Dy(n) or Y(N)d configuration.
940
+
941
+ Args:
942
+ data: The input/update data set for all components
943
+ component: The component of interest
944
+ clock_field: The clock field
945
+ winding_from_field: The winding from field
946
+ winding_to_field: The winding to field
947
+
948
+ Returns:
949
+ A list containing zero or more TransformerClockErrors; listing all the ids of transformers where the clock was
950
+ invalid, given the winding type.
951
+ """
952
+
953
+ clk = data[component][clock_field]
954
+ wfr = data[component][winding_from_field]
955
+ wto = data[component][winding_to_field]
956
+ wfr_is_wye = np.isin(wfr, [WindingType.wye, WindingType.wye_n])
957
+ wto_is_wye = np.isin(wto, [WindingType.wye, WindingType.wye_n])
958
+ odd = clk % 2 == 1
959
+ # even number is not possible if one side is wye winding and the other side is not wye winding.
960
+ # odd number is not possible, if both sides are wye winding or both sides are not wye winding.
961
+ err = (~odd & (wfr_is_wye != wto_is_wye)) | (odd & (wfr_is_wye == wto_is_wye))
962
+ if err.any():
963
+ return [
964
+ TransformerClockError(
965
+ component=component,
966
+ fields=[clock_field, winding_from_field, winding_to_field],
967
+ ids=data[component]["id"][err].flatten().tolist(),
968
+ )
969
+ ]
970
+ return []
971
+
972
+
973
+ def all_same_current_angle_measurement_type_on_terminal(
974
+ data: SingleDataset,
975
+ component: ComponentType,
976
+ measured_object_field: str,
977
+ measured_terminal_type_field: str,
978
+ angle_measurement_type_field: str,
979
+ ) -> list[MixedCurrentAngleMeasurementTypeError]:
980
+ """
981
+ Custom validation rule: All current angle measurement types on a terminal must be the same.
982
+
983
+ Args:
984
+ data (SingleDataset): The input/update data set for all components
985
+ component (ComponentType): The component of interest
986
+ measured_object_field (str): The measured object field
987
+ measured_terminal_type_field (str): The terminal field
988
+ angle_measurement_type_field (str): The angle measurement type field
989
+
990
+ Returns:
991
+ A list containing zero or more MixedCurrentAngleMeasurementTypeErrors; listing all the ids of
992
+ components where the current angle measurement type was not the same for the same terminal.
993
+ """
994
+ sorted_indices = np.argsort(data[component][[measured_object_field, measured_terminal_type_field]])
995
+ sorted_values = data[component][sorted_indices]
996
+
997
+ unique_current_measurements, measurement_sorted_indices = np.unique(
998
+ sorted_values[[measured_object_field, measured_terminal_type_field, angle_measurement_type_field]],
999
+ return_inverse=True,
1000
+ )
1001
+ _, terminal_sorted_indices = np.unique(
1002
+ unique_current_measurements[[measured_object_field, measured_terminal_type_field]], return_inverse=True
1003
+ )
1004
+
1005
+ mixed_sorted_indices = np.setdiff1d(measurement_sorted_indices, terminal_sorted_indices)
1006
+ mixed_terminals = np.unique(
1007
+ sorted_values[mixed_sorted_indices][[measured_object_field, measured_terminal_type_field]]
1008
+ )
1009
+
1010
+ err = np.isin(data[component][[measured_object_field, measured_terminal_type_field]], mixed_terminals)
1011
+ if err.any():
1012
+ return [
1013
+ MixedCurrentAngleMeasurementTypeError(
1014
+ component=component,
1015
+ fields=[measured_object_field, measured_terminal_type_field, angle_measurement_type_field],
1016
+ ids=data[component]["id"][err].flatten().tolist(),
1017
+ )
1018
+ ]
1019
+ return []
1020
+
1021
+
1022
+ def all_same_sensor_type_on_same_terminal(
1023
+ data: SingleDataset,
1024
+ power_sensor_type: ComponentType,
1025
+ current_sensor_type: ComponentType,
1026
+ measured_object_field: str,
1027
+ measured_terminal_type_field: str,
1028
+ ) -> list[MixedPowerCurrentSensorError]:
1029
+ """
1030
+ Custom validation rule: All sensors on a terminal must be of the same type.
1031
+
1032
+ E.g. mixing sym_power_sensor and asym_power_sensor on the same terminal is allowed, but mixing
1033
+ sym_power_sensor and sym_current_sensor is not allowed.
1034
+
1035
+ Args:
1036
+ data (SingleDataset): The input/update data set for all components
1037
+ power_sensor_type (ComponentType): The power sensor component
1038
+ current_sensor_type (ComponentType): The current sensor component
1039
+ measured_object_field (str): The measured object field
1040
+ measured_terminal_type_field (str): The measured terminal type field
1041
+
1042
+ Returns:
1043
+ A list containing zero or more MixedPowerCurrentSensorError; listing all the ids of
1044
+ components that measure the same terminal of the same component in different, unsupported ways.
1045
+ """
1046
+ power_sensor_data = data[power_sensor_type]
1047
+ current_sensor_data = data[current_sensor_type]
1048
+ power_sensor_measured_terminals = power_sensor_data[[measured_object_field, measured_terminal_type_field]]
1049
+ current_sensor_measured_terminals = current_sensor_data[[measured_object_field, measured_terminal_type_field]]
1050
+
1051
+ mixed_terminals = np.intersect1d(power_sensor_measured_terminals, current_sensor_measured_terminals)
1052
+ if mixed_terminals.size != 0:
1053
+ mixed_power_sensor_ids = power_sensor_data["id"][np.isin(power_sensor_measured_terminals, mixed_terminals)]
1054
+ mixed_current_sensor_ids = current_sensor_data["id"][
1055
+ np.isin(current_sensor_measured_terminals, mixed_terminals)
1056
+ ]
1057
+
1058
+ return [
1059
+ MixedPowerCurrentSensorError(
1060
+ fields=[
1061
+ (power_sensor_type, measured_object_field),
1062
+ (power_sensor_type, measured_terminal_type_field),
1063
+ (current_sensor_type, measured_object_field),
1064
+ (current_sensor_type, measured_terminal_type_field),
1065
+ ],
1066
+ ids=[(power_sensor_type, s) for s in mixed_power_sensor_ids.flatten().tolist()]
1067
+ + [(current_sensor_type, s) for s in mixed_current_sensor_ids.flatten().tolist()],
1068
+ )
1069
+ ]
1070
+ return []
1071
+
1072
+
1073
+ def all_valid_fault_phases(
1074
+ data: SingleDataset, component: ComponentType, fault_type_field: str, fault_phase_field: str
1075
+ ) -> list[FaultPhaseError]:
1076
+ """
1077
+ Custom validation rule: Only a subset of fault_phases is supported for each fault type.
1078
+
1079
+ Args:
1080
+ data (SingleDataset): The input/update data set for all components
1081
+ component (ComponentType): The component of interest
1082
+ fault_type_field (str): The fault type field
1083
+ fault_phase_field (str): The fault phase field
1084
+
1085
+ Returns:
1086
+ A list containing zero or more FaultPhaseErrors; listing all the ids of faults where the fault phase was
1087
+ invalid, given the fault phase.
1088
+ """
1089
+ fault_types = data[component][fault_type_field]
1090
+ fault_phases = data[component][fault_phase_field]
1091
+
1092
+ supported_combinations: dict[FaultType, list[FaultPhase]] = {
1093
+ FaultType.three_phase: [FaultPhase.abc, FaultPhase.default_value, FaultPhase.nan],
1094
+ FaultType.single_phase_to_ground: [
1095
+ FaultPhase.a,
1096
+ FaultPhase.b,
1097
+ FaultPhase.c,
1098
+ FaultPhase.default_value,
1099
+ FaultPhase.nan,
1100
+ ],
1101
+ FaultType.two_phase: [FaultPhase.ab, FaultPhase.ac, FaultPhase.bc, FaultPhase.default_value, FaultPhase.nan],
1102
+ FaultType.two_phase_to_ground: [
1103
+ FaultPhase.ab,
1104
+ FaultPhase.ac,
1105
+ FaultPhase.bc,
1106
+ FaultPhase.default_value,
1107
+ FaultPhase.nan,
1108
+ ],
1109
+ FaultType.nan: [],
1110
+ }
1111
+
1112
+ def _fault_phase_unsupported(fault_type: FaultType, fault_phase: FaultPhase):
1113
+ return fault_phase not in supported_combinations.get(fault_type, [])
1114
+
1115
+ err = np.vectorize(_fault_phase_unsupported)(fault_type=fault_types, fault_phase=fault_phases)
1116
+ if err.any():
1117
+ return [
1118
+ FaultPhaseError(
1119
+ component=component,
1120
+ fields=[fault_type_field, fault_phase_field],
1121
+ ids=data[component]["id"][err].flatten().tolist(),
1122
+ )
1123
+ ]
1124
+ return []
1125
+
1126
+
1127
+ def any_voltage_angle_measurement_if_global_current_measurement(
1128
+ data: SingleDataset,
1129
+ component: ComponentType,
1130
+ angle_measurement_type_filter: tuple[str, AngleMeasurementType],
1131
+ voltage_sensor_u_angle_measured: dict[ComponentType, str],
1132
+ ) -> list[MissingVoltageAngleMeasurementError]:
1133
+ """Require a voltage angle measurement if a global angle current measurement is present.
1134
+
1135
+ Args:
1136
+ data (SingleDataset): The input/update data set for all components
1137
+ component (ComponentType): The component of interest
1138
+ angle_measurement_type_filter (tuple[str, AngleMeasurementType]):
1139
+ The angle measurement type field and value to check for
1140
+ voltage_sensor_u_angle_measured (dict[ComponentType, str]):
1141
+ The voltage angle measure field for each voltage sensor type
1142
+
1143
+ Returns:
1144
+ A list containing zero or more MissingVoltageAngleMeasurementError; listing all the ids of global angle current
1145
+ sensors that require at least one voltage angle measurement.
1146
+ """
1147
+ angle_measurement_type_field, angle_measurement_type = angle_measurement_type_filter
1148
+
1149
+ current_sensors = data[component]
1150
+ if np.all(current_sensors[angle_measurement_type_field] != angle_measurement_type):
1151
+ return []
1152
+
1153
+ for voltage_sensor_type, voltage_angle_field in voltage_sensor_u_angle_measured.items():
1154
+ if (np.isfinite(data[voltage_sensor_type][voltage_angle_field])).any():
1155
+ return []
1156
+
1157
+ voltage_and_current_sensor_ids = {sensor: data[sensor]["id"] for sensor in voltage_sensor_u_angle_measured}
1158
+ voltage_and_current_sensor_ids[component] = current_sensors[
1159
+ current_sensors[angle_measurement_type_field] == angle_measurement_type
1160
+ ]["id"]
1161
+
1162
+ return [
1163
+ MissingVoltageAngleMeasurementError(
1164
+ fields=[(component, angle_measurement_type_field), *list(voltage_sensor_u_angle_measured.items())],
1165
+ ids=[
1166
+ (sensor_type, id_)
1167
+ for sensor_type, sensor_data in voltage_and_current_sensor_ids.items()
1168
+ for id_ in sensor_data.flatten().tolist()
1169
+ ],
1170
+ )
1171
+ ]