Spectroscopy is one of the most powerful tools in an astronomer's toolkit. By splitting light into its component wavelengths, we can learn about a source's chemical composition, temperature, velocity, and much more.
In this tutorial we walk through a realistic workflow: downloading a real SDSS optical
spectrum, loading it with FITSIO.jl, wrapping
it in a Spectrum object with physical units via
UnitfulAstro.jl, applying dust corrections
with DustExtinction.jl, propagating
uncertainties with Measurements.jl,
and computing cosmological distances with
Cosmology.jl.
!!! note
This tutorial uses Julia 1.9+. Spectra.jl is under active development and installed
from GitHub. Code blocks show the intended API; some call signatures may evolve.
pkg> add FITSIO Unitful UnitfulAstro DustExtinction Measurements Cosmology Plots
pkg> add https://github.com/JuliaAstro/Spectra.jl
| Package | Role |
|---|---|
FITSIO |
Read spectral data from FITS binary tables |
Spectra |
Core Spectrum type, blackbody, reddening |
DustExtinction |
Empirical extinction laws (CCM89, OD94, CAL00) |
Unitful / UnitfulAstro |
Physical units — Å, nm, Jy, erg/s/cm²/Å |
Measurements |
Automatic uncertainty propagation |
Cosmology |
ΛCDM luminosity distances |
Plots |
Visualisation |
We work with a publicly available spectrum from SDSS DR14 — a galaxy observed on plate 1323, MJD 52797, fiber 12. The FITS file stores the coadded spectrum as a binary table with log₁₀-spaced wavelengths and calibrated flux in units of 10⁻¹⁷ erg/s/cm²/Å.
using Downloads
sdss_url = "https://dr14.sdss.org/optical/spectrum/view/data/format=fits/spec=lite" *
"?plateid=1323&mjd=52797&fiberid=12"
sdss_file = joinpath(@__DIR__, "sdss_example.fits")
if !isfile(sdss_file)
Downloads.download(sdss_url, sdss_file)
endusing FITSIO, Unitful, UnitfulAstro
f = FITS(sdss_file)
loglam = read(f[2], "loglam")
flux_raw = read(f[2], "flux")
ivar = read(f[2], "ivar")
wave = (10 .^ loglam) * u"angstrom"
flux = (flux_raw .* 1e-17) * u"erg/s/cm^2/angstrom"
close(f)
println("Wavelength range : ", minimum(wave), " — ", maximum(wave))
println("Number of pixels : ", length(wave))using Spectra
spec = spectrum(wave, flux)
println(spec)using Plots
waves = spectral_axis(spec)
fluxes = flux_axis(spec)
plot(ustrip.(waves), ustrip.(fluxes),
xlabel = "Wavelength (Å)",
ylabel = "Flux (erg s⁻¹ cm⁻² Å⁻¹)",
title = "SDSS Galaxy — plate 1323, fiber 12",
legend = false, lw = 0.5, color = :steelblue, size = (800, 400))ha_wave = 6563.0u"angstrom"
ha_nm = uconvert(u"nm", ha_wave)
ha_um = uconvert(u"μm", ha_wave)
println("Hα = $ha_wave = $ha_nm = $ha_um")Radio and infrared data are usually stored as Fν (flux per unit frequency, often in Jansky). The conversion is Fν = Fλ × λ² / c.
using Unitful: c0
Flambda = 2.5e-17u"erg/s/cm^2/angstrom"
Fnu = Flambda * ha_wave^2 / c0 |> u"erg/s/cm^2/Hz"
Fnu_jy = uconvert(u"Jy", Fnu)
println("Fλ = $Flambda")
println("Fν = $Fnu = $Fnu_jy")Interstellar dust makes sources appear fainter and redder — it preferentially absorbs
blue photons. Spectra.jl integrates with DustExtinction.jl for one-line corrections.
using DustExtinction
wave_bb = range(3000, 10000, length = 500)
bb = blackbody(wave_bb, 10_000)
bb_05 = redden(bb, 0.5)
bb_15 = redden(bb, 1.5)
bb_30 = redden(bb, 3.0)
plot(spectral_axis(bb), ustrip.(flux_axis(bb)), label = "Av = 0", lw = 2)
plot!(spectral_axis(bb), ustrip.(flux_axis(bb_05)), label = "Av = 0.5", lw = 2)
plot!(spectral_axis(bb), ustrip.(flux_axis(bb_15)), label = "Av = 1.5", lw = 2)
plot!(spectral_axis(bb), ustrip.(flux_axis(bb_30)), label = "Av = 3.0", lw = 2)
plot!(xlabel = "Wavelength (Å)", ylabel = "Flux",
title = "Effect of dust extinction (T = 10 000 K blackbody)",
size = (800, 400))bb_ccm = redden(bb, 1.0, law = CCM89)
bb_od94 = redden(bb, 1.0, law = OD94)
bb_cal = redden(bb, 1.0, law = CAL00)spec_dered = deredden(spec, 0.1)
ws = ustrip.(spectral_axis(spec))
plot(ws, ustrip.(flux_axis(spec)), label = "Observed", lw = 0.5, color = :gray, alpha = 0.6)
plot!(ws, ustrip.(flux_axis(spec_dered)), label = "Dereddened", lw = 0.5, color = :steelblue)
plot!(xlabel = "Wavelength (Å)", ylabel = "Flux (erg s⁻¹ cm⁻² Å⁻¹)",
title = "Dereddening the SDSS spectrum", size = (800, 400))Spectra.jl provides spectral_axis and flux_axis helpers to extract the wavelength
and flux arrays from any Spectrum object. This is useful for passing data to external
fitting routines.
waves = spectral_axis(spec_dered)
fluxes = flux_axis(spec_dered)
println("First wavelength : ", waves[1])
println("Last wavelength : ", waves[end])
# Zoom around Hα (6563 Å)
ha_mask = (ustrip.(waves) .> 6400) .& (ustrip.(waves) .< 6700)
plot(ustrip.(waves[ha_mask]), ustrip.(fluxes[ha_mask]),
xlabel = "Wavelength (Å)", ylabel = "Flux (erg s⁻¹ cm⁻² Å⁻¹)",
title = "SDSS spectrum near Hα (6563 Å)",
lw = 1.5, color = :steelblue, legend = false, size = (800, 400))!!! note
Chebyshev continuum fitting (continuum()) is planned for a future Spectra.jl
release — see JuliaAstro/Spectra.jl#41.
For now, SpectralFitting.jl provides full continuum and line fitting with
OGIP-compatible models.
using Measurements
wave_m = range(4000, 7000, length = 200) * u"angstrom"
flux_m = ((5e-17 .± 0.5e-17) .* ones(200)) * u"erg/s/cm^2/angstrom"
spec_m = spectrum(wave_m, flux_m)
spec_red = redden(spec_m, 0.5)
println("Original flux[1] : ", flux_axis(spec_m)[1])
println("Reddened flux[1] : ", flux_axis(spec_red)[1])wave_synth = range(1000, 20000, length = 1000)
bb_3k = blackbody(wave_synth, 3_000)
bb_6k = blackbody(wave_synth, 6_000)
bb_10k = blackbody(wave_synth, 10_000)
bb_30k = blackbody(wave_synth, 30_000)
plot(spectral_axis(bb_3k), ustrip.(flux_axis(bb_3k)), label = "3 000 K", lw = 2, color = :red)
plot!(spectral_axis(bb_6k), ustrip.(flux_axis(bb_6k)), label = "6 000 K", lw = 2, color = :orange)
plot!(spectral_axis(bb_10k), ustrip.(flux_axis(bb_10k)), label = "10 000 K", lw = 2, color = :steelblue)
plot!(spectral_axis(bb_30k), ustrip.(flux_axis(bb_30k)), label = "30 000 K", lw = 2, color = :purple)
plot!(xlabel = "Wavelength (Å)", ylabel = "Flux",
title = "Planck blackbodies at four temperatures", size = (800, 400))wave_a = range(4000, 8000, length = 500)
source = blackbody(wave_a, 8_000)
sky = blackbody(wave_a, 300) * 0.1
science = source - sky
plot(spectral_axis(source), ustrip.(flux_axis(source)), label = "Source", lw = 2)
plot!(spectral_axis(sky), ustrip.(flux_axis(sky)), label = "Sky", lw = 2)
plot!(spectral_axis(science),ustrip.(flux_axis(science)), label = "Source − Sky", lw = 2, ls = :dash)
plot!(xlabel = "Wavelength (Å)", ylabel = "Flux",
title = "Spectral arithmetic: sky subtraction", size = (800, 400))using Cosmology
cosmo = cosmology()
for z in [0.1, 0.5, 1.0, 2.0]
d = luminosity_dist(cosmo, z)
println("z = $z → dL = ", round(typeof(d), d, digits = 0))
end
wave_rest = range(3000, 8000, length = 500)
bb_rest = blackbody(wave_rest, 6_000)
p = plot(xlabel = "Observed wavelength (Å)", ylabel = "Flux",
title = "Cosmological redshift", size = (800, 400))
for (z, c) in zip([0.0, 0.5, 1.0, 2.0], [:black, :blue, :green, :red])
wave_obs = wave_rest .* (1 + z)
flux_obs = flux_axis(bb_rest) ./ (1 + z)^2
spec_z = spectrum(wave_obs, flux_obs)
lbl = z == 0 ? "z = 0 (rest frame)" : "z = $z"
plot!(p, spectral_axis(spec_z), ustrip.(flux_axis(spec_z)), label = lbl, lw = 2, color = c)
end
p- Loading real spectral data from SDSS FITS files with
FITSIO.jl - Physical units with
Unitful.jl+UnitfulAstro.jl - Dust extinction with CCM89, OD94, CAL00 laws via
DustExtinction.jl - Spectral axis inspection using
spectral_axisandflux_axis - Uncertainty propagation via
Measurements.jl - Synthetic blackbody spectra from
Spectra.jl - Spectral arithmetic — sky subtraction, scaling
- Cosmological redshift and luminosity distances via
Cosmology.jl
| Package | What it adds |
|---|---|
SpectralFitting.jl |
X-ray spectral fitting with OGIP PHA/RMF/ARF |
Korg.jl |
Synthetic stellar spectra from model atmospheres |
AstroImages.jl |
IFU data-cubes — spectra at every spaxel |