Beer-Lambert Calibration Curve — lets-plot

A calibration curve plotting absorbance versus concentration following Beer-Lambert law (A = εlc). Measured calibration standards are shown as scatter points with a linear regression fit line. The regression equation (y = mx + b) and R² value are displayed on the plot. An example unknown sample is marked with dashed lines extending to both axes, demonstrating how the curve is used to determine concentration from a measured absorbance. This plot is fundamental in analytical chemistry for quantitative spectrophotometric analysis.

Beer-Lambert Calibration Curve rendered with lets-plot

Python source (lets-plot)

""" anyplot.ai
calibration-beer-lambert: Beer-Lambert Calibration Curve
Library: letsplot 4.10.1 | Python 3.13.13
Quality: 88/100 | Updated: 2026-06-03
"""

import os

import numpy as np
import pandas as pd
from lets_plot import *
from lets_plot.export import ggsave
from scipy import stats


LetsPlot.setup_html()

THEME = os.getenv("ANYPLOT_THEME", "light")

# Imprint palette: position 1 = calibration standards/regression; position 5 = unknown focal point
BRAND = "#009E73"  # calibration standards + regression (Imprint position 1)
FOCAL = "#AE3030"  # unknown sample highlight (Imprint position 5)

# Theme-adaptive chrome tokens
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# Data — calibration standards for UV-Vis spectrophotometry (Cu²⁺ at 810 nm)
np.random.seed(42)
concentrations = np.array([0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0])
absorbance_true = 0.045 * concentrations
absorbance_measured = absorbance_true + np.random.normal(0, 0.008, len(concentrations))
absorbance_measured[0] = max(0.002, absorbance_measured[0])

# Linear regression
slope, intercept, r_value, _p, _se = stats.linregress(concentrations, absorbance_measured)
r_squared = r_value**2

# Prediction interval over fit range
n = len(concentrations)
x_mean = np.mean(concentrations)
x_fit = np.linspace(0, 12.5, 200)
y_fit = slope * x_fit + intercept
se_y = np.sqrt(np.sum((absorbance_measured - (slope * concentrations + intercept)) ** 2) / (n - 2))
t_val = stats.t.ppf(0.975, n - 2)
pi = t_val * se_y * np.sqrt(1 + 1 / n + (x_fit - x_mean) ** 2 / np.sum((concentrations - x_mean) ** 2))

# Unknown sample — concentration determined from absorbance via regression
unknown_absorbance = 0.34
unknown_concentration = (unknown_absorbance - intercept) / slope

# DataFrames
df_standards = pd.DataFrame({"concentration": concentrations, "absorbance": absorbance_measured})
df_fit = pd.DataFrame({"concentration": x_fit, "absorbance": y_fit, "upper": y_fit + pi, "lower": y_fit - pi})
df_unknown = pd.DataFrame({"concentration": [unknown_concentration], "absorbance": [unknown_absorbance]})
df_segments = pd.DataFrame(
    {
        "x": [0.0, unknown_concentration],
        "y": [unknown_absorbance, 0.0],
        "xend": [unknown_concentration, unknown_concentration],
        "yend": [unknown_absorbance, unknown_absorbance],
    }
)

eq_label = f"y = {slope:.4f}x + {intercept:.4f}\nR² = {r_squared:.5f}"
df_eq = pd.DataFrame({"x": [1.0], "y": [0.49], "label": [eq_label]})
df_unknown_label = pd.DataFrame(
    {
        "x": [unknown_concentration + 0.5],
        "y": [unknown_absorbance + 0.032],
        "label": [f"Unknown\n({unknown_concentration:.1f} mg/L, A = {unknown_absorbance})"],
    }
)

# Build plot — layer_tooltips() is a lets-plot distinctive feature for interactive HTML output
plot = (
    ggplot()
    + geom_ribbon(aes(x="concentration", ymin="lower", ymax="upper"), data=df_fit, fill=BRAND, alpha=0.12)
    + geom_line(aes(x="concentration", y="absorbance"), data=df_fit, color=BRAND, size=1.0)
    + geom_segment(
        aes(x="x", y="y", xend="xend", yend="yend"), data=df_segments, color=FOCAL, size=0.7, linetype="dashed"
    )
    + geom_point(
        aes(x="concentration", y="absorbance"),
        data=df_standards,
        fill=BRAND,
        color="white",
        size=3.5,
        alpha=0.9,
        shape=21,
        stroke=1.0,
        tooltips=layer_tooltips()
        .title("Calibration Standard")
        .line("Concentration: @concentration mg/L")
        .line("Absorbance: @absorbance"),
    )
    + geom_point(
        aes(x="concentration", y="absorbance"),
        data=df_unknown,
        fill=FOCAL,
        color="white",
        size=3.0,
        shape=23,
        stroke=1.0,
        tooltips=layer_tooltips()
        .title("Unknown Sample")
        .line("Concentration: @concentration mg/L")
        .line("Absorbance: @absorbance"),
    )
    + geom_text(aes(x="x", y="y", label="label"), data=df_eq, size=5, color=INK, family="monospace", hjust=0)
    + geom_text(
        aes(x="x", y="y", label="label"), data=df_unknown_label, size=4.5, color=FOCAL, hjust=0, fontface="italic"
    )
    + labs(x="Concentration (mg/L)", y="Absorbance", title="calibration-beer-lambert · python · letsplot · anyplot.ai")
    + scale_x_continuous(limits=[-0.5, 13.5], breaks=[0, 2, 4, 6, 8, 10, 12])
    + scale_y_continuous(limits=[-0.02, 0.58], breaks=[0, 0.1, 0.2, 0.3, 0.4, 0.5])
    + coord_cartesian(xlim=[-0.5, 13.5], ylim=[-0.02, 0.58])
    + ggsize(800, 450)
    + theme_minimal()
    + theme(
        axis_text=element_text(size=10, color=INK_SOFT),
        axis_title=element_text(size=12, color=INK),
        plot_title=element_text(size=16, color=INK, face="bold"),
        panel_grid_major_x=element_blank(),
        panel_grid_major_y=element_line(color=INK_SOFT, size=0.3),
        panel_grid_minor=element_blank(),
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
        panel_border=element_blank(),
        axis_ticks=element_blank(),
        axis_ticks_length=0,
        plot_margin=[30, 40, 20, 20],
    )
)

# Save theme-suffixed PNG (3200×1800 via ggsize(800,450) × scale=4) and interactive HTML
ggsave(plot, filename=f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, filename=f"plot-{THEME}.html", path=".")

Part of Beer-Lambert Calibration Curve on anyplot.ai.

Other implementations