Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/reference/sql/rs_geotransform.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 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_GeoTransform
description: >
Returns the raster's geotransform decomposed into a struct of six doubles,
matching Sedona Spark's RS_GeoTransform: magnitudeI and magnitudeJ (pixel
sizes along the transformed i and j axes), thetaI (rotation of the raster in
radians, positive clockwise), thetaIJ (angle from the transformed i axis to
the transformed j axis in radians, positive counter-clockwise), and offsetX
and offsetY (the upper-left corner of the upper-left pixel). For the raw
affine coefficients instead of this decomposition, use the individual
accessors (RS_ScaleX, RS_SkewX, RS_UpperLeftX, ...) or RS_GeoReference.
kernels:
- returns: struct
args: [raster]
---

## Examples

```sql
SELECT RS_GeoTransform(RS_Example());
```
3 changes: 3 additions & 0 deletions integration/spark-parity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ Useful knobs, both read by `sedonadb.testing_spark`:

## Conventions

One test file per RS_ function, named `test_rs_<function>.py` — parity coverage
for a function lands in its own file, not appended to a neighbor's.

Where the two engines are known to diverge and we intend to close the gap, mark
the case `xfail(reason=...)` rather than deleting or loosening it. The suite then
doubles as a catalog of what to fix, and flips to `xpass` the day the fix lands.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# 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 the scalar raster readers.
"""SedonaDB vs Sedona Spark parity for `RS_BandNoDataValue`.

Sedona Spark is the compatibility target, so each test runs one shared SQL
string on both engines and asserts they agree with the harness-level
Expand Down Expand Up @@ -134,7 +134,8 @@ def test_rs_band_nodata_fractional_on_int_band(tmp_path):
)
def test_rs_band_nodata_out_of_range_band(band, tmp_path):
"""An out-of-range band index gets the same answer from both engines.
Contrast the setter, which both engines refuse (see test_rs_raster_out.py)."""
Contrast the setter, which both engines refuse (see
test_rs_setbandnodatavalue.py)."""
sedona, spark = SedonaDB(), SedonaSpark()
for eng in (sedona, spark):
eng.create_random_raster_view("oob_raster", tmp_path / "oob.tif", nodata=7.0)
Expand Down
82 changes: 82 additions & 0 deletions integration/spark-parity/test_rs_geotransform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# 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_GeoTransform`.

Each test runs one shared SQL string on both engines and asserts they agree
via the harness-level `compare(sql, sedona, spark, expected=...)`; see the
README for the suite's conventions. The anchors here repeat the
implementation's floating-point operation order (both engines compute e.g.
sqrt(a*a + b*b) then divide before acos), so every expected value is
bit-exact rather than approximate.
"""

import math

from sedonadb.testing import SedonaDB, compare
from sedonadb.testing_spark import SedonaSpark


def test_rs_geotransform_north_up(tmp_path):
"""Both engines decompose a north-up geotransform into the same struct.

The default fixture bbox (100, 482, 114, 500) over a 7x6 grid gives
scaleX=2, scaleY=-3 with no skew, so the anchor is hand-derivable:
magnitudes are the pixel sizes, thetaI is acos(1) = 0, and thetaIJ is
acos(0) = pi/2 negated by its sign test (the i-to-j separation of a
y-down raster is -90 degrees)."""
sedona, spark = SedonaDB(), SedonaSpark()
for eng in (sedona, spark):
eng.create_random_raster_view("gt_raster", tmp_path / "gt.tif")
sql = "SELECT RS_GeoTransform(rast) FROM gt_raster"
anchor = {
"magnitudeI": 2.0,
"magnitudeJ": 3.0,
"thetaI": 0.0,
"thetaIJ": -math.pi / 2,
"offsetX": 100.0,
"offsetY": 500.0,
}
compare(sql, sedona, spark, expected=[(anchor,)])


def test_rs_geotransform_skewed(tmp_path):
"""A sheared transform exercises the acos sign tests in the decomposition.

skewX=5 and skewY=3 are deliberately distinct — equal skews are the
degenerate regime where the magnitudes coincide and thetaIJ collapses to
+/-pi/2. The 3-4-5 / 5-12-13 pairs keep the anchor exactly representable:
magnitudeI = sqrt(4^2 + 3^2) = 5, magnitudeJ = sqrt(12^2 + 5^2) = 13,
thetaI = -acos(4/5) (negative because skewY > 0), and thetaIJ =
-acos(-16/65) (products and magnitudes are exact, and the sign test
acos(-63/65) exceeds pi/2)."""
sedona, spark = SedonaDB(), SedonaSpark()
for eng in (sedona, spark):
eng.create_random_raster_view(
"gt_skew_raster",
tmp_path / "gt_skew.tif",
gdal_transform=(100.0, 4.0, 5.0, 500.0, 3.0, -12.0),
)
sql = "SELECT RS_GeoTransform(rast) FROM gt_skew_raster"
anchor = {
"magnitudeI": 5.0,
"magnitudeJ": 13.0,
"thetaI": -math.acos(4.0 / 5.0),
"thetaIJ": -math.acos(-16.0 / 65.0),
"offsetX": 100.0,
"offsetY": 500.0,
}
compare(sql, sedona, spark, expected=[(anchor,)])
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,26 @@
# 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 raster-in / raster-out functions.
"""SedonaDB vs Sedona Spark parity for `RS_SetBandNoDataValue`.

These exercise the raster round-trip *out* of each engine: SedonaDB decodes its
native raster column; Sedona Spark transports the result as GeoTIFF bytes
(`RS_AsGeoTiff`) and decodes it with rasterio. Both sides land as a
`DecodedRaster` (pixels + geotransform + per-band nodata), which is how the
harness-level `sedonadb.testing.compare` compares a raster result — a NULL
raster from every engine also counts as agreement. Same
`xfail`-for-known-divergence policy as the scalar suite.
`xfail`-for-known-divergence policy as the rest of the suite.

Both engines are constructed directly rather than through fixtures: this suite is
only run deliberately, so a missing pyspark, JVM, or Sedona jar should be a
failure with a real traceback, not a skip. `SedonaSpark` caches its
`SparkSession` on the class, so building one per test reuses the same JVM.

`RS_SetBandNoDataValue` is the first case on purpose: it is raster-in/raster-out
but passes pixels through untouched, so a mismatch is a round-trip bug, not an
operation divergence — it isolates the transport. Pixel-transforming ops
(RS_Resample, RS_MapAlgebra) come next, with their known divergences as xfails.
`RS_SetBandNoDataValue` was the harness's first raster-out case on purpose: it
is raster-in/raster-out but passes pixels through untouched, so a mismatch is a
round-trip bug, not an operation divergence — it isolates the transport.
Pixel-transforming ops (RS_Resample, RS_MapAlgebra) each get their own file,
with their known divergences as xfails.
"""

import pytest
Expand Down Expand Up @@ -207,7 +208,7 @@ def test_rs_setbandnodata_negative_on_uint8(tmp_path):
@pytest.mark.parametrize("band", [0, 3])
def test_rs_setbandnodata_out_of_range_band_rejected(band, tmp_path):
"""Both engines refuse an out-of-range band index — unlike the getter,
where SedonaDB returns NULL (see test_rs_scalar.py)."""
where SedonaDB returns NULL (see test_rs_bandnodatavalue.py)."""
sedona, spark = SedonaDB(), SedonaSpark()
for eng in (sedona, spark):
eng.create_random_raster_view(
Expand Down
6 changes: 5 additions & 1 deletion python/sedonadb/python/sedonadb/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,9 @@ def result_to_tuples(
Geometry columns are rendered as WKT strings. List columns (e.g. the
`List<Double>` returned by `RS_Values`) can't be cast to string, so they
pass through as Python lists and are compared by value — assert them with
an expected cell that is itself a list, e.g. ``[([1.0, None],)]``.
an expected cell that is itself a list, e.g. ``[([1.0, None],)]``. Struct
columns (e.g. the one returned by `RS_GeoTransform`) likewise pass
through, as field-name-to-value dicts.
"""
tab = self.result_to_table(result)
columns = []
Expand All @@ -302,6 +304,8 @@ def result_to_tuples(
columns.append(ga.format_wkt(col, precision=wkt_precision).to_pylist())
elif pa.types.is_list(col.type) or pa.types.is_large_list(col.type):
columns.append(col.to_pylist())
elif pa.types.is_struct(col.type):
columns.append(col.to_pylist())
else:
columns.append(col.cast(pa.string()).to_pylist())

Expand Down
30 changes: 30 additions & 0 deletions python/sedonadb/tests/functions/test_raster_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.

import math

import numpy as np
import pandas as pd
import pytest
Expand Down Expand Up @@ -241,6 +243,34 @@ def test_rs_value_matches_rasterio(con):
assert got == expected


def test_rs_geotransform():
# RS_Example's geotransform is scaleX=2, skewX=1, skewY=1, scaleY=2 with
# upper-left (43.08, 79.07). The expected struct follows Sedona Spark's
# RS_GeoTransform decomposition: magnitudes sqrt(scaleX^2 + skewY^2) and
# sqrt(scaleY^2 + skewX^2), thetaI = -acos(scaleX / magnitudeI) (negative
# because skewY > 0), and thetaIJ positive because its sign test stays
# under pi/2 for this south-up transform. The thetaIJ expression repeats
# the implementation's operation order: sqrt(5)**2 is 5.000000000000001,
# so this is acos(4 / that), not acos(0.8).
eng = SedonaDB()
magnitude = math.sqrt(5.0)
eng.assert_query_result(
"SELECT RS_GeoTransform(RS_Example())",
[
(
{
"magnitudeI": magnitude,
"magnitudeJ": magnitude,
"thetaI": -math.acos(2.0 / magnitude),
"thetaIJ": math.acos(4.0 / (magnitude * magnitude)),
"offsetX": 43.08,
"offsetY": 79.07,
},
)
],
)


def test_rs_setgeoreference_roundtrips_with_getter():
# RS_GeoReference emits scaleX, skewY, skewX, scaleY, upperLeftX, upperLeftY;
# RS_SetGeoReference accepts the same six values back (GDAL order).
Expand Down
1 change: 1 addition & 0 deletions rust/sedona-raster-functions/src/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub fn default_function_set() -> FunctionSet {
crate::rs_example::rs_example_udf,
crate::rs_georeference::rs_georeference_udf,
crate::rs_isempty::rs_isempty_udf,
crate::rs_geotransform::rs_geotransform_udf,
crate::rs_geotransform::rs_rotation_udf,
crate::rs_geotransform::rs_scalex_udf,
crate::rs_geotransform::rs_scaley_udf,
Expand Down
Loading
Loading