Skip to content

Commit 5a7aea0

Browse files
committed
Merge remote-tracking branch 'origin/main' into pgm/remove-id-counter
Signed-off-by: Thijs Baaijen <13253091+Thijss@users.noreply.github.com> # Conflicts: # tests/unit/model/grids/test_base.py
2 parents 0f25a88 + c3afcdb commit 5a7aea0

25 files changed

Lines changed: 584 additions & 320 deletions

File tree

.github/workflows/sonar.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
4747
- name: SonarCloud Scan
4848
if: ${{ (github.event_name == 'push') || (github.event.pull_request.head.repo.owner.login == 'PowerGridModel') }}
49-
uses: SonarSource/sonarqube-scan-action@299e4b793aaa83bf2aba7c9c14bedbb485688ec4 # v7.1.0
49+
uses: SonarSource/sonarqube-scan-action@59db25f34e16620e48ab4bb9e4a5dce155cb5432 # v8.0.0
5050
env:
5151
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5252
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ select = [
160160
# raise vanilla args
161161
"TRY002",
162162
# pytest-style
163-
#"PT",
163+
"PT",
164164
]
165165
# Allow fix for all enabled rules (when `--fix`) is provided.
166166
fixable = ["ALL"]

src/power_grid_model_ds/_core/model/arrays/base/array.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
from power_grid_model_ds._core.model.arrays.base._string import convert_array_to_string
2020
from power_grid_model_ds._core.model.arrays.base.errors import ArrayDefinitionError
2121
from power_grid_model_ds._core.model.constants import EMPTY_ID, empty
22-
from power_grid_model_ds._core.utils.misc import array_equal_with_nan, get_inherited_attrs
22+
from power_grid_model_ds._core.utils.misc import (
23+
array_equal_with_nan,
24+
combine_attribute_from_parent_classes,
25+
get_public_annotations,
26+
)
2327

2428
# pylint: disable=missing-function-docstring, too-many-public-methods
2529

@@ -44,9 +48,15 @@ class FancyArray(ABC): # noqa: B024
4448
>>> name: NDArray[np.str_]
4549
>>> value: NDArray[np.float64]
4650
51+
Note on default values:
52+
Default values for columns can be defined by adding a class attribute _defaults,
53+
which is a dictionary mapping column names to their default values.
54+
The _defaults attribute is inherited by child classes.
55+
4756
Note on string-columns:
4857
The default length for string columns is stored in _DEFAULT_STR_LENGTH.
4958
To change this, you can set the _str_lengths class attribute.
59+
The _str_lengths attribute is inherited by child classes.
5060
5161
Example:
5262
>>> class MyArray(FancyArray):
@@ -74,13 +84,13 @@ def data(self) -> NDArray:
7484
@classmethod
7585
@lru_cache
7686
def get_defaults(cls) -> dict[str, Any]:
77-
return get_inherited_attrs(cls, "_defaults")["_defaults"]
87+
return combine_attribute_from_parent_classes(cls, "_defaults", attribute_type=dict)
7888

7989
@classmethod
8090
@lru_cache
8191
def get_dtype(cls):
82-
annotations = get_inherited_attrs(cls, "_str_lengths")
83-
str_lengths = annotations.pop("_str_lengths")
92+
annotations = get_public_annotations(cls)
93+
str_lengths = combine_attribute_from_parent_classes(cls, "_str_lengths", dict)
8494
dtypes = {}
8595
for name, dtype in annotations.items():
8696
if len(dtype.__args__) > 1:

src/power_grid_model_ds/_core/model/grids/_modify.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def add_node(grid: "Grid", node: NodeArray) -> None:
3939
)
4040
grid._append(array=node) # noqa # pylint: disable=protected-access
4141
grid.graphs.add_node_array(node_array=node)
42-
_logger.debug("added node %d", node.id)
42+
_logger.debug("added node %s", node.id.tolist())
4343

4444

