From 1a45161b1abdc2beb0e18a64624341a66049ae62 Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Mon, 13 May 2024 23:09:29 -0400 Subject: [PATCH] docs(api): runtime parameters in Python API 2.18 (#14677) # Overview Full documentation for setting up and using runtime parameters in Python protocols. Closes RTC-416, RTC-428, RTC-429, RTC-430, RTC-451 # Test Plan - Check content and formatting of all pages in the [sandbox](http://sandbox.docs.opentrons.com/docs-rtp/v2/runtime_parameters.html) - Simulate all [code from use case pages](https://gist.github.com/ecormany/9f4d871851ec4baceb9fae177663dd00). # Changelog - New landing page for Runtime Parameters - 6 new subpages - Unhide `ProtocolContext.params` in API Reference. ~Note, this still needs a docstring, either here or in a separate PR.~ Added [docstring](http://sandbox.docs.opentrons.com/docs-rtp/v2/new_protocol_api.html#opentrons.protocol_api.ProtocolContext.params). # Review requests Read some or all of it, try the code, tell me what can be better. # Risk assessment None --------- Co-authored-by: Sanniti Pimpley --- api/docs/v2/conf.py | 1 + api/docs/v2/index.rst | 1 + api/docs/v2/new_labware.rst | 4 + api/docs/v2/new_protocol_api.rst | 2 +- api/docs/v2/parameters/choosing.rst | 53 ++++ api/docs/v2/parameters/defining.rst | 181 ++++++++++++ api/docs/v2/parameters/style.rst | 137 +++++++++ api/docs/v2/parameters/use_case_dry_run.rst | 127 ++++++++ .../v2/parameters/use_case_sample_count.rst | 273 ++++++++++++++++++ api/docs/v2/parameters/using_values.rst | 72 +++++ api/docs/v2/runtime_parameters.rst | 29 ++ .../protocol_api/protocol_context.py | 9 + 12 files changed, 888 insertions(+), 1 deletion(-) create mode 100644 api/docs/v2/parameters/choosing.rst create mode 100644 api/docs/v2/parameters/defining.rst create mode 100644 api/docs/v2/parameters/style.rst create mode 100644 api/docs/v2/parameters/use_case_dry_run.rst create mode 100644 api/docs/v2/parameters/use_case_sample_count.rst create mode 100644 api/docs/v2/parameters/using_values.rst create mode 100644 api/docs/v2/runtime_parameters.rst diff --git a/api/docs/v2/conf.py b/api/docs/v2/conf.py index 480cc2ed872..a0a0e546daf 100644 --- a/api/docs/v2/conf.py +++ b/api/docs/v2/conf.py @@ -444,5 +444,6 @@ ("py:class", r".*protocol_api\.deck.*"), ("py:class", r".*protocol_api\.config.*"), ("py:class", r".*opentrons_shared_data.*"), + ("py:class", r".*protocol_api._parameters.Parameters.*"), ("py:class", r'.*AbstractLabware|APIVersion|LabwareLike|LoadedCoreMap|ModuleTypes|NoneType|OffDeckType|ProtocolCore|WellCore'), # laundry list of not fully qualified things ] diff --git a/api/docs/v2/index.rst b/api/docs/v2/index.rst index 5e29296241d..29fad41865b 100644 --- a/api/docs/v2/index.rst +++ b/api/docs/v2/index.rst @@ -17,6 +17,7 @@ Welcome new_atomic_commands new_complex_commands robot_position + runtime_parameters new_advanced_running new_examples adapting_ot2_flex diff --git a/api/docs/v2/new_labware.rst b/api/docs/v2/new_labware.rst index 50428d4a232..a85512999c9 100644 --- a/api/docs/v2/new_labware.rst +++ b/api/docs/v2/new_labware.rst @@ -269,6 +269,8 @@ To use these optional methods, first create a liquid object with :py:meth:`.Prot Let's examine how these two methods work. The following examples demonstrate how to define colored water samples for a well plate and reservoir. +.. _defining-liquids: + Defining Liquids ================ @@ -291,6 +293,8 @@ This example uses ``define_liquid`` to create two liquid objects and instantiate The ``display_color`` parameter accepts a hex color code, which adds a color to that liquid's label when you import your protocol into the Opentrons App. The ``define_liquid`` method accepts standard 3-, 4-, 6-, and 8-character hex color codes. +.. _loading-liquids: + Labeling Wells and Reservoirs ============================= diff --git a/api/docs/v2/new_protocol_api.rst b/api/docs/v2/new_protocol_api.rst index 8aaddf98f8f..0fd8deb4afb 100644 --- a/api/docs/v2/new_protocol_api.rst +++ b/api/docs/v2/new_protocol_api.rst @@ -14,7 +14,7 @@ Protocols .. autoclass:: opentrons.protocol_api.ProtocolContext :members: - :exclude-members: location_cache, cleanup, clear_commands, params + :exclude-members: location_cache, cleanup, clear_commands Instruments =========== diff --git a/api/docs/v2/parameters/choosing.rst b/api/docs/v2/parameters/choosing.rst new file mode 100644 index 00000000000..2add49a0dd6 --- /dev/null +++ b/api/docs/v2/parameters/choosing.rst @@ -0,0 +1,53 @@ +:og:description: Advice on choosing effective parameters in Opentrons Python protocols. + +.. _good-rtps: + +************************ +Choosing Good Parameters +************************ + +The first decision you need to make when adding parameters to your protocol is "What should be parameterized?" Your goals in adding parameters should be the following: + +1. **Add flexibility.** Accommodate changes from run to run or from lab to lab. +2. **Work efficiently.** Don't burden run setup with too many choices or confusing options. +3. **Avoid errors.** Ensure that every combination of parameters produces an analyzable, runnable protocol. + +The trick to choosing good parameters is reasoning through the choices the protocol's users may make. If any of them lead to nonsensical outcomes or errors, adjust the parameters — or how your protocol :ref:`uses parameter values ` — to avoid those situations. + +Build on a Task +=============== + +Consider what scientific task is at the heart of your protocol, and build parameters that contribute to, rather than diverge from it. + +For example, it makes sense to add a parameter for number of samples to a DNA prep protocol that uses a particular reagent kit. But it wouldn't make sense to add a parameter for *which reagent kit* to use for DNA prep. That kind of parameter would affect so many aspects of the protocol that it would make more sense to maintain a separate protocol for each kit. + +Also consider how a small number of parameters can combine to produce many useful outputs. Take the serial dilution task from the :ref:`tutorial` as an example. We could add just three parameters to it: number of dilutions, dilution factor, and number of rows. Now that single protocol can produce a whole plate that gradually dilutes, a 2×4 grid that rapidly dilutes, and *thousands* of other combinations. + +Consider Contradictions +======================= + +Here's a common time-saving use of parameters: your protocol requires a 1-channel pipette and an 8-channel pipette, but it doesn't matter which mount they're attached to. Without parameters, you would have to assign the mounts in your protocol. Then if the robot is set up in the reverse configuration, you'd have to either physically swap the pipettes or modify your protocol. + +One way to get this information is to ask which mount the 1-channel pipette is on, and which mount the 8-channel pipette is on. But if a technician answers "left" to both questions — even by accident — the API will raise an error, because you can't load two pipettes on a single mount. It's no better to flip things around by asking which pipette is on the left mount, and which pipette is on the right mount. Now the technician can say that both mounts have a 1-channel pipette. This is even more dangerous, because it *might not* raise any errors in analysis. The protocol could run "successfully" on a robot with two 1-channel pipettes, but produce completely unintended results. + +The best way to avoid these contradictions is to collapse the two questions into one, with limited choices. Where are the pipettes mounted? Either the 1-channel is on the left and the 8-channel on the right, or the 8-channel is on the left and the 1-channel is on the right. This approach is best for several reasons: + +- It avoids analysis errors. +- It avoids potentially dangerous execution errors. +- It only requires answering one question instead of two. +- The :ref:`phrasing of the question and answer ` makes it clear that the protocol requires exactly one of each pipette type. + +Set Boundaries +============== + +Numerical parameters support minimum and maximum values, which you should set to avoid incorrect inputs that are outside of your protocol's possibile actions. + +Consider our earlier example of parameterizing serial dilution. Each of the three numerical parameters have logical upper and lower bounds, which we need to enforce to get sensible results. + +- *Number of dilutions* must be between 0 and 11 on a 96-well plate. And it may make sense to require at least 1 dilution. +- *Dilution factor* is a ratio, which we can express as a decimal number that must be between 0 and 1. +- *Number of rows* must be between 1 and 8 on a 96-well plate. + +What if you wanted to perform a dilution with 20 repetitions? It's possible with two 96-well plates, or with a 384-well plate. You could set the maximum for the number of dilutions to 24 and allow for these possibilities — either switching the plate type or loading an additional plate based on the provided value. + +But what if the technician wanted to do just 8 repetitions on a 384-well plate? That would require an additional parameter, an additional choice by the technician, and additional logic in your protocol code. It's up to you as the protocol author to decide if adding more parameters will make protocol setup overly difficult. Sometimes it's more efficient to work with two or three simple protocols rather than one that's long and complex. \ No newline at end of file diff --git a/api/docs/v2/parameters/defining.rst b/api/docs/v2/parameters/defining.rst new file mode 100644 index 00000000000..495b96eb6b1 --- /dev/null +++ b/api/docs/v2/parameters/defining.rst @@ -0,0 +1,181 @@ +:og:description: Define and set possible values for parameters in Opentrons Python protocols. + +.. _defining-rtp: + +******************* +Defining Parameters +******************* + +To use parameters, you need to define them in :ref:`a separate function ` within your protocol. Each parameter definition has two main purposes: to specify acceptable values, and to inform the protocol user what the parameter does. + +Depending on the :ref:`type of parameter `, you'll need to specify some or all of the following. + +.. list-table:: + :header-rows: 1 + + * - Attribute + - Details + * - ``variable_name`` + - + - A unique name for :ref:`referencing the parameter value ` elsewhere in the protocol. + - Must meet the usual requirements for `naming objects in Python `__. + * - ``display_name`` + - + - A label for the parameter shown in the Opentrons App or on the touchscreen. + - Maximum 30 characters. + * - ``description`` + - + - An optional longer explanation of what the parameter does, or how its values will affect the execution of the protocol. + - Maximum 100 characters. + * - ``default`` + - + - The value the parameter will have if the technician makes no changes to it during run setup. + * - ``minimum`` and ``maximum`` + - + - For numeric parameters only. + - Allows free entry of any value within the range (inclusive). + - Both values are required. + - Can't be used at the same time as ``choices``. + * - ``choices`` + - + - For numeric or string parameters. + - Provides a fixed list of values to choose from. + - Each choice has its own display name and value. + - Can't be used at the same time as ``minimum`` and ``maximum``. + * - ``units`` + - + - Optional, for numeric parameters with ``minimum`` and ``maximum`` only. + - Displays after the number during run setup. + - Does not affect the parameter's value or protocol execution. + - Maximum 10 characters. + + + +.. _add-parameters: + +The ``add_parameters()`` Function +================================= + +All parameter definitions are contained in a Python function, which must be named ``add_parameters`` and takes a single argument. Define ``add_parameters()`` before the ``run()`` function that contains protocol commands. + +The examples on this page assume the following definition, which uses the argument name ``parameters``. The type specification of the argument is optional. + +.. code-block:: + + def add_parameters(parameters: protocol_api.ProtocolContext.Parameters): + +Within this function definition, call methods on ``parameters`` to define parameters. The next section demonstrates how each type of parameter has its own method. + +.. _rtp-types: + +Types of Parameters +=================== + +The API supports four types of parameters: Boolean (:py:class:`bool`), integer (:py:class:`int`), floating point number (:py:class:`float`), and string (:py:class:`str`). It is not possible to mix types within a single parameter. + +Boolean Parameters +------------------ + +Boolean parameters are ``True`` or ``False`` only. + +.. code-block:: + + parameters.add_bool( + variable_name="dry_run", + display_name="Dry Run", + description="Skip incubation delays and shorten mix steps.", + default=False + ) + +During run setup, the technician can toggle between the two values. In the Opentrons App, Boolean parameters appear as a toggle switch. On the touchscreen, they appear as *On* or *Off*, for ``True`` and ``False`` respectively. + +.. versionadded:: 2.18 + +Integer Parameters +------------------ + +Integer parameters either accept a range of numbers or a list of numbers. You must specify one or the other; you can't create an open-ended prompt that accepts any integer. + +To specify a range, include ``minimum`` and ``maximum``. + +.. code-block:: + + parameters.add_int( + variable_name="volume", + display_name="Aspirate volume", + description="How much to aspirate from each sample.", + default=20, + minimum=10, + maximum=100, + unit="µL" + ) + +During run setup, the technician can enter any integer value from the minimum up to the maximum. Entering a value outside of the range will show an error. At that point, they can correct their custom value or restore the default value. + +To specify a list of numbers, include ``choices``. Each choice is a dictionary with entries for display name and value. The display names let you briefly explain the effect each choice will have. + +.. code-block:: + + parameters.add_int( + variable_name="volume", + display_name="Aspirate volume", + description="How much to aspirate from each sample.", + default=20, + choices=[ + {"display_name": "Low (10 µL)", "value": 10}, + {"display_name": "Medium (20 µL)", "value": 20}, + {"display_name": "High (50 µL)", "value": 50}, + ] + ) + +During run setup, the technician can choose from a menu of the provided choices. + +.. versionadded:: 2.18 + +Float Parameters +---------------- + +Float parameters either accept a range of numbers or a list of numbers. You must specify one or the other; you can't create an open-ended prompt that accepts any floating point number. + +Specifying a range or list is done exactly the same as in the integer examples above. The only difference is that all values must be floating point numbers. + +.. code-block:: + + parameters.add_float( + variable_name="volume", + display_name="Aspirate volume", + description="How much to aspirate from each sample.", + default=5.0, + choices=[ + {"display_name": "Low (2.5 µL)", "value": 2.5}, + {"display_name": "Medium (5 µL)", "value": 5.0}, + {"display_name": "High (10 µL)", "value": 10.0}, + ] + ) + +.. versionadded:: 2.18 + +String Parameters +----------------- + +String parameters only accept a list of values. You can't currently prompt for free text entry of a string value. + +To specify a list of strings, include ``choices``. Each choice is a dictionary with entries for display name and value. Only the display name will appear during run setup. + +A common use for string display names is to provide an easy-to-read version of an API load name. You can also use them to briefly explain the effect each choice will have. + +.. code-block:: + + parameters.add_str( + variable_name="pipette", + display_name="Pipette type", + choices=[ + {"display_name": "1-Channel 50 µL", "value": "flex_1channel_50"}, + {"display_name": "8-Channel 50 µL", "value": "flex_8channel_50"}, + ], + default="flex_1channel_50", + ) + +During run setup, the technician can choose from a menu of the provided choices. + +.. versionadded:: 2.18 diff --git a/api/docs/v2/parameters/style.rst b/api/docs/v2/parameters/style.rst new file mode 100644 index 00000000000..04e4ef1e36f --- /dev/null +++ b/api/docs/v2/parameters/style.rst @@ -0,0 +1,137 @@ +:og:description: Style and usage guidance for parameters in Opentrons Python protocols. + +.. _rtp-style: + +********************* +Parameter Style Guide +********************* + +It's important to write clear names and descriptions when you :ref:`define parameters ` in your protocols. Clarity improves the user experience for the technicians who run your protocols. They rely on your parameter names and descriptions to understand how the robot will function when running your protocol. + +Adopting the advice of this guide will help make your protocols clear, consistent, and ultimately easy to use. It also aligns them with protocols in the `Opentrons Protocol Library `_, which can help others access and replicate your science. + +General Guidance +================ + +**Parameter names are nouns.** Parameters should be discrete enough that you can describe them in a single word or short noun phrase. ``display_name`` is limited to 30 characters, and you can add more context in the description. + +Don't ask questions or put other sentence punctuation in parameter names. For example: + +.. list-table:: + + * - ✅ Dry run + - ❌ Dry run? + * - ✅ Sample count + - ❌ How many samples? + * - ✅ Number of samples + - ❌ Number of samples to process. + + +**Parameter descriptions explain actions.** In one or two clauses or sentences, state when and how the parameter value is used in the protocol. Don't merely restate the parameter name. + +Punctuate descriptions as sentences, even if they aren't complete sentences. For example: + +.. list-table:: + :header-rows: 1 + :widths: 1 3 + + * - Parameter name + - Parameter description + * - Dry run + - + - ✅ Skip incubation delays and shorten mix steps. + - ❌ Whether to do a dry run. + * - Aspirate volume + - + - ✅ How much to aspirate from each sample. + - ❌ Volume that the pipette will aspirate + * - Dilution factor + - + - ✅ Each step uses this ratio of total liquid to original solution. Express the ratio as a decimal. + - ❌ total/diluent ratio for the process + +Not every parameter requires a description! For example, in a protocol that uses only one pipette, it would be difficult to explain a parameter named "Pipette type" without repeating yourself. In a protocol that offers parameters for two different pipettes, it may be useful to summarize what steps each pipette performs. + +**Use sentence case for readability**. Sentence case means adding a capital letter to *only* the first word of the name and description. This gives your parameters a professional appearance. Keep proper names capitalized as they would be elsewhere in a sentence. For example: + +.. list-table:: + + * - ✅ Number of samples + - ❌ number of samples + * - ✅ Temperature Module slot + - ❌ Temperature module slot + * - ✅ Dilution factor + - ❌ Dilution Factor + +**Use numerals for all numbers.** In a scientific context, this includes single-digit numbers. Additionally, punctuate numbers according to the needs of your protocol's users. If you plan to share your protocol widely, consider using American English number punctuation (comma for thousands separator; period for decimal separator). + +**Order choices logically.** Place items within the ``choices`` attribute in the order that makes sense for your application. + +Numeric choices should either ascend or descend. Consider an offset parameter with choices. Sorting according to value is easy to use in either direction, but sorting by absolute value is difficult: + +.. list-table:: + + * - ✅ -3, -2, -1, 0, 1, 2, 3 + - ❌ 0, 1, -1, 2, -2, 3, -3 + * - ✅ 3, 2, 1, 0, -1, -2, -3 + - + +String choices may have an intrinsic ordering. If they don't, fall back to alphabetical order. + +.. list-table:: + :header-rows: 1 + + * - Parameter name + - Parameter description + * - Liquid color + - + - ✅ Red, Orange, Yellow, Green, Blue, Violet + - ❌ Blue, Green, Orange, Red, Violet, Yellow + * - Tube brand + - + - ✅ Eppendorf, Falcon, Generic, NEST + - ❌ Falcon, NEST, Eppendorf, Generic + +Type-Specific Guidance +====================== + +Booleans +-------- + +The ``True`` value of a Boolean corresponds to the word *On* and the ``False`` value corresponds to the word *Off*. + +**Avoid double negatives.** These are difficult to understand and may lead to a technician making an incorrect choice. Remember that negation can be part of a word's meaning! For example, it's difficult to reason about what will happen when a parameter named "Deactivate module" is set to "Off". + +**When in doubt, clarify in the description.** If you feel like you need to add extra clarity to your Boolean choices, use the phrase "When on" or "When off" at the beginning of your description. For example, a parameter named "Dry run" could have the description "When on, skip protocol delays and return tips instead of trashing them." + +Number Choices +-------------- + +**Don't repeat text in choices.** Rely on the name and description to indicate what the number refers to. It's OK to add units to the display names of numeric choices, because the ``unit`` attribute is ignored when you specify ``choices``. + +.. list-table:: + :header-rows: 1 + + * - Parameter name + - Parameter description + * - Number of columns + - + - ✅ 1, 2, 3 + - ❌ 1 column, 2 columns, 3 columns + * - Aspirate volume + - + - ✅ 10 µL, 20 µL, 50 µL + - ✅ Low (10 µL), Medium (20 µL), High (50 µL) + - ❌ Low volume, Medium volume, High volume + +**Use a range instead of choices when all values are acceptable.** It's faster and easier to enter a numeric value than to choose from a long list. For example, a "Number of columns" parameter that accepts any number 1 through 12 should specify a ``minimum`` and ``maximum``, rather than ``choices``. However, if the application requires that the parameter only accepts even numbers, you need to specify choices (2, 4, 6, 8, 10, 12). + +Strings +------- + +**Avoid strings that are synonymous with "yes" and "no".** When presenting exactly two string choices, consider their meaning. Can they be rephrased in terms of "yes/no", "true/false", or "on/off"? If no, then a string parameter is appropriate. If yes, it's better to use a Boolean, which appears in run setup as a toggle rather than a dropdown menu. + + - ✅ Blue, Red + - ✅ Left-to-right, Right-to-left + - ❌ Include, Exclude + - ❌ Yes, No diff --git a/api/docs/v2/parameters/use_case_dry_run.rst b/api/docs/v2/parameters/use_case_dry_run.rst new file mode 100644 index 00000000000..d23cd2aeb9c --- /dev/null +++ b/api/docs/v2/parameters/use_case_dry_run.rst @@ -0,0 +1,127 @@ +:og:description: How to set up and use a dry run parameter in an Opentrons Python protocol. + +.. _use-case-dry-run: + +**************************** +Parameter Use Case – Dry Run +**************************** + +When testing out a new protocol, it's common to perform a dry run to watch your robot go through all the steps without actually handling samples or reagents. This use case explores how to add a single Boolean parameter for whether you're performing a dry run. + +The code examples will show how this single value can control: + +- Skipping module actions and long delays. +- Reducing mix repetitions to save time. +- Returning tips (that never touched any liquid) to their racks. + +To keep things as simple as possible, this use case only focuses on setting up and using the value of the dry run parameter, which could be just one of many parameters in a complete protocol. + +Dry Run Definition +================== + +First, we need to set up the dry run parameter. We want to set up a simple yes/no choice for the technician running the protocol, so we'll use a Boolean parameter:: + + def add_parameters(parameters): + + parameters.add_bool( + variable_name="dry_run", + display_name="Dry Run", + description=( + "Skip delays," + " shorten mix steps," + " and return tips to their racks." + ), + default=False + ) + +This parameter is set to ``False`` by default, assuming that most runs will be live runs. In other words, during run setup the technician will have to change the parameter setting to perform a dry run. If they leave it as is, the robot will perform a live run. + +Additionally, since "dry run" can have different meanings in different contexts, it's important to include a ``description`` that indicates exactly what the parameter will control — in this case, three things. The following sections will show how to accomplish each of those when the dry run parameter is set to ``True``. + +Skipping Delays +=============== + +Many protocols have built-in delays, either for a module to work or to let a reaction happen passively. Lengthy delays just get in the way when verifying a protocol with a dry run. So wherever the protocol calls for a delay, we can check the value of ``protocol.params.dry_run`` and make the protocol behave accordingly. + +To start, let's consider a simple :py:meth:`.delay` command. We can wrap it in an ``if`` statement such that the delay will only execute when the run is *not* a dry run:: + + if protocol.params.dry_run is False: + protocol.delay(minutes=5) + +You can extend this approach to more complex situations, like module interactions. For example, in a protocol that moves a plate to the Thermocycler for an incubation, you'll want to perform all the movement steps — opening and closing the module lid, and moving the plate to and from the block — but skip the heating and cooling time. The simplest way to do this is, like in the delay example above, to wrap each skippable command:: + + protocol.move_labware(labware=plate, new_location=tc_mod, use_gripper=True) + if protocol.params.dry_run is False: + tc_mod.set_block_temperature(4) + tc_mod.set_lid_temperature(100) + tc_mod.close_lid() + pcr_profile = [ + {"temperature": 68, "hold_time_seconds": 180}, + {"temperature": 98, "hold_time_seconds": 180}, + ] + if protocol.params.dry_run is False: + tc_mod.execute_profile( + steps=pcr_profile, repetitions=1, block_max_volume=50 + ) + tc_mod.open_lid() + +Shortening Mix Steps +==================== + +Similar to delays, mix steps can take a long time because they are inherently repetitive actions. Mixing ten times takes ten times as long as mixing once! To save time, set a mix repetitions variable based on the value of ``protocol.params.dry_run`` and pass that to :py:meth:`.mix`:: + + if protocol.params.dry_run is True: + mix_reps = 1 + else: + mix_reps = 10 + pipette.mix(repetitions=mix_reps, volume=50, location=plate["A1"].bottom()) + +Note that this checks whether the dry run parameter is ``True``. If you prefer to set up all your ``if`` statements to check whether it's ``False``, you can reverse the logic:: + + if protocol.params.dry_run is False: + mix_reps = 10 + else: + mix_reps = 1 + +Returning Tips +============== + +Tips used in a dry run should be reusable — for another dry run, if nothing else. It doesn't make sense to dispose of them in a trash container, unless you specifically need to test movement to the trash. You can choose whether to use :py:meth:`.drop_tip` or :py:meth:`.return_tip` based on the value of ``protocol.params.dry_run``. If the protocol doesn't have too many tip drop actions, you can use an ``if`` statement each time:: + + if protocol.params.dry_run is True: + pipette.return_tip() + else: + pipette.drop_tip() + +However, repeating this block every time you handle tips could significantly clutter your code. Instead, you could define it as a function:: + + def return_or_drop(pipette): + if protocol.params.dry_run is True: + pipette.return_tip() + else: + pipette.drop_tip() + +Then call that function throughout your protocol:: + + pipette.pick_up_tip() + return_or_drop(pipette) + +.. note:: + + It's generally better to define a standalone function, rather than adding a method to the :py:class:`.InstrumentContext` class. This makes your custom, parameterized commands stand out from API methods in your code. + +Additionally, if your protocol uses enough tips that you have to replenish tip racks, you'll need separate behavior for dry runs and live runs. In a live run, once you've used all the tips, the rack is empty, because the tips are in the trash. In a dry run, once you've used all the tips in a rack, the rack is *full*, because you returned the tips. + +The API has methods to handle both of these situations. To continue using the same tip rack without physically replacing it, call :py:meth:`.reset_tipracks`. In the live run, move the empty tip rack off the deck and move a full one into place:: + + if protocol.params.dry_run is True: + pipette.reset_tipracks() + else: + protocol.move_labware( + labware=tips_1, new_location=chute, use_gripper=True + ) + protocol.move_labware( + labware=tips_2, new_location="C3", use_gripper=True + ) + +You can modify this code for similar cases. You may be moving tip racks by hand, rather than with the gripper. Or you could even mix the two, moving the used (but full) rack off-deck by hand — instead of dropping it down the chute, spilling all the tips — and have the gripper move a new rack into place. Ultimately, it's up to you to fine-tune your dry run behavior, and communicate it to your protocol's users with your parameter descriptions. diff --git a/api/docs/v2/parameters/use_case_sample_count.rst b/api/docs/v2/parameters/use_case_sample_count.rst new file mode 100644 index 00000000000..15933752592 --- /dev/null +++ b/api/docs/v2/parameters/use_case_sample_count.rst @@ -0,0 +1,273 @@ +:og:description: How to set up and use a sample count parameter in an Opentrons Python protocol. + +.. _use-case-sample-count: + +********************************* +Parameter Use Case – Sample Count +********************************* + +Choosing how many samples to process is important for efficient automation. This use case explores how a single parameter for sample count can have pervasive effects throughout a protocol. The examples are adapted from an actual parameterized protocol for DNA prep. The sample code will use 8-channel pipettes to process 8, 16, 24, or 32 samples. + +At first glance, it might seem like sample count would primarily affect liquid transfers to and from sample wells. But when using the Python API's full range of capabilities, it affects: + +- How many tip racks to load. +- The initial volume and placement of reagents. +- Pipetting to and from samples. +- If and when tip racks need to be replaced. + +To keep things as simple as possible, this use case only focuses on setting up and using the value of the sample count parameter, which is just one of several parameters present in the full protocol. + +From Samples to Columns +======================= + +First of all, we need to set up the sample count parameter so it's both easy for technicians to understand during protocol setup and easy for us to use in the protocol's ``run()`` function. + +We want to limit the number of samples to 8, 16, 24, or 32, so we'll use an integer parameter with choices:: + + def add_parameters(parameters): + + parameters.add_int( + variable_name="sample_count", + display_name="Sample count", + description="Number of input DNA samples.", + default=24, + choices=[ + {"display_name": "8", "value": 8}, + {"display_name": "16", "value": 16}, + {"display_name": "24", "value": 24}, + {"display_name": "32", "value": 32}, + ] + ) + +All of the possible values are multiples of 8, because the protocol will use an 8-channel pipette to process an entire column of samples at once. Considering how 8-channel pipettes access wells, it may be more useful to operate with a *column count* in code. We can set a ``column_count`` very early in the ``run()`` function by accessing the value of ``params.sample_count`` and dividing it by 8:: + + def run(protocol): + + column_count = protocol.params.sample_count // 8 + +Most examples below will use ``column_count``, rather than redoing (and retyping!) this calculation multiple times. + +Loading Tip Racks +================= + +Tip racks come first in most protocols. To ensure that the protocol runs to completion, we need to load enough tip racks to avoid running out of tips. + +We could load as many tip racks as are needed for our maximum number of samples, but that would be suboptimal. Run setup is faster when the technician doesn't have to load extra items onto the deck. So it's best to examine the protocol's steps and determine how many racks are needed for each value of ``sample_count``. + +In the case of this DNA prep protocol, we can create formulas for the number of 200 µL and 50 µL tip racks needed. The following factors go into these computations: + +- 50 µL tips + - 1 fixed action that picks up once per protocol. + - 7 variable actions that pick up once per sample column. +- 200 µL tips + - 2 fixed actions that pick up once per protocol. + - 11 variable actions that pick up once per sample column. + +Since each tip rack has 12 columns, divide the number of pickup actions by 12 to get the number of racks needed. And we always need to round up — performing 13 pickups requires 2 racks. The :py:func:`math.ceil` method rounds up to the nearest integer. We'll add ``from math import ceil`` at the top of the protocol and then calculate the number of tip racks as follows:: + + tip_rack_50_count = ceil((1 + 7 * column_count) / 12) + tip_rack_200_count = ceil((2 + 13 * column_count) / 12) + +Running the numbers shows that the maximum combined number of tip racks is 7. Now we have to decide where to load up to 7 racks, working around the modules and other labware on the deck. Assuming we're running this protocol on a Flex with staging area slots, they'll all fit! (If you don't have staging area slots, you can load labware off-deck instead.) We'll reserve these slots for the different size racks:: + + tip_rack_50_slots = ["B3", "C3", "B4"] + tip_rack_200_slots = ["A2", "B2", "A3", "A4"] + +Finally, we can combine this information to call :py:meth:`~.ProtocolContext.load_labware`. Depending on the number of racks needed, we'll slice that number of elements from the slot list and use a `list comprehension `__ to gather up the loaded tip racks. For the 50 µL tips, this would look like:: + + tip_racks_50 = [ + protocol.load_labware( + load_name="opentrons_flex_96_tiprack_50ul", + location=slot + ) + for slot in tip_rack_50_slots[:tip_rack_50_count] + ] + +Then we can associate those lists of tip racks directly with each pipette as we load them. All together, the start of our ``run()`` function looks like this:: + + # calculate column count from sample count + column_count = protocol.params.sample_count // 8 + + # calculate number of required tip racks + tip_rack_50_count = ceil((1 + 7 * column_count) / 12) + tip_rack_200_count = ceil((2 + 13 * column_count) / 12) + + # assign tip rack locations (maximal case) + tip_rack_50_slots = ["B3", "C3", "B4"] + tip_rack_200_slots = ["A2", "B2", "A3", "A4"] + + # create lists of loaded tip racks + # limit to number of needed racks for each type + tip_racks_50 = [ + protocol.load_labware( + load_name="opentrons_flex_96_tiprack_50ul", + location=slot + ) + for slot in tip_rack_50_slots[:tip_rack_50_count] + ] + tip_racks_200 = [ + protocol.load_labware( + load_name="opentrons_flex_96_tiprack_200ul", + location=slot + ) + for slot in tip_rack_200_slots[:tip_rack_200_count] + ] + + pipette_50 = protocol.load_instrument( + instrument_name="flex_8channel_50", + mount="right", + tip_racks=tip_racks_50 + ) + pipette_1000 = protocol.load_instrument( + instrument_name="flex_1channel_1000", + mount="left", + tip_racks=tip_racks_200 + ) + +This code will load as few as 3 tip racks and as many as 7, and associate them with the correct pipettes — all based on a single choice from a dropdown menu at run setup. + +Loading Liquids +=============== + +Next come the reagents, samples, and the labware that holds them. + +The required volume of each reagent is dependent on the sample count. While the full protocol defines more than ten liquids, we'll show three reagents plus the samples here. + +First, let's load a reservoir and :ref:`define ` the three example liquids. Definitions only specify the name, description, and display color, so our sample count parameter doesn't come into play yet:: + + # labware to hold reagents + reservoir = protocol.load_labware( + load_name="nest_12_reservoir_15ml", location="C2" + ) + + # reagent liquid definitions + ampure_liquid = protocol.define_liquid( + name="AMPure", description="AMPure Beads", display_color="#704848" + ) + tagstop_liquid = protocol.define_liquid( + name="TAGSTOP", description="Tagmentation Stop", display_color="#FF0000" + ) + twb_liquid = protocol.define_liquid( + name="TWB", description="Tagmentation Wash Buffer", display_color="#FFA000" + ) + +Now we'll bring sample count into consideration as we :ref:`load the liquids `. The application requires the following volumes for each column of samples: + +.. list-table:: + :header-rows: 1 + + * - Liquid + - | Volume + | (µL per column) + * - AMPure Beads + - 180 + * - Tagmentation Stop + - 10 + * - Tagmentation Wash Buffer + - 900 + +To calculate the total volume for each liquid, we'll multiply these numbers by ``column_count`` and by 1.1 (to ensure that the pipette can aspirate the required volume without drawing in air at the bottom of the well). This calculation can be done inline as the ``volume`` value of :py:meth:`.load_liquid`:: + + reservoir["A1"].load_liquid( + liquid=ampure_liquid, volume=180 * column_count * 1.1 + ) + reservoir["A2"].load_liquid( + liquid=tagstop_liquid, volume=10 * column_count * 1.1 + ) + reservoir["A4"].load_liquid( + liquid=twb_liquid, volume=900 * column_count * 1.1 + ) + +Now, for example, the volume of AMPure beads to load will vary from 198 µL for a single sample column up to 792 µL for four columns. + +.. tip:: + + Does telling a technician to load 792 µL of a liquid seem overly precise? Remember that you can perform any calculation you like to set the value of ``volume``! For example, you could round the AMPure volume up to the nearest 10 µL:: + + volume=ceil((180 * column_count * 1.1) / 10) * 10 + +Finally, it's good practice to label the wells where the samples reside. The sample plate starts out atop the Heater-Shaker Module: + +.. code-block:: + + hs_mod = protocol.load_module( + module_name="heaterShakerModuleV1", location="D1" + ) + hs_adapter = hs_mod.load_adapter(name="opentrons_96_pcr_adapter") + sample_plate = hs_adapter.load_labware( + name="opentrons_96_wellplate_200ul_pcr_full_skirt", + label="Sample Plate", + ) + +Now we can construct a ``for`` loop to label each sample well with ``load_liquid()``. The simplest way to do this is to combine our original *sample count* with the fact that the :py:meth:`.Labware.wells()` accessor returns wells top-to-bottom, left-to-right:: + + # define sample liquid + sample_liquid = protocol.define_liquid( + name="Samples", description=None, display_color="#52AAFF" + ) + + # load 40 µL in each sample well + for w in range(protocol.params.sample_count): + sample_plate.wells()[w].load_liquid(liquid=sample_liquid, volume=40) + +Processing Samples +================== + +When it comes time to process the samples, we'll return to working by column, since the protocol uses an 8-channel pipette. There are many pipetting stages in the full protocol, but this section will examine just the stage for adding the Tagmentation Stop liquid. The same techniques would apply to similar stages. + +For pipetting in the original sample locations, we'll command the 50 µL pipette to move to some or all of A1–A4 on the sample plate. Similar to when we loaded tip racks earlier, we can use ``column_count`` to slice a list containing these well names, and then iterate over that list with a ``for`` loop:: + + for w in ["A1", "A2", "A3", "A4"][:column_count]: + pipette_50.pick_up_tip() + pipette_50.aspirate(volume=13, location=reservoir["A2"].bottom()) + pipette_50.dispense(volume=3, location=reservoir["A2"].bottom()) + pipette_50.dispense(volume=10, location=sample_plate[w].bottom()) + pipette_50.move_to(location=sample_plate[w].bottom()) + pipette_50.mix(repetitions=10, volume=20) + pipette_50.blow_out(location=sample_plate[w].top(z=-2)) + pipette_50.drop_tip() + +Each time through the loop, the pipette will fill from the same well of the reservoir and then dispense (and mix and blow out) in a different column of the sample plate. + +Later steps of the protocol will move intermediate samples to the middle of the plate (columns 5–8) and final samples to the right side of the plate (columns 9–12). When moving directly from one set of columns to another, we have to track *both lists* with the ``for`` loop. The :py:func:`zip` function lets us pair up the lists of well names and step through them in parallel:: + + for initial, intermediate in zip( + ["A1", "A2", "A3", "A4"][:column_count], + ["A5", "A6", "A7", "A8"][:column_count], + ): + pipette_50.pick_up_tip() + pipette_50.aspirate(volume=13, location=sample_plate[initial]) + pipette_50.dispense(volume=13, location=sample_plate[intermediate]) + pipette_50.drop_tip() + +This will transfer from column 1 to 5, 2 to 6, and so on — depending on the number of samples chosen during run setup. + +Replenishing Tips +================= + +For the higher values of ``protocol.params.sample_count``, the protocol will load tip racks in the staging area slots (column 4). Since pipettes can't reach these slots, we need to move these tip racks into the working area (columns 1–3) before issuing a pipetting command that targets them, or the API will raise an error. + +A protocol without parameters will always run out of tips at the same time — just add a :py:meth:`.move_labware` command when that happens. But as we saw in the Processing Samples section above, our parameterized protocol will go through tips at a different rate depending on the sample count. + +In our simplified example, we know that when the sample count is 32, the first 200 µL tip rack will be exhausted after three stages of pipetting using the 1000 µL pipette. So, after that step, we could add:: + + if protocol.params.sample_count == 32: + protocol.move_labware( + labware=tip_racks_200[0], + new_location=chute, + use_gripper=True, + ) + protocol.move_labware( + labware=tip_racks_200[-1], + new_location="A2", + use_gripper=True, + ) + +This will replace the first 200 µL tip rack (in slot A2) with the last 200 µL tip rack (in the staging area). + +However, in the full protocol, sample count is not the only parameter that affects the rate of tip use. It would be unwieldy to calculate in advance all the permutations of when tip replenishment is necessary. Instead, before each stage of the protocol, we could use :py:obj:`.Well.has_tip()` to check whether the first tip rack is empty. If the *last well* of the rack is empty, we can assume that the entire rack is empty and needs to be replaced:: + + if tip_racks_200[0].wells()[-1].has_tip is False: + # same move_labware() steps as above + +For a protocol that uses tips at a faster rate than this one — such that it might exhaust a tip rack in a single ``for`` loop of pipetting steps — you may have to perform such checks even more frequently. You can even define a function that counts tips or performs ``has_tip`` checks in combination with picking up a tip, and use that instead of :py:meth:`.pick_up_tip` every time you pipette. The built-in capabilities of Python and the methods of the Python Protocol API give you the flexibility to add this kind of smart behavior to your protocols. diff --git a/api/docs/v2/parameters/using_values.rst b/api/docs/v2/parameters/using_values.rst new file mode 100644 index 00000000000..b49afdac974 --- /dev/null +++ b/api/docs/v2/parameters/using_values.rst @@ -0,0 +1,72 @@ +:og:description: Access parameter values in Opentrons Python protocols. + +.. _using-rtp: + +**************** +Using Parameters +**************** + +Once you've :ref:`defined parameters `, their values are accessible anywhere within the ``run()`` function of your protocol. + +The ``params`` Object +===================== + +Protocols with parameters have a :py:obj:`.ProtocolContext.params` object, which contains the values of all parameters as set during run setup. Each attribute of ``params`` corresponds to the ``variable_name`` of a parameter. + +For example, consider a protocol that defines the following three parameters: + +- ``add_bool`` with ``variable_name="dry_run"`` +- ``add_int`` with ``variable_name="sample_count"`` +- ``add_float`` with ``variable_name="volume"`` + +Then ``params`` will gain three attributes: ``params.dry_run``, ``params.sample_count``, and ``params.volume``. You can use these attributes anywhere you want to access their values, including directly as arguments of methods. + +.. code-block:: + + if protocol.params.dry_run is False: + pipette.mix(repetitions=10, volume=protocol.params.volume) + +You can also save parameter values to variables with names of your choosing. + +Parameter Types +=============== + +Each attribute of ``params`` has the type corresponding to its parameter definition. Keep in mind the parameter's type when using its value in different contexts. + +Say you wanted to add a comment to the run log, stating how many samples the protocol will process. Since ``sample_count`` is an ``int``, you'll need to cast it to a ``str`` or the API will raise an error. + +.. code-block:: + + protocol.comment( + "Processing " + str(protocol.params.sample_count) + " samples." + ) + +Also be careful with ``int`` types when performing calculations: dividing an ``int`` by an ``int`` with the ``/`` operator always produces a ``float``, even if there is no remainder. The :ref:`sample count use case ` converts a sample count to a column count by dividing by 8 — but it uses the ``//`` integer division operator, so the result can be used for creating ranges, slicing lists, and as ``int`` argument values without having to cast it in those contexts. + +Limitations +=========== + +Since ``params`` is only available within the ``run()`` function, there are certain aspects of a protocol that parameter values can't affect. These include, but are not limited to the following: + + +.. list-table:: + :header-rows: 1 + + * - Information + - Location + * - ``import`` statements + - At the beginning of the protocol. + * - Robot type (Flex or OT-2) + - In the ``requirements`` dictionary. + * - API version + - In the ``requirements`` or ``metadata`` dictionary. + * - Protocol name + - In the ``metadata`` dictionary. + * - Protocol description + - In the ``metadata`` dictionary. + * - Protocol author + - In the ``metadata`` dictionary. + * - Other runtime parameters + - In the ``add_parameters()`` function. + * - Non-nested function definitions + - Anywhere outside of ``run()``. diff --git a/api/docs/v2/runtime_parameters.rst b/api/docs/v2/runtime_parameters.rst new file mode 100644 index 00000000000..71689eedb50 --- /dev/null +++ b/api/docs/v2/runtime_parameters.rst @@ -0,0 +1,29 @@ +:og:description: Define and customize parameters in Opentrons Python protocols. + +.. _runtime-parameters: + +****************** +Runtime Parameters +****************** + +.. toctree:: + parameters/choosing + parameters/defining + parameters/using_values + parameters/use_case_sample_count + parameters/use_case_dry_run + parameters/style + +Runtime parameters let you define user-customizable variables in your Python protocols. This gives you greater flexibility and puts extra control in the hands of the technician running the protocol — without forcing them to switch between lots of protocol files or write code themselves. + +This section begins with the fundamentals of runtime parameters: + +- Preliminary advice on how to :ref:`choose good parameters `, before you start writing code. +- The syntax for :ref:`defining parameters ` with boolean, numeric, and string values. +- How to :ref:`use parameter values ` in your protocol, building logic and API calls that implement the technician's choices. + +It continues with a selection of use cases and some overall style guidance. When adding parameters, you are in charge of the user experience when it comes time to set up the protocol! These pages outline best practices for making your protocols reliable and easy to use. + +- :ref:`Use case – sample count `: Change behavior throughout a protocol based on how many samples you plan to process. Setting sample count exactly saves time, tips, and reagents. +- :ref:`Use case – dry run `: Test your protocol, rather than perform a live run, just by flipping a toggle. +- :ref:`Style and usage `: When you're a protocol author, you write code. When you're a parameter author, you write words. Follow this advice to make things as clear as possible for the technicians who will run your protocol. diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index feb8f56d91c..07c4bdfff5d 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -224,6 +224,15 @@ def bundled_data(self) -> Dict[str, bytes]: @property @requires_version(2, 18) def params(self) -> Parameters: + """ + The values of runtime parameters, as set during run setup. + + Each attribute of this object corresponds to the ``variable_name`` of a parameter. + See :ref:`using-rtp` for details. + + Parameter values can only be set during run setup. If you try to alter the value + of any attribute of ``params``, the API will raise an error. + """ return self._params def cleanup(self) -> None: