From 97d135ca5601147fca773e92711b0e80731222cb Mon Sep 17 00:00:00 2001 From: Sandro Campos Date: Fri, 1 Nov 2024 15:38:22 -0400 Subject: [PATCH 1/5] Refactor search --- src/hats/catalog/catalog.py | 70 +---------------- .../healpix_dataset/healpix_dataset.py | 78 ++++++++++++++++++- src/hats/pixel_math/box_filter.py | 3 +- src/hats/pixel_math/cone_filter.py | 7 -- src/hats/pixel_math/polygon_filter.py | 24 ------ src/hats/pixel_math/validators.py | 19 ++++- tests/hats/catalog/test_catalog.py | 12 +-- 7 files changed, 98 insertions(+), 115 deletions(-) delete mode 100644 src/hats/pixel_math/cone_filter.py delete mode 100644 src/hats/pixel_math/polygon_filter.py diff --git a/src/hats/catalog/catalog.py b/src/hats/catalog/catalog.py index 9b694ce4..8929f159 100644 --- a/src/hats/catalog/catalog.py +++ b/src/hats/catalog/catalog.py @@ -2,22 +2,10 @@ from __future__ import annotations -from typing import List, Tuple +from typing import List -import numpy as np - -import hats.pixel_math.healpix_shim as hp from hats.catalog.healpix_dataset.healpix_dataset import HealpixDataset from hats.pixel_math import HealpixPixel -from hats.pixel_math.box_filter import generate_box_moc, wrap_ra_angles -from hats.pixel_math.cone_filter import generate_cone_moc -from hats.pixel_math.polygon_filter import CartesianCoordinates, SphericalCoordinates, generate_polygon_moc -from hats.pixel_math.validators import ( - validate_box_search, - validate_declination_values, - validate_polygon, - validate_radius, -) from hats.pixel_tree.negative_tree import compute_negative_tree_pixels @@ -29,62 +17,6 @@ class Catalog(HealpixDataset): `Norder=/Dir=/Npix=.parquet` """ - def filter_by_cone(self, ra: float, dec: float, radius_arcsec: float) -> Catalog: - """Filter the pixels in the catalog to only include the pixels that overlap with a cone - - Args: - ra (float): Right Ascension of the center of the cone in degrees - dec (float): Declination of the center of the cone in degrees - radius_arcsec (float): Radius of the cone in arcseconds - - Returns: - A new catalog with only the pixels that overlap with the specified cone - """ - validate_radius(radius_arcsec) - validate_declination_values(dec) - return self.filter_by_moc(generate_cone_moc(ra, dec, radius_arcsec, self.get_max_coverage_order())) - - def filter_by_box( - self, ra: Tuple[float, float] | None = None, dec: Tuple[float, float] | None = None - ) -> Catalog: - """Filter the pixels in the catalog to only include the pixels that overlap with a - right ascension or declination range. In case both ranges are provided, filtering - is performed using a polygon. - - Args: - ra (Tuple[float, float]): Right ascension range, in degrees - dec (Tuple[float, float]): Declination range, in degrees - - Returns: - A new catalog with only the pixels that overlap with the specified region - """ - ra = tuple(wrap_ra_angles(ra)) if ra else None - validate_box_search(ra, dec) - return self.filter_by_moc(generate_box_moc(ra, dec, self.get_max_coverage_order())) - - def filter_by_polygon(self, vertices: List[SphericalCoordinates] | List[CartesianCoordinates]) -> Catalog: - """Filter the pixels in the catalog to only include the pixels that overlap - with a polygonal sky region. - - Args: - vertices (List[SphericalCoordinates] | List[CartesianCoordinates]): The vertices - of the polygon to filter points with, in lists of (ra,dec) or (x,y,z) points - on the unit sphere. - - Returns: - A new catalog with only the pixels that overlap with the specified polygon. - """ - if all(len(vertex) == 2 for vertex in vertices): - ra, dec = np.array(vertices).T - validate_declination_values(dec) - # Get the coordinates vector on the unit sphere if we were provided - # with polygon spherical coordinates of ra and dec - cart_vertices = hp.ang2vec(ra, dec, lonlat=True) - else: - cart_vertices = vertices - validate_polygon(cart_vertices) - return self.filter_by_moc(generate_polygon_moc(cart_vertices, self.get_max_coverage_order())) - def generate_negative_tree_pixels(self) -> List[HealpixPixel]: """Get the leaf nodes at each healpix order that have zero catalog data. diff --git a/src/hats/catalog/healpix_dataset/healpix_dataset.py b/src/hats/catalog/healpix_dataset/healpix_dataset.py index 3fa32e58..e85be110 100644 --- a/src/hats/catalog/healpix_dataset/healpix_dataset.py +++ b/src/hats/catalog/healpix_dataset/healpix_dataset.py @@ -1,11 +1,13 @@ from __future__ import annotations from pathlib import Path -from typing import List, Union +from typing import List, Tuple, Union +import astropy.units as u import numpy as np import pandas as pd import pyarrow as pa +from astropy.coordinates import SkyCoord from mocpy import MOC from typing_extensions import Self from upath import UPath @@ -15,6 +17,13 @@ from hats.catalog.partition_info import PartitionInfo from hats.inspection import plot_pixels from hats.pixel_math import HealpixPixel +from hats.pixel_math.box_filter import generate_box_moc, wrap_ra_angles +from hats.pixel_math.validators import ( + validate_box, + validate_declination_values, + validate_polygon, + validate_radius, +) from hats.pixel_tree import PixelAlignment, PixelAlignmentType from hats.pixel_tree.moc_filter import filter_by_moc from hats.pixel_tree.pixel_alignment import align_with_mocs @@ -116,12 +125,77 @@ def filter_from_pixel_list(self, pixels: List[HealpixPixel]) -> Self: A new catalog with only the pixels that overlap with the given pixels. Note that we reset the total_rows to None, as updating would require a scan over the new pixel sizes. """ + if len(self.pixel_tree) == 0: + raise ValueError("Cannot filter empty catalog") orders = np.array([p.order for p in pixels]) pixel_inds = np.array([p.pixel for p in pixels]) max_order = np.max(orders) if len(orders) > 0 else 0 moc = MOC.from_healpix_cells(ipix=pixel_inds, depth=orders, max_depth=max_order) return self.filter_by_moc(moc) + def filter_by_cone(self, ra: float, dec: float, radius_arcsec: float) -> Self: + """Filter the pixels in the catalog to only include the pixels that overlap with a cone + + Args: + ra (float): Right Ascension of the center of the cone in degrees + dec (float): Declination of the center of the cone in degrees + radius_arcsec (float): Radius of the cone in arcseconds + + Returns: + A new catalog with only the pixels that overlap with the specified cone + """ + if len(self.pixel_tree) == 0: + raise ValueError("Cannot filter empty catalog") + validate_radius(radius_arcsec) + validate_declination_values(dec) + cone_moc = MOC.from_cone( + lon=ra * u.deg, + lat=dec * u.deg, + radius=radius_arcsec * u.arcsec, + max_depth=self.get_max_coverage_order(), + ) + return self.filter_by_moc(cone_moc) + + def filter_by_box( + self, ra: Tuple[float, float] | None = None, dec: Tuple[float, float] | None = None + ) -> Self: + """Filter the pixels in the catalog to only include the pixels that overlap with a + right ascension or declination range. In case both ranges are provided, filtering + is performed using a polygon. + + Args: + ra (Tuple[float, float]): Right ascension range, in degrees + dec (Tuple[float, float]): Declination range, in degrees + + Returns: + A new catalog with only the pixels that overlap with the specified region + """ + if len(self.pixel_tree) == 0: + raise ValueError("Cannot filter empty catalog") + ra = tuple(wrap_ra_angles(ra)) if ra else None + validate_box(ra, dec) + box_moc = generate_box_moc(ra, dec, self.get_max_coverage_order()) + return self.filter_by_moc(box_moc) + + def filter_by_polygon(self, vertices: list[tuple[float, float]]) -> Self: + """Filter the pixels in the catalog to only include the pixels that overlap + with a polygonal sky region. + + Args: + vertices (list[tuple[float,float]]): The vertices of the polygon to + filter points with, in lists of (ra,dec) points on the unit sphere. + + Returns: + A new catalog with only the pixels that overlap with the specified polygon. + """ + if len(self.pixel_tree) == 0: + raise ValueError("Cannot filter empty catalog") + validate_polygon(vertices) + polygon_moc = MOC.from_polygon_skycoord( + SkyCoord(vertices, unit="deg"), max_depth=self.get_max_coverage_order() + ) + return self.filter_by_moc(polygon_moc) + def filter_by_moc(self, moc: MOC) -> Self: """Filter the pixels in the catalog to only include the pixels that overlap with the moc provided. @@ -131,6 +205,8 @@ def filter_by_moc(self, moc: MOC) -> Self: Returns: A new catalog with only the pixels that overlap with the moc. Note that we reset the total_rows to 0, as updating would require a scan over the new pixel sizes.""" + if len(self.pixel_tree) == 0: + raise ValueError("Cannot filter empty catalog") filtered_tree = filter_by_moc(self.pixel_tree, moc) filtered_moc = self.moc.intersection(moc) if self.moc is not None else None filtered_catalog_info = self.catalog_info.copy_and_update(total_rows=0) diff --git a/src/hats/pixel_math/box_filter.py b/src/hats/pixel_math/box_filter.py index 5c5d8f27..ae33ee45 100644 --- a/src/hats/pixel_math/box_filter.py +++ b/src/hats/pixel_math/box_filter.py @@ -7,7 +7,6 @@ from mocpy import MOC import hats.pixel_math.healpix_shim as hp -from hats.pixel_math.polygon_filter import SphericalCoordinates def generate_box_moc(ra: Tuple[float, float] | None, dec: Tuple[float, float] | None, order: int) -> MOC: @@ -107,7 +106,7 @@ def _get_division_ra(ra_range: Tuple[float, float]) -> float | None: return division_ra -def _get_pixels_for_subpolygons(polygons: List[List[SphericalCoordinates]], order: int) -> np.ndarray: +def _get_pixels_for_subpolygons(polygons: List[List[tuple[float, float]]], order: int) -> np.ndarray: """Gets the unique pixels for a set of sub-polygons.""" nside = hp.order2nside(order) all_polygon_pixels = [] diff --git a/src/hats/pixel_math/cone_filter.py b/src/hats/pixel_math/cone_filter.py deleted file mode 100644 index 168bb36e..00000000 --- a/src/hats/pixel_math/cone_filter.py +++ /dev/null @@ -1,7 +0,0 @@ -import astropy.units as u -from mocpy import MOC - - -def generate_cone_moc(ra: float, dec: float, radius_arcsec: float, order: int) -> MOC: - """Generate a MOC object that covers the cone""" - return MOC.from_cone(lon=ra * u.deg, lat=dec * u.deg, radius=radius_arcsec * u.arcsec, max_depth=order) diff --git a/src/hats/pixel_math/polygon_filter.py b/src/hats/pixel_math/polygon_filter.py deleted file mode 100644 index 6deac4b3..00000000 --- a/src/hats/pixel_math/polygon_filter.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from typing import Tuple - -import numpy as np -from mocpy import MOC -from typing_extensions import TypeAlias - -import hats.pixel_math.healpix_shim as hp - -# Pair of spherical sky coordinates (ra, dec) -SphericalCoordinates: TypeAlias = Tuple[float, float] - -# Sky coordinates on the unit sphere, in cartesian representation (x,y,z) -CartesianCoordinates: TypeAlias = Tuple[float, float, float] - - -def generate_polygon_moc(vertices: np.array, order: int) -> MOC: - """Generates a moc filled with leaf nodes at a given order that overlap within - a polygon. Vertices is an array of cartesian coordinates, in representation (x,y,z) - and shape (Num vertices, 3), representing the vertices of the polygon.""" - polygon_pixels = hp.query_polygon(hp.order2nside(order), vertices, inclusive=True, nest=True) - polygon_orders = np.full(len(polygon_pixels), fill_value=order) - return MOC.from_healpix_cells(ipix=polygon_pixels, depth=polygon_orders, max_depth=order) diff --git a/src/hats/pixel_math/validators.py b/src/hats/pixel_math/validators.py index 3c836c85..74e785cb 100644 --- a/src/hats/pixel_math/validators.py +++ b/src/hats/pixel_math/validators.py @@ -5,6 +5,8 @@ import numpy as np +import hats.pixel_math.healpix_shim as hp + class ValidatorsErrors(str, Enum): """Error messages for the coordinate validators""" @@ -15,6 +17,7 @@ class ValidatorsErrors(str, Enum): DUPLICATE_VERTICES = "polygon has duplicated vertices" DEGENERATE_POLYGON = "polygon is degenerate" INVALID_RADEC_RANGE = "invalid ra or dec range" + INVALID_COORDS_SHAPE = "invalid coordinates shape" def validate_radius(radius_arcsec: float): @@ -45,16 +48,22 @@ def validate_declination_values(dec: float | List[float]): raise ValueError(ValidatorsErrors.INVALID_DEC.value) -def validate_polygon(vertices: np.ndarray): +def validate_polygon(vertices: list[tuple[float, float]]): """Checks if the polygon contain a minimum of three vertices, that they are unique and that the polygon does not fall on a great circle. Args: - vertices (np.ndarray): The polygon vertices, in cartesian coordinates + vertices (list[tuple[float,float]]): The polygon vertices Raises: ValueError: exception if the polygon is invalid. """ + vertices = np.array(vertices) + if vertices.shape[1] != 2: + raise ValueError(ValidatorsErrors.INVALID_COORDS_SHAPE.value) + _, dec = vertices.T + validate_declination_values(dec) + if len(vertices) < 3: raise ValueError(ValidatorsErrors.INVALID_NUM_VERTICES.value) if len(vertices) != len(np.unique(vertices, axis=0)): @@ -68,12 +77,14 @@ def is_polygon_degenerate(vertices: np.ndarray) -> bool: If the plane intersects the center of the sphere, the polygon is degenerate. Args: - vertices (np.ndarray): The polygon vertices, in cartesian coordinates + vertices (np.ndarray): The polygon vertices, in spherical coordinates Returns: A boolean, which is True if the polygon is degenerate, i.e. if it falls on a great circle, False otherwise. """ + vertices = hp.ang2vec(*vertices.T, lonlat=True) + # Calculate the normal vector of the plane using three of the vertices normal_vector = np.cross(vertices[1] - vertices[0], vertices[2] - vertices[0]) @@ -89,7 +100,7 @@ def is_polygon_degenerate(vertices: np.ndarray) -> bool: return bool(np.isclose(center_distance, 0)) -def validate_box_search(ra: Tuple[float, float] | None, dec: Tuple[float, float] | None): +def validate_box(ra: Tuple[float, float] | None, dec: Tuple[float, float] | None): """Checks if ra and dec values are valid for the box search. - At least one range of ra or dec must have been provided diff --git a/tests/hats/catalog/test_catalog.py b/tests/hats/catalog/test_catalog.py index 46b03e5b..18f56cfd 100644 --- a/tests/hats/catalog/test_catalog.py +++ b/tests/hats/catalog/test_catalog.py @@ -224,14 +224,10 @@ def test_polygonal_filter(small_sky_order1_catalog): assert filtered_catalog.moc == polygon_moc.intersection(small_sky_order1_catalog.moc) -def test_polygonal_filter_with_cartesian_coordinates(small_sky_order1_catalog): - sky_vertices = [(282, -58), (282, -55), (272, -55), (272, -58)] - cartesian_vertices = hp.ang2vec(*np.array(sky_vertices).T, lonlat=True) - filtered_catalog_1 = small_sky_order1_catalog.filter_by_polygon(sky_vertices) - filtered_catalog_2 = small_sky_order1_catalog.filter_by_polygon(cartesian_vertices) - assert filtered_catalog_1.get_healpix_pixels() == filtered_catalog_2.get_healpix_pixels() - assert (1, 46) in filtered_catalog_1.pixel_tree - assert (1, 46) in filtered_catalog_2.pixel_tree +def test_polygonal_filter_invalid_coordinate_shape(small_sky_order1_catalog): + with pytest.raises(ValueError, match="coordinates shape"): + vertices = [(282, -58, 1), (282, -55, 2), (272, -55, 3)] + small_sky_order1_catalog.filter_by_polygon(vertices) def test_polygonal_filter_big(small_sky_order1_catalog): From ab28e02ccf682eed2686f0997e3d7f2805529449 Mon Sep 17 00:00:00 2001 From: Sandro Campos Date: Fri, 1 Nov 2024 16:36:00 -0400 Subject: [PATCH 2/5] Improve docstrings --- .../healpix_dataset/healpix_dataset.py | 20 +++++++------------ src/hats/pixel_math/validators.py | 4 ++-- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/hats/catalog/healpix_dataset/healpix_dataset.py b/src/hats/catalog/healpix_dataset/healpix_dataset.py index e85be110..6136960b 100644 --- a/src/hats/catalog/healpix_dataset/healpix_dataset.py +++ b/src/hats/catalog/healpix_dataset/healpix_dataset.py @@ -108,6 +108,8 @@ def __len__(self): def get_max_coverage_order(self) -> int: """Gets the maximum HEALPix order for which the coverage of the catalog is known from the pixel tree and moc if it exists""" + if len(self.pixel_tree) == 0: + raise ValueError("Cannot get max_order of empty catalog") max_order = ( max(self.moc.max_order, self.pixel_tree.get_max_depth()) if self.moc is not None @@ -125,8 +127,6 @@ def filter_from_pixel_list(self, pixels: List[HealpixPixel]) -> Self: A new catalog with only the pixels that overlap with the given pixels. Note that we reset the total_rows to None, as updating would require a scan over the new pixel sizes. """ - if len(self.pixel_tree) == 0: - raise ValueError("Cannot filter empty catalog") orders = np.array([p.order for p in pixels]) pixel_inds = np.array([p.pixel for p in pixels]) max_order = np.max(orders) if len(orders) > 0 else 0 @@ -137,15 +137,13 @@ def filter_by_cone(self, ra: float, dec: float, radius_arcsec: float) -> Self: """Filter the pixels in the catalog to only include the pixels that overlap with a cone Args: - ra (float): Right Ascension of the center of the cone in degrees - dec (float): Declination of the center of the cone in degrees - radius_arcsec (float): Radius of the cone in arcseconds + ra (float): Right ascension of the center of the cone, in degrees + dec (float): Declination of the center of the cone, in degrees + radius_arcsec (float): Radius of the cone, in arcseconds Returns: A new catalog with only the pixels that overlap with the specified cone """ - if len(self.pixel_tree) == 0: - raise ValueError("Cannot filter empty catalog") validate_radius(radius_arcsec) validate_declination_values(dec) cone_moc = MOC.from_cone( @@ -170,8 +168,6 @@ def filter_by_box( Returns: A new catalog with only the pixels that overlap with the specified region """ - if len(self.pixel_tree) == 0: - raise ValueError("Cannot filter empty catalog") ra = tuple(wrap_ra_angles(ra)) if ra else None validate_box(ra, dec) box_moc = generate_box_moc(ra, dec, self.get_max_coverage_order()) @@ -182,14 +178,12 @@ def filter_by_polygon(self, vertices: list[tuple[float, float]]) -> Self: with a polygonal sky region. Args: - vertices (list[tuple[float,float]]): The vertices of the polygon to - filter points with, in lists of (ra,dec) points on the unit sphere. + vertices (list[tuple[float,float]]): The list of vertice coordinates for + the polygon, (ra, dec), in degrees. Returns: A new catalog with only the pixels that overlap with the specified polygon. """ - if len(self.pixel_tree) == 0: - raise ValueError("Cannot filter empty catalog") validate_polygon(vertices) polygon_moc = MOC.from_polygon_skycoord( SkyCoord(vertices, unit="deg"), max_depth=self.get_max_coverage_order() diff --git a/src/hats/pixel_math/validators.py b/src/hats/pixel_math/validators.py index 74e785cb..8ce4a273 100644 --- a/src/hats/pixel_math/validators.py +++ b/src/hats/pixel_math/validators.py @@ -53,7 +53,8 @@ def validate_polygon(vertices: list[tuple[float, float]]): unique and that the polygon does not fall on a great circle. Args: - vertices (list[tuple[float,float]]): The polygon vertices + vertices (list[tuple[float,float]]): The list of vertice coordinates for + the polygon, (ra, dec), in degrees. Raises: ValueError: exception if the polygon is invalid. @@ -63,7 +64,6 @@ def validate_polygon(vertices: list[tuple[float, float]]): raise ValueError(ValidatorsErrors.INVALID_COORDS_SHAPE.value) _, dec = vertices.T validate_declination_values(dec) - if len(vertices) < 3: raise ValueError(ValidatorsErrors.INVALID_NUM_VERTICES.value) if len(vertices) != len(np.unique(vertices, axis=0)): From 817f20bcc814b825460b02e142216b07b806e45b Mon Sep 17 00:00:00 2001 From: Sandro Campos Date: Fri, 8 Nov 2024 13:41:25 -0500 Subject: [PATCH 3/5] Add convexity check --- src/hats/pixel_math/validators.py | 34 +++++++++++++++++++++++++----- tests/hats/catalog/test_catalog.py | 3 +++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/hats/pixel_math/validators.py b/src/hats/pixel_math/validators.py index 8ce4a273..49478f37 100644 --- a/src/hats/pixel_math/validators.py +++ b/src/hats/pixel_math/validators.py @@ -18,6 +18,7 @@ class ValidatorsErrors(str, Enum): DEGENERATE_POLYGON = "polygon is degenerate" INVALID_RADEC_RANGE = "invalid ra or dec range" INVALID_COORDS_SHAPE = "invalid coordinates shape" + INVALID_CONCAVE_SHAPE = "polygon must be convex" def validate_radius(radius_arcsec: float): @@ -68,8 +69,33 @@ def validate_polygon(vertices: list[tuple[float, float]]): raise ValueError(ValidatorsErrors.INVALID_NUM_VERTICES.value) if len(vertices) != len(np.unique(vertices, axis=0)): raise ValueError(ValidatorsErrors.DUPLICATE_VERTICES.value) - if is_polygon_degenerate(vertices): - raise ValueError(ValidatorsErrors.DEGENERATE_POLYGON.value) + check_polygon_is_valid(vertices) + + +def check_polygon_is_valid(vertices: np.ndarray): + """Check if the polygon has no degenerate corners and it is convex. + + Based on HEALpy's `queryPolygonInternal` implementation: + https://github.com/cds-astro/cds.moc/blob/master/src/healpix/essentials/HealpixBase.java. + + Args: + vertices (np.ndarray): The polygon vertices, in cartesian coordinates + + Returns: + True if polygon is valid, False otherwise. + """ + vertices_xyz = hp.ang2vec(*vertices.T, lonlat=True) + n_vertices = len(vertices_xyz) + flip = 0 + for i in range(n_vertices): + normal = np.cross(vertices_xyz[i], vertices_xyz[(i + 1) % n_vertices]) + hnd = normal.dot(vertices_xyz[(i + 2) % n_vertices]) + if np.isclose(hnd, 0, atol=1e-10): + raise ValueError(ValidatorsErrors.DEGENERATE_POLYGON.value) + if i == 0: + flip = -1 if hnd < 0 else 1 + elif flip * hnd <= 0: + raise ValueError(ValidatorsErrors.INVALID_CONCAVE_SHAPE.value) def is_polygon_degenerate(vertices: np.ndarray) -> bool: @@ -77,14 +103,12 @@ def is_polygon_degenerate(vertices: np.ndarray) -> bool: If the plane intersects the center of the sphere, the polygon is degenerate. Args: - vertices (np.ndarray): The polygon vertices, in spherical coordinates + vertices (np.ndarray): The polygon vertices, in cartesian coordinates Returns: A boolean, which is True if the polygon is degenerate, i.e. if it falls on a great circle, False otherwise. """ - vertices = hp.ang2vec(*vertices.T, lonlat=True) - # Calculate the normal vector of the plane using three of the vertices normal_vector = np.cross(vertices[1] - vertices[0], vertices[2] - vertices[0]) diff --git a/tests/hats/catalog/test_catalog.py b/tests/hats/catalog/test_catalog.py index 18f56cfd..efc398df 100644 --- a/tests/hats/catalog/test_catalog.py +++ b/tests/hats/catalog/test_catalog.py @@ -285,6 +285,9 @@ def test_polygonal_filter_invalid_polygon(small_sky_order1_catalog): with pytest.raises(ValueError, match=ValidatorsErrors.DEGENERATE_POLYGON): vertices = [(50.1, 0), (100.1, 0), (150.1, 0), (200.1, 0)] small_sky_order1_catalog.filter_by_polygon(vertices) + with pytest.raises(ValueError, match=ValidatorsErrors.INVALID_CONCAVE_SHAPE): + vertices = [(45, 30), (60, 60), (90, 45), (60, 50)] + small_sky_order1_catalog.filter_by_polygon(vertices) def test_box_filter_ra(small_sky_order1_catalog): From a109bb55d8f3bb109a3a6dc5c09e2e1bf940a843 Mon Sep 17 00:00:00 2001 From: Sandro Campos Date: Fri, 8 Nov 2024 13:43:43 -0500 Subject: [PATCH 4/5] Remove repeated method --- src/hats/pixel_math/validators.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/hats/pixel_math/validators.py b/src/hats/pixel_math/validators.py index 49478f37..6e60e432 100644 --- a/src/hats/pixel_math/validators.py +++ b/src/hats/pixel_math/validators.py @@ -98,32 +98,6 @@ def check_polygon_is_valid(vertices: np.ndarray): raise ValueError(ValidatorsErrors.INVALID_CONCAVE_SHAPE.value) -def is_polygon_degenerate(vertices: np.ndarray) -> bool: - """Checks if all the vertices of the polygon are contained in a same plane. - If the plane intersects the center of the sphere, the polygon is degenerate. - - Args: - vertices (np.ndarray): The polygon vertices, in cartesian coordinates - - Returns: - A boolean, which is True if the polygon is degenerate, i.e. if it falls - on a great circle, False otherwise. - """ - # Calculate the normal vector of the plane using three of the vertices - normal_vector = np.cross(vertices[1] - vertices[0], vertices[2] - vertices[0]) - - # Check if the other vertices lie on the same plane - for vertex in vertices[3:]: - dot_product = np.dot(normal_vector, vertex - vertices[0]) - if not np.isclose(dot_product, 0): - return False - - # Check if the plane intersects the sphere's center. If it does, - # the polygon is degenerate and therefore, invalid. - center_distance = np.dot(normal_vector, vertices[0]) - return bool(np.isclose(center_distance, 0)) - - def validate_box(ra: Tuple[float, float] | None, dec: Tuple[float, float] | None): """Checks if ra and dec values are valid for the box search. From 98bc34725ba75b314fac97b21ea9765dfdd51374 Mon Sep 17 00:00:00 2001 From: Sandro Campos Date: Fri, 8 Nov 2024 15:38:04 -0500 Subject: [PATCH 5/5] Add more unit tests --- src/hats/catalog/healpix_dataset/healpix_dataset.py | 2 -- src/hats/pixel_tree/moc_filter.py | 2 ++ tests/hats/catalog/test_catalog.py | 7 +++++++ tests/hats/pixel_tree/test_moc_filter.py | 10 ++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/hats/catalog/healpix_dataset/healpix_dataset.py b/src/hats/catalog/healpix_dataset/healpix_dataset.py index da1d6282..b5971717 100644 --- a/src/hats/catalog/healpix_dataset/healpix_dataset.py +++ b/src/hats/catalog/healpix_dataset/healpix_dataset.py @@ -200,8 +200,6 @@ def filter_by_moc(self, moc: MOC) -> Self: Returns: A new catalog with only the pixels that overlap with the moc. Note that we reset the total_rows to 0, as updating would require a scan over the new pixel sizes.""" - if len(self.pixel_tree) == 0: - raise ValueError("Cannot filter empty catalog") filtered_tree = filter_by_moc(self.pixel_tree, moc) filtered_moc = self.moc.intersection(moc) if self.moc is not None else None filtered_catalog_info = self.catalog_info.copy_and_update(total_rows=0) diff --git a/src/hats/pixel_tree/moc_filter.py b/src/hats/pixel_tree/moc_filter.py index 56183773..79ddc371 100644 --- a/src/hats/pixel_tree/moc_filter.py +++ b/src/hats/pixel_tree/moc_filter.py @@ -18,6 +18,8 @@ def filter_by_moc( Returns: A new PixelTree object with only the pixels from the input tree that overlap with the moc. """ + if len(tree) == 0: + return tree moc_ranges = moc.to_depth29_ranges # Convert tree intervals to order 29 to match moc intervals tree_29_ranges = tree.tree << (2 * (29 - tree.tree_order)) diff --git a/tests/hats/catalog/test_catalog.py b/tests/hats/catalog/test_catalog.py index efc398df..988e04a8 100644 --- a/tests/hats/catalog/test_catalog.py +++ b/tests/hats/catalog/test_catalog.py @@ -10,6 +10,7 @@ import hats.pixel_math.healpix_shim as hp from hats.catalog import Catalog, PartitionInfo, TableProperties +from hats.catalog.healpix_dataset.healpix_dataset import HealpixDataset from hats.io import paths from hats.io.file_io import read_fits_image from hats.loaders import read_hats @@ -147,6 +148,12 @@ def test_max_coverage_order(small_sky_order1_catalog): ) +def test_max_coverage_order_empty_catalog(catalog_info): + empty_catalog = HealpixDataset(catalog_info, PixelTree.from_healpix([])) + with pytest.raises(ValueError, match="empty catalog"): + empty_catalog.get_max_coverage_order() + + def test_cone_filter(small_sky_order1_catalog): ra = 315 dec = -66.443 diff --git a/tests/hats/pixel_tree/test_moc_filter.py b/tests/hats/pixel_tree/test_moc_filter.py index dc7da579..5b5fefe0 100644 --- a/tests/hats/pixel_tree/test_moc_filter.py +++ b/tests/hats/pixel_tree/test_moc_filter.py @@ -3,6 +3,7 @@ from hats.pixel_math import HealpixPixel from hats.pixel_tree.moc_filter import filter_by_moc +from hats.pixel_tree.pixel_tree import PixelTree def test_moc_filter(pixel_tree_2): @@ -43,6 +44,15 @@ def test_moc_filter_higher_order(pixel_tree_2): ] +def test_moc_filter_with_empty_tree(): + orders = np.array([1, 1, 2]) + pixels = np.array([45, 46, 128]) + moc = MOC.from_healpix_cells(pixels, orders, 2) + empty_tree = PixelTree.from_healpix([]) + filtered_tree = filter_by_moc(empty_tree, moc) + assert filtered_tree.get_healpix_pixels() == [] + + def test_moc_filter_empty_moc(pixel_tree_2): orders = np.array([]) pixels = np.array([])