Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/ISSUE_TEMPLATE/device-test-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
name: Device hardware test report
about: Tell us what worked or failed when using PyLabRobot on a real device.
title: "[Hardware test] Vendor model — Works / Partly works / Does not work"
labels: ''
assignees: ''
---

<!-- Choose one outcome in the title. Reports of successful runs are welcome too. -->

### Device and environment

- Manufacturer and exact model:
- Firmware version (or unknown):
- PyLabRobot version or Git commit (`git rev-parse HEAD`):
- Operating system and Python version:
- Connection type and settings (USB, serial, TCP, adapters, etc.):
- Relevant hardware configuration, accessories, and labware:
- Date tested:
- Guide followed and related issue / pull request (if any):

### Result on real hardware

Overall outcome: Works / Partly works / Does not work

<!-- Add a row per operation. Describe the physical result, not just the absence of an exception.
Use Pass, Fail, or Not tested. State whether runs were repeated and whether failures are consistent.
If setup fails, mark later operations Not tested. -->

### Reproduction code

<!-- Paste a minimal script or attach the notebook, including setup and cleanup. -->

```python

```

### Evidence

<!-- Attach any relevant I/O logs, full tracebacks, output files, or photos/video of the result.
Remove credentials and private sample data before posting. -->

### Limitations and follow-up

<!-- List operations you could not test, workarounds, and any remaining problems.
If this retests a fix, link it and state the tested commit. -->
31 changes: 28 additions & 3 deletions docs/_exts/plr_devices/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class DeviceRegistryError(ValueError):
}

STATUS_DESCRIPTIONS = {
"wip": "Work in progress.",
"wip": "Work in progress, including models awaiting initial hardware verification.",
"basic": "Core functionality is available.",
"mostly": "Most capabilities are available, but some known commands are still missing.",
"full": "Comprehensive support (at least 90% of capabilities), with documentation.",
Expand All @@ -110,6 +110,7 @@ class DeviceRegistryError(ValueError):
"manager",
"oem",
"notes",
"needs_hardware_testing",
)

_ALL_FIELDS = set(REQUIRED_FIELDS) | set(OPTIONAL_FIELDS)
Expand Down Expand Up @@ -176,6 +177,9 @@ def _validate(device: Any, index: int, seen_ids: Dict[str, int], path: Path) ->
f"{where} ({device_id}): api_version {api_version!r} is not one of {', '.join(API_VERSIONS)}"
)

if "needs_hardware_testing" in device and not isinstance(device["needs_hardware_testing"], bool):
raise DeviceRegistryError(f"{where} ({device_id}): needs_hardware_testing must be a boolean")

