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

CRITICAL

50

Python

Unrecoverable errors

ERROR

40

Python

Failures that stop an operation

WARNING

30

Python

Recoverable problems (timeouts, fallbacks)

INFO

20

Python

High-level actions: setup, teardown, measurement starts

DEBUG

10

Python

Internal state, retry logic, per-well progress

IO

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 INFO level. 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)

pylabrobot.verbose(True)

Console output (DEBUG)

pylabrobot.verbose(True, level=logging.DEBUG)

Console output (IO)

pylabrobot.verbose(True, level=LOG_LEVEL_IO)

Log to file

pylabrobot.setup_logger(log_dir="./logs", level=LOG_LEVEL_IO)

Filter one module

logging.getLogger("pylabrobot.module").setLevel(...)

Disable console

pylabrobot.verbose(False)