4545
def add_branch(grid: "Grid", branch: BranchArray) -> None:
@@ -52,33 +52,43 @@ def add_branch(grid: "Grid", branch: BranchArray) -> None:
5252
grid._append(array=branch) # noqa # pylint: disable=protected-access
5353
grid.graphs.add_branch_array(branch_array=branch)
5454

55-
_logger.debug("added branch %d from %d to %d", branch.id, branch.from_node, branch.to_node)
55+
_logger.debug(
56+
"added branch %s from %s to %s", branch.id.tolist(), branch.from_node.tolist(), branch.to_node.tolist()
57+
)
5658

5759

5860
def make_active(grid: "Grid", branch: BranchArray) -> None:
5961
"""See Grid.make_active()"""
6062
array_field = grid.find_array_field(branch.__class__)
6163
array_attr = getattr(grid, array_field.name)
6264
branch_mask = array_attr.id == branch.id
65+
already_active = bool(array_attr[branch_mask].is_active)
6366
array_attr.from_status[branch_mask] = 1
6467
array_attr.to_status[branch_mask] = 1
6568
setattr(grid, array_field.name, array_attr)
6669

67-
grid.graphs.make_active(branch=branch)
68-
_logger.debug("activated branch %d", branch.id)
70+
if not already_active:
71+
grid.graphs.make_active(branch=branch)
72+
else:
73+
_logger.warning("Branch %s is already active", branch.id.tolist())
74+
_logger.debug("activated branch %s", branch.id.tolist())
6975

7076

7177
def make_inactive(grid, branch: BranchArray, at_to_side: bool = True) -> None:
7278
"""See Grid.make_inactive()"""
7379
array_field = grid.find_array_field(branch.__class__)
7480
array_attr = getattr(grid, array_field.name)
7581
branch_mask = array_attr.id == branch.id
82+
already_inactive = bool(~array_attr[branch_mask].is_active)
7683
status_side = "to_status" if at_to_side else "from_status"
7784
array_attr[status_side][branch_mask] = 0
7885
setattr(grid, array_field.name, array_attr)
7986

80-
grid.graphs.make_inactive(branch=branch)
81-
_logger.debug("deactivated branch %d", branch.id)
87+
if not already_inactive:
88+
grid.graphs.make_inactive(branch=branch)
89+
else:
90+
_logger.warning("Branch %s is already inactive", branch.id.tolist())
91+
_logger.debug("deactivated branch %s", branch.id.tolist())
8292

8393

8494
def delete_node(grid: "Grid", node: NodeArray) -> None:
@@ -123,23 +133,25 @@ def delete_node(grid: "Grid", node: NodeArray) -> None:
123133

124134
grid.graphs.delete_node(node=node)
125135
grid.rebuild_ids()
126-
_logger.debug("deleted node %d", node.id)
136+
_logger.debug("deleted node %s", node.id.tolist())
127137

128138

129139
def delete_branch(grid: "Grid", branch: BranchArray) -> None:
130140
"""See Grid.delete_branch()"""
131141
_delete_branch_array(branch=branch, grid=grid)
132142
grid.graphs.delete_branch(branch=branch)
133143
grid.rebuild_ids()
134-
_logger.debug("""deleted branch %d from %d to %d""", branch.id, branch.from_node, branch.to_node)
144+
_logger.debug(
145+
"deleted branch %s from %s to %s", branch.id.tolist(), branch.from_node.tolist(), branch.to_node.tolist()
146+
)
135147

136148

137149
def delete_branch3(grid: "Grid", branch: Branch3Array) -> None:
138150
"""See Grid.delete_branch3()"""
139151
_delete_branch_array(branch=branch, grid=grid)
140152
grid.graphs.delete_branch3(branch=branch)
141153
grid.rebuild_ids()
142-
_logger.debug("deleted branch3 %d", branch.id)
154+
_logger.debug("deleted branch3 %s", branch.id.tolist())
143155

144156

