Skip to main content

Univariate DPV calibration: OLS fit, inverse-regression prediction, LOD/LOQ.

Project description

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

pip install dpv-calibration

For plotting support (plot_calibration):

pip install "dpv-calibration[plot]"

Quick start

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

@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

@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

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)

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)

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

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.

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dpv_calibration-0.1.0.tar.gz (45.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dpv_calibration-0.1.0-py3-none-any.whl (12.4 kB view details)

Uploaded Python 3

File details

Details for the file dpv_calibration-0.1.0.tar.gz.

File metadata

  • Download URL: dpv_calibration-0.1.0.tar.gz
  • Upload date:
  • Size: 45.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for dpv_calibration-0.1.0.tar.gz
Algorithm Hash digest
SHA256 08e4a900346dd99c040ab60c30908e8b633dd239ff8627a48f9745f3f0175ae4
MD5 b14a40910be37ea5d9f77b0688607473
BLAKE2b-256 1f7da07fab6b1372a3f9975c022cb708114cf3717648f549f53fb7f04b742f48

See more details on using hashes here.

File details

Details for the file dpv_calibration-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for dpv_calibration-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 12369a56ca0fad5ec617fef4d08eadce4c0df3eb6b943c790879b8852387507a
MD5 38198b80c4425f0d45f7e07c30b04d01
BLAKE2b-256 9acefe224be12261571576c6262febc31e07705de93f5d15b54dfbcdaf2f50a7

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page