Skip to content

Add support for geom objects to be rendered with galactic or ecliptic frames - #185

Merged
erykoff merged 27 commits into
mainfrom
geom_frames
Oct 28, 2026
Merged

erykoff merged 27 commits into
mainfrom
geom_frames

Conversation

@erykoff

@erykoff erykoff commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

This PR also adds more convenient ways to clear pixels, including del m[geom] and some optimizations made possible when using pixel ranges.

Also, thanks to some tips from @sidneymau on some memory inefficiencies he has seen I did some investigation into the memory usage when combining very large packed boolean maps and found:

  • There was a problem in the way I was reading in packed boolean arrays from fits which meant that the arrays weren't resizable without a temporary (slow) memory doubling. This was fixed.
  • When doing invert there were unnecessary temporaries being created that could be fixed by doing inversion in place.
  • The bit counting was refactored to take advantage of numpy 2 features which makes it much faster and use much less memory. So doing n_valid or get_valid_area() on a bit-packed map is 12x faster for DP2-sized maps.
  • numpy 2 is now required.

@kadrlica

Copy link
Copy Markdown

I don't know the motivation for del m[geom]. My personal take is:

  • del m[geom] seems overboard to me.
  • m[geom] = None and m &= geom seem reasonable
  • m.clear_pixels(geom) is nice as an explicit interface

@erykoff
erykoff force-pushed the geom_frames branch 2 times, most recently from bbeafcb to 43823f4 Compare October 27, 2026 18:07

@sidneymau sidneymau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few minor comments, but everything looks reasonable to me overall!

Comment thread docs/geometry.rst Outdated
m += healsparse.Circle(ra=202.0, dec=21.0, radius=0.5, value=10)


Using Geometric Shapes To Set And Erase Pixels

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the documentation, it's not clear what is meant by "erasing" a pixel -- does that mean to set the value to UNSEEN? To free the associated memory?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will rewrite the documentation to make this clear. And I should say "clear" pixels, not erase, because that's what the code is called. And what it does is (a) set all pixels to the sentinel value (which is typically UNSEEN for floating point maps, and False for boolean maps, and either 0 or -MAXINT for integer maps) (b) never expand the coverage map (because all of those are already invalid/sentinel values). The associated memory is not freed unless you defragment the map. I'll add all this to the docs.

Comment thread healsparse/geom.py Outdated
in this frame, and output will be equatorial/celestial coordinates.
"""
def __init__(self, *, ra, dec, radius, value, nside_render=None):
def __init__(self, *, ra, dec, radius, value, nside_render=None, geom_frame="equatorial"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would think frame would make more sense than geom_frame throughout -- it's implicit that the frame is the local frame for this object already

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! I'll update.

Comment thread healsparse/healSparseMap.py Outdated
self._n_valid = None

# Convert to an array, no-copy if possible.
_pixels = np.atleast_1d(np.asarray(pixels))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think atleast_1d already converts objects to appropriate array-like objects, so asarray is probably unnecessary (and redundant) here. Did you find this was necessary?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are right.

start, stop = pixel_ranges[:, 0], pixel_ranges[:, 1]
cov0 = np.right_shift(start, shift)
cov1 = np.right_shift(stop - 1, shift) # inclusive; no overrun at the top
nseg = cov1 - cov0 + 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this guaranteed to be nonnegative? I can't seem to break that, so it probably is

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as the pixel ranges are in healpix range ... but now I'm not sure if that's explicitly checked or if it'll just raise an error when called into hpgeom.

Comment thread healsparse/healSparseMap.py Outdated
cov0 = np.right_shift(start, shift)
cov1 = np.right_shift(stop - 1, shift) # inclusive; no overrun at the top
nseg = cov1 - cov0 + 1
rng = np.repeat(np.arange(len(start)), nseg)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using rng as the variable name for anything other than a random number generator is bound to be confusing in the future

Comment thread healsparse/healSparseMap.py Resolved
(key.__class__))

def clear_pixels(self, key):
"""Clear part of a healsparse map.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to my comment about "Erase" earlier -- what is actually happening here? m[key] = None is also shorthand for something more specific. Is the memory associated with each of these keys freed? Or are the values densely set to UNSEEN?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will expand the docstring along with the documentation.

summand += np.sum(last_unpacked[0], dtype=np.int64)
if mid_data is not None:
summand += np.sum(self._bit_count(mid_data), dtype=np.int64)
summand += np.sum(self._last_axis_bit_counts(mid_data, mid_data.size * 8))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use a dtype itemsize instead of 8?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, so the packed boolean array must always beuint8. That gives the most flexibility and efficiency for packing bits. I will add a self._itemsize or something just so it is always clear.

But the changes here on the PR involve the "count number of bits set", not the "check the bits that are set" (which I haven't noticed a problem with but I'll profile that a bit). When I originally wrote this code numpy2 was only in beta, and therefore I couldn't use the new numpy2 bitwise_count routine. This is much faster than the LUT method I was doing before. And it can be made even FASTER if you use wide words (simply viewing the uint8 array as 64-bit words) which then has two effects: (a) the temporary output is 8x smaller (less memory overhead and smaller allocations); (b) numpy will use a special optimized bit-counting instruction on processors that support it. I actually think that the biggest win is moving to the numpy bitwise_count (which is in c rather than via python); second is the memory reduction; third is the special instruction. But all that is somewhat academic, why not do all the optimizations available in numpy 2?

@@ -681,16 +683,31 @@ def _test_bits_at_locs(self, locs):

return self._data[_locs // 8] & (1 << (_locs % 8).astype(np.uint8)) != 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another place where np.dtype(np.int64).itemsize might be useful for generality? E.g., if we ever want a super high resolution map that needs int128 pixels, or if there is ever a need for optimizing smaller maps with smaller bit-packed integers? Or maybe that is overkill...

@erykoff
erykoff merged commit 258972e into main Oct 28, 2026
5 checks passed
@erykoff
erykoff deleted the geom_frames branch October 28, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants