-
Notifications
You must be signed in to change notification settings - Fork 530
Description
Hello,
There is an ongoing pilot project at the OGC called : AI-DGGS for Disaster Management Pilot.
As part of this pilot, multiple DGGRS (H3, S2, Healpix and many more) are used handle by client and server for the new OGC DGGRS API.
During this effort we discovered a missing functionality in H3 which prevented it from being a proper DGGRS (as in the OGC definition).
The ECERE and Geomatys companies developed this missing feature, which we now give to the H3 project.
Authors : Jérôme Saint-Louis (ECERE) and Johann Sorel (Geomatys)
The new function is called : getGeometricSubZones(ancestor_zone, relative_depth)
It's purpose is to return all zones at a given depth that intersect a parent zone.
The zone ids order is also constant, which allow to store only two values (the parent zone and the depth) instead of a long list of zone ids.
Unlike the getChildren function, there is no gap on the border, and no fractal effect.
Here is a visual example :
To compare the result with the existing getChildren function :
Python version :
#!/usr/bin/python3
import h3
import math
import time
from multiprocessing import Pool, cpu_count
def slerp(start_lat, start_lng, end_lat, end_lng, t):
"""
Performs spherical linear interpolation between two geographic points
with optimizations to compute trigonometric values only once.
Assumes a perfect sphere model for simplicity.
"""
# Convert to radians once
start_lat_rad = math.radians(start_lat)
start_lng_rad = math.radians(start_lng)
end_lat_rad = math.radians(end_lat)
end_lng_rad = math.radians(end_lng)
# Pre-calculate trigonometric values
cos_start_lat = math.cos(start_lat_rad)
sin_start_lat = math.sin(start_lat_rad)
cos_end_lat = math.cos(end_lat_rad)
sin_end_lat = math.sin(end_lat_rad)
delta_lng = end_lng_rad - start_lng_rad
# Calculate omega (angular distance)
cos_omega = sin_start_lat * sin_end_lat + cos_start_lat * cos_end_lat * math.cos(delta_lng)
# Clamp cos_omega to [-1, 1] due to floating point inaccuracies
cos_omega = max(-1.0, min(1.0, cos_omega))
omega = math.acos(cos_omega)
# Handle the case where the two points are the same
if omega == 0.0:
return (start_lat, start_lng)
# Calculate sin(omega) only once
sin_omega = math.sin(omega)
# Calculate the blend factors
a = math.sin((1.0 - t) * omega) / sin_omega
b = math.sin(t * omega) / sin_omega
# Calculate interpolated coordinates
x = a * cos_start_lat * math.cos(start_lng_rad) + b * cos_end_lat * math.cos(end_lng_rad)
y = a * cos_start_lat * math.sin(start_lng_rad) + b * cos_end_lat * math.sin(end_lng_rad)
z = a * sin_start_lat + b * sin_end_lat
lat = math.atan2(z, math.sqrt(x*x + y*y))
lng = math.atan2(y, x)
return (math.degrees(lat), math.degrees(lng))
def zoneHasSubZone(ancestor_zone, candidate_subzone):
"""
Checks if a candidate H3 index is geometrically contained within an
ancestral H3 index using a vertex-based boundary check with SLERP.
"""
if h3.get_resolution(candidate_subzone) <= h3.get_resolution(ancestor_zone):
return False
ancestor_resolution = h3.get_resolution(ancestor_zone)
centroid_lat, centroid_lng = h3.cell_to_latlng(candidate_subzone)
vertex_indices = h3.cell_to_vertexes(candidate_subzone)
for vertex_index in vertex_indices:
if vertex_index == 0: continue
vertex_lat, vertex_lng = h3.vertex_to_latlng(vertex_index)
interpolated_lat, interpolated_lng = slerp(
vertex_lat, vertex_lng, centroid_lat, centroid_lng, 0.01
)
coarser_zone = h3.latlng_to_cell(interpolated_lat, interpolated_lng, ancestor_resolution)
if coarser_zone == ancestor_zone:
return True
return False
def _process_neighbor(args):
"""
Helper function for multiprocessing. It processes a single neighbor
and returns its valid descendants.
"""
ancestor_zone, neighbor, target_resolution = args
valid_descendants = []
descendants = h3.cell_to_children(neighbor, target_resolution)
for descendant in descendants:
if zoneHasSubZone(ancestor_zone, descendant):
valid_descendants.append(descendant)
return valid_descendants
def getGeometricSubZones(ancestor_zone, relative_depth):
"""
Calculates all geometrically contained sub-zones within an ancestral zone
at a specific relative depth, with parallel processing for rings.
"""
ancestor_resolution = h3.get_resolution(ancestor_zone)
target_resolution = ancestor_resolution + relative_depth
if not 0 <= target_resolution <= 15:
raise ValueError("Relative depth results in an invalid resolution.")
# Get the centroid child
centroid_lat, centroid_lng = h3.cell_to_latlng(ancestor_zone)
centroid_child = h3.latlng_to_cell(centroid_lat, centroid_lng, ancestor_resolution + 1)
candidate_cells = []
# 1. Add all logical descendants of the centroid child (sequential)
if centroid_child is not None:
centroid_descendants = h3.cell_to_children(centroid_child, target_resolution)
candidate_cells.extend(centroid_descendants)
# 2. Get ring 1 neighbors of the centroid child
ring1_neighbors = list(h3.grid_ring(centroid_child, 1))
# 3. Get ring 2 neighbors of the centroid child
ring2_neighbors = list(h3.grid_ring(centroid_child, 2))
# Prepare arguments for parallel processing
ring_neighbors_to_process = []
for neighbor in ring1_neighbors:
ring_neighbors_to_process.append((ancestor_zone, neighbor, target_resolution))
for neighbor in ring2_neighbors:
ring_neighbors_to_process.append((ancestor_zone, neighbor, target_resolution))
# Parallelize processing of the rings
# Use 'map' to maintain the order of the results
if ring_neighbors_to_process:
with Pool(cpu_count()) as pool:
# The 'map' function ensures the results list has the same order
# as the input list, preserving the ring order.
parallel_results = pool.map(_process_neighbor, ring_neighbors_to_process)
# Collect results in the correct order
for result_list in parallel_results:
candidate_cells.extend(result_list)
return candidate_cells
# --- Expanded Test Suite ---
if __name__ == "__main__":
relative_depths = [1, 2, 3, 4, 5, 6]
# Select one specific pentagon from the list for testing
pentagon_res3 = list(h3.get_pentagons(3))
pentagon_res2 = list(h3.get_pentagons(2))
test_cases = [
("Hexagonal_NYC_Res5", h3.latlng_to_cell(40.7128, -74.0060, 5)),
("Hexagonal_London_Res4", h3.latlng_to_cell(51.5074, -0.1278, 4)),
("Pentagonal_SouthPole_Res3", pentagon_res3[0]),
("Pentagonal_Atlantic_Res2", pentagon_res2[0]),
("Hexagonal_HighRes_Res10", h3.latlng_to_cell(34.0522, -118.2437, 10)),
("Hexagonal_Res3_at_-80_0", h3.latlng_to_cell(-80.0, 0.0, 3)),
]
print("Starting comprehensive test suite...\n")
for name, parent_h3 in test_cases:
parent_resolution = h3.get_resolution(parent_h3)
parent_type = "Pentagonal" if h3.is_pentagon(parent_h3) else "Hexagonal"
print(f"--- Testing {name} ({parent_type}, Res={parent_resolution}, H3={parent_h3}) ---")
for relative_depth in relative_depths:
target_resolution = parent_resolution + relative_depth
if target_resolution > 15:
print(f" Skipping depth {relative_depth}: Target resolution {target_resolution} exceeds max (15).")
continue
try:
start_time = time.perf_counter()
geometric_subzones = getGeometricSubZones(parent_h3, relative_depth)
end_time = time.perf_counter()
duration = end_time - start_time
print(f" Found {len(geometric_subzones)} geometric sub-zones at resolution {target_resolution} (Depth {relative_depth}). Took {duration:.4f} seconds.")
except ValueError as e:
print(f" Error testing depth {relative_depth}: {e}")
print("-" * 30 + "\n")Java version :
/**
* Python code provided by Jerome Saint-Louis to ensure H3 subchild results match DGGRS requirements.
*
*
* @author Jerome Saint-Louis (Ecere), original code in python
* @author Johann Sorel (Geomatys), java port
*/
public final class H3Ext {
/**
* Performs spherical linear interpolation between two geographic points.
* Assumes a perfect sphere model for simplicity.
*/
public static LatLng slerp(LatLng start, LatLng end, double ratio) {
final double startLatRad = Math.toRadians(start.lat);
final double startLngRad = Math.toRadians(start.lng);
final double endLatRad = Math.toRadians(end.lat);
final double endLngRad = Math.toRadians(end.lng);
final double sinStartLatRad = Math.sin(startLatRad);
final double cosStartLatRad = Math.cos(startLatRad);
final double sinEndLatRad = Math.sin(endLatRad);
final double cosEndLatRad = Math.cos(endLatRad);
final double cosOmega = sinStartLatRad * sinEndLatRad
+ cosStartLatRad * cosEndLatRad * Math.cos(endLngRad - startLngRad);
final double omega = Math.acos(cosOmega);
if (omega == 0.0) {
return new LatLng(start.lat, start.lng);
}
final double sinOmega = Math.sin(omega);
final double a = Math.sin((1.0 - ratio) * omega) / sinOmega;
final double b = Math.sin(ratio * omega) / sinOmega;
final double x = a * cosStartLatRad * Math.cos(startLngRad)
+ b * cosEndLatRad * Math.cos(endLngRad);
final double y = a * cosStartLatRad * Math.sin(startLngRad)
+ b * cosEndLatRad * Math.sin(endLngRad);
final double z = a * sinStartLatRad + b * sinEndLatRad;
final double lat = Math.atan2(z, Math.sqrt(x*x + y*y));
final double lng = Math.atan2(y, x);
return new LatLng(Math.toDegrees(lat), Math.toDegrees(lng));
}
/**
* Checks if a candidate H3 index is geometrically contained within an
* ancestral H3 index using a vertex-based boundary check with SLERP.
*/
public static boolean zoneHasSubZone(long ancestorZone, long candidateSubzone) {
final int candidateRes = H3Index.getResolution(candidateSubzone);
final int ancestorRes = H3Index.getResolution(ancestorZone);
if (candidateRes <= ancestorRes) {
return false;
}
final LatLng centroid = H3Dggrs.H3.cellToLatLng(candidateSubzone);
final List<Long> vertexIndices = H3Dggrs.H3.cellToVertexes(candidateSubzone);
for (Long vertexIndex : vertexIndices) {
if (vertexIndex == 0) continue;
final LatLng vertex = H3Dggrs.H3.vertexToLatLng(vertexIndex);
final LatLng interpolated = slerp(vertex, centroid, 0.01);
final long coarser_zone = H3Dggrs.H3.latLngToCell(interpolated.lat, interpolated.lng, ancestorRes);
if (coarser_zone == ancestorZone) {
return true;
}
}
return false;
}
/**
* Calculates all geometrically contained sub-zones within an ancestral zone
* at a specific relative depth, using a rigorous topological search and
* the specified optimization.
*/
public static LongStream getGeometricSubZones(long ancestorZone, int relativeDepth) {
final int ancestorRes = H3Index.getResolution(ancestorZone);
final int targetRes = ancestorRes + relativeDepth;
if (targetRes < 0 || targetRes > 15) {
throw new IllegalArgumentException("Relative depth results in an invalid resolution.");
}
final long centerChild = H3Dggrs.H3.cellToCenterChild(ancestorZone, ancestorRes+1);
final List<Long> rings = new ArrayList<>();
rings.addAll(H3Dggrs.H3.gridRing(centerChild, 1));
rings.addAll(H3Dggrs.H3.gridRing(centerChild, 2));
LongStream stream = LongStream.empty();
stream = LongStream.concat(stream, H3Dggrs.H3.cellToChildren(centerChild, targetRes).stream().mapToLong(Long::longValue));
for (Long r : rings) {
LongStream s = H3Dggrs.H3.cellToChildren(r, targetRes)
.parallelStream()
.filter((Long l) -> zoneHasSubZone(ancestorZone, l))
.mapToLong(Long::longValue);
stream = LongStream.concat(stream, s);
}
return stream;
}
}