145157
def _delete_branch_array(branch: BranchArray | Branch3Array, grid: "Grid"):
@@ -166,4 +178,4 @@ def delete_appliance(grid: "Grid", appliance: ApplianceArray) -> None:
166178
grid.asym_power_sensor = grid.asym_power_sensor.exclude(measured_object=appliance.id)
167179
grid.voltage_regulator = grid.voltage_regulator.exclude(regulated_object=appliance.id)
168180
grid.rebuild_ids()
169-
_logger.debug("deleted appliance %d", appliance.id)
181+
_logger.debug("deleted appliance %s", appliance.id.tolist())

src/power_grid_model_ds/_core/model/grids/_search.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@
2222
def get_branches(grid: "Grid") -> BranchArray:
2323
"""see Grid.get_branches()"""
2424
branch_dtype = BranchArray.get_dtype()
25-
branches = BranchArray()
26-
for array in grid.branch_arrays:
27-
new_branch = BranchArray(data=array.data[list(branch_dtype.names)])
28-
branches = fp.concatenate(branches, new_branch)
29-
return branches
25+
branch_columns = list(branch_dtype.names)
26+
branch_arrays = get_branch_arrays(grid)
27+
consistent_branch_arrays = [array[branch_columns] for array in branch_arrays if array.size]
28+
if len(consistent_branch_arrays) == 0:
29+
return BranchArray()
30+
return fp.concatenate(BranchArray(), *consistent_branch_arrays)
3031

3132

3233
def get_branch_arrays(grid: "Grid") -> list[BranchArray]:

