• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geo-engine / geoengine / 19567405893

21 Nov 2025 10:21AM UTC coverage: 88.567%. First build
19567405893

Pull #1083

github

web-flow
Merge c6cd1b0c9 into ba34db99d
Pull Request #1083: feat: new gdal source workflow optimization

6332 of 7653 new or added lines in 79 files covered. (82.74%)

116676 of 131738 relevant lines covered (88.57%)

492817.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.36
/operators/src/processing/circle_merging_quadtree/operator.rs
1
use std::collections::{HashMap, HashSet};
2

3
use async_trait::async_trait;
4
use futures::StreamExt;
5
use futures::stream::{BoxStream, FuturesUnordered};
6
use geoengine_datatypes::collections::{
7
    BuilderProvider, GeoFeatureCollectionRowBuilder, MultiPointCollection, VectorDataType,
8
};
9
use geoengine_datatypes::primitives::{
10
    BoundingBox2D, Circle, FeatureDataType, FeatureDataValue, Measurement, MultiPoint,
11
    MultiPointAccess, SpatialBounded, SpatialResolution, VectorQueryRectangle,
12
};
13
use geoengine_datatypes::primitives::{CacheHint, ColumnSelection};
14
use serde::{Deserialize, Serialize};
15
use snafu::ensure;
16

17
use crate::adapters::FeatureCollectionStreamExt;
18
use crate::engine::{
19
    CanonicOperatorName, ExecutionContext, InitializedVectorOperator, Operator, QueryContext,
20
    QueryProcessor, SingleVectorSource, TypedVectorQueryProcessor, VectorColumnInfo,
21
    VectorOperator, VectorQueryProcessor, VectorResultDescriptor,
22
};
23
use crate::engine::{InitializedSources, OperatorName, WorkflowOperatorPath};
24
use crate::error::{self, Error};
25
use crate::optimization::OptimizationError;
26
use crate::processing::circle_merging_quadtree::aggregates::MeanAggregator;
27
use crate::processing::circle_merging_quadtree::circle_of_points::CircleOfPoints;
28
use crate::processing::circle_merging_quadtree::circle_radius_model::LogScaledRadius;
29
use crate::util::Result;
30

31
use super::aggregates::{AttributeAggregate, AttributeAggregateType, StringSampler};
32
use super::circle_radius_model::CircleRadiusModel;
33
use super::grid::Grid;
34
use super::quadtree::CircleMergingQuadtree;
35

36
#[derive(Debug, Serialize, Deserialize, Clone)]
37
#[serde(rename_all = "camelCase")]
38
pub struct VisualPointClusteringParams {
39
    pub min_radius_px: f64,
40
    pub delta_px: f64,
41
    pub resolution: f64,
42
    radius_column: String,
43
    count_column: String,
44
    column_aggregates: HashMap<String, AttributeAggregateDef>,
45
}
46

47
#[derive(Debug, Serialize, Deserialize, Clone)]
48
#[serde(rename_all = "camelCase")]
49
pub struct AttributeAggregateDef {
50
    pub column_name: String,
51
    pub aggregate_type: AttributeAggregateType,
52
    // if measurement is unset, it will be taken from the source result descriptor
53
    pub measurement: Option<Measurement>,
54
}
55

56
pub type VisualPointClustering = Operator<VisualPointClusteringParams, SingleVectorSource>;
57

58
impl OperatorName for VisualPointClustering {
59
    const TYPE_NAME: &'static str = "VisualPointClustering";
60
}
61

62
#[typetag::serde]
×
63
#[async_trait]
64
#[allow(clippy::too_many_lines)]
65
impl VectorOperator for VisualPointClustering {
66
    async fn _initialize(
67
        mut self: Box<Self>,
68
        path: WorkflowOperatorPath,
69
        context: &dyn ExecutionContext,
70
    ) -> Result<Box<dyn InitializedVectorOperator>> {
5✔
71
        ensure!(
72
            self.params.min_radius_px > 0.0,
73
            error::InputMustBeGreaterThanZero {
74
                scope: "VisualPointClustering",
75
                name: "minRadius"
76
            }
77
        );
78
        ensure!(
79
            self.params.delta_px >= 0.0,
80
            error::InputMustBeZeroOrPositive {
81
                scope: "VisualPointClustering",
82
                name: "deltaPx"
83
            }
84
        );
85
        ensure!(
86
            self.params.resolution >= 0.0,
87
            error::InputMustBeZeroOrPositive {
88
                scope: "VisualPointClustering",
89
                name: "resolution"
90
            }
91
        );
92
        ensure!(!self.params.radius_column.is_empty(), error::EmptyInput);
93
        ensure!(
94
            self.params.count_column != self.params.radius_column,
95
            error::DuplicateOutputColumns
96
        );
97

98
        let name = CanonicOperatorName::from(&self);
99

100
        let radius_model = LogScaledRadius::new(self.params.min_radius_px, self.params.delta_px)?;
101

102
        let initialized_sources = self
103
            .sources
104
            .initialize_sources(path.clone(), context)
105
            .await?;
106
        let vector_source = initialized_sources.vector;
107

108
        ensure!(
109
            vector_source.result_descriptor().data_type == VectorDataType::MultiPoint,
110
            error::InvalidType {
111
                expected: VectorDataType::MultiPoint.to_string(),
112
                found: vector_source.result_descriptor().data_type.to_string(),
113
            }
114
        );
115

116
        // check that all input columns exist
117
        for column_name in self.params.column_aggregates.keys() {
118
            let column_name_exists = vector_source
119
                .result_descriptor()
120
                .columns
121
                .contains_key(column_name);
122

123
            ensure!(
124
                column_name_exists,
125
                error::MissingInputColumn {
126
                    name: column_name.clone()
127
                }
128
            );
129
        }
130

131
        // check that there are no duplicates in the output columns
132
        let output_names: HashSet<&String> = self
133
            .params
134
            .column_aggregates
135
            .values()
136
            .map(|def| &def.column_name)
137
            .collect();
138
        ensure!(
139
            output_names.len() == self.params.column_aggregates.len(),
140
            error::DuplicateOutputColumns
141
        );
142

143
        // create schema for [`ResultDescriptor`]
144
        let mut new_columns: HashMap<String, VectorColumnInfo> =
145
            HashMap::with_capacity(self.params.column_aggregates.len());
146
        for attribute_aggregate_def in self.params.column_aggregates.values_mut() {
147
            if attribute_aggregate_def.measurement.is_none() {
148
                // take it from source measurement
149

150
                attribute_aggregate_def.measurement = vector_source
151
                    .result_descriptor()
152
                    .column_measurement(&attribute_aggregate_def.column_name)
153
                    .cloned();
154
            }
155

156
            let data_type = match attribute_aggregate_def.aggregate_type {
157
                AttributeAggregateType::MeanNumber => FeatureDataType::Float,
158
                AttributeAggregateType::StringSample => FeatureDataType::Text,
159
                AttributeAggregateType::Null => {
160
                    return Err(Error::InvalidType {
161
                        expected: "not null".to_string(),
162
                        found: "null".to_string(),
163
                    });
164
                }
165
            };
166

167
            new_columns.insert(
168
                attribute_aggregate_def.column_name.clone(),
169
                VectorColumnInfo {
170
                    data_type,
171
                    measurement: attribute_aggregate_def.measurement.clone().into(),
172
                },
173
            );
174
        }
175

176
        // check that output schema does not interfere with count and radius columns
177
        ensure!(
178
            !new_columns.contains_key(&self.params.radius_column)
179
                && !new_columns.contains_key(&self.params.count_column),
180
            error::DuplicateOutputColumns
181
        );
182

183
        let in_desc = vector_source.result_descriptor();
184

185
        Ok(InitializedVisualPointClustering {
186
            name,
187
            path,
188
            params: self.params.clone(),
189
            result_descriptor: VectorResultDescriptor {
190
                data_type: VectorDataType::MultiPoint,
191
                spatial_reference: in_desc.spatial_reference,
192
                columns: new_columns,
193
                time: in_desc.time,
194
                bbox: in_desc.bbox,
195
            },
196
            vector_source,
197
            radius_model,
198
            radius_column: self.params.radius_column,
199
            count_column: self.params.count_column,
200
            attribute_mapping: self.params.column_aggregates,
201
            resolution: self.params.resolution,
202
        }
203
        .boxed())
204
    }
5✔
205

206
    span_fn!(VisualPointClustering);
207
}
208

209
pub struct InitializedVisualPointClustering {
210
    name: CanonicOperatorName,
211
    path: WorkflowOperatorPath,
212
    params: VisualPointClusteringParams,
213
    result_descriptor: VectorResultDescriptor,
214
    vector_source: Box<dyn InitializedVectorOperator>,
215
    radius_model: LogScaledRadius,
216
    radius_column: String,
217
    count_column: String,
218
    attribute_mapping: HashMap<String, AttributeAggregateDef>,
219
    resolution: f64,
220
}
221

222
impl InitializedVectorOperator for InitializedVisualPointClustering {
223
    fn query_processor(&self) -> Result<TypedVectorQueryProcessor> {
5✔
224
        match self.vector_source.query_processor()? {
5✔
225
            TypedVectorQueryProcessor::MultiPoint(source) => {
5✔
226
                Ok(TypedVectorQueryProcessor::MultiPoint(
5✔
227
                    VisualPointClusteringProcessor::new(
5✔
228
                        source,
5✔
229
                        self.radius_model,
5✔
230
                        self.radius_column.clone(),
5✔
231
                        self.count_column.clone(),
5✔
232
                        self.result_descriptor.clone(),
5✔
233
                        self.attribute_mapping.clone(),
5✔
234
                        self.resolution,
5✔
235
                    )
5✔
236
                    .boxed(),
5✔
237
                ))
5✔
238
            }
239
            TypedVectorQueryProcessor::MultiLineString(_) => Err(error::Error::InvalidVectorType {
×
240
                expected: "MultiPoint".to_owned(),
×
241
                found: "MultiLineString".to_owned(),
×
242
            }),
×
243
            TypedVectorQueryProcessor::MultiPolygon(_) => Err(error::Error::InvalidVectorType {
×
244
                expected: "MultiPoint".to_owned(),
×
245
                found: "MultiPolygon".to_owned(),
×
246
            }),
×
247
            TypedVectorQueryProcessor::Data(_) => Err(error::Error::InvalidVectorType {
×
248
                expected: "MultiPoint".to_owned(),
×
249
                found: "Data".to_owned(),
×
250
            }),
×
251
        }
252
    }
5✔
253

254
    fn result_descriptor(&self) -> &VectorResultDescriptor {
×
255
        &self.result_descriptor
×
256
    }
×
257

258
    fn canonic_name(&self) -> CanonicOperatorName {
×
259
        self.name.clone()
×
260
    }
×
261

262
    fn name(&self) -> &'static str {
×
263
        VisualPointClustering::TYPE_NAME
×
264
    }
×
265

266
    fn path(&self) -> WorkflowOperatorPath {
×
267
        self.path.clone()
×
268
    }
×
269

NEW
270
    fn optimize(
×
NEW
271
        &self,
×
NEW
272
        target_resolution: SpatialResolution,
×
NEW
273
    ) -> Result<Box<dyn VectorOperator>, OptimizationError> {
×
274
        Ok(VisualPointClustering {
NEW
275
            params: self.params.clone(),
×
276
            sources: SingleVectorSource {
NEW
277
                vector: self.vector_source.optimize(target_resolution)?,
×
278
            },
279
        }
NEW
280
        .boxed())
×
NEW
281
    }
×
282
}
283

284
pub struct VisualPointClusteringProcessor {
285
    source: Box<dyn VectorQueryProcessor<VectorType = MultiPointCollection>>,
286
    radius_model: LogScaledRadius,
287
    radius_column: String,
288
    count_column: String,
289
    result_descriptor: VectorResultDescriptor,
290
    attribute_mapping: HashMap<String, AttributeAggregateDef>,
291
    resolution: f64,
292
}
293

294
impl VisualPointClusteringProcessor {
295
    fn new(
5✔
296
        source: Box<dyn VectorQueryProcessor<VectorType = MultiPointCollection>>,
5✔
297
        radius_model: LogScaledRadius,
5✔
298
        radius_column: String,
5✔
299
        count_column: String,
5✔
300
        result_descriptor: VectorResultDescriptor,
5✔
301
        attribute_mapping: HashMap<String, AttributeAggregateDef>,
5✔
302
        resolution: f64,
5✔
303
    ) -> Self {
5✔
304
        Self {
5✔
305
            source,
5✔
306
            radius_model,
5✔
307
            radius_column,
5✔
308
            count_column,
5✔
309
            result_descriptor,
5✔
310
            attribute_mapping,
5✔
311
            resolution,
5✔
312
        }
5✔
313
    }
5✔
314

315
    fn create_point_collection(
5✔
316
        circles_of_points: impl Iterator<Item = CircleOfPoints>,
5✔
317
        radius_column: &str,
5✔
318
        count_column: &str,
5✔
319
        columns: &HashMap<String, FeatureDataType>,
5✔
320
        cache_hint: CacheHint,
5✔
321
        resolution: f64,
5✔
322
    ) -> Result<MultiPointCollection> {
5✔
323
        let mut builder = MultiPointCollection::builder();
5✔
324

325
        for (column, &data_type) in columns {
9✔
326
            builder.add_column(column.clone(), data_type)?;
4✔
327
        }
328

329
        builder.add_column(radius_column.to_string(), FeatureDataType::Float)?;
5✔
330
        builder.add_column(count_column.to_string(), FeatureDataType::Int)?;
5✔
331

332
        let mut builder = builder.finish_header();
5✔
333

334
        for circle_of_points in circles_of_points {
17✔
335
            builder.push_geometry(MultiPoint::new(vec![circle_of_points.circle.center()])?);
12✔
336
            builder.push_time_interval(circle_of_points.time_aggregate);
12✔
337

338
            builder.push_data(
12✔
339
                radius_column,
12✔
340
                FeatureDataValue::Float(circle_of_points.circle.radius() / resolution),
12✔
341
            )?;
×
342
            builder.push_data(
12✔
343
                count_column,
12✔
344
                FeatureDataValue::Int(circle_of_points.number_of_points() as i64),
12✔
345
            )?;
×
346

347
            for (column, data_type) in circle_of_points.attribute_aggregates {
22✔
348
                match data_type {
10✔
349
                    AttributeAggregate::MeanNumber(mean_aggregator) => {
3✔
350
                        builder
3✔
351
                            .push_data(&column, FeatureDataValue::Float(mean_aggregator.mean))?;
3✔
352
                    }
353
                    AttributeAggregate::StringSample(string_sampler) => {
4✔
354
                        builder.push_data(
4✔
355
                            &column,
4✔
356
                            FeatureDataValue::Text(string_sampler.strings.join(", ")),
4✔
357
                        )?;
×
358
                    }
359
                    AttributeAggregate::Null => {
360
                        builder.push_null(&column)?;
3✔
361
                    }
362
                }
363
            }
364

365
            builder.finish_row();
12✔
366
        }
367

368
        builder.cache_hint(cache_hint);
5✔
369

370
        builder.build().map_err(Into::into)
5✔
371
    }
5✔
372

373
    fn aggregate_from_feature_data(
26✔
374
        feature_data: FeatureDataValue,
26✔
375
        aggregate_type: AttributeAggregateType,
26✔
376
    ) -> AttributeAggregate {
26✔
377
        match (feature_data, aggregate_type) {
26✔
378
            (
379
                FeatureDataValue::Category(value) | FeatureDataValue::NullableCategory(Some(value)),
×
380
                AttributeAggregateType::MeanNumber,
381
            ) => AttributeAggregate::MeanNumber(MeanAggregator::from_value(f64::from(value))),
×
382
            (
383
                FeatureDataValue::Float(value) | FeatureDataValue::NullableFloat(Some(value)),
10✔
384
                AttributeAggregateType::MeanNumber,
385
            ) => AttributeAggregate::MeanNumber(MeanAggregator::from_value(value)),
10✔
386
            (
387
                FeatureDataValue::Int(value) | FeatureDataValue::NullableInt(Some(value)),
1✔
388
                AttributeAggregateType::MeanNumber,
389
            ) => AttributeAggregate::MeanNumber(MeanAggregator::from_value(value as f64)),
1✔
390
            (
391
                FeatureDataValue::Text(value) | FeatureDataValue::NullableText(Some(value)),
6✔
392
                AttributeAggregateType::StringSample,
393
            ) => AttributeAggregate::StringSample(StringSampler::from_value(value)),
6✔
394
            (
395
                FeatureDataValue::DateTime(value) | FeatureDataValue::NullableDateTime(Some(value)),
×
396
                AttributeAggregateType::StringSample,
397
            ) => AttributeAggregate::StringSample(StringSampler::from_value(value.to_string())),
×
398
            _ => AttributeAggregate::Null,
9✔
399
        }
400
    }
26✔
401
}
402

