Skip to main content

oxigdal_gpkg/
coverage.rs

1//! Tiled Gridded Coverage support for GeoPackage (OGC GeoPackage Extension §F.7).
2//!
3//! Implements reading and decoding of the two extension tables:
4//! - `gpkg_2d_gridded_coverage_ancillary` — per-table coverage metadata
5//! - `gpkg_2d_gridded_tile_ancillary` — per-tile scale/offset/statistics
6//!
7//! These tables are present only when the
8//! `gpkg_2d_gridded_coverage` extension is in use (elevation/DEM data).  All
9//! functions return `Ok(vec![])` or `Ok(…)` gracefully when the optional tables
10//! are absent.
11
12use std::str::FromStr;
13
14use crate::error::GpkgError;
15use crate::gpkg::{GeoPackage, cell_to_i64};
16
17// ─────────────────────────────────────────────────────────────────────────────
18// CoverageDatatype
19// ─────────────────────────────────────────────────────────────────────────────
20
21/// The data type stored in each tile of a gridded coverage.
22///
23/// Corresponds to the `datatype` column of `gpkg_2d_gridded_coverage_ancillary`.
24#[derive(Debug, Clone, PartialEq)]
25pub enum CoverageDatatype {
26    /// 16-bit (or wider) integer samples — typical for elevation data.
27    Integer,
28    /// 32-bit IEEE-754 float samples.
29    Float,
30}
31
32impl CoverageDatatype {
33    /// Return the canonical string stored in `gpkg_2d_gridded_coverage_ancillary`.
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            Self::Integer => "integer",
37            Self::Float => "float",
38        }
39    }
40}
41
42impl FromStr for CoverageDatatype {
43    type Err = GpkgError;
44
45    /// Parse the `datatype` column string from `gpkg_2d_gridded_coverage_ancillary`.
46    ///
47    /// # Errors
48    /// Returns [`GpkgError::InvalidCoverageDatatype`] for any unrecognised string.
49    fn from_str(s: &str) -> Result<Self, Self::Err> {
50        match s {
51            "integer" => Ok(Self::Integer),
52            "float" => Ok(Self::Float),
53            other => Err(GpkgError::InvalidCoverageDatatype(other.to_string())),
54        }
55    }
56}
57
58// ─────────────────────────────────────────────────────────────────────────────
59// GridCellEncoding
60// ─────────────────────────────────────────────────────────────────────────────
61
62/// Interpretation of how each grid sample relates to the cell boundary.
63///
64/// Corresponds to the `grid_cell_encoding` column of
65/// `gpkg_2d_gridded_coverage_ancillary` (OGC §F.7).
66#[derive(Debug, Clone, PartialEq)]
67pub enum GridCellEncoding {
68    /// `"grid-value-is-center"` — the value represents the cell centre.
69    Grid,
70    /// `"grid-value-is-area"` — the value is an average over the entire cell.
71    PixelIsArea,
72    /// `"grid-value-is-corner"` — the value is located at the cell corner.
73    PixelIsPoint,
74}
75
76impl GridCellEncoding {
77    /// Return the canonical encoding string.
78    pub fn as_str(&self) -> &'static str {
79        match self {
80            Self::Grid => "grid-value-is-center",
81            Self::PixelIsArea => "grid-value-is-area",
82            Self::PixelIsPoint => "grid-value-is-corner",
83        }
84    }
85}
86
87impl FromStr for GridCellEncoding {
88    /// The parse is infallible: unrecognised strings map to the default
89    /// ([`GridCellEncoding::Grid`]) per OGC §F.7.
90    type Err = std::convert::Infallible;
91
92    /// Parse the `grid_cell_encoding` column string.
93    ///
94    /// Per OGC §F.7 the encoding is lenient: any unrecognised string maps to
95    /// [`GridCellEncoding::Grid`] (the most common default).
96    fn from_str(s: &str) -> Result<Self, Self::Err> {
97        Ok(match s {
98            "grid-value-is-center" => Self::Grid,
99            "grid-value-is-area" => Self::PixelIsArea,
100            "grid-value-is-corner" => Self::PixelIsPoint,
101            // Lenient: unknown strings default to Grid per §F.7 note.
102            _ => Self::Grid,
103        })
104    }
105}
106
107// ─────────────────────────────────────────────────────────────────────────────
108// GriddedCoverage
109// ─────────────────────────────────────────────────────────────────────────────
110
111/// A parsed row from `gpkg_2d_gridded_coverage_ancillary`.
112///
113/// Column layout (0-indexed):
114///
115/// | # | Column                 | SQLite type |
116/// |---|------------------------|-------------|
117/// | 0 | `id`                   | INTEGER PK  |
118/// | 1 | `tile_matrix_set_name` | TEXT        |
119/// | 2 | `datatype`             | TEXT        |
120/// | 3 | `scale`                | REAL        |
121/// | 4 | `offset`               | REAL        |
122/// | 5 | `precision`            | REAL        |
123/// | 6 | `data_null`            | REAL NULL   |
124/// | 7 | `grid_cell_encoding`   | TEXT        |
125/// | 8 | `uom`                  | TEXT NULL   |
126/// | 9 | `field_name`           | TEXT        |
127/// |10 | `quantity_definition`  | TEXT NULL   |
128#[derive(Debug, Clone)]
129pub struct GriddedCoverage {
130    /// Name of the tile matrix set / user data table this row describes.
131    pub table_name: String,
132    /// Sample data type (`integer` or `float`).
133    pub datatype: CoverageDatatype,
134    /// Scale factor applied to raw integer values: `phys = raw * scale + offset`.
135    pub scale: f64,
136    /// Offset added after scaling: `phys = raw * scale + offset`.
137    pub offset: f64,
138    /// Minimum meaningful difference between adjacent physical values.
139    pub precision: f64,
140    /// Raw value that represents a missing/void sample; `None` if not specified.
141    pub data_null: Option<f64>,
142    /// Interpretation of the grid cell sample position.
143    pub grid_cell_encoding: GridCellEncoding,
144    /// Unit of measure (e.g. `"metre"`), if present.
145    pub uom: Option<String>,
146    /// Short name identifying the measured field (e.g. `"Height"`).
147    pub field_name: String,
148    /// Human-readable description of the physical quantity, if present.
149    pub quantity_definition: Option<String>,
150}
151
152// ─────────────────────────────────────────────────────────────────────────────
153// TileGriddedAncillary
154// ─────────────────────────────────────────────────────────────────────────────
155
156/// A parsed row from `gpkg_2d_gridded_tile_ancillary`.
157///
158/// Column layout (0-indexed):
159///
160/// | # | Column       | SQLite type |
161/// |---|--------------|-------------|
162/// | 0 | `id`         | INTEGER PK  |
163/// | 1 | `tpudt_name` | TEXT        |
164/// | 2 | `tpudt_id`   | INTEGER     |
165/// | 3 | `scale`      | REAL        |
166/// | 4 | `offset`     | REAL        |
167/// | 5 | `min`        | REAL NULL   |
168/// | 6 | `max`        | REAL NULL   |
169/// | 7 | `mean`       | REAL NULL   |
170/// | 8 | `std_dev`    | REAL NULL   |
171///
172/// `tpudt_name` is the user tile pyramid table name; `tpudt_id` is the `id`
173/// of the corresponding row in that tile table.
174#[derive(Debug, Clone)]
175pub struct TileGriddedAncillary {
176    /// Primary key of this row.
177    pub id: i64,
178    /// Foreign key into the tile pyramid table (`id` column of that table).
179    pub tpudt_id: i64,
180    /// Per-tile scale override.  Typically `1.0`.
181    pub scale: f64,
182    /// Per-tile offset override.  Typically `0.0`.
183    pub offset: f64,
184    /// Minimum physical value in this tile (optional).
185    pub min: Option<f64>,
186    /// Maximum physical value in this tile (optional).
187    pub max: Option<f64>,
188    /// Mean physical value in this tile (optional).
189    pub mean: Option<f64>,
190    /// Standard deviation of physical values in this tile (optional).
191    pub std_dev: Option<f64>,
192}
193
194// ─────────────────────────────────────────────────────────────────────────────
195// Cell-value coercions (module-local helpers)
196// ─────────────────────────────────────────────────────────────────────────────
197
198use crate::btree::CellValue;
199
200/// Coerce a [`CellValue`] to `f64`, returning `0.0` for non-numeric types.
201fn cell_to_f64(v: &CellValue) -> f64 {
202    match v {
203        CellValue::Float(f) => *f,
204        CellValue::Integer(i) => *i as f64,
205        _ => 0.0,
206    }
207}
208
209/// Coerce a [`CellValue`] to `Option<f64>`, returning `None` for SQL NULL.
210fn cell_to_optional_f64(v: &CellValue) -> Option<f64> {
211    match v {
212        CellValue::Null => None,
213        CellValue::Float(f) => Some(*f),
214        CellValue::Integer(i) => Some(*i as f64),
215        _ => None,
216    }
217}
218
219/// Coerce a [`CellValue`] to a `String`, returning an empty string for NULL.
220fn cell_to_string(v: &CellValue) -> String {
221    match v {
222        CellValue::Text(s) => s.clone(),
223        CellValue::Integer(i) => i.to_string(),
224        CellValue::Float(f) => f.to_string(),
225        CellValue::Blob(b) => String::from_utf8_lossy(b).into_owned(),
226        CellValue::Null => String::new(),
227    }
228}
229
230/// Coerce a [`CellValue`] to `Option<String>`, returning `None` for SQL NULL.
231fn cell_to_optional_string(v: &CellValue) -> Option<String> {
232    match v {
233        CellValue::Null => None,
234        CellValue::Text(s) if s.is_empty() => None,
235        other => Some(cell_to_string(other)),
236    }
237}
238
239// ─────────────────────────────────────────────────────────────────────────────
240// load_gridded_coverages
241// ─────────────────────────────────────────────────────────────────────────────
242
243/// Read all rows from `gpkg_2d_gridded_coverage_ancillary`.
244///
245/// Returns `Ok(vec![])` when the table is not present in the GeoPackage
246/// (the extension is optional).  Returns a descriptive error for malformed
247/// B-tree data.
248///
249/// # Errors
250/// Propagates any B-tree parse error from the underlying SQLite reader.
251pub fn load_gridded_coverages(reader: &GeoPackage) -> Result<Vec<GriddedCoverage>, GpkgError> {
252    let table_name = "gpkg_2d_gridded_coverage_ancillary";
253    let rows = match reader.scan_table_by_name(table_name)? {
254        Some(r) => r,
255        None => return Ok(Vec::new()),
256    };
257
258    let mut out = Vec::with_capacity(rows.len());
259    for (_rowid, values) in rows {
260        // Minimum 11 columns expected (id + 10 data columns).
261        if values.len() < 11 {
262            continue;
263        }
264
265        // col 0: id (INTEGER) — skipped (not in the public struct)
266        // col 1: tile_matrix_set_name (TEXT)
267        let tbl = cell_to_string(&values[1]);
268        // col 2: datatype (TEXT)
269        let datatype_str = cell_to_string(&values[2]);
270        let datatype = datatype_str.parse::<CoverageDatatype>()?;
271        // col 3: scale (REAL)
272        let scale = cell_to_f64(&values[3]);
273        // col 4: offset (REAL)
274        let offset = cell_to_f64(&values[4]);
275        // col 5: precision (REAL)
276        let precision = cell_to_f64(&values[5]);
277        // col 6: data_null (REAL NULL)
278        let data_null = cell_to_optional_f64(&values[6]);
279        // col 7: grid_cell_encoding (TEXT)
280        let encoding_str = cell_to_string(&values[7]);
281        // GridCellEncoding::FromStr is infallible (returns Grid for unknown)
282        let grid_cell_encoding = encoding_str
283            .parse::<GridCellEncoding>()
284            .unwrap_or(GridCellEncoding::Grid);
285        // col 8: uom (TEXT NULL)
286        let uom = cell_to_optional_string(&values[8]);
287        // col 9: field_name (TEXT)
288        let field_name = cell_to_string(&values[9]);
289        // col 10: quantity_definition (TEXT NULL)
290        let quantity_definition = cell_to_optional_string(&values[10]);
291
292        out.push(GriddedCoverage {
293            table_name: tbl,
294            datatype,
295            scale,
296            offset,
297            precision,
298            data_null,
299            grid_cell_encoding,
300            uom,
301            field_name,
302            quantity_definition,
303        });
304    }
305
306    Ok(out)
307}
308
309// ─────────────────────────────────────────────────────────────────────────────
310// load_gridded_tile_ancillary
311// ─────────────────────────────────────────────────────────────────────────────
312
313/// Read rows from `gpkg_2d_gridded_tile_ancillary` for a specific tile table.
314///
315/// Only rows where `tpudt_name == table_name` are returned; all other rows
316/// are filtered out.  Returns `Ok(vec![])` when the ancillary table is absent.
317///
318/// # Errors
319/// Propagates any B-tree parse error from the underlying SQLite reader.
320pub fn load_gridded_tile_ancillary(
321    reader: &GeoPackage,
322    table_name: &str,
323) -> Result<Vec<TileGriddedAncillary>, GpkgError> {
324    let sys_table = "gpkg_2d_gridded_tile_ancillary";
325    let rows = match reader.scan_table_by_name(sys_table)? {
326        Some(r) => r,
327        None => return Ok(Vec::new()),
328    };
329
330    let mut out = Vec::new();
331    for (_rowid, values) in rows {
332        // Minimum 9 columns expected:
333        // id, tpudt_name, tpudt_id, scale, offset, min, max, mean, std_dev
334        if values.len() < 9 {
335            continue;
336        }
337
338        // col 1: tpudt_name — filter by table_name
339        let tpudt_name = cell_to_string(&values[1]);
340        if tpudt_name != table_name {
341            continue;
342        }
343
344        // col 0: id (INTEGER PK)
345        let id = cell_to_i64(&values[0]);
346        // col 2: tpudt_id (INTEGER FK into the tile table)
347        let tpudt_id = cell_to_i64(&values[2]);
348        // col 3: scale (REAL)
349        let scale = cell_to_f64(&values[3]);
350        // col 4: offset (REAL)
351        let offset = cell_to_f64(&values[4]);
352        // col 5: min (REAL NULL)
353        let min = cell_to_optional_f64(&values[5]);
354        // col 6: max (REAL NULL)
355        let max = cell_to_optional_f64(&values[6]);
356        // col 7: mean (REAL NULL)
357        let mean = cell_to_optional_f64(&values[7]);
358        // col 8: std_dev (REAL NULL)
359        let std_dev = cell_to_optional_f64(&values[8]);
360
361        out.push(TileGriddedAncillary {
362            id,
363            tpudt_id,
364            scale,
365            offset,
366            min,
367            max,
368            mean,
369            std_dev,
370        });
371    }
372
373    Ok(out)
374}
375
376// ─────────────────────────────────────────────────────────────────────────────
377// unscale_value
378// ─────────────────────────────────────────────────────────────────────────────
379
380/// Convert a raw sample value to its physical value using scale and offset.
381///
382/// The conversion formula is:
383/// `phys = raw * effective_scale + effective_offset`
384///
385/// Scale/offset priority:
386/// - If `tile_ancillary` is `Some`, use its `scale` and `offset`.
387/// - Otherwise use `coverage.scale` and `coverage.offset`.
388///
389/// Null-data handling: if `coverage.data_null` is `Some(n)` and `raw == n`,
390/// the function returns [`f64::NAN`] (the sample is void/missing).
391pub fn unscale_value(
392    raw: f64,
393    coverage: &GriddedCoverage,
394    tile_ancillary: Option<&TileGriddedAncillary>,
395) -> f64 {
396    // Null-data check before scaling — compare with coverage data_null.
397    if let Some(null_val) = coverage.data_null {
398        // IEEE equality is intentional: the raw value must exactly match the
399        // sentinel defined in the coverage metadata.
400        #[allow(clippy::float_cmp)]
401        if raw == null_val {
402            return f64::NAN;
403        }
404    }
405
406    // Determine effective scale/offset: tile ancillary wins when present.
407    let (effective_scale, effective_offset) = match tile_ancillary {
408        Some(ta) => (ta.scale, ta.offset),
409        None => (coverage.scale, coverage.offset),
410    };
411
412    raw * effective_scale + effective_offset
413}
414
415// ─────────────────────────────────────────────────────────────────────────────
416// unscale_tile_buffer_u16
417// ─────────────────────────────────────────────────────────────────────────────
418
419/// Apply `unscale_value` to every sample in a u16 tile buffer.
420///
421/// Typical GeoPackage elevation tiles store 16-bit unsigned integers packed in
422/// little-endian byte order (after PNG/TIFF decoding); this function accepts the
423/// already-decoded integer slice and returns the physical float values.
424///
425/// The returned `Vec<f64>` has the same length as `raw_buf`.
426pub fn unscale_tile_buffer_u16(
427    raw_buf: &[u16],
428    coverage: &GriddedCoverage,
429    tile_ancillary: Option<&TileGriddedAncillary>,
430) -> Vec<f64> {
431    raw_buf
432        .iter()
433        .map(|&sample| unscale_value(sample as f64, coverage, tile_ancillary))
434        .collect()
435}
436
437// ─────────────────────────────────────────────────────────────────────────────
438// unscale_tile_buffer_i16
439// ─────────────────────────────────────────────────────────────────────────────
440
441/// Apply `unscale_value` to every sample in an i16 tile buffer.
442///
443/// Signed 16-bit elevation data is common in DTM datasets where terrain can
444/// dip below the reference datum (negative elevations).  The conversion is
445/// identical to [`unscale_tile_buffer_u16`] except the input type is `i16`.
446///
447/// The returned `Vec<f64>` has the same length as `raw_buf`.
448pub fn unscale_tile_buffer_i16(
449    raw_buf: &[i16],
450    coverage: &GriddedCoverage,
451    tile_ancillary: Option<&TileGriddedAncillary>,
452) -> Vec<f64> {
453    raw_buf
454        .iter()
455        .map(|&sample| unscale_value(sample as f64, coverage, tile_ancillary))
456        .collect()
457}
458
459// ─────────────────────────────────────────────────────────────────────────────
460// Unit tests
461// ─────────────────────────────────────────────────────────────────────────────
462
463#[cfg(test)]
464#[allow(clippy::float_cmp, clippy::expect_used, clippy::unwrap_used)]
465mod tests {
466    use super::*;
467
468    fn make_coverage(scale: f64, offset: f64, data_null: Option<f64>) -> GriddedCoverage {
469        GriddedCoverage {
470            table_name: "dem".to_string(),
471            datatype: CoverageDatatype::Integer,
472            scale,
473            offset,
474            precision: 1.0,
475            data_null,
476            grid_cell_encoding: GridCellEncoding::Grid,
477            uom: None,
478            field_name: "Height".to_string(),
479            quantity_definition: None,
480        }
481    }
482
483    fn make_tile_ancillary(scale: f64, offset: f64) -> TileGriddedAncillary {
484        TileGriddedAncillary {
485            id: 1,
486            tpudt_id: 42,
487            scale,
488            offset,
489            min: None,
490            max: None,
491            mean: None,
492            std_dev: None,
493        }
494    }
495
496    // ── CoverageDatatype ─────────────────────────────────────────────────────
497
498    #[test]
499    fn datatype_from_str_integer() {
500        assert_eq!(
501            "integer".parse::<CoverageDatatype>().unwrap(),
502            CoverageDatatype::Integer
503        );
504    }
505
506    #[test]
507    fn datatype_from_str_float() {
508        assert_eq!(
509            "float".parse::<CoverageDatatype>().unwrap(),
510            CoverageDatatype::Float
511        );
512    }
513
514    #[test]
515    fn datatype_from_str_invalid() {
516        let err = "raster".parse::<CoverageDatatype>().unwrap_err();
517        assert!(
518            matches!(err, GpkgError::InvalidCoverageDatatype(ref s) if s == "raster"),
519            "unexpected error: {err:?}"
520        );
521    }
522
523    #[test]
524    fn datatype_as_str_roundtrip() {
525        assert_eq!(CoverageDatatype::Integer.as_str(), "integer");
526        assert_eq!(CoverageDatatype::Float.as_str(), "float");
527    }
528
529    // ── GridCellEncoding ─────────────────────────────────────────────────────
530
531    #[test]
532    fn grid_cell_encoding_parses_all_three() {
533        assert_eq!(
534            "grid-value-is-center".parse::<GridCellEncoding>().unwrap(),
535            GridCellEncoding::Grid
536        );
537        assert_eq!(
538            "grid-value-is-area".parse::<GridCellEncoding>().unwrap(),
539            GridCellEncoding::PixelIsArea
540        );
541        assert_eq!(
542            "grid-value-is-corner".parse::<GridCellEncoding>().unwrap(),
543            GridCellEncoding::PixelIsPoint
544        );
545    }
546
547    #[test]
548    fn grid_cell_encoding_unknown_defaults_to_grid() {
549        // OGC §F.7: lenient parse — unknown strings → Grid
550        assert_eq!(
551            "unknown-encoding".parse::<GridCellEncoding>().unwrap(),
552            GridCellEncoding::Grid
553        );
554    }
555
556    // ── unscale_value ────────────────────────────────────────────────────────
557
558    #[test]
559    fn unscale_value_identity_passthrough() {
560        let cov = make_coverage(1.0, 0.0, None);
561        assert_eq!(unscale_value(42.0, &cov, None), 42.0);
562    }
563
564    #[test]
565    fn unscale_value_scale_and_offset_applied() {
566        // scale=0.1, offset=-100.0: raw=1000.0 → phys = 1000*0.1 + (-100) = 0.0
567        let cov = make_coverage(0.1, -100.0, None);
568        let phys = unscale_value(1000.0, &cov, None);
569        assert!((phys - 0.0).abs() < 1e-10, "expected 0.0, got {phys}");
570    }
571
572    #[test]
573    fn unscale_value_tile_ancillary_overrides_coverage() {
574        // coverage scale=1.0 offset=0.0, tile scale=2.0 offset=5.0
575        // raw=10.0 → tile wins → phys = 10*2 + 5 = 25.0
576        let cov = make_coverage(1.0, 0.0, None);
577        let ta = make_tile_ancillary(2.0, 5.0);
578        let phys = unscale_value(10.0, &cov, Some(&ta));
579        assert!((phys - 25.0).abs() < 1e-10, "expected 25.0, got {phys}");
580    }
581
582    #[test]
583    fn unscale_value_data_null_returns_nan() {
584        let cov = make_coverage(1.0, 0.0, Some(0.0));
585        let result = unscale_value(0.0, &cov, None);
586        assert!(result.is_nan(), "expected NAN, got {result}");
587    }
588
589    #[test]
590    fn unscale_value_non_null_not_nan() {
591        let cov = make_coverage(1.0, 0.0, Some(0.0));
592        let result = unscale_value(1.0, &cov, None);
593        assert!(!result.is_nan(), "should not be NAN for non-null value");
594        assert!((result - 1.0).abs() < 1e-10);
595    }
596
597    // ── unscale_tile_buffer_u16 ──────────────────────────────────────────────
598
599    #[test]
600    fn unscale_buffer_u16_identity() {
601        let cov = make_coverage(1.0, 0.0, None);
602        let raw = [0u16, 1, 65535];
603        let out = unscale_tile_buffer_u16(&raw, &cov, None);
604        assert_eq!(out.len(), 3);
605        assert!((out[0] - 0.0).abs() < 1e-10);
606        assert!((out[1] - 1.0).abs() < 1e-10);
607        assert!((out[2] - 65535.0).abs() < 1e-10);
608    }
609
610    #[test]
611    fn unscale_buffer_u16_with_scale() {
612        // scale=0.01, offset=0.0: [100, 200] → [1.0, 2.0]
613        let cov = make_coverage(0.01, 0.0, None);
614        let raw = [100u16, 200u16];
615        let out = unscale_tile_buffer_u16(&raw, &cov, None);
616        assert!((out[0] - 1.0).abs() < 1e-10, "got {}", out[0]);
617        assert!((out[1] - 2.0).abs() < 1e-10, "got {}", out[1]);
618    }
619
620    // ── unscale_tile_buffer_i16 ──────────────────────────────────────────────
621
622    #[test]
623    fn unscale_buffer_i16_negative_elevations() {
624        let cov = make_coverage(1.0, 0.0, None);
625        let raw = [-100i16, 0i16, 100i16];
626        let out = unscale_tile_buffer_i16(&raw, &cov, None);
627        assert_eq!(out.len(), 3);
628        assert!((out[0] - (-100.0)).abs() < 1e-10, "got {}", out[0]);
629        assert!((out[1] - 0.0).abs() < 1e-10, "got {}", out[1]);
630        assert!((out[2] - 100.0).abs() < 1e-10, "got {}", out[2]);
631    }
632
633    #[test]
634    fn unscale_buffer_i16_with_scale_and_offset() {
635        // scale=0.5, offset=10.0: [-2, 0, 4] → [9.0, 10.0, 12.0]
636        let cov = make_coverage(0.5, 10.0, None);
637        let raw = [-2i16, 0i16, 4i16];
638        let out = unscale_tile_buffer_i16(&raw, &cov, None);
639        assert!((out[0] - 9.0).abs() < 1e-10, "got {}", out[0]);
640        assert!((out[1] - 10.0).abs() < 1e-10, "got {}", out[1]);
641        assert!((out[2] - 12.0).abs() < 1e-10, "got {}", out[2]);
642    }
643}