Skip to content

Commit e8be70c

Browse files
committed
implement BetaCEM
1 parent d11fecd commit e8be70c

1 file changed

Lines changed: 275 additions & 2 deletions

File tree

src/pst/models.py

Lines changed: 275 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from astropy import units as u
55
from scipy import special
66
from scipy import interpolate
7+
from scipy.special import betainc
78

89
from pst.SSP import SSPBase
910
from pst.utils import check_unit, SQRT_2
@@ -269,7 +270,10 @@ def compute_photometry(self, ssp, t_obs, photometry=None) -> u.Quantity:
269270
return model_photometry
270271

271272

272-
#-------------------------------------------------------------------------------
273+
# --------------------------------------------------------------------
274+
# ---------------------- Analytical models ---------------------------
275+
# --------------------------------------------------------------------
276+
273277
class SingleBurstCEM(ChemicalEvolutionModel):
274278
"""
275279
Single-burst star formation model.
@@ -588,8 +592,277 @@ def __init__(self, **kwargs):
588592
def stellar_mass_formed(self, times: u.Quantity):
589593
return super().stellar_mass_formed(times)
590594

595+
class BetaCEM(ChemicalEvolutionModel):
596+
"""
597+
Chemical evolution model whose cumulative *formed* stellar mass follows a
598+
regularised incomplete Beta function (Beta-CMF).
599+
600+
The cumulative fraction of formed mass is
601+
:math:`f_\\star(t) = I_x(\\alpha, \\beta)`, with
602+
:math:`x = \\frac{t - t_\\mathrm{start}}{t_\\mathrm{end} - t_\\mathrm{start}} \\in [0,1]`,
603+
where :math:`I_x` is the regularised incomplete Beta function.
604+
The total formed stellar mass at time ``t`` is then
605+
:math:`M_\\mathrm{formed}(t) = M_{\\mathrm{formed},\\infty} f_\\star(t)`.
606+
607+
Parameters
608+
----------
609+
mass_today : :class:`astropy.units.Quantity`
610+
Asymptotic total *formed* stellar mass by :math:`t_\mathrm{end}`, e.g.
611+
in :math:`\mathrm{M_\odot}`.
612+
alpha, beta : float, optional
613+
Beta-CMF shape parameters (>0). Smaller :math:`\alpha<1` favours earlier
614+
build-up (rapid rise), larger :math:`\alpha>1` delays the early growth.
615+
Larger :math:`\beta>1` sharpens the late-time truncation. Provide these
616+
**or** ``t_mean`` and ``kappa``.
617+
t_mean : :class:`astropy.units.Quantity` or float, optional
618+
Mean formation time. If a Quantity, it is interpreted in Gyr and will be
619+
internally converted to the fractional mean
620+
:math:`m=t_\mathrm{mean}/t_\mathrm{end}`. If a float, it must already be
621+
the fractional mean :math:`m\in(0,1)`. Provide this **with** ``kappa``
622+
as an alternative to ``alpha``/``beta``.
623+
kappa : float, optional
624+
Concentration :math:`\kappa=\alpha+\beta>0` used together with
625+
``t_mean`` to derive :math:`\alpha,\beta`.
626+
today : :class:`astropy.units.Quantity`, optional
627+
Age of the Universe (or modelling horizon). Used as ``t_end`` if
628+
``t_end`` is not given. You may also set :attr:`today` later.
629+
t_start : :class:`astropy.units.Quantity`, optional
630+
Cosmic time when star formation starts. Default is ``0 Gyr``.
631+
t_end : :class:`astropy.units.Quantity`, optional
632+
Cosmic time when the model stops. If ``None``, uses ``today``. Must be
633+
strictly greater than ``t_start``.
634+
ism_metallicity_today : float or :class:`astropy.units.Quantity`, optional
635+
Present-day ISM metallicity (mass fraction). Returned by
636+
:meth:`ism_metallicity`. Default is ``0.02`` (≈ :math:`Z_\odot`).
637+
638+
Attributes
639+
----------
640+
alpha, beta : float
641+
Final shape parameters used by the model (derived if ``t_mean``/``kappa``
642+
were supplied).
643+
t_start, t_end : :class:`astropy.units.Quantity`
644+
Time bounds of the model.
645+
mass_today : :class:`astropy.units.Quantity`
646+
Asymptotic formed stellar mass by :math:`t_\mathrm{end}`.
647+
ism_metallicity_today : float or :class:`astropy.units.Quantity`
648+
ISM metallicity returned by :meth:`ism_metallicity`.
649+
650+
"""
651+
652+
def __init__(self,
653+
mass_today: u.Quantity,
654+
alpha: float = None,
655+
beta: float = None,
656+
t_mean: u.Quantity | float = None,
657+
kappa : float = None,
658+
today: u.Quantity | None = None,
659+
t_start: u.Quantity = 0.0 * u.Gyr,
660+
t_end: u.Quantity | None = None,
661+
ism_metallicity_today: float | u.Quantity = 0.02,
662+
**kwargs):
663+
664+
super().__init__(today=today, **kwargs)
665+
666+
if alpha <= 0 or beta <= 0:
667+
raise ValueError("Beta-CMF shape parameters must be > 0.")
668+
669+
self.mass_today = check_unit(mass_today, u.Msun)
670+
self.t_start = check_unit(t_start, u.Gyr)
671+
self._t_end = check_unit(t_end, u.Gyr) if t_end is not None else None
672+
673+
if t_mean is not None and kappa is not None:
674+
alpha, beta = self._alpha_beta_from_mean_concentration(t_mean, kappa)
675+
elif alpha is None and beta is None:
676+
raise AttributeError("Must provide a value for mean/concentration"
677+
" or alpha/beta parameters")
678+
679+
self.alpha = alpha
680+
self.beta = beta
681+
self.ism_metallicity_today = ism_metallicity_today
682+
683+
@property
684+
def alpha(self):
685+
r"""Beta-CMF early-time shape parameter (:math:`\alpha>0`)."""
686+
return self._alpha
687+
688+
@alpha.setter
689+
def alpha(self, alpha):
690+
if alpha <= 0:
691+
raise ValueError("alpha must be > 0.")
692+
self._alpha = alpha
693+
694+
@property
695+
def beta(self):
696+
r"""Beta-CMF late-time shape parameter (:math:`\beta>0`)."""
697+
return self._beta
698+
699+
@beta.setter
700+
def beta(self, beta):
701+
if beta <= 0:
702+
raise ValueError("beta must be > 0.")
703+
self._beta = beta
704+
705+
@property
706+
def t_end(self) -> u.Quantity:
707+
"""Modelling horizon (defaults to self.today if t_end not explicitly set)."""
708+
if self._t_end is not None:
709+
return self._t_end
710+
if self.today is None:
711+
raise ValueError("t_end is not set and `today` is None; set one of them.")
712+
return self.today
713+
714+
@property
715+
def t_mean(self):
716+
r"""
717+
Mean formation time :math:`t_\mathrm{mean} = \mathbb{E}[t]`.
718+
719+
Returns
720+
-------
721+
t_mean : :class:`astropy.units.Quantity`
722+
Mean time in Gyr. Equal to
723+
:math:`t_\mathrm{end}\,\alpha/(\alpha+\beta)`.
724+
"""
725+
return self.t_end * self.alpha / (self.alpha + self.beta)
726+
727+
@property
728+
def kappa(self):
729+
r"""
730+
Concentration parameter :math:`\kappa=\alpha+\beta`.
731+
732+
Returns
733+
-------
734+
kappa : float
735+
Sum of Beta shape parameters.
736+
"""
737+
return self.alpha + self.beta
738+
739+
def _alpha_beta_from_mean_concentration(self, t_mean, kappa):
740+
"""
741+
Map mean-concentration to Beta shape parameters.
742+
743+
Parameters
744+
----------
745+
t_mean : :class:`astropy.units.Quantity` or float
746+
Mean formation time. If Quantity, interpreted in Gyr and converted
747+
to the fractional mean :math:`m=t_\mathrm{mean}/t_\mathrm{end}`.
748+
If float, it is assumed to be the fractional mean :math:`m`.
749+
kappa : float
750+
Concentration :math:`\kappa>0`.
751+
752+
Returns
753+
-------
754+
alpha, beta : float
755+
Beta shape parameters.
756+
757+
Raises
758+
------
759+
ValueError
760+
If :math:`m \notin (0,1)` or :math:`\kappa \le 0`.
761+
"""
762+
if isinstance(t_mean, u.Quantity):
763+
t_mean /= self.t_end
764+
t_mean = t_mean.decompose()
765+
if not (0 < t_mean < 1):
766+
raise ValueError("m must be in (0,1)")
767+
if not (kappa > 0):
768+
raise ValueError("kappa must be > 0")
769+
return t_mean * kappa, (1 - t_mean) * kappa
770+
771+
def _rescaled_x(self, time: u.Quantity) -> np.ndarray:
772+
"""
773+
Rescale cosmic time to x in [0,1] over [t_start, t_end].
774+
775+
Parameters
776+
----------
777+
time : astropy.units.Quantity
778+
Cosmic time(s) at which to evaluate.
779+
780+
Returns
781+
-------
782+
x : ndarray
783+
Values in [0,1], clipped outside the interval.
784+
"""
785+
t = check_unit(time, u.Gyr).to_value("Gyr")
786+
t0 = self.t_start.to_value("Gyr")
787+
t1 = self.t_end.to_value("Gyr")
788+
if not (t1 > t0):
789+
raise ValueError("t_end must be greater than t_start.")
790+
x = (t - t0) / (t1 - t0)
791+
return np.clip(x, 0.0, 1.0)
792+
793+
def stellar_mass_formed(self, time) -> u.Quantity:
794+
"""
795+
Total *formed* stellar mass at cosmic time ``time``.
796+
797+
Parameters
798+
----------
799+
time : astropy.units.Quantity
800+
Cosmic time(s) at which to evaluate, in Gyr.
801+
802+
Returns
803+
-------
804+
Mformed : astropy.units.Quantity
805+
Cumulative formed mass, in Msun.
806+
"""
807+
x = self._rescaled_x(time)
808+
# Regularised incomplete Beta function I_x(alpha, beta) in x [0,1]
809+
f = betainc(self.alpha, self.beta, x)
810+
return (self.mass_today.to(u.Msun).value * f) * u.Msun
811+
812+
def ism_metallicity(self, time) -> u.Quantity:
813+
"""TODO"""
814+
return np.full(time.size, fill_value=self.ism_metallicity_today)
815+
816+
def sfr(self, time) -> u.Quantity:
817+
"""
818+
Instantaneous star-formation rate at cosmic time ``time`` (analytic).
819+
820+
Parameters
821+
----------
822+
time : u.Quantity or float
823+
Cosmic time(s) at which to evaluate, in Gyr.
824+
"""
825+
from scipy.special import gammaln
826+
827+
t = check_unit(time, u.Gyr)
828+
x = self._rescaled_x(t) # clipped to [0,1]
829+
830+
# log Beta(a,b) = ln Γ(a) + ln Γ(b) − ln Γ(a+b)
831+
logB = (gammaln(self.alpha) + gammaln(self.beta)
832+
- gammaln(self.alpha + self.beta))
833+
834+
# Beta PDF in log-space; zero outside (0,1)
835+
pdf = np.zeros_like(x, dtype=float)
836+
mask = (x > 0.0) & (x < 1.0)
837+
if np.any(mask):
838+
logpdf = ((self.alpha - 1.0) * np.log(x[mask])
839+
+ (self.beta - 1.0) * np.log1p(-x[mask])
840+
- logB)
841+
pdf[mask] = np.exp(logpdf)
842+
843+
return (self.mass_today / (self.t_end - self.t_start)) * pdf
844+
845+
846+
def t_peak(self) -> u.Quantity | None:
847+
"""
848+
Time of peak SFR for alpha, beta > 1; otherwise returns None.
849+
850+
Returns
851+
-------
852+
t_peak : astropy.units.Quantity or None
853+
Cosmic time of maximum SFR, if defined.
854+
"""
855+
if (self.alpha > 1.0) and (self.beta > 1.0):
856+
t0 = self.t_start.to_value("Gyr")
857+
t_span = (self.t_end - self.t_start).to_value("Gyr")
858+
x_peak = (self.alpha - 1.0) / (self.alpha + self.beta - 2.0)
859+
return (t0 + x_peak * t_span) * u.Gyr
860+
return None
861+
862+
# --------------------------------------------------------------------
863+
# ---------------------- Numerical models ----------------------------
864+
# --------------------------------------------------------------------
591865

592-
#-------------------------------------------------------------------------------
593866
class TabularCEM(ChemicalEvolutionModel):
594867
"""Chemical evolution model based on a grid of times and metallicities.
595868

0 commit comments

Comments
 (0)