You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by pa...@apache.org on 2026/05/22 03:40:37 UTC
(sedona-db) branch main updated: feat(rust/sedona-functions,c/sedona-proj): Support geography in CRS/SRID functions (#848)
This is an automated email from the ASF dual-hosted git repository.
paleolimbot 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 e07a2435d feat(rust/sedona-functions,c/sedona-proj): Support geography in CRS/SRID functions (#848)
e07a2435d is described below
commit e07a2435dea2828046831834339b465c33a5b1a8
Author: Dewey Dunnington <de...@dunnington.ca>
AuthorDate: Thu May 21 22:40:32 2026 -0500
feat(rust/sedona-functions,c/sedona-proj): Support geography in CRS/SRID functions (#848)
---
c/sedona-proj/src/st_transform.rs | 293 +++++++++++++++++++++++-----
rust/sedona-functions/src/st_geomfromwkb.rs | 29 ++-
rust/sedona-functions/src/st_geomfromwkt.rs | 21 +-
rust/sedona-functions/src/st_point.rs | 16 +-
rust/sedona-functions/src/st_setsrid.rs | 198 ++++++++++++++++++-
rust/sedona-geometry/src/transform.rs | 7 +
rust/sedona-schema/src/crs.rs | 270 +++++++++++++++++++++++++
rust/sedona-schema/src/datatypes.rs | 19 +-
8 files changed, 784 insertions(+), 69 deletions(-)
diff --git a/c/sedona-proj/src/st_transform.rs b/c/sedona-proj/src/st_transform.rs
index 9128485c4..f6fa9b096 100644
--- a/c/sedona-proj/src/st_transform.rs
+++ b/c/sedona-proj/src/st_transform.rs
@@ -23,13 +23,14 @@ use datafusion_common::config::ConfigOptions;
use datafusion_common::{exec_err, DataFusionError, Result, ScalarValue};
use datafusion_expr::ColumnarValue;
use sedona_common::sedona_internal_err;
-use sedona_expr::item_crs::make_item_crs;
+use sedona_expr::item_crs::{make_item_crs, parse_item_crs_arg_type};
use sedona_expr::scalar_udf::{ScalarKernelRef, SedonaScalarKernel};
use sedona_functions::executor::WkbExecutor;
+use sedona_functions::st_setsrid::{validate_crs_array_for_type, validate_crs_for_type};
use sedona_geometry::transform::{transform, CrsEngine, CrsTransform};
use sedona_geometry::wkb_factory::WKB_MIN_PROBABLE_BYTES;
use sedona_schema::crs::{deserialize_crs, Crs};
-use sedona_schema::datatypes::{Edges, SedonaType, WKB_GEOMETRY, WKB_GEOMETRY_ITEM_CRS};
+use sedona_schema::datatypes::{Edges, SedonaType};
use sedona_schema::matchers::ArgMatcher;
use std::io::Write;
use std::iter::zip;
@@ -59,35 +60,52 @@ impl SedonaScalarKernel for STTransform {
if inputs.len() == 2 {
match (inputs[0], inputs[1]) {
// ScalarCrs output always returns a Wkb output type with concrete Crs
- (ArgInput::Geo(_), ArgInput::ScalarCrs(scalar_value))
- | (ArgInput::ItemCrs, ArgInput::ScalarCrs(scalar_value)) => {
- Ok(Some(output_type_from_scalar_crs_value(scalar_value)?))
- }
+ (ArgInput::Geo(edges, _), ArgInput::ScalarCrs(scalar_value))
+ | (ArgInput::ItemCrs(edges), ArgInput::ScalarCrs(scalar_value)) => Ok(Some(
+ output_type_from_scalar_crs_value(edges, scalar_value)?,
+ )),
// Geo or ItemCrs with ArrayCrs output always return ItemCrs output
- (ArgInput::Geo(_), ArgInput::ArrayCrs)
- | (ArgInput::ItemCrs, ArgInput::ArrayCrs) => {
- Ok(Some(WKB_GEOMETRY_ITEM_CRS.clone()))
- }
+ (ArgInput::Geo(edges, _), ArgInput::ArrayCrs)
+ | (ArgInput::ItemCrs(edges), ArgInput::ArrayCrs) => Ok(Some(
+ SedonaType::new_item_crs(&SedonaType::Wkb(edges, None))?,
+ )),
_ => Ok(None),
}
} else if inputs.len() == 3 {
match (inputs[0], inputs[1], inputs[2]) {
// ScalarCrs output always returns a Wkb output type with concrete Crs
- (ArgInput::Geo(_), ArgInput::ScalarCrs(_), ArgInput::ScalarCrs(scalar_value))
- | (ArgInput::Geo(_), ArgInput::ArrayCrs, ArgInput::ScalarCrs(scalar_value))
- | (ArgInput::ItemCrs, ArgInput::ScalarCrs(_), ArgInput::ScalarCrs(scalar_value))
- | (ArgInput::ItemCrs, ArgInput::ArrayCrs, ArgInput::ScalarCrs(scalar_value)) => {
- Ok(Some(output_type_from_scalar_crs_value(scalar_value)?))
- }
+ (
+ ArgInput::Geo(edges, _),
+ ArgInput::ScalarCrs(_),
+ ArgInput::ScalarCrs(scalar_value),
+ )
+ | (
+ ArgInput::Geo(edges, _),
+ ArgInput::ArrayCrs,
+ ArgInput::ScalarCrs(scalar_value),
+ )
+ | (
+ ArgInput::ItemCrs(edges),
+ ArgInput::ScalarCrs(_),
+ ArgInput::ScalarCrs(scalar_value),
+ )
+ | (
+ ArgInput::ItemCrs(edges),
+ ArgInput::ArrayCrs,
+ ArgInput::ScalarCrs(scalar_value),
+ ) => Ok(Some(output_type_from_scalar_crs_value(
+ edges,
+ scalar_value,
+ )?)),
// Geo or ItemCrs with ArrayCrs output always return ItemCrs output
- (ArgInput::Geo(_), ArgInput::ScalarCrs(_), ArgInput::ArrayCrs)
- | (ArgInput::Geo(_), ArgInput::ArrayCrs, ArgInput::ArrayCrs)
- | (ArgInput::ItemCrs, ArgInput::ScalarCrs(_), ArgInput::ArrayCrs)
- | (ArgInput::ItemCrs, ArgInput::ArrayCrs, ArgInput::ArrayCrs) => {
- Ok(Some(WKB_GEOMETRY_ITEM_CRS.clone()))
- }
+ (ArgInput::Geo(edges, _), ArgInput::ScalarCrs(_), ArgInput::ArrayCrs)
+ | (ArgInput::Geo(edges, _), ArgInput::ArrayCrs, ArgInput::ArrayCrs)
+ | (ArgInput::ItemCrs(edges), ArgInput::ScalarCrs(_), ArgInput::ArrayCrs)
+ | (ArgInput::ItemCrs(edges), ArgInput::ArrayCrs, ArgInput::ArrayCrs) => Ok(Some(
+ SedonaType::new_item_crs(&SedonaType::Wkb(edges, None))?,
+ )),
_ => Ok(None),
}
} else {
@@ -99,7 +117,7 @@ impl SedonaScalarKernel for STTransform {
&self,
arg_types: &[SedonaType],
args: &[ColumnarValue],
- _return_type: &SedonaType,
+ return_type: &SedonaType,
_num_rows: usize,
_config_options: Option<&ConfigOptions>,
) -> Result<ColumnarValue> {
@@ -113,6 +131,8 @@ impl SedonaScalarKernel for STTransform {
WKB_MIN_PROBABLE_BYTES * executor.num_iterations(),
);
+ let (return_item_type, _) = parse_item_crs_arg_type(return_type)?;
+
// Optimize the easy case, where we have exactly one transformation and there are no
// null or missing CRSes to contend with.
let from_index = inputs.len() - 2;
@@ -123,6 +143,11 @@ impl SedonaScalarKernel for STTransform {
let maybe_from_crs = deserialize_crs(&from_constant)?;
let maybe_to_crs = deserialize_crs(&to_constant)?;
if let (Some(from_crs), Some(to_crs)) = (maybe_from_crs, maybe_to_crs) {
+ // Validate the target CRS is valid for the output type (e.g., geography
+ // requires a geographic CRS)
+ let to_crs_opt: Crs = Some(to_crs.clone());
+ validate_crs_for_type(&to_crs_opt, &return_item_type)?;
+
with_global_proj_engine(|engine| {
let crs_transform = engine
.get_transform_crs_to_crs(
@@ -148,9 +173,15 @@ impl SedonaScalarKernel for STTransform {
}
}
- // Iterate over pairs of CRS strings
+ // Get the CRS input arrays
let from_crs_array = from.crs_array(&args[from_index], executor.num_iterations())?;
let to_crs_array = to.crs_array(&args[to_index], executor.num_iterations())?;
+
+ // Validate the to_crs_array if needed to ensure the output CRS is valid
+ // for geography types
+ validate_crs_array_for_type(&to_crs_array, &return_item_type)?;
+
+ // Iterate over pairs of CRS strings
let from_crs_string_view_array = as_string_view_array(&from_crs_array)?;
let to_crs_string_view_array = as_string_view_array(&to_crs_array)?;
let mut crs_to_crs_iter = zip(from_crs_string_view_array, to_crs_string_view_array);
@@ -213,7 +244,7 @@ impl SedonaScalarKernel for STTransform {
let output_geometry = executor.finish(Arc::new(builder.finish()))?;
if let Some(mut crs_output) = maybe_crs_output {
let output_crs = executor.finish(Arc::new(crs_output.finish()))?;
- make_item_crs(&WKB_GEOMETRY, output_geometry, &output_crs, None)
+ make_item_crs(&return_item_type, output_geometry, &output_crs, None)
} else {
Ok(output_geometry)
}
@@ -232,11 +263,14 @@ impl SedonaScalarKernel for STTransform {
}
}
-fn output_type_from_scalar_crs_value(scalar_arg: &ScalarValue) -> Result<SedonaType> {
+fn output_type_from_scalar_crs_value(
+ input_edges: Edges,
+ scalar_arg: &ScalarValue,
+) -> Result<SedonaType> {
if let Some(crs_str) = parse_crs_from_scalar_crs_value(scalar_arg)? {
- Ok(SedonaType::Wkb(Edges::Planar, deserialize_crs(&crs_str)?))
+ Ok(SedonaType::Wkb(input_edges, deserialize_crs(&crs_str)?))
} else {
- Ok(WKB_GEOMETRY)
+ Ok(SedonaType::Wkb(input_edges, None))
}
}
@@ -271,13 +305,12 @@ fn invoke_scalar(wkb: &Wkb, trans: &dyn CrsTransform, builder: &mut impl Write)
/// Helper to label arguments because we have a lot argument types that are valid
#[derive(Debug, Clone, Copy)]
enum ArgInput<'a> {
- /// Geometry input. This currently only matches geometry and not geography
- /// because CRS support for geography is less clear at the moment. Must be
+ /// Geometry or geography input. Must be
/// the first argument (and not supported for other arguments).
- Geo(&'a Crs),
+ Geo(Edges, &'a Crs),
/// Item-level CRS input. Must be the first argument if present (not supported
/// for other arguments).
- ItemCrs,
+ ItemCrs(Edges),
/// Scalar CRS input. Supported for second and third arguments. When present
/// as the last argument (to), this forces type-level CRS output.
ScalarCrs(&'a ScalarValue),
@@ -290,9 +323,15 @@ enum ArgInput<'a> {
impl<'a> ArgInput<'a> {
fn from_return_type_arg(arg_type: &'a SedonaType, scalar_arg: Option<&'a ScalarValue>) -> Self {
- if ArgMatcher::is_item_crs().match_type(arg_type) {
- Self::ItemCrs
- } else if ArgMatcher::is_numeric().match_type(arg_type)
+ if let Ok((SedonaType::Wkb(edges, _) | SedonaType::WkbView(edges, _), maybe_crs_type)) =
+ parse_item_crs_arg_type(arg_type)
+ {
+ if maybe_crs_type.is_some() {
+ return Self::ItemCrs(edges);
+ }
+ }
+
+ if ArgMatcher::is_numeric().match_type(arg_type)
|| ArgMatcher::is_string().match_type(arg_type)
{
if let Some(scalar_crs) = scalar_arg {
@@ -302,8 +341,8 @@ impl<'a> ArgInput<'a> {
}
} else {
match arg_type {
- SedonaType::Wkb(Edges::Planar, crs) | SedonaType::WkbView(Edges::Planar, crs) => {
- Self::Geo(crs)
+ SedonaType::Wkb(edges, crs) | SedonaType::WkbView(edges, crs) => {
+ Self::Geo(*edges, crs)
}
_ => Self::Unsupported,
}
@@ -311,9 +350,15 @@ impl<'a> ArgInput<'a> {
}
fn from_arg(arg_type: &'a SedonaType, arg: &'a ColumnarValue) -> Self {
- if ArgMatcher::is_item_crs().match_type(arg_type) {
- Self::ItemCrs
- } else if ArgMatcher::is_numeric().match_type(arg_type)
+ if let Ok((SedonaType::Wkb(edges, _) | SedonaType::WkbView(edges, _), maybe_crs_type)) =
+ parse_item_crs_arg_type(arg_type)
+ {
+ if maybe_crs_type.is_some() {
+ return Self::ItemCrs(edges);
+ }
+ }
+
+ if ArgMatcher::is_numeric().match_type(arg_type)
|| ArgMatcher::is_string().match_type(arg_type)
{
match arg {
@@ -322,7 +367,9 @@ impl<'a> ArgInput<'a> {
}
} else {
match arg_type {
- SedonaType::Wkb(_, crs) | SedonaType::WkbView(_, crs) => Self::Geo(crs),
+ SedonaType::Wkb(edges, crs) | SedonaType::WkbView(edges, crs) => {
+ Self::Geo(*edges, crs)
+ }
_ => Self::Unsupported,
}
}
@@ -330,7 +377,7 @@ impl<'a> ArgInput<'a> {
fn crs_constant(&self) -> Result<Option<String>> {
match self {
- ArgInput::Geo(crs) => {
+ ArgInput::Geo(_, crs) => {
let crs_str = if let Some(crs) = crs {
crs.to_crs_string()
} else {
@@ -347,7 +394,7 @@ impl<'a> ArgInput<'a> {
fn crs_array(&self, arg: &ColumnarValue, iterations: usize) -> Result<ArrayRef> {
if let Some(crs_constant) = self.crs_constant()? {
ScalarValue::Utf8View(Some(crs_constant)).to_array_of_size(iterations)
- } else if matches!(self, Self::ItemCrs) {
+ } else if matches!(self, Self::ItemCrs(_)) {
match arg {
ColumnarValue::Array(array) => {
let struct_array = as_struct_array(array)?;
@@ -375,7 +422,9 @@ mod tests {
use sedona_expr::scalar_udf::SedonaScalarUDF;
use sedona_schema::crs::lnglat;
use sedona_schema::crs::Crs;
- use sedona_schema::datatypes::WKB_GEOMETRY;
+ use sedona_schema::datatypes::{
+ WKB_GEOGRAPHY, WKB_GEOGRAPHY_ITEM_CRS, WKB_GEOMETRY, WKB_GEOMETRY_ITEM_CRS,
+ };
use sedona_testing::compare::assert_array_equal;
use sedona_testing::create::create_array;
use sedona_testing::create::create_array_item_crs;
@@ -383,6 +432,7 @@ mod tests {
use sedona_testing::testers::ScalarUdfTester;
const NAD83ZONE6PROJ: &str = "EPSG:2230";
+ const NAD83_GEOGRAPHIC: &str = "EPSG:4269";
const WGS84: &str = "EPSG:4326";
#[test]
@@ -426,6 +476,42 @@ mod tests {
assert_array_equal(&result, &expected_array);
}
+ #[test]
+ fn test_invoke_with_string_geography() {
+ let udf = SedonaScalarUDF::from_impl("st_transform", st_transform_impl());
+ let geography_input = SedonaType::Wkb(Edges::Spherical, lnglat());
+ let tester = ScalarUdfTester::new(
+ udf.into(),
+ vec![geography_input.clone(), SedonaType::Arrow(DataType::Utf8)],
+ );
+
+ // Transform to geographic CRS should succeed
+ let expected_return_type = SedonaType::Wkb(Edges::Spherical, get_crs(NAD83_GEOGRAPHIC));
+ let return_type = tester
+ .return_type_with_scalar_scalar(Option::<&str>::None, Some(NAD83_GEOGRAPHIC))
+ .unwrap();
+ assert_eq!(return_type, expected_return_type);
+
+ // Return type with array to argument (returns item CRS)
+ let return_type = tester.return_type().unwrap();
+ assert_eq!(return_type, WKB_GEOGRAPHY_ITEM_CRS.clone());
+
+ // Invoke with geographic CRS should succeed
+ let wkb = create_array(&[None, Some("POINT (79.3871 43.6426)")], &geography_input);
+ let result = tester
+ .invoke_array_scalar(wkb.clone(), NAD83_GEOGRAPHIC)
+ .unwrap();
+ assert_eq!(result.len(), 2);
+
+ // Transform to projected CRS should fail for geography
+ let err = tester.invoke_array_scalar(wkb, NAD83ZONE6PROJ).unwrap_err();
+ assert!(
+ err.message().contains("Can't assign non-geographic CRS"),
+ "Expected error about non-geographic CRS, got: {}",
+ err.message()
+ );
+ }
+
#[test]
fn test_invoke_with_srid() {
let udf = SedonaScalarUDF::from_impl("st_transform", st_transform_impl());
@@ -467,6 +553,36 @@ mod tests {
assert_array_equal(&result, &expected_array);
}
+ #[test]
+ fn test_invoke_with_srid_geography() {
+ let udf = SedonaScalarUDF::from_impl("st_transform", st_transform_impl());
+ let geography_input = SedonaType::Wkb(Edges::Spherical, lnglat());
+ let tester = ScalarUdfTester::new(
+ udf.into(),
+ vec![geography_input.clone(), SedonaType::Arrow(DataType::UInt32)],
+ );
+
+ // Transform to geographic CRS (4269 = NAD83) should succeed
+ let expected_return_type = SedonaType::Wkb(Edges::Spherical, get_crs(NAD83_GEOGRAPHIC));
+ let return_type = tester
+ .return_type_with_scalar_scalar(Option::<&str>::None, Some(4269))
+ .unwrap();
+ assert_eq!(return_type, expected_return_type);
+
+ // Invoke with geographic SRID should succeed
+ let wkb = create_array(&[None, Some("POINT (79.3871 43.6426)")], &geography_input);
+ let result = tester.invoke_array_scalar(wkb.clone(), 4269).unwrap();
+ assert_eq!(result.len(), 2);
+
+ // Transform to projected SRID (2230) should fail for geography
+ let err = tester.invoke_array_scalar(wkb, 2230).unwrap_err();
+ assert!(
+ err.message().contains("Can't assign non-geographic CRS"),
+ "Expected error about non-geographic CRS, got: {}",
+ err.message()
+ );
+ }
+
#[test]
fn test_invoke_with_item_crs() {
let udf = SedonaScalarUDF::from_impl("st_transform", st_transform_impl());
@@ -520,6 +636,48 @@ mod tests {
assert_array_equal(&result, &expected_array);
}
+ #[test]
+ fn test_invoke_with_item_crs_geography() {
+ let udf = SedonaScalarUDF::from_impl("st_transform", st_transform_impl());
+ let geography_input = WKB_GEOGRAPHY_ITEM_CRS.clone();
+ let tester = ScalarUdfTester::new(
+ udf.into(),
+ vec![geography_input.clone(), SedonaType::Arrow(DataType::Utf8)],
+ );
+
+ // Transform to geographic CRS should succeed
+ let expected_return_type = SedonaType::Wkb(Edges::Spherical, get_crs(NAD83_GEOGRAPHIC));
+ let return_type = tester
+ .return_type_with_scalar_scalar(Option::<&str>::None, Some(NAD83_GEOGRAPHIC))
+ .unwrap();
+ assert_eq!(return_type, expected_return_type);
+
+ // Return type with array to argument (returns item CRS)
+ let return_type = tester.return_type().unwrap();
+ assert_eq!(return_type, WKB_GEOGRAPHY_ITEM_CRS.clone());
+
+ // Invoke with geographic CRS should succeed
+ let array_in = create_array_item_crs(
+ &[None, Some("POINT (79.3871 43.6426)")],
+ [None, Some("EPSG:4326")],
+ &WKB_GEOGRAPHY,
+ );
+ let result = tester
+ .invoke_array_scalar(array_in.clone(), NAD83_GEOGRAPHIC)
+ .unwrap();
+ assert_eq!(result.len(), 2);
+
+ // Transform to projected CRS should fail for geography
+ let err = tester
+ .invoke_array_scalar(array_in, NAD83ZONE6PROJ)
+ .unwrap_err();
+ assert!(
+ err.message().contains("Can't assign non-geographic CRS"),
+ "Expected error about non-geographic CRS, got: {}",
+ err.message()
+ );
+ }
+
#[rstest]
fn test_invoke_source_arg() {
let udf = SedonaScalarUDF::from_impl("st_transform", st_transform_impl());
@@ -577,6 +735,53 @@ mod tests {
assert_array_equal(&result, &expected_array);
}
+ #[rstest]
+ fn test_invoke_source_arg_geography() {
+ let udf = SedonaScalarUDF::from_impl("st_transform", st_transform_impl());
+ let geography_input = WKB_GEOGRAPHY;
+ let tester = ScalarUdfTester::new(
+ udf.into(),
+ vec![
+ geography_input.clone(),
+ SedonaType::Arrow(DataType::Utf8),
+ SedonaType::Arrow(DataType::Utf8),
+ ],
+ );
+
+ // Transform to geographic CRS should succeed
+ let expected_return_type = SedonaType::Wkb(Edges::Spherical, get_crs(NAD83_GEOGRAPHIC));
+ let return_type = tester
+ .return_type_with_scalar_scalar_scalar(
+ Option::<&str>::None,
+ Option::<&str>::None,
+ Some(NAD83_GEOGRAPHIC),
+ )
+ .unwrap();
+ assert_eq!(return_type, expected_return_type);
+
+ // Return type with array to argument (returns item CRS)
+ let return_type = tester.return_type().unwrap();
+ assert_eq!(return_type, WKB_GEOGRAPHY_ITEM_CRS.clone());
+
+ // Invoke with geographic CRS should succeed
+ let array_in = create_array(&[None, Some("POINT (79.3871 43.6426)")], &geography_input);
+ let crs_from = create_array!(Utf8, [None, Some(WGS84)]) as ArrayRef;
+ let result = tester
+ .invoke_array_array_scalar(array_in.clone(), crs_from.clone(), NAD83_GEOGRAPHIC)
+ .unwrap();
+ assert_eq!(result.len(), 2);
+
+ // Transform to projected CRS should fail for geography
+ let err = tester
+ .invoke_array_array_scalar(array_in, crs_from, NAD83ZONE6PROJ)
+ .unwrap_err();
+ assert!(
+ err.message().contains("Can't assign non-geographic CRS"),
+ "Expected error about non-geographic CRS, got: {}",
+ err.message()
+ );
+ }
+
#[test]
fn test_invoke_null_crs_to() {
let udf = SedonaScalarUDF::from_impl("st_transform", st_transform_impl());
diff --git a/rust/sedona-functions/src/st_geomfromwkb.rs b/rust/sedona-functions/src/st_geomfromwkb.rs
index c5a8803d0..3e85b9a6d 100644
--- a/rust/sedona-functions/src/st_geomfromwkb.rs
+++ b/rust/sedona-functions/src/st_geomfromwkb.rs
@@ -21,7 +21,9 @@ use datafusion_common::error::Result;
use datafusion_expr::{ColumnarValue, Volatility};
use sedona_expr::scalar_udf::{SedonaScalarKernel, SedonaScalarUDF};
use sedona_schema::{
- datatypes::{SedonaType, WKB_GEOMETRY, WKB_VIEW_GEOGRAPHY, WKB_VIEW_GEOMETRY},
+ datatypes::{
+ SedonaType, WKB_GEOMETRY, WKB_VIEW_GEOGRAPHY, WKB_VIEW_GEOGRAPHY_WGS84, WKB_VIEW_GEOMETRY,
+ },
matchers::ArgMatcher,
};
@@ -61,12 +63,22 @@ pub fn st_geomfromwkbunchecked_udf() -> SedonaScalarUDF {
///
/// An implementation of WKB reading using GeoRust's wkb crate.
pub fn st_geogfromwkb_udf() -> SedonaScalarUDF {
+ // Inner kernel for SRIDified has no CRS - the SRID argument sets it
+ let inner_kernel = Arc::new(STGeomFromWKB {
+ validate: true,
+ out_type: WKB_VIEW_GEOGRAPHY,
+ });
+ let sridified_kernel = Arc::new(SRIDifiedKernel::new(inner_kernel));
+
+ // Standalone kernel returns WGS84 CRS by default
+ let standalone_kernel = Arc::new(STGeomFromWKB {
+ validate: true,
+ out_type: WKB_VIEW_GEOGRAPHY_WGS84.clone(),
+ });
+
SedonaScalarUDF::new(
"st_geogfromwkb",
- vec![Arc::new(STGeomFromWKB {
- validate: true,
- out_type: WKB_VIEW_GEOGRAPHY,
- })],
+ vec![sridified_kernel, standalone_kernel],
Volatility::Immutable,
)
}
@@ -328,11 +340,14 @@ mod tests {
let udf = st_geogfromwkb_udf();
let tester = ScalarUdfTester::new(udf.into(), vec![SedonaType::Arrow(DataType::Binary)]);
- assert_eq!(tester.return_type().unwrap(), WKB_VIEW_GEOGRAPHY);
+ assert_eq!(
+ tester.return_type().unwrap(),
+ WKB_VIEW_GEOGRAPHY_WGS84.clone()
+ );
assert_scalar_equal(
&tester.invoke_scalar(POINT12.to_vec()).unwrap(),
- &create_scalar(Some("POINT (1 2)"), &WKB_VIEW_GEOGRAPHY),
+ &create_scalar(Some("POINT (1 2)"), &WKB_VIEW_GEOGRAPHY_WGS84),
);
}
}
diff --git a/rust/sedona-functions/src/st_geomfromwkt.rs b/rust/sedona-functions/src/st_geomfromwkt.rs
index 79e4c084e..12b7bd17a 100644
--- a/rust/sedona-functions/src/st_geomfromwkt.rs
+++ b/rust/sedona-functions/src/st_geomfromwkt.rs
@@ -28,7 +28,7 @@ use sedona_expr::item_crs::make_item_crs;
use sedona_expr::scalar_udf::{SedonaScalarKernel, SedonaScalarUDF};
use sedona_geometry::wkb_factory::WKB_MIN_PROBABLE_BYTES;
use sedona_schema::{
- datatypes::{SedonaType, WKB_GEOGRAPHY, WKB_GEOMETRY},
+ datatypes::{SedonaType, WKB_GEOGRAPHY, WKB_GEOGRAPHY_WGS84, WKB_GEOMETRY},
matchers::ArgMatcher,
};
use wkb::writer::{write_geometry, WriteOptions};
@@ -64,11 +64,20 @@ pub fn st_geomfromwkt_udf() -> SedonaScalarUDF {
/// An implementation of WKT reading using GeoRust's wkt crate.
/// See [`st_geomfromwkt_udf`] for the corresponding geometry function.
pub fn st_geogfromwkt_udf() -> SedonaScalarUDF {
+ // Inner kernel for SRIDified has no CRS - the SRID argument sets it
+ let inner_kernel = Arc::new(STGeoFromWKT {
+ out_type: WKB_GEOGRAPHY,
+ });
+ let sridified_kernel = Arc::new(SRIDifiedKernel::new(inner_kernel));
+
+ // Standalone kernel returns WGS84 CRS by default
+ let standalone_kernel = Arc::new(STGeoFromWKT {
+ out_type: WKB_GEOGRAPHY_WGS84.clone(),
+ });
+
let udf = SedonaScalarUDF::new(
"st_geogfromwkt",
- vec![Arc::new(STGeoFromWKT {
- out_type: WKB_GEOGRAPHY,
- })],
+ vec![sridified_kernel, standalone_kernel],
Volatility::Immutable,
);
udf.with_aliases(vec!["st_geogfromtext".to_string()])
@@ -444,10 +453,10 @@ mod tests {
fn geog() {
let udf = st_geogfromwkt_udf();
let tester = ScalarUdfTester::new(udf.into(), vec![SedonaType::Arrow(DataType::Utf8)]);
- assert_eq!(tester.return_type().unwrap(), WKB_GEOGRAPHY);
+ assert_eq!(tester.return_type().unwrap(), *WKB_GEOGRAPHY_WGS84);
assert_scalar_equal(
&tester.invoke_scalar("POINT (1 2)").unwrap(),
- &create_scalar(Some("POINT (1 2)"), &WKB_GEOGRAPHY),
+ &create_scalar(Some("POINT (1 2)"), &WKB_GEOGRAPHY_WGS84),
);
}
diff --git a/rust/sedona-functions/src/st_point.rs b/rust/sedona-functions/src/st_point.rs
index c9cab7ab9..1863f73f7 100644
--- a/rust/sedona-functions/src/st_point.rs
+++ b/rust/sedona-functions/src/st_point.rs
@@ -24,7 +24,7 @@ use datafusion_common::scalar::ScalarValue;
use datafusion_expr::{ColumnarValue, Volatility};
use sedona_expr::scalar_udf::{SedonaScalarKernel, SedonaScalarUDF};
use sedona_schema::{
- datatypes::{SedonaType, WKB_GEOGRAPHY, WKB_GEOMETRY},
+ datatypes::{SedonaType, WKB_GEOGRAPHY, WKB_GEOGRAPHY_WGS84, WKB_GEOMETRY},
matchers::ArgMatcher,
};
@@ -52,14 +52,20 @@ pub fn st_point_udf() -> SedonaScalarUDF {
/// Native implementation to create geometries from coordinates.
/// See [`st_geogpoint_udf`] for the corresponding geography constructor.
pub fn st_geogpoint_udf() -> SedonaScalarUDF {
- let kernel = Arc::new(STGeoFromPoint {
+ // Inner kernel for SRIDified has no CRS - the SRID argument sets it
+ let inner_kernel = Arc::new(STGeoFromPoint {
out_type: WKB_GEOGRAPHY,
});
- let sridified_kernel = Arc::new(SRIDifiedKernel::new(kernel.clone()));
+ let sridified_kernel = Arc::new(SRIDifiedKernel::new(inner_kernel));
+
+ // Standalone kernel returns WGS84 CRS by default
+ let standalone_kernel = Arc::new(STGeoFromPoint {
+ out_type: WKB_GEOGRAPHY_WGS84.clone(),
+ });
SedonaScalarUDF::new(
"st_geogpoint",
- vec![sridified_kernel, kernel],
+ vec![sridified_kernel, standalone_kernel],
Volatility::Immutable,
)
}
@@ -357,7 +363,7 @@ mod tests {
],
);
- tester.assert_return_type(WKB_GEOGRAPHY);
+ tester.assert_return_type(WKB_GEOGRAPHY_WGS84.clone());
let result = tester.invoke_scalar_scalar(1.0, 2.0).unwrap();
tester.assert_scalar_result_equals(result, "POINT (1 2)");
}
diff --git a/rust/sedona-functions/src/st_setsrid.rs b/rust/sedona-functions/src/st_setsrid.rs
index b00fc4083..c8bb8790a 100644
--- a/rust/sedona-functions/src/st_setsrid.rs
+++ b/rust/sedona-functions/src/st_setsrid.rs
@@ -22,12 +22,12 @@ use arrow_array::{
};
use arrow_buffer::NullBuffer;
use arrow_schema::DataType;
-use datafusion_common::config::ConfigOptions;
use datafusion_common::{
cast::{as_int64_array, as_string_view_array},
error::Result,
- DataFusionError, ScalarValue,
+ exec_err, DataFusionError, ScalarValue,
};
+use datafusion_common::{config::ConfigOptions, plan_err};
use datafusion_expr::{ColumnarValue, Volatility};
use sedona_common::sedona_internal_err;
use sedona_expr::{
@@ -39,8 +39,8 @@ use sedona_expr::{
};
use sedona_geometry::transform::CrsEngine;
use sedona_schema::{
- crs::{deserialize_crs, CachedCrsNormalization, CachedSRIDToCrs},
- datatypes::SedonaType,
+ crs::{deserialize_crs, CachedCrsNormalization, CachedSRIDToCrs, Crs},
+ datatypes::{Edges, SedonaType},
matchers::ArgMatcher,
};
@@ -214,6 +214,7 @@ fn invoke_set_crs(
let item_crs_matcher = ArgMatcher::is_item_crs();
if item_crs_matcher.match_type(return_type) {
let normalized_crs_value = normalize_crs_array(crs_arg, maybe_engine)?;
+ validate_crs_array_for_type(&normalized_crs_value, item_type)?;
make_item_crs(
item_type,
item_arg,
@@ -260,6 +261,8 @@ fn determine_return_type(
None => None,
};
+ validate_crs_for_type(&new_crs, &item_type)?;
+
match item_type {
SedonaType::Wkb(edges, _) => Ok(Some(SedonaType::Wkb(edges, new_crs))),
SedonaType::WkbView(edges, _) => Ok(Some(SedonaType::WkbView(edges, new_crs))),
@@ -334,6 +337,9 @@ impl SedonaScalarKernel for SRIDifiedKernel {
}
};
+ // Check that the CRS is valid for the target type
+ validate_crs_for_type(&new_crs, &inner_result)?;
+
match &mut inner_result {
SedonaType::Wkb(_, crs) => *crs = new_crs,
SedonaType::WkbView(_, crs) => *crs = new_crs,
@@ -404,6 +410,7 @@ impl SedonaScalarKernel for SRIDifiedKernel {
} else {
let (item_type, _) = parse_item_crs_arg_type(return_type)?;
let normalized_crs_value = normalize_crs_array(&args[orig_args_len], None)?;
+ validate_crs_array_for_type(&normalized_crs_value, &item_type)?;
make_item_crs(
&item_type,
result,
@@ -534,6 +541,67 @@ pub fn validate_crs(
Ok(())
}
+/// Given a resolved [Crs], validate that it is appropriate for a given output
+///
+/// This is primarily to enforce that the geography type may only have a
+/// geographic CRS. The unset CRS is also allowed for now but this may be
+/// updated in the future (in general a Geography type should always have a
+/// CRS).
+pub fn validate_crs_for_type(crs: &Crs, sedona_type: &SedonaType) -> Result<()> {
+ let edges = match sedona_type {
+ SedonaType::Wkb(edges, _) | SedonaType::WkbView(edges, _) => edges,
+ _ => {
+ return sedona_internal_err!(
+ "Non-geometry type in CRS--type validation: {sedona_type}"
+ );
+ }
+ };
+
+ // For geography, ensure the CRS is geographical if present
+ if !matches!(edges, Edges::Planar) {
+ if let Some(crs) = crs {
+ if crs.geographic_params()?.is_none() {
+ return plan_err!(
+ "Can't assign non-geographic CRS {crs} to column of type {sedona_type}"
+ );
+ }
+ }
+ }
+
+ Ok(())
+}
+
+/// Given an array of CRSes (pre-normalized to stringview) check validity against type
+///
+/// This check is skipped for Geometry but occurs for Geography to ensure the
+/// target types are valid.
+pub fn validate_crs_array_for_type(crs_array: &ArrayRef, sedona_type: &SedonaType) -> Result<()> {
+ let edges = match sedona_type {
+ SedonaType::Wkb(edges, _) | SedonaType::WkbView(edges, _) => edges,
+ _ => {
+ return sedona_internal_err!(
+ "Non-geometry type in CRS--type validation: {sedona_type}"
+ );
+ }
+ };
+
+ // For geography, ensure the CRS is geographical if present
+ if !matches!(edges, Edges::Planar) {
+ let crs_array_stringview = as_string_view_array(crs_array)?;
+ for item in crs_array_stringview.iter().flatten() {
+ if let Some(crs) = deserialize_crs(item)? {
+ if crs.geographic_params()?.is_none() {
+ return exec_err!(
+ "Can't assign non-geographic CRS item {item} to column of type {sedona_type}"
+ );
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
+
#[cfg(test)]
mod test {
use std::rc::Rc;
@@ -546,7 +614,9 @@ mod test {
use sedona_geometry::{error::SedonaGeometryError, transform::CrsTransform};
use sedona_schema::{
crs::lnglat,
- datatypes::{Edges, WKB_GEOMETRY, WKB_GEOMETRY_ITEM_CRS},
+ datatypes::{
+ Edges, WKB_GEOGRAPHY, WKB_GEOGRAPHY_ITEM_CRS, WKB_GEOMETRY, WKB_GEOMETRY_ITEM_CRS,
+ },
};
use sedona_testing::{
compare::assert_value_equal,
@@ -660,6 +730,56 @@ mod test {
assert_eq!(err.message(), "Unknown geometry error")
}
+ #[test]
+ fn udf_crs_geography() {
+ let udf: ScalarUDF = st_set_crs_udf().into();
+
+ let wkb_geography_lnglat = SedonaType::Wkb(Edges::Spherical, lnglat());
+ let geog_arg = create_scalar_value(Some("POINT (0 1)"), &WKB_GEOGRAPHY);
+ let geog_lnglat = create_scalar_value(Some("POINT (0 1)"), &wkb_geography_lnglat);
+
+ let geographic_crs_scalar = ScalarValue::Utf8(Some("EPSG:4326".to_string()));
+ let projected_crs_scalar = ScalarValue::Utf8(Some("EPSG:3857".to_string()));
+ let null_crs_scalar = ScalarValue::Utf8(None);
+
+ // Call with a geographic CRS (should succeed for geography)
+ let (return_type, result) = call_udf(
+ &udf,
+ geog_arg.clone(),
+ &[WKB_GEOGRAPHY, SedonaType::Arrow(DataType::Utf8)],
+ geographic_crs_scalar.clone(),
+ )
+ .unwrap();
+ assert_eq!(return_type, wkb_geography_lnglat);
+ assert_value_equal(&result, &geog_lnglat);
+
+ // Call with a null scalar destination (result should be NULL per SQL NULL propagation)
+ let (return_type, result) = call_udf(
+ &udf,
+ geog_arg.clone(),
+ &[WKB_GEOGRAPHY, SedonaType::Arrow(DataType::Utf8)],
+ null_crs_scalar.clone(),
+ )
+ .unwrap();
+ assert_eq!(return_type, WKB_GEOGRAPHY);
+ let null_geog = create_scalar_value(None, &WKB_GEOGRAPHY);
+ assert_value_equal(&result, &null_geog);
+
+ // Call with a projected CRS (should fail for geography - only geographic CRS allowed)
+ let err = call_udf(
+ &udf,
+ geog_arg.clone(),
+ &[WKB_GEOGRAPHY, SedonaType::Arrow(DataType::Utf8)],
+ projected_crs_scalar.clone(),
+ )
+ .unwrap_err();
+ assert!(
+ err.message().contains("Can't assign non-geographic CRS"),
+ "Expected error about non-geographic CRS, got: {}",
+ err.message()
+ );
+ }
+
#[rstest]
fn udf_item_srid_output(
#[values(WKB_GEOMETRY, WKB_GEOMETRY_ITEM_CRS.clone())] sedona_type: SedonaType,
@@ -764,6 +884,74 @@ mod test {
);
}
+ #[rstest]
+ fn udf_item_crs_output_geography(
+ #[values(WKB_GEOGRAPHY, WKB_GEOGRAPHY_ITEM_CRS.clone())] sedona_type: SedonaType,
+ ) {
+ let tester = ScalarUdfTester::new(
+ st_set_crs_udf().into(),
+ vec![sedona_type.clone(), SedonaType::Arrow(DataType::Utf8)],
+ );
+ tester.assert_return_type(WKB_GEOGRAPHY_ITEM_CRS.clone());
+
+ // Test with all geographic CRSes (should succeed)
+ let geography_array = create_array(
+ &[
+ Some("POINT (0 1)"),
+ Some("POINT (2 3)"),
+ Some("POINT (4 5)"),
+ Some("POINT (6 7)"),
+ ],
+ &sedona_type,
+ );
+ let geographic_crs_array = create_array!(
+ Utf8,
+ [
+ Some("EPSG:4326"),
+ Some("EPSG:4269"), // NAD83 - geographic
+ Some("0"), // unset
+ None
+ ]
+ ) as ArrayRef;
+
+ let result = tester
+ .invoke_array_array(geography_array.clone(), geographic_crs_array)
+ .unwrap();
+ assert_eq!(
+ &result,
+ &create_array_item_crs(
+ &[
+ Some("POINT (0 1)"),
+ Some("POINT (2 3)"),
+ Some("POINT (4 5)"),
+ None
+ ],
+ [Some("OGC:CRS84"), Some("EPSG:4269"), None, None],
+ &WKB_GEOGRAPHY
+ )
+ );
+
+ // Test with a projected CRS (should fail for geography)
+ let projected_crs_array = create_array!(
+ Utf8,
+ [
+ Some("EPSG:4326"),
+ Some("EPSG:3857"), // Web Mercator - projected, should fail
+ Some("EPSG:4326"),
+ Some("EPSG:4326")
+ ]
+ ) as ArrayRef;
+
+ let err = tester
+ .invoke_array_array(geography_array, projected_crs_array)
+ .unwrap_err();
+ assert!(
+ err.message().contains("Can't assign non-geographic CRS"),
+ "Expected error about non-geographic CRS, got: {}",
+ err.message()
+ );
+ }
+
fn call_udf(
udf: &ScalarUDF,
arg: ColumnarValue,
diff --git a/rust/sedona-geometry/src/transform.rs b/rust/sedona-geometry/src/transform.rs
index d8d141a53..159aa9e9a 100644
--- a/rust/sedona-geometry/src/transform.rs
+++ b/rust/sedona-geometry/src/transform.rs
@@ -41,6 +41,7 @@ use geo_traits::{
/// Represents a coordinate reference system (CRS) transformation engine.
pub trait CrsEngine: Debug {
+ /// Resolve a transform from a source CRS to a destination CRS
fn get_transform_crs_to_crs(
&self,
from: &str,
@@ -48,6 +49,12 @@ pub trait CrsEngine: Debug {
area_of_interest: Option<BoundingBox>,
options: &str,
) -> Result<Rc<dyn CrsTransform>, SedonaGeometryError>;
+
+ /// Resolve a transform where the transform is represented by a pipeline string
+ ///
+ /// The string accepted varies by engine; however, the PROJ engine typically accepts
+ /// strings in the form of an identifier (for a coordinate transform, not a CRS) or
+ /// PROJ4-ish string.
fn get_transform_pipeline(
&self,
pipeline: &str,
diff --git a/rust/sedona-schema/src/crs.rs b/rust/sedona-schema/src/crs.rs
index fa24602ce..fa5fd2820 100644
--- a/rust/sedona-schema/src/crs.rs
+++ b/rust/sedona-schema/src/crs.rs
@@ -335,6 +335,34 @@ pub trait CoordinateReferenceSystem: Debug {
/// not be escaped. This is the representation expected as input to PROJ, GDAL,
/// and Parquet GEOMETRY/GEOGRAPHY representations of CRS.
fn to_crs_string(&self) -> String;
+
+ /// For Geographic CRSes, return the geographic parameters
+ ///
+ /// For non-geographic CRSes, this must return None. For the purposes of this
+ /// trait, a geographic CRS must be suitable for the Geography type (i.e.,
+ /// units in degrees).
+ fn geographic_params(&self) -> Result<Option<GeographicCrsParams>>;
+}
+
+/// Parameters describing a geographic CRS
+///
+/// These parameters are used for distance calculations on the ellipsoid or sphere
+/// for functions that support it. Notably, this is used for calculations involving
+/// distance in the geography type. Currently this only supports a single parameter
+/// (average spherical radius) but can be expanded to support ellipsoidal parameters
+/// when we have functions that support it.
+pub struct GeographicCrsParams {
+ spherical_radius_m: f64,
+}
+
+impl GeographicCrsParams {
+ /// The average spherical radius in meters for use in spherical geographies
+ ///
+ /// Because geographies with spherical edges approximate distance on the sphere,
+ /// this is the value that will be used to calculate distance in such a case.
+ pub fn spherical_radius(&self) -> f64 {
+ self.spherical_radius_m
+ }
}
/// Concrete implementation of a default longitude/latitude coordinate reference system
@@ -465,6 +493,40 @@ impl CoordinateReferenceSystem for AuthorityCode {
fn to_crs_string(&self) -> String {
self.auth_code.clone()
}
+
+ /// Hard-code support for several known spherical CRSes so we can support them
+ /// in the geography type
+ fn geographic_params(&self) -> Result<Option<GeographicCrsParams>> {
+ match self.auth_code.as_str() {
+ // Default lnglat(). Here we use S2Earth::RadiusMeters() as a constant
+ // for consistent results (if we averaged over the ensemble, distance results
+ // may be subtly different between versions as the ensemble is updated).
+ "OGC:CRS84" | "EPSG:4326" => Ok(Some(GeographicCrsParams {
+ spherical_radius_m: 6371010.0,
+ })),
+ // NAD83
+ // https://spatialreference.org/ref/epsg/4269/
+ "OGC:CRS83" | "EPSG:4269" => {
+ const A: f64 = 6378137.0;
+ const INV_F: f64 = 298.257222101;
+ const B: f64 = A * (1.0 - 1.0 / INV_F);
+ Ok(Some(GeographicCrsParams {
+ spherical_radius_m: (2.0 * A + B) / 3.0,
+ }))
+ }
+ // NAD27
+ // Mean radius = (2a + b) / 3
+ // https://spatialreference.org/ref/epsg/4267/
+ "OGC:CRS27" | "EPSG:4267" => {
+ const A: f64 = 6378206.4;
+ const B: f64 = 6356583.8;
+ Ok(Some(GeographicCrsParams {
+ spherical_radius_m: (2.0 * A + B) / 3.0,
+ }))
+ }
+ _ => Ok(None),
+ }
+ }
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -546,6 +608,78 @@ impl CoordinateReferenceSystem for ProjJSON {
fn to_crs_string(&self) -> String {
self.to_json()
}
+
+ fn geographic_params(&self) -> Result<Option<GeographicCrsParams>> {
+ let Value::Object(obj) = &self.value else {
+ return Ok(None);
+ };
+
+ let Some(Value::String(crs_type)) = obj.get("type") else {
+ return Ok(None);
+ };
+
+ // Only geographic CRSes have geographic params
+ if crs_type != "GeographicCRS" {
+ return Ok(None);
+ }
+
+ // Try datum first, then datum_ensemble
+ let ellipsoid = if let Some(Value::Object(datum)) = obj.get("datum") {
+ datum.get("ellipsoid")
+ } else if let Some(Value::Object(datum_ensemble)) = obj.get("datum_ensemble") {
+ datum_ensemble.get("ellipsoid")
+ } else {
+ return exec_err!(
+ "PROJJSON GeographicCRS missing or malformed datum or datum_ensemble ellipsoid"
+ );
+ };
+
+ let Some(Value::Object(ellipsoid)) = ellipsoid else {
+ return exec_err!("PROJJSON ellipsoid is not an object");
+ };
+
+ // Handle spherical case (radius only)
+ if let Some(Value::Number(radius)) = ellipsoid.get("radius") {
+ let Some(r) = radius.as_f64() else {
+ return exec_err!("PROJJSON radius can't be converted to f64");
+ };
+ return Ok(Some(GeographicCrsParams {
+ spherical_radius_m: r,
+ }));
+ }
+
+ // Get semi_major_axis (required for ellipsoidal case)
+ let Some(Value::Number(semi_major)) = ellipsoid.get("semi_major_axis") else {
+ return exec_err!("PROJJSON ellipsoid missing or malformed semi_major_axis");
+ };
+ let Some(a) = semi_major.as_f64() else {
+ return exec_err!("PROJJSON semi_major_axis can't be converted to f64");
+ };
+
+ // Calculate semi_minor_axis from inverse_flattening or use directly
+ let b = if let Some(Value::Number(inv_f)) = ellipsoid.get("inverse_flattening") {
+ if let Some(inv_f) = inv_f.as_f64() {
+ a * (1.0 - 1.0 / inv_f)
+ } else {
+ return exec_err!("PROJJSON inverse_flattening can't be converted to f64");
+ }
+ } else if let Some(Value::Number(semi_minor)) = ellipsoid.get("semi_minor_axis") {
+ if let Some(b) = semi_minor.as_f64() {
+ b
+ } else {
+ return exec_err!("PROJJSON semi_minor_axis can't be converted to f64");
+ }
+ } else {
+ return exec_err!(
+ "PROJJSON ellipsoid missing or malformed inverse_flattening/semi_minor_axis"
+ );
+ };
+
+ // Mean radius = (2a + b) / 3
+ Ok(Some(GeographicCrsParams {
+ spherical_radius_m: (2.0 * a + b) / 3.0,
+ }))
+ }
}
pub const OGC_CRS84_PROJJSON: &str = r#"{"$schema":"https://proj.org/schemas/v0.7/projjson.schema.json","type":"GeographicCRS","name":"WGS 84 (CRS84)","datum_ensemble":{"name":"World Geodetic System 1984 ensemble","members":[{"name":"World Geodetic System 1984 (Transit)","id":{"authority":"EPSG","code":1166}},{"name":"World Geodetic System 1984 (G730)","id":{"authority":"EPSG","code":1152}},{"name":"World Geodetic System 1984 (G873)","id":{"authority":"EPSG","code":1153}},{"name":"World [...]
@@ -553,8 +687,15 @@ pub const OGC_CRS84_PROJJSON: &str = r#"{"$schema":"https://proj.org/schemas/v0.
#[cfg(test)]
mod test {
use super::*;
+ /// Projected CRS
const EPSG_6318_PROJJSON: &str = r#"{"$schema": "https://proj.org/schemas/v0.4/projjson.schema.json","type": "GeographicCRS","name": "NAD83(2011)","datum": {"type": "GeodeticReferenceFrame","name": "NAD83 (National Spatial Reference System 2011)","ellipsoid": {"name": "GRS 1980","semi_major_axis": 6378137,"inverse_flattening": 298.257222101}},"coordinate_system": {"subtype": "ellipsoidal","axis": [{"name": "Geodetic latitude","abbreviation": "Lat","direction": "north","unit": "degree [...]
+ /// Mars geographic CRS (only defines 'radius')
+ const IAU_49900_PROJJSON: &str = r#"{"$schema":"https://proj.org/schemas/v0.7/projjson.schema.json","type":"GeographicCRS","name":"Mars (2015) - Sphere / Ocentric","datum":{"type":"GeodeticReferenceFrame","name":"Mars (2015) - Sphere","anchor":"Viking 1 lander: 47.95137 W","ellipsoid":{"name":"Mars (2015) - Sphere","radius":3396190},"prime_meridian":{"name":"Reference Meridian","longitude":0}},"coordinate_system":{"subtype":"ellipsoidal","axis":[{"name":"Geodetic latitude","abbreviat [...]
+
+ /// NAD27 (defines semi_major/semi_minor instead of inverse flattening)
+ const EPSG_4267_PROJJSON: &str = r#"{"$schema":"https://proj.org/schemas/v0.7/projjson.schema.json","type":"GeographicCRS","name":"NAD27","datum":{"type":"GeodeticReferenceFrame","name":"North American Datum 1927","ellipsoid":{"name":"Clarke 1866","semi_major_axis":6378206.4,"semi_minor_axis":6356583.8}},"coordinate_system":{"subtype":"ellipsoidal","axis":[{"name":"Geodetic latitude","abbreviation":"Lat","direction":"north","unit":"degree"},{"name":"Geodetic longitude","abbreviation" [...]
+
#[test]
fn deserialize() {
// lnglat() crses
@@ -694,4 +835,133 @@ mod test {
let new_crs = deserialize_crs_from_obj(&crs_value).unwrap();
assert_eq!(new_crs.clone().unwrap().srid().unwrap(), Some(4269));
}
+
+ #[test]
+ fn geographic_params() {
+ let lnglat = AuthorityCode {
+ auth_code: "OGC:CRS84".to_string(),
+ };
+ let params = lnglat.geographic_params().unwrap().unwrap();
+ assert_eq!(params.spherical_radius(), 6371010.0);
+
+ let auth_code_nad27 = AuthorityCode {
+ auth_code: "EPSG:4267".to_string(),
+ };
+ let params = auth_code_nad27.geographic_params().unwrap().unwrap();
+ assert_eq!(params.spherical_radius(), 6370998.866666667);
+
+ let projjson_nad27 = ProjJSON::from_str(EPSG_4267_PROJJSON).unwrap();
+ let params = projjson_nad27.geographic_params().unwrap().unwrap();
+ assert_eq!(params.spherical_radius(), 6370998.866666667);
+
+ let projjson_wgs84 = ProjJSON::from_str(OGC_CRS84_PROJJSON).unwrap();
+ let params = projjson_wgs84.geographic_params().unwrap().unwrap();
+ assert_eq!(params.spherical_radius(), 6371008.771415059);
+
+ let projjson_mars = ProjJSON::from_str(IAU_49900_PROJJSON).unwrap();
+ let params = projjson_mars.geographic_params().unwrap().unwrap();
+ assert_eq!(params.spherical_radius(), 3396190.0);
+ }
+
+ #[test]
+ fn geographic_params_invalid_projjson() {
+ // Missing type field -> Ok(None)
+ let no_type = ProjJSON::try_new(serde_json::json!({"name": "test"})).unwrap();
+ assert!(no_type.geographic_params().unwrap().is_none());
+
+ // Non-GeographicCRS type -> Ok(None)
+ let projected = ProjJSON::try_new(serde_json::json!({
+ "type": "ProjectedCRS",
+ "name": "test"
+ }))
+ .unwrap();
+ assert!(projected.geographic_params().unwrap().is_none());
+
+ // GeographicCRS missing datum and datum_ensemble -> Error
+ let no_datum = ProjJSON::try_new(serde_json::json!({
+ "type": "GeographicCRS",
+ "name": "test"
+ }))
+ .unwrap();
+ assert!(no_datum.geographic_params().is_err());
+
+ // datum is not an object -> Error (datum_ensemble fallback also fails)
+ let datum_not_object = ProjJSON::try_new(serde_json::json!({
+ "type": "GeographicCRS",
+ "datum": "not an object"
+ }))
+ .unwrap();
+ assert!(datum_not_object.geographic_params().is_err());
+
+ // ellipsoid is not an object -> Error
+ let ellipsoid_not_object = ProjJSON::try_new(serde_json::json!({
+ "type": "GeographicCRS",
+ "datum": {"ellipsoid": "not an object"}
+ }))
+ .unwrap();
+ assert!(ellipsoid_not_object.geographic_params().is_err());
+
+ // ellipsoid missing all axis definitions -> Error
+ let ellipsoid_empty = ProjJSON::try_new(serde_json::json!({
+ "type": "GeographicCRS",
+ "datum": {"ellipsoid": {"name": "empty"}}
+ }))
+ .unwrap();
+ assert!(ellipsoid_empty.geographic_params().is_err());
+
+ // semi_major_axis is not a number -> Error
+ let semi_major_not_number = ProjJSON::try_new(serde_json::json!({
+ "type": "GeographicCRS",
+ "datum": {"ellipsoid": {"semi_major_axis": "not a number"}}
+ }))
+ .unwrap();
+ assert!(semi_major_not_number.geographic_params().is_err());
+
+ // semi_major_axis present but no inverse_flattening or semi_minor_axis -> Error
+ let missing_minor = ProjJSON::try_new(serde_json::json!({
+ "type": "GeographicCRS",
+ "datum": {"ellipsoid": {"semi_major_axis": 6378137}}
+ }))
+ .unwrap();
+ assert!(missing_minor.geographic_params().is_err());
+
+ // inverse_flattening is not a number -> Error
+ let inv_f_not_number = ProjJSON::try_new(serde_json::json!({
+ "type": "GeographicCRS",
+ "datum": {"ellipsoid": {
+ "semi_major_axis": 6378137,
+ "inverse_flattening": "not a number"
+ }}
+ }))
+ .unwrap();
+ assert!(inv_f_not_number.geographic_params().is_err());
+
+ // semi_minor_axis is not a number -> Error
+ let semi_minor_not_number = ProjJSON::try_new(serde_json::json!({
+ "type": "GeographicCRS",
+ "datum": {"ellipsoid": {
+ "semi_major_axis": 6378137,
+ "semi_minor_axis": "not a number"
+ }}
+ }))
+ .unwrap();
+ assert!(semi_minor_not_number.geographic_params().is_err());
+
+ // radius is not a number -> Error
+ let radius_not_number = ProjJSON::try_new(serde_json::json!({
+ "type": "GeographicCRS",
+ "datum": {"ellipsoid": {"radius": "not a number"}}
+ }))
+ .unwrap();
+ assert!(radius_not_number.geographic_params().is_err());
+
+ // datum_ensemble path works when datum is missing
+ let datum_ensemble = ProjJSON::try_new(serde_json::json!({
+ "type": "GeographicCRS",
+ "datum_ensemble": {"ellipsoid": {"radius": 6371000}}
+ }))
+ .unwrap();
+ let params = datum_ensemble.geographic_params().unwrap().unwrap();
+ assert_eq!(params.spherical_radius(), 6371000.0);
+ }
}
diff --git a/rust/sedona-schema/src/datatypes.rs b/rust/sedona-schema/src/datatypes.rs
index 3bc32350c..46b49b1f2 100644
--- a/rust/sedona-schema/src/datatypes.rs
+++ b/rust/sedona-schema/src/datatypes.rs
@@ -21,7 +21,7 @@ use serde_json::Value;
use std::fmt::{Debug, Display};
use std::sync::LazyLock;
-use crate::crs::{deserialize_crs, deserialize_crs_from_obj, Crs};
+use crate::crs::{deserialize_crs, deserialize_crs_from_obj, lnglat, Crs};
use crate::extension_type::ExtensionType;
use crate::raster::RasterSchema;
@@ -67,7 +67,8 @@ pub const WKB_VIEW_GEOMETRY: SedonaType = SedonaType::WkbView(Edges::Planar, Crs
/// Sentinel for [`SedonaType::Wkb`] with spherical edges
///
/// This constant is useful when defining type signatures as these ignore the Crs when
-/// matching (and `SedonaType::Wkb(...)` is verbose)
+/// matching (and `SedonaType::Wkb(...)` is verbose). Note that [WKB_GEOGRAPHY_WGS84]
+/// is likely more appropriate in many cases.
pub const WKB_GEOGRAPHY: SedonaType = SedonaType::Wkb(Edges::Spherical, Crs::None);
/// Sentinel for [`SedonaType::WkbView`] with spherical edges
@@ -75,6 +76,20 @@ pub const WKB_GEOGRAPHY: SedonaType = SedonaType::Wkb(Edges::Spherical, Crs::Non
/// See [`WKB_GEOGRAPHY`]
pub const WKB_VIEW_GEOGRAPHY: SedonaType = SedonaType::WkbView(Edges::Spherical, Crs::None);
+/// Sentinel for [`SedonaType::Wkb`] with spherical edges and longitude, latitude CRS
+///
+/// This constant is useful when defining type signatures as these ignore the Crs when
+/// matching (and `SedonaType::Wkb(...)` is verbose)
+pub static WKB_GEOGRAPHY_WGS84: LazyLock<SedonaType> =
+ LazyLock::new(|| SedonaType::Wkb(Edges::Spherical, lnglat()));
+
+/// Sentinel for [`SedonaType::WkbView`] with spherical edges and longitude, latitude CRS
+///
+/// This constant is useful when defining type signatures as these ignore the Crs when
+/// matching (and `SedonaType::WkbView(...)` is verbose)
+pub static WKB_VIEW_GEOGRAPHY_WGS84: LazyLock<SedonaType> =
+ LazyLock::new(|| SedonaType::WkbView(Edges::Spherical, lnglat()));
+
/// Sentinel for [`SedonaType::Raster`]
pub const RASTER: SedonaType = SedonaType::Raster;