EN | JP | Last sync: 2025-12-26

Chapter 2: UV-Vis Spectroscopy

Electronic Transitions, Beer-Lambert Law, and Band Gap Determination

Series: Introduction to Spectroscopy Study Time: 50-60 minutes Level: Intermediate

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.

Key Applications of UV-Vis Spectroscopy

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:

flowchart TD A[Electronic Transitions] --> B[Bonding to Antibonding] A --> C[Non-bonding to Antibonding] B --> B1["sigma to sigma* (High Energy)
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):

Selection Rules for UV-Vis Transitions

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:

Key Relationships

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:

Sources of Deviation from Beer-Lambert Law

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:

flowchart LR A[Light Source] --> B[Monochromator] B --> C[Sample Cell] C --> D[Detector] D --> E[Data Processing] A1[Deuterium Lamp
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
Key Components

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:

Types of Optical Transitions

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)510203040
Absorbance0.120.240.470.710.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:

  1. Prepare buffer solutions spanning the expected pKa range (typically pKa +/- 2)
  2. Add equal amounts of indicator to each buffer
  3. Record UV-Vis spectra of all solutions
  4. Identify wavelengths where protonated and deprotonated forms absorb maximally
  5. Calculate the ratio of absorbances at these wavelengths
  6. Plot log([A-]/[HA]) vs pH
  7. 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

Practical Skills

Applied Capabilities

References

  1. 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).
  2. 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
  3. Perkampus, H.-H. (1992). UV-VIS Spectroscopy and Its Applications. Springer-Verlag, pp. 15-45 (electronic transitions), pp. 78-95 (chromophores).
  4. Owen, T. (1996). Fundamentals of Modern UV-visible Spectroscopy. Agilent Technologies, pp. 25-40 (instrumentation), pp. 55-70 (quantitative analysis).
  5. 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
  6. 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