Skip to content

Commit e7547b0

Browse files
Merge pull request #907 from PowerGridModel/bugfix/none-missing-2
Bugfix: rule in none missing yield false positives
2 parents daae845 + eda52ac commit e7547b0

File tree

6 files changed

+288
-112
lines changed

6 files changed

+288
-112
lines changed

docs/user_manual/components.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -675,13 +675,13 @@ Valid combinations of `power_sigma`, `p_sigma` and `q_sigma` are:
675675

676676
| `power_sigma` | `p_sigma` | `q_sigma` | result |
677677
| :-----------: | :-------: | :-------: | :------: |
678-
| x | x | x | ✔ |
679-
| x | x | | ❌ |
680-
| x | | x | ❌ |
681-
| x | | | ✔ |
682-
| | x | x | ✔ |
683-
| | x | | ❌ |
684-
| | | x | ❌ |
678+
| ✔ | ✔ | ✔ | ✔ |
679+
| ✔ | ✔ | | ❌ |
680+
| ✔ | | ✔ | ❌ |
681+
| ✔ | | | ✔ |
682+
| | ✔ | ✔ | ✔ |
683+
| | ✔ | | ❌ |
684+
| | | ✔ | ❌ |
685685
| | | | ❌ |
686686

687687
```{note}

src/power_grid_model/validation/errors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,14 @@ class FaultPhaseError(MultiFieldValidationError):
472472
_message = "The fault phase is not applicable to the corresponding fault type for {n} {objects}."
473473

474474

475+
class PQSigmaPairError(MultiFieldValidationError):
476+
"""
477+
The combination of p_sigma and q_sigma is not valid. They should be both present or both absent.
478+
"""
479+
480+
_message = "The combination of p_sigma and q_sigma is not valid for {n} {objects}."
481+
482+
475483
class InvalidAssociatedEnumValueError(MultiFieldValidationError):
476484
"""
477485
The value is not a valid value in combination with the other specified attributes.

src/power_grid_model/validation/rules.py

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@
5353
InvalidIdError,
5454
MissingValueError,
5555
MultiComponentNotUniqueError,
56-
MultiFieldValidationError,
5756
NotBetweenError,
5857
NotBetweenOrAtError,
5958
NotBooleanError,
@@ -63,6 +62,7 @@
6362
NotLessOrEqualError,
6463
NotLessThanError,
6564
NotUniqueError,
65+
PQSigmaPairError,
6666
SameValueError,
6767
TransformerClockError,
6868
TwoValuesZeroError,
@@ -754,12 +754,7 @@ def all_finite(data: SingleDataset, exceptions: dict[ComponentType, list[str]] |
754754
return errors
755755

756756

757-
def none_missing(
758-
data: SingleDataset,
759-
component: ComponentType,
760-
fields: list[str | list[str]] | str | list[str],
761-
index: int = 0,
762-
) -> list[MissingValueError]:
757+
def none_missing(data: SingleDataset, component: ComponentType, fields: str | list[str]) -> list[MissingValueError]:
763758
"""
764759
Check that for all records of a particular type of component, the values in the 'fields' columns are not NaN.
765760
Returns an empty list on success, or a list containing a single error object on failure.
@@ -777,23 +772,21 @@ def none_missing(
777772
if isinstance(fields, str):
778773
fields = [fields]
779774
for field in fields:
780-
if isinstance(field, list):
781-
field = field[0]
782775
nan = _nan_type(component, field)
783776
if np.isnan(nan):
784-
invalid = np.isnan(data[component][field][index])
777+
invalid = np.isnan(data[component][field])
785778
else:
786-
invalid = np.equal(data[component][field][index], nan)
779+
invalid = np.equal(data[component][field], nan)
787780

788781
if invalid.any():
789-
if isinstance(invalid, np.ndarray):
790-
invalid = np.any(invalid)
782+
# handle both symmetric and asymmetric values
783+
invalid = np.any(invalid, axis=tuple(range(1, invalid.ndim)))
791784
ids = data[component]["id"][invalid].flatten().tolist()
792785
errors.append(MissingValueError(component, field, ids))
793786
return errors
794787

795788

796-
def valid_p_q_sigma(data: SingleDataset, component: ComponentType) -> list[MultiFieldValidationError]:
789+
def valid_p_q_sigma(data: SingleDataset, component: ComponentType) -> list[PQSigmaPairError]:
797790
"""
798791
Check validity of the pair `(p_sigma, q_sigma)` for 'sym_power_sensor' and 'asym_power_sensor'.
799792
@@ -802,7 +795,7 @@ def valid_p_q_sigma(data: SingleDataset, component: ComponentType) -> list[Multi
802795
component: The component of interest, in this case only 'sym_power_sensor' or 'asym_power_sensor'
803796
804797
Returns:
805-
A list containing zero or one MultiFieldValidationError, listing the p_sigma and q_sigma mismatch.
798+
A list containing zero or one PQSigmaPairError, listing the p_sigma and q_sigma mismatch.
806799
Note that with asymetric power sensors, partial assignment of p_sigma and q_sigma is also considered mismatch.
807800
"""
808801
errors = []
@@ -812,16 +805,18 @@ def valid_p_q_sigma(data: SingleDataset, component: ComponentType) -> list[Multi
812805
q_nan = np.isnan(q_sigma)
813806
p_inf = np.isinf(p_sigma)
814807
q_inf = np.isinf(q_sigma)
815-
if p_sigma.ndim > 1: # if component == 'asym_power_sensor':
816-
p_nan = p_nan.any(axis=-1)
817-
q_nan = q_nan.any(axis=-1)
818-
p_inf = p_inf.any(axis=-1)
819-
q_inf = q_inf.any(axis=-1)
820808
mis_match = p_nan != q_nan
821-
mis_match |= np.logical_or(p_inf, q_inf)
809+
mis_match |= np.logical_xor(p_inf, q_inf) # infinite sigmas are supported if they are both infinite
810+
if p_sigma.ndim > 1: # if component == 'asym_power_sensor':
811+
mis_match = mis_match.any(axis=-1)
812+
mis_match |= np.logical_xor(p_nan.any(axis=-1), p_nan.all(axis=-1))
813+
mis_match |= np.logical_xor(q_nan.any(axis=-1), q_nan.all(axis=-1))
814+
mis_match |= np.logical_xor(p_inf.any(axis=-1), p_inf.all(axis=-1))
815+
mis_match |= np.logical_xor(q_inf.any(axis=-1), q_inf.all(axis=-1))
816+
822817
if mis_match.any():
823818
ids = data[component]["id"][mis_match].flatten().tolist()
824-
errors.append(MultiFieldValidationError(component, ["p_sigma", "q_sigma"], ids))
819+
errors.append(PQSigmaPairError(component, ["p_sigma", "q_sigma"], ids))
825820
return errors
826821

827822

src/power_grid_model/validation/validation.py

Lines changed: 24 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import copy
1515
from collections.abc import Sized as ABCSized
1616
from itertools import chain
17-
from typing import cast
1817

1918
import numpy as np
2019

@@ -247,40 +246,30 @@ def validate_ids(update_data: SingleDataset, input_data: SingleDataset) -> list[
247246
def _process_power_sigma_and_p_q_sigma(
248247
data: SingleDataset,
249248
sensor: ComponentType,
250-
required_list: dict[ComponentType | str, list[str | list[str]]],
251249
) -> None:
252250
"""
253251
Helper function to process the required list when both `p_sigma` and `q_sigma` exist
254252
and valid but `power_sigma` is missing. The field `power_sigma` is set to the norm of
255-
`p_sigma` and `q_sigma`in this case. Happens only on proxy data (not the original data).
253+
`p_sigma` and `q_sigma` in this case. Happens only on proxy data (not the original data).
256254
However, note that this value is eventually not used in the calculation.
257-
"""
258-
259-
def _check_sensor_in_data(_data, _sensor):
260-
return _sensor in _data and isinstance(_data[_sensor], np.ndarray)
261-
262-
def _contains_p_q_sigma(_sensor_data):
263-
return "p_sigma" in _sensor_data.dtype.names and "q_sigma" in _sensor_data.dtype.names
264-
265-
def _process_power_sigma_in_list(_sensor_mask, _power_sigma, _p_sigma, _q_sigma):
266-
_mask = np.logical_not(np.logical_or(np.isnan(_p_sigma), np.isnan(_q_sigma)))
267-
if _power_sigma.ndim < _mask.ndim:
268-
_mask = np.any(_mask, axis=tuple(range(_power_sigma.ndim, _mask.ndim)))
269-
270-
for sublist, should_remove in zip(_sensor_mask, _mask):
271-
if should_remove and "power_sigma" in sublist:
272-
sublist = cast(list[str], sublist)
273-
sublist.remove("power_sigma")
274255
275-
if _check_sensor_in_data(data, sensor):
256+
Args:
257+
data: SingleDataset, pgm data
258+
sensor: only of types ComponentType.sym_power_sensor or ComponentType.asym_power_sensor
259+
"""
260+
if sensor in data:
276261
sensor_data = data[sensor]
277-
sensor_mask = required_list[sensor]
278-
if _contains_p_q_sigma(sensor_data):
279-
p_sigma = sensor_data["p_sigma"]
280-
q_sigma = sensor_data["q_sigma"]
281-
power_sigma = sensor_data["power_sigma"]
262+
power_sigma = sensor_data["power_sigma"]
263+
p_sigma = sensor_data["p_sigma"]
264+
q_sigma = sensor_data["q_sigma"]
265+
266+
# virtual patch to handle missing power_sigma
267+
asym_axes = tuple(range(sensor_data.ndim, p_sigma.ndim))
268+
mask = np.logical_and(np.isnan(power_sigma), np.any(np.logical_not(np.isnan(p_sigma)), axis=asym_axes))
269+
power_sigma[mask] = np.nansum(p_sigma[mask], axis=asym_axes)
282270

283-
_process_power_sigma_in_list(sensor_mask, power_sigma, p_sigma, q_sigma)
271+
mask = np.logical_and(np.isnan(power_sigma), np.any(np.logical_not(np.isnan(q_sigma)), axis=asym_axes))
272+
power_sigma[mask] = np.nansum(q_sigma[mask], axis=asym_axes)
284273

285274

286275
def validate_required_values(
@@ -298,7 +287,7 @@ def validate_required_values(
298287
An empty list if all required data is available, or a list of MissingValueErrors.
299288
"""
300289
# Base
301-
required: dict[ComponentType | str, list[str | list[str]]] = {"base": ["id"]}
290+
required: dict[ComponentType | str, list[str]] = {"base": ["id"]}
302291

303292
# Nodes
304293
required["node"] = required["base"] + ["u_rated"]
@@ -382,12 +371,7 @@ def validate_required_values(
382371
required["asym_voltage_sensor"] = required["voltage_sensor"].copy()
383372
# Different requirements for individual sensors. Avoid shallow copy.
384373
for sensor_type in ("sym_power_sensor", "asym_power_sensor"):
385-
try:
386-
required[sensor_type] = [
387-
required["power_sensor"].copy() for _ in range(data[sensor_type].shape[0]) # type: ignore
388-
]
389-
except KeyError:
390-
pass
374+
required[sensor_type] = required["power_sensor"].copy()
391375

392376
# Faults
393377
required["fault"] = required["base"] + ["fault_object"]
@@ -404,13 +388,13 @@ def validate_required_values(
404388
required["line"] += ["r0", "x0", "c0", "tan0"]
405389
required["shunt"] += ["g0", "b0"]
406390

407-
_process_power_sigma_and_p_q_sigma(data, ComponentType.sym_power_sensor, required)
408-
_process_power_sigma_and_p_q_sigma(data, ComponentType.asym_power_sensor, required)
391+
_process_power_sigma_and_p_q_sigma(data, ComponentType.sym_power_sensor)
392+
_process_power_sigma_and_p_q_sigma(data, ComponentType.asym_power_sensor)
409393

410394
return _validate_required_in_data(data, required)
411395

412396

413-
def _validate_required_in_data(data, required):
397+
def _validate_required_in_data(data: SingleDataset, required: dict[ComponentType | str, list[str]]):
414398
"""
415399
Checks if all required data is available.
416400
@@ -429,25 +413,14 @@ def is_valid_component(data, component):
429413
and isinstance(data[component], ABCSized)
430414
)
431415

432-
def is_nested_list(items):
433-
return isinstance(items, list) and all(isinstance(i, list) for i in items)
434-
435-
def process_nested_items(component, items, data, results):
436-
for index, item in enumerate(sublist for sublist in items):
437-
if index < len(data[component]):
438-
results.append(_none_missing(data, component, item, index))
439-
440-
results = []
416+
results: list[MissingValueError] = []
441417

442418
for component in data:
443419
if is_valid_component(data, component):
444420
items = required.get(component, [])
445-
if is_nested_list(items):
446-
process_nested_items(component, items, data, results)
447-
else:
448-
results.append(_none_missing(data, component, items, 0))
421+
results += _none_missing(data, component, items)
449422

450-
return list(chain(*results))
423+
return results
451424

452425

453426
def validate_values(data: SingleDataset, calculation_type: CalculationType | None = None) -> list[ValidationError]:

tests/unit/validation/test_rules.py

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,22 @@
22
#
33
# SPDX-License-Identifier: MPL-2.0
44

5-
from enum import IntEnum
5+
from unittest import mock
66

77
import numpy as np
88
import pytest
99

1010
from power_grid_model import ComponentType, LoadGenType, initialize_array, power_grid_meta_data
11+
from power_grid_model._core.dataset_definitions import ComponentTypeLike
12+
from power_grid_model._utils import compatibility_convert_row_columnar_dataset
1113
from power_grid_model.enum import Branch3Side, BranchSide, FaultPhase, FaultType
1214
from power_grid_model.validation.errors import (
1315
ComparisonError,
1416
FaultPhaseError,
1517
InfinityError,
1618
InvalidEnumValueError,
1719
InvalidIdError,
20+
MissingValueError,
1821
MultiComponentNotUniqueError,
1922
NotBetweenError,
2023
NotBetweenOrAtError,
@@ -471,9 +474,97 @@ def test_all_finite():
471474
assert InfinityError("bar_test", "bar", [6]) in errors
472475

473476

474-
@pytest.mark.skip("No unit tests available for none_missing")
475477
def test_none_missing():
476-
raise NotImplementedError(f"Unit test for {none_missing}")
478+
dfoo = [("id", "i4"), ("foo", "f8"), ("bar", "(3,)f8"), ("baz", "i4"), ("bla", "i1"), ("ok", "i1")]
479+
dbar = [("id", "i4"), ("foobar", "f8")]
480+
481+
def _mock_nan_type(component: ComponentTypeLike, field: str):
482+
return {
483+
"foo_test": {
484+
"id": np.iinfo("i4").min,
485+
"foo": np.nan,
486+
"bar": np.nan,
487+
"baz": np.iinfo("i4").min,
488+
"bla": np.iinfo("i1").min,
489+
"ok": -1,
490+
},
491+
"bar_test": {"id": np.iinfo("i4").min, "foobar": np.nan},
492+
}[component][field]
493+
494+
with mock.patch("power_grid_model.validation.rules._nan_type", _mock_nan_type):
495+
valid = {
496+
"foo_test": np.array(
497+
[
498+
(1, 3.1, (4.2, 4.3, 4.4), 1, 6, 0),
499+
(2, 5.2, (3.3, 3.4, 3.5), 2, 7, 0),
500+
(3, 7.3, (8.4, 8.5, 8.6), 3, 8, 0),
501+
],
502+
dtype=dfoo,
503+
),
504+
"bar_test": np.array([(4, 0.4), (5, 0.5)], dtype=dbar),
505+
}
506+
errors = none_missing(data=valid, component="foo_test", fields=["foo", "bar", "baz"])
507+
assert len(errors) == 0
508+
509+
invalid = {
510+
"foo_test": np.array(
511+
[
512+
(1, np.nan, (np.nan, np.nan, np.nan), np.iinfo("i4").min, np.iinfo("i1").min, 0),
513+
(2, np.nan, (4.2, 4.3, 4.4), 3, 7, 0),
514+
(3, 7.3, (np.nan, np.nan, np.nan), 5, 8, 0),
515+
(4, 8.3, (8.4, 8.5, 8.6), np.iinfo("i4").min, 9, 0),
516+
(5, 9.3, (9.4, 9.5, 9.6), 6, np.iinfo("i1").min, 0),
517+
(6, 10.3, (10.4, 10.5, 10.6), 7, 11, 0),
518+
],
519+
dtype=dfoo,
520+
),
521+
"bar_test": np.array([(4, 0.4), (5, np.nan)], dtype=dbar),
522+
}
523+
524+
errors = none_missing(data=invalid, component="foo_test", fields="foo")
525+
assert len(errors) == 1
526+
assert errors == [MissingValueError("foo_test", "foo", [1, 2])]
527+
528+
errors = none_missing(data=invalid, component="foo_test", fields="bar")
529+
assert len(errors) == 1
530+
assert errors == [MissingValueError("foo_test", "bar", [1, 3])]
531+
532+
errors = none_missing(data=invalid, component="foo_test", fields="baz")
533+
assert len(errors) == 1
534+
assert errors == [MissingValueError("foo_test", "baz", [1, 4])]
535+
536+
errors = none_missing(data=invalid, component="foo_test", fields="bla")
537+
assert len(errors) == 1
538+
assert errors == [MissingValueError("foo_test", "bla", [1, 5])]
539+
540+
errors = none_missing(data=invalid, component="foo_test", fields="ok")
541+
assert len(errors) == 0
542+
543+
for fields in (("foo", "bar", "baz", "bla", "ok"), ("foo", "bar"), ()):
544+
errors = none_missing(data=invalid, component="foo_test", fields=fields)
545+
expected = []
546+
for field in fields:
547+
expected += none_missing(data=invalid, component="foo_test", fields=field)
548+
assert errors == expected
549+
550+
assert none_missing(
551+
data={
552+
"foo_test": {
553+
"id": invalid["foo_test"]["id"],
554+
"foo": invalid["foo_test"]["foo"],
555+
"bar": invalid["foo_test"]["bar"],
556+
"baz": invalid["foo_test"]["baz"],
557+
"bla": invalid["foo_test"]["bla"],
558+
"ok": invalid["foo_test"]["ok"],
559+
},
560+
"bar_test": {
561+
"id": invalid["bar_test"]["id"],
562+
"foobar": invalid["bar_test"]["foobar"],
563+
},
564+
},
565+
component="foo_test",
566+
fields=("foo", "bar", "baz", "bla", "ok"),
567+
) == none_missing(data=invalid, component="foo_test", fields=("foo", "bar", "baz", "bla", "ok"))
477568

478569

479570
@pytest.mark.skip("No unit tests available for all_valid_clocks")

0 commit comments

Comments
 (0)