|
| 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. |
0 commit comments