Skip to content

add nalgene 1 well trough plate#939

Merged
rickwierenga merged 1 commit intoPyLabRobot:mainfrom
Pioneer-Research-Labs:add_nalgene_troughplate
Mar 16, 2026
Merged

add nalgene 1 well trough plate#939
rickwierenga merged 1 commit intoPyLabRobot:mainfrom
Pioneer-Research-Labs:add_nalgene_troughplate

Conversation

@harley-pioneer
Copy link
Contributor

Added plate definition for 1-well reservoir in SBS format: Thermo Scientific™ Nalgene™ Disposable Polypropylene Robotic Reservoirs

This reservoir is great for bulk plate filling with the 96 head - we use it reliably for one-to-many type stamping

Updating after errors in #824

@rickwierenga rickwierenga merged commit 285da02 into PyLabRobot:main Mar 16, 2026
11 checks passed
@rickwierenga
Copy link
Member

thanks!

@harley-pioneer harley-pioneer deleted the add_nalgene_troughplate branch March 16, 2026 22:49
@BioCam
Copy link
Collaborator

BioCam commented Mar 20, 2026

@harley-pioneer do you have height_volume_data for the "Well" of this plate to enable auto-surface following & volume probing on it?

Please see Add height_volume_data attribute to Container with piecewise-linear interpolation #938 for details

@harley-pioneer
Copy link
Contributor Author

@BioCam I don't have height_volume_data - just to make sure I understand this, do you capture that data IRL for a given container and then use that dict for the attribute, or is that data calculated somewhere? If it's real data, it would be easy to populate it. I really like that idea for these larger reservoirs

@BioCam
Copy link
Collaborator

BioCam commented Mar 20, 2026

@harley-pioneer - sorry, I might not have made the idea behind this clearer:

Yes, the rational:

In the absence of known geometries / the complexity arising from their manufacturing process, we can only guarantee high performance via (1) empirical evaluation, and (2) a means to iterate over them.

This is were a simple dict created by this way is the solution:
In real life...

  1. use force probing to measure the bottom of the Container
  2. use high accuracy pipette to manually pipette liquid into the Container
  3. use a device to measure the height of the known liquid, either via cLLD or where that fails (e.g. 384-wellplates) via pLLD
  4. subtract container_bottom from measured liquid levels -> using relative measurements to obtain true liquid height:volume data

@BioCam
Copy link
Collaborator

BioCam commented Mar 20, 2026

In code, it looks like this:

"""Utility for measuring container height-volume calibration data on a Hamilton STAR."""

import statistics
import warnings
from typing import TYPE_CHECKING, Awaitable, Callable, Dict, List, Optional

if TYPE_CHECKING:
    from pylabrobot.liquid_handling.liquid_handler import LiquidHandler
    from pylabrobot.resources.container import Container


async def _default_prompt(volume: float, step_idx: int, total: int) -> None:
    input(f"[{step_idx + 1}/{total}] Pipette {volume} uL into the container, then press Enter...")


async def measure_height_volume_data(
    lh: "LiquidHandler",
    container: "Container",
    test_volumes: List[float],
    channel_idx: int = 0,
    # ztouch params
    ztouch_num_replicates: int = 5,
    ztouch_speed: float = 18.0,
    ztouch_detection_limiter: int = 0,
    ztouch_push_down_force: int = 0,
    # cLLD params
    clld_num_replicates: int = 3,
    clld_speed: float = 6.0,
    clld_detection_edge: int = 5,
    clld_post_detection_dist: float = 2.0,
    # output
    decimal_places: int = 2,
    prompt_callback: Optional[Callable[[float, int, int], Awaitable[None]]] = None,
) -> Dict[float, float]:
    """Measure height-volume calibration data for a container using a Hamilton STAR.

    Uses ztouch to find the cavity bottom, then cLLD at each test volume to build a
    ``{height_mm: volume_uL}`` mapping suitable for ``Container.height_volume_data``.

    The caller is responsible for tip pickup/drop. A teaching needle (metal tip) must
    already be mounted on ``channel_idx``.

    Args:
        lh: A set-up LiquidHandler with a STARBackend.
        container: The container to calibrate (must be on the deck).
        test_volumes: Sorted list of positive volumes (uL) to measure.
        channel_idx: Channel to use (0-based).
        ztouch_num_replicates: Number of ztouch replicates for cavity bottom.
        ztouch_speed: Ztouch probe speed in mm/s.
        ztouch_detection_limiter: PWM limiter for ztouch detection.
        ztouch_push_down_force: PWM push-down force for ztouch.
        clld_num_replicates: Number of cLLD replicates per volume.
        clld_speed: cLLD probe speed in mm/s.
        clld_detection_edge: Edge steepness for cLLD detection.
        clld_post_detection_dist: Distance to move after cLLD detection in mm.
        decimal_places: Rounding precision for height values.
        prompt_callback: Async callable ``(volume, step_idx, total) -> None`` invoked before
            each volume measurement. Defaults to ``input()`` prompt.

    Returns:
        Dict mapping height (mm above cavity bottom) to volume (uL), always including
        ``{0.0: 0.0}`` as the baseline.

    Raises:
        TypeError: If the backend is not a STARBackend.
        RuntimeError: If no tip is present on the channel.
        ValueError: If test_volumes is invalid (empty, unsorted, duplicates, or negatives).
    """

    from pylabrobot.liquid_handling.backends.hamilton.STAR_backend import STARBackend

    # --- Validation (no hardware touched) ---
    backend = lh.backend
    if not isinstance(backend, STARBackend):
        raise TypeError(
            f"measure_height_volume_data requires a STARBackend, got {type(backend).__name__}"
        )

    if not test_volumes:
        raise ValueError("test_volumes must not be empty")
    if any(v <= 0 for v in test_volumes):
        raise ValueError("All test_volumes must be positive")
    if test_volumes != sorted(test_volumes):
        raise ValueError("test_volumes must be sorted in ascending order")
    if len(test_volumes) != len(set(test_volumes)):
        raise ValueError("test_volumes must not contain duplicates")

    if not (0 <= channel_idx < backend.num_channels):
        raise ValueError(f"channel_idx {channel_idx} out of range [0, {backend.num_channels})")

    if not lh.head[channel_idx].has_tip:
        raise RuntimeError(f"No tip present on channel {channel_idx}")

    if prompt_callback is None:
        prompt_callback = _default_prompt

    # --- Position channel over container ---
    await backend.move_all_channels_in_z_safety()
    await backend.prepare_for_manual_channel_operation(channel_idx)

    container_center = container.get_absolute_location("c", "c", "t")
    await backend.move_channel_x(channel_idx, container_center.x)
    await backend.move_channel_y(channel_idx, container_center.y)

    # --- Measurement with safety wrapper ---
    result: Dict[float, float] = {0.0: 0.0}

    try:
        # Phase A: Cavity bottom (ztouch)
        ztouch_readings = []
        last_z: Optional[float] = None

        for i in range(ztouch_num_replicates):
            start_pos = None
            if last_z is not None:
                start_pos = last_z + 5.0

            z = await backend.ztouch_probe_z_height_using_channel(
                channel_idx=channel_idx,
                channel_speed=ztouch_speed,
                detection_limiter_in_PWM=ztouch_detection_limiter,
                push_down_force_in_PWM=ztouch_push_down_force,
                start_pos_search=start_pos,
                move_channels_to_safe_pos_after=True,
            )
            ztouch_readings.append(z)
            last_z = z

        cavity_bottom_z = statistics.mean(ztouch_readings)
        if len(ztouch_readings) > 1:
            std = statistics.stdev(ztouch_readings)
            if std > 0.5:
                warnings.warn(
                    f"High ztouch variability: std={std:.3f}mm across "
                    f"{ztouch_num_replicates} replicates. Readings: {ztouch_readings}",
                    stacklevel=2,
                )

        print(
            f"Cavity bottom Z: {cavity_bottom_z:.{decimal_places}f} mm "
            f"(std={statistics.stdev(ztouch_readings) if len(ztouch_readings) > 1 else 0:.3f}mm)"
        )

        # Phase B: cLLD measurements per volume
        last_clld_z: Optional[float] = None
        prev_height: Optional[float] = None

        for step_idx, volume in enumerate(test_volumes):
            await prompt_callback(volume, step_idx, len(test_volumes))

            clld_readings = []
            for j in range(clld_num_replicates):
                start_pos = None
                if last_clld_z is not None:
                    start_pos = last_clld_z + 5.0

                z = await backend.clld_probe_z_height_using_channel(
                    channel_idx=channel_idx,
                    channel_speed=clld_speed,
                    detection_edge=clld_detection_edge,
                    post_detection_dist=clld_post_detection_dist,
                    start_pos_search=start_pos,
                    move_channels_to_safe_pos_after=True,
                )
                clld_readings.append(z)
                last_clld_z = z

            mean_z = statistics.mean(clld_readings)
            height = round(mean_z - cavity_bottom_z, decimal_places)

            if prev_height is not None and height <= prev_height:
                warnings.warn(
                    f"Non-monotonic height at {volume} uL: {height}mm <= previous {prev_height}mm. "
                    f"Check for bubbles or evaporation.",
                    stacklevel=2,
                )

            result[height] = volume
            prev_height = height

            std_str = (
                f", std={statistics.stdev(clld_readings):.3f}mm"
                if len(clld_readings) > 1
                else ""
            )
            print(f"  {volume:>8.1f} uL -> {height:.{decimal_places}f} mm{std_str}")

    finally:
        try:
            await backend.move_all_channels_in_z_safety()
        except Exception:
            pass

    # Phase C: Output
    result = dict(sorted(result.items(), key=lambda item: item[0]))

    print("\n# Copy-paste into your container definition:")
    print("height_volume_data = {")
    for h, v in result.items():
        print(f"  {h}: {v},")
    print("}")

    min_h = min(result.keys())
    max_h = max(result.keys())
    min_v = min(result.values())
    max_v = max(result.values())
    print(
        f"\nSummary: {len(result)} points, "
        f"height [{min_h:.{decimal_places}f}, {max_h:.{decimal_places}f}] mm, "
        f"volume [{min_v:.1f}, {max_v:.1f}] uL"
    )

    return result

@BioCam
Copy link
Collaborator

BioCam commented Mar 20, 2026

Here is an example of updating a previous function-based model with empirical data:

Update Corning 3603 with empirical cLLD height_volume_data #948

@rickwierenga
Copy link
Member

@harley-pioneer renamed in aacfe13 per naming standard, releasing in 0.2.1

@harley-pioneer
Copy link
Contributor Author

@BioCam thank you for the code - this is very cool! Some potentially silly Qs about this

  1. For this type of container, you rarely, if every, do have volumes < 30mL in this reservoir. To build the dictionary accurately, would it still be helpful if the volumes are added to the container manually (i.e. not STAR pipetted but by hand with larger volume/pipettes)?
  2. Any instinctive recommendation for number of volumes to use for test_volumes?
  3. Could we put this method somewhere in plr? Could be great in utils and in the user guide recommendation when adding labware (as a recommendation / best practices).

@BioCam
Copy link
Collaborator

BioCam commented Mar 23, 2026

@harley-pioneer, yes yes and yes :)

  1. Yes, I highly recommend adding the volumes to the container manually for calibration data generation.
    True regarding the minimum usable liquid volume -> this is how I define "dead volume" actually: fill liquid with (in your case) serological pipette until the entire bottom is first completely filled;
    that guarantees that an LLD action will work from that height onwards - anywhere in the well, and same then goes for aspirate -> i.e. successful aspirate doesn't depend on the requirement to tilt the plate. - happy to discuss this more, as it might be a bit confusing and requires consideration of "liquid pooling" lower than that height/volume

  2. Absolutely:

    • first start with the liquid volume that fully covers the bottom and first enables reliable LLD (cLLD might fail at low liquid z-height and then I'd switch to the STAR's more sensitive pLLD)
    • next look at how irregular the geometry of your container is -> add small volume addition increments at sections that are visibly highly irregular (for standard Wells that is usually the bottom third)
    • at least 10 measurements should be made as a rough heuristic
    • finally, many containers say in their advertising a specific max volume ... but then you do this probing and realise that you can add a lot more 😅 -> e.g. Hamilton 60 mL troughs can actually hold almost 80 mL, and Hamilton 200 mL troughs can hold almost 300 mL - I spend a fair amount of time to find that top liquid level ... don't forget, only one of us has to precisely do this once, then everyone benefits from it - it is worth spending the time on this! :)
      And, if anyone finds a mistake, with the height_volume_data dict we can improve it.
  3. I have been playing with this idea and been using this now - it is ... just a bit messy and most of the times I make ad hoc modifications based on real world situations - e.g.: if an LLD measurement fails in the middle you have to reset from the point of failur and don't want to go through emptying the entire container and adding every bit of liquid all over again.
    If you think it would be useful and more widely applicable than I've foreseen let's make it a standalone function

@harley-pioneer
Copy link
Contributor Author

@BioCam just an FYI - I tried this today and it looks like my STARlet firmware is too old to run Z-touch probing :(

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants