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();