From ceeb12497360cf4388d9bdbc8030f8a38808aac2 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 14 Mar 2025 10:35:59 +0100 Subject: [PATCH 01/61] combine SFR_Z_fraction and star_formation_rate into a single function call, which pre-calculate the multiplication for speed improvemen --- posydon/popsyn/star_formation_history.py | 24 ++++++++++++++++++++ posydon/popsyn/synthetic_population.py | 28 ++++++------------------ 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index c314a66663..0c9a78f6db 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -103,6 +103,30 @@ def get_illustrisTNG_data(verbose=False): print("Loading IllustrisTNG data...") return np.load(os.path.join(PATH_TO_POSYDON_DATA, "SFR/IllustrisTNG.npz")) +def SFR_per_Z_at_z(z, met_bins, MODEL): + """Calculate the SFR per metallicity bin at a given redshift(s) + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + met_bins : array + Metallicity bins edges in absolute metallicity. + MODEL : dict + Model parameters. + + Returns + ------- + SFH : 2D array + Star formation history per metallicity bin at the given redshift(s). + + """ + SFRD = star_formation_rate(MODEL["SFR"], z) + fSFRD = SFR_Z_fraction_at_given_redshift( + z, MODEL["SFR"], MODEL["sigma"], met_bins, MODEL["Z_max"], select_one_met=False + ) + SFH = SFRD[:, np.newaxis] * fSFRD + return SFH def star_formation_rate(SFR, z): """Star formation rate in M_sun yr^-1 Mpc^-3. diff --git a/posydon/popsyn/synthetic_population.py b/posydon/popsyn/synthetic_population.py index bcc6fb1425..099724388c 100644 --- a/posydon/popsyn/synthetic_population.py +++ b/posydon/popsyn/synthetic_population.py @@ -57,10 +57,7 @@ get_redshift_bin_centers, ) -from posydon.popsyn.star_formation_history import ( - star_formation_rate, - SFR_Z_fraction_at_given_redshift, -) +from posydon.popsyn.star_formation_history import SFR_per_Z_at_z from posydon.popsyn.binarypopulation import ( BinaryPopulation, @@ -2083,22 +2080,11 @@ def calculate_cosmic_weights(self, SFH_identifier, MODEL_in=None): get_redshift_from_cosmic_time = redshift_from_cosmic_time_interpolator() indices = self.indices + met_edges = rates.edges_metallicity_bins # sample the SFH for only the events that are within the Hubble time # only need to sample the SFH at each metallicity and z_birth - # Not for every event! - SFR_at_z_birth = star_formation_rate(rates.MODEL["SFR"], z_birth) - # get metallicity bin edges - met_edges = rates.edges_metallicity_bins - - # get the fractional SFR at each metallicity and z_birth - fSFR = SFR_Z_fraction_at_given_redshift( - z_birth, - rates.MODEL["SFR"], - rates.MODEL["sigma_SFR"], - met_edges, - rates.MODEL["Z_max"], - rates.MODEL["select_one_met"], - ) + # Not for every event! + SFR_per_Z_at_z_birth = SFR_per_Z_at_z(z_birth, met_edges, MODEL) # simulated mass per given metallicity corrected for the unmodeled # single and binary stellar mass @@ -2153,7 +2139,7 @@ def calculate_cosmic_weights(self, SFH_identifier, MODEL_in=None): ) weights = np.zeros((len(met_events), nr_of_birth_bins)) - for i, met in enumerate(rates.centers_metallicity_bins): + for j, met in enumerate(rates.centers_metallicity_bins): mask = met_events == met weights[mask, :] = ( 4.0 @@ -2161,8 +2147,8 @@ def calculate_cosmic_weights(self, SFH_identifier, MODEL_in=None): * c * D_c[mask] ** 2 * deltaT - * (fSFR[:, i] * SFR_at_z_birth) - / M_model[i] + * SFR_per_Z_at_z_birth[:, j] + / M_model[j] ) # yr^-1 with pd.HDFStore(self.filename, mode="a") as store: From 9f511d9e50ab2db09ffe7efd192c14211d523579 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 14 Mar 2025 10:37:08 +0100 Subject: [PATCH 02/61] add correct select_one_met in function --- posydon/popsyn/star_formation_history.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 0c9a78f6db..385c4b368d 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -123,7 +123,12 @@ def SFR_per_Z_at_z(z, met_bins, MODEL): """ SFRD = star_formation_rate(MODEL["SFR"], z) fSFRD = SFR_Z_fraction_at_given_redshift( - z, MODEL["SFR"], MODEL["sigma"], met_bins, MODEL["Z_max"], select_one_met=False + z, + MODEL["SFR"], + MODEL["sigma"], + met_bins, + MODEL["Z_max"], + MODEL['select_one_met'] ) SFH = SFRD[:, np.newaxis] * fSFRD return SFH From e5fa2660b6dc1e01b7584ea0360f1287fe947d70 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 14 Mar 2025 13:32:35 +0100 Subject: [PATCH 03/61] move SFH into separate classes with abstract inheritance --- posydon/popsyn/rate_calculation.py | 1 - posydon/popsyn/star_formation_history.py | 234 +++++++++++++++++++++-- posydon/popsyn/synthetic_population.py | 2 +- 3 files changed, 223 insertions(+), 14 deletions(-) diff --git a/posydon/popsyn/rate_calculation.py b/posydon/popsyn/rate_calculation.py index 3657403ffb..5cbfd8716a 100644 --- a/posydon/popsyn/rate_calculation.py +++ b/posydon/popsyn/rate_calculation.py @@ -150,7 +150,6 @@ def get_redshift_from_cosmic_time(t_cosm): return trained_tz_interp(t_cosm) - def get_redshift_bin_edges(delta_t): """Compute the redshift bin edges. diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 385c4b368d..f912ed5753 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -23,7 +23,7 @@ from posydon.utils.constants import Zsun from posydon.utils.interpolators import interp1d from astropy.cosmology import Planck15 as cosmology - +from abc import ABC, abstractmethod SFH_SCENARIOS = [ "burst", @@ -34,6 +34,225 @@ "custom_log10_histogram", ] +class SFHBase(ABC): + + def __init__(self, MODEL): + self.MODEL = MODEL + # Automatically attach all model parameters as attributes + for key, value in MODEL.items(): + setattr(self, key, value) + + @abstractmethod + def CSFRD(self, z): + """Compute the cosmic star formation rate density.""" + pass + + @abstractmethod + def mean_metallicity(self, z): + """Return the mean metallicity at redshift z.""" + pass + + def std_log_metallicity_dist(self): + sigma = self.sigma + if isinstance(sigma, str): + if sigma == "Bavera+20": + return 0.5 + elif sigma == "Neijssel+19": + return 0.39 + else: + raise ValueError("Unknown sigma choice!") + elif isinstance(sigma, float): + return sigma + else: + raise ValueError(f"Invalid sigma value {sigma}!") + + @abstractmethod + def fSFR(self, z, metallicity_bins): + """Return the fractional SFR as a function of redshift and metallicity bins.""" + pass + + def __call__(self, z, met_bins): + return self.CSFRD(z)[:, np.newaxis] * self.fSFR(z, met_bins) + +class MadauBase(SFHBase): + """ + Base class for Madau-style star-formation history implementations. + This class implements common methods for CSFRD, mean metallicity, + and fractional SFR based on the chosen Madau parameterisation. + The specific parameters for CSFRD must be provided by subclasses. + """ + + def CSFRD(self, z): + p = self.CSFRD_params + return p["a"] * (1.0 + z) ** p["b"] / (1.0 + ((1.0 + z) / p["c"]) ** p["d"]) + + def mean_metallicity(self, z): + return 10 ** (0.153 - 0.074 * z ** 1.34) * Zsun + + def fSFR(self, z, metallicity_bins): + sigma = self.std_log_metallicity_dist() + # Compute mu; if z is an array, mu will be an array. + mu = np.log10(self.mean_metallicity(z)) - sigma ** 2 * np.log(10) / 2.0 + # Ensure mu is an array for consistency + mu_array = np.atleast_1d(mu) + # Use the first value of mu for normalisation + norm = stats.norm.cdf(np.log10(self.Z_max), mu_array[0], sigma) + fSFR = np.empty((len(mu_array), len(metallicity_bins) - 1)) + fSFR[:, :] = np.array( + [ + ( + stats.norm.cdf(np.log10(metallicity_bins[1:]), m, sigma) / norm + - stats.norm.cdf(np.log10(metallicity_bins[:-1]), m, sigma) / norm + ) + for m in mu_array + ] + ) + if not self.select_one_met: + fSFR[:, 0] = stats.norm.cdf(np.log10(metallicity_bins[1]), mu_array, sigma) / norm + fSFR[:, -1] = norm - stats.norm.cdf(np.log10(metallicity_bins[-1]), mu_array, sigma) / norm + return fSFR + +class MadauDickinson14(MadauBase): + + def __init__(self, MODEL): + super().__init__(MODEL) + self.SFR = MODEL["SFR"] + # Parameters for Madau+Dickinson14 CSFRD + self.CSFRD_params = { + "a": 0.015, + "b": 2.7, + "c": 2.9, + "d": 5.6, + } + +class MadauFragos17(MadauBase): + + def __init__(self, MODEL): + super().__init__(MODEL) + self.SFR = MODEL["SFR"] + # Parameters for Madau+Fragos17 CSFRD + self.CSFRD_params = { + "a": 0.01, + "b": 2.6, + "c": 3.2, + "d": 6.2, + } + +class Neijssel19(MadauBase): + + def __init__(self, MODEL): + super().__init__(MODEL) + self.SFR = MODEL["SFR"] + # Parameters for Neijssel+19 CSFRD + self.CSFRD_params = { + "a": 0.01, + "b": 2.77, + "c": 2.9, + "d": 4.7, + } + + # overwrite mean_metallicity method of MadauBase + def mean_metallicity(self, z): + return 0.035 * 10 ** (-0.23 * z) + + # overwrite std_log_metallicity_dist method of MadauBase + # TODO: rewrite such that sigma is just changed for the Neijssel+19 case + def fSFR(self, z, metallicity_bins): + # assume a truncated ln-normal distribution of metallicities + sigma = self.std_log_metallicity_dist() + mu = np.log(self.mean_metallicity(z)) - sigma**2 / 2.0 + # renormalisation constant + norm = stats.norm.cdf(np.log(self.Z_max), mu[0], sigma) + fSFR = np.empty((len(z), len(metallicity_bins) - 1)) + fSFR[:, :] = np.array( + [ + ( + stats.norm.cdf(np.log(metallicity_bins[1:]), m, sigma) / norm + - stats.norm.cdf(np.log(metallicity_bins[:-1]), m, sigma) / norm + ) + for m in mu + ] + ) + if not self.select_one_met: + fSFR[:, 0] = stats.norm.cdf(np.log(metallicity_bins[1]), mu, sigma) / norm + fSFR[:,-1] = norm - stats.norm.cdf(np.log(metallicity_bins[-1]), mu, sigma)/norm + return fSFR + +class IllustrisTNG(SFHBase): + + def __init__(self, MODEL): + super().__init__(MODEL) + # load the TNG data + illustris_data = self._get_illustrisTNG_data() + self.SFR = illustris_data["SFR"] + self.redshifts = illustris_data["redshifts"] + self.Z = illustris_data["mets"] + self.M = illustris_data["M"] # Msun + + def _get_illustrisTNG_data(self, verbose=False): + """Load IllustrisTNG SFR dataset.""" + if verbose: + print("Loading IllustrisTNG data...") + return np.load(os.path.join(PATH_TO_POSYDON_DATA, "SFR/IllustrisTNG.npz")) + + + def CSFRD(self, z): + SFR_interp = interp1d(self.redshifts, self.SFR) + return SFR_interp(z) + + def mean_metallicity(self, z): + out = np.zeros_like(self.redshifts) + for i in range(len(out)): + if np.sum(self.M[i, :]) == 0: + out[i] = 0 + else: + out[i] = np.average(self.Z, weights=self.M[i, :]) + Z_interp = interp1d(self.redshifts, out) + return Z_interp(z) + + def fSFR(self, z, metallicity_bins): + # only use data within the metallicity bounds (no lower bound) + Z_max_mask = self.Z <= self.Z_max + redshift_indices = np.array([np.where(self.redshifts <= i)[0][0] for i in z]) + Z_dist = self.M[:, Z_max_mask][redshift_indices] + fSFR = np.zeros((len(z), len(metallicity_bins) - 1)) + + for i in range(len(z)): + if Z_dist[i].sum() == 0.0: + continue + else: + # Add a final point to the CDF and metallicities to ensure normalisation to 1 + Z_dist_cdf = np.cumsum(Z_dist[i]) / Z_dist[i].sum() + Z_dist_cdf = np.append(Z_dist_cdf, 1) + Z_x_values = np.append(np.log10(self.Z[Z_max_mask]), 0) + Z_dist_cdf_interp = interp1d(Z_x_values, Z_dist_cdf) + + fSFR[i, :] = (Z_dist_cdf_interp(np.log10(metallicity_bins[1:])) - + Z_dist_cdf_interp(np.log10(metallicity_bins[:-1]))) + + if not self.select_one_met: + if len(metallicity_bins) == 2: + fSFR[i, 0] = 1 + else: + fSFR[i, 0] = Z_dist_cdf_interp(np.log10(metallicity_bins[1])) + fSFR[i, -1] = 1 - Z_dist_cdf_interp(np.log10(metallicity_bins[-1])) + + return fSFR + + +def get_SFH_model(MODEL): + + if MODEL["SFR"] == "Madau+Fragos17": + return MadauFragos17(MODEL) + elif MODEL['SFR'] == "Madau+Dickinson14": + return MadauDickinson14(MODEL) + elif MODEL['SFR'] == "Neijssel+19": + return Neijssel19(MODEL) + elif MODEL['SFR'] == "IllustrisTNG": + return IllustrisTNG(MODEL) + else: + raise ValueError("Invalid SFR!") + def get_formation_times(N_binaries, star_formation="constant", **kwargs): """Get formation times of binaries in a population based on a SFH scenario. @@ -121,17 +340,8 @@ def SFR_per_Z_at_z(z, met_bins, MODEL): Star formation history per metallicity bin at the given redshift(s). """ - SFRD = star_formation_rate(MODEL["SFR"], z) - fSFRD = SFR_Z_fraction_at_given_redshift( - z, - MODEL["SFR"], - MODEL["sigma"], - met_bins, - MODEL["Z_max"], - MODEL['select_one_met'] - ) - SFH = SFRD[:, np.newaxis] * fSFRD - return SFH + SFH = get_SFH_model(MODEL) + return SFH(z, met_bins) def star_formation_rate(SFR, z): """Star formation rate in M_sun yr^-1 Mpc^-3. diff --git a/posydon/popsyn/synthetic_population.py b/posydon/popsyn/synthetic_population.py index 099724388c..2dc7761d6f 100644 --- a/posydon/popsyn/synthetic_population.py +++ b/posydon/popsyn/synthetic_population.py @@ -2085,7 +2085,7 @@ def calculate_cosmic_weights(self, SFH_identifier, MODEL_in=None): # only need to sample the SFH at each metallicity and z_birth # Not for every event! SFR_per_Z_at_z_birth = SFR_per_Z_at_z(z_birth, met_edges, MODEL) - + # simulated mass per given metallicity corrected for the unmodeled # single and binary stellar mass M_model = rates.mass_per_metallicity.loc[rates.centers_metallicity_bins / Zsun][ From 592cf8c80b5f98b5ae6c1d7445c481bca2cf3a61 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Mon, 17 Mar 2025 15:37:02 +0100 Subject: [PATCH 04/61] Move SFH to classes (with abstract treatments) + Add Chruslinska+21 SFH models --- posydon/popsyn/star_formation_history.py | 373 ++++++++++++++++++++--- posydon/popsyn/synthetic_population.py | 4 - 2 files changed, 331 insertions(+), 46 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index f912ed5753..388a8ff8d4 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -34,8 +34,9 @@ "custom_log10_histogram", ] + class SFHBase(ABC): - + '''Abstract class for star formation history models''' def __init__(self, MODEL): self.MODEL = MODEL # Automatically attach all model parameters as attributes @@ -51,6 +52,11 @@ def CSFRD(self, z): def mean_metallicity(self, z): """Return the mean metallicity at redshift z.""" pass + + @abstractmethod + def fSFR(self, z, metallicity_bins): + """Return the fractional SFR as a function of redshift and metallicity bins.""" + pass def std_log_metallicity_dist(self): sigma = self.sigma @@ -65,13 +71,22 @@ def std_log_metallicity_dist(self): return sigma else: raise ValueError(f"Invalid sigma value {sigma}!") - - @abstractmethod - def fSFR(self, z, metallicity_bins): - """Return the fractional SFR as a function of redshift and metallicity bins.""" - pass def __call__(self, z, met_bins): + '''Return the star formation history at a given redshift and metallicity bins + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + met_bins : array + Metallicity bins edges in absolute metallicity. + + Returns + ------- + array + Star formation history per metallicity bin at the given redshift(s). + ''' return self.CSFRD(z)[:, np.newaxis] * self.fSFR(z, met_bins) class MadauBase(SFHBase): @@ -83,13 +98,55 @@ class MadauBase(SFHBase): """ def CSFRD(self, z): + '''The cosmic star formation rate density at a given redshift. + + Follows the Madau & Dickinson (2014) cosmic star formation rate density formula. + + Parameters + ---------- + z : float or np.array + Cosmological redshift. + + Returns + ------- + float or array + The cosmic star formation rate density at the given redshift. + ''' p = self.CSFRD_params return p["a"] * (1.0 + z) ** p["b"] / (1.0 + ((1.0 + z) / p["c"]) ** p["d"]) def mean_metallicity(self, z): + '''The mean metallicity at a given redshift + + Follows Madau & Fragos (2017) mean metallicity evolution + + Parameters + ---------- + z : float or np.array + Cosmological redshift. + + Returns + ------- + float or array + The mean metallicity at the given redshift. + ''' return 10 ** (0.153 - 0.074 * z ** 1.34) * Zsun def fSFR(self, z, metallicity_bins): + '''Fraction of the SFR at a given redshift z in a given metallicity bin as described in Bavera et al. (2020). + + Parameters + ---------- + z : np.array + Cosmological redshift. + metallicity_bins : array + Metallicity bins edges in absolute metallicity. + + Returns + ------- + array + Fraction of the SFR in the given metallicity bin at the given redshift. + ''' sigma = self.std_log_metallicity_dist() # Compute mu; if z is an array, mu will be an array. mu = np.log10(self.mean_metallicity(z)) - sigma ** 2 * np.log(10) / 2.0 @@ -109,14 +166,19 @@ def fSFR(self, z, metallicity_bins): ) if not self.select_one_met: fSFR[:, 0] = stats.norm.cdf(np.log10(metallicity_bins[1]), mu_array, sigma) / norm - fSFR[:, -1] = norm - stats.norm.cdf(np.log10(metallicity_bins[-1]), mu_array, sigma) / norm + fSFR[:, -1] = norm - stats.norm.cdf(np.log10(metallicity_bins[-2]), mu_array, sigma) / norm return fSFR class MadauDickinson14(MadauBase): + '''Madau & Dickinson (2014) star formation history model using the + mean metallicity evolution of Madau & Fragos (2017). + + Madau & Dickinson (2014), ARA&A, 52, 415 + https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M/abstract + ''' def __init__(self, MODEL): super().__init__(MODEL) - self.SFR = MODEL["SFR"] # Parameters for Madau+Dickinson14 CSFRD self.CSFRD_params = { "a": 0.015, @@ -126,10 +188,15 @@ def __init__(self, MODEL): } class MadauFragos17(MadauBase): + '''The Madau & Fragos (2017) star formation history model with the + mean metallicity evolution of Madau & Fragos (2017). + + Madau & Fragos (2017), ApJ, 840, 39 + http://adsabs.harvard.edu/abs/2017ApJ...840...39M + ''' def __init__(self, MODEL): super().__init__(MODEL) - self.SFR = MODEL["SFR"] # Parameters for Madau+Fragos17 CSFRD self.CSFRD_params = { "a": 0.01, @@ -139,10 +206,21 @@ def __init__(self, MODEL): } class Neijssel19(MadauBase): + '''The Neijssel et al. (2019) star formation history model, which fits + the Madau & Dickinson (2014) cosmic star formation rate density formula + with the BBH merger rate and uses a truncated log-normal distribution for + the mean metallicity distribution. + The mean metallicity evolution follows the Langer and Normal parameterisation + also fitted to the BBH merger rate. + + Neijssel et al. (2019), MNRAS, 490, 3740 + http://adsabs.harvard.edu/abs/2019MNRAS.490.3740N + ''' + + def __init__(self, MODEL): super().__init__(MODEL) - self.SFR = MODEL["SFR"] # Parameters for Neijssel+19 CSFRD self.CSFRD_params = { "a": 0.01, @@ -175,10 +253,16 @@ def fSFR(self, z, metallicity_bins): ) if not self.select_one_met: fSFR[:, 0] = stats.norm.cdf(np.log(metallicity_bins[1]), mu, sigma) / norm - fSFR[:,-1] = norm - stats.norm.cdf(np.log(metallicity_bins[-1]), mu, sigma)/norm + fSFR[:,-1] = norm - stats.norm.cdf(np.log(metallicity_bins[-2]), mu, sigma)/norm return fSFR class IllustrisTNG(SFHBase): + '''The IllustrisTNG star formation history model. + + Uses the TNG100-1 model from the IllustrisTNG simulation. + + https://www.tng-project.org/ + ''' def __init__(self, MODEL): super().__init__(MODEL) @@ -195,7 +279,6 @@ def _get_illustrisTNG_data(self, verbose=False): print("Loading IllustrisTNG data...") return np.load(os.path.join(PATH_TO_POSYDON_DATA, "SFR/IllustrisTNG.npz")) - def CSFRD(self, z): SFR_interp = interp1d(self.redshifts, self.SFR) return SFR_interp(z) @@ -235,13 +318,216 @@ def fSFR(self, z, metallicity_bins): fSFR[i, 0] = 1 else: fSFR[i, 0] = Z_dist_cdf_interp(np.log10(metallicity_bins[1])) - fSFR[i, -1] = 1 - Z_dist_cdf_interp(np.log10(metallicity_bins[-1])) + fSFR[i, -1] = 1 - Z_dist_cdf_interp(np.log10(metallicity_bins[-2])) return fSFR +class Chruslinska21(SFHBase): + '''The Chruślińska+21 star formation history model. + + Chruślińska et al. (2021), MNRAS, 508, 4994 + https://ui.adsabs.harvard.edu/abs/2021MNRAS.508.4994C/abstract + + Data source: + https://ftp.science.ru.nl/astro/mchruslinska/Chruslinska_et_al_2021/ + + + ''' + def __init__(self, MODEL): + '''Initialise the Chruslinska+21 model + + Parameters + ---------- + MODEL : dict + Model parameters. Chruslinska+21 requires the following parameters: + - sub_model : str + The sub-model to use. This is the name of the file containing the data. + - Z_solar_scaling : str + The scaling of the solar metallicity. Options are: + - Asplund09 + - AndersGrevesse89 + - GrevesseSauval98 + - Villante14 + ''' + if "sub_model" not in MODEL: + raise ValueError("Sub-model not given!") + if 'Z_solar_scaling' not in MODEL: + raise ValueError("Z_solar_scaling not given!") + + super().__init__(MODEL) + self._load_chruslinska_data() + + def _load_chruslinska_data(self, verbose=False): + '''load the data from the Chruslinska+21 models + Transforms the data to the format used in the classes. + + Parameters + ---------- + verbose : bool, optional + Print information about the data loading. + + ''' + # oxygen to hydrogen abundance ratio ( FOH == 12 + log(O/H) ) + # as used in the calculations - do not change + # This is the metallicity bin edges used in the Chruslinska+21 calculations + FOH_min, FOH_max = 5.3, 9.7 + self.FOH_bins = np.linspace(FOH_min,FOH_max, 200) + self.dFOH=self.FOH_bins[1]-self.FOH_bins[0] + # I need to use the Z_solar_scaling parameter to convert the FOH bins to absolute metallicity + # I will use the solar metallicity as the reference point + self.Z = self._FOH_to_Z(self.FOH_bins) + + self._data_folder = os.path.join(PATH_TO_POSYDON_DATA, "SFR/Chruslinska+21") + _, self.redshifts, delta_T = self._load_redshift_data(verbose) + M = self._load_raw_data() + self.SFR = np.array( [M[ii]/(1e6*delta_T[ii]) for ii in range(len(delta_T))])/self.dFOH + + def _FOH_to_Z(self, FOH): + # scalings from Chruslinksa+21 + if self.Z_solar_scaling == 'Asplund09': + Zsun, FOHsun = [0.0134, 8.69] + elif self.Z_solar_scaling == 'AndersGrevesse89': + Zsun,FOHsun = [0.017, 8.83] + elif self.Z_solar_scaling == 'GrevesseSauval98': + Zsun,FOHsun = [0.0201, 8.93] + elif self.Z_solar_scaling == 'Villante14': + Zsun,FOHsun = [0.019, 8.85] + else: + raise ValueError("Invalid Z_solar_scaling!") + logZ = np.log10(Zsun) + FOH - FOHsun + ZZ=10**logZ + return ZZ + + def mean_metallicity(self, z): + '''Calculate the mean metallicity at a given redshift + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + + Returns + ------- + float or array-like + The mean metallicity at the given redshift(s). + ''' + mean_over_redshift = np.zeros_like(self.redshifts) + for i in range(len(mean_over_redshift)): + if np.sum(self.SFR[i]) == 0: + mean_over_redshift[i] = 0 + else: + mean_over_redshift[i] = np.average(self.Z, weights=self.SFR[i,:]*self.dFOH) + + Z_interp = interp1d(self.redshifts, mean_over_redshift) + return Z_interp(z) + + def fSFR(self, z, metallicity_bins): + '''Calculate the fractional SFR as a function of redshift and metallicity bins + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + metallicity_bins : array + Metallicity bins edges in absolute metallicity. + + Returns + ------- + array + Fraction of the SFR in the given metallicity bin at the given redshift. + ''' + # only use data within the metallicity bounds (no lower bound) + Z_max_mask = self.Z <= self.Z_max + redshift_indices = np.array([np.where(self.redshifts <= i)[0][0] for i in z]) + Z_dist = self.SFR[:, Z_max_mask][redshift_indices] + fSFR = np.zeros((len(z), len(metallicity_bins) - 1)) + + for i in range(len(z)): + if Z_dist[i].sum() == 0.0: + continue + else: + # Add a final point to the CDF and metallicities to ensure normalisation to 1 + Z_dist_cdf = np.cumsum(Z_dist[i]) / Z_dist[i].sum() + Z_dist_cdf = np.append(Z_dist_cdf, 1) + Z_x_values = np.append(np.log10(self.Z[Z_max_mask]), 0) + Z_dist_cdf_interp = interp1d(Z_x_values, Z_dist_cdf) + fSFR[i, :] = (Z_dist_cdf_interp(np.log10(metallicity_bins[1:])) - + Z_dist_cdf_interp(np.log10(metallicity_bins[:-1]))) + + if not self.select_one_met: + if len(metallicity_bins) == 2: + fSFR[i, 0] = 1 + else: + fSFR[i, 0] = Z_dist_cdf_interp(np.log10(metallicity_bins[1])) + fSFR[i, -1] = 1 - Z_dist_cdf_interp(np.log10(metallicity_bins[-2])) + + return fSFR + + def _load_redshift_data(self, verbose=False): + '''Load the redshift data from a Chruslinsk+21 model file. + + Returns + ------- + time : array + the center of the time bins + redshift : array + the redshifts corresponding to the time bins + delt : array + the width of the time bins + ''' + if verbose: + print("Loading redshift data...") + + time, redshift, delt = np.loadtxt( + os.path.join(self._data_folder, 'Time_redshift_deltaT.dat'), unpack=True) + return time, redshift, delt + + def _load_raw_data(self): + '''Read the sub-model data from the file + + The data structure is as follows: + - mass per unit (comoving) volume formed in each z (row) - FOH (column) bin + + Returns + ------- + array + Mass formed per unit volume in each redshift and FOH bin + ''' + input_file = os.path.join(self._data_folder, f'{self.sub_model}.dat') + data = np.loadtxt(input_file) + return data + + def CSFRD(self, z): + '''Interpolate the cosmic star formation rate density at the given redshift(s) + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + + Returns + ------- + float or array-like + The cosmic star formation rate density at the given redshift(s). + ''' + SFR_interp = interp1d(self.redshifts, np.sum(self.SFR*self.dFOH, axis=1)) + return SFR_interp(z) + + def get_SFH_model(MODEL): + '''Return the appropriate SFH model based on the given parameters + Parameters + ---------- + MODEL : dict + Model parameters. + + Returns + ------- + SFHBase + The SFH model instance. + ''' if MODEL["SFR"] == "Madau+Fragos17": return MadauFragos17(MODEL) elif MODEL['SFR'] == "Madau+Dickinson14": @@ -250,9 +536,32 @@ def get_SFH_model(MODEL): return Neijssel19(MODEL) elif MODEL['SFR'] == "IllustrisTNG": return IllustrisTNG(MODEL) + elif MODEL['SFR'] == "Chruslinska+21": + return Chruslinska21(MODEL) else: raise ValueError("Invalid SFR!") +def SFR_per_Z_at_z(z, met_bins, MODEL): + """Calculate the SFR per metallicity bin at a given redshift(s) + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + met_bins : array + Metallicity bins edges in absolute metallicity. + MODEL : dict + Model parameters. + + Returns + ------- + SFH : 2D array + Star formation history per metallicity bin at the given redshift(s). + + """ + SFH = get_SFH_model(MODEL) + return SFH(z, met_bins) + def get_formation_times(N_binaries, star_formation="constant", **kwargs): """Get formation times of binaries in a population based on a SFH scenario. @@ -315,33 +624,9 @@ def get_formation_times(N_binaries, star_formation="constant", **kwargs): ) ) +#### OLD CODE BELOW #### -def get_illustrisTNG_data(verbose=False): - """Load IllustrisTNG SFR dataset.""" - if verbose: - print("Loading IllustrisTNG data...") - return np.load(os.path.join(PATH_TO_POSYDON_DATA, "SFR/IllustrisTNG.npz")) -def SFR_per_Z_at_z(z, met_bins, MODEL): - """Calculate the SFR per metallicity bin at a given redshift(s) - - Parameters - ---------- - z : float or array-like - Cosmological redshift. - met_bins : array - Metallicity bins edges in absolute metallicity. - MODEL : dict - Model parameters. - - Returns - ------- - SFH : 2D array - Star formation history per metallicity bin at the given redshift(s). - - """ - SFH = get_SFH_model(MODEL) - return SFH(z, met_bins) def star_formation_rate(SFR, z): """Star formation rate in M_sun yr^-1 Mpc^-3. @@ -384,6 +669,11 @@ def star_formation_rate(SFR, z): else: raise ValueError("Invalid SFR!") +def get_illustrisTNG_data(verbose=False): + """Load IllustrisTNG SFR dataset.""" + if verbose: + print("Loading IllustrisTNG data...") + return np.load(os.path.join(PATH_TO_POSYDON_DATA, "SFR/IllustrisTNG.npz")) def mean_metallicity(SFR, z): """Empiric mean metallicity function. @@ -412,7 +702,6 @@ def mean_metallicity(SFR, z): else: raise ValueError("Invalid SFR!") - def std_log_metallicity_dist(sigma): """Standard deviation of the log-metallicity distribution. @@ -481,7 +770,7 @@ def SFR_Z_fraction_at_given_redshift( ) if not select_one_met: fSFR[:, 0] = stats.norm.cdf(np.log10(metallicity_bins[1]), mu, sigma) / norm - fSFR[:,-1] = norm - stats.norm.cdf(np.log10(metallicity_bins[-1]), mu, sigma)/norm + fSFR[:,-1] = norm - stats.norm.cdf(np.log10(metallicity_bins[-2]), mu, sigma)/norm elif SFR == "Neijssel+19": # assume a truncated ln-normal distribution of metallicities @@ -501,7 +790,7 @@ def SFR_Z_fraction_at_given_redshift( ) if not select_one_met: fSFR[:, 0] = stats.norm.cdf(np.log(metallicity_bins[1]), mu, sigma) / norm - fSFR[:,-1] = norm - stats.norm.cdf(np.log(metallicity_bins[-1]), mu, sigma)/norm + fSFR[:,-1] = norm - stats.norm.cdf(np.log(metallicity_bins[-2]), mu, sigma)/norm elif SFR == "IllustrisTNG": # numerically itegrate the IlluystrisTNG SFR(z,Z) @@ -535,7 +824,7 @@ def SFR_Z_fraction_at_given_redshift( fSFR[i, 0] = 1 else: fSFR[i, 0] = Z_dist_cdf_interp(np.log10(metallicity_bins[1])) - fSFR[i, -1] = 1 - Z_dist_cdf_interp(np.log10(metallicity_bins[-1])) + fSFR[i, -1] = 1 - Z_dist_cdf_interp(np.log10(metallicity_bins[-2])) else: raise ValueError("Invalid SFR!") diff --git a/posydon/popsyn/synthetic_population.py b/posydon/popsyn/synthetic_population.py index 2dc7761d6f..d7a58bef6e 100644 --- a/posydon/popsyn/synthetic_population.py +++ b/posydon/popsyn/synthetic_population.py @@ -2035,10 +2035,6 @@ def calculate_cosmic_weights(self, SFH_identifier, MODEL_in=None): if MODEL_in is None: MODEL = DEFAULT_MODEL else: - for key in MODEL_in: - if key not in DEFAULT_MODEL: - raise ValueError(key + " is not a valid parameter name!") - # write the DEFAULT_MODEL with updates parameters to self.MODEL. MODEL = DEFAULT_MODEL MODEL.update(MODEL_in) From 99191fbd11731e9bad8df131ffc27c1f616e46ad Mon Sep 17 00:00:00 2001 From: Max Briel Date: Mon, 17 Mar 2025 16:18:38 +0100 Subject: [PATCH 05/61] fix fSFR. Now sums correctly to 1 at each redshift. --- posydon/popsyn/star_formation_history.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 388a8ff8d4..2914ff1a6c 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -88,6 +88,7 @@ def __call__(self, z, met_bins): Star formation history per metallicity bin at the given redshift(s). ''' return self.CSFRD(z)[:, np.newaxis] * self.fSFR(z, met_bins) + class MadauBase(SFHBase): """ @@ -152,21 +153,27 @@ def fSFR(self, z, metallicity_bins): mu = np.log10(self.mean_metallicity(z)) - sigma ** 2 * np.log(10) / 2.0 # Ensure mu is an array for consistency mu_array = np.atleast_1d(mu) + + # Use the first value of mu for normalisation - norm = stats.norm.cdf(np.log10(self.Z_max), mu_array[0], sigma) + norm = stats.norm.cdf(np.log10(self.Z_max), mu_array, sigma) + fSFR = np.empty((len(mu_array), len(metallicity_bins) - 1)) + fSFR[:, :] = np.array( [ ( - stats.norm.cdf(np.log10(metallicity_bins[1:]), m, sigma) / norm - - stats.norm.cdf(np.log10(metallicity_bins[:-1]), m, sigma) / norm + stats.norm.cdf(np.log10(metallicity_bins[1:]), m, sigma) + - stats.norm.cdf(np.log10(metallicity_bins[:-1]), m, sigma) ) for m in mu_array ] - ) + ) / norm[:, np.newaxis] + if not self.select_one_met: - fSFR[:, 0] = stats.norm.cdf(np.log10(metallicity_bins[1]), mu_array, sigma) / norm - fSFR[:, -1] = norm - stats.norm.cdf(np.log10(metallicity_bins[-2]), mu_array, sigma) / norm + fSFR[:, 0] = stats.norm.cdf(np.log10(metallicity_bins[1]), mu_array, sigma)/norm + fSFR[:, -1] = 1 - (stats.norm.cdf(np.log10(metallicity_bins[-2]), mu_array, sigma)/norm) + return fSFR class MadauDickinson14(MadauBase): From 4e353d15701410ab5cdbdd6ccb720df674d0c552 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Mon, 17 Mar 2025 17:23:42 +0100 Subject: [PATCH 06/61] add fSFR unit tests --- posydon/popsyn/star_formation_history.py | 11 ++-- .../popsyn/test_star_formation_history.py | 58 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 posydon/unit_tests/popsyn/test_star_formation_history.py diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 2914ff1a6c..9ede8be2df 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -89,7 +89,6 @@ def __call__(self, z, met_bins): ''' return self.CSFRD(z)[:, np.newaxis] * self.fSFR(z, met_bins) - class MadauBase(SFHBase): """ Base class for Madau-style star-formation history implementations. @@ -247,20 +246,20 @@ def fSFR(self, z, metallicity_bins): sigma = self.std_log_metallicity_dist() mu = np.log(self.mean_metallicity(z)) - sigma**2 / 2.0 # renormalisation constant - norm = stats.norm.cdf(np.log(self.Z_max), mu[0], sigma) + norm = stats.norm.cdf(np.log(self.Z_max), mu, sigma) fSFR = np.empty((len(z), len(metallicity_bins) - 1)) fSFR[:, :] = np.array( [ ( - stats.norm.cdf(np.log(metallicity_bins[1:]), m, sigma) / norm - - stats.norm.cdf(np.log(metallicity_bins[:-1]), m, sigma) / norm + stats.norm.cdf(np.log(metallicity_bins[1:]), m, sigma) + - stats.norm.cdf(np.log(metallicity_bins[:-1]), m, sigma) ) for m in mu ] - ) + ) / norm[:, np.newaxis] if not self.select_one_met: fSFR[:, 0] = stats.norm.cdf(np.log(metallicity_bins[1]), mu, sigma) / norm - fSFR[:,-1] = norm - stats.norm.cdf(np.log(metallicity_bins[-2]), mu, sigma)/norm + fSFR[:,-1] = 1 - stats.norm.cdf(np.log(metallicity_bins[-2]), mu, sigma)/norm return fSFR class IllustrisTNG(SFHBase): diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py new file mode 100644 index 0000000000..2139c23e91 --- /dev/null +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -0,0 +1,58 @@ +import numpy as np +import pytest +from posydon.popsyn.star_formation_history import ( + MadauDickinson14, + MadauFragos17, + Neijssel19, + IllustrisTNG, +) + +class TestfSFR: + @pytest.mark.parametrize("model_class, model_name", [ + (MadauFragos17, "Madau+Fragos17"), + (MadauDickinson14, "Madau+Dickinson14"), + (Neijssel19, "Neijssel+19"), + ]) + def test_fSFR_sum_is_one_empirical(self, model_class, model_name): + # Base MODEL parameters + base_args = { + "SFR": model_name, + "sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + } + sfh_instance = model_class(base_args) + # Arbitrary redshift array and metallicity bins + z = np.array([0.001, 0.5, 1.0, 2.0]) + met_bins = np.linspace(0.0001, base_args["Z_max"], 50) + + fSFR = sfh_instance.fSFR(z, met_bins) + # The sum over metallicity bins (axis=1) should be approximately 1 for each redshift + np.testing.assert_allclose(np.sum(fSFR, axis=1), np.ones(len(z)), atol=1e-6) + + def dummy_get_illustrisTNG_data(self, verbose=False): + import numpy as np + return { + "SFR": np.array([1.0, 1.0, 1.0]), + "redshifts": np.array([0.0, 1.0, 2.0]), + "mets": np.linspace(0.001, 0.03, 10), + "M": np.ones((3, 10)), + } + + def test_fSFR_sum_is_one_illustris(self, monkeypatch): + base_args = { + "SFR": "IllustrisTNG", + "sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + } + monkeypatch.setattr(IllustrisTNG, "_get_illustrisTNG_data", self.dummy_get_illustrisTNG_data) + sfh_instance = IllustrisTNG(base_args) + z = np.array([0.5, 1.0, 2.0]) + met_bins = np.linspace(0.0001, base_args["Z_max"], 50) + fSFR = sfh_instance.fSFR(z, met_bins) + np.testing.assert_allclose(np.sum(fSFR, axis=1), np.ones(len(z)), atol=1e-6) + + + + From 9be686beaca4caf3d4cdd573d3d92deb377e8574 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Mon, 17 Mar 2025 17:35:36 +0100 Subject: [PATCH 07/61] add additional basic tests --- .../popsyn/test_star_formation_history.py | 133 +++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 2139c23e91..7acd44e3b7 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -1,12 +1,76 @@ import numpy as np import pytest +from posydon.popsyn.star_formation_history import SFHBase from posydon.popsyn.star_formation_history import ( MadauDickinson14, MadauFragos17, Neijssel19, IllustrisTNG, + Chruslinska21, + get_SFH_model ) + +# Replace duplicate DummySFH definitions with a single merged class +class DummySFH(SFHBase): + def CSFRD(self, z): + if self.MODEL.get("dummy_mode", "std") == "call": + return np.full_like(z, 2.0, dtype=float) + else: + return np.ones_like(z) + + def mean_metallicity(self, z): + return np.ones_like(z) + + def fSFR(self, z, metallicity_bins): + n_z = len(z) + n_bins = len(metallicity_bins) - 1 + if self.MODEL.get("dummy_mode", "std") == "call": + # Return normalized ones for __call__ test + raw = np.ones((n_z, n_bins)) + return raw / np.sum(raw, axis=1, keepdims=True) + else: + return np.ones((n_z, n_bins)) + +# Additional tests for the std_log_metallicity_dist function +class TestStdLogMetallicityDist: + def test_sigma_bavera(self): + # Test with sigma as "Bavera+20" which should return 0.5 + model = {"sigma": "Bavera+20"} + dummy = DummySFH(model) + assert dummy.std_log_metallicity_dist() == 0.5 + + def test_sigma_neijssel(self): + # Test with sigma as "Neijssel+19" which should return 0.39 + model = {"sigma": "Neijssel+19"} + dummy = DummySFH(model) + assert dummy.std_log_metallicity_dist() == 0.39 + + def test_sigma_float(self): + # Test with sigma as a float value + sigma_value = 0.45 + model = {"sigma": sigma_value} + dummy = DummySFH(model) + assert dummy.std_log_metallicity_dist() == sigma_value + + def test_unknown_sigma_string(self): + # Test with an invalid sigma string should raise a ValueError + model = {"sigma": "invalid_sigma"} + dummy = DummySFH(model) + with pytest.raises(ValueError) as excinfo: + dummy.std_log_metallicity_dist() + assert "Unknown sigma choice!" in str(excinfo.value) + + def test_invalid_sigma(self): + # Test with an invalid sigma value should raise a ValueError + model = {"sigma": int(1)} + dummy = DummySFH(model) + with pytest.raises(ValueError) as excinfo: + dummy.std_log_metallicity_dist() + assert "Invalid sigma value" in str(excinfo.value) + + + class TestfSFR: @pytest.mark.parametrize("model_class, model_name", [ (MadauFragos17, "Madau+Fragos17"), @@ -31,7 +95,6 @@ def test_fSFR_sum_is_one_empirical(self, model_class, model_name): np.testing.assert_allclose(np.sum(fSFR, axis=1), np.ones(len(z)), atol=1e-6) def dummy_get_illustrisTNG_data(self, verbose=False): - import numpy as np return { "SFR": np.array([1.0, 1.0, 1.0]), "redshifts": np.array([0.0, 1.0, 2.0]), @@ -53,6 +116,74 @@ def test_fSFR_sum_is_one_illustris(self, monkeypatch): fSFR = sfh_instance.fSFR(z, met_bins) np.testing.assert_allclose(np.sum(fSFR, axis=1), np.ones(len(z)), atol=1e-6) +class TestCallMethod: + def test_call_method_returns_product(self): + # Use DummySFH in 'call' mode to test __call__ + model = {"sigma": 0.5, "Z_max": 0.03, "select_one_met": False, "dummy_mode": "call"} + dummy = DummySFH(model) + z = np.array([0.5, 1.0, 2.0]) + met_bins = np.linspace(0.001, model["Z_max"], 10) + expected = 2 * dummy.fSFR(z, met_bins) + result = dummy(z, met_bins) + np.testing.assert_allclose(result, expected, atol=1e-6) + + +class TestGetSFHModel: + @pytest.mark.parametrize("model_name, model_class", [ + ("Madau+Fragos17", MadauFragos17), + ("Madau+Dickinson14", MadauDickinson14), + ("Neijssel+19", Neijssel19), + ]) + def test_get_model_empirical(self, model_name, model_class): + base_args = { + "SFR": model_name, + "sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + } + model = get_SFH_model(base_args) + assert isinstance(model, model_class) + def test_get_model_illustris(self, monkeypatch): + base_args = { + "SFR": "IllustrisTNG", + "sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + } + # Override _get_illustrisTNG_data to avoid file I/O during tests + def dummy_get_illustrisTNG_data(self, verbose=False): + return { + "SFR": np.array([1.0]), + "redshifts": np.array([0.0]), + "mets": np.linspace(0.001, 0.03, 10), + "M": np.ones((1, 10)), + } + monkeypatch.setattr(IllustrisTNG, "_get_illustrisTNG_data", dummy_get_illustrisTNG_data) + model = get_SFH_model(base_args) + assert isinstance(model, IllustrisTNG) + def test_get_model_chruslinska(self, monkeypatch): + base_args = { + "SFR": "Chruslinska+21", + "sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + "sub_model": "dummy", + "Z_solar_scaling": "Asplund09", + } + # Override _load_chruslinska_data to avoid file I/O during tests + monkeypatch.setattr(Chruslinska21, "_load_chruslinska_data", lambda self, verbose=False: None) + model = get_SFH_model(base_args) + assert isinstance(model, Chruslinska21) + def test_invalid_SFR(self): + base_args = { + "SFR": "InvalidSFR", + "sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + } + with pytest.raises(ValueError) as excinfo: + get_SFH_model(base_args) + assert "Invalid SFR!" in str(excinfo.value) From 5b3a35c2665504bf0178d110bc9d2896b508a86e Mon Sep 17 00:00:00 2001 From: Max Briel Date: Mon, 17 Mar 2025 18:00:03 +0100 Subject: [PATCH 08/61] fix double naming of SFR --- posydon/popsyn/star_formation_history.py | 71 +++++++++++++++++-- .../popsyn/test_star_formation_history.py | 67 +++++++++++++++++ 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 9ede8be2df..678a688a87 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -12,6 +12,7 @@ import os import numpy as np import scipy as sp +import pandas as pd from scipy import stats from posydon.config import PATH_TO_POSYDON_DATA from posydon.utils.constants import age_of_universe @@ -274,7 +275,7 @@ def __init__(self, MODEL): super().__init__(MODEL) # load the TNG data illustris_data = self._get_illustrisTNG_data() - self.SFR = illustris_data["SFR"] + self.SFR_data = illustris_data["SFR"] self.redshifts = illustris_data["redshifts"] self.Z = illustris_data["mets"] self.M = illustris_data["M"] # Msun @@ -286,7 +287,7 @@ def _get_illustrisTNG_data(self, verbose=False): return np.load(os.path.join(PATH_TO_POSYDON_DATA, "SFR/IllustrisTNG.npz")) def CSFRD(self, z): - SFR_interp = interp1d(self.redshifts, self.SFR) + SFR_interp = interp1d(self.redshifts, self.SFR_data) return SFR_interp(z) def mean_metallicity(self, z): @@ -386,7 +387,7 @@ def _load_chruslinska_data(self, verbose=False): self._data_folder = os.path.join(PATH_TO_POSYDON_DATA, "SFR/Chruslinska+21") _, self.redshifts, delta_T = self._load_redshift_data(verbose) M = self._load_raw_data() - self.SFR = np.array( [M[ii]/(1e6*delta_T[ii]) for ii in range(len(delta_T))])/self.dFOH + self.SFR_data = np.array( [M[ii]/(1e6*delta_T[ii]) for ii in range(len(delta_T))])/self.dFOH def _FOH_to_Z(self, FOH): # scalings from Chruslinksa+21 @@ -419,10 +420,10 @@ def mean_metallicity(self, z): ''' mean_over_redshift = np.zeros_like(self.redshifts) for i in range(len(mean_over_redshift)): - if np.sum(self.SFR[i]) == 0: + if np.sum(self.SFR_data[i]) == 0: mean_over_redshift[i] = 0 else: - mean_over_redshift[i] = np.average(self.Z, weights=self.SFR[i,:]*self.dFOH) + mean_over_redshift[i] = np.average(self.Z, weights=self.SFR_data[i,:]*self.dFOH) Z_interp = interp1d(self.redshifts, mean_over_redshift) return Z_interp(z) @@ -445,7 +446,7 @@ def fSFR(self, z, metallicity_bins): # only use data within the metallicity bounds (no lower bound) Z_max_mask = self.Z <= self.Z_max redshift_indices = np.array([np.where(self.redshifts <= i)[0][0] for i in z]) - Z_dist = self.SFR[:, Z_max_mask][redshift_indices] + Z_dist = self.SFR_data[:, Z_max_mask][redshift_indices] fSFR = np.zeros((len(z), len(metallicity_bins) - 1)) for i in range(len(z)): @@ -517,10 +518,62 @@ def CSFRD(self, z): float or array-like The cosmic star formation rate density at the given redshift(s). ''' - SFR_interp = interp1d(self.redshifts, np.sum(self.SFR*self.dFOH, axis=1)) + SFR_interp = interp1d(self.redshifts, np.sum(self.SFR_data*self.dFOH, axis=1)) return SFR_interp(z) +class Fujimoto24(MadauBase): + '''The Fujimoto et al. (2024) star formation history model. + mean metallicity evolution of Madau & Fragos (2017). + + Fujimoto et al. (2024), ApJ SS, 275, 2, 36, 59 + https://ui.adsabs.harvard.edu/abs/2024ApJS..275...36F/abstract + ''' + def __init__(self, MODEL): + super().__init__(MODEL) + # Parameters for Fujimoto+24 CSFRD + self.CSFRD_params = { + "a": 0.010, + "b": 2.8, + "c": 3.3, + "d": 6.6, + } + +class Zalava21(MadauBase): + + def __init__(self, MODEL): + '''Initialise the Zalava+21 model + + Requires the following parameters: + - sub_model : str + Either min or max + ''' + if 'sub_model' not in MODEL: + raise ValueError("Sub-model not given!") + super().__init__(MODEL) + self._load_zalava_data() + + def _load_zalava_data(self): + '''Load the data from the Zalava+21 models + Transforms the data to the format used in the classes. + + ''' + data_file = os.path.join(PATH_TO_POSYDON_DATA, "SFR/Zalava+21.txt") + tmp_data = pd.read_csv(data_file, names=['redshift', 'SFRD_min', 'SFRD_max'], skiprows=1, sep='\s+') + self.redshifts = tmp_data['redshift'].values + if self.sub_model == 'min': + self.SFR_data = tmp_data['SFRD_min'].values + elif self.sub_model == 'max': + self.SFR_data = tmp_data['SFRD_max'].values + else: + raise ValueError("Invalid sub-model!") + + # overwrite the CSFRD method of MadauBase + def CSFRD(self, z): + SFR_interp = interp1d(self.redshifts, self.SFR_data) + return SFR_interp(z) + + def get_SFH_model(MODEL): '''Return the appropriate SFH model based on the given parameters @@ -538,12 +591,16 @@ def get_SFH_model(MODEL): return MadauFragos17(MODEL) elif MODEL['SFR'] == "Madau+Dickinson14": return MadauDickinson14(MODEL) + elif MODEL['SFR'] == 'Fujimoto+24': + return Fujimoto24(MODEL) elif MODEL['SFR'] == "Neijssel+19": return Neijssel19(MODEL) elif MODEL['SFR'] == "IllustrisTNG": return IllustrisTNG(MODEL) elif MODEL['SFR'] == "Chruslinska+21": return Chruslinska21(MODEL) + elif MODEL['SFR'] == "Zalava+21": + return Zalava21(MODEL) else: raise ValueError("Invalid SFR!") diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 7acd44e3b7..fb6641d7e5 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -7,6 +7,7 @@ Neijssel19, IllustrisTNG, Chruslinska21, + Zalava21, get_SFH_model ) @@ -187,3 +188,69 @@ def test_invalid_SFR(self): with pytest.raises(ValueError) as excinfo: get_SFH_model(base_args) assert "Invalid SFR!" in str(excinfo.value) + +# New tests for the Zalava21 (Zavala) class +class DummyLoadZalava: + def __call__(self, verbose=False): + # Dummy load: set redshifts, SFR, Z and dFOH attributes. + self.redshifts = np.array([0.0, 1.0, 2.0]) + # Create a dummy SFR array with 3 rows and 10 columns + self.SFR_data = np.ones((3, 10)) + # Dummy metal abundance array (10 values) and dFOH value + self.Z = np.linspace(0.001, 0.03, 10) + self.dFOH = self.Z[1]-self.Z[0] + +class TestZalava21: + def dummy_load_zalava_data(self, verbose=False): + DummyLoadZalava().__call__(self) + + def test_get_model_zalava_min(self, monkeypatch): + base_args = { + "SFR": "Zalava+21", + "sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + "sub_model": "min", + } + monkeypatch.setattr(Zalava21, "_load_zalava_data", self.dummy_load_zalava_data) + model = get_SFH_model(base_args) + assert np.array_equal(model.SFR_data, np.ones((3, 10))) + + def test_get_model_zalava_max(self, monkeypatch): + base_args = { + "SFR": "Zalava+21", + "sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + "sub_model": "max", + } + monkeypatch.setattr(Zalava21, "_load_zalava_data", self.dummy_load_zalava_data) + model = get_SFH_model(base_args) + assert isinstance(model, Zalava21) + + def test_missing_sub_model_raises(self): + base_args = { + "SFR": "Zalava+21", + "sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + } + with pytest.raises(ValueError) as excinfo: + Zalava21(base_args) + assert "Sub-model not given!" in str(excinfo.value) + + def test_fSFR_sum_is_one_zalava(self, monkeypatch): + base_args = { + "SFR": "Zalava+21", + "sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + "sub_model": "min", + } + monkeypatch.setattr(Zalava21, "_load_zalava_data", self.dummy_load_zalava_data) + model = Zalava21(base_args) + # Use arbitrary redshift and metallicity bins + z = np.array([0.5, 1.0, 2.0]) + met_bins = np.linspace(0.001, base_args["Z_max"], 50) + fSFR = model.fSFR(z, met_bins) + np.testing.assert_allclose(np.sum(fSFR, axis=1), np.ones(len(z)), atol=1e-6) From 22036586c4275bdc9f78ffee8b7812316ca2f663 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Mon, 17 Mar 2025 18:03:22 +0100 Subject: [PATCH 09/61] add tests for new SFRs --- .../popsyn/test_star_formation_history.py | 74 +++++-------------- 1 file changed, 18 insertions(+), 56 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index fb6641d7e5..be4b3f4b01 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -177,80 +177,42 @@ def test_get_model_chruslinska(self, monkeypatch): monkeypatch.setattr(Chruslinska21, "_load_chruslinska_data", lambda self, verbose=False: None) model = get_SFH_model(base_args) assert isinstance(model, Chruslinska21) - - def test_invalid_SFR(self): - base_args = { - "SFR": "InvalidSFR", - "sigma": 0.5, - "Z_max": 0.03, - "select_one_met": False, - } - with pytest.raises(ValueError) as excinfo: - get_SFH_model(base_args) - assert "Invalid SFR!" in str(excinfo.value) - -# New tests for the Zalava21 (Zavala) class -class DummyLoadZalava: - def __call__(self, verbose=False): - # Dummy load: set redshifts, SFR, Z and dFOH attributes. - self.redshifts = np.array([0.0, 1.0, 2.0]) - # Create a dummy SFR array with 3 rows and 10 columns - self.SFR_data = np.ones((3, 10)) - # Dummy metal abundance array (10 values) and dFOH value - self.Z = np.linspace(0.001, 0.03, 10) - self.dFOH = self.Z[1]-self.Z[0] - -class TestZalava21: - def dummy_load_zalava_data(self, verbose=False): - DummyLoadZalava().__call__(self) + assert model.SFR == "Chruslinska+21" - def test_get_model_zalava_min(self, monkeypatch): + def test_get_model_zalava(self, monkeypatch): base_args = { "SFR": "Zalava+21", "sigma": 0.5, "Z_max": 0.03, "select_one_met": False, - "sub_model": "min", + "sub_model": "dummy", + "Z_solar_scaling": "Asplund09", } - monkeypatch.setattr(Zalava21, "_load_zalava_data", self.dummy_load_zalava_data) + # Override _load_zalava_data to avoid file I/O during tests + monkeypatch.setattr(Zalava21, "_load_zalava_data", lambda self, verbose=False: None) model = get_SFH_model(base_args) - assert np.array_equal(model.SFR_data, np.ones((3, 10))) + assert isinstance(model, Zalava21) + assert model.SFR == "Zalava+21" - def test_get_model_zalava_max(self, monkeypatch): + def test_get_fojimoto_model(self): base_args = { - "SFR": "Zalava+21", + "SFR": "Fujimoto+24", "sigma": 0.5, "Z_max": 0.03, "select_one_met": False, - "sub_model": "max", } - monkeypatch.setattr(Zalava21, "_load_zalava_data", self.dummy_load_zalava_data) model = get_SFH_model(base_args) - assert isinstance(model, Zalava21) - - def test_missing_sub_model_raises(self): + assert isinstance(model, SFHBase) + assert model.MODEL["SFR"] == "Fujimoto+24" + assert model.SFR == "Fujimoto+24" + + def test_invalid_SFR(self): base_args = { - "SFR": "Zalava+21", + "SFR": "InvalidSFR", "sigma": 0.5, "Z_max": 0.03, "select_one_met": False, } with pytest.raises(ValueError) as excinfo: - Zalava21(base_args) - assert "Sub-model not given!" in str(excinfo.value) - - def test_fSFR_sum_is_one_zalava(self, monkeypatch): - base_args = { - "SFR": "Zalava+21", - "sigma": 0.5, - "Z_max": 0.03, - "select_one_met": False, - "sub_model": "min", - } - monkeypatch.setattr(Zalava21, "_load_zalava_data", self.dummy_load_zalava_data) - model = Zalava21(base_args) - # Use arbitrary redshift and metallicity bins - z = np.array([0.5, 1.0, 2.0]) - met_bins = np.linspace(0.001, base_args["Z_max"], 50) - fSFR = model.fSFR(z, met_bins) - np.testing.assert_allclose(np.sum(fSFR, axis=1), np.ones(len(z)), atol=1e-6) + get_SFH_model(base_args) + assert "Invalid SFR!" in str(excinfo.value) From 5755de0a04e294431dfb58ca9439b901305a9bc0 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Mon, 17 Mar 2025 18:06:42 +0100 Subject: [PATCH 10/61] remove old SFH code; not used anywhere --- posydon/popsyn/star_formation_history.py | 261 +---------------------- 1 file changed, 1 insertion(+), 260 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 678a688a87..3468e203f9 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -685,263 +685,4 @@ def get_formation_times(N_binaries, star_formation="constant", **kwargs): "Unknown star formation scenario '{}' given. Valid options: {}".format( star_formation, ",".join(SFH_SCENARIOS) ) - ) - -#### OLD CODE BELOW #### - - - -def star_formation_rate(SFR, z): - """Star formation rate in M_sun yr^-1 Mpc^-3. - - Parameters - ---------- - SFR : string - Star formation rate assumption: - - Madau+Fragos17 see arXiv:1606.07887 - - Madau+Dickinson14 see arXiv:1403.0007 - - Neijssel+19 see arXiv:1906.08136 - - IllustrisTNG see see arXiv:1707.03395 - z : double - Cosmological redshift. - - Returns - ------- - double - The total mass of stars in M_sun formed per comoving volume Mpc^-3 - per year. - """ - if SFR == "Madau+Fragos17": - return ( - 0.01 * (1.0 + z) ** 2.6 / (1.0 + ((1.0 + z) / 3.2) ** 6.2) - ) # M_sun yr^-1 Mpc^-3 - elif SFR == "Madau+Dickinson14": - return ( - 0.015 * (1.0 + z) ** 2.7 / (1.0 + ((1.0 + z) / 2.9) ** 5.6) - ) # M_sun yr^-1 Mpc^-3 - elif SFR == "Neijssel+19": - return ( - 0.01 * (1.0 + z) ** 2.77 / (1.0 + ((1.0 + z) / 2.9) ** 4.7) - ) # M_sun yr^-1 Mpc^-3 - elif SFR == "IllustrisTNG": - illustris_data = get_illustrisTNG_data() - SFR = illustris_data["SFR"] # M_sun yr^-1 Mpc^-3 - redshifts = illustris_data["redshifts"] - SFR_interp = interp1d(redshifts, SFR) - return SFR_interp(z) - else: - raise ValueError("Invalid SFR!") - -def get_illustrisTNG_data(verbose=False): - """Load IllustrisTNG SFR dataset.""" - if verbose: - print("Loading IllustrisTNG data...") - return np.load(os.path.join(PATH_TO_POSYDON_DATA, "SFR/IllustrisTNG.npz")) - -def mean_metallicity(SFR, z): - """Empiric mean metallicity function. - - Parameters - ---------- - SFR : string - Star formation rate assumption: - - Madau+Fragos17 see arXiv:1606.07887 - - Madau+Dickinson14 see arXiv:1403.0007 - - Neijssel+19 see arXiv:1906.08136 - z : double - Cosmological redshift. - - Returns - ------- - double - Mean metallicty of the universe at the given redhist. - - """ - - if SFR == "Madau+Fragos17" or SFR == "Madau+Dickinson14": - return 10 ** (0.153 - 0.074 * z**1.34) * Zsun - elif SFR == "Neijssel+19": - return 0.035 * 10 ** (-0.23 * z) - else: - raise ValueError("Invalid SFR!") - -def std_log_metallicity_dist(sigma): - """Standard deviation of the log-metallicity distribution. - - Returns - ------- - double - Standard deviation of the adopted distribution. - - """ - if isinstance(sigma, str): - if sigma == "Bavera+20": - return 0.5 - elif sigma == "Neijssel+19": - return 0.39 - else: - raise ValueError("Uknown sigma choice!") - elif isinstance(sigma, float): - return sigma - else: - raise ValueError(f"Invalid sigma value {sigma}!") - - -def SFR_Z_fraction_at_given_redshift( - z, SFR, sigma, metallicity_bins, Z_max, select_one_met -): - """'Fraction of the SFR at a given redshift z in a given metallicity bin as in Eq. (B.8) of Bavera et al. (2020). - - Parameters - ---------- - z : np.array - Cosmological redshift. - SFR : string - Star formation rate assumption: - - Madau+Fragos17 see arXiv:1606.07887 - - Madau+Dickinson14 see arXiv:1403.0007 - - IllustrisTNG see see arXiv:1707.03395 - - Neijssel+19 see arXiv:1906.08136 - sigma : double / string - Standard deviation of the log-metallicity distribution. - If string, it can be 'Bavera+20' or 'Neijssel+19'. - metallicity_bins : array - Metallicity bins edges in absolute metallicity. - Z_max : double - Maximum metallicity in absolute metallicity. - select_one_met : bool - If True, the function returns the fraction of the SFR in the given metallicity bin. - If False, the function returns the fraction of the SFR in the given metallicity bin and the fraction of the SFR in the metallicity bin - - Returns - ------- - array - Fraction of the SFR in the given metallicity bin at the given redshift. - In absolute metallicity. - """ - - if SFR == "Madau+Fragos17" or SFR == "Madau+Dickinson14": - sigma = std_log_metallicity_dist(sigma) - mu = np.log10(mean_metallicity(SFR, z)) - sigma**2 * np.log(10) / 2.0 - # renormalisation constant. We can use mu[0], since we integrate over the whole metallicity range - norm = stats.norm.cdf(np.log10(Z_max), mu[0], sigma) - fSFR = np.empty((len(z), len(metallicity_bins) - 1)) - fSFR[:, :] = np.array( - [(stats.norm.cdf(np.log10(metallicity_bins[1:]), m, sigma) / norm - - stats.norm.cdf(np.log10(metallicity_bins[:-1]), m, sigma) / norm - ) for m in mu ] - ) - if not select_one_met: - fSFR[:, 0] = stats.norm.cdf(np.log10(metallicity_bins[1]), mu, sigma) / norm - fSFR[:,-1] = norm - stats.norm.cdf(np.log10(metallicity_bins[-2]), mu, sigma)/norm - - elif SFR == "Neijssel+19": - # assume a truncated ln-normal distribution of metallicities - sigma = std_log_metallicity_dist(sigma) - mu = np.log(mean_metallicity(SFR, z)) - sigma**2 / 2.0 - # renormalisation constant - norm = stats.norm.cdf(np.log(Z_max), mu[0], sigma) - fSFR = np.empty((len(z), len(metallicity_bins) - 1)) - fSFR[:, :] = np.array( - [ - ( - stats.norm.cdf(np.log(metallicity_bins[1:]), m, sigma) / norm - - stats.norm.cdf(np.log(metallicity_bins[:-1]), m, sigma) / norm - ) - for m in mu - ] - ) - if not select_one_met: - fSFR[:, 0] = stats.norm.cdf(np.log(metallicity_bins[1]), mu, sigma) / norm - fSFR[:,-1] = norm - stats.norm.cdf(np.log(metallicity_bins[-2]), mu, sigma)/norm - - elif SFR == "IllustrisTNG": - # numerically itegrate the IlluystrisTNG SFR(z,Z) - illustris_data = get_illustrisTNG_data() - redshifts = illustris_data["redshifts"] - Z = illustris_data["mets"] - M = illustris_data["M"] # Msun - # only use data within the metallicity bounds (no lower bound) - Z_max_mask = Z <= Z_max - redshift_indices = np.array([np.where(redshifts <= i)[0][0] for i in z]) - Z_dist = M[:, Z_max_mask][redshift_indices] - fSFR = np.zeros((len(z), len(metallicity_bins) - 1)) - - for i in range(len(z)): - if Z_dist[i].sum() == 0.0: - continue - else: - # We add a final point to the CDF and metallicities to ensure normalisation to 1 - Z_dist_cdf = np.cumsum(Z_dist[i]) / Z_dist[i].sum() - Z_dist_cdf = np.append(Z_dist_cdf, 1) - Z_x_values = np.append(np.log10(Z[Z_max_mask]), 0) - Z_dist_cdf_interp = interp1d(Z_x_values, Z_dist_cdf) - - fSFR[i, :] = (Z_dist_cdf_interp(np.log10(metallicity_bins[1:])) - - Z_dist_cdf_interp(np.log10(metallicity_bins[:-1]))) - - if not select_one_met: - # add the fraction of the SFR in the first and last bin - # or the only bin without selecting one metallicity - if len(metallicity_bins) == 2: - fSFR[i, 0] = 1 - else: - fSFR[i, 0] = Z_dist_cdf_interp(np.log10(metallicity_bins[1])) - fSFR[i, -1] = 1 - Z_dist_cdf_interp(np.log10(metallicity_bins[-2])) - else: - raise ValueError("Invalid SFR!") - - return fSFR - - -def integrated_SFRH_over_redshift(SFR, sigma, Z, Z_max): - """Integrated SFR history over z as in Eq. (B.10) of Bavera et al. (2020). - - Parameters - ---------- - SFR : string - Star formation rate assumption: - - Madau+Fragos17 see arXiv:1606.07887 - - Madau+Dickinson14 see arXiv:1403.0007 - - Neijssel+19 see arXiv:1906.08136 - Z : double - Metallicity. - - Returns - ------- - double - The total mass of stars formed per comoving volume at a given - metallicity Z. - - """ - - def E(z, Omega_m=cosmology.Om0): - Omega_L = 1.0 - Omega_m - return (Omega_m * (1.0 + z) ** 3 + Omega_L) ** (1.0 / 2.0) - - def f(z, Z): - if SFR == "Madau+Fragos17" or SFR == "Madau+Dickinson14": - sigma = std_log_metallicity_dist(sigma) - mu = np.log10(mean_metallicity(SFR, z)) - sigma**2 * np.log(10) / 2.0 - H_0 = cosmology.H0.to("1/yr").value # yr - # put a cutoff on metallicity at Z_max - norm = stats.norm.cdf(np.log10(Z_max), mu, sigma) - return ( - star_formation_rate(SFR, z) - * stats.norm.pdf(np.log10(Z), mu, sigma) - / norm - * (H_0 * (1.0 + z) * E(z)) ** (-1) - ) - elif SFR == "Neijssel+19": - sigma = std_log_metallicity_dist(sigma) - mu = np.log10(mean_metallicity(SFR, z)) - sigma**2 / 2.0 - H_0 = cosmology.H0.to("1/yr").value # yr - return ( - star_formation_rate(SFR, z) - * stats.norm.pdf(np.log(Z), mu, sigma) - * (H_0 * (1.0 + z) * E(z)) ** (-1) - ) - else: - raise ValueError("Invalid SFR!") - - return sp.integrate.quad(f, 1e-10, np.inf, args=(Z,))[0] # M_sun yr^-1 Mpc^-3 + ) \ No newline at end of file From 2f22d6127e9a72f756c798f1104c6fcb275f1bb1 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 15:23:06 +0100 Subject: [PATCH 11/61] move std_log_metallicity_history --- posydon/popsyn/star_formation_history.py | 37 +++++++++++++++--------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 3468e203f9..1df10d5038 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -59,19 +59,6 @@ def fSFR(self, z, metallicity_bins): """Return the fractional SFR as a function of redshift and metallicity bins.""" pass - def std_log_metallicity_dist(self): - sigma = self.sigma - if isinstance(sigma, str): - if sigma == "Bavera+20": - return 0.5 - elif sigma == "Neijssel+19": - return 0.39 - else: - raise ValueError("Unknown sigma choice!") - elif isinstance(sigma, float): - return sigma - else: - raise ValueError(f"Invalid sigma value {sigma}!") def __call__(self, z, met_bins): '''Return the star formation history at a given redshift and metallicity bins @@ -115,6 +102,30 @@ def CSFRD(self, z): ''' p = self.CSFRD_params return p["a"] * (1.0 + z) ** p["b"] / (1.0 + ((1.0 + z) / p["c"]) ** p["d"]) + + def std_log_metallicity_dist(self): + '''return the standard deviation of the log-normal metallicity distribution + + Either recognised the strings "Bavera+20" (sigma=0.5) + or "Neijssel+19" (sigma=0.39) or a float value. + + Returns + ------- + float + The standard deviation of the log-normal metallicity distribution. + ''' + sigma = self.sigma + if isinstance(sigma, str): + if sigma == "Bavera+20": + return 0.5 + elif sigma == "Neijssel+19": + return 0.39 + else: + raise ValueError("Unknown sigma choice!") + elif isinstance(sigma, float): + return sigma + else: + raise ValueError(f"Invalid sigma value {sigma}!") def mean_metallicity(self, z): '''The mean metallicity at a given redshift From 74c6290ae99976691f0e4187e8e67bdfdf4b94a9 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 15:23:27 +0100 Subject: [PATCH 12/61] add dosctring for abstract class --- posydon/popsyn/star_formation_history.py | 73 ++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 1df10d5038..8dc437781b 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -39,6 +39,15 @@ class SFHBase(ABC): '''Abstract class for star formation history models''' def __init__(self, MODEL): + '''Initialise the SFH model + + Adds the model parameters as attributes. + + Parameters + ---------- + MODEL : dict + Model parameters. + ''' self.MODEL = MODEL # Automatically attach all model parameters as attributes for key, value in MODEL.items(): @@ -46,17 +55,70 @@ def __init__(self, MODEL): @abstractmethod def CSFRD(self, z): - """Compute the cosmic star formation rate density.""" + """Compute the cosmic star formation rate density. + + This is an abstract method that must be implemented by subclasses. + The implementation should calculate and return the cosmic star formation + rate density at the given redshift(s). + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + + Returns + ------- + float or array-like + The cosmic star formation rate density at the given redshift(s). + """ pass @abstractmethod def mean_metallicity(self, z): - """Return the mean metallicity at redshift z.""" + """Return the mean metallicity at redshift z. + + This is an abstract method that must be implemented by subclasses. + The implementation should calculate and return the mean metallicity + at the given redshift(s). + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + + Returns + ------- + float or array-like + The mean metallicity at the given redshift(s). + """ pass @abstractmethod def fSFR(self, z, metallicity_bins): - """Return the fractional SFR as a function of redshift and metallicity bins.""" + """Compute the star formation rate fraction (fSFR) at a given redshift + using the specified metallicity bins. + + This is an abstract method that must be implemented by subclasses. + The implementation should calculate and return the fractional SFR per + metallicity bins at the provided redshift (z). + + Parameters + --------- + z : float or array-like + The redshift(s) at which to compute the star formation rate. + metallicity_bins : list or array-like + The metallicity bin boundaries or labels used in the computation to + account for different metallicity contributions. + + Returns + ------- + float or array-like + The calculated star formation rate at the given redshift(s) and + metallicity bins in Msun/yr. + + Raises: + NotImplementedError: If the subclass does not implement this method. + """ pass @@ -104,7 +166,7 @@ def CSFRD(self, z): return p["a"] * (1.0 + z) ** p["b"] / (1.0 + ((1.0 + z) / p["c"]) ** p["d"]) def std_log_metallicity_dist(self): - '''return the standard deviation of the log-normal metallicity distribution + '''Return the standard deviation of the log-normal metallicity distribution Either recognised the strings "Bavera+20" (sigma=0.5) or "Neijssel+19" (sigma=0.39) or a float value. @@ -166,7 +228,7 @@ def fSFR(self, z, metallicity_bins): mu_array = np.atleast_1d(mu) - # Use the first value of mu for normalisation + # Use mu for normalisation norm = stats.norm.cdf(np.log10(self.Z_max), mu_array, sigma) fSFR = np.empty((len(mu_array), len(metallicity_bins) - 1)) @@ -251,7 +313,6 @@ def __init__(self, MODEL): def mean_metallicity(self, z): return 0.035 * 10 ** (-0.23 * z) - # overwrite std_log_metallicity_dist method of MadauBase # TODO: rewrite such that sigma is just changed for the Neijssel+19 case def fSFR(self, z, metallicity_bins): # assume a truncated ln-normal distribution of metallicities From 11707ef9f74a0fc2d661adc331e845c43aa2e336 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 15:32:50 +0100 Subject: [PATCH 13/61] add docstrings --- posydon/popsyn/star_formation_history.py | 91 ++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 8dc437781b..6143d8a72d 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -227,7 +227,6 @@ def fSFR(self, z, metallicity_bins): # Ensure mu is an array for consistency mu_array = np.atleast_1d(mu) - # Use mu for normalisation norm = stats.norm.cdf(np.log10(self.Z_max), mu_array, sigma) @@ -258,6 +257,24 @@ class MadauDickinson14(MadauBase): ''' def __init__(self, MODEL): + '''Initialise the Madau & Dickinson (2014) SFH model with the + metallicity evolution of Madau & Fragos (2017). + + Parameters + ---------- + MODEL : dict + Model parameters. Madau+14 requires the following parameters: + - sigma : float or str + The standard deviation of the log-normal metallicity distribution. + Options are: + - Bavera+20 + - Neijssel+19 + - float + - Z_max : float + The maximum metallicity in absolute units. + - select_one_met : bool + If True, the SFR is calculated for a single metallicity bin. + ''' super().__init__(MODEL) # Parameters for Madau+Dickinson14 CSFRD self.CSFRD_params = { @@ -269,13 +286,31 @@ def __init__(self, MODEL): class MadauFragos17(MadauBase): '''The Madau & Fragos (2017) star formation history model with the - mean metallicity evolution of Madau & Fragos (2017). + metallicity evolution of Madau & Fragos (2017). Madau & Fragos (2017), ApJ, 840, 39 http://adsabs.harvard.edu/abs/2017ApJ...840...39M ''' def __init__(self, MODEL): + '''Initialise the Madau+17 model + + Parameters + ---------- + MODEL : dict + Model parameters. Madau+17 requires the following parameters: + - sigma : float or str + The standard deviation of the log-normal metallicity distribution. + Options are: + - Bavera+20 + - Neijssel+19 + - float + - Z_max : float + The maximum metallicity in absolute units. + - select_one_met : bool + If True, the SFR is calculated for a single metallicity bin. + + ''' super().__init__(MODEL) # Parameters for Madau+Fragos17 CSFRD self.CSFRD_params = { @@ -296,10 +331,24 @@ class Neijssel19(MadauBase): Neijssel et al. (2019), MNRAS, 490, 3740 http://adsabs.harvard.edu/abs/2019MNRAS.490.3740N ''' - - - def __init__(self, MODEL): + '''Initialise the Neijssel+19 model + + Parameters + ---------- + MODEL : dict + Model parameters. Neijssel+19 requires the following parameters: + - sigma : float or str + The standard deviation of the log-normal metallicity distribution. + Options are: + - Bavera+20 + - Neijssel+19 + - float + - Z_max : float + The maximum metallicity in absolute units. + - select_one_met : bool + If True, the SFR is calculated for a single metallicity bin. + ''' super().__init__(MODEL) # Parameters for Neijssel+19 CSFRD self.CSFRD_params = { @@ -311,10 +360,42 @@ def __init__(self, MODEL): # overwrite mean_metallicity method of MadauBase def mean_metallicity(self, z): + '''Calculate the mean metallicity at a given redshift + + Overwrites the mean_metallicity method of MadauBase class. + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + + Returns + ------- + float or array-like + The mean metallicity at the given redshift(s). + ''' return 0.035 * 10 ** (-0.23 * z) # TODO: rewrite such that sigma is just changed for the Neijssel+19 case def fSFR(self, z, metallicity_bins): + '''Fraction of the SFR at a given redshift z in a given metallicity bin + as described in Neijssel et al. (2019). + + Overwrites the fSFR method of MadauBase class. + + Parameters + ---------- + z : np.array + Cosmological redshift. + metallicity_bins : array + Metallicity bins edges in absolute metallicity. + + Returns + ------- + array + Fraction of the SFR in the given metallicity bins at the + given redshift. + ''' # assume a truncated ln-normal distribution of metallicities sigma = self.std_log_metallicity_dist() mu = np.log(self.mean_metallicity(z)) - sigma**2 / 2.0 From 37fbe17dcd6c764c55d62794ec1f47b50cea0e25 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 15:37:03 +0100 Subject: [PATCH 14/61] rename variables --- posydon/popsyn/star_formation_history.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 6143d8a72d..1d542e856e 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -428,7 +428,7 @@ def __init__(self, MODEL): super().__init__(MODEL) # load the TNG data illustris_data = self._get_illustrisTNG_data() - self.SFR_data = illustris_data["SFR"] + self.CSFRD_data = illustris_data["SFR"] self.redshifts = illustris_data["redshifts"] self.Z = illustris_data["mets"] self.M = illustris_data["M"] # Msun @@ -440,14 +440,14 @@ def _get_illustrisTNG_data(self, verbose=False): return np.load(os.path.join(PATH_TO_POSYDON_DATA, "SFR/IllustrisTNG.npz")) def CSFRD(self, z): - SFR_interp = interp1d(self.redshifts, self.SFR_data) + SFR_interp = interp1d(self.redshifts, self.CSFRD_data) return SFR_interp(z) def mean_metallicity(self, z): out = np.zeros_like(self.redshifts) for i in range(len(out)): if np.sum(self.M[i, :]) == 0: - out[i] = 0 + out[i] = np.nan else: out[i] = np.average(self.Z, weights=self.M[i, :]) Z_interp = interp1d(self.redshifts, out) From f55e81335308ebd9b3194d83698eb6cfb6f2dad8 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 15:43:07 +0100 Subject: [PATCH 15/61] add more doc strings --- posydon/popsyn/star_formation_history.py | 61 +++++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 1d542e856e..726a0c17bc 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -377,7 +377,8 @@ def mean_metallicity(self, z): return 0.035 * 10 ** (-0.23 * z) # TODO: rewrite such that sigma is just changed for the Neijssel+19 case - def fSFR(self, z, metallicity_bins): + FOH_min = 5.3 + FOH_max = 9.7 '''Fraction of the SFR at a given redshift z in a given metallicity bin as described in Neijssel et al. (2019). @@ -425,6 +426,17 @@ class IllustrisTNG(SFHBase): ''' def __init__(self, MODEL): + '''Initialise the IllustrisTNG model + + Parameters + ---------- + MODEL : dict + Model parameters. IllustrisTNG requires the following parameters: + - Z_max : float + The maximum metallicity in absolute units. + - select_one_met : bool + If True, the SFR is calculated for a single metallicity bin. + ''' super().__init__(MODEL) # load the TNG data illustris_data = self._get_illustrisTNG_data() @@ -434,16 +446,46 @@ def __init__(self, MODEL): self.M = illustris_data["M"] # Msun def _get_illustrisTNG_data(self, verbose=False): - """Load IllustrisTNG SFR dataset.""" + '''Load IllustrisTNG SFR dataset into the class. + + Parameters + ---------- + verbose : bool, optional + Print information about the data loading. + ''' if verbose: print("Loading IllustrisTNG data...") return np.load(os.path.join(PATH_TO_POSYDON_DATA, "SFR/IllustrisTNG.npz")) def CSFRD(self, z): + '''The cosmic star formation rate density at a given redshift. + + Parameters + ---------- + z : float or np.array + Cosmological redshift. + + Returns + ------- + float or array + The cosmic star formation rate density at the given redshift(s). + ''' SFR_interp = interp1d(self.redshifts, self.CSFRD_data) return SFR_interp(z) def mean_metallicity(self, z): + '''Calculate the mean metallicity at a given redshift + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + + Returns + ------- + float or array-like + The mean metallicity at the given redshift(s). + ''' out = np.zeros_like(self.redshifts) for i in range(len(out)): if np.sum(self.M[i, :]) == 0: @@ -454,6 +496,21 @@ def mean_metallicity(self, z): return Z_interp(z) def fSFR(self, z, metallicity_bins): + '''Calculate the fractional SFR as a function of redshift and + metallicity bins. + + Parameters + ---------- + z : float or array-like + Cosmological redshift. + metallicity_bins : array + Metallicity bins edges in absolute metallicity. + + Returns + ------- + array + Fraction of the SFR in the given metallicity bin at the given redshift. + ''' # only use data within the metallicity bounds (no lower bound) Z_max_mask = self.Z <= self.Z_max redshift_indices = np.array([np.where(self.redshifts <= i)[0][0] for i in z]) From ba64f5075c25f5c87e73d11362be8aa50e39a5ed Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 15:51:51 +0100 Subject: [PATCH 16/61] add even more docs --- posydon/popsyn/star_formation_history.py | 47 +++++++++++++++++------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 726a0c17bc..c18c5cf239 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -146,7 +146,6 @@ class MadauBase(SFHBase): and fractional SFR based on the chosen Madau parameterisation. The specific parameters for CSFRD must be provided by subclasses. """ - def CSFRD(self, z): '''The cosmic star formation rate density at a given redshift. @@ -377,8 +376,7 @@ def mean_metallicity(self, z): return 0.035 * 10 ** (-0.23 * z) # TODO: rewrite such that sigma is just changed for the Neijssel+19 case - FOH_min = 5.3 - FOH_max = 9.7 + def fSFR(self, z, metallicity_bins): '''Fraction of the SFR at a given redshift z in a given metallicity bin as described in Neijssel et al. (2019). @@ -587,9 +585,10 @@ def _load_chruslinska_data(self, verbose=False): # oxygen to hydrogen abundance ratio ( FOH == 12 + log(O/H) ) # as used in the calculations - do not change # This is the metallicity bin edges used in the Chruslinska+21 calculations - FOH_min, FOH_max = 5.3, 9.7 - self.FOH_bins = np.linspace(FOH_min,FOH_max, 200) - self.dFOH=self.FOH_bins[1]-self.FOH_bins[0] + FOH_min = 5.3 + FOH_max = 9.7 + self.FOH_bins = np.linspace(FOH_min, FOH_max, 200) + self.dFOH = self.FOH_bins[1] - self.FOH_bins[0] # I need to use the Z_solar_scaling parameter to convert the FOH bins to absolute metallicity # I will use the solar metallicity as the reference point self.Z = self._FOH_to_Z(self.FOH_bins) @@ -602,18 +601,23 @@ def _load_chruslinska_data(self, verbose=False): def _FOH_to_Z(self, FOH): # scalings from Chruslinksa+21 if self.Z_solar_scaling == 'Asplund09': - Zsun, FOHsun = [0.0134, 8.69] + Zsun = 0.0134 + FOHsun = 8.69 elif self.Z_solar_scaling == 'AndersGrevesse89': - Zsun,FOHsun = [0.017, 8.83] + Zsun = 0.017 + FOHsun = 8.83 elif self.Z_solar_scaling == 'GrevesseSauval98': - Zsun,FOHsun = [0.0201, 8.93] + Zsun = 0.0201 + FOHsun = 8.93 elif self.Z_solar_scaling == 'Villante14': - Zsun,FOHsun = [0.019, 8.85] + Zsun = 0.019 + FOHsun = 8.85 else: - raise ValueError("Invalid Z_solar_scaling!") + raise ValueError("Invalid Z_solar_scaling!"+ + "Options are: Asplund09, AndersGrevesse89,"+ + "GrevesseSauval98, Villante14") logZ = np.log10(Zsun) + FOH - FOHsun - ZZ=10**logZ - return ZZ + return 10**logZ def mean_metallicity(self, z): '''Calculate the mean metallicity at a given redshift @@ -739,6 +743,23 @@ class Fujimoto24(MadauBase): https://ui.adsabs.harvard.edu/abs/2024ApJS..275...36F/abstract ''' def __init__(self, MODEL): + '''Initialise the Fujimoto+24 model + + Parameters + ---------- + MODEL : dict + Model parameters. Fujimoto+24 requires the following parameters: + - sigma : float or str + The standard deviation of the log-normal metallicity distribution. + Options are: + - Bavera+20 + - Neijssel+19 + - float + - Z_max : float + The maximum metallicity in absolute units. + - select_one_met : bool + If True, the SFR is calculated for a single metallicity bin. + ''' super().__init__(MODEL) # Parameters for Fujimoto+24 CSFRD self.CSFRD_params = { From 55b65cc55fe07c409c73671e9138386ae4a6966b Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 15:56:31 +0100 Subject: [PATCH 17/61] more doc strings --- posydon/popsyn/star_formation_history.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index c18c5cf239..e13713e66c 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -307,9 +307,8 @@ def __init__(self, MODEL): - Z_max : float The maximum metallicity in absolute units. - select_one_met : bool - If True, the SFR is calculated for a single metallicity bin. - - ''' + If True, the SFR is calculated for a single metallicity bin. + ''' super().__init__(MODEL) # Parameters for Madau+Fragos17 CSFRD self.CSFRD_params = { @@ -545,8 +544,6 @@ class Chruslinska21(SFHBase): Data source: https://ftp.science.ru.nl/astro/mchruslinska/Chruslinska_et_al_2021/ - - ''' def __init__(self, MODEL): '''Initialise the Chruslinska+21 model @@ -563,6 +560,7 @@ def __init__(self, MODEL): - AndersGrevesse89 - GrevesseSauval98 - Villante14 + - Z_max : float ''' if "sub_model" not in MODEL: raise ValueError("Sub-model not given!") @@ -573,7 +571,7 @@ def __init__(self, MODEL): self._load_chruslinska_data() def _load_chruslinska_data(self, verbose=False): - '''load the data from the Chruslinska+21 models + '''Load the data from the Chruslinska+21 models. Transforms the data to the format used in the classes. Parameters From 4a274d508830eef48c108d2679657c78cb6cd9b1 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 16:03:14 +0100 Subject: [PATCH 18/61] add checks for required parameters in MadauBase --- posydon/popsyn/star_formation_history.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index e13713e66c..339df092a0 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -146,6 +146,16 @@ class MadauBase(SFHBase): and fractional SFR based on the chosen Madau parameterisation. The specific parameters for CSFRD must be provided by subclasses. """ + def __init__(self, MODEL): + if "sigma" not in MODEL: + raise ValueError("sigma not given!") + if "Z_max" not in MODEL: + raise ValueError("Z_max not given!") + if "select_one_met" not in MODEL: + raise ValueError("select_one_met not given!") + super().__init__(MODEL) + self.CSFRD_params = None + def CSFRD(self, z): '''The cosmic star formation rate density at a given redshift. @@ -578,7 +588,6 @@ def _load_chruslinska_data(self, verbose=False): ---------- verbose : bool, optional Print information about the data loading. - ''' # oxygen to hydrogen abundance ratio ( FOH == 12 + log(O/H) ) # as used in the calculations - do not change @@ -597,6 +606,18 @@ def _load_chruslinska_data(self, verbose=False): self.SFR_data = np.array( [M[ii]/(1e6*delta_T[ii]) for ii in range(len(delta_T))])/self.dFOH def _FOH_to_Z(self, FOH): + '''Convert the oxygen to hydrogen abundance ratio to absolute metallicity + + Parameters + ---------- + FOH : float or array-like + The oxygen to hydrogen abundance ratio. + + Returns + ------- + float or array-like + The absolute metallicity. + ''' # scalings from Chruslinksa+21 if self.Z_solar_scaling == 'Asplund09': Zsun = 0.0134 From d5f88e2da4061247cb74f3d2716ae4a86a41421c Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 16:04:47 +0100 Subject: [PATCH 19/61] docstrings: --- posydon/popsyn/star_formation_history.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 339df092a0..a6a057be9f 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -147,6 +147,24 @@ class MadauBase(SFHBase): The specific parameters for CSFRD must be provided by subclasses. """ def __init__(self, MODEL): + '''Initialise the MadauBase class + + Parameters + ---------- + MODEL : dict + Model parameters. MadauBase requires the following parameters: + - sigma : float or str + The standard deviation of the log-normal metallicity distribution. + Options are: + - Bavera+20 + - Neijssel+19 + - float + - Z_max : float + The maximum metallicity in absolute units. + - select_one_met : bool + If True, the SFR is calculated for a single metallicity bin. + + ''' if "sigma" not in MODEL: raise ValueError("sigma not given!") if "Z_max" not in MODEL: From 3712cf23c79770c5d27e856148468e83fd9a6734 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 16:10:32 +0100 Subject: [PATCH 20/61] change name of SFR_per_met_per_z function --- posydon/popsyn/star_formation_history.py | 4 ++-- posydon/popsyn/synthetic_population.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index a6a057be9f..1e71b9e13b 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -852,7 +852,7 @@ def get_SFH_model(MODEL): Returns ------- - SFHBase + a SFHBase instance or subclass The SFH model instance. ''' if MODEL["SFR"] == "Madau+Fragos17": @@ -872,7 +872,7 @@ def get_SFH_model(MODEL): else: raise ValueError("Invalid SFR!") -def SFR_per_Z_at_z(z, met_bins, MODEL): +def SFR_per_met_at_z(z, met_bins, MODEL): """Calculate the SFR per metallicity bin at a given redshift(s) Parameters diff --git a/posydon/popsyn/synthetic_population.py b/posydon/popsyn/synthetic_population.py index d7a58bef6e..d81c494d89 100644 --- a/posydon/popsyn/synthetic_population.py +++ b/posydon/popsyn/synthetic_population.py @@ -57,7 +57,7 @@ get_redshift_bin_centers, ) -from posydon.popsyn.star_formation_history import SFR_per_Z_at_z +from posydon.popsyn.star_formation_history import SFR_per_met_at_z from posydon.popsyn.binarypopulation import ( BinaryPopulation, @@ -2080,7 +2080,7 @@ def calculate_cosmic_weights(self, SFH_identifier, MODEL_in=None): # sample the SFH for only the events that are within the Hubble time # only need to sample the SFH at each metallicity and z_birth # Not for every event! - SFR_per_Z_at_z_birth = SFR_per_Z_at_z(z_birth, met_edges, MODEL) + SFR_per_met_at_z_birth = SFR_per_met_at_z(z_birth, met_edges, self.MODEL) # simulated mass per given metallicity corrected for the unmodeled # single and binary stellar mass @@ -2143,7 +2143,7 @@ def calculate_cosmic_weights(self, SFH_identifier, MODEL_in=None): * c * D_c[mask] ** 2 * deltaT - * SFR_per_Z_at_z_birth[:, j] + * SFR_per_met_at_z_birth[:, j] / M_model[j] ) # yr^-1 From 1d06d9f1f062241cf3bd5f516327c1194494cb8a Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 16:24:32 +0100 Subject: [PATCH 21/61] alter unit tests to adapt to previous changes --- .../popsyn/test_star_formation_history.py | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index be4b3f4b01..14b126bd74 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -1,6 +1,6 @@ import numpy as np import pytest -from posydon.popsyn.star_formation_history import SFHBase +from posydon.popsyn.star_formation_history import SFHBase, MadauBase from posydon.popsyn.star_formation_history import ( MadauDickinson14, MadauFragos17, @@ -13,7 +13,7 @@ # Replace duplicate DummySFH definitions with a single merged class -class DummySFH(SFHBase): +class DummySFH(MadauBase): def CSFRD(self, z): if self.MODEL.get("dummy_mode", "std") == "call": return np.full_like(z, 2.0, dtype=float) @@ -37,26 +37,28 @@ def fSFR(self, z, metallicity_bins): class TestStdLogMetallicityDist: def test_sigma_bavera(self): # Test with sigma as "Bavera+20" which should return 0.5 - model = {"sigma": "Bavera+20"} + model = {"sigma": "Bavera+20", 'Z_max': 0.03, 'select_one_met': False} dummy = DummySFH(model) assert dummy.std_log_metallicity_dist() == 0.5 def test_sigma_neijssel(self): # Test with sigma as "Neijssel+19" which should return 0.39 - model = {"sigma": "Neijssel+19"} + model = {"sigma": "Neijssel+19", 'Z_max': 0.03, 'select_one_met': False} dummy = DummySFH(model) assert dummy.std_log_metallicity_dist() == 0.39 def test_sigma_float(self): # Test with sigma as a float value sigma_value = 0.45 - model = {"sigma": sigma_value} + model = {"sigma": sigma_value, 'Z_max': 0.03, 'select_one_met': False} dummy = DummySFH(model) assert dummy.std_log_metallicity_dist() == sigma_value def test_unknown_sigma_string(self): # Test with an invalid sigma string should raise a ValueError - model = {"sigma": "invalid_sigma"} + model = {"sigma": "invalid_sigma", + 'Z_max': 0.03, + 'select_one_met': False} dummy = DummySFH(model) with pytest.raises(ValueError) as excinfo: dummy.std_log_metallicity_dist() @@ -64,7 +66,7 @@ def test_unknown_sigma_string(self): def test_invalid_sigma(self): # Test with an invalid sigma value should raise a ValueError - model = {"sigma": int(1)} + model = {"sigma": int(1), 'Z_max': 0.03, 'select_one_met': False} dummy = DummySFH(model) with pytest.raises(ValueError) as excinfo: dummy.std_log_metallicity_dist() @@ -95,22 +97,21 @@ def test_fSFR_sum_is_one_empirical(self, model_class, model_name): # The sum over metallicity bins (axis=1) should be approximately 1 for each redshift np.testing.assert_allclose(np.sum(fSFR, axis=1), np.ones(len(z)), atol=1e-6) - def dummy_get_illustrisTNG_data(self, verbose=False): - return { - "SFR": np.array([1.0, 1.0, 1.0]), - "redshifts": np.array([0.0, 1.0, 2.0]), - "mets": np.linspace(0.001, 0.03, 10), - "M": np.ones((3, 10)), - } - def test_fSFR_sum_is_one_illustris(self, monkeypatch): + def dummy_get_illustrisTNG_data(self, verbose=False): + return { + "SFR": np.array([1.0, 1.0, 1.0]), + "redshifts": np.array([0.0, 1.0, 2.0]), + "mets": np.linspace(0.001, 0.03, 10), + "M": np.ones((3, 10)), + } base_args = { "SFR": "IllustrisTNG", "sigma": 0.5, "Z_max": 0.03, "select_one_met": False, } - monkeypatch.setattr(IllustrisTNG, "_get_illustrisTNG_data", self.dummy_get_illustrisTNG_data) + monkeypatch.setattr(IllustrisTNG, "_get_illustrisTNG_data", dummy_get_illustrisTNG_data) sfh_instance = IllustrisTNG(base_args) z = np.array([0.5, 1.0, 2.0]) met_bins = np.linspace(0.0001, base_args["Z_max"], 50) From b715580330afc0f31415efe9033eae684f8cbbd9 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 16:29:25 +0100 Subject: [PATCH 22/61] change ' to " --- posydon/popsyn/star_formation_history.py | 182 +++++++++--------- .../popsyn/test_star_formation_history.py | 12 +- 2 files changed, 97 insertions(+), 97 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 1e71b9e13b..4d0232c323 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -37,9 +37,9 @@ class SFHBase(ABC): - '''Abstract class for star formation history models''' + """Abstract class for star formation history models""" def __init__(self, MODEL): - '''Initialise the SFH model + """Initialise the SFH model Adds the model parameters as attributes. @@ -47,7 +47,7 @@ def __init__(self, MODEL): ---------- MODEL : dict Model parameters. - ''' + """ self.MODEL = MODEL # Automatically attach all model parameters as attributes for key, value in MODEL.items(): @@ -123,7 +123,7 @@ def fSFR(self, z, metallicity_bins): def __call__(self, z, met_bins): - '''Return the star formation history at a given redshift and metallicity bins + """Return the star formation history at a given redshift and metallicity bins Parameters ---------- @@ -136,7 +136,7 @@ def __call__(self, z, met_bins): ------- array Star formation history per metallicity bin at the given redshift(s). - ''' + """ return self.CSFRD(z)[:, np.newaxis] * self.fSFR(z, met_bins) class MadauBase(SFHBase): @@ -147,7 +147,7 @@ class MadauBase(SFHBase): The specific parameters for CSFRD must be provided by subclasses. """ def __init__(self, MODEL): - '''Initialise the MadauBase class + """Initialise the MadauBase class Parameters ---------- @@ -164,7 +164,7 @@ def __init__(self, MODEL): - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. - ''' + """ if "sigma" not in MODEL: raise ValueError("sigma not given!") if "Z_max" not in MODEL: @@ -175,7 +175,7 @@ def __init__(self, MODEL): self.CSFRD_params = None def CSFRD(self, z): - '''The cosmic star formation rate density at a given redshift. + """The cosmic star formation rate density at a given redshift. Follows the Madau & Dickinson (2014) cosmic star formation rate density formula. @@ -188,12 +188,12 @@ def CSFRD(self, z): ------- float or array The cosmic star formation rate density at the given redshift. - ''' + """ p = self.CSFRD_params return p["a"] * (1.0 + z) ** p["b"] / (1.0 + ((1.0 + z) / p["c"]) ** p["d"]) def std_log_metallicity_dist(self): - '''Return the standard deviation of the log-normal metallicity distribution + """Return the standard deviation of the log-normal metallicity distribution Either recognised the strings "Bavera+20" (sigma=0.5) or "Neijssel+19" (sigma=0.39) or a float value. @@ -202,7 +202,7 @@ def std_log_metallicity_dist(self): ------- float The standard deviation of the log-normal metallicity distribution. - ''' + """ sigma = self.sigma if isinstance(sigma, str): if sigma == "Bavera+20": @@ -217,7 +217,7 @@ def std_log_metallicity_dist(self): raise ValueError(f"Invalid sigma value {sigma}!") def mean_metallicity(self, z): - '''The mean metallicity at a given redshift + """The mean metallicity at a given redshift Follows Madau & Fragos (2017) mean metallicity evolution @@ -230,11 +230,11 @@ def mean_metallicity(self, z): ------- float or array The mean metallicity at the given redshift. - ''' + """ return 10 ** (0.153 - 0.074 * z ** 1.34) * Zsun def fSFR(self, z, metallicity_bins): - '''Fraction of the SFR at a given redshift z in a given metallicity bin as described in Bavera et al. (2020). + """Fraction of the SFR at a given redshift z in a given metallicity bin as described in Bavera et al. (2020). Parameters ---------- @@ -247,7 +247,7 @@ def fSFR(self, z, metallicity_bins): ------- array Fraction of the SFR in the given metallicity bin at the given redshift. - ''' + """ sigma = self.std_log_metallicity_dist() # Compute mu; if z is an array, mu will be an array. mu = np.log10(self.mean_metallicity(z)) - sigma ** 2 * np.log(10) / 2.0 @@ -276,15 +276,15 @@ def fSFR(self, z, metallicity_bins): return fSFR class MadauDickinson14(MadauBase): - '''Madau & Dickinson (2014) star formation history model using the + """Madau & Dickinson (2014) star formation history model using the mean metallicity evolution of Madau & Fragos (2017). Madau & Dickinson (2014), ARA&A, 52, 415 https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M/abstract - ''' + """ def __init__(self, MODEL): - '''Initialise the Madau & Dickinson (2014) SFH model with the + """Initialise the Madau & Dickinson (2014) SFH model with the metallicity evolution of Madau & Fragos (2017). Parameters @@ -301,7 +301,7 @@ def __init__(self, MODEL): The maximum metallicity in absolute units. - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. - ''' + """ super().__init__(MODEL) # Parameters for Madau+Dickinson14 CSFRD self.CSFRD_params = { @@ -312,15 +312,15 @@ def __init__(self, MODEL): } class MadauFragos17(MadauBase): - '''The Madau & Fragos (2017) star formation history model with the + """The Madau & Fragos (2017) star formation history model with the metallicity evolution of Madau & Fragos (2017). Madau & Fragos (2017), ApJ, 840, 39 http://adsabs.harvard.edu/abs/2017ApJ...840...39M - ''' + """ def __init__(self, MODEL): - '''Initialise the Madau+17 model + """Initialise the Madau+17 model Parameters ---------- @@ -336,7 +336,7 @@ def __init__(self, MODEL): The maximum metallicity in absolute units. - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. - ''' + """ super().__init__(MODEL) # Parameters for Madau+Fragos17 CSFRD self.CSFRD_params = { @@ -347,7 +347,7 @@ def __init__(self, MODEL): } class Neijssel19(MadauBase): - '''The Neijssel et al. (2019) star formation history model, which fits + """The Neijssel et al. (2019) star formation history model, which fits the Madau & Dickinson (2014) cosmic star formation rate density formula with the BBH merger rate and uses a truncated log-normal distribution for the mean metallicity distribution. @@ -356,9 +356,9 @@ class Neijssel19(MadauBase): Neijssel et al. (2019), MNRAS, 490, 3740 http://adsabs.harvard.edu/abs/2019MNRAS.490.3740N - ''' + """ def __init__(self, MODEL): - '''Initialise the Neijssel+19 model + """Initialise the Neijssel+19 model Parameters ---------- @@ -374,7 +374,7 @@ def __init__(self, MODEL): The maximum metallicity in absolute units. - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. - ''' + """ super().__init__(MODEL) # Parameters for Neijssel+19 CSFRD self.CSFRD_params = { @@ -386,7 +386,7 @@ def __init__(self, MODEL): # overwrite mean_metallicity method of MadauBase def mean_metallicity(self, z): - '''Calculate the mean metallicity at a given redshift + """Calculate the mean metallicity at a given redshift Overwrites the mean_metallicity method of MadauBase class. @@ -399,12 +399,12 @@ def mean_metallicity(self, z): ------- float or array-like The mean metallicity at the given redshift(s). - ''' + """ return 0.035 * 10 ** (-0.23 * z) # TODO: rewrite such that sigma is just changed for the Neijssel+19 case def fSFR(self, z, metallicity_bins): - '''Fraction of the SFR at a given redshift z in a given metallicity bin + """Fraction of the SFR at a given redshift z in a given metallicity bin as described in Neijssel et al. (2019). Overwrites the fSFR method of MadauBase class. @@ -421,7 +421,7 @@ def fSFR(self, z, metallicity_bins): array Fraction of the SFR in the given metallicity bins at the given redshift. - ''' + """ # assume a truncated ln-normal distribution of metallicities sigma = self.std_log_metallicity_dist() mu = np.log(self.mean_metallicity(z)) - sigma**2 / 2.0 @@ -443,15 +443,15 @@ def fSFR(self, z, metallicity_bins): return fSFR class IllustrisTNG(SFHBase): - '''The IllustrisTNG star formation history model. + """The IllustrisTNG star formation history model. Uses the TNG100-1 model from the IllustrisTNG simulation. https://www.tng-project.org/ - ''' + """ def __init__(self, MODEL): - '''Initialise the IllustrisTNG model + """Initialise the IllustrisTNG model Parameters ---------- @@ -461,7 +461,7 @@ def __init__(self, MODEL): The maximum metallicity in absolute units. - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. - ''' + """ super().__init__(MODEL) # load the TNG data illustris_data = self._get_illustrisTNG_data() @@ -471,19 +471,19 @@ def __init__(self, MODEL): self.M = illustris_data["M"] # Msun def _get_illustrisTNG_data(self, verbose=False): - '''Load IllustrisTNG SFR dataset into the class. + """Load IllustrisTNG SFR dataset into the class. Parameters ---------- verbose : bool, optional Print information about the data loading. - ''' + """ if verbose: print("Loading IllustrisTNG data...") return np.load(os.path.join(PATH_TO_POSYDON_DATA, "SFR/IllustrisTNG.npz")) def CSFRD(self, z): - '''The cosmic star formation rate density at a given redshift. + """The cosmic star formation rate density at a given redshift. Parameters ---------- @@ -494,12 +494,12 @@ def CSFRD(self, z): ------- float or array The cosmic star formation rate density at the given redshift(s). - ''' + """ SFR_interp = interp1d(self.redshifts, self.CSFRD_data) return SFR_interp(z) def mean_metallicity(self, z): - '''Calculate the mean metallicity at a given redshift + """Calculate the mean metallicity at a given redshift Parameters ---------- @@ -510,7 +510,7 @@ def mean_metallicity(self, z): ------- float or array-like The mean metallicity at the given redshift(s). - ''' + """ out = np.zeros_like(self.redshifts) for i in range(len(out)): if np.sum(self.M[i, :]) == 0: @@ -521,7 +521,7 @@ def mean_metallicity(self, z): return Z_interp(z) def fSFR(self, z, metallicity_bins): - '''Calculate the fractional SFR as a function of redshift and + """Calculate the fractional SFR as a function of redshift and metallicity bins. Parameters @@ -535,7 +535,7 @@ def fSFR(self, z, metallicity_bins): ------- array Fraction of the SFR in the given metallicity bin at the given redshift. - ''' + """ # only use data within the metallicity bounds (no lower bound) Z_max_mask = self.Z <= self.Z_max redshift_indices = np.array([np.where(self.redshifts <= i)[0][0] for i in z]) @@ -565,16 +565,16 @@ def fSFR(self, z, metallicity_bins): return fSFR class Chruslinska21(SFHBase): - '''The Chruślińska+21 star formation history model. + """The Chruślińska+21 star formation history model. Chruślińska et al. (2021), MNRAS, 508, 4994 https://ui.adsabs.harvard.edu/abs/2021MNRAS.508.4994C/abstract Data source: https://ftp.science.ru.nl/astro/mchruslinska/Chruslinska_et_al_2021/ - ''' + """ def __init__(self, MODEL): - '''Initialise the Chruslinska+21 model + """Initialise the Chruslinska+21 model Parameters ---------- @@ -589,24 +589,24 @@ def __init__(self, MODEL): - GrevesseSauval98 - Villante14 - Z_max : float - ''' + """ if "sub_model" not in MODEL: raise ValueError("Sub-model not given!") - if 'Z_solar_scaling' not in MODEL: + if "Z_solar_scaling" not in MODEL: raise ValueError("Z_solar_scaling not given!") super().__init__(MODEL) self._load_chruslinska_data() def _load_chruslinska_data(self, verbose=False): - '''Load the data from the Chruslinska+21 models. + """Load the data from the Chruslinska+21 models. Transforms the data to the format used in the classes. Parameters ---------- verbose : bool, optional Print information about the data loading. - ''' + """ # oxygen to hydrogen abundance ratio ( FOH == 12 + log(O/H) ) # as used in the calculations - do not change # This is the metallicity bin edges used in the Chruslinska+21 calculations @@ -624,7 +624,7 @@ def _load_chruslinska_data(self, verbose=False): self.SFR_data = np.array( [M[ii]/(1e6*delta_T[ii]) for ii in range(len(delta_T))])/self.dFOH def _FOH_to_Z(self, FOH): - '''Convert the oxygen to hydrogen abundance ratio to absolute metallicity + """Convert the oxygen to hydrogen abundance ratio to absolute metallicity Parameters ---------- @@ -635,18 +635,18 @@ def _FOH_to_Z(self, FOH): ------- float or array-like The absolute metallicity. - ''' + """ # scalings from Chruslinksa+21 - if self.Z_solar_scaling == 'Asplund09': + if self.Z_solar_scaling == "Asplund09": Zsun = 0.0134 FOHsun = 8.69 - elif self.Z_solar_scaling == 'AndersGrevesse89': + elif self.Z_solar_scaling == "AndersGrevesse89": Zsun = 0.017 FOHsun = 8.83 - elif self.Z_solar_scaling == 'GrevesseSauval98': + elif self.Z_solar_scaling == "GrevesseSauval98": Zsun = 0.0201 FOHsun = 8.93 - elif self.Z_solar_scaling == 'Villante14': + elif self.Z_solar_scaling == "Villante14": Zsun = 0.019 FOHsun = 8.85 else: @@ -657,7 +657,7 @@ def _FOH_to_Z(self, FOH): return 10**logZ def mean_metallicity(self, z): - '''Calculate the mean metallicity at a given redshift + """Calculate the mean metallicity at a given redshift Parameters ---------- @@ -668,7 +668,7 @@ def mean_metallicity(self, z): ------- float or array-like The mean metallicity at the given redshift(s). - ''' + """ mean_over_redshift = np.zeros_like(self.redshifts) for i in range(len(mean_over_redshift)): if np.sum(self.SFR_data[i]) == 0: @@ -680,7 +680,7 @@ def mean_metallicity(self, z): return Z_interp(z) def fSFR(self, z, metallicity_bins): - '''Calculate the fractional SFR as a function of redshift and metallicity bins + """Calculate the fractional SFR as a function of redshift and metallicity bins Parameters ---------- @@ -693,7 +693,7 @@ def fSFR(self, z, metallicity_bins): ------- array Fraction of the SFR in the given metallicity bin at the given redshift. - ''' + """ # only use data within the metallicity bounds (no lower bound) Z_max_mask = self.Z <= self.Z_max redshift_indices = np.array([np.where(self.redshifts <= i)[0][0] for i in z]) @@ -723,7 +723,7 @@ def fSFR(self, z, metallicity_bins): return fSFR def _load_redshift_data(self, verbose=False): - '''Load the redshift data from a Chruslinsk+21 model file. + """Load the redshift data from a Chruslinsk+21 model file. Returns ------- @@ -733,16 +733,16 @@ def _load_redshift_data(self, verbose=False): the redshifts corresponding to the time bins delt : array the width of the time bins - ''' + """ if verbose: print("Loading redshift data...") time, redshift, delt = np.loadtxt( - os.path.join(self._data_folder, 'Time_redshift_deltaT.dat'), unpack=True) + os.path.join(self._data_folder, "Time_redshift_deltaT.dat"), unpack=True) return time, redshift, delt def _load_raw_data(self): - '''Read the sub-model data from the file + """Read the sub-model data from the file The data structure is as follows: - mass per unit (comoving) volume formed in each z (row) - FOH (column) bin @@ -751,13 +751,13 @@ def _load_raw_data(self): ------- array Mass formed per unit volume in each redshift and FOH bin - ''' - input_file = os.path.join(self._data_folder, f'{self.sub_model}.dat') + """ + input_file = os.path.join(self._data_folder, f"{self.sub_model}.dat") data = np.loadtxt(input_file) return data def CSFRD(self, z): - '''Interpolate the cosmic star formation rate density at the given redshift(s) + """Interpolate the cosmic star formation rate density at the given redshift(s) Parameters ---------- @@ -768,19 +768,19 @@ def CSFRD(self, z): ------- float or array-like The cosmic star formation rate density at the given redshift(s). - ''' + """ SFR_interp = interp1d(self.redshifts, np.sum(self.SFR_data*self.dFOH, axis=1)) return SFR_interp(z) class Fujimoto24(MadauBase): - '''The Fujimoto et al. (2024) star formation history model. + """The Fujimoto et al. (2024) star formation history model. mean metallicity evolution of Madau & Fragos (2017). Fujimoto et al. (2024), ApJ SS, 275, 2, 36, 59 https://ui.adsabs.harvard.edu/abs/2024ApJS..275...36F/abstract - ''' + """ def __init__(self, MODEL): - '''Initialise the Fujimoto+24 model + """Initialise the Fujimoto+24 model Parameters ---------- @@ -796,7 +796,7 @@ def __init__(self, MODEL): The maximum metallicity in absolute units. - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. - ''' + """ super().__init__(MODEL) # Parameters for Fujimoto+24 CSFRD self.CSFRD_params = { @@ -809,30 +809,30 @@ def __init__(self, MODEL): class Zalava21(MadauBase): def __init__(self, MODEL): - '''Initialise the Zalava+21 model + """Initialise the Zalava+21 model Requires the following parameters: - sub_model : str Either min or max - ''' - if 'sub_model' not in MODEL: + """ + if "sub_model" not in MODEL: raise ValueError("Sub-model not given!") super().__init__(MODEL) self._load_zalava_data() def _load_zalava_data(self): - '''Load the data from the Zalava+21 models + """Load the data from the Zalava+21 models Transforms the data to the format used in the classes. - ''' + """ data_file = os.path.join(PATH_TO_POSYDON_DATA, "SFR/Zalava+21.txt") - tmp_data = pd.read_csv(data_file, names=['redshift', 'SFRD_min', 'SFRD_max'], skiprows=1, sep='\s+') - self.redshifts = tmp_data['redshift'].values - if self.sub_model == 'min': - self.SFR_data = tmp_data['SFRD_min'].values - elif self.sub_model == 'max': - self.SFR_data = tmp_data['SFRD_max'].values + tmp_data = pd.read_csv(data_file, names=["redshift", "SFRD_min", "SFRD_max"], skiprows=1, sep="\s+") + self.redshifts = tmp_data["redshift"].values + if self.sub_model == "min": + self.SFR_data = tmp_data["SFRD_min"].values + elif self.sub_model == "max": + self.SFR_data = tmp_data["SFRD_max"].values else: raise ValueError("Invalid sub-model!") @@ -843,7 +843,7 @@ def CSFRD(self, z): def get_SFH_model(MODEL): - '''Return the appropriate SFH model based on the given parameters + """Return the appropriate SFH model based on the given parameters Parameters ---------- @@ -854,20 +854,20 @@ def get_SFH_model(MODEL): ------- a SFHBase instance or subclass The SFH model instance. - ''' + """ if MODEL["SFR"] == "Madau+Fragos17": return MadauFragos17(MODEL) - elif MODEL['SFR'] == "Madau+Dickinson14": + elif MODEL["SFR"] == "Madau+Dickinson14": return MadauDickinson14(MODEL) - elif MODEL['SFR'] == 'Fujimoto+24': + elif MODEL["SFR"] == "Fujimoto+24": return Fujimoto24(MODEL) - elif MODEL['SFR'] == "Neijssel+19": + elif MODEL["SFR"] == "Neijssel+19": return Neijssel19(MODEL) - elif MODEL['SFR'] == "IllustrisTNG": + elif MODEL["SFR"] == "IllustrisTNG": return IllustrisTNG(MODEL) - elif MODEL['SFR'] == "Chruslinska+21": + elif MODEL["SFR"] == "Chruslinska+21": return Chruslinska21(MODEL) - elif MODEL['SFR'] == "Zalava+21": + elif MODEL["SFR"] == "Zalava+21": return Zalava21(MODEL) else: raise ValueError("Invalid SFR!") diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 14b126bd74..01f21dd54d 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -37,28 +37,28 @@ def fSFR(self, z, metallicity_bins): class TestStdLogMetallicityDist: def test_sigma_bavera(self): # Test with sigma as "Bavera+20" which should return 0.5 - model = {"sigma": "Bavera+20", 'Z_max': 0.03, 'select_one_met': False} + model = {"sigma": "Bavera+20", "Z_max": 0.03, "select_one_met": False} dummy = DummySFH(model) assert dummy.std_log_metallicity_dist() == 0.5 def test_sigma_neijssel(self): # Test with sigma as "Neijssel+19" which should return 0.39 - model = {"sigma": "Neijssel+19", 'Z_max': 0.03, 'select_one_met': False} + model = {"sigma": "Neijssel+19", "Z_max": 0.03, "select_one_met": False} dummy = DummySFH(model) assert dummy.std_log_metallicity_dist() == 0.39 def test_sigma_float(self): # Test with sigma as a float value sigma_value = 0.45 - model = {"sigma": sigma_value, 'Z_max': 0.03, 'select_one_met': False} + model = {"sigma": sigma_value, "Z_max": 0.03, "select_one_met": False} dummy = DummySFH(model) assert dummy.std_log_metallicity_dist() == sigma_value def test_unknown_sigma_string(self): # Test with an invalid sigma string should raise a ValueError model = {"sigma": "invalid_sigma", - 'Z_max': 0.03, - 'select_one_met': False} + "Z_max": 0.03, + "select_one_met": False} dummy = DummySFH(model) with pytest.raises(ValueError) as excinfo: dummy.std_log_metallicity_dist() @@ -66,7 +66,7 @@ def test_unknown_sigma_string(self): def test_invalid_sigma(self): # Test with an invalid sigma value should raise a ValueError - model = {"sigma": int(1), 'Z_max': 0.03, 'select_one_met': False} + model = {"sigma": int(1), "Z_max": 0.03, "select_one_met": False} dummy = DummySFH(model) with pytest.raises(ValueError) as excinfo: dummy.std_log_metallicity_dist() From 5f9bde85a4447bbd47488a14d24967d70acf9f93 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 16:34:52 +0100 Subject: [PATCH 23/61] cleanup line lengths --- posydon/popsyn/star_formation_history.py | 44 +++++++++++++------ .../popsyn/test_star_formation_history.py | 30 ++++++++++--- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 4d0232c323..c16a3eef2e 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -123,7 +123,8 @@ def fSFR(self, z, metallicity_bins): def __call__(self, z, met_bins): - """Return the star formation history at a given redshift and metallicity bins + """Return the star formation history at a given redshift and metallicity + bins Parameters ---------- @@ -177,7 +178,8 @@ def __init__(self, MODEL): def CSFRD(self, z): """The cosmic star formation rate density at a given redshift. - Follows the Madau & Dickinson (2014) cosmic star formation rate density formula. + Follows the Madau & Dickinson (2014) cosmic star formation rate + density formula. Parameters ---------- @@ -193,7 +195,8 @@ def CSFRD(self, z): return p["a"] * (1.0 + z) ** p["b"] / (1.0 + ((1.0 + z) / p["c"]) ** p["d"]) def std_log_metallicity_dist(self): - """Return the standard deviation of the log-normal metallicity distribution + """Return the standard deviation of the log-normal metallicity + distribution Either recognised the strings "Bavera+20" (sigma=0.5) or "Neijssel+19" (sigma=0.39) or a float value. @@ -234,7 +237,8 @@ def mean_metallicity(self, z): return 10 ** (0.153 - 0.074 * z ** 1.34) * Zsun def fSFR(self, z, metallicity_bins): - """Fraction of the SFR at a given redshift z in a given metallicity bin as described in Bavera et al. (2020). + """Fraction of the SFR at a given redshift z in a given metallicity + bin as described in Bavera et al. (2020). Parameters ---------- @@ -246,7 +250,8 @@ def fSFR(self, z, metallicity_bins): Returns ------- array - Fraction of the SFR in the given metallicity bin at the given redshift. + Fraction of the SFR in the given metallicity bin at the given + redshift. """ sigma = self.std_log_metallicity_dist() # Compute mu; if z is an array, mu will be an array. @@ -270,8 +275,12 @@ def fSFR(self, z, metallicity_bins): ) / norm[:, np.newaxis] if not self.select_one_met: - fSFR[:, 0] = stats.norm.cdf(np.log10(metallicity_bins[1]), mu_array, sigma)/norm - fSFR[:, -1] = 1 - (stats.norm.cdf(np.log10(metallicity_bins[-2]), mu_array, sigma)/norm) + fSFR[:, 0] = stats.norm.cdf(np.log10(metallicity_bins[1]), + mu_array, + sigma)/norm + fSFR[:, -1] = 1 - (stats.norm.cdf(np.log10(metallicity_bins[-2]), + mu_array, + sigma)/norm) return fSFR @@ -292,7 +301,8 @@ def __init__(self, MODEL): MODEL : dict Model parameters. Madau+14 requires the following parameters: - sigma : float or str - The standard deviation of the log-normal metallicity distribution. + The standard deviation of the log-normal metallicity + distribution. Options are: - Bavera+20 - Neijssel+19 @@ -438,8 +448,12 @@ def fSFR(self, z, metallicity_bins): ] ) / norm[:, np.newaxis] if not self.select_one_met: - fSFR[:, 0] = stats.norm.cdf(np.log(metallicity_bins[1]), mu, sigma) / norm - fSFR[:,-1] = 1 - stats.norm.cdf(np.log(metallicity_bins[-2]), mu, sigma)/norm + fSFR[:, 0] = stats.norm.cdf(np.log(metallicity_bins[1]), + mu, + sigma) / norm + fSFR[:,-1] = 1 - stats.norm.cdf(np.log(metallicity_bins[-2]), + mu, + sigma)/norm return fSFR class IllustrisTNG(SFHBase): @@ -614,8 +628,9 @@ def _load_chruslinska_data(self, verbose=False): FOH_max = 9.7 self.FOH_bins = np.linspace(FOH_min, FOH_max, 200) self.dFOH = self.FOH_bins[1] - self.FOH_bins[0] - # I need to use the Z_solar_scaling parameter to convert the FOH bins to absolute metallicity - # I will use the solar metallicity as the reference point + # Need to use the Z_solar_scaling parameter to + # convert the FOH bins to absolute metallicity. + # Use solar metallicity as the reference point self.Z = self._FOH_to_Z(self.FOH_bins) self._data_folder = os.path.join(PATH_TO_POSYDON_DATA, "SFR/Chruslinska+21") @@ -827,7 +842,10 @@ def _load_zalava_data(self): """ data_file = os.path.join(PATH_TO_POSYDON_DATA, "SFR/Zalava+21.txt") - tmp_data = pd.read_csv(data_file, names=["redshift", "SFRD_min", "SFRD_max"], skiprows=1, sep="\s+") + tmp_data = pd.read_csv(data_file, + names=["redshift", "SFRD_min", "SFRD_max"], + skiprows=1, + sep="\s+") self.redshifts = tmp_data["redshift"].values if self.sub_model == "min": self.SFR_data = tmp_data["SFRD_min"].values diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 01f21dd54d..00eeccda27 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -95,7 +95,9 @@ def test_fSFR_sum_is_one_empirical(self, model_class, model_name): fSFR = sfh_instance.fSFR(z, met_bins) # The sum over metallicity bins (axis=1) should be approximately 1 for each redshift - np.testing.assert_allclose(np.sum(fSFR, axis=1), np.ones(len(z)), atol=1e-6) + np.testing.assert_allclose(np.sum(fSFR, axis=1), + np.ones(len(z)), + atol=1e-6) def test_fSFR_sum_is_one_illustris(self, monkeypatch): def dummy_get_illustrisTNG_data(self, verbose=False): @@ -111,17 +113,24 @@ def dummy_get_illustrisTNG_data(self, verbose=False): "Z_max": 0.03, "select_one_met": False, } - monkeypatch.setattr(IllustrisTNG, "_get_illustrisTNG_data", dummy_get_illustrisTNG_data) + monkeypatch.setattr(IllustrisTNG, + "_get_illustrisTNG_data", + dummy_get_illustrisTNG_data) sfh_instance = IllustrisTNG(base_args) z = np.array([0.5, 1.0, 2.0]) met_bins = np.linspace(0.0001, base_args["Z_max"], 50) fSFR = sfh_instance.fSFR(z, met_bins) - np.testing.assert_allclose(np.sum(fSFR, axis=1), np.ones(len(z)), atol=1e-6) + np.testing.assert_allclose(np.sum(fSFR, axis=1), + np.ones(len(z)), + atol=1e-6) class TestCallMethod: def test_call_method_returns_product(self): # Use DummySFH in 'call' mode to test __call__ - model = {"sigma": 0.5, "Z_max": 0.03, "select_one_met": False, "dummy_mode": "call"} + model = {"sigma": 0.5, + "Z_max": 0.03, + "select_one_met": False, + "dummy_mode": "call"} dummy = DummySFH(model) z = np.array([0.5, 1.0, 2.0]) met_bins = np.linspace(0.001, model["Z_max"], 10) @@ -161,7 +170,9 @@ def dummy_get_illustrisTNG_data(self, verbose=False): "mets": np.linspace(0.001, 0.03, 10), "M": np.ones((1, 10)), } - monkeypatch.setattr(IllustrisTNG, "_get_illustrisTNG_data", dummy_get_illustrisTNG_data) + monkeypatch.setattr(IllustrisTNG, + "_get_illustrisTNG_data", + dummy_get_illustrisTNG_data) model = get_SFH_model(base_args) assert isinstance(model, IllustrisTNG) @@ -175,7 +186,9 @@ def test_get_model_chruslinska(self, monkeypatch): "Z_solar_scaling": "Asplund09", } # Override _load_chruslinska_data to avoid file I/O during tests - monkeypatch.setattr(Chruslinska21, "_load_chruslinska_data", lambda self, verbose=False: None) + monkeypatch.setattr(Chruslinska21, + "_load_chruslinska_data", + lambda self, verbose=False: None) model = get_SFH_model(base_args) assert isinstance(model, Chruslinska21) assert model.SFR == "Chruslinska+21" @@ -190,7 +203,10 @@ def test_get_model_zalava(self, monkeypatch): "Z_solar_scaling": "Asplund09", } # Override _load_zalava_data to avoid file I/O during tests - monkeypatch.setattr(Zalava21, "_load_zalava_data", lambda self, verbose=False: None) + monkeypatch.setattr(Zalava21, + "_load_zalava_data", + lambda self, + verbose=False: None) model = get_SFH_model(base_args) assert isinstance(model, Zalava21) assert model.SFR == "Zalava+21" From fe7d95f602a556caf609a17d508939a5eebdc70e Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 16:44:55 +0100 Subject: [PATCH 24/61] change output fSFR and _call__ dosctring to ndarray --- posydon/popsyn/star_formation_history.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index c16a3eef2e..c3e7937a7b 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -135,7 +135,7 @@ def __call__(self, z, met_bins): Returns ------- - array + ndarray Star formation history per metallicity bin at the given redshift(s). """ return self.CSFRD(z)[:, np.newaxis] * self.fSFR(z, met_bins) @@ -249,7 +249,7 @@ def fSFR(self, z, metallicity_bins): Returns ------- - array + ndarray Fraction of the SFR in the given metallicity bin at the given redshift. """ @@ -428,7 +428,7 @@ def fSFR(self, z, metallicity_bins): Returns ------- - array + ndarray Fraction of the SFR in the given metallicity bins at the given redshift. """ @@ -547,7 +547,7 @@ def fSFR(self, z, metallicity_bins): Returns ------- - array + ndarray Fraction of the SFR in the given metallicity bin at the given redshift. """ # only use data within the metallicity bounds (no lower bound) @@ -706,7 +706,7 @@ def fSFR(self, z, metallicity_bins): Returns ------- - array + ndarray Fraction of the SFR in the given metallicity bin at the given redshift. """ # only use data within the metallicity bounds (no lower bound) From 32779c1c6e145c5ca29a2e2fc058bb30b73bbe45 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Thu, 20 Mar 2025 16:48:50 +0100 Subject: [PATCH 25/61] add check for model parameters required --- posydon/popsyn/star_formation_history.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index c3e7937a7b..42aed1db6f 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -476,6 +476,11 @@ def __init__(self, MODEL): - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. """ + if "Z_max" not in MODEL: + raise ValueError("Z_max not given!") + if "select_one_met" not in MODEL: + raise ValueError("select_one_met not given!") + super().__init__(MODEL) # load the TNG data illustris_data = self._get_illustrisTNG_data() @@ -603,11 +608,18 @@ def __init__(self, MODEL): - GrevesseSauval98 - Villante14 - Z_max : float + The maximum metallicity in absolute units. + - select_one_met : bool + If True, the SFR is calculated for a single metallicity bin. """ if "sub_model" not in MODEL: raise ValueError("Sub-model not given!") if "Z_solar_scaling" not in MODEL: raise ValueError("Z_solar_scaling not given!") + if "Z_max" not in MODEL: + raise ValueError("Z_max not given!") + if "select_one_met" not in MODEL: + raise ValueError("select_one_met not given!") super().__init__(MODEL) self._load_chruslinska_data() @@ -829,6 +841,10 @@ def __init__(self, MODEL): Requires the following parameters: - sub_model : str Either min or max + - Z_max : float + The maximum metallicity in absolute units. + - select_one_met : bool + If True, the SFR is calculated for a single metallicity bin. """ if "sub_model" not in MODEL: raise ValueError("Sub-model not given!") From 7009186a890f1a890069cf16155c6a8aa9b83b9e Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 21 Mar 2025 12:59:26 +0100 Subject: [PATCH 26/61] restructure + integrate only between Z_min and Z_max + add option normalise --- posydon/popsyn/star_formation_history.py | 150 ++++++++++------------- 1 file changed, 65 insertions(+), 85 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 42aed1db6f..7911c4d0cf 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -48,6 +48,10 @@ def __init__(self, MODEL): MODEL : dict Model parameters. """ + self.Z_max = None + self.Z_min = None + self.normalise = False + self.MODEL = MODEL # Automatically attach all model parameters as attributes for key, value in MODEL.items(): @@ -120,6 +124,36 @@ def fSFR(self, z, metallicity_bins): NotImplementedError: If the subclass does not implement this method. """ pass + + def _distribute_cdf(self, cdf_func, metallicity_bins): + '''Distribute the SFR over the metallicity bins using the CDF + + Parameters + ---------- + cdf_func : function + The cumulative density function to use. + metallicity_bins : array + Metallicity bins edges in absolute metallicity. + + Returns + ------- + ndarray + Fraction of the SFR in the given metallicity bin at the given redshift. + ''' + fSFR = (np.array(cdf_func(metallicity_bins[1:])) + - np.array(cdf_func(metallicity_bins[:-1]))) + + # include material outside the metallicity bounds if requested + if self.Z_max is not None: + fSFR[-1] = cdf_func(self.Z_max) - cdf_func(metallicity_bins[-2]) + + if self.Z_min is not None: + fSFR[0] = cdf_func(metallicity_bins[1]) - cdf_func(self.Z_min) + + if self.normalise: + fSFR /= np.sum(fSFR, axis=1)[:, np.newaxis] + + return fSFR def __call__(self, z, met_bins): @@ -162,16 +196,9 @@ def __init__(self, MODEL): - float - Z_max : float The maximum metallicity in absolute units. - - select_one_met : bool - If True, the SFR is calculated for a single metallicity bin. - """ if "sigma" not in MODEL: raise ValueError("sigma not given!") - if "Z_max" not in MODEL: - raise ValueError("Z_max not given!") - if "select_one_met" not in MODEL: - raise ValueError("select_one_met not given!") super().__init__(MODEL) self.CSFRD_params = None @@ -258,29 +285,11 @@ def fSFR(self, z, metallicity_bins): mu = np.log10(self.mean_metallicity(z)) - sigma ** 2 * np.log(10) / 2.0 # Ensure mu is an array for consistency mu_array = np.atleast_1d(mu) - - # Use mu for normalisation - norm = stats.norm.cdf(np.log10(self.Z_max), mu_array, sigma) - - fSFR = np.empty((len(mu_array), len(metallicity_bins) - 1)) - - fSFR[:, :] = np.array( - [ - ( - stats.norm.cdf(np.log10(metallicity_bins[1:]), m, sigma) - - stats.norm.cdf(np.log10(metallicity_bins[:-1]), m, sigma) - ) - for m in mu_array - ] - ) / norm[:, np.newaxis] - - if not self.select_one_met: - fSFR[:, 0] = stats.norm.cdf(np.log10(metallicity_bins[1]), - mu_array, - sigma)/norm - fSFR[:, -1] = 1 - (stats.norm.cdf(np.log10(metallicity_bins[-2]), - mu_array, - sigma)/norm) + + fSFR = np.zeros((len(z), len(metallicity_bins) - 1)) + for i, mean in enumerate(mu_array): + cdf_func = lambda x: stats.norm.cdf(np.log10(x), mean, sigma) + fSFR[i, :] = self._distribute_cdf(cdf_func, metallicity_bins) return fSFR @@ -412,7 +421,6 @@ def mean_metallicity(self, z): """ return 0.035 * 10 ** (-0.23 * z) - # TODO: rewrite such that sigma is just changed for the Neijssel+19 case def fSFR(self, z, metallicity_bins): """Fraction of the SFR at a given redshift z in a given metallicity bin as described in Neijssel et al. (2019). @@ -435,25 +443,11 @@ def fSFR(self, z, metallicity_bins): # assume a truncated ln-normal distribution of metallicities sigma = self.std_log_metallicity_dist() mu = np.log(self.mean_metallicity(z)) - sigma**2 / 2.0 - # renormalisation constant - norm = stats.norm.cdf(np.log(self.Z_max), mu, sigma) - fSFR = np.empty((len(z), len(metallicity_bins) - 1)) - fSFR[:, :] = np.array( - [ - ( - stats.norm.cdf(np.log(metallicity_bins[1:]), m, sigma) - - stats.norm.cdf(np.log(metallicity_bins[:-1]), m, sigma) - ) - for m in mu - ] - ) / norm[:, np.newaxis] - if not self.select_one_met: - fSFR[:, 0] = stats.norm.cdf(np.log(metallicity_bins[1]), - mu, - sigma) / norm - fSFR[:,-1] = 1 - stats.norm.cdf(np.log(metallicity_bins[-2]), - mu, - sigma)/norm + mu_array = np.atleast_1d(mu) + fSFR = np.zeros((len(z), len(metallicity_bins) - 1)) + for i, mean in enumerate(mu_array): + cdf_func = lambda x: stats.norm.cdf(np.log(x), mean, sigma) + fSFR[i,:] = self._distribute_cdf(cdf_func, metallicity_bins) return fSFR class IllustrisTNG(SFHBase): @@ -476,18 +470,20 @@ def __init__(self, MODEL): - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. """ - if "Z_max" not in MODEL: - raise ValueError("Z_max not given!") - if "select_one_met" not in MODEL: - raise ValueError("select_one_met not given!") + + self.Z_max = None + self.Z_min = None + self.normalise = False super().__init__(MODEL) # load the TNG data illustris_data = self._get_illustrisTNG_data() - self.CSFRD_data = illustris_data["SFR"] - self.redshifts = illustris_data["redshifts"] + # the data is stored in reverse order high to low redshift + self.CSFRD_data = np.flip(illustris_data["SFR"]) + self.redshifts = np.flip(illustris_data["redshifts"]) + self.Z = illustris_data["mets"] - self.M = illustris_data["M"] # Msun + self.M = np.flip(illustris_data["M"], axis=0) # Msun def _get_illustrisTNG_data(self, verbose=False): """Load IllustrisTNG SFR dataset into the class. @@ -556,30 +552,24 @@ def fSFR(self, z, metallicity_bins): Fraction of the SFR in the given metallicity bin at the given redshift. """ # only use data within the metallicity bounds (no lower bound) - Z_max_mask = self.Z <= self.Z_max - redshift_indices = np.array([np.where(self.redshifts <= i)[0][0] for i in z]) - Z_dist = self.M[:, Z_max_mask][redshift_indices] + redshift_indices = np.array([np.where(self.redshifts <= i)[0][-1] for i in z]) + Z_dist = self.M[redshift_indices] fSFR = np.zeros((len(z), len(metallicity_bins) - 1)) - for i in range(len(z)): if Z_dist[i].sum() == 0.0: continue else: + # At a specific redshift, the SFR is distributed over the metallicities + # according to the mass distribution + # Add a final point to the CDF and metallicities to ensure normalisation to 1 Z_dist_cdf = np.cumsum(Z_dist[i]) / Z_dist[i].sum() Z_dist_cdf = np.append(Z_dist_cdf, 1) - Z_x_values = np.append(np.log10(self.Z[Z_max_mask]), 0) + + Z_x_values = np.append(np.log10(self.Z), 0) Z_dist_cdf_interp = interp1d(Z_x_values, Z_dist_cdf) - - fSFR[i, :] = (Z_dist_cdf_interp(np.log10(metallicity_bins[1:])) - - Z_dist_cdf_interp(np.log10(metallicity_bins[:-1]))) - - if not self.select_one_met: - if len(metallicity_bins) == 2: - fSFR[i, 0] = 1 - else: - fSFR[i, 0] = Z_dist_cdf_interp(np.log10(metallicity_bins[1])) - fSFR[i, -1] = 1 - Z_dist_cdf_interp(np.log10(metallicity_bins[-2])) + cdf_fun = lambda x: Z_dist_cdf_interp(np.log10(x)) + fSFR[i, :] = self._distribute_cdf(cdf_fun, metallicity_bins) return fSFR @@ -731,22 +721,12 @@ def fSFR(self, z, metallicity_bins): if Z_dist[i].sum() == 0.0: continue else: - # Add a final point to the CDF and metallicities to ensure normalisation to 1 Z_dist_cdf = np.cumsum(Z_dist[i]) / Z_dist[i].sum() Z_dist_cdf = np.append(Z_dist_cdf, 1) - Z_x_values = np.append(np.log10(self.Z[Z_max_mask]), 0) + Z_x_values = np.append(np.log10(self.Z), 0) Z_dist_cdf_interp = interp1d(Z_x_values, Z_dist_cdf) - - fSFR[i, :] = (Z_dist_cdf_interp(np.log10(metallicity_bins[1:])) - - Z_dist_cdf_interp(np.log10(metallicity_bins[:-1]))) - - if not self.select_one_met: - if len(metallicity_bins) == 2: - fSFR[i, 0] = 1 - else: - fSFR[i, 0] = Z_dist_cdf_interp(np.log10(metallicity_bins[1])) - fSFR[i, -1] = 1 - Z_dist_cdf_interp(np.log10(metallicity_bins[-2])) - + cdf_fun = lambda x: Z_dist_cdf_interp(np.log10(x)) + fSFR[i, :] = self._distribute_cdf(cdf_fun, metallicity_bins) return fSFR def _load_redshift_data(self, verbose=False): From c52c7611a53153351bf52fce5eaa71d9a61fcc68 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 21 Mar 2025 15:00:20 +0100 Subject: [PATCH 27/61] Z_max check --- posydon/popsyn/star_formation_history.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 7911c4d0cf..0e697aff7e 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -56,6 +56,11 @@ def __init__(self, MODEL): # Automatically attach all model parameters as attributes for key, value in MODEL.items(): setattr(self, key, value) + + # check if Z_max is not larger than 1 + if self.Z_max is not None: + if self.Z_max > 1: + raise ValueError("Z_max must be in absolute units!") @abstractmethod def CSFRD(self, z): @@ -151,7 +156,7 @@ def _distribute_cdf(self, cdf_func, metallicity_bins): fSFR[0] = cdf_func(metallicity_bins[1]) - cdf_func(self.Z_min) if self.normalise: - fSFR /= np.sum(fSFR, axis=1)[:, np.newaxis] + fSFR /= np.sum(fSFR) return fSFR @@ -551,7 +556,6 @@ def fSFR(self, z, metallicity_bins): ndarray Fraction of the SFR in the given metallicity bin at the given redshift. """ - # only use data within the metallicity bounds (no lower bound) redshift_indices = np.array([np.where(self.redshifts <= i)[0][-1] for i in z]) Z_dist = self.M[redshift_indices] fSFR = np.zeros((len(z), len(metallicity_bins) - 1)) @@ -561,7 +565,6 @@ def fSFR(self, z, metallicity_bins): else: # At a specific redshift, the SFR is distributed over the metallicities # according to the mass distribution - # Add a final point to the CDF and metallicities to ensure normalisation to 1 Z_dist_cdf = np.cumsum(Z_dist[i]) / Z_dist[i].sum() Z_dist_cdf = np.append(Z_dist_cdf, 1) From 92b252572766ad5d1d96e2e912323ca574d1a6e8 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 21 Mar 2025 21:08:33 +0100 Subject: [PATCH 28/61] update the SFH to use Z_min and Z_max and normalise. Move the splitting per metallicity bin to its own function. Additionally, revamp the unit tests to be more in style with the other unit tests and adapt them for the new functions and treatment of the metallicity bins. --- posydon/popsyn/star_formation_history.py | 28 +- .../popsyn/test_star_formation_history.py | 1066 ++++++++++++++--- 2 files changed, 887 insertions(+), 207 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 0e697aff7e..6290eeecda 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -61,6 +61,13 @@ def __init__(self, MODEL): if self.Z_max is not None: if self.Z_max > 1: raise ValueError("Z_max must be in absolute units!") + if self.Z_min is not None: + if self.Z_min < 0: + raise ValueError("Z_min must be in absolute units!") + if self.Z_min is not None and self.Z_max is not None: + if self.Z_min >= self.Z_max: + raise ValueError("Z_min must be smaller than Z_max!") + @abstractmethod def CSFRD(self, z): @@ -150,10 +157,18 @@ def _distribute_cdf(self, cdf_func, metallicity_bins): # include material outside the metallicity bounds if requested if self.Z_max is not None: - fSFR[-1] = cdf_func(self.Z_max) - cdf_func(metallicity_bins[-2]) + if self.Z_max >= metallicity_bins[-1]: + fSFR[-1] = cdf_func(self.Z_max) - cdf_func(metallicity_bins[-2]) + else: + print("Warning: Z_max is smaller than the highest metallicity bin.") + fSFR[-1] = 0.0 if self.Z_min is not None: - fSFR[0] = cdf_func(metallicity_bins[1]) - cdf_func(self.Z_min) + if self.Z_min <= metallicity_bins[0]: + fSFR[0] = cdf_func(metallicity_bins[1]) - cdf_func(self.Z_min) + else: + print("Warning: Z_min is larger than the lowest metallicity bin.") + fSFR[0] = 0.0 if self.normalise: fSFR /= np.sum(fSFR) @@ -611,9 +626,6 @@ def __init__(self, MODEL): raise ValueError("Z_solar_scaling not given!") if "Z_max" not in MODEL: raise ValueError("Z_max not given!") - if "select_one_met" not in MODEL: - raise ValueError("select_one_met not given!") - super().__init__(MODEL) self._load_chruslinska_data() @@ -715,9 +727,8 @@ def fSFR(self, z, metallicity_bins): Fraction of the SFR in the given metallicity bin at the given redshift. """ # only use data within the metallicity bounds (no lower bound) - Z_max_mask = self.Z <= self.Z_max redshift_indices = np.array([np.where(self.redshifts <= i)[0][0] for i in z]) - Z_dist = self.SFR_data[:, Z_max_mask][redshift_indices] + Z_dist = self.SFR_data[redshift_indices] fSFR = np.zeros((len(z), len(metallicity_bins) - 1)) for i in range(len(z)): @@ -727,6 +738,8 @@ def fSFR(self, z, metallicity_bins): Z_dist_cdf = np.cumsum(Z_dist[i]) / Z_dist[i].sum() Z_dist_cdf = np.append(Z_dist_cdf, 1) Z_x_values = np.append(np.log10(self.Z), 0) + print(Z_x_values.shape) + print(Z_dist_cdf.shape) Z_dist_cdf_interp = interp1d(Z_x_values, Z_dist_cdf) cdf_fun = lambda x: Z_dist_cdf_interp(np.log10(x)) fSFR[i, :] = self._distribute_cdf(cdf_fun, metallicity_bins) @@ -910,7 +923,6 @@ def SFR_per_met_at_z(z, met_bins, MODEL): SFH = get_SFH_model(MODEL) return SFH(z, met_bins) - def get_formation_times(N_binaries, star_formation="constant", **kwargs): """Get formation times of binaries in a population based on a SFH scenario. diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 00eeccda27..ccd1b1721e 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -5,6 +5,7 @@ MadauDickinson14, MadauFragos17, Neijssel19, + Fujimoto24, IllustrisTNG, Chruslinska21, Zalava21, @@ -12,224 +13,891 @@ ) -# Replace duplicate DummySFH definitions with a single merged class -class DummySFH(MadauBase): - def CSFRD(self, z): - if self.MODEL.get("dummy_mode", "std") == "call": - return np.full_like(z, 2.0, dtype=float) - else: - return np.ones_like(z) - - def mean_metallicity(self, z): - return np.ones_like(z) - - def fSFR(self, z, metallicity_bins): - n_z = len(z) - n_bins = len(metallicity_bins) - 1 - if self.MODEL.get("dummy_mode", "std") == "call": - # Return normalized ones for __call__ test - raw = np.ones((n_z, n_bins)) - return raw / np.sum(raw, axis=1, keepdims=True) - else: - return np.ones((n_z, n_bins)) - -# Additional tests for the std_log_metallicity_dist function -class TestStdLogMetallicityDist: - def test_sigma_bavera(self): - # Test with sigma as "Bavera+20" which should return 0.5 - model = {"sigma": "Bavera+20", "Z_max": 0.03, "select_one_met": False} - dummy = DummySFH(model) - assert dummy.std_log_metallicity_dist() == 0.5 - - def test_sigma_neijssel(self): - # Test with sigma as "Neijssel+19" which should return 0.39 - model = {"sigma": "Neijssel+19", "Z_max": 0.03, "select_one_met": False} - dummy = DummySFH(model) - assert dummy.std_log_metallicity_dist() == 0.39 +class TestSFHBase: + def test_init_attributes(self): + """Test that the initialization sets attributes correctly.""" + # Create a concrete subclass for testing + class ConcreteSFH(SFHBase): + def CSFRD(self, z): + return z + + def mean_metallicity(self, z): + return z + + def fSFR(self, z, metallicity_bins): + return np.ones((len(z), len(metallicity_bins)-1)) + + model_dict = { + "test_param": 42, + "Z_max": 0.03, + "another_param": "test" + } + sfh = ConcreteSFH(model_dict) + + # Check that attributes are set correctly + assert sfh.Z_max == 0.03 + assert sfh.test_param == 42 + assert sfh.another_param == "test" + assert sfh.MODEL == model_dict + + def test_validation(self): + """Test that Z_max > 1 raises a ValueError.""" + class ConcreteSFH(SFHBase): + def CSFRD(self, z): + pass + def mean_metallicity(self, z): + pass + def fSFR(self, z, metallicity_bins): + pass + + model_dict = {"Z_max": 1.5} + with pytest.raises(ValueError) as excinfo: + ConcreteSFH(model_dict) + assert "Z_max must be in absolute units!" in str(excinfo.value) + + # Test with Z_min > Z_max + model_dict = {"Z_max": 0.1, "Z_min": 0.2} + with pytest.raises(ValueError) as excinfo: + sfh = ConcreteSFH(model_dict) + assert "Z_min must be smaller than Z_max!" in str(excinfo.value) + + # Test with Z_min < 0 + model_dict = {"Z_max": 0.1, "Z_min": -0.1} + with pytest.raises(ValueError) as excinfo: + sfh = ConcreteSFH(model_dict) + assert "Z_min must be in absolute units!" in str(excinfo.value) + + def test_abstract_methods(self): + """Test that abstract methods must be implemented.""" + # Create incomplete subclasses that don't implement all abstract methods + class IncompleteSFH1(SFHBase): + def CSFRD(self, z): + return z + + class IncompleteSFH2(SFHBase): + def CSFRD(self, z): + return z + def mean_metallicity(self, z): + return z + + model_dict = {"Z_max": 0.03} + with pytest.raises(TypeError): + IncompleteSFH1(model_dict) + + with pytest.raises(TypeError): + IncompleteSFH2(model_dict) + + def test_distribute_cdf(self): + """Test the _distribute_cdf method.""" + class ConcreteSFH(SFHBase): + def CSFRD(self, z): + return z + def mean_metallicity(self, z): + return z + def fSFR(self, z, metallicity_bins): + return np.ones((len(z), len(metallicity_bins)-1)) + + model_dict = {"Z_max": 1, "Z_min": 0.0} + sfh = ConcreteSFH(model_dict) + + # Create a simple CDF functions + cdf_func = lambda x: x + met_edges = np.array([0.0, 0.01, 0.02, 0.03]) + + result = sfh._distribute_cdf(cdf_func, met_edges) + expected = np.array([0.01, 0.01, 0.98]) + np.testing.assert_allclose(result, expected) + + # Test with normalization + sfh.normalise = True + result = sfh._distribute_cdf(cdf_func, met_edges) + np.testing.assert_allclose(np.sum(result), 1.0) + + # Test with different model dicts + model_dict = {"Z_max": 1, "Z_min": 0.015} + sfh = ConcreteSFH(model_dict) + met_edges = np.array([0.3, 0.6, 0.9]) + result = sfh._distribute_cdf(cdf_func, met_edges) + expected = np.array([0.585, 0.4]) + np.testing.assert_allclose(result, expected) + + # Test with normalise + sfh.normalise = True + result = sfh._distribute_cdf(cdf_func, met_edges) + np.testing.assert_allclose(np.sum(result), 1.0) + + # restrict the upper bound + model_dict = {"Z_max": 0.95, "Z_min": 0.2} + sfh = ConcreteSFH(model_dict) + met_edges = np.array([0.3, 0.6, 0.9]) + result = sfh._distribute_cdf(cdf_func, met_edges) + expected = np.array([0.4, 0.35]) + np.testing.assert_allclose(result, expected) + + # Test with normalise + sfh.normalise = True + result = sfh._distribute_cdf(cdf_func, met_edges) + np.testing.assert_allclose(np.sum(result), 1.0) + + + def test_call_method(self): + """Test the __call__ method.""" + class ConcreteSFH(SFHBase): + def CSFRD(self, z): + return np.array([1.0, 2.0]) + + def mean_metallicity(self, z): + return np.array([0.01, 0.02]) + + def fSFR(self, z, metallicity_bins): + # Return a simple array for testing + return np.array([[0.3, 0.7], [0.4, 0.6]]) + + model_dict = {"Z_max": 0.03} + sfh = ConcreteSFH(model_dict) + + z = np.array([0.5, 1.0]) + met_edges = np.array([0.0, 0.01, 0.03]) + + result = sfh(z, met_edges) + + # Expected: CSFRD(z)[:, np.newaxis] * fSFR(z, met_edges) + expected = np.array([ + [1.0 * 0.3, 1.0 * 0.7], + [2.0 * 0.4, 2.0 * 0.6] + ]) + + np.testing.assert_allclose(result, expected) - def test_sigma_float(self): - # Test with sigma as a float value - sigma_value = 0.45 - model = {"sigma": sigma_value, "Z_max": 0.03, "select_one_met": False} - dummy = DummySFH(model) - assert dummy.std_log_metallicity_dist() == sigma_value - def test_unknown_sigma_string(self): - # Test with an invalid sigma string should raise a ValueError - model = {"sigma": "invalid_sigma", - "Z_max": 0.03, - "select_one_met": False} - dummy = DummySFH(model) +class TestMadauBase: + """Test class for MadauBase""" + + class ConcreteMadau(MadauBase): + """Concrete subclass of MadauBase for testing""" + def __init__(self, MODEL): + super().__init__(MODEL) + self.CSFRD_params = { + "a": 0.01, + "b": 2.6, + "c": 3.2, + "d": 6.2 + } + + def test_init_requires_sigma(self): + """Test that MadauBase requires a sigma parameter""" + model_dict = {"Z_max": 0.03} with pytest.raises(ValueError) as excinfo: - dummy.std_log_metallicity_dist() + self.ConcreteMadau(model_dict) + assert "sigma not given!" in str(excinfo.value) + + def test_init_sets_csfrd_params_to_none(self): + """Test that CSFRD_params is set to None initially and can be set by subclass""" + model_dict = {"sigma": 0.5, "Z_max": 0.03} + madau = self.ConcreteMadau(model_dict) + assert madau.CSFRD_params is not None + assert madau.CSFRD_params["a"] == 0.01 + assert madau.CSFRD_params["b"] == 2.6 + assert madau.CSFRD_params["c"] == 3.2 + assert madau.CSFRD_params["d"] == 6.2 + + def test_std_log_metallicity_dist(self): + """Test the std_log_metallicity_dist method with different sigma values""" + # Test Bavera+20 + model_dict = {"sigma": "Bavera+20", "Z_max": 0.03} + madau = self.ConcreteMadau(model_dict) + assert madau.std_log_metallicity_dist() == 0.5 + + # Test Neijssel+19 + model_dict = {"sigma": "Neijssel+19", "Z_max": 0.03} + madau = self.ConcreteMadau(model_dict) + assert madau.std_log_metallicity_dist() == 0.39 + + # Test float value + model_dict = {"sigma": 0.45, "Z_max": 0.03} + madau = self.ConcreteMadau(model_dict) + assert madau.std_log_metallicity_dist() == 0.45 + + # Test unknown string + model_dict = {"sigma": "unknown", "Z_max": 0.03} + madau = self.ConcreteMadau(model_dict) + with pytest.raises(ValueError) as excinfo: + madau.std_log_metallicity_dist() assert "Unknown sigma choice!" in str(excinfo.value) - def test_invalid_sigma(self): - # Test with an invalid sigma value should raise a ValueError - model = {"sigma": int(1), "Z_max": 0.03, "select_one_met": False} - dummy = DummySFH(model) + # Test invalid type + model_dict = {"sigma": 1, "Z_max": 0.03} + madau = self.ConcreteMadau(model_dict) with pytest.raises(ValueError) as excinfo: - dummy.std_log_metallicity_dist() + madau.std_log_metallicity_dist() assert "Invalid sigma value" in str(excinfo.value) - - + + def test_csfrd(self): + """Test the CSFRD method""" + model_dict = {"sigma": 0.5} + madau = self.ConcreteMadau(model_dict) + + # Test with single value + z = 0.0 + result = madau.CSFRD(z) + expected = 0.01 * 1**2.6 / (1 + (1/3.2)**6.2) # a * (1+z)^b / (1 + ((1+z)/c)^d) with z=0 + np.testing.assert_allclose(result, expected) + + # Test with array of values + z_array = np.array([0.0, 1.0, 2.0]) + result = madau.CSFRD(z_array) + expected = np.array([ + 0.01 * 1**2.6 / (1 + (1/3.2)**6.2), # z=0 + 0.01 * 2**2.6 / (1 + (2/3.2)**6.2), # z=1 + 0.01 * 3**2.6 / (1 + (3/3.2)**6.2) # z=2 + ]) + np.testing.assert_allclose(result, expected) + + def test_mean_metallicity(self): + """Test the mean_metallicity method""" + from posydon.utils.constants import Zsun + + model_dict = {"sigma": 0.5, "Z_max": 0.03} + madau = self.ConcreteMadau(model_dict) + + # Test with single value + z = 0.0 + result = madau.mean_metallicity(z) + expected = 10**(0.153) * Zsun + np.testing.assert_allclose(result, expected) + + # Test with array of values + z_array = np.array([0.0, 1.0, 2.0]) + result = madau.mean_metallicity(z_array) + expected = 10**(0.153 - 0.074 * z_array**1.34) * Zsun + np.testing.assert_allclose(result, expected) + + def test_fsfr(self): + """Test the fSFR method""" + model_dict = {"sigma": 0.5, "Z_max": 1} + madau = self.ConcreteMadau(model_dict) + + # Test with redshift array and metallicity bins + z = np.array([0.0, 1.0]) + met_bins = np.array([0.001, 0.01, 0.02, 0.03]) + + result = madau.fSFR(z, met_bins) + + # Shape check - should be (len(z), len(met_bins)-1) + assert result.shape == (2, 3) + + # THIS IS A VALIDATION TEST + from scipy.stats import norm + mean0, mean1 = (np.log10(madau.mean_metallicity(z)) + - model_dict['sigma']**2 * np.log(10) / 2) + print(mean0, mean1) + + expected1 = np.array( + # integral from 0.001 to 0.01; Z_min = lowest bin edge + [norm.cdf(np.log10(0.01), mean0, model_dict['sigma']) + - norm.cdf(np.log10(0.001), mean0, model_dict['sigma']), + # integral from 0.01 to 0.02 + (norm.cdf(np.log10(0.02), mean0, model_dict['sigma']) + - norm.cdf(np.log10(0.01), mean0, model_dict['sigma'])), + # integral from 0.02 to 1 + (norm.cdf(np.log10(1), mean0, model_dict['sigma']) + - norm.cdf(np.log10(0.02), mean0, model_dict['sigma']))],) + + expected2 = np.array( + # integral from 0.001 to 0.01;Z_min = lowest bin edge + [norm.cdf(np.log10(0.01), mean1, model_dict['sigma']) + - norm.cdf(np.log10(0.001), mean1, model_dict['sigma']), + # integral from 0.01 to 0.02 + (norm.cdf(np.log10(0.02), mean1, model_dict['sigma']) + - norm.cdf(np.log10(0.01), mean1, model_dict['sigma'])), + # integral from 0.02 to 1 + (norm.cdf(np.log10(1), mean1, model_dict['sigma']) + - norm.cdf(np.log10(0.02), mean1, model_dict['sigma']))], + ) + expected = np.array([expected1, expected2]) + np.testing.assert_allclose(result, expected) + + # Change Z_min to 0 to include the rest of the lowest mets + model_dict = {"sigma": 0.5, "Z_max": 0.3, "Z_min": 0} + madau = self.ConcreteMadau(model_dict) + result = madau.fSFR(z, met_bins) + + expected1 = np.array( + [norm.cdf(np.log10(0.01), mean0, model_dict['sigma']), + (norm.cdf(np.log10(0.02), mean0, model_dict['sigma']) + - norm.cdf(np.log10(0.01), mean0, model_dict['sigma'])), + (norm.cdf(np.log10(0.3), mean0, model_dict['sigma']) + - norm.cdf(np.log10(0.02), mean0, model_dict['sigma']))],) + expected2 = np.array( + [norm.cdf(np.log10(0.01), mean1, model_dict['sigma']), + (norm.cdf(np.log10(0.02), mean1, model_dict['sigma']) + - norm.cdf(np.log10(0.01), mean1, model_dict['sigma'])), + (norm.cdf(np.log10(0.3), mean1, model_dict['sigma']) + - norm.cdf(np.log10(0.02), mean1, model_dict['sigma']))],) + expected = np.array([expected1, expected2]) + np.testing.assert_allclose(result, expected) + + # Test with normalise + model_dict = {"sigma": 0.5, "Z_max": 0.3, "Z_min": 0, "normalise": True} + madau = self.ConcreteMadau(model_dict) + result = madau.fSFR(z, met_bins) + print(result) + np.testing.assert_allclose(np.sum(result, axis=1), np.ones(2)) + + # Test with Z_min > 0.03 + model_dict = {"sigma": 0.5, + "Z_max": 0.3, + "Z_min": 0.02, + "normalise": True} + madau = self.ConcreteMadau(model_dict) + result = madau.fSFR(z, met_bins) + expected = np.ones(2) + np.testing.assert_allclose(np.sum(result, axis=1), expected) -class TestfSFR: - @pytest.mark.parametrize("model_class, model_name", [ - (MadauFragos17, "Madau+Fragos17"), - (MadauDickinson14, "Madau+Dickinson14"), - (Neijssel19, "Neijssel+19"), - ]) - def test_fSFR_sum_is_one_empirical(self, model_class, model_name): - # Base MODEL parameters - base_args = { - "SFR": model_name, - "sigma": 0.5, - "Z_max": 0.03, - "select_one_met": False, - } - sfh_instance = model_class(base_args) - # Arbitrary redshift array and metallicity bins - z = np.array([0.001, 0.5, 1.0, 2.0]) - met_bins = np.linspace(0.0001, base_args["Z_max"], 50) - - fSFR = sfh_instance.fSFR(z, met_bins) - # The sum over metallicity bins (axis=1) should be approximately 1 for each redshift - np.testing.assert_allclose(np.sum(fSFR, axis=1), - np.ones(len(z)), - atol=1e-6) - - def test_fSFR_sum_is_one_illustris(self, monkeypatch): - def dummy_get_illustrisTNG_data(self, verbose=False): - return { - "SFR": np.array([1.0, 1.0, 1.0]), - "redshifts": np.array([0.0, 1.0, 2.0]), - "mets": np.linspace(0.001, 0.03, 10), - "M": np.ones((3, 10)), - } - base_args = { - "SFR": "IllustrisTNG", - "sigma": 0.5, - "Z_max": 0.03, - "select_one_met": False, +class TestIllustrisTNG: + """Tests for the IllustrisTNG SFH model with mocked data loading.""" + + @pytest.fixture + def mock_illustris_data(self): + """Create mock data for the IllustrisTNG class.""" + # Create mock data structure similar to the npz file + num_redshifts = 10 + num_metallicities = 5 + + mock_data = { + "SFR": np.linspace(0.1, 1.0, num_redshifts)[::-1], # SFR decreases with redshift + "redshifts": np.linspace(0.0, 9.0, num_redshifts)[::-1], # Redshifts from 0 to 9 + "mets": np.logspace(-4, -1, num_metallicities), # Metallicities from 1e-4 to 1e-1 + "M": np.ones((num_redshifts, num_metallicities)) # Equal mass in all bins for simplicity } - monkeypatch.setattr(IllustrisTNG, - "_get_illustrisTNG_data", - dummy_get_illustrisTNG_data) - sfh_instance = IllustrisTNG(base_args) - z = np.array([0.5, 1.0, 2.0]) - met_bins = np.linspace(0.0001, base_args["Z_max"], 50) - fSFR = sfh_instance.fSFR(z, met_bins) - np.testing.assert_allclose(np.sum(fSFR, axis=1), - np.ones(len(z)), - atol=1e-6) + + # Add some variation to mass distribution for testing mean_metallicity + for i in range(num_redshifts): + # Linear decrease in higher metallicities as redshift increases + scale = 1.0 - i / num_redshifts + mock_data["M"][i] = np.linspace(1.0, scale, num_metallicities) + + mock_data["M"] = np.flip(mock_data["M"], axis=0) # Reverse the mass array + return mock_data + + @pytest.fixture + def illustris_model(self, monkeypatch, mock_illustris_data): + """Create an IllustrisTNG model instance with mocked data.""" + # Create a function that returns the mock data + def mock_get_illustrisTNG_data(self, verbose=False): + return mock_illustris_data + + # Patch the _get_illustrisTNG_data method + monkeypatch.setattr(IllustrisTNG, "_get_illustrisTNG_data", mock_get_illustrisTNG_data) + + # Create and return the model + model_dict = {"Z_max": 0.3} + return IllustrisTNG(model_dict) + + def test_init_parameters(self, illustris_model, mock_illustris_data): + """Test that initialization sets the parameters correctly.""" + # Check that data was loaded correctly + np.testing.assert_array_equal(illustris_model.CSFRD_data, np.flip(mock_illustris_data["SFR"])) + np.testing.assert_array_equal(illustris_model.redshifts, np.flip(mock_illustris_data["redshifts"])) + np.testing.assert_array_equal(illustris_model.Z, mock_illustris_data["mets"]) + np.testing.assert_array_equal(illustris_model.M, np.flip(mock_illustris_data["M"], axis=0)) + + # Check that model parameters were set correctly + assert illustris_model.Z_max == 0.3 + + def test_csfrd_calculation(self, illustris_model, mock_illustris_data): + """Test the CSFRD method.""" + # Test at specific redshifts including boundary values + z_values = np.array([0.0, 4.5, 9.0]) + result = illustris_model.CSFRD(z_values) + + # Expected values come from interpolating flipped SFR data + flipped_sfr = np.flip(mock_illustris_data["SFR"]) + flipped_redshifts = np.flip(mock_illustris_data["redshifts"]) + expected = np.interp(z_values, flipped_redshifts, flipped_sfr) + + np.testing.assert_allclose(result, expected) + + def test_mean_metallicity(self, illustris_model, mock_illustris_data): + """Test the mean_metallicity method.""" + # Test at specific redshifts + z_values = np.array([0.0, 4.5, 9.0]) + result = illustris_model.mean_metallicity(z_values) + + # Calculate expected values manually + expected = np.zeros(len(z_values)) + flipped_redshifts = np.flip(mock_illustris_data["redshifts"]) + flipped_masses = np.flip(mock_illustris_data["M"], axis=0) + metallicities = mock_illustris_data["mets"] + + # Calculate expected mean metallicities at each test redshift + out = np.zeros_like(flipped_redshifts) + for i, z in enumerate(out): + weights = flipped_masses[i, :] + if np.sum(weights) == 0: + out[i] = np.nan + else: + out[i] = np.average(metallicities, weights=weights) + Z_interp = np.interp(z_values, flipped_redshifts, out) + np.testing.assert_allclose(result, Z_interp) + + def test_fsfr_calculation(self, illustris_model): + """Test the fSFR method.""" + # Test with redshift array and metallicity bins + z = np.array([0.0, 4.5]) + met_bins = np.array([0.001, 0.01, 0.05, 0.1]) + + result = illustris_model.fSFR(z, met_bins) + + # Shape check - should be (len(z), len(met_bins)-1) + assert result.shape == (2, 3) + + # Test with normalise=True + illustris_model.normalise = True + result = illustris_model.fSFR(z, met_bins) + for row in result: + if np.sum(row) > 0: + np.testing.assert_allclose(np.sum(row), 1.0) -class TestCallMethod: - def test_call_method_returns_product(self): - # Use DummySFH in 'call' mode to test __call__ - model = {"sigma": 0.5, - "Z_max": 0.03, - "select_one_met": False, - "dummy_mode": "call"} - dummy = DummySFH(model) - z = np.array([0.5, 1.0, 2.0]) - met_bins = np.linspace(0.001, model["Z_max"], 10) - expected = 2 * dummy.fSFR(z, met_bins) - result = dummy(z, met_bins) - np.testing.assert_allclose(result, expected, atol=1e-6) +class TestMadauDickinson14: + """Tests for the MadauDickinson14 SFH model""" + + def test_init_parameters(self): + """Test that initialization sets the correct CSFRD parameters""" + model_dict = {"sigma": 0.5, "Z_max": 0.03} + madau = MadauDickinson14(model_dict) + + # Check that CSFRD_params were set correctly + assert madau.CSFRD_params["a"] == 0.015 + assert madau.CSFRD_params["b"] == 2.7 + assert madau.CSFRD_params["c"] == 2.9 + assert madau.CSFRD_params["d"] == 5.6 + + # Check that it inherits correctly from MadauBase + assert isinstance(madau, MadauBase) + + def test_csfrd_calculation(self): + """Test that CSFRD calculations match expected values""" + model_dict = {"sigma": 0.5, "Z_max": 0.03} + madau = MadauDickinson14(model_dict) + + # Test at specific redshifts + z_values = np.array([0.0, 1.0, 2.0, 6.0]) + result = madau.CSFRD(z_values) + + # Calculate expected values manually + p = madau.CSFRD_params + expected = p["a"] * (1.0 + z_values) ** p["b"] / (1.0 + ((1.0 + z_values) / p["c"]) ** p["d"]) + + np.testing.assert_allclose(result, expected) + +class TestMadauFragos17: + """Tests for the MadauFragos17 SFH model""" + + def test_init_parameters(self): + """Test that initialization sets the correct CSFRD parameters""" + model_dict = {"sigma": 0.5, "Z_max": 0.03} + madau = MadauFragos17(model_dict) + + # Check that CSFRD_params were set correctly + assert madau.CSFRD_params["a"] == 0.01 + assert madau.CSFRD_params["b"] == 2.6 + assert madau.CSFRD_params["c"] == 3.2 + assert madau.CSFRD_params["d"] == 6.2 + + # Check that it inherits correctly from MadauBase + assert isinstance(madau, MadauBase) + + def test_csfrd_calculation(self): + """Test that CSFRD calculations match expected values""" + model_dict = {"sigma": 0.5, "Z_max": 0.03} + madau = MadauFragos17(model_dict) + + # Test at specific redshifts + z_values = np.array([0.0, 1.0, 2.0, 6.0]) + result = madau.CSFRD(z_values) + + # Calculate expected values manually using the formula + p = madau.CSFRD_params + expected = p["a"] * (1.0 + z_values) ** p["b"] / (1.0 + ((1.0 + z_values) / p["c"]) ** p["d"]) + + np.testing.assert_allclose(result, expected) +class TestNeijssel19: + """Tests for the Neijssel19 SFH model""" + + def test_init_parameters(self): + """Test that initialization sets the correct CSFRD parameters""" + model_dict = {"sigma": 0.5, "Z_max": 0.03} + neijssel = Neijssel19(model_dict) + + # Check that CSFRD_params were set correctly + assert neijssel.CSFRD_params["a"] == 0.01 + assert neijssel.CSFRD_params["b"] == 2.77 + assert neijssel.CSFRD_params["c"] == 2.9 + assert neijssel.CSFRD_params["d"] == 4.7 + + # Check that it inherits correctly from MadauBase + assert isinstance(neijssel, MadauBase) + + def test_csfrd_calculation(self): + """Test that CSFRD calculations match expected values""" + model_dict = {"sigma": 0.5, "Z_max": 0.03} + neijssel = Neijssel19(model_dict) + + # Test at specific redshifts + z_values = np.array([0.0, 1.0, 2.0, 6.0]) + result = neijssel.CSFRD(z_values) + + # Calculate expected values manually using the formula + p = neijssel.CSFRD_params + expected = p["a"] * (1.0 + z_values) ** p["b"] / (1.0 + ((1.0 + z_values) / p["c"]) ** p["d"]) + + np.testing.assert_allclose(result, expected) + + def test_mean_metallicity(self): + """Test the overridden mean_metallicity method""" + model_dict = {"sigma": 0.5, "Z_max": 0.03} + neijssel = Neijssel19(model_dict) + + # Test at specific redshifts + z_values = np.array([0.0, 1.0, 2.0, 6.0]) + result = neijssel.mean_metallicity(z_values) + + # Calculate expected values based on Neijssel19's formula + expected = 0.035 * 10 ** (-0.23 * z_values) + + np.testing.assert_allclose(result, expected) + + def test_fsfr_with_lognormal(self): + """Test the overridden fSFR method which uses a ln-normal distribution""" + model_dict = {"sigma": 0.5, "Z_max": 0.3} + neijssel = Neijssel19(model_dict) + + # Test with redshift array and metallicity bins + z = np.array([0.0, 1.0]) + met_bins = np.array([0.001, 0.01, 0.02, 0.03]) + + result = neijssel.fSFR(z, met_bins) + + # Shape check - should be (len(z), len(met_bins)-1) + assert result.shape == (2, 3) + + # Test normalization with normalise=True + model_dict = {"sigma": 0.5, "Z_max": 0.3, "normalise": True} + neijssel = Neijssel19(model_dict) + result = neijssel.fSFR(z, met_bins) + np.testing.assert_allclose(np.sum(result, axis=1), np.ones(2)) -class TestGetSFHModel: - @pytest.mark.parametrize("model_name, model_class", [ - ("Madau+Fragos17", MadauFragos17), - ("Madau+Dickinson14", MadauDickinson14), - ("Neijssel+19", Neijssel19), - ]) - def test_get_model_empirical(self, model_name, model_class): - base_args = { - "SFR": model_name, - "sigma": 0.5, - "Z_max": 0.03, - "select_one_met": False, - } - model = get_SFH_model(base_args) - assert isinstance(model, model_class) +class TestFujimoto24: + """Tests for the Fujimoto24 SFH model""" + + def test_init_parameters(self): + """Test that initialization sets the correct CSFRD parameters""" + model_dict = {"sigma": 0.5, "Z_max": 0.03} + fujimoto = Fujimoto24(model_dict) + + # Check that CSFRD_params were set correctly + assert fujimoto.CSFRD_params["a"] == 0.010 + assert fujimoto.CSFRD_params["b"] == 2.8 + assert fujimoto.CSFRD_params["c"] == 3.3 + assert fujimoto.CSFRD_params["d"] == 6.6 + + # Check that it inherits correctly from MadauBase + assert isinstance(fujimoto, MadauBase) + + def test_csfrd_calculation(self): + """Test that CSFRD calculations match expected values""" + model_dict = {"sigma": 0.5, "Z_max": 0.03} + fujimoto = Fujimoto24(model_dict) + + # Test at specific redshifts + z_values = np.array([0.0, 1.0, 2.0, 6.0]) + result = fujimoto.CSFRD(z_values) + + # Calculate expected values manually using the formula + p = fujimoto.CSFRD_params + expected = p["a"] * (1.0 + z_values) ** p["b"] / (1.0 + ((1.0 + z_values) / p["c"]) ** p["d"]) + + np.testing.assert_allclose(result, expected) - def test_get_model_illustris(self, monkeypatch): - base_args = { - "SFR": "IllustrisTNG", - "sigma": 0.5, - "Z_max": 0.03, - "select_one_met": False, +class TestChruslinska21: + """Tests for the Chruslinska21 SFH model with mocked data loading.""" + + @pytest.fixture + def mock_chruslinska_data(self, monkeypatch): + """Create mock data for the Chruslinska21 class.""" + # Create mock data for FOH bins + FOH_bins = np.linspace(5.3, 9.7, 200) + dFOH = FOH_bins[1] - FOH_bins[0] + redshifts = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + delta_T = np.array([1e9, 1e9, 1e9, 1e9, 1e9, 1e9]) # Time bin widths + + # Mock SFR data - decreasing with redshift, varying with metallicity + SFR_data = np.zeros((len(redshifts), len(FOH_bins))) + for i in range(len(redshifts)): + # Simple pattern: peak at middle metallicity, decreasing with redshift + peak_idx = len(FOH_bins) // 2 + SFR_data[i] = np.exp(-0.5 * ((np.arange(len(FOH_bins)) - peak_idx) / 20)**2) + SFR_data[i] *= np.exp(-redshifts[i] / 2) # Decrease with redshift + + # Create method that returns tuple of time, redshift, deltaT + def mock_load_redshift_data(self, verbose=False): + time = np.array([1e9, 2e9, 3e9, 4e9, 5e9, 6e9]) # Fake times + return time, redshifts, delta_T + + # Create method that returns the mock SFR data + def mock_load_raw_data(self): + return SFR_data * 1e6 * delta_T[:, np.newaxis] + + # Patch the methods + monkeypatch.setattr(Chruslinska21, "_load_redshift_data", mock_load_redshift_data) + monkeypatch.setattr(Chruslinska21, "_load_raw_data", mock_load_raw_data) + + return { + "FOH_bins": FOH_bins, + "dFOH": dFOH, + "redshifts": redshifts, + "SFR_data": SFR_data } - # Override _get_illustrisTNG_data to avoid file I/O during tests - def dummy_get_illustrisTNG_data(self, verbose=False): - return { - "SFR": np.array([1.0]), - "redshifts": np.array([0.0]), - "mets": np.linspace(0.001, 0.03, 10), - "M": np.ones((1, 10)), - } - monkeypatch.setattr(IllustrisTNG, - "_get_illustrisTNG_data", - dummy_get_illustrisTNG_data) - model = get_SFH_model(base_args) - assert isinstance(model, IllustrisTNG) - - def test_get_model_chruslinska(self, monkeypatch): - base_args = { - "SFR": "Chruslinska+21", - "sigma": 0.5, - "Z_max": 0.03, - "select_one_met": False, - "sub_model": "dummy", + + @pytest.fixture + def chruslinska_model(self, mock_chruslinska_data, monkeypatch): + """Create a Chruslinska21 model instance with mocked data.""" + model_dict = { + "sub_model": "test_model", "Z_solar_scaling": "Asplund09", - } - # Override _load_chruslinska_data to avoid file I/O during tests - monkeypatch.setattr(Chruslinska21, - "_load_chruslinska_data", - lambda self, verbose=False: None) - model = get_SFH_model(base_args) - assert isinstance(model, Chruslinska21) - assert model.SFR == "Chruslinska+21" - - def test_get_model_zalava(self, monkeypatch): - base_args = { - "SFR": "Zalava+21", - "sigma": 0.5, "Z_max": 0.03, - "select_one_met": False, - "sub_model": "dummy", - "Z_solar_scaling": "Asplund09", + "select_one_met": False } - # Override _load_zalava_data to avoid file I/O during tests - monkeypatch.setattr(Zalava21, - "_load_zalava_data", - lambda self, - verbose=False: None) - model = get_SFH_model(base_args) - assert isinstance(model, Zalava21) - assert model.SFR == "Zalava+21" - - def test_get_fojimoto_model(self): - base_args = { - "SFR": "Fujimoto+24", - "sigma": 0.5, + return Chruslinska21(model_dict) + + def test_init_parameters(self): + """Test that initialization validates required parameters.""" + # Test missing sub_model + with pytest.raises(ValueError) as excinfo: + Chruslinska21({"Z_solar_scaling": "Asplund09", "Z_max": 0.03, "select_one_met": False}) + assert "Sub-model not given!" in str(excinfo.value) + + # Test missing Z_solar_scaling + with pytest.raises(ValueError) as excinfo: + Chruslinska21({"sub_model": "test", "Z_max": 0.03, "select_one_met": False}) + assert "Z_solar_scaling not given!" in str(excinfo.value) + + # Test missing Z_max + with pytest.raises(ValueError) as excinfo: + Chruslinska21({"sub_model": "test", "Z_solar_scaling": "Asplund09", "select_one_met": False}) + assert "Z_max not given!" in str(excinfo.value) + + def test_foh_to_z_conversion(self, chruslinska_model): + """Test the _FOH_to_Z method for all scaling options.""" + # Test Asplund09 scaling + FOH_test = np.array([7.0, 8.0, 8.69, 9.0]) + result = chruslinska_model._FOH_to_Z(FOH_test) + + # Expected: 10^(log10(0.0134) + FOH - 8.69) + Zsun = 0.0134 + FOHsun = 8.69 + expected = 10**(np.log10(Zsun) + FOH_test - FOHsun) + np.testing.assert_allclose(result, expected) + + # Test other scaling options + model_dict = { + "sub_model": "test_model", + "Z_solar_scaling": "AndersGrevesse89", "Z_max": 0.03, - "select_one_met": False, + "select_one_met": False } - model = get_SFH_model(base_args) - assert isinstance(model, SFHBase) - assert model.MODEL["SFR"] == "Fujimoto+24" - assert model.SFR == "Fujimoto+24" + model = Chruslinska21(model_dict) + result = model._FOH_to_Z(FOH_test) + expected = 10**(np.log10(0.017) + FOH_test - 8.83) + np.testing.assert_allclose(result, expected) + + # Test GrevesseSauval98 scaling + model_dict["Z_solar_scaling"] = "GrevesseSauval98" + model = Chruslinska21(model_dict) + result = model._FOH_to_Z(FOH_test) + expected = 10**(np.log10(0.0201) + FOH_test - 8.93) + np.testing.assert_allclose(result, expected) + + # Test Villante14 scaling + model_dict["Z_solar_scaling"] = "Villante14" + model = Chruslinska21(model_dict) + result = model._FOH_to_Z(FOH_test) + expected = 10**(np.log10(0.019) + FOH_test - 8.85) + np.testing.assert_allclose(result, expected) + + # Test invalid scaling + model_dict["Z_solar_scaling"] = "InvalidScaling" + with pytest.raises(ValueError) as excinfo: + model = Chruslinska21(model_dict) + assert "Invalid Z_solar_scaling!" in str(excinfo.value) + + def test_mean_metallicity(self, chruslinska_model, mock_chruslinska_data): + """Test the mean_metallicity method.""" + # Test at specific redshifts + z_values = np.array([0.0, 2.0, 4.0]) + result = chruslinska_model.mean_metallicity(z_values) + print(result) + # Should be an array of the same length as z_values + assert len(result) == len(z_values) + # Should be the same value at all redshifts + assert np.isclose(result[0], result[1]) + assert np.isclose(result[0], result[2]) + assert np.isclose(result[0], 0.0014903210118641882) + + def test_csfrd_calculation(self, chruslinska_model, mock_chruslinska_data): + """Test the CSFRD method.""" + # Test at specific redshifts + z_values = np.array([0.0, 2.0, 4.0]) + result = chruslinska_model.CSFRD(z_values) + + # Should be an array of the same length as z_values + assert len(result) == len(z_values) + + # Should decrease with increasing redshift + assert result[0] > result[1] > result[2] + + def test_fsfr_calculation(self, chruslinska_model): + """Test the fSFR method.""" + # Test with redshift array and metallicity bins + z = np.array([0.0, 2.0]) + met_bins = np.array([0.001, 0.01, 0.02, 0.03]) + + result = chruslinska_model.fSFR(z, met_bins) + # Shape check - should be (len(z), len(met_bins)-1) + assert result.shape == (2, 3) + + # Test with normalization + chruslinska_model.normalise = True + result = chruslinska_model.fSFR(z, met_bins) + for row in result: + if np.sum(row) > 0: + np.testing.assert_allclose(np.sum(row), 1.0) - def test_invalid_SFR(self): - base_args = { - "SFR": "InvalidSFR", - "sigma": 0.5, - "Z_max": 0.03, - "select_one_met": False, +class TestZalava21: + """Tests for the Zalava21 SFH model with mocked data loading.""" + + @pytest.fixture + def mock_zalava_data(self, monkeypatch): + """Create mock data for the Zalava21 class.""" + # Create mock data - simple decreasing function with redshift + redshifts = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]) + SFRD_min = 0.1 * np.exp(-redshifts / 3.0) # Simple declining function + SFRD_max = 0.2 * np.exp(-redshifts / 3.0) # Double the min values + + # Create a mock _load_zalava_data method + def mock_load_zalava(self): + self.redshifts = redshifts + if self.sub_model == "min": + self.SFR_data = SFRD_min + elif self.sub_model == "max": + self.SFR_data = SFRD_max + else: + raise ValueError("Invalid sub-model!") + + # Patch the method + monkeypatch.setattr(Zalava21, "_load_zalava_data", mock_load_zalava) + + return { + "redshifts": redshifts, + "SFRD_min": SFRD_min, + "SFRD_max": SFRD_max } + + def test_init_parameters(self, mock_zalava_data): + """Test that initialization validates and sets parameters correctly.""" + # Test missing sub_model + with pytest.raises(ValueError) as excinfo: + Zalava21({"Z_max": 0.03, "sigma": 0.5}) + assert "Sub-model not given!" in str(excinfo.value) + + # Test valid initialization with min model + model_dict = {"sub_model": "min", "Z_max": 0.03, "sigma": 0.5} + zalava_min = Zalava21(model_dict) + assert zalava_min.sub_model == "min" + assert zalava_min.Z_max == 0.03 + assert zalava_min.sigma == 0.5 + + # Test valid initialization with max model + model_dict = {"sub_model": "max", "Z_max": 0.03, "sigma": 0.5} + zalava_max = Zalava21(model_dict) + assert zalava_max.sub_model == "max" + + # Test invalid sub_model + model_dict = {"sub_model": "invalid", "Z_max": 0.03, "sigma": 0.5} with pytest.raises(ValueError) as excinfo: - get_SFH_model(base_args) - assert "Invalid SFR!" in str(excinfo.value) + zalava_invalid = Zalava21(model_dict) + assert "Invalid sub-model!" in str(excinfo.value) + + def test_csfrd_min_model(self, mock_zalava_data): + """Test the CSFRD method with min sub-model.""" + model_dict = {"sub_model": "min", "Z_max": 0.03, "sigma": 0.5} + zalava = Zalava21(model_dict) + + # Test at specific redshifts + z_values = np.array([0.0, 2.0, 4.0, 6.0]) + result = zalava.CSFRD(z_values) + + # Expected values come from interpolating the mock data + expected = 0.1 * np.exp(-z_values / 3.0) + np.testing.assert_allclose(result, expected) + + def test_csfrd_max_model(self, mock_zalava_data): + """Test the CSFRD method with max sub-model.""" + model_dict = {"sub_model": "max", "Z_max": 0.03, "sigma": 0.5} + zalava = Zalava21(model_dict) + + # Test at specific redshifts + z_values = np.array([0.0, 2.0, 4.0, 6.0]) + result = zalava.CSFRD(z_values) + + # Expected values come from interpolating the mock data + expected = 0.2 * np.exp(-z_values / 3.0) + np.testing.assert_allclose(result, expected) + + def test_fsfr_calculation(self, mock_zalava_data): + """Test the fSFR method which is inherited from MadauBase.""" + model_dict = {"sub_model": "min", "Z_max": 0.03, "sigma": 0.5} + zalava = Zalava21(model_dict) + + # Test with redshift array and metallicity bins + z = np.array([0.0, 2.0]) + met_bins = np.array([0.001, 0.01, 0.02, 0.03]) + + result = zalava.fSFR(z, met_bins) + + # Shape check - should be (len(z), len(met_bins)-1) + assert result.shape == (2, 3) + + # Test normalization + zalava.normalise = True + result = zalava.fSFR(z, met_bins) + for row in result: + np.testing.assert_allclose(np.sum(row), 1.0) + +class TestGetSFHModel: + """Tests for the get_SFH_model function.""" + + def test_returns_correct_instance(self): + """Test that get_SFH_model returns the correct instance for each model.""" + # Test for MadauDickinson14 + model_dict = {"SFR": "Madau+Dickinson14", "sigma": 0.5, "Z_max": 0.03} + model = get_SFH_model(model_dict) + assert isinstance(model, MadauDickinson14) + + # Test for MadauFragos17 + model_dict = {"SFR": "Madau+Fragos17", "sigma": 0.5, "Z_max": 0.03} + model = get_SFH_model(model_dict) + assert isinstance(model, MadauFragos17) + + # Test for Neijssel19 + model_dict = {"SFR": "Neijssel+19", "sigma": 0.5, "Z_max": 0.03} + model = get_SFH_model(model_dict) + assert isinstance(model, Neijssel19) + + # Test for Fujimoto24 + model_dict = {"SFR": "Fujimoto+24", "sigma": 0.5, "Z_max": 0.03} + model = get_SFH_model(model_dict) + assert isinstance(model, Fujimoto24) + \ No newline at end of file From 6671365ffea19ee0a4ffa323c89beb2950488c45 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 21 Mar 2025 21:20:18 +0100 Subject: [PATCH 29/61] fix name typo --- posydon/popsyn/star_formation_history.py | 16 +++--- .../popsyn/test_star_formation_history.py | 56 +++++++++---------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 6290eeecda..9072d8ef17 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -829,10 +829,10 @@ def __init__(self, MODEL): "d": 6.6, } -class Zalava21(MadauBase): +class Zavala21(MadauBase): def __init__(self, MODEL): - """Initialise the Zalava+21 model + """Initialise the Zavala+21 model Requires the following parameters: - sub_model : str @@ -846,14 +846,14 @@ def __init__(self, MODEL): raise ValueError("Sub-model not given!") super().__init__(MODEL) - self._load_zalava_data() + self._load_zavala_data() - def _load_zalava_data(self): - """Load the data from the Zalava+21 models + def _load_zavala_data(self): + """Load the data from the Zavala+21 models Transforms the data to the format used in the classes. """ - data_file = os.path.join(PATH_TO_POSYDON_DATA, "SFR/Zalava+21.txt") + data_file = os.path.join(PATH_TO_POSYDON_DATA, "SFR/Zavala+21.txt") tmp_data = pd.read_csv(data_file, names=["redshift", "SFRD_min", "SFRD_max"], skiprows=1, @@ -897,8 +897,8 @@ def get_SFH_model(MODEL): return IllustrisTNG(MODEL) elif MODEL["SFR"] == "Chruslinska+21": return Chruslinska21(MODEL) - elif MODEL["SFR"] == "Zalava+21": - return Zalava21(MODEL) + elif MODEL["SFR"] == "Zavala+21": + return Zavala21(MODEL) else: raise ValueError("Invalid SFR!") diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index ccd1b1721e..3343bc0b44 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -8,7 +8,7 @@ Fujimoto24, IllustrisTNG, Chruslinska21, - Zalava21, + Zavala21, get_SFH_model ) @@ -775,19 +775,19 @@ def test_fsfr_calculation(self, chruslinska_model): if np.sum(row) > 0: np.testing.assert_allclose(np.sum(row), 1.0) -class TestZalava21: - """Tests for the Zalava21 SFH model with mocked data loading.""" +class TestZavala21: + """Tests for the Zavala21 SFH model with mocked data loading.""" @pytest.fixture - def mock_zalava_data(self, monkeypatch): - """Create mock data for the Zalava21 class.""" + def mock_zavala_data(self, monkeypatch): + """Create mock data for the Zavala21 class.""" # Create mock data - simple decreasing function with redshift redshifts = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]) SFRD_min = 0.1 * np.exp(-redshifts / 3.0) # Simple declining function SFRD_max = 0.2 * np.exp(-redshifts / 3.0) # Double the min values - # Create a mock _load_zalava_data method - def mock_load_zalava(self): + # Create a mock _load_zavala_data method + def mock_load_zavala(self): self.redshifts = redshifts if self.sub_model == "min": self.SFR_data = SFRD_min @@ -797,7 +797,7 @@ def mock_load_zalava(self): raise ValueError("Invalid sub-model!") # Patch the method - monkeypatch.setattr(Zalava21, "_load_zalava_data", mock_load_zalava) + monkeypatch.setattr(Zavala21, "_load_zavala_data", mock_load_zavala) return { "redshifts": redshifts, @@ -805,74 +805,74 @@ def mock_load_zalava(self): "SFRD_max": SFRD_max } - def test_init_parameters(self, mock_zalava_data): + def test_init_parameters(self, mock_zavala_data): """Test that initialization validates and sets parameters correctly.""" # Test missing sub_model with pytest.raises(ValueError) as excinfo: - Zalava21({"Z_max": 0.03, "sigma": 0.5}) + Zavala21({"Z_max": 0.03, "sigma": 0.5}) assert "Sub-model not given!" in str(excinfo.value) # Test valid initialization with min model model_dict = {"sub_model": "min", "Z_max": 0.03, "sigma": 0.5} - zalava_min = Zalava21(model_dict) - assert zalava_min.sub_model == "min" - assert zalava_min.Z_max == 0.03 - assert zalava_min.sigma == 0.5 + zavala_min = Zavala21(model_dict) + assert zavala_min.sub_model == "min" + assert zavala_min.Z_max == 0.03 + assert zavala_min.sigma == 0.5 # Test valid initialization with max model model_dict = {"sub_model": "max", "Z_max": 0.03, "sigma": 0.5} - zalava_max = Zalava21(model_dict) - assert zalava_max.sub_model == "max" + zavala_max = Zavala21(model_dict) + assert zavala_max.sub_model == "max" # Test invalid sub_model model_dict = {"sub_model": "invalid", "Z_max": 0.03, "sigma": 0.5} with pytest.raises(ValueError) as excinfo: - zalava_invalid = Zalava21(model_dict) + zavala_invalid = Zavala21(model_dict) assert "Invalid sub-model!" in str(excinfo.value) - def test_csfrd_min_model(self, mock_zalava_data): + def test_csfrd_min_model(self, mock_zavala_data): """Test the CSFRD method with min sub-model.""" model_dict = {"sub_model": "min", "Z_max": 0.03, "sigma": 0.5} - zalava = Zalava21(model_dict) + zavala = Zavala21(model_dict) # Test at specific redshifts z_values = np.array([0.0, 2.0, 4.0, 6.0]) - result = zalava.CSFRD(z_values) + result = zavala.CSFRD(z_values) # Expected values come from interpolating the mock data expected = 0.1 * np.exp(-z_values / 3.0) np.testing.assert_allclose(result, expected) - def test_csfrd_max_model(self, mock_zalava_data): + def test_csfrd_max_model(self, mock_zavala_data): """Test the CSFRD method with max sub-model.""" model_dict = {"sub_model": "max", "Z_max": 0.03, "sigma": 0.5} - zalava = Zalava21(model_dict) + zavala = Zavala21(model_dict) # Test at specific redshifts z_values = np.array([0.0, 2.0, 4.0, 6.0]) - result = zalava.CSFRD(z_values) + result = zavala.CSFRD(z_values) # Expected values come from interpolating the mock data expected = 0.2 * np.exp(-z_values / 3.0) np.testing.assert_allclose(result, expected) - def test_fsfr_calculation(self, mock_zalava_data): + def test_fsfr_calculation(self, mock_zavala_data): """Test the fSFR method which is inherited from MadauBase.""" model_dict = {"sub_model": "min", "Z_max": 0.03, "sigma": 0.5} - zalava = Zalava21(model_dict) + zavala = Zavala21(model_dict) # Test with redshift array and metallicity bins z = np.array([0.0, 2.0]) met_bins = np.array([0.001, 0.01, 0.02, 0.03]) - result = zalava.fSFR(z, met_bins) + result = zavala.fSFR(z, met_bins) # Shape check - should be (len(z), len(met_bins)-1) assert result.shape == (2, 3) # Test normalization - zalava.normalise = True - result = zalava.fSFR(z, met_bins) + zavala.normalise = True + result = zavala.fSFR(z, met_bins) for row in result: np.testing.assert_allclose(np.sum(row), 1.0) From a629c046dc1c127b71a6cec638e2553587930424 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 21 Mar 2025 21:43:02 +0100 Subject: [PATCH 30/61] remove Z_max requirement for Chruslinska --- posydon/popsyn/star_formation_history.py | 2 -- posydon/unit_tests/popsyn/test_star_formation_history.py | 5 ----- 2 files changed, 7 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 9072d8ef17..7adf8278b3 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -624,8 +624,6 @@ def __init__(self, MODEL): raise ValueError("Sub-model not given!") if "Z_solar_scaling" not in MODEL: raise ValueError("Z_solar_scaling not given!") - if "Z_max" not in MODEL: - raise ValueError("Z_max not given!") super().__init__(MODEL) self._load_chruslinska_data() diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 3343bc0b44..5815916a42 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -684,11 +684,6 @@ def test_init_parameters(self): Chruslinska21({"sub_model": "test", "Z_max": 0.03, "select_one_met": False}) assert "Z_solar_scaling not given!" in str(excinfo.value) - # Test missing Z_max - with pytest.raises(ValueError) as excinfo: - Chruslinska21({"sub_model": "test", "Z_solar_scaling": "Asplund09", "select_one_met": False}) - assert "Z_max not given!" in str(excinfo.value) - def test_foh_to_z_conversion(self, chruslinska_model): """Test the _FOH_to_Z method for all scaling options.""" # Test Asplund09 scaling From 38b2936dd2c6cc8f7ac5fe0e3efe1fed7bc01b8c Mon Sep 17 00:00:00 2001 From: Max Briel Date: Wed, 26 Mar 2025 15:26:04 +0100 Subject: [PATCH 31/61] implement Matthias comments --- posydon/popsyn/star_formation_history.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 7adf8278b3..d09a059850 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -25,6 +25,8 @@ from posydon.utils.interpolators import interp1d from astropy.cosmology import Planck15 as cosmology from abc import ABC, abstractmethod +from posydon.utils.posydonwarning import Pwarn + SFH_SCENARIOS = [ "burst", @@ -86,6 +88,10 @@ def CSFRD(self, z): ------- float or array-like The cosmic star formation rate density at the given redshift(s). + + Raises + ------- + NotImplementedError: If the subclass does not implement this method. """ pass @@ -105,7 +111,11 @@ def mean_metallicity(self, z): Returns ------- float or array-like - The mean metallicity at the given redshift(s). + The mean metallicity at the given redshift(s). + + Raises + ------- + NotImplementedError: If the subclass does not implement this method. """ pass @@ -153,21 +163,21 @@ def _distribute_cdf(self, cdf_func, metallicity_bins): Fraction of the SFR in the given metallicity bin at the given redshift. ''' fSFR = (np.array(cdf_func(metallicity_bins[1:])) - - np.array(cdf_func(metallicity_bins[:-1]))) + - np.array(cdf_func(metallicity_bins[:-1]))) # include material outside the metallicity bounds if requested if self.Z_max is not None: if self.Z_max >= metallicity_bins[-1]: fSFR[-1] = cdf_func(self.Z_max) - cdf_func(metallicity_bins[-2]) else: - print("Warning: Z_max is smaller than the highest metallicity bin.") + Pwarn('Z_max is smaller than the highest metallicity bin.') fSFR[-1] = 0.0 if self.Z_min is not None: if self.Z_min <= metallicity_bins[0]: fSFR[0] = cdf_func(metallicity_bins[1]) - cdf_func(self.Z_min) else: - print("Warning: Z_min is larger than the lowest metallicity bin.") + Pwarn('Z_min is larger than the lowest metallicity bin.') fSFR[0] = 0.0 if self.normalise: @@ -857,6 +867,8 @@ def _load_zavala_data(self): skiprows=1, sep="\s+") self.redshifts = tmp_data["redshift"].values + # The min / max values originally come from their obscured + # and unobscured SFRD model. if self.sub_model == "min": self.SFR_data = tmp_data["SFRD_min"].values elif self.sub_model == "max": From 865be99a86c46609731206c76ad30bbcd96d9764 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Wed, 26 Mar 2025 15:27:57 +0100 Subject: [PATCH 32/61] change to rates.MODEL --- posydon/popsyn/synthetic_population.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posydon/popsyn/synthetic_population.py b/posydon/popsyn/synthetic_population.py index d81c494d89..a3e981d069 100644 --- a/posydon/popsyn/synthetic_population.py +++ b/posydon/popsyn/synthetic_population.py @@ -2080,7 +2080,7 @@ def calculate_cosmic_weights(self, SFH_identifier, MODEL_in=None): # sample the SFH for only the events that are within the Hubble time # only need to sample the SFH at each metallicity and z_birth # Not for every event! - SFR_per_met_at_z_birth = SFR_per_met_at_z(z_birth, met_edges, self.MODEL) + SFR_per_met_at_z_birth = SFR_per_met_at_z(z_birth, met_edges, rates.MODEL) # simulated mass per given metallicity corrected for the unmodeled # single and binary stellar mass From 32f1bc741fe101653cf02f8c0411957a3f67ce0d Mon Sep 17 00:00:00 2001 From: Max Briel Date: Wed, 26 Mar 2025 15:28:41 +0100 Subject: [PATCH 33/61] change comment --- posydon/popsyn/synthetic_population.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posydon/popsyn/synthetic_population.py b/posydon/popsyn/synthetic_population.py index a3e981d069..2f00ed3571 100644 --- a/posydon/popsyn/synthetic_population.py +++ b/posydon/popsyn/synthetic_population.py @@ -2035,7 +2035,7 @@ def calculate_cosmic_weights(self, SFH_identifier, MODEL_in=None): if MODEL_in is None: MODEL = DEFAULT_MODEL else: - # write the DEFAULT_MODEL with updates parameters to self.MODEL. + # write the DEFAULT_MODEL with updates parameters to MODEL. MODEL = DEFAULT_MODEL MODEL.update(MODEL_in) From 8590a9e486e5a61dd6d52d5521c49f75041d7ba3 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Wed, 26 Mar 2025 15:54:28 +0100 Subject: [PATCH 34/61] add test for get_SFR_per_met_at_z --- posydon/popsyn/star_formation_history.py | 19 +- .../popsyn/test_star_formation_history.py | 172 +++++++++++++++++- 2 files changed, 179 insertions(+), 12 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index d09a059850..20d5ce3fb4 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -72,7 +72,7 @@ def __init__(self, MODEL): @abstractmethod - def CSFRD(self, z): + def CSFRD(self, z): # pragma: no cover """Compute the cosmic star formation rate density. This is an abstract method that must be implemented by subclasses. @@ -96,7 +96,7 @@ def CSFRD(self, z): pass @abstractmethod - def mean_metallicity(self, z): + def mean_metallicity(self, z): # pragma: no cover """Return the mean metallicity at redshift z. This is an abstract method that must be implemented by subclasses. @@ -120,7 +120,7 @@ def mean_metallicity(self, z): pass @abstractmethod - def fSFR(self, z, metallicity_bins): + def fSFR(self, z, metallicity_bins): # pragma: no cover """Compute the star formation rate fraction (fSFR) at a given redshift using the specified metallicity bins. @@ -515,7 +515,7 @@ def __init__(self, MODEL): self.Z = illustris_data["mets"] self.M = np.flip(illustris_data["M"], axis=0) # Msun - def _get_illustrisTNG_data(self, verbose=False): + def _get_illustrisTNG_data(self, verbose=False): # pragma: no cover """Load IllustrisTNG SFR dataset into the class. Parameters @@ -712,7 +712,7 @@ def mean_metallicity(self, z): mean_over_redshift = np.zeros_like(self.redshifts) for i in range(len(mean_over_redshift)): if np.sum(self.SFR_data[i]) == 0: - mean_over_redshift[i] = 0 + mean_over_redshift[i] = np.nan else: mean_over_redshift[i] = np.average(self.Z, weights=self.SFR_data[i,:]*self.dFOH) @@ -753,7 +753,7 @@ def fSFR(self, z, metallicity_bins): fSFR[i, :] = self._distribute_cdf(cdf_fun, metallicity_bins) return fSFR - def _load_redshift_data(self, verbose=False): + def _load_redshift_data(self, verbose=False): # pragma: no cover """Load the redshift data from a Chruslinsk+21 model file. Returns @@ -772,7 +772,7 @@ def _load_redshift_data(self, verbose=False): os.path.join(self._data_folder, "Time_redshift_deltaT.dat"), unpack=True) return time, redshift, delt - def _load_raw_data(self): + def _load_raw_data(self): # pragma: no cover """Read the sub-model data from the file The data structure is as follows: @@ -856,7 +856,7 @@ def __init__(self, MODEL): super().__init__(MODEL) self._load_zavala_data() - def _load_zavala_data(self): + def _load_zavala_data(self): # pragma: no cover """Load the data from the Zavala+21 models Transforms the data to the format used in the classes. @@ -933,7 +933,8 @@ def SFR_per_met_at_z(z, met_bins, MODEL): SFH = get_SFH_model(MODEL) return SFH(z, met_bins) -def get_formation_times(N_binaries, star_formation="constant", **kwargs): +# TODO: No testing coverage for the following function, but should be added +def get_formation_times(N_binaries, star_formation="constant", **kwargs): # pragma: no cover """Get formation times of binaries in a population based on a SFH scenario. Parameters diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 5815916a42..1641aca1ab 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -113,6 +113,12 @@ def fSFR(self, z, metallicity_bins): result = sfh._distribute_cdf(cdf_func, met_edges) np.testing.assert_allclose(np.sum(result), 1.0) + # Test model dict warning + model_dict = {"Z_max": 0.02, "Z_min": 0.0} + sfh = ConcreteSFH(model_dict) + with pytest.warns(UserWarning): + result = sfh._distribute_cdf(cdf_func, met_edges) + # Test with different model dicts model_dict = {"Z_max": 1, "Z_min": 0.015} sfh = ConcreteSFH(model_dict) @@ -139,7 +145,6 @@ def fSFR(self, z, metallicity_bins): result = sfh._distribute_cdf(cdf_func, met_edges) np.testing.assert_allclose(np.sum(result), 1.0) - def test_call_method(self): """Test the __call__ method.""" class ConcreteSFH(SFHBase): @@ -425,7 +430,6 @@ def test_mean_metallicity(self, illustris_model, mock_illustris_data): result = illustris_model.mean_metallicity(z_values) # Calculate expected values manually - expected = np.zeros(len(z_values)) flipped_redshifts = np.flip(mock_illustris_data["redshifts"]) flipped_masses = np.flip(mock_illustris_data["M"], axis=0) metallicities = mock_illustris_data["mets"] @@ -440,6 +444,11 @@ def test_mean_metallicity(self, illustris_model, mock_illustris_data): out[i] = np.average(metallicities, weights=weights) Z_interp = np.interp(z_values, flipped_redshifts, out) np.testing.assert_allclose(result, Z_interp) + + # Test empty mass array + flipped_masses[0] = np.zeros_like(flipped_masses[0]) + with pytest.raises(AssertionError): + result = illustris_model.mean_metallicity(z_values) def test_fsfr_calculation(self, illustris_model): """Test the fSFR method.""" @@ -458,6 +467,12 @@ def test_fsfr_calculation(self, illustris_model): for row in result: if np.sum(row) > 0: np.testing.assert_allclose(np.sum(row), 1.0) + + # Test for Z_dist[i].sum = 0 + # Force the first mass array to be all zeros + illustris_model.M[0] = np.zeros_like(illustris_model.M[0]) + result = illustris_model.fSFR(z, met_bins) + np.testing.assert_allclose(result[0], np.zeros_like(result[0])) class TestMadauDickinson14: """Tests for the MadauDickinson14 SFH model""" @@ -740,6 +755,11 @@ def test_mean_metallicity(self, chruslinska_model, mock_chruslinska_data): assert np.isclose(result[0], result[1]) assert np.isclose(result[0], result[2]) assert np.isclose(result[0], 0.0014903210118641882) + + # Test with SFR_data == 0 + chruslinska_model.SFR_data = np.zeros_like(chruslinska_model.SFR_data) + with pytest.raises(AssertionError): + result = chruslinska_model.mean_metallicity(z_values) def test_csfrd_calculation(self, chruslinska_model, mock_chruslinska_data): """Test the CSFRD method.""" @@ -769,6 +789,14 @@ def test_fsfr_calculation(self, chruslinska_model): for row in result: if np.sum(row) > 0: np.testing.assert_allclose(np.sum(row), 1.0) + + # Test with Z_dist[i].sum = 0 + # Force the first mass array to be all zeros + chruslinska_model.SFR_data[0] = np.zeros_like(chruslinska_model.SFR_data[0]) + result = chruslinska_model.fSFR(z, met_bins) + np.testing.assert_allclose(result[0], np.zeros_like(result[0])) + + class TestZavala21: """Tests for the Zavala21 SFH model with mocked data loading.""" @@ -895,4 +923,142 @@ def test_returns_correct_instance(self): model_dict = {"SFR": "Fujimoto+24", "sigma": 0.5, "Z_max": 0.03} model = get_SFH_model(model_dict) assert isinstance(model, Fujimoto24) - \ No newline at end of file + + def test_illustris_tng_model(self, monkeypatch): + """Test that get_SFH_model returns IllustrisTNG instance.""" + # Mock the data loading method + def mock_get_data(self, verbose=False): + # Return minimal mock data structure + return { + "SFR": np.array([0.1, 0.2, 0.3]), + "redshifts": np.array([0.0, 1.0, 2.0]), + "mets": np.array([0.001, 0.01, 0.02]), + "M": np.ones((3, 3)) + } + + # Patch the data loading method + monkeypatch.setattr(IllustrisTNG, "_get_illustrisTNG_data", mock_get_data) + + # Test the model creation + model_dict = {"SFR": "IllustrisTNG", "Z_max": 0.03} + model = get_SFH_model(model_dict) + assert isinstance(model, IllustrisTNG) + + def test_chruslinska_model(self, monkeypatch): + """Test that get_SFH_model returns Chruslinska21 instance.""" + # Mock the methods needed for initialization + def mock_load_data(self): + # Minimal setup to make initialization work + self.FOH_bins = np.linspace(5.3, 9.7, 10) + self.dFOH = self.FOH_bins[1] - self.FOH_bins[0] + self.Z = np.array([0.001, 0.01, 0.02]) + self.redshifts = np.array([0.0, 1.0, 2.0]) + self.SFR_data = np.ones((3, 10)) + + def mock_load_redshift(self, verbose=False): + # Return mock time, redshift, deltaT + return (np.array([1e9, 2e9, 3e9]), + np.array([0.0, 1.0, 2.0]), + np.array([1e9, 1e9, 1e9])) + + def mock_load_raw(self): + # Return mock data matrix + return np.ones((3, 10)) * 1e6 + + # Patch the methods + monkeypatch.setattr(Chruslinska21, "_load_chruslinska_data", mock_load_data) + monkeypatch.setattr(Chruslinska21, "_load_redshift_data", mock_load_redshift) + monkeypatch.setattr(Chruslinska21, "_load_raw_data", mock_load_raw) + + # Test the model creation + model_dict = { + "SFR": "Chruslinska+21", + "sub_model": "test", + "Z_solar_scaling": "Asplund09", + "Z_max": 0.03 + } + model = get_SFH_model(model_dict) + assert isinstance(model, Chruslinska21) + + def test_zavala_model(self, monkeypatch): + """Test that get_SFH_model returns Zavala21 instance.""" + # Mock the data loading method + def mock_load_data(self): + # Set required attributes directly + self.redshifts = np.array([0.0, 1.0, 2.0]) + if self.sub_model == "min": + self.SFR_data = np.array([0.1, 0.08, 0.06]) + else: + self.SFR_data = np.array([0.2, 0.16, 0.12]) + + # Patch the data loading method + monkeypatch.setattr(Zavala21, "_load_zavala_data", mock_load_data) + + # Test for min model + model_dict = { + "SFR": "Zavala+21", + "sub_model": "min", + "sigma": 0.5, + "Z_max": 0.03 + } + model = get_SFH_model(model_dict) + assert isinstance(model, Zavala21) + assert model.sub_model == "min" + + # Test for max model + model_dict = { + "SFR": "Zavala+21", + "sub_model": "max", + "sigma": 0.5, + "Z_max": 0.03 + } + model = get_SFH_model(model_dict) + assert isinstance(model, Zavala21) + assert model.sub_model == "max" + + def test_invalid_model(self): + """Test that get_SFH_model raises an error for an invalid model.""" + + model_dict = {"SFR": "InvalidModel", "sigma": 0.5, "Z_max": 0.03} + with pytest.raises(ValueError) as excinfo: + model = get_SFH_model(model_dict) + assert "Invalid SFR!" in str(excinfo.value) + +class TestSFHUtilityFunctions: + """Tests for utility functions in the star_formation_history module.""" + + def test_SFR_per_met_at_z(self, monkeypatch): + """Test that SFR_per_met_at_z correctly calls the model.""" + # Create a mock model result + expected_result = np.array([[0.1, 0.2], [0.3, 0.4]]) + + # Mock SFH model class + class MockSFH: + def __call__(self, z, met_bins): + return expected_result + + mock_model = MockSFH() + + # Mock the get_SFH_model function to return our mock model + def mock_get_sfh_model(MODEL): + assert MODEL["SFR"] == "TestModel" # Verify correct model is requested + assert MODEL["param"] == "value" # Verify parameters are passed + return mock_model + + # Patch the function + monkeypatch.setattr( + "posydon.popsyn.star_formation_history.get_SFH_model", + mock_get_sfh_model + ) + + # Test the function + from posydon.popsyn.star_formation_history import SFR_per_met_at_z + + z = np.array([0.0, 1.0]) + met_bins = np.array([0.001, 0.01, 0.02]) + model_dict = {"SFR": "TestModel", "param": "value"} + + result = SFR_per_met_at_z(z, met_bins, model_dict) + + # Verify the result + np.testing.assert_array_equal(result, expected_result) From 95c32ae49c60a75b700087d3e8881be029188d4e Mon Sep 17 00:00:00 2001 From: Max Briel Date: Wed, 26 Mar 2025 16:15:10 +0100 Subject: [PATCH 35/61] make sure to test branches --- posydon/popsyn/star_formation_history.py | 1 + .../popsyn/test_star_formation_history.py | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 20d5ce3fb4..9650bcdc9e 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -170,6 +170,7 @@ def _distribute_cdf(self, cdf_func, metallicity_bins): if self.Z_max >= metallicity_bins[-1]: fSFR[-1] = cdf_func(self.Z_max) - cdf_func(metallicity_bins[-2]) else: + print(f"Z_max is smaller than the highest metallicity bin.") Pwarn('Z_max is smaller than the highest metallicity bin.') fSFR[-1] = 0.0 diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 1641aca1ab..a10fba1c61 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -66,7 +66,7 @@ def fSFR(self, z, metallicity_bins): with pytest.raises(ValueError) as excinfo: sfh = ConcreteSFH(model_dict) assert "Z_min must be in absolute units!" in str(excinfo.value) - + def test_abstract_methods(self): """Test that abstract methods must be implemented.""" # Create incomplete subclasses that don't implement all abstract methods @@ -97,13 +97,13 @@ def mean_metallicity(self, z): def fSFR(self, z, metallicity_bins): return np.ones((len(z), len(metallicity_bins)-1)) - model_dict = {"Z_max": 1, "Z_min": 0.0} - sfh = ConcreteSFH(model_dict) - # Create a simple CDF functions cdf_func = lambda x: x met_edges = np.array([0.0, 0.01, 0.02, 0.03]) + + model_dict = {"Z_max": 1, "Z_min": 0.0} + sfh = ConcreteSFH(model_dict) result = sfh._distribute_cdf(cdf_func, met_edges) expected = np.array([0.01, 0.01, 0.98]) np.testing.assert_allclose(result, expected) @@ -112,6 +112,12 @@ def fSFR(self, z, metallicity_bins): sfh.normalise = True result = sfh._distribute_cdf(cdf_func, met_edges) np.testing.assert_allclose(np.sum(result), 1.0) + + # test no Z_min/Z_max set + model_dict = {} + sfh = ConcreteSFH(model_dict) + result = sfh._distribute_cdf(cdf_func, met_edges) + np.testing.assert_allclose(result, 0.01 * np.ones(3)) # Test model dict warning model_dict = {"Z_max": 0.02, "Z_min": 0.0} @@ -144,7 +150,8 @@ def fSFR(self, z, metallicity_bins): sfh.normalise = True result = sfh._distribute_cdf(cdf_func, met_edges) np.testing.assert_allclose(np.sum(result), 1.0) - + + def test_call_method(self): """Test the __call__ method.""" class ConcreteSFH(SFHBase): From 9f66591e6ed3bcfd3e81b53fdd7b0fe8fa3080a8 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Wed, 26 Mar 2025 16:15:45 +0100 Subject: [PATCH 36/61] add SFH tests --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 2543859ba0..a3760e4e93 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -36,4 +36,4 @@ jobs: export PATH_TO_POSYDON=./ export PATH_TO_POSYDON_DATA=./ export MESA_DIR=./ - python -m pytest posydon/unit_tests/ --cov=posydon.utils --cov=posydon.config --cov-branch --cov-report term-missing --cov-fail-under=100 + python -m pytest posydon/unit_tests/ --cov=posydon.utils --cov=posydon.config --cov=posydon.popsyn.star_formation_history --cov-report term-missing --cov-fail-under=100 From b1622d6c876a1673c3f1db6244b46606ea40c6e7 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Wed, 26 Mar 2025 16:16:07 +0100 Subject: [PATCH 37/61] add space --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index a3760e4e93..5c167c41f6 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -36,4 +36,4 @@ jobs: export PATH_TO_POSYDON=./ export PATH_TO_POSYDON_DATA=./ export MESA_DIR=./ - python -m pytest posydon/unit_tests/ --cov=posydon.utils --cov=posydon.config --cov=posydon.popsyn.star_formation_history --cov-report term-missing --cov-fail-under=100 + python -m pytest posydon/unit_tests/ --cov=posydon.utils --cov=posydon.config --cov=posydon.popsyn.star_formation_history --cov-branch --cov-report term-missing --cov-fail-under=100 From 465453e80c119bfc5029320bb977c16267ea4ddf Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 1 Apr 2025 10:58:47 +0200 Subject: [PATCH 38/61] remove duplicate lines in IllustrisTNG class --- posydon/popsyn/star_formation_history.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 9650bcdc9e..8673613f7c 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -500,12 +500,7 @@ def __init__(self, MODEL): The maximum metallicity in absolute units. - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. - """ - - self.Z_max = None - self.Z_min = None - self.normalise = False - + """ super().__init__(MODEL) # load the TNG data illustris_data = self._get_illustrisTNG_data() From f0bd4fc675651a7070e442241768e6631547b976 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 1 Apr 2025 11:10:01 +0200 Subject: [PATCH 39/61] move ConcreteSFH into a fixture --- .../popsyn/test_star_formation_history.py | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index a10fba1c61..a00335ff63 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -14,19 +14,24 @@ class TestSFHBase: - def test_init_attributes(self): - """Test that the initialization sets attributes correctly.""" - # Create a concrete subclass for testing + + @pytest.fixture + def ConcreteSFH(self): + """Create a concrete subclass of SFHBase for testing.""" class ConcreteSFH(SFHBase): def CSFRD(self, z): return z def mean_metallicity(self, z): return z - + def fSFR(self, z, metallicity_bins): return np.ones((len(z), len(metallicity_bins)-1)) + return ConcreteSFH + + def test_init_attributes(self, ConcreteSFH): + """Test that the initialization sets attributes correctly.""" model_dict = { "test_param": 42, "Z_max": 0.03, @@ -40,16 +45,8 @@ def fSFR(self, z, metallicity_bins): assert sfh.another_param == "test" assert sfh.MODEL == model_dict - def test_validation(self): + def test_validation(self, ConcreteSFH): """Test that Z_max > 1 raises a ValueError.""" - class ConcreteSFH(SFHBase): - def CSFRD(self, z): - pass - def mean_metallicity(self, z): - pass - def fSFR(self, z, metallicity_bins): - pass - model_dict = {"Z_max": 1.5} with pytest.raises(ValueError) as excinfo: ConcreteSFH(model_dict) @@ -87,21 +84,12 @@ def mean_metallicity(self, z): with pytest.raises(TypeError): IncompleteSFH2(model_dict) - def test_distribute_cdf(self): + def test_distribute_cdf(self, ConcreteSFH): """Test the _distribute_cdf method.""" - class ConcreteSFH(SFHBase): - def CSFRD(self, z): - return z - def mean_metallicity(self, z): - return z - def fSFR(self, z, metallicity_bins): - return np.ones((len(z), len(metallicity_bins)-1)) - # Create a simple CDF functions cdf_func = lambda x: x met_edges = np.array([0.0, 0.01, 0.02, 0.03]) - model_dict = {"Z_max": 1, "Z_min": 0.0} sfh = ConcreteSFH(model_dict) result = sfh._distribute_cdf(cdf_func, met_edges) From 4a8c2150624c081882442fc616a9f540a9df3c4b Mon Sep 17 00:00:00 2001 From: Max Briel Date: Wed, 16 Apr 2025 10:02:55 +0200 Subject: [PATCH 40/61] Add support for Zmin and Zmax inside bins and inside the same bin. --- posydon/popsyn/star_formation_history.py | 26 ++++++++--- .../popsyn/test_star_formation_history.py | 43 +++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 8673613f7c..b841d42466 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -164,23 +164,39 @@ def _distribute_cdf(self, cdf_func, metallicity_bins): ''' fSFR = (np.array(cdf_func(metallicity_bins[1:])) - np.array(cdf_func(metallicity_bins[:-1]))) - + + first_bin_index = 0 + last_bin_index = len(fSFR) - 1 + # include material outside the metallicity bounds if requested if self.Z_max is not None: if self.Z_max >= metallicity_bins[-1]: fSFR[-1] = cdf_func(self.Z_max) - cdf_func(metallicity_bins[-2]) else: - print(f"Z_max is smaller than the highest metallicity bin.") Pwarn('Z_max is smaller than the highest metallicity bin.') - fSFR[-1] = 0.0 + # find the index of the last bin that is smaller than Z_max + last_bin_index = np.searchsorted(metallicity_bins, self.Z_max) - 1 + fSFR[last_bin_index] = cdf_func(self.Z_max) - cdf_func(metallicity_bins[last_bin_index]) + fSFR[last_bin_index + 1:] = 0.0 if self.Z_min is not None: if self.Z_min <= metallicity_bins[0]: fSFR[0] = cdf_func(metallicity_bins[1]) - cdf_func(self.Z_min) else: Pwarn('Z_min is larger than the lowest metallicity bin.') - fSFR[0] = 0.0 - + # find the index of the first bin that is larger than Z_min + first_bin_index = np.searchsorted(metallicity_bins, self.Z_min) -1 + print(first_bin_index) + fSFR[:first_bin_index] = 0.0 + fSFR[first_bin_index] = cdf_func(metallicity_bins[first_bin_index+1]) - cdf_func(self.Z_min) + + # Check if in the same bin + if self.Z_max is not None and self.Z_min is not None: + if first_bin_index == last_bin_index: + fSFR[first_bin_index] = cdf_func(self.Z_max) - cdf_func(self.Z_min) + fSFR[first_bin_index + 1:] = 0.0 + fSFR[:first_bin_index] = 0.0 + if self.normalise: fSFR /= np.sum(fSFR) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index a00335ff63..dae024b6be 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -139,6 +139,49 @@ def test_distribute_cdf(self, ConcreteSFH): result = sfh._distribute_cdf(cdf_func, met_edges) np.testing.assert_allclose(np.sum(result), 1.0) + # Test minimum in lowest bin + model_dict = {"Z_min": 0.25} + sfh = ConcreteSFH(model_dict) + met_edges = np.array([0.2, 0.3, 0.6, 0.9]) + result = sfh._distribute_cdf(cdf_func, met_edges) + expected = np.array([0.05, 0.3, 0.3]) + np.testing.assert_allclose(result, expected) + + # Test minumum is higher than minimum bin + model_dict = {"Z_min": 0.35} + sfh = ConcreteSFH(model_dict) + met_edges = np.array([0.2, 0.3, 0.6, 0.9]) + result = sfh._distribute_cdf(cdf_func, met_edges) + expected = np.array([0.0, 0.25, 0.3]) + np.testing.assert_allclose(result, expected) + + # Test minimum in lowest bin and maximum + model_dict = {"Z_min" : 0.25, + 'Z_max' : 0.8} + sfh = ConcreteSFH(model_dict) + met_edges = np.array([0.2, 0.3, 0.6, 0.9]) + result = sfh._distribute_cdf(cdf_func, met_edges) + expected = np.array([0.05, 0.3, 0.2]) + np.testing.assert_allclose(result, expected) + + # Test minumum is higher than minimum bin + model_dict = {"Z_min" : 0.35, + 'Z_max' : 0.4} + sfh = ConcreteSFH(model_dict) + met_edges = np.array([0.2, 0.3, 0.6, 0.9]) + result = sfh._distribute_cdf(cdf_func, met_edges) + expected = np.array([0.0, 0.05, 0.0]) + np.testing.assert_allclose(result, expected) + + # Test minumum is higher than minimum bin + model_dict = {"Z_min" : 0.35, + 'Z_max' : 0.65} + sfh = ConcreteSFH(model_dict) + met_edges = np.array([0.2, 0.3, 0.6, 0.9]) + result = sfh._distribute_cdf(cdf_func, met_edges) + expected = np.array([0.0, 0.25, 0.05]) + np.testing.assert_allclose(result, expected) + def test_call_method(self): """Test the __call__ method.""" From 81e2882a6a63b2654a07722098f9cc6ea36a8363 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 09:18:19 +0200 Subject: [PATCH 41/61] add doc string + remove select_one_met --- posydon/popsyn/star_formation_history.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index b841d42466..13ba1c6bf0 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -48,7 +48,13 @@ def __init__(self, MODEL): Parameters ---------- MODEL : dict - Model parameters. + SFH model parameters. + - Z_max : float + The maximum metallicity in absolute units. + - Z_min : float + The minimum metallicity in absolute units. + - normalise : bool + Normalise the metallicity distribution to 1. """ self.Z_max = None self.Z_min = None @@ -246,8 +252,8 @@ def __init__(self, MODEL): """ if "sigma" not in MODEL: raise ValueError("sigma not given!") - super().__init__(MODEL) self.CSFRD_params = None + super().__init__(MODEL) def CSFRD(self, z): """The cosmic star formation rate density at a given redshift. @@ -365,8 +371,6 @@ def __init__(self, MODEL): - float - Z_max : float The maximum metallicity in absolute units. - - select_one_met : bool - If True, the SFR is calculated for a single metallicity bin. """ super().__init__(MODEL) # Parameters for Madau+Dickinson14 CSFRD @@ -400,8 +404,6 @@ def __init__(self, MODEL): - float - Z_max : float The maximum metallicity in absolute units. - - select_one_met : bool - If True, the SFR is calculated for a single metallicity bin. """ super().__init__(MODEL) # Parameters for Madau+Fragos17 CSFRD @@ -436,10 +438,13 @@ def __init__(self, MODEL): - Bavera+20 - Neijssel+19 - float + It can also use the following parameters: - Z_max : float The maximum metallicity in absolute units. - - select_one_met : bool - If True, the SFR is calculated for a single metallicity bin. + - Z_min : float + The minimum metallicity in absolute units. + - normalise : bool + Normalise the metallicity distribution to 1. """ super().__init__(MODEL) # Parameters for Neijssel+19 CSFRD From 6509cf928259208f3e9a14a037c42e607674bd3e Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 09:19:50 +0200 Subject: [PATCH 42/61] replace MODEL with SFH_MODEL --- posydon/popsyn/star_formation_history.py | 107 ++++++++++++----------- 1 file changed, 56 insertions(+), 51 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 13ba1c6bf0..e51ca90444 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -40,14 +40,14 @@ class SFHBase(ABC): """Abstract class for star formation history models""" - def __init__(self, MODEL): + def __init__(self, SFH_MODEL): """Initialise the SFH model Adds the model parameters as attributes. Parameters ---------- - MODEL : dict + SFH_MODEL : dict SFH model parameters. - Z_max : float The maximum metallicity in absolute units. @@ -60,9 +60,9 @@ def __init__(self, MODEL): self.Z_min = None self.normalise = False - self.MODEL = MODEL + self.SFH_MODEL = SFH_MODEL # Automatically attach all model parameters as attributes - for key, value in MODEL.items(): + for key, value in SFH_MODEL.items(): setattr(self, key, value) # check if Z_max is not larger than 1 @@ -234,26 +234,31 @@ class MadauBase(SFHBase): and fractional SFR based on the chosen Madau parameterisation. The specific parameters for CSFRD must be provided by subclasses. """ - def __init__(self, MODEL): + def __init__(self, SFH_MODEL): """Initialise the MadauBase class Parameters ---------- - MODEL : dict - Model parameters. MadauBase requires the following parameters: + SFH_MODEL : dict + SFH model parameters. MadauBase requires the following parameters: - sigma : float or str The standard deviation of the log-normal metallicity distribution. Options are: - Bavera+20 - Neijssel+19 - float + Additional SFH model parameters: - Z_max : float The maximum metallicity in absolute units. + - Z_min : float + The minimum metallicity in absolute units. + - normalise : bool + Normalise the metallicity distribution to 1. """ - if "sigma" not in MODEL: + if "sigma" not in SFH_MODEL: raise ValueError("sigma not given!") self.CSFRD_params = None - super().__init__(MODEL) + super().__init__(SFH_MODEL) def CSFRD(self, z): """The cosmic star formation rate density at a given redshift. @@ -354,13 +359,13 @@ class MadauDickinson14(MadauBase): https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M/abstract """ - def __init__(self, MODEL): + def __init__(self, SFH_MODEL): """Initialise the Madau & Dickinson (2014) SFH model with the metallicity evolution of Madau & Fragos (2017). Parameters ---------- - MODEL : dict + SFH_MODEL : dict Model parameters. Madau+14 requires the following parameters: - sigma : float or str The standard deviation of the log-normal metallicity @@ -372,7 +377,7 @@ def __init__(self, MODEL): - Z_max : float The maximum metallicity in absolute units. """ - super().__init__(MODEL) + super().__init__(SFH_MODEL) # Parameters for Madau+Dickinson14 CSFRD self.CSFRD_params = { "a": 0.015, @@ -389,12 +394,12 @@ class MadauFragos17(MadauBase): http://adsabs.harvard.edu/abs/2017ApJ...840...39M """ - def __init__(self, MODEL): + def __init__(self, SFH_MODEL): """Initialise the Madau+17 model Parameters ---------- - MODEL : dict + SFH_MODEL : dict Model parameters. Madau+17 requires the following parameters: - sigma : float or str The standard deviation of the log-normal metallicity distribution. @@ -405,7 +410,7 @@ def __init__(self, MODEL): - Z_max : float The maximum metallicity in absolute units. """ - super().__init__(MODEL) + super().__init__(SFH_MODEL) # Parameters for Madau+Fragos17 CSFRD self.CSFRD_params = { "a": 0.01, @@ -425,12 +430,12 @@ class Neijssel19(MadauBase): Neijssel et al. (2019), MNRAS, 490, 3740 http://adsabs.harvard.edu/abs/2019MNRAS.490.3740N """ - def __init__(self, MODEL): + def __init__(self, SFH_MODEL): """Initialise the Neijssel+19 model Parameters ---------- - MODEL : dict + SFH_MODEL : dict Model parameters. Neijssel+19 requires the following parameters: - sigma : float or str The standard deviation of the log-normal metallicity distribution. @@ -446,7 +451,7 @@ def __init__(self, MODEL): - normalise : bool Normalise the metallicity distribution to 1. """ - super().__init__(MODEL) + super().__init__(SFH_MODEL) # Parameters for Neijssel+19 CSFRD self.CSFRD_params = { "a": 0.01, @@ -510,19 +515,19 @@ class IllustrisTNG(SFHBase): https://www.tng-project.org/ """ - def __init__(self, MODEL): + def __init__(self, SFH_MODEL): """Initialise the IllustrisTNG model Parameters ---------- - MODEL : dict + SFH_MODEL : dict Model parameters. IllustrisTNG requires the following parameters: - Z_max : float The maximum metallicity in absolute units. - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. """ - super().__init__(MODEL) + super().__init__(SFH_MODEL) # load the TNG data illustris_data = self._get_illustrisTNG_data() # the data is stored in reverse order high to low redshift @@ -627,12 +632,12 @@ class Chruslinska21(SFHBase): Data source: https://ftp.science.ru.nl/astro/mchruslinska/Chruslinska_et_al_2021/ """ - def __init__(self, MODEL): + def __init__(self, SFH_MODEL): """Initialise the Chruslinska+21 model Parameters ---------- - MODEL : dict + SFH_MODEL : dict Model parameters. Chruslinska+21 requires the following parameters: - sub_model : str The sub-model to use. This is the name of the file containing the data. @@ -647,11 +652,11 @@ def __init__(self, MODEL): - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. """ - if "sub_model" not in MODEL: + if "sub_model" not in SFH_MODEL: raise ValueError("Sub-model not given!") - if "Z_solar_scaling" not in MODEL: + if "Z_solar_scaling" not in SFH_MODEL: raise ValueError("Z_solar_scaling not given!") - super().__init__(MODEL) + super().__init__(SFH_MODEL) self._load_chruslinska_data() def _load_chruslinska_data(self, verbose=False): @@ -827,12 +832,12 @@ class Fujimoto24(MadauBase): Fujimoto et al. (2024), ApJ SS, 275, 2, 36, 59 https://ui.adsabs.harvard.edu/abs/2024ApJS..275...36F/abstract """ - def __init__(self, MODEL): + def __init__(self, SFH_MODEL): """Initialise the Fujimoto+24 model Parameters ---------- - MODEL : dict + SFH_MODEL : dict Model parameters. Fujimoto+24 requires the following parameters: - sigma : float or str The standard deviation of the log-normal metallicity distribution. @@ -845,7 +850,7 @@ def __init__(self, MODEL): - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. """ - super().__init__(MODEL) + super().__init__(SFH_MODEL) # Parameters for Fujimoto+24 CSFRD self.CSFRD_params = { "a": 0.010, @@ -856,7 +861,7 @@ def __init__(self, MODEL): class Zavala21(MadauBase): - def __init__(self, MODEL): + def __init__(self, SFH_MODEL): """Initialise the Zavala+21 model Requires the following parameters: @@ -867,10 +872,10 @@ def __init__(self, MODEL): - select_one_met : bool If True, the SFR is calculated for a single metallicity bin. """ - if "sub_model" not in MODEL: + if "sub_model" not in SFH_MODEL: raise ValueError("Sub-model not given!") - super().__init__(MODEL) + super().__init__(SFH_MODEL) self._load_zavala_data() def _load_zavala_data(self): # pragma: no cover @@ -899,12 +904,12 @@ def CSFRD(self, z): return SFR_interp(z) -def get_SFH_model(MODEL): +def get_SFH_model(SFH_MODEL): """Return the appropriate SFH model based on the given parameters Parameters ---------- - MODEL : dict + SFH_MODEL : dict Model parameters. Returns @@ -912,24 +917,24 @@ def get_SFH_model(MODEL): a SFHBase instance or subclass The SFH model instance. """ - if MODEL["SFR"] == "Madau+Fragos17": - return MadauFragos17(MODEL) - elif MODEL["SFR"] == "Madau+Dickinson14": - return MadauDickinson14(MODEL) - elif MODEL["SFR"] == "Fujimoto+24": - return Fujimoto24(MODEL) - elif MODEL["SFR"] == "Neijssel+19": - return Neijssel19(MODEL) - elif MODEL["SFR"] == "IllustrisTNG": - return IllustrisTNG(MODEL) - elif MODEL["SFR"] == "Chruslinska+21": - return Chruslinska21(MODEL) - elif MODEL["SFR"] == "Zavala+21": - return Zavala21(MODEL) + if SFH_MODEL["SFR"] == "Madau+Fragos17": + return MadauFragos17(SFH_MODEL) + elif SFH_MODEL["SFR"] == "Madau+Dickinson14": + return MadauDickinson14(SFH_MODEL) + elif SFH_MODEL["SFR"] == "Fujimoto+24": + return Fujimoto24(SFH_MODEL) + elif SFH_MODEL["SFR"] == "Neijssel+19": + return Neijssel19(SFH_MODEL) + elif SFH_MODEL["SFR"] == "IllustrisTNG": + return IllustrisTNG(SFH_MODEL) + elif SFH_MODEL["SFR"] == "Chruslinska+21": + return Chruslinska21(SFH_MODEL) + elif SFH_MODEL["SFR"] == "Zavala+21": + return Zavala21(SFH_MODEL) else: raise ValueError("Invalid SFR!") -def SFR_per_met_at_z(z, met_bins, MODEL): +def SFR_per_met_at_z(z, met_bins, SFH_MODEL): """Calculate the SFR per metallicity bin at a given redshift(s) Parameters @@ -938,7 +943,7 @@ def SFR_per_met_at_z(z, met_bins, MODEL): Cosmological redshift. met_bins : array Metallicity bins edges in absolute metallicity. - MODEL : dict + SFH_MODEL : dict Model parameters. Returns @@ -947,7 +952,7 @@ def SFR_per_met_at_z(z, met_bins, MODEL): Star formation history per metallicity bin at the given redshift(s). """ - SFH = get_SFH_model(MODEL) + SFH = get_SFH_model(SFH_MODEL) return SFH(z, met_bins) # TODO: No testing coverage for the following function, but should be added From 8c68489774cd0f7cd46e7ad77e372a1e83de6d8a Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 09:24:00 +0200 Subject: [PATCH 43/61] change DEFAULT_MODEL to DEFAULT_SFH_MODEL --- posydon/popsyn/rate_calculation.py | 5 ++--- posydon/popsyn/star_formation_history.py | 7 ++++++- posydon/popsyn/synthetic_population.py | 10 +++++----- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/posydon/popsyn/rate_calculation.py b/posydon/popsyn/rate_calculation.py index 5cbfd8716a..a6698ce2ed 100644 --- a/posydon/popsyn/rate_calculation.py +++ b/posydon/popsyn/rate_calculation.py @@ -14,12 +14,11 @@ from astropy import units as u -DEFAULT_MODEL = { +DEFAULT_SFH_MODEL = { "delta_t": 100, # Myr "SFR": "IllustrisTNG", "sigma_SFR": None, - "Z_max": 1.0, - "select_one_met": False, + "Z_max": None, # Zsun "dlogZ": None, # e.g, [np.log10(0.0142/2),np.log10(0.0142*2)] "Zsun": Zsun, } diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index e51ca90444..431d426ee2 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -374,8 +374,13 @@ def __init__(self, SFH_MODEL): - Bavera+20 - Neijssel+19 - float + Additional SFH model parameters: - Z_max : float The maximum metallicity in absolute units. + - Z_min : float + The minimum metallicity in absolute units. + - normalise : bool + Normalise the metallicity distribution to 1. """ super().__init__(SFH_MODEL) # Parameters for Madau+Dickinson14 CSFRD @@ -443,7 +448,7 @@ def __init__(self, SFH_MODEL): - Bavera+20 - Neijssel+19 - float - It can also use the following parameters: + Additional SFH model parameters: - Z_max : float The maximum metallicity in absolute units. - Z_min : float diff --git a/posydon/popsyn/synthetic_population.py b/posydon/popsyn/synthetic_population.py index 2f00ed3571..6d5e190ddb 100644 --- a/posydon/popsyn/synthetic_population.py +++ b/posydon/popsyn/synthetic_population.py @@ -52,7 +52,7 @@ get_comoving_distance_from_redshift, get_cosmic_time_from_redshift, redshift_from_cosmic_time_interpolator, - DEFAULT_MODEL, + DEFAULT_SFH_MODEL, get_redshift_bin_edges, get_redshift_bin_centers, ) @@ -2027,16 +2027,16 @@ def calculate_cosmic_weights(self, SFH_identifier, MODEL_in=None): Examples -------- >>> transient_population = TransientPopulation('filename.h5', 'transient_name') - >>> transient_population.calculate_cosmic_weights('IllustrisTNG', MODEL_in=DEFAULT_MODEL) + >>> transient_population.calculate_cosmic_weights('IllustrisTNG', MODEL_in=DEFAULT_SFH_MODEL) """ # Set model to DEFAULT or provided MODEL parameters # Allows for partial model specification if MODEL_in is None: - MODEL = DEFAULT_MODEL + MODEL = DEFAULT_SFH_MODEL else: - # write the DEFAULT_MODEL with updates parameters to MODEL. - MODEL = DEFAULT_MODEL + # write the DEFAULT_SFH_MODEL with updated parameters to MODEL. + MODEL = DEFAULT_SFH_MODEL MODEL.update(MODEL_in) path_in_file = ( From cbef6872ec1fa04593faf54b8c14a887ea9b91bf Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 09:25:47 +0200 Subject: [PATCH 44/61] add to default Zmin,Zmax=None + normalise=True --- posydon/popsyn/rate_calculation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/posydon/popsyn/rate_calculation.py b/posydon/popsyn/rate_calculation.py index a6698ce2ed..70f4deb531 100644 --- a/posydon/popsyn/rate_calculation.py +++ b/posydon/popsyn/rate_calculation.py @@ -19,6 +19,8 @@ "SFR": "IllustrisTNG", "sigma_SFR": None, "Z_max": None, # Zsun + "Z_min": None, # Zsun + "normalise": True, # normalise the SFR to 1 "dlogZ": None, # e.g, [np.log10(0.0142/2),np.log10(0.0142*2)] "Zsun": Zsun, } From ee1c8d27b28d6f0225ca79c276d00997e27ed250 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 09:38:56 +0200 Subject: [PATCH 45/61] update docstrings: --- posydon/popsyn/star_formation_history.py | 83 +++++++++++++++++------- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 431d426ee2..361a2d6124 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -254,6 +254,11 @@ def __init__(self, SFH_MODEL): The minimum metallicity in absolute units. - normalise : bool Normalise the metallicity distribution to 1. + - CSFRD_params: dict + Parameters for the cosmic star formation rate density (CSFRD) + - a, b, c, d : float + Follows the Madau & Dickinson (2014) CSFRD formula (Eq. 15): + https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M/abstract """ if "sigma" not in SFH_MODEL: raise ValueError("sigma not given!") @@ -366,7 +371,7 @@ def __init__(self, SFH_MODEL): Parameters ---------- SFH_MODEL : dict - Model parameters. Madau+14 requires the following parameters: + SFH model parameters. Madau+14 requires the following parameters: - sigma : float or str The standard deviation of the log-normal metallicity distribution. @@ -381,6 +386,11 @@ def __init__(self, SFH_MODEL): The minimum metallicity in absolute units. - normalise : bool Normalise the metallicity distribution to 1. + - CSFRD_params: dict + Parameters for the cosmic star formation rate density (CSFRD) + - a, b, c, d : float + Follows the Madau & Dickinson (2014) CSFRD formula (Eq. 15): + https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M/abstract """ super().__init__(SFH_MODEL) # Parameters for Madau+Dickinson14 CSFRD @@ -405,15 +415,20 @@ def __init__(self, SFH_MODEL): Parameters ---------- SFH_MODEL : dict - Model parameters. Madau+17 requires the following parameters: + SFH model parameters. Madau+17 requires the following parameters: - sigma : float or str The standard deviation of the log-normal metallicity distribution. Options are: - Bavera+20 - Neijssel+19 - float + Additional SFH model parameters: - Z_max : float The maximum metallicity in absolute units. + - Z_min : float + The minimum metallicity in absolute units. + - normalise : bool + Normalise the metallicity distribution to 1. """ super().__init__(SFH_MODEL) # Parameters for Madau+Fragos17 CSFRD @@ -441,7 +456,7 @@ def __init__(self, SFH_MODEL): Parameters ---------- SFH_MODEL : dict - Model parameters. Neijssel+19 requires the following parameters: + SFH model parameters. Neijssel+19 requires the following parameters: - sigma : float or str The standard deviation of the log-normal metallicity distribution. Options are: @@ -526,11 +541,13 @@ def __init__(self, SFH_MODEL): Parameters ---------- SFH_MODEL : dict - Model parameters. IllustrisTNG requires the following parameters: + Additional SFH model parameters: - Z_max : float The maximum metallicity in absolute units. - - select_one_met : bool - If True, the SFR is calculated for a single metallicity bin. + - Z_min : float + The minimum metallicity in absolute units. + - normalise : bool + Normalise the metallicity distribution to 1. """ super().__init__(SFH_MODEL) # load the TNG data @@ -643,19 +660,24 @@ def __init__(self, SFH_MODEL): Parameters ---------- SFH_MODEL : dict - Model parameters. Chruslinska+21 requires the following parameters: + SFH model parameters. Chruslinska+21 requires the + following parameters: - sub_model : str - The sub-model to use. This is the name of the file containing the data. + The sub-model to use. + This is the name of the file containing the data. - Z_solar_scaling : str The scaling of the solar metallicity. Options are: - Asplund09 - AndersGrevesse89 - GrevesseSauval98 - Villante14 + Additional SFH model parameters: - Z_max : float The maximum metallicity in absolute units. - - select_one_met : bool - If True, the SFR is calculated for a single metallicity bin. + - Z_min : float + The minimum metallicity in absolute units. + - normalise : bool + Normalise the metallicity distribution to 1. """ if "sub_model" not in SFH_MODEL: raise ValueError("Sub-model not given!") @@ -843,17 +865,20 @@ def __init__(self, SFH_MODEL): Parameters ---------- SFH_MODEL : dict - Model parameters. Fujimoto+24 requires the following parameters: + SFH model parameters. Fujimoto+24 requires the following parameters: - sigma : float or str The standard deviation of the log-normal metallicity distribution. Options are: - Bavera+20 - Neijssel+19 - float + Additional SFH model parameters: - Z_max : float The maximum metallicity in absolute units. - - select_one_met : bool - If True, the SFR is calculated for a single metallicity bin. + - Z_min : float + The minimum metallicity in absolute units. + - normalise : bool + Normalise the metallicity distribution to 1. """ super().__init__(SFH_MODEL) # Parameters for Fujimoto+24 CSFRD @@ -865,17 +890,31 @@ def __init__(self, SFH_MODEL): } class Zavala21(MadauBase): + """The Zavala et al. (2021) star formation history model. + + The "min" and "max" models are based on the obscured and unobscured + star formation rate density models, respectively. + + https://dx.doi.org/10.3847/1538-4357/abdb27 + + """ def __init__(self, SFH_MODEL): """Initialise the Zavala+21 model - Requires the following parameters: - - sub_model : str - Either min or max - - Z_max : float - The maximum metallicity in absolute units. - - select_one_met : bool - If True, the SFR is calculated for a single metallicity bin. + Parameters + ---------- + SFH_MODEL : dict + SFH model parameters. Zavala+21 requires the following parameters: + - sub_model : str + The sub-model to use. Either "min" or "max". + Additional SFH model parameters: + - Z_max : float + The maximum metallicity in absolute units. + - Z_min : float + The minimum metallicity in absolute units. + - normalise : bool + Normalise the metallicity distribution to 1. """ if "sub_model" not in SFH_MODEL: raise ValueError("Sub-model not given!") @@ -915,7 +954,7 @@ def get_SFH_model(SFH_MODEL): Parameters ---------- SFH_MODEL : dict - Model parameters. + SFH model parameters. Returns ------- @@ -949,7 +988,7 @@ def SFR_per_met_at_z(z, met_bins, SFH_MODEL): met_bins : array Metallicity bins edges in absolute metallicity. SFH_MODEL : dict - Model parameters. + SFH model parameters. Returns ------- From 89eaaf55d879196bf0e2d806a6a970b371e64a5c Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 09:49:11 +0200 Subject: [PATCH 46/61] clean up star_formation_history file --- posydon/popsyn/star_formation_history.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 361a2d6124..31772562a3 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -23,7 +23,6 @@ ) from posydon.utils.constants import Zsun from posydon.utils.interpolators import interp1d -from astropy.cosmology import Planck15 as cosmology from abc import ABC, abstractmethod from posydon.utils.posydonwarning import Pwarn @@ -192,7 +191,6 @@ def _distribute_cdf(self, cdf_func, metallicity_bins): Pwarn('Z_min is larger than the lowest metallicity bin.') # find the index of the first bin that is larger than Z_min first_bin_index = np.searchsorted(metallicity_bins, self.Z_min) -1 - print(first_bin_index) fSFR[:first_bin_index] = 0.0 fSFR[first_bin_index] = cdf_func(metallicity_bins[first_bin_index+1]) - cdf_func(self.Z_min) @@ -739,9 +737,11 @@ def _FOH_to_Z(self, FOH): Zsun = 0.019 FOHsun = 8.85 else: - raise ValueError("Invalid Z_solar_scaling!"+ - "Options are: Asplund09, AndersGrevesse89,"+ - "GrevesseSauval98, Villante14") + valid_options = ["Asplund09", "AndersGrevesse89", + "GrevesseSauval98", "Villante14"] + raise ValueError(f"Invalid Z_solar_scaling " + "'{self.Z_solar_scaling}'." + "Valid options: {valid_options}") logZ = np.log10(Zsun) + FOH - FOHsun return 10**logZ @@ -795,8 +795,6 @@ def fSFR(self, z, metallicity_bins): Z_dist_cdf = np.cumsum(Z_dist[i]) / Z_dist[i].sum() Z_dist_cdf = np.append(Z_dist_cdf, 1) Z_x_values = np.append(np.log10(self.Z), 0) - print(Z_x_values.shape) - print(Z_dist_cdf.shape) Z_dist_cdf_interp = interp1d(Z_x_values, Z_dist_cdf) cdf_fun = lambda x: Z_dist_cdf_interp(np.log10(x)) fSFR[i, :] = self._distribute_cdf(cdf_fun, metallicity_bins) @@ -898,7 +896,6 @@ class Zavala21(MadauBase): https://dx.doi.org/10.3847/1538-4357/abdb27 """ - def __init__(self, SFH_MODEL): """Initialise the Zavala+21 model @@ -946,8 +943,7 @@ def _load_zavala_data(self): # pragma: no cover def CSFRD(self, z): SFR_interp = interp1d(self.redshifts, self.SFR_data) return SFR_interp(z) - - + def get_SFH_model(SFH_MODEL): """Return the appropriate SFH model based on the given parameters From a4e726abdc2af9015c5e4fecac0f2a2731c4d1c5 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 10:28:57 +0200 Subject: [PATCH 47/61] update unit tests + implement comments --- posydon/popsyn/star_formation_history.py | 12 +- .../popsyn/test_star_formation_history.py | 201 ++++++++---------- 2 files changed, 98 insertions(+), 115 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 31772562a3..d9038498f1 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -67,10 +67,18 @@ def __init__(self, SFH_MODEL): # check if Z_max is not larger than 1 if self.Z_max is not None: if self.Z_max > 1: - raise ValueError("Z_max must be in absolute units!") + raise ValueError("Z_max must be in absolute units! " + "It cannot be larger than 1!") + elif self.Z_max < 0: + raise ValueError("Z_max must be in absolute units! " + "It cannot be negative!") if self.Z_min is not None: if self.Z_min < 0: - raise ValueError("Z_min must be in absolute units!") + raise ValueError("Z_min must be in absolute units! " + "It cannot be negative!") + elif self.Z_min > 1: + raise ValueError("Z_min must be in absolute units! " + "It cannot be larger than 1!") if self.Z_min is not None and self.Z_max is not None: if self.Z_min >= self.Z_max: raise ValueError("Z_min must be smaller than Z_max!") diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index dae024b6be..1b00fe3dd2 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -1,3 +1,10 @@ +"""Unit tests for posydon/popsyn/star_formation_history.py +""" + +__authors__ = [ + "Max Briel ", +] + import numpy as np import pytest from posydon.popsyn.star_formation_history import SFHBase, MadauBase @@ -40,29 +47,27 @@ def test_init_attributes(self, ConcreteSFH): sfh = ConcreteSFH(model_dict) # Check that attributes are set correctly - assert sfh.Z_max == 0.03 - assert sfh.test_param == 42 - assert sfh.another_param == "test" - assert sfh.MODEL == model_dict + for key, value in model_dict.items(): + assert getattr(sfh, key) == value + + # additional SFH_model set check + assert sfh.SFH_MODEL == model_dict - def test_validation(self, ConcreteSFH): - """Test that Z_max > 1 raises a ValueError.""" - model_dict = {"Z_max": 1.5} - with pytest.raises(ValueError) as excinfo: - ConcreteSFH(model_dict) - assert "Z_max must be in absolute units!" in str(excinfo.value) - - # Test with Z_min > Z_max - model_dict = {"Z_max": 0.1, "Z_min": 0.2} - with pytest.raises(ValueError) as excinfo: - sfh = ConcreteSFH(model_dict) - assert "Z_min must be smaller than Z_max!" in str(excinfo.value) - # Test with Z_min < 0 - model_dict = {"Z_max": 0.1, "Z_min": -0.1} + @pytest.mark.parametrize("model_dict, error_msg", [ + # Z_max + ({"Z_max": 1.5}, "Z_max must be in absolute units! It cannot be larger than 1!"), + ({"Z_max": -0.1}, "Z_max must be in absolute units! It cannot be negative!"), + # Z_min + ({"Z_min": -0.1}, "Z_min must be in absolute units! It cannot be negative!"), + ({"Z_min": 1.2}, "Z_min must be in absolute units! It cannot be larger than 1!"), + # Z_min > Z_max + ({"Z_max": 0.1, "Z_min": 0.2}, "Z_min must be smaller than Z_max!"), + ]) + def test_validation(self, ConcreteSFH, model_dict, error_msg): with pytest.raises(ValueError) as excinfo: sfh = ConcreteSFH(model_dict) - assert "Z_min must be in absolute units!" in str(excinfo.value) + assert error_msg in str(excinfo.value) def test_abstract_methods(self): """Test that abstract methods must be implemented.""" @@ -78,109 +83,79 @@ def mean_metallicity(self, z): return z model_dict = {"Z_max": 0.03} - with pytest.raises(TypeError): + with pytest.raises(TypeError) as excinfo: IncompleteSFH1(model_dict) + assert ("Can't instantiate abstract class IncompleteSFH1 " + "with abstract methods fSFR, mean_metallicity") in str(excinfo.value) - with pytest.raises(TypeError): + with pytest.raises(TypeError) as excinfo: IncompleteSFH2(model_dict) - - def test_distribute_cdf(self, ConcreteSFH): - """Test the _distribute_cdf method.""" - # Create a simple CDF functions + assert ("Can't instantiate abstract class IncompleteSFH2 " + "with abstract method fSFR") in str(excinfo.value) + + @pytest.mark.parametrize( + "model_dict, normalise, met_edges, expected, warning", + [ + # Simple CDF with Z_max=1, Z_min=0.0 + ({"Z_max": 1, "Z_min": 0.0}, False, np.array([0.0, 0.01, 0.02, 0.03]), + np.array([0.01, 0.01, 0.98]), None), + # No Z_min/Z_max set + ({}, False, np.array([0.0, 0.01, 0.02, 0.03]), + 0.01 * np.ones(3), None), + # Model dict warning + ({"Z_max": 0.02, "Z_min": 0.0}, False, np.array([0.0, 0.01, 0.02, 0.03]), + None, UserWarning), + # Different model dicts + ({"Z_max": 1, "Z_min": 0.015}, False, np.array([0.3, 0.6, 0.9]), + np.array([0.585, 0.4]), None), + # With normalization + ({"Z_max": 1, "Z_min": 0.015}, True, np.array([0.3, 0.6, 0.9]), + None, None), + # Restrict upper bound + ({"Z_max": 0.95, "Z_min": 0.2}, False, np.array([0.3, 0.6, 0.9]), + np.array([0.4, 0.35]), None), + # With normalization + ({"Z_max": 0.95, "Z_min": 0.2}, True, np.array([0.3, 0.6, 0.9]), + None, None), + # Minimum in lowest bin + ({"Z_min": 0.25}, False, np.array([0.2, 0.3, 0.6, 0.9]), + np.array([0.05, 0.3, 0.3]), None), + # Minimum higher than minimum bin + ({"Z_min": 0.35}, False, np.array([0.2, 0.3, 0.6, 0.9]), + np.array([0.0, 0.25, 0.3]), None), + # Minimum in lowest bin and maximum + ({"Z_min": 0.25, "Z_max": 0.8}, False, np.array([0.2, 0.3, 0.6, 0.9]), + np.array([0.05, 0.3, 0.2]), None), + # Minimum higher than minimum bin, narrow range + ({"Z_min": 0.35, "Z_max": 0.4}, False, np.array([0.2, 0.3, 0.6, 0.9]), + np.array([0.0, 0.05, 0.0]), None), + # Minimum higher than minimum bin, medium range + ({"Z_min": 0.35, "Z_max": 0.65}, False, np.array([0.2, 0.3, 0.6, 0.9]), + np.array([0.0, 0.25, 0.05]), None), + ]) + def test_distribute_cdf(self, ConcreteSFH, model_dict, normalise, met_edges, expected, warning): + """Test the _distribute_cdf method with various scenarios.""" + # Create a simple CDF function cdf_func = lambda x: x - met_edges = np.array([0.0, 0.01, 0.02, 0.03]) - - model_dict = {"Z_max": 1, "Z_min": 0.0} - sfh = ConcreteSFH(model_dict) - result = sfh._distribute_cdf(cdf_func, met_edges) - expected = np.array([0.01, 0.01, 0.98]) - np.testing.assert_allclose(result, expected) - # Test with normalization - sfh.normalise = True - result = sfh._distribute_cdf(cdf_func, met_edges) - np.testing.assert_allclose(np.sum(result), 1.0) - - # test no Z_min/Z_max set - model_dict = {} + # Create the SFH instance sfh = ConcreteSFH(model_dict) - result = sfh._distribute_cdf(cdf_func, met_edges) - np.testing.assert_allclose(result, 0.01 * np.ones(3)) + sfh.normalise = normalise - # Test model dict warning - model_dict = {"Z_max": 0.02, "Z_min": 0.0} - sfh = ConcreteSFH(model_dict) - with pytest.warns(UserWarning): + # Test execution with or without warning check + if warning: + with pytest.warns(warning): + result = sfh._distribute_cdf(cdf_func, met_edges) + else: result = sfh._distribute_cdf(cdf_func, met_edges) - # Test with different model dicts - model_dict = {"Z_max": 1, "Z_min": 0.015} - sfh = ConcreteSFH(model_dict) - met_edges = np.array([0.3, 0.6, 0.9]) - result = sfh._distribute_cdf(cdf_func, met_edges) - expected = np.array([0.585, 0.4]) - np.testing.assert_allclose(result, expected) - - # Test with normalise - sfh.normalise = True - result = sfh._distribute_cdf(cdf_func, met_edges) - np.testing.assert_allclose(np.sum(result), 1.0) - - # restrict the upper bound - model_dict = {"Z_max": 0.95, "Z_min": 0.2} - sfh = ConcreteSFH(model_dict) - met_edges = np.array([0.3, 0.6, 0.9]) - result = sfh._distribute_cdf(cdf_func, met_edges) - expected = np.array([0.4, 0.35]) - np.testing.assert_allclose(result, expected) - - # Test with normalise - sfh.normalise = True - result = sfh._distribute_cdf(cdf_func, met_edges) - np.testing.assert_allclose(np.sum(result), 1.0) - - # Test minimum in lowest bin - model_dict = {"Z_min": 0.25} - sfh = ConcreteSFH(model_dict) - met_edges = np.array([0.2, 0.3, 0.6, 0.9]) - result = sfh._distribute_cdf(cdf_func, met_edges) - expected = np.array([0.05, 0.3, 0.3]) - np.testing.assert_allclose(result, expected) - - # Test minumum is higher than minimum bin - model_dict = {"Z_min": 0.35} - sfh = ConcreteSFH(model_dict) - met_edges = np.array([0.2, 0.3, 0.6, 0.9]) - result = sfh._distribute_cdf(cdf_func, met_edges) - expected = np.array([0.0, 0.25, 0.3]) - np.testing.assert_allclose(result, expected) - - # Test minimum in lowest bin and maximum - model_dict = {"Z_min" : 0.25, - 'Z_max' : 0.8} - sfh = ConcreteSFH(model_dict) - met_edges = np.array([0.2, 0.3, 0.6, 0.9]) - result = sfh._distribute_cdf(cdf_func, met_edges) - expected = np.array([0.05, 0.3, 0.2]) - np.testing.assert_allclose(result, expected) - - # Test minumum is higher than minimum bin - model_dict = {"Z_min" : 0.35, - 'Z_max' : 0.4} - sfh = ConcreteSFH(model_dict) - met_edges = np.array([0.2, 0.3, 0.6, 0.9]) - result = sfh._distribute_cdf(cdf_func, met_edges) - expected = np.array([0.0, 0.05, 0.0]) - np.testing.assert_allclose(result, expected) - - # Test minumum is higher than minimum bin - model_dict = {"Z_min" : 0.35, - 'Z_max' : 0.65} - sfh = ConcreteSFH(model_dict) - met_edges = np.array([0.2, 0.3, 0.6, 0.9]) - result = sfh._distribute_cdf(cdf_func, met_edges) - expected = np.array([0.0, 0.25, 0.05]) - np.testing.assert_allclose(result, expected) + # Check results + if normalise: + # For normalise=True, check sum is 1.0 + np.testing.assert_allclose(np.sum(result), 1.0) + elif expected is not None: + # For specific expected values + np.testing.assert_allclose(result, expected) def test_call_method(self): From 9d1f522582b18a63329c45069750717f37e53110 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 10:57:31 +0200 Subject: [PATCH 48/61] update call method test --- .../popsyn/test_star_formation_history.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 1b00fe3dd2..b5ce6054c0 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -162,27 +162,34 @@ def test_call_method(self): """Test the __call__ method.""" class ConcreteSFH(SFHBase): def CSFRD(self, z): - return np.array([1.0, 2.0]) + return z*2.0 + # just a placeholder. Doesn't contribute def mean_metallicity(self, z): - return np.array([0.01, 0.02]) + return z*0.01 def fSFR(self, z, metallicity_bins): # Return a simple array for testing - return np.array([[0.3, 0.7], [0.4, 0.6]]) + delta = np.diff(metallicity_bins) + # normalise + delta /= delta.sum() + + out = np.zeros((len(z), len(delta))) + out[:, :] = delta + return out model_dict = {"Z_max": 0.03} sfh = ConcreteSFH(model_dict) z = np.array([0.5, 1.0]) - met_edges = np.array([0.0, 0.01, 0.03]) + met_edges = np.array([0.0, 0.02, 0.06]) result = sfh(z, met_edges) # Expected: CSFRD(z)[:, np.newaxis] * fSFR(z, met_edges) expected = np.array([ - [1.0 * 0.3, 1.0 * 0.7], - [2.0 * 0.4, 2.0 * 0.6] + [1.0 * 1/3, 1.0 * 2/3], + [2.0 * 1/3, 2.0 * 2/3] ]) np.testing.assert_allclose(result, expected) From a61f64958e44d60328ed6ae0022f4ff30c0d7fbf Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 11:06:18 +0200 Subject: [PATCH 49/61] add std_log_metallicity_dist integer support --- posydon/popsyn/star_formation_history.py | 4 ++-- .../popsyn/test_star_formation_history.py | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index d9038498f1..c0cf18a6a6 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -310,8 +310,8 @@ def std_log_metallicity_dist(self): return 0.39 else: raise ValueError("Unknown sigma choice!") - elif isinstance(sigma, float): - return sigma + elif isinstance(sigma, (float, int)): + return np.float64(sigma) else: raise ValueError(f"Invalid sigma value {sigma}!") diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index b5ce6054c0..3ca867b269 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -218,7 +218,7 @@ def test_init_requires_sigma(self): def test_init_sets_csfrd_params_to_none(self): """Test that CSFRD_params is set to None initially and can be set by subclass""" - model_dict = {"sigma": 0.5, "Z_max": 0.03} + model_dict = {"sigma": 0.5} madau = self.ConcreteMadau(model_dict) assert madau.CSFRD_params is not None assert madau.CSFRD_params["a"] == 0.01 @@ -229,29 +229,34 @@ def test_init_sets_csfrd_params_to_none(self): def test_std_log_metallicity_dist(self): """Test the std_log_metallicity_dist method with different sigma values""" # Test Bavera+20 - model_dict = {"sigma": "Bavera+20", "Z_max": 0.03} + model_dict = {"sigma": "Bavera+20"} madau = self.ConcreteMadau(model_dict) assert madau.std_log_metallicity_dist() == 0.5 # Test Neijssel+19 - model_dict = {"sigma": "Neijssel+19", "Z_max": 0.03} + model_dict = {"sigma": "Neijssel+19"} madau = self.ConcreteMadau(model_dict) assert madau.std_log_metallicity_dist() == 0.39 # Test float value - model_dict = {"sigma": 0.45, "Z_max": 0.03} + model_dict = {"sigma": 0.45} madau = self.ConcreteMadau(model_dict) assert madau.std_log_metallicity_dist() == 0.45 # Test unknown string - model_dict = {"sigma": "unknown", "Z_max": 0.03} + model_dict = {"sigma": "unknown"} madau = self.ConcreteMadau(model_dict) with pytest.raises(ValueError) as excinfo: madau.std_log_metallicity_dist() assert "Unknown sigma choice!" in str(excinfo.value) + # Test integer type + model_dict = {"sigma": 1} + madau = self.ConcreteMadau(model_dict) + assert madau.std_log_metallicity_dist() == 1.0 + # Test invalid type - model_dict = {"sigma": 1, "Z_max": 0.03} + model_dict = {"sigma": [0.5, 0.6]} madau = self.ConcreteMadau(model_dict) with pytest.raises(ValueError) as excinfo: madau.std_log_metallicity_dist() From 9e6d24ecac3fc4de88a571d838df6be590ae254b Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 11:38:12 +0200 Subject: [PATCH 50/61] make explicit regex expression --- posydon/popsyn/star_formation_history.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index c0cf18a6a6..ae8095fb45 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -748,8 +748,8 @@ def _FOH_to_Z(self, FOH): valid_options = ["Asplund09", "AndersGrevesse89", "GrevesseSauval98", "Villante14"] raise ValueError(f"Invalid Z_solar_scaling " - "'{self.Z_solar_scaling}'." - "Valid options: {valid_options}") + f"'{self.Z_solar_scaling}'. " + f"Valid options: {valid_options}") logZ = np.log10(Zsun) + FOH - FOHsun return 10**logZ @@ -936,7 +936,7 @@ def _load_zavala_data(self): # pragma: no cover tmp_data = pd.read_csv(data_file, names=["redshift", "SFRD_min", "SFRD_max"], skiprows=1, - sep="\s+") + sep=r"\s+") self.redshifts = tmp_data["redshift"].values # The min / max values originally come from their obscured # and unobscured SFRD model. From 46da66b151b4cf922d0cd08cd4c7c95a9ca6b8e5 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 11:49:43 +0200 Subject: [PATCH 51/61] update unit tests & implement remainder of comments Matthias --- .../popsyn/test_star_formation_history.py | 199 +++++++----------- 1 file changed, 78 insertions(+), 121 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 3ca867b269..23c8fc1a9b 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -6,6 +6,7 @@ ] import numpy as np +import pandas as pd import pytest from posydon.popsyn.star_formation_history import SFHBase, MadauBase from posydon.popsyn.star_formation_history import ( @@ -198,97 +199,106 @@ def fSFR(self, z, metallicity_bins): class TestMadauBase: """Test class for MadauBase""" - class ConcreteMadau(MadauBase): - """Concrete subclass of MadauBase for testing""" - def __init__(self, MODEL): - super().__init__(MODEL) - self.CSFRD_params = { - "a": 0.01, + @pytest.fixture + def e_CSFRD_params(self): + return {"a": 0.01, "b": 2.6, "c": 3.2, - "d": 6.2 - } + "d": 6.2, + } + + @pytest.fixture + def ConcreteMadau(self, e_CSFRD_params): + class ConcreteMadau(MadauBase): + """Concrete subclass of MadauBase for testing""" + def __init__(self, MODEL): + super().__init__(MODEL) + self.CSFRD_params = e_CSFRD_params + return ConcreteMadau - def test_init_requires_sigma(self): + def test_init_requires_sigma(self, ConcreteMadau): """Test that MadauBase requires a sigma parameter""" model_dict = {"Z_max": 0.03} with pytest.raises(ValueError) as excinfo: - self.ConcreteMadau(model_dict) + ConcreteMadau(model_dict) assert "sigma not given!" in str(excinfo.value) - def test_init_sets_csfrd_params_to_none(self): - """Test that CSFRD_params is set to None initially and can be set by subclass""" + def test_init_sets_csfrd_params_to_none(self, ConcreteMadau, e_CSFRD_params): + """Test that CSFRD_params is not set to None initially""" model_dict = {"sigma": 0.5} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) assert madau.CSFRD_params is not None - assert madau.CSFRD_params["a"] == 0.01 - assert madau.CSFRD_params["b"] == 2.6 - assert madau.CSFRD_params["c"] == 3.2 - assert madau.CSFRD_params["d"] == 6.2 + assert madau.CSFRD_params["a"] == e_CSFRD_params["a"] + assert madau.CSFRD_params["b"] == e_CSFRD_params["b"] + assert madau.CSFRD_params["c"] == e_CSFRD_params["c"] + assert madau.CSFRD_params["d"] == e_CSFRD_params["d"] - def test_std_log_metallicity_dist(self): + def test_std_log_metallicity_dist(self, ConcreteMadau): """Test the std_log_metallicity_dist method with different sigma values""" # Test Bavera+20 model_dict = {"sigma": "Bavera+20"} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) assert madau.std_log_metallicity_dist() == 0.5 # Test Neijssel+19 model_dict = {"sigma": "Neijssel+19"} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) assert madau.std_log_metallicity_dist() == 0.39 # Test float value model_dict = {"sigma": 0.45} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) assert madau.std_log_metallicity_dist() == 0.45 # Test unknown string model_dict = {"sigma": "unknown"} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) with pytest.raises(ValueError) as excinfo: madau.std_log_metallicity_dist() assert "Unknown sigma choice!" in str(excinfo.value) # Test integer type model_dict = {"sigma": 1} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) assert madau.std_log_metallicity_dist() == 1.0 # Test invalid type model_dict = {"sigma": [0.5, 0.6]} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) with pytest.raises(ValueError) as excinfo: madau.std_log_metallicity_dist() assert "Invalid sigma value" in str(excinfo.value) - def test_csfrd(self): + def test_csfrd(self, ConcreteMadau, e_CSFRD_params): """Test the CSFRD method""" model_dict = {"sigma": 0.5} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) + + def tmp_CSFRD(z): + return (e_CSFRD_params["a"] * ((1 + z)**e_CSFRD_params["b"]) + / (1 + ((1 + z)/e_CSFRD_params["c"])**e_CSFRD_params["d"])) # Test with single value z = 0.0 result = madau.CSFRD(z) - expected = 0.01 * 1**2.6 / (1 + (1/3.2)**6.2) # a * (1+z)^b / (1 + ((1+z)/c)^d) with z=0 + # a * (1+z)^b / (1 + ((1+z)/c)^d) with z=0 + expected = tmp_CSFRD(z) np.testing.assert_allclose(result, expected) # Test with array of values z_array = np.array([0.0, 1.0, 2.0]) result = madau.CSFRD(z_array) expected = np.array([ - 0.01 * 1**2.6 / (1 + (1/3.2)**6.2), # z=0 - 0.01 * 2**2.6 / (1 + (2/3.2)**6.2), # z=1 - 0.01 * 3**2.6 / (1 + (3/3.2)**6.2) # z=2 + tmp_CSFRD(z) for z in z_array ]) np.testing.assert_allclose(result, expected) - def test_mean_metallicity(self): + def test_mean_metallicity(self, ConcreteMadau): """Test the mean_metallicity method""" from posydon.utils.constants import Zsun model_dict = {"sigma": 0.5, "Z_max": 0.03} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) # Test with single value z = 0.0 @@ -302,10 +312,10 @@ def test_mean_metallicity(self): expected = 10**(0.153 - 0.074 * z_array**1.34) * Zsun np.testing.assert_allclose(result, expected) - def test_fsfr(self): + def test_fsfr(self, ConcreteMadau): """Test the fSFR method""" model_dict = {"sigma": 0.5, "Z_max": 1} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) # Test with redshift array and metallicity bins z = np.array([0.0, 1.0]) @@ -320,12 +330,11 @@ def test_fsfr(self): from scipy.stats import norm mean0, mean1 = (np.log10(madau.mean_metallicity(z)) - model_dict['sigma']**2 * np.log(10) / 2) - print(mean0, mean1) expected1 = np.array( # integral from 0.001 to 0.01; Z_min = lowest bin edge - [norm.cdf(np.log10(0.01), mean0, model_dict['sigma']) - - norm.cdf(np.log10(0.001), mean0, model_dict['sigma']), + [(norm.cdf(np.log10(0.01), mean0, model_dict['sigma']) + - norm.cdf(np.log10(0.001), mean0, model_dict['sigma'])), # integral from 0.01 to 0.02 (norm.cdf(np.log10(0.02), mean0, model_dict['sigma']) - norm.cdf(np.log10(0.01), mean0, model_dict['sigma'])), @@ -335,8 +344,8 @@ def test_fsfr(self): expected2 = np.array( # integral from 0.001 to 0.01;Z_min = lowest bin edge - [norm.cdf(np.log10(0.01), mean1, model_dict['sigma']) - - norm.cdf(np.log10(0.001), mean1, model_dict['sigma']), + [(norm.cdf(np.log10(0.01), mean1, model_dict['sigma']) + - norm.cdf(np.log10(0.001), mean1, model_dict['sigma'])), # integral from 0.01 to 0.02 (norm.cdf(np.log10(0.02), mean1, model_dict['sigma']) - norm.cdf(np.log10(0.01), mean1, model_dict['sigma'])), @@ -349,7 +358,7 @@ def test_fsfr(self): # Change Z_min to 0 to include the rest of the lowest mets model_dict = {"sigma": 0.5, "Z_max": 0.3, "Z_min": 0} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) result = madau.fSFR(z, met_bins) expected1 = np.array( @@ -369,19 +378,19 @@ def test_fsfr(self): # Test with normalise model_dict = {"sigma": 0.5, "Z_max": 0.3, "Z_min": 0, "normalise": True} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) result = madau.fSFR(z, met_bins) - print(result) - np.testing.assert_allclose(np.sum(result, axis=1), np.ones(2)) + expected = np.ones(len(z)) + np.testing.assert_allclose(np.sum(result, axis=1), expected) - # Test with Z_min > 0.03 + # Test with Z_min > met_bins[1] model_dict = {"sigma": 0.5, "Z_max": 0.3, "Z_min": 0.02, "normalise": True} - madau = self.ConcreteMadau(model_dict) + madau = ConcreteMadau(model_dict) result = madau.fSFR(z, met_bins) - expected = np.ones(2) + expected = np.ones(len(z)) np.testing.assert_allclose(np.sum(result, axis=1), expected) class TestIllustrisTNG: @@ -471,7 +480,7 @@ def test_mean_metallicity(self, illustris_model, mock_illustris_data): np.testing.assert_allclose(result, Z_interp) # Test empty mass array - flipped_masses[0] = np.zeros_like(flipped_masses[0]) + illustris_model.M[0] = np.zeros_like(flipped_masses[0]) with pytest.raises(AssertionError): result = illustris_model.mean_metallicity(z_values) @@ -527,7 +536,8 @@ def test_csfrd_calculation(self): # Calculate expected values manually p = madau.CSFRD_params - expected = p["a"] * (1.0 + z_values) ** p["b"] / (1.0 + ((1.0 + z_values) / p["c"]) ** p["d"]) + expected = (p["a"] * (1.0 + z_values) ** p["b"] + / (1.0 + ((1.0 + z_values) / p["c"]) ** p["d"])) np.testing.assert_allclose(result, expected) @@ -547,21 +557,6 @@ def test_init_parameters(self): # Check that it inherits correctly from MadauBase assert isinstance(madau, MadauBase) - - def test_csfrd_calculation(self): - """Test that CSFRD calculations match expected values""" - model_dict = {"sigma": 0.5, "Z_max": 0.03} - madau = MadauFragos17(model_dict) - - # Test at specific redshifts - z_values = np.array([0.0, 1.0, 2.0, 6.0]) - result = madau.CSFRD(z_values) - - # Calculate expected values manually using the formula - p = madau.CSFRD_params - expected = p["a"] * (1.0 + z_values) ** p["b"] / (1.0 + ((1.0 + z_values) / p["c"]) ** p["d"]) - - np.testing.assert_allclose(result, expected) class TestNeijssel19: """Tests for the Neijssel19 SFH model""" @@ -579,22 +574,7 @@ def test_init_parameters(self): # Check that it inherits correctly from MadauBase assert isinstance(neijssel, MadauBase) - - def test_csfrd_calculation(self): - """Test that CSFRD calculations match expected values""" - model_dict = {"sigma": 0.5, "Z_max": 0.03} - neijssel = Neijssel19(model_dict) - - # Test at specific redshifts - z_values = np.array([0.0, 1.0, 2.0, 6.0]) - result = neijssel.CSFRD(z_values) - - # Calculate expected values manually using the formula - p = neijssel.CSFRD_params - expected = p["a"] * (1.0 + z_values) ** p["b"] / (1.0 + ((1.0 + z_values) / p["c"]) ** p["d"]) - - np.testing.assert_allclose(result, expected) - + def test_mean_metallicity(self): """Test the overridden mean_metallicity method""" model_dict = {"sigma": 0.5, "Z_max": 0.03} @@ -627,7 +607,8 @@ def test_fsfr_with_lognormal(self): model_dict = {"sigma": 0.5, "Z_max": 0.3, "normalise": True} neijssel = Neijssel19(model_dict) result = neijssel.fSFR(z, met_bins) - np.testing.assert_allclose(np.sum(result, axis=1), np.ones(2)) + expected = np.ones(len(z)) + np.testing.assert_allclose(np.sum(result, axis=1), expected) class TestFujimoto24: """Tests for the Fujimoto24 SFH model""" @@ -645,21 +626,6 @@ def test_init_parameters(self): # Check that it inherits correctly from MadauBase assert isinstance(fujimoto, MadauBase) - - def test_csfrd_calculation(self): - """Test that CSFRD calculations match expected values""" - model_dict = {"sigma": 0.5, "Z_max": 0.03} - fujimoto = Fujimoto24(model_dict) - - # Test at specific redshifts - z_values = np.array([0.0, 1.0, 2.0, 6.0]) - result = fujimoto.CSFRD(z_values) - - # Calculate expected values manually using the formula - p = fujimoto.CSFRD_params - expected = p["a"] * (1.0 + z_values) ** p["b"] / (1.0 + ((1.0 + z_values) / p["c"]) ** p["d"]) - - np.testing.assert_allclose(result, expected) class TestChruslinska21: """Tests for the Chruslinska21 SFH model with mocked data loading.""" @@ -702,7 +668,7 @@ def mock_load_raw_data(self): } @pytest.fixture - def chruslinska_model(self, mock_chruslinska_data, monkeypatch): + def chruslinska_model(self, mock_chruslinska_data): """Create a Chruslinska21 model instance with mocked data.""" model_dict = { "sub_model": "test_model", @@ -731,9 +697,7 @@ def test_foh_to_z_conversion(self, chruslinska_model): result = chruslinska_model._FOH_to_Z(FOH_test) # Expected: 10^(log10(0.0134) + FOH - 8.69) - Zsun = 0.0134 - FOHsun = 8.69 - expected = 10**(np.log10(Zsun) + FOH_test - FOHsun) + expected = 10**(np.log10(0.0134) + FOH_test - 8.69) np.testing.assert_allclose(result, expected) # Test other scaling options @@ -766,7 +730,10 @@ def test_foh_to_z_conversion(self, chruslinska_model): model_dict["Z_solar_scaling"] = "InvalidScaling" with pytest.raises(ValueError) as excinfo: model = Chruslinska21(model_dict) - assert "Invalid Z_solar_scaling!" in str(excinfo.value) + expected_str = ("Invalid Z_solar_scaling 'InvalidScaling'. " + "Valid options: ['Asplund09', 'AndersGrevesse89', " + "'GrevesseSauval98', 'Villante14']") + assert expected_str in str(excinfo.value) def test_mean_metallicity(self, chruslinska_model, mock_chruslinska_data): """Test the mean_metallicity method.""" @@ -795,7 +762,7 @@ def test_csfrd_calculation(self, chruslinska_model, mock_chruslinska_data): # Should be an array of the same length as z_values assert len(result) == len(z_values) - # Should decrease with increasing redshift + # Should decrease with increasing redshift in the assert result[0] > result[1] > result[2] def test_fsfr_calculation(self, chruslinska_model): @@ -834,24 +801,14 @@ def mock_zavala_data(self, monkeypatch): SFRD_min = 0.1 * np.exp(-redshifts / 3.0) # Simple declining function SFRD_max = 0.2 * np.exp(-redshifts / 3.0) # Double the min values - # Create a mock _load_zavala_data method - def mock_load_zavala(self): - self.redshifts = redshifts - if self.sub_model == "min": - self.SFR_data = SFRD_min - elif self.sub_model == "max": - self.SFR_data = SFRD_max - else: - raise ValueError("Invalid sub-model!") - - # Patch the method - monkeypatch.setattr(Zavala21, "_load_zavala_data", mock_load_zavala) + def mock_read_csv(self, **kwargs): + return pd.DataFrame(data={ + "redshift": redshifts, + "SFRD_min": SFRD_min, + "SFRD_max": SFRD_max + }) - return { - "redshifts": redshifts, - "SFRD_min": SFRD_min, - "SFRD_max": SFRD_max - } + monkeypatch.setattr(pd, "read_csv", mock_read_csv) def test_init_parameters(self, mock_zavala_data): """Test that initialization validates and sets parameters correctly.""" @@ -1044,13 +1001,13 @@ def mock_load_data(self): def test_invalid_model(self): """Test that get_SFH_model raises an error for an invalid model.""" - model_dict = {"SFR": "InvalidModel", "sigma": 0.5, "Z_max": 0.03} + model_dict = {"SFR": "InvalidModel"} with pytest.raises(ValueError) as excinfo: model = get_SFH_model(model_dict) assert "Invalid SFR!" in str(excinfo.value) -class TestSFHUtilityFunctions: - """Tests for utility functions in the star_formation_history module.""" +class TestSFR_per_met_at_z: + """Tests for SFR_per_met_at_z function.""" def test_SFR_per_met_at_z(self, monkeypatch): """Test that SFR_per_met_at_z correctly calls the model.""" From 613833e3e90c73f17a0e35bb68b1519c4aa39473 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 11:57:55 +0200 Subject: [PATCH 52/61] cleanup --- posydon/unit_tests/popsyn/test_star_formation_history.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 23c8fc1a9b..089e54555a 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -389,7 +389,10 @@ def test_fsfr(self, ConcreteMadau): "Z_min": 0.02, "normalise": True} madau = ConcreteMadau(model_dict) - result = madau.fSFR(z, met_bins) + warning_str = "Z_min is larger than the lowest metallicity bin." + with pytest.warns(UserWarning, match=warning_str): + result = madau.fSFR(z, met_bins) + #result = madau.fSFR(z, met_bins) expected = np.ones(len(z)) np.testing.assert_allclose(np.sum(result, axis=1), expected) From a6de5703ff5342b8e5e73a99e9b915bc38846983 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Tue, 29 Apr 2025 12:04:58 +0200 Subject: [PATCH 53/61] warning cleanup --- .../popsyn/test_star_formation_history.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 089e54555a..45f81fd3e1 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -120,19 +120,19 @@ def mean_metallicity(self, z): None, None), # Minimum in lowest bin ({"Z_min": 0.25}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.05, 0.3, 0.3]), None), + np.array([0.05, 0.3, 0.3]), UserWarning), # Minimum higher than minimum bin ({"Z_min": 0.35}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.0, 0.25, 0.3]), None), + np.array([0.0, 0.25, 0.3]), UserWarning), # Minimum in lowest bin and maximum ({"Z_min": 0.25, "Z_max": 0.8}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.05, 0.3, 0.2]), None), + np.array([0.05, 0.3, 0.2]), UserWarning), # Minimum higher than minimum bin, narrow range ({"Z_min": 0.35, "Z_max": 0.4}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.0, 0.05, 0.0]), None), + np.array([0.0, 0.05, 0.0]), UserWarning), # Minimum higher than minimum bin, medium range ({"Z_min": 0.35, "Z_max": 0.65}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.0, 0.25, 0.05]), None), + np.array([0.0, 0.25, 0.05]), UserWarning), ]) def test_distribute_cdf(self, ConcreteSFH, model_dict, normalise, met_edges, expected, warning): """Test the _distribute_cdf method with various scenarios.""" @@ -356,8 +356,8 @@ def test_fsfr(self, ConcreteMadau): expected = np.array([expected1, expected2]) np.testing.assert_allclose(result, expected) - # Change Z_min to 0 to include the rest of the lowest mets - model_dict = {"sigma": 0.5, "Z_max": 0.3, "Z_min": 0} + # Change Z_min to a very small number to include the rest of the lowest mets + model_dict = {"sigma": 0.5, "Z_max": 0.3, "Z_min": 1e-11} madau = ConcreteMadau(model_dict) result = madau.fSFR(z, met_bins) @@ -377,7 +377,7 @@ def test_fsfr(self, ConcreteMadau): np.testing.assert_allclose(result, expected) # Test with normalise - model_dict = {"sigma": 0.5, "Z_max": 0.3, "Z_min": 0, "normalise": True} + model_dict = {"sigma": 0.5, "Z_max": 0.3, "Z_min": 1e-11, "normalise": True} madau = ConcreteMadau(model_dict) result = madau.fSFR(z, met_bins) expected = np.ones(len(z)) From 2f570e180a94b065a2c5a6ca2d5d25746cde5c99 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 2 May 2025 10:26:07 +0200 Subject: [PATCH 54/61] add SFHModelWarning + add sort check --- posydon/popsyn/star_formation_history.py | 11 +++++++++-- posydon/utils/posydonwarning.py | 5 +++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index ae8095fb45..dd6ab79f97 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -175,6 +175,11 @@ def _distribute_cdf(self, cdf_func, metallicity_bins): ndarray Fraction of the SFR in the given metallicity bin at the given redshift. ''' + # verify if the metallicity bins are sorted + if not np.all(np.diff(metallicity_bins) > 0): + raise ValueError("Metallicity bins must be sorted " + "in ascending order.") + fSFR = (np.array(cdf_func(metallicity_bins[1:])) - np.array(cdf_func(metallicity_bins[:-1]))) @@ -186,7 +191,8 @@ def _distribute_cdf(self, cdf_func, metallicity_bins): if self.Z_max >= metallicity_bins[-1]: fSFR[-1] = cdf_func(self.Z_max) - cdf_func(metallicity_bins[-2]) else: - Pwarn('Z_max is smaller than the highest metallicity bin.') + Pwarn('Z_max is smaller than the highest metallicity bin.', + 'SFHModelWarning') # find the index of the last bin that is smaller than Z_max last_bin_index = np.searchsorted(metallicity_bins, self.Z_max) - 1 fSFR[last_bin_index] = cdf_func(self.Z_max) - cdf_func(metallicity_bins[last_bin_index]) @@ -196,7 +202,8 @@ def _distribute_cdf(self, cdf_func, metallicity_bins): if self.Z_min <= metallicity_bins[0]: fSFR[0] = cdf_func(metallicity_bins[1]) - cdf_func(self.Z_min) else: - Pwarn('Z_min is larger than the lowest metallicity bin.') + Pwarn('Z_min is larger than the lowest metallicity bin.', + 'SFHModelWarning') # find the index of the first bin that is larger than Z_min first_bin_index = np.searchsorted(metallicity_bins, self.Z_min) -1 fSFR[:first_bin_index] = 0.0 diff --git a/posydon/utils/posydonwarning.py b/posydon/utils/posydonwarning.py index 738b4c86a2..78b347bad7 100644 --- a/posydon/utils/posydonwarning.py +++ b/posydon/utils/posydonwarning.py @@ -102,6 +102,11 @@ class UnsupportedModelWarning(POSYDONWarning): """Warnings related to selecting a model that is not supported.""" def __init__(self, message=''): super().__init__(message) + +class SFHModelWarning(POSYDONWarning): + """Warnings related to the SFH model.""" + def __init__(self, message=''): + super().__init__(message) # All POSYDON warnings subclasses should be defined beforehand From 70d6588fd14ba8336c72137f7020040389d20868 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 2 May 2025 10:34:26 +0200 Subject: [PATCH 55/61] change to sphinx reference style --- posydon/popsyn/star_formation_history.py | 69 ++++++++++++++++-------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index dd6ab79f97..4505221761 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -270,8 +270,14 @@ def __init__(self, SFH_MODEL): - CSFRD_params: dict Parameters for the cosmic star formation rate density (CSFRD) - a, b, c, d : float - Follows the Madau & Dickinson (2014) CSFRD formula (Eq. 15): - https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M/abstract + Follows the Madau & Dickinson (2014) CSFRD formula (Eq. 15) in [1]_ + + References + ---------- + .. [1] Madau, P., & Dickinson, M. (2014). Cosmic star formation history. + Annual Review of Astronomy and Astrophysics, 52, 415-486. + https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M/abstract + """ if "sigma" not in SFH_MODEL: raise ValueError("sigma not given!") @@ -370,11 +376,15 @@ def fSFR(self, z, metallicity_bins): return fSFR class MadauDickinson14(MadauBase): - """Madau & Dickinson (2014) star formation history model using the - mean metallicity evolution of Madau & Fragos (2017). + """Madau & Dickinson (2014) [1]_ star formation history model using the + mean metallicity evolution of Madau & Fragos (2017) [2]_. - Madau & Dickinson (2014), ARA&A, 52, 415 - https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M/abstract + References + ---------- + .. [1] Madau, P., & Dickinson, M. (2014). ARA&A, 52, 415-486. + https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M + .. [2] Madau, P., & Fragos, T. (2017). ApJ, 840(1), 39. + https://ui.adsabs.harvard.edu/abs/2017ApJ...840...39M """ def __init__(self, SFH_MODEL): @@ -402,8 +412,12 @@ def __init__(self, SFH_MODEL): - CSFRD_params: dict Parameters for the cosmic star formation rate density (CSFRD) - a, b, c, d : float - Follows the Madau & Dickinson (2014) CSFRD formula (Eq. 15): - https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M/abstract + Follows the Madau & Dickinson (2014) CSFRD formula (Eq. 15) in [1]_ + + References + ---------- + .. [1] Madau, P., & Dickinson, M. (2014). ARA&A, 52, 415-486. + https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M """ super().__init__(SFH_MODEL) # Parameters for Madau+Dickinson14 CSFRD @@ -416,10 +430,13 @@ def __init__(self, SFH_MODEL): class MadauFragos17(MadauBase): """The Madau & Fragos (2017) star formation history model with the - metallicity evolution of Madau & Fragos (2017). + metallicity evolution of Madau & Fragos (2017) [1]_. + - Madau & Fragos (2017), ApJ, 840, 39 - http://adsabs.harvard.edu/abs/2017ApJ...840...39M + References + ---------- + .. [1] Madau, P., & Fragos, T. (2017). ApJ, 840(1), 39. + https://ui.adsabs.harvard.edu/abs/2017ApJ...840...39M/abstract """ def __init__(self, SFH_MODEL): @@ -453,15 +470,20 @@ def __init__(self, SFH_MODEL): } class Neijssel19(MadauBase): - """The Neijssel et al. (2019) star formation history model, which fits - the Madau & Dickinson (2014) cosmic star formation rate density formula + """The Neijssel et al. (2019) [1]_ star formation history model, which fits + the Madau & Dickinson (2014) [2]_ cosmic star formation rate density formula with the BBH merger rate and uses a truncated log-normal distribution for the mean metallicity distribution. The mean metallicity evolution follows the Langer and Normal parameterisation also fitted to the BBH merger rate. - Neijssel et al. (2019), MNRAS, 490, 3740 - http://adsabs.harvard.edu/abs/2019MNRAS.490.3740N + + References + ---------- + .. [1] Neijssel, C. J., et al. (2019). MNRAS, 490, 3740. + https://ui.adsabs.harvard.edu/abs/2019MNRAS.490.3740N + .. [2] Madau, P., & Fragos, T. (2017). ApJ, 840(1), 39. + https://ui.adsabs.harvard.edu/abs/2017ApJ...840...39M/ """ def __init__(self, SFH_MODEL): """Initialise the Neijssel+19 model @@ -659,10 +681,13 @@ def fSFR(self, z, metallicity_bins): return fSFR class Chruslinska21(SFHBase): - """The Chruślińska+21 star formation history model. + """The Chruślińska+21 star formation history model [1]_. - Chruślińska et al. (2021), MNRAS, 508, 4994 - https://ui.adsabs.harvard.edu/abs/2021MNRAS.508.4994C/abstract + + References + ---------- + .. [1] Chruślińska, M., et al. (2021). MNRAS, 508, 4994. + https://ui.adsabs.harvard.edu/abs/2021MNRAS.508.4994C Data source: https://ftp.science.ru.nl/astro/mchruslinska/Chruslinska_et_al_2021/ @@ -903,13 +928,15 @@ def __init__(self, SFH_MODEL): } class Zavala21(MadauBase): - """The Zavala et al. (2021) star formation history model. + """The Zavala et al. (2021) star formation history model [1]_. The "min" and "max" models are based on the obscured and unobscured star formation rate density models, respectively. - https://dx.doi.org/10.3847/1538-4357/abdb27 - + References + ---------- + .. [1] Zavala, J., et al. (2021). ApJ, 909(2), 165. + https://ui.adsabs.harvard.edu/abs/2021ApJ...909..165Z/ """ def __init__(self, SFH_MODEL): """Initialise the Zavala+21 model From 0cf1b052eca5a73fad41a91874e4d75f2de40e55 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 2 May 2025 10:35:03 +0200 Subject: [PATCH 56/61] add reference --- posydon/popsyn/star_formation_history.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/posydon/popsyn/star_formation_history.py b/posydon/popsyn/star_formation_history.py index 4505221761..196c79a2b5 100644 --- a/posydon/popsyn/star_formation_history.py +++ b/posydon/popsyn/star_formation_history.py @@ -388,8 +388,8 @@ class MadauDickinson14(MadauBase): """ def __init__(self, SFH_MODEL): - """Initialise the Madau & Dickinson (2014) SFH model with the - metallicity evolution of Madau & Fragos (2017). + """Initialise the Madau & Dickinson (2014) [1]_ SFH model with the + metallicity evolution of Madau & Fragos (2017) [2]_. Parameters ---------- @@ -418,6 +418,8 @@ def __init__(self, SFH_MODEL): ---------- .. [1] Madau, P., & Dickinson, M. (2014). ARA&A, 52, 415-486. https://ui.adsabs.harvard.edu/abs/2014ARA%26A..52..415M + .. [2] Madau, P., & Fragos, T. (2017). ApJ, 840(1), 39. + https://ui.adsabs.harvard.edu/abs/2017ApJ...840...39M """ super().__init__(SFH_MODEL) # Parameters for Madau+Dickinson14 CSFRD From b736b51c23801363074db7708d9e9a2f33f1b964 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 2 May 2025 10:50:54 +0200 Subject: [PATCH 57/61] add check for None output in error --- .../popsyn/test_star_formation_history.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 45f81fd3e1..a0f58c89fd 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd import pytest +from posydon.utils.posydonwarning import SFHModelWarning from posydon.popsyn.star_formation_history import SFHBase, MadauBase from posydon.popsyn.star_formation_history import ( MadauDickinson14, @@ -105,7 +106,7 @@ def mean_metallicity(self, z): 0.01 * np.ones(3), None), # Model dict warning ({"Z_max": 0.02, "Z_min": 0.0}, False, np.array([0.0, 0.01, 0.02, 0.03]), - None, UserWarning), + None, SFHModelWarning), # Different model dicts ({"Z_max": 1, "Z_min": 0.015}, False, np.array([0.3, 0.6, 0.9]), np.array([0.585, 0.4]), None), @@ -120,19 +121,19 @@ def mean_metallicity(self, z): None, None), # Minimum in lowest bin ({"Z_min": 0.25}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.05, 0.3, 0.3]), UserWarning), + np.array([0.05, 0.3, 0.3]), SFHModelWarning), # Minimum higher than minimum bin ({"Z_min": 0.35}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.0, 0.25, 0.3]), UserWarning), + np.array([0.0, 0.25, 0.3]), SFHModelWarning), # Minimum in lowest bin and maximum ({"Z_min": 0.25, "Z_max": 0.8}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.05, 0.3, 0.2]), UserWarning), + np.array([0.05, 0.3, 0.2]), SFHModelWarning), # Minimum higher than minimum bin, narrow range ({"Z_min": 0.35, "Z_max": 0.4}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.0, 0.05, 0.0]), UserWarning), + np.array([0.0, 0.05, 0.0]), SFHModelWarning), # Minimum higher than minimum bin, medium range ({"Z_min": 0.35, "Z_max": 0.65}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.0, 0.25, 0.05]), UserWarning), + np.array([0.0, 0.25, 0.05]), SFHModelWarning), ]) def test_distribute_cdf(self, ConcreteSFH, model_dict, normalise, met_edges, expected, warning): """Test the _distribute_cdf method with various scenarios.""" @@ -145,8 +146,9 @@ def test_distribute_cdf(self, ConcreteSFH, model_dict, normalise, met_edges, exp # Test execution with or without warning check if warning: - with pytest.warns(warning): + with pytest.warns(warning) as excinfo: result = sfh._distribute_cdf(cdf_func, met_edges) + assert excinfo[0].message.args[0] is not None else: result = sfh._distribute_cdf(cdf_func, met_edges) @@ -390,7 +392,7 @@ def test_fsfr(self, ConcreteMadau): "normalise": True} madau = ConcreteMadau(model_dict) warning_str = "Z_min is larger than the lowest metallicity bin." - with pytest.warns(UserWarning, match=warning_str): + with pytest.warns(SFHModelWarning, match=warning_str): result = madau.fSFR(z, met_bins) #result = madau.fSFR(z, met_bins) expected = np.ones(len(z)) From adc7f564f0b7886196d4f16408b9d54674f77d44 Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 2 May 2025 10:53:04 +0200 Subject: [PATCH 58/61] add unit test for unsorted Metalicity bins --- .../popsyn/test_star_formation_history.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index a0f58c89fd..f333c9b37c 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -160,6 +160,20 @@ def test_distribute_cdf(self, ConcreteSFH, model_dict, normalise, met_edges, exp # For specific expected values np.testing.assert_allclose(result, expected) + def test_distribute_cdf_invalid(self, ConcreteSFH): + """Test the _distribute_cdf method with invalid inputs.""" + # Create a simple CDF function + cdf_func = lambda x: x + + # Create the SFH instance + model_dict = {} + sfh = ConcreteSFH(model_dict) + + # Test with invalid metallicity edges + met_edges = np.array([0.04, 0.01, 0.02]) + with pytest.raises(ValueError) as excinfo: + sfh._distribute_cdf(cdf_func, met_edges) + assert "Metallicity bins must be sorted" in str(excinfo.value) def test_call_method(self): """Test the __call__ method.""" From 3c82c0da007d666351ad73eb018d59db7f56404f Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 2 May 2025 10:54:31 +0200 Subject: [PATCH 59/61] suggested change --- posydon/unit_tests/popsyn/test_star_formation_history.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index f333c9b37c..07e104204b 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -345,7 +345,7 @@ def test_fsfr(self, ConcreteMadau): # THIS IS A VALIDATION TEST from scipy.stats import norm mean0, mean1 = (np.log10(madau.mean_metallicity(z)) - - model_dict['sigma']**2 * np.log(10) / 2) + - model_dict['sigma']**2 * np.log(10) / 2) expected1 = np.array( # integral from 0.001 to 0.01; Z_min = lowest bin edge @@ -781,7 +781,7 @@ def test_csfrd_calculation(self, chruslinska_model, mock_chruslinska_data): # Should be an array of the same length as z_values assert len(result) == len(z_values) - # Should decrease with increasing redshift in the + # Should decrease with increasing redshift in the test case assert result[0] > result[1] > result[2] def test_fsfr_calculation(self, chruslinska_model): From 510a958d22c4d9bb4568e4066e60d4b8efd09e7f Mon Sep 17 00:00:00 2001 From: Max Briel Date: Fri, 2 May 2025 10:57:25 +0200 Subject: [PATCH 60/61] add new warning class to test --- posydon/unit_tests/utils/test_posydonwarning.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/posydon/unit_tests/utils/test_posydonwarning.py b/posydon/unit_tests/utils/test_posydonwarning.py index 3fc65c5f43..0641a15206 100644 --- a/posydon/unit_tests/utils/test_posydonwarning.py +++ b/posydon/unit_tests/utils/test_posydonwarning.py @@ -27,8 +27,9 @@ def test_dir(self): 'ClassificationWarning', 'EvolutionWarning',\ 'InappropriateValueWarning', 'IncompletenessWarning',\ 'InterpolationWarning', 'MissingFilesWarning',\ - 'NoPOSYDONWarnings', 'OverwriteWarning', 'POSYDONWarning',\ - 'Pwarn', 'ReplaceValueWarning', 'SetPOSYDONWarnings',\ + 'NoPOSYDONWarnings', 'OverwriteWarning', 'SFHModelWarning',\ + 'POSYDONWarning','Pwarn', 'ReplaceValueWarning',\ + 'SetPOSYDONWarnings',\ 'UnsupportedModelWarning', '_CAUGHT_POSYDON_WARNINGS',\ '_Caught_POSYDON_Warnings', '_POSYDONWarning_subclasses',\ '_POSYDON_WARNINGS_REGISTRY', '__authors__',\ @@ -101,6 +102,10 @@ def test_instance_UnsupportedModelWarning(self): assert isclass(totest.UnsupportedModelWarning) assert issubclass(totest.UnsupportedModelWarning,\ totest.POSYDONWarning) + + def test_instance_SFHModelWarning(self): + assert isclass(totest.SFHModelWarning) + assert issubclass(totest.SFHModelWarning, totest.POSYDONWarning) def test_instance_POSYDONWarning_subclasses(self): assert isinstance(totest._POSYDONWarning_subclasses, (dict)) From b9d91a3abbb0b4d305b0a7d386a01ad6d0f192a8 Mon Sep 17 00:00:00 2001 From: Max <14039563+maxbriel@users.noreply.github.com> Date: Thu, 8 May 2025 13:50:42 +0200 Subject: [PATCH 61/61] Update posydon/unit_tests/popsyn/test_star_formation_history.py Co-authored-by: mkruckow <122798003+mkruckow@users.noreply.github.com> --- .../popsyn/test_star_formation_history.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index 07e104204b..8c5514ef27 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -106,7 +106,7 @@ def mean_metallicity(self, z): 0.01 * np.ones(3), None), # Model dict warning ({"Z_max": 0.02, "Z_min": 0.0}, False, np.array([0.0, 0.01, 0.02, 0.03]), - None, SFHModelWarning), + None, 'Z_max is smaller than the highest metallicity bin.'), # Different model dicts ({"Z_max": 1, "Z_min": 0.015}, False, np.array([0.3, 0.6, 0.9]), np.array([0.585, 0.4]), None), @@ -121,19 +121,24 @@ def mean_metallicity(self, z): None, None), # Minimum in lowest bin ({"Z_min": 0.25}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.05, 0.3, 0.3]), SFHModelWarning), + np.array([0.05, 0.3, 0.3]), + 'Z_min is larger than the lowest metallicity bin.'), # Minimum higher than minimum bin ({"Z_min": 0.35}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.0, 0.25, 0.3]), SFHModelWarning), + np.array([0.0, 0.25, 0.3]), + 'Z_min is larger than the lowest metallicity bin.'), # Minimum in lowest bin and maximum ({"Z_min": 0.25, "Z_max": 0.8}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.05, 0.3, 0.2]), SFHModelWarning), + np.array([0.05, 0.3, 0.2]), + 'Z_max is smaller than the highest metallicity bin.'), # Minimum higher than minimum bin, narrow range ({"Z_min": 0.35, "Z_max": 0.4}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.0, 0.05, 0.0]), SFHModelWarning), + np.array([0.0, 0.05, 0.0]), + 'Z_max is smaller than the highest metallicity bin.'), # Minimum higher than minimum bin, medium range ({"Z_min": 0.35, "Z_max": 0.65}, False, np.array([0.2, 0.3, 0.6, 0.9]), - np.array([0.0, 0.25, 0.05]), SFHModelWarning), + np.array([0.0, 0.25, 0.05]), + 'Z_max is smaller than the highest metallicity bin.'), ]) def test_distribute_cdf(self, ConcreteSFH, model_dict, normalise, met_edges, expected, warning): """Test the _distribute_cdf method with various scenarios.""" @@ -145,10 +150,9 @@ def test_distribute_cdf(self, ConcreteSFH, model_dict, normalise, met_edges, exp sfh.normalise = normalise # Test execution with or without warning check - if warning: - with pytest.warns(warning) as excinfo: + if warning is not None: + with pytest.warns(SFHModelWarning, match=warning): result = sfh._distribute_cdf(cdf_func, met_edges) - assert excinfo[0].message.args[0] is not None else: result = sfh._distribute_cdf(cdf_func, met_edges)