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

第2章:UV-Vis分光法

電子遷移で探る物質の電子構造とバンドギャップ

シリーズ: 分光分析入門 学習時間: 25-30分 難易度: 初級-中級 コード例: 8個

イントロダクション

紫外可視分光法(UV-Vis Spectroscopy)は、紫外線(UV: 200-400 nm)から可視光線(Vis: 400-800 nm)の領域で物質による光の吸収を測定する分析手法です。この領域の光は分子の電子遷移を引き起こすため、UV-Visスペクトルは物質の電子状態に関する豊富な情報を提供します。

材料科学においてUV-Visは、有機分子の共役系解析、半導体のバンドギャップ測定、金属錯体の配位子場分裂エネルギー決定、溶液中の濃度定量など、幅広い応用を持つ基本的な分析ツールです。

この章で学ぶこと

1. 電子遷移の基礎

1.1 分子軌道と電子遷移

分子の電子状態は、結合性軌道、非結合性軌道(孤立電子対)、反結合性軌道で構成されます。UV-Vis領域での光吸収は、電子がより低いエネルギー準位からより高いエネルギー準位へ励起されることで生じます。主な電子遷移には以下の種類があります:

遷移の種類 エネルギー 波長範囲
$\sigma \rightarrow \sigma^*$ 非常に高い < 150 nm(真空紫外) メタン、エタン(C-C, C-H結合)
$n \rightarrow \sigma^*$ 高い 150-250 nm 水、アルコール、アミン(孤立電子対)
$\pi \rightarrow \pi^*$ 中程度 200-500 nm エチレン、ベンゼン、共役ジエン
$n \rightarrow \pi^*$ 低い 250-400 nm カルボニル化合物、アゾ化合物
graph TB subgraph "分子軌道エネルギー準位" A["反結合性 sigma* 軌道"] B["反結合性 pi* 軌道"] C["非結合性 n 軌道"] D["結合性 pi 軌道"] E["結合性 sigma 軌道"] end E -->|"sigma-sigma* 高エネルギー"| A D -->|"pi-pi*"| B C -->|"n-pi*"| B C -->|"n-sigma*"| A style A fill:#ff6b6b,stroke:#333,stroke-width:2px style B fill:#ffa94d,stroke:#333,stroke-width:2px style C fill:#69db7c,stroke:#333,stroke-width:2px style D fill:#4dabf7,stroke:#333,stroke-width:2px style E fill:#748ffc,stroke:#333,stroke-width:2px

1.2 遷移エネルギーと波長の関係

電子遷移に必要なエネルギー $\Delta E$ は、吸収される光の波長 $\lambda$ と以下の関係式で結ばれます:

$$\Delta E = h\nu = \frac{hc}{\lambda}$$

ここで、$h$ はプランク定数($6.626 \times 10^{-34}$ J s)、$c$ は光速($2.998 \times 10^8$ m/s)です。波長が短いほど光子のエネルギーは高く、より大きなエネルギーギャップを持つ遷移を引き起こします。

コード例1: 波長とエネルギーの相互変換

import numpy as np
import matplotlib.pyplot as plt

# 物理定数
h = 6.62607015e-34  # J s (プランク定数)
c = 2.99792458e8    # m/s (光速)
eV = 1.602176634e-19  # J (1 eV)

def wavelength_to_energy(wavelength_nm):
    """
    波長(nm)から光子エネルギー(eV)を計算

    Parameters:
    -----------
    wavelength_nm : float or array
        波長(ナノメートル)

    Returns:
    --------
    energy_eV : float or array
        光子エネルギー(電子ボルト)
    """
    wavelength_m = wavelength_nm * 1e-9
    energy_J = h * c / wavelength_m
    energy_eV = energy_J / eV
    return energy_eV

def energy_to_wavelength(energy_eV_val):
    """
    エネルギー(eV)から波長(nm)を計算

    Parameters:
    -----------
    energy_eV_val : float or array
        光子エネルギー(電子ボルト)

    Returns:
    --------
    wavelength_nm : float or array
        波長(ナノメートル)
    """
    energy_J = energy_eV_val * eV
    wavelength_m = h * c / energy_J
    wavelength_nm = wavelength_m * 1e9
    return wavelength_nm

# 代表的な電子遷移の波長とエネルギー
transitions = {
    'sigma-sigma* (C-C)': 135,
    'n-sigma* (H2O)': 167,
    'n-sigma* (CH3OH)': 183,
    'pi-pi* (ethylene)': 165,
    'pi-pi* (1,3-butadiene)': 217,
    'pi-pi* (benzene)': 254,
    'n-pi* (acetone)': 280,
    'n-pi* (acetaldehyde)': 290
}

print("=" * 70)
print("電子遷移の波長とエネルギー")
print("=" * 70)
print(f"{'遷移':<25} {'波長 (nm)':<15} {'エネルギー (eV)':<15}")
print("-" * 70)

for transition, wavelength in transitions.items():
    energy = wavelength_to_energy(wavelength)
    print(f"{transition:<25} {wavelength:<15} {energy:.2f}")

# UV-Vis領域のエネルギー分布をプロット
wavelengths = np.linspace(200, 800, 500)
energies = wavelength_to_energy(wavelengths)

fig, ax1 = plt.subplots(figsize=(12, 6))

# 波長-エネルギー曲線
ax1.plot(wavelengths, energies, 'b-', linewidth=2)
ax1.set_xlabel('Wavelength (nm)', fontsize=12)
ax1.set_ylabel('Photon Energy (eV)', fontsize=12, color='b')
ax1.tick_params(axis='y', labelcolor='b')
ax1.set_xlim(200, 800)

# 可視光の色を背景に追加
colors = [
    (380, 450, '#7B68EE'),  # 紫
    (450, 495, '#0000FF'),  # 青
    (495, 570, '#00FF00'),  # 緑
    (570, 590, '#FFFF00'),  # 黄
    (590, 620, '#FFA500'),  # 橙
    (620, 750, '#FF0000'),  # 赤
]

for start, end, color in colors:
    ax1.axvspan(max(200, start), min(800, end), alpha=0.2, color=color)

# 紫外領域のマーク
ax1.axvspan(200, 380, alpha=0.1, color='purple', label='UV region')

ax1.set_title('UV-Vis Energy-Wavelength Relationship', fontsize=14, fontweight='bold')
ax1.grid(True, alpha=0.3)
ax1.legend(loc='upper right')

plt.tight_layout()
plt.savefig('uv_vis_energy_wavelength.png', dpi=300, bbox_inches='tight')
plt.show()

print("\n" + "=" * 70)
print("可視光の色と波長範囲")
print("=" * 70)
visible_colors = [
    ('紫', 380, 450, wavelength_to_energy(415)),
    ('青', 450, 495, wavelength_to_energy(472.5)),
    ('緑', 495, 570, wavelength_to_energy(532.5)),
    ('黄', 570, 590, wavelength_to_energy(580)),
    ('橙', 590, 620, wavelength_to_energy(605)),
    ('赤', 620, 750, wavelength_to_energy(685)),
]

print(f"{'色':<8} {'波長範囲 (nm)':<20} {'平均エネルギー (eV)':<15}")
print("-" * 50)
for color, start, end, energy in visible_colors:
    print(f"{color:<8} {start}-{end:<13} {energy:.2f}")

2. ランベルト・ベール則

2.1 光吸収の定量的記述

溶液による光吸収の強度は、ランベルト・ベール則(Lambert-Beer Law)によって定量的に記述されます。透過率 $T$ と吸光度 $A$ は以下のように定義されます:

$$T = \frac{I}{I_0}$$

$$A = -\log_{10}(T) = \log_{10}\left(\frac{I_0}{I}\right) = \varepsilon c l$$

