Skip to content

NanoDrop 1000: restore macOS fixes, measure both optical paths, averaging, crash-safe teardown - #1270

Open
vcjdeboer wants to merge 6 commits into
PyLabRobot:mainfrom
vcjdeboer:nd1000-dual-path-and-lamp-safety
Open

vcjdeboer wants to merge 6 commits into
PyLabRobot:mainfrom
vcjdeboer:nd1000-dual-path-and-lamp-safety

Conversation

@vcjdeboer

Copy link
Copy Markdown
Collaborator

Follow-up to #1166, from running the backend against a physical ND-1000 on macOS.

1. A regression: the macOS fixes from #1252 are not on main

#1252 was merged into add-nd1000-support before #1166 was squashed into main, but its
changes did not survive. On current main:

  • _configure_usb_device calls clear_halt unguarded. On macOS libusb raises
    [Errno 2] Entity not found for an endpoint that is not actually halted, so setup()
    aborts before it can start. The backend cannot be used on macOS at all.
  • stop() no longer performs the USB reset. On this firmware [0x03, 0x00] alone does
    not clear the lamp latch, so the xenon lamp can be left running.

The first commit restores both. It is self-contained and can be cherry-picked on its own
if you would rather take the fix now and review the rest separately.

2. The pedestal solenoid is a path-length selector

Released, the sample column spans the full 1 mm gap; energised, the solenoid pulls the
lever onto a fixed mechanical stop and compresses the column to 0.2 mm. There is one
stop, so exactly two paths — no argument byte to 0x0F produces a third position.

Measured on the instrument with paracetamol, in the 260–280 nm window where neither path
is saturated, the long/short absorbance ratio is 5.5, consistent with 1.0 / 0.2.
Outside that window the ratio is meaningless because one path or the other is out of
range, which is itself the reason auto-ranging exists.

Previously take_blank() and measure_absorbance() both wrapped every acquisition in
set_magnet(True), so every measurement silently used the 0.2 mm short path — the
least sensitive one — and the returned absorbance carried no record of which path
produced it, which makes it uninterpretable and incomparable.

  • take_blank() blanks both paths by default. A sample measured on one path cannot be
    divided by a blank taken on another. Long path first: compressing a liquid column is
    safe, letting it expand can break it.
  • measure_absorbance(path=...) takes "both" (default), "auto", "long", "short"
    or a length in mm, and returns (wavelengths, {path_mm: absorbance}). A normal run
    measures both, as the instrument does — the vendor UV-Vis screen plots them together
    from one cycle, its y-axis reading "0.1 (RED) & 1 mm Absorbance (BLACK)". Both
    readings come from a single sample loading.
  • "auto" reads the long path and compresses only if it exceeds AUTORANGE_CUTOFF_AU.
    That threshold is the vendor's own: "Adjusted long path cutoff to 1.2AU for column
    formation test"
    , ND-1000 operating software V3.5.2 release notes. The decision ignores
    the photon-starved deep UV below 235 nm, which is noise.
  • select_path() picks the longest path still in range and warns when none is.
  • to_path_length() gives the 10 mm cuvette equivalent, ×10 long / ×50 short.
  • dark_spectrum / blank_spectrum are kept as properties onto the short-path baseline,
    which is exactly what they meant before path selection existed.

This changes the return type of measure_absorbance from a 2-tuple to
(wavelengths, {path_mm: absorbance}). Deliberate while the backend is new: an
absorbance without its path length cannot be interpreted. Happy to revisit the shape.

3. Host-side averaging

averages=N on take_blank() and measure_absorbance(). All scans in an average happen
inside one lamp-on, magnet-settled window — re-seating the solenoid between scans would
average over two different liquid columns rather than reducing read noise on one.

Measured (paracetamol, 0.2 mm, 20 ms), baseline noise 400–700 nm: averages=1 → 0.0083 A,
averages=10 → 0.0064 A. That is 1.30×, not √10 = 3.16×, and the shortfall is expected
rather than a defect: absorbance is -log10((S-D)/(B-D)), so averaging only the sample
leaves the blank's noise in place. With comparable noise the prediction is
√(2/1.1) = 1.35×. Blanking with averages=N too is needed for the full benefit, and the
docstring says so.

Smoothing is deliberately not included — Savitzky-Golay or boxcar is a presentation
choice for analysis code, not something a driver should bake into returned data.

4. async with, so a crash cannot leave the lamp burning

Every time this instrument was left with its lamp energised during development, the cause
was a script that died before reaching stop() — an unrelated AttributeError, a
failed probe. stop() itself is reliable; it just has to be reached. Reproduced on
demand, and confirmed fixed:

async with ThermoFisherNanoDrop1000() as nd:
    await nd.take_blank()
    wavelengths, spectra = await nd.measure_absorbance()

