You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ja...@apache.org on 2026/09/28 21:43:45 UTC
(sedona-db) branch main updated: feat(rust/sedona-raster-functions): add RS_SetValue (#1360)
This is an automated email from the ASF dual-hosted git repository.
james-willis pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/sedona-db.git
The following commit(s) were added to refs/heads/main by this push:
new 3f7b7a7b0 feat(rust/sedona-raster-functions): add RS_SetValue (#1360)
3f7b7a7b0 is described below
commit 3f7b7a7b098d540191833ea9754bdd3117d61a37
Author: James Willis <ja...@users.noreply.github.com>
AuthorDate: Mon Sep 28 14:43:38 2026 -0700
feat(rust/sedona-raster-functions): add RS_SetValue (#1360)
---
docs/reference/sql/rs_setvalue.qmd | 71 +++
integration/spark-parity/test_rs_setvalue.py | 144 ++++++
.../sedonadb/tests/functions/test_rs_setvalue.py | 128 +++++
rust/sedona-raster-functions/src/lib.rs | 1 +
rust/sedona-raster-functions/src/register.rs | 1 +
rust/sedona-raster-functions/src/rs_setvalue.rs | 565 +++++++++++++++++++++
rust/sedona-raster-functions/src/rs_value.rs | 4 +-
rust/sedona-raster-functions/src/rs_values.rs | 4 +-
rust/sedona-raster-functions/src/sampling.rs | 15 +-
rust/sedona-raster/src/traits.rs | 54 ++
10 files changed, 976 insertions(+), 11 deletions(-)
diff --git a/docs/reference/sql/rs_setvalue.qmd b/docs/reference/sql/rs_setvalue.qmd
new file mode 100644
index 000000000..85df57b3e
--- /dev/null
+++ b/docs/reference/sql/rs_setvalue.qmd
@@ -0,0 +1,71 @@
+---
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+title: RS_SetValue
+description: >
+ Returns the raster with one pixel of a band set to a new value.
+kernels:
+ - returns: raster
+ args:
+ - raster
+ - name: col_x
+ type: integer
+ description: Column of the pixel (1-based).
+ - name: row_y
+ type: integer
+ description: Row of the pixel (1-based).
+ - name: new_value
+ type: double
+ description: The value to store.
+ - returns: raster
+ args:
+ - raster
+ - name: band
+ type: integer
+ description: >
+ Band index (1-based). When omitted, the raster must have a single band;
+ on a multiband raster the band has to be given.
+ - name: col_x
+ type: integer
+ - name: row_y
+ type: integer
+ - name: new_value
+ type: double
+---
+
+## Description
+
+`col_x` and `row_y` count from 1, as in PostGIS and Sedona Spark: `(1, 1)` is
+the upper-left pixel. The pixel is overwritten even if it holds the band's
+nodata value, and the band's nodata value is unchanged.
+
+The value is stored in the band's pixel type: an integer band truncates it
+toward zero (3.9 is stored as 3) and a float32 band rounds it to the nearest
+float32. A value outside an integer band's range (for a 64-bit integer band,
+beyond ±2^53, the largest integers a double holds exactly), NaN, a pixel
+outside the grid, or a band the raster does not have is an error. Only 2-D
+rasters are supported.
+
+## Examples
+
+Setting pixel (2, 2) of band 1 to 42 raises the band's maximum, which is
+otherwise 1, to 42:
+
+```sql
+SELECT RS_SummaryStats(RS_SetValue(RS_Example(), 1, 2, 2, 42), 'max', 1);
+```
diff --git a/integration/spark-parity/test_rs_setvalue.py b/integration/spark-parity/test_rs_setvalue.py
new file mode 100644
index 000000000..3ed8c6e66
--- /dev/null
+++ b/integration/spark-parity/test_rs_setvalue.py
@@ -0,0 +1,144 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""SedonaDB vs Sedona Spark parity for RS_SetValue.
+
+Each case compares the whole output raster, decoded on both engines, and
+anchors it to the input with the one pixel changed. colX and rowY are 1-based
+on both engines. Three known divergences are xfails: an elided band on a
+multiband raster, which SedonaDB rejects where Sedona Spark writes band 1; a
+value outside an integer band's range, which SedonaDB rejects where Spark's
+Java cast wraps it around (300 in a UInt8 band becomes 44); and a column just
+past the grid's width, which SedonaDB rejects where Spark writes the first
+pixel of the next row.
+"""
+
+import pytest
+
+from sedonadb.raster_testing import DecodedRaster
+from sedonadb.testing import SedonaDB, compare
+from sedonadb.testing_spark import SedonaSpark
+
+
+def _engines(name, tmp_path, **kwargs):
+ sedona, spark = SedonaDB(), SedonaSpark()
+ for eng in (sedona, spark):
+ eng.create_random_raster_view(name, tmp_path / f"{name}.tif", **kwargs)
+ return sedona, spark
+
+
+def _expected(band, col, row, value, **kwargs):
+ """The random fixture raster with one pixel (1-based) set to `value`."""
+ raster = DecodedRaster.random(**kwargs)
+ raster.pixels[band - 1, row - 1, col - 1] = value
+ return raster
+
+
+@pytest.mark.parametrize(
+ "band,col,row",
+ [(1, 1, 1), (2, 7, 6), (1, 4, 3)],
+ ids=["first-pixel", "last-pixel", "interior"],
+)
+def test_rs_setvalue(band, col, row, tmp_path):
+ sedona, spark = _engines("sv_src", tmp_path)
+ sql = f"SELECT RS_SetValue(rast, {band}, {col}, {row}, 42) FROM sv_src"
+ compare(sql, sedona, spark, expected=_expected(band, col, row, 42))
+
+
+def test_rs_setvalue_truncates_toward_zero(tmp_path):
+ """A fractional value in an integer band truncates toward zero."""
+ sedona, spark = _engines("sv_t_src", tmp_path, dtype="int16")
+ sql = "SELECT RS_SetValue(rast, 1, 2, 2, -3.9) FROM sv_t_src"
+ compare(sql, sedona, spark, expected=_expected(1, 2, 2, -3, dtype="int16"))
+
+
+def test_rs_setvalue_float64(tmp_path):
+ sedona, spark = _engines("sv_f_src", tmp_path, dtype="float64")
+ sql = "SELECT RS_SetValue(rast, 2, 3, 4, -0.125) FROM sv_f_src"
+ compare(sql, sedona, spark, expected=_expected(2, 3, 4, -0.125, dtype="float64"))
+
+
+def test_rs_setvalue_overwrites_nodata(tmp_path):
+ """A pixel holding the band's nodata value is overwritten, and the nodata
+ value itself is unchanged."""
+ sedona, spark = _engines("sv_nd_src", tmp_path, nodata=200, plants={(1, 1): 200})
+ sql = "SELECT RS_SetValue(rast, 1, 2, 2, 7) FROM sv_nd_src"
+ expected = _expected(1, 2, 2, 7, nodata=200, plants={(1, 1): 200})
+ compare(sql, sedona, spark, expected=expected)
+
+
+def test_rs_setvalue_elided_band_single_band(tmp_path):
+ sedona, spark = _engines("sv_1b_src", tmp_path, bands=1)
+ sql = "SELECT RS_SetValue(rast, 5, 1, 9) FROM sv_1b_src"
+ compare(sql, sedona, spark, expected=_expected(1, 5, 1, 9, bands=1))
+
+
+def test_rs_setvalue_null_value(tmp_path):
+ """A NULL value gives a NULL raster on both engines (for a raster result,
+ compare accepts only both engines being NULL)."""
+ sedona, spark = _engines("sv_n_src", tmp_path)
+ sql = "SELECT RS_SetValue(rast, 1, 1, 1, CAST(NULL AS DOUBLE)) FROM sv_n_src"
+ for eng in (sedona, spark):
+ assert eng.decode_raster_result(sql) is None
+
+
+@pytest.mark.parametrize(
+ "args",
+ [", 1, 1, 7, 5", ", 1, 0, 1, 5", ", 3, 1, 1, 5"],
+ ids=["row-past-height", "col-zero", "band"],
+)
+def test_rs_setvalue_rejected(args, tmp_path):
+ """Both engines refuse a pixel outside the grid and a band the raster does
+ not have. Error types differ, so parity here is parity on refusal."""
+ sedona, spark = _engines("sv_r_src", tmp_path)
+ sql = f"SELECT RS_SetValue(rast{args}) FROM sv_r_src"
+ for eng in (sedona, spark):
+ with pytest.raises(Exception):
+ eng.decode_raster_result(sql)
+
+
+@pytest.mark.xfail(
+ reason="SedonaDB requires the band argument on a multi-band raster; "
+ "Sedona Spark's band-less form writes band 1"
+)
+def test_rs_setvalue_elided_band_multiband(tmp_path):
+ sedona, spark = _engines("sv_2a_src", tmp_path)
+ sql = "SELECT RS_SetValue(rast, 1, 1, 42) FROM sv_2a_src"
+ compare(sql, sedona, spark, expected=_expected(1, 1, 1, 42))
+
+
+@pytest.mark.xfail(
+ reason="SedonaDB rejects a value outside an integer band's range; Sedona "
+ "Spark's Java cast wraps it around (300 in a UInt8 band becomes 44)"
+)
+def test_rs_setvalue_out_of_range_value(tmp_path):
+ sedona, spark = _engines("sv_o_src", tmp_path)
+ sql = "SELECT RS_SetValue(rast, 1, 1, 1, 300) FROM sv_o_src"
+ compare(sql, sedona, spark)
+
+
+@pytest.mark.xfail(
+ reason="SedonaDB rejects a column past the grid's width; Sedona Spark 1.9.1 "
+ "writes the first pixel of the next row instead (column 8, row 1 of a "
+ "7-wide grid lands on column 1, row 2)"
+)
+def test_rs_setvalue_column_past_width(tmp_path):
+ sedona, spark = _engines("sv_w_src", tmp_path)
+ sql = "SELECT RS_SetValue(rast, 1, 8, 1, 5) FROM sv_w_src"
+ for eng in (sedona, spark):
+ with pytest.raises(Exception):
+ eng.decode_raster_result(sql)
diff --git a/python/sedonadb/tests/functions/test_rs_setvalue.py b/python/sedonadb/tests/functions/test_rs_setvalue.py
new file mode 100644
index 000000000..9852f8f07
--- /dev/null
+++ b/python/sedonadb/tests/functions/test_rs_setvalue.py
@@ -0,0 +1,128 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""RS_SetValue against a numpy reference.
+
+Each fixture is written to a GeoTIFF and read back through RS_FromPath, so the
+kernel runs on the band bytes the planner loads for it. The whole output raster
+is decoded and compared with the input pixels with one pixel changed: every
+other pixel, every other band, the grid and the nodata values must survive.
+"""
+
+import pyarrow as pa
+import pytest
+
+from sedonadb.raster_testing import (
+ DecodedRaster,
+ assert_decoded_equal,
+ decode_raster,
+ random_raster_data,
+ write_geotiff,
+)
+
+pytest.importorskip("rasterio")
+
+BBOX = (100.0, 482.0, 114.0, 500.0)
+
+
+def _set_value(con, path, *args):
+ """RS_SetValue with every argument after the raster as a table column, so
+ the kernel runs its real array path (literals constant-fold)."""
+ names = ["a", "b", "c", "d"][: len(args)]
+ types = [pa.int64()] * (len(args) - 1) + [pa.float64()]
+ columns = {"path": [str(path)]}
+ for name, value, arrow_type in zip(names, args, types):
+ columns[name] = pa.array([value], arrow_type)
+ con.create_data_frame(pa.table(columns)).to_view("setvalue_src", overwrite=True)
+ arg_sql = "".join(f", {name}" for name in names)
+ sql = f"SELECT RS_SetValue(RS_FromPath(path){arg_sql}) FROM setvalue_src"
+ return decode_raster(con.sql(sql).to_arrow_table().column(0)[0])
+
+
+@pytest.fixture()
+def tiff(tmp_path):
+ data = random_raster_data("int16", bands=2, height=6, width=7)
+ path = tmp_path / "setvalue.tif"
+ write_geotiff(path, data, bbox=BBOX, nodata=-9)
+ return path, data
+
+
+@pytest.mark.parametrize(
+ "band,col,row", [(1, 1, 1), (2, 7, 6), (1, 4, 3)], ids=["first", "last", "interior"]
+)
+def test_rs_setvalue(con, tiff, band, col, row):
+ path, data = tiff
+ expected = data.copy()
+ expected[band - 1, row - 1, col - 1] = 1234
+ got = _set_value(con, path, band, col, row, 1234.0)
+ assert_decoded_equal(got, DecodedRaster(expected, bbox=BBOX, nodata=[-9, -9]))
+
+
+def test_rs_setvalue_truncates_toward_zero(con, tiff):
+ path, data = tiff
+ expected = data.copy()
+ expected[0, 0, 0] = -3
+ got = _set_value(con, path, 1, 1, 1, -3.9)
+ assert_decoded_equal(got, DecodedRaster(expected, bbox=BBOX, nodata=[-9, -9]))
+
+
+def test_rs_setvalue_elided_band(con, tmp_path):
+ """The band may be left out only for a single-band raster."""
+ data = random_raster_data("float64", bands=1, height=6, width=7)
+ path = tmp_path / "single.tif"
+ write_geotiff(path, data, bbox=BBOX)
+ expected = data.copy()
+ expected[0, 2, 1] = -0.5
+ got = _set_value(con, path, 2, 3, -0.5)
+ assert_decoded_equal(got, DecodedRaster(expected, bbox=BBOX, nodata=[None]))
+
+
+def test_rs_setvalue_elided_band_on_multiband_errors(con, tiff):
+ path, _ = tiff
+ with pytest.raises(Exception, match="specify which band"):
+ _set_value(con, path, 1, 1, 5.0)
+
+
+@pytest.mark.parametrize(
+ "args,match",
+ [
+ ((1, 8, 1, 5.0), "outside the 7 x 6 grid"),
+ ((1, 0, 1, 5.0), "outside the 7 x 6 grid"),
+ ((3, 1, 1, 5.0), "out of range"),
+ ((1, 1, 1, 40000.0), "does not fit a Int16 pixel"),
+ ],
+ ids=["col-past-width", "col-zero", "band", "value"],
+)
+def test_rs_setvalue_rejected(con, tiff, args, match):
+ path, _ = tiff
+ with pytest.raises(Exception, match=match):
+ _set_value(con, path, *args)
+
+
+def test_rs_setvalue_output_feeds_other_raster_functions(con):
+ """RS_SetValue's output is already loaded, so the planner must not wrap it
+ in another RS_EnsureLoaded when it feeds RS_Value or a second RS_SetValue.
+ Reading from a table keeps the raster out of constant folding."""
+ rasters = con.sql("SELECT RS_Example() AS r").to_arrow_table()
+ con.create_data_frame(rasters).to_view("setvalue_nested", overwrite=True)
+ # Pixels (2, 2) and (3, 3) both hold 7, so RS_Value's grid form reads 7
+ # however it counts.
+ sql = """
+ SELECT RS_Value(RS_SetValue(RS_SetValue(r, 1, 2, 2, 7), 1, 3, 3, 7), 2, 2, 1)
+ FROM setvalue_nested
+ """
+ assert con.sql(sql).to_arrow_table().column(0).to_pylist() == [7.0]
diff --git a/rust/sedona-raster-functions/src/lib.rs b/rust/sedona-raster-functions/src/lib.rs
index 6df48a5c4..30c7ea4c2 100644
--- a/rust/sedona-raster-functions/src/lib.rs
+++ b/rust/sedona-raster-functions/src/lib.rs
@@ -40,6 +40,7 @@ pub mod rs_rastercoordinate;
pub mod rs_set_band_nodata;
pub mod rs_set_georeference;
pub mod rs_setsrid;
+pub mod rs_setvalue;
pub mod rs_size;
pub mod rs_slice;
pub mod rs_spatial_predicates;
diff --git a/rust/sedona-raster-functions/src/register.rs b/rust/sedona-raster-functions/src/register.rs
index c01f3f292..3d9f2fe4a 100644
--- a/rust/sedona-raster-functions/src/register.rs
+++ b/rust/sedona-raster-functions/src/register.rs
@@ -73,6 +73,7 @@ pub fn default_function_set() -> FunctionSet {
crate::rs_set_georeference::rs_set_georeference_udf,
crate::rs_setsrid::rs_set_crs_udf,
crate::rs_setsrid::rs_set_srid_udf,
+ crate::rs_setvalue::rs_setvalue_udf,
crate::rs_size::rs_height_udf,
crate::rs_size::rs_width_udf,
crate::rs_slice::rs_slice_udf,
diff --git a/rust/sedona-raster-functions/src/rs_setvalue.rs b/rust/sedona-raster-functions/src/rs_setvalue.rs
new file mode 100644
index 000000000..2dad52709
--- /dev/null
+++ b/rust/sedona-raster-functions/src/rs_setvalue.rs
@@ -0,0 +1,565 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! `RS_SetValue` — overwrite one pixel of a band.
+//!
+//! ```text
+//! RS_SetValue(raster, colX, rowY, newValue) -> Raster -- single-band rasters only
+//! RS_SetValue(raster, band, colX, rowY, newValue) -> Raster
+//! ```
+//!
+//! `colX` and `rowY` are 1-based, as in PostGIS and Sedona Spark; a pixel
+//! outside the grid is an error. The pixel is overwritten whether or not it holds the band's nodata
+//! value, and the band's nodata value itself is unchanged.
+//!
+//! `newValue` is stored the way Sedona Spark stores it: truncated toward zero in
+//! an integer band and rounded to the nearest `f32` in a Float32 band. A value
+//! outside an integer band's range, or NaN, is an error rather than wrapping
+//! around as Spark's Java cast does.
+//!
+//! Only the addressed band's bytes are copied (into a packed buffer with the one
+//! pixel changed); every other band is carried over zero-copy.
+
+use std::sync::Arc;
+
+use arrow_array::Array;
+use arrow_array::cast::AsArray;
+use arrow_array::types::{Float64Type, Int64Type};
+use arrow_buffer::Buffer;
+use arrow_schema::DataType;
+use datafusion_common::{Result, exec_datafusion_err, exec_err};
+use datafusion_expr::{ColumnarValue, Volatility};
+use sedona_expr::scalar_udf::{SedonaScalarKernel, SedonaScalarUDF};
+use sedona_raster::band_builder::check_band_data_len;
+use sedona_raster::builder::{RasterBuilder, RasterOverrides};
+use sedona_raster::traits::{BandOverrides, BandRef, Override, RasterRef, pixel_f64_to_bytes};
+use sedona_schema::datatypes::SedonaType;
+use sedona_schema::matchers::ArgMatcher;
+use sedona_schema::raster::BandDataType;
+
+use crate::executor::RasterExecutor;
+use crate::pixel_scan::{scan_pixels, spatial_2d_buffer};
+use crate::rs_ensure_loaded::{NEEDS_PIXELS_METADATA_KEY, RETURNS_BYTES_METADATA_KEY};
+use crate::sampling::{default_band, resolve_band};
+
+const FUNC: &str = "RS_SetValue";
+
+/// `RS_SetValue()` scalar UDF — overwrite one pixel of a band.
+pub fn rs_setvalue_udf() -> SedonaScalarUDF {
+ SedonaScalarUDF::new(
+ "rs_setvalue",
+ vec![
+ Arc::new(RsSetValue { with_band: false }), // (raster, colX, rowY, newValue)
+ Arc::new(RsSetValue { with_band: true }), // (raster, band, colX, rowY, newValue)
+ ],
+ Volatility::Immutable,
+ )
+ // The kernel reads and rewrites pixel bytes, so the raster argument must be
+ // materialised InDb first; the planner injects RS_EnsureLoaded on this flag.
+ .with_metadata(NEEDS_PIXELS_METADATA_KEY, "true")
+ // The output is InDb too (the edited band is fresh bytes, the others are
+ // copied from the loaded input), so a consumer of RS_SetValue — including a
+ // nested RS_SetValue — must not wrap it in another RS_EnsureLoaded.
+ .with_metadata(RETURNS_BYTES_METADATA_KEY, "true")
+}
+
+#[derive(Debug)]
+struct RsSetValue {
+ with_band: bool,
+}
+
+impl SedonaScalarKernel for RsSetValue {
+ fn return_type(&self, args: &[SedonaType]) -> Result<Option<SedonaType>> {
+ let mut matchers = vec![ArgMatcher::is_raster()];
+ if self.with_band {
+ matchers.push(ArgMatcher::is_integer());
+ }
+ matchers.extend([
+ ArgMatcher::is_integer(),
+ ArgMatcher::is_integer(),
+ ArgMatcher::is_numeric(),
+ ]);
+ ArgMatcher::new(matchers, SedonaType::Raster).match_args(args)
+ }
+
+ fn invoke_batch(
+ &self,
+ arg_types: &[SedonaType],
+ args: &[ColumnarValue],
+ ) -> Result<ColumnarValue> {
+ let executor = RasterExecutor::new(arg_types, args);
+ let n = executor.num_iterations();
+
+ let int_column = |arg: &ColumnarValue| -> Result<_> {
+ arg.clone().cast_to(&DataType::Int64, None)?.into_array(n)
+ };
+ let first = if self.with_band { 2 } else { 1 };
+ let band_array = self.with_band.then(|| int_column(&args[1])).transpose()?;
+ let col_array = int_column(&args[first])?;
+ let row_array = int_column(&args[first + 1])?;
+ let value_array = args[first + 2]
+ .clone()
+ .cast_to(&DataType::Float64, None)?
+ .into_array(n)?;
+ let band = band_array.as_ref().map(|a| a.as_primitive::<Int64Type>());
+ let col = col_array.as_primitive::<Int64Type>();
+ let row = row_array.as_primitive::<Int64Type>();
+ let value = value_array.as_primitive::<Float64Type>();
+
+ let mut builder = RasterBuilder::new(n);
+ executor.execute_raster_void(|i, raster_opt| {
+ let Some(raster) = raster_opt else {
+ return Ok(builder.append_null()?);
+ };
+ if band.is_some_and(|b| b.is_null(i))
+ || col.is_null(i)
+ || row.is_null(i)
+ || value.is_null(i)
+ {
+ return Ok(builder.append_null()?);
+ }
+ let band_num = match band {
+ // Clamp a negative band to 0 so resolve_band rejects it as not
+ // 1-based rather than wrapping it into a huge usize.
+ Some(band) => band.value(i).max(0) as usize,
+ None => default_band(FUNC, "4-argument", raster.num_bands())?,
+ };
+ set_value(
+ &mut builder,
+ raster,
+ band_num,
+ col.value(i),
+ row.value(i),
+ value.value(i),
+ )
+ })?;
+
+ executor.finish(Arc::new(builder.finish()?))
+ }
+}
+
+/// Copy `raster` into `builder` with the 1-based pixel (`col`, `row`) of the
+/// 1-based band `band_num` set to `value`.
+fn set_value(
+ builder: &mut RasterBuilder,
+ raster: &dyn RasterRef,
+ band_num: usize,
+ col: i64,
+ row: i64,
+ value: f64,
+) -> Result<()> {
+ let target = resolve_band(FUNC, raster, band_num)?;
+ let data = set_pixel(target.as_ref(), col, row, value)?;
+
+ builder.start_raster_from(raster, RasterOverrides::default())?;
+ for band_idx in 0..raster.num_bands() {
+ let band = raster.band(band_idx)?;
+ let overrides = if band_idx + 1 == band_num {
+ // The new bytes are the band's visible pixels, packed row-major, so
+ // they carry an identity view over the visible shape.
+ BandOverrides {
+ data: Override::Set(&data),
+ view: Override::Clear,
+ source_shape: Some(band.shape()),
+ ..Default::default()
+ }
+ } else {
+ BandOverrides::default()
+ };
+ band.copy_into(builder, overrides)?;
+ builder.finish_band()?;
+ }
+ builder.finish_raster()?;
+ Ok(())
+}
+
+/// The band's visible pixels, packed row-major, with the 1-based pixel
+/// (`col`, `row`) set to `value`.
+fn set_pixel(band: &dyn BandRef, col: i64, row: i64, value: f64) -> Result<Buffer> {
+ let buffer = spatial_2d_buffer(FUNC, band)?;
+ let (height, width) = (buffer.shape[0], buffer.shape[1]);
+ if !(1..=width).contains(&col) || !(1..=height).contains(&row) {
+ return exec_err!(
+ "{FUNC}: pixel ({col}, {row}) is outside the {width} x {height} grid \
+ (colX and rowY are 1-based)"
+ );
+ }
+ let pixel = pixel_bytes(value, &buffer.data_type)?;
+
+ // A broadcast view can describe far more pixels than its source holds, so
+ // size the packed output with checked arithmetic and reject it before
+ // allocating rather than after.
+ let len = usize::try_from(width)
+ .ok()
+ .and_then(|w| w.checked_mul(usize::try_from(height).ok()?))
+ .and_then(|n| n.checked_mul(pixel.len()))
+ .ok_or_else(|| {
+ exec_datafusion_err!("{FUNC}: a {width} x {height} band is too large to materialise")
+ })?;
+ check_band_data_len(len).map_err(|e| exec_datafusion_err!("{FUNC}: {e}"))?;
+ let mut data = Vec::with_capacity(len);
+ match buffer.as_contiguous() {
+ Ok(bytes) => data.extend_from_slice(bytes),
+ // A strided, reversed or broadcast view: gather its visible pixels.
+ Err(_) => scan_pixels(FUNC, &buffer, |_, _, bytes| {
+ data.extend_from_slice(bytes);
+ std::ops::ControlFlow::Continue(())
+ })?,
+ }
+ let size = pixel.len();
+ let start = ((row - 1) * width + (col - 1)) as usize * size;
+ data[start..start + size].copy_from_slice(&pixel);
+ Ok(Buffer::from_vec(data))
+}
+
+/// `value` packed as a pixel of `data_type`, via the shared
+/// [`pixel_f64_to_bytes`] (Spark's truncation toward zero for integer bands).
+/// A value that does not fit is an error, where Spark's Java cast would wrap
+/// around instead.
+fn pixel_bytes(value: f64, data_type: &BandDataType) -> Result<Vec<u8>> {
+ pixel_f64_to_bytes(value, data_type)
+ .map_err(|_| exec_datafusion_err!("{FUNC}: {value} does not fit a {data_type:?} pixel"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use arrow_array::{Float64Array, Int64Array};
+ use datafusion_expr::ScalarUDF;
+ use sedona_raster::array::RasterStructArray;
+ use sedona_schema::datatypes::RASTER;
+ use sedona_testing::raster_spec::{RasterSpec, assert_rasters_equal, raster_array};
+ use sedona_testing::testers::ScalarUdfTester;
+
+ fn i64_t() -> SedonaType {
+ SedonaType::Arrow(DataType::Int64)
+ }
+ fn f64_t() -> SedonaType {
+ SedonaType::Arrow(DataType::Float64)
+ }
+
+ /// Run the 5-argument form over one row per spec.
+ fn set(
+ specs: Vec<Option<RasterSpec>>,
+ band: i64,
+ col: i64,
+ row: i64,
+ value: f64,
+ ) -> Result<arrow_array::ArrayRef> {
+ let n = specs.len();
+ let tester = ScalarUdfTester::new(
+ rs_setvalue_udf().into(),
+ vec![RASTER, i64_t(), i64_t(), i64_t(), f64_t()],
+ );
+ tester.invoke_arrays(vec![
+ Arc::new(raster_array(specs)),
+ Arc::new(Int64Array::from(vec![band; n])),
+ Arc::new(Int64Array::from(vec![col; n])),
+ Arc::new(Int64Array::from(vec![row; n])),
+ Arc::new(Float64Array::from(vec![value; n])),
+ ])
+ }
+
+ /// A 3x2 two-band raster: UInt8 band 1 (nodata 0), Float32 band 2.
+ fn two_band() -> RasterSpec {
+ RasterSpec::d2(3, 2)
+ .band_values(&[1u8, 2, 3, 4, 5, 6])
+ .nodata(0u8)
+ .band_values(&[0.5f32, 1.5, 2.5, 3.5, 4.5, 5.5])
+ }
+
+ #[test]
+ fn udf_metadata() {
+ let udf: ScalarUDF = rs_setvalue_udf().into();
+ assert_eq!(udf.name(), "rs_setvalue");
+ assert_eq!(
+ rs_setvalue_udf()
+ .metadata()
+ .get(NEEDS_PIXELS_METADATA_KEY)
+ .map(String::as_str),
+ Some("true")
+ );
+ }
+
+ #[test]
+ fn sets_one_pixel_of_the_addressed_band() {
+ // (col 3, row 2) is the last pixel, 1-based.
+ let result = set(vec![Some(two_band())], 1, 3, 2, 42.0).unwrap();
+ let expected = RasterSpec::d2(3, 2)
+ .band_values(&[1u8, 2, 3, 4, 5, 42])
+ .nodata(0u8)
+ .band_values(&[0.5f32, 1.5, 2.5, 3.5, 4.5, 5.5]);
+ assert_rasters_equal(&result, &[Some(expected)]);
+
+ let result = set(vec![Some(two_band())], 2, 1, 1, -7.25).unwrap();
+ let expected = RasterSpec::d2(3, 2)
+ .band_values(&[1u8, 2, 3, 4, 5, 6])
+ .nodata(0u8)
+ .band_values(&[-7.25f32, 1.5, 2.5, 3.5, 4.5, 5.5]);
+ assert_rasters_equal(&result, &[Some(expected)]);
+ }
+
+ #[test]
+ fn overwrites_a_nodata_pixel_and_keeps_the_nodata_value() {
+ let spec = RasterSpec::d2(2, 1).band_values(&[0u8, 9]).nodata(0u8);
+ let result = set(vec![Some(spec)], 1, 1, 1, 5.0).unwrap();
+ let expected = RasterSpec::d2(2, 1).band_values(&[5u8, 9]).nodata(0u8);
+ assert_rasters_equal(&result, &[Some(expected)]);
+ }
+
+ #[test]
+ fn untouched_bands_share_their_buffers() {
+ let input = raster_array([Some(two_band())]);
+ let tester = ScalarUdfTester::new(
+ rs_setvalue_udf().into(),
+ vec![RASTER, i64_t(), i64_t(), i64_t(), f64_t()],
+ );
+ let result = tester
+ .invoke_arrays(vec![
+ Arc::new(input.clone()),
+ Arc::new(Int64Array::from(vec![1])),
+ Arc::new(Int64Array::from(vec![1])),
+ Arc::new(Int64Array::from(vec![1])),
+ Arc::new(Float64Array::from(vec![9.0])),
+ ])
+ .unwrap();
+ let result = result
+ .as_any()
+ .downcast_ref::<arrow_array::StructArray>()
+ .unwrap();
+ let before = RasterStructArray::try_new(&input).unwrap();
+ let after = RasterStructArray::try_new(result).unwrap();
+ let (before, after) = (before.get(0).unwrap(), after.get(0).unwrap());
+ let band_ptr = |raster: &dyn RasterRef, i| {
+ raster.band(i).unwrap().nd_buffer().unwrap().buffer.as_ptr()
+ };
+ assert_eq!(band_ptr(&before, 1), band_ptr(&after, 1));
+ assert_ne!(band_ptr(&before, 0), band_ptr(&after, 0));
+ }
+
+ #[test]
+ fn integer_values_truncate_toward_zero() {
+ let spec = || RasterSpec::d2(1, 1).band_values(&[0i16]);
+ let result = set(vec![Some(spec())], 1, 1, 1, 3.9).unwrap();
+ assert_rasters_equal(&result, &[Some(RasterSpec::d2(1, 1).band_values(&[3i16]))]);
+ let result = set(vec![Some(spec())], 1, 1, 1, -3.9).unwrap();
+ assert_rasters_equal(&result, &[Some(RasterSpec::d2(1, 1).band_values(&[-3i16]))]);
+ // A value just past the range truncates back into it.
+ let result = set(vec![Some(spec())], 1, 1, 1, 32767.9).unwrap();
+ assert_rasters_equal(
+ &result,
+ &[Some(RasterSpec::d2(1, 1).band_values(&[32767i16]))],
+ );
+ }
+
+ #[test]
+ fn values_outside_an_integer_type_error() {
+ let spec = || Some(RasterSpec::d2(1, 1).band_values(&[0u8]));
+ for value in [256.0, -1.0, f64::NAN, f64::INFINITY] {
+ let err = set(vec![spec()], 1, 1, 1, value).unwrap_err().to_string();
+ assert!(err.contains("does not fit a UInt8 pixel"), "{value}: {err}");
+ }
+ }
+
+ #[test]
+ fn int64_values_stop_at_the_exact_double_range() {
+ // A 64-bit band takes integers up to ±2^53, the largest a double holds
+ // exactly; beyond that the value is rejected rather than stored lossily.
+ let spec = || Some(RasterSpec::d2(1, 1).band_values(&[0i64]));
+ let limit = (1i64 << 53) as f64;
+ let result = set(vec![spec()], 1, 1, 1, -limit).unwrap();
+ assert_rasters_equal(
+ &result,
+ &[Some(RasterSpec::d2(1, 1).band_values(&[-(1i64 << 53)]))],
+ );
+ let err = set(vec![spec()], 1, 1, 1, 1e18).unwrap_err().to_string();
+ assert!(err.contains("does not fit a Int64 pixel"), "{err}");
+ }
+
+ #[test]
+ fn oversized_broadcast_band_errors_before_allocating() {
+ // A 1x1 UInt16 source broadcast to 2^62 columns describes far more
+ // bytes than one band can hold; it must error, not panic in the
+ // allocator.
+ use sedona_raster::builder::StartBandArgs;
+ use sedona_raster::view_entries::{ViewEntries, ViewEntry};
+
+ let width = 1i64 << 62;
+ let view = ViewEntries::try_new(
+ vec![
+ ViewEntry {
+ source_axis: 0,
+ start: 0,
+ step: 0,
+ steps: 1,
+ },
+ ViewEntry {
+ source_axis: 1,
+ start: 0,
+ step: 0,
+ steps: width,
+ },
+ ],
+ &[1, 1],
+ )
+ .unwrap();
+ let mut builder = RasterBuilder::new(1);
+ builder
+ .start_raster_2d(width, 1, 0., 0., 1., -1., 0., 0., None)
+ .unwrap();
+ builder
+ .start_band(StartBandArgs {
+ view: Some(&view),
+ ..StartBandArgs::new(&["y", "x"], &[1, 1], BandDataType::UInt16)
+ })
+ .unwrap();
+ builder.band_data_writer().append_value(7u16.to_le_bytes());
+ builder.finish_band().unwrap();
+ builder.finish_raster().unwrap();
+
+ let tester = ScalarUdfTester::new(
+ rs_setvalue_udf().into(),
+ vec![RASTER, i64_t(), i64_t(), f64_t()],
+ );
+ let err = tester
+ .invoke_arrays(vec![
+ Arc::new(builder.finish().unwrap()),
+ Arc::new(Int64Array::from(vec![1])),
+ Arc::new(Int64Array::from(vec![1])),
+ Arc::new(Float64Array::from(vec![9.0])),
+ ])
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("RS_SetValue"), "{err}");
+ }
+
+ #[test]
+ fn float32_rounds_to_nearest() {
+ let spec = Some(RasterSpec::d2(1, 1).band_values(&[0f32]));
+ let result = set(vec![spec], 1, 1, 1, 0.1).unwrap();
+ assert_rasters_equal(
+ &result,
+ &[Some(RasterSpec::d2(1, 1).band_values(&[0.1f32]))],
+ );
+ }
+
+ #[test]
+ fn pixel_outside_the_grid_errors() {
+ for (col, row) in [(0, 1), (1, 0), (4, 1), (1, 3)] {
+ let err = set(vec![Some(two_band())], 1, col, row, 1.0)
+ .unwrap_err()
+ .to_string();
+ assert!(
+ err.contains("outside the 3 x 2 grid"),
+ "({col}, {row}): {err}"
+ );
+ }
+ }
+
+ #[test]
+ fn band_out_of_range_errors() {
+ for (band, expected) in [(0, "1-based"), (-1, "1-based"), (3, "out of range")] {
+ let err = set(vec![Some(two_band())], band, 1, 1, 1.0)
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains(expected), "{band}: {err}");
+ }
+ }
+
+ #[test]
+ fn elided_band_only_on_a_single_band_raster() {
+ let tester = ScalarUdfTester::new(
+ rs_setvalue_udf().into(),
+ vec![RASTER, i64_t(), i64_t(), f64_t()],
+ );
+ let args = |spec: RasterSpec| -> Vec<arrow_array::ArrayRef> {
+ vec![
+ Arc::new(raster_array([Some(spec)])),
+ Arc::new(Int64Array::from(vec![2])),
+ Arc::new(Int64Array::from(vec![1])),
+ Arc::new(Float64Array::from(vec![7.0])),
+ ]
+ };
+ let single = RasterSpec::d2(2, 1).band_values(&[1u8, 2]);
+ let result = tester.invoke_arrays(args(single)).unwrap();
+ assert_rasters_equal(
+ &result,
+ &[Some(RasterSpec::d2(2, 1).band_values(&[1u8, 7]))],
+ );
+
+ let err = tester
+ .invoke_arrays(args(two_band()))
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("specify which band"), "{err}");
+ }
+
+ #[test]
+ fn null_arguments_give_a_null_raster() {
+ let tester = ScalarUdfTester::new(
+ rs_setvalue_udf().into(),
+ vec![RASTER, i64_t(), i64_t(), i64_t(), f64_t()],
+ );
+ let result = tester
+ .invoke_arrays(vec![
+ Arc::new(raster_array([
+ None,
+ Some(two_band()),
+ Some(two_band()),
+ Some(two_band()),
+ Some(two_band()),
+ ])),
+ Arc::new(Int64Array::from(vec![
+ Some(1),
+ None,
+ Some(1),
+ Some(1),
+ Some(1),
+ ])),
+ Arc::new(Int64Array::from(vec![
+ Some(1),
+ Some(1),
+ None,
+ Some(1),
+ Some(1),
+ ])),
+ Arc::new(Int64Array::from(vec![
+ Some(1),
+ Some(1),
+ Some(1),
+ None,
+ Some(1),
+ ])),
+ Arc::new(Float64Array::from(vec![
+ Some(1.0),
+ Some(1.0),
+ Some(1.0),
+ Some(1.0),
+ None,
+ ])),
+ ])
+ .unwrap();
+ assert_rasters_equal(&result, &[None, None, None, None, None]);
+ }
+
+ #[test]
+ fn non_2d_band_errors() {
+ let spec = RasterSpec::d2(2, 1).band_values_nd(&["time", "y", "x"], &[1, 1, 2], &[0u8, 0]);
+ let err = set(vec![Some(spec)], 1, 1, 1, 1.0).unwrap_err().to_string();
+ assert!(err.contains("2-D"), "{err}");
+ }
+}
diff --git a/rust/sedona-raster-functions/src/rs_value.rs b/rust/sedona-raster-functions/src/rs_value.rs
index fc6660fa5..d5dde1b7f 100644
--- a/rust/sedona-raster-functions/src/rs_value.rs
+++ b/rust/sedona-raster-functions/src/rs_value.rs
@@ -168,7 +168,7 @@ impl RsValuePoint {
}
}
} else {
- default_band("RS_Value", raster.num_bands())?
+ default_band("RS_Value", "2-argument", raster.num_bands())?
};
// Parse the point and bring it into the raster's CRS. Null/empty
@@ -322,7 +322,7 @@ impl RsValuePoint {
let const_band = if self.with_band {
const_band
} else {
- Some(default_band("RS_Value", raster.num_bands())?)
+ Some(default_band("RS_Value", "2-argument", raster.num_bands())?)
};
// Phase 2 — sample. A constant band resolves its buffer/nodata once (now
diff --git a/rust/sedona-raster-functions/src/rs_values.rs b/rust/sedona-raster-functions/src/rs_values.rs
index c868d3d8e..738e2d4f3 100644
--- a/rust/sedona-raster-functions/src/rs_values.rs
+++ b/rust/sedona-raster-functions/src/rs_values.rs
@@ -179,7 +179,7 @@ impl RsValues {
}
}
} else {
- default_band("RS_Values", raster.num_bands())?
+ default_band("RS_Values", "2-argument", raster.num_bands())?
};
// Resolve the band buffer, nodata, and affine transform once for
@@ -342,7 +342,7 @@ impl RsValues {
let const_band = if self.with_band {
const_band
} else {
- Some(default_band("RS_Values", raster.num_bands())?)
+ Some(default_band("RS_Values", "2-argument", raster.num_bands())?)
};
// Phase 2 — sample. A constant band resolves its buffer/nodata/2-D
diff --git a/rust/sedona-raster-functions/src/sampling.rs b/rust/sedona-raster-functions/src/sampling.rs
index 05f7f1fb6..f56187a7b 100644
--- a/rust/sedona-raster-functions/src/sampling.rs
+++ b/rust/sedona-raster-functions/src/sampling.rs
@@ -82,18 +82,19 @@ pub(crate) fn next_band(
}
}
-/// Resolve the 1-based band to sample when no band argument was given: band 1
-/// for a single-band raster, otherwise an error. Sampling an unspecified band of
-/// a multiband raster is ambiguous, so the caller must name the band rather than
+/// Resolve the 1-based band to use when no band argument was given: band 1
+/// for a single-band raster, otherwise an error. Leaving the band out on a
+/// multiband raster is ambiguous, so the caller must name the band rather than
/// silently getting band 1 (matches `RS_SetBandNoDataValue`'s 2-argument form).
-/// `func` names the calling UDF for the error message.
-pub(crate) fn default_band(func: &str, num_bands: usize) -> Result<usize> {
+/// `func` names the calling UDF and `elided_form` the argument form that leaves
+/// the band out (e.g. `"2-argument"`), both for the error message.
+pub(crate) fn default_band(func: &str, elided_form: &str, num_bands: usize) -> Result<usize> {
if num_bands == 1 {
Ok(1)
} else {
exec_err!(
- "{func}: raster has {num_bands} bands; specify which band to sample (the \
- 2-argument form is only allowed for a single-band raster)"
+ "{func}: raster has {num_bands} bands; specify which band to use (the \
+ {elided_form} form is only allowed for a single-band raster)"
)
}
}
diff --git a/rust/sedona-raster/src/traits.rs b/rust/sedona-raster/src/traits.rs
index f0303d33b..ba303bd27 100644
--- a/rust/sedona-raster/src/traits.rs
+++ b/rust/sedona-raster/src/traits.rs
@@ -769,6 +769,20 @@ pub fn nodata_f64_to_bytes(value: f64, dt: &BandDataType) -> Result<Vec<u8>, Ras
})
}
+/// Pack an `f64` pixel value into the little-endian bytes of a band's data
+/// type, the way a pixel write stores it. An integer type truncates toward zero
+/// first (3.9 is stored as 3, as a Java or Spark numeric cast does); the result
+/// must then fit the type exactly, as for [`nodata_f64_to_bytes`]: in range,
+/// not NaN, and within ±2^53 for the 64-bit integer types. `Float32` rounds to
+/// the nearest `f32`.
+pub fn pixel_f64_to_bytes(value: f64, dt: &BandDataType) -> Result<Vec<u8>, RasterError> {
+ let stored = match dt {
+ BandDataType::Float32 | BandDataType::Float64 => value,
+ _ => value.trunc(),
+ };
+ nodata_f64_to_bytes(stored, dt)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -815,6 +829,46 @@ mod tests {
nodata_f64_to_bytes(1e18, &BandDataType::Int64).unwrap_err();
}
+ #[test]
+ fn test_pixel_f64_to_bytes_truncates_toward_zero() {
+ assert_eq!(
+ pixel_f64_to_bytes(3.9, &BandDataType::UInt8).unwrap(),
+ vec![3]
+ );
+ assert_eq!(
+ pixel_f64_to_bytes(-3.9, &BandDataType::Int16).unwrap(),
+ (-3i16).to_le_bytes().to_vec()
+ );
+ // Floats are not truncated; Float32 rounds to the nearest f32.
+ assert_eq!(
+ pixel_f64_to_bytes(0.1, &BandDataType::Float32).unwrap(),
+ 0.1f32.to_le_bytes().to_vec()
+ );
+ assert_eq!(
+ pixel_f64_to_bytes(-7.25, &BandDataType::Float64).unwrap(),
+ (-7.25f64).to_le_bytes().to_vec()
+ );
+ }
+
+ #[test]
+ fn test_pixel_f64_to_bytes_rejects_what_does_not_fit() {
+ // 255.9 truncates to 255 and fits; 256 does not.
+ assert_eq!(
+ pixel_f64_to_bytes(255.9, &BandDataType::UInt8).unwrap(),
+ vec![255]
+ );
+ pixel_f64_to_bytes(256.0, &BandDataType::UInt8).unwrap_err();
+ // -0.5 truncates to 0 and fits an unsigned type; -1 does not.
+ assert_eq!(
+ pixel_f64_to_bytes(-0.5, &BandDataType::UInt8).unwrap(),
+ vec![0]
+ );
+ pixel_f64_to_bytes(-1.0, &BandDataType::UInt8).unwrap_err();
+ pixel_f64_to_bytes(f64::NAN, &BandDataType::Int32).unwrap_err();
+ pixel_f64_to_bytes(f64::INFINITY, &BandDataType::Int32).unwrap_err();
+ pixel_f64_to_bytes(1e18, &BandDataType::Int64).unwrap_err();
+ }
+
#[test]
fn test_nodata_bytes_to_f64_int8() {
let val = nodata_bytes_to_f64(&[0xFE], &BandDataType::Int8).unwrap();