Passed
Push — master ( e3089e...d403db )
by Axel
03:14 queued 11s
created

gammapy/maps/hpx/geom.py (1 issue)

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 re
5
import numpy as np
6
from astropy.coordinates import SkyCoord
7
from astropy.io import fits
8
from astropy.units import Quantity
9
from astropy import units as u
10
from astropy.utils import lazyproperty
11
from gammapy.utils.array import is_power2
12
from .io import HPX_FITS_CONVENTIONS
13
from .utils import (
14
    get_subpixels,
15
    get_superpixels,
16
    parse_hpxregion,
17
    unravel_hpx_index,
18
    ravel_hpx_index,
19
    coords_to_vec,
20
    get_pix_size_from_nside,
21
    get_nside_from_pix_size,
22
    nside_to_order
23
)
24
from .io import HpxConv
25
from ..utils import INVALID_INDEX, coordsys_to_frame, frame_to_coordsys
26
from ..geom import Geom, pix_tuple_to_idx
27
from ..coord import MapCoord, skycoord_to_lonlat
28
from ..axes import MapAxes
29
30
31
# Not sure if we should expose this in the docs or not:
32
# HPX_FITS_CONVENTIONS, HpxConv
33
__all__ = ["HpxGeom"]
34
35
36
class HpxGeom(Geom):
37
    """Geometry class for HEALPIX maps.
38
39
    This class performs mapping between partial-sky indices (pixel
40
    number within a HEALPIX region) and all-sky indices (pixel number
41
    within an all-sky HEALPIX map).  Multi-band HEALPIX geometries use
42
    a global indexing scheme that assigns a unique pixel number based
43
    on the all-sky index and band index.  In the single-band case the
44
    global index is the same as the HEALPIX index.
45
46
    By default the constructor will return an all-sky map.
47
    Partial-sky maps can be defined with the ``region`` argument.
48
49
    Parameters
50
    ----------
51
    nside : `~numpy.ndarray`
52
        HEALPIX nside parameter, the total number of pixels is
53
        12*nside*nside.  For multi-dimensional maps one can pass
54
        either a single nside value or a vector of nside values
55
        defining the pixel size for each image plane.  If nside is not
56
        a scalar then its dimensionality should match that of the
57
        non-spatial axes.
58
    nest : bool
59
        True -> 'NESTED', False -> 'RING' indexing scheme
60
    frame : str
61
        Coordinate system, "icrs" | "galactic"
62
    region : str or tuple
63
        Spatial geometry for partial-sky maps.  If none the map will
64
        encompass the whole sky.  String input will be parsed
65
        according to HPX_REG header keyword conventions.  Tuple
66
        input can be used to define an explicit list of pixels
67
        encompassed by the geometry.
68
    axes : list
69
        Axes for non-spatial dimensions.
70
    """
71
    is_hpx = True
72
    is_region = False
73
74
    def __init__(
75
        self, nside, nest=True, frame="icrs", region=None, axes=None
76
    ):
77
78
        # FIXME: Require NSIDE to be power of two when nest=True
79
80
        self._nside = np.array(nside, ndmin=1)
81
        self._axes = MapAxes.from_default(axes, n_spatial_axes=1)
82
83
        if self.nside.size > 1 and self.nside.shape != self.shape_axes:
84
            raise ValueError(
85
                "Wrong dimensionality for nside. nside must "
86
                "be a scalar or have a dimensionality consistent "
87
                "with the axes argument."
88
            )
89
90
        self._nest = nest
91
        self._frame = frame
92
93
        self._ipix = None
94
        self._region = region
95
        self._create_lookup(region)
96
        self._npix = self._npix * np.ones(self.shape_axes, dtype=int)
97
98
    def _create_lookup(self, region):
99
        """Create local-to-global pixel lookup table."""
100
        if isinstance(region, str):
101
            ipix = [
102
                self.get_index_list(nside, self._nest, region)
103
                for nside in self._nside.flat
104
            ]
105
106
            self._ipix = [
107
                ravel_hpx_index((p, i * np.ones_like(p)), np.ravel(self.npix_max))
108
                for i, p in enumerate(ipix)
109
            ]
110
            self._region = region
111
            self._indxschm = "EXPLICIT"
112
            self._npix = np.array([len(t) for t in self._ipix])
113
            if self.nside.ndim > 1:
114
                self._npix = self._npix.reshape(self.nside.shape)
115
            self._ipix = np.concatenate(self._ipix)
116
117
        elif isinstance(region, tuple):
118
            region = [np.asarray(t) for t in region]
119
            m = np.any(np.stack([t >= 0 for t in region]), axis=0)
120
            region = [t[m] for t in region]
121
122
            self._ipix = ravel_hpx_index(region, self.npix_max)
123
            self._ipix = np.unique(self._ipix)
124
            region = unravel_hpx_index(self._ipix, self.npix_max)
125
            self._region = "explicit"
126
            self._indxschm = "EXPLICIT"
127
            if len(region) == 1:
128
                self._npix = np.array([len(region[0])])
129
            else:
130
                self._npix = np.zeros(self.shape_axes, dtype=int)
131
                idx = np.ravel_multi_index(region[1:], self.shape_axes)
132
                cnt = np.unique(idx, return_counts=True)
133
                self._npix.flat[cnt[0]] = cnt[1]
134
135
        elif region is None:
136
            self._region = None
137
            self._indxschm = "IMPLICIT"
138
            self._npix = self.npix_max
139
140
        else:
141
            raise ValueError(f"Invalid region string: {region!r}")
142
143
    def local_to_global(self, idx_local):
144
        """Compute a local index (partial-sky) from a global (all-sky) index.
145
146
        Returns
147
        -------
148
        idx_global : tuple
149
            A tuple of pixel index vectors with global HEALPIX pixel indices
150
        """
151
        if self._ipix is None:
152
            return idx_local
153
154
        if self.nside.size > 1:
155
            idx = ravel_hpx_index(idx_local, self._npix)
156
        else:
157
            idx_tmp = tuple(
158
                [idx_local[0]] + [np.zeros(t.shape, dtype=int) for t in idx_local[1:]]
159
            )
160
            idx = ravel_hpx_index(idx_tmp, self._npix)
161
162
        idx_global = unravel_hpx_index(self._ipix[idx], self.npix_max)
163
        return idx_global[:1] + tuple(idx_local[1:])
164
165
    def global_to_local(self, idx_global, ravel=False):
166
        """Compute global (all-sky) index from a local (partial-sky) index.
167
168
        Parameters
169
        ----------
170
        idx_global : tuple
171
            A tuple of pixel indices with global HEALPix pixel indices.
172
        ravel : bool
173
            Return a raveled index.
174
175
        Returns
176
        -------
177
        idx_local : tuple
178
            A tuple of pixel indices with local HEALPIX pixel indices.
179
        """
180
        if (
181
                isinstance(idx_global, int)
182
                or (isinstance(idx_global, tuple) and isinstance(idx_global[0], int))
183
                or isinstance(idx_global, np.ndarray)
184
        ):
185
            idx_global = unravel_hpx_index(np.array(idx_global, ndmin=1), self.npix_max)
186
187
        if self.nside.size == 1:
188
            idx = np.array(idx_global[0], ndmin=1)
189
        else:
190
            idx = ravel_hpx_index(idx_global, self.npix_max)
191
192
        if self._ipix is not None:
193
            retval = np.full(idx.size, -1, "i")
194
            m = np.isin(idx.flat, self._ipix)
195
            retval[m] = np.searchsorted(self._ipix, idx.flat[m])
196
            retval = retval.reshape(idx.shape)
197
        else:
198
            retval = idx
199
200
        if self.nside.size == 1:
201
            idx_local = tuple([retval] + list(idx_global[1:]))
202
        else:
203
            idx_local = unravel_hpx_index(retval, self._npix)
204
205
        m = np.any(np.stack([t == INVALID_INDEX.int for t in idx_local]), axis=0)
206
        for i, t in enumerate(idx_local):
207
            idx_local[i][m] = INVALID_INDEX.int
208
209
        if not ravel:
210
            return idx_local
211
        else:
212
            return ravel_hpx_index(idx_local, self.npix)
213
214
    def cutout(self, position, width, **kwargs):
215
        """Create a cutout around a given position.
216
217
        Parameters
218
        ----------
219
        position : `~astropy.coordinates.SkyCoord`
220
            Center position of the cutout region.
221
        width : `~astropy.coordinates.Angle` or `~astropy.units.Quantity`
222
            Diameter of the circular cutout region.
223
224
        Returns
225
        -------
226
        cutout : `~gammapy.maps.WcsNDMap`
227
            Cutout map
228
        """
229
        if not self.is_regular:
230
            raise ValueError("Can only do a cutout from a regular map.")
231
232
        width = u.Quantity(width, "deg").value
233
        return self.create(
234
            nside=self.nside,
235
            nest=self.nest,
236
            width=width,
237
            skydir=position,
238
            frame=self.frame,
239
            axes=self.axes
240
        )
241
242
    def coord_to_pix(self, coords):
243
        import healpy as hp
244
245
        coords = MapCoord.create(coords, frame=self.frame, axis_names=self.axes.names).broadcasted
246
        theta, phi = coords.theta, coords.phi
247
248
        if self.axes:
249
            idxs = self.axes.coord_to_idx(coords, clip=True)
250
            bins = self.axes.coord_to_pix(coords)
251
252
            # FIXME: Figure out how to handle coordinates out of
253
            # bounds of non-spatial dimensions
254
            if self.nside.size > 1:
255
                nside = self.nside[tuple(idxs)]
256
            else:
257
                nside = self.nside
258
259
            m = ~np.isfinite(theta)
260
            theta[m] = 0.0
261
            phi[m] = 0.0
262
            pix = hp.ang2pix(nside, theta, phi, nest=self.nest)
263
            pix = tuple([pix]) + bins
264
            if np.any(m):
265
                for p in pix:
266
                    p[m] = INVALID_INDEX.int
267
        else:
268
            pix = (hp.ang2pix(self.nside, theta, phi, nest=self.nest),)
269
270
        return pix
271
272
    def pix_to_coord(self, pix):
273
        import healpy as hp
274
275
        if self.axes:
276
            bins = []
277
            vals = []
278
            for i, ax in enumerate(self.axes):
279
                bins += [pix[1 + i]]
280
                vals += [ax.pix_to_coord(pix[1 + i])]
281
282
            idxs = pix_tuple_to_idx(bins)
283
284
            if self.nside.size > 1:
285
                nside = self.nside[idxs]
286
            else:
287
                nside = self.nside
288
289
            ipix = np.round(pix[0]).astype(int)
290
            m = ipix == INVALID_INDEX.int
291
            ipix[m] = 0
292
            theta, phi = hp.pix2ang(nside, ipix, nest=self.nest)
293
            coords = [np.degrees(phi), np.degrees(np.pi / 2.0 - theta)]
294
            coords = tuple(coords + vals)
295
            if np.any(m):
296
                for c in coords:
297
                    c[m] = INVALID_INDEX.float
298
        else:
299
            ipix = np.round(pix[0]).astype(int)
300
            theta, phi = hp.pix2ang(self.nside, ipix, nest=self.nest)
301
            coords = (np.degrees(phi), np.degrees(np.pi / 2.0 - theta))
302
303
        return coords
304
305
    def pix_to_idx(self, pix, clip=False):
306
        # FIXME: Look for better method to clip HPX indices
307
        # TODO: copy idx to avoid modifying input pix?
308
        # pix_tuple_to_idx seems to always make a copy!?
309
        idx = pix_tuple_to_idx(pix)
310
        idx_local = self.global_to_local(idx)
311
        for i, _ in enumerate(idx):
312
313
            if clip:
314
                if i > 0:
315
                    np.clip(idx[i], 0, self.axes[i - 1].nbin - 1, out=idx[i])
316
                else:
317
                    np.clip(idx[i], 0, None, out=idx[i])
318
            else:
319
                if i > 0:
320
                    mask = (idx[i] < 0) | (idx[i] >= self.axes[i - 1].nbin)
321
                    np.putmask(idx[i], mask, -1)
322
                else:
323
                    mask = (idx_local[i] < 0) | (idx[i] < 0)
324
                    np.putmask(idx[i], mask, -1)
325
326
        return tuple(idx)
327
328
    @property
329
    def axes(self):
330
        """List of non-spatial axes."""
331
        return self._axes
332
333
    @property
334
    def axes_names(self):
335
        """All axes names"""
336
        return ["skycoord"] + self.axes.names
337
338
    @property
339
    def shape_axes(self):
340
        """Shape of non-spatial axes."""
341
        return self.axes.shape
342
343
    @property
344
    def data_shape(self):
345
        """Shape of the Numpy data array matching this geometry."""
346
        npix_shape = tuple([np.max(self.npix)])
347
        return (npix_shape + self.axes.shape)[::-1]
348
349
    @property
350
    def data_shape_axes(self):
351
        """Shape of data of the non-spatial axes and unit spatial axes."""
352
        return self.axes.shape[::-1] + (1,)
353
354
    @property
355
    def ndim(self):
356
        """Number of dimensions (int)."""
357
        return len(self._axes) + 2
358
359
    @property
360
    def ordering(self):
361
        """HEALPix ordering ('NESTED' or 'RING')."""
362
        return "NESTED" if self.nest else "RING"
363
364
    @property
365
    def nside(self):
366
        """NSIDE in each band."""
367
        return self._nside
368
369
    @property
370
    def order(self):
371
        """ORDER in each band (``NSIDE = 2 ** ORDER``).
372
373
        Set to -1 for bands with NSIDE that is not a power of 2.
374
        """
375
        return nside_to_order(self.nside)
376
377
    @property
378
    def nest(self):
379
        """Is HEALPix order nested? (bool)."""
380
        return self._nest
381
382
    @property
383
    def npix(self):
384
        """Number of pixels in each band.
385
386
        For partial-sky geometries this can
387
        be less than the number of pixels for the band NSIDE.
388
        """
389
        return self._npix
390
391
    @property
392
    def npix_max(self):
393
        """Max. number of pixels"""
394
        maxpix = 12 * self.nside ** 2
395
        return maxpix * np.ones(self.shape_axes, dtype=int)
396
397
    @property
398
    def frame(self):
399
        return self._frame
400
401
    @property
402
    def projection(self):
403
        """Map projection."""
404
        return "HPX"
405
406
    @property
407
    def region(self):
408
        """Region string."""
409
        return self._region
410
411
    @property
412
    def is_allsky(self):
413
        """Flag for all-sky maps."""
414
        return self._region is None
415
416
    @property
417
    def is_regular(self):
418
        """Flag identifying whether this geometry is regular in non-spatial dimensions.
419
420
        False for multi-resolution or irregular geometries.
421
        If true all image planes have the same pixel geometry.
422
        """
423
        if self.nside.size > 1 or self.region == "explicit":
424
            return False
425
        else:
426
            return True
427
428
    @property
429
    def center_coord(self):
430
        """Map coordinates of the center of the geometry (tuple)."""
431
        lon, lat, frame = skycoord_to_lonlat(self.center_skydir)
432
        return tuple([lon, lat]) + self.axes.center_coord
433
434
    @property
435
    def center_pix(self):
436
        """Pixel coordinates of the center of the geometry (tuple)."""
437
        return self.coord_to_pix(self.center_coord)
438
439
    @property
440
    def center_skydir(self):
441
        """Sky coordinate of the center of the geometry.
442
443
        Returns
444
        -------
445
        center : `~astropy.coordinates.SkyCoord`
446
            Center position
447
        """
448
        # TODO: simplify
449
        import healpy as hp
450
451
        if self.is_allsky:
452
            lon, lat = 0., 0.
453
        elif self.region == "explicit":
454
            idx = unravel_hpx_index(self._ipix, self.npix_max)
455
            nside = self._get_nside(idx)
456
            vec = hp.pix2vec(nside, idx[0], nest=self.nest)
457
            vec = np.array([np.mean(t) for t in vec])
458
            lonlat = hp.vec2ang(vec, lonlat=True)
459
            lon, lat = lonlat[0], lonlat[1]
460
        else:
461
            tokens = parse_hpxregion(self.region)
462
            if tokens[0] in ["DISK", "DISK_INC"]:
463
                lon, lat = float(tokens[1]), float(tokens[2])
464
            elif tokens[0] == "HPX_PIXEL":
465
                nside_pix = int(tokens[2])
466
                ipix_pix = int(tokens[3])
467
                if tokens[1] == "NESTED":
468
                    nest_pix = True
469
                elif tokens[1] == "RING":
470
                    nest_pix = False
471
                else:
472
                    raise ValueError(f"Invalid ordering scheme: {tokens[1]!r}")
473
                theta, phi = hp.pix2ang(nside_pix, ipix_pix, nest_pix)
474
                lat = np.degrees((np.pi / 2) - theta)
475
                lon = np.degrees(phi)
476
477
        return SkyCoord(lon, lat, frame=self.frame, unit="deg")
478
479
    @property
480
    def pixel_scales(self):
481
        self.angle_ = """Pixel scale.
482
483
        Returns
484
        -------
485
        angle: `~astropy.coordinates.Angle`
486
        """
487
        return get_pix_size_from_nside(self.nside) * u.deg
488
489
    def interp_weights(self, coords, idxs=None):
490
        """Get interpolation weights for given coords
491
492
        Parameters
493
        ----------
494
        coords : `MapCoord` or dict
495
            Input coordinates
496
        idxs : `~numpy.ndarray`
497
            Indices for non-spatial axes.
498
499
        Returns
500
        -------
501
        weights : `~numpy.ndarray`
502
            Interpolation weights
503
        """
504
        import healpy as hp
505
506
        coords = MapCoord.create(coords, frame=self.frame).broadcasted
507
508
        if idxs is None:
509
            idxs = self.coord_to_idx(coords, clip=True)[1:]
510
511
        theta, phi = coords.theta, coords.phi
512
513
        m = ~np.isfinite(theta)
514
        theta[m] = 0
515
        phi[m] = 0
516
517
        if not self.is_regular:
518
            nside = self.nside[tuple(idxs)]
519
        else:
520
            nside = self.nside
521
522
        pix, wts = hp.get_interp_weights(nside, theta, phi, nest=self.nest)
523
        wts[:, m] = 0
524
        pix[:, m] = INVALID_INDEX.int
525
526
        if not self.is_regular:
527
            pix_local = [self.global_to_local([pix] + list(idxs))[0]]
528
        else:
529
            pix_local = [self.global_to_local(pix, ravel=True)]
530
531
        # If a pixel lies outside of the geometry set its index to the center pixel
532
        m = pix_local[0] == INVALID_INDEX.int
533
        if m.any():
534
            coords_ctr = [coords.lon, coords.lat]
535
            coords_ctr += [ax.pix_to_coord(t) for ax, t in zip(self.axes, idxs)]
536
            idx_ctr = self.coord_to_idx(coords_ctr)
537
            idx_ctr = self.global_to_local(idx_ctr)
538
            pix_local[0][m] = (idx_ctr[0] * np.ones(pix.shape, dtype=int))[m]
539
540
        pix_local += [np.broadcast_to(t, pix_local[0].shape) for t in idxs]
541
        return pix_local, wts
542
543
    @property
544
    def ipix(self):
545
        """HEALPIX pixel and band indices for every pixel in the map."""
546
        return self.get_idx()
547
548
    def is_aligned(self, other):
549
        """Check if HEALPIx geoms and extra axes are aligned.
550
551
        Parameters
552
        ----------
553
        other : `HpxGeom`
554
            Other geom.
555
556
        Returns
557
        -------
558
        aligned : bool
559
            Whether geometries are aligned
560
        """
561
        for axis, otheraxis in zip(self.axes, other.axes):
562
            if axis != otheraxis:
563
                return False
564
565
        if not self.nside == other.nside:
566
            return False
567
        elif not self.frame == other.frame:
568
            return False
569
        elif not self.nest == other.nest:
570
            return False
571
        else:
572
            return True
573
574
    def to_nside(self, nside):
575
        """Upgrade or downgrade the reoslution to a given nside
576
577
        Parameters
578
        ----------
579
        nside : int
580
            Nside
581
582
        Returns
583
        -------
584
        geom : `~HpxGeom`
585
            A HEALPix geometry object.
586
        """
587
        if not self.is_regular:
588
            raise ValueError("Upgrade and degrade only implemented for standard maps")
589
590
        axes = copy.deepcopy(self.axes)
591
        return self.__class__(
592
            nside=nside,
593
            nest=self.nest,
594
            frame=self.frame,
595
            region=self.region,
596
            axes=axes
597
        )
598
599
    def to_binsz(self, binsz):
600
        """Change pixel size of the geometry.
601
602
        Parameters
603
        ----------
604
        binsz : float or `~astropy.units.Quantity`
605
            New pixel size. A float is assumed to be in degree.
606
607
        Returns
608
        -------
609
        geom : `WcsGeom`
610
            Geometry with new pixel size.
611
        """
612
        binsz = u.Quantity(binsz, "deg").value
613
614
        if self.is_allsky:
615
            return self.create(
616
                binsz=binsz,
617
                frame=self.frame,
618
                axes=copy.deepcopy(self.axes),
619
            )
620
        else:
621
            return self.create(
622
                skydir=self.center_skydir,
623
                binsz=binsz,
624
                width=self.width.to_value("deg"),
625
                frame=self.frame,
626
                axes=copy.deepcopy(self.axes),
627
            )
628
629
    def separation(self, center):
630
        """Compute sky separation wrt a given center.
631
632
        Parameters
633
        ----------
634
        center : `~astropy.coordinates.SkyCoord`
635
            Center position
636
637
        Returns
638
        -------
639
        separation : `~astropy.coordinates.Angle`
640
            Separation angle array (1D)
641
        """
642
        coord = self.to_image().get_coord()
643
        return center.separation(coord.skycoord)
644
645
    def to_swapped(self):
646
        """Geometry copy with swapped ORDERING (NEST->RING or vice versa).
647
648
        Returns
649
        -------
650
        geom : `~HpxGeom`
651
            A HEALPix geometry object.
652
        """
653
        axes = copy.deepcopy(self.axes)
654
        return self.__class__(
655
            self.nside, not self.nest, frame=self.frame, region=self.region, axes=axes,
656
        )
657
658
    def to_image(self):
659
        return self.__class__(
660
            np.max(self.nside), self.nest, frame=self.frame, region=self.region
661
        )
662
663
    def to_cube(self, axes):
664
        axes = copy.deepcopy(self.axes) + axes
665
        return self.__class__(
666
            np.max(self.nside),
667
            self.nest,
668
            frame=self.frame,
669
            region=self.region,
670
            axes=axes,
671
        )
672
673
    def _get_neighbors(self, idx):
674
        import healpy as hp
675
676
        nside = self._get_nside(idx)
677
        idx_nb = (hp.get_all_neighbours(nside, idx[0], nest=self.nest),)
678
        idx_nb += tuple([t[None, ...] * np.ones_like(idx_nb[0]) for t in idx[1:]])
679
680
        return idx_nb
681
682
    def _pad_spatial(self, pad_width):
683
        if self.is_allsky:
684
            raise ValueError("Cannot pad an all-sky map.")
685
686
        idx = self.get_idx(flat=True)
687
        idx_r = ravel_hpx_index(idx, self.npix_max)
688
689
        # TODO: Pre-filter indices to find those close to the edge
690
        idx_nb = self._get_neighbors(idx)
691
        idx_nb = ravel_hpx_index(idx_nb, self.npix_max)
692
693
        for _ in range(pad_width):
694
            mask_edge = np.isin(idx_nb, idx_r, invert=True)
695
            idx_edge = idx_nb[mask_edge]
696
            idx_edge = np.unique(idx_edge)
697
            idx_r = np.sort(np.concatenate((idx_r, idx_edge)))
698
            idx_nb = unravel_hpx_index(idx_edge, self.npix_max)
699
            idx_nb = self._get_neighbors(idx_nb)
700
            idx_nb = ravel_hpx_index(idx_nb, self.npix_max)
701
702
        idx = unravel_hpx_index(idx_r, self.npix_max)
703
        return self.__class__(
704
            self.nside.copy(),
705
            self.nest,
706
            frame=self.frame,
707
            region=idx,
708
            axes=copy.deepcopy(self.axes),
709
        )
710
711
    def crop(self, crop_width):
712
        if self.is_allsky:
713
            raise ValueError("Cannot crop an all-sky map.")
714
715
        idx = self.get_idx(flat=True)
716
        idx_r = ravel_hpx_index(idx, self.npix_max)
717
718
        # TODO: Pre-filter indices to find those close to the edge
719
        idx_nb = self._get_neighbors(idx)
720
        idx_nb = ravel_hpx_index(idx_nb, self.npix_max)
721
722
        for _ in range(crop_width):
723
            # Mask of pixels that have at least one neighbor not
724
            # contained in the geometry
725
            mask_edge = np.any(np.isin(idx_nb, idx_r, invert=True), axis=0)
726
            idx_r = idx_r[~mask_edge]
727
            idx_nb = idx_nb[:, ~mask_edge]
728
729
        idx = unravel_hpx_index(idx_r, self.npix_max)
730
        return self.__class__(
731
            self.nside.copy(),
732
            self.nest,
733
            frame=self.frame,
734
            region=idx,
735
            axes=copy.deepcopy(self.axes),
736
        )
737
738
    def upsample(self, factor):
739
        if not is_power2(factor):
740
            raise ValueError("Upsample factor must be a power of 2.")
741
742
        if self.is_allsky:
743
            return self.__class__(
744
                self.nside * factor,
745
                self.nest,
746
                frame=self.frame,
747
                region=self.region,
748
                axes=copy.deepcopy(self.axes),
749
            )
750
751
        idx = list(self.get_idx(flat=True))
752
        nside = self._get_nside(idx)
753
754
        idx_new = get_subpixels(idx[0], nside, nside * factor, nest=self.nest)
755
        for i in range(1, len(idx)):
756
            idx[i] = idx[i][..., None] * np.ones(idx_new.shape, dtype=int)
757
758
        idx[0] = idx_new
759
        return self.__class__(
760
            self.nside * factor,
761
            self.nest,
762
            frame=self.frame,
763
            region=tuple(idx),
764
            axes=copy.deepcopy(self.axes),
765
        )
766
767
    def downsample(self, factor, axis_name=None):
768
        if not is_power2(factor):
769
            raise ValueError("Downsample factor must be a power of 2.")
770
        if axis_name is not None:
771
            raise ValueError("Currently the only valid axis name is None.")
772
773
        if self.is_allsky:
774
            return self.__class__(
775
                self.nside // factor,
776
                self.nest,
777
                frame=self.frame,
778
                region=self.region,
779
                axes=copy.deepcopy(self.axes),
780
            )
781
782
        idx = list(self.get_idx(flat=True))
783
        nside = self._get_nside(idx)