ここで:

モル吸光係数の意味
モル吸光係数 $\varepsilon$ は、分子固有の光吸収能力を表します。$\varepsilon$ が大きいほど、その波長での光吸収が強いことを意味します。典型的な値の範囲:

コード例2: ランベルト・ベール則と検量線作成

import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

def beer_lambert(epsilon, c, l):
    """
    ランベルト・ベール則による吸光度計算

    Parameters:
    -----------
    epsilon : float
        モル吸光係数 (L mol^-1 cm^-1)
    c : float or array
        濃度 (mol/L)
    l : float
        光路長 (cm)

    Returns:
    --------
    A : float or array
        吸光度
    """
    return epsilon * c * l

def calculate_concentration(A, epsilon, l):
    """
    吸光度から濃度を計算

    Parameters:
    -----------
    A : float
        吸光度
    epsilon : float
        モル吸光係数 (L mol^-1 cm^-1)
    l : float
        光路長 (cm)

    Returns:
    --------
    c : float
        濃度 (mol/L)
    """
    return A / (epsilon * l)

# 過マンガン酸カリウム(KMnO4)の検量線作成
# 525 nm での モル吸光係数: 約 2500 L mol^-1 cm^-1
epsilon_KMnO4 = 2500  # L mol^-1 cm^-1
path_length = 1.0  # cm

# 標準溶液の調製(既知濃度)
concentrations_std = np.array([0.0, 0.02, 0.04, 0.06, 0.08, 0.10]) * 1e-3  # mol/L

# 理論吸光度
absorbances_theoretical = beer_lambert(epsilon_KMnO4, concentrations_std, path_length)

# 実験データをシミュレート(ノイズ付き)
np.random.seed(42)
noise = np.random.normal(0, 0.005, len(concentrations_std))
absorbances_exp = absorbances_theoretical + noise
absorbances_exp[0] = 0  # ブランクは0

# 線形回帰
slope, intercept, r_value, p_value, std_err = stats.linregress(
    concentrations_std * 1e3, absorbances_exp
)

print("=" * 70)
print("KMnO4 検量線解析結果")
print("=" * 70)
print(f"波長: 525 nm")
print(f"光路長: {path_length} cm")
print(f"理論モル吸光係数: {epsilon_KMnO4} L mol^-1 cm^-1")
print("-" * 70)
print(f"回帰式: A = {slope:.2f} x c (mM) + {intercept:.4f}")
print(f"実験モル吸光係数: {slope * 1000:.0f} L mol^-1 cm^-1")
print(f"決定係数 R^2: {r_value**2:.6f}")
print(f"標準誤差: {std_err:.4f}")

# 未知試料の濃度決定
unknown_absorbance = 0.175
unknown_concentration = (unknown_absorbance - intercept) / slope  # mM
print("-" * 70)
print(f"未知試料の吸光度: {unknown_absorbance}")
print(f"推定濃度: {unknown_concentration:.4f} mM ({unknown_concentration * 1e-3:.6f} mol/L)")

# プロット
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# 検量線
ax1 = axes[0]
ax1.scatter(concentrations_std * 1e3, absorbances_exp, s=100, c='#f093fb',
            edgecolors='black', zorder=5, label='Experimental data')
x_fit = np.linspace(0, 0.12, 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})')

# 未知試料のプロット
ax1.axhline(y=unknown_absorbance, color='green', linestyle='--', alpha=0.7)
ax1.axvline(x=unknown_concentration, color='green', linestyle='--', alpha=0.7)
ax1.scatter([unknown_concentration], [unknown_absorbance], s=150, c='green',
            marker='*', zorder=6, label=f'Unknown sample: {unknown_concentration:.3f} mM')

ax1.set_xlabel('Concentration (mM)', fontsize=12)
ax1.set_ylabel('Absorbance', fontsize=12)
ax1.set_title('KMnO4 Calibration Curve (525 nm)', fontsize=14, fontweight='bold')
ax1.legend(loc='upper left')
ax1.grid(True, alpha=0.3)
ax1.set_xlim(-0.005, 0.12)
ax1.set_ylim(-0.01, 0.30)

# ビール則からの偏差(高濃度領域)
ax2 = axes[1]
concentrations_wide = np.linspace(0, 0.5e-3, 100)
absorbances_ideal = beer_lambert(epsilon_KMnO4, concentrations_wide, path_length)

# 高濃度での負の偏差をシミュレート
absorbances_real = absorbances_ideal * (1 - 0.3 * (concentrations_wide / concentrations_wide.max())**2)

ax2.plot(concentrations_wide * 1e3, absorbances_ideal, 'b-', linewidth=2,
         label='Ideal (Beer-Lambert law)')
ax2.plot(concentrations_wide * 1e3, absorbances_real, 'r--', linewidth=2,
         label='Real (with deviation)')
ax2.axvline(x=0.15, color='orange', linestyle=':', linewidth=2,
            label='Recommended upper limit')

ax2.set_xlabel('Concentration (mM)', fontsize=12)
ax2.set_ylabel('Absorbance', fontsize=12)
ax2.set_title('Deviation from Beer-Lambert Law', fontsize=14, fontweight='bold')
ax2.legend(loc='upper left')
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('beer_lambert_calibration.png', dpi=300, bbox_inches='tight')
plt.show()

2.2 ビール則からの偏差

ランベルト・ベール則は理想的な条件下でのみ成立します。以下の要因により偏差が生じることがあります:

実用上、吸光度 $A < 1.0$(透過率 $T > 10\%$)の範囲で測定することが推奨されます。

3. 発色団と助色団

3.1 発色団(Chromophore)

発色団は、UV-Vis領域で光を吸収する官能基です。発色団の電子構造が吸収波長を決定します。

発色団 遷移 吸収極大 (nm) モル吸光係数
C=C(エチレン) $\pi \rightarrow \pi^*$ 165 10,000
C=O(アルデヒド) $n \rightarrow \pi^*$ 290 15
C=O(ケトン) $n \rightarrow \pi^*$ 280 20
N=N(アゾ) $n \rightarrow \pi^*$ 350 15
ベンゼン環 $\pi \rightarrow \pi^*$ 254 200

3.2 助色団(Auxochrome)

助色団は、それ自体では発色しませんが、発色団に結合すると吸収波長や吸光度を変化させる官能基です。典型的な助色団には -OH、-NH2、-OR、-NR2、-Cl などがあります。

吸収スペクトルの変化

コード例3: 共役系の拡張と吸収スペクトル変化

import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm

def generate_absorption_spectrum(lambda_max, epsilon_max, fwhm, wavelengths):
    """
    ガウス型吸収スペクトルを生成

    Parameters:
    -----------
    lambda_max : float
        吸収極大波長 (nm)
    epsilon_max : float
        最大モル吸光係数
    fwhm : float
        半値全幅 (nm)
    wavelengths : array
        波長配列 (nm)

    Returns:
    --------
    spectrum : array
        モル吸光係数のスペクトル
    """
    sigma = fwhm / (2 * np.sqrt(2 * np.log(2)))
    spectrum = epsilon_max * np.exp(-(wavelengths - lambda_max)**2 / (2 * sigma**2))
    return spectrum

# 共役ポリエンの吸収スペクトル(共役長の影響)
# エチレン → ブタジエン → ヘキサトリエン → オクタテトラエン
polyenes = {
    'Ethylene (1 C=C)': {'lambda_max': 165, 'epsilon': 10000, 'fwhm': 30},
    '1,3-Butadiene (2 C=C)': {'lambda_max': 217, 'epsilon': 21000, 'fwhm': 35},
    '1,3,5-Hexatriene (3 C=C)': {'lambda_max': 258, 'epsilon': 35000, 'fwhm': 40},
    '1,3,5,7-Octatetraene (4 C=C)': {'lambda_max': 290, 'epsilon': 52000, 'fwhm': 45},
}

