Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,23 @@ projects = ["test", "docs"]
DustExtinction = "fb44c06c-c62f-5397-83f5-69249e0a3c8e"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7"
PhysicalConstants = "5ad8b20f-a522-5ce9-bfc9-ddf1d5bda6ab"
RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"
Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d"
UnitfulAstro = "6112ee07-acf9-5e0f-b108-d242c714bf9f"
UnitfulEquivalences = "da9c4bc3-91c8-4f02-8a40-6b990d2a7e0c"

[weakdeps]
DynamicQuantities = "06fc5a27-2a28-4c7c-a15d-362465fb6821"
Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d"

[extensions]
SpectrumBaseDynamicQuantitiesExt = "DynamicQuantities"
SpectrumBaseUnitfulExt = "Unitful"

[compat]
DustExtinction = ">=0.11.7"
DynamicQuantities = "1"
LinearAlgebra = "1.5"
Measurements = "2"
PhysicalConstants = "0.2"
RecipesBase = "1"
Unitful = "1"
UnitfulAstro = "1"
UnitfulEquivalences = "0.2.1"
julia = "1.10"
1 change: 1 addition & 0 deletions docs/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
DataInterpolations = "82cc6244-b520-54b8-b5a6-8a565e85f1d0"
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
DynamicQuantities = "06fc5a27-2a28-4c7c-a15d-362465fb6821"
FITSIO = "525bcba6-941b-5504-bd06-fd0dc1a4d2eb"
Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59"
Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7"
Expand Down
8 changes: 7 additions & 1 deletion docs/make.jl
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
using Documenter
using SpectrumBase
using Unitful
using DynamicQuantities
using Measurements

# The Unitful and DynamicQuantities integrations live in package extensions, so
# their docstrings must be pulled from those modules explicitly.
const unitful_ext = Base.get_extension(SpectrumBase, :SpectrumBaseUnitfulExt)
const dynamicquantities_ext = Base.get_extension(SpectrumBase, :SpectrumBaseDynamicQuantitiesExt)

