Logging the actions that a robot takes is critical for all live protocols. Logs are indispensable for diagnosing problems and for ensuring the validity of scientific data produced by automated experiments.
One run identifier, computed once and reused everywhere below — in the log lines, in the directory name, and in the manifest. Re-deriving it at each use site is a common way to end up with a manifest that disagrees with the directory it sits in.
15.1 Log at process, command, and firmware levels
A useful automation log often has three distinct levels:
Tier
Example logger
What it records
Process
protocol
steps, samples, run IDs, decisions
Command
pylabrobot
PLR frontend calls and arguments
Firmware / I/O
pylabrobot.io.*
messages exchanged with a physical device
PyLabRobot already logs frontend calls at DEBUG, while its I/O layer uses the lower LOG_LEVEL_IO level:
LOG_LEVEL_IO is a custom level below DEBUG, registered under the name IO. Setting a logger to DEBUG therefore excludes device traffic; you have to ask for level 5 to see it.
IO = 5 -> IO
DEBUG= 10
INFO = 20
A console can show only process-level information while a file records everything:
console = logging.StreamHandler()console.setLevel(logging.INFO)run_log = logging.FileHandler("run.log")run_log.setLevel(LOG_LEVEL_IO)run_log.setFormatter(logging.Formatter("%(asctime)s%(name)-24s%(levelname)-5s%(message)s"))protocol_log = logging.getLogger("protocol")protocol_log.setLevel(logging.INFO)ifnotany(isinstance(h, logging.Handler) for h in protocol_log.handlers): protocol_log.addHandler(console) protocol_log.addHandler(run_log)plr_log = logging.getLogger("pylabrobot")plr_log.setLevel(LOG_LEVEL_IO)plr_log.addHandler(run_log)
1
Console handler: the process tier only, so an operator sees steps rather than device traffic.
2
File handler: everything down to IO.
3
Your own logger. Any name works; a package-style name lets you filter by prefix later.
4
PLR’s logger. It sets propagate = False, so its records reach only handlers attached to it — attaching to the root logger does nothing.
Then protocol code can add the high-level story while PLR records the individual commands underneath it:
With physical hardware, the I/O logger can additionally capture the lower-level device traffic.
Inside an exception handler, logger.exception(...) includes the current traceback and is therefore especially useful around protocol or machine-step boundaries:
asyncdef failing_step():raiseRuntimeError("demo failure")try:await failing_step()exceptException: protocol_log.exception("plate run failed")
plate run failed
Traceback (most recent call last):
File "/tmp/ipykernel_3368/2717072940.py", line 5, in <module>
await failing_step()
File "/tmp/ipykernel_3368/2717072940.py", line 2, in failing_step
raise RuntimeError("demo failure")
RuntimeError: demo failure
logger.exception(msg) attaches the current traceback, and only works inside an except block — called elsewhere it records NoneType: None.
See also:pylabrobot.verbose(True) attaches a console handler to the pylabrobot logger, and configure() calls it automatically in a Jupyter kernel.
15.2 Organize data by experiment and run
It can be very helpful to configure a filesystem for organizing data collected across experiments so that each experiment’s data lives in an easily identifiable location. This is especially helpful for analyzing trends across experiments.
A simple convention is one timestamped directory per run:
run_dir = Path("experiments/camp_screen") /f"{run_id}_plate001"for subdir in ["inputs", "raw", "derived", "logs", "state"]: (run_dir / subdir).mkdir(parents=True, exist_ok=True)print(run_dir)print(sorted(p.name for p in run_dir.iterdir()))
1
A sortable timestamp plus something identifying, so directory listings order themselves and two runs started in the same second cannot collide. exist_ok=True below would otherwise merge them into one directory rather than complaining.
2
mkdir(parents=True, exist_ok=True) builds the whole chain in one call. This is os.makedirs(path, exist_ok=True) — the same call, reached through Path, which composes better with the reads and writes that follow.
manifest.json
what this run is
samples.sqlite
structured sample/process state
inputs/
worklists, sample maps, requested parameters
raw/
machine-produced data
derived/
outputs that can be regenerated from raw data
logs/
process and machine logs
state/
deck or process checkpoints
These two are PLR (chapter 11): the layout says what was on the deck, the state says what was in it. save_state_to_file mid-run is a checkpoint; load_state_from_file resumes from it.
Machine/site configuration used for a run can also be copied into the run directory, so an old run does not depend on whatever happens to be in today’s configuration file:
PyLabRobot’s own configuration is currently narrow and primarily concerned with logging; instrument addresses and other application-specific settings generally need to live in application configuration.
See also:pylabrobot.config configures logging only — the details are in chapter 12.
15.3 What to remember
Log different kinds of information at different levels: process decisions, PLR commands, and device I/O.
LOG_LEVEL_IO is a custom level below DEBUG — set a handler to it, not to DEBUG, to see device traffic.
logger.exception(...) records the traceback, but only inside an except block.
One timestamped directory per run keeps metadata, raw data, derived outputs, logs, and state together, so an old run does not depend on today’s files.
PLR state saves into the run’s state/ subdirectory for checkpoints and resumption.