"""
phot_convert.py
Implements phot_convert, a convenience wrapper around the compiled
PhotConvert binding that deduces the source photometric system from
an astropy Quantity's own unit.
:copyright: Copyright (c) 2026 Mark Krumholz
"""
from typing import cast
from astropy import units as u
from ._slug import Filter, PhotConvert
# The unit phot_convert re-attaches to its result for each photometric
# system, and (read in reverse) the units phot_convert recognizes when
# deducing a Quantity's own system. Fnu's unit here is erg/(s Hz) --
# a specific luminosity, with no particular distance baked in -- not
# Jy (a real flux density): the compiled PhotConvert binding's own Fnu
# system now always means a specific luminosity (see PhotCommons.hpp's
# own comments on fourPiTenPcSq), matching Flambda's erg/(s Angstrom)
# below, which was never Jy's flux-domain analog (erg/s/cm^2/Angstrom)
# in the first place.
_PHOT_SYSTEM_UNITS: dict[str, u.UnitBase] = {
"Flambda": u.erg / u.s / u.AA,
"Fnu": u.erg / u.s / u.Hz,
"ST": u.STmag,
"AB": u.ABmag,
"Vega": u.mag,
}
# (phot_from, phot_to) pairs whose underlying PhotConvert<From, To>
# specialization doesn't actually use its wl argument (Fnu<->AB and
# Flambda<->ST have fixed, wavelength-independent zero points;
# Flambda<->Vega uses fluxVega instead) -- see PhotCommons.hpp's own
# comments on each specialization for the physics. Every other pair
# genuinely depends on wavelength.
_WL_OPTIONAL_PAIRS: frozenset[tuple[str, str]] = frozenset({
("Fnu", "AB"), ("AB", "Fnu"),
("Flambda", "ST"), ("ST", "Flambda"),
("Flambda", "Vega"), ("Vega", "Flambda"),
})
def _detect_phot_system(unit: u.UnitBase) -> str:
"""
Deduce a phot_convert phot_from/phot_to string from a unit.
Parameters
----------
unit : astropy.units.UnitBase
The unit to deduce a photometric system from: mag(ST),
mag(AB), a bare magnitude, or a specific luminosity like
erg/(s Angstrom) or erg/(s Hz). Note that this is *not* the
same as a real physical flux density like Jy -- see
_PHOT_SYSTEM_UNITS's own comment for why Fnu's unit here is
erg/(s Hz), not Jy.
Returns
-------
str
One of "Flambda", "Fnu", "ST", "AB", or "Vega".
Raises
------
ValueError
If unit doesn't match any recognized photometric system --
e.g. Lsun (Lbol), Jy (a real flux density, not a specific
luminosity), or photon/s (an idealized photon-count filter),
none of which is a convertible photometric quantity here.
"""
if isinstance(unit, u.MagUnit):
physical = unit.physical_unit
if physical == u.Unit("ST"):
return "ST"
if physical == u.Unit("AB"):
return "AB"
elif unit.is_equivalent(u.mag):
return "Vega"
elif unit.is_equivalent(_PHOT_SYSTEM_UNITS["Fnu"]):
return "Fnu"
elif unit.is_equivalent(_PHOT_SYSTEM_UNITS["Flambda"]):
return "Flambda"
raise ValueError(
f"phot_convert: cannot deduce a photometric system from unit "
f"{unit!r} (expected mag(ST), mag(AB), a bare magnitude, or a "
"specific luminosity like erg/(s Angstrom) or erg/(s Hz))")
[docs]
def phot_convert(phot: u.Quantity, phot_to: str,
wl: u.Quantity | float | None = None, filt: Filter | None = None) -> u.Quantity:
"""
Convert a photometric Quantity from its own system to another.
Parameters
----------
phot : astropy.units.Quantity
The photometry to convert; its own unit determines the source
photometric system (see _detect_phot_system()).
phot_to : str
The photometric system to convert phot to: one of "Flambda",
"Fnu", "ST", "AB", or "Vega".
wl : astropy.units.Quantity or float, optional
The wavelength(s) at which phot is evaluated (a plain float is
interpreted as Angstrom); broadcast against phot, so e.g. a
whole spectrum can be converted in one call by passing
matching phot and wl arrays. Required only if the (phot_from,
phot_to) pair actually depends on wavelength -- every pair
except Fnu<->AB, Flambda<->ST, and Flambda<->Vega.
filt : Filter, optional
A filter whose fluxVega() gives the Vega zero point to use;
required if phot's own system or phot_to is "Vega", ignored
otherwise.
Returns
-------
astropy.units.Quantity
phot converted to phot_to, with phot_to's own unit attached.
Returns phot unchanged if it is already in phot_to's system.
Raises
------
ValueError
If phot's unit doesn't match a recognized photometric system,
if wl is required (see above) but not given, or if filt is
required (phot's own system or phot_to is "Vega") but not
given.
RuntimeError
If phot_to is not a recognized photometric system.
"""
phot_from = _detect_phot_system(phot.unit)
if phot_from == phot_to:
return phot
if (phot_from, phot_to) not in _WL_OPTIONAL_PAIRS and wl is None:
raise ValueError(
f"phot_convert: converting {phot_from} to {phot_to} requires a "
"wavelength (wl) -- this pair depends on wavelength, unlike "
"Fnu<->AB, Flambda<->ST, or Flambda<->Vega")
if wl is None:
wl_value = 0.0
elif isinstance(wl, u.Quantity):
# isinstance already guarantees this at runtime; cast because
# astropy ships no type stubs, so pyright can't narrow a
# Quantity | float union against it on its own (see
# pyrightconfig.json's own comment on useLibraryCodeForTypes)
wl_value = cast(u.Quantity, wl).to(u.AA).value
else:
wl_value = wl
if "Vega" in (phot_from, phot_to) and filt is None:
raise ValueError(
f"phot_convert: converting {phot_from} to {phot_to} requires a "
"filter (filt), to look up its own fluxVega()")
# phot.value is only correct if phot happens to already be expressed
# in _PHOT_SYSTEM_UNITS[phot_from] itself -- _detect_phot_system()
# accepts any equivalent unit (e.g. W/Hz for Fnu, erg/s/micron for
# Flambda), so phot must be explicitly converted to that canonical
# unit first, or an equivalent-but-differently-scaled input would
# silently pass the wrong number through to the compiled PhotConvert
# binding, which assumes its own fixed cgs convention.
phot_value = phot.to_value(_PHOT_SYSTEM_UNITS[phot_from])
result = PhotConvert(phot_from, phot_to, phot_value, wl_value, filt)
return result * _PHOT_SYSTEM_UNITS[phot_to]