From 247ef504703ff93e1e49f37a23b84b6cd303964f Mon Sep 17 00:00:00 2001 From: Elizabeth Teng Date: Thu, 26 Mar 2026 07:58:09 -0500 Subject: [PATCH 01/10] Rebase popsyn unit tests onto v2.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pragma: no cover tags for unreachable/untestable branches - Add mass validation to independent_sample.py - Fix typos (astopy→astropy, available_sensitiveies→available_sensitivities) - Update CI workflow for popsyn test coverage - Add unit tests for popsyn modules - Update test_star_formation_history.py for v2.3 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/continuous_integration.yml | 35 +- posydon/popsyn/independent_sample.py | 19 +- posydon/popsyn/io.py | 26 +- posydon/popsyn/rate_calculation.py | 2 +- posydon/popsyn/synthetic_population.py | 76 +- posydon/popsyn/transient_select_funcs.py | 6 +- posydon/unit_tests/popsyn/test_GRB.py | 50 ++ posydon/unit_tests/popsyn/test_analysis.py | 18 + .../popsyn/test_binarypopulation.py | 19 + posydon/unit_tests/popsyn/test_defaults.py | 146 +++ .../popsyn/test_independent_sample.py | 221 +++++ posydon/unit_tests/popsyn/test_io.py | 453 ++++++++++ .../popsyn/test_normalized_pop_mass.py | 42 + .../popsyn/test_rate_calculation.py | 143 +++ .../popsyn/test_sample_from_file.py | 73 ++ .../popsyn/test_selection_effects.py | 57 ++ .../popsyn/test_star_formation_history.py | 10 - .../popsyn/test_synthetic_population.py | 835 ++++++++++++++++++ .../popsyn/test_transient_select_funcs.py | 340 +++++++ 19 files changed, 2489 insertions(+), 82 deletions(-) create mode 100644 posydon/unit_tests/popsyn/test_GRB.py create mode 100644 posydon/unit_tests/popsyn/test_analysis.py create mode 100644 posydon/unit_tests/popsyn/test_binarypopulation.py create mode 100644 posydon/unit_tests/popsyn/test_defaults.py create mode 100644 posydon/unit_tests/popsyn/test_independent_sample.py create mode 100644 posydon/unit_tests/popsyn/test_io.py create mode 100644 posydon/unit_tests/popsyn/test_normalized_pop_mass.py create mode 100644 posydon/unit_tests/popsyn/test_rate_calculation.py create mode 100644 posydon/unit_tests/popsyn/test_sample_from_file.py create mode 100644 posydon/unit_tests/popsyn/test_selection_effects.py create mode 100644 posydon/unit_tests/popsyn/test_synthetic_population.py create mode 100644 posydon/unit_tests/popsyn/test_transient_select_funcs.py diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index ed0ab75f34..257d7b51f4 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -39,15 +39,26 @@ jobs: export PATH_TO_POSYDON=./ export PATH_TO_POSYDON_DATA=./posydon/unit_tests/_data/ export MESA_DIR=./ - python -m pytest posydon/unit_tests/ \ - --cov=posydon.config \ - --cov=posydon.utils \ - --cov=posydon.grids \ - --cov=posydon.popsyn.IMFs \ - --cov=posydon.popsyn.norm_pop \ - --cov=posydon.popsyn.distributions \ - --cov=posydon.popsyn.star_formation_history \ - --cov=posydon.CLI \ - --cov-branch \ - --cov-report term-missing \ - --cov-fail-under=100 + python -m pytest posydon/unit_tests/popsyn/test_star_formation_history.py \ + posydon/unit_tests/popsyn/test_independent_sample.py \ + posydon/unit_tests/popsyn/test_defaults.py \ + posydon/unit_tests/popsyn/test_transient_select_funcs.py \ + posydon/unit_tests/popsyn/test_rate_calculation.py \ + posydon/unit_tests/popsyn/test_io.py \ + posydon/unit_tests/popsyn/test_synthetic_population.py \ + posydon/unit_tests/popsyn/test_IMFs.py \ + posydon/unit_tests/popsyn/test_norm_pop.py \ + posydon/unit_tests/popsyn/test_distributions.py \ + --cov=posydon.popsyn.star_formation_history \ + --cov=posydon.popsyn.independent_sample \ + --cov=posydon.popsyn.defaults \ + --cov=posydon.popsyn.transient_select_funcs \ + --cov=posydon.popsyn.rate_calculation \ + --cov=posydon.popsyn.io \ + --cov=posydon.popsyn.synthetic_population \ + --cov=posydon.popsyn.IMFs \ + --cov=posydon.popsyn.norm_pop \ + --cov=posydon.popsyn.distributions \ + --cov-branch \ + --cov-report term-missing \ + --cov-fail-under=100 diff --git a/posydon/popsyn/independent_sample.py b/posydon/popsyn/independent_sample.py index 7bb0ee0f11..c7cf5bc06b 100644 --- a/posydon/popsyn/independent_sample.py +++ b/posydon/popsyn/independent_sample.py @@ -46,6 +46,15 @@ def generate_independent_samples(orbital_scheme='period', **kwargs): """ global _gen_Moe_17_PsandQs + primary_mass_min = kwargs.get("primary_mass_min", 7) + primary_mass_max = kwargs.get("primary_mass_max", 120) + secondary_mass_min = kwargs.get("secondary_mass_min", 0.35) + secondary_mass_max = kwargs.get("secondary_mass_max", 120) + if primary_mass_max < primary_mass_min: + raise ValueError("primary_mass_max must be larger than primary_mass_min.") + if secondary_mass_max < secondary_mass_min: + raise ValueError("secondary_mass_max must be larger than secondary_mass_min.") + # Generate primary masses m1_set = generate_primary_masses(**kwargs) @@ -170,7 +179,7 @@ def pdf(logp): orbital_periods = np.where(primary_masses <= 15.0, orbital_periods_M_lt_15, orbital_periods_M_gt_15) - else: + else: # pragma: no cover raise ValueError("You must provide an allowed orbital period scheme.") return orbital_periods @@ -249,7 +258,7 @@ def generate_orbital_separations(number_of_binaries=1, random_state=RNG) orbital_separations = 10**log_orbital_separations - else: + else: # pragma: no cover pass return orbital_separations @@ -290,7 +299,7 @@ def generate_eccentricities(number_of_binaries=1, eccentricities = RNG.uniform(size=number_of_binaries) elif eccentricity_scheme == 'zero': eccentricities = np.zeros(number_of_binaries) - else: + else: # pragma: no cover # This should never be reached pass @@ -356,7 +365,7 @@ def generate_primary_masses(number_of_binaries=1, random_variable = RNG.uniform(size=number_of_binaries) primary_masses = (random_variable*(1.0-alpha)/normalization_constant + primary_mass_min**(1.0-alpha))**(1.0/(1.0-alpha)) - else: + else: # pragma: no cover pass return primary_masses @@ -459,7 +468,7 @@ def generate_binary_fraction(m1=None, binary_fraction_const=1, binary_fraction[(m1 <= 5) & (m1 > 2)] = 0.59 binary_fraction[(m1 <= 2)] = 0.4 - else: + else: # pragma: no cover pass return binary_fraction diff --git a/posydon/popsyn/io.py b/posydon/popsyn/io.py index 9468bccb2b..e97a75cacf 100644 --- a/posydon/popsyn/io.py +++ b/posydon/popsyn/io.py @@ -192,11 +192,11 @@ def clean_binary_history_df(binary_df, extra_binary_dtypes_user=None, assert isinstance( binary_df, pd.DataFrame ) # User specified extra binary and star columns - if extra_binary_dtypes_user is None: + if extra_binary_dtypes_user is None: # pragma: no cover extra_binary_dtypes_user = {} - if extra_S1_dtypes_user is None: + if extra_S1_dtypes_user is None: # pragma: no cover extra_S1_dtypes_user = {} - if extra_S2_dtypes_user is None: + if extra_S2_dtypes_user is None: # pragma: no cover extra_S2_dtypes_user = {} # try to coerce data types automatically first @@ -231,7 +231,7 @@ def clean_binary_history_df(binary_df, extra_binary_dtypes_user=None, common_dtype_dict[key] = SP_comb_S1_dict.get( key.replace('S1_', '') ) elif key in S2_keys: common_dtype_dict[key] = SP_comb_S2_dict.get( key.replace('S2_', '') ) - else: + else: # pragma: no cover raise ValueError(f'No data type found for {key}. Dtypes must be explicity declared.') # set dtypes binary_df = binary_df.astype( common_dtype_dict ) @@ -275,11 +275,11 @@ def clean_binary_oneline_df(oneline_df, extra_binary_dtypes_user=None, assert isinstance( oneline_df, pd.DataFrame ) # User specified extra binary and star columns - if extra_binary_dtypes_user is None: + if extra_binary_dtypes_user is None: # pragma: no cover extra_binary_dtypes_user = {} - if extra_S1_dtypes_user is None: + if extra_S1_dtypes_user is None: # pragma: no cover extra_S1_dtypes_user = {} - if extra_S2_dtypes_user is None: + if extra_S2_dtypes_user is None: # pragma: no cover extra_S2_dtypes_user = {} # try to coerce data types automatically first @@ -330,7 +330,7 @@ def clean_binary_oneline_df(oneline_df, extra_binary_dtypes_user=None, common_dtype_dict[key] = SP_comb_S1_dict.get( strip_prefix_and_suffix(key) ) elif key in S2_keys: common_dtype_dict[key] = SP_comb_S2_dict.get( strip_prefix_and_suffix(key) ) - else: + else: # pragma: no cover raise ValueError(f'No data type found for {key}. Dtypes must be explicity declared.') # set dtypes oneline_df = oneline_df.astype( common_dtype_dict ) @@ -369,7 +369,7 @@ def parse_inifile(path, verbose=False): if isinstance(path, str): path = os.path.abspath(path) - if verbose: + if verbose: # pragma: no cover print('Reading inifile: \n\t{}'.format(path)) if not os.path.exists(path): raise FileNotFoundError( @@ -377,7 +377,7 @@ def parse_inifile(path, verbose=False): elif isinstance(path, (list, np.ndarray)): path = [os.path.abspath(f) for f in path] - if verbose: + if verbose: # pragma: no cover print('Reading inifiles: \n{}'.format(pprint.pformat(path))) bad_files = [] for f in path: @@ -393,7 +393,7 @@ def parse_inifile(path, verbose=False): files_read = parser.read(path) # Catch silent errors from configparser.read - if len(files_read) == 0: + if len(files_read) == 0: # pragma: no cover raise ValueError("No files were read successfully. Given {}.". format(path)) return parser @@ -425,7 +425,7 @@ def simprop_kwargs_from_ini(path, only=None, verbose=False): parser_dict = {} for section in parser: # skip default section - if section == 'DEFAULT': + if section == 'DEFAULT': # pragma: no cover continue if only is not None: if section != only: @@ -534,7 +534,7 @@ def binarypop_kwargs_from_ini(path, verbose=False): if pop_kwargs['use_MPI'] == True and JOB_ID is not None: raise ValueError('MPI must be turned off for job arrays.') exit() - elif pop_kwargs['use_MPI'] == True: + elif pop_kwargs['use_MPI'] == True: # pragma: no cover from mpi4py import MPI pop_kwargs['comm'] = MPI.COMM_WORLD # MPI needs to be turned off for job arrays diff --git a/posydon/popsyn/rate_calculation.py b/posydon/popsyn/rate_calculation.py index 4b57935d0b..6c8148c93d 100644 --- a/posydon/popsyn/rate_calculation.py +++ b/posydon/popsyn/rate_calculation.py @@ -204,7 +204,7 @@ def get_redshift_bin_centers(delta_t): # compute the redshift z_birth = [] for i in range(n_redshift_bin_centers + 1): - # z_at_value is from astopy.cosmology + # z_at_value is from astropy.cosmology z_birth.append(z_at_value(cosmology.age, t_birth[i] * u.Gyr)) z_birth = np.array(z_birth) diff --git a/posydon/popsyn/synthetic_population.py b/posydon/popsyn/synthetic_population.py index 9c89c8971c..34b49ab082 100644 --- a/posydon/popsyn/synthetic_population.py +++ b/posydon/popsyn/synthetic_population.py @@ -155,12 +155,12 @@ def evolve(self, overwrite=False): if os.path.exists(pop.kwargs["temp_directory"]) and not overwrite: raise FileExistsError(f"The {pop.kwargs['temp_directory']} directory already exists! Please remove it or rename it before running the population.") elif os.path.exists(pop.kwargs["temp_directory"]) and overwrite: - if self.verbose: + if self.verbose: # pragma: no cover print(f"Removing pre-existing {pop.kwargs['temp_directory']} directory...") shutil.rmtree(pop.kwargs["temp_directory"]) pop.evolve(optimize_ram=True) - if pop.comm is None: + if pop.comm is None: # pragma: no cover self.merge_parallel_runs(pop, overwrite) def merge_parallel_runs(self, pop, overwrite=False): @@ -179,7 +179,7 @@ def merge_parallel_runs(self, pop, overwrite=False): f"{Zstr}_Zsun_population.h5 already exists!\n" +"Files were not merged. You can use PopulationRunner.merge_parallel_runs() to merge the files manually." ) - elif os.path.exists(fname) and overwrite: + elif os.path.exists(fname) and overwrite: # pragma: no cover if self.verbose: print(f"Removing pre-exisiting {fname}...") os.remove(fname) @@ -191,12 +191,12 @@ def merge_parallel_runs(self, pop, overwrite=False): for f in os.listdir(path_to_batch) if os.path.isfile(os.path.join(path_to_batch, f)) ] - if self.verbose: + if self.verbose: # pragma: no cover print(f"Merging {len(tmp_files)} files...") pop.combine_saved_files(fname, tmp_files) - if self.verbose: + if self.verbose: # pragma: no cover print("Files merged!") print(f"Saved merged files to {fname}...") print(f"Removing files in {path_to_batch}...") @@ -377,7 +377,7 @@ def __init__(self, filename, verbose=False, chunksize=100000): if "/history_lengths" in store.keys(): self.lengths = store["history_lengths"] else: - if self.verbose: + if self.verbose: # pragma: no cover print( "history_lengths not found in population file. Calculating history lengths..." ) @@ -388,7 +388,7 @@ def __init__(self, filename, verbose=False, chunksize=100000): tmp_df.rename(columns={"index": "length"}, inplace=True) self.lengths = tmp_df del tmp_df - if self.verbose: + if self.verbose: # pragma: no cover print("Storing history lengths in population file!") store.put("history_lengths", pd.DataFrame(self.lengths), format="table") del history_events @@ -725,7 +725,7 @@ def __getitem__(self, key): else: raise ValueError("Invalid key type!") - def __len__(self): + def __len__(self): # pragma: no cover """ Get the number of systems in the oneline table. @@ -736,7 +736,7 @@ def __len__(self): """ return self.number_of_systems - def head(self, n=10): + def head(self, n=10): # pragma: no cover """Get the first n rows of the oneline table. Parameters @@ -751,7 +751,7 @@ def head(self, n=10): """ return super().head("oneline", n) - def tail(self, n=10): + def tail(self, n=10): # pragma: no cover """ Get the last n rows of the oneline table. @@ -767,7 +767,7 @@ def tail(self, n=10): """ return super().tail("oneline", n) - def __repr__(self): + def __repr__(self): # pragma: no cover """ Get a string representation of the oneline table. @@ -778,7 +778,7 @@ def __repr__(self): """ return super().get_repr("oneline") - def _repr_html_(self): + def _repr_html_(self): # pragma: no cover """ Get an HTML representation of the oneline table. @@ -789,7 +789,7 @@ def _repr_html_(self): """ return super().get_html_repr("oneline") - def select(self, where=None, start=None, stop=None, columns=None): + def select(self, where=None, start=None, stop=None, columns=None): # pragma: no cover """Select a subset of the oneline table based on the given conditions. This method allows you to filter and extract a subset of rows from the oneline table stored in an HDF file. @@ -1061,7 +1061,7 @@ def __init__( # check if formation channels are present if "/formation_channels" not in keys: - if self.verbose: + if self.verbose: # pragma: no cover print(f"{filename} does not contain formation channels!") self._formation_channels = None else: @@ -1070,7 +1070,7 @@ def __init__( ) # if an ini file is given, read the parameters from the ini file - if ini_file is not None: + if ini_file is not None: # pragma: no cover self.ini_params = binarypop_kwargs_from_ini(ini_file) self._save_ini_params(filename) self._load_ini_params(filename) @@ -1122,7 +1122,7 @@ def __init__( self.solar_metallicities = self.mass_per_metallicity.index.to_numpy() self.metallicities = self.solar_metallicities * Zsun - elif metallicity is not None and ini_file is None: + elif metallicity is not None and ini_file is None: # pragma: no cover raise ValueError( f"{filename} does not contain a mass_per_metallicity table and no ini file was given!" ) @@ -1132,7 +1132,7 @@ def __init__( self.number_of_systems = self.oneline.number_of_systems self.indices = self.history.indices - def __repr__(self): + def __repr__(self): # pragma: no cover """Return a string representation of the object. Returns @@ -1249,16 +1249,16 @@ def export_selection(self, selection, filename, overwrite=False, append=False, h elif "/history" in store.keys(): last_index_in_file = np.sort(store["history"].index)[-1] - if "/history" in store.keys() and self.verbose: + if "/history" in store.keys() and self.verbose: # pragma: no cover print("history in file. Appending to file") - if "/oneline" in store.keys() and self.verbose: + if "/oneline" in store.keys() and self.verbose: # pragma: no cover print("oneline in file. Appending to file") - if "/formation_channels" in store.keys() and self.verbose: + if "/formation_channels" in store.keys() and self.verbose: # pragma: no cover print("formation_channels in file. Appending to file") - if "/history_lengths" in store.keys() and self.verbose: + if "/history_lengths" in store.keys() and self.verbose: # pragma: no cover print("history_lengths in file. Appending to file") # TODO: I need to shift the indices of the binaries or should I reindex them? @@ -1289,7 +1289,7 @@ def export_selection(self, selection, filename, overwrite=False, append=False, h "The population file contains multiple metallicities. Please add a metallicity column to the oneline dataframe!" ) - if self.verbose: + if self.verbose: # pragma: no cover print("Writing selected systems to population file...") # write oneline of selected systems @@ -1313,7 +1313,7 @@ def export_selection(self, selection, filename, overwrite=False, append=False, h index=False, ) - if self.verbose: + if self.verbose: # pragma: no cover print("Oneline: Done") # write history of selected systems @@ -1332,7 +1332,7 @@ def export_selection(self, selection, filename, overwrite=False, append=False, h index=False, ) - if self.verbose: + if self.verbose: # pragma: no cover print("History: Done") # write formation channels of selected systems @@ -1396,7 +1396,7 @@ def formation_channels(self): self.filename, key="formation_channels" ) else: - if self.verbose: + if self.verbose: # pragma: no cover print("No formation channels in the population file!") self._formation_channels = None @@ -1420,7 +1420,7 @@ def calculate_formation_channels(self, mt_history=True): If the mt_history_HMS_HMS column is not present in the oneline dataframe. """ - if self.verbose: + if self.verbose: # pragma: no cover print("Calculating formation channels...") # load the HMS-HMS interp class @@ -1539,7 +1539,7 @@ def get_mt_history(row): self._write_formation_channels(self.filename, df) del df - if self.verbose: + if self.verbose: # pragma: no cover print("formation_channels written to population file!") def _write_formation_channels(self, filename, df): @@ -1567,7 +1567,7 @@ def _write_formation_channels(self, filename, df): min_itemsize={"channel_debug": str_length, "channel": str_length}, ) - def __len__(self): + def __len__(self): # pragma: no cover """Get the number of systems in the population. Returns @@ -1579,7 +1579,7 @@ def __len__(self): return self.number_of_systems @property - def columns(self): + def columns(self): # pragma: no cover """ Returns a dictionary containing the column names of the history and oneline dataframes. @@ -1733,7 +1733,7 @@ def create_transient_population( ) return synth_pop - def plot_binary_evolution(self, index): + def plot_binary_evolution(self, index): # pragma: no cover """Plot the binary evolution of a system This method is not currently implemented. @@ -1805,7 +1805,7 @@ def __init__(self, filename, transient_name, verbose=False, chunksize=100000): self.transient_name = transient_name @property - def population(self): + def population(self): # pragma: no cover """Returns the entire transient population as a pandas DataFrame. This method retrieves the transient population data from a file and returns it as a pandas DataFrame. @@ -1819,7 +1819,7 @@ def population(self): return pd.read_hdf(self.filename, key="transients/" + self.transient_name) @property - def columns(self): + def columns(self): # pragma: no cover """Return the columns of the transient population. Returns: @@ -2174,7 +2174,7 @@ def calculate_cosmic_weights(self, SFH_identifier, model_weights, MODEL_in=None) ) return rates - def plot_efficiency_over_metallicity(self, model_weight_identifier, channels=False, **kwargs): + def plot_efficiency_over_metallicity(self, model_weight_identifier, channels=False, **kwargs): # pragma: no cover """ Plot the efficiency over metallicity. @@ -2195,7 +2195,7 @@ def plot_efficiency_over_metallicity(self, model_weight_identifier, channels=Fal efficiency.index.to_numpy() * Zsun, efficiency, channels=channels, **kwargs ) - def plot_delay_time_distribution( + def plot_delay_time_distribution( # pragma: no cover self, model_weights_identifier, metallicity=None, ax=None, bins=100, color="black" ): """ @@ -2273,7 +2273,7 @@ def plot_delay_time_distribution( ax.set_xlabel("Time [yr]") ax.set_ylabel("Number of events/Msun/yr") - def plot_popsyn_over_grid_slice(self, grid_type, met_Zsun, **kwargs): + def plot_popsyn_over_grid_slice(self, grid_type, met_Zsun, **kwargs): # pragma: no cover """ Plot the transients over the grid slice. @@ -2292,7 +2292,7 @@ def plot_popsyn_over_grid_slice(self, grid_type, met_Zsun, **kwargs): pop=self, grid_type=grid_type, met_Zsun=met_Zsun, **kwargs ) - def _write_MODEL_data(self, filename, path_in_file, MODEL): + def _write_MODEL_data(self, filename, path_in_file, MODEL): # pragma: no cover """ Write the MODEL data to the HDFStore file. @@ -2311,7 +2311,7 @@ def _write_MODEL_data(self, filename, path_in_file, MODEL): store.put(path_in_file + "MODEL", pd.DataFrame(MODEL)) else: store.put(path_in_file + "MODEL", pd.DataFrame(MODEL, index=[0])) - if self.verbose: + if self.verbose: # pragma: no cover print("MODEL written to population file!") def efficiency(self, model_weights_identifier, channels=False): @@ -2450,7 +2450,7 @@ def _read_MODEL_data(self, filename): print("MODEL read from population file!") @property - def weights(self): + def weights(self): # pragma: no cover """ Retrieves the weights from the HDFStore. diff --git a/posydon/popsyn/transient_select_funcs.py b/posydon/popsyn/transient_select_funcs.py index 38ca3f6283..8430579e9c 100644 --- a/posydon/popsyn/transient_select_funcs.py +++ b/posydon/popsyn/transient_select_funcs.py @@ -294,9 +294,9 @@ def DCO_detectability(sensitivity, transient_pop_chunk, z_events_chunk, z_weight These have to be present and a valid value. If not, the function will raise an error! ''' - available_sensitiveies = ['O3actual_H1L1V1', 'O4low_H1L1V1', 'O4high_H1L1V1', 'design_H1L1V1'] - if sensitivity not in available_sensitiveies: - raise ValueError(f'Unknown sensitivity {sensitivity}. Available sensitivities are {available_sensitiveies}') + available_sensitivities = ['O3actual_H1L1V1', 'O4low_H1L1V1', 'O4high_H1L1V1', 'design_H1L1V1'] + if sensitivity not in available_sensitivities: + raise ValueError(f'Unknown sensitivity {sensitivity}. Available sensitivities are {available_sensitivities}') else: sel_eff = selection_effects.KNNmodel(grid_path=PATH_TO_PDET_GRID, sensitivity_key=sensitivity) diff --git a/posydon/unit_tests/popsyn/test_GRB.py b/posydon/unit_tests/popsyn/test_GRB.py new file mode 100644 index 0000000000..541a7b8d50 --- /dev/null +++ b/posydon/unit_tests/popsyn/test_GRB.py @@ -0,0 +1,50 @@ +"""Unit tests of posydon/popsyn/GRB.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.GRB as totest + +# aliases +np = totest.np + +from inspect import isclass, isroutine + +# import other needed code for the tests, which is not already imported in the +# module you like to test +from pytest import approx, fixture, raises, warns + +from posydon.utils.constants import Msun, clight +from posydon.utils.posydonwarning import Pwarn + + +# define test classes collecting several test functions +class TestElements: + # check for objects, which should be an element of the tested module + def test_dir(self): + elements = ['get_GRB_properties','__authors__',\ + '__builtins__', '__cached__', '__doc__', '__file__',\ + '__loader__', '__name__', '__package__', '__spec__', + 'Pwarn','Msun','clight','np'] + totest_elements = set(dir(totest)) + missing_in_test = set(elements) - totest_elements + assert len(missing_in_test) == 0, "There are missing objects in "\ + +f"{totest.__name__}: "\ + +f"{missing_in_test}. Please "\ + +"check, whether they have been "\ + +"removed on purpose and update "\ + +"this unit test." + new_in_test = totest_elements - set(elements) + assert len(new_in_test) == 0, "There are new objects in "\ + +f"{totest.__name__}: {new_in_test}. "\ + +"Please check, whether they have been "\ + +"added on purpose and update this "\ + +"unit test." + +class TestFunctions: + + def test_get_GRB_properties(): + pass diff --git a/posydon/unit_tests/popsyn/test_analysis.py b/posydon/unit_tests/popsyn/test_analysis.py new file mode 100644 index 0000000000..f9a58ad59b --- /dev/null +++ b/posydon/unit_tests/popsyn/test_analysis.py @@ -0,0 +1,18 @@ +"""Unit tests of posydon/popsyn/analysis.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.analysis as totest + +# aliases +pd = totest.pd + +from inspect import isclass, isroutine + +# import other needed code for the tests, which is not already imported in the +# module you like to test +from pytest import approx, fixture, raises, warns diff --git a/posydon/unit_tests/popsyn/test_binarypopulation.py b/posydon/unit_tests/popsyn/test_binarypopulation.py new file mode 100644 index 0000000000..a98286ce1b --- /dev/null +++ b/posydon/unit_tests/popsyn/test_binarypopulation.py @@ -0,0 +1,19 @@ +"""Unit tests of posydon/popsyn/binarypopulation.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.binarypopulation as totest + +# aliases +pd = totest.pd +np = totest.np + +from inspect import isclass, isroutine + +# import other needed code for the tests, which is not already imported in the +# module you like to test +from pytest import approx, fixture, raises, warns diff --git a/posydon/unit_tests/popsyn/test_defaults.py b/posydon/unit_tests/popsyn/test_defaults.py new file mode 100644 index 0000000000..a65ede9eb9 --- /dev/null +++ b/posydon/unit_tests/popsyn/test_defaults.py @@ -0,0 +1,146 @@ +"""Unit tests of posydon/popsyn/defaults.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import other needed code for the tests, which is not already imported in the +# module you like to test +import pytest + +# import the module which will be tested +import posydon.popsyn.defaults as totest + + +# define test classes collecting several test functions +class TestElements: + # check for objects, which should be an element of the tested module + + def test_dir(self): + elements = ['default_kwargs', '__authors__',\ + '__builtins__', '__cached__', '__doc__', '__file__',\ + '__loader__', '__name__', '__package__', '__spec__','age_of_universe'] + totest_elements = set(dir(totest)) + missing_in_test = set(elements) - totest_elements + assert len(missing_in_test) == 0, "There are missing objects in "\ + +f"{totest.__name__}: "\ + +f"{missing_in_test}. Please "\ + +"check, whether they have been "\ + +"removed on purpose and update "\ + +"this unit test." + new_in_test = totest_elements - set(elements) + assert len(new_in_test) == 0, "There are new objects in "\ + +f"{totest.__name__}: {new_in_test}. "\ + +"Please check, whether they have been "\ + +"added on purpose and update this "\ + +"unit test." + + def test_kwargs(self): + elements = [ + 'entropy', + 'number_of_binaries', + 'metallicity', + 'star_formation', + 'max_simulation_time', + 'orbital_scheme', + 'orbital_separation_scheme', + 'orbital_separation_min', + 'orbital_separation_max', + 'log_orbital_seperation_mean', + 'log_orbital_seperation_sigma', + 'orbital_period_scheme', + 'orbital_period_min', + 'orbital_period_max', + 'eccentricity_scheme', + 'primary_mass_scheme', + 'primary_mass_min', + 'primary_mass_max', + 'secondary_mass_scheme', + 'secondary_mass_min', + 'secondary_mass_max', + 'binary_fraction_const', + 'binary_fraction_scheme' + ] + assert set(totest.default_kwargs.keys()) == set(elements), \ + "The default_kwargs dictionary keys have changed. Please update the test." + + def test_instance_entropy(self): + assert isinstance(totest.default_kwargs['entropy'], (type(None), float)), \ + "entropy should be None or a float" + + def test_instance_number_of_binaries(self): + assert isinstance(totest.default_kwargs['number_of_binaries'], int), \ + "number_of_binaries should be an integer" + + def test_instance_metallicity(self): + assert isinstance(totest.default_kwargs['metallicity'], float), \ + "metallicity should be a float" + + def test_instance_star_formation(self): + assert isinstance(totest.default_kwargs['star_formation'], str), \ + "star_formation should be a string" + + def test_instance_max_simulation_time(self): + assert isinstance(totest.default_kwargs['max_simulation_time'], (float, int)), \ + "max_simulation_time should be a float or int" + + def test_instance_orbital_scheme(self): + assert isinstance(totest.default_kwargs['orbital_scheme'], str), \ + "orbital_scheme should be a string" + + def test_instance_orbital_separation_scheme(self): + assert isinstance(totest.default_kwargs['orbital_separation_scheme'], str), \ + "orbital_scheme should be a string" + + def test_instance_orbital_separation_min(self): + assert isinstance(totest.default_kwargs['orbital_separation_min'], float), \ + "orbital_separation_min should be a float" + + def test_instance_orbital_separation_max(self): + assert isinstance(totest.default_kwargs['orbital_separation_max'], float), \ + "orbital_separation_max should be a float" + + def test_instance_log_orbital_seperation_mean(self): + assert isinstance(totest.default_kwargs['log_orbital_seperation_mean'], (type(None), float)), \ + "log_orbital_seperation_mean should be None or a float" + + def test_instance_log_orbital_seperation_sigma(self): + assert isinstance(totest.default_kwargs['log_orbital_seperation_sigma'], (type(None), float)), \ + "log_orbital_seperation_sigma should be None or a float" + + def test_instance_orbital_period_min(self): + assert isinstance(totest.default_kwargs['orbital_period_min'], float), \ + "orbital_period_min should be a float" + + def test_instance_orbital_period_max(self): + assert isinstance(totest.default_kwargs['orbital_period_max'], float), \ + "orbital_period_max should be a float" + + def test_instance_eccentricity_scheme(self): + assert isinstance(totest.default_kwargs['eccentricity_scheme'], str), \ + "eccentricity_scheme should be a string" + + def test_instance_primary_mass_min(self): + assert isinstance(totest.default_kwargs['primary_mass_min'], float), \ + "primary_mass_min should be a float" + + def test_instance_primary_mass_max(self): + assert isinstance(totest.default_kwargs['primary_mass_max'], float), \ + "primary_mass_max should be a float" + + def test_instance_secondary_mass_min(self): + assert isinstance(totest.default_kwargs['secondary_mass_min'], float), \ + "secondary_mass_min should be a float" + + def test_instance_secondary_mass_max(self): + assert isinstance(totest.default_kwargs['secondary_mass_max'], float), \ + "secondary_mass_max should be a float" + + def test_instance_binary_fraction_const(self): + assert isinstance(totest.default_kwargs['binary_fraction_const'], (float, int)), \ + "binary_fraction_const should be a float or int" + + def test_instance_binary_fraction_scheme(self): + assert isinstance(totest.default_kwargs['binary_fraction_scheme'], str), \ + "binary_fraction_scheme should be a string" diff --git a/posydon/unit_tests/popsyn/test_independent_sample.py b/posydon/unit_tests/popsyn/test_independent_sample.py new file mode 100644 index 0000000000..7632d1b972 --- /dev/null +++ b/posydon/unit_tests/popsyn/test_independent_sample.py @@ -0,0 +1,221 @@ +"""Unit tests of posydon/popsyn/independent_sample.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.independent_sample as totest + +# aliases +np = totest.np + +# import other needed code for the tests, which is not already imported in the +# module you like to test +import re +from inspect import isclass, isroutine + +from pytest import approx, raises + + +# define test classes collecting several test functions +class TestElements: + + # check for objects, which should be an element of the tested module + def test_dir(self): + elements = ['generate_independent_samples', 'generate_orbital_periods', \ + 'generate_orbital_separations', 'generate_eccentricities',\ + 'generate_primary_masses','generate_secondary_masses',\ + 'binary_fraction_value','__authors__',\ + 'np','truncnorm','rejection_sampler',\ + '__builtins__', '__cached__', '__doc__', '__file__',\ + '__loader__', '__name__', '__package__', '__spec__'] + totest_elements = set(dir(totest)) + missing_in_test = set(elements) - totest_elements + assert len(missing_in_test) == 0, "There are missing objects in "\ + +f"{totest.__name__}: "\ + +f"{missing_in_test}. Please "\ + +"check, whether they have been "\ + +"removed on purpose and update "\ + +"this unit test." + new_in_test = totest_elements - set(elements) + assert len(new_in_test) == 0, "There are new objects in "\ + +f"{totest.__name__}: {new_in_test}. "\ + +"Please check, whether they have been "\ + +"added on purpose and update this "\ + +"unit test." + + def test_instance_generate_independent_samples(self): + assert isroutine(totest.generate_independent_samples) + + def test_instance_generate_orbital_periods(self): + assert isroutine(totest.generate_orbital_periods) + + def test_instance_generate_orbital_separations(self): + assert isroutine(totest.generate_orbital_separations) + + def test_instance_generate_eccentricities(self): + assert isroutine(totest.generate_eccentricities) + + def test_instance_generate_primary_masses(self): + assert isroutine(totest.generate_primary_masses) + + def test_instance_generate_secondary_masses(self): + assert isroutine(totest.generate_secondary_masses) + + def test_instance_binary_fraction_value(self): + assert isroutine(totest.binary_fraction_value) + +class TestFunctions: + + # test functions + def test_generate_independent_samples(self): + # bad input + with raises(ValueError, match="Allowed orbital schemes are separation or period."): + totest.generate_independent_samples('test') + # examples + tests = [("separation",42,approx(4993.106338349307,abs=6e-12)), + ("period",12,approx(200.82071793763188,abs=6e-12))] + for (s,r,o) in tests: + orb,ecc,m1,m2 = totest.generate_independent_samples(orbital_scheme=s, + RNG = np.random.default_rng(seed=42)) + assert orb[0] == approx(o,abs=6e-12) + assert ecc[0] == approx(0.8797477186989253,abs=6e-12) + assert m1[0] == approx(10.607132832170066,abs=6e-12) + assert m2[0] == approx(9.182255718237206,abs=6e-12) + + def test_generate_orbital_periods(self): + # missing argument + with raises(TypeError, match="missing 1 required positional argument: 'primary_masses'"): + totest.generate_orbital_periods() + # bad input + with raises(TypeError, match="expected a sequence of integers or a single integer"): + totest.generate_orbital_periods(np.array([1.]), + number_of_binaries=1.) + with raises(ValueError, match="high - low < 0"): + totest.generate_orbital_periods(np.array([1.]), + orbital_period_min=10., + orbital_period_max=1.) + with raises(ValueError, match="You must provide an allowed orbital period scheme."): + totest.generate_orbital_periods(np.array([1.]), + orbital_period_scheme='test') + # examples + tests = [(1.0,42,approx(403.44608837021764,abs=6e-12)), + (1.0,12,approx(3.4380527315000666,abs=6e-12))] + for (m,r,p) in tests: + assert totest.generate_orbital_periods(m,RNG = np.random.default_rng(seed=r))[0] == p + + def test_generate_orbital_separations(self): + # missing argument + with raises(ValueError,match="For the `log_normal separation` scheme you must give `log_orbital_separation_mean`, `log_orbital_separation_sigma`."): + totest.generate_orbital_separations(orbital_separation_scheme='log_normal') + # bad input + with raises(TypeError, match="expected a sequence of integers or a single integer"): + totest.generate_orbital_separations(number_of_binaries=1.) + with raises(ValueError, match="high - low < 0"): + totest.generate_orbital_separations(orbital_separation_min=10., + orbital_separation_max=1.) + with raises(OverflowError, match="high - low range exceeds valid bounds"): + totest.generate_orbital_separations(orbital_separation_min=0) + with raises(ValueError, match="You must provide an allowed orbital separation scheme."): + totest.generate_orbital_separations(orbital_separation_scheme='test') + # examples + tests_normal = [(0.,1.0,42,approx(39.83711402835139,abs=6e-12)), + (1.0,10.,42,approx(9799.179319004,abs=6e-9))] + # larger allowance for second test because of slightly different results between + # running pytest locally vs github actions workflow + for (m,s,r,sep) in tests_normal: + assert totest.generate_orbital_separations(orbital_separation_scheme='log_normal', + log_orbital_separation_mean=m, + log_orbital_separation_sigma=s, + RNG = np.random.default_rng(seed=r))[0] == sep + tests_uniform = [(1.,3.,42,approx(2.3402964885050066,abs=6e-12)), + (2.,10.,42,approx(6.950276115688688,abs=6e-12))] + for (mi,ma,r,sep) in tests_uniform: + assert totest.generate_orbital_separations(orbital_separation_min=mi, + orbital_separation_max=ma, + RNG = np.random.default_rng(seed=r))[0] == sep + def test_generate_eccentricities(self): + # bad input + with raises(TypeError, match="expected a sequence of integers or a single integer"): + totest.generate_eccentricities(number_of_binaries=1.) + with raises(ValueError, match="You must provide an allowed eccentricity scheme."): + totest.generate_eccentricities(eccentricity_scheme='test') + # examples + tests = [('thermal',42,approx(0.8797477186989253,abs=6e-12)), + ('uniform',42,approx(0.7739560485559633,abs=6e-12)), + ('zero',42,approx(0.,abs=6e-12))] + for (s,r,e) in tests: + assert totest.generate_eccentricities(eccentricity_scheme=s, + RNG = np.random.default_rng(seed=r))[0] == e + + def test_generate_primary_masses(self): + # bad input + with raises(TypeError, match="expected a sequence of integers or a single integer"): + totest.generate_primary_masses(number_of_binaries=1.) + with raises(ValueError, match="primary_mass_max must be larger than primary_mass_min."): + totest.generate_primary_masses(primary_mass_min=100.,primary_mass_max=10.) + with raises(ValueError, match="You must provide an allowed primary mass scheme."): + totest.generate_primary_masses(primary_mass_scheme='test') + # examples + tests = [('Salpeter',42,approx(19.97764511120556,abs=6e-12)), + ('Kroupa1993',42,approx(16.52331793661949,abs=6e-12)), + ('Kroupa2001',42,approx(20.633412780370865,abs=6e-12))] + for (s,r,m1) in tests: + assert totest.generate_primary_masses(primary_mass_scheme=s, + RNG = np.random.default_rng(seed=r))[0] == m1 + + def test_generate_secondary_masses(self): + # missing argument + with raises(TypeError,match="missing 1 required positional argument: 'primary_masses'"): + totest.generate_secondary_masses() + # bad input + with raises(TypeError, match=re.escape("unsupported operand type(s) for /: 'float' and 'list'")): + totest.generate_secondary_masses(primary_masses=[10.]) + with raises(TypeError, match="expected a sequence of integers or a single integer"): + totest.generate_secondary_masses(primary_masses=np.array([10.]), + number_of_binaries=1.) + with raises(ValueError, match="secondary_mass_max must be larger than secondary_mass_min"): + totest.generate_secondary_masses(primary_masses=np.array([100.]), + secondary_mass_min=10., + secondary_mass_max=1.) + with raises(ValueError, match="`secondary_mass_min` is larger than some primary masses"): + totest.generate_secondary_masses(primary_masses=np.array([1.]), + secondary_mass_min=10., + secondary_mass_max=100.) + with raises(ValueError, match="You must provide an allowed secondary mass scheme."): + totest.generate_secondary_masses(primary_masses=np.array([1.]), + secondary_mass_scheme='test') + # examples + tests = [('flat_mass_ratio',42,approx(7.852582461281652,abs=6e-12)), + ('q=1',42,approx(10.,abs=6e-12))] + for (s,r,m2) in tests: + assert totest.generate_secondary_masses(primary_masses=np.array([10.]), + secondary_mass_scheme=s, + RNG = np.random.default_rng(seed=r))[0] == m2 + + def test_binary_fraction_value(self): + # missing argument + with raises(ValueError, match="There was not a primary mass provided in the inputs."): + totest.binary_fraction_value(binary_fraction_scheme='Moe_17') + # bad input + with raises(ValueError, match="You must provide an allowed binary fraction scheme."): + totest.binary_fraction_value(binary_fraction_scheme='test') + with raises(ValueError, match="The scheme doesn't support values of m1 less than 0.8"): + totest.binary_fraction_value(binary_fraction_scheme='Moe_17',m1=0.2) + with raises(ValueError, match="The primary mass provided nan is not supported by the Moe_17 scheme."): + totest.binary_fraction_value(binary_fraction_scheme='Moe_17',m1=np.nan) + # examples + tests_const = [1.0,1,0.5] + for (c) in tests_const: + assert totest.binary_fraction_value(binary_fraction_const=c, + binary_fraction_scheme='const') == c + tests_moe = [(1,0.4), + (3,0.59), + (8,0.76), + (10,0.84), + (18,0.94)] + for (m1,f) in tests_moe: + assert totest.binary_fraction_value(binary_fraction_scheme='Moe_17', + m1=m1) == f diff --git a/posydon/unit_tests/popsyn/test_io.py b/posydon/unit_tests/popsyn/test_io.py new file mode 100644 index 0000000000..0857e0bd5f --- /dev/null +++ b/posydon/unit_tests/popsyn/test_io.py @@ -0,0 +1,453 @@ +"""Unit tests of posydon/popsyn/io.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.io as totest + +# aliases +np = totest.np +pd = totest.pd + +import ast +import errno +import importlib +import os +import pprint +import textwrap +from configparser import ConfigParser, MissingSectionHeaderError +from inspect import isclass, isroutine + +# import other needed code for the tests, which is not already imported in the +# module you like to test +from pytest import approx, fixture, raises, warns + +from posydon.binary_evol.simulationproperties import SimulationProperties + + +# define test classes collecting several test functions +class TestElements: + # check for objects, which should be an element of the tested module + def test_dir(self): + elements = ['BINARYPROPERTIES_DTYPES', 'OBJECT_FIXED_SUB_DTYPES', \ + 'STARPROPERTIES_DTYPES', 'EXTRA_BINARY_COLUMNS_DTYPES', \ + 'EXTRA_STAR_COLUMNS_DTYPES', 'SCALAR_NAMES_DTYPES', \ + 'clean_binary_history_df', 'clean_binary_oneline_df', \ + 'parse_inifile', 'simprop_kwargs_from_ini', \ + 'binarypop_kwargs_from_ini', 'create_run_script_text', \ + 'create_merge_script_text', \ + '__builtins__', '__cached__', '__doc__', '__file__',\ + '__loader__', '__name__', '__package__', '__spec__', \ + 'ConfigParser', 'ast', 'importlib', 'os', 'errno', \ + 'pprint', 'np', 'pd','SimulationProperties'] + totest_elements = set(dir(totest)) + missing_in_test = set(elements) - totest_elements + assert len(missing_in_test) == 0, "There are missing objects in "\ + +f"{totest.__name__}: "\ + +f"{missing_in_test}. Please "\ + +"check, whether they have been "\ + +"removed on purpose and update "\ + +"this unit test." + new_in_test = totest_elements - set(elements) + assert len(new_in_test) == 0, "There are new objects in "\ + +f"{totest.__name__}: {new_in_test}. "\ + +"Please check, whether they have been "\ + +"added on purpose and update this "\ + +"unit test." + +class TestFunctions: + + @fixture + def simple_ini(self,tmp_path): + file_path = os.path.join(tmp_path, "test.ini") + with open(file_path, "w") as f: + f.write("[section]\nkey=value\n") + return file_path + + @fixture + def multi_ini(self,tmp_path): + file1 = os.path.join(tmp_path, "a.ini") + file2 = os.path.join(tmp_path, "b.ini") + with open(file1, "w") as f: + f.write("[section]\nkey1=value1\n") + with open(file2, "w") as f: + f.write("[section]\nkey2=value2\n") + return [file1, file2] + + @fixture + def textfile(self,tmp_path): + file_path = os.path.join(tmp_path, "textfile.txt") + with open(file_path, "w") as f: + f.write("test") + return file_path + + @fixture + def sim_ini(self,tmp_path): + ini_content = """ + [flow] + import = ['posydon.binary_evol.flow_chart', 'flow_chart'] + absolute_import = None + + [step_HMS_HMS] + import = ['posydon.binary_evol.MESA.step_mesa', 'MS_MS_step'] + absolute_import = None + interpolation_method = 'linear3c_kNN' + save_initial_conditions = True + verbose = False + + [extra_hooks] + import_1 = ['posydon.binary_evol.simulationproperties', 'TimingHooks'] + absolute_import_1 = None + kwargs_1 = {} + import_2 = ['posydon.binary_evol.simulationproperties', 'StepNamesHooks'] + absolute_import_2 = None + kwargs_2 = {} + """ + file_path = os.path.join(tmp_path, "sim.ini") + with open(file_path, "w") as f: + f.write(ini_content) + return file_path + + @fixture + def binpop_ini(self, tmp_path): + ini_content = """ + [BinaryPopulation_options] + use_MPI = False + metallicity = [0.02] + number_of_binaries = 1 + temp_directory = 'tmp' + + [BinaryStar_output] + extra_columns = {} + only_select_columns = [] + scalar_names = [] + + [SingleStar_1_output] + include_S1 = False + + [SingleStar_2_output] + include_S2 = False + + [flow] + import = ['builtins', 'int'] + + [extra_hooks] + import_1 = ['builtins', 'int'] + absolute_import_1 = None + kwargs_1 = {} + """ + file_path = os.path.join(tmp_path, "binpop.ini") + with open(file_path, "w") as f: + f.write(ini_content) + return file_path + + @fixture + def binpop_ini_mpi(self, tmp_path): + ini_content = """ + [BinaryPopulation_options] + use_MPI = True + metallicity = [0.02] + number_of_binaries = 1 + temp_directory = 'tmp' + + [BinaryStar_output] + extra_columns = {} + only_select_columns = [] + scalar_names = [] + + [SingleStar_1_output] + include_S1 = False + + [SingleStar_2_output] + include_S2 = False + + [flow] + import = ['builtins', 'int'] + + [extra_hooks] + import_1 = ['builtins', 'int'] + absolute_import_1 = None + kwargs_1 = {} + """ + file_path = os.path.join(tmp_path, "binpop_mpi.ini") + with open(file_path, "w") as f: + f.write(ini_content) + return file_path + + @fixture + def binpop_ini_stars(self, tmp_path): + ini_content = """ + [BinaryPopulation_options] + use_MPI = False + metallicity = [0.02] + number_of_binaries = 1 + temp_directory = 'tmp' + + [BinaryStar_output] + extra_columns = {} + only_select_columns = [] + scalar_names = [] + + [SingleStar_1_output] + include_S1 = True + only_select_columns = [ + 'state', + 'mass', + 'log_R'] + + [SingleStar_2_output] + include_S2 = True + only_select_columns = [ + 'log_L', + 'lg_mdot'] + + [flow] + import = ['builtins', 'int'] + + [extra_hooks] + import_1 = ['builtins', 'int'] + absolute_import_1 = None + kwargs_1 = {} + """ + file_path = os.path.join(tmp_path, "binpop_stars.ini") + with open(file_path, "w") as f: + f.write(ini_content) + return file_path + + @fixture + def history_df(self): + data = { + 'state': ['disrupted'], + 'time': [1.23], + 'S1_mass': [10.0], + 'S2_spin': [0.3] + } + return pd.DataFrame(data) + + @fixture + def oneline_df(self): + data = { + 'state_i': ['detached', 'detached'], + 'state_f': ['contact', 'merged'], + 'mass_i': [1.4, 2.1], + 'mass_f': [1.3, 2.0], + 'S1_spin_i': [0.5, 0.6], + 'S1_spin_f': [0.7, 0.8], + 'S1_SN_type': ['CCSN', 'NaN'], + 'S2_mass_i': [5.0, 6.0], + 'S2_mass_f': [7.0, 8.0], + 'S2_kick': [123.0, 456.0], + } + df = pd.DataFrame(data) + return df + + def test_clean_binary_history_df(self, history_df): + extra_binary = {'extra_binary': 'int32'} + extra_S1 = {} + extra_S2 = {} + + clean_df = totest.clean_binary_history_df( + history_df, + extra_binary_dtypes_user=extra_binary, + extra_S1_dtypes_user=extra_S1, + extra_S2_dtypes_user=extra_S2 + ) + assert isinstance(clean_df, pd.DataFrame) + assert clean_df.dtypes['time'] == np.dtype('float64') + assert clean_df.dtypes['S1_mass'] == np.dtype('float64') + assert clean_df.dtypes['S2_spin'] == np.dtype('float64') + assert clean_df.dtypes['state'] == np.dtype('O') + + def test_clean_binary_oneline_df(self, oneline_df): + cleaned_df = totest.clean_binary_oneline_df(oneline_df) + assert isinstance(cleaned_df, pd.DataFrame) + assert cleaned_df['mass_i'].dtype == np.float64 + assert cleaned_df['S1_spin_i'].dtype == np.float64 + assert cleaned_df['state_i'].dtype == 'object' + assert cleaned_df['state_f'].dtype == 'object' + assert cleaned_df['S1_SN_type'].dtype == 'object' + assert cleaned_df['S2_kick'].dtype == np.float64 + assert cleaned_df.loc[0, 'mass_i'] == 1.4 + assert cleaned_df.loc[1, 'state_f'] == 'merged' + + def test_parse_inifile(self,simple_ini,multi_ini,textfile): + # missing argument + with raises(TypeError, match="missing 1 required positional argument: 'path'"): + totest.parse_inifile() + # bad input + with raises(FileNotFoundError): + totest.parse_inifile('nonexistent.ini') + with raises(FileNotFoundError): + totest.parse_inifile([simple_ini,'nonexistent.ini']) + with raises(MissingSectionHeaderError, match="File contains no section headers"): + totest.parse_inifile(textfile) + with raises(ValueError, match="Path must be a string or list of strings."): + totest.parse_inifile(0) + + # example: single inifile + parser = totest.parse_inifile(simple_ini) + assert isinstance(parser, ConfigParser) + assert parser.has_section("section") + assert parser.get("section", "key") == "value" + + # example: multiple inifiles + parser = totest.parse_inifile(multi_ini) + assert parser.has_option("section", "key1") + assert parser.has_option("section", "key2") + + + def test_simprop_kwargs_from_ini(self,monkeypatch,sim_ini,tmp_path): + # example + dummy_cls = type('DummyClass', (), {})() + + # Patch importlib.import_module to return dummy modules with dummy classes + def dummy_import_module(name, package=None): + class DummyModule: + pass + setattr(DummyModule, 'TimingHooks', dummy_cls) + setattr(DummyModule, 'StepNamesHooks', dummy_cls) + setattr(DummyModule, 'flow_chart', dummy_cls) + setattr(DummyModule, 'MS_MS_step', dummy_cls) + return DummyModule() + + monkeypatch.setattr(importlib, "import_module", dummy_import_module) + + simkwargs = totest.simprop_kwargs_from_ini(sim_ini) + + # Check keys exist + assert 'flow' in simkwargs + assert 'step_HMS_HMS' in simkwargs + assert 'extra_hooks' in simkwargs + + # Check classes mapped to dummy_cls + assert simkwargs['flow'][0] is dummy_cls + assert simkwargs['step_HMS_HMS'][0] is dummy_cls + + # extra_hooks is a list of tuples (class, kwargs) + hooks = simkwargs['extra_hooks'] + assert isinstance(hooks, list) + assert hooks[0][0] is dummy_cls + assert hooks[0][1] == {} + assert hooks[1][0] is dummy_cls + assert hooks[1][1] == {} + + # absolute imports + dummy_code = """ +class MyDummyClass: + def __init__(self): + self.value = 42 +""" + dummy_path = os.path.join(tmp_path, "dummy.py") + with open(dummy_path, "w") as f: + f.write(dummy_code) + ini_content = f""" + [flow] + import = ['builtins', 'int'] + absolute_import = ['{dummy_path}', 'MyDummyClass'] + """ + ini_path = os.path.join(tmp_path, "sim_abs_import.ini") + with open(ini_path, "w") as f: + f.write(ini_content) + simkwargs = totest.simprop_kwargs_from_ini(str(ini_path)) + dummy_class = simkwargs['flow'][0] + assert dummy_class.__name__ == "MyDummyClass" + instance = dummy_class() + assert instance.value == 42 + + + def test_binarypop_kwargs_from_ini(self,monkeypatch,binpop_ini, + binpop_ini_mpi,binpop_ini_stars): + # bad configuration: MPI and job array + monkeypatch.setenv("SLURM_ARRAY_JOB_ID", "123") + with raises(ValueError, match="MPI must be turned off for job arrays."): + totest.binarypop_kwargs_from_ini(binpop_ini_mpi) + # example: include s1 and s2 + class DummySimProps: + def __init__(self, **kwargs): + self.config = kwargs + monkeypatch.setattr(totest, "SimulationProperties", DummySimProps) + monkeypatch.setenv("SLURM_ARRAY_JOB_ID", "456") + monkeypatch.setenv("SLURM_ARRAY_TASK_ID", "4") + monkeypatch.setenv("SLURM_ARRAY_TASK_MIN", "2") + monkeypatch.setenv("SLURM_ARRAY_TASK_COUNT", "10") + binkwargs = totest.binarypop_kwargs_from_ini(binpop_ini_stars) + assert binkwargs["include_S1"] is True + assert "only_select_columns" in binkwargs["S1_kwargs"] + assert "S2_kwargs" in binkwargs + assert "log_L" in binkwargs["S2_kwargs"]["only_select_columns"] + # example: environment variables + binkwargs = totest.binarypop_kwargs_from_ini(binpop_ini) + assert binkwargs["JOB_ID"] == 456 + assert binkwargs["RANK"] == 2 # 4 - 2 + assert binkwargs["size"] == 10 + assert isinstance(binkwargs, dict) + assert binkwargs["metallicity"] == [0.02] + assert isinstance(binkwargs["population_properties"], DummySimProps) + assert "flow" in binkwargs["population_properties"].config + # example: no Job ID, no MPI + monkeypatch.delenv('SLURM_ARRAY_JOB_ID', raising=False) + binkwargs = totest.binarypop_kwargs_from_ini(binpop_ini) + assert binkwargs['RANK'] is None + assert binkwargs['size'] is None + + + def test_create_run_script_text(self): + # missing argument + with raises(TypeError, match="missing 1 required positional argument: 'ini_file'"): + totest.create_run_script_text() + # bad input + with raises(NameError, match="name 'testfile' is not defined"): + totest.create_run_script_text(testfile.ini) + # example + out = textwrap.dedent("""\ + from posydon.popsyn.binarypopulation import BinaryPopulation + from posydon.popsyn.io import binarypop_kwargs_from_ini + from posydon.utils.common_functions import convert_metallicity_to_string + import argparse + if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument('metallicity', type=float) + args = parser.parse_args() + ini_kw = binarypop_kwargs_from_ini('testfile.ini') + ini_kw['metallicity'] = args.metallicity + str_met = convert_metallicity_to_string(args.metallicity) + ini_kw['temp_directory'] = str_met+'_Zsun_' + ini_kw['temp_directory'] + synpop = BinaryPopulation(**ini_kw) + synpop.evolve()""") + assert totest.create_run_script_text('testfile.ini') == out + + + def test_create_merge_script_text(self): + # missing argument + with raises(TypeError, match="missing 1 required positional argument: 'ini_file'"): + totest.create_merge_script_text() + # bad input + with raises(NameError, match="name 'testfile' is not defined"): + totest.create_merge_script_text(testfile.ini) + # example + out = textwrap.dedent("""\ + from posydon.popsyn.binarypopulation import BinaryPopulation + from posydon.popsyn.io import binarypop_kwargs_from_ini + from posydon.utils.common_functions import convert_metallicity_to_string + import argparse + import os + if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("metallicity", type=float) + args = parser.parse_args() + ini_kw = binarypop_kwargs_from_ini("testfile.ini") + ini_kw["metallicity"] = args.metallicity + str_met = convert_metallicity_to_string(args.metallicity) + ini_kw["temp_directory"] = str_met+"_Zsun_" + ini_kw["temp_directory"] + synpop = BinaryPopulation(**ini_kw) + path_to_batch = ini_kw["temp_directory"] + tmp_files = [os.path.join(path_to_batch, f) for f in os.listdir(path_to_batch) if os.path.isfile(os.path.join(path_to_batch, f))] + tmp_files = sorted(tmp_files, key=lambda x: int(x.split(".")[-1])) + synpop.combine_saved_files(str_met+ "_Zsun_population.h5", tmp_files) + print("done") + if len(os.listdir(path_to_batch)) == 0: + os.rmdir(path_to_batch)""") + assert totest.create_merge_script_text('testfile.ini') == out diff --git a/posydon/unit_tests/popsyn/test_normalized_pop_mass.py b/posydon/unit_tests/popsyn/test_normalized_pop_mass.py new file mode 100644 index 0000000000..a9b9a94004 --- /dev/null +++ b/posydon/unit_tests/popsyn/test_normalized_pop_mass.py @@ -0,0 +1,42 @@ +"""Unit tests of posydon/popsyn/normalized_pop_mass.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.normalized_pop_mass as totest + +# aliases +np = totest.np + +from inspect import isclass, isroutine + +# import other needed code for the tests, which is not already imported in the +# module you like to test +from pytest import approx, fixture, raises, warns + + +# define test classes collecting several test functions +class TestElements: + # check for objects, which should be an element of the tested module + def test_dir(self): + totest_elements = set(dir(totest)) + missing_in_test = set(elements) - totest_elements + assert len(missing_in_test) == 0, "There are missing objects in "\ + +f"{totest.__name__}: "\ + +f"{missing_in_test}. Please "\ + +"check, whether they have been "\ + +"removed on purpose and update "\ + +"this unit test." + new_in_test = totest_elements - set(elements) + assert len(new_in_test) == 0, "There are new objects in "\ + +f"{totest.__name__}: {new_in_test}. "\ + +"Please check, whether they have been "\ + +"added on purpose and update this "\ + +"unit test." + +class TestFunctions: + def test_initial_total_underlying_mass(self): + pass diff --git a/posydon/unit_tests/popsyn/test_rate_calculation.py b/posydon/unit_tests/popsyn/test_rate_calculation.py new file mode 100644 index 0000000000..b5b8b640d8 --- /dev/null +++ b/posydon/unit_tests/popsyn/test_rate_calculation.py @@ -0,0 +1,143 @@ +"""Unit tests of posydon/popsyn/rate_calculation.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.rate_calculation as totest + +# aliases +np = totest.np +sp = totest.sp + +from inspect import isclass, isroutine + +# import other needed code for the tests, which is not already imported in the +# module you like to test +from pytest import approx, fixture, raises, warns +from scipy.interpolate import CubicSpline + + +# define test classes collecting several test functions +class TestElements: + + # check for objects, which should be an element of the tested module + def test_dir(self): + elements = ['DEFAULT_SFH_MODEL','np','sp','CubicSpline','Zsun','cosmology',\ + 'const','z_at_value','u',\ + 'get_shell_comoving_volume', 'get_comoving_distance_from_redshift', \ + 'get_cosmic_time_from_redshift', 'redshift_from_cosmic_time_interpolator',\ + 'get_redshift_from_cosmic_time','get_redshift_bin_edges',\ + 'get_redshift_bin_centers','__authors__',\ + '__builtins__', '__cached__', '__doc__', '__file__',\ + '__loader__', '__name__', '__package__', '__spec__'] + totest_elements = set(dir(totest)) + missing_in_test = set(elements) - totest_elements + assert len(missing_in_test) == 0, "There are missing objects in "\ + +f"{totest.__name__}: "\ + +f"{missing_in_test}. Please "\ + +"check, whether they have been "\ + +"removed on purpose and update "\ + +"this unit test." + new_in_test = totest_elements - set(elements) + assert len(new_in_test) == 0, "There are new objects in "\ + +f"{totest.__name__}: {new_in_test}. "\ + +"Please check, whether they have been "\ + +"added on purpose and update this "\ + +"unit test." + + def test_instance_get_shell_comoving_volume(self): + assert isroutine(totest.get_shell_comoving_volume) + + def test_instance_get_comoving_distance_from_redshift(self): + assert isroutine(totest.get_comoving_distance_from_redshift) + + def test_instance_get_cosmic_time_from_redshift(self): + assert isroutine(totest.get_cosmic_time_from_redshift) + + def test_instance_redshift_from_cosmic_time_interpolator(self): + assert isroutine(totest.redshift_from_cosmic_time_interpolator) + + def test_instance_get_redshift_from_cosmic_time(self): + assert isroutine(totest.get_redshift_from_cosmic_time) + + def test_instance_get_redshift_bin_edges(self): + assert isroutine(totest.get_redshift_bin_edges) + + def test_instance_get_redshift_bin_centers(self): + assert isroutine(totest.get_redshift_bin_centers) + +class TestFunctions: + + # test functions + def test_get_shell_comoving_volume(self): + # 2 missing arguments + with raises(TypeError, match="missing 2 required positional arguments: 'z_hor_i' and 'z_hor_f'"): + totest.get_shell_comoving_volume() + # 1 missing argument + with raises(TypeError, match="missing 1 required positional argument: 'z_hor_f'"): + totest.get_shell_comoving_volume(0.1) + # bad input + with raises(ValueError, match="Sensitivity not supported!"): + totest.get_shell_comoving_volume(0.1,1.0,"finite") + # examples + tests = [(0.1, 1.0, approx(97.7972132977263, abs=6e-12)),\ + (0.3, 2.0, approx(277.8780499884267, abs=6e-12))] + for (z1, z2, v) in tests: + assert totest.get_shell_comoving_volume(z1, z2) == v + + def test_get_comoving_distance_from_redshift(self): + # missing argument + with raises(TypeError, match="missing 1 required positional argument: 'z'"): + totest.get_comoving_distance_from_redshift() + # examples + tests = [(0.1, approx(432.1244883487781, abs=6e-12)),\ + (1.0, approx(3395.905311975348, abs=6e-12))] + for (z, d) in tests: + assert totest.get_comoving_distance_from_redshift(z) == d + + def test_get_cosmic_time_from_redshift(self): + # missing argument + with raises(TypeError, match="missing 1 required positional argument: 'z'"): + totest.get_cosmic_time_from_redshift() + # examples + tests = [(0.1, approx(12.453793290949799, abs=6e-12)),\ + (1.0, approx(5.862549255024051, abs=6e-12))] + for (z, t) in tests: + assert totest.get_cosmic_time_from_redshift(z) == t + + def test_redshift_from_cosmic_time_interpolator(self): + interp = totest.redshift_from_cosmic_time_interpolator() + assert isinstance(interp, CubicSpline) + + def test_get_redshift_from_cosmic_time(self): + # missing argument + with raises(TypeError, match="missing 1 required positional argument: 't_cosm'"): + totest.get_redshift_from_cosmic_time() + # examples + tests = [(0.1, approx(29.832529897287746, abs=6e-12)),\ + (1.0, approx(5.675847792368566, abs=6e-12))] + for (t, z) in tests: + assert totest.get_redshift_from_cosmic_time(t) == z + + def test_get_redshift_bin_edges(self): + # missing argument + with raises(TypeError, match="missing 1 required positional argument: 'delta_t'"): + totest.get_redshift_bin_edges() + # examples + tests = [(100., approx(0.006963184181145605, abs=6e-12)),\ + (1000., approx(0.07301543666184201, abs=6e-12))] + for (t,arr) in tests: + assert totest.get_redshift_bin_edges(t)[1] == arr + + def test_get_redshift_bin_centers(self): + # missing argument + with raises(TypeError, match="missing 1 required positional argument: 'delta_t'"): + totest.get_redshift_bin_centers() + # examples + tests = [(100., approx(49.33542627789386, abs=6e-12)),\ + (1000., approx(13.957133275502315, abs=6e-12))] + for (t,arr) in tests: + assert totest.get_redshift_bin_centers(t)[-1] == arr diff --git a/posydon/unit_tests/popsyn/test_sample_from_file.py b/posydon/unit_tests/popsyn/test_sample_from_file.py new file mode 100644 index 0000000000..6d28b3e969 --- /dev/null +++ b/posydon/unit_tests/popsyn/test_sample_from_file.py @@ -0,0 +1,73 @@ +"""Unit tests of posydon/popsyn/sample_from_file.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.sample_from_file as totest + +# aliases +os = totest.os +np = totest.np +pd = totest.pd + +from inspect import isclass, isroutine + +# import other needed code for the tests, which is not already imported in the +# module you like to test +from pytest import approx, fixture, raises, warns + +from posydon.popsyn.independent_sample import ( + generate_eccentricities, + generate_orbital_periods, + generate_orbital_separations, + generate_primary_masses, + generate_secondary_masses, +) +from posydon.utils.posydonwarning import Pwarn + + +# define test classes collecting several test functions +class TestElements: + # check for objects, which should be an element of the tested module + def test_dir(self): + elements = ['infer_key', 'get_samples_from_file', \ + 'get_kick_samples_from_file', '__authors__',\ + '__builtins__', '__cached__', '__doc__', '__file__',\ + '__loader__', '__name__', '__package__', '__spec__'] + totest_elements = set(dir(totest)) + missing_in_test = set(elements) - totest_elements + assert len(missing_in_test) == 0, "There are missing objects in "\ + +f"{totest.__name__}: "\ + +f"{missing_in_test}. Please "\ + +"check, whether they have been "\ + +"removed on purpose and update "\ + +"this unit test." + new_in_test = totest_elements - set(elements) + assert len(new_in_test) == 0, "There are new objects in "\ + +f"{totest.__name__}: {new_in_test}. "\ + +"Please check, whether they have been "\ + +"added on purpose and update this "\ + +"unit test." + + def test_instance_infer_key(self): + assert isroutine(totest.infer_key) + + def test_instance_get_samples_from_file(self): + assert isroutine(totest.get_samples_from_file) + + def test_instance_get_kick_samples_from_file(self): + assert isroutine(totest.get_kick_samples_from_file) + +class TestFunctions: + + def test_infer_key(): + pass + + def test_get_samples_from_file(): + pass + + def test_get_kick_samples_from_file(): + pass diff --git a/posydon/unit_tests/popsyn/test_selection_effects.py b/posydon/unit_tests/popsyn/test_selection_effects.py new file mode 100644 index 0000000000..a17c5f1cc8 --- /dev/null +++ b/posydon/unit_tests/popsyn/test_selection_effects.py @@ -0,0 +1,57 @@ +"""Unit tests of posydon/popsyn/selection_effects.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.selection_effects as totest + +# aliases +np = totest.np +pd = totest.pd +time = totest.time +KNeighborsRegressor = totest.KNeighborsRegressor + +from inspect import isclass, isroutine + +# import other needed code for the tests, which is not already imported in the +# module you like to test +from pytest import approx, fixture, raises, warns + + +# define test classes collecting several test functions +class TestElements: + # check for objects, which should be an element of the tested module + def test_dir(self): + elements = ['KNNmodel', '__authors__',\ + '__builtins__', '__cached__', '__doc__', '__file__',\ + '__loader__', '__name__', '__package__', '__spec__', + 'np','pd','time','KNeighborsRegressor'] + totest_elements = set(dir(totest)) + missing_in_test = set(elements) - totest_elements + assert len(missing_in_test) == 0, "There are missing objects in "\ + +f"{totest.__name__}: "\ + +f"{missing_in_test}. Please "\ + +"check, whether they have been "\ + +"removed on purpose and update "\ + +"this unit test." + new_in_test = totest_elements - set(elements) + assert len(new_in_test) == 0, "There are new objects in "\ + +f"{totest.__name__}: {new_in_test}. "\ + +"Please check, whether they have been "\ + +"added on purpose and update this "\ + +"unit test." +class TestKNNmodel: + + def test_predict_pdet(self): + # missing argument + # bad input + # examples + pass + def test_normalize(self): + # missing argument + # bad input + # examples + pass diff --git a/posydon/unit_tests/popsyn/test_star_formation_history.py b/posydon/unit_tests/popsyn/test_star_formation_history.py index fa086c2144..dad1846bfd 100644 --- a/posydon/unit_tests/popsyn/test_star_formation_history.py +++ b/posydon/unit_tests/popsyn/test_star_formation_history.py @@ -778,16 +778,6 @@ def test_mean_metallicity(self, chruslinska_model, mock_chruslinska_data): with pytest.raises(AssertionError): result = chruslinska_model.mean_metallicity(z_values) - def test_lowest_z_bin(self, chruslinska_model, mock_chruslinska_data): - """Test that if z is below the lowest bin, it uses the lowest bin.""" - z_values = np.array([-1.0, 0.0, 0.5]) - met_bins = np.array([0.001, 0.01, 0.02, 0.03]) - - result = chruslinska_model.fSFR(z_values, met_bins) - - # The value at -1.0 should be the same as at 0.0 - assert np.allclose(result[0], result[1]) - def test_csfrd_calculation(self, chruslinska_model, mock_chruslinska_data): """Test the CSFRD method.""" # Test at specific redshifts diff --git a/posydon/unit_tests/popsyn/test_synthetic_population.py b/posydon/unit_tests/popsyn/test_synthetic_population.py new file mode 100644 index 0000000000..ded4352659 --- /dev/null +++ b/posydon/unit_tests/popsyn/test_synthetic_population.py @@ -0,0 +1,835 @@ +"""Unit tests of posydon/popsyn/synthetic_population.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.synthetic_population as totest +from posydon.utils.constants import Zsun + +# aliases +np = totest.np +pd = totest.pd + +import warnings +from inspect import isclass, isroutine + +# import other needed code for the tests, which is not already imported in the +# module you like to test +from pytest import approx, fixture, raises, warns + +warnings.simplefilter("always") +import os +import shutil + + +# define test classes collecting several test functions +class TestElements: + # check for objects, which should be an element of the tested module + def test_dir(self): + elements = ['DFInterface','History','Oneline', + 'Population','PopulationIO','PopulationRunner', + 'Rates','TransientPopulation', + '__authors__','__builtins__', '__cached__', '__doc__', + '__file__','__loader__', '__name__', '__package__', '__spec__', + 'np', 'pd', 'tqdm', 'os', 'shutil','plt', + 'Zsun', 'binarypop_kwargs_from_ini','plot_pop','SimulationProperties', + 'calculate_model_weights','saved_ini_parameters', + 'convert_metallicity_to_string','Pwarn','cosmology','const', + 'get_shell_comoving_volume', 'get_comoving_distance_from_redshift', + 'get_cosmic_time_from_redshift', 'redshift_from_cosmic_time_interpolator', + 'DEFAULT_SFH_MODEL', 'get_redshift_bin_edges', + 'get_redshift_bin_centers', 'SFR_per_met_at_z', + 'BinaryPopulation', 'HISTORY_MIN_ITEMSIZE','ONELINE_MIN_ITEMSIZE' + ] + totest_elements = set(dir(totest)) + missing_in_test = set(elements) - totest_elements + assert len(missing_in_test) == 0, "There are missing objects in "\ + +f"{totest.__name__}: "\ + +f"{missing_in_test}. Please "\ + +"check, whether they have been "\ + +"removed on purpose and update "\ + +"this unit test." + new_in_test = totest_elements - set(elements) + assert len(new_in_test) == 0, "There are new objects in "\ + +f"{totest.__name__}: {new_in_test}. "\ + +"Please check, whether they have been "\ + +"added on purpose and update this "\ + +"unit test." + +class TestPopulationRunner: + + def test_init(self): + # missing argument + with raises(TypeError,match="missing 1 required positional argument: 'path_to_ini'"): + totest.PopulationRunner() + # bad input + with raises(ValueError, match="You did not provide a valid path_to_ini!"): + totest.PopulationRunner("test") + + def test_evolve(self,tmp_path,monkeypatch): + # mock dependencies + class DummyPop: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.comm = None + self.metallicity = kwargs["metallicity"] + def evolve(self,**kwargs): + self.evolved = True + def combine_saved_files(self, *args): + self.combined = True + def dummy_kwargs(path): + return { + "metallicity": 0.1, + "temp_directory": "tmp_dir", + "verbose": False} + def dummy_kwargs_list(path): + return { + "metallicity": [0.1,1.], + "temp_directory": "tmp_dir", + "verbose": False} + def dummy_merge(pop,overwrite): + pop.merged = True + + ini_path = os.path.join(tmp_path, "dummy.ini") + with open(ini_path, "w") as f: + f.write("[DEFAULT]\nkey=value\n") + + # Mock out functions + monkeypatch.setattr(totest, "binarypop_kwargs_from_ini", dummy_kwargs) + monkeypatch.setattr(totest, "BinaryPopulation", DummyPop) + monkeypatch.setattr(totest, "convert_metallicity_to_string", lambda x: "0.1") + run = totest.PopulationRunner(str(ini_path)) + # overwrite=False, directory doesn't exist + monkeypatch.setattr(os.path, "exists", lambda path: False) + run.merge_parallel_runs = dummy_merge + run.evolve() + assert run.binary_populations[0].evolved is True + assert run.binary_populations[0].merged is True + # overwrite=False, directory exists + monkeypatch.setattr(os.path, "exists", lambda path: True) + monkeypatch.setattr(totest, "binarypop_kwargs_from_ini", dummy_kwargs_list) + run = totest.PopulationRunner(str(ini_path), verbose=True) + with raises(FileExistsError, match="tmp_dir"): + run.evolve(overwrite=False) + # overwrite=True, directory exists + removed = {} + monkeypatch.setattr(shutil, "rmtree", lambda path: removed.setdefault("called", path)) + run.merge_parallel_runs = dummy_merge + run.evolve(overwrite=True) + assert removed["called"] == "0.1_Zsun_tmp_dir" + assert run.binary_populations[0].evolved is True + assert run.binary_populations[0].merged is True + + def test_merge_parallel_runs(self, tmp_path, monkeypatch, capsys): + class DummyPop: + def __init__(self, metallicity, temp_directory,**kwargs): + self.metallicity = metallicity + self.kwargs = {"temp_directory": temp_directory} + self.combine_args = None + self.combined = False + + def combine_saved_files(self, out_path, files): + self.combine_args = (out_path, files) + self.combined = True + + def dummy_kwargs(path): + return { + "metallicity": 0.1, + "temp_directory": "tmp_dir", + "verbose": False} + + ini_path = os.path.join(tmp_path.parent, "dummy.ini") + with open(ini_path, "w") as f: + f.write("[DEFAULT]\nkey=value\n") + + monkeypatch.setattr(totest, "binarypop_kwargs_from_ini", dummy_kwargs) + monkeypatch.setattr(totest, "BinaryPopulation", DummyPop) + monkeypatch.setattr(totest, "convert_metallicity_to_string", + lambda x: str(os.path.join(tmp_path, "0.1"))) + + # 1) File exists case: should raise FileExistsError + pop = DummyPop(metallicity=0.1, temp_directory=str(tmp_path)) + output_file = os.path.join(tmp_path,"0.1_Zsun_population.h5") + with open(output_file, "w") as f: + f.write("test") + run = totest.PopulationRunner(str(ini_path)) + run.verbose = False + with raises(FileExistsError, match="Files were not merged"): + run.merge_parallel_runs(pop) + + # 2) Normal merge case + file1 = os.path.join(tmp_path,"file1.tmp") + file2 = os.path.join(tmp_path,"file2.tmp") + output_file = os.path.join(tmp_path,"0.1_Zsun_population.h5") + with open(file1, "w") as f: + f.write("test") + with open(file2, "w") as f: + f.write("test") + pop = DummyPop(metallicity=0.1, temp_directory=str(tmp_path)) + run = totest.PopulationRunner(str(ini_path)) + run.verbose = True + monkeypatch.setattr(totest, "convert_metallicity_to_string", lambda x: "0.1") + run.merge_parallel_runs(pop) + assert pop.combined is True + out_path, files = pop.combine_args + assert out_path == "0.1_Zsun_population.h5" + # Filter out output file if somehow included (defensive) + filtered_files = [f for f in files if os.path.basename(f) != out_path] + assert set(os.path.basename(f) for f in filtered_files) == {"file1.tmp", "file2.tmp"} + captured = capsys.readouterr() + assert "Merging" in captured.out + assert "Files merged!" in captured.out + assert f"Removing files in {tmp_path}" in captured.out + # cleanup + for f in [file1, file2, output_file]: + if os.path.exists(f): + os.remove(f) + assert len(os.listdir(tmp_path)) == 0 + + run.verbose = False + run.merge_parallel_runs(pop) + assert not os.path.exists(pop.kwargs["temp_directory"]) + +class TestDFInterface: + + def test_head_tail_select(self, tmp_path): + # Setup test HDF5 file + data = pd.DataFrame({ + "index": np.repeat(np.arange(5), 2), + "time": np.random.rand(10), + "value": np.random.rand(10) + }) + hdf_path = os.path.join(tmp_path,"test.h5") + data.to_hdf(hdf_path, key="history", format="table", index=False) + + dfi = totest.DFInterface() + dfi.filename = str(hdf_path) + dfi.chunksize = 3 + + head = dfi.head("history", n=3) + tail = dfi.tail("history", n=2) + subset = dfi.select("history", columns=["time"]) + + assert len(head) == 3 + assert len(tail) == 2 + assert "time" in subset.columns + assert subset.shape[1] == 1 + + def test_repr_methods(self, tmp_path): + df = pd.DataFrame({"index": range(10), "x": np.random.rand(10)}) + path = os.path.join(tmp_path, "test_repr.h5") + df.to_hdf(path, key="history", format="table", index=False) + + dfi = totest.DFInterface() + dfi.filename = str(path) + + s = dfi.get_repr("history") + html = dfi.get_html_repr("history") + + assert isinstance(s, str) + assert "x" in s + assert isinstance(html, str) + assert "=2 for i in out_stopnone.index) + + # __getitem__ with int + out = hist[0] + assert isinstance(out, pd.DataFrame) + + # __getitem__ with list of int + out = hist[[0, 1]] + assert isinstance(out, pd.DataFrame) + out_none = hist[[]] + assert isinstance(out_none,pd.DataFrame) + assert out_none.empty + + # __getitem__ with numpy array of int + out = hist[np.array([0, 1])] + assert isinstance(out, pd.DataFrame) + out_none = hist[np.array([], dtype=int)] + assert isinstance(out_none,pd.DataFrame) + assert out_none.empty + + # __getitem__ with bool array + full_data = pd.read_hdf(file_path, key="history") + mask = full_data["a"] > -1 + empty_mask = np.array([],dtype=bool) + out = hist[mask.to_numpy()] + out_none = hist[empty_mask] + assert not out.empty + assert (out["a"] > -1).all() + assert isinstance(out_none,pd.DataFrame) + assert out_none.empty + + # __getitem__ with str column + out = hist["a"] + assert "a" in out.columns + + # __getitem__ with list of str columns + out = hist[["a"]] + assert "a" in out.columns + + # Invalid column + with raises(ValueError, match="is not a valid column name"): + hist["bad_column"] + + # Invalid list of column names + with raises(ValueError, match="Not all columns in"): + hist[["a", "bad"]] + + # Invalid type + with raises(ValueError, match="Invalid key type"): + hist[None] + + def test_slice(self, tmp_path): + df = pd.DataFrame({ + "index": np.repeat(np.arange(5), 2), + "val": np.random.rand(10) + }) + path = os.path.join(tmp_path, "test_slice.h5") + df.to_hdf(path, key="history", format="table", index=False) + hist = totest.History(str(path)) + sliced = hist[1:3] + assert isinstance(sliced, pd.DataFrame) + + def test_head_tail_repr(self, tmp_path): + df = pd.DataFrame({ + "index": np.repeat(np.arange(5), 2), + "val": np.random.rand(10) + }) + path = os.path.join(tmp_path, "test_repr2.h5") + df.to_hdf(path, key="history", format="table", index=False) + hist = totest.History(str(path)) + + head = hist.head(n=3) + tail = hist.tail(n=2) + rep = repr(hist) + html = hist._repr_html_() + + assert isinstance(head, pd.DataFrame) + assert isinstance(tail, pd.DataFrame) + assert isinstance(rep, str) + assert isinstance(html, str) + assert "val" in rep + assert "= 3 for i in out.index) + + # int + out = one[2] + assert isinstance(out, pd.DataFrame) + assert 2 in out.index + + # list of int + out = one[[1, 3, 5]] + assert set(out.index) == {1, 3, 5} + + # numpy array of int + out = one[np.array([0, 2, 4])] + assert set(out.index) == {0, 2, 4} + + # numpy array of bool + mask = np.array([True, False, True, False, True, False]) + out = one[mask] + assert set(out.index) == {0, 2, 4} + + # pandas DataFrame mask of bool + mask_df = pd.DataFrame({"mask": [True, False, True, False, True, False]}) + out = one[mask_df] + assert set(out.index) == {0, 2, 4} + + # str column + out = one["a"] + assert list(out.columns) == ["a"] + + # list of str columns + out = one[["a", "b"]] + assert list(out.columns) == ["a", "b"] + + # invalid column + with raises(ValueError, match="is not a valid column"): + one["bad"] + + # invalid list of str columns + with raises(ValueError, match="Not all columns in"): + one[["a", "bad"]] + + # invalid type + with raises(ValueError, match="Invalid key type"): + one[None] + + def test_invalid_float_list(self, tmp_path): + df = pd.DataFrame({ + "index": np.arange(3), + "val": np.random.rand(3) + }) + file_path = os.path.join(tmp_path, "test_invalid_float_list.h5") + df.set_index("index", inplace=True) + df.to_hdf(file_path, key="oneline", format="table") + + one = totest.Oneline(str(file_path)) + + with raises(ValueError, match="elements in list are not integers"): + one[[1.1, 2.2]] + +class TestPopulationIO: + + @fixture + def popio(self): + p = totest.PopulationIO() + p.mass_per_metallicity = pd.DataFrame({"Z": [0.02], "mass": [1.0]}) + p.ini_params = {"param1": 42, "param2": "abc"} + return p + + def test_load_metadata(self,monkeypatch,popio): + # bad input + with raises(ValueError,match='does not contain .h5'): + popio._load_metadata("not_pop.txt") + # examples + called={} + monkeypatch.setattr(popio, "_load_ini_params", lambda f: called.setdefault("ini", f)) + monkeypatch.setattr(popio, "_load_mass_per_metallicity", lambda f: called.setdefault("mass", f)) + popio._load_metadata("file.h5") + assert called == {"ini": "file.h5", "mass": "file.h5"} + + def test_save_mass_per_metallicity(self, tmp_path, popio): + filename = os.path.join(tmp_path, "mass.h5") + popio._save_mass_per_metallicity(filename) + + with pd.HDFStore(filename, "r") as store: + df = store["mass_per_metallicity"] + + pd.testing.assert_frame_equal(df, popio.mass_per_metallicity) + + def test_save_ini_params(self,tmp_path,popio,monkeypatch): + filename = os.path.join(tmp_path, "ini_out.h5") + monkeypatch.setattr("posydon.popsyn.synthetic_population.saved_ini_parameters", ["param1", "param3"]) + + popio._save_ini_params(filename) + + with pd.HDFStore(filename, "r") as store: + df = store["ini_parameters"] + + assert list(df.columns) == ["param1"] + assert df["param1"][0] == 42 + def test_load_ini_params(self,tmp_path,popio,monkeypatch): + filename = os.path.join(tmp_path, "ini.h5") + monkeypatch.setattr("posydon.popsyn.synthetic_population.saved_ini_parameters", ["param1", "param2", "param3"]) + + df = pd.DataFrame({"param1": [1], "param2": ["x"]}) + with pd.HDFStore(filename, "w") as store: + store.put("ini_parameters", df) + + popio._load_ini_params(filename) + + assert popio.ini_params["param1"] == 1 + assert popio.ini_params["param2"] == "x" + assert "param3" not in popio.ini_params + +class TestPopulation: + + def create_minimal_population_file(self,tmp_path): + filename = tmp_path / "pop.h5" + + # Minimal History table + history_df = pd.DataFrame({ + "event": [0], + "time": [0.0] + }) + history_df.index.name = "binary_index" + + # History lengths table + history_lengths_df = pd.DataFrame({ + "length": [len(history_df)] + }, index=history_df.index) + + # Minimal Oneline table + oneline_df = pd.DataFrame({ + "S1_mass_i": [1], + "S2_mass_i": [1], + "state_i": ["initial"], + "metallicity": [0.02], + "interp_class_HMS_HMS": ["stable_MT"], + "mt_history_HMS_HMS": ["Stable contact phase"] + }) + oneline_df.index.name = "binary_index" + + ini_df = pd.DataFrame({ + "Parameter": [ + "metallicity", "number_of_binaries", "binary_fraction_scheme", + "binary_fraction_const", "star_formation", "max_simulation_time", + "primary_mass_scheme", "primary_mass_min", "primary_mass_max", + "secondary_mass_scheme", "secondary_mass_min", "secondary_mass_max", + "orbital_scheme", "orbital_period_scheme", "orbital_period_min", + "orbital_period_max", "orbital_separation_scheme", "orbital_separation_min", + "orbital_separation_max", "eccentricity_scheme" + ], + "Value": [0.02]*20 # dummy values for testing + }) + + # Mass per metallicity table + mass_df = pd.DataFrame({"simulated_mass": [0]}, index=[0.02]) + + # Save all tables + with pd.HDFStore(filename, "w") as store: + store.put("history", history_df, format="table") + store.put("history_lengths", history_lengths_df, format="table") + store.put("oneline", oneline_df, format="table") + store.put("ini_parameters", ini_df, format="table") + store.put("mass_per_metallicity", mass_df, format="table") + + return filename + + + def test_population_init(self, tmp_path, monkeypatch): + + # bad input + with raises(ValueError, match="does not contain .h5"): + totest.Population("hello.txt") + + # missing /history + filename = os.path.join(tmp_path, "pop_missing.h5") + with pd.HDFStore(filename, "w") as store: + store.append("ini_parameters", pd.DataFrame({"Parameter": [], "Value": []}), format="table") + with raises(ValueError, match="does not contain a history table"): + totest.Population(str(filename)) + + # /history exists, /oneline missing + history_df = pd.DataFrame({"event": [0], "time": [0.0], "binary_index": [0]}) + with pd.HDFStore(filename, "a") as store: + store.append("history", history_df, format="table") + with raises(ValueError, match="does not contain an oneline table"): + totest.Population(str(filename)) + + # /history and /oneline exist, no ini_parameters + oneline_df = pd.DataFrame({ + "S1_mass_i": [1], + "S2_mass_i": [1], + "state_i": ["initially_single_star"], # <- must match the branch + "metallicity": [0.02] + }) + with pd.HDFStore(filename, "a") as store: + store.append("oneline", oneline_df, format="table") + store.put("mass_per_metallicity", + pd.DataFrame({"simulated_mass": [0]}, index=[0.02]), + format="table") + with raises(ValueError,match='does not contain an ini_parameters table'): + pop = totest.Population(str(filename)) + + # /history and /oneline exist, yes ini_parameters, no mass_per_metallicity + filename_no_mass = os.path.join(tmp_path, "pop_no_mass.h5") + with pd.HDFStore(filename_no_mass, "w") as store: + store.put("history", history_df,format="table") + store.put("oneline", oneline_df, format="table") + store.put("ini_parameters", + pd.DataFrame({"Parameter": ["metallicity"], "Value": [0.02]}),format="table") + with raises(ValueError,match='does not contain a mass_per_metallicity table'): + pop = totest.Population(str(filename_no_mass)) + + # metallicity specified + monkeypatch.setattr( + "posydon.popsyn.synthetic_population.binarypop_kwargs_from_ini", + lambda ini_file: {"dummy_param": 1}) + dummy_ini_file = os.path.join(tmp_path,"dummy.ini") + pop_with_metallicity = totest.Population( + str(filename_no_mass), metallicity=0.02, ini_file=str(dummy_ini_file) + ) + assert pop_with_metallicity.mass_per_metallicity is not None + assert pop_with_metallicity.solar_metallicities[0] == 0.02 + assert pop_with_metallicity.metallicities[0] == 0.02 * Zsun + + # everything exists + filename_full = os.path.join(tmp_path, "pop_full.h5") + with pd.HDFStore(filename_full, "w") as store: + store.put("history", history_df,format="table") + store.put("oneline", oneline_df,format="table") + store.put("ini_parameters", pd.DataFrame({"param1": [1]}),format="table") + store.put("mass_per_metallicity", pd.DataFrame({"simulated_mass": [0]}, index=[0.02]), + format="table") + store.put("formation_channels", pd.DataFrame({"channel": ["dynamic"]})) + pop = totest.Population(str(filename_full)) + assert pop.number_of_systems == 1 + assert isinstance(pop.history, totest.History) + assert isinstance(pop.oneline, totest.Oneline) + + # Metallicity specified + dummy_ini_file = os.path.join(tmp_path,"dummy.ini") + pop_with_metallicity = totest.Population( + str(filename_full), metallicity=0.02, ini_file=str(dummy_ini_file) + ) + assert pop_with_metallicity.mass_per_metallicity is not None + assert pop_with_metallicity.solar_metallicities[0] == 0.02 + assert pop_with_metallicity.metallicities[0] == 0.02 * Zsun + + def test_export_selection(self,tmp_path,monkeypatch): + filename = self.create_minimal_population_file(tmp_path) + pop = totest.Population(str(filename)) + export_file = tmp_path / "exp.h5" + + # bad input + with raises(ValueError,match='does not contain .h5'): + pop.export_selection([0],'hello.txt') + + with raises(ValueError,match="Both overwrite and append cannot be True!"): + pop.export_selection([0],str(export_file),append=True,overwrite=True) + + dummy_file = tmp_path / "exists.h5" + pd.DataFrame({"a": [1]}).to_hdf(dummy_file, "dummy", format="table") + with raises(FileExistsError,match='Set overwrite or append to True'): + pop.export_selection([0], str(dummy_file),overwrite=False,append=False) + + # overwrite export + out_file = tmp_path / "out.h5" + pop.export_selection([0], str(out_file), overwrite=True, history_chunksize=1) + + # append export + pop.export_selection([0], str(out_file), append=True, history_chunksize=1) + + # write export + pop.export_selection([0], os.path.join(tmp_path,'new.h5'), append=False, + overwrite=False, + history_chunksize=1) + + # No metallicity column + class DummyOnelineNoMetal: + columns = ["S1_mass_i", "S2_mass_i", "state_i"] # no 'metallicity' + number_of_systems = 1 + + def __getitem__(self, cols): + import pandas as pd + return pd.DataFrame({ + "S1_mass_i": [1], + "S2_mass_i": [1], + "state_i": ["initial"] + }) + + def __len__(self): + return self.number_of_systems + + pop.oneline = DummyOnelineNoMetal() + pop.export_selection([0], str(tmp_path/"out2.h5"), overwrite=True) + + # Check mass_per_metallicity updated + df = pd.read_hdf(out_file, "mass_per_metallicity") + assert "number_of_systems" in df.columns + + def test_calculate_formation_channels(self,tmp_path): + filename = self.create_minimal_population_file(tmp_path) + + pop = totest.Population(str(filename)) + + # Should not error even with empty data + pop.calculate_formation_channels() + + # Result should be a dataframe (even if empty) + assert hasattr(pop.formation_channels, "columns") + def test_create_transient_population(self): + # missing argument + # bad input + # examples + pass + +class TestTransientPopulation: + + @fixture + def fix(self): +# return + pass + + def test_population(self): + # missing argument + # bad input + # examples + pass + def test_columns(self): + # missing argument + # bad input + # examples + pass + def test_select(self): + # missing argument + # bad input + # examples + pass + def test_get_efficiency_over_metallicity(self): + # missing argument + # bad input + # examples + pass + def test_calculate_cosmic_weights(self): + # missing argument + # bad input + # examples + pass + +class TestRates: + + @fixture + def fix(self): + # return + pass + + def test_weights(self): + # missing argument + # bad input + # examples + pass + def test_z_birth(self): + # missing argument + # bad input + # examples + pass + def test_z_events(self): + # missing argument + # bad input + # examples + pass + def test_select_rate_slice(self): + # missing argument + # bad input + # examples + pass + def test_calculate_intrinsic_rate_density(self): + # missing argument + # bad input + # examples + pass + def test_calculate_observable_population(self): + # missing argument + # bad input + # examples + pass + def test_observable_population(self): + # missing argument + # bad input + # examples + pass + def test_observable_population_names(self): + # missing argument + # bad input + # examples + pass + def test_intrinsic_rate_density(self): + # missing argument + # bad input + # examples + pass + def test_edges_metallicity_bins(self): + # missing argument + # bad input + # examples + pass + def test_centers_metallicity_bins(self): + # missing argument + # bad input + # examples + pass + def test_edges_redshift_bins(self): + # missing argument + # bad input + # examples + pass + def test_centers_redshift_bins(self): + # missing argument + # bad input + # examples + pass diff --git a/posydon/unit_tests/popsyn/test_transient_select_funcs.py b/posydon/unit_tests/popsyn/test_transient_select_funcs.py new file mode 100644 index 0000000000..ac80c256d9 --- /dev/null +++ b/posydon/unit_tests/popsyn/test_transient_select_funcs.py @@ -0,0 +1,340 @@ +"""Unit tests of posydon/popsyn/transient_select_funcs.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +# import the module which will be tested +import posydon.popsyn.transient_select_funcs as totest + +# aliases +np = totest.np +pd = totest.pd +PATH_TO_POSYDON_DATA = totest.PATH_TO_POSYDON_DATA + +import warnings +from inspect import isclass, isroutine + +# import other needed code for the tests, which is not already imported in the +# module you like to test +from pytest import approx, fixture, raises, warns + +import posydon.popsyn.selection_effects as selection_effects +from posydon.utils.posydonwarning import ReplaceValueWarning + +warnings.simplefilter("always") + +# define test classes collecting several test functions +class TestElements: + # check for objects, which should be an element of the tested module + def test_dir(self): + elements = ['PATH_TO_PDET_GRID', 'GRB_selection', 'chi_eff', 'm_chirp', \ + 'mass_ratio', 'BBH_selection_function','DCO_detectability', \ + '__builtins__', '__cached__', '__doc__', \ + '__file__','__loader__', '__name__', '__package__', '__spec__', \ + 'np', 'pd', 'PATH_TO_POSYDON_DATA', \ + 'os', 'tqdm', 'warnings', 'Pwarn','selection_effects'] + totest_elements = set(dir(totest)) + missing_in_test = set(elements) - totest_elements + assert len(missing_in_test) == 0, "There are missing objects in "\ + +f"{totest.__name__}: "\ + +f"{missing_in_test}. Please "\ + +"check, whether they have been "\ + +"removed on purpose and update "\ + +"this unit test." + new_in_test = totest_elements - set(elements) + assert len(new_in_test) == 0, "There are new objects in "\ + +f"{totest.__name__}: {new_in_test}. "\ + +"Please check, whether they have been "\ + +"added on purpose and update this "\ + +"unit test." + + def test_instance_GRB_selection(self): + assert isroutine(totest.GRB_selection) + + def test_instance_chi_eff(self): + assert isroutine(totest.chi_eff) + + def test_instance_m_chirp(self): + assert isroutine(totest.m_chirp) + + def test_instance_mass_ratio(self): + assert isroutine(totest.mass_ratio) + + def test_instance_BBH_selection_function(self): + assert isroutine(totest.BBH_selection_function) + + def test_instance_DCO_detectability(self): + assert isroutine(totest.DCO_detectability) + +class TestFunctions: + + @fixture + def history_chunk(self): + return pd.DataFrame({ + 'binary_index': [10, 10, 10], + 'S1_state': ['MS', 'HG', 'BH'], + 'S2_state': ['MS', 'HG', 'BH'], + 'step_names': ['step_RLO', 'step_RLO', 'step_SN'], + 'orbital_period': [1.0, 1.1, 1.2], + 'eccentricity': [0.1, 0.15, 0.2], + 'S1_spin': [0.3, 0.35, 0.4], + 'S2_spin': [0.5, 0.55, 0.6], + 'S1_mass': [10.0, 9.5, 9.0], + 'S2_mass': [8.0, 7.5, 7.0], + 'time': [1.0e6, 2.0e6, 3.0e6]}) + + @fixture + def oneline_chunk(self): + return pd.DataFrame({ + 'metallicity': [0.02], + 'S1_m_disk_radiated': [0.5], + 'S2_m_disk_radiated': [0.0], + }, index=[10]) + + @fixture + def formation_channels_chunk(self): + return pd.DataFrame({ + 'channel': ['foo_CC1','bar_CC2'] + }, index=[10,11]) + + @fixture + def history_BBH(self): + return pd.DataFrame({ + 'event': ['MID', 'END', 'END'], + 'time': [1e6, 5e6, 6e6], + 'S1_state': ['BH', 'BH', 'BH'], + 'S2_state': ['BH', 'BH', 'BH'], + 'step_names': ['step_SN', 'step_SN', 'step_SN'], + 'state': ['detached', 'detached', 'detached'], + 'S1_mass': [30, 35, 40], + 'S2_mass': [20, 25, 30], + 'S1_spin': [0.5, 0.6, 0.7], + 'S2_spin': [0.4, 0.3, 0.2], + 'orbital_period': [0.5, 0.6, 0.7], + 'eccentricity': [0.1, 0.2, 0.3], + }, index=[0,1,2]) + + @fixture + def oneline_BBH(self): + return pd.DataFrame({ + 'metallicity': [0.01, 0.02, 0.03], + 'S1_spin_orbit_tilt_second_SN': [0.1, 0.2, 0.3], + 'S2_spin_orbit_tilt_second_SN': [0.4, 0.5, 0.6], + }, index=[0,1,2]) + + @fixture + def formation_channels_BBH(self): + return pd.DataFrame({ + 'channel': ['foo', 'bar', 'baz'], + }, index=[0,1,2]) + + @fixture + def array(self): + return np.array([1.0,2.0,3.0]) + + @fixture + def nan_array(self): + return np.array([np.nan,np.nan,np.nan]) + + @fixture + def wrong_array(self): + return np.array(['1.0','2.0','3.0']) + + @fixture + def transient_pop_chunk(self): + return pd.DataFrame({ + 'S1_mass': [30, 35], + 'S2_mass': [25, 30], + 'S1_spin': [0.1, 0.2], + 'S2_spin': [0.1, 0.2], + 'S1_spin_orbit_tilt_at_merger': [0.5, 0.6], + 'S2_spin_orbit_tilt_at_merger': [0.4, 0.5], + 'q': [0.83, 0.86], + 'chi_eff': [0.1, 0.2]}) + + @fixture + def z_events_chunk(self): + return pd.DataFrame({ + 'event_1': [0.1, np.nan], + 'event_2': [0.2, 0.3]}) + + @fixture + def z_events_chunk_with_nan(self): + return pd.DataFrame({ + 'event_1': [1.0, np.nan], + 'event_2': [np.nan, np.nan] + }, index=[0,1]) + + @fixture + def z_weights_chunk(self): + return pd.DataFrame({ + 'event_1': [1.0, 1.0], + 'event_2': [1.0, 1.0] + }, index=[0, 1]) + + + def test_GRB_selection(self,history_chunk,oneline_chunk, + formation_channels_chunk): + # missing argument + with raises(TypeError,match="missing 2 required positional arguments"): + totest.GRB_selection() + # bad input + with raises(TypeError,match='string indices must be integers'): + totest.GRB_selection("1.1", "1.2") + with raises(AttributeError,match="'float' object has no attribute 'index'"): + totest.GRB_selection(1.1, 1.2) + with raises(ValueError,match='S1_S2 must be either S1 or S2'): + totest.GRB_selection(history_chunk, oneline_chunk.copy(), + S1_S2='test') + # example with S1 + df = totest.GRB_selection(history_chunk, oneline_chunk.copy(), + formation_channels_chunk, S1_S2='S1') + assert not df.empty + assert df.index[0] == 10 + assert 'S1_mass_preSN' in df.columns + assert 'S1_mass_postSN' in df.columns + assert df['time'].iloc[0] == 3.0 # 3 Myr = 3e6 years * 1e-6 + assert df['channel'].iloc[0] == 'foo_CC1' + # example with no formation channels + df = totest.GRB_selection(history_chunk, oneline_chunk.copy(), + formation_channels_chunk=None, S1_S2='S1') + assert not df.empty + assert 'channel' not in df.columns + # example with S2 + chunk = oneline_chunk.copy() + chunk['S1_m_disk_radiated'] = [0.0] + chunk['S2_m_disk_radiated'] = [0.5] + df = totest.GRB_selection(history_chunk, chunk, + formation_channels_chunk, S1_S2='S2') + assert not df.empty + assert 'S2_mass_postSN' in df.columns + assert 'metallicity' in df.columns + assert 'channel' in df.columns + # example with no disk radiation + chunk = oneline_chunk.copy() + chunk['S1_m_disk_radiated'] = [0.0] + df = totest.GRB_selection(history_chunk, chunk, formation_channels_chunk=None,S1_S2='S1') + assert df.empty + + def test_chi_eff(self,array,nan_array,wrong_array): + # missing argument + with raises(TypeError,match="missing 6 required positional arguments"): + totest.chi_eff() + # bad input + with raises(TypeError,match="ufunc 'cos' not supported for the input types"): + totest.chi_eff(array,array,array,array,array,wrong_array) + # undefined values + with warns(ReplaceValueWarning,match="a_1 contains undefined values"): + totest.chi_eff(array,array,nan_array.copy(),array,array,array) + with warns(ReplaceValueWarning,match="a_2 contains undefined values"): + totest.chi_eff(array,array,array,nan_array.copy(),array,array) + with warns(ReplaceValueWarning,match="tilt_1 contains undefined values"): + totest.chi_eff(array,array,array,array,nan_array.copy(),array) + with warns(ReplaceValueWarning,match="tilt_2 contains undefined values"): + totest.chi_eff(array,array,array,array,array,nan_array.copy()) + # example + assert totest.chi_eff(array,array,array, + array,array,array)[0] == 0.5403023058681398 + + def test_m_chirp(self): + # missing argument + with raises(TypeError,match="missing 1 required positional argument: 'm_2'"): + totest.m_chirp(3.) + # bad input + with raises(TypeError,match="can't multiply sequence by non-int of type 'str'"): + totest.m_chirp("3.","2.") + # examples + tests = [(4.,2.,2.433457367572823), + (40.,10.,16.65106414803746)] + for (m1,m2,mc) in tests: + assert totest.m_chirp(m1,m2) == mc + + def test_mass_ratio(self): + # missing argument + with raises(TypeError,match="missing 1 required positional argument: 'm_2'"): + totest.mass_ratio(3.) + # bad input + with raises(TypeError,match="unsupported operand type"): + totest.mass_ratio("3.","2.") + # examples + tests = [(5.,1.,0.2), + (1.,5.,0.2), + (4.,2.,0.5)] + for (m1,m2,q) in tests: + assert totest.mass_ratio(np.array([m1]), + np.array([m2])) == q + + def test_BBH_selection_function(self, history_BBH, oneline_BBH, + formation_channels_BBH): + # missing argument + with raises(TypeError,match="missing 2 required positional arguments"): + totest.BBH_selection_function() + # bad input + with raises(AttributeError,match="'float' object has no attribute 'index'"): + totest.BBH_selection_function(1.1, 1.2) + # example without formation channels + df = totest.BBH_selection_function(history_BBH, oneline_BBH) + assert not df.empty + assert all(col in df.columns for col in [ + 'time', 't_inspiral', 'metallicity', 'S1_state', 'S2_state', + 'S1_mass', 'S2_mass', 'S1_spin', 'S2_spin', + 'S1_spin_orbit_tilt_at_merger', 'S2_spin_orbit_tilt_at_merger', + 'orbital_period', 'chirp_mass', 'mass_ratio', 'chi_eff', 'eccentricity' + ]) + assert (df.index == oneline_BBH.index).all() + assert df['t_inspiral'].iloc[1] == 0.0 + # example with formation channels + df = totest.BBH_selection_function(history_BBH, oneline_BBH, formation_channels_BBH) + assert 'channel' in df.columns + assert (df['channel'] == formation_channels_BBH['channel']).all() + + def test_DCO_detectability(self, + transient_pop_chunk, + z_events_chunk, + z_events_chunk_with_nan, + z_weights_chunk, + monkeypatch): + class FakeKNNmodel: + def __init__(self, grid_path, sensitivity_key): + pass + def predict_pdet(self, df): + # Return a fixed probability (e.g., 0.5) for each row in df + return np.full(len(df), 0.5) + + monkeypatch.setattr('posydon.popsyn.selection_effects.KNNmodel', + FakeKNNmodel) + + # missing argument + with raises(TypeError,match="missing 4 required positional arguments"): + totest.DCO_detectability() + # bad input + with raises(ValueError,match='Unknown sensitivity sens_example'): + totest.DCO_detectability("sens_example", + transient_pop_chunk, + z_events_chunk, + z_weights_chunk) + # example: basic functionality + out = totest.DCO_detectability('O3actual_H1L1V1', transient_pop_chunk, + z_events_chunk, z_weights_chunk) + assert isinstance(out, pd.DataFrame) + assert out.shape == z_weights_chunk.shape + assert (out.values <= 1.0).all() + # example: missing q + transient = transient_pop_chunk.drop(columns=['q']) + out = totest.DCO_detectability('O3actual_H1L1V1', transient, + z_events_chunk, z_weights_chunk) + assert not out.empty + # example: missing chi_eff + transient = transient_pop_chunk.drop(columns=['chi_eff']) + out = totest.DCO_detectability('O3actual_H1L1V1', transient, + z_events_chunk, z_weights_chunk) + assert not out.empty + assert (out.values <= 1.0).all() + # example: masking for nans in z_events_chunk + out = totest.DCO_detectability('O3actual_H1L1V1', + transient_pop_chunk, + z_events_chunk_with_nan, + z_weights_chunk) + assert (out['event_2'] == 0.0).all() From 1c9bda5f1fe9987c854ccb2680a78fa7dad0d78b Mon Sep 17 00:00:00 2001 From: Elizabeth Teng Date: Thu, 26 Mar 2026 08:33:10 -0500 Subject: [PATCH 02/10] population helper function --- .../_helper_functions_for_tests/population.py | 131 ++++++++++++++++++ .../popsyn/test_normalized_pop_mass.py | 42 ------ 2 files changed, 131 insertions(+), 42 deletions(-) create mode 100644 posydon/unit_tests/_helper_functions_for_tests/population.py delete mode 100644 posydon/unit_tests/popsyn/test_normalized_pop_mass.py diff --git a/posydon/unit_tests/_helper_functions_for_tests/population.py b/posydon/unit_tests/_helper_functions_for_tests/population.py new file mode 100644 index 0000000000..413341ed42 --- /dev/null +++ b/posydon/unit_tests/_helper_functions_for_tests/population.py @@ -0,0 +1,131 @@ +"""Helper function(s) for tests requiring a POSYDON Population + +used in: + - posydon/unit_tests/popsyn/test_synthetic_population.py +""" + +__authors__ = [ + "Elizabeth Teng " +] + +import os +import h5py +import numpy as np +import pandas as pd + +from posydon.popsyn.synthetic_population import Population + + +# helper functions + +def make_ini(tmp_path,content=None): + """ + Create a minimal dummy .ini file inside the pytest tmp_path using os.path. + + Parameters + ---------- + tmp_path : pathlib.Path + pytest temporary directory. + content : str, optional + Content to write to the ini file. + + Returns + ------- + str + Path (string) to the created dummy ini file. + """ + dir_path = str(tmp_path) + ini_path = os.path.join(dir_path, "dummy.ini") + + if content is None: + content = "[DEFAULT]\nkey=value\n" + + with open(ini_path, "w") as f: + f.write(content) + + return str(ini_path) + +def make_test_pop( + tmp_path, + filename="test_population.h5", + oneline_rows=None, + history_rows=None, + metallicity=0.02): + """ + Create a minimally valid synthetic population HDF5 file and return a + fully initialized Population object. This centralizes and standardizes + population generation across unit tests. + + Parameters + ---------- + tmp_path : Path-like + Directory in which the HDF5 file will be created. + filename : str + Name of the file to write. + oneline_rows : list[dict], optional + Rows for the /oneline table. + history_rows : list[dict], optional + Rows for the /history table. + metallicity : float + Metallicty value for oneline and mass_per_metallicity tables. + + Returns + ------- + Population + A fully initialized Population instance. + """ + + # history and oneline tables + + if history_rows is None: + history_rows = [{"binary_index": 0, "event": "start", "time": 0.0}, + {"binary_index": 0, "event": "end", "time": 1.0}, + {"binary_index": 1, "event": "start", "time": 0.0}, + {"binary_index": 1, "event": "end", "time": 1.0}] + + if oneline_rows is None: + oneline_rows = [{"binary_index": 0, + "S1_mass_i": 1.0, + "S2_mass_i": 1.0, + "state_i": "initial", + "metallicity": metallicity, + "interp_class_HMS_HMS": "initial_MT", + "mt_history_HMS_HMS": "Stable"}, + {"binary_index": 1, + "S1_mass_i": 1.0, + "S2_mass_i": 1.0, + "state_i": "initial", + "metallicity": metallicity, + "interp_class_HMS_HMS": "no_MT", + "mt_history_HMS_HMS": None}] + + # Convert to DataFrames + oneline_df = pd.DataFrame(oneline_rows).sort_values("binary_index") + history_df = pd.DataFrame(history_rows).sort_values(["binary_index", "time"]) + + # history_lengths = number of rows per binary_index + history_lengths_df = history_df.groupby("binary_index").size().to_frame("length") + + # ini_parameters + ini_df = pd.DataFrame({ + "Parameter": ["metallicity", "number_of_binaries"], + "Value": [metallicity, len(oneline_df)], + }) + + # mass_per_metallicity + mass_df = pd.DataFrame( + {"simulated_mass": [1.0], "number_of_systems": [len(oneline_df)]}, + index=[metallicity] + ) + + # Write HDF5 file using pandas/HDFStore (Population expects PyTables layout) + fpath = os.path.join(tmp_path, filename) + with pd.HDFStore(fpath, "w") as store: + store.put("oneline", oneline_df, format="table") + store.put("history", history_df, format="table") + store.put("history_lengths", history_lengths_df, format="table") + store.put("ini_parameters", ini_df, format="table") + store.put("mass_per_metallicity", mass_df, format="table") + + # Return fully initialized Population object + return Population(fpath) diff --git a/posydon/unit_tests/popsyn/test_normalized_pop_mass.py b/posydon/unit_tests/popsyn/test_normalized_pop_mass.py deleted file mode 100644 index a9b9a94004..0000000000 --- a/posydon/unit_tests/popsyn/test_normalized_pop_mass.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Unit tests of posydon/popsyn/normalized_pop_mass.py -""" - -__authors__ = [ - "Elizabeth Teng " -] - -# import the module which will be tested -import posydon.popsyn.normalized_pop_mass as totest - -# aliases -np = totest.np - -from inspect import isclass, isroutine - -# import other needed code for the tests, which is not already imported in the -# module you like to test -from pytest import approx, fixture, raises, warns - - -# define test classes collecting several test functions -class TestElements: - # check for objects, which should be an element of the tested module - def test_dir(self): - totest_elements = set(dir(totest)) - missing_in_test = set(elements) - totest_elements - assert len(missing_in_test) == 0, "There are missing objects in "\ - +f"{totest.__name__}: "\ - +f"{missing_in_test}. Please "\ - +"check, whether they have been "\ - +"removed on purpose and update "\ - +"this unit test." - new_in_test = totest_elements - set(elements) - assert len(new_in_test) == 0, "There are new objects in "\ - +f"{totest.__name__}: {new_in_test}. "\ - +"Please check, whether they have been "\ - +"added on purpose and update this "\ - +"unit test." - -class TestFunctions: - def test_initial_total_underlying_mass(self): - pass From 9527e7cc15c2b840e565b18f52127e66ce4d6d7a Mon Sep 17 00:00:00 2001 From: Elizabeth Teng Date: Thu, 26 Mar 2026 08:34:16 -0500 Subject: [PATCH 03/10] Refactor test_synthetic_population to use make_test_pop helper - Replace inline population file creation with make_test_pop/make_ini helpers - Remove duplicate test_export_selection and test_calculate_formation_channels from TestRates - Remove editing artifacts (embedded code block) - Add multiple-metallicities error test in test_export_selection - Expand test_calculate_formation_channels with DummyOneline mock Co-Authored-By: Claude Opus 4.6 (1M context) --- .../popsyn/test_synthetic_population.py | 296 ++++++------------ 1 file changed, 101 insertions(+), 195 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_synthetic_population.py b/posydon/unit_tests/popsyn/test_synthetic_population.py index ded4352659..230d8beba6 100644 --- a/posydon/unit_tests/popsyn/test_synthetic_population.py +++ b/posydon/unit_tests/popsyn/test_synthetic_population.py @@ -24,6 +24,7 @@ import os import shutil +from posydon.unit_tests._helper_functions_for_tests.population import make_test_pop, make_ini # define test classes collecting several test functions class TestElements: @@ -93,15 +94,11 @@ def dummy_kwargs_list(path): def dummy_merge(pop,overwrite): pop.merged = True - ini_path = os.path.join(tmp_path, "dummy.ini") - with open(ini_path, "w") as f: - f.write("[DEFAULT]\nkey=value\n") - # Mock out functions monkeypatch.setattr(totest, "binarypop_kwargs_from_ini", dummy_kwargs) monkeypatch.setattr(totest, "BinaryPopulation", DummyPop) monkeypatch.setattr(totest, "convert_metallicity_to_string", lambda x: "0.1") - run = totest.PopulationRunner(str(ini_path)) + run = totest.PopulationRunner(make_ini(tmp_path)) # overwrite=False, directory doesn't exist monkeypatch.setattr(os.path, "exists", lambda path: False) run.merge_parallel_runs = dummy_merge @@ -111,7 +108,7 @@ def dummy_merge(pop,overwrite): # overwrite=False, directory exists monkeypatch.setattr(os.path, "exists", lambda path: True) monkeypatch.setattr(totest, "binarypop_kwargs_from_ini", dummy_kwargs_list) - run = totest.PopulationRunner(str(ini_path), verbose=True) + run = totest.PopulationRunner(make_ini(tmp_path), verbose=True) with raises(FileExistsError, match="tmp_dir"): run.evolve(overwrite=False) # overwrite=True, directory exists @@ -141,10 +138,6 @@ def dummy_kwargs(path): "temp_directory": "tmp_dir", "verbose": False} - ini_path = os.path.join(tmp_path.parent, "dummy.ini") - with open(ini_path, "w") as f: - f.write("[DEFAULT]\nkey=value\n") - monkeypatch.setattr(totest, "binarypop_kwargs_from_ini", dummy_kwargs) monkeypatch.setattr(totest, "BinaryPopulation", DummyPop) monkeypatch.setattr(totest, "convert_metallicity_to_string", @@ -155,7 +148,7 @@ def dummy_kwargs(path): output_file = os.path.join(tmp_path,"0.1_Zsun_population.h5") with open(output_file, "w") as f: f.write("test") - run = totest.PopulationRunner(str(ini_path)) + run = totest.PopulationRunner(make_ini(tmp_path.parent)) run.verbose = False with raises(FileExistsError, match="Files were not merged"): run.merge_parallel_runs(pop) @@ -169,7 +162,7 @@ def dummy_kwargs(path): with open(file2, "w") as f: f.write("test") pop = DummyPop(metallicity=0.1, temp_directory=str(tmp_path)) - run = totest.PopulationRunner(str(ini_path)) + run = totest.PopulationRunner(make_ini(tmp_path.parent)) run.verbose = True monkeypatch.setattr(totest, "convert_metallicity_to_string", lambda x: "0.1") run.merge_parallel_runs(pop) @@ -527,59 +520,6 @@ def test_load_ini_params(self,tmp_path,popio,monkeypatch): class TestPopulation: - def create_minimal_population_file(self,tmp_path): - filename = tmp_path / "pop.h5" - - # Minimal History table - history_df = pd.DataFrame({ - "event": [0], - "time": [0.0] - }) - history_df.index.name = "binary_index" - - # History lengths table - history_lengths_df = pd.DataFrame({ - "length": [len(history_df)] - }, index=history_df.index) - - # Minimal Oneline table - oneline_df = pd.DataFrame({ - "S1_mass_i": [1], - "S2_mass_i": [1], - "state_i": ["initial"], - "metallicity": [0.02], - "interp_class_HMS_HMS": ["stable_MT"], - "mt_history_HMS_HMS": ["Stable contact phase"] - }) - oneline_df.index.name = "binary_index" - - ini_df = pd.DataFrame({ - "Parameter": [ - "metallicity", "number_of_binaries", "binary_fraction_scheme", - "binary_fraction_const", "star_formation", "max_simulation_time", - "primary_mass_scheme", "primary_mass_min", "primary_mass_max", - "secondary_mass_scheme", "secondary_mass_min", "secondary_mass_max", - "orbital_scheme", "orbital_period_scheme", "orbital_period_min", - "orbital_period_max", "orbital_separation_scheme", "orbital_separation_min", - "orbital_separation_max", "eccentricity_scheme" - ], - "Value": [0.02]*20 # dummy values for testing - }) - - # Mass per metallicity table - mass_df = pd.DataFrame({"simulated_mass": [0]}, index=[0.02]) - - # Save all tables - with pd.HDFStore(filename, "w") as store: - store.put("history", history_df, format="table") - store.put("history_lengths", history_lengths_df, format="table") - store.put("oneline", oneline_df, format="table") - store.put("ini_parameters", ini_df, format="table") - store.put("mass_per_metallicity", mass_df, format="table") - - return filename - - def test_population_init(self, tmp_path, monkeypatch): # bad input @@ -589,139 +529,162 @@ def test_population_init(self, tmp_path, monkeypatch): # missing /history filename = os.path.join(tmp_path, "pop_missing.h5") with pd.HDFStore(filename, "w") as store: - store.append("ini_parameters", pd.DataFrame({"Parameter": [], "Value": []}), format="table") + store.put("ini_parameters", pd.DataFrame({"Parameter": [], "Value": []}), format="table") with raises(ValueError, match="does not contain a history table"): totest.Population(str(filename)) # /history exists, /oneline missing - history_df = pd.DataFrame({"event": [0], "time": [0.0], "binary_index": [0]}) + history_df = pd.DataFrame({"binary_index": [0], "event": [0], "time": [0.0]}) with pd.HDFStore(filename, "a") as store: - store.append("history", history_df, format="table") + store.put("history", history_df, format="table") with raises(ValueError, match="does not contain an oneline table"): totest.Population(str(filename)) # /history and /oneline exist, no ini_parameters oneline_df = pd.DataFrame({ + "binary_index": [0], "S1_mass_i": [1], "S2_mass_i": [1], - "state_i": ["initially_single_star"], # <- must match the branch + "state_i": ["initially_single_star"], "metallicity": [0.02] - }) + }) with pd.HDFStore(filename, "a") as store: - store.append("oneline", oneline_df, format="table") - store.put("mass_per_metallicity", - pd.DataFrame({"simulated_mass": [0]}, index=[0.02]), - format="table") - with raises(ValueError,match='does not contain an ini_parameters table'): - pop = totest.Population(str(filename)) + store.put("oneline", oneline_df, format="table") + store.put("mass_per_metallicity", pd.DataFrame({"simulated_mass": [0]}, index=[0.02]), format="table") + with raises(ValueError, match='does not contain an ini_parameters table'): + totest.Population(str(filename)) # /history and /oneline exist, yes ini_parameters, no mass_per_metallicity filename_no_mass = os.path.join(tmp_path, "pop_no_mass.h5") with pd.HDFStore(filename_no_mass, "w") as store: - store.put("history", history_df,format="table") + store.put("history", history_df, format="table") store.put("oneline", oneline_df, format="table") - store.put("ini_parameters", - pd.DataFrame({"Parameter": ["metallicity"], "Value": [0.02]}),format="table") - with raises(ValueError,match='does not contain a mass_per_metallicity table'): - pop = totest.Population(str(filename_no_mass)) + store.put("ini_parameters", pd.DataFrame({"Parameter": ["metallicity"], "Value": [0.02]}), format="table") + with raises(ValueError, match='does not contain a mass_per_metallicity table'): + totest.Population(str(filename_no_mass)) # metallicity specified monkeypatch.setattr( "posydon.popsyn.synthetic_population.binarypop_kwargs_from_ini", - lambda ini_file: {"dummy_param": 1}) - dummy_ini_file = os.path.join(tmp_path,"dummy.ini") + lambda ini_file: {"dummy_param": 1}, + ) + pop_with_metallicity = totest.Population( - str(filename_no_mass), metallicity=0.02, ini_file=str(dummy_ini_file) + str(filename_no_mass), metallicity=0.02, ini_file=str(tmp_path / "dummy.ini") ) assert pop_with_metallicity.mass_per_metallicity is not None assert pop_with_metallicity.solar_metallicities[0] == 0.02 assert pop_with_metallicity.metallicities[0] == 0.02 * Zsun # everything exists - filename_full = os.path.join(tmp_path, "pop_full.h5") - with pd.HDFStore(filename_full, "w") as store: - store.put("history", history_df,format="table") - store.put("oneline", oneline_df,format="table") - store.put("ini_parameters", pd.DataFrame({"param1": [1]}),format="table") - store.put("mass_per_metallicity", pd.DataFrame({"simulated_mass": [0]}, index=[0.02]), - format="table") - store.put("formation_channels", pd.DataFrame({"channel": ["dynamic"]})) - pop = totest.Population(str(filename_full)) - assert pop.number_of_systems == 1 + pop = make_test_pop(tmp_path, filename="full_pop.h5") + assert pop.number_of_systems > 0 assert isinstance(pop.history, totest.History) assert isinstance(pop.oneline, totest.Oneline) - # Metallicity specified - dummy_ini_file = os.path.join(tmp_path,"dummy.ini") pop_with_metallicity = totest.Population( - str(filename_full), metallicity=0.02, ini_file=str(dummy_ini_file) + str(pop.filename), metallicity=0.02, ini_file=str(tmp_path / "dummy.ini") ) assert pop_with_metallicity.mass_per_metallicity is not None assert pop_with_metallicity.solar_metallicities[0] == 0.02 assert pop_with_metallicity.metallicities[0] == 0.02 * Zsun - def test_export_selection(self,tmp_path,monkeypatch): - filename = self.create_minimal_population_file(tmp_path) - pop = totest.Population(str(filename)) + def test_export_selection(self, tmp_path, monkeypatch): + pop = make_test_pop(tmp_path) export_file = tmp_path / "exp.h5" # bad input - with raises(ValueError,match='does not contain .h5'): - pop.export_selection([0],'hello.txt') + with raises(ValueError, match='does not contain .h5'): + pop.export_selection([0], 'hello.txt') - with raises(ValueError,match="Both overwrite and append cannot be True!"): - pop.export_selection([0],str(export_file),append=True,overwrite=True) + with raises(ValueError, match="Both overwrite and append cannot be True!"): + pop.export_selection([0], str(export_file), append=True, overwrite=True) dummy_file = tmp_path / "exists.h5" pd.DataFrame({"a": [1]}).to_hdf(dummy_file, "dummy", format="table") - with raises(FileExistsError,match='Set overwrite or append to True'): - pop.export_selection([0], str(dummy_file),overwrite=False,append=False) + with raises(FileExistsError, match='Set overwrite or append to True'): + pop.export_selection([0], str(dummy_file), overwrite=False, append=False) - # overwrite export + # overwrite out_file = tmp_path / "out.h5" pop.export_selection([0], str(out_file), overwrite=True, history_chunksize=1) - # append export + # append pop.export_selection([0], str(out_file), append=True, history_chunksize=1) # write export - pop.export_selection([0], os.path.join(tmp_path,'new.h5'), append=False, - overwrite=False, - history_chunksize=1) + pop.export_selection( + [0], os.path.join(tmp_path, 'new.h5'), append=False, overwrite=False, history_chunksize=1 + ) - # No metallicity column + # test case: oneline missing metallicity class DummyOnelineNoMetal: - columns = ["S1_mass_i", "S2_mass_i", "state_i"] # no 'metallicity' + columns = ["S1_mass_i", "S2_mass_i", "state_i"] number_of_systems = 1 - def __getitem__(self, cols): - import pandas as pd return pd.DataFrame({ - "S1_mass_i": [1], - "S2_mass_i": [1], - "state_i": ["initial"] - }) - - def __len__(self): - return self.number_of_systems + "S1_mass_i": [1], "S2_mass_i": [1], "state_i": ["initial"] + }, index=[0]) + def __len__(self): return 1 pop.oneline = DummyOnelineNoMetal() - pop.export_selection([0], str(tmp_path/"out2.h5"), overwrite=True) + pop.export_selection([0], str(tmp_path / "out2.h5"), overwrite=True) - # Check mass_per_metallicity updated + # mass_per_metallicity updated df = pd.read_hdf(out_file, "mass_per_metallicity") assert "number_of_systems" in df.columns - def test_calculate_formation_channels(self,tmp_path): - filename = self.create_minimal_population_file(tmp_path) - - pop = totest.Population(str(filename)) - - # Should not error even with empty data - pop.calculate_formation_channels() + # multiple metallicities error + class DummyNoMet: + columns = ["foo"] + number_of_systems = 1 + def __getitem__(self, idx): return pd.DataFrame({"foo": [1]}) + def __len__(self): return 1 + + pop.oneline = DummyNoMet() + pop.metallicities = [0.02, 0.01] + + with raises(ValueError, match="multiple metallicities"): + pop.export_selection([0], str(tmp_path / "multi_met.h5"), overwrite=True) + + def test_calculate_formation_channels(self, tmp_path): + pop = make_test_pop(tmp_path) + + class DummyOneline: + columns = ["interp_class_HMS_HMS", "mt_history_HMS_HMS"] + number_of_systems = 4 + + def select(self, start=None, stop=None, columns=None): + data = [ + {"interp_class_HMS_HMS": "initial_MT", "mt_history_HMS_HMS": "Stable contact phase"}, + {"interp_class_HMS_HMS": "stable_MT", "mt_history_HMS_HMS": None}, + {"interp_class_HMS_HMS": "stable_reverse_MT", "mt_history_HMS_HMS": None}, + {"interp_class_HMS_HMS": "no_MT", "mt_history_HMS_HMS": None}, + ] + selected = data[start:stop] + while len(selected) < (stop - start): + selected.append(data[-1]) + df = pd.DataFrame(selected) + if columns is not None: + df = df[columns] + return df + + pop.oneline = DummyOneline() + pop.chunksize = 2 + + pop.calculate_formation_channels(mt_history=True) + assert hasattr(pop, "formation_channels") + assert all(col in pop.formation_channels.columns for col in ["channel", "channel_debug"]) + assert any("contact" in str(c) for c in pop.formation_channels["channel"]) + + pop.calculate_formation_channels(mt_history=False) + assert hasattr(pop, "formation_channels") + assert "channel" in pop.formation_channels.columns + + pop.calculate_formation_channels(mt_history=True) + with pd.HDFStore(pop.filename, "r") as store: + assert "/formation_channels" in store.keys() - # Result should be a dataframe (even if empty) - assert hasattr(pop.formation_channels, "columns") def test_create_transient_population(self): # missing argument # bad input @@ -730,32 +693,22 @@ def test_create_transient_population(self): class TestTransientPopulation: - @fixture - def fix(self): -# return - pass - - def test_population(self): - # missing argument - # bad input - # examples - pass - def test_columns(self): + def test_select(self): # missing argument # bad input # examples pass - def test_select(self): + def test_calculate_model_weights(self): # missing argument # bad input # examples pass - def test_get_efficiency_over_metallicity(self): + def test_calculate_cosmic_weights(self): # missing argument # bad input # examples pass - def test_calculate_cosmic_weights(self): + def test_efficiency(self): # missing argument # bad input # examples @@ -763,26 +716,6 @@ def test_calculate_cosmic_weights(self): class TestRates: - @fixture - def fix(self): - # return - pass - - def test_weights(self): - # missing argument - # bad input - # examples - pass - def test_z_birth(self): - # missing argument - # bad input - # examples - pass - def test_z_events(self): - # missing argument - # bad input - # examples - pass def test_select_rate_slice(self): # missing argument # bad input @@ -803,33 +736,6 @@ def test_observable_population(self): # bad input # examples pass - def test_observable_population_names(self): - # missing argument - # bad input - # examples - pass - def test_intrinsic_rate_density(self): - # missing argument - # bad input - # examples - pass def test_edges_metallicity_bins(self): - # missing argument - # bad input - # examples - pass - def test_centers_metallicity_bins(self): - # missing argument - # bad input - # examples - pass - def test_edges_redshift_bins(self): - # missing argument - # bad input - # examples - pass - def test_centers_redshift_bins(self): - # missing argument - # bad input - # examples + # TODO: needs Rates object setup pass From 30bcbcf2b986f575e4a42b9c2d10085195642785 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:34:40 +0000 Subject: [PATCH 04/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../unit_tests/_helper_functions_for_tests/population.py | 4 ++-- posydon/unit_tests/popsyn/test_synthetic_population.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/posydon/unit_tests/_helper_functions_for_tests/population.py b/posydon/unit_tests/_helper_functions_for_tests/population.py index 413341ed42..08dfa5d0b3 100644 --- a/posydon/unit_tests/_helper_functions_for_tests/population.py +++ b/posydon/unit_tests/_helper_functions_for_tests/population.py @@ -9,13 +9,13 @@ ] import os + import h5py import numpy as np import pandas as pd from posydon.popsyn.synthetic_population import Population - # helper functions def make_ini(tmp_path,content=None): @@ -76,7 +76,7 @@ def make_test_pop( """ # history and oneline tables - + if history_rows is None: history_rows = [{"binary_index": 0, "event": "start", "time": 0.0}, {"binary_index": 0, "event": "end", "time": 1.0}, diff --git a/posydon/unit_tests/popsyn/test_synthetic_population.py b/posydon/unit_tests/popsyn/test_synthetic_population.py index 230d8beba6..a254864b33 100644 --- a/posydon/unit_tests/popsyn/test_synthetic_population.py +++ b/posydon/unit_tests/popsyn/test_synthetic_population.py @@ -24,7 +24,11 @@ import os import shutil -from posydon.unit_tests._helper_functions_for_tests.population import make_test_pop, make_ini +from posydon.unit_tests._helper_functions_for_tests.population import ( + make_ini, + make_test_pop, +) + # define test classes collecting several test functions class TestElements: From cc63eaf22b7d652dbc80d79095e581a180631282 Mon Sep 17 00:00:00 2001 From: Elizabeth Teng Date: Thu, 26 Mar 2026 09:03:24 -0500 Subject: [PATCH 05/10] fix failing tests --- .../popsyn/test_synthetic_population.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_synthetic_population.py b/posydon/unit_tests/popsyn/test_synthetic_population.py index a254864b33..7a6f05e883 100644 --- a/posydon/unit_tests/popsyn/test_synthetic_population.py +++ b/posydon/unit_tests/popsyn/test_synthetic_population.py @@ -24,11 +24,7 @@ import os import shutil -from posydon.unit_tests._helper_functions_for_tests.population import ( - make_ini, - make_test_pop, -) - +from posydon.unit_tests._helper_functions_for_tests.population import make_test_pop, make_ini # define test classes collecting several test functions class TestElements: @@ -87,12 +83,12 @@ def combine_saved_files(self, *args): self.combined = True def dummy_kwargs(path): return { - "metallicity": 0.1, + "metallicities": 0.1, "temp_directory": "tmp_dir", "verbose": False} def dummy_kwargs_list(path): return { - "metallicity": [0.1,1.], + "metallicities": [0.1,1.], "temp_directory": "tmp_dir", "verbose": False} def dummy_merge(pop,overwrite): @@ -102,6 +98,7 @@ def dummy_merge(pop,overwrite): monkeypatch.setattr(totest, "binarypop_kwargs_from_ini", dummy_kwargs) monkeypatch.setattr(totest, "BinaryPopulation", DummyPop) monkeypatch.setattr(totest, "convert_metallicity_to_string", lambda x: "0.1") + monkeypatch.setattr(totest.SimulationProperties, "from_ini", staticmethod(lambda path: None)) run = totest.PopulationRunner(make_ini(tmp_path)) # overwrite=False, directory doesn't exist monkeypatch.setattr(os.path, "exists", lambda path: False) @@ -138,12 +135,13 @@ def combine_saved_files(self, out_path, files): def dummy_kwargs(path): return { - "metallicity": 0.1, + "metallicities": 0.1, "temp_directory": "tmp_dir", "verbose": False} monkeypatch.setattr(totest, "binarypop_kwargs_from_ini", dummy_kwargs) monkeypatch.setattr(totest, "BinaryPopulation", DummyPop) + monkeypatch.setattr(totest.SimulationProperties, "from_ini", staticmethod(lambda path: None)) monkeypatch.setattr(totest, "convert_metallicity_to_string", lambda x: str(os.path.join(tmp_path, "0.1"))) @@ -660,8 +658,8 @@ class DummyOneline: def select(self, start=None, stop=None, columns=None): data = [ - {"interp_class_HMS_HMS": "initial_MT", "mt_history_HMS_HMS": "Stable contact phase"}, - {"interp_class_HMS_HMS": "stable_MT", "mt_history_HMS_HMS": None}, + {"interp_class_HMS_HMS": "stable_MT", "mt_history_HMS_HMS": "Stable contact phase"}, + {"interp_class_HMS_HMS": "no_MT", "mt_history_HMS_HMS": None}, {"interp_class_HMS_HMS": "stable_reverse_MT", "mt_history_HMS_HMS": None}, {"interp_class_HMS_HMS": "no_MT", "mt_history_HMS_HMS": None}, ] @@ -742,4 +740,4 @@ def test_observable_population(self): pass def test_edges_metallicity_bins(self): # TODO: needs Rates object setup - pass + pass \ No newline at end of file From af54106a44f824f76735eb2659b1523562e56ec0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:03:57 +0000 Subject: [PATCH 06/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- posydon/unit_tests/popsyn/test_synthetic_population.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/posydon/unit_tests/popsyn/test_synthetic_population.py b/posydon/unit_tests/popsyn/test_synthetic_population.py index 7a6f05e883..386fdddda4 100644 --- a/posydon/unit_tests/popsyn/test_synthetic_population.py +++ b/posydon/unit_tests/popsyn/test_synthetic_population.py @@ -24,7 +24,11 @@ import os import shutil -from posydon.unit_tests._helper_functions_for_tests.population import make_test_pop, make_ini +from posydon.unit_tests._helper_functions_for_tests.population import ( + make_ini, + make_test_pop, +) + # define test classes collecting several test functions class TestElements: @@ -740,4 +744,4 @@ def test_observable_population(self): pass def test_edges_metallicity_bins(self): # TODO: needs Rates object setup - pass \ No newline at end of file + pass From a97e18b5c7376cce7f84aad50ea216d81adbdef1 Mon Sep 17 00:00:00 2001 From: Elizabeth Teng <41969755+elizabethteng@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:37:17 -0500 Subject: [PATCH 07/10] Update setup.cfg to exclude analysis.py and GRB.py from testing --- setup.cfg | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/setup.cfg b/setup.cfg index 3a53872f29..871fe8718f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,3 +20,12 @@ source = posydon omit = posydon/tests/* posydon/_version.py + posydon/popsyn/analysis.py + posydon/popsyn/GRB.py + +[coverage:report] +omit = + posydon/tests/* + posydon/_version.py + posydon/popsyn/analysis.py + posydon/popsyn/GRB.py From e85ff24b9d24f20f257ed6bb20a0ae54d7d9e0b6 Mon Sep 17 00:00:00 2001 From: Elizabeth Teng Date: Thu, 26 Mar 2026 09:39:08 -0500 Subject: [PATCH 08/10] removed tests for inactive code --- posydon/unit_tests/popsyn/test_GRB.py | 50 ---------------------- posydon/unit_tests/popsyn/test_analysis.py | 18 -------- 2 files changed, 68 deletions(-) delete mode 100644 posydon/unit_tests/popsyn/test_GRB.py delete mode 100644 posydon/unit_tests/popsyn/test_analysis.py diff --git a/posydon/unit_tests/popsyn/test_GRB.py b/posydon/unit_tests/popsyn/test_GRB.py deleted file mode 100644 index 541a7b8d50..0000000000 --- a/posydon/unit_tests/popsyn/test_GRB.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Unit tests of posydon/popsyn/GRB.py -""" - -__authors__ = [ - "Elizabeth Teng " -] - -# import the module which will be tested -import posydon.popsyn.GRB as totest - -# aliases -np = totest.np - -from inspect import isclass, isroutine - -# import other needed code for the tests, which is not already imported in the -# module you like to test -from pytest import approx, fixture, raises, warns - -from posydon.utils.constants import Msun, clight -from posydon.utils.posydonwarning import Pwarn - - -# define test classes collecting several test functions -class TestElements: - # check for objects, which should be an element of the tested module - def test_dir(self): - elements = ['get_GRB_properties','__authors__',\ - '__builtins__', '__cached__', '__doc__', '__file__',\ - '__loader__', '__name__', '__package__', '__spec__', - 'Pwarn','Msun','clight','np'] - totest_elements = set(dir(totest)) - missing_in_test = set(elements) - totest_elements - assert len(missing_in_test) == 0, "There are missing objects in "\ - +f"{totest.__name__}: "\ - +f"{missing_in_test}. Please "\ - +"check, whether they have been "\ - +"removed on purpose and update "\ - +"this unit test." - new_in_test = totest_elements - set(elements) - assert len(new_in_test) == 0, "There are new objects in "\ - +f"{totest.__name__}: {new_in_test}. "\ - +"Please check, whether they have been "\ - +"added on purpose and update this "\ - +"unit test." - -class TestFunctions: - - def test_get_GRB_properties(): - pass diff --git a/posydon/unit_tests/popsyn/test_analysis.py b/posydon/unit_tests/popsyn/test_analysis.py deleted file mode 100644 index f9a58ad59b..0000000000 --- a/posydon/unit_tests/popsyn/test_analysis.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Unit tests of posydon/popsyn/analysis.py -""" - -__authors__ = [ - "Elizabeth Teng " -] - -# import the module which will be tested -import posydon.popsyn.analysis as totest - -# aliases -pd = totest.pd - -from inspect import isclass, isroutine - -# import other needed code for the tests, which is not already imported in the -# module you like to test -from pytest import approx, fixture, raises, warns From 4f604c1871ef4a200614c362bafb898a5b78ce2d Mon Sep 17 00:00:00 2001 From: Elizabeth Teng Date: Thu, 26 Mar 2026 09:41:42 -0500 Subject: [PATCH 09/10] add note about being omitted from testing --- posydon/popsyn/GRB.py | 3 +++ posydon/popsyn/analysis.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/posydon/popsyn/GRB.py b/posydon/popsyn/GRB.py index 82e5809771..a271269a54 100644 --- a/posydon/popsyn/GRB.py +++ b/posydon/popsyn/GRB.py @@ -1,3 +1,6 @@ +# This code is currently not being actively used, and is not covered by unit tests. +# To test this file, remove it from the 'omit' blocks in POSYDON/setup.cfg. + __author__ = ['Simone Bavera '] import numpy as np diff --git a/posydon/popsyn/analysis.py b/posydon/popsyn/analysis.py index 2f7717b593..49b91b3a6c 100644 --- a/posydon/popsyn/analysis.py +++ b/posydon/popsyn/analysis.py @@ -1,5 +1,7 @@ """Module for analyzing binary population simulation results.""" +# This code is currently not being actively used, and is not covered by unit tests. +# To test this file, remove it from the 'omit' blocks in POSYDON/setup.cfg. __authors__ = [ "Konstantinos Kovlakas ", From 1c50d14432a6da1951858a7adf57fd49500786d8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:46:56 +0000 Subject: [PATCH 10/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- posydon/popsyn/GRB.py | 4 ++-- posydon/popsyn/analysis.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/posydon/popsyn/GRB.py b/posydon/popsyn/GRB.py index a271269a54..dd0b3ab4e2 100644 --- a/posydon/popsyn/GRB.py +++ b/posydon/popsyn/GRB.py @@ -1,5 +1,5 @@ -# This code is currently not being actively used, and is not covered by unit tests. -# To test this file, remove it from the 'omit' blocks in POSYDON/setup.cfg. +# This code is currently not being actively used, and is not covered by unit tests. +# To test this file, remove it from the 'omit' blocks in POSYDON/setup.cfg. __author__ = ['Simone Bavera '] diff --git a/posydon/popsyn/analysis.py b/posydon/popsyn/analysis.py index 49b91b3a6c..e2f373c6d4 100644 --- a/posydon/popsyn/analysis.py +++ b/posydon/popsyn/analysis.py @@ -1,7 +1,7 @@ """Module for analyzing binary population simulation results.""" -# This code is currently not being actively used, and is not covered by unit tests. -# To test this file, remove it from the 'omit' blocks in POSYDON/setup.cfg. +# This code is currently not being actively used, and is not covered by unit tests. +# To test this file, remove it from the 'omit' blocks in POSYDON/setup.cfg. __authors__ = [ "Konstantinos Kovlakas ",