784
        idx_new = get_superpixels(idx[0], nside, nside // factor, nest=self.nest)
785
        idx[0] = idx_new
786
        return self.__class__(
787
            self.nside // factor,
788
            self.nest,
789
            frame=self.frame,
790
            region=tuple(idx),
791
            axes=copy.deepcopy(self.axes),
792
        )
793
794
    @classmethod
795
    def create(
796
        cls,
797
        nside=None,
798
        binsz=None,
799
        nest=True,
800
        frame="icrs",
801
        region=None,
802
        axes=None,
803
        skydir=None,
804
        width=None,
805
    ):
806
        """Create an HpxGeom object.
807
808
        Parameters
809
        ----------
810
        nside : int or `~numpy.ndarray`
811
            HEALPix NSIDE parameter.  This parameter sets the size of
812
            the spatial pixels in the map.
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)
844
        >>> geom = HpxGeom.create(binsz=0.1, width=10.0)
845
        >>> geom = HpxGeom.create(nside=64, width=10.0, axes=[axis])
846
        >>> geom = HpxGeom.create(nside=[32,64], width=10.0, axes=[axis])
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)
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable match_hpx_pix does not seem to be defined.
Loading history...
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.
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
        Return
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(
1182
                nside=hpx.nside, pix=pix, nest=hpx.nest, step=1
1183
            )
1184
1185
            lon, lat = hp.vec2ang(vtx.T, lonlat=True)
1186
            boundaries = SkyCoord(lon * u.deg, lat * u.deg, frame=hpx.frame)
1187
1188
            # Compute maximum separation between all pairs of boundaries and take it
1189
            # as width
1190
            width = boundaries.separation(boundaries[:, np.newaxis]).max()
1191
1192
            wcs_tile_geom = WcsGeom.create(
1193
                skydir=(float(skydir[0]), float(skydir[1])),
1194
                width=width + margin,
1195
                binsz=binsz,
1196
                frame=hpx.frame,
1197
                proj="TAN",
1198
                axes=self.axes
1199
            )
1200
            wcs_tiles.append(wcs_tile_geom)
1201
1202
        return wcs_tiles
1203
1204
    def get_idx(self, idx=None, local=False, flat=False, sparse=False, mode="center", axis_name=None):
1205
        # TODO: simplify this!!!
1206
        if idx is not None and np.any(np.array(idx) >= np.array(self.shape_axes)):
1207
            raise ValueError(f"Image index out of range: {idx!r}")
1208
1209
        # Regular all- and partial-sky maps
1210
        if self.is_regular:
1211
            pix = [np.arange(np.max(self._npix))]
1212
            if idx is None:
1213
                for ax in self.axes:
1214
                    if mode == "edges" and ax.name == axis_name:
1215
                        pix += [np.arange(-0.5, ax.nbin, dtype=float)]
1216
                    else:
1217
                        pix += [np.arange(ax.nbin, dtype=int)]
1218
            else:
1219
                pix += [t for t in idx]
1220
1221
            pix = np.meshgrid(*pix[::-1], indexing="ij", sparse=sparse)[::-1]
1222
            pix = self.local_to_global(pix)
1223
1224
        # Non-regular all-sky
1225
        elif self.is_allsky and not self.is_regular:
1226
1227
            shape = (np.max(self.npix),)
1228
            if idx is None:
1229
                shape = shape + self.shape_axes
1230
            else:
1231
                shape = shape + (1,) * len(self.axes)
1232
            pix = [np.full(shape, -1, dtype=int) for i in range(1 + len(self.axes))]
1233
            for idx_img in np.ndindex(self.shape_axes):
1234
1235
                if idx is not None and idx_img != idx:
1236
                    continue
1237
1238
                npix = self._npix[idx_img]
1239
                if idx is None:
1240
                    s_img = (slice(0, npix),) + idx_img
1241
                else:
1242
                    s_img = (slice(0, npix),) + (0,) * len(self.axes)
1243
1244
                pix[0][s_img] = np.arange(self._npix[idx_img])
1245
                for j in range(len(self.axes)):
1246
                    pix[j + 1][s_img] = idx_img[j]
1247
            pix = [p.T for p in pix]
1248
1249
        # Explicit pixel indices
1250
        else:
1251
1252
            if idx is not None:
1253
                npix_sum = np.concatenate(([0], np.cumsum(self._npix)))
1254
                idx_ravel = np.ravel_multi_index(idx, self.shape_axes)
1255
                s = slice(npix_sum[idx_ravel], npix_sum[idx_ravel + 1])
1256
            else:
1257
                s = slice(None)
1258
            pix_flat = unravel_hpx_index(self._ipix[s], self.npix_max)
1259
1260
            shape = (np.max(self.npix),)
1261
            if idx is None:
1262
                shape = shape + self.shape_axes
1263
            else:
1264
                shape = shape + (1,) * len(self.axes)
1265
            pix = [np.full(shape, -1, dtype=int) for _ in range(1 + len(self.axes))]
1266
1267
            for idx_img in np.ndindex(self.shape_axes):
1268
1269
                if idx is not None and idx_img != idx:
1270
                    continue
1271
1272
                npix = int(self._npix[idx_img])
1273
                if idx is None:
1274
                    s_img = (slice(0, npix),) + idx_img
1275
                else:
1276
                    s_img = (slice(0, npix),) + (0,) * len(self.axes)
1277
1278
                if self.axes:
1279
                    m = np.all(
1280
                        np.stack([pix_flat[i + 1] == t for i, t in enumerate(idx_img)]),
1281
                        axis=0,
1282
                    )
1283
                    pix[0][s_img] = pix_flat[0][m]
1284
                else:
1285
                    pix[0][s_img] = pix_flat[0]
1286
1287
                for j in range(len(self.axes)):
1288
                    pix[j + 1][s_img] = idx_img[j]
1289
1290
            pix = [p.T for p in pix]
1291
1292
        if local:
1293
            pix = self.global_to_local(pix)
1294
1295
        if flat:
1296
            pix = tuple([p[p != INVALID_INDEX.int] for p in pix])
1297
1298
        return pix
1299
1300
    def region_mask(self, regions):
1301
        """Create a mask from a given list of regions
1302
1303
        The mask is filled such that a pixel inside the region is filled with
1304
        "True". To invert the mask, e.g. to create a mask with exclusion regions
1305
        the tilde (~) operator can be used (see example below).
1306
1307
        Parameters
1308
        ----------
1309
        regions : str, `~regions.Region` or list of `~regions.Region`
1310
            Region or list of regions (pixel or sky regions accepted).
1311
            A region can be defined as a string ind DS9 format as well.
1312
            See http://ds9.si.edu/doc/ref/region.html for details.
1313
1314
        Returns
1315
        -------
1316
        mask_map : `~gammapy.maps.WcsNDMap` of boolean type
1317
            Boolean region mask
1318
1319
        """
1320
        from gammapy.maps import Map, RegionGeom
1321
1322
        if not self.is_regular:
1323
            raise ValueError("Multi-resolution maps not supported yet")
1324
1325
        # TODO: use spatial coordinates only...
1326
        geom = RegionGeom.from_regions(regions)
1327
        coords = self.get_coord()
1328
        mask = geom.contains(coords)
1329
        return Map.from_geom(self, data=mask)
1330
1331
    def get_coord(self, idx=None, flat=False, sparse=False, mode="center", axis_name=None):
1332
        if mode == "edges" and axis_name is None:
1333
            raise ValueError("Mode 'edges' requires axis name to be defined")
1334
1335
        pix = self.get_idx(idx=idx, flat=flat, sparse=sparse, mode=mode, axis_name=axis_name)
1336
        data = self.pix_to_coord(pix)
1337
1338
        coords = MapCoord.create(
1339
            data=data, frame=self.frame, axis_names=self.axes.names
1340
        )
1341
1342
        return coords
1343
1344
    def contains(self, coords):
1345
        idx = self.coord_to_idx(coords)
1346
        return np.all(np.stack([t != INVALID_INDEX.int for t in idx]), axis=0)
1347
1348
    def solid_angle(self):
1349
        """Solid angle array (`~astropy.units.Quantity` in ``sr``).
1350
1351
        The array has the same dimensionality as ``map.nside``
1352
        since all pixels have the same solid angle.
1353
        """
1354
        import healpy as hp
1355
1356
        return Quantity(hp.nside2pixarea(self.nside), "sr")
1357
1358
    def __repr__(self):
1359
        lon, lat = self.center_skydir.data.lon.deg, self.center_skydir.data.lat.deg
1360
        return (
1361
            f"{self.__class__.__name__}\n\n"
1362
            f"\taxes       : {self.axes_names}\n"
1363
            f"\tshape      : {self.data_shape[::-1]}\n"
1364
            f"\tndim       : {self.ndim}\n"
1365
            f"\tnside      : {self.nside[0]}\n"
1366
            f"\tnested     : {self.nest}\n"
1367
            f"\tframe      : {self.frame}\n"
1368
            f"\tprojection : {self.projection}\n"
1369
            f"\tcenter     : {lon:.1f} deg, {lat:.1f} deg\n"
1370
        )
1371
1372
    def __eq__(self, other):
1373
        if not isinstance(other, self.__class__):
1374
            return NotImplemented
1375
1376
        if self.is_allsky and other.is_allsky is False:
1377
            return NotImplemented
1378
1379
        # check overall shape and axes compatibility
1380
        if self.data_shape != other.data_shape:
1381
            return False
1382
1383
        for axis, otheraxis in zip(self.axes, other.axes):
1384
            if axis != otheraxis:
1385
                return False
1386
1387
        return (
1388
            self.nside == other.nside
1389
            and self.frame == other.frame
1390
            and self.order == other.order
1391
            and self.nest == other.nest
1392
        )
1393
1394
    def __ne__(self, other):
1395
        return not self.__eq__(other)
1396