Metadata-Version: 2.4
Name: dpv-calibration
Version: 0.1.0
Summary: Univariate DPV calibration: OLS fit, inverse-regression prediction, LOD/LOQ.
Project-URL: Repository, https://github.com/danvu772/dpv-calibration
License: MIT License
        
        Copyright (c) 2026 Dan Vu
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Requires-Python: >=3.10
Requires-Dist: dpv-monopeak>=0.1.1
Requires-Dist: numpy>=1.24
Requires-Dist: openpyxl>=3.1
Requires-Dist: pandas>=2.0
Requires-Dist: scikit-learn>=1.3
Requires-Dist: scipy>=1.10
Provides-Extra: plot
Requires-Dist: matplotlib>=3.7; extra == 'plot'
Description-Content-Type: text/markdown

# dpv-calibration

Univariate OLS calibration for Differential Pulse Voltammetry (DPV). Given a set of standard scans at known concentrations, the model fits a calibration line, predicts analyte concentration from new scans via inverse regression, and reports 95% confidence intervals plus LOD/LOQ classification. All statistics follow Lavagnini & Magno (2007).

Depends on [dpv-monopeak](https://github.com/danvu772/dpv-monopeak) for peak feature extraction from raw voltage/current arrays.

---

## Usage with AI assistants

This README is designed to be pasted directly into an LLM coding assistant as context. All types, units, parameter names, return values, and mathematical definitions are documented explicitly so that an LLM can generate correct, complete code against this API without access to the source.

---

## Installation

```bash
pip install dpv-calibration
```

For plotting support (`plot_calibration`):

```bash
pip install "dpv-calibration[plot]"
```

---

## Quick start

```python
import numpy as np
from dpv_calibration import DPVScan, CalibrationModel, plot_calibration

# Build calibration scans (v in volts, i in µA, concentration_M in mol/L)
scans = [
    DPVScan(v=v_arr, i=i_arr, concentration_M=1e-9),
    DPVScan(v=v_arr, i=i_arr, concentration_M=1e-8),
    # ... one DPVScan per replicate per concentration level
]

# Fit — log scale is auto-selected when dynamic range exceeds 1000×
model = CalibrationModel(feature="Height")
model.fit(scans)
print(model)
# CalibrationModel(feature='Height', log_x=True, R²=0.9821, RMSECV=0.3104,
#                  LOD=3.14e-13 M, LOQ=1.05e-12 M)

# Predict from a new scan
result = model.predict(v=new_v, i=new_i)
print(result.concentration_M)  # float, mol/L
print(result.status)            # "below_lod" | "detected" | "quantifiable"
print(result.ci)                # (lower_M, upper_M) — 95% CI in mol/L

# Plot (requires matplotlib)
ax = plot_calibration(model)
ax.figure.savefig("calibration.png", dpi=150, bbox_inches="tight")
```

---

## API reference

### `DPVScan`

```python
@dataclass
class DPVScan:
    v: np.ndarray        # voltage array, shape (N,), volts
    i: np.ndarray        # current array, shape (N,), µA (must be consistent across the calibration set)
    concentration_M: float  # analyte concentration, mol/L (must be > 0)
```

Plain data container. No processing occurs at construction. `v` and `i` must be the same length.

---

### `PredictionResult`

```python
@dataclass
class PredictionResult:
    concentration_M: float  # predicted analyte concentration, mol/L
    status: str             # detection classification (see below)
    ci: tuple               # (lower_M, upper_M) — 95% confidence interval, mol/L
```

**`status` values:**

| Value | Condition | Meaning |
|---|---|---|
| `"below_lod"` | `concentration_M < model.lod_M` | Signal is indistinguishable from blank noise. Do not report a concentration. |
| `"detected"` | `lod_M ≤ concentration_M < loq_M` | Analyte is present but cannot be quantified accurately. Use for presence/absence decisions only. |
| `"quantifiable"` | `concentration_M ≥ loq_M` | Result is reliable for quantitative reporting. |

**`ci`** is a `(lower_M, upper_M)` tuple in mol/L. Both bounds are back-transformed to linear molar units even when the model was fitted in log space. The interval widens when the predicted concentration is far from the centre of the calibration range or when n is small.

---

### `CalibrationModel`

```python
CalibrationModel(feature: str = "Height", log_x: bool | None = None)
```

**Constructor parameters:**

| Parameter | Type | Default | Description |
|---|---|---|---|
| `feature` | `str` | `"Height"` | dpv-monopeak feature used as the calibration signal (y). Valid values: `"Height"`, `"Area"`, `"Width"`, `"X"`, `"YOffset"`, `"MaxSlope"`, `"MinSlope"`, `"SumSlope"`. |
| `log_x` | `bool \| None` | `None` | Whether to fit in log₁₀(concentration) space. `None` = auto: set to `True` if `max(concentration) / min(concentration) > 1000`, else `False`. Can be forced by passing `True` or `False` explicitly. |

**Public attributes — available after `.fit()`:**

| Attribute | Type | Unit | Description |
|---|---|---|---|
| `feature` | `str` | — | Feature name passed at construction. |
| `log_x` | `bool` | — | Whether the fit used log₁₀ concentration space. Resolved from `None` after fit. |
| `r2` | `float` | — | Coefficient of determination (R²) of the OLS fit on the training data. |
| `rmsecv` | `float` | log₁₀(M) or M | Root-mean-square error of leave-one-out cross-validation, in the same units as the transformed x (log₁₀ M if `log_x=True`, M if `log_x=False`). |
| `lod_M` | `float` | mol/L | Limit of detection. Concentration below which the signal cannot be distinguished from blank noise (3σ rule). |
| `loq_M` | `float` | mol/L | Limit of quantification. Concentration below which quantitative accuracy is insufficient (10σ rule). Always `> lod_M`. |

**`__repr__` example (fitted):**

```
CalibrationModel(feature='Height', log_x=True, R²=0.9821, RMSECV=0.3104,
                 LOD=3.14e-13 M, LOQ=1.05e-12 M)
```

**`__repr__` example (not fitted):**

```
CalibrationModel(feature='Height', not fitted)
```

---

#### `CalibrationModel.fit(scans)`

```python
model.fit(scans: list[DPVScan]) -> CalibrationModel
```

Fit the calibration model. Returns `self` (supports chaining: `model.fit(scans).predict(v, i)`).

- Calls `dpv_monopeak.extract_dpv_features(scan.v, scan.i)` for each scan. Scans where no valid Faradaic peak is found (`None` return) are **silently skipped**.
- Resolves `log_x` from `None` if not set explicitly.
- Fits OLS in the (optionally log-transformed) concentration × feature space.
- Computes `r2`, `rmsecv` (leave-one-out CV), `lod_M`, `loq_M`.

Multiple replicates at the same concentration level are supported — each `DPVScan` is treated as an independent observation.

---

#### `CalibrationModel.predict(v, i, m=1, alpha=0.05)`

```python
model.predict(
    v: np.ndarray,       # voltage array of the unknown scan, volts
    i: np.ndarray,       # current array, µA
    m: int = 1,          # number of replicates averaged to produce this scan
    alpha: float = 0.05, # CI significance level (0.05 → 95%, 0.01 → 99%)
) -> PredictionResult
```

Predict concentration from a raw DPV scan using inverse regression.

**Raises:**
- `RuntimeError` — if called before `.fit()`.
- `ValueError` — if `extract_dpv_features` finds no valid peak in the scan.

**`m` (replicates):** if the scan passed to `predict` is the average of `m` replicate measurements, set `m` accordingly. The CI width scales as `1/√m`. Pass `m=1` for a single scan.

**`alpha`:** significance level for the confidence interval. `alpha=0.05` gives a 95% CI, `alpha=0.01` gives 99%.

**How inverse regression works:** standard calibration predicts signal from concentration (`y = b₀ + b₁x`). To go from a measured signal `y₀` back to concentration, the equation is inverted: `x̂₀ = (y₀ − b₀) / b₁`. This is not a separate regression of x on y — it correctly propagates calibration uncertainty. When `log_x=True`, `x̂₀` is in log₁₀(M) and is back-transformed via `10^x̂₀` before being stored in `PredictionResult.concentration_M`.

---

### `plot_calibration`

```python
from dpv_calibration import plot_calibration  # requires [plot] extra

plot_calibration(
    model: CalibrationModel,
    ax: matplotlib.axes.Axes | None = None,
    alpha: float = 0.05,
) -> matplotlib.axes.Axes
```

Plot a fitted model. Creates a new figure if `ax` is `None`.

**What it draws:**

| Element | Description |
|---|---|
| Scatter | Calibration points (`_x_train`, `_y_train`) — x is in transformed space |
| OLS line | Fitted line over the calibration range ± 10% padding |
| Confidence band | Shaded band for the mean response at each x (Lavagnini & Magno 2007, Eq. 9b) |
| Orange dashed line | LOD position: `log₁₀(lod_M)` if `log_x=True`, else `lod_M` |
| Red dashed line | LOQ position: `log₁₀(loq_M)` if `log_x=True`, else `loq_M` |
| Text annotation | R² and RMSECV in the bottom-right corner |

**X-axis label:** `"log₁₀(Concentration / M)"` when `model.log_x=True`; `"Concentration (M)"` when `False`.  
**Y-axis label:** the value of `model.feature`.

**Raises:**
- `ImportError` — if matplotlib is not installed (`pip install "dpv-calibration[plot]"`).
- `RuntimeError` — if the model has not been fitted.

---

## Loading your data

The snippet below is a complete, copy-pasteable loader for **PSTrace multi-curve Excel exports** (PalmSens instrument software). Adapt it to your own file format — the only requirement for the rest of the API is a list of `DPVScan` objects with correct `v`, `i`, and `concentration_M` values.

```python
import re
import numpy as np
import pandas as pd
from dpv_calibration import DPVScan


def load_palmsens_xlsx(
    path: str,
    keyword: str = "cort",
    conc_pattern: str = r"(\d+(?:\.\d+)?)\s*(pM|nM|uM|mM)",
) -> list[DPVScan]:
    """
    Load DPV scans from a PSTrace multi-curve Excel export.

    Expected file layout
    --------------------
    Row 0  — scan names (e.g. "10nM Cort 5mM redox det")
    Row 1  — column units (e.g. "V", "µA")
    Row 2+ — numeric data, 81 rows per scan

    Columns come in pairs: (voltage, current), (voltage, current), ...

    Parameters
    ----------
    path         : path to .xlsx file
    keyword      : case-insensitive substring — only column-pairs whose name
                   contains this string are loaded (use "" to load all)
    conc_pattern : regex with two capture groups (value, unit)
                   applied to each scan name to extract concentration;
                   columns with no match are skipped
    """
    _units = {"pm": 1e-12, "nm": 1e-9, "um": 1e-6, "mm": 1e-3}

    def _parse_conc(label):
        m = re.search(conc_pattern, label, re.IGNORECASE)
        if not m:
            return None
        value, unit = float(m.group(1)), m.group(2).lower()
        return value * _units[unit]

    raw = pd.read_excel(path, header=None)
    scans = []
    for col in range(0, raw.shape[1], 2):
        name = str(raw.iloc[0, col])
        if keyword.lower() not in name.lower():
            continue
        data = raw.iloc[2:, col:col + 2].dropna()
        data.columns = ["V", "I"]
        data = data.apply(pd.to_numeric, errors="coerce").dropna()
        if len(data) != 81:       # PSTrace DPV exports exactly 81 points
            continue
        conc = _parse_conc(name)
        if conc is None:
            continue
        scans.append(DPVScan(
            v=data["V"].values,
            i=data["I"].values,
            concentration_M=conc,
        ))
    return scans
```

**Concentration labels recognised by the default pattern** (case-insensitive, first match in the label):

| Example label | Parsed concentration |
|---|---|
| `"1pM Cort 5mM redox det"` | `1e-12 M` |
| `"10nM cort"` | `1e-8 M` |
| `"0.5uM Cortisol"` | `5e-7 M` |
| `"1mM Cort 5mM redox 10mM PBS det"` | `1e-3 M` |

---

## Mathematical reference

All equations follow **Lavagnini & Magno (2007)** unless noted.

### OLS calibration line

Fit: `y = b₀ + b₁x`, where `y` is the peak feature value and `x` is concentration (or log₁₀ concentration when `log_x=True`).

| Quantity | Formula | Eq. |
|---|---|---|
| Slope | $b_1 = \dfrac{\sum(x_i - \bar{x})(y_i - \bar{y})}{\sum(x_i - \bar{x})^2}$ | 3–4 |
| Intercept | $b_0 = \bar{y} - b_1\bar{x}$ | 3–4 |
| Residual SD | $s_{y/x} = \sqrt{\dfrac{\sum(y_i - \hat{y}_i)^2}{n - 2}}$ | 5 |

### Confidence band for mean response (used in `plot_calibration`)

$$\hat{y}(x) \pm t_{n-2,\,\alpha/2} \cdot s_{y/x} \sqrt{\frac{1}{n} + \frac{(x - \bar{x})^2}{S_{xx}}}$$

where $S_{xx} = \sum(x_i - \bar{x})^2$. (Eq. 9b)

### Inverse regression (used in `predict`)

$$\hat{x}_0 = \frac{y_0 - b_0}{b_1}$$

95% CI on $\hat{x}_0$:

$$\hat{x}_0 \pm t_{n-2,\,\alpha/2} \cdot \frac{s_{y/x}}{|b_1|} \sqrt{\frac{1}{m} + \frac{1}{n} + \frac{(\hat{x}_0 - \bar{x})^2}{S_{xx}}}$$

where $m$ = number of replicates in the unknown measurement. (Eq. 10–11)

When `log_x=True`, $\hat{x}_0$ is in log₁₀(M). All reported concentrations and CI bounds are back-transformed: $c = 10^{\hat{x}_0}$.

### LOD and LOQ

Computed in transformed x-space, then back-transformed:

$$\text{LOD} = \frac{3\,s_{y/x}}{b_1}, \qquad \text{LOQ} = \frac{10\,s_{y/x}}{b_1}$$

(Lavagnini & Magno 2007, Section V; Miller & Miller 1988)

- **LOD** — signal is 3 standard deviations above blank; the smallest concentration that can be reliably detected.
- **LOQ** — signal is 10 standard deviations above blank; the smallest concentration that can be reliably quantified (≤ 10% RSD).

---

## Citation

> Lavagnini, I.; Magno, F. "A statistical overview on univariate calibration, inverse regression, and detection limits: Application to gas chromatography/mass spectrometry technique." *Mass Spectrometry Reviews* **2007**, *26*, 1–18. https://doi.org/10.1002/mas.20100

---

## License

MIT © 2026 Dan Vu
