diff --git a/docs/_exts/pylabrobot_cards/__init__.py b/docs/_exts/pylabrobot_cards/__init__.py new file mode 100644 index 00000000000..df0020efb85 --- /dev/null +++ b/docs/_exts/pylabrobot_cards/__init__.py @@ -0,0 +1 @@ +from .directives import setup # re-export for Sphinx diff --git a/docs/_exts/pylabrobot_cards/directives.py b/docs/_exts/pylabrobot_cards/directives.py new file mode 100644 index 00000000000..25c330373ef --- /dev/null +++ b/docs/_exts/pylabrobot_cards/directives.py @@ -0,0 +1,182 @@ +from docutils.parsers.rst import Directive, directives +from docutils import nodes +from .nodes import plr_card_grid_placeholder + + +def _split_tags(raw): + if not raw: + return [] + return [t.strip() for t in raw.split(",") if t.strip()] + + +def _ensure_env_map(env): + if not hasattr(env, "plr_cards"): + env.plr_cards = {} + return env.plr_cards + + +class _BaseCardDirective(Directive): + has_content = False + required_arguments = 0 + optional_arguments = 0 + final_argument_whitespace = False + option_spec = { + "header": directives.unchanged_required, + "card_description": directives.unchanged, + "image": directives.unchanged, + "image_hover": directives.unchanged, + "link": directives.unchanged_required, + "tags": directives.unchanged, + } + + def run(self): + env = self.state.document.settings.env + docname = env.docname + cards_map = _ensure_env_map(env) + cards = cards_map.setdefault(docname, []) + cards.append({ + "header": self.options.get("header", ""), + "desc": self.options.get("card_description", ""), + "image": self.options.get("image", ""), + "image_hover": self.options.get("image_hover", ""), + "link": self.options.get("link", ""), + "tags": _split_tags(self.options.get("tags", "")), + }) + # No visible node; actual rendering happens in the grid placeholder. + return [] + + +class PyLabRobotCard(_BaseCardDirective): + """ + Add a card. Aliases: + - .. customcarditem:: (compat) + - .. plrcard:: (PyLabRobot) + Options: + :header: Title (required) + :card_description: Short text + :image: path/to/image.png + :link: path/to/page.html (required) + :tags: Tag1, Tag2 + """ + + +class _BaseGridDirective(Directive): + has_content = False + + def run(self): + return [plr_card_grid_placeholder("")] + + +class PyLabRobotCardGrid(_BaseGridDirective): + """ + Insert a grid of the cards collected in this page. Aliases: + - .. cardgrid:: (compat) + - .. plrcardgrid:: (PyLabRobot) + """ + + +def _purge(app, env, docname): + if hasattr(env, "plr_cards") and docname in env.plr_cards: + del env.plr_cards[docname] + + +def _merge(app, env, docnames, other): + if not hasattr(other, "plr_cards"): + return + if not hasattr(env, "plr_cards"): + env.plr_cards = {} + env.plr_cards.update(other.plr_cards) + + +def _page_ctx(app, pagename, templatename, context, doctree): + env = app.builder.env + per_page = getattr(env, "plr_cards", {}).get(pagename, []) + all_tags = [] + for cards in getattr(env, "plr_cards", {}).values(): + for c in cards: + all_tags.extend(c.get("tags", [])) + all_tags = sorted(set(all_tags), key=str.lower) + context["plr_cards"] = per_page + context["plr_cards_all_tags"] = all_tags + +import os +from docutils import nodes + + +def _replace_placeholders(app, doctree, fromdocname): + env = app.builder.env + per_page = getattr(env, "plr_cards", {}).get(fromdocname, []) + + # prefix like "", "../", "../../" depending on nesting of fromdocname + docdir = os.path.dirname(fromdocname).replace("\\", "/") + depth = 0 if docdir == "" else docdir.count("/") + 1 + prefix = "../" * depth + + def is_url(p): + return p.startswith(("http://", "https://")) + + cards_render = [] + for c in per_page: + img = (c.get("image") or "").replace("\\", "/") + img_hover = (c.get("image_hover") or "").replace("\\", "/") + + image_url = "" + image_hover_url = "" + + # Resolve main image + if img: + if is_url(img) or img.startswith("/"): + image_url = img # absolute http(s) or site-root path + else: + if img.startswith("_static/"): + image_url = prefix + img + else: + image_url = prefix + img + + # Resolve hover image + if img_hover: + if is_url(img_hover) or img_hover.startswith("/"): + image_hover_url = img_hover + else: + if img_hover.startswith("_static/"): + image_hover_url = prefix + img_hover + else: + image_hover_url = prefix + img_hover + + cards_render.append({ + **c, + "image_url": image_url, + "image_hover_url": image_hover_url, + }) + + page_tags = sorted({t for c in per_page for t in c.get("tags", [])}, key=str.lower) + + for node in doctree.traverse(plr_card_grid_placeholder): + html = app.builder.templates.render("plr_card_grid.html", { + "cards": cards_render, + "all_tags": page_tags, + }) + raw = nodes.raw("", html, format="html") + node.replace_self(raw) + + +def setup(app): + from sphinx.application import Sphinx # noqa: F401 + + # Register directives (compat + PLR names) + app.add_directive("customcarditem", PyLabRobotCard) # compat + app.add_directive("plrcard", PyLabRobotCard) # PLR + app.add_directive("cardgrid", PyLabRobotCardGrid) # compat + app.add_directive("plrcardgrid", PyLabRobotCardGrid) # PLR + + # Events + app.connect("env-purge-doc", _purge) + app.connect("env-merge-info", _merge) + app.connect("html-page-context", _page_ctx) + app.connect("doctree-resolved", _replace_placeholders) + + return { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_exts/pylabrobot_cards/nodes.py b/docs/_exts/pylabrobot_cards/nodes.py new file mode 100644 index 00000000000..3a0e2cf87c5 --- /dev/null +++ b/docs/_exts/pylabrobot_cards/nodes.py @@ -0,0 +1,6 @@ +from docutils import nodes + + +class plr_card_grid_placeholder(nodes.General, nodes.Element): + """Placeholder node replaced by rendered card grid HTML.""" + pass diff --git a/docs/_static/cookbook_img/hi.png b/docs/_static/cookbook_img/hi.png new file mode 100644 index 00000000000..b224131f42d Binary files /dev/null and b/docs/_static/cookbook_img/hi.png differ diff --git a/docs/_static/cookbook_img/recipe_01_core_move.gif b/docs/_static/cookbook_img/recipe_01_core_move.gif new file mode 100644 index 00000000000..6dc47babac7 Binary files /dev/null and b/docs/_static/cookbook_img/recipe_01_core_move.gif differ diff --git a/docs/_static/cookbook_img/recipe_01_core_move_static.png b/docs/_static/cookbook_img/recipe_01_core_move_static.png new file mode 100644 index 00000000000..dee6a8327ab Binary files /dev/null and b/docs/_static/cookbook_img/recipe_01_core_move_static.png differ diff --git a/docs/_static/plr_cards.css b/docs/_static/plr_cards.css new file mode 100644 index 00000000000..abdba0e18bd --- /dev/null +++ b/docs/_static/plr_cards.css @@ -0,0 +1,223 @@ +/* ==== PyLabRobot Card Grid Styling ==== */ + +/* ==== Filter Bar ==== */ + +.plr-card-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.plr-tag-buttons { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 0.6rem; +} + +.plr-filter-btn { + display: inline-flex !important; + align-items: center; + justify-content: center; + padding: 0.45rem 1.1rem; + border: 1px solid var(--pst-color-border, #ccc); + border-radius: 20px; + background: var(--pst-color-surface, #f7f7f7); + font-size: 0.9rem; + font-weight: 500; + text-align: center; + white-space: nowrap; + cursor: pointer; + transition: all 0.15s ease-in-out; + color: var(--pst-color-text, #222); + min-width: 100px; + text-decoration: none !important; +} + +.plr-filter-btn:hover { + background: var(--pst-color-hover, #e9eef7); + border-color: var(--pst-color-primary, #1a4ed8); + color: var(--pst-color-primary, #1a4ed8); + transform: translateY(-1px); +} + +.plr-filter-btn.active { + background: var(--pst-color-primary, #1a4ed8); + color: #fff; + border-color: var(--pst-color-primary, #1a4ed8); + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(26, 78, 216, 0.25); +} + +/* ==== Grid Layout ==== */ + +.plr-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 1rem; + margin-top: 1rem; + width: 100%; + position: relative; +} + +/* ==== Card Core ==== */ + +.plr-card { + display: flex; + flex-direction: column; + border: 1px solid var(--pst-color-border, #e0e0e0); + border-radius: 12px; + background: var(--pst-color-background, #fff); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); + transition: transform 0.15s ease, box-shadow 0.25s ease, border-color 0.25s ease; + overflow: hidden; + opacity: 1; + transform: scale(1); + height: 100%; + animation: plrFadeIn 0.4s ease both; +} + +.plr-card.hidden { + opacity: 0; + transform: scale(0.97); + pointer-events: none; + transition: opacity 0.25s ease, transform 0.25s ease; +} + +.plr-card:hover { + transform: translateY(-4px); + border-color: var(--pst-color-primary, #1a4ed8); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); +} + +.plr-card a { + text-decoration: none; + color: inherit; + display: flex; + flex-direction: row; + align-items: stretch; + height: 100%; +} + +/* ==== Content Layout ==== */ + +.plr-card-content { + flex: 1; + display: flex; + align-items: stretch; + justify-content: space-between; + min-height: 220px; + width: 100%; +} + +.plr-card-text { + flex: 1 1 60%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + padding: 1.5rem 1.7rem; +} + +.plr-card-text h4 { + margin: 0 0 0.6rem 0; + font-size: 1.2rem; + font-weight: 600; + color: var(--pst-color-primary, #1a4ed8); + line-height: 1.3; +} + +.plr-card-text p { + margin: 0; + margin-top: 0.25rem; + color: var(--pst-color-text, #333); + font-size: 0.95rem; + line-height: 1.55; + visibility: visible; + opacity: 1; + display: block; +} + +/* ==== Image ==== */ + +.plr-card-image { + flex: 0 0 320px; + overflow: hidden; + position: relative; + border-left: 1px solid var(--pst-color-border, #e0e0e0); + background: var(--pst-color-surface, #fafafa); + transition: transform 0.35s ease, box-shadow 0.35s ease; +} + +/* Force repaint on hover image switch */ +.plr-card-image img { + position: relative; + z-index: 2; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 0; + transition: transform 0.35s ease, filter 0.3s ease, opacity 0.25s ease-in-out; + will-change: transform, filter, opacity; + isolation: isolate; + contain: paint; + backface-visibility: hidden; +} + +/* Image visual feedback */ +.plr-card:hover .plr-card-image img { + transform: scale(1.05); + filter: brightness(1.05); +} + +/* Add depth with subtle overlay */ +.plr-card-image::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.05), transparent); + opacity: 0; + transition: opacity 0.3s ease; + z-index: 1; +} + +.plr-card:hover .plr-card-image::after { + opacity: 1; +} + +/* ==== Animations ==== */ + +@keyframes plrFadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ==== Responsive ==== */ + +@media (max-width: 900px) { + .plr-card a { + flex-direction: column; + } + + .plr-card-text { + padding: 1rem 1.25rem; + } + + .plr-card-image { + width: 100%; + height: 220px; + border-left: none; + border-top: 1px solid var(--pst-color-border, #e0e0e0); + } + + .plr-filter-btn { + font-size: 0.85rem; + padding: 0.4rem 0.9rem; + min-width: auto; + } +} diff --git a/docs/_static/plr_cards.js b/docs/_static/plr_cards.js new file mode 100644 index 00000000000..48d30257fc3 --- /dev/null +++ b/docs/_static/plr_cards.js @@ -0,0 +1,141 @@ +(function () { + function $(sel, root) { return (root || document).querySelector(sel); } + function $all(sel, root) { return [].slice.call((root || document).querySelectorAll(sel)); } + + /* -------------------- FILTERING -------------------- */ + + function applyFilters() { + var activeTags = $all(".plr-filter-btn.active") + .map(b => b.getAttribute("data-tag")) + .filter(t => t && t !== "All"); + + var searchEl = $("#plr-card-search"); + var q = (searchEl ? searchEl.value : "").trim().toLowerCase(); + + var cards = $all(".plr-card"); + var visibleCount = 0; + + cards.forEach(function (card) { + var tags = (card.getAttribute("data-tags") || "").split(/\s+/).filter(Boolean); + var title = (card.getAttribute("data-title") || "").toLowerCase(); + var desc = (card.getAttribute("data-desc") || "").toLowerCase(); + + // Tag logic: show if card has *all* active tags + var tagOk = activeTags.length === 0 || activeTags.every(t => tags.includes(t)); + var textOk = !q || title.includes(q) || desc.includes(q); + + if (tagOk && textOk) { + card.classList.remove("hidden"); + card.style.display = ""; + visibleCount++; + } else { + card.classList.add("hidden"); + card.style.display = "none"; + } + }); + + var viewAll = $("#plr-view-all"); + if (viewAll) { + viewAll.style.display = visibleCount < cards.length ? "flex" : "none"; + } + } + + function onClickTag(e) { + var btn = e.target.closest(".plr-filter-btn"); + if (!btn) return; + + var tag = btn.getAttribute("data-tag"); + + if (tag === "All") { + // Reset all + $all(".plr-filter-btn").forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + } else { + // Toggle active state + btn.classList.toggle("active"); + // If any tag active, deactivate “All” + var allBtn = $(".plr-filter-btn[data-tag='All']"); + if (allBtn) { + if ($all(".plr-filter-btn.active").some(b => b.getAttribute("data-tag") !== "All")) + allBtn.classList.remove("active"); + else + allBtn.classList.add("active"); + } + } + + applyFilters(); + } + + function onSearch() { applyFilters(); } + + function resetFilters() { + $all(".plr-filter-btn").forEach(b => b.classList.remove("active")); + var first = $(".plr-filter-btn[data-tag='All']"); + if (first) first.classList.add("active"); + var search = $("#plr-card-search"); + if (search) search.value = ""; + applyFilters(); + try { window.scrollTo({ top: 0, behavior: "smooth" }); } catch (_) {} + } + + /* -------------------- HOVER IMAGE SWAP -------------------- */ + + function setupDelegatedImageHover() { + var grid = $(".plr-card-grid"); + if (!grid) return; + + $all(".plr-card-image img[data-hover]").forEach(function (img) { + var h = img.getAttribute("data-hover"); + if (h) { var pre = new Image(); pre.src = h; } + if (!img.getAttribute("data-src-original")) { + img.setAttribute("data-src-original", img.getAttribute("src") || ""); + } + }); + + grid.addEventListener("pointerenter", function (e) { + var img = e.target.closest(".plr-card-image img"); + if (!img) return; + var hoverSrc = img.getAttribute("data-hover"); + if (!hoverSrc) return; + if (!img.getAttribute("data-src-original")) { + img.setAttribute("data-src-original", img.getAttribute("src") || ""); + } + if (img.getAttribute("src") !== hoverSrc) { + img.setAttribute("src", hoverSrc); + } + }, true); + + grid.addEventListener("pointerleave", function (e) { + var img = e.target.closest(".plr-card-image img"); + if (!img) return; + var original = img.getAttribute("data-src-original"); + if (!original) return; + if (img.getAttribute("src") !== original) { + img.setAttribute("src", original); + } + }, true); + } + + /* -------------------- INIT -------------------- */ + + function init() { + var menu = $(".plr-filter-menu"); + if (menu && !menu.querySelector(".plr-filter-btn.active")) { + var first = menu.querySelector(".plr-filter-btn[data-tag='All']"); + if (first) first.classList.add("active"); + } + if (menu) menu.addEventListener("click", onClickTag); + + var search = $("#plr-card-search"); + if (search) search.addEventListener("input", onSearch); + + var viewAllBtn = $("#plr-view-all button"); + if (viewAllBtn) viewAllBtn.addEventListener("click", resetFilters); + + applyFilters(); + setupDelegatedImageHover(); + } + + if (document.readyState !== "loading") init(); + else document.addEventListener("DOMContentLoaded", init); +})(); diff --git a/docs/_templates/plr_card_grid.html b/docs/_templates/plr_card_grid.html new file mode 100644 index 00000000000..bd943b236ff --- /dev/null +++ b/docs/_templates/plr_card_grid.html @@ -0,0 +1,39 @@ +{# plr_card_grid.html — standalone snippet, no layout inheritance #} + +
+
+
All
+
Resource Movement
+
PlateAdapter
+
Hamilton STAR(let)
+
+
+ +
+ {% for card in cards %} +
+ +
+
+

