Setting Up Logging#
PyLabRobot uses standard Python logging. All loggers live under the "pylabrobot" name, so standard Python techniques for setting levels, adding handlers, and filtering by module all apply.
This also means PLR’s logging tools can serve as building blocks for audit trails and traceability (which are particularly important in regulated environments) - see Logging & Validation for an overview of these concepts.
Log levels#
Python’s five built-in levels apply as normal:
Level |
Value |
Defined by |
Typical content |
|---|---|---|---|
|
50 |
Python |
Unrecoverable errors |
|
40 |
Python |
Failures that stop an operation |
|
30 |
Python |
Recoverable problems (timeouts, fallbacks) |
|
20 |
Python |
High-level actions: setup, teardown, measurement starts |
|
10 |
Python |
Internal state, retry logic, per-well progress |
|
5 |
PyLabRobot |
Raw bytes sent to / received from hardware |
PyLabRobot adds one extra level: IO (value 5), which sits below DEBUG. It exists because hardware backends can generate a large volume of raw data frames sent to and received from hardware. Mixing those into DEBUG would make debug logs unreadable during normal development, so they are separated into their own level that you only enable when you are actively inspecting wire traffic executed by PyLabRobot.
Note
This is a device-agnostic feature of PyLabRobot: regardless of whether you are talking to a Hamilton STAR over USB, a plate reader over serial, or a temperature controller over TCP, all low-level communication is logged through the same IO level. This means you get a consistent way to inspect and record wire traffic across every backend, using the same verbose() call and the same log format. When something goes wrong at the hardware level, you don’t need device-specific debugging tools - just set the log level to IO and every byte sent and received will appear in your log.
Quick start#
Warning
Default logging behaviour:
Jupyter notebooks: console output (StreamHandler) at
INFOlevel. No file logging.Scripts: no logging enabled. No console output, no file logging. You must opt in.
In neither case does PLR write to a file by default - call setup_logger() to enable file logging (see details below).
import pylabrobot
# Enable console output at INFO level (the default)
pylabrobot.verbose(True)
To see DEBUG messages:
import logging
pylabrobot.verbose(True, level=logging.DEBUG)
To see everything including raw IO bytes:
from pylabrobot.io import LOG_LEVEL_IO
pylabrobot.verbose(True, level=LOG_LEVEL_IO)
To turn console output off again:
pylabrobot.verbose(False)
What the output looks like#
At INFO level you see high-level actions:
2026-03-04 17:07:58,102 - pylabrobot - INFO - Setting up the plate reader.
2026-03-04 17:07:59,003 - pylabrobot - INFO - Plate reader set up successfully.
2026-03-04 17:07:59,210 - pylabrobot - INFO - Starting fluorescence measurement.
At DEBUG you also see internal details:
2026-03-04 17:07:59,814 - pylabrobot - DEBUG - FL measurement progress: 5/96
2026-03-04 17:07:59,920 - pylabrobot - DEBUG - Status flags: busy=True, running=True, plate_detected=True
2026-03-04 17:08:00,015 - pylabrobot - DEBUG - Retry 1/3: waiting for device ready
At IO you additionally see every raw frame on the wire:
2026-03-04 17:07:59,211 - pylabrobot - IO - sent 47 bytes: 020027...0d
2026-03-04 17:07:59,318 - pylabrobot - IO - read 12 bytes: 02000a...0d
Each level includes all messages from the levels above it, so IO gives you everything.
Logging to a file#
Use setup_logger to write logs to disk. This is useful for long-running protocols where you want a persistent record.
from pylabrobot.io import LOG_LEVEL_IO
# File output at IO level (captures everything)
pylabrobot.setup_logger(log_dir="./logs", level=LOG_LEVEL_IO)
# Console output at INFO level
pylabrobot.verbose(True)
Warning
Default log file name
When given a directory, setup_logger automatically creates a date-stamped file like logs/pylabrobot-20260312.log. Since the filename is based on the date only, multiple runs on the same day will append to the same file.
To keep logs separate per run, pass a full file path ending in .log, .txt, or .jsonl.
How handlers work#
The code above creates two independent handlers on the "pylabrobot" logger:
Logger: "pylabrobot"
(level = IO, i.e. everything)
|
+-------------+-------------+
| |
StreamHandler FileHandler
(level = INFO) (level = IO)
| |
console logs/pylabrobot-
20260312.log
Each handler has its own level, so you can show INFO in the console while capturing IO-level detail to file. Every log message passes through the logger first. If the message meets the logger’s level, it is forwarded to each handler, which then applies its own level filter. This is standard Python logging behaviour - PLR just configures it for you.
Filtering by module#
Since PLR uses Python’s logger hierarchy, you can adjust the level for a specific backend without changing the global level:
import logging
# Only show warnings and above from the STAR backend
logging.getLogger("pylabrobot.liquid_handling.backends.hamilton.STAR").setLevel(logging.WARNING)
# Show debug output from the plate reader
logging.getLogger("pylabrobot.plate_reading").setLevel(logging.DEBUG)
Real-world example: protocol logging#
In practice you often want PLR’s hardware logs alongside your own protocol-level messages (e.g. which protocol is running, who started it, and why). You can combine setup_logger for PLR with standard Python loggers for your own code, all writing to the same file:
import pylabrobot
import logging
from datetime import datetime
from pylabrobot.io import LOG_LEVEL_IO
# 1. Let PLR handle its own file logging with a detailed timestamp
timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
log_file = f"./logs/{timestamp}_cell_viability_assay.log"
pylabrobot.setup_logger(log_dir=log_file, level=LOG_LEVEL_IO)
# 2. Create your own loggers for protocol-level events
logger_protocol = logging.getLogger("protocol")
logger_protocol.setLevel(logging.INFO)
# 3. Reuse PLR's handlers so everything goes to the same console and file
for handler in logging.getLogger("pylabrobot").handlers:
logger_protocol.addHandler(handler)
# 4. Log protocol metadata
logger_protocol.info("START AUTOMATED PROTOCOL")
logger_protocol.info("Protocol: cell_viability_assay")
logger_protocol.info("User: alice")
This gives you a single log file with both PLR hardware traces and your protocol events, without having to manually set up file handlers and formatters.
Summary#
Goal |
Code |
|---|---|
Console output (INFO) |
|
Console output (DEBUG) |
|
Console output (IO) |
|
Log to file |
|
Filter one module |
|
Disable console |
|