You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2020/08/31 17:35:46 UTC

[sis] 02/02: Add a WraparoundTransform, to be used in a next commit for resampling images crossing the anti-meridian.

This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 5227d015b8f07ac700a3a098c8ccf0d84664ed7f
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sun Aug 30 13:35:57 2020 +0200

    Add a WraparoundTransform, to be used in a next commit for resampling images crossing the anti-meridian.
---
 .../coverage/grid/ResampledGridCoverageTest.java   |   2 +-
 .../internal/referencing/WraparoundTransform.java  | 417 +++++++++++++++++++++
 .../referencing/operation/matrix/MatrixSIS.java    |   2 +-
 .../operation/transform/ConcatenatedTransform.java |   4 +-
 .../operation/transform/MathTransforms.java        |   5 +-
 .../referencing/WraparoundTransformTest.java       | 135 +++++++
 .../apache/sis/referencing/crs/HardCodedCRS.java   |  47 ++-
 .../sis/referencing/crs/HardCodedCRSTest.java      |   4 +-
 .../apache/sis/referencing/cs/HardCodedAxes.java   |   9 +-
 .../org/apache/sis/referencing/cs/HardCodedCS.java |   9 +-
 .../sis/referencing/datum/HardCodedDatum.java      |   8 +-
 .../sis/test/suite/ReferencingTestSuite.java       |   1 +
 12 files changed, 624 insertions(+), 19 deletions(-)

