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/06/16 04:10:00 UTC
(sedona-db) branch main updated: feat(rust/sedona-raster-zarr): derive georeferencing from coordinate arrays (#954)
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 88c255ec4 feat(rust/sedona-raster-zarr): derive georeferencing from coordinate arrays (#954)
88c255ec4 is described below
commit 88c255ec45ccf665b2a459c5da28a46bc811057f
Author: James Willis <ja...@users.noreply.github.com>
AuthorDate: Mon Jun 15 21:09:55 2026 -0700
feat(rust/sedona-raster-zarr): derive georeferencing from coordinate arrays (#954)
---
rust/sedona-raster-zarr/src/coords.rs | 218 ++++++++++++++++++++++++
rust/sedona-raster-zarr/src/lib.rs | 1 +
rust/sedona-raster-zarr/src/loader.rs | 108 +++++++++---
rust/sedona-raster-zarr/tests/zarr_roundtrip.rs | 114 +++++++++++++
4 files changed, 416 insertions(+), 25 deletions(-)
diff --git a/rust/sedona-raster-zarr/src/coords.rs b/rust/sedona-raster-zarr/src/coords.rs
new file mode 100644
index 000000000..fdc99c164
--- /dev/null
+++ b/rust/sedona-raster-zarr/src/coords.rs
@@ -0,0 +1,218 @@
+// 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.
+
+//! Deriving a 2-D geotransform from a Zarr group's 1-D spatial coordinate
+//! arrays, for CF-style datasets that carry coordinate variables but no
+//! explicit `spatial:transform`.
+//!
+//! An affine transform can only represent a *uniform* grid, so the only
+//! usable coordinate arrays are numeric ones with a constant step. We read the
+//! values, confirm regular spacing, and synthesize the transform — treating
+//! the coordinate values as cell centers (a half-step shift gives the cell-edge
+//! origin GDAL expects). `latitude`/`longitude`-named axes additionally imply
+//! geographic `EPSG:4326`; generic `y`/`x` get the transform but no CRS (the
+//! coordinate space is unlabeled — attach a CRS with `RS_SetCRS`).
+
+use arrow_schema::ArrowError;
+use sedona_common::sedona_internal_datafusion_err;
+use zarrs::array::data_type::{
+ Float32DataType, Float64DataType, Int16DataType, Int32DataType, Int64DataType, Int8DataType,
+ UInt16DataType, UInt32DataType, UInt64DataType, UInt8DataType,
+};
+use zarrs::array::{Array, ArraySubset};
+use zarrs::storage::AsyncReadableListableStorage;
+
+/// Relative tolerance for the regular-spacing check, generous enough to absorb
+/// float32 coordinate rounding (e.g. a 0.25° step stored as `f32`).
+const STEP_REL_TOL: f64 = 1e-3;
+
+/// Read a 1-D coordinate array's values as `f64`.
+///
+/// Returns `Ok(None)` — not an error — when the array is absent, not 1-D, or
+/// has a non-numeric dtype, so the caller can fall back gracefully.
+pub(crate) async fn read_coord_values(
+ storage: &AsyncReadableListableStorage,
+ name: &str,
+) -> Result<Option<Vec<f64>>, ArrowError> {
+ let path = format!("/{}", name.trim_start_matches('/'));
+ let Ok(array) = Array::async_open(storage.clone(), &path).await else {
+ return Ok(None);
+ };
+ if array.shape().len() != 1 {
+ return Ok(None);
+ }
+ let subset = ArraySubset::new_with_shape(array.shape().to_vec());
+
+ macro_rules! retrieve_as {
+ ($t:ty) => {{
+ let values: Vec<$t> = array
+ .async_retrieve_array_subset::<Vec<$t>>(&subset)
+ .await
+ .map_err(|e| {
+ ArrowError::ExternalError(Box::new(sedona_internal_datafusion_err!(
+ "failed to read coordinate array {path}: {e}"
+ )))
+ })?;
+ // Lossless for the float dtypes and exact for the small-magnitude
+ // integer coordinates seen in practice; `as f64` can lose precision
+ // only for i64/u64 magnitudes above 2^53, which a coordinate axis
+ // would never reach.
+ values.into_iter().map(|v| v as f64).collect::<Vec<f64>>()
+ }};
+ }
+
+ let dt = array.data_type();
+ let values = if dt.is::<Float64DataType>() {
+ retrieve_as!(f64)
+ } else if dt.is::<Float32DataType>() {
+ retrieve_as!(f32)
+ } else if dt.is::<Int64DataType>() {
+ retrieve_as!(i64)
+ } else if dt.is::<Int32DataType>() {
+ retrieve_as!(i32)
+ } else if dt.is::<Int16DataType>() {
+ retrieve_as!(i16)
+ } else if dt.is::<Int8DataType>() {
+ retrieve_as!(i8)
+ } else if dt.is::<UInt64DataType>() {
+ retrieve_as!(u64)
+ } else if dt.is::<UInt32DataType>() {
+ retrieve_as!(u32)
+ } else if dt.is::<UInt16DataType>() {
+ retrieve_as!(u16)
+ } else if dt.is::<UInt8DataType>() {
+ retrieve_as!(u8)
+ } else {
+ // Non-numeric coordinate (e.g. fixed-length string, datetime): not
+ // usable for a numeric affine, so fall back rather than erroring.
+ return Ok(None);
+ };
+ Ok(Some(values))
+}
+
+/// The uniform step of a regularly-spaced coordinate array, or `None` if it is
+/// too short, zero-width, or irregular (beyond [`STEP_REL_TOL`]).
+pub(crate) fn regular_step(values: &[f64]) -> Option<f64> {
+ if values.len() < 2 {
+ return None;
+ }
+ // An interior NaN/inf would slip past the per-gap check below (every
+ // comparison against NaN is false), so reject non-finite values up front.
+ if values.iter().any(|v| !v.is_finite()) {
+ return None;
+ }
+ // Average step, so float rounding doesn't bias the result toward the first
+ // gap; then confirm every gap matches it within tolerance.
+ let step = (values[values.len() - 1] - values[0]) / (values.len() - 1) as f64;
+ if step == 0.0 || !step.is_finite() {
+ return None;
+ }
+ let tol = step.abs() * STEP_REL_TOL;
+ if values
+ .windows(2)
+ .any(|w| ((w[1] - w[0]) - step).abs() > tol)
+ {
+ return None;
+ }
+ Some(step)
+}
+
+/// Synthesize a GDAL geotransform
+/// `[origin_x, scale_x, skew_x, origin_y, skew_y, scale_y]` from regular 1-D
+/// `x` (column) and `y` (row) coordinate *cell-center* values.
+///
+/// Coordinates are cell centers, so each origin is shifted half a step back to
+/// the cell edge. 1-D rectilinear coordinates carry no rotation, so the skews
+/// are zero. Returns `None` if either axis is not regularly spaced.
+pub(crate) fn transform_from_coords(x: &[f64], y: &[f64]) -> Option<[f64; 6]> {
+ let scale_x = regular_step(x)?;
+ let scale_y = regular_step(y)?;
+ Some([
+ x[0] - scale_x / 2.0,
+ scale_x,
+ 0.0,
+ y[0] - scale_y / 2.0,
+ 0.0,
+ scale_y,
+ ])
+}
+
+/// Infer a geographic CRS from the spatial dimension names: `lat`/`latitude`
+/// paired with `lon`/`longitude` implies `EPSG:4326`. Generic `y`/`x` carry no
+/// implied CRS — the derived transform is still valid, just in an unlabeled
+/// coordinate space (attach a CRS with `RS_SetCRS`).
+pub(crate) fn infer_geographic_crs(y_name: &str, x_name: &str) -> Option<&'static str> {
+ let is_lat = |s: &str| matches!(s.to_ascii_lowercase().as_str(), "lat" | "latitude");
+ let is_lon = |s: &str| matches!(s.to_ascii_lowercase().as_str(), "lon" | "longitude");
+ (is_lat(y_name) && is_lon(x_name)).then_some("EPSG:4326")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn regular_step_detects_uniform_spacing() {
+ assert_eq!(regular_step(&[0.0, 0.25, 0.5, 0.75]), Some(0.25));
+ // Descending (e.g. north-to-south latitude) yields a negative step.
+ assert_eq!(regular_step(&[45.0, 44.75, 44.5]), Some(-0.25));
+ // float32-style rounding within tolerance still reads as regular; the
+ // returned step is the average, so compare approximately.
+ let step = regular_step(&[0.0, 0.1, 0.2000001, 0.3]).unwrap();
+ assert!((step - 0.1).abs() < 1e-6, "step={step}");
+ }
+
+ #[test]
+ fn regular_step_rejects_irregular_or_degenerate() {
+ assert_eq!(regular_step(&[0.0, 1.0, 3.0]), None); // irregular
+ assert_eq!(regular_step(&[5.0]), None); // too short
+ assert_eq!(regular_step(&[2.0, 2.0, 2.0]), None); // zero step
+ }
+
+ #[test]
+ fn regular_step_rejects_non_finite() {
+ // A non-finite value anywhere (incl. an interior one, which the per-gap
+ // check can't catch) must be rejected.
+ assert_eq!(regular_step(&[0.0, f64::NAN, 2.0]), None);
+ assert_eq!(regular_step(&[0.0, 1.0, f64::INFINITY]), None);
+ assert_eq!(regular_step(&[f64::NAN, 1.0, 2.0]), None);
+ }
+
+ #[test]
+ fn transform_from_coords_shifts_to_cell_edge() {
+ // Hurricane-style: lon 265..320 step 0.25, lat 45..15 step -0.25.
+ let lon: Vec<f64> = (0..221).map(|i| 265.0 + i as f64 * 0.25).collect();
+ let lat: Vec<f64> = (0..121).map(|i| 45.0 - i as f64 * 0.25).collect();
+ let t = transform_from_coords(&lon, &lat).unwrap();
+ assert_eq!(t, [264.875, 0.25, 0.0, 45.125, 0.0, -0.25]);
+ }
+
+ #[test]
+ fn transform_from_coords_none_when_irregular() {
+ assert_eq!(transform_from_coords(&[0.0, 1.0, 3.0], &[0.0, 1.0]), None);
+ }
+
+ #[test]
+ fn infer_crs_only_for_lat_lon() {
+ assert_eq!(
+ infer_geographic_crs("latitude", "longitude"),
+ Some("EPSG:4326")
+ );
+ assert_eq!(infer_geographic_crs("lat", "lon"), Some("EPSG:4326"));
+ assert_eq!(infer_geographic_crs("y", "x"), None);
+ }
+}
diff --git a/rust/sedona-raster-zarr/src/lib.rs b/rust/sedona-raster-zarr/src/lib.rs
index fdee437e3..20178041d 100644
--- a/rust/sedona-raster-zarr/src/lib.rs
+++ b/rust/sedona-raster-zarr/src/lib.rs
@@ -27,6 +27,7 @@
//!
//! Local filesystem stores only — `file://` URIs or bare paths.
+mod coords;
mod dtype;
mod geozarr;
mod loader;
diff --git a/rust/sedona-raster-zarr/src/loader.rs b/rust/sedona-raster-zarr/src/loader.rs
index b70bb145e..f2514b882 100644
--- a/rust/sedona-raster-zarr/src/loader.rs
+++ b/rust/sedona-raster-zarr/src/loader.rs
@@ -40,6 +40,7 @@ use zarrs::group::Group;
use zarrs::node::NodeMetadata;
use zarrs::storage::{AsyncReadableListableStorage, AsyncReadableListableStorageTraits};
+use crate::coords;
use crate::dtype::zarr_to_band_data_type;
use crate::geozarr::GroupGeoMetadata;
use crate::source_uri::build_chunk_anchor;
@@ -252,6 +253,10 @@ struct ArrayInfo {
/// position — ragged final chunks are not emitted as separate short
/// rows.
chunk_shape: Vec<u64>,
+ /// Full array shape (extent per dim). Used to validate that a spatial
+ /// coordinate array's length matches the data extent before deriving a
+ /// geotransform from it.
+ shape: Vec<u64>,
/// Encoded fill value in native-endian byte representation, for the
/// `nodata` field. None when the array has no fill value declared.
nodata: Option<Vec<u8>>,
@@ -279,21 +284,7 @@ async fn open_and_validate(
)))
})?;
- let geo = GroupGeoMetadata::from_attributes(group.attributes())?;
-
- // CRS-without-transform is almost always a malformed-metadata bug —
- // the user thinks they have full georef but downstream spatial joins
- // will silently use the identity-pixel-coords default. Error loudly
- // so they fix the metadata rather than getting empty result sets.
- if geo.crs.is_some() && geo.transform.is_none() {
- return Err(ArrowError::InvalidArgumentError(format!(
- "Zarr group at {group_uri} declares a CRS but no `spatial:transform` \
- attribute; refusing to fall back to the identity transform because \
- that would silently produce wrong results in spatial joins. Declare \
- `spatial:transform` on the group or remove the CRS to read this as \
- a non-georeferenced datacube."
- )));
- }
+ let mut geo = GroupGeoMetadata::from_attributes(group.attributes())?;
let arrays = match arrays_filter {
// Explicit filter: open each named array directly, skipping the
@@ -358,19 +349,84 @@ async fn open_and_validate(
let spatial_dim_indices =
resolve_spatial_dim_indices(&array_infos[0].dim_names, geo.spatial_dims.as_deref())?;
+ // Effective transform + CRS. An explicit GeoZarr `spatial:transform` wins;
+ // failing that, derive one from the spatial coordinate arrays (the common
+ // CF / non-GeoZarr case); failing that, fall back to identity pixel coords.
let group_transform = match geo.transform {
Some(t) => t,
None => {
- // Both `spatial:transform` and `proj:*` are absent (the
- // CRS-only case errored above). Fall back to identity pixel
- // coordinates and breadcrumb a warning so spatial-join
- // surprises are debuggable.
- log::warn!(
- "Zarr group at {group_uri} has no `spatial:transform`; falling back \
- to the identity pixel-coordinate transform [0, 1, 0, 0, 0, -1]. \
- Spatial operations against this raster will use pixel coordinates."
- );
- [0.0, 1.0, 0.0, 0.0, 0.0, -1.0]
+ let y_axis = spatial_dim_indices[0];
+ let x_axis = spatial_dim_indices[1];
+ let y_name = array_infos[0].dim_names[y_axis].clone();
+ let x_name = array_infos[0].dim_names[x_axis].clone();
+ let x_vals = coords::read_coord_values(&storage, &x_name).await?;
+ let y_vals = coords::read_coord_values(&storage, &y_name).await?;
+
+ // A coordinate array must span the data extent along its axis; a
+ // length mismatch is a malformed coord variable that would yield a
+ // wrong scale, so refuse to derive a transform from it.
+ let x_ok = x_vals
+ .as_ref()
+ .is_none_or(|v| v.len() as u64 == array_infos[0].shape[x_axis]);
+ let y_ok = y_vals
+ .as_ref()
+ .is_none_or(|v| v.len() as u64 == array_infos[0].shape[y_axis]);
+ if !(x_ok && y_ok) {
+ log::warn!(
+ "Zarr group at {group_uri}: a spatial coordinate array's length does \
+ not match the data extent; ignoring coordinates for georeferencing"
+ );
+ }
+ let derived = match (x_ok && y_ok, x_vals, y_vals) {
+ (true, Some(x), Some(y)) => coords::transform_from_coords(&x, &y),
+ _ => None,
+ };
+ match derived {
+ Some(t) => {
+ // Regular CF coordinate arrays imply geographic lon/lat;
+ // infer the CRS from the dim names only when none was
+ // declared. Generic y/x stay CRS-less (attach via RS_SetCRS).
+ let crs_note = if let Some(declared) = geo.crs.as_deref() {
+ format!("keeping the declared CRS {declared:?}")
+ } else if let Some(inferred) = coords::infer_geographic_crs(&y_name, &x_name) {
+ geo.crs = Some(inferred.to_string());
+ format!("inferred CRS {inferred} from the dim names")
+ } else {
+ "no CRS inferred (spatial dims are not lat/lon) — set one with RS_SetCRS"
+ .to_string()
+ };
+ log::debug!(
+ "Zarr group at {group_uri} has no `spatial:transform`; derived a \
+ geotransform from the {x_name:?}/{y_name:?} coordinate arrays; {crs_note}"
+ );
+ t
+ }
+ // CRS-without-transform and no usable coordinate arrays is
+ // almost always malformed metadata; error rather than silently
+ // using pixel coordinates in spatial joins.
+ None if geo.crs.is_some() => {
+ return Err(ArrowError::InvalidArgumentError(format!(
+ "Zarr group at {group_uri} declares a CRS but has neither a \
+ `spatial:transform` attribute nor regularly-spaced numeric \
+ spatial coordinate arrays; refusing to fall back to the \
+ identity transform because that would silently produce wrong \
+ results in spatial joins. Declare `spatial:transform`, provide \
+ regular coordinate arrays, or remove the CRS to read this as a \
+ non-georeferenced datacube."
+ )));
+ }
+ // No CRS and no usable coordinates: index space, with a
+ // breadcrumb so spatial-join surprises are debuggable.
+ None => {
+ log::warn!(
+ "Zarr group at {group_uri} has no `spatial:transform` and no \
+ usable spatial coordinate arrays; falling back to the identity \
+ pixel-coordinate transform [0, 1, 0, 0, 0, -1]. Spatial \
+ operations against this raster will use pixel coordinates."
+ );
+ [0.0, 1.0, 0.0, 0.0, 0.0, -1.0]
+ }
+ }
}
};
@@ -498,6 +554,7 @@ fn collect_array_infos(
.iter()
.map(|n| n.get())
.collect();
+ let shape = array.shape().to_vec();
let fill_bytes = array.fill_value().as_ne_bytes();
let nodata = if fill_bytes.is_empty() {
None
@@ -510,6 +567,7 @@ fn collect_array_infos(
dim_names,
chunk_grid_shape,
chunk_shape,
+ shape,
nodata,
});
}
diff --git a/rust/sedona-raster-zarr/tests/zarr_roundtrip.rs b/rust/sedona-raster-zarr/tests/zarr_roundtrip.rs
index a7ee38863..beba12008 100644
--- a/rust/sedona-raster-zarr/tests/zarr_roundtrip.rs
+++ b/rust/sedona-raster-zarr/tests/zarr_roundtrip.rs
@@ -443,3 +443,117 @@ async fn errors_on_mismatched_chunk_grids() {
"got: {err}"
);
}
+
+/// A CF-style group with no `spatial:transform`: a single-chunk 2-D data array
+/// `temperature` with dims `[y_name, x_name]` plus matching 1-D coordinate
+/// arrays. Georeferencing must be derived from the (regularly spaced)
+/// coordinate values. `&[f64]` implements `IntoArrayBytes`, so the element
+/// slice goes straight to `store_chunk` (typed `store_chunk_elements` is
+/// deprecated).
+fn build_coord_fixture(y_name: &str, y_vals: &[f64], x_name: &str, x_vals: &[f64]) -> TempDir {
+ let tmp = TempDir::new().unwrap();
+ let store = Arc::new(FilesystemStore::new(tmp.path()).unwrap());
+
+ // No spatial:transform, no proj:* — only coordinate arrays.
+ GroupBuilder::new()
+ .build(store.clone(), "/")
+ .unwrap()
+ .store_metadata()
+ .unwrap();
+
+ // 1-D coordinate arrays, single chunk each.
+ for (name, vals) in [(y_name, y_vals), (x_name, x_vals)] {
+ let n = vals.len() as u64;
+ let arr = ArrayBuilder::new(vec![n], vec![n], data_type::float64(), 0.0f64)
+ .dimension_names(Some([name]))
+ .build(store.clone(), &format!("/{name}"))
+ .unwrap();
+ arr.store_metadata().unwrap();
+ arr.store_chunk(&[0], vals).unwrap();
+ }
+
+ // 2-D data array, dims [y_name, x_name], single chunk.
+ let (ny, nx) = (y_vals.len() as u64, x_vals.len() as u64);
+ let data = ArrayBuilder::new(vec![ny, nx], vec![ny, nx], data_type::uint8(), 0u8)
+ .dimension_names(Some([y_name, x_name]))
+ .build(store.clone(), "/temperature")
+ .unwrap();
+ data.store_metadata().unwrap();
+ data.store_chunk(&[0, 0], vec![0u8; (ny * nx) as usize])
+ .unwrap();
+
+ tmp
+}
+
+#[tokio::test]
+async fn derives_geotransform_and_crs_from_coordinate_arrays() {
+ let tmp = build_coord_fixture("latitude", &[20.0, 19.0], "longitude", &[10.0, 11.0, 12.0]);
+ let uri = format!("file://{}", tmp.path().display());
+ let arr = read_all(&uri, None).await.unwrap();
+
+ let rasters = RasterStructArray::new(&arr);
+ assert_eq!(
+ rasters.len(),
+ 1,
+ "single-chunk data array -> one raster row"
+ );
+ let raster = rasters.get(0).unwrap();
+
+ // lon [10,11,12] step +1 -> scale_x 1, origin_x 10 - 0.5 = 9.5
+ // lat [20,19] step -1 -> scale_y -1, origin_y 20 - (-0.5) = 20.5
+ assert_eq!(
+ raster.transform().to_vec(),
+ vec![9.5, 1.0, 0.0, 20.5, 0.0, -1.0]
+ );
+ // latitude/longitude dim names imply geographic EPSG:4326.
+ assert_eq!(raster.crs(), Some("EPSG:4326"));
+}
+
+#[tokio::test]
+async fn derives_transform_without_crs_for_generic_xy_dims() {
+ // `y`/`x` (not lat/lon): transform is still derived from the coordinates,
+ // but no CRS is inferred — the user attaches one with RS_SetCRS.
+ let tmp = build_coord_fixture("y", &[20.0, 19.0], "x", &[10.0, 11.0, 12.0]);
+ let uri = format!("file://{}", tmp.path().display());
+ let arr = read_all(&uri, None).await.unwrap();
+
+ let raster = RasterStructArray::new(&arr).get(0).unwrap();
+ assert_eq!(
+ raster.transform().to_vec(),
+ vec![9.5, 1.0, 0.0, 20.5, 0.0, -1.0]
+ );
+ assert_eq!(raster.crs(), None);
+}
+
+#[tokio::test]
+async fn irregular_coordinate_arrays_fall_back_to_identity() {
+ // x is irregularly spaced (0, 1, 3), so no affine can represent it; with no
+ // declared CRS the reader falls back to the identity pixel transform.
+ let tmp = build_coord_fixture("y", &[0.0, 1.0], "x", &[0.0, 1.0, 3.0]);
+ let uri = format!("file://{}", tmp.path().display());
+ let arr = read_all(&uri, None).await.unwrap();
+
+ let raster = RasterStructArray::new(&arr).get(0).unwrap();
+ assert_eq!(
+ raster.transform().to_vec(),
+ vec![0.0, 1.0, 0.0, 0.0, 0.0, -1.0]
+ );
+ assert_eq!(raster.crs(), None);
+}
+
+#[tokio::test]
+async fn derives_transform_with_explicit_arrays_filter() {
+ // The coordinate arrays are not in the `arrays` filter, but the reader
+ // opens them by name, so derivation still works.
+ let tmp = build_coord_fixture("latitude", &[20.0, 19.0], "longitude", &[10.0, 11.0, 12.0]);
+ let uri = format!("file://{}", tmp.path().display());
+ let arrays = ["temperature".to_string()];
+ let arr = read_all(&uri, Some(&arrays)).await.unwrap();
+
+ let raster = RasterStructArray::new(&arr).get(0).unwrap();
+ assert_eq!(
+ raster.transform().to_vec(),
+ vec![9.5, 1.0, 0.0, 20.5, 0.0, -1.0]
+ );
+ assert_eq!(raster.crs(), Some("EPSG:4326"));
+}