Quadrant Definition Standardization Across PLR & Across Plate Formats

Hi everyone,

In many automated protocols we (automators) perform liquid transfers between different plate formats, e.g.: from a 96-wellplate to 24-wellplates, or from 384-wellplates to 96-wellplates.

To facilitate these liquid transfers it is useful to have a set of pre-defined transfer patterns which map individual samples between the plates involved.

1st Transfer Pattern

One such transfer pattern has already been established in PLR:

  • wellplate_format: 384
  • transfer_pattern: 384<->96
  • quadrant_type: “checkerboard”
  • quadrant_internal_fill_order: “row-major” order
  • number_of_parallel_channels_in_y_possible: 8 (with minimal y-distance=9mm)
  • PLR-discussed use case: 96head-based transfers

It’s implementation can be found in pylabrobot/resources/plate.py:

class Plate(ItemizedResource["Well"]):
    ...
    
  # TODO: add quadrant definition for 96-well plates & specify current
  # quadrant definition is only for 384-well plates
  def get_quadrant(self, quadrant: int) -> List["Well"]:
    """Return the wells in the specified quadrant. Quadrants are overlapping and refer to
    alternating rows and columns of the plate. Quadrant 1 contains A1, A3, C1, etc. Quadrant 2
    contains A2, quadrant 3 contains B1, and quadrant 4 contains B2."""

    if quadrant == 1:
      return [
        self.get_well((row, column))
        for row in range(0, self.num_items_y, 2)
        for column in range(0, self.num_items_x, 2)
      ]
    elif quadrant == 2:
      return [
        self.get_well((row, column))
        for row in range(0, self.num_items_y, 2)
        for column in range(1, self.num_items_x, 2)
      ]
    elif quadrant == 3:
      return [
        self.get_well((row, column))
        for row in range(1, self.num_items_y, 2)
        for column in range(0, self.num_items_x, 2)
      ]
    elif quadrant == 4:
      return [
        self.get_well((row, column))
        for row in range(1, self.num_items_y, 2)
        for column in range(1, self.num_items_x, 2)
      ]
    else:
      raise ValueError(f"Invalid quadrant number: {quadrant}. Quadrant must be 1, 2, 3, or 4.")

This 384<->96 transfer pattern is also already defined in the PLR docs “Using the 96 head”:

There the indexing of the different quadrant is shown as…


…i.e. left-top (quadrant_1) > right-top (quadrant_2) > left-bottom (quadrant_3) > right-bottom (quadrant_4)

N.B.: At the moment, this is a bug when used for any plate_format besides 384-wellplates - but is missing error raising.


I believe it is time to revisit this definition, and expand a standardized quadrant definition scheme across different plate_formats and quadrant_types for all of PLR.

N.B.: This is all part of what I consider “sample management” > subcategory: “process management”.
PLR should not dictate how programmers perform sample management, but instead PLR must empower sample management systems programmers might want to use.


Proposal

I propose the establishment of a modified .get_quadrant() method in Plate() which returns the desired quadrant, regardless of plate_format (!), based on 4 attributes:

  • quadrant: int
  • quadrant_type: Literal[ “checkerboard”, “block”]
  • quadrant_indexing_pattern: Literal[“N”, “reverse_N”, “Z”, “reverse_Z”]
  • quadrant_internal_fill_order: Literal[ “column-major”, “row-major”] = “column-major”

Quick examples:

new_wellplate_obj = Thermo_TS_96_wellplate_1200ul_Rb(
    name="new_96_wellplate",
    quadrant_type="block",
    quadrant_indexing_pattern="reverse_N",
    quadrant_internal_fill_order="column-major"
)

new_wellplate_obj.get_quadrant(1) 
# returns list of 24 well identifiers in column-major order,
# i.e. ["A1", "B1", "C1", "D1", "A2", "B2", ... , "D6"]

new_wellplate_obj.get_quadrant(2) 
# returns list of 24 well identifiers in column-major order,
# i.e. ["E1", "F1", "G1", "H1", "E2", "F2", ... , "H6"]

Explanations

(I am going to use 96-wellplates and 384-wellplates as examples but all plate_formats with an even number of columns and an even number of rows can be partitioned into “quadrants” / 4 zones <== assumption for this method).

Fill Order

All matrices, including plates, can be filled in two different orders:

  1. Row-major order (row-wise filling)

    • Filling proceeds across a row before moving to the next row.
    • Mostly used by manual handling of plates.
  2. Column-major order (column-wise filling)

    • Filling proceeds down a column before moving to the next column.
    • Common standard in robotic handling of plates due to parallelization opportunity of liquid transfer actions in the same x-coordinate(–> massive temporal acceleration)

Quadrant Types

Depending on 1) manual vs fully-automated processing, and 2) y-channel spacing ability, two common definitions for “quadrants” are:

  1. “block”
    • division of plate into 4 zones based on the plate’s 2 symmetric axes
  2. “checkerboard”
    • division of plate into 4 zones based on using every other row + every other column