diff --git a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ResampledGridCoverageTest.java b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ResampledGridCoverageTest.java
index 841e08d..64a9a2e 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ResampledGridCoverageTest.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ResampledGridCoverageTest.java
@@ -358,7 +358,7 @@ public final strictfp class ResampledGridCoverageTest extends TestCase {
     @Test
     public void testTemporalAxisMoved() throws TransformException {
         final GridCoverage source = createCoverageND(true);
-        final GridGeometry target = createGridGeometryND(HardCodedCRS.TIME_WGS84, 1, 2, 3, 0, false);
+        final GridGeometry target = createGridGeometryND(HardCodedCRS.WGS84_4D_TIME_FIRST, 1, 2, 3, 0, false);
         final GridCoverage result = resample(source, target);
         assertAxisDirectionsEqual("Expected (t,λ,φ,H) axes.",
                 result.getGridGeometry().getCoordinateReferenceSystem().getCoordinateSystem(),
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/WraparoundTransform.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/WraparoundTransform.java
new file mode 100644
index 0000000..7366cf6
--- /dev/null
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/WraparoundTransform.java
@@ -0,0 +1,417 @@
+/*
+ * 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.internal.referencing;
+
+import java.util.List;
+import org.opengis.util.FactoryException;
+import org.opengis.geometry.DirectPosition;
+import org.opengis.referencing.cs.CoordinateSystem;
+import org.opengis.referencing.cs.CoordinateSystemAxis;
+import org.opengis.referencing.operation.CoordinateOperation;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransformFactory;
+import org.opengis.referencing.operation.Matrix;
+import org.opengis.referencing.operation.NoninvertibleTransformException;
+import org.apache.sis.referencing.factory.InvalidGeodeticParameterException;
+import org.apache.sis.referencing.operation.matrix.Matrices;
+import org.apache.sis.referencing.operation.matrix.MatrixSIS;
+import org.apache.sis.referencing.operation.transform.AbstractMathTransform;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.internal.system.Modules;
+import org.apache.sis.internal.util.Numerics;
+import org.apache.sis.io.wkt.Formatter;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.ComparisonMode;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.logging.Logging;
+
+
+/**
+ * Enforces coordinate values in the range of a wraparound axis (typically longitude).
+ * This transform is usually not needed for the [-180 … +180]° range since it is the
+ * range of trigonometric functions. However this transform is useful for shifting
+ * transformation results in the [0 … 360]° range.
+ *
+ * <p>{@code WraparoundTransform}s are not created automatically by {@link org.apache.sis.referencing.CRS#findOperation
+ * CRS.findOperation(…)} because they introduce a discontinuity in coordinate transformations. Such discontinuities are
+ * hurtless when transforming only a cloud of points, but produce undesirable artifacts when transforming geometries.
+ * Callers need to invoke {@link #create create} explicitly if discontinuities are acceptable.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public final class WraparoundTransform extends AbstractMathTransform {
+    /**
+     * The dimension of source and target coordinates.
+     */
+    private final int dimension;
+
+    /**
+     * The dimension where to apply wraparound.
+     */
+    final int wraparoundDimension;
+
+    /**
+     * Creates a new transform with a wraparound behavior in the given dimension.
+     * Input and output values in the wraparound dimension shall be normalized in
+     * the [0 … 1] range.
+     */
+    private WraparoundTransform(final int dimension, final int wraparoundDimension) {
+        this.dimension = dimension;
+        this.wraparoundDimension = wraparoundDimension;
+    }
+
+    /**
+     * Returns an instance with the given number of dimensions while keeping {@link #wraparoundDimension} unchanged.
+     * If no instance can be created for the given number of dimensions, then this method returns {@code null}.
+     */
+    private WraparoundTransform redim(final int n) {
+        if (n == dimension) return this;
+        if (n >= wraparoundDimension) return null;
+        return new WraparoundTransform(n, wraparoundDimension);
+    }
+
+    /**
+     * Returns the transform of the given coordinate operation augmented with a "wrap around" behavior if applicable.
+     * The wraparound is applied on target coordinates and aims to clamp coordinate values inside the range of target
+     * coordinate system axes.
+     *
+     * @param  factory  the factory to use for creating math transforms.
+     * @param  op       the coordinate operation for which to get the math transform.
+     * @return the math transform for the given coordinate operation.
+     * @throws FactoryException if an error occurred while creating the math transform.
+     */
+    public static MathTransform create(final MathTransformFactory factory, final CoordinateOperation op)
+            throws FactoryException
+    {
+        MathTransform tr = op.getMathTransform();
+        final CoordinateSystem cs = op.getTargetCRS().getCoordinateSystem();
+        final int dimension = cs.getDimension();
+        for (final int wraparoundDimension : CoordinateOperations.wrapAroundChanges(op)) {
+            final CoordinateSystemAxis axis = cs.getAxis(wraparoundDimension);
+            final MathTransform wraparound  = create(factory, dimension, wraparoundDimension,
+                                                     axis.getMinimumValue(), axis.getMaximumValue());
+            tr = factory.createConcatenatedTransform(tr, wraparound);
+        }
+        return tr;
+    }
+
+    /**
+     * Creates a transform with a "wrap around" behavior in the given dimension.
+     *
+     * @param  factory              the factory to use for creating math transforms.
+     * @param  dimension            the number of source and target dimensions.
+     * @param  wraparoundDimension  the dimension where "wrap around" behavior apply.
+     * @param  minimum              minimal value in the "wrap around" dimension.
+     * @param  maximum              maximal value in the "wrap around" dimension.
+     * @return the math transform with "wrap around" behavior in the specified dimension.
+     * @throws FactoryException in an error occurred while creating the math transform.
+     */
+    private static MathTransform create(final MathTransformFactory factory, final int dimension,
+            final int wraparoundDimension, final double minimum, final double maximum) throws FactoryException
+    {
+        ArgumentChecks.ensureStrictlyPositive("dimension", dimension);
+        ArgumentChecks.ensureBetween("wraparoundDimension", 0, dimension - 1, wraparoundDimension);
+        NoninvertibleTransformException cause = null;
+        final double span = maximum - minimum;
+        if (span > 0 && span != Double.POSITIVE_INFINITY) {
+            final MatrixSIS m = Matrices.createIdentity(dimension + 1);
+            m.setElement(wraparoundDimension, wraparoundDimension, span);
+            m.setElement(wraparoundDimension, dimension, minimum);
+            final MathTransform denormalize = factory.createAffineTransform(m);
+            final WraparoundTransform wraparound = new WraparoundTransform(dimension, wraparoundDimension);
+            try {
+                return factory.createConcatenatedTransform(denormalize.inverse(),
+                       factory.createConcatenatedTransform(wraparound, denormalize));
+            } catch (NoninvertibleTransformException e) {
+                // Matrix is non-invertible only if the range given in argument is illegal.
+                cause = e;
+            }
+        }
+        throw new InvalidGeodeticParameterException(Errors.format(Errors.Keys.IllegalRange_2, minimum, maximum), cause);
+    }
+
+    /**
+     * Gets the dimension of input points.
+     *
+     * @return the dimension of input points.
+     */
+    @Override
+    public int getSourceDimensions() {
+        return dimension;
+    }
+
+    /**
+     * Gets the dimension of output points.
+     *
+     * @return the dimension of output points.
+     */
+    @Override
+    public int getTargetDimensions() {
+        return dimension;
+    }
+
+    /**
+     * Gets the derivative of this transform at a point.
+     */
+    @Override
+    public Matrix derivative(final DirectPosition point) {
+        final MatrixSIS derivative = Matrices.createIdentity(dimension);
+        final double v = point.getOrdinate(wraparoundDimension);
+        if (v == Math.floor(v)) {
+            derivative.setElement(wraparoundDimension, wraparoundDimension, Double.NEGATIVE_INFINITY);
+        }
+        return derivative;
+    }
+
+    /**
+     * Wraparounds a single coordinate point in an array,
+     * and optionally computes the transform derivative at that location.
+     */
+    @Override
+    public Matrix transform(final double[] srcPts, final int srcOff,
+                            final double[] dstPts, final int dstOff, final boolean derivate)
+    {
+        double v = srcPts[srcOff];
+        v -= Math.floor(v);
+        if (dstPts != null) {
+            System.arraycopy(srcPts, srcOff, dstPts, dstOff, dimension);
+            dstPts[dstOff + wraparoundDimension] = v;
+        }
+        if (!derivate) {
+            return null;
+        }
+        final MatrixSIS derivative = Matrices.createIdentity(dimension);
+        if (v == 0) {
+            derivative.setElement(wraparoundDimension, wraparoundDimension, Double.NEGATIVE_INFINITY);
+        }
+        return derivative;
+    }
+
+    /**
+     * Transforms many coordinates in a list of ordinal values.
+     */
+    @Override
+    public void transform(final double[] srcPts, int srcOff,
+                          final double[] dstPts, int dstOff, int numPts)
+    {
+        System.arraycopy(srcPts, srcOff, dstPts, dstOff, numPts * dimension);
+        dstOff += wraparoundDimension;
+        while (--numPts >= 0) {
+            dstPts[dstOff] -= Math.floor(dstPts[dstOff]);
+            dstOff += dimension;
+        }
+    }
+
+    /**
+     * Transforms many coordinates in a list of ordinal values.
+     */
+    @Override
+    public void transform(final float[] srcPts, int srcOff,
+                          final float[] dstPts, int dstOff, int numPts)
+    {
+        System.arraycopy(srcPts, srcOff, dstPts, dstOff, numPts * dimension);
+        dstOff += wraparoundDimension;
+        while (--numPts >= 0) {
+            dstPts[dstOff] -= Math.floor(dstPts[dstOff]);
+            dstOff += dimension;
+        }
+    }
+
+    /**
+     * Throws a {@code NoninvertibleTransformException}.
+     * We do not return another {@code WraparoundTransform} for three reasons:
+     *
+     * <ul>
+     *   <li>The inverse wraparound would work on a different range of values, but we do not know that range.</li>
+     *   <li>Even if we knew the original range of values, creating the inverse transform would require the affine
+     *       transforms before and after {@code WraparoundTransform} to be different; it would not be their inverse.
+     *       This is impractical, especially since the transform matrices may have been multiplied with other affine
+     *       transforms.</li>
+     *   <li>Even if we were able to build the inverse {@code WraparoundTransform}, it would not necessarily be
+     *       appropriate. For example in "ProjectedCRS → BaseCRS → GeographicCRS" operation chain, wraparound
+     *       may happen after the geographic CRS. But in the "GeographicCRS → BaseCRS → ProjectedCRS" inverse
+     *       operation, the wraparound would be between BaseCRS and ProjectedCRS, which is often not needed.</li>
+     * </ul>
+     *
+     * We do not return an identity transform because it causes incorrect resampling operation steps when concatenated,
+     * especially when testing if transforms are mutually the inverse of each other.
+     *
+     * @return never return.
+     * @throws NoninvertibleTransformException always thrown.
+     */
+    @Override
+    public MathTransform inverse() throws NoninvertibleTransformException {
+        return super.inverse();
+    }
+
+    /**
+     * Concatenates in an optimized way this math transform with the given one, if possible.
+     */
+    @Override
+    protected MathTransform tryConcatenate(final boolean applyOtherFirst, final MathTransform other,
+                                           final MathTransformFactory factory) throws FactoryException
+    {
+        /*
+         * If the other transform is also a `WraparoundTransform` for the same dimension,
+         * then there is no need to concatenate those two consecutive redudant transforms.
+         */
+        if (equals(other, null)) {
+            return this;
+        }
+        /*
+         * If two `WraparoundTransform` instances are separated only by a `LinearTransform`,
+         * then that linear transform is moved before or after this `WraparoundTransform`
+         * for increasing the chances to concatenate it using a matrix multiplication.
+         */
+        if (applyOtherFirst) {
+            final List<MathTransform> steps = MathTransforms.getSteps(other);
+            if (steps.size() == 2) {
+                final MathTransform middle = steps.get(1);
+                Matrix matrix = MathTransforms.getMatrix(middle);
+                if (matrix != null) try {
+                    MathTransform step2 = this;
+                    final MathTransform after = movable(matrix, factory);
+                    if (after != null) {
+                        /*
+                         * Update the middle matrix with everything that we could not put in `after`.
+                         * Usually the matrix is square before the multiplication. But if it was not the case,
+                         * the new matrix will have the same number of columns (source coordinates) but a new
+                         * number of rows (target coordinates). The result should be a square matrix.
+                         */
+                        final Matrix remaining = Matrices.multiply(MathTransforms.getMatrix(after.inverse()), matrix);
+                        final WraparoundTransform redim = redim(remaining.getNumRow() - 1);
+                        if (redim != null) {
+                            step2 = factory.createConcatenatedTransform(redim, after);
+                            matrix = remaining;
+                        }
+                    }
+                    /*
+                     * Now look at the non-linear transform. If it is another instance of `WraparoundTransform`,
+                     * then we may move the calculation of some coordinates before it.
+                     */
+                    MathTransform step1 = steps.get(0);
+                    if (step1 instanceof WraparoundTransform) {
+                        WraparoundTransform wb = (WraparoundTransform) step1;
+                        final MathTransform before = wb.movable(matrix, factory);
+                        if (before != null) {
+                            final Matrix remaining = Matrices.multiply(matrix, MathTransforms.getMatrix(before.inverse()));
+                            wb = wb.redim(remaining.getNumCol() - 1);
+                            if (wb != null) {
+                                step1 = factory.createConcatenatedTransform(before, wb);
+                                matrix = remaining;
+                            }
+                        }
+                    }
+                    /*
+                     * Done moving the linear operations that we can move.
+                     * Put everything together.
+                     */
+                    return factory.createConcatenatedTransform(
+                           factory.createConcatenatedTransform(step1,
+                           factory.createAffineTransform(matrix)), step2);
+                } catch (NoninvertibleTransformException e) {
+                    // Should not happen. But if it is the case, just abandon the optimization effort.
+                    Logging.recoverableException(Logging.getLogger(Modules.REFERENCING), getClass(), "tryConcatenate", e);
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns a transform based on the given matrix but converting only coordinates in dimensions
+     * that can be processed indifferently before or after this {@code WraparoundTransform}.
+     *
+     * @param  matrix   the matrix to analyze.
+     * @param  factory  the factory given to {@link #tryConcatenate tryConcatenate(…)}.
+     * @return a transform processing only the movable parts, or {@code null} if identity.
+     */
+    private MathTransform movable(Matrix matrix, final MathTransformFactory factory) throws FactoryException {
+        long canMoveAfter = Numerics.bitmask(dimension) - 1;
+        canMoveAfter &= ~Numerics.bitmask(wraparoundDimension);
+        /*
+         * If any matrix row (output coordinate) uses the wraparound dimension as input,
+         * then we can not move that row because the coordinate value may not be the same
+         * after execution of `WraparoundTransform`.
+         */
+        if (matrix.getNumCol() - 1 > wraparoundDimension) {
+            for (int j = matrix.getNumRow(); --j >= 0;) {
+                if (matrix.getElement(j, wraparoundDimension) != 0) {
+                    canMoveAfter &= ~Numerics.bitmask(j);
+                }
+            }
+        }
+        if (canMoveAfter != 0) {
+            /*
+             * Create a matrix which will convert coordinates in all dimensions
+             * that we can process before or after this `WraparoundTransform`.
+             * We start with a copy and set to identity the rows that we can not move.
+             */
+            matrix = Matrices.copy(matrix);
+            for (int j = matrix.getNumRow() - 1; --j >=0;) {
+                if ((canMoveAfter & Numerics.bitmask(j)) == 0) {
+                    for (int i=matrix.getNumCol(); --i >= 0;) {
+                        matrix.setElement(j, i, (i == j) ? 1 : 0);
+                    }
+                }
+            }
+        }
+        if (!matrix.isIdentity()) {
+            return factory.createAffineTransform(matrix);
+        }
+        return null;
+    }
+
+    /**
+     * Formats this transform as a pseudo-WKT element.
+     *
+     * @param  formatter  the formatter to use.
+     * @return the WKT element name, which is {@code "Wraparound_MT"}.
+     */
+    @Override
+    protected String formatTo(final Formatter formatter) {
+        formatter.append(wraparoundDimension);
+        formatter.setInvalidWKT(WraparoundTransform.class, null);
+        return "Wraparound_MT";
+    }
+
+    /**
+     * Compares this transform with the given object for equality.
+     *
+     * @param  object  the object to compare with this transform.
+     * @param  mode    ignored, can be {@code null}.
+     */
+    @Override
+    public boolean equals(final Object object, final ComparisonMode mode) {
+        if (object instanceof WraparoundTransform) {
+            final WraparoundTransform other = (WraparoundTransform) object;
+            return other.dimension == dimension && other.wraparoundDimension == wraparoundDimension;
+        }
+        return false;
+    }
+
+    /**
+     * Computes a hash code value for this transform.
+     */
+    @Override
+    protected int computeHashCode() {
+        return dimension * 31 + wraparoundDimension;
+    }
+}
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/MatrixSIS.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/MatrixSIS.java
index 42542bd..5500dda 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/MatrixSIS.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/MatrixSIS.java
@@ -548,7 +548,7 @@ public abstract class MatrixSIS implements Matrix, LenientComparable, Cloneable,
      * <h4>Relationship with coordinate operations</h4>
      * In the context of coordinate operations, {@code Matrix.multiply(other)} is equivalent to
      * <code>{@linkplain AffineTransform#concatenate AffineTransform.concatenate}(other)</code>:
-     * first transforms by the supplied transform and then transform the result by the original transform.
+     * first transforms by the {@code other} transform and then transform the result by {@code this} transform.
      *
      * @param  matrix  the matrix to multiply to this matrix.
      * @return the result of {@code this} × {@code matrix}.
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/ConcatenatedTransform.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/ConcatenatedTransform.java
index 801919d..bc6c9d2 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/ConcatenatedTransform.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/ConcatenatedTransform.java
@@ -903,8 +903,8 @@ class ConcatenatedTransform extends AbstractMathTransform implements Serializabl
             }
         }
         /*
-         * Do not invoke super.tryConcatenate(applyOtherFirst, other, factory); the test of whether 'this'
-         * is the inverse of 'other' has been done indirectly by the calls to 'createOptimized'.
+         * Do not invoke super.tryConcatenate(applyOtherFirst, other, factory); the test of whether `this`
+         * is the inverse of `other` has been done indirectly by the calls to `createOptimized(…)`.
          */
         return null;
     }
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java
index 356de2f..425051d 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java
@@ -571,8 +571,9 @@ public final class MathTransforms extends Static {
      *
      * <ul>
      *   <li>If {@code transform} is {@code null}, returns an empty list.</li>
-     *   <li>Otherwise if {@code transform} is the result of a call to a {@code concatenate(…)} method,
-     *       returns all components. All nested concatenated transforms (if any) will be flattened.</li>
+     *   <li>Otherwise if {@code transform} is the result of calls to {@code concatenate(…)} methods, returns
+     *       all steps making the transformation chain. Nested concatenated transforms (if any) are flattened.
+     *       Note that some steps may have have been merged together, resulting in a shorter list.</li>
      *   <li>Otherwise returns the given transform in a list of size 1.</li>
      * </ul>
      *
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/internal/referencing/WraparoundTransformTest.java b/core/sis-referencing/src/test/java/org/apache/sis/internal/referencing/WraparoundTransformTest.java
new file mode 100644
index 0000000..492d2a5
--- /dev/null
+++ b/core/sis-referencing/src/test/java/org/apache/sis/internal/referencing/WraparoundTransformTest.java
@@ -0,0 +1,135 @@
+/*
+ * 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.internal.referencing;
+
+import java.util.List;
+import java.util.Collections;
+import org.opengis.util.FactoryException;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransformFactory;
+import org.apache.sis.internal.system.DefaultFactories;
+import org.apache.sis.referencing.crs.HardCodedCRS;
+import org.apache.sis.referencing.cs.AxesConvention;
+import org.apache.sis.referencing.operation.AbstractCoordinateOperation;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.referencing.operation.matrix.Matrix3;
+import org.apache.sis.referencing.operation.matrix.Matrix4;
+import org.apache.sis.referencing.operation.matrix.NoninvertibleMatrixException;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.opengis.test.Assert.*;
+
+
+/**
+ * Tests {@link WraparoundTransform}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public final strictfp class WraparoundTransformTest extends TestCase {
+    /**
+     * Tests wraparound on one axis.
+     *
+     * @throws FactoryException if the transform can not be created.
+     * @throws NoninvertibleMatrixException if the expected matrix can not be inverted.
+     */
+    @Test
+    public void testOneAxis() throws FactoryException, NoninvertibleMatrixException {
+        final AbstractCoordinateOperation op = new AbstractCoordinateOperation(
+                Collections.singletonMap(AbstractCoordinateOperation.NAME_KEY, "Wrapper"),
+                HardCodedCRS.WGS84_φλ.forConvention(AxesConvention.POSITIVE_RANGE),
+                HardCodedCRS.WGS84_φλ, null, MathTransforms.scale(3, 5));
+        /*
+         * Transform should be  [scale & normalization]  →  [wraparound]  →  [denormalization].
+         * The wraparound is applied on target coordinates, which is why it appears after [scale].
+         * Wrararound is often (but not always) unnecessary on source coordinates if the operation
+         * uses trigonometric functions.
+         */
+        final MathTransform wt = WraparoundTransform.create(DefaultFactories.forClass(MathTransformFactory.class), op);
+        final List<MathTransform> steps = MathTransforms.getSteps(wt);
+        assertEquals(3, steps.size());
+        assertEquals(1, ((WraparoundTransform) steps.get(1)).wraparoundDimension);
+        /*
+         * Wraparound outputs are in [0 … 1) range (0 inclusive and 1 exclusive), so we expect a
+         * multiplication by the span of each axis for getting the final range.
+         */
+        assertMatrixEquals("denormalize", new Matrix3(
+                1,   0,    0,                           // Latitude (no wrap around)
+                0, 360, -180,                           // Longitude in [-180 … 180) range.
+                0,   0,    1),
+                MathTransforms.getMatrix(steps.get(2)), STRICT);
+        /*
+         * The normalization is the inverse of above matrix (when source and target axes have the same span).
+         * But we expect the normalization matrix to be concatenated with the (3, 2, 5) scale operation.
+         */
+        assertMatrixEquals("normalize", new Matrix3(
+                3,      0,            0,                // 3 is a factor in MathTransforms.scale(…).
+                0, 5./360,  -(-180./360),               // 5 is (idem).
+                0,      0,            1),
+                MathTransforms.getMatrix(steps.get(0)), 1E-15);
+    }
+
+    /**
+     * Tests wraparound on two axes. We expects two instances of {@link WraparoundTransform} without linear
+     * transform between them. The absence of separation between the two {@link WraparoundTransform}s is an
+     * indirect test of {@link WraparoundTransform#tryConcatenate(boolean, MathTransform, MathTransformFactory)}.
+     *
+     * @throws FactoryException if the transform can not be created.
+     * @throws NoninvertibleMatrixException if the expected matrix can not be inverted.
+     */
+    @Test
+    public void testTwoAxes() throws FactoryException, NoninvertibleMatrixException {
+        final AbstractCoordinateOperation op = new AbstractCoordinateOperation(
+                Collections.singletonMap(AbstractCoordinateOperation.NAME_KEY, "Wrapper"),
+                HardCodedCRS.WGS84_3D_TIME.forConvention(AxesConvention.POSITIVE_RANGE),
+                HardCodedCRS.WGS84_3D_TIME_CYCLIC, null, MathTransforms.scale(3, 2, 5));
+        /*
+         * Transform should be  [scale & normalization]  →  [wraparound 1]  →  [wraparound 2]  →  [denormalization].
+         * At first an affine transform existed between the two [wraparound] operations, but that affine transform
+         * should have been moved by `WraparoundTransform.tryConcatenate(…)` in order to combine them with initial
+         * [normalization} and final {denormalization].
+         */
+        final MathTransform wt = WraparoundTransform.create(DefaultFactories.forClass(MathTransformFactory.class), op);
+        final List<MathTransform> steps = MathTransforms.getSteps(wt);
+        assertEquals(4, steps.size());
+        assertEquals(0, ((WraparoundTransform) steps.get(1)).wraparoundDimension);
+        assertEquals(2, ((WraparoundTransform) steps.get(2)).wraparoundDimension);
+        /*
+         * Wraparound outputs are in [0 … 1) range (0 inclusive and 1 exclusive), so we expect a
+         * multiplication by the span of each axis for getting the final range.
+         */
+        assertMatrixEquals("denormalize", new Matrix4(
+                360,   0,   0, -180,                        // Longitude in [-180 … 180) range.
+                  0,   1,   0,    0,                        // Latitude (no wrap around)
+                  0,   0, 365,    1,                        // Day of year in [1 … 366) range.
+                  0,   0,   0,    1),
+                MathTransforms.getMatrix(steps.get(3)), STRICT);
+        /*
+         * The normalization is the inverse of above matrix (when source and target axes have the same span).
+         * But we expect the normalization matrix to be concatenated with the (3, 2, 5) scale operation.
+         */
+        assertMatrixEquals("normalize", new Matrix4(
+                3./360,  0,       0,  -(-180./360),         // 3 is a factor in MathTransforms.scale(…).
+                    0,   2,       0,            0,          // 2 is (idem).
+                    0,   0,  5./365,     -(1./365),         // 5 is (idem).
+                    0,   0,       0,            1),
+                MathTransforms.getMatrix(steps.get(0)), 1E-15);
+    }
+}
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/crs/HardCodedCRS.java b/core/sis-referencing/src/test/java/org/apache/sis/referencing/crs/HardCodedCRS.java
index 65bc107..f37bf16 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/referencing/crs/HardCodedCRS.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/crs/HardCodedCRS.java
@@ -83,11 +83,36 @@ public final strictfp class HardCodedCRS {
      * with the 3 first dimensions specified by {@link #WGS84_3D} and the fourth dimension specified
      * by {@link #TIME}.
      *
-     * @see #TIME_WGS84
+     * @see #WGS84_4D_TIME_FIRST
      */
     public static final DefaultCompoundCRS WGS84_4D;
 
     /**
+     * A (λ,φ,t) CRS where <var>t</var> is the {@link #TIME}.
+     *
+     * @since 1.1
+     */
+    public static final DefaultCompoundCRS WGS84_3D_TIME;
+
+    /**
+     * A (λ,φ,t) CRS where <var>t</var> is the {@link #DAY_OF_YEAR}.
+     * This CRS has two wraparound axes: <var>λ</var> and <var>t</var>.
+     *
+     * @since 1.1
+     */
+    public static final DefaultCompoundCRS WGS84_3D_TIME_CYCLIC;
+
+    /**
+     * A four-dimensional geographic coordinate reference system with time as the first axis.
+     * This CRS uses (<var>time</var>, <var>longitude</var>, <var>latitude</var>, <var>height</var>)
+     * with the first dimension specified by {@link #TIME} and the 3 last dimensions specified by {@link #WGS84_3D}.
+     * Such axis order is unusual but we use it as a way to verify that SIS is robust to arbitrary axis order.
+     *
+     * @since 1.1
+     */
+    public static final DefaultCompoundCRS WGS84_4D_TIME_FIRST;
+
+    /**
      * A two-dimensional geographic coordinate reference system using the Paris prime meridian.
      * This CRS uses (<var>longitude</var>, <var>latitude</var>) coordinates with longitude values
      * increasing towards the East and latitude values increasing towards the North.
@@ -243,17 +268,23 @@ public final strictfp class HardCodedCRS {
     public static final DefaultTemporalCRS TIME = new DefaultTemporalCRS(
             getProperties(HardCodedCS.DAYS), HardCodedDatum.MODIFIED_JULIAN, HardCodedCS.DAYS);
 
+    static {
+        WGS84_4D = new DefaultCompoundCRS(properties("WGS 84 (3D) + time", null), WGS84_3D, TIME);
+        WGS84_4D_TIME_FIRST = new DefaultCompoundCRS(properties("time + WGS 84 (3D)", null), TIME, WGS84_3D);
+    }
+
     /**
-     * A four-dimensional geographic coordinate reference system with time as the first axis.
-     * This CRS uses (<var>time</var>, <var>longitude</var>, <var>latitude</var>, <var>height</var>)
-     * with the first dimension specified by {@link #TIME} and the 3 last dimensions specified by {@link #WGS84_3D}.
-     * Such axis order is unusual but we use it as a way to verify that SIS is robust to arbitrary axis order.
+     * A parametric CRS for day of year, without any particular year.
+     * The axis is cyclic: after day 365 we restart at day 1.
+     *
+     * @since 1.1
      */
-    public static final DefaultCompoundCRS TIME_WGS84 = new DefaultCompoundCRS(
-            properties("time + WGS 84 (3D)", null), TIME, WGS84_3D);;
+    public static final DefaultParametricCRS DAY_OF_YEAR = new DefaultParametricCRS(
+            getProperties(HardCodedCS.DAY_OF_YEAR), HardCodedDatum.DAY_OF_YEAR, HardCodedCS.DAY_OF_YEAR);
 
     static {
-        WGS84_4D = new DefaultCompoundCRS(properties("WGS 84 (3D) + time", null), WGS84_3D, TIME);
+        WGS84_3D_TIME = new DefaultCompoundCRS(properties("WGS 84 + time", null), WGS84, TIME);
+        WGS84_3D_TIME_CYCLIC = new DefaultCompoundCRS(properties("WGS 84 + day of year", null), WGS84, DAY_OF_YEAR);
     }
 
     /**
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/crs/HardCodedCRSTest.java b/core/sis-referencing/src/test/java/org/apache/sis/referencing/crs/HardCodedCRSTest.java
index 609ac38..48440de 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/referencing/crs/HardCodedCRSTest.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/crs/HardCodedCRSTest.java
@@ -66,13 +66,13 @@ public final strictfp class HardCodedCRSTest extends TestCase {
         assertEquals("TIME",         1, TIME        .getCoordinateSystem().getDimension());
         assertEquals("DEPTH",        1, DEPTH       .getCoordinateSystem().getDimension());
         assertEquals("WGS84",        2, WGS84       .getCoordinateSystem().getDimension());
-        assertEquals("WGS84_φλ",     2, WGS84_φλ   .getCoordinateSystem().getDimension());
+        assertEquals("WGS84_φλ",     2, WGS84_φλ    .getCoordinateSystem().getDimension());
         assertEquals("WGS84_3D",     3, WGS84_3D    .getCoordinateSystem().getDimension());
         assertEquals("CARTESIAN_2D", 2, CARTESIAN_2D.getCoordinateSystem().getDimension());
         assertEquals("CARTESIAN_3D", 3, CARTESIAN_3D.getCoordinateSystem().getDimension());
         assertEquals("GEOCENTRIC",   3, GEOCENTRIC  .getCoordinateSystem().getDimension());
         assertEquals("SPHERICAL",    3, SPHERICAL   .getCoordinateSystem().getDimension());
-        assertEquals("GEOID_4D",     4, GEOID_4D     .getCoordinateSystem().getDimension());
+        assertEquals("GEOID_4D",     4, GEOID_4D    .getCoordinateSystem().getDimension());
     }
 
     /**
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/cs/HardCodedAxes.java b/core/sis-referencing/src/test/java/org/apache/sis/referencing/cs/HardCodedAxes.java
index ac65e53..a6f2754 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/referencing/cs/HardCodedAxes.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/cs/HardCodedAxes.java
@@ -32,7 +32,7 @@ import org.apache.sis.measure.Units;
  * Consequently EPSG codes are not provided.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
+ * @version 1.1
  * @since   0.4
  * @module
  */
@@ -432,6 +432,13 @@ public final strictfp class HardCodedAxes {
             AxisDirection.FUTURE, Units.DAY, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, null);
 
     /**
+     * Axis for time values in a {@linkplain org.apache.sis.referencing.cs.DefaultParametricCS parametric CS}.
+     * The axis is cyclic: after day 365 we restart at day 1.
+     */
+    public static final DefaultCoordinateSystemAxis DAY_OF_YEAR = create("Day of year", "t",
+            AxisDirection.FUTURE, Units.DAY, 1, 366, RangeMeaning.WRAPAROUND);
+
+    /**
      * Axis for column indices in a {@linkplain org.apache.sis.coverage.grid.GridCoverage grid coverage}.
      * Increasing values go toward {@linkplain AxisDirection#COLUMN_POSITIVE positive column number}.
      * The abbreviation is lower case <cite>"i"</cite>.
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/cs/HardCodedCS.java b/core/sis-referencing/src/test/java/org/apache/sis/referencing/cs/HardCodedCS.java
index efb6957..c5ecf4c 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/referencing/cs/HardCodedCS.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/cs/HardCodedCS.java
@@ -29,7 +29,7 @@ import static org.apache.sis.referencing.IdentifiedObjects.getProperties;
  * Collection of coordinate systems for testing purpose.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.1
  * @since   0.4
  * @module
  */
@@ -256,6 +256,13 @@ public final strictfp class HardCodedCS {
             getProperties(HardCodedAxes.DEPTH), HardCodedAxes.DEPTH);
 
     /**
+     * A parametric CRS for day of year, without any particular year.
+     * The axis is cyclic: after day 365 we restart at day 1.
+     */
+    public static final DefaultParametricCS DAY_OF_YEAR = new DefaultParametricCS(
+            getProperties(HardCodedAxes.DAY_OF_YEAR), HardCodedAxes.DAY_OF_YEAR);
+
+    /**
      * A one-dimensional temporal CS with
      * <var>{@linkplain HardCodedAxes#TIME time}</var>,
      * axis in {@linkplain Units#DAY day} units.
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/datum/HardCodedDatum.java b/core/sis-referencing/src/test/java/org/apache/sis/referencing/datum/HardCodedDatum.java
index 0dee2dd..5e19b08 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/referencing/datum/HardCodedDatum.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/datum/HardCodedDatum.java
@@ -34,7 +34,7 @@ import static org.apache.sis.internal.util.StandardDateFormat.MILLISECONDS_PER_D
  * Collection of datum for testing purpose.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.1
  * @since   0.4
  * @module
  */
@@ -153,6 +153,12 @@ public final strictfp class HardCodedDatum {
             new Date(-40587L * MILLISECONDS_PER_DAY));
 
     /**
+     * A parametric datum for day of year, without any particular year.
+     */
+    public static final DefaultParametricDatum DAY_OF_YEAR = new DefaultParametricDatum(
+            properties("Day of year", null, null));
+
+    /**
      * Image with {@link PixelInCell#CELL_CENTER}.
      */
     public static final DefaultImageDatum IMAGE = new DefaultImageDatum(
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java b/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
index 438c672..981f88e 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
@@ -148,6 +148,7 @@ import org.junit.BeforeClass;
     org.apache.sis.referencing.operation.DefaultFormulaTest.class,
     org.apache.sis.referencing.operation.DefaultOperationMethodTest.class,
     org.apache.sis.referencing.operation.transform.OperationMethodSetTest.class,
+    org.apache.sis.internal.referencing.WraparoundTransformTest.class,
 
     // Registration of map projections and other math transforms.
     org.apache.sis.internal.referencing.provider.AffineTest.class,