403
struct GridFoldState {
404
    grid: Grid<LogScaledRadius>,
405
    column_mapping: HashMap<String, AttributeAggregateDef>,
406
    cache_hint: CacheHint,
407
}
408

409
#[async_trait]
410
impl QueryProcessor for VisualPointClusteringProcessor {
411
    type Output = MultiPointCollection;
412
    type SpatialBounds = BoundingBox2D;
413
    type Selection = ColumnSelection;
414
    type ResultDescription = VectorResultDescriptor;
415

416
    async fn _query<'a>(
417
        &'a self,
418
        query: VectorQueryRectangle,
419
        ctx: &'a dyn QueryContext,
420
    ) -> Result<BoxStream<'a, Result<Self::Output>>> {
5✔
421
        // we aggregate all points into one collection
422

423
        let column_schema = self
424
            .result_descriptor
425
            .columns
426
            .iter()
427
            .map(|(name, column_info)| (name.clone(), column_info.data_type))
4✔
428
            .collect();
429
        let scaled_radius_model = self.radius_model.with_scaled_radii(self.resolution)?;
430

431
        let initial_grid_fold_state = Result::<GridFoldState>::Ok(GridFoldState {
432
            grid: Grid::new(query.spatial_bounds().spatial_bounds(), scaled_radius_model),
433
            column_mapping: self.attribute_mapping.clone(),
434
            cache_hint: CacheHint::max_duration(),
435
        });
436

437
        let grid_future = self.source.query(query.clone(), ctx).await?.fold(
438
            initial_grid_fold_state,
439
            |state, feature_collection| async move {
5✔
440
                // TODO: worker thread
441

442
                let GridFoldState {
443
                    mut grid,
5✔
444
                    column_mapping,
5✔
445
                    mut cache_hint,
5✔
446
                } = state?;
5✔
447

448
                let feature_collection = feature_collection?;
5✔
449

450
                for feature in &feature_collection {
41✔
451
                    // TODO: pre-aggregate multi-points differently?
452
                    for coordinate in feature.geometry.points() {
36✔
453
                        let circle =
36✔
454
                            Circle::from_coordinate(coordinate, grid.radius_model().min_radius());
36✔
455

456
                        let mut attribute_aggregates = HashMap::with_capacity(column_mapping.len());
36✔
457

458
                        for (
459
                            src_column,
26✔
460
                            AttributeAggregateDef {
461
                                column_name: tgt_column,
26✔
462
                                aggregate_type,
26✔
463
                                measurement: _,
464
                            },
465
                        ) in &column_mapping
62✔
466
                        {
467
                            let attribute_aggregate =
26✔
468
                                if let Some(feature_data) = feature.get(src_column) {
26✔
469
                                    Self::aggregate_from_feature_data(feature_data, *aggregate_type)
26✔
470
                                } else {
471
                                    AttributeAggregate::Null
×
472
                                };
473

474
                            attribute_aggregates.insert(tgt_column.clone(), attribute_aggregate);
26✔
475
                        }
476

477
                        grid.insert(CircleOfPoints::new_with_one_point(
36✔
478
                            circle,
36✔
479
                            feature.time_interval,
36✔
480
                            attribute_aggregates,
36✔
481
                        ));
482
                    }
483
                }
484

485
                cache_hint.merge_with(&feature_collection.cache_hint);
5✔
486

487
                Ok(GridFoldState {
5✔
488
                    grid,
5✔
489
                    column_mapping,
5✔
490
                    cache_hint,
5✔
491
                })
5✔
492
            },
10✔
493
        );
494

495
        let stream = FuturesUnordered::new();
496
        stream.push(grid_future);
497

498
        let stream = stream.map(move |grid| {
5✔
499
            let GridFoldState {
500
                grid,
5✔
501
                column_mapping: _,
502
                cache_hint,
5✔
503
            } = grid?;
5✔
504

505
            let mut cmq = CircleMergingQuadtree::new(
5✔
506
                query.spatial_bounds().spatial_bounds(),
5✔
507
                *grid.radius_model(),
5✔
508
                1,
509
            );
510

511
            // TODO: worker thread
512
            for circle_of_points in grid.drain() {
12✔
513
                cmq.insert_circle(circle_of_points);
12✔
514
            }
12✔
515

516
            Self::create_point_collection(
5✔
517
                cmq.into_iter(),
5✔
518
                &self.radius_column,
5✔
519
                &self.count_column,
5✔
520
                &column_schema,
5✔
521
                cache_hint,
5✔
522
                self.resolution,
5✔
523
            )
524
        });
5✔
525

526
        Ok(stream.merge_chunks(ctx.chunk_byte_size().into()).boxed())
527
    }
5✔
528

529
    fn result_descriptor(&self) -> &VectorResultDescriptor {
10✔
530
        &self.result_descriptor
10✔
531
    }
10✔
532
}
533

