# `Nanodrop`

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.

# `absorbance_spectrum`

```elixir
@type absorbance_spectrum() :: %{
  absorbance: [float()],
  wavelengths: [float()],
  timestamp: DateTime.t()
}
```

# `wavelength_calibration`

```elixir
@type wavelength_calibration() :: %{
  intercept: float(),
  first_coefficient: float(),
  second_coefficient: float(),
  third_coefficient: float()
}
```

# `absorbance_at`

```elixir
@spec absorbance_at(absorbance_spectrum(), float()) :: float()
```

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`.

# `calibrate`

```elixir
@spec calibrate(GenServer.server()) :: :ok | {:error, term()}
```

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.

# `calibrated?`

```elixir
@spec calibrated?(GenServer.server()) :: boolean()
```

Returns whether the device is calibrated (has both dark and blank spectra).

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `get_raw_spectrum`

```elixir
@spec get_raw_spectrum(GenServer.server()) ::
  {:ok, Nanodrop.Spectrum.t()} | {:error, term()}
```

Acquires a raw spectrum from the device.

Returns pixel intensity values without any processing.

# `get_spectrum`

```elixir
@spec get_spectrum(GenServer.server()) ::
  {:ok, absorbance_spectrum()} | {:error, term()}
```

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.

# `info`

```elixir
@spec info(GenServer.server()) :: map()
```

Returns device information.

# `list_devices`

```elixir
@spec list_devices() :: [Nanodrop.Device.device_info()]
```

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<...>}]

# `measure_nucleic_acid`

```elixir
@spec measure_nucleic_acid(
  GenServer.server(),
  keyword()
) :: {:ok, map()} | {:error, term()}
```

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.

# `measure_protein`

```elixir
@spec measure_protein(
  GenServer.server(),
  keyword()
) :: {:ok, map()} | {:error, term()}
```

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.

# `serial_number`

```elixir
@spec serial_number(GenServer.server()) :: String.t()
```

Returns the device serial number.

# `set_blank`

```elixir
@spec set_blank(GenServer.server()) :: :ok | {:error, term()}
```

Measures and stores the blank/reference spectrum.

Call this with your reference solvent (water, buffer) on the pedestal.

# `set_dark`

```elixir
@spec set_dark(GenServer.server()) :: :ok | {:error, term()}
```

Measures and stores the dark spectrum.

Call this with the light path blocked (pedestal arm closed, nothing on pedestal).

# `set_integration_time`

```elixir
@spec set_integration_time(GenServer.server(), pos_integer()) ::
  :ok | {:error, term()}
```

Sets the integration time in microseconds.

Valid range: 3,000 - 655,350,000 µs.
Default is 100,000 µs (100ms).

# `start_link`

```elixir
@spec start_link(keyword()) :: GenServer.on_start()
```

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

# `wavelength_calibration`

```elixir
@spec wavelength_calibration(GenServer.server()) :: map()
```

Returns the wavelength calibration coefficients.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
