diff --git a/pylabrobot/hamilton/star/MOTION_PROFILES.md b/pylabrobot/hamilton/star/MOTION_PROFILES.md new file mode 100644 index 00000000000..c38a53c4673 --- /dev/null +++ b/pylabrobot/hamilton/star/MOTION_PROFILES.md @@ -0,0 +1,459 @@ +# Motion profiles, by firmware command + +What the viewer plays for each Hamilton STAR firmware command, and where every number and choice +comes from. Each command the v1 driver sends either has a motion profile (made of phases, played in +order) or has none. Each value is tagged with its source: + +| Tag | Meaning | +|---|---| +| **FW** | Carried by the command itself: the page plays what the device was told. | +| **PLR** | A value or model in PyLabRobot's code: a driver constant, a configuration, or the resource model. Cited by file and symbol. | +| **SIM** | How PLR's STAR simulator records the command (`hamilton/star/driver/simulator.py`). This is PLR's own model of the motion, not a documented firmware sequence. | +| **FIT** | Fitted to a real STAR's own command timings: every firmware request and reply, millisecond-stamped, from Venus HxUsbComm traces. Reproducible with `tools/hxusbcomm_timing.py`; data and statistics in section 8. | +| **DOC** | External documentation or a forum post, linked to the post. labautomation.io posts by Hamilton staff are treated as authoritative. | +| **HEUR** | Our choice, with no documented reference. The justification is given. | + +Files: the decoder is `pylabrobot/visualizer3D/motion.py` (Python, turns a command into targets); +the player is `static/motion_player.js` (the page, plays targets in phases); the profile is +`static/motion_profile.js`. + +--- + +## 0. Applies to every profile + +| Choice | Value | Tag | Source / justification | +|---|---|---|---| +| Speed profile of a single move | Symmetric trapezoid (a triangle if too short to cruise; constant speed with no acceleration), except the X-arm, which moves on a jerk-limited S-curve | SIM + FIT | Trapezoid: the simulator's timing model, `SimulatedPipettes._get_travel_time` (`simulator.py`). S-curve for X: the traces reject a trapezoid for the X-arm (section 8.1). Page: `motionProfile(distance, speed, acceleration, jerk)` in `static/motion_profile.js`. | +| Moves that happen at once | Drives in the same phase start together, and the phase ends with the slowest | SIM | `owe_motion_time`: "the drives move at once, so a command takes as long as its slowest axis" (`simulator.py`). | +| Smallest move played | 0.05 mm (`STILL`); anything smaller is skipped | HEUR | Below the 0.1 mm firmware resolution; avoids zero-length tweens. | +| Model arrivals | After each motion, the model's own update must find the page already there (0.11 mm / 0.11° tolerance in the harness) | PLR + HEUR | The model is the source of truth (PLR). The tolerance is ours: `ARRIVAL_MM` / `ARRIVAL_DEG` in `smoothness_page.mjs`, just above the firmware's 0.1 mm resolution. Enforced by `smoothness.py`'s `assert_smooth`. | +| Page hidden / background tab | Motions jump to their end | HEUR | Browsers throttle hidden tabs, and blocking the run on an unwatched page makes no sense. | +| Python waits for the page | Until every connected page reports `motion_done`, at most 120 s (`Viewer3D.MOTION_TIMEOUT_S`) | HEUR | Paces the simulated run to the drawing; the timeout keeps a stuck page from hanging a run. | +| Heights | Converted to where the channel's stop disc is; the lowest point of a mounted tip is the disc minus the tip's overhang | PLR + SIM | `_Frames.lowest_point_z`, with the overhang from `SimulatedPipettes._below_stop_disc`. The master reports the lowest point of what a channel carries (`simulator.py` `RZ`). For CO-RE grip tools this is the grip line (`HamiltonCoreGripperTool.grip_line_height`). **Gap:** `_below_stop_disc` exists only on the simulator. Driving a real STAR, the decoder takes the overhang as 0, so heights with a tip mounted would be off by the tip's length. It should come from the model (`TipMountingShaft.tip_bottom`) instead. | + +### Drive speeds used when the command does not state one + +| Drive | Speed | Acceleration | Tag | Source | +|---|---|---|---|---| +| X-arm | 600 mm/s | 1297 mm/s², jerk 3210 mm/s³ | FIT | `X_SPEED`, `X_ACCELERATION`, `X_JERK` (`motion.py`). The driver states no X rate; firmware X commands carry only an acceleration *level*. Section 8.1: S-curve within-group R² 0.976, RMS 23.4 ms, ΔAIC 72.7 over a trapezoid. Jerk is well determined; speed and acceleration less so. | +| Channel Y | 300 mm/s | 900 mm/s² | FIT | `CHANNEL_Y_SPEED`, `CHANNEL_Y_ACCELERATION` (`motion.py`), from single-channel jogs (section 8.2); PLR's `default_y_speed` is 250 mm/s. The firmware states Y acceleration only as a level (1–4, `default_y_acceleration_level = 3`). The channels set off 0.118 s apart (`CHANNEL_Y_STAGGER`, section 8.4). The simulator still times Y at constant speed. | +| Channel Z | 150 mm/s | 800 mm/s² | FIT + PLR | Speed: `CHANNEL_Z_SPEED`, fitted from dispense→aspirate pairs (section 8.4; loose, 150–200); PLR's `default_z_speed` is 125 mm/s. Acceleration: `Pipettes.default_z_acceleration`, which the traces agree with. | +| 96-head Y / Z | the head's defaults | the head's defaults | PLR | `HeadConfiguration.y_drive_speed_default` / `z_drive_speed_default` and their accelerations (`features/head.py`): the value the firmware reports when read, else the configured default increments. | +| iSWAP gripper, when a close states no speed | `gripper_close_speed_default_increments` (5000), converted | `gripper_acceleration_default_increments` (75), converted | PLR | `iSWAPConfiguration` (`features/iswap.py`). | + +--- + +## 1. Channel commands + +### `C0 TP` — tip pick-up (`Pipettes.pick_up_tips`) +Profile: **stroke with handover**. + +| Phase | Target | Tag | Source | +|---|---|---|---| +| 1. Rise | Any channel lower than `th` rises to it (by lowest point) | FW + SIM | `th`. The simulator records the stroke "across at the height it starts from" (`_record_tip_command`). | +| 2. Across | The arm to the X of the last column (`xp`); the channels to `yp`; the channels not named pushed along the rail by the spacing rule | FW + PLR + SIM | Positions are FW. Pushing the others follows `Pipettes._plan_y_positions(make_space=True)`, as the simulator does ("one rail", `_record_tip_command`). Ported as `_Frames.planned_ys`. | +| 0. Fixed | 0.065 s of command handling before anything moves | HEUR | Section 8.6. | +| 3. Down | The lowest point to `tp` at the Z drive's speed, setting off when 82% of the crossing's time has passed | FW + FIT | `tp`; the overlap, section 8.6. | +| 3b. Press | On to `tz` at 11.5 mm/s | FW + FIT | `tz`; the speed, section 8.6. | +| 4. Handover and hold | Each spot's tip goes to the channel's shaft, placed as `TipMountingShaft.mount_tip` places it, then seated at the Z drive's pace; the channels hold 4.356 s, the rest of the fixed time | PLR + FIT + HEUR | The placement is PLR (`_mounted_location`). Seating the tip at the Z-drive pace instead of snapping it is ours (HEUR): the model's placement and the stroke's bottom differ by the fitting geometry. | +| 5. Up | The lowest point to `th`, now with the tip's overhang | FW + PLR | The overhang is read off the tip in the spot and where the shaft seats it (as `TipMountingShaft.tip_bottom` reads it); the simulator's `defined_tip_lengths` only when the model has no tip there. | + +Also used: begin height `tp` = spot + collar height (PLR, legacy `STARBackend.pick_up_tips`, per `_pick_up_tips_in_one_move`). + +### `C0 TR` — tip drop / return (`drop_tips`, `return_tips`, `discard_tips`) +Profile: **stroke with handover**. + +| Phase | Target | Tag | Source | +|---|---|---|---| +| 1–2 | As `C0 TP` | FW + SIM | | +| 3. Down | `tz`. With `ti=1` (DROP) this is the stop disc's height; with PLACE_SHIFT it is where the tip's cone ends | PLR | `_unchecked_fw_drop_tips` docstring: "With `PLACE_SHIFT` the heights are where the tip's cone ends; with `DROP`, the stop disc's." | +| 4. Handover | The tip goes to the spot under the channel (placed by `resting_location`), or nowhere (waste) | PLR | `tip_rack.resting_location`. | +| 5. Up | To `te`, empty | FW | | + +Default heights: DROP begins at spot + collar and ends a fitting depth lower; PLACE_SHIFT uses 59.9 / 49.9 mm (PLR, legacy "Empirical, from legacy: https://github.com/PyLabRobot/pylabrobot/pull/63"). + +### `C0 AS` — aspirate (`Pipettes.aspirate`) +Profile: **stroke with dwell and leave**. + +| Phase | Target | Tag | Source | +|---|---|---|---| +| 1–2 | As `C0 TP` | FW + SIM | | +| 0. Fixed | 1.879 s + 6.761 × transport-air time, before anything moves | FIT + HEUR | Section 8.6; placement HEUR. | +| 3. Down | max(`zl` ∓ `ip`, `zx`): into the liquid by the immersion depth, or above it when `it` is 1; no lower than the minimum height | FW | Field meanings are in legacy's `STARBackend.aspirate_pip` docstring (after the firmware guide): `zl` "Liquid surface at function without LLD", `ip` "Immersion depth", `zx` "Minimum height (maximum immersion depth)". | +| 4. Dwell | Longest channel of (`av` / `as`) + `wt` + mixing; while the volume is drawn, each tip follows the surface down by `fp` at a steady pace (no lower than `zx`) | FW + FIT | Volume ÷ flow rate, settling time, mixing (section 8.6). `fp` is "the total distance the tip moves during aspiration, from the surface down to the new, reduced height" (PLR maintainer, [discuss.pylabrobot.org/t/304/6](https://discuss.pylabrobot.org/t/hamilton-surface-following-issues/304/6)). | +| 5. Leave | Up to the surface at the swap speed `de` | FW | `de` "Swap speed (on leaving liquid)" (legacy docstring; default 10 mm/s). | +| 5b. Pull-out | On up by `po` from the surface, at the swap speed, before the transport air is drawn | FW + FIT + HEUR | `po` "rise before drawing transport air" (PLR's `aspirate` docstring). The speed is FIT: Venus aspirations otherwise identical take 5.35 s (n 4 vs 802) and 4.85 s (n 12 vs 3,534) longer with a 10 mm pull-out at a 2 mm/s swap speed, i.e. 10 mm at 2 mm/s. Measured from the surface: HEUR. With PLR's defaults (`po` 10 mm, `de` 2 mm/s) every aspirate spends about 5 s on it. | +| 6. Up | To `te` | FW | | + +Known divergences from the device (to do): +- The conical second section (`zu`, `zr`) is not played: how the firmware changes the following pace there isn't documented. +- In the demo's aspirate (PLR's defaults), PLR sent the minimum height `zx` equal to the surface `zl`, so the tip went no deeper than the surface and could follow nowhere, whatever `ip` and `fp` said. Worth checking how PLR sets `zx` upstream. +- In v1, LLD is **never** done inside `C0AS`: capacitive/pressure searches run first as their own commands (see `Px ZL` / `Px ZE`, section 5), and `C0AS` goes with LLD off (PR [#1362](https://github.com/PyLabRobot/pylabrobot/pull/1362)). So `C0AS` itself needs no search phase. + +### `C0 JY` — channels to Y positions +Profile: **single axis (Y)**. Each channel to `yp` (FW), at the Y drive speed (section 0). + +### `C0 JZ` — channels to Z positions +Profile: **single axis (Z)**. Each channel's lowest point to `zp` (FW), at the Z drive speed and acceleration (section 0). + +### `C0 FY` — free the Y range (the iSWAP's `make_space`) +Profile: **single axis (Y), commanded target from the model**. The master packs the channels as far forward as they fit, each against the one in front. The target isn't in the command. The v1 driver reads the channels' Y back after the command, in a `finally` (PLR, `iSWAP.make_space`). The simulator writes the packed positions ahead, because its reads answer from the model (SIM, `_unchecked_fw_position_components_for_free_y_range`), and the page plays to them (`recorded_first`: state held back until played). This is the commanded outcome, not a confirmed one: a failure would be reconciled only by the read-back (planned). + +### `C0 ZA` — all channels to Z safety +Profile: **single axis (Z), commanded target from the model**. Every channel up to the top of its Z travel, `Pipettes.configuration.z_range[1]`, where `probe_z_max` leaves them (PLR). The simulator writes this ahead of the command, since its reads answer from the model (SIM, `SimulatedPipettes.probe_z_max`); the driver's read-back is what confirms it. + +### `C0 ZT` — pick up the CO-RE grip tools (`Pipettes.pick_up_core_gripper_tools`) +Profile: **stroke with handover**, on two adjacent channels. + +| Phase | Target | Tag | Source | +|---|---|---|---| +| 1–2 | Both channels to the tools' pick-up points: X `xs`, Y `ya` (back) / `yb` (front) | FW + PLR | Points come from the model: each tool's `pick_up_location` (`resources/hamilton/core_gripper_tools.py`), in the holder at `channel_x_center` and its front/back channel Y centres (`core_grippers.py`). They match legacy's wire exactly (`xs07975 ya1250 yb1070` on a STARlet, legacy `STAR_tests.py`). | +| 3. Down | `tz` = tool top − 10 mm, by the stop disc (the channel is bare) | PLR + HEUR | Heights are legacy's (`pick_up_core_gripper_tools`: begin 235, end 225, against tools whose tops stand at 235.0, probed per `hamilton_core_gripper_1000ul_5ml_on_waste`). Reading `tz` as a stop-disc height is ours (HEUR): the firmware's reference point for `ZT` heights is not documented publicly. | +| 4. Handover | Each tool onto its shaft, placed by `mount_tip` (pick-up location on the shaft's axis) | PLR | `TipMountingShaft.mount_tip`. | +| 5. Up | `th`, with the tool hanging by its grip line (pick-up z − fitting depth − grip-line height) | PLR + SIM | Matches what the simulator reports for a channel carrying a tool. | + +Travel height `th`: as high as a tool on a channel reaches (v1's convention for tip commands, `_tip_traverse_height`). Legacy sends the iSWAP traversal height, 280 mm (PLR, `_iswap_traversal_height`); `minimum_traverse_height=280` reproduces it. + +### `C0 ZS` — return the CO-RE grip tools (`return_core_gripper_tools`) +Profile: **stroke with handover**, the reverse of `C0 ZT`. The channels go to where the tools were parked (remembered at pick-up). Down to `tz` = tool top − 30 mm (legacy: begin 215, end 205; PLR). Here `tz` is read as the lowest point *with* the tool mounted (HEUR, as for `ZT`). Each tool goes back to its parked location and rotation (PLR), then the channels rise to `te`. + +--- + +## 2. X-arm + +### `X0 XP` / `X0 SP` — arm to an X position +Profile: **single axis (X)**. `XP` is the left arm and `SP` the right. The target is the arm's `…a` field, in increments converted by `XArmConfiguration.x_increments_to_mm`, less `reference_point_from_left` (FW + PLR), at 400 mm/s and 500 mm/s² (HEUR, section 0). Everything on the arm (channels, 96-head, iSWAP) rides with it (PLR: they're children of the arm resource). + +--- + +## 3. iSWAP + +Joint conventions (DOC, Camillo Moschner, [discuss.pylabrobot.org/t/517/1](https://discuss.pylabrobot.org/t/intro-to-epic-tame-the-iswap/517/1)): +- Rotation (elbow) −90° / 0° / +90° = left / front / right. +- Wrist −135° / −45° / +45° / +135° = right / straight / left / reverse. + +In PLR these are `iSWAPConfiguration`'s predefined increments, read at setup. + +### `R0 YA` / `R0 ZA` — the head along Y / Z +Profile: **single axis**. +- Y: to `ya` at `yv`, converted by the arm's configuration (FW + PLR). No acceleration rate, since `R0 YA` carries only a level, so it plays at constant speed (HEUR, as channel Y). +- Z: to `za` + `elbow_z_offset_above_finger` at `zv`, with acceleration `zr` × 1000 increments/s² (FW + PLR). + +### `R0 PA` — elbow and wrist together +Profile: **two joint turns, together**. Each joint to its own angle at its own speed and acceleration (`wa`/`wv`/`wr` for the elbow, `ta`/`tv`/`tr` for the wrist; FW), converted by `iSWAPConfiguration` (PLR). Each turns about the pivot the driver turns it about (`proximal_joint`; PLR). Both run on a straight line in joint space (HEUR; the controller's interpolation is not public). + +### `R0 GA` — jaws to a width +Profile: **jaws**. The fingers move to the width `ga` at `gv` / `gr` (FW). Each finger travels half the width change in the same time, so at half the drive's speed (derived from symmetric jaws). If opening, the page lets go of what it held *before* moving; if closing, it takes hold *after* (HEUR: the ordering that avoids dragging or dropping the plate mid-motion). + +### `C0 GC` — close onto an object +Profile: **jaws**. Closes to `gb` at the default close speed and acceleration (PLR, section 0), then takes hold (HEUR, as `R0 GA`). + +### Plate and lid moves (`iSWAPTransport`, Python, from the primitives above) +The compound commands (`C0 PP` / `PR` / `PM`) are not used. Their internal motion isn't public, and the firmware "chooses among multiple valid poses unpredictably" (DOC, [discuss.pylabrobot.org/t/517/1](https://discuss.pylabrobot.org/t/intro-to-epic-tame-the-iswap/517/1)). A move is planned as primitives (`hamilton/star/driver/features/iswap_transport.py`): + +| Choice | Value | Tag | Source / justification | +|---|---|---|---| +| Phases | open → rise → travel → descend → grip / release → rise | HEUR | Follows the phases `C0 PP`'s parameters describe (open width, traverse height, grip height, end height). | +| Travel height | `iswap.default_minimum_traverse_height` (284 mm), fixed, never computed from the deck | PLR | Legacy sends 280 mm. | +| Grip width, open margin, strength, tolerance, pick-up depth | width across the plate; +3 mm; 4; 2 mm; 5 mm below the top (or `preferred_pickup_location`) | PLR | Legacy `STARBackend.pick_up_resource`. | +| Grip below a lid | 2 mm below the lid's skirt (`nesting_z_height`) | HEUR | Jaws closing within the skirt would close on the lid. | +| Elbow configuration | Only the three elbow stops; the stop with the smallest joint turn from now, front on ties | HEUR | The stops are PLR/DOC; the ranking is ours. | +| Travel order | Y then turn, else turn then Y, else turn at a safe Y = max(y_min, min(now, target, y_max − link 1 − tool)) | HEUR | Chosen so the turn never sweeps the arm behind the rail. Checked by sampling each turn at 36 points against `_check_pose_reachable`. | +| X during travel | Runs concurrently with Y and the turn | HEUR | The X-arm is a separate drive. | + +### Not decoded +- `C0 PG` park: close the jaws, lift to the traverse height, retract ("Sending no height leaves that to the master, which does not raise the arm"; PLR, `iSWAP.park` docstring). A rotation-drive calibration lists a parking position of ≈29500 increments (≈+91°) (DOC, [discuss.pylabrobot.org/t/517/4](https://discuss.pylabrobot.org/t/intro-to-epic-tame-the-iswap/517/4)). The retract path itself is not public. +- `R0 GB` close to an object, `R0 GS` relative jaw move: jaws profiles, not yet mapped. +- `C0 FI`, `R0 GI`: initialisation; no public profile. +- Venus 6.2 teaches custom Get/Place paths as waypoint poses (Transport Paths Editor), documented only in the non-public Venus 6.2 Programmer's Manual (DOC, [labautomation.io/t/5759](https://labautomation.io/t/transport-paths-editor/5759)). + +--- + +## 4. 96-head + +### `C0 EP` / `C0 ER` — tip pick-up / drop +Profile: **stroke with handover**, all 96 shafts. The head goes to `xs` (sign from `xd`) and `yh`, down and up as the tip commands (FW). Channel A1 goes to spot A1 (PLR: `Head96` / `NChannelPipette` layout). The head's drive defaults are in section 0. The simulator places the head after the command (`SimulatedHead96._place_after_tip_command`: arm X, head Y, z = ze + overhang; SIM). + +### `H0 YA` / `H0 ZA` — the head along Y / Z +Profile: **single axis**, to the target at `yv`/`yr` or `zv`/`zr` (FW), converted by `HeadConfiguration` (PLR). + +### Not decoded +- `H0 PA` / `H0 PB` aspirate / dispense (a stroke with dwell, like `C0 AS`). +- `C0 EM` move to a coordinate. +- `H0 ZL` cLLD probe (see section 5). +- `H0 DQ` dispensing drive (piston only, no visible motion). +- Touch-off is not available on the multi-probe head: "the MPH is a significantly heavier tool … making this feature impractical" (DOC, Hamilton, [labautomation.io/t/1788/2](https://labautomation.io/t/touch-off-with-mph/1788/2)). + +--- + +## 5. Not decoded yet, with what is known + +### Detection searches — `Px ZL` (cLLD), `Px ZE` (pLLD), `Px ZH` (Z-touch), `C0 XL`, `Px YL` +Profile to build: **search**. Each channel on its own, all concurrently: +1. approach to the start; +2. a slow constant-speed descent (or traverse, for X/Y) at the search speed, until detection or the end; +3. a small post-detection move; +4. then stay, or go to Z safety. + +Channels stop at different heights at different moments. + +| Parameter | Firmware field | Tag | Source | +|---|---|---|---| +| Start, end (stop-disc heights) | `zc`, `zh` (ZL/ZE); `zb`, `za` (ZH) | FW | `_unchecked_fw_probe_z_using_clld` / `_plld` / `_ztouch` docstrings (`pipettes.py`). | +| Search speed, acceleration | `zl`, `zr` (ZL); `zu`, `zr` (ZH); approach `zv` (ZH) | FW | Same. | +| After detection | `zj` (0 down, 1 up) by `zi` | FW | Same. | +| Z-touch force | detection limiter PWM `cg`, push-down PWM `cf` | FW | Same. Touch-off "relies on force feedback to determine when the channel is contacting the bottom surface" (DOC, Hamilton, [labautomation.io/t/1788/2](https://labautomation.io/t/touch-off-with-mph/1788/2)). Needs channel firmware from March 2022 on (DOC, [discuss.pylabrobot.org/t/543/3](https://discuss.pylabrobot.org/t/reason-behind-z-touch-only-being-available-to-8-channels-made-in-2022-and-onwards/543/3)). | +| Where searches start and stop | 5 mm above a container's top (2 mm for wells); 1 mm below the modelled cavity bottom | PLR | `Pipettes.search_start_clearance`, `well_search_start_clearance`, `search_limit_below_cavity_bottom`. | +| Sequence in an LLD aspirate | `Px DC` (blow-out air, in air) → approach → searches → `C0 RL` → `C0 AS` with LLD off | PLR | PR [#1362](https://github.com/PyLabRobot/pylabrobot/pull/1362). | +| **Where it stops** | not in the command; known only from the answer (`C0 RL`, or the model the simulator moves) | — | Needs post-answer targets: play the descent to where the model reports (exact in the simulator), or an open-ended descent at search speed that stops when the answer lands (on hardware). | + +### Single-channel and arm moves +- `Px ZA`: one channel to a stop-disc Z. A single-axis Z move (FW). +- `C0 JE`: "the device spreads them itself, over the same band the initialization procedure uses" (PLR, `spread_channels`). The targets are the band spread evenly (PLR, `default_initialize_y_positions`). +- `C0 JP`: frees one channel as much as possible; "the device decides where the others go" (PLR, `make_max_space_for_channel`). Targets are known only after the answer. +- `C0 KX` / `KR`: "The master raises what the arm carries before it travels" to Z safety, then X (PLR, `_unchecked_fw_move_x_with_attached_components_at_z_safety`). Profile: Z-safety phase, then X. +- `Px DC`: blow-out air drawn in air. Piston only, no visible motion. + +### Side touch (dispense; v1 has no dispense yet) +"The channel will go down to the touch-off z-height specified, then moves over to the right, dispense, and then moves up" (DOC, Hamilton, [labautomation.io/t/2255/6](https://labautomation.io/t/post-dispense-tip-touch-on-starlet/2255/6)). "It is restricted to a positive x-movement so to the right. Max distance is 4.5mm" (DOC, Hamilton, [labautomation.io/t/2255/9](https://labautomation.io/t/post-dispense-tip-touch-on-starlet/2255/9)). Legacy's `C0 DS` carries a side-touch distance. + +### Autoload, initialisation +- Autoload (`I0 YA/ZA/YP/ZP`, `C0 IV`, the barcode scanner move, carrier load/unload): no public profile. Loading moves a carrier into the tree. +- Initialisation (`C0 DI`, `C0 FI`, `X0 XI`, the heads): no public profile. + +--- + +## 6. Commands with no motion profile +Reads (`R*`, `Q*`, `VW`), pressure-monitoring and sensor setup (`AC`, `AF`, `AN`, `AQ`, `BG`, `BH`), drive parameters (`AA`), brakes (`R0 BA/BO`), drive power (`X0 XO`, `R0 GO`), cover (`C0 CO/HO/CE/CD`), tip-type definition (`C0 TT`), master setup (`C0 UA`, `C0 VI`), and autoload sensing and barcode setup (`CQ`, `CS`, `CT`, `CB`, `CP`, `AR`, `AF`). + +--- + +## 8. Measured from firmware traces + +**Source:** Venus `HxUsbComm*.trc` logs from the Chory lab's STAR (266 files, 447 MB, Nov 2023 – Apr 2025; not in the repo). Every request (`<`) and reply (`>`) carries a millisecond timestamp. Pairing them by id gives 2,559,959 answered commands and how long each took. Arm-X reads (`C0 RX`, 744,966 of them) give where the arm was before most moves. + +**Reproduce:** `python tools/hxusbcomm_timing.py --max-mb 900 --out timing.json` on any Venus traces. It reads one file at a time, so memory stays flat. + +**How moves are isolated:** within a group of otherwise identical commands, time = overhead + move(distance), with one overhead per group. +- Pure X jogs: `C0 JX`, and `C0 EM` when Y and Z are unchanged. +- `C0 AS`/`DS` identical except for X, with the channels' Y unchanged from the previous command. +- Single-channel Y and Z jogs: `C0 KY`, `C0 KZ`. +- A move is measured only from a previous command that answered without error. + +### 8.1 The X-arm is jerk-limited +15 groups: + +| Model | Parameters | RMS | Within-group R² | AIC | AICc | BIC | +|---|---|---|---|---|---|---| +| **S-curve, overhead per group** | v 600 mm/s, a 1297 mm/s², j 3210 mm/s³ | **23.4 ms** | **0.976** | **−639.6** | **−630.0** | **−594.6** | +| Trapezoid, overhead per group | v 595 mm/s, a 605 mm/s² | 35.5 ms | 0.945 | −566.9 | −558.4 | −524.4 | +| S-curve, one shared delay | — | 3818 ms | −635 | 249.1 | 249.6 | 259.1 | +| Trapezoid, one shared delay | — | 3880 ms | −656 | 250.1 | 250.3 | 257.6 | + +- **Why a trapezoid can't fit:** in both pure jogs, going from 1 to 10 mm adds 0.30 s and from 10 to 100 mm adds 0.50 s (a ratio of 1.65). A trapezoid gives at least √10 ≈ 3.2 for any acceleration, even with a speed cap. A constant delay cancels in these differences, so it can't rescue the trapezoid. +- **Sensitivity:** jerk is well determined (halving or doubling it roughly doubles the RMS). Speed and acceleration are loosely determined above ≈450 mm/s and ≈1100 mm/s², because few recorded moves are long enough to cruise. +- **One shared delay is rejected:** the constant part of a command's time is specific to the command, not a single latency. + +### 8.2 Channel Y and Z are trapezoids + +| Axis | Jog data (distance: median time) | Fit | +|---|---|---| +| Channel Y (`C0 KY`) | 1 mm: 0.132 s (n20); 10 mm: 0.285 s (n63); 100 mm: 0.730 s (n1) | Trapezoid v ≈ 300 mm/s, a ≈ 900 mm/s², RMS 4.7 ms. Jerk improves it by < 1 ms (noise). | +| Channel Z (`C0 KZ`) | 1 mm: 0.185 s (n139); 10 mm: 0.338 s (n39) | PLR's `default_z_acceleration` 800 mm/s² reproduces the 1→10 mm step to 0.1 ms. | + +Only three Y distances (one sample at 100 mm) and two Z distances exist, so mild jerk on Y or Z can't be ruled out. + +### 8.3 Other factors (command overheads are now used by the page: 8.6) +- **Communication round trip:** read replies arrive in ≈9–21 ms (median `X0 RF` 9 ms, `H0 RH` 7 ms, `C0 RX` 21 ms). This is the only delay common to all commands. +- **Command overhead is command-specific.** For the same X move, the 96-head's `C0 EM` takes ≈0.36 s longer than a channel `C0 JX` (overheads ≈0.48 s vs ≈0.12 s after the fitted move time). +- **In tip commands, short X moves overlap the Z stroke.** A `C0 TP` after a `C0 TR` takes 6.331 s for 9 mm and 6.335 s for 18 mm, where the X-arm alone needs ≈0.1 s more for the longer move. Above ≈20 mm the X distance shows again. The firmware appears to move X while Z is still travelling. The player runs the phases in sequence, so tip strokes are drawn slightly long for short moves. +- **Reply times cluster on ≈50 ms steps** in some long commands (e.g. `C0 DS` at 4.10 / 4.15 / 4.20 s), which suggests the controller reports on a polling tick. It limits resolution for small effects. +- **Command durations** (medians over all traces; these depend on each command's parameters, so they describe, they don't model): + +| Command | n | Median | Command | n | Median | +|---|---|---|---|---|---| +| `C0 AS` aspirate | 229,489 | 6.41 s | `C0 PP` iSWAP get plate | 50,365 | 6.76 s | +| `C0 DS` dispense | 218,050 | 5.00 s | `C0 PR` iSWAP put plate | 47,743 | 6.05 s | +| `C0 TP` tip pick-up | 55,084 | 6.18 s | `C0 PG` iSWAP park | 11,839 | 7.62 s | +| `C0 TR` tip drop | 55,088 | 8.02 s | `C0 EA` / `C0 ED` 96-head aspirate / dispense | 49,616 / 48,950 | 7.75 / 6.11 s | +| `C0 EP` / `C0 ER` 96-head tips | 5,965 / 5,967 | 8.82 / 7.46 s | `C0 RX` read arm X | 744,966 | 0.021 s | + +### 8.4 Channel commands: a full model, and do the channels move in Y one after another? + +**Reproduce:** `python tools/hxusbcomm_channels.py `. + +**Method.** Each `C0 AS/DS/TP/TR` is paired with the channel command just before it, when nothing else moved in between, both answered without error, and the active channels share one X. The duration is fit as + +d = a[group] + b_v · volume time + b_m · mix time + b_z · Tz(stroke) + b_xy · Txy(dx, dy₁…dy₈) + +- A group is the previous command plus every categorical parameter; continuous parameters, and mixing parameters when there is no mixing, are removed. +- Volume time is volume ÷ flow for the slowest channel. The Z stroke runs from traverse height to the liquid surface or tip height. +- X uses the §8.1 S-curve and Y the §8.2 trapezoid. Nonlinear constants come from a grid search with the linear solve inside each grid point. +- A Y shift under 0.5 mm counts as no move (positions jitter by 0.1 mm between commands). + +**`C0 AS`** (228,260 pairs, 706 groups; Y moves: 63,073 × 8 channels, 5,532 × 7, 95 × 2–4): + +| Txy model | R² within | RMS | AIC | BIC | +|---|---|---|---|---| +| no XY | 0.9610 | 96.3 ms | −1,067,012 | −1,059,682 | +| X only | 0.9831 | 63.4 ms | −1,257,512 | −1,250,172 | +| Y together (max) | 0.9708 | 83.4 ms | −1,132,777 | −1,125,437 | +| Y sequential (sum) | 0.9708 | 83.2 ms | −1,133,447 | −1,126,107 | +| max(X, Y together) | 0.9885 | 52.4 ms | −1,345,185 | −1,337,845 | +| max(X, Y sequential) | 0.9756 | 76.2 ms | −1,173,739 | −1,166,398 | +| X then Y together | 0.9853 | 59.1 ms | −1,290,135 | −1,282,795 | +| X then Y sequential | 0.9740 | 78.6 ms | −1,159,627 | −1,152,287 | +| **max(X, Y together + 0.80 s when Y moves)** | **0.9950** | **34.5 ms** | **−1,536,170** | **−1,528,820** | +| max(X, Y together + 0.12 s per extra channel) | 0.9950 | 34.6 ms | −1,534,356 | −1,527,005 | +| max(X, Y together + 0.80 s + 0.00 s per extra channel) | 0.9950 | 34.5 ms | −1,536,168 | −1,528,807 | + +**`C0 DS`** (216,436 pairs, 357 groups; Y moves: 67,759 × 8, 5,533 × 7, 71 × 4): + +| Txy model | R² within | RMS | AIC | BIC | +|---|---|---|---|---| +| no XY | 0.8699 | 148.8 ms | −824,027 | −820,324 | +| X only | 0.8820 | 141.7 ms | −845,089 | −841,376 | +| Y together (max) | 0.9637 | 78.6 ms | −1,100,086 | −1,096,373 | +| Y sequential (sum) | 0.9639 | 78.4 ms | −1,101,521 | −1,097,808 | +| max(X, Y together) | 0.8941 | 134.3 ms | −868,499 | −864,786 | +| max(X, Y sequential) | 0.9730 | 67.8 ms | −1,164,167 | −1,160,454 | +| X then Y together | 0.9335 | 106.3 ms | −969,445 | −965,732 | +| X then Y sequential | 0.9632 | 79.1 ms | −1,097,515 | −1,093,802 | +| max(X, Y together + 0.75 s when Y moves) | 0.9885 | 44.3 ms | −1,348,629 | −1,344,906 | +| max(X, Y together + 0.12 s per extra channel) | 0.9884 | 44.4 ms | −1,347,722 | −1,343,999 | +| **max(X, Y together + 0.20 s + 0.08 s per extra channel)** | **0.9887** | **43.9 ms** | **−1,352,573** | **−1,348,840** | + +**`C0 TP` / `C0 TR`:** within groups, a tip pick-up never changes Y. The best models reach R² 0.05 (TP, RMS 26 ms) and 0.007 (TR, RMS 123 ms). Nothing about Y can be read from them. + +**What is established:** +- X and Y overlap. In an earlier pass with a coarser key, max(X, Y together + a fitted Y cost) beat X-then-Y-with-its-own-fitted-cost by ΔAIC ≈ 212,000 (AS) and ≈ 433,000 (DS). +- Any Y move costs ≈0.75–0.80 s on top of its travel time (FIT). +- Volume time enters with slope 0.99–1.02 (FIT). + +**What is not established: ripple, i.e. whether the Y cost is a stagger per channel.** A flat 0.80 s and 0.12 s per extra channel (0.84 s for 8 channels) predict the same for the 8- and 7-channel moves that make up 99.9% of the data, so their AIC difference is small. For DS, the joint model's per-channel part rests on the 7-vs-8 split, which is also a split between protocols. +- The one independent test uses held-out rows with 2–6 channels moving, fitted on the rest. +- On 53 four-channel dispenses from one protocol, the flat 0.80 s overpredicts by 0.50 s (RMS 498 ms), while 0.12 s per extra channel misses by only −0.07 s (RMS 86 ms). This points to a stagger, but it comes from a single protocol. +- The aspirate "few-channel" rows are 0.1 mm jitter, not moves. +- On this data alone the ripple is suggestive. A run that moves 1–4 channels in Y inside otherwise identical commands would test it independently. Single-channel `C0 KY` jogs (overhead ≈65–75 ms beyond travel) are a different command and aren't used as evidence. + +**The ripple's size, taken as given.** The ripple is known from the device (user; DOC to follow). Fitting one Y cost per count of channels moving (`C0 DS`, `ripple` in the tool's output; R² within 0.9883, RMS 42.5 ms, AIC −1,366,602, the best DS model): + +| Channels moving in Y | Y cost beyond travel (FIT) | 95% profile interval | Rows | Source of the rows | +|---|---|---|---|---| +| 4 (channels 1–4) | 0.300 s | 0.295–0.315 s | 71 | one protocol, 15 groups, 12 with still rows | +| 7 | 0.690 s | 0.685–0.695 s | 5,533 | channel 8 idle | +| 8 | 0.760 s | 0.755–0.760 s | 67,759 | — | + +- A line through the three gives **0.118 s per extra channel** with a fixed part of −0.05 s, i.e. about none. +- The page model: channel *i* (in the order they move) starts 0.12 s after the one before it, and the command waits for the last to arrive. +- The 7→8 step (0.07 s) is smaller than the line's slope. The order the firmware moves them in, and whether the stagger depends on distance, aren't resolved by these three counts. + +**Pure Z,** from DS→AS pairs with no X or Y change and no mixing (32,968 pairs): +- Strokes span 12.9–57.9 mm in two clusters, 13 mm and 53–56 mm. +- Z is identified only when inert parameters are left out of the group key. With the mixing speed in the key, groups split by protocol and absorb the stroke: R² gains just 0.0002. + +| Model (group key without inert parameters) | R² within | RMS | AIC | BIC | +|---|---|---|---|---| +| volume only | 0.7644 | 134.9 ms | −132,056 | −131,972 | +| volume + linear stroke (0.0142 s/mm) | 0.9858 | 33.2 ms | −224,555 | −224,463 | +| **volume + 2 × trapezoid (v 150 mm/s, a 800 mm/s²), slope fixed at 1** | **0.9858** | **33.1 ms** | **−224,741** | **−224,640** | +| volume + 2 × trapezoid (v 125, a 800) | 0.9812 | 38.1 ms | −215,344 | −215,260 | + +The acceleration agrees with PLR's `default_z_acceleration` (800 mm/s²). The speed is loosely determined (150–200 mm/s across group keys), because the strokes form two clusters. + +### 8.5 Next fits the same data supports +- A decisive Y-ripple run (above). +- iSWAP park (`C0 PG`), and the far-right iSWAP path (8.8). +- The X/Z overlap in tip commands, as a phase overlap in the player. + +### 8.6 Fixed time per command (what a command takes beyond the drawn motion) + +**Reproduce:** `python tools/hxusbcomm_fixed.py `. + +**Method.** Each command is timed exactly as the page draws it, using the constants in `motion.py`: +- X on the S-curve; +- channel Y at 300 mm/s and 900 mm/s², the channels setting off 0.118 s apart; +- Z at 150 mm/s and 800 mm/s²; +- the aspiration dwell (volume ÷ flow + settling time), and the swap-speed exit. + +The measured duration minus that drawn time is the command's fixed time. It's fitted as a linear model of the parameters that plausibly set it. The page plays it as a pause before the motion, except the tip commands', which hold at the bottom: a pick-up with the tips on, a drop while they are pushed off. Each hold is the measured duration less the drawn motion, so it assumes the tip commands' Z runs at the fitted speed. *Where* in a command the firmware spends it isn't measured (HEUR). + +| Command | Model (FIT) | n | R² of the fixed time | RMS | AIC | BIC | Constant only: RMS / AIC | Whole-duration R², median miss | +|---|---|---|---|---|---|---|---|---| +| `C0 AS` | 1.879 s + 0.979 × mix volume time + 0.557 s × mix cycles + 6.761 × transport-air time | 228,477 | 0.641 | 521 ms | −297,806 | −297,765 | 870 ms / −63,563 | 0.912, 65 ms | +| `C0 TP` | 4.421 s, played as a 4.356 s hold at the bottom after 0.065 s of command handling (placement HEUR), **plus the press drawn as motion** (0.0871 s/mm of `tp − tz`) and the descent setting off at 82% of the crossing (both below) | 48,304 | 0.830 | 38 ms | −316,189 | −316,172 | 94 ms / −228,685 | ≈0.84, — | +| `C0 TR` | 5.009 s (mean; channels add 0.21 s each, R² 0.15, but 7 against 8 channels is also one protocol against the others: left out), played as a 4.944 s hold at the bottom while the tips are pushed off, after 0.065 s of command handling (placement HEUR) | 54,290 | 0.000 | 176 ms | −188,805 | −188,797 | same | ≈0.61, — | +| `C0 ZA` | 0.14 s (median of 1,783; the channels were mostly already up) | 1,783 | — | — | — | — | — | +| `C0 EP` (96-head tips on) | 4.938 s (median, against PLR's head drive defaults; IQR 4.934–4.947), played as a 4.873 s hold at the bottom after 0.065 s of handling (placement HEUR) | 155 | — | — | — | — | — | +| `C0 ER` (96-head tips off) | 4.490 s (median; IQR 4.281–4.499), played as a 4.425 s hold at the bottom after 0.065 s of handling (placement HEUR) | 5,867 | — | — | — | — | — | +| `C0 EA` / `C0 ED` (96-head aspirate / dispense) | `EA` 5.80 s; `ED` by mode (`da`): jet (0, 1) 1.41 s, surface / empty (2, 3, 4) 5.85 s. Held at the bottom with the pumping (volume ÷ flow) and settling time, after 0.065 s of handling (placement HEUR). From four local traces (`tools/hxusbcomm_head96_liquid.py`: travel timed as 8.7, less pumping and settling): `EA` median of 46 (IQR 5.2–6.9 s; 5.4–6.9 by protocol), `ED` without mixing at ≥ 100 µL/s: mode 1 median of 12 (1.40–1.44 s), mode 2 median of 8 (5.83–5.90 s), mode 3 a single 4.90 s. Modes 0 and 4 are unrecorded, and a few mode-3 or 10 µL/s dispenses took 11–13 s that these parameters don't explain; the Chory lab set (≈49,000 of each) would settle both | 46 / 21 | — | — | — | — | — | +| `H0 YA/ZA` (96-head moves) | 0.11 s: an `H0 YP` that travels nothing (10th percentile of 31); HEUR by analogy | — | — | — | — | — | — | +| `C0 JY/JZ/FY`, `X0 XP`, iSWAP `R0` steps | 0.065 s: a 1 mm `C0 KY` jog less its travel; HEUR by analogy | — | — | — | — | — | — | +| `C0 ZT`/`ZS` (CO-RE tools) | as `C0 TP`/`C0 TR`, held at the bottom; HEUR, no recorded counterpart | — | — | — | — | — | — | +| `C0 ZP`/`ZR` (CO-RE plate grip / release) | as `C0 ZT`/`ZS`, held at the bottom (`CORE_PLATE_GRIP_FIXED`, `CORE_PLATE_RELEASE_FIXED`); HEUR and likely long: a grip closes two channels on a plate, with no tip to clamp or check. No recorded counterpart; a trace of a protocol that uses the CO-RE gripper (KAPA does) would settle it | — | — | — | — | — | — | + +- **Why these R² look modest:** they measure the fixed time, which is what's left after the drawn motion, and it spreads little (tip pick-up: 88 ms). Against the whole duration the page's timing reaches R² 0.91 (aspirate), ≈0.84 (tip pick-up) and ≈0.61 (tip drop; the whole-duration figures for the tip commands are 1 − RMS²/variance of the duration). +- **Channel count is left out.** Nearly every recorded command uses 7 or 8 channels, so its coefficient tracks protocols: +0.04 s in one extraction, −0.27 s in another differing by 0.1% of rows. +- **Aspirate:** the median |residual| is 65 ms; the RMS is inflated by a few protocols (liquid-level detection, 34 rows, which the page doesn't draw). Mixing happens at the bottom, so its two terms are added to the dwell, not the pause. +- **Tip press:** the fixed time of `C0 TP` grows 0.0871 s per mm of `tp − tz`. The page goes down to `tp` at the Z drive's speed and presses on to `tz` at 1 / 0.0871 = **11.5 mm/s**. Only two press lengths are recorded (8 and 10 mm). +- **Tip pick-up overlap:** the descent to `tp` sets off when 82% of the crossing's time has passed (R² of the fixed time 0.830 against 0.810 in sequence, ΔAIC 7,234). Going from 9 to 18 mm adds 116 ms of X travel but only 7 ms to the command; 27 mm adds 198 ms but 112 ms. Nearly every recorded pick-up moves 9 mm (48,024 of 48,172), so the share is loosely determined. Drops can't be checked: hardly any travel without a Y move. +- **Correction (2026-09-26):** a tip command sends `tp` and `tz` once for all channels. An earlier version of the tool read them as channel 0's only and timed the other channels' descents from Z = 0, which made the pick-up and drop fixed times about 3 s too small (1.46 and 3.06 s). The aspirate, whose heights are always per channel, was unaffected, as are the ripple and Z fits in 8.4. + +### 8.7 The 96-head + +`C0 EM/EP/ER` following another head command (7,182 pairs, 86 groups). Z heights are fixed within each group, so head Z can't be identified here. The head's X and Y overlap (ΔAIC ≈ 11,100 over X-then-Y at the same constants). + +| Model | R² within | RMS | AIC | BIC | +|---|---|---|---|---| +| no travel terms | 0 | 268.7 ms | −18,705 | — | +| X then head Y, + Z, PLR defaults | −0.001 | 268.8 ms | −18,698 | −18,107 | +| **max(X, head Y) + Z, PLR defaults (Y 390.6 mm/s, 546.9 mm/s²; Z 85 mm/s, 400 mm/s²), no free parameter** | **0.787** | **124.1 ms** | **−29,803** | **−29,211** | +| max(X, head Y 300, 1500) + Z PLR, head Y fitted | 0.855 | 102.2 ms | −32,594 | −32,002 | + +The fitted head constants slide along the grid edges (speed and acceleration trade off, and few moves reach cruise), so the page keeps **PLR's head defaults**. The data agrees with them (R² 0.79 with no free parameter); what it adds is the overlap of X and Y and the fixed times in 8.6. + +### 8.8 The iSWAP + +Venus sends whole moves (`C0 PP` get, `C0 PR` put); PLR sends the iSWAP's steps one by one (`R0` commands, each carrying its own speeds). So these fits describe the device, but don't map one-to-one onto PLR's steps. + +| Command | Model (fixed effect per target site and grip parameters) | n | Groups | R² within | RMS | AIC | BIC | +|---|---|---|---|---|---|---|---| +| `C0 PP` | max(X, Y), Y 400 mm/s, 1200 mm/s² | 33,727 | 158 | 0.650 | 229 ms | −99,149 | −97,818 | +| `C0 PP` | **X then Y, Y 300 mm/s, 800 mm/s²** | 33,727 | 158 | **0.923** | **107 ms** | **−150,299** | **−148,967** | +| `C0 PR` | max(X, Y), Y 300 mm/s, 200 mm/s² | 47,384 | 201 | 0.507 | 120 ms | −200,479 | −198,717 | +| `C0 PR` | **X then Y, Y 300 mm/s, 800 mm/s²** | 47,384 | 201 | **0.815** | **74 ms** | **−246,796** | **−245,034** | + +- **The iSWAP moves X first, then Y** (ΔAIC ≈ 51,000 for gets and ≈ 46,000 for puts). This is unlike the channels and the 96-head, which overlap X and Y. +- **iSWAP Y ≈ 300 mm/s, 800 mm/s² (FIT).** PLR sends 4,751 increments/s ≈ 220 mm/s, which it documents as 68% of the firmware's default (≈ 323 mm/s). Venus evidently runs near the documented default. PLR's plans carry their own speeds, so the page already plays PLR's slower Y; that is what PLR would do on the device. +- **Fixed times of the whole moves:** `C0 PP` 4.93 s and `C0 PR` 4.54 s for targets on the deck. +- **Far-right targets (X > 850 mm) take ≈ 8.5 s longer** (13.49 / 12.85 s fixed, 6,575 + 10,640 moves). They're all at X ≈ 890–910 mm, Y ≈ 244 or 421 mm, most likely off-deck or extended-reach positions where the firmware takes a longer arm path. Not modelled further. +- **Not yet known:** how a whole move's fixed time divides among PLR's `R0` steps (gripper open/close, wrist, Z). The page gives each step the generic 0.065 s. + +### 8.9 The whole timing model, at a glance + +| Factor | Value used by the page | Tag | Fit quality | PLR's value | Known for sure? | +|---|---|---|---|---|---| +| X-arm | S-curve 600 mm/s, 1297 mm/s², 3210 mm/s³ | FIT | R² 0.976, RMS 23 ms, AIC −640 (vs trapezoid −567) | none stated | Jerk yes; speed/acceleration loose above ≈450 / ≈1100 | +| Channel Y travel | trapezoid 300 mm/s, 900 mm/s² | FIT | RMS 4.7 ms on jogs; slope 1.01 in the full model | 250 mm/s | Yes for 1–10 mm; one 100 mm sample | +| Channel Y ripple | 0.118 s per channel after the first, channel order | FIT | best DS model: R² 0.9883, RMS 42.5 ms, AIC −1,366,602 | none (moves together) | Size yes (4/7/8 channels); order and distance dependence no | +| X and channel Y | overlap: max(X, Y) | FIT | AS, each with a fitted Y cost: R² 0.993 vs X-then-Y 0.983 (ΔAIC ≈ 212,000) | the simulator: at once | Yes | +| Channel Z | trapezoid 150 mm/s, 800 mm/s² | FIT (speed), PLR (acceleration) | R² 0.986, RMS 33 ms, AIC −224,741 | 125 mm/s, 800 mm/s² | Acceleration yes; speed loose, 150–200 | +| Aspiration dwell | volume ÷ flow + settle | FW | slope 0.99–1.02 | same | Yes | +| Mixing | 0.979 × 2·cycles·volume ÷ speed + 0.557 s per cycle | FIT | part of the AS model | not drawn before | Yes (20k mixes) | +| Tip press | 11.5 mm/s from `tp` to `tz` | FIT | TP fixed-time R² 0.83, RMS 38 ms | none (straight to `tz`) | Two press lengths only | +| Fixed times | per command, 8.6 | FIT / HEUR | 8.6 | none | Measured for AS, TP, TR, ZA, EP, ER; by analogy for the rest; when in the command it falls isn't known | +| 96-head X and Y | overlap: max(X, Y) | FIT | ΔAIC ≈ 11,100 over X-then-Y | the simulator: at once | Yes | +| 96-head Y, Z | PLR defaults (390.6 / 546.9; 85 / 400) | PLR | R² 0.79 with no free parameter | same | Not identified better by the traces | +| iSWAP X and Y | X then Y | FIT | ΔAIC ≈ 46,000–51,000 | PLR plans its own step order | Yes, for Venus's whole moves | +| iSWAP Y | PLR's own step speeds (≈220 mm/s) | PLR | Venus: 300 mm/s, 800 mm/s² (R² 0.92 / 0.82) | ≈220 mm/s | Yes for Venus; PLR deliberately slower | +| iSWAP step fixed time | 0.065 s per `R0` step | HEUR | — | none | No: whole moves measured (4.9 / 4.5 s), not their split + +## 7. Liquid, as drawn (not a firmware command) +A vessel's cavity is tinted from white to orange by volume ÷ capacity, stepping to 35% for any liquid at all (PR #1378 page, `live.js` `refreshOverlays`; not ours). It changes when the model's state update arrives, after the command. Tips show their contents only in the 2D channel panel. Moving liquid (a surface lowered over the dwell, a column rising in the tip) is not drawn yet. The data for it are PLR's (`Container.compute_height_from_volume`) plus the `C0 AS` fields above. + +--- + +## Sources +- PyLabRobot forum: [surface following, post 6](https://discuss.pylabrobot.org/t/hamilton-surface-following-issues/304/6); [z-touch firmware, posts 2–3](https://discuss.pylabrobot.org/t/reason-behind-z-touch-only-being-available-to-8-channels-made-in-2022-and-onwards/543/3); [Tame the iSWAP, post 1](https://discuss.pylabrobot.org/t/intro-to-epic-tame-the-iswap/517/1) and [post 4](https://discuss.pylabrobot.org/t/intro-to-epic-tame-the-iswap/517/4). +- labautomation.io: [side touch, post 6](https://labautomation.io/t/post-dispense-tip-touch-on-starlet/2255/6) and [post 9](https://labautomation.io/t/post-dispense-tip-touch-on-starlet/2255/9); [touch-off and the MPH, post 2](https://labautomation.io/t/touch-off-with-mph/1788/2); [Transport Paths Editor](https://labautomation.io/t/transport-paths-editor/5759). +- PyLabRobot PRs: [#1362 LLD aspirate](https://github.com/PyLabRobot/pylabrobot/pull/1362), [#1333 cLLD](https://github.com/PyLabRobot/pylabrobot/pull/1333), [#1334 pLLD](https://github.com/PyLabRobot/pylabrobot/pull/1334), [#1336 probing](https://github.com/PyLabRobot/pylabrobot/pull/1336), [#1352 96-head cLLD](https://github.com/PyLabRobot/pylabrobot/pull/1352), [#63 PLACE_SHIFT heights](https://github.com/PyLabRobot/pylabrobot/pull/63). diff --git a/pylabrobot/hamilton/star/driver/features/core_grippers.py b/pylabrobot/hamilton/star/driver/features/core_grippers.py index 9e19d35d0c4..e2d2ecca208 100644 --- a/pylabrobot/hamilton/star/driver/features/core_grippers.py +++ b/pylabrobot/hamilton/star/driver/features/core_grippers.py @@ -77,6 +77,10 @@ def __init__( self._holding_resource_width: Optional[float] = None self._held_resource: Optional[Resource] = None self._taken_from: Optional[Tuple[Resource, Optional[Coordinate]]] = None + # For whoever acts a command out (the viewer), set before it is sent: what a grip takes and + # where it will hang from the front tool, or what a release puts down and where. Cleared by + # nothing in particular: each grip or release overwrites it. + self._handover: Optional[Tuple[Resource, Optional[Resource], Optional[Coordinate]]] = None # -- what carries them --------------------------------------------------------------------------- @@ -168,19 +172,19 @@ def _front_tool(self) -> Optional[Resource]: shaft = self._pipettes.shaft(self._front_channel) return shaft.tip if shaft is not None and shaft.has_tip() else None - def _hang_held_resource_on_the_front_tool(self) -> None: - """Hang the held resource from the front tool where the jaws hold it, so it rides with them. + def _hang_location(self, held: Resource, from_top: Optional[float]) -> Optional[Coordinate]: + """Where `held` hangs from the front tool, as its child location, or None without the model. - Its centre at the jaws' centre, its top `pickup_distance_from_top` above the grip line - the - front channel's stop disc less the tool's overhang. Nothing happens while nothing models them. + Its centre at the jaws' centre, its top `from_top` below the grip line - the front channel's + stop disc less the tool's overhang. """ - held, from_top, tool = self._held_resource, self._pickup_distance_from_top, self._front_tool() + tool = self._front_tool() if held is None or from_top is None or not isinstance(tool, HamiltonCoreGripperTool): - return + return None back = self._pipettes.get_reference_point_location(cast(int, self._back_channel)) front = self._pipettes.get_reference_point_location(cast(int, self._front_channel)) if back is None or front is None: - return + return None grip_line = front.z - (tool.get_size_z() - tool.fitting_depth - tool.grip_line_height) center = held.center().rotated(held.get_absolute_rotation()) lfb = Coordinate( @@ -188,8 +192,20 @@ def _hang_held_resource_on_the_front_tool(self) -> None: (back.y + front.y) / 2 - center.y, grip_line + from_top - held.get_absolute_size_z(), ) + return lfb - tool.get_location_wrt(self._deck) + + def _hang_held_resource_on_the_front_tool(self) -> None: + """Hang the held resource from the front tool where the jaws hold it, so it rides with them. + + Nothing happens while nothing models them. + """ + held, from_top = self._held_resource, self._pickup_distance_from_top + location = self._hang_location(held, from_top) if held is not None else None + tool = self._front_tool() + if held is None or location is None or not isinstance(tool, HamiltonCoreGripperTool): + return held.unassign() - tool.assign_child_resource(held, location=lfb - tool.get_location_wrt(self._deck)) + tool.assign_child_resource(held, location=location) def _put_held_resource_on_the_deck(self) -> None: """Put a resource hanging from the front tool on the deck, where it is now.""" @@ -856,6 +872,9 @@ async def pick_up_resource( raise ValueError(f"the jaws would close to {closed} mm; squeeze_mm is too large") await pipettes._require_iswap_parked() + # For whoever acts the command out: what is taken, and where it will hang. + self._handover = (resource, self._front_tool(), self._hang_location(resource, from_top)) + source = (resource.parent, resource.location) try: async with pipettes._temporary_z_drive_profile( @@ -966,6 +985,9 @@ async def drop_resource( raise ValueError(f"y_clearance must be 0 or more, is {y_clearance}") await pipettes._require_iswap_parked() + # For whoever acts the command out: what is put down, and where it lands. + self._handover = (held, destination, child) + try: async with pipettes._temporary_z_drive_profile( acceleration=z_acceleration, channels=[back, front] diff --git a/pylabrobot/hamilton/star/driver/features/head96.py b/pylabrobot/hamilton/star/driver/features/head96.py index 81d9babed1b..8d3c140bd61 100644 --- a/pylabrobot/hamilton/star/driver/features/head96.py +++ b/pylabrobot/hamilton/star/driver/features/head96.py @@ -751,6 +751,27 @@ async def pick_up_tips( await self._record_after_tip_command(command_error) await self.dispensing_drive_request_uL_position() + def _spots_under_shafts(self, offset: Optional[Coordinate]) -> List[Optional[int]]: + """For each shaft, in spot order (A1, B1, ..., H12), the index of the rack spot it stands over + when head channel A1 is sent to spot A1 plus `offset`, or None where it is past the rack. + + Raises: + ValueError: If the offset leaves the channels between spots rather than over them. + """ + pitch = self.configuration.channel_pitch + offset = offset or Coordinate.zero() + columns, rows = round(offset.x / pitch), round(-offset.y / pitch) + for residual in (offset.x - columns * pitch, -offset.y - rows * pitch): + if abs(residual) > 1.0: + raise ValueError( + f"an offset of ({offset.x}, {offset.y}) puts the channels between the rack's spots" + ) + under: List[Optional[int]] = [] + for shaft in range(96): + column, row = shaft // 8 + columns, shaft % 8 + rows + under.append(column * 8 + row if 0 <= column < 12 and 0 <= row < 8 else None) + return under + # -- tip drop ------------------------------------------------------------------------------------ async def drop_tips( diff --git a/pylabrobot/hamilton/star/driver/features/pipettes.py b/pylabrobot/hamilton/star/driver/features/pipettes.py index 931221ef832..c06c81667d7 100644 --- a/pylabrobot/hamilton/star/driver/features/pipettes.py +++ b/pylabrobot/hamilton/star/driver/features/pipettes.py @@ -66,6 +66,7 @@ from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.errors import HasTipError, NoTipError +from pylabrobot.resources.hamilton.core_gripper_tools import HamiltonCoreGripperTool from pylabrobot.resources.hamilton.tip_creators import HamiltonTip, TipDropMethod, TipPickupMethod from pylabrobot.resources.n_channel_pipettes import NChannelPipette, TipMountingShaft from pylabrobot.resources.resource import Resource @@ -84,6 +85,20 @@ """An X tolerance wider than any deck: X alone never splits a tip command into batches, so spots spread across columns go out in one, as legacy sends them.""" + +def core_tool_face_distance(tool: HamiltonCoreGripperTool) -> float: + """How far a CO-RE grip tool's face stands from its channel's axis, in Y.""" + pick_up = tool.pick_up_location or tool.get_anchor("c", "c", "t") + return float(tool.get_size_y() - pick_up.y) + + +def core_tool_grip_line_overhang(tool: HamiltonCoreGripperTool) -> float: + """How far a mounted CO-RE grip tool's grip line hangs below its channel's stop disc, in mm: the + height the CO-RE plate commands are given in.""" + pick_up = tool.pick_up_location or tool.get_anchor("c", "c", "t") + return float(pick_up.z - tool.fitting_depth - tool.grip_line_height) + + T = TypeVar("T") ChannelType = Literal["ML_STAR", "ML_STAR_RPC"] diff --git a/pylabrobot/hamilton/star/driver/simulator.py b/pylabrobot/hamilton/star/driver/simulator.py index 76f721542e6..740af048cae 100644 --- a/pylabrobot/hamilton/star/driver/simulator.py +++ b/pylabrobot/hamilton/star/driver/simulator.py @@ -15,7 +15,7 @@ import datetime import logging import math -from typing import Any, Dict, List, Literal, Optional, Tuple, cast +from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Tuple, cast from pylabrobot.hamilton.protocol.text.framing import ( assemble_channel_command, @@ -1892,6 +1892,11 @@ def __init__( # What the drives would still be doing, in seconds: the longest move recorded since the last # command, waited out before the next one goes. self._motion_owed = 0.0 + # Who acts out a command's motion before the device answers it, or None: called with the + # module, the command and its parameters, and awaited. A viewer that animates what the drives + # do between the positions the model records sets this, so the command takes as long as the + # animation and the model moves only once it has played. + self.motion_listener: Optional[Callable[[str, str, Dict[str, Any]], Awaitable[None]]] = None # How far a tip of each defined type stands below the stop disc, by tip type index, as # `define_tip_needle` was told. A tip command names one of these, and what it collects hangs # that far down: the traverse height it ends at is the tip's, so the stop disc ends higher. @@ -2098,6 +2103,8 @@ async def _send( **kwargs, ) await self.pay_motion_time() + if self.motion_listener is not None: + await self.motion_listener(module, command, kwargs) answered = await self._answer(module, command, **kwargs) if answered is None: self._log_exchange(cmd, None) diff --git a/pylabrobot/hamilton/star/motion.py b/pylabrobot/hamilton/star/motion.py new file mode 100644 index 00000000000..dc88de8d185 --- /dev/null +++ b/pylabrobot/hamilton/star/motion.py @@ -0,0 +1,1242 @@ +"""What a STAR's firmware commands ask its drives to do, in the resource tree's frames. + +PyLabRobot records where the arm and the channels are once a command has run. How they got there is +the device's own business: its motion controller works it out from the command. The page acts that +out, and needs the command's targets to do it. + +`star_motion` reads one command and returns those targets as local positions of the resources that +model the drives, so the page can move them without knowing anything about firmware. A command that +moves nothing, or one this does not read, returns None. + +Everything here comes from the driver or from the command itself, except what the driver does not +state and a STAR's own command timings do (Venus HxUsbComm traces; MOTION_PROFILES.md section 8): +the X-arm's speed, acceleration and jerk (it moves on an S-curve); the channels' Y speed and +acceleration, and the ripple in which they start their Y moves one after another; the channels' Z +speed; the slow press of a tip pick-up; and each command's fixed time. + +- Channel Z acceleration is the channels' default (`Pipettes.default_z_acceleration`), which the + traces agree with. +- The stroke of a tip command is the one the simulator records (`_record_tip_command`): across, with + the arm and the channels moving at once, down onto the spots, and back up. A pick-up presses the + last stretch, from `tp` to `tz`, slowly. +- An aspiration dwells for as long as its own volume and flow rate say, plus its settling time and + its mixing, and leaves the liquid at its own swap speed. +- Every command also takes a fixed time, beyond the motion: what the traces measure less what the + page draws, as a function of the command's own parameters where they matter. +- The 96-head's Y and Z move at the head's own drive defaults (`HeadConfiguration`), or at what a + move of its own carries; its tip commands make the channels' stroke, channel A1 going to spot A1. +- An iSWAP command moves one drive or two, each at the speed and acceleration the command carries, + converted by the arm's own configuration. The elbow's Y is stated only as a level, so it too moves + at constant speed. A joint turns about the pivot the driver turns it about (`proximal_joint`). + +Heights: the firmware positions the lowest point of what a channel carries, while a channel's +resource is placed by its stop disc, which sits higher by the length of a mounted tip. Every Z here +is converted to the stop disc, with the overhang the channel has when the move is made. +""" + +from typing import Any, Dict, List, Optional, Sequence, cast + +from pylabrobot.hamilton.star.driver.features.pipettes import ( + core_tool_face_distance, + core_tool_grip_line_overhang, +) +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.hamilton.core_grippers import ( + HamiltonCoreGrippers, + HamiltonCoreGripperTool, +) +from pylabrobot.resources.tip_rack import TipSpot, resting_location + +# The driver states no X speed or acceleration: these are fitted to a STAR's own timings, from 2.56 +# million command/reply pairs in Venus HxUsbComm traces (`tools/hxusbcomm_timing.py`). The X-arm is +# jerk-limited: an S-curve fits within-group R^2 0.976 against a trapezoid's 0.945 (delta AIC 72.7, 15 groups). +# Jerk is well determined; speed and acceleration less so (anything above ~450 mm/s and ~1100 mm/s^2 +# fits nearly as well), since few recorded moves are long enough to cruise. +X_SPEED = 600.0 # mm/s +X_ACCELERATION = 1297.0 # mm/s^2 +X_JERK = 3210.0 # mm/s^3 +# Channel Y is stated only as an acceleration level, not a rate. Single-channel Y jogs in the same +# traces (`C0 KY`, 1/10/100 mm) fit a trapezoid at 300 mm/s and this acceleration within 5 ms; jerk +# adds nothing. In the full model of `C0 AS/DS` (`tools/hxusbcomm_channels.py`) the Y travel at these +# values enters with slope 1.01. PLR's `default_y_speed` is 250 mm/s. +CHANNEL_Y_SPEED = 300.0 # mm/s +CHANNEL_Y_ACCELERATION = 900.0 # mm/s^2 +# The channels start their Y moves one after another (the ripple): the Y cost of a move grows by +# this much per channel moving after the first - 0.30 / 0.69 / 0.76 s for 4 / 7 / 8 channels in +# `C0 DS`, a line with slope 0.118 s and no fixed part (MOTION_PROFILES.md 8.4). The order they go +# in is not measured; the page takes them in channel order. +CHANNEL_Y_STAGGER = 0.118 # s +# Channel Z: dispense-then-aspirate pairs with no X or Y move fit a trapezoid at PLR's acceleration +# and this speed (R^2 within 0.986, delta AIC 186 over a straight line). The speed is loose, 150-200 +# mm/s, as the recorded strokes form two clusters. PLR's `default_z_speed` is 125 mm/s. +CHANNEL_Z_SPEED = 150.0 # mm/s +# A tip pick-up goes down to `tp` at the Z drive's speed and presses on to `tz` at this one: the +# fixed time of `C0 TP` grows 0.0871 s per mm of `tp - tz` (R^2 0.83 with TIP_PICKUP_DOWN_FROM; only +# 8 and 10 mm recorded). +TIP_PRESS_SPEED = 11.5 # mm/s +# A tip pick-up lowers its channels before the crossing has finished: from this share of the +# crossing's time (`tools/hxusbcomm_fixed.py`, delta AIC 7,234 over the two in sequence). Moving 18 +# rather than 9 mm adds 116 ms of X travel but 7 ms to the command, 27 mm adds 198 ms but 112 ms. +# Nearly every recorded pick-up moves 9 mm, so the share is loosely determined. Not measured for +# drops: hardly any recorded drop travels without a Y move. +TIP_PICKUP_DOWN_FROM = 0.82 + +# What a command takes beyond the motion the page draws: the measured duration less the drawn one, +# as a model of the command's own parameters (MOTION_PROFILES.md 8.6, `tools/hxusbcomm_fixed.py`). Played as a pause before +# the motion, since the traces time commands but not what the firmware does when. +# The channel count is left out: nearly every recorded command uses 7 or 8 channels, so its +# coefficient only tracks protocols (it swings from +0.04 to -0.27 s with 0.1% of the data). +ASPIRATE_FIXED = 1.879 # s +ASPIRATE_FIXED_PER_TRANSPORT_AIR_TIME = 6.761 # s per s of transport air at the flow rate +# Mixing happens at the bottom, so it adds to the dwell: its volume time, and a turnaround per cycle. +MIX_VOLUME_TIME_FACTOR = 0.979 +MIX_PER_CYCLE = 0.557 # s +TIP_PICKUP_FIXED = 4.421 # s: the tip clamped and checked, beyond the drawn motion +# Held at the bottom, the tip on the shaft, all but a simple command's handling (as a drop's, HEUR). +TIP_PICKUP_HOLD = TIP_PICKUP_FIXED - 0.065 # s +TIP_DROP_FIXED = ( + 5.009 # s, the mean. The channel count adds 0.21 s each (R^2 0.15), but 7 against 8 +) +# channels is also one protocol against the others, so it is left out. +# Where in a drop that time goes is not in the traces, which time whole commands. It is played at +# the bottom, as the hold while the tips are pushed off (HEUR, from watching the device), all but a +# simple command's handling before the motion. +TIP_EJECT_HOLD = TIP_DROP_FIXED - 0.065 # s +CHANNELS_UP_FIXED = 0.14 # s: `C0 ZA`, median over 1,783, the channels mostly already up +HEAD96_TIP_PICKUP_FIXED = 4.938 # s: `C0 EP`, median over 155, against PLR's head drive defaults +HEAD96_TIP_DROP_FIXED = 4.490 # s: `C0 ER`, median over 5,867 +HEAD96_MOVE_FIXED = 0.11 # s: a head command that travels nothing (`H0 YP`, 10th percentile) +# The 96-head's aspirate and dispense beyond their drawn travel, pumping (volume / flow) and settling +# time, from Venus traces (`tools/hxusbcomm_head96_liquid.py`, MOTION_PROFILES.md 8.6): +# `C0 EA` median of 46 (IQR 5.2-6.9 s, 5.4-6.9 by protocol). `C0 ED` by its mode (`da`): jet (0, +# 1) 1.41 s, median of 12 mode-1 dispenses (1.40-1.44 s); surface and empty (2, 3, 4) 5.85 s, median of 8 +# mode-2 dispenses at normal flow. Four traces: modes 0 and 4 are unrecorded, and a few mode-3 or +# 10 uL/s dispenses took 11-13 s the parameters here do not explain. +HEAD96_ASPIRATE_FIXED = 5.80 # s +HEAD96_DISPENSE_FIXED = {0: 1.41, 1: 1.41, 2: 5.85, 3: 5.85, 4: 5.85} # s, by `da` +# Simple single-drive commands with no recorded counterpart (`C0 JY/JZ/FY`, `X0 XP`, iSWAP `R0` +# primitives): what a single-channel 1 mm jog takes beyond its travel (`C0 KY`, 0.132 s less 0.067 s). +SIMPLE_MOVE_FIXED = 0.065 # s +# CO-RE grip tools have no recorded counterpart either; their strokes are tip strokes (HEUR). +CORE_TOOL_PICKUP_FIXED = TIP_PICKUP_FIXED +CORE_TOOL_RETURN_FIXED = TIP_DROP_FIXED +# Nor do their plate grip and release (`C0 ZP` / `ZR`): taken as the tool strokes' (HEUR, and likely +# long - a grip closes two channels on a plate, with no tip to clamp or check). Named apart so that a +# trace with them in it replaces these alone. +CORE_PLATE_GRIP_FIXED = CORE_TOOL_PICKUP_FIXED +CORE_PLATE_RELEASE_FIXED = CORE_TOOL_RETURN_FIXED + +# How close a command's position has to be to a tip spot's centre to be taken as that spot, in mm. +SPOT_TOLERANCE = 1.0 + +# `TipDropMethod.DROP`, as `C0 TR` sends it in `ti`. +TIP_DROP = 1 + + +def _pipetting_arm(driver: Any) -> Optional[Any]: + """The arm carrying the channels, or None when there is none or nothing models it yet.""" + arm = next((a for a in getattr(driver, "arms", []) if a.pipettes is not None), None) + if arm is None or arm.resource is None or not arm.pipettes.resources: + return None + return arm + + +def _xyz(coordinate: Any) -> Dict[str, float]: + return {"x": float(coordinate.x), "y": float(coordinate.y), "z": float(coordinate.z)} + + +def _tenths(value: Any) -> float: + return int(value) / 10 + + +def _as_list(value: Any) -> List[Any]: + """A per-channel parameter as a list; `JY` sends its values as one space-separated string.""" + if isinstance(value, str): + return value.split() + return list(value) + + +def _involved(pattern: Sequence[Any]) -> List[int]: + return [i for i, used in enumerate(pattern) if used in (True, 1, "1")] + + +class _Frames: + """Converts the positions the drives report into where the resources modelling them sit.""" + + def __init__(self, driver: Any, arm: Any): + self.driver = driver + self.arm = arm + self.pipettes = arm.pipettes + self.channels = arm.pipettes.resources + self.deck = driver.deck + self.on_arm = [c.parent.get_location_wrt(self.deck) for c in self.channels] + self.anchors = [self.pipettes._reference_anchor(c) for c in self.channels] + + def overhang(self, channel: int) -> float: + """How far what a channel carries hangs below its stop disc, in mm, read off the model. + + The firmware positions the lowest point of what the channel carries: a tip's bottom, a CO-RE + grip tool's grip line. From the resource tree, so it holds for any driver, not only the + simulator (which works it out the same way, `SimulatedPipettes._below_stop_disc`). + """ + shaft = self.shaft(channel) + if shaft is None: + return 0.0 + bottom = shaft.tip_bottom() + if bottom is None: + return 0.0 + if isinstance(shaft.tip, HamiltonCoreGripperTool): + return float(-bottom.z - shaft.tip.grip_line_height) + return float(-bottom.z) + + def arm_x(self, x: float) -> float: + return round(float(x - self.arm.configuration.reference_point_from_left), 2) + + def channel_y(self, channel: int, y: float) -> float: + return round(float(y - self.on_arm[channel].y - self.anchors[channel].y), 2) + + def channel_z(self, channel: int, stop_disc_z: float) -> float: + return round(float(stop_disc_z - self.on_arm[channel].z - self.anchors[channel].z), 2) + + def lowest_point_z(self, channel: int, z: float, overhang: Optional[float] = None) -> float: + """A firmware height, the lowest point, as the channel's local Z.""" + return self.channel_z(channel, z + (self.overhang(channel) if overhang is None else overhang)) + + def current_y(self, channel: int) -> float: + """Where the channel's reference point is along Y, in mm on the deck.""" + location = self.channels[channel].location + return float(location.y + self.on_arm[channel].y + self.anchors[channel].y) + + def planned_ys(self, targets: Dict[int, float]) -> Dict[int, float]: + """Every channel's Y once the named ones are at their targets, on one rail. + + As `Pipettes._plan_y_positions(make_space=True)` plans it - the plan the simulator records a + tip command with - from where the model has the channels, read as the device reports them, to a + tenth. Channel 0 is at the back, so Y falls as the channel number rises. Behind the backmost + named channel and in front of the frontmost, the others are pushed only as far as the spacing + asks; between two named channels, each unnamed one stands at the spacing in front of the one + behind it. + """ + if not targets: + return {} + n = len(self.channels) + + def gap(i: int, j: int) -> float: + return float(self.pipettes._min_spacing_between(i, j)) + + positions = [round(self.current_y(c), 1) for c in range(n)] + positions[-1] = max(positions[-1], self.driver.configuration.left_arm_min_y_position) + for c in range(n - 2, -1, -1): + if positions[c] - positions[c + 1] < gap(c, c + 1): + positions[c] = positions[c + 1] + gap(c, c + 1) + ys = dict(enumerate(positions)) + ys.update(targets) + back, front = min(targets), max(targets) + for c in range(back, 0, -1): + if ys[c - 1] - ys[c] < gap(c - 1, c): + ys[c - 1] = ys[c] + gap(c - 1, c) + for c in range(back + 1, front): + if c not in targets: + ys[c] = ys[c - 1] - gap(c - 1, c) + for c in range(front, n - 1): + if ys[c] - ys[c + 1] < gap(c, c + 1): + ys[c + 1] = ys[c] - gap(c, c + 1) + return {c: round(y, 2) for c, y in ys.items()} + + def shaft(self, channel: int) -> Optional[Any]: + return next( + ( + child for child in self.channels[channel].children if child.category == "tip_mounting_shaft" + ), + None, + ) + + def spot_at(self, x: float, y: float) -> Optional[TipSpot]: + """The tip spot centred at (x, y) on the deck, or None.""" + for resource in self.deck.get_all_children(): + if not isinstance(resource, TipSpot): + continue + centre = resource.get_location_wrt(self.deck, "c", "c", "b") + if abs(centre.x - x) <= SPOT_TOLERANCE and abs(centre.y - y) <= SPOT_TOLERANCE: + return resource + return None + + def drives(self) -> Dict[str, Dict[str, Optional[float]]]: + p = self.pipettes + return { + "x": {"speed": X_SPEED, "acceleration": X_ACCELERATION, "jerk": X_JERK}, + "y": { + "speed": CHANNEL_Y_SPEED, + "acceleration": CHANNEL_Y_ACCELERATION, + "stagger": CHANNEL_Y_STAGGER, + }, + "z": {"speed": CHANNEL_Z_SPEED, "acceleration": p.default_z_acceleration}, + } + + +def _request(frames: _Frames, kind: str, command: str) -> Dict[str, Any]: + return { + "kind": kind, + "command": command, + "arm": None, + "channels": [], + "traverse": [], + "attach": [], + "dwell": 0.0, + "fixed": 0.0, + "drives": frames.drives(), + "moves": [], + "turns": [], + "jaws": None, + } + + +def _channel(frames: _Frames, channel: int, **targets: Optional[float]) -> Dict[str, Any]: + return {"name": frames.channels[channel].name, "channel": channel, **targets} + + +def _stroke( + frames: _Frames, + kind: str, + command: str, + params: Dict[str, Any], + traverse: float, + down: Dict[int, float], + end: Dict[int, float], + extra: Optional[Dict[int, Dict[str, float]]] = None, +) -> Dict[str, Any]: + """A command that travels at a height, goes down onto its targets and comes back up. + + `traverse` is a firmware height; `down` and `end` are already local Z, keyed by channel. `extra` + adds fields to a channel's entry. + """ + pattern = _as_list(params["tm"]) + involved = _involved(pattern) + xs, ys = _as_list(params["xp"]), _as_list(params["yp"]) + request = _request(frames, kind, command) + if not involved: + return request + # The arm ends over the last column the command visited, as the simulator records it. + request["arm"] = {"name": frames.arm.resource.name, "x": frames.arm_x(_tenths(xs[involved[-1]]))} + planned = frames.planned_ys({c: _tenths(ys[c]) for c in involved}) + request["traverse"] = [ + _channel(frames, c, z=frames.lowest_point_z(c, traverse)) for c in range(len(frames.channels)) + ] + request["channels"] = [ + _channel( + frames, + c, + y=frames.channel_y(c, y), + down=down.get(c), + end=end.get(c), + **(extra or {}).get(c, {}), + ) + for c, y in planned.items() + ] + return request + + +def _mounted_location(shaft: Any, tip: Any) -> Dict[str, float]: + """Where a tip sits on a shaft once picked up, as `TipMountingShaft` places it: its pick-up + location `fitting_depth` up the shaft's axis.""" + grip = (tip.pick_up_location or tip.get_anchor("c", "c", "t")).rotated(tip.rotation) + return _xyz( + Coordinate( + shaft.get_size_x() / 2 - grip.x, + shaft.get_size_y() / 2 - grip.y, + tip.fitting_depth - grip.z, + ) + ) + + +def _handover(tip: Any, parent: Any, location: Optional[Dict[str, float]]) -> Dict[str, Any]: + """A tip changing hands at the bottom of a stroke, placed where the model will place it.""" + return { + "name": tip.name, + "parent": None if parent is None else parent.name, + "location": location, + "rotation": _xyz(tip.rotation), + } + + +def _positions(params: Dict[str, Any], channel: int) -> Any: + return _tenths(_as_list(params["xp"])[channel]), _tenths(_as_list(params["yp"])[channel]) + + +def _tip_pickup(frames: _Frames, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + involved = _involved(_as_list(params["tm"])) + # How far each tip will hang below its stop disc once on: read off the tip in the spot and where + # the shaft will seat it, as `TipMountingShaft.tip_bottom` then reads it. The simulator's table of + # defined tip lengths is only the fallback, for a spot the model has no tip in. + defined: float = getattr(frames.driver, "defined_tip_lengths", {}).get(int(params["tt"]), 0.0) + + def carried(c: int) -> float: + spot, shaft = frames.spot_at(*_positions(params, c)), frames.shaft(c) + if spot is None or spot.tip is None or shaft is None: + return defined + return float(-_mounted_location(shaft, spot.tip)["z"]) + + request = _stroke( + frames, + "tip_pickup", + command, + params, + traverse=_tenths(params["th"]), + # Down to where the tip begins, then pressed on to the end of the search, slowly. + down={c: frames.lowest_point_z(c, _tenths(params["tp"])) for c in involved}, + # It comes away carrying the tip, which then hangs below the stop disc. + end={c: frames.lowest_point_z(c, _tenths(params["th"]), carried(c)) for c in involved}, + extra={ + c: {"press": frames.lowest_point_z(c, _tenths(params["tz"])), "press_speed": TIP_PRESS_SPEED} + for c in involved + }, + ) + # The channels hold at the bottom once the tips are on: the pick-up's fixed time, bar the + # command's handling before it moves. + request["down_from"] = TIP_PICKUP_DOWN_FROM + request["dwell"] = TIP_PICKUP_HOLD + request["fixed"] = round(TIP_PICKUP_FIXED - TIP_PICKUP_HOLD, 3) + # At the bottom of the stroke each channel takes the tip in the spot under it onto its shaft. + for c in involved: + spot, shaft = frames.spot_at(*_positions(params, c)), frames.shaft(c) + if spot is not None and spot.tip is not None and shaft is not None: + request["attach"].append(_handover(spot.tip, shaft, _mounted_location(shaft, spot.tip))) + return request + + +def _tip_drop(frames: _Frames, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + involved = _involved(_as_list(params["tm"])) + # With `DROP` the heights are the stop disc's, not the lowest point's (`_unchecked_fw_drop_tips`): + # the tip still on it hangs below them. + drop = int(params.get("ti", 0)) == TIP_DROP + request = _stroke( + frames, + "tip_drop", + command, + params, + traverse=_tenths(params["th"]), + down={ + c: frames.lowest_point_z(c, _tenths(params["tz"]), 0.0 if drop else None) for c in involved + }, + # It comes away empty. + end={c: frames.lowest_point_z(c, _tenths(params["te"]), 0.0) for c in involved}, + ) + # At the bottom the channels hold while the tips are pushed off: the drop's fixed time, bar the + # command's handling before it moves. + request["dwell"] = TIP_EJECT_HOLD + request["fixed"] = round(TIP_DROP_FIXED - TIP_EJECT_HOLD, 3) + # At the bottom of the stroke each channel leaves its tip in the spot under it, or, over + # somewhere that is not a spot - the waste - where it is. + for c in involved: + shaft = frames.shaft(c) + if shaft is None or shaft.tip is None: + continue + spot = frames.spot_at(*_positions(params, c)) + if spot is not None and spot.tip is None: + request["attach"].append(_handover(shaft.tip, spot, _xyz(resting_location(spot, shaft.tip)))) + else: + request["attach"].append(_handover(shaft.tip, None, None)) + return request + + +def _as_tip_command(params: Dict[str, Any], back: int, channels: int) -> Dict[str, Any]: + """A CO-RE tool command's two channels in a tip command's lists: one X, a Y each.""" + xs, ys, pattern = ["0"] * channels, ["0"] * channels, [False] * channels + for channel, y in ((back, params["ya"]), (back + 1, params["yb"])): + xs[channel], ys[channel], pattern[channel] = params["xs"], y, True + return {**params, "xp": xs, "yp": ys, "tm": pattern} + + +def _below_grip_line(tool: Any) -> float: + """How far a mounted CO-RE grip tool's grip line hangs below the stop disc, in mm: what the + master reports a channel carrying one at.""" + pick_up = tool.pick_up_location or tool.get_anchor("c", "c", "t") + return float(pick_up.z - tool.fitting_depth - tool.grip_line_height) + + +def _core_grippers(frames: "_Frames") -> Optional[Any]: + """The driver's CO-RE grippers feature, or None when the driver has none.""" + return getattr(frames.pipettes._driver, "core_grippers", None) + + +def _core_channels(frames: "_Frames") -> List[int]: + """The channels carrying CO-RE grip tools, back to front, from the feature that picked them up; + from the model where the feature does not know.""" + grippers = _core_grippers(frames) + if grippers is not None and grippers._back_channel is not None: + return [grippers._back_channel, grippers._front_channel] + return [ + c + for c in range(len(frames.channels)) + if isinstance(frames.pipettes.get_mounted_tool(c), HamiltonCoreGripperTool) + ] + + +def _core_holder(frames: "_Frames") -> Any: + """The CO-RE gripper holder the deck carries.""" + deck = frames.pipettes._driver.deck + for resource in deck.get_all_children(): + if isinstance(resource, HamiltonCoreGrippers): + return resource + raise TypeError("the deck carries no CO-RE gripper holder") + + +def _hold_at_bottom(request: Dict[str, Any], fixed: float) -> Dict[str, Any]: + """Spend a command's fixed time at the bottom of its stroke - where tips are clamped or pushed + off, a tool seated, a plate gripped or let go - rather than as a still pause before it moves, + bar the command's handling (`SIMPLE_MOVE_FIXED`), as the channels' tip commands do. Where in a + command the firmware spends it is not measured (HEUR); played before the move, the head and the + grip tools stood for seconds before setting off.""" + request["dwell"] = round(fixed - SIMPLE_MOVE_FIXED, 3) + request["fixed"] = SIMPLE_MOVE_FIXED + return request + + +def _core_tool_pickup(frames: _Frames, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + """`C0 ZT`: down onto the parked tools, and up carrying them, as a tip pick-up.""" + back = int(params["pa"]) - 1 + holder = _core_holder(frames) + tools = {back: holder.back_tool, back + 1: holder.front_tool} + request = _stroke( + frames, + "core_tool_pickup", + command, + _as_tip_command(params, back, len(frames.channels)), + traverse=_tenths(params["th"]), + down={c: frames.lowest_point_z(c, _tenths(params["tz"]), 0.0) for c in tools}, + end={ + c: frames.lowest_point_z(c, _tenths(params["th"]), _below_grip_line(tool)) + for c, tool in tools.items() + }, + ) + for c, tool in tools.items(): + shaft = frames.shaft(c) + if shaft is not None: + request["attach"].append(_handover(tool, shaft, _mounted_location(shaft, tool))) + return _hold_at_bottom(request, CORE_TOOL_PICKUP_FIXED) + + +def _core_tool_return(frames: _Frames, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + """`C0 ZS`: down into the holder with the tools, leaving each where it was parked, and up.""" + channels = _core_channels(frames) + if len(channels) != 2: + raise ValueError("a tool return needs two channels carrying tools") + request = _stroke( + frames, + "core_tool_return", + command, + _as_tip_command(params, channels[0], len(frames.channels)), + traverse=_tenths(params["th"]), + down={c: frames.lowest_point_z(c, _tenths(params["tz"])) for c in channels}, + end={c: frames.lowest_point_z(c, _tenths(params["te"]), 0.0) for c in channels}, + ) + for c in channels: + shaft = frames.shaft(c) + if shaft is None or shaft.tip is None: + continue + grippers = _core_grippers(frames) + parked = ( + { + tool.name: (holder, location, tool.rotation) + for tool, holder, location in grippers._parked_tools + } + if grippers is not None + else {} + ) + holder, location, rotation = parked[shaft.tip.name] + handover = _handover(shaft.tip, holder, _xyz(location)) + handover["rotation"] = _xyz(rotation) + request["attach"].append(handover) + return _hold_at_bottom(request, CORE_TOOL_RETURN_FIXED) + + +def _core_plate(frames: _Frames, command: str, params: Dict[str, Any], kind: str) -> Dict[str, Any]: + """`C0 ZP`, `ZM`, `ZR`: the two tool channels travel, go down to the grip line at the plate's + centre, one on each side of it, and come up. At the bottom of a grip the plate passes to the + front tool channel's shaft; at the bottom of a release, to what it is put down on - as + `COREGripper` leaves it for the command (`Pipettes._core_handover`).""" + channels = _core_channels(frames) + if len(channels) != 2: + raise ValueError("a CO-RE plate command needs two channels carrying tools") + back, front = channels + shaft = frames.shaft(front) + tool = shaft.tip if shaft is not None else None + if not isinstance(tool, HamiltonCoreGripperTool): + raise ValueError("a CO-RE plate command needs its CO-RE grip tool on the front channel") + grippers = _core_grippers(frames) + handover = grippers._handover if grippers is not None else None + held = handover[0] if handover else None + width = held.get_absolute_size_y() if held is not None else _tenths(params.get("yo", 0)) - 3.0 + half = width / 2 + core_tool_face_distance(tool) + centre_y, grip = _tenths(params["yj"]), _tenths(params["zj"]) + along = {**params, "ya": round((centre_y + half) * 10), "yb": round((centre_y - half) * 10)} + end = _tenths(params["zj"] if kind == "core_move" else params.get("te", params["th"])) + below = core_tool_grip_line_overhang(tool) + request = _stroke( + frames, + kind, + command, + _as_tip_command(along, back, len(frames.channels)), + traverse=_tenths(params["th"]), + down={c: frames.lowest_point_z(c, grip, below) for c in channels}, + end={c: frames.lowest_point_z(c, end, below) for c in channels}, + ) + if handover and kind != "core_move": + resource, parent, location = handover + request["attach"].append( + { + "name": resource.name, + "parent": None if parent is None else parent.name, + "location": None if location is None else _xyz(location), + "rotation": _xyz(resource.rotation), + } + ) + if kind == "core_move": + return request + return _hold_at_bottom( + request, CORE_PLATE_GRIP_FIXED if kind == "core_grip" else CORE_PLATE_RELEASE_FIXED + ) + + +def _aspirate(frames: _Frames, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + involved = _involved(_as_list(params["tm"])) + + # Lists other than the positions run over the channels involved, in order. + def per_channel(field: str) -> Dict[int, float]: + values = _as_list(params[field]) + return {c: _tenths(values[i]) for i, c in enumerate(involved)} + + def raw(field: str, default: int = 0) -> Dict[int, int]: + values = _as_list(params.get(field, [default] * len(involved))) + return {c: int(values[i]) for i, c in enumerate(involved)} + + def optional(field: str) -> Dict[int, float]: + return per_channel(field) if field in params else {c: 0.0 for c in involved} + + surface = per_channel("zl") + floor = per_channel("zx") + swap_speed = per_channel("de") + # Into the liquid by the immersion depth, or above it when the direction says so (`it` 1); never + # below the lowest the command allows. + direction = raw("it") + immersion = optional("ip") + down = { + c: max(surface[c] + (immersion[c] if direction[c] == 1 else -immersion[c]), floor[c]) + for c in involved + } + # While it draws, the tip follows the sinking surface down by `fp`, at a steady pace over the + # time the volume takes. The narrower lower section `zu`/`zr` changes that pace on the device in + # a way the driver does not document, so it is not drawn. + following = optional("fp") + volumes, speeds = per_channel("av"), per_channel("as_") + draw_time = {c: volumes[c] / speeds[c] if speeds[c] else 0.0 for c in involved} + followed = {c: max(down[c] - following[c], floor[c]) for c in involved} + # Out of the liquid at the swap speed, then up by the pull-out distance before the transport air + # is drawn, at the swap speed too: Venus aspirations otherwise identical take 5.35 s (n 4 against + # 802) and 4.85 s (n 12 against 3,534) longer with a 10 mm pull-out at 2 mm/s - 10 mm at 2 mm/s. + # From the surface (HEUR - the driver says only "rise before drawing transport air"). + pull_out = optional("po") + + def extras(c: int) -> Dict[str, float]: + extra: Dict[str, float] = {} + if followed[c] < down[c] - 0.05 and draw_time[c] > 0: + extra["follow"] = frames.lowest_point_z(c, followed[c]) + extra["follow_speed"] = round((down[c] - followed[c]) / draw_time[c], 3) + if swap_speed[c] > 0: + extra["leave"] = frames.lowest_point_z(c, surface[c]) + extra["leave_speed"] = swap_speed[c] + if pull_out[c] > 0: + extra["pull_out"] = frames.lowest_point_z(c, surface[c] + pull_out[c]) + return extra + + request = _stroke( + frames, + "aspirate", + command, + params, + traverse=_tenths(params["th"]), + down={c: frames.lowest_point_z(c, down[c]) for c in involved}, + end={c: frames.lowest_point_z(c, _tenths(params["te"])) for c in involved}, + extra={c: extras(c) for c in involved}, + ) + # As long as the slowest channel takes to draw its volume, settle and mix. + settle = per_channel("wt") + cycles = {c: int(v) for c, v in zip(involved, _as_list(params.get("mc", [0] * len(involved))))} + mix_volumes = per_channel("mv") if "mv" in params else {c: 0.0 for c in involved} + mix_speeds = per_channel("ms") if "ms" in params else {c: 0.0 for c in involved} + + def mixing(c: int) -> float: + if not cycles.get(c) or not mix_speeds[c]: + return 0.0 + volume_time = 2 * cycles[c] * mix_volumes[c] / mix_speeds[c] + return MIX_VOLUME_TIME_FACTOR * volume_time + MIX_PER_CYCLE * cycles[c] + + request["dwell"] = round( + max((volumes[c] / speeds[c] if speeds[c] else 0.0) + settle[c] + mixing(c) for c in involved), + 2, + ) + transport_air = per_channel("ta") if "ta" in params else {c: 0.0 for c in involved} + request["fixed"] = round( + ASPIRATE_FIXED + + ASPIRATE_FIXED_PER_TRANSPORT_AIR_TIME + * max(transport_air[c] / speeds[c] if speeds[c] else 0.0 for c in involved), + 3, + ) + return request + + +def _move_y(frames: _Frames, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + request = _request(frames, "move_y", command) + ys = _as_list(params["yp"]) + request["channels"] = [ + _channel(frames, c, y=frames.channel_y(c, _tenths(y))) + for c, y in enumerate(ys[: len(frames.channels)]) + ] + return request + + +def _free_y_range(frames: _Frames, command: str) -> Dict[str, Any]: + """`C0 FY`: the master packs the channels as far forward as they fit, each against the ones in + front of it - where the simulator puts them, and writes them before the command is sent.""" + request = _request(frames, "move_y", command) + request["recorded_first"] = True + floor = frames.driver.configuration.left_arm_min_y_position + widths = [c.width for c in frames.pipettes.configuration.channels] + request["channels"] = [ + _channel(frames, c, y=frames.channel_y(c, floor + sum(widths[c + 1 :]))) + for c in range(len(frames.channels)) + ] + return request + + +def _channels_up(frames: _Frames, command: str) -> Dict[str, Any]: + """`C0 ZA`: every channel up to the top of its Z travel, where `probe_z_max` leaves them - and + where the simulator writes them before the command is sent.""" + request = _request(frames, "move_z", command) + request["recorded_first"] = True + ceiling = frames.pipettes.configuration.z_range[1] + request["channels"] = [ + _channel(frames, c, end=frames.channel_z(c, ceiling)) for c in range(len(frames.channels)) + ] + return request + + +def _move_z(frames: _Frames, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + request = _request(frames, "move_z", command) + zs = _as_list(params["zp"]) + request["channels"] = [ + _channel(frames, c, end=frames.lowest_point_z(c, _tenths(z))) + for c, z in enumerate(zs[: len(frames.channels)]) + ] + return request + + +def _move_x(frames: _Frames, driver: Any, command: str, params: Dict[str, Any]) -> Optional[Dict]: + side = "left" if command == "XP" else "right" + arm = next((a for a in driver.arms if a.side == side), None) + if arm is None or arm.resource is None: + return None + increments = params.get(f"{arm.parameter_prefix}a") + if increments is None: + return None + x = arm.configuration.x_increments_to_mm(int(increments)) + request = _request(frames, "move_x", command) + request["arm"] = { + "name": arm.resource.name, + "x": round(x - arm.configuration.reference_point_from_left, 2), + } + return request + + +# -- iSWAP --------------------------------------------------------------------- + + +def _iswap_of(driver: Any) -> Optional[Any]: + """The iSWAP, or None when there is none or nothing models it yet.""" + for arm in getattr(driver, "arms", []): + iswap = arm.iswap + if iswap is not None and None not in (iswap.resource, iswap.link_1, iswap.gripper): + return iswap + return None + + +def _iswap_request(kind: str, command: str) -> Dict[str, Any]: + # The iSWAP writes a move's target into the model as it sends it (`elbow_move_to_y_position`, + # `rotate_to_angles`, `gripper_move_to_jaw_position`): what the model says of these parts before + # the move is played is already its end. + return { + "kind": kind, + "command": command, + "recorded_first": True, + "arm": None, + "channels": [], + "traverse": [], + "attach": [], + "dwell": 0.0, + "drives": {}, + "moves": [], + "turns": [], + "jaws": None, + } + + +def _elbow_move(driver: Any, iswap: Any, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + """`R0 YA` or `R0 ZA`: the head the arm hangs from, along Y or Z.""" + c = iswap.configuration + head = iswap.resource + on_arm, anchor = head.parent.get_location_wrt(driver.deck), head.reference_point + request = _iswap_request("iswap_move", "R0" + command) + if command == "YA": + y = c.y_increments_to_mm(int(params["ya"])) + request["moves"].append( + { + "name": head.name, + "axis": 1, + "to": round(float(y - on_arm.y - anchor.y), 2), + "speed": c.y_increments_to_mm(int(params["yv"])), + "acceleration": None, + } + ) + else: + # The drive counts the finger plane; the head's bottom stands above it. + z = c.z_increments_to_mm(int(params["za"])) + c.elbow_z_offset_above_finger + request["moves"].append( + { + "name": head.name, + "axis": 2, + "to": round(float(z - on_arm.z - anchor.z), 2), + "speed": c.z_increments_to_mm(int(params["zv"])), + "acceleration": round(int(params["zr"]) * 1000 * c.z_mm_per_increment, 2), + } + ) + return request + + +def _joints(iswap: Any, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + """`R0 PA`: the elbow and the wrist together, each to its own angle at its own speed. + + A joint's angle is stated as its drive reports it; `base` is what the driver subtracts from it to + get the resource's rotation (`elbow_drive_update_angle`, `wrist_drive_update_angle`). + """ + c = iswap.configuration + request = _iswap_request("iswap_turn", "R0" + command) + link, gripper = iswap.link_1, iswap.gripper + request["turns"].append( + { + "name": link.name, + "drive": c.elbow_drive_increments_to_angle(int(params["wa"])), + "base": 90.0, + "pivot": _xyz(link.proximal_joint), + "speed": c.elbow_increments_to_deg_per_sec(int(params["wv"])), + "acceleration": c.elbow_increments_to_deg_per_sec2(int(params["wr"])), + } + ) + if c.wrist_drive_predefined_increments is not None: + request["turns"].append( + { + "name": gripper.name, + "drive": c.wrist_increments_to_deg(int(params["ta"])), + "base": c.wrist_increments_to_deg(c.wrist_drive_predefined_increments.straight), + "pivot": _xyz(gripper.proximal_joint), + "speed": c.wrist_increments_to_deg_per_sec(int(params["tv"])), + "acceleration": c.wrist_increments_to_deg_per_sec2(int(params["tr"])), + } + ) + return request + + +def _jaws( + iswap: Any, command: str, width: float, speed: float, acceleration: float +) -> Optional[Dict[str, Any]]: + """The fingers stood `width` apart, as `MechanicalGripper._place_the_fingers` stands them. + + Each finger travels half of what the width does, in the same time, so at half the drive's speed. + The page decides from which way they move whether this closes on something or lets it go. + """ + gripper = iswap.gripper + low, high = gripper.jaw_range + if not low <= width <= high: + return None # the model leaves the jaws where they are, and so does the page + centre = gripper.proximal_joint.y + gripper.tool_center_point.y + fingers = [] + for finger, side in zip(gripper.fingers, (1.0, -1.0)): + facing = centre + side * width / 2.0 + fingers.append( + {"name": finger.name, "y": round(facing if side > 0 else facing - finger.get_size_y(), 3)} + ) + request = _iswap_request("iswap_jaws", command) + request["jaws"] = { + "gripper": gripper.name, + "width": width, + "fingers": fingers, + "speed": speed / 2.0, + "acceleration": acceleration / 2.0, + # Where the fingers close, in the gripper's own frame: what they take hold of is there. + "grip_point": _xyz(gripper.proximal_joint + gripper.tool_center_point), + } + return request + + +def _iswap_motion( + driver: Any, iswap: Any, module: str, command: str, params: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + c = iswap.configuration + if module == "R0" and command in ("YA", "ZA"): + return _elbow_move(driver, iswap, command, params) + if module == "R0" and command == "PA": + return _joints(iswap, command, params) + if module == "R0" and command == "GA": + return _jaws( + iswap, + "R0GA", + c.gripper_increments_to_mm(int(params["ga"])), + c.gripper_increments_to_mm_per_sec(int(params["gv"])), + c.gripper_increments_to_mm_per_sec2(int(params["gr"])), + ) + if module == "C0" and command == "GC": + # Closes onto what is there, at the drive's closing speed; the width is in tenths of a mm. + return _jaws( + iswap, + "C0GC", + _tenths(params["gb"]), + c.gripper_increments_to_mm_per_sec(c.gripper_close_speed_default_increments), + c.gripper_increments_to_mm_per_sec2(c.gripper_acceleration_default_increments), + ) + return None + + +# -- 96-head ------------------------------------------------------------------------------------- + + +def _head96_of(driver: Any) -> Optional[Any]: + """The 96-head, or None when there is none or nothing models it yet.""" + for arm in getattr(driver, "arms", []): + head = arm.head96 + if head is not None and head.resource is not None and head.resource.location is not None: + return head + return None + + +class _HeadFrames: + """Converts what the head's drives report - channel A1, on the deck - into where its resource + sits, as `Head.update_location_by_reference_point` records it.""" + + def __init__(self, driver: Any, head: Any): + self.driver = driver + self.head = head + self.resource = head.resource + self.a1 = head.resource.get_item("A1") + self.on_arm = head.resource.parent.get_location_wrt(driver.deck) + # The drives report channel A1's axis; a shaft, like any resource, is placed by its corner, set + # back from the axis by its radius. Read as the corner, the head was drawn half a shaft (3.5 mm) + # off in X and Y - the tips against the walls of a MIDI plate's 6.8 mm wells. + self.axis_in_head = self.a1.location + Coordinate( + self.a1.get_size_x() / 2, self.a1.get_size_y() / 2, 0 + ) + + def axis_on_deck(self) -> Coordinate: + """Where channel A1's axis is now, in deck mm (Z at the shaft's end).""" + return cast(Coordinate, self.a1.get_location_wrt(self.driver.deck, x="c", y="c", z="b")) + + def local_y(self, y: float) -> float: + return round(float(y - self.on_arm.y - self.axis_in_head.y), 2) + + def local_z(self, stop_disc_z: float) -> float: + return round(float(stop_disc_z - self.on_arm.z - self.a1.location.z), 2) + + def overhang(self) -> float: + """How far what channel A1 carries hangs below it, in mm.""" + bottom = self.a1.tip_bottom() + return -float(bottom.z) if bottom is not None else 0.0 + + def drives(self) -> Dict[str, Dict[str, Optional[float]]]: + c = self.head.configuration + return { + "x": {"speed": X_SPEED, "acceleration": X_ACCELERATION, "jerk": X_JERK}, + "y": {"speed": c.y_drive_speed_default, "acceleration": c.y_drive_acceleration_default}, + "z": {"speed": c.z_drive_speed_default, "acceleration": c.z_drive_acceleration_default}, + } + + def request(self, kind: str, command: str) -> Dict[str, Any]: + return { + "kind": kind, + "command": command, + "arm": None, + "channels": [], + "traverse": [], + "attach": [], + "dwell": 0.0, + "drives": self.drives(), + "moves": [], + "turns": [], + "jaws": None, + } + + +def _spots_under_head(deck: Any, head: Any, x: float, y: float) -> List[Optional[Any]]: + """Per shaft, the tip spot it stands over with head channel A1 at (x, y): the rack of whichever + spot is there, paired with the shafts as the head pairs them for an offset command + (`Head96._spots_under_shafts`). All None over no rack.""" + for resource in deck.get_all_children(): + if isinstance(resource, TipSpot) and resource.parent is not None: + centre = resource.get_location_wrt(deck, "c", "c", "b") + if abs(centre.x - x) <= SPOT_TOLERANCE and abs(centre.y - y) <= SPOT_TOLERANCE: + rack = resource.parent + if rack.num_items != 96: + break + a1 = rack.get_item("A1").get_location_wrt(deck, "c", "c", "b") + under = head._spots_under_shafts(Coordinate(x - a1.x, y - a1.y, 0)) + return [None if j is None else rack.get_item(j) for j in under] + return [None] * 96 + + +def _head96_tips(driver: Any, head: Any, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + """`C0 EP` or `C0 ER`: the head's stroke, channel A1 to spot A1. + + Up to traverse height, across - the arm in X, the head in Y - down with the stop discs to the + spot's height, the 96 tips changing hands, and up to where the command ends: the bottom of what + the head then carries, as the simulator records it (`SimulatedHead96._place_after_tip_command`). + """ + frames = _HeadFrames(driver, head) + pick_up = command == "EP" + request = frames.request("head96_tip_pickup" if pick_up else "head96_tip_drop", "C0" + command) + x = _tenths(params["xs"]) * (-1 if int(params["xd"]) else 1) + y = _tenths(params["yh"]) + arm = head.arm + if arm is not None and arm.resource is not None: + a1 = frames.axis_on_deck() + request["arm"] = { + "name": arm.resource.name, + "x": round(float(arm.resource.location.x + x - a1.x), 2), + } + + # Each shaft works the spot under it, whichever spot of the rack channel A1 stands over: a head + # sent over a rack shifted by whole columns (a partial pick-up off a tip support) pairs them + # shifted. + under = _spots_under_head(driver.deck, head, x, y) + shafts = frames.resource.get_all_items() + after = 0.0 + if pick_up: + for shaft, spot in zip(shafts, under): + if spot is not None and spot.tip is not None: + mounted = _mounted_location(shaft, spot.tip) + request["attach"].append(_handover(spot.tip, shaft, mounted)) + if after == 0.0: + after = -mounted["z"] + else: + for i, shaft in enumerate(shafts): + if shaft.tip is None: + continue + spot = under[i] + if spot is not None and spot.tracks_tips and spot.tip is None: + request["attach"].append( + _handover(shaft.tip, spot, _xyz(resting_location(spot, shaft.tip))) + ) + else: + request["attach"].append(_handover(shaft.tip, None, None)) + + name = frames.resource.name + request["traverse"] = [ + {"name": name, "z": frames.local_z(_tenths(params["zh"]) + frames.overhang())} + ] + request["channels"] = [ + { + "name": name, + "channel": 0, + "y": frames.local_y(y), + # The stop discs go to the spot's height: channel A1 to the centre of spot A1, at its Z. + "down": frames.local_z(_tenths(params["za"])), + "end": frames.local_z(_tenths(params["ze"]) + after), + } + ] + # The 96 tips are clamped on, or pushed off, at the bottom: the measured fixed time is spent there. + return _hold_at_bottom(request, HEAD96_TIP_PICKUP_FIXED if pick_up else HEAD96_TIP_DROP_FIXED) + + +def _head96_liquid(driver: Any, head: Any, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + """`C0 EA` or `C0 ED`: the head's stroke, channel A1 over well A1 (or centred over a container): + up to traverse height, across, down with the tips to the liquid surface the command gives, and + up to where it ends. The liquid itself is the model's, booked by the command.""" + frames = _HeadFrames(driver, head) + aspirate = command == "EA" + request = frames.request("head96_aspirate" if aspirate else "head96_dispense", "C0" + command) + x = _tenths(params["xs"]) * (-1 if int(params["xd"]) else 1) + y = _tenths(params["yh"]) + arm = head.arm + if arm is not None and arm.resource is not None: + a1 = frames.axis_on_deck() + request["arm"] = { + "name": arm.resource.name, + "x": round(float(arm.resource.location.x + x - a1.x), 2), + } + overhang = frames.overhang() + name = frames.resource.name + request["traverse"] = [{"name": name, "z": frames.local_z(_tenths(params["zh"]) + overhang)}] + request["channels"] = [ + { + "name": name, + "channel": 0, + "y": frames.local_y(y), + "down": frames.local_z(_tenths(params["zt"]) + overhang), + "end": frames.local_z(_tenths(params["ze"]) + overhang), + } + ] + # At the bottom: the volume at the command's flow rate, the settling time, and the command's + # measured fixed time (by dispense mode for a dispense). + volume, flow = (params["af"], params["ag"]) if aspirate else (params["df"], params["dg"]) + pumping = _tenths(volume) / max(_tenths(flow), 1e-6) + if aspirate: + fixed = HEAD96_ASPIRATE_FIXED + else: + fixed = HEAD96_DISPENSE_FIXED.get(int(params.get("da", 2)), HEAD96_DISPENSE_FIXED[2]) + _hold_at_bottom(request, fixed) + request["dwell"] = round(request["dwell"] + pumping + _tenths(params["wh"]), 3) + return request + + +def _head96_move(driver: Any, head: Any, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + """`H0 YA` or `H0 ZA`: the head alone, along Y or Z, at the speed the command carries.""" + frames = _HeadFrames(driver, head) + c = head.configuration + request = frames.request("head96_move", head.configuration.module + command) + if command == "YA": + request["moves"].append( + { + "name": frames.resource.name, + "axis": 1, + "to": frames.local_y(c.y_drive_increments_to_mm(int(params["ya"]))), + "speed": c.y_drive_increments_to_mm(int(params["yv"])), + "acceleration": c.y_drive_acceleration_increments_to_mm(int(params["yr"])), + } + ) + else: + request["moves"].append( + { + "name": frames.resource.name, + "axis": 2, + "to": frames.local_z(c.z_drive_increments_to_mm(int(params["za"]))), + "speed": c.z_drive_increments_to_mm(int(params["zv"])), + "acceleration": c.z_drive_acceleration_increments_to_mm(int(params["zr"])), + } + ) + return request + + +def star_motion( + driver: Any, module: str, command: str, params: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + """What one STAR firmware command asks the drives to do, as local targets, or None. + + Args: + driver: the STAR driver the command is going to, which knows the arm and the channels. + module: the module the command is addressed to. + command: the two-letter command code. + params: the command's parameters, as the driver passes them to be assembled. + + Returns: + A motion request for the page: the command's `kind`; the arm's target X; each channel's + targets - `y`, the heights `down` and `end`, and for an aspiration where it `leave`s the liquid + and at what `leave_speed`; the height every channel rises to first (`traverse`); the tips that + change hands at the bottom of the stroke (`attach`: a tip's name and the resource that takes + it, or None to leave it where it is); how long the stroke dwells at the bottom; the command's + `fixed` time beyond its motion; and the drives' speeds (the channels' Y with the `stagger` one + channel starts after another). A tip pick-up's channels also `press` to a height at a + `press_speed`. None for a command that moves nothing, or one this does not read. + """ + if command[0] in ("R", "Q"): + return None + request = _decode(driver, module, command, params) + if request is not None and not request.get("fixed"): + request["fixed"] = _FIXED.get(module + command, SIMPLE_MOVE_FIXED) + return request + + +# The fixed time of the commands whose time does not depend on their parameters; any other command +# the decoder reads is a simple single-drive move (`SIMPLE_MOVE_FIXED`). +_FIXED = { + "C0ZT": CORE_TOOL_PICKUP_FIXED, + "C0ZS": CORE_TOOL_RETURN_FIXED, + "C0ZP": CORE_TOOL_PICKUP_FIXED, + "C0ZR": CORE_TOOL_RETURN_FIXED, + "C0ZA": CHANNELS_UP_FIXED, + "C0EP": HEAD96_TIP_PICKUP_FIXED, + "C0ER": HEAD96_TIP_DROP_FIXED, + "C0EA": HEAD96_ASPIRATE_FIXED, + "C0ED": HEAD96_DISPENSE_FIXED[2], + "H0YA": HEAD96_MOVE_FIXED, + "H0ZA": HEAD96_MOVE_FIXED, +} + + +def _decode( + driver: Any, module: str, command: str, params: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + key = module + command + try: + iswap = _iswap_of(driver) + if iswap is not None and (module == "R0" or key == "C0GC"): + return _iswap_motion(driver, iswap, module, command, params) + head = _head96_of(driver) + if head is not None and key in ("C0EP", "C0ER"): + return _head96_tips(driver, head, command, params) + if head is not None and key in ("C0EA", "C0ED"): + return _head96_liquid(driver, head, command, params) + if head is not None and module == head.configuration.module and command in ("YA", "ZA"): + return _head96_move(driver, head, command, params) + arm = _pipetting_arm(driver) + if arm is None: + return None + frames = _Frames(driver, arm) + if key == "C0TP": + return _tip_pickup(frames, key, params) + if key == "C0TR": + return _tip_drop(frames, key, params) + if key == "C0ZT": + return _core_tool_pickup(frames, key, params) + if key == "C0ZS": + return _core_tool_return(frames, key, params) + if key == "C0ZP": + return _core_plate(frames, key, params, "core_grip") + if key == "C0ZM": + return _core_plate(frames, key, params, "core_move") + if key == "C0ZR": + return _core_plate(frames, key, params, "core_release") + if key == "C0AS": + return _aspirate(frames, key, params) + if key == "C0JY": + return _move_y(frames, key, params) + if key == "C0JZ": + return _move_z(frames, key, params) + if key == "C0FY": + return _free_y_range(frames, key) + if key == "C0ZA": + return _channels_up(frames, key) + if module == "X0" and command in ("XP", "SP"): + return _move_x(frames, driver, command, params) + except (KeyError, IndexError, ValueError, TypeError, RuntimeError): + # A command shaped otherwise than this reads it is not acted out; the model still records + # where it ends. + return None + return None + + +def attach_viewer_motion(driver: Any, viewer: Any) -> None: + """Let a viewer act out what the driver's commands move. + + The driver hands each command to its `motion_listener` before the device answers it (a + `STARSimulationDriver` does); this reads the command for what it moves (`star_motion`) and hands + it to the viewer's `act_out`, which holds the command until the pages have played it. A read + moves nothing and is not worth a message. + + Args: + driver: a simulated STAR driver with a `motion_listener`. + viewer: a `Viewer3D` with an `act_out`. + """ + + async def listener(module: str, command: str, params: Dict[str, Any]) -> None: + motion = star_motion(driver, module, command, params) + if motion is None and command[0] in ("R", "Q"): + return + await viewer.act_out(module + command, motion) + + driver.motion_listener = listener diff --git a/pylabrobot/hamilton/star/motion_tests.py b/pylabrobot/hamilton/star/motion_tests.py new file mode 100644 index 00000000000..685914fdd42 --- /dev/null +++ b/pylabrobot/hamilton/star/motion_tests.py @@ -0,0 +1,555 @@ +"""The motion channel, checked without a browser. + +What a command is read as (`motion.py`), and how the server holds a command until the pages have +played it, against a simulated STAR and pages that are only websocket clients. The player itself is +checked in Node (`motion_player_tests.mjs`), run from here where Node is installed. +""" + +import asyncio +import inspect +import json +import pathlib +import shutil +import subprocess +import time +import unittest +from typing import Any, Dict, List, Optional, Tuple, cast +from unittest import mock + +import websockets + +from pylabrobot.hamilton.star import motion +from pylabrobot.hamilton.star.motion import ( + HEAD96_ASPIRATE_FIXED, + HEAD96_DISPENSE_FIXED, + HEAD96_TIP_DROP_FIXED, + HEAD96_TIP_PICKUP_FIXED, + SIMPLE_MOVE_FIXED, + attach_viewer_motion, + star_motion, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.tip_rack import TipRack +from pylabrobot.resources.tip_tracking import does_tip_tracking, set_tip_tracking +from pylabrobot.visualizer3D.demo import build_facility, fill, star_of +from pylabrobot.visualizer3D.server import Viewer3D +from pylabrobot.visualizer3D.server_tests import free_ports, track_volumes + +HERE = pathlib.Path(__file__).parent +NODE = shutil.which("node") + + +async def simulated_star(test: unittest.TestCase) -> Any: + """The demo facility's STAR, set up, tracking volumes and tips.""" + track_volumes(test) + was_tracking = does_tip_tracking() + set_tip_tracking(True) + test.addCleanup(set_tip_tracking, was_tracking) + facility = build_facility() + star = star_of(facility) + await star.setup() + return facility, star + + +class DecoderTests(unittest.IsolatedAsyncioTestCase): + """A command is read for where it takes the drives, which is where the model then has them.""" + + async def asyncSetUp(self) -> None: + self.facility, self.star = await simulated_star(self) + self.requests: List[Dict[str, Any]] = [] + + async def listen(module: str, command: str, params: Dict[str, Any]) -> None: + request = star_motion(self.star.driver, module, command, params) + if request is not None: + self.requests.append(request) + + self.star.driver.motion_listener = listen + self.rack = self.star.deck.get_resource("tips_0") + self.source = self.star.deck.get_resource("source_0") + assert isinstance(self.rack, TipRack) and isinstance(self.source, Plate) + fill(self.source, 300.0) + + def assert_ends_where_the_model_is(self, request: Dict[str, Any]) -> None: + arm = self.star.x_arm.resource + if request["arm"] is not None: + self.assertAlmostEqual(request["arm"]["x"], arm.location.x, delta=0.1, msg="arm") + channels = {c.name: c for c in self.star.pipettes.resources} + for target in request["channels"]: + at = channels[target["name"]].location + if target.get("y") is not None: + self.assertAlmostEqual(target["y"], at.y, delta=0.1, msg=f"{target['name']} y") + if target.get("end") is not None: + self.assertAlmostEqual(target["end"], at.z, delta=0.1, msg=f"{target['name']} z") + + async def test_each_command_ends_where_the_model_records_it_ending(self): + spots = [self.rack.get_item(f"{row}1") for row in "ABCDEFGH"] + wells = [self.source.get_item(f"{row}1") for row in "ABCDEFGH"] + for operation, kind in ( + (lambda: self.star.pipettes.pick_up_tips(spots), "tip_pickup"), + (lambda: self.star.pipettes.aspirate(wells, [50.0] * 8), "aspirate"), + (lambda: self.star.pipettes.drop_tips(spots), "tip_drop"), + ): + self.requests.clear() + await operation() + stroke = [r for r in self.requests if r["kind"] == kind] + self.assertEqual(len(stroke), 1, f"{kind}: {[r['kind'] for r in self.requests]}") + self.assert_ends_where_the_model_is(stroke[0]) + + async def test_heights_with_a_tip_come_from_the_model_not_the_simulator(self): + """On a real STAR there is no simulator to ask how far a tip hangs below the stop disc. + Read from it, every height with a tip on was off by the tip's length.""" + pipettes = self.star.pipettes + simulators_own = type(pipettes)._below_stop_disc + + def not_from_the_decoder(this: Any, channel: int) -> float: + # The simulator keeps its own model with it; only the decoder must not lean on it. + if inspect.stack()[1].filename == motion.__file__: + raise AssertionError("the decoder asked the simulator how long the tip is") + return float(simulators_own(this, channel)) + + with mock.patch.object(type(pipettes), "_below_stop_disc", not_from_the_decoder): + with mock.patch.object(self.star.driver, "defined_tip_lengths", {}, create=True): + await self.test_each_command_ends_where_the_model_records_it_ending() + + async def test_the_tips_change_hands_where_the_model_then_has_them(self): + """Placed anywhere else, the tip jumps when the model's own move reaches the page.""" + spots = [self.rack.get_item(f"{row}2") for row in "AB"] + tips = [spot.tip for spot in spots] + await self.star.pipettes.pick_up_tips(spots) + pick_up = next(r for r in self.requests if r["kind"] == "tip_pickup") + shafts = [c.children[0].name for c in self.star.pipettes.resources[:2]] + self.assertEqual( + [(a["name"], a["parent"]) for a in pick_up["attach"]], + [(t.name, s) for t, s in zip(tips, shafts)], + ) + for handover, tip in zip(pick_up["attach"], tips): + self.assertEqual(tip.parent.name, handover["parent"]) + for axis in "xyz": + self.assertAlmostEqual(handover["location"][axis], getattr(tip.location, axis), 6) + + await self.star.pipettes.drop_tips(spots) + drop = next(r for r in self.requests if r["kind"] == "tip_drop") + self.assertEqual( + [(a["name"], a["parent"]) for a in drop["attach"]], + [(t.name, s.name) for t, s in zip(tips, spots)], + ) + for handover, tip in zip(drop["attach"], tips): + for axis in "xyz": + self.assertAlmostEqual(handover["location"][axis], getattr(tip.location, axis), 6) + + async def test_a_dropped_tip_is_let_go_of_down_in_its_spot(self): + """`DROP` heights are the stop disc's: taken as the tip's bottom, the channel stops a tip's + length short and lets go of it in the air above the rack.""" + spot = self.rack.get_item("A4") + tip = spot.tip + await self.star.pipettes.pick_up_tips([spot]) + channel, shaft = self.star.pipettes.resources[0], self.star.pipettes.resources[0].children[0] + carried = tip.get_absolute_location() - shaft.get_absolute_location() + await self.star.pipettes.drop_tips([spot]) + stroke = [r for r in self.requests if r["kind"] == "tip_drop"][-1]["channels"][0] + # Where the tip is at the bottom of the stroke, and where the model then rests it. + bottom = shaft.get_absolute_location().z - (stroke["end"] - stroke["down"]) + self.assertAlmostEqual(bottom + carried.z, tip.get_absolute_location().z, delta=0.5) + self.assertEqual(channel.location.z, stroke["end"]) + + async def test_an_aspiration_dwells_as_long_as_its_volume_takes(self): + await self.star.pipettes.pick_up_tips([self.rack.get_item("A3")]) + await self.star.pipettes.aspirate([self.source.get_item("A3")], [100.0]) + aspirate = next(r for r in self.requests if r["kind"] == "aspirate") + self.assertGreater(aspirate["dwell"], 0.0) + # The drives move as a STAR's own timings say, where PLR's defaults are slower. + self.assertEqual(aspirate["drives"]["z"]["speed"], motion.CHANNEL_Z_SPEED) + self.assertEqual(aspirate["drives"]["y"]["speed"], motion.CHANNEL_Y_SPEED) + self.assertEqual(aspirate["drives"]["y"]["stagger"], motion.CHANNEL_Y_STAGGER) + + async def test_every_command_takes_its_fixed_time_as_well(self): + spots = [self.rack.get_item(f"{row}5") for row in "ABCD"] + await self.star.pipettes.pick_up_tips(spots) + await self.star.pipettes.aspirate( + [self.source.get_item(f"{row}5") for row in "ABCD"], [50.0] * 4 + ) + await self.star.pipettes.drop_tips(spots) + by_kind = {r["kind"]: r for r in self.requests} + pickup = by_kind["tip_pickup"] + self.assertAlmostEqual(pickup["fixed"] + pickup["dwell"], motion.TIP_PICKUP_FIXED, places=3) + self.assertGreater(pickup["dwell"], 1.0) + self.assertGreaterEqual(by_kind["aspirate"]["fixed"] + 0.001, motion.ASPIRATE_FIXED) + # A drop spends its fixed time at the bottom, holding while the tips are pushed off. + drop = by_kind["tip_drop"] + self.assertAlmostEqual(drop["fixed"] + drop["dwell"], motion.TIP_DROP_FIXED, places=3) + self.assertGreater(drop["dwell"], 2.5) + for request in self.requests: + self.assertGreater(request["fixed"], 0.0, request["kind"]) + + async def test_a_tip_pick_up_presses_the_last_stretch_slowly(self): + await self.star.pipettes.pick_up_tips([self.rack.get_item("A6")]) + channel = next(r for r in self.requests if r["kind"] == "tip_pickup")["channels"][0] + self.assertLess(channel["press"], channel["down"]) + self.assertEqual(channel["press_speed"], motion.TIP_PRESS_SPEED) + + async def test_an_aspiration_follows_the_surface_and_pulls_out(self): + """Immersion, its direction, surface following and the pull-out, read off the command.""" + sent: List[Dict[str, Any]] = [] + listen = self.star.driver.motion_listener + + async def keep(module: str, command: str, params: Dict[str, Any]) -> None: + if module + command == "C0AS": + sent.append(dict(params)) + await listen(module, command, params) + + self.star.driver.motion_listener = keep + await self.star.pipettes.pick_up_tips([self.rack.get_item("A7")]) + await self.star.pipettes.aspirate([self.source.get_item("A7")], [100.0]) + params = sent[-1] + + # PLR sends the lowest allowed height as the surface itself; room below it, so immersion shows. + floor = [f"{int(z) - 200:04}" for z in params["zl"]] + + def decoded(**changed: Any) -> Dict[str, Any]: + request = star_motion(self.star.driver, "C0", "AS", {**params, "zx": floor, **changed}) + assert request is not None + return cast(Dict[str, Any], request["channels"][0]) + + plain = decoded(ip=["0020"], it=["0"], fp=["0000"], po=["0000"]) + deeper = decoded(ip=["0050"], it=["0"], fp=["0000"], po=["0000"]) + above = decoded(ip=["0050"], it=["1"], fp=["0000"], po=["0000"]) + self.assertAlmostEqual(plain["down"] - deeper["down"], 3.0, places=2) + self.assertAlmostEqual(above["down"] - deeper["down"], 10.0, places=2) + self.assertNotIn("follow", plain) + self.assertNotIn("pull_out", plain) + + following = decoded(ip=["0020"], it=["0"], fp=["0040"], po=["0100"]) + self.assertAlmostEqual(following["down"] - following["follow"], 4.0, places=2) + self.assertGreater(following["follow_speed"], 0.0) + self.assertAlmostEqual(following["pull_out"] - following["leave"], 10.0, places=2) + + async def test_a_read_moves_nothing(self): + self.assertIsNone(star_motion(self.star.driver, "C0", "RY", {})) + self.assertIsNone(star_motion(self.star.driver, "C0", "XX", {})) + + +class Head96DecoderTests(unittest.IsolatedAsyncioTestCase): + """A 96-head tip command is read for where it takes the arm and the head, and which tips move.""" + + async def test_a_rack_is_picked_up_and_put_back_where_the_model_then_has_it(self): + facility, star = await simulated_star(self) + head = star.driver.arms[0].head96 + requests: List[Dict[str, Any]] = [] + + async def listen(module: str, command: str, params: Dict[str, Any]) -> None: + request = star_motion(star.driver, module, command, params) + if request is not None: + requests.append(request) + + star.driver.motion_listener = listen + rack = star.deck.get_resource("tips_0") + tips = [spot.tip for spot in rack.get_all_items()] + for operation in (head.pick_up_tips, head.drop_tips): + requests.clear() + await operation(rack) + stroke = [r for r in requests if r["kind"].startswith("head96_tip")][-1] + self.assertAlmostEqual(stroke["arm"]["x"], star.x_arm.resource.location.x, delta=0.05) + target = stroke["channels"][0] + self.assertAlmostEqual(target["y"], head.resource.location.y, delta=0.05) + self.assertAlmostEqual(target["end"], head.resource.location.z, delta=0.05) + self.assertEqual(len(stroke["attach"]), 96) + for handover, tip in zip(stroke["attach"], tips): + self.assertEqual(handover["name"], tip.name) + self.assertEqual(handover["parent"], tip.parent.name) + for axis in "xyz": + self.assertAlmostEqual(handover["location"][axis], getattr(tip.location, axis), 6) + + async def test_the_tips_are_clamped_at_the_bottom_not_waited_for_before_the_head_moves(self): + facility, star = await simulated_star(self) + head = star.driver.arms[0].head96 + requests: List[Dict[str, Any]] = [] + + async def listen(module: str, command: str, params: Dict[str, Any]) -> None: + request = star_motion(star.driver, module, command, params) + if request is not None: + requests.append(request) + + star.driver.motion_listener = listen + rack = star.deck.get_resource("tips_0") + for operation, fixed in ( + (head.pick_up_tips, HEAD96_TIP_PICKUP_FIXED), + (head.drop_tips, HEAD96_TIP_DROP_FIXED), + ): + requests.clear() + await operation(rack) + stroke = [r for r in requests if r["kind"].startswith("head96_tip")][-1] + self.assertEqual(stroke["fixed"], SIMPLE_MOVE_FIXED) + self.assertAlmostEqual(stroke["dwell"] + stroke["fixed"], fixed, 3) + + async def test_an_aspirate_and_a_dispense_take_their_pumping_and_measured_time_at_the_bottom( + self, + ): + facility, star = await simulated_star(self) + head = star.driver.arms[0].head96 + requests: List[Tuple[str, Dict[str, Any], Dict[str, Any]]] = [] + + async def listen(module: str, command: str, params: Dict[str, Any]) -> None: + request = star_motion(star.driver, module, command, params) + if request is not None: + requests.append((command, params, request)) + + await head.pick_up_tips(star.deck.get_resource("tips_0")) + plate = star.deck.get_resource("source_1") + for well in plate.get_all_items(): + well.set_volume(100.0) + star.driver.motion_listener = listen + await head.aspirate(plate, 50.0) + await head.dispense(plate, 50.0) + self.assertEqual(sorted(c for c, _, _ in requests if c in ("EA", "ED")), ["EA", "ED"]) + for command, params, request in requests: + if command not in ("EA", "ED"): + continue + volume, flow = ( + (params["af"], params["ag"]) if command == "EA" else (params["df"], params["dg"]) + ) + pumping = int(volume) / int(flow) + measured = ( + HEAD96_ASPIRATE_FIXED if command == "EA" else HEAD96_DISPENSE_FIXED[int(params["da"])] + ) + settling = int(params["wh"]) / 10 + self.assertEqual(request["fixed"], SIMPLE_MOVE_FIXED) + self.assertAlmostEqual(request["dwell"] + request["fixed"], pumping + settling + measured, 2) + + +class FakePage: + """A page that is only a websocket: it plays each motion by waiting `delay` seconds.""" + + def __init__(self, viewer: Viewer3D, delay: Optional[float]): + self.viewer = viewer + self.delay = delay # None: never says it is done + self.events: List[str] = [] + self._socket: Any = None + self._task: Optional["asyncio.Task[None]"] = None + + async def open(self) -> "FakePage": + self._socket = await websockets.connect(self.viewer.ws_url, max_size=None) + await self._socket.send(json.dumps({"event": "hello", "data": {"backend": "none"}})) + self._task = asyncio.ensure_future(self._listen()) + while "scene" not in self.events: + await asyncio.sleep(0.01) + return self + + async def _listen(self) -> None: + try: + async for message in self._socket: + parsed = json.loads(message) + kind, data = parsed["event"], parsed["data"] + self.events.append(kind) + if kind == "state" and data.get("locations"): + self.events.append("locations") + self.events.extend(f"at:{name}" for name in data["locations"]) + if kind == "motion" and self.delay is not None: + asyncio.ensure_future(self._play(data["id"])) + except websockets.ConnectionClosed: + pass + + async def _play(self, motion_id: int) -> None: + await asyncio.sleep(self.delay or 0.0) + await self._socket.send(json.dumps({"event": "motion_done", "data": {"id": motion_id}})) + + async def close(self) -> None: + await self._socket.close() + if self._task is not None: + await self._task + + +class ServerTests(unittest.IsolatedAsyncioTestCase): + """A command waits until every page has played it, and for nothing when there is nobody.""" + + async def asyncSetUp(self) -> None: + self.facility, self.star = await simulated_star(self) + fs_port, ws_port = free_ports(2) + self.viewer = Viewer3D(self.facility, open_browser=False, fs_port=fs_port, ws_port=ws_port) + await self.viewer.start() + attach_viewer_motion(self.star.driver, self.viewer) + self.addAsyncCleanup(self.viewer.stop) + self.rack = self.star.deck.get_resource("tips_0") + assert isinstance(self.rack, TipRack) + + async def page(self, delay: Optional[float]) -> FakePage: + page = await FakePage(self.viewer, delay).open() + self.addAsyncCleanup(page.close) + return page + + async def timed_pick_up(self, well: str = "A1") -> float: + began = time.monotonic() + await self.star.pipettes.pick_up_tips([self.rack.get_item(well)]) + return time.monotonic() - began + + async def test_a_command_waits_for_the_page(self): + page = await self.page(0.5) + self.assertGreaterEqual(await self.timed_pick_up(), 0.5) + self.assertIn("motion", page.events) + + async def test_the_slowest_page_sets_the_pace(self): + await self.page(0.1) + await self.page(0.6) + self.assertGreaterEqual(await self.timed_pick_up(), 0.6) + + async def test_with_no_page_nothing_waits(self): + self.assertLess(await self.timed_pick_up(), 0.5) + + async def test_a_page_that_leaves_lets_the_command_go(self): + page = await self.page(None) # never answers + asyncio.get_running_loop().call_later(0.3, lambda: asyncio.ensure_future(page.close())) + self.assertLess(await self.timed_pick_up(), 5.0) + + async def test_a_page_that_leaves_while_a_motion_is_sent_lets_it_go_once(self): + """A reload closes the page while the motion is still being sent to it: letting the page go + releases the motion, and the send finding nobody left must not release it again.""" + await self.page(None) # never answers + send = self.viewer._broadcast + + async def send_then_leave(event: str, data: Any) -> None: + await send(event, data) + if event == "motion": + for websocket in list(self.viewer._clients): + self.viewer._clients.discard(websocket) + self.viewer._release_motions(websocket) + + self.viewer._broadcast = send_then_leave # type: ignore[method-assign] + self.assertLess(await self.timed_pick_up(), 5.0) + + async def test_a_motion_starts_from_where_the_last_command_left_things(self): + """The model moves when a command ends; the next motion is played from there, so what that + move changed has to reach the page first.""" + page = await self.page(0.05) + await self.timed_pick_up("A1") + await self.star.pipettes.drop_tips([self.rack.get_item("A1")]) + motions = [i for i, kind in enumerate(page.events) if kind == "motion"] + self.assertGreaterEqual(len(motions), 2) + between = page.events[motions[0] + 1 : motions[-1]] + # The pick-up took a tip onto a shaft, a change of shape, which carries the positions with it. + self.assertIn("moves", between, f"the pick-up reached the page too late: {page.events}") + + +class ISWAPDecoderTests(unittest.IsolatedAsyncioTestCase): + """An iSWAP command is read for the drives it moves, which is where the model then has them.""" + + async def asyncSetUp(self) -> None: + self.facility, self.star = await simulated_star(self) + self.iswap = self.star.iswap + self.requests: List[Dict[str, Any]] = [] + + async def listen(module: str, command: str, params: Dict[str, Any]) -> None: + request = star_motion(self.star.driver, module, command, params) + if request is not None: + self.requests.append(request) + + self.star.driver.motion_listener = listen + await self.iswap.make_space() + self.parked = await self.iswap.elbow_request_y_position() + + def last(self, kind: str) -> Dict[str, Any]: + return [r for r in self.requests if r["kind"] == kind][-1] + + async def test_the_head_moves_where_the_model_puts_it(self): + await self.iswap.elbow_move_to_y_position(self.parked - 150.0) + await self.iswap.elbow_move_to_z_position(250.0) + head = self.iswap.resource + moves = [r["moves"][0] for r in self.requests if r["kind"] == "iswap_move"] + along_y, along_z = moves[-2], moves[-1] + self.assertEqual((along_y["axis"], along_z["axis"]), (1, 2)) + self.assertAlmostEqual(along_y["to"], head.location.y, delta=0.05) + self.assertAlmostEqual(along_z["to"], head.location.z, delta=0.05) + self.assertGreater(along_y["speed"], 0) + + async def test_a_joint_turns_to_the_rotation_the_model_gives_it(self): + await self.iswap.elbow_move_to_y_position(self.parked - 200.0) + await self.iswap.rotate_to_angles(elbow_absolute_angle="front", gripper_absolute_angle="left") + turns = {t["name"]: t for t in self.last("iswap_turn")["turns"]} + for resource in (self.iswap.link_1, self.iswap.gripper): + turn = turns[resource.name] + self.assertAlmostEqual((turn["drive"] - turn["base"]) % 360, resource.rotation.z % 360, 3) + self.assertEqual(turn["pivot"]["x"], resource.proximal_joint.x) + self.assertGreater(turn["speed"], 0) + + async def test_the_fingers_stand_where_the_model_stands_them(self): + await self.iswap.gripper_move_to_jaw_position(90.0) + jaws = self.last("iswap_jaws")["jaws"] + for finger, target in zip(self.iswap.gripper.fingers, jaws["fingers"]): + self.assertEqual(target["name"], finger.name) + self.assertAlmostEqual(target["y"], finger.location.y, delta=0.01) + self.assertEqual(jaws["gripper"], self.iswap.gripper.name) + + +class Head96OffsetAndLiquidDecoderTests(unittest.IsolatedAsyncioTestCase): + """The 96-head's commands, read for what they move and which tips change hands.""" + + async def asyncSetUp(self) -> None: + self.facility, self.star = await simulated_star(self) + self.head = self.star.head96 + self.rack = self.star.deck.get_resource("tips_1") + self.requests: List[Dict[str, Any]] = [] + + async def listen(module: str, command: str, params: Dict[str, Any]) -> None: + request = star_motion(self.star.driver, module, command, params) + if request is not None: + self.requests.append(request) + + self.star.driver.motion_listener = listen + + async def test_an_aspiration_goes_down_to_the_liquid_surface(self): + # The 96-head has no liquid class for the demo rack's filtered 1000 uL tips, and the + # aspirate refuses without one; the decoder only needs the corrected volume, so the class + # the 300 uL head uses stands in. + from pylabrobot.hamilton.star.liquid_classes.mapping import star_mapping + from pylabrobot.resources.liquid import Liquid + + liquid_class = star_mapping[(300, True, True, False, Liquid.WATER, False, False)] + await self.head.pick_up_tips(self.rack) + plate = self.star.deck.get_resource("source_1") + fill(plate, 200.0) + await self.head.aspirate(plate, 50.0, liquid_height=2.0, hamilton_liquid_class=liquid_class) + request = [r for r in self.requests if r["kind"] == "head96_aspirate"][-1] + self.assertEqual(request["command"], "C0EA") + channel = request["channels"][0] + self.assertLess(channel["down"], channel["end"]) + + +class ISWAPServerTests(unittest.IsolatedAsyncioTestCase): + async def test_a_move_the_model_records_first_is_played_before_it_is_told(self): + """The iSWAP writes a move's target before sending it. Told first, the page would put the head + at the end of the move and have nothing left to play.""" + facility, star = await simulated_star(self) + fs_port, ws_port = free_ports(2) + viewer = Viewer3D(facility, open_browser=False, fs_port=fs_port, ws_port=ws_port) + await viewer.start() + self.addAsyncCleanup(viewer.stop) + attach_viewer_motion(star.driver, viewer) + await star.iswap.make_space() + y = await star.iswap.elbow_request_y_position() + page = await FakePage(viewer, 0.05).open() + self.addAsyncCleanup(page.close) + head = f"at:{star.iswap.resource.name}" + before = len(page.events) + await star.iswap.elbow_move_to_y_position(y - 100.0) + for _ in range(100): + if head in page.events[before:]: + break + await asyncio.sleep(0.02) + after = page.events[before:] + self.assertIn("motion", after) + self.assertIn(head, after) + self.assertLess(after.index("motion"), after.index(head), after) + + +@unittest.skipUnless(NODE, "no Node to run the player's tests") +class PlayerTests(unittest.TestCase): + def test_the_player(self): + result = subprocess.run( + [str(NODE), "--test", str(HERE.parent.parent / "visualizer3D" / "motion_player_tests.mjs")], + capture_output=True, + text=True, + timeout=120, + ) + self.assertEqual(result.returncode, 0, result.stdout[-3000:] + result.stderr[-2000:]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/visualizer3D/browser_tests.py b/pylabrobot/visualizer3D/browser_tests.py index 04156acf6e1..cc811d34cf7 100644 --- a/pylabrobot/visualizer3D/browser_tests.py +++ b/pylabrobot/visualizer3D/browser_tests.py @@ -40,7 +40,8 @@ def _find_chrome() -> str: - """Where a headless Chrome is, or empty: `PLR_CHROME`, then the path, then the macOS install.""" + """Where a headless Chrome is, or empty: `PLR_CHROME`, then the path, then the macOS and Windows + installs.""" named = os.environ.get("PLR_CHROME", "") if named: return named @@ -48,8 +49,14 @@ def _find_chrome() -> str: found = shutil.which(name) if found is not None: return found - installed = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" - return installed if os.path.isfile(installed) else "" + for installed in ( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"), + ): + if os.path.isfile(installed): + return installed + return "" CHROME = _find_chrome() diff --git a/pylabrobot/visualizer3D/core_gripper_demo.py b/pylabrobot/visualizer3D/core_gripper_demo.py new file mode 100644 index 00000000000..d7da55fae11 --- /dev/null +++ b/pylabrobot/visualizer3D/core_gripper_demo.py @@ -0,0 +1,78 @@ +"""The CO-RE gripper: tools on, a plate gripped off its site, carried across the deck, put down. + +The plate hangs from the front tool's jaws in the model, rides the X-arm, and passes to the site +when the jaws open. + +Run it: + + python -m pylabrobot.visualizer3D.core_gripper_demo +""" + +import asyncio +import logging + +from pylabrobot.resources.plate import Plate + +from ..hamilton.star.motion import attach_viewer_motion +from .demo import build_facility, star_of +from .server import Viewer3D + + +async def main() -> None: + logging.disable(logging.WARNING) + + facility = build_facility() + star = star_of(facility) + await star.setup() + grippers = star.core_grippers + if grippers is None: + raise RuntimeError("the simulated STARlet has no CO-RE grippers") + + deck = star.deck + source = deck.get_resource("source_0") + if not isinstance(source, Plate): + raise RuntimeError("the demo deck no longer holds the plate this run grips") + + # The site it goes to has to be free: the destination carrier's front-most site stands empty. + destination_carrier = deck.get_resource("destination_carrier") + site = min(destination_carrier.children, key=lambda s: s.get_absolute_location().y) + held = [child for child in site.children if isinstance(child, Plate)] + for plate in held: + plate.unassign() + + viewer = Viewer3D(facility, name="core_gripper_demo.py", open_browser=False) + await viewer.start() + attach_viewer_motion(star.driver, viewer) + print("waiting for a browser to draw the scene") + await viewer.wait_for_browser() + await viewer.wait_for_models() + print("the page is up; the run starts in ten seconds") + await asyncio.sleep(10.0) + + print("picking up the CO-RE grip tools") + await grippers.pick_up_tools(front_channel=7) + + print(f"gripping {source.name} where it stands") + await grippers.pick_up_resource(source) + + here = source.get_absolute_location().x + there = site.get_absolute_location().x + print(f"carrying it across the deck, {there - here:.0f} mm to the front-most destination site") + x = await star.x_arm.request_position() + await star.x_arm.move_to_x_position(x + (there - here)) + + print("putting it down on the site") + await grippers.drop_resource(site) + + print("returning the tools to their holder") + await grippers.drop_tools() + + print("run complete; the viewer stays up") + await asyncio.Event().wait() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/pylabrobot/visualizer3D/head96_demo.py b/pylabrobot/visualizer3D/head96_demo.py new file mode 100644 index 00000000000..a341babe367 --- /dev/null +++ b/pylabrobot/visualizer3D/head96_demo.py @@ -0,0 +1,71 @@ +"""The 96 head: a full plate of tips, a full plate of aspiration, a full plate of dispensing. + +The head moves as one: down to the liquid's surface, following it as it draws, then out and over. + +Run it: + + python -m pylabrobot.visualizer3D.head96_demo +""" + +import asyncio +import logging + +from pylabrobot.resources import set_volume_tracking +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.tip_rack import TipRack +from pylabrobot.resources.tip_tracking import set_tip_tracking + +from ..hamilton.star.motion import attach_viewer_motion +from .demo import build_facility, fill, star_of +from .server import Viewer3D + + +async def main() -> None: + logging.disable(logging.WARNING) + set_volume_tracking(True) + set_tip_tracking(True) + + facility = build_facility() + star = star_of(facility) + await star.setup() + head96 = star.head96 + if head96 is None: + raise RuntimeError("the simulated STARlet has no 96 head") + + deck = star.deck + rack = deck.get_resource("tips_0") + if not isinstance(rack, TipRack): + raise RuntimeError("the demo deck no longer holds the tips this run uses") + source = deck.get_resource("source_0") + destination = deck.get_resource("destination_0") + if not isinstance(source, Plate) or not isinstance(destination, Plate): + raise RuntimeError("the demo deck no longer holds the plates this run uses") + fill(source, 200.0) + + viewer = Viewer3D(facility, name="head96_demo.py", open_browser=False) + await viewer.start() + attach_viewer_motion(star.driver, viewer) + print("waiting for a browser to draw the scene") + await viewer.wait_for_browser() + await viewer.wait_for_models() + print("the page is up; the run starts in ten seconds") + await asyncio.sleep(10.0) + + print("picking up ninety-six tips") + await head96.pick_up_tips(rack) + + print("aspirating fifty uL from every well of the source plate") + await head96.aspirate(source, 50.0) + + print("dispensing into the destination plate") + await head96.dispense(destination, 50.0) + + print("run complete; the viewer stays up") + await asyncio.Event().wait() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/pylabrobot/visualizer3D/iswap_demo.py b/pylabrobot/visualizer3D/iswap_demo.py new file mode 100644 index 00000000000..55d3ef7d01a --- /dev/null +++ b/pylabrobot/visualizer3D/iswap_demo.py @@ -0,0 +1,185 @@ +"""The iSWAP carrying a lidded plate between two carrier sites, and taking its lid off and on. + +Run it: + + python -m pylabrobot.visualizer3D.iswap_demo + +The arm is driven only by the PR's primitive moves - the X-arm, the head's Y and Z, the two joints +and the jaws - each of which the page plays at the speed the command carries. PyLabRobot does not +move what the iSWAP grips, so the page does: it hands the labware to the gripper when the jaws close +on it and to what is under it when they open - a site, or for a lid, a plate with no lid. + +Until stopped: the plate goes over with its lid, the lid comes off onto the empty site, goes back +on, and the plate comes back. +""" + +import asyncio +import logging +from typing import List + +from pylabrobot.hamilton.star.motion import attach_viewer_motion +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.corning.plates import ( + cor_96_wellplate_360uL_Fb, + cor_96_wellplate_360uL_Fb_lid, +) +from pylabrobot.resources.hamilton.plate_carriers import PLT_CAR_L5AC_A00 +from pylabrobot.resources.lid import Lid +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.resource import Resource + +from .demo import build_facility, star_of +from .server import Viewer3D + +# How far above a plate's bottom the jaws take hold of it, in mm: below where its lid comes down +# over it, or they would close on the lid. +PLATE_GRIP_HEIGHT = 4.0 +# How far above a lid's bottom the jaws take hold of it, in mm. +LID_GRIP_HEIGHT = 5.0 +# How much narrower than what they hold the jaws close to, in mm: they stop on it. +SQUEEZE = 3.0 + + +def grip_centre(iswap, deck: Resource) -> Coordinate: + """Where the model has the point the jaws close on, in mm on the deck.""" + gripper = iswap.gripper + local = (gripper.proximal_joint + gripper.tool_center_point).vector() + turn = gripper.get_absolute_rotation().get_rotation_matrix() + base = gripper.get_location_wrt(deck) + moved = [sum(turn[row][k] * local[k] for k in range(3)) for row in range(3)] + return Coordinate(base.x + moved[0], base.y + moved[1], base.z + moved[2]) + + +async def move_grip_centre(iswap, deck: Resource, target: Coordinate, axes: str) -> None: + """Bring the grip centre to `target` along the named axes, the joints held where they are.""" + offset = target - grip_centre(iswap, deck) + if "x" in axes: + x = await iswap.arm.request_position() + await iswap.arm.move_to_x_position(round(x + offset.x, 1)) + if "y" in axes: + y = await iswap.elbow_request_y_position() + await iswap.elbow_move_to_y_position(round(y + offset.y, 1)) + if "z" in axes: + z = await iswap.elbow_request_z_position() + await iswap.elbow_move_to_z_position(round(z + offset.z, 1)) + + +async def carry(iswap, deck: Resource, width: float, here: Coordinate, there: Coordinate, travel_z): + """Take what is `width` wide and gripped at `here` to `there`, travelling at `travel_z`.""" + await iswap.gripper_open() + await move_grip_centre(iswap, deck, Coordinate(here.x, here.y, travel_z), "xy") + await move_grip_centre(iswap, deck, here, "z") + await iswap.gripper_move_to_jaw_position(width - SQUEEZE) + await move_grip_centre(iswap, deck, Coordinate(here.x, here.y, travel_z), "z") + await move_grip_centre(iswap, deck, Coordinate(there.x, there.y, travel_z), "xy") + await move_grip_centre(iswap, deck, there, "z") + await iswap.gripper_open() + await move_grip_centre(iswap, deck, Coordinate(there.x, there.y, travel_z), "z") + + +def surface(site: Resource, deck: Resource) -> Coordinate: + """The middle of a site's surface, in mm on the deck.""" + return site.get_location_wrt(deck, "c", "c", "b") + + +def plate_grip(site: Resource, plate: Plate, deck: Resource) -> Coordinate: + """Where the jaws close on `plate` standing on `site`.""" + seated = plate.location.z if plate.location is not None else 0.0 + return surface(site, deck) + Coordinate(0, 0, seated + PLATE_GRIP_HEIGHT) + + +def lid_grip_on_plate(site: Resource, plate: Plate, lid: Lid, deck: Resource) -> Coordinate: + """Where the jaws close on `lid` covering `plate` on `site`: its bottom is the plate's top less + the height it nests over it.""" + seated = plate.location.z if plate.location is not None else 0.0 + bottom = seated + plate.get_size_z() - lid.nesting_z_height + return surface(site, deck) + Coordinate(0, 0, bottom + LID_GRIP_HEIGHT) + + +def lid_grip_on_site(site: Resource, deck: Resource) -> Coordinate: + """Where the jaws close on a lid lying on an empty `site`.""" + return surface(site, deck) + Coordinate(0, 0, LID_GRIP_HEIGHT) + + +async def main() -> None: + logging.disable(logging.WARNING) + facility = build_facility(bare=True) + star = star_of(facility) + deck = star.deck + + # A carrier of its own on an otherwise empty deck, its two sites as far apart as the carrier + # comes: the jaws open wider than what they grip, so everything around a move needs room for + # the open jaws, and out here there is nothing to clip. + carrier = PLT_CAR_L5AC_A00(name="plate_carrier") + deck.assign_child_resource(carrier, track=10) + + plate = cor_96_wellplate_360uL_Fb(name="plate") + carrier[0] = plate + lid = cor_96_wellplate_360uL_Fb_lid(name="plate_lid") + plate.assign_child_resource(lid) + await star.setup() + iswap = star.iswap + if iswap is None: + raise RuntimeError("the simulated STARlet has no iSWAP") + + # The turned gripper reaches less far than the elbow's own travel: with the wrist at -135 the + # grip centre hangs behind the elbow, so the farthest site the pose can bring the jaws to is the + # elbow's front stop less that hang - site three, not site four. + if plate.parent is None: + raise RuntimeError("the plate has no parent site to grip it at") + sites: List[Resource] = [plate.parent, carrier[3]] + + viewer = Viewer3D(facility, name="iswap_demo.py", open_browser=False) + await viewer.start() + attach_viewer_motion(star.driver, viewer) + print("waiting for a browser to draw the scene") + await viewer.wait_for_browser() + await viewer.wait_for_models() + print("the page is up; the run starts in ten seconds") + await asyncio.sleep(10.0) + + # Clear of the channels, then turned so the jaws close across the plate's short side. The elbow + # is brought forward by the first carry itself: its reach envelope depends on the turn, so an + # absolute jog from the folded park would guess wrong. + await iswap.make_space() + await iswap.rotate_to_angles(elbow_absolute_angle="front", gripper_absolute_angle="left") + travel_z = grip_centre(iswap, deck).z + + width = plate.get_size_y() + lid_width = lid.get_size_y() + while True: + start, other = sites + print("the plate goes over, with its lid") + await carry( + iswap, deck, width, plate_grip(start, plate, deck), plate_grip(other, plate, deck), travel_z + ) + print("its lid comes off, onto the site it left") + await carry( + iswap, + deck, + lid_width, + lid_grip_on_plate(other, plate, lid, deck), + lid_grip_on_site(start, deck), + travel_z, + ) + print("and goes back on") + await carry( + iswap, + deck, + lid_width, + lid_grip_on_site(start, deck), + lid_grip_on_plate(other, plate, lid, deck), + travel_z, + ) + print("the plate comes back") + await carry( + iswap, deck, width, plate_grip(other, plate, deck), plate_grip(start, plate, deck), travel_z + ) + await asyncio.sleep(1.0) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/pylabrobot/visualizer3D/motion_demo.py b/pylabrobot/visualizer3D/motion_demo.py new file mode 100644 index 00000000000..d806c1e0622 --- /dev/null +++ b/pylabrobot/visualizer3D/motion_demo.py @@ -0,0 +1,72 @@ +"""A simulated STARlet pipetting, with the drives' motion acted out between the commands. + +Run it: + + python -m pylabrobot.visualizer3D.motion_demo + +The viewer reads each firmware command for its targets and the page plays how the drives get +there: across (the arm and the channels at once), down, tips taken or left at the bottom, back up. +Each command waits until the page has played it, so the run goes at the pace of the picture. Add +`?motion=2` to the address (before the `#`) for twice the speed, `?motion=0` to skip the motion. + +Column by column: pick up eight tips, aspirate from the source plate, put the tips back. +""" + +import asyncio +import logging + +from pylabrobot.hamilton.star.motion import attach_viewer_motion +from pylabrobot.resources import set_volume_tracking +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.tip_rack import TipRack +from pylabrobot.resources.tip_tracking import set_tip_tracking + +from .demo import build_facility, fill, star_of +from .server import Viewer3D + +ROWS = "ABCDEFGH" + + +async def main() -> None: + logging.disable(logging.WARNING) + set_volume_tracking(True) + # So a tip picked up is the one that was in the spot, carried to the channel and back. + set_tip_tracking(True) + + facility = build_facility() + star = star_of(facility) + await star.setup() + pipettes = star.pipettes + if pipettes is None: + raise RuntimeError("the simulated STARlet has no channels") + + rack = star.deck.get_resource("tips_0") + source = star.deck.get_resource("source_0") + if not isinstance(rack, TipRack) or not isinstance(source, Plate): + raise TypeError("the demo deck no longer holds the rack and plate this run uses") + fill(source, 300.0) + + viewer = Viewer3D(facility, name="motion_demo.py") + await viewer.start() + attach_viewer_motion(star.driver, viewer) + print("waiting for a browser to draw the scene") + await viewer.wait_for_browser() + await asyncio.sleep(1.0) + + for column in range(1, 13): + tips = [rack.get_item(f"{row}{column}") for row in ROWS] + wells = [source.get_item(f"{row}{column}") for row in ROWS] + print(f"column {column}: pick up, aspirate, put back") + await pipettes.pick_up_tips(tips) + await pipettes.aspirate(wells, [50.0] * len(wells)) + await pipettes.drop_tips(tips) + + print("run complete; the viewer stays up") + await asyncio.Event().wait() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/pylabrobot/visualizer3D/motion_player_tests.mjs b/pylabrobot/visualizer3D/motion_player_tests.mjs new file mode 100644 index 00000000000..1a1cce5c338 --- /dev/null +++ b/pylabrobot/visualizer3D/motion_player_tests.mjs @@ -0,0 +1,522 @@ +// The motion player, outside a page: `node --test pylabrobot/visualizer3D/motion_player_tests.mjs`. +// Run from Python by `motion_tests.py`, which skips it where there is no Node. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { heldAt, siteUnder } from "./static/carry.js"; +import { createPlayer, turnedLocation } from "./static/motion_player.js"; +import { motionProfile } from "./static/motion_profile.js"; + +const DRIVES = { + x: { speed: 400, acceleration: 500 }, + y: { speed: 250, acceleration: null }, + z: { speed: 125, acceleration: 800 }, +}; + +// A world of named resources with positions, recording every change in order. +function fakeWorld(positions) { + const names = Object.keys(positions); + const at = names.map((n) => [...positions[n]]); + const log = []; + const parents = {}; + return { + log, + parents, + at: (name) => at[names.indexOf(name)], + deps: (skipping = () => false) => ({ + readAxis: (i, axis) => at[i][axis], + setAxis: (i, axis, value) => { + at[i][axis] = value; + log.push({ name: names[i], axis, value }); + }, + indexOf: (name) => (names.includes(name) ? names.indexOf(name) : undefined), + attach: (name, parent) => { + parents[name] = parent; + log.push({ attach: name, parent, z: at[names.indexOf("ch0")][2] }); + }, + skipping, + }), + }; +} + +// Play to the end in fixed frames, as the frame loop would, and say how long it took. +async function playOut(player, request, frame = 1 / 60) { + let done = false; + const playing = player.play(request).then(() => { + done = true; + }); + let seconds = 0; + for (let i = 0; i < 100000 && !done; i++) { + await new Promise((resolve) => setImmediate(resolve)); // let finished moves start the next + if (done) break; + player.step(frame); + seconds += frame; + } + await playing; + return seconds; +} + +const pickUp = { + kind: "tip_pickup", + arm: { name: "arm", x: 300 }, + traverse: [{ name: "ch0", z: 0 }], + channels: [{ name: "ch0", channel: 0, y: 200, down: -100, end: 10 }], + attach: [{ name: "tip", parent: "shaft0" }], + dwell: 0, + drives: DRIVES, +}; + +const start = () => ({ arm: [100, 0, 0], ch0: [0, 150, 10], tip: [0, 0, 0] }); + +test("a pick-up goes across, then down, takes the tip at the bottom, then comes up", async () => { + const world = fakeWorld(start()); + await playOut(createPlayer(world.deps()), pickUp); + + const firstZ = world.log.findIndex((e) => e.axis === 2); + const across = world.log.slice(0, firstZ); + assert.ok(across.some((e) => e.name === "arm") && across.some((e) => e.axis === 1)); + // The arm and the channel travel together: their changes interleave rather than follow. + const lastArm = across.findLastIndex((e) => e.name === "arm"); + const firstY = across.findIndex((e) => e.axis === 1); + assert.ok(firstY < lastArm, "the channel waited for the arm"); + + const handover = world.log.findIndex((e) => e.attach === "tip"); + assert.ok(handover > firstZ, "the tip was taken before the channel went down"); + assert.equal(world.log[handover].z, -100, "the tip was taken away from the bottom"); + assert.equal(world.parents.tip, "shaft0"); + const after = world.log.slice(handover + 1).filter((e) => e.axis === 2); + assert.ok(after.length > 1 && after.at(-1).value === 10, "the channel did not come back up"); + assert.deepEqual(world.at("arm"), [300, 0, 0]); + assert.deepEqual(world.at("ch0"), [0, 200, 10]); +}); + +test("a move passes through the positions between, forward only", async () => { + const world = fakeWorld(start()); + await playOut(createPlayer(world.deps()), pickUp); + const xs = world.log.filter((e) => e.name === "arm").map((e) => e.value); + assert.ok(xs.filter((x) => x > 101 && x < 299).length >= 10, "the arm jumped"); + for (let i = 1; i < xs.length; i++) assert.ok(xs[i] >= xs[i - 1], "the arm went backwards"); +}); + +test("a motion takes as long as its drives say", async () => { + const world = fakeWorld(start()); + const seconds = await playOut(createPlayer(world.deps()), pickUp); + // Across is the slower of X and Y; then down and back up in Z. + const across = Math.max( + motionProfile(200, 400, 500).duration, + motionProfile(50, 250, null).duration, + ); + const expected = + across + motionProfile(110, 125, 800).duration + motionProfile(110, 125, 800).duration; + assert.ok(Math.abs(seconds - expected) < 0.2, `took ${seconds}, expected ${expected}`); +}); + +test("an aspiration dwells at the bottom and leaves the liquid at its own speed", async () => { + const world = fakeWorld(start()); + const aspirate = { + ...pickUp, + kind: "aspirate", + attach: [], + dwell: 1.5, + channels: [{ name: "ch0", channel: 0, y: 150, down: -100, end: 10, leave: -90, leave_speed: 5 }], + }; + const quick = await playOut(createPlayer(fakeWorld(start()).deps()), { ...aspirate, dwell: 0 }); + const withDwell = await playOut(createPlayer(world.deps()), aspirate); + assert.ok(Math.abs(withDwell - quick - 1.5) < 0.05, "the dwell was not waited out"); + // 10 mm at 5 mm/s is at least 2 s, far slower than the drive's own 125 mm/s. + const leaving = world.log.filter((e) => e.axis === 2 && e.value > -100 && e.value <= -90); + assert.ok(leaving.length > 100, "it left the liquid at the drive's speed, not its own"); +}); + +test("a command's fixed time is spent before anything moves", async () => { + const world = fakeWorld(start()); + const quick = await playOut(createPlayer(fakeWorld(start()).deps()), pickUp); + const player = createPlayer(world.deps()); + let seconds = 0; + const playing = player.play({ ...pickUp, fixed: 1.2 }); + for (; seconds < 1.1; seconds += 1 / 60) { + await new Promise((resolve) => setImmediate(resolve)); + player.step(1 / 60); + } + assert.equal(world.log.length, 0, "something moved during the fixed time"); + let rest = 0; + let done = false; + playing.then(() => (done = true)); + for (let i = 0; i < 100000 && !done; i++) { + await new Promise((resolve) => setImmediate(resolve)); + if (done) break; + player.step(1 / 60); + rest += 1 / 60; + } + assert.ok(Math.abs(seconds + rest - quick - 1.2) < 0.05, `took ${seconds + rest}, expected ${quick + 1.2}`); +}); + +test("the channels set off in Y one after another, each a stagger after the last", async () => { + const positions = { arm: [100, 0, 0], ch0: [0, 150, 10], ch1: [0, 140, 10], ch2: [0, 130, 10] }; + const world = fakeWorld(positions); + const request = { + ...pickUp, + arm: null, + attach: [], + traverse: [], + channels: [ + { name: "ch2", channel: 2, y: 30 }, + { name: "ch0", channel: 0, y: 50 }, + { name: "ch1", channel: 1, y: 40 }, + ], + drives: { ...DRIVES, y: { speed: 250, acceleration: 800, stagger: 0.5 } }, + }; + const seconds = await playOut(createPlayer(world.deps()), request); + // All three move toward the front, so the front-most channel leaves first and the ripple runs + // back: ch2 first, ch0 last, two staggers after it. A channel that set off before the one in + // front of it had moved would run into the back of it. + const first = (name) => world.log.findIndex((e) => e.name === name); + assert.ok(first("ch2") < first("ch1") && first("ch1") < first("ch0"), "not leader-first"); + const expected = 2 * 0.5 + motionProfile(100, 250, 800).duration; + assert.ok(Math.abs(seconds - expected) < 0.05, `took ${seconds}, expected ${expected}`); + assert.deepEqual([world.at("ch0")[1], world.at("ch1")[1], world.at("ch2")[1]], [50, 40, 30]); +}); + +test("a tip pick-up presses the last stretch at its own slow speed", async () => { + const world = fakeWorld(start()); + const pressing = { + ...pickUp, + channels: [{ name: "ch0", channel: 0, y: 150, down: -90, press: -100, press_speed: 10, end: 10 }], + }; + const seconds = await playOut(createPlayer(world.deps()), pressing); + const handover = world.log.findIndex((e) => e.attach === "tip"); + assert.equal(world.log[handover].z, -100, "the tip was taken before the press ended"); + const slow = world.log.filter((e) => e.axis === 2 && e.value < -90 && e.value >= -100); + assert.ok(slow.length > 50, "the press ran at the drive's speed"); + assert.ok(seconds > 1.0, `10 mm at 10 mm/s took only ${seconds}`); +}); + +test("an aspiration follows the surface down while it draws, then pulls out", async () => { + const world = fakeWorld(start()); + const aspirate = { + ...pickUp, + kind: "aspirate", + arm: null, + attach: [], + dwell: 2, + channels: [ + { + name: "ch0", + channel: 0, + y: 150, + down: -100, + follow: -104, + follow_speed: 2, + leave: -100, + leave_speed: 50, + pull_out: -90, + end: 10, + }, + ], + }; + const seconds = await playOut(createPlayer(world.deps()), aspirate); + const zs = world.log.filter((e) => e.name === "ch0" && e.axis === 2).map((e) => e.value); + const lowest = Math.min(...zs); + assert.ok(Math.abs(lowest + 104) < 0.01, `it went down to ${lowest}, not the followed -104`); + // 4 mm at 2 mm/s fills the 2 s dwell: steadily, not in a jump. + const following = world.log.filter((e) => e.axis === 2 && e.value < -100 && e.value > -104); + assert.ok(following.length > 60, "it jumped rather than followed"); + // After the lowest point: out to the surface, then on up through the pull-out stretch. + const after = zs.slice(zs.indexOf(lowest)); + assert.ok(after.filter((z) => z > -95 && z < -90).length > 3, "it did not pull out steadily"); + assert.equal(world.at("ch0")[2], 10); + assert.ok(seconds > 2, `took only ${seconds}`); +}); + +test("a pick-up starts down before its crossing has finished", async () => { + const request = { ...pickUp, attach: [], down_from: 0.5 }; + const world = fakeWorld(start()); + const overlapped = await playOut(createPlayer(world.deps()), request); + const sequence = await playOut(createPlayer(fakeWorld(start()).deps()), { ...request, down_from: 0 }); + assert.ok(overlapped < sequence - 0.1, `overlapped ${overlapped}, in sequence ${sequence}`); + // The channel was going down while the arm still moved. + const firstDown = world.log.findIndex((e) => e.name === "ch0" && e.axis === 2); + const lastArm = world.log.findLastIndex((e) => e.name === "arm"); + assert.ok(firstDown < lastArm, "the descent waited for the arm"); + assert.deepEqual(world.at("arm"), [300, 0, 0]); +}); + +test("a page in the background jumps to the end and still hands the tips over", async () => { + const world = fakeWorld(start()); + const player = createPlayer(world.deps(() => true)); + await player.play(pickUp); // resolves with no frames at all + assert.deepEqual(world.at("ch0"), [0, 200, 10]); + assert.equal(world.parents.tip, "shaft0"); + assert.equal(player.step(0.016), false); +}); + +test("speed 0 jumps to the end", async () => { + const world = fakeWorld(start()); + const player = createPlayer(world.deps()); + player.setSpeed(0); + await player.play(pickUp); + assert.deepEqual(world.at("arm"), [300, 0, 0]); +}); + +test("a resource the page does not have is skipped, not waited for", async () => { + const world = fakeWorld(start()); + const request = { ...pickUp, arm: { name: "gone", x: 5 } }; + await playOut(createPlayer(world.deps()), request); + assert.deepEqual(world.at("ch0"), [0, 200, 10]); +}); + +test("the frame loop keeps going between one move ending and the next starting", async () => { + const world = fakeWorld(start()); + const player = createPlayer(world.deps()); + const playing = player.play(pickUp); + for (let i = 0; i < 2000; i++) { + await new Promise((resolve) => setImmediate(resolve)); + if (!player.step(1 / 60)) break; + } + await playing; + assert.deepEqual(world.at("ch0"), [0, 200, 10], "the loop stopped with the motion unfinished"); +}); + +test("the profile reaches the end exactly and never overshoots", () => { + for (const [d, v, a] of [ + [200, 400, 500], + [5, 400, 500], + [50, 250, null], + ]) { + const p = motionProfile(d, v, a); + assert.equal(p.progress(p.duration), 1); + for (let t = 0; t <= p.duration; t += p.duration / 50) { + assert.ok(p.progress(t) >= 0 && p.progress(t) <= 1); + } + } +}); + +// -- iSWAP ----------------------------------------------------------------------------------------- + +test("a turn keeps its pivot where PyLabRobot's rotate_to keeps it", () => { + // iSWAP link 1 in the demo, turned by `rotate_to(z=200, pivot_coordinate=proximal_joint)`. + const pivot = { x: 12.7, y: 12.75, z: -20.3 }; + const after = turnedLocation({ x: 2.8527, y: 2.2055, z: -15.3 }, 1.34477, 200, pivot); + assert.ok(Math.abs(after.x - 22.8233) < 1e-3 && Math.abs(after.y - 31.5748) < 1e-3); + assert.equal(after.z, -15.3); +}); + +// A joint as the page has it: its rotation and location, turned through `turnTo`. +function jointWorld(rotation, location) { + const state = { rotation, location: { ...location } }; + const turns = []; + const deps = { + readAxis: (_i, axis) => (axis === 5 ? state.rotation : 0), + setAxis: () => {}, + indexOf: (name) => (name === "link" ? 0 : undefined), + attach: () => {}, + skipping: () => false, + turnTo: (_i, degrees, pivot) => { + state.location = turnedLocation(state.location, state.rotation, degrees, pivot); + state.rotation = degrees; + turns.push(degrees); + }, + }; + return { state, turns, deps }; +} + +test("a joint turns the way its drive runs, from its angle to the one sent", async () => { + // Link 1 pointing right (drive 90, rotation 0) sent to the left (drive -90): through the front + // (rotation 270), not the short way through the back. + const pivot = { x: 12.7, y: 12.75, z: -20.3 }; + const { state, turns, deps } = jointWorld(0, { x: 0, y: 0, z: 0 }); + const start = turnedLocation(state.location, 0, 0, pivot); + const turn = { name: "link", drive: -90, base: 90, pivot, speed: 60, acceleration: 200 }; + await playOut(createPlayer(deps), { turns: [turn] }); + assert.equal(((state.rotation % 360) + 360) % 360, 180); + const unwrapped = turns.map((z) => z + 90); + assert.ok(unwrapped.some((d) => Math.abs(d) < 5), "it did not pass through the front"); + for (let i = 1; i < unwrapped.length; i++) assert.ok(unwrapped[i] <= unwrapped[i - 1] + 1e-9); + // The pivot has not moved. + const a = (state.rotation * Math.PI) / 180; + const px = state.location.x + Math.cos(a) * pivot.x - Math.sin(a) * pivot.y; + const py = state.location.y + Math.sin(a) * pivot.x + Math.cos(a) * pivot.y; + const p0x = start.x + pivot.x; + const p0y = start.y + pivot.y; + assert.ok(Math.abs(px - p0x) < 1e-6 && Math.abs(py - p0y) < 1e-6, "the pivot moved"); +}); + +test("a turn takes as long as its drive's speed and acceleration say", async () => { + const { deps } = jointWorld(0, { x: 0, y: 0, z: 0 }); + const turn = { name: "link", drive: -90, base: 90, pivot: { x: 0, y: 0, z: 0 }, speed: 60, acceleration: 200 }; + const seconds = await playOut(createPlayer(deps), { turns: [turn] }); + const expected = motionProfile(180, 60, 200).duration; + assert.ok(Math.abs(seconds - expected) < 0.1, `took ${seconds}, expected ${expected}`); +}); + +function jawsWorld(y0, y1) { + const at = { f0: y0, f1: y1 }; + const log = []; + const deps = { + readAxis: (i) => (i === 0 ? at.f0 : at.f1), + setAxis: (i, _axis, value) => { + at[i === 0 ? "f0" : "f1"] = value; + }, + indexOf: (name) => ({ f0: 0, f1: 1 })[name], + attach: () => {}, + skipping: () => false, + grip: (gripper) => log.push({ grip: gripper, f0: at.f0 }), + release: (gripper) => log.push({ release: gripper, f0: at.f0 }), + }; + return { at, log, deps }; +} + +const jaws = (f0, f1) => ({ + jaws: { + gripper: "g", + fingers: [ + { name: "f0", y: f0 }, + { name: "f1", y: f1 }, + ], + speed: 20, + acceleration: 100, + grip_point: { x: 0, y: 0, z: 0 }, + }, +}); + +test("jaws that close take hold once they have closed", async () => { + const { log, deps } = jawsWorld(110, -20); + await playOut(createPlayer(deps), jaws(90, 0)); + assert.deepEqual(log, [{ grip: "g", f0: 90 }]); +}); + +test("jaws that open let go before they move", async () => { + const { log, deps } = jawsWorld(90, 0); + await playOut(createPlayer(deps), jaws(110, -20)); + assert.deepEqual(log, [{ release: "g", f0: 90 }]); +}); + +const box = (x0, y0, z0, x1, y1, z1) => ({ + min: { x: x0, y: y0, z: z0 }, + max: { x: x1, y: y1, z: z1 }, +}); + +test("the jaws take the plate, not a well in it or the site under it", () => { + const candidates = [ + { index: 1, category: "plate_holder", box: box(0, 0, 100, 127, 86, 100) }, + { index: 2, category: "plate", box: box(0, 0, 97, 128, 85, 111) }, + { index: 3, category: "well", box: box(60, 40, 98, 67, 47, 111) }, + { index: 4, category: "plate_carrier", box: box(-5, -5, 0, 140, 500, 120) }, + ]; + assert.equal(heldAt({ x: 64, y: 43, z: 104 }, candidates), 2); + assert.equal(heldAt({ x: 300, y: 43, z: 104 }, candidates), undefined); +}); + +test("a plate let go of lands on the site under it", () => { + const plate = box(200, 0, 97, 328, 85, 111); + const sites = [ + { index: 1, category: "plate_holder", box: box(0, 0, 100, 127, 86, 100) }, + { index: 5, category: "plate_holder", box: box(200, 0, 100, 327, 86, 100) }, + { index: 6, category: "plate_holder", box: box(200, 0, 40, 327, 86, 40) }, + ]; + // The skirt sits 3 mm into the site: its surface stands above the plate's bottom. + assert.equal(siteUnder(plate, sites, { index: 9, category: "plate" }), 5); + // Carried away from any site, it has none. + assert.equal(siteUnder(box(600, 0, 97, 728, 85, 111), sites, { index: 9 }), undefined); +}); + +// A plate 14.2 mm tall on a site at z 100, seated 3 mm into it, and its lid, 8.9 mm tall, nesting +// 7.6 mm over it - the demo's Corning plate. +const site = { index: 1, category: "plate_holder", box: box(0, 0, 100, 127, 86, 100) }; +const plateOn = (index, covered = false) => ({ + index, + category: "plate", + covered, + box: box(0, 0, 97, 128, 85, 111.2), +}); +const lidAt = (z) => box(0, 0, z, 128, 85, z + 8.9); +const LID = { index: 7, category: "lid", nesting: 7.6 }; + +test("the jaws take the top lid of a nested stack, not the one under it", () => { + // Hamilton's ComfortLid: 8.5 tall, 6.7 apart in a stack. Gripped 5 below its top, the point is + // in the top lid and 1.7 above the one under it: within reach of both. + const stack = [0, 1, 2, 3, 4].map((i) => ({ + index: 10 + i, + category: "lid", + box: box(0, 0, 200 + 6.7 * i, 127.5, 85.3, 208.5 + 6.7 * i), + })); + const top = 200 + 6.7 * 4 + 8.5; + assert.equal(heldAt({ x: 64, y: 43, z: top - 5 }, stack), 14); + assert.equal(heldAt({ x: 64, y: 43, z: top - 5 }, [...stack].reverse()), 14); +}); + +test("the jaws take the lid at its height, and the plate below it", () => { + const candidates = [plateOn(2), { index: 7, category: "lid", box: lidAt(103.6) }]; + assert.equal(heldAt({ x: 64, y: 43, z: 109 }, candidates), 7); + assert.equal(heldAt({ x: 64, y: 43, z: 101 }, candidates), 2); +}); + +test("a lid let go of over a plate with none becomes that plate's lid", () => { + // Where it sits on the plate: the plate's top less the nesting. + assert.equal(siteUnder(lidAt(103.6), [site, plateOn(2)], LID), 2); +}); + +test("a lid is not put on a plate that has one, nor is a plate put on a plate", () => { + assert.equal(siteUnder(lidAt(103.6), [site, plateOn(2, true)], LID), 1); + assert.equal(siteUnder(box(0, 0, 111, 128, 85, 125), [plateOn(2)], { category: "plate" }), undefined); +}); + +test("a lid let go of on an empty site lands on the site", () => { + assert.equal(siteUnder(lidAt(100), [site], LID), 1); +}); + +test("a tip is handed over where it stands, then seated where the model gives it", async () => { + const world = fakeWorld(start()); + const deps = world.deps(); + const placed = []; + deps.attach = (name, parent, where) => placed.push({ name, parent, where }); + const location = { x: -0.6, y: -0.6, z: -2.05 }; + const request = { + ...pickUp, + attach: [{ name: "tip", parent: "shaft0", location, rotation: { x: 0, y: 0, z: 0 } }], + }; + await playOut(createPlayer(deps), request); + // No place given with the handover: it keeps where it is, and moves only as a seating. + assert.deepEqual(placed, [{ name: "tip", parent: "shaft0", where: undefined }]); + assert.deepEqual(world.at("tip"), [-0.6, -0.6, -2.05]); + const seating = world.log.filter((e) => e.name === "tip" && e.axis === 2).map((e) => e.value); + assert.ok(seating.length > 2, "the tip jumped to its seat instead of moving there"); +}); + +// -- the jerk-limited profile ----------------------------------------------------------------------- + +test("an S-curve takes as long as the fitted X-arm model says, short moves and long", () => { + // Reference durations from `tools/hxusbcomm_timing.py`'s `move_time` at the fitted X-arm limits. + const expected = { 1: 0.21522, 10: 0.463676, 100: 0.99896, 500: 1.709912 }; + for (const [d, t] of Object.entries(expected)) { + const { duration } = motionProfile(Number(d), 600, 1297, 3210); + assert.ok(Math.abs(duration - t) < 1e-4, `${d} mm: ${duration} s, expected ${t} s`); + } +}); + +test("an S-curve starts and ends at rest, climbs monotonically and is symmetric", () => { + for (const d of [1, 10, 100, 500]) { + const { duration, progress } = motionProfile(d, 600, 1297, 3210); + assert.equal(progress(0), 0); + assert.equal(progress(duration), 1); + let last = 0; + const n = 200; + for (let k = 1; k <= n; k++) { + const p = progress((duration * k) / n); + assert.ok(p >= last - 1e-12, `${d} mm goes backwards at step ${k}`); + last = p; + } + // Rest to rest, mirrored: as far in at a quarter as short of the end at three quarters. + assert.ok(Math.abs(progress(duration / 4) - (1 - progress((3 * duration) / 4))) < 1e-9); + // And it starts gently: jerk-limited, so the first hundredth covers far less than a hundredth. + assert.ok(progress(duration / 100) < 0.001); + } +}); + +test("without a jerk the profile is the simulator's trapezoid", () => { + const withNone = motionProfile(100, 250, 800); + const withInfinite = motionProfile(100, 250, 800, Number.POSITIVE_INFINITY); + assert.ok(Math.abs(withNone.duration - (100 / 250 + 250 / 800)) < 1e-9); + assert.equal(withInfinite.duration, withNone.duration); +}); diff --git a/pylabrobot/visualizer3D/ripple_demo.py b/pylabrobot/visualizer3D/ripple_demo.py new file mode 100644 index 00000000000..c91a4c339b0 --- /dev/null +++ b/pylabrobot/visualizer3D/ripple_demo.py @@ -0,0 +1,94 @@ +"""The Y ripple: one aspirate, five plates, the channels fanning out in Y. + +A1 of every plate on the source carrier is aspirated in one go - the channels start their Y moves +one after another (the ripple), each ending over a different plate. What was drawn is then +dispensed into the first column of the front-most plate on the destination carrier: the channels +close back up to the 9 mm pitch, dispensing down the column. + +Run it: + + python -m pylabrobot.visualizer3D.ripple_demo +""" + +import asyncio +import logging + +from pylabrobot.resources import set_volume_tracking +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.tip_rack import TipRack +from pylabrobot.resources.tip_tracking import set_tip_tracking + +from ..hamilton.star.motion import attach_viewer_motion +from .demo import build_facility, fill, star_of +from .server import Viewer3D + +ROWS = "ABCDE" # the carrier holds five plates, so five channels carry the aspirate + + +async def main() -> None: + logging.disable(logging.WARNING) + set_volume_tracking(True) + set_tip_tracking(True) + + facility = build_facility() + star = star_of(facility) + await star.setup() + pipettes = star.pipettes + if pipettes is None: + raise RuntimeError("the simulated STARlet has no channels") + + deck = star.deck + source_carrier = deck.get_resource("source_carrier") + destination_carrier = deck.get_resource("destination_carrier") + rack = deck.get_resource("tips_0") + if not isinstance(rack, TipRack): + raise RuntimeError("the demo deck no longer holds the tips this run uses") + + plates = [ + child for site in source_carrier.children for child in site.children if isinstance(child, Plate) + ] + # Channel 0 is the back-most channel, and per-channel Y runs back to front: the wells go to the + # channels in the same order, so every plate is hit at once, in a single command. + plates.sort(key=lambda p: p.get_absolute_location().y, reverse=True) + if len(plates) < 2: + raise RuntimeError("expected several plates on the source carrier") + for plate in plates: + fill(plate, 200.0) + + destinations = [ + child + for site in destination_carrier.children + for child in site.children + if isinstance(child, Plate) + ] + front = min(destinations, key=lambda p: p.get_absolute_location().y) + + viewer = Viewer3D(facility, name="ripple_demo.py", open_browser=False) + await viewer.start() + attach_viewer_motion(star.driver, viewer) + print("waiting for a browser to draw the scene") + await viewer.wait_for_browser() + await viewer.wait_for_models() + print("the page is up; the run starts in ten seconds") + await asyncio.sleep(10.0) + + print(f"picking up {len(ROWS)} tips, one per channel that will aspirate") + await pipettes.pick_up_tips([rack.get_item(f"{row}1") for row in ROWS]) + + wells = [plate.get_item("A1") for plate in plates] + print(f"aspirating A1 of {len(wells)} plates at once - the channels ripple out in Y") + await pipettes.aspirate(wells, [50.0] * len(wells)) + + column = [front.get_item(f"{row}1") for row in ROWS] + print(f"dispensing into the first column of {front.name}, the front-most destination plate") + await pipettes.dispense(column, [50.0] * len(column)) + + print("run complete; the viewer stays up") + await asyncio.Event().wait() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/pylabrobot/visualizer3D/server.py b/pylabrobot/visualizer3D/server.py index f4a23eca6c2..8c6fe4a9c19 100644 --- a/pylabrobot/visualizer3D/server.py +++ b/pylabrobot/visualizer3D/server.py @@ -152,6 +152,19 @@ def _signature(cleaned: Dict[str, Any], key: str) -> str: return key + repr([round(v, STATE_DECIMALS) for v in _xyz(cleaned["location"])]) +def _moved_names(request: Dict[str, Any]) -> Set[str]: + """Every resource a motion moves.""" + names: Set[str] = set() + if request.get("arm"): + names.add(request["arm"]["name"]) + for key in ("channels", "traverse", "moves", "turns"): + names.update(entry["name"] for entry in request.get(key) or []) + jaws = request.get("jaws") + if jaws: + names.update(finger["name"] for finger in jaws["fingers"]) + return names + + class Viewer3D: """A visualizer that takes any resource as its world. @@ -202,9 +215,12 @@ class Viewer3D: _scene: Optional[Scene] _refused_a_token: bool _browser_drawing: Optional[asyncio.Event] + _models_drawn: Optional[asyncio.Event] _moved: Set[str] _scene_timer: Optional[asyncio.TimerHandle] _subscribed: Dict[int, Tuple[Resource, Callable[[Dict[str, Any]], None]]] + _motions: Dict[int, Tuple["asyncio.Future[None]", Set[Any]]] + _motion_count: int # -- packing ----------------------------------------------------------------- @@ -429,11 +445,13 @@ def _scene_message(self, rebuild: bool = False) -> Dict[str, Any]: } return self._scene_payload - def _moves(self) -> Optional[List[Dict[str, Any]]]: + def _moves(self, hold: FrozenSet[str] = frozenset()) -> Optional[List[Dict[str, Any]]]: """Moves since the scene was built, applied to the kept scene too, or None if a name changed. A move is a resource whose parent or local transform differs from the kept scene, as `{name, - parent, location, rotation}`. A name appearing or disappearing needs a rebuild. + parent, location, rotation}`. A name appearing or disappearing needs a rebuild. A resource in + `hold` that has only moved, not changed parent, is left out and left unrecorded: a motion about + to be played takes it there, and the model has it there already. """ scene = self._scene if scene is None or frozenset(all_names(self.root)) != self._known_names: @@ -453,10 +471,10 @@ def walk(resource: Resource, parent: Optional[str]) -> None: float(rotation.z), ] parent_index = -1 if parent is None else self._index_of[parent] - if ( - parent_index != scene.parent_of_instance[index] - or local != scene.transforms[6 * index : 6 * index + 6] - ): + reparented = parent_index != scene.parent_of_instance[index] + if not reparented and resource.name in hold: + pass + elif reparented or local != scene.transforms[6 * index : 6 * index + 6]: scene.parent_of_instance[index] = parent_index scene.transforms[6 * index : 6 * index + 6] = local moves.append( @@ -484,7 +502,7 @@ async def _send_scene_to_all(self) -> None: self._published = {name: _signature(cleaned, key) for name, (cleaned, key) in states.items()} await self._broadcast("state", pack_state(states, self._epoch)) - async def _flush_scene(self) -> None: + async def _flush_scene(self, hold: FrozenSet[str] = frozenset()) -> None: self._scene_timer = None if not self._clients: # Nobody to tell. The kept scene is dropped, so the next client is greeted with one built @@ -492,7 +510,7 @@ async def _flush_scene(self) -> None: self._scene = None self._scene_payload = None return - moves = self._moves() + moves = self._moves(hold) if moves is None: self.rebuilds += 1 await self._send_scene_to_all() @@ -587,6 +605,18 @@ def _on_client_message(self, message: Any) -> bool: self._browser_drawing.set() return True + def _on_models_drawn(self, message: Any) -> bool: + """Whether `message` is a page saying every model file of its scene has loaded (or failed).""" + try: + parsed = json.loads(message) + except (TypeError, ValueError): + return False + if not isinstance(parsed, dict) or parsed.get("event") != "models_drawn": + return False + if self._models_drawn is not None: + self._models_drawn.set() + return True + async def _handler(self, websocket: ServerConnection) -> None: self._clients.add(websocket) await websocket.send(_encode("scene", self._scene_message())) @@ -594,6 +624,10 @@ async def _handler(self, websocket: ServerConnection) -> None: greeted = False try: async for message in websocket: + if self._on_motion_played(websocket, message): + continue + if self._on_models_drawn(message): + continue # A page says hello once. A repeat is still read, or keepalive stalls, but not kept. if not greeted: greeted = self._on_client_message(message) @@ -601,6 +635,107 @@ async def _handler(self, websocket: ServerConnection) -> None: pass finally: self._clients.discard(websocket) + # A page that has gone plays nothing more, so nothing waits for it. + self._release_motions(websocket) + + # -- motion ------------------------------------------------------------------ + + # How long a command waits for a page to play its motion before going on without it. + MOTION_TIMEOUT_S = 120.0 + + async def act_out(self, command: str, motion: Optional[Dict[str, Any]]) -> None: + """Act out one command's motion, and hold the command until a page has played it. + + The motion is sent to the pages and waited on until every one of them says it has played it: + the slowest page sets the pace, and a page in a background tab, which gets no frames, jumps to + the end and answers at once. The model then records where the command ended, which is where + the pages have just brought everything. With no page connected, nothing is waited for. + + A motion of None is a command that moves nothing: it is said to the page, so whatever it moves + can be put down to it, and the page jumps. + + Args: + command: the command as the device names it, for a page that lists them. + motion: what the command moves, read out of it by the device's own motion model, or None. + """ + if not self._clients or self._loop is None or asyncio.get_running_loop() is not self._loop: + return + if motion is None: + await self._broadcast("command", {"command": command}) + return + request = motion + # A change is handed to the loop to be queued, so let what the last command changed reach the + # page first: a motion starts from where the page has everything. + await asyncio.sleep(0) + # Except where this very command has already been written: the iSWAP records a move's target as + # it sends it, and so does a channel packing (`recorded_first`). Sent now, that would put the + # part at the end of the move before it is played, so it is held until the pages have played the + # move there. Taken before anything else can flush. Only for those: most commands write the + # model once they have run, so what is pending is the last command's result, and holding that + # back would send it after this one's move - drawn back where it was, until the model's own + # move puts it right. + moving = _moved_names(request) if request.get("recorded_first") else set() + held = {name: self._pending.pop(name) for name in list(self._pending) if name in moving} + # A change of shape - a tip taken onto a shaft - waits out its debounce, and holds the + # positions back with it; the command that caused it is over, so it goes now. + if self._scene_timer is not None: + self._scene_timer.cancel() + # What this command already wrote is held back here too: a change of shape carries every + # position that differs from the kept scene, the move's own target among them. + await self._flush_scene(frozenset(moving)) + if self._pending: + await self._flush() + self._motion_count += 1 + motion_id = self._motion_count + played: "asyncio.Future[None]" = self._loop.create_future() + # Registered before sending: a page in the background answers as soon as it is told. + pages = set(self._clients) + self._motions[motion_id] = (played, pages) + try: + await self._broadcast("motion", {"id": motion_id, **request}) + # A page that could not be sent to has been dropped, and will not answer. One that closed while + # this was being sent - a reload - has already let the motion go. + pages.intersection_update(self._clients) + if not pages and not played.done(): + played.set_result(None) + await asyncio.wait_for(asyncio.shield(played), self.MOTION_TIMEOUT_S) + except asyncio.TimeoutError: + print(f"viewer: no page played {command} within {self.MOTION_TIMEOUT_S} s; going on") + finally: + self._motions.pop(motion_id, None) + if held: + # Anything newer that arrived meanwhile has the last word. + for name, state in held.items(): + self._pending.setdefault(name, state) + await self._flush() + + def _on_motion_played(self, websocket: Any, message: Any) -> bool: + """Take a page's word that it has played a motion; whether the message was that.""" + try: + parsed = json.loads(message) + except (TypeError, ValueError): + return False + if not isinstance(parsed, dict) or parsed.get("event") != "motion_done": + return False + data = parsed.get("data") + motion_id = data.get("id") if isinstance(data, dict) else None + waiting = self._motions.get(motion_id) if isinstance(motion_id, int) else None + if waiting is not None: + played, pages = waiting + pages.discard(websocket) + if not pages and not played.done(): + played.set_result(None) + return True + + def _release_motions(self, websocket: Any = None) -> None: + """Stop waiting on one page, or on every page when none is named.""" + for played, pages in self._motions.values(): + if websocket is None: + pages.clear() + else: + pages.discard(websocket) + if not pages and not played.done(): + played.set_result(None) # -- static files ------------------------------------------------------------ @@ -685,8 +820,9 @@ class Server(http.server.ThreadingHTTPServer): def handle_error(self, request: Any, client_address: Any) -> None: # A browser that leaves a page mid-download, or stalls past the timeout, is no error of - # ours, and a traceback on every reload buries anything that is. - if isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError, socket.timeout)): + # ours, and a traceback on every reload buries anything that is. Every flavour of a + # vanished peer - broken pipe, reset, abort (Windows) - is a ConnectionError. + if isinstance(sys.exc_info()[1], (ConnectionError, socket.timeout)): return super().handle_error(request, client_address) @@ -773,6 +909,10 @@ def __init__( self.rebuilds = 0 # how many scene rebuilds a run actually cost self.clients_seen = [] # what each page said it draws with self._browser_drawing = None # made on the loop `start` runs on + self._models_drawn = None # likewise + # Motions sent to the pages and not yet played, by id, with the pages still playing each. + self._motions = {} + self._motion_count = 0 # Every resource this viewer listens to, with the callback it gave, so `stop` can take it back. # By identity: a tip compares by value and is not hashable. @@ -785,6 +925,7 @@ def __init__( async def start(self) -> None: self._loop = asyncio.get_running_loop() self._browser_drawing = asyncio.Event() + self._models_drawn = asyncio.Event() # Off the loop, before a client can connect: the package walk for model files, and the size of # the tree as one node per resource, which the stats panel compares against. self._legacy_bytes, _ = await asyncio.gather( @@ -826,11 +967,32 @@ async def wait_for_browser(self, timeout: Optional[float] = None) -> None: raise RuntimeError("start the viewer before waiting for a browser") await asyncio.wait_for(self._browser_drawing.wait(), timeout) + async def wait_for_models(self, timeout: Optional[float] = None) -> None: + """Wait until a page has every model file of the scene on screen, or has given up on it. + + `wait_for_browser` returns as soon as a page is drawing - boxes, until the files land. Call this + too before a run whose first moves should be seen on the models themselves. A page says so once, + when it has loaded; each call takes one saying, so a later call waits for the next page - a + refresh - and a run can be played again for it. + + Args: + timeout: seconds to wait, or None to wait for as long as it takes. + + Raises: + RuntimeError: the viewer has not been started. + asyncio.TimeoutError: no page had its models drawn within `timeout`. + """ + if self._models_drawn is None: + raise RuntimeError("start the viewer before waiting for its models") + await asyncio.wait_for(self._models_drawn.wait(), timeout) + self._models_drawn.clear() + async def stop(self) -> None: """Close both servers and stop listening to the tree. The ports are free for the next viewer, and no change is handed to a loop that is gone. """ + self._release_motions() self._unsubscribe(self.root) self.root.deregister_did_assign_resource_callback(self._on_assign) self.root.deregister_did_unassign_resource_callback(self._on_unassign) diff --git a/pylabrobot/visualizer3D/static/app.js b/pylabrobot/visualizer3D/static/app.js index 1c9f45c2b0f..ef4ef72e68f 100644 --- a/pylabrobot/visualizer3D/static/app.js +++ b/pylabrobot/visualizer3D/static/app.js @@ -44,6 +44,7 @@ import { updateOrigin, } from "./marks.js"; import { buildDeclaredMeshes, dracoLoader, gltfLoader } from "./models.js"; +import { dropMotions, playMotion, stepMotion } from "./motion.js"; import { clearSelection, hoverBox, @@ -84,7 +85,7 @@ import { updateBullseyes, updateDeltaLabels, } from "./tools.js"; -import { connect, initTransport } from "./transport.js"; +import { connect, initTransport, send } from "./transport.js"; import { buildTree, refreshTreeInfo, @@ -435,6 +436,8 @@ function rebuildScene(data) { const kept = rememberView(); setWorld(buildWorld(data)); glides.clear(); + // A motion under way moves resources by where they stood in the last scene. + dropMotions(); timings.decodeMs = performance.now() - _tScene; const _tBuild = performance.now(); forgetDetail(); @@ -511,6 +514,8 @@ whileMoving(updateArms); whileMoving(updateGlides); +whileMoving(stepMotion); + whileMoving(() => controls.update()); whileMoving(() => gif.isRecording()); @@ -582,6 +587,10 @@ initTransport({ moves: (moves) => { if (world && Array.isArray(moves)) applyMoves(moves); }, + // A command the device is carrying out: acted out, and the server told when it has been. + motion: (data) => { + playMotion(data, () => send("motion_done", { id: data.id })); + }, }, }); diff --git a/pylabrobot/visualizer3D/static/carry.js b/pylabrobot/visualizer3D/static/carry.js new file mode 100644 index 00000000000..1c53b971061 --- /dev/null +++ b/pylabrobot/visualizer3D/static/carry.js @@ -0,0 +1,120 @@ +// What a gripper takes hold of when its jaws close, and what a plate it lets go of comes to rest on. +// +// PyLabRobot does not move a plate when the iSWAP grips it, so the page does: the plate is handed to +// the gripper when the jaws close on it, rides the arm, and is handed to the site under it when they +// open. A lid is labware like any other, except that what takes it can be a plate: set down on one +// with no lid, it becomes that plate's lid, as `Plate.assign_child_resource` makes it. Pure - it is +// given boxes, not the scene - so it can be checked outside a page. + +// What an arm picks up: labware, not what labware holds or what holds it. +export const MOVABLE = new Set([ + "plate", + "lid", + "tip_rack", + "tube_rack", + "plate_adapter", + "trough", +]); + +// What a plate is set down on. +export const SITES = new Set(["resource_holder", "plate_holder", "plate_adapter"]); + +// What a lid is set down on besides a site: a plate that has none, its bottom `nesting_z_height` +// below the plate's top. +export const LIDDABLE = new Set(["plate"]); + +// How far a point may lie outside a box and still be taken as in it, in mm. +const REACH = 2; +// How far above a site a plate may be let go of and still land on it, in mm. +const DROP = 30; +// How far a site's surface may stand above the plate's bottom: a plate's skirt sits down into its +// site, 3 mm on the demo's carriers. +const SUNK = 10; + +/** + * @typedef {object} Candidate + * @property {number} index + * @property {string} category + * @property {{min: {x: number, y: number, z: number}, max: {x: number, y: number, z: number}}} box + * @property {boolean} [covered] a plate that already has a lid + */ + +const inside = (p, box, pad) => + p.x >= box.min.x - pad && + p.x <= box.max.x + pad && + p.y >= box.min.y - pad && + p.y <= box.max.y + pad && + p.z >= box.min.z - pad && + p.z <= box.max.z + pad; + +const volume = (box) => (box.max.x - box.min.x) * (box.max.y - box.min.y) * (box.max.z - box.min.z); + +// How far a point lies outside a box, in mm: 0 inside it. +const outside = (p, box) => + Math.hypot( + Math.max(box.min.x - p.x, 0, p.x - box.max.x), + Math.max(box.min.y - p.y, 0, p.y - box.max.y), + Math.max(box.min.z - p.z, 0, p.z - box.max.z), + ); + +/** + * The labware at the point the jaws close on: of the movable things whose box holds it (within + * `REACH`), the one it is least outside of, then the smallest. A lid nested on another lid is + * gripped in its own box and within reach of the one below; the one it is in is the one taken. + * + * @param {{x: number, y: number, z: number}} point in world mm + * @param {Candidate[]} candidates + * @returns {number | undefined} + */ +export function heldAt(point, candidates) { + let best; + let rank = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]; + for (const c of candidates) { + if (!MOVABLE.has(c.category) || !inside(point, c.box, REACH)) continue; + const r = [outside(point, c.box), volume(c.box)]; + if (r[0] < rank[0] - 1e-9 || (Math.abs(r[0] - rank[0]) <= 1e-9 && r[1] < rank[1])) { + rank = r; + best = c.index; + } + } + return best; +} + +/** + * What something let go of lands on: the highest seat under its middle that is near its bottom - a + * little above it, as a skirt sits down into a site, or a drop's height below it. A site's seat is + * its surface; for a lid, a plate with no lid is a seat too, `nesting` below the plate's top. + * + * @param {{min: {x: number, y: number, z: number}, max: {x: number, y: number, z: number}}} box + * what was let go of, in world mm + * @param {Candidate[]} candidates + * @param {{index?: number, category?: string, nesting?: number}} [released] what was let go of: + * not a seat for itself, and a lid when its category says so + * @returns {number | undefined} + */ +export function siteUnder(box, candidates, released = {}) { + const lid = released.category === "lid"; + const middle = { x: (box.min.x + box.max.x) / 2, y: (box.min.y + box.max.y) / 2 }; + let best; + let highest = Number.NEGATIVE_INFINITY; + for (const c of candidates) { + if (c.index === released.index) continue; + const b = c.box; + let seat; + if (SITES.has(c.category)) seat = b.max.z; + else if (lid && LIDDABLE.has(c.category) && !c.covered) + seat = b.max.z - (released.nesting ?? 0); + else continue; + const under = + middle.x >= b.min.x - REACH && + middle.x <= b.max.x + REACH && + middle.y >= b.min.y - REACH && + middle.y <= b.max.y + REACH; + if (!under || seat > box.min.z + SUNK || seat < box.min.z - DROP) continue; + if (seat > highest) { + highest = seat; + best = c.index; + } + } + return best; +} diff --git a/pylabrobot/visualizer3D/static/live.js b/pylabrobot/visualizer3D/static/live.js index acd17aaa1ce..c4bacea49f4 100644 --- a/pylabrobot/visualizer3D/static/live.js +++ b/pylabrobot/visualizer3D/static/live.js @@ -4,7 +4,7 @@ import * as THREE from "three"; import { placeEdges, showEdges } from "./boxes.js"; -import { LIQUID, MOVING_PARTS, VESSEL_EMPTY } from "./constants.js"; +import { DEG, LIQUID, MOVING_PARTS, VESSEL_EMPTY } from "./constants.js"; import { hiddenNames, isVisible, @@ -18,6 +18,7 @@ import { } from "./drawn.js"; import { armPose, arms, gridMarks, referenceMarks, refreshHalos } from "./marks.js"; import { applyJoints } from "./models.js"; +import { turnedLocation } from "./motion_player.js"; import { mirrorPlacement, modelOf, @@ -110,29 +111,74 @@ export function updateArms(delta) { reduce || !glideSeconds ? arm.targetX : arm.currentX + (arm.targetX - arm.currentX) * Math.min(1, delta / glideSeconds); - const local = new THREE.Matrix4().makeTranslation(arm.currentX, arm.local[1], arm.local[2]); - arm.group.matrix.multiplyMatrices(arm.parentMatrix, local); - arm.group.matrixWorldNeedsUpdate = true; - - // Keep the scene model in step with what is drawn. Everything else reads position from here - - // the info panel, the selection box, the coordinate tool - so moving only the group would - // leave all of them quoting where the arm used to be. - mirrorPlacement(arm.index, arm.currentX, arm.group.matrix); - announce({ kind: "glide", index: arm.index }); - // What the arm itself is drawn from. Its frame and outline ride the group and have moved - // already, but everything else the arm owns is a separate object with a baked matrix - its - // declared model above all, which hangs off the view rather than off the group - and the line - // below deliberately skips the arm while it works out the subtree beneath it. Without this a - // part drawn from a file stays where it was loaded while the arm travels out from under it, - // which is what a model-drawn X-arm did: the reference line moved and the geometry did not. - redraw([arm.index]); - // Whatever rides the arm moves with it. Its own matrix is already set from the group, so only - // what is beneath it needs working out. - refreshSubtree(arm.index, true); + placeArm(arm); } return moved; } +// Draw an arm at its `currentX`, and everything it carries with it. +function placeArm(arm) { + const local = new THREE.Matrix4().makeTranslation(arm.currentX, arm.local[1], arm.local[2]); + arm.group.matrix.multiplyMatrices(arm.parentMatrix, local); + arm.group.matrixWorldNeedsUpdate = true; + + // Keep the scene model in step with what is drawn. Everything else reads position from here - + // the info panel, the selection box, the coordinate tool - so moving only the group would + // leave all of them quoting where the arm used to be. + mirrorPlacement(arm.index, arm.currentX, arm.group.matrix); + announce({ kind: "glide", index: arm.index }); + // What the arm itself is drawn from. Its frame and outline ride the group and have moved + // already, but everything else the arm owns is a separate object with a baked matrix - its + // declared model above all, which hangs off the view rather than off the group - and the line + // below deliberately skips the arm while it works out the subtree beneath it. Without this a + // part drawn from a file stays where it was loaded while the arm travels out from under it, + // which is what a model-drawn X-arm did: the reference line moved and the geometry did not. + redraw([arm.index]); + // Whatever rides the arm moves with it. Its own matrix is already set from the group, so only + // what is beneath it needs working out. + refreshSubtree(arm.index, true); +} + +const AXES = ["x", "y", "z"]; + +/** + * Where a resource is drawn along one axis, relative to its parent, in mm. + * + * @param {number} index + * @param {number} axis 0, 1 or 2 for x, y or z + */ +export function readAxis(index, axis) { + const arm = axis === 0 ? arms.find((a) => a.index === index) : undefined; + return arm ? arm.currentX : world.local[index * 6 + axis]; +} + +/** + * Draw a resource at `value` along one axis, and everything it carries with it. What a motion + * moves the drives through: an arm travels in its own group, anything else by its transform. + * + * @param {number} index + * @param {number} axis 0, 1 or 2 for x, y or z + * @param {number} value in mm, relative to its parent + */ +export function setAxis(index, axis, value) { + const arm = axis === 0 ? arms.find((a) => a.index === index) : undefined; + if (arm) { + arm.currentX = value; + arm.targetX = value; + placeArm(arm); + return; + } + // A motion is where this resource is drawn from now on; an ease toward an older target is over. + glides.delete(index); + const o = index * 6; + const location = { x: world.local[o], y: world.local[o + 1], z: world.local[o + 2] }; + location[AXES[axis]] = value; + if (setLocal(index, location)) { + refreshSubtree(index); + announce({ kind: "glide", index }); + } +} + // What each gliding resource is easing toward, by index. A second move replaces the first: the // model is already at the new target and only the drawing is behind. export const glides = new Map(); @@ -355,3 +401,66 @@ export function applyMoves(moves) { refreshHalos(); announce({ kind: "moves", rowsUnder }); } + +/** + * Hand a resource to a new holder where it stands: a tip taken onto a shaft at the bottom of a + * pick-up, or left in its spot at the bottom of a drop. Drawn now, as the device does it; the model + * records the same change once the command has run, and that `moves` message then finds it done. + * + * @param {string} name + * @param {string | null} parentName the new holder, or null to leave it standing where it is + * @param {{location: any, rotation?: any}} [placed] where the new holder has it, relative to itself, + * when the model says: a tip on a shaft sits at its fitting depth, not where the shaft met it + */ +export function reattach(name, parentName, placed) { + const index = world.indexOfName.get(name); + if (index === undefined) return; + const parent = parentName === null ? -1 : (world.indexOfName.get(parentName) ?? -1); + if (placed && parent >= 0) { + applyMoves([ + { + name, + parent: world.names[parent], + location: placed.location, + rotation: placed.rotation ?? { x: 0, y: 0, z: 0 }, + }, + ]); + return; + } + if (parent === world.parentOf[index]) return; + const local = parent >= 0 ? world.matrices[parent].clone().invert() : new THREE.Matrix4(); + local.multiply(world.matrices[index]); + const position = new THREE.Vector3(); + const turn = new THREE.Quaternion(); + local.decompose(position, turn, new THREE.Vector3()); + const euler = new THREE.Euler().setFromQuaternion(turn, "XYZ"); + applyMoves([ + { + name, + parent: parent >= 0 ? world.names[parent] : null, + location: { x: position.x, y: position.y, z: position.z }, + rotation: { x: euler.x / DEG, y: euler.y / DEG, z: euler.z / DEG }, + }, + ]); +} + +/** + * Turn a resource about Z to `degrees`, relative to its parent, keeping `pivot` - a point in its own + * frame - where it is. What `Resource.rotate_to(z=..., pivot_coordinate=...)` does to it, one frame + * of a turn at a time. A joint turns only about Z; the other angles are left as they are. + * + * @param {number} index + * @param {number} degrees + * @param {{x: number, y: number, z: number}} pivot + */ +export function turnTo(index, degrees, pivot) { + const o = index * 6; + const local = world.local; + const here = { x: local[o], y: local[o + 1], z: local[o + 2] }; + const moved = turnedLocation(here, local[o + 5], degrees, pivot); + setLocalRotation(index, { x: local[o + 3], y: local[o + 4], z: degrees }); + setLocal(index, moved); + glides.delete(index); + refreshSubtree(index); + announce({ kind: "glide", index }); +} diff --git a/pylabrobot/visualizer3D/static/models.js b/pylabrobot/visualizer3D/static/models.js index 85c5f77e657..8be649058f3 100644 --- a/pylabrobot/visualizer3D/static/models.js +++ b/pylabrobot/visualizer3D/static/models.js @@ -21,6 +21,7 @@ import { travels, } from "./drawn.js"; import { view } from "./renderer.js"; +import { send } from "./transport.js"; import { modelOf, world } from "./world.js"; // Geometry a resource declared for itself, drawn in place of its box. @@ -41,6 +42,19 @@ export const dracoLoader = new DRACOLoader(); // Counted up per rebuild, so a file that lands late is placed only by the scene that asked for it. let sceneGeneration = 0; +// The server is told once per connection, when the first scene it sends has every model file on +// screen: a run waiting for a page (a refresh, or a page whose socket dropped and came back) starts +// on that, and not on the rebuilds its own moves cause. +let toldDrawn = false; +function tellDrawn() { + if (toldDrawn) return; + toldDrawn = true; + send("models_drawn", {}); +} +window.addEventListener("plr:status", (event) => { + if (/** @type {CustomEvent} */ (event).detail?.connected) toldDrawn = false; +}); + // Every file that has been fetched and parsed, by url. A tree that changes shape sends a whole // scene, and an instanced mesh cannot be resized - so the meshes are built again, from this, // without going back to the network. @@ -225,6 +239,14 @@ export function buildDeclaredMeshes() { } for (const modelIndex of kept) modelIsDrawn(modelIndex); + // Files still being fetched for this scene. The server is told once none are, so a run can wait + // for the page to have every model on screen before it starts to move anything. + let loading = 0; + const loadingGeneration = sceneGeneration; + const settled = () => { + if (--loading === 0 && loadingGeneration === sceneGeneration) tellDrawn(); + }; + for (const [modelIndex, instances] of byModel) { const declared = world.models[modelIndex].mesh; const scale = MESH_UNITS[declared.units] ?? 1; @@ -310,10 +332,24 @@ export function buildDeclaredMeshes() { place(parsed); continue; } - gltfLoader.load(declared.url, place, undefined, (error) => - console.warn(`could not load the mesh declared by ${world.names[instances[0]]}`, error), + loading++; + gltfLoader.load( + declared.url, + (gltf) => { + try { + place(gltf); + } finally { + settled(); + } + }, + undefined, + (error) => { + console.warn(`could not load the mesh declared by ${world.names[instances[0]]}`, error); + settled(); + }, ); } + if (loading === 0) tellDrawn(); // Whatever is left was drawn for a set of resources this scene does not have. Let go here, so a // file that lands later finds nothing to reuse and builds its meshes afresh. for (const built of instanced.values()) { diff --git a/pylabrobot/visualizer3D/static/motion.js b/pylabrobot/visualizer3D/static/motion.js new file mode 100644 index 00000000000..bac1b729822 --- /dev/null +++ b/pylabrobot/visualizer3D/static/motion.js @@ -0,0 +1,115 @@ +// Acting out what a device's drives do between the positions PyLabRobot records. +// +// PyLabRobot sends a device a command and records where it ends up; the device's own motion +// controller decides how to get there. The server reads each command for its targets (see +// `motion.py`) and sends them here as a motion, which `motion_player.js` plays; the server is told +// when it is done. Python holds the command until then, so the model moves only once the picture +// has got there. +// +// One thing is the page's alone: a plate the iSWAP grips. PyLabRobot does not move it, so the page +// hands it to the gripper when the jaws close on it and to the site under it when they open +// (`carry.js`). A new scene puts it back where PyLabRobot has it. + +import * as THREE from "three"; + +import { heldAt, LIDDABLE, MOVABLE, SITES, siteUnder } from "./carry.js"; +import { worldBox } from "./drawn.js"; +import { readAxis, reattach, setAxis, turnTo } from "./live.js"; +import { createPlayer } from "./motion_player.js"; +import { modelOf, world } from "./world.js"; + +// What each gripper holds, by name, as the page has it. +const held = new Map(); + +// Every resource of the given categories, with the box it takes up in the world. +function candidates(categories) { + const found = []; + for (let index = 0; index < world.names.length; index++) { + const category = modelOf(index).category; + if (categories.has(category)) found.push({ index, category, box: worldBox(index) }); + } + return found; +} + +function grip(gripper, point) { + const index = world.indexOfName.get(gripper); + if (index === undefined || held.has(gripper)) return; + const at = new THREE.Vector3(point.x, point.y, point.z).applyMatrix4(world.matrices[index]); + const taken = heldAt(at, candidates(MOVABLE)); + if (taken === undefined) return; + reattach(world.names[taken], gripper); + held.set(gripper, world.names[taken]); +} + +function release(gripper) { + const name = held.get(gripper); + held.delete(gripper); + const index = name === undefined ? undefined : world.indexOfName.get(name); + if (index === undefined) return; + const model = modelOf(index); + const lid = model.category === "lid"; + const seats = candidates(lid ? new Set([...SITES, ...LIDDABLE]) : SITES).map((c) => + LIDDABLE.has(c.category) + ? { + ...c, + covered: world.childrenOf[c.index].some( + (child) => child !== index && modelOf(child).category === "lid", + ), + } + : c, + ); + const site = siteUnder(worldBox(index), seats, { + index, + category: model.category, + nesting: model.nesting_z_height, + }); + // Nothing under it: it stays where it was let go of. + reattach(name, site === undefined ? null : world.names[site]); +} + +const player = createPlayer({ + readAxis, + setAxis, + indexOf: (name) => world?.indexOfName.get(name), + attach: reattach, + turnTo, + grip, + release, + // A tab in the background gets no frames, so nothing it played would ever finish and the + // command would wait out its timeout. It jumps instead. + skipping: () => document.hidden, +}); + +// How fast motions play against the drives' own speeds: `?motion=2` twice as fast, `?motion=0` +// not at all - everything jumps to where it ends and the command goes straight on. +const asked = Number(new URLSearchParams(location.search).get("motion") ?? 1); +player.setSpeed(Number.isFinite(asked) && asked >= 0 ? asked : 1); + +export const stepMotion = (delta) => player.step(delta); + +/** A new scene: nothing moving carries on, and nothing is held that PyLabRobot does not have. */ +export function dropMotions() { + player.dropAll(); + held.clear(); +} + +document.addEventListener("visibilitychange", () => { + if (document.hidden) player.finishAll(); +}); + +/** + * Play a motion the server sent, then say so. The command waits for that, so it is said whatever + * happens: a motion that cannot be played is reported played at once. + * + * @param {any} request what `motion.py` read from the command + * @param {() => void} done tells the server + */ +export async function playMotion(request, done) { + try { + if (world) await player.play(request); + } catch (error) { + console.warn("a motion could not be played", error); + } finally { + done(); + } +} diff --git a/pylabrobot/visualizer3D/static/motion_player.js b/pylabrobot/visualizer3D/static/motion_player.js new file mode 100644 index 00000000000..a907f546e9c --- /dev/null +++ b/pylabrobot/visualizer3D/static/motion_player.js @@ -0,0 +1,373 @@ +// Plays a motion the server read from a device command: the drives' moves, in the order the device +// makes them, each following its drive's speed profile. Pure - it is handed how to read and set +// positions and how to move a tip - so it can be checked outside a page. +// +// First the command's fixed time: what the device takes beyond its motion, measured from its own +// command timings, spent before anything moves. Then the stroke the simulator records a tip +// command as: across, with the arm and the channels moving at once, the channels setting off one +// after another (the ripple); down onto the targets, a tip pick-up pressing the last stretch +// slowly; back up. Before it, anything below the traverse height rises to it; at the bottom, tips +// change hands and an aspiration dwells, then leaves the liquid at its own swap speed. A command +// that only moves one axis plays that one move. +// +// An iSWAP command moves its drives at once: the head along Y or Z, the two joints turning about +// their pivots, or the jaws. Jaws that close take hold of what is between them; jaws that open let +// go of it first. + +import { motionProfile } from "./motion_profile.js"; + +// A move shorter than this, in mm, is not made at all. +const STILL = 0.05; + +// Where the crossing is among the phases: after the fixed time and the rise to traverse height. +const ACROSS = 2; + +/** + * Where a resource sits once turned about Z from `from` to `to` degrees, keeping `pivot` - a point + * in its own frame - where it was: what `Resource.rotate_to(z=..., pivot_coordinate=...)` does. + * + * @param {{x: number, y: number, z: number}} location relative to its parent, before the turn + * @param {number} from degrees + * @param {number} to degrees + * @param {{x: number, y: number, z: number}} pivot + * @returns {{x: number, y: number, z: number}} + */ +export function turnedLocation(location, from, to, pivot) { + const a = (from * Math.PI) / 180; + const b = (to * Math.PI) / 180; + // Where the pivot is in the parent's frame, which the turn keeps. + const px = location.x + Math.cos(a) * pivot.x - Math.sin(a) * pivot.y; + const py = location.y + Math.sin(a) * pivot.x + Math.cos(a) * pivot.y; + return { + x: px - (Math.cos(b) * pivot.x - Math.sin(b) * pivot.y), + y: py - (Math.sin(b) * pivot.x + Math.cos(b) * pivot.y), + z: location.z, + }; +} + +/** + * @param {object} deps + * @param {(index: number, axis: number) => number} deps.readAxis where a resource is, per axis + * @param {(index: number, axis: number, value: number) => void} deps.setAxis put it somewhere + * @param {(name: string) => number | undefined} deps.indexOf a resource by name + * @param {(name: string, parent: string | null, placed?: any) => void} deps.attach hand a tip + * to a new holder, at `placed` ({location, rotation}) when given, else where it stands + * @param {() => boolean} deps.skipping whether to jump to the end rather than play + * @param {(index: number, degrees: number, pivot: any) => void} [deps.turnTo] turn about a pivot + * @param {(gripper: string, point: any) => void} [deps.grip] take hold of what is at `point` + * @param {(gripper: string) => void} [deps.release] let go of what the gripper holds + */ +export function createPlayer({ + readAxis, + setAxis, + indexOf, + attach, + skipping, + turnTo = () => {}, + grip = () => {}, + release = () => {}, +}) { + let speed = 1; + // What is moving now: one entry per axis of a resource or joint, or a pause (no `apply`). + const running = new Set(); + // Motions being played, which keep the frame loop going between one move ending and the next + // starting. + let playing = 0; + + function finish(entry) { + entry.apply?.(entry.to); + running.delete(entry); + entry.resolve(); + } + + // Take a value from `from` to `to` as `drive` would, handing each step to `apply`. + function tween(from, to, drive, apply) { + /** @type {Promise} */ + const moved = new Promise((resolve) => { + const profile = motionProfile(to - from, drive?.speed, drive?.acceleration, drive?.jerk); + const entry = { from, to, profile, t: 0, resolve, apply }; + if (skipping() || !(speed > 0) || profile.duration === 0) finish(entry); + else running.add(entry); + }); + return moved; + } + + // Move one axis of a resource to `to`, as `drive` would. + function move(index, axis, to, drive) { + return tween(readAxis(index, axis), to, drive, (v) => setAxis(index, axis, v)); + } + + // Turn a joint to the angle its drive is sent to. The resource's rotation is that angle less the + // joint's `base`, and the drive goes the way its own angles run, not the short way round. + function turn(index, joint) { + const from = ((((readAxis(index, 5) + joint.base + 180) % 360) + 360) % 360) - 180; + return tween(from, joint.drive, joint, (angle) => + turnTo(index, angle - joint.base, joint.pivot), + ); + } + + function pause(seconds) { + /** @type {Promise} */ + const paused = new Promise((resolve) => { + if (skipping() || !(speed > 0) || !(seconds > 0)) { + resolve(); + return; + } + running.add({ profile: { duration: seconds }, t: 0, resolve }); + }); + return paused; + } + + // The phases of a motion, in order. Each looks at where things are when its turn comes and + // returns the moves it starts together; an empty one is skipped. + function phases(request, timing = { across: 0 }) { + const drives = request.drives ?? {}; + const channels = (key) => + (request.channels ?? []) + .filter((c) => c[key] !== null && c[key] !== undefined) + .map((c) => ({ ...c, index: indexOf(c.name) })) + .filter((c) => c.index !== undefined); + + return [ + // What the command takes beyond its motion, before anything moves. + () => (request.fixed > 0 ? [() => pause(request.fixed)] : []), + + // Up to traverse height: whatever is lower rises, whatever is higher stays. + () => + (request.traverse ?? []) + .map((t) => ({ ...t, index: indexOf(t.name) })) + .filter((t) => t.index !== undefined && readAxis(t.index, 2) < t.z - STILL) + .map((t) => () => move(t.index, 2, t.z, drives.z)), + + // Across: the arm in X and the channels in Y, at once. + () => { + const moves = []; + const arm = request.arm; + const armIndex = arm ? indexOf(arm.name) : undefined; + // How long the crossing takes, for a descent that sets off before it has finished. + timing.across = 0; + if (armIndex !== undefined && Math.abs(readAxis(armIndex, 0) - arm.x) >= STILL) { + const dx = arm.x - readAxis(armIndex, 0); + timing.across = motionProfile( + dx, + drives.x?.speed, + drives.x?.acceleration, + drives.x?.jerk, + ).duration; + moves.push(() => move(armIndex, 0, arm.x, drives.x)); + } + // The channels set off one after another, each `stagger` after the last. Who goes first + // depends on where they are going: the channel the move puts ahead of the others leaves + // first, and the ripple runs back along the direction of travel - a channel starting + // before the one in front of it has moved would run into the back of it. + const stagger = drives.y?.stagger > 0 ? drives.y.stagger : 0; + const moving = channels("y").filter((c) => Math.abs(readAxis(c.index, 1) - c.y) >= STILL); + const rankOf = new Map(); + for (const towardBack of [false, true]) { + moving + .filter((c) => c.y - readAxis(c.index, 1) > 0 === towardBack) + .sort((a, b) => (towardBack ? b.y - a.y : a.y - b.y)) + .forEach((c, rank) => { + rankOf.set(c, rank); + }); + } + moving.forEach((c) => { + const rank = rankOf.get(c); + const dy = c.y - readAxis(c.index, 1); + const travel = motionProfile(dy, drives.y?.speed, drives.y?.acceleration).duration; + timing.across = Math.max(timing.across, rank * stagger + travel); + moves.push(async () => { + if (rank > 0 && stagger > 0) await pause(rank * stagger); + await move(c.index, 1, c.y, drives.y); + }); + }); + return moves; + }, + + // Down onto the targets. + () => + channels("down") + .filter((c) => Math.abs(readAxis(c.index, 2) - c.down) >= STILL) + .map((c) => () => move(c.index, 2, c.down, drives.z)), + + // The last stretch of a tip pick-up, pressed on slowly. + () => + channels("press") + .filter((c) => Math.abs(readAxis(c.index, 2) - c.press) >= STILL) + .map( + (c) => () => + move(c.index, 2, c.press, { + speed: c.press_speed, + acceleration: drives.z?.acceleration, + }), + ), + + // At the bottom: tips change hands, and whatever the command does there takes its time. + () => { + const handovers = request.attach ?? []; + if (!handovers.length && !(request.dwell > 0)) return []; + return [ + async () => { + // Handed over where it stands, so the handover itself moves nothing; then seated where + // the model will have it - a tip pressed onto its shaft, or settling in its spot - at the + // pace of the Z drive, while whatever the command does at the bottom takes its time. + const seating = []; + for (const { name, parent, location } of handovers) { + attach(name, parent ?? null); + const index = indexOf(name); + if (!location || index === undefined) continue; + ["x", "y", "z"].forEach((key, axis) => { + if (Math.abs(readAxis(index, axis) - location[key]) >= STILL / 10) { + seating.push(move(index, axis, location[key], drives.z)); + } + }); + } + // An aspiration's tips follow the sinking surface down, at a steady pace, while it draws. + const following = channels("follow") + .filter((c) => Math.abs(readAxis(c.index, 2) - c.follow) >= STILL) + .map((c) => move(c.index, 2, c.follow, { speed: c.follow_speed })); + await Promise.all([pause(request.dwell), ...seating, ...following]); + }, + ]; + }, + + // Out of the liquid, at the command's own speed. + () => + channels("leave") + .filter((c) => c.leave - readAxis(c.index, 2) >= STILL) + .map( + (c) => () => + move(c.index, 2, c.leave, { + speed: c.leave_speed, + acceleration: drives.z?.acceleration, + }), + ), + + // Up by the pull-out distance before the transport air is drawn, at the swap speed. + () => + channels("pull_out") + .filter((c) => c.pull_out - readAxis(c.index, 2) >= STILL) + .map( + (c) => () => + move(c.index, 2, c.pull_out, { + speed: c.leave_speed, + acceleration: drives.z?.acceleration, + }), + ), + + // Back up, to where the command ends. + () => + channels("end") + .filter((c) => Math.abs(readAxis(c.index, 2) - c.end) >= STILL) + .map((c) => () => move(c.index, 2, c.end, drives.z)), + + // A drive of the iSWAP's head, each at the command's own speed. + () => + (request.moves ?? []) + .map((m) => ({ ...m, index: indexOf(m.name) })) + .filter( + (m) => m.index !== undefined && Math.abs(readAxis(m.index, m.axis) - m.to) >= STILL, + ) + .map((m) => () => move(m.index, m.axis, m.to, m)), + + // The joints, turning together about their pivots. + () => + (request.turns ?? []) + .map((joint) => ({ ...joint, index: indexOf(joint.name) })) + .filter((joint) => joint.index !== undefined) + .map((joint) => () => turn(joint.index, joint)), + + // The jaws: letting go before they open, taking hold once they have closed. + () => { + const jaws = request.jaws; + if (!jaws) return []; + const fingers = jaws.fingers + .map((f) => ({ ...f, index: indexOf(f.name) })) + .filter((f) => f.index !== undefined); + if (!fingers.length) return []; + // The first finger faces the grip centre from +Y, so it closes toward -Y. + const closing = fingers[0].y < readAxis(fingers[0].index, 1) - STILL; + const opening = fingers[0].y > readAxis(fingers[0].index, 1) + STILL; + return [ + async () => { + if (opening) release(jaws.gripper); + await Promise.all(fingers.map((f) => move(f.index, 1, f.y, jaws))); + if (closing) grip(jaws.gripper, jaws.grip_point); + }, + ]; + }, + ]; + } + + return { + setSpeed(value) { + speed = Math.max(0, value); + }, + + /** + * Play a motion to its end. Resolves when it has, or at once when it cannot be played. + * + * @param {any} request what `motion.py` read from the command + */ + async play(request) { + playing++; + try { + const timing = { across: 0 }; + const list = phases(request, timing); + for (let i = 0; i < list.length; i++) { + const moves = list[i](); + if (!moves.length) continue; + const crossing = Promise.all(moves.map((start) => start())); + // A descent that sets off before the crossing has finished (`down_from`, a share of the + // crossing's time): the firmware lowers a tip pick-up's channels while the arm still + // travels. Otherwise each phase waits for the one before it. + if (i === ACROSS && request.down_from > 0 && request.down_from < 1 && timing.across > 0) { + await pause(request.down_from * timing.across); + const down = list[i + 1](); + await Promise.all([crossing, ...down.map((start) => start())]); + i++; + continue; + } + await crossing; + } + } finally { + playing--; + } + }, + + /** + * Advance everything that is moving. For the frame loop; says whether anything is still + * moving, or about to. + * + * @param {number} delta seconds since the last frame + */ + step(delta) { + const step = delta * speed; + for (const entry of running) { + entry.t += step; + if (entry.t >= entry.profile.duration) { + finish(entry); + continue; + } + if (entry.apply) { + const share = entry.profile.progress(entry.t); + entry.apply(entry.from + (entry.to - entry.from) * share); + } + } + return running.size > 0 || playing > 0; + }, + + /** Bring everything moving to where it was going, at once. */ + finishAll() { + for (const entry of [...running]) finish(entry); + }, + + /** Drop everything moving where it stands: the scene it was moving in is gone. */ + dropAll() { + for (const entry of [...running]) { + running.delete(entry); + entry.resolve(); + } + }, + }; +} diff --git a/pylabrobot/visualizer3D/static/motion_profile.js b/pylabrobot/visualizer3D/static/motion_profile.js new file mode 100644 index 00000000000..d5d90236a49 --- /dev/null +++ b/pylabrobot/visualizer3D/static/motion_profile.js @@ -0,0 +1,127 @@ +// How far along a move a drive is at a given moment: speed up, cruise, slow down. +// +// Without a jerk limit, the symmetric trapezoid the STAR simulator times moves by +// (`_get_travel_time`): a move too short to reach cruise speed speeds up for half its length and +// slows down for the other half. With one, an S-curve: acceleration itself ramps at the jerk, which +// is how the X-arm moves on a STAR (fitted from HxUsbComm traces; see MOTION_PROFILES.md). +// Pure: no three, no page, so it can be checked on its own. + +/** + * @typedef {object} Profile + * @property {number} duration seconds the move takes + * @property {(t: number) => number} progress share of the distance covered at `t` seconds, 0..1 + */ + +/** + * @param {number} distance mm, of either sign; only its size counts + * @param {number} speed mm/s the drive cruises at + * @param {number} acceleration mm/s^2 it speeds up and slows down at + * @param {number} [jerk] mm/s^3 its acceleration changes at; none for a trapezoid + * @returns {Profile} + */ +export function motionProfile(distance, speed, acceleration, jerk) { + const d = Math.abs(distance); + if (!(d > 0) || !(speed > 0)) return { duration: 0, progress: () => 1 }; + if (!(acceleration > 0)) { + const duration = d / speed; + return { duration, progress: (t) => clamp(t / duration) }; + } + if (jerk > 0 && Number.isFinite(jerk)) return sCurve(d, speed, acceleration, jerk); + + const tAccel = speed / acceleration; + const dAccel = 0.5 * acceleration * tAccel * tAccel; + + if (d >= 2 * dAccel) { + // Trapezoid: up to speed, cruise, and down again. + const dCruise = d - 2 * dAccel; + const tCruise = dCruise / speed; + const duration = 2 * tAccel + tCruise; + return { + duration, + progress: (t) => { + if (t >= duration) return 1; + if (t <= tAccel) return (0.5 * acceleration * t * t) / d; + if (t <= tAccel + tCruise) return (dAccel + speed * (t - tAccel)) / d; + const s = t - tAccel - tCruise; + return clamp((dAccel + dCruise + speed * s - 0.5 * acceleration * s * s) / d); + }, + }; + } + + // Triangle: never reaches cruise speed. + const vPeak = Math.sqrt(acceleration * d); + const tPeak = vPeak / acceleration; + const duration = 2 * tPeak; + return { + duration, + progress: (t) => { + if (t >= duration) return 1; + if (t <= tPeak) return (0.5 * acceleration * t * t) / d; + const s = t - tPeak; + return clamp((0.5 * d + vPeak * s - 0.5 * acceleration * s * s) / d); + }, + }; +} + +// The phases of one ramp from rest to `v`: jerk up, hold the acceleration, jerk down - the middle +// phase only when `v` is high enough for the acceleration to reach its limit. +function ramp(v, a, j) { + if (v >= (a * a) / j) return [a / j, v / a - a / j, a / j]; + const t1 = Math.sqrt(v / j); + return [t1, 0, t1]; +} + +const rampTime = (v, a, j) => ramp(v, a, j).reduce((s, t) => s + t, 0); + +/** Rest to rest over `d`, the speed, acceleration and jerk all limited: seven phases at most. */ +function sCurve(d, vmax, a, j) { + // The peak speed: the cruise speed if the move is long enough to reach it, else the speed at + // which a ramp up and a ramp down cover the distance between them (found by halving). + let v = vmax; + if (d < vmax * rampTime(vmax, a, j)) { + let lo = 0; + let hi = vmax; + for (let i = 0; i < 60; i++) { + const mid = (lo + hi) / 2; + if (mid * rampTime(mid, a, j) < d) lo = mid; + else hi = mid; + } + v = hi; + } + const [t1, t2, t3] = ramp(v, a, j); + const cruise = Math.max(0, (d - v * (t1 + t2 + t3)) / v); + // Each phase as [duration, jerk]; the accelerations and speeds follow by integration. + const phases = [ + [t1, j], + [t2, 0], + [t3, -j], + [cruise, 0], + [t3, -j], + [t2, 0], + [t1, j], + ]; + const duration = phases.reduce((s, [t]) => s + t, 0); + return { + duration, + progress: (t) => { + if (t >= duration) return 1; + let p = 0; + let vel = 0; + let acc = 0; + let left = Math.max(0, t); + for (const [dt, jk] of phases) { + const s = Math.min(dt, left); + p += vel * s + (acc * s * s) / 2 + (jk * s * s * s) / 6; + vel += acc * s + (jk * s * s) / 2; + acc += jk * s; + left -= s; + if (left <= 0) break; + } + return clamp(p / d); + }, + }; +} + +function clamp(p) { + return Math.max(0, Math.min(1, p)); +} diff --git a/pylabrobot/visualizer3D/static/transport.js b/pylabrobot/visualizer3D/static/transport.js index e0cbf3a38ee..2996c61364e 100644 --- a/pylabrobot/visualizer3D/static/transport.js +++ b/pylabrobot/visualizer3D/static/transport.js @@ -53,6 +53,11 @@ function sayHello() { ); } +/** Say something to the server, when there is a socket to say it on. */ +export function send(event, data) { + if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ event, data })); +} + export function connect() { if ( socket && @@ -89,6 +94,7 @@ export function connect() { if (kind === "scene") handlers.scene(data); else if (kind === "state") handlers.state(data); else if (kind === "moves") handlers.moves(data.moves); + else if (kind === "motion") handlers.motion(data); }; }