534
#[cfg(test)]
535
mod tests {
536
    use geoengine_datatypes::collections::ChunksEqualIgnoringCacheHint;
537
    use geoengine_datatypes::primitives::BoundingBox2D;
538
    use geoengine_datatypes::primitives::CacheHint;
539
    use geoengine_datatypes::primitives::FeatureData;
540
    use geoengine_datatypes::primitives::TimeInterval;
541
    use geoengine_datatypes::util::test::TestDefault;
542

543
    use crate::{engine::MockExecutionContext, mock::MockFeatureCollectionSource};
544

545
    use super::*;
546

547
    #[tokio::test]
548
    async fn simple_test() {
1✔
549
        let mut coordinates = vec![(0.0, 0.1); 9];
1✔
550
        coordinates.extend_from_slice(&[(50.0, 50.1)]);
1✔
551

552
        let input = MultiPointCollection::from_data(
1✔
553
            MultiPoint::many(coordinates).unwrap(),
1✔
554
            vec![TimeInterval::default(); 10],
1✔
555
            HashMap::default(),
1✔
556
            CacheHint::default(),
1✔
557
        )
558
        .unwrap();
1✔
559

560
        let operator = VisualPointClustering {
1✔
561
            params: VisualPointClusteringParams {
1✔
562
                min_radius_px: 8.,
1✔
563
                delta_px: 1.,
1✔
564
                resolution: 0.1,
1✔
565
                radius_column: "radius".to_string(),
1✔
566
                count_column: "count".to_string(),
1✔
567
                column_aggregates: Default::default(),
1✔
568
            },
1✔
569
            sources: SingleVectorSource {
1✔
570
                vector: MockFeatureCollectionSource::single(input).boxed(),
1✔
571
            },
1✔
572
        };
1✔
573

574
        let execution_context = MockExecutionContext::test_default();
1✔
575

576
        let initialized_operator = operator
1✔
577
            .boxed()
1✔
578
            .initialize(WorkflowOperatorPath::initialize_root(), &execution_context)
1✔
579
            .await
1✔
580
            .unwrap();
1✔
581

582
        let query_processor = initialized_operator
1✔
583
            .query_processor()
1✔
584
            .unwrap()
1✔
585
            .multi_point()
1✔
586
            .unwrap();
1✔
587

588
        let query_context = execution_context.mock_query_context_test_default();
1✔
589

590
        let qrect = VectorQueryRectangle::new(
1✔
591
            BoundingBox2D::new((-180., -90.).into(), (180., 90.).into()).unwrap(),
1✔
592
            TimeInterval::default(),
1✔
593
            ColumnSelection::all(),
1✔
594
        );
595

596
        let query = query_processor.query(qrect, &query_context).await.unwrap();
1✔
597

598
        let result: Vec<MultiPointCollection> = query.map(Result::unwrap).collect().await;
1✔
599

600
        assert_eq!(result.len(), 1);
1✔
601
        assert!(
1✔
602
            result[0].chunks_equal_ignoring_cache_hint(
1✔
603
                &MultiPointCollection::from_slices(
1✔
604
                    &[(0.0, 0.099_999_999_999_999_99), (50.0, 50.1)],
1✔
605
                    &[TimeInterval::default(); 2],
1✔
606
                    &[
1✔
607
                        ("count", FeatureData::Int(vec![9, 1])),
1✔
608
                        (
1✔
609
                            "radius",
1✔
610
                            FeatureData::Float(vec![10.197_224_577_336_218, 8.])
1✔
611
                        )
1✔
612
                    ],
1✔
613
                )
1✔
614
                .unwrap()
1✔
615
            )
1✔
616
        );
1✔
617
    }
1✔
618

619
    #[tokio::test]
620
    async fn simple_test_with_aggregate() {
1✔
621
        let mut coordinates = vec![(0.0, 0.1); 9];
1✔
622
        coordinates.extend_from_slice(&[(50.0, 50.1)]);
1✔
623

624
        let input = MultiPointCollection::from_slices(
1✔
625
            &MultiPoint::many(coordinates).unwrap(),
1✔
626
            &[TimeInterval::default(); 10],
1✔
627
            &[(
1✔
628
                "foo",
1✔
629
                FeatureData::Float(vec![1., 2., 3., 4., 5., 6., 7., 8., 9., 10.]),
1✔
630
            )],
1✔
631
        )
632
        .unwrap();
1✔
633

634
        let operator = VisualPointClustering {
1✔
635
            params: VisualPointClusteringParams {
1✔
636
                min_radius_px: 8.,
1✔
637
                delta_px: 1.,
1✔
638
                resolution: 0.1,
1✔
639
                radius_column: "radius".to_string(),
1✔
640
                count_column: "count".to_string(),
1✔
641
                column_aggregates: [(
1✔
642
                    "foo".to_string(),
1✔
643
                    AttributeAggregateDef {
1✔
644
                        column_name: "bar".to_string(),
1✔
645
                        aggregate_type: AttributeAggregateType::MeanNumber,
1✔
646
                        measurement: None,
1✔
647
                    },
1✔
648
                )]
1✔
649
                .iter()
1✔
650
                .cloned()
1✔
651
                .collect(),
1✔
652
            },
1✔
653
            sources: SingleVectorSource {
1✔
654
                vector: MockFeatureCollectionSource::single(input).boxed(),
1✔
655
            },
1✔
656
        };
1✔
657

658
        let execution_context = MockExecutionContext::test_default();
1✔
659

660
        let initialized_operator = operator
1✔
661
            .boxed()
1✔
662
            .initialize(WorkflowOperatorPath::initialize_root(), &execution_context)
1✔
663
            .await
1✔
664
            .unwrap();
1✔
665

666
        let query_processor = initialized_operator
1✔
667
            .query_processor()
1✔
668
            .unwrap()
1✔
669
            .multi_point()
1✔
670
            .unwrap();
1✔
671

672
        let query_context = execution_context.mock_query_context_test_default();
1✔
673

674
        let qrect = VectorQueryRectangle::new(
1✔
675
            BoundingBox2D::new((-180., -90.).into(), (180., 90.).into()).unwrap(),
1✔
676
            TimeInterval::default(),
1✔
677
            ColumnSelection::all(),
1✔
678
        );
679

680
        let query = query_processor.query(qrect, &query_context).await.unwrap();
1✔
681

682
        let result: Vec<MultiPointCollection> = query.map(Result::unwrap).collect().await;
1✔
683

684
        assert_eq!(result.len(), 1);
1✔
685
        assert!(
1✔
686
            result[0].chunks_equal_ignoring_cache_hint(
1✔
687
                &MultiPointCollection::from_slices(
1✔
688
                    &[(0.0, 0.099_999_999_999_999_99), (50.0, 50.1)],
1✔
689
                    &[TimeInterval::default(); 2],
1✔
690
                    &[
1✔
691
                        ("count", FeatureData::Int(vec![9, 1])),
1✔
692
                        (
1✔
693
                            "radius",
1✔
694
                            FeatureData::Float(vec![10.197_224_577_336_218, 8.])
1✔
695
                        ),
1✔
696
                        ("bar", FeatureData::Float(vec![5., 10.]))
1✔
697
                    ],
1✔
698
                )
1✔
699
                .unwrap()
1✔
700
            )
1✔
701
        );
1✔
702
    }
1✔
703

704
    #[tokio::test]
705
    async fn aggregate_of_null() {
1✔
706
        let mut coordinates = vec![(0.0, 0.1); 2];
1✔
707
        coordinates.extend_from_slice(&[(50.0, 50.1); 2]);
1✔
708

709
        let input = MultiPointCollection::from_slices(
1✔
710
            &MultiPoint::many(coordinates).unwrap(),
1✔
711
            &[TimeInterval::default(); 4],
1✔
712
            &[(
1✔
713
                "foo",
1✔
714
                FeatureData::NullableInt(vec![Some(1), None, None, None]),
1✔
715
            )],
1✔
716
        )
717
        .unwrap();
1✔
718

719
        let operator = VisualPointClustering {
1✔
720
            params: VisualPointClusteringParams {
1✔
721
                min_radius_px: 8.,
1✔
722
                delta_px: 1.,
1✔
723
                resolution: 0.1,
1✔
724
                radius_column: "radius".to_string(),
1✔
725
                count_column: "count".to_string(),
1✔
726
                column_aggregates: [(
1✔
727
                    "foo".to_string(),
1✔
728
                    AttributeAggregateDef {
1✔
729
                        column_name: "foo".to_string(),
1✔
730
                        aggregate_type: AttributeAggregateType::MeanNumber,
1✔
731
                        measurement: None,
1✔
732
                    },
1✔
733
                )]
1✔
734
                .iter()
1✔
735
                .cloned()
1✔
736
                .collect(),
1✔
737
            },
1✔
738
            sources: SingleVectorSource {
1✔
739
                vector: MockFeatureCollectionSource::single(input).boxed(),
1✔
740
            },
1✔
741
        };
1✔
742

743
        let execution_context = MockExecutionContext::test_default();
1✔
744

745
        let initialized_operator = operator
1✔
746
            .boxed()
1✔
747
            .initialize(WorkflowOperatorPath::initialize_root(), &execution_context)
1✔
748
            .await
1✔
749
            .unwrap();
1✔
750

751
        let query_processor = initialized_operator
1✔
752
            .query_processor()
1✔
753
            .unwrap()
1✔
754
            .multi_point()
1✔
755
            .unwrap();
1✔
756

757
        let query_context = execution_context.mock_query_context_test_default();
1✔
758

759
        let qrect = VectorQueryRectangle::new(
1✔
760
            BoundingBox2D::new((-180., -90.).into(), (180., 90.).into()).unwrap(),
1✔
761
            TimeInterval::default(),
1✔
762
            ColumnSelection::all(),
1✔
763
        );
764

765
        let query = query_processor.query(qrect, &query_context).await.unwrap();
1✔
766

767
        let result: Vec<MultiPointCollection> = query.map(Result::unwrap).collect().await;
1✔
768

769
        assert_eq!(result.len(), 1);
1✔
770
        assert!(
1✔
771
            result[0].chunks_equal_ignoring_cache_hint(
1✔
772
                &MultiPointCollection::from_slices(
1✔
773
                    &[(0.0, 0.1), (50.0, 50.1)],
1✔
774
                    &[TimeInterval::default(); 2],
1✔
775
                    &[
1✔
776
                        ("count", FeatureData::Int(vec![2, 2])),
1✔
777
                        (
1✔
778
                            "radius",
1✔
779
                            FeatureData::Float(vec![8.693_147_180_559_945, 8.693_147_180_559_945])
1✔
780
                        ),
1✔
781
                        ("foo", FeatureData::NullableFloat(vec![Some(1.), None]))
1✔
782
                    ],
1✔
783
                )
1✔
784
                .unwrap()
1✔
785
            )
1✔
786
        );
1✔
787
    }
1✔
788

789
    #[tokio::test]
790
    async fn text_aggregate() {
1✔
791
        let mut coordinates = vec![(0.0, 0.1); 2];
1✔
792
        coordinates.extend_from_slice(&[(50.0, 50.1); 2]);
1✔
793
        coordinates.extend_from_slice(&[(25.0, 25.1); 2]);
1✔
794

795
        let input = MultiPointCollection::from_slices(
1✔
796
            &MultiPoint::many(coordinates).unwrap(),
1✔
797
            &[TimeInterval::default(); 6],
1✔
798
            &[(
1✔
799
                "text",
1✔
800
                FeatureData::NullableText(vec![
1✔
801
                    Some("foo".to_string()),
1✔
802
                    Some("bar".to_string()),
1✔
803
                    Some("foo".to_string()),
1✔
804
                    None,
1✔
805
                    None,
1✔
806
                    None,
1✔
807
                ]),
1✔
808
            )],
1✔
809
        )
810
        .unwrap();
1✔
811

812
        let operator = VisualPointClustering {
1✔
813
            params: VisualPointClusteringParams {
1✔
814
                min_radius_px: 8.,
1✔
815
                delta_px: 1.,
1✔
816
                resolution: 0.1,
1✔
817
                radius_column: "radius".to_string(),
1✔
818
                count_column: "count".to_string(),
1✔
819
                column_aggregates: [(
1✔
820
                    "text".to_string(),
1✔
821
                    AttributeAggregateDef {
1✔
822
                        column_name: "text".to_string(),
1✔
823
                        aggregate_type: AttributeAggregateType::StringSample,
1✔
824
                        measurement: None,
1✔
825
                    },
1✔
826
                )]
1✔
827
                .iter()
1✔
828
                .cloned()
1✔
829
                .collect(),
1✔
830
            },
1✔
831
            sources: SingleVectorSource {
1✔
832
                vector: MockFeatureCollectionSource::single(input).boxed(),
1✔
833
            },
1✔
834
        };
1✔
835

836
        let execution_context = MockExecutionContext::test_default();
1✔
837

838
        let initialized_operator = operator
1✔
839
            .boxed()
1✔
840
            .initialize(WorkflowOperatorPath::initialize_root(), &execution_context)
1✔
841
            .await
1✔
842
            .unwrap();
1✔
843

844
        let query_processor = initialized_operator
1✔
845
            .query_processor()
1✔
846
            .unwrap()
1✔
847
            .multi_point()
1✔
848
            .unwrap();
1✔
849

850
        let query_context = execution_context.mock_query_context_test_default();
1✔
851

852
        let qrect = VectorQueryRectangle::new(
1✔
853
            BoundingBox2D::new((-180., -90.).into(), (180., 90.).into()).unwrap(),
1✔
854
            TimeInterval::default(),
1✔
855
            ColumnSelection::all(),
1✔
856
        );
857

858
        let query = query_processor.query(qrect, &query_context).await.unwrap();
1✔
859

860
        let result: Vec<MultiPointCollection> = query.map(Result::unwrap).collect().await;
1✔
861

862
        assert_eq!(result.len(), 1);
1✔
863
        assert!(
1✔
864
            result[0].chunks_equal_ignoring_cache_hint(
1✔
865
                &MultiPointCollection::from_slices(
1✔
866
                    &[(0.0, 0.1), (50.0, 50.1), (25.0, 25.1)],
1✔
867
                    &[TimeInterval::default(); 3],
1✔
868
                    &[
1✔
869
                        ("count", FeatureData::Int(vec![2, 2, 2])),
1✔
870
                        (
1✔
871
                            "radius",
1✔
872
                            FeatureData::Float(vec![
1✔
873
                                8.693_147_180_559_945,
1✔
874
                                8.693_147_180_559_945,
1✔
875
                                8.693_147_180_559_945
1✔
876
                            ])
1✔
877
                        ),
1✔
878
                        (
1✔
879
                            "text",
1✔
880
                            FeatureData::NullableText(vec![
1✔
881
                                Some("foo, bar".to_string()),
1✔
882
                                Some("foo".to_string()),
1✔
883
                                None
1✔
884
                            ])
1✔
885
                        )
1✔
886
                    ],
1✔
887
                )
1✔
888
                .unwrap()
1✔
889
            )
1✔
890
        );
1✔
891
    }
1✔
892

893
    #[tokio::test]
894
    async fn it_attaches_cache_hint() {
1✔
895
        let mut coordinates = vec![(0.0, 0.1); 2];
1✔
896
        coordinates.extend_from_slice(&[(50.0, 50.1); 2]);
1✔
897
        coordinates.extend_from_slice(&[(25.0, 25.1); 2]);
1✔
898

899
        let mut input = MultiPointCollection::from_slices(
1✔
900
            &MultiPoint::many(coordinates).unwrap(),
1✔
901
            &[TimeInterval::default(); 6],
1✔
902
            &[(
1✔
903
                "text",
1✔
904
                FeatureData::NullableText(vec![
1✔
905
                    Some("foo".to_string()),
1✔
906
                    Some("bar".to_string()),
1✔
907
                    Some("foo".to_string()),
1✔
908
                    None,
1✔
909
                    None,
1✔
910
                    None,
1✔
911
                ]),
1✔
912
            )],
1✔
913
        )
914
        .unwrap();
1✔
915

916
        let cache_hint = CacheHint::seconds(1234);
1✔
917

918
        input.cache_hint = cache_hint;
1✔
919

920
        let operator = VisualPointClustering {
1✔
921
            params: VisualPointClusteringParams {
1✔
922
                min_radius_px: 8.,
1✔
923
                delta_px: 1.,
1✔
924
                resolution: 0.1,
1✔
925
                radius_column: "radius".to_string(),
1✔
926
                count_column: "count".to_string(),
1✔
927
                column_aggregates: [(
1✔
928
                    "text".to_string(),
1✔
929
                    AttributeAggregateDef {
1✔
930
                        column_name: "text".to_string(),
1✔
931
                        aggregate_type: AttributeAggregateType::StringSample,
1✔
932
                        measurement: None,
1✔
933
                    },
1✔
934
                )]
1✔
935
                .iter()
1✔
936
                .cloned()
1✔
937
                .collect(),
1✔
938
            },
1✔
939
            sources: SingleVectorSource {
1✔
940
                vector: MockFeatureCollectionSource::single(input).boxed(),
1✔
941
            },
1✔
942
        };
1✔
943

944
        let execution_context = MockExecutionContext::test_default();
1✔
945

946
        let initialized_operator = operator
1✔
947
            .boxed()
1✔
948
            .initialize(WorkflowOperatorPath::initialize_root(), &execution_context)
1✔
949
            .await
1✔
950
            .unwrap();
1✔
951

952
        let query_processor = initialized_operator
1✔
953
            .query_processor()
1✔
954
            .unwrap()
1✔
955
            .multi_point()
1✔
956
            .unwrap();
1✔
957

958
        let query_context = execution_context.mock_query_context_test_default();
1✔
959

960
        let qrect = VectorQueryRectangle::new(
1✔
961
            BoundingBox2D::new((-180., -90.).into(), (180., 90.).into()).unwrap(),
1✔
962
            TimeInterval::default(),
1✔
963
            ColumnSelection::all(),
1✔
964
        );
965

966
        let query = query_processor.query(qrect, &query_context).await.unwrap();
1✔
967

968
        let result: Vec<MultiPointCollection> = query.map(Result::unwrap).collect().await;
1✔
969

970
        assert_eq!(result.len(), 1);
1✔
971
        assert_eq!(result[0].cache_hint.expires(), cache_hint.expires());
1✔
972
    }
1✔
973
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc