Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions docs/contributor_guide/event-schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,85 @@ record per channel with `channel` and direct `resource` fields.
| `liquid_handler.tip_pickup_96` | `device`, `resources` | Direct resource is the operated `TipRack`. |
| `liquid_handler.tip_drop_96` | `device`, `resources` | Direct resource is the destination `TipRack` or `Trash`. |

## Plate reading and imaging

### Plate-reader lifecycle and measurements

| Operation | Fields | Notes |
| --- | --- | --- |
| `plate_reader.open` | `device`, `resources` | Opens the reader. `resources` contains the directly loaded plate when one is assigned. |
| `plate_reader.close` | `device`, `resources` | Closes the reader. `resources` contains the directly loaded plate when one is assigned. |
| `plate_reader.read_luminescence` | `device`, `resources`, `well_count`, `return_format`, `focal_height`; **completed only:** `record_count` | Reads luminescence from the selected wells. |
| `plate_reader.read_absorbance` | `device`, `resources`, `well_count`, `return_format`, `wavelength_nm`; **completed only:** `record_count` | Reads absorbance at the requested wavelength. |
| `plate_reader.read_fluorescence` | `device`, `resources`, `well_count`, `return_format`, `excitation_wavelength_nm`, `emission_wavelength_nm`, `focal_height`; **completed only:** `record_count` | Reads fluorescence at the requested wavelengths. |

For measurement operations, `resources` contains the direct selected `Well` references. Their
`ancestors` retain the owning plate; do not replace the wells with that plate. `well_count` is the
number of selected wells, while `record_count` is the number of records returned by the backend.
`return_format` is `"records"` or `"legacy_matrix"` and describes the public return projection.
Measurement values are not copied into events.

`focal_height` uses PLR's default length unit (millimeters). Wavelengths are deliberately expressed
in nanometers and therefore use the `_nm` suffix.

### Imaging

| Operation | Fields | Notes |
| --- | --- | --- |
| `imager.capture` | `device`, `resources`, optional `plate`, `target`, `mode`, `objective`, `exposure`, `focus`, `gain`; **completed only:** `image_count`, `reported_exposure_time_ms`, `reported_focal_height` | Captures one user-requested imaging result. Software auto-exposure or autofocus retries remain inside this single lifecycle. |

`target` contains integer `row` and `column` indices. `mode` and `objective` are stable enum member
names. When the caller supplies a `Well`, `resources` contains that direct well reference; a
row/column tuple has no direct resource and uses an empty list. `plate`, when known, identifies the
loaded plate that provides target context.

The three requested setting objects are JSON-ready and use these shapes:

| Setting | Modes and fields |
| --- | --- |
| `exposure` | Fixed: `mode="fixed"`, `time_ms`; machine auto: `mode="machine_auto"`; software auto: `mode="software_auto"`, `minimum_time_ms`, `maximum_time_ms`, optional `max_rounds`. |
| `focus` | Fixed: `mode="fixed"`, `height`; machine auto: `mode="machine_auto"`; software auto: `mode="software_auto"`, `minimum_height`, `maximum_height`, `tolerance`, `timeout`. |
| `gain` | Fixed: `mode="fixed"`, `value`; machine auto: `mode="machine_auto"`. |

Exposure values use milliseconds, as made explicit by `_ms`. Focus heights and focus tolerance use
PLR's default length unit; autofocus `timeout` uses the default time unit. The completed event
contains only bounded result metadata. Pixel arrays and other image data are never included.

`ImageReader` inherits the PlateReader and Imager public operations. It emits the inherited
canonical lifecycle directly and must not add a second wrapper lifecycle.

Backend-only keyword arguments are forwarded to the backend but are not part of these canonical
payloads.

## Thermocycling

Controllers that are `ResourceHolder`s include their directly loaded resource in `resources` when
one is assigned at operation start.