src/power_grid_model_ds/_core/model/grids/base.py

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@
4646
get_nearest_substation_node,
4747
get_typed_branches,
4848
)
49-
from power_grid_model_ds._core.model.grids.serialization.json import deserialize_from_json, serialize_to_json
49+
from power_grid_model_ds._core.model.grids.serialization.json import (
50+
deserialize_from_json,
51+
deserialize_from_json_string,
52+
serialize_to_json,
53+
serialize_to_json_string,
54+
)
5055
from power_grid_model_ds._core.model.grids.serialization.pickle import load_grid_from_pickle, save_grid_to_pickle
5156
from power_grid_model_ds._core.model.grids.serialization.string import (
5257
deserialize_from_str,
@@ -464,20 +469,53 @@ def merge(self, other_grid, mode: Literal["keep_ids", "recalculate_ids"]):
464469
"""
465470
return merge_grids(self, other_grid, mode)
466471

467-
def serialize(self, path: Path, **kwargs) -> Path:
472+
@overload
473+
def serialize(self, path: Path, mode: Literal["json"] = "json", **kwargs) -> Path: ...
474+
475+
@overload
476+
def serialize(self, path: None = None, *, mode: Literal["json_string"], **kwargs) -> str: ...
477+
478+
def serialize(self, path=None, mode: Literal["json", "json_string"] = "json", **kwargs):
468479
"""Serialize the grid.
469480
470481
Args:
471-
path: Destination file path to write JSON to.
472-
**kwargs: Additional keyword arguments forwarded to ``json.dump``
482+
path: Destination file path. Required when mode is ``"json"``, ignored otherwise.
483+
mode: Serialization target. Use ``"json"`` (default) to write a JSON file, or ``"json_string"`` to
484+
return a JSON string.
485+
**kwargs: Additional keyword arguments forwarded to ``json.dump`` / ``json.dumps``.
473486
Returns:
474-
Path: The path where the file was saved.
487+
Path when mode is ``"json"``, str when mode is ``"json_string"``.
475488
"""
476-
return serialize_to_json(grid=self, path=path, strict=True, **kwargs)
489+
match mode:
490+
case "json_string":
491+
return serialize_to_json_string(grid=self, **kwargs)
492+
case "json":
493+
if not isinstance(path, Path):
494+
raise TypeError("path must be a Path when mode='json'")
495+
return serialize_to_json(grid=self, path=path, strict=True, **kwargs)
496+
case _:
497+
raise ValueError(f"Invalid mode '{mode}'. Expected 'json' or 'json_string'.")
498+
499+
@classmethod
500+
def from_json_string(cls: type[Self], json_string: str) -> Self:
501+
"""Deserialize the grid from a JSON string.
502+
503+
Args:
504+
json_string: A JSON string as produced by ``serialize(mode="json_string")``.
505+
Returns:
506+
Self: The deserialized grid instance.
507+
"""
508+
return deserialize_from_json_string(json_string=json_string, target_grid_class=cls)
477509

478510
@classmethod
479511
def deserialize(cls: type[Self], path: Path) -> Self:
480-
"""Deserialize the grid."""
512+
"""Deserialize the grid from a JSON file.
513+
514+
Args:
515+
path: Path to the JSON file.
516+
Returns:
517+
Self: The deserialized grid instance.
518+
"""
481519
return deserialize_from_json(path=path, target_grid_class=cls)
482520

483521
def rebuild_graphs(self) -> None:

src/power_grid_model_ds/_core/model/grids/serialization/json.py

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,22 @@ def serialize_to_json[G: Grid](grid: G, path: Path, strict: bool = True, **kwarg
3333
Path: The path where the file was saved
3434
"""
3535
path.parent.mkdir(parents=True, exist_ok=True)
36+
json_data = serialize_to_dict(grid=grid, strict=strict, **kwargs)
37+
with path.open("w", encoding="utf-8") as f:
38+
json.dump(json_data, f, **kwargs)
39+
return path
40+
3641

42+
def serialize_to_dict[G: Grid](grid: G, strict: bool = True, **kwargs) -> dict:
43+
"""Serialize a Grid object to a Python dict.
44+
45+
Args:
46+
grid: The Grid object to serialize
47+
strict: Whether to raise an error if the grid object is not serializable.
48+
**kwargs: Keyword arguments forwarded to json.dumps for serializability checks (e.g. cls).
49+
Returns:
50+
dict: A PGM-compatible dict representation of the grid.
51+
"""
3752
serialized_data = {}
3853

3954
for field in dataclasses.fields(grid):
@@ -49,16 +64,10 @@ def serialize_to_json[G: Grid](grid: G, path: Path, strict: bool = True, **kwarg
4964
if _is_serializable(field_value, strict, **kwargs):
5065
serialized_data[field.name] = field_value
5166

52-
# Store in a wrapper for PGM compatibility
53-
json_data = {"data": serialized_data}
54-
55-
with Path(path).open("w", encoding="utf-8") as f:
56-
json.dump(json_data, f, **kwargs)
57-
58-
return path
67+
return {"data": serialized_data}
5968

6069

61-
def deserialize_from_json[G: Grid](path: Path, target_grid_class: type[G], **kwargs) -> G:
70+
def deserialize_from_json[G: Grid](path: Path, target_grid_class: type[G]) -> G:
6271
"""Load a Grid object from JSON format with cross-type loading support.
6372
6473
Args:
@@ -68,19 +77,54 @@ def deserialize_from_json[G: Grid](path: Path, target_grid_class: type[G], **kwa
6877
Returns:
6978
Grid: The deserialized Grid object of the specified target class
7079
"""
71-
if "decoder_cls" in kwargs:
72-
kwargs["cls"] = kwargs.pop("decoder_cls")
80+
with path.open(encoding="utf-8") as f:
81+
json_data = json.load(f)
82+
return deserialize_from_dict(data=json_data, target_grid_class=target_grid_class)
7383

74-
with Path(path).open("r", encoding="utf-8") as f:
75-
json_data = json.load(f, **kwargs)
7684

85+
def deserialize_from_dict[G: Grid](data: dict, target_grid_class: type[G]) -> G:
86+
"""Load a Grid object from a Python dict.
87+
88+
Args:
89+
data: A dict as produced by ``serialize_to_dict``.
90+
target_grid_class: Grid class to load into.
91+
92+
Returns:
93+
Grid: The deserialized Grid object of the specified target class
94+
"""
7795
grid = target_grid_class.empty()
78-
_restore_grid_values(grid, json_data["data"])
96+
_restore_grid_values(grid, data["data"])
7997
grid.rebuild_ids()
8098
grid.rebuild_graphs()
8199
return grid
82100

83101

102+
def serialize_to_json_string[G: Grid](grid: G, strict: bool = True, **kwargs) -> str:
103+
"""Serialize a Grid to a JSON string (in memory, no file I/O).
104+
105+
Args:
106+
grid: The Grid object to serialize.
107+
strict: Whether to raise an error if the grid is not serializable.
108+
**kwargs: Forwarded to json.dumps (e.g. indent, sort_keys, cls).
109+
Returns:
110+
str: A JSON string representation of the grid.
111+
"""
112+
data = serialize_to_dict(grid=grid, strict=strict, **kwargs)
113+
return json.dumps(data, **kwargs)
114+
115+
116+
def deserialize_from_json_string[G: Grid](json_string: str, target_grid_class: type[G]) -> G:
117+
"""Load a Grid from a JSON string.
118+
119+
Args:
120+
json_string: A JSON string as produced by ``serialize_to_json_string``.
121+
target_grid_class: Grid class to load into.
122+
Returns:
123+
Grid: The deserialized Grid object.
124+
"""
125+
return deserialize_from_dict(data=json.loads(json_string), target_grid_class=target_grid_class)
126+
127+
84128
def _restore_grid_values[G: Grid](grid: G, json_data: dict) -> None:
85129
"""Restore arrays to the grid."""
86130
for attr_name, attr_values in json_data.items():

src/power_grid_model_ds/_core/utils/misc.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,34 @@ def is_sequence(seq):
2323
return isinstance(seq, Sequence)
2424

2525

26-
def get_inherited_attrs(cls: type, *private_attributes):
27-
"""
28-
Get the attribute from the object and all its parents
29-
"""
26+
def get_public_annotations(cls: type):
27+
"""Get the public annotations for a class"""
28+
# Note: include_extras=True for annotated types like NDArray3
29+
class_attributes = get_type_hints(cls, include_extras=True)
30+
return {attr: type_ for attr, type_ in class_attributes.items() if not attr.startswith("_")}
3031

31-
# The extras are needed for annotated types like NDArray3
32-
retrieved_attributes = get_type_hints(cls, include_extras=True)
33-
retrieved_attributes = {attr: type for attr, type in retrieved_attributes.items() if not attr.startswith("_")}
3432

35-
for private_attr in private_attributes:
36-
for parent in reversed(list(cls.__mro__)):
37-
attr_dict = retrieved_attributes.get(private_attr, {})
38-
attr_dict.update(getattr(parent, private_attr, {}))
39-
retrieved_attributes[private_attr] = attr_dict
33+
def combine_attribute_from_parent_classes[T: (dict, set)](cls: type, attribute_name: str, attribute_type: type[T]) -> T:
34+
"""Combine all versions of an attribute in the Method Resolution Order (mro) of a class into a single attribute
4035
41-
return retrieved_attributes
36+
For dicts this means the dict is updated so that child classes override parent classes.
37+
For sets this means the sets are unioned together.
38+
39+
Types other than dict and set are not supported
40+
"""
41+
combined_attr = attribute_type()
42+
for parent in reversed(list(cls.__mro__)):
43+
parent_attr = getattr(parent, attribute_name, attribute_type())
44+
if attribute_type is dict:
45+
combined_attr.update(parent_attr)
46+
elif attribute_type is set:
47+
combined_attr |= parent_attr
48+
else:
49+
raise NotImplementedError(
50+
f"Type {attribute_type} cannot combine inherited for attribute {attribute_name}. "
51+
f"Only dict and set are currently supported."
52+
)
53+
return combined_attr
4254

4355

4456
def array_equal_with_nan(array1: np.ndarray, array2: np.ndarray) -> bool:

0 commit comments

Comments
 (0)