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

geo-engine / geoengine / 23058781799

13 Mar 2026 03:48PM UTC coverage: 88.252% (+0.1%) from 88.133%
23058781799

push

github

web-flow
feat: add histogram and statistics plot operators to openapi.json (#1130)

* feat: add histogram and statistics plot operators to openapi.json

* feat: implement api_operator macro for generating API specifications

* fix: add SingleRasterOrVector operator and source schemas to OpenAPI

* refactor: clean up unused schemas and improve justfile commands

* fix: expose RasterDatasetFromWorkflowResult for testing

* refactor: update Workflow enum and improve ToSchema implementation

* docs: enhance documentation for api_operator macro and string_token macro

399 of 436 new or added lines in 9 files covered. (91.51%)

13 existing lines in 2 files now uncovered.

113256 of 128333 relevant lines covered (88.25%)

504640.83 hits per line

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

84.95
/services/src/api/model/processing_graphs/plots.rs
1
use crate::{
2
    api::model::processing_graphs::source_parameters::{
3
        MultipleRasterOrSingleVectorSource, SingleRasterOrVectorSource,
4
    },
5
    string_token,
6
};
7
use geoengine_macros::{api_operator, type_tag};
8
use ordered_float::NotNan;
9
use serde::{Deserialize, Serialize};
10
use utoipa::ToSchema;
11

12
/// The `Histogram` is a _plot operator_ that computes a histogram plot either over attributes of a vector dataset or values of a raster source.
13
/// The output is a plot in [Vega-Lite](https://vega.github.io/vega-lite/) specification.
14
///
15
/// For instance, you want to plot the data distribution of numeric attributes of a feature collection.
16
/// Then you can use a histogram with a suitable number of buckets to visualize and assess this.
17
///
18
/// ## Errors
19
///
20
/// The operator returns an error if the selected column (`columnName`) does not exist or is not numeric.
21
///
22
/// ## Notes
23
///
24
/// If `bounds` or `buckets` are not defined, the operator will determine these values by itself which requires processing the data twice.
25
///
26
/// If the `buckets` parameter is set to `squareRootChoiceRule`, the operator estimates it using the square root of the number of elements in the data.
27
///
28
#[api_operator(examples(json!({
30✔
29
    "type": "Histogram",
30
    "params": {
31
        "columnName": "foobar",
32
        "bounds": {
33
            "min": 5.0,
34
            "max": 10.0
35
        },
36
        "buckets": {
37
            "type": "number",
38
            "value": 15
39
        },
40
        "interactive": false
41
    },
42
    "sources": {
43
        "vector": {
44
            "type": "OgrSource",
45
            "params": {
46
                "data": "ndvi"
47
            }
48
        }
49
    }
50
})))]
51
pub struct Histogram {
52
    pub params: HistogramParameters,
53
    pub sources: SingleRasterOrVectorSource,
54
}
55

56
/// The parameter spec for `Histogram`
57
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
58
#[serde(rename_all = "camelCase")]
59
pub struct HistogramParameters {
60
    /// Name of the (numeric) vector attribute or raster band to compute the histogram on.
61
    #[schema(examples("temperature"))]
62
    pub attribute_name: String,
63
    /// If `data`, it computes the bounds of the underlying data. If `values`, one can specify custom bounds.
64
    #[schema(examples(json!({ "min": 0.0, "max": 20.0 }), "data"))]
65
    pub bounds: HistogramBounds,
66
    /// The number of buckets. The value can be specified or calculated.
67
    #[schema(examples(json!({ "type": "number", "value": 20 })))]
68
    pub buckets: HistogramBuckets,
69
    /// Flag, if the histogram should have user interactions for a range selection. It is `false` by default.
70
    #[serde(default)]
71
    #[schema(examples(true))]
72
    pub interactive: bool,
73
}
74

75
string_token!(Data, "data");
76

77
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
78
#[serde(rename_all = "camelCase")]
79
pub enum HistogramBounds {
80
    Data(Data),
81
    Values {
82
        #[schema(value_type = f64)]
83
        min: NotNan<f64>,
84
        #[schema(value_type = f64)]
85
        max: NotNan<f64>,
86
    },
87
}
88

NEW
89
fn default_max_number_of_buckets() -> u8 {
×
NEW
90
    20
×
NEW
91
}
×
92

93
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
94
#[serde(rename_all = "camelCase", tag = "type")]
95
pub enum HistogramBuckets {
96
    #[serde(rename_all = "camelCase")]
97
    Number { value: u8 },
98
    #[serde(rename_all = "camelCase")]
99
    SquareRootChoiceRule {
100
        #[serde(default = "default_max_number_of_buckets")]
101
        max_number_of_buckets: u8,
102
    },
103
}
104

105
impl TryFrom<Histogram> for geoengine_operators::plot::Histogram {
106
    type Error = anyhow::Error;
107
    fn try_from(value: Histogram) -> Result<Self, Self::Error> {
1✔
108
        let params = geoengine_operators::plot::HistogramParams {
1✔
109
            attribute_name: value.params.attribute_name,
1✔
110
            bounds: match value.params.bounds {
1✔
111
                HistogramBounds::Data(_) => {
112
                    geoengine_operators::plot::HistogramBounds::Data(Default::default())
1✔
113
                }
NEW
114
                HistogramBounds::Values { min, max } => {
×
NEW
115
                    geoengine_operators::plot::HistogramBounds::Values {
×
NEW
116
                        min: *min,
×
NEW
117
                        max: *max,
×
NEW
118
                    }
×
119
                }
120
            },
121
            buckets: match value.params.buckets {
1✔
122
                HistogramBuckets::Number { value } => {
1✔
123
                    geoengine_operators::plot::HistogramBuckets::Number { value }
1✔
124
                }
125
                HistogramBuckets::SquareRootChoiceRule {
NEW
126
                    max_number_of_buckets,
×
NEW
127
                } => geoengine_operators::plot::HistogramBuckets::SquareRootChoiceRule {
×
NEW
128
                    max_number_of_buckets,
×
NEW
129
                },
×
130
            },
131
            interactive: value.params.interactive,
1✔
132
        };
133
        let sources = value.sources.try_into()?;
1✔
134
        Ok(Self { params, sources })
1✔
135
    }
1✔
136
}
137

138
/// The `Statistics` operator is a _plot operator_ that computes count statistics over
139
///
140
/// - a selection of numerical columns of a single vector dataset, or
141
/// - multiple raster datasets.
142
///
143
/// The output is a JSON description.
144
///
145
/// For instance, you want to get an overview of a raster data source.
146
/// Then, you can use this operator to get basic count statistics.
147
///
148
/// ## Vector Data
149
///
150
/// In the case of vector data, the operator generates one statistic for each of the selected numerical attributes.
151
/// The operator returns an error if one of the selected attributes is not numeric.
152
///
153
/// ## Raster Data
154
///
155
/// For raster data, the operator generates one statistic for each input raster.
156
///
157
/// ## Inputs
158
///
159
/// The operator consumes exactly one _vector_ or multiple _raster_ operators.
160
///
161
/// | Parameter | Type                                 |
162
/// | --------- | ------------------------------------ |
163
/// | `source`  | `MultipleRasterOrSingleVectorSource` |
164
///
165
/// ## Errors
166
///
167
/// The operator returns an error in the following cases.
168
///
169
/// - Vector data: The `attribute` for one of the given `columnNames` is not numeric.
170
/// - Vector data: The `attribute` for one of the given `columnNames` does not exist.
171
/// - Raster data: The length of the `columnNames` parameter does not match the number of input rasters.
172
///
173
/// ### Example Output
174
///
175
/// ```json
176
/// {
177
///   "A": {
178
///     "valueCount": 6,
179
///     "validCount": 6,
180
///     "min": 1.0,
181
///     "max": 6.0,
182
///     "mean": 3.5,
183
///     "stddev": 1.707,
184
///     "percentiles": [
185
///       {
186
///         "percentile": 0.25,
187
///         "value": 2.0
188
///       },
189
///       {
190
///         "percentile": 0.5,
191
///         "value": 3.5
192
///       },
193
///       {
194
///         "percentile": 0.75,
195
///         "value": 5.0
196
///       }
197
///     ]
198
///   }
199
/// }
200
/// ```
201
///
202
#[api_operator(examples(json!({
30✔
203
    "type": "Statistics",
204
    "params": {
205
        "columnNames": ["A"],
206
        "percentiles": [0.25, 0.5, 0.75]
207
    },
208
    "sources": {
209
        "source": [{
210
            "type": "GdalSource",
211
            "params": {
212
            "data": "ndvi"
213
            }
214
        }]
215
    }
216
})))]
217
pub struct Statistics {
218
    pub params: StatisticsParameters,
219
    pub sources: MultipleRasterOrSingleVectorSource,
220
}
221

222
/// The parameter spec for `Statistics`
223
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
224
#[serde(rename_all = "camelCase")]
225
pub struct StatisticsParameters {
226
    /// # Vector data
227
    /// The names of the attributes to generate statistics for.
228
    ///
229
    /// # Raster data
230
    /// _Optional_: An alias for each input source.
231
    /// The operator will automatically name the rasters `Raster-1`, `Raster-2`, … if this parameter is empty.
232
    /// If aliases are given, the number of aliases must match the number of input rasters.
233
    /// Otherwise an error is returned.
234
    #[schema(examples(json!(["x", "y"])))]
235
    #[serde(default)]
236
    pub column_names: Vec<String>,
237
    /// The percentiles to compute for each attribute.
238
    #[serde(default)]
239
    #[schema(value_type = Vec<f64>, examples(json!([0.25, 0.5, 0.75])))]
240
    pub percentiles: Vec<NotNan<f64>>,
241
}
242

243
impl TryFrom<Statistics> for geoengine_operators::plot::Statistics {
244
    type Error = anyhow::Error;
245
    fn try_from(value: Statistics) -> Result<Self, Self::Error> {
2✔
246
        let params = geoengine_operators::plot::StatisticsParams {
2✔
247
            column_names: value.params.column_names,
2✔
248
            percentiles: value.params.percentiles.clone(),
2✔
249
        };
2✔
250
        let sources = value.sources.try_into()?;
2✔
251
        Ok(Self { params, sources })
2✔
252
    }
2✔
253
}
254

255
#[cfg(test)]
256
mod tests {
257
    use super::*;
258
    use crate::api::model::processing_graphs::parameters::SpatialBoundsDerive;
259
    use crate::api::model::processing_graphs::source::{
260
        MockPointSource, MockPointSourceParameters,
261
    };
262
    use crate::api::model::processing_graphs::source_parameters::{
263
        MultipleRasterOrSingleVectorOperator, MultipleRasterOrSingleVectorSource,
264
        SingleRasterOrVectorOperator, SingleRasterOrVectorSource,
265
    };
266
    use crate::api::model::{datatypes::Coordinate2D, processing_graphs::VectorOperator};
267
    use ordered_float::NotNan;
268

269
    #[test]
270
    fn it_converts_statistics_operators() {
1✔
271
        let stats = Statistics {
1✔
272
            r#type: Default::default(),
1✔
273
            params: StatisticsParameters {
1✔
274
                column_names: vec!["A".to_string()],
1✔
275
                percentiles: vec![NotNan::new(0.5).unwrap()],
1✔
276
            },
1✔
277
            sources: MultipleRasterOrSingleVectorSource {
1✔
278
                source: MultipleRasterOrSingleVectorOperator::Vector(
1✔
279
                    VectorOperator::MockPointSource(MockPointSource {
1✔
280
                        r#type: Default::default(),
1✔
281
                        params: MockPointSourceParameters {
1✔
282
                            points: vec![Coordinate2D { x: 1.0, y: 2.0 }],
1✔
283
                            spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
284
                        },
1✔
285
                    }),
1✔
286
                ),
1✔
287
            },
1✔
288
        };
1✔
289

290
        let operators: geoengine_operators::plot::Statistics =
1✔
291
            stats.try_into().expect("conversion failed");
1✔
292

293
        assert_eq!(operators.params.column_names, vec!["A".to_string()]);
1✔
294
        assert_eq!(operators.params.percentiles.len(), 1);
1✔
295
        assert_eq!(
1✔
296
            operators.params.percentiles[0].clone(),
1✔
297
            NotNan::new(0.5).unwrap()
1✔
298
        );
299
    }
1✔
300

301
    #[test]
302
    fn it_converts_histogram_operators() {
1✔
303
        let hist = Histogram {
1✔
304
            r#type: Default::default(),
1✔
305
            params: HistogramParameters {
1✔
306
                attribute_name: "temperature".to_string(),
1✔
307
                bounds: HistogramBounds::Data(Data),
1✔
308
                buckets: HistogramBuckets::Number { value: 10 },
1✔
309
                interactive: false,
1✔
310
            },
1✔
311
            sources: SingleRasterOrVectorSource {
1✔
312
                source: SingleRasterOrVectorOperator::Vector(VectorOperator::MockPointSource(
1✔
313
                    MockPointSource {
1✔
314
                        r#type: Default::default(),
1✔
315
                        params: MockPointSourceParameters {
1✔
316
                            points: vec![Coordinate2D { x: 1.0, y: 2.0 }],
1✔
317
                            spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
318
                        },
1✔
319
                    },
1✔
320
                )),
1✔
321
            },
1✔
322
        };
1✔
323

324
        let operators: geoengine_operators::plot::Histogram =
1✔
325
            hist.try_into().expect("conversion failed");
1✔
326

327
        assert_eq!(operators.params.attribute_name, "temperature");
1✔
328
        if let geoengine_operators::plot::HistogramBounds::Data(_) = &operators.params.bounds {
1✔
329
        } else {
1✔
NEW
330
            panic!("expected Data bounds");
×
331
        }
332

333
        if let geoengine_operators::plot::HistogramBuckets::Number { value } =
1✔
334
            &operators.params.buckets
1✔
335
        {
336
            assert_eq!(*value, 10);
1✔
337
        } else {
NEW
338
            panic!("expected Number buckets");
×
339
        }
340
    }
1✔
341
}
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