From c4b211859e92db456215a3ed60ab646b9380add0 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 10 Nov 2026 12:34:09 +0100 Subject: [PATCH] crs: select coordinate transformations for a provider's transformation area When several coordinate operations connect two coordinate reference systems, the operation was chosen without an area of interest. A national operation then ranks before a more accurate regional one, even when the regional grid file is available, e.g. a gridless Helmert transformation instead of the HeTA2010 grid for DHDN to ETRS89 in Hessen. The new provider option transformationArea (a bounding box in CRS84) is passed to the operation selection for all transformations of the provider, so the most accurate operation whose area of use contains the box is used. Without the option the selection is unchanged. CrsTransformerFactory gets an overload with an area of interest and a view that applies an area to all transformers; transformers are cached per area. A box that covers the whole world selects like no box. --- .../crs/domain/CrsTransformerFactory.java | 21 +++ .../domain/CrsTransformerFactoryForArea.java | 153 +++++++++++++++++ .../crs/infra/CrsTransformerFactoryProj.java | 129 +++++++++++--- .../CrsTransformerAreaOfInterestSpec.groovy | 157 ++++++++++++++++++ .../src/test/resources/proj/README.md | 11 ++ .../proj/de_hvbg_hessen_HeTA2010.tif | Bin 0 -> 29845 bytes .../domain/AbstractFeatureProvider.java | 52 +++++- .../domain/FeatureProviderDataV2.java | 42 +++++ 8 files changed, 539 insertions(+), 26 deletions(-) create mode 100644 xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactoryForArea.java create mode 100644 xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerAreaOfInterestSpec.groovy create mode 100644 xtraplatform-crs/src/test/resources/proj/README.md create mode 100644 xtraplatform-crs/src/test/resources/proj/de_hvbg_hessen_HeTA2010.tif diff --git a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactory.java b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactory.java index 9ccc28983..3f878c9c2 100644 --- a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactory.java +++ b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactory.java @@ -20,4 +20,25 @@ public interface CrsTransformerFactory extends Volatile2 { Optional getTransformer(EpsgCrs sourceCrs, EpsgCrs targetCrs); Optional getTransformer(EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d); + + /** + * Returns a transformer that is selected for an area of interest. When several coordinate + * operations between the two coordinate reference systems are available, the most accurate one + * whose area of use contains the area of interest is chosen, e.g. a regional grid instead of a + * national one. Without an area of interest, or with an area that covers the whole world, the + * selection is the same as for {@link #getTransformer(EpsgCrs, EpsgCrs, boolean)}. + * + * @param areaOfInterest the area of interest, a box in any supported coordinate reference system + */ + Optional getTransformer( + EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d, Optional areaOfInterest); + + /** + * Returns a view of this factory that selects all transformers for the given area of interest, + * see {@link #getTransformer(EpsgCrs, EpsgCrs, boolean, Optional)}. All other methods delegate to + * this factory. If this factory also implements {@link CrsInfo}, so does the view. + */ + default CrsTransformerFactory withAreaOfInterest(BoundingBox areaOfInterest) { + return CrsTransformerFactoryForArea.of(this, areaOfInterest); + } } diff --git a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactoryForArea.java b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactoryForArea.java new file mode 100644 index 000000000..3bfbd5d26 --- /dev/null +++ b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactoryForArea.java @@ -0,0 +1,153 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.crs.domain; + +import de.ii.xtraplatform.base.domain.resiliency.VolatileRegistry.ChangeHandler; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import javax.measure.Unit; +import org.opengis.referencing.cs.AxisDirection; + +/** + * A view of a {@link CrsTransformerFactory} that selects every transformer for a fixed area of + * interest. The view is not registered as a volatile itself, the delegate is. + */ +class CrsTransformerFactoryForArea implements CrsTransformerFactory { + + private final CrsTransformerFactory delegate; + private final Optional areaOfInterest; + + CrsTransformerFactoryForArea(CrsTransformerFactory delegate, BoundingBox areaOfInterest) { + this.delegate = Objects.requireNonNull(delegate); + this.areaOfInterest = Optional.of(areaOfInterest); + } + + static CrsTransformerFactory of(CrsTransformerFactory delegate, BoundingBox areaOfInterest) { + return delegate instanceof CrsInfo + ? new WithCrsInfo(delegate, areaOfInterest) + : new CrsTransformerFactoryForArea(delegate, areaOfInterest); + } + + @Override + public boolean isSupported(EpsgCrs crs) { + return delegate.isSupported(crs); + } + + @Override + public Optional getTransformer(EpsgCrs sourceCrs, EpsgCrs targetCrs) { + return delegate.getTransformer(sourceCrs, targetCrs, false, areaOfInterest); + } + + @Override + public Optional getTransformer( + EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d) { + return delegate.getTransformer(sourceCrs, targetCrs, force2d, areaOfInterest); + } + + /** An explicit area of interest takes precedence over the area of this view. */ + @Override + public Optional getTransformer( + EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d, Optional areaOfInterest) { + return delegate.getTransformer( + sourceCrs, targetCrs, force2d, areaOfInterest.or(() -> this.areaOfInterest)); + } + + @Override + public CrsTransformerFactory withAreaOfInterest(BoundingBox areaOfInterest) { + return of(delegate, areaOfInterest); + } + + @Override + public String getUniqueKey() { + return delegate.getUniqueKey(); + } + + @Override + public Optional getInstanceId() { + return delegate.getInstanceId(); + } + + @Override + public State getState() { + return delegate.getState(); + } + + @Override + public Optional getMessage() { + return delegate.getMessage(); + } + + @Override + public Runnable onStateChange(ChangeHandler handler, boolean initialCall) { + return delegate.onStateChange(handler, initialCall); + } + + /** The view for a delegate that also provides {@link CrsInfo}. */ + static final class WithCrsInfo extends CrsTransformerFactoryForArea implements CrsInfo { + + private final CrsInfo crsInfo; + + WithCrsInfo(CrsTransformerFactory delegate, BoundingBox areaOfInterest) { + super(delegate, areaOfInterest); + this.crsInfo = (CrsInfo) delegate; + } + + @Override + public boolean is3d(EpsgCrs crs) { + return crsInfo.is3d(crs); + } + + @Override + public Unit getUnit(EpsgCrs crs) { + return crsInfo.getUnit(crs); + } + + @Override + public List getAxisAbbreviations(EpsgCrs crs) { + return crsInfo.getAxisAbbreviations(crs); + } + + @Override + public List> getAxisUnits(EpsgCrs crs) { + return crsInfo.getAxisUnits(crs); + } + + @Override + public List> getAxisMinimums(EpsgCrs crs) { + return crsInfo.getAxisMinimums(crs); + } + + @Override + public List> getAxisMaximums(EpsgCrs crs) { + return crsInfo.getAxisMaximums(crs); + } + + @Override + public OptionalInt getAxisWithWraparound(EpsgCrs crs) { + return crsInfo.getAxisWithWraparound(crs); + } + + @Override + public List getAxisDirections(EpsgCrs crs) { + return crsInfo.getAxisDirections(crs); + } + + @Override + public Optional getDomainOfValidity(EpsgCrs crs) { + return crsInfo.getDomainOfValidity(crs); + } + + @Override + public List getPrecisionList(EpsgCrs crs, Map coordinatePrecision) { + return crsInfo.getPrecisionList(crs, coordinatePrecision); + } + } +} diff --git a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java index 60a63f15d..50688b930 100644 --- a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java +++ b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java @@ -18,6 +18,7 @@ import de.ii.xtraplatform.blobs.domain.ResourceStore; import de.ii.xtraplatform.crs.domain.BoundingBox; import de.ii.xtraplatform.crs.domain.CrsInfo; +import de.ii.xtraplatform.crs.domain.CrsTransformationException; import de.ii.xtraplatform.crs.domain.CrsTransformer; import de.ii.xtraplatform.crs.domain.CrsTransformerFactory; import de.ii.xtraplatform.crs.domain.EpsgCrs; @@ -86,8 +87,7 @@ public class CrsTransformerFactoryProj extends AbstractVolatile private final ProjLoader projLoader; private final ResourceStore projStore; private final Map crsCache; - private final Map> transformerCache; - private final Map> transformerCacheForce2d; + private final Map transformerCache; private final Lock projLock; private final boolean useCaches; private final boolean asyncStartup; @@ -103,7 +103,6 @@ public CrsTransformerFactoryProj( this.projStore = resourceStore.with("proj"); this.crsCache = new ConcurrentHashMap<>(); this.transformerCache = new ConcurrentHashMap<>(); - this.transformerCacheForce2d = new ConcurrentHashMap<>(); this.projLock = new ReentrantLock(); this.useCaches = true; this.asyncStartup = appContext.getConfiguration().getModules().isStartupAsync(); @@ -117,7 +116,6 @@ public CrsTransformerFactoryProj( this.projStore = resourceStore.with("proj"); this.crsCache = new ConcurrentHashMap<>(); this.transformerCache = new ConcurrentHashMap<>(); - this.transformerCacheForce2d = new ConcurrentHashMap<>(); this.projLock = new ReentrantLock(); this.useCaches = true; this.asyncStartup = false; @@ -355,6 +353,12 @@ public Optional getTransformer(EpsgCrs sourceCrs, EpsgCrs target @Override public Optional getTransformer( EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d) { + return getTransformer(sourceCrs, targetCrs, force2d, Optional.empty()); + } + + @Override + public Optional getTransformer( + EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d, Optional areaOfInterest) { if (Objects.equals(sourceCrs, targetCrs)) { return Optional.empty(); } @@ -366,21 +370,58 @@ public Optional getTransformer( targetProjCrs = getHorizontalCrs(targetProjCrs); } + Optional area = toGeographicArea(areaOfInterest); CoordinateReferenceSystem finalSourceProjCrs = sourceProjCrs; CoordinateReferenceSystem finalTargetProjCrs = targetProjCrs; CrsTransformer transformer = useCaches - ? getCacheForSource(sourceCrs, force2d) - .computeIfAbsent( - targetCrs, - ignore -> - createCrsTransformer( - sourceCrs, targetCrs, finalSourceProjCrs, finalTargetProjCrs)) - : createCrsTransformer(sourceCrs, targetCrs, sourceProjCrs, targetProjCrs); + ? transformerCache.computeIfAbsent( + new TransformerKey(sourceCrs, targetCrs, force2d, area), + ignore -> + createCrsTransformer( + sourceCrs, targetCrs, finalSourceProjCrs, finalTargetProjCrs, area)) + : createCrsTransformer(sourceCrs, targetCrs, sourceProjCrs, targetProjCrs, area); return Optional.of(transformer); } + /** + * Converts an area of interest to a 2D box in CRS84. An area that covers the whole world does not + * restrict the selection of operations and is dropped, so it shares the cache entry and the + * operation of a request without an area. + */ + private Optional toGeographicArea(Optional areaOfInterest) { + if (areaOfInterest.isEmpty()) { + return Optional.empty(); + } + + BoundingBox area = areaOfInterest.get(); + if (!OgcCrs.CRS84.equals(area.getEpsgCrs()) && !OgcCrs.CRS84h.equals(area.getEpsgCrs())) { + try { + Optional toCrs84 = + getTransformer(area.getEpsgCrs(), OgcCrs.CRS84, true, Optional.empty()); + if (toCrs84.isPresent()) { + area = toCrs84.get().transformBoundingBox(area); + } + } catch (CrsTransformationException e) { + throw new IllegalArgumentException( + String.format("The area of interest cannot be converted to CRS84: %s", e.getMessage()), + e); + } + } + + if (area.getXmin() <= -180 + && area.getYmin() <= -90 + && area.getXmax() >= 180 + && area.getYmax() >= 90) { + return Optional.empty(); + } + + return Optional.of( + BoundingBox.of( + area.getXmin(), area.getYmin(), area.getXmax(), area.getYmax(), OgcCrs.CRS84)); + } + @Override public List getPrecisionList(EpsgCrs crs, Map coordinatePrecision) { ImmutableList.Builder precisionListBuilder = new ImmutableList.Builder<>(); @@ -543,18 +584,13 @@ private CoordinateReferenceSystem applyAxisOrder( return crs; } - private Map getCacheForSource(EpsgCrs crs, boolean force2d) { - return force2d - ? transformerCacheForce2d.computeIfAbsent(crs, ignore -> new ConcurrentHashMap<>()) - : transformerCache.computeIfAbsent(crs, ignore -> new ConcurrentHashMap<>()); - } - @SuppressWarnings("PMD.CyclomaticComplexity") private CrsTransformer createCrsTransformer( EpsgCrs sourceCrs, EpsgCrs targetCrs, CoordinateReferenceSystem sourceProjCrs, - CoordinateReferenceSystem targetProjCrs) { + CoordinateReferenceSystem targetProjCrs, + Optional areaOfInterest) { projLock.lock(); try { @@ -568,12 +604,28 @@ private CrsTransformer createCrsTransformer( coordinateOperationContext.setSpatialCriterion(SpatialCriterion.PARTIAL_INTERSECTION); coordinateOperationContext.setGridAvailabilityUse( GridAvailabilityUse.DISCARD_OPERATION_IF_MISSING_GRID); + areaOfInterest.ifPresent( + area -> + coordinateOperationContext.setAreaOfInterest( + area.getXmin(), area.getXmax(), area.getYmin(), area.getYmax())); CoordinateOperation coordinateOperation = Proj.createCoordinateOperation(sourceProjCrs, targetProjCrs, coordinateOperationContext); checkForMissingGridFiles(coordinateOperation); - if (LOGGER.isDebugEnabled()) { + if (areaOfInterest.isPresent() && LOGGER.isInfoEnabled()) { + BoundingBox area = areaOfInterest.get(); + LOGGER.info( + "Chosen operation for {} -> {} in area [{}, {}, {}, {}]: {}{}", + sourceCrs.toHumanReadableString(), + targetCrs.toHumanReadableString(), + area.getXmin(), + area.getYmin(), + area.getXmax(), + area.getYmax(), + coordinateOperation.getName().getCode(), + getGridFile(coordinateOperation).orElse("")); + } else if (LOGGER.isDebugEnabled()) { LOGGER.debug( "Chosen operation for {} -> {}: {}{}", sourceCrs.toHumanReadableString(), @@ -648,4 +700,43 @@ private Optional getGridFile(CoordinateOperation coordinateOperation) { } return Optional.empty(); } + + /** Cache key of a transformer; the area of interest is a 2D box in CRS84 or empty. */ + private static final class TransformerKey { + private final EpsgCrs sourceCrs; + private final EpsgCrs targetCrs; + private final boolean force2d; + private final Optional areaOfInterest; + + TransformerKey( + EpsgCrs sourceCrs, + EpsgCrs targetCrs, + boolean force2d, + Optional areaOfInterest) { + this.sourceCrs = sourceCrs; + this.targetCrs = targetCrs; + this.force2d = force2d; + this.areaOfInterest = areaOfInterest; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TransformerKey)) { + return false; + } + TransformerKey that = (TransformerKey) o; + return force2d == that.force2d + && sourceCrs.equals(that.sourceCrs) + && targetCrs.equals(that.targetCrs) + && areaOfInterest.equals(that.areaOfInterest); + } + + @Override + public int hashCode() { + return Objects.hash(sourceCrs, targetCrs, force2d, areaOfInterest); + } + } } diff --git a/xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerAreaOfInterestSpec.groovy b/xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerAreaOfInterestSpec.groovy new file mode 100644 index 000000000..4c9eda303 --- /dev/null +++ b/xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerAreaOfInterestSpec.groovy @@ -0,0 +1,157 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.crs.infra + +import de.ii.xtraplatform.base.domain.resiliency.VolatileRegistry +import de.ii.xtraplatform.blobs.domain.ResourceStore +import de.ii.xtraplatform.crs.domain.BoundingBox +import de.ii.xtraplatform.crs.domain.CrsInfo +import de.ii.xtraplatform.crs.domain.CrsTransformer +import de.ii.xtraplatform.crs.domain.CrsTransformerFactory +import de.ii.xtraplatform.crs.domain.EpsgCrs +import de.ii.xtraplatform.crs.domain.OgcCrs +import de.ii.xtraplatform.proj.domain.ProjLoaderImpl +import spock.lang.Shared +import spock.lang.Specification + +import java.nio.file.Path +import java.util.concurrent.CompletableFuture + +/** + * The PROJ data directory of the tests contains no grid files; the only grid is a cropped copy of + * HeTA2010 (DHDN to ETRS89 for Hessen) in the custom PROJ directory (test resources, `proj`). + * Without an area of interest, PROJ ranks a gridless Helmert transformation for the whole zone + * first; for an area inside Hessen, HeTA2010 is the most accurate operation. + */ +class CrsTransformerAreaOfInterestSpec extends Specification { + + static final EpsgCrs GK3 = EpsgCrs.of(5677) + static final EpsgCrs UTM32 = EpsgCrs.of(25832) + + // Frankfurt am Main in EPSG:5677, and in EPSG:25832 transformed with the full HeTA2010 grid + static final double[] FRANKFURT_GK3 = [3477335.33, 5552790.87] + static final double[] FRANKFURT_UTM32_HETA2010 = [477269.5063, 5551009.5743] + + // EPSG area of use of DHDN to ETRS89 (11), the operation with HeTA2010 + static final BoundingBox HESSEN = BoundingBox.of(7.77, 49.39, 10.24, 51.66, OgcCrs.CRS84) + + static final BoundingBox WORLD = BoundingBox.of(-180, -90, 180, 90, OgcCrs.CRS84) + + @Shared + CrsTransformerFactoryProj transformerFactory + + def setupSpec() { + Path gridDirectory = Path.of(getClass().getResource("/proj/de_hvbg_hessen_HeTA2010.tif").toURI()).getParent() + ResourceStore projStore = Stub() + projStore.asLocalPath(*_) >> Optional.of(gridDirectory) + ResourceStore resourceStore = Stub() + resourceStore.with(*_) >> projStore + VolatileRegistry volatileRegistry = Stub() + volatileRegistry.onAvailable(*_) >> CompletableFuture.completedFuture(null) + transformerFactory = new CrsTransformerFactoryProj(new ProjLoaderImpl(Path.of(System.getProperty("java.io.tmpdir"), "proj", "data")), resourceStore, volatileRegistry) + transformerFactory.onStart(false).toCompletableFuture().join() + } + + static double distanceToHeTA2010(CrsTransformer transformer) { + def result = transformer.transform(FRANKFURT_GK3[0], FRANKFURT_GK3[1]) + return Math.hypot(result.getX() - FRANKFURT_UTM32_HETA2010[0], result.getY() - FRANKFURT_UTM32_HETA2010[1]) + } + + def 'without an area of interest, HeTA2010 is not selected'() { + when: + CrsTransformer transformer = transformerFactory.getTransformer(GK3, UTM32).get() + + then: + distanceToHeTA2010(transformer) > 0.1 + } + + def 'an area of interest inside Hessen selects HeTA2010 - #name'() { + when: + CrsTransformer transformer = transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(area)).get() + + then: + distanceToHeTA2010(transformer) < 0.001 + + where: + name | area + "area of use" | HESSEN + "Frankfurt" | BoundingBox.of(8.6, 50.1, 8.7, 50.2, OgcCrs.CRS84) + "Frankfurt in EPSG:25832" | BoundingBox.of(470000, 5550000, 480000, 5560000, UTM32) + } + + def 'an area of interest beyond the area of use does not select HeTA2010 - #name'() { + when: + CrsTransformer transformer = transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(area)).get() + + then: + distanceToHeTA2010(transformer) > 0.1 + + where: + name | area + "0.02 degrees larger" | BoundingBox.of(7.70, 49.37, 10.26, 51.68, OgcCrs.CRS84) + "crossing the east" | BoundingBox.of(8.0, 50.0, 10.6, 51.0, OgcCrs.CRS84) + "whole world" | WORLD + } + + def 'the inverse transformation uses HeTA2010 for an area of interest inside Hessen'() { + when: + CrsTransformer transformer = transformerFactory.getTransformer(UTM32, GK3, false, Optional.of(HESSEN)).get() + def result = transformer.transform(FRANKFURT_UTM32_HETA2010[0], FRANKFURT_UTM32_HETA2010[1]) + + then: + Math.hypot(result.getX() - FRANKFURT_GK3[0], result.getY() - FRANKFURT_GK3[1]) < 0.001 + } + + def 'a whole-world area of interest shares the transformer of no area of interest'() { + when: + CrsTransformer world = transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(WORLD)).get() + CrsTransformer none = transformerFactory.getTransformer(GK3, UTM32).get() + + then: + world.is(none) + } + + def 'transformers are cached per area of interest'() { + when: + CrsTransformer first = transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(HESSEN)).get() + CrsTransformer second = transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(HESSEN)).get() + CrsTransformer none = transformerFactory.getTransformer(GK3, UTM32).get() + + then: + first.is(second) + !first.is(none) + } + + def 'a factory view for an area of interest selects HeTA2010 and delegates everything else'() { + given: + CrsTransformerFactory view = transformerFactory.withAreaOfInterest(HESSEN) + + when: + CrsTransformer transformer = view.getTransformer(GK3, UTM32).get() + + then: + transformer.is(transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(HESSEN)).get()) + distanceToHeTA2010(transformer) < 0.001 + view instanceof CrsInfo + ((CrsInfo) view).is3d(GK3) == transformerFactory.is3d(GK3) + view.isSupported(GK3) + view.getUniqueKey() == transformerFactory.getUniqueKey() + view.getState() == transformerFactory.getState() + } + + def 'an explicit area of interest takes precedence over the area of a factory view'() { + given: + CrsTransformerFactory view = transformerFactory.withAreaOfInterest(HESSEN) + + when: + CrsTransformer transformer = view.getTransformer(GK3, UTM32, false, Optional.of(WORLD)).get() + + then: + transformer.is(transformerFactory.getTransformer(GK3, UTM32).get()) + } +} diff --git a/xtraplatform-crs/src/test/resources/proj/README.md b/xtraplatform-crs/src/test/resources/proj/README.md new file mode 100644 index 000000000..9944cba5a --- /dev/null +++ b/xtraplatform-crs/src/test/resources/proj/README.md @@ -0,0 +1,11 @@ +# Test grid + +`de_hvbg_hessen_HeTA2010.tif` is a cropped copy (8.5–8.9 E, 50.0–50.3 N, around Frankfurt am +Main) of the HeTA2010 NTv2 grid for the transformation DHDN → ETRS89 in Hessen, as distributed in +GeoTIFF form by the PROJ data package (`de_hvbg_hessen_HeTA2010.tif`). It is used to test the +selection of coordinate operations for an area of interest. + +- Source: Hessische Verwaltung für Bodenmanagement und Geoinformation (HVBG), + https://www.hvbg.hessen.de +- License: Data licence Germany – attribution – version 2.0, https://www.govdata.de/dl-de/by-2-0 +- Modification: cropped with `gdal_translate -projwin 8.5 50.3 8.9 50.0`; values unchanged. diff --git a/xtraplatform-crs/src/test/resources/proj/de_hvbg_hessen_HeTA2010.tif b/xtraplatform-crs/src/test/resources/proj/de_hvbg_hessen_HeTA2010.tif new file mode 100644 index 0000000000000000000000000000000000000000..b7f8cb7526bebfcc76c329bc613c78d22782060e GIT binary patch literal 29845 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%0D&IGz+ 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 validateArea(BoundingBox area) { + if (!OgcCrs.CRS84.equals(area.getEpsgCrs())) { + return Optional.of("'transformationArea' must be given in CRS84."); + } + if (area.getXmin() > area.getXmax() || area.getYmin() > area.getYmax()) { + return Optional.of( + "'transformationArea' is invalid: 'xmin' and 'ymin' must not exceed 'xmax' and 'ymax'."); + } + if (area.getXmin() < -180 + || area.getXmax() > 180 + || area.getYmin() < -90 + || area.getYmax() > 90) { + return Optional.of( + "'transformationArea' is invalid: longitudes must be within [-180, 180] and latitudes" + + " within [-90, 90]."); + } + return Optional.empty(); + } + @Override protected State reconcileStateNoComponents(@Nullable String capability) { return State.AVAILABLE; @@ -170,7 +202,7 @@ protected boolean onStartup() throws InterruptedException { if (multiQueries().isSupported()) { addCapability(MultiFeatureQueries.CAPABILITY); } - addSubcomponent(crsTransformerFactory, FeatureCrs.CAPABILITY); + addSubcomponent(crsTransformerFactoryComponent, FeatureCrs.CAPABILITY); addSubcomponent( connector, FeatureQueries.CAPABILITY, @@ -201,7 +233,7 @@ protected boolean onStartup() throws InterruptedException { Optional connectionError = getConnector().getConnectionError(); String message = connectionError.map(Throwable::getMessage).orElse("unknown reason"); - LOGGER.error("Feature provider with id '{}' could not be started: {}", getId(), message); + LOGGER.error(COULD_NOT_START, getId(), message); if (connectionError.isPresent() && LOGGER.isDebugEnabled()) { LOGGER.debug("Stacktrace:", connectionError.get()); } @@ -216,8 +248,7 @@ protected boolean onStartup() throws InterruptedException { Optional runnerError = getRunnerError(getConnectionInfo()); if (runnerError.isPresent()) { - LOGGER.error( - "Feature provider with id '{}' could not be started: {}", getId(), runnerError.get()); + LOGGER.error(COULD_NOT_START, getId(), runnerError.get()); // TODO: volatile defective // NOPMD return false; } @@ -227,8 +258,15 @@ protected boolean onStartup() throws InterruptedException { getData().getTypes().values(), encryption.isEnabled(), supportsEncryptedProperties()); if (encryptionError.isPresent()) { - LOGGER.error( - "Feature provider with id '{}' could not be started: {}", getId(), encryptionError.get()); + LOGGER.error(COULD_NOT_START, getId(), encryptionError.get()); + return false; + } + + Optional transformationAreaError = + getData().getTransformationArea().flatMap(AbstractFeatureProvider::validateArea); + + if (transformationAreaError.isPresent()) { + LOGGER.error(COULD_NOT_START, getId(), transformationAreaError.get()); return false; } diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java index e4aff9998..cee2fd2a1 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import de.ii.xtraplatform.cql.domain.CustomFunction; +import de.ii.xtraplatform.crs.domain.BoundingBox; import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.docs.DocIgnore; import de.ii.xtraplatform.entities.domain.AutoEntity; @@ -78,6 +79,47 @@ default long getEntitySchemaVersion() { @JsonDeserialize(converter = ZoneIdFromString.class) Optional getNativeTimeZone(); + /** + * @langEn Area, as a bounding box in CRS84 (`xmin`, `ymin`, `xmax`, `ymax`), that is used to + * select the transformation between two coordinate reference systems when several are + * available, for example a regional NTv2 grid instead of a national one. The most accurate + * transformation whose area of use contains this box is used; grid files that are not part of + * the default installation have to be provided in `resources/proj`. Without a value, the + * transformation is selected independently of an area. + *

