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}