Skip to content

Commit c7c0a0b

Browse files
committed
Merge branch 'master' into qepro-driver
2 parents 513c25e + d9ef686 commit c7c0a0b

59 files changed

Lines changed: 2352 additions & 58 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
---
2+
name: pyhardwarelibrary
3+
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.
4+
---
5+
6+
# Using PyHardwareLibrary
7+
8+
PyHardwareLibrary is a device-oriented library: every instrument is a `PhysicalDevice`
9+
subclass with a uniform lifecycle and a small, predictable public API per family. This
10+
skill is for *using* devices to get work done (move, measure, set). To *write a new
11+
driver*, read `CLAUDE.md` and `README-4-New-device-coding-example.md` instead.
12+
13+
## The one rule: lifecycle
14+
15+
Every device follows the same pattern. Construct it, initialize it, use it, shut it down.
16+
17+
```python
18+
from hardwarelibrary.motion.sutterdevice import SutterDevice
19+
20+
stage = SutterDevice() # construct (does not touch hardware)
21+
stage.initializeDevice() # opens the port, raises on failure
22+
try:
23+
stage.moveTo((1000, 2000, 0))
24+
print(stage.position())
25+
finally:
26+
stage.shutdownDevice() # always release the port
27+
```
28+
29+
- `initializeDevice()` opens the connection and raises `PhysicalDevice.UnableToInitialize`
30+
if the device is absent/busy. Nothing works before it.
31+
- `shutdownDevice()` closes the port. Always call it (use `try/finally`).
32+
- Calling a command before `initializeDevice()` raises `PhysicalDevice.NotInitialized`.
33+
34+
## Finding / constructing a device
35+
36+
There is no reliable generic auto-discovery — do **not** rely on `PhysicalDevice.any()`
37+
(it is incomplete and returns nothing). Use one of these instead:
38+
39+
- **Spectrometers have working discovery**: `Spectrometer.any()` returns the first
40+
supported spectrometer, ready to use.
41+
- **Everything else: construct the concrete class directly.** USB devices match by
42+
serial number (default `None`/`"*"` = first found); serial/TCP devices take a port
43+
path. See each family below for the exact constructor.
44+
45+
## Run without hardware (debug devices)
46+
47+
Most families ship a `DebugXxxDevice` that emulates the protocol in memory, so examples
48+
and tests run with nothing plugged in. They obey the same lifecycle and API.
49+
50+
```python
51+
from hardwarelibrary.motion.linearmotiondevice import DebugLinearMotionDevice
52+
from hardwarelibrary.daq.labjackdevice import DebugLabjackDevice
53+
from hardwarelibrary.sources.millennia import DebugMillenniaEv25Device
54+
55+
stage = DebugLinearMotionDevice()
56+
stage.initializeDevice()
57+
stage.moveTo((10, 20, 30))
58+
print(stage.position()) # (10, 20, 30)
59+
stage.shutdownDevice()
60+
```
61+
62+
## Family quick reference
63+
64+
### Linear motion stages — Sutter MP-285, Thorlabs (Kinesis)
65+
66+
Base: `LinearMotionDevice`. Positions are 3-tuples `(x, y, z)` in native steps.
67+
68+
```python
69+
from hardwarelibrary.motion.sutterdevice import SutterDevice
70+
stage = SutterDevice(serialNumber=None) # first one found
71+
# Thorlabs is not re-exported from motion/__init__; import from its module and
72+
# install the extra: pip install -e .[thorlabs]
73+
# from hardwarelibrary.motion.thorlabs import ThorlabsKinesisDevice
74+
# stage = ThorlabsKinesisDevice(serialNumber="...")
75+
stage.initializeDevice()
76+
77+
stage.moveTo((x, y, z)) # absolute, native steps
78+
stage.moveBy((dx, dy, dz)) # relative
79+
pos = stage.position() # -> (x, y, z)
80+
stage.home()
81+
82+
stage.moveInMicronsTo((x_um, y_um, z_um)) # micron helpers
83+
stage.moveInMicronsBy((dx_um, dy_um, dz_um))
84+
posUm = stage.positionInMicrons()
85+
86+
# Raster a sample: returns dicts {"index": (i, j), "position": (x, y, depth)},
87+
# positions in microns relative to the current position.
88+
for point in stage.mapPositions(width, height, stepInMicrons):
89+
stage.moveInMicronsTo(point["position"])
90+
stage.shutdownDevice()
91+
```
92+
93+
### Rotation stages — Intellidrive
94+
95+
Base: `RotationDevice`. Note `IntellidriveDevice(serialNumber)` requires the serial number.
96+
97+
```python
98+
from hardwarelibrary.motion.intellidrivedevice import IntellidriveDevice
99+
rot = IntellidriveDevice(serialNumber="...")
100+
rot.initializeDevice()
101+
rot.moveTo(angle) # absolute, degrees
102+
rot.moveBy(deltaTheta) # relative
103+
theta = rot.orientation()
104+
rot.home()
105+
rot.shutdownDevice()
106+
```
107+
108+
### Spectrometers — Ocean Insight USB2000 / USB2000+ / USB4000 / USB650 / SAS
109+
110+
Base: `Spectrometer`. The public method *is* the hardware hook (no `do*` wrapper).
111+
112+
```python
113+
from hardwarelibrary.spectrometers import Spectrometer
114+
115+
spectrometer = Spectrometer.any() # discovery works here
116+
spectrometer.initializeDevice()
117+
spectrometer.setIntegrationTime(50) # ms
118+
spectrum = spectrometer.getSpectrum() # numpy array, aligned to .wavelength
119+
print(spectrometer.getSerialNumber())
120+
spectrometer.saveSpectrum("scan.csv") # wavelength + intensity columns
121+
spectrometer.shutdownDevice()
122+
123+
# One-liner live view (initializes for you; needs matplotlib + Tk):
124+
# Spectrometer.any().display()
125+
```
126+
127+
### Laser sources — Cobolt, Spectra-Physics Millennia eV, Sirah Matisse
128+
129+
Base: `LaserSourceDevice` plus capability mixins. A device only has the methods of
130+
the capabilities it declares — check the table.
131+
132+
| Capability | Methods |
133+
|---|---|
134+
| `OnOffControl` | `turnOn()`, `turnOff()`, `isLaserOn()`, `canTurnOn()` |
135+
| `ShutterControl` | `openShutter()`, `closeShutter()`, `isShutterOpen()` |
136+
| `PowerControl` | `setPower(watts)`, `power()` |
137+
| `InterlockControl` | `interlock()` |
138+
| `WavelengthControl` | `setWavelength(nm)`, `wavelength()`, `wavelengthRange()` |
139+
140+
```python
141+
from hardwarelibrary.sources.millennia import MillenniaEv25Device
142+
143+
laser = MillenniaEv25Device(portPath="/dev/cu.usbmodemXXXX")
144+
laser.initializeDevice()
145+
laser.setPower(5.0) # watts
146+
laser.turnOn()
147+
laser.openShutter()
148+
print(laser.power(), laser.isLaserOn(), laser.isShutterOpen())
149+
laser.closeShutter()
150+
laser.turnOff()
151+
laser.shutdownDevice()
152+
```
153+
154+
- Cobolt: `CoboltDevice(portPath="COM3")` — OnOff + Power (+ autostart constraints; it
155+
may refuse `turnOn()` when autostart is on, raising `CoboltCantTurnOnWithAutostartOn`).
156+
- Matisse: `MatisseDevice(...)` over TCP — `WavelengthControl` (`setWavelength`/`wavelength`)
157+
plus BiFi/etalon/piezo/scan methods.
158+
159+
### Power meters — Gentec-EO Integra
160+
161+
Base: `PowerMeterDevice`.
162+
163+
```python
164+
from hardwarelibrary.powermeters import IntegraDevice
165+
meter = IntegraDevice()
166+
meter.initializeDevice()
167+
meter.setCalibrationWavelength(800) # nm
168+
watts = meter.measureAbsolutePower() # the read method
169+
meter.shutdownDevice()
170+
```
171+
172+
### DAQ — LabJack U3
173+
174+
Combines capability mixins: analog/digital in/out, plus hardware-timed input.
175+
176+
```python
177+
from hardwarelibrary.daq.labjackdevice import LabjackDevice
178+
daq = LabjackDevice() # serialNumber="*" -> first found
179+
daq.initializeDevice()
180+
v = daq.getAnalogVoltage(channel=0) # read
181+
daq.setAnalogVoltage(2.5, channel=1) # write (U3 DACs are slow PWM)
182+
bit = daq.getDigitalValue(channel=4)
183+
daq.setDigitalValue(1, channel=5)
184+
# Hardware-timed acquisition: daq.acquireWaveform(...) (AnalogInputStreamDevice)
185+
daq.shutdownDevice()
186+
```
187+
188+
### Oscilloscopes — Tektronix TDS
189+
190+
Instantiated directly (no family/driver split); methods are SCPI per instrument.
191+
192+
```python
193+
from hardwarelibrary.oscilloscope import OscilloscopeDevice
194+
scope = OscilloscopeDevice()
195+
scope.initializeDevice()
196+
# per-instrument SCPI methods / Channels enum
197+
scope.shutdownDevice()
198+
```
199+
200+
## Reacting to events (NotificationCenter)
201+
202+
Devices post Cocoa-style notifications (state changes, measurements, moves) instead of
203+
requiring polling. Observe them from anywhere:
204+
205+
```python
206+
from hardwarelibrary.notificationcenter import NotificationCenter
207+
from hardwarelibrary.powermeters.powermeterdevice import PowerMeterNotification
208+
209+
def onMeasure(notification):
210+
print("power:", notification.userInfo)
211+
212+
NotificationCenter().addObserver(self, onMeasure, PowerMeterNotification.didMeasure)
213+
# ... measurements now call onMeasure with the value in notification.userInfo
214+
NotificationCenter().removeObserver(self)
215+
```
216+
217+
Useful notification enums: `PhysicalDeviceNotification` (will/did initialize/shutdown,
218+
status), `LinearMotionNotification` (willMove/didMove/didGetPosition),
219+
`PowerMeterNotification.didMeasure`.
220+
221+
## Headless / GUI apps: DeviceController
222+
223+
For an app (GUI, long-running service) wrap the device in a `DeviceController`. It runs
224+
**all** device access on one worker thread, so blocking calls never freeze a UI and port
225+
access is serialized. It auto-reconnects and reports through `NotificationCenter`.
226+
227+
```python
228+
from hardwarelibrary.devicecontroller import (
229+
DeviceController, DeviceControllerNotification as N)
230+
from hardwarelibrary.notificationcenter import NotificationCenter
231+
from hardwarelibrary.sources.millennia import MillenniaEv25Device
232+
233+
controller = DeviceController(MillenniaEv25Device(portPath="/dev/cu.usbmodemXXXX"))
234+
235+
NotificationCenter().addObserver(self, lambda n: print(n.userInfo), N.status)
236+
controller.start()
237+
controller.connect()
238+
239+
controller.submit(lambda device: device.turnOn()) # fire-and-forget
240+
reading = controller.submit(lambda device: device.power()).result() # query, returns a Future
241+
# Do not block on .result() from a UI thread.
242+
243+
controller.stop()
244+
```
245+
246+
`submit(action)` runs `action(device)` on the worker and returns a
247+
`concurrent.futures.Future` carrying the result or the exception. Failures also post a
248+
`commandFailed` notification; drops post `connectionLost`/`connectionFailed`.
249+
250+
## Gotchas
251+
252+
- **Always `initializeDevice()` before use, and `shutdownDevice()` after** — wrap in
253+
`try/finally`. A leaked open port blocks the next run with a "resource busy" error.
254+
- **`PhysicalDevice.any()` / `anyDevice()` are incomplete** — only `Spectrometer.any()`
255+
returns a usable device. For other families, construct the concrete class.
256+
- **`DeviceManager` is not fully operational** — prefer the per-family approach above.
257+
- **No hardware? Use the `DebugXxxDevice`** for that family to develop and test.
258+
- **The version is git-tag-derived** (`setuptools-scm`); read `CHANGELOG.md`, because
259+
API changes can land even when the minor version is unchanged.

.github/workflows/tests.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
branches: [master]
8+
9+
# Cancel superseded runs on the same branch/PR to save runner minutes.
10+
concurrency:
11+
group: tests-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
test:
19+
name: ${{ matrix.os }} / py${{ matrix.python-version }}
20+
runs-on: ${{ matrix.os }}
21+
# Hardware-dependent tests skipTest() when no device is attached, so the
22+
# suite is fully runnable headless; this guard just stops a hung run.
23+
timeout-minutes: 20
24+
strategy:
25+
fail-fast: false
26+
matrix:
27+
os: [ubuntu-latest, macos-latest, windows-latest]
28+
python-version: ["3.11", "3.12", "3.13", "3.14"]
29+
include:
30+
# Honour requires-python >= 3.9 without tripling the matrix: cover the
31+
# two oldest supported versions on Linux only.
32+
- os: ubuntu-latest
33+
python-version: "3.9"
34+
- os: ubuntu-latest
35+
python-version: "3.10"
36+
37+
env:
38+
# No display on the runners; force matplotlib's non-interactive backend so
39+
# any import or plotting call in the tests cannot block on a GUI.
40+
MPLBACKEND: Agg
41+
42+
steps:
43+
- uses: actions/checkout@v6
44+
with:
45+
# Full history + tags so setuptools_scm can version the editable install.
46+
fetch-depth: 0
47+
48+
- uses: actions/setup-python@v6
49+
with:
50+
python-version: ${{ matrix.python-version }}
51+
52+
- name: Install libusb (Linux)
53+
# pyusb has no wheel-bundled backend; provide libusb so `import usb`
54+
# finds one. Tests skip when no device is present, but this keeps the
55+
# backend lookup from emitting noise.
56+
if: runner.os == 'Linux'
57+
run: sudo apt-get update && sudo apt-get install -y libusb-1.0-0
58+
59+
- name: Install package and test dependencies
60+
run: |
61+
python -m pip install --upgrade pip
62+
pip install -e ".[dev]"
63+
64+
- name: Run tests
65+
run: python -m pytest hardwarelibrary/tests -v --tb=short

.readthedocs.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
version: 2
2+
3+
build:
4+
os: ubuntu-22.04
5+
tools:
6+
python: "3.12"
7+
apt_packages:
8+
- libusb-1.0-0
9+
10+
sphinx:
11+
configuration: docs/source/conf.py
12+
13+
python:
14+
install:
15+
- method: pip
16+
path: .
17+
extra_requirements:
18+
- docs

0 commit comments

Comments
 (0)