This chapter covers UV-Vis (ultraviolet-visible) spectroscopy, a fundamental technique for studying electronic transitions in molecules and materials. You will learn about electronic transition types, the Beer-Lambert law for quantitative analysis, chromophores and auxochromes, instrumentation principles, and practical applications including band gap determination for semiconductors using Tauc plots.
Introduction
UV-Vis spectroscopy probes electronic transitions in molecules and materials by measuring the absorption of ultraviolet (200-400 nm) and visible (400-800 nm) light. When photons with appropriate energy are absorbed, electrons are promoted from occupied molecular orbitals to unoccupied orbitals, providing information about electronic structure, conjugation, and material properties.
- Quantitative Analysis: Concentration determination using Beer-Lambert law
- Band Gap Measurement: Optical band gap of semiconductors via Tauc plots
- Molecular Structure: Identification of chromophores and conjugation extent
- Reaction Kinetics: Monitoring reaction progress through absorbance changes
- Material Characterization: Thin film optical properties, nanoparticle analysis
1. Electronic Transitions
1.1 Types of Electronic Transitions
Electronic transitions in molecules involve the promotion of electrons between different types of molecular orbitals. The main transition types are classified based on the initial and final orbital types:
150-200 nm"] B --> B2["pi to pi* (Medium Energy)
200-500 nm"] C --> C1["n to sigma* (Medium-High Energy)
150-250 nm"] C --> C2["n to pi* (Low Energy)
250-400 nm"] style A fill:#f093fb,color:#fff style B fill:#f5576c,color:#fff style C fill:#4ecdc4,color:#fff
The energy ordering of these transitions follows: $\sigma \to \sigma^* > n \to \sigma^* > \pi \to \pi^* > n \to \pi^*$
| Transition Type | Wavelength Range | Molar Absorptivity | Example Functional Groups |
|---|---|---|---|
| $\sigma \to \sigma^*$ | <150 nm (vacuum UV) | 1,000-10,000 | C-C, C-H bonds |
| $n \to \sigma^*$ | 150-250 nm | 100-3,000 | -OH, -NH2, -SH, halogens |
| $\pi \to \pi^*$ | 200-500 nm | 1,000-100,000 | C=C, C=O, aromatic rings |
| $n \to \pi^*$ | 250-400 nm | 10-100 | C=O, N=O, -NO2 |
Code Example 1: Electronic Transition Energy Diagram
"""
Electronic Transition Types and Energy Levels
Visualizes the relative energies of molecular orbitals and transitions
"""
import numpy as np
import matplotlib.pyplot as plt
def plot_molecular_orbital_diagram():
"""
Create a molecular orbital energy diagram showing
different electronic transitions.
Returns:
--------
fig : matplotlib figure
The generated figure
"""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 8))
# Define orbital energy levels (arbitrary units)
orbitals = {
'sigma': -4.0,
'pi': -2.5,
'n': -1.5,
'pi*': 1.5,
'sigma*': 3.5
}
# Define transitions with their properties
transitions = [
{'from': 'sigma', 'to': 'sigma*', 'color': '#e74c3c',
'label': r'$\sigma \to \sigma^*$', 'wavelength': '<150 nm'},
{'from': 'n', 'to': 'sigma*', 'color': '#e67e22',
'label': r'$n \to \sigma^*$', 'wavelength': '150-250 nm'},
{'from': 'pi', 'to': 'pi*', 'color': '#2ecc71',
'label': r'$\pi \to \pi^*$', 'wavelength': '200-500 nm'},
{'from': 'n', 'to': 'pi*', 'color': '#3498db',
'label': r'$n \to \pi^*$', 'wavelength': '250-400 nm'}
]
# Plot orbital energy levels
orbital_x = {'sigma': 0.5, 'pi': 1.5, 'n': 2.5, 'pi*': 3.5, 'sigma*': 4.5}
for orbital, energy in orbitals.items():
ax1.hlines(energy, orbital_x[orbital] - 0.3, orbital_x[orbital] + 0.3,
linewidth=4, color='#2c3e50')
ax1.text(orbital_x[orbital], energy + 0.3, orbital,
ha='center', fontsize=12, fontweight='bold')
# Draw transitions with arrows
arrow_offsets = [-0.3, -0.1, 0.1, 0.3]
for i, trans in enumerate(transitions):
from_x = orbital_x[trans['from']] + arrow_offsets[i % 4] * 0.3
to_x = orbital_x[trans['to']] + arrow_offsets[i % 4] * 0.3
ax1.annotate('', xy=(to_x, orbitals[trans['to']] - 0.15),
xytext=(from_x, orbitals[trans['from']] + 0.15),
arrowprops=dict(arrowstyle='->', color=trans['color'],
lw=2, mutation_scale=15))
ax1.set_xlim(0, 5)
ax1.set_ylim(-5, 5)
ax1.set_ylabel('Energy (arbitrary units)', fontsize=12)
ax1.set_title('Molecular Orbital Energy Diagram', fontsize=14, fontweight='bold')
ax1.set_xticks([])
ax1.axhline(y=0, color='gray', linestyle='--', alpha=0.3)
ax1.text(0.1, 0.2, 'HOMO-LUMO gap', fontsize=10, color='gray')
# Add legend
for i, trans in enumerate(transitions):
ax1.plot([], [], color=trans['color'], linewidth=3,
label=f"{trans['label']} ({trans['wavelength']})")
ax1.legend(loc='upper right', fontsize=10)
# Plot absorption spectrum simulation
wavelengths = np.linspace(150, 700, 1000)
# Simulate absorption bands for each transition
def gaussian_band(wavelength, center, width, intensity):
return intensity * np.exp(-(wavelength - center)**2 / (2 * width**2))
spectrum = np.zeros_like(wavelengths)
bands = [
{'center': 180, 'width': 15, 'intensity': 0.8, 'label': r'$\sigma \to \sigma^*$'},
{'center': 220, 'width': 20, 'intensity': 0.5, 'label': r'$n \to \sigma^*$'},
{'center': 350, 'width': 40, 'intensity': 1.0, 'label': r'$\pi \to \pi^*$'},
{'center': 300, 'width': 25, 'intensity': 0.2, 'label': r'$n \to \pi^*$'}
]
colors = ['#e74c3c', '#e67e22', '#2ecc71', '#3498db']
for band, color in zip(bands, colors):
band_spectrum = gaussian_band(wavelengths, band['center'],
band['width'], band['intensity'])
spectrum += band_spectrum
ax2.fill_between(wavelengths, band_spectrum, alpha=0.3, color=color)
ax2.plot(wavelengths, band_spectrum, color=color, linewidth=1.5,
linestyle='--', alpha=0.7)
ax2.plot(wavelengths, spectrum, 'k-', linewidth=2, label='Total Absorption')
# Add UV/Visible regions
ax2.axvspan(150, 200, alpha=0.1, color='purple', label='Vacuum UV')
ax2.axvspan(200, 400, alpha=0.1, color='violet', label='UV')
ax2.axvspan(400, 700, alpha=0.1, color='yellow', label='Visible')
ax2.set_xlabel('Wavelength (nm)', fontsize=12)
ax2.set_ylabel('Absorbance (a.u.)', fontsize=12)
ax2.set_title('Simulated UV-Vis Absorption Spectrum', fontsize=14, fontweight='bold')
ax2.set_xlim(150, 700)
ax2.grid(True, alpha=0.3)
ax2.legend(loc='upper right', fontsize=9)
plt.tight_layout()
plt.savefig('electronic_transitions.png', dpi=300, bbox_inches='tight')
plt.show()
return fig
# Execute
fig = plot_molecular_orbital_diagram()
# Print transition summary
print("=" * 70)
print("Electronic Transition Types Summary")
print("=" * 70)
print(f"{'Transition':<15} {'Energy':<12} {'Wavelength':<15} {'Intensity':<15}")
print("-" * 70)
print(f"{'sigma->sigma*':<15} {'Highest':<12} {'<150 nm':<15} {'Strong':<15}")
print(f"{'n->sigma*':<15} {'High':<12} {'150-250 nm':<15} {'Medium':<15}")
print(f"{'pi->pi*':<15} {'Medium':<12} {'200-500 nm':<15} {'Strong':<15}")
print(f"{'n->pi*':<15} {'Lowest':<12} {'250-400 nm':<15} {'Weak (forbidden)':<15}")
1.2 Selection Rules for Electronic Transitions
Not all electronic transitions are equally probable. Selection rules determine which transitions are "allowed" (high intensity) or "forbidden" (low intensity):
- Spin Selection Rule: $\Delta S = 0$ (transitions between states of the same spin multiplicity are allowed)
- Laporte Selection Rule: In centrosymmetric molecules, only $g \leftrightarrow u$ transitions are allowed (parity must change)
- Orbital Overlap: Good spatial overlap between initial and final orbitals increases transition probability
Note: "Forbidden" transitions may still occur weakly due to vibronic coupling, spin-orbit coupling, or symmetry breaking.
1.3 Chromophores and Auxochromes
A chromophore is a functional group responsible for the absorption of UV-Vis light. An auxochrome is a group that modifies the absorption properties of a chromophore when attached to it.
Code Example 2: Chromophore Database and Absorption Prediction
"""
Chromophore and Auxochrome Effects on UV-Vis Absorption
Demonstrates how molecular structure affects absorption wavelength
"""
import numpy as np
import matplotlib.pyplot as plt
class ChromophoreDatabase:
"""
Database of common chromophores and their absorption properties
"""
def __init__(self):
# Chromophore data: (lambda_max in nm, epsilon in L mol^-1 cm^-1)
self.chromophores = {
'C=C (isolated)': {'lambda_max': 171, 'epsilon': 15000,
'transition': 'pi->pi*'},
'C=C-C=C (conjugated)': {'lambda_max': 217, 'epsilon': 21000,
'transition': 'pi->pi*'},
'C=C-C=C-C=C (triene)': {'lambda_max': 258, 'epsilon': 35000,
'transition': 'pi->pi*'},
'Benzene': {'lambda_max': 255, 'epsilon': 200,
'transition': 'pi->pi*'},
'Naphthalene': {'lambda_max': 314, 'epsilon': 300,
'transition': 'pi->pi*'},
'Anthracene': {'lambda_max': 375, 'epsilon': 8000,
'transition': 'pi->pi*'},
'C=O (aldehyde)': {'lambda_max': 290, 'epsilon': 15,
'transition': 'n->pi*'},
'C=O (ketone)': {'lambda_max': 280, 'epsilon': 20,
'transition': 'n->pi*'},
'NO2': {'lambda_max': 271, 'epsilon': 19,
'transition': 'n->pi*'},
'N=N (azo)': {'lambda_max': 347, 'epsilon': 15,
'transition': 'n->pi*'}
}
# Auxochrome effects (shift in nm when attached to benzene)
self.auxochromes = {
'-OH': {'shift': 15, 'type': 'bathochromic'},
'-OCH3': {'shift': 20, 'type': 'bathochromic'},
'-NH2': {'shift': 45, 'type': 'bathochromic'},
'-N(CH3)2': {'shift': 60, 'type': 'bathochromic'},
'-Cl': {'shift': 6, 'type': 'bathochromic'},
'-Br': {'shift': 10, 'type': 'bathochromic'},
'-NO2': {'shift': 25, 'type': 'bathochromic'},
'-COCH3': {'shift': 15, 'type': 'bathochromic'}
}
def predict_absorption(self, chromophore, auxochrome=None):
"""
Predict absorption wavelength for a chromophore with optional auxochrome
Parameters:
-----------
chromophore : str
Name of the chromophore
auxochrome : str, optional
Name of the auxochrome
Returns:
--------
dict : Predicted absorption properties
"""
if chromophore not in self.chromophores:
raise ValueError(f"Unknown chromophore: {chromophore}")
base = self.chromophores[chromophore]
lambda_max = base['lambda_max']
if auxochrome and auxochrome in self.auxochromes:
shift = self.auxochromes[auxochrome]['shift']
lambda_max += shift
return {
'lambda_max': lambda_max,
'epsilon': base['epsilon'],
'transition': base['transition']
}
def plot_conjugation_effect(self):
"""
Demonstrate the effect of conjugation length on absorption
"""
# Extended conjugation data (polyenes)
n_double_bonds = np.arange(1, 12)
# Empirical formula for polyene absorption
lambda_max = 171 + 30 * (n_double_bonds - 1) + 5 * (n_double_bonds - 1)**0.5
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
# Conjugation length effect
ax1.plot(n_double_bonds, lambda_max, 'o-', linewidth=2,
markersize=10, color='#f093fb')
ax1.fill_between(n_double_bonds, 400, 700, alpha=0.2,
color='yellow', label='Visible region')
ax1.axhline(y=400, color='violet', linestyle='--',
linewidth=1, label='UV/Vis boundary')
ax1.set_xlabel('Number of Conjugated Double Bonds', fontsize=12)
ax1.set_ylabel(r'$\lambda_{max}$ (nm)', fontsize=12)
ax1.set_title('Effect of Conjugation Length on Absorption',
fontsize=14, fontweight='bold')
ax1.grid(True, alpha=0.3)
ax1.legend()
# Color annotation
for n, lam in zip(n_double_bonds[4:8], lambda_max[4:8]):
if 400 <= lam <= 700:
color = wavelength_to_rgb(lam)
ax1.scatter([n], [lam], c=[color], s=200, edgecolors='black',
linewidths=2, zorder=5)
# Chromophore comparison
chromophores_to_plot = ['C=C (isolated)', 'C=C-C=C (conjugated)',
'Benzene', 'Naphthalene', 'Anthracene']
lambdas = [self.chromophores[c]['lambda_max'] for c in chromophores_to_plot]
epsilons = [np.log10(self.chromophores[c]['epsilon']) for c in chromophores_to_plot]
scatter = ax2.scatter(lambdas, epsilons, s=200, c=lambdas,
cmap='viridis', edgecolors='black', linewidths=2)
for chrom, lam, eps in zip(chromophores_to_plot, lambdas, epsilons):
ax2.annotate(chrom.split('(')[0].strip(), (lam, eps),
textcoords="offset points", xytext=(10, 5),
fontsize=9, rotation=15)
ax2.set_xlabel(r'$\lambda_{max}$ (nm)', fontsize=12)
ax2.set_ylabel(r'log($\epsilon$) [L mol$^{-1}$ cm$^{-1}$]', fontsize=12)
ax2.set_title('Chromophore Comparison', fontsize=14, fontweight='bold')
ax2.grid(True, alpha=0.3)
plt.colorbar(scatter, ax=ax2, label=r'$\lambda_{max}$ (nm)')
plt.tight_layout()
plt.savefig('chromophore_effects.png', dpi=300, bbox_inches='tight')
plt.show()
def wavelength_to_rgb(wavelength):
"""
Convert wavelength (nm) to approximate RGB color
Parameters:
-----------
wavelength : float
Wavelength in nanometers (380-780 nm)
Returns:
--------
tuple : RGB values (0-1 range)
"""
if wavelength < 380 or wavelength > 780:
return (0.5, 0.5, 0.5) # Gray for out of range
if wavelength < 440:
r = -(wavelength - 440) / (440 - 380)
g = 0.0
b = 1.0
elif wavelength < 490:
r = 0.0
g = (wavelength - 440) / (490 - 440)
b = 1.0
elif wavelength < 510:
r = 0.0
g = 1.0
b = -(wavelength - 510) / (510 - 490)
elif wavelength < 580:
r = (wavelength - 510) / (580 - 510)
g = 1.0
b = 0.0
elif wavelength < 645:
r = 1.0
g = -(wavelength - 645) / (645 - 580)
b = 0.0
else:
r = 1.0
g = 0.0
b = 0.0
return (r, g, b)
# Create database and demonstrate
db = ChromophoreDatabase()
db.plot_conjugation_effect()
# Print chromophore data
print("\n" + "=" * 70)
print("Common Chromophores and Their Absorption Properties")
print("=" * 70)
print(f"{'Chromophore':<25} {'lambda_max (nm)':<15} {'epsilon':<15} {'Transition':<15}")
print("-" * 70)
for name, data in db.chromophores.items():
print(f"{name:<25} {data['lambda_max']:<15} {data['epsilon']:<15} {data['transition']:<15}")
# Demonstrate auxochrome effect
print("\n" + "=" * 70)
print("Auxochrome Effects on Benzene Absorption")
print("=" * 70)
base_benzene = db.chromophores['Benzene']['lambda_max']
print(f"Benzene (base): {base_benzene} nm")
for aux, data in db.auxochromes.items():
new_lambda = base_benzene + data['shift']
print(f"Benzene + {aux}: {new_lambda} nm (shift: +{data['shift']} nm, {data['type']})")
2. Beer-Lambert Law and Quantitative Analysis
2.1 Fundamentals of Beer-Lambert Law
The Beer-Lambert law (also known as the Beer-Lambert-Bouguer law) describes the relationship between the absorption of light and the properties of the material through which the light is traveling:
$$A = \log_{10}\left(\frac{I_0}{I}\right) = \varepsilon c l$$
where:
- $A$ = Absorbance (dimensionless)
- $I_0$ = Incident light intensity
- $I$ = Transmitted light intensity
- $\varepsilon$ = Molar absorptivity (L mol$^{-1}$ cm$^{-1}$)
- $c$ = Concentration (mol L$^{-1}$)
- $l$ = Path length (cm)
- Transmittance: $T = I/I_0$ (often expressed as %T)
- Absorbance: $A = -\log_{10}(T) = 2 - \log_{10}(\%T)$
- For accurate measurements: 0.1 < A < 1.0 (10% to 80% T)
Code Example 3: Beer-Lambert Law Analysis and Calibration
"""
Beer-Lambert Law: Calibration Curve and Concentration Determination
Demonstrates quantitative analysis using UV-Vis spectroscopy
"""
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
from scipy.optimize import curve_fit
def beer_lambert_analysis():
"""
Perform Beer-Lambert law calibration and unknown concentration determination
"""
# Physical parameters
epsilon = 15000 # L mol^-1 cm^-1 (typical for organic dye)
path_length = 1.0 # cm
# Generate calibration data with realistic noise
np.random.seed(42)
concentrations_uM = np.array([2, 5, 10, 15, 20, 25, 30]) # micromolar
concentrations_M = concentrations_uM * 1e-6 # Convert to M
# True absorbances
true_absorbances = epsilon * concentrations_M * path_length
# Add measurement noise (typical 0.005 AU)
measured_absorbances = true_absorbances + np.random.normal(0, 0.005, len(true_absorbances))
# Linear regression
slope, intercept, r_value, p_value, std_err = stats.linregress(
concentrations_uM, measured_absorbances)
# Calculate epsilon from slope
calculated_epsilon = slope / (path_length * 1e-6)
# Plotting
fig, axes = plt.subplots(2, 2, figsize=(14, 12))
# 1. Calibration curve
ax1 = axes[0, 0]
ax1.scatter(concentrations_uM, measured_absorbances, s=100, color='#f093fb',
edgecolors='black', linewidths=1.5, label='Measured Data', zorder=5)
# Fit line
x_fit = np.linspace(0, 35, 100)
y_fit = slope * x_fit + intercept
ax1.plot(x_fit, y_fit, 'r-', linewidth=2,
label=f'Linear Fit (R$^2$ = {r_value**2:.4f})')
# Confidence interval
y_pred = slope * concentrations_uM + intercept
residuals = measured_absorbances - y_pred
ss_res = np.sum(residuals**2)
ss_tot = np.sum((measured_absorbances - np.mean(measured_absorbances))**2)
n = len(concentrations_uM)
se_y = np.sqrt(ss_res / (n - 2))
ax1.fill_between(x_fit, y_fit - 2*se_y, y_fit + 2*se_y,
alpha=0.2, color='red', label='95% CI')
ax1.set_xlabel('Concentration ($\mu$M)', fontsize=12)
ax1.set_ylabel('Absorbance', fontsize=12)
ax1.set_title('Beer-Lambert Law Calibration Curve', fontsize=14, fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3)
ax1.set_xlim(0, 35)
ax1.set_ylim(0, 0.5)
# Add equation to plot
ax1.text(0.05, 0.95, f'A = {slope:.4f}C + {intercept:.4f}\n'
f'$\epsilon$ = {calculated_epsilon:.0f} L mol$^{{-1}}$ cm$^{{-1}}$',
transform=ax1.transAxes, fontsize=11, verticalalignment='top',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
# 2. Transmittance vs Absorbance
ax2 = axes[0, 1]
transmittance = 10**(-measured_absorbances) * 100
ax2.scatter(measured_absorbances, transmittance, s=100, color='#f5576c',
edgecolors='black', linewidths=1.5)
A_range = np.linspace(0, 2, 100)
T_range = 10**(-A_range) * 100
ax2.plot(A_range, T_range, 'b-', linewidth=2)
# Optimal range
ax2.axvspan(0.1, 1.0, alpha=0.2, color='green', label='Optimal A range (0.1-1.0)')
ax2.set_xlabel('Absorbance', fontsize=12)
ax2.set_ylabel('Transmittance (%)', fontsize=12)
ax2.set_title('Transmittance vs Absorbance', fontsize=14, fontweight='bold')
ax2.legend()
ax2.grid(True, alpha=0.3)
# 3. Concentration determination for unknowns
ax3 = axes[1, 0]
unknown_absorbances = np.array([0.15, 0.28, 0.35])
unknown_concentrations = (unknown_absorbances - intercept) / slope
# Show calibration curve with unknowns
ax3.scatter(concentrations_uM, measured_absorbances, s=100, color='#f093fb',
edgecolors='black', linewidths=1.5, label='Standards', zorder=5)
ax3.plot(x_fit, y_fit, 'b-', linewidth=2, alpha=0.5)
for i, (conc, A) in enumerate(zip(unknown_concentrations, unknown_absorbances)):
ax3.scatter([conc], [A], s=200, color='red', marker='*',
edgecolors='black', linewidths=1.5, zorder=6)
ax3.hlines(A, 0, conc, colors='red', linestyles='--', alpha=0.5)
ax3.vlines(conc, 0, A, colors='red', linestyles='--', alpha=0.5)
ax3.annotate(f'Unknown {i+1}\nC = {conc:.1f} $\mu$M',
(conc, A), textcoords="offset points",
xytext=(10, 10), fontsize=10)
ax3.set_xlabel('Concentration ($\mu$M)', fontsize=12)
ax3.set_ylabel('Absorbance', fontsize=12)
ax3.set_title('Unknown Sample Analysis', fontsize=14, fontweight='bold')
ax3.legend()
ax3.grid(True, alpha=0.3)
ax3.set_xlim(0, 35)
ax3.set_ylim(0, 0.5)
# 4. Residual analysis
ax4 = axes[1, 1]
ax4.scatter(concentrations_uM, residuals * 1000, s=100, color='#4ecdc4',
edgecolors='black', linewidths=1.5)
ax4.axhline(y=0, color='black', linestyle='-', linewidth=1)
ax4.axhline(y=2*se_y*1000, color='red', linestyle='--', alpha=0.5)
ax4.axhline(y=-2*se_y*1000, color='red', linestyle='--', alpha=0.5)
ax4.set_xlabel('Concentration ($\mu$M)', fontsize=12)
ax4.set_ylabel('Residual (mAU)', fontsize=12)
ax4.set_title('Residual Analysis', fontsize=14, fontweight='bold')
ax4.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('beer_lambert_analysis.png', dpi=300, bbox_inches='tight')
plt.show()
return slope, intercept, r_value**2, calculated_epsilon
# Execute analysis
slope, intercept, r_squared, epsilon = beer_lambert_analysis()
# Print results
print("\n" + "=" * 60)
print("Beer-Lambert Law Calibration Results")
print("=" * 60)
print(f"Slope: {slope:.6f} AU/$\mu$M")
print(f"Intercept: {intercept:.6f} AU")
print(f"R-squared: {r_squared:.6f}")
print(f"Calculated molar absorptivity: {epsilon:.0f} L mol^-1 cm^-1")
print("\n" + "=" * 60)
print("Unknown Sample Analysis")
print("=" * 60)
unknown_A = [0.15, 0.28, 0.35]
for i, A in enumerate(unknown_A):
conc = (A - intercept) / slope
print(f"Unknown {i+1}: A = {A:.2f} => C = {conc:.2f} micromolar")
2.2 Deviations from Beer-Lambert Law
The Beer-Lambert law is an idealization that assumes certain conditions. Deviations can occur due to several factors:
- Chemical Deviations: Aggregation, association, dissociation, or chemical reactions at high concentrations
- Instrumental Deviations: Stray light, non-monochromatic source, detector non-linearity
- Concentration Effects: Refractive index changes at high concentrations
- Scattering: Particulate matter or colloidal solutions
Code Example 4: Detecting Beer-Lambert Law Deviations
"""
Detection and Analysis of Beer-Lambert Law Deviations
Shows common causes and how to identify non-linear behavior
"""
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit
def simulate_beer_lambert_deviations():
"""
Simulate various types of deviations from Beer-Lambert law
"""
# True parameters
epsilon = 10000 # L mol^-1 cm^-1
path_length = 1.0 # cm
# Wide concentration range
concentrations = np.linspace(0.1, 100, 100) * 1e-6 # M
# Ideal Beer-Lambert law
A_ideal = epsilon * concentrations * path_length
# 1. Chemical deviation (dimerization at high concentration)
# Monomer-dimer equilibrium: 2M <-> D, K_d = [D]/[M]^2
K_d = 5e4 # M^-1
def calculate_with_dimerization(C_total, K_d, eps_m, eps_d, l):
# Solve quadratic for monomer concentration
# C_total = [M] + 2[D] = [M] + 2*K_d*[M]^2
a = 2 * K_d
b = 1
c = -C_total
C_monomer = (-b + np.sqrt(b**2 - 4*a*c)) / (2*a)
C_dimer = K_d * C_monomer**2
return (eps_m * C_monomer + eps_d * C_dimer) * l
eps_monomer = epsilon
eps_dimer = epsilon * 0.3 # Dimer has lower absorptivity
A_dimer = np.array([calculate_with_dimerization(c, K_d, eps_monomer, eps_dimer, path_length)
for c in concentrations])
# 2. Stray light effect
stray_light_fraction = 0.01 # 1% stray light
T_true = 10**(-A_ideal)
T_measured = (T_true + stray_light_fraction) / (1 + stray_light_fraction)
A_stray = -np.log10(T_measured)
# 3. Aggregation-induced enhancement
# At high concentration, J-aggregates form with enhanced absorption
K_agg = 1e4 # Aggregation constant
enhancement_factor = 1 + 0.5 * concentrations * K_agg
A_aggregate = A_ideal * enhancement_factor
# Plotting
fig, axes = plt.subplots(2, 2, figsize=(14, 12))
# 1. All deviations comparison
ax1 = axes[0, 0]
ax1.plot(concentrations * 1e6, A_ideal, 'k-', linewidth=2, label='Ideal (Beer-Lambert)')
ax1.plot(concentrations * 1e6, A_dimer, '--', linewidth=2,
color='#e74c3c', label='Dimerization (negative)')
ax1.plot(concentrations * 1e6, A_stray, '--', linewidth=2,
color='#3498db', label='Stray light (negative)')
ax1.plot(concentrations * 1e6, A_aggregate, '--', linewidth=2,
color='#2ecc71', label='Aggregation (positive)')
ax1.set_xlabel('Concentration ($\mu$M)', fontsize=12)
ax1.set_ylabel('Absorbance', fontsize=12)
ax1.set_title('Beer-Lambert Law Deviations', fontsize=14, fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3)
# 2. Deviation magnitude
ax2 = axes[0, 1]
ax2.plot(concentrations * 1e6, (A_dimer - A_ideal) / A_ideal * 100,
linewidth=2, color='#e74c3c', label='Dimerization')
ax2.plot(concentrations * 1e6, (A_stray - A_ideal) / A_ideal * 100,
linewidth=2, color='#3498db', label='Stray light')
ax2.plot(concentrations * 1e6, (A_aggregate - A_ideal) / A_ideal * 100,
linewidth=2, color='#2ecc71', label='Aggregation')
ax2.axhline(y=0, color='black', linestyle='-', linewidth=1)
ax2.axhline(y=5, color='gray', linestyle='--', alpha=0.5)
ax2.axhline(y=-5, color='gray', linestyle='--', alpha=0.5)
ax2.set_xlabel('Concentration ($\mu$M)', fontsize=12)
ax2.set_ylabel('Deviation (%)', fontsize=12)
ax2.set_title('Relative Deviation from Ideal', fontsize=14, fontweight='bold')
ax2.legend()
ax2.grid(True, alpha=0.3)
# 3. Practical calibration range determination
ax3 = axes[1, 0]
# Simulate experimental data with noise
np.random.seed(42)
exp_conc = np.array([5, 10, 20, 30, 40, 50, 60, 70, 80, 90]) * 1e-6
exp_A_noise = np.array([calculate_with_dimerization(c, K_d, eps_monomer, eps_dimer, path_length)
for c in exp_conc])
exp_A_noise += np.random.normal(0, 0.01, len(exp_A_noise))
# Linear fit for low concentration range
low_mask = exp_conc < 40e-6
slope_low, intercept_low = np.polyfit(exp_conc[low_mask] * 1e6, exp_A_noise[low_mask], 1)
# Full range fit
slope_full, intercept_full = np.polyfit(exp_conc * 1e6, exp_A_noise, 1)
ax3.scatter(exp_conc * 1e6, exp_A_noise, s=100, color='#f093fb',
edgecolors='black', linewidths=1.5, zorder=5)
x_fit = np.linspace(0, 100, 100)
ax3.plot(x_fit, slope_low * x_fit + intercept_low, 'g-', linewidth=2,
label=f'Linear range fit (R$^2$ high)')
ax3.plot(x_fit, slope_full * x_fit + intercept_full, 'r--', linewidth=2,
label='Full range fit (poor)')
ax3.axvspan(0, 40, alpha=0.2, color='green', label='Linear range')
ax3.axvspan(40, 100, alpha=0.2, color='red', label='Non-linear range')
ax3.set_xlabel('Concentration ($\mu$M)', fontsize=12)
ax3.set_ylabel('Absorbance', fontsize=12)
ax3.set_title('Identifying Linear Calibration Range', fontsize=14, fontweight='bold')
ax3.legend(loc='upper left')
ax3.grid(True, alpha=0.3)
# 4. Second derivative test for linearity
ax4 = axes[1, 1]
# Calculate numerical second derivative
dA_dC = np.gradient(A_dimer, concentrations * 1e6)
d2A_dC2 = np.gradient(dA_dC, concentrations * 1e6)
ax4.plot(concentrations * 1e6, d2A_dC2 * 1000, linewidth=2, color='#9b59b6')
ax4.axhline(y=0, color='black', linestyle='-', linewidth=1)
ax4.fill_between(concentrations * 1e6, d2A_dC2 * 1000,
where=np.abs(d2A_dC2 * 1000) < 0.1,
alpha=0.3, color='green', label='Linear region')
ax4.set_xlabel('Concentration ($\mu$M)', fontsize=12)
ax4.set_ylabel('$d^2A/dC^2$ (arbitrary units)', fontsize=12)
ax4.set_title('Linearity Test: Second Derivative', fontsize=14, fontweight='bold')
ax4.legend()
ax4.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('beer_lambert_deviations.png', dpi=300, bbox_inches='tight')
plt.show()
# Execute
simulate_beer_lambert_deviations()
print("\n" + "=" * 60)
print("Practical Guidelines for Beer-Lambert Law")
print("=" * 60)
print("1. Keep absorbance in range 0.1 - 1.0")
print("2. Use dilution series to check linearity")
print("3. Watch for curvature at high concentrations")
print("4. Consider chemical equilibria in your system")
print("5. Minimize stray light with proper instrument maintenance")
3. UV-Vis Spectrometer Components
3.1 Instrument Design
A UV-Vis spectrometer consists of several key components that work together to measure the absorption of light by a sample:
UV: 190-400 nm] --> A A2[Tungsten Lamp
Vis: 350-900 nm] --> A B1[Diffraction Grating
or Prism] --> B C1[Cuvette
Quartz or Glass] --> C D1[Photomultiplier
or CCD] --> D style A fill:#f093fb,color:#fff style B fill:#f5576c,color:#fff style C fill:#4ecdc4,color:#fff style D fill:#ffe66d,color:#000 style E fill:#a8e6cf,color:#000
- Light Source: Deuterium lamp (UV) + Tungsten-halogen lamp (Visible)
- Monochromator: Diffraction grating to select specific wavelengths
- Sample Holder: Quartz cuvettes (UV-Vis) or glass cuvettes (Vis only)
- Detector: Photomultiplier tube (PMT) or charge-coupled device (CCD)
- Double-Beam Design: Compensates for drift by measuring sample and reference simultaneously
Code Example 5: Spectrometer Simulation and Signal Processing
"""
UV-Vis Spectrometer Simulation
Demonstrates data acquisition, noise, and signal processing
"""
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import savgol_filter
from scipy.ndimage import gaussian_filter1d
def simulate_uvvis_measurement():
"""
Simulate UV-Vis spectral measurement with realistic instrument effects
"""
# Wavelength range
wavelengths = np.linspace(200, 800, 601)
# True absorption spectrum (two Gaussian peaks)
def true_spectrum(wl):
peak1 = 0.8 * np.exp(-(wl - 280)**2 / (2 * 20**2)) # UV peak
peak2 = 0.5 * np.exp(-(wl - 450)**2 / (2 * 40**2)) # Visible peak
return peak1 + peak2
A_true = true_spectrum(wavelengths)
# Simulate instrument effects
np.random.seed(42)
# 1. Add photon shot noise (sqrt(N) dependent)
baseline_signal = 10000 # Photon counts at 100% T
T_true = 10**(-A_true)
signal_counts = T_true * baseline_signal
shot_noise = np.sqrt(signal_counts) / baseline_signal
# 2. Add dark current noise
dark_noise = 0.002 * np.random.randn(len(wavelengths))
# 3. Add baseline drift (slow variation)
baseline_drift = 0.01 * np.sin(2 * np.pi * wavelengths / 300)
# Combine all noise sources
T_measured = T_true + shot_noise * np.random.randn(len(wavelengths)) + dark_noise
A_measured = -np.log10(np.clip(T_measured, 0.001, 1.0)) + baseline_drift
# Apply signal processing
# Savitzky-Golay smoothing
A_smoothed = savgol_filter(A_measured, window_length=11, polyorder=3)
# Baseline correction
A_corrected = A_smoothed - baseline_drift
# Plotting
fig, axes = plt.subplots(2, 2, figsize=(14, 12))
# 1. Raw vs Smoothed spectrum
ax1 = axes[0, 0]
ax1.plot(wavelengths, A_measured, 'gray', linewidth=0.5, alpha=0.7, label='Raw Data')
ax1.plot(wavelengths, A_smoothed, 'b-', linewidth=2, label='Smoothed (SG filter)')
ax1.plot(wavelengths, A_true, 'r--', linewidth=2, label='True Spectrum')
ax1.set_xlabel('Wavelength (nm)', fontsize=12)
ax1.set_ylabel('Absorbance', fontsize=12)
ax1.set_title('Raw vs Processed UV-Vis Spectrum', fontsize=14, fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3)
# 2. Noise analysis
ax2 = axes[0, 1]
residual = A_measured - A_true
ax2.plot(wavelengths, residual, 'purple', linewidth=0.5)
ax2.fill_between(wavelengths, residual, alpha=0.3, color='purple')
ax2.axhline(y=0, color='black', linestyle='-', linewidth=1)
ax2.axhline(y=np.std(residual), color='red', linestyle='--', label=f'1 sigma = {np.std(residual):.4f}')
ax2.axhline(y=-np.std(residual), color='red', linestyle='--')
ax2.set_xlabel('Wavelength (nm)', fontsize=12)
ax2.set_ylabel('Residual (AU)', fontsize=12)
ax2.set_title('Noise Analysis', fontsize=14, fontweight='bold')
ax2.legend()
ax2.grid(True, alpha=0.3)
# 3. Signal-to-Noise Ratio
ax3 = axes[1, 0]
# Calculate SNR across spectrum
window = 20
snr = np.zeros(len(wavelengths) - window)
for i in range(len(snr)):
signal = np.mean(A_true[i:i+window])
noise = np.std(A_measured[i:i+window] - A_true[i:i+window])
snr[i] = signal / noise if noise > 0 else 0
ax3.plot(wavelengths[window//2:-window//2], snr, 'b-', linewidth=2)
ax3.axhline(y=10, color='orange', linestyle='--', label='Acceptable SNR (10)')
ax3.axhline(y=100, color='green', linestyle='--', label='Good SNR (100)')
ax3.set_xlabel('Wavelength (nm)', fontsize=12)
ax3.set_ylabel('Signal-to-Noise Ratio', fontsize=12)
ax3.set_title('SNR Across Spectrum', fontsize=14, fontweight='bold')
ax3.legend()
ax3.grid(True, alpha=0.3)
ax3.set_yscale('log')
# 4. Comparison of smoothing methods
ax4 = axes[1, 1]
# Different smoothing approaches
A_gaussian = gaussian_filter1d(A_measured, sigma=3)
A_sg_5 = savgol_filter(A_measured, window_length=5, polyorder=2)
A_sg_21 = savgol_filter(A_measured, window_length=21, polyorder=3)
# Focus on a peak region
mask = (wavelengths > 250) & (wavelengths < 320)
ax4.plot(wavelengths[mask], A_measured[mask], 'gray', linewidth=0.5,
alpha=0.7, label='Raw')
ax4.plot(wavelengths[mask], A_gaussian[mask], 'g-', linewidth=2,
label='Gaussian (sigma=3)')
ax4.plot(wavelengths[mask], A_sg_5[mask], 'b-', linewidth=2,
label='SG (window=5)')
ax4.plot(wavelengths[mask], A_sg_21[mask], 'r-', linewidth=2,
label='SG (window=21)')
ax4.plot(wavelengths[mask], A_true[mask], 'k--', linewidth=2, label='True')
ax4.set_xlabel('Wavelength (nm)', fontsize=12)
ax4.set_ylabel('Absorbance', fontsize=12)
ax4.set_title('Comparison of Smoothing Methods', fontsize=14, fontweight='bold')
ax4.legend()
ax4.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('uvvis_instrument_simulation.png', dpi=300, bbox_inches='tight')
plt.show()
return wavelengths, A_true, A_measured, A_smoothed
# Execute simulation
wavelengths, A_true, A_measured, A_smoothed = simulate_uvvis_measurement()
# Print instrument parameters
print("\n" + "=" * 60)
print("UV-Vis Spectrometer Specifications (Typical)")
print("=" * 60)
print("Wavelength range: 190 - 1100 nm")
print("Spectral bandwidth: 0.5 - 4 nm")
print("Photometric range: -3 to 4 AU")
print("Photometric accuracy: +/- 0.002 AU at 1 AU")
print("Stray light: < 0.01% at 220 nm")
print("Scan speed: 50 - 4000 nm/min")
4. Band Gap Determination for Semiconductors
4.1 Optical Band Gap and Tauc Analysis
For semiconductors, UV-Vis spectroscopy provides a powerful method to determine the optical band gap ($E_g$). The Tauc relation connects the absorption coefficient to the photon energy:
$$(\alpha h\nu)^n = A(h\nu - E_g)$$
where:
- $\alpha$ = Absorption coefficient (cm$^{-1}$)
- $h\nu$ = Photon energy (eV)
- $E_g$ = Band gap energy (eV)
- $A$ = Proportionality constant
- $n$ = 2 for direct allowed transitions, 1/2 for indirect allowed transitions
- Direct band gap ($n = 2$): Electrons transition directly from valence to conduction band (e.g., GaAs, CdS, ZnO)
- Indirect band gap ($n = 1/2$): Transition requires phonon assistance (e.g., Si, Ge)
- For thin films: $\alpha = 2.303 \times A / d$ where $A$ is absorbance and $d$ is film thickness
Code Example 6: Tauc Plot Analysis for Band Gap Determination
"""
Tauc Plot Analysis for Semiconductor Band Gap Determination
Demonstrates extraction of optical band gap from UV-Vis spectra
"""
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit
from scipy.stats import linregress
class TaucPlotAnalyzer:
"""
Analyzer for determining optical band gap from UV-Vis spectra
using the Tauc plot method.
"""
def __init__(self, wavelength_nm, absorbance, film_thickness_nm=None):
"""
Initialize with spectral data.
Parameters:
-----------
wavelength_nm : array
Wavelength in nanometers
absorbance : array
Absorbance (AU)
film_thickness_nm : float, optional
Film thickness for calculating absorption coefficient
"""
self.wavelength = wavelength_nm
self.absorbance = absorbance
self.thickness = film_thickness_nm
# Convert to photon energy
self.energy_eV = 1239.8 / wavelength_nm # hc/lambda in eV
# Calculate absorption coefficient if thickness is known
if film_thickness_nm is not None:
self.alpha = 2.303 * absorbance / (film_thickness_nm * 1e-7) # cm^-1
else:
# Use absorbance directly (proportional to alpha)
self.alpha = absorbance
def calculate_tauc(self, transition_type='direct'):
"""
Calculate Tauc plot values.
Parameters:
-----------
transition_type : str
'direct' (n=2) or 'indirect' (n=1/2)
Returns:
--------
tauc_y : array
(alpha * hv)^n values
"""
if transition_type == 'direct':
n = 2
elif transition_type == 'indirect':
n = 0.5
else:
raise ValueError("transition_type must be 'direct' or 'indirect'")
tauc_y = (self.alpha * self.energy_eV) ** n
return tauc_y, n
def fit_bandgap(self, transition_type='direct', fit_range=None):
"""
Determine band gap by linear extrapolation.
Parameters:
-----------
transition_type : str
'direct' or 'indirect'
fit_range : tuple, optional
(E_min, E_max) energy range for linear fit
Returns:
--------
E_g : float
Optical band gap in eV
fit_params : dict
Fitting parameters and statistics
"""
tauc_y, n = self.calculate_tauc(transition_type)
# Find linear region if not specified
if fit_range is None:
# Find region with steepest slope
gradient = np.gradient(tauc_y, self.energy_eV)
max_slope_idx = np.argmax(gradient)
# Define fit range around maximum slope
width = int(len(self.energy_eV) * 0.15)
start_idx = max(0, max_slope_idx - width)
end_idx = min(len(self.energy_eV), max_slope_idx + width)
fit_mask = np.zeros(len(self.energy_eV), dtype=bool)
fit_mask[start_idx:end_idx] = True
else:
fit_mask = (self.energy_eV >= fit_range[0]) & (self.energy_eV <= fit_range[1])
# Linear regression
slope, intercept, r_value, p_value, std_err = linregress(
self.energy_eV[fit_mask], tauc_y[fit_mask])
# Band gap is x-intercept
E_g = -intercept / slope if slope != 0 else 0
fit_params = {
'slope': slope,
'intercept': intercept,
'r_squared': r_value**2,
'std_err': std_err,
'fit_mask': fit_mask,
'n': n
}
return E_g, fit_params
def plot_analysis(self, transition_type='direct', fit_range=None):
"""
Create comprehensive Tauc plot analysis figure.
"""
E_g, fit_params = self.fit_bandgap(transition_type, fit_range)
tauc_y, n = self.calculate_tauc(transition_type)
fig, axes = plt.subplots(2, 2, figsize=(14, 12))
# 1. Absorbance spectrum
ax1 = axes[0, 0]
ax1.plot(self.wavelength, self.absorbance, 'b-', linewidth=2)
ax1.set_xlabel('Wavelength (nm)', fontsize=12)
ax1.set_ylabel('Absorbance', fontsize=12)
ax1.set_title('UV-Vis Absorption Spectrum', fontsize=14, fontweight='bold')
ax1.grid(True, alpha=0.3)
# Mark absorption edge
edge_idx = np.argmax(np.gradient(self.absorbance))
ax1.axvline(x=self.wavelength[edge_idx], color='red', linestyle='--',
alpha=0.5, label=f'Edge ~ {self.wavelength[edge_idx]:.0f} nm')
ax1.legend()
# 2. Absorbance vs Energy
ax2 = axes[0, 1]
ax2.plot(self.energy_eV, self.absorbance, 'b-', linewidth=2)
ax2.set_xlabel('Photon Energy (eV)', fontsize=12)
ax2.set_ylabel('Absorbance', fontsize=12)
ax2.set_title('Absorbance vs Photon Energy', fontsize=14, fontweight='bold')
ax2.grid(True, alpha=0.3)
ax2.axvline(x=E_g, color='red', linestyle='--',
label=f'$E_g$ = {E_g:.2f} eV')
ax2.legend()
# 3. Tauc plot with fit
ax3 = axes[1, 0]
ax3.plot(self.energy_eV, tauc_y, 'b-', linewidth=2, label='Data')
# Plot linear fit
fit_mask = fit_params['fit_mask']
x_fit = np.linspace(E_g - 0.5, self.energy_eV[fit_mask].max() + 0.3, 100)
y_fit = fit_params['slope'] * x_fit + fit_params['intercept']
y_fit = np.maximum(y_fit, 0) # No negative values
ax3.plot(x_fit, y_fit, 'r--', linewidth=2,
label=f'Linear fit (R$^2$ = {fit_params["r_squared"]:.4f})')
ax3.scatter(self.energy_eV[fit_mask], tauc_y[fit_mask],
c='red', s=20, alpha=0.5, label='Fit region')
# Mark band gap
ax3.axvline(x=E_g, color='green', linestyle='-', linewidth=2)
ax3.scatter([E_g], [0], s=200, c='green', marker='v', zorder=5)
ax3.annotate(f'$E_g$ = {E_g:.3f} eV', (E_g, tauc_y.max() * 0.1),
fontsize=12, fontweight='bold')
exponent = '2' if n == 2 else '1/2'
ax3.set_xlabel('Photon Energy (eV)', fontsize=12)
ax3.set_ylabel(f'$(\\alpha h\\nu)^{{{exponent}}}$', fontsize=12)
ax3.set_title(f'Tauc Plot ({transition_type.capitalize()} Transition)',
fontsize=14, fontweight='bold')
ax3.legend()
ax3.grid(True, alpha=0.3)
ax3.set_xlim(E_g - 0.5, self.energy_eV.max())
ax3.set_ylim(0, tauc_y.max() * 1.1)
# 4. Derivative analysis
ax4 = axes[1, 1]
# First derivative of absorbance
dA_dE = np.gradient(self.absorbance, self.energy_eV)
ax4.plot(self.energy_eV, dA_dE / dA_dE.max(), 'b-', linewidth=2,
label='dA/dE (normalized)')
# Second derivative
d2A_dE2 = np.gradient(dA_dE, self.energy_eV)
ax4.plot(self.energy_eV, d2A_dE2 / np.abs(d2A_dE2).max(), 'g-',
linewidth=2, label='d$^2$A/dE$^2$ (normalized)')
ax4.axvline(x=E_g, color='red', linestyle='--',
label=f'Tauc $E_g$ = {E_g:.2f} eV')
ax4.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax4.set_xlabel('Photon Energy (eV)', fontsize=12)
ax4.set_ylabel('Derivative (normalized)', fontsize=12)
ax4.set_title('Derivative Analysis', fontsize=14, fontweight='bold')
ax4.legend()
ax4.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('tauc_plot_analysis.png', dpi=300, bbox_inches='tight')
plt.show()
return E_g, fit_params
# Generate synthetic semiconductor spectrum (e.g., ZnO)
def generate_semiconductor_spectrum(E_g=3.3, urbach_energy=0.05):
"""
Generate synthetic UV-Vis spectrum for a semiconductor.
Parameters:
-----------
E_g : float
Band gap energy (eV)
urbach_energy : float
Urbach tail parameter (eV)
"""
wavelengths = np.linspace(250, 800, 551)
energies = 1239.8 / wavelengths
# Absorption above band gap
alpha = np.zeros_like(energies)
above_gap = energies > E_g
alpha[above_gap] = 5e4 * np.sqrt(energies[above_gap] - E_g)
# Urbach tail below band gap
below_gap = energies <= E_g
alpha[below_gap] = 5e4 * np.sqrt(0.01) * np.exp((energies[below_gap] - E_g) / urbach_energy)
# Convert to absorbance (assuming 100 nm film)
thickness = 100e-7 # cm
absorbance = alpha * thickness / 2.303
# Add noise
absorbance += 0.01 * np.random.randn(len(absorbance))
absorbance = np.maximum(absorbance, 0)
return wavelengths, absorbance
# Create and analyze
wavelengths, absorbance = generate_semiconductor_spectrum(E_g=3.3)
analyzer = TaucPlotAnalyzer(wavelengths, absorbance, film_thickness_nm=100)
E_g, params = analyzer.plot_analysis(transition_type='direct')
print("\n" + "=" * 60)
print("Tauc Plot Analysis Results")
print("=" * 60)
print(f"Optical Band Gap: {E_g:.3f} eV")
print(f"Corresponding wavelength: {1239.8/E_g:.1f} nm")
print(f"R-squared of fit: {params['r_squared']:.4f}")
print(f"Transition type: Direct (n = 2)")
4.2 Urbach Tail and Disorder Analysis
Below the band gap, absorption follows an exponential decay known as the Urbach tail, which provides information about disorder and defect states:
$$\alpha = \alpha_0 \exp\left(\frac{h\nu - E_0}{E_U}\right)$$
where $E_U$ is the Urbach energy, indicating the degree of structural disorder.
Code Example 7: Complete Spectral Analysis Workflow
"""
Complete UV-Vis Spectral Analysis Workflow
Comprehensive analysis including baseline correction, peak fitting,
band gap determination, and quality assessment
"""
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit
from scipy.signal import savgol_filter, find_peaks
from scipy.stats import linregress
class UVVisAnalyzer:
"""
Comprehensive UV-Vis spectral analysis class
"""
def __init__(self, wavelength, absorbance):
"""
Initialize analyzer with spectral data.
"""
self.wavelength = np.array(wavelength)
self.absorbance = np.array(absorbance)
self.energy = 1239.8 / self.wavelength
self.processed_absorbance = None
self.baseline = None
self.peaks = None
def preprocess(self, smooth_window=11, baseline_degree=2):
"""
Preprocess spectrum: smoothing and baseline correction.
"""
# Savitzky-Golay smoothing
smoothed = savgol_filter(self.absorbance, smooth_window, polyorder=3)
# Polynomial baseline fitting (using endpoints)
n_points = int(len(self.wavelength) * 0.1)
baseline_x = np.concatenate([self.wavelength[:n_points],
self.wavelength[-n_points:]])
baseline_y = np.concatenate([smoothed[:n_points], smoothed[-n_points:]])
coeffs = np.polyfit(baseline_x, baseline_y, baseline_degree)
self.baseline = np.polyval(coeffs, self.wavelength)
self.processed_absorbance = smoothed - self.baseline
self.processed_absorbance = np.maximum(self.processed_absorbance, 0)
return self.processed_absorbance
def find_peaks_wavelength(self, prominence=0.1):
"""
Find absorption peaks in the spectrum.
"""
if self.processed_absorbance is None:
self.preprocess()
peaks, properties = find_peaks(self.processed_absorbance,
prominence=prominence * np.max(self.processed_absorbance))
self.peaks = {
'indices': peaks,
'wavelengths': self.wavelength[peaks],
'energies': self.energy[peaks],
'absorbances': self.processed_absorbance[peaks],
'prominences': properties['prominences']
}
return self.peaks
def fit_gaussian_peaks(self, n_peaks=None):
"""
Fit Gaussian functions to identified peaks.
"""
if self.peaks is None:
self.find_peaks_wavelength()
if n_peaks is None:
n_peaks = len(self.peaks['indices'])
def multi_gaussian(x, *params):
result = np.zeros_like(x)
for i in range(0, len(params), 3):
amp, cen, wid = params[i], params[i+1], params[i+2]
result += amp * np.exp(-(x - cen)**2 / (2 * wid**2))
return result
# Initial guesses
p0 = []
for i in range(min(n_peaks, len(self.peaks['indices']))):
idx = self.peaks['indices'][i]
p0.extend([self.processed_absorbance[idx],
self.wavelength[idx],
20]) # Initial width guess
try:
popt, pcov = curve_fit(multi_gaussian, self.wavelength,
self.processed_absorbance, p0=p0, maxfev=10000)
fitted_peaks = []
for i in range(0, len(popt), 3):
fitted_peaks.append({
'amplitude': popt[i],
'center': popt[i+1],
'width': popt[i+2],
'fwhm': 2.355 * abs(popt[i+2]),
'area': popt[i] * abs(popt[i+2]) * np.sqrt(2 * np.pi)
})
return fitted_peaks, popt, multi_gaussian
except RuntimeError:
print("Peak fitting failed to converge")
return None, None, None
def determine_bandgap(self, transition_type='direct'):
"""
Determine optical band gap using Tauc method.
"""
if self.processed_absorbance is None:
self.preprocess()
n = 2 if transition_type == 'direct' else 0.5
tauc_y = (self.processed_absorbance * self.energy) ** n
# Find linear region
gradient = np.gradient(tauc_y, self.energy)
max_slope_idx = np.argmax(gradient[len(gradient)//4:]) + len(gradient)//4
width = int(len(self.energy) * 0.1)
start_idx = max(0, max_slope_idx - width)
end_idx = min(len(self.energy), max_slope_idx + width)
slope, intercept, r_value, _, _ = linregress(
self.energy[start_idx:end_idx], tauc_y[start_idx:end_idx])
E_g = -intercept / slope if slope > 0 else 0
return E_g, r_value**2, tauc_y
def full_analysis(self):
"""
Perform complete spectral analysis and generate report.
"""
fig, axes = plt.subplots(2, 3, figsize=(18, 12))
# 1. Raw spectrum
ax1 = axes[0, 0]
ax1.plot(self.wavelength, self.absorbance, 'b-', linewidth=1)
ax1.set_xlabel('Wavelength (nm)', fontsize=11)
ax1.set_ylabel('Absorbance', fontsize=11)
ax1.set_title('Raw Spectrum', fontsize=12, fontweight='bold')
ax1.grid(True, alpha=0.3)
# 2. Processed spectrum with baseline
ax2 = axes[0, 1]
self.preprocess()
ax2.plot(self.wavelength, self.absorbance, 'gray', linewidth=1,
alpha=0.5, label='Raw')
ax2.plot(self.wavelength, self.baseline, 'r--', linewidth=1.5,
label='Baseline')
ax2.plot(self.wavelength, self.processed_absorbance, 'b-', linewidth=2,
label='Corrected')
ax2.set_xlabel('Wavelength (nm)', fontsize=11)
ax2.set_ylabel('Absorbance', fontsize=11)
ax2.set_title('Baseline Correction', fontsize=12, fontweight='bold')
ax2.legend()
ax2.grid(True, alpha=0.3)
# 3. Peak identification
ax3 = axes[0, 2]
peaks = self.find_peaks_wavelength()
ax3.plot(self.wavelength, self.processed_absorbance, 'b-', linewidth=2)
ax3.scatter(peaks['wavelengths'], peaks['absorbances'],
c='red', s=100, zorder=5, marker='v')
for i, (wl, a) in enumerate(zip(peaks['wavelengths'], peaks['absorbances'])):
ax3.annotate(f'{wl:.0f} nm', (wl, a), textcoords="offset points",
xytext=(0, 10), ha='center', fontsize=9)
ax3.set_xlabel('Wavelength (nm)', fontsize=11)
ax3.set_ylabel('Absorbance', fontsize=11)
ax3.set_title('Peak Identification', fontsize=12, fontweight='bold')
ax3.grid(True, alpha=0.3)
# 4. Gaussian peak fitting
ax4 = axes[1, 0]
fitted_peaks, popt, gauss_func = self.fit_gaussian_peaks(n_peaks=3)
if fitted_peaks is not None:
ax4.plot(self.wavelength, self.processed_absorbance, 'k.',
markersize=2, alpha=0.5, label='Data')
ax4.plot(self.wavelength, gauss_func(self.wavelength, *popt),
'r-', linewidth=2, label='Total Fit')
colors = ['#3498db', '#2ecc71', '#9b59b6']
for i, (peak, color) in enumerate(zip(fitted_peaks, colors)):
single_peak = peak['amplitude'] * np.exp(
-(self.wavelength - peak['center'])**2 / (2 * peak['width']**2))
ax4.fill_between(self.wavelength, single_peak, alpha=0.3,
color=color, label=f'Peak {i+1}')
ax4.set_xlabel('Wavelength (nm)', fontsize=11)
ax4.set_ylabel('Absorbance', fontsize=11)
ax4.set_title('Gaussian Peak Fitting', fontsize=12, fontweight='bold')
ax4.legend(loc='upper right', fontsize=8)
ax4.grid(True, alpha=0.3)
# 5. Tauc plot
ax5 = axes[1, 1]
E_g, r_sq, tauc_y = self.determine_bandgap('direct')
ax5.plot(self.energy, tauc_y, 'b-', linewidth=2)
ax5.axvline(x=E_g, color='red', linestyle='--', linewidth=2)
ax5.scatter([E_g], [0], s=200, c='red', marker='v', zorder=5)
ax5.annotate(f'$E_g$ = {E_g:.3f} eV\n({1239.8/E_g:.0f} nm)',
(E_g, tauc_y.max() * 0.3), fontsize=11, fontweight='bold')
ax5.set_xlabel('Photon Energy (eV)', fontsize=11)
ax5.set_ylabel('$(\\alpha h\\nu)^2$', fontsize=11)
ax5.set_title('Tauc Plot (Direct Transition)', fontsize=12, fontweight='bold')
ax5.grid(True, alpha=0.3)
ax5.set_xlim(E_g - 1, self.energy.max())
ax5.set_ylim(0, tauc_y.max() * 1.1)
# 6. Analysis summary
ax6 = axes[1, 2]
ax6.axis('off')
summary_text = "Analysis Summary\n" + "=" * 40 + "\n\n"
if fitted_peaks:
summary_text += "Identified Peaks:\n"
for i, peak in enumerate(fitted_peaks):
summary_text += f" Peak {i+1}: {peak['center']:.1f} nm\n"
summary_text += f" FWHM: {peak['fwhm']:.1f} nm\n"
summary_text += f" Area: {peak['area']:.4f}\n\n"
summary_text += f"Band Gap Analysis:\n"
summary_text += f" $E_g$ = {E_g:.3f} eV ({1239.8/E_g:.0f} nm)\n"
summary_text += f" Fit quality: R$^2$ = {r_sq:.4f}\n\n"
summary_text += f"Data Quality:\n"
summary_text += f" Wavelength range: {self.wavelength.min():.0f}-{self.wavelength.max():.0f} nm\n"
summary_text += f" Max absorbance: {self.absorbance.max():.3f}\n"
summary_text += f" Points: {len(self.wavelength)}"
ax6.text(0.1, 0.9, summary_text, transform=ax6.transAxes,
fontsize=11, verticalalignment='top', fontfamily='monospace',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
ax6.set_title('Analysis Report', fontsize=12, fontweight='bold')
plt.tight_layout()
plt.savefig('uvvis_full_analysis.png', dpi=300, bbox_inches='tight')
plt.show()
return {
'peaks': fitted_peaks,
'bandgap': E_g,
'bandgap_wavelength': 1239.8 / E_g,
'fit_quality': r_sq
}
# Generate complex test spectrum
def generate_complex_spectrum():
"""Generate UV-Vis spectrum with multiple features."""
wavelength = np.linspace(200, 800, 601)
# Multiple absorption peaks
peaks = [
{'center': 280, 'width': 25, 'amplitude': 0.8},
{'center': 350, 'width': 35, 'amplitude': 0.5},
{'center': 520, 'width': 50, 'amplitude': 0.3}
]
absorbance = np.zeros_like(wavelength)
for peak in peaks:
absorbance += peak['amplitude'] * np.exp(
-(wavelength - peak['center'])**2 / (2 * peak['width']**2))
# Add baseline drift
absorbance += 0.02 + 0.0001 * (800 - wavelength)
# Add noise
absorbance += 0.01 * np.random.randn(len(wavelength))
return wavelength, absorbance
# Execute full analysis
wavelength, absorbance = generate_complex_spectrum()
analyzer = UVVisAnalyzer(wavelength, absorbance)
results = analyzer.full_analysis()
print("\n" + "=" * 60)
print("Complete UV-Vis Analysis Results")
print("=" * 60)
if results['peaks']:
print("\nPeak Analysis:")
for i, peak in enumerate(results['peaks']):
print(f" Peak {i+1}: {peak['center']:.1f} nm (FWHM: {peak['fwhm']:.1f} nm)")
print(f"\nBand Gap: {results['bandgap']:.3f} eV ({results['bandgap_wavelength']:.0f} nm)")
print(f"Fit Quality: R^2 = {results['fit_quality']:.4f}")
5. Practical Applications
5.1 Application Areas
| Application | Method | Information Obtained |
|---|---|---|
| Concentration Analysis | Beer-Lambert Law | Quantitative composition |
| Semiconductor Characterization | Tauc Plot | Band gap, transition type |
| Reaction Kinetics | Time-resolved absorbance | Rate constants, mechanisms |
| Nanoparticle Analysis | Plasmon resonance | Size, shape, concentration |
| Protein Analysis | 280 nm absorbance | Concentration, purity |
| Thin Film Characterization | Transmission/Reflection | Thickness, optical constants |
Exercises
Practice Problems (Click to Expand)
Basic Level
Problem 1: A solution has a transmittance of 35% at 450 nm. Calculate the absorbance.
View Solution
T = 0.35 # 35%
A = -np.log10(T)
print(f"Absorbance: {A:.3f}")
# Output: Absorbance: 0.456
Problem 2: If a compound has a molar absorptivity of 25,000 L mol$^{-1}$ cm$^{-1}$ at its absorption maximum and an absorbance of 0.8 is measured in a 1 cm cell, what is the concentration?
View Solution
epsilon = 25000 # L mol^-1 cm^-1
A = 0.8
l = 1 # cm
c = A / (epsilon * l)
print(f"Concentration: {c:.2e} M = {c*1e6:.1f} micromolar")
# Output: Concentration: 3.20e-05 M = 32.0 micromolar
Problem 3: Explain why $n \to \pi^*$ transitions typically have lower molar absorptivity than $\pi \to \pi^*$ transitions.
View Solution
$n \to \pi^*$ transitions have lower molar absorptivity because:
- The spatial overlap between non-bonding orbitals (localized on heteroatoms) and $\pi^*$ orbitals (delocalized over the conjugated system) is poor
- These transitions are often symmetry-forbidden in molecules with certain point groups
- The transition dipole moment is smaller due to less charge redistribution
Intermediate Level
Problem 4: A calibration curve was prepared with the following data. Determine the concentration of an unknown sample with A = 0.45.
| Concentration (micromolar) | 5 | 10 | 20 | 30 | 40 |
|---|---|---|---|---|---|
| Absorbance | 0.12 | 0.24 | 0.47 | 0.71 | 0.95 |
View Solution
import numpy as np
from scipy.stats import linregress
conc = np.array([5, 10, 20, 30, 40])
A = np.array([0.12, 0.24, 0.47, 0.71, 0.95])
slope, intercept, r, p, se = linregress(conc, A)
print(f"Calibration: A = {slope:.4f} * C + {intercept:.4f}")
print(f"R-squared: {r**2:.4f}")
unknown_A = 0.45
unknown_C = (unknown_A - intercept) / slope
print(f"Unknown concentration: {unknown_C:.1f} micromolar")
# Output: ~19.1 micromolar
Problem 5: A semiconductor thin film (thickness 200 nm) shows an absorption edge at approximately 380 nm. Estimate the band gap energy and determine whether this is likely a direct or indirect band gap material based on the absorption characteristics.
View Solution
wavelength_edge = 380 # nm
E_g = 1239.8 / wavelength_edge
print(f"Estimated band gap: {E_g:.2f} eV")
# Output: ~3.26 eV
# This is consistent with ZnO or similar wide band gap semiconductors
# ZnO has a direct band gap, so Tauc plot with n=2 should be used
# The sharp absorption edge is characteristic of direct transitions
Problem 6: Explain how the addition of an electron-donating group (-NH2) to benzene affects its UV absorption spectrum compared to plain benzene.
View Solution
Adding -NH2 (aniline formation) causes:
- Bathochromic shift: The absorption maximum shifts to longer wavelengths (red shift) due to extended conjugation between the lone pair on nitrogen and the aromatic ring
- Increased intensity: The molar absorptivity increases due to enhanced transition dipole moment
- Benzene: lambda_max ~255 nm, epsilon ~200
- Aniline: lambda_max ~280 nm (B-band), epsilon ~1400
Advanced Level
Problem 7: Write Python code to determine both direct and indirect band gaps from a given absorption spectrum and compare the quality of the fits.
View Solution
import numpy as np
from scipy.stats import linregress
import matplotlib.pyplot as plt
def analyze_both_transitions(wavelength, absorbance):
energy = 1239.8 / wavelength
results = {}
for trans_type, n in [('direct', 2), ('indirect', 0.5)]:
tauc_y = (absorbance * energy) ** n
# Find linear region
gradient = np.gradient(tauc_y, energy)
max_idx = np.argmax(gradient[len(gradient)//4:]) + len(gradient)//4
width = int(len(energy) * 0.1)
start = max(0, max_idx - width)
end = min(len(energy), max_idx + width)
slope, intercept, r, _, _ = linregress(energy[start:end], tauc_y[start:end])
E_g = -intercept / slope if slope > 0 else 0
results[trans_type] = {'E_g': E_g, 'r_squared': r**2}
print("Direct transition: E_g = {:.3f} eV (R^2 = {:.4f})".format(
results['direct']['E_g'], results['direct']['r_squared']))
print("Indirect transition: E_g = {:.3f} eV (R^2 = {:.4f})".format(
results['indirect']['E_g'], results['indirect']['r_squared']))
# Better fit indicates the correct transition type
if results['direct']['r_squared'] > results['indirect']['r_squared']:
print("-> Direct transition likely (better linear fit)")
else:
print("-> Indirect transition likely (better linear fit)")
return results
Problem 8: A compound shows solvatochromism, with its absorption maximum shifting from 450 nm in hexane to 520 nm in water. What does this indicate about the nature of the electronic transition?
View Solution
The positive solvatochromism (red shift with increasing solvent polarity) indicates:
- The excited state is more polar than the ground state
- This is characteristic of $\pi \to \pi^*$ transitions where charge transfer occurs
- Polar solvents stabilize the excited state more than the ground state, lowering the transition energy
- The shift of 70 nm (0.37 eV) is significant and suggests substantial charge redistribution in the excited state
Problem 9: Design an experiment to determine the pKa of an indicator dye using UV-Vis spectroscopy.
View Solution
Experimental Design:
- Prepare buffer solutions spanning the expected pKa range (typically pKa +/- 2)
- Add equal amounts of indicator to each buffer
- Record UV-Vis spectra of all solutions
- Identify wavelengths where protonated and deprotonated forms absorb maximally
- Calculate the ratio of absorbances at these wavelengths
- Plot log([A-]/[HA]) vs pH
- pKa = pH at the point where the ratio equals 1 (Henderson-Hasselbalch equation)
Problem 10: Implement a complete UV-Vis data processing pipeline that includes: baseline correction, smoothing, peak detection, and quantitative analysis with error estimation.
View Solution
See Code Example 7 for a complete implementation. Key components include:
- Savitzky-Golay filtering for smoothing
- Polynomial baseline fitting with endpoint selection
- Peak detection using scipy.signal.find_peaks
- Gaussian peak fitting with error estimation from covariance matrix
- Bootstrap resampling for confidence intervals
Learning Objectives Review
After completing this chapter, verify your understanding of the following concepts:
Fundamental Understanding
- Can explain the four types of electronic transitions and their energy ordering
- Understand the physical meaning of chromophores and auxochromes
- Can apply the Beer-Lambert law for quantitative analysis
- Understand selection rules for electronic transitions
Practical Skills
- Can create calibration curves and determine unknown concentrations
- Can identify and correct for deviations from Beer-Lambert law
- Can construct and interpret Tauc plots for band gap determination
- Can perform baseline correction and peak fitting on spectra
Applied Capabilities
- Can distinguish between direct and indirect band gap semiconductors
- Can predict how structural changes affect UV-Vis absorption
- Can design experiments for concentration determination and materials characterization
References
- Skoog, D. A., Holler, F. J., Crouch, S. R. (2017). Principles of Instrumental Analysis (7th ed.). Cengage Learning, pp. 301-350 (UV-Vis spectroscopy fundamentals).
- Tauc, J. (1966). Optical properties and electronic structure of amorphous Ge and Si. Materials Research Bulletin, 3(1), 37-46. DOI: 10.1016/0025-5408(68)90023-8
- Perkampus, H.-H. (1992). UV-VIS Spectroscopy and Its Applications. Springer-Verlag, pp. 15-45 (electronic transitions), pp. 78-95 (chromophores).
- Owen, T. (1996). Fundamentals of Modern UV-visible Spectroscopy. Agilent Technologies, pp. 25-40 (instrumentation), pp. 55-70 (quantitative analysis).
- Murphy, A. B. (2007). Band-gap determination from diffuse reflectance measurements of semiconductor films. Solar Energy Materials and Solar Cells, 91(14), 1326-1337. DOI: 10.1016/j.solmat.2007.05.005
- Makula, P., Pacia, M., Macyk, W. (2018). How to correctly determine the band gap energy of modified semiconductor photocatalysts. The Journal of Physical Chemistry Letters, 9(23), 6814-6817. DOI: 10.1021/acs.jpclett.8b02892
Next Steps
In Chapter 2, we covered UV-Vis spectroscopy fundamentals including electronic transitions, the Beer-Lambert law, chromophores and auxochromes, instrumentation, and band gap determination using Tauc plots. We also implemented comprehensive Python tools for spectral analysis.
Chapter 3 will cover infrared (IR) spectroscopy and Fourier transform infrared (FTIR) spectroscopy. We will explore molecular vibrations, functional group identification, the fingerprint region, and applications in polymer characterization and surface analysis.
Disclaimer
- This content is provided solely for educational, research, and informational purposes and does not constitute professional advice (legal, accounting, technical warranty, etc.).
- This content and accompanying code examples are provided "AS IS" without any warranty, express or implied, including but not limited to merchantability, fitness for a particular purpose, non-infringement, accuracy, completeness, operation, or safety.
- The author and Tohoku University assume no responsibility for the content, availability, or safety of external links, third-party data, tools, libraries, etc.
- To the maximum extent permitted by applicable law, the author and Tohoku University shall not be liable for any direct, indirect, incidental, special, consequential, or punitive damages arising from the use, execution, or interpretation of this content.
- The content may be changed, updated, or discontinued without notice.
- The copyright and license of this content are subject to the stated conditions (e.g., CC BY 4.0). Such licenses typically include no-warranty clauses.