cosmic-popsynth 3.6.2__cp313-cp313-macosx_14_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cosmic/filter.py ADDED
@@ -0,0 +1,214 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright (C) Duncan Macleod (2017-2020)
3
+ #
4
+ # This file is part of GWpy.
5
+ #
6
+ # GWpy is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # GWpy is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with GWpy. If not, see <http://www.gnu.org/licenses/>.
18
+
19
+ """Utilies for filtering a `Table` using column slice definitions
20
+ """
21
+
22
+ import operator
23
+ import token
24
+ import re
25
+ from tokenize import generate_tokens
26
+ from collections import OrderedDict
27
+
28
+ from six import string_types
29
+ from six.moves import StringIO
30
+
31
+ __author__ = "Duncan Macleod <duncan.macleod@ligo.org>"
32
+
33
+ OPERATORS = OrderedDict(
34
+ [
35
+ ("<", operator.lt),
36
+ ("<=", operator.le),
37
+ ("=", operator.eq),
38
+ ("==", operator.eq),
39
+ (">=", operator.ge),
40
+ (">", operator.gt),
41
+ ("!=", operator.ne),
42
+ ]
43
+ )
44
+
45
+ OPERATORS_INV = OrderedDict(
46
+ [
47
+ ("<=", operator.ge),
48
+ ("<", operator.gt),
49
+ (">", operator.lt),
50
+ (">=", operator.le),
51
+ ]
52
+ )
53
+
54
+ QUOTE_REGEX = re.compile(r"^[\s\"\']+|[\s\"\']+$")
55
+ DELIM_REGEX = re.compile(r"(and|&+)", re.I)
56
+
57
+
58
+ # -- filter parsing -----------------------------------------------------------
59
+
60
+
61
+ def _float_or_str(value):
62
+ """Internal method to attempt `float(value)` handling a `ValueError`"""
63
+ # remove any surrounding quotes
64
+ value = QUOTE_REGEX.sub("", value)
65
+ try: # attempt `float()` conversion
66
+ return float(value)
67
+ except ValueError: # just return the input
68
+ return value
69
+
70
+
71
+ def parse_operator(mathstr):
72
+ """Parse a `str` as a function from the `operator` module
73
+
74
+ Parameters
75
+ ----------
76
+ mathstr : `str`
77
+ a `str` representing a mathematical operator
78
+
79
+ Returns
80
+ -------
81
+ op : `func`
82
+ a callable `operator` module function
83
+
84
+ Raises
85
+ ------
86
+ KeyError
87
+ if input `str` cannot be mapped to an `operator` function
88
+
89
+ Examples
90
+ --------
91
+ >>> parse_operator('>')
92
+ <built-in function gt>
93
+ """
94
+ try:
95
+ return OPERATORS[mathstr]
96
+ except KeyError as exc:
97
+ exc.args = ("Unrecognised operator %r" % mathstr,)
98
+ raise
99
+
100
+
101
+ def parse_column_filter(definition):
102
+ """Parse a `str` of the form 'column>50'
103
+
104
+ Parameters
105
+ ----------
106
+ definition : `str`
107
+ a column filter definition of the form ``<name><operator><threshold>``
108
+ or ``<threshold><operator><name><operator><threshold>``, e.g.
109
+ ``frequency >= 10``, or ``50 < snr < 100``
110
+
111
+ Returns
112
+ -------
113
+ filters : `list` of `tuple`
114
+ a `list` of filter 3-`tuple`s, where each `tuple` contains the
115
+ following elements:
116
+
117
+ - ``column`` (`str`) - the name of the column on which to operate
118
+ - ``operator`` (`callable`) - the operator to call when evaluating
119
+ the filter
120
+ - ``operand`` (`anything`) - the argument to the operator function
121
+
122
+ Raises
123
+ ------
124
+ ValueError
125
+ if the filter definition cannot be parsed
126
+
127
+ KeyError
128
+ if any parsed operator string cannnot be mapped to a function from
129
+ the `operator` module
130
+
131
+ Notes
132
+ -----
133
+ Strings that contain non-alphanumeric characters (e.g. hyphen `-`) should
134
+ be quoted inside the filter definition, to prevent such characters
135
+ being interpreted as operators, e.g. ``channel = X1:TEST`` should always
136
+ be passed as ``channel = "X1:TEST"``.
137
+
138
+ Examples
139
+ --------
140
+ >>> parse_column_filter("frequency>10")
141
+ [('frequency', <function operator.gt>, 10.)]
142
+ >>> parse_column_filter("50 < snr < 100")
143
+ [('snr', <function operator.gt>, 50.), ('snr', <function operator.lt>, 100.)]
144
+ >>> parse_column_filter("channel = "H1:TEST")
145
+ [('channel', <function operator.eq>, 'H1:TEST')]
146
+ """ # noqa
147
+ # parse definition into parts (skipping null tokens)
148
+ parts = list(generate_tokens(StringIO(definition.strip()).readline))
149
+ while parts[-1][0] in (token.ENDMARKER, token.NEWLINE):
150
+ parts = parts[:-1]
151
+
152
+ # parse simple definition: e.g: snr > 5
153
+ if len(parts) == 3:
154
+ a, b, c = parts # pylint: disable=invalid-name
155
+ if a[0] in [token.NAME, token.STRING]: # string comparison
156
+ name = QUOTE_REGEX.sub("", a[1])
157
+ oprtr = OPERATORS[b[1]]
158
+ value = _float_or_str(c[1])
159
+ return [(name, oprtr, value)]
160
+ elif b[0] in [token.NAME, token.STRING]:
161
+ name = QUOTE_REGEX.sub("", b[1])
162
+ oprtr = OPERATORS_INV[b[1]]
163
+ value = _float_or_str(a[1])
164
+ return [(name, oprtr, value)]
165
+
166
+ # parse between definition: e.g: 5 < snr < 10
167
+ elif len(parts) == 5:
168
+ a, b, c, d, e = list(zip(*parts))[1] # pylint: disable=invalid-name
169
+ name = QUOTE_REGEX.sub("", c)
170
+ return [
171
+ (name, OPERATORS_INV[b], _float_or_str(a)),
172
+ (name, OPERATORS[d], _float_or_str(e)),
173
+ ]
174
+
175
+ raise ValueError("Cannot parse filter definition from %r" % definition)
176
+
177
+
178
+ def parse_column_filters(*definitions):
179
+ """Parse multiple compound column filter definitions
180
+
181
+ Examples
182
+ --------
183
+ >>> parse_column_filters('snr > 10', 'frequency < 1000')
184
+ [('snr', <function operator.gt>, 10.), ('frequency', <function operator.lt>, 1000.)]
185
+ >>> parse_column_filters('snr > 10 && frequency < 1000')
186
+ [('snr', <function operator.gt>, 10.), ('frequency', <function operator.lt>, 1000.)]
187
+ """ # noqa: E501
188
+ fltrs = []
189
+ for def_ in _flatten(definitions):
190
+ if is_filter_tuple(def_):
191
+ fltrs.append(def_)
192
+ else:
193
+ for splitdef in DELIM_REGEX.split(def_)[::2]:
194
+ fltrs.extend(parse_column_filter(splitdef))
195
+ return fltrs
196
+
197
+
198
+ def _flatten(container):
199
+ """Flatten arbitrary nested list of filters into a 1-D list"""
200
+ if isinstance(container, string_types):
201
+ container = [container]
202
+ for elem in container:
203
+ if isinstance(elem, string_types) or is_filter_tuple(elem):
204
+ yield elem
205
+ else:
206
+ for elem2 in _flatten(elem):
207
+ yield elem2
208
+
209
+
210
+ def is_filter_tuple(tup):
211
+ """Return whether a `tuple` matches the format for a column filter"""
212
+ return isinstance(tup, (tuple, list)) and (
213
+ len(tup) == 3 and isinstance(tup[0], string_types) and callable(tup[1])
214
+ )
@@ -0,0 +1,15 @@
1
+ import subprocess
2
+
3
+ def get_commit_hash():
4
+ # Run git command to get the latest commit hash
5
+ result = subprocess.run(['git', 'rev-parse', 'HEAD'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
6
+ commit_hash = result.stdout.decode('utf-8').strip()
7
+ return commit_hash
8
+
9
+ def write_commit_hash_to_file(commit_hash):
10
+ with open('./src/cosmic/_commit_hash.py', 'w') as f:
11
+ f.write(f'COMMIT_HASH = "{commit_hash}"\n')
12
+
13
+ if __name__ == "__main__":
14
+ commit_hash = get_commit_hash()
15
+ write_commit_hash_to_file(commit_hash)
cosmic/output.py ADDED
@@ -0,0 +1,466 @@
1
+ import json
2
+ import pandas as pd
3
+ import h5py as h5
4
+ from cosmic.evolve import Evolve
5
+ from cosmic._version import __version__
6
+ from cosmic.plotting import plot_binary_evol
7
+ import matplotlib.pyplot as plt
8
+ from matplotlib.colors import ListedColormap, BoundaryNorm
9
+ import numpy as np
10
+ import warnings
11
+
12
+
13
+ __all__ = ['COSMICOutput', 'COSMICPopOutput', 'save_initC', 'load_initC']
14
+
15
+
16
+ kstar_translator = [
17
+ {'long': 'Main Sequence (Low mass)', 'short': 'MS < 0.7', 'colour': (0.996078, 0.843476, 0.469158, 1.0)},
18
+ {'long': 'Main Sequence', 'short': 'MS', 'colour': (0.996078, 0.843476, 0.469158, 1.0)},
19
+ {'long': 'Hertzsprung Gap', 'short': 'HG', 'colour': (0.939608, 0.471373, 0.094902, 1.0)},
20
+ {'long': 'First Giant Branch', 'short': 'FGB', 'colour': (0.716186, 0.833203, 0.916155, 1.0)},
21
+ {'long': 'Core Helium Burning', 'short': 'CHeB', 'colour': (0.29098, 0.59451, 0.78902, 1.0)},
22
+ {'long': 'Early AGB', 'short': 'EAGB', 'colour': (0.294902, 0.690196, 0.384314, 1.0)},
23
+ {'long': 'Thermally Pulsing AGB', 'short': 'TPAGB',
24
+ 'colour': (0.723122, 0.889612, 0.697178, 1.0)},
25
+ {'long': 'Helium Main Sequence', 'short': 'HeMS', 'colour': (0.254627, 0.013882, 0.615419, 1.0)},
26
+ {'long': 'Helium Hertsprung Gap', 'short': 'HeHG', 'colour': (0.562738, 0.051545, 0.641509, 1.0)},
27
+ {'long': 'Helium Giant Branch', 'short': 'HeGB', 'colour': (0.798216, 0.280197, 0.469538, 1.0)},
28
+ {'long': 'Helium White Dwarf', 'short': 'HeWD', 'colour': (0.368166, 0.232828, 0.148275, 1.0)},
29
+ {'long': 'Carbon/Oxygen White Dwarf', 'short': 'COWD', 'colour': (0.620069, 0.392132, 0.249725, 1.0)},
30
+ {'long': 'Oxygen/Neon White Dwarf', 'short': 'ONeWD', 'colour': (0.867128, 0.548372, 0.349225, 1.0)},
31
+ {'long': 'Neutron Star', 'short': 'NS', 'colour': (0.501961, 0.501961, 0.501961, 1.0)},
32
+ {'long': 'Black Hole', 'short': 'BH', 'colour': (0.0, 0.0, 0.0, 1.0)},
33
+ {'long': 'Massless Remnant', 'short': 'MR', 'colour': "white"},
34
+ {'long': 'Chemically Homogeneous', 'short': 'CHE', 'colour': (0.647059, 0.164706, 0.164706, 1.0)}
35
+ ]
36
+
37
+
38
+ class COSMICOutput:
39
+ def __init__(self, bpp=None, bcm=None, initC=None, kick_info=None, file=None, label=None,
40
+ file_key_suffix=''):
41
+ """Container for COSMIC output data components.
42
+
43
+ Can be initialized either from data components directly or by loading from an HDF5 file.
44
+
45
+ Parameters
46
+ ----------
47
+ bpp : `pandas.DataFrame`, optional
48
+ Important evolution timestep table, by default None
49
+ bcm : `pandas.DataFrame`, optional
50
+ User-defined timestep table, by default None
51
+ initC : `pandas.DataFrame`, optional
52
+ Initial conditions table, by default None
53
+ kick_info : `pandas.DataFrame`, optional
54
+ Natal kick information table, by default None
55
+ file : `str`, optional
56
+ Filename/path to HDF5 file to load data from, by default None
57
+ label : `str`, optional
58
+ Optional label for the output instance, by default None
59
+ file_key_suffix : `str`, optional
60
+ Suffix to append to dataset keys when loading from file, by default ''. E.g. if set to '_singles',
61
+ datasets 'bpp_singles', 'bcm_singles', etc. will be loaded as bpp, bcm, etc.
62
+
63
+ Raises
64
+ ------
65
+ ValueError
66
+ If neither file nor all data components are provided.
67
+ """
68
+ # require that either file is given or all data components are given
69
+ if file is None and (bpp is None or bcm is None or initC is None or kick_info is None):
70
+ raise ValueError("Either file or all data components (bpp, bcm, initC, kick_info) must be provided.")
71
+ if file is not None:
72
+ self.bpp = pd.read_hdf(file, key=f'bpp{file_key_suffix}')
73
+ self.bcm = pd.read_hdf(file, key=f'bcm{file_key_suffix}')
74
+ self.initC = load_initC(file, key=f'initC{file_key_suffix}',
75
+ settings_key=f'initC_{file_key_suffix}_settings')
76
+ self.kick_info = pd.read_hdf(file, key=f'kick_info{file_key_suffix}')
77
+ with h5.File(file, 'r') as f:
78
+ file_version = f.attrs.get('COSMIC_version', 'unknown')
79
+ label = f.attrs.get('label', '')
80
+ self.label = label if label != '' else None
81
+ if file_version != __version__:
82
+ warnings.warn(f"You have loaded COSMICOutput from a file that was run using COSMIC version {file_version}, "
83
+ f"but the current version is {__version__}. "
84
+ "There may be compatibility issues, or differences in output when rerunning, be sure to check the changelog.", UserWarning)
85
+ else:
86
+ self.bpp = bpp
87
+ self.bcm = bcm
88
+ self.initC = initC
89
+ self.kick_info = kick_info
90
+ self.label = label if label is not None else None
91
+
92
+ def __len__(self):
93
+ return len(self.initC)
94
+
95
+ def __repr__(self):
96
+ return f'<COSMICOutput{" - " + self.label if self.label is not None else ""}: {len(self)} {"binaries" if len(self) != 1 else "binary"}>'
97
+
98
+ def __getitem__(self, key):
99
+ """Subselect binaries by bin_num across all data components.
100
+ Keys can be integers or lists/arrays of integers or slices.
101
+ If the key is an array of bools, mask initC to get the corresponding bin_nums."""
102
+ # convert key to list of bin_nums, regardless of input type
103
+ if isinstance(key, int):
104
+ key = [key]
105
+ elif isinstance(key, slice):
106
+ key = self.initC['bin_num'].iloc[key].tolist()
107
+ elif isinstance(key, (pd.Series, list, np.ndarray)) and len(key) == len(self.initC) and isinstance(key[0], (bool, np.bool_)):
108
+ if not key.any():
109
+ raise IndexError("Boolean mask resulted in zero selected binaries.")
110
+ key = self.initC['bin_num'][key].tolist()
111
+ # otherwise, reject invalid types
112
+ elif not isinstance(key, (list, np.ndarray, pd.Series)):
113
+ raise TypeError("Key must be an int, slice, list/array of ints, or boolean mask.")
114
+
115
+ bpp_subset = self.bpp[self.bpp['bin_num'].isin(key)]
116
+ bcm_subset = self.bcm[self.bcm['bin_num'].isin(key)]
117
+ initC_subset = self.initC[self.initC['bin_num'].isin(key)]
118
+ kick_info_subset = self.kick_info[self.kick_info['bin_num'].isin(key)]
119
+ return COSMICOutput(bpp=bpp_subset, bcm=bcm_subset, initC=initC_subset, kick_info=kick_info_subset, label=self.label)
120
+
121
+ @property
122
+ def final_bpp(self):
123
+ """Get the final timestep for each binary from the bpp table.
124
+
125
+ Returns
126
+ -------
127
+ final_bpp : `pandas.DataFrame`
128
+ DataFrame containing only the final timestep for each binary.
129
+ """
130
+ return self.bpp.drop_duplicates(subset='bin_num', keep='last')
131
+
132
+ def save(self, output_file):
133
+ """Save all data components to an HDF5 file
134
+
135
+ Parameters
136
+ ----------
137
+ output_file : `str`
138
+ Filename/path to the HDF5 file
139
+ """
140
+ self.bpp.to_hdf(output_file, key='bpp')
141
+ self.bcm.to_hdf(output_file, key='bcm')
142
+ save_initC(output_file, self.initC, key='initC', settings_key='initC_settings')
143
+ self.kick_info.to_hdf(output_file, key='kick_info')
144
+ with h5.File(output_file, 'a') as f:
145
+ f.attrs['COSMIC_version'] = __version__
146
+ f.attrs['label'] = self.label if self.label is not None else ''
147
+
148
+ def rerun_with_settings(self, new_settings, reset_kicks=False, inplace=False):
149
+ """Rerun the simulation with new settings.
150
+
151
+ Parameters
152
+ ----------
153
+ new_settings : `dict`
154
+ Dictionary of new settings to apply. Any setting not included will retain its original value.
155
+ reset_kicks : `bool`, optional
156
+ If True, reset natal kicks to be randomly sampled again.
157
+ If False, retain original kicks. By default False.
158
+ (You may want to reset the kicks if changing settings that affect remnant masses or
159
+ kick distribution.)
160
+ inplace : `bool`, optional
161
+ If True, update the current instance. If False, return a new instance. By default False.
162
+
163
+ Returns
164
+ -------
165
+ new_output : `COSMICOutput`
166
+ New COSMICOutput instance with updated simulation results (only if inplace is False).
167
+ """
168
+ # merge new settings with existing initC
169
+ updated_initC = self.initC.copy()
170
+ for key, value in new_settings.items():
171
+ if key in updated_initC.columns:
172
+ updated_initC[key] = value
173
+ else:
174
+ raise KeyError(f"Setting '{key}' not found in initC columns.")
175
+
176
+ # reset kicks if requested
177
+ if reset_kicks:
178
+ kick_cols = ["natal_kick_1", "natal_kick_2", "phi_1", "phi_2", "theta_1", "theta_2",
179
+ "mean_anomaly_1", "mean_anomaly_2"]
180
+ for col in kick_cols:
181
+ updated_initC[col] = -100.0
182
+ elif 'kickflag' in new_settings or 'remnantflag' in new_settings:
183
+ warnings.warn(
184
+ "You have changed 'kickflag' or 'remnantflag' without resetting kicks. "
185
+ "This may lead to inconsistent results if the kick distribution or remnant masses have changed. "
186
+ "Consider setting reset_kicks=True.", UserWarning
187
+ )
188
+
189
+ # re-run the simulation
190
+ new_bpp, new_bcm, new_initC, new_kick_info = Evolve.evolve(initialbinarytable=updated_initC)
191
+
192
+ if inplace:
193
+ self.bpp = new_bpp
194
+ self.bcm = new_bcm
195
+ self.initC = new_initC
196
+ self.kick_info = new_kick_info
197
+ else:
198
+ return COSMICOutput(bpp=new_bpp, bcm=new_bcm, initC=new_initC, kick_info=new_kick_info)
199
+
200
+
201
+ def plot_detailed_evolution(self, bin_num, show=True, **kwargs):
202
+ """Plot detailed evolution for a specific binary.
203
+
204
+ Parameters
205
+ ----------
206
+ bin_num : `int`
207
+ Index of the binary to plot.
208
+ **kwargs :
209
+ Additional keyword arguments passed to the plotting function (plotting.plot_binary_evol).
210
+ """
211
+ # check the bin_num is in the bcm
212
+ if bin_num not in self.bcm['bin_num'].values:
213
+ raise ValueError(f"bin_num {bin_num} not found in bcm table.")
214
+
215
+ # warn if bcm has only two entries for this binary
216
+ bcm_subset = self.bcm[self.bcm['bin_num'] == bin_num]
217
+ if len(bcm_subset) <= 2:
218
+ warnings.warn(
219
+ f"bcm table for bin_num {bin_num} has only {len(bcm_subset)} entries. Detailed evolution "
220
+ "plot may be uninformative. You should set dtp, or timestep_conditions, to increase the "
221
+ "number of timesteps in the bcm table.", UserWarning
222
+ )
223
+
224
+ if "ktype_kwargs" not in kwargs:
225
+ kwargs["ktype_kwargs"] = {'k_type_colors': [kstar_translator[k]["colour"] for k in range(len(kstar_translator))]}
226
+ fig = plot_binary_evol(self.bcm.loc[bin_num], **kwargs)
227
+ if show:
228
+ plt.show()
229
+ return fig
230
+
231
+
232
+ def plot_distribution(self, x_col, y_col=None, c_col=None, when='final',
233
+ fig=None, ax=None, show=True,
234
+ xlabel='auto', ylabel='auto', clabel='auto', **kwargs):
235
+ """Plot distribution of binaries in specified columns.
236
+
237
+ Plots can be histograms (if only x_col is given) or scatter plots (if both x_col and y_col are given).
238
+ Optionally, colour coding can be applied using c_col.
239
+
240
+ Parameters
241
+ ----------
242
+ x_col : `str`
243
+ Column name for x-axis.
244
+ y_col : `str`, optional
245
+ Column name for y-axis. If None, a histogram will be plotted. By default None.
246
+ c_col : `str`, optional
247
+ Column name for colour coding. By default None.
248
+ when : `str`, optional
249
+ When to take the values from: 'initial' or 'final'. By default 'final'.
250
+ fig : `matplotlib.figure.Figure`, optional
251
+ Figure to plot on. If None, a new figure is created. By default None.
252
+ ax : `matplotlib.axes.Axes`, optional
253
+ Axes to plot on. If None, new axes are created. By default None.
254
+ show : `bool`, optional
255
+ If True, display the plot immediately. By default True.
256
+ xlabel : `str`, optional
257
+ Label for x-axis. If 'auto', uses the column name. By default 'auto'.
258
+ ylabel : `str`, optional
259
+ Label for y-axis. If 'auto', uses the column name or 'Count' for histogram. By default 'auto'.
260
+ clabel : `str`, optional
261
+ Label for colorbar. If 'auto', uses the column name. By default 'auto
262
+ **kwargs :
263
+ Additional keyword arguments passed to the plotting function.
264
+
265
+ Returns
266
+ -------
267
+ fig : `matplotlib.figure.Figure`
268
+ The figure containing the plot.
269
+ ax : `matplotlib.axes.Axes`
270
+ The axes containing the plot.
271
+ """
272
+ if fig is None or ax is None:
273
+ fig, ax = plt.subplots()
274
+
275
+ if when == 'initial':
276
+ data = self.initC
277
+ elif when == 'final':
278
+ data = self.bpp.drop_duplicates(subset='bin_num', keep='last')
279
+ else:
280
+ raise ValueError("Parameter 'when' must be either 'initial' or 'final'.")
281
+
282
+ if xlabel == 'auto':
283
+ xlabel = x_col
284
+ if ylabel == 'auto':
285
+ ylabel = y_col if y_col is not None else 'Count'
286
+ if clabel == 'auto' and c_col is not None:
287
+ clabel = c_col
288
+
289
+ if y_col is None:
290
+ # histogram
291
+ ax.hist(data[x_col], bins=kwargs.get('bins', "fd"),
292
+ color=kwargs.get('color', "tab:blue"), **kwargs)
293
+ ax.set(
294
+ xlabel=xlabel,
295
+ ylabel=ylabel,
296
+ )
297
+ else:
298
+ # scatter plot
299
+ c = data[c_col] if c_col is not None else kwargs.get('color', None)
300
+ if c_col == 'kstar_1' or c_col == 'kstar_2':
301
+ c = data[c_col].map(lambda k: kstar_translator[k]['colour'])
302
+ sc = ax.scatter(data[x_col], data[y_col],
303
+ c=c,
304
+ **kwargs)
305
+ ax.set(
306
+ xlabel=xlabel,
307
+ ylabel=ylabel,
308
+ )
309
+ if c_col is not None and c_col not in ['kstar_1', 'kstar_2']:
310
+ cbar = fig.colorbar(sc, ax=ax)
311
+ cbar.set_label(clabel)
312
+ elif c_col is not None:
313
+ # extract colours and labels
314
+ colours = [entry["colour"] for entry in kstar_translator[1:-2]]
315
+ labels = [entry["short"] for entry in kstar_translator[1:-2]]
316
+
317
+ # create colormap
318
+ cmap = ListedColormap(colours)
319
+ bounds = np.arange(len(colours) + 1)
320
+ norm = BoundaryNorm(bounds, cmap.N)
321
+
322
+ cb = plt.colorbar(
323
+ mappable=plt.cm.ScalarMappable(norm=norm, cmap=cmap),
324
+ ticks=np.arange(len(colours)) + 0.5,
325
+ boundaries=bounds,
326
+ ax=ax
327
+ )
328
+ cb.ax.set_yticklabels(labels)
329
+ cb.set_label(clabel)
330
+
331
+ if show:
332
+ plt.show()
333
+ return fig, ax
334
+
335
+
336
+ class COSMICPopOutput():
337
+ def __init__(self, file, label=None):
338
+ # read in convergence tables and totals
339
+ keys = ['conv', 'idx', 'match', 'mass_binaries', 'mass_singles',
340
+ 'n_binaries', 'n_singles', 'mass_stars', 'n_stars']
341
+ for key in keys:
342
+ setattr(self, key, pd.read_hdf(file, key=key))
343
+
344
+ # load config back from JSON storage
345
+ with h5.File(file, 'r') as f:
346
+ self.config = json.loads(f['config'][()])
347
+
348
+ # create a COSMICOutput for the binaries, and optionally for the singles
349
+ self.output = COSMICOutput(file=file, label=label + ' [binaries]' if label is not None else None)
350
+ singles = "keep_singles" in self.config["sampling"] and self.config["sampling"]["keep_singles"]
351
+ self.singles_output = COSMICOutput(
352
+ file=file, label=label + ' [singles]' if label is not None else None,
353
+ file_key_suffix='_singles'
354
+ ) if singles else None
355
+ self.label = label
356
+
357
+ def __repr__(self):
358
+ r = f'<COSMICPopOutput{" - " + self.label if self.label is not None else ""}: {len(self.output)} binaries>'
359
+ if self.singles_output is not None:
360
+ r = r[:-1] + f', {len(self.singles_output)} singles>'
361
+ return r
362
+
363
+ def __len__(self):
364
+ return len(self.conv)
365
+
366
+ def to_combined_output(self):
367
+ """Combine binaries and singles into a single COSMICOutput instance.
368
+
369
+ Returns
370
+ -------
371
+ combined_output : `COSMICOutput`
372
+ COSMICOutput instance containing both binaries and singles.
373
+
374
+ Raises
375
+ ------
376
+ ValueError
377
+ If singles output is not available.
378
+ """
379
+ if self.singles_output is None:
380
+ raise ValueError("Singles output is not available in this COSMICPopOutput instance.")
381
+
382
+ bpp = pd.concat([self.output.bpp, self.singles_output.bpp], ignore_index=True)
383
+ bcm = pd.concat([self.output.bcm, self.singles_output.bcm], ignore_index=True)
384
+ initC = pd.concat([self.output.initC, self.singles_output.initC], ignore_index=True)
385
+ kick_info = pd.concat([self.output.kick_info, self.singles_output.kick_info], ignore_index=True)
386
+
387
+ return COSMICOutput(
388
+ bpp=bpp,
389
+ bcm=bcm,
390
+ initC=initC,
391
+ kick_info=kick_info,
392
+ label=self.label + ' [combined]' if self.label is not None else None
393
+ )
394
+
395
+
396
+ def save_initC(filename, initC, key="initC", settings_key="initC_settings", force_save_all=False):
397
+ """Save an initC table to an HDF5 file.
398
+
399
+ Any column where every binary has the same value (setting) is saved separately with only a single copy
400
+ to save space.
401
+
402
+ This will take slightly longer (a few seconds instead of 1 second) to run but will save you around
403
+ a kilobyte per binary, which adds up!
404
+
405
+ Parameters
406
+ ----------
407
+ filename : `str`
408
+ Filename/path to the HDF5 file
409
+ initC : `pandas.DataFrame`
410
+ Initial conditions table
411
+ key : `str`, optional
412
+ Dataset key to use for main table, by default "initC"
413
+ settings_key : `str`, optional
414
+ Dataset key to use for settings table, by default "initC_settings"
415
+ force_save_all : `bool`, optional
416
+ If true, force all settings columns to be saved in the main table, by default False
417
+ """
418
+
419
+ # for each column, check if all values are the same
420
+ uniques = initC.nunique(axis=0)
421
+ compress_cols = [col for col in initC.columns if uniques[col] == 1]
422
+
423
+ if len(compress_cols) == 0 or force_save_all:
424
+ # nothing to compress, just save the whole table
425
+ initC.to_hdf(filename, key=key)
426
+ else:
427
+ # save the main table without the compressed columns
428
+ initC.drop(columns=compress_cols).to_hdf(filename, key=key)
429
+
430
+ # save the compressed columns separately
431
+ settings_df = pd.DataFrame([{col: initC[col].iloc[0] for col in compress_cols}])
432
+ settings_df.to_hdf(filename, key=settings_key)
433
+
434
+
435
+ def load_initC(filename, key="initC", settings_key="initC_settings"):
436
+ """Load an initC table from an HDF5 file.
437
+
438
+ If settings were saved separately, they are merged back into the main table.
439
+
440
+ Parameters
441
+ ----------
442
+ filename : `str`
443
+ Filename/path to the HDF5 file
444
+ key : `str`, optional
445
+ Dataset key to use for main table, by default "initC"
446
+ settings_key : `str`, optional
447
+ Dataset key to use for settings table, by default "initC_settings"
448
+
449
+ Returns
450
+ -------
451
+ initC : `pandas.DataFrame`
452
+ Initial conditions table
453
+ """
454
+
455
+ with h5.File(filename, 'r') as f:
456
+ has_settings = settings_key in f.keys()
457
+
458
+ initC = pd.read_hdf(filename, key=key)
459
+
460
+ if has_settings:
461
+ settings_df = pd.read_hdf(filename, key=settings_key)
462
+ initC.loc[:, settings_df.columns] = settings_df.values[0]
463
+
464
+ return initC
465
+
466
+