|
4 | 4 | from astropy import units as u |
5 | 5 | from scipy import special |
6 | 6 | from scipy import interpolate |
| 7 | +from scipy.special import betainc |
7 | 8 |
|
8 | 9 | from pst.SSP import SSPBase |
9 | 10 | from pst.utils import check_unit, SQRT_2 |
@@ -269,7 +270,10 @@ def compute_photometry(self, ssp, t_obs, photometry=None) -> u.Quantity: |
269 | 270 | return model_photometry |
270 | 271 |
|
271 | 272 |
|
272 | | -#------------------------------------------------------------------------------- |
| 273 | +# -------------------------------------------------------------------- |
| 274 | +# ---------------------- Analytical models --------------------------- |
| 275 | +# -------------------------------------------------------------------- |
| 276 | + |
273 | 277 | class SingleBurstCEM(ChemicalEvolutionModel): |
274 | 278 | """ |
275 | 279 | Single-burst star formation model. |
@@ -588,8 +592,277 @@ def __init__(self, **kwargs): |
588 | 592 | def stellar_mass_formed(self, times: u.Quantity): |
589 | 593 | return super().stellar_mass_formed(times) |
590 | 594 |
|
| 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 | +# -------------------------------------------------------------------- |
591 | 865 |
|
592 | | -#------------------------------------------------------------------------------- |
593 | 866 | class TabularCEM(ChemicalEvolutionModel): |
594 | 867 | """Chemical evolution model based on a grid of times and metallicities. |
595 | 868 | |
|
0 commit comments