| Operation | Fields | Notes |
| --- | --- | --- |
| `thermocycler.open_lid` | `device`, optional `resources` | Opens the thermocycler lid. |
| `thermocycler.close_lid` | `device`, optional `resources` | Closes the thermocycler lid. |
| `thermocycler.set_block_temperature` | `device`, optional `resources`, `target_temperatures` | Sets one temperature per block zone. |
| `thermocycler.set_lid_temperature` | `device`, optional `resources`, `target_temperatures` | Sets one temperature per lid zone. |
| `thermocycler.deactivate_block` | `device`, optional `resources` | Turns off block temperature control. |
| `thermocycler.deactivate_lid` | `device`, optional `resources` | Turns off lid temperature control. |
| `thermocycler.run_protocol` | `device`, optional `resources`, `block_max_volume`, `stage_count`, `step_definition_count`, `step_execution_count`, optional `temperature_zone_count` | Submits a bounded summary of the requested protocol. Completion means the backend coroutine returned successfully, not that the physical temperature profile finished. |

`target_temperatures` is an ordered list in PLR's default temperature unit (degrees Celsius); it is
plural because the public API supports multiple thermal zones. `block_max_volume` uses PLR's
default volume unit (microliters). `step_definition_count` counts the distinct step definitions in
all stages, and `step_execution_count` includes stage repetition. `temperature_zone_count` is
included when it can be derived from the protocol.

The complete `Protocol`, individual temperatures and hold times, backend return value, and backend
keyword arguments are deliberately excluded from the event payload.

`run_pcr_profile` is a composite convenience method and has no separate parent lifecycle; its
instrumented primitive calls emit their normal events. Thermocycler status queries and wait helpers
are not yet instrumented because their polling and completion semantics need to be stabilized
before they can define canonical EventBus operations.

## Shaking and environmental control