for field in ("manager", "oem"):
value = device.get(field)
if value is not None and not str(value).startswith(("http://", "https://")):
Expand All @@ -199,13 +203,15 @@ def _validate(device: Any, index: int, seen_ids: Dict[str, int], path: Path) ->
model_where = f"{where} ({device_id}) models[{model_index}]"
if not isinstance(model, dict):
raise DeviceRegistryError(f"{model_where}: model must be an object")
unknown_model_fields = sorted(set(model) - {"name", "status"})
unknown_model_fields = sorted(set(model) - {"name", "status", "needs_hardware_testing"})
if unknown_model_fields:
raise DeviceRegistryError(
f"{model_where}: unknown field(s): {', '.join(unknown_model_fields)}"
)
if not isinstance(model.get("name"), str) or not model["name"]:
raise DeviceRegistryError(f"{model_where}: name must be a non-empty string")
if "needs_hardware_testing" in model and not isinstance(model["needs_hardware_testing"], bool):
raise DeviceRegistryError(f"{model_where}: needs_hardware_testing must be a boolean")
model_status = model.get("status")
if model_status is not None and model_status not in STATUSES:
raise DeviceRegistryError(
Expand Down Expand Up @@ -259,12 +265,31 @@ def get_device(app, device_id: str) -> Optional[Device]:
return None


def needs_hardware_testing(device: Device, model: Optional[Dict[str, Any]] = None) -> bool:
"""Whether a device or model needs testing; models inherit the device flag by default."""
if model is not None:
return bool(model.get("needs_hardware_testing", device.get("needs_hardware_testing", False)))
if device.get("models"):
return any(needs_hardware_testing(device, entry) for entry in device["models"])
return bool(device.get("needs_hardware_testing", False))


def filter_devices(devices: Sequence[Device], filters: Dict[str, str]) -> List[Device]:
"""Keep devices matching every filter."""

def matches(device: Device, field: str, wanted: str) -> bool:
if field == "needs_hardware_testing":
return needs_hardware_testing(device) == (wanted.lower() == "true")
if field == "capabilities":
return wanted.lower() in {c.lower() for c in device.get("capabilities", [])}
return str(device.get(field, "")).lower() == wanted.lower()

return [d for d in devices if all(matches(d, f, w) for f, w in filters.items() if w)]
selected = [d for d in devices if all(matches(d, f, w) for f, w in filters.items() if w)]
if filters.get("needs_hardware_testing", "").lower() == "true":
# Keep the full registry intact for other tables and cards in the same build.
return [
Device({**d, "models": [m for m in d["models"] if needs_hardware_testing(d, m)]})
if d.get("models") else d
for d in selected
]
return selected
3 changes: 3 additions & 0 deletions docs/_exts/plr_devices/directive.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class DeviceTable(Directive):
"status": directives.unchanged,
"search": directives.unchanged,
"filters": directives.unchanged,
"needs-hardware-testing": directives.flag,
}

def run(self):
Expand All @@ -80,6 +81,8 @@ def run(self):
}
node["search"] = _flag(self.options.get("search"))
node["filters_ui"] = _flag(self.options.get("filters"))
if "needs-hardware-testing" in self.options:
node["filters"]["needs_hardware_testing"] = "true"
return [node]


