From f136f621e222a7fad948d87d6f2fc232e6220d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Sat, 27 Jun 2026 19:38:13 -0400 Subject: [PATCH 1/2] Refresh CLAUDE.md for v1.2.0 and add a user-facing skill Problem: CLAUDE.md had drifted from the v1.2.0 codebase (it still warned that directory-wide pytest collection returns zero tests, did not mention DeviceController, described the laser-source family as a single base class rather than capability mixins, and had no Thorlabs Kinesis backend or release/versioning guidance). There was also no concise, runnable guide for people who only want to *use* the library to control hardware. Solution: - Refine CLAUDE.md in place (structure and house rules preserved): - pytest now collects hardwarelibrary/tests/ via pyproject.toml (python_files=["test*.py"], testpaths); note CI + Sphinx/RTD. - Add DeviceController to the core architecture. - Correct the laser-source family to capability mixins (OnOffControl, ShutterControl, PowerControl, WavelengthControl, ...); document the same shape for DAQ; add the Thorlabs Kinesis/pylablib dispatcher and the thorlabs extra. - New "Versioning and releases" section: tag vX.Y.Z -> publish.yml (PyPI Trusted Publishing + GitHub Release), setuptools-scm version, CHANGELOG.md is the source of truth. - Cheatsheet: whole-suite run + testDeviceController/testThorlabs. - Add .claude/skills/pyhardwarelibrary/SKILL.md: a user-facing skill covering the device lifecycle, discovery, debug devices, per-family public API, NotificationCenter events, and DeviceController. All code examples were smoke-tested against the installed library. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/pyhardwarelibrary/SKILL.md | 255 ++++++++++++++++++++++ CLAUDE.md | 25 ++- 2 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/pyhardwarelibrary/SKILL.md diff --git a/.claude/skills/pyhardwarelibrary/SKILL.md b/.claude/skills/pyhardwarelibrary/SKILL.md new file mode 100644 index 0000000..1cd0056 --- /dev/null +++ b/.claude/skills/pyhardwarelibrary/SKILL.md @@ -0,0 +1,255 @@ +--- +name: pyhardwarelibrary +description: Control lab hardware with the PyHardwareLibrary Python package — motion stages, spectrometers, lasers, power meters, DAQs, and oscilloscopes. Use when a user wants to move a stage, acquire a spectrum, turn a laser on/off or set its power/wavelength, read a power meter, read/write DAQ voltages, discover connected devices, run code without hardware (debug devices), or build a headless/GUI controller around a device. Covers the device lifecycle, per-family public API, event notifications, and the DeviceController worker-thread wrapper. +--- + +# Using PyHardwareLibrary + +PyHardwareLibrary is a device-oriented library: every instrument is a `PhysicalDevice` +subclass with a uniform lifecycle and a small, predictable public API per family. This +skill is for *using* devices to get work done (move, measure, set). To *write a new +driver*, read `CLAUDE.md` and `README-4-New-device-coding-example.md` instead. + +## The one rule: lifecycle + +Every device follows the same pattern. Construct it, initialize it, use it, shut it down. + +```python +from hardwarelibrary.motion.sutterdevice import SutterDevice + +stage = SutterDevice() # construct (does not touch hardware) +stage.initializeDevice() # opens the port, raises on failure +try: + stage.moveTo((1000, 2000, 0)) + print(stage.position()) +finally: + stage.shutdownDevice() # always release the port +``` + +- `initializeDevice()` opens the connection and raises `PhysicalDevice.UnableToInitialize` + if the device is absent/busy. Nothing works before it. +- `shutdownDevice()` closes the port. Always call it (use `try/finally`). +- Calling a command before `initializeDevice()` raises `PhysicalDevice.NotInitialized`. + +## Finding / constructing a device + +There is no reliable generic auto-discovery — do **not** rely on `PhysicalDevice.any()` +(it is incomplete and returns nothing). Use one of these instead: + +- **Spectrometers have working discovery**: `Spectrometer.any()` returns the first + supported spectrometer, ready to use. +- **Everything else: construct the concrete class directly.** USB devices match by + serial number (default `None`/`"*"` = first found); serial/TCP devices take a port + path. See each family below for the exact constructor. + +## Run without hardware (debug devices) + +Most families ship a `DebugXxxDevice` that emulates the protocol in memory, so examples +and tests run with nothing plugged in. They obey the same lifecycle and API. + +```python +from hardwarelibrary.motion.linearmotiondevice import DebugLinearMotionDevice +from hardwarelibrary.daq.labjackdevice import DebugLabjackDevice +from hardwarelibrary.sources.millennia import DebugMillenniaEv25Device + +stage = DebugLinearMotionDevice() +stage.initializeDevice() +stage.moveTo((10, 20, 30)) +print(stage.position()) # (10, 20, 30) +stage.shutdownDevice() +``` + +## Family quick reference + +### Linear motion stages — Sutter MP-285, Thorlabs (Kinesis) + +Base: `LinearMotionDevice`. Positions are 3-tuples `(x, y, z)` in native steps. + +```python +from hardwarelibrary.motion.sutterdevice import SutterDevice +stage = SutterDevice(serialNumber=None) # first one found +# Thorlabs is not re-exported from motion/__init__; import from its module and +# install the extra: pip install -e .[thorlabs] +# from hardwarelibrary.motion.thorlabs import ThorlabsKinesisDevice +# stage = ThorlabsKinesisDevice(serialNumber="...") +stage.initializeDevice() + +stage.moveTo((x, y, z)) # absolute, native steps +stage.moveBy((dx, dy, dz)) # relative +pos = stage.position() # -> (x, y, z) +stage.home() + +stage.moveInMicronsTo((x_um, y_um, z_um)) # micron helpers +stage.moveInMicronsBy((dx_um, dy_um, dz_um)) +posUm = stage.positionInMicrons() +points = stage.mapPositions(width, height, stepInMicrons) # raster a sample +stage.shutdownDevice() +``` + +### Rotation stages — Intellidrive + +Base: `RotationDevice`. Note `IntellidriveDevice(serialNumber)` requires the serial number. + +```python +from hardwarelibrary.motion.intellidrivedevice import IntellidriveDevice +rot = IntellidriveDevice(serialNumber="...") +rot.initializeDevice() +rot.moveTo(angle) # absolute, degrees +rot.moveBy(deltaTheta) # relative +theta = rot.orientation() +rot.home() +rot.shutdownDevice() +``` + +### Spectrometers — Ocean Insight USB2000 / USB2000+ / USB4000 / USB650 / SAS + +Base: `Spectrometer`. The public method *is* the hardware hook (no `do*` wrapper). + +```python +from hardwarelibrary.spectrometers import Spectrometer + +spectrometer = Spectrometer.any() # discovery works here +spectrometer.initializeDevice() +spectrometer.setIntegrationTime(50) # ms +spectrum = spectrometer.getSpectrum() # numpy array, aligned to .wavelength +print(spectrometer.getSerialNumber()) +spectrometer.saveSpectrum("scan.csv") # wavelength + intensity columns +spectrometer.shutdownDevice() + +# One-liner live view (initializes for you; needs matplotlib + Tk): +# Spectrometer.any().display() +``` + +### Laser sources — Cobolt, Spectra-Physics Millennia eV, Sirah Matisse + +Base: `LaserSourceDevice` plus capability mixins. A device only has the methods of +the capabilities it declares — check the table. + +| Capability | Methods | +|---|---| +| `OnOffControl` | `turnOn()`, `turnOff()`, `isLaserOn()`, `canTurnOn()` | +| `ShutterControl` | `openShutter()`, `closeShutter()`, `isShutterOpen()` | +| `PowerControl` | `setPower(watts)`, `power()` | +| `InterlockControl` | `interlock()` | +| `WavelengthControl` | `setWavelength(nm)`, `wavelength()`, `wavelengthRange()` | + +```python +from hardwarelibrary.sources.millennia import MillenniaEv25Device + +laser = MillenniaEv25Device(portPath="/dev/cu.usbmodemXXXX") +laser.initializeDevice() +laser.setPower(5.0) # watts +laser.turnOn() +laser.openShutter() +print(laser.power(), laser.isLaserOn(), laser.isShutterOpen()) +laser.closeShutter() +laser.turnOff() +laser.shutdownDevice() +``` + +- Cobolt: `CoboltDevice(portPath="COM3")` — OnOff + Power (+ autostart constraints; it + may refuse `turnOn()` when autostart is on, raising `CoboltCantTurnOnWithAutostartOn`). +- Matisse: `MatisseDevice(...)` over TCP — `WavelengthControl` (`setWavelength`/`wavelength`) + plus BiFi/etalon/piezo/scan methods. + +### Power meters — Gentec-EO Integra + +Base: `PowerMeterDevice`. + +```python +from hardwarelibrary.powermeters import IntegraDevice +meter = IntegraDevice() +meter.initializeDevice() +meter.setCalibrationWavelength(800) # nm +watts = meter.measureAbsolutePower() # the read method +meter.shutdownDevice() +``` + +### DAQ — LabJack U3 + +Combines capability mixins: analog/digital in/out, plus hardware-timed input. + +```python +from hardwarelibrary.daq.labjackdevice import LabjackDevice +daq = LabjackDevice() # serialNumber="*" -> first found +daq.initializeDevice() +v = daq.getAnalogVoltage(channel=0) # read +daq.setAnalogVoltage(2.5, channel=1) # write (U3 DACs are slow PWM) +bit = daq.getDigitalValue(channel=4) +daq.setDigitalValue(1, channel=5) +# Hardware-timed acquisition: daq.acquireWaveform(...) (AnalogInputStreamDevice) +daq.shutdownDevice() +``` + +### Oscilloscopes — Tektronix TDS + +Instantiated directly (no family/driver split); methods are SCPI per instrument. + +```python +from hardwarelibrary.oscilloscope import OscilloscopeDevice +scope = OscilloscopeDevice() +scope.initializeDevice() +# per-instrument SCPI methods / Channels enum +scope.shutdownDevice() +``` + +## Reacting to events (NotificationCenter) + +Devices post Cocoa-style notifications (state changes, measurements, moves) instead of +requiring polling. Observe them from anywhere: + +```python +from hardwarelibrary.notificationcenter import NotificationCenter +from hardwarelibrary.powermeters.powermeterdevice import PowerMeterNotification + +def onMeasure(notification): + print("power:", notification.userInfo) + +NotificationCenter().addObserver(self, onMeasure, PowerMeterNotification.didMeasure) +# ... measurements now call onMeasure with the value in notification.userInfo +NotificationCenter().removeObserver(self) +``` + +Useful notification enums: `PhysicalDeviceNotification` (will/did initialize/shutdown, +status), `LinearMotionNotification` (willMove/didMove/didGetPosition), +`PowerMeterNotification.didMeasure`. + +## Headless / GUI apps: DeviceController + +For an app (GUI, long-running service) wrap the device in a `DeviceController`. It runs +**all** device access on one worker thread, so blocking calls never freeze a UI and port +access is serialized. It auto-reconnects and reports through `NotificationCenter`. + +```python +from hardwarelibrary.devicecontroller import ( + DeviceController, DeviceControllerNotification as N) +from hardwarelibrary.notificationcenter import NotificationCenter +from hardwarelibrary.sources.millennia import MillenniaEv25Device + +controller = DeviceController(MillenniaEv25Device(portPath="/dev/cu.usbmodemXXXX")) + +NotificationCenter().addObserver(self, lambda n: print(n.userInfo), N.status) +controller.start() +controller.connect() + +controller.submit(lambda device: device.turnOn()) # fire-and-forget +reading = controller.submit(lambda device: device.power()).result() # query, returns a Future +# Do not block on .result() from a UI thread. + +controller.stop() +``` + +`submit(action)` runs `action(device)` on the worker and returns a +`concurrent.futures.Future` carrying the result or the exception. Failures also post a +`commandFailed` notification; drops post `connectionLost`/`connectionFailed`. + +## Gotchas + +- **Always `initializeDevice()` before use, and `shutdownDevice()` after** — wrap in + `try/finally`. A leaked open port blocks the next run with a "resource busy" error. +- **`PhysicalDevice.any()` / `anyDevice()` are incomplete** — only `Spectrometer.any()` + returns a usable device. For other families, construct the concrete class. +- **`DeviceManager` is not fully operational** — prefer the per-family approach above. +- **No hardware? Use the `DebugXxxDevice`** for that family to develop and test. +- **The version is git-tag-derived** (`setuptools-scm`); read `CHANGELOG.md`, because + API changes can land even when the minor version is unchanged. diff --git a/CLAUDE.md b/CLAUDE.md index 6b34efd..b190d53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,8 +22,9 @@ This file is for AI-assisted contributors. Humans should read `README.md` and th - Always try to use a local virtual environment if present in `.venv`. Python can be `python3`, or in a venv `python`. - Tests: `python3 -m pytest hardwarelibrary/tests/.py -v`. -- Directory collection (`pytest hardwarelibrary/tests/`) currently returns **zero tests** because pytest's default `python_files` pattern is `test_*.py` and the files here are `testFoo.py`. Name files explicitly until `pyproject.toml` is updated. -- Tests for hardware-dependent code must `skipTest(...)` when no hardware is attached — they must not fail. Pattern established in PRs #65 and #66; the `DebugLabjackDevice` tests in PR on `labjack-rewrite` are the cleanest reference. +- Directory collection (`pytest hardwarelibrary/tests/`) now works: `pyproject.toml` sets `python_files = ["test*.py"]` and `testpaths = ["hardwarelibrary/tests"]`, so the `testFoo.py` naming is collected. (The historical "zero tests" gotcha — pytest's default `test_*.py` pattern not matching `testFoo.py` — is resolved.) +- Tests for hardware-dependent code must `skipTest(...)` when no hardware is attached — they must not fail. Pattern established in PRs #65 and #66; the `DebugLabjackDevice` tests (`tests/testLabjackU3.py`) and the `skipUnless` library-presence guards in `tests/test_pylablib_kinesis.py` are the cleanest references. +- CI runs the suite on push/PR via `.github/workflows/tests.yml`; Sphinx docs build and publish to ReadTheDocs (`.readthedocs.yaml`, `docs/`). ## Architecture @@ -32,6 +33,7 @@ Core triad — all under `hardwarelibrary/`: - `physicaldevice.py:PhysicalDevice` — abstract base (`abc.ABC`) for every device. Owns lifecycle (`initializeDevice` / `shutdownDevice`), state machine (`DeviceState`), port handle, and monitoring thread. The lifecycle hooks `doInitializeDevice` / `doShutdownDevice` are `@abstractmethod`, so a driver that omits either fails at instantiation rather than at call time. - `devicemanager.py:DeviceManager` — singleton. Discovers connected USB devices, dispatches add/remove notifications. - `notificationcenter.py:NotificationCenter` — Cocoa-style pub/sub. Devices post notifications on state changes and measurements. +- `devicecontroller.py:DeviceController` — headless, toolkit-agnostic wrapper around *any* `PhysicalDevice`. Owns a single worker thread through which all device access flows (submitted actions and the periodic status poll alike), so blocking calls never touch a UI thread and port access is serialized. `submit(action)` runs `action(device)` on that thread and returns a `concurrent.futures.Future`; `connect()`/`disconnect()` manage the link with optional auto-reconnect. Everything is reported through `NotificationCenter` (`DeviceControllerNotification`), so any front-end (Tk, Qt, CLI, a test) just observes. Use this instead of hand-rolling a thread per app. Communication abstractions under `hardwarelibrary/communication/`: @@ -52,10 +54,15 @@ Each family has an **abstract base class**. A driver subclasses it and implement | Spectrometer | `spectrometers/` | `Spectrometer` (`base.py`) | `getSpectrum`, `getSerialNumber` (here the public method *is* the hook; no `doXxx` wrapper) | | Oscilloscope | `oscilloscope/` | `OscilloscopeDevice` | instantiated directly (Tektronix), no family/driver split; per-instrument SCPI methods | | Camera | `cameras/` | `CameraDevice` | `doCaptureFrame` | -| Laser source | `sources/` | `LaserSourceDevice` | `doTurnOn`, `doTurnOff`, `doSetPower`, `doGetPower`, `doGetOnOffState`, `doGetInterlockState` | +| Laser source | `sources/` | `LaserSourceDevice` (marker) + capability mixins (see below) | the `do*` hooks of whichever mixins the driver declares | | DAQ | `daq/` | capability mixins (see below) | one method per mixin: `getAnalogVoltage` / `setAnalogVoltage` / `getDigitalValue` / `setDigitalValue` | -`DAQDevice` and `SourceDevice` uses **interface-segregated capability mixins** instead of one base class, because a device may do any subset of read/write on analog/digital lines: `AnalogInputDevice` (`getAnalogVoltage`), `AnalogOutputDevice` (`setAnalogVoltage`), `DigitalInputDevice` (`getDigitalValue`), `DigitalOutputDevice` (`setDigitalValue`), plus `AnalogIODevice` / `DigitalIODevice` that combine each pair. The `configure*` and `direction*` methods are optional no-op hooks. A driver combines `PhysicalDevice` with the mixins it needs, e.g. `class LabjackDevice(PhysicalDevice, AnalogIODevice, DigitalIODevice)`. +**Thorlabs linear motion** is a backend dispatcher: `ThorlabsDevice` (in `motion/thorlabs.py`) routes to `ThorlabsKinesisDevice`, which drives the stage through `pylablib`'s Kinesis support. The old FTDI path (`ThorlabsFTDIDevice`) was dropped. Install with the `thorlabs` extra (`pip install -e .[thorlabs]`). + +Both the **DAQ and laser-source families use interface-segregated capability mixins** instead of one fat base class, because a device may implement any subset of the capabilities. + +- DAQ (`daq/daqdevice.py`): `AnalogInputDevice` (`getAnalogVoltage`), `AnalogOutputDevice` (`setAnalogVoltage`), `DigitalInputDevice` (`getDigitalValue`), `DigitalOutputDevice` (`setDigitalValue`), plus `AnalogIODevice` / `DigitalIODevice` that combine each pair, and `AnalogInputStreamDevice` for hardware-timed acquisition. The `configure*` and `direction*` methods are optional no-op hooks. Example: `class LabjackDevice(PhysicalDevice, AnalogIODevice, DigitalIODevice, AnalogInputStreamDevice)`. +- Laser sources (`sources/capabilities.py`): `OnOffControl` (`turnOn`/`turnOff`/`isLaserOn`), `ShutterControl` (`openShutter`/`closeShutter`/`isShutterOpen`), `PowerControl` (`setPower`/`power`), `InterlockControl` (`interlock`), `AutostartControl`, `WavelengthControl` (`setWavelength`/`wavelength`), `DispersionControl`. Each exposes a public method that calls a `do*` abstract hook the driver implements. `LaserSourceDevice` is a thin marker base; the behavior comes from the mixins. Examples: `class CoboltDevice(LaserSourceDevice, OnOffControl, PowerControl, ...)`, `class MillenniaEv25Device(LaserSourceDevice, OnOffControl, ShutterControl, PowerControl)`, `class MatisseDevice(PhysicalDevice, WavelengthControl)`. ## Adding a new device @@ -83,13 +90,23 @@ Reference implementation: `hardwarelibrary/daq/labjackdevice.py`. The pattern: - The main branch is named `master`, not `main`. - Open PRs against `master`. Prefer per-bug commits (each with a Problem/Solution body) over bundled commits — PR #74 is the template. +## Versioning and releases + +- The version is **not** stored in a file — `setuptools-scm` derives it from the latest git tag (`pyproject.toml` has `dynamic = ["version"]`). `hardwarelibrary.__version__` reads it back at runtime. +- A release is cut by creating and pushing an annotated tag `vX.Y.Z`. That triggers `.github/workflows/publish.yml`, which builds the sdist/wheel, publishes to PyPI via Trusted Publishing (OIDC, no token), and creates a GitHub Release with auto-generated notes. +- Follow semver: new device/feature with no breakage → minor; fixes only → patch. +- Record API-affecting changes in `CHANGELOG.md` (Keep a Changelog format). Note the project's standing warning: **API changes can land even when the minor version is unchanged**, so the changelog and release notes are the source of truth, not the version number. + ## Test running cheatsheet ``` +python3 -m pytest hardwarelibrary/tests/ -v # whole suite (collection now works) python3 -m pytest hardwarelibrary/tests/testCommandRecognition.py -v python3 -m pytest hardwarelibrary/tests/testPhysicalDevice.py -v python3 -m pytest hardwarelibrary/tests/testTableDrivenDebugPort.py -v python3 -m pytest hardwarelibrary/tests/testLabjackU3.py -v +python3 -m pytest hardwarelibrary/tests/testDeviceController.py -v +python3 -m pytest hardwarelibrary/tests/testThorlabs.py -v ``` Single test: From 7fc41851247694383d7a8c0c1c3d1d96b6aedf5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Sat, 27 Jun 2026 23:50:41 -0400 Subject: [PATCH 2/2] Fix doc inconsistencies found in review Problem: review of the new guides surfaced three inaccuracies. - CLAUDE.md said test_pylablib_kinesis.py uses skipUnless; it uses skipTest(). - LinearMotionDevice.mapPositions had a wrong docstring: it claimed the direction values were "leftRight"/"zigzag" (the Direction enum is unidirectional/bidirectional) and that it returns position tuples usable directly in moveTo, when it actually returns {"index", "position"} dicts with positions in microns (so they suit moveInMicronsTo, not moveTo). - SKILL.md mirrored that wrong mapPositions usage. Solution: correct the CLAUDE.md wording, rewrite the mapPositions docstring to match the real return shape and direction values, and update the SKILL.md example to iterate the dicts and feed point["position"] to moveInMicronsTo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/pyhardwarelibrary/SKILL.md | 6 +++++- CLAUDE.md | 2 +- hardwarelibrary/motion/linearmotiondevice.py | 10 ++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.claude/skills/pyhardwarelibrary/SKILL.md b/.claude/skills/pyhardwarelibrary/SKILL.md index 1cd0056..1ba0ff2 100644 --- a/.claude/skills/pyhardwarelibrary/SKILL.md +++ b/.claude/skills/pyhardwarelibrary/SKILL.md @@ -82,7 +82,11 @@ stage.home() stage.moveInMicronsTo((x_um, y_um, z_um)) # micron helpers stage.moveInMicronsBy((dx_um, dy_um, dz_um)) posUm = stage.positionInMicrons() -points = stage.mapPositions(width, height, stepInMicrons) # raster a sample + +# Raster a sample: returns dicts {"index": (i, j), "position": (x, y, depth)}, +# positions in microns relative to the current position. +for point in stage.mapPositions(width, height, stepInMicrons): + stage.moveInMicronsTo(point["position"]) stage.shutdownDevice() ``` diff --git a/CLAUDE.md b/CLAUDE.md index b190d53..eea8fed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ This file is for AI-assisted contributors. Humans should read `README.md` and th - Always try to use a local virtual environment if present in `.venv`. Python can be `python3`, or in a venv `python`. - Tests: `python3 -m pytest hardwarelibrary/tests/.py -v`. - Directory collection (`pytest hardwarelibrary/tests/`) now works: `pyproject.toml` sets `python_files = ["test*.py"]` and `testpaths = ["hardwarelibrary/tests"]`, so the `testFoo.py` naming is collected. (The historical "zero tests" gotcha — pytest's default `test_*.py` pattern not matching `testFoo.py` — is resolved.) -- Tests for hardware-dependent code must `skipTest(...)` when no hardware is attached — they must not fail. Pattern established in PRs #65 and #66; the `DebugLabjackDevice` tests (`tests/testLabjackU3.py`) and the `skipUnless` library-presence guards in `tests/test_pylablib_kinesis.py` are the cleanest references. +- Tests for hardware-dependent code must `skipTest(...)` when no hardware is attached — they must not fail. Pattern established in PRs #65 and #66; the `DebugLabjackDevice` tests (`tests/testLabjackU3.py`) and the library-presence `skipTest(...)` guards in `tests/test_pylablib_kinesis.py` are the cleanest references. - CI runs the suite on push/PR via `.github/workflows/tests.yml`; Sphinx docs build and publish to ReadTheDocs (`.readthedocs.yaml`, `docs/`). ## Architecture diff --git a/hardwarelibrary/motion/linearmotiondevice.py b/hardwarelibrary/motion/linearmotiondevice.py index 602b57a..409a3be 100644 --- a/hardwarelibrary/motion/linearmotiondevice.py +++ b/hardwarelibrary/motion/linearmotiondevice.py @@ -114,9 +114,15 @@ def mapPositions( stepInMicrons: float, direction: Direction = Direction.unidirectional, ): - """mapPositions(width, height, stepInMicrons[, direction == "leftRight" or "zigzag"]) + """Build a raster of positions covering a width x height grid. - Returns a list of position tuples, which can be used directly in moveTo functions, to map a sample. + direction is a Direction: unidirectional (every row left to right) or + bidirectional (alternate rows reversed, a serpentine/zigzag path). + + Returns a list of dicts, one per grid point, each shaped + {"index": (i, j), "position": (x, y, depth)}. Positions are in microns + (relative to the current position), so feed point["position"] to + moveInMicronsTo, not moveTo (which expects native steps). """ (initWidth, initHeight, depth) = self.positionInMicrons()