1
|
|
|
# Licensed under a 3-clause BSD style license - see LICENSE.rst |
2
|
|
|
"""Utilities for dealing with HEALPix projections and mappings.""" |
3
|
|
|
import copy |
4
|
|
|
import numpy as np |
5
|
|
|
from astropy import units as u |
6
|
|
|
from astropy.coordinates import SkyCoord |
7
|
|
|
from astropy.io import fits |
8
|
|
|
from astropy.units import Quantity |
9
|
|
|
from gammapy.utils.array import is_power2 |
10
|
|
|
from ..axes import MapAxes |
11
|
|
|
from ..coord import MapCoord, skycoord_to_lonlat |
12
|
|
|
from ..geom import Geom, pix_tuple_to_idx |
13
|
|
|
from ..utils import INVALID_INDEX, coordsys_to_frame, frame_to_coordsys |
14
|
|
|
from .io import HPX_FITS_CONVENTIONS, HpxConv |
15
|
|
|
from .utils import ( |
16
|
|
|
coords_to_vec, |
17
|
|
|
get_nside_from_pix_size, |
18
|
|
|
get_pix_size_from_nside, |
19
|
|
|
get_subpixels, |
20
|
|
|
get_superpixels, |
21
|
|
|
match_hpx_pix, |
22
|
|
|
nside_to_order, |
23
|
|
|
parse_hpxregion, |
24
|
|
|
ravel_hpx_index, |
25
|
|
|
unravel_hpx_index, |
26
|
|
|
) |
27
|
|
|
|
28
|
|
|
# Not sure if we should expose this in the docs or not: |
29
|
|
|
# HPX_FITS_CONVENTIONS, HpxConv |
30
|
|
|
__all__ = ["HpxGeom"] |
31
|
|
|
|
32
|
|
|
|
33
|
|
|
class HpxGeom(Geom): |
34
|
|
|
"""Geometry class for HEALPIX maps. |
35
|
|
|
|
36
|
|
|
This class performs mapping between partial-sky indices (pixel |
37
|
|
|
number within a HEALPIX region) and all-sky indices (pixel number |
38
|
|
|
within an all-sky HEALPIX map). Multi-band HEALPIX geometries use |
39
|
|
|
a global indexing scheme that assigns a unique pixel number based |
40
|
|
|
on the all-sky index and band index. In the single-band case the |
41
|
|
|
global index is the same as the HEALPIX index. |
42
|
|
|
|
43
|
|
|
By default the constructor will return an all-sky map. |
44
|
|
|
Partial-sky maps can be defined with the ``region`` argument. |
45
|
|
|
|
46
|
|
|
Parameters |
47
|
|
|
---------- |
48
|
|
|
nside : `~numpy.ndarray` |
49
|
|
|
HEALPIX nside parameter, the total number of pixels is |
50
|
|
|
12*nside*nside. For multi-dimensional maps one can pass |
51
|
|
|
either a single nside value or a vector of nside values |
52
|
|
|
defining the pixel size for each image plane. If nside is not |
53
|
|
|
a scalar then its dimensionality should match that of the |
54
|
|
|
non-spatial axes. If nest is True, nside must be a power of 2, |
55
|
|
|
less than 2**30. |
56
|
|
|
nest : bool |
57
|
|
|
True -> 'NESTED', False -> 'RING' indexing scheme |
58
|
|
|
frame : str |
59
|
|
|
Coordinate system, "icrs" | "galactic" |
60
|
|
|
region : str or tuple |
61
|
|
|
Spatial geometry for partial-sky maps. If none the map will |
62
|
|
|
encompass the whole sky. String input will be parsed |
63
|
|
|
according to HPX_REG header keyword conventions. Tuple |
64
|
|
|
input can be used to define an explicit list of pixels |
65
|
|
|
encompassed by the geometry. |
66
|
|
|
axes : list |
67
|
|
|
Axes for non-spatial dimensions. |
68
|
|
|
""" |
69
|
|
|
|
70
|
|
|
is_hpx = True |
71
|
|
|
is_region = False |
72
|
|
|
|
73
|
|
|
def __init__(self, nside, nest=True, frame="icrs", region=None, axes=None): |
74
|
|
|
from healpy.pixelfunc import check_nside |
75
|
|
|
|
76
|
|
|
check_nside(nside, nest=nest) |
77
|
|
|
|
78
|
|
|
self._nside = np.array(nside, ndmin=1) |
79
|
|
|
self._axes = MapAxes.from_default(axes, n_spatial_axes=1) |
80
|
|
|
|
81
|
|
|
if self.nside.size > 1 and self.nside.shape != self.shape_axes: |
82
|
|
|
raise ValueError( |
83
|
|
|
"Wrong dimensionality for nside. nside must " |
84
|
|
|
"be a scalar or have a dimensionality consistent " |
85
|
|
|
"with the axes argument." |
86
|
|
|
) |
87
|
|
|
|
88
|
|
|
self._frame = frame |
89
|
|
|
self._nest = nest |
90
|
|
|
self._ipix = None |
91
|
|
|
self._region = region |
92
|
|
|
self._create_lookup(region) |
93
|
|
|
self._npix = self._npix * np.ones(self.shape_axes, dtype=int) |
94
|
|
|
|
95
|
|
|
def _create_lookup(self, region): |
96
|
|
|
"""Create local-to-global pixel lookup table.""" |
97
|
|
|
if isinstance(region, str): |
98
|
|
|
ipix = [ |
99
|
|
|
self.get_index_list(nside, self._nest, region) |
100
|
|
|
for nside in self._nside.flat |
101
|
|
|
] |
102
|
|
|
|
103
|
|
|
self._ipix = [ |
104
|
|
|
ravel_hpx_index((p, i * np.ones_like(p)), np.ravel(self.npix_max)) |
105
|
|
|
for i, p in enumerate(ipix) |
106
|
|
|
] |
107
|
|
|
self._region = region |
108
|
|
|
self._indxschm = "EXPLICIT" |
109
|
|
|
self._npix = np.array([len(t) for t in self._ipix]) |
110
|
|
|
if self.nside.ndim > 1: |
111
|
|
|
self._npix = self._npix.reshape(self.nside.shape) |
112
|
|
|
self._ipix = np.concatenate(self._ipix) |
113
|
|
|
|
114
|
|
|
elif isinstance(region, tuple): |
115
|
|
|
region = [np.asarray(t) for t in region] |
116
|
|
|
m = np.any(np.stack([t >= 0 for t in region]), axis=0) |
117
|
|
|
region = [t[m] for t in region] |
118
|
|
|
|
119
|
|
|
self._ipix = ravel_hpx_index(region, self.npix_max) |
120
|
|
|
self._ipix = np.unique(self._ipix) |
121
|
|
|
region = unravel_hpx_index(self._ipix, self.npix_max) |
122
|
|
|
self._region = "explicit" |
123
|
|
|
self._indxschm = "EXPLICIT" |
124
|
|
|
if len(region) == 1: |
125
|
|
|
self._npix = np.array([len(region[0])]) |
126
|
|
|
else: |
127
|
|
|
self._npix = np.zeros(self.shape_axes, dtype=int) |
128
|
|
|
idx = np.ravel_multi_index(region[1:], self.shape_axes) |
129
|
|
|
cnt = np.unique(idx, return_counts=True) |
130
|
|
|
self._npix.flat[cnt[0]] = cnt[1] |
131
|
|
|
|
132
|
|
|
elif region is None: |
133
|
|
|
self._region = None |
134
|
|
|
self._indxschm = "IMPLICIT" |
135
|
|
|
self._npix = self.npix_max |
136
|
|
|
|
137
|
|
|
else: |
138
|
|
|
raise ValueError(f"Invalid region string: {region!r}") |
139
|
|
|
|
140
|
|
|
def local_to_global(self, idx_local): |
141
|
|
|
"""Compute a local index (partial-sky) from a global (all-sky) index. |
142
|
|
|
|
143
|
|
|
Returns |
144
|
|
|
------- |
145
|
|
|
idx_global : tuple |
146
|
|
|
A tuple of pixel index vectors with global HEALPIX pixel indices |
147
|
|
|
""" |
148
|
|
|
if self._ipix is None: |
149
|
|
|
return idx_local |
150
|
|
|
|
151
|
|
|
if self.nside.size > 1: |
152
|
|
|
idx = ravel_hpx_index(idx_local, self._npix) |
153
|
|
|
else: |
154
|
|
|
idx_tmp = tuple( |
155
|
|
|
[idx_local[0]] + [np.zeros(t.shape, dtype=int) for t in idx_local[1:]] |
156
|
|
|
) |
157
|
|
|
idx = ravel_hpx_index(idx_tmp, self._npix) |
158
|
|
|
|
159
|
|
|
idx_global = unravel_hpx_index(self._ipix[idx], self.npix_max) |
160
|
|
|
return idx_global[:1] + tuple(idx_local[1:]) |
161
|
|
|
|
162
|
|
|
def global_to_local(self, idx_global, ravel=False): |
163
|
|
|
"""Compute global (all-sky) index from a local (partial-sky) index. |
164
|
|
|
|
165
|
|
|
Parameters |
166
|
|
|
---------- |
167
|
|
|
idx_global : tuple |
168
|
|
|
A tuple of pixel indices with global HEALPix pixel indices. |
169
|
|
|
ravel : bool |
170
|
|
|
Return a raveled index. |
171
|
|
|
|
172
|
|
|
Returns |
173
|
|
|
------- |
174
|
|
|
idx_local : tuple |
175
|
|
|
A tuple of pixel indices with local HEALPIX pixel indices. |
176
|
|
|
""" |
177
|
|
|
if ( |
178
|
|
|
isinstance(idx_global, int) |
179
|
|
|
or (isinstance(idx_global, tuple) and isinstance(idx_global[0], int)) |
180
|
|
|
or isinstance(idx_global, np.ndarray) |
181
|
|
|
): |
182
|
|
|
idx_global = unravel_hpx_index(np.array(idx_global, ndmin=1), self.npix_max) |
183
|
|
|
|
184
|
|
|
if self.nside.size == 1: |
185
|
|
|
idx = np.array(idx_global[0], ndmin=1) |
186
|
|
|
else: |
187
|
|
|
idx = ravel_hpx_index(idx_global, self.npix_max) |
188
|
|
|
|
189
|
|
|
if self._ipix is not None: |
190
|
|
|
retval = np.full(idx.size, -1, "i") |
191
|
|
|
m = np.isin(idx.flat, self._ipix) |
192
|
|
|
retval[m] = np.searchsorted(self._ipix, idx.flat[m]) |
193
|
|
|
retval = retval.reshape(idx.shape) |
194
|
|
|
else: |
195
|
|
|
retval = idx |
196
|
|
|
|
197
|
|
|
if self.nside.size == 1: |
198
|
|
|
idx_local = tuple([retval] + list(idx_global[1:])) |
199
|
|
|
else: |
200
|
|
|
idx_local = unravel_hpx_index(retval, self._npix) |
201
|
|
|
|
202
|
|
|
m = np.any(np.stack([t == INVALID_INDEX.int for t in idx_local]), axis=0) |
203
|
|
|
for i, t in enumerate(idx_local): |
204
|
|
|
idx_local[i][m] = INVALID_INDEX.int |
205
|
|
|
|
206
|
|
|
if not ravel: |
207
|
|
|
return idx_local |
208
|
|
|
else: |
209
|
|
|
return ravel_hpx_index(idx_local, self.npix) |
210
|
|
|
|
211
|
|
|
def cutout(self, position, width, **kwargs): |
212
|
|
|
"""Create a cutout around a given position. |
213
|
|
|
|
214
|
|
|
Parameters |
215
|
|
|
---------- |
216
|
|
|
position : `~astropy.coordinates.SkyCoord` |
217
|
|
|
Center position of the cutout region. |
218
|
|
|
width : `~astropy.coordinates.Angle` or `~astropy.units.Quantity` |
219
|
|
|
Diameter of the circular cutout region. |
220
|
|
|
|
221
|
|
|
Returns |
222
|
|
|
------- |
223
|
|
|
cutout : `~gammapy.maps.WcsNDMap` |
224
|
|
|
Cutout map |
225
|
|
|
""" |
226
|
|
|
if not self.is_regular: |
227
|
|
|
raise ValueError("Can only do a cutout from a regular map.") |
228
|
|
|
|
229
|
|
|
width = u.Quantity(width, "deg").value |
230
|
|
|
return self.create( |
231
|
|
|
nside=self.nside, |
232
|
|
|
nest=self.nest, |
233
|
|
|
width=width, |
234
|
|
|
skydir=position, |
235
|
|
|
frame=self.frame, |
236
|
|
|
axes=self.axes, |
237
|
|
|
) |
238
|
|
|
|
239
|
|
|
def coord_to_pix(self, coords): |
240
|
|
|
import healpy as hp |
241
|
|
|
|
242
|
|
|
coords = MapCoord.create( |
243
|
|
|
coords, frame=self.frame, axis_names=self.axes.names |
244
|
|
|
).broadcasted |
245
|
|
|
theta, phi = coords.theta, coords.phi |
246
|
|
|
|
247
|
|
|
if self.axes: |
248
|
|
|
idxs = self.axes.coord_to_idx(coords, clip=True) |
249
|
|
|
bins = self.axes.coord_to_pix(coords) |
250
|
|
|
|
251
|
|
|
# FIXME: Figure out how to handle coordinates out of |
252
|
|
|
# bounds of non-spatial dimensions |
253
|
|
|
if self.nside.size > 1: |
254
|
|
|
nside = self.nside[tuple(idxs)] |
255
|
|
|
else: |
256
|
|
|
nside = self.nside |
257
|
|
|
|
258
|
|
|
m = ~np.isfinite(theta) |
259
|
|
|
theta[m] = 0.0 |
260
|
|
|
phi[m] = 0.0 |
261
|
|
|
pix = hp.ang2pix(nside, theta, phi, nest=self.nest) |
262
|
|
|
pix = tuple([pix]) + bins |
263
|
|
|
if np.any(m): |
264
|
|
|
for p in pix: |
265
|
|
|
p[m] = INVALID_INDEX.int |
266
|
|
|
else: |
267
|
|
|
pix = (hp.ang2pix(self.nside, theta, phi, nest=self.nest),) |
268
|
|
|
|
269
|
|
|
return pix |
270
|
|
|
|
271
|
|
|
def pix_to_coord(self, pix): |
272
|
|
|
import healpy as hp |
273
|
|
|
|
274
|
|
|
if self.axes: |
275
|
|
|
bins = [] |
276
|
|
|
vals = [] |
277
|
|
|
for i, ax in enumerate(self.axes): |
278
|
|
|
bins += [pix[1 + i]] |
279
|
|
|
vals += [ax.pix_to_coord(pix[1 + i])] |
280
|
|
|
|
281
|
|
|
idxs = pix_tuple_to_idx(bins) |
282
|
|
|
|
283
|
|
|
if self.nside.size > 1: |
284
|
|
|
nside = self.nside[idxs] |
285
|
|
|
else: |
286
|
|
|
nside = self.nside |
287
|
|
|
|
288
|
|
|
ipix = np.round(pix[0]).astype(int) |
289
|
|
|
m = ipix == INVALID_INDEX.int |
290
|
|
|
ipix[m] = 0 |
291
|
|
|
theta, phi = hp.pix2ang(nside, ipix, nest=self.nest) |
292
|
|
|
coords = [np.degrees(phi), np.degrees(np.pi / 2.0 - theta)] |
293
|
|
|
coords = tuple(coords + vals) |
294
|
|
|
if np.any(m): |
295
|
|
|
for c in coords: |
296
|
|
|
c[m] = INVALID_INDEX.float |
297
|
|
|
else: |
298
|
|
|
ipix = np.round(pix[0]).astype(int) |
299
|
|
|
theta, phi = hp.pix2ang(self.nside, ipix, nest=self.nest) |
300
|
|
|
coords = (np.degrees(phi), np.degrees(np.pi / 2.0 - theta)) |
301
|
|
|
|
302
|
|
|
return coords |
303
|
|
|
|
304
|
|
|
def pix_to_idx(self, pix, clip=False): |
305
|
|
|
# FIXME: Look for better method to clip HPX indices |
306
|
|
|
# TODO: copy idx to avoid modifying input pix? |
307
|
|
|
# pix_tuple_to_idx seems to always make a copy!? |
308
|
|
|
idx = pix_tuple_to_idx(pix) |
309
|
|
|
idx_local = self.global_to_local(idx) |
310
|
|
|
for i, _ in enumerate(idx): |
311
|
|
|
|
312
|
|
|
if clip: |
313
|
|
|
if i > 0: |
314
|
|
|
np.clip(idx[i], 0, self.axes[i - 1].nbin - 1, out=idx[i]) |
315
|
|
|
else: |
316
|
|
|
np.clip(idx[i], 0, None, out=idx[i]) |
317
|
|
|
else: |
318
|
|
|
if i > 0: |
319
|
|
|
mask = (idx[i] < 0) | (idx[i] >= self.axes[i - 1].nbin) |
320
|
|
|
np.putmask(idx[i], mask, -1) |
321
|
|
|
else: |
322
|
|
|
mask = (idx_local[i] < 0) | (idx[i] < 0) |
323
|
|
|
np.putmask(idx[i], mask, -1) |
324
|
|
|
|
325
|
|
|
return tuple(idx) |
326
|
|
|
|
327
|
|
|
@property |
328
|
|
|
def axes(self): |
329
|
|
|
"""List of non-spatial axes.""" |
330
|
|
|
return self._axes |
331
|
|
|
|
332
|
|
|
@property |
333
|
|
|
def axes_names(self): |
334
|
|
|
"""All axes names""" |
335
|
|
|
return ["skycoord"] + self.axes.names |
336
|
|
|
|
337
|
|
|
@property |
338
|
|
|
def shape_axes(self): |
339
|
|
|
"""Shape of non-spatial axes.""" |
340
|
|
|
return self.axes.shape |
341
|
|
|
|
342
|
|
|
@property |
343
|
|
|
def data_shape(self): |
344
|
|
|
"""Shape of the Numpy data array matching this geometry.""" |
345
|
|
|
npix_shape = tuple([np.max(self.npix)]) |
346
|
|
|
return (npix_shape + self.axes.shape)[::-1] |
347
|
|
|
|
348
|
|
|
@property |
349
|
|
|
def data_shape_axes(self): |
350
|
|
|
"""Shape of data of the non-spatial axes and unit spatial axes.""" |
351
|
|
|
return self.axes.shape[::-1] + (1,) |
352
|
|
|
|
353
|
|
|
@property |
354
|
|
|
def ndim(self): |
355
|
|
|
"""Number of dimensions (int).""" |
356
|
|
|
return len(self._axes) + 2 |
357
|
|
|
|
358
|
|
|
@property |
359
|
|
|
def ordering(self): |
360
|
|
|
"""HEALPix ordering ('NESTED' or 'RING').""" |
361
|
|
|
return "NESTED" if self.nest else "RING" |
362
|
|
|
|
363
|
|
|
@property |
364
|
|
|
def nside(self): |
365
|
|
|
"""NSIDE in each band.""" |
366
|
|
|
return self._nside |
367
|
|
|
|
368
|
|
|
@property |
369
|
|
|
def order(self): |
370
|
|
|
"""ORDER in each band (``NSIDE = 2 ** ORDER``). |
371
|
|
|
|
372
|
|
|
Set to -1 for bands with NSIDE that is not a power of 2. |
373
|
|
|
""" |
374
|
|
|
return nside_to_order(self.nside) |
375
|
|
|
|
376
|
|
|
@property |
377
|
|
|
def nest(self): |
378
|
|
|
"""Is HEALPix order nested? (bool).""" |
379
|
|
|
return self._nest |
380
|
|
|
|
381
|
|
|
@property |
382
|
|
|
def npix(self): |
383
|
|
|
"""Number of pixels in each band. |
384
|
|
|
|
385
|
|
|
For partial-sky geometries this can |
386
|
|
|
be less than the number of pixels for the band NSIDE. |
387
|
|
|
""" |
388
|
|
|
return self._npix |
389
|
|
|
|
390
|
|
|
@property |
391
|
|
|
def npix_max(self): |
392
|
|
|
"""Max. number of pixels""" |
393
|
|
|
maxpix = 12 * self.nside**2 |
394
|
|
|
return maxpix * np.ones(self.shape_axes, dtype=int) |
395
|
|
|
|
396
|
|
|
@property |
397
|
|
|
def frame(self): |
398
|
|
|
return self._frame |
399
|
|
|
|
400
|
|
|
@property |
401
|
|
|
def projection(self): |
402
|
|
|
"""Map projection.""" |
403
|
|
|
return "HPX" |
404
|
|
|
|
405
|
|
|
@property |
406
|
|
|
def region(self): |
407
|
|
|
"""Region string.""" |
408
|
|
|
return self._region |
409
|
|
|
|
410
|
|
|
@property |
411
|
|
|
def is_allsky(self): |
412
|
|
|
"""Flag for all-sky maps.""" |
413
|
|
|
return self._region is None |
414
|
|
|
|
415
|
|
|
@property |
416
|
|
|
def is_regular(self): |
417
|
|
|
"""Flag identifying whether this geometry is regular in non-spatial dimensions. |
418
|
|
|
|
419
|
|
|
False for multi-resolution or irregular geometries. |
420
|
|
|
If true all image planes have the same pixel geometry. |
421
|
|
|
""" |
422
|
|
|
if self.nside.size > 1 or self.region == "explicit": |
423
|
|
|
return False |
424
|
|
|
else: |
425
|
|
|
return True |
426
|
|
|
|
427
|
|
|
@property |
428
|
|
|
def center_coord(self): |
429
|
|
|
"""Map coordinates of the center of the geometry (tuple).""" |
430
|
|
|
lon, lat, frame = skycoord_to_lonlat(self.center_skydir) |
431
|
|
|
return tuple([lon, lat]) + self.axes.center_coord |
432
|
|
|
|
433
|
|
|
@property |
434
|
|
|
def center_pix(self): |
435
|
|
|
"""Pixel coordinates of the center of the geometry (tuple).""" |
436
|
|
|
return self.coord_to_pix(self.center_coord) |
437
|
|
|
|
438
|
|
|
@property |
439
|
|
|
def center_skydir(self): |
440
|
|
|
"""Sky coordinate of the center of the geometry. |
441
|
|
|
|
442
|
|
|
Returns |
443
|
|
|
------- |
444
|
|
|
center : `~astropy.coordinates.SkyCoord` |
445
|
|
|
Center position |
446
|
|
|
""" |
447
|
|
|
# TODO: simplify |
448
|
|
|
import healpy as hp |
449
|
|
|
|
450
|
|
|
if self.is_allsky: |
451
|
|
|
lon, lat = 0.0, 0.0 |
452
|
|
|
elif self.region == "explicit": |
453
|
|
|
idx = unravel_hpx_index(self._ipix, self.npix_max) |
454
|
|
|
nside = self._get_nside(idx) |
455
|
|
|
vec = hp.pix2vec(nside, idx[0], nest=self.nest) |
456
|
|
|
vec = np.array([np.mean(t) for t in vec]) |
457
|
|
|
lonlat = hp.vec2ang(vec, lonlat=True) |
458
|
|
|
lon, lat = lonlat[0], lonlat[1] |
459
|
|
|
else: |
460
|
|
|
tokens = parse_hpxregion(self.region) |
461
|
|
|
if tokens[0] in ["DISK", "DISK_INC"]: |
462
|
|
|
lon, lat = float(tokens[1]), float(tokens[2]) |
463
|
|
|
elif tokens[0] == "HPX_PIXEL": |
464
|
|
|
nside_pix = int(tokens[2]) |
465
|
|
|
ipix_pix = int(tokens[3]) |
466
|
|
|
if tokens[1] == "NESTED": |
467
|
|
|
nest_pix = True |
468
|
|
|
elif tokens[1] == "RING": |
469
|
|
|
nest_pix = False |
470
|
|
|
else: |
471
|
|
|
raise ValueError(f"Invalid ordering scheme: {tokens[1]!r}") |
472
|
|
|
theta, phi = hp.pix2ang(nside_pix, ipix_pix, nest_pix) |
473
|
|
|
lat = np.degrees((np.pi / 2) - theta) |
474
|
|
|
lon = np.degrees(phi) |
475
|
|
|
|
476
|
|
|
return SkyCoord(lon, lat, frame=self.frame, unit="deg") |
|
|
|
|
477
|
|
|
|
478
|
|
|
@property |
479
|
|
|
def pixel_scales(self): |
480
|
|
|
self.angle_ = """Pixel scale. |
481
|
|
|
|
482
|
|
|
Returns |
483
|
|
|
------- |
484
|
|
|
angle: `~astropy.coordinates.Angle` |
485
|
|
|
""" |
486
|
|
|
return get_pix_size_from_nside(self.nside) * u.deg |
487
|
|
|
|
488
|
|
|
def interp_weights(self, coords, idxs=None): |
489
|
|
|
"""Get interpolation weights for given coords |
490
|
|
|
|
491
|
|
|
Parameters |
492
|
|
|
---------- |
493
|
|
|
coords : `MapCoord` or dict |
494
|
|
|
Input coordinates |
495
|
|
|
idxs : `~numpy.ndarray` |
496
|
|
|
Indices for non-spatial axes. |
497
|
|
|
|
498
|
|
|
Returns |
499
|
|
|
------- |
500
|
|
|
weights : `~numpy.ndarray` |
501
|
|
|
Interpolation weights |
502
|
|
|
""" |
503
|
|
|
import healpy as hp |
504
|
|
|
|
505
|
|
|
coords = MapCoord.create(coords, frame=self.frame).broadcasted |
506
|
|
|
|
507
|
|
|
if idxs is None: |
508
|
|
|
idxs = self.coord_to_idx(coords, clip=True)[1:] |
509
|
|
|
|
510
|
|
|
theta, phi = coords.theta, coords.phi |
511
|
|
|
|
512
|
|
|
m = ~np.isfinite(theta) |
513
|
|
|
theta[m] = 0 |
514
|
|
|
phi[m] = 0 |
515
|
|
|
|
516
|
|
|
if not self.is_regular: |
517
|
|
|
nside = self.nside[tuple(idxs)] |
518
|
|
|
else: |
519
|
|
|
nside = self.nside |
520
|
|
|
|
521
|
|
|
pix, wts = hp.get_interp_weights(nside, theta, phi, nest=self.nest) |
522
|
|
|
wts[:, m] = 0 |
523
|
|
|
pix[:, m] = INVALID_INDEX.int |
524
|
|
|
|
525
|
|
|
if not self.is_regular: |
526
|
|
|
pix_local = [self.global_to_local([pix] + list(idxs))[0]] |
527
|
|
|
else: |
528
|
|
|
pix_local = [self.global_to_local(pix, ravel=True)] |
529
|
|
|
|
530
|
|
|
# If a pixel lies outside of the geometry set its index to the center pixel |
531
|
|
|
m = pix_local[0] == INVALID_INDEX.int |
532
|
|
|
if m.any(): |
533
|
|
|
coords_ctr = [coords.lon, coords.lat] |
534
|
|
|
coords_ctr += [ax.pix_to_coord(t) for ax, t in zip(self.axes, idxs)] |
535
|
|
|
idx_ctr = self.coord_to_idx(coords_ctr) |
536
|
|
|
idx_ctr = self.global_to_local(idx_ctr) |
537
|
|
|
pix_local[0][m] = (idx_ctr[0] * np.ones(pix.shape, dtype=int))[m] |
538
|
|
|
|
539
|
|
|
pix_local += [np.broadcast_to(t, pix_local[0].shape) for t in idxs] |
540
|
|
|
return pix_local, wts |
541
|
|
|
|
542
|
|
|
@property |
543
|
|
|
def ipix(self): |
544
|
|
|
"""HEALPIX pixel and band indices for every pixel in the map.""" |
545
|
|
|
return self.get_idx() |
546
|
|
|
|
547
|
|
|
def is_aligned(self, other): |
548
|
|
|
"""Check if HEALPIx geoms and extra axes are aligned. |
549
|
|
|
|
550
|
|
|
Parameters |
551
|
|
|
---------- |
552
|
|
|
other : `HpxGeom` |
553
|
|
|
Other geom. |
554
|
|
|
|
555
|
|
|
Returns |
556
|
|
|
------- |
557
|
|
|
aligned : bool |
558
|
|
|
Whether geometries are aligned |
559
|
|
|
""" |
560
|
|
|
for axis, otheraxis in zip(self.axes, other.axes): |
561
|
|
|
if axis != otheraxis: |
562
|
|
|
return False |
563
|
|
|
|
564
|
|
|
if not self.nside == other.nside: |
565
|
|
|
return False |
566
|
|
|
elif not self.frame == other.frame: |
567
|
|
|
return False |
568
|
|
|
elif not self.nest == other.nest: |
569
|
|
|
return False |
570
|
|
|
else: |
571
|
|
|
return True |
572
|
|
|
|
573
|
|
|
def to_nside(self, nside): |
574
|
|
|
"""Upgrade or downgrade the reoslution to a given nside |
575
|
|
|
|
576
|
|
|
Parameters |
577
|
|
|
---------- |
578
|
|
|
nside : int |
579
|
|
|
Nside |
580
|
|
|
|
581
|
|
|
Returns |
582
|
|
|
------- |
583
|
|
|
geom : `~HpxGeom` |
584
|
|
|
A HEALPix geometry object. |
585
|
|
|
""" |
586
|
|
|
if not self.is_regular: |
587
|
|
|
raise ValueError("Upgrade and degrade only implemented for standard maps") |
588
|
|
|
|
589
|
|
|
axes = copy.deepcopy(self.axes) |
590
|
|
|
return self.__class__( |
591
|
|
|
nside=nside, nest=self.nest, frame=self.frame, region=self.region, axes=axes |
592
|
|
|
) |
593
|
|
|
|
594
|
|
|
def to_binsz(self, binsz): |
595
|
|
|
"""Change pixel size of the geometry. |
596
|
|
|
|
597
|
|
|
Parameters |
598
|
|
|
---------- |
599
|
|
|
binsz : float or `~astropy.units.Quantity` |
600
|
|
|
New pixel size. A float is assumed to be in degree. |
601
|
|
|
|
602
|
|
|
Returns |
603
|
|
|
------- |
604
|
|
|
geom : `WcsGeom` |
605
|
|
|
Geometry with new pixel size. |
606
|
|
|
""" |
607
|
|
|
binsz = u.Quantity(binsz, "deg").value |
608
|
|
|
|
609
|
|
|
if self.is_allsky: |
610
|
|
|
return self.create( |
611
|
|
|
binsz=binsz, |
612
|
|
|
frame=self.frame, |
613
|
|
|
axes=copy.deepcopy(self.axes), |
614
|
|
|
) |
615
|
|
|
else: |
616
|
|
|
return self.create( |
617
|
|
|
skydir=self.center_skydir, |
618
|
|
|
binsz=binsz, |
619
|
|
|
width=self.width.to_value("deg"), |
620
|
|
|
frame=self.frame, |
621
|
|
|
axes=copy.deepcopy(self.axes), |
622
|
|
|
) |
623
|
|
|
|
624
|
|
|
def separation(self, center): |
625
|
|
|
"""Compute sky separation wrt a given center. |
626
|
|
|
|
627
|
|
|
Parameters |
628
|
|
|
---------- |
629
|
|
|
center : `~astropy.coordinates.SkyCoord` |
630
|
|
|
Center position |
631
|
|
|
|
632
|
|
|
Returns |
633
|
|
|
------- |
634
|
|
|
separation : `~astropy.coordinates.Angle` |
635
|
|
|
Separation angle array (1D) |
636
|
|
|
""" |
637
|
|
|
coord = self.to_image().get_coord() |
638
|
|
|
return center.separation(coord.skycoord) |
639
|
|
|
|
640
|
|
|
def to_swapped(self): |
641
|
|
|
"""Geometry copy with swapped ORDERING (NEST->RING or vice versa). |
642
|
|
|
|
643
|
|
|
Returns |
644
|
|
|
------- |
645
|
|
|
geom : `~HpxGeom` |
646
|
|
|
A HEALPix geometry object. |
647
|
|
|
""" |
648
|
|
|
axes = copy.deepcopy(self.axes) |
649
|
|
|
return self.__class__( |
650
|
|
|
self.nside, |
651
|
|
|
not self.nest, |
652
|
|
|
frame=self.frame, |
653
|
|
|
region=self.region, |
654
|
|
|
axes=axes, |
655
|
|
|
) |
656
|
|
|
|
657
|
|
|
def to_image(self): |
658
|
|
|
return self.__class__( |
659
|
|
|
np.max(self.nside), self.nest, frame=self.frame, region=self.region |
660
|
|
|
) |
661
|
|
|
|
662
|
|
|
def to_cube(self, axes): |
663
|
|
|
axes = copy.deepcopy(self.axes) + axes |
664
|
|
|
return self.__class__( |
665
|
|
|
np.max(self.nside), |
666
|
|
|
self.nest, |
667
|
|
|
frame=self.frame, |
668
|
|
|
region=self.region, |
669
|
|
|
axes=axes, |
670
|
|
|
) |
671
|
|
|
|
672
|
|
|
def _get_neighbors(self, idx): |
673
|
|
|
import healpy as hp |
674
|
|
|
|
675
|
|
|
nside = self._get_nside(idx) |
676
|
|
|
idx_nb = (hp.get_all_neighbours(nside, idx[0], nest=self.nest),) |
677
|
|
|
idx_nb += tuple([t[None, ...] * np.ones_like(idx_nb[0]) for t in idx[1:]]) |
678
|
|
|
|
679
|
|
|
return idx_nb |
680
|
|
|
|
681
|
|
|
def _pad_spatial(self, pad_width): |
682
|
|
|
if self.is_allsky: |
683
|
|
|
raise ValueError("Cannot pad an all-sky map.") |
684
|
|
|
|
685
|
|
|
idx = self.get_idx(flat=True) |
686
|
|
|
idx_r = ravel_hpx_index(idx, self.npix_max) |
687
|
|
|
|
688
|
|
|
# TODO: Pre-filter indices to find those close to the edge |
689
|
|
|
idx_nb = self._get_neighbors(idx) |
690
|
|
|
idx_nb = ravel_hpx_index(idx_nb, self.npix_max) |
691
|
|
|
|
692
|
|
|
for _ in range(pad_width): |
693
|
|
|
mask_edge = np.isin(idx_nb, idx_r, invert=True) |
694
|
|
|
idx_edge = idx_nb[mask_edge] |
695
|
|
|
idx_edge = np.unique(idx_edge) |
696
|
|
|
idx_r = np.sort(np.concatenate((idx_r, idx_edge))) |
697
|
|
|
idx_nb = unravel_hpx_index(idx_edge, self.npix_max) |
698
|
|
|
idx_nb = self._get_neighbors(idx_nb) |
699
|
|
|
idx_nb = ravel_hpx_index(idx_nb, self.npix_max) |
700
|
|
|
|
701
|
|
|
idx = unravel_hpx_index(idx_r, self.npix_max) |
702
|
|
|
return self.__class__( |
703
|
|
|
self.nside.copy(), |
704
|
|
|
self.nest, |
705
|
|
|
frame=self.frame, |
706
|
|
|
region=idx, |
707
|
|
|
axes=copy.deepcopy(self.axes), |
708
|
|
|
) |
709
|
|
|
|
710
|
|
|
def crop(self, crop_width): |
711
|
|
|
if self.is_allsky: |
712
|
|
|
raise ValueError("Cannot crop an all-sky map.") |
713
|
|
|
|
714
|
|
|
idx = self.get_idx(flat=True) |
715
|
|
|
idx_r = ravel_hpx_index(idx, self.npix_max) |
716
|
|
|
|
717
|
|
|
# TODO: Pre-filter indices to find those close to the edge |
718
|
|
|
idx_nb = self._get_neighbors(idx) |
719
|
|
|
idx_nb = ravel_hpx_index(idx_nb, self.npix_max) |
720
|
|
|
|
721
|
|
|
for _ in range(crop_width): |
722
|
|
|
# Mask of pixels that have at least one neighbor not |
723
|
|
|
# contained in the geometry |
724
|
|
|
mask_edge = np.any(np.isin(idx_nb, idx_r, invert=True), axis=0) |
725
|
|
|
idx_r = idx_r[~mask_edge] |
726
|
|
|
idx_nb = idx_nb[:, ~mask_edge] |
727
|
|
|
|
728
|
|
|
idx = unravel_hpx_index(idx_r, self.npix_max) |
729
|
|
|
return self.__class__( |
730
|
|
|
self.nside.copy(), |
731
|
|
|
self.nest, |
732
|
|
|
frame=self.frame, |
733
|
|
|
region=idx, |
734
|
|
|
axes=copy.deepcopy(self.axes), |
735
|
|
|
) |
736
|
|
|
|
737
|
|
|
def upsample(self, factor): |
738
|
|
|
if not is_power2(factor): |
739
|
|
|
raise ValueError("Upsample factor must be a power of 2.") |
740
|
|
|
|
741
|
|
|
if self.is_allsky: |
742
|
|
|
return self.__class__( |
743
|
|
|
self.nside * factor, |
744
|
|
|
self.nest, |
745
|
|
|
frame=self.frame, |
746
|
|
|
region=self.region, |
747
|
|
|
axes=copy.deepcopy(self.axes), |
748
|
|
|
) |
749
|
|
|
|
750
|
|
|
idx = list(self.get_idx(flat=True)) |
751
|
|
|
nside = self._get_nside(idx) |
752
|
|
|
|
753
|
|
|
idx_new = get_subpixels(idx[0], nside, nside * factor, nest=self.nest) |
754
|
|
|
for i in range(1, len(idx)): |
755
|
|
|
idx[i] = idx[i][..., None] * np.ones(idx_new.shape, dtype=int) |
756
|
|
|
|
757
|
|
|
idx[0] = idx_new |
758
|
|
|
return self.__class__( |
759
|
|
|
self.nside * factor, |
760
|
|
|
self.nest, |
761
|
|
|
frame=self.frame, |
762
|
|
|
region=tuple(idx), |
763
|
|
|
axes=copy.deepcopy(self.axes), |
764
|
|
|
) |
765
|
|
|
|
766
|
|
|
def downsample(self, factor, axis_name=None): |
767
|
|
|
if not is_power2(factor): |
768
|
|
|
raise ValueError("Downsample factor must be a power of 2.") |
769
|
|
|
if axis_name is not None: |
770
|
|
|
raise ValueError("Currently the only valid axis name is None.") |
771
|
|
|
|
772
|
|
|
if self.is_allsky: |
773
|
|
|
return self.__class__( |
774
|
|
|
self.nside // factor, |
775
|
|
|
self.nest, |
776
|
|
|
frame=self.frame, |
777
|
|
|
region=self.region, |
778
|
|
|
axes=copy.deepcopy(self.axes), |
779
|
|
|
) |
780
|
|
|
|
781
|
|
|
idx = list(self.get_idx(flat=True)) |
782
|
|
|
nside = self._get_nside(idx) |
783
|
|
|
idx_new = get_superpixels(idx[0], nside, nside // factor, nest=self.nest) |
784
|
|
|
idx[0] = idx_new |
785
|
|
|
return self.__class__( |
786
|
|
|
self.nside // factor, |
787
|
|
|
self.nest, |
788
|
|
|
frame=self.frame, |
789
|
|
|
region=tuple(idx), |
790
|
|
|
axes=copy.deepcopy(self.axes), |
791
|
|
|
) |
792
|
|
|
|
793
|
|
|
@classmethod |
794
|
|
|
def create( |
795
|
|
|
cls, |
796
|
|
|
nside=None, |
797
|
|
|
binsz=None, |
798
|
|
|
nest=True, |
799
|
|
|
frame="icrs", |
800
|
|
|
region=None, |
801
|
|
|
axes=None, |
802
|
|
|
skydir=None, |
803
|
|
|
width=None, |
804
|
|
|
): |
805
|
|
|
"""Create an HpxGeom object. |
806
|
|
|
|
807
|
|
|
Parameters |
808
|
|
|
---------- |
809
|
|
|
nside : int or `~numpy.ndarray` |
810
|
|
|
HEALPix NSIDE parameter. This parameter sets the size of |
811
|
|
|
the spatial pixels in the map. If nest is True, nside must be a |
812
|
|
|
power of 2, less than 2**30. |
813
|
|
|
binsz : float or `~numpy.ndarray` |
814
|
|
|
Approximate pixel size in degrees. An NSIDE will be |
815
|
|
|
chosen that corresponds to a pixel size closest to this |
816
|
|
|
value. This option is superseded by nside. |
817
|
|
|
nest : bool |
818
|
|
|
True for HEALPIX "NESTED" indexing scheme, False for "RING" scheme |
819
|
|
|
frame : {"icrs", "galactic"}, optional |
820
|
|
|
Coordinate system, either Galactic ("galactic") or Equatorial ("icrs"). |
821
|
|
|
skydir : tuple or `~astropy.coordinates.SkyCoord` |
822
|
|
|
Sky position of map center. Can be either a SkyCoord |
823
|
|
|
object or a tuple of longitude and latitude in deg in the |
824
|
|
|
coordinate system of the map. |
825
|
|
|
region : str |
826
|
|
|
HPX region string. Allows for partial-sky maps. |
827
|
|
|
width : float |
828
|
|
|
Diameter of the map in degrees. If set the map will |
829
|
|
|
encompass all pixels within a circular region centered on |
830
|
|
|
``skydir``. |
831
|
|
|
axes : list |
832
|
|
|
List of axes for non-spatial dimensions. |
833
|
|
|
|
834
|
|
|
Returns |
835
|
|
|
------- |
836
|
|
|
geom : `~HpxGeom` |
837
|
|
|
A HEALPix geometry object. |
838
|
|
|
|
839
|
|
|
Examples |
840
|
|
|
-------- |
841
|
|
|
>>> from gammapy.maps import HpxGeom, MapAxis |
842
|
|
|
>>> axis = MapAxis.from_bounds(0,1,2) |
843
|
|
|
>>> geom = HpxGeom.create(nside=16) # doctest: +SKIP |
844
|
|
|
>>> geom = HpxGeom.create(binsz=0.1, width=10.0) # doctest: +SKIP |
845
|
|
|
>>> geom = HpxGeom.create(nside=64, width=10.0, axes=[axis]) # doctest: +SKIP |
846
|
|
|
>>> geom = HpxGeom.create(nside=[32,64], width=10.0, axes=[axis]) # doctest: +SKIP |
847
|
|
|
""" |
848
|
|
|
if nside is None and binsz is None: |
849
|
|
|
raise ValueError("Either nside or binsz must be defined.") |
850
|
|
|
|
851
|
|
|
if nside is None and binsz is not None: |
852
|
|
|
nside = get_nside_from_pix_size(binsz) |
853
|
|
|
|
854
|
|
|
if skydir is None: |
855
|
|
|
lon, lat = (0.0, 0.0) |
856
|
|
|
elif isinstance(skydir, tuple): |
857
|
|
|
lon, lat = skydir |
858
|
|
|
elif isinstance(skydir, SkyCoord): |
859
|
|
|
lon, lat, frame = skycoord_to_lonlat(skydir, frame=frame) |
860
|
|
|
else: |
861
|
|
|
raise ValueError(f"Invalid type for skydir: {type(skydir)!r}") |
862
|
|
|
|
863
|
|
|
if region is None and width is not None: |
864
|
|
|
region = f"DISK({lon},{lat},{width/2})" |
865
|
|
|
|
866
|
|
|
return cls(nside, nest=nest, frame=frame, region=region, axes=axes) |
867
|
|
|
|
868
|
|
|
@classmethod |
869
|
|
|
def from_header(cls, header, hdu_bands=None, format=None): |
870
|
|
|
"""Create an HPX object from a FITS header. |
871
|
|
|
|
872
|
|
|
Parameters |
873
|
|
|
---------- |
874
|
|
|
header : `~astropy.io.fits.Header` |
875
|
|
|
The FITS header |
876
|
|
|
hdu_bands : `~astropy.io.fits.BinTableHDU` |
877
|
|
|
The BANDS table HDU. |
878
|
|
|
format : str, optional |
879
|
|
|
FITS convention. If None the format is guessed. The following |
880
|
|
|
formats are supported: |
881
|
|
|
|
882
|
|
|
- "gadf" |
883
|
|
|
- "fgst-ccube" |
884
|
|
|
- "fgst-ltcube" |
885
|
|
|
- "fgst-bexpcube" |
886
|
|
|
- "fgst-srcmap" |
887
|
|
|
- "fgst-template" |
888
|
|
|
- "fgst-srcmap-sparse" |
889
|
|
|
- "galprop" |
890
|
|
|
- "galprop2" |
891
|
|
|
- "healpy" |
892
|
|
|
|
893
|
|
|
Returns |
894
|
|
|
------- |
895
|
|
|
hpx : `~HpxGeom` |
896
|
|
|
HEALPix geometry. |
897
|
|
|
""" |
898
|
|
|
if format is None: |
899
|
|
|
format = HpxConv.identify_hpx_format(header) |
900
|
|
|
|
901
|
|
|
conv = HPX_FITS_CONVENTIONS[format] |
902
|
|
|
|
903
|
|
|
axes = MapAxes.from_table_hdu(hdu_bands, format=format) |
904
|
|
|
|
905
|
|
|
if header["PIXTYPE"] != "HEALPIX": |
906
|
|
|
raise ValueError( |
907
|
|
|
f"Invalid header PIXTYPE: {header['PIXTYPE']} (must be HEALPIX)" |
908
|
|
|
) |
909
|
|
|
|
910
|
|
|
if header["ORDERING"] == "RING": |
911
|
|
|
nest = False |
912
|
|
|
elif header["ORDERING"] == "NESTED": |
913
|
|
|
nest = True |
914
|
|
|
else: |
915
|
|
|
raise ValueError( |
916
|
|
|
f"Invalid header ORDERING: {header['ORDERING']} (must be RING or NESTED)" |
917
|
|
|
) |
918
|
|
|
|
919
|
|
|
if hdu_bands is not None and "NSIDE" in hdu_bands.columns.names: |
920
|
|
|
nside = hdu_bands.data.field("NSIDE").reshape(axes.shape).astype(int) |
921
|
|
|
elif "NSIDE" in header: |
922
|
|
|
nside = header["NSIDE"] |
923
|
|
|
elif "ORDER" in header: |
924
|
|
|
nside = 2 ** header["ORDER"] |
925
|
|
|
else: |
926
|
|
|
raise ValueError("Failed to extract NSIDE or ORDER.") |
927
|
|
|
|
928
|
|
|
try: |
929
|
|
|
frame = coordsys_to_frame(header[conv.frame]) |
930
|
|
|
except KeyError: |
931
|
|
|
frame = header.get("COORDSYS", "icrs") |
932
|
|
|
|
933
|
|
|
try: |
934
|
|
|
region = header["HPX_REG"] |
935
|
|
|
except KeyError: |
936
|
|
|
try: |
937
|
|
|
region = header["HPXREGION"] |
938
|
|
|
except KeyError: |
939
|
|
|
region = None |
940
|
|
|
|
941
|
|
|
return cls(nside, nest, frame=frame, region=region, axes=axes) |
942
|
|
|
|
943
|
|
|
@classmethod |
944
|
|
|
def from_hdu(cls, hdu, hdu_bands=None): |
945
|
|
|
"""Create an HPX object from a BinTable HDU. |
946
|
|
|
|
947
|
|
|
Parameters |
948
|
|
|
---------- |
949
|
|
|
hdu : `~astropy.io.fits.BinTableHDU` |
950
|
|
|
The FITS HDU |
951
|
|
|
hdu_bands : `~astropy.io.fits.BinTableHDU` |
952
|
|
|
The BANDS table HDU |
953
|
|
|
|
954
|
|
|
Returns |
955
|
|
|
------- |
956
|
|
|
hpx : `~HpxGeom` |
957
|
|
|
HEALPix geometry. |
958
|
|
|
""" |
959
|
|
|
# FIXME: Need correct handling of IMPLICIT and EXPLICIT maps |
960
|
|
|
|
961
|
|
|
# if HPX region is not defined then geometry is defined by |
962
|
|
|
# the set of all pixels in the table |
963
|
|
|
if "HPX_REG" not in hdu.header: |
964
|
|
|
pix = (hdu.data.field("PIX"), hdu.data.field("CHANNEL")) |
965
|
|
|
else: |
966
|
|
|
pix = None |
967
|
|
|
|
968
|
|
|
return cls.from_header(hdu.header, hdu_bands=hdu_bands, pix=pix) |
969
|
|
|
|
970
|
|
|
def to_header(self, format="gadf", **kwargs): |
971
|
|
|
"""Build and return FITS header for this HEALPIX map.""" |
972
|
|
|
header = fits.Header() |
973
|
|
|
format = kwargs.get("format", HPX_FITS_CONVENTIONS[format]) |
974
|
|
|
|
975
|
|
|
# FIXME: For some sparse maps we may want to allow EXPLICIT |
976
|
|
|
# with an empty region string |
977
|
|
|
indxschm = kwargs.get("indxschm", None) |
978
|
|
|
|
979
|
|
|
if indxschm is None: |
980
|
|
|
if self._region is None: |
981
|
|
|
indxschm = "IMPLICIT" |
982
|
|
|
elif self.is_regular == 1: |
983
|
|
|
indxschm = "EXPLICIT" |
984
|
|
|
else: |
985
|
|
|
indxschm = "LOCAL" |
986
|
|
|
|
987
|
|
|
if "FGST" in format.convname.upper(): |
988
|
|
|
header["TELESCOP"] = "GLAST" |
989
|
|
|
header["INSTRUME"] = "LAT" |
990
|
|
|
|
991
|
|
|
header[format.frame] = frame_to_coordsys(self.frame) |
992
|
|
|
header["PIXTYPE"] = "HEALPIX" |
993
|
|
|
header["ORDERING"] = self.ordering |
994
|
|
|
header["INDXSCHM"] = indxschm |
995
|
|
|
header["ORDER"] = np.max(self.order) |
996
|
|
|
header["NSIDE"] = np.max(self.nside) |
997
|
|
|
header["FIRSTPIX"] = 0 |
998
|
|
|
header["LASTPIX"] = np.max(self.npix_max) - 1 |
999
|
|
|
header["HPX_CONV"] = format.convname.upper() |
1000
|
|
|
|
1001
|
|
|
if self.frame == "icrs": |
1002
|
|
|
header["EQUINOX"] = (2000.0, "Equinox of RA & DEC specifications") |
1003
|
|
|
|
1004
|
|
|
if self.region: |
1005
|
|
|
header["HPX_REG"] = self._region |
1006
|
|
|
|
1007
|
|
|
return header |
1008
|
|
|
|
1009
|
|
|
def _make_bands_cols(self): |
1010
|
|
|
cols = [] |
1011
|
|
|
if self.nside.size > 1: |
1012
|
|
|
cols += [fits.Column("NSIDE", "I", array=np.ravel(self.nside))] |
1013
|
|
|
return cols |
1014
|
|
|
|
1015
|
|
|
@staticmethod |
1016
|
|
|
def get_index_list(nside, nest, region): |
1017
|
|
|
"""Get list of pixels indices for all the pixels in a region. |
1018
|
|
|
|
1019
|
|
|
Parameters |
1020
|
|
|
---------- |
1021
|
|
|
nside : int |
1022
|
|
|
HEALPIX nside parameter |
1023
|
|
|
nest : bool |
1024
|
|
|
True for 'NESTED', False = 'RING' |
1025
|
|
|
region : str |
1026
|
|
|
HEALPIX region string |
1027
|
|
|
|
1028
|
|
|
Returns |
1029
|
|
|
------- |
1030
|
|
|
ilist : `~numpy.ndarray` |
1031
|
|
|
List of pixel indices. |
1032
|
|
|
""" |
1033
|
|
|
import healpy as hp |
1034
|
|
|
|
1035
|
|
|
# TODO: this should return something more friendly than a tuple |
1036
|
|
|
# e.g. a namedtuple or a dict |
1037
|
|
|
tokens = parse_hpxregion(region) |
1038
|
|
|
|
1039
|
|
|
reg_type = tokens[0] |
1040
|
|
|
if reg_type == "DISK": |
1041
|
|
|
lon, lat = float(tokens[1]), float(tokens[2]) |
1042
|
|
|
radius = np.radians(float(tokens[3])) |
1043
|
|
|
vec = coords_to_vec(lon, lat)[0] |
1044
|
|
|
ilist = hp.query_disc(nside, vec, radius, inclusive=False, nest=nest) |
1045
|
|
|
elif reg_type == "DISK_INC": |
1046
|
|
|
lon, lat = float(tokens[1]), float(tokens[2]) |
1047
|
|
|
radius = np.radians(float(tokens[3])) |
1048
|
|
|
vec = coords_to_vec(lon, lat)[0] |
1049
|
|
|
fact = int(tokens[4]) |
1050
|
|
|
ilist = hp.query_disc( |
1051
|
|
|
nside, vec, radius, inclusive=True, nest=nest, fact=fact |
1052
|
|
|
) |
1053
|
|
|
elif reg_type == "HPX_PIXEL": |
1054
|
|
|
nside_pix = int(tokens[2]) |
1055
|
|
|
if tokens[1] == "NESTED": |
1056
|
|
|
ipix_ring = hp.nest2ring(nside_pix, int(tokens[3])) |
1057
|
|
|
elif tokens[1] == "RING": |
1058
|
|
|
ipix_ring = int(tokens[3]) |
1059
|
|
|
else: |
1060
|
|
|
raise ValueError(f"Invalid ordering scheme: {tokens[1]!r}") |
1061
|
|
|
ilist = match_hpx_pix(nside, nest, nside_pix, ipix_ring) |
1062
|
|
|
else: |
1063
|
|
|
raise ValueError(f"Invalid region type: {reg_type!r}") |
1064
|
|
|
|
1065
|
|
|
return ilist |
1066
|
|
|
|
1067
|
|
|
@property |
1068
|
|
|
def width(self): |
1069
|
|
|
"""Width of the map""" |
1070
|
|
|
# TODO: simplify |
1071
|
|
|
import healpy as hp |
1072
|
|
|
|
1073
|
|
|
if self.is_allsky: |
1074
|
|
|
width = 180.0 |
1075
|
|
|
elif self.region == "explicit": |
1076
|
|
|
idx = unravel_hpx_index(self._ipix, self.npix_max) |
1077
|
|
|
nside = self._get_nside(idx) |
1078
|
|
|
ang = hp.pix2ang(nside, idx[0], nest=self.nest, lonlat=True) |
1079
|
|
|
dirs = SkyCoord(ang[0], ang[1], unit="deg", frame=self.frame) |
1080
|
|
|
width = np.max(dirs.separation(self.center_skydir)) |
1081
|
|
|
else: |
1082
|
|
|
tokens = parse_hpxregion(self.region) |
1083
|
|
|
if tokens[0] in {"DISK", "DISK_INC"}: |
1084
|
|
|
width = float(tokens[3]) |
1085
|
|
|
elif tokens[0] == "HPX_PIXEL": |
1086
|
|
|
pix_size = get_pix_size_from_nside(int(tokens[2])) |
1087
|
|
|
width = 2.0 * pix_size |
1088
|
|
|
|
1089
|
|
|
return u.Quantity(width, "deg") |
|
|
|
|
1090
|
|
|
|
1091
|
|
|
def _get_nside(self, idx): |
1092
|
|
|
if self.nside.size > 1: |
1093
|
|
|
return self.nside[tuple(idx[1:])] |
1094
|
|
|
else: |
1095
|
|
|
return self.nside |
1096
|
|
|
|
1097
|
|
|
def to_wcs_geom(self, proj="AIT", oversample=2, width_pix=None): |
1098
|
|
|
"""Make a WCS projection appropriate for this HPX pixelization. |
1099
|
|
|
|
1100
|
|
|
Parameters |
1101
|
|
|
---------- |
1102
|
|
|
proj : str |
1103
|
|
|
Projection type of WCS geometry. |
1104
|
|
|
oversample : float |
1105
|
|
|
Oversampling factor for WCS map. This will be the |
1106
|
|
|
approximate ratio of the width of a HPX pixel to a WCS |
1107
|
|
|
pixel. If this parameter is None then the width will be |
1108
|
|
|
set from ``width_pix``. |
1109
|
|
|
width_pix : int |
1110
|
|
|
Width of the WCS geometry in pixels. The pixel size will |
1111
|
|
|
be set to the number of pixels satisfying ``oversample`` |
1112
|
|
|
or ``width_pix`` whichever is smaller. If this parameter |
1113
|
|
|
is None then the width will be set from ``oversample``. |
1114
|
|
|
|
1115
|
|
|
Returns |
1116
|
|
|
------- |
1117
|
|
|
wcs : `~gammapy.maps.WcsGeom` |
1118
|
|
|
WCS geometry |
1119
|
|
|
""" |
1120
|
|
|
from gammapy.maps import WcsGeom |
1121
|
|
|
|
1122
|
|
|
pix_size = get_pix_size_from_nside(self.nside) |
1123
|
|
|
binsz = np.min(pix_size) / oversample |
1124
|
|
|
width = 2.0 * self.width.to_value("deg") + np.max(pix_size) |
1125
|
|
|
|
1126
|
|
|
if width_pix is not None and int(width / binsz) > width_pix: |
1127
|
|
|
binsz = width / width_pix |
1128
|
|
|
|
1129
|
|
|
if width > 90.0: |
1130
|
|
|
width = min(360.0, width), min(180.0, width) |
1131
|
|
|
|
1132
|
|
|
axes = copy.deepcopy(self.axes) |
1133
|
|
|
|
1134
|
|
|
return WcsGeom.create( |
1135
|
|
|
width=width, |
1136
|
|
|
binsz=binsz, |
1137
|
|
|
frame=self.frame, |
1138
|
|
|
axes=axes, |
1139
|
|
|
skydir=self.center_skydir, |
1140
|
|
|
proj=proj, |
1141
|
|
|
) |
1142
|
|
|
|
1143
|
|
|
def to_wcs_tiles(self, nside_tiles=4, margin="0 deg"): |
1144
|
|
|
"""Create WCS tiles geometries from HPX geometry with given nside. |
1145
|
|
|
|
1146
|
|
|
The HEALPix geom is divide into superpixels defined by nside_tiles, |
1147
|
|
|
which are then represented by a WCS geometry using a tangential |
1148
|
|
|
projection. The number of WCS tiles is given by the number of pixels |
1149
|
|
|
for the given nside_tiles. |
1150
|
|
|
|
1151
|
|
|
Parameters |
1152
|
|
|
---------- |
1153
|
|
|
nside_tiles : int |
1154
|
|
|
Nside for super pixel tiles. Usually nsi |
1155
|
|
|
margin : Angle |
1156
|
|
|
Width margin of the wcs tile |
1157
|
|
|
|
1158
|
|
|
Returns |
1159
|
|
|
------- |
1160
|
|
|
wcs_tiles : list |
1161
|
|
|
List of WCS tile geoms. |
1162
|
|
|
""" |
1163
|
|
|
import healpy as hp |
1164
|
|
|
from gammapy.maps import WcsGeom |
1165
|
|
|
|
1166
|
|
|
margin = u.Quantity(margin) |
1167
|
|
|
|
1168
|
|
|
if nside_tiles >= self.nside: |
1169
|
|
|
raise ValueError(f"nside_tiles must be < {self.nside}") |
1170
|
|
|
|
1171
|
|
|
if not self.is_allsky: |
1172
|
|
|
raise ValueError("to_wcs_tiles() is only supported for all sky geoms") |
1173
|
|
|
|
1174
|
|
|
binsz = np.degrees(hp.nside2resol(self.nside)) * u.deg |
1175
|
|
|
|
1176
|
|
|
hpx = self.to_image().to_nside(nside=nside_tiles) |
1177
|
|
|
wcs_tiles = [] |
1178
|
|
|
|
1179
|
|
|
for pix in range(int(hpx.npix)): |
1180
|
|
|
skydir = hpx.pix_to_coord([pix]) |
1181
|
|
|
vtx = hp.boundaries(nside=hpx.nside, pix=pix, nest=hpx.nest, step=1) |
1182
|
|
|
|
1183
|
|
|
lon, lat = hp.vec2ang(vtx.T, lonlat=True) |
1184
|
|
|
boundaries = SkyCoord(lon * u.deg, lat * u.deg, frame=hpx.frame) |
1185
|
|
|
|
1186
|
|
|
# Compute maximum separation between all pairs of boundaries and take it |
1187
|
|
|
# as width |
1188
|
|
|
width = boundaries.separation(boundaries[:, np.newaxis]).max() |
1189
|
|
|
|
1190
|
|
|
wcs_tile_geom = WcsGeom.create( |
1191
|
|
|
skydir=(float(skydir[0]), float(skydir[1])), |
1192
|
|
|
width=width + margin, |
1193
|
|
|
binsz=binsz, |
1194
|
|
|
frame=hpx.frame, |
1195
|
|
|
proj="TAN", |
1196
|
|
|
axes=self.axes, |
1197
|
|
|
) |
1198
|
|
|
wcs_tiles.append(wcs_tile_geom) |
1199
|
|
|
|
1200
|
|
|
return wcs_tiles |
1201
|
|
|
|
1202
|
|
|
def get_idx( |
1203
|
|
|
self, |
1204
|
|
|
idx=None, |
1205
|
|
|
local=False, |
1206
|
|
|
flat=False, |
1207
|
|
|
sparse=False, |
1208
|
|
|
mode="center", |
1209
|
|
|
axis_name=None, |
1210
|
|
|
): |
1211
|
|
|
# TODO: simplify this!!! |
1212
|
|
|
if idx is not None and np.any(np.array(idx) >= np.array(self.shape_axes)): |
1213
|
|
|
raise ValueError(f"Image index out of range: {idx!r}") |
1214
|
|
|
|
1215
|
|
|
# Regular all- and partial-sky maps |
1216
|
|
|
if self.is_regular: |
1217
|
|
|
pix = [np.arange(np.max(self._npix))] |
1218
|
|
|
if idx is None: |
1219
|
|
|
for ax in self.axes: |
1220
|
|
|
if mode == "edges" and ax.name == axis_name: |
1221
|
|
|
pix += [np.arange(-0.5, ax.nbin, dtype=float)] |
1222
|
|
|
else: |
1223
|
|
|
pix += [np.arange(ax.nbin, dtype=int)] |
1224
|
|
|
else: |
1225
|
|
|
pix += [t for t in idx] |
1226
|
|
|
|
1227
|
|
|
pix = np.meshgrid(*pix[::-1], indexing="ij", sparse=sparse)[::-1] |
1228
|
|
|
pix = self.local_to_global(pix) |
1229
|
|
|
|
1230
|
|
|
# Non-regular all-sky |
1231
|
|
|
elif self.is_allsky and not self.is_regular: |
1232
|
|
|
|
1233
|
|
|
shape = (np.max(self.npix),) |
1234
|
|
|
if idx is None: |
1235
|
|
|
shape = shape + self.shape_axes |
1236
|
|
|
else: |
1237
|
|
|
shape = shape + (1,) * len(self.axes) |
1238
|
|
|
pix = [np.full(shape, -1, dtype=int) for i in range(1 + len(self.axes))] |
1239
|
|
|
for idx_img in np.ndindex(self.shape_axes): |
1240
|
|
|
|
1241
|
|
|
if idx is not None and idx_img != idx: |
1242
|
|
|
continue |
1243
|
|
|
|
1244
|
|
|
npix = self._npix[idx_img] |
1245
|
|
|
if idx is None: |
1246
|
|
|
s_img = (slice(0, npix),) + idx_img |
1247
|
|
|
else: |
1248
|
|
|
s_img = (slice(0, npix),) + (0,) * len(self.axes) |
1249
|
|
|
|
1250
|
|
|
pix[0][s_img] = np.arange(self._npix[idx_img]) |
1251
|
|
|
for j in range(len(self.axes)): |
1252
|
|
|
pix[j + 1][s_img] = idx_img[j] |
1253
|
|
|
pix = [p.T for p in pix] |
1254
|
|
|
|
1255
|
|
|
# Explicit pixel indices |
1256
|
|
|
else: |
1257
|
|
|
|
1258
|
|
|
if idx is not None: |
1259
|
|
|
npix_sum = np.concatenate(([0], np.cumsum(self._npix))) |
1260
|
|
|
idx_ravel = np.ravel_multi_index(idx, self.shape_axes) |
1261
|
|
|
s = slice(npix_sum[idx_ravel], npix_sum[idx_ravel + 1]) |
1262
|
|
|
else: |
1263
|
|
|
s = slice(None) |
1264
|
|
|
pix_flat = unravel_hpx_index(self._ipix[s], self.npix_max) |
1265
|
|
|
|
1266
|
|
|
shape = (np.max(self.npix),) |
1267
|
|
|
if idx is None: |
1268
|
|
|
shape = shape + self.shape_axes |
1269
|
|
|
else: |
1270
|
|
|
shape = shape + (1,) * len(self.axes) |
1271
|
|
|
pix = [np.full(shape, -1, dtype=int) for _ in range(1 + len(self.axes))] |
1272
|
|
|
|
1273
|
|
|
for idx_img in np.ndindex(self.shape_axes): |
1274
|
|
|
|
1275
|
|
|
if idx is not None and idx_img != idx: |
1276
|
|
|
continue |
1277
|
|
|
|
1278
|
|
|
npix = int(self._npix[idx_img]) |
1279
|
|
|
if idx is None: |
1280
|
|
|
s_img = (slice(0, npix),) + idx_img |
1281
|
|
|
else: |
1282
|
|
|
s_img = (slice(0, npix),) + (0,) * len(self.axes) |
1283
|
|
|
|
1284
|
|
|
if self.axes: |
1285
|
|
|
m = np.all( |
1286
|
|
|
np.stack([pix_flat[i + 1] == t for i, t in enumerate(idx_img)]), |
1287
|
|
|
axis=0, |
1288
|
|
|
) |
1289
|
|
|
pix[0][s_img] = pix_flat[0][m] |
1290
|
|
|
else: |
1291
|
|
|
pix[0][s_img] = pix_flat[0] |
1292
|
|
|
|
1293
|
|
|
for j in range(len(self.axes)): |
1294
|
|
|
pix[j + 1][s_img] = idx_img[j] |
1295
|
|
|
|
1296
|
|
|
pix = [p.T for p in pix] |
1297
|
|
|
|
1298
|
|
|
if local: |
1299
|
|
|
pix = self.global_to_local(pix) |
1300
|
|
|
|
1301
|
|
|
if flat: |
1302
|
|
|
pix = tuple([p[p != INVALID_INDEX.int] for p in pix]) |
1303
|
|
|
|
1304
|
|
|
return pix |
1305
|
|
|
|
1306
|
|
|
def region_mask(self, regions): |
1307
|
|
|
"""Create a mask from a given list of regions |
1308
|
|
|
|
1309
|
|
|
The mask is filled such that a pixel inside the region is filled with |
1310
|
|
|
"True". To invert the mask, e.g. to create a mask with exclusion regions |
1311
|
|
|
the tilde (~) operator can be used (see example below). |
1312
|
|
|
|
1313
|
|
|
Parameters |
1314
|
|
|
---------- |
1315
|
|
|
regions : str, `~regions.Region` or list of `~regions.Region` |
1316
|
|
|
Region or list of regions (pixel or sky regions accepted). |
1317
|
|
|
A region can be defined as a string ind DS9 format as well. |
1318
|
|
|
See http://ds9.si.edu/doc/ref/region.html for details. |
1319
|
|
|
|
1320
|
|
|
Returns |
1321
|
|
|
------- |
1322
|
|
|
mask_map : `~gammapy.maps.WcsNDMap` of boolean type |
1323
|
|
|
Boolean region mask |
1324
|
|
|
|
1325
|
|
|
""" |
1326
|
|
|
from gammapy.maps import Map, RegionGeom |
1327
|
|
|
|
1328
|
|
|
if not self.is_regular: |
1329
|
|
|
raise ValueError("Multi-resolution maps not supported yet") |
1330
|
|
|
|
1331
|
|
|
# TODO: use spatial coordinates only... |
1332
|
|
|
geom = RegionGeom.from_regions(regions) |
1333
|
|
|
coords = self.get_coord() |
1334
|
|
|
mask = geom.contains(coords) |
1335
|
|
|
return Map.from_geom(self, data=mask) |
1336
|
|
|
|
1337
|
|
|
def get_coord( |
1338
|
|
|
self, idx=None, flat=False, sparse=False, mode="center", axis_name=None |
1339
|
|
|
): |
1340
|
|
|
if mode == "edges" and axis_name is None: |
1341
|
|
|
raise ValueError("Mode 'edges' requires axis name to be defined") |
1342
|
|
|
|
1343
|
|
|
pix = self.get_idx( |
1344
|
|
|
idx=idx, flat=flat, sparse=sparse, mode=mode, axis_name=axis_name |
1345
|
|
|
) |
1346
|
|
|
data = self.pix_to_coord(pix) |
1347
|
|
|
|
1348
|
|
|
coords = MapCoord.create( |
1349
|
|
|
data=data, frame=self.frame, axis_names=self.axes.names |
1350
|
|
|
) |
1351
|
|
|
|
1352
|
|
|
return coords |
1353
|
|
|
|
1354
|
|
|
def contains(self, coords): |
1355
|
|
|
idx = self.coord_to_idx(coords) |
1356
|
|
|
return np.all(np.stack([t != INVALID_INDEX.int for t in idx]), axis=0) |
1357
|
|
|
|
1358
|
|
|
def solid_angle(self): |
1359
|
|
|
"""Solid angle array (`~astropy.units.Quantity` in ``sr``). |
1360
|
|
|
|
1361
|
|
|
The array has the same dimensionality as ``map.nside`` |
1362
|
|
|
since all pixels have the same solid angle. |
1363
|
|
|
""" |
1364
|
|
|
import healpy as hp |
1365
|
|
|
|
1366
|
|
|
return Quantity(hp.nside2pixarea(self.nside), "sr") |
1367
|
|
|
|
1368
|
|
|
def __repr__(self): |
1369
|
|
|
lon, lat = self.center_skydir.data.lon.deg, self.center_skydir.data.lat.deg |
1370
|
|
|
return ( |
1371
|
|
|
f"{self.__class__.__name__}\n\n" |
1372
|
|
|
f"\taxes : {self.axes_names}\n" |
1373
|
|
|
f"\tshape : {self.data_shape[::-1]}\n" |
1374
|
|
|
f"\tndim : {self.ndim}\n" |
1375
|
|
|
f"\tnside : {self.nside[0]}\n" |
1376
|
|
|
f"\tnested : {self.nest}\n" |
1377
|
|
|
f"\tframe : {self.frame}\n" |
1378
|
|
|
f"\tprojection : {self.projection}\n" |
1379
|
|
|
f"\tcenter : {lon:.1f} deg, {lat:.1f} deg\n" |
1380
|
|
|
) |
1381
|
|
|
|
1382
|
|
|
def is_allclose(self, other, rtol_axes=1e-6, atol_axes=1e-6): |
1383
|
|
|
"""Compare two data IRFs for equivalency |
1384
|
|
|
|
1385
|
|
|
Parameters |
1386
|
|
|
---------- |
1387
|
|
|
other : `HpxGeom` |
1388
|
|
|
Geom to compare against |
1389
|
|
|
rtol_axes : float |
1390
|
|
|
Relative tolerance for the axes comparison. |
1391
|
|
|
atol_axes : float |
1392
|
|
|
Relative tolerance for the axes comparison. |
1393
|
|
|
|
1394
|
|
|
Returns |
1395
|
|
|
------- |
1396
|
|
|
is_allclose : bool |
1397
|
|
|
Whether the geometry is all close. |
1398
|
|
|
""" |
1399
|
|
|
if not isinstance(other, self.__class__): |
1400
|
|
|
return TypeError(f"Cannot compare {type(self)} and {type(other)}") |
1401
|
|
|
|
1402
|
|
|
if self.is_allsky and not other.is_allsky: |
1403
|
|
|
return False |
1404
|
|
|
|
1405
|
|
|
if self.data_shape != other.data_shape: |
1406
|
|
|
return False |
1407
|
|
|
|
1408
|
|
|
axes_eq = self.axes.is_allclose(other.axes, rtol=rtol_axes, atol=atol_axes) |
1409
|
|
|
|
1410
|
|
|
hpx_eq = ( |
1411
|
|
|
self.nside == other.nside |
1412
|
|
|
and self.frame == other.frame |
1413
|
|
|
and self.order == other.order |
1414
|
|
|
and self.nest == other.nest |
1415
|
|
|
) |
1416
|
|
|
|
1417
|
|
|
return axes_eq and hpx_eq |
1418
|
|
|
|
1419
|
|
|
def __eq__(self, other): |
1420
|
|
|
if not isinstance(other, self.__class__): |
1421
|
|
|
return False |
1422
|
|
|
|
1423
|
|
|
return self.is_allclose(other=other) |
1424
|
|
|
|
1425
|
|
|
def __ne__(self, other): |
1426
|
|
|
return not self.__eq__(other) |
1427
|
|
|
|