Packages

Elixir library for interfacing with NanoDrop 1000 spectrophotometers over USB. Uses the Ocean Optics OOI protocol.

Current section

Files

Jump to
nanodrop lib nanodrop.ex
Raw

lib/nanodrop.ex

defmodule Nanodrop do
@moduledoc """
Elixir library for interfacing with NanoDrop 1000 spectrophotometers.
The NanoDrop 1000 internally uses an Ocean Optics USB2000 spectrometer and
communicates via the OOI (Ocean Optics Interface) protocol over USB.
## Quick Start
# Start the server (connects to first available device)
{:ok, pid} = Nanodrop.start_link()
# Calibrate with water/buffer on pedestal (takes dark + blank in one shot)
:ok = Nanodrop.calibrate(pid)
# Measure a sample (includes full spectrum in result)
{:ok, result} = Nanodrop.measure_nucleic_acid(pid)
# => %{a260: 1.5, a280: 0.75, a260_a280: 2.0, concentration_ng_ul: 75.0, spectrum: ...}
# Or get the spectrum and analyze it
{:ok, spectrum} = Nanodrop.get_spectrum(pid)
abs_280 = Nanodrop.absorbance_at(spectrum, 280.0)
## Device Identification
- USB Vendor ID: `0x2457` (Ocean Optics)
- USB Product ID: `0x1002` (USB2000)
## Calibration
For accurate absorbance measurements, you need two reference spectra:
1. **Dark** - Detector baseline with no light (close pedestal arm, no sample)
2. **Blank** - 100% transmission through solvent (water/buffer on pedestal)
Absorbance is then calculated as: `A = -log10((sample - dark) / (blank - dark))`
## Distributed Operation
This module is designed to work over Erlang distribution. The NanoDrop server
can run on a dedicated node (e.g., a Nerves device with USB access) while
being controlled from a remote node.
### Direct node reference
# On the device node (e.g., nanodrop@device.local)
{:ok, pid} = Nanodrop.start_link(name: Nanodrop)
# From a remote node
Nanodrop.set_dark({Nanodrop, :"nanodrop@device.local"})
Nanodrop.set_blank({Nanodrop, :"nanodrop@device.local"})
{:ok, result} = Nanodrop.measure_nucleic_acid({Nanodrop, :"nanodrop@device.local"})
### Global registration
# On the device node
{:ok, pid} = Nanodrop.start_link(name: {:global, :nanodrop})
# From any connected node
Nanodrop.set_dark({:global, :nanodrop})
{:ok, result} = Nanodrop.measure_nucleic_acid({:global, :nanodrop})
### Process groups (pg)
# On the device node
{:ok, pid} = Nanodrop.start_link()
:pg.join(:spectrophotometers, pid)
# From any connected node
[pid | _] = :pg.get_members(:spectrophotometers)
{:ok, result} = Nanodrop.measure_nucleic_acid(pid)
All API functions accept any valid `GenServer.server()` reference.
"""
use GenServer
alias Nanodrop.Device
alias Nanodrop.OOI
alias Nanodrop.Spectrum
@default_integration_time 100_000
@calibration_max_age_seconds 30 * 60
@measurement_staleness_seconds 5 * 60
@type wavelength_calibration :: %{
intercept: float(),
first_coefficient: float(),
second_coefficient: float(),
third_coefficient: float()
}
defstruct ~w[device serial_number wavelength_calibration dark blank integration_time last_measurement_at]a
@typep state :: %__MODULE__{
device: Device.t(),
serial_number: String.t(),
wavelength_calibration: wavelength_calibration(),
dark: OOI.t() | nil,
blank: OOI.t() | nil,
integration_time: pos_integer(),
last_measurement_at: DateTime.t() | nil
}
# ===========================================================================
# Boilerplate & Initialization
# ===========================================================================
@doc """
Starts the NanoDrop server and connects to a device.
Returns `:ignore` if running in network-only mode or if the USB library
is not available. This allows the application to start on nodes that
don't have USB access (e.g., remote control nodes in a distributed setup).
## Options
- `:device` - A device info map from `Nanodrop.list_devices/0`. If not provided,
connects to the first available device.
- `:name` - Optional name for the GenServer.
## Configuration
- `:network_only` - When set to `true` in application config, the server
will return `:ignore` instead of connecting to USB. This is useful for
nodes that only need to call a remote NanoDrop server over distribution.
config :nanodrop, network_only: true
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
server_opts = Keyword.take(opts, [:name])
if network_only?() do
:ignore
else
GenServer.start_link(__MODULE__, opts, server_opts)
end
end
@spec network_only?() :: boolean()
defp network_only? do
Application.get_env(:nanodrop, :network_only, false) or not usb_available?()
end
@spec usb_available?() :: boolean()
defp usb_available?, do: Code.ensure_loaded?(:usb)
@impl true
def init(opts) do
device_info = Keyword.get(opts, :device)
with {:ok, device} <- Device.open(device_info),
{:ok, state} <- initialize_device(device) do
{:ok, state}
else
{:error, reason} ->
{:stop, reason}
end
end
# USB device handle is automatically closed when owner process terminates
# (NIF uses enif_monitor_process for cleanup)
# ===========================================================================
# API + Implementations
# ===========================================================================
@type absorbance_spectrum :: %{
absorbance: [float()],
wavelengths: [float()],
timestamp: DateTime.t()
}
@spec list_devices() :: [Device.device_info()]
@spec info(GenServer.server()) :: map()
@spec serial_number(GenServer.server()) :: String.t()
@spec wavelength_calibration(GenServer.server()) :: map()
@spec set_integration_time(GenServer.server(), pos_integer()) :: :ok | {:error, term()}
@spec set_dark(GenServer.server()) :: :ok | {:error, term()}
@spec set_blank(GenServer.server()) :: :ok | {:error, term()}
@spec calibrate(GenServer.server()) :: :ok | {:error, term()}
@spec calibrated?(GenServer.server()) :: boolean()
@spec get_raw_spectrum(GenServer.server()) :: {:ok, Spectrum.t()} | {:error, term()}
@spec get_spectrum(GenServer.server()) :: {:ok, absorbance_spectrum()} | {:error, term()}
@spec absorbance_at(absorbance_spectrum(), float()) :: float()
@spec measure_nucleic_acid(GenServer.server(), keyword()) :: {:ok, map()} | {:error, term()}
@spec measure_protein(GenServer.server(), keyword()) :: {:ok, map()} | {:error, term()}
@doc """
Lists all connected NanoDrop devices.
Returns a list of device info maps that can be used with `start_link/1`.
## Example
Nanodrop.list_devices()
#=> [%{vendor_id: 9303, product_id: 4098, bus: 1, address: 19, device_ref: #Reference<...>}]
"""
defdelegate list_devices(), to: Device
@doc """
Returns device information.
"""
def info(server) do
GenServer.call(server, :info)
end
@spec info_impl(GenServer.from(), state()) :: {:reply, map(), state()}
defp info_impl(_from, state) do
now = DateTime.utc_now()
calibrated_at =
case {state.dark, state.blank} do
{dark = %{}, blank = %{}} -> Enum.min([dark.timestamp, blank.timestamp], DateTime)
_ -> nil
end
info = %{
serial_number: state.serial_number,
wavelength_calibration: state.wavelength_calibration,
integration_time: state.integration_time,
calibrated: _calibrated?(state, now),
calibrated_at: calibrated_at,
last_measurement_at: state.last_measurement_at
}
{:reply, info, state}
end
@doc """
Returns the device serial number.
"""
def serial_number(server) do
GenServer.call(server, :serial_number)
end
@spec serial_number_impl(GenServer.from(), state()) :: {:reply, String.t(), state()}
defp serial_number_impl(_from, state) do
{:reply, state.serial_number, state}
end
@doc """
Returns the wavelength calibration coefficients.
"""
def wavelength_calibration(server) do
GenServer.call(server, :wavelength_calibration)
end
@spec wavelength_calibration_impl(GenServer.from(), state()) ::
{:reply, Spectrum.calibration(), state()}
defp wavelength_calibration_impl(_from, state) do
{:reply, state.wavelength_calibration, state}
end
@doc """
Sets the integration time in microseconds.
Valid range: 3,000 - 655,350,000 µs.
Default is 100,000 µs (100ms).
"""
def set_integration_time(server, microseconds) do
GenServer.call(server, {:set_integration_time, microseconds})
end
@spec set_integration_time_impl(pos_integer(), GenServer.from(), state()) ::
{:reply, :ok | {:error, term()}, state()}
defp set_integration_time_impl(microseconds, _from, state) do
case OOI.set_integration_time(state.device, microseconds) do
:ok ->
{:reply, :ok, %{state | integration_time: microseconds}}
{:error, _} = error ->
{:reply, error, state}
end
end
@doc """
Measures and stores the dark spectrum.
Call this with the light path blocked (pedestal arm closed, nothing on pedestal).
"""
def set_dark(server) do
GenServer.call(server, {:set_spectrum, :dark})
end
@doc """
Measures and stores the blank/reference spectrum.
Call this with your reference solvent (water, buffer) on the pedestal.
"""
def set_blank(server) do
GenServer.call(server, {:set_spectrum, :blank})
end
@doc """
Performs full calibration in one shot (dark + blank).
Call this with your reference solvent (water, buffer) on the pedestal
and the arm closed. This will:
1. Take a dark measurement (no lamp flash) - detector baseline
2. Take a blank measurement (lamp flashes) - reference through solvent
This is more efficient than calling `set_dark/1` and `set_blank/1` separately
since both measurements are taken with the same sample in place.
"""
def calibrate(server) do
GenServer.call(server, :calibrate)
end
# Minimum blank intensity to consider calibration valid (strobe fired)
@min_blank_intensity 2500
@spec calibrate_impl(GenServer.from(), state()) ::
{:reply, :ok | {:error, term()}, state()}
defp calibrate_impl(_from, state) do
# Take dark spectrum first (no strobe), then blank (with strobe)
# Both measurements use the same sample in place
with {:ok, dark} <- OOI.get_dark_spectrum(state.device),
{:ok, blank} <- OOI.get_spectrum(state.device),
:ok <- validate_blank_intensity(blank) do
{:reply, :ok, %{state | dark: dark, blank: blank}}
else
{:error, _} = error -> {:reply, error, state}
end
end
defp validate_blank_intensity(blank) do
max_intensity = Enum.max(blank.raw_pixels)
if max_intensity >= @min_blank_intensity do
:ok
else
{:error, {:calibration_failed, :low_blank_intensity, max_intensity}}
end
end
# implementation for both dark and blank spectrum
@spec set_spectrum_impl(:dark | :blank, GenServer.from(), state()) ::
{:reply, :ok | {:error, term()}, state()}
defp set_spectrum_impl(:dark, _from, state) do
# Dark measurement: no strobe/lamp - measures detector baseline
case OOI.get_dark_spectrum(state.device) do
{:ok, spectrum} ->
{:reply, :ok, %{state | dark: spectrum}}
{:error, _} = error ->
{:reply, error, state}
end
end
defp set_spectrum_impl(:blank, _from, state) do
# Blank measurement: strobe/lamp enabled - measures through reference solvent
case OOI.get_spectrum(state.device) do
{:ok, spectrum} ->
{:reply, :ok, %{state | blank: spectrum}}
{:error, _} = error ->
{:reply, error, state}
end
end
@doc """
Returns whether the device is calibrated (has both dark and blank spectra).
"""
def calibrated?(server) do
GenServer.call(server, :calibrated?)
end
@spec calibrated_impl(GenServer.from(), state()) :: {:reply, boolean(), state()}
defp calibrated_impl(_from, state) do
{:reply, _calibrated?(state, DateTime.utc_now()), state}
end
@doc """
Acquires a raw spectrum from the device.
Returns pixel intensity values without any processing.
"""
def get_raw_spectrum(server) do
GenServer.call(server, :get_raw_spectrum)
end
@spec get_raw_spectrum_impl(GenServer.from(), state()) ::
{:reply, {:ok, OOI.t()} | {:error, term()}, state()}
defp get_raw_spectrum_impl(_from, state) do
case OOI.get_spectrum(state.device) do
{:ok, ooi} ->
{:reply, {:ok, ooi}, state}
{:error, _} = error ->
{:reply, error, state}
end
end
@doc """
Acquires a spectrum and calculates absorbance values.
Requires calibration (dark and blank spectra).
Returns a spectrum with absorbance and wavelength values for each pixel.
This is the single GenServer call for spectrum acquisition. Use this
with `absorbance_at/2` or the measurement functions for analysis.
"""
def get_spectrum(server) do
GenServer.call(server, :get_spectrum)
end
@spec get_spectrum_impl(GenServer.from(), state()) ::
{:reply, {:ok, absorbance_spectrum()} | {:error, term()}, state()}
defp get_spectrum_impl(_from, state) do
now = DateTime.utc_now()
with :ok <- check_calibration(state, now),
{:ok, spectrum} <- OOI.get_spectrum(state.device) do
absorbance = calculate_absorbance_spectrum(spectrum, state)
{:reply, {:ok, absorbance}, %{state | last_measurement_at: now}}
else
{:error, _} = error -> {:reply, error, state}
end
end
@doc """
Returns the absorbance at a specific wavelength from a spectrum.
Wavelength is in nanometers. This is a pure function that operates
on a spectrum returned by `get_spectrum/1`.
"""
def absorbance_at(spectrum, wavelength_nm) do
Spectrum.absorbance_at(spectrum, wavelength_nm)
end
@doc """
Measures nucleic acid concentration.
Returns A260, A280, A260/A280 ratio, estimated concentration, and the full spectrum.
Uses the approximation: 1 A260 = 50 ng/µL for dsDNA (1mm path).
## Options
- `:factor` - Conversion factor (default: 50.0 for dsDNA, use 33.0 for ssDNA, 40.0 for RNA)
Requires calibration.
"""
def measure_nucleic_acid(server, opts \\ []) do
with {:ok, spectrum} <- get_spectrum(server) do
a260 = absorbance_at(spectrum, 260.0)
a280 = absorbance_at(spectrum, 280.0)
a230 = absorbance_at(spectrum, 230.0)
factor = Keyword.get(opts, :factor, 50.0)
result = %{
a260: a260,
a280: a280,
a230: a230,
a260_a280: safe_ratio(a260, a280),
a260_a230: safe_ratio(a260, a230),
concentration_ng_ul: a260 * factor,
spectrum: spectrum
}
{:ok, result}
end
end
@doc """
Measures protein concentration using A280.
Returns A280, estimated concentration, and the full spectrum.
## Options
- `:extinction_coefficient` - Extinction coefficient (default: 1.0, meaning 1 A280 = 1 mg/mL)
Requires calibration.
"""
def measure_protein(server, opts \\ []) do
with {:ok, spectrum} <- get_spectrum(server) do
a280 = absorbance_at(spectrum, 280.0)
extinction = Keyword.get(opts, :extinction_coefficient, 1.0)
result = %{
a280: a280,
concentration_mg_ml: a280 / extinction,
spectrum: spectrum
}
{:ok, result}
end
end
# ===========================================================================
# Helper Functions
# ===========================================================================
defp initialize_device(device) do
with :ok <- OOI.initialize(device),
:ok <- OOI.set_integration_time(device, @default_integration_time),
{:ok, serial} <- OOI.query_info(device, :serial_number),
{:ok, calibration} <- OOI.query_info(device, :wavelength_calibration) do
state = %__MODULE__{
device: device,
serial_number: serial,
wavelength_calibration: calibration,
integration_time: @default_integration_time,
dark: nil,
blank: nil
}
{:ok, state}
end
end
defp _calibrated?(%{dark: nil}, _now), do: false
defp _calibrated?(%{blank: nil}, _now), do: false
defp _calibrated?(state, now) do
calibrated_at = Enum.min([state.dark.timestamp, state.blank.timestamp], DateTime)
calibration_age = DateTime.diff(now, calibrated_at, :second)
calibration_stale = calibration_age > @calibration_max_age_seconds
measurement_stale =
state.last_measurement_at == nil or
DateTime.diff(now, state.last_measurement_at, :second) > @measurement_staleness_seconds
not (calibration_stale and measurement_stale)
end
defp check_calibration(%{dark: nil}, _now), do: {:error, :no_dark_calibration}
defp check_calibration(%{blank: nil}, _now), do: {:error, :no_blank_calibration}
defp check_calibration(state, now) do
if _calibrated?(state, now) do
:ok
else
{:error, :recalibration_needed}
end
end
defp calculate_absorbance_spectrum(sample, state) do
dark = state.dark.raw_pixels
blank = state.blank.raw_pixels
sample_pixels = sample.raw_pixels
cal = state.wavelength_calibration
absorbance =
[sample_pixels, dark, blank]
|> Enum.zip()
|> Enum.map(fn {s, d, b} ->
transmittance = (s - d) / max(b - d, 1)
-:math.log10(max(transmittance, 0.0001))
end)
wavelengths =
Enum.map(0..2047, fn n ->
cal.intercept +
cal.first_coefficient * n +
cal.second_coefficient * n * n +
cal.third_coefficient * n * n * n
end)
Spectrum.new(wavelengths, absorbance)
end
defp safe_ratio(_a, b) when b == 0, do: nil
defp safe_ratio(a, b), do: a / b
# ===========================================================================
# Router
# ===========================================================================
@impl true
def handle_call(:info, from, state), do: info_impl(from, state)
def handle_call(:serial_number, from, state), do: serial_number_impl(from, state)
def handle_call(:wavelength_calibration, from, state),
do: wavelength_calibration_impl(from, state)
def handle_call({:set_integration_time, us}, from, state),
do: set_integration_time_impl(us, from, state)
def handle_call({:set_spectrum, mode}, from, state), do: set_spectrum_impl(mode, from, state)
def handle_call(:calibrate, from, state), do: calibrate_impl(from, state)
def handle_call(:calibrated?, from, state), do: calibrated_impl(from, state)
def handle_call(:get_raw_spectrum, from, state), do: get_raw_spectrum_impl(from, state)
def handle_call(:get_spectrum, from, state), do: get_spectrum_impl(from, state)
end