Controllers that are `ResourceHolder`s include their directly loaded resource in `resources` when
Expand Down
19 changes: 19 additions & 0 deletions docs/user_guide/machine-agnostic-features/event-bus.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ events.
| `legacy.storage.Incubator` | `incubator.fetch_plate`, `incubator.take_in_plate` |
| `high_res.sample_storage.HighResSampleStorage` | incubator fetch/take-in/nest transfer; temperature, humidity, CO2, and O2 control when supported |
| `legacy.liquid_handling.LiquidHandler` | resource pickup/move/drop; tip pickup/drop; 96-head tip pickup/drop; aspirate; dispense |
| `legacy.plate_reading.PlateReader` | open/close; luminescence, absorbance, and fluorescence reads |
| `legacy.plate_reading.Imager` | `imager.capture` |
| `legacy.plate_reading.ImageReader` | inherited PlateReader and Imager operations, without duplicate lifecycle records |
| `legacy.thermocycling.Thermocycler` | lid open/close; block/lid temperature set/deactivate; protocol submission |
| `legacy.shaking.Shaker` | `shaker.shake`, `shaker.stop_shaking` |
| `legacy.temperature_controlling.TemperatureController` | set temperature, wait for temperature, deactivate |
| `legacy.centrifuge.Centrifuge` | `centrifuge.spin` |
Expand Down Expand Up @@ -166,6 +170,21 @@ operated resources plus `liquid_operations`, one record per channel, with `chann
`resource`, optional owning `plate`, and `volume`. Tip events similarly include direct tip
locations and per-channel `tip_operations`.

### PlateReader and Imager

Plate-reader measurement events identify the directly selected wells, the requested modality
settings, and bounded record counts without including returned measurement data. `imager.capture`
records the resolved target and JSON-ready exposure, focus, and gain settings; software-auto
retries remain inside one lifecycle and image data is excluded. `ImageReader` inherits both event
families without double instrumentation.

### Thermocycler

Thermocycler events cover the seven primitive lid, thermal-control, and protocol-submission
operations. Zoned setpoints use `target_temperatures`. Protocol events contain counts and volume
metadata rather than the full profile, and completion means backend submission returned rather
than physical execution finished.

### Shaker and environmental controllers

Shaker events include `speed_rpm` and optional `duration`. Temperature-controller events include
Expand Down
178 changes: 161 additions & 17 deletions pylabrobot/legacy/plate_reading/imager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import time
from typing import Any, Awaitable, Callable, Coroutine, Dict, Literal, Optional, Tuple, Union, cast

from pylabrobot.events import event_operation, is_event_bus_active, resource_reference
from pylabrobot.legacy.machines import Machine, need_setup_finished
from pylabrobot.legacy.plate_reading.backend import ImagerBackend
from pylabrobot.legacy.plate_reading.standard import (
Expand Down Expand Up @@ -37,12 +38,74 @@
logger = logging.getLogger(__name__)


def _exposure_event_data(exposure_time: Union[Exposure, AutoExposure]) -> dict[str, Any]:
if isinstance(exposure_time, AutoExposure):
data: dict[str, Any] = {
"mode": "software_auto",
"minimum_time_ms": exposure_time.low,
"maximum_time_ms": exposure_time.high,
}
if exposure_time.max_rounds is not None:
data["max_rounds"] = exposure_time.max_rounds
return data
if exposure_time == "machine-auto":
return {"mode": "machine_auto"}
return {"mode": "fixed", "time_ms": exposure_time}


def _focus_event_data(focal_height: Union[FocalPosition, AutoFocus]) -> dict[str, Any]:
if isinstance(focal_height, AutoFocus):
return {
"mode": "software_auto",
"minimum_height": focal_height.low,
"maximum_height": focal_height.high,
"tolerance": focal_height.tolerance,
"timeout": focal_height.timeout,
}
if focal_height == "machine-auto":
return {"mode": "machine_auto"}
return {"mode": "fixed", "height": focal_height}


def _gain_event_data(gain: Gain) -> dict[str, Any]:
if gain == "machine-auto":
return {"mode": "machine_auto"}
return {"mode": "fixed", "value": gain}


def _capture_event_data(
imager: "Imager",
well: Union[Well, Tuple[int, int]],
row: int,
column: int,
mode: ImagingMode,
objective: Objective,
exposure_time: Union[Exposure, AutoExposure],
focal_height: Union[FocalPosition, AutoFocus],
gain: Gain,
) -> dict[str, Any]:
data: dict[str, Any] = {
"device": resource_reference(imager),
"resources": [resource_reference(well)] if isinstance(well, Well) else [],
"target": {"row": row, "column": column},
"mode": mode.name,
"objective": objective.name,
"exposure": _exposure_event_data(exposure_time),
"focus": _focus_event_data(focal_height),
"gain": _gain_event_data(gain),
}
plate = next((child for child in imager.children if isinstance(child, Plate)), None)
if plate is not None:
data["plate"] = resource_reference(plate)
return data


async def _golden_ratio_search(
func: Callable[..., Coroutine[Any, Any, float]], a: float, b: float, tol: float, timeout: float
):
"""Golden ratio search to maximize a unimodal function `func` over the interval [a, b]."""
# thanks chat
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
phi = (1 + math.sqrt(5)) / 2 # Golden ratio

c = b - (b - a) / phi
d = a + (b - a) / phi
Expand Down Expand Up @@ -118,7 +181,7 @@ async def _capture_auto_exposure(
mode: ImagingMode,
objective: Objective,
auto_exposure: AutoExposure,
focal_height: float,
focal_height: Union[float, AutoFocus],
gain: float,
**backend_kwargs,
) -> ImagingResult:
Expand Down Expand Up @@ -152,7 +215,7 @@ def _rms_split(low: float, high: float) -> float:
rounds += 1

p = _rms_split(low, high)
res = await self.capture(
res = await self._capture(
well=well,
mode=mode,
objective=objective,
Expand Down Expand Up @@ -187,7 +250,7 @@ async def _capture_auto_focus(
**backend_kwargs,
) -> ImagingResult:
async def local_capture(focal_height: float) -> ImagingResult:
return await self.capture(
return await self._capture(
well=well,
mode=mode,
objective=objective,
Expand All @@ -211,17 +274,15 @@ async def capture_and_evaluate(focal_height: float) -> float:
)
return await local_capture(best_focal_height)

@need_setup_finished
async def capture(
def _validate_capture_request(
self,
well: Union[Well, Tuple[int, int]],
mode: ImagingMode,
objective: Objective,
exposure_time: Union[Exposure, AutoExposure] = "machine-auto",
focal_height: FocalPosition = "machine-auto",
focal_height: Union[FocalPosition, AutoFocus] = "machine-auto",
gain: Gain = "machine-auto",
**backend_kwargs,
) -> ImagingResult:
) -> Tuple[int, int]:
if exposure_time != "machine-auto" and not isinstance(
exposure_time, (int, float, AutoExposure)
):
Expand All @@ -243,28 +304,52 @@ async def capture(
if isinstance(exposure_time, AutoExposure):
assert focal_height != "machine-auto", "Focal height must be specified for auto exposure"
assert gain != "machine-auto", "Gain must be specified for auto exposure"
elif isinstance(focal_height, AutoFocus):
assert isinstance(exposure_time, (int, float)), (
"Exposure time must be specified for auto focus"
)
assert gain != "machine-auto", "Gain must be specified for auto focus"
return row, column

@need_setup_finished
async def _capture(
self,
well: Union[Well, Tuple[int, int]],
mode: ImagingMode,
objective: Objective,
exposure_time: Union[Exposure, AutoExposure] = "machine-auto",
focal_height: Union[FocalPosition, AutoFocus] = "machine-auto",
gain: Gain = "machine-auto",
**backend_kwargs,
) -> ImagingResult:
row, column = self._validate_capture_request(
well,
mode,
objective,
exposure_time,
focal_height,
gain,
)

if isinstance(exposure_time, AutoExposure):
return await self._capture_auto_exposure(
well=well,
mode=mode,
objective=objective,
auto_exposure=exposure_time,
focal_height=focal_height,
gain=gain,
focal_height=cast(Union[float, AutoFocus], focal_height),
gain=cast(float, gain),
**backend_kwargs,
)

if isinstance(focal_height, AutoFocus):
assert isinstance(exposure_time, (int, float)), (
"Exposure time must be specified for auto focus"
)
assert gain != "machine-auto", "Gain must be specified for auto focus"
return await self._capture_auto_focus(
well=well,
mode=mode,
objective=objective,
exposure_time=exposure_time,
exposure_time=cast(float, exposure_time),
auto_focus=focal_height,
gain=gain,
gain=cast(float, gain),
**backend_kwargs,
)

Expand All @@ -280,6 +365,65 @@ async def capture(
**backend_kwargs,
)

@need_setup_finished
async def capture(
self,
well: Union[Well, Tuple[int, int]],
mode: ImagingMode,
objective: Objective,
exposure_time: Union[Exposure, AutoExposure] = "machine-auto",
focal_height: Union[FocalPosition, AutoFocus] = "machine-auto",
gain: Gain = "machine-auto",
**backend_kwargs,
) -> ImagingResult:
"""Capture one user-requested image result with one semantic EventBus lifecycle."""

row, column = self._validate_capture_request(
well,
mode,
objective,
exposure_time,
focal_height,
gain,
)
operation_data = (
_capture_event_data(
self,
well,
row,
column,
mode,
objective,
exposure_time,
focal_height,
gain,
)
if is_event_bus_active()
else {}
)
completion_data: dict[str, Any] = {}
with event_operation(
"imager.capture",
**operation_data,
completed_data_factory=lambda: {**operation_data, **completion_data},
):
result = await self._capture(
well=well,
mode=mode,
objective=objective,
exposure_time=exposure_time,
focal_height=focal_height,
gain=gain,
**backend_kwargs,
)
if operation_data:
completion_data.update(
image_count=len(result.images),
reported_exposure_time_ms=result.exposure_time,
reported_focal_height=result.focal_height,
)
return result


def max_pixel_at_fraction(
fraction: float, margin: float
Expand Down
Loading
Loading