|
1
|
|
|
import numpy as np |
|
|
|
|
|
|
2
|
|
|
|
|
3
|
|
|
from astromodels import use_astromodels_memoization |
|
4
|
|
|
|
|
5
|
|
|
|
|
6
|
|
|
def _select_with_wrap_around(arr, start, stop, wrap=(360, 0)): |
|
7
|
|
|
|
|
8
|
|
|
if start > stop: |
|
9
|
|
|
|
|
10
|
|
|
# This can happen if for instance lon_start = 280 and lon_stop = 10, which means we |
|
11
|
|
|
# should keep between 280 and 360 and then between 0 to 10 |
|
12
|
|
|
idx = ((arr >= stop) & (arr <= wrap[0])) | ((arr >= wrap[1]) & (arr <= start)) |
|
13
|
|
|
|
|
14
|
|
|
else: |
|
15
|
|
|
|
|
16
|
|
|
idx = (arr >= start) & (arr <= stop) |
|
17
|
|
|
|
|
18
|
|
|
return idx |
|
19
|
|
|
|
|
20
|
|
|
# Conversion factor between deg^2 and rad^2 |
|
21
|
|
|
deg2_to_rad2 = 0.00030461741978670857 |
|
|
|
|
|
|
22
|
|
|
|
|
23
|
|
|
|
|
24
|
|
|
class ConvolvedExtendedSource(object): |
|
|
|
|
|
|
25
|
|
|
|
|
26
|
|
|
def __init__(self, source, response, flat_sky_projection): |
|
|
|
|
|
|
27
|
|
|
|
|
28
|
|
|
self._response = response |
|
29
|
|
|
self._flat_sky_projection = flat_sky_projection |
|
30
|
|
|
|
|
31
|
|
|
# Get name |
|
32
|
|
|
self._name = source.name |
|
33
|
|
|
|
|
34
|
|
|
self._source = source |
|
35
|
|
|
|
|
36
|
|
|
# Find out the response bins we need to consider for this extended source |
|
37
|
|
|
|
|
38
|
|
|
# # Get the footprint (i..e, the coordinates of the 4 points limiting the projections) |
|
39
|
|
|
(ra1, dec1), (ra2, dec2), (ra3, dec3), (ra4, dec4) = flat_sky_projection.wcs.calc_footprint() |
|
|
|
|
|
|
40
|
|
|
|
|
41
|
|
|
(lon_start, lon_stop), (lat_start, lat_stop) = source.get_boundaries() |
|
42
|
|
|
|
|
43
|
|
|
# Figure out maximum and minimum declination to be covered |
|
44
|
|
|
dec_min = max(min([dec1, dec2, dec3, dec4]), lat_start) |
|
45
|
|
|
dec_max = min(max([dec1, dec2, dec3, dec4]), lat_stop) |
|
46
|
|
|
|
|
47
|
|
|
# Get the defined dec bins lower edges |
|
48
|
|
|
lower_edges = np.array(map(lambda x: x[0], response.dec_bins)) |
|
|
|
|
|
|
49
|
|
|
upper_edges = np.array(map(lambda x: x[-1], response.dec_bins)) |
|
|
|
|
|
|
50
|
|
|
centers = np.array(map(lambda x: x[1], response.dec_bins)) |
|
|
|
|
|
|
51
|
|
|
|
|
52
|
|
|
dec_bins_to_consider_idx = np.flatnonzero((upper_edges >= dec_min) & (lower_edges <= dec_max)) |
|
53
|
|
|
|
|
54
|
|
|
# Wrap the selection so we have always one bin before and one after. |
|
55
|
|
|
# NOTE: we assume that the ROI do not overlap with the very first or the very last dec bin |
|
56
|
|
|
# Add one dec bin to cover the last part |
|
57
|
|
|
dec_bins_to_consider_idx = np.append(dec_bins_to_consider_idx, [dec_bins_to_consider_idx[-1] + 1]) |
|
58
|
|
|
# Add one dec bin to cover the first part |
|
59
|
|
|
dec_bins_to_consider_idx = np.insert(dec_bins_to_consider_idx, 0, [dec_bins_to_consider_idx[0] - 1]) |
|
60
|
|
|
|
|
61
|
|
|
self._dec_bins_to_consider = map(lambda x: response.response_bins[centers[x]], dec_bins_to_consider_idx) |
|
|
|
|
|
|
62
|
|
|
|
|
63
|
|
|
print("Considering %i dec bins for extended source %s" % (len(self._dec_bins_to_consider), |
|
64
|
|
|
self._name)) |
|
65
|
|
|
|
|
66
|
|
|
# Find central bin for the PSF |
|
67
|
|
|
|
|
68
|
|
|
dec_center = (lat_start + lat_stop) / 2.0 |
|
69
|
|
|
# |
|
70
|
|
|
self._central_response_bins = response.get_response_dec_bin(dec_center, interpolate=False) |
|
71
|
|
|
|
|
72
|
|
|
print("Central bin is bin at Declination = %.3f" % dec_center) |
|
|
|
|
|
|
73
|
|
|
|
|
74
|
|
|
# Take note of the pixels within the flat sky projection that actually need to be computed. If the extended |
|
75
|
|
|
# source is significantly smaller than the flat sky projection, this gains a substantial amount of time |
|
76
|
|
|
|
|
77
|
|
|
idx_lon = _select_with_wrap_around(self._flat_sky_projection.ras, lon_start, lon_stop, (360, 0)) |
|
78
|
|
|
idx_lat = _select_with_wrap_around(self._flat_sky_projection.decs, lat_start, lat_stop, (90, -90)) |
|
79
|
|
|
|
|
80
|
|
|
self._active_flat_sky_mask = (idx_lon & idx_lat) |
|
81
|
|
|
|
|
82
|
|
|
assert np.sum(self._active_flat_sky_mask) > 0, "Mismatch between source %s and ROI" % self._name |
|
83
|
|
|
|
|
84
|
|
|
# Get the energies needed for the computation of the flux |
|
85
|
|
|
self._energy_centers_keV = self._central_response_bins[self._central_response_bins.keys()[0]].sim_energy_bin_centers * 1e9 |
|
|
|
|
|
|
86
|
|
|
|
|
87
|
|
|
# Prepare array for fluxes |
|
88
|
|
|
self._all_fluxes = np.zeros((self._flat_sky_projection.ras.shape[0], |
|
89
|
|
|
self._energy_centers_keV.shape[0])) |
|
90
|
|
|
|
|
91
|
|
|
def _setup_callbacks(self, callback): |
|
92
|
|
|
|
|
93
|
|
|
# Register call back with all free parameters and all linked parameters. If a parameter is linked to another |
|
94
|
|
|
# one, the other one might be in a different source, so we add a callback to that one so that we recompute |
|
95
|
|
|
# this source when needed. |
|
96
|
|
|
for parameter in self._source.parameters.values(): |
|
97
|
|
|
|
|
98
|
|
|
if parameter.free: |
|
99
|
|
|
parameter.add_callback(callback) |
|
100
|
|
|
|
|
101
|
|
|
if parameter.has_auxiliary_variable(): |
|
102
|
|
|
# Add a callback to the auxiliary variable so that when that is changed, we need |
|
103
|
|
|
# to recompute the model |
|
104
|
|
|
aux_variable, _ = parameter.auxiliary_variable |
|
105
|
|
|
|
|
106
|
|
|
aux_variable.add_callback(callback) |
|
107
|
|
|
|
|
108
|
|
|
def get_source_map(self, energy_bin_id, tag=None): |
|
|
|
|
|
|
109
|
|
|
|
|
110
|
|
|
raise NotImplementedError("You need to implement this in derived classes") |
|
111
|
|
|
|
|
112
|
|
|
|
|
113
|
|
|
class ConvolvedExtendedSource3D(ConvolvedExtendedSource): |
|
|
|
|
|
|
114
|
|
|
|
|
115
|
|
|
def __init__(self, source, response, flat_sky_projection): |
|
116
|
|
|
|
|
117
|
|
|
super(ConvolvedExtendedSource3D, self).__init__(source, response, flat_sky_projection) |
|
118
|
|
|
|
|
119
|
|
|
# We implement a caching system so that the source flux is evaluated only when strictly needed, |
|
120
|
|
|
# because it is the most computationally intense part otherwise. |
|
121
|
|
|
self._recompute_flux = True |
|
122
|
|
|
|
|
123
|
|
|
# Register callback to keep track of the parameter changes |
|
124
|
|
|
self._setup_callbacks(self._parameter_change_callback) |
|
125
|
|
|
|
|
126
|
|
|
def _parameter_change_callback(self, this_parameter): |
|
|
|
|
|
|
127
|
|
|
|
|
128
|
|
|
# A parameter has changed, we need to recompute the function. |
|
129
|
|
|
# NOTE: we do not recompute it here because if more than one parameter changes at the time (like for example |
|
130
|
|
|
# during sampling) we do not want to recompute the function multiple time before we get to the convolution |
|
131
|
|
|
# stage. Therefore we will compute the function in the get_source_map method |
|
132
|
|
|
|
|
133
|
|
|
# print("%s has changed" % this_parameter.name) |
|
134
|
|
|
self._recompute_flux = True |
|
135
|
|
|
|
|
136
|
|
|
def get_source_map(self, energy_bin_id, tag=None): |
|
137
|
|
|
|
|
138
|
|
|
# We do not use the memoization in astromodels because we are doing caching by ourselves, |
|
139
|
|
|
# so the astromodels memoization would turn into 100% cache miss and use a lot of RAM for nothing, |
|
140
|
|
|
# given that we are evaluating the function on many points and many energies |
|
141
|
|
|
with use_astromodels_memoization(False): |
|
142
|
|
|
|
|
143
|
|
|
# If we need to recompute the flux, let's do it |
|
144
|
|
|
if self._recompute_flux: |
|
145
|
|
|
|
|
146
|
|
|
# print("recomputing %s" % self._name) |
|
147
|
|
|
|
|
148
|
|
|
# Recompute the fluxes for the pixels that are covered by this extended source |
|
149
|
|
|
self._all_fluxes[self._active_flat_sky_mask, :] = self._source( |
|
150
|
|
|
self._flat_sky_projection.ras[self._active_flat_sky_mask], |
|
|
|
|
|
|
151
|
|
|
self._flat_sky_projection.decs[self._active_flat_sky_mask], |
|
|
|
|
|
|
152
|
|
|
self._energy_centers_keV) # 1 / (keV cm^2 s rad^2) |
|
|
|
|
|
|
153
|
|
|
|
|
154
|
|
|
# We don't need to recompute the function anymore until a parameter changes |
|
155
|
|
|
self._recompute_flux = False |
|
156
|
|
|
|
|
157
|
|
|
# Now compute the expected signal |
|
158
|
|
|
|
|
159
|
|
|
pixel_area_rad2 = self._flat_sky_projection.project_plane_pixel_area * deg2_to_rad2 |
|
160
|
|
|
|
|
161
|
|
|
this_model_image = np.zeros(self._all_fluxes.shape[0]) |
|
162
|
|
|
|
|
163
|
|
|
# Loop over the Dec bins that cover this source and compute the expected flux, interpolating between |
|
164
|
|
|
# two dec bins for each point |
|
165
|
|
|
|
|
166
|
|
|
for dec_bin1, dec_bin2 in zip(self._dec_bins_to_consider[:-1], self._dec_bins_to_consider[1:]): |
|
167
|
|
|
|
|
168
|
|
|
# Get the two response bins to consider |
|
169
|
|
|
this_response_bin1 = dec_bin1[energy_bin_id] |
|
170
|
|
|
this_response_bin2 = dec_bin2[energy_bin_id] |
|
171
|
|
|
|
|
172
|
|
|
# Figure out which pixels are between the centers of the dec bins we are considering |
|
173
|
|
|
c1, c2 = this_response_bin1.declination_center, this_response_bin2.declination_center |
|
|
|
|
|
|
174
|
|
|
|
|
175
|
|
|
idx = (self._flat_sky_projection.decs >= c1) & (self._flat_sky_projection.decs < c2) & \ |
|
176
|
|
|
self._active_flat_sky_mask |
|
177
|
|
|
|
|
178
|
|
|
# Reweight the spectrum separately for the two bins |
|
179
|
|
|
# NOTE: the scale is the same because the sim_differential_photon_fluxes are the same (the simulation |
|
180
|
|
|
# used to make the response used the same spectrum for each bin). What changes between the two bins |
|
181
|
|
|
# is the observed signal per bin (the .sim_signal_events_per_bin member) |
|
182
|
|
|
scale = (self._all_fluxes[idx, :] * pixel_area_rad2) / this_response_bin1.sim_differential_photon_fluxes |
|
183
|
|
|
|
|
184
|
|
|
# Compute the interpolation weights for the two responses |
|
185
|
|
|
w1 = (self._flat_sky_projection.decs[idx] - c2) / (c1 - c2) |
|
|
|
|
|
|
186
|
|
|
w2 = (self._flat_sky_projection.decs[idx] - c1) / (c2 - c1) |
|
|
|
|
|
|
187
|
|
|
|
|
188
|
|
|
this_model_image[idx] = (w1 * np.sum(scale * this_response_bin1.sim_signal_events_per_bin, axis=1) + |
|
189
|
|
|
w2 * np.sum(scale * this_response_bin2.sim_signal_events_per_bin, axis=1)) * \ |
|
190
|
|
|
1e9 |
|
191
|
|
|
|
|
192
|
|
|
# Reshape the flux array into an image |
|
193
|
|
|
this_model_image = this_model_image.reshape((self._flat_sky_projection.npix_height, |
|
194
|
|
|
self._flat_sky_projection.npix_width)).T |
|
195
|
|
|
|
|
196
|
|
|
return this_model_image |
|
197
|
|
|
|
|
198
|
|
|
|
|
199
|
|
|
class ConvolvedExtendedSource2D(ConvolvedExtendedSource3D): |
|
|
|
|
|
|
200
|
|
|
|
|
201
|
|
|
pass |
|
202
|
|
|
|
|
203
|
|
|
# def __init__(self, source, response, flat_sky_projection): |
|
204
|
|
|
# |
|
205
|
|
|
# assert source.spatial_shape.n_dim == 2, "Use the ConvolvedExtendedSource3D for this source" |
|
206
|
|
|
# |
|
207
|
|
|
# super(ConvolvedExtendedSource2D, self).__init__(source, response, flat_sky_projection) |
|
208
|
|
|
# |
|
209
|
|
|
# # Set up the caching |
|
210
|
|
|
# self._spectral_part = {} |
|
211
|
|
|
# self._spatial_part = None |
|
212
|
|
|
# |
|
213
|
|
|
# # Now make a list of parameters for the spectral shape. |
|
214
|
|
|
# self._spectral_parameters = [] |
|
215
|
|
|
# self._spatial_parameters = [] |
|
216
|
|
|
# |
|
217
|
|
|
# for component in self._source.components.values(): |
|
218
|
|
|
# |
|
219
|
|
|
# for parameter in component.shape.parameters.values(): |
|
220
|
|
|
# |
|
221
|
|
|
# self._spectral_parameters.append(parameter.path) |
|
222
|
|
|
# |
|
223
|
|
|
# # If there are links, we need to keep track of them |
|
224
|
|
|
# if parameter.has_auxiliary_variable(): |
|
225
|
|
|
# |
|
226
|
|
|
# aux_variable, _ = parameter.auxiliary_variable |
|
227
|
|
|
# |
|
228
|
|
|
# self._spectral_parameters.append(aux_variable.path) |
|
229
|
|
|
# |
|
230
|
|
|
# for parameter in self._source.spatial_shape.parameters.values(): |
|
231
|
|
|
# |
|
232
|
|
|
# self._spatial_parameters.append(parameter.path) |
|
233
|
|
|
# |
|
234
|
|
|
# # If there are links, we need to keep track of them |
|
235
|
|
|
# if parameter.has_auxiliary_variable(): |
|
236
|
|
|
# |
|
237
|
|
|
# aux_variable, _ = parameter.auxiliary_variable |
|
238
|
|
|
# |
|
239
|
|
|
# self._spatial_parameters.append(aux_variable.path) |
|
240
|
|
|
# |
|
241
|
|
|
# self._setup_callbacks(self._parameter_change_callback) |
|
242
|
|
|
# |
|
243
|
|
|
# def _parameter_change_callback(self, this_parameter): |
|
244
|
|
|
# |
|
245
|
|
|
# if this_parameter.path in self._spectral_parameters: |
|
246
|
|
|
# |
|
247
|
|
|
# # A spectral parameters. Need to recompute the spectrum |
|
248
|
|
|
# self._spectral_part = {} |
|
249
|
|
|
# |
|
250
|
|
|
# else: |
|
251
|
|
|
# |
|
252
|
|
|
# # A spatial parameter, need to recompute the spatial shape |
|
253
|
|
|
# self._spatial_part = None |
|
254
|
|
|
# |
|
255
|
|
|
# def _compute_response_scale(self): |
|
256
|
|
|
# |
|
257
|
|
|
# # First get the differential flux from the spectral components (most likely there is only one component, |
|
258
|
|
|
# # but let's do it so it can generalize to multi-component sources) |
|
259
|
|
|
# |
|
260
|
|
|
# results = [component.shape(self._energy_centers_keV) for component in self._source.components.values()] |
|
261
|
|
|
# |
|
262
|
|
|
# this_diff_flux = np.sum(results, 0) |
|
263
|
|
|
# |
|
264
|
|
|
# # NOTE: the sim_differential_photon_fluxes are the same for every bin, so it doesn't matter which |
|
265
|
|
|
# # bin I read them from |
|
266
|
|
|
# |
|
267
|
|
|
# scale = this_diff_flux * 1e9 / self._central_response_bins[0].sim_differential_photon_fluxes |
|
268
|
|
|
# |
|
269
|
|
|
# return scale |
|
270
|
|
|
# |
|
271
|
|
|
# def get_source_map(self, energy_bin_id, tag=None): |
|
272
|
|
|
# |
|
273
|
|
|
# # We do not use the memoization in astromodels because we are doing caching by ourselves, |
|
274
|
|
|
# # so the astromodels memoization would turn into 100% cache miss |
|
275
|
|
|
# with use_astromodels_memoization(False): |
|
276
|
|
|
# |
|
277
|
|
|
# # Spatial part: first we try to see if we have it already in the cache |
|
278
|
|
|
# |
|
279
|
|
|
# if self._spatial_part is None: |
|
280
|
|
|
# |
|
281
|
|
|
# # Cache miss, we need to recompute |
|
282
|
|
|
# |
|
283
|
|
|
# # We substitute integration with a discretization, which will work |
|
284
|
|
|
# # well if the size of the pixel of the flat sky grid is small enough compared to the features of the |
|
285
|
|
|
# # extended source |
|
286
|
|
|
# brightness = self._source.spatial_shape(self._flat_sky_projection.ras, self._flat_sky_projection.decs) # 1 / rad^2 |
|
|
|
|
|
|
287
|
|
|
# |
|
288
|
|
|
# pixel_area_rad2 = self._flat_sky_projection.project_plane_pixel_area * deg2_to_rad2 |
|
289
|
|
|
# |
|
290
|
|
|
# integrated_flux = brightness * pixel_area_rad2 |
|
291
|
|
|
# |
|
292
|
|
|
# # Now convolve with psf |
|
293
|
|
|
# spatial_part = integrated_flux.reshape((self._flat_sky_projection.npix_height, |
|
294
|
|
|
# self._flat_sky_projection.npix_width)).T |
|
295
|
|
|
# |
|
296
|
|
|
# # Memorize hoping to reuse it next time |
|
297
|
|
|
# self._spatial_part = spatial_part |
|
298
|
|
|
# |
|
299
|
|
|
# # Spectral part |
|
300
|
|
|
# spectral_part = self._spectral_part.get(energy_bin_id, None) |
|
301
|
|
|
# |
|
302
|
|
|
# if spectral_part is None: |
|
303
|
|
|
# |
|
304
|
|
|
# spectral_part = np.zeros(self._all_fluxes.shape[0]) |
|
305
|
|
|
# |
|
306
|
|
|
# scale = self._compute_response_scale() |
|
307
|
|
|
# |
|
308
|
|
|
# # Loop over the Dec bins that cover this source and compute the expected flux, interpolating between |
|
309
|
|
|
# # two dec bins for each point |
|
310
|
|
|
# |
|
311
|
|
|
# for dec_bin1, dec_bin2 in zip(self._dec_bins_to_consider[:-1], self._dec_bins_to_consider[1:]): |
|
312
|
|
|
# # Get the two response bins to consider |
|
313
|
|
|
# this_response_bin1 = dec_bin1[energy_bin_id] |
|
314
|
|
|
# this_response_bin2 = dec_bin2[energy_bin_id] |
|
315
|
|
|
# |
|
316
|
|
|
# # Figure out which pixels are between the centers of the dec bins we are considering |
|
317
|
|
|
# c1, c2 = this_response_bin1.declination_center, this_response_bin2.declination_center |
|
318
|
|
|
# |
|
319
|
|
|
# idx = (self._flat_sky_projection.decs >= c1) & (self._flat_sky_projection.decs < c2) & \ |
|
320
|
|
|
# self._active_flat_sky_mask |
|
321
|
|
|
# |
|
322
|
|
|
# # Reweight the spectrum separately for the two bins |
|
323
|
|
|
# |
|
324
|
|
|
# # Compute the interpolation weights for the two responses |
|
325
|
|
|
# w1 = (self._flat_sky_projection.decs[idx] - c2) / (c1 - c2) |
|
326
|
|
|
# w2 = (self._flat_sky_projection.decs[idx] - c1) / (c2 - c1) |
|
327
|
|
|
# |
|
328
|
|
|
# spectral_part[idx] = (w1 * np.sum(scale * this_response_bin1.sim_signal_events_per_bin) + |
|
329
|
|
|
# w2 * np.sum(scale * this_response_bin2.sim_signal_events_per_bin)) |
|
330
|
|
|
# |
|
331
|
|
|
# # Memorize hoping to reuse it next time |
|
332
|
|
|
# self._spectral_part[energy_bin_id] = spectral_part.reshape((self._flat_sky_projection.npix_height, |
|
333
|
|
|
# self._flat_sky_projection.npix_width)).T |
|
334
|
|
|
# |
|
335
|
|
|
# return self._spectral_part[energy_bin_id] * self._spatial_part |
|
336
|
|
|
|
The coding style of this project requires that you add a docstring to this code element. Below, you find an example for methods:
If you would like to know more about docstrings, we recommend to read PEP-257: Docstring Conventions.