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::arrays::PrimitiveArray;
17use vortex_array::arrays::struct_::StructArrayExt;
18use vortex_array::dtype::DType;
19use vortex_array::dtype::Nullability;
20use vortex_array::dtype::extension::ExtDType;
21use vortex_array::scalar::Scalar;
22use vortex_error::VortexExpect;
23use vortex_error::VortexResult;
24use vortex_error::vortex_err;
25use vortex_session::VortexSession;
26use vortex_session::registry::CachedId;
27
28use crate::extension::GeoMetadata;
29use crate::extension::Rect;
30use crate::extension::box_storage_dtype;
31use crate::extension::coordinate::Dimension;
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    if xs.is_empty() {
82        return None;
83    }
84    let min_max = |vals: &[f64]| {
85        vals.iter()
86            .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
87                (lo.min(v), hi.max(v))
88            })
89    };
90    let (xmin, xmax) = min_max(xs);
91    let (ymin, ymax) = min_max(ys);
92    Some(GeoRect::new((xmin, ymin), (xmax, ymax)))
93}
94
95/// Read an AABB stat scalar (a nullable native `geoarrow.box`) into a [`GeoRect`], or `None` when
96/// the scalar is null (an empty group).
97fn rect_from_storage(scalar: &Scalar) -> VortexResult<Option<GeoRect<f64>>> {
98    if scalar.is_null() {
99        return Ok(None);
100    }
101    let storage = scalar.as_extension().to_storage_scalar();
102    let fields = storage.as_struct();
103    let read = |name: &str| -> VortexResult<f64> {
104        f64::try_from(
105            &fields
106                .field(name)
107                .ok_or_else(|| vortex_err!("AABB missing {name}"))?,
108        )
109    };
110    Ok(Some(GeoRect::new(
111        (read("xmin")?, read("ymin")?),
112        (read("xmax")?, read("ymax")?),
113    )))
114}
115
116/// Serialize a [`GeoRect`] as a native `geoarrow.box` stat scalar (inverse of [`rect_from_storage`]).
117fn rect_to_storage(rect: GeoRect<f64>) -> Scalar {
118    let storage = Scalar::struct_(
119        aabb_storage_dtype(),
120        vec![
121            Scalar::primitive(rect.min().x, Nullability::NonNullable),
122            Scalar::primitive(rect.min().y, Nullability::NonNullable),
123            Scalar::primitive(rect.max().x, Nullability::NonNullable),
124            Scalar::primitive(rect.max().y, Nullability::NonNullable),
125        ],
126    );
127    Scalar::extension::<Rect>(GeoMetadata::default(), storage)
128}
129
130impl AggregateFnVTable for GeometryAabb {
131    type Options = EmptyOptions;
132    type Partial = AabbPartial;
133
134    fn id(&self) -> AggregateFnId {
135        static ID: CachedId = CachedId::new("vortex.geo.aabb");
136        *ID
137    }
138
139    // Serializable so the zoned writer can persist this as a per-chunk stat. No options to encode.
140    fn serialize(&self, _options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
141        Ok(Some(vec![]))
142    }
143
144    fn deserialize(
145        &self,
146        _metadata: &[u8],
147        _session: &VortexSession,
148    ) -> VortexResult<Self::Options> {
149        Ok(EmptyOptions)
150    }
151
152    fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option<DType> {
153        is_native_geometry(input_dtype).then(aabb_dtype)
154    }
155
156    fn zone_stat_default(&self, input_dtype: &DType) -> Option<AggregateFnRef> {
157        is_native_geometry(input_dtype).then(|| self.bind(EmptyOptions))
158    }
159
160    fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
161        self.return_dtype(options, input_dtype)
162    }
163
164    fn empty_partial(
165        &self,
166        _options: &Self::Options,
167        _input_dtype: &DType,
168    ) -> VortexResult<Self::Partial> {
169        Ok(AabbPartial { rect: None })
170    }
171
172    fn combine_partials(&self, partial: &mut Self::Partial, other: Scalar) -> VortexResult<()> {
173        if let Some(rect) = rect_from_storage(&other)? {
174            partial.merge(rect);
175        }
176        Ok(())
177    }
178
179    fn to_scalar(&self, partial: &Self::Partial) -> VortexResult<Scalar> {
180        Ok(match partial.rect {
181            Some(rect) => rect_to_storage(rect),
182            None => Scalar::null(aabb_dtype()),
183        })
184    }
185
186    fn reset(&self, partial: &mut Self::Partial) {
187        partial.rect = None;
188    }
189
190    fn is_saturated(&self, _partial: &Self::Partial) -> bool {
191        // An AABB can always grow, so it is never saturated.
192        false
193    }
194
195    fn accumulate(
196        &self,
197        partial: &mut Self::Partial,
198        batch: &Columnar,
199        ctx: &mut ExecutionCtx,
200    ) -> VortexResult<()> {
201        let array = match batch {
202            Columnar::Canonical(canonical) => canonical.clone().into_array(),
203            Columnar::Constant(constant) => constant.clone().into_array(),
204        };
205        // Min/max the raw x/y buffers directly - cheap, and avoids `to_geometry`'s panic on empty
206        // points (which decoding each geometry would hit).
207        let coords = flatten_coordinates(&array, ctx)?;
208        let xs = coords
209            .unmasked_field_by_name("x")?
210            .clone()
211            .execute::<PrimitiveArray>(ctx)?;
212        let ys = coords
213            .unmasked_field_by_name("y")?
214            .clone()
215            .execute::<PrimitiveArray>(ctx)?;
216        if let Some(rect) = aabb_of(xs.as_slice::<f64>(), ys.as_slice::<f64>()) {
217            partial.merge(rect);
218        }
219        Ok(())
220    }
221
222    fn finalize(&self, partials: ArrayRef) -> VortexResult<ArrayRef> {
223        // The stored partial is already the AABB struct, so finalizing is the identity.
224        Ok(partials)
225    }
226
227    fn finalize_scalar(&self, partial: &Self::Partial) -> VortexResult<Scalar> {
228        self.to_scalar(partial)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use geo::Rect as GeoRect;
235    use vortex_array::ArrayRef;
236    use vortex_array::VortexSessionExecute;
237    use vortex_array::aggregate_fn::Accumulator;
238    use vortex_array::aggregate_fn::AggregateFnVTable;
239    use vortex_array::aggregate_fn::DynAccumulator;
240    use vortex_array::aggregate_fn::EmptyOptions;
241    use vortex_array::aggregate_fn::session::AggregateFnSessionExt;
242    use vortex_array::dtype::DType;
243    use vortex_array::dtype::Nullability;
244    use vortex_array::dtype::PType;
245    use vortex_array::scalar::Scalar;
246    use vortex_error::VortexResult;
247
248    use super::AabbPartial;
249    use super::GeometryAabb;
250    use super::aabb_dtype;
251    use super::rect_from_storage;
252    use crate::test_harness::geo_session;
253    use crate::test_harness::linestring_column;
254    use crate::test_harness::multilinestring_column;
255    use crate::test_harness::multipoint_column;
256    use crate::test_harness::multipolygon_column;
257    use crate::test_harness::point_column;
258    use crate::test_harness::polygon_column;
259
260    /// One column of every native geometry type over the same `(x, y)` vertex set.
261    fn every_native_column(vertices: &[(f64, f64)]) -> VortexResult<Vec<ArrayRef>> {
262        let (xs, ys): (Vec<f64>, Vec<f64>) = vertices.iter().copied().unzip();
263        let flat = vertices.to_vec();
264        Ok(vec![
265            point_column(xs, ys)?,
266            linestring_column(vec![flat.clone()])?,
267            multipoint_column(vec![flat.clone()])?,
268            polygon_column(vec![vec![flat.clone()]])?,
269            multilinestring_column(vec![vec![flat.clone()]])?,
270            multipolygon_column(vec![vec![vec![flat]]])?,
271        ])
272    }
273
274    /// The aggregate must be serializable so the zoned writer can persist its zone-stat descriptor.
275    #[test]
276    fn serializes_for_zone_storage() -> VortexResult<()> {
277        let session = vortex_array::array_session();
278        let metadata = GeometryAabb
279            .serialize(&EmptyOptions)?
280            .expect("GeometryAabb must be serializable to be stored as a zone statistic");
281        GeometryAabb.deserialize(&metadata, &session)?;
282        Ok(())
283    }
284
285    /// The AABB result's corners as `(xmin, ymin, xmax, ymax)`.
286    fn aabb(result: &Scalar) -> VortexResult<(f64, f64, f64, f64)> {
287        let rect = rect_from_storage(result)?.expect("non-null AABB");
288        Ok((rect.min().x, rect.min().y, rect.max().x, rect.max().y))
289    }
290
291    /// The AABB of a Point column is the min/max of its coordinates, accumulated across batches.
292    #[test]
293    fn point_aabb_across_batches() -> VortexResult<()> {
294        let session = vortex_array::array_session();
295        let mut ctx = session.create_execution_ctx();
296
297        let dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
298        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?;
299
300        acc.accumulate(&point_column(vec![1.0, 3.0], vec![2.0, 4.0])?, &mut ctx)?;
301        acc.accumulate(&point_column(vec![-1.0], vec![5.0])?, &mut ctx)?;
302
303        assert_eq!(aabb(&acc.finish()?)?, (-1.0, 2.0, 3.0, 5.0));
304        Ok(())
305    }
306
307    /// The AABB of a Polygon column unions every ring vertex - exercising the `List<List<Struct>>`
308    /// unwrap, not just the bare Point struct.
309    #[test]
310    fn polygon_aabb_union_all_vertices() -> VortexResult<()> {
311        let session = vortex_array::array_session();
312        let mut ctx = session.create_execution_ctx();
313
314        // Two rectangles: (0,0)-(2,3) and (5,5)-(7,8). The chunk AABB is their union: (0,0)-(7,8).
315        let polygons = polygon_column(vec![
316            vec![vec![(0.0, 0.0), (2.0, 0.0), (2.0, 3.0), (0.0, 3.0)]],
317            vec![vec![(5.0, 5.0), (7.0, 5.0), (7.0, 8.0), (5.0, 8.0)]],
318        ])?;
319        let dtype = polygons.dtype().clone();
320        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?;
321        acc.accumulate(&polygons, &mut ctx)?;
322
323        assert_eq!(aabb(&acc.finish()?)?, (0.0, 0.0, 7.0, 8.0));
324        Ok(())
325    }
326
327    /// Every native geometry type over the same vertex set yields the same AABB - the zone stat
328    /// covers the whole type family.
329    #[test]
330    fn aabb_covers_every_native_geometry_type() -> VortexResult<()> {
331        let session = vortex_array::array_session();
332        let mut ctx = session.create_execution_ctx();
333
334        for column in every_native_column(&[(1.0, 2.0), (-1.0, 5.0), (3.0, 4.0)])? {
335            let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, column.dtype().clone())?;
336            acc.accumulate(&column, &mut ctx)?;
337            assert_eq!(
338                aabb(&acc.finish()?)?,
339                (-1.0, 2.0, 3.0, 5.0),
340                "AABB mismatch for {}",
341                column.dtype()
342            );
343        }
344        Ok(())
345    }
346
347    /// The AABB of a MultiPolygon column unions every vertex of every polygon's rings - exercising
348    /// the triple-`List` unwrap.
349    #[test]
350    fn multipolygon_aabb_union_all_vertices() -> VortexResult<()> {
351        let session = vortex_array::array_session();
352        let mut ctx = session.create_execution_ctx();
353
354        // Multipolygon 0: squares (0,0)-(1,1) and (4,4)-(5,5); multipolygon 1: square (-3,7)-(-2,9).
355        let multipolygons = multipolygon_column(vec![
356            vec![
357                vec![vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]],
358                vec![vec![(4.0, 4.0), (5.0, 4.0), (5.0, 5.0), (4.0, 5.0)]],
359            ],
360            vec![vec![vec![
361                (-3.0, 7.0),
362                (-2.0, 7.0),
363                (-2.0, 9.0),
364                (-3.0, 9.0),
365            ]]],
366        ])?;
367        let dtype = multipolygons.dtype().clone();
368        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?;
369        acc.accumulate(&multipolygons, &mut ctx)?;
370
371        assert_eq!(aabb(&acc.finish()?)?, (-3.0, 0.0, 5.0, 9.0));
372        Ok(())
373    }
374
375    /// `combine_partials` unions partial boxes - the path the zoned writer takes when a zone's
376    /// array is chunked.
377    #[test]
378    fn combine_partials_unions_boxes() -> VortexResult<()> {
379        let bbox = |xmin, ymin, xmax, ymax| AabbPartial {
380            rect: Some(GeoRect::new((xmin, ymin), (xmax, ymax))),
381        };
382        let mut partial = AabbPartial { rect: None };
383        GeometryAabb.combine_partials(
384            &mut partial,
385            GeometryAabb.to_scalar(&bbox(0.0, 0.0, 1.0, 1.0))?,
386        )?;
387        GeometryAabb.combine_partials(
388            &mut partial,
389            GeometryAabb.to_scalar(&bbox(5.0, -2.0, 7.0, 3.0))?,
390        )?;
391        assert_eq!(
392            aabb(&GeometryAabb.to_scalar(&partial)?)?,
393            (0.0, -2.0, 7.0, 3.0)
394        );
395        Ok(())
396    }
397
398    /// A null partial (an empty group's AABB) is a no-op in `combine_partials`.
399    #[test]
400    fn combine_partials_ignores_null() -> VortexResult<()> {
401        let mut partial = AabbPartial {
402            rect: Some(GeoRect::new((0.0, 0.0), (1.0, 1.0))),
403        };
404        GeometryAabb.combine_partials(&mut partial, Scalar::null(aabb_dtype()))?;
405        assert_eq!(
406            aabb(&GeometryAabb.to_scalar(&partial)?)?,
407            (0.0, 0.0, 1.0, 1.0)
408        );
409        Ok(())
410    }
411
412    /// All-NaN coordinates: `f64::min`/`max` skip the NaNs and `geo::Rect` normalizes the result to
413    /// a valid (whole-plane) box, so such a chunk is always kept. Sound - NaN-coordinate rows can
414    /// never satisfy `distance <= r` anyway.
415    #[test]
416    fn all_nan_coordinates_kept() -> VortexResult<()> {
417        let session = vortex_array::array_session();
418        let mut ctx = session.create_execution_ctx();
419
420        let column = point_column(vec![f64::NAN, f64::NAN], vec![f64::NAN, f64::NAN])?;
421        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, column.dtype().clone())?;
422        acc.accumulate(&column, &mut ctx)?;
423
424        let (xmin, ymin, xmax, ymax) = aabb(&acc.finish()?)?;
425        assert!(xmin <= xmax && ymin <= ymax);
426        Ok(())
427    }
428
429    /// An empty group yields a null AABB.
430    #[test]
431    fn empty_group_is_null() -> VortexResult<()> {
432        let dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
433        let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?;
434        assert!(acc.finish()?.is_null());
435        Ok(())
436    }
437
438    /// After `initialize`, the registry yields a default zone statistic for every native geometry
439    /// type (so the zoned writer stores it) but none for ordinary numeric columns.
440    #[test]
441    fn registered_as_geometry_zone_default() -> VortexResult<()> {
442        let session = geo_session();
443
444        for column in every_native_column(&[(0.0, 0.0), (1.0, 1.0)])? {
445            assert!(
446                !session
447                    .aggregate_fns()
448                    .zone_stat_defaults(column.dtype())
449                    .is_empty(),
450                "a geometry zone-stat default should be discovered for {}",
451                column.dtype()
452            );
453        }
454        let i32_dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
455        assert!(
456            session
457                .aggregate_fns()
458                .zone_stat_defaults(&i32_dtype)
459                .is_empty(),
460            "no geometry zone-stat default should apply to numeric columns"
461        );
462        Ok(())
463    }
464}