From 5779962ace8766952d83360fa4bc55b16dae03ec Mon Sep 17 00:00:00 2001 From: Heinrich Date: Tue, 21 Jul 2026 13:06:01 +0000 Subject: [PATCH 1/4] Completed S0A-1, claude.md init --- .gitignore | 5 +- CLAUDE.md | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 6eb2444..8b2fa39 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ htmlcov/ # Local test data (fixtures live under PycroFlow/tests/fixtures/) PycroFlow/TestData/* -# Claude Code (project instructions + local settings; kept out of version control) -CLAUDE.md +# Claude Code: CLAUDE.md (shared project instructions) IS committed; keep +# local settings and personal notes out of version control. .claude/ +CLAUDE.local.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5323db4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +PycroFlow is a Python framework for coordinating microscopy image acquisition, fluid handling (Hamilton liquid handlers), and illumination control in automated fluorescence microscopy experiments (Exchange-PAINT, MERPAINT, Z-PAINT). It targets Windows 10 with hardware serial communication. Python 3.10+. + +See `ARCHITECTURE.md` for the package map, `docs/architecture.md` for detail, and `docs/adr/` for the rationale behind major decisions. + +## Repository & workflow + +- **Active branch:** `feature-FullAutoS0A` (feature branch; PRs target `master`). This repo is one of several checked out under the `DNA-PAINT-FullAutomation` workspace — see the standing pointers below. +- **Versioning:** the version is hardcoded in `pyproject.toml` (`[project] version`) and is the single source of truth; `PycroFlow.__version__` reads it at runtime via `importlib.metadata.version("PycroFlow")` (falling back to `"0.0.0"` in an uninstalled source tree). To release, bump the number manually in `pyproject.toml` — there is no setuptools-scm / git-tag-driven versioning here. +- **Changelog:** keep `CHANGELOG.txt` current — add an entry under an `[Unreleased]` heading in every PR that changes behaviour, and promote `[Unreleased]` to a dated, version-stamped section when you bump the version. + +### Standing pointers + +Workspace-level planning docs live in the sibling `planning/` folder — `@`-reference them as `../../planning/…` from this repo root: + +- **Playbook** — `../../planning/DNA-PAINT_ClaudeCode-Implementation-Playbook.md` (operating model, Step 0 foundations, gated work-order sequence) +- **Design doc** — `../../planning/DNA-PAINT_Automation-Recommendation.md` (the automation recommendation: initiatives, roadmap, target architecture) +- **Work-order briefs** — `../../planning/DNA-PAINT_Work-Order-Briefs.md` (paste-ready briefs S0A/S0B + WP-1…WP-16) +- **Progress tracker** — `../../planning/DNA-PAINT_Implementation-Progress-Tracker.md` (tick-off worksheet + "where we are") +- **Planning index / reading guide** — `../../planning/README.md` +- **ModuleSpec contract reference** — `../../planning/picasso-workflow_Module-Annotations_Reference.md` +- **Cross-repo contracts** (registry OpenAPI spec + published client, shared metric/workflow schemas) are produced in Step 0B and published from `picasso-registry`; record their concrete paths here once S0B lands. + +## Commands + +### Install +```bash +pip install -e ".[dev]" # dev / CI (hardware libs mocked in tests) +pip install -e ".[hardware]" # lab Windows box (real instruments) +pip install -e ".[gui]" # PyQt6 for the `pycroflow-gui` frontend +``` +Console scripts: `pycroflow` (CLI), `pycroflow-gui` (Qt GUI). +All metadata and dependencies live in `pyproject.toml`; `setup.py` is a thin shim. There is no `requirements.txt` — its former contents are fully covered by the core `dependencies` plus the `[hardware]` / `[dev]` / `[gui]` extras. + +## Configuration + +### Code Style +- Formatting: Black with 79-char lines. `.flake8` sets `max-line-length = 79` and `extend-ignore = E203, W503` (the Black-compatibility ignores — Black owns line wrapping, so those two pycodestyle rules stay off; note `E501` is *not* ignored here). `ruff` and `mypy` are available via the `[dev]` extra but are not yet wired into CI. +- Docstring convention: NumPy style (numpydoc) — one-line imperative + summary, then `Parameters` / `Returns` / `Raises` / `Notes` sections with + dashed underlines and `name : type` fields. Pair with PEP 604 type hints in + signatures (`from __future__ import annotations`); don't restate a + parameter's type in prose when the annotation already gives it. Matches the + upstream `picasso` package. +- Test coverage requirement: 80% + +## Testing Structure + +### Run all tests +```bash +# from the repo root +python -m unittest discover -v +``` +The suite runs without vendor SDKs: `PycroFlow/tests/_mock_hardware.py` installs `sys.modules` mocks for pycromanager / monet / pycobolt / nidaqmx when they're not importable. Real SDKs are preferred when present. + +### Run a single test file / case +```bash +python -m unittest PycroFlow.tests.test_protocols -v +python -m unittest PycroFlow.tests.test_protocols.TestProtocolBuilder.test_05 -v +``` + +### Coverage +`coverage` + `pytest-cov` are in the `[dev]` extra and configured under `[tool.coverage.*]` in `pyproject.toml` (scoped to the `PycroFlow` package, tests/emulators omitted), so no `--source`/path flags are needed. + +Inline in the pytest run (via `pytest-cov`): +```bash +pytest --cov # coverage summary printed after the test run +pytest --cov --cov-report=term-missing +pytest --cov --cov-report=html # also writes htmlcov/ +``` +Standalone (unittest runner, or pytest without the plugin): +```bash +coverage run -m unittest discover && coverage report +coverage run -m pytest && coverage report +coverage html # browsable report in htmlcov/ +``` +The suite is unittest-style; pytest works as an alternate runner via the top-level `conftest.py`, which installs the same hardware mocks as `tests/__init__.py`. + +### Regenerate protocol regression snapshots (after an intended wire change) +```bash +PYCROFLOW_UPDATE_SNAPSHOTS=1 python -m unittest PycroFlow.tests.test_regression_protocols -v +``` +Commit the updated JSON in `PycroFlow/tests/fixtures/snapshots/`. + +CI runs `python -m unittest discover -v` on Windows / Python 3.10 (`.github/workflows/tests.yml`). There are no configured linters/formatters yet (`ruff`/`mypy` are in the `[dev]` extra). + +### Hardware emulators (`tests/emulators/`) +Behavioral hardware fakes for tests, in three fidelity layers (vs. the import-only `MagicMock` shims in `tests/_mock_hardware.py`): +- **Serial-level** — `hamilton_serial.FakeHamiltonSerial` (+ `patch_serial()` / `make_fake_bus()`) presents a `serial.Serial` surface speaking the Hamilton PSD/MVP wire protocol, so the *real* `SerialBus` / `Pump` / `Valve` run end-to-end (covers the command encode/response decode path). `arduino_serial.FakeArduinoSerial` (+ `connect_interface()`) does the same for `ArduinoSensorInterface`. +- **HAL-level** — `hal_devices.EmulatedPump` / `EmulatedValve` / `EmulatedSpillSensor` implement the `hal/` ABCs with in-memory state + a command log. +- **Subsystem-level** — `subsystems.EmulatedFluidSystem` / `EmulatedImagingSystem` / `EmulatedIlluminationSystem` implement `AbstractSystem` with deterministic pause/resume/abort for driving `ProtocolOrchestrator`. + +Tests live in `tests/test_emulators.py`. + +## Architecture + +### Orchestration (`orchestration/`) +`ProtocolOrchestrator` (in `orchestration/core.py`) manages `FluidHandler` / `ImagingHandler` / `IlluminationHandler` daemon threads. Cross-subsystem sync uses a signal protocol (`$type: 'signal'` / `'wait for signal'`). `orchestration/signal_registry.py` provides an `Event`-backed `SignalRegistry` (no busy-poll, hard timeout); `orchestration/threadexchange.py` provides a per-instance `ThreadExchange` (locks/events/lists/registry). Supports pause/resume/abort. Typed-entry dispatch via `functools.singledispatch`. + +### Protocol system (`protocols/` + `protocol_entries.py` + `schemas/`) +Two levels: the **Experiment Design** (high-level intent — volumes, reservoir names, the per-type design like SPH-RESI target/RESI rounds) is compiled into the **Run Sequence** (linearized per-subsystem `$type` entry lists). `ProtocolBuilder` (`protocols/builder.py`) does the compile: `build_protocol(config)` returns the validated dict (no I/O); `create_protocol(config)` also writes the canonical YAML and returns `(fname, steps)`. Experiment types dispatch through the `EXPERIMENT_TYPES` registry (`exchange`, `merpaint`, `flushtest`, `sph-resi`). + +The Experiment Design has its own pydantic schema (`schemas/experiment_design.py`, typed `Exchange` + `SPH-RESI`, hyphen aliases via `Field(alias=...)`, `validate_experiment_design`). Fields carry editor metadata in `Field(json_schema_extra=...)` (helpers `_field(...)` / `_unit(...)`; read back via `field_meta` / `field_unit`) — all advisory (no effect on validation), consumed by the schema-driven editor: `unit` (volumes `µl`, velocities `µl/min`, delays `s`, incubations `min`, exposure `ms`, laser power `mW`); `choices` / `choices_from` + `allow_none` (dropdowns — e.g. `mode` is a fixed list; imager/buffer/blocker/adapter fields are dropdowns of the design's reservoir *names*; `illu.settings.laser` is a dropdown of the setup's monet laser lines; a `list[scalar]` with `choices_from` (Exchange `imagers`) becomes add/remove dropdown rows like the RESI-rounds, with optional `title` (group-box name, e.g. "rounds") and `row_label` (per-row template, e.g. "imager round {}", renumbered on add/remove)); `tooltip`; and for mappings `columns` / `display_value_first` / `key_choices_from` / `value_choices_from`. `SchemaForm(..., context=..., skip_fields=...)` threads an observable `FormContext` of dropdown options (`reservoir_names` from the design, `reservoir_ids` from the loaded setup via `SystemService.reservoir_ids()`, `lasers` from the setup's monet config via `SystemService.laser_options()` — both through the design tab's provider callables) down the form tree and omits a union variant's `type` (shown by the selector). The `reservoir_names` table publishes its values live (`provides='reservoir_names'`), so the imager/buffer dropdowns refresh as you edit it. `reservoir_names` / `special_names` render as labelled (ID, name) tables (the latter stored name→id but displayed id-first); reservoir ids are restricted to the setup's manifold. In `FluidSettings` the reservoir tables come first, then volumes/cleaning; the wash buffers are **not** duplicated there — they live only in the `experiment` block (the SPH-RESI builder reads `experiment['wash_buffer_1'/'wash_buffer_2']`). Scalar form labels are left-aligned (`QFormLayout.setLabelAlignment`). — it's the single source of truth for both builder/GUI validation and the schema-driven editor. The Run Sequence is pinned by `schemas/protocol_schema.py` (discriminated union, `extra='allow'`); `protocol_entries.py` exposes the typed models + `parse_entry` / `parse_protocol`. + +### Fluid automation (`fluid/` + `pyHamilton/` + `hal/`) +`LegacyArchitecture` (`fluid/legacy.py`) drives Hamilton MVP valves and PSD syringe pumps over serial. Instrument topology lives in `configs/legacy_system.yaml` and `configs/legacy_tubing.yaml`, loaded by `configs/__init__.py` and re-exported as `legacy_system_config` / `legacy_tubing_config`. `pyHamilton/` is the in-house serial driver (`SerialBus` in `communication.py`, `command.py`, `mvp.py`, `psd.py`). `hal/` defines vendor-neutral `Pump` / `Valve` / `SpillSensor` ABCs. + +**ibidi MultiFlOW multiplexer** (`ibidi_multiplexer.py`) is an alternative to the Hamilton MVP rotary valves for reservoir multiplexing (the syringe pumps stay Hamilton). It's a standalone 24-channel bi-stable-valve actuator on its **own** USB serial port; `IbidiMultiplexer` implements the `hal/` `Valve` ABC and presents `set_valve(channel)` (atomic exclusive open via `SETBATCHVALVES`) so `LegacyArchitecture._set_valves` drives it unchanged. A setup wires it with an optional `hamilton.ibidi:` block (`port`/`baud`/`channels`/`address`) and reservoirs whose `valve_pos` is `{ibidi: , 1: in}` (see `configs/setups/IbidiEmulator.yaml`, a 24-reservoir emulated setup). The serial-level emulator is `tests/emulators/ibidi_serial.py` (`FakeIbidiSerial` / `patch_ibidi_serial`), patched alongside `patch_serial` in `SystemService.connect_fluid` for emulated setups. + +**Per-microscope setups** live in `configs/setups/.yaml` (e.g. `Mercury`, `Emulator`, `IbidiEmulator`) — the fixed hardware (interface, valves, pumps, flush_pos, full reservoir manifold, tubing, PFS tags) plus the `setup` name (a `monet.CONFIGS` key). `configs.load_setup(name)` / `list_setups()` load them; `configs.assemble_hamilton_config(setup, fluid_settings)` merges a setup's manifold with an Experiment Design's `reservoir_names` / `special_names` into the `hamilton_config` `LegacyArchitecture` expects. The `Emulator` setup (`emulated: true`) makes `SystemService.connect_*` build the *real* drivers over `tests/emulators` (`patch_serial` fake serial for fluid; `EmulatedImaging/IlluminationSystem`) so the whole app runs with no instruments. + +### Imaging (`imaging.py` + `services/mm_core.py` + `mm_lock.py`) +`ImagingSystem` wraps pycromanager for acquisition and PFS monitoring. The MM Core/Studio singletons are owned by `services/mm_core.py` (supersedes `util.PyMgrSingleton`). `mm_lock.MmCoreLock` is a filesystem mutex that prevents PycroFlow imaging and a standalone monet GUI from attaching to MM simultaneously (raises `MmLockHeld`). + +### Illumination (`illumination.py`) +`IlluminationSystem` manages laser power/wavelength via **monet**, which is an external sibling repository (not vendored — see `docs/adr/004`). Tests mock it. The monet config name is the **microscope setup** name (a `monet.CONFIGS` key), passed to `IlluminationSystem(setup=...)` by `SystemService.connect_illumination` — *not* carried in the experiment design. The Experiment Design only holds illumination **intent** (`illu.settings`: laser, power_acq/nonacq in mW, warmup, shutter); it has no `illu.parameters` (monet provides the per-microscope calibration; the old `channel_group`/`filter`/`ROI` were unused). monet loads lazily on first laser use (`_ensure_monet`). + +### Services (`services/`) +Frontend-agnostic layer both the CLI and the Qt GUI consume: `ExperimentService` (lifecycle + observer hooks), `SystemService` (manual hardware control), `mm_core` (Core ownership). + +### Spill sensor (`spill_sensor_arduino.py`) +`ArduinoSensorInterface` polls an Arduino over serial for wetness/spill detection in a background thread. Port via the `PYCROFLOW_SPILL_PORT` env var. + +### Frontends (`frontend_cli.py`, `gui/`) +`PycroFlowInteractive` (`cmd.Cmd`) is the `pycroflow` console entry point; lifecycle commands route through `services/`. `gui/` is the `pycroflow-gui` PyQt6 frontend (`[gui]` extra): a `PycroFlowMainWindow` with a toolbar (setup selector + Connect + run controls) over tabs (**Experiment Design / Run Sequence / Fluid / Imaging / Monet**), all on the same `services/` layer. The toolbar holds only the setup selector + Connect; the run controls (Load run sequence, Start, a single Pause/Resume toggle, Abort) live in the Run Sequence tab, enabled/relabelled per experiment state. The window title shows the package version (`PycroFlow <__version__>`). The **microscope setup** is chosen in the toolbar combo (drives the Monet tab); subsystems **autoconnect** once an experiment design is loaded — the main window is the connection coordinator (`_connect_system`/`_autoconnect`, fluid/illumination in background workers, imaging on the GUI thread for MM/ZMQ safety) and mirrors the connected systems into `ExperimentService`. Each subsystem tab shows its connection status + a manual Connect/Reconnect (illumination status lives in the Monet tab). While an experiment is running (ORCHESTRATING/RUNNING/PAUSED) the manual hardware controls — setup selector, per-tab connect, fluid manual ops, and the embedded monet GUI — are disabled so they can't fight the orchestrator for the instruments (the fluid emergency STOP stays enabled). **Experiment Design** is a schema-driven structured editor (`gui/widgets/schema_form.py`, generated from the `ExperimentDesign` pydantic model) with Load/Save/Translate (loading a design *from a file* `os.chdir`s to its folder so run outputs land beside it); **Run Sequence** shows the compiled steps in three side-by-side per-subsystem lists (fluid/img/illu) with a single editable parameter box below (labelled with the last-clicked step's list); clicking a step selects + centres the concurrent step in the other two lists (traced via the signal/wait-for-signal happens-before graph → longest-path "logical levels"), and a "Center on current step" button (enabled only while running) scrolls all three lists to the running step, live progress bars (overall + rounds + within-current-round steps + per-subsystem within-step bars for imaging frames, fluid incubation waits, and fluid inject/pump-out — the latter a volume/velocity time estimate, not pump polling — fed by `ExperimentService.step_progress()` → each handler's `get_step_progress()`), and step shading. Protocol/design YAML can be drag&dropped onto their tabs (`gui/widgets/dnd.py`). Long blocking calls run via `gui/widgets/worker.py` (`run_in_background`) so the UI never freezes. The Monet tab embeds monet's `MonetWidget(initial_microscope=)` (falls back to `MonetMainWindow`, then a placeholder if monet is absent/mocked/a non-PyQt6 `QWidget`). `gui/qt_bridge.py` marshals service observer callbacks onto the GUI thread as Qt signals. The package is import-safe without PyQt6 — Qt is imported lazily so `import PycroFlow.gui` and the test suite work without the `[gui]` extra. + +### Logging +loguru, configured by `PycroFlow.setup_logging(clean_old=False)`. **Importing the package no longer touches the filesystem** — frontends call `setup_logging` explicitly (the CLI does, with `clean_old=True`). `pyHamilton` and `monet` logs are filtered out of the main log. + +## Key Patterns + +- **Abstract base classes:** `AbstractSystem` / `AbstractSystemHandler` (`orchestration/core.py`) define the subsystem contract; `hal/` defines the hardware contract. +- **Threading:** one daemon thread per subsystem handler; sync via `threading.Event`, `queue.Queue`, `threading.Lock`, and `SignalRegistry`. +- **Configuration:** instrument configs are YAML (`configs/`); protocol configs load from YAML and validate against the pydantic schema. +- **Back-compat shims:** `PycroFlow.hamilton_architecture`, `PycroFlow.protocols`, and `PycroFlow.orchestration` re-export from their new submodule homes so old import paths keep working. +- **Tests use `unittest`** with `unittest.mock`. `tests/__init__.py` installs hardware mocks and uses a tempdir for outputs (it does NOT clear `PycroFlow/TestData/`). Protocol output is pinned by snapshot regression. From fd67d79c359a2c11cf94c23bc7928f675cd10fd8 Mon Sep 17 00:00:00 2001 From: Heinrich Date: Tue, 21 Jul 2026 13:54:42 +0000 Subject: [PATCH 2/4] S0A-2 (first pass) - reformatting and full-black run --- .flake8 | 7 ------- CHANGELOG.txt | 0 setup.py | 4 ---- 3 files changed, 11 deletions(-) delete mode 100644 .flake8 delete mode 100644 CHANGELOG.txt delete mode 100644 setup.py diff --git a/.flake8 b/.flake8 deleted file mode 100644 index b9cbe7a..0000000 --- a/.flake8 +++ /dev/null @@ -1,7 +0,0 @@ -[flake8] -# Match CLAUDE.md / Black. E203 (whitespace before ':') and W503 (line -# break before binary operator) are disabled per Black's documented -# flake8 compatibility settings — Black formats these in ways pycodestyle -# would otherwise flag. -max-line-length = 79 -extend-ignore = E203, W503 diff --git a/CHANGELOG.txt b/CHANGELOG.txt deleted file mode 100644 index e69de29..0000000 diff --git a/setup.py b/setup.py deleted file mode 100644 index 0db812e..0000000 --- a/setup.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Legacy shim. All metadata and dependencies live in pyproject.toml.""" -from setuptools import setup - -setup() From 7174c1836ee60dcb278531eff3c7364e111aec10 Mon Sep 17 00:00:00 2001 From: Heinrich Date: Tue, 21 Jul 2026 13:56:20 +0000 Subject: [PATCH 3/4] S0A-2 (first pass) - bringing repos in line adn full black pass --- .github/workflows/tests.yml | 25 + .gitignore | 2 + .pre-commit-config.yaml | 44 + CHANGELOG.md | 37 + CLAUDE.md | 8 +- PycroFlow/__init__.py | 28 +- PycroFlow/configs/__init__.py | 68 +- PycroFlow/examples/demo_protocols.py | 98 +- PycroFlow/fluid/__init__.py | 1 + PycroFlow/fluid/legacy.py | 8 +- PycroFlow/fluid/wet_tests.py | 1 + PycroFlow/gui/__init__.py | 1 + PycroFlow/gui/__main__.py | 3 +- PycroFlow/gui/app.py | 6 +- PycroFlow/gui/main_window.py | 82 +- PycroFlow/gui/qt_bridge.py | 1 + PycroFlow/gui/tabs/experiment_design_tab.py | 93 +- PycroFlow/gui/tabs/experiment_tab.py | 244 ++-- PycroFlow/gui/tabs/fluid_tab.py | 77 +- PycroFlow/gui/tabs/imaging_tab.py | 16 +- PycroFlow/gui/tabs/monet_tab.py | 12 +- PycroFlow/gui/widgets/dnd.py | 2 +- PycroFlow/gui/widgets/schema_form.py | 225 ++-- PycroFlow/gui/widgets/worker.py | 8 +- PycroFlow/hal/__init__.py | 5 + PycroFlow/hal/pumps.py | 1 + PycroFlow/hal/sensors.py | 5 +- PycroFlow/hal/valves.py | 1 + PycroFlow/hamilton_architecture.py | 1 + PycroFlow/mm_lock.py | 21 +- PycroFlow/orchestration/__init__.py | 2 +- PycroFlow/orchestration/signal_registry.py | 5 +- PycroFlow/orchestration/threadexchange.py | 56 +- PycroFlow/protocol_entries.py | 33 +- PycroFlow/protocols/__init__.py | 1 + PycroFlow/protocols/builder.py | 10 +- PycroFlow/protocols/exchange.py | 1 + PycroFlow/protocols/flushtest.py | 1 + PycroFlow/protocols/merpaint.py | 1 + PycroFlow/protocols/sph_resi.py | 1 + PycroFlow/protocols/timing.py | 45 +- PycroFlow/pyHamilton/__init__.py | 21 +- PycroFlow/pyHamilton/command.py | 339 +++-- PycroFlow/pyHamilton/commandPSD4.py | 127 +- PycroFlow/pyHamilton/commandPSD4SmoothFlow.py | 102 +- PycroFlow/pyHamilton/commandPSD6.py | 124 +- PycroFlow/pyHamilton/commandPSD6SmoothFlow.py | 102 +- PycroFlow/pyHamilton/communication.py | 58 +- PycroFlow/pyHamilton/util.py | 99 +- PycroFlow/schemas/__init__.py | 1 + PycroFlow/schemas/experiment_design.py | 166 ++- PycroFlow/schemas/protocol_schema.py | 42 +- PycroFlow/services/__init__.py | 1 + PycroFlow/services/experiment_service.py | 54 +- PycroFlow/services/mm_core.py | 10 +- PycroFlow/services/system_service.py | 100 +- PycroFlow/tests/__init__.py | 6 +- PycroFlow/tests/_mock_hardware.py | 42 +- PycroFlow/tests/emulators/__init__.py | 25 +- PycroFlow/tests/emulators/arduino_serial.py | 39 +- PycroFlow/tests/emulators/hal_devices.py | 56 +- PycroFlow/tests/emulators/hamilton_serial.py | 48 +- PycroFlow/tests/emulators/subsystems.py | 44 +- .../tests/fixtures/configs/exchange_basic.py | 109 +- PycroFlow/tests/test_emulators.py | 127 +- PycroFlow/tests/test_experiment_design.py | 345 ++--- PycroFlow/tests/test_fluid_legacy.py | 111 +- PycroFlow/tests/test_frontend_cli.py | 103 +- PycroFlow/tests/test_hamilton_architecture.py | 208 ++-- PycroFlow/tests/test_hamilton_components.py | 95 +- PycroFlow/tests/test_illumination.py | 80 +- PycroFlow/tests/test_imaging.py | 77 +- PycroFlow/tests/test_orchestration.py | 195 +-- PycroFlow/tests/test_protocols.py | 330 ++--- PycroFlow/tests/test_protocols_sph_resi.py | 213 ++-- PycroFlow/tests/test_regression_protocols.py | 46 +- PycroFlow/tests/test_services_coverage.py | 51 +- PycroFlow/tests/test_spill_sensor.py | 50 +- PycroFlow/tests/test_stage1_reliability.py | 96 +- PycroFlow/tests/test_stage2_schema.py | 127 +- PycroFlow/tests/test_stage3_services.py | 105 +- .../test_stage4_signal_and_typed_dispatch.py | 178 ++- PycroFlow/tests/test_stage5_gui.py | 1109 +++++++++++------ PycroFlow/tests/test_timing.py | 76 +- PycroFlow/tests/test_util.py | 25 +- conftest.py | 1 + docs/confluence/upload_to_confluence.py | 38 +- example_experiment/start_experiment_240119.py | 272 ++-- example_experiment/start_experiment_240202.py | 265 ++-- example_experiment/start_experiment_240223.py | 282 +++-- example_experiment/start_experiment_240301.py | 283 +++-- .../start_experiment_initial.py | 218 ++-- pyproject.toml | 42 +- scripts/calibrate_pfsoffset.py | 89 +- scripts/move_PFS.py | 108 +- scripts/start_zpaint.py | 287 +++-- snippets/AriaComm.py | 18 +- snippets/AriaProtocol.py | 462 ++++--- snippets/FlowAcquisition.py | 403 +++--- snippets/ProcessedLiveView.py | 30 +- snippets/access_mm_multid.py | 60 +- snippets/arduino_connection.py | 90 +- snippets/inputinterrupt.py | 22 +- snippets/testacq.py | 150 ++- 104 files changed, 5989 insertions(+), 3873 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4986313..219bf0d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,6 +10,31 @@ concurrency: cancel-in-progress: true jobs: + lint: + name: lint (black + flake8) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install lint tools + # Pin Black to the version the pre-commit hook / local devs run so CI + # and pre-commit agree. flake8 reads [tool.flake8] via Flake8-pyproject. + run: | + python -m pip install --upgrade pip + pip install "black==25.9.0" flake8 Flake8-pyproject + + - name: black --check + run: black --check . + + - name: flake8 + run: flake8 . + unittest: name: unittest on Windows (Python ${{ matrix.python-version }}) runs-on: windows-latest diff --git a/.gitignore b/.gitignore index 8b2fa39..2409d05 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ PycroFlow.egg-info/* *.egg-info/ build/ dist/ +# Generated by setuptools-scm at build/install time (version from git tag). +PycroFlow/_version.py # Test artifacts .pytest_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..03435fb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,44 @@ +# Applies to every hook below. Keep the vendored upstream PyHamiltonPSD +# snapshot pristine, and leave the regression snapshot fixtures byte-exact +# (they are written without a trailing newline by the snapshot regenerator and +# compared via json.loads, so the end-of-file-fixer must not touch them). +exclude: > + (?x)^( + PycroFlow/pyHamilton/pyHamiltonPSD_packagefiles/ + |PycroFlow/tests/fixtures/snapshots/ + ) + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + # Use the maintained black mirror, pinned to the version the team runs + # locally so pre-commit and a manual `black` agree (keep in sync via + # `pre-commit autoupdate`). black reads line-length from [tool.black]. + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 25.9.0 + hooks: + - id: black + # flake8 reads its config from pyproject.toml [tool.flake8] via the + # Flake8-pyproject plugin (no separate .flake8 file). The exclude below + # mirrors [tool.flake8].extend-exclude; it is repeated here because + # pre-commit passes an explicit file list that flake8's own directory-walk + # excludes do not filter (the generated _version.py, the pyHamilton driver's + # star-import idiom, and the throwaway snippets/scripts/example scripts). + - repo: https://github.com/pycqa/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + additional_dependencies: [Flake8-pyproject] + exclude: > + (?x)^( + PycroFlow/_version\.py + |PycroFlow/pyHamilton/ + |snippets/ + |scripts/ + |example_experiment/ + ) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0e56e80 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- Versioning now derives from the git tag via `setuptools-scm` (writes + `PycroFlow/_version.py`); the manual `version` string in `pyproject.toml` + is gone. `PycroFlow.__version__` reads the generated module with a fallback. +- Consolidated lint config into `pyproject.toml`: added `[tool.black]` + (line-length 79, `target-version = ["py310"]`) and `[tool.flake8]` + (`extend-ignore = E203,E501,W503` — Black owns line length), replacing the + standalone `.flake8`. + +### Added + +- Shared `.pre-commit-config.yaml` (pre-commit-hooks + Black + flake8 via + Flake8-pyproject), matching the rest of the DNA-PAINT stack. +- `black --check` and `flake8` lint job in CI. +- This changelog. + +### Removed + +- Legacy `setup.py` shim (`pyproject.toml` is the canonical build config). +- Empty `CHANGELOG.txt` (superseded by this `CHANGELOG.md`). + +## [0.1.0] + +Initial tagged release. PycroFlow coordinates microscopy image acquisition, +Hamilton fluid handling, and monet illumination control for automated +DNA-PAINT experiments (Exchange-PAINT, MERPAINT, Z-PAINT, SPH-RESI), with a +CLI (`pycroflow`) and a PyQt6 GUI (`pycroflow-gui`) over a shared service layer. diff --git a/CLAUDE.md b/CLAUDE.md index 5323db4..5ce00c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,8 +11,8 @@ See `ARCHITECTURE.md` for the package map, `docs/architecture.md` for detail, an ## Repository & workflow - **Active branch:** `feature-FullAutoS0A` (feature branch; PRs target `master`). This repo is one of several checked out under the `DNA-PAINT-FullAutomation` workspace — see the standing pointers below. -- **Versioning:** the version is hardcoded in `pyproject.toml` (`[project] version`) and is the single source of truth; `PycroFlow.__version__` reads it at runtime via `importlib.metadata.version("PycroFlow")` (falling back to `"0.0.0"` in an uninstalled source tree). To release, bump the number manually in `pyproject.toml` — there is no setuptools-scm / git-tag-driven versioning here. -- **Changelog:** keep `CHANGELOG.txt` current — add an entry under an `[Unreleased]` heading in every PR that changes behaviour, and promote `[Unreleased]` to a dated, version-stamped section when you bump the version. +- **Versioning:** driven by `setuptools-scm` from the latest reachable **git tag** (single source of truth — `pyproject.toml` has `dynamic = ["version"]`, no static number). At build/install time scm writes the resolved value into `PycroFlow/_version.py` (git-ignored, generated); `PycroFlow.__init__` imports it (`from ._version import version`), falling back to `importlib.metadata` then `"0.0.0"` in an uninstalled source tree. `[tool.setuptools_scm]` sets `fallback_version` for checkouts with no reachable tag. To release, create an annotated `vX.Y.Z` git tag — do **not** edit a version string. +- **Changelog:** keep `CHANGELOG.md` (Keep a Changelog format, SemVer) current — add an entry under the `[Unreleased]` heading in every PR that changes behaviour, and promote `[Unreleased]` to a dated, version-stamped section when you cut a release tag. ### Standing pointers @@ -35,12 +35,12 @@ pip install -e ".[hardware]" # lab Windows box (real instruments) pip install -e ".[gui]" # PyQt6 for the `pycroflow-gui` frontend ``` Console scripts: `pycroflow` (CLI), `pycroflow-gui` (Qt GUI). -All metadata and dependencies live in `pyproject.toml`; `setup.py` is a thin shim. There is no `requirements.txt` — its former contents are fully covered by the core `dependencies` plus the `[hardware]` / `[dev]` / `[gui]` extras. +All metadata, dependencies, and build config live in `pyproject.toml` (canonical — there is no `setup.py`). There is no `requirements.txt` — its former contents are fully covered by the core `dependencies` plus the `[hardware]` / `[dev]` / `[gui]` extras. ## Configuration ### Code Style -- Formatting: Black with 79-char lines. `.flake8` sets `max-line-length = 79` and `extend-ignore = E203, W503` (the Black-compatibility ignores — Black owns line wrapping, so those two pycodestyle rules stay off; note `E501` is *not* ignored here). `ruff` and `mypy` are available via the `[dev]` extra but are not yet wired into CI. +- Formatting: Black with 79-char lines. Lint config lives in `pyproject.toml`: `[tool.black]` (`line-length = 79`, `target-version = ["py310"]`) and `[tool.flake8]` (read via the Flake8-pyproject plugin; `max-line-length = 79`, `extend-ignore = E203,E501,W503`). Black owns line wrapping, so **E501 is ignored** (matching the rest of the DNA-PAINT stack); E203/W503 are the standard Black-compatibility ignores. The generated `_version.py` is excluded. `black --check` + `flake8` run in CI and via `.pre-commit-config.yaml` (Black + flake8 + pre-commit-hooks). `ruff` and `mypy` are available via the `[dev]` extra but are not yet wired into CI. - Docstring convention: NumPy style (numpydoc) — one-line imperative summary, then `Parameters` / `Returns` / `Raises` / `Notes` sections with dashed underlines and `name : type` fields. Pair with PEP 604 type hints in diff --git a/PycroFlow/__init__.py b/PycroFlow/__init__.py index 174bb79..16a4e48 100644 --- a/PycroFlow/__init__.py +++ b/PycroFlow/__init__.py @@ -9,16 +9,24 @@ a protocol in a startup script) does not flood the terminal. Until ``setup_logging`` installs the real sinks, records simply go nowhere. """ + from loguru import logger import os import sys +# Version comes from the git tag via setuptools-scm, which writes the +# resolved value into the generated ``_version.py`` at build/install time. +# Fall back to importlib.metadata (installed dist) and finally a sentinel so +# importing from an uninstalled source tree without a build never crashes. try: - from importlib.metadata import PackageNotFoundError, version + from ._version import version as __version__ +except ImportError: # no generated file (uninstalled source tree) + try: + from importlib.metadata import PackageNotFoundError, version - __version__ = version("PycroFlow") -except (ImportError, PackageNotFoundError): # not installed (e.g. source tree) - __version__ = "0.0.0" + __version__ = version("PycroFlow") + except (ImportError, PackageNotFoundError): + __version__ = "0.0.0" # loguru auto-installs a DEBUG->stderr handler (id 0) on import. Drop it so @@ -43,7 +51,7 @@ def log_filter(record): """Exclude subpackage logs (pyHamilton, monet) from the main log file.""" - subpackages = ['pyHamilton', 'monet'] + subpackages = ["pyHamilton", "monet"] if any(sp in record["name"] for sp in subpackages): return False return True @@ -59,7 +67,7 @@ def logging_configured(): return _LOGGING_CONFIGURED -def clean_old_logs(prefix='pycroflow.log', directory='.'): +def clean_old_logs(prefix="pycroflow.log", directory="."): """Delete rotated log files matching ``prefix`` in ``directory``. Previously called ``rem_old_logfiles`` and run at import time, which @@ -78,8 +86,12 @@ def clean_old_logs(prefix='pycroflow.log', directory='.'): pass -def setup_logging(logfile='pycroflow.log', clean_old=False, - stderr_level='ERROR', hamilton_logfile='hamilton.log'): +def setup_logging( + logfile="pycroflow.log", + clean_old=False, + stderr_level="ERROR", + hamilton_logfile="hamilton.log", +): """Configure loguru sinks for PycroFlow. Three sinks are installed: diff --git a/PycroFlow/configs/__init__.py b/PycroFlow/configs/__init__.py index 289faa8..2a37fb4 100644 --- a/PycroFlow/configs/__init__.py +++ b/PycroFlow/configs/__init__.py @@ -8,21 +8,21 @@ The package data is included via ``[tool.setuptools.package-data]`` in ``pyproject.toml``. """ + import copy from pathlib import Path import yaml - _CONFIG_DIR = Path(__file__).resolve().parent -_SETUP_DIR = _CONFIG_DIR / 'setups' +_SETUP_DIR = _CONFIG_DIR / "setups" def _resolve(path_or_name, suffix, base=None): """Accept either a bare name ('default') or a path; return a Path.""" p = Path(path_or_name) - if p.suffix == '': - p = (base or _CONFIG_DIR) / f'{path_or_name}{suffix}' + if p.suffix == "": + p = (base or _CONFIG_DIR) / f"{path_or_name}{suffix}" return p @@ -35,22 +35,22 @@ def _records_to_tubing(records): """ result = {} for record in records: - result[(record['from'], record['to'])] = record['volume'] + result[(record["from"], record["to"])] = record["volume"] return result -def load_legacy_system(name='legacy_system'): +def load_legacy_system(name="legacy_system"): """Load a legacy system config YAML and return the parsed dict. ``name`` may be either a basename (e.g. ``'legacy_system'``) found in :mod:`PycroFlow.configs`, or an absolute / relative path to a YAML file. """ - path = _resolve(name, '.yaml') + path = _resolve(name, ".yaml") with open(path) as f: return yaml.safe_load(f) -def load_legacy_tubing(name='legacy_tubing'): +def load_legacy_tubing(name="legacy_tubing"): """Load a legacy tubing config and convert list-of-records to tuple-keyed dict, matching the original in-source dict shape. @@ -62,7 +62,7 @@ def load_legacy_tubing(name='legacy_tubing'): which round-trips to ``{('R21', 'pump_a'): 365, ...}``. """ - path = _resolve(name, '.yaml') + path = _resolve(name, ".yaml") with open(path) as f: records = yaml.safe_load(f) return _records_to_tubing(records) @@ -70,6 +70,7 @@ def load_legacy_tubing(name='legacy_tubing'): # --- Per-microscope setup (hardware) configs ----------------------------- + def list_setups(): """Return the names of the available setup configs. @@ -81,7 +82,7 @@ def list_setups(): """ if not _SETUP_DIR.is_dir(): return [] - return sorted(p.stem for p in _SETUP_DIR.glob('*.yaml')) + return sorted(p.stem for p in _SETUP_DIR.glob("*.yaml")) def load_setup(name): @@ -101,11 +102,11 @@ def load_setup(name): dict The parsed setup with ``tubing`` converted to a tuple-keyed dict. """ - path = _resolve(name, '.yaml', base=_SETUP_DIR) + path = _resolve(name, ".yaml", base=_SETUP_DIR) with open(path) as f: setup = yaml.safe_load(f) - if isinstance(setup.get('tubing'), list): - setup['tubing'] = _records_to_tubing(setup['tubing']) + if isinstance(setup.get("tubing"), list): + setup["tubing"] = _records_to_tubing(setup["tubing"]) return setup @@ -135,14 +136,14 @@ def assemble_hamilton_config(setup, fluid_settings): ``(hamilton_config, tubing_config)`` ready for ``LegacyArchitecture(hamilton_config, tubing_config)``. """ - hamilton = copy.deepcopy(setup['hamilton']) - manifold = hamilton.pop('reservoir_a_manifold', []) - by_id = {entry['id']: entry for entry in manifold} + hamilton = copy.deepcopy(setup["hamilton"]) + manifold = hamilton.pop("reservoir_a_manifold", []) + by_id = {entry["id"]: entry for entry in manifold} - special_names = dict(fluid_settings.get('special_names', {})) - cleaning = fluid_settings.get('cleaning_reservoirs', []) or [] + special_names = dict(fluid_settings.get("special_names", {})) + cleaning = fluid_settings.get("cleaning_reservoirs", []) or [] - used_ids = list(fluid_settings.get('reservoir_names', {}).keys()) + used_ids = list(fluid_settings.get("reservoir_names", {}).keys()) for res in cleaning: if isinstance(res, int): rid = res @@ -151,7 +152,8 @@ def assemble_hamilton_config(setup, fluid_settings): if rid is None: raise KeyError( "Cleaning reservoir {!r} is neither an int id nor a " - "name in special_names {}".format(res, special_names)) + "name in special_names {}".format(res, special_names) + ) if rid not in used_ids: used_ids.append(rid) @@ -160,14 +162,14 @@ def assemble_hamilton_config(setup, fluid_settings): if rid not in by_id: raise KeyError( "Reservoir id {!r} is not wired in setup " - "{!r}'s reservoir_a_manifold".format( - rid, setup.get('setup'))) + "{!r}'s reservoir_a_manifold".format(rid, setup.get("setup")) + ) reservoir_a.append(by_id[rid]) - hamilton['reservoir_a'] = reservoir_a - hamilton['special_names'] = special_names - hamilton['cleaning_reservoirs'] = cleaning - return hamilton, setup.get('tubing', {}) + hamilton["reservoir_a"] = reservoir_a + hamilton["special_names"] = special_names + hamilton["cleaning_reservoirs"] = cleaning + return hamilton, setup.get("tubing", {}) def assemble_imaging_config(setup, design): @@ -188,11 +190,11 @@ def assemble_imaging_config(setup, design): dict Config for :class:`PycroFlow.imaging.ImagingSystem`. """ - cfg = copy.deepcopy(setup.get('imaging', {})) - cfg.setdefault('save_dir', design.get('save_dir', '.')) - cfg['base_name'] = design.get('base_name', 'experiment') - img = design.get('img', {}) - settings = img.get('settings', {}) if isinstance(img, dict) else {} - if 'use_positions' in settings: - cfg['use_positions'] = settings['use_positions'] + cfg = copy.deepcopy(setup.get("imaging", {})) + cfg.setdefault("save_dir", design.get("save_dir", ".")) + cfg["base_name"] = design.get("base_name", "experiment") + img = design.get("img", {}) + settings = img.get("settings", {}) if isinstance(img, dict) else {} + if "use_positions" in settings: + cfg["use_positions"] = settings["use_positions"] return cfg diff --git a/PycroFlow/examples/demo_protocols.py b/PycroFlow/examples/demo_protocols.py index 8453e71..90378d8 100644 --- a/PycroFlow/examples/demo_protocols.py +++ b/PycroFlow/examples/demo_protocols.py @@ -13,44 +13,56 @@ """ protocol_fluid = [ - {'$type': 'inject', 'reservoir_id': 0, 'volume': 500, 'wait_time': 1}, - {'$type': 'incubate', 'duration': 120}, - {'$type': 'inject', 'reservoir_id': 1, 'volume': 500, 'velocity': 600, 'wait_time': 1}, - {'target': 'fluid', '$type': 'signal', 'value': 'fluid round 1 done'}, - {'$type': 'flush', 'flushfactor': 1}, - {'$type': 'wait for signal', 'target': 'img', 'value': 'round 1 done'}, - {'$type': 'inject', 'reservoir_id': 14, 'volume': 500, 'wait_time': 1}, + {"$type": "inject", "reservoir_id": 0, "volume": 500, "wait_time": 1}, + {"$type": "incubate", "duration": 120}, + { + "$type": "inject", + "reservoir_id": 1, + "volume": 500, + "velocity": 600, + "wait_time": 1, + }, + {"target": "fluid", "$type": "signal", "value": "fluid round 1 done"}, + {"$type": "flush", "flushfactor": 1}, + {"$type": "wait for signal", "target": "img", "value": "round 1 done"}, + {"$type": "inject", "reservoir_id": 14, "volume": 500, "wait_time": 1}, ] protocol_imaging = [ - {'$type': 'wait for signal', 'target': 'fluid', 'value': 'round 1 done'}, - {'$type': 'acquire', 'frames': 100, 't_exp': 100, 'round': 1, 'message': 'R3'}, - {'$type': 'signal', 'value': 'imaging round 1 done'}, + {"$type": "wait for signal", "target": "fluid", "value": "round 1 done"}, + { + "$type": "acquire", + "frames": 100, + "t_exp": 100, + "round": 1, + "message": "R3", + }, + {"$type": "signal", "value": "imaging round 1 done"}, ] protocol_illumination = [ - {'$type': 'power', 'value': 1}, - {'$type': 'wait for signal', 'target': 'fluid', 'value': 'round 1 done'}, - {'$type': 'power', 'value': 50}, - {'$type': 'wait for signal', 'target': 'img', 'value': 'round 1 done'}, + {"$type": "power", "value": 1}, + {"$type": "wait for signal", "target": "fluid", "value": "round 1 done"}, + {"$type": "power", "value": 50}, + {"$type": "wait for signal", "target": "img", "value": "round 1 done"}, ] protocol = { - 'fluid': { - 'parameters': { - 'start_velocity': 50, - 'max_velocity': 1000, - 'stop_velocity': 500, - 'mode': 'tubing_stack', # or 'tubing_flush' - 'extractionfactor': 1, + "fluid": { + "parameters": { + "start_velocity": 50, + "max_velocity": 1000, + "stop_velocity": 500, + "mode": "tubing_stack", # or 'tubing_flush' + "extractionfactor": 1, }, - 'protocol_entries': protocol_fluid, + "protocol_entries": protocol_fluid, }, - 'img': { - 'protocol_entries': protocol_imaging, + "img": { + "protocol_entries": protocol_imaging, }, - 'illu': { - 'protocol_entries': protocol_illumination, + "illu": { + "protocol_entries": protocol_illumination, }, } @@ -59,24 +71,24 @@ # form generated by the upper system from an aggregated Exchange/MERPAINT # protocol. Kept for reference / legacy callers. hamilton_flat_protocol = { - 'parameters': { - 'start_velocity': 50, - 'max_velocity': 3000, - 'stop_velocity': 500, - 'mode': 'tubing_stack', # or 'tubing_flush' - 'extractionfactor': 1, + "parameters": { + "start_velocity": 50, + "max_velocity": 3000, + "stop_velocity": 500, + "mode": "tubing_stack", # or 'tubing_flush' + "extractionfactor": 1, }, - 'imaging': { - 'frames': 30000, - 't_exp': 100, + "imaging": { + "frames": 30000, + "t_exp": 100, }, - 'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 0, 'volume': 500}, - {'$type': 'incubate', 'duration': 120}, - {'$type': 'inject', 'reservoir_id': 1, 'volume': 500, 'velocity': 600}, - {'$type': 'acquire', 'frames': 10000, 't_exp': 100, 'round': 1}, - {'$type': 'flush', 'flushfactor': 1}, - {'$type': 'await_acquisition'}, - {'$type': 'inject', 'reservoir_id': 14, 'volume': 500}, + "protocol_entries": [ + {"$type": "inject", "reservoir_id": 0, "volume": 500}, + {"$type": "incubate", "duration": 120}, + {"$type": "inject", "reservoir_id": 1, "volume": 500, "velocity": 600}, + {"$type": "acquire", "frames": 10000, "t_exp": 100, "round": 1}, + {"$type": "flush", "flushfactor": 1}, + {"$type": "await_acquisition"}, + {"$type": "inject", "reservoir_id": 14, "volume": 500}, ], } diff --git a/PycroFlow/fluid/__init__.py b/PycroFlow/fluid/__init__.py index 6e5720b..97ec754 100644 --- a/PycroFlow/fluid/__init__.py +++ b/PycroFlow/fluid/__init__.py @@ -9,6 +9,7 @@ Back-compat: ``import PycroFlow.hamilton_architecture as ha`` still works via the shim at ``PycroFlow/hamilton_architecture.py``. """ + from PycroFlow.fluid.legacy import LegacyArchitecture __all__ = ["LegacyArchitecture"] diff --git a/PycroFlow/fluid/legacy.py b/PycroFlow/fluid/legacy.py index 980072c..4b6cc3f 100644 --- a/PycroFlow/fluid/legacy.py +++ b/PycroFlow/fluid/legacy.py @@ -912,7 +912,10 @@ def execute_protocol_entry(self, i): est = self._estimate_entry_duration(self.protocol[i]) if est: self._step_estimate = ( - time.time(), est, self.protocol[i].get("$type")) + time.time(), + est, + self.protocol[i].get("$type"), + ) try: if self.parameters["mode"] == "tubing_stack": if (self.last_protocol_entry != i - 1) or (i == 0): @@ -964,7 +967,8 @@ def _estimate_entry_duration(self, pentry): return None vol = pentry.get("volume") velocity = pentry.get("velocity") or self.parameters.get( - "max_velocity") + "max_velocity" + ) if not vol or not velocity: return None seconds = 120.0 * float(vol) / float(velocity) # pickup + dispense diff --git a/PycroFlow/fluid/wet_tests.py b/PycroFlow/fluid/wet_tests.py index eac5385..4e9f90a 100644 --- a/PycroFlow/fluid/wet_tests.py +++ b/PycroFlow/fluid/wet_tests.py @@ -5,6 +5,7 @@ function bodies still live with the class. A follow-up cleanup will extract them here in full. """ + from PycroFlow.fluid.legacy import ( prep_legacy_wettest, do_legacy_wettest, diff --git a/PycroFlow/gui/__init__.py b/PycroFlow/gui/__init__.py index b694c96..75efc66 100644 --- a/PycroFlow/gui/__init__.py +++ b/PycroFlow/gui/__init__.py @@ -15,4 +15,5 @@ def main(argv=None): """Lazy entry point — defers the PyQt6 import to call time.""" from PycroFlow.gui.app import main as _main + return _main(argv) diff --git a/PycroFlow/gui/__main__.py b/PycroFlow/gui/__main__.py index b6f87a9..b4a3ef7 100644 --- a/PycroFlow/gui/__main__.py +++ b/PycroFlow/gui/__main__.py @@ -1,5 +1,6 @@ """Allow ``python -m PycroFlow.gui``.""" + from PycroFlow.gui.app import main -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/PycroFlow/gui/app.py b/PycroFlow/gui/app.py index 2f0cf8d..fb66af3 100644 --- a/PycroFlow/gui/app.py +++ b/PycroFlow/gui/app.py @@ -4,6 +4,7 @@ Micro-Manager Core (so the embedded monet tab and PycroFlow imaging use one connection in one process), and runs the Qt event loop. """ + import sys import PycroFlow @@ -16,7 +17,8 @@ def _require_pyqt6(): except ImportError: sys.stderr.write( "PyQt6 is required for the PycroFlow GUI but is not installed.\n" - "Install it with: pip install -e \".[gui]\"\n") + 'Install it with: pip install -e ".[gui]"\n' + ) raise SystemExit(2) @@ -49,5 +51,5 @@ def main(argv=None): return app.exec() -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/PycroFlow/gui/main_window.py b/PycroFlow/gui/main_window.py index 0c5f10c..55b02c3 100644 --- a/PycroFlow/gui/main_window.py +++ b/PycroFlow/gui/main_window.py @@ -14,9 +14,16 @@ Owns the :class:`QtBridge` that marshals ExperimentService observer callbacks onto the GUI thread. """ + from PyQt6.QtWidgets import ( - QMainWindow, QTabWidget, QToolBar, QLabel, QComboBox, QMessageBox, + QMainWindow, + QTabWidget, + QToolBar, + QLabel, + QComboBox, + QMessageBox, ) + # Qt6 moved QAction out of QtWidgets into QtGui. from PyQt6.QtGui import QAction @@ -30,7 +37,6 @@ from PycroFlow.gui.tabs.imaging_tab import ImagingTab from PycroFlow.gui.tabs.monet_tab import MonetTab - # Experiment states during which hardware must not be touched manually (the # orchestrator owns the instruments). _RUN_LOCK_STATES = { @@ -50,6 +56,7 @@ def __init__(self, experiment_service, system_service, parent=None): self._connecting = set() from PycroFlow import __version__ + self.setWindowTitle("PycroFlow {}".format(__version__)) self._build_toolbar() self._build_tabs() @@ -84,15 +91,19 @@ def _build_tabs(self): on_translated=self._on_translated, on_design_loaded=self._on_design_changed, reservoir_ids_provider=self._system_service.reservoir_ids, - laser_options_provider=self._system_service.laser_options) + laser_options_provider=self._system_service.laser_options, + ) self.run_sequence_tab = ExperimentTab( - self._experiment_service, self._bridge) + self._experiment_service, self._bridge + ) self.fluid_tab = FluidTab( self._system_service, - on_connect=lambda: self._connect_system('fluid')) + on_connect=lambda: self._connect_system("fluid"), + ) self.imaging_tab = ImagingTab( self._system_service, - on_connect=lambda: self._connect_system('imaging')) + on_connect=lambda: self._connect_system("imaging"), + ) self.monet_tab = MonetTab() self.tabs.addTab(self.design_tab, "Experiment Design") @@ -165,7 +176,7 @@ def _lock_hardware(self, locked): def _autoconnect(self): if self._system_service.setup is None: return - for key in ('illumination', 'imaging', 'fluid'): + for key in ("illumination", "imaging", "fluid"): if not self._is_connected(key): self._connect_system(key, warn_missing=False) @@ -180,9 +191,10 @@ def _reconnect_all(self): """ if self._system_service.setup is None: QMessageBox.warning( - self, "No setup", "Select a microscope setup first.") + self, "No setup", "Select a microscope setup first." + ) return - for key in ('illumination', 'imaging', 'fluid'): + for key in ("illumination", "imaging", "fluid"): self._connect_system(key, warn_missing=False) def _disconnect_all(self): @@ -195,7 +207,8 @@ def _connect_system(self, key, warn_missing=True): if self._system_service.setup is None: if warn_missing: QMessageBox.warning( - self, "No setup", "Select a microscope setup first.") + self, "No setup", "Select a microscope setup first." + ) return if key in self._connecting: return @@ -218,13 +231,15 @@ def err(exc): self._connecting.discard(key) self._refresh_status() QMessageBox.critical( - self, "Connection failed", - "Could not connect the {} system:\n\n{!r}".format(key, exc)) + self, + "Connection failed", + "Could not connect the {} system:\n\n{!r}".format(key, exc), + ) # Imaging touches the Micro-Manager Core (pycromanager/ZMQ): keep it # on the GUI thread. Fluid (serial) and illumination run in the # background so the UI stays responsive. - if key == 'imaging': + if key == "imaging": try: call() except Exception as exc: @@ -236,40 +251,42 @@ def err(exc): def _connect_call(self, key, warn_missing): svc = self._system_service - if key == 'fluid': + if key == "fluid": design = self._experiment_service.experiment_design - fluid = (design or {}).get('fluid') - if not fluid or not fluid.get('settings'): + fluid = (design or {}).get("fluid") + if not fluid or not fluid.get("settings"): if warn_missing: QMessageBox.warning( - self, "No experiment design", + self, + "No experiment design", "Load an experiment design first — the fluid system " - "needs its reservoir list.") + "needs its reservoir list.", + ) return None return lambda: svc.connect_fluid(fluid) - if key == 'imaging': + if key == "imaging": if svc.is_emulated(): return svc.connect_imaging design = self._experiment_service.experiment_design or {} cfg = configs.assemble_imaging_config(svc.setup, design) return lambda: svc.connect_imaging(cfg) - if key == 'illumination': + if key == "illumination": return svc.connect_illumination return None def _is_connected(self, key): return { - 'fluid': self._system_service.fluid_system, - 'imaging': self._system_service.imaging_system, - 'illumination': self._system_service.illumination_system, + "fluid": self._system_service.fluid_system, + "imaging": self._system_service.imaging_system, + "illumination": self._system_service.illumination_system, }.get(key) is not None def _set_tab_connecting(self, key): - if key == 'fluid': + if key == "fluid": self.fluid_tab.set_status_text("connecting…") - elif key == 'imaging': + elif key == "imaging": self.imaging_tab.set_status_text("connecting…") - elif key == 'illumination': + elif key == "illumination": self.monet_tab.set_illumination_status("connecting…") self._update_statusbar() @@ -277,8 +294,10 @@ def _refresh_status(self): self.fluid_tab.refresh() self.imaging_tab.refresh() self.monet_tab.set_illumination_status( - "connected" if self._is_connected('illumination') - else "not connected") + "connected" + if self._is_connected("illumination") + else "not connected" + ) self._update_statusbar() def _status_word(self, key): @@ -294,8 +313,11 @@ def _update_statusbar(self): parts = ["Setup: {}".format(self.setup_combo.currentText())] if self._system_service.is_emulated(): parts[0] += " (emulated)" - for key, label in (('fluid', 'Fluid'), ('imaging', 'Imaging'), - ('illumination', 'Illumination')): + for key, label in ( + ("fluid", "Fluid"), + ("imaging", "Imaging"), + ("illumination", "Illumination"), + ): parts.append("{}: {}".format(label, self._status_word(key))) self.status_label.setText(" ".join(parts)) diff --git a/PycroFlow/gui/qt_bridge.py b/PycroFlow/gui/qt_bridge.py index 3421f09..9cf6ab5 100644 --- a/PycroFlow/gui/qt_bridge.py +++ b/PycroFlow/gui/qt_bridge.py @@ -11,6 +11,7 @@ Tabs/widgets connect to :class:`QtBridge` signals instead of registering service observers directly, so no widget ever runs on a worker thread. """ + from PyQt6.QtCore import QObject, pyqtSignal from PycroFlow.services.experiment_service import ExperimentState diff --git a/PycroFlow/gui/tabs/experiment_design_tab.py b/PycroFlow/gui/tabs/experiment_design_tab.py index 4a75cb8..d8d0a85 100644 --- a/PycroFlow/gui/tabs/experiment_design_tab.py +++ b/PycroFlow/gui/tabs/experiment_design_tab.py @@ -5,14 +5,25 @@ target / RESI rounds), and **Translate** it into the Run Sequence tab via :meth:`PycroFlow.services.ExperimentService.translate`. """ + import os import yaml from PyQt6.QtCore import QTimer from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, - QFileDialog, QMessageBox, QAbstractButton, QCheckBox, QComboBox, - QLineEdit, QAbstractSpinBox, + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QFileDialog, + QMessageBox, + QAbstractButton, + QCheckBox, + QComboBox, + QLineEdit, + QAbstractSpinBox, ) from PycroFlow.schemas.experiment_design import ExperimentDesign @@ -21,9 +32,15 @@ class ExperimentDesignTab(YamlDropMixin, QWidget): - def __init__(self, experiment_service, on_translated=None, - on_design_loaded=None, reservoir_ids_provider=None, - laser_options_provider=None, parent=None): + def __init__( + self, + experiment_service, + on_translated=None, + on_design_loaded=None, + reservoir_ids_provider=None, + laser_options_provider=None, + parent=None, + ): super().__init__(parent) self._svc = experiment_service self._on_translated = on_translated @@ -46,8 +63,12 @@ def _build_ui(self): self.save_btn = QPushButton("Save…") self.clear_btn = QPushButton("Clear") self.translate_btn = QPushButton("Translate → Run Sequence") - for b in (self.load_btn, self.save_btn, self.clear_btn, - self.translate_btn): + for b in ( + self.load_btn, + self.save_btn, + self.clear_btn, + self.translate_btn, + ): controls.addWidget(b) # Estimated run time, recomputed live from the current (unsaved) # design whenever a field changes — see _connect_estimate_signals. @@ -78,7 +99,8 @@ def _build_ui(self): def _set_form(self, data): self._form = SchemaForm( - ExperimentDesign, data, context=self._editor_context(data)) + ExperimentDesign, data, context=self._editor_context(data) + ) self.scroll.setWidget(self._form) self._wire_save_dir_hint() self._connect_estimate_signals() @@ -93,12 +115,12 @@ def _editor_context(self, data): reservoir-id inputs, and ``lasers`` (from the setup's monet config) the laser dropdown (both snapshot at form-build time). """ - settings = ((data or {}).get('fluid', {}) or {}).get('settings', {}) - names = list((settings.get('reservoir_names') or {}).values()) + settings = ((data or {}).get("fluid", {}) or {}).get("settings", {}) + names = list((settings.get("reservoir_names") or {}).values()) return { - 'reservoir_names': names, - 'reservoir_ids': self._call_provider(self._reservoir_ids_provider), - 'lasers': self._call_provider(self._laser_options_provider), + "reservoir_names": names, + "reservoir_ids": self._call_provider(self._reservoir_ids_provider), + "lasers": self._call_provider(self._laser_options_provider), } @staticmethod @@ -120,7 +142,7 @@ def _wire_save_dir_hint(self): already-absolute path. """ self._save_dir_hint = None - editor = self._form.field_editor('save_dir') + editor = self._form.field_editor("save_dir") line = editor.line_edit() if editor is not None else None if line is None: return @@ -133,7 +155,7 @@ def update(text): if os.path.isabs(text): hint.setText("") else: - hint.setText("→ {}".format(os.path.abspath(text or '.'))) + hint.setText("→ {}".format(os.path.abspath(text or "."))) line.textChanged.connect(update) update(line.text()) @@ -142,15 +164,18 @@ def update(text): def _on_load(self): path, _ = QFileDialog.getOpenFileName( - self, "Load experiment design", "", "YAML files (*.yaml *.yml)") + self, "Load experiment design", "", "YAML files (*.yaml *.yml)" + ) if path: self.load_design_path(path) def _on_clear(self): reply = QMessageBox.question( - self, "Clear experiment design", + self, + "Clear experiment design", "Discard the current experiment design and reset the editor to " - "an empty design? Unsaved changes will be lost.") + "an empty design? Unsaved changes will be lost.", + ) if reply == QMessageBox.StandardButton.Yes: self._svc.clear_design() self._set_form({}) @@ -161,7 +186,8 @@ def load_design_path(self, path): self._svc.load_experiment_design(path) except Exception as exc: QMessageBox.critical( - self, "Invalid experiment design", "{}".format(exc)) + self, "Invalid experiment design", "{}".format(exc) + ) return self._set_form(self._svc.experiment_design) if self._on_design_loaded is not None: @@ -175,23 +201,23 @@ def _on_save(self): model = self._form.to_model() except Exception as exc: QMessageBox.warning( - self, "Cannot save — invalid design", "{}".format(exc)) + self, "Cannot save — invalid design", "{}".format(exc) + ) return path, _ = QFileDialog.getSaveFileName( - self, "Save experiment design", "", "YAML files (*.yaml *.yml)") + self, "Save experiment design", "", "YAML files (*.yaml *.yml)" + ) if not path: return with open(path, "w") as f: - yaml.safe_dump( - model.model_dump(by_alias=True), f, sort_keys=False) + yaml.safe_dump(model.model_dump(by_alias=True), f, sort_keys=False) def _on_translate(self): try: self._svc.load_experiment_design(self._form.to_dict()) self._svc.translate() except Exception as exc: - QMessageBox.critical( - self, "Translation failed", "{}".format(exc)) + QMessageBox.critical(self, "Translation failed", "{}".format(exc)) return self._schedule_estimate() if self._on_translated is not None: @@ -215,7 +241,7 @@ def _connect_estimate_signals(self): if self._form is None: return for w in [self._form] + self._form.findChildren(QWidget): - if w.property('_estimate_hooked'): + if w.property("_estimate_hooked"): continue hooked = True if isinstance(w, QAbstractSpinBox): @@ -233,7 +259,7 @@ def _connect_estimate_signals(self): else: hooked = False if hooked: - w.setProperty('_estimate_hooked', True) + w.setProperty("_estimate_hooked", True) def _recompute_estimate(self): """Compile the current design and show its estimated run time. @@ -245,17 +271,22 @@ def _recompute_estimate(self): """ from PycroFlow.protocols import ProtocolBuilder from PycroFlow.protocols.timing import ( - estimate_total_duration, format_duration) + estimate_total_duration, + format_duration, + ) from PycroFlow.schemas import validate_experiment_design + # New list/dict rows may have appeared since the last hook pass. self._connect_estimate_signals() try: design = validate_experiment_design( - self._form.to_dict()).model_dump(by_alias=True) + self._form.to_dict() + ).model_dump(by_alias=True) protocol = ProtocolBuilder().build_protocol(design) total = estimate_total_duration(protocol) except Exception: self.estimate_label.setText("Estimated duration: — (incomplete)") return self.estimate_label.setText( - "Estimated duration: ~{}".format(format_duration(total))) + "Estimated duration: ~{}".format(format_duration(total)) + ) diff --git a/PycroFlow/gui/tabs/experiment_tab.py b/PycroFlow/gui/tabs/experiment_tab.py index de37dc8..8126128 100644 --- a/PycroFlow/gui/tabs/experiment_tab.py +++ b/PycroFlow/gui/tabs/experiment_tab.py @@ -10,54 +10,86 @@ step's parameters in the editable box below, labelled with which list it came from. """ + import ast import time from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QColor, QBrush from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QPushButton, QLabel, - QListWidget, QPlainTextEdit, QFileDialog, QGroupBox, QTableWidget, - QTableWidgetItem, QMessageBox, QProgressBar, QAbstractItemView, + QWidget, + QVBoxLayout, + QHBoxLayout, + QGridLayout, + QPushButton, + QLabel, + QListWidget, + QPlainTextEdit, + QFileDialog, + QGroupBox, + QTableWidget, + QTableWidgetItem, + QMessageBox, + QProgressBar, + QAbstractItemView, ) from PycroFlow.services.experiment_service import ExperimentState from PycroFlow.gui.widgets.dnd import YamlDropMixin from PycroFlow.protocols.timing import ( - estimate_durations, estimate_total_duration, estimate_remaining, - format_duration) - + estimate_durations, + estimate_total_duration, + estimate_remaining, + format_duration, +) # The subsystems, in display order. -_SYSTEMS = ('fluid', 'img', 'illu') -_SYSTEM_LABELS = {'fluid': 'Fluid', 'img': 'Imaging', 'illu': 'Illumination'} +_SYSTEMS = ("fluid", "img", "illu") +_SYSTEM_LABELS = {"fluid": "Fluid", "img": "Imaging", "illu": "Illumination"} # Scroll a list so the target row sits in the middle of the viewport. _CENTER = QAbstractItemView.ScrollHint.PositionAtCenter # States during which we poll the orchestrator for live progress. -_ACTIVE_STATES = {ExperimentState.ORCHESTRATING, ExperimentState.RUNNING, - ExperimentState.PAUSED} +_ACTIVE_STATES = { + ExperimentState.ORCHESTRATING, + ExperimentState.RUNNING, + ExperimentState.PAUSED, +} # Which run controls are enabled in each experiment state. Start is also # available after a run finished/aborted, to launch a fresh run of the same # loaded sequence (the service rebuilds the orchestrator for it). -_CAN_START = {ExperimentState.LOADED, ExperimentState.ORCHESTRATING, - ExperimentState.PAUSED, ExperimentState.FINISHED, - ExperimentState.ABORTED} -_CAN_ABORT = {ExperimentState.ORCHESTRATING, ExperimentState.RUNNING, - ExperimentState.PAUSED} +_CAN_START = { + ExperimentState.LOADED, + ExperimentState.ORCHESTRATING, + ExperimentState.PAUSED, + ExperimentState.FINISHED, + ExperimentState.ABORTED, +} +_CAN_ABORT = { + ExperimentState.ORCHESTRATING, + ExperimentState.RUNNING, + ExperimentState.PAUSED, +} # Loading a new run sequence is only allowed when nothing is running. -_CAN_LOAD = {ExperimentState.IDLE, ExperimentState.LOADED, - ExperimentState.FINISHED, ExperimentState.ABORTED} +_CAN_LOAD = { + ExperimentState.IDLE, + ExperimentState.LOADED, + ExperimentState.FINISHED, + ExperimentState.ABORTED, +} # Clearing the loaded run sequence is allowed when one is loaded but not # running (nothing to clear in IDLE). -_CAN_CLEAR = {ExperimentState.LOADED, ExperimentState.FINISHED, - ExperimentState.ABORTED} +_CAN_CLEAR = { + ExperimentState.LOADED, + ExperimentState.FINISHED, + ExperimentState.ABORTED, +} # Step-list shading. -_FINISHED_COLOR = QColor("#e8f5e9") # light green — completed -_ACTIVE_COLOR = QColor("#fff59d") # amber — currently executing +_FINISHED_COLOR = QColor("#e8f5e9") # light green — completed +_ACTIVE_COLOR = QColor("#fff59d") # amber — currently executing class _Stopwatch: @@ -148,8 +180,13 @@ def _build_ui(self): # One button toggles Pause/Resume depending on the run state. self.pause_resume_btn = QPushButton("Pause") self.abort_btn = QPushButton("Abort") - for b in (self.load_btn, self.clear_btn, self.start_btn, - self.pause_resume_btn, self.abort_btn): + for b in ( + self.load_btn, + self.clear_btn, + self.start_btn, + self.pause_resume_btn, + self.abort_btn, + ): controls.addWidget(b) controls.addStretch() layout.addLayout(controls) @@ -177,10 +214,12 @@ def _build_ui(self): # only stretching column, so all bars share width and right edge), # column 2 = the right-aligned current/total count. self.overall_bar, self.overall_count, _ = self._add_bar( - prog_grid, 1, "Overall") + prog_grid, 1, "Overall" + ) # Steps performed within the round currently being executed. self.current_round_bar, self.current_round_count, _ = self._add_bar( - prog_grid, 2, "Steps in Round") + prog_grid, 2, "Steps in Round" + ) # Round counter + per-subsystem step status on one line. self.step_status = QLabel("—") self.step_status.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -191,7 +230,8 @@ def _build_ui(self): self.substep_bars = {} for i, system in enumerate(_SYSTEMS): bar, count, name = self._add_bar( - prog_grid, 4 + i, _SYSTEM_LABELS[system]) + prog_grid, 4 + i, _SYSTEM_LABELS[system] + ) self.substep_bars[system] = (name, bar, count) self._set_substep_visible(system, False) layout.addWidget(prog_box) @@ -204,9 +244,12 @@ def _build_ui(self): self.center_btn = QPushButton("Center on current step") self.center_btn.clicked.connect(self._center_on_current) steps_head.addWidget(self.center_btn) - steps_head.addWidget(QLabel( - "Click a step to highlight the concurrent step in the other " - "systems.")) + steps_head.addWidget( + QLabel( + "Click a step to highlight the concurrent step in the other " + "systems." + ) + ) steps_head.addStretch() steps_layout.addLayout(steps_head) @@ -262,7 +305,8 @@ def _add_bar(grid, row, label): count = QLabel("0/0") count.setMinimumWidth(70) count.setAlignment( - Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) grid.addWidget(count, row, 2) return bar, count, name @@ -271,7 +315,8 @@ def _connect_signals(self): self._bridge.log_message.connect(self._on_log) for system, lst in self.step_lists.items(): lst.currentRowChanged.connect( - lambda row, s=system: self._on_step_selected(s, row)) + lambda row, s=system: self._on_step_selected(s, row) + ) self.apply_btn.clicked.connect(self._on_apply) self._poll_timer.timeout.connect(self._poll_progress) self.load_btn.clicked.connect(self._on_load) @@ -284,15 +329,18 @@ def _connect_signals(self): def _on_load(self): path, _ = QFileDialog.getOpenFileName( - self, "Load Run Sequence YAML", "", "YAML files (*.yaml *.yml)") + self, "Load Run Sequence YAML", "", "YAML files (*.yaml *.yml)" + ) if path: self.load_protocol_path(path) def _on_clear(self): reply = QMessageBox.question( - self, "Clear run sequence", + self, + "Clear run sequence", "Unload the current run sequence? This clears the loaded steps " - "and progress (the hardware connections stay).") + "and progress (the hardware connections stay).", + ) if reply == QMessageBox.StandardButton.Yes: self._service.clear_protocol() @@ -378,19 +426,23 @@ def _populate_steps(self): lst.clear() sub = protocol.get(system, {}) entries = ( - sub.get('protocol_entries', []) if isinstance(sub, dict) - else []) + sub.get("protocol_entries", []) + if isinstance(sub, dict) + else [] + ) self._entries[system] = list(entries) self._round_of[system] = self._round_indices(entries) for i, entry in enumerate(entries): type_ = ( - entry.get('$type', '?') if isinstance(entry, dict) - else '?') + entry.get("$type", "?") if isinstance(entry, dict) else "?" + ) lst.addItem("{:d}: {}".format(i, type_)) self._levels = self._compute_levels() self._round_names = [ - self._acquire_label(e) for e in self._entries.get('img', []) - if isinstance(e, dict) and e.get('$type') == 'acquire'] + self._acquire_label(e) + for e in self._entries.get("img", []) + if isinstance(e, dict) and e.get("$type") == "acquire" + ] self._durations = estimate_durations(protocol) self._total_duration = estimate_total_duration(protocol) self._overall_sw.reset() @@ -418,7 +470,9 @@ def _update_total_estimate_label(self): if self._total_duration > 0: self.total_estimate_label.setText( "Estimated sequence duration: ~{}".format( - format_duration(self._total_duration))) + format_duration(self._total_duration) + ) + ) else: self.total_estimate_label.setText("") @@ -434,13 +488,16 @@ def _update_time_summary(self, overall_remaining, round_remaining): self._update_total_estimate_label() return txt = "Overall: {} elapsed · ~{} left · ~{} total".format( - format_duration(elapsed), format_duration(overall_remaining), - format_duration(self._total_duration)) + format_duration(elapsed), + format_duration(overall_remaining), + format_duration(self._total_duration), + ) round_elapsed = self._round_sw.elapsed() if round_remaining > 0 or round_elapsed > 0: txt += " Round: {} elapsed · ~{} left".format( format_duration(round_elapsed), - format_duration(round_remaining)) + format_duration(round_remaining), + ) self.total_estimate_label.setText(txt) def _compute_levels(self): @@ -457,8 +514,8 @@ def _compute_levels(self): sigmap = {} for system in _SYSTEMS: for i, e in enumerate(self._entries[system]): - if isinstance(e, dict) and e.get('$type') == 'signal': - val = e.get('value') + if isinstance(e, dict) and e.get("$type") == "signal": + val = e.get("value") if val is not None and val not in sigmap: sigmap[val] = (system, i) levels = {s: [0] * len(self._entries[s]) for s in _SYSTEMS} @@ -471,9 +528,11 @@ def _compute_levels(self): entries = self._entries[system] for i, e in enumerate(entries): lvl = levels[system][i - 1] + 1 if i > 0 else 0 - if (isinstance(e, dict) - and e.get('$type') == 'wait for signal'): - dep = sigmap.get(e.get('value')) + if ( + isinstance(e, dict) + and e.get("$type") == "wait for signal" + ): + dep = sigmap.get(e.get("value")) if dep is not None: ds, di = dep lvl = max(lvl, levels[ds][di] + 1) @@ -538,10 +597,10 @@ def _is_round_marker(entry): """ if not isinstance(entry, dict): return False - type_ = entry.get('$type') - if type_ == 'acquire': + type_ = entry.get("$type") + if type_ == "acquire": return True - if type_ == 'wait for signal' and entry.get('target') == 'img': + if type_ == "wait for signal" and entry.get("target") == "img": return True return False @@ -568,7 +627,7 @@ def _poll_progress(self): self.overall_bar.setValue(pct) self.overall_count.setText("{}/{}".format(done, total)) - done_rounds, total_rounds = self._round_counts(prog.get('img')) + done_rounds, total_rounds = self._round_counts(prog.get("img")) parts = [] if total_rounds: # 1-based number of the round currently executing, plus a short @@ -577,21 +636,29 @@ def _poll_progress(self): label = "" if 0 <= current_round - 1 < len(self._round_names): label = self._round_names[current_round - 1] - parts.append("Round {}/{}{}".format( - current_round, total_rounds, - ": {}".format(label) if label else "")) + parts.append( + "Round {}/{}{}".format( + current_round, + total_rounds, + ": {}".format(label) if label else "", + ) + ) for key in _SYSTEMS: if key in prog: cur, tot = prog[key] parts.append( "{} {}/{} ({})".format( - key, cur, tot, self._step_name(key, cur))) + key, cur, tot, self._step_name(key, cur) + ) + ) self.step_status.setText(" ".join(parts)) round_remaining = self._update_current_round_bar( - prog, done_rounds, total_rounds) + prog, done_rounds, total_rounds + ) self._update_time_summary( - estimate_remaining(self._durations, prog), round_remaining) + estimate_remaining(self._durations, prog), round_remaining + ) self._update_substep_bars() self._shade_steps(prog) self._check_finished() @@ -605,8 +672,10 @@ def _check_finished(self): run controls and unlocks the hardware tabs via the usual state-change handlers. """ - if (self._service.state is ExperimentState.RUNNING - and self._service.is_finished()): + if ( + self._service.state is ExperimentState.RUNNING + and self._service.is_finished() + ): self._service.end() def _set_substep_visible(self, system, visible): @@ -615,7 +684,7 @@ def _set_substep_visible(self, system, visible): def _update_substep_bars(self): """Update the per-subsystem within-step bars (hidden when N/A).""" - getter = getattr(self._service, 'step_progress', None) + getter = getattr(self._service, "step_progress", None) sp = getter() if callable(getter) else {} if not isinstance(sp, dict): sp = {} @@ -634,7 +703,7 @@ def _update_substep_bars(self): def _substep_caption(name, cur, tot): # Imaging counts frames; the fluid steps (incubate / inject / # pump_out) are time-based and shown in seconds. - if name == 'frames': + if name == "frames": return "frames {}/{}".format(int(cur), int(tot)) return "{} {:.0f}/{:.0f} s".format(name, cur, tot) @@ -642,7 +711,7 @@ def _step_name(self, system, cur): """``$type`` of the step a subsystem is currently on (or 'done').""" entries = self._entries.get(system, []) if 0 <= cur < len(entries) and isinstance(entries[cur], dict): - return entries[cur].get('$type', '?') + return entries[cur].get("$type", "?") if entries and cur >= len(entries): return "done" return "—" @@ -656,13 +725,13 @@ def _acquire_label(entry): SPH-RESI). Falls back to the legacy ``message`` (minus its ``round_`` prefix) for protocols built before names existed. """ - name = entry.get('name') + name = entry.get("name") if name: return str(name) - msg = entry.get('message') + msg = entry.get("message") if isinstance(msg, str): - return msg[len('round_'):] if msg.startswith('round_') else msg - return '' + return msg[len("round_") :] if msg.startswith("round_") else msg + return "" def _round_counts(self, img_prog): """(completed_rounds, total_rounds) from the imaging acquisitions. @@ -671,12 +740,15 @@ def _round_counts(self, img_prog): imaging handler gives the completed-round count. """ protocol = self._service.protocol or {} - img = protocol.get('img', {}) + img = protocol.get("img", {}) entries = ( - img.get('protocol_entries', []) if isinstance(img, dict) else []) + img.get("protocol_entries", []) if isinstance(img, dict) else [] + ) acquire_idx = [ - i for i, e in enumerate(entries) - if isinstance(e, dict) and e.get('$type') == 'acquire'] + i + for i, e in enumerate(entries) + if isinstance(e, dict) and e.get("$type") == "acquire" + ] total_rounds = len(acquire_idx) if not total_rounds: return 0, 0 @@ -768,9 +840,10 @@ def _on_step_selected(self, system, row): self.apply_btn.setEnabled(False) return entry = entries[row] - type_ = entry.get('$type', '?') if isinstance(entry, dict) else '?' + type_ = entry.get("$type", "?") if isinstance(entry, dict) else "?" self.step_param_label.setText( - "{} · step {}: {}".format(_SYSTEM_LABELS[system], row, type_)) + "{} · step {}: {}".format(_SYSTEM_LABELS[system], row, type_) + ) if not isinstance(entry, dict): # Non-dict entry: show it read-only, nothing to edit. self._set_value_row(0, "value", entry, editable=False) @@ -779,12 +852,12 @@ def _on_step_selected(self, system, row): return # '$type' first (read-only — it is the schema discriminator), then # the remaining parameters in definition order. - keys = [k for k in entry if k != '$type'] - if '$type' in entry: - keys = ['$type'] + keys + keys = [k for k in entry if k != "$type"] + if "$type" in entry: + keys = ["$type"] + keys self.step_table.setRowCount(len(keys)) for r, key in enumerate(keys): - self._set_value_row(r, key, entry[key], editable=(key != '$type')) + self._set_value_row(r, key, entry[key], editable=(key != "$type")) self.apply_btn.setEnabled(True) def _on_apply(self): @@ -809,7 +882,7 @@ def _on_apply(self): if key_item is None or value_item is None: continue key = key_item.text() - if key == '$type': + if key == "$type": continue original = value_item.data(Qt.ItemDataRole.UserRole) try: @@ -818,8 +891,10 @@ def _on_apply(self): errors.append("{}: {}".format(key, exc)) if errors: QMessageBox.warning( - self, "Could not apply some values", - "These fields were left unchanged:\n\n" + "\n".join(errors)) + self, + "Could not apply some values", + "These fields were left unchanged:\n\n" + "\n".join(errors), + ) # Re-render from the stored entry so displayed text and the cached # value types reflect what was actually written. self._on_step_selected(self._current_sys, row) @@ -836,7 +911,8 @@ def _set_value_row(self, r, key, value, editable): value_item.setData(Qt.ItemDataRole.UserRole, value) if not editable: value_item.setFlags( - value_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + value_item.flags() & ~Qt.ItemFlag.ItemIsEditable + ) self.step_table.setItem(r, 1, value_item) @staticmethod @@ -858,9 +934,9 @@ def _coerce(text, original): text = text.strip() if isinstance(original, bool): # before int — bool subclasses int low = text.lower() - if low in ('true', '1', 'yes', 'on'): + if low in ("true", "1", "yes", "on"): return True - if low in ('false', '0', 'no', 'off'): + if low in ("false", "0", "no", "off"): return False raise ValueError("expected a boolean") if isinstance(original, int): diff --git a/PycroFlow/gui/tabs/fluid_tab.py b/PycroFlow/gui/tabs/fluid_tab.py index 1806ce6..e8b3dab 100644 --- a/PycroFlow/gui/tabs/fluid_tab.py +++ b/PycroFlow/gui/tabs/fluid_tab.py @@ -10,14 +10,22 @@ :class:`PycroFlow.services.system_service.SystemService` so the tab never reaches into private attributes of the fluid system. """ + from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, - QGroupBox, QFormLayout, QComboBox, QMessageBox, + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QLineEdit, + QGroupBox, + QFormLayout, + QComboBox, + QMessageBox, ) from PycroFlow.gui.widgets.worker import run_in_background - _STOP_STYLE = ( "background-color: #b00000; color: white; font-weight: bold; " "padding: 8px;" @@ -34,8 +42,11 @@ def __init__(self, system_service, on_connect=None, parent=None): # Buttons disabled while a fluid op runs in the background (the serial # bus serves one operation at a time). STOP stays enabled. self._busy_buttons = [ - self.fill_btn, self.clean_btn, self.stroke_btn, - self.move_btn, self.valve_btn, + self.fill_btn, + self.clean_btn, + self.stroke_btn, + self.move_btn, + self.valve_btn, ] def _build_ui(self): @@ -46,7 +57,8 @@ def _build_ui(self): status_row = QHBoxLayout(status_box) connected = self._svc.fluid_system is not None self.status_label = QLabel( - "connected" if connected else "not connected") + "connected" if connected else "not connected" + ) status_row.addWidget(self.status_label) status_row.addStretch() self.connect_btn = QPushButton("Connect") @@ -147,7 +159,8 @@ def refresh(self): """Update the connection label from the fluid system.""" connected = self._svc.fluid_system is not None self.status_label.setText( - "connected" if connected else "not connected") + "connected" if connected else "not connected" + ) def set_status_text(self, text): """Set the status label (e.g. 'connecting…') from the coordinator.""" @@ -175,11 +188,13 @@ def _on_fill(self): def _on_clean(self): reply = QMessageBox.question( - self, "Clean tubings", + self, + "Clean tubings", "Start the tubing cleaning procedure?\n\n" "Make sure the input and output needles are in the same " "container (fluidly connected) and cleaning reservoirs are " - "connected to their tanks.") + "connected to their tanks.", + ) if reply == QMessageBox.StandardButton.Yes: self._run(self._svc.clean_tubings, "Clean tubings") @@ -196,11 +211,13 @@ def _on_stroke(self): dispense_dir=self.stroke_dispense.currentText(), ) if vel is not None: - kwargs['velocity'] = vel + kwargs["velocity"] = vel self._run( lambda: self._svc.manual_pump( - self.stroke_pump.currentText(), **kwargs), - "Pump stroke") + self.stroke_pump.currentText(), **kwargs + ), + "Pump stroke", + ) def _on_move(self): vol, ok = self._num(self.move_vol, "Volume", True, float) @@ -210,11 +227,13 @@ def _on_move(self): if not ok: return pres, ok = self._num( - self.move_pickup_res, "Pickup reservoir", False, int) + self.move_pickup_res, "Pickup reservoir", False, int + ) if not ok: return dres, ok = self._num( - self.move_dispense_res, "Dispense reservoir", False, int) + self.move_dispense_res, "Dispense reservoir", False, int + ) if not ok: return kwargs = dict( @@ -223,15 +242,17 @@ def _on_move(self): dispense_dir=self.move_dispense_dir.currentText(), ) if vel is not None: - kwargs['velocity'] = vel + kwargs["velocity"] = vel if pres is not None: - kwargs['pickup_res'] = pres + kwargs["pickup_res"] = pres if dres is not None: - kwargs['dispense_res'] = dres + kwargs["dispense_res"] = dres self._run( lambda: self._svc.manual_pump( - self.move_pump.currentText(), **kwargs), - "Pump move") + self.move_pump.currentText(), **kwargs + ), + "Pump move", + ) def _on_set_valves(self): rid, ok = self._num(self.valve_res, "Reservoir id", True, int) @@ -252,18 +273,19 @@ def _num(self, edit, name, required, conv): that fails ``conv`` -> a warning + (None, False). """ text = edit.text().strip() - if text == '': + if text == "": if required: QMessageBox.warning( - self, "Invalid input", "{} is required.".format(name)) + self, "Invalid input", "{} is required.".format(name) + ) return None, False return None, True try: return conv(text), True except ValueError: QMessageBox.warning( - self, "Invalid input", - "{} must be a number.".format(name)) + self, "Invalid input", "{} must be a number.".format(name) + ) return None, False def _run(self, call, what): @@ -276,14 +298,17 @@ def _run(self, call, what): return self._set_busy(True) run_in_background( - self, call, + self, + call, on_done=lambda _: self._set_busy(False), - on_error=lambda exc: self._on_op_error(exc, what)) + on_error=lambda exc: self._on_op_error(exc, what), + ) def _on_op_error(self, exc, what): self._set_busy(False) QMessageBox.critical( - self, "{} failed".format(what), "{!r}".format(exc)) + self, "{} failed".format(what), "{!r}".format(exc) + ) def _set_busy(self, busy): self._busy = busy diff --git a/PycroFlow/gui/tabs/imaging_tab.py b/PycroFlow/gui/tabs/imaging_tab.py index 942fcf5..4718b44 100644 --- a/PycroFlow/gui/tabs/imaging_tab.py +++ b/PycroFlow/gui/tabs/imaging_tab.py @@ -3,8 +3,14 @@ Read-only view for now — live preview and a graphical acquisition editor are explicitly out of scope for the initial GUI (see the plan's Stage 5 notes). """ + from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QGroupBox, QFormLayout, + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QGroupBox, + QFormLayout, QPushButton, ) @@ -24,7 +30,8 @@ def _build_ui(self): status_row = QHBoxLayout() connected = self._svc.imaging_system is not None self.status_label = QLabel( - "connected" if connected else "not connected") + "connected" if connected else "not connected" + ) status_row.addWidget(self.status_label) status_row.addStretch() self.connect_btn = QPushButton("Connect") @@ -61,11 +68,12 @@ def refresh(self): """ imaging = self._svc.imaging_system self.status_label.setText( - "connected" if imaging is not None else "not connected") + "connected" if imaging is not None else "not connected" + ) if imaging is None: return # Best-effort, defensive: hardware attributes may be absent under # mocks or before the first acquisition. - pfs = getattr(imaging, 'last_pfs_status', None) + pfs = getattr(imaging, "last_pfs_status", None) if pfs is not None: self.pfs_label.setText(str(pfs)) diff --git a/PycroFlow/gui/tabs/monet_tab.py b/PycroFlow/gui/tabs/monet_tab.py index c9d119a..c935c83 100644 --- a/PycroFlow/gui/tabs/monet_tab.py +++ b/PycroFlow/gui/tabs/monet_tab.py @@ -17,6 +17,7 @@ binding (e.g. PyQt5) — constructing the latter would emit "Must construct a QApplication before a QWidget" and crash the process. """ + from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel @@ -79,7 +80,8 @@ def _embed(self, setup_name): "Install the monet sibling package to enable " "laser/illumination\n" "control here (pip install -e ../monet).\n\n" - "Detail: {}".format(problem)) + "Detail: {}".format(problem) + ) self._embed_layout.addWidget(self._placeholder) return # Embed monet's widget as a child. It does not own the QApplication @@ -109,12 +111,14 @@ def _make_monet_window(setup_name=None): class is a PyQt6 ``QWidget`` subclass *before* instantiating). """ from PyQt6.QtWidgets import QWidget + try: import monet.gui as mg except Exception as exc: return None, "import failed: {!r}".format(exc) - cls = getattr(mg, 'MonetWidget', None) or getattr( - mg, 'MonetMainWindow', None) + cls = getattr(mg, "MonetWidget", None) or getattr( + mg, "MonetMainWindow", None + ) if cls is None: return None, "monet.gui has no MonetWidget / MonetMainWindow" if not (isinstance(cls, type) and issubclass(cls, QWidget)): @@ -131,7 +135,7 @@ class is a PyQt6 ``QWidget`` subclass *before* instantiating). # Pre-select the scope in monet's own combo for convenience (display # only — does not connect). Best-effort; private API may be absent. if setup_name: - combo = getattr(window, '_scope_combo', None) + combo = getattr(window, "_scope_combo", None) if combo is not None: try: idx = combo.findText(setup_name) diff --git a/PycroFlow/gui/widgets/dnd.py b/PycroFlow/gui/widgets/dnd.py index b5b946f..c85b9bf 100644 --- a/PycroFlow/gui/widgets/dnd.py +++ b/PycroFlow/gui/widgets/dnd.py @@ -29,7 +29,7 @@ def _yaml_path(event): if len(urls) != 1: return None path = urls[0].toLocalFile() - if path.lower().endswith(('.yaml', '.yml')): + if path.lower().endswith((".yaml", ".yml")): return path return None diff --git a/PycroFlow/gui/widgets/schema_form.py b/PycroFlow/gui/widgets/schema_form.py index 9c2eb6a..4336974 100644 --- a/PycroFlow/gui/widgets/schema_form.py +++ b/PycroFlow/gui/widgets/schema_form.py @@ -11,14 +11,25 @@ ``target-rounds`` round-trip); ``to_model()`` validates it against the model and raises the schema's validation error on bad input. """ + import ast import typing from typing import Union, get_args, get_origin from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox, QLabel, - QLineEdit, QCheckBox, QComboBox, QPushButton, QGridLayout, QToolButton, + QWidget, + QVBoxLayout, + QHBoxLayout, + QFormLayout, + QGroupBox, + QLabel, + QLineEdit, + QCheckBox, + QComboBox, + QPushButton, + QGridLayout, + QToolButton, ) from pydantic import BaseModel @@ -26,7 +37,6 @@ from PycroFlow.schemas.experiment_design import field_meta - # --- type helpers -------------------------------------------------------- _NONE = type(None) @@ -88,7 +98,7 @@ def _coerce_scalar(text, ann): def _variant_label(model_cls): """The discriminator literal of a model with a ``type`` field.""" - fi = model_cls.model_fields.get('type') + fi = model_cls.model_fields.get("type") if fi is not None: args = get_args(fi.annotation) if args: @@ -98,6 +108,7 @@ def _variant_label(model_cls): # --- editor context (shared, observable dropdown options) --------------- + class FormContext: """Named dropdown option lists shared down a form tree, with live updates. @@ -109,11 +120,12 @@ class FormContext: def __init__(self, options=None): self._options = {k: list(v) for k, v in (options or {}).items()} - self._subs = {} # key -> [callback] + self._subs = {} # key -> [callback] def get(self, key, default=None): - return list(self._options.get(key, default if default is not None - else [])) + return list( + self._options.get(key, default if default is not None else []) + ) def subscribe(self, key, callback): self._subs.setdefault(key, []).append(callback) @@ -134,6 +146,7 @@ def set_options(self, key, values): # --- field editors ------------------------------------------------------- + class _ScalarEditor(QWidget): def __init__(self, ann, optional, value, parent=None): super().__init__(parent) @@ -164,7 +177,7 @@ def get_value(self): if isinstance(self._w, QCheckBox): return self._w.isChecked() text = self._w.text().strip() - if text == '': + if text == "": # Empty -> None; to_dict drops it so pydantic applies the field # default (or raises a clear 'required' error). return None @@ -180,7 +193,7 @@ def __init__(self, item_ann, value, parent=None): lay.setContentsMargins(0, 0, 0, 0) self._w = QLineEdit() if value: - self._w.setText(', '.join(str(v) for v in value)) + self._w.setText(", ".join(str(v) for v in value)) self._w.setPlaceholderText("comma-separated") lay.addWidget(self._w) @@ -188,7 +201,7 @@ def get_value(self): text = self._w.text().strip() if not text: return [] - items = [p.strip() for p in text.split(',') if p.strip()] + items = [p.strip() for p in text.split(",") if p.strip()] return [_coerce_scalar(p, self._item_ann) for p in items] @@ -208,9 +221,9 @@ def __init__(self, options, value, allow_none, ann=str, parent=None): lay.setContentsMargins(0, 0, 0, 0) self._combo = QComboBox() if allow_none: - self._combo.addItem('') # blank -> None + self._combo.addItem("") # blank -> None self._combo.addItems(opts) - cur = '' if value is None else str(value) + cur = "" if value is None else str(value) idx = self._combo.findText(cur) if idx >= 0: self._combo.setCurrentIndex(idx) @@ -228,12 +241,12 @@ def set_options(self, options): """ cur = self._combo.currentText() opts = [str(o) for o in options] - if cur not in ('', *opts): + if cur not in ("", *opts): opts.append(cur) self._combo.blockSignals(True) self._combo.clear() if self._allow_none: - self._combo.addItem('') + self._combo.addItem("") self._combo.addItems(opts) idx = self._combo.findText(cur) if idx >= 0: @@ -242,8 +255,8 @@ def set_options(self, options): def get_value(self): text = self._combo.currentText() - if text == '': - return None # to_dict drops it -> field default / required error + if text == "": + return None # to_dict drops it -> field default / required error return _coerce_scalar(text, self._ann) @@ -255,9 +268,18 @@ class _ListChoiceEditor(QGroupBox): SPH-RESI RESI-rounds. """ - def __init__(self, item_ann, value, title, choices_key=None, - static_options=None, allow_none=False, context=None, - row_label=None, parent=None): + def __init__( + self, + item_ann, + value, + title, + choices_key=None, + static_options=None, + allow_none=False, + context=None, + row_label=None, + parent=None, + ): super().__init__(title, parent) self.is_block = True self._item_ann = item_ann @@ -268,14 +290,14 @@ def __init__(self, item_ann, value, title, choices_key=None, # Optional per-row label template, numbered 1..n (e.g. # 'imager round {}'); the labels renumber on add/remove. self._row_label = row_label - self._items = [] # [(row_widget, _ChoiceEditor, label_or_None)] + self._items = [] # [(row_widget, _ChoiceEditor, label_or_None)] self._lay = QVBoxLayout(self) self._items_lay = QVBoxLayout() self._lay.addLayout(self._items_lay) add = QPushButton("Add") add.clicked.connect(lambda: self._add_item(None)) self._lay.addWidget(add) - for v in (value or []): + for v in value or []: self._add_item(v) def _options(self): @@ -294,9 +316,13 @@ def _add_item(self, val): label = QLabel() rlay.addWidget(label) ed = _ChoiceEditor( - self._options(), val, self._allow_none, self._item_ann) - if (self._choices_key and self._ctx is not None - and hasattr(self._ctx, 'subscribe')): + self._options(), val, self._allow_none, self._item_ann + ) + if ( + self._choices_key + and self._ctx is not None + and hasattr(self._ctx, "subscribe") + ): self._ctx.subscribe(self._choices_key, ed.set_options) rlay.addWidget(ed, 1) rm = QPushButton("✕") @@ -324,7 +350,7 @@ def get_value(self): out = [] for _, ed, _ in self._items: v = ed.get_value() - if v is not None and v != '': + if v is not None and v != "": out.append(v) return out @@ -339,10 +365,21 @@ class _MappingEditor(QGroupBox): a dropdown restricted to those options (e.g. the setup's reservoir ids). """ - def __init__(self, key_ann, val_ann, value, title, *, columns=None, - display_value_first=False, key_choices=None, - value_choices=None, provides=None, context=None, - parent=None): + def __init__( + self, + key_ann, + val_ann, + value, + title, + *, + columns=None, + display_value_first=False, + key_choices=None, + value_choices=None, + provides=None, + context=None, + parent=None, + ): super().__init__(title, parent) self.is_block = True self._key_ann = key_ann @@ -355,7 +392,7 @@ def __init__(self, key_ann, val_ann, value, title, *, columns=None, self._provides = provides self._ctx = context self._rows = [] - self._next_row = 0 # monotonic grid row, so removals never collide + self._next_row = 0 # monotonic grid row, so removals never collide self._lay = QVBoxLayout(self) self._grid = QGridLayout() self._lay.addLayout(self._grid) @@ -365,29 +402,30 @@ def __init__(self, key_ann, val_ann, value, title, *, columns=None, self._grid.addWidget(lbl, self._next_row, c) self._next_row += 1 add = QPushButton("Add") - add.clicked.connect(lambda: self._add_row('', '')) + add.clicked.connect(lambda: self._add_row("", "")) self._lay.addWidget(add) for k, v in (value or {}).items(): self._add_row(k, v) @staticmethod def _make_cell(val, choices): - if choices: # non-empty -> dropdown; empty/None -> free text + if choices: # non-empty -> dropdown; empty/None -> free text opts = [str(c) for c in choices] - if val not in (None, '') and str(val) not in opts: + if val not in (None, "") and str(val) not in opts: opts.append(str(val)) combo = QComboBox() combo.addItems(opts) - idx = combo.findText('' if val in (None, '') else str(val)) + idx = combo.findText("" if val in (None, "") else str(val)) if idx >= 0: combo.setCurrentIndex(idx) return combo - return QLineEdit('' if val in (None, '') else str(val)) + return QLineEdit("" if val in (None, "") else str(val)) @staticmethod def _cell_text(w): - return (w.currentText() if isinstance(w, QComboBox) - else w.text()).strip() + return ( + w.currentText() if isinstance(w, QComboBox) else w.text() + ).strip() def _add_row(self, k, v): r = self._next_row @@ -418,8 +456,7 @@ def _connect_change(self, w): def _notify(self): if self._provides and self._ctx is not None: vals = [self._cell_text(val_w) for _, val_w, _ in self._rows] - self._ctx.set_options( - self._provides, [v for v in vals if v]) + self._ctx.set_options(self._provides, [v for v in vals if v]) def _remove(self, row): for w in row: @@ -432,10 +469,11 @@ def get_value(self): out = {} for key_w, val_w, _ in self._rows: k = self._cell_text(key_w) - if k == '': + if k == "": continue out[_coerce_scalar(k, self._key_ann)] = _coerce_scalar( - self._cell_text(val_w), self._val_ann) + self._cell_text(val_w), self._val_ann + ) return out @@ -454,7 +492,7 @@ def __init__(self, item_cls, value, title, context=None, parent=None): add = QPushButton("Add item") add.clicked.connect(lambda: self._add_item({})) self._lay.addWidget(add) - for item in (value or []): + for item in value or []: self._add_item(item) def _add_item(self, data): @@ -492,7 +530,7 @@ def __init__(self, item_cls, value, title, context=None, parent=None): self._items_lay = QVBoxLayout() self._lay.addLayout(self._items_lay) add = QPushButton("Add entry") - add.clicked.connect(lambda: self._add_item('', {})) + add.clicked.connect(lambda: self._add_item("", {})) self._lay.addWidget(add) for k, v in (value or {}).items(): self._add_item(k, v) @@ -550,7 +588,7 @@ def __init__(self, variants, value, title, context=None, parent=None): self._lay.addWidget(self._holder) self._form = None - initial = (value or {}).get('type') + initial = (value or {}).get("type") if initial in self._by_label: self._combo.setCurrentText(initial) self._rebuild(value or {}) @@ -562,12 +600,13 @@ def _rebuild(self, value): cls = self._by_label[self._combo.currentText()] # The variant 'type' is the selector above, so skip it in the sub-form. self._form = SchemaForm( - cls, value, context=self._context, skip_fields={'type'}) + cls, value, context=self._context, skip_fields={"type"} + ) self._holder_lay.addWidget(self._form) def get_value(self): data = self._form.to_dict() - data['type'] = self._combo.currentText() + data["type"] = self._combo.currentText() return data @@ -581,13 +620,14 @@ def __init__(self, value, parent=None): lay.setContentsMargins(0, 0, 0, 0) self._w = QLineEdit() if value is not None: - self._w.setText(repr(value) if not isinstance(value, str) - else value) + self._w.setText( + repr(value) if not isinstance(value, str) else value + ) lay.addWidget(self._w) def get_value(self): text = self._w.text().strip() - if text == '': + if text == "": return None try: return ast.literal_eval(text) @@ -596,7 +636,7 @@ def get_value(self): def _has_choices(meta): - return 'choices' in meta or 'choices_from' in meta + return "choices" in meta or "choices_from" in meta def _make_editor(ann, optional, value, label, meta, context): @@ -613,33 +653,48 @@ def _make_editor(ann, optional, value, label, meta, context): if _has_choices(meta): # list of dropdowns (add/remove rows), e.g. Exchange imagers. return _ListChoiceEditor( - item_ann, value, meta.get('title', label), - choices_key=meta.get('choices_from'), - static_options=meta.get('choices'), - allow_none=meta.get('allow_none', False), context=context, - row_label=meta.get('row_label')) + item_ann, + value, + meta.get("title", label), + choices_key=meta.get("choices_from"), + static_options=meta.get("choices"), + allow_none=meta.get("allow_none", False), + context=context, + row_label=meta.get("row_label"), + ) return _ListScalarEditor(item_ann, value) if _is_dict(ann): kt, vt = (get_args(ann) + (str, str))[:2] if _is_model(vt): return _DictModelEditor(vt, value, label, context) return _MappingEditor( - kt, vt, value, label, - columns=meta.get('columns'), - display_value_first=meta.get('display_value_first', False), - key_choices=(context.get(meta['key_choices_from']) - if 'key_choices_from' in meta else None), - value_choices=(context.get(meta['value_choices_from']) - if 'value_choices_from' in meta else None), - provides=meta.get('provides'), context=context) + kt, + vt, + value, + label, + columns=meta.get("columns"), + display_value_first=meta.get("display_value_first", False), + key_choices=( + context.get(meta["key_choices_from"]) + if "key_choices_from" in meta + else None + ), + value_choices=( + context.get(meta["value_choices_from"]) + if "value_choices_from" in meta + else None + ), + provides=meta.get("provides"), + context=context, + ) # A scalar with a declared option set -> a single dropdown. if _has_choices(meta): - key = meta.get('choices_from') - opts = meta.get('choices') + key = meta.get("choices_from") + opts = meta.get("choices") if opts is None: opts = context.get(key, []) - editor = _ChoiceEditor(opts, value, meta.get('allow_none', False), ann) - if key is not None and hasattr(context, 'subscribe'): + editor = _ChoiceEditor(opts, value, meta.get("allow_none", False), ann) + if key is not None and hasattr(context, "subscribe"): context.subscribe(key, editor.set_options) return editor if ann in (int, float, str, bool): @@ -679,7 +734,8 @@ def __init__(self, model_cls, value, title, context=None, parent=None): def _set_expanded(self, expanded): self._toggle.setArrowType( - Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow) + Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow + ) self._form.setVisible(expanded) def get_value(self): @@ -689,8 +745,15 @@ def get_value(self): class SchemaForm(QWidget): """Editable form generated from a pydantic model class.""" - def __init__(self, model_cls, data=None, parent=None, *, context=None, - skip_fields=None): + def __init__( + self, + model_cls, + data=None, + parent=None, + *, + context=None, + skip_fields=None, + ): super().__init__(parent) self._model_cls = model_cls # context: dynamic dropdown options shared down the form tree, keyed by @@ -698,10 +761,13 @@ def __init__(self, model_cls, data=None, parent=None, *, context=None, # wrapped into a FormContext (the same instance is threaded into every # nested form, so live updates propagate). skip_fields: field # names/aliases to omit (e.g. a union's 'type', shown by the selector). - self._context = (context if isinstance(context, FormContext) - else FormContext(context or {})) + self._context = ( + context + if isinstance(context, FormContext) + else FormContext(context or {}) + ) self._skip = set(skip_fields or ()) - self._editors = {} # alias -> editor + self._editors = {} # alias -> editor data = data or {} form = QFormLayout(self) form.setContentsMargins(0, 0, 0, 0) @@ -724,17 +790,18 @@ def __init__(self, model_cls, data=None, parent=None, *, context=None, else: value = None editor = _make_editor( - ann, optional, value, alias, meta, self._context) + ann, optional, value, alias, meta, self._context + ) self._editors[alias] = editor # Show the field's physical unit (if declared) after the input. - unit = meta.get('unit') - if unit and hasattr(editor, 'add_suffix'): + unit = meta.get("unit") + if unit and hasattr(editor, "add_suffix"): hint = QLabel(unit) hint.setStyleSheet("color: gray;") editor.add_suffix(hint) - if meta.get('tooltip'): - editor.setToolTip(meta['tooltip']) - if getattr(editor, 'is_block', False): + if meta.get("tooltip"): + editor.setToolTip(meta["tooltip"]) + if getattr(editor, "is_block", False): form.addRow(editor) else: form.addRow(alias, editor) diff --git a/PycroFlow/gui/widgets/worker.py b/PycroFlow/gui/widgets/worker.py index f22183e..862e1d2 100644 --- a/PycroFlow/gui/widgets/worker.py +++ b/PycroFlow/gui/widgets/worker.py @@ -9,8 +9,8 @@ Tests can call :func:`set_synchronous(True)` to run ``fn`` inline (no thread), so assertions about the call and the callbacks hold deterministically. """ -from PyQt6.QtCore import QObject, QThread, pyqtSignal +from PyQt6.QtCore import QObject, QThread, pyqtSignal _SYNCHRONOUS = False @@ -42,7 +42,7 @@ class BackgroundTask(QObject): """A single fn() run on its own QThread, with GUI-thread callbacks.""" def __init__(self, owner, fn, on_done=None, on_error=None): - super().__init__(owner) # GUI-thread affinity (owner is a widget) + super().__init__(owner) # GUI-thread affinity (owner is a widget) self._owner = owner self._on_done = on_done self._on_error = on_error @@ -73,7 +73,7 @@ def _stop(self): self._thread.wait() def _dispose(self): - tasks = getattr(self._owner, '_bg_tasks', None) + tasks = getattr(self._owner, "_bg_tasks", None) if tasks is not None: tasks.discard(self) self.deleteLater() @@ -91,7 +91,7 @@ def run_in_background(owner, fn, on_done=None, on_error=None): if on_done is not None: on_done(result) return None - if not hasattr(owner, '_bg_tasks'): + if not hasattr(owner, "_bg_tasks"): owner._bg_tasks = set() task = BackgroundTask(owner, fn, on_done, on_error) owner._bg_tasks.add(task) diff --git a/PycroFlow/hal/__init__.py b/PycroFlow/hal/__init__.py index 0711066..8f0b086 100644 --- a/PycroFlow/hal/__init__.py +++ b/PycroFlow/hal/__init__.py @@ -15,6 +15,7 @@ fluid code (``hamilton_architecture``) to call HAL methods instead of talking to ``pyHamilton`` directly. """ + from PycroFlow.hal.pumps import Pump from PycroFlow.hal.valves import Valve from PycroFlow.hal.sensors import SpillSensor @@ -30,21 +31,25 @@ def _register_existing_implementations(): """ try: from PycroFlow.hamilton_components import Pump as _HamiltonPump + Pump.register(_HamiltonPump) except Exception: pass try: from PycroFlow.hamilton_components import Valve as _HamiltonValve + Valve.register(_HamiltonValve) except Exception: pass try: from PycroFlow.peristaltic_drifton import DriftonPump as _DriftonPump + Pump.register(_DriftonPump) except Exception: pass try: from PycroFlow.spill_sensor_arduino import ArduinoSensorInterface + SpillSensor.register(ArduinoSensorInterface) except Exception: pass diff --git a/PycroFlow/hal/pumps.py b/PycroFlow/hal/pumps.py index 8aa6f3b..7f3d087 100644 --- a/PycroFlow/hal/pumps.py +++ b/PycroFlow/hal/pumps.py @@ -5,6 +5,7 @@ :class:`PycroFlow.peristaltic_drifton.DriftonPump` (Drifton peristaltic) already match this duck-typed interface; the ABC pins the contract. """ + from __future__ import annotations import abc diff --git a/PycroFlow/hal/sensors.py b/PycroFlow/hal/sensors.py index 71739a6..eb70d6e 100644 --- a/PycroFlow/hal/sensors.py +++ b/PycroFlow/hal/sensors.py @@ -3,6 +3,7 @@ Currently only :class:`SpillSensor`; future leak detectors, pressure sensors, etc. can subclass without changing orchestration code. """ + from __future__ import annotations import abc @@ -25,7 +26,9 @@ def poll_sensor(self) -> Optional[bool]: """One-shot read. Returns True (wet) / False (dry) / None (error).""" @abc.abstractmethod - def monitor_sensor(self, fn_on_wet: Optional[Callable[[str], None]] = None) -> None: + def monitor_sensor( + self, fn_on_wet: Optional[Callable[[str], None]] = None + ) -> None: """Start a background monitoring thread that invokes ``fn_on_wet`` when a wet reading is observed.""" diff --git a/PycroFlow/hal/valves.py b/PycroFlow/hal/valves.py index 238a0f5..8861da2 100644 --- a/PycroFlow/hal/valves.py +++ b/PycroFlow/hal/valves.py @@ -4,6 +4,7 @@ directions. The existing :class:`PycroFlow.hamilton_components.Valve` (Hamilton MVP) matches this interface. """ + from __future__ import annotations import abc diff --git a/PycroFlow/hamilton_architecture.py b/PycroFlow/hamilton_architecture.py index c767746..84bac99 100644 --- a/PycroFlow/hamilton_architecture.py +++ b/PycroFlow/hamilton_architecture.py @@ -16,6 +16,7 @@ protocol-entry execution, wet tests) into sibling submodules. This shim will continue to re-export the union for back-compat. """ + from PycroFlow.fluid.legacy import * # noqa: F401, F403 # Explicit re-exports for the most commonly-referenced names, so that diff --git a/PycroFlow/mm_lock.py b/PycroFlow/mm_lock.py index 4515b0b..926ed9c 100644 --- a/PycroFlow/mm_lock.py +++ b/PycroFlow/mm_lock.py @@ -12,6 +12,7 @@ alive and auto-reclaim a stale lock left by a crashed process, only refusing when a live process genuinely holds it. """ + import atexit import os import platform @@ -43,14 +44,15 @@ def _pid_alive(pid): """ if not pid or pid <= 0: return False - if platform.system() == 'Windows': + if platform.system() == "Windows": import ctypes process_query_limited_information = 0x1000 still_active = 259 kernel32 = ctypes.windll.kernel32 handle = kernel32.OpenProcess( - process_query_limited_information, False, pid) + process_query_limited_information, False, pid + ) if not handle: # No handle: most likely the process does not exist. (A rare # access-denied would also land here; treating that as "dead" @@ -80,13 +82,13 @@ def default_lock_path(): Windows: ``%LOCALAPPDATA%\\PycroFlow\\mm.lock`` POSIX: ``~/.cache/PycroFlow/mm.lock`` """ - if platform.system() == 'Windows': - base = os.environ.get('LOCALAPPDATA') + if platform.system() == "Windows": + base = os.environ.get("LOCALAPPDATA") if not base: - base = str(Path.home() / 'AppData' / 'Local') + base = str(Path.home() / "AppData" / "Local") else: - base = os.environ.get('XDG_CACHE_HOME') or str(Path.home() / '.cache') - return Path(base) / 'PycroFlow' / 'mm.lock' + base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") + return Path(base) / "PycroFlow" / "mm.lock" class MmCoreLock: @@ -159,9 +161,10 @@ def _create(self): If the lockfile already exists (``O_EXCL``). """ fd = os.open( - str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644 + ) try: - os.write(fd, str(os.getpid()).encode('utf-8')) + os.write(fd, str(os.getpid()).encode("utf-8")) finally: os.close(fd) diff --git a/PycroFlow/orchestration/__init__.py b/PycroFlow/orchestration/__init__.py index 178e72a..a7f47b4 100644 --- a/PycroFlow/orchestration/__init__.py +++ b/PycroFlow/orchestration/__init__.py @@ -11,6 +11,7 @@ ``imaging``, ``illumination``, ``hamilton_components``, the rest of the package and external scripts) continue to work unchanged. """ + from PycroFlow.orchestration.core import ( AbstractSystem, AbstractSystemHandler, @@ -25,7 +26,6 @@ from PycroFlow.orchestration.signal_registry import SignalRegistry from PycroFlow.orchestration.threadexchange import ThreadExchange - __all__ = [ "AbstractSystem", "AbstractSystemHandler", diff --git a/PycroFlow/orchestration/signal_registry.py b/PycroFlow/orchestration/signal_registry.py index 1c2c93a..943f42c 100644 --- a/PycroFlow/orchestration/signal_registry.py +++ b/PycroFlow/orchestration/signal_registry.py @@ -22,6 +22,7 @@ lock guards the dict membership check + insertion that ``register`` and ``fire`` perform when a signal is fired before anyone has registered it. """ + from __future__ import annotations import threading @@ -64,7 +65,9 @@ def fire(self, target: str, value: str) -> None: no-op once the event is set.""" self._get_or_create(target, value).set() - def wait(self, target: str, value: str, timeout: Optional[float] = None) -> bool: + def wait( + self, target: str, value: str, timeout: Optional[float] = None + ) -> bool: """Block until ``(target, value)`` fires or ``timeout`` elapses. Returns True iff the signal fired before the timeout. Caller is diff --git a/PycroFlow/orchestration/threadexchange.py b/PycroFlow/orchestration/threadexchange.py index f9e6450..ca26e67 100644 --- a/PycroFlow/orchestration/threadexchange.py +++ b/PycroFlow/orchestration/threadexchange.py @@ -22,6 +22,7 @@ path is preserved in the per-subsystem ``list[str]`` for substring- matching semantics that callers still depend on. """ + from __future__ import annotations import queue @@ -30,8 +31,7 @@ from PycroFlow.orchestration.signal_registry import SignalRegistry - -_SUBSYSTEMS = ('fluid', 'img', 'illu') +_SUBSYSTEMS = ("fluid", "img", "illu") class ThreadExchange(dict): @@ -49,20 +49,20 @@ class ThreadExchange(dict): """ @classmethod - def create(cls) -> 'ThreadExchange': + def create(cls) -> "ThreadExchange": """Build a fresh exchange with all primitives newly allocated.""" tx = cls() for sub in _SUBSYSTEMS: - tx[f'{sub}_lock'] = threading.Lock() + tx[f"{sub}_lock"] = threading.Lock() tx[sub] = [] # list[str]: legacy message log - tx[f'{sub}_finished'] = threading.Event() - tx['fluid_queue'] = queue.Queue() - tx['start_protocol_flag'] = threading.Event() - tx['pause_protocol_flag'] = threading.Event() - tx['abort_protocol_flag'] = threading.Event() - tx['abort_flag'] = threading.Event() - tx['graceful_stop_flag'] = threading.Event() - tx['signal_registry'] = SignalRegistry() + tx[f"{sub}_finished"] = threading.Event() + tx["fluid_queue"] = queue.Queue() + tx["start_protocol_flag"] = threading.Event() + tx["pause_protocol_flag"] = threading.Event() + tx["abort_protocol_flag"] = threading.Event() + tx["abort_flag"] = threading.Event() + tx["graceful_stop_flag"] = threading.Event() + tx["signal_registry"] = SignalRegistry() return tx # --- Typed accessors. Read-only — modifying through the typed name @@ -70,64 +70,64 @@ def create(cls) -> 'ThreadExchange': @property def signal_registry(self) -> SignalRegistry: - return self['signal_registry'] + return self["signal_registry"] @property def fluid_lock(self) -> threading.Lock: - return self['fluid_lock'] + return self["fluid_lock"] @property def img_lock(self) -> threading.Lock: - return self['img_lock'] + return self["img_lock"] @property def illu_lock(self) -> threading.Lock: - return self['illu_lock'] + return self["illu_lock"] @property def fluid(self) -> List[str]: - return self['fluid'] + return self["fluid"] @property def img(self) -> List[str]: - return self['img'] + return self["img"] @property def illu(self) -> List[str]: - return self['illu'] + return self["illu"] @property def abort_flag(self) -> threading.Event: - return self['abort_flag'] + return self["abort_flag"] @property def abort_protocol_flag(self) -> threading.Event: - return self['abort_protocol_flag'] + return self["abort_protocol_flag"] @property def pause_protocol_flag(self) -> threading.Event: - return self['pause_protocol_flag'] + return self["pause_protocol_flag"] @property def start_protocol_flag(self) -> threading.Event: - return self['start_protocol_flag'] + return self["start_protocol_flag"] @property def graceful_stop_flag(self) -> threading.Event: - return self['graceful_stop_flag'] + return self["graceful_stop_flag"] @property def fluid_finished(self) -> threading.Event: - return self['fluid_finished'] + return self["fluid_finished"] @property def img_finished(self) -> threading.Event: - return self['img_finished'] + return self["img_finished"] @property def illu_finished(self) -> threading.Event: - return self['illu_finished'] + return self["illu_finished"] @property def fluid_queue(self) -> queue.Queue: - return self['fluid_queue'] + return self["fluid_queue"] diff --git a/PycroFlow/protocol_entries.py b/PycroFlow/protocol_entries.py index ccb65a4..5c3e1a6 100644 --- a/PycroFlow/protocol_entries.py +++ b/PycroFlow/protocol_entries.py @@ -13,6 +13,7 @@ Callers that still pass raw dicts work because the orchestration code parses lazily and falls back to the original ``$type`` string dispatch. """ + from PycroFlow.schemas import validate_protocol from PycroFlow.schemas.protocol_schema import ( AcquireEntry, @@ -33,23 +34,22 @@ WaitForSignalEntry, ) - # Mapping ``$type`` value -> entry model. Used by :func:`parse_entry` so a # raw dict can be coerced without going through the full Protocol model # (which requires a fully-formed protocol dict). ENTRY_MODELS_BY_TYPE = { - 'inject': InjectEntry, - 'incubate': IncubateEntry, - 'flush': FlushEntry, - 'pump_out': PumpOutEntry, - 'await_acquisition': AwaitAcquisitionEntry, - 'signal': SignalEntry, - 'wait for signal': WaitForSignalEntry, - 'acquire': AcquireEntry, - 'power': PowerEntry, - 'set power': SetPowerEntry, - 'set shutter': SetShutterEntry, - 'laser enable': LaserEnableEntry, + "inject": InjectEntry, + "incubate": IncubateEntry, + "flush": FlushEntry, + "pump_out": PumpOutEntry, + "await_acquisition": AwaitAcquisitionEntry, + "signal": SignalEntry, + "wait for signal": WaitForSignalEntry, + "acquire": AcquireEntry, + "power": PowerEntry, + "set power": SetPowerEntry, + "set shutter": SetShutterEntry, + "laser enable": LaserEnableEntry, } @@ -76,20 +76,21 @@ def parse_entry(raw): Raises :class:`KeyError` for unknown ``$type``s and the underlying ``pydantic.ValidationError`` for malformed fields. """ - type_key = raw['$type'] + type_key = raw["$type"] model = ENTRY_MODELS_BY_TYPE.get(type_key) if model is None and isinstance(type_key, str): model = ENTRY_MODELS_BY_TYPE.get(type_key.lower()) if model is None: raise KeyError( "unknown protocol entry $type {!r}; known types: {}".format( - type_key, sorted(ENTRY_MODELS_BY_TYPE), + type_key, + sorted(ENTRY_MODELS_BY_TYPE), ) ) # Lowercase the dispatch field in the input so the Literal discriminator # matches. The pydantic model preserves all other fields verbatim. if isinstance(type_key, str) and type_key not in ENTRY_MODELS_BY_TYPE: - raw = dict(raw, **{'$type': type_key.lower()}) + raw = dict(raw, **{"$type": type_key.lower()}) return model.model_validate(raw) diff --git a/PycroFlow/protocols/__init__.py b/PycroFlow/protocols/__init__.py index 5f1e529..123f257 100644 --- a/PycroFlow/protocols/__init__.py +++ b/PycroFlow/protocols/__init__.py @@ -19,6 +19,7 @@ Back-compat re-exports keep ``from PycroFlow.protocols import ProtocolBuilder`` working. """ + from PycroFlow.protocols.builder import ProtocolBuilder __all__ = ["ProtocolBuilder"] diff --git a/PycroFlow/protocols/builder.py b/PycroFlow/protocols/builder.py index fbd2c0e..a107dfc 100644 --- a/PycroFlow/protocols/builder.py +++ b/PycroFlow/protocols/builder.py @@ -160,7 +160,8 @@ def _prune_orphan_waits(protocol): for system in protocol.values(): entries = system["protocol_entries"] system["protocol_entries"] = [ - entry for entry in entries + entry + for entry in entries if not ( entry.get("$type") == "wait for signal" and entry.get("target") not in present @@ -251,7 +252,12 @@ def create_steps(self, config): return getattr(self, method_name)(config) def create_stepset_acquisition( - self, illusttg, imgsttg, unique_name, readable_name, fluid_wait=True, + self, + illusttg, + imgsttg, + unique_name, + readable_name, + fluid_wait=True, name=None, ): """Create the step set for an acquisition. diff --git a/PycroFlow/protocols/exchange.py b/PycroFlow/protocols/exchange.py index 7b9cf3b..67fe5ac 100644 --- a/PycroFlow/protocols/exchange.py +++ b/PycroFlow/protocols/exchange.py @@ -6,6 +6,7 @@ that take a builder argument; external callers that already import from this module then won't need to change. """ + from PycroFlow.protocols.builder import ProtocolBuilder diff --git a/PycroFlow/protocols/flushtest.py b/PycroFlow/protocols/flushtest.py index a84f7a5..cf49ae4 100644 --- a/PycroFlow/protocols/flushtest.py +++ b/PycroFlow/protocols/flushtest.py @@ -1,4 +1,5 @@ """Flush-test step builders. See ``exchange.py`` for the migration plan.""" + from PycroFlow.protocols.builder import ProtocolBuilder diff --git a/PycroFlow/protocols/merpaint.py b/PycroFlow/protocols/merpaint.py index bb0e7ba..bcc4477 100644 --- a/PycroFlow/protocols/merpaint.py +++ b/PycroFlow/protocols/merpaint.py @@ -1,4 +1,5 @@ """MERPAINT step builders. See ``exchange.py`` for the migration plan.""" + from PycroFlow.protocols.builder import ProtocolBuilder diff --git a/PycroFlow/protocols/sph_resi.py b/PycroFlow/protocols/sph_resi.py index 95d41aa..efd551c 100644 --- a/PycroFlow/protocols/sph_resi.py +++ b/PycroFlow/protocols/sph_resi.py @@ -1,4 +1,5 @@ """SPH-RESI step builders. See ``exchange.py`` for the migration plan.""" + from PycroFlow.protocols.builder import ProtocolBuilder diff --git a/PycroFlow/protocols/timing.py b/PycroFlow/protocols/timing.py index e9f6f20..b951475 100644 --- a/PycroFlow/protocols/timing.py +++ b/PycroFlow/protocols/timing.py @@ -18,10 +18,11 @@ model mirrors :meth:`PycroFlow.fluid.legacy.LegacyFluidHandler._estimate_entry_duration`. """ + from __future__ import annotations # Subsystems that may carry timed work, in display order. -_SYSTEMS = ('fluid', 'img', 'illu') +_SYSTEMS = ("fluid", "img", "illu") def estimate_entry_duration(entry, parameters=None): @@ -41,27 +42,27 @@ def estimate_entry_duration(entry, parameters=None): if not isinstance(entry, dict): return 0.0 parameters = parameters or {} - type_ = entry.get('$type') - if type_ == 'acquire': - frames = entry.get('frames') or 0 - t_exp = entry.get('t_exp') or 0 # milliseconds per frame + type_ = entry.get("$type") + if type_ == "acquire": + frames = entry.get("frames") or 0 + t_exp = entry.get("t_exp") or 0 # milliseconds per frame return float(frames) * float(t_exp) / 1000.0 - if type_ == 'incubate': + if type_ == "incubate": try: - return max(float(entry.get('duration') or 0), 0.0) + return max(float(entry.get("duration") or 0), 0.0) except (TypeError, ValueError): return 0.0 - if type_ in ('inject', 'pump_out'): - vol = entry.get('volume') - velocity = entry.get('velocity') or parameters.get('max_velocity') + if type_ in ("inject", "pump_out"): + vol = entry.get("volume") + velocity = entry.get("velocity") or parameters.get("max_velocity") if not vol or not velocity: return 0.0 # Pick the volume up and dispense it: ~2 * volume / velocity minutes. seconds = 120.0 * float(vol) / float(velocity) - if type_ == 'inject': - seconds += parameters.get('inject_in_to_out_delay', 0) or 0 - seconds += parameters.get('inject_out_to_in_delay', 0) or 0 - seconds += 2 * (entry.get('delay', 0) or 0) + if type_ == "inject": + seconds += parameters.get("inject_in_to_out_delay", 0) or 0 + seconds += parameters.get("inject_out_to_in_delay", 0) or 0 + seconds += 2 * (entry.get("delay", 0) or 0) return max(seconds, 0.0) return 0.0 @@ -87,8 +88,8 @@ def estimate_durations(protocol): sub = protocol.get(system) if not isinstance(sub, dict): continue - entries = sub.get('protocol_entries') or [] - params = sub.get('parameters') or {} + entries = sub.get("protocol_entries") or [] + params = sub.get("parameters") or {} out[system] = [estimate_entry_duration(e, params) for e in entries] return out @@ -112,7 +113,7 @@ def estimate_remaining(durations, current): remaining = 0.0 for system, durs in durations.items(): cur = current.get(system, (0, 0))[0] if current else 0 - remaining += sum(durs[max(cur, 0):]) + remaining += sum(durs[max(cur, 0) :]) return remaining @@ -123,14 +124,14 @@ def format_duration(seconds): """ seconds = max(float(seconds or 0), 0.0) if seconds <= 0: - return '0s' + return "0s" if seconds < 60: - return '{:d}s'.format(int(round(seconds))) + return "{:d}s".format(int(round(seconds))) mins = int(seconds // 60) if mins < 60: - return '{:d}m'.format(mins) + return "{:d}m".format(mins) hrs, mins = divmod(mins, 60) if hrs < 24: - return '{:d}h {:d}m'.format(hrs, mins) + return "{:d}h {:d}m".format(hrs, mins) days, hrs = divmod(hrs, 24) - return '{:d}d {:d}h'.format(days, hrs) + return "{:d}d {:d}h".format(days, hrs) diff --git a/PycroFlow/pyHamilton/__init__.py b/PycroFlow/pyHamilton/__init__.py index f99f378..9a525ad 100644 --- a/PycroFlow/pyHamilton/__init__.py +++ b/PycroFlow/pyHamilton/__init__.py @@ -16,7 +16,7 @@ def log_filter(record): return "pyHamilton" in record["name"] -def clean_old_logs(prefix='pyhamilton.log', directory='.'): +def clean_old_logs(prefix="pyhamilton.log", directory="."): """Delete rotated pyHamilton log files. Opt-in; no longer runs at import.""" try: files = os.listdir(directory) @@ -30,7 +30,7 @@ def clean_old_logs(prefix='pyhamilton.log', directory='.'): pass -def setup_logging(logfile='hamilton.log', clean_old=False): +def setup_logging(logfile="hamilton.log", clean_old=False): """Add a pyHamilton-only file sink. Safe to call multiple times.""" if clean_old: clean_old_logs(prefix=logfile) @@ -56,7 +56,7 @@ def rem_old_logfiles(): clean_old_logs() -#List of pumps. Initially the list is empty +# List of pumps. Initially the list is empty pumps = [] pumpLength = 16 @@ -64,22 +64,31 @@ def rem_old_logfiles(): def connect(port, baudrate): initializeSerial(port, baudrate) + def disconnect(): disconnectSerial() + def executeCommand(pump, command, waitForPump=False): if pump.checkValidity(command): sendCommand(pump.asciiAddress, command, waitForPump) + def definePump(address: str, type: util.PSDTypes, syringe: util.SyringeTypes): if len(pumps) < pumpLength: newPump = PSD(address, type) logging.debug("Enable h Factor Commands and Queries") - sendCommand(newPump.asciiAddress, newPump.command.enableHFactorCommandsAndQueries() + newPump.command.executeCommandBuffer()) - result = sendCommand(newPump.asciiAddress, newPump.command.syringeModeQuery(), True) + sendCommand( + newPump.asciiAddress, + newPump.command.enableHFactorCommandsAndQueries() + + newPump.command.executeCommandBuffer(), + ) + result = sendCommand( + newPump.asciiAddress, newPump.command.syringeModeQuery(), True + ) resolution = result[3:4] newPump.setResolution(int(resolution)) newPump.calculateSteps() newPump.calculateSyringeStroke() newPump.setVolume(syringe) - pumps.append(newPump) \ No newline at end of file + pumps.append(newPump) diff --git a/PycroFlow/pyHamilton/command.py b/PycroFlow/pyHamilton/command.py index 02f156d..020e6be 100644 --- a/PycroFlow/pyHamilton/command.py +++ b/PycroFlow/pyHamilton/command.py @@ -15,13 +15,14 @@ def __init__(self, type: PSDTypes): Yx - Initialize PSD, Assign Valve Output to Left Wx - Initialize PSD, Configure for No Valve """ + def initialize(self, drive: str, value=0): - cmd: str = '' - if drive == 'Z' or drive == 'Y' or drive == 'W': + cmd: str = "" + if drive == "Z" or drive == "Y" or drive == "W": cmd += drive else: print("Error! Incorrect drive!") - cmd = 'cmdError' + cmd = "cmdError" return cmd if (value == 1) or (value >= 10 and value <= 40): cmd += str(value) @@ -31,13 +32,14 @@ def initialize(self, drive: str, value=0): R - Execute Command Buffer X - Execute Command Buffer from Beginning """ - def executeCommandBuffer(self, type='R'): - cmd: str = '' - if type == 'R' or type == 'X': + + def executeCommandBuffer(self, type="R"): + cmd: str = "" + if type == "R" or type == "X": cmd += type else: print("Error! Incorrect type!") - cmd = 'cmdError' + cmd = "cmdError" return cmd """ @@ -57,28 +59,30 @@ def executeCommandBuffer(self, type='R'): # - volume parameter: is volume required per second, volume value is measured in micro liters # def syringeMovement(self, typeOf: str, volume: float): - cmd: str = '' + cmd: str = "" if typeOf == SyringeMovement.absoluteMovement.value: - cmd += 'A' + cmd += "A" elif typeOf == SyringeMovement.relativePickup.value: - cmd += 'P' + cmd += "P" elif typeOf == SyringeMovement.relativeDispense.value: - cmd += 'D' + cmd += "D" elif typeOf == SyringeMovement.returnSteps.value: - cmd += 'K' + cmd += "K" elif typeOf == SyringeMovement.backoffSteps.value: - cmd += 'k' + cmd += "k" else: - print("Invalid value \"" + str(typeOf) + "\" for type of movement!") - cmd = 'cmdError' + print('Invalid value "' + str(typeOf) + '" for type of movement!') + cmd = "cmdError" value: int = self.volumeToSteps(volume) if self.checkIntervalCorrectness(value, typeOf) == True: - value = int(value * self.motorsteps_per_step) # apparently needed as the OEM/high force PSD4 needs D6000 for a full stroke + value = int( + value * self.motorsteps_per_step + ) # apparently needed as the OEM/high force PSD4 needs D6000 for a full stroke cmd += str(value) else: - print("Invalid value \"" + str(volume) + "\" for volume!") - cmd = 'cmdError' + print('Invalid value "' + str(volume) + '" for volume!') + cmd = "cmdError" return cmd # This script can set start, stop and maximum velocity using as parameter the fluid volume requested per second @@ -92,24 +96,24 @@ def syringeMovement(self, typeOf: str, volume: float): # - volume parameter: is volume required per second, volume value is measured in micro liters # def velocityConfiguration(self, typeOf: str, volume: float): - cmd: str = '' + cmd: str = "" if typeOf == VelocityTypes.maxVelocity.value: - cmd += 'V' + cmd += "V" elif typeOf == VelocityTypes.startVelocity.value: - cmd += 'v' + cmd += "v" elif typeOf == VelocityTypes.stopVelocity.value: - cmd += 'c' + cmd += "c" else: - print("Invalid value \"" + str(typeOf) + "\" for type of velocity!") - cmd = 'cmdError' + print('Invalid value "' + str(typeOf) + '" for type of velocity!') + cmd = "cmdError" return cmd parameterV: int = self.parameterVCalculation(volume) if self.checkParameterV(parameterV, typeOf) == True: cmd += str(parameterV) else: - print("Invalid value \"" + str(volume) + "\" for volume!") - cmd = 'cmdError' + print('Invalid value "' + str(volume) + '" for volume!') + cmd = "cmdError" return cmd # helper method that checks if the value is in the correct range @@ -128,9 +132,10 @@ def checkValueInInterval(self, value: int, valueST: int, valueHG: int): # calculation of flow rate for parameter "u" def calculateParameterU(self, volRequested: float): result: float = 0 - if (self.syringeStroke == 192000 or self.syringeStroke == 384000) and \ - ( 0.0 <= volRequested <= self.maxVolum and self.maxVolum != 0 ): - result = (volRequested * self.syringeStroke / self.maxVolum) + if (self.syringeStroke == 192000 or self.syringeStroke == 384000) and ( + 0.0 <= volRequested <= self.maxVolum and self.maxVolum != 0 + ): + result = volRequested * self.syringeStroke / self.maxVolum return (int)(result) @@ -138,7 +143,7 @@ def calculateParameterU(self, volRequested: float): def parameterVCalculation(self, volRequested: float): result: float = 0 if 0.0 <= volRequested <= self.maxVolum and self.maxVolum != 0: - result = (volRequested * self.syringeStroke / self.maxVolum) + result = volRequested * self.syringeStroke / self.maxVolum if self.syringeStroke == 192000 or self.syringeStroke == 384000: result /= 4 @@ -150,20 +155,38 @@ def parameterVCalculation(self, volRequested: float): def checkParameterV(self, value: int, typeOf: str): result: bool = False if self.syringeStroke == 6000 or self.syringeStroke == 12000: - if typeOf == VelocityTypes.maxVelocity.value and 2 <= value <= 5800: + if ( + typeOf == VelocityTypes.maxVelocity.value + and 2 <= value <= 5800 + ): result = True - elif typeOf == VelocityTypes.startVelocity.value and 50 <= value <= 1000: + elif ( + typeOf == VelocityTypes.startVelocity.value + and 50 <= value <= 1000 + ): result = True - elif typeOf == VelocityTypes.stopVelocity.value and 50 <= value <= 2700: + elif ( + typeOf == VelocityTypes.stopVelocity.value + and 50 <= value <= 2700 + ): result = True else: result = False elif self.syringeStroke == 192000 or self.syringeStroke == 384000: - if typeOf == VelocityTypes.maxVelocity.value and 2 <= value <= 3400: + if ( + typeOf == VelocityTypes.maxVelocity.value + and 2 <= value <= 3400 + ): result = True - elif typeOf == VelocityTypes.startVelocity.value and 50 <= value <= 800: + elif ( + typeOf == VelocityTypes.startVelocity.value + and 50 <= value <= 800 + ): result = True - elif typeOf == VelocityTypes.stopVelocity.value and 50 <= value <= 1700: + elif ( + typeOf == VelocityTypes.stopVelocity.value + and 50 <= value <= 1700 + ): result = True else: result = False @@ -175,9 +198,11 @@ def checkParameterV(self, value: int, typeOf: str): def checkIntervalCorrectness(self, value: int, syringeMov: str): result: bool = False if self.syringeStroke == 6000: - if syringeMov == SyringeMovement.absoluteMovement.value or \ - syringeMov == SyringeMovement.relativePickup.value or \ - syringeMov == SyringeMovement.relativeDispense.value: + if ( + syringeMov == SyringeMovement.absoluteMovement.value + or syringeMov == SyringeMovement.relativePickup.value + or syringeMov == SyringeMovement.relativeDispense.value + ): result = self.checkValueInInterval(value, 3000, 24000) elif syringeMov == SyringeMovement.returnSteps.value: result = self.checkValueInInterval(value, 100, 800) @@ -186,9 +211,11 @@ def checkIntervalCorrectness(self, value: int, syringeMov: str): else: result = False elif self.syringeStroke == 12000: - if syringeMov == SyringeMovement.absoluteMovement.value or \ - syringeMov == SyringeMovement.relativePickup.value or \ - syringeMov == SyringeMovement.relativeDispense.value: + if ( + syringeMov == SyringeMovement.absoluteMovement.value + or syringeMov == SyringeMovement.relativePickup.value + or syringeMov == SyringeMovement.relativeDispense.value + ): result = self.checkValueInInterval(value, 6000, 48000) elif syringeMov == SyringeMovement.returnSteps.value: result = self.checkValueInInterval(value, 100, 800) @@ -197,9 +224,11 @@ def checkIntervalCorrectness(self, value: int, syringeMov: str): else: result = False elif self.syringeStroke == 192000: - if syringeMov == SyringeMovement.absoluteMovement.value or \ - syringeMov == SyringeMovement.relativePickup.value or \ - syringeMov == SyringeMovement.relativeDispense.value: + if ( + syringeMov == SyringeMovement.absoluteMovement.value + or syringeMov == SyringeMovement.relativePickup.value + or syringeMov == SyringeMovement.relativeDispense.value + ): result = self.checkValueInInterval(value, 192000, 192000) elif syringeMov == SyringeMovement.returnSteps.value: result = self.checkValueInInterval(value, 6400, 6400) @@ -208,9 +237,11 @@ def checkIntervalCorrectness(self, value: int, syringeMov: str): else: result = False elif self.syringeStroke == 384000: - if syringeMov == SyringeMovement.absoluteMovement.value or \ - syringeMov == SyringeMovement.relativePickup.value or \ - syringeMov == SyringeMovement.relativeDispense.value: + if ( + syringeMov == SyringeMovement.absoluteMovement.value + or syringeMov == SyringeMovement.relativePickup.value + or syringeMov == SyringeMovement.relativeDispense.value + ): result = self.checkValueInInterval(value, 384000, 384000) elif syringeMov == SyringeMovement.returnSteps.value: result = self.checkValueInInterval(value, 6400, 6400) @@ -231,73 +262,81 @@ def volumeToSteps(self, volRequested: float): # z - Set Counter Position def setCounterPosition(self): - return 'z' + return "z" # Ax - Absolute Position def absolutePosition(self, value): - cmd: str = 'A' + cmd: str = "A" cmd += str(value) return cmd # Px - Relative Pickup def relativePickup(self, value: int): - cmd: str = 'P' + cmd: str = "P" cmd += str(value) return cmd # Dx - Relative Dispense def relativeDispense(self, value: int): - cmd: str = 'D' + cmd: str = "D" cmd += str(value) return cmd # Kx - Return Steps def returnSteps(self, value: int): - cmd: str = 'K' + cmd: str = "K" cmd += str(value) return cmd # kx - Back-off Steps def backoffSteps(self, value: int): - cmd: str = 'k' + cmd: str = "k" cmd += str(value) return cmd - ''' + """ Valve Commands - ''' + """ # Ix - Move Valve to Input Position def moveValveToInputPosition(self, value=0): - cmd: str = 'I' + cmd: str = "I" if value == 0: pass elif 1 <= value <= 8: cmd += str(value) else: - print("Invalid value " + str(value) + " for Move valve to input position command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for Move valve to input position command!" + ) + cmd = "cmdError" return cmd # Ox - Move Valve to Output Position def moveValveToOutputPosition(self, value=0): - cmd: str = 'O' + cmd: str = "O" if value == 0: pass elif 1 <= value <= 8: cmd += str(value) else: - print("Invalid value " + str(value) + " for Move valve to output position command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for Move valve to output position command!" + ) + cmd = "cmdError" return cmd # B - Move Valve to Bypass (Throughput Position) def moveValveToBypass(self): - return 'B' + return "B" # E - Move Valve to Extra Position def moveValveToExtraPosition(self): - return 'E' + return "E" """ Action commands @@ -305,130 +344,162 @@ def moveValveToExtraPosition(self): # g - Define a Position in a Command String def definePositionInCommandString(self): - return 'g' + return "g" # Gx - Repeat Commands def repeatCommands(self, value=0): - cmd: str = 'G' + cmd: str = "G" if value == 0: pass elif 1 <= value <= 65535: cmd += str(value) else: - print("Invalid value " + str(value) + " for Repeat Commands command!") - cmd = 'cmdError' + print( + "Invalid value " + str(value) + " for Repeat Commands command!" + ) + cmd = "cmdError" return cmd # Mx - Delay - performs a delay of x milliseconds.where 5 ≤ x ≤ 30,000 milliseconds. def delay(self, value: int): - cmd: str = 'M' + cmd: str = "M" if 5 <= value <= 30000: cmd += str(value) else: print("Invalid value " + str(value) + " for Delay command!") - cmd = 'cmdError' + cmd = "cmdError" return cmd # Hx - Halt Command Execution def halt(self, value: int): - cmd: str = 'H' + cmd: str = "H" if 0 <= value <= 2: cmd += str(value) else: print("Invalid value " + str(value) + " for Halt command!") - cmd = 'cmdError' + cmd = "cmdError" return cmd # Jx - Auxiliary Outputs def auxiliaryOutputs(self, value: int): - cmd: str = 'J' + cmd: str = "J" if 0 <= value <= 7: cmd += str(value) else: - print("Invalid value " + str(value) + " for Auxiliary Outputs command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for Auxiliary Outputs command!" + ) + cmd = "cmdError" return cmd # sx - Store Command String def storeCommandString(self, location: int, command: str): - cmd: str = 's' + cmd: str = "s" if 0 <= location <= 14: cmd += str(location) cmd += command else: - print("Invalid value " + str(location) + " for Store Command String command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(location) + + " for Store Command String command!" + ) + cmd = "cmdError" return cmd # ex - Execute Command String in EEPROM Location def executeCommandStringInEEPROMLocation(self, location: int): - cmd: str = 'e' + cmd: str = "e" if 0 <= location <= 14: cmd += str(location) else: - print("Invalid value " + str(location) + " for Execute Command String in EEPROM Location command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(location) + + " for Execute Command String in EEPROM Location command!" + ) + cmd = "cmdError" return cmd def terminateCommandBuffer(self): - return 'T' + return "T" """ Motor Commands """ def setAcceleration(self, value: int): - cmd: str = 'L' + cmd: str = "L" if 0 <= value <= 20: cmd += str(value) else: - print("Invalid value " + str(value) + " for Set acceleration command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for Set acceleration command!" + ) + cmd = "cmdError" return cmd def setSpeed(self, value: int): - cmd: str = 'S' + cmd: str = "S" if 1 <= value <= 40: cmd += str(value) else: print("Invalid value " + str(value) + " for Set speed command!") - cmd = 'cmdError' + cmd = "cmdError" return cmd def increaseStopVelocityBySteps(self, value: int): - cmd: str = 'C' + cmd: str = "C" if 0 <= value <= 25: cmd += str(value) else: - print("Invalid value " + str(value) + " for Increase Stop Velocity by Steps command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for Increase Stop Velocity by Steps command!" + ) + cmd = "cmdError" return cmd def setStartVelocity(self, value: int): - cmd: str = 'v' + cmd: str = "v" if 50 <= value <= 1000: cmd += str(value) else: - print("Invalid value " + str(value) + " for Set start velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for Set start velocity command!" + ) + cmd = "cmdError" return cmd def setMaximumVelocity(self, value: int): - cmd: str = 'V' + cmd: str = "V" if 2 <= value <= 5800: cmd += str(value) else: - print("Invalid value " + str(value) + " for Set maximum velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for Set maximum velocity command!" + ) + cmd = "cmdError" return cmd def stopVelocity(self, value: int): - cmd: str = 'c' + cmd: str = "c" if 50 <= value <= 2700: cmd += str(value) else: - print("Invalid value " + str(value) + " for Stop velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + str(value) + " for Stop velocity command!" + ) + cmd = "cmdError" return cmd """ @@ -449,27 +520,33 @@ def initializeValve(self): return "h20000" def initializeSyringeOnly(self, speedCode: int): - cmd: str = 'h' + cmd: str = "h" cmdValue = 10000 # permitted values between 0-40 if 0 <= speedCode <= 40: cmdValue += speedCode cmd += str(cmdValue) else: - print("Invalid speed code " + str(speedCode) + " for Initialize Syringe Only command!") - cmd = 'cmdError' + print( + "Invalid speed code " + + str(speedCode) + + " for Initialize Syringe Only command!" + ) + cmd = "cmdError" return cmd def setSyringeMode(self, mode: int): - cmd: str = 'h' + cmd: str = "h" cmdValue = 11000 # permitted values between 0-15 if 0 <= mode <= 15: cmdValue += mode cmd += str(cmdValue) else: - print("Invalid mode " + str(mode) + " for Set Syringe Mode command!") - cmd = 'cmdError' + print( + "Invalid mode " + str(mode) + " for Set Syringe Mode command!" + ) + cmd = "cmdError" return cmd def enableValveMovement(self): @@ -479,13 +556,17 @@ def disableValveMovement(self): return "h20002" def setValveType(self, type: int): - cmd: str = 'h2100' + cmd: str = "h2100" # permitted values between 0-6 if 0 <= type <= 6: cmd += str(type) else: - print("Invalid valve type " + str(type) + " for Set Valve Type command!") - cmd = 'cmdError' + print( + "Invalid valve type " + + str(type) + + " for Set Valve Type command!" + ) + cmd = "cmdError" return cmd def moveValveToInputPositionInShortestDirection(self): @@ -507,44 +588,60 @@ def moveValveToExtraPositionInShortestDirection(self): return "h23006" def moveValveClockwiseDirection(self, position: int): - cmd: str = 'h2400' + cmd: str = "h2400" # permitted values between 1-8 if 1 <= position <= 8: cmd += str(position) else: - print("Invalid position " + str(position) + " for Move Valve in Clockwise Direction command!") - cmd = 'cmdError' + print( + "Invalid position " + + str(position) + + " for Move Valve in Clockwise Direction command!" + ) + cmd = "cmdError" return cmd def moveValveCounterclockwiseDirection(self, position: int): - cmd: str = 'h2500' + cmd: str = "h2500" # permitted values between 1-8 if 1 <= position <= 8: cmd += str(position) else: - print("Invalid position " + str(position) + " for Move Valve in Counterclockwise Direction command!") - cmd = 'cmdError' + print( + "Invalid position " + + str(position) + + " for Move Valve in Counterclockwise Direction command!" + ) + cmd = "cmdError" return cmd def moveValveInShortestDirection(self, position: int): - cmd: str = 'h2600' + cmd: str = "h2600" # permitted values between 1-8 if 1 <= position <= 8: cmd += str(position) else: - print("Invalid position " + str(position) + " for Move Valve in Shortest Direction command!") - cmd = 'cmdError' + print( + "Invalid position " + + str(position) + + " for Move Valve in Shortest Direction command!" + ) + cmd = "cmdError" return cmd def angularValveMoveCommandCtr(self, cmdValue: int, incrementWith: int): - cmd: str = 'h' + cmd: str = "h" # permitted values between 0-345 incremented by 15 if 345 >= incrementWith >= 0 == incrementWith % 15: cmdValue += incrementWith cmd += str(cmdValue) else: - print("Invalid angle value " + str(incrementWith) + " for Angular Valve Move command!") - cmd = 'cmdError' + print( + "Invalid angle value " + + str(incrementWith) + + " for Angular Valve Move command!" + ) + cmd = "cmdError" return cmd def clockwiseAngularValveMove(self, position: int): @@ -624,4 +721,4 @@ def valveAngleQuery(self): return QueryCommandsEnumeration.VALVE_ANGLE.value def lastDigitalOutValueQuery(self): - return QueryCommandsEnumeration.LAST_DIGITAL_OUT_VALUE.value \ No newline at end of file + return QueryCommandsEnumeration.LAST_DIGITAL_OUT_VALUE.value diff --git a/PycroFlow/pyHamilton/commandPSD4.py b/PycroFlow/pyHamilton/commandPSD4.py index 7cfc00f..9158123 100644 --- a/PycroFlow/pyHamilton/commandPSD4.py +++ b/PycroFlow/pyHamilton/commandPSD4.py @@ -23,138 +23,189 @@ def setSyringeMode(self, mode: int): def absolutePosition(self, value: int): # absolute position x where 0 ≤ x ≤ 3,000 in standard mode or 0 ≤ x ≤ 24,000 in high resolution mode bResult = False - cmd: str = 'A' + cmd: str = "A" value = int(value * self.motorsteps_per_step) if self.checkValueInInterval(value, 3000, 24000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 absolute position command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 absolute position command!" + ) + cmd = "cmdError" return cmd def absolutePositionWithReadyStatus(self, value: int): # absolute position x where 0 ≤ x ≤ 3,000 in standard mode or 0 ≤ x ≤ 24,000 in high resolution mode - cmd: str = 'a' + cmd: str = "a" value = int(value * self.motorsteps_per_step) if self.checkValueInInterval(value, 3000, 24000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 absolute position with ready status command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 absolute position with ready status command!" + ) + cmd = "cmdError" return cmd def relativePickup(self, value: int): # number of steps x where 0 ≤ x ≤ 3,000 in standard mode or 0 ≤ x ≤ 24,000 in high resolution mode - cmd: str = 'P' + cmd: str = "P" value = int(value * self.motorsteps_per_step) if self.checkValueInInterval(value, 3000, 24000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 relative pickup command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 relative pickup command!" + ) + cmd = "cmdError" return cmd def relativePickupWithReadyStatus(self, value: int): # number of steps x where 0 ≤ x ≤ 3,000 in standard mode or 0 ≤ x ≤ 24,000 in high resolution mode - cmd: str = 'p' + cmd: str = "p" value = int(value * self.motorsteps_per_step) if self.checkValueInInterval(value, 3000, 24000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 relative pickup with ready status command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 relative pickup with ready status command!" + ) + cmd = "cmdError" return cmd def relativeDispense(self, value: int): # number of steps x where 0 ≤ x ≤ 3,000 in standard mode or 0 ≤ x ≤ 24,000 in high resolution mode - cmd: str = 'D' + cmd: str = "D" value = int(value * self.motorsteps_per_step) if self.checkValueInInterval(value, 3000, 24000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 relative dispense command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 relative dispense command!" + ) + cmd = "cmdError" return cmd def relativeDispenseWithReadyStatus(self, value: int): # number of steps x where 0 ≤ x ≤ 3,000 in standard mode or 0 ≤ x ≤ 24,000 in high resolution mode - cmd: str = 'd' + cmd: str = "d" value = int(value * self.motorsteps_per_step) if self.checkValueInInterval(value, 3000, 24000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 relative dispense with ready status command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 relative dispense with ready status command!" + ) + cmd = "cmdError" return cmd def returnSteps(self, value: int): # Return Steps x where 0 ≤ x ≤ 3,000 in standard mode or 0 ≤ x ≤ 24,000 in high resolution mode - cmd: str = 'K' + cmd: str = "K" value = int(value * self.motorsteps_per_step) if self.checkValueInInterval(value, 100, 800): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 return steps command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 return steps command!" + ) + cmd = "cmdError" return cmd def backoffSteps(self, value: int): # Back-off Steps x where 0 ≤ x ≤ 3,000 in standard mode or 0 ≤ x ≤ 24,000 - cmd: str = 'k' + cmd: str = "k" value = int(value * self.motorsteps_per_step) if self.checkValueInInterval(value, 200, 1600): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 backoff steps command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 backoff steps command!" + ) + cmd = "cmdError" return cmd """ Motor Commands """ + def standardHighResolutionSelection(self, mode: int): # x=0 for standard resolution mode # x=1 for high resolution mode - cmd: str = 'N' + cmd: str = "N" if mode == 0 or mode == 1: cmd += str(mode) self.resolution_mode = mode - print("Resolution mode set on: " + str(mode) + " using PSD4 st/hg resolution selection") + print( + "Resolution mode set on: " + + str(mode) + + " using PSD4 st/hg resolution selection" + ) else: - cmd = 'cmdError' - print("Wrong parameter value for standard/high resolution selection command!") + cmd = "cmdError" + print( + "Wrong parameter value for standard/high resolution selection command!" + ) return cmd def setStartVelocity(self, value: int): - cmd: str = 'v' + cmd: str = "v" value = int(value * self.motorsteps_per_step) if 50 <= value <= 1000: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 start velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 start velocity command!" + ) + cmd = "cmdError" return cmd def setMaximumVelocity(self, value: int): - cmd: str = 'V' + cmd: str = "V" value = int(value * self.motorsteps_per_step) if 2 <= value <= 5800: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 set maximum velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 set maximum velocity command!" + ) + cmd = "cmdError" return cmd def stopVelocity(self, value: int): - cmd: str = 'c' + cmd: str = "c" value = int(value * self.motorsteps_per_step) if 50 <= value <= 2700: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 stop velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 stop velocity command!" + ) + cmd = "cmdError" return cmd def syringeHomeSensorStatusQuery(self): - return QueryCommandsEnumeration.SYRINGE_HOME_SENSOR_STATUS.value \ No newline at end of file + return QueryCommandsEnumeration.SYRINGE_HOME_SENSOR_STATUS.value diff --git a/PycroFlow/pyHamilton/commandPSD4SmoothFlow.py b/PycroFlow/pyHamilton/commandPSD4SmoothFlow.py index 2936708..349b601 100644 --- a/PycroFlow/pyHamilton/commandPSD4SmoothFlow.py +++ b/PycroFlow/pyHamilton/commandPSD4SmoothFlow.py @@ -7,52 +7,72 @@ def __init__(self, type): def absolutePosition(self, value): # absolute position x where 0 ≤ x ≤ 192.000 - cmd: str = 'A' + cmd: str = "A" if 0 <= value <= 192000: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 smooth flow absolute position command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 smooth flow absolute position command!" + ) + cmd = "cmdError" return cmd def relativePickup(self, value: int): # number of steps x where 0 ≤ x ≤ 192.000 - cmd: str = 'P' + cmd: str = "P" if 0 <= value <= 192000: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 smooth flow relative pickup command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 smooth flow relative pickup command!" + ) + cmd = "cmdError" return cmd def relativeDispense(self, value: int): # number of steps x where 0 ≤ x ≤ 192.000 - cmd: str = 'D' + cmd: str = "D" if 0 <= value <= 192000: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 smooth flow relative dispense command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 smooth flow relative dispense command!" + ) + cmd = "cmdError" return cmd def returnSteps(self, value: int): # Return Steps x where 0 ≤ x ≤ 6400 - cmd: str = 'K' + cmd: str = "K" if 0 <= value <= 6400: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 smooth flow return steps command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 smooth flow return steps command!" + ) + cmd = "cmdError" return cmd def backoffSteps(self, value: int): # Back-off Steps x where 0 ≤ x ≤ 12800 - cmd: str = 'k' + cmd: str = "k" if 0 <= value <= 12800: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 smooth flow backoff steps command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 smooth flow backoff steps command!" + ) + cmd = "cmdError" return cmd """ @@ -60,55 +80,75 @@ def backoffSteps(self, value: int): """ def setStartVelocity(self, value: int): - cmd: str = 'v' + cmd: str = "v" if 50 <= value <= 800: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 smooth flow start velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 smooth flow start velocity command!" + ) + cmd = "cmdError" return cmd def setMaximumVelocity(self, value: int): - cmd: str = 'V' + cmd: str = "V" if 2 <= value <= 3400: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 smooth flow set maximum velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 smooth flow set maximum velocity command!" + ) + cmd = "cmdError" return cmd def stopVelocity(self, value: int): - cmd: str = 'c' + cmd: str = "c" if 50 <= value <= 1700: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 smooth flow stop velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 smooth flow stop velocity command!" + ) + cmd = "cmdError" return cmd # sets the maximum velocity in μsteps/minute. def setMaximumMicroStepVelocity(self, value): - cmd: str = 'u' + cmd: str = "u" if 400 <= value <= 816000: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD4 smooth flow set maximum microstep velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD4 smooth flow set maximum microstep velocity command!" + ) + cmd = "cmdError" return cmd # sets the maximum velocity in μsteps/minute using as parameter microliters. def setMaximumMicroStepVelocityPerMinute(self, flowrate: float): - cmd: str = 'u' + cmd: str = "u" calculatedValue: int = self.calculateParameterU(flowrate) if 400 <= calculatedValue <= 816000: cmd += str(calculatedValue) else: - print("Invalid value \"" + str(calculatedValue) + "\" for PSD4 smooth flow set maximum microstep velocity command!") - cmd = 'cmdError' + print( + 'Invalid value "' + + str(calculatedValue) + + '" for PSD4 smooth flow set maximum microstep velocity command!' + ) + cmd = "cmdError" return cmd def stopCommandBuffer(self): - return 't' + return "t" def syringeDiagnosticTimerValueQuery(self): return QueryCommandsEnumeration.SYRINGE_DIAGNOSTIC_TIMER_VALUE.value diff --git a/PycroFlow/pyHamilton/commandPSD6.py b/PycroFlow/pyHamilton/commandPSD6.py index 1de2114..04d3d96 100644 --- a/PycroFlow/pyHamilton/commandPSD6.py +++ b/PycroFlow/pyHamilton/commandPSD6.py @@ -20,82 +20,114 @@ def setSyringeMode(self, mode: int): def absolutePosition(self, value): # absolute position x where 0 ≤ x ≤ 6,000 in standard mode or 0 ≤ x ≤ 48,000 in high resolution mode - cmd: str = 'A' + cmd: str = "A" if self.checkValueInInterval(value, 6000, 48000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 absolute position command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 absolute position command!" + ) + cmd = "cmdError" return cmd def absolutePositionWithReadyStatus(self, value): # absolute position x where 0 ≤ x ≤ 6,000 in standard mode or 0 ≤ x ≤ 48,000 in high resolution mode - cmd: str = 'a' + cmd: str = "a" if self.checkValueInInterval(value, 6000, 48000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 absolute position with ready status command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 absolute position with ready status command!" + ) + cmd = "cmdError" return cmd def relativePickup(self, value: int): # number of steps x where 0 ≤ x ≤ 6,000 in standard mode or 0 ≤ x ≤ 48,000 in high resolution mode - cmd: str = 'P' + cmd: str = "P" if self.checkValueInInterval(value, 6000, 48000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 relative pickup command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 relative pickup command!" + ) + cmd = "cmdError" return cmd def relativePickupWithReadyStatus(self, value: int): # number of steps x where 0 ≤ x ≤ 6,000 in standard mode or 0 ≤ x ≤ 48,000 in high resolution mode - cmd: str = 'p' + cmd: str = "p" if self.checkValueInInterval(value, 6000, 48000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 relative pickup with ready status command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 relative pickup with ready status command!" + ) + cmd = "cmdError" return cmd def relativeDispense(self, value: int): # number of steps x where 0 ≤ x ≤ 6,000 in standard mode or 0 ≤ x ≤ 48,000 in high resolution mode - cmd: str = 'D' + cmd: str = "D" if self.checkValueInInterval(value, 6000, 48000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 relative dispense command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 relative dispense command!" + ) + cmd = "cmdError" return cmd def relativeDispenseWithReadyStatus(self, value: int): # number of steps x where 0 ≤ x ≤ 6,000 in standard mode or 0 ≤ x ≤ 48,000 in high resolution mode - cmd: str = 'd' + cmd: str = "d" if self.checkValueInInterval(value, 6000, 48000): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 relative dispense with ready status command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 relative dispense with ready status command!" + ) + cmd = "cmdError" return cmd def returnSteps(self, value: int): # Return Steps x where 0 ≤ x ≤ 100 in standard mode or 0 ≤ x ≤ 800 in high resolution mode - cmd: str = 'K' + cmd: str = "K" if self.checkValueInInterval(value, 100, 800): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 return steps command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 return steps command!" + ) + cmd = "cmdError" return cmd def backoffSteps(self, value: int): # Back-off Steps x where 0 ≤ x ≤ 200 in standard mode and 0≤ x ≤ 1,600 - cmd: str = 'k' + cmd: str = "k" if self.checkValueInInterval(value, 200, 1600): cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 backoff steps command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 backoff steps command!" + ) + cmd = "cmdError" return cmd """ @@ -105,39 +137,57 @@ def backoffSteps(self, value: int): def standardHighResolutionSelection(self, mode: int): # x=0 for standard resolution mode # x=1 for high resolution mode - cmd: str = 'N' + cmd: str = "N" if mode == 0 or mode == 1: cmd += str(mode) self.resolution_mode = mode - print("Resolution mode set on: " + str(mode) + " using PSD6 st/hg resolution selection") + print( + "Resolution mode set on: " + + str(mode) + + " using PSD6 st/hg resolution selection" + ) else: - cmd = 'cmdError' - print("Wrong parameter value for standard/high resolution selection command!") + cmd = "cmdError" + print( + "Wrong parameter value for standard/high resolution selection command!" + ) return cmd def setStartVelocity(self, value: int): - cmd: str = 'v' + cmd: str = "v" if 50 <= value <= 1000: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 start velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 start velocity command!" + ) + cmd = "cmdError" return cmd def setMaximumVelocity(self, value: int): - cmd: str = 'V' + cmd: str = "V" if 2 <= value <= 5800: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 set maximum velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 set maximum velocity command!" + ) + cmd = "cmdError" return cmd def stopVelocity(self, value: int): - cmd: str = 'c' + cmd: str = "c" if 50 <= value <= 2700: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 stop velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 stop velocity command!" + ) + cmd = "cmdError" return cmd diff --git a/PycroFlow/pyHamilton/commandPSD6SmoothFlow.py b/PycroFlow/pyHamilton/commandPSD6SmoothFlow.py index 8479849..5e579e3 100644 --- a/PycroFlow/pyHamilton/commandPSD6SmoothFlow.py +++ b/PycroFlow/pyHamilton/commandPSD6SmoothFlow.py @@ -7,52 +7,72 @@ def __init__(self, type): def absolutePosition(self, value): # absolute position x where 0 ≤ x ≤ 384000 - cmd: str = 'A' + cmd: str = "A" if 0 <= value <= 384000: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 smooth flow absolute position command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 smooth flow absolute position command!" + ) + cmd = "cmdError" return cmd def relativePickup(self, value: int): # number of steps x where 0 ≤ x ≤ 384.000 - cmd: str = 'P' + cmd: str = "P" if 0 <= value <= 384000: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 smooth flow relative pickup command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 smooth flow relative pickup command!" + ) + cmd = "cmdError" return cmd def relativeDispense(self, value: int): # number of steps x where 0 ≤ x ≤ 384.000 - cmd: str = 'D' + cmd: str = "D" if 0 <= value <= 384000: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 smooth flow relative dispense command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 smooth flow relative dispense command!" + ) + cmd = "cmdError" return cmd def returnSteps(self, value: int): # Return Steps x where 0 ≤ x ≤ 6.400 - cmd: str = 'K' + cmd: str = "K" if 0 <= value <= 6400: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 smooth flow return steps command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 smooth flow return steps command!" + ) + cmd = "cmdError" return cmd def backoffSteps(self, value: int): # Back-off Steps x where 0 ≤ x ≤ 12.800 - cmd: str = 'k' + cmd: str = "k" if 0 <= value <= 12800: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 smooth flow backoff steps command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 smooth flow backoff steps command!" + ) + cmd = "cmdError" return cmd """ @@ -60,53 +80,73 @@ def backoffSteps(self, value: int): """ def setStartVelocity(self, value: int): - cmd: str = 'v' + cmd: str = "v" if 50 <= value <= 800: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 smooth flow start velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 smooth flow start velocity command!" + ) + cmd = "cmdError" return cmd def setMaximumVelocity(self, value: int): - cmd: str = 'V' + cmd: str = "V" if 2 <= value <= 3400: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 smooth flow set maximum velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 smooth flow set maximum velocity command!" + ) + cmd = "cmdError" return cmd def stopVelocity(self, value: int): - cmd: str = 'c' + cmd: str = "c" if 50 <= value <= 1700: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 smooth flow stop velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 smooth flow stop velocity command!" + ) + cmd = "cmdError" return cmd # sets the maximum velocity in μsteps/minute. def setMaximumMicroStepVelocity(self, value): - cmd: str = 'u' + cmd: str = "u" if 400 <= value <= 816000: cmd += str(value) else: - print("Invalid value " + str(value) + " for PSD6 smooth flow set maximum microstep velocity command!") - cmd = 'cmdError' + print( + "Invalid value " + + str(value) + + " for PSD6 smooth flow set maximum microstep velocity command!" + ) + cmd = "cmdError" return cmd # sets the maximum velocity in μsteps/minute using as parameter microliters. def setMaximumMicroStepVelocityPerMinute(self, flowrate: float): - cmd: str = 'u' + cmd: str = "u" calculatedValue: int = self.calculateParameterU(flowrate) if 400 <= calculatedValue <= 816000: cmd += str(calculatedValue) else: - print("Invalid value \"" + str(calculatedValue) + "\" for PSD6 smooth flow set maximum microstep velocity command!") - cmd = 'cmdError' + print( + 'Invalid value "' + + str(calculatedValue) + + '" for PSD6 smooth flow set maximum microstep velocity command!' + ) + cmd = "cmdError" return cmd # test with a ps6 sf pump def stopCommandBuffer(self): - return 't' \ No newline at end of file + return "t" diff --git a/PycroFlow/pyHamilton/communication.py b/PycroFlow/pyHamilton/communication.py index c804854..c28bf79 100644 --- a/PycroFlow/pyHamilton/communication.py +++ b/PycroFlow/pyHamilton/communication.py @@ -11,6 +11,7 @@ it to cancel ``waitForResponse`` mid-poll. ``SerialBus`` reads this name at call time so external assignments stay effective. """ + import sys import threading import time @@ -18,7 +19,6 @@ import serial from loguru import logger - # External cancellation hook. Set this to a threading.Event so a long-running # waitForResponse() can be cancelled by setting the event from another # thread. Left as None by default for back-compat. @@ -27,18 +27,18 @@ # Pump-status bytes returned in the second position of a Hamilton response. # Used by waitForResponse to log human-readable status and to detect 'ready'. STATUS_BYTES_INFO = { - '@': "Pump is busy - no error", - '`': "Pump is ready - no error", - 'a': "Initialization error – occurs when the pump fails to initialize", - 'b': "Invalid command – occurs when an unrecognized command is used.", - 'c': "Invalid operand – occurs when an invalid parameter is given with a command.", - 'd': "Invalid command sequence – occurs when the command communication protocol is incorrect", - 'f': "EEPROM failure – occurs when the EEPROM is faulty", - 'g': "Syringe not initialized – occurs when the syringe fails to initialize", - 'i': "Syringe overload – occurs when the syringe encounters excessive back pressure.", - 'j': "Valve overload – occurs when the valve drive encounters excessive back pressure.", - 'k': "Syringe move not allowed – when the valve is in the bypass or throughput position, syringe move commands are not allowed.", - 'o': "Pump is busy – occurs when the command buffer is full" + "@": "Pump is busy - no error", + "`": "Pump is ready - no error", + "a": "Initialization error – occurs when the pump fails to initialize", + "b": "Invalid command – occurs when an unrecognized command is used.", + "c": "Invalid operand – occurs when an invalid parameter is given with a command.", + "d": "Invalid command sequence – occurs when the command communication protocol is incorrect", + "f": "EEPROM failure – occurs when the EEPROM is faulty", + "g": "Syringe not initialized – occurs when the syringe fails to initialize", + "i": "Syringe overload – occurs when the syringe encounters excessive back pressure.", + "j": "Valve overload – occurs when the valve drive encounters excessive back pressure.", + "k": "Syringe move not allowed – when the valve is in the bypass or throughput position, syringe move commands are not allowed.", + "o": "Pump is busy – occurs when the command buffer is full", } # Back-compat alias for code that previously imported statusBytesInfo. @@ -54,7 +54,7 @@ class SerialBus: consumers. """ - def __init__(self, com_port_prefix='COM'): + def __init__(self, com_port_prefix="COM"): self.ser = None self.com_port_prefix = com_port_prefix self.lock = threading.Lock() @@ -64,7 +64,7 @@ def initialize(self, comm_port, baudrate): self.ser.port = self.com_port_prefix + str(comm_port) self.ser.baudrate = baudrate self.ser.bytesize = 8 - self.ser.parity = 'N' + self.ser.parity = "N" self.ser.stopbits = 1 self.ser.xonxoff = False self.ser.rtscts = False @@ -72,7 +72,7 @@ def initialize(self, comm_port, baudrate): self.ser.timeout = 10 self.ser.open() if self.ser.isOpen(): - logger.debug('Open: ' + self.ser.portstr) + logger.debug("Open: " + self.ser.portstr) def disconnect(self): if self.ser is not None and self.ser.isOpen(): @@ -82,14 +82,14 @@ def encode_command(self, message): """Send ``message`` followed by CRLF, log the response. Does NOT serialize through the lock — call from contexts where you already hold it.""" - encoded = (message + '\r\n').encode() + encoded = (message + "\r\n").encode() self.ser.write(encoded) respond_bytes = self.ser.readline() - logger.debug('Response :' + respond_bytes.decode()) + logger.debug("Response :" + respond_bytes.decode()) def send_command(self, pump_address, message, wait_for_pump=False): - command_header = '/' + pump_address - command_footer = '\r\n' + command_header = "/" + pump_address + command_footer = "\r\n" command = command_header + message + command_footer logger.debug("Sending command " + command) encoded_command = command.encode() @@ -101,12 +101,12 @@ def send_command(self, pump_address, message, wait_for_pump=False): response = response_bytes.decode() except UnicodeDecodeError as exc: logger.exception(str(exc)) - response = '' + response = "" if wait_for_pump: self.wait_for_response(command_header, command_footer) - logger.debug('Response :' + response) + logger.debug("Response :" + response) return response def wait_for_response(self, header, footer): @@ -123,7 +123,7 @@ def wait_for_response(self, header, footer): comm_module = sys.modules[__name__] while True: time.sleep(0.02) - query = header + 'QR' + footer + query = header + "QR" + footer with self.lock: self.ser.write(query.encode()) respond_bytes = self.ser.readline() @@ -132,11 +132,14 @@ def wait_for_response(self, header, footer): response_bit = decoded[2:3] if response_bit in STATUS_BYTES_INFO: logger.debug( - "Pump status: " + response_bit + ' - ' - + STATUS_BYTES_INFO[response_bit]) - if response_bit == '`': + "Pump status: " + + response_bit + + " - " + + STATUS_BYTES_INFO[response_bit] + ) + if response_bit == "`": return - flag = getattr(comm_module, 'abort_wait_response_flag', None) + flag = getattr(comm_module, "abort_wait_response_flag", None) if flag is not None and flag.is_set(): logger.debug("waitForResponse aborted by external flag") return @@ -169,6 +172,7 @@ def set_bus(bus): # --- Module-level shim functions preserved for back-compat --------------- + def initializeSerial(commPort, baudrate): _BUS.initialize(commPort, baudrate) # keep the legacy module global in sync for callers that read it diff --git a/PycroFlow/pyHamilton/util.py b/PycroFlow/pyHamilton/util.py index d1f72a0..22b392b 100644 --- a/PycroFlow/pyHamilton/util.py +++ b/PycroFlow/pyHamilton/util.py @@ -2,65 +2,64 @@ class QueryCommandsEnumeration(Enum): - BUFFER_STATUS = 'F' - FIRMWARE_VERSION = '&' - FIRMWARE_CHECKSUM = '#' - PUMP_STATUS = 'Q' - ABSOLUTE_SYRINGE_POSITION = '?' - START_VELOCITY = '?1' - MAXIMUM_VELOCITY = '?2' - STOP_VELOCITY = '?3' - ACTUAL_SYRINGE_POSITION = '?4' - NUMBER_OF_RETURN_STEPS = '?12' - STATUS_AUXILIARY_INPUT_1 = '?13' - STATUS_AUXILIARY_INPUT_2 = '?14' - RETURNS_255 = '?22' - NUMBER_OF_BACKOFF_STEPS = '?24' - SYRINGE_STATUS = '?10000' - SYRINGE_HOME_SENSOR_STATUS = '?10001' - SYRINGE_MODE = '?11000' - VALVE_STATUS = '?20000' - VALVE_TYPE = '?21000' - VALVE_LOGICAL_POSITION = '?23000' - VALVE_NUMERICAL_POSITION = '?24000' - VALVE_ANGLE = '?25000' - LAST_DIGITAL_OUT_VALUE = '?37000' - SYRINGE_DIAGNOSTIC_TIMER_VALUE = '?38000' + BUFFER_STATUS = "F" + FIRMWARE_VERSION = "&" + FIRMWARE_CHECKSUM = "#" + PUMP_STATUS = "Q" + ABSOLUTE_SYRINGE_POSITION = "?" + START_VELOCITY = "?1" + MAXIMUM_VELOCITY = "?2" + STOP_VELOCITY = "?3" + ACTUAL_SYRINGE_POSITION = "?4" + NUMBER_OF_RETURN_STEPS = "?12" + STATUS_AUXILIARY_INPUT_1 = "?13" + STATUS_AUXILIARY_INPUT_2 = "?14" + RETURNS_255 = "?22" + NUMBER_OF_BACKOFF_STEPS = "?24" + SYRINGE_STATUS = "?10000" + SYRINGE_HOME_SENSOR_STATUS = "?10001" + SYRINGE_MODE = "?11000" + VALVE_STATUS = "?20000" + VALVE_TYPE = "?21000" + VALVE_LOGICAL_POSITION = "?23000" + VALVE_NUMERICAL_POSITION = "?24000" + VALVE_ANGLE = "?25000" + LAST_DIGITAL_OUT_VALUE = "?37000" + SYRINGE_DIAGNOSTIC_TIMER_VALUE = "?38000" class PSDTypes(Enum): - psd4 = '4' - psd6 = '6' - psd4SmoothFlow = '4sf' - psd6SmoothFlow = '6sf' + psd4 = "4" + psd6 = "6" + psd4SmoothFlow = "4sf" + psd6SmoothFlow = "6sf" class SyringeTypes(Enum): - syringe12uL = '12.5u' - syringe25uL = '25u' - syringe50uL = '50u' - syringe100uL = '100u' - syringe125uL = '125u' - syringe250uL = '250u' - syringe500uL = '500u' - syringe1mL = '1.0m' - syringe2mL = '2.5m' - syringe5mL = '5.0m' - syringe10mL = '10m' - syringe25mL = '25m' - syringe50mL = '50m' + syringe12uL = "12.5u" + syringe25uL = "25u" + syringe50uL = "50u" + syringe100uL = "100u" + syringe125uL = "125u" + syringe250uL = "250u" + syringe500uL = "500u" + syringe1mL = "1.0m" + syringe2mL = "2.5m" + syringe5mL = "5.0m" + syringe10mL = "10m" + syringe25mL = "25m" + syringe50mL = "50m" class SyringeMovement(Enum): - absoluteMovement = 'absolute' - relativePickup = 'pickup' - relativeDispense = 'dispense' - returnSteps = 'return' - backoffSteps = 'backoff' + absoluteMovement = "absolute" + relativePickup = "pickup" + relativeDispense = "dispense" + returnSteps = "return" + backoffSteps = "backoff" class VelocityTypes(Enum): - maxVelocity = 'max' - startVelocity = 'start' - stopVelocity = 'stop' - + maxVelocity = "max" + startVelocity = "start" + stopVelocity = "stop" diff --git a/PycroFlow/schemas/__init__.py b/PycroFlow/schemas/__init__.py index 93222be..d735c62 100644 --- a/PycroFlow/schemas/__init__.py +++ b/PycroFlow/schemas/__init__.py @@ -10,6 +10,7 @@ Stage 4 of the restructuring will turn these from validation-only into the canonical typed representation of protocol entries. """ + from PycroFlow.schemas.protocol_schema import ( Protocol, ProtocolEntry, diff --git a/PycroFlow/schemas/experiment_design.py b/PycroFlow/schemas/experiment_design.py index a9b9f47..6344a4b 100644 --- a/PycroFlow/schemas/experiment_design.py +++ b/PycroFlow/schemas/experiment_design.py @@ -18,6 +18,7 @@ The schema is the single source of truth both for builder/GUI validation and for the schema-driven structured editor (which renders from ``model_fields``). """ + from __future__ import annotations import sys @@ -38,7 +39,7 @@ # populate_by_name: accept both python name and JSON alias. # extra='allow': keep fields we have not modeled yet (forward-compat). -_CFG = ConfigDict(populate_by_name=True, extra='allow') +_CFG = ConfigDict(populate_by_name=True, extra="allow") def _field(default=..., *, alias=None, default_factory=None, **extra): @@ -57,7 +58,7 @@ def _field(default=..., *, alias=None, default_factory=None, **extra): (delays), ``min`` (incubations), ``ms`` (exposure), ``mW`` (laser power). """ extra = {k: v for k, v in extra.items() if v is not None} - kw = {'alias': alias, 'json_schema_extra': (extra or None)} + kw = {"alias": alias, "json_schema_extra": (extra or None)} if default_factory is not None: return Field(default_factory=default_factory, **kw) return Field(default, **kw) @@ -76,13 +77,13 @@ def field_meta(field_info) -> dict: field_info : pydantic.fields.FieldInfo An entry of ``model.model_fields``. """ - extra = getattr(field_info, 'json_schema_extra', None) + extra = getattr(field_info, "json_schema_extra", None) return dict(extra) if isinstance(extra, dict) else {} def field_unit(field_info) -> Optional[str]: """Return a model field's declared unit, or ``None``.""" - return field_meta(field_info).get('unit') + return field_meta(field_info).get("unit") class ExperimentDesignValidationError(ValueError): @@ -96,92 +97,111 @@ class ExperimentDesignValidationError(ValueError): # --- SPH-RESI nested blocks ---------------------------------------------- + class ResiRound(BaseModel): """One RESI round: which adapter, and how long to incubate it.""" + model_config = _CFG - adapter: str = _field(choices_from='reservoir_names', allow_none=True) - adapter_incubation: float = _unit(unit='min') + adapter: str = _field(choices_from="reservoir_names", allow_none=True) + adapter_incubation: float = _unit(unit="min") class TargetRound(BaseModel): """Per-target parameters for an SPH-RESI run.""" + model_config = _CFG bc_imager_pre: str = _field( - alias='BC_imager_pre', choices_from='reservoir_names', - allow_none=True) - frames_bc_pre: int = Field(alias='frames_BC_pre') + alias="BC_imager_pre", choices_from="reservoir_names", allow_none=True + ) + frames_bc_pre: int = Field(alias="frames_BC_pre") bc_imager_post: str = _field( - alias='BC_imager_post', choices_from='reservoir_names', - allow_none=True) - frames_bc_post: int = Field(alias='frames_BC_post') + alias="BC_imager_post", choices_from="reservoir_names", allow_none=True + ) + frames_bc_post: int = Field(alias="frames_BC_post") resi_imager: str = _field( - alias='RESI-imager', choices_from='reservoir_names', allow_none=True) - resi_frames: int = Field(alias='RESI-frames') - resi_rounds: List[ResiRound] = Field(alias='RESI-rounds') + alias="RESI-imager", choices_from="reservoir_names", allow_none=True + ) + resi_frames: int = Field(alias="RESI-frames") + resi_rounds: List[ResiRound] = Field(alias="RESI-rounds") class Round0(BaseModel): """Optional pre-target imaging round (e.g. alignment structures).""" + model_config = _CFG - round0_imager: str = _field(choices_from='reservoir_names', - allow_none=True) + round0_imager: str = _field( + choices_from="reservoir_names", allow_none=True + ) frames_round0: int # --- experiment-type design blocks (discriminated on ``type``) ----------- + class ExchangeExperiment(BaseModel): """Exchange-PAINT experiment design.""" + model_config = _CFG - type: Literal['Exchange'] - wash_buffer: str = _field(choices_from='reservoir_names', allow_none=True) + type: Literal["Exchange"] + wash_buffer: str = _field(choices_from="reservoir_names", allow_none=True) initial_imager: Optional[str] = _field( - None, choices_from='reservoir_names', allow_none=True) + None, choices_from="reservoir_names", allow_none=True + ) # One dropdown row per exchange round (add/remove), chosen from the # design's reservoir names; shown as a 'rounds' box with per-row labels. imagers: List[str] = _field( - default_factory=list, choices_from='reservoir_names', allow_none=True, - title='rounds', row_label='imager round {}') + default_factory=list, + choices_from="reservoir_names", + allow_none=True, + title="rounds", + row_label="imager round {}", + ) class SphResiExperiment(BaseModel): """SPH-RESI experiment design.""" + model_config = _CFG - type: Literal['SPH-RESI'] - wash_buffer_1: str = _field(choices_from='reservoir_names', - allow_none=True) + type: Literal["SPH-RESI"] + wash_buffer_1: str = _field( + choices_from="reservoir_names", allow_none=True + ) wash_buffer_2: Optional[str] = _field( - None, choices_from='reservoir_names', allow_none=True) - blocker: str = _field(choices_from='reservoir_names', allow_none=True) - blocker_incubation: float = _unit(unit='min') + None, choices_from="reservoir_names", allow_none=True + ) + blocker: str = _field(choices_from="reservoir_names", allow_none=True) + blocker_incubation: float = _unit(unit="min") initial_imager_present: bool = False round0: Optional[Round0] - target_rounds: Dict[str, TargetRound] = Field(alias='target-rounds') + target_rounds: Dict[str, TargetRound] = Field(alias="target-rounds") ExperimentBlock = Annotated[ Union[ExchangeExperiment, SphResiExperiment], - Field(discriminator='type'), + Field(discriminator="type"), ] # --- fluid / img / illu sections ----------------------------------------- + class FluidParameters(BaseModel): """Per-run fluid driver parameters (passed through to the Run Sequence).""" + model_config = _CFG - start_velocity: float = _unit(500, 'µl/min') - max_velocity: float = _unit(10000, 'µl/min') - stop_velocity: float = _unit(500, 'µl/min') - pumpout_dispense_velocity: float = _unit(290000, 'µl/min') - clean_velocity: float = _unit(10000, 'µl/min') - clean_delay: float = _unit(0, 's') - mode: str = _field('tubing_ignore', - choices=['tubing_ignore', 'tubing_stack']) + start_velocity: float = _unit(500, "µl/min") + max_velocity: float = _unit(10000, "µl/min") + stop_velocity: float = _unit(500, "µl/min") + pumpout_dispense_velocity: float = _unit(290000, "µl/min") + clean_velocity: float = _unit(10000, "µl/min") + clean_delay: float = _unit(0, "s") + mode: str = _field( + "tubing_ignore", choices=["tubing_ignore", "tubing_stack"] + ) extractionfactor: float = 1 - inject_pickup_extravol: float = _unit(0, 'µl') - inject_in_to_out_delay: float = _unit(0, 's') - inject_out_to_in_delay: float = _unit(0, 's') + inject_pickup_extravol: float = _unit(0, "µl") + inject_in_to_out_delay: float = _unit(0, "s") + inject_out_to_in_delay: float = _unit(0, "s") inject_precreate_underpressure: bool = False @@ -192,37 +212,44 @@ class FluidSettings(BaseModel): design refers to). Wash buffers are not repeated here — they live in the ``experiment`` block. """ + model_config = _CFG reservoir_names: Dict[int, str] = _field( - key_choices_from='reservoir_ids', - columns=['Reservoir ID', 'Name'], + key_choices_from="reservoir_ids", + columns=["Reservoir ID", "Name"], # The names defined here populate the imager/buffer dropdowns; publish # them live so those dropdowns update as the table is edited. - provides='reservoir_names') + provides="reservoir_names", + ) # Stored name -> id, but displayed (ID, name) for consistency with # reservoir_names (display_value_first swaps the columns; the id column # offers the setup's reservoir ids). special_names: Dict[str, int] = _field( - default_factory=dict, display_value_first=True, - value_choices_from='reservoir_ids', - columns=['Reservoir ID', 'Special name']) - vol_wash: float = _unit(unit='µl') - vol_reagent: Optional[float] = _unit(None, 'µl') - vol_imager_post: Optional[float] = _unit(None, 'µl') - vol_remove_before_flush: float = _unit(0, 'µl') - wait_after_pickup: float = _unit(0, 's') + default_factory=dict, + display_value_first=True, + value_choices_from="reservoir_ids", + columns=["Reservoir ID", "Special name"], + ) + vol_wash: float = _unit(unit="µl") + vol_reagent: Optional[float] = _unit(None, "µl") + vol_imager_post: Optional[float] = _unit(None, "µl") + vol_remove_before_flush: float = _unit(0, "µl") + wait_after_pickup: float = _unit(0, "s") cleaning_reservoirs: List[Union[int, str]] = _field( default_factory=list, - tooltip='Comma-separated reservoir ids or special names used for ' - 'cleaning, e.g. "h2o, ipa".') + tooltip="Comma-separated reservoir ids or special names used for " + 'cleaning, e.g. "h2o, ipa".', + ) experiment: ExperimentBlock class FluidSection(BaseModel): model_config = _CFG enabled: bool = _field( - True, tooltip='Include this subsystem when translating to the Run ' - 'Sequence. Deselect to leave it out of the run.') + True, + tooltip="Include this subsystem when translating to the Run " + "Sequence. Deselect to leave it out of the run.", + ) parameters: FluidParameters = Field(default_factory=FluidParameters) settings: FluidSettings @@ -236,7 +263,7 @@ class ImgParameters(BaseModel): class ImgSettings(BaseModel): model_config = _CFG - t_exp: float = _unit(unit='ms') + t_exp: float = _unit(unit="ms") # frames may be a single count or a per-imager mapping (Exchange). frames: Optional[Union[int, Dict[str, int]]] = None darkframes: Optional[int] = None @@ -245,22 +272,24 @@ class ImgSettings(BaseModel): class ImgSection(BaseModel): model_config = _CFG enabled: bool = _field( - True, tooltip='Include this subsystem when translating to the Run ' - 'Sequence. Deselect to leave it out of the run.') + True, + tooltip="Include this subsystem when translating to the Run " + "Sequence. Deselect to leave it out of the run.", + ) parameters: ImgParameters = Field(default_factory=ImgParameters) settings: ImgSettings class IlluSettings(BaseModel): model_config = _CFG - laser: int = _field(choices_from='lasers') - power_acq: float = _unit(unit='mW') - power_nonacq: Optional[float] = _unit(None, 'mW') - warmup_delay: float = _unit(0, 's') + laser: int = _field(choices_from="lasers") + power_acq: float = _unit(unit="mW") + power_nonacq: Optional[float] = _unit(None, "mW") + warmup_delay: float = _unit(0, "s") shutter_off_nonacq: bool = False lasers_off_finally: bool = False - @model_validator(mode='after') + @model_validator(mode="after") def _default_nonacq_power(self): # The builder emits a non-acquisition 'set power' step; default it to # the acquisition power when not given so it is never None. @@ -274,16 +303,19 @@ class IlluSection(BaseModel): # setup (not the design), and the old channel_group/filter/ROI were unused. model_config = _CFG enabled: bool = _field( - True, tooltip='Include this subsystem when translating to the Run ' - 'Sequence. Deselect to leave it out of the run.') + True, + tooltip="Include this subsystem when translating to the Run " + "Sequence. Deselect to leave it out of the run.", + ) settings: IlluSettings class ExperimentDesign(BaseModel): """Top-level experiment design (compiles to a Run Sequence).""" + model_config = _CFG base_name: str - save_dir: str = '.' + save_dir: str = "." fluid: FluidSection img: ImgSection illu: Optional[IlluSection] = None diff --git a/PycroFlow/schemas/protocol_schema.py b/PycroFlow/schemas/protocol_schema.py index fd1527c..2fc912d 100644 --- a/PycroFlow/schemas/protocol_schema.py +++ b/PycroFlow/schemas/protocol_schema.py @@ -16,6 +16,7 @@ of the discriminated union still catches typos in ``$type`` and missing required fields. """ + from __future__ import annotations import sys @@ -31,14 +32,13 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError - # Configure each entry model so: # - the ``$type`` JSON key maps to the model field named ``kind`` via alias # - extra fields are preserved rather than rejected (back-compat) # - models can be constructed from Python attribute names OR JSON aliases _ENTRY_CONFIG = ConfigDict( populate_by_name=True, - extra='allow', + extra="allow", ) @@ -50,16 +50,17 @@ class SchemaValidationError(ValueError): # --- Fluid-subsystem entries --------------------------------------------- + class InjectEntry(BaseModel): model_config = _ENTRY_CONFIG - kind: Literal['inject'] = Field(alias='$type') + kind: Literal["inject"] = Field(alias="$type") reservoir_id: int volume: float class IncubateEntry(BaseModel): model_config = _ENTRY_CONFIG - kind: Literal['incubate'] = Field(alias='$type') + kind: Literal["incubate"] = Field(alias="$type") # orchestration.run_protocol coerces with float(), so str values are # accepted in practice (test_protocols.test_06 even asserts a string). duration: Union[float, str] @@ -67,35 +68,37 @@ class IncubateEntry(BaseModel): class FlushEntry(BaseModel): model_config = _ENTRY_CONFIG - kind: Literal['flush'] = Field(alias='$type') + kind: Literal["flush"] = Field(alias="$type") flushfactor: float class PumpOutEntry(BaseModel): """Pump-out-only step (no aspirate). ProtocolBuilder produces this for 'remove before wash' segments of Exchange-PAINT protocols.""" + model_config = _ENTRY_CONFIG - kind: Literal['pump_out'] = Field(alias='$type') + kind: Literal["pump_out"] = Field(alias="$type") volume: float class AwaitAcquisitionEntry(BaseModel): model_config = _ENTRY_CONFIG - kind: Literal['await_acquisition'] = Field(alias='$type') + kind: Literal["await_acquisition"] = Field(alias="$type") # --- Cross-subsystem coordination entries -------------------------------- + class SignalEntry(BaseModel): model_config = _ENTRY_CONFIG - kind: Literal['signal'] = Field(alias='$type') + kind: Literal["signal"] = Field(alias="$type") value: str target: Optional[str] = None class WaitForSignalEntry(BaseModel): model_config = _ENTRY_CONFIG - kind: Literal['wait for signal'] = Field(alias='$type') + kind: Literal["wait for signal"] = Field(alias="$type") target: str value: str timeout: Optional[float] = None @@ -103,38 +106,41 @@ class WaitForSignalEntry(BaseModel): # --- Imaging-subsystem entries ------------------------------------------- + class AcquireEntry(BaseModel): model_config = _ENTRY_CONFIG - kind: Literal['acquire'] = Field(alias='$type') + kind: Literal["acquire"] = Field(alias="$type") frames: int t_exp: float # --- Illumination-subsystem entries -------------------------------------- + class PowerEntry(BaseModel): """Legacy demo type — single-value power adjustment.""" + model_config = _ENTRY_CONFIG - kind: Literal['power'] = Field(alias='$type') + kind: Literal["power"] = Field(alias="$type") value: float class SetPowerEntry(BaseModel): model_config = _ENTRY_CONFIG - kind: Literal['set power'] = Field(alias='$type') + kind: Literal["set power"] = Field(alias="$type") laser: int power: float class SetShutterEntry(BaseModel): model_config = _ENTRY_CONFIG - kind: Literal['set shutter'] = Field(alias='$type') + kind: Literal["set shutter"] = Field(alias="$type") state: bool class LaserEnableEntry(BaseModel): model_config = _ENTRY_CONFIG - kind: Literal['laser enable'] = Field(alias='$type') + kind: Literal["laser enable"] = Field(alias="$type") laser: object # int OR the string 'all'; tighten in Stage 4 state: bool @@ -157,13 +163,14 @@ class LaserEnableEntry(BaseModel): SetShutterEntry, LaserEnableEntry, ], - Field(discriminator='kind'), + Field(discriminator="kind"), ] class SubsystemProtocol(BaseModel): """One subsystem's slice of the protocol.""" - model_config = ConfigDict(extra='allow') + + model_config = ConfigDict(extra="allow") protocol_entries: List[ProtocolEntry] parameters: Optional[dict] = None @@ -171,7 +178,8 @@ class SubsystemProtocol(BaseModel): class Protocol(BaseModel): """Top-level protocol model. Every subsystem is optional; absent means that subsystem doesn't participate in the experiment.""" - model_config = ConfigDict(extra='allow') + + model_config = ConfigDict(extra="allow") fluid: Optional[SubsystemProtocol] = None img: Optional[SubsystemProtocol] = None illu: Optional[SubsystemProtocol] = None diff --git a/PycroFlow/services/__init__.py b/PycroFlow/services/__init__.py index 10b6093..7349f85 100644 --- a/PycroFlow/services/__init__.py +++ b/PycroFlow/services/__init__.py @@ -15,6 +15,7 @@ * :class:`SystemService` — hardware-control commands (manual pump moves, tubing fill, system cleanup) that the CLI exposes. """ + from PycroFlow.services.mm_core import get_core, get_studio, reset_core from PycroFlow.services.experiment_service import ( ExperimentService, diff --git a/PycroFlow/services/experiment_service.py b/PycroFlow/services/experiment_service.py index e80d256..ada09ec 100644 --- a/PycroFlow/services/experiment_service.py +++ b/PycroFlow/services/experiment_service.py @@ -11,6 +11,7 @@ :class:`SystemService`). It owns the orchestrator instance and the protocol that was loaded. """ + from __future__ import annotations import enum @@ -26,8 +27,9 @@ class ExperimentState(enum.Enum): """Lifecycle states observable by frontends.""" + IDLE = "idle" - LOADED = "loaded" # protocol parsed, orchestrator not started + LOADED = "loaded" # protocol parsed, orchestrator not started # handler threads running, protocol not started ORCHESTRATING = "orchestrating" RUNNING = "running" @@ -199,18 +201,20 @@ def _build_orchestrator(self) -> None: proto = self._protocol or {} self._orchestrator = ProtocolOrchestrator( self._protocol, - imaging_system=( - self._imaging_system if "img" in proto else None), - fluid_system=( - self._fluid_system if "fluid" in proto else None), + imaging_system=(self._imaging_system if "img" in proto else None), + fluid_system=(self._fluid_system if "fluid" in proto else None), illumination_system=( - self._illumination_system if "illu" in proto else None), + self._illumination_system if "illu" in proto else None + ), ) self._orchestrator_systems = self._current_systems() def _current_systems(self): - return (self._fluid_system, self._imaging_system, - self._illumination_system) + return ( + self._fluid_system, + self._imaging_system, + self._illumination_system, + ) def load_protocol_from_yaml(self, path: str) -> None: """Load a protocol from a YAML file (the format produced by @@ -234,14 +238,17 @@ def start(self, system_steps: Optional[Dict] = None) -> None: # system=None and the protocol silently finishes immediately); # skipping it when systems are unchanged preserves an # externally-set orchestrator. - if (self._state is not ExperimentState.LOADED - or self._current_systems() != self._orchestrator_systems): + if ( + self._state is not ExperimentState.LOADED + or self._current_systems() != self._orchestrator_systems + ): self._build_orchestrator() if not any(self._current_systems()): logger.warning( "Starting with no subsystems connected — the protocol " "will finish immediately. Connect hardware (or the " - "Emulator setup) in the System tab first.") + "Emulator setup) in the System tab first." + ) self._orchestrator.start_orchestration() self._set_state(ExperimentState.ORCHESTRATING) if self._state in ( @@ -332,15 +339,18 @@ def progress(self) -> Dict: if self._orchestrator is None or self._protocol is None: return {} handlers = { - 'fluid': self._orchestrator.fluid_handler, - 'img': self._orchestrator.imaging_handler, - 'illu': self._orchestrator.illumination_handler, + "fluid": self._orchestrator.fluid_handler, + "img": self._orchestrator.imaging_handler, + "illu": self._orchestrator.illumination_handler, } out = {} for key, handler in handlers.items(): sub = self._protocol.get(key, {}) - entries = sub.get('protocol_entries', []) if isinstance( - sub, dict) else [] + entries = ( + sub.get("protocol_entries", []) + if isinstance(sub, dict) + else [] + ) total = len(entries) if handler.system is None: cur = total @@ -363,13 +373,13 @@ def step_progress(self) -> Dict: if self._orchestrator is None: return {} handlers = { - 'fluid': self._orchestrator.fluid_handler, - 'img': self._orchestrator.imaging_handler, - 'illu': self._orchestrator.illumination_handler, + "fluid": self._orchestrator.fluid_handler, + "img": self._orchestrator.imaging_handler, + "illu": self._orchestrator.illumination_handler, } out = {} for key, handler in handlers.items(): - getter = getattr(handler, 'get_step_progress', None) + getter = getattr(handler, "get_step_progress", None) out[key] = getter() if getter is not None else None return out @@ -408,8 +418,8 @@ def _set_state(self, new_state: ExperimentState) -> None: if old is new_state: return logger.debug( - "ExperimentService: {} -> {}".format( - old.value, new_state.value)) + "ExperimentService: {} -> {}".format(old.value, new_state.value) + ) for fn in list(self._state_observers): try: fn(old, new_state) diff --git a/PycroFlow/services/mm_core.py b/PycroFlow/services/mm_core.py index 0ea9617..2146591 100644 --- a/PycroFlow/services/mm_core.py +++ b/PycroFlow/services/mm_core.py @@ -13,13 +13,11 @@ Lazy initialization keeps the module importable without ``pycromanager`` on dev / CI machines. """ -from __future__ import annotations - -from typing import Optional +from __future__ import annotations -_core = None # type: Optional[object] -_studio = None # type: Optional[object] +_core: object | None = None +_studio: object | None = None def get_core(): @@ -27,6 +25,7 @@ def get_core(): global _core if _core is None: from pycromanager import Core + _core = Core() return _core @@ -36,6 +35,7 @@ def get_studio(): global _studio if _studio is None: from pycromanager import Studio + _studio = Studio(convert_camel_case=True) return _studio diff --git a/PycroFlow/services/system_service.py b/PycroFlow/services/system_service.py index fbdfe01..c5396f6 100644 --- a/PycroFlow/services/system_service.py +++ b/PycroFlow/services/system_service.py @@ -5,6 +5,7 @@ ``frontend_cli`` poked ``self.fluid_system._pump`` directly — that's the sort of leakage this service eliminates. """ + from __future__ import annotations from loguru import logger @@ -25,6 +26,7 @@ def _load_yaml_or_dict(config): """ if isinstance(config, str): import yaml + with open(config) as f: return yaml.full_load(f) return config @@ -69,8 +71,8 @@ def load_setup(self, name): from PycroFlow.configs import load_setup self._setup = load_setup(name) - self._setup_name = self._setup.get('setup', name) - if self._setup.get('emulated'): + self._setup_name = self._setup.get("setup", name) + if self._setup.get("emulated"): # Warm the emulator import on THIS (main) thread. Importing the # tests package runs install_hardware_mocks(), whose subprocess # imports deadlock if first triggered from a worker thread; doing @@ -88,7 +90,7 @@ def get_monet_setup(self): def is_emulated(self) -> bool: """Whether the loaded setup runs against emulated hardware.""" - return bool(self._setup and self._setup.get('emulated')) + return bool(self._setup and self._setup.get("emulated")) def reservoir_ids(self) -> list: """Reservoir ids wired in the current setup's manifold (sorted). @@ -98,10 +100,10 @@ def reservoir_ids(self) -> list: """ if not self._setup: return [] - manifold = (self._setup.get('hamilton', {}) - or {}).get('reservoir_a_manifold', []) - ids = [e['id'] for e in manifold - if isinstance(e, dict) and 'id' in e] + manifold = (self._setup.get("hamilton", {}) or {}).get( + "reservoir_a_manifold", [] + ) + ids = [e["id"] for e in manifold if isinstance(e, dict) and "id" in e] return sorted(ids) def laser_options(self) -> list: @@ -118,11 +120,14 @@ def laser_options(self) -> list: import monet except Exception: return [] - configs = getattr(monet, 'CONFIGS', None) + configs = getattr(monet, "CONFIGS", None) if not isinstance(configs, dict): return [] - lasers = (configs.get(name) or {}).get('lasers') \ - if isinstance(configs.get(name), dict) else None + lasers = ( + (configs.get(name) or {}).get("lasers") + if isinstance(configs.get(name), dict) + else None + ) if not isinstance(lasers, dict): return [] return sorted(lasers.keys()) @@ -139,9 +144,9 @@ def connection_states(self) -> dict: (True when that subsystem object has been built/connected). """ return { - 'fluid': self.fluid_system is not None, - 'imaging': self.imaging_system is not None, - 'illumination': self.illumination_system is not None, + "fluid": self.fluid_system is not None, + "imaging": self.imaging_system is not None, + "illumination": self.illumination_system is not None, } def connect_fluid(self, fluid): @@ -173,33 +178,37 @@ def connect_fluid(self, fluid): from PycroFlow.configs import assemble_hamilton_config import PycroFlow.hamilton_architecture as ha - if 'settings' in fluid: - settings = fluid['settings'] - parameters = fluid.get('parameters', {}) + if "settings" in fluid: + settings = fluid["settings"] + parameters = fluid.get("parameters", {}) else: # a bare settings dict settings = fluid parameters = {} hamilton, tubing = assemble_hamilton_config(self._setup, settings) - if hamilton.get('system_type') != 'legacy': + if hamilton.get("system_type") != "legacy": raise NotImplementedError( "system_type {!r} is not implemented".format( - hamilton.get('system_type'))) - interface = hamilton['interface'] + hamilton.get("system_type") + ) + ) + interface = hamilton["interface"] if self.is_emulated(): from PycroFlow.tests.emulators import patch_serial + with patch_serial(): - ha.connect(interface['COM'], interface['baud']) + ha.connect(interface["COM"], interface["baud"]) self.fluid_system = ha.LegacyArchitecture(hamilton, tubing) else: - ha.connect(interface['COM'], interface['baud']) + ha.connect(interface["COM"], interface["baud"]) self.fluid_system = ha.LegacyArchitecture(hamilton, tubing) # Seed parameters so manual fill/clean/pump work before a Run # Sequence is loaded; translate later re-assigns the full protocol. self.fluid_system._assign_protocol( - {'parameters': dict(parameters), 'protocol_entries': []}) + {"parameters": dict(parameters), "protocol_entries": []} + ) return self.fluid_system def connect_imaging(self, imaging_config=None): @@ -223,11 +232,14 @@ def connect_imaging(self, imaging_config=None): """ if self.is_emulated(): from PycroFlow.tests.emulators import EmulatedImagingSystem + self.imaging_system = EmulatedImagingSystem() else: import PycroFlow.imaging as im + self.imaging_system = im.ImagingSystem( - _load_yaml_or_dict(imaging_config)) + _load_yaml_or_dict(imaging_config) + ) return self.imaging_system def connect_illumination(self): @@ -245,11 +257,14 @@ def connect_illumination(self): """ if self.is_emulated(): from PycroFlow.tests.emulators import EmulatedIlluminationSystem + self.illumination_system = EmulatedIlluminationSystem() else: import PycroFlow.illumination as il + self.illumination_system = il.IlluminationSystem( - setup=self.get_monet_setup()) + setup=self.get_monet_setup() + ) return self.illumination_system # --- Disconnection ------------------------------------------------- @@ -260,8 +275,10 @@ def disconnect_fluid(self) -> None: return try: import PycroFlow.hamilton_architecture as ha + if self.is_emulated(): from PycroFlow.tests.emulators import patch_serial + with patch_serial(): ha.disconnect() else: @@ -275,7 +292,7 @@ def disconnect_imaging(self) -> None: if self.imaging_system is None: return try: - if hasattr(self.imaging_system, 'close'): + if hasattr(self.imaging_system, "close"): self.imaging_system.close() except Exception as exc: logger.warning("imaging disconnect failed: {!r}".format(exc)) @@ -286,7 +303,7 @@ def disconnect_illumination(self) -> None: if self.illumination_system is None: return try: - for name in ('close', 'shutdown', 'disconnect'): + for name in ("close", "shutdown", "disconnect"): fn = getattr(self.illumination_system, name, None) if callable(fn): fn() @@ -298,9 +315,9 @@ def disconnect_illumination(self) -> None: def disconnect(self, key: str) -> None: """Disconnect one subsystem ('fluid'/'imaging'/'illumination').""" { - 'fluid': self.disconnect_fluid, - 'imaging': self.disconnect_imaging, - 'illumination': self.disconnect_illumination, + "fluid": self.disconnect_fluid, + "imaging": self.disconnect_imaging, + "illumination": self.disconnect_illumination, }[key]() def disconnect_all(self) -> None: @@ -312,22 +329,22 @@ def disconnect_all(self) -> None: # --- Fluid --------------------------------------------------------- def fill_tubings(self) -> None: - self._require('fluid_system') + self._require("fluid_system") self.fluid_system.fill_tubings() def clean_tubings(self) -> None: # confirm=False: no terminal prompt — GUI callers confirm via a # dialog before calling, and there is no stdin to block on. - self._require('fluid_system') + self._require("fluid_system") self.fluid_system.clean_tubings(confirm=False) def deliver_fluid(self, reservoir_id: int, volume: float) -> None: - self._require('fluid_system') + self._require("fluid_system") self.fluid_system.deliver_fluid(reservoir_id, volume) def set_valves(self, reservoir_id: int) -> None: """Route the manifold valves to access ``reservoir_id`` (manual).""" - self._require('fluid_system') + self._require("fluid_system") self.fluid_system._set_valves(reservoir_id) def stop_all_moves(self) -> None: @@ -346,14 +363,15 @@ def manual_pump(self, pump_name: str, *args, **kwargs): ``pump_name`` is one of the keys exposed by the fluid system (e.g. ``'pump_a'``, ``'pump_out'``). Extra args are forwarded. """ - self._require('fluid_system') - pump_method = getattr(self.fluid_system, '_pump', None) + self._require("fluid_system") + pump_method = getattr(self.fluid_system, "_pump", None) if pump_method is None: raise RuntimeError("fluid_system has no _pump method") pump_obj = getattr(self.fluid_system, pump_name, None) if pump_obj is None: raise KeyError( - "no such pump on fluid_system: {!r}".format(pump_name)) + "no such pump on fluid_system: {!r}".format(pump_name) + ) return pump_method(pump_obj, *args, **kwargs) # --- Imaging ------------------------------------------------------- @@ -362,21 +380,21 @@ def close_imaging(self) -> None: """Release the MM Core lock (Stage 1) and any other resources.""" if self.imaging_system is None: return - if hasattr(self.imaging_system, 'close'): + if hasattr(self.imaging_system, "close"): self.imaging_system.close() # --- Illumination -------------------------------------------------- def set_laser(self, laser: int) -> None: - self._require('illumination_system') + self._require("illumination_system") self.illumination_system.set_laser(laser) def set_laser_enabled(self, laser: int, enabled: bool = True) -> None: - self._require('illumination_system') + self._require("illumination_system") self.illumination_system.set_laser_enabled(laser, enabled=enabled) def set_sample_power(self, power: float, warmup_delay: float = 0) -> None: - self._require('illumination_system') + self._require("illumination_system") self.illumination_system.set_sample_power(power, warmup_delay) # --- Cleanup ------------------------------------------------------- @@ -392,6 +410,6 @@ def _require(self, attr: str) -> None: if getattr(self, attr, None) is None: raise RuntimeError( "SystemService.{} is None; no {} configured".format( - attr, attr.replace('_system', '') + attr, attr.replace("_system", "") ) ) diff --git a/PycroFlow/tests/__init__.py b/PycroFlow/tests/__init__.py index 1b06458..cdc6316 100644 --- a/PycroFlow/tests/__init__.py +++ b/PycroFlow/tests/__init__.py @@ -15,6 +15,7 @@ $ cd /Users/hgrabmayr/GitHub/PycroFlow $ python -m unittest -v """ + import atexit import os import shutil @@ -25,14 +26,15 @@ # pycromanager / monet / pycobolt / nidaqmx installed. No-op when the real # library is importable. from PycroFlow.tests._mock_hardware import install_hardware_mocks + install_hardware_mocks() TEST_FIXTURES_DIR = os.path.abspath( - os.path.join(os.path.dirname(__file__), os.pardir, 'TestData') + os.path.join(os.path.dirname(__file__), os.pardir, "TestData") ) -TEST_OUTPUT_DIR = tempfile.mkdtemp(prefix='pycroflow-test-') +TEST_OUTPUT_DIR = tempfile.mkdtemp(prefix="pycroflow-test-") @atexit.register diff --git a/PycroFlow/tests/_mock_hardware.py b/PycroFlow/tests/_mock_hardware.py index 9a23915..155bcad 100644 --- a/PycroFlow/tests/_mock_hardware.py +++ b/PycroFlow/tests/_mock_hardware.py @@ -13,32 +13,32 @@ keep hardware integration tests gated on real-SDK availability with ``unittest.skipUnless``. """ + import importlib import sys from unittest.mock import MagicMock - _HARDWARE_MODULES = [ - 'pycromanager', - 'pycromanager.acquisitions', - 'pycromanager.acq_util', - 'pycromanager.zmq_bridge', - 'monet', - 'monet.control', - 'monet.gui', - 'monet.beampath', - 'pycobolt', - 'nidaqmx', - 'ThorlabsPM100', - 'pyvisa', - 'msl', - 'msl.equipment', - 'Arduino', - 'pandas', - 'lmfit', - 'matplotlib', - 'matplotlib.pyplot', - 'PyHamiltonPSD', + "pycromanager", + "pycromanager.acquisitions", + "pycromanager.acq_util", + "pycromanager.zmq_bridge", + "monet", + "monet.control", + "monet.gui", + "monet.beampath", + "pycobolt", + "nidaqmx", + "ThorlabsPM100", + "pyvisa", + "msl", + "msl.equipment", + "Arduino", + "pandas", + "lmfit", + "matplotlib", + "matplotlib.pyplot", + "PyHamiltonPSD", ] diff --git a/PycroFlow/tests/emulators/__init__.py b/PycroFlow/tests/emulators/__init__.py index 7534c63..412d7c5 100644 --- a/PycroFlow/tests/emulators/__init__.py +++ b/PycroFlow/tests/emulators/__init__.py @@ -22,6 +22,7 @@ (which only let imports succeed), these emulators model device *behavior* and so support real behavioral assertions. """ + from PycroFlow.tests.emulators.hal_devices import ( EmulatedPump, EmulatedValve, @@ -44,16 +45,16 @@ ) __all__ = [ - 'EmulatedPump', - 'EmulatedValve', - 'EmulatedSpillSensor', - 'FakeHamiltonSerial', - 'EmulatedHamiltonDevice', - 'patch_serial', - 'make_fake_bus', - 'FakeArduinoSerial', - 'connect_interface', - 'EmulatedFluidSystem', - 'EmulatedImagingSystem', - 'EmulatedIlluminationSystem', + "EmulatedPump", + "EmulatedValve", + "EmulatedSpillSensor", + "FakeHamiltonSerial", + "EmulatedHamiltonDevice", + "patch_serial", + "make_fake_bus", + "FakeArduinoSerial", + "connect_interface", + "EmulatedFluidSystem", + "EmulatedImagingSystem", + "EmulatedIlluminationSystem", ] diff --git a/PycroFlow/tests/emulators/arduino_serial.py b/PycroFlow/tests/emulators/arduino_serial.py index 633bfbb..ebace04 100644 --- a/PycroFlow/tests/emulators/arduino_serial.py +++ b/PycroFlow/tests/emulators/arduino_serial.py @@ -20,6 +20,7 @@ already attached to a fake (patching out the 2 s init sleep), so a test can poll or monitor immediately. """ + from __future__ import annotations from contextlib import contextmanager @@ -64,31 +65,31 @@ def write(self, data): return len(data) def readline(self): - idx = self._rx.find(b'\n') + idx = self._rx.find(b"\n") if idx == -1: line, self._rx = bytes(self._rx), bytearray() return line - line = bytes(self._rx[:idx + 1]) - del self._rx[:idx + 1] + line = bytes(self._rx[: idx + 1]) + del self._rx[: idx + 1] return line # -- protocol ------------------------------------------------------------- def _reply_for(self, ch): - if ch == 'H': - return 'HANDSHAKE_OK\n' - if ch == 'P': - return ('WET' if self.wet else 'DRY') + '\n' - if ch == 'B': - return 'START_BROADCAST_OK\n' - if ch == 'S': - return 'STOP_BROADCAST_OK\n' - if ch == 'R': - return '' - return '' + if ch == "H": + return "HANDSHAKE_OK\n" + if ch == "P": + return ("WET" if self.wet else "DRY") + "\n" + if ch == "B": + return "START_BROADCAST_OK\n" + if ch == "S": + return "STOP_BROADCAST_OK\n" + if ch == "R": + return "" + return "" @contextmanager -def connect_interface(port='COM-EMU', wet=False): +def connect_interface(port="COM-EMU", wet=False): """Yield a connected ``ArduinoSensorInterface`` backed by a fake serial. Patches ``serial.Serial`` in the spill-sensor module and ``time.sleep`` so @@ -99,12 +100,14 @@ def connect_interface(port='COM-EMU', wet=False): from PycroFlow import spill_sensor_arduino as ssa fake = FakeArduinoSerial(wet=wet) - with mock.patch.object(ssa.serial, 'Serial', return_value=fake), \ - mock.patch.object(ssa.time, 'sleep', lambda *a, **k: None): + with ( + mock.patch.object(ssa.serial, "Serial", return_value=fake), + mock.patch.object(ssa.time, "sleep", lambda *a, **k: None), + ): iface = ssa.ArduinoSensorInterface(port=port) ok = iface.connect() if not ok: - raise RuntimeError('emulated Arduino handshake failed') + raise RuntimeError("emulated Arduino handshake failed") try: yield iface finally: diff --git a/PycroFlow/tests/emulators/hal_devices.py b/PycroFlow/tests/emulators/hal_devices.py index 9a7a701..907115f 100644 --- a/PycroFlow/tests/emulators/hal_devices.py +++ b/PycroFlow/tests/emulators/hal_devices.py @@ -11,6 +11,7 @@ Each device keeps a ``commands`` log of ``(method, kwargs)`` tuples so tests can assert the sequence of operations without coupling to vendor command strings. """ + from __future__ import annotations import threading @@ -28,8 +29,14 @@ class EmulatedPump(Pump): forgiving behavior of the real firmware (which simply stalls). """ - def __init__(self, address='emu-pump', syringe_volume=500.0, - input_pos='in', output_pos='out', waste_pos=None): + def __init__( + self, + address="emu-pump", + syringe_volume=500.0, + input_pos="in", + output_pos="out", + waste_pos=None, + ): self.address = address self.syringe_volume = float(syringe_volume) self.input_pos = input_pos @@ -45,9 +52,12 @@ def __init__(self, address='emu-pump', syringe_volume=500.0, def _log(self, method, **kwargs): self.commands.append((method, kwargs)) - def pickup(self, vol, velocity=None, waitForPump=False, - override_pause_flag=False): - self._log('pickup', vol=vol, velocity=velocity, waitForPump=waitForPump) + def pickup( + self, vol, velocity=None, waitForPump=False, override_pause_flag=False + ): + self._log( + "pickup", vol=vol, velocity=velocity, waitForPump=waitForPump + ) self.last_velocity = velocity self.target_volume = min(self.syringe_volume, self.target_volume + vol) if waitForPump: @@ -55,10 +65,12 @@ def pickup(self, vol, velocity=None, waitForPump=False, else: self.moving = True - def dispense(self, vol, velocity=None, waitForPump=False, - override_pause_flag=False): - self._log('dispense', vol=vol, velocity=velocity, - waitForPump=waitForPump) + def dispense( + self, vol, velocity=None, waitForPump=False, override_pause_flag=False + ): + self._log( + "dispense", vol=vol, velocity=velocity, waitForPump=waitForPump + ) self.last_velocity = velocity self.target_volume = max(0.0, self.target_volume - vol) if waitForPump: @@ -67,20 +79,20 @@ def dispense(self, vol, velocity=None, waitForPump=False, self.moving = True def set_valve(self, pos, move_now=True): - if pos == 'in': + if pos == "in": pos = self.input_pos - elif pos == 'out': + elif pos == "out": pos = self.output_pos - self._log('set_valve', pos=pos, move_now=move_now) + self._log("set_valve", pos=pos, move_now=move_now) self.valve_pos = pos def wait_until_done(self): - self._log('wait_until_done') + self._log("wait_until_done") self.volume = self.target_volume self.moving = False def stop_current_move(self): - self._log('stop_current_move') + self._log("stop_current_move") # The real pump freezes wherever it is; reflect that by syncing the # target to the (unchanged) current volume. self.target_volume = self.volume @@ -93,7 +105,7 @@ def get_current_volume(self): class EmulatedValve(Valve): """An in-memory multi-position rotary valve.""" - def __init__(self, address='emu-valve', n_positions=8): + def __init__(self, address="emu-valve", n_positions=8): self.address = address self.n_positions = n_positions self.position = None @@ -101,7 +113,7 @@ def __init__(self, address='emu-valve', n_positions=8): self.commands = [] def set_valve(self, pos, move_now=True): - self.commands.append(('set_valve', {'pos': pos, 'move_now': move_now})) + self.commands.append(("set_valve", {"pos": pos, "move_now": move_now})) if move_now: self.position = pos self.moving = False @@ -110,13 +122,13 @@ def set_valve(self, pos, move_now=True): self._pending = pos def wait_until_done(self): - self.commands.append(('wait_until_done', {})) - if self.moving and hasattr(self, '_pending'): + self.commands.append(("wait_until_done", {})) + if self.moving and hasattr(self, "_pending"): self.position = self._pending self.moving = False def get_status(self): - return 'moving' if self.moving else 'idle@{}'.format(self.position) + return "moving" if self.moving else "idle@{}".format(self.position) class EmulatedSpillSensor(SpillSensor): @@ -154,14 +166,16 @@ def poll_sensor(self): self.poll_count += 1 return self._wet - def monitor_sensor(self, fn_on_wet: Optional[Callable[[str], None]] = None): + def monitor_sensor( + self, fn_on_wet: Optional[Callable[[str], None]] = None + ): self._abort.clear() def _worker(): while not self._abort.is_set(): if self._connected and self._wet: if fn_on_wet is not None: - fn_on_wet('Spill sensor is wet.') + fn_on_wet("Spill sensor is wet.") return if self._abort.wait(timeout=self.poll_interval): return diff --git a/PycroFlow/tests/emulators/hamilton_serial.py b/PycroFlow/tests/emulators/hamilton_serial.py index ae2a528..2b609e9 100644 --- a/PycroFlow/tests/emulators/hamilton_serial.py +++ b/PycroFlow/tests/emulators/hamilton_serial.py @@ -30,6 +30,7 @@ assert abs(pump.get_current_volume() - 250) < 1.0 assert fake.device('3').syringe_steps > 0 # ascii addr of pump '2' """ + from __future__ import annotations import re @@ -37,16 +38,16 @@ from contextlib import contextmanager from unittest import mock -ETX = '\x03' -STATUS_READY = '`' -STATUS_BUSY = '@' +ETX = "\x03" +STATUS_READY = "`" +STATUS_BUSY = "@" # Opcode patterns applied to the message body (after the leading '/'). -_RE_PICKUP = re.compile(r'P(\d+)') -_RE_DISPENSE = re.compile(r'D(\d+)') -_RE_ABS_MOVE = re.compile(r'A(\d+)') +_RE_PICKUP = re.compile(r"P(\d+)") +_RE_DISPENSE = re.compile(r"D(\d+)") +_RE_ABS_MOVE = re.compile(r"A(\d+)") # Move-valve-in-shortest-direction: h2600; clockwise h2400, ccw h2500. -_RE_VALVE_SHORTEST = re.compile(r'h2[456]00(\d)') +_RE_VALVE_SHORTEST = re.compile(r"h2[456]00(\d)") class EmulatedHamiltonDevice: @@ -77,15 +78,15 @@ def handle(self, message): self.last_command = message # --- Queries --------------------------------------------------------- - if message == '?': # absolute syringe position + if message == "?": # absolute syringe position data = STATUS_READY + str(self.syringe_steps) - elif message.startswith('?11000'): # syringe-mode query + elif message.startswith("?11000"): # syringe-mode query # Host reads reply[3] as the resolution digit, so the payload must # start with the mode digit. data = STATUS_READY + str(self.resolution_mode) - elif message.startswith('?'): # other queries: benign ready + 0 - data = STATUS_READY + '0' - elif message.startswith('Q'): # pump/valve status query (incl. 'QR') + elif message.startswith("?"): # other queries: benign ready + 0 + data = STATUS_READY + "0" + elif message.startswith("Q"): # pump/valve status query (incl. 'QR') data = STATUS_READY else: # --- Action commands -------------------------------------------- @@ -97,7 +98,7 @@ def handle(self, message): return response_body def _apply_action(self, message): - if 'Z' in message or 'Y' in message or 'h20000' in message: + if "Z" in message or "Y" in message or "h20000" in message: self.initialized = True m = _RE_VALVE_SHORTEST.search(message) @@ -123,7 +124,7 @@ class FakeHamiltonSerial: def __init__(self, *args, **kwargs): self._open = False - self._rx = bytearray() # bytes waiting to be readline()'d + self._rx = bytearray() # bytes waiting to be readline()'d self._lock = threading.Lock() self.devices = {} # (address, message) for every command seen, across all devices. @@ -176,12 +177,12 @@ def write(self, data): def readline(self): with self._lock: - idx = self._rx.find(b'\n') + idx = self._rx.find(b"\n") if idx == -1: line, self._rx = bytes(self._rx), bytearray() else: - line = bytes(self._rx[:idx + 1]) - del self._rx[:idx + 1] + line = bytes(self._rx[: idx + 1]) + del self._rx[: idx + 1] return line def read(self, size=1): @@ -201,17 +202,17 @@ def device(self, address): def _split_frames(text): # Commands are CRLF-terminated; a single write carries exactly one, but # be tolerant of batching. - return [f for f in re.split(r'\r\n', text) if f] + return [f for f in re.split(r"\r\n", text) if f] def _handle_frame(self, frame): - if not frame.startswith('/'): + if not frame.startswith("/"): # Unaddressed/garbage frame: reply ready so callers don't hang. - return '/0' + STATUS_READY + ETX + '\r\n' + return "/0" + STATUS_READY + ETX + "\r\n" address = frame[1] message = frame[2:] self.command_log.append((address, message)) body = self.device(address).handle(message) - return '/0' + body + ETX + '\r\n' + return "/0" + body + ETX + "\r\n" @contextmanager @@ -228,8 +229,9 @@ def _factory(*args, **kwargs): return fake with mock.patch( - 'PycroFlow.pyHamilton.communication.serial.Serial', - side_effect=_factory): + "PycroFlow.pyHamilton.communication.serial.Serial", + side_effect=_factory, + ): yield fake diff --git a/PycroFlow/tests/emulators/subsystems.py b/PycroFlow/tests/emulators/subsystems.py index 04b5172..72bfe36 100644 --- a/PycroFlow/tests/emulators/subsystems.py +++ b/PycroFlow/tests/emulators/subsystems.py @@ -13,6 +13,7 @@ :class:`EmulatedValve` so an orchestration test can also assert hardware-level effects (e.g. that an ``inject`` entry moved fluid). """ + from __future__ import annotations from PycroFlow.orchestration import AbstractSystem @@ -22,7 +23,7 @@ class _BaseEmulatedSystem(AbstractSystem): def __init__(self): self.protocol = None - self.executed = [] # list of (index, entry) actually run + self.executed = [] # list of (index, entry) actually run self.paused = False self.aborted = False @@ -34,7 +35,7 @@ def _assign_multiprocess_events(self, *flags): self._flags = flags def execute_protocol_entry(self, i): - entry = self.protocol['protocol_entries'][i] + entry = self.protocol["protocol_entries"][i] self.executed.append((i, entry)) self._on_entry(entry) @@ -60,7 +61,7 @@ def __init__(self): self.acquisitions = [] def _on_entry(self, entry): - if entry.get('$type') == 'acquire': + if entry.get("$type") == "acquire": self.acquisitions.append(entry) def close(self): @@ -78,12 +79,12 @@ def __init__(self): self.shutter_open = False def _on_entry(self, entry): - t = entry.get('$type') - if t in ('set power', 'power'): - self.power = entry.get('power', entry.get('value')) - self.laser = entry.get('laser', self.laser) - elif t == 'set shutter': - self.shutter_open = bool(entry.get('state')) + t = entry.get("$type") + if t in ("set power", "power"): + self.power = entry.get("power", entry.get("value")) + self.laser = entry.get("laser", self.laser) + elif t == "set shutter": + self.shutter_open = bool(entry.get("state")) # Manual-control surface (matches IlluminationSystem) so the GUI/CLI # SystemService laser controls work against the emulator. @@ -112,20 +113,21 @@ def __init__(self, pump=None, valve=None): self.injections = [] def _on_entry(self, entry): - t = entry.get('$type') - if t == 'inject': - vol = entry.get('volume', 0) - velocity = entry.get('velocity') - res = entry.get('reservoir_id') + t = entry.get("$type") + if t == "inject": + vol = entry.get("volume", 0) + velocity = entry.get("velocity") + res = entry.get("reservoir_id") self.injections.append((res, vol)) if res is not None: self.valve.set_valve(res) - self.pump.set_valve('in') + self.pump.set_valve("in") self.pump.pickup(vol, velocity=velocity, waitForPump=True) - self.pump.set_valve('out') + self.pump.set_valve("out") self.pump.dispense(vol, velocity=velocity, waitForPump=True) - elif t == 'flush': - factor = entry.get('flushfactor', 1) - self.pump.set_valve('out') - self.pump.dispense(self.pump.syringe_volume * factor, - waitForPump=True) + elif t == "flush": + factor = entry.get("flushfactor", 1) + self.pump.set_valve("out") + self.pump.dispense( + self.pump.syringe_volume * factor, waitForPump=True + ) diff --git a/PycroFlow/tests/fixtures/configs/exchange_basic.py b/PycroFlow/tests/fixtures/configs/exchange_basic.py index e183597..5f28a78 100644 --- a/PycroFlow/tests/fixtures/configs/exchange_basic.py +++ b/PycroFlow/tests/fixtures/configs/exchange_basic.py @@ -11,72 +11,77 @@ WASH_VOLUME = 2000 IMAGER_VOLUME = 950 VOLUME_REDUCTION_FOR_XCHG = 50 -WASH_BUFFER = 'PBS' +WASH_BUFFER = "PBS" RESERVOIR_NAMES = { - 1: 'EGFR', 2: '5T4', 3: 'AXL', 4: 'Her2', 5: 'PDL1', 6: WASH_BUFFER, + 1: "EGFR", + 2: "5T4", + 3: "AXL", + 4: "Her2", + 5: "PDL1", + 6: WASH_BUFFER, } -TARGET_SEQUENCE = ['EGFR', '5T4', 'AXL', 'Her2', 'PDL1'] -INITIAL_TARGET = 'Her3' +TARGET_SEQUENCE = ["EGFR", "5T4", "AXL", "Her2", "PDL1"] +INITIAL_TARGET = "Her3" CONFIG = { - 'save_dir': '.', - 'base_name': 'regression_exchange_basic', - 'fluid': { - 'parameters': { - 'start_velocity': 500, - 'max_velocity': 1000, - 'stop_velocity': 500, - 'pumpout_dispense_velocity': 20000, - 'clean_velocity': 3000, - 'clean_delay': 10, - 'mode': 'tubing_ignore', - 'extractionfactor': 6, - 'inject_pickup_extravol': 1500, - 'inject_in_to_out_delay': 15, - 'inject_out_to_in_delay': 5, - 'inject_precreate_underpressure': False, + "save_dir": ".", + "base_name": "regression_exchange_basic", + "fluid": { + "parameters": { + "start_velocity": 500, + "max_velocity": 1000, + "stop_velocity": 500, + "pumpout_dispense_velocity": 20000, + "clean_velocity": 3000, + "clean_delay": 10, + "mode": "tubing_ignore", + "extractionfactor": 6, + "inject_pickup_extravol": 1500, + "inject_in_to_out_delay": 15, + "inject_out_to_in_delay": 5, + "inject_precreate_underpressure": False, }, - 'settings': { - 'vol_wash_pre': int(0.1 * WASH_VOLUME), - 'vol_wash': int(0.9 * WASH_VOLUME), - 'vol_imager_pre': int(0.9 * IMAGER_VOLUME), - 'vol_imager_post': int(0.1 * IMAGER_VOLUME), - 'vol_remove_before_wash': VOLUME_REDUCTION_FOR_XCHG, - 'wait_after_pickup': 5, - 'reservoir_names': RESERVOIR_NAMES, - 'experiment': { - 'type': 'Exchange', - 'wash_buffer': WASH_BUFFER, - 'imagers': TARGET_SEQUENCE, - 'initial_imager': INITIAL_TARGET, + "settings": { + "vol_wash_pre": int(0.1 * WASH_VOLUME), + "vol_wash": int(0.9 * WASH_VOLUME), + "vol_imager_pre": int(0.9 * IMAGER_VOLUME), + "vol_imager_post": int(0.1 * IMAGER_VOLUME), + "vol_remove_before_wash": VOLUME_REDUCTION_FOR_XCHG, + "wait_after_pickup": 5, + "reservoir_names": RESERVOIR_NAMES, + "experiment": { + "type": "Exchange", + "wash_buffer": WASH_BUFFER, + "imagers": TARGET_SEQUENCE, + "initial_imager": INITIAL_TARGET, }, }, }, - 'img': { - 'parameters': { - 'show_progress': True, - 'show_display': True, - 'close_display_after_acquisition': True, + "img": { + "parameters": { + "show_progress": True, + "show_display": True, + "close_display_after_acquisition": True, }, - 'settings': { - 'frames': 15, - 'darkframes': 50, - 't_exp': 75, + "settings": { + "frames": 15, + "darkframes": 50, + "t_exp": 75, }, }, - 'illu': { - 'parameters': { - 'setup': 'Crick', + "illu": { + "parameters": { + "setup": "Crick", }, - 'settings': { - 'laser': 560, - 'power_acq': 30, - 'power_nonacq': 1, - 'warmup_delay': 5, - 'shutter_off_nonacq': True, - 'lasers_off_finally': True, + "settings": { + "laser": 560, + "power_acq": 30, + "power_nonacq": 1, + "warmup_delay": 5, + "shutter_off_nonacq": True, + "lasers_off_finally": True, }, }, } diff --git a/PycroFlow/tests/test_emulators.py b/PycroFlow/tests/test_emulators.py index 451a11c..3cf5a34 100644 --- a/PycroFlow/tests/test_emulators.py +++ b/PycroFlow/tests/test_emulators.py @@ -3,11 +3,16 @@ These cover the emulators themselves and, importantly, drive the *real* drivers against them so the wire-protocol encode/decode path gets genuine coverage. """ + import threading import unittest import PycroFlow.pyHamilton as ham -from PycroFlow.hal import Pump as PumpABC, Valve as ValveABC, SpillSensor as SpillABC +from PycroFlow.hal import ( + Pump as PumpABC, + Valve as ValveABC, + SpillSensor as SpillABC, +) from PycroFlow.tests import emulators as emu @@ -25,15 +30,23 @@ def tearDown(self): def _make_pump(self, fake): from PycroFlow.hamilton_components import Pump + pump = Pump( - '2', '500u', instrument_type='4', valve_type='Y', - output_pos='out', input_pos='in', waste_pos=1, - pause_flag=self.flag, abort_flag=self.flag) + "2", + "500u", + instrument_type="4", + valve_type="Y", + output_pos="out", + input_pos="in", + waste_pos=1, + pause_flag=self.flag, + abort_flag=self.flag, + ) return pump def test_pickup_dispense_round_trip(self): with emu.patch_serial() as fake: - ham.connect('18', 9600) + ham.connect("18", 9600) pump = self._make_pump(fake) pump.pickup(250, waitForPump=True) @@ -43,42 +56,43 @@ def test_pickup_dispense_round_trip(self): self.assertAlmostEqual(pump.get_current_volume(), 150.0, delta=1.0) # The emulated device accumulated steps from the wire. - self.assertGreater(fake.device('3').syringe_steps, 0) + self.assertGreater(fake.device("3").syringe_steps, 0) def test_valve_moves_are_tracked(self): from PycroFlow.hamilton_components import Valve + with emu.patch_serial() as fake: - ham.connect('18', 9600) - valve = Valve('1', 'MVP', '8-5') + ham.connect("18", 9600) + valve = Valve("1", "MVP", "8-5") valve.pause_flag = self.flag valve.abort_flag = self.flag valve.set_valve(3) # ascii address of switch address '1' is '2'. - self.assertEqual(fake.device('2').valve_pos, 3) + self.assertEqual(fake.device("2").valve_pos, 3) valve.set_valve(5) - self.assertEqual(fake.device('2').valve_pos, 5) + self.assertEqual(fake.device("2").valve_pos, 5) # Command log captured the addressed frames. addrs = {a for a, _ in fake.command_log} - self.assertIn('2', addrs) + self.assertIn("2", addrs) def test_make_fake_bus_helper(self): bus = emu.make_fake_bus() ham.communication.set_bus(bus) try: - resp = bus.send_command('3', '?') # absolute syringe position - self.assertIn('`', resp) - self.assertTrue(resp.endswith('\r\n')) + resp = bus.send_command("3", "?") # absolute syringe position + self.assertIn("`", resp) + self.assertTrue(resp.endswith("\r\n")) finally: ham.communication.set_bus(ham.communication.SerialBus()) def test_unaddressed_frame_does_not_hang(self): fake = emu.FakeHamiltonSerial() fake.open() - fake.write(b'garbage\r\n') + fake.write(b"garbage\r\n") line = fake.readline() - self.assertTrue(line.endswith(b'\r\n')) + self.assertTrue(line.endswith(b"\r\n")) class HalDeviceEmulatorTest(unittest.TestCase): @@ -89,10 +103,10 @@ def test_emulators_satisfy_abcs(self): def test_pump_volume_tracking(self): pump = emu.EmulatedPump(syringe_volume=500) - pump.set_valve('in') + pump.set_valve("in") pump.pickup(300, waitForPump=True) self.assertEqual(pump.get_current_volume(), 300) - pump.set_valve('out') + pump.set_valve("out") pump.dispense(100, waitForPump=True) self.assertEqual(pump.get_current_volume(), 200) # Clamped at the syringe capacity. @@ -100,8 +114,9 @@ def test_pump_volume_tracking(self): self.assertEqual(pump.get_current_volume(), 500) # Command log records the sequence. methods = [m for m, _ in pump.commands] - self.assertEqual(methods[:4], - ['set_valve', 'pickup', 'set_valve', 'dispense']) + self.assertEqual( + methods[:4], ["set_valve", "pickup", "set_valve", "dispense"] + ) def test_pump_async_then_wait(self): pump = emu.EmulatedPump(syringe_volume=500) @@ -116,7 +131,7 @@ def test_valve_deferred_move(self): valve = emu.EmulatedValve() valve.set_valve(4, move_now=False) self.assertIsNone(valve.position) - self.assertIn('moving', valve.get_status()) + self.assertIn("moving", valve.get_status()) valve.wait_until_done() self.assertEqual(valve.position, 4) @@ -141,7 +156,7 @@ def on_wet(msg): sensor.monitor_sensor(fn_on_wet=on_wet) sensor.set_wet(True) - self.assertTrue(fired.wait(timeout=2), 'wet callback never fired') + self.assertTrue(fired.wait(timeout=2), "wet callback never fired") self.assertEqual(len(msgs), 1) sensor.stop_monitoring() @@ -163,7 +178,7 @@ def test_monitor_fires_on_wet(self): def test_handshake_recorded(self): with emu.connect_interface() as iface: - self.assertIn('H', iface.serial_conn.written) + self.assertIn("H", iface.serial_conn.written) class SubsystemEmulatorTest(unittest.TestCase): @@ -171,11 +186,17 @@ def test_fluid_system_injects_through_pump(self): import PycroFlow.orchestration as por from PycroFlow.orchestration import ThreadExchange - protocol = {'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 2, 'volume': 300}, - {'$type': 'inject', 'reservoir_id': 5, 'volume': 150, - 'velocity': 600}, - ]} + protocol = { + "protocol_entries": [ + {"$type": "inject", "reservoir_id": 2, "volume": 300}, + { + "$type": "inject", + "reservoir_id": 5, + "volume": 150, + "velocity": 600, + }, + ] + } fluid = emu.EmulatedFluidSystem() tx = ThreadExchange.create() handler = por.FluidHandler(fluid, protocol, tx) @@ -192,38 +213,54 @@ def test_orchestration_end_to_end_with_emulated_systems(self): import PycroFlow.orchestration as por protocol = { - 'fluid': {'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 0, 'volume': 100}, - {'$type': 'signal', 'value': 'fluid round 1 done'}, - {'$type': 'wait for signal', 'target': 'img', - 'value': 'imaging round 1 done'}, - ]}, - 'img': {'protocol_entries': [ - {'$type': 'wait for signal', 'target': 'fluid', - 'value': 'fluid round 1 done'}, - {'$type': 'acquire', 'frames': 1000, 't_exp': 100, - 'message': 'r1'}, - {'$type': 'signal', 'value': 'imaging round 1 done'}, - ]}, + "fluid": { + "protocol_entries": [ + {"$type": "inject", "reservoir_id": 0, "volume": 100}, + {"$type": "signal", "value": "fluid round 1 done"}, + { + "$type": "wait for signal", + "target": "img", + "value": "imaging round 1 done", + }, + ] + }, + "img": { + "protocol_entries": [ + { + "$type": "wait for signal", + "target": "fluid", + "value": "fluid round 1 done", + }, + { + "$type": "acquire", + "frames": 1000, + "t_exp": 100, + "message": "r1", + }, + {"$type": "signal", "value": "imaging round 1 done"}, + ] + }, } fluid = emu.EmulatedFluidSystem() imaging = emu.EmulatedImagingSystem() po = por.ProtocolOrchestrator( - protocol, fluid_system=fluid, imaging_system=imaging) + protocol, fluid_system=fluid, imaging_system=imaging + ) po.start_orchestration() po.start_protocol() import time + deadline = time.time() + 5 while time.time() < deadline and not po.poll_protocol_finished(): time.sleep(0.05) po.end_orchestration() - self.assertIn('fluid round 1 done', po.threadexchange['fluid']) - self.assertIn('imaging round 1 done', po.threadexchange['img']) + self.assertIn("fluid round 1 done", po.threadexchange["fluid"]) + self.assertIn("imaging round 1 done", po.threadexchange["img"]) self.assertEqual(len(imaging.acquisitions), 1) self.assertEqual(fluid.injections, [(0, 100)]) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_experiment_design.py b/PycroFlow/tests/test_experiment_design.py index 42579af..9cef3d3 100644 --- a/PycroFlow/tests/test_experiment_design.py +++ b/PycroFlow/tests/test_experiment_design.py @@ -1,5 +1,6 @@ """Tests for the high-level experiment design: schema, setup configs, builder split, and the ExperimentService translate path.""" + import os import tempfile import unittest @@ -13,9 +14,9 @@ ) from PycroFlow.services import ExperimentService - _EXAMPLE = os.path.join( - os.path.dirname(PycroFlow.__file__), 'examples', 'sph_resi_6plex.yaml') + os.path.dirname(PycroFlow.__file__), "examples", "sph_resi_6plex.yaml" +) def _example_design(): @@ -28,42 +29,49 @@ class TestExperimentDesignSchema(unittest.TestCase): def test_accepts_example_sphresi(self): design = _example_design() model = validate_experiment_design(design) - self.assertEqual(model.fluid.settings.experiment.type, 'SPH-RESI') + self.assertEqual(model.fluid.settings.experiment.type, "SPH-RESI") def test_fields_declare_units(self): from PycroFlow.schemas.experiment_design import ( - field_unit, FluidParameters, FluidSettings, ImgSettings, - IlluSettings, ResiRound) + field_unit, + FluidParameters, + FluidSettings, + ImgSettings, + IlluSettings, + ResiRound, + ) def u(model, field): return field_unit(model.model_fields[field]) - self.assertEqual(u(FluidParameters, 'max_velocity'), 'µl/min') - self.assertEqual(u(FluidParameters, 'clean_delay'), 's') - self.assertEqual(u(FluidParameters, 'inject_in_to_out_delay'), 's') - self.assertEqual(u(FluidSettings, 'vol_wash'), 'µl') - self.assertEqual(u(ResiRound, 'adapter_incubation'), 'min') - self.assertEqual(u(ImgSettings, 't_exp'), 'ms') - self.assertEqual(u(IlluSettings, 'power_acq'), 'mW') - self.assertEqual(u(IlluSettings, 'warmup_delay'), 's') + self.assertEqual(u(FluidParameters, "max_velocity"), "µl/min") + self.assertEqual(u(FluidParameters, "clean_delay"), "s") + self.assertEqual(u(FluidParameters, "inject_in_to_out_delay"), "s") + self.assertEqual(u(FluidSettings, "vol_wash"), "µl") + self.assertEqual(u(ResiRound, "adapter_incubation"), "min") + self.assertEqual(u(ImgSettings, "t_exp"), "ms") + self.assertEqual(u(IlluSettings, "power_acq"), "mW") + self.assertEqual(u(IlluSettings, "warmup_delay"), "s") # Unitless fields report None. - self.assertIsNone(u(FluidParameters, 'extractionfactor')) + self.assertIsNone(u(FluidParameters, "extractionfactor")) def test_fluid_settings_reordered_and_wash_buffers_removed(self): from PycroFlow.schemas.experiment_design import FluidSettings + fields = list(FluidSettings.model_fields) # Wash buffers live only in the experiment block now. - self.assertNotIn('wash_buffer_1', fields) - self.assertNotIn('wash_buffer_2', fields) + self.assertNotIn("wash_buffer_1", fields) + self.assertNotIn("wash_buffer_2", fields) # Reservoir tables first; volumes then cleaning above experiment. - self.assertEqual(fields[:2], ['reservoir_names', 'special_names']) - self.assertEqual(fields[-2:], ['cleaning_reservoirs', 'experiment']) + self.assertEqual(fields[:2], ["reservoir_names", "special_names"]) + self.assertEqual(fields[-2:], ["cleaning_reservoirs", "experiment"]) def test_illu_section_has_no_parameters(self): from PycroFlow.schemas.experiment_design import IlluSection + # The illu 'parameters' block is gone — the monet config name comes # from the microscope setup, not the design. - self.assertNotIn('parameters', IlluSection.model_fields) + self.assertNotIn("parameters", IlluSection.model_fields) # The example (no longer carrying illu.parameters) still validates. model = validate_experiment_design(_example_design()) self.assertEqual(model.illu.settings.laser, 642) @@ -76,48 +84,64 @@ def test_units_do_not_break_validation(self): def test_accepts_exchange(self): design = { - 'base_name': 'x', - 'fluid': {'settings': { - 'vol_wash': 10, 'vol_imager_post': 5, - 'reservoir_names': {1: 'R1'}, - 'experiment': {'type': 'Exchange', 'wash_buffer': 'B', - 'imagers': ['R1']}}}, - 'img': {'settings': {'t_exp': 100, 'frames': 100}}, + "base_name": "x", + "fluid": { + "settings": { + "vol_wash": 10, + "vol_imager_post": 5, + "reservoir_names": {1: "R1"}, + "experiment": { + "type": "Exchange", + "wash_buffer": "B", + "imagers": ["R1"], + }, + } + }, + "img": {"settings": {"t_exp": 100, "frames": 100}}, } model = validate_experiment_design(design) - self.assertEqual(model.fluid.settings.experiment.type, 'Exchange') + self.assertEqual(model.fluid.settings.experiment.type, "Exchange") def test_rejects_bad_experiment_type(self): design = _example_design() - design['fluid']['settings']['experiment']['type'] = 'NOPE' + design["fluid"]["settings"]["experiment"]["type"] = "NOPE" with self.assertRaises(ExperimentDesignValidationError): validate_experiment_design(design) def test_rejects_missing_required(self): design = _example_design() - del design['fluid']['settings']['vol_wash'] + del design["fluid"]["settings"]["vol_wash"] with self.assertRaises(ExperimentDesignValidationError): validate_experiment_design(design) def test_hyphenated_aliases_round_trip(self): model = validate_experiment_design(_example_design()) d = model.model_dump(by_alias=True) - exp = d['fluid']['settings']['experiment'] - self.assertIn('target-rounds', exp) - tr = exp['target-rounds']['A1'] - self.assertIn('RESI-rounds', tr) - self.assertIn('RESI-imager', tr) + exp = d["fluid"]["settings"]["experiment"] + self.assertIn("target-rounds", exp) + tr = exp["target-rounds"]["A1"] + self.assertIn("RESI-rounds", tr) + self.assertIn("RESI-imager", tr) def test_power_nonacq_defaults_to_acq(self): - model = validate_experiment_design({ - 'base_name': 'x', - 'fluid': {'settings': { - 'vol_wash': 1, 'reservoir_names': {1: 'R1'}, - 'experiment': {'type': 'Exchange', 'wash_buffer': 'B', - 'imagers': ['R1']}}}, - 'img': {'settings': {'t_exp': 100}}, - 'illu': {'settings': {'laser': 642, 'power_acq': 70}}, - }) + model = validate_experiment_design( + { + "base_name": "x", + "fluid": { + "settings": { + "vol_wash": 1, + "reservoir_names": {1: "R1"}, + "experiment": { + "type": "Exchange", + "wash_buffer": "B", + "imagers": ["R1"], + }, + } + }, + "img": {"settings": {"t_exp": 100}}, + "illu": {"settings": {"laser": 642, "power_acq": 70}}, + } + ) self.assertEqual(model.illu.settings.power_nonacq, 70) @@ -125,148 +149,181 @@ class TestSetupConfigs(unittest.TestCase): def test_list_setups(self): setups = configs.list_setups() - self.assertIn('Emulator', setups) - self.assertIn('Mercury', setups) + self.assertIn("Emulator", setups) + self.assertIn("Mercury", setups) def test_load_setup_tubing_tuple_keys(self): - setup = configs.load_setup('Mercury') - self.assertFalse(setup['emulated']) + setup = configs.load_setup("Mercury") + self.assertFalse(setup["emulated"]) # tubing records convert to a tuple-keyed dict - self.assertIn(('pump_a', 'sample'), setup['tubing']) + self.assertIn(("pump_a", "sample"), setup["tubing"]) def test_assemble_filters_and_attaches(self): - setup = configs.load_setup('Emulator') - ham, tub = configs.assemble_hamilton_config(setup, { - 'reservoir_names': {1: 'A1', 7: 'C+'}, - 'special_names': {'flushbuffer_a': 7, 'h2o': 16}, - 'cleaning_reservoirs': ['h2o'], - }) - ids = sorted(r['id'] for r in ham['reservoir_a']) - self.assertEqual(ids, [1, 7, 16]) # 16 pulled in via cleaning 'h2o' - self.assertEqual(ham['special_names']['flushbuffer_a'], 7) - self.assertEqual(ham['cleaning_reservoirs'], ['h2o']) - self.assertIn('interface', ham) + setup = configs.load_setup("Emulator") + ham, tub = configs.assemble_hamilton_config( + setup, + { + "reservoir_names": {1: "A1", 7: "C+"}, + "special_names": {"flushbuffer_a": 7, "h2o": 16}, + "cleaning_reservoirs": ["h2o"], + }, + ) + ids = sorted(r["id"] for r in ham["reservoir_a"]) + self.assertEqual(ids, [1, 7, 16]) # 16 pulled in via cleaning 'h2o' + self.assertEqual(ham["special_names"]["flushbuffer_a"], 7) + self.assertEqual(ham["cleaning_reservoirs"], ["h2o"]) + self.assertIn("interface", ham) def test_assemble_unknown_reservoir_raises(self): - setup = configs.load_setup('Emulator') + setup = configs.load_setup("Emulator") with self.assertRaises(KeyError): configs.assemble_hamilton_config( - setup, {'reservoir_names': {999: 'nope'}}) + setup, {"reservoir_names": {999: "nope"}} + ) class TestBuilderSplit(unittest.TestCase): def test_build_protocol_no_io(self): design = _example_design() - before = set(os.listdir('.')) + before = set(os.listdir(".")) protocol = ProtocolBuilder().build_protocol(design) - after = set(os.listdir('.')) - self.assertEqual(before, after) # nothing written - self.assertIn('fluid', protocol) - self.assertGreater(len(protocol['fluid']['protocol_entries']), 0) + after = set(os.listdir(".")) + self.assertEqual(before, after) # nothing written + self.assertIn("fluid", protocol) + self.assertGreater(len(protocol["fluid"]["protocol_entries"]), 0) def test_create_protocol_writes(self): design = _example_design() with tempfile.TemporaryDirectory() as d: - design['save_dir'] = d + design["save_dir"] = d fname, steps = ProtocolBuilder().create_protocol(design) self.assertTrue(os.path.exists(os.path.join(d, fname))) - self.assertIn('fluid', steps) + self.assertIn("fluid", steps) class TestEmulatedFluidOps(unittest.TestCase): def _connected(self): from PycroFlow.services import SystemService + svc = SystemService() - svc.load_setup('Emulator') - svc.connect_fluid({ - 'parameters': {'max_velocity': 200, 'clean_velocity': 200, - 'clean_delay': 0}, - 'settings': {'reservoir_names': {1: 'R1', 7: 'C+'}, - 'special_names': {'flushbuffer_a': 7, 'h2o': 16}, - 'cleaning_reservoirs': ['h2o']}, - }) + svc.load_setup("Emulator") + svc.connect_fluid( + { + "parameters": { + "max_velocity": 200, + "clean_velocity": 200, + "clean_delay": 0, + }, + "settings": { + "reservoir_names": {1: "R1", 7: "C+"}, + "special_names": {"flushbuffer_a": 7, "h2o": 16}, + "cleaning_reservoirs": ["h2o"], + }, + } + ) return svc def test_fill_tubings_runs(self): svc = self._connected() - svc.fill_tubings() # over the fake serial; must not raise + svc.fill_tubings() # over the fake serial; must not raise def test_clean_tubings_is_gui_safe(self): from unittest import mock + svc = self._connected() # No terminal prompt: even with input() sabotaged, clean runs. with mock.patch( - 'builtins.input', - side_effect=AssertionError("input() must not be called")): + "builtins.input", + side_effect=AssertionError("input() must not be called"), + ): svc.clean_tubings() def test_clean_tubings_without_reservoirs_raises(self): # An empty/unresolved cleaning_reservoirs would pump nothing — clean # must raise (so the GUI reports it) instead of silently completing. from PycroFlow.services import SystemService + svc = SystemService() - svc.load_setup('Emulator') - svc.connect_fluid({ - 'parameters': {'max_velocity': 200, 'clean_velocity': 200}, - 'settings': {'reservoir_names': {1: 'R1', 7: 'C+'}, - 'special_names': {'flushbuffer_a': 7}, - 'cleaning_reservoirs': []}, - }) + svc.load_setup("Emulator") + svc.connect_fluid( + { + "parameters": {"max_velocity": 200, "clean_velocity": 200}, + "settings": { + "reservoir_names": {1: "R1", 7: "C+"}, + "special_names": {"flushbuffer_a": 7}, + "cleaning_reservoirs": [], + }, + } + ) with self.assertRaises(ValueError): svc.clean_tubings() def test_disconnect_releases_systems(self): from PycroFlow.services import SystemService + svc = SystemService() - svc.load_setup('Emulator') - svc.connect_fluid({ - 'parameters': {'max_velocity': 200}, - 'settings': {'reservoir_names': {1: 'R1'}, - 'special_names': {'flushbuffer_a': 1}}, - }) + svc.load_setup("Emulator") + svc.connect_fluid( + { + "parameters": {"max_velocity": 200}, + "settings": { + "reservoir_names": {1: "R1"}, + "special_names": {"flushbuffer_a": 1}, + }, + } + ) svc.connect_imaging() svc.connect_illumination() self.assertEqual( svc.connection_states(), - {'fluid': True, 'imaging': True, 'illumination': True}) + {"fluid": True, "imaging": True, "illumination": True}, + ) svc.disconnect_all() self.assertEqual( svc.connection_states(), - {'fluid': False, 'imaging': False, 'illumination': False}) + {"fluid": False, "imaging": False, "illumination": False}, + ) # Idempotent: disconnecting again is a safe no-op. svc.disconnect_all() # And the hardware is free to reconnect afterwards. - svc.connect_fluid({ - 'parameters': {'max_velocity': 200}, - 'settings': {'reservoir_names': {1: 'R1'}, - 'special_names': {'flushbuffer_a': 1}}, - }) - self.assertTrue(svc.connection_states()['fluid']) + svc.connect_fluid( + { + "parameters": {"max_velocity": 200}, + "settings": { + "reservoir_names": {1: "R1"}, + "special_names": {"flushbuffer_a": 1}, + }, + } + ) + self.assertTrue(svc.connection_states()["fluid"]) def test_inject_step_duration_estimate(self): fluid = self._connected().fluid_system # inject of 1000 µl at 200 µl/min -> ~2*1000/200 = 10 min = 600 s. est = fluid._estimate_entry_duration( - {'$type': 'inject', 'volume': 1000}) + {"$type": "inject", "volume": 1000} + ) self.assertGreaterEqual(est, 600) # non-time-based steps have no estimate. self.assertIsNone( - fluid._estimate_entry_duration({'$type': 'signal', 'value': 'x'})) + fluid._estimate_entry_duration({"$type": "signal", "value": "x"}) + ) def test_get_step_progress_during_inject(self): import time + fluid = self._connected().fluid_system # Idle: nothing running. self.assertIsNone(fluid.get_step_progress()) # Simulate a running inject started 1 s ago with a 100 s estimate. - fluid._step_estimate = (time.time() - 1.0, 100.0, 'inject') + fluid._step_estimate = (time.time() - 1.0, 100.0, "inject") cur, tot, label = fluid.get_step_progress() - self.assertEqual((tot, label), (100.0, 'inject')) + self.assertEqual((tot, label), (100.0, "inject")) self.assertTrue(0.5 <= cur <= 3.0) # Elapsed is capped at the estimate. - fluid._step_estimate = (time.time() - 500.0, 100.0, 'inject') + fluid._step_estimate = (time.time() - 500.0, 100.0, "inject") self.assertEqual(fluid.get_step_progress()[0], 100.0) @@ -276,15 +333,16 @@ def test_translate_from_design(self): svc = ExperimentService() svc.load_experiment_design(_EXAMPLE) protocol = svc.translate() - self.assertEqual(svc.state.value, 'loaded') - self.assertGreater(len(protocol['fluid']['protocol_entries']), 0) - self.assertGreater(len(protocol['img']['protocol_entries']), 0) - self.assertGreater(len(protocol['illu']['protocol_entries']), 0) + self.assertEqual(svc.state.value, "loaded") + self.assertGreater(len(protocol["fluid"]["protocol_entries"]), 0) + self.assertGreater(len(protocol["img"]["protocol_entries"]), 0) + self.assertGreater(len(protocol["illu"]["protocol_entries"]), 0) def test_attach_systems_feeds_translate(self): from unittest.mock import MagicMock + svc = ExperimentService() - fluid = MagicMock(name='fluid') + fluid = MagicMock(name="fluid") svc.attach_systems(fluid_system=fluid) svc.load_experiment_design(_EXAMPLE) svc.translate() @@ -292,15 +350,17 @@ def test_attach_systems_feeds_translate(self): def test_load_from_path_changes_cwd(self): import shutil + original = os.getcwd() self.addCleanup(os.chdir, original) folder = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, folder, True) - dst = os.path.join(folder, 'design.yaml') + dst = os.path.join(folder, "design.yaml") shutil.copy(_EXAMPLE, dst) ExperimentService().load_experiment_design(dst) - self.assertEqual(os.path.realpath(os.getcwd()), - os.path.realpath(folder)) + self.assertEqual( + os.path.realpath(os.getcwd()), os.path.realpath(folder) + ) def test_load_from_dict_keeps_cwd(self): original = os.getcwd() @@ -314,14 +374,14 @@ class TestSubsystemDeselection(unittest.TestCase): is left out of the compiled Run Sequence without dangling waits.""" def _entries(self, protocol, system): - return protocol.get(system, {}).get('protocol_entries', []) + return protocol.get(system, {}).get("protocol_entries", []) def _wait_targets(self, protocol): targets = set() for system in protocol.values(): - for entry in system['protocol_entries']: - if entry.get('$type') == 'wait for signal': - targets.add(entry['target']) + for entry in system["protocol_entries"]: + if entry.get("$type") == "wait for signal": + targets.add(entry["target"]) return targets def test_enabled_defaults_true_and_round_trips(self): @@ -330,56 +390,63 @@ def test_enabled_defaults_true_and_round_trips(self): self.assertTrue(model.img.enabled) self.assertTrue(model.illu.enabled) d = model.model_dump(by_alias=True) - self.assertTrue(d['illu']['enabled']) + self.assertTrue(d["illu"]["enabled"]) def test_all_enabled_matches_baseline(self): # Adding the flag must not change the emitted protocol when everything # is enabled (the default) — same subsystems, same waits. protocol = ProtocolBuilder().build_protocol(_example_design()) - self.assertEqual( - set(protocol.keys()), {'fluid', 'img', 'illu'}) + self.assertEqual(set(protocol.keys()), {"fluid", "img", "illu"}) def test_deselect_illu_drops_key_and_waits(self): design = _example_design() - design['illu']['enabled'] = False + design["illu"]["enabled"] = False protocol = ProtocolBuilder().build_protocol(design) - self.assertNotIn('illu', protocol) + self.assertNotIn("illu", protocol) # fluid + img still present and non-empty (structure intact). - self.assertTrue(self._entries(protocol, 'fluid')) - self.assertTrue(self._entries(protocol, 'img')) + self.assertTrue(self._entries(protocol, "fluid")) + self.assertTrue(self._entries(protocol, "img")) # No survivor waits on the dropped illu subsystem. - self.assertNotIn('illu', self._wait_targets(protocol)) + self.assertNotIn("illu", self._wait_targets(protocol)) def test_deselect_img_drops_key_and_orphan_fluid_waits(self): design = _example_design() - design['img']['enabled'] = False + design["img"]["enabled"] = False protocol = ProtocolBuilder().build_protocol(design) - self.assertNotIn('img', protocol) - self.assertTrue(self._entries(protocol, 'fluid')) + self.assertNotIn("img", protocol) + self.assertTrue(self._entries(protocol, "fluid")) # The fluid 'wait for signal target=img' (done imaging) is pruned; # likewise any illu wait on img. - self.assertNotIn('img', self._wait_targets(protocol)) + self.assertNotIn("img", self._wait_targets(protocol)) def test_design_without_illu_block_compiles(self): # An absent illu section (illu -> None after validation) must not # crash the exchange builder; it just yields a fluid+img protocol. design = { - 'base_name': 'x', - 'fluid': {'settings': { - 'vol_wash': 10, 'vol_imager_post': 5, - 'reservoir_names': {1: 'R1'}, - 'experiment': {'type': 'Exchange', 'wash_buffer': 'R1', - 'imagers': ['R1']}}}, - 'img': {'settings': {'t_exp': 100, 'frames': 100}}, + "base_name": "x", + "fluid": { + "settings": { + "vol_wash": 10, + "vol_imager_post": 5, + "reservoir_names": {1: "R1"}, + "experiment": { + "type": "Exchange", + "wash_buffer": "R1", + "imagers": ["R1"], + }, + } + }, + "img": {"settings": {"t_exp": 100, "frames": 100}}, } design = ExperimentService().load_experiment_design(design) protocol = ProtocolBuilder().build_protocol(design) - self.assertEqual(set(protocol.keys()), {'fluid', 'img'}) + self.assertEqual(set(protocol.keys()), {"fluid", "img"}) def test_deselected_subsystem_not_wired_into_orchestrator(self): from unittest.mock import MagicMock + design = _example_design() - design['illu']['enabled'] = False + design["illu"]["enabled"] = False svc = ExperimentService() svc.attach_systems( fluid_system=MagicMock(), @@ -395,5 +462,5 @@ def test_deselected_subsystem_not_wired_into_orchestrator(self): self.assertIsNotNone(svc._orchestrator.imaging_system) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_fluid_legacy.py b/PycroFlow/tests/test_fluid_legacy.py index d45512d..7c86efe 100644 --- a/PycroFlow/tests/test_fluid_legacy.py +++ b/PycroFlow/tests/test_fluid_legacy.py @@ -6,67 +6,78 @@ (protocol dispatch, deliver_fluid, fill_tubings, flush, pump_out, and the pause/resume/abort/stop lifecycle) that those tests don't reach. """ + import threading import unittest import PycroFlow.pyHamilton as ham from PycroFlow.hamilton_components import ReservoirDict -from PycroFlow import fluid as _fluid_pkg from PycroFlow.fluid import legacy as fluid_legacy from PycroFlow.fluid.legacy import LegacyArchitecture from PycroFlow.tests.emulators import patch_serial - SYSTEM_CONFIG = { - 'system_type': 'legacy', - 'valve_a': [ - {'address': 0, 'instrument_type': 'MVP', 'valve_type': '8-5'}, - {'address': 1, 'instrument_type': 'MVP', 'valve_type': '8-5'}, + "system_type": "legacy", + "valve_a": [ + {"address": 0, "instrument_type": "MVP", "valve_type": "8-5"}, + {"address": 1, "instrument_type": "MVP", "valve_type": "8-5"}, ], - 'valve_flush': {'address': 4, 'instrument_type': 'MVP', 'valve_type': '8-5'}, + "valve_flush": { + "address": 4, + "instrument_type": "MVP", + "valve_type": "8-5", + }, # flush position must be a valid MVP position (1-8); 'flush' is used by # fill_tubings/_flush. - 'flush_pos': {'inject': 1, 'flush': 2}, - 'pump_a': {'address': 2, 'instrument_type': '4', 'valve_type': 'Y', - 'syringe': '500u'}, - 'pump_out': {'address': 3, 'instrument_type': '4', 'valve_type': 'Y', - 'syringe': '5.0m'}, - 'reservoir_a': [ - {'id': 0, 'valve_pos': {0: 3, 1: 2}}, - {'id': 1, 'valve_pos': {0: 2, 1: 2}}, - {'id': 3, 'valve_pos': {0: 2, 1: 4}}, + "flush_pos": {"inject": 1, "flush": 2}, + "pump_a": { + "address": 2, + "instrument_type": "4", + "valve_type": "Y", + "syringe": "500u", + }, + "pump_out": { + "address": 3, + "instrument_type": "4", + "valve_type": "Y", + "syringe": "5.0m", + }, + "reservoir_a": [ + {"id": 0, "valve_pos": {0: 3, 1: 2}}, + {"id": 1, "valve_pos": {0: 2, 1: 2}}, + {"id": 3, "valve_pos": {0: 2, 1: 4}}, ], - 'special_names': {'flushbuffer_a': 3}, + "special_names": {"flushbuffer_a": 3}, } TUBING_CONFIG = { - ('R0', 'pump_a'): 0, - ('R1', 'pump_a'): 0, - ('R3', 'pump_a'): 0, - ('pump_a', 'valve_flush'): 50, # nonzero so _flush actually pumps - ('valve_flush', 'sample'): 0, + ("R0", "pump_a"): 0, + ("R1", "pump_a"): 0, + ("R3", "pump_a"): 0, + ("pump_a", "valve_flush"): 50, # nonzero so _flush actually pumps + ("valve_flush", "sample"): 0, } PARAMETERS = { - 'start_velocity': 50, - 'max_velocity': 1000, - 'stop_velocity': 500, - 'mode': 'tubing_ignore', - 'extractionfactor': 2, - 'pumpout_dispense_velocity': 20000, - 'inject_pickup_extravol': 1500, + "start_velocity": 50, + "max_velocity": 1000, + "stop_velocity": 500, + "mode": "tubing_ignore", + "extractionfactor": 2, + "pumpout_dispense_velocity": 20000, + "inject_pickup_extravol": 1500, # Zero delays exercise the empty-pump path of _inject that used to raise # ZeroDivisionError (regression guard for that fix). - 'inject_in_to_out_delay': 0, - 'inject_out_to_in_delay': 0, - 'clean_velocity': 3000, + "inject_in_to_out_delay": 0, + "inject_out_to_in_delay": 0, + "clean_velocity": 3000, } INJECT_PROTOCOL = { - 'parameters': dict(PARAMETERS), - 'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 0, 'volume': 10}, - {'$type': 'inject', 'reservoir_id': 1, 'volume': 10}, + "parameters": dict(PARAMETERS), + "protocol_entries": [ + {"$type": "inject", "reservoir_id": 0, "volume": 10}, + {"$type": "inject", "reservoir_id": 1, "volume": 10}, ], } @@ -91,15 +102,19 @@ def setUp(self): # __init__ before its own connect() call, so the bus must already hold # the emulated serial. Connect first, then mark connected so __init__ # skips reconnecting. - ham.connect('18', 9600) + ham.connect("18", 9600) fluid_legacy.is_connected = True self.la = LegacyArchitecture(SYSTEM_CONFIG, TUBING_CONFIG) - self.la._assign_protocol({'parameters': dict(PARAMETERS), - 'protocol_entries': list( - INJECT_PROTOCOL['protocol_entries'])}) + self.la._assign_protocol( + { + "parameters": dict(PARAMETERS), + "protocol_entries": list(INJECT_PROTOCOL["protocol_entries"]), + } + ) self.la._assign_multiprocess_events( - threading.Event(), threading.Event(), threading.Event()) + threading.Event(), threading.Event(), threading.Event() + ) def tearDown(self): self._ctx.__exit__(None, None, None) @@ -120,13 +135,13 @@ def test_test_communication_runs(self): # --- protocol dispatch ----------------------------------------------- def test_execute_protocol_entry_tubing_ignore_inject(self): - self.la.parameters['mode'] = 'tubing_ignore' + self.la.parameters["mode"] = "tubing_ignore" before = len(self.fake.command_log) self.la.execute_protocol_entry(0) self.assertGreater(len(self.fake.command_log), before) def test_execute_protocol_entry_tubing_stack_inject(self): - self.la.parameters['mode'] = 'tubing_stack' + self.la.parameters["mode"] = "tubing_stack" before = len(self.fake.command_log) self.la.execute_protocol_entry(0) self.assertGreater(len(self.fake.command_log), before) @@ -134,8 +149,8 @@ def test_execute_protocol_entry_tubing_stack_inject(self): self.assertEqual(self.la.last_protocol_entry, 0) def test_execute_single_protocol_entry_pump_out(self): - self.la.protocol = [{'$type': 'pump_out', 'volume': 50}] - self.la.parameters['mode'] = 'tubing_ignore' + self.la.protocol = [{"$type": "pump_out", "volume": 50}] + self.la.parameters["mode"] = "tubing_ignore" before = len(self.fake.command_log) self.la.execute_protocol_entry(0) self.assertGreater(len(self.fake.command_log), before) @@ -143,7 +158,7 @@ def test_execute_single_protocol_entry_pump_out(self): self.assertEqual(self.la.last_protocol_entry, -1) def test_unknown_mode_raises(self): - self.la.parameters['mode'] = 'nonsense' + self.la.parameters["mode"] = "nonsense" with self.assertRaises(Exception): self.la.execute_protocol_entry(0) @@ -159,7 +174,7 @@ def test_pump_out_method(self): self.la._pump_out(50) self.assertGreater(len(self.fake.command_log), before) # pump_out picked up then dispensed -> ascii address '4' - self.assertTrue(any(a == '4' for a, _ in self.fake.command_log)) + self.assertTrue(any(a == "4" for a, _ in self.fake.command_log)) def test_fill_tubings_returns_total_volume(self): total = self.la.fill_tubings(extra_vol=10) @@ -190,5 +205,5 @@ def test_resume_returns_true_when_not_aborted(self): self.assertTrue(self.la.resume_execution()) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_frontend_cli.py b/PycroFlow/tests/test_frontend_cli.py index d11bdf9..334c1c0 100644 --- a/PycroFlow/tests/test_frontend_cli.py +++ b/PycroFlow/tests/test_frontend_cli.py @@ -5,6 +5,7 @@ the commands against mocks, asserting delegation, argument parsing, and the "orchestration not started" guards. """ + import io import os import tempfile @@ -19,15 +20,15 @@ def _cli(): # __init__ scans the cwd and auto-loads configs; disable that for tests. # configure_logging=False keeps the test run from writing log files into # the repo / reconfiguring loguru sinks. - with patch('PycroFlow.frontend_cli.os.listdir', return_value=[]): + with patch("PycroFlow.frontend_cli.os.listdir", return_value=[]): return PycroFlowInteractive(configure_logging=False) def _cli_with_orchestrator(): cli = _cli() - cli.orchestrator = MagicMock(name='orchestrator') - cli.fluid_system = MagicMock(name='fluid_system') - cli.illumination_system = MagicMock(name='illumination_system') + cli.orchestrator = MagicMock(name="orchestrator") + cli.fluid_system = MagicMock(name="fluid_system") + cli.illumination_system = MagicMock(name="illumination_system") return cli @@ -39,57 +40,58 @@ def _assert_guarded(self, method, *args): buf = io.StringIO() with redirect_stdout(buf): method(cli, *args) - self.assertIn('Start orchestration first', buf.getvalue()) + self.assertIn("Start orchestration first", buf.getvalue()) def test_guards(self): - self._assert_guarded(PycroFlowInteractive.do_start_protocol, '') - self._assert_guarded(PycroFlowInteractive.do_pause_protocol, '') - self._assert_guarded(PycroFlowInteractive.do_resume_protocol, '') - self._assert_guarded(PycroFlowInteractive.do_abort_protocol, '') - self._assert_guarded(PycroFlowInteractive.do_is_protocol_done, '') - self._assert_guarded(PycroFlowInteractive.do_get_protocol_iter, '') - self._assert_guarded(PycroFlowInteractive.do_set_valves, '0') - self._assert_guarded(PycroFlowInteractive.do_power, '20') + self._assert_guarded(PycroFlowInteractive.do_start_protocol, "") + self._assert_guarded(PycroFlowInteractive.do_pause_protocol, "") + self._assert_guarded(PycroFlowInteractive.do_resume_protocol, "") + self._assert_guarded(PycroFlowInteractive.do_abort_protocol, "") + self._assert_guarded(PycroFlowInteractive.do_is_protocol_done, "") + self._assert_guarded(PycroFlowInteractive.do_get_protocol_iter, "") + self._assert_guarded(PycroFlowInteractive.do_set_valves, "0") + self._assert_guarded(PycroFlowInteractive.do_power, "20") def test_abort_orchestration_guard(self): cli = _cli() buf = io.StringIO() with redirect_stdout(buf): - cli.do_abort_orchestration('') - self.assertIn('Start orchestration first', buf.getvalue()) + cli.do_abort_orchestration("") + self.assertIn("Start orchestration first", buf.getvalue()) def test_start_orchestration_without_protocol(self): cli = _cli() buf = io.StringIO() with redirect_stdout(buf): - cli.do_start_orchestration('') - self.assertIn('Load the protocol first', buf.getvalue()) + cli.do_start_orchestration("") + self.assertIn("Load the protocol first", buf.getvalue()) class ProtocolControlTest(unittest.TestCase): def test_start_protocol_parses_entries(self): cli = _cli_with_orchestrator() - cli.do_start_protocol('fluid: 5, img: 2') + cli.do_start_protocol("fluid: 5, img: 2") cli.orchestrator.start_protocol.assert_called_once_with( - {'fluid': 4, 'img': 1}) + {"fluid": 4, "img": 1} + ) def test_start_protocol_no_entries(self): cli = _cli_with_orchestrator() - cli.do_start_protocol('') + cli.do_start_protocol("") cli.orchestrator.start_protocol.assert_called_once_with({}) def test_pause_resume_abort_delegate(self): cli = _cli_with_orchestrator() - cli.do_pause_protocol('') - cli.do_resume_protocol('') - cli.do_abort_protocol('') + cli.do_pause_protocol("") + cli.do_resume_protocol("") + cli.do_abort_protocol("") cli.orchestrator.pause_protocol.assert_called_once() cli.orchestrator.resume_protocol.assert_called_once() cli.orchestrator.abort_protocol.assert_called_once() def test_abort_orchestration_delegates(self): cli = _cli_with_orchestrator() - cli.do_abort_orchestration('') + cli.do_abort_orchestration("") cli.orchestrator.abort_orchestration.assert_called_once() def test_is_protocol_done_prints(self): @@ -97,44 +99,47 @@ def test_is_protocol_done_prints(self): cli.orchestrator.poll_protocol_finished.return_value = True buf = io.StringIO() with redirect_stdout(buf): - cli.do_is_protocol_done('') - self.assertIn('True', buf.getvalue()) + cli.do_is_protocol_done("") + self.assertIn("True", buf.getvalue()) def test_get_protocol_iter_queries_all_systems(self): cli = _cli_with_orchestrator() - cli.do_get_protocol_iter('') + cli.do_get_protocol_iter("") self.assertEqual( - cli.orchestrator.execute_system_function.call_count, 3) + cli.orchestrator.execute_system_function.call_count, 3 + ) def test_set_protocol_iter_parses_and_delegates(self): cli = _cli_with_orchestrator() - cli.do_set_protocol_iter('img=3 fluid=7') + cli.do_set_protocol_iter("img=3 fluid=7") # one execute_system_function per named system self.assertEqual( - cli.orchestrator.execute_system_function.call_count, 2) + cli.orchestrator.execute_system_function.call_count, 2 + ) def test_set_protocol_iter_bad_input(self): cli = _cli_with_orchestrator() buf = io.StringIO() with redirect_stdout(buf): - cli.do_set_protocol_iter('img=notanint') - self.assertIn('Input Error', buf.getvalue()) + cli.do_set_protocol_iter("img=notanint") + self.assertIn("Input Error", buf.getvalue()) class FluidCommandTest(unittest.TestCase): def test_set_valves_delegates(self): cli = _cli_with_orchestrator() - cli.do_set_valves('3') + cli.do_set_valves("3") cli.orchestrator.execute_system_function.assert_called_once() _, kwargs = cli.orchestrator.execute_system_function.call_args - self.assertEqual(kwargs['kwargs'], {'reservoir_id': 3}) + self.assertEqual(kwargs["kwargs"], {"reservoir_id": 3}) def test_inject_parses_and_delegates_twice(self): cli = _cli_with_orchestrator() - cli.do_inject('10 velocity=600 pickup_res=2') + cli.do_inject("10 velocity=600 pickup_res=2") # one call to _set_valves, one to _inject self.assertEqual( - cli.orchestrator.execute_system_function.call_count, 2) + cli.orchestrator.execute_system_function.call_count, 2 + ) def test_deliver_delegates(self): cli = _cli_with_orchestrator() @@ -146,37 +151,38 @@ def test_fill_tubings_without_fluid_system_prints(self): cli.fluid_system = None buf = io.StringIO() with redirect_stdout(buf): - cli.do_fill_tubings('') - self.assertIn('needs to be initialized', buf.getvalue()) + cli.do_fill_tubings("") + self.assertIn("needs to be initialized", buf.getvalue()) def test_fill_tubings_delegates_to_fluid_system(self): cli = _cli() cli.fluid_system = MagicMock() - cli.do_fill_tubings('') + cli.do_fill_tubings("") cli.fluid_system.fill_tubings.assert_called_once() class IlluminationCommandTest(unittest.TestCase): def test_laser_delegates_set_and_enable(self): cli = _cli_with_orchestrator() - cli.do_laser('560 1') + cli.do_laser("560 1") self.assertEqual( - cli.orchestrator.execute_system_function.call_count, 2) + cli.orchestrator.execute_system_function.call_count, 2 + ) def test_power_delegates(self): cli = _cli_with_orchestrator() - cli.do_power('30') + cli.do_power("30") cli.orchestrator.execute_system_function.assert_called_once() class LifecycleTest(unittest.TestCase): def test_precmd_passthrough(self): - self.assertEqual(_cli().precmd('hello'), 'hello') + self.assertEqual(_cli().precmd("hello"), "hello") def test_exit_closes_and_returns_true(self): cli = _cli_with_orchestrator() cli.orchestrator.poll_protocol_finished.return_value = True - self.assertTrue(cli.do_exit('')) + self.assertTrue(cli.do_exit("")) cli.orchestrator.end_orchestration.assert_called_once() def test_close_aborts_when_not_finished(self): @@ -194,12 +200,13 @@ def test_load_protocol_assigns_to_systems(self): cli = _cli() cli.fluid_system = MagicMock() cli.imaging_system = MagicMock() - fd, path = tempfile.mkstemp(suffix='.yaml') + fd, path = tempfile.mkstemp(suffix=".yaml") try: - with os.fdopen(fd, 'w') as f: + with os.fdopen(fd, "w") as f: f.write( "fluid:\n protocol_entries: []\n" - "img:\n protocol_entries: []\n") + "img:\n protocol_entries: []\n" + ) cli.do_load_protocol(path) finally: os.unlink(path) @@ -209,5 +216,5 @@ def test_load_protocol_assigns_to_systems(self): self.assertIsNone(cli.illumination_system) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_hamilton_architecture.py b/PycroFlow/tests/test_hamilton_architecture.py index 1d1bd19..4df303d 100644 --- a/PycroFlow/tests/test_hamilton_architecture.py +++ b/PycroFlow/tests/test_hamilton_architecture.py @@ -6,62 +6,89 @@ import PycroFlow.pyHamilton as ham from PycroFlow.hamilton_architecture import LegacyArchitecture - logger = logging.getLogger(__name__) class LegacyArchitectureTest(unittest.TestCase): def setUp(self): test_system_config = { - 'system_type': 'legacy', - 'valve_a': [ - {'address': 0, 'instrument_type': 'MVP', 'valve_type': '8-5'}, - {'address': 1, 'instrument_type': 'MVP', 'valve_type': '8-5'}, - ], - 'valve_flush': {'address': 4, 'instrument_type': 'MVP', 'valve_type': '8-5'}, - 'flush_pos': {'inject': 1, 'flush': 0}, - 'pump_a': {'address': 2, 'instrument_type': '4', 'valve_type': 'Y', 'syringe': '500u'}, - 'pump_out': {'address': 3, 'instrument_type': '4', 'valve_type': 'Y', 'syringe': '5.0m'}, - 'reservoir_a': [ - {'id': 0, 'valve_pos': {0: 3, 1: 2}}, - {'id': 1, 'valve_pos': {0: 2, 1: 2}}, - {'id': 3, 'valve_pos': {0: 2, 1: 4}}, - ], - 'special_names': { - 'flushbuffer_a': 3, # defines the reservoir id with the buffer that can be used for flushing} - } - } + "system_type": "legacy", + "valve_a": [ + {"address": 0, "instrument_type": "MVP", "valve_type": "8-5"}, + {"address": 1, "instrument_type": "MVP", "valve_type": "8-5"}, + ], + "valve_flush": { + "address": 4, + "instrument_type": "MVP", + "valve_type": "8-5", + }, + "flush_pos": {"inject": 1, "flush": 0}, + "pump_a": { + "address": 2, + "instrument_type": "4", + "valve_type": "Y", + "syringe": "500u", + }, + "pump_out": { + "address": 3, + "instrument_type": "4", + "valve_type": "Y", + "syringe": "5.0m", + }, + "reservoir_a": [ + {"id": 0, "valve_pos": {0: 3, 1: 2}}, + {"id": 1, "valve_pos": {0: 2, 1: 2}}, + {"id": 3, "valve_pos": {0: 2, 1: 4}}, + ], + "special_names": { + "flushbuffer_a": 3, # defines the reservoir id with the buffer that can be used for flushing} + }, + } test_tubing_config = { - ('R0', 'pump_a'): 0, - ('R1', 'pump_a'): 0, - ('R3', 'pump_a'): 0, - ('pump_a', 'valve_flush'): 0, - ('valve_flush', 'sample'): 0, + ("R0", "pump_a"): 0, + ("R1", "pump_a"): 0, + ("R3", "pump_a"): 0, + ("pump_a", "valve_flush"): 0, + ("valve_flush", "sample"): 0, } test_protocol = { - 'parameters': { - 'start_velocity': 50, - 'max_velocity': 1000, - 'stop_velocity': 500, - 'mode': 'tubing_stack', - 'extractionfactor': 2, - 'pumpout_dispense_velocity': 20000, - 'inject_pickup_extravol': 1500, + "parameters": { + "start_velocity": 50, + "max_velocity": 1000, + "stop_velocity": 500, + "mode": "tubing_stack", + "extractionfactor": 2, + "pumpout_dispense_velocity": 20000, + "inject_pickup_extravol": 1500, # Zero the equilibration delays so the mocked _inject test # doesn't actually time.sleep(20s); they have no effect # without real hardware. - 'inject_in_to_out_delay': 0, - 'inject_out_to_in_delay': 0, - 'clean_velocity': 3000}, - 'imaging': { - 'frames': 30000, - 't_exp': 100}, - 'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 0, 'volume': 500}, - {'$type': 'inject', 'reservoir_id': 1, 'volume': 200, 'velocity': 600}, - {'$type': 'acquire', 'frames': 10000, 't_exp': 100, 'round': 1}, - {'$type': 'inject', 'reservoir_id': 0, 'volume': 300}, # for more commplex system: 'mix' - ]} + "inject_in_to_out_delay": 0, + "inject_out_to_in_delay": 0, + "clean_velocity": 3000, + }, + "imaging": {"frames": 30000, "t_exp": 100}, + "protocol_entries": [ + {"$type": "inject", "reservoir_id": 0, "volume": 500}, + { + "$type": "inject", + "reservoir_id": 1, + "volume": 200, + "velocity": 600, + }, + { + "$type": "acquire", + "frames": 10000, + "t_exp": 100, + "round": 1, + }, + { + "$type": "inject", + "reservoir_id": 0, + "volume": 300, + }, # for more commplex system: 'mix' + ], + } # Patch the in-house serial driver (PycroFlow.pyHamilton.communication), # NOT the external pyHamiltonPSD package — the code calls # ham.communication.sendCommand where ham is PycroFlow.pyHamilton. @@ -70,22 +97,26 @@ def setUp(self): # Response layout: result[3:4] is parsed as the resolution mode int # by Pump.__init__, so position 3 must be a digit. patch_send_command = patch( - 'PycroFlow.pyHamilton.communication.sendCommand', - create=True, return_value='/0`1\x03') + "PycroFlow.pyHamilton.communication.sendCommand", + create=True, + return_value="/0`1\x03", + ) patch_send_command.start() self.addCleanup(patch_send_command.stop) patch_connect = patch( - 'PycroFlow.pyHamilton.communication.initializeSerial', create=True) + "PycroFlow.pyHamilton.communication.initializeSerial", create=True + ) patch_connect.start() self.addCleanup(patch_connect.stop) - patch_connect2 = patch('PycroFlow.pyHamilton.connect', create=True) + patch_connect2 = patch("PycroFlow.pyHamilton.connect", create=True) patch_connect2.start() self.addCleanup(patch_connect2.stop) patch_disconnect = patch( - 'PycroFlow.pyHamilton.communication.disconnectSerial', create=True) + "PycroFlow.pyHamilton.communication.disconnectSerial", create=True + ) patch_disconnect.start() self.addCleanup(patch_disconnect.stop) @@ -107,7 +138,8 @@ def setUp(self): # Valve/Pump.set_valve dereferences a None pause_flag. Mirror that # setup so direct-call tests (set_valve, inject) work. self.va._assign_multiprocess_events( - threading.Event(), threading.Event(), threading.Event()) + threading.Event(), threading.Event(), threading.Event() + ) # print(self.va.pump_a.call_args_list) # print(self.va.pump_out.call_args_list) @@ -128,10 +160,10 @@ def test_tubing_stack_1(self): # as no tubing volume is assigned, the tubing column # matches the single steps tubing_stack_expected = { - 0: [(0, 500.)], - 1: [(1, 200.)], + 0: [(0, 500.0)], + 1: [(1, 200.0)], 2: [], - 3: [(0, 300.)], + 3: [(0, 300.0)], } # print('expected', tubing_stack_expected) # print('actual', self.va.tubing_stack) @@ -140,11 +172,11 @@ def test_tubing_stack_1(self): def test_tubing_stack_2(self): # check tubing column with volume in tubings test_tubing_config_2 = { - ('R0', 'pump_a'): 0, - ('R1', 'pump_a'): 0, - ('R3', 'pump_a'): 0, - ('pump_a', 'valve_flush'): 0, - ('valve_flush', 'sample'): 100, + ("R0", "pump_a"): 0, + ("R1", "pump_a"): 0, + ("R3", "pump_a"): 0, + ("pump_a", "valve_flush"): 0, + ("valve_flush", "sample"): 100, } self.va._assign_tubing_config(test_tubing_config_2) self.va._assemble_tubing_stack(0) @@ -153,10 +185,10 @@ def test_tubing_stack_2(self): # as no tubing volume is assigned, the tubing column # matches the single steps tubing_stack_expected = { - 0: [(0, 500.), (1, 100.)], - 1: [(1, 100.), (0, 100.)], + 0: [(0, 500.0), (1, 100.0)], + 1: [(1, 100.0), (0, 100.0)], 2: [], - 3: [(0, 200.), (3, 100.)], + 3: [(0, 200.0), (3, 100.0)], } # print('expected', tubing_stack_expected) # print('actual', self.va.tubing_stack) @@ -165,11 +197,11 @@ def test_tubing_stack_2(self): def test_tubing_stack_3(self): # check tubing column with volume in tubings test_tubing_config_2 = { - ('R0', 'pump_a'): 100, - ('R1', 'pump_a'): 300, - ('R3', 'pump_a'): 200, - ('pump_a', 'valve_flush'): 0, - ('valve_flush', 'sample'): 0, + ("R0", "pump_a"): 100, + ("R1", "pump_a"): 300, + ("R3", "pump_a"): 200, + ("pump_a", "valve_flush"): 0, + ("valve_flush", "sample"): 0, } self.va._assign_tubing_config(test_tubing_config_2) self.va._assemble_tubing_stack(0) @@ -178,10 +210,10 @@ def test_tubing_stack_3(self): # as no tubing volume is assigned, the tubing column # matches the single steps tubing_stack_expected = { - 0: [(0, 500.), (1, 100.)], - 1: [(1, 100.), (0, 300.)], + 0: [(0, 500.0), (1, 100.0)], + 1: [(1, 100.0), (0, 300.0)], 2: [], - 3: [(3, 200.)], + 3: [(3, 200.0)], } # print('expected', tubing_stack_expected) # print('actual', self.va.tubing_stack) @@ -198,15 +230,21 @@ def test_set_valve(self): ham.communication.sendCommand.reset_mock() self.va._set_valves(0) # logger.debug(ham.communication.sendCommand.call_args_list) - ham.communication.sendCommand.assert_has_calls([ - call('1', 'h26003R', waitForPump=False), - call('2', 'h26002R', waitForPump=False)]) + ham.communication.sendCommand.assert_has_calls( + [ + call("1", "h26003R", waitForPump=False), + call("2", "h26002R", waitForPump=False), + ] + ) ham.communication.sendCommand.reset_mock() self.va._set_valves(3) - ham.communication.sendCommand.assert_has_calls([ - call('1', 'h26002R', waitForPump=False), - call('2', 'h26004R', waitForPump=False)]) + ham.communication.sendCommand.assert_has_calls( + [ + call("1", "h26002R", waitForPump=False), + call("2", "h26004R", waitForPump=False), + ] + ) def test_inject(self): """Test system injection issues the expected device commands. @@ -223,28 +261,30 @@ def test_inject(self): self.va._inject(10) except ValueError: # hamilton devices are not connected. skip - print('skipping test as hamilton is not connected') + print("skipping test as hamilton is not connected") return sent = ham.communication.sendCommand.call_args_list self.assertGreater(len(sent), 0, "no device commands issued") # Flush valve (address '5') is set at the start of an injection. - self.assertIn( - call('5', 'h26001R', waitForPump=False), sent) + self.assertIn(call("5", "h26001R", waitForPump=False), sent) # pump_a (address '2' on the test config -> ascii) and pump_out # (address '3') should both receive volume commands containing a # pickup 'P' or dispense 'D' opcode. def has_volume_command(addr): return any( - c.args and c.args[0] == addr + c.args + and c.args[0] == addr and isinstance(c.args[1], str) - and ('P' in c.args[1] or 'D' in c.args[1]) - for c in sent) + and ("P" in c.args[1] or "D" in c.args[1]) + for c in sent + ) self.assertTrue( - has_volume_command('3'), "pump_out issued no volume command") + has_volume_command("3"), "pump_out issued no volume command" + ) self.assertTrue( - has_volume_command('4'), "valve_flush/pump path issued no command") - + has_volume_command("4"), "valve_flush/pump path issued no command" + ) diff --git a/PycroFlow/tests/test_hamilton_components.py b/PycroFlow/tests/test_hamilton_components.py index 2d16647..deb7dc0 100644 --- a/PycroFlow/tests/test_hamilton_components.py +++ b/PycroFlow/tests/test_hamilton_components.py @@ -1,50 +1,55 @@ """Tests for hamilton_components: tubing/reservoir data structures (pure logic) and Pump/Valve driven against the Hamilton serial emulator.""" + import threading import unittest import PycroFlow.pyHamilton as ham from PycroFlow.hamilton_components import ( - Reservoir, ReservoirDict, TubingConfig, Pump, Valve, + Reservoir, + ReservoirDict, + TubingConfig, + Pump, + Valve, ) from PycroFlow.tests.emulators import patch_serial - # --------------------------------------------------------------------------- # Pure data structures (no hardware) # --------------------------------------------------------------------------- + class TubingConfigTest(unittest.TestCase): def test_get_returns_entry(self): - tc = TubingConfig({('R0', 'pump_a'): 42}) - self.assertEqual(tc.get('R0', 'pump_a'), 42) + tc = TubingConfig({("R0", "pump_a"): 42}) + self.assertEqual(tc.get("R0", "pump_a"), 42) def test_reservoir_to_pump_direct(self): - tc = TubingConfig({('R2', 'pump_a'): 17}) - self.assertEqual(tc.get_reservoir_to_pump(2, 'a'), 17) + tc = TubingConfig({("R2", "pump_a"): 17}) + self.assertEqual(tc.get_reservoir_to_pump(2, "a"), 17) def test_reservoir_to_pump_assembled_along_segments(self): - tc = TubingConfig({('R2', 'V0'): 10, ('V0', 'pump_a'): 5}) - self.assertEqual(tc.get_reservoir_to_pump(2, 'a'), 15) + tc = TubingConfig({("R2", "V0"): 10, ("V0", "pump_a"): 5}) + self.assertEqual(tc.get_reservoir_to_pump(2, "a"), 15) def test_reservoir_to_pump_via_special_name(self): - tc = TubingConfig({('R2', 'pump_a'): 8}) - tc.set_special_names({'buffer': 2}) - self.assertEqual(tc.get_reservoir_to_pump('buffer', 'a'), 8) + tc = TubingConfig({("R2", "pump_a"): 8}) + tc.set_special_names({"buffer": 2}) + self.assertEqual(tc.get_reservoir_to_pump("buffer", "a"), 8) def test_reservoir_to_closest_valve(self): - tc = TubingConfig({('R2', 'V0'): 11}) + tc = TubingConfig({("R2", "V0"): 11}) self.assertEqual(tc.get_reservoir_to_closest_valve(2), 11) def test_reservoir_to_closest_valve_missing_raises(self): - tc = TubingConfig({('R2', 'sample'): 11}) + tc = TubingConfig({("R2", "sample"): 11}) with self.assertRaises(KeyError): tc.get_reservoir_to_closest_valve(2) def test_set_reservoir_to_pump(self): tc = TubingConfig({}) - tc.set_reservoir_to_pump(3, 'a', 99) - self.assertEqual(tc.get_reservoir_to_pump(3, 'a'), 99) + tc.set_reservoir_to_pump(3, "a", 99) + self.assertEqual(tc.get_reservoir_to_pump(3, "a"), 99) class ReservoirDictTest(unittest.TestCase): @@ -61,7 +66,7 @@ def test_accepts_str_ids(self): rd = ReservoirDict() rd.add(Reservoir(0, {0: 3})) # input functions may pass string ids - self.assertEqual(rd.get_reservoir_nvalves('0'), 1) + self.assertEqual(rd.get_reservoir_nvalves("0"), 1) def test_reservoir_nvalves_property(self): self.assertEqual(Reservoir(0, {0: 1, 1: 2, 2: 3}).nvalves, 3) @@ -71,6 +76,7 @@ def test_reservoir_nvalves_property(self): # Pump / Valve against the serial emulator # --------------------------------------------------------------------------- + class HamiltonDeviceTest(unittest.TestCase): def setUp(self): self.flag = threading.Event() @@ -78,25 +84,33 @@ def setUp(self): ham.communication.abort_wait_response_flag = threading.Event() self._ctx = patch_serial() self.fake = self._ctx.__enter__() - ham.connect('18', 9600) + ham.connect("18", 9600) def tearDown(self): self._ctx.__exit__(None, None, None) ham.communication.abort_wait_response_flag = self._saved - def _pump(self, output_pos='out'): - return Pump('2', '500u', instrument_type='4', valve_type='Y', - output_pos=output_pos, input_pos='in', waste_pos=1, - pause_flag=self.flag, abort_flag=self.flag) + def _pump(self, output_pos="out"): + return Pump( + "2", + "500u", + instrument_type="4", + valve_type="Y", + output_pos=output_pos, + input_pos="in", + waste_pos=1, + pause_flag=self.flag, + abort_flag=self.flag, + ) def test_construct_with_input_output_position(self): # output_pos == 'in' exercises the 'Y' init branch. - pump = self._pump(output_pos='in') + pump = self._pump(output_pos="in") self.assertEqual(pump.syringe_volume, 500.0) def test_get_status_returns_response(self): pump = self._pump() - self.assertIn('`', pump.get_status()) + self.assertIn("`", pump.get_status()) def test_stop_current_move_is_safe(self): pump = self._pump() @@ -104,17 +118,17 @@ def test_stop_current_move_is_safe(self): def test_decode_response_valid(self): pump = self._pump() - self.assertEqual(pump.decode_response('/0`12000\x03'), '12000') + self.assertEqual(pump.decode_response("/0`12000\x03"), "12000") def test_decode_response_not_ready_raises(self): pump = self._pump() with self.assertRaises(ValueError): - pump.decode_response('/0@\x03') # busy, no ready byte + pump.decode_response("/0@\x03") # busy, no ready byte def test_decode_response_incomplete_raises(self): pump = self._pump() with self.assertRaises(ValueError): - pump.decode_response('/0`12000') # no ETX + pump.decode_response("/0`12000") # no ETX def test_set_velocity_sends_command(self): # Regression: set_velocity used to raise TypeError from a stray unary @@ -124,8 +138,10 @@ def test_set_velocity_sends_command(self): before = len(self.fake.command_log) pump.set_velocity(100, 1000, 100) # µL/min sent = self.fake.command_log[before:] - self.assertTrue(any('V' in msg for _, msg in sent), - "set_velocity issued no max-velocity command") + self.assertTrue( + any("V" in msg for _, msg in sent), + "set_velocity issued no max-velocity command", + ) def test_velocity_conversion_round_trips(self): pump = self._pump() @@ -146,35 +162,42 @@ def test_resume_current_move_after_pickup(self): pump.resume_current_move() def test_valve_deferred_move_returns_exec_command(self): - valve = Valve('1', 'MVP', '8-5') + valve = Valve("1", "MVP", "8-5") valve.pause_flag = self.flag valve.abort_flag = self.flag exec_cmd = valve.set_valve(3, move_now=False) # move_now=False returns the execute-buffer command for later. - self.assertEqual(exec_cmd, 'R') + self.assertEqual(exec_cmd, "R") valve.wait_until_done() - self.assertIn('`', valve.get_status()) + self.assertIn("`", valve.get_status()) def test_pump_flags_default_to_events(self): # Regression: building a Pump without orchestration (e.g. to run # fill_tubings directly) left pause_flag/abort_flag as None, so the # flag-checking loops raised AttributeError. They must default to # unset Events so direct hardware use works. - pump = Pump('2', '500u', instrument_type='4', valve_type='Y', - output_pos='out', input_pos='in', waste_pos=1) + pump = Pump( + "2", + "500u", + instrument_type="4", + valve_type="Y", + output_pos="out", + input_pos="in", + waste_pos=1, + ) self.assertIsInstance(pump.pause_flag, threading.Event) self.assertIsInstance(pump.abort_flag, threading.Event) self.assertFalse(pump.pause_flag.is_set()) - pump.set_valve('in') # exercises the flag loop; must not raise + pump.set_valve("in") # exercises the flag loop; must not raise def test_valve_flags_default_to_events(self): # Same regression for valves: set_valve hits the # `while self.pause_flag.is_set()` loop that crashed with None. - valve = Valve('1', 'MVP', '8-5') + valve = Valve("1", "MVP", "8-5") self.assertIsInstance(valve.pause_flag, threading.Event) self.assertIsInstance(valve.abort_flag, threading.Event) valve.set_valve(3, move_now=False) # must not raise -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_illumination.py b/PycroFlow/tests/test_illumination.py index 8e54fea..2f73cb0 100644 --- a/PycroFlow/tests/test_illumination.py +++ b/PycroFlow/tests/test_illumination.py @@ -5,6 +5,7 @@ the attribute surface IlluminationSystem touches and verify the control logic (laser selection, power, attenuation, beam path, protocol dispatch, pause/abort). """ + import unittest from unittest.mock import MagicMock @@ -25,7 +26,7 @@ def set(self, pos): def home(self): self.homed = True - self.pos = 'home' + self.pos = "home" def curr_pos(self): return self.pos @@ -39,7 +40,7 @@ def __init__(self): class FakeBeampath: def __init__(self): self.positions = None - self.objects = {'shutter': MagicMock(autoshutter=True)} + self.objects = {"shutter": MagicMock(autoshutter=True)} class FakeInstrument: @@ -72,7 +73,7 @@ def _make_system(): isy.instrument = FakeInstrument() isy.power_setvalues = {488: 10, 561: 20} isy.mprotocol = { - 'beampath': {488: ['open488'], 561: ['open561'], 'end': ['closed']}, + "beampath": {488: ["open488"], 561: ["open561"], "end": ["closed"]}, } return isy @@ -122,12 +123,12 @@ def test_set_sample_power_noop_when_unchanged(self): class AttenuationTest(unittest.TestCase): def test_set_attenuation_numeric(self): isy = _make_system() - isy.set_attenuation('0.5') + isy.set_attenuation("0.5") self.assertEqual(isy.instrument.attenuator.pos, 0.5) def test_set_attenuation_home(self): isy = _make_system() - isy.set_attenuation('HOME') + isy.set_attenuation("HOME") self.assertTrue(isy.instrument.attenuator.homed) @@ -135,12 +136,12 @@ class BeampathTest(unittest.TestCase): def test_beampath_open_sets_positions_for_current_laser(self): isy = _make_system() isy.beampath_open() - self.assertEqual(isy.instrument.beampath.positions, ['open488']) + self.assertEqual(isy.instrument.beampath.positions, ["open488"]) def test_beampath_close_sets_end_positions(self): isy = _make_system() isy.beampath_close() - self.assertEqual(isy.instrument.beampath.positions, ['closed']) + self.assertEqual(isy.instrument.beampath.positions, ["closed"]) def test_beampath_open_guards_missing_protocol(self): isy = _make_system() @@ -152,40 +153,51 @@ def test_beampath_open_guards_missing_protocol(self): class ProtocolDispatchTest(unittest.TestCase): def _run(self, entries): isy = _make_system() - isy.protocol = {'protocol_entries': entries} + isy.protocol = {"protocol_entries": entries} for i in range(len(entries)): isy.execute_protocol_entry(i) return isy def test_set_power_entry(self): - isy = self._run([ - {'$type': 'set power', 'laser': 561, 'power': 55}, - ]) + isy = self._run( + [ + {"$type": "set power", "laser": 561, "power": 55}, + ] + ) self.assertEqual(isy.instrument.laser, 561) self.assertEqual(isy.instrument.power, 55) - self.assertEqual(isy.instrument.beampath.positions, ['open561']) + self.assertEqual(isy.instrument.beampath.positions, ["open561"]) def test_set_shutter_entry_open_and_close(self): - isy = self._run([ - {'$type': 'set shutter', 'state': True}, - ]) - self.assertEqual(isy.instrument.beampath.positions, ['open488']) - isy.protocol = {'protocol_entries': [ - {'$type': 'set shutter', 'state': False}]} + isy = self._run( + [ + {"$type": "set shutter", "state": True}, + ] + ) + self.assertEqual(isy.instrument.beampath.positions, ["open488"]) + isy.protocol = { + "protocol_entries": [{"$type": "set shutter", "state": False}] + } isy.execute_protocol_entry(0) - self.assertEqual(isy.instrument.beampath.positions, ['closed']) + self.assertEqual(isy.instrument.beampath.positions, ["closed"]) def test_laser_enable_entry_single(self): - isy = self._run([ - {'$type': 'laser enable', 'laser': 561, 'state': True}, - ]) + isy = self._run( + [ + {"$type": "laser enable", "laser": 561, "state": True}, + ] + ) self.assertTrue(isy.instrument.lasers[561].enabled) def test_laser_enable_entry_all(self): - isy = self._run([ - {'$type': 'laser enable', 'laser': 'all', 'state': True}, - ]) - self.assertTrue(all(l.enabled for l in isy.instrument.lasers.values())) + isy = self._run( + [ + {"$type": "laser enable", "laser": "all", "state": True}, + ] + ) + self.assertTrue( + all(laser.enabled for laser in isy.instrument.lasers.values()) + ) class LazyMonetTest(unittest.TestCase): @@ -194,13 +206,15 @@ def test_assign_protocol_does_not_load_monet(self): # (which builds the orchestrator) never touches hardware. isy = IlluminationSystem() isy._assign_protocol( - {'protocol_entries': [], 'parameters': {'setup': 'X'}}) - self.assertIsNone(getattr(isy, 'instrument', None)) + {"protocol_entries": [], "parameters": {"setup": "X"}} + ) + self.assertIsNone(getattr(isy, "instrument", None)) def test_ensure_monet_loads_once(self): isy = IlluminationSystem() isy._assign_protocol( - {'protocol_entries': [], 'parameters': {'setup': 'X'}}) + {"protocol_entries": [], "parameters": {"setup": "X"}} + ) calls = [] def fake_load(name): @@ -209,9 +223,9 @@ def fake_load(name): isy._load_monet_control = fake_load isy._ensure_monet() - self.assertEqual(calls, ['X']) - isy._ensure_monet() # idempotent — already loaded - self.assertEqual(calls, ['X']) + self.assertEqual(calls, ["X"]) + isy._ensure_monet() # idempotent — already loaded + self.assertEqual(calls, ["X"]) class PauseAbortTest(unittest.TestCase): @@ -226,5 +240,5 @@ def test_pause_resume_abort_flags(self): self.assertFalse(isy._paused) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_imaging.py b/PycroFlow/tests/test_imaging.py index 9b2b8f3..fbf58d3 100644 --- a/PycroFlow/tests/test_imaging.py +++ b/PycroFlow/tests/test_imaging.py @@ -6,35 +6,38 @@ import PycroFlow.imaging as pim from PycroFlow.tests import TEST_OUTPUT_DIR - logger = logging.getLogger(__name__) def _make_config(): return { - 'save_dir': TEST_OUTPUT_DIR, - 'base_name': 'AutomationTest_R2R4', + "save_dir": TEST_OUTPUT_DIR, + "base_name": "AutomationTest_R2R4", # ImagingSystem.__init__ seeds a PFS log from these tags via the # (mocked) Core; values are irrelevant, only the keys must exist. - 'pfs_pars': { - 'tag_zdrive': 'ZDrive', - 'tag_status': 'PFS', - 'prop_status': 'PFS Status', - 'prop_state': 'PFS in Range', + "pfs_pars": { + "tag_zdrive": "ZDrive", + "tag_status": "PFS", + "prop_status": "PFS Status", + "prop_state": "PFS in Range", }, } def _make_protocol(): return { - 'parameters': { - 'show_progress': False, - 'show_display': True, - 'close_display_after_acquisition': True, + "parameters": { + "show_progress": False, + "show_display": True, + "close_display_after_acquisition": True, }, - 'protocol_entries': [ - {'$type': 'acquire', 'frames': 10, 't_exp': 100, - 'message': 'round_1'}, + "protocol_entries": [ + { + "$type": "acquire", + "frames": 10, + "t_exp": 100, + "message": "round_1", + }, ], } @@ -53,21 +56,24 @@ class TestImaging(unittest.TestCase): def setUp(self): # Acquisition is used as a context manager; MagicMock supports the # protocol out of the box (__enter__/__exit__ return MagicMocks). - self.mock_acquisition = patch( - 'PycroFlow.imaging.Acquisition').start() + self.mock_acquisition = patch("PycroFlow.imaging.Acquisition").start() self.addCleanup(patch.stopall) - patch('PycroFlow.imaging.multi_d_acquisition_events', - return_value=[None]).start() + patch( + "PycroFlow.imaging.multi_d_acquisition_events", return_value=[None] + ).start() # Avoid touching a real Micro-Manager: hand back mock Core/Studio and # neutralize the filesystem MM-Core lock. - self.mock_core = MagicMock(name='core') - self.mock_studio = MagicMock(name='studio') - patch('PycroFlow.services.mm_core.get_core', - return_value=self.mock_core).start() - patch('PycroFlow.services.mm_core.get_studio', - return_value=self.mock_studio).start() - patch('PycroFlow.imaging.MmCoreLock').start() + self.mock_core = MagicMock(name="core") + self.mock_studio = MagicMock(name="studio") + patch( + "PycroFlow.services.mm_core.get_core", return_value=self.mock_core + ).start() + patch( + "PycroFlow.services.mm_core.get_studio", + return_value=self.mock_studio, + ).start() + patch("PycroFlow.imaging.MmCoreLock").start() def test_01_construction(self): """ImagingSystem constructs and runs its self-test acquisition.""" @@ -75,24 +81,26 @@ def test_01_construction(self): isy._assign_protocol(_make_protocol()) # __init__ runs test_acquisition(), which opens one Acquisition. self.assertTrue(self.mock_acquisition.called) - self.assertTrue(os.path.isdir(isy.config['save_dir'])) + self.assertTrue(os.path.isdir(isy.config["save_dir"])) def test_create_savedir_appends_when_folder_exists(self): """An existing target folder gets the first free _1/_2 suffix rather than erroring — so loading a design twice never fails on the dir.""" import types - name = 'savedir_collision_test' + + name = "savedir_collision_test" def make(): o = types.SimpleNamespace( - config={'save_dir': TEST_OUTPUT_DIR, 'base_name': name}) + config={"save_dir": TEST_OUTPUT_DIR, "base_name": name} + ) pim.ImagingSystem.create_savedir(o) - return o.config['save_dir'] + return o.config["save_dir"] p0, p1, p2 = make(), make(), make() self.assertEqual(p0, os.path.join(TEST_OUTPUT_DIR, name)) - self.assertEqual(p1, os.path.join(TEST_OUTPUT_DIR, name + '_1')) - self.assertEqual(p2, os.path.join(TEST_OUTPUT_DIR, name + '_2')) + self.assertEqual(p1, os.path.join(TEST_OUTPUT_DIR, name + "_1")) + self.assertEqual(p2, os.path.join(TEST_OUTPUT_DIR, name + "_2")) for p in (p0, p1, p2): self.assertTrue(os.path.isdir(p)) @@ -110,9 +118,10 @@ def test_02(self): self.mock_core.set_exposure.assert_any_call(100) # A PFS log was written next to the acquisition. pfs_log = os.path.join( - isy.config['save_dir'], 'prtclstep0_round_1_pfs.xlsx') + isy.config["save_dir"], "prtclstep0_round_1_pfs.xlsx" + ) self.assertTrue(os.path.isfile(pfs_log)) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_orchestration.py b/PycroFlow/tests/test_orchestration.py index 464c5e4..67a6562 100644 --- a/PycroFlow/tests/test_orchestration.py +++ b/PycroFlow/tests/test_orchestration.py @@ -6,13 +6,12 @@ import PycroFlow.orchestration as por from PycroFlow.orchestration import ThreadExchange - logger = logging.getLogger(__name__) def _wrap(entries): """Handlers expect a {'protocol_entries': [...]} dict, not a bare list.""" - return {'protocol_entries': entries} + return {"protocol_entries": entries} class TestOrchestration(unittest.TestCase): @@ -33,144 +32,192 @@ def get_threadexchange(self): def test_01(self): threadexchange = self.get_threadexchange() protocol_fluid = [ - {'$type': 'inject', 'reservoir_id': 0, 'volume': 500}, - {'$type': 'incubate', 'duration': 120}, - {'$type': 'inject', 'reservoir_id': 1, 'volume': 500, - 'velocity': 600}, - {'$type': 'signal', 'value': 'fluid round 1 done'}, - {'$type': 'flush', 'flushfactor': 1}, - {'$type': 'wait for signal', 'target': 'img', - 'value': 'round 1 done'}, - {'$type': 'inject', 'reservoir_id': 20, 'volume': 500}, + {"$type": "inject", "reservoir_id": 0, "volume": 500}, + {"$type": "incubate", "duration": 120}, + { + "$type": "inject", + "reservoir_id": 1, + "volume": 500, + "velocity": 600, + }, + {"$type": "signal", "value": "fluid round 1 done"}, + {"$type": "flush", "flushfactor": 1}, + { + "$type": "wait for signal", + "target": "img", + "value": "round 1 done", + }, + {"$type": "inject", "reservoir_id": 20, "volume": 500}, ] - fh = por.FluidHandler(MagicMock(), _wrap(protocol_fluid), threadexchange) + fh = por.FluidHandler( + MagicMock(), _wrap(protocol_fluid), threadexchange + ) fh.execute_protocol_entry(0) - threadexchange['abort_flag'].set() + threadexchange["abort_flag"].set() fh.run() def test_02(self): - logger.debug('TESTING FluidHandler') + logger.debug("TESTING FluidHandler") threadexchange = self.get_threadexchange() protocol_fluid = [ - {'$type': 'inject', 'reservoir_id': 0, 'volume': 500}, - {'$type': 'signal', 'value': 'fluid round 1 done'}, - {'$type': 'wait for signal', 'target': 'img', - 'value': 'round 1 done'}, - {'$type': 'inject', 'reservoir_id': 20, 'volume': 500}, + {"$type": "inject", "reservoir_id": 0, "volume": 500}, + {"$type": "signal", "value": "fluid round 1 done"}, + { + "$type": "wait for signal", + "target": "img", + "value": "round 1 done", + }, + {"$type": "inject", "reservoir_id": 20, "volume": 500}, ] dummy_system = MagicMock() - fh = por.FluidHandler(dummy_system, _wrap(protocol_fluid), threadexchange) - threadexchange['start_protocol_flag'].set() + fh = por.FluidHandler( + dummy_system, _wrap(protocol_fluid), threadexchange + ) + threadexchange["start_protocol_flag"].set() fh.start() # now running in separate thread time.sleep(1) - threadexchange['abort_flag'].set() - threadexchange['abort_protocol_flag'].set() + threadexchange["abort_flag"].set() + threadexchange["abort_protocol_flag"].set() fh.join(timeout=2) # The handler emits an 'Ending.' marker via send_message when it # aborts during housekeeping, so the message log may carry it after # the protocol signal. - self.assertIn('fluid round 1 done', threadexchange['fluid']) + self.assertIn("fluid round 1 done", threadexchange["fluid"]) # The handler also calls system setup (_assign_protocol, # _assign_multiprocess_events) and abort_execution on shutdown, so # check the inject step was executed rather than asserting the exact # call list. - self.assertIn(call.execute_protocol_entry(0), dummy_system.method_calls) + self.assertIn( + call.execute_protocol_entry(0), dummy_system.method_calls + ) def test_03(self): - logger.debug('TESTING ImagingHandler') + logger.debug("TESTING ImagingHandler") threadexchange = self.get_threadexchange() protocol_img = [ - {'$type': 'acquire', 'frames': 1000, 't_exp': 100}, - {'$type': 'signal', 'value': 'imaging round 1 done'}, - {'$type': 'wait for signal', 'target': 'img', - 'value': 'round 1 done'}, + {"$type": "acquire", "frames": 1000, "t_exp": 100}, + {"$type": "signal", "value": "imaging round 1 done"}, + { + "$type": "wait for signal", + "target": "img", + "value": "round 1 done", + }, ] dummy_system = MagicMock() - fh = por.ImagingHandler(dummy_system, _wrap(protocol_img), threadexchange) - threadexchange['start_protocol_flag'].set() + fh = por.ImagingHandler( + dummy_system, _wrap(protocol_img), threadexchange + ) + threadexchange["start_protocol_flag"].set() fh.start() # now running in separate thread time.sleep(1) - threadexchange['abort_flag'].set() + threadexchange["abort_flag"].set() fh.join(timeout=2) - self.assertIn('imaging round 1 done', threadexchange['img']) - self.assertIn(call.execute_protocol_entry(0), dummy_system.method_calls) + self.assertIn("imaging round 1 done", threadexchange["img"]) + self.assertIn( + call.execute_protocol_entry(0), dummy_system.method_calls + ) def test_04(self): - logger.debug('TESTING IlluminationHandler') + logger.debug("TESTING IlluminationHandler") threadexchange = self.get_threadexchange() protocol_illu = [ - {'$type': 'power', 'value': 20}, - {'$type': 'signal', 'value': 'illumination round 1 done'}, - {'$type': 'wait for signal', 'target': 'img', - 'value': 'round 1 done'}, + {"$type": "power", "value": 20}, + {"$type": "signal", "value": "illumination round 1 done"}, + { + "$type": "wait for signal", + "target": "img", + "value": "round 1 done", + }, ] dummy_system = MagicMock() - fh = por.IlluminationHandler(dummy_system, _wrap(protocol_illu), threadexchange) - threadexchange['start_protocol_flag'].set() + fh = por.IlluminationHandler( + dummy_system, _wrap(protocol_illu), threadexchange + ) + threadexchange["start_protocol_flag"].set() fh.start() # now running in separate thread time.sleep(1) - threadexchange['abort_flag'].set() + threadexchange["abort_flag"].set() fh.join(timeout=2) - self.assertIn('illumination round 1 done', threadexchange['illu']) - self.assertIn(call.execute_protocol_entry(0), dummy_system.method_calls) + self.assertIn("illumination round 1 done", threadexchange["illu"]) + self.assertIn( + call.execute_protocol_entry(0), dummy_system.method_calls + ) def test_05(self): - logger.debug('TESTING Orchestration') + logger.debug("TESTING Orchestration") protocol = { - 'fluid': _wrap([ - {'$type': 'signal', 'value': 'fluid round 1 done'}, - {'$type': 'wait for signal', 'target': 'img', - 'value': 'imaging round 1 done'}]), - 'img': _wrap([ - {'$type': 'wait for signal', 'target': 'fluid', - 'value': 'fluid round 1 done'}, - {'$type': 'signal', 'value': 'imaging round 1 done'}]), + "fluid": _wrap( + [ + {"$type": "signal", "value": "fluid round 1 done"}, + { + "$type": "wait for signal", + "target": "img", + "value": "imaging round 1 done", + }, + ] + ), + "img": _wrap( + [ + { + "$type": "wait for signal", + "target": "fluid", + "value": "fluid round 1 done", + }, + {"$type": "signal", "value": "imaging round 1 done"}, + ] + ), } dummy_fluid = MagicMock() dummy_imaging = MagicMock() po = por.ProtocolOrchestrator( - protocol, fluid_system=dummy_fluid, imaging_system=dummy_imaging) + protocol, fluid_system=dummy_fluid, imaging_system=dummy_imaging + ) po.start_orchestration() po.start_protocol() # now running in separate thread time.sleep(1) - logger.debug('protocol finished' + str(po.poll_protocol_finished())) + logger.debug("protocol finished" + str(po.poll_protocol_finished())) po.end_orchestration() - self.assertEqual(po.threadexchange['fluid'], ['fluid round 1 done']) - self.assertEqual(po.threadexchange['img'], ['imaging round 1 done']) + self.assertEqual(po.threadexchange["fluid"], ["fluid round 1 done"]) + self.assertEqual(po.threadexchange["img"], ["imaging round 1 done"]) def test_get_step_progress_handler_and_system(self): # The handler returns its own step_progress (set by the incubate # dispatcher) if present, else delegates to the system. threadexchange = self.get_threadexchange() system = MagicMock() - system.get_step_progress.return_value = (3, 10, 'frames') + system.get_step_progress.return_value = (3, 10, "frames") fh = por.ImagingHandler( - system, _wrap([{'$type': 'acquire', 'frames': 10, 't_exp': 1}]), - threadexchange) + system, + _wrap([{"$type": "acquire", "frames": 10, "t_exp": 1}]), + threadexchange, + ) # No handler-level progress -> delegates to the system. - self.assertEqual(fh.get_step_progress(), (3, 10, 'frames')) + self.assertEqual(fh.get_step_progress(), (3, 10, "frames")) # Handler-level progress (e.g. incubate) takes precedence. - fh.step_progress = (5.0, 30.0, 'incubate') - self.assertEqual(fh.get_step_progress(), (5.0, 30.0, 'incubate')) + fh.step_progress = (5.0, 30.0, "incubate") + self.assertEqual(fh.get_step_progress(), (5.0, 30.0, "incubate")) def test_incubate_sets_step_progress(self): # The incubate dispatcher exposes elapsed/total while waiting. from PycroFlow.protocol_entries import parse_entry from PycroFlow.orchestration.core import dispatch_entry + threadexchange = self.get_threadexchange() fh = por.FluidHandler( - MagicMock(), _wrap([{'$type': 'incubate', 'duration': 0.3}]), - threadexchange) + MagicMock(), + _wrap([{"$type": "incubate", "duration": 0.3}]), + threadexchange, + ) seen = [] def watch(): @@ -181,14 +228,15 @@ def watch(): time.sleep(0.01) import threading + t = threading.Thread(target=watch) t.start() - dispatch_entry(parse_entry({'$type': 'incubate', 'duration': 0.3}), fh) + dispatch_entry(parse_entry({"$type": "incubate", "duration": 0.3}), fh) t.join(timeout=1) self.assertTrue(seen, "step_progress was never set during incubate") cur, tot, label = seen[0] self.assertEqual(tot, 0.3) - self.assertEqual(label, 'incubate') + self.assertEqual(label, "incubate") # Cleared once the wait completes. self.assertIsNone(fh.step_progress) @@ -197,14 +245,17 @@ def test_illumination_handler_assigns_protocol(self): # system (like Fluid/Imaging) or execute_protocol_entry raises # AttributeError('IlluminationSystem' has no attribute 'protocol'). from PycroFlow.tests.emulators import EmulatedIlluminationSystem - protocol = {'illu': _wrap([ - {'$type': 'set power', 'laser': 1, 'power': 5}])} + + protocol = { + "illu": _wrap([{"$type": "set power", "laser": 1, "power": 5}]) + } illu = EmulatedIlluminationSystem() por.ProtocolOrchestrator(protocol, illumination_system=illu) self.assertIsNotNone(illu.protocol) self.assertEqual( - illu.protocol['protocol_entries'][0]['$type'], 'set power') + illu.protocol["protocol_entries"][0]["$type"], "set power" + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_protocols.py b/PycroFlow/tests/test_protocols.py index 433f071..b5e65ce 100644 --- a/PycroFlow/tests/test_protocols.py +++ b/PycroFlow/tests/test_protocols.py @@ -4,7 +4,6 @@ import PycroFlow.protocols as pprot from PycroFlow.tests import TEST_OUTPUT_DIR - logger = logging.getLogger(__name__) @@ -25,51 +24,55 @@ def test_02(self): frames = 1000 t_exp = 100 - message = 'msg' + message = "msg" pb.create_step_acquire(nframes=frames, t_exp=t_exp, message=message) steps_expect = { - 'fluid': [], - 'illu': [], - 'img': [{ - '$type': 'acquire', - 'frames': frames, - 't_exp': t_exp, - 'message': message}] + "fluid": [], + "illu": [], + "img": [ + { + "$type": "acquire", + "frames": frames, + "t_exp": t_exp, + "message": message, + } + ], } self.assertEqual(pb.steps, steps_expect) def test_03(self): pb = pprot.ProtocolBuilder() - system = 'fluid' - target = 'img' - message = 'msg' + system = "fluid" + target = "img" + message = "msg" pb.create_step_waitfor_signal(system, target, message) steps_expect = { - 'fluid': [{ - '$type': 'wait for signal', - 'target': target, - 'value': message}], - 'illu': [], - 'img': [] + "fluid": [ + { + "$type": "wait for signal", + "target": target, + "value": message, + } + ], + "illu": [], + "img": [], } self.assertEqual(pb.steps, steps_expect) def test_04(self): pb = pprot.ProtocolBuilder() - system = 'fluid' - message = 'msg' + system = "fluid" + message = "msg" pb.create_step_signal(system, message) steps_expect = { - 'fluid': [{ - '$type': 'signal', - 'value': message}], - 'illu': [], - 'img': [] + "fluid": [{"$type": "signal", "value": message}], + "illu": [], + "img": [], } self.assertEqual(pb.steps, steps_expect) @@ -83,13 +86,16 @@ def test_05(self): # create_step_inject defaults delay=0 and includes it in the entry. steps_expect = { - 'fluid': [{ - '$type': 'inject', - 'volume': volume, - 'reservoir_id': reservoir_id, - 'delay': 0}], - 'illu': [], - 'img': [] + "fluid": [ + { + "$type": "inject", + "volume": volume, + "reservoir_id": reservoir_id, + "delay": 0, + } + ], + "illu": [], + "img": [], } self.assertEqual(pb.steps, steps_expect) @@ -106,9 +112,9 @@ def test_06(self): # create_step_incubate converts minutes -> seconds and stores the # numeric value; orchestration.run_protocol coerces with float(). steps_expect = { - 'fluid': [{'$type': 'incubate', 'duration': t_incu * 60}], - 'illu': [], - 'img': [] + "fluid": [{"$type": "incubate", "duration": t_incu * 60}], + "illu": [], + "img": [], } self.assertEqual(pb.steps, steps_expect) @@ -120,34 +126,41 @@ def test_07(self): pb = pprot.ProtocolBuilder() reservoir_names = { - 1: 'R1', 3: 'R3', 5: 'R5', 6: 'R6', - 7: 'R2', 8: 'R4', 9: 'Res9', 10: 'Buffer B+'} + 1: "R1", + 3: "R3", + 5: "R5", + 6: "R6", + 7: "R2", + 8: "R4", + 9: "Res9", + 10: "Buffer B+", + } flow_acq_config = { - 'save_dir': TEST_OUTPUT_DIR, - 'base_name': 'AutomationTest_R2R4', - 'fluid': { - 'parameters': {}, - 'settings': { - 'vol_wash_pre': 50, - 'vol_wash': 500, - 'vol_imager_pre': 500, - 'vol_imager_post': 100, - 'vol_remove_before_wash': 50, - 'wait_after_pickup': 5, - 'reservoir_names': reservoir_names, - 'experiment': { - 'type': 'Exchange', - 'wash_buffer': 'Buffer B+', - 'imagers': ['R4', 'R2'], + "save_dir": TEST_OUTPUT_DIR, + "base_name": "AutomationTest_R2R4", + "fluid": { + "parameters": {}, + "settings": { + "vol_wash_pre": 50, + "vol_wash": 500, + "vol_imager_pre": 500, + "vol_imager_post": 100, + "vol_remove_before_wash": 50, + "wait_after_pickup": 5, + "reservoir_names": reservoir_names, + "experiment": { + "type": "Exchange", + "wash_buffer": "Buffer B+", + "imagers": ["R4", "R2"], }, }, }, - 'img': { - 'parameters': {}, - 'settings': { - 'frames': 50000, - 'darkframes': 50, - 't_exp': 100, + "img": { + "parameters": {}, + "settings": { + "frames": 50000, + "darkframes": 50, + "t_exp": 100, }, }, } @@ -155,54 +168,62 @@ def test_07(self): pb.create_steps_exchange(flow_acq_config) - self.assertGreater(len(pb.steps['fluid']), 0) - self.assertGreater(len(pb.steps['img']), 0) + self.assertGreater(len(pb.steps["fluid"]), 0) + self.assertGreater(len(pb.steps["img"]), 0) def test_08(self): # MERPAINT step builder smoke-test. pb = pprot.ProtocolBuilder() reservoir_names = { - 1: 'ad_1', 2: 'ad_2', 3: 'ad_3', - 4: 'er_1', 5: 'er_2', 6: 'er_3', - 7: 'R2', 8: 'R4', 9: 'Res9', - 10: 'Buffer B+', 11: 'HybBuf'} + 1: "ad_1", + 2: "ad_2", + 3: "ad_3", + 4: "er_1", + 5: "er_2", + 6: "er_3", + 7: "R2", + 8: "R4", + 9: "Res9", + 10: "Buffer B+", + 11: "HybBuf", + } flow_acq_config = { - 'save_dir': TEST_OUTPUT_DIR, - 'base_name': 'AutomationTest_R2R4', - 'fluid': { - 'parameters': {}, - 'settings': { - 'vol_wash_pre': 50, - 'vol_wash': 500, - 'vol_imager_pre': 500, - 'vol_imager_post': 100, - 'vol_remove_before_wash': 50, - 'wait_after_pickup': 5, - 'reservoir_names': reservoir_names, - 'experiment': { - 'type': 'MERPAINT', - 'wash_buffer': 'Buffer B+', - 'hybridization_buffer': 'HybBuf', - 'imaging_buffer': 'Buffer B+', - 'wash_buffer_vol': 500, - 'hybridization_buffer_vol': 750, - 'imaging_buffer_vol': 400, - 'imager_vol': 400, - 'adapter_vol': 400, - 'hybridization_time': 600, - 'imagers': ['R4', 'R2'], - 'adapters': ['ad_1', 'ad_2', 'ad_3'], - 'erasers': ['er_1', 'er_2', 'er_3'], + "save_dir": TEST_OUTPUT_DIR, + "base_name": "AutomationTest_R2R4", + "fluid": { + "parameters": {}, + "settings": { + "vol_wash_pre": 50, + "vol_wash": 500, + "vol_imager_pre": 500, + "vol_imager_post": 100, + "vol_remove_before_wash": 50, + "wait_after_pickup": 5, + "reservoir_names": reservoir_names, + "experiment": { + "type": "MERPAINT", + "wash_buffer": "Buffer B+", + "hybridization_buffer": "HybBuf", + "imaging_buffer": "Buffer B+", + "wash_buffer_vol": 500, + "hybridization_buffer_vol": 750, + "imaging_buffer_vol": 400, + "imager_vol": 400, + "adapter_vol": 400, + "hybridization_time": 600, + "imagers": ["R4", "R2"], + "adapters": ["ad_1", "ad_2", "ad_3"], + "erasers": ["er_1", "er_2", "er_3"], }, }, }, - 'img': { - 'parameters': {}, - 'settings': { - 'frames': 50000, - 'darkframes': 50, - 't_exp': 100, + "img": { + "parameters": {}, + "settings": { + "frames": 50000, + "darkframes": 50, + "t_exp": 100, }, }, } @@ -210,8 +231,8 @@ def test_08(self): pb.create_steps_MERPAINT(flow_acq_config) - self.assertGreater(len(pb.steps['fluid']), 0) - self.assertGreater(len(pb.steps['img']), 0) + self.assertGreater(len(pb.steps["fluid"]), 0) + self.assertGreater(len(pb.steps["img"]), 0) def test_09(self): # FlushTest step builder smoke-test. @@ -224,76 +245,91 @@ def test_09(self): pb = pprot.ProtocolBuilder() reservoir_names = { - 1: 'ad_1', 2: 'ad_2', 3: 'ad_3', - 4: 'er_1', 5: 'er_2', 6: 'er_3', - 7: 'R2', 8: 'R4', 9: 'Res9', - 10: 'Buffer B+', 11: 'HybBuf'} + 1: "ad_1", + 2: "ad_2", + 3: "ad_3", + 4: "er_1", + 5: "er_2", + 6: "er_3", + 7: "R2", + 8: "R4", + 9: "Res9", + 10: "Buffer B+", + 11: "HybBuf", + } flow_acq_config = { - 'save_dir': TEST_OUTPUT_DIR, - 'base_name': 'AutomationTest_R2R4', - 'fluid_settings': { - 'vol_wash': 500, - 'vol_imager_pre': 500, - 'vol_imager_post': 100, - 'reservoir_names': reservoir_names, - 'experiment': { - 'type': 'FlushTest', - 'fluids': ['R4', 'Buffer B+', 'R2'], - 'fluid_vols': [100, 300, 200], + "save_dir": TEST_OUTPUT_DIR, + "base_name": "AutomationTest_R2R4", + "fluid_settings": { + "vol_wash": 500, + "vol_imager_pre": 500, + "vol_imager_post": 100, + "reservoir_names": reservoir_names, + "experiment": { + "type": "FlushTest", + "fluids": ["R4", "Buffer B+", "R2"], + "fluid_vols": [100, 300, 200], }, }, - 'imaging_settings': { - 'frames': 50000, - 't_exp': 100, + "imaging_settings": { + "frames": 50000, + "t_exp": 100, }, } pb.reservoir_vols = {id: 0 for id in reservoir_names} pb.create_steps_flushtest(flow_acq_config) - self.assertGreater(len(pb.steps['fluid']), 0) - self.assertGreater(len(pb.steps['img']), 0) + self.assertGreater(len(pb.steps["fluid"]), 0) + self.assertGreater(len(pb.steps["img"]), 0) def test_10(self): # Full create_protocol pipeline test — produces a YAML output file. pb = pprot.ProtocolBuilder() reservoir_names = { - 1: 'R1', 3: 'R3', 5: 'R5', 6: 'R6', - 7: 'R2', 8: 'R4', 9: 'Res9', 10: 'Buffer B+'} + 1: "R1", + 3: "R3", + 5: "R5", + 6: "R6", + 7: "R2", + 8: "R4", + 9: "Res9", + 10: "Buffer B+", + } flow_acq_config = { - 'save_dir': TEST_OUTPUT_DIR, - 'protocol_folder': TEST_OUTPUT_DIR, - 'base_name': 'AutomationTest_R2R4', - 'fluid': { - 'parameters': {}, - 'settings': { - 'vol_wash_pre': 50, - 'vol_wash': 500, - 'vol_imager_pre': 500, - 'vol_imager_post': 100, - 'vol_remove_before_wash': 50, - 'wait_after_pickup': 5, - 'reservoir_names': reservoir_names, - 'experiment': { - 'type': 'Exchange', - 'wash_buffer': 'Buffer B+', - 'imagers': ['R4', 'R2'], + "save_dir": TEST_OUTPUT_DIR, + "protocol_folder": TEST_OUTPUT_DIR, + "base_name": "AutomationTest_R2R4", + "fluid": { + "parameters": {}, + "settings": { + "vol_wash_pre": 50, + "vol_wash": 500, + "vol_imager_pre": 500, + "vol_imager_post": 100, + "vol_remove_before_wash": 50, + "wait_after_pickup": 5, + "reservoir_names": reservoir_names, + "experiment": { + "type": "Exchange", + "wash_buffer": "Buffer B+", + "imagers": ["R4", "R2"], }, }, }, - 'img': { - 'parameters': {}, - 'settings': { - 'frames': 50000, - 'darkframes': 50, - 't_exp': 100, + "img": { + "parameters": {}, + "settings": { + "frames": 50000, + "darkframes": 50, + "t_exp": 100, }, }, } pb.reservoir_vols = {id: 0 for id in reservoir_names} fname, steps = pb.create_protocol(flow_acq_config) - self.assertTrue(fname.endswith('.yaml')) - self.assertGreater(len(steps['fluid']), 0) - self.assertGreater(len(steps['img']), 0) + self.assertTrue(fname.endswith(".yaml")) + self.assertGreater(len(steps["fluid"]), 0) + self.assertGreater(len(steps["img"]), 0) diff --git a/PycroFlow/tests/test_protocols_sph_resi.py b/PycroFlow/tests/test_protocols_sph_resi.py index ef9cc0c..2095077 100644 --- a/PycroFlow/tests/test_protocols_sph_resi.py +++ b/PycroFlow/tests/test_protocols_sph_resi.py @@ -2,6 +2,7 @@ including dispatch through the EXPERIMENT_TYPES registry and the round0 / wash-buffer-2 branches. Illumination is omitted (illusttg=None) so the acquisition stepset takes its no-illumination path.""" + import unittest import PycroFlow.protocols as pprot @@ -9,28 +10,30 @@ def _img_settings(): - return {'parameters': {}, 'settings': { - 'frames': 50000, 'darkframes': 50, 't_exp': 100}} + return { + "parameters": {}, + "settings": {"frames": 50000, "darkframes": 50, "t_exp": 100}, + } def _base_config(experiment, reservoir_names): return { - 'save_dir': TEST_OUTPUT_DIR, - 'base_name': 'sph_resi_test', - 'fluid': { - 'parameters': {}, - 'settings': { - 'vol_wash': 7, - 'vol_reagent': 7, - 'vol_remove_before_flush': 2, - 'wait_after_pickup': 0, - 'reservoir_names': reservoir_names, - 'wash_buffer_1': experiment['wash_buffer_1'], - 'wash_buffer_2': experiment.get('wash_buffer_2'), - 'experiment': experiment, + "save_dir": TEST_OUTPUT_DIR, + "base_name": "sph_resi_test", + "fluid": { + "parameters": {}, + "settings": { + "vol_wash": 7, + "vol_reagent": 7, + "vol_remove_before_flush": 2, + "wait_after_pickup": 0, + "reservoir_names": reservoir_names, + "wash_buffer_1": experiment["wash_buffer_1"], + "wash_buffer_2": experiment.get("wash_buffer_2"), + "experiment": experiment, }, }, - 'img': _img_settings(), + "img": _img_settings(), # No 'illu' key -> illusttg is None -> acquisition skips laser steps. } @@ -39,34 +42,40 @@ class SphResiBuilderTest(unittest.TestCase): def _build(self, config): pb = pprot.ProtocolBuilder() pb.reservoir_vols = { - i: 0 for i in config['fluid']['settings']['reservoir_names']} - pb.steps = {'fluid': [], 'img': [], 'illu': []} + i: 0 for i in config["fluid"]["settings"]["reservoir_names"] + } + pb.steps = {"fluid": [], "img": [], "illu": []} return pb def test_round0_disabled_single_target(self): reservoir_names = { - 0: 'R1-lo', 1: 'R1-hi', 2: 'R3-A1', - 7: 'Blocker', 8: 'A1-c1', 9: 'A1-c2', - 18: 'Wash Buffer 1'} + 0: "R1-lo", + 1: "R1-hi", + 2: "R3-A1", + 7: "Blocker", + 8: "A1-c1", + 9: "A1-c2", + 18: "Wash Buffer 1", + } experiment = { - 'type': 'SPH-RESI', - 'wash_buffer_1': 'Wash Buffer 1', - 'wash_buffer_2': None, - 'blocker': 'Blocker', - 'blocker_incubation': 5, - 'initial_imager_present': False, # -> BC_imager_pre injection runs - 'round0': False, # not a dict -> round0 block skipped - 'target-rounds': { - 'A1': { - 'BC_imager_pre': 'R1-lo', - 'frames_BC_pre': 5000, - 'BC_imager_post': 'R1-hi', - 'frames_BC_post': 15000, - 'RESI-imager': 'R3-A1', - 'RESI-frames': 50000, - 'RESI-rounds': [ - {'adapter': 'A1-c1', 'adapter_incubation': 0.5}, - {'adapter': 'A1-c2', 'adapter_incubation': 5}, + "type": "SPH-RESI", + "wash_buffer_1": "Wash Buffer 1", + "wash_buffer_2": None, + "blocker": "Blocker", + "blocker_incubation": 5, + "initial_imager_present": False, # -> BC_imager_pre injection runs + "round0": False, # not a dict -> round0 block skipped + "target-rounds": { + "A1": { + "BC_imager_pre": "R1-lo", + "frames_BC_pre": 5000, + "BC_imager_post": "R1-hi", + "frames_BC_post": 15000, + "RESI-imager": "R3-A1", + "RESI-frames": 50000, + "RESI-rounds": [ + {"adapter": "A1-c1", "adapter_incubation": 0.5}, + {"adapter": "A1-c2", "adapter_incubation": 5}, ], }, }, @@ -74,85 +83,111 @@ def test_round0_disabled_single_target(self): config = _base_config(experiment, reservoir_names) pb = self._build(config) pb.create_steps_sph_resi(config) - self.assertGreater(len(pb.steps['fluid']), 0) - self.assertGreater(len(pb.steps['img']), 0) + self.assertGreater(len(pb.steps["fluid"]), 0) + self.assertGreater(len(pb.steps["img"]), 0) def test_round0_dict_and_wash_buffer_2_two_targets(self): reservoir_names = { - 0: 'R1-lo', 1: 'R1-hi', 2: 'R3-A1', 3: 'R3-A2', - 7: 'Blocker', 8: 'A1-c1', 9: 'A1-c2', 11: 'A2-c1', - 18: 'Wash Buffer 1', 19: 'Wash Buffer 2'} + 0: "R1-lo", + 1: "R1-hi", + 2: "R3-A1", + 3: "R3-A2", + 7: "Blocker", + 8: "A1-c1", + 9: "A1-c2", + 11: "A2-c1", + 18: "Wash Buffer 1", + 19: "Wash Buffer 2", + } experiment = { - 'type': 'SPH-RESI', - 'wash_buffer_1': 'Wash Buffer 1', - 'wash_buffer_2': 'Wash Buffer 2', # exercises washbuf2 branches - 'blocker': 'Blocker', - 'blocker_incubation': 5, - 'initial_imager_present': False, - 'round0': { # dict -> round0 block runs - 'round0_imager': 'R1-lo', - 'frames_round0': 1000, + "type": "SPH-RESI", + "wash_buffer_1": "Wash Buffer 1", + "wash_buffer_2": "Wash Buffer 2", # exercises washbuf2 branches + "blocker": "Blocker", + "blocker_incubation": 5, + "initial_imager_present": False, + "round0": { # dict -> round0 block runs + "round0_imager": "R1-lo", + "frames_round0": 1000, }, - 'target-rounds': { - 'A1': { - 'BC_imager_pre': 'R1-lo', 'frames_BC_pre': 5000, - 'BC_imager_post': 'R1-hi', 'frames_BC_post': 15000, - 'RESI-imager': 'R3-A1', 'RESI-frames': 50000, - 'RESI-rounds': [ - {'adapter': 'A1-c1', 'adapter_incubation': 0.5}], + "target-rounds": { + "A1": { + "BC_imager_pre": "R1-lo", + "frames_BC_pre": 5000, + "BC_imager_post": "R1-hi", + "frames_BC_post": 15000, + "RESI-imager": "R3-A1", + "RESI-frames": 50000, + "RESI-rounds": [ + {"adapter": "A1-c1", "adapter_incubation": 0.5} + ], }, - 'A2': { # second target -> "not last round" wash - 'BC_imager_pre': 'R1-hi', 'frames_BC_pre': 5000, - 'BC_imager_post': 'R1-hi', 'frames_BC_post': 15000, - 'RESI-imager': 'R3-A2', 'RESI-frames': 50000, - 'RESI-rounds': [ - {'adapter': 'A2-c1', 'adapter_incubation': 5}], + "A2": { # second target -> "not last round" wash + "BC_imager_pre": "R1-hi", + "frames_BC_pre": 5000, + "BC_imager_post": "R1-hi", + "frames_BC_post": 15000, + "RESI-imager": "R3-A2", + "RESI-frames": 50000, + "RESI-rounds": [ + {"adapter": "A2-c1", "adapter_incubation": 5} + ], }, }, } config = _base_config(experiment, reservoir_names) pb = self._build(config) pb.create_steps_sph_resi(config) - self.assertGreater(len(pb.steps['fluid']), 0) - self.assertGreater(len(pb.steps['img']), 0) + self.assertGreater(len(pb.steps["fluid"]), 0) + self.assertGreater(len(pb.steps["img"]), 0) def test_dispatch_via_create_steps_registry(self): # Exercises EXPERIMENT_TYPES dispatch for the 'sph-resi' key. reservoir_names = { - 0: 'R1-lo', 1: 'R1-hi', 2: 'R3-A1', - 7: 'Blocker', 8: 'A1-c1', 18: 'Wash Buffer 1'} + 0: "R1-lo", + 1: "R1-hi", + 2: "R3-A1", + 7: "Blocker", + 8: "A1-c1", + 18: "Wash Buffer 1", + } experiment = { - 'type': 'SPH-RESI', - 'wash_buffer_1': 'Wash Buffer 1', - 'wash_buffer_2': None, - 'blocker': 'Blocker', - 'blocker_incubation': 5, - 'initial_imager_present': True, - 'round0': False, - 'target-rounds': { - 'A1': { - 'BC_imager_pre': 'R1-lo', 'frames_BC_pre': 5000, - 'BC_imager_post': 'R1-hi', 'frames_BC_post': 15000, - 'RESI-imager': 'R3-A1', 'RESI-frames': 50000, - 'RESI-rounds': [ - {'adapter': 'A1-c1', 'adapter_incubation': 0.5}], + "type": "SPH-RESI", + "wash_buffer_1": "Wash Buffer 1", + "wash_buffer_2": None, + "blocker": "Blocker", + "blocker_incubation": 5, + "initial_imager_present": True, + "round0": False, + "target-rounds": { + "A1": { + "BC_imager_pre": "R1-lo", + "frames_BC_pre": 5000, + "BC_imager_post": "R1-hi", + "frames_BC_post": 15000, + "RESI-imager": "R3-A1", + "RESI-frames": 50000, + "RESI-rounds": [ + {"adapter": "A1-c1", "adapter_incubation": 0.5} + ], }, }, } config = _base_config(experiment, reservoir_names) pb = pprot.ProtocolBuilder() steps, reservoir_vols = pb.create_steps(config) - self.assertIn('fluid', steps) - self.assertGreater(len(steps['fluid']), 0) + self.assertIn("fluid", steps) + self.assertGreater(len(steps["fluid"]), 0) def test_unknown_experiment_type_raises(self): config = _base_config( - {'type': 'Nonexistent', 'wash_buffer_1': 'Wash Buffer 1'}, - {18: 'Wash Buffer 1'}) + {"type": "Nonexistent", "wash_buffer_1": "Wash Buffer 1"}, + {18: "Wash Buffer 1"}, + ) pb = pprot.ProtocolBuilder() with self.assertRaises(KeyError): pb.create_steps(config) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_regression_protocols.py b/PycroFlow/tests/test_regression_protocols.py index 9015ccb..8d6a824 100644 --- a/PycroFlow/tests/test_regression_protocols.py +++ b/PycroFlow/tests/test_regression_protocols.py @@ -16,6 +16,7 @@ (typed protocol entries) — both refactors must leave the produced steps byte-identical for existing experiments. """ + import importlib import json import os @@ -26,18 +27,17 @@ import PycroFlow.protocols as pprot from PycroFlow.tests.fixtures import configs as configs_pkg - -_FIXTURES_ROOT = Path(__file__).parent / 'fixtures' -_SNAPSHOTS_DIR = _FIXTURES_ROOT / 'snapshots' -_UPDATE = os.environ.get('PYCROFLOW_UPDATE_SNAPSHOTS') == '1' +_FIXTURES_ROOT = Path(__file__).parent / "fixtures" +_SNAPSHOTS_DIR = _FIXTURES_ROOT / "snapshots" +_UPDATE = os.environ.get("PYCROFLOW_UPDATE_SNAPSHOTS") == "1" def _discover_fixtures(): """Yield (fixture_name, config_dict) for each fixture module.""" for module_info in pkgutil.iter_modules(configs_pkg.__path__): name = module_info.name - mod = importlib.import_module(f'{configs_pkg.__name__}.{name}') - config = getattr(mod, 'CONFIG', None) + mod = importlib.import_module(f"{configs_pkg.__name__}.{name}") + config = getattr(mod, "CONFIG", None) if config is None: continue yield name, config @@ -56,7 +56,7 @@ class TestRegressionProtocols(unittest.TestCase): def test_create_steps_snapshots(self): fixtures = list(_discover_fixtures()) if not fixtures: - self.skipTest('no fixtures under tests/fixtures/configs/') + self.skipTest("no fixtures under tests/fixtures/configs/") _SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True) failures = [] @@ -65,22 +65,26 @@ def test_create_steps_snapshots(self): with self.subTest(fixture=name): builder = pprot.ProtocolBuilder() steps, reservoir_vols = builder.create_steps(config) - actual = _normalize({ - 'steps': steps, - 'reservoir_vols': reservoir_vols, - }) + actual = _normalize( + { + "steps": steps, + "reservoir_vols": reservoir_vols, + } + ) - snapshot_path = _SNAPSHOTS_DIR / f'{name}.json' + snapshot_path = _SNAPSHOTS_DIR / f"{name}.json" if _UPDATE or not snapshot_path.exists(): snapshot_path.write_text( - json.dumps(actual, indent=2, sort_keys=True, default=str) + json.dumps( + actual, indent=2, sort_keys=True, default=str + ) ) if not _UPDATE: self.skipTest( - f'wrote initial snapshot for {name!r}; ' - f'commit {snapshot_path.relative_to(_FIXTURES_ROOT.parent)} ' - f'and re-run.' + f"wrote initial snapshot for {name!r}; " + f"commit {snapshot_path.relative_to(_FIXTURES_ROOT.parent)} " + f"and re-run." ) continue @@ -89,14 +93,14 @@ def test_create_steps_snapshots(self): failures.append((name, snapshot_path, expected, actual)) if failures: - msg_lines = ['snapshot mismatch:'] + msg_lines = ["snapshot mismatch:"] for name, path, expected, actual in failures: - msg_lines.append(f' fixture {name!r} differs from {path}') + msg_lines.append(f" fixture {name!r} differs from {path}") msg_lines.append( - ' regenerate with PYCROFLOW_UPDATE_SNAPSHOTS=1 if change is intentional.' + " regenerate with PYCROFLOW_UPDATE_SNAPSHOTS=1 if change is intentional." ) - self.fail('\n'.join(msg_lines)) + self.fail("\n".join(msg_lines)) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_services_coverage.py b/PycroFlow/tests/test_services_coverage.py index 3617af8..fce456f 100644 --- a/PycroFlow/tests/test_services_coverage.py +++ b/PycroFlow/tests/test_services_coverage.py @@ -1,5 +1,6 @@ """Additional coverage for the services layer (lifecycle, manual control, MM Core ownership) beyond the happy paths in test_stage3_services.""" + import os import tempfile import types @@ -7,22 +8,24 @@ from unittest.mock import MagicMock, patch from PycroFlow.services import ( - ExperimentService, ExperimentState, SystemService, mm_core, + ExperimentService, + ExperimentState, + SystemService, + mm_core, ) from PycroFlow.services import get_core, get_studio, reset_core - # --------------------------------------------------------------------------- # ExperimentService lifecycle # --------------------------------------------------------------------------- -_MINIMAL = {'fluid': {'protocol_entries': []}} +_MINIMAL = {"fluid": {"protocol_entries": []}} def _loaded_service_with_mock_orchestrator(): svc = ExperimentService() - svc.load_protocol(_MINIMAL) # builds a real (unstarted) orchestrator - svc._orchestrator = MagicMock(name='orchestrator') # swap in a stub + svc.load_protocol(_MINIMAL) # builds a real (unstarted) orchestrator + svc._orchestrator = MagicMock(name="orchestrator") # swap in a stub return svc @@ -36,8 +39,8 @@ def test_start_from_loaded_runs(self): def test_start_forwards_system_steps(self): svc = _loaded_service_with_mock_orchestrator() - svc.start(system_steps={'fluid': 3}) - svc._orchestrator.start_protocol.assert_called_once_with({'fluid': 3}) + svc.start(system_steps={"fluid": 3}) + svc._orchestrator.start_protocol.assert_called_once_with({"fluid": 3}) def test_pause_resume_transitions(self): svc = _loaded_service_with_mock_orchestrator() @@ -73,7 +76,7 @@ def test_start_after_finished_rebuilds_and_runs(self): svc.start() svc.end() self.assertEqual(svc.state, ExperimentState.FINISHED) - with patch.object(svc, '_build_orchestrator') as build: + with patch.object(svc, "_build_orchestrator") as build: svc.start() build.assert_called_once() self.assertEqual(svc.state, ExperimentState.RUNNING) @@ -96,7 +99,7 @@ def test_clear_protocol_refused_while_active(self): def test_clear_design_forgets_design_only(self): svc = _loaded_service_with_mock_orchestrator() - svc._experiment_design = {'base_name': 'x'} + svc._experiment_design = {"base_name": "x"} svc.clear_design() self.assertIsNone(svc.experiment_design) # The loaded run sequence is untouched. @@ -107,7 +110,7 @@ def test_start_after_aborted_rebuilds_and_runs(self): svc.start() svc.abort() self.assertEqual(svc.state, ExperimentState.ABORTED) - with patch.object(svc, '_build_orchestrator') as build: + with patch.object(svc, "_build_orchestrator") as build: svc.start() build.assert_called_once() self.assertEqual(svc.state, ExperimentState.RUNNING) @@ -130,21 +133,22 @@ def test_pause_without_protocol_raises(self): def test_load_from_yaml(self): svc = ExperimentService() - fd, path = tempfile.mkstemp(suffix='.yaml') + fd, path = tempfile.mkstemp(suffix=".yaml") try: - with os.fdopen(fd, 'w') as f: + with os.fdopen(fd, "w") as f: f.write("fluid:\n protocol_entries: []\n") svc.load_protocol_from_yaml(path) finally: os.unlink(path) self.assertEqual(svc.state, ExperimentState.LOADED) - self.assertEqual(svc.protocol, {'fluid': {'protocol_entries': []}}) + self.assertEqual(svc.protocol, {"fluid": {"protocol_entries": []}}) # --------------------------------------------------------------------------- # SystemService manual control # --------------------------------------------------------------------------- + class SystemServiceTest(unittest.TestCase): def test_fluid_commands_delegate(self): fluid = MagicMock() @@ -181,7 +185,7 @@ def test_close_imaging_noop_without_system(self): def test_stop_all_moves_swallows_errors(self): fluid = MagicMock() - fluid.stop_all_moves.side_effect = RuntimeError('boom') + fluid.stop_all_moves.side_effect = RuntimeError("boom") # Should log and not propagate. SystemService(fluid_system=fluid).stop_all_moves() @@ -190,14 +194,14 @@ def test_manual_pump_no_pump_method(self): fluid = types.SimpleNamespace(pump_a=object()) svc = SystemService(fluid_system=fluid) with self.assertRaises(RuntimeError): - svc.manual_pump('pump_a') + svc.manual_pump("pump_a") def test_manual_pump_unknown_pump(self): # has _pump but no such pump attribute. - fluid = types.SimpleNamespace(_pump=lambda *a, **k: 'ok') + fluid = types.SimpleNamespace(_pump=lambda *a, **k: "ok") svc = SystemService(fluid_system=fluid) with self.assertRaises(KeyError): - svc.manual_pump('pump_zzz') + svc.manual_pump("pump_zzz") def test_close_is_idempotent_and_calls_through(self): fluid = MagicMock() @@ -212,6 +216,7 @@ def test_close_is_idempotent_and_calls_through(self): # mm_core ownership # --------------------------------------------------------------------------- + class MmCoreTest(unittest.TestCase): def setUp(self): reset_core() @@ -220,8 +225,9 @@ def tearDown(self): reset_core() def test_get_studio_caches(self): - with patch('pycromanager.Studio', - return_value=MagicMock(name='Studio')) as studio_cls: + with patch( + "pycromanager.Studio", return_value=MagicMock(name="Studio") + ) as studio_cls: a = get_studio() b = get_studio() self.assertIs(a, b) @@ -229,7 +235,7 @@ def test_get_studio_caches(self): self.assertTrue(mm_core.is_initialized()) def test_reset_core_clears_cache(self): - with patch('pycromanager.Core', return_value=MagicMock()): + with patch("pycromanager.Core", return_value=MagicMock()): get_core() self.assertTrue(mm_core.is_initialized()) reset_core() @@ -239,10 +245,11 @@ def test_share_with_monet_sets_pycrocore(self): # monet is mocked at import; share_with_monet should assign our Core # onto monet.beampath.pycrocore. import monet.beampath as mbp - with patch('pycromanager.Core', return_value=MagicMock(name='Core')): + + with patch("pycromanager.Core", return_value=MagicMock(name="Core")): mm_core.share_with_monet() self.assertIs(mbp.pycrocore, get_core()) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_spill_sensor.py b/PycroFlow/tests/test_spill_sensor.py index 0aaa0b9..cd155b0 100644 --- a/PycroFlow/tests/test_spill_sensor.py +++ b/PycroFlow/tests/test_spill_sensor.py @@ -4,6 +4,7 @@ background monitoring, broadcast control, port auto-detection, and disconnect — none of which need a real Arduino. """ + import threading import unittest from unittest.mock import MagicMock, patch @@ -16,15 +17,17 @@ class ConnectTest(unittest.TestCase): def test_connect_handshake_success(self): with connect_interface() as iface: self.assertTrue(iface.is_connected) - self.assertIn('H', iface.serial_conn.written) + self.assertIn("H", iface.serial_conn.written) def test_connect_handshake_failure_returns_false(self): # A serial whose handshake reply is wrong -> connect() returns False. bad = FakeArduinoSerial() - bad._reply_for = lambda ch: 'NOPE\n' - with patch.object(ssa.serial, 'Serial', return_value=bad), \ - patch.object(ssa.time, 'sleep', lambda *a, **k: None): - iface = ssa.ArduinoSensorInterface(port='COM-EMU') + bad._reply_for = lambda ch: "NOPE\n" + with ( + patch.object(ssa.serial, "Serial", return_value=bad), + patch.object(ssa.time, "sleep", lambda *a, **k: None), + ): + iface = ssa.ArduinoSensorInterface(port="COM-EMU") self.assertFalse(iface.connect()) self.assertFalse(iface.is_connected) @@ -37,17 +40,17 @@ def test_poll_dry_then_wet(self): self.assertTrue(iface.poll_sensor()) def test_poll_when_not_connected_returns_none(self): - iface = ssa.ArduinoSensorInterface(port='COM-EMU') + iface = ssa.ArduinoSensorInterface(port="COM-EMU") self.assertIsNone(iface.poll_sensor()) def test_poll_unexpected_response_returns_none(self): with connect_interface() as iface: - iface.serial_conn._reply_for = lambda ch: 'GIBBERISH\n' + iface.serial_conn._reply_for = lambda ch: "GIBBERISH\n" self.assertIsNone(iface.poll_sensor()) def test_poll_serial_error_returns_none(self): with connect_interface() as iface: - iface.serial_conn.write = MagicMock(side_effect=OSError('boom')) + iface.serial_conn.write = MagicMock(side_effect=OSError("boom")) self.assertIsNone(iface.poll_sensor()) @@ -60,7 +63,7 @@ def test_monitor_fires_callback_on_wet(self): iface.stop_monitoring() def test_stop_monitoring_safe_when_never_started(self): - iface = ssa.ArduinoSensorInterface(port='COM-EMU') + iface = ssa.ArduinoSensorInterface(port="COM-EMU") # No monitor thread exists yet; stop must not raise. iface.stop_monitoring() @@ -72,30 +75,35 @@ def test_start_and_stop_broadcast(self): self.assertTrue(iface.stop_broadcast()) def test_broadcast_when_not_connected_returns_none(self): - iface = ssa.ArduinoSensorInterface(port='COM-EMU') + iface = ssa.ArduinoSensorInterface(port="COM-EMU") self.assertIsNone(iface.start_broadcast()) self.assertIsNone(iface.stop_broadcast()) class PortDiscoveryTest(unittest.TestCase): def test_find_arduino_port_matches_description(self): - fake_port = MagicMock(device='COM7', description='Arduino Uno') - other = MagicMock(device='COM1', description='Bluetooth') - with patch.object(ssa.serial.tools.list_ports, 'comports', - return_value=[other, fake_port]): + fake_port = MagicMock(device="COM7", description="Arduino Uno") + other = MagicMock(device="COM1", description="Bluetooth") + with patch.object( + ssa.serial.tools.list_ports, + "comports", + return_value=[other, fake_port], + ): iface = ssa.ArduinoSensorInterface() - self.assertEqual(iface.find_arduino_port(), 'COM7') + self.assertEqual(iface.find_arduino_port(), "COM7") def test_find_arduino_port_none_when_absent(self): - other = MagicMock(device='COM1', description='Bluetooth') - with patch.object(ssa.serial.tools.list_ports, 'comports', - return_value=[other]): + other = MagicMock(device="COM1", description="Bluetooth") + with patch.object( + ssa.serial.tools.list_ports, "comports", return_value=[other] + ): iface = ssa.ArduinoSensorInterface() self.assertIsNone(iface.find_arduino_port()) def test_connect_without_port_uses_discovery_failure(self): - with patch.object(ssa.serial.tools.list_ports, 'comports', - return_value=[]): + with patch.object( + ssa.serial.tools.list_ports, "comports", return_value=[] + ): iface = ssa.ArduinoSensorInterface() # no port given self.assertFalse(iface.connect()) @@ -112,5 +120,5 @@ def test_disconnect_closes_and_clears(self): iface.disconnect() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_stage1_reliability.py b/PycroFlow/tests/test_stage1_reliability.py index 98f919b..b6ca545 100644 --- a/PycroFlow/tests/test_stage1_reliability.py +++ b/PycroFlow/tests/test_stage1_reliability.py @@ -6,6 +6,7 @@ * The MM Core lockfile (``mm_lock.MmCoreLock``) refuses double-acquire. * The PFS health predicate identifies bad states by set comparison. """ + import os import tempfile import threading @@ -26,7 +27,8 @@ class _StubHandler(AbstractSystemHandler): Skips the threading.Thread.start() machinery entirely — we only need ``wait_xchange`` to be callable with a populated ``txchange``. """ - target = 'fluid' + + target = "fluid" def execute_protocol_entry(self, i): pass @@ -38,20 +40,20 @@ def work_queue(self): def _make_txchange(): """Build a minimal threadexchange dict for the stub handler.""" return { - 'fluid_lock': threading.Lock(), - 'fluid': [], - 'fluid_finished': threading.Event(), - 'img_lock': threading.Lock(), - 'img': [], - 'img_finished': threading.Event(), - 'illu_lock': threading.Lock(), - 'illu': [], - 'illu_finished': threading.Event(), - 'abort_flag': threading.Event(), - 'abort_protocol_flag': threading.Event(), - 'pause_protocol_flag': threading.Event(), - 'start_protocol_flag': threading.Event(), - 'graceful_stop_flag': threading.Event(), + "fluid_lock": threading.Lock(), + "fluid": [], + "fluid_finished": threading.Event(), + "img_lock": threading.Lock(), + "img": [], + "img_finished": threading.Event(), + "illu_lock": threading.Lock(), + "illu": [], + "illu_finished": threading.Event(), + "abort_flag": threading.Event(), + "abort_protocol_flag": threading.Event(), + "pause_protocol_flag": threading.Event(), + "start_protocol_flag": threading.Event(), + "graceful_stop_flag": threading.Event(), } @@ -61,40 +63,43 @@ def test_raises_on_timeout(self): """Typo'd signal value used to hang forever — now raises promptly.""" txch = _make_txchange() handler = _StubHandler( - protocol={'protocol_entries': []}, threadexchange=txch) + protocol={"protocol_entries": []}, threadexchange=txch + ) t0 = time.monotonic() with self.assertRaises(WaitForSignalTimeout) as ctx: - handler.wait_xchange('img', 'never-arrives', timeout=0.2) + handler.wait_xchange("img", "never-arrives", timeout=0.2) elapsed = time.monotonic() - t0 self.assertLess(elapsed, 1.0, "timeout should fire within 1s") - self.assertIn('never-arrives', str(ctx.exception)) + self.assertIn("never-arrives", str(ctx.exception)) def test_signal_arrival_returns_silently(self): """When the signal arrives, the call returns without raising.""" txch = _make_txchange() handler = _StubHandler( - protocol={'protocol_entries': []}, threadexchange=txch) + protocol={"protocol_entries": []}, threadexchange=txch + ) def deliver(): time.sleep(0.05) - with txch['img_lock']: - txch['img'].append('round 1 done') + with txch["img_lock"]: + txch["img"].append("round 1 done") threading.Thread(target=deliver, daemon=True).start() - handler.wait_xchange('img', 'round 1 done', timeout=2.0) + handler.wait_xchange("img", "round 1 done", timeout=2.0) def test_abort_flag_returns_silently(self): """Abort flag short-circuits the wait — no exception, no hang.""" txch = _make_txchange() handler = _StubHandler( - protocol={'protocol_entries': []}, threadexchange=txch) + protocol={"protocol_entries": []}, threadexchange=txch + ) def abort(): time.sleep(0.05) - txch['abort_flag'].set() + txch["abort_flag"].set() threading.Thread(target=abort, daemon=True).start() - handler.wait_xchange('img', 'never-arrives', timeout=10.0) + handler.wait_xchange("img", "never-arrives", timeout=10.0) def test_default_timeout_is_sane(self): """Default (4 hours) covers our longest acquisition.""" @@ -105,8 +110,8 @@ def test_default_timeout_is_sane(self): class TestMmCoreLock(unittest.TestCase): def setUp(self): - self.tmpdir = tempfile.mkdtemp(prefix='mmlock-') - self.path = os.path.join(self.tmpdir, 'mm.lock') + self.tmpdir = tempfile.mkdtemp(prefix="mmlock-") + self.path = os.path.join(self.tmpdir, "mm.lock") def tearDown(self): try: @@ -152,12 +157,13 @@ def test_stale_lock_is_reclaimed(self): # must reclaim it instead of forcing a manual delete. import subprocess import sys - proc = subprocess.Popen([sys.executable, '-c', 'pass']) + + proc = subprocess.Popen([sys.executable, "-c", "pass"]) proc.wait() - with open(self.path, 'w') as f: - f.write(str(proc.pid)) # PID guaranteed no longer running + with open(self.path, "w") as f: + f.write(str(proc.pid)) # PID guaranteed no longer running lock = MmCoreLock(path=self.path) - lock.acquire() # reclaims, does not raise + lock.acquire() # reclaims, does not raise try: with open(self.path) as f: self.assertEqual(f.read().strip(), str(os.getpid())) @@ -166,8 +172,8 @@ def test_stale_lock_is_reclaimed(self): def test_corrupt_lock_is_reclaimed(self): # A garbage/empty lockfile (no readable PID) is treated as stale. - with open(self.path, 'w') as f: - f.write('not-a-pid') + with open(self.path, "w") as f: + f.write("not-a-pid") lock = MmCoreLock(path=self.path) lock.acquire() try: @@ -177,7 +183,7 @@ def test_corrupt_lock_is_reclaimed(self): def test_live_holder_is_refused(self): # A lockfile owned by a live process (here, ourselves) must refuse. - with open(self.path, 'w') as f: + with open(self.path, "w") as f: f.write(str(os.getpid())) lock = MmCoreLock(path=self.path) with self.assertRaises(MmLockHeld): @@ -188,27 +194,31 @@ class TestPfsHealthCheck(unittest.TestCase): def test_known_bad_status(self): from PycroFlow.imaging import _pfs_is_unhealthy - self.assertTrue(_pfs_is_unhealthy('Failed Focus')) - self.assertTrue(_pfs_is_unhealthy('Out of Range')) - self.assertTrue(_pfs_is_unhealthy('failed focus')) # case-insensitive + + self.assertTrue(_pfs_is_unhealthy("Failed Focus")) + self.assertTrue(_pfs_is_unhealthy("Out of Range")) + self.assertTrue(_pfs_is_unhealthy("failed focus")) # case-insensitive def test_known_good_status(self): from PycroFlow.imaging import _pfs_is_unhealthy - self.assertFalse(_pfs_is_unhealthy('Locked in Focus')) - self.assertFalse(_pfs_is_unhealthy('Within Range')) + + self.assertFalse(_pfs_is_unhealthy("Locked in Focus")) + self.assertFalse(_pfs_is_unhealthy("Within Range")) def test_empty_status(self): from PycroFlow.imaging import _pfs_is_unhealthy - self.assertFalse(_pfs_is_unhealthy('')) + + self.assertFalse(_pfs_is_unhealthy("")) self.assertFalse(_pfs_is_unhealthy(None)) def test_unknown_status_falls_back_to_substring(self): from PycroFlow.imaging import _pfs_is_unhealthy + # Unknown status with 'fail' substring should be reported unhealthy - self.assertTrue(_pfs_is_unhealthy('Custom failure mode 42')) + self.assertTrue(_pfs_is_unhealthy("Custom failure mode 42")) # Unknown status without 'fail' should be reported healthy - self.assertFalse(_pfs_is_unhealthy('Some Unknown Status')) + self.assertFalse(_pfs_is_unhealthy("Some Unknown Status")) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_stage2_schema.py b/PycroFlow/tests/test_stage2_schema.py index a3f10da..d88eb33 100644 --- a/PycroFlow/tests/test_stage2_schema.py +++ b/PycroFlow/tests/test_stage2_schema.py @@ -1,10 +1,9 @@ """Tests for the pydantic protocol schema added in Stage 2.""" + import unittest from PycroFlow.schemas import ( - Protocol, SchemaValidationError, - SubsystemProtocol, validate_protocol, ) @@ -13,86 +12,138 @@ class TestProtocolSchema(unittest.TestCase): def test_demo_protocol_validates(self): from PycroFlow.examples.demo_protocols import protocol + validate_protocol(protocol) def test_unknown_type_raises(self): with self.assertRaises(SchemaValidationError) as ctx: validate_protocol( - {'fluid': {'protocol_entries': [{'$type': 'made-up'}]}}) - self.assertIn('made-up', str(ctx.exception)) + {"fluid": {"protocol_entries": [{"$type": "made-up"}]}} + ) + self.assertIn("made-up", str(ctx.exception)) def test_missing_required_field_raises(self): # 'inject' requires reservoir_id and volume with self.assertRaises(SchemaValidationError) as ctx: validate_protocol( - {'fluid': {'protocol_entries': [{'$type': 'inject'}]}}) + {"fluid": {"protocol_entries": [{"$type": "inject"}]}} + ) msg = str(ctx.exception) - self.assertIn('reservoir_id', msg) - self.assertIn('volume', msg) + self.assertIn("reservoir_id", msg) + self.assertIn("volume", msg) def test_extra_fields_allowed(self): # Existing protocols carry extra fields (wait_time, round, ...). # extra='allow' must keep working so we don't break them. - validate_protocol({ - 'fluid': {'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 1, 'volume': 500, - 'wait_time': 5, 'velocity': 200, 'delay': 0}, - ]}, - 'img': {'protocol_entries': [ - {'$type': 'acquire', 'frames': 100, 't_exp': 75, - 'round': 3, 'message': 'R4'}, - ]}, - }) + validate_protocol( + { + "fluid": { + "protocol_entries": [ + { + "$type": "inject", + "reservoir_id": 1, + "volume": 500, + "wait_time": 5, + "velocity": 200, + "delay": 0, + }, + ] + }, + "img": { + "protocol_entries": [ + { + "$type": "acquire", + "frames": 100, + "t_exp": 75, + "round": 3, + "message": "R4", + }, + ] + }, + } + ) def test_incubate_accepts_string_duration(self): # orchestration.run_protocol coerces via float(), so the wire format # has historically been lax. test_protocols.test_06 asserts a string. validate_protocol( - {'fluid': {'protocol_entries': [ - {'$type': 'incubate', 'duration': '120'}, - ]}}) + { + "fluid": { + "protocol_entries": [ + {"$type": "incubate", "duration": "120"}, + ] + } + } + ) def test_pump_out_validates(self): validate_protocol( - {'fluid': {'protocol_entries': [ - {'$type': 'pump_out', 'volume': 500}, - {'$type': 'pump_out', 'volume': 100, 'extractionfactor': 2.0}, - ]}}) + { + "fluid": { + "protocol_entries": [ + {"$type": "pump_out", "volume": 500}, + { + "$type": "pump_out", + "volume": 100, + "extractionfactor": 2.0, + }, + ] + } + } + ) def test_wait_for_signal_timeout_optional(self): # The Stage 1 wait_xchange timeout key is optional in the wire format. validate_protocol( - {'fluid': {'protocol_entries': [ - {'$type': 'wait for signal', 'target': 'img', - 'value': 'round 1 done', 'timeout': 600}, - {'$type': 'wait for signal', 'target': 'img', - 'value': 'round 2 done'}, - ]}}) + { + "fluid": { + "protocol_entries": [ + { + "$type": "wait for signal", + "target": "img", + "value": "round 1 done", + "timeout": 600, + }, + { + "$type": "wait for signal", + "target": "img", + "value": "round 2 done", + }, + ] + } + } + ) def test_subsystems_independent(self): # A protocol may include only some subsystems. validate_protocol( - {'img': {'protocol_entries': [ - {'$type': 'acquire', 'frames': 10, 't_exp': 100}, - ]}}) + { + "img": { + "protocol_entries": [ + {"$type": "acquire", "frames": 10, "t_exp": 100}, + ] + } + } + ) def test_create_protocol_invokes_validation(self): # End-to-end: ProtocolBuilder.create_protocol must call the validator # on the produced dict. - import os import tempfile from PycroFlow.tests.fixtures.configs.exchange_basic import CONFIG from PycroFlow.protocols import ProtocolBuilder + cfg = dict(CONFIG) - cfg['save_dir'] = tempfile.mkdtemp(prefix='schema-test-') + cfg["save_dir"] = tempfile.mkdtemp(prefix="schema-test-") try: pb = ProtocolBuilder() fname, _ = pb.create_protocol(cfg) - self.assertTrue(fname.endswith('.yaml')) + self.assertTrue(fname.endswith(".yaml")) finally: import shutil - shutil.rmtree(cfg['save_dir'], ignore_errors=True) + + shutil.rmtree(cfg["save_dir"], ignore_errors=True) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_stage3_services.py b/PycroFlow/tests/test_stage3_services.py index aa883bc..c7c25e0 100644 --- a/PycroFlow/tests/test_stage3_services.py +++ b/PycroFlow/tests/test_stage3_services.py @@ -1,4 +1,5 @@ """Tests for the Stage-3 HAL + services layer.""" + import unittest from unittest.mock import MagicMock, patch @@ -17,14 +18,17 @@ class TestHALAbcsRegisterConcrete(unittest.TestCase): def test_hamilton_pump_is_hal_pump(self): from PycroFlow.hamilton_components import Pump as HamiltonPump + self.assertTrue(issubclass(HamiltonPump, Pump)) def test_hamilton_valve_is_hal_valve(self): from PycroFlow.hamilton_components import Valve as HamiltonValve + self.assertTrue(issubclass(HamiltonValve, Valve)) def test_arduino_sensor_is_hal_spill_sensor(self): from PycroFlow.spill_sensor_arduino import ArduinoSensorInterface + self.assertTrue(issubclass(ArduinoSensorInterface, SpillSensor)) @@ -43,8 +47,9 @@ def test_lazy_init(self): def test_get_core_caches(self): # When real pycromanager is installed it tries an actual connection; # patch the Core constructor so the test is portable. - with patch('pycromanager.Core', - return_value=MagicMock(name='Core')) as core_cls: + with patch( + "pycromanager.Core", return_value=MagicMock(name="Core") + ) as core_cls: a = get_core() b = get_core() self.assertIs(a, b) @@ -62,6 +67,7 @@ def test_initial_state_is_idle(self): def test_load_protocol_transitions_to_loaded(self): from PycroFlow.examples.demo_protocols import protocol + svc = ExperimentService() svc.load_protocol(protocol) self.assertEqual(svc.state, ExperimentState.LOADED) @@ -70,34 +76,42 @@ def test_load_protocol_transitions_to_loaded(self): def test_state_observer_fires(self): from PycroFlow.examples.demo_protocols import protocol + svc = ExperimentService() transitions = [] svc.add_state_observer(lambda o, n: transitions.append((o, n))) svc.load_protocol(protocol) - self.assertEqual(transitions, [ - (ExperimentState.IDLE, ExperimentState.LOADED), - ]) + self.assertEqual( + transitions, + [ + (ExperimentState.IDLE, ExperimentState.LOADED), + ], + ) def test_log_observer_fires(self): from PycroFlow.examples.demo_protocols import protocol + svc = ExperimentService() lines = [] svc.add_log_observer(lambda msg: lines.append(msg)) svc.load_protocol(protocol) self.assertEqual(len(lines), 1) - self.assertIn('loaded', lines[0]) + self.assertIn("loaded", lines[0]) def test_observer_exception_does_not_break_service(self): from PycroFlow.examples.demo_protocols import protocol + svc = ExperimentService() svc.add_state_observer( - lambda o, n: (_ for _ in ()).throw(RuntimeError('boom'))) + lambda o, n: (_ for _ in ()).throw(RuntimeError("boom")) + ) # Must not raise. svc.load_protocol(protocol) self.assertEqual(svc.state, ExperimentState.LOADED) def test_load_protocol_while_running_rejects(self): from PycroFlow.examples.demo_protocols import protocol + svc = ExperimentService() svc.load_protocol(protocol) # Hand-walk into a forbidden state to verify the guard. @@ -113,15 +127,17 @@ def test_attach_systems_sets_refs(self): svc = ExperimentService() f, i, lum = object(), object(), object() svc.attach_systems( - fluid_system=f, imaging_system=i, illumination_system=lum) + fluid_system=f, imaging_system=i, illumination_system=lum + ) self.assertIs(svc._fluid_system, f) self.assertIs(svc._imaging_system, i) self.assertIs(svc._illumination_system, lum) def test_attach_systems_feeds_orchestrator(self): from PycroFlow.examples.demo_protocols import protocol + svc = ExperimentService() - fluid = MagicMock(name='fluid_system') + fluid = MagicMock(name="fluid_system") svc.attach_systems(fluid_system=fluid) svc.load_protocol(protocol) self.assertIs(svc.orchestrator.fluid_system, fluid) @@ -137,15 +153,22 @@ def test_start_rebuilds_with_systems_attached_after_load(self): # feed the orchestrator — start() rebuilds it. Otherwise handlers # see system=None and the protocol finishes immediately. from PycroFlow.examples.demo_protocols import protocol + svc = ExperimentService() svc.load_protocol(protocol) self.assertIsNone(svc.orchestrator.fluid_system) - fluid = MagicMock(name='fluid') + fluid = MagicMock(name="fluid") svc.attach_systems(fluid_system=fluid) - with patch('PycroFlow.orchestration.core.ProtocolOrchestrator.' - 'start_orchestration'), \ - patch('PycroFlow.orchestration.core.ProtocolOrchestrator.' - 'start_protocol'): + with ( + patch( + "PycroFlow.orchestration.core.ProtocolOrchestrator." + "start_orchestration" + ), + patch( + "PycroFlow.orchestration.core.ProtocolOrchestrator." + "start_protocol" + ), + ): svc.start() self.assertIs(svc.orchestrator.fluid_system, fluid) self.assertEqual(svc.state, ExperimentState.RUNNING) @@ -171,7 +194,7 @@ def test_manual_pump_delegates(self): fluid.pump_a = MagicMock() fluid._pump.return_value = 42 svc = SystemService(fluid_system=fluid) - result = svc.manual_pump('pump_a', vol=100) + result = svc.manual_pump("pump_a", vol=100) self.assertEqual(result, 42) fluid._pump.assert_called_once_with(fluid.pump_a, vol=100) @@ -179,22 +202,24 @@ def test_connection_states(self): svc = SystemService() self.assertEqual( svc.connection_states(), - {'fluid': False, 'imaging': False, 'illumination': False}) + {"fluid": False, "imaging": False, "illumination": False}, + ) svc.imaging_system = object() - self.assertTrue(svc.connection_states()['imaging']) + self.assertTrue(svc.connection_states()["imaging"]) def test_connect_imaging_builds_and_stores(self): sentinel = object() - with patch('PycroFlow.imaging.ImagingSystem', return_value=sentinel): + with patch("PycroFlow.imaging.ImagingSystem", return_value=sentinel): svc = SystemService() - result = svc.connect_imaging({'pfs_pars': {}}) + result = svc.connect_imaging({"pfs_pars": {}}) self.assertIs(result, sentinel) self.assertIs(svc.imaging_system, sentinel) def test_connect_illumination_builds_and_stores(self): sentinel = object() - with patch('PycroFlow.illumination.IlluminationSystem', - return_value=sentinel): + with patch( + "PycroFlow.illumination.IlluminationSystem", return_value=sentinel + ): svc = SystemService() result = svc.connect_illumination() self.assertIs(result, sentinel) @@ -203,17 +228,18 @@ def test_connect_illumination_builds_and_stores(self): def test_laser_options_from_monet_config(self): import sys import types + svc = SystemService() - svc.load_setup('Mercury') - fake = types.ModuleType('monet') - fake.CONFIGS = {'Mercury': {'lasers': {640: {}, 488: {}, 561: {}}}} - with patch.dict(sys.modules, {'monet': fake}): + svc.load_setup("Mercury") + fake = types.ModuleType("monet") + fake.CONFIGS = {"Mercury": {"lasers": {640: {}, 488: {}, 561: {}}}} + with patch.dict(sys.modules, {"monet": fake}): self.assertEqual(svc.laser_options(), [488, 561, 640]) def test_laser_options_empty_without_real_config(self): # Emulator has no monet config (and monet may be mocked) -> empty. svc = SystemService() - svc.load_setup('Emulator') + svc.load_setup("Emulator") self.assertEqual(svc.laser_options(), []) # No setup at all -> empty too. self.assertEqual(SystemService().laser_options(), []) @@ -221,39 +247,42 @@ def test_laser_options_empty_without_real_config(self): def test_connect_illumination_passes_monet_setup(self): # The monet config name is taken from the chosen microscope setup. svc = SystemService() - svc.load_setup('Mercury') # non-emulated -> real illumination path - with patch('PycroFlow.illumination.IlluminationSystem') as IS: + svc.load_setup("Mercury") # non-emulated -> real illumination path + with patch("PycroFlow.illumination.IlluminationSystem") as IS: svc.connect_illumination() - IS.assert_called_once_with(setup='Mercury') + IS.assert_called_once_with(setup="Mercury") def test_connect_fluid_requires_setup(self): svc = SystemService() with self.assertRaises(RuntimeError): - svc.connect_fluid({'settings': {'reservoir_names': {}}}) + svc.connect_fluid({"settings": {"reservoir_names": {}}}) def test_connect_fluid_emulated_builds_legacy(self): # The Emulator setup connects the real LegacyArchitecture over the # fake serial wire emulator; design parameters are seeded so manual # ops work immediately. from PycroFlow.fluid.legacy import LegacyArchitecture + svc = SystemService() - svc.load_setup('Emulator') + svc.load_setup("Emulator") fluid = { - 'parameters': {'max_velocity': 200, 'clean_velocity': 200}, - 'settings': {'reservoir_names': {1: 'R1', 7: 'C+'}, - 'special_names': {'flushbuffer_a': 7}}, + "parameters": {"max_velocity": 200, "clean_velocity": 200}, + "settings": { + "reservoir_names": {1: "R1", 7: "C+"}, + "special_names": {"flushbuffer_a": 7}, + }, } fs = svc.connect_fluid(fluid) self.assertIsInstance(fs, LegacyArchitecture) self.assertIs(svc.fluid_system, fs) - self.assertEqual(fs.parameters['max_velocity'], 200) + self.assertEqual(fs.parameters["max_velocity"], 200) def test_load_setup_and_monet_name(self): svc = SystemService() - svc.load_setup('Emulator') + svc.load_setup("Emulator") self.assertTrue(svc.is_emulated()) - self.assertEqual(svc.get_monet_setup(), 'Emulator') + self.assertEqual(svc.get_monet_setup(), "Emulator") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_stage4_signal_and_typed_dispatch.py b/PycroFlow/tests/test_stage4_signal_and_typed_dispatch.py index fc3aa18..368c9a9 100644 --- a/PycroFlow/tests/test_stage4_signal_and_typed_dispatch.py +++ b/PycroFlow/tests/test_stage4_signal_and_typed_dispatch.py @@ -1,4 +1,5 @@ """Tests for the Stage 4 typed-entry / signal-registry / ThreadExchange work.""" + import threading import time import unittest @@ -7,7 +8,6 @@ ProtocolOrchestrator, SignalRegistry, ThreadExchange, - WaitForSignalTimeout, ) from PycroFlow.orchestration.core import dispatch_entry from PycroFlow.protocol_entries import ( @@ -18,15 +18,15 @@ parse_entry, ) - # ---------- SignalRegistry ----------------------------------------------- + class TestSignalRegistry(unittest.TestCase): def test_wait_returns_true_after_fire(self): reg = SignalRegistry() - reg.fire('img', 'round 1 done') - self.assertTrue(reg.wait('img', 'round 1 done', timeout=0.5)) + reg.fire("img", "round 1 done") + self.assertTrue(reg.wait("img", "round 1 done", timeout=0.5)) def test_wait_blocks_until_fire(self): reg = SignalRegistry() @@ -34,10 +34,10 @@ def test_wait_blocks_until_fire(self): def producer(): time.sleep(0.05) - reg.fire('img', 'round 1 done') + reg.fire("img", "round 1 done") threading.Thread(target=producer, daemon=True).start() - self.assertTrue(reg.wait('img', 'round 1 done', timeout=2.0)) + self.assertTrue(reg.wait("img", "round 1 done", timeout=2.0)) elapsed = time.monotonic() - t0 # No busy-poll — should fire shortly after the 50 ms sleep, not # the previous 50 ms granularity. @@ -45,35 +45,46 @@ def producer(): def test_wait_times_out(self): reg = SignalRegistry() - self.assertFalse(reg.wait('img', 'never', timeout=0.05)) + self.assertFalse(reg.wait("img", "never", timeout=0.05)) def test_fire_before_register_is_observed(self): reg = SignalRegistry() - reg.fire('img', 'early') + reg.fire("img", "early") # New consumer that didn't pre-register still sees the signal # because _get_or_create reuses the existing Event. - self.assertTrue(reg.is_set('img', 'early')) + self.assertTrue(reg.is_set("img", "early")) def test_reset_drops_signals(self): reg = SignalRegistry() - reg.fire('img', 'one') + reg.fire("img", "one") reg.reset() - self.assertFalse(reg.is_set('img', 'one')) + self.assertFalse(reg.is_set("img", "one")) # ---------- ThreadExchange ---------------------------------------------- + class TestThreadExchange(unittest.TestCase): def test_create_has_all_expected_keys(self): tx = ThreadExchange.create() expected = { - 'fluid_lock', 'fluid', 'fluid_finished', 'fluid_queue', - 'img_lock', 'img', 'img_finished', - 'illu_lock', 'illu', 'illu_finished', - 'start_protocol_flag', 'pause_protocol_flag', - 'abort_protocol_flag', 'abort_flag', 'graceful_stop_flag', - 'signal_registry', + "fluid_lock", + "fluid", + "fluid_finished", + "fluid_queue", + "img_lock", + "img", + "img_finished", + "illu_lock", + "illu", + "illu_finished", + "start_protocol_flag", + "pause_protocol_flag", + "abort_protocol_flag", + "abort_flag", + "graceful_stop_flag", + "signal_registry", } self.assertEqual(set(tx.keys()), expected) @@ -82,80 +93,96 @@ def test_instances_do_not_alias(self): # attribute, so two orchestrators shared one set of locks/events. a = ThreadExchange.create() b = ThreadExchange.create() - self.assertIsNot(a['fluid_lock'], b['fluid_lock']) - self.assertIsNot(a['abort_flag'], b['abort_flag']) - self.assertIsNot(a['signal_registry'], b['signal_registry']) + self.assertIsNot(a["fluid_lock"], b["fluid_lock"]) + self.assertIsNot(a["abort_flag"], b["abort_flag"]) + self.assertIsNot(a["signal_registry"], b["signal_registry"]) def test_dict_access_still_works(self): # All existing self.txchange['fluid_lock'] sites must keep working. tx = ThreadExchange.create() - self.assertIsInstance(tx['fluid_lock'], type(threading.Lock())) - self.assertEqual(tx['fluid'], []) - tx['fluid'].append('hello') - self.assertEqual(tx['fluid'], ['hello']) + self.assertIsInstance(tx["fluid_lock"], type(threading.Lock())) + self.assertEqual(tx["fluid"], []) + tx["fluid"].append("hello") + self.assertEqual(tx["fluid"], ["hello"]) def test_typed_accessors(self): tx = ThreadExchange.create() - self.assertIs(tx.fluid_lock, tx['fluid_lock']) - self.assertIs(tx.signal_registry, tx['signal_registry']) - self.assertIs(tx.abort_flag, tx['abort_flag']) + self.assertIs(tx.fluid_lock, tx["fluid_lock"]) + self.assertIs(tx.signal_registry, tx["signal_registry"]) + self.assertIs(tx.abort_flag, tx["abort_flag"]) def test_orchestrator_uses_per_instance_threadexchange(self): from PycroFlow.examples.demo_protocols import protocol + a = ProtocolOrchestrator(protocol) b = ProtocolOrchestrator(protocol) - self.assertIsNot(a.threadexchange['fluid_lock'], - b.threadexchange['fluid_lock']) + self.assertIsNot( + a.threadexchange["fluid_lock"], b.threadexchange["fluid_lock"] + ) # ---------- parse_entry (typed coercion) -------------------------------- + class TestParseEntry(unittest.TestCase): def test_inject(self): - e = parse_entry({'$type': 'inject', 'reservoir_id': 1, 'volume': 500}) + e = parse_entry({"$type": "inject", "reservoir_id": 1, "volume": 500}) self.assertIsInstance(e, InjectEntry) self.assertEqual(e.reservoir_id, 1) self.assertEqual(e.volume, 500) def test_case_insensitive_dispatch(self): # Old code did step['$type'].lower(); typed-entry path must match. - e = parse_entry({'$type': 'Inject', 'reservoir_id': 1, 'volume': 100}) + e = parse_entry({"$type": "Inject", "reservoir_id": 1, "volume": 100}) self.assertIsInstance(e, InjectEntry) def test_wait_for_signal_optional_timeout(self): - e = parse_entry({ - '$type': 'wait for signal', - 'target': 'img', 'value': 'round 1 done', 'timeout': 600, - }) + e = parse_entry( + { + "$type": "wait for signal", + "target": "img", + "value": "round 1 done", + "timeout": 600, + } + ) self.assertEqual(e.timeout, 600) - e2 = parse_entry({ - '$type': 'wait for signal', 'target': 'img', 'value': 'round 1 done', - }) + e2 = parse_entry( + { + "$type": "wait for signal", + "target": "img", + "value": "round 1 done", + } + ) self.assertIsNone(e2.timeout) def test_unknown_type_raises(self): with self.assertRaises(KeyError): - parse_entry({'$type': 'totally-fake'}) + parse_entry({"$type": "totally-fake"}) # ---------- Stage-4 typed dispatch in run_protocol ---------------------- + class _FakeHandler: """Stub matching the AbstractSystemHandler surface dispatch_entry uses.""" + def __init__(self): self.tx_messages = [] self.tx_waits = [] self.exec_calls = [] self.protocol_iter = 0 self.txchange = { - 'abort_flag': threading.Event(), - 'abort_protocol_flag': threading.Event(), + "abort_flag": threading.Event(), + "abort_protocol_flag": threading.Event(), } + def send_message(self, msg): self.tx_messages.append(msg) + def wait_xchange(self, target, value, timeout=None): self.tx_waits.append((target, value, timeout)) + def execute_protocol_entry(self, i): self.exec_calls.append(i) @@ -164,21 +191,28 @@ class TestDispatchEntry(unittest.TestCase): def test_signal_entry_calls_send_message(self): h = _FakeHandler() - dispatch_entry(SignalEntry(**{'$type': 'signal', 'value': 'fluid round 1 done'}), h) - self.assertEqual(h.tx_messages, ['fluid round 1 done']) + dispatch_entry( + SignalEntry(**{"$type": "signal", "value": "fluid round 1 done"}), + h, + ) + self.assertEqual(h.tx_messages, ["fluid round 1 done"]) def test_wait_for_signal_calls_wait_xchange(self): h = _FakeHandler() - entry = WaitForSignalEntry(**{ - '$type': 'wait for signal', 'target': 'img', 'value': 'round 1 done', - 'timeout': 30.0, - }) + entry = WaitForSignalEntry( + **{ + "$type": "wait for signal", + "target": "img", + "value": "round 1 done", + "timeout": 30.0, + } + ) dispatch_entry(entry, h) - self.assertEqual(h.tx_waits, [('img', 'round 1 done', 30.0)]) + self.assertEqual(h.tx_waits, [("img", "round 1 done", 30.0)]) def test_incubate_busy_waits_then_returns(self): h = _FakeHandler() - entry = IncubateEntry(**{'$type': 'incubate', 'duration': 0.05}) + entry = IncubateEntry(**{"$type": "incubate", "duration": 0.05}) t0 = time.monotonic() dispatch_entry(entry, h) elapsed = time.monotonic() - t0 @@ -189,8 +223,8 @@ def test_incubate_busy_waits_then_returns(self): def test_incubate_aborts_early(self): h = _FakeHandler() - h.txchange['abort_flag'].set() - entry = IncubateEntry(**{'$type': 'incubate', 'duration': 100.0}) + h.txchange["abort_flag"].set() + entry = IncubateEntry(**{"$type": "incubate", "duration": 100.0}) t0 = time.monotonic() dispatch_entry(entry, h) elapsed = time.monotonic() - t0 @@ -202,13 +236,17 @@ def test_unknown_typed_entry_falls_through_to_execute(self): # InjectEntry (no specific handler registered). h = _FakeHandler() dispatch_entry( - InjectEntry(**{'$type': 'inject', 'reservoir_id': 1, 'volume': 500}), - h) + InjectEntry( + **{"$type": "inject", "reservoir_id": 1, "volume": 500} + ), + h, + ) self.assertEqual(h.exec_calls, [0]) # ---------- send_message integration: fires SignalRegistry -------------- + class TestSendMessageFiresRegistry(unittest.TestCase): def test_send_message_fires_matching_registry_signal(self): @@ -217,35 +255,43 @@ def test_send_message_fires_matching_registry_signal(self): from PycroFlow.orchestration.core import AbstractSystemHandler class _Stub(AbstractSystemHandler): - target = 'fluid' + target = "fluid" + def execute_protocol_entry(self, i): pass + def work_queue(self): pass tx = ThreadExchange.create() - handler = _Stub(protocol={'protocol_entries': []}, threadexchange=tx) - handler.send_message('round 1 done') - self.assertIn('round 1 done', tx['fluid']) - self.assertTrue(tx.signal_registry.is_set('fluid', 'round 1 done')) + handler = _Stub(protocol={"protocol_entries": []}, threadexchange=tx) + handler.send_message("round 1 done") + self.assertIn("round 1 done", tx["fluid"]) + self.assertTrue(tx.signal_registry.is_set("fluid", "round 1 done")) def test_prefixed_send_fires_stripped_value_too(self): from PycroFlow.orchestration.core import AbstractSystemHandler class _Stub(AbstractSystemHandler): - target = 'fluid' - def execute_protocol_entry(self, i): pass - def work_queue(self): pass + target = "fluid" + + def execute_protocol_entry(self, i): + pass + + def work_queue(self): + pass tx = ThreadExchange.create() - handler = _Stub(protocol={'protocol_entries': []}, threadexchange=tx) + handler = _Stub(protocol={"protocol_entries": []}, threadexchange=tx) # Legacy convention: 'fluid round 1 done' should be observable as # both the full string and the stripped 'round 1 done' so existing # wait_xchange callers that strip the prefix wake up correctly. - handler.send_message('fluid round 1 done') - self.assertTrue(tx.signal_registry.is_set('fluid', 'fluid round 1 done')) - self.assertTrue(tx.signal_registry.is_set('fluid', 'round 1 done')) + handler.send_message("fluid round 1 done") + self.assertTrue( + tx.signal_registry.is_set("fluid", "fluid round 1 done") + ) + self.assertTrue(tx.signal_registry.is_set("fluid", "round 1 done")) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_stage5_gui.py b/PycroFlow/tests/test_stage5_gui.py index 0f2aff3..604c033 100644 --- a/PycroFlow/tests/test_stage5_gui.py +++ b/PycroFlow/tests/test_stage5_gui.py @@ -12,16 +12,18 @@ live acquisition, real laser control from the monet tab, and the single-process MM Core conflict resolution. """ + import importlib import os import sys import types import unittest -os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") try: import PyQt6 # noqa: F401 + _HAVE_PYQT6 = True except ImportError: _HAVE_PYQT6 = False @@ -29,7 +31,7 @@ def _import_safe_without_pyqt6(): """import PycroFlow.gui must not import PyQt6 at package level.""" - mod = importlib.import_module('PycroFlow.gui') + mod = importlib.import_module("PycroFlow.gui") return mod @@ -38,13 +40,13 @@ class TestGuiImportSafety(unittest.TestCase): def test_package_import_does_not_require_pyqt6(self): # Importing the package should succeed and must not pull PyQt6 in by # itself (the Qt-dependent modules import it lazily). - before = 'PyQt6' in sys.modules + before = "PyQt6" in sys.modules _import_safe_without_pyqt6() # If PyQt6 wasn't already loaded, importing the package shouldn't # have loaded it. (When it was already loaded by another test, we # can't assert much — just that the import works.) if not before: - self.assertNotIn('PyQt6', sys.modules) + self.assertNotIn("PyQt6", sys.modules) @unittest.skipUnless(_HAVE_PYQT6, "PyQt6 not installed") @@ -53,6 +55,7 @@ class TestQtBridge(unittest.TestCase): @classmethod def setUpClass(cls): from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) def test_state_observer_emits_signal(self): @@ -65,11 +68,13 @@ def test_state_observer_emits_signal(self): bridge.state_changed.connect(lambda o, n: received.append((o, n))) from PycroFlow.examples.demo_protocols import protocol + svc.load_protocol(protocol) # Same-thread emission is synchronous under DirectConnection. self.assertEqual( - received, [(ExperimentState.IDLE, ExperimentState.LOADED)]) + received, [(ExperimentState.IDLE, ExperimentState.LOADED)] + ) def test_log_observer_emits_signal(self): from PycroFlow.services import ExperimentService @@ -81,9 +86,10 @@ def test_log_observer_emits_signal(self): bridge.log_message.connect(lambda m: lines.append(m)) from PycroFlow.examples.demo_protocols import protocol + svc.load_protocol(protocol) - self.assertTrue(any('loaded' in m for m in lines)) + self.assertTrue(any("loaded" in m for m in lines)) @unittest.skipUnless(_HAVE_PYQT6, "PyQt6 not installed") @@ -92,11 +98,13 @@ class TestMainWindow(unittest.TestCase): @classmethod def setUpClass(cls): from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) def _build(self): from PycroFlow.services import ExperimentService, SystemService from PycroFlow.gui.main_window import PycroFlowMainWindow + return PycroFlowMainWindow(ExperimentService(), SystemService()) def test_builds_tabs(self): @@ -104,17 +112,19 @@ def test_builds_tabs(self): self.assertEqual(w.tabs.count(), 5) self.assertEqual( [w.tabs.tabText(i) for i in range(5)], - ['Experiment Design', 'Run Sequence', 'Fluid', 'Imaging', - 'Monet']) + ["Experiment Design", "Run Sequence", "Fluid", "Imaging", "Monet"], + ) def test_window_title_has_version(self): from PycroFlow import __version__ + w = self._build() self.assertIn(__version__, w.windowTitle()) def test_run_controls_live_in_run_sequence_tab(self): from PycroFlow.examples.demo_protocols import protocol from PycroFlow.services import ExperimentState + w = self._build() tab = w.run_sequence_tab # Idle: only Load is available. @@ -129,22 +139,23 @@ def test_run_controls_live_in_run_sequence_tab(self): self.assertFalse(tab.abort_btn.isEnabled()) # Running: the toggle shows Pause; Load is disabled. tab._service._set_state(ExperimentState.RUNNING) - self.assertEqual(tab.pause_resume_btn.text(), 'Pause') + self.assertEqual(tab.pause_resume_btn.text(), "Pause") self.assertTrue(tab.pause_resume_btn.isEnabled()) self.assertTrue(tab.abort_btn.isEnabled()) self.assertFalse(tab.load_btn.isEnabled()) # Paused: the same toggle shows Resume. tab._service._set_state(ExperimentState.PAUSED) - self.assertEqual(tab.pause_resume_btn.text(), 'Resume') + self.assertEqual(tab.pause_resume_btn.text(), "Resume") self.assertTrue(tab.pause_resume_btn.isEnabled()) def test_pause_resume_toggle_calls_service(self): from unittest.mock import MagicMock from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.RUNNING - tab = ExperimentTab(svc, MagicMock(name='bridge')) + tab = ExperimentTab(svc, MagicMock(name="bridge")) tab._on_pause_resume() svc.pause.assert_called_once() svc.resume.assert_not_called() @@ -154,13 +165,13 @@ def test_pause_resume_toggle_calls_service(self): def test_experiment_tab_reflects_state(self): from PycroFlow.examples.demo_protocols import protocol + w = self._build() w.run_sequence_tab._service.load_protocol(protocol) # The bridge updates the label synchronously (same thread). - self.assertEqual(w.run_sequence_tab.state_label.text(), 'loaded') + self.assertEqual(w.run_sequence_tab.state_label.text(), "loaded") # Fluid step list populated from the fluid protocol entries. - self.assertGreater( - w.run_sequence_tab.step_lists['fluid'].count(), 0) + self.assertGreater(w.run_sequence_tab.step_lists["fluid"].count(), 0) @staticmethod def _table_dict(tab): @@ -171,22 +182,24 @@ def _table_dict(tab): def test_experiment_tab_shows_step_parameters(self): from PycroFlow.examples.demo_protocols import protocol + w = self._build() tab = w.run_sequence_tab tab._service.load_protocol(protocol) - self.assertGreater(tab.step_lists['fluid'].count(), 0) + self.assertGreater(tab.step_lists["fluid"].count(), 0) # Selecting a step shows that entry's parameters in the table. - tab.step_lists['fluid'].setCurrentRow(0) - entry = tab._entries['fluid'][0] + tab.step_lists["fluid"].setCurrentRow(0) + entry = tab._entries["fluid"][0] shown = self._table_dict(tab) - self.assertEqual(shown.get('$type'), str(entry['$type'])) + self.assertEqual(shown.get("$type"), str(entry["$type"])) for key in entry: self.assertIn(key, shown) # The parameter box labels which list the step came from. - self.assertIn('Fluid', tab.step_param_label.text()) + self.assertIn("Fluid", tab.step_param_label.text()) def test_close_event_is_safe(self): from PyQt6.QtGui import QCloseEvent + w = self._build() # Should not raise even with no protocol / no hardware. w.closeEvent(QCloseEvent()) @@ -195,135 +208,195 @@ def test_progress_bars_and_step_shading(self): from unittest.mock import MagicMock from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ( - ExperimentTab, _FINISHED_COLOR, _ACTIVE_COLOR) - svc = MagicMock(name='service') + ExperimentTab, + _FINISHED_COLOR, + _ACTIVE_COLOR, + ) + + svc = MagicMock(name="service") svc.state = ExperimentState.RUNNING svc.protocol = { - 'fluid': {'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 1, 'volume': 10}, - {'$type': 'signal', 'value': 'x'}, - {'$type': 'incubate', 'duration': 1}]}, - 'img': {'protocol_entries': [ - {'$type': 'acquire', 'frames': 1, 't_exp': 1}, - {'$type': 'acquire', 'frames': 1, 't_exp': 1}]}, - 'illu': {'protocol_entries': []}, + "fluid": { + "protocol_entries": [ + {"$type": "inject", "reservoir_id": 1, "volume": 10}, + {"$type": "signal", "value": "x"}, + {"$type": "incubate", "duration": 1}, + ] + }, + "img": { + "protocol_entries": [ + {"$type": "acquire", "frames": 1, "t_exp": 1}, + {"$type": "acquire", "frames": 1, "t_exp": 1}, + ] + }, + "illu": {"protocol_entries": []}, } svc.progress.return_value = { - 'fluid': (1, 3), 'img': (1, 2), 'illu': (0, 0)} - tab = ExperimentTab(svc, MagicMock(name='bridge')) + "fluid": (1, 3), + "img": (1, 2), + "illu": (0, 0), + } + tab = ExperimentTab(svc, MagicMock(name="bridge")) tab._populate_steps() tab._poll_progress() # overall: done 1+1+0=2 / total 3+2+0=5 = 40%, count shown on the right self.assertEqual(tab.overall_bar.value(), 40) - self.assertEqual(tab.overall_count.text(), '2/5') + self.assertEqual(tab.overall_count.text(), "2/5") # rounds: 2 acquires, img cur=1 -> imaging the 2nd round of 2, shown as # a "Round k/N" prefix on the status line (no separate rounds bar). - self.assertFalse(hasattr(tab, 'round_bar')) - self.assertIn('Round 2/2', tab.step_status.text()) + self.assertFalse(hasattr(tab, "round_bar")) + self.assertIn("Round 2/2", tab.step_status.text()) # per-subsystem status: centered, current step name in brackets from PyQt6.QtCore import Qt + # fluid cur=1 -> entries[1] is the 'signal' step - self.assertIn('fluid 1/3 (signal)', tab.step_status.text()) + self.assertIn("fluid 1/3 (signal)", tab.step_status.text()) self.assertTrue( - bool(tab.step_status.alignment() & Qt.AlignmentFlag.AlignCenter)) + bool(tab.step_status.alignment() & Qt.AlignmentFlag.AlignCenter) + ) # fluid rows: 0 finished, 1 active (== cur), 2 pending - fluid = tab.step_lists['fluid'] - self.assertEqual( - fluid.item(0).background().color(), _FINISHED_COLOR) - self.assertEqual( - fluid.item(1).background().color(), _ACTIVE_COLOR) + fluid = tab.step_lists["fluid"] + self.assertEqual(fluid.item(0).background().color(), _FINISHED_COLOR) + self.assertEqual(fluid.item(1).background().color(), _ACTIVE_COLOR) def test_round_status_shows_description(self): from unittest.mock import MagicMock from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.RUNNING # Acquires carry the builder's human-readable round 'name'. svc.protocol = { - 'fluid': {'protocol_entries': []}, - 'img': {'protocol_entries': [ - {'$type': 'acquire', 'frames': 1, 't_exp': 1, 'name': 'R1'}, - {'$type': 'acquire', 'frames': 1, 't_exp': 1, - 'name': 'A1 RESI round 2'}]}, - 'illu': {'protocol_entries': []}, + "fluid": {"protocol_entries": []}, + "img": { + "protocol_entries": [ + { + "$type": "acquire", + "frames": 1, + "t_exp": 1, + "name": "R1", + }, + { + "$type": "acquire", + "frames": 1, + "t_exp": 1, + "name": "A1 RESI round 2", + }, + ] + }, + "illu": {"protocol_entries": []}, } svc.progress.return_value = { - 'fluid': (0, 0), 'img': (1, 2), 'illu': (0, 0)} + "fluid": (0, 0), + "img": (1, 2), + "illu": (0, 0), + } svc.step_progress.return_value = {} - tab = ExperimentTab(svc, MagicMock(name='bridge')) + tab = ExperimentTab(svc, MagicMock(name="bridge")) tab._populate_steps() tab._poll_progress() # img cur=1 -> on the 2nd acquire (round 2 of 2), labelled by name. - self.assertIn('Round 2/2: A1 RESI round 2', tab.step_status.text()) + self.assertIn("Round 2/2: A1 RESI round 2", tab.step_status.text()) def test_current_round_progress_bar(self): from unittest.mock import MagicMock from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.RUNNING # Two rounds: each fluid round ends at a wait-for-img; each img round # ends at an acquire. svc.protocol = { - 'fluid': {'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 1, 'volume': 10}, - {'$type': 'signal', 'value': 'done flushing r0'}, - {'$type': 'wait for signal', 'target': 'img', 'value': 'a'}, - {'$type': 'inject', 'reservoir_id': 2, 'volume': 10}, - {'$type': 'signal', 'value': 'done flushing r1'}, - {'$type': 'wait for signal', 'target': 'img', 'value': 'b'}]}, - 'img': {'protocol_entries': [ - {'$type': 'acquire', 'frames': 1, 't_exp': 1}, - {'$type': 'signal', 'value': 'done imaging r0'}, - {'$type': 'acquire', 'frames': 1, 't_exp': 1}, - {'$type': 'signal', 'value': 'done imaging r1'}]}, - 'illu': {'protocol_entries': []}, + "fluid": { + "protocol_entries": [ + {"$type": "inject", "reservoir_id": 1, "volume": 10}, + {"$type": "signal", "value": "done flushing r0"}, + { + "$type": "wait for signal", + "target": "img", + "value": "a", + }, + {"$type": "inject", "reservoir_id": 2, "volume": 10}, + {"$type": "signal", "value": "done flushing r1"}, + { + "$type": "wait for signal", + "target": "img", + "value": "b", + }, + ] + }, + "img": { + "protocol_entries": [ + {"$type": "acquire", "frames": 1, "t_exp": 1}, + {"$type": "signal", "value": "done imaging r0"}, + {"$type": "acquire", "frames": 1, "t_exp": 1}, + {"$type": "signal", "value": "done imaging r1"}, + ] + }, + "illu": {"protocol_entries": []}, } # Mid round 0: fluid on step 1 of {0,1,2}, img acquire not yet done. svc.progress.return_value = { - 'fluid': (1, 6), 'img': (0, 4), 'illu': (0, 0)} - tab = ExperimentTab(svc, MagicMock(name='bridge')) + "fluid": (1, 6), + "img": (0, 4), + "illu": (0, 0), + } + tab = ExperimentTab(svc, MagicMock(name="bridge")) tab._populate_steps() tab._poll_progress() # Round-0 steps: fluid 0,1,2 + img 0 = 4 total; done = fluid step 0. self.assertEqual(tab.current_round_bar.value(), 25) - self.assertEqual(tab.current_round_count.text(), '1/4') + self.assertEqual(tab.current_round_count.text(), "1/4") def test_duration_estimate_display(self): from unittest.mock import MagicMock from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.RUNNING # fluid: 60 + 60 = 120 s; img: 1000 * 120 ms = 120 s; total 240 s = 4m. svc.protocol = { - 'fluid': {'protocol_entries': [ - {'$type': 'incubate', 'duration': 60}, - {'$type': 'incubate', 'duration': 60}]}, - 'img': {'protocol_entries': [ - {'$type': 'acquire', 'frames': 1000, 't_exp': 120}]}, - 'illu': {'protocol_entries': []}, + "fluid": { + "protocol_entries": [ + {"$type": "incubate", "duration": 60}, + {"$type": "incubate", "duration": 60}, + ] + }, + "img": { + "protocol_entries": [ + {"$type": "acquire", "frames": 1000, "t_exp": 120} + ] + }, + "illu": {"protocol_entries": []}, } svc.progress.return_value = { - 'fluid': (0, 2), 'img': (0, 1), 'illu': (0, 0)} + "fluid": (0, 2), + "img": (0, 1), + "illu": (0, 0), + } svc.step_progress.return_value = {} - tab = ExperimentTab(svc, MagicMock(name='bridge')) + tab = ExperimentTab(svc, MagicMock(name="bridge")) tab._populate_steps() # Total shown up front (before/at the start of the run). self.assertIn( - 'Estimated sequence duration: ~4m', - tab.total_estimate_label.text()) + "Estimated sequence duration: ~4m", tab.total_estimate_label.text() + ) # Once running, the single time line shows elapsed / remaining / total. tab._on_state_changed(ExperimentState.LOADED, ExperimentState.RUNNING) tab._poll_progress() - self.assertIn('elapsed', tab.total_estimate_label.text()) - self.assertIn('~4m left', tab.total_estimate_label.text()) + self.assertIn("elapsed", tab.total_estimate_label.text()) + self.assertIn("~4m left", tab.total_estimate_label.text()) # After the first fluid incubate, remaining drops to 60 + 120 = 3m. svc.progress.return_value = { - 'fluid': (1, 2), 'img': (0, 1), 'illu': (0, 0)} + "fluid": (1, 2), + "img": (0, 1), + "illu": (0, 0), + } tab._poll_progress() - self.assertIn('~3m left', tab.total_estimate_label.text()) + self.assertIn("~3m left", tab.total_estimate_label.text()) # The two progress bars stay horizontally aligned (same grid column). tab.resize(900, 700) tab.show() @@ -337,28 +410,36 @@ def test_elapsed_time_shown_and_frozen_on_pause(self): from unittest.mock import MagicMock from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.RUNNING svc.protocol = { - 'fluid': {'protocol_entries': [ - {'$type': 'incubate', 'duration': 60}]}, - 'img': {'protocol_entries': [ - {'$type': 'acquire', 'frames': 1000, 't_exp': 120}]}, - 'illu': {'protocol_entries': []}, + "fluid": { + "protocol_entries": [{"$type": "incubate", "duration": 60}] + }, + "img": { + "protocol_entries": [ + {"$type": "acquire", "frames": 1000, "t_exp": 120} + ] + }, + "illu": {"protocol_entries": []}, } svc.progress.return_value = { - 'fluid': (0, 1), 'img': (0, 1), 'illu': (0, 0)} + "fluid": (0, 1), + "img": (0, 1), + "illu": (0, 0), + } svc.step_progress.return_value = {} - tab = ExperimentTab(svc, MagicMock(name='bridge')) + tab = ExperimentTab(svc, MagicMock(name="bridge")) tab._populate_steps() # Entering RUNNING starts the stopwatches; the time line then shows # both the overall and the current-round elapsed readings. tab._on_state_changed(ExperimentState.LOADED, ExperimentState.RUNNING) time.sleep(0.02) tab._poll_progress() - self.assertIn('Overall:', tab.total_estimate_label.text()) - self.assertIn('Round:', tab.total_estimate_label.text()) - self.assertEqual(tab.total_estimate_label.text().count('elapsed'), 2) + self.assertIn("Overall:", tab.total_estimate_label.text()) + self.assertIn("Round:", tab.total_estimate_label.text()) + self.assertEqual(tab.total_estimate_label.text().count("elapsed"), 2) # Pausing freezes the elapsed reading. tab._on_state_changed(ExperimentState.RUNNING, ExperimentState.PAUSED) frozen = tab._overall_sw.elapsed() @@ -369,18 +450,22 @@ def test_poll_ends_run_when_finished(self): from unittest.mock import MagicMock from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.RUNNING svc.protocol = { - 'fluid': {'protocol_entries': [{'$type': 'incubate'}]}, - 'img': {'protocol_entries': []}, - 'illu': {'protocol_entries': []}, + "fluid": {"protocol_entries": [{"$type": "incubate"}]}, + "img": {"protocol_entries": []}, + "illu": {"protocol_entries": []}, } svc.progress.return_value = { - 'fluid': (1, 1), 'img': (0, 0), 'illu': (0, 0)} + "fluid": (1, 1), + "img": (0, 0), + "illu": (0, 0), + } svc.step_progress.return_value = {} svc.is_finished.return_value = True - tab = ExperimentTab(svc, MagicMock(name='bridge')) + tab = ExperimentTab(svc, MagicMock(name="bridge")) tab._populate_steps() # Polling notices completion and ends the run (-> FINISHED). tab._poll_progress() @@ -395,9 +480,10 @@ def test_controls_reset_after_run_finishes(self): from unittest.mock import MagicMock from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.FINISHED - tab = ExperimentTab(svc, MagicMock(name='bridge')) + tab = ExperimentTab(svc, MagicMock(name="bridge")) tab._refresh_controls(ExperimentState.FINISHED) # Back to a "not running" state: Start available, Pause/Abort off. self.assertTrue(tab.start_btn.isEnabled()) @@ -408,9 +494,10 @@ def test_clear_button_enabled_only_when_loaded_not_running(self): from unittest.mock import MagicMock from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.IDLE - tab = ExperimentTab(svc, MagicMock(name='bridge')) + tab = ExperimentTab(svc, MagicMock(name="bridge")) for state, enabled in ( (ExperimentState.IDLE, False), (ExperimentState.LOADED, True), @@ -426,25 +513,31 @@ def test_clear_run_sequence_empties_view(self): from PyQt6.QtWidgets import QMessageBox from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.LOADED svc.protocol = { - 'fluid': {'protocol_entries': [ - {'$type': 'incubate', 'duration': 1}]}, - 'img': {'protocol_entries': []}, - 'illu': {'protocol_entries': []}, + "fluid": { + "protocol_entries": [{"$type": "incubate", "duration": 1}] + }, + "img": {"protocol_entries": []}, + "illu": {"protocol_entries": []}, } svc.progress.return_value = { - 'fluid': (1, 1), 'img': (0, 0), 'illu': (0, 0)} + "fluid": (1, 1), + "img": (0, 0), + "illu": (0, 0), + } svc.step_progress.return_value = {} - tab = ExperimentTab(svc, MagicMock(name='bridge')) + tab = ExperimentTab(svc, MagicMock(name="bridge")) tab._populate_steps() tab._poll_progress() - self.assertGreater(tab.step_lists['fluid'].count(), 0) + self.assertGreater(tab.step_lists["fluid"].count(), 0) # Confirming the dialog clears the run sequence via the service. with patch( - 'PycroFlow.gui.tabs.experiment_tab.QMessageBox.question', - return_value=QMessageBox.StandardButton.Yes): + "PycroFlow.gui.tabs.experiment_tab.QMessageBox.question", + return_value=QMessageBox.StandardButton.Yes, + ): tab._on_clear() svc.clear_protocol.assert_called_once() # The bridge is mocked here, so drive the resulting IDLE transition @@ -454,21 +547,23 @@ def test_clear_run_sequence_empties_view(self): svc.progress.return_value = {} svc.state = ExperimentState.IDLE tab._on_state_changed(ExperimentState.LOADED, ExperimentState.IDLE) - self.assertEqual(tab.step_lists['fluid'].count(), 0) - self.assertEqual(tab.overall_count.text(), '0/0') - self.assertEqual(tab.step_status.text(), '—') + self.assertEqual(tab.step_lists["fluid"].count(), 0) + self.assertEqual(tab.overall_count.text(), "0/0") + self.assertEqual(tab.step_status.text(), "—") def test_clear_run_sequence_cancelled_keeps_it(self): from unittest.mock import MagicMock, patch from PyQt6.QtWidgets import QMessageBox from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.LOADED - tab = ExperimentTab(svc, MagicMock(name='bridge')) + tab = ExperimentTab(svc, MagicMock(name="bridge")) with patch( - 'PycroFlow.gui.tabs.experiment_tab.QMessageBox.question', - return_value=QMessageBox.StandardButton.No): + "PycroFlow.gui.tabs.experiment_tab.QMessageBox.question", + return_value=QMessageBox.StandardButton.No, + ): tab._on_clear() svc.clear_protocol.assert_not_called() @@ -476,52 +571,68 @@ def test_within_step_bars(self): from unittest.mock import MagicMock from PycroFlow.services import ExperimentState from PycroFlow.gui.tabs.experiment_tab import ExperimentTab - svc = MagicMock(name='service') + + svc = MagicMock(name="service") svc.state = ExperimentState.RUNNING svc.protocol = { - 'fluid': {'protocol_entries': [{'$type': 'incubate'}]}, - 'img': {'protocol_entries': [{'$type': 'acquire'}]}, - 'illu': {'protocol_entries': []}, + "fluid": {"protocol_entries": [{"$type": "incubate"}]}, + "img": {"protocol_entries": [{"$type": "acquire"}]}, + "illu": {"protocol_entries": []}, } svc.progress.return_value = { - 'fluid': (0, 1), 'img': (0, 1), 'illu': (0, 0)} + "fluid": (0, 1), + "img": (0, 1), + "illu": (0, 0), + } # Imaging mid-acquisition; fluid incubating; illu nothing. svc.step_progress.return_value = { - 'img': (200, 500, 'frames'), - 'fluid': (12.0, 30.0, 'incubate'), - 'illu': None, + "img": (200, 500, "frames"), + "fluid": (12.0, 30.0, "incubate"), + "illu": None, } - tab = ExperimentTab(svc, MagicMock(name='bridge')) + tab = ExperimentTab(svc, MagicMock(name="bridge")) tab._populate_steps() tab._poll_progress() - img_bar, img_count = tab.substep_bars['img'][1:] + img_bar, img_count = tab.substep_bars["img"][1:] # isHidden() reflects the explicit show/hide flag (isVisible() needs a # shown top-level window, which offscreen tests don't have). self.assertFalse(img_bar.isHidden()) self.assertEqual(img_bar.value(), 40) - self.assertEqual(img_count.text(), 'frames 200/500') - fluid_count = tab.substep_bars['fluid'][2] - self.assertEqual(fluid_count.text(), 'incubate 12/30 s') + self.assertEqual(img_count.text(), "frames 200/500") + fluid_count = tab.substep_bars["fluid"][2] + self.assertEqual(fluid_count.text(), "incubate 12/30 s") # Illumination has no sub-progress -> its row stays hidden. - self.assertTrue(tab.substep_bars['illu'][1].isHidden()) + self.assertTrue(tab.substep_bars["illu"][1].isHidden()) @staticmethod def _sync_protocol(): # One round: fluid flushes then signals; img waits, acquires, signals; # fluid then waits for imaging. return { - 'fluid': {'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 1, 'volume': 10}, - {'$type': 'signal', 'value': 'flush0'}, - {'$type': 'wait for signal', 'target': 'img', - 'value': 'img0'}, - {'$type': 'inject', 'reservoir_id': 2, 'volume': 10}]}, - 'img': {'protocol_entries': [ - {'$type': 'wait for signal', 'target': 'fluid', - 'value': 'flush0'}, - {'$type': 'acquire', 'frames': 1, 't_exp': 1}, - {'$type': 'signal', 'value': 'img0'}]}, - 'illu': {'protocol_entries': []}, + "fluid": { + "protocol_entries": [ + {"$type": "inject", "reservoir_id": 1, "volume": 10}, + {"$type": "signal", "value": "flush0"}, + { + "$type": "wait for signal", + "target": "img", + "value": "img0", + }, + {"$type": "inject", "reservoir_id": 2, "volume": 10}, + ] + }, + "img": { + "protocol_entries": [ + { + "$type": "wait for signal", + "target": "fluid", + "value": "flush0", + }, + {"$type": "acquire", "frames": 1, "t_exp": 1}, + {"$type": "signal", "value": "img0"}, + ] + }, + "illu": {"protocol_entries": []}, } def test_step_correlation_across_systems(self): @@ -529,20 +640,21 @@ def test_step_correlation_across_systems(self): tab = w.run_sequence_tab tab._service.load_protocol(self._sync_protocol()) # Click the fluid round-0 inject -> img is parked at its wait. - tab.step_lists['fluid'].setCurrentRow(0) - self.assertEqual(tab.step_lists['img'].currentRow(), 0) + tab.step_lists["fluid"].setCurrentRow(0) + self.assertEqual(tab.step_lists["img"].currentRow(), 0) # Click the img acquire -> fluid is blocked at its wait-for-imaging. - tab.step_lists['img'].setCurrentRow(1) - self.assertEqual(tab.step_lists['fluid'].currentRow(), 2) + tab.step_lists["img"].setCurrentRow(1) + self.assertEqual(tab.step_lists["fluid"].currentRow(), 2) # The parameter box still reflects the clicked (img) step. - self.assertIn('Imaging', tab.step_param_label.text()) + self.assertIn("Imaging", tab.step_param_label.text()) def test_center_button_enabled_only_while_running(self): from PycroFlow.services import ExperimentState + w = self._build() tab = w.run_sequence_tab tab._service.load_protocol(self._sync_protocol()) - self.assertFalse(tab.center_btn.isEnabled()) # loaded, not running + self.assertFalse(tab.center_btn.isEnabled()) # loaded, not running tab._service._set_state(ExperimentState.RUNNING) self.assertTrue(tab.center_btn.isEnabled()) # Centring must not raise (scrolls each list to its current step). @@ -551,39 +663,56 @@ def test_center_button_enabled_only_while_running(self): def test_experiment_tab_lists_all_subsystem_steps(self): w = self._build() proto = { - 'fluid': {'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 1, 'volume': 100}]}, - 'img': {'protocol_entries': [ - {'$type': 'acquire', 'frames': 10, 't_exp': 100}]}, - 'illu': {'protocol_entries': [ - {'$type': 'set power', 'laser': 560, 'power': 30}]}, + "fluid": { + "protocol_entries": [ + {"$type": "inject", "reservoir_id": 1, "volume": 100} + ] + }, + "img": { + "protocol_entries": [ + {"$type": "acquire", "frames": 10, "t_exp": 100} + ] + }, + "illu": { + "protocol_entries": [ + {"$type": "set power", "laser": 560, "power": 30} + ] + }, } tab = w.run_sequence_tab tab._service.load_protocol(proto) # Each subsystem has its own list. self.assertEqual( - [tab.step_lists['fluid'].item(0).text()], ['0: inject']) + [tab.step_lists["fluid"].item(0).text()], ["0: inject"] + ) self.assertEqual( - [tab.step_lists['img'].item(0).text()], ['0: acquire']) + [tab.step_lists["img"].item(0).text()], ["0: acquire"] + ) self.assertEqual( - [tab.step_lists['illu'].item(0).text()], ['0: set power']) + [tab.step_lists["illu"].item(0).text()], ["0: set power"] + ) # Selecting the img step shows its parameters, labelled "Imaging". - tab.step_lists['img'].setCurrentRow(0) + tab.step_lists["img"].setCurrentRow(0) shown = self._table_dict(tab) - self.assertEqual(shown['$type'], 'acquire') - self.assertEqual(shown['frames'], '10') - self.assertEqual(shown['t_exp'], '100') - self.assertIn('Imaging', tab.step_param_label.text()) + self.assertEqual(shown["$type"], "acquire") + self.assertEqual(shown["frames"], "10") + self.assertEqual(shown["t_exp"], "100") + self.assertIn("Imaging", tab.step_param_label.text()) # With no signals the lone steps are concurrent, so clicking img # correlates the other lists to their step 0. - self.assertEqual(tab.step_lists['fluid'].currentRow(), 0) - self.assertEqual(tab.step_lists['illu'].currentRow(), 0) + self.assertEqual(tab.step_lists["fluid"].currentRow(), 0) + self.assertEqual(tab.step_lists["illu"].currentRow(), 0) def _load_inject(self, tab): - proto = {'fluid': {'protocol_entries': [ - {'$type': 'inject', 'reservoir_id': 1, 'volume': 100}]}} + proto = { + "fluid": { + "protocol_entries": [ + {"$type": "inject", "reservoir_id": 1, "volume": 100} + ] + } + } tab._service.load_protocol(proto) - tab.step_lists['fluid'].setCurrentRow(0) + tab.step_lists["fluid"].setCurrentRow(0) def _set_cell(self, tab, key, text): for r in range(tab.step_table.rowCount()): @@ -596,34 +725,36 @@ def test_experiment_tab_edit_writes_back_to_protocol(self): w = self._build() tab = w.run_sequence_tab self._load_inject(tab) - self._set_cell(tab, 'volume', '250') + self._set_cell(tab, "volume", "250") tab._on_apply() # _entries['fluid'][0] is a reference into the loaded protocol, so both # the cached entry and the service's protocol are updated, as int. - self.assertEqual(tab._entries['fluid'][0]['volume'], 250) - stored = tab._service.protocol['fluid']['protocol_entries'][0] - self.assertEqual(stored['volume'], 250) - self.assertIsInstance(stored['volume'], int) + self.assertEqual(tab._entries["fluid"][0]["volume"], 250) + stored = tab._service.protocol["fluid"]["protocol_entries"][0] + self.assertEqual(stored["volume"], 250) + self.assertIsInstance(stored["volume"], int) def test_experiment_tab_invalid_edit_reported_and_skipped(self): from unittest.mock import patch from PycroFlow.gui.tabs import experiment_tab as et + w = self._build() tab = w.run_sequence_tab self._load_inject(tab) - self._set_cell(tab, 'volume', 'not-a-number') - with patch.object(et.QMessageBox, 'warning') as warn: + self._set_cell(tab, "volume", "not-a-number") + with patch.object(et.QMessageBox, "warning") as warn: tab._on_apply() warn.assert_called_once() - self.assertEqual(tab._entries['fluid'][0]['volume'], 100) + self.assertEqual(tab._entries["fluid"][0]["volume"], 100) def test_experiment_tab_type_field_not_editable(self): from PyQt6.QtCore import Qt + w = self._build() tab = w.run_sequence_tab self._load_inject(tab) for r in range(tab.step_table.rowCount()): - if tab.step_table.item(r, 0).text() == '$type': + if tab.step_table.item(r, 0).text() == "$type": flags = tab.step_table.item(r, 1).flags() self.assertFalse(bool(flags & Qt.ItemFlag.ItemIsEditable)) return @@ -636,21 +767,24 @@ class TestMonetTab(unittest.TestCase): @classmethod def setUpClass(cls): from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) def tearDown(self): - sys.modules.pop('monet', None) - sys.modules.pop('monet.gui', None) + sys.modules.pop("monet", None) + sys.modules.pop("monet.gui", None) # Restore the shared hardware mocks so later tests that import # PycroFlow.illumination / monet still find a mocked monet (these # tests pop it to exercise the absent / present paths). from PycroFlow.tests._mock_hardware import install_hardware_mocks + install_hardware_mocks() def test_placeholder_when_monet_absent(self): - sys.modules.pop('monet', None) - sys.modules.pop('monet.gui', None) + sys.modules.pop("monet", None) + sys.modules.pop("monet.gui", None) from PycroFlow.gui.tabs.monet_tab import MonetTab + # Force the import inside MonetTab to fail by inserting a stub that # raises on attribute access of gui. tab = MonetTab() @@ -661,9 +795,10 @@ def test_placeholder_when_monet_absent(self): def test_embeds_when_monet_present(self): from PyQt6.QtWidgets import QWidget + # Inject a fake monet.gui.MonetMainWindow that is a real QWidget. - fake_monet = types.ModuleType('monet') - fake_gui = types.ModuleType('monet.gui') + fake_monet = types.ModuleType("monet") + fake_gui = types.ModuleType("monet.gui") class FakeMonetWindow(QWidget): def __init__(self): @@ -676,10 +811,11 @@ def close(self): fake_gui.MonetMainWindow = FakeMonetWindow fake_monet.gui = fake_gui - sys.modules['monet'] = fake_monet - sys.modules['monet.gui'] = fake_gui + sys.modules["monet"] = fake_monet + sys.modules["monet.gui"] = fake_gui from PycroFlow.gui.tabs.monet_tab import MonetTab + tab = MonetTab() self.assertIsInstance(tab._monet_window, FakeMonetWindow) # shutdown triggers monet's cleanup (close()). @@ -693,115 +829,136 @@ class TestConnectionFlow(unittest.TestCase): @classmethod def setUpClass(cls): from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) def setUp(self): from PycroFlow.gui.widgets import worker + worker.set_synchronous(True) def tearDown(self): from PycroFlow.gui.widgets import worker + worker.set_synchronous(False) def _win(self): from PycroFlow.services import ExperimentService, SystemService from PycroFlow.gui.main_window import PycroFlowMainWindow + return PycroFlowMainWindow(ExperimentService(), SystemService()) def test_startup_loads_setup_and_drives_monet(self): w = self._win() self.assertIsNotNone(w._system_service.setup) self.assertEqual( - w._system_service.get_monet_setup(), - w.setup_combo.currentText()) + w._system_service.get_monet_setup(), w.setup_combo.currentText() + ) def test_autoconnect_on_design_load(self): import PycroFlow + w = self._win() - w._on_setup_changed('Emulator') + w._on_setup_changed("Emulator") path = os.path.join( - os.path.dirname(PycroFlow.__file__), 'examples', - 'sph_resi_6plex.yaml') + os.path.dirname(PycroFlow.__file__), + "examples", + "sph_resi_6plex.yaml", + ) w.design_tab.load_design_path(path) self.assertEqual( w._system_service.connection_states(), - {'fluid': True, 'imaging': True, 'illumination': True}) - self.assertEqual(w.fluid_tab.status_label.text(), 'connected') - self.assertEqual(w.imaging_tab.status_label.text(), 'connected') + {"fluid": True, "imaging": True, "illumination": True}, + ) + self.assertEqual(w.fluid_tab.status_label.text(), "connected") + self.assertEqual(w.imaging_tab.status_label.text(), "connected") self.assertIsNotNone(w._experiment_service._fluid_system) def test_status_bar_confirms_connections(self): import PycroFlow + w = self._win() - w._on_setup_changed('Emulator') + w._on_setup_changed("Emulator") # Before connecting: setup shown, systems not connected. text = w.status_label.text() - self.assertIn('Setup: Emulator', text) - self.assertIn('not connected', text) + self.assertIn("Setup: Emulator", text) + self.assertIn("not connected", text) # After autoconnect (via design load): each system confirmed. path = os.path.join( - os.path.dirname(PycroFlow.__file__), 'examples', - 'sph_resi_6plex.yaml') + os.path.dirname(PycroFlow.__file__), + "examples", + "sph_resi_6plex.yaml", + ) w.design_tab.load_design_path(path) text = w.status_label.text() - self.assertIn('Fluid: ✓ connected', text) - self.assertIn('Imaging: ✓ connected', text) - self.assertIn('Illumination: ✓ connected', text) - self.assertNotIn('not connected', text) + self.assertIn("Fluid: ✓ connected", text) + self.assertIn("Imaging: ✓ connected", text) + self.assertIn("Illumination: ✓ connected", text) + self.assertNotIn("not connected", text) def test_fluid_connect_requires_design(self): from unittest.mock import patch from PycroFlow.gui import main_window as mw + w = self._win() - with patch.object(mw.QMessageBox, 'warning') as warn: + with patch.object(mw.QMessageBox, "warning") as warn: w.fluid_tab._on_connect_clicked() warn.assert_called_once() self.assertIsNone(w._system_service.fluid_system) def test_manual_imaging_connect(self): w = self._win() - w._on_setup_changed('Emulator') + w._on_setup_changed("Emulator") w.imaging_tab._on_connect_clicked() self.assertIsNotNone(w._system_service.imaging_system) - self.assertEqual(w.imaging_tab.status_label.text(), 'connected') + self.assertEqual(w.imaging_tab.status_label.text(), "connected") def test_toolbar_connect_reconnects_when_already_connected(self): from unittest.mock import patch import PycroFlow + w = self._win() - w._on_setup_changed('Emulator') + w._on_setup_changed("Emulator") path = os.path.join( - os.path.dirname(PycroFlow.__file__), 'examples', - 'sph_resi_6plex.yaml') - w.design_tab.load_design_path(path) # autoconnects all subsystems + os.path.dirname(PycroFlow.__file__), + "examples", + "sph_resi_6plex.yaml", + ) + w.design_tab.load_design_path(path) # autoconnects all subsystems self.assertEqual( w._system_service.connection_states(), - {'fluid': True, 'imaging': True, 'illumination': True}) + {"fluid": True, "imaging": True, "illumination": True}, + ) # _autoconnect skips already-connected subsystems (no-op here)... with patch.object( - w, '_connect_system', wraps=w._connect_system) as auto: + w, "_connect_system", wraps=w._connect_system + ) as auto: w._autoconnect() auto.assert_not_called() # ...but the toolbar Connect re-targets every subsystem regardless, so # picking a different setup and hitting Connect actually reconnects. with patch.object( - w, '_connect_system', wraps=w._connect_system) as manual: + w, "_connect_system", wraps=w._connect_system + ) as manual: w.act_connect.trigger() self.assertEqual( {c.args[0] for c in manual.call_args_list}, - {'fluid', 'imaging', 'illumination'}) + {"fluid", "imaging", "illumination"}, + ) def test_toolbar_connect_warns_without_setup(self): from unittest.mock import patch from PycroFlow.gui import main_window as mw + w = self._win() - w._system_service._setup = None # simulate no setup loaded - with patch.object(mw.QMessageBox, 'warning') as warn: + w._system_service._setup = None # simulate no setup loaded + with patch.object(mw.QMessageBox, "warning") as warn: w.act_connect.trigger() warn.assert_called_once() def test_hardware_locked_during_run(self): from PycroFlow.services import ExperimentState + w = self._win() self.assertTrue(w.fluid_tab.connect_btn.isEnabled()) self.assertTrue(w.setup_combo.isEnabled()) @@ -826,53 +983,63 @@ def test_hardware_locked_during_run(self): def test_toolbar_disconnect_releases_all_systems(self): import PycroFlow + w = self._win() - w._on_setup_changed('Emulator') + w._on_setup_changed("Emulator") path = os.path.join( - os.path.dirname(PycroFlow.__file__), 'examples', - 'sph_resi_6plex.yaml') + os.path.dirname(PycroFlow.__file__), + "examples", + "sph_resi_6plex.yaml", + ) w.design_tab.load_design_path(path) self.assertTrue(all(w._system_service.connection_states().values())) w.act_disconnect.trigger() self.assertEqual( w._system_service.connection_states(), - {'fluid': False, 'imaging': False, 'illumination': False}) - self.assertIn('not connected', w.status_label.text()) + {"fluid": False, "imaging": False, "illumination": False}, + ) + self.assertIn("not connected", w.status_label.text()) def test_setup_change_disconnects_systems(self): from unittest.mock import patch + w = self._win() - w._on_setup_changed('Emulator') - w.imaging_tab._on_connect_clicked() # connect (no design needed) + w._on_setup_changed("Emulator") + w.imaging_tab._on_connect_clicked() # connect (no design needed) self.assertIsNotNone(w._system_service.imaging_system) # Changing the setup disconnects existing systems first, so the live # hardware never disagrees with the selected setup. With no design # loaded there is nothing to reconnect, so it stays disconnected. with patch.object( - w._system_service, 'disconnect_all', - wraps=w._system_service.disconnect_all) as da: - w._on_setup_changed('Emulator') + w._system_service, + "disconnect_all", + wraps=w._system_service.disconnect_all, + ) as da: + w._on_setup_changed("Emulator") da.assert_called_once() self.assertEqual( w._system_service.connection_states(), - {'fluid': False, 'imaging': False, 'illumination': False}) + {"fluid": False, "imaging": False, "illumination": False}, + ) def test_connect_disconnects_first(self): from unittest.mock import patch + w = self._win() - w._on_setup_changed('Emulator') - w.imaging_tab._on_connect_clicked() # initial connect + w._on_setup_changed("Emulator") + w.imaging_tab._on_connect_clicked() # initial connect self.assertIsNotNone(w._system_service.imaging_system) # Reconnecting frees the existing handle first. with patch.object( - w._system_service, 'disconnect', - wraps=w._system_service.disconnect) as dc: + w._system_service, "disconnect", wraps=w._system_service.disconnect + ) as dc: w.imaging_tab._on_connect_clicked() - dc.assert_any_call('imaging') + dc.assert_any_call("imaging") self.assertIsNotNone(w._system_service.imaging_system) def test_finished_run_unlocks_hardware(self): from PycroFlow.services import ExperimentState + w = self._win() w._experiment_service._set_state(ExperimentState.RUNNING) self.assertFalse(w.setup_combo.isEnabled()) @@ -889,8 +1056,10 @@ def test_finished_run_unlocks_hardware(self): def _example_design(): import PycroFlow from PycroFlow.services import ExperimentService + path = os.path.join( - os.path.dirname(PycroFlow.__file__), 'examples', 'sph_resi_6plex.yaml') + os.path.dirname(PycroFlow.__file__), "examples", "sph_resi_6plex.yaml" + ) return ExperimentService().load_experiment_design(path), path @@ -900,224 +1069,300 @@ class TestSchemaForm(unittest.TestCase): @classmethod def setUpClass(cls): from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) def test_roundtrip_and_validate(self): from PycroFlow.gui.widgets.schema_form import SchemaForm from PycroFlow.schemas.experiment_design import ExperimentDesign + design, _ = _example_design() form = SchemaForm(ExperimentDesign, design) model = form.to_model() - self.assertEqual(model.fluid.settings.experiment.type, 'SPH-RESI') + self.assertEqual(model.fluid.settings.experiment.type, "SPH-RESI") d = form.to_dict() - tr = d['fluid']['settings']['experiment']['target-rounds']['A1'] - self.assertEqual(len(tr['RESI-rounds']), 6) + tr = d["fluid"]["settings"]["experiment"]["target-rounds"]["A1"] + self.assertEqual(len(tr["RESI-rounds"]), 6) def test_list_model_editor_add_remove(self): from PycroFlow.gui.widgets.schema_form import _ListModelEditor from PycroFlow.schemas.experiment_design import ResiRound + ed = _ListModelEditor( - ResiRound, [{'adapter': 'a', 'adapter_incubation': 1}], 'RESI') + ResiRound, [{"adapter": "a", "adapter_incubation": 1}], "RESI" + ) self.assertEqual(len(ed.get_value()), 1) - ed._add_item({'adapter': 'b', 'adapter_incubation': 2}) + ed._add_item({"adapter": "b", "adapter_incubation": 2}) self.assertEqual(len(ed.get_value()), 2) ed._remove(ed._items[0]) self.assertEqual(len(ed.get_value()), 1) - self.assertEqual(ed.get_value()[0]['adapter'], 'b') + self.assertEqual(ed.get_value()[0]["adapter"], "b") def test_scalar_defaults_seeded(self): from PycroFlow.gui.widgets.schema_form import SchemaForm from PycroFlow.schemas.experiment_design import FluidParameters + form = SchemaForm(FluidParameters, {}) - self.assertEqual(form.to_dict()['mode'], 'tubing_ignore') + self.assertEqual(form.to_dict()["mode"], "tubing_ignore") def test_form_labels_are_left_aligned(self): from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QFormLayout from PycroFlow.gui.widgets.schema_form import SchemaForm from PycroFlow.schemas.experiment_design import FluidParameters + form = SchemaForm(FluidParameters, {}) lay = form.layout() self.assertIsInstance(lay, QFormLayout) self.assertTrue( - bool(lay.labelAlignment() & Qt.AlignmentFlag.AlignLeft)) + bool(lay.labelAlignment() & Qt.AlignmentFlag.AlignLeft) + ) def test_mode_is_dropdown(self): from PycroFlow.gui.widgets.schema_form import SchemaForm, _ChoiceEditor from PycroFlow.schemas.experiment_design import FluidParameters + form = SchemaForm(FluidParameters, {}) - ed = form.field_editor('mode') + ed = form.field_editor("mode") self.assertIsInstance(ed, _ChoiceEditor) items = [ed._combo.itemText(i) for i in range(ed._combo.count())] - self.assertEqual(set(items), {'tubing_ignore', 'tubing_stack'}) - self.assertEqual(ed.get_value(), 'tubing_ignore') + self.assertEqual(set(items), {"tubing_ignore", "tubing_stack"}) + self.assertEqual(ed.get_value(), "tubing_ignore") def test_imager_fields_are_name_dropdowns(self): from PycroFlow.gui.widgets.schema_form import SchemaForm, _ChoiceEditor from PycroFlow.schemas.experiment_design import SphResiExperiment + form = SchemaForm( SphResiExperiment, - {'type': 'SPH-RESI', 'wash_buffer_1': 'R1', 'blocker': 'R2', - 'blocker_incubation': 5, 'round0': None, 'target-rounds': {}}, - context={'reservoir_names': ['R1', 'R2', 'C+']}, - skip_fields={'type'}) - ed = form.field_editor('wash_buffer_1') + { + "type": "SPH-RESI", + "wash_buffer_1": "R1", + "blocker": "R2", + "blocker_incubation": 5, + "round0": None, + "target-rounds": {}, + }, + context={"reservoir_names": ["R1", "R2", "C+"]}, + skip_fields={"type"}, + ) + ed = form.field_editor("wash_buffer_1") self.assertIsInstance(ed, _ChoiceEditor) items = [ed._combo.itemText(i) for i in range(ed._combo.count())] - self.assertIn('R1', items) - self.assertIn('C+', items) - self.assertIn('', items) # the None option + self.assertIn("R1", items) + self.assertIn("C+", items) + self.assertIn("", items) # the None option def test_laser_is_dropdown_from_monet_lasers(self): from PycroFlow.gui.widgets.schema_form import SchemaForm, _ChoiceEditor from PycroFlow.schemas.experiment_design import IlluSettings - form = SchemaForm(IlluSettings, {'laser': 642, 'power_acq': 70}, - context={'lasers': [488, 561, 640, 642]}) - ed = form.field_editor('laser') + + form = SchemaForm( + IlluSettings, + {"laser": 642, "power_acq": 70}, + context={"lasers": [488, 561, 640, 642]}, + ) + ed = form.field_editor("laser") self.assertIsInstance(ed, _ChoiceEditor) items = [ed._combo.itemText(i) for i in range(ed._combo.count())] - self.assertEqual(items, ['488', '561', '640', '642']) + self.assertEqual(items, ["488", "561", "640", "642"]) # The selected laser round-trips back to an int. - self.assertEqual(form.to_dict()['laser'], 642) - self.assertIsInstance(form.to_dict()['laser'], int) + self.assertEqual(form.to_dict()["laser"], 642) + self.assertIsInstance(form.to_dict()["laser"], int) def test_imager_dropdowns_update_live_on_reservoir_edit(self): from PycroFlow.gui.widgets.schema_form import SchemaForm from PycroFlow.schemas.experiment_design import FluidSettings - form = SchemaForm(FluidSettings, { - 'vol_wash': 10, 'reservoir_names': {1: 'R1', 2: 'R2'}, - 'experiment': {'type': 'Exchange', 'wash_buffer': 'R1'}}, - context={'reservoir_names': ['R1', 'R2'], - 'reservoir_ids': [1, 2, 3]}) - wb = form.field_editor('experiment')._form.field_editor('wash_buffer') + + form = SchemaForm( + FluidSettings, + { + "vol_wash": 10, + "reservoir_names": {1: "R1", 2: "R2"}, + "experiment": {"type": "Exchange", "wash_buffer": "R1"}, + }, + context={ + "reservoir_names": ["R1", "R2"], + "reservoir_ids": [1, 2, 3], + }, + ) + wb = form.field_editor("experiment")._form.field_editor("wash_buffer") self.assertIn( - 'R2', [wb._combo.itemText(i) for i in range(wb._combo.count())]) + "R2", [wb._combo.itemText(i) for i in range(wb._combo.count())] + ) # Rename reservoir 2 in the table -> the imager dropdown updates. - name_cell = form.field_editor('reservoir_names')._rows[1][1] - name_cell.setText('NEWDYE') + name_cell = form.field_editor("reservoir_names")._rows[1][1] + name_cell.setText("NEWDYE") items = [wb._combo.itemText(i) for i in range(wb._combo.count())] - self.assertIn('NEWDYE', items) - self.assertNotIn('R2', items) + self.assertIn("NEWDYE", items) + self.assertNotIn("R2", items) def test_exchange_imagers_are_addremove_dropdowns(self): from PycroFlow.gui.widgets.schema_form import ( - SchemaForm, _ListChoiceEditor, _ChoiceEditor) + SchemaForm, + _ListChoiceEditor, + _ChoiceEditor, + ) from PycroFlow.schemas.experiment_design import ExchangeExperiment + form = SchemaForm( ExchangeExperiment, - {'type': 'Exchange', 'wash_buffer': 'C+', - 'imagers': ['R1', 'R2']}, - context={'reservoir_names': ['R1', 'R2', 'R3', 'C+']}, - skip_fields={'type'}) - ed = form.field_editor('imagers') + {"type": "Exchange", "wash_buffer": "C+", "imagers": ["R1", "R2"]}, + context={"reservoir_names": ["R1", "R2", "R3", "C+"]}, + skip_fields={"type"}, + ) + ed = form.field_editor("imagers") self.assertIsInstance(ed, _ListChoiceEditor) self.assertEqual(len(ed._items), 2) # Box is titled 'rounds' with a per-row 'imager round {k}' label. - self.assertEqual(ed.title(), 'rounds') + self.assertEqual(ed.title(), "rounds") self.assertEqual( [lbl.text() for _, _, lbl in ed._items], - ['imager round 1', 'imager round 2']) + ["imager round 1", "imager round 2"], + ) # Each round is a reservoir-name dropdown. row0 = ed._items[0][1] self.assertIsInstance(row0, _ChoiceEditor) - self.assertIn('R3', [row0._combo.itemText(i) - for i in range(row0._combo.count())]) + self.assertIn( + "R3", [row0._combo.itemText(i) for i in range(row0._combo.count())] + ) # Add / remove rows like the RESI rounds; labels renumber. - ed._add_item('R3') - self.assertEqual(form.to_dict()['imagers'], ['R1', 'R2', 'R3']) - self.assertEqual(ed._items[-1][2].text(), 'imager round 3') + ed._add_item("R3") + self.assertEqual(form.to_dict()["imagers"], ["R1", "R2", "R3"]) + self.assertEqual(ed._items[-1][2].text(), "imager round 3") ed._remove(ed._items[0]) - self.assertEqual(form.to_dict()['imagers'], ['R2', 'R3']) + self.assertEqual(form.to_dict()["imagers"], ["R2", "R3"]) self.assertEqual( [lbl.text() for _, _, lbl in ed._items], - ['imager round 1', 'imager round 2']) + ["imager round 1", "imager round 2"], + ) def test_exchange_field_order_initial_imager_before_rounds(self): from PycroFlow.schemas.experiment_design import ExchangeExperiment - fields = [f for f in ExchangeExperiment.model_fields if f != 'type'] - self.assertEqual( - fields, ['wash_buffer', 'initial_imager', 'imagers']) + + fields = [f for f in ExchangeExperiment.model_fields if f != "type"] + self.assertEqual(fields, ["wash_buffer", "initial_imager", "imagers"]) def test_exchange_imager_rows_update_live_on_reservoir_edit(self): from PycroFlow.gui.widgets.schema_form import SchemaForm from PycroFlow.schemas.experiment_design import FluidSettings + data = { - 'vol_wash': 10, 'reservoir_names': {1: 'R1', 2: 'R2'}, - 'experiment': {'type': 'Exchange', 'wash_buffer': 'R1', - 'imagers': ['R1']}} - form = SchemaForm(FluidSettings, data, context={ - 'reservoir_names': ['R1', 'R2'], 'reservoir_ids': [1, 2, 3]}) - imagers = form.field_editor('experiment')._form.field_editor('imagers') + "vol_wash": 10, + "reservoir_names": {1: "R1", 2: "R2"}, + "experiment": { + "type": "Exchange", + "wash_buffer": "R1", + "imagers": ["R1"], + }, + } + form = SchemaForm( + FluidSettings, + data, + context={ + "reservoir_names": ["R1", "R2"], + "reservoir_ids": [1, 2, 3], + }, + ) + imagers = form.field_editor("experiment")._form.field_editor("imagers") row0 = imagers._items[0][1] - self.assertIn('R2', [row0._combo.itemText(i) - for i in range(row0._combo.count())]) + self.assertIn( + "R2", [row0._combo.itemText(i) for i in range(row0._combo.count())] + ) # Rename reservoir 2 -> the imager dropdown options follow. - form.field_editor('reservoir_names')._rows[1][1].setText('NEWDYE') - self.assertIn('NEWDYE', [row0._combo.itemText(i) - for i in range(row0._combo.count())]) + form.field_editor("reservoir_names")._rows[1][1].setText("NEWDYE") + self.assertIn( + "NEWDYE", + [row0._combo.itemText(i) for i in range(row0._combo.count())], + ) def test_experiment_type_not_duplicated(self): # The union selector supplies 'type'; the variant sub-form must not # render a separate 'type' editor, but to_dict still carries it. from PycroFlow.gui.widgets.schema_form import SchemaForm from PycroFlow.schemas.experiment_design import FluidSettings - form = SchemaForm(FluidSettings, { - 'vol_wash': 10, 'reservoir_names': {1: 'R1'}, - 'experiment': {'type': 'Exchange', 'wash_buffer': 'R1'}}, - context={'reservoir_names': ['R1']}) - union = form.field_editor('experiment') - self.assertNotIn('type', union._form._editors) - self.assertEqual(union.get_value()['type'], 'Exchange') + + form = SchemaForm( + FluidSettings, + { + "vol_wash": 10, + "reservoir_names": {1: "R1"}, + "experiment": {"type": "Exchange", "wash_buffer": "R1"}, + }, + context={"reservoir_names": ["R1"]}, + ) + union = form.field_editor("experiment") + self.assertNotIn("type", union._form._editors) + self.assertEqual(union.get_value()["type"], "Exchange") def test_special_names_id_first_and_roundtrips(self): from PycroFlow.gui.widgets.schema_form import SchemaForm from PycroFlow.schemas.experiment_design import FluidSettings - form = SchemaForm(FluidSettings, { - 'vol_wash': 10, 'reservoir_names': {1: 'R1', 7: 'C+'}, - 'special_names': {'flushbuffer_a': 7}, - 'experiment': {'type': 'Exchange', 'wash_buffer': 'R1'}}, - context={'reservoir_names': ['R1', 'C+'], - 'reservoir_ids': [1, 7]}) - sn = form.field_editor('special_names') - self.assertTrue(sn._dvf) # the id (value) is shown first + + form = SchemaForm( + FluidSettings, + { + "vol_wash": 10, + "reservoir_names": {1: "R1", 7: "C+"}, + "special_names": {"flushbuffer_a": 7}, + "experiment": {"type": "Exchange", "wash_buffer": "R1"}, + }, + context={"reservoir_names": ["R1", "C+"], "reservoir_ids": [1, 7]}, + ) + sn = form.field_editor("special_names") + self.assertTrue(sn._dvf) # the id (value) is shown first # Stored mapping is still name -> id. - self.assertEqual( - form.to_dict()['special_names'], {'flushbuffer_a': 7}) + self.assertEqual(form.to_dict()["special_names"], {"flushbuffer_a": 7}) def test_reservoir_id_dropdown_restricted_to_setup(self): from PyQt6.QtWidgets import QComboBox from PycroFlow.gui.widgets.schema_form import SchemaForm from PycroFlow.schemas.experiment_design import FluidSettings - form = SchemaForm(FluidSettings, { - 'vol_wash': 10, 'reservoir_names': {1: 'R1'}, - 'experiment': {'type': 'Exchange', 'wash_buffer': 'R1'}}, - context={'reservoir_names': ['R1'], 'reservoir_ids': [1, 2, 3]}) - rn = form.field_editor('reservoir_names') + + form = SchemaForm( + FluidSettings, + { + "vol_wash": 10, + "reservoir_names": {1: "R1"}, + "experiment": {"type": "Exchange", "wash_buffer": "R1"}, + }, + context={"reservoir_names": ["R1"], "reservoir_ids": [1, 2, 3]}, + ) + rn = form.field_editor("reservoir_names") key_w = rn._rows[0][0] self.assertIsInstance(key_w, QComboBox) items = [key_w.itemText(i) for i in range(key_w.count())] - self.assertEqual(set(items), {'1', '2', '3'}) + self.assertEqual(set(items), {"1", "2", "3"}) def test_cleaning_reservoirs_tooltip(self): from PycroFlow.gui.widgets.schema_form import SchemaForm from PycroFlow.schemas.experiment_design import FluidSettings - form = SchemaForm(FluidSettings, { - 'vol_wash': 10, 'reservoir_names': {1: 'R1'}, - 'experiment': {'type': 'Exchange', 'wash_buffer': 'R1'}}, - context={'reservoir_names': ['R1']}) + + form = SchemaForm( + FluidSettings, + { + "vol_wash": 10, + "reservoir_names": {1: "R1"}, + "experiment": {"type": "Exchange", "wash_buffer": "R1"}, + }, + context={"reservoir_names": ["R1"]}, + ) self.assertIn( - 'omma', form.field_editor('cleaning_reservoirs').toolTip()) + "omma", form.field_editor("cleaning_reservoirs").toolTip() + ) def test_units_shown_next_to_inputs(self): from PyQt6.QtWidgets import QLabel from PycroFlow.gui.widgets.schema_form import SchemaForm from PycroFlow.schemas.experiment_design import FluidParameters + form = SchemaForm(FluidParameters, {}) - vel = form.field_editor('max_velocity') + vel = form.field_editor("max_velocity") self.assertIn( - 'µl/min', [lbl.text() for lbl in vel.findChildren(QLabel)]) + "µl/min", [lbl.text() for lbl in vel.findChildren(QLabel)] + ) # A unitless field gets no unit label. - ef = form.field_editor('extractionfactor') + ef = form.field_editor("extractionfactor") self.assertEqual([lbl.text() for lbl in ef.findChildren(QLabel)], []) @@ -1127,29 +1372,36 @@ class TestExperimentDesignTab(unittest.TestCase): @classmethod def setUpClass(cls): from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) def test_load_and_translate(self): from PycroFlow.services import ExperimentService from PycroFlow.gui.tabs.experiment_design_tab import ( - ExperimentDesignTab) + ExperimentDesignTab, + ) + _, path = _example_design() svc = ExperimentService() translated = [] tab = ExperimentDesignTab( - svc, on_translated=lambda: translated.append(1)) + svc, on_translated=lambda: translated.append(1) + ) tab.load_design_path(path) self.assertEqual( - svc.experiment_design['fluid']['settings']['experiment']['type'], - 'SPH-RESI') + svc.experiment_design["fluid"]["settings"]["experiment"]["type"], + "SPH-RESI", + ) tab._on_translate() - self.assertEqual(svc.state.value, 'loaded') + self.assertEqual(svc.state.value, "loaded") self.assertEqual(translated, [1]) def test_drag_drop_loads_design(self): from PycroFlow.services import ExperimentService from PycroFlow.gui.tabs.experiment_design_tab import ( - ExperimentDesignTab) + ExperimentDesignTab, + ) + _, path = _example_design() svc = ExperimentService() tab = ExperimentDesignTab(svc) @@ -1160,67 +1412,82 @@ def test_reservoir_ids_provider_feeds_form(self): from PyQt6.QtWidgets import QComboBox from PycroFlow.services import ExperimentService from PycroFlow.gui.tabs.experiment_design_tab import ( - ExperimentDesignTab) - self.addCleanup(os.chdir, os.getcwd()) # load_design_path chdirs + ExperimentDesignTab, + ) + + self.addCleanup(os.chdir, os.getcwd()) # load_design_path chdirs _, path = _example_design() tab = ExperimentDesignTab( ExperimentService(), - reservoir_ids_provider=lambda: [1, 2, 3, 4, 5, 6, 7]) + reservoir_ids_provider=lambda: [1, 2, 3, 4, 5, 6, 7], + ) tab.load_design_path(path) - settings = tab._form.field_editor('fluid')._form.field_editor( - 'settings')._form - rn = settings.field_editor('reservoir_names') + settings = ( + tab._form.field_editor("fluid") + ._form.field_editor("settings") + ._form + ) + rn = settings.field_editor("reservoir_names") key_w = rn._rows[0][0] self.assertIsInstance(key_w, QComboBox) def test_save_dir_absolute_path_hint(self): from PycroFlow.services import ExperimentService from PycroFlow.gui.tabs.experiment_design_tab import ( - ExperimentDesignTab) + ExperimentDesignTab, + ) + tab = ExperimentDesignTab(ExperimentService()) - editor = tab._form.field_editor('save_dir') + editor = tab._form.field_editor("save_dir") line = editor.line_edit() # Relative path -> hint shows the resolved absolute destination. - line.setText('subdir') + line.setText("subdir") self.assertEqual( - tab._save_dir_hint.text(), - '→ {}'.format(os.path.abspath('subdir'))) + tab._save_dir_hint.text(), "→ {}".format(os.path.abspath("subdir")) + ) # '.' resolves to the working directory. - line.setText('.') + line.setText(".") self.assertEqual( - tab._save_dir_hint.text(), '→ {}'.format(os.path.abspath('.'))) + tab._save_dir_hint.text(), "→ {}".format(os.path.abspath(".")) + ) # Absolute path -> no hint shown. - line.setText(os.path.abspath('subdir')) - self.assertEqual(tab._save_dir_hint.text(), '') + line.setText(os.path.abspath("subdir")) + self.assertEqual(tab._save_dir_hint.text(), "") def test_duration_estimate_is_automatic_no_button(self): from PycroFlow.services import ExperimentService from PycroFlow.gui.tabs.experiment_design_tab import ( - ExperimentDesignTab) - self.addCleanup(os.chdir, os.getcwd()) # load_design_path chdirs + ExperimentDesignTab, + ) + + self.addCleanup(os.chdir, os.getcwd()) # load_design_path chdirs _, path = _example_design() tab = ExperimentDesignTab(ExperimentService()) # The explicit button is gone — estimation is live. - self.assertFalse(hasattr(tab, 'estimate_btn')) + self.assertFalse(hasattr(tab, "estimate_btn")) tab.load_design_path(path) - tab._recompute_estimate() # fire the debounced recompute directly - self.assertIn('Estimated duration: ~', tab.estimate_label.text()) + tab._recompute_estimate() # fire the debounced recompute directly + self.assertIn("Estimated duration: ~", tab.estimate_label.text()) def test_incomplete_design_estimate_is_graceful(self): from PycroFlow.services import ExperimentService from PycroFlow.gui.tabs.experiment_design_tab import ( - ExperimentDesignTab) + ExperimentDesignTab, + ) + # Empty default form cannot compile; the label says so, no exception. tab = ExperimentDesignTab(ExperimentService()) tab._recompute_estimate() - self.assertIn('incomplete', tab.estimate_label.text()) + self.assertIn("incomplete", tab.estimate_label.text()) def test_sections_are_collapsible_without_dropping_data(self): from PyQt6.QtCore import Qt from PycroFlow.services import ExperimentService from PycroFlow.gui.tabs.experiment_design_tab import ( - ExperimentDesignTab) + ExperimentDesignTab, + ) from PycroFlow.gui.widgets.schema_form import _ModelEditor + self.addCleanup(os.chdir, os.getcwd()) _, path = _example_design() tab = ExperimentDesignTab(ExperimentService()) @@ -1228,15 +1495,15 @@ def test_sections_are_collapsible_without_dropping_data(self): sections = tab._form.findChildren(_ModelEditor) self.assertTrue(sections) # Each section has an arrow toggle, expanded (▾) by default. - self.assertTrue(all(hasattr(s, '_toggle') for s in sections)) - fluid = tab._form.field_editor('fluid') + self.assertTrue(all(hasattr(s, "_toggle") for s in sections)) + fluid = tab._form.field_editor("fluid") self.assertEqual(fluid._toggle.arrowType(), Qt.ArrowType.DownArrow) # Collapsing flips the arrow and hides the body but keeps the value. # isVisibleTo() reflects the explicit hide without needing show(). fluid._toggle.setChecked(False) self.assertEqual(fluid._toggle.arrowType(), Qt.ArrowType.RightArrow) self.assertFalse(fluid._form.isVisibleTo(fluid)) - self.assertIn('fluid', tab._form.to_dict()) + self.assertIn("fluid", tab._form.to_dict()) # Re-expanding restores the arrow and the body. fluid._toggle.setChecked(True) self.assertEqual(fluid._toggle.arrowType(), Qt.ArrowType.DownArrow) @@ -1247,23 +1514,26 @@ def test_clear_resets_design_and_form(self): from PycroFlow.services import ExperimentService from PycroFlow.gui.tabs import experiment_design_tab as edt from PycroFlow.gui.tabs.experiment_design_tab import ( - ExperimentDesignTab) + ExperimentDesignTab, + ) + self.addCleanup(os.chdir, os.getcwd()) _, path = _example_design() svc = ExperimentService() tab = ExperimentDesignTab(svc) tab.load_design_path(path) self.assertIsNotNone(svc.experiment_design) - loaded_name = tab._form.to_dict().get('base_name') + loaded_name = tab._form.to_dict().get("base_name") self.assertTrue(loaded_name) # Confirming clears the service design and rebuilds an empty form. with patch.object( - edt.QMessageBox, 'question', - return_value=edt.QMessageBox.StandardButton.Yes): + edt.QMessageBox, + "question", + return_value=edt.QMessageBox.StandardButton.Yes, + ): tab._on_clear() self.assertIsNone(svc.experiment_design) - self.assertNotEqual( - tab._form.to_dict().get('base_name'), loaded_name) + self.assertNotEqual(tab._form.to_dict().get("base_name"), loaded_name) @unittest.skipUnless(_HAVE_PYQT6, "PyQt6 not installed") @@ -1272,40 +1542,45 @@ class TestMonetSetSetup(unittest.TestCase): @classmethod def setUpClass(cls): from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) def tearDown(self): - sys.modules.pop('monet', None) - sys.modules.pop('monet.gui', None) + sys.modules.pop("monet", None) + sys.modules.pop("monet.gui", None) from PycroFlow.tests._mock_hardware import install_hardware_mocks + install_hardware_mocks() def test_set_setup_preselects_without_autoconnect(self): from PyQt6.QtWidgets import QWidget, QComboBox - fake_monet = types.ModuleType('monet') - fake_gui = types.ModuleType('monet.gui') + + fake_monet = types.ModuleType("monet") + fake_gui = types.ModuleType("monet.gui") class FakeMonetWidget(QWidget): def __init__(self, initial_microscope=None): super().__init__() self.initial_microscope = initial_microscope self._scope_combo = QComboBox() - self._scope_combo.addItems(['Emulator', 'Mercury']) + self._scope_combo.addItems(["Emulator", "Mercury"]) fake_gui.MonetWidget = FakeMonetWidget fake_monet.gui = fake_gui - sys.modules['monet'] = fake_monet - sys.modules['monet.gui'] = fake_gui + sys.modules["monet"] = fake_monet + sys.modules["monet.gui"] = fake_gui from PycroFlow.gui.tabs.monet_tab import MonetTab + tab = MonetTab() - tab.set_setup('Mercury') + tab.set_setup("Mercury") # No auto-connect: initial_microscope is NOT passed (avoids fighting # PycroFlow's IlluminationSystem for the laser COM port). self.assertIsNone(tab._monet_window.initial_microscope) # The scope is pre-selected for display. self.assertEqual( - tab._monet_window._scope_combo.currentText(), 'Mercury') + tab._monet_window._scope_combo.currentText(), "Mercury" + ) @unittest.skipUnless(_HAVE_PYQT6, "PyQt6 not installed") @@ -1314,20 +1589,24 @@ class TestFluidTab(unittest.TestCase): @classmethod def setUpClass(cls): from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) def setUp(self): from PycroFlow.gui.widgets import worker + worker.set_synchronous(True) def tearDown(self): from PycroFlow.gui.widgets import worker + worker.set_synchronous(False) def _tab(self): from unittest.mock import MagicMock from PycroFlow.gui.tabs.fluid_tab import FluidTab - svc = MagicMock(name='system_service') + + svc = MagicMock(name="system_service") svc.fluid_system = object() return FluidTab(svc), svc @@ -1339,58 +1618,76 @@ def test_fill_calls_service(self): def test_clean_confirm_yes_calls_service(self): from unittest.mock import patch from PycroFlow.gui.tabs import fluid_tab as ft + tab, svc = self._tab() - with patch.object(ft.QMessageBox, 'question', - return_value=ft.QMessageBox.StandardButton.Yes): + with patch.object( + ft.QMessageBox, + "question", + return_value=ft.QMessageBox.StandardButton.Yes, + ): tab._on_clean() svc.clean_tubings.assert_called_once() def test_clean_confirm_no_does_nothing(self): from unittest.mock import patch from PycroFlow.gui.tabs import fluid_tab as ft + tab, svc = self._tab() - with patch.object(ft.QMessageBox, 'question', - return_value=ft.QMessageBox.StandardButton.No): + with patch.object( + ft.QMessageBox, + "question", + return_value=ft.QMessageBox.StandardButton.No, + ): tab._on_clean() svc.clean_tubings.assert_not_called() def test_stroke_calls_manual_pump(self): tab, svc = self._tab() - tab.stroke_pump.setCurrentText('pump_a') - tab.stroke_vol.setText('150') - tab.stroke_vel.setText('200') - tab.stroke_pickup.setCurrentText('in') - tab.stroke_dispense.setCurrentText('out') + tab.stroke_pump.setCurrentText("pump_a") + tab.stroke_vol.setText("150") + tab.stroke_vel.setText("200") + tab.stroke_pickup.setCurrentText("in") + tab.stroke_dispense.setCurrentText("out") tab._on_stroke() svc.manual_pump.assert_called_once_with( - 'pump_a', vol=150.0, pickup_dir='in', dispense_dir='out', - velocity=200.0) + "pump_a", + vol=150.0, + pickup_dir="in", + dispense_dir="out", + velocity=200.0, + ) def test_move_includes_reservoirs(self): tab, svc = self._tab() - tab.move_pump.setCurrentText('pump_a') - tab.move_vol.setText('80') - tab.move_pickup_res.setText('5') - tab.move_dispense_res.setText('7') - tab.move_pickup_dir.setCurrentText('in') - tab.move_dispense_dir.setCurrentText('in') + tab.move_pump.setCurrentText("pump_a") + tab.move_vol.setText("80") + tab.move_pickup_res.setText("5") + tab.move_dispense_res.setText("7") + tab.move_pickup_dir.setCurrentText("in") + tab.move_dispense_dir.setCurrentText("in") tab._on_move() svc.manual_pump.assert_called_once_with( - 'pump_a', vol=80.0, pickup_dir='in', dispense_dir='in', - pickup_res=5, dispense_res=7) + "pump_a", + vol=80.0, + pickup_dir="in", + dispense_dir="in", + pickup_res=5, + dispense_res=7, + ) def test_set_valves_calls_service(self): tab, svc = self._tab() - tab.valve_res.setText('3') + tab.valve_res.setText("3") tab._on_set_valves() svc.set_valves.assert_called_once_with(3) def test_set_valves_required_empty_warns(self): from unittest.mock import patch from PycroFlow.gui.tabs import fluid_tab as ft + tab, svc = self._tab() - tab.valve_res.setText('') - with patch.object(ft.QMessageBox, 'warning') as warn: + tab.valve_res.setText("") + with patch.object(ft.QMessageBox, "warning") as warn: tab._on_set_valves() warn.assert_called_once() svc.set_valves.assert_not_called() @@ -1407,10 +1704,12 @@ class TestWorker(unittest.TestCase): @classmethod def setUpClass(cls): from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) def _pump_until(self, predicate, timeout=5.0): import time + deadline = time.time() + timeout while not predicate() and time.time() < deadline: self.app.processEvents() @@ -1420,6 +1719,7 @@ def test_runs_off_thread_and_calls_on_done(self): from PyQt6.QtWidgets import QWidget from PyQt6.QtCore import QThread from PycroFlow.gui.widgets import worker + worker.set_synchronous(False) owner = QWidget() results = [] @@ -1438,6 +1738,7 @@ def work(): def test_on_error_called_for_exception(self): from PyQt6.QtWidgets import QWidget from PycroFlow.gui.widgets import worker + worker.set_synchronous(False) owner = QWidget() errors = [] @@ -1450,5 +1751,5 @@ def boom(): self.assertIsInstance(errors[0], RuntimeError) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_timing.py b/PycroFlow/tests/test_timing.py index a0fc546..141e345 100644 --- a/PycroFlow/tests/test_timing.py +++ b/PycroFlow/tests/test_timing.py @@ -1,5 +1,6 @@ """Tests for the Run Sequence duration estimates (:mod:`PycroFlow.protocols.timing`).""" + import os import unittest @@ -14,9 +15,9 @@ ) from PycroFlow.schemas import validate_experiment_design - _EXAMPLE = os.path.join( - os.path.dirname(PycroFlow.__file__), 'examples', 'sph_resi_6plex.yaml') + os.path.dirname(PycroFlow.__file__), "examples", "sph_resi_6plex.yaml" +) def _example_protocol(): @@ -24,7 +25,8 @@ def _example_protocol(): with open(_EXAMPLE) as f: design = validate_experiment_design(yaml.safe_load(f)).model_dump( - by_alias=True) + by_alias=True + ) return ProtocolBuilder().build_protocol(design) @@ -33,48 +35,52 @@ class TestEntryDuration(unittest.TestCase): def test_acquire_is_frames_times_exposure(self): # 1000 frames * 100 ms = 100 s. d = estimate_entry_duration( - {'$type': 'acquire', 'frames': 1000, 't_exp': 100}) + {"$type": "acquire", "frames": 1000, "t_exp": 100} + ) self.assertAlmostEqual(d, 100.0) def test_incubate_uses_duration(self): self.assertAlmostEqual( - estimate_entry_duration({'$type': 'incubate', 'duration': 42}), - 42.0) + estimate_entry_duration({"$type": "incubate", "duration": 42}), + 42.0, + ) # Strings are accepted (orchestration coerces with float()). self.assertAlmostEqual( - estimate_entry_duration({'$type': 'incubate', 'duration': '12'}), - 12.0) + estimate_entry_duration({"$type": "incubate", "duration": "12"}), + 12.0, + ) def test_inject_uses_volume_over_velocity(self): # 120 * 500 / 1000 = 60 s, plus delays. d = estimate_entry_duration( - {'$type': 'inject', 'volume': 500, 'velocity': 1000}) + {"$type": "inject", "volume": 500, "velocity": 1000} + ) self.assertAlmostEqual(d, 60.0) def test_inject_falls_back_to_max_velocity(self): d = estimate_entry_duration( - {'$type': 'inject', 'volume': 500}, - {'max_velocity': 1000}) + {"$type": "inject", "volume": 500}, {"max_velocity": 1000} + ) self.assertAlmostEqual(d, 60.0) def test_inject_adds_equilibration_delays(self): d = estimate_entry_duration( - {'$type': 'inject', 'volume': 500, 'velocity': 1000, 'delay': 5}, - {'inject_in_to_out_delay': 3, 'inject_out_to_in_delay': 2}) + {"$type": "inject", "volume": 500, "velocity": 1000, "delay": 5}, + {"inject_in_to_out_delay": 3, "inject_out_to_in_delay": 2}, + ) self.assertAlmostEqual(d, 60.0 + 3 + 2 + 2 * 5) def test_coordination_and_instant_steps_are_zero(self): for entry in ( - {'$type': 'signal', 'value': 'x'}, - {'$type': 'wait for signal', 'target': 'img', 'value': 'x'}, - {'$type': 'set power', 'laser': 1, 'power': 10}, - {'$type': 'flush', 'flushfactor': 1}, + {"$type": "signal", "value": "x"}, + {"$type": "wait for signal", "target": "img", "value": "x"}, + {"$type": "set power", "laser": 1, "power": 10}, + {"$type": "flush", "flushfactor": 1}, ): self.assertEqual(estimate_entry_duration(entry), 0.0) def test_missing_params_are_zero_not_error(self): - self.assertEqual( - estimate_entry_duration({'$type': 'inject'}), 0.0) + self.assertEqual(estimate_entry_duration({"$type": "inject"}), 0.0) self.assertEqual(estimate_entry_duration(None), 0.0) @@ -83,31 +89,29 @@ class TestProtocolTotals(unittest.TestCase): def test_durations_align_with_entries(self): proto = _example_protocol() durs = estimate_durations(proto) - for system in ('fluid', 'img', 'illu'): + for system in ("fluid", "img", "illu"): self.assertIn(system, durs) self.assertEqual( - len(durs[system]), - len(proto[system]['protocol_entries'])) + len(durs[system]), len(proto[system]["protocol_entries"]) + ) def test_total_is_positive_and_sums_subsystems(self): proto = _example_protocol() durs = estimate_durations(proto) total = estimate_total_duration(proto) self.assertGreater(total, 0) - self.assertAlmostEqual( - total, sum(sum(v) for v in durs.values())) + self.assertAlmostEqual(total, sum(sum(v) for v in durs.values())) def test_remaining_decreases_as_steps_complete(self): durs = estimate_durations(_example_protocol()) at_start = {s: (0, len(v)) for s, v in durs.items()} at_end = {s: (len(v), len(v)) for s, v in durs.items()} - self.assertGreater( - estimate_remaining(durs, at_start), 0) - self.assertAlmostEqual( - estimate_remaining(durs, at_end), 0.0) + self.assertGreater(estimate_remaining(durs, at_start), 0) + self.assertAlmostEqual(estimate_remaining(durs, at_end), 0.0) self.assertGreaterEqual( estimate_remaining(durs, at_start), - estimate_remaining(durs, at_end)) + estimate_remaining(durs, at_end), + ) def test_empty_protocol_is_zero(self): self.assertEqual(estimate_total_duration({}), 0) @@ -117,13 +121,13 @@ def test_empty_protocol_is_zero(self): class TestFormatDuration(unittest.TestCase): def test_formats(self): - self.assertEqual(format_duration(0), '0s') - self.assertEqual(format_duration(-5), '0s') - self.assertEqual(format_duration(45), '45s') - self.assertEqual(format_duration(90), '1m') - self.assertEqual(format_duration(3700), '1h 1m') - self.assertEqual(format_duration(90000), '1d 1h') + self.assertEqual(format_duration(0), "0s") + self.assertEqual(format_duration(-5), "0s") + self.assertEqual(format_duration(45), "45s") + self.assertEqual(format_duration(90), "1m") + self.assertEqual(format_duration(3700), "1h 1m") + self.assertEqual(format_duration(90000), "1d 1h") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/PycroFlow/tests/test_util.py b/PycroFlow/tests/test_util.py index 5f7cd2d..6c2db33 100644 --- a/PycroFlow/tests/test_util.py +++ b/PycroFlow/tests/test_util.py @@ -1,4 +1,5 @@ """Tests for PycroFlow.util (time formatting, progress bar, MM singleton).""" + import io import unittest from contextlib import redirect_stdout @@ -10,12 +11,12 @@ class FmtTimeDeltaTest(unittest.TestCase): def test_zero_is_blank_padded_to_width(self): out = util.fmt_time_delta(0, width=10) - self.assertEqual(out, ' ' * 10) + self.assertEqual(out, " " * 10) def test_contains_unit_snippets(self): out = util.fmt_time_delta(65) - self.assertIn('min', out) - self.assertIn('s', out) + self.assertIn("min", out) + self.assertIn("s", out) def test_truncated_to_width(self): # A large delta produces a long string that must be clipped to width. @@ -30,17 +31,17 @@ class ProgressBarTest(unittest.TestCase): def test_progress_and_end_do_not_raise(self): buf = io.StringIO() with redirect_stdout(buf): - pb = util.ProgressBar('Acq', 10) + pb = util.ProgressBar("Acq", 10) pb.progress(0.5) pb.progress(1) # the x==1 branch (chardeci becomes '') pb.end_progress() # The title is printed on construction and at the end. - self.assertIn('Acq', buf.getvalue()) + self.assertIn("Acq", buf.getvalue()) def test_progress_increment_counts(self): buf = io.StringIO() with redirect_stdout(buf): - pb = util.ProgressBar('Acq', 4) + pb = util.ProgressBar("Acq", 4) pb.progress_increment() pb.progress_increment() self.assertEqual(pb.nimgs_acquired, 2) @@ -60,16 +61,18 @@ def tearDown(self): util.PyMgrSingleton._PyMgrSingleton__instance = None def test_get_core_caches_single_instance(self): - with patch('PycroFlow.util.Core', - return_value=MagicMock(name='Core')) as core_cls: + with patch( + "PycroFlow.util.Core", return_value=MagicMock(name="Core") + ) as core_cls: a = util.PyMgrSingleton.get_core() b = util.PyMgrSingleton.get_core() self.assertIs(a, b) core_cls.assert_called_once() def test_get_studio_caches_single_instance(self): - with patch('PycroFlow.util.Studio', - return_value=MagicMock(name='Studio')) as studio_cls: + with patch( + "PycroFlow.util.Studio", return_value=MagicMock(name="Studio") + ) as studio_cls: a = util.PyMgrSingleton.get_studio() b = util.PyMgrSingleton.get_studio() self.assertIs(a, b) @@ -81,5 +84,5 @@ def test_direct_second_instantiation_rejected(self): util.PyMgrSingleton() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/conftest.py b/conftest.py index 7e09ed9..d092925 100644 --- a/conftest.py +++ b/conftest.py @@ -4,6 +4,7 @@ mocks are also installed from ``PycroFlow/tests/__init__.py`` so unittest discovery works too — keep both paths in sync. """ + from PycroFlow.tests._mock_hardware import install_hardware_mocks install_hardware_mocks() diff --git a/docs/confluence/upload_to_confluence.py b/docs/confluence/upload_to_confluence.py index 832914f..e18a05d 100644 --- a/docs/confluence/upload_to_confluence.py +++ b/docs/confluence/upload_to_confluence.py @@ -21,6 +21,7 @@ To upload only one page, pass its file as an argument: python docs/confluence/upload_to_confluence.py pycroflow-overview.confluence.html """ + import base64 import json import os @@ -61,8 +62,9 @@ def _request(method, url, token_header, payload=None): def _find_page(base, auth, space, title): - q = urllib.parse.urlencode({ - "title": title, "spaceKey": space, "expand": "version"}) + q = urllib.parse.urlencode( + {"title": title, "spaceKey": space, "expand": "version"} + ) res = _request("GET", "{}/rest/api/content?{}".format(base, q), auth) results = res.get("results", []) return results[0] if results else None @@ -76,23 +78,32 @@ def upload(base, auth, space, parent, fname, title): page_id = existing["id"] version = existing["version"]["number"] + 1 payload = { - "id": page_id, "type": "page", "title": title, + "id": page_id, + "type": "page", + "title": title, "space": {"key": space}, "body": {"storage": storage}, "version": {"number": version}, } - _request("PUT", "{}/rest/api/content/{}".format(base, page_id), - auth, payload) + _request( + "PUT", + "{}/rest/api/content/{}".format(base, page_id), + auth, + payload, + ) print("updated '{}' (v{}, id {})".format(title, version, page_id)) else: payload = { - "type": "page", "title": title, + "type": "page", + "title": title, "space": {"key": space}, "body": {"storage": storage}, } if parent: payload["ancestors"] = [{"id": parent}] - res = _request("POST", "{}/rest/api/content".format(base), auth, payload) + res = _request( + "POST", "{}/rest/api/content".format(base), auth, payload + ) print("created '{}' (id {})".format(title, res["id"])) @@ -103,14 +114,19 @@ def main(argv): space = _env("CONFLUENCE_SPACE") parent = os.environ.get("CONFLUENCE_PARENT") - auth = "Basic " + base64.b64encode( - "{}:{}".format(email, token).encode()).decode() + auth = ( + "Basic " + + base64.b64encode("{}:{}".format(email, token).encode()).decode() + ) files = argv[1:] or list(PAGES) for fname in files: if fname not in PAGES: - sys.exit("Unknown page file: {} (known: {})".format( - fname, ", ".join(PAGES))) + sys.exit( + "Unknown page file: {} (known: {})".format( + fname, ", ".join(PAGES) + ) + ) upload(base, auth, space, parent, fname, PAGES[fname]) diff --git a/example_experiment/start_experiment_240119.py b/example_experiment/start_experiment_240119.py index c15c9ad..c9a5d91 100644 --- a/example_experiment/start_experiment_240119.py +++ b/example_experiment/start_experiment_240119.py @@ -4,8 +4,8 @@ import os ################ CHANGE EXPERIMENT SETTINGS HERE ################ -'''Set the name of the experiment. This will be the base folder.''' -experiment_name = 'SKBR3_6plex' +"""Set the name of the experiment. This will be the base folder.""" +experiment_name = "SKBR3_6plex" # volume settings wash_volume = 2000 # ul @@ -13,20 +13,26 @@ volume_reduction_for_xchg = 130 # ul # fluidics settings -wash_buffer = 'PBS' +wash_buffer = "PBS" -'''Set at which tubing number to find which solution.''' +"""Set at which tubing number to find which solution.""" reservoir_names = { - 1: 'EGFR', 2: '5T4', 3: 'cMet', 4: 'AXL', 5: 'B7H3', 6: wash_buffer} + 1: "EGFR", + 2: "5T4", + 3: "cMet", + 4: "AXL", + 5: "B7H3", + 6: wash_buffer, +} -'''If an imager is already present in the sample and ready for imaging +"""If an imager is already present in the sample and ready for imaging without prior fluid exchange, write its name here. Otherwise, set -to None.''' -initial_target = 'HER3' +to None.""" +initial_target = "HER3" -'''Set the sequence in which the targets should be imaged. Make sure -that all these names match those in reservoir_names.''' -target_sequence = ['EGFR', '5T4', 'AXL', 'B7H3', 'cMet'] +"""Set the sequence in which the targets should be imaged. Make sure +that all these names match those in reservoir_names.""" +target_sequence = ["EGFR", "5T4", "AXL", "B7H3", "cMet"] # imaging settings exposure_time = 75 # ms @@ -35,166 +41,178 @@ # illumination settings laser = 560 -sample_power = 30 #mW +sample_power = 30 # mW ############# NO NEED TO CHANGE ANYTHING BELOW HERE ############## reservoir_a_connections = [ - {'id': 1, 'valve_pos': {1: 6}}, - {'id': 2, 'valve_pos': {1: 7}}, - {'id': 3, 'valve_pos': {1: 8}}, - {'id': 4, 'valve_pos': {1: 1}}, - {'id': 5, 'valve_pos': {1: 2}}, - {'id': 6, 'valve_pos': {2: 1, 1: 5}}, - {'id': 7, 'valve_pos': {2: 2, 1: 5}}, - {'id': 8, 'valve_pos': {2: 3, 1: 5}}, - {'id': 9, 'valve_pos': {2: 4, 1: 5}}, - {'id': 10, 'valve_pos': {2: 5, 1: 5}}, - {'id': 11, 'valve_pos': {2: 6, 1: 5}}, - {'id': 12, 'valve_pos': {2: 7, 1: 5}}, - {'id': 13, 'valve_pos': {2: 8, 1: 5}}, - ] - + {"id": 1, "valve_pos": {1: 6}}, + {"id": 2, "valve_pos": {1: 7}}, + {"id": 3, "valve_pos": {1: 8}}, + {"id": 4, "valve_pos": {1: 1}}, + {"id": 5, "valve_pos": {1: 2}}, + {"id": 6, "valve_pos": {2: 1, 1: 5}}, + {"id": 7, "valve_pos": {2: 2, 1: 5}}, + {"id": 8, "valve_pos": {2: 3, 1: 5}}, + {"id": 9, "valve_pos": {2: 4, 1: 5}}, + {"id": 10, "valve_pos": {2: 5, 1: 5}}, + {"id": 11, "valve_pos": {2: 6, 1: 5}}, + {"id": 12, "valve_pos": {2: 7, 1: 5}}, + {"id": 13, "valve_pos": {2: 8, 1: 5}}, +] + resa_conn_present = [] for res_used in reservoir_names.keys(): used_conn = [ - resa_conn for resa_conn in reservoir_a_connections - if resa_conn['id'] == res_used] + resa_conn + for resa_conn in reservoir_a_connections + if resa_conn["id"] == res_used + ] if len(used_conn) == 1: used_conn = used_conn[0] else: - raise KeyError('Used reservoirs must be specified exactly once!') + raise KeyError("Used reservoirs must be specified exactly once!") resa_conn_present.append(used_conn) hamilton_config = { - 'interface': { - 'COM': '43', - 'baud': 9600}, - 'system_type': 'legacy', - 'valve_a': [ - {'address': 2, 'instrument_type': 'MVP', 'valve_type': '8-5'}], - 'pump_a': { - 'address': 1, 'instrument_type': '4', 'valve_type': '8-5', - 'syringe': '500u', 'input_pos': None, 'output_pos': 4, - 'motorsteps_per_step': 2}, # OEM/high force version internally counts in half-steps (total 6000 steps per stroke), lab version with full steps (3000 steps per stroke) - 'flush_pos': {'flush': 3, 'inject': 4}, - 'pump_out': { - 'address': 0, 'instrument_type': '4', 'valve_type': 'Y', - 'syringe': '5.0m', 'input_pos':'out', 'output_pos': 'in', - 'motorsteps_per_step': 2}, - 'reservoir_a': resa_conn_present, - 'special_names': { - 'flushbuffer_a': 6, # defines the reservoir id with the buffer that can be used for flushing should be at the end of multiple MVPs - 'rbs': 11, - 'ipa': 12, - 'h2o': 13, - 'empty': 14 - }, + "interface": {"COM": "43", "baud": 9600}, + "system_type": "legacy", + "valve_a": [{"address": 2, "instrument_type": "MVP", "valve_type": "8-5"}], + "pump_a": { + "address": 1, + "instrument_type": "4", + "valve_type": "8-5", + "syringe": "500u", + "input_pos": None, + "output_pos": 4, + "motorsteps_per_step": 2, + }, # OEM/high force version internally counts in half-steps (total 6000 steps per stroke), lab version with full steps (3000 steps per stroke) + "flush_pos": {"flush": 3, "inject": 4}, + "pump_out": { + "address": 0, + "instrument_type": "4", + "valve_type": "Y", + "syringe": "5.0m", + "input_pos": "out", + "output_pos": "in", + "motorsteps_per_step": 2, + }, + "reservoir_a": resa_conn_present, + "special_names": { + "flushbuffer_a": 6, # defines the reservoir id with the buffer that can be used for flushing should be at the end of multiple MVPs + "rbs": 11, + "ipa": 12, + "h2o": 13, + "empty": 14, + }, } tubing_config = { - ('R13', 'V2'): 20, - ('R12', 'V2'): 20, - ('R11', 'V2'): 20, - ('R10', 'V2'): 20, - ('R9', 'V2'): 20, - ('R8', 'V2'): 20, - ('R7', 'V2'): 20, - ('R6', 'V2'): 20, - ('R5', 'pump_a'): 20, - ('R4', 'pump_a'): 20, - ('R3', 'pump_a'): 20, - ('R2', 'pump_a'): 20, - ('R1', 'pump_a'): 20, - ('V2', 'pump_a'): 20, - ('pump_a', 'sample'): 70, + ("R13", "V2"): 20, + ("R12", "V2"): 20, + ("R11", "V2"): 20, + ("R10", "V2"): 20, + ("R9", "V2"): 20, + ("R8", "V2"): 20, + ("R7", "V2"): 20, + ("R6", "V2"): 20, + ("R5", "pump_a"): 20, + ("R4", "pump_a"): 20, + ("R3", "pump_a"): 20, + ("R2", "pump_a"): 20, + ("R1", "pump_a"): 20, + ("V2", "pump_a"): 20, + ("pump_a", "sample"): 70, } imaging_config = { - 'save_dir': r'.', - 'use_positions': use_mm_positions, - 'psf_pars': { # for Crick - 'tag_pfs': 'PFS', - 'tag_zdrive': 'ZDrive', - 'tag_status': 'PFS', - 'prop_state': 'PFS in Range', - 'prop_status': 'PFS Status', - 'deltat': 10} + "save_dir": r".", + "use_positions": use_mm_positions, + "psf_pars": { # for Crick + "tag_pfs": "PFS", + "tag_zdrive": "ZDrive", + "tag_status": "PFS", + "prop_state": "PFS in Range", + "prop_status": "PFS Status", + "deltat": 10, + }, } fluid = { - 'parameters': { - 'start_velocity': 50, - 'max_velocity': 200, - 'stop_velocity': 50, - 'pumpout_dispense_velocity': 300, - 'clean_velocity': 1500, - 'mode': 'tubing_ignore', # 'tubing_stack' or 'tubing_flush' or 'tubing_ignore' - 'extractionfactor': 4}, - 'settings': { - 'vol_wash_pre': int(0.1 * wash_volume), # in ul - 'vol_wash': int(0.9 * wash_volume), # in ul - 'vol_imager_pre': int(0.9 * imager_volume), # in ul - 'vol_imager_post': int(0.1 * imager_volume), # in ul - 'vol_remove_before_wash': volume_reduction_for_xchg, - 'wait_after_pickup': 5, - 'reservoir_names': reservoir_names, - 'experiment' : { - 'type': 'Exchange', # options: ['Exchange', 'MERPAINT', 'FlushTest'] - 'wash_buffer': wash_buffer, - 'imagers': target_sequence, - 'initial_imager': initial_target} - } + "parameters": { + "start_velocity": 50, + "max_velocity": 200, + "stop_velocity": 50, + "pumpout_dispense_velocity": 300, + "clean_velocity": 1500, + "mode": "tubing_ignore", # 'tubing_stack' or 'tubing_flush' or 'tubing_ignore' + "extractionfactor": 4, + }, + "settings": { + "vol_wash_pre": int(0.1 * wash_volume), # in ul + "vol_wash": int(0.9 * wash_volume), # in ul + "vol_imager_pre": int(0.9 * imager_volume), # in ul + "vol_imager_post": int(0.1 * imager_volume), # in ul + "vol_remove_before_wash": volume_reduction_for_xchg, + "wait_after_pickup": 5, + "reservoir_names": reservoir_names, + "experiment": { + "type": "Exchange", # options: ['Exchange', 'MERPAINT', 'FlushTest'] + "wash_buffer": wash_buffer, + "imagers": target_sequence, + "initial_imager": initial_target, + }, + }, } imaging = { - 'parameters': { # general parameters for the imaging system - 'show_progress': True, - 'show_display': True, - 'close_display_after_acquisition': True, - }, - 'settings': { # settings for protocol generation - 'frames': n_frames, - 'darkframes': 50, - 't_exp': exposure_time, # in ms - } + "parameters": { # general parameters for the imaging system + "show_progress": True, + "show_display": True, + "close_display_after_acquisition": True, + }, + "settings": { # settings for protocol generation + "frames": n_frames, + "darkframes": 50, + "t_exp": exposure_time, # in ms + }, } illumination = { - 'parameters': { # general parameters for the illumination system - 'setup': 'Crick', + "parameters": { # general parameters for the illumination system + "setup": "Crick", # 'channel_group': 'Filter turret', # 'filter': '2-G561', # 'ROI': [512, 512, 512, 512] - }, - 'settings': { # settings for protocol generation - 'laser': laser, - 'power_acq': sample_power, #mW - 'power_nonacq': 1, - 'warmup_delay': 5, - 'shutter_off_nonacq': True, - } + }, + "settings": { # settings for protocol generation + "laser": laser, + "power_acq": sample_power, # mW + "power_nonacq": 1, + "warmup_delay": 5, + "shutter_off_nonacq": True, + }, } flow_acq_config = { - 'save_dir': r'.', - 'base_name': experiment_name, - 'fluid': fluid, - 'img': imaging, - 'illu': illumination, # comment out for non-automated illumination + "save_dir": r".", + "base_name": experiment_name, + "fluid": fluid, + "img": imaging, + "illu": illumination, # comment out for non-automated illumination } -if __name__ == '__main__': +if __name__ == "__main__": pb = ProtocolBuilder() protocol_fname, _ = pb.create_protocol(flow_acq_config) - imaging_config['base_name'] = os.path.splitext(protocol_fname)[0] + imaging_config["base_name"] = os.path.splitext(protocol_fname)[0] pfi = PycroFlowInteractive() pfi.do_load_hamilton(hamilton_config, tubing_config) pfi.do_load_imaging(imaging_config) pfi.do_load_protocol(protocol_fname) - pfi.cmdloop() \ No newline at end of file + pfi.cmdloop() diff --git a/example_experiment/start_experiment_240202.py b/example_experiment/start_experiment_240202.py index 29f5eba..29a9ee2 100644 --- a/example_experiment/start_experiment_240202.py +++ b/example_experiment/start_experiment_240202.py @@ -4,8 +4,8 @@ import os ################ CHANGE EXPERIMENT SETTINGS HERE ################ -'''Set the name of the experiment. This will be the base folder.''' -experiment_name = 'CHO_test' +"""Set the name of the experiment. This will be the base folder.""" +experiment_name = "CHO_test" # volume settings wash_volume = 2000 # ul @@ -13,20 +13,19 @@ volume_reduction_for_xchg = 400 # ul # fluidics settings -wash_buffer = 'PBS' +wash_buffer = "PBS" -'''Set at which tubing number to find which solution.''' -reservoir_names = { - 1: 'EGFP', 2: 'ALFA', 6: wash_buffer} +"""Set at which tubing number to find which solution.""" +reservoir_names = {1: "EGFP", 2: "ALFA", 6: wash_buffer} -'''If an imager is already present in the sample and ready for imaging +"""If an imager is already present in the sample and ready for imaging without prior fluid exchange, write its name here. Otherwise, set -to None.''' -initial_target = 'CD86' +to None.""" +initial_target = "CD86" -'''Set the sequence in which the targets should be imaged. Make sure -that all these names match those in reservoir_names.''' -target_sequence = ['EGFP', 'ALFA'] +"""Set the sequence in which the targets should be imaged. Make sure +that all these names match those in reservoir_names.""" +target_sequence = ["EGFP", "ALFA"] # imaging settings exposure_time = 100 # ms @@ -35,168 +34,182 @@ # illumination settings laser = 560 -sample_power = 30 #mW +sample_power = 30 # mW ############# NO NEED TO CHANGE ANYTHING BELOW HERE ############## reservoir_a_connections = [ - {'id': 1, 'valve_pos': {1: 6}}, - {'id': 2, 'valve_pos': {1: 7}}, - {'id': 3, 'valve_pos': {1: 8}}, - {'id': 4, 'valve_pos': {1: 1}}, - {'id': 5, 'valve_pos': {1: 2}}, - {'id': 6, 'valve_pos': {2: 1, 1: 5}}, - {'id': 7, 'valve_pos': {2: 2, 1: 5}}, - {'id': 8, 'valve_pos': {2: 3, 1: 5}}, - {'id': 9, 'valve_pos': {2: 4, 1: 5}}, - {'id': 10, 'valve_pos': {2: 5, 1: 5}}, - {'id': 11, 'valve_pos': {2: 6, 1: 5}}, - {'id': 12, 'valve_pos': {2: 7, 1: 5}}, - {'id': 13, 'valve_pos': {2: 8, 1: 5}}, - ] - + {"id": 1, "valve_pos": {1: 6}}, + {"id": 2, "valve_pos": {1: 7}}, + {"id": 3, "valve_pos": {1: 8}}, + {"id": 4, "valve_pos": {1: 1}}, + {"id": 5, "valve_pos": {1: 2}}, + {"id": 6, "valve_pos": {2: 1, 1: 5}}, + {"id": 7, "valve_pos": {2: 2, 1: 5}}, + {"id": 8, "valve_pos": {2: 3, 1: 5}}, + {"id": 9, "valve_pos": {2: 4, 1: 5}}, + {"id": 10, "valve_pos": {2: 5, 1: 5}}, + {"id": 11, "valve_pos": {2: 6, 1: 5}}, + {"id": 12, "valve_pos": {2: 7, 1: 5}}, + {"id": 13, "valve_pos": {2: 8, 1: 5}}, +] + resa_conn_present = [] for res_used in reservoir_names.keys(): used_conn = [ - resa_conn for resa_conn in reservoir_a_connections - if resa_conn['id'] == res_used] + resa_conn + for resa_conn in reservoir_a_connections + if resa_conn["id"] == res_used + ] if len(used_conn) == 1: used_conn = used_conn[0] else: - raise KeyError('Used reservoirs must be specified exactly once!') + raise KeyError("Used reservoirs must be specified exactly once!") resa_conn_present.append(used_conn) hamilton_config = { - 'interface': { - 'COM': '43', - 'baud': 9600}, - 'system_type': 'legacy', - 'valve_a': [ - {'address': 2, 'instrument_type': 'MVP', 'valve_type': '8-5'}], - 'pump_a': { - 'address': 1, 'instrument_type': '4', 'valve_type': '8-5', - 'syringe': '500u', 'input_pos': None, 'output_pos': 4, 'waste_pos': 3, - 'motorsteps_per_step': 2}, # OEM/high force version internally counts in half-steps (total 6000 steps per stroke), lab version with full steps (3000 steps per stroke) - 'flush_pos': {'flush': 3, 'inject': 4}, - 'pump_out': { - 'address': 0, 'instrument_type': '4', 'valve_type': 'Y', - 'syringe': '5.0m', 'input_pos': 'out', 'output_pos': 'in', 'waste_pos': 'in', - 'motorsteps_per_step': 2}, - 'reservoir_a': resa_conn_present, - 'special_names': { - 'flushbuffer_a': 6, # defines the reservoir id with the buffer that can be used for flushing should be at the end of multiple MVPs + "interface": {"COM": "43", "baud": 9600}, + "system_type": "legacy", + "valve_a": [{"address": 2, "instrument_type": "MVP", "valve_type": "8-5"}], + "pump_a": { + "address": 1, + "instrument_type": "4", + "valve_type": "8-5", + "syringe": "500u", + "input_pos": None, + "output_pos": 4, + "waste_pos": 3, + "motorsteps_per_step": 2, + }, # OEM/high force version internally counts in half-steps (total 6000 steps per stroke), lab version with full steps (3000 steps per stroke) + "flush_pos": {"flush": 3, "inject": 4}, + "pump_out": { + "address": 0, + "instrument_type": "4", + "valve_type": "Y", + "syringe": "5.0m", + "input_pos": "out", + "output_pos": "in", + "waste_pos": "in", + "motorsteps_per_step": 2, + }, + "reservoir_a": resa_conn_present, + "special_names": { + "flushbuffer_a": 6, # defines the reservoir id with the buffer that can be used for flushing should be at the end of multiple MVPs # 'rbs': 11, # 'ipa': 12, # 'h2o': 13, # 'empty': 14 - }, + }, } tubing_config = { - ('R13', 'V2'): 20, - ('R12', 'V2'): 20, - ('R11', 'V2'): 20, - ('R10', 'V2'): 20, - ('R9', 'V2'): 20, - ('R8', 'V2'): 20, - ('R7', 'V2'): 20, - ('R6', 'V2'): 20, - ('R5', 'pump_a'): 20, - ('R4', 'pump_a'): 20, - ('R3', 'pump_a'): 20, - ('R2', 'pump_a'): 20, - ('R1', 'pump_a'): 20, - ('V2', 'pump_a'): 20, - ('pump_a', 'flush_waste'): 70, - ('pump_a', 'sample'): 70, + ("R13", "V2"): 20, + ("R12", "V2"): 20, + ("R11", "V2"): 20, + ("R10", "V2"): 20, + ("R9", "V2"): 20, + ("R8", "V2"): 20, + ("R7", "V2"): 20, + ("R6", "V2"): 20, + ("R5", "pump_a"): 20, + ("R4", "pump_a"): 20, + ("R3", "pump_a"): 20, + ("R2", "pump_a"): 20, + ("R1", "pump_a"): 20, + ("V2", "pump_a"): 20, + ("pump_a", "flush_waste"): 70, + ("pump_a", "sample"): 70, } imaging_config = { - 'save_dir': r'.', - 'use_positions': use_mm_positions, - 'pfs_pars': { # for Crick - 'tag_pfs': 'PFS', - 'tag_zdrive': 'ZDrive', - 'tag_status': 'PFS', - 'prop_state': 'PFS in Range', - 'prop_status': 'PFS Status', - 'deltat': 10} + "save_dir": r".", + "use_positions": use_mm_positions, + "pfs_pars": { # for Crick + "tag_pfs": "PFS", + "tag_zdrive": "ZDrive", + "tag_status": "PFS", + "prop_state": "PFS in Range", + "prop_status": "PFS Status", + "deltat": 10, + }, } fluid = { - 'parameters': { - 'start_velocity': 500, # ul/min - 'max_velocity': 2000, - 'stop_velocity': 500, - 'pumpout_dispense_velocity': 3000, - 'clean_velocity': 3000, - 'clean_delay': 10, # seconds of delay between pickup and dispense - 'mode': 'tubing_ignore', # 'tubing_stack' or 'tubing_flush' or 'tubing_ignore' - 'extractionfactor': 4}, - 'settings': { - 'vol_wash_pre': int(0.1 * wash_volume), # in ul - 'vol_wash': int(0.9 * wash_volume), # in ul - 'vol_imager_pre': int(0.9 * imager_volume), # in ul - 'vol_imager_post': int(0.1 * imager_volume), # in ul - 'vol_remove_before_wash': volume_reduction_for_xchg, - 'wait_after_pickup': 5, - 'reservoir_names': reservoir_names, - 'experiment' : { - 'type': 'Exchange', # options: ['Exchange', 'MERPAINT', 'FlushTest'] - 'wash_buffer': wash_buffer, - 'imagers': target_sequence, - 'initial_imager': initial_target} - } + "parameters": { + "start_velocity": 500, # ul/min + "max_velocity": 2000, + "stop_velocity": 500, + "pumpout_dispense_velocity": 3000, + "clean_velocity": 3000, + "clean_delay": 10, # seconds of delay between pickup and dispense + "mode": "tubing_ignore", # 'tubing_stack' or 'tubing_flush' or 'tubing_ignore' + "extractionfactor": 4, + }, + "settings": { + "vol_wash_pre": int(0.1 * wash_volume), # in ul + "vol_wash": int(0.9 * wash_volume), # in ul + "vol_imager_pre": int(0.9 * imager_volume), # in ul + "vol_imager_post": int(0.1 * imager_volume), # in ul + "vol_remove_before_wash": volume_reduction_for_xchg, + "wait_after_pickup": 5, + "reservoir_names": reservoir_names, + "experiment": { + "type": "Exchange", # options: ['Exchange', 'MERPAINT', 'FlushTest'] + "wash_buffer": wash_buffer, + "imagers": target_sequence, + "initial_imager": initial_target, + }, + }, } imaging = { - 'parameters': { # general parameters for the imaging system - 'show_progress': True, - 'show_display': True, - 'close_display_after_acquisition': True, - }, - 'settings': { # settings for protocol generation - 'frames': n_frames, - 'darkframes': 50, - 't_exp': exposure_time, # in ms - } + "parameters": { # general parameters for the imaging system + "show_progress": True, + "show_display": True, + "close_display_after_acquisition": True, + }, + "settings": { # settings for protocol generation + "frames": n_frames, + "darkframes": 50, + "t_exp": exposure_time, # in ms + }, } illumination = { - 'parameters': { # general parameters for the illumination system - 'setup': 'Crick', + "parameters": { # general parameters for the illumination system + "setup": "Crick", # 'channel_group': 'Filter turret', # 'filter': '2-G561', # 'ROI': [512, 512, 512, 512] - }, - 'settings': { # settings for protocol generation - 'laser': laser, - 'power_acq': sample_power, #mW - 'power_nonacq': 1, - 'warmup_delay': 5, - 'shutter_off_nonacq': True, - } + }, + "settings": { # settings for protocol generation + "laser": laser, + "power_acq": sample_power, # mW + "power_nonacq": 1, + "warmup_delay": 5, + "shutter_off_nonacq": True, + }, } flow_acq_config = { - 'save_dir': r'.', - 'base_name': experiment_name, - 'fluid': fluid, - 'img': imaging, - 'illu': illumination, # comment out for non-automated illumination + "save_dir": r".", + "base_name": experiment_name, + "fluid": fluid, + "img": imaging, + "illu": illumination, # comment out for non-automated illumination } -if __name__ == '__main__': +if __name__ == "__main__": pb = ProtocolBuilder() protocol_fname, _ = pb.create_protocol(flow_acq_config) - imaging_config['base_name'] = os.path.splitext(protocol_fname)[0] + imaging_config["base_name"] = os.path.splitext(protocol_fname)[0] pfi = PycroFlowInteractive() pfi.do_load_hamilton(hamilton_config, tubing_config) pfi.do_load_imaging(imaging_config) pfi.do_load_protocol(protocol_fname) - pfi.cmdloop() \ No newline at end of file + pfi.cmdloop() diff --git a/example_experiment/start_experiment_240223.py b/example_experiment/start_experiment_240223.py index be6cb37..246cf89 100644 --- a/example_experiment/start_experiment_240223.py +++ b/example_experiment/start_experiment_240223.py @@ -4,29 +4,36 @@ import os ################ CHANGE EXPERIMENT SETTINGS HERE ################ -'''Set the name of the experiment. This will be the base folder.''' -experiment_name = 'StdSample' +"""Set the name of the experiment. This will be the base folder.""" +experiment_name = "StdSample" # volume settings wash_volume = 2000 # ul imager_volume = 950 # ul -volume_reduction_for_xchg = 50 # ul ATTENTION: The sample chamber must hold at least this volume above the output needle. +volume_reduction_for_xchg = 50 # ul ATTENTION: The sample chamber must hold at least this volume above the output needle. # fluidics settings -wash_buffer = 'PBS' +wash_buffer = "PBS" -'''Set at which tubing number to find which solution.''' +"""Set at which tubing number to find which solution.""" reservoir_names = { - 1: 'Vim1', 2: 'Tubuli2',3: 'Vim2', 4: 'Tubuli3',5: 'Vim3', 6: wash_buffer,7: 'Tubuli4'} + 1: "Vim1", + 2: "Tubuli2", + 3: "Vim2", + 4: "Tubuli3", + 5: "Vim3", + 6: wash_buffer, + 7: "Tubuli4", +} -'''If an imager is already present in the sample and ready for imaging +"""If an imager is already present in the sample and ready for imaging without prior fluid exchange, write its name here. Otherwise, set -to None.''' -initial_target = 'Tubuli1' +to None.""" +initial_target = "Tubuli1" -'''Set the sequence in which the targets should be imaged. Make sure -that all these names match those in reservoir_names.''' -target_sequence = ['Vim1', 'Tubuli2','Vim2', 'Tubuli3'] +"""Set the sequence in which the targets should be imaged. Make sure +that all these names match those in reservoir_names.""" +target_sequence = ["Vim1", "Tubuli2", "Vim2", "Tubuli3"] # imaging settings exposure_time = 150 # ms @@ -35,174 +42,187 @@ # illumination settings laser = 560 -sample_power = 20 #mW +sample_power = 20 # mW ############# NO NEED TO CHANGE ANYTHING BELOW HERE ############## reservoir_a_connections = [ - {'id': 1, 'valve_pos': {1: 6}}, - {'id': 2, 'valve_pos': {1: 7}}, - {'id': 3, 'valve_pos': {1: 8}}, - {'id': 4, 'valve_pos': {1: 1}}, - {'id': 5, 'valve_pos': {1: 2}}, - {'id': 6, 'valve_pos': {2: 1, 1: 5}}, - {'id': 7, 'valve_pos': {2: 2, 1: 5}}, - {'id': 8, 'valve_pos': {2: 3, 1: 5}}, - {'id': 9, 'valve_pos': {2: 4, 1: 5}}, - {'id': 10, 'valve_pos': {2: 5, 1: 5}}, - {'id': 11, 'valve_pos': {2: 6, 1: 5}}, - {'id': 12, 'valve_pos': {2: 7, 1: 5}}, - {'id': 13, 'valve_pos': {2: 8, 1: 5}}, - ] - + {"id": 1, "valve_pos": {1: 6}}, + {"id": 2, "valve_pos": {1: 7}}, + {"id": 3, "valve_pos": {1: 8}}, + {"id": 4, "valve_pos": {1: 1}}, + {"id": 5, "valve_pos": {1: 2}}, + {"id": 6, "valve_pos": {2: 1, 1: 5}}, + {"id": 7, "valve_pos": {2: 2, 1: 5}}, + {"id": 8, "valve_pos": {2: 3, 1: 5}}, + {"id": 9, "valve_pos": {2: 4, 1: 5}}, + {"id": 10, "valve_pos": {2: 5, 1: 5}}, + {"id": 11, "valve_pos": {2: 6, 1: 5}}, + {"id": 12, "valve_pos": {2: 7, 1: 5}}, + {"id": 13, "valve_pos": {2: 8, 1: 5}}, +] + resa_conn_present = [] for res_used in reservoir_names.keys(): used_conn = [ - resa_conn for resa_conn in reservoir_a_connections - if resa_conn['id'] == res_used] + resa_conn + for resa_conn in reservoir_a_connections + if resa_conn["id"] == res_used + ] if len(used_conn) == 1: used_conn = used_conn[0] else: - raise KeyError('Used reservoirs must be specified exactly once!') + raise KeyError("Used reservoirs must be specified exactly once!") resa_conn_present.append(used_conn) hamilton_config = { - 'interface': { - 'COM': '43', - 'baud': 9600}, - 'system_type': 'legacy', - 'valve_a': [ - {'address': 2, 'instrument_type': 'MVP', 'valve_type': '8-5'}], - 'pump_a': { - 'address': 1, 'instrument_type': '4', 'valve_type': '8-5', - 'syringe': '500u', 'input_pos': None, 'output_pos': 4, 'waste_pos': 3, - 'motorsteps_per_step': 2}, # OEM/high force version internally counts in half-steps (total 6000 steps per stroke), lab version with full steps (3000 steps per stroke) - 'flush_pos': {'flush': 3, 'inject': 4}, - 'pump_out': { - 'address': 0, 'instrument_type': '4', 'valve_type': 'Y', - 'syringe': '5.0m', 'input_pos':'out', 'output_pos': 'in', 'waste_pos': 'in', - 'motorsteps_per_step': 2}, - 'reservoir_a': resa_conn_present, - 'special_names': { - 'flushbuffer_a': 6, # defines the reservoir id with the buffer that can be used for flushing should be at the end of multiple MVPs + "interface": {"COM": "43", "baud": 9600}, + "system_type": "legacy", + "valve_a": [{"address": 2, "instrument_type": "MVP", "valve_type": "8-5"}], + "pump_a": { + "address": 1, + "instrument_type": "4", + "valve_type": "8-5", + "syringe": "500u", + "input_pos": None, + "output_pos": 4, + "waste_pos": 3, + "motorsteps_per_step": 2, + }, # OEM/high force version internally counts in half-steps (total 6000 steps per stroke), lab version with full steps (3000 steps per stroke) + "flush_pos": {"flush": 3, "inject": 4}, + "pump_out": { + "address": 0, + "instrument_type": "4", + "valve_type": "Y", + "syringe": "5.0m", + "input_pos": "out", + "output_pos": "in", + "waste_pos": "in", + "motorsteps_per_step": 2, + }, + "reservoir_a": resa_conn_present, + "special_names": { + "flushbuffer_a": 6, # defines the reservoir id with the buffer that can be used for flushing should be at the end of multiple MVPs # 'rbs': 11, # 'ipa': 12, # 'h2o': 13, # 'empty': 14 - }, + }, } tubing_config = { - ('R13', 'V2'): 20, - ('R12', 'V2'): 20, - ('R11', 'V2'): 20, - ('R10', 'V2'): 20, - ('R9', 'V2'): 20, - ('R8', 'V2'): 20, - ('R7', 'V2'): 20, - ('R6', 'V2'): 20, - ('R5', 'pump_a'): 20, - ('R4', 'pump_a'): 20, - ('R3', 'pump_a'): 20, - ('R2', 'pump_a'): 20, - ('R1', 'pump_a'): 20, - ('V2', 'pump_a'): 20, - ('pump_a', 'flush_waste'): 70, - ('pump_a', 'sample'): 70, + ("R13", "V2"): 20, + ("R12", "V2"): 20, + ("R11", "V2"): 20, + ("R10", "V2"): 20, + ("R9", "V2"): 20, + ("R8", "V2"): 20, + ("R7", "V2"): 20, + ("R6", "V2"): 20, + ("R5", "pump_a"): 20, + ("R4", "pump_a"): 20, + ("R3", "pump_a"): 20, + ("R2", "pump_a"): 20, + ("R1", "pump_a"): 20, + ("V2", "pump_a"): 20, + ("pump_a", "flush_waste"): 70, + ("pump_a", "sample"): 70, } imaging_config = { - 'save_dir': r'.', - 'use_positions': use_mm_positions, - 'pfs_pars': { # for Crick - 'tag_pfs': 'PFS', - 'tag_zdrive': 'ZDrive', - 'tag_status': 'PFS', - 'prop_state': 'PFS in Range', - 'prop_status': 'PFS Status', - 'deltat': 10} + "save_dir": r".", + "use_positions": use_mm_positions, + "pfs_pars": { # for Crick + "tag_pfs": "PFS", + "tag_zdrive": "ZDrive", + "tag_status": "PFS", + "prop_state": "PFS in Range", + "prop_status": "PFS Status", + "deltat": 10, + }, } fluid = { - 'parameters': { - 'start_velocity': 500, # ul/min - 'max_velocity': 2000, - 'stop_velocity': 500, - 'pumpout_dispense_velocity': 10000, - 'clean_velocity': 3000, - 'clean_delay': 10, # seconds of delay between pickup and dispense - 'mode': 'tubing_ignore', # 'tubing_stack' or 'tubing_flush' or 'tubing_ignore' - 'extractionfactor': 6, - 'inject_pickup_extravol': 1500, # extra volume to extract during injection - 'inject_in_to_out_delay': 15, # seconds to wait between start of pickup and dispense in injection - 'inject_out_to_in_delay': 5, # seconds pickup should continue after dispense is done in injection - 'inject_precreate_underpressure': True, # initially makes one full pumpout syringe volume extraction to create enough underpressure in the output tubing to robustly extract during injection. + "parameters": { + "start_velocity": 500, # ul/min + "max_velocity": 2000, + "stop_velocity": 500, + "pumpout_dispense_velocity": 10000, + "clean_velocity": 3000, + "clean_delay": 10, # seconds of delay between pickup and dispense + "mode": "tubing_ignore", # 'tubing_stack' or 'tubing_flush' or 'tubing_ignore' + "extractionfactor": 6, + "inject_pickup_extravol": 1500, # extra volume to extract during injection + "inject_in_to_out_delay": 15, # seconds to wait between start of pickup and dispense in injection + "inject_out_to_in_delay": 5, # seconds pickup should continue after dispense is done in injection + "inject_precreate_underpressure": True, # initially makes one full pumpout syringe volume extraction to create enough underpressure in the output tubing to robustly extract during injection. + }, + "settings": { + "vol_wash_pre": int(0.1 * wash_volume), # in ul + "vol_wash": int(0.9 * wash_volume), # in ul + "vol_imager_pre": int(0.9 * imager_volume), # in ul + "vol_imager_post": int(0.1 * imager_volume), # in ul + "vol_remove_before_wash": volume_reduction_for_xchg, + "wait_after_pickup": 9, # seconds between pickup and dispense during injection, to equilibrate pressure in pump + "reservoir_names": reservoir_names, + "experiment": { + "type": "Exchange", # options: ['Exchange', 'MERPAINT', 'FlushTest'] + "wash_buffer": wash_buffer, + "imagers": target_sequence, + "initial_imager": initial_target, }, - 'settings': { - 'vol_wash_pre': int(0.1 * wash_volume), # in ul - 'vol_wash': int(0.9 * wash_volume), # in ul - 'vol_imager_pre': int(0.9 * imager_volume), # in ul - 'vol_imager_post': int(0.1 * imager_volume), # in ul - 'vol_remove_before_wash': volume_reduction_for_xchg, - 'wait_after_pickup': 9, # seconds between pickup and dispense during injection, to equilibrate pressure in pump - 'reservoir_names': reservoir_names, - 'experiment' : { - 'type': 'Exchange', # options: ['Exchange', 'MERPAINT', 'FlushTest'] - 'wash_buffer': wash_buffer, - 'imagers': target_sequence, - 'initial_imager': initial_target} - } + }, } imaging = { - 'parameters': { # general parameters for the imaging system - 'show_progress': True, - 'show_display': True, - 'close_display_after_acquisition': True, - }, - 'settings': { # settings for protocol generation - 'frames': n_frames, - 'darkframes': 50, - 't_exp': exposure_time, # in ms - } + "parameters": { # general parameters for the imaging system + "show_progress": True, + "show_display": True, + "close_display_after_acquisition": True, + }, + "settings": { # settings for protocol generation + "frames": n_frames, + "darkframes": 50, + "t_exp": exposure_time, # in ms + }, } illumination = { - 'parameters': { # general parameters for the illumination system - 'setup': 'Crick', + "parameters": { # general parameters for the illumination system + "setup": "Crick", # 'channel_group': 'Filter turret', # 'filter': '2-G561', # 'ROI': [512, 512, 512, 512] - }, - 'settings': { # settings for protocol generation - 'laser': laser, - 'power_acq': sample_power, #mW - 'power_nonacq': 1, - 'warmup_delay': 5, - 'shutter_off_nonacq': True, - 'lasers_off_finally': True, - } + }, + "settings": { # settings for protocol generation + "laser": laser, + "power_acq": sample_power, # mW + "power_nonacq": 1, + "warmup_delay": 5, + "shutter_off_nonacq": True, + "lasers_off_finally": True, + }, } flow_acq_config = { - 'save_dir': r'.', - 'base_name': experiment_name, - 'fluid': fluid, - 'img': imaging, - 'illu': illumination, # comment out for non-automated illumination + "save_dir": r".", + "base_name": experiment_name, + "fluid": fluid, + "img": imaging, + "illu": illumination, # comment out for non-automated illumination } -if __name__ == '__main__': +if __name__ == "__main__": pb = ProtocolBuilder() protocol_fname, _ = pb.create_protocol(flow_acq_config) - imaging_config['base_name'] = os.path.splitext(protocol_fname)[0] + imaging_config["base_name"] = os.path.splitext(protocol_fname)[0] pfi = PycroFlowInteractive() pfi.do_load_hamilton(hamilton_config, tubing_config) pfi.do_load_imaging(imaging_config) pfi.do_load_protocol(protocol_fname) - pfi.cmdloop() \ No newline at end of file + pfi.cmdloop() diff --git a/example_experiment/start_experiment_240301.py b/example_experiment/start_experiment_240301.py index e93fd8a..5155bb6 100644 --- a/example_experiment/start_experiment_240301.py +++ b/example_experiment/start_experiment_240301.py @@ -4,205 +4,224 @@ import os ################ CHANGE EXPERIMENT SETTINGS HERE ################ -'''Set the name of the experiment. This will be the base folder.''' -experiment_name = 'SKBR3' +"""Set the name of the experiment. This will be the base folder.""" +experiment_name = "SKBR3" # volume settings wash_volume = 2000 # ul imager_volume = 950 # ul -volume_reduction_for_xchg = 50 # ul ATTENTION: The sample chamber must hold at least this volume above the output needle. +volume_reduction_for_xchg = 50 # ul ATTENTION: The sample chamber must hold at least this volume above the output needle. # fluidics settings -wash_buffer = 'PBS' +wash_buffer = "PBS" -'''Set at which tubing number to find which solution.''' +"""Set at which tubing number to find which solution.""" reservoir_names = { - 1: 'EGFR', 2: '5T4',3: 'AXL', 4: 'Her2',5: 'PDL1', 6: wash_buffer} + 1: "EGFR", + 2: "5T4", + 3: "AXL", + 4: "Her2", + 5: "PDL1", + 6: wash_buffer, +} -'''If an imager is already present in the sample and ready for imaging +"""If an imager is already present in the sample and ready for imaging without prior fluid exchange, write its name here. Otherwise, set -to None.''' -initial_target = 'Her3' +to None.""" +initial_target = "Her3" -'''Set the sequence in which the targets should be imaged. Make sure -that all these names match those in reservoir_names.''' -target_sequence = ['EGFR','5T4','AXL','Her2','PDL1'] +"""Set the sequence in which the targets should be imaged. Make sure +that all these names match those in reservoir_names.""" +target_sequence = ["EGFR", "5T4", "AXL", "Her2", "PDL1"] # imaging settings -exposure_time = 75 # ms +exposure_time = 75 # ms n_frames = 15 use_mm_positions = False # illumination settings laser = 560 -sample_power = 30 #mW +sample_power = 30 # mW ############# NO NEED TO CHANGE ANYTHING BELOW HERE ############## reservoir_a_connections = [ - {'id': 1, 'valve_pos': {1: 6}}, - {'id': 2, 'valve_pos': {1: 7}}, - {'id': 3, 'valve_pos': {1: 8}}, - {'id': 4, 'valve_pos': {1: 1}}, - {'id': 5, 'valve_pos': {1: 2}}, - {'id': 6, 'valve_pos': {2: 1, 1: 5}}, - {'id': 7, 'valve_pos': {2: 2, 1: 5}}, - {'id': 8, 'valve_pos': {2: 3, 1: 5}}, - {'id': 9, 'valve_pos': {2: 4, 1: 5}}, - {'id': 10, 'valve_pos': {2: 5, 1: 5}}, - {'id': 11, 'valve_pos': {2: 6, 1: 5}}, - {'id': 12, 'valve_pos': {2: 7, 1: 5}}, - {'id': 13, 'valve_pos': {2: 8, 1: 5}}, - ] - + {"id": 1, "valve_pos": {1: 6}}, + {"id": 2, "valve_pos": {1: 7}}, + {"id": 3, "valve_pos": {1: 8}}, + {"id": 4, "valve_pos": {1: 1}}, + {"id": 5, "valve_pos": {1: 2}}, + {"id": 6, "valve_pos": {2: 1, 1: 5}}, + {"id": 7, "valve_pos": {2: 2, 1: 5}}, + {"id": 8, "valve_pos": {2: 3, 1: 5}}, + {"id": 9, "valve_pos": {2: 4, 1: 5}}, + {"id": 10, "valve_pos": {2: 5, 1: 5}}, + {"id": 11, "valve_pos": {2: 6, 1: 5}}, + {"id": 12, "valve_pos": {2: 7, 1: 5}}, + {"id": 13, "valve_pos": {2: 8, 1: 5}}, +] + resa_conn_present = [] for res_used in reservoir_names.keys(): used_conn = [ - resa_conn for resa_conn in reservoir_a_connections - if resa_conn['id'] == res_used] + resa_conn + for resa_conn in reservoir_a_connections + if resa_conn["id"] == res_used + ] if len(used_conn) == 1: used_conn = used_conn[0] else: - raise KeyError('Used reservoirs must be specified exactly once!') + raise KeyError("Used reservoirs must be specified exactly once!") resa_conn_present.append(used_conn) hamilton_config = { - 'interface': { - 'COM': '43', - 'baud': 9600}, - 'system_type': 'legacy', - 'valve_a': [ - {'address': 2, 'instrument_type': 'MVP', 'valve_type': '8-5'}], - 'pump_a': { - 'address': 1, 'instrument_type': '4', 'valve_type': '8-5', - 'syringe': '500u', 'input_pos': None, 'output_pos': 4, 'waste_pos': 3, - 'motorsteps_per_step': 2}, # OEM/high force version internally counts in half-steps (total 6000 steps per stroke), lab version with full steps (3000 steps per stroke) - 'flush_pos': {'flush': 3, 'inject': 4}, - 'pump_out': { - 'address': 0, 'instrument_type': '4', 'valve_type': 'Y', - 'syringe': '5.0m', 'input_pos':'out', 'output_pos': 'in', 'waste_pos': 'in', - 'motorsteps_per_step': 2}, - 'reservoir_a': resa_conn_present, - 'special_names': { - 'flushbuffer_a': 6, # defines the reservoir id with the buffer that can be used for flushing should be at the end of multiple MVPs + "interface": {"COM": "43", "baud": 9600}, + "system_type": "legacy", + "valve_a": [{"address": 2, "instrument_type": "MVP", "valve_type": "8-5"}], + "pump_a": { + "address": 1, + "instrument_type": "4", + "valve_type": "8-5", + "syringe": "500u", + "input_pos": None, + "output_pos": 4, + "waste_pos": 3, + "motorsteps_per_step": 2, + }, # OEM/high force version internally counts in half-steps (total 6000 steps per stroke), lab version with full steps (3000 steps per stroke) + "flush_pos": {"flush": 3, "inject": 4}, + "pump_out": { + "address": 0, + "instrument_type": "4", + "valve_type": "Y", + "syringe": "5.0m", + "input_pos": "out", + "output_pos": "in", + "waste_pos": "in", + "motorsteps_per_step": 2, + }, + "reservoir_a": resa_conn_present, + "special_names": { + "flushbuffer_a": 6, # defines the reservoir id with the buffer that can be used for flushing should be at the end of multiple MVPs # 'rbs': 11, # 'ipa': 12, # 'h2o': 13, # 'empty': 14 - }, + }, } tubing_config = { - ('R13', 'V2'): 20, - ('R12', 'V2'): 20, - ('R11', 'V2'): 20, - ('R10', 'V2'): 20, - ('R9', 'V2'): 20, - ('R8', 'V2'): 20, - ('R7', 'V2'): 20, - ('R6', 'V2'): 20, - ('R5', 'pump_a'): 20, - ('R4', 'pump_a'): 20, - ('R3', 'pump_a'): 20, - ('R2', 'pump_a'): 20, - ('R1', 'pump_a'): 20, - ('V2', 'pump_a'): 20, - ('pump_a', 'flush_waste'): 70, - ('pump_a', 'sample'): 70, + ("R13", "V2"): 20, + ("R12", "V2"): 20, + ("R11", "V2"): 20, + ("R10", "V2"): 20, + ("R9", "V2"): 20, + ("R8", "V2"): 20, + ("R7", "V2"): 20, + ("R6", "V2"): 20, + ("R5", "pump_a"): 20, + ("R4", "pump_a"): 20, + ("R3", "pump_a"): 20, + ("R2", "pump_a"): 20, + ("R1", "pump_a"): 20, + ("V2", "pump_a"): 20, + ("pump_a", "flush_waste"): 70, + ("pump_a", "sample"): 70, } imaging_config = { - 'save_dir': r'.', - 'use_positions': use_mm_positions, - 'pfs_pars': { # for Crick - 'tag_pfs': 'PFS', - 'tag_zdrive': 'ZDrive', - 'tag_status': 'PFS', - 'prop_state': 'PFS in Range', - 'prop_status': 'PFS Status', - 'deltat': 10} + "save_dir": r".", + "use_positions": use_mm_positions, + "pfs_pars": { # for Crick + "tag_pfs": "PFS", + "tag_zdrive": "ZDrive", + "tag_status": "PFS", + "prop_state": "PFS in Range", + "prop_status": "PFS Status", + "deltat": 10, + }, } fluid = { - 'parameters': { - 'start_velocity': 500, # ul/min - 'max_velocity': 1000, - 'stop_velocity': 500, - 'pumpout_dispense_velocity': 20000, - 'clean_velocity': 3000, - 'clean_delay': 10, # seconds of delay between pickup and dispense in cleaning - 'mode': 'tubing_ignore', # 'tubing_stack' or 'tubing_flush' or 'tubing_ignore' - 'extractionfactor': 6, - 'inject_pickup_extravol': 1500, # extra volume to extract during injection - 'inject_in_to_out_delay': 15, # seconds to wait between start of pickup and dispense in injection - 'inject_out_to_in_delay': 5, # seconds pickup should continue after dispense is done in injection - 'inject_precreate_underpressure': False, # initially makes one full pumpout syringe volume extraction to create enough underpressure in the output tubing to robustly extract during injection. + "parameters": { + "start_velocity": 500, # ul/min + "max_velocity": 1000, + "stop_velocity": 500, + "pumpout_dispense_velocity": 20000, + "clean_velocity": 3000, + "clean_delay": 10, # seconds of delay between pickup and dispense in cleaning + "mode": "tubing_ignore", # 'tubing_stack' or 'tubing_flush' or 'tubing_ignore' + "extractionfactor": 6, + "inject_pickup_extravol": 1500, # extra volume to extract during injection + "inject_in_to_out_delay": 15, # seconds to wait between start of pickup and dispense in injection + "inject_out_to_in_delay": 5, # seconds pickup should continue after dispense is done in injection + "inject_precreate_underpressure": False, # initially makes one full pumpout syringe volume extraction to create enough underpressure in the output tubing to robustly extract during injection. + }, + "settings": { + "vol_wash_pre": int(0.1 * wash_volume), # in ul + "vol_wash": int(0.9 * wash_volume), # in ul + "vol_imager_pre": int(0.9 * imager_volume), # in ul + "vol_imager_post": int(0.1 * imager_volume), # in ul + "vol_remove_before_wash": volume_reduction_for_xchg, + "wait_after_pickup": 5, # seconds between pickup and dispense during injection, to equilibrate pressure in pump + "reservoir_names": reservoir_names, + "experiment": { + "type": "Exchange", # options: ['Exchange', 'MERPAINT', 'FlushTest'] + "wash_buffer": wash_buffer, + "imagers": target_sequence, + "initial_imager": initial_target, }, - 'settings': { - 'vol_wash_pre': int(0.1 * wash_volume), # in ul - 'vol_wash': int(0.9 * wash_volume), # in ul - 'vol_imager_pre': int(0.9 * imager_volume), # in ul - 'vol_imager_post': int(0.1 * imager_volume), # in ul - 'vol_remove_before_wash': volume_reduction_for_xchg, - 'wait_after_pickup': 5, # seconds between pickup and dispense during injection, to equilibrate pressure in pump - 'reservoir_names': reservoir_names, - 'experiment' : { - 'type': 'Exchange', # options: ['Exchange', 'MERPAINT', 'FlushTest'] - 'wash_buffer': wash_buffer, - 'imagers': target_sequence, - 'initial_imager': initial_target} - } + }, } imaging = { - 'parameters': { # general parameters for the imaging system - 'show_progress': True, - 'show_display': True, - 'close_display_after_acquisition': True, - }, - 'settings': { # settings for protocol generation - 'frames': n_frames, - 'darkframes': 50, - 't_exp': exposure_time, # in ms - } + "parameters": { # general parameters for the imaging system + "show_progress": True, + "show_display": True, + "close_display_after_acquisition": True, + }, + "settings": { # settings for protocol generation + "frames": n_frames, + "darkframes": 50, + "t_exp": exposure_time, # in ms + }, } illumination = { - 'parameters': { # general parameters for the illumination system - 'setup': 'Crick', + "parameters": { # general parameters for the illumination system + "setup": "Crick", # 'channel_group': 'Filter turret', # 'filter': '2-G561', # 'ROI': [512, 512, 512, 512] - }, - 'settings': { # settings for protocol generation - 'laser': laser, - 'power_acq': sample_power, #mW - 'power_nonacq': 1, - 'warmup_delay': 5, - 'shutter_off_nonacq': True, - 'lasers_off_finally': True, - } + }, + "settings": { # settings for protocol generation + "laser": laser, + "power_acq": sample_power, # mW + "power_nonacq": 1, + "warmup_delay": 5, + "shutter_off_nonacq": True, + "lasers_off_finally": True, + }, } flow_acq_config = { - 'save_dir': r'.', - 'base_name': experiment_name, - 'fluid': fluid, - 'img': imaging, - 'illu': illumination, # comment out for non-automated illumination + "save_dir": r".", + "base_name": experiment_name, + "fluid": fluid, + "img": imaging, + "illu": illumination, # comment out for non-automated illumination } -if __name__ == '__main__': +if __name__ == "__main__": pb = ProtocolBuilder() protocol_fname, _ = pb.create_protocol(flow_acq_config) - imaging_config['base_name'] = os.path.splitext(protocol_fname)[0] + imaging_config["base_name"] = os.path.splitext(protocol_fname)[0] pfi = PycroFlowInteractive() pfi.do_load_hamilton(hamilton_config, tubing_config) pfi.do_load_imaging(imaging_config) pfi.do_load_protocol(protocol_fname) - pfi.cmdloop() \ No newline at end of file + pfi.cmdloop() diff --git a/example_experiment/start_experiment_initial.py b/example_experiment/start_experiment_initial.py index d766d6d..0f559a1 100644 --- a/example_experiment/start_experiment_initial.py +++ b/example_experiment/start_experiment_initial.py @@ -2,140 +2,170 @@ from PycroFlow.protocols import ProtocolBuilder import yaml - hamilton_config = { - 'interface': { - 'COM': '18', - 'baud': 9600}, - 'system_type': 'legacy', - 'valve_a': [ - {'address': 2, 'instrument_type': 'MVP', 'valve_type': '8-5'}, - {'address': 3, 'instrument_type': 'MVP', 'valve_type': '8-5'}, - {'address': 4, 'instrument_type': 'MVP', 'valve_type': '8-5'}], - 'valve_flush': - {'address': 5, 'instrument_type': 'MVP', 'valve_type': '4-2'}, - 'flush_pos': {'inject': 4, 'flush': 1}, # 1: flush/waste/pumptoblue; 2: pump sealed; 3: pump sealed; 4: inject/sample/pumptored - 'pump_a': - {'address': 1, 'instrument_type': '4', 'valve_type': 'Y', - 'syringe': '500u'}, - 'pump_out': { - 'address': 0, 'instrument_type': '4', 'valve_type': 'Y', - 'syringe': '5.0m'}, - 'reservoir_a': [ - {'id': 0, 'valve_pos': {4: 2}}, - {'id': 1, 'valve_pos': {4: 3}}, + "interface": {"COM": "18", "baud": 9600}, + "system_type": "legacy", + "valve_a": [ + {"address": 2, "instrument_type": "MVP", "valve_type": "8-5"}, + {"address": 3, "instrument_type": "MVP", "valve_type": "8-5"}, + {"address": 4, "instrument_type": "MVP", "valve_type": "8-5"}, + ], + "valve_flush": { + "address": 5, + "instrument_type": "MVP", + "valve_type": "4-2", + }, + "flush_pos": { + "inject": 4, + "flush": 1, + }, # 1: flush/waste/pumptoblue; 2: pump sealed; 3: pump sealed; 4: inject/sample/pumptored + "pump_a": { + "address": 1, + "instrument_type": "4", + "valve_type": "Y", + "syringe": "500u", + }, + "pump_out": { + "address": 0, + "instrument_type": "4", + "valve_type": "Y", + "syringe": "5.0m", + }, + "reservoir_a": [ + {"id": 0, "valve_pos": {4: 2}}, + {"id": 1, "valve_pos": {4: 3}}, # {'id': 2, 'valve_pos': {4: 4}}, # {'id': 3, 'valve_pos': {4: 5}}, # {'id': 4, 'valve_pos': {4: 6}}, # {'id': 5, 'valve_pos': {4: 7}}, # {'id': 6, 'valve_pos': {4: 8}}, - {'id': 7, 'valve_pos': {4: 1, 3: 2}}, - {'id': 8, 'valve_pos': {4: 1, 3: 3}}, + {"id": 7, "valve_pos": {4: 1, 3: 2}}, + {"id": 8, "valve_pos": {4: 1, 3: 3}}, # {'id': 9, 'valve_pos': {4: 1, 3: 4}}, # {'id': 10, 'valve_pos': {4: 1, 3: 5}}, # {'id': 11, 'valve_pos': {4: 1, 3: 6}}, # {'id': 12, 'valve_pos': {4: 1, 3: 7}}, # {'id': 13, 'valve_pos': {4: 1, 3: 8}}, - {'id': 14, 'valve_pos': {4: 1, 3: 1, 2: 1}}, - {'id': 15, 'valve_pos': {4: 1, 3: 1, 2: 2}}, - {'id': 16, 'valve_pos': {4: 1, 3: 1, 2: 3}}, + {"id": 14, "valve_pos": {4: 1, 3: 1, 2: 1}}, + {"id": 15, "valve_pos": {4: 1, 3: 1, 2: 2}}, + {"id": 16, "valve_pos": {4: 1, 3: 1, 2: 3}}, # {'id': 17, 'valve_pos': {4: 1, 3: 1, 2: 4}}, # {'id': 18, 'valve_pos': {4: 1, 3: 1, 2: 5}}, # {'id': 19, 'valve_pos': {4: 1, 3: 1, 2: 6}}, # {'id': 20, 'valve_pos': {4: 1, 3: 1, 2: 7}}, # {'id': 21, 'valve_pos': {4: 1, 3: 1, 2: 8}}, - ], - 'special_names': { - 'flushbuffer_a': 14, # defines the reservoir id with the buffer that can be used for flushing should be at the end of multiple MVPs - }, + ], + "special_names": { + "flushbuffer_a": 14, # defines the reservoir id with the buffer that can be used for flushing should be at the end of multiple MVPs + }, } tubing_config = { - ('R21', 'pump_a'): 365, - ('R20', 'pump_a'): 365, - ('R19', 'pump_a'): 365, - ('R18', 'pump_a'): 365, - ('R17', 'pump_a'): 365, - ('R16', 'pump_a'): 365, - ('R15', 'pump_a'): 365, - ('R14', 'pump_a'): 365, - ('R13', 'pump_a'): 260, - ('R12', 'pump_a'): 260, - ('R11', 'pump_a'): 260, - ('R10', 'pump_a'): 260, - ('R9', 'pump_a'): 260, - ('R8', 'pump_a'): 260, - ('R7', 'pump_a'): 260, - ('R6', 'pump_a'): 215, - ('R5', 'pump_a'): 215, - ('R4', 'pump_a'): 215, - ('R3', 'pump_a'): 215, - ('R2', 'pump_a'): 215, - ('R1', 'pump_a'): 215, - ('R0', 'pump_a'): 215, - ('pump_a', 'valve_flush'): 156, - ('valve_flush', 'sample'): 256, + ("R21", "pump_a"): 365, + ("R20", "pump_a"): 365, + ("R19", "pump_a"): 365, + ("R18", "pump_a"): 365, + ("R17", "pump_a"): 365, + ("R16", "pump_a"): 365, + ("R15", "pump_a"): 365, + ("R14", "pump_a"): 365, + ("R13", "pump_a"): 260, + ("R12", "pump_a"): 260, + ("R11", "pump_a"): 260, + ("R10", "pump_a"): 260, + ("R9", "pump_a"): 260, + ("R8", "pump_a"): 260, + ("R7", "pump_a"): 260, + ("R6", "pump_a"): 215, + ("R5", "pump_a"): 215, + ("R4", "pump_a"): 215, + ("R3", "pump_a"): 215, + ("R2", "pump_a"): 215, + ("R1", "pump_a"): 215, + ("R0", "pump_a"): 215, + ("pump_a", "valve_flush"): 156, + ("valve_flush", "sample"): 256, } imaging_config = { - 'save_dir': r'.', - 'base_name': 'AutomationTest', + "save_dir": r".", + "base_name": "AutomationTest", } fluid = { - 'parameters': { - 'start_velocity': 50, - 'max_velocity': 1000, - 'stop_velocity': 500, - 'mode': 'tubing_stack', # or 'tubing_flush' - 'extractionfactor': 1}, - 'settings': { - 'vol_wash': 500, # in ul - 'vol_imager_pre': 500, # in ul - 'vol_imager_post': 100, # in ul - 'reservoir_names': { - 1: 'R1', 3: 'R3', 5: 'R5', 6: 'R6', - 7: 'R2', 8: 'R4', 9: 'Res9', 10: 'Buffer B+'}, - 'experiment' : { - 'type': 'Exchange', # options: ['Exchange', 'MERPAINT', 'FlushTest'] - 'wash_buffer': 'Buffer B+', - 'imagers': [ - 'R4', 'R2', 'R4', 'R2', 'R4', 'R2', 'R4', 'R2', 'R4', 'R2'],} - } + "parameters": { + "start_velocity": 50, + "max_velocity": 1000, + "stop_velocity": 500, + "mode": "tubing_stack", # or 'tubing_flush' + "extractionfactor": 1, + }, + "settings": { + "vol_wash": 500, # in ul + "vol_imager_pre": 500, # in ul + "vol_imager_post": 100, # in ul + "reservoir_names": { + 1: "R1", + 3: "R3", + 5: "R5", + 6: "R6", + 7: "R2", + 8: "R4", + 9: "Res9", + 10: "Buffer B+", + }, + "experiment": { + "type": "Exchange", # options: ['Exchange', 'MERPAINT', 'FlushTest'] + "wash_buffer": "Buffer B+", + "imagers": [ + "R4", + "R2", + "R4", + "R2", + "R4", + "R2", + "R4", + "R2", + "R4", + "R2", + ], + }, + }, } imaging = { - 'settings': { - 'frames': 50000, - 't_exp': 100, # in ms - } + "settings": { + "frames": 50000, + "t_exp": 100, # in ms + } } illumination = { - 'parameters': { - 'channel_group': 'Filter turret', - 'filter': '2-G561', - 'ROI': [512, 512, 512, 512]}, - 'settings': { - 'setup': 'Mercury', - 'laser': 560, - 'power': 30, #mW - } + "parameters": { + "channel_group": "Filter turret", + "filter": "2-G561", + "ROI": [512, 512, 512, 512], + }, + "settings": { + "setup": "Mercury", + "laser": 560, + "power": 30, # mW + }, } flow_acq_config = { - 'save_dir': r'.', - 'base_name': 'AutomationTest', - 'fluid': fluid, - 'img': imaging, + "save_dir": r".", + "base_name": "AutomationTest", + "fluid": fluid, + "img": imaging, # 'illu': illumination, } -if __name__ == '__main__': +if __name__ == "__main__": pb = ProtocolBuilder() protocol_fname, _ = pb.create_protocol(flow_acq_config) @@ -143,4 +173,4 @@ pfi.do_load_hamilton(hamilton_config, tubing_config) pfi.do_load_imaging(imaging_config) pfi.do_load_protocol(protocol_fname) - pfi.cmdloop() \ No newline at end of file + pfi.cmdloop() diff --git a/pyproject.toml b/pyproject.toml index 8cdf828..22980b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=64", "wheel"] +requires = ["setuptools>=64", "setuptools-scm>=8", "wheel"] build-backend = "setuptools.build_meta" [project] name = "PycroFlow" -version = "0.1.0" +dynamic = ["version"] description = "Microscopy and Fluid automation coordination" readme = "README.md" license = { text = "MIT" } @@ -83,6 +83,16 @@ dev = [ pycroflow = "PycroFlow.frontend_cli:main" pycroflow-gui = "PycroFlow.gui.app:main" +[tool.setuptools_scm] +# Version is derived from the latest reachable git tag (single source of +# truth — no static number in this file). The resolved version is written +# into an importable module so it survives an install from a wheel/sdist +# (no .git present at runtime); PycroFlow.__init__ imports it with a fallback. +write_to = "PycroFlow/_version.py" +# Shown when built outside a git repo with no reachable tag (e.g. a shallow +# checkout, or a branch that predates the first tag). +fallback_version = "0.1.0" + [tool.setuptools.packages.find] include = ["PycroFlow*"] @@ -118,3 +128,31 @@ exclude_also = [ "raise NotImplementedError", "if TYPE_CHECKING:", ] + +[tool.black] +# Pin target-version so Black doesn't infer an open-ended range from +# requires-python (which triggers a warning). line-length matches .flake8's +# historical 79 and the shared stack convention. +target-version = ["py310"] +line-length = 79 +# Keep the vendored upstream PyHamiltonPSD package snapshot pristine (it ships +# its own LICENSE/CHANGELOG); don't reformat it. Regex on the file path. +extend-exclude = "/pyHamiltonPSD_packagefiles/" + +[tool.flake8] +# Read by flake8 via the Flake8-pyproject plugin (no separate .flake8 file). +# Black owns line wrapping, so E501 (line too long) is ignored here to match +# picasso-workflow's rule — long strings/comments Black can't split are +# intentional. E203 (whitespace before ':') and W503 (line break before +# binary operator) are the standard Black-compatibility ignores. The generated +# _version.py is excluded (it's machine-written by setuptools-scm). +max-line-length = 79 +extend-ignore = "E203,E501,W503" +# Excluded from flake8 (all still Black-formatted): +# _version.py - machine-generated by setuptools-scm +# PycroFlow/pyHamilton - in-house serial driver; uses `from x import *` +# as its idiom (F403/F405), like picasso's ext/ +# snippets, scripts, example_experiment - throwaway/example scripts, not +# part of the shipped package (see packages.find), +# carry WIP F-codes (undefined names, bare excepts) +extend-exclude = "PycroFlow/_version.py,PycroFlow/pyHamilton,snippets,scripts,example_experiment" diff --git a/scripts/calibrate_pfsoffset.py b/scripts/calibrate_pfsoffset.py index 2013b48..8d37af1 100644 --- a/scripts/calibrate_pfsoffset.py +++ b/scripts/calibrate_pfsoffset.py @@ -1,14 +1,15 @@ #!/usr/bin/env python """ - PycroFlow/calibrate_pfsoffset.py - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +PycroFlow/calibrate_pfsoffset.py +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - A calibration routine for mapping Nikon Perfect Focus System (PFS) - offset values to stage positions (which are calibrated) +A calibration routine for mapping Nikon Perfect Focus System (PFS) +offset values to stage positions (which are calibrated) - :authors: Heinrich Grabmayr, 2023 - :copyright: Copyright (c) 2023 Jungmann Lab, MPI of Biochemistry +:authors: Heinrich Grabmayr, 2023 +:copyright: Copyright (c) 2023 Jungmann Lab, MPI of Biochemistry """ + import numpy as np import matplotlib.pyplot as plt import time @@ -16,15 +17,15 @@ def calibrate(core, range_pars=(150, 400, 0.005), sleep=0): - tag_pfs = 'TIPFSOffset' - tag_zdrive = 'TIZDrive' - tag_status = 'TIPFSStatus' - prop_status = 'State' + tag_pfs = "TIPFSOffset" + tag_zdrive = "TIZDrive" + tag_status = "TIPFSStatus" + prop_status = "State" # tag_zpiezo = 'ZStage' - tag_pfs = 'PFSOffset' - tag_zdrive = 'ZDrive' - tag_status = 'PFS' - prop_status = 'PFS in Range' + tag_pfs = "PFSOffset" + tag_zdrive = "ZDrive" + tag_status = "PFS" + prop_status = "PFS in Range" pfs_range = np.arange(*range_pars) pfs_range = pfs_range[:, np.newaxis] @@ -35,7 +36,7 @@ def calibrate(core, range_pars=(150, 400, 0.005), sleep=0): zpos = np.nan * np.ones_like(pfs_range) for i in range(pfs_range.shape[1]): - print('round', i) + print("round", i) for j, pfs in enumerate(pfs_range[:, i]): # print(i, 'of', len(pfs_range)) core.set_position(tag_pfs, float(pfs)) @@ -43,21 +44,39 @@ def calibrate(core, range_pars=(150, 400, 0.005), sleep=0): time.sleep(sleep) pos = core.get_position(tag_zdrive) zpos[j, i] = pos - plot_calibration(pfs_range, zpos, show=False, range_pars=range_pars, sleep=sleep) - np.save('pfs_range_{:d}_{:d}_{:d}_sleep{:d}.npy'.format( - range_pars[0], range_pars[1], int(range_pars[2]*1000), int(sleep*1000)), pfs_range) - np.save('zpos_{:d}_{:d}_{:d}.npy'.format( - range_pars[0], range_pars[1], int(range_pars[2]*1000), int(sleep*1000)), zpos) + plot_calibration( + pfs_range, zpos, show=False, range_pars=range_pars, sleep=sleep + ) + np.save( + "pfs_range_{:d}_{:d}_{:d}_sleep{:d}.npy".format( + range_pars[0], + range_pars[1], + int(range_pars[2] * 1000), + int(sleep * 1000), + ), + pfs_range, + ) + np.save( + "zpos_{:d}_{:d}_{:d}.npy".format( + range_pars[0], + range_pars[1], + int(range_pars[2] * 1000), + int(sleep * 1000), + ), + zpos, + ) return pfs_range, zpos -def plot_calibration(pfs_range, zpos, show=True, range_pars=(150, 400, 0.005), sleep=0): +def plot_calibration( + pfs_range, zpos, show=True, range_pars=(150, 400, 0.005), sleep=0 +): fig, ax = plt.subplots(nrows=1) for i in range(pfs_range.shape[1]): - ax.plot(pfs_range[:, i], zpos[:, i], label='round {:d}'.format(i)) - ax.set_xlabel('PFS offset [a.u.]') - ax.set_ylabel('ZDrive position [µm]') + ax.plot(pfs_range[:, i], zpos[:, i], label="round {:d}".format(i)) + ax.set_xlabel("PFS offset [a.u.]") + ax.set_ylabel("ZDrive position [µm]") ax.legend() # fig, ax = plt.subplots(nrows=2) # ax[0].plot(pfs_range, zpos) @@ -66,8 +85,14 @@ def plot_calibration(pfs_range, zpos, show=True, range_pars=(150, 400, 0.005), s # ax[1].plot(pfs_range, zpos) # ax[1].set_xlabel('PFS offset [a.u.]') # ax[1].set_ylabel('Z piezo position [nm]') - fig.savefig('calibration_{:d}_{:d}_{:d}_sleep{:d}.png'.format( - range_pars[0], range_pars[1], int(range_pars[2]*1000), int(sleep*1000))) + fig.savefig( + "calibration_{:d}_{:d}_{:d}_sleep{:d}.png".format( + range_pars[0], + range_pars[1], + int(range_pars[2] * 1000), + int(sleep * 1000), + ) + ) if show: plt.show() @@ -76,14 +101,16 @@ def wait_for_focus(core, tag_status, prop_status, timeout=1): tic = time.time() while time.time() - tic < timeout: state = core.get_property(tag_status, prop_status) - if state == 'Off': + if state == "Off": return False status = core.get_property(tag_status, prop_status) - if status == 'Locked in focus': + if status == "Locked in focus": return True - elif status == 'Focus lock failed': + elif status == "Focus lock failed": return False - elif status == 'In Range': # check whether 'PFS in Range' is actually the correct property to use on Skylab + elif ( + status == "In Range" + ): # check whether 'PFS in Range' is actually the correct property to use on Skylab time.sleep(0.02) return True @@ -94,4 +121,4 @@ def wait_for_focus(core, tag_status, prop_status, timeout=1): if __name__ == "__main__": core = Core() pfs_range, zpos = calibrate(core, range_pars=(1000, 15000, 10), sleep=0) - #pfs_range, zpos = calibrate(core, range_pars=(150, 600, 0.005), sleep=.2) + # pfs_range, zpos = calibrate(core, range_pars=(150, 600, 0.005), sleep=.2) diff --git a/scripts/move_PFS.py b/scripts/move_PFS.py index 2811306..2c368de 100644 --- a/scripts/move_PFS.py +++ b/scripts/move_PFS.py @@ -1,23 +1,30 @@ #!/usr/bin/env python """ - PycroFlow/start_zpaint.py - ~~~~~~~~~~~~~~~~~~~~~~~~~ +PycroFlow/start_zpaint.py +~~~~~~~~~~~~~~~~~~~~~~~~~ - start a DNA-PAINT zstack: from micromanager, read the multi-d-acquisition - settings, convert the relative z positions to PSF offset values, and - perform separate acquisitions +start a DNA-PAINT zstack: from micromanager, read the multi-d-acquisition +settings, convert the relative z positions to PSF offset values, and +perform separate acquisitions - :authors: Heinrich Grabmayr, 2023 - :copyright: Copyright (c) 2023 Jungmann Lab, MPI of Biochemistry +:authors: Heinrich Grabmayr, 2023 +:copyright: Copyright (c) 2023 Jungmann Lab, MPI of Biochemistry """ + import numpy as np import cmd from pycromanager import Core def move_pfsoffset( - zpos, core, ztags, jump_factor_settings, max_iter=50, - tolerance=.01, approx_stepsperum=1000): + zpos, + core, + ztags, + jump_factor_settings, + max_iter=50, + tolerance=0.01, + approx_stepsperum=1000, +): """move the Nikon PFS offset such that the ZDrive-position reaches the target given. Args: @@ -52,8 +59,8 @@ def move_pfsoffset( pfsoffset_pos = np.nan * np.ones(max_iter) for i in range(max_iter): - zdrive_pos[i] = core.get_position(ztags['zdrive']) - pfsoffset_pos[i] = core.get_position(ztags['pfsoffset']) + zdrive_pos[i] = core.get_position(ztags["zdrive"]) + pfsoffset_pos[i] = core.get_position(ztags["pfsoffset"]) if np.abs(zpos - zdrive_pos[i]) < tolerance: # on target @@ -64,21 +71,22 @@ def move_pfsoffset( # distance to move deltaz = zpos - zdrive_pos[i] # set the jump factor according to the thresholds - for th, fac in zip(jump_factor_settings['thresholds'], - jump_factor_settings['factors']): + for th, fac in zip( + jump_factor_settings["thresholds"], jump_factor_settings["factors"] + ): if np.abs(deltaz) < th: jump_factor = fac break else: - jump_factor = jump_factor_settings['factors'][-1] + jump_factor = jump_factor_settings["factors"][-1] delta_steps = int(jump_factor * deltaz * approx_stepsperum) - core.set_position(ztags['pfsoffset'], pfsoffset_pos[i] + delta_steps) + core.set_position(ztags["pfsoffset"], pfsoffset_pos[i] + delta_steps) # TODO implement something like calibrate_pfsoffset.wait_for_focus # but only when the PFS focus/on-target Status/State can be retreived # on TiE2, until then: just wait # time.sleep(0.05). # maybe not; apparently, set_position waits - return zdrive_pos[:i + 1], pfsoffset_pos[:i + 1] + return zdrive_pos[: i + 1], pfsoffset_pos[: i + 1] def get_abs_zpos(core, ztags): @@ -91,16 +99,16 @@ def get_abs_zpos(core, ztags): Returns: the absolute zdrive position """ - return core.get_position(ztags['zdrive']) + return core.get_position(ztags["zdrive"]) class PFSmove(cmd.Cmd): - """Command-line interactive power setting. - """ - intro = '''Welcome to PFSmove. Use this to move absolute or relative + """Command-line interactive power setting.""" + + intro = """Welcome to PFSmove. Use this to move absolute or relative in z while haveing PFS on. - ''' - prompt = '(PFS move)' + """ + prompt = "(PFS move)" file = None def __init__(self, core, acq_settings): @@ -109,10 +117,9 @@ def __init__(self, core, acq_settings): self.acq_settings = acq_settings def do_pos(self, line): - """Get the current ZDrive position - """ - zpos = get_abs_zpos(self.core, self.acq_settings['ztags']) - print('z pos: ', zpos, 'um') + """Get the current ZDrive position""" + zpos = get_abs_zpos(self.core, self.acq_settings["ztags"]) + print("z pos: ", zpos, "um") def do_abs(self, zpos): """Absolute move @@ -122,10 +129,14 @@ def do_abs(self, zpos): """ zpos = float(zpos) move_pfsoffset( - zpos, self.core, self.acq_settings['ztags'], - self.acq_settings['jump_factor_settings'], - acq_settings['zmaxiter'], acq_settings['ztolerance'], - approx_stepsperum=1000) + zpos, + self.core, + self.acq_settings["ztags"], + self.acq_settings["jump_factor_settings"], + acq_settings["zmaxiter"], + acq_settings["ztolerance"], + approx_stepsperum=1000, + ) def do_rel(self, zmove): """Relative move @@ -134,16 +145,19 @@ def do_rel(self, zmove): the distance to move by in nm """ zmove = float(zmove) / 1000 - curr_z = get_abs_zpos(self.core, self.acq_settings['ztags']) + curr_z = get_abs_zpos(self.core, self.acq_settings["ztags"]) move_pfsoffset( - curr_z + zmove, self.core, self.acq_settings['ztags'], - self.acq_settings['jump_factor_settings'], - acq_settings['zmaxiter'], acq_settings['ztolerance'], - approx_stepsperum=1000) + curr_z + zmove, + self.core, + self.acq_settings["ztags"], + self.acq_settings["jump_factor_settings"], + acq_settings["zmaxiter"], + acq_settings["ztolerance"], + approx_stepsperum=1000, + ) def do_exit(self, line): - """Exit the interaction - """ + """Exit the interaction""" self.close() return True @@ -154,20 +168,20 @@ def close(self): pass -if __name__ == '__main__': +if __name__ == "__main__": core = Core() acq_settings = { - 'ztags': { - 'zdrive': 'ZDrive', - 'pfsoffset': 'PFSOffset', - 'pfson': ('PFS', 'FocusMaintenance') + "ztags": { + "zdrive": "ZDrive", + "pfsoffset": "PFSOffset", + "pfson": ("PFS", "FocusMaintenance"), }, - 'jump_factor_settings': { - 'thresholds': (.5, 2), - 'factors': (.2, .4, .6), + "jump_factor_settings": { + "thresholds": (0.5, 2), + "factors": (0.2, 0.4, 0.6), }, - 'ztolerance': .025, # tolerance of z movement in um - 'zmaxiter': 50, # maximum number of iterations for moving in z + "ztolerance": 0.025, # tolerance of z movement in um + "zmaxiter": 50, # maximum number of iterations for moving in z } PFSmove(core, acq_settings).cmdloop() diff --git a/scripts/start_zpaint.py b/scripts/start_zpaint.py index 2f3b350..acb6721 100644 --- a/scripts/start_zpaint.py +++ b/scripts/start_zpaint.py @@ -1,18 +1,19 @@ #!/usr/bin/env python """ - PycroFlow/start_zpaint.py - ~~~~~~~~~~~~~~~~~~~~~~~~~ +PycroFlow/start_zpaint.py +~~~~~~~~~~~~~~~~~~~~~~~~~ - start a DNA-PAINT zstack: from micromanager, read the multi-d-acquisition - settings, convert the relative z positions to PSF offset values, and - perform separate acquisitions +start a DNA-PAINT zstack: from micromanager, read the multi-d-acquisition +settings, convert the relative z positions to PSF offset values, and +perform separate acquisitions - :authors: Heinrich Grabmayr, 2023 - :copyright: Copyright (c) 2023 Jungmann Lab, MPI of Biochemistry +:authors: Heinrich Grabmayr, 2023 +:copyright: Copyright (c) 2023 Jungmann Lab, MPI of Biochemistry """ + import os import time -from datetime import datetime +from datetime import datetime import yaml import numpy as np from pycromanager import Studio, Core, Acquisition, multi_d_acquisition_events @@ -36,16 +37,22 @@ def zpos2pfsoffset(zpos, curr_offset, calibration=None): return zpos else: curr_zpos = np.interp( - curr_offset, calibration[:, 1], calibration[:, 0]) + curr_offset, calibration[:, 1], calibration[:, 0] + ) z_relative = calibration[:, 0] - curr_zpos - pfs_values = np.interp( - zpos, z_relative, calibration[:, 1]) + pfs_values = np.interp(zpos, z_relative, calibration[:, 1]) return pfs_values def move_pfsoffset( - zpos, core, ztags, jump_factor_settings, max_iter=50, - tolerance=.01, approx_stepsperum=1000): + zpos, + core, + ztags, + jump_factor_settings, + max_iter=50, + tolerance=0.01, + approx_stepsperum=1000, +): """move the Nikon PFS offset such that the ZDrive-position reaches the target given. Args: @@ -80,8 +87,8 @@ def move_pfsoffset( pfsoffset_pos = np.nan * np.ones(max_iter) for i in range(max_iter): - zdrive_pos[i] = core.get_position(ztags['zdrive']) - pfsoffset_pos[i] = core.get_position(ztags['pfsoffset']) + zdrive_pos[i] = core.get_position(ztags["zdrive"]) + pfsoffset_pos[i] = core.get_position(ztags["pfsoffset"]) if np.abs(zpos - zdrive_pos[i]) < tolerance: # on target @@ -92,21 +99,22 @@ def move_pfsoffset( # distance to move deltaz = zpos - zdrive_pos[i] # set the jump factor according to the thresholds - for th, fac in zip(jump_factor_settings['thresholds'], - jump_factor_settings['factors']): + for th, fac in zip( + jump_factor_settings["thresholds"], jump_factor_settings["factors"] + ): if np.abs(deltaz) < th: jump_factor = fac break else: - jump_factor = jump_factor_settings['factors'][-1] + jump_factor = jump_factor_settings["factors"][-1] delta_steps = int(jump_factor * deltaz * approx_stepsperum) - core.set_position(ztags['pfsoffset'], pfsoffset_pos[i] + delta_steps) + core.set_position(ztags["pfsoffset"], pfsoffset_pos[i] + delta_steps) # TODO implement something like calibrate_pfsoffset.wait_for_focus # but only when the PFS focus/on-target Status/State can be retreived # on TiE2, until then: just wait # time.sleep(0.05). # maybe not; apparently, set_position waits - return zdrive_pos[:i+1], pfsoffset_pos[:i+1] + return zdrive_pos[: i + 1], pfsoffset_pos[: i + 1] def get_multid_settings(studio): @@ -119,32 +127,34 @@ def get_multid_settings(studio): the acquisition description, with keys 'usedims', 'Z', 'C', 'S', 'comment', 'prefix', 'root' """ - #studio = Studio(convert_camel_case=True) + # studio = Studio(convert_camel_case=True) acqmgr = studio.get_acquisition_manager() acqsttgs = acqmgr.get_acquisition_settings() # https://valelab4.ucsf.edu/~MM/doc-2.0.0-gamma/mmstudio/org/micromanager/acquisition/SequenceSettings.html usedims = {} - usedims['C'] = acqsttgs.use_channels() - usedims['T'] = acqsttgs.use_frames() - usedims['S'] = acqsttgs.use_position_list() - usedims['Z'] = acqsttgs.use_slices() + usedims["C"] = acqsttgs.use_channels() + usedims["T"] = acqsttgs.use_frames() + usedims["S"] = acqsttgs.use_position_list() + usedims["Z"] = acqsttgs.use_slices() - channels = [acqsttgs.channels().get(i) - for i in range(acqsttgs.channels().size())] + channels = [ + acqsttgs.channels().get(i) for i in range(acqsttgs.channels().size()) + ] z_definition = { - 'slices': [acqsttgs.slices().get(i) - for i in range(acqsttgs.slices().size())], - 'bot': acqsttgs.slice_z_bottom_um(), - 'step': acqsttgs.slice_z_step_um(), - 'top': acqsttgs.slice_z_top_um(), - 'relative': acqsttgs.relative_z_slice() + "slices": [ + acqsttgs.slices().get(i) for i in range(acqsttgs.slices().size()) + ], + "bot": acqsttgs.slice_z_bottom_um(), + "step": acqsttgs.slice_z_step_um(), + "top": acqsttgs.slice_z_top_um(), + "relative": acqsttgs.relative_z_slice(), } t_definition = { - 'n': acqsttgs.num_frames(), - 'dt': acqsttgs.interval_ms(), + "n": acqsttgs.num_frames(), + "dt": acqsttgs.interval_ms(), } # https://forum.image.sc/t/importing-coordinates-from-multi-d-acquisition-stage-position-list-through-pycromanager/46746 @@ -153,19 +163,20 @@ def get_multid_settings(studio): pos_list = studio.get_position_list_manager().get_position_list() positions = [ pos_list.get_position(i) - for i in range(pos_list.get_number_of_positions())] + for i in range(pos_list.get_number_of_positions()) + ] comment = acqsttgs.comment() acq = { - 'usedims': usedims, - 'Z': z_definition, - 'C': channels, - 'S': positions, - 'T': t_definition, - 'comment': comment, - 'prefix': acqsttgs.prefix(), - 'root': acqsttgs.root() + "usedims": usedims, + "Z": z_definition, + "C": channels, + "S": positions, + "T": t_definition, + "comment": comment, + "prefix": acqsttgs.prefix(), + "root": acqsttgs.root(), } return acq @@ -188,26 +199,35 @@ def mm2zpaint_acq(mmacq, core, ztags): the reimaining dimensions, to be taken care from pycromanager """ multid_sttg = { - 'usedims': {'C': False, 'T': mmacq['usedims']['T'], 'Z': False, 'S': False}, - 'T': mmacq['T'], - 'C': [], - 'S': [], - 'Z': [], - 'comment': mmacq['comment'], - 'prefix': mmacq['prefix'], - 'root': mmacq['root'], + "usedims": { + "C": False, + "T": mmacq["usedims"]["T"], + "Z": False, + "S": False, + }, + "T": mmacq["T"], + "C": [], + "S": [], + "Z": [], + "comment": mmacq["comment"], + "prefix": mmacq["prefix"], + "root": mmacq["root"], } zpacq = { - 'usedims': { - 'C': mmacq['usedims']['C'], 'T': False, 'Z': mmacq['usedims']['Z'], 'S': mmacq['usedims']['S']}, - 'T': [], - 'C': mmacq['C'], - 'S': mmacq['S'], - 'Z': get_abs_zplanes(mmacq['Z'], core, ztags), - 'comment': mmacq['comment'], - 'prefix': mmacq['prefix'], - 'root': mmacq['root'], + "usedims": { + "C": mmacq["usedims"]["C"], + "T": False, + "Z": mmacq["usedims"]["Z"], + "S": mmacq["usedims"]["S"], + }, + "T": [], + "C": mmacq["C"], + "S": mmacq["S"], + "Z": get_abs_zplanes(mmacq["Z"], core, ztags), + "comment": mmacq["comment"], + "prefix": mmacq["prefix"], + "root": mmacq["root"], } return multid_sttg, zpacq @@ -228,9 +248,9 @@ def get_abs_zplanes(z_sttg, core, ztags): the absolute zdrive plane positions to move to """ # zplanes = np.linspace(z_sttg['bot'], z_sttg['top'], num=z_sttg['slices']) - zplanes = np.array(z_sttg['slices']) - if z_sttg['relative']: - zplanes = zplanes + core.get_position(ztags['zdrive']) + zplanes = np.array(z_sttg["slices"]) + if z_sttg["relative"]: + zplanes = zplanes + core.get_position(ztags["zdrive"]) return zplanes @@ -248,9 +268,9 @@ def start_acq(core, acq_settings): ztolerance : float, tolerance of z movement in um zmaxiter : int, maximum number of iterations for moving in z """ - pfson = acq_settings['ztags']['pfson'] - if core.get_property(pfson[0], pfson[1]) != 'On': - raise ValueError('PFS is not on. Please switch on and restart.') + pfson = acq_settings["ztags"]["pfson"] + if core.get_property(pfson[0], pfson[1]) != "On": + raise ValueError("PFS is not on. Please switch on and restart.") studio = Studio(convert_camel_case=False) @@ -259,77 +279,97 @@ def start_acq(core, acq_settings): studio.live().set_live_mode_on(False) acq = get_multid_settings(studio) - multid_sttg, zpacq = mm2zpaint_acq(acq, core, acq_settings['ztags']) + multid_sttg, zpacq = mm2zpaint_acq(acq, core, acq_settings["ztags"]) # create the root folder - dirpath = os.path.join(zpacq['root'], zpacq['prefix']) - ext = '' + dirpath = os.path.join(zpacq["root"], zpacq["prefix"]) + ext = "" extit = 0 while os.path.exists(dirpath + ext): extit += 1 - ext = '_{:d}'.format(extit) + ext = "_{:d}".format(extit) dirpath = dirpath + ext os.makedirs(dirpath) # save settings acquisition_config = { - 'inner dimensions': multid_sttg.copy(), - 'outer dimensions': zpacq.copy(), - 'acquisition_settings': acq_settings, + "inner dimensions": multid_sttg.copy(), + "outer dimensions": zpacq.copy(), + "acquisition_settings": acq_settings, } - acquisition_config['outer dimensions']['C'] = [str(c) for c in acquisition_config['outer dimensions']['C']] - acquisition_config['outer dimensions']['S'] = [str(c) for c in acquisition_config['outer dimensions']['S']] - acquisition_config['outer dimensions']['Z'] = [float(c) for c in acquisition_config['outer dimensions']['Z']] + acquisition_config["outer dimensions"]["C"] = [ + str(c) for c in acquisition_config["outer dimensions"]["C"] + ] + acquisition_config["outer dimensions"]["S"] = [ + str(c) for c in acquisition_config["outer dimensions"]["S"] + ] + acquisition_config["outer dimensions"]["Z"] = [ + float(c) for c in acquisition_config["outer dimensions"]["Z"] + ] print(acquisition_config) - with open(os.path.join( - dirpath, 'acquisition_configuration.yaml'), 'w') as f: + with open( + os.path.join(dirpath, "acquisition_configuration.yaml"), "w" + ) as f: yaml.dump(acquisition_config, f) acq_log = {} acq_i = 0 # create multi-d-acquisition events print(multid_sttg) - print('Hard-coded dimension order: PCZT') + print("Hard-coded dimension order: PCZT") events = multi_d_acquisition_events( - num_time_points=multid_sttg['T']['n'], - time_interval_s=multid_sttg['T']['dt'] / 1000) + num_time_points=multid_sttg["T"]["n"], + time_interval_s=multid_sttg["T"]["dt"] / 1000, + ) - z_start = core.get_position(acq_settings['ztags']['pfsoffset']) + z_start = core.get_position(acq_settings["ztags"]["pfsoffset"]) viewer = None # fix dimension order to SCZT for i_s, pos in zp_iterate_S(zpacq, core): for i_c, c in zp_iterate_C(zpacq, core): for i_z, z, (zdrive_pos, pfsoffset_pos) in zp_iterate_Z( - zpacq, core, acq_settings): + zpacq, core, acq_settings + ): # add log entries acq_log[acq_i] = { - 'i_s': i_s, 'i_c': i_c, 'i_z': i_z, - 'pos': str(pos), 'c': str(c), 'z': float(z), - 'curr_zpos': float(zdrive_pos[-1]), - 'curr_pfsoffset': float(pfsoffset_pos[-1]), - 'time': datetime.now().strftime('%y%m%d-%H%M:%S.%f'), + "i_s": i_s, + "i_c": i_c, + "i_z": i_z, + "pos": str(pos), + "c": str(c), + "z": float(z), + "curr_zpos": float(zdrive_pos[-1]), + "curr_pfsoffset": float(pfsoffset_pos[-1]), + "time": datetime.now().strftime("%y%m%d-%H%M:%S.%f"), } acq_i += 1 - prefix = zpacq['prefix'] + '_P{:d}_C{:d}_Z{:d}'.format( - i_s, i_c, i_z) - print('acquring dataset ', acq_i) - with Acquisition(directory=dirpath, name=prefix, show_display=acq_settings['show_display']) as acq: + prefix = zpacq["prefix"] + "_P{:d}_C{:d}_Z{:d}".format( + i_s, i_c, i_z + ) + print("acquring dataset ", acq_i) + with Acquisition( + directory=dirpath, + name=prefix, + show_display=acq_settings["show_display"], + ) as acq: acq.acquire(events) - if acq_settings['show_display']: + if acq_settings["show_display"]: viewer = acq.get_viewer() - time.sleep(.2) - if viewer is not None and acq_settings['close_display_after_acquisition']: + time.sleep(0.2) + if ( + viewer is not None + and acq_settings["close_display_after_acquisition"] + ): viewer.close() - acq_log[acq_i-1]['dummy-acq prefix'] = prefix + acq_log[acq_i - 1]["dummy-acq prefix"] = prefix # acq_log[acq_i-1]['dummy-acq events'] = str(events) # move back in z to initial position - core.set_position(acq_settings['ztags']['pfsoffset'], z_start) - + core.set_position(acq_settings["ztags"]["pfsoffset"], z_start) + # write log - with open(os.path.join( - dirpath, 'acquisition_log.yaml'), 'w') as f: + with open(os.path.join(dirpath, "acquisition_log.yaml"), "w") as f: yaml.dump(acq_log, f) @@ -345,10 +385,10 @@ def zp_iterate_S(acq, core): sval : MultiPosition the position """ - if not acq['usedims']['S'] or acq['S'] == []: + if not acq["usedims"]["S"] or acq["S"] == []: yield 0, 0 else: - for i, pos in enumerate(acq['S']): + for i, pos in enumerate(acq["S"]): pos.go_to_position(pos, core) yield i, pos @@ -365,10 +405,10 @@ def zp_iterate_C(acq, core): cval : int the the channel index """ - if not acq['usedims']['C'] or acq['C'] == []: + if not acq["usedims"]["C"] or acq["C"] == []: yield 0, 0 else: - for i, chan in enumerate(acq['C']): + for i, chan in enumerate(acq["C"]): chan_group = chan.channel_group() filt = chan.config() core.set_config(chan_group, filt) @@ -393,33 +433,38 @@ def zp_iterate_Z(acq, core, acq_settings): zval : float the PFS offset position """ - if not acq['usedims']['Z'] or len(acq['Z']) == 0: + if not acq["usedims"]["Z"] or len(acq["Z"]) == 0: yield 0, 0 else: - for i, zpos in enumerate(acq['Z']): + for i, zpos in enumerate(acq["Z"]): # move to z position zdrive_pos, pfsoffset_pos = move_pfsoffset( - zpos, core, acq_settings['ztags'], acq_settings['jump_factor_settings'], - acq_settings['zmaxiter'], acq_settings['ztolerance']) + zpos, + core, + acq_settings["ztags"], + acq_settings["jump_factor_settings"], + acq_settings["zmaxiter"], + acq_settings["ztolerance"], + ) yield i, zpos, (zdrive_pos, pfsoffset_pos) -if __name__ == '__main__': +if __name__ == "__main__": core = Core() acq_settings = { - 'ztags': { - 'zdrive': 'ZDrive', # set to 'ZPiezo' for piezo-controlled acquisition - 'pfsoffset': 'PFSOffset', - 'pfson': ('PFS', 'FocusMaintenance') + "ztags": { + "zdrive": "ZDrive", # set to 'ZPiezo' for piezo-controlled acquisition + "pfsoffset": "PFSOffset", + "pfson": ("PFS", "FocusMaintenance"), }, - 'jump_factor_settings': { - 'thresholds': (.5, 2), - 'factors': (.2, .4, .6), + "jump_factor_settings": { + "thresholds": (0.5, 2), + "factors": (0.2, 0.4, 0.6), }, - 'ztolerance': .025, # tolerance of z movement in um - 'zmaxiter': 50, # maximum number of iterations for moving in z - 'show_display': True, # apparently, this has to be true, otherwise a pycromanager error gets raised - 'close_display_after_acquisition': True, + "ztolerance": 0.025, # tolerance of z movement in um + "zmaxiter": 50, # maximum number of iterations for moving in z + "show_display": True, # apparently, this has to be true, otherwise a pycromanager error gets raised + "close_display_after_acquisition": True, } start_acq(core, acq_settings) diff --git a/snippets/AriaComm.py b/snippets/AriaComm.py index a445cff..996a7cc 100644 --- a/snippets/AriaComm.py +++ b/snippets/AriaComm.py @@ -12,6 +12,7 @@ >>> connection.close() > """ + import socket @@ -25,31 +26,30 @@ class AriaConnection: the port the server runs on (locally). Must match the setting in Aria software """ + def __init__(self, port=7167): self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server_address = ('localhost', port) + server_address = ("localhost", port) self.sock.bind(server_address) self.sock.listen(1) # exactly one client can connect def wait_for_aria_conn(self): - """connect to the Aria client; blocking. - """ + """connect to the Aria client; blocking.""" self.aria_sock, self.aria_address = self.sock.accept() def send_trigger(self): - """Send the trigger codeword - """ - self.aria_sock.sendall(b'OK') + """Send the trigger codeword""" + self.aria_sock.sendall(b"OK") def sense_trigger(self): """Wait for the trigger codeword. No other messages can be expected in this conversation. """ trig = self.aria_sock.recv(2) - assert trig==b'OK' + assert trig == b"OK" def __del__(self): - #self.aria_sock.shutdown() + # self.aria_sock.shutdown() self.aria_sock.close() - #self.sock.shutdown() + # self.sock.shutdown() self.sock.close() diff --git a/snippets/AriaProtocol.py b/snippets/AriaProtocol.py index 40462ac..f66f017 100644 --- a/snippets/AriaProtocol.py +++ b/snippets/AriaProtocol.py @@ -1,18 +1,20 @@ #!/usr/bin/env python """ - PycroFlow/AriaProtocol.py - ~~~~~~~~~~~~~~~~~~~~~~~~~ +PycroFlow/AriaProtocol.py +~~~~~~~~~~~~~~~~~~~~~~~~~ - Creates protocols for Fluigent Aria. Re-engineered from Aria-saved - protocols. +Creates protocols for Fluigent Aria. Re-engineered from Aria-saved +protocols. - :authors: Heinrich Grabmayr, 2022 - :copyright: Copyright (c) 2022 Jungmann Lab, MPI of Biochemistry +:authors: Heinrich Grabmayr, 2022 +:copyright: Copyright (c) 2022 Jungmann Lab, MPI of Biochemistry """ + import os import yaml from datetime import date, datetime + def create_protocol(config, base_name): """Create a protocol based on a configuration file. @@ -23,38 +25,38 @@ def create_protocol(config, base_name): fname = filename of saved protocol """ basic_protocol = { - "UserComment": 'null', + "UserComment": "null", } protocol_addition = { "InjectionMethod": 0, - "ZeroPressureBeforeSwitch": 'false', + "ZeroPressureBeforeSwitch": "false", "DiffusionLeadVolume": "0 µl", "DiffusionLagVolume": "0 µl", "DiffusionBufferVolume": "0 µl", "BufferReservoir": 9, "StartTime": "2022-10-21T17:07:47.6951287+02:00", - "StartAsap": 'true', + "StartAsap": "true", "PrefillStep": { - "PrefillEnabled": 'true', + "PrefillEnabled": "true", "WarningMessage": "", - "DisplayWarning": 'false', - "WarningType": 0 + "DisplayWarning": "false", + "WarningType": 0, }, - "PreloadFlowRatePreset": 2 + "PreloadFlowRatePreset": 2, } steps, reservoir_vols, imground_descriptions = create_steps(config) basic_protocol["Steps"] = steps - reservoirs = create_reservoirs(config['reservoir_names'], reservoir_vols) - basic_protocol['Reservoirs'] = reservoirs + reservoirs = create_reservoirs(config["reservoir_names"], reservoir_vols) + basic_protocol["Reservoirs"] = reservoirs for k, v in protocol_addition.items(): basic_protocol[k] = v # save protocol - fname = base_name + datetime.now().strftime('_%y%m%d-%H%M') + '.aseq' - filename = os.path.join(config['protocol_folder'], fname) + fname = base_name + datetime.now().strftime("_%y%m%d-%H%M") + ".aseq" + filename = os.path.join(config["protocol_folder"], fname) # with open(filename, 'w') as f: # yaml.dump(basic_protocol, f, default_flow_style=True, canonical=True, default_style='"') @@ -72,7 +74,10 @@ def create_reservoirs(reservoir_names, reservoir_vols): reservoirs = [] for reservoirnr, name in reservoir_names.items(): reservoirs.append( - create_reservoir(reservoirnr-1, name, reservoir_vols.get(name, 0))) + create_reservoir( + reservoirnr - 1, name, reservoir_vols.get(name, 0) + ) + ) return reservoirs @@ -91,10 +96,11 @@ def create_reservoir(idx, name, vol): "Name": name, "Volume": "{:d} µl".format(vol), "Size": size, # 1: the 8 in front; 2: the 2 on the side - "IsOverCapacity": 'false' + "IsOverCapacity": "false", } return reservoir + def create_steps(config): """Creates the protocol steps one after another @@ -104,31 +110,36 @@ def create_steps(config): reservoir_vols : dict keys: reservoir names, values: volumes """ - if config['experiment']['type'] == 'Exchange': - experiment = config['experiment'] - reservoirs = config['reservoir_names'] - imager_vol = config['vol_imager'] - wash_vol = config['vol_wash'] - use_ttl = config['use_TTL'] + if config["experiment"]["type"] == "Exchange": + experiment = config["experiment"] + reservoirs = config["reservoir_names"] + imager_vol = config["vol_imager"] + wash_vol = config["vol_wash"] + use_ttl = config["use_TTL"] steps, reservoir_vols, imground_descriptions = create_steps_Exchange( - experiment, reservoirs, imager_vol, wash_vol, use_ttl=use_ttl) - elif config['experiment']['type'] == 'MERPAINT': + experiment, reservoirs, imager_vol, wash_vol, use_ttl=use_ttl + ) + elif config["experiment"]["type"] == "MERPAINT": steps, reservoir_vols, imground_descriptions = create_steps_MERPAINT( - config) - elif config['experiment']['type'] == 'FlushTest': - experiment = config['experiment'] - reservoirs = config['reservoir_names'] - use_ttl = config['use_TTL'] + config + ) + elif config["experiment"]["type"] == "FlushTest": + experiment = config["experiment"] + reservoirs = config["reservoir_names"] + use_ttl = config["use_TTL"] steps, reservoir_vols, imground_descriptions = create_steps_FlushTest( - experiment, reservoirs, use_ttl=use_ttl) + experiment, reservoirs, use_ttl=use_ttl + ) else: raise KeyError( - 'Experiment type {:s} not implemented.'.format(config['type'])) + "Experiment type {:s} not implemented.".format(config["type"]) + ) return steps, reservoir_vols, imground_descriptions -def create_steps_Exchange(experiment, reservoirs, imager_vol, wash_vol, - use_ttl=False): +def create_steps_Exchange( + experiment, reservoirs, imager_vol, wash_vol, use_ttl=False +): """Creates the protocol steps for an Exchange-PAINT experiment Args: experiment : dict @@ -149,19 +160,22 @@ def create_steps_Exchange(experiment, reservoirs, imager_vol, wash_vol, a description of each imaging round """ # check that all mentioned sources acqually exist - assert experiment['wash_buffer'] in reservoirs.values() - assert all([name in reservoirs.values() for name in experiment['imagers']]) + assert experiment["wash_buffer"] in reservoirs.values() + assert all([name in reservoirs.values() for name in experiment["imagers"]]) - washbuf = experiment['wash_buffer'] - res_idcs = {name: nr-1 for nr, name in reservoirs.items()} + washbuf = experiment["wash_buffer"] + res_idcs = {name: nr - 1 for nr, name in reservoirs.items()} speed = 80.0 # maximum speed with Flow Sensor S steps = [] imground_descriptions = [] step_idx = 1 - steps.append(create_step_inject( - step_idx, 10, speed, res_idcs[washbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, 10, speed, res_idcs[washbuf], TTL_at_end=True + ) + ) reservoir_vols = {washbuf: 10} step_idx = 2 if use_ttl: @@ -170,12 +184,21 @@ def create_steps_Exchange(experiment, reservoirs, imager_vol, wash_vol, steps.append(create_step_sendTCP(step_idx)) step_idx += 1 steps.append(create_step_waitforTCP(step_idx)) - for round, imager in enumerate(experiment['imagers']): + for round, imager in enumerate(experiment["imagers"]): imground_descriptions.append(imager) step_idx += 1 - steps.append(create_step_inject( - step_idx, int(0.8*imager_vol), speed, res_idcs[imager], TTL_at_end=True)) - reservoir_vols[imager] = reservoir_vols.get(imager, 0) + int(0.8*imager_vol) + steps.append( + create_step_inject( + step_idx, + int(0.8 * imager_vol), + speed, + res_idcs[imager], + TTL_at_end=True, + ) + ) + reservoir_vols[imager] = reservoir_vols.get(imager, 0) + int( + 0.8 * imager_vol + ) if not use_ttl: step_idx += 1 steps.append(create_step_sendTCP(step_idx)) @@ -187,20 +210,36 @@ def create_steps_Exchange(experiment, reservoirs, imager_vol, wash_vol, steps.append(create_step_waitforTCP(step_idx)) step_idx += 1 - steps.append(create_step_inject( - step_idx, int(0.2*imager_vol), speed, res_idcs[imager], TTL_at_end=True)) - reservoir_vols[imager] = reservoir_vols.get(imager, 0) + int(0.2*imager_vol) - - if round < len(experiment['imagers'])-1: + steps.append( + create_step_inject( + step_idx, + int(0.2 * imager_vol), + speed, + res_idcs[imager], + TTL_at_end=True, + ) + ) + reservoir_vols[imager] = reservoir_vols.get(imager, 0) + int( + 0.2 * imager_vol + ) + + if round < len(experiment["imagers"]) - 1: step_idx += 1 - steps.append(create_step_inject( - step_idx, wash_vol, speed, res_idcs[washbuf], TTL_at_end=False)) + steps.append( + create_step_inject( + step_idx, + wash_vol, + speed, + res_idcs[washbuf], + TTL_at_end=False, + ) + ) reservoir_vols[washbuf] = reservoir_vols.get(washbuf, 0) + wash_vol return steps, reservoir_vols, imground_descriptions -def create_steps_MERPAINT(experiment, reservoirs, - use_ttl=False): + +def create_steps_MERPAINT(experiment, reservoirs, use_ttl=False): """Creates the protocol steps for an MERPAINT experiment Args: experiment : dict @@ -246,77 +285,105 @@ def create_steps_MERPAINT(experiment, reservoirs, keys: reservoir names, values: volumes """ # check that all mentioned sources acqually exist - assert experiment['wash_buffer'] in reservoirs.values() - assert experiment['hybridization_buffer'] in reservoirs.values() - assert experiment['imaging_buffer'] in reservoirs.values() - assert all([name in reservoirs.values() for name in experiment['imagers']]) - assert all([name in reservoirs.values() for name in experiment['adapters']]) - assert all([name in reservoirs.values() for name in experiment['erasers']]) - - washbuf = experiment['wash_buffer'] - hybbuf = experiment['hybridization_buffer'] - imgbuf = experiment['imaging_buffer'] - washvol = experiment['wash_buffer_vol'] - hybvol = experiment['hybridization_buffer_vol'] - imgbufvol = experiment['imaging_buffer_vol'] - imagervol = experiment['imager_vol'] - adaptervol = experiment['adapter_vol'] - eraservol = experiment['adapter_vol'] - hybtime = experiment['hybridization_time'] - - darkframes = experiment.get('check_dark_frames') + assert experiment["wash_buffer"] in reservoirs.values() + assert experiment["hybridization_buffer"] in reservoirs.values() + assert experiment["imaging_buffer"] in reservoirs.values() + assert all([name in reservoirs.values() for name in experiment["imagers"]]) + assert all( + [name in reservoirs.values() for name in experiment["adapters"]] + ) + assert all([name in reservoirs.values() for name in experiment["erasers"]]) + + washbuf = experiment["wash_buffer"] + hybbuf = experiment["hybridization_buffer"] + imgbuf = experiment["imaging_buffer"] + washvol = experiment["wash_buffer_vol"] + hybvol = experiment["hybridization_buffer_vol"] + imgbufvol = experiment["imaging_buffer_vol"] + imagervol = experiment["imager_vol"] + adaptervol = experiment["adapter_vol"] + eraservol = experiment["adapter_vol"] + hybtime = experiment["hybridization_time"] + + darkframes = experiment.get("check_dark_frames") if darkframes: check_dark_frames = True else: check_dark_frames = False darkframes = 0 - res_idcs = {name: nr-1 for nr, name in reservoirs.items()} + res_idcs = {name: nr - 1 for nr, name in reservoirs.items()} speed = 80.0 # maximum speed with Flow Sensor S steps = [] step_idx = 1 - steps.append(create_step_inject( - step_idx, 10, speed, res_idcs[washbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, 10, speed, res_idcs[washbuf], TTL_at_end=True + ) + ) reservoir_vols = {washbuf: 10} step_idx = 2 if use_ttl: steps.append(create_step_waitforTTL(step_idx)) else: steps.append(create_step_waitforTCP(step_idx)) - for merpaintround, (adapter, eraser) in enumerate(zip(experiment - ['adapters'], experiment['erasers'])): + for merpaintround, (adapter, eraser) in enumerate( + zip(experiment["adapters"], experiment["erasers"]) + ): # hybridization buffer step_idx += 1 - steps.append(create_step_inject( - step_idx, hybvol, speed, res_idcs[hybbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, hybvol, speed, res_idcs[hybbuf], TTL_at_end=True + ) + ) reservoir_vols[hybbuf] = reservoir_vols.get(hybbuf, 0) + hybvol # adapter step_idx += 1 - steps.append(create_step_inject( - step_idx, adaptervol, speed, res_idcs[adapter], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, adaptervol, speed, res_idcs[adapter], TTL_at_end=True + ) + ) reservoir_vols[adapter] = reservoir_vols.get(adapter, 0) + adaptervol # incubation step_idx += 1 - steps.append(create_step_incubate( - step_idx, hybtime)) + steps.append(create_step_incubate(step_idx, hybtime)) # 2xSSC step_idx += 1 - steps.append(create_step_inject( - step_idx, washvol, speed, res_idcs[washbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, washvol, speed, res_idcs[washbuf], TTL_at_end=True + ) + ) reservoir_vols[washbuf] = reservoir_vols.get(washbuf, 0) + washvol # iterate over imagers for imager_round, imager in enumerate(imagers): # imaging buffer step_idx += 1 - steps.append(create_step_inject( - step_idx, imgbufvol, speed, res_idcs[imgbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, + imgbufvol, + speed, + res_idcs[imgbuf], + TTL_at_end=True, + ) + ) reservoir_vols[imgbuf] = reservoir_vols.get(imgbuf, 0) + imgbufvol # imager step_idx += 1 - steps.append(create_step_inject( - step_idx, imagervol, speed, res_idcs[imager], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, + imagervol, + speed, + res_idcs[imager], + TTL_at_end=True, + ) + ) reservoir_vols[imager] = reservoir_vols.get(imager, 0) + imagervol if not use_ttl: @@ -332,33 +399,51 @@ def create_steps_MERPAINT(experiment, reservoirs, # de-hybridize adapter # washbuf step_idx += 1 - steps.append(create_step_inject( - step_idx, washvol, speed, res_idcs[washbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, washvol, speed, res_idcs[washbuf], TTL_at_end=True + ) + ) reservoir_vols[washbuf] = reservoir_vols.get(washbuf, 0) + washvol # hybridization buffer step_idx += 1 - steps.append(create_step_inject( - step_idx, hybvol, speed, res_idcs[hybbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, hybvol, speed, res_idcs[hybbuf], TTL_at_end=True + ) + ) reservoir_vols[hybbuf] = reservoir_vols.get(hybbuf, 0) + hybvol # adapter step_idx += 1 - steps.append(create_step_inject( - step_idx, adaptervol, speed, res_idcs[adapter], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, adaptervol, speed, res_idcs[adapter], TTL_at_end=True + ) + ) reservoir_vols[adapter] = reservoir_vols.get(adapter, 0) + adaptervol # incubation step_idx += 1 - steps.append(create_step_incubate( - step_idx, hybtime)) + steps.append(create_step_incubate(step_idx, hybtime)) # washbuf step_idx += 1 - steps.append(create_step_inject( - step_idx, washvol, speed, res_idcs[washbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, washvol, speed, res_idcs[washbuf], TTL_at_end=True + ) + ) reservoir_vols[washbuf] = reservoir_vols.get(washbuf, 0) + washvol if check_dark_frames: # imaging buffer step_idx += 1 - steps.append(create_step_inject( - step_idx, imgbufvol, speed, res_idcs[imgbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, + imgbufvol, + speed, + res_idcs[imgbuf], + TTL_at_end=True, + ) + ) reservoir_vols[imgbuf] = reservoir_vols.get(imgbuf, 0) + imgbufvol # acquire movie if not use_ttl: @@ -372,8 +457,15 @@ def create_steps_MERPAINT(experiment, reservoirs, steps.append(create_step_waitforTCP(step_idx)) # washbuf step_idx += 1 - steps.append(create_step_inject( - step_idx, washvol, speed, res_idcs[washbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, + washvol, + speed, + res_idcs[washbuf], + TTL_at_end=True, + ) + ) reservoir_vols[washbuf] = reservoir_vols.get(washbuf, 0) + washvol return steps, reservoir_vols @@ -400,10 +492,10 @@ def create_steps_FlushTest(experiment, reservoirs, use_ttl=False): imground_descriptions : list of str a description of each imaging round """ - assert experiment['wash_buffer'] in reservoirs.values() - assert all([name in reservoirs.values() for name in experiment['fluids']]) + assert experiment["wash_buffer"] in reservoirs.values() + assert all([name in reservoirs.values() for name in experiment["fluids"]]) - washbuf = experiment['wash_buffer'] + washbuf = experiment["wash_buffer"] res_idcs = {name: nr - 1 for nr, name in reservoirs.items()} speed = 30.0 # maximum speed for HybBuf @@ -412,8 +504,11 @@ def create_steps_FlushTest(experiment, reservoirs, use_ttl=False): # extended prefill step_idx = 1 - steps.append(create_step_inject( - step_idx, 10, speed, res_idcs[washbuf], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, 10, speed, res_idcs[washbuf], TTL_at_end=True + ) + ) reservoir_vols = {washbuf: 10} # sync aria and computer step_idx = 2 @@ -424,12 +519,16 @@ def create_steps_FlushTest(experiment, reservoirs, use_ttl=False): step_idx += 1 steps.append(create_step_waitforTCP(step_idx)) for round, (fluid, fluid_vol) in enumerate( - zip(experiment['fluids'], experiment['fluid_vols'])): + zip(experiment["fluids"], experiment["fluid_vols"]) + ): imground_descriptions.append(fluid) # send minimal amount of fluid, for TTL to trigger acquisition if used step_idx += 1 - steps.append(create_step_inject( - step_idx, 1, speed, res_idcs[fluid], TTL_at_end=True)) + steps.append( + create_step_inject( + step_idx, 1, speed, res_idcs[fluid], TTL_at_end=True + ) + ) reservoir_vols[fluid] = reservoir_vols.get(fluid, 0) + int(fluid_vol) if not use_ttl: step_idx += 1 @@ -437,9 +536,15 @@ def create_steps_FlushTest(experiment, reservoirs, use_ttl=False): # flush during acquisition step_idx += 1 - steps.append(create_step_inject( - step_idx, int(fluid_vol), speed, res_idcs[fluid], - TTL_at_end=False)) + steps.append( + create_step_inject( + step_idx, + int(fluid_vol), + speed, + res_idcs[fluid], + TTL_at_end=False, + ) + ) reservoir_vols[fluid] = reservoir_vols.get(fluid, 0) + int(fluid_vol) step_idx += 1 @@ -463,18 +568,19 @@ def create_step_incubate(step_idx, t_incu): step : dict the step configuration """ - timeoutstr = datetime.timedelta(seconds=t_incu).strftime('%H:%M:%S') + timeoutstr = datetime.timedelta(seconds=t_incu).strftime("%H:%M:%S") step = { "$type": "Incubate", "Duration": timeoutstr, "Description": "Incubate for " + timeoutstr, "Index": step_idx, "StepNumber": step_idx, - "TtlStart": 'false', - "TtlEnd": 'false' - } + "TtlStart": "false", + "TtlEnd": "false", + } return step + def create_step_waitforTTL(step_idx): """Creates a step to wait for a TTL pulse. @@ -488,11 +594,12 @@ def create_step_waitforTTL(step_idx): "Timeout": "12:00:00", "Index": step_idx, "StepNumber": step_idx, - "TtlStart": 'false', - "TtlEnd": 'false' - } + "TtlStart": "false", + "TtlEnd": "false", + } return step + def create_step_waitforTCP(step_idx): """Creates a step to wait for a TCP/IP signal. @@ -507,13 +614,14 @@ def create_step_waitforTCP(step_idx): "Timeout": "12:00:00", "Index": step_idx, "StepNumber": step_idx, - "TtlStart": 'false', + "TtlStart": "false", "StartSignalType": 0, - "TtlEnd": 'false', - "EndSignalType": 0 - } + "TtlEnd": "false", + "EndSignalType": 0, + } return step + def create_step_sendTCP(step_idx): """Creates a step to send a TCP/IP signal. @@ -525,18 +633,18 @@ def create_step_sendTCP(step_idx): "$type": "SendExternalSignal", "SignalType": 1, "Message": "OK", - "Description": "Send TCP message \\\"OK\\\"", + "Description": 'Send TCP message \\"OK\\"', "Index": step_idx, "StepNumber": step_idx, - "TtlStart": 'false', + "TtlStart": "false", "StartSignalType": 0, - "TtlEnd": 'false', - "EndSignalType": 0 - } + "TtlEnd": "false", + "EndSignalType": 0, + } return step -def create_step_inject( - step_idx, volume, speed, reservoir_idx, TTL_at_end): + +def create_step_inject(step_idx, volume, speed, reservoir_idx, TTL_at_end): """Creates a step to wait for a TTL pulse. Args: step_idx : int @@ -557,27 +665,29 @@ def create_step_inject( "$type": "InjectVolume", "Volume": "{:d} µl".format(volume), "Description": ( - "Inject {:d} µl".format(volume) + - " from Reservoir {:d}".format(reservoir_idx+1) + - " into Chip2 at {:.0f} µl/min".format(speed)), # here it says Chip2, in the Aria GUI it says Chip1 + "Inject {:d} µl".format(volume) + + " from Reservoir {:d}".format(reservoir_idx + 1) + + " into Chip2 at {:.0f} µl/min".format(speed) + ), # here it says Chip2, in the Aria GUI it says Chip1 "DefaultQ": speed, "Reservoir": reservoir_idx, "Qorder": "{:.0f} µl/min".format(speed), "StringInjectionDestinations": "1, ", # we only have the one chip "Index": step_idx, "StepNumber": 0, # for whatever reason, this is always 0 for injection - "TtlStart": 'false', + "TtlStart": "false", "StartSignalType": 0, - } + } if TTL_at_end: - step['Description'] = step['Description'] + ' (TTL)' - step['TtlEnd'] = 'true' + step["Description"] = step["Description"] + " (TTL)" + step["TtlEnd"] = "true" else: - step['TtlEnd'] = 'false' + step["TtlEnd"] = "false" step["EndSignalType"] = 0 return step + def write_to_file(fname, d): """write the protocol to file. @@ -587,25 +697,26 @@ def write_to_file(fname, d): d : dict the protocol """ - with open(fname, 'wb') as f: + with open(fname, "wb") as f: write_dict(f, d, islast=True) -def write_dict(fh, d, indent_lvl=0, key='', islast=False): + +def write_dict(fh, d, indent_lvl=0, key="", islast=False): """Start a dict Args: fh : file handle """ - indents = ' '*2*indent_lvl - if key == '': - writeline(fh, indents+'{\n') + indents = " " * 2 * indent_lvl + if key == "": + writeline(fh, indents + "{\n") else: key = '"' + key + '"' - writeline(fh, indents+str(key)+': {\n') + writeline(fh, indents + str(key) + ": {\n") indent_lvl += 1 - indents = ' '*2*indent_lvl + indents = " " * 2 * indent_lvl N = len(d.keys()) for i, (k, v) in enumerate(d.items()): - if i == N-1: + if i == N - 1: sub_islast = True else: sub_islast = False @@ -615,37 +726,38 @@ def write_dict(fh, d, indent_lvl=0, key='', islast=False): write_list(fh, v, indent_lvl, k, islast=sub_islast) else: if isinstance(v, str): - if v not in ['true', 'false', 'null']: + if v not in ["true", "false", "null"]: v = '"' + v + '"' elif isinstance(v, int): v = str(v) elif isinstance(v, float): - v = '{:.1f}'.format(v) + v = "{:.1f}".format(v) else: raise NotImplmentedError() if not sub_islast: - v += ',' + v += "," k = '"' + k + '"' - writeline(fh, indents+str(k)+': '+v+'\n') - indent_lvl -=1 - indents = ' '*2*indent_lvl + writeline(fh, indents + str(k) + ": " + v + "\n") + indent_lvl -= 1 + indents = " " * 2 * indent_lvl if islast: - writeline(fh, indents+'}\n') + writeline(fh, indents + "}\n") else: - writeline(fh, indents+'},\n') + writeline(fh, indents + "},\n") + -def write_list(fh, l, indent_lvl=0, key='', islast=False): - indents = ' '*2*indent_lvl - if key == '': - writeline(fh, indents+'[\n') +def write_list(fh, l, indent_lvl=0, key="", islast=False): + indents = " " * 2 * indent_lvl + if key == "": + writeline(fh, indents + "[\n") else: key = '"' + key + '"' - writeline(fh, indents+str(key)+': [\n') + writeline(fh, indents + str(key) + ": [\n") indent_lvl += 1 - indents = ' '*2*indent_lvl + indents = " " * 2 * indent_lvl N = len(l) for i, v in enumerate(l): - if i == N-1: + if i == N - 1: sub_islast = True else: sub_islast = False @@ -655,26 +767,28 @@ def write_list(fh, l, indent_lvl=0, key='', islast=False): write_list(fh, v, indent_lvl, islast=sub_islast) else: if isinstance(v, str): - if v not in ['true', 'false', 'null']: + if v not in ["true", "false", "null"]: v = '"' + v + '"' elif isinstance(v, int): v = str(v) elif isinstance(v, float): - v = '{:.1f}'.format(v) + v = "{:.1f}".format(v) else: raise NotImplmentedError() if not sub_islast: - v += ',' - writeline(fh, indents+v+'\n') - indent_lvl -=1 - indents = ' '*2*indent_lvl + v += "," + writeline(fh, indents + v + "\n") + indent_lvl -= 1 + indents = " " * 2 * indent_lvl if islast: - writeline(fh, indents+']\n') + writeline(fh, indents + "]\n") else: - writeline(fh, indents+'],\n') + writeline(fh, indents + "],\n") + def writeline(fh, line): - fh.write(line.encode('utf8')) + fh.write(line.encode("utf8")) + def write_entry(fh, e, indent_lvl=0): pass diff --git a/snippets/FlowAcquisition.py b/snippets/FlowAcquisition.py index a028784..2360740 100644 --- a/snippets/FlowAcquisition.py +++ b/snippets/FlowAcquisition.py @@ -1,34 +1,35 @@ #!/usr/bin/env python """ - PycroFlow/FlowAcquisition.py - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - A first script to start on the topic of automating Exchange-PAINT - experiments using Pycromanager and Fluigent Aria. - - Usage: - * Start this program first (python FlowAcquisition.py in pycroflow environemnt) - * Start Aria - * follow instructions in command line - - Aria Protocol for simple exchange experiment: - * 10ul Buffer injection, ending in TTL - * Wait for external TTL - (here, a pause can be made, for connecting the slide) - * iteratively, for all rounds: - - inject 200ul respective imager, ending with TTL - - Wait for external TTL (here, the acquisition takes place) - - inject 1000ul buffer - - Aria Protocol for MERPAINT experiment: - * 10ul Buffer injection, ending in TTL - * Wait for external TTL - (here, a pause can be made, for connecting the slide) - * same as above, but with interleaved hybridization of multiplex-adapter - - :authors: Heinrich Grabmayr, 2022 - :copyright: Copyright (c) 2022 Jungmann Lab, MPI of Biochemistry +PycroFlow/FlowAcquisition.py +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A first script to start on the topic of automating Exchange-PAINT +experiments using Pycromanager and Fluigent Aria. + +Usage: +* Start this program first (python FlowAcquisition.py in pycroflow environemnt) +* Start Aria +* follow instructions in command line + +Aria Protocol for simple exchange experiment: +* 10ul Buffer injection, ending in TTL +* Wait for external TTL + (here, a pause can be made, for connecting the slide) +* iteratively, for all rounds: + - inject 200ul respective imager, ending with TTL + - Wait for external TTL (here, the acquisition takes place) + - inject 1000ul buffer + +Aria Protocol for MERPAINT experiment: +* 10ul Buffer injection, ending in TTL +* Wait for external TTL + (here, a pause can be made, for connecting the slide) +* same as above, but with interleaved hybridization of multiplex-adapter + +:authors: Heinrich Grabmayr, 2022 +:copyright: Copyright (c) 2022 Jungmann Lab, MPI of Biochemistry """ + import logging import sys import os @@ -36,7 +37,14 @@ from icecream import ic from inputimeout import inputimeout, TimeoutOccurred -from pycromanager import Acquisition, multi_d_acquisition_events, start_headless, Core, Studio +from pycromanager import ( + Acquisition, + multi_d_acquisition_events, + start_headless, + Core, + Studio, +) + # import monet.control as mcont from arduino_connection import AriaTrigger from AriaComm import AriaConnection @@ -46,18 +54,16 @@ from time import sleep import yaml -sys.path.insert(0, 'Z:\\users\\grabmayr\\power_calibration\\monet') +sys.path.insert(0, "Z:\\users\\grabmayr\\power_calibration\\monet") from PycroFlow.monet import CONFIGS from PycroFlow.monet.control import IlluminationLaserControl as ILC - - logger = logging.getLogger(__name__) ic.configureOutput(outputFunction=logger.debug) -mm_app_path = r'C:\Program Files\Micro-Manager-2.0' -config_file = r'C:\Users\miblab\Desktop\MMConfig_1.cfg' +mm_app_path = r"C:\Program Files\Micro-Manager-2.0" +config_file = r"C:\Users\miblab\Desktop\MMConfig_1.cfg" # # save_dir = r"Z:\users\grabmayr\FlowAutomation\testdata" # base_name = 'exchange_experiment' @@ -70,57 +76,80 @@ # laser_power = 35 flow_acq_config = { - 'rounds': 10, - 'frames': 50000, - 't_exp': 100, # in ms - 'ROI': [512, 512, 512, 512], - 'save_dir': r'Z:\users\grabmayr\microscopy_data',#r"Z:\users\grabmayr\FlowAutomation\testdata", - 'base_name': 'AutomationTest_R2R4', - 'aria_parameters': { - 'use_TTL': False, - 'max_flowstep': 30*60, # in s - 'TTL_duration': 0.3, # in s - 'vol_wash': 500, # in ul - 'vol_imager': 500, # in ul - 'reservoir_names': { - 1: 'R1', 2: 'empt', 3: 'R3', 4: 'empt', 5: 'R5', 6: 'R6', - 7: 'R2', 8: 'R4', 9: 'Res9', 10: 'Buffer B+'}, - 'experiment' : { - 'type': 'Exchange', # options: ['Exchange', 'MERPAINT', 'FlushTest'] - 'wash_buffer': 'Buffer B+', - 'imagers': ['R4', 'R2', 'R4', 'R2', 'R4', 'R2', 'R4', 'R2', 'R4', 'R2'], -# 'imagers': ['500 pM P 3', '500 pM DB', '1 nM DB'], -# 'imagers': ['R 2', 'R 4'], -# 'imagers': ['R 4'], + "rounds": 10, + "frames": 50000, + "t_exp": 100, # in ms + "ROI": [512, 512, 512, 512], + "save_dir": r"Z:\users\grabmayr\microscopy_data", # r"Z:\users\grabmayr\FlowAutomation\testdata", + "base_name": "AutomationTest_R2R4", + "aria_parameters": { + "use_TTL": False, + "max_flowstep": 30 * 60, # in s + "TTL_duration": 0.3, # in s + "vol_wash": 500, # in ul + "vol_imager": 500, # in ul + "reservoir_names": { + 1: "R1", + 2: "empt", + 3: "R3", + 4: "empt", + 5: "R5", + 6: "R6", + 7: "R2", + 8: "R4", + 9: "Res9", + 10: "Buffer B+", + }, + "experiment": { + "type": "Exchange", # options: ['Exchange', 'MERPAINT', 'FlushTest'] + "wash_buffer": "Buffer B+", + "imagers": [ + "R4", + "R2", + "R4", + "R2", + "R4", + "R2", + "R4", + "R2", + "R4", + "R2", + ], + # 'imagers': ['500 pM P 3', '500 pM DB', '1 nM DB'], + # 'imagers': ['R 2', 'R 4'], + # 'imagers': ['R 4'], }, # 'protocol_folder': r'Z:\users\grabmayr\microscopy_data' - 'protocol_folder': r'C:\Users\miblab\AppData\Local\Fluigent\Aria\Sequences' + "protocol_folder": r"C:\Users\miblab\AppData\Local\Fluigent\Aria\Sequences", + }, + "mm_parameters": { + "mm_app_path": r"C:\Program Files\Micro-Manager-2.0", + "mm_config_file": r"C:\Users\miblab\Desktop\MMConfig_1.cfg", + "channel_group": "Filter turret", + "filter": "2-G561", }, - 'mm_parameters': { - 'mm_app_path': r'C:\Program Files\Micro-Manager-2.0', - 'mm_config_file': r'C:\Users\miblab\Desktop\MMConfig_1.cfg', - 'channel_group': 'Filter turret', - 'filter': '2-G561', + "illu_parameters": { + "setup": "Mercury", + "laser": 560, + "power": 30, # mW }, - 'illu_parameters': { - 'setup': 'Mercury', - 'laser': 560, - 'power': 30, #mW - } } def optional_break(timeout=5): try: ipt = inputimeout( - 'Proceed? [Y/N - default Y, respond within {:.1f}s]'.format(timeout), - timeout=timeout) + "Proceed? [Y/N - default Y, respond within {:.1f}s]".format( + timeout + ), + timeout=timeout, + ) except TimeoutOccurred: - ipt = 'Y' - if 'N' in ipt.upper(): + ipt = "Y" + if "N" in ipt.upper(): # user input - ipt = input('Enter anything when ready.') - print('proceeding.') + ipt = input("Enter anything when ready.") + print("proceeding.") def main(acquisition_config, dry_run=False, break_for_slide=True): @@ -133,22 +162,27 @@ def main(acquisition_config, dry_run=False, break_for_slide=True): break_for_slide : bool do a nonoptional break for slide connection """ - starttime_str = datetime.now().strftime('_%y-%m-%d_%H%M') + starttime_str = datetime.now().strftime("_%y-%m-%d_%H%M") sdir = os.path.join( - acquisition_config['save_dir'], - datetime.now().strftime('%y%m%d')+'_'+acquisition_config['base_name']) + acquisition_config["save_dir"], + datetime.now().strftime("%y%m%d") + + "_" + + acquisition_config["base_name"], + ) if not os.path.exists(sdir): os.mkdir(sdir) - acquisition_config['save_dir'] = sdir - with open(os.path.join(sdir, 'acquisition_configuration.yaml'), 'w') as f: + acquisition_config["save_dir"] = sdir + with open(os.path.join(sdir, "acquisition_configuration.yaml"), "w") as f: yaml.dump(acquisition_config, f) # start power control - if 'illu_parameters' in acquisition_config.keys(): - illuconfig = CONFIGS[acquisition_config['illu_parameters']['setup']] + if "illu_parameters" in acquisition_config.keys(): + illuconfig = CONFIGS[acquisition_config["illu_parameters"]["setup"]] laserlaunch = ILC(illuconfig) - laserlaunch.laser = acquisition_config['illu_parameters']['laser'] # nm + laserlaunch.laser = acquisition_config["illu_parameters"][ + "laser" + ] # nm laserlaunch.power = 1 # mW # Start the Java process @@ -156,145 +190,171 @@ def main(acquisition_config, dry_run=False, break_for_slide=True): core = Core() studio = Studio(convert_camel_case=True) - print('Connected to Micromanager.') + print("Connected to Micromanager.") # test the possibility to acquire (fail early) if studio.live().is_live_mode_on(): studio.live().set_live_mode_on(False) events = multi_d_acquisition_events( - num_time_points=10,time_interval_s=.1) + num_time_points=10, time_interval_s=0.1 + ) with Acquisition( - directory=acquisition_config['save_dir'], name='testacquisition', - show_display=False, debug=True) as acq: + directory=acquisition_config["save_dir"], + name="testacquisition", + show_display=False, + debug=True, + ) as acq: acq.acquire(events) # start aria triggering connection if not dry_run: - protocol_file = '' + protocol_file = "" protocol_file, imground_descriptions = _create_protocol( - acquisition_config['aria_parameters'], - acquisition_config['base_name']) - if acquisition_config['aria_parameters']['use_TTL']: - aria = AriaTrigger(acquisition_config['aria_parameters']) - print('Please set Aria TTL duration to 300 ms, load Aria protocol {:s} and start it now.'.format( - protocol_file)) + acquisition_config["aria_parameters"], + acquisition_config["base_name"], + ) + if acquisition_config["aria_parameters"]["use_TTL"]: + aria = AriaTrigger(acquisition_config["aria_parameters"]) + print( + "Please set Aria TTL duration to 300 ms, load Aria protocol {:s} and start it now.".format( + protocol_file + ) + ) else: aria = AriaConnection() - print('Please load Aria protocol {:s} and start it now.'.format( - protocol_file)) + print( + "Please load Aria protocol {:s} and start it now.".format( + protocol_file + ) + ) aria.wait_for_aria_conn() - print('initialized triggering.') + print("initialized triggering.") # first item in aria protocol must be a minute buffer injection, ending # with a trigger signal, and followed by a "wait for TTL" step if not dry_run: - print('Waiting for aria to have pre-injected the buffers.') + print("Waiting for aria to have pre-injected the buffers.") aria.sense_trigger() tic = time.time() - print('Ready to connect and mount slide.') + print("Ready to connect and mount slide.") if break_for_slide: - input('Press Enter to continue') + input("Press Enter to continue") else: optional_break() - twait = max([0, tic+15-time.time()]) + twait = max([0, tic + 15 - time.time()]) time.sleep(twait) aria.send_trigger() for round, desc in enumerate(imground_descriptions): - acq_name = (acquisition_config['base_name'] + - starttime_str + '_round{:d}_{:s}'.format(round, desc)) + acq_name = ( + acquisition_config["base_name"] + + starttime_str + + "_round{:d}_{:s}".format(round, desc) + ) if not dry_run: - print('waiting for Aria pulsing to signal readiness for round {:d}'.format(round)) + print( + "waiting for Aria pulsing to signal readiness for round {:d}".format( + round + ) + ) aria.sense_trigger() - print('received trigger') - print('About to start acquisition {:s}.'.format(acq_name)) - if round==0 and break_for_slide: - if 'illu_parameters' in acquisition_config.keys(): - laserlaunch.power = 20 # mW - input('Check focus. Stop Live View when done. Press Enter to continue.') - if round==0 and not break_for_slide: - if 'illu_parameters' in acquisition_config.keys(): - laserlaunch.power = 20 # mW - print('Now you could check focus. Stop Live View when done.') + print("received trigger") + print("About to start acquisition {:s}.".format(acq_name)) + if round == 0 and break_for_slide: + if "illu_parameters" in acquisition_config.keys(): + laserlaunch.power = 20 # mW + input( + "Check focus. Stop Live View when done. Press Enter to continue." + ) + if round == 0 and not break_for_slide: + if "illu_parameters" in acquisition_config.keys(): + laserlaunch.power = 20 # mW + print("Now you could check focus. Stop Live View when done.") optional_break(timeout=5) else: optional_break(timeout=5) - if 'illu_parameters' in acquisition_config.keys(): - laserlaunch.power = acquisition_config['illu_parameters']['power'] # mW + if "illu_parameters" in acquisition_config.keys(): + laserlaunch.power = acquisition_config["illu_parameters"][ + "power" + ] # mW if studio.live().is_live_mode_on(): studio.live().set_live_mode_on(False) record_movie(acq_name, acquisition_config, core) - if 'illu_parameters' in acquisition_config.keys(): - laserlaunch.power = 1 # mW + if "illu_parameters" in acquisition_config.keys(): + laserlaunch.power = 1 # mW - print('Acquisition of ', acq_name, 'done.') + print("Acquisition of ", acq_name, "done.") if not dry_run: aria.send_trigger() - print('Finished. Now cleaning will take 1-2 hours!') - if 'illu_parameters' in acquisition_config.keys(): - laserlaunch.power = 1 # mW + print("Finished. Now cleaning will take 1-2 hours!") + if "illu_parameters" in acquisition_config.keys(): + laserlaunch.power = 1 # mW laserlaunch.laser_enabled = False - def image_saved_fn(axes, dataset): # pixels = dataset.read_image(**axes) # TODO: on-the-fly testing and quality control of data pass + def start_progress(ltitle, n_frames): global progress_x, title global nimgs_acquired, nimgs_total nimgs_acquired = 0 title = ltitle nimgs_total = n_frames - #sys.stdout.write(title + ": [" + "-"*40 + "]" + chr(8)*41) - #sys.stdout.flush() - print(title + ": [" + "-"*40 + "]", end='\r') + # sys.stdout.write(title + ": [" + "-"*40 + "]" + chr(8)*41) + # sys.stdout.flush() + print(title + ": [" + "-" * 40 + "]", end="\r") progress_x = 0 + def progress2(x): global progress_x, title x = int(x * 40 // 100) - deci = int((x - int(x))*10) + deci = int((x - int(x)) * 10) sys.stdout.write("#" * (x - progress_x)) - #sys.stdout.write("#" * (x - progress_x-1) + str(deci)) + # sys.stdout.write("#" * (x - progress_x-1) + str(deci)) sys.stdout.flush() progress_x = x + def progress(x): global title - deci = int((x - int(x))*10) + deci = int((x - int(x)) * 10) x = int(x * 40 // 100) - y = max([0, 40-x-1]) - print(title + ": [" + '#'*x + str(deci) +"-"*y + "]", end='\r') - #print(x, y, deci, x+y+1) + y = max([0, 40 - x - 1]) + print(title + ": [" + "#" * x + str(deci) + "-" * y + "]", end="\r") + # print(x, y, deci, x+y+1) def end_progress(): - #sys.stdout.write("#" * (40 - progress_x) + "]\n") - #sys.stdout.flush() - print(title + ": [" + "#"*40 + "]", end='\n') + # sys.stdout.write("#" * (40 - progress_x) + "]\n") + # sys.stdout.flush() + print(title + ": [" + "#" * 40 + "]", end="\n") + def image_process_fn(img, meta): try: global nimgs_acquired, nimgs_total - nimgs_acquired+=1 - progress(nimgs_acquired/nimgs_total*100) + nimgs_acquired += 1 + progress(nimgs_acquired / nimgs_total * 100) except Exception as e: print(e) return (img, meta) def test_record_movie(): - acq_name = flow_acq_config['base_name'] + acq_name = flow_acq_config["base_name"] acquisition_config = flow_acq_config record_movie(acq_name, acquisition_config) + def record_movie(acq_name, acquisition_config, core=None): """Records a movie via pycromanager Args: @@ -306,31 +366,34 @@ def record_movie(acq_name, acquisition_config, core=None): frames : the number of frames to acquire t_exp : the exposure time. """ - acq_dir = acquisition_config['save_dir'] - n_frames = acquisition_config['frames'] - t_exp = acquisition_config['t_exp'] - chan_group = acquisition_config['mm_parameters']['channel_group'] - filter = acquisition_config['mm_parameters']['filter'] - roi = acquisition_config['ROI'] - -# if core is not None: -# core.set_exposure(t_exp) -# core.set_config(chan_group, filter) -# core.set_roi(*roi) - - start_progress('Acquisition', n_frames) - - with Acquisition(directory=acq_dir, name=acq_name, show_display=True, - image_process_fn=image_process_fn, - ) as acq: + acq_dir = acquisition_config["save_dir"] + n_frames = acquisition_config["frames"] + t_exp = acquisition_config["t_exp"] + chan_group = acquisition_config["mm_parameters"]["channel_group"] + filter = acquisition_config["mm_parameters"]["filter"] + roi = acquisition_config["ROI"] + + # if core is not None: + # core.set_exposure(t_exp) + # core.set_config(chan_group, filter) + # core.set_roi(*roi) + + start_progress("Acquisition", n_frames) + + with Acquisition( + directory=acq_dir, + name=acq_name, + show_display=True, + image_process_fn=image_process_fn, + ) as acq: events = multi_d_acquisition_events( num_time_points=n_frames, - time_interval_s=0,#t_exp/1000, - #channel_group=chan_group, channels=[filter], - channel_exposures_ms= [t_exp], - order='tcpz', + time_interval_s=0, # t_exp/1000, + # channel_group=chan_group, channels=[filter], + channel_exposures_ms=[t_exp], + order="tcpz", ) - #for e in events: + # for e in events: # ic(e) acq.acquire(events) @@ -342,18 +405,22 @@ def acq(): # bridge = Bridge() # core = bridge.get_core() - core=Core() + core = Core() # mm = bridge.get_studio() # pm = mm.positions() save_dir = r"Z:\users\grabmayr\FlowAutomation\testdata" - acq_name = r'exchange_experiment' + acq_name = r"exchange_experiment" n_frames = 2 t_exp = 2 - chan_group = 'Filter turret' - filter = '2-G561' + chan_group = "Filter turret" + filter = "2-G561" - with Acquisition(directory=save_dir, name=acq_name, show_display=False, debug=True, - ) as acq: + with Acquisition( + directory=save_dir, + name=acq_name, + show_display=False, + debug=True, + ) as acq: events = multi_d_acquisition_events( num_time_points=n_frames, ) @@ -364,9 +431,11 @@ def config_logger(): logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) formatter = logging.Formatter( - '%(asctime)s | %(name)s | %(levelname)s -> %(message)s') + "%(asctime)s | %(name)s | %(levelname)s -> %(message)s" + ) file_handler = handlers.RotatingFileHandler( - 'pycroflow.log', maxBytes=1e6, backupCount=5) + "pycroflow.log", maxBytes=1e6, backupCount=5 + ) file_handler.setFormatter(formatter) file_handler.setLevel(logging.DEBUG) stream_handler = logging.StreamHandler() @@ -379,5 +448,5 @@ def config_logger(): if __name__ == "__main__": config_logger() logger = logging.getLogger(__name__) - logger.debug('start logging') + logger.debug("start logging") main(flow_acq_config, break_for_slide=True) diff --git a/snippets/ProcessedLiveView.py b/snippets/ProcessedLiveView.py index 26fb1fd..b2f3970 100644 --- a/snippets/ProcessedLiveView.py +++ b/snippets/ProcessedLiveView.py @@ -8,7 +8,7 @@ import numpy as np import matplotlib.pyplot as plt -#Setup +# Setup # get object representing MMCore core = Core() @@ -17,22 +17,24 @@ #### Setting and getting properties #### -#Here we set a property of the core itself, but same code works for device properties -auto_shutter = core.get_property('Core', 'AutoShutter') -core.set_property('Core', 'AutoShutter', 0) +# Here we set a property of the core itself, but same code works for device properties +auto_shutter = core.get_property("Core", "AutoShutter") +core.set_property("Core", "AutoShutter", 0) #### Acquiring images #### -#The micro-manager core exposes several mechanisms foor acquiring images. In order to -#not interfere with other pycromanager functionality, this is the one that should be used +# The micro-manager core exposes several mechanisms foor acquiring images. In order to +# not interfere with other pycromanager functionality, this is the one that should be used core.snap_image() tagged_image = core.get_tagged_image() -#If using micro-manager multi-camera adapter, use core.getTaggedImage(i), where i is -#the camera index - -#pixels by default come out as a 1D array. We can reshape them into an image -pixels = np.reshape(tagged_image.pix, - newshape=[tagged_image.tags['Height'], tagged_image.tags['Width']]) -#plot it -plt.imshow(pixels, cmap='gray') +# If using micro-manager multi-camera adapter, use core.getTaggedImage(i), where i is +# the camera index + +# pixels by default come out as a 1D array. We can reshape them into an image +pixels = np.reshape( + tagged_image.pix, + newshape=[tagged_image.tags["Height"], tagged_image.tags["Width"]], +) +# plot it +plt.imshow(pixels, cmap="gray") plt.show() diff --git a/snippets/access_mm_multid.py b/snippets/access_mm_multid.py index 74e51b8..3c9e812 100644 --- a/snippets/access_mm_multid.py +++ b/snippets/access_mm_multid.py @@ -1,55 +1,61 @@ #!/usr/bin/env python """ - PycroFlow/access_mm_multid.py - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +PycroFlow/access_mm_multid.py +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - access the multi d acquisition entries of micromanager +access the multi d acquisition entries of micromanager - :authors: Heinrich Grabmayr, 2023 - :copyright: Copyright (c) 2023 Jungmann Lab, MPI of Biochemistry +:authors: Heinrich Grabmayr, 2023 +:copyright: Copyright (c) 2023 Jungmann Lab, MPI of Biochemistry """ + from pycromanager import Studio def get_multid(): studio = Studio(convert_camel_case=False) acqmgr = studio.getAcquisitionManager() - acqsttgs = acqmgr.getAcquisitionSettings() + acqsttgs = acqmgr.getAcquisitionSettings() # https://valelab4.ucsf.edu/~MM/doc-2.0.0-gamma/mmstudio/org/micromanager/acquisition/SequenceSettings.html acqorder = acqsttgs.acqOrderMode() - acqordermap = { - 0: 'TSZC', - 1: 'TSCZ', - 2: 'STZC', - 3: 'STCZ' - } + acqordermap = {0: "TSZC", 1: "TSCZ", 2: "STZC", 3: "STCZ"} usedims = {} - usedims['C'] = acqsttgs.useChannels() - usedims['T'] = acqsttgs.useFrames() - usedims['S'] = acqsttgs.usePositionList() - usedims['Z'] = acqsttgs.useSlices() - - print('channels', [acqsttgs.channels().get(i) for i in range(acqsttgs.channels().size())]) + usedims["C"] = acqsttgs.useChannels() + usedims["T"] = acqsttgs.useFrames() + usedims["S"] = acqsttgs.usePositionList() + usedims["Z"] = acqsttgs.useSlices() + + print( + "channels", + [ + acqsttgs.channels().get(i) + for i in range(acqsttgs.channels().size()) + ], + ) z_definition = { - 'slices': [acqsttgs.slices().get(i) for i in range(acqsttgs.slices().size())], - 'bot': acqsttgs.sliceZBottomUm(), - 'step': acqsttgs.sliceZStepUm(), - 'top': acqsttgs.sliceZTopUm(), - 'relative': acqsttgs.relativeZSlice() + "slices": [ + acqsttgs.slices().get(i) for i in range(acqsttgs.slices().size()) + ], + "bot": acqsttgs.sliceZBottomUm(), + "step": acqsttgs.sliceZStepUm(), + "top": acqsttgs.sliceZTopUm(), + "relative": acqsttgs.relativeZSlice(), } - positions = studio.getPositionListManager().getPositionList().getPositions() + positions = ( + studio.getPositionListManager().getPositionList().getPositions() + ) print(positions) - print('usedims', usedims) + print("usedims", usedims) return acqorder, z_definition -if __name__ == '__main__': +if __name__ == "__main__": acqorder, z_definition = get_multid() print(acqorder) - print(z_definition) \ No newline at end of file + print(z_definition) diff --git a/snippets/arduino_connection.py b/snippets/arduino_connection.py index 891c2f5..26a6025 100644 --- a/snippets/arduino_connection.py +++ b/snippets/arduino_connection.py @@ -1,13 +1,14 @@ #!/usr/bin/env python """ - PycroFlow/arduino_connection.py - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +PycroFlow/arduino_connection.py +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - do the arduino connection. simple functions to begin with. +do the arduino connection. simple functions to begin with. - :authors: Heinrich Grabmayr, 2022 - :copyright: Copyright (c) 2022 Jungmann Lab, MPI of Biochemistry +:authors: Heinrich Grabmayr, 2022 +:copyright: Copyright (c) 2022 Jungmann Lab, MPI of Biochemistry """ + import logging from icecream import ic import time @@ -17,7 +18,7 @@ from Arduino import Arduino -class AriaTrigger(): +class AriaTrigger: def __init__(self, parameters={}): """ Args: @@ -29,10 +30,10 @@ def __init__(self, parameters={}): TTL_duration : float, default 0.3 (both directions) max_flowstep : float, default 30 min, timeout for aria TTL """ - self.pulse_pin = parameters.get('pulse_pin', 13) - self.sense_pin = parameters.get('sense_pin', 12) - self.pulse_duration = parameters.get('TTL_duration', .3) - self.pulse_timeout = parameters.get('max_flowstep', 30*60) + self.pulse_pin = parameters.get("pulse_pin", 13) + self.sense_pin = parameters.get("sense_pin", 12) + self.pulse_duration = parameters.get("TTL_duration", 0.3) + self.pulse_timeout = parameters.get("max_flowstep", 30 * 60) self.board = Arduino() self.board.pinMode(self.pulse_pin, "OUTPUT") @@ -45,8 +46,14 @@ def send_trigger(self): time.sleep(self.pulse_duration) self.board.digitalWrite(self.pulse_pin, "LOW") - def sense_trigger(self, timeout=None, baseline=False, refresh_rate=.01, - min_duration=None, max_duration=None): + def sense_trigger( + self, + timeout=None, + baseline=False, + refresh_rate=0.01, + min_duration=None, + max_duration=None, + ): """ TODO: in a thread, read input and return sense_pulse if 'continue' is entered.. @@ -68,42 +75,48 @@ def sense_trigger(self, timeout=None, baseline=False, refresh_rate=.01, if timeout is None: timeout = self.pulse_timeout if min_duration is None: - min_duration = max([self.pulse_duration - .1, .02]) + min_duration = max([self.pulse_duration - 0.1, 0.02]) if max_duration is None: - max_duration = self.pulse_duration + .1 + max_duration = self.pulse_duration + 0.1 tstart = time.time() triggered = False edge_times_rising, edge_times_falling = [], [] - while time.time()-tstart < timeout: - tleft = timeout - (time.time()-tstart) + while time.time() - tstart < timeout: + tleft = timeout - (time.time() - tstart) triggered, edge_detected = self.sense_edge( - tleft, 'both', refresh_rate) + tleft, "both", refresh_rate + ) tic = time.time() - if edge_detected=='falling': + if edge_detected == "falling": edge_times_falling.append(tic) - elif edge_detected=='rising': + elif edge_detected == "rising": edge_times_rising.append(tic) - elif edge_detected=='none': + elif edge_detected == "none": # timeout break # print('edge_times_rising', edge_times_rising) # print('edge_times_falling', edge_times_falling) # find a pulse time_deltas = np.fromiter( - (f-r - for f, r in - itertools.product(edge_times_falling, edge_times_rising)), - dtype=np.float64) - if baseline==True: - time_deltas = - time_deltas - if np.any((time_deltas>=min_duration) & - (time_deltas<=max_duration)): + ( + f - r + for f, r in itertools.product( + edge_times_falling, edge_times_rising + ) + ), + dtype=np.float64, + ) + if baseline == True: + time_deltas = -time_deltas + if np.any( + (time_deltas >= min_duration) & (time_deltas <= max_duration) + ): triggered = True break # print('sense pulse time deltas:', time_deltas) return triggered - def sense_edge(self, timeout=10, edge='rising', refresh_rate=.01): + def sense_edge(self, timeout=10, edge="rising", refresh_rate=0.01): """ Args: timeout : int @@ -117,25 +130,28 @@ def sense_edge(self, timeout=10, edge='rising', refresh_rate=.01): previous_state = self.board.digitalRead(self.sense_pin) while not triggered: state = self.board.digitalRead(self.sense_pin) - if edge=='rising' or edge=='both': + if edge == "rising" or edge == "both": if (not previous_state) and state: triggered = True - edge_detected = 'rising' - if edge=='falling' or edge=='both': + edge_detected = "rising" + if edge == "falling" or edge == "both": if previous_state and (not state): triggered = True - edge_detected = 'falling' + edge_detected = "falling" if triggered: break - if time.time()-tic > timeout: - print('Sensing TTL pulse timed out after {:.1f}s.'.format(timeout)) - edge_detected = 'none' + if time.time() - tic > timeout: + print( + "Sensing TTL pulse timed out after {:.1f}s.".format( + timeout + ) + ) + edge_detected = "none" break time.sleep(refresh_rate) previous_state = state return triggered, edge_detected - def close(self): self.board.close() diff --git a/snippets/inputinterrupt.py b/snippets/inputinterrupt.py index 4c8cad0..a5e2963 100644 --- a/snippets/inputinterrupt.py +++ b/snippets/inputinterrupt.py @@ -16,6 +16,7 @@ sense_edge() qu.put(True) """ + import sys import queue import time @@ -23,15 +24,16 @@ DEFAULT_TIMEOUT = 30.0 INTERVAL = 0.05 -SP = ' ' -CR = '\r' -LF = '\n' +SP = " " +CR = "\r" +LF = "\n" CRLF = CR + LF class TimeoutOccurred(Exception): pass + class InterruptOccurred(Exception): pass @@ -41,7 +43,7 @@ def echo(string): sys.stdout.flush() -def posix_inputimeout(qu, prompt='', timeout=DEFAULT_TIMEOUT): +def posix_inputimeout(qu, prompt="", timeout=DEFAULT_TIMEOUT): echo(prompt) sel = selectors.DefaultSelector() sel.register(sys.stdin, selectors.EVENT_READ) @@ -49,7 +51,7 @@ def posix_inputimeout(qu, prompt='', timeout=DEFAULT_TIMEOUT): begin = time.monotonic() end = begin + timeout - while.time.monotonic() < end: + while time.monotonic() < end: events = sel.select(INTERVAL) if events: @@ -71,11 +73,11 @@ def posix_inputimeout(qu, prompt='', timeout=DEFAULT_TIMEOUT): raise TimeoutOccurred -def win_inputimeout(qu, prompt='', timeout=DEFAULT_TIMEOUT): +def win_inputimeout(qu, prompt="", timeout=DEFAULT_TIMEOUT): echo(prompt) begin = time.monotonic() end = begin + timeout - line = '' + line = "" while time.monotonic() < end: if msvcrt.kbhit(): @@ -83,12 +85,12 @@ def win_inputimeout(qu, prompt='', timeout=DEFAULT_TIMEOUT): if c in (CR, LF): echo(CRLF) return line - if c == '\003': + if c == "\003": raise KeyboardInterrupt - if c == '\b': + if c == "\b": line = line[:-1] cover = SP * len(prompt + line + SP) - echo(''.join([CR, cover, CR, prompt, line])) + echo("".join([CR, cover, CR, prompt, line])) else: line += c try: diff --git a/snippets/testacq.py b/snippets/testacq.py index ed269a6..f6ec0fa 100644 --- a/snippets/testacq.py +++ b/snippets/testacq.py @@ -1,4 +1,3 @@ - """aborting an acqiusition, as proposed by chatGPT @@ -24,20 +23,6 @@ def check_abort(): acquire_frames() """ - - - - - - - - - - - - - - """ Created on Tue Oct 4 17:07:56 2022 ​ @@ -47,6 +32,7 @@ def check_abort(): from pycromanager import Acquisition, multi_d_acquisition_events from time import sleep import warnings + warnings.filterwarnings("ignore", category=DeprecationWarning) # ============================================================================= @@ -54,7 +40,7 @@ def check_abort(): # ============================================================================= bridge = pycro.Bridge() core = bridge.get_core() -#core=Core() +# core=Core() mm = bridge.get_studio() pm = mm.positions() @@ -64,8 +50,8 @@ def check_abort(): # TODO: Implement importer for xy position list and their names from micromanager after test run works, framerate(mmc.set_property("Camera", "Framerate", framerate)) # ============================================================================= save_dir = r"X:\users\jfischer\1.RNA-PAINT\z.microscopy_raw\221007_30plex_test" -save_dir = r'Z:\users\grabmayr\FlowAutomation\testdata' -base_name = r'30plex' +save_dir = r"Z:\users\grabmayr\FlowAutomation\testdata" +base_name = r"30plex" # ============================================================================= # FOV and binning parameter settings @@ -79,24 +65,80 @@ def check_abort(): # ============================================================================= # Channel specific settings # ============================================================================= -Dichro_channelname='Filter turret' -Dichro_list=['2-G561'] -channel_exp_time = [100] # First list entry corresponds to exposure time of first channel in channel_list -#channel_framerate = ['9.9987', '33.327'] # TODO: How do I use the correct (maximum) framerate values for each exposure time +Dichro_channelname = "Filter turret" +Dichro_list = ["2-G561"] +channel_exp_time = [ + 100 +] # First list entry corresponds to exposure time of first channel in channel_list +# channel_framerate = ['9.9987', '33.327'] # TODO: How do I use the correct (maximum) framerate values for each exposure time -Exp_channelname= '1.Measurement_presets' -Exp_list=['30ms', '100ms'] +Exp_channelname = "1.Measurement_presets" +Exp_list = ["30ms", "100ms"] # channel_frames = [10000, 5000] # First list entry corresponds to number of acquired frames of first channel in Dichro_list -channel_frames = [10] # First list entry corresponds to number of acquired frames of first channel in Dichro_list +channel_frames = [ + 10 +] # First list entry corresponds to number of acquired frames of first channel in Dichro_list # ============================================================================= # XY Position list # ============================================================================= -xy_pos_list = [[27544, -6345],[32044, -6345],[36544, -6345],[41044, -6345],[45544, -6345],[45544, -10845],[41044, -10845],[36544, -10845],[32044, -10845],[27544, -10845],[23044, -10845],[18544, -10845],[14044, -10845],[9544, -10845],[5044, -10845],[5044, -15345],[9544, -15345],[14044, -15345],[18544, -15345],[23044, -15345],[27544, -15345],[32044, -15345],[36544, -15345],[41044, -15345],[45544, -15345]] # only for testing -xy_pos_name_list = ['D13', 'D14', 'D15', 'D16', 'D17', 'E17', 'E16', 'E15', 'E14', 'E13', 'E12', 'E11', 'E10', 'E9', 'E8', 'F08', 'F09', 'F10', 'F11', 'F12', 'F13', 'F14', 'F15', 'F16', 'F17'] # only for testing - -#[5044, -6345],[9544, -6345],[14044, -6345],[18544, -6345],[23044, -6345], +xy_pos_list = [ + [27544, -6345], + [32044, -6345], + [36544, -6345], + [41044, -6345], + [45544, -6345], + [45544, -10845], + [41044, -10845], + [36544, -10845], + [32044, -10845], + [27544, -10845], + [23044, -10845], + [18544, -10845], + [14044, -10845], + [9544, -10845], + [5044, -10845], + [5044, -15345], + [9544, -15345], + [14044, -15345], + [18544, -15345], + [23044, -15345], + [27544, -15345], + [32044, -15345], + [36544, -15345], + [41044, -15345], + [45544, -15345], +] # only for testing +xy_pos_name_list = [ + "D13", + "D14", + "D15", + "D16", + "D17", + "E17", + "E16", + "E15", + "E14", + "E13", + "E12", + "E11", + "E10", + "E9", + "E8", + "F08", + "F09", + "F10", + "F11", + "F12", + "F13", + "F14", + "F15", + "F16", + "F17", +] # only for testing + +# [5044, -6345],[9544, -6345],[14044, -6345],[18544, -6345],[23044, -6345], #'D08', 'D09', 'D10', 'D11', 'D12',​​ # xy_pos_list=[] # xy_pos_name_list=[] @@ -139,26 +181,56 @@ def check_abort(): # Full function to be executed # ============================================================================= -sleep(2) # To give the hardware time to adjust the parameters +sleep(2) # To give the hardware time to adjust the parameters -#Function that drives to each position in the xy position list and acquires a time series for each dichro channel. -def record_multiple_pos_channels(acq_dir, base_name, xy_pos_list, xy_pos_name_list, channel_groupname, Dichro_list, channel_exp_time, channel_frames): + +# Function that drives to each position in the xy position list and acquires a time series for each dichro channel. +def record_multiple_pos_channels( + acq_dir, + base_name, + xy_pos_list, + xy_pos_name_list, + channel_groupname, + Dichro_list, + channel_exp_time, + channel_frames, +): for i in range(len(xy_pos_list)): # change the xy position to the next one # core.set_xy_position(xy_pos_list[i][0],xy_pos_list[i][1]) # core.wait_for_device("XYStage") # Waits until the camera reports that it is no longer moving for j in range(len(Dichro_list)): # Set the channel to the right setting for each measuremen - #core.set_config(channel_groupname, channel_list[j]) + # core.set_config(channel_groupname, channel_list[j]) core.set_config(Dichro_channelname, Dichro_list[j]) # core.set_config(Exp_channelname, Exp_list[j]) core.wait_for_device("HamamatsuHam_DCAM") - #sleep(2) # To give the hardware time to adjust the parameters - with Acquisition(directory=acq_dir, name= base_name + '_' + xy_pos_name_list[i] +'_' + Dichro_list[j], show_display=True) as acq: - events = multi_d_acquisition_events(num_time_points=channel_frames[j], time_interval_s=0) - acq.acquire(events, keep_shutter_open = True) - -record_multiple_pos_channels(save_dir, base_name, xy_pos_list, xy_pos_name_list, Dichro_channelname, Dichro_list, channel_exp_time, channel_frames) + # sleep(2) # To give the hardware time to adjust the parameters + with Acquisition( + directory=acq_dir, + name=base_name + + "_" + + xy_pos_name_list[i] + + "_" + + Dichro_list[j], + show_display=True, + ) as acq: + events = multi_d_acquisition_events( + num_time_points=channel_frames[j], time_interval_s=0 + ) + acq.acquire(events, keep_shutter_open=True) + + +record_multiple_pos_channels( + save_dir, + base_name, + xy_pos_list, + xy_pos_name_list, + Dichro_channelname, + Dichro_list, + channel_exp_time, + channel_frames, +) # ============================================================================= # Code snippets for testing # ============================================================================= From 65625c2b25daa6eca4a8d3b2a1168dcf58cd3f28 Mon Sep 17 00:00:00 2001 From: Heinrich Date: Wed, 29 Jul 2026 14:53:49 +0000 Subject: [PATCH 4/4] Fix subsystem-deselection edge cases in builder + CLI The new per-subsystem `enabled` flag dropped deselected subsystems from the compiled Run Sequence, but several paths still assumed all three keys were present: - frontend_cli.do_load_protocol: guard fluid/img `_assign_protocol` with .get() (mirroring the illu branch) so loading a design with a subsystem deselected no longer raises KeyError. - builder.create_protocol: return step lists derived from the compiled, pruned protocol instead of the raw self.steps, so callers see exactly what was written to disk (no orphaned 'wait for signal' entries). - builder.build_protocol: raise ValueError when every subsystem is deselected instead of validating and running as a silent no-op; broaden the enabled check to also treat the string "false" as disabled for hand-edited YAML. - CHANGELOG: document the subsystem-selection feature under [Unreleased]. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ PycroFlow/frontend_cli.py | 7 +++++-- PycroFlow/protocols/builder.py | 21 +++++++++++++++++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e56e80..a401f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Per-subsystem selection: an `enabled` flag on the fluid / img / illu + sections of an experiment design lets a subsystem be deselected. The + builder omits deselected subsystems from the compiled Run Sequence, prunes + cross-subsystem `wait for signal` entries that targeted a dropped + subsystem, and raises if nothing is selected; the orchestrator only wires + hardware for subsystems present in the protocol. - Shared `.pre-commit-config.yaml` (pre-commit-hooks + Black + flake8 via Flake8-pyproject), matching the rest of the DNA-PAINT stack. - `black --check` and `flake8` lint job in CI. diff --git a/PycroFlow/frontend_cli.py b/PycroFlow/frontend_cli.py index f27bc8a..7f06eb7 100644 --- a/PycroFlow/frontend_cli.py +++ b/PycroFlow/frontend_cli.py @@ -117,9 +117,12 @@ def do_load_protocol(self, fname_protocol): """Load the Fluid Automation protocol""" with open(fname_protocol, "r") as f: self.protocol = yaml.full_load(f) - if self.fluid_system: + # A deselected subsystem (``enabled: false`` in the design) is dropped + # from the compiled Run Sequence, so its top-level key may be absent — + # guard each access the same way the ``illu`` branch below does. + if self.fluid_system and self.protocol.get("fluid"): self.fluid_system._assign_protocol(self.protocol["fluid"]) - if self.imaging_system: + if self.imaging_system and self.protocol.get("img"): self.imaging_system._assign_protocol(self.protocol["img"]) if self.protocol.get("illu"): diff --git a/PycroFlow/protocols/builder.py b/PycroFlow/protocols/builder.py index a107dfc..fd7e2e4 100644 --- a/PycroFlow/protocols/builder.py +++ b/PycroFlow/protocols/builder.py @@ -123,7 +123,7 @@ def build_protocol(self, config): # (``enabled: false``). All step lists are still generated above so # the round structure is intact; deselected ones are simply not # emitted, and orphaned cross-subsystem waits are pruned below. - if not section or section.get("enabled", True) is False: + if not section or section.get("enabled", True) in (False, "false"): continue protocol[system] = {"protocol_entries": steps[system]} if "parameters" in section.keys(): @@ -135,6 +135,15 @@ def build_protocol(self, config): # ``target`` names a subsystem not in the emitted protocol). self._prune_orphan_waits(protocol) + # An empty protocol means every subsystem was absent or deselected; + # this would otherwise validate and run as a silent no-op that reports + # success without doing anything. Fail loudly instead. + if not protocol: + raise ValueError( + "No subsystems selected: enable at least one of " + "fluid / img / illu in the experiment design." + ) + # Pin the wire format: catch malformed entries (unknown $type, missing # required fields, typos in field names) here before the orchestrator # picks them up mid-run. Schema-validation only — protocol dict is @@ -206,7 +215,15 @@ def create_protocol(self, config): default_style='"', ) - return fname, self.steps + # Derive the returned step lists from the compiled ``protocol`` rather + # than ``self.steps``: the former reflects deselected-subsystem drops + # and orphan-wait pruning, so callers see exactly what was written to + # disk instead of the raw (pre-prune) step lists. + steps = { + system: content["protocol_entries"] + for system, content in protocol.items() + } + return fname, steps # Registry mapping the user-facing experiment type (case-insensitive) # to the ProtocolBuilder method that knows how to expand it. New