diff --git a/lonboard/_geoarrow/ops/__init__.py b/lonboard/_geoarrow/ops/__init__.py index af743492..09a2bbbc 100644 --- a/lonboard/_geoarrow/ops/__init__.py +++ b/lonboard/_geoarrow/ops/__init__.py @@ -1,5 +1,5 @@ """Geometry operations on GeoArrow memory.""" -from .bbox import total_bounds -from .centroid import weighted_centroid +from .bbox import Bbox, total_bounds +from .centroid import WeightedCentroid, weighted_centroid from .reproject import reproject_column, reproject_table diff --git a/lonboard/_h3/__init__.py b/lonboard/_h3/__init__.py new file mode 100644 index 00000000..5aa804ab --- /dev/null +++ b/lonboard/_h3/__init__.py @@ -0,0 +1,3 @@ +from ._h3_to_str import h3_to_str +from ._str_to_h3 import str_to_h3 +from ._validate_h3_cell import validate_h3_indices diff --git a/lonboard/_h3/_h3_to_str.py b/lonboard/_h3/_h3_to_str.py new file mode 100644 index 00000000..438ed96c --- /dev/null +++ b/lonboard/_h3/_h3_to_str.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from numpy.typing import NDArray + + +def h3_to_str(h3_indices: NDArray[np.uint64]) -> NDArray[np.str_]: + """Convert an array of H3 indices (uint64) to their hexadecimal string representations. + + Returns a numpy array of type S15 (fixed-length ASCII strings of length 15). + """ + # Ensure input is a numpy array of uint64 + hex_chars = np.empty((h3_indices.size, 15), dtype="S1") + + # Prepare hex digits lookup + hex_digits = np.array(list("0123456789ABCDEF"), dtype="S1") + + # Fill each digit + for i in range(15): + shift = (15 - 1 - i) * 4 + hex_chars[:, i] = hex_digits[(h3_indices >> shift) & 0xF] + + return hex_chars.view(" NDArray[np.uint64]: + """Convert an array of hexadecimal strings to H3 indices (uint64). + + This is a pure NumPy vectorized implementation that processes hex strings + character by character without Python loops. + + Args: + hex_arr: Array of hexadecimal strings (15 characters each) + + Returns: + Array of H3 indices as uint64 integers + + """ + if len(hex_arr) == 0: + return np.array([], dtype=np.uint64) + + # Convert to S15 fixed-width byte strings if needed + # View as 2D array of individual bytes (shape: n x 15) + hex_bytes = np.asarray(hex_arr, dtype="S15").view("S1").reshape(len(hex_arr), -1) + + # Convert ASCII bytes to numeric values + # Get the ASCII code of each character + ascii_vals = hex_bytes.view(np.uint8) + + # Convert hex ASCII to numeric values (0-15) + # '0'-'9' (48-57) -> 0-9 + # 'A'-'F' (65-70) -> 10-15 + # 'a'-'f' (97-102) -> 10-15 + vals = ascii_vals - ord("0") # Shift '0' to 0 + vals = np.where(vals > 9, vals - 7, vals) # 'A'=65-48=17 -> 17-7=10 + vals = np.where(vals > 15, vals - 32, vals) # 'a'=97-48=49 -> 49-7=42 -> 42-32=10 + + # Create powers of 16 for each position (most significant first) + # For 15 hex digits: [16^14, 16^13, ..., 16^1, 16^0] + n_digits = hex_bytes.shape[1] + powers = 16 ** np.arange(n_digits - 1, -1, -1, dtype=np.uint64) + + # Compute dot product to get final uint64 values + # Each row: sum(digit_i * 16^(n-1-i)) + return np.dot(vals.astype(np.uint64), powers) diff --git a/lonboard/_h3/_validate_h3_cell.py b/lonboard/_h3/_validate_h3_cell.py new file mode 100644 index 00000000..8954f221 --- /dev/null +++ b/lonboard/_h3/_validate_h3_cell.py @@ -0,0 +1,219 @@ +"""Implement h3 cell validation in pure numpy. + +It's hard to surface errors from deck.gl back to Python, so it's a bad user experience +if the JS console errors and silently nothing renders. But also I don't want to depend +on the h3 library for this because the h3 library isn't vectorized (arghhhh!) and I +don't want to require the dependency. + +So instead, I spend my time porting code into Numpy 😄. + +Ported from Rust code in h3o: + +https://github.com/HydroniumLabs/h3o/blob/07dcb85d9cb539f685ec63050ef0954b1d9f3864/src/index/cell.rs#L1897-L1962 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from numpy.typing import NDArray + +__all__ = ["validate_h3_indices"] + +MODE_OFFSET = 59 +"""Offset (in bits) of the mode in an H3 index.""" + +MODE_MASK = 0b1111 << MODE_OFFSET + +EDGE_OFFSET = 56 +"""Offset (in bits) of the cell edge in an H3 index.""" + +EDGE_MASK = 0b111 << EDGE_OFFSET + +VERTEX_OFFSET = 56 +"""Offset (in bits) of the cell vertex in an H3 index.""" + +VERTEX_MASK = 0b111 << VERTEX_OFFSET + +DIRECTIONS_MASK = 0x0000_1FFF_FFFF_FFFF +"""Bitmask to select the directions bits in an H3 index.""" + +INDEX_MODE_CELL = 1 +"""H3 index mode for cells.""" + +BASE_CELL_OFFSET = 45 +"""Offset (in bits) of the base cell in an H3 index.""" + +BASE_CELL_MASK = 0b111_1111 << BASE_CELL_OFFSET +"""Bitmask to select the base cell bits in an H3 index.""" + +MAX_BASE_CELL = 121 +"""Maximum value for a base cell.""" + +RESOLUTION_OFFSET = 52 +"""The bit offset of the resolution in an H3 index.""" + +RESOLUTION_MASK = 0b1111 << RESOLUTION_OFFSET +"""Bitmask to select the resolution bits in an H3 index.""" + +MAX_RESOLUTION = 15 +"""Maximum supported H3 resolution.""" + +DIRECTION_BITSIZE = 3 +"""Size, in bits, of a direction (range [0; 6]).""" + +BASE_PENTAGONS_HI = 0x0020_0802_0008_0100 +"""Bitmap where a bit's position represents a base cell value (high part). + +Refactored from upstream 128 bit integer +https://github.com/HydroniumLabs/h3o/blob/3b40550291a57552117c48c19841557a3b0431e1/src/base_cell.rs#L12 +""" + +BASE_PENTAGONS_LO = 0x8402_0040_0100_4010 +"""Bitmap where a bit's position represents a base cell value (low part). + +Refactored from upstream 128 bit integer +https://github.com/HydroniumLabs/h3o/blob/3b40550291a57552117c48c19841557a3b0431e1/src/base_cell.rs#L12 +""" + +PENTAGON_BASE_CELLS = np.array( + [4, 14, 24, 33, 38, 49, 58, 63, 72, 83, 97, 107], + dtype=np.uint8, +) +"""Set of pentagon base cells.""" + + +def validate_h3_indices(h3_indices: NDArray[np.uint64]) -> None: + """Validate an array of uint64 H3 indices. + + Raises ValueError if any index is invalid. + """ + invalid_reserved_bits = h3_indices >> 56 & 0b1000_0111 != 0 + bad_indices = np.where(invalid_reserved_bits)[0] + if len(bad_indices) > 0: + raise ValueError( + f"Tainted reserved bits in indices: {bad_indices.tolist()}\n" + f"with values {h3_indices[bad_indices].tolist()}", + ) + + invalid_mode = get_mode(h3_indices) != INDEX_MODE_CELL + bad_indices = np.where(invalid_mode)[0] + if len(bad_indices) > 0: + raise ValueError( + f"Invalid index mode in indices: {bad_indices.tolist()}", + f"with values {h3_indices[bad_indices].tolist()}", + ) + + base = get_base_cell(h3_indices) + invalid_base_cell = base > MAX_BASE_CELL + bad_indices = np.where(invalid_base_cell)[0] + if len(bad_indices) > 0: + raise ValueError( + f"Invalid base cell in indices: {bad_indices.tolist()}", + f"with values {h3_indices[bad_indices].tolist()}", + ) + + # Resolution is always valid: coded on 4 bits, valid range is [0; 15]. + resolution = get_resolution(h3_indices) + + # Check that we have a tail of unused cells after `resolution` cells. + # + # We expect every bit to be 1 in the tail (because unused cells are + # represented by `0b111`), i.e. every bit set to 0 after a NOT. + unused_count = MAX_RESOLUTION - resolution + unused_bitsize = unused_count * DIRECTION_BITSIZE + unused_mask = (1 << unused_bitsize.astype(np.uint64)) - 1 + invalid_unused_direction_pattern = (~h3_indices) & unused_mask != 0 + bad_indices = np.where(invalid_unused_direction_pattern)[0] + if len(bad_indices) > 0: + raise ValueError( + f"Invalid unused direction pattern in indices: {bad_indices.tolist()}", + f"with values {h3_indices[bad_indices].tolist()}", + ) + + # Check that we have `resolution` valid cells (no unused ones). + dirs_mask = (1 << (resolution * DIRECTION_BITSIZE).astype(np.uint64)) - 1 + dirs = (h3_indices >> unused_bitsize) & dirs_mask + invalid_unused_direction = has_unused_direction(dirs) + bad_indices = np.where(invalid_unused_direction)[0] + if len(bad_indices) > 0: + raise ValueError( + f"Unexpected unused direction in indices: {bad_indices.tolist()}", + f"with values {h3_indices[bad_indices].tolist()}", + ) + + # Check for pentagons with deleted subsequence. + has_pentagon_base = np.logical_and(is_pentagon(base), resolution != 0) + pentagon_base_indices = np.where(has_pentagon_base)[0] + if len(pentagon_base_indices) > 0: + pentagons = h3_indices[pentagon_base_indices] + pentagon_resolutions = resolution[pentagon_base_indices] + pentagon_dirs = dirs[pentagon_base_indices] + + # Move directions to the front, so that we can count leading zeroes. + pentagon_offset = 64 - (pentagon_resolutions * DIRECTION_BITSIZE) + + # NOTE: The following was ported via GPT from Rust `leading_zeros` + # https://github.com/HydroniumLabs/h3o/blob/07dcb85d9cb539f685ec63050ef0954b1d9f3864/src/index/cell.rs#L1951 + + # Find the position of the first bit set, if it's a multiple of 3 + # that means we have a K axe as the first non-center direction, + # which is forbidden. + shifted = pentagon_dirs << pentagon_offset + + # Compute leading zeros for each element (assuming 64-bit unsigned integers) + # where `leading_zeros = 64 - shifted.bit_length()` + # numpy doesn't have bit_length, so use log2 and handle zeros + bitlen = np.where(shifted == 0, 0, np.floor(np.log2(shifted)).astype(int) + 1) + leading_zeros = 64 - bitlen + + # Add 1 and check if multiple of 3 + is_multiple_of_3 = ((leading_zeros + 1) % 3) == 0 + bad_indices = np.where(is_multiple_of_3)[0] + if len(bad_indices) > 0: + raise ValueError( + f"Pentagonal cell index with a deleted subsequence: {bad_indices.tolist()}", + f"with values {pentagons[bad_indices].tolist()}", + ) + + +def get_mode(bits: NDArray[np.uint64]) -> NDArray[np.uint8]: + """Return the H3 index mode bits.""" + return ((bits & MODE_MASK) >> MODE_OFFSET).astype(np.uint8) + + +def get_base_cell(bits: NDArray[np.uint64]) -> NDArray[np.uint8]: + """Return the H3 index base cell bits.""" + return ((bits & BASE_CELL_MASK) >> BASE_CELL_OFFSET).astype(np.uint8) + + +def get_resolution(bits: NDArray[np.uint64]) -> NDArray[np.uint8]: + """Return the H3 index resolution.""" + return ((bits & RESOLUTION_MASK) >> RESOLUTION_OFFSET).astype(np.uint8) + + +def has_unused_direction(dirs: NDArray) -> NDArray[np.bool_]: + """Check if there is at least one unused direction in the given directions. + + Copied from upstream + https://github.com/HydroniumLabs/h3o/blob/07dcb85d9cb539f685ec63050ef0954b1d9f3864/src/index/cell.rs#L2056-L2107 + """ + LO_MAGIC = 0b001_001_001_001_001_001_001_001_001_001_001_001_001_001_001 # noqa: N806 + HI_MAGIC = 0b100_100_100_100_100_100_100_100_100_100_100_100_100_100_100 # noqa: N806 + + return ((~dirs - LO_MAGIC) & (dirs & HI_MAGIC)) != 0 + + +def is_pentagon(cell: NDArray[np.uint8]) -> NDArray[np.bool_]: + """Return true if the base cell is pentagonal. + + Note that this is **not** copied from the upstream: + https://github.com/HydroniumLabs/h3o/blob/3b40550291a57552117c48c19841557a3b0431e1/src/base_cell.rs#L33-L47 + + Because they use a 128 bit integer as a bitmap, which is not available in + numpy. Instead we use a simple lookup in a static array. + """ + return np.isin(cell, PENTAGON_BASE_CELLS) diff --git a/lonboard/_utils.py b/lonboard/_utils.py index 1276a46c..eeaeeae2 100644 --- a/lonboard/_utils.py +++ b/lonboard/_utils.py @@ -58,12 +58,19 @@ def auto_downcast(df: DF) -> DF: check_pandas_version() + # This will fail if the df is pandas input: + # TypeError: data type 'geometry' not understood + try: + df_attr = df.select_dtypes(exclude="geometry") + except TypeError: + df_attr = df + # Convert objects to numeric types where possible. # Note: we have to exclude geometry because - # `convert_dtypes(dtype_backend="pyarrow")` fails on the geometory column, but we + # `convert_dtypes(dtype_backend="pyarrow")` fails on the geometry column, but we # also have to manually cast to a non-geo data frame because it'll fail to convert # dtypes on a GeoDataFrame without a geom col - casted_df = pd.DataFrame(df.select_dtypes(exclude="geometry")).convert_dtypes( # type: ignore + casted_df = pd.DataFrame(df_attr).convert_dtypes( # type: ignore infer_objects=True, convert_string=True, convert_integer=True, diff --git a/lonboard/layer/_base.py b/lonboard/layer/_base.py index ca4c462a..30fac635 100644 --- a/lonboard/layer/_base.py +++ b/lonboard/layer/_base.py @@ -14,19 +14,20 @@ from lonboard._geoarrow._duckdb import from_duckdb as _from_duckdb from lonboard._geoarrow.c_stream_import import import_arrow_c_stream from lonboard._geoarrow.geopandas_interop import geopandas_to_geoarrow -from lonboard._geoarrow.ops import reproject_table -from lonboard._geoarrow.ops.bbox import Bbox, total_bounds -from lonboard._geoarrow.ops.centroid import WeightedCentroid, weighted_centroid +from lonboard._geoarrow.ops import ( + Bbox, + WeightedCentroid, + reproject_table, + total_bounds, + weighted_centroid, +) from lonboard._geoarrow.ops.coord_layout import make_geometry_interleaved from lonboard._geoarrow.parse_wkb import parse_serialized_table from lonboard._geoarrow.row_index import add_positional_row_index from lonboard._serialization import infer_rows_per_chunk from lonboard._utils import auto_downcast as _auto_downcast from lonboard._utils import get_geometry_column_index, remove_extension_kwargs -from lonboard.traits import ( - ArrowTableTrait, - VariableLengthTuple, -) +from lonboard.traits import ArrowTableTrait, VariableLengthTuple if TYPE_CHECKING: import sys diff --git a/lonboard/layer/_bitmap.py b/lonboard/layer/_bitmap.py index 9e1ca3da..a8e6d89a 100644 --- a/lonboard/layer/_bitmap.py +++ b/lonboard/layer/_bitmap.py @@ -6,8 +6,7 @@ import traitlets as t -from lonboard._geoarrow.ops.bbox import Bbox -from lonboard._geoarrow.ops.centroid import WeightedCentroid +from lonboard._geoarrow.ops import Bbox, WeightedCentroid from lonboard.layer._base import BaseLayer from lonboard.traits import ( VariableLengthTuple, diff --git a/lonboard/layer/_h3.py b/lonboard/layer/_h3.py new file mode 100644 index 00000000..c1ff7229 --- /dev/null +++ b/lonboard/layer/_h3.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +import numpy as np +import traitlets as t + +from lonboard._geoarrow.ops import Bbox, WeightedCentroid +from lonboard._utils import auto_downcast as _auto_downcast +from lonboard.layer._base import BaseArrowLayer +from lonboard.traits import ArrowTableTrait, ColorAccessor, FloatAccessor +from lonboard.traits._h3 import H3Accessor + +if TYPE_CHECKING: + import sys + + import pandas as pd + from arro3.core import ChunkedArray + from arro3.core.types import ArrowStreamExportable + + from lonboard.types.layer import H3AccessorInput, H3HexagonLayerKwargs + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + + if sys.version_info >= (3, 12): + from typing import Unpack + else: + from typing_extensions import Unpack + +SEED_VALUE = 42 +RNG = np.random.default_rng(SEED_VALUE) +MAX_SAMPLE_N = 10000 + + +def default_h3_viewport(ca: ChunkedArray) -> tuple[Bbox, WeightedCentroid] | None: + try: + import h3.api.numpy_int as h3 + except ImportError: + warnings.warn( + "h3-py is not installed, cannot compute default H3 viewport.", + ImportWarning, + ) + return None + + sample_n = min(MAX_SAMPLE_N, len(ca)) + sample_h3 = RNG.choice(ca.to_numpy(), size=sample_n, replace=False) + + if not hasattr(h3, "cells_to_geo"): + warnings.warn( + "h3-py v4 is not installed, cannot compute default H3 viewport.", + ImportWarning, + ) + return None + + geo_dict = h3.cells_to_geo(sample_h3) + + geom_type = geo_dict.get("type") + if geom_type == "Polygon": + polygons = [geo_dict["coordinates"]] + elif geom_type == "MultiPolygon": + polygons = geo_dict["coordinates"] + else: + # I think it should always be Polygon/MultiPolygon + return None + + coords = np.array( + [pt for polygon in polygons for ring in polygon for pt in ring], + dtype=np.float64, + ) + centroid = coords.mean(axis=0) + + minx, miny = coords.min(axis=0) + maxx, maxy = coords.max(axis=0) + + return ( + Bbox(minx=float(minx), miny=float(miny), maxx=float(maxx), maxy=float(maxy)), + # Still give it the weight of the full input dataset + WeightedCentroid(x=float(centroid[0]), y=float(centroid[1]), num_items=len(ca)), + ) + + +class H3HexagonLayer(BaseArrowLayer): + """The `H3HexagonLayer` renders H3 hexagons. + + **Example:** + + From GeoPandas: + + ```py + import geopandas as gpd + from lonboard import Map, H3HexagonLayer + + # A GeoDataFrame with Polygon or MultiPolygon geometries + gdf = gpd.GeoDataFrame() + layer = H3HexagonLayer.from_geopandas( + gdf, + get_fill_color=[255, 0, 0], + ) + m = Map(layer) + ``` + + From an Arrow-compatible source like [pyogrio][pyogrio] or [geoarrow-rust](https://geoarrow.github.io/geoarrow-rs/python/latest): + + ```py + from geoarrow.rust.io import read_flatgeobuf + from lonboard import Map, H3HexagonLayer + + # Example: A FlatGeobuf file with Polygon or MultiPolygon geometries + table = read_flatgeobuf("path/to/file.fgb") + layer = H3HexagonLayer( + table, + get_fill_color=[255, 0, 0], + ) + m = Map(layer) + ``` + """ + + def __init__( + self, + table: ArrowStreamExportable, + *, + get_hexagon: H3AccessorInput, + _rows_per_chunk: int | None = None, + **kwargs: Unpack[H3HexagonLayerKwargs], + ) -> None: + """Create a new H3HexagonLayer. + + Args: + table: _description_ + + Keyword Args: + get_hexagon: _description_ + kwargs: Extra args passed down as H3HexagonLayer attributes. + + """ + super().__init__( + table=table, + get_hexagon=get_hexagon, + _rows_per_chunk=_rows_per_chunk, + **kwargs, + ) + + # Assign viewport after get_hexagon has already been validated to be uint64 + # array + default_viewport = default_h3_viewport(self.get_hexagon) + if default_viewport is not None: + self._bbox = default_viewport[0] + self._weighted_centroid = default_viewport[1] + + @classmethod + def from_pandas( + cls, + df: pd.DataFrame, + *, + get_hexagon: H3AccessorInput, + auto_downcast: bool = True, + **kwargs: Unpack[H3HexagonLayerKwargs], + ) -> Self: + """Create a new H3HexagonLayer from a pandas DataFrame. + + Args: + df: _description_ + + Keyword Args: + get_hexagon: _description_ + auto_downcast: _description_. Defaults to True. + kwargs: Extra args passed down as H3HexagonLayer attributes. + + Raises: + ImportError: _description_ + + Returns: + _description_ + + """ + try: + import pyarrow as pa + except ImportError as e: + raise ImportError( + "pyarrow required for converting GeoPandas to arrow.\n" + "Run `pip install pyarrow`.", + ) from e + + if auto_downcast: + # Note: we don't deep copy because we don't need to clone geometries + df = _auto_downcast(df.copy()) # type: ignore + + table = pa.Table.from_pandas(df) + return cls(table, get_hexagon=get_hexagon, **kwargs) + + _layer_type = t.Unicode("h3-hexagon").tag(sync=True) + + table = ArrowTableTrait(geometry_required=False) + + get_hexagon = H3Accessor() + """ + todo + """ + + high_precision = t.Bool(None, allow_none=True).tag(sync=True) + + stroked = t.Bool(None, allow_none=True).tag(sync=True) + """Whether to draw an outline around the polygon (solid fill). + + Note that both the outer polygon as well the outlines of any holes will be drawn. + + - Type: `bool`, optional + - Default: `True` + """ + + filled = t.Bool(None, allow_none=True).tag(sync=True) + """Whether to draw a filled polygon (solid fill). + + Note that only the area between the outer polygon and any holes will be filled. + + - Type: `bool`, optional + - Default: `True` + """ + + extruded = t.Bool(None, allow_none=True).tag(sync=True) + """Whether to extrude the polygons. + + Based on the elevations provided by the `getElevation` accessor. + + If set to `false`, all polygons will be flat, this generates less geometry and is + faster than simply returning 0 from getElevation. + + - Type: `bool`, optional + - Default: `False` + """ + + wireframe = t.Bool(None, allow_none=True).tag(sync=True) + """ + Whether to generate a line wireframe of the polygon. The outline will have + "horizontal" lines closing the top and bottom polygons and a vertical line + (a "strut") for each vertex on the polygon. + + - Type: `bool`, optional + - Default: `False` + + **Remarks:** + + - These lines are rendered with `GL.LINE` and will thus always be 1 pixel wide. + - Wireframe and solid extrusions are exclusive, you'll need to create two layers + with the same data if you want a combined rendering effect. + """ + + elevation_scale = t.Float(None, allow_none=True, min=0).tag(sync=True) + """Elevation multiplier. + + The final elevation is calculated by `elevationScale * getElevation(d)`. + `elevationScale` is a handy property to scale all elevation without updating the + data. + + - Type: `float`, optional + - Default: `1` + """ + + line_width_units = t.Unicode(None, allow_none=True).tag(sync=True) + """ + The units of the outline width, one of `'meters'`, `'common'`, and `'pixels'`. See + [unit + system](https://deck.gl/docs/developer-guide/coordinate-systems#supported-units). + + - Type: `str`, optional + - Default: `'meters'` + """ + + line_width_scale = t.Float(None, allow_none=True, min=0).tag(sync=True) + """ + The outline width multiplier that multiplied to all outlines of `Polygon` and + `MultiPolygon` features if the `stroked` attribute is true. + + - Type: `float`, optional + - Default: `1` + """ + + line_width_min_pixels = t.Float(None, allow_none=True, min=0).tag(sync=True) + """ + The minimum outline width in pixels. This can be used to prevent the outline from + getting too small when zoomed out. + + - Type: `float`, optional + - Default: `0` + """ + + line_width_max_pixels = t.Float(None, allow_none=True, min=0).tag(sync=True) + """ + The maximum outline width in pixels. This can be used to prevent the outline from + getting too big when zoomed in. + + - Type: `float`, optional + - Default: `None` + """ + + line_joint_rounded = t.Bool(None, allow_none=True).tag(sync=True) + """Type of joint. If `true`, draw round joints. Otherwise draw miter joints. + + - Type: `bool`, optional + - Default: `False` + """ + + line_miter_limit = t.Float(None, allow_none=True, min=0).tag(sync=True) + """The maximum extent of a joint in ratio to the stroke width. + + Only works if `line_joint_rounded` is false. + + - Type: `float`, optional + - Default: `4` + """ + + get_fill_color = ColorAccessor(None, allow_none=True) + """ + The fill color of each polygon in the format of `[r, g, b, [a]]`. Each channel is a + number between 0-255 and `a` is 255 if not supplied. + + - Type: [ColorAccessor][lonboard.traits.ColorAccessor], optional + - If a single `list` or `tuple` is provided, it is used as the fill color for + all polygons. + - If a numpy or pyarrow array is provided, each value in the array will be used + as the fill color for the polygon at the same row index. + - Default: `[0, 0, 0, 255]`. + """ + + get_line_color = ColorAccessor(None, allow_none=True) + """ + The outline color of each polygon in the format of `[r, g, b, [a]]`. Each channel is + a number between 0-255 and `a` is 255 if not supplied. + + Only applies if `stroked=True`. + + - Type: [ColorAccessor][lonboard.traits.ColorAccessor], optional + - If a single `list` or `tuple` is provided, it is used as the outline color for + all polygons. + - If a numpy or pyarrow array is provided, each value in the array will be used + as the outline color for the polygon at the same row index. + - Default: `[0, 0, 0, 255]`. + """ + + get_line_width = FloatAccessor(None, allow_none=True) + """ + The width of the outline of each polygon, in units specified by `line_width_units` + (default `'meters'`). + + - Type: [FloatAccessor][lonboard.traits.FloatAccessor], optional + - If a number is provided, it is used as the outline width for all polygons. + - If an array is provided, each value in the array will be used as the outline + width for the polygon at the same row index. + - Default: `1`. + """ + + get_elevation = FloatAccessor(None, allow_none=True) + """ + The elevation to extrude each polygon with, in meters. + + Only applies if `extruded=True`. + + - Type: [FloatAccessor][lonboard.traits.FloatAccessor], optional + - If a number is provided, it is used as the width for all polygons. + - If an array is provided, each value in the array will be used as the width for + the polygon at the same row index. + - Default: `1000`. + """ diff --git a/lonboard/traits/_h3.py b/lonboard/traits/_h3.py new file mode 100644 index 00000000..ae4d37c9 --- /dev/null +++ b/lonboard/traits/_h3.py @@ -0,0 +1,134 @@ +# ruff: noqa: SLF001 + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np +from arro3.core import ( + Array, + ChunkedArray, + DataType, +) + +from lonboard._h3._str_to_h3 import str_to_h3 +from lonboard._serialization import ( + ACCESSOR_SERIALIZATION, +) +from lonboard.traits._base import FixedErrorTraitType + +if TYPE_CHECKING: + import pandas as pd + from numpy.typing import NDArray + from traitlets.traitlets import TraitType + + from lonboard.layer import BaseArrowLayer + + +class H3Accessor(FixedErrorTraitType): + """A trait to validate h3 cell input. + + Various input is allowed: + + - A numpy `ndarray` with an object, S15, or uint64 data type. + - A pandas `Series` with an object or uint64 data type. + - A pyarrow string, large string, string view array, or uint64 array, or a chunked array of those types. + - Any Arrow string, large string, string view array, or uint64 array, or a chunked array of those types from a library that implements the [Arrow PyCapsule + Interface](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html). + """ + + default_value = None + info_text = ( + "a float value or numpy ndarray or Arrow array representing an array of floats" + ) + + def __init__( + self: TraitType, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.tag(sync=True, **ACCESSOR_SERIALIZATION) + + def _pandas_to_numpy( + self, + obj: BaseArrowLayer, + value: pd.Series, + ) -> NDArray[np.str_] | NDArray[np.uint64]: + """Cast pandas Series to numpy ndarray.""" + if isinstance(value.dtype, np.dtype) and np.issubdtype(value.dtype, np.integer): + return np.asarray(value, dtype=np.uint64) + + if not isinstance(value.dtype, np.dtype) or not np.issubdtype( + value.dtype, + np.object_, + ): + self.error( + obj, + value, + info="H3 Pandas series not object or uint64 dtype.", + ) + + if not (value.str.len() == 15).all(): + self.error( + obj, + value, + info="H3 Pandas series not all 15 characters long.", + ) + + return np.asarray(value, dtype="S15") + + def _numpy_to_arrow(self, obj: BaseArrowLayer, value: np.ndarray) -> ChunkedArray: + if np.issubdtype(value.dtype, np.uint64): + return ChunkedArray([value]) + + if np.issubdtype(value.dtype, np.object_): + if {len(v) for v in value} != {15}: + self.error( + obj, + value, + info="numpy object array not all 15 characters long", + ) + + value = np.asarray(value, dtype="S15") + + if not np.issubdtype(value.dtype, np.dtype("S15")): + self.error(obj, value, info="numpy array not object, str, or uint64 dtype") + + h3_uint8_array = str_to_h3(value) + return ChunkedArray([h3_uint8_array]) + + def validate(self, obj: BaseArrowLayer, value: Any) -> ChunkedArray: + # pandas Series + if ( + value.__class__.__module__.startswith("pandas") + and value.__class__.__name__ == "Series" + ): + value = self._pandas_to_numpy(obj, value) + + if isinstance(value, np.ndarray): + value = self._numpy_to_arrow(obj, value) + elif hasattr(value, "__arrow_c_array__"): + value = ChunkedArray([Array.from_arrow(value)]) + elif hasattr(value, "__arrow_c_stream__"): + value = ChunkedArray.from_arrow(value) + else: + self.error(obj, value) + + assert isinstance(value, ChunkedArray) + + if ( + DataType.is_string(value.type) + or DataType.is_large_string(value.type) + or DataType.is_string_view(value.type) + ): + value = self._numpy_to_arrow(obj, value.to_numpy()) + + if not DataType.is_uint64(value.type): + self.error( + obj, + value, + info="H3 Arrow array must be uint64 type.", + ) + + return value.rechunk(max_chunksize=obj._rows_per_chunk) diff --git a/lonboard/types/layer.py b/lonboard/types/layer.py index 17e4d503..0b5cb4f7 100644 --- a/lonboard/types/layer.py +++ b/lonboard/types/layer.py @@ -49,6 +49,19 @@ ArrowArrayExportable, ArrowStreamExportable, ] +H3AccessorInput = Union[ + NDArray[np.object_], + NDArray[np.str_], + NDArray[np.uint64], + "pd.Series", + "pa.StringArray", + "pa.LargeStringArray", + "pa.StringViewArray", + "pa.UInt64Array", + "pa.ChunkedArray", + ArrowArrayExportable, + ArrowStreamExportable, +] NormalAccessorInput = Union[ list[int], tuple[int, int, int], @@ -156,6 +169,25 @@ class ColumnLayerKwargs(BaseLayerKwargs, total=False): get_line_width: FloatAccessorInput +class H3HexagonLayerKwargs(BaseLayerKwargs, total=False): + high_precision: bool + stroked: bool + filled: bool + extruded: bool + wireframe: bool + elevation_scale: IntFloat + line_width_units: Units + line_width_scale: IntFloat + line_width_min_pixels: IntFloat + line_width_max_pixels: IntFloat + line_joint_rounded: bool + line_miter_limit: IntFloat + get_fill_color: ColorAccessorInput + get_line_color: ColorAccessorInput + get_line_width: FloatAccessorInput + get_elevation: FloatAccessorInput + + class PathLayerKwargs(BaseLayerKwargs, total=False): width_units: Units width_scale: IntFloat diff --git a/package-lock.json b/package-lock.json index 67480474..8485eb1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@deck.gl/mapbox": "^9.2.2", "@deck.gl/mesh-layers": "^9.2.2", "@deck.gl/react": "^9.2.2", - "@geoarrow/deck.gl-layers": "^0.4.0-beta.1", + "@geoarrow/deck.gl-layers": "^0.4.0-beta.4", "@geoarrow/geoarrow-js": "^0.3.2", "@nextui-org/react": "^2.4.8", "@xstate/react": "^6.0.0", diff --git a/package.json b/package.json index 45c30006..bc713e34 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@deck.gl/mapbox": "^9.2.2", "@deck.gl/mesh-layers": "^9.2.2", "@deck.gl/react": "^9.2.2", - "@geoarrow/deck.gl-layers": "^0.4.0-beta.1", + "@geoarrow/deck.gl-layers": "^0.4.0-beta.4", "@geoarrow/geoarrow-js": "^0.3.2", "@nextui-org/react": "^2.4.8", "@xstate/react": "^6.0.0", diff --git a/pyproject.toml b/pyproject.toml index 52866e49..ae5af152 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ dev = [ "geoarrow-rust-core>=0.4.0", "geoarrow-rust-io>=0.4.1", "geodatasets>=2024.8.0", + "h3>=4.3.1", "jupyterlab>=4.3.3", "matplotlib>=3.7.5", "movingpandas>=0.20.0", @@ -119,11 +120,12 @@ artifacts = ["lonboard/static/*.js", "lonboard/static/*.css"] [[tool.mypy.overrides]] module = [ - "pyogrio.*", - "pyarrow.*", + "geoarrow.pyarrow.*", "geodatasets.*", + "h3.*", "ipywidgets.*", - "geoarrow.pyarrow.*", + "pyarrow.*", + "pyogrio.*", ] ignore_missing_imports = true diff --git a/src/model/layer.ts b/src/model/layer.ts index 16435706..66c21675 100644 --- a/src/model/layer.ts +++ b/src/model/layer.ts @@ -12,9 +12,11 @@ import { GeoArrowScatterplotLayer, GeoArrowSolidPolygonLayer, _GeoArrowTextLayer as GeoArrowTextLayer, + GeoArrowH3HexagonLayer, GeoArrowTripsLayer, } from "@geoarrow/deck.gl-layers"; import type { + GeoArrowH3HexagonLayerProps, GeoArrowArcLayerProps, GeoArrowColumnLayerProps, GeoArrowHeatmapLayerProps, @@ -427,6 +429,78 @@ export class ColumnModel extends BaseArrowLayerModel { } } +export class H3HexagonModel extends BaseArrowLayerModel { + static layerType = "h3-hexagon"; + + protected highPrecision?: GeoArrowH3HexagonLayerProps["highPrecision"] | null; + protected coverage?: GeoArrowH3HexagonLayerProps["coverage"] | null; + protected centerHexagon?: GeoArrowH3HexagonLayerProps["centerHexagon"] | null; + protected extruded?: GeoArrowH3HexagonLayerProps["extruded"] | null; + + protected getHexagon!: arrow.Vector; + protected getFillColor?: ColorAccessorInput | null; + protected getLineColor?: ColorAccessorInput | null; + protected getLineWidth?: FloatAccessorInput | null; + protected getElevation?: FloatAccessorInput | null; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("high_precision", "highPrecision"); + this.initRegularAttribute("coverage", "coverage"); + this.initRegularAttribute("center_hexagon", "centerHexagon"); + this.initRegularAttribute("extruded", "extruded"); + + this.initVectorizedAccessor("get_hexagon", "getHexagon"); + this.initVectorizedAccessor("get_fill_color", "getFillColor"); + this.initVectorizedAccessor("get_line_color", "getLineColor"); + this.initVectorizedAccessor("get_line_width", "getLineWidth"); + this.initVectorizedAccessor("get_elevation", "getElevation"); + } + + layerProps(batchIndex: number): GeoArrowH3HexagonLayerProps { + return { + id: `${this.model.model_id}-${batchIndex}`, + data: this.table.batches[batchIndex], + // Required argument + getHexagon: this.getHexagon.data[batchIndex], + ...(isDefined(this.highPrecision) && { + highPrecision: this.highPrecision, + }), + ...(isDefined(this.coverage) && { coverage: this.coverage }), + ...(isDefined(this.centerHexagon) && { + centerHexagon: this.centerHexagon, + }), + ...(isDefined(this.extruded) && { extruded: this.extruded }), + ...(isDefined(this.getFillColor) && { + getFillColor: accessColorData(this.getFillColor, batchIndex), + }), + ...(isDefined(this.getLineColor) && { + getLineColor: accessColorData(this.getLineColor, batchIndex), + }), + ...(isDefined(this.getLineWidth) && { + getLineWidth: accessFloatData(this.getLineWidth, batchIndex), + }), + ...(isDefined(this.getElevation) && { + getElevation: accessFloatData(this.getElevation, batchIndex), + }), + }; + } + + render(): GeoArrowH3HexagonLayer[] { + const layers: GeoArrowH3HexagonLayer[] = []; + for (let batchIdx = 0; batchIdx < this.table.batches.length; batchIdx++) { + layers.push( + new GeoArrowH3HexagonLayer({ + ...this.baseLayerProps(), + ...this.layerProps(batchIdx), + }), + ); + } + return layers; + } +} + export class HeatmapModel extends BaseArrowLayerModel { static layerType = "heatmap"; @@ -1277,6 +1351,10 @@ export async function initializeLayer( layerModel = new ColumnModel(model, updateStateCallback); break; + case H3HexagonModel.layerType: + layerModel = new H3HexagonModel(model, updateStateCallback); + break; + case HeatmapModel.layerType: layerModel = new HeatmapModel(model, updateStateCallback); break; diff --git a/tests/test_h3/__init__.py b/tests/test_h3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_h3/test_h3_cell_validation.py b/tests/test_h3/test_h3_cell_validation.py new file mode 100644 index 00000000..5acb2d57 --- /dev/null +++ b/tests/test_h3/test_h3_cell_validation.py @@ -0,0 +1,140 @@ +"""Vendor h3o cell index tests + +https://github.com/HydroniumLabs/h3o/blob/6918ea071cf2d65a20cbb103f32d984a01161819/tests/h3/is_valid_cell.rs +""" + +import h3.api.numpy_int as h3 +import numpy as np +import pytest + +from lonboard._h3 import validate_h3_indices + +VALID_INDICES = np.array( + [ + 0x8075FFFFFFFFFFF, + 0x81757FFFFFFFFFF, + 0x82754FFFFFFFFFF, + 0x83754EFFFFFFFFF, + 0x84754A9FFFFFFFF, + 0x85754E67FFFFFFF, + 0x86754E64FFFFFFF, + 0x87754E64DFFFFFF, + 0x88754E6499FFFFF, + 0x89754E64993FFFF, + 0x8A754E64992FFFF, + 0x8B754E649929FFF, + 0x8C754E649929DFF, + 0x8D754E64992D6FF, + 0x8E754E64992D6DF, + 0x8F754E64992D6D8, + ], + dtype=np.uint64, +) + + +def test_valid_indices(): + for cell in VALID_INDICES: + assert h3.is_valid_cell(cell) + validate_h3_indices(VALID_INDICES) + + +def test_invalid_high_bit_set(): + h3_indices = np.array([0x88C2BAE305336BFF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises(ValueError, match="Tainted reserved bits in indices"): + validate_h3_indices(h3_indices) + + +def test_invalid_mode(): + h3_indices = np.array([0x28C2BAE305336BFF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises(ValueError, match="Invalid index mode in indices"): + validate_h3_indices(h3_indices) + + +def test_tainted_reserved_bits(): + h3_indices = np.array([0xAC2BAE305336BFF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises(ValueError, match="Tainted reserved bits in indices"): + validate_h3_indices(h3_indices) + + +def test_invalid_base_cell(): + h3_indices = np.array([0x80FFFFFFFFFFFFF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises(ValueError, match="Invalid base cell in indices"): + validate_h3_indices(h3_indices) + + +def test_unexpected_unused_first(): + h3_indices = np.array([0x8C2BEE305336BFFF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises(ValueError, match="Tainted reserved bits in indices"): + validate_h3_indices(h3_indices) + + +def test_unexpected_unused_middle(): + h3_indices = np.array([0x8C2BAE33D336BFF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises(ValueError, match="Unexpected unused direction in indices"): + validate_h3_indices(h3_indices) + + +def test_unexpected_unused_last(): + h3_indices = np.array([0x8C2BAE305336FFF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises(ValueError, match="Unexpected unused direction in indices"): + validate_h3_indices(h3_indices) + + +def test_missing_unused_first(): + h3_indices = np.array([0x8C0FAE305336AFF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises(ValueError, match="Invalid unused direction pattern in indices"): + validate_h3_indices(h3_indices) + + +def test_missing_unused_middle(): + h3_indices = np.array([0x8C0FAE305336FEF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises(ValueError, match="Invalid unused direction pattern in indices"): + validate_h3_indices(h3_indices) + + +def test_missing_unused_last(): + h3_indices = np.array([0x81757FFFFFFFFFE], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises(ValueError, match="Invalid unused direction pattern in indices"): + validate_h3_indices(h3_indices) + + +def test_deleted_subsequence_hexagon1(): + h3_indices = np.array([0x81887FFFFFFFFFF], dtype=np.uint64) + assert h3.is_valid_cell(h3_indices[0]) + validate_h3_indices(h3_indices) + + +def test_deleted_subsequence_pentagon1(): + h3_indices = np.array([0x81087FFFFFFFFFF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises( + ValueError, + match="Pentagonal cell index with a deleted subsequence", + ): + validate_h3_indices(h3_indices) + + +def test_deleted_subsequence_hexagon2(): + h3_indices = np.array([0x8804000011FFFFF], dtype=np.uint64) + assert h3.is_valid_cell(h3_indices[0]) + validate_h3_indices(h3_indices) + + +def test_deleted_subsequence_pentagon2(): + h3_indices = np.array([0x8808000011FFFFF], dtype=np.uint64) + assert not h3.is_valid_cell(h3_indices[0]) + with pytest.raises( + ValueError, + match="Pentagonal cell index with a deleted subsequence", + ): + validate_h3_indices(h3_indices) diff --git a/tests/test_h3/test_h3_layer.py b/tests/test_h3/test_h3_layer.py new file mode 100644 index 00000000..dec717fa --- /dev/null +++ b/tests/test_h3/test_h3_layer.py @@ -0,0 +1,40 @@ +import numpy as np +import pandas as pd +import pyarrow as pa + +from lonboard import Map +from lonboard._h3 import h3_to_str +from lonboard.layer._h3 import H3HexagonLayer + +VALID_INDICES = np.array( + [ + 0x8075FFFFFFFFFFF, + 0x81757FFFFFFFFFF, + 0x82754FFFFFFFFFF, + 0x83754EFFFFFFFFF, + 0x84754A9FFFFFFFF, + 0x85754E67FFFFFFF, + 0x86754E64FFFFFFF, + 0x87754E64DFFFFFF, + 0x88754E6499FFFFF, + 0x89754E64993FFFF, + 0x8A754E64992FFFF, + 0x8B754E649929FFF, + 0x8C754E649929DFF, + 0x8D754E64992D6FF, + 0x8E754E64992D6DF, + 0x8F754E64992D6D8, + ], + dtype=np.uint64, +) + + +def test_from_geopandas(): + hex_str = h3_to_str(VALID_INDICES) + hex_str_pa_arr = pa.array(hex_str).cast(pa.string()) + + df = pd.DataFrame({"h3": VALID_INDICES, "h3_str": hex_str}) + + layer = H3HexagonLayer.from_pandas(df, get_hexagon=hex_str_pa_arr) + m = Map(layer) + assert isinstance(m.layers[0], H3HexagonLayer) diff --git a/tests/test_h3/test_h3_trait.py b/tests/test_h3/test_h3_trait.py new file mode 100644 index 00000000..508ffb73 --- /dev/null +++ b/tests/test_h3/test_h3_trait.py @@ -0,0 +1,93 @@ +import numpy as np +import pandas as pd +import pyarrow as pa +import pytest +from traitlets import TraitError + +from lonboard.layer import BaseLayer +from lonboard.traits._h3 import H3Accessor + +VALID_INDICES = np.array( + [ + 0x8075FFFFFFFFFFF, + 0x81757FFFFFFFFFF, + 0x82754FFFFFFFFFF, + ], + dtype=np.uint64, +) + +HEX_STRINGS = [f"{v:x}" for v in VALID_INDICES] + + +class H3AccessorWidget(BaseLayer): + _rows_per_chunk = 2 + + # Any tests that are intended to pass validation checks must also have 3 rows, since + # there's another length check in the serialization code. + table = pa.table({"data": [1, 2, 3]}) + + get_hexagon = H3Accessor() + + +def test_pandas_series_str(): + str_series = pd.Series(HEX_STRINGS) + H3AccessorWidget(get_hexagon=str_series) + + +def test_pandas_series_uint64(): + uint_series = pd.Series(VALID_INDICES) + H3AccessorWidget(get_hexagon=uint_series) + + +def test_pandas_series_str_invalid_length(): + str_series = pd.Series(["abc", "defg", "hijklmnop"]) + with pytest.raises(TraitError, match="not all 15 characters long"): + H3AccessorWidget(get_hexagon=str_series) + + +def test_pandas_u8(): + uint8_array = np.array([1, 2, 3], dtype=np.uint8) + with pytest.raises( + TraitError, + match="numpy array not object, str, or uint64 dtype", + ): + H3AccessorWidget(get_hexagon=uint8_array) + + +def test_numpy_s15(): + str_array = np.array(HEX_STRINGS, dtype="S15") + H3AccessorWidget(get_hexagon=str_array) + + +def test_numpy_str_object(): + str_array = np.array(HEX_STRINGS, dtype=np.object_) + H3AccessorWidget(get_hexagon=str_array) + + +def test_numpy_uint64(): + H3AccessorWidget(get_hexagon=VALID_INDICES) + + +def test_numpy_uint8(): + uint8_array = np.array([1, 2, 3], dtype=np.uint8) + with pytest.raises( + TraitError, + match="numpy array not object, str, or uint64 dtype", + ): + H3AccessorWidget(get_hexagon=uint8_array) + + +def test_arrow_string_array(): + str_array = pa.array(HEX_STRINGS, type=pa.string()) + H3AccessorWidget(get_hexagon=str_array) + + str_array = pa.array(HEX_STRINGS, type=pa.large_string()) + H3AccessorWidget(get_hexagon=str_array) + + str_array = pa.array(HEX_STRINGS, type=pa.string_view()) + H3AccessorWidget(get_hexagon=str_array) + + +def test_arrow_uint64_array(): + uint64_array = pa.array(VALID_INDICES, type=pa.uint64()) + H3AccessorWidget(get_hexagon=uint64_array) diff --git a/tests/test_h3/test_str_to_h3.py b/tests/test_h3/test_str_to_h3.py new file mode 100644 index 00000000..77f12f39 --- /dev/null +++ b/tests/test_h3/test_str_to_h3.py @@ -0,0 +1,94 @@ +"""Tests for str_to_h3 conversion.""" + +import h3.api.numpy_int as h3 +import numpy as np + +from lonboard._h3 import h3_to_str, str_to_h3 + +H3_INTEGERS = np.array( + [ + 0x8075FFFFFFFFFFF, + 0x81757FFFFFFFFFF, + 0x82754FFFFFFFFFF, + 0x83754EFFFFFFFFF, + 0x84754A9FFFFFFFF, + 0x85754E67FFFFFFF, + 0x86754E64FFFFFFF, + 0x87754E64DFFFFFF, + 0x88754E6499FFFFF, + 0x89754E64993FFFF, + 0x8A754E64992FFFF, + 0x8B754E649929FFF, + 0x8C754E649929DFF, + 0x8D754E64992D6FF, + 0x8E754E64992D6DF, + 0x8F754E64992D6D8, + ], + dtype=np.uint64, +) +H3_HEX_STRINGS = [h3.int_to_str(v) for v in H3_INTEGERS] + + +def test_str_to_h3_roundtrip(): + """Test that str_to_h3 correctly converts hex strings back to uint64.""" + # Convert to strings and back + hex_strings = h3_to_str(H3_INTEGERS) + assert [bytes(x).decode().lower() for x in hex_strings] == H3_HEX_STRINGS + + back = str_to_h3(hex_strings) + + np.testing.assert_array_equal(back, H3_INTEGERS) + + +def test_str_to_h3_single(): + """Test single hex string conversion.""" + hex_strings = np.array(["8075FFFFFFFFFFF"], dtype="S15") + result = str_to_h3(hex_strings) + expected = np.array([0x8075FFFFFFFFFFF], dtype=np.uint64) + + np.testing.assert_array_equal(result, expected) + + +def test_str_to_h3_lowercase(): + """Test that lowercase hex strings work.""" + hex_strings = np.array(["8075fffffffffff"], dtype="S15") + result = str_to_h3(hex_strings) + expected = np.array([0x8075FFFFFFFFFFF], dtype=np.uint64) + + np.testing.assert_array_equal(result, expected) + + +def test_str_to_h3_mixed_case(): + """Test that mixed case hex strings work.""" + hex_strings = np.array(["8075fFfFfFfFfFf"], dtype="S15") + result = str_to_h3(hex_strings) + expected = np.array([0x8075FFFFFFFFFFF], dtype=np.uint64) + + np.testing.assert_array_equal(result, expected) + + +def test_str_to_h3_empty(): + """Test empty array handling.""" + hex_strings = np.array([], dtype=np.str_) + result = str_to_h3(hex_strings) + expected = np.array([], dtype=np.uint64) + + np.testing.assert_array_equal(result, expected) + + +def test_str_to_h3_zeros(): + """Test conversion of zeros.""" + hex_strings = np.array(["000000000000000"], dtype=np.str_) + result = str_to_h3(hex_strings) + expected = np.array([0x0], dtype=np.uint64) + + np.testing.assert_array_equal(result, expected) + + +def test_str_to_h3_max_value(): + """Test conversion of maximum uint64 value.""" + hex_strings = np.array(["FFFFFFFFFFFFFFF"], dtype=np.str_) + result = str_to_h3(hex_strings) + expected = np.array([0xFFFFFFFFFFFFFFF], dtype=np.uint64) + + np.testing.assert_array_equal(result, expected) diff --git a/uv.lock b/uv.lock index bf223275..7e625524 100644 --- a/uv.lock +++ b/uv.lock @@ -1230,6 +1230,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h3" +version = "4.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/97/7c795fd4b7f7913cc001d73c5470ec278d705fdea7bb23b67b561e198426/h3-4.3.1.tar.gz", hash = "sha256:ecac67318538ecef1d893c019946d4cce58c1eef9349090b887ebfe8a59d4f31", size = 167964, upload-time = "2025-08-10T19:54:43.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/58/3de20e25db166f220050798c60dc58b0a44842607eb5d26ccbf01d0c3d32/h3-4.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce6033bdee8dd0179a99f818d6bdce01bf63821fc0ecee9ce5d013ec54cb1000", size = 859236, upload-time = "2025-08-10T19:53:48.859Z" }, + { url = "https://files.pythonhosted.org/packages/15/73/2532b9ebf3deb64cb508e0ed3602cfa656f772c2bc2870724437b823cd99/h3-4.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6e0d81d60397fae0159e2f6f6a457f959f5b137b1db969e513aeeb26895798a", size = 802307, upload-time = "2025-08-10T19:53:50.332Z" }, + { url = "https://files.pythonhosted.org/packages/5b/98/37360e0c43a0ed88ca4f057adc3218987f30d4b6c97bac70f5b31e437569/h3-4.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65662d46dc3786b961533231e5597aafe642b835a9f09d4fcb1e4081fd3161f9", size = 991394, upload-time = "2025-08-10T19:53:51.718Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/314aefe73af35adc1ee6bf757cf6fe8bb6c8d7d30df181a184ecccea136f/h3-4.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f0349364c2bcd7344880902d63fa2e3b5d9a96edbdf0d2d59d2d2e9ee65814", size = 1028103, upload-time = "2025-08-10T19:53:53.25Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/3b87a291ef209ff480a230403934e7710e01558b2a947d1a8d774d52bf78/h3-4.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e41ddbcc8d89e81a7a2b8de80d957f260802a47fe84fb12b4b1fdfacef93dea", size = 1039453, upload-time = "2025-08-10T19:53:54.689Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8b/4e626a127e85c23e492f96cf52b20b8023420ad32af94c98895dffbf2372/h3-4.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:5fc9fcf2f4a96b71b084a0a535f0a3728d43258624e0aad077304bf170f6d95c", size = 795954, upload-time = "2025-08-10T19:53:55.94Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c8/ae8aba6d2dd4c327b31339b478553fdde482e187899f79165c8e7c9ab621/h3-4.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:693f91e0819cd77b2037d7b8e8ef2b807243896a8bf9d542385067087c67b561", size = 859078, upload-time = "2025-08-10T19:53:57.136Z" }, + { url = "https://files.pythonhosted.org/packages/6f/46/68a542833bd3c0c10ffb9d9654eca76fc4e6a36a2439df61c56b9484f3f6/h3-4.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2eee0ce19777910187018d8878b2ba746a529c3cf54efa0fd1b79be95034c4b5", size = 800943, upload-time = "2025-08-10T19:53:58.587Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cc/dfe823ec29dd974449914fe59a181522b939fd7cbe0929df81310c128ef9/h3-4.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1fad090aa81eb6ac2e97cd06e3c17c2021b32afef55f202f4b733fecccfd51c", size = 994141, upload-time = "2025-08-10T19:54:00.08Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ca/e0a85dc6ac504d69cb2777e225c34c29b42f11f9d80fd70e58bbaec600da/h3-4.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd5d6893a3b81b6855c8343375f335b639de202559c69802c4739497cf0d6127", size = 1028418, upload-time = "2025-08-10T19:54:01.095Z" }, + { url = "https://files.pythonhosted.org/packages/ff/da/8ea4dd1462b006da75b3e0d57c4f4fcd116f7c438c0ae4e74c6204f17a6a/h3-4.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e357001998db9babb4e8b23b617134819e5a2e8c3223c5b292ab05e4c36f19b0", size = 1040091, upload-time = "2025-08-10T19:54:02.419Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7d/05bcc6720fb0fb3e965deb5fd7de4c0b444935adcd32cc23c90f04d34cac/h3-4.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:b3b67b687f339c0bb9f656a8120dcf36714364aadb77c8641206ace9cf664850", size = 796274, upload-time = "2025-08-10T19:54:03.734Z" }, + { url = "https://files.pythonhosted.org/packages/9f/46/ddfb53cf1549808724186d3b50f77dd85d95c02e424668b8bd9b13fb85eb/h3-4.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:5961d986e77900e57b25ce0b0be362d2181bd3db9e1b8792f2b4a503f1d0857e", size = 696343, upload-time = "2025-08-10T19:54:04.91Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e4/37f37656f2f3aac6e057a4796f8acba9042ece471679d202614b2810d5fe/h3-4.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a838d17726baf573cde98338b8eba9c78f5fb0a60e076a4f038b1573c64959d", size = 865460, upload-time = "2025-08-10T19:54:06.305Z" }, + { url = "https://files.pythonhosted.org/packages/7b/63/22bca3886d0bc57e9b1b6d0385555ea69aec65f6aade0b80d89bd4db4a34/h3-4.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:63affe0b9d590e138aa3595300e8989d309480b650c3ba5f1219fa412e06147a", size = 798471, upload-time = "2025-08-10T19:54:07.278Z" }, + { url = "https://files.pythonhosted.org/packages/4d/82/b4152f051322b098e94672a76350c4c21092e86973cd0e859dd438b8dd6c/h3-4.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:180c82873452aa3fef17b01bc1a5bea90cc508b5d7f78657566dc3e2cc5a0b87", size = 975788, upload-time = "2025-08-10T19:54:08.655Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/d5ee6d3fb0a11b754a565cf412e8b93b5543a278fa58c6e92d5e4a274be2/h3-4.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:382fbc8904cdf1b606c374c32f1501c182157bb179ac2261eb5f9bf1f06613ad", size = 1017151, upload-time = "2025-08-10T19:54:10.071Z" }, + { url = "https://files.pythonhosted.org/packages/51/03/a6c392952d098e642309a6d71d68b57c9af70e9b987078cdba6474538b75/h3-4.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:089a83c3046887d762a702fa8bbd63508113ce91b702749a3ee260531da9d084", size = 1027707, upload-time = "2025-08-10T19:54:11.566Z" }, + { url = "https://files.pythonhosted.org/packages/94/a4/d77cffe5f4e361ef7fb95ac266eb292acb88be2b8a09e2f8ddc07bda233c/h3-4.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:bbc9bed33915386cfa7fa29314e2cc2f7aa94384835d5e2f772c4f66133b34fa", size = 787032, upload-time = "2025-08-10T19:54:12.825Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1e/29c0e00fa57300e5b99095b572237d2914fc416cb038f9977ad69440742d/h3-4.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:87ac213063082a67698098c51e54443f465a239a11bf5e7aa4cf95071e3ea2f3", size = 698434, upload-time = "2025-08-10T19:54:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/df/2c/5194b5f27465c4ea65e1b779ac62551e2893bdfb26933d03e113310658f7/h3-4.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:836903c620114b67438cf589be108d974d72c96091e9f0d1461975114ce439a2", size = 858154, upload-time = "2025-08-10T19:54:14.773Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3c/7edad39214b7e6ec0fea4da241fda0ecdc4bc9c25d565483fe4eba89dd8f/h3-4.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f3f5ca6ac0f155a88889de788c13025f582cbde4cbc6281c472c4673784b6f54", size = 792498, upload-time = "2025-08-10T19:54:15.761Z" }, + { url = "https://files.pythonhosted.org/packages/44/bc/a11b200229591989426121590810fba43465c7bbedf3d7f235aee99e3d2d/h3-4.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a8dcb11f7b5b902521b6dd6955fe8710ab7aef33bccf21861182bc7ae02e04e", size = 971224, upload-time = "2025-08-10T19:54:16.726Z" }, + { url = "https://files.pythonhosted.org/packages/1d/99/5efd413072428bf1973c34d76a2f476a621848da86cecd8392ef59ba7640/h3-4.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b4885dd55ae5bc1848334981f1593eea098133c13bedc66bca1dac624cefe2c", size = 1013740, upload-time = "2025-08-10T19:54:17.758Z" }, + { url = "https://files.pythonhosted.org/packages/2a/3a/93e7962d160cd2f8c4f2108a2606f7a4713edbe4dbbb39aef019bf2ebb08/h3-4.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ba48c0d64a5dc6843290441c2cf885a4912ccd861c3288e8f8be47a2d09e201", size = 1024968, upload-time = "2025-08-10T19:54:18.759Z" }, + { url = "https://files.pythonhosted.org/packages/90/bc/efe2bfc2c746bc7d092aef97ac6182c1e2f7b3759618ee55c21628fe1b80/h3-4.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:14f2012c8bbc6ec72e74ceeb46d617019bb3489c07d1c0dee516bc7765509850", size = 783789, upload-time = "2025-08-10T19:54:19.794Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1b/8ed8745a3a80a031201890cafa8a45780605005fafd3e460d75fb697fa00/h3-4.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:166fd7ecc8a384aad8272432ea482310dbe0022cb12dc84b9f8fd704a908890a", size = 695774, upload-time = "2025-08-10T19:54:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/5c/40/49b703d520189f2df139a72a9d899fec0a4382855e69241187272738887d/h3-4.3.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:adf011133912ec8346b7c9c2e396f8c4ad8395a3713d3ceac36c0fad6e66e7d5", size = 858473, upload-time = "2025-08-10T19:54:22.13Z" }, + { url = "https://files.pythonhosted.org/packages/02/99/b2b8b3834fe1460da1f0ac5d6aaf0ae24a6c20d171605b694d364982c323/h3-4.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:348aa0a4f5899fcab48c5d0573c68b1190a3b3e574294524349428658587b7a3", size = 795377, upload-time = "2025-08-10T19:54:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/6e/3a/c85c9f345e76c4f94a0d2868052d7b52e2727fb9a16f06ba1a29265d9787/h3-4.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8b0ce8ac5b4227f511a56933f3da9587d31e4ad68e29ce82eb6214b949883fe", size = 1014140, upload-time = "2025-08-10T19:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/81/90/b498d83eba23fd428ddeeb4801f383ead0f86393607a749926363cd41b67/h3-4.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:b6e2dd52fd50546fb7be574025e7ac01c34b77c171583008cbcdbb2b9e918766", size = 799379, upload-time = "2025-08-10T19:54:25.444Z" }, + { url = "https://files.pythonhosted.org/packages/10/ff/c5bd98976a9904f314ed4edd960f115e865adc2e85205d9273bf3c8211a8/h3-4.3.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fc8a2c12d945ce840d20bbca68c2c4aae52db7b27a5c275c0171e9887ee902c0", size = 893161, upload-time = "2025-08-10T19:54:26.426Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e2/deffa7ce37527d380e11e0cdaa99065ede701d6243aa508625718aa2d1db/h3-4.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae63c00cedc0f499d83fb8eec9a84291c16ceb81d5909f75dd28fc6b0efe6d96", size = 839868, upload-time = "2025-08-10T19:54:27.414Z" }, + { url = "https://files.pythonhosted.org/packages/68/fa/956d6a8fe9cdcf761f65a732e6aa047e1c5dc6ed22d81ede868fb4d8bb83/h3-4.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aec62f93cae0e146766e6bfced32e5b0f9964aa33bf738d19be779d4a77328f0", size = 983850, upload-time = "2025-08-10T19:54:28.397Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b0/f9ae26739d77e846911a8ffebef13964116cd68df21b50e74ff5725ccd49/h3-4.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:b005d38c4e91917b0b2e6053a47f07b123cc5eed794cb849a2d347b6b3888ea0", size = 893310, upload-time = "2025-08-10T19:54:29.796Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1815,6 +1858,7 @@ dev = [ { name = "geoarrow-rust-core" }, { name = "geoarrow-rust-io" }, { name = "geodatasets" }, + { name = "h3" }, { name = "jupyterlab" }, { name = "matplotlib" }, { name = "movingpandas" }, @@ -1874,6 +1918,7 @@ dev = [ { name = "geoarrow-rust-core", specifier = ">=0.4.0" }, { name = "geoarrow-rust-io", specifier = ">=0.4.1" }, { name = "geodatasets", specifier = ">=2024.8.0" }, + { name = "h3", specifier = ">=4.3.1" }, { name = "jupyterlab", specifier = ">=4.3.3" }, { name = "matplotlib", specifier = ">=3.7.5" }, { name = "movingpandas", specifier = ">=0.20.0" },