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.

""" anyplot.ai
calibration-beer-lambert: Beer-Lambert Calibration Curve
Library: altair 6.1.0 | Python 3.13.13
Quality: 91/100 | Updated: 2026-06-03
"""
import os
import altair as alt
import numpy as np
import pandas as pd
from PIL import Image
# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
# Imprint palette — position 1 (brand green) for calibration series, position 4 (ochre) for unknown
BRAND = "#009E73"
UNKNOWN_COLOR = "#BD8233"
# Data — UV-Vis spectrophotometry calibration standards
np.random.seed(42)
concentrations = np.array([0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0])
epsilon_l = 0.045
true_absorbance = epsilon_l * concentrations
measured_absorbance = true_absorbance + np.random.normal(0, 0.008, len(concentrations))
measured_absorbance[0] = max(measured_absorbance[0], 0.002)
# Regression stats for prediction interval and annotation
n = len(concentrations)
x_mean = np.mean(concentrations)
y_mean = np.mean(measured_absorbance)
ss_xx = np.sum((concentrations - x_mean) ** 2)
ss_xy = np.sum((concentrations - x_mean) * (measured_absorbance - y_mean))
slope = ss_xy / ss_xx
intercept = y_mean - slope * x_mean
residuals = measured_absorbance - (slope * concentrations + intercept)
ss_res = np.sum(residuals**2)
ss_tot = np.sum((measured_absorbance - y_mean) ** 2)
r_squared = 1 - ss_res / ss_tot
# Prediction interval (95%, df=6)
x_fit = np.linspace(0, 15, 200)
y_fit = slope * x_fit + intercept
mse = ss_res / (n - 2)
se_pred = np.sqrt(mse * (1 + 1 / n + (x_fit - x_mean) ** 2 / ss_xx))
t_val = 2.447
upper = y_fit + t_val * se_pred
lower = y_fit - t_val * se_pred
# Unknown sample determination
unknown_absorbance = 0.38
unknown_concentration = (unknown_absorbance - intercept) / slope
# DataFrames
standards_df = pd.DataFrame({"Concentration (mg/L)": concentrations, "Absorbance": measured_absorbance})
fit_df = pd.DataFrame({"Concentration (mg/L)": x_fit, "Absorbance": y_fit, "Upper": upper, "Lower": lower})
unknown_point_df = pd.DataFrame({"Concentration (mg/L)": [unknown_concentration], "Absorbance": [unknown_absorbance]})
unknown_hline_df = pd.DataFrame(
{"Concentration (mg/L)": [0, unknown_concentration], "Absorbance": [unknown_absorbance, unknown_absorbance]}
)
unknown_vline_df = pd.DataFrame(
{"Concentration (mg/L)": [unknown_concentration, unknown_concentration], "Absorbance": [0, unknown_absorbance]}
)
# Shared scales
x_scale = alt.Scale(domain=[0, 15.5], nice=False)
y_scale = alt.Scale(domain=[0, 0.68])
# Prediction interval band (muted brand green)
band = (
alt.Chart(fit_df)
.mark_area(opacity=0.12, color=BRAND)
.encode(x=alt.X("Concentration (mg/L):Q", scale=x_scale), y=alt.Y("Lower:Q", scale=y_scale), y2="Upper:Q")
)
# Regression line — idiomatic Altair transform_regression
reg_line = (
alt.Chart(standards_df)
.mark_line(color=BRAND, strokeWidth=2.5)
.transform_regression("Concentration (mg/L)", "Absorbance")
.encode(x=alt.X("Concentration (mg/L):Q", scale=x_scale), y=alt.Y("Absorbance:Q", scale=y_scale))
)
# Calibration standard points with hover highlighting
highlight = alt.selection_point(on="pointerover", nearest=True, empty=False)
points = (
alt.Chart(standards_df)
.mark_point(filled=True, color=BRAND, stroke=PAGE_BG, strokeWidth=1.5)
.encode(
x=alt.X(
"Concentration (mg/L):Q",
scale=x_scale,
title="Concentration (mg/L)",
axis=alt.Axis(values=[0, 2, 4, 6, 8, 10, 12, 14]),
),
y=alt.Y(
"Absorbance:Q",
scale=y_scale,
title="Absorbance",
axis=alt.Axis(values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6], format=".1f"),
),
size=alt.condition(highlight, alt.value(280), alt.value(200)),
tooltip=[alt.Tooltip("Concentration (mg/L):Q", format=".1f"), alt.Tooltip("Absorbance:Q", format=".4f")],
)
.add_params(highlight)
)
# Unknown sample dashed projection lines
h_line = (
alt.Chart(unknown_hline_df)
.mark_line(color=UNKNOWN_COLOR, strokeWidth=1.8, strokeDash=[8, 6])
.encode(x=alt.X("Concentration (mg/L):Q", scale=x_scale), y=alt.Y("Absorbance:Q", scale=y_scale))
)
v_line = (
alt.Chart(unknown_vline_df)
.mark_line(color=UNKNOWN_COLOR, strokeWidth=1.8, strokeDash=[8, 6])
.encode(x=alt.X("Concentration (mg/L):Q", scale=x_scale), y=alt.Y("Absorbance:Q", scale=y_scale))
)
# Unknown sample point (diamond marker)
unknown_pt = (
alt.Chart(unknown_point_df)
.mark_point(size=220, filled=True, color=UNKNOWN_COLOR, stroke=PAGE_BG, strokeWidth=1.5, shape="diamond")
.encode(
x=alt.X("Concentration (mg/L):Q", scale=x_scale),
y=alt.Y("Absorbance:Q", scale=y_scale),
tooltip=[
alt.Tooltip("Concentration (mg/L):Q", title="Predicted Conc.", format=".2f"),
alt.Tooltip("Absorbance:Q", title="Measured Abs.", format=".4f"),
],
)
)
# Regression equation annotation — lighter weight for secondary info
eq_text = f"y = {slope:.4f}x + {intercept:.4f} R² = {r_squared:.4f}"
annotation_df = pd.DataFrame({"Concentration (mg/L)": [1.0], "Absorbance": [0.055], "text": [eq_text]})
eq_label = (
alt.Chart(annotation_df)
.mark_text(fontSize=10, align="left", fontWeight="normal", color=INK_SOFT)
.encode(x=alt.X("Concentration (mg/L):Q", scale=x_scale), y=alt.Y("Absorbance:Q", scale=y_scale), text="text:N")
)
# Unknown sample label
unknown_label_df = pd.DataFrame(
{
"Concentration (mg/L)": [unknown_concentration + 0.4],
"Absorbance": [unknown_absorbance + 0.028],
"text": [f"Unknown ({unknown_concentration:.1f} mg/L)"],
}
)
unknown_label = (
alt.Chart(unknown_label_df)
.mark_text(fontSize=10, align="left", fontWeight="bold", color=UNKNOWN_COLOR)
.encode(x=alt.X("Concentration (mg/L):Q", scale=x_scale), y=alt.Y("Absorbance:Q", scale=y_scale), text="text:N")
)
# Title with scaled font size for length
title_str = "calibration-beer-lambert · python · altair · anyplot.ai"
title_fs = round(16 * min(1.0, 67 / len(title_str)))
# Compose layers and configure theme-adaptive chrome
chart = (
alt.layer(band, reg_line, points, h_line, v_line, unknown_pt, eq_label, unknown_label)
.properties(
width=620, height=320, background=PAGE_BG, title=alt.Title(title_str, fontSize=title_fs, fontWeight="bold")
)
.configure_view(fill=PAGE_BG, strokeWidth=0)
.configure_axis(
labelFontSize=10,
titleFontSize=12,
titleColor=INK,
labelColor=INK_SOFT,
grid=False,
domainColor=INK_SOFT,
domainWidth=0.6,
tickColor=INK_SOFT,
tickSize=5,
tickWidth=0.6,
)
.configure_title(color=INK)
.interactive()
)
# Save PNG
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
# Pad to exactly 3200 × 1800 (landscape target)
TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
raise SystemExit(
f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
f"Shrink chart .properties(width=, height=) values and re-render."
)
if _w < TW or _h < TH:
_canvas = Image.new("RGB", (TW, TH), PAGE_BG)
_canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
_canvas.save(f"plot-{THEME}.png")
# Save HTML
chart.save(f"plot-{THEME}.html")
Part of Beer-Lambert Calibration Curve on anyplot.ai.