makedocs(sitename = "SpectrumBase.jl",
format = Documenter.HTML(;
prettyurls = true,
canonical = "https://juliaastro.org/SpectrumBase/stable/",
),
authors = "Miles Lucas and contributors.",
linkcheck = !("skiplinks" in ARGS),
modules = [SpectrumBase],
modules = [SpectrumBase, unitful_ext, dynamicquantities_ext],
pages = [
"Home" => "index.md",
"spectrum.md",
Expand Down
14 changes: 14 additions & 0 deletions docs/src/spectrum.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,25 @@ SpectrumBase.meta(::AbstractSpectrum)

## Unitful helpers

Loading [Unitful.jl](https://github.com/PainterQubits/Unitful.jl) activates an
extension that lets spectra carry units and adds the following helpers.

```@docs
Unitful.unit
Unitful.ustrip
```

## DynamicQuantities helpers

Loading [DynamicQuantities.jl](https://github.com/SymbolicML/DynamicQuantities.jl)
activates an analogous extension. Since DynamicQuantities has no unit objects,
the `unit` helper is replaced by a `dimension` one.

```@docs
DynamicQuantities.ustrip
DynamicQuantities.dimension
```

## Utilities

```@docs
Expand Down
2 changes: 1 addition & 1 deletion docs/src/transforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ doppler_shift!

## Axis conversions

Powered by [UnitfulEquivalences.jl](https://github.com/sostock/UnitfulEquivalences.jl), [`uconvert`](@ref) from [Unitful.jl](https://github.com/JuliaPhysics/Unitful.jl) is extended to convert the spectral axis between wavelength, frequency, and photon energy, along with its associated flux density:
Powered by [UnitfulEquivalences.jl](https://github.com/sostock/UnitfulEquivalences.jl), [`uconvert`](@ref Unitful.uconvert) from [Unitful.jl](https://github.com/JuliaPhysics/Unitful.jl) is extended to convert the spectral axis between wavelength, frequency, and photon energy, along with its associated flux density:

```jldoctest
julia> using SpectrumBase, Unitful, UnitfulAstro
Expand Down
96 changes: 96 additions & 0 deletions ext/SpectrumBaseDynamicQuantitiesExt.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
module SpectrumBaseDynamicQuantitiesExt

using SpectrumBase
using SpectrumBase: AbstractSpectrum, Spectrum, spectrum, spectral_axis, flux_axis, meta, blackbody
import SpectrumBase: _ustrip, _velocity_ratio, _spectral_axis_factor
using DynamicQuantities: DynamicQuantities, @u_str, ustrip, dimension, uexpand,
UnionAbstractQuantity, Constants

# DynamicQuantities does not encode a quantity's dimension in its type (all
# `Quantity{Float64,Dimensions}` share one concrete type), so — unlike the
# Unitful backend — every check here inspects the *value*. `uexpand` normalizes
# symbolic units (`us"..."`) to base `Dimensions` so the comparisons below work
# regardless of how the axis was constructed.
const _LENGTH_DIM = dimension(u"m")
const _FREQUENCY_DIM = dimension(u"Hz")
const _ENERGY_DIM = dimension(u"J")

_dimension(q) = dimension(uexpand(q))
_is_wavelength(q) = _dimension(q) == _LENGTH_DIM
_is_frequency(q) = _dimension(q) == _FREQUENCY_DIM
_is_energy(q) = _dimension(q) == _ENERGY_DIM

# --- units-agnostic hooks from the core -------------------------------------

_ustrip(x::UnionAbstractQuantity) = ustrip(x)

_velocity_ratio(v::UnionAbstractQuantity) = ustrip(uexpand(v / Constants.c))

# Resolved from the axis value rather than the element type (see note above): a
# wavelength axis stretches, a frequency or energy axis compresses.
function _spectral_axis_factor(spec::AbstractSpectrum{<:UnionAbstractQuantity}, factor)
q = first(spectral_axis(spec))
_is_wavelength(q) && return factor
(_is_frequency(q) || _is_energy(q)) && return inv(factor)
throw(ArgumentError("cannot shift a spectral axis with dimension $(_dimension(q)); expected a wavelength, frequency, or energy"))
end

# --- spectrum constructors for DynamicQuantities axes -----------------------

function _assert_spectral_dimension(axis)
q = first(axis)
@assert (_is_wavelength(q) || _is_frequency(q) || _is_energy(q)) "spectral_axis not recognized as having dimensions of wavelength, frequency, or energy."
end

function SpectrumBase.spectrum(spectral_axis::AbstractVector{<:UnionAbstractQuantity}, flux_axis::AbstractVector{<:UnionAbstractQuantity}; kwds...)
_assert_spectral_dimension(spectral_axis)
Spectrum(spectral_axis, flux_axis, Dict{Symbol, Any}(kwds))
end

function SpectrumBase.spectrum(spectral_axis::AbstractMatrix{<:UnionAbstractQuantity}, flux_axis::AbstractMatrix{<:UnionAbstractQuantity}; kwds...)
_assert_spectral_dimension(spectral_axis)
Spectrum(spectral_axis, flux_axis, Dict{Symbol, Any}(kwds))
end

function SpectrumBase.spectrum(spectral_axis::AbstractMatrix{<:UnionAbstractQuantity}, flux_axis::AbstractVector{<:UnionAbstractQuantity}; kwds...)
_assert_spectral_dimension(spectral_axis)
Spectrum(spectral_axis, flux_axis, Dict{Symbol, Any}(kwds))
end

# --- ustrip / dimension on whole spectra ------------------------------------

"""
DynamicQuantities.ustrip(::AbstractSpectrum)

Remove the units from a spectrum, returning a plain-number spectrum with the axis
values expressed in SI base units. The DynamicQuantities analog of the Unitful
`ustrip(::AbstractSpectrum)` method.
"""
DynamicQuantities.ustrip(spec::AbstractSpectrum) = spectrum(ustrip.(spectral_axis(spec)), ustrip.(flux_axis(spec)); meta(spec)...)

"""
DynamicQuantities.dimension(::AbstractSpectrum)

Get the dimensions of a spectrum as a tuple of the spectral-axis and flux-axis
dimensions. The DynamicQuantities analog of the Unitful `unit(::AbstractSpectrum)`
method (DynamicQuantities has no unit objects, only dimensions).
"""
DynamicQuantities.dimension(spec::AbstractSpectrum) = _dimension(first(spectral_axis(spec))), _dimension(first(flux_axis(spec)))

# --- blackbody with DynamicQuantities arguments -----------------------------

function SpectrumBase.blackbody(wave::AbstractVector{<:UnionAbstractQuantity}, T::UnionAbstractQuantity)
flux = SpectrumBase.blackbody(T).(wave)
return spectrum(wave, flux, name = "Blackbody", T = T)
end

"""
blackbody(T::DynamicQuantities.UnionAbstractQuantity)

Returns a function for calculating blackbody curves. The flux is returned in SI
base units (W m⁻² m⁻¹).
"""
SpectrumBase.blackbody(T::UnionAbstractQuantity) =
w -> 2 * Constants.h * Constants.c^2 / w^5 / (exp(Constants.h * Constants.c / (w * Constants.k_B * T)) - 1)

end # module
192 changes: 192 additions & 0 deletions ext/SpectrumBaseUnitfulExt.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
module SpectrumBaseUnitfulExt

using SpectrumBase
using SpectrumBase: AbstractSpectrum, Spectrum, BinnedSpectrum, spectrum, spectral_axis, flux_axis, meta, blackbody
import SpectrumBase: _ustrip, _velocity_ratio, _spectral_axis_factor
using Unitful: Unitful, @u_str, ustrip, unit, dimension, Quantity, Length, Frequency, Energy, NoUnits
using UnitfulEquivalences: Spectral, SpectralDensity

# Unit-ful physical constants, rebuilt from the SI floats that live in
# `SpectrumBase` so the Unitful backend agrees with the numeric core to the last
# digit.
const h = SpectrumBase.PLANCK_CONSTANT * u"J*s"
const c_0 = SpectrumBase.SPEED_OF_LIGHT * u"m/s"
const k_B = SpectrumBase.BOLTZMANN_CONSTANT * u"J/K"

# Recognized spectral-axis dimensions: wavelength, frequency, photon energy.
const SPECTRAL_DIMENSIONS = (u"𝐋", u"𝐓^-1", u"𝐋^2 * 𝐌 * 𝐓^-2")

# --- units-agnostic hooks from the core -------------------------------------

_ustrip(x::Quantity) = ustrip(x)

_velocity_ratio(v::Quantity) = ustrip(NoUnits, v / c_0)

_spectral_axis_factor(::Type{<:Length}, factor) = factor # Wavelength: stretches
_spectral_axis_factor(::Type{<:Frequency}, factor) = inv(factor) # Frequency: compresses
_spectral_axis_factor(::Type{<:Energy}, factor) = inv(factor) # Energy: compresses
_spectral_axis_factor(::Type{S}, factor) where {S <: Quantity} =
throw(ArgumentError("cannot shift a spectral axis with dimension $(dimension(S)); expected a wavelength, frequency, or energy"))

# --- spectrum constructors for Unitful axes ---------------------------------

function SpectrumBase.spectrum(spectral_axis::AbstractVector{<:Quantity}, flux_axis::AbstractVector{<:Quantity}; kwds...)
@assert dimension(eltype(spectral_axis)) ∈ SPECTRAL_DIMENSIONS "spectral_axis not recognized as having dimensions of wavelength, frequency, or energy."
Spectrum(spectral_axis, flux_axis, Dict{Symbol, Any}(kwds))
end

function SpectrumBase.spectrum(spectral_axis::AbstractMatrix{<:Quantity}, flux_axis::AbstractMatrix{<:Quantity}; kwds...)
@assert dimension(eltype(spectral_axis)) ∈ SPECTRAL_DIMENSIONS "spectral_axis not recognized as having dimensions of wavelength, frequency, or energy."
Spectrum(spectral_axis, flux_axis, Dict{Symbol, Any}(kwds))
end

function SpectrumBase.spectrum(spectral_axis::AbstractMatrix{<:Quantity}, flux_axis::AbstractVector{<:Quantity}; kwds...)
@assert dimension(eltype(spectral_axis)) ∈ SPECTRAL_DIMENSIONS "spectral_axis not recognized as having dimensions of wavelength, frequency, or energy."
Spectrum(spectral_axis, flux_axis, Dict{Symbol, Any}(kwds))
end

# --- ustrip / unit on whole spectra -----------------------------------------

"""
Unitful.ustrip(::AbstractSpectrum)

Remove the units from a spectrum. Useful for processing spectra in tools that don't play nicely with `Unitful.jl`

# Examples
```jldoctest
julia> using Random

julia> rng = Random.seed!(0)
TaskLocalRNG()

julia> using Unitful, UnitfulAstro

julia> wave = range(1e4, 3e4, length=1000);

julia> flux = wave .* 10 .+ randn(rng, 1000);

julia> spec = spectrum(wave*u"angstrom", flux*u"W/m^2/angstrom")
SingleSpectrum(Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(Å,), 𝐋, nothing}}, Unitful.Quantity{Float64, 𝐌 𝐋^-1 𝐓^-3, Unitful.FreeUnits{(Å^-1, m^-2, W), 𝐌 𝐋^-1 𝐓^-3, nothing}})
spectral axis (1000,): 10000.0 Å .. 30000.0 Å
flux axis (1000,): 99999.76809093042 W Å^-1 m^-2 .. 300000.2474309158 W Å^-1 m^-2
meta: Dict{Symbol, Any}()

julia> ustrip(spec)
SingleSpectrum(Float64, Float64)
spectral axis (1000,): 10000.0 .. 30000.0
flux axis (1000,): 99999.76809093042 .. 300000.2474309158
meta: Dict{Symbol, Any}()
```
"""
Unitful.ustrip(spec::AbstractSpectrum) = spectrum(ustrip.(spectral_axis(spec)), ustrip.(flux_axis(spec)); meta(spec)...)

"""
Unitful.unit(::AbstractSpectrum)

Get the units of a spectrum. Returns a tuple of the spectral axis units and flux/sigma units

# Examples
```jldoctest
julia> using Random

julia> rng = Random.seed!(0)
TaskLocalRNG()

julia> using Unitful, UnitfulAstro

julia> wave = range(1e4, 3e4, length=1000);

julia> flux = wave .* 10 .+ randn(rng, 1000);

julia> spec = spectrum(wave * u"angstrom", flux * u"W/m^2/angstrom");

julia> w_unit, f_unit = unit(spec)
(Å, W Å^-1 m^-2)
```
"""
Unitful.unit(spec::AbstractSpectrum) = unit(eltype(spectral_axis(spec))), unit(eltype(flux_axis(spec)))

# --- blackbody with Unitful arguments ---------------------------------------

function SpectrumBase.blackbody(wave::AbstractVector{<:Quantity}, T::Quantity)
out_unit = u"W/m^2" / unit(eltype(wave))
flux = _blackbody(wave, T) .|> out_unit
return spectrum(wave, flux, name = "Blackbody", T = T)
end

"""
blackbody(T::Unitful.Quantity)

Returns a function for calculating blackbody curves.
"""
SpectrumBase.blackbody(T::Quantity) = w -> 2h * c_0^2 / w^5 / (exp(h * c_0 / (w * k_B * T)) - 1)

_blackbody(wave::AbstractVector{<:Quantity}, T::Quantity) = SpectrumBase.blackbody(T).(wave)

# --- spectral axis conversions ----------------------------------------------

# Same-dimension targets are a plain unit conversion, keeping conversions idempotent.
_spectral_value(spectral_unit, x) =
dimension(spectral_unit) == dimension(x) ? Unitful.uconvert(spectral_unit, x) :
Unitful.uconvert(spectral_unit, x, Spectral())

_flux_value(flux_unit, x, at) =
dimension(flux_unit) == dimension(x) ? Unitful.uconvert(flux_unit, x) :
Unitful.uconvert(flux_unit, x, SpectralDensity(at))

_convert_spectral_axis(spec::Spectrum, spectral_unit) = _spectral_value.(spectral_unit, spectral_axis(spec))

_convert_flux_axis(spec::Spectrum, ::Nothing) = copy(flux_axis(spec))
function _convert_flux_axis(spec::Spectrum{<:Unitful.Quantity}, flux_unit::Unitful.Units)
spec isa BinnedSpectrum && throw(ArgumentError(
"flux density conversion is not defined for binned spectra: bin values are integrated quantities, so converting them requires a bin-integration convention"))
return _flux_value.(flux_unit, flux_axis(spec), spectral_axis(spec))
end

function _convert_spectrum(spec::Spectrum{<:Unitful.Quantity}, spectral_unit::Unitful.Units, flux_unit::Union{Nothing, Unitful.Units})
dimension(spectral_unit) ∈ SPECTRAL_DIMENSIONS || throw(ArgumentError(
"spectral axis unit must be a wavelength, frequency, or energy unit; got $spectral_unit"))
return Spectrum(_convert_spectral_axis(spec, spectral_unit), _convert_flux_axis(spec, flux_unit), deepcopy(meta(spec)))
end

@doc raw"""
Unitful.uconvert(spectral_unit::Unitful.Units, spec::Spectrum)
Unitful.uconvert(units::Tuple{Unitful.Units, Unitful.Units}, spec::Spectrum)

Convert the spectral axis of `spec` to `spectral_unit`, which may be any wavelength, frequency, or
photon-energy unit. Coordinates are related by the photon equivalence ``E = hν = hc/λ``.
A target of the same dimension as the current axis is a plain unit conversion.

The conversion is applied elementwise, so a reciprocal conversion (e.g., wavelength -->
frequency) yields an axis with the opposite direction of monotonicity.

Given a single unit, flux values are copied unchanged. Beware that a flux density then
remains "per" its original coordinate (e.g., a per-wavelength density against a frequency
axis). Pass a `(spectral_unit, flux_unit)` tuple, i.e., the shape returned by `unit(spec)`, to
also convert flux density values into the matching convention via [`UnitfulEquivalences.SpectralDensity`](https://github.com/sostock/UnitfulEquivalences.jl),
which preserves integrals: ``F_ν = F_λ λ^2 / c``.

# Examples

```jldoctest
julia> using Unitful, UnitfulAstro

julia> spec = spectrum([1.0, 1.5, 2.0]u"μm", [1.0, 2.0, 3.0]u"W/m^2/μm");

julia> νspec = uconvert((u"THz", u"Jy"), spec);

julia> issorted(spectral_axis(νspec); rev = true)
true

julia> flux_axis(νspec)[1] ≈ uconvert(u"Jy", 1.0u"W/m^2/μm" * (1.0u"μm")^2 / Unitful.c0)
true
```
"""
Unitful.uconvert(spectral_unit::Unitful.Units, spec::Spectrum{<:Unitful.Quantity}) = _convert_spectrum(spec, spectral_unit, nothing)
Unitful.uconvert(units::Tuple{Unitful.Units, Unitful.Units}, spec::Spectrum{<:Unitful.Quantity}) = _convert_spectrum(spec, units[1], units[2])

const _UNITLESS_AXIS_MSG = "`uconvert` requires a spectral axis with Unitful units. This spectrum's axis is unitless. Attach units first, e.g., `spectrum(axis * u\"angstrom\", flux)`"
Unitful.uconvert(::Unitful.Units, ::AbstractSpectrum) = throw(ArgumentError(_UNITLESS_AXIS_MSG))
Unitful.uconvert(::Tuple{Unitful.Units, Unitful.Units}, ::AbstractSpectrum) = throw(ArgumentError(_UNITLESS_AXIS_MSG))

end # module
Loading
Loading