#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Galactic structure priors for Bayesian stellar parameter estimation.
This module provides log-prior functions for Galactic structure modeling
including disk and halo number densities, metallicity distributions,
and age-metallicity relations. These priors encode our knowledge of the
Milky Way's stellar populations and are essential for distance estimation.
Functions
---------
logn_disk : Disk number density
Exponential disk model
logn_halo : Halo number density
Flattened power-law halo
logp_feh : Metallicity distribution
Gaussian metallicity distribution
logp_age_from_feh : Age-metallicity relation
Age distribution conditional on metallicity
logp_galactic_structure : Combined prior
Full Galactic structure prior combining disk and halo
See Also
--------
brutus.analysis.individual.BruteForce : Uses Galactic priors for distance fitting
brutus.priors.stellar : Stellar population priors
brutus.priors.astrometric : Parallax and proper motion priors
Notes
-----
These priors provide critical constraints on stellar distances by
incorporating knowledge of Galactic structure:
- **Number density priors** (disk and halo) weight distance based on
expected stellar distributions in the Galaxy
- **Metallicity priors** provide realistic [Fe/H] distributions for
disk and halo populations
- **Age-metallicity relations** link stellar age and composition
The combined prior `logp_galactic_structure` integrates disk and halo
models with appropriate mixing fractions.
The priors use Galactocentric coordinates, which requires coordinate
transformations from equatorial coordinates and distances.
Examples
--------
Evaluate disk number density:
>>> import numpy as np
>>> from brutus.priors.galactic import logn_disk
>>>
>>> # Position in Galactic disk
>>> R = np.array([8.0]) # kpc from Galactic center
>>> Z = np.array([0.1]) # kpc above midplane
>>> log_density = logn_disk(R, Z)
>>> print(f"Log-density: {log_density[0]:.3f}")
Combined Galactic structure prior:
>>> from brutus.priors.galactic import logp_galactic_structure
>>>
>>> # Galactic coordinates (l, b) in degrees and distances in kpc
>>> coord = (90.0, 30.0)
>>> distances = np.array([1.0, 2.0, 5.0])
>>>
>>> # Evaluate the combined thin disk + thick disk + halo prior
>>> log_prior = logp_galactic_structure(distances, coord)
"""
import warnings
from math import erf, exp, log, pi, sqrt
import numpy as np
from numba import jit, prange
# Import utility functions from brutus.utils
from brutus.utils import truncnorm_logpdf
from brutus.utils.math import galactic_to_galactocentric_cyl
_LOG_2PI = log(2.0 * pi)
_SQRT2 = sqrt(2.0)
def _logsumexp3(a, b, c):
"""Fast logsumexp of 3 arrays using numpy's logaddexp ufunc."""
return np.logaddexp(np.logaddexp(a, b), c)
@jit(nopython=True, parallel=True, cache=True)
def _galactic_prior_fused(
dists,
sin_l,
cos_l,
sin_b,
cos_b,
feh_arr,
loga_arr,
has_feh,
has_loga,
R_solar,
Z_solar,
# Thin disk
R_thin,
Z_thin,
Rs_thin,
# Thick disk
R_thick,
Z_thick,
Rs_thick,
f_thick,
# Halo
Rs_halo,
eta_halo,
q_halo_ctr,
q_halo_inf,
r_q_halo,
f_halo,
# Metallicity
feh_thin_mean,
feh_thin_sigma,
feh_thick_mean,
feh_thick_sigma,
feh_halo_mean,
feh_halo_sigma,
# Age
max_age,
min_age,
feh_age_ctr,
feh_age_scale,
nsigma_from_max_age,
max_sigma_age,
min_sigma_age,
):
"""Fused galactic prior: coords + density + vol + met + age in one pass.
``sin_l``/``cos_l``/``sin_b``/``cos_b`` are the precomputed sine/cosine
of the (scalar) Galactic longitude/latitude; the Galactocentric (R, Z)
conversion is folded into the parallel loop with the exact operation
order of ``galactic_to_galactocentric_cyl`` (bitwise-identical results,
no full-size temporaries).
"""
N = len(dists)
logp_out = np.empty(N)
# --- Loop-invariant constants (hoisted out of the prange loop) ---
# Solar halo normalization
r_solar = sqrt(R_solar**2 + Z_solar**2)
r_prime_solar = sqrt(r_solar**2 + r_q_halo**2)
q_solar = q_halo_inf - (q_halo_inf - q_halo_ctr) * exp(
1.0 - r_prime_solar / r_q_halo
)
R_eff_solar_halo = sqrt(R_solar**2 + (Z_solar / q_solar) ** 2 + Rs_halo**2)
# Solar disk normalizations use the SMOOTHED solar radius so the disk
# density is exactly 1 at the solar position (consistent with the halo
# normalization above and with ``logn_disk``); otherwise the f_thick /
# f_halo mixture fractions are silently rescaled by ~10%.
R_solar_eff_thin = sqrt(R_solar**2 + Rs_thin**2)
R_solar_eff_thick = sqrt(R_solar**2 + Rs_thick**2)
abs_Z_solar = abs(Z_solar)
ln_f_thick = log(f_thick)
ln_f_halo = log(f_halo)
# Age-prior constants depend only on the component metallicity means,
# not on the grid point: 3 exp + 6 erf + logs hoisted out of the loop.
age_mean_c = np.empty(3)
age_sigma_c = np.empty(3)
lndenom_c = np.empty(3)
for comp_idx in range(3):
if comp_idx == 0:
fm = feh_thin_mean
elif comp_idx == 1:
fm = feh_thick_mean
else:
fm = feh_halo_mean
age_mean = (max_age - min_age) / (
1.0 + exp((fm - feh_age_ctr) / feh_age_scale)
) + min_age
age_sigma = (max_age - age_mean) / nsigma_from_max_age
if age_sigma < min_sigma_age:
age_sigma = min_sigma_age
if age_sigma > max_sigma_age:
age_sigma = max_sigma_age
alpha = (min_age - age_mean) / age_sigma
beta = (max_age - age_mean) / age_sigma
denom = max(erf(beta / _SQRT2) - erf(alpha / _SQRT2), 1e-300)
age_mean_c[comp_idx] = age_mean
age_sigma_c[comp_idx] = age_sigma
lndenom_c[comp_idx] = log(age_sigma / 2.0) + log(denom)
for i in prange(N):
d = dists[i]
# Galactocentric cylindrical coordinates (same operation order as
# galactic_to_galactocentric_cyl for bitwise-identical results;
# explicit multiplication instead of **2 matches numpy's squaring
# fast path in both JIT and NUMBA_DISABLE_JIT modes)
x = d * cos_b * cos_l - R_solar
y = d * cos_b * sin_l
R = sqrt(x * x + y * y)
Z = d * sin_b + Z_solar
# Volume factor
vol = 2.0 * log(d + 1e-300)
# Thin disk
R_eff_thin = sqrt(R**2 + Rs_thin**2)
lnp_thin = (
-(R_eff_thin - R_solar_eff_thin) / R_thin
- (abs(Z) - abs_Z_solar) / Z_thin
+ vol
)
# Thick disk
R_eff_thick = sqrt(R**2 + Rs_thick**2)
lnp_thick = (
-(R_eff_thick - R_solar_eff_thick) / R_thick
- (abs(Z) - abs_Z_solar) / Z_thick
+ vol
+ ln_f_thick
)
# Halo
r = sqrt(R**2 + Z**2)
r_prime = sqrt(r**2 + r_q_halo**2)
q = q_halo_inf - (q_halo_inf - q_halo_ctr) * exp(1.0 - r_prime / r_q_halo)
R_eff_halo = sqrt(R**2 + (Z / q) ** 2 + Rs_halo**2)
lnp_halo = -eta_halo * log(R_eff_halo / R_eff_solar_halo) + vol + ln_f_halo
# logsumexp3
mx = max(lnp_thin, max(lnp_thick, lnp_halo))
logp_total = mx + log(
exp(lnp_thin - mx) + exp(lnp_thick - mx) + exp(lnp_halo - mx)
)
# Metallicity prior
feh_lnp = 0.0
feh_lnp_thin = 0.0
feh_lnp_thick = 0.0
feh_lnp_halo = 0.0
if has_feh:
feh_val = feh_arr[i]
# Component membership
ln_w_thin = lnp_thin - logp_total
ln_w_thick = lnp_thick - logp_total
ln_w_halo = lnp_halo - logp_total
# Gaussian logpdf for each component
feh_lnp_thin = (
-0.5
* (
(feh_val - feh_thin_mean) ** 2 / feh_thin_sigma**2
+ _LOG_2PI
+ 2 * log(feh_thin_sigma)
)
+ ln_w_thin
)
feh_lnp_thick = (
-0.5
* (
(feh_val - feh_thick_mean) ** 2 / feh_thick_sigma**2
+ _LOG_2PI
+ 2 * log(feh_thick_sigma)
)
+ ln_w_thick
)
feh_lnp_halo = (
-0.5
* (
(feh_val - feh_halo_mean) ** 2 / feh_halo_sigma**2
+ _LOG_2PI
+ 2 * log(feh_halo_sigma)
)
+ ln_w_halo
)
mx2 = max(feh_lnp_thin, max(feh_lnp_thick, feh_lnp_halo))
feh_lnp = mx2 + log(
exp(feh_lnp_thin - mx2)
+ exp(feh_lnp_thick - mx2)
+ exp(feh_lnp_halo - mx2)
)
logp_total += feh_lnp
# Age prior
if has_loga:
age_val = 10.0 ** loga_arr[i] / 1e9 # Gyr
if has_feh:
# Metallicity-updated membership weights: the joint mixture
# prior is sum_c w_c(pos) p(feh|c) p(age|c), so the age term
# must weight components by w_c(pos) p(feh|c) / Z_feh rather
# than by the position-only w_c(pos).
ln_w_thin = feh_lnp_thin - feh_lnp
ln_w_thick = feh_lnp_thick - feh_lnp
ln_w_halo = feh_lnp_halo - feh_lnp
else:
ln_w_thin = lnp_thin - logp_total
ln_w_thick = lnp_thick - logp_total
ln_w_halo = lnp_halo - logp_total
# Compute truncated normal for each component
age_lnp_total = -1e300
for comp_idx in range(3):
if comp_idx == 0:
ln_w = ln_w_thin
elif comp_idx == 1:
ln_w = ln_w_thick
else:
ln_w = ln_w_halo
xi = (age_val - age_mean_c[comp_idx]) / age_sigma_c[comp_idx]
lnphi = -0.5 * (_LOG_2PI + xi * xi)
age_comp = lnphi - lndenom_c[comp_idx] + ln_w
# Bounds check
if age_val < min_age or age_val > max_age:
age_comp = -1e300
# Accumulate logsumexp
if age_comp > age_lnp_total:
age_lnp_total = (
age_comp + log(1.0 + exp(age_lnp_total - age_comp))
if age_lnp_total > -1e200
else age_comp
)
else:
age_lnp_total = (
age_lnp_total + log(1.0 + exp(age_comp - age_lnp_total))
if age_comp > -1e200
else age_lnp_total
)
logp_total += age_lnp_total
logp_out[i] = logp_total
return logp_out
__all__ = [
"logn_disk",
"logn_halo",
"logp_feh",
"logp_age_from_feh",
"logp_galactic_structure",
]
[docs]
def logn_disk(R, Z, R_solar=8.2, Z_solar=0.025, R_scale=2.6, Z_scale=0.3, R_smooth=2.0):
r"""
Log-number density for the Galactic disk stellar population.
Implements an exponential disk model with separate radial and vertical
scale lengths, smoothed near the Galactic center to avoid singularities.
Parameters
----------
R : array_like
Galactocentric cylindrical radius in kpc.
Z : array_like
Height above the Galactic midplane in kpc.
R_solar : float, optional
Solar Galactocentric radius in kpc. Default is 8.2.
Z_solar : float, optional
Solar height above midplane in kpc. Default is 0.025.
R_scale : float, optional
Disk radial scale length in kpc. Default is 2.6.
Z_scale : float, optional
Disk vertical scale height in kpc. Default is 0.3.
R_smooth : float, optional
Smoothing radius to avoid central singularity in kpc. Default is 2.0.
Returns
-------
logn : array_like
Normalized log-number density relative to Solar neighborhood.
See Also
--------
logn_halo : Halo number density
logp_galactic_structure : Combined disk+halo model
Notes
-----
The disk number density follows:
.. math::
n_{\\text{disk}}(R, Z) \\propto \\exp\\left(-\\frac{R_{\\text{eff}} - R_{\\text{eff},\\odot}}{R_{\\text{scale}}} - \\frac{|Z| - |Z_\\odot|}{Z_{\\text{scale}}}\\right)
where :math:`R_{\\text{eff}} = \\sqrt{R^2 + R_{\\text{smooth}}^2}` provides
smoothing near the Galactic center and
:math:`R_{\\text{eff},\\odot} = \\sqrt{R_\\odot^2 + R_{\\text{smooth}}^2}`
is the smoothed solar radius, so the normalized density is exactly 1 at
the solar position (consistent with :func:`logn_halo`). This keeps the
mixture fractions (``f_thick``, ``f_halo``) in
:func:`logp_galactic_structure` exact at the solar position.
References
----------
Bland-Hawthorn & Gerhard (2016) - The Galaxy in Context
"""
R = np.asarray(R)
Z = np.asarray(Z)
# Smoothed effective radius
R_eff = np.sqrt(R**2 + R_smooth**2)
# Exponential disk components, normalized at the SMOOTHED solar radius
# so that logn_disk(R_solar, Z_solar) == 0 (as for logn_halo).
R_eff_solar = np.sqrt(R_solar**2 + R_smooth**2)
radial_term = (R_eff - R_eff_solar) / R_scale
vertical_term = (np.abs(Z) - np.abs(Z_solar)) / Z_scale
return -(radial_term + vertical_term)
[docs]
def logn_halo(
R,
Z,
R_solar=8.2,
Z_solar=0.025,
R_smooth=2.0,
eta=4.2,
q_ctr=0.2,
q_inf=0.8,
r_q=6.0,
):
r"""
Log-number density for the Galactic halo stellar population.
Implements a flattened power-law halo model with radius-dependent
oblateness following observational constraints.
Parameters
----------
R : array_like
Galactocentric cylindrical radius in kpc.
Z : array_like
Height above the Galactic midplane in kpc.
R_solar : float, optional
Solar Galactocentric radius in kpc. Default is 8.2.
Z_solar : float, optional
Solar height above midplane in kpc. Default is 0.025.
R_smooth : float, optional
Smoothing radius to avoid central singularity in kpc. Default is 2.0.
eta : float, optional
Power-law index for halo density profile. Default is 4.2.
q_ctr : float, optional
Halo oblateness at Galactic center. Default is 0.2.
q_inf : float, optional
Halo oblateness at large radii. Default is 0.8.
r_q : float, optional
Scale radius for oblateness transition in kpc. Default is 6.0.
Returns
-------
logn : array_like
Normalized log-number density relative to Solar neighborhood.
See Also
--------
logn_disk : Disk number density
logp_galactic_structure : Combined disk+halo model
Notes
-----
The halo follows a flattened power-law profile:
.. math::
n_{\\text{halo}}(R, Z) \\propto R_{\\text{eff}}^{-\\eta}
where the effective radius includes radius-dependent flattening:
.. math::
R_{\\text{eff}} = \\sqrt{R^2 + (Z/q)^2 + R_{\\text{smooth}}^2}
q(r) = q_\\infty - (q_\\infty - q_{\\text{ctr}}) e^{1 - r'/r_q}
r' = \\sqrt{r^2 + r_q^2}, \\quad r = \\sqrt{R^2 + Z^2}
References
----------
Bland-Hawthorn & Gerhard (2016) - The Galaxy in Context
Bell et al. (2008) - Stellar Halo Properties from SDSS
"""
R = np.asarray(R)
Z = np.asarray(Z)
# Spherical radius from Galactic center
r = np.sqrt(R**2 + Z**2)
# Radius-dependent oblateness
r_prime = np.sqrt(r**2 + r_q**2)
q = q_inf - (q_inf - q_ctr) * np.exp(1.0 - r_prime / r_q)
# Effective radius with flattening and smoothing
R_eff = np.sqrt(R**2 + (Z / q) ** 2 + R_smooth**2)
# Solar normalization values
r_solar = np.sqrt(R_solar**2 + Z_solar**2)
r_prime_solar = np.sqrt(r_solar**2 + r_q**2)
q_solar = q_inf - (q_inf - q_ctr) * np.exp(1.0 - r_prime_solar / r_q)
R_eff_solar = np.sqrt(R_solar**2 + (Z_solar / q_solar) ** 2 + R_smooth**2)
# Power-law halo profile
logn = -eta * np.log(R_eff / R_eff_solar)
return logn
[docs]
def logp_feh(feh, feh_mean=-0.2, feh_sigma=0.3):
r"""
Log-prior for stellar metallicity in Galactic components.
Implements a Gaussian metallicity distribution appropriate for
different Galactic stellar populations (disk, thick disk, halo).
Parameters
----------
feh : array_like
Stellar metallicity [Fe/H] in dex.
feh_mean : float, optional
Mean metallicity of the population in dex. Default is -0.2 (thin disk).
feh_sigma : float, optional
Metallicity dispersion in dex. Default is 0.3.
Returns
-------
logp : array_like
Normalized log-probability density for the input metallicities.
Notes
-----
The metallicity prior follows a normal distribution:
.. math::
\\log p([\\text{Fe/H}]) = -\\frac{1}{2}\\left[\\frac{([\\text{Fe/H}] - \\mu_{\\text{Fe/H}})^2}{\\sigma_{\\text{Fe/H}}^2} + \\log(2\\pi\\sigma_{\\text{Fe/H}}^2)\\right]
Typical values for different Galactic components:
- Thin disk: feh_mean = -0.2, feh_sigma = 0.3
- Thick disk: feh_mean = -0.7, feh_sigma = 0.4
- Halo: feh_mean = -1.6, feh_sigma = 0.5
References
----------
Bland-Hawthorn & Gerhard (2016) - The Galaxy in Context
"""
feh = np.asarray(feh)
# Gaussian log-prior
chi2 = (feh - feh_mean) ** 2 / feh_sigma**2
log_norm = np.log(2.0 * np.pi * feh_sigma**2)
logp = -0.5 * (chi2 + log_norm)
return logp
[docs]
def logp_age_from_feh(
age,
feh_mean=-0.2,
max_age=13.8,
min_age=0.0,
feh_age_ctr=-0.5,
feh_age_scale=0.5,
nsigma_from_max_age=2.0,
max_sigma=4.0,
min_sigma=1.0,
):
r"""
Log-prior for stellar age based on metallicity-age relation.
Implements the age-metallicity relation observed in the Galactic disk,
where older stars tend to be more metal-poor. Uses truncated normal
distribution bounded by physically reasonable ages.
Parameters
----------
age : array_like
Stellar ages in Gyr.
feh_mean : float, optional
Mean metallicity of the population in dex. Default is -0.2.
max_age : float, optional
Maximum allowed stellar age in Gyr. Default is 13.8 (age of Universe).
min_age : float, optional
Minimum allowed stellar age in Gyr. Default is 0.0.
feh_age_ctr : float, optional
Metallicity where mean age is halfway between min/max. Default is -0.5.
feh_age_scale : float, optional
Scale length for metallicity-age relation in dex. Default is 0.5.
nsigma_from_max_age : float, optional
Number of σ the mean age is below max_age. Default is 2.0.
max_sigma : float, optional
Maximum age dispersion in Gyr. Default is 4.0.
min_sigma : float, optional
Minimum age dispersion in Gyr. Default is 1.0.
Returns
-------
logp : array_like
Normalized log-probability density for the input ages.
Notes
-----
The age-metallicity relation follows a logistic function:
.. math::
\\langle t \\rangle = \\frac{t_{\\max} - t_{\\min}}{1 + \\exp\\left(\\frac{[\\text{Fe/H}] - c}{s}\\right)} + t_{\\min}
where c is the central metallicity and s is the scale length.
The age dispersion decreases for younger (more metal-rich) stars:
.. math::
\\sigma_t = \\min\\left(\\max\\left(\\frac{t_{\\max} - \\langle t \\rangle}{n\\sigma}, \\sigma_{\\min}\\right), \\sigma_{\\max}\\right)
Ages are drawn from a truncated normal distribution bounded by [min_age, max_age].
References
----------
Bland-Hawthorn & Gerhard (2016) - The Galaxy in Context
Nordström et al. (2004) - Age-metallicity relation in Solar neighborhood
"""
age = np.asarray(age)
# Predicted mean age from metallicity
age_mean_pred = (max_age - min_age) / (
1.0 + np.exp((feh_mean - feh_age_ctr) / feh_age_scale)
) + min_age
# Age dispersion (younger stars have smaller dispersion)
age_sigma_pred = (max_age - age_mean_pred) / nsigma_from_max_age
age_sigma_pred = np.clip(age_sigma_pred, min_sigma, max_sigma)
# Truncated normal distribution bounds
a = (min_age - age_mean_pred) / age_sigma_pred # Lower bound
b = (max_age - age_mean_pred) / age_sigma_pred # Upper bound
# Compute truncated normal log-probability
logp = truncnorm_logpdf(age, a, b, loc=age_mean_pred, scale=age_sigma_pred)
return logp
[docs]
def logp_galactic_structure(
dists,
coord,
labels=None,
feh=None,
loga=None,
R_solar=8.2,
Z_solar=0.025,
R_thin=2.6,
Z_thin=0.3,
Rs_thin=2.0,
R_thick=2.0,
Z_thick=0.9,
f_thick=0.04,
Rs_thick=2.0,
Rs_halo=2.0,
q_halo_ctr=0.2,
q_halo_inf=0.8,
r_q_halo=6.0,
eta_halo=4.2,
f_halo=0.005,
feh_thin=-0.2,
feh_thin_sigma=0.3,
feh_thick=-0.7,
feh_thick_sigma=0.4,
feh_halo=-1.6,
feh_halo_sigma=0.5,
max_age=13.8,
min_age=0.0,
feh_age_ctr=-0.5,
feh_age_scale=0.5,
nsigma_from_max_age=2.0,
max_sigma=4.0,
min_sigma=1.0,
return_components=False,
):
"""
Complete Galactic structure log-prior with thin disk, thick disk, and halo.
Implements a sophisticated three-component Galactic model based on
Bland-Hawthorn & Gerhard (2016). Combines spatial number density priors
with optional metallicity and age priors for realistic stellar populations.
Parameters
----------
dists : array_like
Distance from observer in kpc.
coord : tuple of floats
Galactic coordinates (l, b) in degrees.
labels : structured array, optional
Stellar labels containing 'feh' and/or 'loga' for metallicity/age priors.
feh : array_like, optional
Per-point [Fe/H] as a plain float array, an alternative to ``labels``
for the metallicity prior. Lets callers (e.g. ``logpost_grid``) avoid
tiling a structured ``labels`` array; equivalent to passing the same
values via ``labels``. Honored on every code path (fused kernel,
numpy fallback, and small arrays). If both ``labels`` and
``feh``/``loga`` are given, the plain arrays take precedence.
loga : array_like, optional
Per-point log10(age/yr) as a plain float array, an alternative to
``labels`` for the age prior (see ``feh``).
R_solar : float, optional
Solar Galactocentric radius in kpc. Default is 8.2.
Z_solar : float, optional
Solar height above midplane in kpc. Default is 0.025.
R_thin : float, optional
Thin disk radial scale length in kpc. Default is 2.6.
Z_thin : float, optional
Thin disk vertical scale height in kpc. Default is 0.3.
Rs_thin : float, optional
Thin disk smoothing radius in kpc. Default is 2.0.
R_thick : float, optional
Thick disk radial scale length in kpc. Default is 2.0.
Z_thick : float, optional
Thick disk vertical scale height in kpc. Default is 0.9.
f_thick : float, optional
Thick disk relative normalization. Default is 0.04.
Rs_thick : float, optional
Thick disk smoothing radius in kpc. Default is 2.0.
Rs_halo : float, optional
Halo smoothing radius in kpc. Default is 2.0.
q_halo_ctr : float, optional
Halo central oblateness. Default is 0.2.
q_halo_inf : float, optional
Halo asymptotic oblateness. Default is 0.8.
r_q_halo : float, optional
Halo oblateness transition radius in kpc. Default is 6.0.
eta_halo : float, optional
Halo power-law index. Default is 4.2.
f_halo : float, optional
Halo relative normalization. Default is 0.005.
feh_thin : float, optional
Thin disk mean metallicity in dex. Default is -0.2.
feh_thin_sigma : float, optional
Thin disk metallicity dispersion in dex. Default is 0.3.
feh_thick : float, optional
Thick disk mean metallicity in dex. Default is -0.7.
feh_thick_sigma : float, optional
Thick disk metallicity dispersion in dex. Default is 0.4.
feh_halo : float, optional
Halo mean metallicity in dex. Default is -1.6.
feh_halo_sigma : float, optional
Halo metallicity dispersion in dex. Default is 0.5.
max_age : float, optional
Maximum stellar age in Gyr. Default is 13.8.
min_age : float, optional
Minimum stellar age in Gyr. Default is 0.0.
feh_age_ctr : float, optional
Central metallicity for age-metallicity relation. Default is -0.5.
feh_age_scale : float, optional
Scale length for age-metallicity relation. Default is 0.5.
nsigma_from_max_age : float, optional
Age dispersion parameter. Default is 2.0.
max_sigma : float, optional
Maximum age dispersion in Gyr. Default is 4.0.
min_sigma : float, optional
Minimum age dispersion in Gyr. Default is 1.0.
return_components : bool, optional
Whether to return individual component contributions. Default is False.
Returns
-------
logp : array_like
Total log-prior probability density.
components : dict, optional
Individual component contributions (if return_components=True).
Notes
-----
The Galactic model combines three stellar populations:
1. **Thin Disk**: Young, metal-rich stars with small scale height
2. **Thick Disk**: Intermediate-age, metal-poor stars with larger scale height
3. **Halo**: Old, very metal-poor stars with flattened power-law profile
Each component has distinct spatial, metallicity, and age distributions
calibrated from observations. The model accounts for:
- Coordinate transformations from observer to Galactocentric frame
- Volume correction factors (dV ∝ distance²)
- Component membership probabilities
- Conditional metallicity and age priors
When stellar labels are provided, applies population-specific priors:
- Metallicity: Gaussian distributions with different means/dispersions
- Age: Age-metallicity relation with truncated normal distributions
When both metallicity and age are supplied, the joint prior is the
proper mixture ``sum_c w_c(pos) p(feh|c) p(age|c)``: the age term
weights each component by its metallicity-updated membership
probability ``w_c(pos) p(feh|c) / sum_c' w_c'(pos) p(feh|c')``, not by
the position-only weight. This matters for chemically extreme stars
(e.g. nearby metal-poor stars are correctly weighted toward old,
halo-like ages).
References
----------
Bland-Hawthorn & Gerhard (2016) - The Galaxy in Context
"""
dists = np.asarray(dists, dtype=float)
# Extract Galactic (l, b) in degrees
if hasattr(coord, "galactic"):
# coord is already a SkyCoord object
ell = coord.galactic.l.deg
b = coord.galactic.b.deg
else:
# coord is a tuple of (l, b) in degrees
ell, b = coord[0], coord[1]
# Callers may pass the per-point ``feh`` / ``loga`` as plain float arrays
# directly (instead of a structured ``labels`` array). This is the hot path
# from logpost_grid, where it avoids tiling a structured array across all MC
# samples -- a numpy structured-dtype ``np.tile`` is ~100x slower than the
# equivalent float tile. The values handed to the fused kernel are identical
# either way, so results are bitwise-identical to the structured path.
use_arrays = feh is not None or loga is not None
if use_arrays:
# Normalize to contiguous float64 up front so the numba kernel types
# cleanly (lists, float32, object arrays) and misuse gives an
# actionable error rather than failing later inside the kernel.
if feh is not None:
feh = np.ascontiguousarray(feh, dtype=np.float64)
if feh.ndim != 1 or feh.shape[0] != len(dists):
raise ValueError(
f"`feh` must be a 1D array matching len(dists)="
f"{len(dists)}; got shape {np.shape(feh)}."
)
if loga is not None:
loga = np.ascontiguousarray(loga, dtype=np.float64)
if loga.ndim != 1 or loga.shape[0] != len(dists):
raise ValueError(
f"`loga` must be a 1D array matching len(dists)="
f"{len(dists)}; got shape {np.shape(loga)}."
)
# A plain array overrides only its own field. Any field not supplied
# as an array is sourced from a caller-provided structured `labels`,
# so mixing the two call styles cannot silently drop the metallicity
# or age prior (previously `feh=` alongside labels containing 'loga'
# discarded the age term).
if labels is not None:
if feh is None and "feh" in labels.dtype.names:
feh = np.ascontiguousarray(labels["feh"], dtype=np.float64)
if loga is None and "loga" in labels.dtype.names:
loga = np.ascontiguousarray(labels["loga"], dtype=np.float64)
# Fast path: use fused numba kernel when labels are provided and
# return_components is not needed. Eliminates ~15 temporary arrays
# (the coordinate conversion and volume factor are computed per point
# inside the kernel). Requires a single (scalar) sky position.
if (
(labels is not None or use_arrays)
and not return_components
and len(dists) > 1000
and np.ndim(ell) == 0
and np.ndim(b) == 0
):
if use_arrays:
has_feh = feh is not None
has_loga = loga is not None
feh_arr = feh if has_feh else np.empty(0)
loga_arr = loga if has_loga else np.empty(0)
else:
has_feh = "feh" in labels.dtype.names
has_loga = "loga" in labels.dtype.names
feh_arr = labels["feh"] if has_feh else np.empty(0)
loga_arr = labels["loga"] if has_loga else np.empty(0)
ell_rad = np.deg2rad(float(ell))
b_rad = np.deg2rad(float(b))
try:
return _galactic_prior_fused(
dists,
np.sin(ell_rad),
np.cos(ell_rad),
np.sin(b_rad),
np.cos(b_rad),
feh_arr,
loga_arr,
has_feh,
has_loga,
R_solar,
Z_solar,
R_thin,
Z_thin,
Rs_thin,
R_thick,
Z_thick,
Rs_thick,
f_thick,
Rs_halo,
eta_halo,
q_halo_ctr,
q_halo_inf,
r_q_halo,
f_halo,
feh_thin,
feh_thin_sigma,
feh_thick,
feh_thick_sigma,
feh_halo,
feh_halo_sigma,
max_age,
min_age,
feh_age_ctr,
feh_age_scale,
nsigma_from_max_age,
max_sigma,
min_sigma,
)
except Exception as e:
warnings.warn(
f"Numba fused galactic prior failed, falling back to numpy: {e}",
RuntimeWarning,
stacklevel=2,
)
# ---- Numpy path (small arrays, return_components, array coordinates,
# or fused-kernel fallback; also the NUMBA_DISABLE_JIT coverage path) ----
# Volume correction factor (dV ∝ r² dr); the fused kernel computes this
# per point, so it is only materialized here.
vol_factor = 2.0 * np.log(dists + 1e-300)
# Convert to Galactocentric cylindrical coordinates using fast NumPy math
R, Z = galactic_to_galactocentric_cyl(
dists, ell, b, R_solar=R_solar, Z_solar=Z_solar
)
# Plain feh/loga arrays must receive the exact same priors as the fused
# kernel: synthesize a structured labels array so the code below applies
# them (previously these were silently dropped on this path).
if use_arrays:
_dtype = []
if feh is not None:
_dtype.append(("feh", np.float64))
if loga is not None:
_dtype.append(("loga", np.float64))
labels = np.empty(len(dists), dtype=_dtype)
if feh is not None:
labels["feh"] = feh
if loga is not None:
labels["loga"] = loga
# Thin disk component
logp_thin = logn_disk(
R,
Z,
R_solar=R_solar,
Z_solar=Z_solar,
R_scale=R_thin,
Z_scale=Z_thin,
R_smooth=Rs_thin,
)
logp_thin += vol_factor
# Thick disk component
logp_thick = logn_disk(
R,
Z,
R_solar=R_solar,
Z_solar=Z_solar,
R_scale=R_thick,
Z_scale=Z_thick,
R_smooth=Rs_thick,
)
logp_thick += vol_factor + np.log(f_thick)
# Halo component
logp_halo = logn_halo(
R,
Z,
R_solar=R_solar,
Z_solar=Z_solar,
R_smooth=Rs_halo,
eta=eta_halo,
q_ctr=q_halo_ctr,
q_inf=q_halo_inf,
r_q=r_q_halo,
)
logp_halo += vol_factor + np.log(f_halo)
# Combined number density prior
logp = _logsumexp3(logp_thin, logp_thick, logp_halo)
# Component tracking
components = {"number_density": [logp_thin, logp_thick, logp_halo]}
# Apply metallicity and age priors if labels provided
if labels is not None:
# Component membership probabilities (reuse logp_thin/thick/halo)
lnprior_thin = logp_thin - logp
lnprior_thick = logp_thick - logp
lnprior_halo = logp_halo - logp
# Membership weights for the age mixture. Position-only by default;
# updated with the metallicity evidence below when 'feh' is present,
# so the joint prior is sum_c w_c(pos) p(feh|c) p(age|c) (matching
# the fused kernel) rather than a product of marginal mixtures.
age_w_thin = lnprior_thin
age_w_thick = lnprior_thick
age_w_halo = lnprior_halo
# Metallicity prior — fused computation to minimize temporaries
if "feh" in labels.dtype.names:
try:
feh = labels["feh"]
# Inline Gaussian logpdf for all 3 components at once
# logp_feh(feh, mu, sigma) = -0.5*((feh-mu)^2/sigma^2 + log(2*pi*sigma^2))
feh_lnp_thin = (
-0.5
* (
(feh - feh_thin) ** 2 / feh_thin_sigma**2
+ np.log(2.0 * np.pi * feh_thin_sigma**2)
)
+ lnprior_thin
)
feh_lnp_thick = (
-0.5
* (
(feh - feh_thick) ** 2 / feh_thick_sigma**2
+ np.log(2.0 * np.pi * feh_thick_sigma**2)
)
+ lnprior_thick
)
feh_lnp_halo = (
-0.5
* (
(feh - feh_halo) ** 2 / feh_halo_sigma**2
+ np.log(2.0 * np.pi * feh_halo_sigma**2)
)
+ lnprior_halo
)
feh_lnp = _logsumexp3(feh_lnp_thin, feh_lnp_thick, feh_lnp_halo)
logp += feh_lnp
components["feh"] = [feh_lnp_thin, feh_lnp_thick, feh_lnp_halo]
# Metallicity-updated membership weights for the age mixture
age_w_thin = feh_lnp_thin - feh_lnp
age_w_thick = feh_lnp_thick - feh_lnp
age_w_halo = feh_lnp_halo - feh_lnp
except (KeyError, IndexError, ValueError) as e:
warnings.warn(
f"Metallicity prior computation failed: {e}",
RuntimeWarning,
stacklevel=2,
)
# Age prior — fused computation
if "loga" in labels.dtype.names:
try:
age = 10.0 ** labels["loga"] / 1e9 # Convert log(age) to Gyr
# Compute age priors for all 3 components with shared parameters.
# logp_age_from_feh computes a truncated normal; inline the core
# computation to avoid 3 separate function calls + temporaries.
age_params = []
for fm, lnp_comp in [
(feh_thin, age_w_thin),
(feh_thick, age_w_thick),
(feh_halo, age_w_halo),
]:
age_mean = (max_age - min_age) / (
1.0 + np.exp((fm - feh_age_ctr) / feh_age_scale)
) + min_age
age_sigma = np.clip(
(max_age - age_mean) / nsigma_from_max_age,
min_sigma,
max_sigma,
)
age_params.append(
truncnorm_logpdf(
age,
(min_age - age_mean) / age_sigma,
(max_age - age_mean) / age_sigma,
loc=age_mean,
scale=age_sigma,
)
+ lnp_comp
)
age_lnp_thin, age_lnp_thick, age_lnp_halo = age_params
age_lnp = _logsumexp3(age_lnp_thin, age_lnp_thick, age_lnp_halo)
logp += age_lnp
components["age"] = [age_lnp_thin, age_lnp_thick, age_lnp_halo]
except (KeyError, IndexError, ValueError) as e:
warnings.warn(
f"Age prior computation failed: {e}",
RuntimeWarning,
stacklevel=2,
)
if return_components:
return logp, components
else:
return logp