Skip to content

Commit 8cda8e8

Browse files
added rust radius utility
1 parent 2b14dbe commit 8cda8e8

15 files changed

Lines changed: 273 additions & 32 deletions

File tree

bench/ops/README.md

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@ Measures the 9 `CostFactors` ns/op constants used by the query planner's cost mo
99
`shapely.box` (polygons), then wraps it in a `SpatialFrame`/`Engine`.
1010
- Build cost times only `engine.build_index()` on a fresh engine. Probe cost times the repeated queries against a pre-built index.
1111
- The constant is `time / workload_term`, where the term is read directly off the cost
12-
model formula in `cost.rs` (e.g. `Q * N` for `scan_ns_per_item`).
12+
model formula in `cost.rs` (e.g. `Q * N` for `knn_scan_ns_per_item`).
1313
- This ratio is computed at multiple dataset sizes and we return the median as the suggested value.
1414

1515

1616
## What it measures
1717

1818
| Constant | Op timed | Dataset | Term |
1919
|---|---|---|---|
20-
| `scan_ns_per_item` | brute-force kNN, no index | uniform points | `Q * N` |
20+
| `knn_scan_ns_per_item` | brute-force kNN, no index | uniform points | `Q * N` |
21+
| `bbox_scan_ns_per_item` | brute-force range, no index | uniform points | `Q * N` |
2122
| `grid_build_ns_per_item` | build | uniform points | `N` |
2223
| `kdtree_build_ns_per_item` | build | clustered points | `N * log2(N)` |
2324
| `rtree_build_ns_per_item` | build | polygons | `N * log2(N)` |
@@ -46,19 +47,20 @@ Flags:
4647
Suggested CostFactors (copy into src/planner/calibration.rs):
4748
4849
Brute Force
49-
scan_ns_per_item: 100.80,
50+
knn_scan_ns_per_item: 3.07,
51+
bbox_scan_ns_per_item: 0.59,
5052
5153
Points
52-
grid_build_ns_per_item: 84.74,
53-
kdtree_build_ns_per_item: 4.36,
54-
grid_range_ns: 211.38,
55-
kdtree_range_ns: 81.19,
56-
kdtree_knn_ns: 148.71,
54+
grid_build_ns_per_item: 10.33,
55+
kdtree_build_ns_per_item: 0.47,
56+
grid_range_ns: 34.40,
57+
kdtree_range_ns: 17.56,
58+
kdtree_knn_ns: 24.92,
5759
5860
Polygons
59-
rtree_build_ns_per_item: 74.17,
60-
rtree_range_ns: 176.16,
61-
rtree_knn_ns: 1299.75,
61+
rtree_build_ns_per_item: 60.08,
62+
rtree_range_ns: 24.69,
63+
rtree_knn_ns: 132.17,
6264
63-
elapsed: 20.3 s peak RSS: 268.9 MiB
65+
elapsed: 2.9 s peak RSS: 281.2 MiB
6466
```

bench/ops/__main__.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
# Default destination for the written report
4040
_OUTPUT_PATH = Path(__file__).resolve().parents[2] / "assets" / "ops.txt"
4141

42-
_SCAN_FIELDS = ["scan_ns_per_item"]
42+
_SCAN_FIELDS = ["knn_scan_ns_per_item", "bbox_scan_ns_per_item"]
4343
_POINT_FIELDS = [
4444
"grid_build_ns_per_item",
4545
"kdtree_build_ns_per_item",
@@ -135,6 +135,14 @@ def _scan_probe(
135135
return t, _Q * n
136136

137137

138+
def _bbox_scan_probe(n: int, runs: int, seed: int) -> tuple[float, float]:
139+
# Time one no-index range scan over an empty box, isolating the per-item box test in a single call
140+
sf = _point_frame(generate_points(n, seed), "none")
141+
empty_box = (2.0, 2.0, 3.0, 3.0)
142+
t = time_min(lambda: sf.engine.range_query(*empty_box), runs)
143+
return t, n
144+
145+
138146
def _range_probe(
139147
n: int,
140148
runs: int,
@@ -198,9 +206,12 @@ def run(runs: int, seed: int) -> None:
198206
boxes = _query_boxes(_Q, seed, _BOX_SIDE)
199207

200208
fits: dict[str, float] = {
201-
"scan_ns_per_item": _measure_median(
209+
"knn_scan_ns_per_item": _measure_median(
202210
lambda n: _scan_probe(n, runs, seed, qxs, qys), _SCAN_SIZES
203211
),
212+
"bbox_scan_ns_per_item": _measure_median(
213+
lambda n: _bbox_scan_probe(n, runs, seed), _SCAN_SIZES
214+
),
204215
"grid_build_ns_per_item": _measure_median(
205216
lambda n: _build_probe(n, runs, seed, clustered=False), _POINT_SIZES
206217
),

bench/spatial_bench/queries/q01.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Q1: Trips starting within ~50km (0.45 degrees) of the Sedona city center.
22
3-
PyCanopy: a within-distance join of all trip pickup points against the single
4-
center point.
3+
PyCanopy: a single-point radius filter of all trip pickup points against the
4+
Sedona center.
55
"""
66

77
from __future__ import annotations
@@ -21,12 +21,11 @@
2121
def pycanopy(tables) -> pl.DataFrame:
2222
trip = tables.table("trip", ["t_tripkey", "t_pickuploc", "t_pickuptime"])
2323
sf = tables.point_frame(trip, "t_pickuploc")
24-
center_df = pl.DataFrame({"cx": [CENTER[0]], "cy": [CENTER[1]]})
25-
joined = sf.lazy().within_distance_join(center_df, "cx", "cy", distance=RADIUS).collect()
24+
near = sf.radius_query(CENTER[0], CENTER[1], RADIUS)
2625
return (
27-
joined.with_columns(
26+
near.with_columns(
2827
distance_to_center=(
29-
(pl.col("_x") - pl.col("cx")) ** 2 + (pl.col("_y") - pl.col("cy")) ** 2
28+
(pl.col("_x") - CENTER[0]) ** 2 + (pl.col("_y") - CENTER[1]) ** 2
3029
).sqrt()
3130
)
3231
.select(

python/pycanopy/engine.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,19 @@ def polygon_pairs_intersection_area(
774774
np.ascontiguousarray(j_idx, dtype=np.uint64),
775775
)
776776

777+
def radius_query(self, cx: float, cy: float, distance: float) -> np.ndarray:
778+
"""Return indices of engine points within `distance` of the center (cx, cy).
779+
780+
Args:
781+
cx: Center x coordinate.
782+
cy: Center y coordinate.
783+
distance: Maximum Euclidean distance for a match.
784+
785+
Returns:
786+
uint64 array of matching point indices.
787+
"""
788+
return self._core.radius_query(cx, cy, distance)
789+
777790
def points_within_distance_of_polygon(self, polygon, distance: float) -> np.ndarray:
778791
"""Return indices of engine points within `distance` of a query polygon.
779792

python/pycanopy/executor.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
ScalarNode,
2626
SelectNode,
2727
WithinDistanceJoinNode,
28+
WithinDistanceOfPointNode,
2829
WithinJoinNode,
2930
)
3031

@@ -366,6 +367,8 @@ def _emit_node(self, node, sf, lf: pl.LazyFrame, plugin_path: PluginPath) -> pl.
366367
return self._emit_polygon_knn_join(node, sf, lf)
367368
if isinstance(node, PointsWithinDistanceOfPolygonNode):
368369
return self._emit_points_within_distance_of_polygon(node, sf, lf)
370+
if isinstance(node, WithinDistanceOfPointNode):
371+
return self._emit_within_distance_of_point(node, sf, lf)
369372
raise TypeError(f"Unknown plan node type: {type(node)}")
370373

371374
# filter nodes
@@ -419,6 +422,13 @@ def _emit_points_within_distance_of_polygon(
419422
indices = sf.engine.points_within_distance_of_polygon(node.polygon, node.distance)
420423
return self._filter_by_indices(lf, indices)
421424

425+
def _emit_within_distance_of_point(
426+
self, node: WithinDistanceOfPointNode, sf, lf: pl.LazyFrame
427+
) -> pl.LazyFrame:
428+
# One radius query resolves the indices, then the lf filters by original row
429+
indices = sf.engine.radius_query(node.cx, node.cy, node.distance)
430+
return self._filter_by_indices(lf, indices)
431+
422432
def _filter_by_indices(self, lf: pl.LazyFrame, indices) -> pl.LazyFrame:
423433
# Filter the lf to the given original row positions (list or numpy array), empty to nothing
424434
if len(indices) == 0:

python/pycanopy/frame.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,20 @@ def intersects_pairs(self, key_col: str | None = None) -> pl.DataFrame:
273273
},
274274
)
275275

276+
def radius_query(self, cx: float, cy: float, distance: float) -> pl.DataFrame:
277+
"""Return the rows whose point lies within `distance` of the center (cx, cy).
278+
279+
Args:
280+
cx: Center x coordinate.
281+
cy: Center y coordinate.
282+
distance: Maximum Euclidean distance for a match.
283+
284+
Returns:
285+
The subset of this frame's DataFrame within the radius.
286+
"""
287+
idx = self._engine.radius_query(cx, cy, distance)
288+
return self._df[pl.Series(idx.astype(np.uint32))]
289+
276290
def points_within_distance_of_polygon(self, polygon, distance: float) -> pl.DataFrame:
277291
"""Return the rows whose point lies within `distance` of a polygon boundary (zero inside).
278292

python/pycanopy/lazy.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
ScalarNode,
2929
SelectNode,
3030
WithinDistanceJoinNode,
31+
WithinDistanceOfPointNode,
3132
WithinJoinNode,
3233
)
3334
from pycanopy.optimizer import SpatialOptimizer
@@ -82,6 +83,11 @@ def _fmt_node(node) -> str:
8283
return f"POLY_KNN_JOIN [k={node.k}, query_rows={len(node.query_df):,}, barrier]"
8384
if isinstance(node, PointsWithinDistanceOfPolygonNode):
8485
return f"POINTS_WITHIN_DIST_OF_POLY [dist={node.distance:.4g}]"
86+
if isinstance(node, WithinDistanceOfPointNode):
87+
return (
88+
f"WITHIN_DIST_OF_POINT [center=({node.cx:.4g}, {node.cy:.4g}), "
89+
f"dist={node.distance:.4g}, sel={node.selectivity:.3g}]"
90+
)
8591
if isinstance(node, IntersectsSelfJoinNode):
8692
return "INTERSECTS_SELF_JOIN [pairs, barrier]"
8793
return f"UNKNOWN [{type(node).__name__}]"
@@ -380,6 +386,25 @@ def points_within_distance_of_polygon(self, polygon, distance: float) -> Spatial
380386
[*self._plan, PointsWithinDistanceOfPolygonNode(polygon, distance)],
381387
)
382388

389+
def within_distance_of_point(self, cx: float, cy: float, distance: float) -> SpatialLazyFrame:
390+
"""Keep points within `distance` of a single center (point dataset).
391+
392+
Distance is Euclidean. The result is a subset of this frame's rows like a spatial
393+
filter.
394+
395+
Args:
396+
cx: Center x coordinate.
397+
cy: Center y coordinate.
398+
distance: Maximum Euclidean distance for a row to be kept.
399+
400+
Returns:
401+
New SpatialLazyFrame with the within-distance-of-point node appended.
402+
"""
403+
return SpatialLazyFrame(
404+
self._sf,
405+
[*self._plan, WithinDistanceOfPointNode(cx, cy, distance)],
406+
)
407+
383408
def intersects_pairs(self) -> SpatialLazyFrame:
384409
"""Find all intersecting polygon pairs with overlap area and IoU (polygon dataset).
385410

python/pycanopy/nodes.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,20 @@ class PointsWithinDistanceOfPolygonNode:
159159
selectivity: float = 1.0
160160

161161

162+
@dataclass
163+
class WithinDistanceOfPointNode:
164+
"""Spatial filter: keep points within `distance` of a single center (point dataset).
165+
166+
Distance is Euclidean. Returns a subset of the frame's rows, so it behaves like
167+
range / contains.
168+
"""
169+
170+
cx: float
171+
cy: float
172+
distance: float
173+
selectivity: float = 1.0
174+
175+
162176
@dataclass
163177
class IntersectsSelfJoinNode:
164178
"""Spatial self-join: all intersecting polygon pairs with overlap area and IoU.

python/pycanopy/optimizer.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import dataclasses
1515
import json
16+
import math
1617

1718
from pycanopy.nodes import (
1819
ContainsNode,
@@ -28,6 +29,7 @@
2829
ScalarNode,
2930
SelectNode,
3031
WithinDistanceJoinNode,
32+
WithinDistanceOfPointNode,
3133
WithinJoinNode,
3234
)
3335

@@ -160,6 +162,8 @@ def _assign_selectivity(self, plan: Plan, engine) -> Plan:
160162
node = dataclasses.replace(node, selectivity=1.0 / max(n, 1))
161163
elif isinstance(node, KnnNode):
162164
node = dataclasses.replace(node, selectivity=min(1.0, node.k / max(n, 1)))
165+
elif isinstance(node, WithinDistanceOfPointNode):
166+
node = dataclasses.replace(node, selectivity=self._disk_selectivity(node, extent))
163167
elif isinstance(node, ScalarNode):
164168
node = dataclasses.replace(node, cost=_scalar_cost(node.expr))
165169
# join nodes have no selectivity field
@@ -181,6 +185,20 @@ def _range_selectivity(
181185
query_area = max(0.0, node.max_x - node.min_x) * max(0.0, node.max_y - node.min_y)
182186
return min(1.0, query_area / total_area)
183187

188+
def _disk_selectivity(
189+
self,
190+
node: WithinDistanceOfPointNode,
191+
extent: tuple[float, float, float, float] | None,
192+
) -> float:
193+
# Selectivity of a radius query as its disk area divided by the dataset extent area
194+
if extent is None:
195+
return 1.0
196+
min_x, min_y, max_x, max_y = extent
197+
total_area = (max_x - min_x) * (max_y - min_y)
198+
if total_area <= 0.0:
199+
return 1.0
200+
return min(1.0, math.pi * node.distance * node.distance / total_area)
201+
184202
def _cost_sort(self, plan: Plan) -> Plan:
185203
# Reorder nodes so cheaper operations run first. Joins and KNN are barriers, and within
186204
# a run scalars go first (Polars handles them cheaply) then spatials by ascending selectivity.

src/lib.rs

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ use query::{
3030
batch::{
3131
par_bbox_filter, par_contains, par_knn, par_knn_to_polygons, par_knn_to_polygons_sorted,
3232
par_knn_with_delta, par_points_within_distance_of_polygon, par_polygon_intersects_join,
33-
par_within_distance, par_within_distance_flipped, par_within_distance_to_polygons,
33+
par_radius, par_within_distance, par_within_distance_flipped,
34+
par_within_distance_to_polygons,
3435
},
3536
geometry::{convex_hull_area, polygon_area, polygon_intersection_area},
3637
multipoly::{dedup_indices, dedup_self_pairs, polygon_parts_csr, sum_part_areas},
@@ -643,6 +644,85 @@ impl Engine {
643644
Ok(result)
644645
}
645646

647+
/// Engine point indices within `distance` (Euclidean) of the center (cx, cy)
648+
fn radius_query<'py>(
649+
&mut self,
650+
py: Python<'py>,
651+
cx: f64,
652+
cy: f64,
653+
distance: f64,
654+
) -> PyResult<Bound<'py, PyArray1<u64>>> {
655+
if self.ring_offsets.is_some() {
656+
return Err(PyValueError::new_err(
657+
"radius_query requires a point dataset",
658+
));
659+
}
660+
let bbox = Rect::new(
661+
coord! { x: cx - distance, y: cy - distance },
662+
coord! { x: cx + distance, y: cy + distance },
663+
);
664+
// Skip histogram early-exit when delta is non-empty: delta points are not in the histogram
665+
if self.delta_xs.is_empty() {
666+
if let Some(hist) = &self.stats.histogram {
667+
if !hist.has_any_in_bbox(&bbox) {
668+
let empty: Vec<u64> = Vec::new();
669+
return Ok(PyArray1::from_vec(py, empty));
670+
}
671+
}
672+
}
673+
let kind = self.plan_index(&Query::Range { bbox }, 1);
674+
self.build_index_if_needed(kind);
675+
let mut result = match kind {
676+
IndexKind::BruteForce => par_radius(
677+
self.brute.as_ref().unwrap(),
678+
&self.xs,
679+
&self.ys,
680+
cx,
681+
cy,
682+
distance,
683+
),
684+
IndexKind::RTree => par_radius(
685+
self.rtree.as_ref().unwrap(),
686+
&self.xs,
687+
&self.ys,
688+
cx,
689+
cy,
690+
distance,
691+
),
692+
IndexKind::KdTree => par_radius(
693+
self.kdtree.as_ref().unwrap(),
694+
&self.xs,
695+
&self.ys,
696+
cx,
697+
cy,
698+
distance,
699+
),
700+
IndexKind::Grid => par_radius(
701+
self.grid.as_ref().unwrap(),
702+
&self.xs,
703+
&self.ys,
704+
cx,
705+
cy,
706+
distance,
707+
),
708+
};
709+
// Refine delta points by exact distance and address them in the combined index space
710+
if !self.delta_xs.is_empty() {
711+
let d2 = distance * distance;
712+
let n_main = self.xs.len();
713+
for (i, (&x, &y)) in self.delta_xs.iter().zip(self.delta_ys.iter()).enumerate() {
714+
let dx = x - cx;
715+
let dy = y - cy;
716+
if dx * dx + dy * dy <= d2 {
717+
result.push((n_main + i) as u64);
718+
}
719+
}
720+
self.delta_query_cost += self.delta_xs.len() as u64;
721+
self.maybe_flush_on_cost(kind);
722+
}
723+
Ok(PyArray1::from_vec(py, result))
724+
}
725+
646726
/// Intersect multiple bbox queries via two-pointer sorted merge on the hit arrays.
647727
/// O(K * |H|) over K predicates and per-predicate hit count |H| rather than O(K * N)
648728
/// bitmap ANDs. Short-circuits once the running intersection is empty.

0 commit comments

Comments
 (0)