From a8dd47693e5657e8f579f4cdf775d7ac8d13b95f Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Sun, 5 Oct 2025 13:27:16 +0900 Subject: [PATCH 01/12] Implement ST_Azimuth() --- rust/sedona-functions/src/lib.rs | 1 + rust/sedona-functions/src/register.rs | 2 + rust/sedona-functions/src/st_azimuth.rs | 66 +++++++++ rust/sedona-geo/src/lib.rs | 1 + rust/sedona-geo/src/register.rs | 7 +- rust/sedona-geo/src/st_azimuth.rs | 182 ++++++++++++++++++++++++ 6 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 rust/sedona-functions/src/st_azimuth.rs create mode 100644 rust/sedona-geo/src/st_azimuth.rs diff --git a/rust/sedona-functions/src/lib.rs b/rust/sedona-functions/src/lib.rs index 989c53a7e..6c144f7c2 100644 --- a/rust/sedona-functions/src/lib.rs +++ b/rust/sedona-functions/src/lib.rs @@ -26,6 +26,7 @@ pub mod st_analyze_aggr; mod st_area; mod st_asbinary; mod st_astext; +mod st_azimuth; mod st_buffer; mod st_centroid; mod st_collect; diff --git a/rust/sedona-functions/src/register.rs b/rust/sedona-functions/src/register.rs index 339e7a3bf..b08338ca8 100644 --- a/rust/sedona-functions/src/register.rs +++ b/rust/sedona-functions/src/register.rs @@ -64,6 +64,7 @@ pub fn default_function_set() -> FunctionSet { crate::st_area::st_area_udf, crate::st_asbinary::st_asbinary_udf, crate::st_astext::st_astext_udf, + crate::st_azimuth::st_azimuth_udf, crate::st_buffer::st_buffer_udf, crate::st_centroid::st_centroid_udf, crate::st_dimension::st_dimension_udf, @@ -127,6 +128,7 @@ pub mod stubs { pub use crate::predicates::*; pub use crate::referencing::*; pub use crate::st_area::st_area_udf; + pub use crate::st_azimuth::st_azimuth_udf; pub use crate::st_centroid::st_centroid_udf; pub use crate::st_length::st_length_udf; pub use crate::st_perimeter::st_perimeter_udf; diff --git a/rust/sedona-functions/src/st_azimuth.rs b/rust/sedona-functions/src/st_azimuth.rs new file mode 100644 index 000000000..12232aae5 --- /dev/null +++ b/rust/sedona-functions/src/st_azimuth.rs @@ -0,0 +1,66 @@ +// 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. +use arrow_schema::DataType; +use datafusion_expr::{scalar_doc_sections::DOC_SECTION_OTHER, Documentation, Volatility}; +use sedona_expr::scalar_udf::SedonaScalarUDF; +use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher}; + +/// ST_Azimuth() scalar UDF stub +/// +/// Stub function for azimuth calculation between two points. +pub fn st_azimuth_udf() -> SedonaScalarUDF { + SedonaScalarUDF::new_stub( + "st_azimuth", + ArgMatcher::new( + vec![ + ArgMatcher::is_geometry_or_geography(), + ArgMatcher::is_geometry_or_geography(), + ], + SedonaType::Arrow(DataType::Float64), + ), + Volatility::Immutable, + Some(st_azimuth_doc()), + ) +} + +fn st_azimuth_doc() -> Documentation { + Documentation::builder( + DOC_SECTION_OTHER, + "Returns the azimuth in radians from geomA to geomB", + "ST_Azimuth (A: Geometry, B: Geometry)", + ) + .with_argument("geomA", "geometry: Start point geometry") + .with_argument("geomB", "geometry: End point geometry") + .with_sql_example( + "SELECT ST_Azimuth(\n ST_GeomFromText('POINT (0 0)'),\n ST_GeomFromText('POINT (1 1)')\n )", + ) + .build() +} + +#[cfg(test)] +mod tests { + use datafusion_expr::ScalarUDF; + + use super::*; + + #[test] + fn udf_metadata() { + let udf: ScalarUDF = st_azimuth_udf().into(); + assert_eq!(udf.name(), "st_azimuth"); + assert!(udf.documentation().is_some()); + } +} diff --git a/rust/sedona-geo/src/lib.rs b/rust/sedona-geo/src/lib.rs index 0a5224ab0..e37430d60 100644 --- a/rust/sedona-geo/src/lib.rs +++ b/rust/sedona-geo/src/lib.rs @@ -17,6 +17,7 @@ pub mod centroid; pub mod register; mod st_area; +mod st_azimuth; mod st_centroid; mod st_distance; mod st_dwithin; diff --git a/rust/sedona-geo/src/register.rs b/rust/sedona-geo/src/register.rs index 9041c2dc3..f581aa0e7 100644 --- a/rust/sedona-geo/src/register.rs +++ b/rust/sedona-geo/src/register.rs @@ -21,15 +21,16 @@ use crate::st_intersection_aggr::st_intersection_aggr_impl; use crate::st_line_interpolate_point::st_line_interpolate_point_impl; use crate::st_union_aggr::st_union_aggr_impl; use crate::{ - st_area::st_area_impl, st_centroid::st_centroid_impl, st_distance::st_distance_impl, - st_dwithin::st_dwithin_impl, st_intersects::st_intersects_impl, st_length::st_length_impl, - st_perimeter::st_perimeter_impl, + st_area::st_area_impl, st_azimuth::st_azimuth_impl, st_centroid::st_centroid_impl, + st_distance::st_distance_impl, st_dwithin::st_dwithin_impl, st_intersects::st_intersects_impl, + st_length::st_length_impl, st_perimeter::st_perimeter_impl, }; pub fn scalar_kernels() -> Vec<(&'static str, ScalarKernelRef)> { vec![ ("st_intersects", st_intersects_impl()), ("st_area", st_area_impl()), + ("st_azimuth", st_azimuth_impl()), ("st_centroid", st_centroid_impl()), ("st_distance", st_distance_impl()), ("st_dwithin", st_dwithin_impl()), diff --git a/rust/sedona-geo/src/st_azimuth.rs b/rust/sedona-geo/src/st_azimuth.rs new file mode 100644 index 000000000..0abe9c514 --- /dev/null +++ b/rust/sedona-geo/src/st_azimuth.rs @@ -0,0 +1,182 @@ +// 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. + +use std::sync::Arc; + +use arrow_array::builder::Float64Builder; +use arrow_schema::DataType; +use datafusion_common::{error::Result, exec_datafusion_err}; +use datafusion_expr::ColumnarValue; +use geo_traits::{CoordTrait, GeometryTrait, GeometryType, PointTrait}; +use sedona_expr::scalar_udf::{ScalarKernelRef, SedonaScalarKernel}; +use sedona_functions::executor::WkbExecutor; +use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher}; +use wkb::reader::Wkb; + +/// ST_Azimuth() implementation following PostGIS semantics +pub fn st_azimuth_impl() -> ScalarKernelRef { + Arc::new(STAzimuth {}) +} + +#[derive(Debug)] +struct STAzimuth {} + +impl SedonaScalarKernel for STAzimuth { + fn return_type(&self, args: &[SedonaType]) -> Result> { + let matcher = ArgMatcher::new( + vec![ArgMatcher::is_geometry(), ArgMatcher::is_geometry()], + SedonaType::Arrow(DataType::Float64), + ); + + matcher.match_args(args) + } + + fn invoke_batch( + &self, + arg_types: &[SedonaType], + args: &[ColumnarValue], + ) -> Result { + let executor = WkbExecutor::new(arg_types, args); + let mut builder = Float64Builder::with_capacity(executor.num_iterations()); + executor.execute_wkb_wkb_void(|maybe_start, maybe_end| { + match (maybe_start, maybe_end) { + (Some(start), Some(end)) => match invoke_scalar(start, end)? { + Some(angle) => builder.append_value(angle), + None => builder.append_null(), + }, + _ => builder.append_null(), + } + + Ok(()) + })?; + + executor.finish(Arc::new(builder.finish())) + } +} + +fn invoke_scalar(start: &Wkb, end: &Wkb) -> Result> { + let Some((start_x, start_y)) = point_xy(start)? else { + return Ok(None); + }; + let Some((end_x, end_y)) = point_xy(end)? else { + return Ok(None); + }; + + let dx = end_x - start_x; + let dy = end_y - start_y; + + if dx == 0.0 && dy == 0.0 { + return Ok(None); + } + + let mut angle = dx.atan2(dy); + if angle < 0.0 { + angle += 2.0 * std::f64::consts::PI; + } + + Ok(Some(angle)) +} + +fn point_xy(geom: &Wkb) -> Result> { + match geom.as_type() { + GeometryType::Point(point) => { + if let Some(coord) = point.coord() { + Ok(Some((coord.x(), coord.y()))) + } else { + Ok(None) + } + } + _ => Err(exec_datafusion_err!( + "ST_Azimuth expects both arguments to be POINT geometries" + )), + } +} + +#[cfg(test)] +mod tests { + use datafusion_common::scalar::ScalarValue; + use rstest::rstest; + use sedona_functions::register::stubs::st_azimuth_udf; + use sedona_schema::datatypes::{WKB_GEOMETRY, WKB_VIEW_GEOMETRY}; + use sedona_testing::create::create_scalar; + use sedona_testing::testers::ScalarUdfTester; + + use super::*; + + #[rstest] + fn udf( + #[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] start_type: SedonaType, + #[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] end_type: SedonaType, + ) { + let mut udf = st_azimuth_udf(); + udf.add_kernel(st_azimuth_impl()); + let tester = ScalarUdfTester::new(udf.into(), vec![start_type.clone(), end_type.clone()]); + + assert_eq!( + tester.return_type().unwrap(), + SedonaType::Arrow(DataType::Float64) + ); + + let start = create_scalar(Some("POINT (0 0)"), &start_type); + let north = create_scalar(Some("POINT (0 1)"), &end_type); + let east = create_scalar(Some("POINT (1 0)"), &end_type); + let south = create_scalar(Some("POINT (0 -1)"), &end_type); + let west = create_scalar(Some("POINT (-1 0)"), &end_type); + + let result = tester + .invoke_scalar_scalar(start.clone(), north.clone()) + .unwrap(); + assert!(matches!( + result, + ScalarValue::Float64(Some(val)) if (val - 0.0).abs() < 1e-12 + )); + + let result = tester + .invoke_scalar_scalar(start.clone(), east.clone()) + .unwrap(); + assert!(matches!( + result, + ScalarValue::Float64(Some(val)) if (val - std::f64::consts::FRAC_PI_2).abs() < 1e-12 + )); + + let result = tester + .invoke_scalar_scalar(start.clone(), south.clone()) + .unwrap(); + assert!(matches!( + result, + ScalarValue::Float64(Some(val)) if (val - std::f64::consts::PI).abs() < 1e-12 + )); + + let result = tester + .invoke_scalar_scalar(start.clone(), west.clone()) + .unwrap(); + assert!(matches!( + result, + ScalarValue::Float64(Some(val)) if (val - (3.0 * std::f64::consts::FRAC_PI_2)).abs() < 1e-12 + )); + + let result = tester + .invoke_scalar_scalar(start.clone(), start.clone()) + .unwrap(); + assert!(result.is_null()); + + let result = tester + .invoke_scalar_scalar(ScalarValue::Null, north.clone()) + .unwrap(); + assert!(result.is_null()); + } +} From 351885d0b98a6069753c34f01a8d8a46f272ddc5 Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Sun, 5 Oct 2025 22:12:55 +0900 Subject: [PATCH 02/12] Tweak --- rust/sedona-functions/src/st_azimuth.rs | 4 +- rust/sedona-geo/src/st_azimuth.rs | 58 +++++++++++-------------- 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/rust/sedona-functions/src/st_azimuth.rs b/rust/sedona-functions/src/st_azimuth.rs index 12232aae5..70d9ee362 100644 --- a/rust/sedona-functions/src/st_azimuth.rs +++ b/rust/sedona-functions/src/st_azimuth.rs @@ -40,13 +40,13 @@ pub fn st_azimuth_udf() -> SedonaScalarUDF { fn st_azimuth_doc() -> Documentation { Documentation::builder( DOC_SECTION_OTHER, - "Returns the azimuth in radians from geomA to geomB", + "Returns the azimuth (a clockwise angle measured from north) in radians from geomA to geomB", "ST_Azimuth (A: Geometry, B: Geometry)", ) .with_argument("geomA", "geometry: Start point geometry") .with_argument("geomB", "geometry: End point geometry") .with_sql_example( - "SELECT ST_Azimuth(\n ST_GeomFromText('POINT (0 0)'),\n ST_GeomFromText('POINT (1 1)')\n )", + "SELECT ST_Azimuth(\n ST_Point(0, 0),\n ST_Point(1, 1)\n )", ) .build() } diff --git a/rust/sedona-geo/src/st_azimuth.rs b/rust/sedona-geo/src/st_azimuth.rs index 0abe9c514..55f9a7c9b 100644 --- a/rust/sedona-geo/src/st_azimuth.rs +++ b/rust/sedona-geo/src/st_azimuth.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use arrow_array::builder::Float64Builder; use arrow_schema::DataType; -use datafusion_common::{error::Result, exec_datafusion_err}; +use datafusion_common::error::Result; use datafusion_expr::ColumnarValue; use geo_traits::{CoordTrait, GeometryTrait, GeometryType, PointTrait}; use sedona_expr::scalar_udf::{ScalarKernelRef, SedonaScalarKernel}; @@ -27,7 +27,7 @@ use sedona_functions::executor::WkbExecutor; use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher}; use wkb::reader::Wkb; -/// ST_Azimuth() implementation following PostGIS semantics +/// ST_Azimuth() implementation pub fn st_azimuth_impl() -> ScalarKernelRef { Arc::new(STAzimuth {}) } @@ -69,41 +69,38 @@ impl SedonaScalarKernel for STAzimuth { } fn invoke_scalar(start: &Wkb, end: &Wkb) -> Result> { - let Some((start_x, start_y)) = point_xy(start)? else { - return Ok(None); - }; - let Some((end_x, end_y)) = point_xy(end)? else { - return Ok(None); - }; + match (start.as_type(), end.as_type()) { + (GeometryType::Point(start_point), GeometryType::Point(end_point)) => { + match (start_point.coord(), end_point.coord()) { + // If both geometries are non-empty points, calculate the angle + (Some(start_coord), Some(end_coord)) => Ok(calc_azimuth( + start_coord.x(), + start_coord.y(), + end_coord.x(), + end_coord.y(), + )), + // If either of the points is empty, the result is NULL + _ => Ok(None), + } + } + _ => Err(datafusion_common::error::DataFusionError::Execution( + "ST_Azimuth expects both arguments to be POINT geometries".into(), + )), + } +} +// Note: When the two points are completely coincident, PostGIS's ST_Azimuth() +// returns NULL. However, this returns 0.0. +fn calc_azimuth(start_x: f64, start_y: f64, end_x: f64, end_y: f64) -> Option { let dx = end_x - start_x; let dy = end_y - start_y; - if dx == 0.0 && dy == 0.0 { - return Ok(None); - } - let mut angle = dx.atan2(dy); if angle < 0.0 { angle += 2.0 * std::f64::consts::PI; } - Ok(Some(angle)) -} - -fn point_xy(geom: &Wkb) -> Result> { - match geom.as_type() { - GeometryType::Point(point) => { - if let Some(coord) = point.coord() { - Ok(Some((coord.x(), coord.y()))) - } else { - Ok(None) - } - } - _ => Err(exec_datafusion_err!( - "ST_Azimuth expects both arguments to be POINT geometries" - )), - } + Some(angle) } #[cfg(test)] @@ -169,11 +166,6 @@ mod tests { ScalarValue::Float64(Some(val)) if (val - (3.0 * std::f64::consts::FRAC_PI_2)).abs() < 1e-12 )); - let result = tester - .invoke_scalar_scalar(start.clone(), start.clone()) - .unwrap(); - assert!(result.is_null()); - let result = tester .invoke_scalar_scalar(ScalarValue::Null, north.clone()) .unwrap(); From 833ad30d172db06501778678e528154c06ce7b68 Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Mon, 6 Oct 2025 08:27:01 +0900 Subject: [PATCH 03/12] Update rust/sedona-functions/src/st_azimuth.rs Co-authored-by: Peter Nguyen --- rust/sedona-functions/src/st_azimuth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/sedona-functions/src/st_azimuth.rs b/rust/sedona-functions/src/st_azimuth.rs index 70d9ee362..13cb770c7 100644 --- a/rust/sedona-functions/src/st_azimuth.rs +++ b/rust/sedona-functions/src/st_azimuth.rs @@ -46,7 +46,7 @@ fn st_azimuth_doc() -> Documentation { .with_argument("geomA", "geometry: Start point geometry") .with_argument("geomB", "geometry: End point geometry") .with_sql_example( - "SELECT ST_Azimuth(\n ST_Point(0, 0),\n ST_Point(1, 1)\n )", + "SELECT ST_Azimuth(ST_Point(0, 0), ST_Point(1, 1))", ) .build() } From aecaf6c520e371f7304aac62285db1afacb76b9d Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Mon, 6 Oct 2025 08:38:11 +0900 Subject: [PATCH 04/12] Move implementation to sedona-functions --- rust/sedona-functions/src/st_azimuth.rs | 160 ++++++++++++++++++++-- rust/sedona-geo/src/lib.rs | 1 - rust/sedona-geo/src/register.rs | 7 +- rust/sedona-geo/src/st_azimuth.rs | 174 ------------------------ 4 files changed, 152 insertions(+), 190 deletions(-) delete mode 100644 rust/sedona-geo/src/st_azimuth.rs diff --git a/rust/sedona-functions/src/st_azimuth.rs b/rust/sedona-functions/src/st_azimuth.rs index 13cb770c7..dfec9b3d6 100644 --- a/rust/sedona-functions/src/st_azimuth.rs +++ b/rust/sedona-functions/src/st_azimuth.rs @@ -14,24 +14,27 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. +use arrow_array::builder::Float64Builder; use arrow_schema::DataType; -use datafusion_expr::{scalar_doc_sections::DOC_SECTION_OTHER, Documentation, Volatility}; -use sedona_expr::scalar_udf::SedonaScalarUDF; +use datafusion_common::error::Result; +use datafusion_expr::{ + scalar_doc_sections::DOC_SECTION_OTHER, ColumnarValue, Documentation, Volatility, +}; +use geo_traits::{CoordTrait, GeometryTrait, GeometryType, PointTrait}; +use sedona_expr::scalar_udf::{SedonaScalarKernel, SedonaScalarUDF}; use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher}; +use std::sync::Arc; +use wkb::reader::Wkb; -/// ST_Azimuth() scalar UDF stub +use crate::executor::WkbExecutor; + +/// ST_Azimuth() scalar UDF /// /// Stub function for azimuth calculation between two points. pub fn st_azimuth_udf() -> SedonaScalarUDF { - SedonaScalarUDF::new_stub( + SedonaScalarUDF::new( "st_azimuth", - ArgMatcher::new( - vec![ - ArgMatcher::is_geometry_or_geography(), - ArgMatcher::is_geometry_or_geography(), - ], - SedonaType::Arrow(DataType::Float64), - ), + vec![Arc::new(STAzimuth {})], Volatility::Immutable, Some(st_azimuth_doc()), ) @@ -51,9 +54,85 @@ fn st_azimuth_doc() -> Documentation { .build() } +#[derive(Debug)] +struct STAzimuth {} + +impl SedonaScalarKernel for STAzimuth { + fn return_type(&self, args: &[SedonaType]) -> Result> { + let matcher = ArgMatcher::new( + vec![ArgMatcher::is_geometry(), ArgMatcher::is_geometry()], + SedonaType::Arrow(DataType::Float64), + ); + + matcher.match_args(args) + } + + fn invoke_batch( + &self, + arg_types: &[SedonaType], + args: &[ColumnarValue], + ) -> Result { + let executor = WkbExecutor::new(arg_types, args); + let mut builder = Float64Builder::with_capacity(executor.num_iterations()); + executor.execute_wkb_wkb_void(|maybe_start, maybe_end| { + match (maybe_start, maybe_end) { + (Some(start), Some(end)) => match invoke_scalar(start, end)? { + Some(angle) => builder.append_value(angle), + None => builder.append_null(), + }, + _ => builder.append_null(), + } + + Ok(()) + })?; + + executor.finish(Arc::new(builder.finish())) + } +} + +fn invoke_scalar(start: &Wkb, end: &Wkb) -> Result> { + match (start.as_type(), end.as_type()) { + (GeometryType::Point(start_point), GeometryType::Point(end_point)) => { + match (start_point.coord(), end_point.coord()) { + // If both geometries are non-empty points, calculate the angle + (Some(start_coord), Some(end_coord)) => Ok(calc_azimuth( + start_coord.x(), + start_coord.y(), + end_coord.x(), + end_coord.y(), + )), + // If either of the points is empty, the result is NULL + _ => Ok(None), + } + } + _ => Err(datafusion_common::error::DataFusionError::Execution( + "ST_Azimuth expects both arguments to be POINT geometries".into(), + )), + } +} + +// Note: When the two points are completely coincident, PostGIS's ST_Azimuth() +// returns NULL. However, this returns 0.0. +fn calc_azimuth(start_x: f64, start_y: f64, end_x: f64, end_y: f64) -> Option { + let dx = end_x - start_x; + let dy = end_y - start_y; + + let mut angle = dx.atan2(dy); + if angle < 0.0 { + angle += 2.0 * std::f64::consts::PI; + } + + Some(angle) +} + #[cfg(test)] mod tests { + use datafusion_common::scalar::ScalarValue; use datafusion_expr::ScalarUDF; + use rstest::rstest; + use sedona_schema::datatypes::{WKB_GEOMETRY, WKB_VIEW_GEOMETRY}; + use sedona_testing::create::create_scalar; + use sedona_testing::testers::ScalarUdfTester; use super::*; @@ -63,4 +142,63 @@ mod tests { assert_eq!(udf.name(), "st_azimuth"); assert!(udf.documentation().is_some()); } + + #[rstest] + fn udf( + #[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] start_type: SedonaType, + #[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] end_type: SedonaType, + ) { + let tester = ScalarUdfTester::new( + st_azimuth_udf().into(), + vec![start_type.clone(), end_type.clone()], + ); + + assert_eq!( + tester.return_type().unwrap(), + SedonaType::Arrow(DataType::Float64) + ); + + let start = create_scalar(Some("POINT (0 0)"), &start_type); + let north = create_scalar(Some("POINT (0 1)"), &end_type); + let east = create_scalar(Some("POINT (1 0)"), &end_type); + let south = create_scalar(Some("POINT (0 -1)"), &end_type); + let west = create_scalar(Some("POINT (-1 0)"), &end_type); + + let result = tester + .invoke_scalar_scalar(start.clone(), north.clone()) + .unwrap(); + assert!(matches!( + result, + ScalarValue::Float64(Some(val)) if (val - 0.0).abs() < 1e-12 + )); + + let result = tester + .invoke_scalar_scalar(start.clone(), east.clone()) + .unwrap(); + assert!(matches!( + result, + ScalarValue::Float64(Some(val)) if (val - std::f64::consts::FRAC_PI_2).abs() < 1e-12 + )); + + let result = tester + .invoke_scalar_scalar(start.clone(), south.clone()) + .unwrap(); + assert!(matches!( + result, + ScalarValue::Float64(Some(val)) if (val - std::f64::consts::PI).abs() < 1e-12 + )); + + let result = tester + .invoke_scalar_scalar(start.clone(), west.clone()) + .unwrap(); + assert!(matches!( + result, + ScalarValue::Float64(Some(val)) if (val - (3.0 * std::f64::consts::FRAC_PI_2)).abs() < 1e-12 + )); + + let result = tester + .invoke_scalar_scalar(ScalarValue::Null, north.clone()) + .unwrap(); + assert!(result.is_null()); + } } diff --git a/rust/sedona-geo/src/lib.rs b/rust/sedona-geo/src/lib.rs index e37430d60..0a5224ab0 100644 --- a/rust/sedona-geo/src/lib.rs +++ b/rust/sedona-geo/src/lib.rs @@ -17,7 +17,6 @@ pub mod centroid; pub mod register; mod st_area; -mod st_azimuth; mod st_centroid; mod st_distance; mod st_dwithin; diff --git a/rust/sedona-geo/src/register.rs b/rust/sedona-geo/src/register.rs index f581aa0e7..9041c2dc3 100644 --- a/rust/sedona-geo/src/register.rs +++ b/rust/sedona-geo/src/register.rs @@ -21,16 +21,15 @@ use crate::st_intersection_aggr::st_intersection_aggr_impl; use crate::st_line_interpolate_point::st_line_interpolate_point_impl; use crate::st_union_aggr::st_union_aggr_impl; use crate::{ - st_area::st_area_impl, st_azimuth::st_azimuth_impl, st_centroid::st_centroid_impl, - st_distance::st_distance_impl, st_dwithin::st_dwithin_impl, st_intersects::st_intersects_impl, - st_length::st_length_impl, st_perimeter::st_perimeter_impl, + st_area::st_area_impl, st_centroid::st_centroid_impl, st_distance::st_distance_impl, + st_dwithin::st_dwithin_impl, st_intersects::st_intersects_impl, st_length::st_length_impl, + st_perimeter::st_perimeter_impl, }; pub fn scalar_kernels() -> Vec<(&'static str, ScalarKernelRef)> { vec![ ("st_intersects", st_intersects_impl()), ("st_area", st_area_impl()), - ("st_azimuth", st_azimuth_impl()), ("st_centroid", st_centroid_impl()), ("st_distance", st_distance_impl()), ("st_dwithin", st_dwithin_impl()), diff --git a/rust/sedona-geo/src/st_azimuth.rs b/rust/sedona-geo/src/st_azimuth.rs deleted file mode 100644 index 55f9a7c9b..000000000 --- a/rust/sedona-geo/src/st_azimuth.rs +++ /dev/null @@ -1,174 +0,0 @@ -// 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. - -use std::sync::Arc; - -use arrow_array::builder::Float64Builder; -use arrow_schema::DataType; -use datafusion_common::error::Result; -use datafusion_expr::ColumnarValue; -use geo_traits::{CoordTrait, GeometryTrait, GeometryType, PointTrait}; -use sedona_expr::scalar_udf::{ScalarKernelRef, SedonaScalarKernel}; -use sedona_functions::executor::WkbExecutor; -use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher}; -use wkb::reader::Wkb; - -/// ST_Azimuth() implementation -pub fn st_azimuth_impl() -> ScalarKernelRef { - Arc::new(STAzimuth {}) -} - -#[derive(Debug)] -struct STAzimuth {} - -impl SedonaScalarKernel for STAzimuth { - fn return_type(&self, args: &[SedonaType]) -> Result> { - let matcher = ArgMatcher::new( - vec![ArgMatcher::is_geometry(), ArgMatcher::is_geometry()], - SedonaType::Arrow(DataType::Float64), - ); - - matcher.match_args(args) - } - - fn invoke_batch( - &self, - arg_types: &[SedonaType], - args: &[ColumnarValue], - ) -> Result { - let executor = WkbExecutor::new(arg_types, args); - let mut builder = Float64Builder::with_capacity(executor.num_iterations()); - executor.execute_wkb_wkb_void(|maybe_start, maybe_end| { - match (maybe_start, maybe_end) { - (Some(start), Some(end)) => match invoke_scalar(start, end)? { - Some(angle) => builder.append_value(angle), - None => builder.append_null(), - }, - _ => builder.append_null(), - } - - Ok(()) - })?; - - executor.finish(Arc::new(builder.finish())) - } -} - -fn invoke_scalar(start: &Wkb, end: &Wkb) -> Result> { - match (start.as_type(), end.as_type()) { - (GeometryType::Point(start_point), GeometryType::Point(end_point)) => { - match (start_point.coord(), end_point.coord()) { - // If both geometries are non-empty points, calculate the angle - (Some(start_coord), Some(end_coord)) => Ok(calc_azimuth( - start_coord.x(), - start_coord.y(), - end_coord.x(), - end_coord.y(), - )), - // If either of the points is empty, the result is NULL - _ => Ok(None), - } - } - _ => Err(datafusion_common::error::DataFusionError::Execution( - "ST_Azimuth expects both arguments to be POINT geometries".into(), - )), - } -} - -// Note: When the two points are completely coincident, PostGIS's ST_Azimuth() -// returns NULL. However, this returns 0.0. -fn calc_azimuth(start_x: f64, start_y: f64, end_x: f64, end_y: f64) -> Option { - let dx = end_x - start_x; - let dy = end_y - start_y; - - let mut angle = dx.atan2(dy); - if angle < 0.0 { - angle += 2.0 * std::f64::consts::PI; - } - - Some(angle) -} - -#[cfg(test)] -mod tests { - use datafusion_common::scalar::ScalarValue; - use rstest::rstest; - use sedona_functions::register::stubs::st_azimuth_udf; - use sedona_schema::datatypes::{WKB_GEOMETRY, WKB_VIEW_GEOMETRY}; - use sedona_testing::create::create_scalar; - use sedona_testing::testers::ScalarUdfTester; - - use super::*; - - #[rstest] - fn udf( - #[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] start_type: SedonaType, - #[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] end_type: SedonaType, - ) { - let mut udf = st_azimuth_udf(); - udf.add_kernel(st_azimuth_impl()); - let tester = ScalarUdfTester::new(udf.into(), vec![start_type.clone(), end_type.clone()]); - - assert_eq!( - tester.return_type().unwrap(), - SedonaType::Arrow(DataType::Float64) - ); - - let start = create_scalar(Some("POINT (0 0)"), &start_type); - let north = create_scalar(Some("POINT (0 1)"), &end_type); - let east = create_scalar(Some("POINT (1 0)"), &end_type); - let south = create_scalar(Some("POINT (0 -1)"), &end_type); - let west = create_scalar(Some("POINT (-1 0)"), &end_type); - - let result = tester - .invoke_scalar_scalar(start.clone(), north.clone()) - .unwrap(); - assert!(matches!( - result, - ScalarValue::Float64(Some(val)) if (val - 0.0).abs() < 1e-12 - )); - - let result = tester - .invoke_scalar_scalar(start.clone(), east.clone()) - .unwrap(); - assert!(matches!( - result, - ScalarValue::Float64(Some(val)) if (val - std::f64::consts::FRAC_PI_2).abs() < 1e-12 - )); - - let result = tester - .invoke_scalar_scalar(start.clone(), south.clone()) - .unwrap(); - assert!(matches!( - result, - ScalarValue::Float64(Some(val)) if (val - std::f64::consts::PI).abs() < 1e-12 - )); - - let result = tester - .invoke_scalar_scalar(start.clone(), west.clone()) - .unwrap(); - assert!(matches!( - result, - ScalarValue::Float64(Some(val)) if (val - (3.0 * std::f64::consts::FRAC_PI_2)).abs() < 1e-12 - )); - - let result = tester - .invoke_scalar_scalar(ScalarValue::Null, north.clone()) - .unwrap(); - assert!(result.is_null()); - } -} From cf305bb638dc517ab4ee2d7c8e1f96f04b91a4f4 Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Mon, 6 Oct 2025 08:41:45 +0900 Subject: [PATCH 05/12] Add benchmark --- rust/sedona-functions/benches/native-functions.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rust/sedona-functions/benches/native-functions.rs b/rust/sedona-functions/benches/native-functions.rs index 5cfe15fd9..ef5ee81d2 100644 --- a/rust/sedona-functions/benches/native-functions.rs +++ b/rust/sedona-functions/benches/native-functions.rs @@ -138,6 +138,14 @@ fn criterion_benchmark(c: &mut Criterion) { benchmark::scalar(c, &f, "native", "st_mmin", LineString(10)); benchmark::scalar(c, &f, "native", "st_mmax", LineString(10)); + benchmark::scalar( + c, + &f, + "native", + "st_azimuth", + BenchmarkArgs::ArrayArray(Point, Point), + ); + benchmark::aggregate(c, &f, "native", "st_envelope_aggr", Point); benchmark::aggregate(c, &f, "native", "st_envelope_aggr", LineString(10)); From 3de7f973abb28434d686d47ab57ba625be06e964 Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Mon, 6 Oct 2025 08:49:43 +0900 Subject: [PATCH 06/12] Return NULL for the same points case --- rust/sedona-functions/src/st_azimuth.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rust/sedona-functions/src/st_azimuth.rs b/rust/sedona-functions/src/st_azimuth.rs index dfec9b3d6..9016c5116 100644 --- a/rust/sedona-functions/src/st_azimuth.rs +++ b/rust/sedona-functions/src/st_azimuth.rs @@ -111,12 +111,14 @@ fn invoke_scalar(start: &Wkb, end: &Wkb) -> Result> { } } -// Note: When the two points are completely coincident, PostGIS's ST_Azimuth() -// returns NULL. However, this returns 0.0. fn calc_azimuth(start_x: f64, start_y: f64, end_x: f64, end_y: f64) -> Option { let dx = end_x - start_x; let dy = end_y - start_y; + if dx == 0.0 && dy == 0.0 { + return None; + } + let mut angle = dx.atan2(dy); if angle < 0.0 { angle += 2.0 * std::f64::consts::PI; From a6e05d66a67ed10bbb1abbabef97b34af42f67e2 Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Mon, 6 Oct 2025 08:50:00 +0900 Subject: [PATCH 07/12] Add tests --- rust/sedona-functions/src/st_azimuth.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/rust/sedona-functions/src/st_azimuth.rs b/rust/sedona-functions/src/st_azimuth.rs index 9016c5116..407479380 100644 --- a/rust/sedona-functions/src/st_azimuth.rs +++ b/rust/sedona-functions/src/st_azimuth.rs @@ -165,6 +165,8 @@ mod tests { let east = create_scalar(Some("POINT (1 0)"), &end_type); let south = create_scalar(Some("POINT (0 -1)"), &end_type); let west = create_scalar(Some("POINT (-1 0)"), &end_type); + let same = create_scalar(Some("POINT (0 0)"), &end_type); + let empty = create_scalar(Some("POINT EMPTY"), &end_type); let result = tester .invoke_scalar_scalar(start.clone(), north.clone()) @@ -198,6 +200,19 @@ mod tests { ScalarValue::Float64(Some(val)) if (val - (3.0 * std::f64::consts::FRAC_PI_2)).abs() < 1e-12 )); + // If two points are the same, return NULL + let result = tester + .invoke_scalar_scalar(start.clone(), same.clone()) + .unwrap(); + assert!(result.is_null()); + + // If either one of the points is empty, return NULL + let result = tester + .invoke_scalar_scalar(start.clone(), empty.clone()) + .unwrap(); + assert!(result.is_null()); + + // If either one of the points is NULL, return NULL let result = tester .invoke_scalar_scalar(ScalarValue::Null, north.clone()) .unwrap(); From 055dda5fe3dd467bf2104d34f0ac811952d10b3e Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Mon, 6 Oct 2025 23:01:02 +0900 Subject: [PATCH 08/12] Add Python tests for ST_Azimuth --- .../tests/functions/test_functions.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/python/sedonadb/tests/functions/test_functions.py b/python/sedonadb/tests/functions/test_functions.py index 8811f7038..82b20887e 100644 --- a/python/sedonadb/tests/functions/test_functions.py +++ b/python/sedonadb/tests/functions/test_functions.py @@ -119,6 +119,29 @@ def test_st_astext(eng, geom): eng.assert_query_result(f"SELECT ST_AsText({geom_or_null(geom)})", expected) +@pytest.mark.parametrize("eng", [SedonaDB, PostGIS]) +@pytest.mark.parametrize( + ("geom1", "geom2", "expected"), + [ + # TODO: PostGIS fails without explicit ::GEOMETRY type cast, but casting + # doesn't work on SedonaDB yet. + # (None, None, None), + ("POINT (0 0)", None, None), + (None, "POINT (0 0)", None), + ("POINT (0 0)", "POINT (0 0)", None), + ("POINT (0 0)", "POINT (1 1)", 0.7853981633974483), # 45 / 180 * PI + ("POINT (0 0)", "POINT (-1 -1)", 3.9269908169872414), # 225 / 180 * PI + ], +) +def test_st_azimuth(eng, geom1, geom2, expected): + eng = eng.create_or_skip() + eng.assert_query_result( + f"SELECT ST_Azimuth({geom_or_null(geom1)}, {geom_or_null(geom2)})", + expected, + numeric_epsilon=1e-8, + ) + + @pytest.mark.parametrize("eng", [SedonaDB, PostGIS]) @pytest.mark.parametrize( ("geom", "dist", "expected_area"), From a85e2c6bc34262182244c9b106df0b827a6fb895 Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Mon, 6 Oct 2025 23:05:00 +0900 Subject: [PATCH 09/12] Update rust/sedona-functions/src/st_azimuth.rs Co-authored-by: Dewey Dunnington --- rust/sedona-functions/src/st_azimuth.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rust/sedona-functions/src/st_azimuth.rs b/rust/sedona-functions/src/st_azimuth.rs index 407479380..e5c45585b 100644 --- a/rust/sedona-functions/src/st_azimuth.rs +++ b/rust/sedona-functions/src/st_azimuth.rs @@ -105,9 +105,7 @@ fn invoke_scalar(start: &Wkb, end: &Wkb) -> Result> { _ => Ok(None), } } - _ => Err(datafusion_common::error::DataFusionError::Execution( - "ST_Azimuth expects both arguments to be POINT geometries".into(), - )), + _ => exec_err!("ST_Azimuth expects both arguments to be POINT geometries"), } } From 8fbff66d56fd6f19e520a07ae388075f136bd142 Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Mon, 6 Oct 2025 23:06:07 +0900 Subject: [PATCH 10/12] Add imports --- rust/sedona-functions/src/st_azimuth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/sedona-functions/src/st_azimuth.rs b/rust/sedona-functions/src/st_azimuth.rs index e5c45585b..c58d9ecee 100644 --- a/rust/sedona-functions/src/st_azimuth.rs +++ b/rust/sedona-functions/src/st_azimuth.rs @@ -16,7 +16,7 @@ // under the License. use arrow_array::builder::Float64Builder; use arrow_schema::DataType; -use datafusion_common::error::Result; +use datafusion_common::{error::Result, exec_err}; use datafusion_expr::{ scalar_doc_sections::DOC_SECTION_OTHER, ColumnarValue, Documentation, Volatility, }; From 9904cbe30b5eb9dd31adc6c0b7afd9cf4472fdf6 Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Mon, 6 Oct 2025 23:10:48 +0900 Subject: [PATCH 11/12] Reject empty points --- rust/sedona-functions/src/st_azimuth.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/rust/sedona-functions/src/st_azimuth.rs b/rust/sedona-functions/src/st_azimuth.rs index c58d9ecee..d8a6d32d8 100644 --- a/rust/sedona-functions/src/st_azimuth.rs +++ b/rust/sedona-functions/src/st_azimuth.rs @@ -101,11 +101,13 @@ fn invoke_scalar(start: &Wkb, end: &Wkb) -> Result> { end_coord.x(), end_coord.y(), )), - // If either of the points is empty, the result is NULL - _ => Ok(None), + // If either of the points is empty, raise an error. + _ => { + exec_err!("ST_Azimuth expects both arguments to be non-empty POINT geometries") + } } } - _ => exec_err!("ST_Azimuth expects both arguments to be POINT geometries"), + _ => exec_err!("ST_Azimuth expects both arguments to be non-empty POINT geometries"), } } @@ -205,10 +207,14 @@ mod tests { assert!(result.is_null()); // If either one of the points is empty, return NULL - let result = tester - .invoke_scalar_scalar(start.clone(), empty.clone()) - .unwrap(); - assert!(result.is_null()); + let result = tester.invoke_scalar_scalar(start.clone(), empty.clone()); + assert!( + result.is_err() + && result + .unwrap_err() + .to_string() + .contains("ST_Azimuth expects both arguments to be non-empty POINT geometries") + ); // If either one of the points is NULL, return NULL let result = tester From 95bfd01e02c592bf638a013ced67ff2dd7c15e36 Mon Sep 17 00:00:00 2001 From: Hiroaki Yutani Date: Mon, 6 Oct 2025 23:22:38 +0900 Subject: [PATCH 12/12] Use degrees() in document --- rust/sedona-functions/src/st_azimuth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/sedona-functions/src/st_azimuth.rs b/rust/sedona-functions/src/st_azimuth.rs index d8a6d32d8..d74f4b19f 100644 --- a/rust/sedona-functions/src/st_azimuth.rs +++ b/rust/sedona-functions/src/st_azimuth.rs @@ -49,7 +49,7 @@ fn st_azimuth_doc() -> Documentation { .with_argument("geomA", "geometry: Start point geometry") .with_argument("geomB", "geometry: End point geometry") .with_sql_example( - "SELECT ST_Azimuth(ST_Point(0, 0), ST_Point(1, 1))", + "SELECT degrees(ST_Azimuth(ST_Point(0, 0), ST_Point(1, 1)))", ) .build() }