diff --git a/common/src/test/java/org/apache/sedona/common/raster/CrsRoundTripComplianceTest.java b/common/src/test/java/org/apache/sedona/common/raster/CrsRoundTripComplianceTest.java index ef6e8ad5868..c58f4e02823 100644 --- a/common/src/test/java/org/apache/sedona/common/raster/CrsRoundTripComplianceTest.java +++ b/common/src/test/java/org/apache/sedona/common/raster/CrsRoundTripComplianceTest.java @@ -158,11 +158,21 @@ public void testProjJsonRoundTrip_Geographic_NAD83_4269() throws FactoryExceptio assertProjJsonRoundTrip(4269); } + @Test + public void testProjJsonRoundTrip_Geographic_NAD27_4267() throws FactoryException { + assertProjJsonRoundTrip(4267); + } + @Test public void testProjJsonRoundTrip_TransverseMercator_32617() throws FactoryException { assertProjJsonRoundTrip(32617); } + @Test + public void testProjJsonRoundTrip_TransverseMercator_NAD27_26711() throws FactoryException { + assertProjJsonRoundTrip(26711); + } + @Test public void testProjJsonRoundTrip_PseudoMercator_3857() throws FactoryException { assertProjJsonRoundTrip(3857); @@ -361,11 +371,21 @@ public void testWkt2RoundTrip_Geographic_NAD83_4269() throws FactoryException { assertWkt2RoundTrip(4269); } + @Test + public void testWkt2RoundTrip_Geographic_NAD27_4267() throws FactoryException { + assertWkt2RoundTrip(4267); + } + @Test public void testWkt2RoundTrip_TransverseMercator_32617() throws FactoryException { assertWkt2RoundTrip(32617); } + @Test + public void testWkt2RoundTrip_TransverseMercator_NAD27_26711() throws FactoryException { + assertWkt2RoundTrip(26711); + } + @Test public void testWkt2RoundTrip_PseudoMercator_3857() throws FactoryException { assertWkt2RoundTrip(3857); @@ -447,13 +467,13 @@ public void testWkt2RoundTrip_ObliqueStereographic_28992() throws FactoryExcepti } // --------------------------------------------------------------------------- - // PROJJSON import failures — spherical datums not parseable after round-trip + // Spherical datum coverage // --------------------------------------------------------------------------- @Test - public void testProjJsonRoundTrip_LambertAzimuthalEqualArea_Spherical_2163_importFails() + public void testProjJsonRoundTrip_LambertAzimuthalEqualArea_Spherical_2163() throws FactoryException { - assertProjJsonImportFails(2163); + assertProjJsonRoundTrip(2163); } @Test @@ -534,6 +554,10 @@ private void assertProjJsonRoundTrip(int epsg) throws FactoryException { // First export from EPSG String export1 = RasterAccessors.crs(raster1, "projjson"); assertNotNull("EPSG:" + epsg + " export to PROJJSON should not be null", export1); + assertEquals( + "EPSG:" + epsg + " default CRS export should equal explicit PROJJSON", + export1, + RasterAccessors.crs(raster1)); // Re-import from PROJJSON string and re-export GridCoverage2D raster2 = RasterEditors.setCrs(baseRaster, export1); @@ -580,29 +604,6 @@ private void assertWkt2RoundTrip(int epsg) throws FactoryException { export3); } - /** - * Assert that PROJJSON export succeeds but re-import fails (spherical datum CRS that proj4sedona - * can export but GeoTools cannot re-parse). - */ - private void assertProjJsonImportFails(int epsg) throws FactoryException { - GridCoverage2D baseRaster = RasterConstructors.makeEmptyRaster(1, 4, 4, 0, 0, 1); - GridCoverage2D raster1 = RasterEditors.setCrs(baseRaster, "EPSG:" + epsg); - - // Export should succeed - String exported = RasterAccessors.crs(raster1, "projjson"); - assertNotNull("EPSG:" + epsg + " export to PROJJSON should succeed", exported); - - // Re-import should fail - Exception thrown = - assertThrows( - "EPSG:" + epsg + " PROJJSON re-import should fail for spherical datum", - IllegalArgumentException.class, - () -> RasterEditors.setCrs(baseRaster, exported)); - assertTrue( - "Error message should mention CRS parsing", - thrown.getMessage().contains("Cannot parse CRS string")); - } - /** * Assert a full WKT1 format round trip: EPSG → RS_CRS("wkt1") → RS_SetCRS → RS_CRS("wkt1"). WKT1 * includes AUTHORITY["EPSG","xxxx"] so SRID is always preserved. diff --git a/docs/tutorial/files/geoparquet-sedona-spark.md b/docs/tutorial/files/geoparquet-sedona-spark.md index 5310cccfad0..c2370afa8a5 100644 --- a/docs/tutorial/files/geoparquet-sedona-spark.md +++ b/docs/tutorial/files/geoparquet-sedona-spark.md @@ -210,7 +210,12 @@ When no `geoparquet.crs` option is explicitly provided, Sedona will automaticall * If geometries in a column have mixed SRIDs, the `crs` field defaults to `null`. * If an explicit `geoparquet.crs` or `geoparquet.crs.` option is provided, it always takes precedence over the SRID-derived CRS. -Sedona geoparquet reader and writer do NOT check the axis order (lon/lat or lat/lon) and assume they are handled by the users themselves when writing / reading the files. You can always use [`ST_FlipCoordinates`](../../api/sql/Geometry-Editors/ST_FlipCoordinates.md) to swap the axis order of your geometries. +GeoParquet stores WKB coordinates in `(x, y)` order. Per the GeoParquet 1.1 +specification, this ordering overrides any axis order declared by the CRS. Sedona +therefore preserves the authoritative CRS axis metadata and does not reorder geometry +coordinates while reading or writing GeoParquet. You can use +[`ST_FlipCoordinates`](../../api/sql/Geometry-Editors/ST_FlipCoordinates.md) when the +coordinates themselves need to be swapped. ## Save GeoParquet with Covering Metadata diff --git a/docs/tutorial/files/geoparquet-sedona-spark.zh.md b/docs/tutorial/files/geoparquet-sedona-spark.zh.md index d2a8cec29c8..4d3c1ccf40e 100644 --- a/docs/tutorial/files/geoparquet-sedona-spark.zh.md +++ b/docs/tutorial/files/geoparquet-sedona-spark.zh.md @@ -207,7 +207,10 @@ df.write.format("geoparquet") * 若同一列中的几何对象 SRID 不一致,`crs` 字段默认置为 `null`。 * 若显式提供了 `geoparquet.crs` 或 `geoparquet.crs.` 选项,则始终优先于 SRID 推导出的 CRS。 -Sedona 的 GeoParquet 读写器**不会**校验坐标轴顺序(lon/lat 还是 lat/lon),假定使用者在读写时自行处理。可以使用 [`ST_FlipCoordinates`](../../api/sql/Geometry-Editors/ST_FlipCoordinates.md) 来交换几何对象的坐标轴顺序。 +GeoParquet 以 `(x, y)` 顺序存储 WKB 坐标。根据 GeoParquet 1.1 规范,此顺序会覆盖 CRS +中声明的坐标轴顺序。因此,Sedona 会保留权威的 CRS 坐标轴元数据,并且在读写 GeoParquet +时不会重新排列几何坐标。如果坐标本身需要交换,可以使用 +[`ST_FlipCoordinates`](../../api/sql/Geometry-Editors/ST_FlipCoordinates.md)。 ## 写出带 covering 元数据的 GeoParquet diff --git a/pom.xml b/pom.xml index 9f6f2af8b7d..53706476950 100644 --- a/pom.xml +++ b/pom.xml @@ -96,7 +96,7 @@ 2.5.0 1.52 2.9.2 - 0.1.2 + 0.1.5 provided diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/CRSTransformProj4Test.scala b/spark/common/src/test/scala/org/apache/sedona/sql/CRSTransformProj4Test.scala index 1159ebc6adc..9bfeac373b8 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/CRSTransformProj4Test.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/CRSTransformProj4Test.scala @@ -83,6 +83,20 @@ class CRSTransformProj4Test extends TestBaseScala { assertEquals(4180998.88, result.getCoordinate.y, COORD_TOLERANCE) } + it("should apply the RT90 datum shift for EPSG:3021 to EPSG:3006 (GH-3161)") { + val result = sparkSession + .sql("SELECT ST_Transform(ST_Point(1272691.622, 6404578.16), 'epsg:3021', 'epsg:3006')") + .first() + .getAs[Geometry](0) + + assertNotNull(result) + assertEquals(3006, result.getSRID) + // PostGIS reference reported in GH-3161. The one-metre suite tolerance allows for + // projection-algorithm differences while guarding against the reported 180-metre error. + assertEquals(320728.44351324334, result.getCoordinate.x, COORD_TOLERANCE) + assertEquals(6400228.104394391, result.getCoordinate.y, COORD_TOLERANCE) + } + it("should transform using PROJ string") { val projString = "+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +no_defs" diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/geoparquetIOTests.scala b/spark/common/src/test/scala/org/apache/sedona/sql/geoparquetIOTests.scala index 85d9d549681..37afe6ad185 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/geoparquetIOTests.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/geoparquetIOTests.scala @@ -1471,6 +1471,35 @@ class geoparquetIOTests extends TestBaseScala with BeforeAndAfterAll { } } + describe("GeoParquetMetaData.sridToProjJson") { + it("should preserve authoritative polar axis metadata") { + implicit val formats: org.json4s.Formats = org.json4s.DefaultFormats + val crs = + GeoParquetMetaData.sridToProjJson(32661).getOrElse(fail("EPSG:32661 should resolve")) + + assert((crs \ "id" \ "authority").extract[String] == "EPSG") + assert((crs \ "id" \ "code").extract[Int] == 32661) + + val axes = (crs \ "coordinate_system" \ "axis").children + assert(axes.map(axis => (axis \ "abbreviation").extract[String]) == List("N", "E")) + assert(axes.map(axis => (axis \ "direction").extract[String]) == List("south", "south")) + assert( + axes.map(axis => (axis \ "meridian" \ "longitude" \ "value").extract[Double]) == + List(180.0, 90.0)) + } + + it("should generate and recover NAD27 projected CRS metadata") { + implicit val formats: org.json4s.Formats = org.json4s.DefaultFormats + val crs = + GeoParquetMetaData.sridToProjJson(26711).getOrElse(fail("EPSG:26711 should resolve")) + + assert((crs \ "type").extract[String] == "ProjectedCRS") + assert((crs \ "id" \ "authority").extract[String] == "EPSG") + assert((crs \ "id" \ "code").extract[Int] == 26711) + assert(GeoParquetMetaData.extractSridFromCrs(Some(crs)) == 26711) + } + } + describe("GeoParquetMetaData.extractSridFromCrs") { it("should return 4326 when CRS is omitted (None)") { assert(GeoParquetMetaData.extractSridFromCrs(None) == 4326)