Skip to content

Commit

Permalink
feat(components): Add deepcopy of component
Browse files Browse the repository at this point in the history
This feature requires that we remove the system_uuid field from
components. It provided some value for consistency checks but caused
issues with the deepcopy. We would have to clear the field on all
composed components.
  • Loading branch information
daniel-thom committed Apr 12, 2024
1 parent be8dc56 commit 5f7ab77
Show file tree
Hide file tree
Showing 7 changed files with 84 additions and 94 deletions.
5 changes: 0 additions & 5 deletions docs/explanation/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ those types.
{
"uuid": "1e5f90ae-a386-4c8a-89ae-0ed123da3e26",
"name": null,
"system_uuid": "9c33cd43-38ac-4ecf-97b8-0eccd1aa1fb5",
"x": 0.0,
"y": 0.0,
"crs": null,
Expand Down Expand Up @@ -44,7 +43,6 @@ de-serialized.
{
"uuid": "e503984a-3285-43b6-84c2-805eb3889210",
"name": "bus1",
"system_uuid": "9c33cd43-38ac-4ecf-97b8-0eccd1aa1fb5",
"voltage": 1.1,
"coordinates": {
"__metadata__": {
Expand Down Expand Up @@ -76,12 +74,10 @@ Here is an example of a bus serialized that way (`bus.model_dump_json(indent=2)`
{
"uuid": "e503984a-3285-43b6-84c2-805eb3889210",
"name": "bus1",
"system_uuid": "9c33cd43-38ac-4ecf-97b8-0eccd1aa1fb5",
"voltage": 1.1,
"coordinates": {
"uuid": "1e5f90ae-a386-4c8a-89ae-0ed123da3e26",
"name": null,
"system_uuid": "9c33cd43-38ac-4ecf-97b8-0eccd1aa1fb5",
"x": 0.0,
"y": 0.0,
"crs": null
Expand All @@ -97,7 +93,6 @@ instance. Here is an example of such a component:
{
"uuid": "711d2724-5814-4e0e-be5f-4b0b825b7f07",
"name": "test",
"system_uuid": "264a2e80-64f4-42ea-b2c4-87aa708287b4",
"distance": {
"value": 2,
"units": "meter",
Expand Down
7 changes: 0 additions & 7 deletions docs/tutorials/custom_system.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,6 @@ class Bus(Component):
voltage: float
coordinates: Location | None = None

def check_component_addition(self, system_uuid: UUID):
if self.coordinates is not None and not self.coordinates.is_attached(
system_uuid=system_uuid
):
msg = f"{self.label} has coordinates that are not attached to the system"
raise ISOperationNotAllowed(msg)

@classmethod
def example(cls) -> "Bus":
return Bus(
Expand Down
48 changes: 3 additions & 45 deletions src/infrasys/component.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
"""Defines base models for components."""

from typing import Any, Optional
from uuid import UUID
from typing import Any

from pydantic import Field, field_serializer
from pydantic import Field
from rich import print as _pprint
from typing_extensions import Annotated

from infrasys.base_quantity import BaseQuantity
from infrasys.exceptions import (
ISNotStored,
ISAlreadyAttached,
)
from infrasys.models import (
InfraSysBaseModelWithIdentifers,
)
Expand All @@ -28,27 +23,10 @@ class Component(InfraSysBaseModelWithIdentifers):
"""Base class for all models representing entities that get attached to a System."""

name: Annotated[str, Field(frozen=True)]
system_uuid: Annotated[Optional[UUID], Field(repr=False, exclude=True)] = None

@field_serializer("system_uuid")
def _serialize_system_uuid(self, _) -> str:
return str(self.system_uuid)

def check_component_addition(self, system_uuid: UUID) -> None:
def check_component_addition(self) -> None:
"""Perform checks on the component before adding it to a system."""

def is_attached(self, system_uuid: Optional[UUID] = None) -> bool:
"""Return True if the component is attached to a system.
Parameters
----------
system_uuid : UUID
Only return True if the component is attached to the system with this UUID.
"""
if self.system_uuid is None:
return False
return self.system_uuid == system_uuid

def model_dump_custom(self, *args, **kwargs) -> dict[str, Any]:
"""Custom serialization for this package"""
refs = {x: self._model_dump_field(x) for x in self.model_fields}
Expand Down Expand Up @@ -82,26 +60,6 @@ def pprint(self):
return _pprint(self)


def raise_if_attached(component: Component):
"""Raise an exception if this component is attached to a system."""
if component.system_uuid is not None:
msg = f"{component.label} is attached to system {component.system_uuid}"
raise ISAlreadyAttached(msg)


def raise_if_not_attached(component: Component, system_uuid: UUID):
"""Raise an exception if this component is not attached to a system.
Parameters
----------
system_uuid : UUID
The component must be attached to the system with this UUID.
"""
if component.system_uuid is None or component.system_uuid != system_uuid:
msg = f"{component.label} is not attached to the system"
raise ISNotStored(msg)


def serialize_component_reference(component: Component) -> dict[str, Any]:
"""Make a JSON serializable reference to a component."""
return SerializedTypeMetadata(
Expand Down
37 changes: 29 additions & 8 deletions src/infrasys/component_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from uuid import UUID
from loguru import logger

from infrasys.component import Component, raise_if_attached
from infrasys.component import Component
from infrasys.exceptions import ISAlreadyAttached, ISNotStored, ISOperationNotAllowed
from infrasys.models import make_label, get_class_and_name_from_label

Expand Down Expand Up @@ -211,7 +211,6 @@ def remove(self, component: Component) -> Any:
for i, comp in enumerate(container):
if comp.uuid == component.uuid:
container.pop(i)
component.system_uuid = None
if not self._components[component_type][component.name]:
self._components[component_type].pop(component.name)
self._components_by_uuid.pop(component.uuid)
Expand All @@ -229,14 +228,14 @@ def copy(
name: str | None = None,
attach=False,
) -> Component:
"""Create a copy of the component. Time series data is excluded."""
"""Create a shallow copy of the component."""
values = {}
for field in type(component).model_fields:
cur_val = getattr(component, field)
if field == "name" and name:
# Name is special-cased because it is a frozen field.
val = name
elif field in ("system_uuid", "uuid"):
elif field in ("uuid",):
continue
else:
val = cur_val
Expand All @@ -250,6 +249,11 @@ def copy(

return new_component

def deepcopy(self, component: Component) -> Component:
"""Create a deep copy of the component."""
values = component.model_dump()
return type(component)(**values)

def change_uuid(self, component: Component) -> None:
"""Change the component UUID."""
raise NotImplementedError("change_component_uuid")
Expand All @@ -267,12 +271,12 @@ def update(
return

def _add(self, component: Component, deserialization_in_progress: bool) -> None:
raise_if_attached(component)
self.raise_if_attached(component)
if not deserialization_in_progress:
# TODO: Do we want any checks during deserialization? User could change the JSON.
# We could prevent the user from changing the JSON with a checksum.
self._check_component_addition(component)
component.check_component_addition(self._uuid)
component.check_component_addition()
if component.uuid in self._components_by_uuid:
msg = f"{component.label} with UUID={component.uuid} is already stored"
raise ISAlreadyAttached(msg)
Expand All @@ -287,7 +291,6 @@ def _add(self, component: Component, deserialization_in_progress: bool) -> None:

self._components[cls][name].append(component)
self._components_by_uuid[component.uuid] = component
component.system_uuid = self._uuid
logger.debug("Added {} to the system", component.label)

def _check_component_addition(self, component: Component) -> None:
Expand All @@ -308,7 +311,7 @@ def _check_component_addition(self, component: Component) -> None:
def _handle_composed_component(self, component: Component) -> None:
"""Do what's needed for a composed component depending on system settings:
nothing, add, or raise an exception."""
if component.system_uuid is not None:
if component.uuid in self._components_by_uuid:
return

if self._auto_add_composed_components:
Expand All @@ -320,3 +323,21 @@ def _handle_composed_component(self, component: Component) -> None:
f"its composed component {component.label} is not already attached."
)
raise ISOperationNotAllowed(msg)

def raise_if_attached(self, component: Component):
"""Raise an exception if this component is attached to a system."""
if component.uuid in self._components_by_uuid:
msg = f"{component.label} is already attached to the system"
raise ISAlreadyAttached(msg)

def raise_if_not_attached(self, component: Component):
"""Raise an exception if this component is not attached to a system.
Parameters
----------
system_uuid : UUID
The component must be attached to the system with this UUID.
"""
if component.uuid not in self._components_by_uuid:
msg = f"{component.label} is not attached to the system"
raise ISNotStored(msg)
50 changes: 38 additions & 12 deletions src/infrasys/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,10 @@
from infrasys.exceptions import (
ISFileExists,
ISConflictingArguments,
ISConflictingSystem,
)
from infrasys.models import make_label
from infrasys.component import (
Component,
raise_if_not_attached,
)
from infrasys.component_manager import ComponentManager
from infrasys.serialization import (
Expand Down Expand Up @@ -349,8 +347,14 @@ def copy_component(
name: str | None = None,
attach: bool = False,
) -> Any:
"""Create a copy of the component. Time series data is excluded. The new component will
have a different UUID from the original.
"""Create a copy of the component. Time series data is excluded.
- The new component will have a different UUID than the original.
- The copied component will have shared references to any composed components.
The intention of this method is to provide a way to create variants of a component that
will be added to the same system. Please refer to :`deepcopy_component`: to create
copies that are suitable for addition to a different system.
Parameters
----------
Expand All @@ -366,9 +370,38 @@ def copy_component(
>>> gen1 = system.get_component(Generator, "gen1")
>>> gen2 = system.copy_component(gen, name="gen2")
>>> gen3 = system.copy_component(gen, name="gen3", attach=True)
See Also
--------
deepcopy_component
"""
return self._component_mgr.copy(component, name=name, attach=attach)

def deepcopy_component(self, component: Component) -> Any:
"""Create a deep copy of the component and all composed components. All attributes,
including names and UUIDs, will be identical to the original. Unlike
:meth:`copy_component`, there will be no shared references to composed components.
The intention of this method is to provide a way to create variants of a component that
will be added to a different system. Please refer to :`copy_component`: to create
copies that are suitable for addition to the same system.
Parameters
----------
component : Component
Source component
Examples
--------
>>> gen1 = system.get_component(Generator, "gen1")
>>> gen2 = system.deepcopy_component(gen)
See Also
--------
copy_component
"""
return self._component_mgr.deepcopy(component)

def get_component(self, component_type: Type[Component], name: str) -> Any:
"""Return the component with the passed type and name.
Expand Down Expand Up @@ -523,7 +556,7 @@ def remove_component(self, component: Component) -> Any:
>>> gen = system.get_component(Generator, "gen1")
>>> system.remove_component(gen)
"""
raise_if_not_attached(component, self.uuid)
self._component_mgr.raise_if_not_attached(component)
if self.has_time_series(component):
for metadata in self._time_series_mgr.list_time_series_metadata(component):
self.remove_time_series(
Expand Down Expand Up @@ -1012,13 +1045,6 @@ def _try_deserialize_component(

metadata = SerializedTypeMetadata(**component[TYPE_METADATA])
component_type = cached_types.get_type(metadata.fields)
system_uuid = values.pop("system_uuid")
if str(self.uuid) != system_uuid:
msg = (
"component has a system_uuid that conflicts with the system: "
f"{values} component's system_uuid={system_uuid} system={self.uuid}"
)
raise ISConflictingSystem(msg)
actual_component = component_type(**values)
self._components.add(actual_component, deserialization_in_progress=True)
return actual_component
Expand Down
10 changes: 0 additions & 10 deletions tests/models/simple_system.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
"""Defines models that can be used for testing the package."""

from typing import Any
from uuid import UUID

from infrasys.exceptions import ISOperationNotAllowed
from infrasys import Component, Location, System


Expand All @@ -13,14 +11,6 @@ class SimpleBus(Component):
voltage: float
coordinates: Location | None = None

def check_component_addition(self, system_uuid: UUID) -> None:
if self.coordinates is not None and not self.coordinates.is_attached(
system_uuid=system_uuid
):
# Other packages might want to auto-add in the System class.
msg = f"{self.label} has coordinates that are not attached to the system"
raise ISOperationNotAllowed(msg)

@classmethod
def example(cls) -> "SimpleBus":
return SimpleBus(
Expand Down
21 changes: 14 additions & 7 deletions tests/test_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,15 +428,25 @@ def test_copy_component(simple_system_with_time_series: SimpleSystem):
gen2 = system.copy_component(gen1)
assert gen2.uuid != gen1.uuid
assert gen2.name == gen1.name
assert gen2.system_uuid is None
assert gen2.bus is gen1.bus

gen3 = system.copy_component(gen1, name="gen3")
assert gen3.name == "gen3"
assert gen2.system_uuid is None

gen4 = system.copy_component(gen1, name="gen4", attach=True)
assert gen4.name == "gen4"
assert gen4.system_uuid == gen1.system_uuid


def test_deepcopy_component(simple_system_with_time_series: SimpleSystem):
system = simple_system_with_time_series
gen1 = system.get_component(SimpleGenerator, "test-gen")
subsystem = SimpleSubsystem(name="subsystem1", generators=[gen1])
system.add_component(subsystem)
gen2 = system.deepcopy_component(gen1)
assert gen2.name == gen1.name
assert gen2.uuid == gen1.uuid
assert gen2.bus.uuid == gen1.bus.uuid
assert gen2.bus is not gen1.bus


@pytest.mark.parametrize("in_memory", [True, False])
Expand All @@ -463,7 +473,6 @@ def test_remove_component(in_memory):

system.remove_component_by_uuid(gen2.uuid)
assert not system.has_time_series(gen2)
assert gen2.system_uuid is None

with pytest.raises(ISNotStored):
system.remove_component(gen2)
Expand Down Expand Up @@ -501,17 +510,15 @@ def test_system_to_dict():
assert len(component_dict) == 3 # 3 generators
assert component_dict[0]["bus"] == gen1.bus.label

exclude = {"system_uuid"}
variable_name = "active_power"
length = 8784
data = range(length)
start = datetime(year=2020, month=1, day=1)
resolution = timedelta(hours=1)
ts = SingleTimeSeries.from_array(data, variable_name, start, resolution)
system.add_time_series(ts, gen1)
component_dicts = list(system.to_records(SimpleGenerator, exclude=exclude))
component_dicts = list(system.to_records(SimpleGenerator))
assert len(component_dicts) == 3 # 3 generators
assert "system_uuid" not in component_dicts[0]


def test_time_series_metadata_sql():
Expand Down

0 comments on commit 5f7ab77

Please sign in to comment.