Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-supportbefore #1166 was squashed into main, but itschanges did not survive. On current
main:_configure_usb_devicecallsclear_haltunguarded. On macOS libusb raises[Errno 2] Entity not foundfor an endpoint that is not actually halted, sosetup()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 doesnot 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
0x0Fproduces 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()andmeasure_absorbance()both wrapped every acquisition inset_magnet(True), so every measurement silently used the 0.2 mm short path — theleast 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 bedivided 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 runmeasures 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 exceedsAUTORANGE_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_spectrumare 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_absorbancefrom a 2-tuple to(wavelengths, {path_mm: absorbance}). Deliberate while the backend is new: anabsorbance without its path length cannot be interpreted. Happy to revisit the shape.
3. Host-side averaging
averages=Nontake_blank()andmeasure_absorbance(). All scans in an average happeninside 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 expectedrather than a defect: absorbance is
-log10((S-D)/(B-D)), so averaging only the sampleleaves the blank's noise in place. With comparable noise the prediction is
√(2/1.1) = 1.35×. Blanking with
averages=Ntoo is needed for the full benefit, and thedocstring 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 burningEvery time this instrument was left with its lamp energised during development, the cause
was a script that died before reaching
stop()— an unrelatedAttributeError, afailed probe.
stop()itself is reliable; it just has to be reached. Reproduced ondemand, and confirmed fixed:
One thing I tried and deliberately left out: a process-wide failsafe on
atexit. Do notdo this — an
atexithandler runs during interpreter finalisation, when libusb's contextis already being torn down, and
dev.write()there segfaults the process (exit code139, with the handler's own log line printed immediately before the crash). A segfault is
not catchable, so wrapping it in
try/exceptaccomplishes nothing, and such a guard isworse than none. A
sys.excepthookversion does work, but overriding a global hook as animport 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_pathrejecting a saturated long path, teardown via
__aexit__, and the crash reproductionbefore and after.
ruff checkandruff formatclean.🤖 Generated with Claude Code