diff --git a/pylabrobot/centrifuge/centrifuge.py b/pylabrobot/centrifuge/centrifuge.py index 2476882e5ad..b1b50ff457e 100644 --- a/pylabrobot/centrifuge/centrifuge.py +++ b/pylabrobot/centrifuge/centrifuge.py @@ -135,17 +135,22 @@ def serialize(self) -> dict: @classmethod def deserialize(cls, data: dict, allow_marshal: bool = False): backend = CentrifugeBackend.deserialize(data["backend"]) - buckets = tuple(ResourceHolder.deserialize(bucket) for bucket in data["buckets"]) - assert len(buckets) == 2 + buckets_data = data.get("buckets") + buckets = ( + tuple(ResourceHolder.deserialize(bucket) for bucket in buckets_data) if buckets_data else None + ) + if buckets is not None: + assert len(buckets) == 2 + rotation_data = data.get("rotation") return cls( backend=backend, name=data["name"], size_x=data["size_x"], size_y=data["size_y"], size_z=data["size_z"], - rotation=Rotation.deserialize(data["rotation"]), - category=data["category"], - model=data["model"], + rotation=Rotation.deserialize(rotation_data) if rotation_data else None, + category=data.get("category", "centrifuge"), + model=data.get("model"), buckets=buckets, ) @@ -228,15 +233,19 @@ def serialize(self) -> dict: @classmethod def deserialize(cls, data: dict, allow_marshal: bool = False): + resource_data = data.get("resource", {}) + machine_data = data.get("machine", {}) return cls( - backend=LoaderBackend.deserialize(data["machine"]["backend"]), + backend=LoaderBackend.deserialize(machine_data["backend"]), centrifuge=Centrifuge.deserialize(data["centrifuge"]), - name=data["resource"]["name"], - size_x=data["resource"]["size_x"], - size_y=data["resource"]["size_y"], - size_z=data["resource"]["size_z"], - child_location=deserialize(data["resource"]["child_location"]), - rotation=deserialize(data["resource"]["rotation"]), - category=data["resource"]["category"], - model=data["resource"]["model"], + name=resource_data["name"], + size_x=resource_data["size_x"], + size_y=resource_data["size_y"], + size_z=resource_data["size_z"], + child_location=deserialize(resource_data["child_location"]), + rotation=deserialize(resource_data.get("rotation")) + if resource_data.get("rotation") + else None, + category=resource_data.get("category", "loader"), + model=resource_data.get("model"), ) diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index 327d446a184..a0ec0223d04 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -345,15 +345,15 @@ def serialize(self): @classmethod def deserialize(cls, data: dict) -> "Serial": return cls( - port=data["port"], - baudrate=data["baudrate"], - bytesize=data["bytesize"], - parity=data["parity"], - stopbits=data["stopbits"], - write_timeout=data["write_timeout"], - timeout=data["timeout"], - rtscts=data["rtscts"], - dsrdtr=data["dsrdtr"], + port=data.get("port"), + baudrate=data.get("baudrate", 9600), + bytesize=data.get("bytesize", 8), + parity=data.get("parity", "N"), + stopbits=data.get("stopbits", 1), + write_timeout=data.get("write_timeout", 1), + timeout=data.get("timeout", 1), + rtscts=data.get("rtscts", False), + dsrdtr=data.get("dsrdtr", False), ) diff --git a/pylabrobot/io/socket.py b/pylabrobot/io/socket.py index 9095b952cec..8698205ce65 100644 --- a/pylabrobot/io/socket.py +++ b/pylabrobot/io/socket.py @@ -98,15 +98,11 @@ def serialize(self): @classmethod def deserialize(cls, data: dict) -> "Socket": - kwargs = {} - if "read_timeout" in data: - kwargs["read_timeout"] = data["read_timeout"] - if "write_timeout" in data: - kwargs["write_timeout"] = data["write_timeout"] return cls( host=data["host"], port=data["port"], - **kwargs, + read_timeout=data.get("read_timeout", 30), + write_timeout=data.get("write_timeout", 30), ) async def write(self, data: bytes, timeout: Optional[float] = None) -> None: diff --git a/pylabrobot/pumps/calibration.py b/pylabrobot/pumps/calibration.py index 33d5b86820a..64a1c64162c 100644 --- a/pylabrobot/pumps/calibration.py +++ b/pylabrobot/pumps/calibration.py @@ -103,7 +103,7 @@ def serialize(self) -> dict: def deserialize(cls, data: dict) -> PumpCalibration: return cls( calibration=data["calibration"], - calibration_mode=data["calibration_mode"], + calibration_mode=data.get("calibration_mode", "duration"), ) @classmethod diff --git a/pylabrobot/resources/hamilton/tip_creators.py b/pylabrobot/resources/hamilton/tip_creators.py index 042052099a6..979358a8a7c 100644 --- a/pylabrobot/resources/hamilton/tip_creators.py +++ b/pylabrobot/resources/hamilton/tip_creators.py @@ -100,12 +100,12 @@ def serialize(self): @classmethod def deserialize(cls, data): return HamiltonTip( - name=data["name"], + name=data.get("name"), has_filter=data["has_filter"], total_tip_length=data["total_tip_length"], maximal_volume=data["maximal_volume"], tip_size=TipSize[data["tip_size"]], - pickup_method=TipPickupMethod[data["pickup_method"]], + pickup_method=TipPickupMethod[data.get("pickup_method", "OUT_OF_RACK")], ) diff --git a/pylabrobot/resources/plate_adapter.py b/pylabrobot/resources/plate_adapter.py index 164d46d5318..ef83c61de22 100644 --- a/pylabrobot/resources/plate_adapter.py +++ b/pylabrobot/resources/plate_adapter.py @@ -129,9 +129,9 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> PlateAdapter: dz=data["dz"], adapter_hole_size_x=data["adapter_hole_size_x"], adapter_hole_size_y=data["adapter_hole_size_y"], - adapter_hole_dx=data["adapter_hole_dx"], - adapter_hole_dy=data["adapter_hole_dy"], - plate_z_offset=data["plate_z_offset"], + adapter_hole_dx=data.get("adapter_hole_dx", 9.0), + adapter_hole_dy=data.get("adapter_hole_dy", 9.0), + plate_z_offset=data.get("plate_z_offset", 0.0), category=data.get("category"), model=data.get("model"), ) diff --git a/pylabrobot/resources/resource.py b/pylabrobot/resources/resource.py index 29986d97651..4ced1a65aff 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -6,7 +6,7 @@ import sys from typing import Any, Callable, Dict, List, Optional, Union, cast -from pylabrobot.serializer import deserialize, serialize +from pylabrobot.serializer import compact, deserialize, serialize from pylabrobot.utils.linalg import matrix_vector_multiply_3x3 from pylabrobot.utils.object_parsing import find_subclass @@ -717,7 +717,7 @@ def save(self, fn: str, indent: Optional[int] = None): >>> deck.save("my_layout.json") """ - serialized = self.serialize() + serialized = compact(self.serialize()) with open(fn, "w", encoding="utf-8") as f: json.dump(serialized, f, indent=indent) @@ -750,13 +750,14 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> Self: "parent_name", "location", ]: # delete meta keys - del data_copy[key] - children_data = data_copy.pop("children") - rotation = data_copy.pop("rotation") + data_copy.pop(key, None) + children_data = data_copy.pop("children", []) + rotation = data_copy.pop("rotation", None) barcode = data_copy.pop("barcode", None) preferred_pickup_location = data_copy.pop("preferred_pickup_location", None) resource = subclass(**deserialize(data_copy, allow_marshal=allow_marshal)) - resource.rotation = Rotation.deserialize(rotation) # not pretty, should be done in init. + if rotation is not None: + resource.rotation = Rotation.deserialize(rotation) # not pretty, should be done in init. if barcode is not None: resource.barcode = Barcode.deserialize(barcode) if preferred_pickup_location is not None: diff --git a/pylabrobot/resources/rotation.py b/pylabrobot/resources/rotation.py index e242c0fa805..301ceb7d3ec 100644 --- a/pylabrobot/resources/rotation.py +++ b/pylabrobot/resources/rotation.py @@ -64,7 +64,7 @@ def __add__(self, other) -> "Rotation": @staticmethod def deserialize(data) -> "Rotation": - return Rotation(data["x"], data["y"], data["z"]) + return Rotation(data.get("x", 0), data.get("y", 0), data.get("z", 0)) def __repr__(self) -> str: return self.__str__() diff --git a/pylabrobot/resources/tip_rack.py b/pylabrobot/resources/tip_rack.py index 1d89293aabc..97da89c417c 100644 --- a/pylabrobot/resources/tip_rack.py +++ b/pylabrobot/resources/tip_rack.py @@ -122,7 +122,7 @@ def make_tip(name: str) -> Tip: name=data["name"], size_x=data["size_x"], size_y=data["size_y"], - size_z=data["size_z"], + size_z=data.get("size_z", 0), make_tip=make_tip, category=data.get("category", "tip_spot"), ) diff --git a/pylabrobot/serializer.py b/pylabrobot/serializer.py index dff6442230d..5e2a75bf3d2 100644 --- a/pylabrobot/serializer.py +++ b/pylabrobot/serializer.py @@ -6,7 +6,7 @@ import math import sys import types -from typing import Any, Dict, List, Union, cast +from typing import Any, Dict, List, Optional, Union, cast if sys.version_info >= (3, 10): from typing import TypeAlias @@ -145,8 +145,161 @@ def deserialize(data: JSON, allow_marshal: bool = False) -> Any: params = {k: deserialize(v, allow_marshal=allow_marshal) for k, v in data.items()} if "deserialize" in klass.__dict__: return klass.deserialize(params) + params = _fill_defaults(klass, params) return klass(**params) return {k: deserialize(v, allow_marshal=allow_marshal) for k, v in data.items()} if isinstance(data, object): return data raise TypeError(f"Cannot deserialize {data} of type {type(data)}") + + +def apply_merge_patch(target: JSON, patch: JSON) -> JSON: + """Apply a JSON Merge Patch (RFC 7386) to a target document. + + Rules: + - If patch is not a dict, it replaces the target entirely. + - If a patch value is None (JSON null), the key is removed from target. + - If a patch value is a dict, recurse. + - Otherwise, replace the key. + """ + if not isinstance(patch, dict): + return patch + + if not isinstance(target, dict): + target = {} + + result = dict(target) + for key, value in patch.items(): + if value is None: + result.pop(key, None) + else: + result[key] = apply_merge_patch(result.get(key), value) + return result + + +def create_merge_patch(source: JSON, target: JSON) -> Optional[JSON]: + """Create a JSON Merge Patch (RFC 7386) that transforms source into target. + + Returns None if source and target are equal (no patch needed). + """ + if isinstance(source, dict) and isinstance(target, dict): + patch: Dict[str, JSON] = {} + all_keys = set(source.keys()) | set(target.keys()) + for key in all_keys: + if key not in target: + patch[key] = None # remove + elif key not in source: + patch[key] = target[key] # add + else: + sub_patch = create_merge_patch(source[key], target[key]) + if sub_patch is not None: + patch[key] = sub_patch + return patch if patch else None + + if source == target: + # Handle NaN: NaN != NaN, but we treat string "nan" as equal + if isinstance(source, float) and isinstance(target, float): + if math.isnan(source) and math.isnan(target): + return None + else: + return None + return target + + +def compact(data: JSON) -> JSON: + """Strip fields from a serialized dict tree that match constructor defaults. + + For every dict with a ``"type"`` field, looks up the class via + :func:`get_plr_class_from_string`, inspects ``__init__`` defaults, serializes + each default, and omits fields where the serialized value matches. + + Meta keys (``type``, ``location``, ``parent_name``) are always kept. + ``children`` is kept only when non-empty. + """ + if isinstance(data, list): + return [compact(item) for item in data] + if not isinstance(data, dict): + return data + + if "type" not in data: + return {k: compact(v) for k, v in data.items()} + + type_name = data["type"] + + # Try to look up the class; if not found, just recurse children + try: + klass = get_plr_class_from_string(type_name) + except ValueError: + return {k: compact(v) for k, v in data.items()} + + # Get constructor defaults + defaults = _get_init_defaults(klass) + + meta_keys = {"type", "location", "parent_name", "name"} + result: Dict[str, JSON] = {} + + for key, value in data.items(): + # Always keep meta keys + if key in meta_keys: + result[key] = compact(value) if isinstance(value, (dict, list)) else value + continue + + # children: keep only when non-empty + if key == "children": + if isinstance(value, list) and len(value) > 0: + result[key] = compact(value) + continue + + # If we have a default for this key and it matches, skip it + if key in defaults: + default_serialized = serialize(defaults[key]) + if _json_equal(value, default_serialized): + continue + + # Recurse into nested dicts/lists + result[key] = compact(value) + + return result + + +def _get_init_defaults(klass: type) -> Dict[str, Any]: + """Get the default values for a class's __init__ parameters.""" + try: + sig = inspect.signature(klass.__init__) + except (ValueError, TypeError): + return {} + + defaults: Dict[str, Any] = {} + for name, param in sig.parameters.items(): + if name == "self": + continue + if param.default is not inspect.Parameter.empty: + defaults[name] = param.default + return defaults + + +def _fill_defaults(klass: type, params: Dict[str, Any]) -> Dict[str, Any]: + """Fill in missing params from constructor defaults.""" + defaults = _get_init_defaults(klass) + for key, default_value in defaults.items(): + if key not in params: + params[key] = default_value + return params + + +def _json_equal(a: JSON, b: JSON) -> bool: + """Compare two JSON values for equality, handling NaN.""" + if isinstance(a, float) and isinstance(b, float): + if math.isnan(a) and math.isnan(b): + return True + if type(a) != type(b): + return False + if isinstance(a, dict) and isinstance(b, dict): + if set(a.keys()) != set(b.keys()): + return False + return all(_json_equal(a[k], b[k]) for k in a) + if isinstance(a, list) and isinstance(b, list): + if len(a) != len(b): + return False + return all(_json_equal(x, y) for x, y in zip(a, b)) + return a == b diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 6ed68482173..7aec6f7f156 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -194,7 +194,9 @@ def serialize(self): @classmethod def deserialize(cls, data: dict, allow_marshal: bool = False): + data = data.copy() backend = IncubatorBackend.deserialize(data.pop("backend")) + rotation_data = data.get("rotation") return cls( backend=backend, name=data["name"], @@ -203,7 +205,7 @@ def deserialize(cls, data: dict, allow_marshal: bool = False): size_z=data["size_z"], racks=[PlateCarrier.deserialize(rack) for rack in data["racks"]], loading_tray_location=cast(Coordinate, deserialize(data["loading_tray_location"])), - rotation=Rotation.deserialize(data["rotation"]), - category=data["category"], - model=data["model"], + rotation=Rotation.deserialize(rotation_data) if rotation_data else None, + category=data.get("category"), + model=data.get("model"), ) diff --git a/pylabrobot/tests/serializer_tests.py b/pylabrobot/tests/serializer_tests.py index 356ae12cafd..10a380aff71 100644 --- a/pylabrobot/tests/serializer_tests.py +++ b/pylabrobot/tests/serializer_tests.py @@ -1,6 +1,12 @@ import math -from pylabrobot.serializer import deserialize, serialize +from pylabrobot.serializer import ( + apply_merge_patch, + compact, + create_merge_patch, + deserialize, + serialize, +) def test_serialize_deserialize_closure(): @@ -71,3 +77,288 @@ def make_tip(name): result = deserialize(data) assert isinstance(result, TipSpot) assert result.name == "A1" + + +# ---- RFC 7386 JSON Merge Patch tests ---- + + +class TestApplyMergePatch: + """Test RFC 7386 apply_merge_patch.""" + + def test_scalar_replace(self): + assert apply_merge_patch({"a": 1}, {"a": 2}) == {"a": 2} + + def test_null_removes_key(self): + assert apply_merge_patch({"a": 1, "b": 2}, {"a": None}) == {"b": 2} + + def test_add_new_key(self): + assert apply_merge_patch({"a": 1}, {"b": 2}) == {"a": 1, "b": 2} + + def test_nested_merge(self): + target = {"a": {"b": 1, "c": 2}} + patch = {"a": {"b": 3}} + assert apply_merge_patch(target, patch) == {"a": {"b": 3, "c": 2}} + + def test_nested_null_removes(self): + target = {"a": {"b": 1, "c": 2}} + patch = {"a": {"c": None}} + assert apply_merge_patch(target, patch) == {"a": {"b": 1}} + + def test_non_dict_patch_replaces(self): + assert apply_merge_patch({"a": 1}, "hello") == "hello" + + def test_non_dict_target_becomes_dict(self): + assert apply_merge_patch("hello", {"a": 1}) == {"a": 1} + + def test_rfc7386_test_vectors(self): + """Test cases from RFC 7386 Section 3.""" + assert apply_merge_patch({"a": "b"}, {"a": "c"}) == {"a": "c"} + assert apply_merge_patch({"a": "b"}, {"b": "c"}) == {"a": "b", "b": "c"} + assert apply_merge_patch({"a": "b"}, {"a": None}) == {} + assert apply_merge_patch({"a": "b", "b": "c"}, {"a": None}) == {"b": "c"} + assert apply_merge_patch({"a": ["b"]}, {"a": "c"}) == {"a": "c"} + assert apply_merge_patch({"a": "c"}, {"a": ["b"]}) == {"a": ["b"]} + assert apply_merge_patch({"a": {"b": "c"}}, {"a": {"b": "d", "c": None}}) == {"a": {"b": "d"}} + assert apply_merge_patch({"a": [{"b": "c"}]}, {"a": [1]}) == {"a": [1]} + assert apply_merge_patch(["a", "b"], {"a": "c"}) == {"a": "c"} # array replaced by object + assert apply_merge_patch({}, {"a": {"bb": {"ccc": None}}}) == {"a": {"bb": {}}} + + def test_empty_patch(self): + assert apply_merge_patch({"a": 1}, {}) == {"a": 1} + + +class TestCreateMergePatch: + """Test create_merge_patch.""" + + def test_equal_dicts(self): + assert create_merge_patch({"a": 1}, {"a": 1}) is None + + def test_changed_value(self): + assert create_merge_patch({"a": 1}, {"a": 2}) == {"a": 2} + + def test_removed_key(self): + assert create_merge_patch({"a": 1, "b": 2}, {"a": 1}) == {"b": None} + + def test_added_key(self): + assert create_merge_patch({"a": 1}, {"a": 1, "b": 2}) == {"b": 2} + + def test_nested_change(self): + source = {"a": {"b": 1, "c": 2}} + target = {"a": {"b": 3, "c": 2}} + assert create_merge_patch(source, target) == {"a": {"b": 3}} + + def test_roundtrip(self): + source = {"a": 1, "b": {"c": 3, "d": 4}, "e": [1, 2]} + target = {"a": 2, "b": {"c": 3}, "f": 6} + patch = create_merge_patch(source, target) + assert patch is not None + result = apply_merge_patch(source, patch) + assert result == target + + def test_non_dict_values(self): + assert create_merge_patch("a", "b") == "b" + assert create_merge_patch("a", "a") is None + assert create_merge_patch(1, 2) == 2 + assert create_merge_patch(1, 1) is None + + +class TestCompact: + """Test compact() strips default values.""" + + def test_strips_none_defaults(self): + """Resource fields like category=None, model=None should be stripped.""" + from pylabrobot.resources import Resource + + r = Resource(name="test", size_x=10, size_y=20, size_z=30) + data = r.serialize() + compacted = compact(data) + + # None-default fields should be stripped + assert "category" not in compacted + assert "model" not in compacted + assert "barcode" not in compacted + assert "preferred_pickup_location" not in compacted + + # Required fields should be kept + assert compacted["name"] == "test" + assert compacted["size_x"] == 10 + assert compacted["size_y"] == 20 + assert compacted["size_z"] == 30 + assert compacted["type"] == "Resource" + + def test_keeps_non_default_values(self): + """Non-default values should be kept.""" + from pylabrobot.resources import Resource + + r = Resource(name="test", size_x=10, size_y=20, size_z=30, category="plate", model="xyz") + data = r.serialize() + compacted = compact(data) + + assert compacted["category"] == "plate" + assert compacted["model"] == "xyz" + + def test_strips_empty_children(self): + """Empty children list should be stripped.""" + from pylabrobot.resources import Resource + + r = Resource(name="test", size_x=10, size_y=20, size_z=30) + data = r.serialize() + compacted = compact(data) + + assert "children" not in compacted + + def test_keeps_nonempty_children(self): + """Non-empty children list should be kept.""" + from pylabrobot.resources import Resource + from pylabrobot.resources.coordinate import Coordinate + + parent = Resource(name="parent", size_x=100, size_y=100, size_z=100) + child = Resource(name="child", size_x=10, size_y=10, size_z=10) + parent.assign_child_resource(child, location=Coordinate(0, 0, 0)) + + data = parent.serialize() + compacted = compact(data) + + assert "children" in compacted + assert len(compacted["children"]) == 1 + + def test_default_rotation_compacted(self): + """Default rotation (0,0,0) should have its default fields stripped. + + The rotation field itself is kept because the Resource __init__ default is None, + but the Rotation's own x=0, y=0, z=0 defaults are stripped. + """ + from pylabrobot.resources import Resource + + r = Resource(name="test", size_x=10, size_y=20, size_z=30) + data = r.serialize() + compacted = compact(data) + + # rotation is kept (because Resource default is None, not Rotation(0,0,0)) + # but Rotation(0,0,0) fields are compacted (x,y,z stripped since they match defaults) + assert "rotation" in compacted + assert compacted["rotation"] == {"type": "Rotation"} + + def test_keeps_non_default_rotation(self): + """Non-default rotation should be kept.""" + from pylabrobot.resources import Resource + from pylabrobot.resources.rotation import Rotation + + r = Resource(name="test", size_x=10, size_y=20, size_z=30, rotation=Rotation(z=90)) + data = r.serialize() + compacted = compact(data) + + assert "rotation" in compacted + + def test_recursive_compaction(self): + """Compact should recurse into children.""" + from pylabrobot.resources import Resource + from pylabrobot.resources.coordinate import Coordinate + + parent = Resource(name="parent", size_x=100, size_y=100, size_z=100) + child = Resource(name="child", size_x=10, size_y=10, size_z=10) + parent.assign_child_resource(child, location=Coordinate(0, 0, 0)) + + data = parent.serialize() + compacted = compact(data) + + child_data = compacted["children"][0] + assert "category" not in child_data # None default stripped + assert "model" not in child_data # None default stripped + + def test_unknown_type_passes_through(self): + """Types not found in PLR should pass through unchanged.""" + data = {"type": "UnknownType12345", "foo": "bar", "children": []} + compacted = compact(data) + assert compacted == {"type": "UnknownType12345", "foo": "bar", "children": []} + + +class TestCompactDeserializeRoundTrip: + """Test that compact JSON round-trips through deserialization.""" + + def test_resource_roundtrip(self): + """serialize -> compact -> deserialize should produce an equal resource.""" + from pylabrobot.resources import Resource + + r = Resource(name="test", size_x=10, size_y=20, size_z=30) + data = r.serialize() + compacted = compact(data) + restored = Resource.deserialize(compacted) + + assert restored.name == r.name + assert restored._size_x == r._size_x + assert restored._size_y == r._size_y + assert restored._size_z == r._size_z + assert restored.category == r.category + assert restored.model == r.model + + def test_resource_with_children_roundtrip(self): + """Resources with children should round-trip through compact.""" + from pylabrobot.resources import Resource + from pylabrobot.resources.coordinate import Coordinate + + parent = Resource(name="parent", size_x=100, size_y=100, size_z=100) + child = Resource(name="child", size_x=10, size_y=10, size_z=10) + parent.assign_child_resource(child, location=Coordinate(5, 5, 0)) + + data = parent.serialize() + compacted = compact(data) + restored = Resource.deserialize(compacted) + + assert restored.name == "parent" + assert len(restored.children) == 1 + assert restored.children[0].name == "child" + + def test_old_full_format_still_works(self): + """Old full-format JSON (with all fields) should still deserialize.""" + from pylabrobot.resources import Resource + + full_data = { + "name": "test", + "type": "Resource", + "size_x": 10, + "size_y": 20, + "size_z": 30, + "location": None, + "rotation": {"x": 0, "y": 0, "z": 0}, + "category": None, + "model": None, + "barcode": None, + "preferred_pickup_location": None, + "children": [], + "parent_name": None, + } + r = Resource.deserialize(full_data) + assert r.name == "test" + assert r._size_x == 10 + + def test_compact_format_works(self): + """Compact JSON (missing optional fields) should deserialize.""" + from pylabrobot.resources import Resource + + compact_data = { + "name": "test", + "type": "Resource", + "size_x": 10, + "size_y": 20, + "size_z": 30, + } + r = Resource.deserialize(compact_data) + assert r.name == "test" + assert r._size_x == 10 + assert r.category is None + assert r.model is None + assert r.barcode is None + assert len(r.children) == 0 + + def test_resource_with_rotation_roundtrip(self): + """Resource with non-default rotation round-trips.""" + from pylabrobot.resources import Resource + from pylabrobot.resources.rotation import Rotation + + r = Resource(name="rotated", size_x=10, size_y=20, size_z=30, rotation=Rotation(z=90)) + data = r.serialize() + compacted = compact(data) + restored = Resource.deserialize(compacted) + + assert restored.rotation.z == 90