diff --git a/Project.toml b/Project.toml index aabfe9c..f760f0b 100644 --- a/Project.toml +++ b/Project.toml @@ -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" diff --git a/docs/Project.toml b/docs/Project.toml index 4ae2119..9693e45 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -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" diff --git a/docs/make.jl b/docs/make.jl index da47359..5b2bd03 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,8 +1,14 @@ 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, @@ -10,7 +16,7 @@ makedocs(sitename = "SpectrumBase.jl", ), authors = "Miles Lucas and contributors.", linkcheck = !("skiplinks" in ARGS), - modules = [SpectrumBase], + modules = [SpectrumBase, unitful_ext, dynamicquantities_ext], pages = [ "Home" => "index.md", "spectrum.md", diff --git a/docs/src/spectrum.md b/docs/src/spectrum.md index 297ef38..fa58a28 100644 --- a/docs/src/spectrum.md +++ b/docs/src/spectrum.md @@ -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 diff --git a/docs/src/transforms.md b/docs/src/transforms.md index ee1c248..77127ff 100644 --- a/docs/src/transforms.md +++ b/docs/src/transforms.md @@ -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 diff --git a/ext/SpectrumBaseDynamicQuantitiesExt.jl b/ext/SpectrumBaseDynamicQuantitiesExt.jl new file mode 100644 index 0000000..7a7c90a --- /dev/null +++ b/ext/SpectrumBaseDynamicQuantitiesExt.jl @@ -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 diff --git a/ext/SpectrumBaseUnitfulExt.jl b/ext/SpectrumBaseUnitfulExt.jl new file mode 100644 index 0000000..8e65ae4 --- /dev/null +++ b/ext/SpectrumBaseUnitfulExt.jl @@ -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 diff --git a/src/SpectrumBase.jl b/src/SpectrumBase.jl index 94777d6..2b08ebe 100644 --- a/src/SpectrumBase.jl +++ b/src/SpectrumBase.jl @@ -18,8 +18,19 @@ export blackbody #, line_flux, equivalent_width using RecipesBase: @recipe using Measurements: Measurements -using Unitful: Unitful, @u_str, ustrip, unit, dimension -using PhysicalConstants.CODATA2018: h, c_0, k_B + +# Physical constants, CODATA2018 / SI-2019 exact values in SI base units. These are +# the single source of truth for the numeric core and for both units extensions +# (`SpectrumBaseUnitfulExt`, `SpectrumBaseDynamicQuantitiesExt`), which re-attach +# units to these same numbers so every backend agrees to the last digit. +const PLANCK_CONSTANT = 6.62607015e-34 # h, J s +const SPEED_OF_LIGHT = 2.99792458e8 # c_0, m s^-1 +const BOLTZMANN_CONSTANT = 1.380649e-23 # k_B, J K^-1 + +# Strip units from a scalar. The unitless core is the identity; the Unitful and +# DynamicQuantities extensions specialize this on their quantity types so that +# plotting and other numeric consumers see plain numbers. +_ustrip(x) = x """ AbstractSpectrum{S <: Number, F <: Number} @@ -155,67 +166,16 @@ Base.:-(s::AbstractSpectrum, o::AbstractSpectrum) = s - o # Satisfy Aqua # Multi-Spectrum Base.:+(s::T, o::T) where {T <: AbstractSpectrum} = T(spectral_axis(s), flux_axis(s) .+ flux_axis(o), meta(s)) Base.:*(s::T, o::T) where {T <: AbstractSpectrum} = T(spectral_axis(s), flux_axis(s) .* flux_axis(o), meta(s)) -Base.:/(s::T, o::T) where {T <: AbstractSpectrum} = T(spectral_axis(s), flux_axis(s) ./ flux_axis(o) * unit(s)[2], meta(s)) +# Dividing two spectra cancels the flux units; multiply back a unit-valued one so +# the ratio keeps flux units. `oneunit` is units-agnostic (it is `1` for plain +# reals), which keeps this method in the unitless core. +Base.:/(s::T, o::T) where {T <: AbstractSpectrum} = T(spectral_axis(s), flux_axis(s) ./ flux_axis(o) * oneunit(eltype(flux_axis(s))), meta(s)) Base.:-(s::T, o::T) where {T <: AbstractSpectrum} = T(spectral_axis(s), flux_axis(s) .- flux_axis(o), meta(s)) -""" - 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))) +# `ustrip(spec)` and `unit(spec)` (and the DynamicQuantities `dimension(spec)` +# analog) live in the units extensions, since they extend functions owned by the +# respective units package. See `ext/SpectrumBaseUnitfulExt.jl` and +# `ext/SpectrumBaseDynamicQuantitiesExt.jl`. # Spectrum types and basic arithmetic include("spectrum_single.jl") @@ -305,23 +265,10 @@ function spectrum(spectral_axis::AbstractMatrix{<:Real}, flux_axis::AbstractVect Spectrum(spectral_axis, flux_axis, Dict{Symbol,Any}(kwds)) end -# Recognized spectral-axis dimensions: wavelength, frequency, photon energy. -const SPECTRAL_DIMENSIONS = (u"𝐋", u"𝐓^-1", u"𝐋^2 * 𝐌 * 𝐓^-2") - -function spectrum(spectral_axis::AbstractVector{<:Unitful.Quantity}, flux_axis::AbstractVector{<:Unitful.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 spectrum(spectral_axis::AbstractMatrix{<:Unitful.Quantity}, flux_axis::AbstractMatrix{<:Unitful.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 spectrum(spectral_axis::AbstractMatrix{<:Unitful.Quantity}, flux_axis::AbstractVector{<:Unitful.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 +# `spectrum(::AbstractArray{<:Quantity}, ...)` methods, which validate that the +# spectral axis carries wavelength, frequency, or energy dimensions, live in the +# units extensions (`ext/SpectrumBaseUnitfulExt.jl`, +# `ext/SpectrumBaseDynamicQuantitiesExt.jl`). include("utils.jl") include("transforms/resampler.jl") diff --git a/src/plotting.jl b/src/plotting.jl index 9fef960..4a9b16c 100644 --- a/src/plotting.jl +++ b/src/plotting.jl @@ -3,7 +3,7 @@ xlabel --> "wave" ylabel --> "flux density" label --> "" - ustrip.(spectral_axis(spec)), Measurements.value.(ustrip.(flux_axis(spec))) + _ustrip.(spectral_axis(spec)), Measurements.value.(_ustrip.(flux_axis(spec))) end @recipe function f(spec::EchelleSpectrum) diff --git a/src/transforms/redshift.jl b/src/transforms/redshift.jl index 24629a2..b3e9086 100644 --- a/src/transforms/redshift.jl +++ b/src/transforms/redshift.jl @@ -1,7 +1,9 @@ # Dimensionless velocity β ≡ v / c against the speed of light in vacuum. -# A plain number is taken as km/s, the usual unit for radial velocities. -_velocity_ratio(v::Unitful.Quantity) = ustrip(Unitful.NoUnits, v / c_0) -_velocity_ratio(v::Real) = _velocity_ratio(v * u"km/s") +# A plain number is taken as km/s, the usual unit for radial velocities; the +# `Quantity` methods (which honor the velocity's own units) live in the units +# extensions. +const _SPEED_OF_LIGHT_KMPS = SPEED_OF_LIGHT / 1000 +_velocity_ratio(v::Real) = v / _SPEED_OF_LIGHT_KMPS # Doppler shift function _doppler_factor(v; relativistic) @@ -18,17 +20,15 @@ function _redshift_factor(z) end # Resolve the (1 + z) stretch factor to the scalar actually multiplied onto a spectral axis of element type `S`. +# Dimension-aware `Quantity` methods (wavelength stretches; frequency and energy compress) live in the units extensions. _spectral_axis_factor(::Type{<:Real}, factor) = factor # Unitless: assume wavelength -_spectral_axis_factor(::Type{<:Unitful.Length}, factor) = factor # Wavelength: stretches -_spectral_axis_factor(::Type{<:Unitful.Frequency}, factor) = inv(factor) # Frequency: compresses -_spectral_axis_factor(::Type{<:Unitful.Energy}, factor) = inv(factor) # Energy: compresses -_spectral_axis_factor(::Type{S}, factor) where {S <: Unitful.Quantity} = - throw(ArgumentError("cannot shift a spectral axis with dimension $(dimension(S)); expected a wavelength, frequency, or energy")) -_spectral_axis_factor(spec::AbstractSpectrum, factor) = _spectral_axis_factor(eltype(spectral_axis(spec)), factor) +# Dispatches on `S` so the factor stays inferable. The DynamicQuantities extension, whose +# element type does not carry its dimension, specializes this on the spectrum instead. +_spectral_axis_factor(spec::AbstractSpectrum{S}, factor) where {S} = _spectral_axis_factor(S, factor) # Rebuilding through the constructor lets the element type promote and re-runs the invariant checks. -function _scale_spectral_axis(spec::Spectrum{S}, factor) where {S} - spectral_ax = spectral_axis(spec) .* _spectral_axis_factor(S, factor) +function _scale_spectral_axis(spec::Spectrum, factor) + spectral_ax = spectral_axis(spec) .* _spectral_axis_factor(spec, factor) flux_ax = copy(flux_axis(spec)) return Spectrum(spectral_ax, flux_ax, deepcopy(meta(spec))) end diff --git a/src/transforms/spectral_axis.jl b/src/transforms/spectral_axis.jl deleted file mode 100644 index f24d00f..0000000 --- a/src/transforms/spectral_axis.jl +++ /dev/null @@ -1,65 +0,0 @@ -using UnitfulEquivalences: Spectral, SpectralDensity - -# 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)) diff --git a/src/transforms/transforms.jl b/src/transforms/transforms.jl index fc6c02b..fcd974a 100644 --- a/src/transforms/transforms.jl +++ b/src/transforms/transforms.jl @@ -1,6 +1,5 @@ include("redden.jl") include("redshift.jl") -include("spectral_axis.jl") # ## Broadening ops # include("kernels.jl") diff --git a/src/utils.jl b/src/utils.jl index 3b69a16..fc23952 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -10,7 +10,10 @@ Create a blackbody spectrum using Planck's law. The curve follows the mathematic If `wave` and `T` are not `Unitful.Quantity`, they are assumed to be in angstrom and Kelvin, and the returned flux will be in units `W m^-2 Å^-1`. -The physical constants are calculated using [PhysicalConstants.jl](https://github.com/juliaphysics/physicalconstants.jl), specifically the CODATA2018 measurement set. +The physical constants are the CODATA2018 / SI-2019 exact values. + +When `wave` and `T` are `Unitful` or `DynamicQuantities` quantities, the corresponding +units extension handles the calculation and the flux carries the appropriate units. # References @@ -33,7 +36,7 @@ SingleSpectrum(Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(μm,), 𝐋, n julia> blackbody(ustrip.(u"angstrom", wave), 6000) SingleSpectrum(Float64, Float64) spectral axis (100,): 10000.0 .. 30000.0 - flux axis (100,): 1190.9562575755397 .. 40.04325690910415 + flux axis (100,): 1190.9562575755401 .. 40.043256909104144 meta: Dict{Symbol, Any}(:T => 6000, :name => "Blackbody") julia> spectral_axis(bb)[argmax(bb)] @@ -43,25 +46,21 @@ julia> 2898u"μm*K" / bb.T # See if it matches up with Wien's law 1.449 μm ``` """ -function blackbody(wave::AbstractVector{<:Unitful.Quantity}, T::Unitful.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 - function blackbody(wave::AbstractVector{<:Real}, T::Real) - flux = ustrip.(u"W/m^2/angstrom", _blackbody(wave * u"angstrom", T * u"K")) + flux = _planck_per_angstrom.(wave, T) return spectrum(wave, flux, name = "Blackbody", T = T) end -_blackbody(wave::AbstractVector{<:Unitful.Quantity}, T::Unitful.Quantity) = blackbody(T).(wave) - -""" - blackbody(T::Unitful.Quantity) - -Returns a function for calculating blackbody curves. -""" -blackbody(T::Unitful.Quantity) = w->2h * c_0^2 / w^5 / (exp(h * c_0 / (w * k_B * T)) - 1) +# Planck's law Bλ(T) for wavelength `λ` in angstrom and temperature `T` in Kelvin, +# returned in W m^-2 Å^-1. Uses the SI constants defined in `SpectrumBase`; the +# unit-aware `blackbody` methods (Unitful/DynamicQuantities `Quantity` arguments) +# live in the units extensions. +function _planck_per_angstrom(λ, T) + λ_m = λ * 1e-10 # angstrom -> m + spectral_radiance = 2 * PLANCK_CONSTANT * SPEED_OF_LIGHT^2 / λ_m^5 / + expm1(PLANCK_CONSTANT * SPEED_OF_LIGHT / (λ_m * BOLTZMANN_CONSTANT * T)) + return spectral_radiance * 1e-10 # W m^-2 m^-1 -> W m^-2 Å^-1 +end #""" # equivalent_width(::AbstractSpectrum) diff --git a/test/Project.toml b/test/Project.toml index 7b080ef..f3d3a7c 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -3,6 +3,7 @@ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" DataInterpolations = "82cc6244-b520-54b8-b5a6-8a565e85f1d0" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DustExtinction = "fb44c06c-c62f-5397-83f5-69249e0a3c8e" +DynamicQuantities = "06fc5a27-2a28-4c7c-a15d-362465fb6821" Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7" ParallelTestRunner = "d3525ed8-44d0-4b2c-a655-542cee43accc" diff --git a/test/doctests.jl b/test/doctests.jl index 610dee3..70bc415 100644 --- a/test/doctests.jl +++ b/test/doctests.jl @@ -1,5 +1,15 @@ using Documenter: DocMeta, doctest +import DynamicQuantities -DocMeta.setdocmeta!(SpectrumBase, :DocTestSetup, :(using SpectrumBase); recursive = true) +# Docstrings defined in the units extensions belong to those modules, so doctest them too. +const modules = [ + SpectrumBase, + Base.get_extension(SpectrumBase, :SpectrumBaseUnitfulExt), + Base.get_extension(SpectrumBase, :SpectrumBaseDynamicQuantitiesExt), +] -doctest(SpectrumBase) +for m in modules + DocMeta.setdocmeta!(m, :DocTestSetup, :(using SpectrumBase); recursive = true) +end + +doctest(joinpath(pkgdir(SpectrumBase), "docs", "src"), modules) diff --git a/test/ext/dynamicquantities.jl b/test/ext/dynamicquantities.jl new file mode 100644 index 0000000..77affb5 --- /dev/null +++ b/test/ext/dynamicquantities.jl @@ -0,0 +1,110 @@ +import DynamicQuantities as DQ +using SpectrumBase: SingleSpectrum, BinnedSpectrum, redshift, redshift!, doppler_shift, doppler_shift!, blackbody + +# DynamicQuantities normalizes to SI base units, so `angstrom` and `keV` are +# stored as their SI-base values; `DQ.ustrip` returns those base-unit numbers. +Å = DQ.u"angstrom" +keV = DQ.Constants.keV +Wm2 = DQ.u"W/m^2" + +@testset "DynamicQuantities extension" begin + @testset "spectrum constructor" begin + wave = collect(range(4000.0, 8000.0, length = 10)) .* Å + flux = ones(10) .* Wm2 + spec = spectrum(wave, flux) + @test spec isa SingleSpectrum + + # energy and frequency axes are also accepted + energy = collect(range(1.0, 5.0, length = 10)) .* keV + @test spectrum(energy, flux) isa SingleSpectrum + freq = collect(range(1.0, 5.0, length = 10)) .* DQ.u"THz" + @test spectrum(freq, flux) isa SingleSpectrum + + # bin edges + @test spectrum([1.0 2.0; 2.0 3.0] .* keV, [1.0, 2.0] .* Wm2) isa BinnedSpectrum + + # a non-wavelength/frequency/energy axis is rejected + bad = collect(range(1.0, 10.0, length = 10)) .* DQ.u"s" + @test_throws AssertionError spectrum(bad, flux) + end + + @testset "Wavelength axis stretches" begin + wave = collect(range(4000.0, 8000.0, length = 10)) .* Å + spec = spectrum(wave, ones(10) .* Wm2) + + shifted = @inferred redshift(spec, 0.5) + @test all(spectral_axis(shifted) .≈ wave .* 1.5) + @test flux_axis(shifted) == flux_axis(spec) + + # roundtrip + @test all(spectral_axis(redshift(shifted, -1 / 3)) .≈ wave) + end + + @testset "Energy axis compresses" begin + energy = collect(range(1.0, 5.0, length = 10)) .* keV + spec = spectrum(energy, ones(10) .* Wm2) + + shifted = @inferred redshift(spec, 0.5) + @test all(spectral_axis(shifted) .≈ energy ./ 1.5) + @test all(spectral_axis(redshift(shifted, -1 / 3)) .≈ energy) + end + + @testset "Frequency axis compresses" begin + freq = collect(range(1.0, 5.0, length = 10)) .* DQ.u"THz" + spec = spectrum(freq, ones(10) .* Wm2) + + shifted = @inferred redshift(spec, 0.5) + @test all(spectral_axis(shifted) .≈ freq ./ 1.5) + end + + @testset "redshift in-place" begin + wave = collect(range(4000.0, 8000.0, length = 10)) .* Å + spec = spectrum(wave, ones(10) .* Wm2) + expected = spectral_axis(spec) .* 1.5 + result = @inferred redshift!(spec, 0.5) + @test result === spec + @test all(spectral_axis(spec) .≈ expected) + end + + @testset "Doppler shift" begin + wave = collect(range(4000.0, 8000.0, length = 10)) .* Å + spec = spectrum(wave, ones(10) .* Wm2) + + # bare Real velocity is km/s, as in the numeric core + v = 100.0 + @test all(spectral_axis(doppler_shift(spec, v)) .≈ wave .* (1 + v / C_KMPS)) + + # DynamicQuantities velocity honors its own units + vq = 100.0 * DQ.u"km/s" + β = 1e5 / C_MPS + @test all(spectral_axis(doppler_shift(spec, vq)) .≈ wave .* (1 + β)) + + shifted_rel = doppler_shift(spec, vq; relativistic = true) + @test all(spectral_axis(shifted_rel) .≈ wave .* sqrt((1 + β) / (1 - β))) + + # superluminal is rejected in the relativistic case + @test_throws DomainError doppler_shift(spec, DQ.Constants.c; relativistic = true) + end + + @testset "ustrip and dimension" begin + wave = collect(range(4000.0, 8000.0, length = 10)) .* Å + spec = spectrum(wave, ones(10) .* Wm2) + + stripped = DQ.ustrip(spec) + @test eltype(spectral_axis(stripped)) <: Real + @test spectral_axis(stripped) ≈ DQ.ustrip.(wave) + + w_dim, f_dim = DQ.dimension(spec) + @test w_dim == DQ.dimension(DQ.u"m") + @test f_dim == DQ.dimension(Wm2) + end + + @testset "blackbody" begin + wave = collect(range(1.0, 3.0, length = 10)) .* DQ.u"μm" + bb = blackbody(wave, 2000.0 * DQ.u"K") + @test bb isa SingleSpectrum + @test spectral_axis(bb) == wave + @test all(DQ.ustrip.(flux_axis(bb)) .> 0) + @test bb.T == 2000.0 * DQ.u"K" + end +end