Source code for pst.SSP

import os
from glob import glob
from copy import deepcopy
import numpy as np

from astropy.io import fits
from astropy.wcs import WCS
from astropy.table import Table
from astropy import units as u
from astropy import units
from astropy import constants
from pst.utils import check_unit, flux_conserving_interpolation

[docs] class SSPBase(object): """Base class that represents a model of Simple Stellar Populations. Description ----------- This class is meant for representing a model of Simple Stellar Populations that is mainly composed by a discreted grid of ages and metallicities and their associated spectral energy distributions. Attributes ---------- ages: astropy.units.Quantity Ages of the SSPs. metallicities: astropy.units.Quantity Metallicities of the SSPs. L_lambda: astropy.units.Quantity Spectral energy distribution of each SSP. Each dimension correspond to (metallicity, ages, wavelength). wavelength: astroy.units.Quantity Wavelength array associated to the SED of the SSPs. """ default_path = os.path.join(os.path.dirname(__file__), "data", "ssp") @property def name(self): """Name of the SSP model.""" if hasattr(self, '_name'): return self._name else: return None @name.setter def name(self, value): self._name = value @property def isochrone(self): """Isochrone model associated to the SSP model.""" if hasattr(self, '_isochrone'): return self._isochrone else: return None @isochrone.setter def isochrone(self, value): self._isochrone = value @property def stellar_library(self): """Stellar atmospheres model associated to the SSP model.""" if hasattr(self, '_stellar_library'): return self._stellar_library else: return None @stellar_library.setter def stellar_library(self, value): self._stellar_library = value @property def imf(self): """Return the IMF associated to the SSP model.""" if hasattr(self, '_imf'): return self._imf else: return None @imf.setter def imf(self, value): """Set the IMF associated to the SSP model.""" self._imf = value @property def ages(self): """Ages of the SSPs.""" return self._ages @ages.setter def ages(self, ages_array): check_unit(ages_array, u.Gyr) self._ages = ages_array @property def metallicities(self): """Metallicities of the SSPs.""" return self._metallicities @metallicities.setter def metallicities(self, metallicities_array): check_unit(metallicities_array, u.dimensionless_unscaled) self._metallicities = metallicities_array @property def n_ages(self): """Number of ages in the SSP model.""" return self.ages.size @property def n_metallicities(self): """Number of metallicities in the SSP model.""" return self.metallicities.size @property def n_ssps(self): """Number of SSPs in the model.""" return self.n_ages * self.n_metallicities @property def n_wavelengths(self): """Number of wavelength points in the SSP model.""" return self.wavelength.size @property def L_lambda(self): """Spectral energy distribution of each SSP. Each dimension correspond to (metallicity, ages, wavelength). Units must be luminosity per wavelength unit per *initial* mass unit (e.g. Lsun / Angstrom / Msun).""" return self._L_lambda @L_lambda.setter def L_lambda(self, l_lamdba): check_unit(l_lamdba, u.Lsun / u.Angstrom / u.Msun) self._L_lambda = l_lamdba @property def wavelength(self): """Wavelength array associated to the SED of the SSPs.""" return self._wavelength @wavelength.setter def wavelength(self, wave): check_unit(wave, u.Angstrom) self._wavelength = wave @property def remnant_mass_frac(self): """Return the remnant mass of the SSP model.""" if hasattr(self, '_remnant_mass_frac'): return self._remnant_mass_frac else: return np.zeros((self.metallicities.size, self.ages.size)) @remnant_mass_frac.setter def remnant_mass_frac(self, value): """Set the remnant mass of the SSP model.""" self._remnant_mass_frac = value @property def returned_mass_frac(self): """Return the returned mass of the SSP model.""" if hasattr(self, '_returned_mass_frac'): return self._returned_mass_frac else: return np.zeros((self.metallicities.size, self.ages.size)) @returned_mass_frac.setter def returned_mass_frac(self, value): """Set the returned mass of the SSP model.""" self._returned_mass_frac = value @property def current_mass(self): """Return the current mass of the SSP model.""" return 1.0 - self.returned_mass_frac @property def log_ionising_HI_photons(self): """Number of ionising photons of the SSP model in dex(s^-1).""" if hasattr(self, '_ionising_HI_photons'): return self._ionising_HI_photons else: return None @log_ionising_HI_photons.setter def log_ionising_HI_photons(self, value): """Set the number of ionising photons of the SSP model.""" value = check_unit(value, u.dex(u.s**-1 / u.Msun)) self._ionising_HI_photons = value @property def log_ionising_HeI_photons(self): """Number of ionising photons of the SSP model in dex(s^-1).""" if hasattr(self, '_ionising_HeI_photons'): return self._ionising_HeI_photons else: return None @log_ionising_HeI_photons.setter def log_ionising_HeI_photons(self, value): """Set the number of ionising photons of the SSP model.""" value = check_unit(value, u.dex(u.s**-1 / u.Msun)) self._ionising_HeI_photons = value @property def log_ionising_HeII_photons(self): """Number of ionising photons of the SSP model in dex(s^-1).""" if hasattr(self, '_ionising_HeII_photons'): return self._ionising_HeII_photons else: return None @log_ionising_HeII_photons.setter def log_ionising_HeII_photons(self, value): """Set the number of ionising photons of the SSP model.""" value = check_unit(value, u.dex(u.s**-1 / u.Msun)) self._ionising_HeII_photons = value @property def supernova_rate(self): """Return the supernova rate of the SSP model.""" if hasattr(self, '_supernova_rate'): return self._supernova_rate else: return None @supernova_rate.setter def supernova_rate(self, value): """Set the supernova rate of the SSP model.""" value = check_unit(value, u.s**-1 * u.Msun**-1) self._supernova_rate = value
[docs] def get_weights(self, ages, metallicities, masses=None): """2D interpolation of a list of ages and metallicities. Parameters ---------- ages : np.array or astropy.units.Quantity SSP ages to interpolate. metallicites : np.array or astropy.units.Quantity Metallicity associated to each age. masses : np.array, astropy.units.Quantity or None, optional Stellar mass corresponding to each SSP. """ ages = check_unit(ages, u.Gyr) metallicities = check_unit(metallicities, u.dimensionless_unscaled) if masses is None: masses = np.ones(ages.size) << u.Msun else: masses = check_unit(masses, u.Msun) age_idx = np.clip(self.ages.searchsorted(ages), 1, self.ages.size-1) weights_age = np.log(ages / self.ages[age_idx-1]) weights_age /= np.log(self.ages[age_idx] / self.ages[age_idx-1]) weights_age = np.clip(weights_age, 0., 1.) z_idx = np.clip(self.metallicities.searchsorted(metallicities), 1, self.metallicities.size-1) weights_z = np.log(metallicities / self.metallicities[z_idx-1]) weights_z /= np.log(self.metallicities[z_idx] / self.metallicities[z_idx-1]) weights_z = np.clip(weights_z, 0., 1.) weights = np.zeros((self.metallicities.size, self.ages.size)) << masses.unit np.add.at(weights, (z_idx, age_idx), masses * weights_age * weights_z) np.add.at(weights, (z_idx-1, age_idx), masses * weights_age * (1-weights_z)) np.add.at(weights, (z_idx-1, age_idx-1), masses * (1-weights_age) * (1-weights_z)) np.add.at(weights, (z_idx, age_idx-1), masses * (1-weights_age) * weights_z) return weights
[docs] def get_ssp_l_lambda(self, age, metallicity): """Compute the SED associated to an SSP of a given age and metallicity. Parameters ---------- age : float or u.Quantity SSP age. metallicity : float or u.Quantity SSP metallicity Returns ------- sed : u.Quantity Spectra energy distribution associated to the SSP. """ age = check_unit(age, u.Gyr) metallicity = check_unit(metallicity, u.dimensionless_unscaled) age_idx = np.clip(self.ages.searchsorted(age), 1, self.ages.size-1) weights_age = np.log(age / self.ages[age_idx-1]) weights_age /= np.log(self.ages[age_idx] / self.ages[age_idx-1]) weights_age = np.clip(weights_age, 0., 1.) z_idx = np.clip(self.metallicities.searchsorted(metallicity), 1, self.metallicities.size-1) weights_z = np.log(metallicity / self.metallicities[z_idx-1]) weights_z /= np.log(self.metallicities[z_idx] / self.metallicities[z_idx-1]) weights_z = np.clip(weights_z, 0., 1.) sed = self.L_lambda[z_idx, age_idx] * weights_age * weights_z sed += self.L_lambda[z_idx-1, age_idx] * weights_age * (1-weights_z) sed += self.L_lambda[z_idx-1, age_idx-1] * (1-weights_age) * (1-weights_z) sed += self.L_lambda[z_idx, age_idx-1] * (1-weights_age) * weights_z return sed
[docs] def get_ssp_logedges(self): """Get the edges of the SSP metallicities and ages.""" logages = np.log10(self.ages / units.yr) lim = 1.5 * logages[[0, -1]] - 0.5 * logages[[1, -2]] logage_bin_edges = np.hstack( [lim[0], (logages[1:] + logages[:-1])/2, lim[1]]) log_met_bins = np.log10(self.metallicities) lim = 1.5 * log_met_bins[[0, -1]] - 0.5 * log_met_bins[[1, -2]] logmet_bin_edges = np.hstack( [lim[0], (log_met_bins[1:] + log_met_bins[:-1])/2, lim[1]]) return logmet_bin_edges, logage_bin_edges
[docs] def regrid(self, age_bins, metallicity_bins, verbose=True): """Interpolate the SSP model to a new grid of input ages and metallicities. Parameters ---------- age_bins : np.array or astropy.units.Quantity """ if verbose: print("[SSP] Interpolating the SSP model to a new grid of ages and metallicities") age_bins = check_unit(age_bins, u.Gyr) metallicity_bins = check_unit(metallicity_bins, u.dimensionless_unscaled) # Bin the SED of the SSPs new_l_lambda = np.zeros((metallicity_bins.size, age_bins.size, self.wavelength.size)) * self.L_lambda.unit if verbose: print("New SSP SED shape: ", new_l_lambda.shape) for j, m_bin in enumerate(metallicity_bins): for i, a_bin in enumerate(age_bins): weights = self.get_weights(a_bin, m_bin, 1.0 * u.Msun) new_l_lambda[j, i] = np.sum( self.L_lambda * weights[:, :, np.newaxis] / u.Msun, axis=(0, 1)) if verbose: print("Updating SSP model metallicities, ages and SED") self.metallicities = metallicity_bins self.ages = age_bins self.L_lambda = new_l_lambda
[docs] def cut_wavelength(self, wl_min=None, wl_max=None, verbose=True): """Cut model wavelength edges. Parameters ---------- wl_min : float or astropy.units.Quantity, optional Minimum wavelength value. wl_max : float or astropy.units.Quantity, optional Maximum wavelength value. """ if wl_min is None: wl_min = self.wavelength[0] else: wl_min = check_unit(wl_min, self.wavelength.unit) if wl_max is None: wl_max = self.wavelength[-1] else: wl_max = check_unit(wl_max, self.wavelength.unit) cut_pts = np.where((self.wavelength >= wl_min) & (self.wavelength <= wl_max))[0] if len(cut_pts) == 0: raise NameError( 'Wavelength cuts {}, {} out of range for array with lims {} {}' .format(wl_min, wl_max, self.wavelength.min(), self.wavelength.max()) ) else: self.wavelength = self.wavelength[cut_pts] self.L_lambda = self.L_lambda[:, :, cut_pts] if verbose: print('[SSP] Models cut between {} {}'.format(wl_min, wl_max))
[docs] def interpolate_sed(self, new_wl, verbose=True, log=False, **interp_kwargs): """Flux-conserving interpolation. Parameters ---------- - new_wl: bin centers of the new interpolated points. """ new_wl = check_unit(new_wl, self.wavelength.unit) if new_wl.size == self.wavelength.size and ( self.wavelength.value == new_wl.value).all(): print("[SSP] SSP already sampled in target wavelength grid") return if verbose: print('[SSP] Interpolating SSP SEDs') if log: target_wl = np.log(new_wl.to_value(self.wavelength.unit)) ref_wl = np.log(self.wavelength.value) else: target_wl = new_wl ref_wl = self.wavelength new_l_lambda = np.empty( shape=(self.metallicities.size, self.ages.size, new_wl.size), dtype=np.float32) * self.L_lambda.unit for i in range(self.L_lambda.shape[0]): for j in range(self.L_lambda.shape[1]): new_l_lambda[i, j] = flux_conserving_interpolation( target_wl, ref_wl, self.L_lambda[i, j], **interp_kwargs) self.L_lambda = new_l_lambda self.wavelength = new_wl
[docs] def get_mass_lum_ratio(self, wl_range): """Compute the mass-to-light ratio within a wavelength range.""" if not isinstance(wl_range, u.Quantity): print("Assuming that input wavelength range has same units as wavelength") wl_range = np.array(wl_range) * self.wavelength.unit pts = np.where((self.wavelength >= wl_range[0]) & (self.wavelength <= wl_range[1]))[0] mass_to_lum = np.empty( (self.metallicities.size, self.ages.size) ) * 1 / (self.L_lambda.unit * self.wavelength.unit) for i in range(self.metallicities.size): for j in range(self.ages.size): mass_to_lum[i, j] = 1/np.mean(self.L_lambda[i, j][pts] * self.wavelength[pts]) return mass_to_lum
[docs] def get_specific_mass_lum_ratio(self, wl_range): """Compute the mass-to-light ratio per wavelength unit within a wavelength range.""" if not isinstance(wl_range, u.Quantity): print("Assuming that input wavelength range has same units as wavelength") wl_range = np.array(wl_range) * self.wavelength.unit pts = np.where((self.wavelength >= wl_range[0]) & (self.wavelength <= wl_range[1]))[0] mass_to_lum = np.empty( (self.metallicities.size, self.ages.size) ) * 1 / self.L_lambda.unit for i in range(self.metallicities.size): for j in range(self.ages.size): mass_to_lum[i, j] = 1/np.mean(self.L_lambda[i, j][pts]) return mass_to_lum
[docs] def get_ionising_photon_rate(self, species='HI'): """Compute the ionising photon rate of the SSP model for a given species. Parameters ---------- species : str, optional Species for which to compute the ionising photon rate. Must be one of 'HI', 'HeI' or 'HeII'. Default is 'HI'. Returns ------- photon_rate : astropy.units.Quantity Ionising photon rate of the SSP model for the given species in units of s^-1 / Msun. """ # Integrate the SED up to 912 AA if species == 'HI': wl_limit = 912 * u.Angstrom elif species == 'HeI': wl_limit = 504 * u.Angstrom elif species == 'HeII': wl_limit = 228 * u.Angstrom else: raise ValueError("Species must be one of 'HI', 'HeI' or 'HeII'") pts = np.where(self.wavelength <= wl_limit)[0] photon_rate = np.empty((self.metallicities.size, self.ages.size)) * u.s**-1 / u.Msun # q = int(L_lambda / (h c / lambda) dlamda) # Divide by 1e40 to avoid overflow q_lambda = ( self.L_lambda[:, :, pts].to("erg / (s * Angstrom * Msun)") / 1e40 * self.wavelength.to("Angstrom")[pts] ) / (constants.h.to("erg s") * constants.c.to("Angstrom/s")) for i in range(self.metallicities.size): for j in range(self.ages.size): photon_rate[i, j] = np.trapz(q_lambda[i, j], self.wavelength[pts]) if species == 'HI': self.log_ionising_HI_photons = np.log10(photon_rate.to_value(u.s**-1 / u.Msun)) + 40 << u.dex(u.s**-1 / u.Msun) elif species == 'HeI': self.log_ionising_HeI_photons = np.log10(photon_rate.to_value(u.s**-1 / u.Msun)) + 40 << u.dex(u.s**-1 / u.Msun) elif species == 'HeII': self.log_ionising_HeII_photons = np.log10(photon_rate.to_value(u.s**-1 / u.Msun)) + 40 << u.dex(u.s**-1 / u.Msun) return photon_rate.to_value(u.s**-1 / u.Msun) << 1e40 * u.s**-1 / u.Msun
[docs] def compute_photometry(self, filter_list, z_obs=0.0, verbose=True): """Compute the SSP synthetic photometry of a set of filters. Parameters ---------- filter_list: list of pst.observable.Filter A list of photometric filters. Returns ------- photometry: np.ndarray Array storing the photometry. The dimensions correspond to filter, metallicity and age. """ if verbose: print("Computing synthetic photometry for SSP model") self.photometry = np.zeros((len(filter_list), *self.L_lambda.shape[:-1])) * u.Jy / u.Msun self.photometry_filters = filter_list for ith, f in enumerate(filter_list): f.interpolate(self.wavelength * (1 + z_obs)) flux, _ = f.get_fnu( self.L_lambda * u.Msun / 4 / np.pi / (10 * u.pc)**2, mask_nan=False) flux /= 1 + z_obs self.photometry[ith] = flux.to('Jy') / u.Msun return self.photometry
[docs] def copy(self): """Return a copy of the SSP model.""" return deepcopy(self)
[docs] class PopStar(SSPBase): """ PopStar SSP models (Mollá et al. 2009). This class represents the PopStar evolutionary synthesis models for simple stellar populations as presented in Mollá et al. (2009). It provides access to spectral energy distributions (SEDs) across a range of metallicities and ages, optionally including nebular emission. Parameters ---------- IMF : str Initial Mass Function name. Must be one of the supported IMFs listed below: - 'sal' : Salpeter (1955) - 'fer' : Ferrini, Penco & Palla (1990) - 'kro' : Kroupa (2002) - 'cha' : Chabrier (2003) This keyword is used to select the corresponding PopStar model files. nebular : bool, optional If True, include nebular emission in the SEDs. Default is False, meaning only the stellar continuum is considered. path : str or None, optional Filesystem path to the PopStar model data. If None (default), the package default path plus 'PopStar' subdirectory is used. verbose : bool, optional If True (default), print informational messages during initialization. Example ------- Initialize with the desired IMF and nebular option: >>> from pst.SSP import PopStar >>> help(PopStar) # Retrieve the class documentation >>> popstar = PopStar(IMF="cha", nebular=True) # Initialise the model >>> print(popstar.wavelength) <Quantity [91. , 94. , 96. , ..., 1200000., 1400000., 1600000.] Angstrom> >>> oldest_sed = popstar.L_lambda[-1, -1] # Get the SED of the oldest SSP with highest metallicity Notes ----- - The PopStar models cover metallicities from 0.0001 to 0.05 and ages spanning from 10^5 to ~10^10 years. - Nebular emission is included only if `nebular=True` is set. - Model files are expected to be in compressed FITS format with specific naming conventions as per Mollá et al. 2009. For more details see the :class:`SSPBase` base class documentation. References ---------- Mollá, M., García-Vargas, M. L., & Bressan, A. (2009). PopStar I: evolutionary synthesis model description. `MNRAS, 398(1), 451-470 <https://academic.oup.com/mnras/article/398/1/451/1099698>`_. `Fractal data repository <https://www.fractal-es.com/PopStar/>`_ """ def __init__(self, IMF, nebular=False, path=None, verbose=True): if path is None: self.path = os.path.join(self.default_path, 'PopStar') else: self.path = path self.metallicities = np.array([0.0001, 0.0004, 0.004, 0.008, 0.02, 0.05]) * units.dimensionless_unscaled self.log_ages_yr = np.array([5.00, 5.48, 5.70, 5.85, 6.00, 6.10, 6.18, 6.24, 6.30, 6.35, 6.40, 6.44, 6.48, 6.51, 6.54, 6.57, 6.60, 6.63, 6.65, 6.68, 6.70, 6.72, 6.74, 6.76, 6.78, 6.81, 6.85, 6.86, 6.88, 6.89, 6.90, 6.92, 6.93, 6.94, 6.95, 6.97, 6.98, 6.99, 7.00, 7.04, 7.08, 7.11, 7.15, 7.18, 7.20, 7.23, 7.26, 7.28, 7.30, 7.34, 7.38, 7.41, 7.45, 7.48, 7.51, 7.53, 7.56, 7.58, 7.60, 7.62, 7.64, 7.66, 7.68, 7.70, 7.74, 7.78, 7.81, 7.85, 7.87, 7.90, 7.93, 7.95, 7.98, 8.00, 8.30, 8.48, 8.60, 8.70, 8.78, 8.85, 8.90, 8.95, 9.00, 9.18, 9.30, 9.40, 9.48, 9.54, 9.60, 9.65, 9.70, 9.74, 9.78, 9.81, 9.85, 9.90, 9.95, 10.00, 10.04, 10.08, 10.11, 10.12, 10.13, 10.14, 10.15, 10.18]) * units.dimensionless_unscaled self.ages = 10**self.log_ages_yr * units.yr if verbose: print("> Initialising Popstar models (IMF='"+IMF+"')") # isochrone age in delta [log(tau)]=0.01 if nebular: column = "_total" if verbose: print('--> Including NEBULAR emission') else: column = "_stellar" if verbose: print('--> Only stellar continuum') with fits.open( os.path.join(self.path, f"popstar_{IMF.lower()}_0.15_100.fits.gz") ) as hdul: self.wavelength = hdul[1].data["wavelength"] * u.Unit( hdul[1].header["TUNIT1"]) self.L_lambda = np.empty( shape=(self.metallicities.size, self.log_ages_yr.size, self.wavelength.size), dtype=np.float32 ) * u.Unit(hdul[2].header["TUNIT1"]) for i, Z in enumerate(self.metallicities.value): table = hdul["SED_Z_0.{:04.0f}".format(Z*1e4)].data for j, age in enumerate(self.log_ages_yr.value): self.L_lambda[i][j] = table[ "logage_yr_{:02.2f}".format(age) + column] * self.L_lambda.unit
[docs] class PyPopStar(SSPBase): """PyPopStar SSP models (Millán-Irigoyen et al. 2021). This class represents the PyPopStar simple stellar population (SSP) models as described in Millán-Irigoyen et al. (2021). It provides spectral energy distributions (SEDs) for a range of metallicities and ages, optionally including nebular emission. Parameters ---------- IMF : str Initial Mass Function identifier. Must be one of the supported IMFs: - 'KRO' : Kroupa (2002) - 'SAL' : Salpeter (1955) - 'CHA' : Chabrier (2003) This keyword selects the corresponding model subdirectory and file set. nebular : bool, optional If True, nebular emission is included in the SEDs. Default is False, meaning only the stellar continuum is included. path : str or None, optional Filesystem path to the PyPopStar model data. If None (default), the package default path plus 'PyPopStar' and IMF subdirectories are used. verbose : bool, optional If True (default), prints informational messages during initialization. Example ------- Initialize the PyPopStar model with a chosen IMF and nebular emission option: >>> from pst.SSP import PyPopStar >>> psp = PyPopStar(IMF='KRO', nebular=False) >>> print(psp.wavelength) <Quantity [ ... ] Angstrom> >>> sed_example = psp.L_lambda[0, 0] # SED for lowest metallicity, youngest age Notes ----- - PyPopStar models cover metallicities from 0.004 to 0.05 and ages spanning approximately 10^5 to 10^10 years. - Nebular emission is included only if `nebular=True`. - Model files must follow the naming convention: `'SSP-{IMF}_Z{metallicity}_logt{log_age}.fits'`. For additional details, see the :class:`SSPBase` base class documentation. References ---------- Millán-Irigoyen, I.; Mollá, M.; Cerviño, M.; et al. (2021). HR-PYPOPSTAR: high-wavelength-resolution stellar populations evolutionary synthesis model `MNRAS, 506 (4), 4781-4799 <https://ui.adsabs.harvard.edu/abs/2021MNRAS.506.4781M/abstract>`_. `Fractal data repository <https://www.fractal-es.com/PopStar/>`_ """ def __init__(self, IMF, nebular=False, path=None, verbose=True): if path is None: self.path = os.path.join(self.default_path, 'PyPopStar', IMF) else: self.path = path self.metallicities = np.array([0.004, 0.008, 0.02, 0.05] ) * units.dimensionless_unscaled self.log_ages_yr = np.array([ 5., 5.48, 5.7 , 5.85, 6. , 6.1 , 6.18, 6.24, 6.3 , 6.35, 6.4 , 6.44, 6.48, 6.51, 6.54, 6.57, 6.6 , 6.63, 6.65, 6.68, 6.7 , 6.72, 6.74, 6.76, 6.78, 6.81, 6.85, 6.86, 6.88, 6.89, 6.9 , 6.92, 6.93, 6.94, 6.95, 6.97, 6.98, 6.99, 7. , 7.04, 7.08, 7.11, 7.15, 7.18, 7.2 , 7.23, 7.26, 7.28, 7.3 , 7.34, 7.38, 7.41, 7.45, 7.48, 7.51, 7.53, 7.56, 7.58, 7.6 , 7.62, 7.64, 7.66, 7.68, 7.7 , 7.74, 7.78, 7.81, 7.85, 7.87, 7.9 , 7.93, 7.95, 7.98, 8. , 8.3 , 8.48, 8.6 , 8.7 , 8.78, 8.85, 8.9 , 8.95, 9. , 9.18, 9.3 , 9.4 , 9.48, 9.54, 9.6 , 9.65, 9.7 , 9.74, 9.78, 9.81, 9.85, 9.9 , 9.95, 10. , 10.04, 10.08, 10.11, 10.12, 10.13, 10.14, 10.15, 10.18] ) * units.dimensionless_unscaled self.ages = 10**self.log_ages_yr * units.yr # isochrone age in delta [log(tau)]=0.01 # self.wavelength = np.loadtxt(os.path.join(self.path, 'KRO', 'sp', # 'sp_z0.004_logt05.00.dat'), # dtype=float, usecols=(0,), unpack=True # ) * u.Angstrom header = 'SSP-{}'.format(IMF) if nebular: if verbose: print("> Initialising Popstar models (neb em) (IMF='" + IMF + "')") column = 'flux_total' else: if verbose: print("> Initialising Popstar models (no neb em) (IMF='" + IMF + "')") column = 'flux_stellar' with fits.open(os.path.join(self.path, header+'_Z{:03.3f}_logt{:05.2f}.fits'.format( self.metallicities.value[0], self.log_ages_yr.value[0])) ) as hdul: self.wavelength = hdul[1].data['wavelength'] * units.Angstrom # Angstrom self.L_lambda = np.empty( shape=(self.metallicities.size, self.log_ages_yr.size, self.wavelength.size), dtype=np.float32) * u.Lsun / u.Angstrom / u.Msun for i, Z in enumerate(self.metallicities.value): for j, age in enumerate(self.log_ages_yr.value): filename = header+'_Z{:03.3f}_logt{:05.2f}.fits'.format(Z, age) file = os.path.join(self.path, filename) with fits.open(file) as hdul: self.L_lambda[i][j] = hdul[1].data[column] * self.L_lambda.unit hdul.close() # Avoid 0 flux self.L_lambda[self.L_lambda <= 0] += self.L_lambda[self.L_lambda > 0].min()
[docs] class BC03_2003(SSPBase): """ BC03 SSP models (Bruzual & Charlot 2003, original version). This class implements the 2003 version of the Bruzual & Charlot (BC03) evolutionary synthesis models. It supports both the STELIB and BaSeL stellar spectral libraries and various IMFs. Parameters ---------- isochrone : str, optional Isochrone set to use:'Padova1994' (default) or 'Padova2000'. model : str, optional Stellar spectral library. Must be one of: - 'BaSeL' : Low-resolution spectra - 'STELIB' : High-resolution spectra Case-insensitive. Default is 'BaSeL'. imf : str, optional Initial Mass Function. Must be one of: - 'chabrier' or 'cha' : Chabrier (2003) - 'salpeter' or 'sal' : Salpeter (1955) - 'kroupa' or 'kro' : Kroupa (2002) Case-insensitive. Default is 'chabrier'. path : str or None, optional Filesystem path to the BC03 model data. If None (default), the package default path is used. verbose : bool, optional If True (default), print informational messages during initialization. Example ------- Create a BC03 SSP object using the Chabrier IMF and BaSeL library: >>> from pst.SSP import BC03_2003 >>> ssp = BC03_2003(model='BaSeL', imf='Chabrier') >>> print(ssp.wavelength) <Quantity [ ... ] Angstrom> >>> sed = ssp.L_lambda[0, -1] # SED for lowest metallicity and oldest age Notes ----- - This implementation loads the original 2003 release of BC03. - Supported metallicities (Padova1994 isochrones): - 'm22': Z = 0.0001 - 'm32': Z = 0.0004 - 'm42': Z = 0.004 - 'm52': Z = 0.008 - 'm62': Z = 0.02 - 'm72': Z = 0.05 - SEDs are read from FITS files with filenames of the form: `bc2003_{res}_{metallicity}_{imf}_ssp.fits` - Units for `L_lambda` are Lsun / Angstrom / Msun. - Ages are loaded from a standard time grid provided in `TIME_SCALE.DAT`. See also -------- :class:`SSPBase` : Base class for all SSP models. References ---------- Bruzual, G. & Charlot, S. (2003). Stellar population synthesis at the resolution of 2003. `MNRAS, 344(4), 1000–1028 <https://ui.adsabs.harvard.edu/abs/2003MNRAS.344.1000B/abstract>`_. `Gustavo Bruzual page <https://www.bruzual.org/>`_ """ _metallicity_map = { # Padova1994 'm22': 0.0001, 'm32': 0.0004, 'm42': 0.004, 'm52': 0.008, 'm62': 0.02, 'm72': 0.05, #'m82': 0.1 Not available in the 2003 version } _resolution = {'BaSeL': 'lr', 'stelib': 'hr'} def __init__(self, isochrone='Padova1994', model='BaSeL', imf='Chabrier', path=None, verbose=True) -> None: self.isochrone = isochrone self.model, model_key = self._parse_model(model) self.imf, imf_key = self._parse_imf(imf) if path is None: self.path = os.path.join(self.default_path, 'BC03', 'bc03_2003ver', "bc03", self.isochrone, self.imf) else: self.path = path if verbose: print(f"> Initialising BC03 model {self.model} (IMF={self.imf})") self.metallicities = np.array(list(self._metallicity_map.values()) ) * units.dimensionless_unscaled self.ages = np.loadtxt( os.path.join(self.default_path, 'BC03', 'TIME_SCALE.DAT') ) * units.yr self.log_ages_yr = np.log10(self.ages / units.yr) load_wavelength = False for i, metallicity_key in enumerate(self._metallicity_map.keys()): fits_path = os.path.join( self.path, f"bc2003_{self._resolution[model_key]}_{metallicity_key}_{imf_key}_ssp.fits") table = Table.read(fits_path) if not load_wavelength: self.wavelength = table['wavelength'].value * u.angstrom self.L_lambda = np.zeros((self.metallicities.size, self.ages.size, self.wavelength.size)) * u.Lsun / u.Angstrom / u.Msun load_wavelength = True table.remove_column("wavelength") for j, column in enumerate(table.itercols()): self.L_lambda[i, j] = column.value * self.L_lambda.unit def _parse_model(self, model): if 'basel' in model.lower(): model = 'BaSeL' key = 'BaSeL' elif 'stelib' in model.lower(): model = 'stelib' key = 'stelib' else: raise NameError(f"Unrecognized model: {model}.\n" + "Select Basel or Stelib") return model, key def _parse_imf(self, imf): if 'cha' in imf.lower(): imf = 'chabrier' key = 'chab' elif 'sal' in imf.lower(): imf = 'salpeter' key = 'salp' elif 'kro' in imf.lower(): imf = 'kroupa' key = 'kro' else: raise NameError(f"Unrecognized IMF: {imf}.\n" + "Select Chabrier, Salpeter or Kroupa") return imf, key
[docs] class BC03_2013(SSPBase): """ BC03 SSP models (Bruzual & Charlot 2003, 2013 updated version). This class implements the updated 2013 version of the Bruzual & Charlot (BC03) simple stellar population (SSP) models, using improved coverage of high metallicities and updated isochrone input. It supports both the STELIB and BaSeL stellar spectral libraries and several initial mass functions (IMFs). Parameters ---------- isochrone : str, optional Isochrone set to use. Currently only 'Padova1994' is supported (default). model : str, optional Stellar spectral library. Must be one of: - 'BaSeL' : Low-resolution spectra - 'STELIB' : High-resolution spectra Case-insensitive. Default is 'BaSeL'. imf : str, optional Initial Mass Function. Must be one of: - 'chabrier' or 'cha' : Chabrier (2003) - 'salpeter' or 'sal' : Salpeter (1955) - 'kroupa' or 'kro' : Kroupa (2002) Case-insensitive. Default is 'chabrier'. path : str or None, optional Filesystem path to the BC03 model data. If None (default), the package default path is used. verbose : bool, optional If True (default), print informational messages during initialization. Example ------- Create a BC03 SSP object using the Chabrier IMF and BaSeL library: >>> from pst.SSP import BC03_2013 >>> ssp = BC03_2013(model='BaSeL', imf='Chabrier') >>> print(ssp.wavelength) <Quantity [ ... ] Angstrom> >>> sed = ssp.L_lambda[0, -1] # SED for lowest metallicity and oldest age Notes ----- - This version extends the original 2003 release by including an additional high-metallicity track (Z = 0.1). - Supported metallicities (Padova1994 isochrones): - 'm22': Z = 0.0001 - 'm32': Z = 0.0004 - 'm42': Z = 0.004 - 'm52': Z = 0.008 - 'm62': Z = 0.02 - 'm72': Z = 0.05 - 'm82': Z = 0.1 - File naming convention: `bc2003_{res}_{model}_{metallicity}_{imf}_ssp.fits` - Units for `L_lambda` are Lsun / Angstrom / Msun. - Ages are taken from the same `TIME_SCALE.DAT` file as BC03_2003. See also -------- :class:`SSPBase` : Common functionality for SSP models. References ---------- Bruzual, G. & Charlot, S. (2003). Stellar population synthesis at the resolution of 2003. `MNRAS, 344(4), 1000–1028 <https://ui.adsabs.harvard.edu/abs/2003MNRAS.344.1000B/abstract>`_. `Gustavo Bruzual page <https://www.bruzual.org/>`_ """ _metallicity_map = { # Padova1994 'm22': 0.0001, 'm32': 0.0004, 'm42': 0.004, 'm52': 0.008, 'm62': 0.02, 'm72': 0.05, 'm82': 0.1} _resolution = {'BaSeL': 'lr', 'stelib': 'hr'} def __init__(self, isochrone='Padova1994', model='BaSeL', imf='Chabrier', path=None, verbose=True) -> None: self.isochrone = isochrone self.model, model_key = self._parse_model(model) self.imf, imf_key = self._parse_imf(imf) if path is None: self.path = os.path.join(self.default_path, 'BC03', 'bc03_2013ver', "bc03", self.isochrone, self.imf) else: self.path = path if verbose: print(f"> Initialising BC03 model {self.model} (IMF={self.imf})") self.metallicities = np.array(list(self._metallicity_map.values()) ) * units.dimensionless_unscaled self.ages = np.loadtxt( os.path.join(self.default_path, 'BC03', 'TIME_SCALE.DAT') ) * units.yr self.log_ages_yr = np.log10(self.ages / units.yr) load_wavelength = False for i, metallicity_key in enumerate(self._metallicity_map.keys()): fits_path = os.path.join( self.path, f"bc2003_{self._resolution[model_key]}_{model_key}_{metallicity_key}_{imf_key}_ssp.fits") table = Table.read(fits_path) if not load_wavelength: self.wavelength = table['wavelength'].value * u.angstrom self.L_lambda = np.zeros((self.metallicities.size, self.ages.size, self.wavelength.size)) * u.Lsun / u.Angstrom / u.Msun load_wavelength = True table.remove_column("wavelength") for j, column in enumerate(table.itercols()): self.L_lambda[i, j] = column.value * self.L_lambda.unit def _parse_model(self, model): if 'basel' in model.lower(): model = 'BaSeL' key = 'BaSeL' elif 'stelib' in model.lower(): model = 'stelib' key = 'stelib' else: raise NameError(f"Unrecognized model: {model}.\n" + "Select Basel or Stelib") return model, key def _parse_imf(self, imf): if 'cha' in imf.lower(): imf = 'chabrier' key = 'chab' elif 'sal' in imf.lower(): imf = 'salpeter' key = 'salp' elif 'kro' in imf.lower(): imf = 'kroupa' key = 'kro' else: raise NameError(f"Unrecognized IMF: {imf}.\n" + "Select Chabrier, Salpeter or Kroupa") return imf, key
[docs] class BC03_2016(SSPBase): """ BC03 SSP models (Bruzual & Charlot 2003, 2016 updated version). This class implements the 2016 version of the Bruzual & Charlot (BC03) simple stellar population (SSP) models. It includes higher metallicity coverage and new stellar libraries, including MILES, STELIB, and BaSeL. Stellar tracks and isochrones are based on the PARSEC code (Bressan et al. 2012). This version uses updated file naming conventions and directory structure. Parameters ---------- model : str, optional Stellar spectral library. Must be one of: - 'BaSeL' : BaSeL 3.1 + ATLAS (low resolution) - 'STELIB' : STELIB + ATLAS (high resolution) - 'MILES' : MILES + STELIB (high resolution) Case-insensitive. Default is 'BaSeL'. imf : str, optional Initial Mass Function. Must be one of: - 'chabrier' or 'cha' : Chabrier IMF - 'salpeter' or 'sal' : Salpeter IMF - 'kroupa' or 'kro' : Kroupa IMF Case-insensitive. Default is 'Kroupa'. path : str or None, optional Filesystem path to the BC03 model data. If None (default), the package default path is used. load_properties : bool, optional If True (default), load the model properties during initialization. The properties include remnant mass fraction, returned mass fraction, supernova rate, and ionizing photon rates (Q_HI, Q_HeI, Q_HeII) as a function of age and metallicity (see :class:`SSPBase`). verbose : bool, optional If True (default), print informational messages during initialization. Example ------- Create a BC03 SSP object using the Chabrier IMF and BaSeL library: >>> from pst.SSP import BC03_2016 >>> ssp = BC03_2016(model='BaSeL', imf='Chabrier') >>> print(ssp.wavelength) <Quantity [ ... ] Angstrom> >>> sed = ssp.L_lambda[0, -1] # SED for lowest metallicity and oldest age Notes ----- - This version supports extended metallicity coverage up to Z = 0.1. - The directory and file naming conventions follow: bc2003_{res}_{model_key}_{metallicity}_{imf_key}_ssp.fits - Supported metallicities (Padova1994 isochrones): - 'm22': Z = 0.0001 - 'm32': Z = 0.0004 - 'm42': Z = 0.004 - 'm52': Z = 0.008 - 'm62': Z = 0.02 - 'm72': Z = 0.05 - 'm82': Z = 0.1 - MILES input is selected via `model='MILES'` (resolved as `xmiless` internally). See also -------- :class:`SSPBase` : Base class for all SSP models. References ---------- Bruzual, G., & Charlot, S. (2003). Stellar population synthesis at the resolution of 2003. `MNRAS, 344(4), 1000-1028 <https://ui.adsabs.harvard.edu/abs/2003MNRAS.344.1000B/abstract>`_. `Gustavo Bruzual page <https://www.bruzual.org/>`_ Gutkin, J., Charlot, S., & Bruzual, G. (2016). Modelling the nebular emission from primeval to present-day star-forming galaxies `MNRAS, 462(2), 1757-1774 <https://ui.adsabs.harvard.edu/abs/2016MNRAS.462.1757G>`_. """ _metallicity_map = { # Padova1994 'm22': 0.0001, 'm32': 0.0004, 'm42': 0.004, 'm52': 0.008, 'm62': 0.02, 'm72': 0.05, 'm82': 0.1} # Wavelength resolution corresponding to each atmospheric library _resolution = {'BaSeL': 'lr', 'stelib': 'hr', 'xmiless': 'hr'} isochrone = "PARSEC2012" def __init__(self, model='BaSeL', imf='Kroupa', path=None, load_properties=True, verbose=True) -> None: self.stellar_library, model_key = self._parse_model(model) self.imf, imf_key = self._parse_imf(imf) if path is None: self.path = os.path.join(self.default_path, 'BC03', 'bc03_2016ver', self.stellar_library, self.imf) else: self.path = path ages_file = os.path.join(self.default_path, 'BC03', 'TIME_SCALE.DAT') if not os.path.isfile(ages_file): raise FileNotFoundError(f"Required ages file not found: {ages_file}") if verbose: print(f"> Initialising BC03 model {self.stellar_library} (IMF={self.imf})") # Grid of metallicities and ages self.metallicities = np.array(list(self._metallicity_map.values()) ) * units.dimensionless_unscaled self.ages = np.loadtxt(ages_file, skiprows=1) << units.yr # TODO: deprecate self.log_ages_yr = np.log10(self.ages / units.yr) # Load SEDs from files load_wavelength = False remnant_mass_frac = np.zeros((self.metallicities.size, self.ages.size)) returned_mass_frac = np.zeros((self.metallicities.size, self.ages.size)) snr = np.zeros((self.metallicities.size, self.ages.size)) / (units.yr * units.Msun) log_q_hi = np.zeros((self.metallicities.size, self.ages.size)) log_q_hei = np.zeros((self.metallicities.size, self.ages.size)) log_q_heii = np.zeros((self.metallicities.size, self.ages.size)) for i, metallicity_key in enumerate(self._metallicity_map.keys()): fits_path = os.path.join( self.path, f"bc2003_{self._resolution[model_key]}_{model_key}_{metallicity_key}_{imf_key}_ssp.fits") if not os.path.isfile(fits_path): raise FileNotFoundError(f"Required FITS file not found: {fits_path}") table = Table.read(fits_path) if not load_wavelength: self.wavelength = table['wavelength'].value << u.angstrom self.L_lambda = np.zeros((self.metallicities.size, self.ages.size, self.wavelength.size)) << u.Lsun / u.Angstrom / u.Msun load_wavelength = True table.remove_column("wavelength") for j, column in enumerate(table.itercols()): self.L_lambda[i, j] = column.value << self.L_lambda.unit if load_properties: # Load the mass return fractions and SNR from the properties file props_path = os.path.join( self.path, f"bc2003_{self._resolution[model_key]}_{model_key}_{metallicity_key}_{imf_key}_ssp.4color") if not os.path.isfile(props_path): raise FileNotFoundError(f"Required properties file not found: {props_path}") props_table = Table.read(props_path, format='ascii', header_start=29) remnant_mass_frac[i] = props_table['M_remnants'].value returned_mass_frac[i] = props_table['M_ret_gas'].value # Load the SN rates props_path = os.path.join( self.path, f"bc2003_{self._resolution[model_key]}_{model_key}_{metallicity_key}_{imf_key}_ssp.3color") if not os.path.isfile(props_path): raise FileNotFoundError(f"Required properties file not found: {props_path}") props_table = Table.read(props_path, format='ascii', header_start=29) log_q_hi[i] = props_table['NLy'].value log_q_hei[i] = props_table['NHeI'].value log_q_heii[i] = props_table['NHeII'].value bol_lum_lsun = props_table['Bol_Flux'].value bol_snr = props_table['SNR/yr/Lo'].value snr[i] = bol_snr * bol_lum_lsun << (1 / units.yr / units.Msun) self.remnant_mass_frac = remnant_mass_frac self.returned_mass_frac = returned_mass_frac self.log_ionising_HI_photons = log_q_hi self.log_ionising_HeI_photons = log_q_hei self.log_ionising_HeII_photons = log_q_heii self.supernova_rate = snr def _parse_model(self, model): if 'basel' in model.lower(): model = 'BaSeL3.1_Atlas' key = 'BaSeL' elif 'stelib' in model.lower(): model = 'Stelib_Atlas' key = 'stelib' elif 'miles' in model.lower(): model = 'Miles_Atlas' key = 'xmiless' else: raise NameError(f"Unrecognized model: {model}.\n" + "Select Basel, Stelib, Miles") return model, key def _parse_imf(self, imf): if 'cha' in imf.lower(): imf = 'Chabrier_IMF' key = 'chab' elif 'sal' in imf.lower(): imf = 'Salpeter_IMF' key = 'salp' elif 'kro' in imf.lower(): imf = 'Kroupa_IMF' key = 'kroup' else: raise NameError(f"Unrecognized IMF: {imf}.\n" + "Select Chabrier, Salpeter or Kroupa") return imf, key
[docs] class BaseGM(SSPBase): """ SSP model class for the GM (Granada-MILES) base used in the CALIFA survey. This class loads a combined grid of simple stellar population (SSP) models: - The **young stellar populations** (ages < 63 Myr) are taken from GRANADA models developed by González Delgado et al. (2005). - The **older populations** (ages ≥ 63 Myr) are based on the MILES spectral library, specifically using the models from Vazdekis et al. (2010). Parameters ---------- path : str or None, optional Path to the `gsd01_156.fits` file containing the GRANADA SSP model cube. If None, a default path is used. The companion `fits_like_properties.dat` file containing the age and metallicity grid must reside in the same directory. verbose : bool, optional If True, print initialization messages. Default is True. Notes ----- - The Granada models are provided as 156 SEDs, reshaped to a (39 × 4) grid of ages and metallicities. - All models assume a **Salpeter (1955) initial mass function (IMF)**. - Spectral range: **3000 Å to 7000 Å**, with **0.3 Å resolution**. Example ------- >>> from pst.SSP import BaseGM >>> gm = BaseGM(verbose=False) >>> print(gm.wavelength[0], gm.wavelength[-1]) 3000.0 Angstrom 7000.0 Angstrom >>> print(gm.ages.min(), gm.ages.max()) 1e6 yr 1.3e10 yr References ---------- González Delgado, R. M., et al. (2005). "Evolutionary stellar population synthesis at high spectral resolution: optical wavelengths ." MNRAS, 357, 945. https://ui.adsabs.harvard.edu/abs/2005MNRAS.357..945G Sánchez, S. F., et al. (2012). "CALIFA, the Calar Alto Legacy Integral Field Area survey: I. Survey presentation." A&A, 538, A8. https://ui.adsabs.harvard.edu/abs/2012A%26A...538A...8S """ def __init__(self, path=None, verbose=True): if path is None: self.path = os.path.join(self.default_path, 'BaseGM', 'gsd01_156.fits') self._ssp_properties_path = os.path.join( self.default_path, 'BaseGM', 'fits_like_properties.dat') else: self.path = path self._ssp_properties_path = os.path.join(os.path.dirname(path), "fits_like_properties.dat") self.metallicities = np.loadtxt(self._ssp_properties_path, usecols=(1) ) * units.dimensionless_unscaled self.ages = np.loadtxt(self._ssp_properties_path, usecols=(0)) * units.yr self.log_ages_yr = np.log10(self.ages / units.yr) if verbose: print("> Initialising GRANADA models (IMF=Salpeter)") ssp_fits = fits.open(self.path) wl0 = ssp_fits[0].header['CRVAL1'] deltawl = ssp_fits[0].header['CDELT1'] self._header = ssp_fits[0].header self.norm = np.array( [ssp_fits[0].header['NORM'+str(i)] for i in range(156)] ) * units.Lsun / units.Msun / units.Angstrom SED = ssp_fits[0].data * self.norm[:, np.newaxis] SED = SED.reshape(39, 4, -1) self.norm = self.norm.reshape(39, 4) self.age_sort = np.argsort(self.ages.reshape(39, 4), axis=0) SED = SED[self.age_sort[:, 0], :, :] self.norm = self.norm[self.age_sort[:, 0], :] SED = SED.transpose(1, 0, 2) self.norm = self.norm.transpose(1, 0) self.wavelength = np.arange(wl0, wl0 + deltawl * SED.shape[-1], deltawl) * units.Angstrom # AA self.L_lambda = SED ssp_fits.close() self.ages = np.unique(self.ages) self.log_ages_yr = np.log10(self.ages.value) * units.dimensionless_unscaled self.metallicities = np.sort(np.unique(self.metallicities))
# class FSPS(SSPBase): # """Fast Stellar Population Synthesis models...""" # def __init__(self): # print("> Initialising FSPS models") # self.path = os.path.join(config.path_to_ssp_models, 'FSPS', # 'fsps_ssp_models.hdf5') # file = h5py.File(self.path, 'r') # elements = list(file.keys()) # log_ages = [] # metallicities = [] # for elem_i in elements: # if elem_i.find('log_age') >= 0: # init = 'log_age_' # end = '_Z_' # log_ages.append(elem_i[elem_i.find(init)+len(init): # elem_i.find(end)]) # metallicities.append(elem_i[elem_i.find(end)+len(end):]) # metallicities = np.sort(np.unique(metallicities)) # log_ages = np.unique(log_ages) # self.wavelength = file['wavelength'][()] # self.metallicities = np.array(metallicities, dtype=float) # self.log_ages_yr = np.sort(np.array(log_ages, dtype=float)) # self.ages = 10**self.log_ages_yr # self.L_lambda = self.L_lambda = np.empty( # shape=(self.metallicities.size, self.log_ages_yr.size, # self.wavelength.size), dtype=np.float32) # for i, met in enumerate(metallicities): # for j, log_age in enumerate(log_ages): # name = 'log_age_{}_Z_{}'.format(log_age, met) # l_lambda = file[name]['L_lambda'][()] # self.L_lambda[i][j] = l_lambda # file.close() # = 'Lsun/Angstrom/Msun'
[docs] class XSL(SSPBase): """ X-Shooter SSP empirical models. These models provide spectral energy distributions for simple stellar populations observed with the `X-Shooter instrument <http://www.eso.org/sci/facilities/paranal/instruments/xshooter/overview.html>`_. They cover a wide range of ages and metallicities, using two sets of isochrones and two IMFs. Parameters ---------- IMF : str Initial Mass Function to use. Options: - 'Kroupa' : Kroupa (2001) IMF - 'Salpeter' : Salpeter (1955) IMF ISO : str Set of isochrones to use. Options: - 'P00' : Padova 2000 isochrones - 'PC' : PARSEC-COLIBRI isochrones path : str or None, optional Path to directory containing the SSP FITS files. If None, defaults to `default_path/XSL/IMF`. verbose : bool, optional If True, prints initialization messages. Default is True. Notes ----- - Model files must be available in the specified `path`. References ---------- Verro, K., Trager, S.C., Peletier, R.F., et al. 2022, `A&A, 661, A50 <https://ui.adsabs.harvard.edu/abs/2022A&A...661A..50V>`_. Project home page: http://xsl.u-strasbg.fr/index.html See also -------- SSPBase Base class for SSP models. Example ------- >>> from pst.SSP import XSL >>> xsl = XSL(IMF='Kroupa', ISO='P00', verbose=False) >>> print(xsl.wavelength[0], xsl.wavelength[-1]) 3000.0 Angstrom 25000.0 Angstrom >>> print(xsl.ages.min(), xsl.ages.max()) 5e7 yr 1.6e10 yr >>> print(xsl.metallicities) [0.0004 0.001 0.004 0.008 0.019 0.03] >>> print(xsl.L_lambda.shape) (6, 26, 58642) """ _c_imf = dict(salpeter=9799552.50, kroupa=5567946.09) _initial_mass_functions = ["Kroupa", "Salpeter"] _iso_ages = { "PC": 10**np.arange(7.7, 10.1, 0.1) << u.yr, "P00": np.array([8.91e8, 1e9, 1.12e9, 1.26e9, 1.41e9, 1.58e9, 1.78e9, 2e9, 2.24e9, 2.51e9, 2.82e9, 3.16e9, 3.55e9, 3.98e9, 4.47e9, 5.01e9, 5.62e9, 6.31e9, 7.08e9, 7.94e9, 8.91e9, 1e10, 1.12e10, 1.26e10, 1.41e10, 1.59e10, 1.78e10]) << u.yr } _iso_metals = { "P00": np.array([0.0004, 0.001, 0.004, 0.008, 0.019, 0.03] ) << u.dimensionless_unscaled, "PC": 10**np.array([-2.2, -2.0, -1.8, -1.6, -1.4, -1.2, -1.0, -0.8, -0.6, -0.4, -0.2, 0, 0.2]) * 0.019 << u.dimensionless_unscaled } _n_wavelength = 58642 def __init__(self, IMF, ISO, path=None, verbose=True): if verbose: print("> Initialising X-Shooter (XSL) models (IMF={}, ISO={})".format( IMF, ISO)) if (IMF != 'Kroupa') & (IMF != 'Salpeter'): raise NameError('IMF not valid (use Kroupa | Salpeter)') if (ISO != 'P00') & (ISO != 'PC'): raise NameError('ISO not valid (use P00 for Padova2000 or PC for PARSEC/COLIBRI)') if path: self.path = path else: self.path = os.path.join(self.default_path, 'XSL', IMF) self._get_ssps(ISO) self._fetch_files(ISO, IMF) def _get_ssps(self, iso): self.ages = self._iso_ages[iso] self.log_ages_yr = np.log10(self.ages.to_value("yr")) self.metallicities = self._iso_metals[iso] self.L_lambda = np.empty( shape=(self.metallicities.size, self.ages.size, self._n_wavelength), dtype=np.float32 ) * units.Lsun / units.Msun / units.Angstrom def _fetch_files(self, iso, imf): c_solar = self._c_imf[imf.lower()] # Convert to solar units if iso == "P00": file_fmt = r"XSL_SSP_T{:2.2e}_Z{}_Kroupa_P00.fits" for age_idx, age in enumerate(self.ages): for met_idx, met in enumerate(self.metallicities): filename = file_fmt.format(age.to_value("yr"), met.value) path_to_file = os.path.join(self.path, filename) print(path_to_file) if not os.path.isfile(path_to_file): raise FileNotFoundError(f"{path_to_file} not found") with fits.open(path_to_file) as hdul: spec = hdul[0].data * c_solar self.L_lambda[met_idx][age_idx] = spec * self._L_lambda.unit elif iso == "PC": file_fmt = r"XSL_SSP_logT{:.1f}_MH{:.1f}_Kroupa_PC.fits" for age_idx, age in enumerate(self.ages): for met_idx, met in enumerate(self.metallicities): filename = file_fmt.format(np.log10(age.to_value("yr")), np.log10(met.value / 0.019)) if met == 0.019: filename = file_fmt.format(np.log10(age.to_value("yr")), np.log10(met.value / 0.019 - 1e-3)) path_to_file = os.path.join(self.path, filename) print(path_to_file) if not os.path.isfile(path_to_file): raise FileNotFoundError(f"{path_to_file} not found") with fits.open(path_to_file) as hdul: spec = hdul[0].data * c_solar self.L_lambda[met_idx][age_idx] = spec * self._L_lambda.unit # Use the last file to load the wavelenth array with fits.open(path_to_file) as hdul: self.wavelength = 10**( (np.arange(0, hdul[0].data.size, 1) - hdul[0].header['CRPIX1']) * hdul[0].header['CDELT1'] + hdul[0].header['CRVAL1'] + 1 ) * units.Angstrom
[docs] class EMILES(SSPBase): """ E-MILES simple stellar population (SSP) models. These models provide high-quality empirical SSP spectra covering the spectral range 1680−50000 Å at moderately high resolution. The E-MILES library combines multiple stellar libraries and theoretical isochrones to deliver spectra of single-age, single-metallicity populations. The UV spectral range (1680–3540 Å) is computed using the `NGSL space-based stellar library <https://archive.stsci.edu/prepds/stisngsl/>`_, offering a significant improvement over earlier space-based models. The optical spectra use the MILES empirical library, and redder wavelengths are covered by `Indo-US <https://noirlab.edu/science/observing-noirlab/observing-kitt-peak/telescope-and-instrument-documentation/cflib>`_, `CaT <https://research.iac.es/proyecto/miles/pages/stellar-libraries/cat-library.php>`_, and `IRTF <https://irtfweb.ifa.hawaii.edu/~spex/IRTF_Spectral_Library/>`_ empirical libraries, all computed with consistent methods. The SSP spectra span metallicities from −1.79 < [M/H] < +0.26 and ages greater than 30 Myr, across several IMF types with varying slopes. Spectral resolution: - UV range (λ < 3060 Å): FWHM ≈ 3 Å - UV range (3060 Å < λ < 3540 Å): FWHM ≈ 5 Å - Optical range (3540 Å to 8950 Å): FWHM ≈ 2.5 Å - Infrared (longer wavelengths): σ = 60 km/s Parameters ---------- iso : str Isochrone set to use. Options: - 'BASTI' : BASTI isochrones (Pietrinferni et al. 2004) - 'PADOVA00' : Padova 2000 isochrones (Girardi et al. 2000) imf : str Initial mass function to use. Options: - 'KROUPA_UNIVERSAL' : Kroupa universal IMF (Kroupa 2001) - 'UNIMODAL' : Unimodal IMF (single power-law, Vazdekis et al. 1996) - 'BIMODAL' : Bimodal IMF (Vazdekis et al. 1996) - 'CHABRIER' : Chabrier IMF (Chabrier 2003) path : str or None, optional Path to the directory containing the E-MILES FITS files. Defaults to `default_path/EMILES`. verbose : bool, optional If True, prints initialization messages. Default is True. Notes ----- - The spectral resolution and wavelength coverage follow Vazdekis et al. (2016) and Röck et al. (2016). - The models cover a wavelength range of approximately 1680–50000 Å with spectral resolution ~2.5 Å (FWHM). - The metallicities are given as logarithmic values relative to solar, with solar metallicity fixed at Z=0.019. References ---------- Vazdekis, A., Koleva, M., Ricciardelli, E., et al. 2016, `MNRAS, 463, 3409 <https://ui.adsabs.harvard.edu/abs/2016MNRAS.463.3409V/abstract>`_ Röck, B., Vazdekis, A., Ricciardelli, E., et al. 2016, `A&A, 589, A73, 8 <https://ui.adsabs.harvard.edu/abs/2016A%26A...589A..73R/abstract>`_ See also -------- SSPBase Base class for simple stellar population models. Example ------- >>> from pst.SSP import EMILES >>> emiles = EMILES(iso='BASTI', imf='KROUPA_UNIVERSAL', verbose=False) >>> print(emiles.wavelength[0], emiles.wavelength[-1]) 1680.0 Angstrom 50000.0 Angstrom >>> print(emiles.ages.min(), emiles.ages.max()) 30.0 Myr 14.0 Gyr >>> print(emiles.metallicities) [0.0001 0.00016 ... 0.03] # actual values depend on the isochrone >>> print(emiles.L_lambda.shape) (12, 52, 53689) """ _lib_isochrones = {"BASTI": "iTp", "PADOVA00": "iPp"} _lib_imfs = {"KROUPA_UNIVERSAL": "ku1.30", "UNIMODAL": "un", "BIMODAL": "bi", "CHABRIER": "ch1.30"} _n_wavelength = 53689 # Spectra size _iso_logmetals = { "BASTI": np.array([-2.27, -1.79, -1.49, -1.26, -0.96, -0.66, -0.35, -0.25, 0.06, 0.15, 0.26, 0.4]), "PADOVA00": np.array([-2.32, -1.71 , -1.31 , -0.71, -0.40, 0.00, 0.22]) } _iso_ages = { "BASTI": np.array( [0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.60, 0.70, 0.80, 0.90, 1.0, 1.250, 1.500, 1.750, 2.000, 2.25, 2.50, 2.75, 3.0, 3.25, 3.50, 3.75, 4.0, 4.5, 5.0, 5.5, 6.0, 6.5, 7., 7.5, 8., 8.5, 9.0, 9.5, 10., 10.5, 11., 11.5, 12., 12.5, 13., 13.5, 14.]) << u.Gyr, "PADOVA00": np.array( [0.0631, 0.0708, 0.0794, 0.0891, 0.1000, 0.1122, 0.1259, 0.1413, 0.1585, 0.1778, 0.1995, 0.2239, 0.2512, 0.2818, 0.3162, 0.3548, 0.3981, 0.4467, 0.5012, 0.5623, 0.6310, 0.7079, 0.7943, 0.8913, 1.0000, 1.1220, 1.2589, 1.4125, 1.5849, 1.7783, 1.9953, 2.2387, 2.5119, 2.8184, 3.1623, 3.5481, 3.9811, 4.4668, 5.0119, 5.6234, 6.3096, 7.0795, 7.9433, 8.9125, 10.0000, 11.2202, 12.5893, 14.1254, 15.8489, 17.7828]) << u.Gyr } def __init__(self, iso, imf, path=None, verbose=True): if verbose: print("> Initialising E-MILES models (IMF={}, ISO={})".format( imf, iso)) if path: self.path = path else: self.path = os.path.join(self.default_path, 'EMILES') model_name = self._get_models_prefix(iso, imf) self._load_models(model_name)
[docs] def _get_models_prefix(self, iso, imf): """Get the file prefix used to load the model files.""" model_name = os.path.join(self.path, "E") # IMF if not imf.upper() in self._lib_imfs.keys(): raise NameError(f"Input IMF {imf} not recognized" + f"\nThe available isochrones are: {self._lib_imfs.keys()}") else: model_name += self._lib_imfs[imf.upper()] # Metallicity and age model_name += r"Z{}{:.2f}T{:07.4f}_" # Isochrone if not iso.upper() in self._lib_isochrones: raise NameError(f"Input isochrone {iso} not recognized" + f"\nThe available isochrones are: {list(self._lib_isochrones.keys())}") else: model_name += self._lib_isochrones[iso.upper()] self._ages = self._iso_ages[iso.upper()] self.log_ages_yr = np.log10(self._ages / units.yr) # Solar metallicity defined in V+16. self._logmetals = self._iso_logmetals[iso.upper()] self._metallicities = 10**self._logmetals * 0.019 # Alpha over iron model_name += "0.00_baseFe.fits" return model_name
[docs] def _load_models(self, model_name): """Load the SSP models from individual FITS files.""" self.L_lambda = np.zeros( (self.metallicities.size, self.ages.size, self._n_wavelength)) * u.Lsun / u.Angstrom / u.Msun for met_idx, metal in enumerate(self._logmetals): if metal < 0: m_prefix = "m" else: m_prefix = "p" for age_idx, age in enumerate(self.ages.to_value("Gyr")): file = model_name.format(m_prefix, np.abs(metal), age) assert os.path.isfile(file), f"File {file} not found" with fits.open(file) as hdul: self.L_lambda[met_idx, age_idx] = hdul[0].data << self.L_lambda.unit with fits.open(file) as hdul: wcs = WCS(hdul[0].header) self.wavelength = wcs.array_index_to_world_values( np.arange(0, self._n_wavelength)) << u.AA
if __name__ == '__main__': # ssp = PopStar(IMF='cha_0.15_100') from matplotlib import pyplot as plt ssp = BaseGM() # %% ... Paranoy@ Rulz! ;^D # Mr Krtxo \(゚▽゚)/