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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions .claude/skills/pyhardwarelibrary/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
---
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()

# 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()
```

### 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.
25 changes: 21 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<file>.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 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

Expand All @@ -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/`:

Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions hardwarelibrary/motion/linearmotiondevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading