13  Saving and loading

Saving the state of the deck to disk can be phenomenally useful for error recovery. PLR serializes two different things, through two different pairs of calls.

Layout is the resource tree: which labware exists, what type it is, and where it sits. save() writes it, load_from_json_file() reads it.

State is what is in that labware: volumes in wells, tips in spots. save_state_to_file() writes it, load_state_from_file() reads it.

Loading a layout gives you an empty deck of the right shape. The volumes come back only if you also load the state.

import json, pathlib

from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import LiquidHandlerChatterboxBackend
from pylabrobot.resources import (
    STARLetDeck, PLT_CAR_L5AC_A00, cor_96_wellplate_360uL_Fb,
    TIP_CAR_288_C00, opentrons_96_filtertiprack_200ul,
    Deck, Resource, set_volume_tracking, set_tip_tracking,
)

lh = LiquidHandler(backend=LiquidHandlerChatterboxBackend(), deck=STARLetDeck())
await lh.setup()

carrier = PLT_CAR_L5AC_A00(name="carrier")
lh.deck.assign_child_resource(carrier, rails=10)
carrier[0] = plate = cor_96_wellplate_360uL_Fb(name="plate")

tips = TIP_CAR_288_C00(name="tip_carrier")
lh.deck.assign_child_resource(tips, rails=2)
tips[0] = rack = opentrons_96_filtertiprack_200ul(name="rack")

set_volume_tracking(True)
set_tip_tracking(True)

plate["A1"][0].set_volume(150)
plate["B1"][0].set_volume(75)
1
Two wells with known contents, so there is something for the state file to carry.

13.1 Save a deck layout

Write the deck to a file, and rebuild it later without re-running the assignment code.

layout = pathlib.Path("layout.json")
lh.deck.save(str(layout), indent=2)

raw = json.loads(layout.read_text())
print("type:    ", raw["type"])
print("children:", [c["name"] for c in raw["children"]])
print("bytes:   ", layout.stat().st_size)
1
save() is on Resource, so any resource can be written, not just a deck. indent is passed through to json.dump.
2
The type field holds the class name, which is what reconstruction looks up.
type:     HamiltonSTARDeck
children: ['trash_core96', 'waste_block', 'trash', 'carrier', 'tip_carrier']
bytes:    289643
restored = Deck.load_from_json_file(str(layout))
print("class:    ", type(restored).__name__)
print("resources:", len(restored.get_all_resources()))
print("plate found:", restored.get_resource("plate").name)
1
A classmethod: it builds a new object rather than mutating an existing deck.
2026-08-27 22:14:06,915 - pylabrobot - WARNING - Resource 'rack' is very high on the deck: 279.19 mm. Be careful when traversing the deck.
class:     HamiltonSTARDeck
resources: 217
plate found: plate

Loading re-runs assign_child_resource for every resource in the file, so any warning the deck emits at assignment time is emitted again — here, the tall-tip-rack traverse warning that the setup cell already produced once.

The file is large.

A STARlet deck with one plate and one tip rack serializes to roughly 300 KB, because every well and every tip spot is a resource with its own entry. This is a description of the deck, not a diff.

See also: the visualizer in chapter 1 renders from this same serialize() output.


13.2 Save and restore contents

Record what is in the wells, and put it back on a deck that has already been rebuilt.

state = pathlib.Path("state.json")
lh.deck.save_state_to_file(str(state), indent=2)

st = json.loads(state.read_text())
print("entries:", len(st))
print("A1:", st["plate_well_A1"]["volume"])
print("B1:", st["plate_well_B1"]["volume"])
print("bytes:  ", state.stat().st_size)
1
Writes the state of this resource and everything below it.
2
A flat dict keyed by resource name — not nested like the layout. Every stateful resource on the deck gets an entry, which is why there are hundreds.
entries: 218
A1: 150
B1: 75
bytes:   94562

The single-resource forms:

print("one well: ", plate["A1"][0].serialize_state())
print("all state:", len(lh.deck.serialize_all_state()), "entries")
1
serialize_state() returns this resource’s own state as a dict.
2
serialize_all_state() returns the flat name → state mapping for the whole subtree. This is what save_state_to_file writes.
one well:  {'rotation': {'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation'}, 'volume': 150, 'pending_volume': 150, 'thing': 'well_0_0_volume_tracker', 'max_volume': 360}
all state: 218 entries
WarningGotcha: state is keyed by name, so names are the contract

load_state matches entries to resources by name. Rename a plate between the save and the load and its wells no longer match, so the volumes are silently not restored — there is no error, because a tree that lacks a key simply has nothing applied to it.

See also: set_volume() in chapter 5 writes the same tracker directly, without a file.


13.3 Why layout and state are two files

See what each file restores, by loading them one at a time.

fresh = Deck.load_from_json_file(str(layout))
fresh_plate = fresh.get_resource("plate")
print("after layout only:", fresh_plate["A1"][0].tracker.get_used_volume())

fresh.load_state_from_file(str(state))
print("after state too:  ", fresh_plate["A1"][0].tracker.get_used_volume())
print("and B1:           ", fresh_plate["B1"][0].tracker.get_used_volume())
1
The layout rebuilds the plate, the wells, and their positions — with empty trackers.
2
The state fills the trackers in.
2026-08-27 22:14:07,023 - pylabrobot - WARNING - Resource 'rack' is very high on the deck: 279.19 mm. Be careful when traversing the deck.
after layout only: 0
after state too:   150
and B1:            75

Which file changes when.

The layout changes when you assign, unassign, or move labware. The state changes on every aspirate, dispense, pick-up, and drop. Saving state mid-run is cheap relative to the layout: about 100 KB against 300 KB for the deck above, and the layout is usually identical between runs.

See also: telling PLR about an off-deck move is a layout change (chapter 9); telling it a trough was filled by hand is a state change.


13.4 Load a layout that uses classes you defined

Reconstruct a deck whose type fields name classes that are not part of PLR.

from pylabrobot.utils.object_parsing import find_subclass

print("Plate:        ", find_subclass("Plate", Resource))
print("NotAnyClass:  ", find_subclass("NotAnyClass", Resource))
1
Reconstruction resolves a type string to a class with find_subclass.
2
An unknown name resolves to None, and deserialize turns that into a ValueError.
Plate:         <class 'pylabrobot.resources.plate.Plate'>
NotAnyClass:   None
broken = json.loads(layout.read_text())
broken["children"][0]["type"] = "MyCustomCarrier"
broken_file = pathlib.Path("broken.json")
broken_file.write_text(json.dumps(broken))

try:
    Deck.load_from_json_file(str(broken_file))
except ValueError as e:
    print(f"{type(e).__name__}: {e}")
1
Standing in for a layout saved by code that defined its own carrier class.
ValueError: Could not find subclass with name MyCustomCarrier
WarningGotcha: import your own classes before loading

For labware you define yourself (chapter 17), the class must be imported into the loading process before load_from_json_file runs, even though the file names it. The import is what registers it.

PLR’s own definitions are exempt in practice because pylabrobot.resources.__init__ imports every vendor package, so the classes are registered as soon as you import anything from resources.

Python you need here

SomeClass.__subclasses__() returns direct subclasses only, which is why find_subclass recurses. The list is populated as a side effect of class creation, so it reflects what has been imported — not what exists on disk.

See also: deserialize(..., allow_marshal=True) additionally reconstructs functions from the file. It is off by default because it executes data from the file as code.


13.5 What to remember

  • Layout = save() / load_from_json_file(): the tree, types, and positions. State = save_state_to_file() / load_state_from_file(): volumes and tips.
  • Loading a layout gives empty trackers. Load state to fill them.
  • The layout is nested and keyed by structure; the state is flat and keyed by resource name. A renamed resource silently gets no state back.
  • serialize_state() is one resource, serialize_all_state() is the subtree.
  • Reconstruction resolves type through find_subclass, which only sees imported classes — import your own before loading, or get ValueError: Could not find subclass with name ....