The box has to lie completely within the area of use of the intended transformation. If + * it extends beyond it, even by a few hundredths of a degree, a less accurate transformation + * that covers the whole box is selected instead. For this reason, the spatial extent of the + * data, of an API or of a provider is often not suitable: for a dataset that covers the + * region of a regional transformation completely, the extent usually extends slightly beyond + * its area of use, for example because of features on the boundary, or because the extent of + * data stored in a projected coordinate reference system becomes larger when it is converted + * to CRS84. Use the area of use of the intended transformation as given in the EPSG registry + * instead, for example `7.77, 49.39, 10.24, 51.66` for the transformation from DHDN to ETRS89 + * in Hessen (HeTA2010). Coordinates slightly outside of the area of use are still transformed + * as long as they are covered by the grid. + * @langDe Gebiet als Bounding Box in CRS84 (`xmin`, `ymin`, `xmax`, `ymax`), das bei mehreren + * möglichen Transformationen zwischen zwei Koordinatenreferenzsystemen die Auswahl bestimmt, + * z.B. ein regionales NTv2-Gitter statt eines bundesweiten. Verwendet wird die genaueste + * Transformation, deren Gültigkeitsbereich dieses Gebiet enthält; Gitterdateien, die nicht + * zur Standardinstallation gehören, sind in `resources/proj` bereitzustellen. Ohne Angabe + * wird die Transformation unabhängig von einem Gebiet ausgewählt. + *

Das Gebiet muss vollständig im Gültigkeitsbereich der gewünschten Transformation liegen. + * Ragt es darüber hinaus, auch nur um wenige Hundertstel Grad, wird stattdessen eine + * ungenauere Transformation ausgewählt, die das ganze Gebiet abdeckt. Deshalb eignet sich die + * räumliche Ausdehnung der Daten, einer API oder eines Providers oft nicht: Bei einem + * Datensatz, der das Gebiet einer regionalen Transformation vollständig abdeckt, ragt die + * Ausdehnung meist knapp über deren Gültigkeitsbereich hinaus, z.B. wegen Objekten auf der + * Grenze oder weil die Ausdehnung von Daten in einem projizierten Koordinatenreferenzsystem + * bei der Umrechnung nach CRS84 größer wird. Stattdessen ist der Gültigkeitsbereich der + * gewünschten Transformation laut EPSG-Registry anzugeben, z.B. `7.77, 49.39, 10.24, 51.66` + * für die Transformation von DHDN nach ETRS89 in Hessen (HeTA2010). Koordinaten knapp + * außerhalb des Gültigkeitsbereichs werden weiterhin transformiert, solange sie vom Gitter + * abgedeckt sind. + * @default null + * @since v4.9 + */ + Optional getTransformationArea(); + @DocIgnore Optional getDefaultLanguage();