wavelengths = np.linspace(150, 450, 500)

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# 吸収スペクトル
ax1 = axes[0]
colors = ['#f093fb', '#f5576c', '#4ecdc4', '#ffe66d']

for (name, params), color in zip(polyenes.items(), colors):
    spectrum = generate_absorption_spectrum(
        params['lambda_max'], params['epsilon'], params['fwhm'], wavelengths
    )
    ax1.plot(wavelengths, spectrum / 1000, linewidth=2, color=color, label=name)
    ax1.axvline(x=params['lambda_max'], color=color, linestyle='--', alpha=0.5)

ax1.set_xlabel('Wavelength (nm)', fontsize=12)
ax1.set_ylabel('Molar absorptivity (x10$^3$ L mol$^{-1}$ cm$^{-1}$)', fontsize=12)
ax1.set_title('Conjugated Polyenes: Extended Conjugation Effect', fontsize=14, fontweight='bold')
ax1.legend(loc='upper right', fontsize=9)
ax1.grid(True, alpha=0.3)
ax1.set_xlim(150, 450)
ax1.set_ylim(0, 60)

# 可視光領域のマーク
ax1.axvspan(380, 450, alpha=0.1, color='purple')

# 共役長と吸収極大の関係
ax2 = axes[1]
n_double_bonds = [1, 2, 3, 4]
lambda_max_values = [165, 217, 258, 290]
epsilon_values = [10000, 21000, 35000, 52000]

ax2.plot(n_double_bonds, lambda_max_values, 'o-', markersize=12, linewidth=2,
         color='#f093fb', markeredgecolor='black', label='$\\lambda_{max}$')
ax2.set_xlabel('Number of conjugated C=C bonds', fontsize=12)
ax2.set_ylabel('$\\lambda_{max}$ (nm)', fontsize=12, color='#f093fb')
ax2.tick_params(axis='y', labelcolor='#f093fb')
ax2.set_xlim(0.5, 4.5)
ax2.set_ylim(100, 350)

# 第2軸:モル吸光係数
ax2_twin = ax2.twinx()
ax2_twin.plot(n_double_bonds, np.array(epsilon_values) / 1000, 's-', markersize=10,
              linewidth=2, color='#4ecdc4', markeredgecolor='black', label='$\\epsilon_{max}$')
ax2_twin.set_ylabel('$\\epsilon_{max}$ (x10$^3$ L mol$^{-1}$ cm$^{-1}$)', fontsize=12, color='#4ecdc4')
ax2_twin.tick_params(axis='y', labelcolor='#4ecdc4')
ax2_twin.set_ylim(0, 60)

ax2.set_title('Effect of Conjugation Length on UV-Vis Absorption', fontsize=14, fontweight='bold')
ax2.grid(True, alpha=0.3)

# 凡例を結合
lines1, labels1 = ax2.get_legend_handles_labels()
lines2, labels2 = ax2_twin.get_legend_handles_labels()
ax2.legend(lines1 + lines2, labels1 + labels2, loc='upper left')

plt.tight_layout()
plt.savefig('conjugation_effect.png', dpi=300, bbox_inches='tight')
plt.show()

# 数値データの表示
print("=" * 70)
print("共役ポリエンのUV-Vis吸収特性")
print("=" * 70)
print(f"{'化合物':<35} {'共役C=C数':<12} {'lambda_max (nm)':<18} {'epsilon_max':<15}")
print("-" * 70)
for (name, params) in polyenes.items():
    n_cc = name.split('(')[1].split()[0]
    print(f"{name:<35} {n_cc:<12} {params['lambda_max']:<18} {params['epsilon']:<15}")

4. UV-Vis分光光度計の構成

4.1 分光光度計の基本構成

UV-Vis分光光度計は以下の主要構成要素から成ります:

graph LR A[光源] --> B[分光器] B --> C[試料室] C --> D[検出器] D --> E[データ処理] subgraph "光源" A1[重水素ランプ UV] A2[タングステンランプ Vis] end subgraph "分光器" B1[回折格子] B2[スリット] end subgraph "試料室" C1[セル] C2[リファレンス] end style A fill:#f093fb,stroke:#333 style B fill:#f5576c,stroke:#333 style C fill:#4ecdc4,stroke:#333 style D fill:#ffe66d,stroke:#333 style E fill:#a29bfe,stroke:#333

4.2 ダブルビーム方式

現代のUV-Vis分光光度計の多くはダブルビーム方式を採用しています。光を二つに分け、一方を試料セル、他方をリファレンス(参照)セルに通すことで、光源の強度変動やセルの汚れによる誤差を補正できます。

コード例4: UV-Visスペクトルのシミュレーションと解析

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import find_peaks
from scipy.ndimage import gaussian_filter1d

def simulate_uv_vis_spectrum(compounds, wavelengths):
    """
    複数成分の混合物のUV-Visスペクトルをシミュレート

    Parameters:
    -----------
    compounds : list of dict
        各化合物の情報 {'name', 'lambda_max', 'epsilon', 'fwhm', 'concentration'}
    wavelengths : array
        波長配列 (nm)

    Returns:
    --------
    total_absorbance : array
        合計吸光度スペクトル
    individual_spectra : dict
        個別成分のスペクトル
    """
    total_absorbance = np.zeros_like(wavelengths, dtype=float)
    individual_spectra = {}
    path_length = 1.0  # cm

    for compound in compounds:
        sigma = compound['fwhm'] / (2 * np.sqrt(2 * np.log(2)))
        epsilon_spectrum = compound['epsilon'] * np.exp(
            -(wavelengths - compound['lambda_max'])**2 / (2 * sigma**2)
        )
        absorbance = epsilon_spectrum * compound['concentration'] * path_length
        individual_spectra[compound['name']] = absorbance
        total_absorbance += absorbance

    return total_absorbance, individual_spectra

def analyze_spectrum(wavelengths, absorbance, prominence=0.01):
    """
    スペクトルのピーク解析

    Parameters:
    -----------
    wavelengths : array
        波長配列 (nm)
    absorbance : array
        吸光度配列
    prominence : float
        ピーク検出の閾値

    Returns:
    --------
    peaks_info : list of dict
        検出されたピークの情報
    """
    peaks, properties = find_peaks(absorbance, prominence=prominence)

    peaks_info = []
    for i, peak_idx in enumerate(peaks):
        peaks_info.append({
            'wavelength': wavelengths[peak_idx],
            'absorbance': absorbance[peak_idx],
            'prominence': properties['prominences'][i]
        })

    return peaks_info

# 有機分子混合物のスペクトルシミュレーション
compounds = [
    {'name': 'Benzene', 'lambda_max': 254, 'epsilon': 200, 'fwhm': 15,
     'concentration': 1e-4},
    {'name': 'Naphthalene', 'lambda_max': 275, 'epsilon': 5600, 'fwhm': 18,
     'concentration': 2e-5},
    {'name': 'Naphthalene (2nd band)', 'lambda_max': 220, 'epsilon': 100000, 'fwhm': 25,
     'concentration': 2e-5},
    {'name': 'Acetone', 'lambda_max': 280, 'epsilon': 15, 'fwhm': 20,
     'concentration': 1e-2},
]

wavelengths = np.linspace(200, 350, 500)

# スペクトル計算
total_abs, individual = simulate_uv_vis_spectrum(compounds, wavelengths)

# ノイズ追加(実験データのシミュレート)
np.random.seed(42)
noise = np.random.normal(0, 0.002, len(wavelengths))
measured_abs = total_abs + noise

# スムージング
smoothed_abs = gaussian_filter1d(measured_abs, sigma=2)

# ピーク検出
peaks_info = analyze_spectrum(wavelengths, smoothed_abs, prominence=0.005)

# プロット
fig, axes = plt.subplots(2, 2, figsize=(14, 12))

# 個別成分スペクトル
ax1 = axes[0, 0]
colors = ['#f093fb', '#f5576c', '#4ecdc4', '#ffe66d']
for (name, spectrum), color in zip(individual.items(), colors):
    ax1.plot(wavelengths, spectrum, linewidth=2, color=color, label=name)

ax1.set_xlabel('Wavelength (nm)', fontsize=12)
ax1.set_ylabel('Absorbance', fontsize=12)
ax1.set_title('Individual Component Spectra', fontsize=14, fontweight='bold')
ax1.legend(loc='upper right', fontsize=9)
ax1.grid(True, alpha=0.3)

# 合計スペクトルと測定スペクトル
ax2 = axes[0, 1]
ax2.plot(wavelengths, measured_abs, 'k-', alpha=0.3, linewidth=1, label='Measured (with noise)')
ax2.plot(wavelengths, smoothed_abs, 'b-', linewidth=2, label='Smoothed')
ax2.plot(wavelengths, total_abs, 'r--', linewidth=1.5, label='True spectrum')

# ピークをマーク
for peak in peaks_info:
    ax2.axvline(x=peak['wavelength'], color='green', linestyle=':', alpha=0.7)
    ax2.annotate(f"{peak['wavelength']:.0f} nm",
                xy=(peak['wavelength'], peak['absorbance']),
                xytext=(peak['wavelength'] + 10, peak['absorbance'] + 0.02),
                fontsize=9, color='green')

ax2.set_xlabel('Wavelength (nm)', fontsize=12)
ax2.set_ylabel('Absorbance', fontsize=12)
ax2.set_title('Mixed Sample UV-Vis Spectrum', fontsize=14, fontweight='bold')
ax2.legend(loc='upper right')
ax2.grid(True, alpha=0.3)

# 透過率スペクトル
ax3 = axes[1, 0]
transmittance = 10**(-smoothed_abs) * 100  # パーセント透過率
ax3.plot(wavelengths, transmittance, 'g-', linewidth=2)
ax3.set_xlabel('Wavelength (nm)', fontsize=12)
ax3.set_ylabel('Transmittance (%)', fontsize=12)
ax3.set_title('Transmittance Spectrum', fontsize=14, fontweight='bold')
ax3.set_ylim(0, 105)
ax3.grid(True, alpha=0.3)

# 一次微分スペクトル
ax4 = axes[1, 1]
derivative = np.gradient(smoothed_abs, wavelengths)
ax4.plot(wavelengths, derivative, 'm-', linewidth=2)
ax4.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax4.set_xlabel('Wavelength (nm)', fontsize=12)
ax4.set_ylabel('dA/d$\\lambda$', fontsize=12)
ax4.set_title('First Derivative Spectrum', fontsize=14, fontweight='bold')
ax4.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('uv_vis_spectrum_analysis.png', dpi=300, bbox_inches='tight')
plt.show()

# ピーク情報の表示
print("=" * 60)
print("検出されたピーク情報")
print("=" * 60)
print(f"{'波長 (nm)':<15} {'吸光度':<15} {'プロミネンス':<15}")
print("-" * 60)
for peak in sorted(peaks_info, key=lambda x: x['wavelength']):
    print(f"{peak['wavelength']:<15.1f} {peak['absorbance']:<15.4f} {peak['prominence']:<15.4f}")

5. 半導体のバンドギャップ決定

5.1 Taucプロット法

半導体材料のバンドギャップ($E_g$)はUV-Vis拡散反射スペクトルから決定できます。Taucの関係式は以下で表されます:

$$(\alpha h\nu)^n = A(h\nu - E_g)$$

ここで:

粉末試料の場合、Kubelka-Munk関数 $F(R)$ を用いて吸収係数を近似します:

$$F(R) = \frac{(1-R)^2}{2R} \approx \alpha$$

コード例5: Taucプロットによるバンドギャップ決定

import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
from scipy.ndimage import gaussian_filter1d

def kubelka_munk(reflectance):
    """
    Kubelka-Munk変換

    Parameters:
    -----------
    reflectance : array
        反射率(0-1)

    Returns:
    --------
    F_R : array
        Kubelka-Munk関数値
    """
    R = np.clip(reflectance, 0.001, 0.999)  # ゼロ除算を防ぐ
    F_R = (1 - R)**2 / (2 * R)
    return F_R

def tauc_plot(wavelength_nm, reflectance, n=2, fit_range=None):
    """
    Taucプロットによるバンドギャップ決定

    Parameters:
    -----------
    wavelength_nm : array
        波長(nm)
    reflectance : array
        反射率(0-1)
    n : float
        遷移タイプ(2: 直接許容遷移、1/2: 間接許容遷移)
    fit_range : tuple
        フィッティング範囲(eV)

    Returns:
    --------
    E_g : float
        バンドギャップ(eV)
    energy : array
        光子エネルギー配列
    tauc_y : array
        Taucプロットのy軸値
    fit_params : dict
        フィッティングパラメータ
    """
    # 波長をエネルギーに変換
    h = 6.62607015e-34  # J s
    c = 2.99792458e8    # m/s
    eV = 1.602176634e-19  # J

    wavelength_m = wavelength_nm * 1e-9
    energy = (h * c / wavelength_m) / eV  # eV

    # Kubelka-Munk変換
    F_R = kubelka_munk(reflectance)

    # Taucプロット値
    tauc_y = (F_R * energy)**n

    # スムージング
    tauc_y_smooth = gaussian_filter1d(tauc_y, sigma=3)

    # 線形領域の自動検出またはユーザー指定
    if fit_range is None:
        # 最大傾斜領域を探す
        gradient = np.gradient(tauc_y_smooth, energy)
        max_grad_idx = np.argmax(gradient)
        # 傾斜が大きい領域の前後を取る
        fit_mask = (energy > energy[max_grad_idx] - 0.3) & (energy < energy[max_grad_idx] + 0.3)
    else:
        fit_mask = (energy >= fit_range[0]) & (energy <= fit_range[1])

    # 線形回帰
    if np.sum(fit_mask) > 10:
        slope, intercept, r_value, p_value, std_err = stats.linregress(
            energy[fit_mask], tauc_y_smooth[fit_mask]
        )
        E_g = -intercept / slope if slope > 0 else np.nan
    else:
        slope, intercept, E_g, r_value = np.nan, np.nan, np.nan, np.nan

    fit_params = {
        'slope': slope,
        'intercept': intercept,
        'r_squared': r_value**2 if not np.isnan(r_value) else np.nan,
        'fit_mask': fit_mask
    }

    return E_g, energy, tauc_y_smooth, fit_params

# 半導体材料のUV-Visデータシミュレーション
def simulate_semiconductor_spectrum(E_g, wavelength_nm, transition='direct'):
    """
    半導体の拡散反射スペクトルをシミュレート
    """
    h = 6.62607015e-34
    c = 2.99792458e8
    eV = 1.602176634e-19

    wavelength_m = wavelength_nm * 1e-9
    energy = (h * c / wavelength_m) / eV

    # 吸収係数のモデル
    n = 2 if transition == 'direct' else 0.5
    alpha = np.where(energy > E_g, 1e5 * (energy - E_g)**n, 0)

    # 反射率に変換(簡略化モデル)
    reflectance = np.exp(-alpha * 1e-4)  # 仮想的な膜厚
    reflectance = np.clip(reflectance, 0.01, 0.99)

    # ノイズ追加
    noise = np.random.normal(0, 0.01, len(reflectance))
    reflectance = np.clip(reflectance + noise, 0.01, 0.99)

    return reflectance

# 異なるバンドギャップの半導体をシミュレート
semiconductors = {
    'TiO2 (anatase)': {'E_g_true': 3.2, 'transition': 'indirect'},
    'ZnO': {'E_g_true': 3.37, 'transition': 'direct'},
    'CdS': {'E_g_true': 2.42, 'transition': 'direct'},
    'Fe2O3 (hematite)': {'E_g_true': 2.1, 'transition': 'indirect'},
}

wavelength = np.linspace(300, 800, 500)

fig, axes = plt.subplots(2, 2, figsize=(14, 12))

results = {}

for idx, (name, params) in enumerate(semiconductors.items()):
    ax = axes[idx // 2, idx % 2]

    # スペクトル生成
    np.random.seed(idx)
    reflectance = simulate_semiconductor_spectrum(
        params['E_g_true'], wavelength, params['transition']
    )

    # Taucプロット解析
    n = 2 if params['transition'] == 'direct' else 0.5
    E_g, energy, tauc_y, fit_params = tauc_plot(
        wavelength, reflectance, n=n
    )

    results[name] = {'E_g_measured': E_g, 'E_g_true': params['E_g_true']}

    # プロット
    ax.plot(energy, tauc_y, 'b-', linewidth=2, label='Tauc plot')

    # フィッティング線
    if not np.isnan(fit_params['slope']):
        x_fit = np.linspace(E_g - 0.5, np.max(energy[fit_params['fit_mask']]) + 0.5, 100)
        y_fit = fit_params['slope'] * x_fit + fit_params['intercept']
        y_fit = np.clip(y_fit, 0, None)
        ax.plot(x_fit, y_fit, 'r--', linewidth=2,
                label=f'Linear fit (R$^2$={fit_params["r_squared"]:.3f})')

    # バンドギャップのマーク
    ax.axvline(x=E_g, color='green', linestyle=':', linewidth=2,
               label=f'$E_g$ = {E_g:.2f} eV')
    ax.axvline(x=params['E_g_true'], color='orange', linestyle='--', linewidth=1.5,
               alpha=0.7, label=f'True $E_g$ = {params["E_g_true"]:.2f} eV')

    transition_type = 'Direct' if params['transition'] == 'direct' else 'Indirect'
    ax.set_xlabel('Photon Energy (eV)', fontsize=11)
    ax.set_ylabel(f'$(F(R) \\cdot h\\nu)^{{{n}}}$', fontsize=11)
    ax.set_title(f'{name} ({transition_type} transition)', fontsize=12, fontweight='bold')
    ax.legend(loc='upper left', fontsize=9)
    ax.grid(True, alpha=0.3)
    ax.set_xlim(1.5, 4.5)
    ax.set_ylim(0, np.nanmax(tauc_y) * 1.2)

plt.tight_layout()
plt.savefig('tauc_plot_bandgap.png', dpi=300, bbox_inches='tight')
plt.show()

# 結果の表示
print("=" * 70)
print("Taucプロットによるバンドギャップ決定結果")
print("=" * 70)
print(f"{'材料':<25} {'測定値 (eV)':<15} {'文献値 (eV)':<15} {'誤差 (%)':<10}")
print("-" * 70)
for name, values in results.items():
    error = abs(values['E_g_measured'] - values['E_g_true']) / values['E_g_true'] * 100
    print(f"{name:<25} {values['E_g_measured']:<15.2f} {values['E_g_true']:<15.2f} {error:<10.1f}")

5.2 直接遷移と間接遷移

半導体のバンド構造により、光学遷移は直接遷移と間接遷移に分類されます:

コード例6: 複数試料のバンドギャップ比較解析

import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

def batch_bandgap_analysis(samples_data, plot=True):
    """
    複数試料のバンドギャップを一括解析

    Parameters:
    -----------
    samples_data : list of dict
        各試料のデータ {'name', 'wavelength', 'reflectance', 'transition'}
    plot : bool
        プロットを表示するか

    Returns:
    --------
    results : dict
        各試料のバンドギャップ結果
    """
    h = 6.62607015e-34
    c = 2.99792458e8
    eV = 1.602176634e-19

    results = {}

    if plot:
        n_samples = len(samples_data)
        n_cols = min(3, n_samples)
        n_rows = (n_samples + n_cols - 1) // n_cols
        fig, axes = plt.subplots(n_rows, n_cols, figsize=(5*n_cols, 4*n_rows))
        axes = np.atleast_2d(axes).flatten()

    for idx, sample in enumerate(samples_data):
        wavelength = sample['wavelength']
        reflectance = sample['reflectance']
        n = 2 if sample['transition'] == 'direct' else 0.5

        # エネルギー変換
        wavelength_m = wavelength * 1e-9
        energy = (h * c / wavelength_m) / eV

        # Kubelka-Munk変換
        R = np.clip(reflectance, 0.001, 0.999)
        F_R = (1 - R)**2 / (2 * R)

        # Taucプロット
        tauc_y = (F_R * energy)**n

        # 微分による線形領域検出
        gradient = np.gradient(tauc_y, energy)
        gradient_smooth = gaussian_filter1d(gradient, sigma=5)

        # 最大勾配点
        max_grad_idx = np.argmax(gradient_smooth)

        # フィッティング領域
        fit_width = int(len(energy) * 0.15)
        start_idx = max(0, max_grad_idx - fit_width // 2)
        end_idx = min(len(energy), max_grad_idx + fit_width // 2)

        # 線形回帰
        slope, intercept, r_value, _, _ = stats.linregress(
            energy[start_idx:end_idx], tauc_y[start_idx:end_idx]
        )

        E_g = -intercept / slope if slope > 0 else np.nan

        results[sample['name']] = {
            'E_g': E_g,
            'R_squared': r_value**2,
            'transition': sample['transition']
        }

        if plot:
            ax = axes[idx]
            ax.plot(energy, tauc_y, 'b-', linewidth=1.5, alpha=0.8)

            # フィッティング線
            x_fit = np.linspace(E_g - 0.3, energy[end_idx], 50)
            y_fit = slope * x_fit + intercept
            y_fit = np.clip(y_fit, 0, None)
            ax.plot(x_fit, y_fit, 'r--', linewidth=2)

            ax.axvline(x=E_g, color='green', linestyle=':', linewidth=2)
            ax.set_xlabel('Photon Energy (eV)', fontsize=10)
            ax.set_ylabel(f'$(F(R) \\cdot h\\nu)^{{{n}}}$', fontsize=10)
            ax.set_title(f"{sample['name']}\n$E_g$ = {E_g:.2f} eV", fontsize=11, fontweight='bold')
            ax.grid(True, alpha=0.3)
            ax.set_xlim(min(energy) - 0.2, max(energy) + 0.2)
            ax.set_ylim(0, max(tauc_y) * 1.1)

    # 未使用の軸を非表示
    if plot:
        for idx in range(len(samples_data), len(axes)):
            axes[idx].set_visible(False)

        plt.tight_layout()
        plt.savefig('batch_bandgap_analysis.png', dpi=300, bbox_inches='tight')
        plt.show()

    return results

# ドーピングによるバンドギャップ変調のシミュレーション
def simulate_doped_semiconductor(base_E_g, dopant_concentration, wavelength):
    """
    ドーピングによるバンドギャップ変調をシミュレート
    """
    # バーンシュタイン-モス効果による青方シフト
    delta_E = 0.1 * np.log(1 + dopant_concentration * 100)
    effective_E_g = base_E_g + delta_E

    h = 6.62607015e-34
    c = 2.99792458e8
    eV = 1.602176634e-19

    wavelength_m = wavelength * 1e-9
    energy = (h * c / wavelength_m) / eV

    # 吸収スペクトル
    alpha = np.where(energy > effective_E_g, 1e5 * (energy - effective_E_g)**2, 0)
    reflectance = np.exp(-alpha * 1e-4)
    reflectance = np.clip(reflectance + np.random.normal(0, 0.005, len(wavelength)), 0.01, 0.99)

    return reflectance, effective_E_g

# ZnOドーピング系列のシミュレーション
wavelength = np.linspace(300, 600, 400)
dopant_levels = [0.0, 0.01, 0.02, 0.05, 0.10]

samples = []
true_E_g_values = []

for conc in dopant_levels:
    np.random.seed(int(conc * 1000))
    reflectance, true_E_g = simulate_doped_semiconductor(3.37, conc, wavelength)
    true_E_g_values.append(true_E_g)

    samples.append({
        'name': f'ZnO (Al {conc*100:.0f}%)',
        'wavelength': wavelength,
        'reflectance': reflectance,
        'transition': 'direct'
    })

# バッチ解析
results = batch_bandgap_analysis(samples, plot=True)

# 結果まとめ
print("\n" + "=" * 70)
print("Al-doped ZnOシリーズのバンドギャップ解析")
print("=" * 70)
print(f"{'試料':<20} {'測定E_g (eV)':<15} {'真E_g (eV)':<15} {'R^2':<10}")
print("-" * 70)
for (name, res), true_Eg in zip(results.items(), true_E_g_values):
    print(f"{name:<20} {res['E_g']:<15.3f} {true_Eg:<15.3f} {res['R_squared']:<10.4f}")

6. 実践的スペクトル解析

6.1 ベースライン補正とスムージング

実験データには様々なノイズやベースラインのドリフトが含まれます。正確な解析のためには、前処理が重要です。

コード例7: スペクトルデータの前処理

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import savgol_filter
from scipy.ndimage import gaussian_filter1d
from scipy import sparse
from scipy.sparse.linalg import splu

def baseline_als(y, lam=1e5, p=0.01, niter=10):
    """
    Asymmetric Least Squares (ALS) によるベースライン補正

    Parameters:
    -----------
    y : array
        スペクトルデータ
    lam : float
        平滑化パラメータ(大きいほど滑らか)
    p : float
        非対称パラメータ(0-1、小さいほどベースラインが下に)
    niter : int
        反復回数

    Returns:
    --------
    baseline : array
        推定されたベースライン
    """
    L = len(y)
    D = sparse.diags([1, -2, 1], [0, -1, -2], shape=(L, L-2))
    w = np.ones(L)

    for i in range(niter):
        W = sparse.spdiags(w, 0, L, L)
        Z = W + lam * D.dot(D.T)
        z = splu(Z).solve(w * y)
        w = p * (y > z) + (1-p) * (y < z)

    return z

def preprocess_spectrum(wavelength, absorbance, smooth_method='savgol',
                        baseline_correct=True, normalize=True):
    """
    スペクトルデータの前処理パイプライン

    Parameters:
    -----------
    wavelength : array
        波長配列
    absorbance : array
        吸光度配列
    smooth_method : str
        スムージング方法 ('savgol', 'gaussian', 'none')
    baseline_correct : bool
        ベースライン補正を行うか
    normalize : bool
        正規化を行うか

    Returns:
    --------
    processed : dict
        前処理されたデータと中間結果
    """
    processed = {
        'wavelength': wavelength,
        'original': absorbance.copy()
    }

    # スムージング
    if smooth_method == 'savgol':
        window_length = min(21, len(absorbance) // 10 * 2 + 1)
        smoothed = savgol_filter(absorbance, window_length, polyorder=3)
    elif smooth_method == 'gaussian':
        smoothed = gaussian_filter1d(absorbance, sigma=2)
    else:
        smoothed = absorbance.copy()

    processed['smoothed'] = smoothed

    # ベースライン補正
    if baseline_correct:
        baseline = baseline_als(smoothed)
        corrected = smoothed - baseline
        processed['baseline'] = baseline
        processed['corrected'] = corrected
    else:
        corrected = smoothed
        processed['corrected'] = corrected

    # 正規化
    if normalize:
        max_abs = np.max(corrected)
        if max_abs > 0:
            normalized = corrected / max_abs
        else:
            normalized = corrected
        processed['normalized'] = normalized
    else:
        processed['normalized'] = corrected

    return processed

# ノイズとベースラインドリフトを持つスペクトルのシミュレーション
np.random.seed(42)
wavelength = np.linspace(200, 600, 500)

# 真のスペクトル(3つのピーク)
true_spectrum = (
    0.8 * np.exp(-(wavelength - 280)**2 / (2 * 15**2)) +
    0.5 * np.exp(-(wavelength - 350)**2 / (2 * 20**2)) +
    0.3 * np.exp(-(wavelength - 450)**2 / (2 * 25**2))
)

# ベースラインドリフト
baseline_drift = 0.1 + 0.0005 * (wavelength - 200) + 0.00001 * (wavelength - 200)**2

# ノイズ
noise = np.random.normal(0, 0.02, len(wavelength))

# 測定データ
measured = true_spectrum + baseline_drift + noise

# 前処理
result = preprocess_spectrum(wavelength, measured, smooth_method='savgol',
                             baseline_correct=True, normalize=True)

# プロット
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# 生データ vs スムージング後
ax1 = axes[0, 0]
ax1.plot(wavelength, measured, 'b-', alpha=0.5, linewidth=1, label='Raw data')
ax1.plot(wavelength, result['smoothed'], 'r-', linewidth=2, label='Smoothed (Savitzky-Golay)')
ax1.set_xlabel('Wavelength (nm)', fontsize=11)
ax1.set_ylabel('Absorbance', fontsize=11)
ax1.set_title('Step 1: Smoothing', fontsize=12, fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3)

# ベースライン推定
ax2 = axes[0, 1]
ax2.plot(wavelength, result['smoothed'], 'b-', linewidth=1.5, label='Smoothed data')
ax2.plot(wavelength, result['baseline'], 'g--', linewidth=2, label='Estimated baseline (ALS)')
ax2.plot(wavelength, baseline_drift, 'orange', linestyle=':', linewidth=2, label='True baseline')
ax2.set_xlabel('Wavelength (nm)', fontsize=11)
ax2.set_ylabel('Absorbance', fontsize=11)
ax2.set_title('Step 2: Baseline Estimation', fontsize=12, fontweight='bold')
ax2.legend()
ax2.grid(True, alpha=0.3)

# ベースライン補正後
ax3 = axes[1, 0]
ax3.plot(wavelength, result['corrected'], 'b-', linewidth=2, label='Baseline corrected')
ax3.plot(wavelength, true_spectrum, 'r--', linewidth=1.5, label='True spectrum')
ax3.set_xlabel('Wavelength (nm)', fontsize=11)
ax3.set_ylabel('Absorbance', fontsize=11)
ax3.set_title('Step 3: Baseline Correction', fontsize=12, fontweight='bold')
ax3.legend()
ax3.grid(True, alpha=0.3)

# 正規化後
ax4 = axes[1, 1]
ax4.plot(wavelength, result['normalized'], 'b-', linewidth=2, label='Processed & normalized')
ax4.plot(wavelength, true_spectrum / np.max(true_spectrum), 'r--', linewidth=1.5,
         label='True (normalized)')
ax4.set_xlabel('Wavelength (nm)', fontsize=11)
ax4.set_ylabel('Normalized Absorbance', fontsize=11)
ax4.set_title('Step 4: Normalization', fontsize=12, fontweight='bold')
ax4.legend()
ax4.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('spectrum_preprocessing.png', dpi=300, bbox_inches='tight')
plt.show()

# 処理品質の評価
correlation = np.corrcoef(result['normalized'],
                          true_spectrum / np.max(true_spectrum))[0, 1]
rmse = np.sqrt(np.mean((result['normalized'] - true_spectrum / np.max(true_spectrum))**2))

print("=" * 60)
print("前処理品質評価")
print("=" * 60)
print(f"真のスペクトルとの相関係数: {correlation:.4f}")
print(f"RMSE: {rmse:.4f}")
print(f"処理ステップ: スムージング -> ベースライン補正 -> 正規化")

6.2 多成分混合物のスペクトル分解

コード例8: 最小二乗法による多成分解析

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import nnls

def multicomponent_analysis(wavelength, mixture_spectrum, reference_spectra,
                            method='nnls'):
    """
    多成分混合物のスペクトル分解

    Parameters:
    -----------
    wavelength : array
        波長配列
    mixture_spectrum : array
        混合物の吸光度スペクトル
    reference_spectra : dict
        成分名をキー、参照スペクトルを値とする辞書
    method : str
        解析方法 ('nnls': 非負最小二乗法, 'lstsq': 通常の最小二乗法)

    Returns:
    --------
    concentrations : dict
        各成分の相対濃度
    reconstructed : array
        再構成スペクトル
    residual : array
        残差スペクトル
    """
    # 参照スペクトル行列の構築
    component_names = list(reference_spectra.keys())
    A = np.column_stack([reference_spectra[name] for name in component_names])

    # 解析
    if method == 'nnls':
        coefficients, residual_norm = nnls(A, mixture_spectrum)
    else:
        coefficients, residual_norm, _, _ = np.linalg.lstsq(A, mixture_spectrum, rcond=None)

    # 結果の整理
    concentrations = {name: coeff for name, coeff in zip(component_names, coefficients)}

    # 再構成
    reconstructed = A @ coefficients
    residual = mixture_spectrum - reconstructed

    return concentrations, reconstructed, residual

# 3成分混合物のシミュレーション
wavelength = np.linspace(200, 500, 400)

# 純成分の参照スペクトル
reference_spectra = {
    'Component A': 0.8 * np.exp(-(wavelength - 250)**2 / (2 * 20**2)) + \
                   0.3 * np.exp(-(wavelength - 350)**2 / (2 * 15**2)),
    'Component B': 0.6 * np.exp(-(wavelength - 280)**2 / (2 * 25**2)) + \
                   0.4 * np.exp(-(wavelength - 420)**2 / (2 * 30**2)),
    'Component C': 0.9 * np.exp(-(wavelength - 320)**2 / (2 * 22**2))
}

# 既知の混合比
true_concentrations = {'Component A': 0.4, 'Component B': 0.35, 'Component C': 0.25}

# 混合スペクトルの生成
mixture_true = sum(conc * reference_spectra[name]
                   for name, conc in true_concentrations.items())

# ノイズ追加
np.random.seed(42)
mixture_measured = mixture_true + np.random.normal(0, 0.01, len(wavelength))

# 多成分解析
conc_estimated, reconstructed, residual = multicomponent_analysis(
    wavelength, mixture_measured, reference_spectra, method='nnls'
)

# 正規化(合計を1に)
total_conc = sum(conc_estimated.values())
conc_normalized = {name: c / total_conc for name, c in conc_estimated.items()}

# プロット
fig = plt.figure(figsize=(16, 10))

# 参照スペクトル
ax1 = fig.add_subplot(2, 2, 1)
colors = ['#f093fb', '#f5576c', '#4ecdc4']
for (name, spectrum), color in zip(reference_spectra.items(), colors):
    ax1.plot(wavelength, spectrum, linewidth=2, color=color, label=name)
ax1.set_xlabel('Wavelength (nm)', fontsize=11)
ax1.set_ylabel('Absorbance', fontsize=11)
ax1.set_title('Reference Spectra (Pure Components)', fontsize=12, fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3)

# 混合スペクトルと再構成
ax2 = fig.add_subplot(2, 2, 2)
ax2.plot(wavelength, mixture_measured, 'k-', linewidth=1.5, alpha=0.7, label='Measured mixture')
ax2.plot(wavelength, reconstructed, 'r--', linewidth=2, label='Reconstructed')
ax2.set_xlabel('Wavelength (nm)', fontsize=11)
ax2.set_ylabel('Absorbance', fontsize=11)
ax2.set_title('Mixture Spectrum Analysis', fontsize=12, fontweight='bold')
ax2.legend()
ax2.grid(True, alpha=0.3)

# 残差
ax3 = fig.add_subplot(2, 2, 3)
ax3.plot(wavelength, residual, 'g-', linewidth=1.5)
ax3.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax3.fill_between(wavelength, residual, alpha=0.3, color='green')
ax3.set_xlabel('Wavelength (nm)', fontsize=11)
ax3.set_ylabel('Residual', fontsize=11)
ax3.set_title(f'Residual (RMSE = {np.sqrt(np.mean(residual**2)):.4f})',
              fontsize=12, fontweight='bold')
ax3.grid(True, alpha=0.3)

# 濃度比較
ax4 = fig.add_subplot(2, 2, 4)
x = np.arange(len(true_concentrations))
width = 0.35

true_vals = [true_concentrations[name] for name in reference_spectra.keys()]
est_vals = [conc_normalized[name] for name in reference_spectra.keys()]

bars1 = ax4.bar(x - width/2, true_vals, width, label='True', color='#4ecdc4', edgecolor='black')
bars2 = ax4.bar(x + width/2, est_vals, width, label='Estimated', color='#f093fb', edgecolor='black')

ax4.set_ylabel('Relative Concentration', fontsize=11)
ax4.set_title('Component Concentration Comparison', fontsize=12, fontweight='bold')
ax4.set_xticks(x)
ax4.set_xticklabels([f'Comp. {c}' for c in ['A', 'B', 'C']])
ax4.legend()
ax4.set_ylim(0, 0.6)
ax4.grid(axis='y', alpha=0.3)

# 値をバーの上に表示
for bar, val in zip(bars1, true_vals):
    ax4.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,
             f'{val:.2f}', ha='center', va='bottom', fontsize=10)
for bar, val in zip(bars2, est_vals):
    ax4.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,
             f'{val:.2f}', ha='center', va='bottom', fontsize=10)

plt.tight_layout()
plt.savefig('multicomponent_analysis.png', dpi=300, bbox_inches='tight')
plt.show()

# 結果の表示
print("=" * 70)
print("多成分スペクトル解析結果")
print("=" * 70)
print(f"{'成分':<15} {'真の濃度':<15} {'推定濃度':<15} {'誤差 (%)':<10}")
print("-" * 70)
for name in reference_spectra.keys():
    true_c = true_concentrations[name]
    est_c = conc_normalized[name]
    error = abs(true_c - est_c) / true_c * 100
    print(f"{name:<15} {true_c:<15.3f} {est_c:<15.3f} {error:<10.1f}")

print("-" * 70)
print(f"再構成RMSE: {np.sqrt(np.mean(residual**2)):.4f}")
print(f"R^2: {1 - np.sum(residual**2) / np.sum((mixture_measured - np.mean(mixture_measured))**2):.4f}")

7. 演習問題

演習問題(クリックして展開)

Basic レベル(基礎理解)

問題1: 波長 450 nm の光の光子エネルギー(eV)を計算してください。

解答を見る

解答:

# コード例1の関数を使用
energy = wavelength_to_energy(450)
print(f"450 nm の光子エネルギー: {energy:.2f} eV")
# 出力: 約2.76 eV

問題2: モル吸光係数が 15000 L mol-1 cm-1、光路長が 1 cm のとき、吸光度 0.6 を示す溶液の濃度を求めてください。

解答を見る

解答:

ランベルト・ベール則より:

$$c = \frac{A}{\varepsilon l} = \frac{0.6}{15000 \times 1} = 4 \times 10^{-5} \text{ mol/L} = 40 \text{ }\mu\text{M}$$

問題3: n→pi* 遷移と pi→pi* 遷移のモル吸光係数の大きさの違いを説明してください。

解答を見る

解答:

n→pi* 遷移は軌道の対称性の観点から禁制遷移(または弱い許容遷移)であり、モル吸光係数は通常 10-100 L mol-1 cm-1 程度と小さい。一方、pi→pi* 遷移は許容遷移であり、モル吸光係数は 103-105 L mol-1 cm-1 と大きい。

Medium レベル(実践的計算)

問題4: 共役ジエンが長くなると吸収極大が長波長側にシフトする理由を、分子軌道論の観点から説明してください。

解答を見る

解答:

共役系が長くなると、HOMO(最高被占分子軌道)のエネルギーは上昇し、LUMO(最低空分子軌道)のエネルギーは低下します。その結果、HOMO-LUMOギャップが小さくなり、励起に必要なエネルギーが減少します。エネルギーが小さくなると、吸収する光の波長は長くなります(深色シフト/レッドシフト)。

問題5: TiO2(アナターゼ相)のバンドギャップを Tauc プロットから決定する場合、n の値は 2 と 1/2 のどちらを使用すべきですか?理由も説明してください。

解答を見る

解答:

TiO2(アナターゼ相)は間接遷移型半導体であるため、n = 1/2 を使用します。間接遷移では、電子の遷移にフォノンの関与が必要であり、遷移確率が直接遷移より低いため、吸収端の立ち上がりが緩やかになります。

問題6: ある有機色素の水溶液で、濃度を 2 倍にしても吸光度が 2 倍にならなかった。考えられる原因を 2 つ挙げてください。

解答を見る

解答:

  • 化学的要因: 高濃度での色素分子の会合(二量体形成など)により、モル吸光係数が変化した。
  • 機器的要因: 吸光度が 1.0 を超えると、透過光強度が弱くなり、検出器のS/N比が低下して正確な測定ができなくなる(ビール則からの機器的偏差)。

Hard レベル(高度な解析)

問題7: 以下の Python コードを完成させ、3 成分混合物のスペクトルから各成分の濃度を決定してください。

解答を見る(完全なコード)
# コード例8 を参照
# multicomponent_analysis 関数を使用して解析

# 参照スペクトルを用意(純成分の単位濃度当たりの吸光度)
reference_spectra = {
    'Dye A': spectrum_A,  # 事前に測定
    'Dye B': spectrum_B,
    'Dye C': spectrum_C
}

# 未知混合物のスペクトル
mixture_spectrum = measured_data  # 測定データ

# 解析実行
concentrations, reconstructed, residual = multicomponent_analysis(
    wavelength, mixture_spectrum, reference_spectra, method='nnls'
)

# 結果表示
for name, conc in concentrations.items():
    print(f"{name}: {conc:.4f}")

問題8: Kubelka-Munk 変換が必要な理由と、その限界について説明してください。

解答を見る

解答:

必要な理由: 粉末試料や不透明試料では透過率を直接測定できないため、拡散反射率から吸収係数を推定する必要があります。Kubelka-Munk関数 F(R) = (1-R)2/(2R) は、吸収係数と散乱係数の比に比例し、吸収スペクトルの近似となります。

限界:

  • 試料の散乱係数が波長に依存する場合、正確な吸収スペクトルが得られない
  • 粒子サイズが不均一な場合、散乱特性が変化する
  • 高吸収領域では精度が低下する
  • 参照標準(BaSO4など)の品質に結果が依存する

問題9: 半導体ナノ粒子のサイズが小さくなるとバンドギャップが大きくなる現象(量子閉じ込め効果)を UV-Vis スペクトルで確認する方法を説明してください。

解答を見る

解答:

量子閉じ込め効果により、ナノ粒子のサイズが減少すると、エネルギー準位の間隔が広がり、有効バンドギャップが増加します。UV-Vis スペクトルでは:

  1. 異なるサイズのナノ粒子懸濁液の吸収スペクトルを測定
  2. 吸収端(onset)の波長を比較:小さい粒子ほど短波長側にシフト(青方シフト)
  3. Tauc プロットからバンドギャップを定量:粒子サイズとバンドギャップの相関をプロット
  4. Brus の式と比較して、量子閉じ込め効果の大きさを評価

問題10: アセトンのカルボニル基の n→pi* 遷移が溶媒の極性によって青方シフト(浅色シフト)する理由を説明してください。

解答を見る

解答:

n→pi* 遷移では、基底状態の孤立電子対(n軌道)が励起状態ではpi*軌道に移動します。

  • 基底状態: 孤立電子対は局在化しており、極性溶媒と強い水素結合や双極子-双極子相互作用を形成し、安定化されます
  • 励起状態: 電子がpi*軌道に励起されると、孤立電子対の局在性が失われ、溶媒との相互作用による安定化が減少します

結果として、極性溶媒中では基底状態がより安定化され、遷移に必要なエネルギーが増加します。これにより、吸収波長が短波長側にシフト(青方シフト)します。

学習目標の確認

この章で学んだ内容を振り返り、以下の項目を確認してください。

基本理解

実践スキル

応用力

参考文献

  1. Skoog, D. A., Holler, F. J., Crouch, S. R. (2017). Principles of Instrumental Analysis (7th ed.). Cengage Learning, pp. 336-380 (UV-Vis spectroscopy), pp. 381-420 (molecular luminescence). - 分光分析の標準的教科書
  2. Perkampus, H. H. (1992). UV-VIS Spectroscopy and Its Applications. Springer-Verlag, pp. 15-45 (electronic transitions), pp. 68-95 (chromophores), pp. 120-150 (applications). - UV-Vis分光法の詳細な解説
  3. Tauc, J. (1968). 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 - Taucプロット法の原著論文
  4. Murphy, A. B. (2007). Band-gap determination from diffuse reflectance measurements of semiconductor films, and application to photoelectrochemical water-splitting. Solar Energy Materials and Solar Cells, 91(14), 1326-1337. DOI: 10.1016/j.solmat.2007.05.005 - バンドギャップ決定法の詳細な解説
  5. Kubelka, P., Munk, F. (1931). Ein Beitrag zur Optik der Farbanstriche. Zeitschrift fur technische Physik, 12, 593-601. - Kubelka-Munk理論の原著論文
  6. Woodward, R. B. (1941). Structure and the absorption spectra of alpha, beta-unsaturated ketones. Journal of the American Chemical Society, 63(4), 1123-1126. - Woodward則(共役系の吸収予測)
  7. SciPy 1.11 Documentation. scipy.optimize.nnls, scipy.signal.savgol_filter. Available at: https://docs.scipy.org/doc/scipy/reference/ - Pythonによるスペクトルデータ処理

次のステップ

第2章では、UV-Vis分光法の原理、ランベルト・ベール則、発色団・助色団、分光光度計の構成、Taucプロットによるバンドギャップ決定を学びました。Pythonを用いた検量線作成、スペクトル前処理、多成分解析の実践的スキルも習得しました。

第3章では、赤外分光法(IR/FTIR)を学びます。分子振動の理論、官能基の特性吸収、フーリエ変換赤外分光法の原理、高分子・有機材料の構造解析など、振動分光法の基礎から応用までをカバーします。

免責事項