Expand Down
43 changes: 26 additions & 17 deletions docs/_static/devices.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,13 +231,14 @@
},
{
"id": "big-bear-orbital-shaker",
"needs_hardware_testing": true,
"vendor": "BigBear",
"name": "Orbital Shaker",
"kind": "shaker",
"capabilities": [
"shaking"
],
"status": "mostly",
"status": "wip",
"api": "pylabrobot.big_bear.BigBearOrbitalShaker",
"api_version": "v1",
"code_slug": "big_bear",
Expand Down Expand Up @@ -344,14 +345,15 @@
},
{
"id": "cole-parmer-genogrinder",
"needs_hardware_testing": true,
"vendor": "Cole Parmer",
"name": "GenoGrinder",
"kind": "shaker",
"capabilities": [
"shaking",
"grinding"
],
"status": "mostly",
"status": "wip",
"api": "pylabrobot.cole_parmer.GenoGrinder",
"api_version": "v1",
"code_slug": "cole_parmer",
Expand Down Expand Up @@ -384,13 +386,14 @@
},
{
"id": "curiox-ht2000",
"needs_hardware_testing": true,
"vendor": "Curiox",
"name": "HT2000",
"kind": "plate washer",
"capabilities": [
"plate washing"
],
"status": "mostly",
"status": "wip",
"api": "pylabrobot.curiox.CurioxHT2000",
"api_version": "v1",
"code_slug": "curiox",
Expand Down Expand Up @@ -553,6 +556,7 @@
},
{
"id": "highres-ambistore",
"needs_hardware_testing": true,
"vendor": "HighRes Biosolutions",
"name": "AmbiStore",
"kind": "storage",
Expand Down Expand Up @@ -618,6 +622,7 @@
},
{
"id": "highres-tundrastore",
"needs_hardware_testing": true,
"vendor": "HighRes Biosolutions",
"name": "TundraStore",
"kind": "storage",
Expand Down Expand Up @@ -705,17 +710,17 @@
"vendor": "Inheco",
"name": "Thermoshake",
"models": [
{"name": "Thermoshake", "status": "full"},
{"name": "Thermoshake AC", "status": "wip"},
{"name": "Thermoshake RM", "status": "wip"}
{"name": "Thermoshake", "status": "wip", "needs_hardware_testing": true},
{"name": "Thermoshake AC", "status": "wip", "needs_hardware_testing": true},
{"name": "Thermoshake RM", "status": "wip", "needs_hardware_testing": false}
],
"kind": "heater shaker",
"capabilities": [
"heating",
"active cooling",
"shaking"
],
"status": "full",
"status": "wip",
"api": "pylabrobot.inheco.inheco_thermoshake",
"api_version": "v1",
"code_slug": "inheco",
Expand All @@ -726,13 +731,14 @@
},
{
"id": "kbioscience-kube",
"needs_hardware_testing": true,
"vendor": "KBioscience",
"name": "KUBE",
"kind": "sealer",
"capabilities": [
"sealing"
],
"status": "mostly",
"status": "wip",
"api": "pylabrobot.kbioscience.KBioscienceKUBE",
"api_version": "v1",
"code_slug": "kbioscience",
Expand All @@ -745,9 +751,9 @@
"vendor": "KBiosystems",
"name": "Ultraseal",
"models": [
{"name": "Ultraseal ePRO", "status": "mostly"},
{"name": "Ultraseal PRO", "status": "mostly"},
{"name": "Ultraseal XT PRO", "status": "mostly"}
{"name": "Ultraseal ePRO", "status": "wip", "needs_hardware_testing": true},
{"name": "Ultraseal PRO", "status": "mostly", "needs_hardware_testing": false},
{"name": "Ultraseal XT PRO", "status": "wip", "needs_hardware_testing": true}
],
"kind": "sealer",
"capabilities": [
Expand Down Expand Up @@ -794,6 +800,7 @@
},
{
"id": "mettler-toledo-mt-sics",
"needs_hardware_testing": true,
"vendor": "Mettler Toledo",
"name": "MT-SICS scales and weigh modules",
"models": [
Expand Down Expand Up @@ -1292,7 +1299,7 @@
{"name": "WXS205", "status": "wip"},
{"name": "WXS205DU", "status": "wip"},
{"name": "WXS205S/15", "status": "wip"},
{"name": "WXS205SDU/15", "status": "full"},
{"name": "WXS205SDU/15", "status": "full", "needs_hardware_testing": false},
{"name": "WXS205SDUV/15", "status": "wip"},
{"name": "WXS205SV/15", "status": "wip"},
{"name": "WXS26", "status": "wip"},
Expand Down Expand Up @@ -1844,13 +1851,14 @@
},
{
"id": "sartorius-entris2",
"needs_hardware_testing": true,
"vendor": "Sartorius",
"name": "Entris II",
"kind": "scale",
"capabilities": [
"weighing"
],
"status": "mostly",
"status": "wip",
"api": "pylabrobot.sartorius.SartoriusEntris2",
"api_version": "v1",
"code_slug": "sartorius",
Expand Down Expand Up @@ -1903,18 +1911,19 @@
},
{
"id": "thermo-fisher-alps",
"needs_hardware_testing": true,
"vendor": "Thermo Fisher",
"name": "ALPS",
"models": [
{"name": "ALPS 300", "status": "mostly"},
{"name": "ALPS 3000", "status": "mostly"},
{"name": "ALPS 5000", "status": "mostly"}
{"name": "ALPS 300", "status": "wip"},
{"name": "ALPS 3000", "status": "wip"},
{"name": "ALPS 5000", "status": "wip"}
],
"kind": "sealer",
"capabilities": [
"sealing"
],
"status": "mostly",
"status": "wip",
"api": "pylabrobot.thermo_fisher.alps",
"api_version": "v1",
"code_slug": "thermo_fisher/alps",
Expand Down
5 changes: 5 additions & 0 deletions docs/contributor_guide/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ To build the documentation, run `make docs` in the root directory. The documenta

## Common Tasks

### Testing a device

You can contribute by testing PyLabRobot on hardware you have access to and reporting successful
runs or failures. See {doc}`/user_guide/needs-testing` for the device table and reporting process.

### Fixing a bug

Bug fixes are an easy way to get started contributing.
Expand Down
23 changes: 23 additions & 0 deletions docs/contributor_guide/device-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Append an object to `docs/_static/devices.json`:
| `models` | no | Model objects when one entry covers several models. `name` is required; `status` may be `wip`, `basic`, `mostly`, or `full` and defaults to the device status when omitted. Models render as searchable sub-rows with their support status beneath the device. |
| `kind` | yes | Device type, e.g. `plate reader`, `sealer`, `arm`. Must be one of `KINDS` in `docs/_exts/plr_devices/data.py`. |
| `status` | yes | One of `wip`, `basic`, `mostly`, `full`. See {doc}`/user_guide/machines` for what each level means. |
| `needs_hardware_testing` | no | Boolean, default `false`. Set `true` when hardware testing is needed, based on the device's docs, driver warnings, or reports. Each model can also set this boolean; omitted model flags inherit the device flag. |
| `capabilities` | no | Core functions, e.g. `["heating", "shaking"]`. Must come from `CAPABILITIES` in `docs/_exts/plr_devices/data.py`. These drive the badges and the capability filter. |
| `api` | no | Import path of the driver class, e.g. `pylabrobot.curiox.CurioxHT2000`. |
| `api_version` | no | `v1`, or `v0` for drivers still under `pylabrobot.legacy`. |
Expand Down Expand Up @@ -117,6 +118,28 @@ Options narrow it down:
`filters` take `false` to hide the search box or the chips, which is useful for a short,
pre-filtered list on a vendor page.

The {doc}`/user_guide/needs-testing` page uses:

````md
```{device-table}
:needs-hardware-testing:
```
````

This includes devices that need hardware testing and families with at least one model that needs
it. Only flagged models appear in this table; other tables and cards keep the complete model list.
An explicit model flag overrides the device default. For example, a family can have
`"needs_hardware_testing": true` with `"needs_hardware_testing": false` on its tested model.
A family whose models are all explicitly `false` is excluded. An omitted or `false` flag means
there is no testing request recorded, not a claim that all firmware or operations are verified.

Use existing docs, code warnings, or linked hardware reports to set the flag; do not infer it from
`wip` or any other support level. A device or model awaiting initial hardware verification has
`status: "wip"`, even if its shared driver works on other models. After reviewing a hardware report,
update only the tested model or device, preserve flags for untested siblings, and link the evidence
in the device guide. Follow
the reporting and follow-up process on {doc}`/user_guide/needs-testing`.

## Rendering a card

`device-card` renders one device. It works anywhere MyST is parsed, including markdown cells in the
Expand Down
1 change: 1 addition & 0 deletions docs/user_guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ getting-started/units
:hidden:

machines
needs-testing
definitions
generic/index
00_liquid-handling/_liquid-handling
Expand Down
10 changes: 7 additions & 3 deletions docs/user_guide/machines.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# Supported Machines

Every machine PyLabRobot supports, and how complete each driver is. Some are still work in
Every machine PyLabRobot supports, and its current support level. Some are still work in
progress (WIP) — if you have one of those, or a machine that is not listed at all, get in touch on
the [forum](https://discuss.pylabrobot.org).

Have access to hardware? See {doc}`needs-testing` for devices awaiting verification and how to
report what works or fails.

```{device-table}
```

Expand All @@ -26,9 +29,10 @@ PyLabRobot does not solve that. **Type** is the one label that names what a mach
filterable, so a machine you think of as a shaker is still findable by someone who thinks of it as
a heater.

**Support** is how complete the PyLabRobot integration is:
**Support** is how complete the PyLabRobot integration is for the listed device or model. A shared
driver does not establish support for every model it can communicate with.

- **WIP** — work in progress.
- **WIP** — work in progress, including models awaiting initial hardware verification.
- **Basic** — core functionality is available, integrated into `pylabrobot:main`.
- **Mostly** — most capabilities are available, but some known commands are still missing.
- **Full** — comprehensive support (≥90% of capabilities), with documentation.
Expand Down
Loading
Loading