• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

simonsobs / so3g / 12832805671

17 Jan 2025 04:16PM UTC coverage: 50.45% (+0.4%) from 50.055%
12832805671

push

github

web-flow
Stokes response (#193)

* Plans for arbitrary detector response stuff

* Detector response now supported in c++, and represented in a redone FocalPlane class in python

* Arbitrary response support

* Passes tests after adding .items(), updating xieta arguments and replacing asm.dets with asm.fplane.quats

* Temporary compatibility with old sotodlib

* Default FocalPlane is not empty, so make a separate constructor for making one with just the boresight

* Better docstrings and comments

* Working on new documentation

* More documentation. Updated numbers in examples which seem to have rotted at some point

* Matthews comments, plus go to G3VectorQuat instead of numpy array

* Ready for new review

* Fixed last-minute typo

* Addressed last round of commments

69 of 87 new or added lines in 3 files covered. (79.31%)

1 existing line in 1 file now uncovered.

1401 of 2777 relevant lines covered (50.45%)

0.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.07
/python/proj/wcs.py
1
import so3g
1✔
2
from . import quat
1✔
3

4
import numpy as np
1✔
5

6
from .ranges import Ranges, RangesMatrix
1✔
7
from . import mapthreads
1✔
8

9
# For coordinate systems we use the following abbreviations:
10
#
11
# - DC: Detector coordinates
12
# - FP: Focal plane coordinates
13
# - CS: Celestial spherical coordinates (likely equatorial)
14
# - NS: Native spherical coordinates of the map projection
15
#
16
# Saying that a quaternion rotation q_B<A "takes A to B", where A and
17
# B are coordinate systems, means that if a vector has coordinates v_A
18
# in system A then its coordinates in system B are:
19
#
20
#      v_B = q_B<A v_A q_B<A*
21
#
22
# The rotation taking detector coordinates to native spherical
23
# coordinates is the product
24
#
25
#     q_NS<DC = q_NS<CS q_CS<FP q_FP<DC
26
#
27
# In this module, quaternion rotations are written as:
28
# - Q_CS<FP: Assembly.Q (vector in samples)
29
# - Q_FP<DC: Assembly.dets (vector in dets)
30
# - Q_NS<CS: Projectionst.q_celestial_to_native
31
#
32
# The Projectionist caches a vector of Q_NS<FP, which is then simply
33
# called "q_native".  This is usually the thing to pass to C++ level.
34

35

36
#: Valid settings for "interpol".  First entry is the default.
37
INTERPOLS = ['nearest', 'bilinear']
1✔
38

39
class _ProjectionistBase:
1✔
40
    """This is a base class to assist with populating data
41
    structures needed for accelerated pointing routines.
42
    This class should not be used directly; call instead
43
    :class:`Projectionist` for rectangular pixelizations
44
    or :class:`ProjectionistHealpix` for HEALPix.
45

46
    On instantiation, it carries information about the relation
47
    between celestial spherical coordinates and the Native Spherical
48
    coordinates of the projection, and also the pixelization scheme
49
    for the projection.
50

51

52
    The following symbols are used in docstrings to represent shapes:
53

54
    * ``n_dets`` - Number of detectors in the focal plane.
55
    * ``n_samps`` - Number of samples in the signal space of each detector.
56
    * ``n_threads`` - Number of threads to use in parallelized computation.
57
    * ``n_mapidx`` - Number of indices required to specify a pixel in
58
      a map.  For simple RectPix maps this is probably 2, with the first
59
      index specifying row and the second index specifying column.
60
      But for tiled maps n_mapidx is 3, with the first axis specifying
61
      the tile number. For HEALPix, n_mapidx is 1 for untiled and 2
62
      for tiled maps.
63
    * ``n_comp`` - Number of map components, such as Stokes components
64
      or other spin components.  This takes the value 3, for example,
65
      if projection into TQU space has been requested.
66

67
    When coordinate computation or projection routines are called, a
68
    ProjEng (a wrapped C++ object) is instantiated to perform the
69
    accelerated computations.  The spin-composition (i.e. is this a
70
    'T' map or a 'TQU' map) must be specified at this time, to
71
    instantiate the correct accelerated object.
72

73
    Some method parameters are common to many functions and are documented
74
    here for consistency:
75

76
    * ``assembly`` - an Assembly, providing a focal plane (quaternion
77
      offsets for each detector) as well as a boresight vector
78
      (quaternion for each time sample).
79
    * ``comps`` - a string specifying the Stokes components of the
80
      map, for example 'T' or 'TQU'.  When absent, this will be
81
      guessed from the map shape; with 1|2|3 mapping to 'T'|'QU'|'TQU'
82
      respectively.
83
    * ``threads`` - the thread assignment, consisting of a list of
84
      RangesMatrix objects.  Each RangesMatrix object must have shape
85
      (n_threads, n_dets, n_samps).  The n_threads does not need to
86
      be the same for every entry in the list.  In TOD-to-map
87
      operations, each entry of this list is processed fully before
88
      proceeding to the next one.  Each entry "ranges" is processed
89
      using (up to) the specified number of threads, such that thread
90
      i performs operations only on the samples included in
91
      ranges[i,:,:].  Most thread assignment routines in this module
92
      will return a list of two RangesMatrix objects,
93
      [ranges_parallel, ranges_serial].  The first item represents the
94
      part of the computation that can be done in parallel, and has
95
      shape (n_threads, n_dets, n_samps).  The ranges_serial object
96
      has shape (1, n_dets, n_samps) and represents any samples that
97
      need to be treated in a single thread.  The ranges_serial is
98
      only non-trivial when interpolation is active.
99

100
    """
101

102
    def __init__(self):
1✔
103
        raise NotImplementedError("Use child class Projectionist or ProjectionistHealpix")
×
104

105
    def _get_pixelizor_args(self):
1✔
106
        raise NotImplementedError("Use child class Projectionist or ProjectionistHealpix")
×
107

108
    def get_ProjEng(self, comps='TQU', proj_name=None, get=True,
1✔
109
                    instance=True, interpol=None):
110
        """Returns an so3g.ProjEng object appropriate for use with the
111
        configured geometry.
112

113
        """
114
        if proj_name is None: proj_name = self.proj_name
1✔
115
        tile_suffix = 'Tiled' if self.tiling else 'NonTiled'
1✔
116

117
        # Interpolation mode
118
        if interpol is None:
1✔
119
            interpol = self.interpol
1✔
120
        if interpol in ["nn", "nearest"]:
1✔
121
            interpol_suffix = ""
1✔
122
        elif interpol in ["lin", "bilinear"]:
1✔
123
            interpol_suffix = "_Bilinear"
1✔
124
        else:
125
            raise ValueError("ProjEng interpol '%s' not recognized" % str(interpol))
×
126

127
        projeng_name = f'ProjEng_{proj_name}_{comps}_{tile_suffix}{interpol_suffix}'
1✔
128
        if not get:
1✔
129
            return projeng_name
×
130
        try:
1✔
131
            projeng_cls = getattr(so3g, projeng_name)
1✔
132
        except AttributeError:
×
133
            raise ValueError(f'There is no projector implemented for '
×
134
                             f'pixelization "{proj_name}", components '
135
                             f'"{comps}" (tried "{projeng_name}").')
136
        if not instance:
1✔
137
            return projeng_cls
×
138
        return projeng_cls(self._get_pixelizor_args())
1✔
139

140
    def _cache_q_fp_to_native(self, q_fp_to_celestial):
1✔
141
        """Get q_fp_to_native for argument q_fp_to_celestial, and cache the
142
        result, and return that result later if called with the same
143
        argument.
144

145
        """
146
        if q_fp_to_celestial is not self._q_fp_to_celestial:
1✔
147
            self._q_fp_to_native = self.q_celestial_to_native * q_fp_to_celestial
1✔
148
            self._q_fp_to_celestial = q_fp_to_celestial
1✔
149
        return self._q_fp_to_native
1✔
150

151
    def _guess_comps(self, map_shape):
1✔
152
        if map_shape[0] == 1:
×
153
            return 'T'
×
154
        elif map_shape[0] == 2:
×
155
            return 'QU'
×
156
        elif map_shape[0] == 3:
×
157
            return 'TQU'
×
158
        raise ValueError('No default components for ncomp=%i; '
×
159
                         'set comps=... explicitly.' % map_shape[0])
160

161
    def _get_map_shape(self, imap):
1✔
162
        """
163
        Get map shape, supporting "bare" tiled maps (lists of None or tile array).
164
        For a list of tiles it returns the *tile shape*
165
        """
166
        if isinstance(imap, list): # Assume tile list
×
167
            for tile in imap:
×
168
                if tile is not None:
×
169
                    return tile.shape
×
170
            raise ValueError("map has no non-None entries")
×
171
        else:
172
            return imap.shape
×
173

174
    def get_pixels(self, assembly):
1✔
175
        """Get the pixel indices for the provided pointing Assembly.  For each
176
        detector, an int32 array of shape (n_samps, n_mapidx) is
177
        returned.  The value of the first slot of the second axis will
178
        be -1 if and only if the sample's projected position is
179
        outside the map footprint.
180

181
        See class documentation for description of standard arguments.
182

183
        """
184
        projeng = self.get_ProjEng('TQU')
×
185
        q_native = self._cache_q_fp_to_native(assembly.Q)
×
NEW
186
        return projeng.pixels(q_native, assembly.fplane.quats, None)
×
187

188
    def get_pointing_matrix(self, assembly):
1✔
189
        """Get the pointing matrix information, which is to say both the pixel
190
        indices and the "spin projection factors" for the provided
191
        pointing Assembly.  Returns (pixel_indices, spin_proj) where
192
        pixel_indices is as returned by get_pixels() and spin_proj is
193
        a list of float arrays of shape [n_samps, n_comp].  As an
194
        alternative to on-the-fly computation, this information can be
195
        cached and used for very fast projection operations.
196

197
        See class documentation for description of standard arguments.
198

199
        """
200
        projeng = self.get_ProjEng('TQU')
×
201
        q_native = self._cache_q_fp_to_native(assembly.Q)
×
NEW
202
        return projeng.pointing_matrix(q_native, assembly.fplane.quats, assembly.fplane.resps, None, None)
×
203

204
    def get_coords(self, assembly, use_native=False, output=None):
1✔
205
        """Get the spherical coordinates for the provided pointing Assembly.
206
        For each detector, a float64 array of shape [n_samps,4] is
207
        returned.  In the right-most index, the first two components
208
        are the longitude and latitude in radians.  The next two
209
        components are the cosine and sine of the parallactic angle.
210

211
        This routine uses a trivial CAR projection to return results
212
        from the celestial coordinate system (i.e. (lon,lat) =
213
        (RA,dec)), and the parallactic angle will be relative to that
214
        projection.  If you are interested in the parallactic angle of
215
        the native spherical coordinate system (e.g. if you're doing a
216
        tangent plane projection), make a second call specifying
217
        use_native=True.  In this case you might also take a look at
218
        the get_planar() routine.
219

220
        See class documentation for description of standard arguments.
221

222
        """
223
        projeng = self.get_ProjEng('TQU', 'CAR')
×
224
        if use_native:
×
225
            q_native = self._cache_q_fp_to_native(assembly.Q)
×
226
        else:
227
            q_native = assembly.Q
×
NEW
228
        return projeng.coords(q_native, assembly.fplane.quats, output)
×
229

230
    def get_planar(self, assembly, output=None):
1✔
231
        """Get projection plane coordinates for all detectors at all times.
232
        For each detector, a float64 array of shape [n_samps,4] is
233
        returned.  The first two elements are the x and y projection
234
        plane coordiantes, similar to the "intermediate world
235
        coordinates", in FITS language.  Insofar as FITS ICW has units
236
        of degrees, these coordinates have units of radians.  Indices
237
        2 and 3 carry the cosine and sine of the detector parallactic
238
        angle.
239

240
        """
241
        q_native = self._cache_q_fp_to_native(assembly.Q)
×
242
        projeng = self.get_ProjEng('TQU')
×
NEW
243
        return projeng.coords(q_native, assembly.fplane.quats, output)
×
244

245
    def zeros(self, super_shape):
1✔
246
        """Return a map, filled with zeros, with shape (super_shape,) +
247
        self.shape.  For tiled maps, this will be a list of map tiles
248
        (with shape (super_shape, ) + tile_shape.  If any tiles are
249
        not active, None is returned in the corresponding slots.
250

251
        """
252
        projeng = self.get_ProjEng('T')
1✔
253
        return projeng.zeros(super_shape)
1✔
254

255
    def to_map(self, signal, assembly, output=None, det_weights=None,
1✔
256
               threads=None, comps=None):
257
        """Project signal into a map.
258

259
        Arguments:
260
          signal (Signal-like): The signal to project.
261
          output (Map-like): The map into which to accumulate the
262
            projected signal.  If None, a map will be initialized internally.
263
          det_weights (shape n_det): Weights to use for each detector.
264

265
        See class documentation for description of standard arguments.
266

267
        """
268
        if output is None and comps is None:
1✔
269
            raise ValueError("Provide an output map or specify component of "
×
270
                             "interest (e.g. comps='TQU').")
271
        if comps is None:
1✔
272
            comps = self._guess_comps(self._get_map_shape(output))
×
273
        projeng = self.get_ProjEng(comps)
1✔
274
        q_native = self._cache_q_fp_to_native(assembly.Q)
1✔
275
        map_out = projeng.to_map(output, q_native, assembly.fplane.quats,
1✔
276
            assembly.fplane.resps, signal, det_weights, threads)
277
        return map_out
1✔
278

279
    def to_weights(self, assembly, output=None, det_weights=None,
1✔
280
                   threads=None, comps=None):
281
        """Project pointing into a weights map.
282

283
        Arguments:
284
          output (Map-like): The weights map into which to accumulate
285
            the projected signal.  If None, a map will be initialized
286
            internally.
287
          det_weights (shape n_det): Weights to use for each detector.
288

289
        See class documentation for description of standard arguments.
290

291
        """
292
        if output is None and comps is None:
1✔
293
            raise ValueError("Provide an output map or specify component of "
×
294
                             "interest (e.g. comps='TQU').")
295
        if comps is None:
1✔
296
            shape = self._get_map_shape(output)
×
297
            assert(shape[0] == shape[1])
×
298
            comps = self._guess_comps(shape[1:])
×
299
        projeng = self.get_ProjEng(comps)
1✔
300
        q_native = self._cache_q_fp_to_native(assembly.Q)
1✔
301
        map_out = projeng.to_weight_map(output, q_native, assembly.fplane.quats,
1✔
302
            assembly.fplane.resps, det_weights, threads)
303
        return map_out
1✔
304

305
    def from_map(self, src_map, assembly, signal=None, comps=None):
1✔
306
        """De-project from a map, returning a Signal-like object.
307

308
        Arguments:
309
          src_map (Map-like): The map from which to sample.
310
          signal (Signal-like): The object into which to accumulate
311
            the signal.  If not provided, a suitable object will be
312
            created and initialized to zero.
313

314
        See class documentation for description of standard arguments.
315

316
        """
317
        if comps is None:
1✔
318
            comps = self._guess_comps(self._get_map_shape(src_map))
×
319
        projeng = self.get_ProjEng(comps)
1✔
320
        q_native = self._cache_q_fp_to_native(assembly.Q)
1✔
321
        signal_out = projeng.from_map(src_map, q_native, assembly.fplane.quats,
1✔
322
            assembly.fplane.resps, signal)
323
        return signal_out
1✔
324

325
    def assign_threads(self, assembly, method='domdir', n_threads=None):
1✔
326
        """Get a thread assignment RangesMatrix.  Different algorithms can be
327
        chosen, though this method does not provide fine-grained
328
        control of algorithm parameters and instead seeks to apply
329
        reasonable defaults.
330

331
        The methods exposed here are:
332

333
        - ``'simple'``: Divides the map into n_threads horizontal
334
          bands, and assigns the pixels in each band to a single
335
          thread.  This tends to be bad for scans that aren't
336
          horizontally correlated, or that don't have equal weight
337
          throughout the map.  The 'domdir' algorithm addresses both
338
          of those problems.
339
        - ``'domdir'``: For constant elevation scans, determines the
340
          dominant scan direction in the map and divides it into bands
341
          parallel to that scan direction.  The thickness of bands is
342
          adjusted so roughly equal numbers of samples fall into each
343
          band.
344
        - ``'tiles'``: In Tiled projections, assign each tile to a
345
          thread.  Some effort is made to balance the total number of
346
          samples over the threads.
347

348
        The returned object can be passed to the ``threads`` argument
349
        of the projection methods in this class.
350

351
        See class documentation for description of standard arguments.
352

353
        Args:
354
          method (str): Algorithm to use.
355

356
        """
357
        if method not in THREAD_ASSIGNMENT_METHODS:
1✔
358
            raise ValueError(f'No thread assignment method "{method}"; '
×
359
                             f'expected one of {THREAD_ASSIGNMENT_METHODS}')
360
        n_threads = mapthreads.get_num_threads(n_threads)
1✔
361

362
        if method == 'simple':
1✔
363
            projeng = self.get_ProjEng('T')
1✔
364
            q_native = self._cache_q_fp_to_native(assembly.Q)
1✔
365
            omp_ivals = projeng.pixel_ranges(q_native, assembly.fplane.quats, None, n_threads)
1✔
366
            return wrap_ivals(omp_ivals)
1✔
367

368
        elif method == 'domdir':
1✔
369
            fplane_rep = assembly.fplane[::100]
1✔
370
            if (self.tiling is not None) and (self.active_tiles is None):
1✔
371
                tile_info = self.get_active_tiles(assembly)
1✔
372
                active_tiles = tile_info['active_tiles']
1✔
373
                self.active_tiles = active_tiles
1✔
374
            else:
375
                active_tiles = None
1✔
376
            return mapthreads.get_threads_domdir(
1✔
377
                assembly.Q, assembly.fplane, shape=self.naxis[::-1], wcs=self.wcs,
378
                tile_shape=self.tile_shape, active_tiles=active_tiles,
379
                n_threads=n_threads, fplane_rep=fplane_rep)
380

381
        elif method == 'tiles':
1✔
382
            tile_info = self.get_active_tiles(assembly, assign=n_threads)
1✔
383
            self.active_tiles = tile_info['active_tiles']
1✔
384
            return tile_info['group_ranges']
1✔
385

386
        raise RuntimeError(f"Unimplemented method: {method}.")
×
387

388
    def assign_threads_from_map(self, assembly, tmap, n_threads=None):
1✔
389
        """Assign threads based on a map.
390

391
        The returned object can be passed to the ``threads`` argument
392
        of the projection methods in this object.
393

394
        See class documentation for description of standard arguments.
395

396
        Args:
397
          tmap: Map structure with shape (1,m,n) where each pixel,
398
            when converted to integer, species the index of the thread
399
            to which that pixel should be assigned.
400

401
        """
402
        projeng = self.get_ProjEng('T')
1✔
403
        q_native = self._cache_q_fp_to_native(assembly.Q)
1✔
404
        n_threads = mapthreads.get_num_threads(n_threads)
1✔
405
        omp_ivals = projeng.pixel_ranges(q_native, assembly.fplane.quats, tmap, n_threads)
1✔
406
        return wrap_ivals(omp_ivals)
1✔
407

408
    def get_active_tiles(self, assembly, assign=False):
1✔
409
        """For a tiled Projection, figure out what tiles are hit by an
410
        assembly.
411

412
        See class documentation for description of standard arguments.
413

414
        Args:
415
          assign (bool or int): If True or an int, then the function
416
            will also compute a thread assignment, based on assigning
417
            each tile to a particular thread.  If this is an int, it
418
            sets the thread count; if it is simply True then
419
            OMP_NUM_THREADS is used.
420

421
        Returns:
422
          dict : The basic analysis results are:
423

424
            - ``'active_tiles'`` (list of int): sorted, non-repeating
425
              list of tiles that are hit by the assembly.
426
            - ``'hit_counts'`` (list of int): the number of hits in
427
              each tile returned in 'active_tiles', respectively.
428

429
          If the thread assignment took place then the dict will also
430
          contain:
431

432
            - ``'group_tiles'`` (list of lists of ints): There is one
433
              entry per thread, and the entry lists the tiles that
434
              have been assigned to that thread.
435
            - ``'group_ranges'`` (RangesMatrix): The thread
436
              assignments; shape (n_thread, n_det, n_samp).
437

438
        """
439
        if self.tiling is None:
1✔
440
            raise RuntimeError("This Projectionist not set up for Tiled maps.")
1✔
441
        projeng = self.get_ProjEng('T')
1✔
442
        q_native = self._cache_q_fp_to_native(assembly.Q)
1✔
443
        # This returns a G3VectorInt of length n_tiles giving count of hits per tile.
444
        hits = np.array(projeng.tile_hits(q_native, assembly.fplane.quats))
1✔
445
        tiles = np.nonzero(hits)[0]
1✔
446
        hits = hits[tiles]
1✔
447
        if assign is True:
1✔
448
            assign = so3g.useful_info()['omp_num_threads']
×
449
        if assign > 0:
1✔
450
            group_n = np.array([0 for g in range(assign)])
1✔
451
            group_tiles = [[] for _ in group_n]
1✔
452
            cands = sorted(list(zip(hits, tiles)), reverse=True) # [(1000, 12), (900, 4), ...]
1✔
453
            for _n, _t in cands:
1✔
454
                idx = group_n.argmin()
1✔
455
                group_n[idx] += _n
1✔
456
                group_tiles[idx].append(_t)
1✔
457
            imax = np.argmax(group_n)
1✔
458
            # max_ratio = group_n[imax] / np.mean(np.concatenate([group_n[:imax], group_n[imax+1:]]))
459
            # if len(group_n)>1 and max_ratio > 1.1:
460
            #     print(f"Warning: Threads poorly balanced. Max/mean hits = {max_ratio}")
461

462
            # Now paint them into Ranges.
463
            R = projeng.tile_ranges(q_native, assembly.fplane.quats, group_tiles)
1✔
464
            R = wrap_ivals(R)
1✔
465
            return {
1✔
466
                'active_tiles': list(tiles),
467
                'hit_counts': list(hits),
468
                'group_ranges': R,
469
                'group_tiles': group_tiles,
470
            }
471
        return {
1✔
472
            'active_tiles': list(tiles),
473
            'hit_counts': list(hits),
474
        }
475

476
    _ivals_format = 2
1✔
477

478
class Projectionist(_ProjectionistBase):
1✔
479
    """This class assists with analyzing WCS information to populate data
480
    structures needed for accelerated pointing routines for rectangular
481
    pixelization.
482

483
    See also the methods and parameters defined in the base class
484
    :class:`_ProjectionistBase`.
485

486
    As in pixell, the code and discussion here uses the term
487
    "geometry" to refer to the combination of an astropy.WCS object
488
    and a 2-d array shape.
489

490
    Some common method parameters specific to rectangular pixelization
491
    are documented here for consistency.
492

493
    * ``proj_name`` - a string specifying a projection.  The
494
      nomenclature is mostly the same as the FITS CTYPE identifiers.
495
      Accepted values: ARC, CAR, CEA, TAN, ZEA, Flat, Quat.
496
    * ``shape`` - the shape describing the celestial axes of the map.
497
      (For tiled representation this specifies the parent footprint.)
498
    * ``wcs`` - the WCS describing the celestial axes of the map.
499
      Together with ``shape`` this is a geometry; see pixell.enmap
500
      documentation.
501
    * ``interpol``: How positions that fall between pixel centers will
502
      be handled. Options are "nearest" (default): Use Nearest
503
      Neighbor interpolation, so a sample takes the value of
504
      whatever pixel is closest; or "bilinear": linearly
505
      interpolate between the four closest pixels. bilinear is
506
      slower (around 60%) but avoids problems caused by a
507
      discontinuous model.
508

509
    Attributes:
510
        naxis: 2-element integer array specifying the map shape (for
511
            the 2 celestial axes).
512
        cdelt: 2-element float array specifying the pixel pitch.
513
        crpix: 2-element float array specifying the pixel coordinates
514
            of the reference point.
515
        proj_name: string, name of the projection.
516
        q_celestial_to_native: quaternion rotation taking celestial
517
            coordinates to the native spherical coordinates of the
518
            projection.
519
        tile_shape: 2-element integer array specifying the tile shape
520
            (this is the shape of one full-size sub-tile, not the
521
            decimation factor on each axis).
522
        tiling: a _Tiling object if the map is tiled, None otherwise.
523

524
    """
525

526
    @staticmethod
1✔
527
    def get_q(wcs):
1✔
528
        """Analyze a wcs object to compute the quaternion rotation from
529
        celestial to native spherical coordinates.
530

531
        """
532
        alpha0, delta0 = wcs.wcs.crval  # In degrees.
1✔
533
        if (wcs.wcs.phi0 == 0. and wcs.wcs.theta0 == 0.):
1✔
534
            # This is typical for cylindrical projections.
535
            assert((delta0 >= 0 and wcs.wcs.lonpole == 180.0) or
×
536
                   (delta0 <= 0 and wcs.wcs.lonpole ==   0.0))
537
            Q = (quat.euler(1,  delta0 * quat.DEG) *
×
538
                 quat.euler(2, -alpha0 * quat.DEG))
539
        elif (wcs.wcs.phi0 == 0. and wcs.wcs.theta0 == 90.):
1✔
540
            # This is typical for zenithal projections.
541
            assert(wcs.wcs.lonpole == 180.0)
1✔
542
            Q = (quat.euler(2, np.pi) *
1✔
543
                 quat.euler(1, (delta0 - 90)*quat.DEG) *
544
                 quat.euler(2, -alpha0 * quat.DEG))
545
        else:
546
            raise ValueError(f'Unimplemented NSC reference (phi0,theta0)='
×
547
                             '({wcs.wcs.phi0:.2f},{wcs.wcs.theta0:.2f})')
548
        return Q
1✔
549

550
    def __init__(self):
1✔
551
        self._q_fp_to_native = None
1✔
552
        self._q_fp_to_celestial = None
1✔
553
        self.tile_shape = None
1✔
554
        self.active_tiles = None
1✔
555
        self.wcs = None
1✔
556
        self.proj_name = None
1✔
557
        self.q_celestial_to_native = None
1✔
558
        self.interpol = INTERPOLS[0]
1✔
559

560
        self.naxis = np.array([0, 0])
1✔
561
        self.cdelt = np.array([0., 0.])
1✔
562
        self.crpix = np.array([0., 0.])
1✔
563

564
    @property
1✔
565
    def tiling(self):
1✔
566
        if self.tile_shape is None:
1✔
567
            return None
1✔
568
        return _Tiling(self.naxis[::-1], self.tile_shape)
1✔
569

570
    @classmethod
1✔
571
    def for_geom(cls, shape, wcs, interpol=None):
1✔
572
        """Construct a Projectionist for use with the specified "geometry".
573

574
        The shape and wcs are the core information required to prepare
575
        a Projectionist, so this method is likely to be called by
576
        other constructors.
577
        """
578
        self = cls()
1✔
579
        ax1, ax2 = wcs.wcs.lng, wcs.wcs.lat
1✔
580
        ndim = len(shape)
1✔
581
        # The axes are numbered from outside in...
582
        self.naxis = np.array([shape[ndim - ax1 - 1],
1✔
583
                               shape[ndim - ax2 - 1]], dtype=int)
584
        # Get just the celestial part.
585
        wcs = wcs.celestial
1✔
586
        self.wcs = wcs
1✔
587

588
        # Extract the projection name (e.g. CAR)
589
        proj = [c[-3:] for c in wcs.wcs.ctype]
1✔
590
        assert(proj[0] == proj[1])
1✔
591
        proj_name = proj[0]  # Projection name
1✔
592
        self.proj_name = proj_name
1✔
593

594
        # Store the rotation to native spherical coordinates.
595
        self.q_celestial_to_native = self.get_q(wcs)
1✔
596

597
        # Store the grid info.
598
        self.cdelt = np.array(wcs.wcs.cdelt) * quat.DEG
1✔
599
        self.crpix = np.array(wcs.wcs.crpix)
1✔
600

601
        # Pixel interpolation mode
602
        if interpol is None:
1✔
603
            interpol = INTERPOLS[0]
1✔
604
        self.interpol = interpol
1✔
605

606
        return self
1✔
607

608
    @classmethod
1✔
609
    def for_map(cls, emap, wcs=None, interpol=None):
1✔
610
        """Construct a Projectionist for use with maps having the same
611
        geometry as the provided enmap.
612

613
        Args:
614
          emap: enmap from which to extract shape and wcs information.
615
            It is acceptable to pass a bare ndarray here (or anything
616
            with shape attribute), provided that wcs is provided
617
            separately.
618
          wcs: optional WCS object to use instead of emap.wcs.
619
        """
620
        if wcs is None:
×
621
            wcs = emap.wcs
×
622
        return cls.for_geom(emap.shape, wcs, interpol=interpol)
×
623

624
    @classmethod
1✔
625
    def for_source_at(cls, alpha0, delta0, gamma0=0.,
1✔
626
                      proj_name='TAN'):
627
        """Return a pointing-only Projectionist where some particular
628
        equatorial position will be put at the North Pole of the
629
        Native spherical coordinates.
630

631
        """
632
        self = cls()
×
633
        self.proj_name = proj_name
×
634
        assert(gamma0 == 0.)
×
635
        self.q_celestial_to_native = (
×
636
            quat.euler(2, np.pi)
637
            * quat.euler(1, (delta0 - 90)*quat.DEG)
638
            * quat.euler(2, -alpha0 * quat.DEG))
639
        return self
×
640

641
    @classmethod
1✔
642
    def for_tiled(cls, shape, wcs, tile_shape, active_tiles=True, interpol=None):
1✔
643
        """Construct a Projectionist for use with the specified geometry
644
        (shape, wcs), cut into tiles of shape tile_shape.
645

646
        See class documentation for description of standard arguments.
647

648
        Args:
649
            tile_shape: tuple of ints, giving the shape of each tile.
650
            active_tiles: bool or list of ints.  Specifies which tiles
651
                should be considered active.  If True, all tiles are
652
                populated.  If None or False, this will remain
653
                uninitialized and attempts to generate blank maps
654
                (such as calls to zeros or to projection functions
655
                without a target map set) will fail.  Otherwise this
656
                must be a list of integers specifying which tiles to
657
                populate on such calls.
658

659
        """
660
        self = cls.for_geom(shape, wcs, interpol=interpol)
1✔
661
        self.tile_shape = np.array(tile_shape, 'int')
1✔
662
        if active_tiles is True:
1✔
663
            self.active_tiles = list(range(self.tiling.tile_count))
1✔
664
        elif active_tiles in [False, None]:
1✔
665
            self.active_tiles = None
1✔
666
        else:
667
            # Presumably a list of tile numbers.
668
            self.active_tiles = active_tiles
1✔
669
        return self
1✔
670

671
    def from_map(self, src_map, assembly, signal=None, comps=None):
1✔
672
        """De-project from a map, returning a Signal-like object.
673
        See parent class documentation for full description.
674

675
        """
676
        if src_map.ndim == 2:
×
677
            # Promote to (1,...)
678
            src_map = src_map[None]
×
679
        return super().from_map(src_map, assembly, signal, comps)
×
680

681
    def _get_pixelizor_args(self):
1✔
682
        """Returns a tuple of arguments that may be passed to the ProjEng
683
        constructor to define the pixelization.
684

685
        """
686
        # All these casts are required because boost-python doesn't
687
        # like numpy scalars.
688
        args = (int(self.naxis[1]), int(self.naxis[0]),
1✔
689
                float(self.cdelt[1]), float(self.cdelt[0]),
690
                float(self.crpix[1]), float(self.crpix[0]))
691
        if self.tiling:
1✔
692
            args += tuple(map(int, self.tile_shape))
1✔
693
            if self.active_tiles is not None:
1✔
694
                args += (self.active_tiles,)
1✔
695
        return args
1✔
696

697
    def _guess_comps(self, map_shape):
1✔
698
        if len(map_shape) != 3:
×
699
            raise ValueError('Cannot guess components based on '
×
700
                             'map with %i!=3 dimensions!' % len(map_shape))
701
        return super()._guess_comps(map_shape)
×
702

703

704
class ProjectionistHealpix(_ProjectionistBase):
1✔
705
    """Projectionist for Healpix maps.
706
    See base class :class:`_ProjectionistBase` for more methods
707
    and explanation of common method parameters.
708

709
    Attributes:
710
        nside: int, nside of the map, power of 2; 0 < nside <= 8192.
711
        nside_tile: None, int, or 'auto', nside of tiling.
712
                    Ntile will be 12*nside_tile**2. None for untiled.
713
                    If 'auto', an appropriate nside_tile will be computed
714
                    and set on calling :func:`compute_nside_tile`.
715
        ordering: str, 'NEST' or 'RING'. Only NEST supported for tiled maps.
716

717
    """
718
    def __init__(self):
1✔
719
        self._q_fp_to_native = None
1✔
720
        self._q_fp_to_celestial = None
1✔
721
        self.active_tiles = None
1✔
722
        self.proj_name = None
1✔
723
        self.q_celestial_to_native = quat.quat(1,0,0,0)
1✔
724
        self.interpol = 'nearest'
1✔
725
        self.tiling = None
1✔
726

727
        self.nside = None
1✔
728
        self.nside_tile = None
1✔
729
        self.ordering = 'NEST'  # 'RING' or 'NEST'. Only NEST can be used with tiles
1✔
730

731
    @classmethod
1✔
732
    def for_healpix(cls, nside, nside_tile=None, active_tiles=None, ordering='NEST', interpol=None):
1✔
733
        """Construct a Projectionist for Healpix maps.
734

735
        See class documentation for description of standard arguments.
736

737
        """
738
        self=cls()
1✔
739
        self.proj_name = 'HP'
1✔
740
        self.nside = nside
1✔
741
        self.active_tiles = active_tiles
1✔
742
        if interpol is not None and (interpol not in ['nearest', 'nn']):
1✔
743
            raise NotImplementedError("Only 'nearest' interpolation is supported for Healpix")
×
744
        self.interpol = 'nearest'
1✔
745

746
        self.ordering = ordering
1✔
747
        if ordering not in ['NEST', 'RING']:
1✔
748
            raise ValueError("ordering {ordering} should be 'NEST' or 'RING'")
×
749

750
        if nside_tile is not None:
1✔
751
            self.nside_tile = nside_tile
1✔
752
            self.tiling = True
1✔
753

754
        if ordering == 'RING' and self.nside_tile is not None:
1✔
755
            raise NotImplementedError("'RING' not supported for tiled maps")
×
756

757
        return self
1✔
758

759
    def compute_nside_tile(self, assembly, nActivePerThread=5, nThreads=None):
1✔
760
        """Automatically compute and set self.nside_tile for good balancing over threads.
761

762
        Arguments:
763
          nActivePerThread: int, how many active pixels do you want per thread (minimum)
764
          nThreads: int, number of threads to optimize for (takes from OMP_NUM_THREADS if None)
765

766
        """
767
        if self.nside_tile == 'auto':
1✔
768
            ## Estimate fsky
769
            nside_tile0 = min(4, self.nside)  # ntile = 192, for estimating fsky
1✔
770
            self.nside_tile = nside_tile0
1✔
771
            nActive = len(self.get_active_tiles(assembly)['active_tiles'])
1✔
772
            fsky = nActive / (12 * nside_tile0**2)
1✔
773
            if nThreads is None:
1✔
774
                nThreads = so3g.useful_info()['omp_num_threads']
1✔
775
            # nside_tile is smallest power of 2 satisfying nTile >= nActivePerThread * nthread / fsky
776
            self.nside_tile = int(2**np.ceil(0.5 * np.log2(nActivePerThread * nThreads / (12 * fsky))))
1✔
777
            self.nside_tile = min(self.nside_tile, self.nside)
1✔
778
        return self.nside_tile
1✔
779

780
    def get_active_tiles(self, assembly, assign=False):
1✔
781
        """For a tiled Projection, figure out what tiles are hit by an
782
        assembly. See parent class documentation for full description.
783

784
        """
785
        self.compute_nside_tile(assembly) # Set nside_tile if 'auto'
1✔
786
        return super().get_active_tiles(assembly, assign)
1✔
787

788
    def get_coords(self, assembly, use_native=False, output=None):
1✔
789
        """Get the spherical coordinates for the provided pointing Assembly.
790
        See parent class documentation for full description.
791

792
        """
793
        projeng = self.get_ProjEng('TQU')
×
794
        if use_native:
×
795
            q_native = self._cache_q_fp_to_native(assembly.Q)
×
796
        else:
797
            q_native = assembly.Q
×
NEW
798
        return projeng.coords(q_native, assembly.fplane.quats, output)
×
799

800
    def from_map(self, src_map, assembly, signal=None, comps=None):
1✔
801
        """De-project from a map, returning a Signal-like object.
802
        See parent class documentation for full description.
803

804
        """
805
        if self.nside_tile is None and src_map.ndim == 1:
1✔
806
            # Promote to (1,...)
807
            src_map = src_map[None]
×
808
        elif self.nside_tile is not None:
1✔
809
            if not isinstance(src_map, list):
×
810
                raise TypeError(f"Expected list for tiled map; src_map is {type(src_map)}")
×
811
            shape = self._get_map_shape(src_map)
×
812
            if len(shape) == 1:
×
813
                # Promote to (1,...)
814
                for itile in range(len(src_map)):
×
815
                    if src_map[itile] is not None:
×
816
                        src_map[itile] = src_map[itile][None]
×
817
        return super().from_map(src_map, assembly, signal, comps)
1✔
818

819
    def assign_threads(self, assembly, method=None, n_threads=None):
1✔
820
        """Get a thread assignment RangesMatrix.
821
        Available methods are ``'simple'`` and ``'tiles'``.
822
        See parent class documentation for full description.
823

824
        """
825
        if method is None:
1✔
826
            if self.nside_tile is None:
×
827
                method = 'simple'
×
828
            else:
829
                method = 'tiles'
×
830
        if (method not in THREAD_ASSIGNMENT_METHODS_HP) and \
1✔
831
           (method in THREAD_ASSIGNMENT_METHODS):
832
           raise ValueError(f'Thread assignment method "{method}" '
×
833
                            'not supported for ProjectionistHealpix. '
834
                            f'Expected one of {THREAD_ASSIGNMENT_METHODS_HP}.')
835
        return super().assign_threads(assembly, method, n_threads)
1✔
836

837
    def _get_pixelizor_args(self):
1✔
838
        """Returns a tuple of arguments that may be passed to the ProjEng
839
        constructor to define the pixelization.
840

841
        """
842
        if self.active_tiles is not None:
1✔
843
            active_tiles = list(map(int, self.active_tiles))
1✔
844
        else:
845
            active_tiles = None
1✔
846
        nside_tile = None
1✔
847
        if self.nside_tile is not None:
1✔
848
            nside_tile = int(self.nside_tile)
1✔
849

850
        args = (int(self.nside),
1✔
851
                int(self.ordering == 'NEST'),
852
                nside_tile,
853
                active_tiles)
854
        return args
1✔
855

856
    def _guess_comps(self, map_shape):
1✔
857
        if len(map_shape) != 2:
×
858
            raise ValueError('Cannot guess components based on '
×
859
                             'map with %i!=2 dimensions!' % len(map_shape))
860
        return super()._guess_comps(map_shape)
×
861

862

863
class _Tiling:
1✔
864
    """Utility class for computations involving tiled maps.
865

866
    """
867
    def __init__(self, shape, tile_shape):
1✔
868
        self.shape = shape[-2:]
1✔
869
        self.tile_shape = tile_shape
1✔
870
        nt0 = int(np.ceil(self.shape[0] / self.tile_shape[0]))
1✔
871
        nt1 = int(np.ceil(self.shape[1] / self.tile_shape[1]))
1✔
872
        self.super_shape = (nt0, nt1)
1✔
873
        self.tile_count = nt0 * nt1
1✔
874
    def tile_dims(self, tile):
1✔
875
        rowcol = self.tile_rowcol(tile)
1✔
876
        return (min(self.shape[0] - rowcol[0] * self.tile_shape[0], self.tile_shape[0]),
1✔
877
                min(self.shape[1] - rowcol[1] * self.tile_shape[1], self.tile_shape[1]))
878
    def tile_rowcol(self, tile):
1✔
879
        if tile >= self.tile_count:
1✔
880
            raise ValueError(f"Request for tile {tile} is out of bounds for "
×
881
                             f"super_shape={self.super_shape}")
882
        return (tile // self.super_shape[1], tile % self.super_shape[1])
1✔
883
    def tile_offset(self, tile):
1✔
884
        row, col = self.tile_rowcol(tile)
1✔
885
        return row * self.tile_shape[0], col * self.tile_shape[1]
1✔
886

887
def wrap_ivals(ivals):
1✔
888
    """Thread computation routines at C++ level return nested lists of
889
    Ranges objects; i.e. something like this::
890

891
      ivals = [
892
               [                             # thread assignments for first "bunch"
893
                 [Ranges, Ranges, ... ],     # for thread 0
894
                 [Ranges, Ranges, ... ],
895
                 ...
896
                 [Ranges, Ranges, ... ],     # for thread n-1.
897
               ],
898
               [                             # thread assignments for second "bunch"
899
                 [Ranges, Ranges, ... ],     # for thread 0
900
               ],
901
              ]
902

903
    This function wraps and returns each highest level entry into a
904
    RangesMatrix, i.e.::
905

906
       wrapped = [
907
               RangesMatrix(n_threads1, n_det, n_samp),
908
               RangesMatrix(n_threads2, n_det, n_samp),
909
       ]
910

911
    Currently all use cases have len(ivals) == 2 and n_threads2 = 1
912
    but the scheme is more general than that.
913

914
    """
915
    return [RangesMatrix([RangesMatrix(y) for y in x]) for x in ivals]
1✔
916

917
THREAD_ASSIGNMENT_METHODS = [
1✔
918
    'simple',
919
    'domdir',
920
    'tiles',
921
]
922

923
THREAD_ASSIGNMENT_METHODS_HP = [
1✔
924
    'simple',
925
    'tiles'
926
]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc