Skip to main content

vortex_geo/aggregate_fn/
aabb.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! The 2D axis-aligned bounding-box (AABB) aggregate for native geometry columns.
5
6use geo::Rect as GeoRect;
7use vortex_array::ArrayRef;
8use vortex_array::Columnar;
9use vortex_array::ExecutionCtx;
10use vortex_array::IntoArray;
11use vortex_array::aggregate_fn::AggregateFnId;
12use vortex_array::aggregate_fn::AggregateFnRef;
13use vortex_array::aggregate_fn::AggregateFnVTable;
14use vortex_array::aggregate_fn::AggregateFnVTableExt;
15use vortex_array::aggregate_fn::EmptyOptions;
16use vortex_array::dtype::DType;
17use vortex_array::dtype::Nullability;
18use vortex_array::dtype::extension::ExtDType;
19use vortex_array::scalar::Scalar;
20use vortex_error::VortexExpect;
21use vortex_error::VortexResult;
22use vortex_error::vortex_err;
23use vortex_session::VortexSession;
24use vortex_session::registry::CachedId;
25
26use crate::extension::GeoMetadata;
27use crate::extension::Rect;
28use crate::extension::box_storage_dtype;
29use crate::extension::coordinate::Dimension;
30use crate::extension::coordinate::box_corners;
31use crate::extension::coordinate::ordinates;
32use crate::extension::flatten_coordinates;
33use crate::extension::is_native_geometry;
34
35/// Aggregates a native geometry column's 2D axis-aligned bounding box (AABB) as a native
36/// `geoarrow.box`, the spatial analogue of min/max. Also the default zone statistic for such
37/// columns (via `zone_stat_default`).
38#[derive(Clone, Debug)]
39pub struct GeometryAabb;
40
41/// Running union of geometry AABBs, or `None` until the first row. A transient
42/// `geo::Rect` value - the persisted stat is the native box (see `to_scalar`).
43pub struct AabbPartial {
44    rect: Option<GeoRect<f64>>,
45}
46
47impl AabbPartial {
48    /// Grow the accumulated box to also cover `other`.
49    fn merge(&mut self, other: GeoRect<f64>) {
50        self.rect = Some(self.rect.map_or(other, |cur| {
51            GeoRect::new(
52                (
53                    cur.min().x.min(other.min().x),
54                    cur.min().y.min(other.min().y),
55                ),
56                (
57                    cur.max().x.max(other.max().x),
58                    cur.max().y.max(other.max().y),
59                ),
60            )
61        }));
62    }
63}
64
65/// The stat's type: the native `geoarrow.box` (2D), nullable so an empty group is a null box.
66fn aabb_dtype() -> DType {
67    DType::Extension(
68        ExtDType::<Rect>::try_new(GeoMetadata::default(), aabb_storage_dtype())
69            .vortex_expect("2D box storage is a valid Rect")
70            .erased(),
71    )
72}
73
74/// The `Rect` storage `Struct<xmin, ymin, xmax, ymax>` backing the zone statistic.
75fn aabb_storage_dtype() -> DType {
76    box_storage_dtype(Dimension::Xy, Nullability::Nullable)
77}
78
79/// The AABB of the raw `x`/`y` slices, or `None` when empty.
80fn aabb_of(xs: &[f64], ys: &[f64]) -> Option<GeoRect<f64>> {
81    (!xs.is_empty()).then(|| {
82        let [xmin, ymin, xmax, ymax] = box_corners(xs, ys);
83        GeoRect::new((xmin, ymin), (xmax, ymax))
84    })
85}
86
87/// Read an AABB stat scalar (a nullable native `geoarrow.box`) into a [`GeoRect`], or `None` when
88/// the scalar is null (an empty group).
89fn rect_from_storage(scalar: &Scalar) -> VortexResult<Option<GeoRect<f64>>> {
90    if scalar.is_null() {
91        return Ok(None);
92    }
93    let storage = scalar.as_extension().to_storage_scalar();
94    let fields = storage.as_struct();
95    let read = |name: &str| -> VortexResult<f64> {
96        f64::try_from(
97            &fields
98                .field(name)
99                .ok_or_else(|| vortex_err!("AABB missing {name}"))?,
100        )
101    };
102    Ok(Some(GeoRect::new(
103        (read("xmin")?, read("ymin")?),
104        (read("xmax")?, read("ymax")?),
105    )))
106}
107
108/// Serialize a [`GeoRect`] as a native `geoarrow.box` stat scalar (inverse of [`rect_from_storage`]).
109fn rect_to_storage(rect: GeoRect<f64>) -> Scalar {
110    let storage = Scalar::struct_(
111        aabb_storage_dtype(),
112        vec![
113            Scalar::primitive(rect.min().x, Nullability::NonNullable),
114            Scalar::primitive(rect.min().y, Nullability::NonNullable),
115            Scalar::primitive(rect.max().x, Nullability::NonNullable),
116            Scalar::primitive(rect.max().y, Nullability::NonNullable),
117        ],
118    );
119    Scalar::extension::<Rect>(GeoMetadata::default(), storage)
120}
121
122impl AggregateFnVTable for GeometryAabb {
123    type Options = EmptyOptions;
124    type Partial = AabbPartial;
125
126    fn id(&self) -> AggregateFnId {
127        static ID: CachedId = CachedId::new("vortex.geo.aabb");
128        *ID
129    }
130
131    // Serializable so the zoned writer can persist this as a per-chunk stat. No options to encode.
132    fn serialize(&self, _options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
133        Ok(Some(vec![]))
134    }
135
136    fn deserialize(
137        &self,
138        _metadata: &[u8],
139        _session: &VortexSession,
140    ) -> VortexResult<Self::Options> {
141        Ok(EmptyOptions)
142    }
143
144    fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option<DType> {
145        is_native_geometry(input_dtype).then(aabb_dtype)
146    }
147
148    fn zone_stat_default(&self, input_dtype: &DType) -> Option<AggregateFnRef> {
149        is_native_geometry(input_dtype).then(|| self.bind(EmptyOptions))
150    }
151
152    fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
153        self.return_dtype(options, input_dtype)
154    }
155
156    fn empty_partial(
157        &self,
158        _options: &Self::Options,
159        _input_dtype: &DType,
160    ) -> VortexResult<Self::Partial> {
161        Ok(AabbPartial { rect: None })
162    }
163
164    fn combine_partials(&self, partial: &mut Self::Partial, other: Scalar) -> VortexResult<()> {
165        if let Some(rect) = rect_from_storage(&other)? {
166            partial.merge(rect);
167        }
168        Ok(())
169    }
170
171    fn to_scalar(&self, partial: &Self::Partial) -> VortexResult<Scalar> {
172        Ok(match partial.rect {
173            Some(rect) => rect_to_storage(rect),
174            None => Scalar::null(aabb_dtype()),
175        })
176    }
177
178    fn reset(&self, partial: &mut Self::Partial) {
179        partial.rect = None;
180    }
181
182    fn is_saturated(&self, _partial: &Self::Partial) -> bool {
183        // An AABB can always grow, so it is never saturated.
184        false
185    }
186
187    fn accumulate(
188        &self,
189        partial: &mut Self::Partial,
190        batch: &Columnar,
191        ctx: &mut ExecutionCtx,
192    ) -> VortexResult<()> {
193        let array = match batch {
194            Columnar::Canonical(canonical) => canonical.clone().into_array(),
195            Columnar::Constant(constant) => constant.clone().into_array(),
196        };
197        // Drop null rows before reading coordinates: a null geometry's storage holds placeholder
198        // coordinates (e.g. `(0, 0)`) that would otherwise widen the zone box and drag it toward
199        // the origin. `filter` collapses an all-true mask back to the input, so a null-free batch
200        // passes through unchanged.
201        //
202        // TODO(perf): on nullable data this `filter` compacts the whole column even for a single
203        // null before we min/max it. A validity-aware min/max straight over the raw x/y buffers
204        // would skip that copy. Left as-is for now: this is a write-time zone stat, and the common
205        // non-nullable case already costs nothing (the all-true mask makes `filter` a no-op).
206        let valid = array.validity()?.execute_mask(array.len(), ctx)?;
207        let array = array.filter(valid)?;
208        // Null rows are gone, so every coordinate below belongs to a present geometry — the
209        // `unmasked_field_by_name` reads are therefore safe. Min/max the raw x/y buffers directly:
210        // cheap, and avoids `to_geometry`'s panic on empty points (which decoding would hit).
211        let coords = flatten_coordinates(&array, ctx)?;
212        let xs = ordinates(&coords, "x", ctx)?;
213        let ys = ordinates(&coords, "y", ctx)?;
214        if let Some(rect) = aabb_of(&xs, &ys) {
215            partial.merge(rect);
216        }
217        Ok(())
218    }
219
220    fn finalize(&self, partials: ArrayRef) -> VortexResult<ArrayRef> {
221        // The stored partial is already the AABB struct, so finalizing is the identity.
222        Ok(partials)
223    }
224
225    fn finalize_scalar(&self, partial: &Self::Partial) -> VortexResult<Scalar> {
226        self.to_scalar(partial)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use geo::Rect as GeoRect;
233    use vortex_array::ArrayRef;
234    use vortex_array::VortexSessionExecute;
235    use vortex_array::aggregate_fn::Accumulator;
236    use vortex_array::aggregate_fn::AggregateFnVTable;
237    use vortex_array::aggregate_fn::DynAccumulator;
238    use vortex_array::aggregate_fn::EmptyOptions;
239    use vortex_array::aggregate_fn::session::AggregateFnSessionExt;
240    use vortex_array::dtype::DType;
241    use vortex_array::dtype::Nullability;
242    use vortex_array::dtype::PType;
243    use vortex_array::scalar::Scalar;
244    use vortex_error::VortexResult;
245
246    use super::AabbPartial;
247    use super::GeometryAabb;
248    use super::aabb_dtype;
249    use super::rect_from_storage;
250    use crate::test_harness::geo_session;
251    use crate::test_harness::linestring_column;
252    use crate::test_harness::multilinestring_column;
253    use crate::test_harness::multipoint_column;
254    use crate::test_harness::multipolygon_column;
255    use crate::test_harness::nullable_point_column;
256    use crate::test_harness::point_column;
257    use crate::test_harness::polygon_column;
258
259    /// One column of every native geometry type over the same `(x, y)` vertex set.
260    fn every_native_column(vertices: &[(f64, f64)]) -> VortexResult<Vec<ArrayRef>> {
261        let (xs, ys): (Vec<f64>, Vec<f64>) = vertices.iter().copied().unzip();
262        let flat = vertices.to_vec();
263        Ok(vec![
264            point_column(xs, ys)?,
265            linestring_column(vec![flat.clone()])?,
266            multipoint_column(vec![flat.clone()])?,
267            polygon_column(vec![vec![flat.clone()]])?,
268            multilinestring_column(vec![vec![flat.clone()]])?,
269            multipolygon_column(vec![vec![vec![flat]]])?,
270        ])
271    }
272
273    /// The aggregate must be serializable so the zoned writer can persist its zone-stat descriptor.
274    #[test]
275    fn serializes_for_zone_storage() -> VortexResult<()> {
276        let session = vortex_array::array_session();
277        let metadata = GeometryAabb
278            .serialize(&EmptyOptions)?
279            .expect("GeometryAabb must be serializable to be stored as a zone statistic");
280        GeometryAabb.deserialize(&metadata, &session)?;
281        Ok(())
282    }
283
284    /// The AABB result's corners as `(xmin, ymin, xmax, ymax)`.
285    fn aabb(result: &Scalar) -> VortexResult<(f64, f64, f64, f64)> {
286        let rect = rect_from_storage(result)?.expect("non-null AABB");
287        Ok((rect.min().x, rect.min().y, rect.max().x, rect.max().y))
288    }
289
290    /// The AABB of a Point column is the min/max of its coordinates, accumulated across batches.
291    #[test]
292    fn point_aabb_across_batches() -> VortexResult<()> {
293        let session = vortex_array::array_session();
294        let mut ctx = session.create_execution_ctx();
295
296        let dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
297        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?;
298
299        acc.accumulate(&point_column(vec![1.0, 3.0], vec![2.0, 4.0])?, &mut ctx)?;
300        acc.accumulate(&point_column(vec![-1.0], vec![5.0])?, &mut ctx)?;
301
302        assert_eq!(aabb(&acc.finish()?)?, (-1.0, 2.0, 3.0, 5.0));
303        Ok(())
304    }
305
306    /// Null rows contribute no extent: their placeholder coordinates must not widen the box toward
307    /// the origin (matching min/max, which skip nulls).
308    #[test]
309    fn null_rows_do_not_widen_aabb() -> VortexResult<()> {
310        let session = vortex_array::array_session();
311        let mut ctx = session.create_execution_ctx();
312
313        // The valid points sit far from the origin; the null row's storage placeholder is (0, 0).
314        let column = nullable_point_column(vec![Some((5.0, 6.0)), None, Some((7.0, 8.0))])?;
315        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, column.dtype().clone())?;
316        acc.accumulate(&column, &mut ctx)?;
317
318        // The box covers only the two valid points, never (0, 0).
319        assert_eq!(aabb(&acc.finish()?)?, (5.0, 6.0, 7.0, 8.0));
320        Ok(())
321    }
322
323    /// The AABB of a Polygon column unions every ring vertex - exercising the `List<List<Struct>>`
324    /// unwrap, not just the bare Point struct.
325    #[test]
326    fn polygon_aabb_union_all_vertices() -> VortexResult<()> {
327        let session = vortex_array::array_session();
328        let mut ctx = session.create_execution_ctx();
329
330        // Two rectangles: (0,0)-(2,3) and (5,5)-(7,8). The chunk AABB is their union: (0,0)-(7,8).
331        let polygons = polygon_column(vec![
332            vec![vec![(0.0, 0.0), (2.0, 0.0), (2.0, 3.0), (0.0, 3.0)]],
333            vec![vec![(5.0, 5.0), (7.0, 5.0), (7.0, 8.0), (5.0, 8.0)]],
334        ])?;
335        let dtype = polygons.dtype().clone();
336        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?;
337        acc.accumulate(&polygons, &mut ctx)?;
338
339        assert_eq!(aabb(&acc.finish()?)?, (0.0, 0.0, 7.0, 8.0));
340        Ok(())
341    }
342
343    /// Every native geometry type over the same vertex set yields the same AABB - the zone stat
344    /// covers the whole type family.
345    #[test]
346    fn aabb_covers_every_native_geometry_type() -> VortexResult<()> {
347        let session = vortex_array::array_session();
348        let mut ctx = session.create_execution_ctx();
349
350        for column in every_native_column(&[(1.0, 2.0), (-1.0, 5.0), (3.0, 4.0)])? {
351            let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, column.dtype().clone())?;
352            acc.accumulate(&column, &mut ctx)?;
353            assert_eq!(
354                aabb(&acc.finish()?)?,
355                (-1.0, 2.0, 3.0, 5.0),
356                "AABB mismatch for {}",
357                column.dtype()
358            );
359        }
360        Ok(())
361    }
362
363    /// The AABB of a MultiPolygon column unions every vertex of every polygon's rings - exercising
364    /// the triple-`List` unwrap.
365    #[test]
366    fn multipolygon_aabb_union_all_vertices() -> VortexResult<()> {
367        let session = vortex_array::array_session();
368        let mut ctx = session.create_execution_ctx();
369
370        // Multipolygon 0: squares (0,0)-(1,1) and (4,4)-(5,5); multipolygon 1: square (-3,7)-(-2,9).
371        let multipolygons = multipolygon_column(vec![
372            vec![
373                vec![vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]],
374                vec![vec![(4.0, 4.0), (5.0, 4.0), (5.0, 5.0), (4.0, 5.0)]],
375            ],
376            vec![vec![vec![
377                (-3.0, 7.0),
378                (-2.0, 7.0),
379                (-2.0, 9.0),
380                (-3.0, 9.0),
381            ]]],
382        ])?;
383        let dtype = multipolygons.dtype().clone();
384        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?;
385        acc.accumulate(&multipolygons, &mut ctx)?;
386
387        assert_eq!(aabb(&acc.finish()?)?, (-3.0, 0.0, 5.0, 9.0));
388        Ok(())
389    }
390
391    /// `combine_partials` unions partial boxes - the path the zoned writer takes when a zone's
392    /// array is chunked.
393    #[test]
394    fn combine_partials_unions_boxes() -> VortexResult<()> {
395        let bbox = |xmin, ymin, xmax, ymax| AabbPartial {
396            rect: Some(GeoRect::new((xmin, ymin), (xmax, ymax))),
397        };
398        let mut partial = AabbPartial { rect: None };
399        GeometryAabb.combine_partials(
400            &mut partial,
401            GeometryAabb.to_scalar(&bbox(0.0, 0.0, 1.0, 1.0))?,
402        )?;
403        GeometryAabb.combine_partials(
404            &mut partial,
405            GeometryAabb.to_scalar(&bbox(5.0, -2.0, 7.0, 3.0))?,
406        )?;
407        assert_eq!(
408            aabb(&GeometryAabb.to_scalar(&partial)?)?,
409            (0.0, -2.0, 7.0, 3.0)
410        );
411        Ok(())
412    }
413
414    /// A null partial (an empty group's AABB) is a no-op in `combine_partials`.
415    #[test]
416    fn combine_partials_ignores_null() -> VortexResult<()> {
417        let mut partial = AabbPartial {
418            rect: Some(GeoRect::new((0.0, 0.0), (1.0, 1.0))),
419        };
420        GeometryAabb.combine_partials(&mut partial, Scalar::null(aabb_dtype()))?;
421        assert_eq!(
422            aabb(&GeometryAabb.to_scalar(&partial)?)?,
423            (0.0, 0.0, 1.0, 1.0)
424        );
425        Ok(())
426    }
427
428    /// All-NaN coordinates: `f64::min`/`max` skip the NaNs and `geo::Rect` normalizes the result to
429    /// a valid (whole-plane) box, so such a chunk is always kept. Sound - NaN-coordinate rows can
430    /// never satisfy `distance <= r` anyway.
431    #[test]
432    fn all_nan_coordinates_kept() -> VortexResult<()> {
433        let session = vortex_array::array_session();
434        let mut ctx = session.create_execution_ctx();
435
436        let column = point_column(vec![f64::NAN, f64::NAN], vec![f64::NAN, f64::NAN])?;
437        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, column.dtype().clone())?;
438        acc.accumulate(&column, &mut ctx)?;
439
440        let (xmin, ymin, xmax, ymax) = aabb(&acc.finish()?)?;
441        assert!(xmin <= xmax && ymin <= ymax);
442        Ok(())
443    }
444
445    /// An empty group yields a null AABB.
446    #[test]
447    fn empty_group_is_null() -> VortexResult<()> {
448        let dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
449        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?;
450        assert!(acc.finish()?.is_null());
451        Ok(())
452    }
453
454    /// After `initialize`, the registry yields a default zone statistic for every native geometry
455    /// type (so the zoned writer stores it) but none for ordinary numeric columns.
456    #[test]
457    fn registered_as_geometry_zone_default() -> VortexResult<()> {
458        let session = geo_session();
459
460        for column in every_native_column(&[(0.0, 0.0), (1.0, 1.0)])? {
461            assert!(
462                !session
463                    .aggregate_fns()
464                    .zone_stat_defaults(column.dtype())
465                    .is_empty(),
466                "a geometry zone-stat default should be discovered for {}",
467                column.dtype()
468            );
469        }
470        let i32_dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
471        assert!(
472            session
473                .aggregate_fns()
474                .zone_stat_defaults(&i32_dtype)
475                .is_empty(),
476            "no geometry zone-stat default should apply to numeric columns"
477        );
478        Ok(())
479    }
480}