Tutorial 7: 3D Dust Mapping#
This tutorial demonstrates line-of-sight dust modeling with brutus, fitting multi-cloud models to stellar distance-extinction posteriors using nested sampling.
Topics Covered#
Loading stellar posteriors for distance and extinction
Line-of-sight dust modeling with multi-cloud models
LOS dust fitting with nested sampling (dynesty)
Prerequisites#
This tutorial optionally uses:
Stellar posterior samples from Tutorial 5
The tutorial will generate synthetic data if these files are not available.
# Imports and setup
import numpy as np
import matplotlib.pyplot as plt
from pathlib import Path
import warnings
warnings.filterwarnings('ignore')
import h5py
# Import tutorial utilities
from tutorial_utils import (
set_plot_style,
find_brutus_data_file,
save_figure as save_fig_util,
)
# Set plot style
set_plot_style()
plt.rcParams['figure.figsize'] = (10, 6)
# Create plots directory if needed
plots_dir = Path('plots/tutorial_07')
plots_dir.mkdir(parents=True, exist_ok=True)
def save_figure(fig, name):
"""Helper to save figures."""
filepath = plots_dir / f"{name}.png"
fig.savefig(filepath, dpi=150, bbox_inches='tight')
print(f" Saved: {filepath}")
Line-of-Sight Dust Modeling#
Real dust distributions often have discrete cloud structures along the line of sight.
The brutus.analysis.los_dust module provides tools to fit multi-cloud models to
stellar extinction measurements, decomposing the cumulative extinction into a
foreground component plus discrete cloud jumps at specific distances.
Input Data#
The LOS fitter works with posterior samples from BruteForce fits. Each star contributes distance and extinction samples that collectively trace the distance-reddening relation along the sightline.
Let’s load stellar posteriors and examine the distance-extinction structure.
Note: The LOS dust fitting in this tutorial uses the dynesty nested sampling package. Install it with
pip install dynestyif you don’t have it already.
# Try to load real stellar posterior samples or generate synthetic ones
print("Preparing stellar posterior samples for LOS dust modeling...\n")
try:
# Try to find results from Tutorial 5 (Orion field, no-dust-prior fit)
datafile = Path('Orion_l209.1_b-19.9_mist_nodust.h5')
if not datafile.exists():
datafile = find_brutus_data_file("Orion_l209.1_b-19.9_mist_nodust.h5")
with h5py.File(datafile, 'r') as f:
# Load distance and extinction samples
dist_samples = f['samps_dist'][:] if 'samps_dist' in f else None
av_samples = f['samps_red'][:] if 'samps_red' in f else None
chi2 = f['obj_chi2min'][:] if 'obj_chi2min' in f else None
nbands = f['obj_Nbands'][:] if 'obj_Nbands' in f else None
if dist_samples is not None and av_samples is not None:
print(f"Loaded {len(dist_samples)} stellar posterior samples from file")
# Apply a loose chi2 cut to remove poor fits
if chi2 is not None and nbands is not None:
from scipy.stats import chi2 as chi2_dist
# Goodness-of-fit p-value, not chi2/Nbands.
dof = np.maximum(nbands - 3, 1)
pvals = chi2_dist.sf(chi2, dof)
good = pvals > 1e-6 # drop only the worst fits
dist_samples = dist_samples[good]
av_samples = av_samples[good]
print(f" After p-value > 1e-6 cut: {len(dist_samples)} stars kept")
# Convert distances to distance modulus for LOS fitting
dm_samples = 5 * np.log10(dist_samples * 1000) - 5 # kpc to distance modulus
source = "Real Data"
else:
raise ValueError("No distance/extinction samples in file")
except (FileNotFoundError, ValueError, KeyError) as e:
# Generate synthetic samples
print("Generating synthetic stellar samples...")
print(f" (Real data not available: {e})\n")
n_stars = 50
n_samples = 100
# Create synthetic distance modulus samples
# Simulate stars at various distances with measurement uncertainties
true_dm = np.random.uniform(7, 10, n_stars) # True distance moduli
dm_error = np.random.uniform(0.1, 0.3, n_stars) # Uncertainties
dm_samples = np.zeros((n_stars, n_samples))
for i in range(n_stars):
dm_samples[i] = np.random.normal(true_dm[i], dm_error[i], n_samples)
# Create synthetic extinction samples (correlated with distance)
# Simulate a dust cloud at distance modulus ~8.5
cloud_dm = 8.5
cloud_width = 0.3
av_samples = np.zeros((n_stars, n_samples))
for i in range(n_stars):
# Background extinction
av_bg = 0.3
# Add cloud extinction for stars behind it
for j in range(n_samples):
if dm_samples[i, j] > cloud_dm:
av_cloud = 1.5 * (1 - np.exp(-(dm_samples[i, j] - cloud_dm) / cloud_width))
av_samples[i, j] = av_bg + av_cloud + np.random.normal(0, 0.1)
else:
av_samples[i, j] = av_bg * (dm_samples[i, j] / cloud_dm) + np.random.normal(0, 0.1)
av_samples = np.maximum(0, av_samples) # No negative extinction
source = "Synthetic"
print(f"Using {source} stellar samples:")
print(f" Stars: {len(dm_samples)}")
print(f" Samples per star: {dm_samples.shape[1]}")
print(f" Distance modulus range: [{dm_samples.min():.1f}, {dm_samples.max():.1f}]")
print(f" A_V range: [{av_samples.min():.2f}, {av_samples.max():.2f}] mag")
Fitting a LOS Dust Model with Dynesty#
The LOS fitter uses two key functions designed for nested sampling:
los_clouds_priortransform(u, rlims, dlims): Transforms unit cube draws into physical parameters. For an $n$-cloud model the parameter vector has $2n + 4$ elements: $[p_b, s_{\text{fore}}, s_{\text{back}}, A_{V,\text{fore}}, d_1, A_{V,1}, \ldots, d_n, A_{V,n}]$los_clouds_loglike_samples(theta, dsamps, rsamps, ...): Log-likelihood of the cloud model given stellar distance/reddening posterior samples.
For a 1-cloud model (ndim=6), the parameters are:
Parameter |
Meaning |
|---|---|
$p_b$ |
Outlier fraction |
$s_{\text{fore}}$ |
Foreground smoothing scale |
$s_{\text{back}}$ |
Background smoothing scale |
$A_{V,\text{fore}}$ |
Foreground extinction (before cloud) |
$d_1$ |
Cloud distance (distance modulus) |
$A_{V,1}$ |
Total extinction (after cloud) |
Let’s run a real nested sampling fit on the stellar posteriors.
# Run LOS dust fit with dynesty
from brutus.analysis import los_clouds_priortransform, los_clouds_loglike_samples
import dynesty
from dynesty.utils import resample_equal
# Set up 1-cloud model (ndim = 6)
ndim = 6
# Determine appropriate bounds from the data
dm_range = (float(dm_samples.min()) - 0.5, float(dm_samples.max()) + 0.5)
av_range = (0.0, max(3.0, float(av_samples.max()) + 0.5))
print(f"LOS fit setup:")
print(f" Model: 1-cloud ({ndim} parameters)")
print(f" Stars: {dm_samples.shape[0]}")
print(f" Samples per star: {dm_samples.shape[1]}")
print(f" Distance modulus bounds: [{dm_range[0]:.1f}, {dm_range[1]:.1f}]")
print(f" A_V bounds: [{av_range[0]:.1f}, {av_range[1]:.1f}]")
def prior_transform(u):
return los_clouds_priortransform(u, rlims=av_range, dlims=dm_range)
def loglike(theta):
return los_clouds_loglike_samples(
theta, dm_samples, av_samples,
kernel='gauss', rlims=av_range, Ndraws=25, monotonic=True
)
# Run nested sampling (rslice sampler is more efficient for this problem)
import time
print("\nRunning nested sampling...")
t0 = time.time()
sampler = dynesty.NestedSampler(loglike, prior_transform, ndim,
nlive=200, sample='rslice')
sampler.run_nested(print_progress=True, maxcall=250000)
results = sampler.results
t_run = time.time() - t0
print(f" Completed in {t_run:.1f}s")
print(f" Log-evidence: {results.logz[-1]:.1f} +/- {results.logzerr[-1]:.1f}")
print(f" Iterations: {results.niter}")
# Extract weighted posterior
weights = np.exp(results.logwt - results.logz[-1])
weights /= weights.sum()
posterior = resample_equal(results.samples, weights)
param_names = ['P_b', 's_fore', 's_back', 'A_V_fore', 'd_cloud (DM)', 'A_V_total']
print(f"\nPosterior summary (median [16th, 84th]):")
for i, name in enumerate(param_names):
med = np.median(posterior[:, i])
lo, hi = np.percentile(posterior[:, i], [16, 84])
print(f" {name:20s}: {med:.3f} [{lo:.3f}, {hi:.3f}]")
# Convert cloud distance to kpc
dm_cloud = np.median(posterior[:, 4])
dist_kpc = 10**((dm_cloud + 5) / 5) / 1000
delta_av = np.median(posterior[:, 5] - posterior[:, 3])
print(f"\nCloud properties:")
print(f" Distance: {dist_kpc:.2f} kpc (DM = {dm_cloud:.1f})")
print(f" Foreground A_V: {np.median(posterior[:, 3]):.3f} mag")
print(f" Extinction jump at cloud: {delta_av:.3f} mag")
Summary and Key Takeaways#
This tutorial has demonstrated line-of-sight dust modeling with brutus:
Key Concepts#
LOS Dust Modeling (
brutus.analysis.los_dust)Multi-cloud models fit discrete dust structures along the line of sight
Prior transform + log-likelihood designed for nested sampling (dynesty)
Compare evidence (logZ) across models with different numbers of clouds
Stellar Posteriors
Per-star distance and extinction samples trace the distance-reddening relation
bin_pdfs_distredbins and smooths posteriors for visualization
Next Steps#
Tutorial 8: Photometric Calibration
Try fitting 2-cloud and 3-cloud models and comparing evidence
Apply LOS fitting across multiple sightlines for a 3D dust map
print("Tutorial 7 Complete!")
print("=" * 60)
print("\nGenerated plots:")
for plot_file in sorted(plots_dir.glob('*.png')):
print(f" - {plot_file.name}")
Tutorial 7 Complete!
============================================================
Generated plots:
- dust_sightlines.png
- kernel_comparison.png
- los_dust_fit.png
- stellar_samples.png