One thing I tried and deliberately left out: a process-wide failsafe on atexit. Do not
do this
— an atexit handler runs during interpreter finalisation, when libusb's context
is already being torn down, and dev.write() there segfaults the process (exit code
139, with the handler's own log line printed immediately before the crash). A segfault is
not catchable, so wrapping it in try/except accomplishes nothing, and such a guard is
worse than none. A sys.excepthook version does work, but overriding a global hook as an
import side effect seemed too invasive to propose here. Happy to add it if you want it.

5. Tests

The backend had none. 12 unit tests covering the parts that are pure computation and need
no USB transport. pylabrobot/thermo_fisher/ passes: 79 passed, 4 skipped.

Verification

All of the above was exercised against a physical ND-1000 on macOS: both baselines
acquired, both paths measured from one loading, forced and auto modes, select_path
rejecting a saturated long path, teardown via __aexit__, and the crash reproduction
before and after.

ruff check and ruff format clean.

🤖 Generated with Claude Code

vcjdeboer and others added 5 commits September 15, 2026 14:50
These two fixes were reviewed and merged as PyLabRobot#1252 into add-nd1000-support, but did
not survive the squash of PyLabRobot#1166 into main. Without them the backend cannot be used
on macOS at all.

- _configure_usb_device: macOS libusb raises "Entity not found" for an endpoint that
  is not actually halted, so the unguarded clear_halt aborts setup() before it can
  start. Make it best-effort per endpoint. seabreeze, the reference Ocean Optics
  stack for this same silicon, does not call clear_halt at all.
- stop(): the lamp latch on this firmware clears on a USB bus reset, not on
  [0x03, 0x00] alone. Restore the reset the original driver performed. Teardown only,
  so the re-enumeration is harmless.

Both were found by running against a physical ND-1000 on macOS and are re-verified
there today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pedestal solenoid is a path-length selector, not just a clamp. Released, the
sample column spans the full 1 mm gap; energised, it pulls the lever onto a fixed
mechanical stop and compresses the column to 0.2 mm. Measured on a physical ND-1000
with paracetamol: in the 260-280 nm window, where neither path is saturated, the
long/short absorbance ratio is 5.5, consistent with 1.0 / 0.2. Outside that window
the ratio is meaningless because one path or the other is out of range.

Previously take_blank() and measure_absorbance() both wrapped every acquisition in
set_magnet(True), so all measurements silently used the 0.2 mm short path -- the
least sensitive one -- and the returned absorbance carried no record of the path it
was taken on, which makes it uninterpretable and incomparable.

- take_blank() acquires dark+blank for both paths by default. A sample measured on
  one path cannot be divided by a blank from another. Long path first: compressing a
  liquid column is safe, letting it expand can break it.
- measure_absorbance(path=...) takes "both" (default), "auto", "long", "short" or a
  length in mm, and returns (wavelengths, {path_mm: absorbance}). A normal run
  measures both, as the instrument does -- the vendor UV-Vis screen plots the long
  and short paths together from one cycle, its y-axis reading "0.1 (RED) & 1 mm
  Absorbance (BLACK)". Both readings come from one sample loading.
- "auto" reads the long path and compresses only if it exceeds AUTORANGE_CUTOFF_AU,
  matching the vendor: "Adjusted long path cutoff to 1.2AU for column formation test"
  (ND-1000 software V3.5.2 release notes). The decision ignores the photon-starved
  deep-UV edge below 235 nm, which is noise.
- select_path() picks the longest path still in range, and warns when none is.
- to_path_length() rescales to a 10 mm cuvette equivalent, x10 long / x50 short.
- dark_spectrum / blank_spectrum are kept as properties onto the short-path baseline,
  which is exactly what they meant before path selection existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The instrument has no accumulate command, so averaging is host-side. Adds `averages`
to take_blank() and measure_absorbance(), both routed through _read_averaged().

Every scan in an average must happen inside one lamp-on, magnet-settled window:
re-seating the solenoid between scans would average over two different liquid columns
rather than reducing read noise on one.

Measured on a physical ND-1000 (paracetamol, 0.2 mm path, 20 ms), baseline noise over
400-700 nm: averages=1 gives 0.0083 A, averages=10 gives 0.0064 A -- 1.30x, not the
naive sqrt(10)=3.16x. That is expected rather than a defect: absorbance is
-log10((S-D)/(B-D)), so averaging only the sample leaves the blank's noise in place.
With comparable blank and sample noise the prediction is sqrt(2/1.1)=1.35x, which the
measurement matches. Averaging the blank as well is required for the full benefit, and
the docstring says so.

Smoothing is deliberately not included: Savitzky-Golay or boxcar is a presentation
choice for analysis code, not something a driver should bake into returned data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…burning

Every observed case of this instrument being left with its xenon lamp energised was a
script that died before reaching stop() -- an unrelated AttributeError, a failed probe.
stop() itself is reliable: [0x03, 0x00] silences the lamp every time on a healthy
device, verified by ear on a physical ND-1000. It simply never got sent.

    async with ThermoFisherNanoDrop1000() as nd:
      await nd.take_blank()
      wavelengths, spectra = await nd.measure_absorbance()

__aexit__ runs on exception propagation, so teardown happens either way.

Verified on hardware: the identical crash that previously left the lamp burning now
leaves it silent when wrapped in `async with`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backend had no tests. These cover the parts that are pure computation and need
no USB transport: path-length rescaling, path selection and its saturation fallback,
the auto-range window excluding the photon-starved deep UV, the legacy
dark_spectrum/blank_spectrum properties mapping onto the short-path baseline, the
Beer-Lambert math, and refusal to divide a sample by a blank from a different path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vcjdeboer
vcjdeboer requested a review from a team as a code owner September 15, 2026 12:53
baselines holds List[float], but the dark_spectrum/blank_spectrum setters accept
Optional[List[float]] for backwards compatibility. Assigning None straight through
put a None where a list is expected.

Setting None means 'no baseline', so remove the entry instead of storing None. The
getters already return None for a missing key, so behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant