GS 2.27.5 / GT 33.5 - envelope transformations no longer working correctly

We have encountered an issue with 2.27.5 / 33.5 where for some of our layers the envelope transformation is no longer working correctly.

Background: we have some data stores that have custom CRSs, which we define in eps.properties. And we have epsg_operations.properties that defines transformations for those.

There are many, many places in GeoServer that do Envelope.transform(targetCRS, lenient), especially from/to native CRS to declared CRS and/or request CRS.

Problem: Now with GS 2.27.5 / GT 33.5 the envelope transformation does not work properly anymore. The following figure compares the result of GS 2.27.3 and GS 2.27.5 when pressing the button “Compute from native bounds“.

In this snapshot we are right in Envelope.transform()near the end of the method where the following block is performed:

CoordinateOperationFactory coordinateOperationFactory = CRS.getCoordinateOperationFactory(lenient);

final CoordinateOperation operation = coordinateOperationFactory.createOperation(crs, targetCRS);
final GeneralBounds transformed = CRS.transform(operation, this);
transformed.setCoordinateReferenceSystem(targetCRS);

This example has an envelope in custom polar stereographic projection and the aim is to tranforsm to WGS84 to obtain the lat/lon bounding box.

The src envelope is this:

"ReferencedEnvelope[-523.4622 : 376.53779999999995, -4658.645 : -3758.6450000000004] DefaultProjectedCRS[EPSG:Radolan projection] AXIS["X", EAST] AXIS["Y", NORTH]". 

When starting to look at the screenshot from the bottom, you can see that with GS 2.27.3 the target envelope is something like this:

GS 2.27.3 transformed bounds in WGS84:

"GeneralBounds[(2.071479739549674, 46.95257809439008), (15.720754942504271, 54.905353130084585)]"

That is a bounding box roughly covering Germany. Now looking at GS 2.27.5 the bounding box basically collapses:

GS 2.27.5 transformed bounds in WGS84:

"GeneralBounds[(0.002071479739549676, 0.08995480639774257), (0.015720754942504273, 0.08996376533827592)]"

This is very wrong!

Analysis

The reason seems to be that GT 33.3 and GT 33.5 are choosing different operations to perform the transform.

GT 33.3 chooses

Operation["1000001 ⇨ 4326", 
  SOURCE["Radolan projection", AUTHORITY["EPSG","1000001"]], 
  TARGET["WGS 84", AUTHORITY["EPSG","4326"]], 
  METHOD["Stereographic_North_Pole"]]

using the math transform:

CONCAT_MT[PARAM_MT["Affine", 
    PARAMETER["num_row", 3], 
    PARAMETER["num_col", 3], 
    PARAMETER["elt_0_0", 1000.0], 
    PARAMETER["elt_1_1", 1000.0]], 
  INVERSE_MT[PARAM_MT["Stereographic_North_Pole", 
      PARAMETER["semi_major", 6370040.0], 
      PARAMETER["semi_minor", 6370040.0], 
      PARAMETER["central_meridian", 10.0], 
      PARAMETER["Standard_Parallel_1", 60.0], 
      PARAMETER["scale_factor", 1.0], 
      PARAMETER["false_easting", 0.0], 
      PARAMETER["false_northing", 0.0]]]]

while GT 33.5 picks

Operation["GEOGCS["WGS84(DD)", 
  DATUM["WGS84", 
    SPHEROID["WGS84", 6378137.0, 298.257223563]], 
  PRIMEM["Greenwich", 0.0], 
  UNIT["degree", 0.017453292519943295], 
  AXIS["Geodetic longitude", EAST], 
  AXIS["Geodetic latitude", NORTH], 
  AUTHORITY["EPSG","4326"]] ⇨ PROJCS["Radolan projection", 
  GEOGCS["Radolan Coordinate System", 
    DATUM["Radolan Sphere", 
      SPHEROID["Radolan Sphere", 6370040.0, 0.0]], 
    PRIMEM["Greenwich", 0.0, AUTHORITY["EPSG","8901"]], 
    UNIT["degree", 0.017453292519943295], 
    AXIS["Longitude", EAST], 
    AXIS["Latitude", NORTH]], 
  PROJECTION["Stereographic_North_Pole"], 
  PARAMETER["central_meridian", 10.0], 
  PARAMETER["Standard_Parallel_1", 60.0], 
  PARAMETER["scale_factor", 1.0], 
  PARAMETER["false_easting", 0.0], 
  PARAMETER["false_northing", 0.0], 
  UNIT["km", 1000.0], 
  AXIS["X", EAST], 
  AXIS["Y", NORTH], 
  AUTHORITY["EPSG","1000001"]]", 
  SOURCE["Radolan projection", AUTHORITY["EPSG","1000001"]], 
  TARGET["WGS84(DD)", AUTHORITY["EPSG","4326"]], 
  METHOD["Stereographic_North_Pole"]]

using the math transform

CONCAT_MT[INVERSE_MT[PARAM_MT["Stereographic_North_Pole", 
      PARAMETER["semi_major", 6370040.0], 
      PARAMETER["semi_minor", 6370040.0], 
      PARAMETER["central_meridian", 10.0], 
      PARAMETER["Standard_Parallel_1", 60.0], 
      PARAMETER["scale_factor", 1.0], 
      PARAMETER["false_easting", 0.0], 
      PARAMETER["false_northing", 0.0]]], 
  PARAM_MT["Affine", 
    PARAMETER["num_row", 3], 
    PARAMETER["num_col", 3], 
    PARAMETER["elt_0_0", 0.001], 
    PARAMETER["elt_1_1", 0.001]]]

Both operations seem to be doing the inverse of “polar stereographic” but the scaling is flipped and inverted.

While I am not sure why there are two factories that try doing things differently, surely from the result we know that only what GT 33.3 was doing is correct.

it looks like GT 33.3 used the “AuthorityBackedFactory“ directly, now with https://osgeo-org.atlassian.net/browse/GEOT-7844 the “ManyCoordinateOperationFactory” maintains a pool and chooses the first factory that claims to be able to handle the operation.

In this example the pool of factory of the “ManyCoordinateOperationFactory” looks like this for us:

I haven’t been yet able to dig further, but my feeling is that with GT 33.5 a different factory is used and that one somehow doesn’t work well with our custom epsg.properties and epsg_operations.properties.

Is there any way we can fix this, so that our data-stores are working again?

Thanks!
Sören

Oh btw, the projection we are talking about in this example is:

snippet from epsg.properties:

# RADOLAN projection
1000001=PROJCS["Radolan projection",  \
  GEOGCS["Radolan Coordinate System",  \
    DATUM["Radolan Sphere",  \
      SPHEROID["Radolan Sphere", 6370040.0, 0.0]],  \
    PRIMEM["Greenwich", 0.0, AUTHORITY["EPSG","8901"]],  \
    UNIT["degree", 0.017453292519943295],  \
    AXIS["Longitude", EAST],  \
    AXIS["Latitude", NORTH]],  \
  PROJECTION["Stereographic_North_Pole"],  \
  PARAMETER["central_meridian", 10.0],  \
  PARAMETER["Standard_Parallel_1", 60.0],  \
  PARAMETER["scale_factor", 1.0],  \
  PARAMETER["false_easting", 0.0],  \
  PARAMETER["false_northing", 0.0],  \
  UNIT["km", 1000.0],  \
  AXIS["X", EAST],  \
  AXIS["Y", NORTH],  \
  AUTHORITY["EPSG","1000001"]]

The transformation defined in epsg_operations.properties is :

4326,1000001=CONCAT_MT[ \
  PARAM_MT["Stereographic_North_Pole", \
    PARAMETER["semi_major", 6370040.0], \
    PARAMETER["semi_minor", 6370040.0], \
    PARAMETER["central_meridian", 10.0], \
    PARAMETER["Standard_Parallel_1", 60.0], \
    PARAMETER["scale_factor", 1.0], \
    PARAMETER["false_easting", 0.0], \
    PARAMETER["false_northing", 0.0]]]

Ok, I think I have found the problem:

There’s a conceptual problem in the class org.geotools.referencing.operation.PropertyCoordinateOperationFactory, which is now used by org.vfny.geoserver.crs.GeoserverWKTOperationFactory. ( GEOS-12033 ).

The intention is that users can define their own CRS operations in a file named epsg_operations.properties, where the format is something like:

source,target=transform

So for example if you had a custom operation from EPSG:4326 to EPSG:100001, you could specify:

4326,1000001=CONCAT_MT[ \
  PARAM_MT["Stereographic_North_Pole", \
    PARAMETER["semi_major", 6370040.0], \
    PARAMETER["semi_minor", 6370040.0], \
    PARAMETER["central_meridian", 10.0], \
    PARAMETER["Standard_Parallel_1", 60.0], \
    PARAMETER["scale_factor", 1.0], \
    PARAMETER["false_easting", 0.0], \
    PARAMETER["false_northing", 0.0]]]

Whenever GeoServer would then do transformations between the two CRSs it would basically ask the factory to return the operation that represents the entry in this file.

It is essential to note that this also includes the inverse operation. So if GeoServer wanted to perform a transformation from EPSG:100001 to EPSG:4326, it would ask the same factory and it would seamlessly return the inverse of that operation.

This is what was before GeoServer 2.27.4. Then with 2.27.4 the GeoserverWKTOperationFactory was changed to use that new factory class PropertyCoordinateOperationFactory that did not exist before. The problem now is that that new base class does not properly handle the inverse case anymore.

Let me explain:

Other than the formerly used factory, this new class handles the inverse case by itself in a single call to getCoordinateOperations(…). First it will extract the key-pair from the properties and see if a direct mapping is available. That case seems to work fine and we do not need to explain it further.

Now comes tricky part, if a direct mapping is not found, then the method will invert source and target and look again to find a mapping. If such a mapping is not found, the method returns early and we do not need to discuss further.

BUT in case an inverse mapping is found, the method fails to function properly, because if only the inverted mapping is found, then from now on, we need to flip all the CRS handling and then finally invert the result. But that’s not what is happening. What it does instead is to derive a new math transform to handle axis order and units, but it derives it from the original source, not the original target. This part needs to incorporate that we are now deriving a transform from target to source, which is then later inverted again.

I think this is a bug and needs to be fixed. I was able to verify the fix in our environment and created a test-case that fails, without the fix.

I should probably make a ticket and a PR for this?

Thanks
Sören

Thanks!