{ "cells": [ { "cell_type": "markdown", "id": "intro", "metadata": {}, "source": [ "# BMG Labtech VANTAstar\n", "\n", "The VANTAstar is a monochromator-based multi-mode microplate reader from BMG Labtech. PyLabRobot drives it over its native USB interface: **absorbance** (endpoint and spectra), top **fluorescence intensity** (endpoint, excitation and emission spectra) with automatic gain and focus, orbital and spiral **well scanning**, **shaking** and the **incubator**; **luminescence** is implemented but untested." ] }, { "cell_type": "markdown", "id": "card", "metadata": {}, "source": [ "```{device-card} bmg-vantastar\n", "```" ] }, { "cell_type": "markdown", "id": "warning", "metadata": {}, "source": [ "```{warning}\n", "This driver has been tested on **one VANTAstar (firmware 1.12)** only. Its **luminescence** read has not been tested on a reader with luminescence: the tested reader has no luminescence module and refuses it. `setup()` logs a warning. If you verify it on your reader, please [report the result](../../needs-testing.md) or open a pull request to update this warning.\n", "```" ] }, { "cell_type": "markdown", "id": "properties", "metadata": {}, "source": [ "## Properties\n", "\n", "| | |\n", "|---|---|\n", "| Measurement modes | absorbance (endpoint, spectrum), fluorescence intensity (top: endpoint, excitation and emission spectra), luminescence (top, untested); kinetic reads with `run_cycles` |\n", "| Well scanning | orbital, spiral |\n", "| Environment | incubator (heating), shaking (orbital, double orbital, linear) |\n", "| Interface | USB (VID 0x0483 / PID 0xA29A) |\n", "| Not supported | bottom reads, pathlength correction, matrix well scanning |" ] }, { "cell_type": "markdown", "id": "how-it-talks", "metadata": {}, "source": [ "## How it talks\n", "\n", "The reader connects over USB (full speed) and exchanges BMG's binary command and reply packets with the computer through `pylabrobot.io.USB`. A read is a single command that carries all of its settings: the plate geometry, the wells, the optics, the reading order and timing. The reader answers it with the run time it expects, which the driver logs (INFO); on the tested reader every read finished a few seconds sooner. `reader.estimate_duration` asks for that estimate without reading. The driver then polls the reader's status and fetches the data as the reader stores it. Every method below takes the settings of its read as arguments; no Voyager protocol files are involved." ] }, { "cell_type": "markdown", "id": "physical-setup", "metadata": {}, "source": [ "## Physical setup\n", "\n", "1. Connect the reader to the computer with its USB cable and switch it on.\n", "2. Install the USB dependencies: `pip install \"pylabrobot[usb]\"` (see [Using the USB interface](../../getting-started/installation.md)).\n", "3. On Windows, the driver was tested with the WinUSB driver that BMG installs with its Voyager software; it needs no driver replacement. Linux and macOS have not been tested.\n", "4. Close Voyager before you connect; the driver was tested with Voyager closed." ] }, { "cell_type": "markdown", "id": "setup-md", "metadata": {}, "source": [ "## Setup\n", "\n", "Create the reader with the `VANTAstar` factory and call `setup()`: it opens the USB connection, initializes the reader (homes the axes, about 10 s) and reads its firmware version and serial number." ] }, { "cell_type": "code", "execution_count": null, "id": "setup", "metadata": {}, "outputs": [], "source": [ "from pylabrobot.bmg_labtech import VANTAstar\n", "\n", "reader = VANTAstar(name=\"vantastar\")\n", "await reader.setup()\n", "reader.driver.configuration.firmware_version" ] }, { "cell_type": "markdown", "id": "plate-md", "metadata": {}, "source": [ "## Loading a plate\n", "\n", "Open the drawer, assign a plate to the reader's plate holder, then close the drawer. The plate's geometry is sent with every read." ] }, { "cell_type": "code", "execution_count": null, "id": "plate", "metadata": {}, "outputs": [], "source": [ "from pylabrobot.resources import cor_96_wellplate_360uL_Fb\n", "\n", "await reader.drawer.open()\n", "plate = cor_96_wellplate_360uL_Fb(name=\"plate\")\n", "reader.plate_holder.assign_child_resource(plate)\n", "await reader.drawer.close()" ] }, { "cell_type": "markdown", "id": "absorbance-md", "metadata": {}, "source": [ "## Absorbance\n", "\n", "Measure the optical density at one to eight wavelengths (nm). The defaults (22 flashes, 0.5 s settling) are Voyager's \"precise\" preset. The optical density is computed as Voyager computes it; it is not pathlength-corrected. Voyager reports optical densities above 3.5 as 3.5; PyLabRobot returns the computed values, `inf` for a well that lets no measurable light through." ] }, { "cell_type": "code", "execution_count": null, "id": "absorbance", "metadata": {}, "outputs": [], "source": [ "results = await reader.absorbance.measure([600])\n", "results[0].data[\"A1\"]" ] }, { "cell_type": "markdown", "id": "well-scan-md", "metadata": {}, "source": [ "### Read settings\n", "\n", "Every read takes `measurement_settings`, a `MeasurementSettings` with the names the CLARIOstar uses: the flashes per well (1-500) and the settling time (0.02-1.0 s), where None takes the read's defaults; the corner the read starts at, `reading_direction` row or column by column, `bidirectional` for back and forth (Voyager's default); a pause after shaking, before the read; and the well scan. The reader refuses settings out of these ranges.\n", "\n", "Instead of the centre of each well (`well_scan=\"point\"`), the reader can average over an orbit or a spiral of `scan_diameter` mm around it. The diameter must fit the well: the reader refuses a wider one. Averaging near the well's edge reads higher optical densities than the centre, in Voyager too. Matrix well scans are not supported.\n", "\n", "`shake_settings` shakes the plate right before the read, as Voyager does." ] }, { "cell_type": "code", "execution_count": null, "id": "well-scan", "metadata": {}, "outputs": [], "source": [ "from pylabrobot.bmg_labtech.vantastar import MeasurementSettings, ShakeSettings\n", "\n", "results = await reader.absorbance.measure(\n", " [600],\n", " measurement_settings=MeasurementSettings(well_scan=\"orbital\", scan_diameter=3, flashes=22),\n", " shake_settings=ShakeSettings(pattern=\"orbital\", speed=300, duration=5),\n", ")" ] }, { "cell_type": "markdown", "id": "absorbance-spectrum-md", "metadata": {}, "source": [ "### Absorbance spectrum\n", "\n", "The reader's spectrometer reads every wavelength of a spectrum at once, well by well (220-1000 nm, in whole nm steps). The result holds each well's spectrum." ] }, { "cell_type": "code", "execution_count": null, "id": "absorbance-spectrum", "metadata": {}, "outputs": [], "source": [ "spectrum = await reader.absorbance.measure_spectrum(220, 1000, step_size=1, wells=plate.get_items(\"A1:A3\"))\n", "spectrum.data[\"A1\"][450]" ] }, { "cell_type": "markdown", "id": "absorbance-kinetic-md", "metadata": {}, "source": [ "### Kinetic reads\n", "\n", "To read the plate repeatedly, run a read in `run_cycles` (`pylabrobot.utils.timing`), as on the CLARIOstar: cycle `i` starts `i * cycle_time` seconds after the first, and `run_cycles` returns what each cycle's read returned. A cycle that starts late, because the read before it took longer than the cycle time, runs at once, or is skipped with `late=\"skip\"`. Any read works this way, fluorescence too." ] }, { "cell_type": "code", "execution_count": null, "id": "absorbance-kinetic", "metadata": {}, "outputs": [], "source": [ "from pylabrobot.utils.timing import run_cycles\n", "\n", "cycles = await run_cycles(\n", " cycle_function=lambda cycle: reader.absorbance.measure([450], wells=plate.get_items(\"A1:A3\")),\n", " cycle_time=30,\n", " cycles=6,\n", ")\n", "[results[0].data[\"A1\"] for results in cycles]" ] }, { "cell_type": "markdown", "id": "estimate-duration-md", "metadata": {}, "source": [ "### Estimating a read's duration\n", "\n", "`reader.estimate_duration` returns the run time in seconds the reader expects for a read, without reading: pass the read as a lambda, as to `run_cycles`. The read ends at its command, which goes to the reader with its time-only flag; nothing moves. Shaking before the read and `pause_before_reading` are not part of the estimate, and any call other than a read is refused. On the tested reader every read finished a few seconds before its estimate, so a `cycle_time` of at least the estimate leaves room." ] }, { "cell_type": "code", "execution_count": null, "id": "estimate-duration", "metadata": {}, "outputs": [], "source": [ "await reader.estimate_duration(lambda: reader.absorbance.measure([450], wells=plate.get_items(\"A1:A3\")))" ] }, { "cell_type": "markdown", "id": "fluorescence-md", "metadata": {}, "source": [ "## Fluorescence intensity\n", "\n", "A read goes through a `FluorescenceChannel` (`pylabrobot.lib.fluorescence`), as on the CLARIOstar: the excitation and emission set through the monochromators, a wavelength and a bandwidth (8-100 nm) each. Without bandwidths the channel reads at 8 nm excitation and 12 nm emission (`fluorescence.default_excitation_bandwidth` and `default_emission_bandwidth`); filters do not apply. The band edges are the nominal ones, wavelength +/- bandwidth / 2, and the dichroic is set midway between them, as Voyager sets it automatically. The focal height, in mm above the plate's base, is optional: without one, the read takes 25 % of the wells' depth above their floor (`fluorescence.default_focal_height_percent_of_well_depth`), 6.2 mm on this plate.\n", "\n", "Voyager places the band edges closer together, by an amount its filter software computes from the wavelength, the bandwidth and the slit. Narrow bands differ most: for excitation 365/8 nm and emission 400/10 nm Voyager uses 362.9-367.1 nm and 397.8-402.2 nm, where the nominal edges are 361-369 nm and 395-405 nm; bands of 20 nm and more differ by a few tenths of a nm. Readings at narrow bandwidths are therefore not directly comparable with Voyager's." ] }, { "cell_type": "code", "execution_count": null, "id": "fluorescence", "metadata": {}, "outputs": [], "source": [ "from pylabrobot.lib.fluorescence import FluorescenceChannel\n", "\n", "channel_365_400 = FluorescenceChannel(\n", " excitation_wavelength=365,\n", " excitation_bandwidth=8,\n", " emission_wavelength=400,\n", " emission_bandwidth=10,\n", ")\n", "result = await reader.fluorescence.measure(channel_365_400, focal_height=5.0, gain=500)\n", "result.data[\"A1\"]" ] }, { "cell_type": "markdown", "id": "fluorescence-voyager-md", "metadata": {}, "source": [ "To read with other band edges, such as the ones Voyager uses for 365/8 nm and 400/10 nm, pass them as `monochromator`.\n", "\n", "Up to five channels in one read take a list of channels and a gain each (or one gain for all) and return one result per channel." ] }, { "cell_type": "code", "execution_count": null, "id": "fluorescence-voyager", "metadata": {}, "outputs": [], "source": [ "from pylabrobot.bmg_labtech.vantastar.driver.features.measurement import MonochromatorSettings\n", "\n", "voyager_365_400 = MonochromatorSettings(\n", " excitation_lower=362.9,\n", " excitation_upper=367.1,\n", " dichroic=382.0,\n", " emission_lower=397.8,\n", " emission_upper=402.2,\n", ")\n", "result = await reader.fluorescence.measure(\n", " channel_365_400, focal_height=5.0, gain=500, monochromator=voyager_365_400\n", ")\n", "\n", "channel_485_528 = FluorescenceChannel(excitation_wavelength=485, emission_wavelength=528)\n", "results = await reader.fluorescence.measure(\n", " [channel_365_400, channel_485_528], focal_height=5.0, gain=[500, 700]\n", ")" ] }, { "cell_type": "markdown", "id": "gain-md", "metadata": {}, "source": [ "### Automatic gain and focus\n", "\n", "`fluorescence.adjust_gain_and_focus` lets the reader find the gain and the focal height on reference wells, as Voyager's automatic gain and focus does. Pass the result to `fluorescence.measure`. With `enhanced_dynamic_range=True` no gain is needed.\n", "\n", "`adjust_focus=False` finds the gain alone at the given `focal_height`; `adjust_gain=False` finds the focal height alone. Voyager's autofocus runs both on the reference wells, then the focus alone on every well of the read, and reads at that height." ] }, { "cell_type": "code", "execution_count": null, "id": "gain", "metadata": {}, "outputs": [], "source": [ "reference_wells = plate.get_items(\"A1:H1\")\n", "adjusted = await reader.fluorescence.adjust_gain_and_focus(channel_365_400, wells=reference_wells)\n", "result = await reader.fluorescence.measure(\n", " channel_365_400, focal_height=adjusted.focal_height, gain=adjusted.gain\n", ")" ] }, { "cell_type": "markdown", "id": "z-profile-md", "metadata": {}, "source": [ "### Z-profiles\n", "\n", "`fluorescence.measure_z_profile` returns the reader's z-scan of one well, to analyse or debug the focus: the counts at each focal height from 16 mm down, in 0.1 mm steps by default (`focal_height_step`, 0.1-1.0 mm), the peak focal height the reader computes from the smoothed profile and, with `gain_target_percent`, the gain it fits at the peak. It is the scan `adjust_gain_and_focus` runs. The profile's counts are at a gain the reader chooses for the scan, so they show its shape." ] }, { "cell_type": "code", "execution_count": null, "id": "z-profile", "metadata": {}, "outputs": [], "source": [ "profile = await reader.fluorescence.measure_z_profile(\n", " channel_365_400, well=plate.get_well(\"A1\"), gain_target_percent=90\n", ")\n", "print(profile.peak_focal_height, profile.gain, profile.peak_detector_percent)\n", "profile.z_profile[:3]" ] }, { "cell_type": "markdown", "id": "fluorescence-spectrum-md", "metadata": {}, "source": [ "### Fluorescence spectra\n", "\n", "`fluorescence.measure_spectrum` scans the excitation or the emission monochromator while the other stays fixed, with the gain given; the bandwidths and the focal height default as in `fluorescence.measure`. The counts are the reader's raw counts: Voyager shows spectra multiplied by BMG's instrument correction curve, a factor per wavelength, so its exported spectra differ from these by that factor.\n", "\n", "To measure at two excitation and emission pairs, read them one after another (Voyager reads them as separate passes too)." ] }, { "cell_type": "code", "execution_count": null, "id": "fluorescence-spectrum", "metadata": {}, "outputs": [], "source": [ "emission = await reader.fluorescence.measure_spectrum(\n", " \"emission\",\n", " 480,\n", " 700,\n", " excitation_wavelength=450,\n", " excitation_bandwidth=16,\n", " emission_bandwidth=10,\n", " step_size=2,\n", " gain=1500,\n", " focal_height=6.8,\n", " wells=plate.get_items(\"H1:H3\"),\n", ")\n", "emission.data[\"H1\"][600]" ] }, { "cell_type": "markdown", "id": "luminescence-md", "metadata": {}, "source": [ "## Luminescence\n", "\n", "```{warning}\n", "Luminescence is **untested**: neither the command nor the decoding of its data has run on a reader with luminescence. The tested reader has no luminescence module and refuses the read (`CommandRefusedError`, quit code 205). Please report how it behaves on a reader with the module.\n", "```\n", "\n", "The defaults are Voyager's: all the emitted light, the reader's automatic gain, 0.8 s per well, 0.5 mm aperture distance." ] }, { "cell_type": "code", "execution_count": null, "id": "luminescence", "metadata": {}, "outputs": [], "source": [ "result = await reader.luminescence.measure(integration_time=0.8)\n", "result.data[\"A1\"]" ] }, { "cell_type": "markdown", "id": "shake-md", "metadata": {}, "source": [ "## Shaking\n", "\n", "Shake the plate with the shaker: the pattern orbital, linear or double orbital; the speed in rpm, a multiple of 100; the duration in seconds. To shake right before a read, pass `shake_settings` to the read instead." ] }, { "cell_type": "code", "execution_count": null, "id": "shake", "metadata": {}, "outputs": [], "source": [ "await reader.shaker.shake(\"double_orbital\", speed=300, duration=10)" ] }, { "cell_type": "markdown", "id": "temperature-md", "metadata": {}, "source": [ "## Temperature\n", "\n", "Heat the incubator with the temperature controller and read its temperature; `wait_for_temperature` waits until it is within 0.5 C of the target." ] }, { "cell_type": "code", "execution_count": null, "id": "temperature", "metadata": {}, "outputs": [], "source": [ "await reader.temperature_controller.start_temperature_control(30.0)\n", "await reader.temperature_controller.measure_temperature()" ] }, { "cell_type": "markdown", "id": "temperature-off-md", "metadata": {}, "source": [ "Switch heating off again; the temperature stays monitored." ] }, { "cell_type": "code", "execution_count": null, "id": "temperature-off", "metadata": {}, "outputs": [], "source": [ "await reader.temperature_controller.stop_temperature_control()" ] }, { "cell_type": "markdown", "id": "teardown-md", "metadata": {}, "source": [ "## Teardown\n", "\n", "Stop any running measurement, close the drawer and disconnect. `stop` refuses while a plate is still in the reader, unless you pass `accept_plate_left_in_device=True`." ] }, { "cell_type": "code", "execution_count": null, "id": "teardown", "metadata": {}, "outputs": [], "source": [ "await reader.drawer.open()\n", "reader.plate_holder.unassign_child_resource(plate)\n", "await reader.drawer.close()\n", "await reader.stop()" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }