Resource is too small to space channels error

I am running into something funky, I am trying to dispense to plate and am getting “Resource is too small to space channels.”. Oddly though the previous round I just pipetted to these wells. This time I just have less transfers.

First dispense well coordinates which works:

[Well(name='bar4_well_0_0', location=Coordinate(010.870, 070.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well), Well(name='bar4_well_0_1', location=Coordinate(010.870, 061.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well), Well(name='bar4_well_0_2', location=Coordinate(010.870, 052.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well), Well(name='bar4_well_0_3', location=Coordinate(010.870, 043.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well), Well(name='bar4_well_0_4', location=Coordinate(010.870, 034.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well), Well(name='bar4_well_0_5', location=Coordinate(010.870, 025.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well), Well(name='bar4_well_0_2', location=Coordinate(010.870, 052.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well), Well(name='bar4_well_0_2', location=Coordinate(010.870, 052.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well)]

Second dispense well coordinates that do not work:

[Well(name='bar4_well_0_2', location=Coordinate(010.870, 052.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well), Well(name='bar4_well_0_2', location=Coordinate(010.870, 052.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well)]

Error:

{
	"name": "ValueError",
	"message": "Resource is too small to space channels.",
	"stack": "\u001b[31m---------------------------------------------------------------------------\u001b[39m\n\u001b[31mValueError\u001b[39m                                Traceback (most recent call last)\n\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[172]\u001b[39m\u001b[32m, line 47\u001b[39m\n\u001b[32m     45\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m lh.aspirate(resources=wells_to_asp, vols=volumes_transfer)\n\u001b[32m     46\u001b[39m \u001b[38;5;28mprint\u001b[39m(wells_to_disp)\n\u001b[32m---> \u001b[39m\u001b[32m47\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m lh.dispense(resources= wells_to_disp, vols=volumes_transfer)\n\u001b[32m     48\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m lh.discard_tips()\n\n\u001b[36mFile \u001b[39m\u001b[32mC:\\pylabrobot_hackathon\\pylabrobot\\pylabrobot\\machines\\machine.py:35\u001b[39m, in \u001b[36mneed_setup_finished.<locals>.wrapper\u001b[39m\u001b[34m(*args, **kwargs)\u001b[39m\n\u001b[32m     33\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28mself\u001b[39m.setup_finished:\n\u001b[32m     34\u001b[39m   \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[33m\"\u001b[39m\u001b[33mThe setup has not finished. See `setup`.\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m---> \u001b[39m\u001b[32m35\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m func(*args, **kwargs)\n\n\u001b[36mFile \u001b[39m\u001b[32mC:\\pylabrobot_hackathon\\pylabrobot\\pylabrobot\\liquid_handling\\liquid_handler.py:1139\u001b[39m, in \u001b[36mLiquidHandler.dispense\u001b[39m\u001b[34m(self, resources, vols, use_channels, flow_rates, offsets, liquid_height, blow_out_air_volume, spread, **backend_kwargs)\u001b[39m\n\u001b[32m   1135\u001b[39m   center_offsets = get_tight_single_resource_liquid_op_offsets(\n\u001b[32m   1136\u001b[39m     resource=resource, num_channels=\u001b[38;5;28mlen\u001b[39m(use_channels)\n\u001b[32m   1137\u001b[39m   )\n\u001b[32m   1138\u001b[39m \u001b[38;5;28;01melif\u001b[39;00m spread == \u001b[33m\"\u001b[39m\u001b[33mwide\u001b[39m\u001b[33m\"\u001b[39m:\n\u001b[32m-> \u001b[39m\u001b[32m1139\u001b[39m   center_offsets = \u001b[43mget_wide_single_resource_liquid_op_offsets\u001b[49m\u001b[43m(\u001b[49m\n\u001b[32m   1140\u001b[39m \u001b[43m    \u001b[49m\u001b[43mresource\u001b[49m\u001b[43m=\u001b[49m\u001b[43mresource\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mnum_channels\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43mlen\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43muse_channels\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m   1141\u001b[39m \u001b[43m  \u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m   1142\u001b[39m \u001b[38;5;28;01melif\u001b[39;00m spread == \u001b[33m\"\u001b[39m\u001b[33mcustom\u001b[39m\u001b[33m\"\u001b[39m:\n\u001b[32m   1143\u001b[39m   center_offsets = [Coordinate.zero()] * \u001b[38;5;28mlen\u001b[39m(use_channels)\n\n\u001b[36mFile \u001b[39m\u001b[32mC:\\pylabrobot_hackathon\\pylabrobot\\pylabrobot\\liquid_handling\\utils.py:28\u001b[39m, in \u001b[36mget_wide_single_resource_liquid_op_offsets\u001b[39m\u001b[34m(resource, num_channels)\u001b[39m\n\u001b[32m     21\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mget_wide_single_resource_liquid_op_offsets\u001b[39m(\n\u001b[32m     22\u001b[39m   resource: Resource,\n\u001b[32m     23\u001b[39m   num_channels: \u001b[38;5;28mint\u001b[39m,\n\u001b[32m     24\u001b[39m ) -> List[Coordinate]:\n\u001b[32m     25\u001b[39m   resource_size = resource.get_absolute_size_y()\n\u001b[32m     26\u001b[39m   centers = \u001b[38;5;28mlist\u001b[39m(\n\u001b[32m     27\u001b[39m     \u001b[38;5;28mreversed\u001b[39m(\n\u001b[32m---> \u001b[39m\u001b[32m28\u001b[39m       \u001b[43m_get_centers_with_margin\u001b[49m\u001b[43m(\u001b[49m\n\u001b[32m     29\u001b[39m \u001b[43m        \u001b[49m\u001b[43mdim_size\u001b[49m\u001b[43m=\u001b[49m\u001b[43mresource_size\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m     30\u001b[39m \u001b[43m        \u001b[49m\u001b[43mn\u001b[49m\u001b[43m=\u001b[49m\u001b[43mnum_channels\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m     31\u001b[39m \u001b[43m        \u001b[49m\u001b[43mmargin\u001b[49m\u001b[43m=\u001b[49m\u001b[43mMIN_SPACING_EDGE\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m     32\u001b[39m \u001b[43m        \u001b[49m\u001b[43mmin_spacing\u001b[49m\u001b[43m=\u001b[49m\u001b[43mMIN_SPACING_BETWEEN_CHANNELS\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m     33\u001b[39m \u001b[43m      \u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m     34\u001b[39m     )\n\u001b[32m     35\u001b[39m   )  \u001b[38;5;66;03m# reverse because channels are from back to front\u001b[39;00m\n\u001b[32m     37\u001b[39m   \u001b[38;5;66;03m# offsets are relative to the center of the resource, but above we computed them wrt lfb\u001b[39;00m\n\u001b[32m     38\u001b[39m   \u001b[38;5;66;03m# so we need to subtract the center of the resource\u001b[39;00m\n\u001b[32m     39\u001b[39m   \u001b[38;5;66;03m# also, offsets are in absolute space, so we need to rotate the center\u001b[39;00m\n\u001b[32m     40\u001b[39m   \u001b[38;5;28;01mreturn\u001b[39;00m [\n\u001b[32m     41\u001b[39m     Coordinate(\n\u001b[32m     42\u001b[39m       x=\u001b[32m0\u001b[39m,\n\u001b[32m   (...)\u001b[39m\u001b[32m     46\u001b[39m     \u001b[38;5;28;01mfor\u001b[39;00m c \u001b[38;5;129;01min\u001b[39;00m centers\n\u001b[32m     47\u001b[39m   ]\n\n\u001b[36mFile \u001b[39m\u001b[32mC:\\pylabrobot_hackathon\\pylabrobot\\pylabrobot\\liquid_handling\\utils.py:14\u001b[39m, in \u001b[36m_get_centers_with_margin\u001b[39m\u001b[34m(dim_size, n, margin, min_spacing)\u001b[39m\n\u001b[32m     12\u001b[39m \u001b[38;5;250m\u001b[39m\u001b[33;03m\"\"\"Get the centers of the channels with a minimum margin on the edges.\"\"\"\u001b[39;00m\n\u001b[32m     13\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m dim_size < margin * \u001b[32m2\u001b[39m + (n - \u001b[32m1\u001b[39m) * min_spacing:\n\u001b[32m---> \u001b[39m\u001b[32m14\u001b[39m   \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[33m\"\u001b[39m\u001b[33mResource is too small to space channels.\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m     15\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m dim_size - (n - \u001b[32m1\u001b[39m) * min_spacing <= min_spacing * \u001b[32m2\u001b[39m:\n\u001b[32m     16\u001b[39m   remaining_space = dim_size - (n - \u001b[32m1\u001b[39m) * min_spacing - margin * \u001b[32m2\u001b[39m\n\n\u001b[31mValueError\u001b[39m: Resource is too small to space channels."
}

This is a standard 9mm raster plate, so I am not sure why I am getting a spacing issue.

Ok, I add one well that is not the same in the same dispense and the spacing error goes away

What works:

Dispense:
C1
C1
C3

What gives me the error:

Dispense:
C1
C1
C1

current behavior:

when you pass only one container to lh.dispense (as a list), PLR will try to fit all channels into that one single container. if you want to dispense to the same container with multiple channels one by one, you will need to call the command dispense N times. (with some firmware-specific examples, all aspirate/dispense calls expect that the operation will be executed in one go simultaneously by all channels)

when you pass multiple containers, PLR will distribute the channels over those containers. the channels and containers at index i in their list match.

The first case is an edge-case we haven’t defined. It just happens to work right now.

We should define the expected behavior for it though.

I like to keep aspirate and dispense and all other commands as dumb as possible because “smart” code is confusing

Why is this the expected behavior?

that’s the way it’s currently defined. it’s predictable, and can be used when aspirating from a trough for example

one function call = one operation

A little design context on what I am trying to do. A simple cherrypicking operation from a .csv that retains pipetting order of the .csv.

With this behavior I would have to check if the next batch of 8 (num_channels) hits is not in the same resource and modify my aspirate command accordingly…

I guess the other option is to give them distinct aspirate dispense commands but wouldn’t that be way slow? [quote=“rickwierenga, post:4, topic:351”]
you will need to call the command dispense N times
[/quote]

how would we decide whether to spread channels or raise an error? should we fit the channels if it fits otherwise go one by one? what if 2 out of 3 fit?

as long as the robot can’t physically fit the channels all at the same time, one way or another it’s gonna have to go through the containers one by one. in terms of time that does not make a difference. it does make the code slightly more complicated

1 Like

I haven’t looked at troughs, do you model them differently than Hamilton, as one big well? Hamilton models troughs as 8 wells for this reason.

one big “well” (Container) yes of course

:clown_face:

1 Like

What determines the channel spacing when going into one container? What’s default?

channels as wide as possible

you can control this with the spread: Literal["wide", "tight", "custom"] = "wide" parameter

apologies for poor documentation on this. let’s find out what we want and then document it

1 Like

Gotcha, the thing I worry about is the 60ml troughs from Hamilton have anti-splash baffles. I would be worried the default config would run into these baffles.

Also how does custom work? Defines the spacing? Spacing has to be even?

yes we don’t model these things yet

the user can specify an offset for every channel (using the default offsets parameter)

1 Like

@cwehrhan, I wonder whether the STAR firmware can solve your problem (if you use a STAR or STARlet?):

The STAR(let)'s firmware is actually incredibly smart: it detects a firmware aspirate or dispense command is spaced across different x and y coordinates and smartly identifies an ideal pipetting plan.

But, as @rickwierenga correctly mentioned, the programmer does not intuitively know what that pipetting plan will be - by default we have therefore disabled firmware_planning.

But PLR does not diminish machine powers, out of principle!

So we did empower the STARBackend to take use of this firmware_planning if a user explicitly activates it:

lh.backend.allow_firmware_planning = True if script_mode == "execution" else 0

Example:

well_list = ["A1", "D4", "B10", "E1", "A4", "H1", "C10"] 

len(well_list)
>> 7

When aspirating/dispensing with allow_firmware_planning = True the command will automagically recognise which pipetting action it can perform in parallel!

i.e. this single command looks like this:

 Wells:   |  A1  |  D4  | B10  |  E1  |  A4  |  H1  | G10  |
------------------------------------------------------------
 Round 0: |  ch0 |      |      | ch3  |      | ch5  |      |
 Round 1: |      | ch1  |      |      |      |      |      |
 Round 2: |      |      | ch2  |      |      |      | ch6  |
 Round 3: |      |      |      |      | ch4  |      |      |

…and yes: this firmware_planning completely changes the game for cherry-picking operations.
How? Because instead of performing 7 distinct aspirate/dispense commands your STAR(let) now performs only 4, representing a 42.9% time saving :fire:

I am not entirely sure whether this does solve what I interpret as your “occasionally cherry-pick / hit list might create a pickup pattern in which each channel is targeted for the same well” problem; it might (?), but I thought this was a good application to mention this STAR_firmware + PLR empowerment.

1 Like

what camillo said is true and very useful, but:

the problem here is entirely in PLR (LiquidHandler even, not STARBackend) so this flag won’t instantly solve the problem

the question in this thread I think is how we define the behavior on the front end right now. (while taking into account some robots like star have the powerful functions camillo mentioned)

1 Like

Ah yes this is the behavior I was expecting, when moving from Venus. That I can just throw a set of wells at the aspirate command and the firmware figures it out.

Gemini wrote me this little helper, thing is eventually I’ll need to pass in liquid classes etc… So eventually I’m basically just rewriting the asp/disp function with an additional check… which seems a bit silly?

async def execute_transfer_step(operation, wells, volumes, name):
    """
    Checks if all well objects in a list refer to the same physical well.
    Executes the operation as a batch or individually based on the check.
    """
    if not wells:
        return # Do nothing if the list of wells is empty

    # Create a set of unique (plate_name, well_name) tuples directly from the well objects
    unique_wells = {(well.parent.name, well.name) for well in wells}

    # Check if the set contains only one unique item
    if len(unique_wells) == 1:
        print(f"✅ All {name}s are for a single location. Processing one by one.")
        for well, vol in zip(wells, volumes):
            await operation(resources=[well], vols=[vol])
    else:
        print(f"⚠️ Multiple {name} locations found: {unique_wells}. Processing as a single batch.")
        await operation(resources=wells, vols=volumes)

I guess I’m just wondering if this is something that could be in the aspirate function to begin with?