-
Notifications
You must be signed in to change notification settings - Fork 6
feat: grid serialization #100
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jaapschoutenalliander
wants to merge
20
commits into
main
Choose a base branch
from
feature/serialization
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
9c3a161
feat: setup grid serialization methods
jaapschoutenalliander 1491e14
test: cleanup tests
jaapschoutenalliander 6e11a3d
chore: requirements add msgpack
jaapschoutenalliander d401ef6
chore: sonar feedback
jaapschoutenalliander f629e6a
chore: sonar feedback
jaapschoutenalliander b77f8e3
chore: sonar feedback
jaapschoutenalliander 108b934
feat: remove msgpack
jaapschoutenalliander a6f0cbe
feat: bypass pgm json conversion for simplicity
jaapschoutenalliander af87381
feat: support additional values
jaapschoutenalliander 89fa6ee
feat: support additional values
jaapschoutenalliander e838550
feat: support additional values
jaapschoutenalliander 942e22b
feat: remove obsolete test
jaapschoutenalliander ec8c44c
chore: feedback
jaapschoutenalliander c588156
feat: pr feedback
jaapschoutenalliander 8bcf0e7
feat: pr feedback json kwargs
jaapschoutenalliander b1da441
chore: type
jaapschoutenalliander c35e081
chore: type
jaapschoutenalliander 21906dc
Merge remote-tracking branch 'origin/main' into feature/serialization
Thijss 257360d
Apply some changes from review
Thijss deb0c57
Apply suggestion from @Thijss
Thijss File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,7 @@ | |
| from power_grid_model_ds._core.model.grids._text_sources import TextSource | ||
| from power_grid_model_ds._core.model.grids.helpers import set_feeder_ids, set_is_feeder | ||
| from power_grid_model_ds._core.utils.pickle import get_pickle_path, load_from_pickle, save_to_pickle | ||
| from power_grid_model_ds._core.utils.serialization import load_grid_from_json, save_grid_to_json | ||
| from power_grid_model_ds._core.utils.zip import file2gzip | ||
|
|
||
| Self = TypeVar("Self", bound="Grid") | ||
|
|
@@ -360,7 +361,10 @@ def get_downstream_nodes(self, node_id: int, inclusive: bool = False): | |
| ) | ||
|
|
||
| def cache(self, cache_dir: Path, cache_name: str, compress: bool = True): | ||
| """Cache Grid to a folder | ||
| """Cache Grid to a folder using pickle format. | ||
|
|
||
| Note: Consider using save_to_json() for better | ||
| interoperability and standardized format. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. raise Deprecation Warning? |
||
|
|
||
| Args: | ||
| cache_dir (Path): The directory to save the cache to. | ||
|
|
@@ -435,6 +439,22 @@ def from_txt_file(cls, txt_file_path: Path): | |
| txt_lines = f.readlines() | ||
| return TextSource(grid_class=cls).load_from_txt(*txt_lines) | ||
|
|
||
| def serialize(self, path: Path, **kwargs) -> Path: | ||
| """Serialize the grid to JSON format. | ||
|
|
||
| Args: | ||
| path: Destination file path to write JSON to. | ||
| **kwargs: Additional keyword arguments forwarded to ``json.dump`` | ||
| Returns: | ||
| Path: The path where the file was saved. | ||
| """ | ||
| return save_grid_to_json(grid=self, path=path, **kwargs) | ||
|
|
||
| @classmethod | ||
| def deserialize(cls: Type[Self], path: Path) -> Self: | ||
| """Deserialize the grid from JSON format.""" | ||
| return load_grid_from_json(path=path, target_grid_class=cls) | ||
|
|
||
| def set_feeder_ids(self): | ||
| """Sets feeder and substation id properties in the grids arrays""" | ||
| set_is_feeder(grid=self) | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| # SPDX-FileCopyrightText: Contributors to the Power Grid Model project <[email protected]> | ||
| # | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
|
|
||
| """Serialization utilities for Grid objects using power-grid-model serialization with extensions support.""" | ||
|
|
||
| import dataclasses | ||
| import json | ||
| import logging | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING, Type, TypeVar | ||
|
|
||
| from power_grid_model_ds._core.model.arrays.base.array import FancyArray | ||
|
|
||
| if TYPE_CHECKING: | ||
| # Import only for type checking to avoid circular imports at runtime | ||
| from power_grid_model_ds._core.model.grids.base import Grid | ||
|
|
||
| G = TypeVar("G", bound=Grid) | ||
| else: | ||
| # Runtime: don't import Grid to avoid circular import; keep unbound TypeVar | ||
| G = TypeVar("G") | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def save_grid_to_json(grid, path: Path, strict: bool = True, **kwargs) -> Path: | ||
| """Save a Grid object to JSON format using power-grid-model serialization with extensions support. | ||
|
|
||
| Args: | ||
| grid: The Grid object to serialize | ||
| path: The file path to save to | ||
| strict: Whether to raise an error if the grid object is not serializable. | ||
| **kwargs: Keyword arguments forwarded to json.dump (for example, indent, sort_keys, | ||
| ensure_ascii, etc.). | ||
| Returns: | ||
| Path: The path where the file was saved | ||
| """ | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| serialized_data = {} | ||
|
|
||
| for field in dataclasses.fields(grid): | ||
| if field.name in ["graphs", "_id_counter"]: | ||
| continue | ||
|
|
||
| field_value = getattr(grid, field.name) | ||
|
|
||
| if isinstance(field_value, FancyArray): | ||
| serialized_data[field.name] = { | ||
| "data": {name: field_value[name].tolist() for name in field_value.dtype.names}, | ||
| } | ||
| continue | ||
|
|
||
| try: | ||
| json.dumps(field_value) | ||
| except TypeError as e: | ||
| if strict: | ||
| raise | ||
| logger.warning(f"Failed to serialize '{field.name}': {e}") | ||
|
|
||
| # Write to file | ||
| with open(path, "w", encoding="utf-8") as f: | ||
| json.dump(serialized_data, f, **kwargs) | ||
|
|
||
| return path | ||
|
|
||
|
|
||
| def load_grid_from_json(path: Path, target_grid_class: Type[G]) -> G: | ||
| """Load a Grid object from JSON format with cross-type loading support. | ||
|
|
||
| Args: | ||
| path: The file path to load from | ||
| target_grid_class: Grid class to load into. | ||
|
|
||
| Returns: | ||
| Grid: The deserialized Grid object of the specified target class | ||
| """ | ||
| with open(path, "r", encoding="utf-8") as f: | ||
| input_data = json.load(f) | ||
|
|
||
| grid = target_grid_class.empty() | ||
| _restore_grid_values(grid, input_data) | ||
| graph_class = grid.graphs.__class__ | ||
| grid.graphs = graph_class.from_arrays(grid) | ||
| return grid | ||
|
|
||
|
|
||
| def _restore_grid_values(grid: G, json_data: dict) -> None: | ||
| """Restore arrays to the grid.""" | ||
| for attr_name, attr_values in json_data.items(): | ||
| if not hasattr(grid, attr_name): | ||
| logger.warning(f"Unexpected attribute '{attr_name}'") | ||
| continue | ||
|
|
||
| grid_attr = getattr(grid, attr_name) | ||
| attr_class = grid_attr.__class__ | ||
| if isinstance(grid_attr, FancyArray): | ||
| if extra := set(attr_values["data"]) - set(grid_attr.columns): | ||
| logger.warning(f"{attr_name} has extra columns: {extra}") | ||
|
|
||
| matched_columns = {col: attr_values["data"][col] for col in grid_attr.columns if col in attr_values["data"]} | ||
| restored_array = attr_class(**matched_columns) | ||
| setattr(grid, attr_name, restored_array) | ||
| continue | ||
|
|
||
| # load other values | ||
| setattr(grid, attr_name, attr_class(attr_values)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ik mis tests voor wanneer het crasht. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| # SPDX-FileCopyrightText: Contributors to the Power Grid Model project <[email protected]> | ||
| # | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
|
|
||
| """Comprehensive unit tests for Grid serialization with power-grid-model compatibility.""" | ||
|
|
||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
|
|
||
| import numpy as np | ||
| import pytest | ||
| from numpy.typing import NDArray | ||
|
|
||
| from power_grid_model_ds import Grid | ||
| from power_grid_model_ds._core.model.arrays.base.array import FancyArray | ||
| from power_grid_model_ds._core.utils.serialization import ( | ||
| load_grid_from_json, | ||
| save_grid_to_json, | ||
| ) | ||
| from power_grid_model_ds.arrays import LineArray | ||
| from power_grid_model_ds.arrays import NodeArray as BaseNodeArray | ||
| from power_grid_model_ds.fancypy import array_equal | ||
|
|
||
|
|
||
| class ExtendedNodeArray(BaseNodeArray): | ||
| """Test array with extended columns""" | ||
|
|
||
| _defaults = {"u": 0.0, "analysis_flag": 0} | ||
| u: NDArray[np.float64] | ||
| analysis_flag: NDArray[np.int32] | ||
|
|
||
|
|
||
| class ExtendedLineArray(LineArray): | ||
| """Test array with extended columns""" | ||
|
|
||
| _defaults = {"i_from": 0.0, "loading_factor": 0.0} | ||
| i_from: NDArray[np.float64] | ||
| loading_factor: NDArray[np.float64] | ||
|
|
||
|
|
||
| @dataclass | ||
| class ExtendedGrid(Grid): | ||
| """Test grid with extended arrays""" | ||
|
|
||
| node: ExtendedNodeArray | ||
| line: ExtendedLineArray | ||
|
|
||
| value_extension: float = 0.0 | ||
| str_extension: str = "default" | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def basic_grid(): | ||
| """Basic grid fixture""" | ||
| return Grid.from_txt("1 2", "2 3", "S10 1") | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def extended_grid(): | ||
| """Extended grid fixture with additional columns""" | ||
| grid = ExtendedGrid.empty() | ||
| nodes = ExtendedNodeArray( | ||
| id=[1, 2, 3], u_rated=[10500, 10500, 10500], u=[10450, 10400, 10350], analysis_flag=[1, 0, 1] | ||
| ) | ||
| lines = ExtendedLineArray( | ||
| id=[10, 11], | ||
| from_node=[1, 2], | ||
| to_node=[2, 3], | ||
| from_status=[1, 1], | ||
| to_status=[1, 1], | ||
| r1=[0.1, 0.15], | ||
| x1=[0.2, 0.25], | ||
| c1=[1e-6, 1.2e-6], | ||
| tan1=[0.0, 0.0], | ||
| i_n=[400, 350], | ||
| i_from=[150.5, 120.3], | ||
| loading_factor=[0.75, 0.68], | ||
| ) | ||
| grid.append(nodes) | ||
| grid.append(lines) | ||
| return grid | ||
|
|
||
|
|
||
| class TestSerializationRoundtrips: | ||
| """Test serialization across different formats and configurations""" | ||
|
|
||
| def test_basic_serialization_roundtrip(self, basic_grid: Grid, tmp_path: Path): | ||
| """Test basic serialization roundtrip for all formats""" | ||
| path = tmp_path / "test.json" | ||
| result_path = save_grid_to_json(basic_grid, path) | ||
| assert result_path.exists() | ||
|
|
||
| # Load and verify | ||
| loaded_grid = load_grid_from_json(path, target_grid_class=Grid) | ||
| array_equal(loaded_grid.node, basic_grid.node) | ||
| array_equal(loaded_grid.line, basic_grid.line) | ||
| assert list(loaded_grid.node.id) == list(basic_grid.node.id) | ||
|
|
||
| def test_extended_serialization_roundtrip(self, extended_grid: ExtendedGrid, tmp_path: Path): | ||
| """Test extended serialization preserving custom data""" | ||
| path = tmp_path / "extended.json" | ||
|
|
||
| save_grid_to_json(extended_grid, path) | ||
| loaded_grid = load_grid_from_json(path, target_grid_class=ExtendedGrid) | ||
|
|
||
| # Verify core data | ||
| assert loaded_grid.node.size == extended_grid.node.size | ||
| assert loaded_grid.line.size == extended_grid.line.size | ||
| assert loaded_grid.value_extension == extended_grid.value_extension | ||
| assert loaded_grid.str_extension == extended_grid.str_extension | ||
|
|
||
| # Verify extended data | ||
| np.testing.assert_array_equal(loaded_grid.node.u, extended_grid.node.u) | ||
| np.testing.assert_array_equal(loaded_grid.line.i_from, extended_grid.line.i_from) | ||
|
|
||
| def test_empty_grid_handling(self, tmp_path: Path): | ||
| """Test serialization of empty grids""" | ||
| empty_grid = Grid.empty() | ||
|
|
||
| json_path = tmp_path / "empty.json" | ||
|
|
||
| # Should handle empty grids | ||
| save_grid_to_json(empty_grid, json_path) | ||
|
|
||
| # Should load back as empty | ||
| loaded_json = load_grid_from_json(json_path, target_grid_class=Grid) | ||
| assert loaded_json.node.size == 0 | ||
|
|
||
|
|
||
| class TestCrossTypeCompatibility: | ||
| """Test cross-type loading and compatibility""" | ||
|
|
||
| def test_basic_to_extended_loading(self, basic_grid: Grid, tmp_path: Path): | ||
| """Test loading basic grid into extended type""" | ||
| path = tmp_path / "basic.json" | ||
|
|
||
| # Save basic grid | ||
| save_grid_to_json(basic_grid, path) | ||
| loaded_grid = load_grid_from_json(path, target_grid_class=ExtendedGrid) | ||
|
|
||
| # Core data should transfer | ||
| array_equal(loaded_grid.node, basic_grid.node) | ||
| array_equal(loaded_grid.line, basic_grid.line) | ||
|
|
||
| def test_extended_to_basic_loading(self, extended_grid: ExtendedGrid, tmp_path: Path): | ||
| """Test loading extended grid into basic type""" | ||
| path = tmp_path / "extended.json" | ||
|
|
||
| # Save extended grid | ||
| save_grid_to_json(extended_grid, path) | ||
| loaded_grid = load_grid_from_json(path, target_grid_class=Grid) | ||
|
|
||
| # Core data should transfer | ||
| array_equal(loaded_grid.node, extended_grid.node) | ||
| array_equal(loaded_grid.line, extended_grid.line) | ||
|
|
||
|
|
||
| class TestExtensionHandling: | ||
| """Test extension data handling and edge cases""" | ||
|
|
||
| def test_custom_array_serialization_roundtrip(self, tmp_path: Path): | ||
| """Test serialization and loading of grids with custom arrays""" | ||
|
|
||
| # Create a custom array type that properly extends FancyArray | ||
| class CustomMetadataArray(FancyArray): | ||
| """Custom metadata array for testing""" | ||
|
|
||
| _defaults = {"metadata_value": 0.0, "category": 0} | ||
|
|
||
| id: NDArray[np.int32] | ||
| metadata_value: NDArray[np.float64] | ||
| category: NDArray[np.int32] | ||
|
|
||
| # Create a grid with custom arrays | ||
| @dataclass | ||
| class GridWithCustomArray(Grid): | ||
| custom_metadata: CustomMetadataArray | ||
|
|
||
| # Create test grid with custom data | ||
| grid = GridWithCustomArray.empty() | ||
|
|
||
| # Add some basic grid data | ||
| nodes = grid.node.__class__(id=[1, 2], u_rated=[10000, 10000]) | ||
| grid.append(nodes) | ||
|
|
||
| # Add custom metadata | ||
| custom_data = CustomMetadataArray(id=[100, 200, 300], metadata_value=[1.5, 2.5, 3.5], category=[1, 2, 1]) | ||
| grid.custom_metadata = custom_data | ||
|
|
||
| # Test JSON serialization | ||
| json_path = tmp_path / "custom_array.json" | ||
| save_grid_to_json(grid, json_path) | ||
|
|
||
| # Load back and verify | ||
| loaded_grid = load_grid_from_json(json_path, target_grid_class=GridWithCustomArray) | ||
|
|
||
| # Verify core data | ||
| assert loaded_grid.node.size == 2 | ||
| np.testing.assert_array_equal(loaded_grid.node.id, [1, 2]) | ||
|
|
||
| # Verify custom array was preserved | ||
| assert hasattr(loaded_grid, "custom_metadata") | ||
| assert loaded_grid.custom_metadata.size == 3 | ||
| np.testing.assert_array_equal(loaded_grid.custom_metadata.id, [100, 200, 300]) | ||
| np.testing.assert_array_almost_equal(loaded_grid.custom_metadata.metadata_value, [1.5, 2.5, 3.5]) | ||
| np.testing.assert_array_equal(loaded_grid.custom_metadata.category, [1, 2, 1]) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.