This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
commit 5915cf55f76e409a5fd88453df6b2ffc61ddfd86 Author: Martin Desruisseaux <[email protected]> AuthorDate: Fri Jul 17 12:43:14 2026 +0200 chore(Storage): add a test to investigate data discovery in NetCDF files generated by GDAL using Transverse mercator projection. Fix the cause, which was a mismatched number of dimensions between the data cube and the matrix in the `GeoTransform` attribute. Fix the CRS definitions, which was using wrong names for some elements such as the base CRS of a projected CRS. Co-authored-by: Alexis Manin <[email protected]> --- .../sis/coverage/grid/GridDerivationTest.java | 2 +- .../apache/sis/coverage/grid/GridGeometryTest.java | 9 +- .../test/org/apache/sis/feature/Assertions.java | 13 + .../main/org/apache/sis/referencing/CRS.java | 19 +- .../operation/transform/CopyTransform.java | 4 +- .../operation/transform/MathTransforms.java | 33 +- .../apache/sis/storage/netcdf/base/CRSBuilder.java | 8 +- .../apache/sis/storage/netcdf/base/Convention.java | 16 +- .../org/apache/sis/storage/netcdf/base/Grid.java | 4 +- .../sis/storage/netcdf/base/GridMapping.java | 601 ++++++++++++--------- .../sis/storage/netcdf/classic/ChannelDecoder.java | 2 +- .../sis/storage/netcdf/internal/Resources.java | 5 + .../storage/netcdf/internal/Resources.properties | 1 + .../netcdf/internal/Resources_fr.properties | 1 + .../sis/storage/netcdf/MetadataReaderTest.java | 6 +- .../storage/netcdf/NetcdfStoreProviderTest.java | 1 + .../apache/sis/storage/netcdf/NetcdfStoreTest.java | 80 ++- .../transverse-mercator-wrong-geotransform.nc | Bin 0 -> 2676 bytes .../org/apache/sis/storage/DataStoreTestCase.java | 72 +++ .../test/org/apache/sis/test/LoggingWatcher.java | 34 +- 20 files changed, 591 insertions(+), 320 deletions(-) diff --git a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridDerivationTest.java b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridDerivationTest.java index 12f7dc725f..70269fab5d 100644 --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridDerivationTest.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridDerivationTest.java @@ -41,7 +41,6 @@ import org.apache.sis.referencing.operation.matrix.Matrices; import org.apache.sis.referencing.operation.matrix.Matrix3; import org.apache.sis.referencing.operation.matrix.Matrix4; import org.apache.sis.referencing.operation.transform.MathTransforms; -import static org.apache.sis.coverage.grid.GridGeometryTest.assertExtentEquals; // Test dependencies import org.junit.jupiter.api.Test; @@ -50,6 +49,7 @@ import org.apache.sis.test.TestCase; import org.apache.sis.referencing.crs.HardCodedCRS; import org.apache.sis.referencing.operation.HardCodedConversions; import static org.apache.sis.referencing.Assertions.assertEnvelopeEquals; +import static org.apache.sis.feature.Assertions.assertExtentEquals; import static org.apache.sis.feature.Assertions.assertGridToCenterEquals; import static org.apache.sis.feature.Assertions.assertGridToCornerEquals; diff --git a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridGeometryTest.java b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridGeometryTest.java index 028e0f15e0..d2faf4e56a 100644 --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridGeometryTest.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridGeometryTest.java @@ -64,6 +64,7 @@ import static org.apache.sis.feature.Assertions.assertGridToCornerEquals; // Specific to the geoapi-3.1 and geoapi-4.0 branches: import org.opengis.referencing.ObjectDomain; import static org.opengis.test.Assertions.assertAxisDirectionsEqual; +import static org.apache.sis.feature.Assertions.assertExtentEquals; /** @@ -80,14 +81,6 @@ public final class GridGeometryTest extends TestCase { public GridGeometryTest() { } - /** - * Verifies grid extent coordinates. - */ - static void assertExtentEquals(final long[] low, final long[] high, final GridExtent extent) { - assertArrayEquals(low, extent.getLow() .getCoordinateValues(), "extent.low"); - assertArrayEquals(high, extent.getHigh().getCoordinateValues(), "extent.high"); - } - /** * Verifies the shift between the two {@code gridToCRS} transforms. * This method should be invoked when the transforms are linear. diff --git a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/feature/Assertions.java b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/feature/Assertions.java index 52d73444f0..63f0721364 100644 --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/feature/Assertions.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/feature/Assertions.java @@ -22,6 +22,7 @@ import java.awt.image.Raster; import java.awt.image.RenderedImage; import org.opengis.referencing.operation.Matrix; import org.apache.sis.image.PixelIterator; +import org.apache.sis.coverage.grid.GridExtent; import org.apache.sis.coverage.grid.GridGeometry; import org.apache.sis.coverage.grid.PixelInCell; @@ -45,6 +46,18 @@ public final class Assertions { private Assertions() { } + /** + * Verifies grid extent coordinates. + * + * @param low the expected low coordinate values. + * @param high the expected high coordinate values. + * @param actual the extent to validate. + */ + public static void assertExtentEquals(final long[] low, final long[] high, final GridExtent actual) { + assertArrayEquals(low, actual.getLow() .getCoordinateValues(), "low"); + assertArrayEquals(high, actual.getHigh().getCoordinateValues(), "high"); + } + /** * Verifies the matrix of the transform of the grid geometry to pixel corner. * diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/CRS.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/CRS.java index fa2e0ab237..320141476b 100644 --- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/CRS.java +++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/CRS.java @@ -1111,12 +1111,15 @@ public final class CRS { /** * Returns the geodetic reference frame used by the given coordinate reference system. - * If the given <abbr>CRS</abbr> is an instance of {@link GeodeticCRS}, then this method returns the - * <abbr>CRS</abbr>'s datum. Otherwise, if the given <abbr>CRS</abbr> is an instance of {@link CompoundCRS}, - * then this method searches for the first geodetic component. Otherwise, this method returns an empty value. + * If the given <abbr>CRS</abbr> is a {@link GeodeticCRS}, then this method returns the <abbr>CRS</abbr>'s datum. + * If the given <abbr>CRS</abbr> is a {@link DerivedCRS} and if its base <abbr>CRS</abbr> is a {@link GeodeticCRS}, + * then this method returns the base <abbr>CRS</abbr>'s datum. + * Otherwise, if the given <abbr>CRS</abbr> is a {@link CompoundCRS}, + * then this method performs the above checks on each component. + * Otherwise, this method returns an empty value. * * @param crs the coordinate reference system for which to get the geodetic reference frame, or {@code null}. - * @return the geodetic reference frame, or an empty value if none. + * @return the geodetic reference frame of the given <abbr>CRS</abbr>, or an empty value if none. * * @see DatumOrEnsemble#getEllipsoid(CoordinateReferenceSystem) * @see DatumOrEnsemble#getPrimeMeridian(CoordinateReferenceSystem) @@ -1124,10 +1127,14 @@ public final class CRS { * * @since 1.6 */ - public static Optional<GeodeticDatum> getGeodeticReferenceFrame(final CoordinateReferenceSystem crs) { + public static Optional<GeodeticDatum> getGeodeticReferenceFrame(CoordinateReferenceSystem crs) { + if (crs instanceof DerivedCRS) { + crs = ((DerivedCRS) crs).getBaseCRS(); + } if (crs instanceof GeodeticCRS) { return Optional.ofNullable(DatumOrEnsemble.asDatum((GeodeticCRS) crs)); - } else if (crs instanceof CompoundCRS) { + } + if (crs instanceof CompoundCRS) { for (CoordinateReferenceSystem component : ((CompoundCRS) crs).getComponents()) { final Optional<GeodeticDatum> datum = getGeodeticReferenceFrame(component); if (datum.isPresent()) { diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/CopyTransform.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/CopyTransform.java index 4202647372..cc07585453 100644 --- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/CopyTransform.java +++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/CopyTransform.java @@ -49,7 +49,7 @@ final class CopyTransform extends AbstractLinearTransform { private final int srcDim; /** - * The indices of coordinates to copy in the source array. + * The indices of source coordinates to copy in the source array. * The length of this array is the target dimension. */ private final int[] indices; @@ -59,7 +59,7 @@ final class CopyTransform extends AbstractLinearTransform { * * @param srcDim the dimension of source coordinates. * Must be greater than the highest value in {@code indices}. - * @param indices the indices of coordinates to copy in the target array. + * @param indices the indices of source coordinates to copy in the target array. * The length of this array is the number of target dimensions. */ CopyTransform(final int srcDim, final int[] indices) { diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransforms.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransforms.java index ae4e96a9b0..e15829a938 100644 --- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransforms.java +++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransforms.java @@ -61,7 +61,7 @@ import org.opengis.coordinate.MismatchedDimensionException; * GeoAPI factory interfaces instead. * * @author Martin Desruisseaux (Geomatys) - * @version 1.5 + * @version 1.7 * * @see MathTransformFactory * @@ -75,7 +75,7 @@ public final class MathTransforms { } /** - * Returns an identity transform of the specified dimension. + * Returns an identity transform of the specified number of dimensions. * * <p>Special cases:</p> * <ul> @@ -91,6 +91,35 @@ public final class MathTransforms { return IdentityTransform.create(dimension); } + /** + * Returns a transform of the specified number of dimensions which swaps the two first axes. + * The most typical use case is for switching between (<var>latitude</var>, <var>longitude</var>) + * and (<var>longitude</var>, <var>latitude</var>) axis order of geographic <abbr>CRS</abbr>. + * + * <h4>Warning: use in last resort only</h4> + * While switching the axis order of geographic <abbr>CRS</abbr> seems a very frequent problem, + * the need to invoke this method should be very rare. Apache <abbr>SIS</abbr> already handles + * axis order changes when deriving Coordinate Operations between Coordinate Reference Systems (<abbr>CRS</abbr>). + * If a user's code needs to change axis order manually, this is often the symptom of a design problem such as + * data without {@link org.opengis.referencing.crs.CoordinateReferenceSystem} or with an incorrect <abbr>CRS</abbr>, + * or computation that does not use another <abbr>CRS</abbr> for describing their desired coordinate system. + * + * @param dimension number of dimensions of the transform to be returned. + * @return a transform of the specified dimension which swaps the two first axes. + * + * @since 1.7 + */ + public static LinearTransform swapTwoFirstAxes(final int dimension) { + ArgumentChecks.ensurePositive("dimension", dimension); + if (dimension < 2) { + return IdentityTransform.create(dimension); + } + final int[] indices = ArraysExt.range(0, dimension); + indices[0] = 1; + indices[1] = 0; + return new CopyTransform(dimension, indices); + } + /** * Creates an affine transform which applies the same translation for all dimensions. * For each dimension, input values <var>x</var> are converted into output values <var>y</var> diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/CRSBuilder.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/CRSBuilder.java index 55cad0d6f9..e3eee51839 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/CRSBuilder.java +++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/CRSBuilder.java @@ -294,7 +294,7 @@ abstract class CRSBuilder<D extends Datum, CS extends CoordinateSystem> { * We should have at most one builder of each class. But if we nevertheless have more, * add to the most recently used builder. If there is no builder, create a new one. */ - for (int i=components.size(); --i >= 0;) { + for (int i = components.size(); --i >= 0;) { final CRSBuilder<?,?> builder = components.get(i); if (addTo.isInstance(builder) || i == alternative) { builder.add(axis); @@ -418,7 +418,7 @@ previous: for (int i = components.size(); --i >= 0;) { * Using a predefined CS allows us to get more complete definitions (minimum and maximum values, etc.). */ if (coordinateSystem != null) { - for (int i=dimension; --i >= 0;) { + for (int i = dimension; --i >= 0;) { final Axis expected = axes[i]; if (expected == null || !expected.isSameUnitAndDirection(coordinateSystem.getAxis(i))) { coordinateSystem = null; @@ -441,7 +441,7 @@ previous: for (int i = components.size(); --i >= 0;) { */ if (grid) { final CoordinateSystem cs = referenceSystem.getCoordinateSystem(); - for (int i=cs.getDimension(); --i >= 0;) { + for (int i = cs.getDimension(); --i >= 0;) { final CoordinateSystemAxis axis = cs.getAxis(i); if (axis.getRangeMeaning() == RangeMeaning.WRAPAROUND) { final NumberRange<?> range = axes[i].read().range(); // Vector is cached. @@ -473,7 +473,7 @@ previous: for (int i = components.size(); --i >= 0;) { final Map<String,?> properties; if (coordinateSystem == null) { // Fallback if the coordinate system is not predefined. - final StringJoiner joiner = new StringJoiner(" "); + final var joiner = new StringJoiner(" "); final CSFactory csFactory = decoder.getCSFactory(); final var iso = new CoordinateSystemAxis[dimension]; for (int i=0; i<iso.length; i++) { diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Convention.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Convention.java index 60798cdb08..25b0f7b7d1 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Convention.java +++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Convention.java @@ -481,12 +481,12 @@ public class Convention { * * @see <a href="http://cfconventions.org/cf-conventions/cf-conventions.html#grid-mappings-and-projections">CF-conventions</a> */ - public Map<String,Object> projection(final Node node) { + public Map<String, Object> projection(final Node node) { final String method = node.getAttributeAsString(CF.GRID_MAPPING_NAME); if (method == null) { return null; } - final var definition = new LinkedHashMap<String,Object>(); + final var definition = new LinkedHashMap<String, Object>(); definition.put(CF.GRID_MAPPING_NAME, method); for (final String name : node.getAttributeNames()) try { final String nameLC = name.toLowerCase(Decoder.DATA_LOCALE); @@ -560,9 +560,11 @@ public class Convention { } /** - * Returns an identification of default geodetic components to use if no corresponding information is found in the - * netCDF file. The default implementation returns <q>Unknown datum based upon the GRS 1980 ellipsoid</q>. - * Note that the GRS 1980 ellipsoid is close to WGS 84 ellipsoid. + * Returns an identification of default geodetic components to use if no corresponding information is found + * in the netCDF file. The prime meridian shall be Greenwich or the international meridian. + * + * <p>The default implementation returns <q>Unknown datum based upon the GRS 1980 ellipsoid</q>. + * Note that the <abbr>GRS</abbr> 1980 ellipsoid is close to <abbr>WGS</abbr> 84 ellipsoid.</p> * * <h4>Maintenance note</h4> * If this default is changed, search also for "GRS 1980" strings in {@link CRSBuilder} class. @@ -710,7 +712,7 @@ public class Convention { * @param data the variable for which to get no-data values. * @return no-data values with bitmask of their roles or textual descriptions. */ - public Map<Number,Object> nodataValues(final Variable data) { + public Map<Number, Object> nodataValues(final Variable data) { final var pads = new LinkedHashMap<Number,Object>(); for (int i=0; i < NODATA_ATTRIBUTES.length; i++) { final String name = NODATA_ATTRIBUTES[i]; @@ -788,7 +790,7 @@ public class Convention { * @param data the variable for which to get the colors. * @return colors to use for each category, or {@code null} for the default. */ - public Function<Category,Color[]> getColors(final Variable data) { + public Function<Category, Color[]> getColors(final Variable data) { return null; } } diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Grid.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Grid.java index d5163847a4..2cbeae8384 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Grid.java +++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Grid.java @@ -419,12 +419,12 @@ public abstract class Grid extends NamedElement { */ final int[] gridDimensionIndices = new int[nonLinears.size()]; Arrays.fill(gridDimensionIndices, -1); - for (int i=0; i<gridDimensionIndices.length; i++) { + for (int i=0; i < gridDimensionIndices.length; i++) { final int tgtDim = deferred[i]; final Axis axis = axes[tgtDim]; findFree: for (int srcDim : axis.gridDimensionIndices) { // In preference order (will take only one). srcDim = lastSrcDim - srcDim; // Convert netCDF order to "natural" order. - for (int j=affine.getNumRow(); --j>=0;) { + for (int j = affine.getNumRow(); --j>=0;) { if (affine.getElement(j, srcDim) != 0) { continue findFree; } diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/GridMapping.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/GridMapping.java index 253642a975..22a3b3a02e 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/GridMapping.java +++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/GridMapping.java @@ -40,6 +40,7 @@ import org.opengis.parameter.ParameterValueGroup; import org.opengis.parameter.ParameterNotFoundException; import org.opengis.referencing.IdentifiedObject; import org.opengis.referencing.cs.CoordinateSystem; +import org.opengis.referencing.crs.CRSFactory; import org.opengis.referencing.crs.ProjectedCRS; import org.opengis.referencing.crs.GeographicCRS; import org.opengis.referencing.crs.CoordinateReferenceSystem; @@ -47,6 +48,7 @@ import org.opengis.referencing.operation.TransformException; import org.opengis.referencing.operation.OperationMethod; import org.opengis.referencing.operation.MathTransform; import org.opengis.referencing.operation.Conversion; +import org.opengis.referencing.operation.Matrix; import org.opengis.referencing.datum.DatumFactory; import org.opengis.referencing.datum.GeodeticDatum; import org.opengis.referencing.datum.PrimeMeridian; @@ -58,9 +60,7 @@ import org.apache.sis.referencing.crs.AbstractCRS; import org.apache.sis.referencing.cs.AxesConvention; import org.apache.sis.referencing.cs.CoordinateSystems; import org.apache.sis.referencing.datum.DatumOrEnsemble; -import org.apache.sis.referencing.datum.BursaWolfParameters; import org.apache.sis.referencing.datum.DefaultGeodeticDatum; -import org.apache.sis.referencing.operation.matrix.Matrix3; import org.apache.sis.referencing.operation.matrix.Matrices; import org.apache.sis.referencing.operation.matrix.MatrixSIS; import org.apache.sis.referencing.operation.transform.MathTransforms; @@ -85,10 +85,12 @@ import org.apache.sis.util.resources.Errors; import org.apache.sis.util.resources.IndexedResourceBundle; import org.apache.sis.io.wkt.WKTFormat; import org.apache.sis.io.wkt.Warnings; +import org.apache.sis.util.Exceptions; import org.apache.sis.math.NumberType; import org.apache.sis.measure.Units; // Specific to the geoapi-3.1 and geoapi-4.0 branches: +import org.opengis.referencing.crs.DerivedCRS; import org.opengis.referencing.datum.DatumEnsemble; @@ -96,8 +98,11 @@ import org.opengis.referencing.datum.DatumEnsemble; * Helper object for creating a {@link GridGeometry} instance defined by attributes on a variable. * Those attributes are defined by <abbr>CF</abbr>-conventions, but some other non-<abbr>CF</abbr> * attributes are also recognized (e.g. <abbr>GDAL</abbr> and <abbr>ESRI</abbr> conventions). - * This class uses a different approach than {@link CRSBuilder}, + * + * <p>This class uses a different approach than {@link CRSBuilder}, * which creates Coordinate Reference Systems by inspecting coordinate system axes. + * The two approaches are complementary, as this {@code GridMapping} class usually creates a + * two-dimensional <abbr>CRS</abbr>. The other dimensions need to be taken from {@link CRSBuilder}.</p> * * @author Martin Desruisseaux (Geomatys) * @@ -168,6 +173,12 @@ final class GridMapping { */ private MathTransform gridToCRS; + /** + * The pixel in cell convention of the method that decoded the {@link #gridToCRS} field. + * This is null if the pixel in cell convention was not inferred. + */ + private PixelInCell anchorFromConvention; + /** * Whether the {@link #crs} was defined by a WKT string. */ @@ -189,9 +200,10 @@ final class GridMapping { * only one set of grid mapping attributes for the whole file. * * @param variable the variable for which to create a grid geometry. + * @return grid geometry information, or {@code null} if not found. */ static GridMapping forVariable(final Variable variable) { - final Map<String,GridMapping> gridMapping = variable.decoder.gridMapping; + final Map<String, GridMapping> gridMapping = variable.decoder.gridMapping; for (final String name : variable.decoder.convention().nameOfMappingNode(variable)) { GridMapping gm = gridMapping.get(name); if (gm != null) { @@ -204,7 +216,7 @@ final class GridMapping { if (!gridMapping.containsKey(name)) { final Node mapping = variable.decoder.findNode(name); if (mapping != null) { - gm = parse(mapping); + gm = tryAllConventions(mapping); } gridMapping.put(name, gm); // Store even if null. if (gm != null) { @@ -219,7 +231,7 @@ final class GridMapping { final String name = variable.getName(); GridMapping gm = gridMapping.get(name); if (gm == null && !gridMapping.containsKey(name)) { - gm = parse(variable); + gm = tryAllConventions(variable); gridMapping.put(name, gm); // Store even if null. } return gm; @@ -228,195 +240,173 @@ final class GridMapping { /** * Parses the map projection parameters defined as attribute associated to the given variable. * This method tries to parse <abbr>CF</abbr>-compliant attributes, potentially mixed with - * non-standard extensions (for example <abbr>GDAL</abbr>). + * non-standard extensions (for example <abbr>GDAL</abbr> and <abbr>ESRI</abbr>). + * + * @param variable the variable from which to get the map projection parameters. + * @return grid geometry information, or {@code null} if not found or if an error occurred. */ - private static GridMapping parse(final Node mapping) { - final var gm = new GridMapping(mapping); + @SuppressWarnings("UseSpecificCatch") + private static GridMapping tryAllConventions(final Node mapping) { + var gm = new GridMapping(mapping); // Tries CF-convention first, and if it doesn't work, try GDAL convention. - return (gm.parseProjectionParameters() || gm.parseGeoTransform() || gm.parseESRI()) ? gm : null; + int i = 0; + boolean stop = false; + Exception warning = null; + do { + try { + switch (i++) { + case 0: stop = gm.parseProjectionParameters(); break; + case 1: stop = gm.parseGDAL(); break; + case 2: stop = gm.parseESRI(); break; + default: stop = true; gm = null; break; + } + } catch (Exception e) { // Checked exceptions | ClassCastException | IllegalArgumentException and more. + e = Exceptions.unwrap(e); + if (warning == null) warning = e; + else warning.addSuppressed(e); + } + } while (!stop); + if (warning != null) { + cannotCreateGridOrCRS(mapping, warning, warning instanceof TransformException); + } + return gm; } /** * Sets the <abbr>CRS</abbr> and "grid to <abbr>CRS</abbr>" from the <abbr>CF</abbr> conventions. * If this method does not find the expected attributes, then it does nothing. * + * <p>The <abbr>CRS</abbr> may also be specified in <abbr>WKT</abbr> form in attributes which are themselves + * specified by different conventions (<abbr>CF</abbr>, <abbr>GDAL</abbr>, <abbr>ESRI</abbr>). + * If the <abbr>CRS</abbr> is specified both by <abbr>WKT</abbr> and by attributes on a "grid mapping" variable, + * then the grid mapping attributes have precedence as initially mandated by the <abbr>CF</abbr> conventions. + * Note: the latter rule has been relaxed by <abbr>CF</abbr> issue #222, but <abbr>SIS</abbr> implementation + * continues to follow the original specification.</p> + * + * <p>The <abbr>CRS</abbr> created by this method is two-dimensional. + * The addition of vertical or temporal axes must be done by the caller.</p> + * * @return whether this method found grid geometry attributes. + * @throws ClassCastException if an attribute value is not of the expected type. + * @throws FactoryException if an error occurred during the attempt to initialize {@link #crs}. + * @throws TransformException if an error occurred during the attempt to initialize {@link #gridToCRS}. * * @see <a href="http://cfconventions.org/cf-conventions/cf-conventions.html#grid-mappings-and-projections">CF-conventions</a> + * @see <a href="https://github.com/cf-convention/cf-conventions/issues/222">Allow CRS WKT to represent the <abbr>CRS</abbr> + * without requiring reader to compare with grid mapping parameters</a> */ - private boolean parseProjectionParameters() { - final Map<String, Object> definition = mapping.decoder.convention().projection(mapping); - if (definition != null) try { - // Take only one WKT for now. We will parse the other one later. - final var alreadyParsedWKT = new ArrayList<String>(2); - if (crs == null) setOrVerifyWKT(definition, CRS_WKT, alreadyParsedWKT); - if (crs == null) setOrVerifyWKT(definition, SPATIAL_REF, alreadyParsedWKT); - /* - * Fetch now numerical values that are not map projection parameters. - * This step needs to be done before to try to set parameter values. - */ - final Object greenwichLongitude = definition.remove(Convention.LONGITUDE_OF_PRIME_MERIDIAN); - /* - * Prepare the block of projection parameters. The set of legal parameter depends on the map projection. - * We assume that all numerical values are map projection parameters. Character sequences (assumed to be - * component names) are handled later. The CF-conventions use parameter names that are slightly different - * than OGC names, but Apache SIS implementations of map projections know how to handle them, including - * the redundant parameters like "inverse_flattening" and "earth_radius". - */ - final String mappingName = (String) definition.remove(CF.GRID_MAPPING_NAME); - final OperationMethod method = mapping.decoder.findOperationMethod(mappingName); - final ParameterValueGroup parameters = method.getParameters().createValue(); - for (final Iterator<Map.Entry<String,Object>> it = definition.entrySet().iterator(); it.hasNext();) { - final Map.Entry<String,Object> entry = it.next(); - final String name = entry.getKey(); - final Object value = entry.getValue(); - try { - if (value instanceof Number || value instanceof double[] || value instanceof float[]) { - it.remove(); - parameters.parameter(name).setValue(value); - } else if (value instanceof String) { - final var text = (String) value; - if (name.endsWith(Convention.NAME_SUFFIX)) { - continue; - } - switch (name) { - case CRS_WKT: - case SPATIAL_REF: continue; // Will be parsed after this loop. - case "geotransform": { // "GeoTransform" made lower-case. - if (parseGeoTransform(null, text)) { - it.remove(); - } - continue; + private boolean parseProjectionParameters() throws FactoryException, TransformException { + final Decoder decoder = mapping.decoder; + final Map<String, Object> definition = decoder.convention().projection(mapping); + if (definition == null) { + return false; + } + /* + * Search in advance for a CRS that we can use as a fallback. We need only one such CRS for now, + * the other CRS definitions will be parsed later in this method. We need this fallback in order + * to provide default names to geodetic objects in the common case where the `definition` map + * does not contain these names. + */ + final var alreadyParsedWKT = new ArrayList<String>(2); + if (crs == null) setOrVerifyWKT(alreadyParsedWKT, definition, CRS_WKT); + if (crs == null) setOrVerifyWKT(alreadyParsedWKT, definition, SPATIAL_REF); + final CoordinateReferenceSystem fromWKT = crs; + /* + * Fetch now numerical values that are not map projection parameters. + * This step needs to be done before to try to set parameter values. + */ + final Object greenwichLongitude = definition.remove(Convention.LONGITUDE_OF_PRIME_MERIDIAN); + final String mappingName = (String) definition.remove(CF.GRID_MAPPING_NAME); + /* + * Prepare the group of projection parameters. The set of legal parameter depends on the map projection. + * We assume that all numerical values are map projection parameters. Character sequences (assumed to be + * component names) are handled later. The CF-conventions use parameter names that are slightly different + * than OGC names, but Apache SIS implementations of map projections know how to handle them, including + * the redundant parameters like "inverse_flattening" and "earth_radius". + */ + final OperationMethod method = decoder.findOperationMethod(mappingName); + final ParameterValueGroup parameters = method.getParameters().createValue(); + for (final Iterator<Map.Entry<String, Object>> it = definition.entrySet().iterator(); it.hasNext();) { + final Map.Entry<String, Object> entry = it.next(); + final String name = entry.getKey(); + final Object value = entry.getValue(); + try { + if (value instanceof Number || value instanceof double[] || value instanceof float[]) { + it.remove(); + parameters.parameter(name).setValue(value); + } else if (value instanceof String) { + final var text = (String) value; + if (name.endsWith(Convention.NAME_SUFFIX)) { + continue; + } + switch (name) { + case CRS_WKT: + case SPATIAL_REF: continue; // Will be parsed after this loop. + case "geotransform": { // "GeoTransform" made lower-case. + if (parseGeoTransform(text)) { + it.remove(); } - } - /* - * In principle we should ignore non-numeric parameters. But in practice, some badly encoded - * netCDF files store parameters as strings instead of numbers. If the parameter name is - * known to the projection method, try to parse the character string. - */ - final ParameterValue<?> parameter; - try { - parameter = parameters.parameter(name); - } catch (IllegalArgumentException e) { continue; } - final Class<?> type = parameter.getDescriptor().getValueClass(); - if (NumberType.isReal(type)) { - it.remove(); - parameter.setValue(Double.parseDouble(text)); - } else if (NumberType.isReal(type.getComponentType())) { - it.remove(); - parameter.setValue(parseDoubles(text), null); - } } - } catch (IllegalArgumentException ex) { // Includes NumberFormatException. - warning(mapping, - ex, - null, // Default to `Resources` bundle. - Resources.Keys.CanNotSetProjectionParameter_5, - mapping.decoder.getFilename(), - mapping.getName(), - name, - value, - ex.getLocalizedMessage()); + /* + * In principle, we should ignore non-numeric parameters. But in practice, some badly encoded + * netCDF files store parameters as strings instead of numbers. If the parameter name is known + * to the projection method, try to parse the character string. + */ + final ParameterValue<?> parameter; + try { + parameter = parameters.parameter(name); + } catch (ParameterNotFoundException e) { + // No warning because it may be normal. + continue; + } + final Class<?> type = parameter.getDescriptor().getValueClass(); + if (NumberType.isReal(type)) { + it.remove(); + parameter.setValue(Double.parseDouble(text)); + } else if (NumberType.isReal(type.getComponentType())) { + it.remove(); + parameter.setValue(parseDoubles(text), null); + } } + } catch (IllegalArgumentException ex) { // Includes NumberFormatException. + warning(mapping, + ex, + null, // Default to `Resources` bundle. + Resources.Keys.CanNotSetProjectionParameter_5, + decoder.getFilename(), + mapping.getName(), + name, + value, + ex.getLocalizedMessage()); } - /* - * In principle, projection parameters do not include the semi-major and semi-minor axis lengths. - * But if those information are provided, then we use them for building the geodetic reference frame. - * Otherwise a default reference frame will be used. - */ - final CoordinateReferenceSystem fromWKT = crs; - final boolean geographic = (method instanceof PseudoPlateCarree); - final GeographicCRS baseCRS = createBaseCRS(mapping.decoder, fromWKT, parameters, definition, greenwichLongitude, geographic); - final MathTransform baseToCRS; - if (geographic) { - // Only swap axis order from (latitude, longitude) to (longitude, latitude). - baseToCRS = MathTransforms.linear(new Matrix3(0, 1, 0, 1, 0, 0, 0, 0, 1)); - crs = baseCRS; - } else { - // Create a projected CRS. - Supplier<Object> nameFallback = () -> (fromWKT instanceof ProjectedCRS) ? - ((ProjectedCRS) fromWKT).getConversionFromBase().getName() : mapping.getName(); - Map<String,?> properties = properties(definition, Convention.CONVERSION_NAME, nameFallback, false); - final Decoder decoder = mapping.decoder; - final Conversion conversion = decoder.getCoordinateOperationFactory() - .createDefiningConversion(properties, method, parameters); - - nameFallback = () -> (fromWKT != null) ? fromWKT.getName() : conversion.getName(); - properties = properties(definition, Convention.PROJECTED_CRS_NAME, nameFallback, true); - final ProjectedCRS projected = decoder.getCRSFactory() - .createProjectedCRS(properties, baseCRS, conversion, decoder.getStandardProjectedCS()); - - baseToCRS = projected.getConversionFromBase().getMathTransform(); - crs = projected; - } - /* - * The CF-Convention said that even if a WKT definition is provided, other attributes shall be present - * and have precedence over the WKT definition. Consequently, the purpose of WKT in netCDF files is not - * obvious (except for CompoundCRS). - */ - if (fromWKT != null) verifyCRS(fromWKT); - setOrVerifyWKT(definition, CRS_WKT, alreadyParsedWKT); - setOrVerifyWKT(definition, SPATIAL_REF, alreadyParsedWKT); - /* - * Report all projection parameters that have not been used. If the map is not rendered - * at expected location, it may be because we have ignored some important parameters. - */ - definition.remove(CF.LONG_NAME); - if (!definition.isEmpty()) { - warningInMapping(mapping, null, Resources.Keys.UnknownProjectionParameters_3, - String.join(", ", definition.keySet())); - } - /* - * Build the "grid to CRS" if present. This is not defined by CF-convention, - * but may be present in some non-CF conventions. - */ - if (gridToCRS == null) { - gridToCRS = mapping.decoder.convention().gridToCRS(mapping, baseToCRS); - // Map pixel corners by `convention().gridToCRS(…)` contract. - } else { - gridToCRS = MathTransforms.concatenate(gridToCRS, baseToCRS); - } - return true; - } catch (ClassCastException | IllegalArgumentException | FactoryException | TransformException e) { - cannotCreateGridOrCRS(mapping, e, false); } - return false; - } - - /** - * Creates the geographic CRS from axis length specified in the given map projection parameters. - * The returned CRS will always have (latitude, longitude) axes in that order and in degrees. - * - * @param decoder the decoder from which to get factories, conventions and listeners. - * @param fromWKT the CRS parsed from WKT if any. Used for fetching default object names. - * @param parameters parameters from which to get ellipsoid axis lengths. Will not be modified. - * @param definition map from which to get element names. Elements used will be removed. - * @param isMainCRS whether the returned <abbr>CRS</abbr> will be the main one. - */ - private static GeographicCRS createBaseCRS(final Decoder decoder, - final CoordinateReferenceSystem fromWKT, - final ParameterValueGroup parameters, - final Map<String,Object> definition, - final Object greenwichLongitude, - final boolean isMainCRS) - throws FactoryException - { - final DatumFactory datumFactory = decoder.getDatumFactory(); + /* + * In principle, projection parameters do not include the semi-major and semi-minor axis lengths. + * But if those information are provided, then we use them for building the geodetic reference frame. + * Otherwise, a default reference frame will be used. + */ final CommonCRS defaultDefinitions = decoder.convention().defaultHorizontalCRS(false); - boolean isSpecified = false; + final DatumFactory datumFactory = decoder.getDatumFactory(); + boolean hasBuiltSomeCustomObjects = false; /* * Prime meridian built from "longitude_of_prime_meridian". */ final PrimeMeridian meridian; if (greenwichLongitude instanceof Number) { final double longitude = ((Number) greenwichLongitude).doubleValue(); - Supplier<Object> nameFallback = () -> DatumOrEnsemble.getPrimeMeridian(fromWKT) - .<Object>map(PrimeMeridian::getName).orElse(longitude == 0 ? "Greenwich" : null); - Map<String,?> properties = properties(definition, Convention.PRIME_MERIDIAN_NAME, nameFallback, false); + final Map<String,?> properties = properties(definition, false, Convention.PRIME_MERIDIAN_NAME, () -> { + // Fallback if `definition` does not contain a name for the prime meridian. + PrimeMeridian template = DatumOrEnsemble.getPrimeMeridian(fromWKT).orElse(null); + if (template == null) { + if (longitude != 0) return null; + template = defaultDefinitions.primeMeridian(); + } + return template.getName(); + }); meridian = datumFactory.createPrimeMeridian(properties, longitude, Units.DEGREE); - isSpecified = true; + hasBuiltSomeCustomObjects = true; } else { meridian = defaultDefinitions.primeMeridian(); } @@ -430,10 +420,16 @@ final class GridMapping { try { final ParameterValue<?> p = parameters.parameter(Constants.SEMI_MAJOR); final Unit<Length> axisUnit = p.getUnit().asType(Length.class); - final double semiMajor = p.doubleValue(); + final double semiMajor = p.doubleValue(); + boolean isIvfDefinitive; + try { + isIvfDefinitive = parameters.parameter(Constants.IS_IVF_DEFINITIVE).booleanValue(); + } catch (ParameterNotFoundException e) { + // Ignore - may be normal if the map projection is not an Apache SIS implementation. + isIvfDefinitive = false; + } final double secondDefiningParameter; final boolean isSphere; - final boolean isIvfDefinitive = parameters.parameter(Constants.IS_IVF_DEFINITIVE).booleanValue(); if (isIvfDefinitive) { secondDefiningParameter = parameters.parameter(Constants.INVERSE_FLATTENING).doubleValue(); isSphere = (secondDefiningParameter == 0) || Double.isInfinite(secondDefiningParameter); @@ -441,7 +437,8 @@ final class GridMapping { secondDefiningParameter = parameters.parameter(Constants.SEMI_MINOR).doubleValue(axisUnit); isSphere = secondDefiningParameter == semiMajor; } - final Supplier<Object> nameFallback = () -> { // Default ellipsoid name if not specified. + final Map<String,?> properties = properties(definition, false, Convention.ELLIPSOID_NAME, () -> { + // Fallback if `definition` does not contain a name for the ellipsoid. return DatumOrEnsemble.getEllipsoid(fromWKT).<Object>map(Ellipsoid::getName).orElseGet(() -> { final Locale locale = decoder.getLocale(); final String name = Vocabulary.forLocale(locale).getString(isSphere ? Vocabulary.Keys.Sphere : Vocabulary.Keys.Ellipsoid); @@ -452,31 +449,33 @@ final class GridMapping { new FieldPosition(0)) .append(" km").toString(); }); - }; - final Map<String,?> properties = properties(definition, Convention.ELLIPSOID_NAME, nameFallback, false); + }); if (isIvfDefinitive) { ellipsoid = datumFactory.createFlattenedSphere(properties, semiMajor, secondDefiningParameter, axisUnit); } else { ellipsoid = datumFactory.createEllipsoid(properties, semiMajor, secondDefiningParameter, axisUnit); } - isSpecified = true; - } catch (ParameterNotFoundException | IllegalStateException e) { - // Ignore - may be normal if the map projection is not an Apache SIS implementation. + hasBuiltSomeCustomObjects = true; + } catch (IllegalStateException e) { + warningInMapping(mapping, e, Resources.Keys.MissingEllipsoid_3, e.getLocalizedMessage()); ellipsoid = defaultDefinitions.ellipsoid(); } /* * Geodetic reference frame built from "towgs84" and above properties. + * The class of the "towgs84" entry will be verified by the datum constuctor. */ - final Object bursaWolf = definition.remove(Convention.TOWGS84); final GeodeticDatum datum; DatumEnsemble<GeodeticDatum> ensemble = null; - if (isSpecified || bursaWolf != null) { - Supplier<Object> nameFallback = () -> CRS.getGeodeticReferenceFrame(fromWKT).map(GeodeticDatum::getName).orElse(null); - Map<String,Object> properties = properties(definition, Convention.GEODETIC_DATUM_NAME, nameFallback, false); - if (bursaWolf instanceof BursaWolfParameters) { + final Object bursaWolf = definition.remove(Convention.TOWGS84); + if (hasBuiltSomeCustomObjects || bursaWolf != null) { + Map<String, Object> properties = properties(definition, false, Convention.GEODETIC_DATUM_NAME, () -> { + // Fallback if `definition` does not contain a name for the geodetic reference frame. + return CRS.getGeodeticReferenceFrame(fromWKT).map(GeodeticDatum::getName).orElse(null); + }); + if (bursaWolf != null) { properties = new HashMap<>(properties); properties.put(DefaultGeodeticDatum.BURSA_WOLF_KEY, bursaWolf); - isSpecified = true; + hasBuiltSomeCustomObjects = true; } datum = datumFactory.createGeodeticDatum(properties, ellipsoid, meridian); } else { @@ -486,19 +485,83 @@ final class GridMapping { } } /* - * Geographic CRS from all above properties. + * Geographic or projected CRS built from above properties. + * The geographic CRS will always have (latitude, longitude) axes in that order and in degrees. + * The swapping to (longitude, latitude) axis order will be done by the `baseToCRS` transform. */ - if (isSpecified) { - Supplier<Object> nameFallback = () -> (fromWKT != null ? fromWKT : datum).getName(); - Map<String,?> properties = properties(definition, Convention.GEOGRAPHIC_CRS_NAME, nameFallback, isMainCRS); - return decoder.getCRSFactory().createGeographicCRS( - properties, - datum, - ensemble, - defaultDefinitions.geographic().getCoordinateSystem()); + final boolean wantGeographicCRS = (method instanceof PseudoPlateCarree); + GeographicCRS baseCRS = defaultDefinitions.geographic(); + final CRSFactory crsFactory = decoder.getCRSFactory(); + if (hasBuiltSomeCustomObjects) { + final Map<String,?> properties = properties(definition, wantGeographicCRS, Convention.GEOGRAPHIC_CRS_NAME, () -> { + // Fallback if `definition` does not contain a name for the geodetic CRS. + IdentifiedObject base = CRS.getHorizontalComponent(fromWKT); + if (base == null) { + base = datum; + } else if (base instanceof DerivedCRS) { + base = ((DerivedCRS) fromWKT).getBaseCRS(); + } + return base.getName(); + }); + baseCRS = crsFactory.createGeographicCRS(properties, datum, ensemble, baseCRS.getCoordinateSystem()); + } + // Only swap axis order from (latitude, longitude) to (longitude, latitude). + MathTransform baseToCRS = MathTransforms.swapTwoFirstAxes(2); + if (wantGeographicCRS) { + crs = baseCRS; } else { - return defaultDefinitions.geographic(); + /* + * For any "projection" other than Pseudo Plate Carrée, we will create a projected CRS, + * which requires a `Conversion` object built from the values in the `parameters` group. + * Reminder: this parameter group has been created from a subset of `definition` at the + * beginning of this method. + */ + Map<String,?> properties = properties(definition, false, Convention.CONVERSION_NAME, () -> { + if (fromWKT instanceof ProjectedCRS) { + return ((ProjectedCRS) fromWKT).getConversionFromBase().getName(); + } + return mapping.getName(); // Variable on which projection parameters are defined as attributes. + }); + final Conversion conversion = decoder.getCoordinateOperationFactory() + .createDefiningConversion(properties, method, parameters); + /* + * Projected CRS. The "base to CRS" transform is the conversion from base directly. + */ + properties = properties(definition, true, Convention.PROJECTED_CRS_NAME, () -> { + return (fromWKT != null) ? fromWKT.getName() : conversion.getName(); + }); + final ProjectedCRS projected = crsFactory.createProjectedCRS(properties, baseCRS, conversion, decoder.getStandardProjectedCS()); + baseToCRS = MathTransforms.concatenate(baseToCRS, projected.getConversionFromBase().getMathTransform()); + crs = projected; } + /* + * The CF-Convention said that even if a WKT definition is provided, other attributes shall be present + * and have precedence over the WKT definition. Consequently, the purpose of WKT in netCDF files is not + * obvious (except for CompoundCRS). + */ + if (fromWKT != null) verifyCRS(fromWKT); + setOrVerifyWKT(alreadyParsedWKT, definition, CRS_WKT); + setOrVerifyWKT(alreadyParsedWKT, definition, SPATIAL_REF); + /* + * Report all projection parameters that have not been used. If the map is not rendered + * at expected location, it may be because we have ignored some important parameters. + */ + definition.remove(CF.LONG_NAME); + if (!definition.isEmpty()) { + warningInMapping(mapping, null, Resources.Keys.UnknownProjectionParameters_3, + String.join(", ", definition.keySet())); + } + /* + * Build the "grid to CRS" if present. This is not defined by CF-convention, + * but may be present in some non-CF conventions. + */ + if (gridToCRS == null) { + gridToCRS = decoder.convention().gridToCRS(mapping, baseToCRS); + // Map pixel corners by `convention().gridToCRS(…)` contract. + } else { + gridToCRS = MathTransforms.concatenate(gridToCRS, baseToCRS); + } + return true; } /** @@ -507,14 +570,14 @@ final class GridMapping { * fetched from the value of the attribute named {@code nameAttribute}. * * @param definition map containing the attribute values. + * @param takeComment whether to consume the {@code comment} attribute. * @param nameAttribute name of the attribute from which to get the name. * @param nameFallback can return {@link String}, {@link Identifier} or {@code null}. - * @param takeComment whether to consume the {@code comment} attribute. */ - private static Map<String,Object> properties(final Map<String,Object> definition, - final String nameAttribute, - final Supplier<?> nameFallback, - final boolean takeComment) + private static Map<String, Object> properties(final Map<String, Object> definition, + final boolean takeComment, + final String nameAttribute, + final Supplier<?> nameFallback) { Object name = definition.remove(nameAttribute); if (name == null) { @@ -539,21 +602,21 @@ final class GridMapping { * If {@link #crs} is null, it is set to the parsing result. Otherwise, the current {@link #crs} has precedence * but the parsed <abbr>CRS</abbr> is compared and a warning is logged if an inconsistency is found. * - * @param definition map containing the attribute values. - * @param attributeName name of the attribute to consume in the definition map. - * @param done <abbr>WKT</abbr> already parsed, for avoiding repetition. + * @param alreadyParsedWKT <abbr>WKT</abbr> already parsed, for avoiding repetition. + * @param definition map containing the attribute values. + * @param attributeName name of the attribute to consume in the definition map. */ @SuppressWarnings("UseSpecificCatch") - private void setOrVerifyWKT(final Map<String,Object> definition, final String attributeName, final List<String> done) { + private void setOrVerifyWKT(final List<String> alreadyParsedWKT, final Map<String, Object> definition, final String attributeName) { Object value = definition.remove(attributeName); if (value instanceof String) { String wkt = ((String) value).strip(); - for (String previous : done) { + for (String previous : alreadyParsedWKT) { if (wkt.equalsIgnoreCase(previous)) { return; } } - done.add(wkt); + alreadyParsedWKT.add(wkt); CoordinateReferenceSystem fromWKT; try { fromWKT = createFromWKT((String) value); @@ -587,56 +650,55 @@ final class GridMapping { } /** - * Tries to parse a CRS and affine transform from GDAL GeoTransform coefficients. - * Those coefficients are not in the usual order expected by matrix, affine - * transforms or TFW files. The relationship from pixel/line (P,L) coordinates - * to CRS are: + * Tries to parse a <abbr>CRS</abbr> and affine transform from <abbr>GDAL</abbr> GeoTransform coefficients. + * This is used for parsing the <abbr>GDAL</abbr>'s {@value #GEOTRANSFORM} attribute. + * The result is stored in the {@link #crs} and {@link #gridToCRS} fields. + * + * @return whether this method found grid geometry attributes. + */ + private boolean parseGDAL() throws ParseException { + boolean found = parseGeoTransform(mapping.getAttributeAsString(GEOTRANSFORM)); + final String wkt = mapping.getAttributeAsString(SPATIAL_REF); + if (wkt != null) { + crs = createFromWKT(wkt); + isWKT = true; + found = true; + } + return found; + } + + /** + * Tries to parse an affine transform from <abbr>GDAL</abbr> GeoTransform coefficients. + * Those coefficients are not in the order usually found in matrices, affine transforms or <abbr>TFW</abbr> files. + * The relationship from pixel/line (P,L) coordinates to <abbr>CRS</abbr> are: * * {@snippet lang="java" : * X = c[0] + P*c[1] + L*c[2]; * Y = c[3] + P*c[4] + L*c[5]; * } * + * The result is stored in the {@link #gridToCRS} field. + * * @return whether this method found grid geometry attributes. */ - private boolean parseGeoTransform() { - return parseGeoTransform(mapping.getAttributeAsString(SPATIAL_REF), - mapping.getAttributeAsString(GEOTRANSFORM)); - } - - /** - * Implementation of {@link #parseGeoTransform()} with given attribute values. - * Used for parsing the <abbr>GDAL</abbr>'s {@value #GEOTRANSFORM} attribute. - * Results is stored in {@link #gridToCRS}. - */ @SuppressWarnings("UseSpecificCatch") - private boolean parseGeoTransform(final String wkt, final String gtr) { - boolean grid = false; - boolean done = false; - try { - if (wkt != null) { - crs = createFromWKT(wkt); - isWKT = true; - done = true; - } - if (gtr != null) { - grid = true; - final double[] c = parseDoubles(gtr); - if (c.length != 6) { - throw new DataStoreContentException(mapping.errors().getString(Errors.Keys.UnexpectedArrayLength_2, 6, c.length)); - } + private boolean parseGeoTransform(final String gtr) { + if (gtr != null) { + final double[] c = parseDoubles(gtr); + if (c.length == 6) { /* * GDAL convention maps pixel corners and see the data as if it was an image. * The row which is visually on the top is handled as if its index was zero, * ignoring the fact that this is usually the last row in a netCDF variable. */ gridToCRS = new AffineTransform2D(c[1], c[4], c[2], c[5], c[0], c[3]); // X_DIMENSION, Y_DIMENSION - done = true; + anchorFromConvention = PixelInCell.CELL_CORNER; + return true; } - } catch (Exception e) { - cannotCreateGridOrCRS(mapping, e, grid); + var e = new DataStoreContentException(mapping.errors().getString(Errors.Keys.UnexpectedArrayLength_2, 6, c.length)); + cannotCreateGridOrCRS(mapping, e, true); } - return done; + return false; } /** @@ -650,12 +712,11 @@ final class GridMapping { /** * Tries to parse the Coordinate Reference System using ESRI conventions or other non-CF conventions. - * This method is invoked as a fallback if {@link #parseGeoTransform()} found no grid geometry. + * This method is invoked as a fallback if {@link #parseGDAL()} found no grid geometry. * * @return whether this method found grid geometry attributes. */ - @SuppressWarnings("UseSpecificCatch") - private boolean parseESRI() { + private boolean parseESRI() throws ParseException, FactoryException { String code = mapping.getAttributeAsString("ESRI_pe_string"); isWKT = (code != null); if (code == null) { @@ -671,21 +732,16 @@ final class GridMapping { * The CRS parsings below need to take those differences in account, except axis order which is tested in * the `adaptGridCRS(…)` method. */ - try { - if (isWKT) { - crs = createFromWKT(code); - } else { - crs = CRS.forCode(Constants.EPSG + ':' + code); - } - } catch (Exception e) { - cannotCreateGridOrCRS(mapping, e, false); - return false; + if (isWKT) { + crs = createFromWKT(code); + } else { + crs = CRS.forCode(Constants.EPSG + ':' + code); } return true; } /** - * Creates a coordinate reference system by parsing a Well Known Text (WKT) string. + * Creates a coordinate reference system by parsing a Well Known Text (<abbr>WKT</abbr>) string. * The WKT is presumed to use the GDAL flavor of WKT 1, and warnings are redirected to decoder listeners. */ private CoordinateReferenceSystem createFromWKT(final String wkt) throws ParseException { @@ -756,21 +812,21 @@ final class GridMapping { * @return the transform for the given variable. */ private MathTransform gridToCRS(final Variable variable) { - MathTransform implicitG2C = gridToCRS; - if (implicitG2C != null) { + MathTransform flipped = gridToCRS; + if (flipped != null) { final int yDim = variable.getNumDimensions() - (1 + SOURCE_AXIS_TO_FLIP); if (yDim >= 0) { final long height = variable.getGridDimensions().get(yDim).length(); if (height >= 0) { // Negative if undetermined length. - final int srcDim = gridToCRS.getSourceDimensions(); + final int srcDim = flipped.getSourceDimensions(); final MatrixSIS m = Matrices.createIdentity(srcDim + 1); m.setElement(SOURCE_AXIS_TO_FLIP, SOURCE_AXIS_TO_FLIP, -1); m.setElement(SOURCE_AXIS_TO_FLIP, srcDim, height); - implicitG2C = MathTransforms.concatenate(MathTransforms.linear(m), implicitG2C); + flipped = MathTransforms.concatenate(MathTransforms.linear(m), flipped); } } } - return implicitG2C; + return flipped; } /** @@ -830,9 +886,9 @@ final class GridMapping { */ CoordinateReferenceSystem explicitCRS = crs; MathTransform explicitG2C = gridToCRS(variable); - if (explicitG2C != null) { + if (anchorFromConvention != null) { // GDAL "GeoTransform" uses pixel corner convention. - anchor = PixelInCell.CELL_CORNER; + anchor = anchorFromConvention; } int firstAffectedCoordinate = 0; boolean isSameGrid = true; @@ -849,11 +905,13 @@ final class GridMapping { * * This is where the potential difference between EPSG axis order and grid axis order is handled. * If we cannot find which component to replace, assume that grid mapping describes the first dimensions. - * We have no guarantees that this latter assumption is right, but it seems to match common practice. + * We have no guarantees that this latter assumption is correct, but it seems to match common practice. */ + Matrix swapAxisOrder = null; final CoordinateSystem cs = implicitCRS.getCoordinateSystem(); firstAffectedCoordinate = AxisDirections.indexOfColinear(cs, explicitCRS.getCoordinateSystem()); if (firstAffectedCoordinate < 0) { + final CoordinateReferenceSystem beforeSwap = explicitCRS; explicitCRS = AbstractCRS.castOrCopy(explicitCRS).forConvention(AxesConvention.RIGHT_HANDED); firstAffectedCoordinate = AxisDirections.indexOfColinear(cs, explicitCRS.getCoordinateSystem()); if (firstAffectedCoordinate < 0) { @@ -862,6 +920,13 @@ final class GridMapping { explicitCRS = crs; // If specified by WKT, use the CRS verbatim. } } + if (explicitCRS != beforeSwap) try { + swapAxisOrder = CoordinateSystems.swapAndScaleAxes(beforeSwap.getCoordinateSystem(), + explicitCRS.getCoordinateSystem()); + } catch (IllegalArgumentException | IncommensurableException e) { + cannotCreateGridOrCRS(variable, e, false); + return null; + } } /* * Replace the grid CRS (or a component of it) by the CRS parsed from WKT or EPSG code with same (if possible) @@ -878,17 +943,17 @@ final class GridMapping { if (isSameGrid) { explicitCRS = implicitCRS; // Keep existing instance if appropriate. } - } - /* - * If we have run the `AbstractCRS.castOrCopy(…).forConvention(…)` code above, the axis order of the CRS - * may be different than the axis order which was assumed when the "grid to CRS" transform was built. - */ - if (explicitCRS != crs && explicitG2C != null) try { - var swap = CoordinateSystems.swapAndScaleAxes(crs.getCoordinateSystem(), explicitCRS.getCoordinateSystem()); - explicitG2C = MathTransforms.concatenate(explicitG2C, MathTransforms.linear(swap)); - } catch (IllegalArgumentException | IncommensurableException e) { - cannotCreateGridOrCRS(variable, e, false); - return null; + /* + * If we have run the `AbstractCRS.castOrCopy(…).forConvention(…)` code above, + * the axis order of the CRS may become different than the axis order which was + * assumed when the "grid to CRS" transform was built. + */ + if (swapAxisOrder != null) { + MathTransform swap = MathTransforms.linear(swapAxisOrder); + int numTrailingCoordinates = explicitG2C.getTargetDimensions() - firstAffectedCoordinate; + swap = MathTransforms.passThrough(firstAffectedCoordinate, swap, numTrailingCoordinates); + explicitG2C = MathTransforms.concatenate(explicitG2C, swap); + } } } /* diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java index 8c8c1ecd36..8e9536d59b 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java +++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java @@ -1065,7 +1065,7 @@ nextVar: for (final VariableInfo variable : variables) { * and we would not need to check for variables having dimension names. However, in practice there is * incomplete attributes, so we check for other dimensions even if the above loop did some work. */ - for (int i=variable.dimensions.length; --i >= 0;) { // Reverse of netCDF order. + for (int i = variable.dimensions.length; --i >= 0;) { // Reverse of netCDF order. final DimensionInfo dimension = variable.dimensions[i]; if (usedDimensions.add(dimension)) { final List<VariableInfo> axis = dimToAxes.get(dimension); // Should have only 1 element. diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources.java index 26b5fa05ae..d9d5583fde 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources.java +++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources.java @@ -185,6 +185,11 @@ public class Resources extends IndexedResourceBundle { */ public static final short MismatchedVariableType_3 = 13; + /** + * Missing or incomplete ellipsoid on the “{1}” variable of netCDF file “{0}”: {2} + */ + public static final short MissingEllipsoid_3 = 31; + /** * Missing attribute “{2}” on the “{1}” variable of netCDF file “{0}”. */ diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources.properties b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources.properties index 2c64238ed4..567b430342 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources.properties +++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources.properties @@ -41,6 +41,7 @@ GridLongitudeSpanTooWide_2 = The grid spans {0}\u00b0 of longitude, which MismatchedAttributeLength_5 = Attributes \u201c{1}\u201d and \u201c{2}\u201d on variable \u201c{0}\u201d have different lengths: {3} and {4} respectively. MismatchedVariableSize_3 = The declared size of variable \u201c{1}\u201d in netCDF file \u201c{0}\u201d is {2,number} bytes greater than expected. MismatchedVariableType_3 = Variables \u201c{1}\u201d and \u201c{2}\u201d in netCDF file \u201c{0}\u201d does not have the same type. +MissingEllipsoid_3 = Missing or incomplete ellipsoid on the \u201c{1}\u201d variable of netCDF file \u201c{0}\u201d: {2} MissingVariableAttribute_3 = Missing attribute \u201c{2}\u201d on the \u201c{1}\u201d variable of netCDF file \u201c{0}\u201d. ResamplingIntervalNotFound_2 = Variable \u201c{1}\u201d or netCDF file \u201c{0}\u201d has a different size than its coordinate system, but no resampling interval is specified. UnexpectedAxisCount_4 = Reference system of type \u2018{1}\u2019 cannot have {2}\u00a0axes. The axes found in the \u201c{0}\u201d netCDF file are: {3}. diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources_fr.properties b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources_fr.properties index 6e94aba15d..24f66085b3 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources_fr.properties +++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/internal/Resources_fr.properties @@ -46,6 +46,7 @@ GridLongitudeSpanTooWide_2 = La grille s\u2019\u00e9tend sur {0}\u00b0 de MismatchedAttributeLength_5 = Les attributs \u201c{1}\u201d et \u201c{2}\u201d de la variable \u201c{0}\u201d ont des longueurs diff\u00e9rentes\u00a0: {3} et {4} respectivement. MismatchedVariableSize_3 = La longueur d\u00e9clar\u00e9e de la variable \u00ab\u202f{1}\u202f\u00bb dans le fichier netCDF \u00ab\u202f{0}\u202f\u00bb d\u00e9passe de {2,number} octets la valeur attendue. MismatchedVariableType_3 = Les variables \u00ab\u202f{1}\u202f\u00bb et \u00ab\u202f{2}\u202f\u00bb dans le fichier netCDF \u00ab\u202f{0}\u202f\u00bb ne sont pas du m\u00eame type. +MissingEllipsoid_3 = L\u2019ellipso\u00efde est manquant ou incomplet sur la variable \u201c{1}\u201d du fichier netCDF \u201c{0}\u201d\u00a0: {2} MissingVariableAttribute_3 = Il manque l\u2019attribut \u201c{2}\u201d sur la variable \u201c{1}\u201d du fichier netCDF \u201c{0}\u201d. ResamplingIntervalNotFound_2 = La variable \u00ab\u202f{1}\u202f\u00bb du fichier netCDF \u00ab\u202f{0}\u202f\u00bb a une taille diff\u00e9rente de celle de son syst\u00e8me de coordonn\u00e9es, mais l\u2019intervalle d\u2019\u00e9chantillonnage n\u2019a pas \u00e9t\u00e9 sp\u00e9cifi\u00e9. UnexpectedAxisCount_4 = Les syst\u00e8mes de r\u00e9f\u00e9rence de type \u2018{1}\u2019 ne peuvent pas avoir {2}\u00a0axes. Les axes trouv\u00e9s dans le fichier netCDF \u00ab\u202f{0}\u202f\u00bb sont\u00a0: {3}. diff --git a/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/MetadataReaderTest.java b/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/MetadataReaderTest.java index 689854ae78..ae23a1d98d 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/MetadataReaderTest.java +++ b/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/MetadataReaderTest.java @@ -48,11 +48,13 @@ import org.opengis.test.dataset.TestData; /** - * Tests {@link MetadataReader}. This tests uses the SIS embedded implementation and the UCAR library - * for reading netCDF attributes. + * Tests {@link MetadataReader}. + * This tests uses the <abbr>SIS</abbr> embedded implementation + * and the <abbr>UCAR</abbr> library for reading netCDF attributes. * * @author Martin Desruisseaux (Geomatys) */ +@SuppressWarnings("exports") public final class MetadataReaderTest extends TestCase { /** * Creates a new test case. diff --git a/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/NetcdfStoreProviderTest.java b/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/NetcdfStoreProviderTest.java index c9856d40bf..51580075d1 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/NetcdfStoreProviderTest.java +++ b/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/NetcdfStoreProviderTest.java @@ -41,6 +41,7 @@ import org.opengis.test.dataset.TestData; * * @author Martin Desruisseaux (Geomatys) */ +@SuppressWarnings("exports") public final class NetcdfStoreProviderTest extends TestCase { /** * Creates a new test case. diff --git a/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/NetcdfStoreTest.java b/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/NetcdfStoreTest.java index a2ea937da1..01c42e47fe 100644 --- a/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/NetcdfStoreTest.java +++ b/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/NetcdfStoreTest.java @@ -16,16 +16,27 @@ */ package org.apache.sis.storage.netcdf; +import java.net.URL; import org.opengis.metadata.Metadata; +import org.opengis.referencing.crs.CoordinateReferenceSystem; +import org.opengis.referencing.crs.ProjectedCRS; +import org.opengis.referencing.operation.MathTransform; +import org.apache.sis.referencing.operation.transform.LinearTransform; +import org.apache.sis.referencing.CRS; +import org.apache.sis.coverage.grid.GridExtent; +import org.apache.sis.coverage.grid.GridGeometry; +import org.apache.sis.coverage.grid.PixelInCell; import org.apache.sis.storage.StorageConnector; import org.apache.sis.storage.DataStoreException; +import org.apache.sis.storage.GridCoverageResource; import org.apache.sis.system.Loggers; import org.apache.sis.util.Version; // Test dependencies import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; -import org.apache.sis.test.TestCaseWithLogs; +import static org.apache.sis.feature.Assertions.assertExtentEquals; +import org.apache.sis.storage.DataStoreTestCase; // Specific to the geoapi-3.1 and geoapi-4.0 branches: import org.opengis.test.dataset.TestData; @@ -35,8 +46,10 @@ import org.opengis.test.dataset.TestData; * Tests {@link NetcdfStore}. * * @author Martin Desruisseaux (Geomatys) + * @author Alexis Manin (Geomatys) */ -public final class NetcdfStoreTest extends TestCaseWithLogs { +@SuppressWarnings("exports") +public final class NetcdfStoreTest extends DataStoreTestCase { /** * Creates a new test case. */ @@ -45,13 +58,39 @@ public final class NetcdfStoreTest extends TestCaseWithLogs { } /** - * Returns a new netCDF store to test. + * Returns a new netCDF store opened on a dataset specified by an enumeration value. * - * @param dataset the name of the datastore to load. + * @param dataset the name of the dataset to open. + * @return netCDF data store opened for the given dataset. * @throws DataStoreException if an error occurred while reading the netCDF file. */ - private static NetcdfStore create(final TestData dataset) throws DataStoreException { - return new NetcdfStore(null, new StorageConnector(dataset.location())); + private NetcdfStore create(final TestData dataset) throws DataStoreException { + return create(dataset.name(), dataset.location()); + } + + /** + * Returns a new netCDF store opened on a dataset specified by a resource name. + * + * @param dataset the name of a file in the same directory as this test class. + * @throws DataStoreException if an error occurred while reading the netCDF file. + */ + private NetcdfStore create(final String dataset) throws DataStoreException { + return create(dataset, NetcdfStoreTest.class.getResource(dataset)); + } + + /** + * Returns a new netCDF store to test from the given <abbr>URL</abbr>. + * + * @param name the name of the dataset to open, for assertion message. + * @param dataset the <abbr>URL</abbr> of the dataset to open. + * @return netCDF data store opened for the given dataset. + * @throws DataStoreException if an error occurred while reading the netCDF file. + */ + private NetcdfStore create(final String name, final URL dataset) throws DataStoreException { + assertNotNull(dataset, name); + final var store = new NetcdfStore(null, new StorageConnector(dataset)); + listen(store); + return store; } /** @@ -86,4 +125,33 @@ public final class NetcdfStoreTest extends TestCaseWithLogs { assertEquals(4, version.getMinor()); loggings.assertNoUnexpectedLog(); } + + /** + * Tests the reading of a file a <abbr>CRS</abbr> declared using <abbr>GDAL</abbr>-specific properties. + * + * @throws DataStoreException if an error occurred while reading the netCDF file. + */ + @Test + public void testFileWithGDALEncoding() throws DataStoreException { + try (NetcdfStore store = create("transverse-mercator-wrong-geotransform.nc")) { + final var r = assertInstanceOf(GridCoverageResource.class, store.findResource("evi")); + final GridGeometry gg = r.getGridGeometry(); + assertEquals(3, gg.getDimension()); + + final GridExtent extent = gg.getExtent(); + assertExtentEquals(new long[3], new long[] {2, 3, 2}, extent); + + final MathTransform gridToCRS = gg.getGridToCRS(PixelInCell.CELL_CORNER); + assertEquals(3, gridToCRS.getSourceDimensions()); + assertEquals(3, gridToCRS.getTargetDimensions()); + assertFalse(gridToCRS instanceof LinearTransform); + + final CoordinateReferenceSystem crs = gg.getCoordinateReferenceSystem(); + assertInstanceOf(ProjectedCRS.class, CRS.getHorizontalComponent(crs)); + assertNotNull(CRS.getTemporalComponent(crs)); + } + // The reader should have emitted a warning about mismatched `GeoTransform` values. + loggings.assertNextLogContains("GeoTransform", "evi", "transverse-mercator-wrong-geotransform.nc"); + loggings.assertNoUnexpectedLog(); + } } diff --git a/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/transverse-mercator-wrong-geotransform.nc b/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/transverse-mercator-wrong-geotransform.nc new file mode 100644 index 0000000000..a1851f4d54 Binary files /dev/null and b/endorsed/src/org.apache.sis.storage.netcdf/test/org/apache/sis/storage/netcdf/transverse-mercator-wrong-geotransform.nc differ diff --git a/endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/DataStoreTestCase.java b/endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/DataStoreTestCase.java new file mode 100644 index 0000000000..277161153c --- /dev/null +++ b/endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/DataStoreTestCase.java @@ -0,0 +1,72 @@ +/* + * 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. + */ +package org.apache.sis.storage; + +import java.util.logging.Logger; +import org.apache.sis.storage.event.StoreListener; +import org.apache.sis.storage.event.WarningEvent; + +// Test dependencies +import org.apache.sis.test.TestCaseWithLogs; + + +/** + * Base class for {@link DataStore} tests which may emit warnings. + * + * @author Martin Desruisseaux (Geomatys) + */ +@SuppressWarnings("exports") +public abstract class DataStoreTestCase extends TestCaseWithLogs implements StoreListener<WarningEvent> { + /** + * Creates a new test case which will listen to logs emitted by the given logger. + * + * @param logger the logger to listen to. + */ + protected DataStoreTestCase(Logger logger) { + super(logger); + } + + /** + * Creates a new test case which will listen to logs emitted by the logger of the given name. + * + * @param logger name of the logger to listen. + */ + protected DataStoreTestCase(String logger) { + super(logger); + } + + /** + * Performs an unconditional redirection of the warnings emitted by the given store to the JUnit extension. + * The {@link #eventOccured(WarningEvent)} method will be invoked for each warning. + * + * @param store the data store to listen to. + */ + protected final void listen(DataStore store) { + store.addListener(WarningEvent.class, this); + } + + /** + * Forwards the warning emitted by the data store to the JUnit extension that we use for checking warnings. + * This is defined as an ordinary method rather than a lambda function for making easy to set debugger break point. + * + * @param event the warning emitted by the data store. + */ + @Override + public final void eventOccured(WarningEvent event) { + loggings.accept(event.getDescription()); + } +} diff --git a/endorsed/src/org.apache.sis.util/test/org/apache/sis/test/LoggingWatcher.java b/endorsed/src/org.apache.sis.util/test/org/apache/sis/test/LoggingWatcher.java index 0b1addf8c0..28c39b2b8b 100644 --- a/endorsed/src/org.apache.sis.util/test/org/apache/sis/test/LoggingWatcher.java +++ b/endorsed/src/org.apache.sis.util/test/org/apache/sis/test/LoggingWatcher.java @@ -22,6 +22,7 @@ import java.util.Queue; import java.util.LinkedList; import java.util.ConcurrentModificationException; import java.util.Objects; +import java.util.function.Consumer; import java.util.logging.Filter; import java.util.logging.Level; import java.util.logging.Logger; @@ -74,7 +75,7 @@ import org.junit.jupiter.api.extension.ExtensionContext; * * @author Martin Desruisseaux (Geomatys) */ -public final class LoggingWatcher implements BeforeEachCallback, AfterEachCallback, Filter { +public final class LoggingWatcher implements BeforeEachCallback, AfterEachCallback, Filter, Consumer<LogRecord> { /** * Name of the lock to use in a JUnit {@code ResourceLock} annotation. * Tests that are executed in a single thread can be run in parallel @@ -265,26 +266,37 @@ public final class LoggingWatcher implements BeforeEachCallback, AfterEachCallba */ LoggingWatcher owner = null; synchronized (logger) { - if (allFilters == null) { // Should never be null, but sometime happens for an unknown reason. - return true; - } - for (final LoggingWatcher w : allFilters) { - if (w.isMultiThread || w.threadId == record.getLongThreadID()) { - owner = w; - break; + if (allFilters != null) { // Should never be null when executed by JUnit. + for (final LoggingWatcher w : allFilters) { + if (w.isMultiThread || w.threadId == record.getLongThreadID()) { + owner = w; + break; + } } + } else { + owner = this; // Not executed by JUnit. Assume sequential execution. } } if (owner == null) { return true; } - synchronized (owner) { - owner.messages.add(new Message(owner.formatter.formatMessage(record), record.getThrown())); - } + owner.accept(record); } return TestCase.VERBOSE; } + /** + * Unconditionally adds the logging message to the {@link #messages} list. + * Contrarily to {@link #isLoggable(LogRecord)}, this method does not check + * ownership or the log level. + * + * @param record the log record to add to the {@link #messages} list. + */ + @Override + public synchronized void accept(final LogRecord record) { + messages.add(new Message(formatter.formatMessage(record), record.getThrown())); + } + /** * Skips the next log messages if it contains all the given keywords. * This method is used instead of {@link #assertNextLogContains(String...)} when a log message may or