{{ card.header }}

+ {% if card.desc %} +

{{ card.desc }}

+ {% endif %} +
+
+ {% if card.image_url %} + {{ card.header }} + {% endif %} +
+
+
+
+ {% endfor %} +
diff --git a/docs/conf.py b/docs/conf.py index 7525b811215..190e78f0345 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,8 @@ import sys sys.path.insert(0, os.path.abspath("..")) - +# Allow importing local Sphinx extensions (e.g., pylabrobot_cards) +sys.path.append(os.path.abspath("./_exts")) # -- Project information ----------------------------------------------------- @@ -22,7 +23,6 @@ copyright = "2025, PyLabRobot" author = "The PyLabRobot authors" - # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be @@ -31,6 +31,7 @@ extensions = [ "sphinx.ext.napoleon", "sphinx.ext.autodoc", + "pylabrobot_cards", # NEW: PLR cards (plrcard/plrcardgrid + compat) "sphinx.ext.autosummary", "sphinx.ext.autosectionlabel", "sphinx.ext.intersphinx", @@ -82,10 +83,24 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = [ "_static", - "resources/library/img" - ] + "resources/library/img", + "cookbook/img" +] html_extra_path = ["resources/library/img"] +# --- PLR cards assets (CSS/JS) --- +# Ensure lists exist, then append our assets without clobbering existing values. +html_css_files = list(globals().get("html_css_files", [])) +if "plr_cards.css" not in html_css_files: + html_css_files.append("plr_cards.css") # served from _static/plr_cards.css + +html_js_files = list(globals().get("html_js_files", [])) +if "plr_cards.js" not in html_js_files: + html_js_files.append("plr_cards.js") # served from _static/plr_cards.js + +# NOTE: templates_path already includes "_templates", which is where +# plr_card_grid.html should live. + html_theme_options = { "show_nav_level": 1, "use_edit_page_button": True, @@ -129,7 +144,6 @@ html_logo = "_static/logo.png" - autodoc_default_flags = ["members"] autosummary_generate = True autosummary_ignore_module_all = False @@ -177,8 +191,6 @@ exclude_patterns.append("api/**") suppress_warnings.append("toc.excluded") -suppress_warnings.append( - "autosectionlabel.*" -) +suppress_warnings.append("autosectionlabel.*") html_favicon = "_static/favicon.ico" diff --git a/docs/cookbook/img/hi.png b/docs/cookbook/img/hi.png new file mode 100644 index 00000000000..b224131f42d Binary files /dev/null and b/docs/cookbook/img/hi.png differ diff --git a/docs/cookbook/index.rst b/docs/cookbook/index.rst new file mode 100644 index 00000000000..472c31bcb53 --- /dev/null +++ b/docs/cookbook/index.rst @@ -0,0 +1,46 @@ +Cookbook +======== + +The PyLabRobot Cookbook is a curated collection of concise, modular code +examples, called recipes, that demonstrate practical solutions to common lab +automation challenges. +Each recipe is short, focused, and reusable - designed to help +you apply specific PyLabRobot features in real-world contexts. +Rather than full experimental protocols, these recipes aim to inspire, +teach, and accelerate your own automation workflows. + +------------------------------------------ + +.. raw:: html + +
+ +
+ +
+ +.. Add recipe cards below this line + +.. plrcard:: + :header: Move plate to Alpaqua magnet using CORE grippers + :card_description: Learn about...
+ • Resource movement using CORE grippers
+ • Resource position check using grippers
+ • PLR autocorrection of plate placement onto PlateAdapter/magnet + :image: _static/cookbook_img/recipe_01_core_move_static.png + :image_hover: _static/cookbook_img/recipe_01_core_move.gif + :link: recipes/star_movement_plate_to_alpaqua_core.html + :tags: ResourceMovement PlateAdapter HamiltonSTAR + +.. plrcardgrid:: + +.. End of tutorial card section + +.. ----------------------------------------- +.. Page TOC +.. ----------------------------------------- +.. toctree:: + :maxdepth: 2 + :hidden: + + recipes/star_movement_plate_to_alpaqua_core diff --git a/docs/cookbook/recipes/star_movement_plate_to_alpaqua_core.ipynb b/docs/cookbook/recipes/star_movement_plate_to_alpaqua_core.ipynb new file mode 100644 index 00000000000..1c9bc4e59c1 --- /dev/null +++ b/docs/cookbook/recipes/star_movement_plate_to_alpaqua_core.ipynb @@ -0,0 +1,361 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Moving plate onto Alpaqua magnet using CORE grippers\n", + "\n", + "Information:\n", + "- PLR Recipe ID #1\n", + "- Machines used:\n", + " - Hamilton STAR\n", + "- Non-PLR dependencies: None \n", + "- tags: #resourcemovement #plateadapter #hamiltonstar\n", + "- Author(s): Camillo Moschner\n", + "- Version history: 0 (2025-10-23)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set Protocol Mode" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "protocol_mode = \"simulation\" # \"execution\" or \"simulation\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Import Statements" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Machine & Visualizer" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + " \n", + "import random \n", + "import time\n", + "\n", + "from pylabrobot.liquid_handling import LiquidHandler\n", + "from pylabrobot.resources.hamilton import STARLetDeck\n", + "from pylabrobot.visualizer.visualizer import Visualizer\n", + "\n", + "if protocol_mode == \"execution\":\n", + "\n", + " from pylabrobot.liquid_handling.backends import STARBackend\n", + "\n", + " backend = STARBackend()\n", + "\n", + "elif protocol_mode == \"simulation\":\n", + "\n", + " from pylabrobot.liquid_handling.backends.hamilton.STAR_chatterbox import STARChatterboxBackend\n", + " \n", + " backend = STARChatterboxBackend()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Required Resources" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import (\n", + " hamilton_mfx_carrier_L5_base,\n", + " hamilton_mfx_plateholder_DWP_metal_tapped,\n", + " hamilton_mfx_plateholder_DWP_flat,\n", + " alpaqua_96_plateadapter_magnum_flx,\n", + " Azenta4titudeFrameStar_96_wellplate_200ul_Vb,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note: if you're unsure whether you have the resources mentioned in a PLR automated Protocol, use Python's inbuild `help` function to check out the resource definition's docstring - they always contain the manufacturer's catalogue/part number (and if available a direct link to the product page):" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on function hamilton_mfx_plateholder_DWP_metal_tapped in module pylabrobot.resources.hamilton.mfx_modules:\n", + "\n", + "hamilton_mfx_plateholder_DWP_metal_tapped(name: str) -> pylabrobot.resources.carrier.PlateHolder\n", + " Hamilton MFX DWP Module (cat.-no. 188042 / 188042-00).\n", + " Hamilton name: 'MFX_DWP_rackbased_module'\n", + " It also contains metal clamps at the corners.\n", + " https://www.hamiltoncompany.com/other-robotics/188042\n", + "\n" + ] + } + ], + "source": [ + "help(hamilton_mfx_plateholder_DWP_metal_tapped) # for more information visit the docs 'Resource Management' section" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Instantiate Frontend & Connect to Machine" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Websocket server started at http://127.0.0.1:2122\n", + "File server started at http://127.0.0.1:1338 . Open this URL in your browser.\n", + "C0CDid0001\n" + ] + } + ], + "source": [ + "lh = LiquidHandler(backend=backend, deck=STARLetDeck())\n", + "\n", + "await lh.setup()\n", + "\n", + "vis = Visualizer(resource=lh)\n", + "await vis.setup()\n", + "\n", + "await lh.backend.disable_cover_control() # 😈" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configure Deck Layout" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "# Setup MFX Carrier for Magnetic Bead Resuspension\n", + "\n", + "mfx_plateholder_dwp_1 = hamilton_mfx_plateholder_DWP_metal_tapped(name=f\"mfx_plateholder_dwp_tapped_dwp_0\")\n", + "\n", + "mfx_carrier_tapped_plate_holder_example = hamilton_mfx_carrier_L5_base(\n", + " name=\"mfx_carrier_tapped_plate_holder_example\",\n", + " modules={\n", + " 0: mfx_plateholder_dwp_1,\n", + " }\n", + ")\n", + "\n", + "mfx_carrier_tapped_plate_holder_example[0] = wash_plate = Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=\"wash_plate_0\")\n", + "\n", + "lh.deck.assign_child_resource(mfx_carrier_tapped_plate_holder_example, rails=1)\n", + "\n", + "# Setup Magnet-carrying MFX Carrier\n", + "\n", + "plateholder_flat_0 = hamilton_mfx_plateholder_DWP_flat(name=f\"plateholder_flat_0\")\n", + "magnet_0 = alpaqua_96_plateadapter_magnum_flx(name=f\"alpaqua_magnet_0\")\n", + "plateholder_flat_0.assign_child_resource(magnet_0)\n", + "\n", + "mfx_carrier_magnet_example = hamilton_mfx_carrier_L5_base(\n", + " name=\"mfx_carrier_magnet_example\",\n", + " modules={\n", + " 0: plateholder_flat_0,\n", + " }\n", + ")\n", + "magnet_0.plate_z_offset = 0.62 # <===== PLATE-SPECIFIC !\n", + "# empirical: distance between Alpaqua magnet hole bottom to\n", + "# cavity_bottom of the well that is placed on top of it\n", + "# use ztouch_probing to measure both \n", + "\n", + "lh.deck.assign_child_resource(mfx_carrier_magnet_example, rails=8)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Execution" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Move Plate Onto Magnet PlateAdapter" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0ZPid0002xs01679xd0yj1147yv0050zj1932zy0500yo0885yg0825yw40th2800te2800\n", + "C0ZRid0003xs03254xd0yj1138zj2162zi000zy0500yo0885th2800te2800\n", + "False\n" + ] + } + ], + "source": [ + "plate_index = 0 # always design for throughput adaptivness ;)\n", + "\n", + "plate_to_move = lh.deck.get_resource(f\"wash_plate_{plate_index}\")\n", + "move_target = lh.deck.get_resource(f\"alpaqua_magnet_{plate_index}\")\n", + "\n", + "\n", + "back_channel_idx = random.randint(1, 6) # Reduce wear & tear on any single channel\n", + "\n", + "if protocol_mode == \"simulation\":\n", + " time.sleep(2)\n", + " \n", + "await lh.move_plate(\n", + " plate=plate_to_move,\n", + " to=move_target,\n", + " use_arm=\"core\",\n", + " channel_1=back_channel_idx,\n", + " channel_2=back_channel_idx + 1,\n", + " pickup_distance_from_top=6,\n", + " core_grip_strength=40,\n", + " return_core_gripper=False,\n", + ")\n", + "\n", + "if protocol_mode == \"execution\":\n", + " # \"smart\" command, will ask operator for input if it cannot find plate in move_target location\n", + " # place into condition for simulation mode\n", + "\n", + " # (1) check transfer success, (2) push plate flush\n", + " await lh.backend.core_check_resource_exists_at_location_center(\n", + " location=plate_to_move.get_absolute_location(),\n", + " resource=plate_to_move,\n", + " gripper_y_margin=9,\n", + " enable_recovery=True,\n", + " audio_feedback=False,\n", + " )\n", + "\n", + "print(lh.backend.core_parked)\n", + "# >>> False # save time - keep CORE grippers on channels during magnetisation time\n", + "\n", + "if protocol_mode == \"simulation\":\n", + " time.sleep(2)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Move Plate back onto tapped PlateHolder" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0ZPid0004xs03254xd0yj1138yv0050zj2162zy0500yo0885yg0825yw40th2800te2800\n", + "C0ZRid0005xs01679xd0yj1147zj1932zi000zy0500yo0885th2800te2800\n", + "C0ZSid0006xs07975xd0ya1240yb1065tp2150tz2050th2800te2800\n", + "True\n" + ] + } + ], + "source": [ + "move_target = lh.deck.get_resource(f\"mfx_plateholder_dwp_tapped_dwp_{plate_index}\")\n", + "\n", + "await lh.move_plate(\n", + " plate=plate_to_move,\n", + " to=move_target,\n", + " use_arm=\"core\",\n", + " channel_1=back_channel_idx,\n", + " channel_2=back_channel_idx + 1,\n", + " pickup_distance_from_top=6,\n", + " core_grip_strength=40,\n", + " return_core_gripper=False,\n", + ")\n", + "\n", + "if protocol_mode == \"execution\":\n", + "\n", + " await lh.backend.core_check_resource_exists_at_location_center(\n", + " location=plate_to_move.get_absolute_location(),\n", + " resource=plate_to_move,\n", + " gripper_y_margin=9,\n", + " enable_recovery=True,\n", + " audio_feedback=False,\n", + " )\n", + " \n", + "await lh.backend.put_core()\n", + "\n", + "print(lh.backend.core_parked)\n", + "# >>> True" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/index.md b/docs/index.md index 016417e1386..a7079869079 100644 --- a/docs/index.md +++ b/docs/index.md @@ -154,6 +154,14 @@ user_guide/index Resource Management ``` +```{toctree} +:maxdepth: 0 +:caption: Cookbook +:hidden: + + Cookbook +``` + ```{toctree} :maxdepth: 2 :caption: Development @@ -176,6 +184,7 @@ api/pylabrobot Community ``` + ## Citing If you use PyLabRobot in your research, please cite the following paper: diff --git a/docs/resources/library/hamilton.md b/docs/resources/library/hamilton.md index 713c8773afc..4fbab1d87f3 100644 --- a/docs/resources/library/hamilton.md +++ b/docs/resources/library/hamilton.md @@ -39,9 +39,9 @@ See [MFX Carrier documentation](/resources/carrier/mfx-carrier/mfx_carrier). | Description | Image | PLR definition | | - | - | - | | 'MFX_TIP_module'
Part no.: 188160 or 188040
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/188040)
Module to position a high-, standard-, low volume or 5ml tip rack (but not a 384 tip rack) | ![](img/hamilton/MFX_TIP_module_188040.jpg) | `MFX_TIP_module` | -| 'MFX_DWP_rackbased_module'
Part no.: 188229?
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/188229) (<-non-functional link?)
MFX DWP module rack-based | ![](img/hamilton/MFX_DWP_RB_module_188229_.jpg) | `MFX_DWP_rackbased_module` | +| 'hamilton_mfx_plateholder_DWP_flat'
Part no.: 188229
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/188229) (<-non-functional link?)
MFX DWP module rack-based | ![](img/hamilton/MFX_DWP_RB_module_188229_.jpg) | `hamilton_mfx_plateholder_DWP_flat` | | 'MFX_DWP_module_flat'
Part no.: 6601988-01
manufacturer website unknown | ![](img/hamilton/MFX_DWP_module_flat.jpg) | `MFX_DWP_module_flat` | -| 'Hamilton_MFX_plateholder_DWP_metal_tapped'
Part no.: 188042
[manufacturer website](https://www.hamiltoncompany.com/other-robotics/188042)
Carries a single ANSI/SLAS footprint DWP, a Matrix or Micronics tube rack, or a Nunc reagent reservoir. Occupies one MFX site.| ![](img/hamilton/Hamilton_MFX_plateholder_DWP_metal_tapped.png) | `Hamilton_MFX_plateholder_DWP_metal_tapped` | +| 'hamilton_mfx_plateholder_DWP_metal_tapped'
Part no.: 188042
[manufacturer website](https://www.hamiltoncompany.com/other-robotics/188042)
Carries a single ANSI/SLAS footprint DWP, a Matrix or Micronics tube rack, or a Nunc reagent reservoir. Occupies one MFX site.| ![](img/hamilton/Hamilton_MFX_plateholder_DWP_metal_tapped.png) | `hamilton_mfx_plateholder_DWP_metal_tapped` | ### Tube carriers diff --git a/pylabrobot/resources/alpaqua/magnetic_racks.py b/pylabrobot/resources/alpaqua/magnetic_racks.py index f9cfe31db1c..d8b24867f11 100644 --- a/pylabrobot/resources/alpaqua/magnetic_racks.py +++ b/pylabrobot/resources/alpaqua/magnetic_racks.py @@ -1,12 +1,15 @@ -# currently implemented as PlateAdapter to enable simple and fast assignment -# of plates to them, with self-correcting location placement +import warnings +# implemented as PlateAdapter to enable simple and fast assignment +# of plates to them, with self-correcting location placement from pylabrobot.resources.plate_adapter import PlateAdapter -def Alpaqua_96_magnum_flx(name: str) -> PlateAdapter: +def alpaqua_96_plateadapter_magnum_flx(name: str) -> PlateAdapter: """Alpaqua Engineering LLC cat. no.: A000400 Magnetic rack for 96-well plates. + implemented as PlateAdapter to enable simple and fast assignment of + plates to them, with self-correcting location placement """ return PlateAdapter( name=name, @@ -15,9 +18,24 @@ def Alpaqua_96_magnum_flx(name: str) -> PlateAdapter: size_z=35.0, dx=9.8, dy=6.8, - dz=27.5, # TODO: correct dz once Plate definition has been completely fixed + dz=27.5, # refers to magnet hole bottom + plate_z_offset=0.0, # adjust at runtime based on plate's well geometry adapter_hole_size_x=8.0, adapter_hole_size_y=8.0, adapter_hole_size_z=8.0, # guesstimate - model="Alpaqua_96_magnum_flx", + model=alpaqua_96_plateadapter_magnum_flx.__name__, + ) + + +# Deprecated names for backwards compatibility +# TODO: Remove >2026-02 + + +def Alpaqua_96_magnum_flx(name: str) -> PlateAdapter: + """Deprecated alias for `alpaqua_96_plateadapter_magnum_flx`.""" + warnings.warn( + "Alpaqua_96_magnum_flx is deprecated. Use 'alpaqua_96_plateadapter_magnum_flx' instead.", + DeprecationWarning, + stacklevel=2, ) + return alpaqua_96_plateadapter_magnum_flx(name) diff --git a/pylabrobot/resources/hamilton/mfx_carriers.py b/pylabrobot/resources/hamilton/mfx_carriers.py index d7481f62a7e..77096370932 100644 --- a/pylabrobot/resources/hamilton/mfx_carriers.py +++ b/pylabrobot/resources/hamilton/mfx_carriers.py @@ -1,3 +1,4 @@ +import warnings from typing import Dict from pylabrobot.resources.carrier import ( @@ -7,8 +8,9 @@ ) -def MFX_CAR_L5_base(name: str, modules: Dict[int, ResourceHolder]) -> MFXCarrier: +def hamilton_mfx_carrier_L5_base(name: str, modules: Dict[int, ResourceHolder]) -> MFXCarrier: """Hamilton cat. no.: 188039 + Hamilton name: 'MFX_CAR_L5_base' Labware carrier base for up to 5 Multiflex Modules """ locations = [ @@ -122,3 +124,17 @@ def MFX_CAR_P3_base(name: str, modules: Dict[int, ResourceHolder]) -> MFXCarrier sites=sites, model="MFX_CAR_P3_base", ) + + +# Deprecated names for backwards compatibility +# TODO: Remove >2026-02 + + +def MFX_CAR_L5_base(name: str, modules: Dict[int, ResourceHolder]) -> MFXCarrier: + """Deprecated alias for `hamilton_mfx_carrier_L5_base`.""" + warnings.warn( + "MFX_CAR_L5_base is deprecated. Use 'hamilton_mfx_carrier_L5_base' instead.", + DeprecationWarning, + stacklevel=2, + ) + return hamilton_mfx_carrier_L5_base(name, modules) diff --git a/pylabrobot/resources/hamilton/mfx_modules.py b/pylabrobot/resources/hamilton/mfx_modules.py index 1b54adb14e5..66b8ad60685 100644 --- a/pylabrobot/resources/hamilton/mfx_modules.py +++ b/pylabrobot/resources/hamilton/mfx_modules.py @@ -1,3 +1,5 @@ +import warnings + from pylabrobot.resources.carrier import Coordinate, PlateHolder from pylabrobot.resources.resource_holder import ResourceHolder @@ -21,8 +23,9 @@ def MFX_TIP_module(name: str) -> ResourceHolder: ) -def MFX_DWP_rackbased_module(name: str) -> PlateHolder: +def hamilton_mfx_plateholder_DWP_flat(name: str) -> PlateHolder: """Hamilton cat. no.: 188229 + Hamilton name: 'MFX_DWP_rackbased_module' Module to position a Deep Well Plate / tube racks (MATRIX or MICRONICS) / NUNC reagent trough. """ @@ -36,13 +39,14 @@ def MFX_DWP_rackbased_module(name: str) -> PlateHolder: size_z=178.0 - 18.195 - 100, # 59.81mm # probe height - carrier_height - deck_height child_location=Coordinate(4.0, 3.5, 178.0 - 18.195 - 100), - model=MFX_DWP_rackbased_module.__name__, + model=hamilton_mfx_plateholder_DWP_flat.__name__, pedestal_size_z=0, ) -def Hamilton_MFX_plateholder_DWP_metal_tapped(name: str) -> PlateHolder: +def hamilton_mfx_plateholder_DWP_metal_tapped(name: str) -> PlateHolder: """Hamilton MFX DWP Module (cat.-no. 188042 / 188042-00). + Hamilton name: 'MFX_DWP_rackbased_module' It also contains metal clamps at the corners. https://www.hamiltoncompany.com/other-robotics/188042 """ @@ -55,14 +59,14 @@ def Hamilton_MFX_plateholder_DWP_metal_tapped(name: str) -> PlateHolder: # probe height - carrier_height - deck_height child_location=Coordinate(4.0, 4.0, 183.95 - 18.195 - 100), # measured pedestal_size_z=-4.74, - model=Hamilton_MFX_plateholder_DWP_metal_tapped.__name__, + model=hamilton_mfx_plateholder_DWP_metal_tapped.__name__, ) def MFX_DWP_module_flat(name: str) -> PlateHolder: """Hamilton cat. no.: 6601988-01 - Module to position a Deep Well Plate. Flat, metal base; no metal clamps like - MFX_DWP_rackbased_module. + Hamilton name: 'MFX_DWP_module_flat' + Module to position a Deep Well Plate. Flat, metal base; no metal clamps. Grey plastic corner clips secure plate. Plates rest on corners, rather than pedestal, so pedestal_size_z=0, """ @@ -76,6 +80,30 @@ def MFX_DWP_module_flat(name: str) -> PlateHolder: size_y=length, size_z=66.4, # measured with caliper child_location=Coordinate(x=(width - 127.76) / 2, y=(length - 85.48) / 2, z=66.4), - model=MFX_DWP_rackbased_module.__name__, + model=MFX_DWP_module_flat.__name__, pedestal_size_z=0, ) + + +# Deprecated names for backwards compatibility +# TODO: Remove >2026-02 + + +def Hamilton_MFX_plateholder_DWP_metal_tapped(name: str) -> PlateHolder: + """Deprecated alias for `hamilton_mfx_plateholder_DWP_metal_tapped`.""" + warnings.warn( + "Hamilton_MFX_plateholder_DWP_metal_tapped is deprecated. Use 'hamilton_mfx_plateholder_DWP_metal_tapped' instead.", + DeprecationWarning, + stacklevel=2, + ) + return hamilton_mfx_plateholder_DWP_metal_tapped(name) + + +def MFX_DWP_rackbased_module(name: str) -> PlateHolder: + """Deprecated alias for `hamilton_mfx_plateholder_DWP_flat`.""" + warnings.warn( + "MFX_DWP_rackbased_module is deprecated. Use 'hamilton_mfx_plateholder_DWP_flat' instead.", + DeprecationWarning, + stacklevel=2, + ) + return hamilton_mfx_plateholder_DWP_flat(name)