Add utility functions to get distribution of event counts in reconstructed energy given a flux model and a declination#322
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds helper utilities (requested in issue #301) to compute the expected distribution of reconstructed-energy event counts for IceCube public-data point-source datasets, given a flux model and a fixed declination.
Changes:
- Add utilities to fold a flux model through effective area + smearing matrix to obtain expected reco-energy counts per dataset and summed over seasons.
- Make
get_bin_indices_from_lower_and_upper_binedgesaccept scalar inputs by normalizingvalueswithnp.atleast_1d. - Add a generic
math_function_strproperty forFunctionEnergyFluxProfile.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 12 comments.
| File | Description |
|---|---|
skyllh/core/flux_model.py |
Adds math_function_str for callable-based energy flux profiles. |
skyllh/core/binning.py |
Makes bin-index lookup robust to scalar values inputs. |
skyllh/analyses/i3/publicdata_ps/utils.py |
Introduces the new reco-energy expected-counts helper functions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Build a uniform 0.1-step grid in log10(E/GeV) covering all valid reco-E bins. | ||
| reco_e_edges = np.arange(0.0, 9.0 + 0.05, 0.1) | ||
| reco_e_centers = get_bincenters_from_binedges(reco_e_edges) | ||
| out_bin_width = reco_e_edges[1] - reco_e_edges[0] |
There was a problem hiding this comment.
I don't wanna do this because then combining counts from different datasets is non-trivial. And that's because different smearing matrices have different reco energy bins.
| # Resolve energy range limits (in GeV). | ||
| E_min = energy_range[0] if energy_range is not None else 10.0 ** true_e_edges[0] | ||
| E_max = energy_range[1] if energy_range is not None else 10.0 ** true_e_edges[-1] |
There was a problem hiding this comment.
This is now checked in the public function. See 72441d8
| log10_E_centre = 0.5 * (log10_E1 + log10_E2) | ||
| aeff_e_idx = get_bin_indices_from_lower_and_upper_binedges(aeff_e_bins_lo, aeff_e_bins_hi, log10_E_centre)[0] | ||
| A_eff = aeff_dec[aeff_e_idx] # cm^2 |
| # Marginalise smearing matrix over psi and ang_err axes: | ||
| p_reco = sm.histogram[i, sm_dec_idx, :, :, :].sum(axis=(-1, -2)) # (n_reco_e,) | ||
| if p_reco.sum() == 0.0: | ||
| continue | ||
|
|
||
| # Convert probability to probability density in log10(E_reco) space. | ||
| bw = reco_e_hi[i, sm_dec_idx, :] - reco_e_lo[i, sm_dec_idx, :] # bin widths | ||
| pdf = np.where(bw > 0, p_reco / bw, 0.0) # (n_reco_e,) | ||
|
|
||
| # Original reco-energy bin edges for this (true_e_i, dec) slice. | ||
| # Note: this might break for zero-binwidth bins, but they should never happen for the reco energies. | ||
| original_edges = np.append(reco_e_lo[i, sm_dec_idx, :], reco_e_hi[i, sm_dec_idx, -1]) | ||
|
|
||
| # 1D spline interpolation of the PDF onto the uniform output grid. | ||
| spl = FctSpline1D(pdf, original_edges) | ||
| counts_per_sec += weight * spl(reco_e_centers) * out_bin_width |
| if livetimes is not None and len(livetimes) != len(datasets): | ||
| raise ValueError('Length of livetimes must match the number of datasets.') | ||
|
|
||
| counts_total = 0 | ||
|
|
||
| # Loop over datasets and sum contributions accounting for livetimes. | ||
| for i, ds in enumerate(datasets): | ||
| if livetimes is not None: | ||
| if livetimes[i] is None: | ||
| data = ds.load_data() | ||
| livetime = data.livetime | ||
| else: | ||
| livetime = livetimes[i] | ||
| else: | ||
| data = ds.load_data() | ||
| livetime = data.livetime | ||
|
|
||
| reco_e_edges, counts_per_sec = reco_energy_counts_per_second_per_ds( | ||
| ds, flux, dec, Phi0=Phi0, energy_range=energy_range | ||
| ) | ||
| counts_total += counts_per_sec * livetime * 24 * 3600 # Convert days to seconds | ||
| return reco_e_edges, counts_total |
| # Resolve energy range limits (in GeV). | ||
| E_min = energy_range[0] if energy_range is not None else 10.0 ** true_e_edges[0] | ||
| E_max = energy_range[1] if energy_range is not None else 10.0 ** true_e_edges[-1] |
| log10_E_centre = 0.5 * (log10_E1 + log10_E2) | ||
| aeff_e_idx = get_bin_indices_from_lower_and_upper_binedges(aeff_e_bins_lo, aeff_e_bins_hi, log10_E_centre)[0] | ||
| A_eff = aeff_dec[aeff_e_idx] # cm^2 |
There was a problem hiding this comment.
This also does not happen because in true energy, the sm and aeff ranges are the same
| # Marginalise smearing matrix over psi and ang_err axes: | ||
| p_reco = sm.histogram[i, sm_dec_idx, :, :, :].sum(axis=(-1, -2)) # (n_reco_e,) | ||
| if p_reco.sum() == 0.0: | ||
| continue | ||
|
|
||
| # Convert probability to probability density in log10(E_reco) space. | ||
| bw = reco_e_hi[i, sm_dec_idx, :] - reco_e_lo[i, sm_dec_idx, :] # bin widths | ||
| pdf = np.where(bw > 0, p_reco / bw, 0.0) # (n_reco_e,) | ||
|
|
||
| # Original reco-energy bin edges for this (true_e_i, dec) slice. | ||
| # Note: this might break for zero-binwidth bins, but they should never happen for the reco energies. | ||
| original_edges = np.append(reco_e_lo[i, sm_dec_idx, :], reco_e_hi[i, sm_dec_idx, -1]) | ||
|
|
||
| # 1D spline interpolation of the PDF onto the uniform output grid. | ||
| spl = FctSpline1D(pdf, original_edges) | ||
| counts_per_sec += weight * spl(reco_e_centers) * out_bin_width |
There was a problem hiding this comment.
As written in the comment note, I believe this never actually happens. But yes, it is a liability in principle. Just not sure I want to overcomplicate the code for something that currently can't happen...
| if livetimes is not None and len(livetimes) != len(datasets): | ||
| raise ValueError('Length of livetimes must match the number of datasets.') | ||
|
|
||
| counts_total = 0 | ||
|
|
||
| # Loop over datasets and sum contributions accounting for livetimes. | ||
| for i, ds in enumerate(datasets): | ||
| if livetimes is not None: | ||
| if livetimes[i] is None: | ||
| data = ds.load_data() | ||
| livetime = data.livetime | ||
| else: | ||
| livetime = livetimes[i] | ||
| else: | ||
| data = ds.load_data() | ||
| livetime = data.livetime | ||
|
|
||
| reco_e_edges, counts_per_sec = reco_energy_counts_per_second_per_ds( | ||
| ds, flux, dec, Phi0=Phi0, energy_range=energy_range | ||
| ) | ||
| counts_total += counts_per_sec * livetime * 24 * 3600 # Convert days to seconds | ||
| return reco_e_edges, counts_total |
| reco_e_edges : ndarray, shape (n_bins + 1,) | ||
| Bin edges of the output reconstructed log10(E/GeV) axis. | ||
| counts_per_sec : ndarray, shape (n_bins,) | ||
| Expected counts per second in each reco-energy bin. | ||
| """ |
|
What do you think about making You could then load aeff/sm (and temporarily cache them) there to figure out the common bounds for |
tomaskontrimas
left a comment
There was a problem hiding this comment.
Looks good to me! 🚀
This PR addresses issue #301.
It has been tested using the PowerLawEnergyFluxProfile flux model and a custom callable that implements the same power law. Passing one of the two gives the same results.
This is an initial implementation. For instance, it could be extended in the future to allow integration over a declination band.