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 at the start of each session
:ok = Nanodrop.set_dark(pid) # Pedestal closed, no sample
:ok = Nanodrop.set_blank(pid) # Water or buffer on pedestal
# 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.Protocol
alias Nanodrop.Spectrum
@default_integration_time 100_000
@calibration_max_age_seconds 30 * 60
@measurement_staleness_seconds 5 * 60
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: Spectrum.calibration(),
dark: Spectrum.t() | nil,
blank: Spectrum.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 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 Protocol.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
# 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(mode, _from, state) do
case Protocol.get_spectrum(state.device) do
{:ok, spectrum} ->
{:reply, :ok, %{state | mode => 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, Spectrum.t()} | {:error, term()}, state()}
defp get_raw_spectrum_impl(_from, state) do
case Protocol.get_spectrum(state.device) do
{:ok, spectrum} ->
spectrum = Spectrum.with_calibration(spectrum, state.wavelength_calibration)
{:reply, {:ok, spectrum}, 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} <- Protocol.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
pixel = wavelength_to_pixel(wavelength_nm, spectrum.wavelengths)
Enum.at(spectrum.absorbance, pixel)
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 <- Protocol.initialize(device),
:ok <- Protocol.set_integration_time(device, @default_integration_time),
{:ok, serial} <- Protocol.query_info(device, :serial_number),
{:ok, calibration} <- Protocol.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_pixels =
[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)
%{
absorbance: absorbance_pixels,
wavelengths: wavelengths,
timestamp: DateTime.utc_now()
}
end
defp wavelength_to_pixel(wavelength_nm, wavelengths) do
# Find the pixel index closest to the target wavelength
wavelengths
|> Enum.with_index()
|> Enum.min_by(fn {wl, _idx} -> abs(wl - wavelength_nm) end)
|> elem(1)
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(: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