CKD Emission Tutorial: ArtEmisPure with OpaCKD
Hajime Kawahara with Claude Code, July 1 (2025)
This tutorial demonstrates how to use the Correlated K-Distribution (CKD) method for atmospheric emission calculations with ExoJAX. The CKD method provides significant computational speedup by pre-computing opacity tables and using quadrature integration over g-ordinates.
What is CKD?
Correlated K-Distribution (CKD) is a method that: - Pre-computes opacity tables on temperature-pressure grids - Groups spectral lines by absorption strength (k-distribution) - Uses Gauss-Legendre quadrature to integrate over g-ordinates - Provides band-averaged spectra with much faster computation
# Import required packages
import numpy as np
import matplotlib.pyplot as plt
from jax import config
# ExoJAX imports
from exojax.test.emulate_mdb import mock_mdbExomol, mock_wavenumber_grid
from exojax.opacity import OpaCKD, OpaPremodit
from exojax.rt import ArtEmisPure
# Enable 64-bit precision for accurate calculations
config.update("jax_enable_x64", True)
print("ExoJAX CKD Tutorial: Emission Spectroscopy")
print("===========================================")
ExoJAX CKD Tutorial: Emission Spectroscopy
===========================================
1. Setup Atmospheric Model and Molecular Database
First, we’ll set up our atmospheric model and molecular opacity database.
# Setup wavenumber grid and molecular database
nu_grid, wav, res = mock_wavenumber_grid()
print(f"Wavenumber grid: {len(nu_grid)} points from {nu_grid[0]:.1f} to {nu_grid[-1]:.1f} cm⁻¹")
print(f"Spectral resolution: {res:.1f}")
# Create mock H2O molecular database
mdb = mock_mdbExomol("H2O")
print(f"Molecular database: {mdb.nurange[0]:.1f} - {mdb.nurange[1]:.1f} cm⁻¹")
# Setup atmospheric radiative transfer
art = ArtEmisPure(
pressure_top=1.0e-8,
pressure_btm=1.0e2,
nlayer=100,
nu_grid=nu_grid
)
print(f"Atmospheric layers: {art.nlayer}")
print(f"Pressure range: {art.pressure_top:.1e} - {art.pressure_btm:.1e} bar")
xsmode = modit xsmode assumes ESLOG in wavenumber space: xsmode=modit Your wavelength grid is in * ascending * order The wavenumber grid is in ascending order by definition. Please be careful when you use the wavelength grid. Wavenumber grid: 20000 points from 4329.0 to 4363.0 cm⁻¹ Spectral resolution: 2556525.8 xsmode = modit xsmode assumes ESLOG in wavenumber space: xsmode=modit Your wavelength grid is in * ascending * order The wavenumber grid is in ascending order by definition. Please be careful when you use the wavelength grid. radis== 0.15.2 HITRAN exact name= H2(16O) radis engine = vaex
/home/kawahara/exojax/src/exojax/utils/grids.py:85: UserWarning: Both input wavelength and output wavenumber are in ascending order.
warnings.warn(
/home/kawahara/exojax/src/exojax/utils/grids.py:85: UserWarning: Both input wavelength and output wavenumber are in ascending order.
warnings.warn(
/home/kawahara/exojax/src/exojax/utils/grids.py:85: UserWarning: Both input wavelength and output wavenumber are in ascending order.
warnings.warn(
/home/kawahara/exojax/src/exojax/utils/grids.py:85: UserWarning: Both input wavelength and output wavenumber are in ascending order.
warnings.warn(
/home/kawahara/exojax/src/exojax/database/api.py:134: UserWarning: The current version of radis does not support broadf_download (requires >=0.16).
warnings.warn(msg, UserWarning)
/home/kawahara/exojax/src/exojax/utils/molname.py:197: FutureWarning: e2s will be replaced to exact_molname_exomol_to_simple_molname.
warnings.warn(
/home/kawahara/exojax/src/exojax/utils/molname.py:91: FutureWarning: exojax.utils.molname.exact_molname_exomol_to_simple_molname will be replaced to radis.api.exomolapi.exact_molname_exomol_to_simple_molname.
warnings.warn(
/home/kawahara/exojax/src/exojax/utils/molname.py:91: FutureWarning: exojax.utils.molname.exact_molname_exomol_to_simple_molname will be replaced to radis.api.exomolapi.exact_molname_exomol_to_simple_molname.
warnings.warn(
Molecule: H2O
Isotopologue: 1H2-16O
Background atmosphere: H2
ExoMol database: None
Local folder: H2O/1H2-16O/SAMPLE
Transition files:
=> File 1H2-16O__SAMPLE__04300-04400.trans
Broadener: H2
Broadening code level: a1
DataFrame (self.df) available.
Molecular database: 4329.0 - 4363.0 cm⁻¹
rtsolver: ibased
Intensity-based n-stream solver, isothermal layer (e.g. NEMESIS, pRT like)
Atmospheric layers: 100
Pressure range: 1.0e-08 - 1.0e+02 bar
2. Define Atmospheric Profile
We’ll create a simple atmospheric profile with temperature and mixing ratio.
# Create atmospheric temperature profile
Tarr = np.linspace(1000.0, 1500.0, 100) # Linear temperature profile
mmr_arr = np.full(100, 0.1) # Constant H2O mixing ratio
gravity = 2478.57 # Surface gravity (cm/s²)
# Plot atmospheric profile
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
# Temperature profile
ax1.semilogy(Tarr, art.pressure)
ax1.set_xlabel('Temperature (K)')
ax1.set_ylabel('Pressure (bar)')
ax1.set_title('Temperature Profile')
ax1.grid(True, alpha=0.3)
ax1.invert_yaxis()
# Mixing ratio profile
ax2.semilogy(mmr_arr, art.pressure)
ax2.set_xlabel('H₂O Mixing Ratio')
ax2.set_ylabel('Pressure (bar)')
ax2.set_title('H₂O Mixing Ratio Profile')
ax2.grid(True, alpha=0.3)
ax2.invert_yaxis()
plt.tight_layout()
plt.show()
print(f"Temperature range: {np.min(Tarr):.0f} - {np.max(Tarr):.0f} K")
print(f"H2O mixing ratio: {mmr_arr[0]:.1f} (constant)")

Temperature range: 1000 - 1500 K
H2O mixing ratio: 0.1 (constant)
3. Setup Standard Line-by-Line Opacity Calculator
First, we’ll compute the standard high-resolution spectrum using line-by-line calculations.
# Initialize standard opacity calculator (Premodit)
base_opa = OpaPremodit(mdb, nu_grid, auto_trange=[500.0, 1500.0])
# Compute line-by-line cross-sections and emission spectrum
print("\nComputing line-by-line emission spectrum...")
xsmatrix = base_opa.xsmatrix(Tarr, art.pressure)
dtau = art.opacity_profile_xs(xsmatrix, mmr_arr, base_opa.mdb.molmass, gravity)
F0_lbl = art.run(dtau, Tarr)
print(f"Line-by-line spectrum computed!")
OpaPremodit: params automatically set.
default elower grid trange (degt) file version: 2
Robust range: 485.7803992045456 - 1514.171191195336 K
OpaPremodit: Tref_broadening is set to 866.0254037844389 K
max value of ngamma_ref_grid : 25.22068521876662
min value of ngamma_ref_grid : 14.029708313440466
ngamma_ref_grid grid : [14.02970695 16.24522392 18.81060491 21.78110064 25.22068787]
max value of n_Texp_grid : 0.541
min value of n_Texp_grid : 0.216
n_Texp_grid grid : [0.21599999 0.3785 0.54100007]
uniqidx: 100%|██████████| 4/4 [00:00<00:00, 19021.79it/s]
Premodit: Twt= 1108.7151960064205 K Tref= 570.4914318566549 K
Making LSD:|####################| 100%
Computing line-by-line emission spectrum...
Line-by-line spectrum computed!
4. Setup CKD Opacity Calculator
Now we’ll initialize the CKD opacity calculator and pre-compute the opacity tables.
# Initialize CKD opacity calculator
opa_ckd = OpaCKD(
base_opa, # Base opacity calculator
Ng=32, # Number of g-ordinates for quadrature
band_width=0.5 # Spectral band width
)
print(f"CKD Opacity Calculator Setup:")
print(f" Number of g-ordinates (Ng): {opa_ckd.Ng}")
print(f" Band width: {opa_ckd.band_width}")
print(f" Number of spectral bands: {len(opa_ckd.nu_bands)}")
print(f" Spectral range: {opa_ckd.nu_bands[0]:.1f} - {opa_ckd.nu_bands[-1]:.1f} cm⁻¹")
# Pre-compute CKD tables on temperature-pressure grid
print("\nPre-computing CKD tables...")
NTgrid = 10
NPgrid = 10
T_grid = np.linspace(np.min(Tarr), np.max(Tarr), NTgrid)
P_grid = np.logspace(
np.log10(np.min(art.pressure)),
np.log10(np.max(art.pressure)),
NPgrid,
)
opa_ckd.precompute_tables(T_grid, P_grid)
print(f"CKD tables computed on {NTgrid}×{NPgrid} T-P grid")
print(f"Temperature grid: {T_grid[0]:.0f} - {T_grid[-1]:.0f} K")
print(f"Pressure grid: {P_grid[0]:.1e} - {P_grid[-1]:.1e} bar")
CKD Opacity Calculator Setup:
Number of g-ordinates (Ng): 32
Band width: 0.5
Number of spectral bands: 68
Spectral range: 4329.3 - 4362.8 cm⁻¹
Pre-computing CKD tables...
Generated g-grid: 32 points, range [0.0014, 0.9986]
Processing 68 spectral bands...
Band 1: [4329.0, 4329.5] cm⁻¹, 295 frequencies
Band 2: [4329.5, 4330.0] cm⁻¹, 294 frequencies
Band 3: [4330.0, 4330.5] cm⁻¹, 294 frequencies
Band 4: [4330.5, 4331.0] cm⁻¹, 294 frequencies
Band 5: [4331.0, 4331.5] cm⁻¹, 294 frequencies
Band 6: [4331.5, 4332.0] cm⁻¹, 294 frequencies
Band 7: [4332.0, 4332.5] cm⁻¹, 294 frequencies
Band 8: [4332.5, 4333.0] cm⁻¹, 294 frequencies
Band 9: [4333.0, 4333.5] cm⁻¹, 294 frequencies
Band 10: [4333.5, 4334.0] cm⁻¹, 295 frequencies
Band 11: [4334.0, 4334.5] cm⁻¹, 294 frequencies
Band 12: [4334.5, 4335.0] cm⁻¹, 294 frequencies
Band 13: [4335.0, 4335.5] cm⁻¹, 294 frequencies
Band 14: [4335.5, 4336.0] cm⁻¹, 294 frequencies
Band 15: [4336.0, 4336.5] cm⁻¹, 294 frequencies
Band 16: [4336.5, 4337.0] cm⁻¹, 294 frequencies
Band 17: [4337.0, 4337.5] cm⁻¹, 294 frequencies
Band 18: [4337.5, 4338.0] cm⁻¹, 294 frequencies
Band 19: [4338.0, 4338.5] cm⁻¹, 294 frequencies
Band 20: [4338.5, 4339.0] cm⁻¹, 295 frequencies
Band 21: [4339.0, 4339.5] cm⁻¹, 294 frequencies
Band 22: [4339.5, 4340.0] cm⁻¹, 294 frequencies
Band 23: [4340.0, 4340.5] cm⁻¹, 294 frequencies
Band 24: [4340.5, 4341.0] cm⁻¹, 294 frequencies
Band 25: [4341.0, 4341.5] cm⁻¹, 294 frequencies
Band 26: [4341.5, 4342.0] cm⁻¹, 294 frequencies
Band 27: [4342.0, 4342.5] cm⁻¹, 294 frequencies
Band 28: [4342.5, 4343.0] cm⁻¹, 294 frequencies
Band 29: [4343.0, 4343.5] cm⁻¹, 294 frequencies
Band 30: [4343.5, 4344.0] cm⁻¹, 295 frequencies
Band 31: [4344.0, 4344.5] cm⁻¹, 294 frequencies
Band 32: [4344.5, 4345.0] cm⁻¹, 294 frequencies
Band 33: [4345.0, 4345.5] cm⁻¹, 294 frequencies
Band 34: [4345.5, 4346.0] cm⁻¹, 294 frequencies
Band 35: [4346.0, 4346.5] cm⁻¹, 294 frequencies
Band 36: [4346.5, 4347.0] cm⁻¹, 294 frequencies
Band 37: [4347.0, 4347.5] cm⁻¹, 294 frequencies
Band 38: [4347.5, 4348.0] cm⁻¹, 294 frequencies
Band 39: [4348.0, 4348.5] cm⁻¹, 295 frequencies
Band 40: [4348.5, 4349.0] cm⁻¹, 294 frequencies
Band 41: [4349.0, 4349.5] cm⁻¹, 294 frequencies
Band 42: [4349.5, 4350.0] cm⁻¹, 294 frequencies
Band 43: [4350.0, 4350.5] cm⁻¹, 294 frequencies
Band 44: [4350.5, 4351.0] cm⁻¹, 294 frequencies
Band 45: [4351.0, 4351.5] cm⁻¹, 294 frequencies
Band 46: [4351.5, 4352.0] cm⁻¹, 294 frequencies
Band 47: [4352.0, 4352.5] cm⁻¹, 294 frequencies
Band 48: [4352.5, 4353.0] cm⁻¹, 294 frequencies
Band 49: [4353.0, 4353.5] cm⁻¹, 295 frequencies
Band 50: [4353.5, 4354.0] cm⁻¹, 294 frequencies
Band 51: [4354.0, 4354.5] cm⁻¹, 294 frequencies
Band 52: [4354.5, 4355.0] cm⁻¹, 294 frequencies
Band 53: [4355.0, 4355.5] cm⁻¹, 294 frequencies
Band 54: [4355.5, 4356.0] cm⁻¹, 294 frequencies
Band 55: [4356.0, 4356.5] cm⁻¹, 294 frequencies
Band 56: [4356.5, 4357.0] cm⁻¹, 294 frequencies
Band 57: [4357.0, 4357.5] cm⁻¹, 294 frequencies
Band 58: [4357.5, 4358.0] cm⁻¹, 294 frequencies
Band 59: [4358.0, 4358.5] cm⁻¹, 295 frequencies
Band 60: [4358.5, 4359.0] cm⁻¹, 294 frequencies
Band 61: [4359.0, 4359.5] cm⁻¹, 294 frequencies
Band 62: [4359.5, 4360.0] cm⁻¹, 294 frequencies
Band 63: [4360.0, 4360.5] cm⁻¹, 294 frequencies
Band 64: [4360.5, 4361.0] cm⁻¹, 294 frequencies
Band 65: [4361.0, 4361.5] cm⁻¹, 294 frequencies
Band 66: [4361.5, 4362.0] cm⁻¹, 294 frequencies
Band 67: [4362.0, 4362.5] cm⁻¹, 294 frequencies
Band 68: [4362.5, 4363.0] cm⁻¹, 295 frequencies
Creating CKD table info...
CKD precomputation complete! Ready for interpolation.
Table dimensions: T=10, P=10, g=32, bands=68
CKD tables computed on 10×10 T-P grid
Temperature grid: 1000 - 1500 K
Pressure grid: 1.0e-08 - 1.0e+02 bar
5. Compute CKD Emission Spectrum
Now we’ll compute the emission spectrum using the CKD method.
# Get CKD cross-section tensor and compute CKD spectrum
print("Computing CKD emission spectrum...")
xs_ckd = opa_ckd.xstensor_ckd(Tarr, art.pressure)
dtau_ckd = art.opacity_profile_xs_ckd(
xs_ckd, mmr_arr, base_opa.mdb.molmass, gravity
)
print(f"CKD optical depth tensor shape: {dtau_ckd.shape}")
print(f" Layers: {dtau_ckd.shape[0]}")
print(f" G-ordinates: {dtau_ckd.shape[1]}")
print(f" Spectral bands: {dtau_ckd.shape[2]}")
# Run CKD emission calculation
F0_ckd = art.run_ckd(
dtau_ckd, Tarr, opa_ckd.ckd_info.weights, opa_ckd.nu_bands
)
print(f"\nCKD spectrum computed!")
print(f"CKD flux shape: {F0_ckd.shape}")
print(f"CKD flux range: [{np.min(F0_ckd):.2e}, {np.max(F0_ckd):.2e}] erg/s/cm²/Hz")
Computing CKD emission spectrum...
CKD optical depth tensor shape: (100, 32, 68)
Layers: 100
G-ordinates: 32
Spectral bands: 68
CKD spectrum computed!
CKD flux shape: (68,)
CKD flux range: [2.61e+04, 3.53e+04] erg/s/cm²/Hz
6. Compute Reference Band Averages
To validate the CKD results, we’ll compute reference band averages from the line-by-line spectrum.
# Compute reference band averages by direct integration
print("Computing reference band averages...")
flux_average_reference = []
band_edges = opa_ckd.band_edges
for band_idx in range(len(opa_ckd.nu_bands)):
# Create mask for frequencies within this band
mask = (band_edges[band_idx, 0] <= nu_grid) & (
nu_grid < band_edges[band_idx, 1]
)
# Arithmetic average over the band
flux_average_reference.append(np.mean(F0_lbl[mask]))
flux_average_reference = np.array(flux_average_reference)
print(f"Reference band averages computed for {len(flux_average_reference)} bands")
Computing reference band averages...
Reference band averages computed for 68 bands
7. Compare Results and Validate Accuracy
Let’s compare the CKD results with both the high-resolution line-by-line spectrum and the reference band averages.
# Calculate accuracy metrics
res = np.sqrt(np.sum((F0_ckd - flux_average_reference)**2)/len(F0_ckd))/np.mean(flux_average_reference)
max_relative_error = np.max(np.abs((F0_ckd - flux_average_reference) / flux_average_reference))
mean_relative_error = np.mean(np.abs((F0_ckd - flux_average_reference) / flux_average_reference))
print(f"CKD Accuracy Assessment:")
print(f" RMS relative error: {res:.4f}")
print(f" Maximum relative error: {max_relative_error:.4f}")
print(f" Mean relative error: {mean_relative_error:.4f}")
# Calculate spectral resolution
resolution = opa_ckd.nu_bands[0]/(band_edges[0, 1] - band_edges[0, 0])
print(f" Effective resolution: {resolution:.1f}")
# Check if accuracy meets typical requirements
accuracy_threshold = 0.05 # 5% error threshold
if res < accuracy_threshold:
print(f"✓ CKD accuracy meets requirement (< {accuracy_threshold:.1%})")
else:
print(f"⚠ CKD error exceeds threshold ({accuracy_threshold:.1%})")
CKD Accuracy Assessment:
RMS relative error: 0.0009
Maximum relative error: 0.0018
Mean relative error: 0.0008
Effective resolution: 8692.6
✓ CKD accuracy meets requirement (< 5.0%)
8. Visualize Results
Finally, let’s create a comprehensive comparison plot showing all three spectra.
# Create comparison plot
plt.figure(figsize=(7, 4))
# Plot line-by-line spectrum (high resolution)
plt.plot(nu_grid, F0_lbl,
label="Line-by-Line (Premodit)",
alpha=0.7, linewidth=0.8, color='lightblue')
# Plot CKD spectrum
plt.plot(opa_ckd.nu_bands, F0_ckd,
'o-', label="CKD Method",
markersize=4, linewidth=2, color='red')
# Plot reference band averages
plt.plot(opa_ckd.nu_bands, flux_average_reference,
's-', label="Reference Band Average",
markersize=3, linewidth=1.5, color='black', alpha=0.8)
plt.xlabel('Wavenumber (cm⁻¹)', fontsize=12)
plt.ylabel('Flux (erg/s/cm²/Hz)', fontsize=12)
plt.title(f'CKD vs Line-by-Line Emission Spectrum\n'
f'Resolution: {resolution:.0f}, RMS Error: {res:.4f}', fontsize=14)
plt.legend(fontsize=11)
plt.grid(True, alpha=0.3)
plt.yscale('log')
# Add text box with key parameters
textstr = f'Ng = {opa_ckd.Ng}\nBands = {len(opa_ckd.nu_bands)}\nLayers = {art.nlayer}'
props = dict(boxstyle='round', facecolor='wheat', alpha=0.8)
plt.text(0.02, 0.98, textstr, transform=plt.gca().transAxes, fontsize=10,
verticalalignment='top', bbox=props)
plt.tight_layout()
plt.show()
# Save the figure
plt.savefig(f"ckd_emission_comparison_res{resolution:.0f}.png",
dpi=300, bbox_inches='tight')
print(f"Figure saved as: ckd_emission_comparison_res{resolution:.0f}.png")

Figure saved as: ckd_emission_comparison_res8693.png
<Figure size 640x480 with 0 Axes>
9. Performance Comparison
Let’s demonstrate the computational speedup achieved by the CKD method.
import time
# Time line-by-line calculation
start_time = time.time()
for _ in range(5): # Multiple runs for better timing
xsmatrix = base_opa.xsmatrix(Tarr, art.pressure)
dtau = art.opacity_profile_xs(xsmatrix, mmr_arr, base_opa.mdb.molmass, gravity)
F0_lbl_timing = art.run(dtau, Tarr)
lbl_time = (time.time() - start_time) / 5
# Time CKD calculation (excluding table pre-computation)
start_time = time.time()
for _ in range(5):
xs_ckd = opa_ckd.xstensor_ckd(Tarr, art.pressure)
dtau_ckd = art.opacity_profile_xs_ckd(xs_ckd, mmr_arr, base_opa.mdb.molmass, gravity)
F0_ckd_timing = art.run_ckd(dtau_ckd, Tarr, opa_ckd.ckd_info.weights, opa_ckd.nu_bands)
ckd_time = (time.time() - start_time) / 5
speedup = lbl_time / ckd_time
print(f"Performance Comparison:")
print(f" Line-by-Line time: {lbl_time:.3f} seconds")
print(f" CKD time: {ckd_time:.3f} seconds")
print(f" Speedup factor: {speedup:.1f}×")
print(f" Spectral points: {len(nu_grid)} → {len(opa_ckd.nu_bands)} ({len(opa_ckd.nu_bands)/len(nu_grid):.1%})")
Performance Comparison:
Line-by-Line time: 0.130 seconds
CKD time: 0.075 seconds
Speedup factor: 1.7×
Spectral points: 20000 → 68 (0.3%)
Summary
This tutorial demonstrated how to use the CKD method with ExoJAX for emission spectroscopy:
Key Steps:
Setup: Initialize atmospheric model and molecular database
Profile: Define temperature and mixing ratio profiles
Line-by-Line: Compute high-resolution reference spectrum
CKD Setup: Initialize CKD calculator and pre-compute tables
CKD Calculation: Compute band-averaged spectrum using CKD
Validation: Compare CKD results with reference data
Visualization: Plot comparison and analyze accuracy