250227_Moschner_explainer_quadrants

Quadrant Indexing Pattern

Finally, quadrants must have an unambiguous indexing pattern (regardless of quadrant type chosen).
Four different quadrant indexing patterns can be used based on the letter created when the first wells of each quadrant are connected:

  1. N
  2. reverse_N
  3. Z
  4. reverse_Z


Design Considerations

Specifying all these attributes / design factors for quadrant use should be possible at the level of calling a quadrant, i.e.

wellplate_obj.get_quadrant(
    quadrant=3,
    quadrant_type="block",
    quadrant_indexing_pattern="reverse_N",
    quadrant_internal_fill_order="column-major"
)

…but realistically this would be incredibly tedious.

Therefore, the .get_quadrant() method should have defaults that are defined during at the Plate() instantiation level (as shown in the quick example above).

This means that an automator decides during Plate instantiation to use one transfer pattern for their Plate object during that automated protocol.

This aims to empower any sample management scheme programmers might want to use.


Implementation Proposal

This means:

  1. Plate has to be updated to take these attributes, defaulting all of them to None → this ensures all existing Plate definitions won’t be broken in the process.
    (Pragmatic Programmer’s mindset: “Do No Harm”)
  2. Plate’s .get_quadrant() method has to be updated
  3. an error should be raised when Plate.get_quadrant() is called on a Plate object that has no defined quadrant attributes.
  4. an error should be raised when Plate.get_quadrant() is called on a Plate if the Plate contains uneven row-numbers and/or uneven plate-numbers.

Please let me know what you think about this development proposal.

:mechanical_arm:

2 Likes

Depending on potential changes made, we can use the infographics above to update our docs :slight_smile:

This is phenomenal documentation and illustrations to describe these complex visual relationships. Thanks for putting this together!

I’ve only ever seen/done “Z” + “Checkerboard” for the combining/splitting quadrants for going to the next 4-quad plate (ex. 4x 96 to 1x 384 or 1x 96 to 4x 24) and I do like the standardizing of it so it makes it equivalent between people/protocols.

I’m all for the power to fully customize something like this so that you can choose whichever pattern and fill order you prefer. I wonder if it makes sense to make this an aspect of the liquid transfer rather than an inherent attribute of Plate()? I may be misunderstanding the implementation suggestion but if I use a 384wp with these attributes filled out, would I be unable to do a different type of quadrant identifying and transfer?

1 Like

great illustrations as always!

I disagree with instantiating these parameters at plate init time because they are not fundamental physical attributes of a Plate. Second, when calling get_quadrant way down in the code (possibly different files) it will be ambiguous what’s going on. Third, I imagine if people use this they might want to switch it around. These arguments are all combined as “it is not part of the state of the plate”, it’s a higher level view of the plate, a view that changes over time, and it’s bad to mix that with attributes like size.

We should “do the boring thing” and provide one get_quadrant function defaulting to Z (current implementation). We could just define “quadrant 1 means this set of wells” instead of leaving that definition up to the user through a configuration. This is unambiguous. The user will know if they want wells in “N order”, they should get quadrants 3, 1, 4, 2. Thinking about these symbols as unordered identifiers, it makes a lot of sense to me. It’s like saying 1 = “top_left”, 2=“top_right”, etc. Just a symbol representing a set of wells.

It might be nice to have parameters for get_quadrant (the ones you proposed), so that numbers 1, 2, 3, 4 (as identifiers for the set of wells) match the user’s access sequence (ordered). That might make sense if there is an inherent ordering to the symbols representing quadrants, which I am not convinced is true.

What is the point of “block” quadrants? I get that it looks nice, but will anyone actually use it?

Happy you find it useful! Definitely a morning of procedural figure generation well spent :sweat_smile:

Yeah, this (“Z” + “Checkerboard”) seems to be the most common transfer pattern.
It’s great for 384<->4x96 transfers because the “Checkerboard” quadrant type ensures the 9mm minimal channel distance required for 8-channels, while the “Z” indexing pattern is intuitive (though it makes no difference to “inverse_N”.
But, I’ve found it to be an obstacle with 96<->4x24 transfers.
Both, the “Checkerboard” type and the “Z” indexing pattern, independently of each other, limit chronologically transferring only 4 rows at a time.

Ahh good question… I don’t think this is necessary / I am not sure I can imagine a way to make it work well.

Plate.get_quadrant() returns a list of well identifiers.

This then feeds into any pick_up_tips, aspirate, dispense, discard_tips cycle.

actually a list of Well instances, can directly be passed into LH functions :slight_smile:

1 Like

Ahhh, just answered that :slight_smile:
Here with a bit more detail:

Transferring 96<->4x24 using “Checkerboard” is highly inefficient:

  • as shown below you can only use 4 rows at a time.
  • “Block” quadrants enable the use of all 8 rows at the same time.
  • “Z” indexing pattern also means that only 4 rows can be used at a time (chronologically), because quadrant_2 is to the right of quadrant_1.
  • “reverse_N” defines quadrant_2 to be below quadrant_1, enabling (chronological) processing of both at the same time :slight_smile:

i.e. what works well for a 384-wellplate doesn’t work well for 96-wellplates.
This discrepancy between wellplate formats has been the trigger for making .get_quadrant() more useful (and the fact that currently it’s broken - i.e. doesn’t work for anything beyond 384-wellplates).

I’ve implemented this in my own automated Protocols (aPs) for a while. This is an idea to share its utility.

1 Like

That is true; but is this different to Plate.set_well_liquids(), a representation of the wells’ liquids with its use independent of the physical geometry of the plate, or the current use of Plate.get_quadrants()?

That is why the attributes store the information and are always callable via e.g. Plate. quadrant_type.

That is why Plate.quadrant() can overwrite the attributes during execution from the default generated during instantiation.
If people have private plate definitions and they want to, they can even set default quadrant attributes in the plate function definition itself (just not in the PLR Resource Library because others might want to change it).

With this update the quadrant design space would expand from currently being 1 to:

2 (quadrant_types) * 4 (quadrant_indexing_patterns) * 2 (quadrant_internal_fill_orders) = 16 transfer patterns.

Another feature this enables: currently quadrant usage in portrait mode is incredibly tedious. A flexible quadrant system makes it much easier. :slight_smile:

ok agree we should add quadrant type.

yes: liquids are a physical property of the well.

(there is an argument to be made to separate this further)

get_quadrant is a function: it would return the well for an identifier as requested at that time

It’s not obvious when you read the code.

It’s really just 1 → 2 (quadrant type) because the fill order, {reverse,0}{Z,N} is equivalent, just different identifiers.

It does highlight something else: instead of having 1 identifier per set of wells, we would then have 4: a {reverse,0}{Z,N} + {0,1,2,3}, and there are 4 combinations that all point to the same set of wells.

Could be easily visible in the Plate.__repr__ method?

I’m sorry, I don’t follow: What do you mean by {reverse,0}{Z,N} + {0,1,2,3}?
Particularly what do you mean by {reverse,0}?

I think this is an interesting point:
Currently, PLR has 2 (unofficial) types of Resources from my perspective:

  1. “normal” resources that represent a physical item
  2. “management” resources which actually do not represent a single physical item but instead manage an accumulation of physical items (only example at the moment ResourceStack)

Through this discussion it seems that Plate is actually already a hybrid “normal”/“management” resource at the moment:
it represents the physical item but via Plate.get_quadrant() it manages access to subsets of its children?

I could imagine removing .get_quadrant() all together, and instead refactoring it into a standalone management resource?
e.g.

QuadrantExtractor(
    plate=plate_obj,
    quadrant_type="block",
    <remaining attributes
) -> list of Well instances

That would make Plate a pure representation of the physical item… The question is whether this is necessary/wanted?

@rickwierenga and I had a quick online meeting clarifying some meaning, here is a summary of the conclusions we’ve drawn:

  • quadrant_indexing_patterns based on the name of the letter generated is indeed ambiguous and not necessary: instead we can simply call the quadrant directly based on an identifier showcasing the relative position of that quadrant to the plate’s origin: “top_left”, “top_right”, “bottom_left”, “bottom_right”.
    Calling Plate.get_quadrant() four times in this order is equivalent to the above-mentioned “Z” pattern but less ambiguous + reduces the number of attributes required.
  • quadrant_type being either “block” or “checkerboard” is very useful; will become an attribute of the method, default to “checkerboard”.
  • quadrant_internal_fill_order is very useful; will become an attribute of the method, default to “column-major”.

def get_quadrant(
    quadrant: Literal[
        "tl", "top_left",
        "tr", "top_right",
        "bl", "bottom_left",
        "br", "bottom_right"
        ],
    quadrant_type: Literal["block", "checkerboard"] = "checkerboard",
    quadrant_internal_fill_order: Literal["column-major", "row-major"] = "column-major"
)

I’ll aim to push the PR in the next 5 days.

Please provide more feedback in this thread in the meantime if you can think of ways to improve this standardization scheme / see potential issues with it :slight_smile:

1 Like

Visual explanation as to why the quadrant_indexing_pattern does not have to be explicitly stated but can instead be generated at will by the user:

…universal identifers:

…creation of patterns by choosing the order in which universal quadrant identifiers are being called:
250227_Moschner_explainer_universal_quadrant_ids

Thank you @rickwierenga for improving this proposal :muscle:

1 Like

Pull Request #410 to integrate this method (and the new Plate Quadrant Definition Standards associated with it) into PLR in progress.

Code examples are provided there.

1 Like

based

2 Likes

Update 2025-03-06: new Plate.get_quadrant() method merged into PLR in PR#410.

Plus, dedicated tutorial now available on the PLR docs pages: Tutorial: Plate Quadrants

Thank you everyone who contributed to this!

1 Like