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 2026/01/17 19:28:25 UTC

(sis) branch geoapi-4.0 updated: Add `MissingSourceDimensionsException` telling which axes of the target CRS cannot be mapped to a source CRS. Add `CoordinateOperationContext.getConstantCoordinates()` as a public API in replacement of `CoordinateOperations.CONSTANT_COORDINATES` internal thread-local. Add `CRS.findOperation(…) expecting `CoordinateOperationContext` argument for making possible to use the new API for constant coordinates. Redirect some loggings to user-supplied filter, for future use by data stores.

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


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new c440daa7c2 Add `MissingSourceDimensionsException` telling which axes of the target CRS cannot be mapped to a source CRS. Add `CoordinateOperationContext.getConstantCoordinates()` as a public API in replacement of `CoordinateOperations.CONSTANT_COORDINATES` internal thread-local. Add `CRS.findOperation(…) expecting `CoordinateOperationContext` argument for making possible to use the new API for constant coordinates. Redirect some loggings to user-supplied filter, for future use by [...]
c440daa7c2 is described below

commit c440daa7c25e588bd4399c7d0e7664ed4e053530
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sat Jan 17 16:02:24 2026 +0100

    Add `MissingSourceDimensionsException` telling which axes of the target CRS cannot be mapped to a source CRS.
    Add `CoordinateOperationContext.getConstantCoordinates()` as a public API in replacement of `CoordinateOperations.CONSTANT_COORDINATES` internal thread-local.
    Add `CRS.findOperation(…) expecting `CoordinateOperationContext` argument for making possible to use the new API for constant coordinates.
    Redirect some loggings to user-supplied filter, for future use by data stores.
---
 .../coverage/grid/CoordinateOperationFinder.java   | 136 +++------
 .../org/apache/sis/coverage/grid/GridGeometry.java |  22 +-
 .../apache/sis/coverage/grid/GridGeometryTest.java |  13 +-
 .../main/org/apache/sis/portrayal/Canvas.java      |   2 +-
 .../org/apache/sis/portrayal/CanvasContext.java    |   6 +-
 .../main/org/apache/sis/geometry/Envelopes.java    |  55 ++--
 .../main/org/apache/sis/referencing/CRS.java       | 200 ++++++++++---
 .../sis/referencing/MultiRegisterOperations.java   |   1 +
 .../sis/referencing/cs/CoordinateSystems.java      |   4 +-
 .../apache/sis/referencing/internal/Resources.java |   2 +-
 .../sis/referencing/internal/Resources.properties  |   2 +-
 .../referencing/internal/Resources_fr.properties   |   2 +-
 .../internal/shared/CoordinateOperations.java      |   8 -
 .../operation/CoordinateOperationContext.java      | 257 ++++++++++++----
 .../operation/CoordinateOperationFinder.java       |  66 +++--
 .../operation/CoordinateOperationRegistry.java     |  92 +++---
 .../operation/InverseOperationMethod.java          |   6 +-
 .../operation/MismatchedDatumException.java        |   8 +-
 .../MissingSourceDimensionsException.java          | 121 ++++++++
 .../referencing/operation/SubOperationInfo.java    | 323 ++++++++++++---------
 .../operation/TransformedCoordinateSet.java        |   5 +-
 .../sis/referencing/operation/matrix/Matrices.java |  19 +-
 .../matrix/NoninvertibleMatrixException.java       |   8 +-
 .../matrix/UnderdeterminedMatrixException.java     |  81 ++++++
 .../referencing/operation/matrix/MatricesTest.java |   5 +-
 .../sis/storage/geotiff/MultiResolutionImage.java  |   8 +-
 .../main/org/apache/sis/util/Localized.java        |   2 +-
 .../main/org/apache/sis/util/logging/Logging.java  |   2 +-
 28 files changed, 982 insertions(+), 474 deletions(-)

diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/CoordinateOperationFinder.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/CoordinateOperationFinder.java
index d9c7c484b5..5d8d8aacc3 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/CoordinateOperationFinder.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/CoordinateOperationFinder.java
@@ -17,10 +17,8 @@
 package org.apache.sis.coverage.grid;
 
 import java.util.Arrays;
-import java.util.function.Supplier;
 import javax.measure.Quantity;
 import javax.measure.quantity.Length;
-import org.opengis.geometry.Envelope;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.util.FactoryException;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
@@ -30,17 +28,15 @@ import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.collection.BackingStoreException;
 import org.apache.sis.referencing.CRS;
-import org.apache.sis.referencing.internal.shared.CoordinateOperations;
 import org.apache.sis.referencing.internal.shared.WraparoundApplicator;
-import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox;
+import org.apache.sis.referencing.operation.CoordinateOperationContext;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.referencing.operation.transform.WraparoundTransform;
 import org.apache.sis.geometry.AbstractDirectPosition;
-import org.apache.sis.geometry.Envelopes;
+import org.apache.sis.geometry.GeneralDirectPosition;
 import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.measure.Quantities;
 import org.apache.sis.measure.Units;
-import org.apache.sis.util.logging.Logging;
 import org.apache.sis.util.internal.shared.Numerics;
 
 
@@ -73,7 +69,7 @@ import org.apache.sis.util.internal.shared.Numerics;
  * @author  Alexis Manin (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
  */
-final class CoordinateOperationFinder implements Supplier<double[]> {
+final class CoordinateOperationFinder extends CoordinateOperationContext {
     /**
      * Whether the operation is between cell centers or cell corners.
      *
@@ -86,15 +82,6 @@ final class CoordinateOperationFinder implements Supplier<double[]> {
      */
     private final GridGeometry source, target;
 
-    /**
-     * The target coordinate values, computed only if needed. This is computed by {@link #get()}, which is
-     * itself invoked indirectly by {@link org.apache.sis.referencing.operation.CoordinateOperationFinder}.
-     * The value is cached in case {@code get()} is invoked many times during the same finder execution.
-     *
-     * @see #get()
-     */
-    private double[] coordinates;
-
     /**
      * The coordinate operation from source to target CRS, computed when first needed.
      * May be {@code null} if there is no information about the source and target CRS.
@@ -225,7 +212,7 @@ final class CoordinateOperationFinder implements Supplier<double[]> {
     private boolean isWraparoundDisabled;
 
     /**
-     * Whether the {@link #get()} method should accept dimensions that are not slices in a multi-dimensional cube.
+     * Whether this {@link DirectPosition} should accept dimensions that are not slices in a multi-dimensional cube.
      * If 0 (the default), the dimensions are required to be slices. If non-zero, the requirement is relaxed, with
      * -1 meaning to use the low grid coordinate and +1 meaning to use the high grid coordinate.
      *
@@ -270,25 +257,25 @@ final class CoordinateOperationFinder implements Supplier<double[]> {
      */
     final void setAnchor(final PixelInCell newValue) {
         /*
-         * If `coordinates` is non-null, it means that `CoordinateOperationContext.getConstantCoordinates()`
-         * has been invoked during previous `gridToCRS()` execution, which implies that `changeOfCRS` depends
-         * on the `anchor` value. In such case we will need to recompute all fields that depend on `changeOfCRS`.
+         * If `hasUsedConstantValues()` is true, it means that `CoordinateOperationContext.getConstantCoordinates()`
+         * has been invoked during previous `gridToCRS()` execution, which implies that `changeOfCRS` depends on the
+         * `anchor` value. In such case, we will need to recompute all fields that depend on `changeOfCRS`.
          */
         anchor = newValue;
         gridToCRS = null;
         crsToGrid = null;
         if (hasUsedConstantValues()) {
-            coordinates        = null;
-            changeOfCRS        = null;
-            forwardChangeOfCRS = null;
-            inverseChangeOfCRS = null;
-            knowChangeOfCRS    = false;
-            // Do not clear `isWraparoundNeeded`; its value is still valid.
+            super.setConstantCoordinates(null);
+            changeOfCRS         = null;
+            forwardChangeOfCRS  = null;
+            inverseChangeOfCRS  = null;
+            knowChangeOfCRS     = false;
+            // Do not clear `isWraparoundNeeded` as its value is still valid.
         }
     }
 
     /**
-     * Sets whether the {@link #get()} method should accept dimensions that are not slices in a multi-dimensional cube.
+     * Sets whether this {@link DirectPosition} should accept dimensions that are not slices in a multi-dimensional cube.
      *
      * @param  mode  0 to require slices, -1 for using low grid coordinates, or +1 for high grid coordinates.
      */
@@ -338,22 +325,17 @@ final class CoordinateOperationFinder implements Supplier<double[]> {
      * Returns the coordinate operation from source CRS to target CRS. It may be the identity operation.
      * We try to take envelopes in account because the operation choice may depend on the geographic area.
      *
-     * @todo Specify also the desired resolution, computed from target grid geometry. It will require
-     *       more direct use of {@link org.apache.sis.referencing.operation.CoordinateOperationContext}.
-     *       As a side effect, we could remove {@link CoordinateOperations#CONSTANT_COORDINATES} hack.
+     * @todo Specify also the desired resolution, computed from target grid geometry.
      *
      * @return operation from source CRS to target CRS, or {@code null} if a CRS is not specified.
      * @throws FactoryException if no operation can be found between the source and target CRS.
      * @throws TransformException if some coordinates cannot be transformed to the specified target.
+     *
+     * @see #getConstantCoordinates()
      */
     private CoordinateOperation changeOfCRS() throws FactoryException, TransformException {
         if (!knowChangeOfCRS) {
-            try {
-                CoordinateOperations.CONSTANT_COORDINATES.set(this);
-                changeOfCRS = changeOfCRS(source, target);
-            } finally {
-                CoordinateOperations.CONSTANT_COORDINATES.remove();
-            }
+            changeOfCRS = changeOfCRS(source, target, this);
             knowChangeOfCRS = true;
         }
         return changeOfCRS;
@@ -362,39 +344,27 @@ final class CoordinateOperationFinder implements Supplier<double[]> {
     /**
      * Computes the change of <abbr>CRS</abbr> between the given pair of grid geometries.
      *
-     * @param  source  the grid geometry which is the source of the coordinate operation to find.
-     * @param  target  the grid geometry which is the target of the coordinate operation to find.
+     * @param  source   the grid geometry which is the source of the coordinate operation to find.
+     * @param  target   the grid geometry which is the target of the coordinate operation to find.
+     * @param  context  contains coordinate values to use as constant if a source CRS is missing.
      * @return coordinate operation from source to target CRS, or {@code null} if none.
      * @throws FactoryException if no operation can be found between the source and target CRS.
      * @throws TransformException if some coordinates cannot be transformed to the specified target.
      */
-    private static CoordinateOperation changeOfCRS(final GridGeometry source, final GridGeometry target)
+    private static CoordinateOperation changeOfCRS(final GridGeometry source, final GridGeometry target,
+                                                   final CoordinateOperationContext context)
             throws FactoryException, TransformException
     {
         CoordinateOperation changeOfCRS = null;
-        final Envelope sourceEnvelope = source.envelope;
-        final Envelope targetEnvelope = target.envelope;
-        try {
-            if (sourceEnvelope != null && targetEnvelope != null) {
-                changeOfCRS = Envelopes.findOperation(sourceEnvelope, targetEnvelope);
-            }
-            if (changeOfCRS == null && source.isDefined(GridGeometry.CRS) && target.isDefined(GridGeometry.CRS)) {
-                final CoordinateReferenceSystem sourceCRS = source.getCoordinateReferenceSystem();
-                /*
-                 * Unconditionally create operation even if CRS are the same. A non-null operation trig
-                 * the check for wraparound axes, which is necessary even if the transform is identity.
-                 */
-                DefaultGeographicBoundingBox areaOfInterest = null;
-                if (sourceEnvelope != null || targetEnvelope != null) try {
-                    areaOfInterest = new DefaultGeographicBoundingBox();
-                    areaOfInterest.setBounds(targetEnvelope != null ? targetEnvelope : sourceEnvelope);
-                } catch (TransformException e) {
-                    areaOfInterest = null;
-                    recoverableException("changeOfCRS", e);
-                }
-                changeOfCRS = CRS.findOperation(sourceCRS, target.getCoordinateReferenceSystem(), areaOfInterest);
-            }
-        } catch (BackingStoreException e) {                         // May be thrown by getConstantCoordinates().
+        if (source.isDefined(GridGeometry.CRS) && target.isDefined(GridGeometry.CRS)) try {
+            /*
+             * Unconditionally create the operation even if the CRS are equivalent. A non-null operation
+             * trigs the check for wraparound axes, which is necessary even if the transform is identity.
+             */
+            context.addAreaOfInterest(source.envelope);
+            context.addAreaOfInterest(target.envelope);
+            changeOfCRS = CRS.findOperation(source.getCoordinateMetadata(), target.getCoordinateMetadata(), context);
+        } catch (BackingStoreException e) {
             throw e.unwrapOrRethrow(TransformException.class);
         }
         return changeOfCRS;
@@ -482,7 +452,7 @@ apply:          if (forwardChangeOfCRS == null) {
     /**
      * Computes the transform from “geospatial coordinates of the target” to “grid coordinates of the source”.
      * This is similar to invoking {@link MathTransform#inverse()} on {@link #gridToCRS()}, except in the way
-     * wraparounds are handled.
+     * that wraparounds are handled.
      *
      * @return operation from target geospatial coordinates to source grid indices.
      * @throws FactoryException if no operation can be found between the source and target CRS.
@@ -498,7 +468,7 @@ apply:          if (forwardChangeOfCRS == null) {
                     inverseChangeOfCRS = changeOfCRS.getMathTransform().inverse();
                 }
             } else {
-                final CoordinateOperation inverse = changeOfCRS(target, source);
+                final CoordinateOperation inverse = changeOfCRS(target, source, new CoordinateOperationContext());
                 if (inverse != null) {
                     sourceCRS = inverse.getTargetCRS();
                     inverseChangeOfCRS = inverse.getMathTransform();
@@ -692,7 +662,7 @@ apply:          if (forwardChangeOfCRS == null) {
      * @param  grid         the source or target grid providing the point of interest.
      * @param  changeOfCRS  transform from source CRS to target CRS, or {@code null} if none.
      */
-    private static DirectPosition median(final GridGeometry grid, final MathTransform changeOfCRS) throws TransformException {
+    private static DirectPosition median(final GridGeometry grid, final MathTransform changeOfCRS) {
         if (!grid.isDefined(GridGeometry.EXTENT | GridGeometry.GRID_TO_CRS)) {
             return null;
         }
@@ -739,21 +709,17 @@ apply:          if (forwardChangeOfCRS == null) {
      * For example, this is invoked during the conversion from (<var>x</var>, <var>y</var>)
      * coordinates to (<var>x</var>, <var>y</var>, <var>t</var>). If constant values can
      * be given to the missing dimensions, then those values are returned.
-     * Otherwise this method returns {@code null}.
-     *
-     * <p>The returned array has a length equals to the number of dimensions in the target <abbr>CRS</abbr>.
-     * Only coordinates in dimensions without source (<var>t</var> in the above example) will be used.
-     * All other coordinate values will be ignored.</p>
+     * Otherwise, this method returns an empty array.
      *
-     * @see org.apache.sis.referencing.operation.CoordinateOperationContext#getConstantCoordinates()
+     * @see #changeOfCRS()
+     * @see CoordinateOperationContext#getConstantCoordinates()
      */
     @Override
-    @SuppressWarnings("ReturnOfCollectionOrArrayField")
-    public double[] get() {
+    public DirectPosition getConstantCoordinates() {
+        DirectPosition coordinates = super.getConstantCoordinates();
         if (coordinates == null && target.isDefined(GridGeometry.EXTENT | GridGeometry.GRID_TO_CRS)) {
             final MathTransform tr = target.getGridToCRS(anchor);
-            coordinates = new double[tr.getTargetDimensions()];
-            double[] gc = new double[tr.getSourceDimensions()];
+            final double[] gc = new double[tr.getSourceDimensions()];
             Arrays.fill(gc, Double.NaN);
             final GridExtent extent = target.getExtent();
             for (int i=0; i<gc.length; i++) {
@@ -776,20 +742,22 @@ apply:          if (forwardChangeOfCRS == null) {
              * will have non-NaN coordinate values only if they do not depend on any dimension
              * other than the one having a grid size of 1.
              */
+            final var gp = new GeneralDirectPosition(tr.getTargetDimensions());
             try {
-                tr.transform(gc, 0, coordinates, 0, 1);
+                tr.transform(gc, 0, gp.coordinates, 0, 1);
             } catch (TransformException e) {
-                throw new BackingStoreException(e);
+                throw new IllegalGridGeometryException(e, "gridToCRS");
             }
+            super.setConstantCoordinates(coordinates = gp);
         }
         return coordinates;
     }
 
     /**
-     * Returns {@code true} if {@link #get()} has been invoked and computed coordinates.
+     * Returns {@code true} if {@link #getConstantCoordinates()} has been invoked and computed coordinates.
      */
     final boolean hasUsedConstantValues() {
-        return coordinates != null;
+        return super.getConstantCoordinates() != null;
     }
 
     /**
@@ -816,14 +784,4 @@ apply:          if (forwardChangeOfCRS == null) {
             processor.setPositionalAccuracyHints(hints);                        // Null elements will be ignored.
         }
     }
-
-    /**
-     * Invoked when an ignorable exception occurred.
-     *
-     * @param  caller  the method where the exception occurred.
-     * @param  e       the ignorable exception.
-     */
-    private static void recoverableException(final String caller, final Exception e) {
-        Logging.recoverableException(GridExtent.LOGGER, CoordinateOperationFinder.class, caller, e);
-    }
 }
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridGeometry.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridGeometry.java
index 3f8e687a46..a4d4d39e06 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridGeometry.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridGeometry.java
@@ -30,6 +30,7 @@ import org.opengis.util.FactoryException;
 import org.opengis.metadata.Identifier;
 import org.opengis.metadata.extent.GeographicBoundingBox;
 import org.opengis.geometry.Envelope;
+import org.opengis.coordinate.CoordinateMetadata;
 import org.opengis.referencing.operation.Matrix;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
@@ -47,7 +48,7 @@ import org.apache.sis.geometry.Envelopes;
 import org.apache.sis.geometry.AbstractEnvelope;
 import org.apache.sis.geometry.GeneralEnvelope;
 import org.apache.sis.geometry.ImmutableEnvelope;
-import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox;
+import org.apache.sis.coordinate.DefaultCoordinateMetadata;
 import org.apache.sis.referencing.IdentifiedObjects;
 import org.apache.sis.referencing.crs.AbstractCRS;
 import org.apache.sis.referencing.operation.matrix.Matrices;
@@ -59,6 +60,7 @@ import org.apache.sis.referencing.internal.shared.ExtendedPrecisionMatrix;
 import org.apache.sis.referencing.internal.shared.DirectPositionView;
 import org.apache.sis.referencing.internal.shared.TemporalAccessor;
 import org.apache.sis.referencing.internal.shared.AxisDirections;
+import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox;
 import org.apache.sis.metadata.internal.shared.ReferencingServices;
 import org.apache.sis.feature.internal.Resources;
 import org.apache.sis.util.LenientComparable;
@@ -984,6 +986,20 @@ public class GridGeometry implements LenientComparable, Serializable {
      * force developers to think about whether they want a gridToCRS transform locating pixel corner or center.
      */
 
+    /**
+     * Returns the "real world" coordinate reference system together with the data epoch if any.
+     * This method is preferable to {@link #getCoordinateReferenceSystem()} when the <abbr>CRS</abbr> may be dynamic.
+     *
+     * @return the coordinate reference system (never {@code null}) together with the data epoch if any.
+     * @throws IncompleteGridGeometryException if this grid geometry has no <abbr>CRS</abbr> —
+     *         i.e. <code>{@linkplain #isDefined isDefined}({@linkplain #CRS})</code> returned {@code false}.
+     *
+     * @since 1.6
+     */
+    public CoordinateMetadata getCoordinateMetadata() {
+        return new DefaultCoordinateMetadata(getCoordinateReferenceSystem(), null);
+    }
+
     /**
      * Returns the coordinate reference system of the given envelope if defined, or {@code null} if none.
      * Contrarily to {@link #getCoordinateReferenceSystem()}, this method does not throw exception.
@@ -994,9 +1010,11 @@ public class GridGeometry implements LenientComparable, Serializable {
 
     /**
      * Returns the "real world" coordinate reference system.
+     * This method assumes that the <abbr>CRS</abbr> is static.
+     * If the <abbr>CRS</abbr> may be dynamic, use {@link #getCoordinateMetadata()} instead.
      *
      * @return the coordinate reference system (never {@code null}).
-     * @throws IncompleteGridGeometryException if this grid geometry has no CRS —
+     * @throws IncompleteGridGeometryException if this grid geometry has no <abbr>CRS</abbr> —
      *         i.e. <code>{@linkplain #isDefined isDefined}({@linkplain #CRS})</code> returned {@code false}.
      */
     public CoordinateReferenceSystem getCoordinateReferenceSystem() {
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 b5958df5c7..1cb5e7bf9b 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
@@ -16,16 +16,18 @@
  */
 package org.apache.sis.coverage.grid;
 
+import java.util.Set;
 import org.opengis.util.FactoryException;
 import org.opengis.geometry.Envelope;
 import org.opengis.metadata.spatial.DimensionNameType;
+import org.opengis.referencing.cs.AxisDirection;
 import org.opengis.referencing.crs.DerivedCRS;
 import org.opengis.referencing.operation.Matrix;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
-import org.opengis.referencing.operation.OperationNotFoundException;
 import org.opengis.referencing.operation.NoninvertibleTransformException;
 import org.apache.sis.geometry.GeneralEnvelope;
+import org.apache.sis.referencing.operation.MissingSourceDimensionsException;
 import org.apache.sis.referencing.operation.matrix.Matrix2;
 import org.apache.sis.referencing.operation.matrix.Matrix3;
 import org.apache.sis.referencing.operation.matrix.Matrix4;
@@ -41,6 +43,7 @@ import static org.junit.jupiter.api.Assertions.*;
 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.test.Assertions.assertSetEquals;
 import static org.apache.sis.test.Assertions.assertMessageContains;
 import static org.apache.sis.referencing.Assertions.assertMatrixEquals;
 import static org.apache.sis.referencing.Assertions.assertEnvelopeEquals;
@@ -547,9 +550,11 @@ public final class GridGeometryTest extends TestCase {
                 new double[] { 1, -3.0, 56}), envelope);
 
         // Cannot map time to gravity-related height.
-        Throwable e = assertThrows(TransformException.class, () -> grid.getEnvelope(HardCodedCRS.GEOID_3D)).getCause();
-        e = assertInstanceOf(OperationNotFoundException.class, e);
-        assertMessageContains(e, "WGS 84 + time", "WGS 84 + height");
+        final var e = assertInstanceOf(
+                MissingSourceDimensionsException.class,
+                assertThrows(TransformException.class, () -> grid.getEnvelope(HardCodedCRS.GEOID_3D)).getCause());
+        assertMessageContains(e, "WGS 84 + time", "MSL height");
+        assertSetEquals(Set.of(AxisDirection.UP), e.getMissingAxes());
 
         // Ellipsoidal height is a special case, with default height of 0.
         envelope = grid.getEnvelope(HardCodedCRS.WGS84_3D);
diff --git a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Canvas.java b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Canvas.java
index 89ea01640d..d0ab1fbd45 100644
--- a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Canvas.java
+++ b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/Canvas.java
@@ -408,7 +408,7 @@ public class Canvas extends Observable implements Localized {
         displayBounds = new GeneralEnvelope(displayCRS);
         displayBounds.setToNaN();
         coordinateOperationFactory = DefaultCoordinateOperationFactory.provider();
-        operationContext = new CanvasContext();
+        operationContext = new CanvasContext(locale);
     }
 
     /**
diff --git a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasContext.java b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasContext.java
index f1af2c773e..d104b6eefd 100644
--- a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasContext.java
+++ b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasContext.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.portrayal;
 
+import java.util.Locale;
 import java.util.Optional;
 import java.util.OptionalDouble;
 import org.opengis.metadata.extent.GeographicBoundingBox;
@@ -87,8 +88,11 @@ final class CanvasContext extends CoordinateOperationContext {
 
     /**
      * Creates a new context.
+     *
+     * @param  locale  the locale for error messages.
      */
-    CanvasContext() {
+    CanvasContext(final Locale locale) {
+        setLocale(locale);
     }
 
     /**
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/geometry/Envelopes.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/geometry/Envelopes.java
index 4901fe52db..85cdcf3162 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/geometry/Envelopes.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/geometry/Envelopes.java
@@ -43,16 +43,16 @@ import org.opengis.referencing.operation.OperationNotFoundException;
 import org.opengis.referencing.operation.NoninvertibleTransformException;
 import org.opengis.util.FactoryException;
 import org.opengis.metadata.extent.GeographicBoundingBox;
-import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox;
-import org.apache.sis.metadata.internal.shared.ReferencingServices;
 import org.apache.sis.parameter.Parameters;
 import org.apache.sis.referencing.CRS;
+import org.apache.sis.referencing.operation.CoordinateOperationContext;
 import org.apache.sis.referencing.operation.AbstractCoordinateOperation;
 import org.apache.sis.referencing.operation.transform.AbstractMathTransform;
 import org.apache.sis.referencing.operation.transform.WraparoundTransform;
 import org.apache.sis.referencing.internal.shared.CoordinateOperations;
 import org.apache.sis.referencing.internal.shared.DirectPositionView;
 import org.apache.sis.referencing.internal.shared.TemporalAccessor;
+import org.apache.sis.coordinate.DefaultCoordinateMetadata;
 import org.apache.sis.system.Loggers;
 import org.apache.sis.util.internal.shared.Numerics;
 import org.apache.sis.util.logging.Logging;
@@ -234,13 +234,13 @@ public final class Envelopes {
      * GeographicBoundingBox)} CRS.findOperation(source.getCoordinateReferenceSystem(), target.getCoordinateReferenceSystem(),
      * <var>areaOfInterest</var>)</code></blockquote>
      *
-     * If at least one envelope is null or has no CRS, then this method returns {@code null}.
+     * If at least one envelope is null or has no <abbr>CRS</abbr>, then this method returns {@code null}.
      *
      * @param  source  the source envelope, or {@code null}.
      * @param  target  the target envelope, or {@code null}.
-     * @return the mathematical operation from {@code source} CRS to {@code target} CRS,
-     *         or {@code null} if at least one argument is null or has no CRS.
-     * @throws OperationNotFoundException if no operation was found between the given pair of CRS.
+     * @return the mathematical operation from the <abbr>CRS</abbr> of the {@code source} envelope to the <abbr>CRS</abbr>
+     *         of the {@code target} envelope, or {@code null} if at least one argument is null or has no <abbr>CRS</abbr>.
+     * @throws OperationNotFoundException if no operation was found between the <abbr>CRS</abbr>s of the given envelopes.
      * @throws FactoryException if the operation cannot be created for another reason.
      *
      * @see CRS#findOperation(CoordinateReferenceSystem, CoordinateReferenceSystem, GeographicBoundingBox)
@@ -249,39 +249,18 @@ public final class Envelopes {
      */
     public static CoordinateOperation findOperation(final Envelope source, final Envelope target) throws FactoryException {
         if (source != null && target != null) {
-            final CoordinateReferenceSystem sourceCRS, targetCRS;
-            if ((sourceCRS = source.getCoordinateReferenceSystem()) != null &&
-                (targetCRS = target.getCoordinateReferenceSystem()) != null)
-            {
-                /*
-                 * Computing the area of interest (AOI) unconditionally would be harmless, but it is useless if the CRS
-                 * are the same since the result will be identity anyway. Conversely we could skip AOI computation more
-                 * often for example with the following condition instead of !=:
-                 *
-                 *     !Utilities.deepEquals(sourceCRS, targetCRS, ComparisonMode.ALLOW_VARIANT)
-                 *
-                 * but it would not be very advantageous if testing the condition is almost as long as computing the AOI.
-                 * For now we keep != as a very cheap test which will work quite often.
-                 */
-                DefaultGeographicBoundingBox areaOfInterest = null;
-                if (sourceCRS != targetCRS) try {
-                    final DefaultGeographicBoundingBox targetAOI;
-                    final ReferencingServices converter = ReferencingServices.getInstance();
-                    areaOfInterest = converter.setBounds(source, null, "findOperation");                // Should be first.
-                    targetAOI      = converter.setBounds(target, null, "findOperation");
-                    if (areaOfInterest == null) {
-                        areaOfInterest = targetAOI;
-                    } else if (targetAOI != null) {
-                        areaOfInterest.add(targetAOI);
-                    }
-                } catch (TransformException e) {
-                    /*
-                     * Note: we may succeed to transform `source` and fail to transform `target` to geographic bounding box,
-                     * but the opposite is unlikely because `source` should not have less dimensions than `target`.
-                     */
-                    Logging.recoverableException(LOGGER, Envelopes.class, "findOperation", e);
+            final CoordinateReferenceSystem sourceCRS = source.getCoordinateReferenceSystem();
+            if (sourceCRS != null) {
+                final CoordinateReferenceSystem targetCRS = target.getCoordinateReferenceSystem();
+                if (targetCRS != null && !CRS.equivalent(sourceCRS, targetCRS)) {
+                    final var context = new CoordinateOperationContext();
+                    context.addAreaOfInterest(source);
+                    context.addAreaOfInterest(target);
+                    return CRS.findOperation(
+                            new DefaultCoordinateMetadata(sourceCRS, null),
+                            new DefaultCoordinateMetadata(targetCRS, null),
+                            context);
                 }
-                return CRS.findOperation(sourceCRS, targetCRS, areaOfInterest);
             }
         }
         return null;
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 8fb46bdba5..a55de2d6ad 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
@@ -86,6 +86,7 @@ import org.apache.sis.referencing.operation.DefaultCoordinateOperationFactory;
 import org.apache.sis.referencing.operation.DefaultConversion;
 import org.apache.sis.referencing.factory.GeodeticObjectFactory;
 import org.apache.sis.referencing.factory.UnavailableFactoryException;
+import org.apache.sis.coordinate.DefaultCoordinateMetadata;
 import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox;
 import org.apache.sis.metadata.iso.extent.Extents;
 import org.apache.sis.util.ArgumentChecks;
@@ -105,20 +106,21 @@ import org.opengis.coordinate.CoordinateMetadata;
 
 
 /**
- * Static methods working on {@linkplain CoordinateReferenceSystem Coordinate Reference Systems}.
+ * Static methods working on Coordinate Reference System (<abbr>CRS</abbr>) objects.
  * The methods defined in this class can be grouped in three categories:
  *
  * <ul>
  *   <li>Factory methods, the most notable one being {@link #forCode(String)}.</li>
  *   <li>Methods providing information, like {@link #isHorizontalCRS(CoordinateReferenceSystem)}.</li>
- *   <li>Finding coordinate operations between a source and a target CRS.</li>
+ *   <li>Finding coordinate operations between a source and a target <abbr>CRS</abbr>.</li>
  * </ul>
  *
  * <h2>Usage example</h2>
  * The most frequently used methods in this class are {@link #forCode forCode(…)}, {@link #fromWKT fromWKT(…)}
- * and {@link #findOperation findOperation(…)}. An usage example is like below
- * (see the <a href="https://sis.apache.org/tables/CoordinateReferenceSystems.html">Apache SIS™ Coordinate
- * Reference System (CRS) codes</a> page for the complete list of EPSG codes):
+ * and {@link #findOperation(CoordinateReferenceSystem, CoordinateReferenceSystem, GeographicBoundingBox) findOperation(…)}.
+ * An usage example is like below
+ * (see the <a href="https://sis.apache.org/tables/CoordinateReferenceSystems.html">Apache <abbr>SIS</abbr>™
+ * Coordinate Reference System (<abbr>CRS</abbr>) codes</a> page for the complete list of EPSG codes):
  *
  * {@snippet lang="java" :
  *     CoordinateReferenceSystem source = CRS.forCode("EPSG:4326");                   // WGS 84
@@ -134,7 +136,7 @@ import org.opengis.coordinate.CoordinateMetadata;
  *     System.out.println(position);
  *     }
  *
- * <h2>Note on kinds of CRS</h2>
+ * <h2>Note on kinds of <abbr>CRS</abbr></h2>
  * The {@link #getSingleComponents(CoordinateReferenceSystem)} method decomposes an arbitrary CRS into a flat
  * list of single components. In such flat list, vertical and temporal components can easily be identified by
  * {@code instanceof} checks. But identifying the horizontal component is not as easy. The list below suggests
@@ -673,35 +675,35 @@ public final class CRS {
      * @throws FactoryException if the operation cannot be created for another reason.
      *
      * @since 1.5
+     *
+     * @deprecated Replaced by {@link #findOperation(CoordinateMetadata, CoordinateMetadata, CoordinateOperationContext)}.
+     * This method will be removed for avoiding ambiguity when the last argument is null.
      */
+    @Deprecated(since = "1.6", forRemoval = true)
     public static CoordinateOperation findOperation(final CoordinateMetadata source,
                                                     final CoordinateMetadata target,
                                                     final GeographicBoundingBox areaOfInterest)
             throws FactoryException
     {
-        // TODO: take epoch in account.
-        if (source.getCoordinateEpoch().isPresent() || target.getCoordinateEpoch().isPresent()) {
-            throw new FactoryException("This version of Apache SIS does not yet support coordinate epoch.");
-        }
-        return findOperation(source.getCoordinateReferenceSystem(), target.getCoordinateReferenceSystem(), areaOfInterest);
+        return findOperation(source, target, CoordinateOperationContext.fromBoundingBox(areaOfInterest));
     }
 
     /**
-     * Finds a mathematical operation that transforms or converts coordinates from the given source to the
-     * given target coordinate reference system. If an estimation of the geographic area containing the points
-     * to transform is known, it can be specified for helping this method to find a better suited operation.
+     * Finds a mathematical operation that transform coordinates from the given source to the given target <abbr>CRS</abbr>.
+     * If an estimation of the geographic area containing the points to transform is known,
+     * it can be specified for helping this method to find a better suited operation.
      * If no area of interest is specified, then the current default is the widest
      * {@linkplain DefaultObjectDomain#getDomainOfValidity() domain of validity}.
      * A future Apache SIS version may also take the country of current locale in account.
      *
-     * <div class="note"><b>Note:</b>
-     * the area of interest is just one aspect that may affect the coordinate operation.
-     * Other aspects are the time of interest (because some coordinate operations take in account the
-     * plate tectonics movement) or the desired accuracy. For more control on the coordinate operation
-     * to create, see {@link CoordinateOperationContext}.</div>
+     * <p>Note that the area of interest is only one aspect that may affect the coordinate operation.
+     * Other aspects are the data epochs (because some coordinate operations take in account the plate tectonics movement)
+     * or the desired accuracy. For more control on the coordinate operation to create, see the
+     * {@linkplain #findOperation(CoordinateMetadata, CoordinateMetadata, CoordinateOperationContext) method below}.
+     * Alternatively, the area of interest can also be {@linkplain Envelopes#findOperation(Envelope, Envelope) specified as envelopes}.</p>
      *
-     * After the caller received a {@code CoordinateOperation} instance, the following methods can be invoked
-     * for checking if the operation suits the caller's needs:
+     * <p>After the caller received a {@code CoordinateOperation} instance,
+     * the following methods can be invoked for checking if the operation suits the caller's needs:</p>
      *
      * <ul>
      *   <li>{@link #getGeographicBoundingBox(CoordinateOperation)}
@@ -710,26 +712,37 @@ public final class CRS {
      *       for checking if the operation has sufficient accuracy for caller's purpose.</li>
      * </ul>
      *
-     * If the source and target CRS are equivalent, then this method returns an operation backed by an
-     * {@linkplain org.opengis.referencing.operation.MathTransform#isIdentity() identity} transform.
-     * If there is no known operation between the given pair of CRS, then this method throws an
-     * {@link OperationNotFoundException}.
+     * <p>If the source and target <abbr>CRS</abbr> are equivalent, then this method returns an operation
+     * backed by an {@linkplain org.opengis.referencing.operation.MathTransform#isIdentity() identity} transform.
+     * If there is no known operation between the given pair of <abbr>CRS</abbr>s,
+     * then this method throws an {@link OperationNotFoundException}.</p>
      *
-     * <p>Note that <code>CRS.findOperation(<var>B</var>, <var>A</var>, <var>aoi</var>)</code> is not necessarily
+     * <h4>Inverse operation</h4>
+     * Note that <code>CRS.findOperation(<var>B</var>, <var>A</var>, <var>aoi</var>)</code> is not necessarily
      * the exact converse of <code>CRS.findOperation(<var>A</var>, <var>B</var>, <var>aoi</var>)</code>.
-     * Some deviations may exist for example because of different paths explored in the geodetic database.
-     * For the inverse of an existing {@link CoordinateOperation}, using
-     * {@link org.opengis.referencing.operation.MathTransform#inverse()} is preferable.</p>
-     *
-     * @param  sourceCRS       the CRS of source coordinates.
-     * @param  targetCRS       the CRS of target coordinates.
+     * Some deviations may exist, for example because of different paths explored in the geodetic database.
+     * For the mathematical inverse of an existing {@link CoordinateOperation}, using
+     * {@link org.opengis.referencing.operation.MathTransform#inverse()} is preferable.
+     *
+     * <h4>Advanced usages</h4>
+     * It is not recommended to use the returned coordinate operation for transforming bounding boxes simply by
+     * transforming the four corners. Use the {@linkplain Envelopes#transform(Envelope, CoordinateReferenceSystem)
+     * envelope transformation methods} instead. These methods will take care of line curvature, envelope spanning
+     * the anti-meridian (longitude wraparound) and singularity points at the poles.
+     *
+     * <p>Likewise, for finding an operation for transforming pixel coordinates between two rasters, use the
+     * {@linkplain org.apache.sis.coverage.grid.GridGeometry#createTransformTo grid geometry method} instead.
+     * That latter method will take care of rasters spanning the anti-meridian, contrarily to the operations
+     * returned by this {@code CRS} class.</p>
+     *
+     * @param  sourceCRS       the <abbr>CRS</abbr> of source coordinates.
+     * @param  targetCRS       the <abbr>CRS</abbr> of target coordinates.
      * @param  areaOfInterest  the area of interest, or {@code null} if none.
      * @return the mathematical operation from {@code sourceCRS} to {@code targetCRS}.
      * @throws OperationNotFoundException if no operation was found between the given pair of <abbr>CRS</abbr>s.
      * @throws FactoryException if the operation cannot be created for another reason.
      *
      * @see Envelopes#findOperation(Envelope, Envelope)
-     * @see DefaultCoordinateOperationFactory#createOperation(CoordinateReferenceSystem, CoordinateReferenceSystem, CoordinateOperationContext)
      *
      * @since 0.7
      */
@@ -740,7 +753,66 @@ public final class CRS {
     {
         ArgumentChecks.ensureNonNull("sourceCRS", sourceCRS);
         ArgumentChecks.ensureNonNull("targetCRS", targetCRS);
-        final CoordinateOperationContext context = CoordinateOperationContext.fromBoundingBox(areaOfInterest);
+        return findOperation(
+                new DefaultCoordinateMetadata(sourceCRS, null),
+                new DefaultCoordinateMetadata(targetCRS, null),
+                CoordinateOperationContext.fromBoundingBox(areaOfInterest));
+    }
+
+    /**
+     * Finds a mathematical operation that transforms coordinates between the given <abbr>CRS</abbr>s and epochs.
+     * If many operations exist between the specified pair of <abbr>CRS</abbr>s, an operation is selected using the
+     * area of interest (<abbr>AOI</abbr>) and desired accuracy specified in the optional {@code context} argument:
+     * the operation having a {@linkplain DefaultObjectDomain#getDomainOfValidity() domain of validity} resulting in
+     * the widest intersection with the <abbr>AOI</abbr> is preferred. If many operations result in the same intersection,
+     * then the operation having an accuracy just sufficient for the desired accuracy is preferred.
+     *
+     * <p>After the caller received a {@code CoordinateOperation} instance,
+     * the following methods can be invoked for checking if the operation suits the caller's needs:</p>
+     *
+     * <ul>
+     *   <li>{@link #getGeographicBoundingBox(CoordinateOperation)}
+     *       for checking if the operation is valid in the caller's area of interest.</li>
+     *   <li>{@link #getLinearAccuracy(CoordinateOperation)}
+     *       for checking if the operation has sufficient accuracy for caller's purpose.</li>
+     * </ul>
+     *
+     * <p>If the source and target <abbr>CRS</abbr> are equivalent, then this method returns an operation
+     * backed by an {@linkplain org.opengis.referencing.operation.MathTransform#isIdentity() identity} transform.
+     * If there is no known operation between the given pair of <abbr>CRS</abbr>s,
+     * then this method throws an {@link OperationNotFoundException}.</p>
+     *
+     * <h4>Inverse operation</h4>
+     * Note that <code>CRS.findOperation(<var>B</var>, <var>A</var>, <var>context</var>)</code> is not necessarily
+     * the exact converse of <code>CRS.findOperation(<var>A</var>, <var>B</var>, <var>context</var>)</code>.
+     * Some deviations may exist, for example because of different paths explored in the geodetic database.
+     * For the mathematical inverse of an existing {@link CoordinateOperation}, using
+     * {@link org.opengis.referencing.operation.MathTransform#inverse()} is preferable.
+     *
+     * @param  source   the <abbr>CRS</abbr> and epoch of source coordinates.
+     * @param  target   the <abbr>CRS</abbr> and epoch of target coordinates.
+     * @param  context  area of interest, desired accuracy and other options, or {@code null} if none.
+     * @return the mathematical operation from {@code source} to {@code target}.
+     * @throws OperationNotFoundException if no operation was found between the given pair of <abbr>CRS</abbr>s and epochs.
+     * @throws FactoryException if the operation cannot be created for another reason.
+     *
+     * @see DefaultCoordinateOperationFactory#createOperation(CoordinateReferenceSystem, CoordinateReferenceSystem, CoordinateOperationContext)
+     *
+     * @since 1.6
+     */
+    public static CoordinateOperation findOperation(final CoordinateMetadata source,
+                                                    final CoordinateMetadata target,
+                                                    final CoordinateOperationContext context)
+            throws FactoryException
+    {
+        ArgumentChecks.ensureNonNull("source", source);
+        ArgumentChecks.ensureNonNull("target", target);
+        if (source.getCoordinateEpoch().isPresent() || target.getCoordinateEpoch().isPresent()) {
+            throw new FactoryException("This version of Apache SIS does not yet support coordinate epoch.");
+        }
+        // TODO: take epoch in account.
+        final CoordinateReferenceSystem sourceCRS = source.getCoordinateReferenceSystem();
+        final CoordinateReferenceSystem targetCRS = target.getCoordinateReferenceSystem();
         /*
          * In principle following code should just delegate to factory.createOperation(…). However, that operation
          * may fail if a connection to the EPSG database has been found, but the EPSG tables do not yet exist in
@@ -763,21 +835,22 @@ public final class CRS {
     }
 
     /**
-     * Finds mathematical operations that transform or convert coordinates from the given source to the
-     * given target coordinate reference system. If at least one operation exists, they are returned in
-     * preference order: the operation having the widest intersection between its
-     * {@linkplain DefaultObjectDomain#getDomainOfValidity() domain of validity}
-     * and the given area of interest are returned first.
-     *
-     * @param  sourceCRS       the CRS of source coordinates.
-     * @param  targetCRS       the CRS of target coordinates.
+     * Finds mathematical operations that transform coordinates from the given source to the given target <abbr>CRS</abbr>
+     * in a given area of interest. If many operations exist, they are returned in preference order: the operation having
+     * the widest intersection between its {@linkplain DefaultObjectDomain#getDomainOfValidity() domain of validity} and
+     * the given area of interest are returned first.
+     *
+     * <p>This is a convenience method for static <abbr>CRS</abbr>s and a context defined only by the area of interest.
+     * For an alternative allowing to specify data epochs (for dynamic <abbr>CRS</abbr>s) and desired accuracy, see the
+     * {@linkplain #findOperations(CoordinateMetadata, CoordinateMetadata, CoordinateOperationContext) method below}.</p>
+     *
+     * @param  sourceCRS       the <abbr>CRS</abbr> of source coordinates.
+     * @param  targetCRS       the <abbr>CRS</abbr> of target coordinates.
      * @param  areaOfInterest  the area of interest, or {@code null} if none.
      * @return mathematical operations from {@code sourceCRS} to {@code targetCRS}.
-     * @throws OperationNotFoundException if no operation was found between the given pair of CRS.
+     * @throws OperationNotFoundException if no operation was found between the given pair of <abbr>CRS</abbr>s.
      * @throws FactoryException if the operation cannot be created for another reason.
      *
-     * @see DefaultCoordinateOperationFactory#createOperations(CoordinateReferenceSystem, CoordinateReferenceSystem, CoordinateOperationContext)
-     *
      * @since 1.0
      */
     public static List<CoordinateOperation> findOperations(final CoordinateReferenceSystem sourceCRS,
@@ -787,7 +860,42 @@ public final class CRS {
     {
         ArgumentChecks.ensureNonNull("sourceCRS", sourceCRS);
         ArgumentChecks.ensureNonNull("targetCRS", targetCRS);
-        final CoordinateOperationContext context = CoordinateOperationContext.fromBoundingBox(areaOfInterest);
+        return findOperations(
+                new DefaultCoordinateMetadata(sourceCRS, null),
+                new DefaultCoordinateMetadata(targetCRS, null),
+                CoordinateOperationContext.fromBoundingBox(areaOfInterest));
+    }
+
+    /**
+     * Finds mathematical operations that transforms coordinates between the given <abbr>CRS</abbr>s and epochs.
+     * If many operations exist, they are sorted in preference order according criteria specified by {@code context}:
+     * best matches with the area of interest (<abbr>AOI</abbr>) are first, then operations matching <abbr>AOI</abbr>
+     * equally well are sorted by best matches with the desired accuracy.
+     *
+     * @param  source   the <abbr>CRS</abbr> and epoch of source coordinates.
+     * @param  target   the <abbr>CRS</abbr> and epoch of target coordinates.
+     * @param  context  area of interest, desired accuracy and other options, or {@code null} if none.
+     * @return mathematical operations from {@code source} to {@code target} <abbr>CRS</abbr> and epoch.
+     * @throws OperationNotFoundException if no operation was found between the given pair of <abbr>CRS</abbr>s.
+     * @throws FactoryException if the operation cannot be created for another reason.
+     *
+     * @see DefaultCoordinateOperationFactory#createOperations(CoordinateReferenceSystem, CoordinateReferenceSystem, CoordinateOperationContext)
+     *
+     * @since 1.6
+     */
+    public static List<CoordinateOperation> findOperations(final CoordinateMetadata source,
+                                                           final CoordinateMetadata target,
+                                                           final CoordinateOperationContext context)
+            throws FactoryException
+    {
+        ArgumentChecks.ensureNonNull("source", source);
+        ArgumentChecks.ensureNonNull("target", target);
+        if (source.getCoordinateEpoch().isPresent() || target.getCoordinateEpoch().isPresent()) {
+            throw new FactoryException("This version of Apache SIS does not yet support coordinate epoch.");
+        }
+        // TODO: take epoch in account.
+        final CoordinateReferenceSystem sourceCRS = source.getCoordinateReferenceSystem();
+        final CoordinateReferenceSystem targetCRS = target.getCoordinateReferenceSystem();
         final DefaultCoordinateOperationFactory factory = DefaultCoordinateOperationFactory.provider();
         try {
             return factory.createOperations(sourceCRS, targetCRS, context);
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/MultiRegisterOperations.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/MultiRegisterOperations.java
index 75d861363b..fbab73afa0 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/MultiRegisterOperations.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/MultiRegisterOperations.java
@@ -27,6 +27,7 @@ import org.opengis.util.Factory;
 import org.opengis.util.FactoryException;
 import org.opengis.metadata.extent.GeographicBoundingBox;
 import org.opengis.referencing.AuthorityFactory;
+import org.opengis.referencing.NoSuchAuthorityCodeException;
 import org.opengis.referencing.crs.CRSFactory;
 import org.opengis.referencing.crs.CRSAuthorityFactory;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/cs/CoordinateSystems.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/cs/CoordinateSystems.java
index f497b1bc42..fdb305898d 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/cs/CoordinateSystems.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/cs/CoordinateSystems.java
@@ -46,6 +46,7 @@ import org.apache.sis.referencing.internal.shared.AxisDirections;
 import org.apache.sis.referencing.internal.shared.Formulas;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.matrix.MatrixSIS;
+import org.apache.sis.referencing.operation.matrix.UnderdeterminedMatrixException;
 
 
 /**
@@ -334,7 +335,8 @@ next:   for (final CoordinateSystem cs : targets) {
      * @param  targetCS  the target coordinate system.
      * @return the conversion from {@code sourceCS} to {@code targetCS} as an affine transform.
      *         Only axis direction and units are taken in account.
-     * @throws IllegalArgumentException if the CS are not of the same type, or axes do not match.
+     * @throws IllegalArgumentException if the <abbr>CS</abbr> are not of the same type, or axes do not match.
+     * @throws UnderdeterminedMatrixException if {@code targetCS} has an axis that {@code sourceCS} does not have.
      * @throws IncommensurableException if the units are not compatible, or the conversion is non-linear.
      *
      * @see Matrices#createTransform(AxisDirection[], AxisDirection[])
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources.java
index dd2f78b00b..641280441a 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources.java
@@ -188,7 +188,7 @@ public class Resources extends IndexedResourceBundle {
         public static final short ConformanceMeansDatumShift = 11;
 
         /**
-         * A constant value is required for the coordinate at index {0}.
+         * A constant value is required for coordinate axis “{0}”.
          */
         public static final short ConstantCoordinateValueRequired_1 = 109;
 
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources.properties b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources.properties
index a602475cb3..836bbae563 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources.properties
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources.properties
@@ -71,7 +71,7 @@ CanNotTransformEnvelopeToGeodetic = Cannot transform envelope to a geodetic refe
 CanNotTransformGeometry           = Cannot transform the given geometry.
 CanNotUseGeodeticParameters_2     = Cannot use the {0} geodetic parameters. Caused by: {1}
 ColinearAxisDirections_2          = Axis directions {0} and {1} are colinear.
-ConstantCoordinateValueRequired_1 = A constant value is required for the coordinate at index {0}.
+ConstantCoordinateValueRequired_1 = A constant value is required for coordinate axis \u201c{0}\u201d.
 CoordinateOperationNotFound_2     = Coordinate operation from system \u201c{0}\u201d to \u201c{1}\u201d has not been found.
 DatumChangesDirectory_1           = Datum shift files are searched in the \u201c{0}\u201d directory.
 DatumChangeNotFound_2             = No information about how to change from datum \u201c{0}\u201d to \u201c{1}\u201d.
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources_fr.properties b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources_fr.properties
index 981a56dd6b..751d994bc7 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources_fr.properties
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/Resources_fr.properties
@@ -76,7 +76,7 @@ CanNotTransformEnvelopeToGeodetic = Ne peut pas transformer l\u2019enveloppe ver
 CanNotTransformGeometry           = Ne peut pas transformer la g\u00e9om\u00e9trie donn\u00e9e.
 CanNotUseGeodeticParameters_2     = Ne peut pas utiliser les param\u00e8tres g\u00e9od\u00e9siques {0}. La cause est\u202f: {1}
 ColinearAxisDirections_2          = Les directions d\u2019axes {0} et {1} sont colin\u00e9aires.
-ConstantCoordinateValueRequired_1 = Une valeur constante est requise pour la coordonn\u00e9e \u00e0 l\u2019index {0}.
+ConstantCoordinateValueRequired_1 = Une valeur constante est requise pour la coordonn\u00e9e de l\u2019axe \u00ab\u202f{0}\u202f\u00bb.
 CoordinateOperationNotFound_2     = L\u2019op\u00e9ration sur les coordonn\u00e9es du syst\u00e8me \u00ab\u202f{0}\u202f\u00bb vers \u00ab\u202f{1}\u202f\u00bb n\u2019a pas \u00e9t\u00e9 trouv\u00e9e.
 DatumChangesDirectory_1           = Les fichiers de changements de r\u00e9f\u00e9rentiel sont cherch\u00e9s dans le dossier \u00ab\u202f{0}\u202f\u00bb.
 DatumChangeNotFound_2             = Il n\u2019y a pas d\u2019information sur la fa\u00e7on de passer du r\u00e9f\u00e9rentiel \u00ab\u202f{0}\u202f\u00bb vers \u00ab\u202f{1}\u202f\u00bb.
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/shared/CoordinateOperations.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/shared/CoordinateOperations.java
index 47f2423d40..83640f532b 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/shared/CoordinateOperations.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/shared/CoordinateOperations.java
@@ -19,7 +19,6 @@ package org.apache.sis.referencing.internal.shared;
 import java.util.Set;
 import java.util.Map;
 import java.util.HashMap;
-import java.util.function.Supplier;
 import java.util.logging.Logger;
 import javax.measure.UnitConverter;
 import javax.measure.IncommensurableException;
@@ -87,13 +86,6 @@ public final class CoordinateOperations {
      */
     public static final String OPERATION_TYPE_KEY = "operationType";
 
-    /**
-     * Value of {@link org.apache.sis.referencing.operation.CoordinateOperationContext#getConstantCoordinates()}.
-     * This thread-local is used as a workaround for the fact that we do not yet provide a public API for this
-     * functionality. This workaround should be deleted after a public API is defined.
-     */
-    public static final ThreadLocal<Supplier<double[]>> CONSTANT_COORDINATES = new ThreadLocal<>();
-
     /**
      * Cached values of {@link #wrapAroundChanges wrapAroundChanges(…)}, created when first needed.
      * Indices are bit masks computed by {@link #changes changes(…)}. Since the most common "wrap around" axes
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationContext.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationContext.java
index cc0c608536..1df1f3f0d0 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationContext.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationContext.java
@@ -16,22 +16,25 @@
  */
 package org.apache.sis.referencing.operation;
 
-import java.io.Serializable;
-import java.util.function.Supplier;
+import java.util.Locale;
+import java.util.logging.Filter;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.logging.LogRecord;
 import java.util.function.Predicate;
+import org.opengis.geometry.Envelope;
+import org.opengis.geometry.DirectPosition;
 import org.opengis.metadata.extent.Extent;
 import org.opengis.metadata.extent.GeographicBoundingBox;
 import org.opengis.referencing.operation.CoordinateOperation;
 import org.opengis.referencing.operation.TransformException;
-import org.opengis.referencing.operation.OperationNotFoundException;
-import org.apache.sis.referencing.internal.shared.CoordinateOperations;
 import org.apache.sis.metadata.iso.extent.DefaultExtent;
 import org.apache.sis.metadata.iso.extent.Extents;
 import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.Localized;
 import org.apache.sis.util.collection.Containers;
 import org.apache.sis.measure.Latitude;
 import org.apache.sis.measure.Longitude;
-import org.apache.sis.util.collection.BackingStoreException;
 
 
 /**
@@ -40,14 +43,14 @@ import org.apache.sis.util.collection.BackingStoreException;
  *
  * <ul>
  *   <li>The geographic area where the transformation will be used.</li>
- *   <li>The desired accuracy. A coarser accuracy may allow SIS to choose a faster transformation method.</li>
+ *   <li>The desired accuracy. A coarser accuracy may allow <abbr>SIS</abbr> to choose a faster transformation method.</li>
  * </ul>
  *
  * While optional, those information can help {@link DefaultCoordinateOperationFactory}
  * to choose the most suitable coordinate transformation between two CRS.
  *
- * <p>{@code CoordinateOperationContext} is part of the API
- * used by SIS for implementing the <i>late binding</i> model.
+ * <p>{@code CoordinateOperationContext} is part of the <abbr>API</abbr>
+ * used by <abbr>SIS</abbr> for implementing the <i>late binding</i> model.
  * See {@linkplain org.apache.sis.referencing.operation package javadoc}
  * for a note on early binding versus late binding implementations.</p>
  *
@@ -59,24 +62,18 @@ import org.apache.sis.util.collection.BackingStoreException;
  * to Texas, but not suitable to the rest of North-America.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.6
  * @since   0.7
  *
  * @todo Should also take the country of a {@link java.util.Locale}. The EPSG database contains ISO2 and ISO3
  *       identifiers that we can use.
  */
-public class CoordinateOperationContext implements Serializable {
-    /**
-     * For cross-version compatibility.
-     */
-    private static final long serialVersionUID = -6944460471653277973L;
-
+public class CoordinateOperationContext implements Localized {
     /**
      * The spatiotemporal area of interest, or {@code null} if none. This instance may be updated or
      * replaced by other methods in this class, or (indirectly) by {@link CoordinateOperationFinder}.
      */
-    @SuppressWarnings("serial")         // Most SIS implementations are serializable.
-    private Extent areaOfInterest;
+    private DefaultExtent areaOfInterest;
 
     /**
      * The desired accuracy in metres, or 0 for the best accuracy available.
@@ -84,6 +81,23 @@ public class CoordinateOperationContext implements Serializable {
      */
     private double desiredAccuracy;
 
+    /**
+     * Coordinate values that can be considered as constant, or {@code null} if none.
+     *
+     * @see #getConstantCoordinates()
+     */
+    private DirectPosition constantCoordinates;
+
+    /**
+     * The locale for error messages, or {@code null} for the default locale.
+     */
+    private Locale locale;
+
+    /**
+     * Whether to log a warning.
+     */
+    private transient Filter logFilter;
+
     /**
      * Creates a new context with no area of interest and the best accuracy available.
      */
@@ -151,38 +165,73 @@ public class CoordinateOperationContext implements Serializable {
      *
      * @param  area  the spatiotemporal area of interest, or {@code null} if none.
      */
-    public void setAreaOfInterest(Extent area) {
-        if (area != null) {
-            area = new DefaultExtent(area);
-        }
-        areaOfInterest = area;
+    public void setAreaOfInterest(final Extent area) {
+        areaOfInterest = (area != null) ? new DefaultExtent(area) : null;
     }
 
     /**
-     * Sets the geographic component of the area of interest, or {@code null} if none.
-     * This convenience method set the bounding box into the spatiotemporal {@link Extent}.
+     * Sets the geographic element of the area of interest, or {@code null} if none.
+     * This convenience method sets the given bounding box into the spatiotemporal {@link Extent}.
      *
      * <p>The reverse operation can be done with <code>{@linkplain Extents#getGeographicBoundingBox(Extent)
      * Extents.getGeographicBoundingBox}({@linkplain #getAreaOfInterest()})</code>.</p>
      *
-     * @param  area  the geographic area of interest, or {@code null} if none.
+     * @param  area  the new geographic area of interest, or {@code null} if none.
      */
     public void setAreaOfInterest(final GeographicBoundingBox area) {
         areaOfInterest = setGeographicBoundingBox(areaOfInterest, area);
     }
 
+    /**
+     * Adds a geographic element of the area of interest.
+     * The given area is added to the list returned by {@link Extent#getGeographicElements()}.
+     * If the list contains two or more elements, {@link DefaultCoordinateOperationFactory}
+     * will use the union of all elements.
+     *
+     * @param  area  the geographic area of interest to add, or {@code null} if none.
+     * @since 1.6
+     */
+    public void addAreaOfInterest(final GeographicBoundingBox area) {
+        if (area != null) {
+            if (areaOfInterest == null) {
+                setAreaOfInterest(area);
+            } else {
+                areaOfInterest.getGeographicElements().add(area);
+            }
+        }
+    }
+
+    /**
+     * Adds the geographic, vertical and temporal elements of an envelope as areas of interest.
+     * The geographic element is added to the list returned by {@link Extent#getGeographicElements()},
+     * and likewise for vertical and temporal components. If the envelope cannot be transformed to the
+     * <abbr>CRS</abbr> expected by a component list, then the exception is reported to the
+     * {@linkplain #getLogFilter() log filter} and eventually logged.
+     *
+     * @param area  the envelope to add as an area of interest, or {@code null} if none.
+     * @since 1.6
+     */
+    public void addAreaOfInterest(final Envelope area) {
+        if (area != null) try {
+            if (areaOfInterest == null) {
+                areaOfInterest = new DefaultExtent();
+            }
+            areaOfInterest.addElements(area);
+        } catch (TransformException e) {
+            recoverableException("addAreaOfInterest", e);
+        }
+    }
+
     /**
      * Sets the given geographic bounding box in the given extent.
-     * This method may modify the extent given in parameters.
+     * Modifies the extent given in parameters if non-null, or otherwise creates a new extent.
      *
      * @param  areaOfInterest  the extent to update, or {@code null}.
      * @return the updated extent. May be the given one or a new instance.
      */
-    static Extent setGeographicBoundingBox(Extent areaOfInterest, final GeographicBoundingBox bbox) {
+    static DefaultExtent setGeographicBoundingBox(DefaultExtent areaOfInterest, final GeographicBoundingBox bbox) {
         if (areaOfInterest != null) {
-            final DefaultExtent ex = DefaultExtent.castOrCopy(areaOfInterest);
-            ex.setGeographicElements(Containers.singletonOrEmpty(bbox));
-            areaOfInterest = ex;
+            areaOfInterest.setGeographicElements(Containers.singletonOrEmpty(bbox));
         } else if (bbox != null) {
             areaOfInterest = new DefaultExtent(null, bbox, null, null);
         }
@@ -226,39 +275,143 @@ public class CoordinateOperationContext implements Serializable {
     }
 
     /**
-     * Invoked when at least one coordinate in the target <abbr>CRS</abbr> cannot be computed from the coordinates in
-     * the source <abbr>CRS</abbr>. For example, if the source <abbr>CRS</abbr> has (<var>x</var>, <var>y</var>) axes
+     * Returns coordinate values that may be considered as constant, or {@code null} if none.
+     * This method is invoked when at least one coordinate in the target <abbr>CRS</abbr>
+     * cannot be computed from the coordinates in the source <abbr>CRS</abbr>.
+     * For example, if the source <abbr>CRS</abbr> has (<var>x</var>, <var>y</var>) axes
      * and the target <abbr>CRS</abbr> has (<var>x</var>, <var>y</var>, <var>t</var>) axes,
      * then this method is invoked for determining which value to assign to the <var>t</var> coordinate.
-     * In some cases, the user can tell that the coordinate should be set to a constant value.
+     * All other coordinates will be ignored and can be NaN.
+     *
+     * <h4>Coordinate reference system</h4>
+     * If {@link DirectPosition#getCoordinateReferenceSystem()} returns null, the default coordinate reference system
+     * is the target <abbr>CRS</abbr> of the requested coordinate operation. Otherwise, the <abbr>CRS</abbr> of the
+     * returned position should be one of the components of the target <abbr>CRS</abbr>. If the <abbr>CRS</abbr> is
+     * not recognized, the returned position may be ignored.
      *
-     * <p>If this method returns {@code null}, then the {@link CoordinateOperationFinder} caller will throw an
-     * {@link OperationNotFoundException}. Otherwise, the returned array should have a length equals to the number
-     * of dimensions in the full (usually compound) target <abbr>CRS</abbr>. Only coordinate values in dimensions
-     * without source (the <var>t</var> dimension in the above example) will be used.
-     * All other coordinate values will be ignored.</p>
+     * @return coordinates to take as constants, or {@code null} if none.
+     * @since 1.6
+     */
+    public DirectPosition getConstantCoordinates() {
+        return constantCoordinates;
+    }
+
+    /**
+     * Sets coordinate values that may be considered as constant.
+     * This position will be used if at least one coordinate in the target <abbr>CRS</abbr>
+     * cannot be computed from the coordinates in the source <abbr>CRS</abbr>.
      *
-     * @return coordinate values to take as constants for the specified target component, or {@code null} if none.
-     * @throws TransformException if the coordinates cannot be computed. This exception may occur when the constant
-     *         coordinate values are the results of performing a coordinate operation in advance.
+     * <h4>Allowed coordinate reference systems</h4>
+     * The Coordinate Reference System (<abbr>CRS</abbr>) of the given position is optional.
+     * If absent, it is assumed to be the target <abbr>CRS</abbr> of the coordinate operation.
+     * If present, it should be either the target <abbr>CRS</abbr> of the requested operation,
+     * or one of the components of the target <abbr>CRS</abbr> (if the latter is compound).
+     * If this condition does not hold, the position may be ignored (it will not be transformed).
      *
-     * @todo Non-public API for now, pending more feedback from experience. A public method would be non-static.
+     * @param coordinates  coordinate values that may be considered as constant, or {@code null} if none.
+     * @since 1.6
      */
-    static double[] getConstantCoordinates() throws TransformException {
-        final Supplier<double[]> f = CoordinateOperations.CONSTANT_COORDINATES.get();
-        if (f != null) try {
-            return f.get();
-        } catch (BackingStoreException e) {
-            throw e.unwrapOrRethrow(TransformException.class);
-        }
-        return null;
+    public void setConstantCoordinates(final DirectPosition coordinates) {
+        constantCoordinates = coordinates;
     }
 
     /**
      * Returns whether it is safe to use cached operation.
      * This is {@code false} if the operation result may depend on external configuration.
      */
-    static boolean canReadFromCache() {
-        return CoordinateOperations.CONSTANT_COORDINATES.get() == null;
+    final boolean canReadFromCache() {
+        return getAreaOfInterest() == null && getDesiredAccuracy() == 0 && getConstantCoordinates() == null;
+    }
+
+    /**
+     * Returns the locale for error messages.
+     * By default, this is the <abbr>JVM</abbr> default locale.
+     * Note that this locale has no incidence on the selection of coordinate operations.
+     * In particular, this locale does not determine a geographic area.
+     *
+     * @return the locale for error messages.
+     * @since 1.6
+     */
+    @Override
+    public Locale getLocale() {
+        return (locale != null) ? locale : Locale.getDefault();
+    }
+
+    /**
+     * Sets the locale for error messages.
+     * Locale specified by this method are used on a best-effort basis only.
+     * Note that this locale has no incidence on the selection of coordinate operations.
+     * In particular, this locale does not determine a geographic area.
+     *
+     * @param  locale  the locale for error messages, or {@code null} for the <abbr>JVM</abbr> default locale.
+     * @since 1.6
+     */
+    public void setLocale(final Locale locale) {
+        this.locale = locale;
+    }
+
+    /**
+     * Returns a filter which will receive warnings, or {@code null} if none.
+     * If that filter returns {@code false} for a given log record, then that
+     * record will not be logged by {@link CoordinateOperationFinder}.
+     *
+     * @return a filter which will receive warnings, or {@code null} if none.
+     * @since 1.6
+     */
+    public Filter getLogFilter() {
+        return logFilter;
+    }
+
+    /**
+     * Specifies a filter which will receive warnings, or {@code null} if none.
+     * If that filter returns {@code false} for a given log record, then that
+     * record will not be logged by {@link CoordinateOperationFinder}.
+     *
+     * <h4>Usage example</h4>
+     * This filter can be used as a way to redirect the logs to something else,
+     * such as {@linkplain org.apache.sis.storage.event.StoreListener data store listeners}.
+     * In such case, the return value of the filter controls whether the record should be also
+     * logged with the usual loggers.
+     *
+     * @param logFilter  a filter which will receive warnings, or {@code null} if none.
+     * @since 1.6
+     */
+    public void setLogFilter(final Filter logFilter) {
+        this.logFilter = logFilter;
+    }
+
+    /**
+     * Logs a record about an exception that we can ignore.
+     *
+     * @param method  the method to declare as the source.
+     * @param e       the exception to report.
+     */
+    private void recoverableException(final String method, final Exception e) {
+        final var record = new LogRecord(Level.FINE, e.getLocalizedMessage());
+        record.setThrown(e);
+        record.setSourceMethodName(method);
+        log(this, CoordinateOperationContext.class, AbstractCoordinateOperation.LOGGER, record);
+    }
+
+    /**
+     * Logs the given record. The logger name of the record is unconditionally set to the name of the given logger.
+     * Then, if the context is non-null and has a non-null log filter, the record is submitted to the filter.
+     * If the filter didn't returned {@code false}, the record is logged.
+     *
+     * @param context  the context, or {@code null} if none.
+     * @param caller   the class to report as the source of the log.
+     * @param logger   the logger where to log the record.
+     * @param record   the record to log.
+     */
+    static void log(CoordinateOperationContext context, Class<?> caller, Logger logger, LogRecord record) {
+        record.setSourceClassName(caller.getCanonicalName());
+        record.setLoggerName(logger.getName());
+        if (context != null) {
+            final Filter logFilter = context.getLogFilter();
+            if (logFilter != null && !logFilter.isLoggable(record)) {
+                return;
+            }
+        }
+        logger.log(record);
     }
 }
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationFinder.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationFinder.java
index 2921d6c4e0..ca2d90fdd4 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationFinder.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationFinder.java
@@ -22,6 +22,7 @@ import java.util.List;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.ListIterator;
+import java.util.Locale;
 import java.util.Optional;
 import java.time.Duration;
 import javax.measure.Unit;
@@ -39,6 +40,7 @@ import org.opengis.metadata.extent.GeographicBoundingBox;
 import org.opengis.parameter.ParameterValueGroup;
 import org.apache.sis.measure.Units;
 import org.apache.sis.metadata.iso.citation.Citations;
+import org.apache.sis.metadata.iso.extent.DefaultExtent;
 import org.apache.sis.metadata.iso.extent.Extents;
 import org.apache.sis.referencing.CRS;
 import org.apache.sis.referencing.CommonCRS;
@@ -58,6 +60,7 @@ import org.apache.sis.referencing.datum.DatumOrEnsemble;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.matrix.MatrixSIS;
 import org.apache.sis.referencing.operation.matrix.NoninvertibleMatrixException;
+import org.apache.sis.referencing.operation.matrix.UnderdeterminedMatrixException;
 import org.apache.sis.referencing.operation.provider.DatumShiftMethod;
 import org.apache.sis.referencing.operation.provider.GeocentricAffine;
 import org.apache.sis.util.Utilities;
@@ -136,7 +139,7 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
      * The pair of source and target CRS for which we already searched a coordinate operation.
      * This is used as a safety against infinite recursion.
      */
-    private final Map<CRSPair,Boolean> previousSearches;
+    private final Map<CRSPair, Boolean> previousSearches;
 
     /**
      * Whether this finder instance is allowed to use {@link DefaultCoordinateOperationFactory#cache}.
@@ -162,13 +165,13 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
      * @see DefaultCoordinateOperationFactory#createOperationFinder(CoordinateOperationAuthorityFactory, CoordinateOperationContext)
      */
     public CoordinateOperationFinder(final CoordinateOperationAuthorityFactory registry,
-                                     final CoordinateOperationFactory          factory,
-                                     final CoordinateOperationContext          context) throws FactoryException
+                                     final CoordinateOperationFactory factory,
+                                     final CoordinateOperationContext context) throws FactoryException
     {
         super(registry, factory, context);
         identifierOfStepCRS = new HashMap<>(8);
         previousSearches    = new HashMap<>(8);
-        canReadFromCache    = (context == null) && (factory == factorySIS) && CoordinateOperationContext.canReadFromCache();
+        canReadFromCache    = (context == null || context.canReadFromCache()) && (factory == factorySIS);
         canStoreInCache     = (context == null);
     }
 
@@ -242,9 +245,10 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
             return asList(createFromAffineTransform(AXIS_CHANGES, sourceCRS, targetCRS, null,
                             CoordinateSystems.swapAndScaleAxes(sourceCRS.getCoordinateSystem(),
                                                                targetCRS.getCoordinateSystem())));
-        } catch (IllegalArgumentException | IncommensurableException e) {
-            final CRSPair key = new CRSPair(sourceCRS, targetCRS);
-            throw new FactoryException(resources().getString(Resources.Keys.CanNotInstantiateGeodeticObject_1, key), e);
+        } catch (UnderdeterminedMatrixException exception) {
+            throw new MissingSourceDimensionsException(notFoundMessage(sourceCRS, targetCRS), exception);
+        } catch (IllegalArgumentException | IncommensurableException exception) {
+            throw new OperationNotFoundException(notFoundMessage(sourceCRS, targetCRS), exception);
         }
         /*
          * If this method is invoked recursively, verify if the requested operation is already in the cache.
@@ -268,7 +272,7 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
         if (bbox == null) {
             bbox = Extents.intersection(CRS.getGeographicBoundingBox(sourceCRS),
                                         CRS.getGeographicBoundingBox(targetCRS));
-            areaOfInterest = CoordinateOperationContext.setGeographicBoundingBox(areaOfInterest, bbox);
+            areaOfInterest = CoordinateOperationContext.setGeographicBoundingBox(DefaultExtent.castOrCopy(areaOfInterest), bbox);
         }
         /*
          * Verify in the EPSG dataset if the operation is explicitly defined by an authority.
@@ -752,6 +756,8 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
         final Matrix matrix;
         try {
             matrix = CoordinateSystems.swapAndScaleAxes(sourceCS, targetCS);
+        } catch (UnderdeterminedMatrixException exception) {
+            throw new MissingSourceDimensionsException(notFoundMessage(sourceCRS, targetCRS), exception);
         } catch (IllegalArgumentException | IncommensurableException exception) {
             throw new OperationNotFoundException(notFoundMessage(sourceCRS, targetCRS), exception);
         }
@@ -820,6 +826,8 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
         final MatrixSIS matrix;
         try {
             matrix = MatrixSIS.castOrCopy(CoordinateSystems.swapAndScaleAxes(sourceCS, targetCS));
+        } catch (UnderdeterminedMatrixException exception) {
+            throw new MissingSourceDimensionsException(notFoundMessage(sourceCRS, targetCRS), exception);
         } catch (IllegalArgumentException | IncommensurableException exception) {
             throw new OperationNotFoundException(notFoundMessage(sourceCRS, targetCRS), exception);
         }
@@ -873,6 +881,8 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
         final Matrix matrix;
         try {
             matrix = CoordinateSystems.swapAndScaleAxes(sourceCS, targetCS);
+        } catch (UnderdeterminedMatrixException exception) {
+            throw new MissingSourceDimensionsException(notFoundMessage(sourceCRS, targetCRS), exception);
         } catch (IllegalArgumentException | IncommensurableException exception) {
             throw new OperationNotFoundException(notFoundMessage(sourceCRS, targetCRS), exception);
         }
@@ -909,15 +919,12 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
          */
         final SubOperationInfo[] infos;
         try {
-            infos = SubOperationInfo.createSteps(this, sourceComponents, targetComponents);
+            infos = SubOperationInfo.createSteps(this, sourceCRS, sourceComponents, targetComponents);
         } catch (NoninvertibleTransformException e) {
             throw new OperationNotFoundException(notFoundMessage(sourceCRS, targetCRS), e);
         } catch (TransformException e) {
             throw new FactoryException(notFoundMessage(sourceCRS, targetCRS), e);
         }
-        if (infos == null) {
-            throw new OperationNotFoundException(notFoundMessage(sourceCRS, targetCRS));
-        }
         /*
          * At this point, a coordinate operation has been found for all components of the target CRS.
          * However, the CoordinateOperation.getSourceCRS() values are not necessarily in the same order
@@ -959,8 +966,8 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
         for (int i=0; i<stepComponents.length; i++) {
             final SubOperationInfo          info   = infos[i];
             final CoordinateReferenceSystem source = stepComponents[i];
-            final CoordinateReferenceSystem target = targetComponents.get(info.targetComponentIndex);
-            canStoreInCache &= info.canStoreInCache;
+            final CoordinateReferenceSystem target = info.targetComponent;
+            canStoreInCache &= info.canStoreInCache();
             /*
              * In order to compute `stepTargetCRS`, replace in-place a single element in `stepComponents`.
              * For each step except the last one, `stepTargetCRS` is a mix of target CRS and source CRS.
@@ -1008,16 +1015,14 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
          * source dimensions, add those constants as the last step. It never occur except is some particular
          * contexts like when computing a transform between two `GridGeometry` instances.
          */
-        if (stepComponents.length < infos.length) try {
-            final Matrix m = SubOperationInfo.createConstantOperation(infos,
+        if (stepComponents.length < infos.length) {
+            final Matrix m = SubOperationInfo.createConstantOperation(context, infos,
                     stepSourceCRS.getCoordinateSystem().getDimension(),
                         targetCRS.getCoordinateSystem().getDimension());
             operation = concatenate(operation, createFromAffineTransform(CONSTANTS, stepSourceCRS, targetCRS, null, m));
             for (int i = stepComponents.length; i < infos.length; i++) {
-                canStoreInCache &= infos[i].canStoreInCache;
+                canStoreInCache &= infos[i].canStoreInCache();
             }
-        } catch (TransformException e) {
-            throw new FactoryException(notFoundMessage(sourceCRS, targetCRS), e);
         }
         return asList(operation);
     }
@@ -1182,7 +1187,7 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
         if (isIdentity(step3)) return concatenate(step1, step2);
         if (canHide(step1.getName())) return concatenate(concatenate(step1, step2), step3);
         if (canHide(step3.getName())) return concatenate(step1, concatenate(step2, step3));
-        final Map<String,?> properties = defaultName(step1.getSourceCRS(), step3.getTargetCRS());
+        final Map<String, ?> properties = defaultName(step1.getSourceCRS(), step3.getTargetCRS());
         return factory.createConcatenatedOperation(properties, null, null, step1, step2, step3);
     }
 
@@ -1223,7 +1228,7 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
     /**
      * Returns the given name in a singleton map.
      */
-    private static Map<String,?> properties(final String name) {
+    private static Map<String, ?> properties(final String name) {
         return Map.of(IdentifiedObject.NAME_KEY, name);
     }
 
@@ -1233,7 +1238,7 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
      * where "(step 1)" may be replaced by "(step 2)", "(step 3)", <i>etc.</i> if this
      * method has already been invoked for the same identifier (directly or indirectly).
      */
-    private Map<String,?> derivedFrom(final IdentifiedObject object) {
+    private Map<String, ?> derivedFrom(final IdentifiedObject object) {
         Identifier oldID = object.getName();
         Object p = identifierOfStepCRS.get(oldID);
         if (p instanceof Identifier) {
@@ -1245,7 +1250,7 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
         identifierOfStepCRS.put(newID, oldID);
         identifierOfStepCRS.put(oldID, count);
 
-        final Map<String,Object> properties = properties(newID);
+        final Map<String, Object> properties = properties(newID);
         properties.put(IdentifiedObject.REMARKS_KEY, Vocabulary.formatInternational(Vocabulary.Keys.DerivedFrom_1, label(object)));
         return properties;
     }
@@ -1253,7 +1258,7 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
     /**
      * Returns a name for a transformation between two CRS.
      */
-    private static Map<String,?> defaultName(CoordinateReferenceSystem source, CoordinateReferenceSystem target) {
+    private static Map<String, ?> defaultName(CoordinateReferenceSystem source, CoordinateReferenceSystem target) {
         return properties(new CRSPair(source, target).toString());
     }
 
@@ -1278,8 +1283,12 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
      *
      * @see #datumChangeNotFound(IdentifiedObject, IdentifiedObject)
      */
-    private String notFoundMessage(final CoordinateReferenceSystem source, final CoordinateReferenceSystem target) {
-        return resources().getString(Resources.Keys.CoordinateOperationNotFound_2, label(source), label(target));
+    final String notFoundMessage(final CoordinateReferenceSystem source, final CoordinateReferenceSystem target) {
+        final Locale locale = getLocale();
+        return Resources.forLocale(locale).getString(
+                Resources.Keys.CoordinateOperationNotFound_2,
+                CRSPair.label(source, locale),
+                CRSPair.label(target, locale));
     }
 
     /**
@@ -1290,7 +1299,10 @@ public class CoordinateOperationFinder extends CoordinateOperationRegistry {
      * @return a default error message.
      */
     private String canNotInvert(final DerivedCRS crs) {
-        return resources().getString(Resources.Keys.NonInvertibleOperation_1, label(crs.getConversionFromBase()));
+        final Locale locale = getLocale();
+        return Resources.forLocale(locale).getString(
+                Resources.Keys.NonInvertibleOperation_1,
+                CRSPair.label(crs.getConversionFromBase(), locale));
     }
 
     /**
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationRegistry.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationRegistry.java
index af31347efd..bb4f667fdc 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationRegistry.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationRegistry.java
@@ -75,7 +75,6 @@ import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.ComparisonMode;
 import org.apache.sis.util.Utilities;
 import org.apache.sis.util.Deprecable;
-import org.apache.sis.util.logging.Logging;
 import org.apache.sis.util.collection.Containers;
 import org.apache.sis.util.collection.BackingStoreException;
 import org.apache.sis.util.resources.Vocabulary;
@@ -212,6 +211,14 @@ class CoordinateOperationRegistry {
      */
     final DefaultCoordinateOperationFactory factorySIS;
 
+    /**
+     * Options such as initial area of interest, desired accuracy and coordinates that may be considered as constants.
+     * This is null if no context has been specified.
+     *
+     * @since 1.6
+     */
+    protected final CoordinateOperationContext context;
+
     /**
      * The spatiotemporal area of interest, or {@code null} if none.
      * When a new {@code CoordinateOperationFinder} instance is created with a non-null
@@ -239,14 +246,6 @@ class CoordinateOperationRegistry {
      */
     boolean stopAtFirst;
 
-    /**
-     * A filter that can be used for applying additional restrictions on the coordinate operation,
-     * or {@code null} if none. If non-null, only operations passing this filter will be considered.
-     *
-     * @see CoordinateOperationContext#getOperationFilter()
-     */
-    private Predicate<CoordinateOperation> filter;
-
     /**
      * Authority codes found for CRS. This is a cache for {@link #findCode(CoordinateReferenceSystem)}.
      * This map may be non-empty only if {@link #codeFinder} is non-null.
@@ -262,13 +261,6 @@ class CoordinateOperationRegistry {
      */
     private final Map<CoordinateReferenceSystem, List<String>> authorityCodes;
 
-    /**
-     * The locale for error messages.
-     *
-     * @todo Add a setter method, or make configurable in some way.
-     */
-    private Locale locale;
-
     /**
      * Creates a new instance for the given factory and context.
      *
@@ -302,10 +294,10 @@ class CoordinateOperationRegistry {
             }
         }
         this.authorityCodes = authorityCodes;
+        this.context = context;
         if (context != null) {
             areaOfInterest  = context.getAreaOfInterest();
             desiredAccuracy = context.getDesiredAccuracy();
-            filter          = context.getOperationFilter();
         }
     }
 
@@ -701,7 +693,7 @@ class CoordinateOperationRegistry {
                 continue;                                   // Try again with the next best case.
             }
             /*
-             * It is possible that the CRS given to this method were not quite right.  For example, the user
+             * It is possible that the CRSs given to this method were not quite right. For example, the user
              * may have created his CRS from a WKT using a different axis order than the order specified by
              * the authority and still (wrongly) call those CRS "EPSG:xxxx".  So we check if the source and
              * target CRS for the operation we just created are equivalent to the CRS specified by the user.
@@ -713,6 +705,7 @@ class CoordinateOperationRegistry {
              * FactoryException propagate.
              */
             operation = complete(operation, sourceCRS, targetCRS);
+            final Predicate<CoordinateOperation> filter = (context != null) ? context.getOperationFilter() : null;
             if (filter == null || filter.test(operation)) {
                 if (stopAtFirst) {
                     operations.clear();
@@ -754,7 +747,7 @@ class CoordinateOperationRegistry {
         final CoordinateReferenceSystem sourceCRS = op.getSourceCRS();
         final CoordinateReferenceSystem targetCRS = op.getTargetCRS();
         final MathTransform transform = op.getMathTransform().inverse();
-        final OperationMethod method = InverseOperationMethod.create(op.getMethod(), factorySIS);
+        final OperationMethod method = InverseOperationMethod.create(op.getMethod(), this);
         final Map<String,Object> properties = properties(INVERSE_OPERATION);
         InverseOperationMethod.properties(op, properties);
         inverse = createFromMathTransform(properties, targetCRS, sourceCRS, transform, method, null, typeOf(op));
@@ -1388,13 +1381,22 @@ class CoordinateOperationRegistry {
         return null;
     }
 
+    /**
+     * Returns the locale to use for error messages.
+     *
+     * @return the locale, or {@code null} for the default locale.
+     */
+    final Locale getLocale() {
+        return (context != null) ? context.getLocale() : null;
+    }
+
     /**
      * Returns the localized resources for error messages.
      *
      * @return localized resources for errors.
      */
     final Resources resources() {
-        return Resources.forLocale(locale);
+        return Resources.forLocale(getLocale());
     }
 
     /**
@@ -1404,7 +1406,7 @@ class CoordinateOperationRegistry {
      * @return label identifying the given object.
      */
     final String label(final IdentifiedObject object) {
-        return CRSPair.label(object, locale);
+        return CRSPair.label(object, getLocale());
     }
 
     /**
@@ -1416,45 +1418,51 @@ class CoordinateOperationRegistry {
      * @return a default error message.
      */
     final String datumChangeNotFound(final IdentifiedObject source, final IdentifiedObject target) {
-        return resources().getString(Resources.Keys.DatumChangeNotFound_2,
+        final Locale locale = getLocale();
+        return Resources.forLocale(locale).getString(Resources.Keys.DatumChangeNotFound_2,
                 IdentifiedObjects.getDisplayName(source, locale),
                 IdentifiedObjects.getDisplayName(target, locale));
     }
 
     /**
-     * Logs an unexpected but ignorable exception. This method pretends that the logging
-     * come from {@link CoordinateOperationFinder} since this is the public API which
-     * use this {@code CoordinateOperationRegistry} class.
+     * Logs an unexpected but ignorable exception. This method pretends that the logging come from
+     * some {@link CoordinateOperationFinder} method because this is the public <abbr>API</abbr>
+     * which uses this {@code CoordinateOperationRegistry} class.
      *
      * @param record     the record to log, or {@code null} for creating from the exception.
-     * @param exception  the exception which occurred, or {@code null} if a {@code record} is specified instead.
+     * @param exception  the exception that occurred, or {@code null} if a {@code record} is specified instead.
      */
-    private static void log(LogRecord record, final Exception exception) {
+    private void log(LogRecord record, final Exception exception) {
         if (record == null) {
             record = new LogRecord(Level.WARNING, exception.getLocalizedMessage());
+            /*
+             * We usually do not log the stack trace because this method is invoked mostly for exceptions
+             * such as `NoSuchAuthorityCodeException` or `MissingFactoryResourceException`, for which the
+             * message is descriptive enough. But we make a special case for `NoninvertibleTransformException`
+             * because its cause may have deeper root.
+             */
+            if (exception instanceof NoninvertibleTransformException) {
+                record.setThrown(exception);
+            }
         }
-        /*
-         * We usually do not log the stack trace because this method should be invoked only for exceptions
-         * such as NoSuchAuthorityCodeException or MissingFactoryResourceException, for which the message
-         * is descriptive enough. But we make a special case for NoninvertibleTransformException because
-         * its cause may have deeper root.
-         */
-        if (exception instanceof NoninvertibleTransformException) {
-            record.setThrown(exception);
+        if (record.getSourceMethodName() == null) {
+            record.setSourceMethodName("createOperations");
         }
-        Logging.completeAndLog(AbstractCoordinateOperation.LOGGER, CoordinateOperationFinder.class, "createOperations", record);
+        CoordinateOperationContext.log(context, CoordinateOperationFinder.class, AbstractCoordinateOperation.LOGGER, record);
     }
 
     /**
      * Logs an ignorable exception. This method pretends that the logging come from
-     * {@link CoordinateOperationFinder} since this is the public API which use this
-     * {@code CoordinateOperationRegistry} class.
+     * {@link CoordinateOperationFinder} because this is the public <abbr>API</abbr>
+     * which uses this {@code CoordinateOperationRegistry} class.
      *
      * @param  method     the method name where the error occurred.
-     * @param  exception  the exception which occurred, or {@code null} if a {@code record} is specified instead.
+     * @param  exception  the exception that occurred.
      */
-    static void recoverableException(final String method, final Exception exception) {
-        Logging.recoverableException(AbstractCoordinateOperation.LOGGER,
-                CoordinateOperationFinder.class, method, exception);
+    final void recoverableException(final String method, final Exception exception) {
+        final var record = new LogRecord(Level.FINE, exception.getLocalizedMessage());
+        record.setThrown(exception);
+        record.setSourceMethodName(method);
+        log(record, null);
     }
 }
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/InverseOperationMethod.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/InverseOperationMethod.java
index 50a5a0d9c0..e71d98b793 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/InverseOperationMethod.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/InverseOperationMethod.java
@@ -76,7 +76,7 @@ final class InverseOperationMethod extends DefaultOperationMethod {
      * for the inverse operation either with the exact same parameter values or with the sign of some values
      * reversed, then the given method is returned as-is. Otherwise a synthetic method is created.
      */
-    static OperationMethod create(OperationMethod method, final DefaultCoordinateOperationFactory factorySIS)
+    static OperationMethod create(OperationMethod method, final CoordinateOperationRegistry caller)
             throws FactoryException
     {
         if (method instanceof InverseOperationMethod) {
@@ -84,10 +84,10 @@ final class InverseOperationMethod extends DefaultOperationMethod {
         }
         if (!(method instanceof AbstractProvider)) try {
             method = CoordinateOperations.findMethod(
-                    factorySIS.getMathTransformFactory(),
+                    caller.factorySIS.getMathTransformFactory(),
                     method.getName().getCode());
         } catch (NoSuchIdentifierException e) {
-            CoordinateOperationRegistry.recoverableException("inverse", e);
+            caller.recoverableException("inverse", e);
         }
         if (method instanceof AbstractProvider) {
             final AbstractProvider inverse = ((AbstractProvider) method).inverse();
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MismatchedDatumException.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MismatchedDatumException.java
index 6e8666c6e6..a4bb586cb0 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MismatchedDatumException.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MismatchedDatumException.java
@@ -18,19 +18,19 @@ package org.apache.sis.referencing.operation;
 
 
 /**
- * Thrown when the source and target CRS of a {@linkplain DefaultConversion conversion} use different datum.
- * By definition, conversions do not perform any change of datum
+ * Thrown when the source and target <abbr>CRS</abbr> of a coordinate conversion use different datum.
+ * By definition, {@linkplain DefaultConversion conversions} do not perform any change of datum
  * while {@linkplain DefaultTransformation transformations} can do.
  *
  * <h2>Usage note</h2>
- * SIS is tolerant about different datum at {@code DefaultConversion} construction time,
+ * Apache <abbr>SIS</abbr> is tolerant about different datum at {@code DefaultConversion} construction time,
  * for the reasons explained in {@linkplain DefaultConversion#DefaultConversion(java.util.Map,
  * org.opengis.referencing.crs.CoordinateReferenceSystem,
  * org.opengis.referencing.crs.CoordinateReferenceSystem,
  * org.opengis.referencing.crs.CoordinateReferenceSystem,
  * org.opengis.referencing.operation.OperationMethod,
  * org.opengis.referencing.operation.MathTransform) its constructor}.
- * However, SIS is stricter at {@linkplain org.apache.sis.referencing.crs.DefaultDerivedCRS Derived CRS}
+ * However, <abbr>SIS</abbr> is stricter at {@linkplain org.apache.sis.referencing.crs.DefaultDerivedCRS Derived CRS}
  * construction time.
  *
  * @author  Martin Desruisseaux (Geomatys)
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MissingSourceDimensionsException.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MissingSourceDimensionsException.java
new file mode 100644
index 0000000000..690f82ecc0
--- /dev/null
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MissingSourceDimensionsException.java
@@ -0,0 +1,121 @@
+/*
+ * 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.referencing.operation;
+
+import java.util.Set;
+import java.util.Collections;
+import org.apache.sis.referencing.internal.shared.AxisDirections;
+import org.opengis.referencing.cs.AxisDirection;
+import org.opengis.referencing.cs.CoordinateSystem;
+import org.opengis.referencing.operation.OperationNotFoundException;
+import org.apache.sis.referencing.operation.matrix.UnderdeterminedMatrixException;
+import org.apache.sis.util.collection.CodeListSet;
+
+
+/**
+ * Thrown when a coordinate operation cannot be find because some dimensions are missing in the source <abbr>CRS</abbr>.
+ * The missing dimensions are identified by the directions of the missing axes. For example, if the coordinate system of
+ * the source <abbr>CRS</abbr> does not have a temporal axis which was requested by the target <abbr>CRS</abbr>,
+ * then the {@link #getMissingAxes()} should contain {@link AxisDirection#FUTURE} or {@link AxisDirection#PAST}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.6
+ * @since   1.6
+ */
+public class MissingSourceDimensionsException extends OperationNotFoundException {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = 5228802326932593847L;
+
+    /**
+     * The unknown axes identified by their directions, created when first needed.
+     */
+    private CodeListSet<AxisDirection> axes;
+
+    /**
+     * Constructs a new exception with no message.
+     */
+    public MissingSourceDimensionsException() {
+        super();
+    }
+
+    /**
+     * Constructs a new exception with the specified detail message.
+     *
+     * @param message  the detail message, or {@code null} if none.
+     */
+    public MissingSourceDimensionsException(final String message) {
+        super(message);
+    }
+
+    /**
+     * Constructs a new exception with the specified detail message and cause.
+     *
+     * @param message  the detail message, or {@code null} if none.
+     * @param cause    the cause, or {@code null} if none.
+     */
+    public MissingSourceDimensionsException(final String message, final Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Adds all axis directions of the target coordinate system
+     * that are not found in the source coordinate system.
+     */
+    final void addMissing(final CoordinateSystem source, final CoordinateSystem target) {
+        for (int i = target.getDimension(); --i >= 0;) {
+            AxisDirection direction = target.getAxis(i).getDirection();
+            if (AxisDirections.indexOfColinear(source, direction) < 0) {
+                addMissing(direction);
+            }
+        }
+    }
+
+    /**
+     * Indicates which dimension was missing, specified as an axis direction.
+     *
+     * @param  direction  direction of the missing axis.
+     */
+    public void addMissing(final AxisDirection direction) {
+        if (axes == null) {
+            axes = new CodeListSet<>(AxisDirection.class);
+            final Throwable cause = getCause();
+            if (cause instanceof UnderdeterminedMatrixException) {
+                axes.addAll(((UnderdeterminedMatrixException) cause).getUnknownAxes());
+            }
+        }
+        axes.add(direction);
+    }
+
+    /**
+     * Indices which axes were missing in the source coordinate system.
+     * The returned set does not need to be exhaustive and may be empty if this information is not available.
+     *
+     * @return directions of axes that are missing in the source coordinate system.
+     */
+    public Set<AxisDirection> getMissingAxes() {
+        if (axes != null) {
+            return Collections.unmodifiableSet(axes);
+        }
+        final Throwable cause = getCause();
+        if (cause instanceof UnderdeterminedMatrixException) {
+            return ((UnderdeterminedMatrixException) cause).getUnknownAxes();
+        }
+        return Set.of();
+    }
+}
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/SubOperationInfo.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/SubOperationInfo.java
index 17a604bd58..153c5c23d0 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/SubOperationInfo.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/SubOperationInfo.java
@@ -18,12 +18,15 @@ package org.apache.sis.referencing.operation;
 
 import java.util.List;
 import org.opengis.referencing.crs.*;
+import org.opengis.referencing.cs.CoordinateSystemAxis;
 import org.opengis.referencing.operation.Matrix;
 import org.opengis.referencing.operation.CoordinateOperation;
 import org.opengis.referencing.operation.OperationNotFoundException;
 import org.opengis.referencing.operation.NoninvertibleTransformException;
 import org.opengis.referencing.operation.TransformException;
+import org.opengis.geometry.DirectPosition;
 import org.opengis.util.FactoryException;
+import org.apache.sis.referencing.CRS;
 import org.apache.sis.referencing.internal.Resources;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.matrix.MatrixSIS;
@@ -79,31 +82,28 @@ final class SubOperationInfo {
     }
 
     /**
-     * The coordinate operation between a source CRS component and a target CRS component.
-     * Exactly one of {@link #operation} or {@link #constants} shall be non-null.
+     * The coordinate operation between a component of the source CRS and a component of the target CRS.
+     * If {@code null}, then {@link CoordinateOperationContext#getConstantCoordinates()} shall return a
+     * non-null value, otherwise a {@link MissingSourceDimensionsException} will be thrown.
      */
     final CoordinateOperation operation;
 
     /**
-     * The constant values to store in target coordinates, or {@code null} if none. This array is usually null.
-     * It may be non-null if no source CRS component has been found for a target CRS component.
-     * Exactly one of {@link #operation} or {@link #constants} shall be non-null.
+     * The constant values to store in target coordinates, or {@code null} if none.
+     * This property is usually null, but may be non-null if no component of the source <abbr>CRS</abbr>
+     * has been found for a component of the target <abbr>CRS</abbr>, and fallbacks were specified with
+     * {@link CoordinateOperationContext#getConstantCoordinates()}.
      *
-     * <p>The array length is the number of dimensions in the full (usually compound) target CRS, but only the
-     * coordinate values for sourceless dimensions are used. Other coordinates are ignored and can be NaN.</p>
-     *
-     * <h4>Example</h4>
-     * If the source <abbr>CRS</abbr> has (<var>x</var>, <var>y</var>) axes and the target <abbr>CRS</abbr>
-     * has (<var>x</var>, <var>y</var>, <var>t</var>) axes, then this array may be set to a non-null value
-     * for specifying the <var>t</var> value.
+     * <p>The array length is the number of dimensions in the <em>component</em> of the target <abbr>CRS</abbr>
+     * for which this {@code SubOperationInfo} is constructed. These coordinates cannot be <abbr>NaN</abbr>.</p>
      *
      * @see CoordinateOperationContext#getConstantCoordinates()
      */
-    private final double[] constants;
+    private double[] constantCoordinates;
 
     /**
-     * The first dimension (inclusive) and the last dimension (exclusive) where the {@link SingleCRS} starts/ends
-     * in the full (usually compound) CRS.
+     * The first dimension (inclusive) and the last dimension (exclusive) where the
+     * {@link SingleCRS} starts/ends in the full (usually compound) <abbr>CRS</abbr>.
      *
      * @see #sourceToSelected(int, SubOperationInfo[])
      */
@@ -117,45 +117,48 @@ final class SubOperationInfo {
     final int targetComponentIndex;
 
     /**
-     * Whether this operation can be cached. This flag shall be {@code false} if
-     * the operation depends on parameters that may vary between two executions.
+     * The component of the target <abbr>CRS</abbr> which is managed by this {@code SubOperationInfo}.
+     * This is the component at the index specified by {@link #targetComponentIndex}.
      */
-    final boolean canStoreInCache;
+    final SingleCRS targetComponent;
 
     /**
-     * Creates a new instance wrapping the given coordinate operation or coordinate constants.
-     * Exactly one of {@code operation} or {@code constants} shall be non-null.
+     * Creates a new instance wrapping the given coordinate operation or constant coordinates.
+     * The constant coordinates are fetched after construction if they appear needed.
+     *
+     * @see #fetchConstantsForMissingSourceDimensions(CoordinateOperationContext)
      */
-    private SubOperationInfo(final CoordinateOperation operation, final double[] constants,
+    private SubOperationInfo(final CoordinateOperation operation,
                              final int sourceLowerDimension, final int sourceUpperDimension,
                              final int targetLowerDimension, final int targetUpperDimension,
-                             final int targetComponentIndex, final boolean canStoreInCache)
+                             final int targetComponentIndex, final SingleCRS targetComponent)
     {
         this.operation            = operation;
-        this.constants            = constants;
         this.sourceLowerDimension = sourceLowerDimension;
         this.sourceUpperDimension = sourceUpperDimension;
         this.targetLowerDimension = targetLowerDimension;
         this.targetUpperDimension = targetUpperDimension;
         this.targetComponentIndex = targetComponentIndex;
-        this.canStoreInCache      = canStoreInCache;
-        assert (operation == null) != (constants == null);
+        this.targetComponent      = targetComponent;
     }
 
     /**
      * Searches in given list of source components for operations capable to transform coordinates to each target CRS.
-     * There is one {@code SubOperationInfo} per target CRS because we need to satisfy all target dimensions, while it
-     * is okay to ignore some source dimensions. If an operation cannot be found, then this method returns {@code null}.
+     * There is one {@code SubOperationInfo} instance per target <abbr>CRS</abbr> because we need to satisfy all target
+     * dimensions. However, it is okay to ignore some components or dimensions of the source <abbr>CRS</abbr>.
      *
-     * @param  caller   the object which is inferring a coordinate operation.
-     * @param  sources  all components of the source CRS.
-     * @param  targets  all components of the target CRS.
-     * @return information about each coordinate operation from a source CRS to a target CRS, or {@code null}.
+     * @param  caller     the object which is inferring a coordinate operation.
+     * @param  sourceCRS  the source source <abbr>CRS</abbr>, used for error message.
+     * @param  sources    all components of the source <abbr>CRS</abbr>.
+     * @param  targets    all components of the target <abbr>CRS</abbr>.
+     * @return information about each coordinate operation from a source <abbr>CRS</abbr> to a target <abbr>CRS</abbr>.
      * @throws FactoryException if an error occurred while grabbing a coordinate operation.
+     * @throws MissingSourceDimensionsException if a target axis cannot be mapped to a source axis or a constant.
      * @throws TransformException if an error occurred while computing a coordinate value for unmatched dimension.
      * @throws NoninvertibleTransformException if the default coordinate value for a unmatched dimension is NaN.
      */
     static SubOperationInfo[] createSteps(final CoordinateOperationFinder caller,
+                                          final CoordinateReferenceSystem sourceCRS,
                                           final List<? extends SingleCRS> sources,
                                           final List<? extends SingleCRS> targets)
             throws FactoryException, TransformException
@@ -163,108 +166,137 @@ final class SubOperationInfo {
         final var infos = new SubOperationInfo[targets.size()];
         final var sourceComponentIsUsed = new boolean[sources.size()];
         /*
-         * Iterate over target CRS because all of them must have an operation.
-         * One the other hand, source CRS can be used zero or one (not two) time.
+         * Each target CRS must be associated to exactly one source CRS
+         * (or to zero source CRS if the missing source CRS can be replaced by constant coordinate values).
+         * Each source CRS must be associated to zero or one target CRS, not necessarily in the same order.
+         * Therefore, the iteration over source components will be repeated for each target CRS, combined
+         * with `sourceComponentIsUsed` flags for using each source CRS at most once.
          */
         int targetLowerDimension;
         int targetUpperDimension = 0;
-next:   for (int targetComponentIndex = 0; targetComponentIndex < infos.length; targetComponentIndex++) {
-            final SingleCRS target = targets.get(targetComponentIndex);
+        int targetComponentIndex = 0;
+        while (targetComponentIndex < infos.length) {
+            final SingleCRS targetComponent = targets.get(targetComponentIndex);
             targetLowerDimension  = targetUpperDimension;
-            targetUpperDimension += target.getCoordinateSystem().getDimension();
+            targetUpperDimension += targetComponent.getCoordinateSystem().getDimension();
 
-            final Class<?> targetType = type(target);
+            final Class<?> targetType = type(targetComponent);
             OperationNotFoundException failure = null;
+            CoordinateOperation operation = null;
             /*
              * For each target CRS, search for a source CRS which has not yet been used.
              * Some sources may be left unused after this method completion. Check only
              * sources that may be compatible according the `COMPATIBLE_TYPES` array.
              */
-            int sourceLowerDimension;
+            int sourceLowerDimension = 0;
             int sourceUpperDimension = 0;
-            for (int sourceComponentIndex = 0; sourceComponentIndex < sourceComponentIsUsed.length; sourceComponentIndex++) {
-                final SingleCRS source = sources.get(sourceComponentIndex);
-                sourceLowerDimension  = sourceUpperDimension;
-                sourceUpperDimension += source.getCoordinateSystem().getDimension();
+            int sourceComponentIndex = 0;
+searchSrc:  while (sourceComponentIndex < sourceComponentIsUsed.length) {
+                final SingleCRS sourceComponent = sources.get(sourceComponentIndex);
+                sourceUpperDimension += sourceComponent.getCoordinateSystem().getDimension();
                 if (!sourceComponentIsUsed[sourceComponentIndex]) {
-                    final Class<?> sourceType = type(source);
+                    final Class<?> sourceType = type(sourceComponent);
                     for (final Class<?>[] compatibleTypes : COMPATIBLE_TYPES) {
                         if (compatibleTypes[0].isAssignableFrom(targetType)) {
                             for (final Class<?> compatibleType : compatibleTypes) {
-                                if (compatibleType.isAssignableFrom(sourceType)) {
-                                    final CoordinateOperation operation;
-                                    try {
-                                        operation = caller.createOperation(source, target);
-                                    } catch (OperationNotFoundException exception) {
-                                        if (failure == null) {
-                                            failure = exception;
-                                        } else {
-                                            failure.addSuppressed(exception);
-                                        }
-                                        continue;
-                                    }
-                                    /*
-                                     * Found an operation. Exclude the source component from the list because each source
-                                     * should be used at most once by `SubOperationInfo`. Note that the same source may
-                                     * be used again in another context if that source is also an interpolation CRS.
-                                     *
-                                     * EXAMPLE: consider a coordinate operation from (GeodeticCRS₁, VerticalCRS₁) source
-                                     * to (GeodeticCRS₂, VerticalCRS₂) target.  The source GeodeticCRS₁ should be mapped
-                                     * to exactly one target component (which is GeodeticCRS₂)  and  VerticalCRS₁ mapped
-                                     * to VerticalCRS₂.  But the operation on vertical coordinates may need GeodeticCRS₁
-                                     * for doing its work, so GeodeticCRS₁ is needed twice. However, when needed for the
-                                     * vertical coordinate operation, the GeodeticCRS₁ is used as an "interpolation CRS".
-                                     * Interpolation CRS are handled in other code paths, it is not the business of this
-                                     * `SubOperationInfo` class to care about them. From the point of view of this class,
-                                     * GeodeticCRS₁ is used only once.
-                                     */
+                                if (compatibleType.isAssignableFrom(sourceType)) try {
+                                    operation = caller.createOperation(sourceComponent, targetComponent);
                                     sourceComponentIsUsed[sourceComponentIndex] = true;
-                                    infos[targetComponentIndex] = new SubOperationInfo(
-                                            operation, null,
-                                            sourceLowerDimension, sourceUpperDimension,
-                                            targetLowerDimension, targetUpperDimension,
-                                            targetComponentIndex, true);
-
-                                    if (failure != null) {
-                                        CoordinateOperationRegistry.recoverableException("decompose", failure);
+                                    break searchSrc;
+                                } catch (OperationNotFoundException exception) {
+                                    if (failure == null) {
+                                        failure = exception;
+                                    } else {
+                                        failure.addSuppressed(exception);
                                     }
-                                    continue next;
                                 }
                             }
                         }
                     }
                 }
+                // Following variable must be updated before to stop the loop. Therefore, it cannot be at loop beginning.
+                sourceLowerDimension = sourceUpperDimension;
+                sourceComponentIndex++;
             }
             if (failure != null) {
-                throw failure;
+                if (operation == null) {
+                    throw failure;
+                }
+                caller.recoverableException("createOperationStep", failure);
             }
+            final var info = new SubOperationInfo(operation,
+                    sourceLowerDimension, sourceUpperDimension,
+                    targetLowerDimension, targetUpperDimension,
+                    targetComponentIndex, targetComponent);
             /*
-             * If we reach this point, we have not been able to find a source CRS that we can map to the target CRS.
-             * Usually this is fatal, returning null will instruct the caller to throw `OperationNotFoundException`.
+             * If we enter in the following block, we have not been able to find a source for the target component.
+             * Usually, we don't know how to get coordinate values, so `MissingSourceDimensionsException` is thrown.
              * However, in some contexts (e.g. when searching for an operation between two `GridGeometry` instances)
              * it is possible to assign a constant value to the target coordinates. Those values cannot be guessed
-             * by `org.apache.sis.referencing`; they must be provided by caller. If such constants are specified,
-             * then we will try to apply them.
+             * by `org.apache.sis.referencing`, they must be provided by the user.
              */
-            final double[] constants = CoordinateOperationContext.getConstantCoordinates();
-            if (constants == null || constants.length < targetUpperDimension) {
-                return null;
-            }
-            for (int i = targetLowerDimension; i < targetUpperDimension; i++) {
-                if (Double.isNaN(constants[i])) {
-                    throw new NoninvertibleTransformException(caller.resources()
-                            .getString(Resources.Keys.ConstantCoordinateValueRequired_1, i));
+            if (operation == null) {
+                final CoordinateSystemAxis missing = info.fetchConstantsForMissingSourceDimensions(caller.context);
+                if (info.constantCoordinates == null) {
+                    final MissingSourceDimensionsException e;
+                    if (missing != null) {
+                        e = new MissingSourceDimensionsException(caller.resources().getString(
+                                Resources.Keys.ConstantCoordinateValueRequired_1, caller.label(missing)));
+                        e.addMissing(missing.getDirection());
+                    } else {
+                        e = new MissingSourceDimensionsException(caller.notFoundMessage(sourceCRS, targetComponent));
+                        e.addMissing(sourceCRS.getCoordinateSystem(), targetComponent.getCoordinateSystem());
+                    }
+                    throw e;
                 }
             }
-            infos[targetComponentIndex] = new SubOperationInfo(
-                    null, constants,
-                    sourceUpperDimension, sourceUpperDimension,
-                    targetLowerDimension, targetUpperDimension,
-                    targetComponentIndex, false);
+            infos[targetComponentIndex++] = info;
         }
         return infos;
     }
 
+    /**
+     * Initializes the {@link #constantCoordinates} field to the constant coordinate values for the operation step
+     * managed by this {@code SubOperationInfo}. If no constants have been specified, {@link #constantCoordinates}
+     * field stay null and the reason for the failure is returned. That reason is an axis missing in the source
+     * <abbr>CRS</abbr>, or {@code null} if that axis is unknown.
+     *
+     * @param  context  options supplied by the user, or {@code null}.
+     * @return the coordinate axis that could not be resolved, or {@code null} if none or unknown.
+     */
+    private CoordinateSystemAxis fetchConstantsForMissingSourceDimensions(final CoordinateOperationContext context) {
+        if (context != null) {
+            final DirectPosition coordinates = context.getConstantCoordinates();
+            if (coordinates != null) {
+                /*
+                 * Finds the index of the first coordinate to use among the constant coordinates.
+                 * The default CRS of `coordinates` is the full target CRS (with all dimensions).
+                 * If a different CRS is specified, search the index of this target CRS component.
+                 * No coordinate transformation is perfomed in this method, only selection.
+                 */
+                int indexOfConstant = targetLowerDimension;     // Value for the default CRS.
+                final CoordinateReferenceSystem crs = coordinates.getCoordinateReferenceSystem();
+locate:         if (crs != null) {
+                    indexOfConstant = 0;
+                    for (SingleCRS component : CRS.getSingleComponents(crs)) {
+                        if (CRS.equivalent(targetComponent, component)) break locate;
+                        indexOfConstant += component.getCoordinateSystem().getDimension();
+                    }
+                    return null;
+                }
+                final int d = coordinates.getDimension();
+                final var c = new double[targetUpperDimension - targetLowerDimension];
+                for (int i=0; i<c.length; i++) {
+                    if (indexOfConstant >= d || Double.isNaN(c[i] = coordinates.getCoordinate(indexOfConstant++))) {
+                        return targetComponent.getCoordinateSystem().getAxis(i);
+                    }
+                }
+                constantCoordinates = c;
+            }
+        }
+        return null;
+    }
+
     /**
      * Returns the source CRS of given operations. This method modifies the given array in-place by moving all
      * sourceless operations last. Then an array is returned with the source CRS of only ordinary operations.
@@ -375,59 +407,84 @@ next:   for (int targetComponentIndex = 0; targetComponentIndex < infos.length;
 
     /**
      * Returns the matrix of an operation setting some coordinates to constant values.
+     * This is possible only if the user as provided a non-null value for
+     * {@link CoordinateOperationContext#getConstantCoordinates()}.
      *
+     * @param  context   options supplied by the user, or {@code null}.
      * @param  selected  all operations from source to target {@link CompoundCRS}.
      * @param  srcDim    number of dimensions in the target CRS of previous operation step.
      * @param  tgtDim    number of dimensions in the full (usually compound) target CRS.
      */
-    static MatrixSIS createConstantOperation(final SubOperationInfo[] selected, final int srcDim, final int tgtDim)
-            throws TransformException
+    static MatrixSIS createConstantOperation(final CoordinateOperationContext context,
+            final SubOperationInfo[] selected, final int srcDim, final int tgtDim)
+            throws MissingSourceDimensionsException
     {
-        final boolean[] targetDimensionIsUsed = new boolean[tgtDim];
-        final MatrixSIS m = Matrices.createZero(tgtDim + 1, srcDim + 1);
-        m.setElement(tgtDim, srcDim, 1);
+        final MatrixSIS matrix = Matrices.createZero(tgtDim + 1, srcDim + 1);
+        matrix.setElement(tgtDim, srcDim, 1);
         for (final SubOperationInfo component : selected) {
-            if (component.constants != null) {
-                /*
-                 * Component for which no operation has been found.
-                 * Set all coordinate values of that component to the specified constants
-                 */
-                for (int j = component.targetLowerDimension; j < component.targetUpperDimension; j++) {
-                    m.setElement(j, srcDim, component.constants[j]);
-                    targetDimensionIsUsed[j] = true;
-                }
-            } else {
-                /*
-                 * Component for which an operation has been found, but maybe this is the inverse of the
-                 * "Geographic 3D to 2D conversion" (EPSG:9659) which sets the height to `DEFAULT_HEIGHT`.
-                 * If all values in the row are zero, then the coordinate value is unconditionally zero.
-                 * Replace that value (usually the default ellipsoidal height) by the specified constant.
-                 */
-                final Matrix last = MathTransforms.getMatrix(MathTransforms.getLastStep(component.operation.getMathTransform()));
-                if (last != null) {
+            component.setConstantTerms(context, matrix, srcDim);
+        }
+        return matrix;
+    }
+
+    /**
+     * Sets the coefficients of the given matrix to the constant coordinate values
+     * in the dimensions managed by this {@code SubOperationInfo}.
+     *
+     * @param  context  options supplied by the user, or {@code null}.
+     * @param  matrix   the matrix for which to set the coefficients.
+     * @param  translationColumn  index of the last column of the given matrix.
+     */
+    private void setConstantTerms(final CoordinateOperationContext context, final MatrixSIS matrix, final int translationColumn) {
+        if (constantCoordinates != null) {
+            /*
+             * Component for which no operation has been found.
+             * Set all coordinate values of that component to the specified constant values.
+             */
+            for (int j = targetUpperDimension - targetLowerDimension; --j >= 0;) {
+                matrix.setElement(targetLowerDimension + j, translationColumn, constantCoordinates[j]);
+            }
+        } else {
+            /*
+             * Component for which an operation has been found, but maybe this is the inverse of the
+             * "Geographic 3D to 2D conversion" (EPSG:9659) which sets the height to `DEFAULT_HEIGHT`.
+             * If all values in the row are zero, then the coordinate value is unconditionally zero.
+             * Replace that value (usually the default ellipsoidal height) by the specified constant.
+             */
+            final var targetDimensionIsUsed = new boolean[targetUpperDimension - targetLowerDimension];
+            final Matrix last = MathTransforms.getMatrix(MathTransforms.getLastStep(operation.getMathTransform()));
+            if (last != null) {
+                fetchConstantsForMissingSourceDimensions(context);
+                if (constantCoordinates != null) {
 otherRow:           for (int j = last.getNumRow() - 1; --j >= 0;) {     // Ignore the last row.
                         for (int i = last.getNumCol(); --i >= 0;) {
                             if (last.getElement(j, i) != 0) {
                                 continue otherRow;
                             }
                         }
-                        final double[] constants = CoordinateOperationContext.getConstantCoordinates();
-                        if (constants == null) break;
-                        final int k = component.targetLowerDimension + j;
-                        m.setElement(j, srcDim, constants[k]);
-                        targetDimensionIsUsed[k] = true;
+                        matrix.setElement(targetLowerDimension + j, translationColumn, constantCoordinates[j]);
+                        targetDimensionIsUsed[j] = true;
                     }
                 }
             }
-        }
-        /*
-         * All coordinates that have not been set to a constant shall be propagated unchanged (scale factor of 1).
-         */
-        for (int i=0,j=0; j<tgtDim; j++) {
-            if (!targetDimensionIsUsed[j]) {
-                m.setElement(j, i++, 1);
+            /*
+             * All coordinates that have not been set to a constant
+             * shall be propagated unchanged (scale factor of 1).
+             */
+            int i = sourceLowerDimension;
+            for (int j = 0; j < targetDimensionIsUsed.length; j++) {
+                if (!targetDimensionIsUsed[j]) {
+                    matrix.setElement(targetLowerDimension + j, i++, 1);
+                }
             }
         }
-        return m;
+    }
+
+    /**
+     * Whether this operation can be cached. This flag shall be {@code false} if
+     * the operation depends on parameters that may vary between two executions.
+     */
+    final boolean canStoreInCache() {
+        return constantCoordinates == null;
     }
 }
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/TransformedCoordinateSet.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/TransformedCoordinateSet.java
index e51b2228f8..eca20504d4 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/TransformedCoordinateSet.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/TransformedCoordinateSet.java
@@ -23,7 +23,6 @@ import java.util.function.Consumer;
 import java.util.function.UnaryOperator;
 import org.opengis.util.FactoryException;
 import org.opengis.geometry.DirectPosition;
-import org.opengis.metadata.extent.GeographicBoundingBox;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.referencing.CRS;
@@ -83,9 +82,9 @@ final class TransformedCoordinateSet extends AbstractCoordinateSet implements Un
         }
         final CoordinateMetadata metadata = data.getCoordinateMetadata();
         if (metadata != null) try {
-            GeographicBoundingBox aoi = CRS.getGeographicBoundingBox(op);
+            final var context = CoordinateOperationContext.fromBoundingBox(CRS.getGeographicBoundingBox(op));
             final var step = new DefaultCoordinateMetadata(op.getSourceCRS(), op.getSourceEpoch().orElse(null));
-            transform = MathTransforms.concatenate(CRS.findOperation(metadata, step, aoi).getMathTransform(), transform);
+            transform = MathTransforms.concatenate(CRS.findOperation(metadata, step, context).getMathTransform(), transform);
         } catch (FactoryException | MismatchedDimensionException e) {
             throw new TransformException(e.getMessage(), e);
         }
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/Matrices.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/Matrices.java
index 4665b95ef7..66bed3e256 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/Matrices.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/Matrices.java
@@ -272,6 +272,7 @@ public final class Matrices {
          * (NORTH,EAST) to (EAST,NORTH)), then coordinates at index {@code srcIndex} will have
          * to be moved at index {@code dstIndex}.
          */
+        UnderdeterminedMatrixException exception = null;
         for (int dstIndex = 0; dstIndex < dstAxes.length; dstIndex++) {
             boolean hasFound = false;
             final AxisDirection dstDir = dstAxes[dstIndex];
@@ -312,10 +313,16 @@ public final class Matrices {
                 }
             }
             if (!hasFound) {
-                throw new IllegalArgumentException(Resources.format(
-                        Resources.Keys.CanNotMapAxisToDirection_1, dstAxes[dstIndex]));
+                if (exception == null) {
+                    exception = new UnderdeterminedMatrixException(Resources.format(
+                            Resources.Keys.CanNotMapAxisToDirection_1, dstDir));
+                }
+                exception.addUnknown(dstDir);
             }
         }
+        if (exception != null) {
+            throw exception;
+        }
         matrix.setElement(dstAxes.length, srcAxes.length, 1);
         return matrix;
     }
@@ -439,8 +446,8 @@ public final class Matrices {
      * @param  srcAxes  the ordered sequence of axis directions for source coordinate system.
      * @param  dstAxes  the ordered sequence of axis directions for destination coordinate system.
      * @return the transform from the given source axis directions to the given target axis directions.
-     * @throws IllegalArgumentException if {@code dstAxes} contains at least one axis not found in {@code srcAxes},
-     *         or if some colinear axes were found.
+     * @throws UnderdeterminedMatrixException if {@code dstAxes} contains at least one axis not found in {@code srcAxes}.
+     * @throws IllegalArgumentException if some colinear axes were found.
      *
      * @see #createTransform(Envelope, Envelope)
      * @see #createTransform(Envelope, AxisDirection[], Envelope, AxisDirection[])
@@ -511,8 +518,8 @@ public final class Matrices {
      *         to the given envelope and target axis directions.
      * @throws MismatchedDimensionException if an envelope {@linkplain Envelope#getDimension() dimension} does not
      *         match the length of the axis directions sequence.
-     * @throws IllegalArgumentException if {@code dstAxes} contains at least one axis not found in {@code srcAxes},
-     *         or if some colinear axes were found.
+     * @throws UnderdeterminedMatrixException if {@code dstAxes} contains at least one axis not found in {@code srcAxes}.
+     * @throws IllegalArgumentException if some colinear axes were found.
      *
      * @see #createTransform(Envelope, Envelope)
      * @see #createTransform(AxisDirection[], AxisDirection[])
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/NoninvertibleMatrixException.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/NoninvertibleMatrixException.java
index 2552c5508f..f5a99b6f29 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/NoninvertibleMatrixException.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/NoninvertibleMatrixException.java
@@ -23,10 +23,10 @@ import org.opengis.referencing.operation.NoninvertibleTransformException;
  * Thrown when a matrix cannot be inverted.
  * This may be because the matrix is singular, or because the matrix is not square.
  *
- * <h2>Usage note</h2>
- * This exception extends {@code NoninvertibleTransformException} because the matrices in this package
- * are used in <i>Coordinate Operation Steps</i>, in which case a singular matrix means that the
- * operation is not invertible.
+ * <h2>Design note</h2>
+ * This exception extends {@code NoninvertibleTransformException} because in the context
+ * of Apache <abbr>SIS</abbr>, matrix inversions occur mostly during the construction of
+ * a coordinate operation between a pair of <abbr>CRS</abbr>.
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 0.4
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/UnderdeterminedMatrixException.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/UnderdeterminedMatrixException.java
new file mode 100644
index 0000000000..a0c7f12e70
--- /dev/null
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/matrix/UnderdeterminedMatrixException.java
@@ -0,0 +1,81 @@
+/*
+ * 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.referencing.operation.matrix;
+
+import java.util.Set;
+import java.util.Collections;
+import org.apache.sis.util.collection.CodeListSet;
+import org.opengis.referencing.cs.AxisDirection;
+
+
+/**
+ * Thrown when a matrix cannot be determined because of unknown terms.
+ * It may happen, for example, during the construction of an affine transform between two
+ * coordinate systems when the source coordinate system does not have enough dimensions.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.6
+ *
+ * @see Matrices#createTransform(AxisDirection[], AxisDirection[])
+ *
+ * @since 1.6
+ */
+public class UnderdeterminedMatrixException extends IllegalArgumentException {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = 5248380660948565901L;
+
+    /**
+     * The unknown axes, identified by their directions.
+     */
+    private final CodeListSet<AxisDirection> axes = new CodeListSet<>(AxisDirection.class);
+
+    /**
+     * Constructs a new exception with the specified detail message.
+     *
+     * @param message  the detail message, or {@code null} if none.
+     */
+    public UnderdeterminedMatrixException(final String message) {
+        super(message);
+    }
+
+    /**
+     * Indicates which term was unknown, specified as an axis direction for convenience.
+     * Unknown axes correspond to specific row indexes in the matrix, but are specified
+     * as axis directions for making easier for the caller to analyze. There is usually
+     * only one missing axis (typically the vertical or temporal one), but more can be added.
+     *
+     * <p>In the current Apache <abbr>SIS</abbr> version, the only reason for undetermined
+     * matrix is a missing axis, but more reasons may be added in future versions.</p>
+     *
+     * @param  direction  direction of the unknown axis.
+     */
+    public void addUnknown(final AxisDirection direction) {
+        axes.add(direction);
+    }
+
+    /**
+     * Indicates which axes were unknown during the attempt to create an affine transform.
+     * Unknown axes are identified by the directions that they were expected to have.
+     *
+     * @return directions of axes that are missing in the source coordinate system.
+     */
+    public Set<AxisDirection> getUnknownAxes() {
+        return Collections.unmodifiableSet(axes);
+    }
+}
diff --git a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/matrix/MatricesTest.java b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/matrix/MatricesTest.java
index 89d0028a49..b67b620626 100644
--- a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/matrix/MatricesTest.java
+++ b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/matrix/MatricesTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.referencing.operation.matrix;
 
+import java.util.Set;
 import static java.lang.Double.NaN;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.DirectPosition;
@@ -33,6 +34,7 @@ import org.apache.sis.util.iso.Types;
 import org.junit.jupiter.api.Test;
 import static org.junit.jupiter.api.Assertions.*;
 import org.apache.sis.test.TestCase;
+import static org.apache.sis.test.Assertions.assertSetEquals;
 import static org.apache.sis.test.Assertions.assertMultilinesEquals;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
@@ -181,11 +183,12 @@ public final class MatricesTest extends TestCase {
      */
     @Test
     public void testCreateTransformWithAxisNotInSource() {
-        var e = assertThrows(IllegalArgumentException.class,
+        var e = assertThrows(UnderdeterminedMatrixException.class,
                 () -> Matrices.createTransform(
                         new AxisDirection[] {NORTH, EAST, UP},
                         new AxisDirection[] {DOWN, GEOCENTRIC_X}));
         assertMessageContainsDirection(e, GEOCENTRIC_X);
+        assertSetEquals(Set.of(GEOCENTRIC_X), e.getUnknownAxes());
     }
 
     /**
diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/MultiResolutionImage.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/MultiResolutionImage.java
index 75792a41a8..dd8495506a 100644
--- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/MultiResolutionImage.java
+++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/MultiResolutionImage.java
@@ -73,7 +73,7 @@ final class MultiResolutionImage extends GridResourceWrapper implements StoreRes
 
     /**
      * The last coordinate operation returned by {@link #getTransformFrom(CoordinateReferenceSystem)}.
-     * Used as an optimization in the common case where the same CRS is used for many requests.
+     * Used as an optimization in the common case where the same <abbr>CRS</abbr> is used for many requests.
      */
     private volatile CoordinateOperation lastOperation;
 
@@ -205,12 +205,12 @@ final class MultiResolutionImage extends GridResourceWrapper implements StoreRes
     }
 
     /**
-     * Converts a resolution from units in the given CRS to units of this coverage CRS.
+     * Returns the resolution of the given grid geometry, but in units of this coverage <abbr>CRS</abbr>.
      *
      * @param  domain  the geometry from which to get the resolution.
      * @return resolution from the given grid geometry in units of this coverage CRS, or {@code null}.
      */
-    private double[] getResolution(final GridGeometry domain) throws DataStoreException {
+    private double[] convertResolutionOf(final GridGeometry domain) throws DataStoreException {
         if (domain == null || !domain.isDefined(GridGeometry.RESOLUTION)) {
             return null;
         }
@@ -253,7 +253,7 @@ final class MultiResolutionImage extends GridResourceWrapper implements StoreRes
      */
     @Override
     public GridCoverage read(final GridGeometry domain, final int... ranges) throws DataStoreException {
-        final double[] request = getResolution(domain);
+        final double[] request = convertResolutionOf(domain);
         int level = (request != null) ? resolutions.length : 1;
         synchronized (getSynchronizationLock()) {
 finer:      while (--level > 0) {
diff --git a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/Localized.java b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/Localized.java
index 3c20d02fe2..c5be805ed9 100644
--- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/Localized.java
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/Localized.java
@@ -41,7 +41,7 @@ public interface Localized {
      * </ul>
      *
      * Implementations are encouraged to return a non-null value in every cases.
-     * Nevertheless client codes should be prepared to receive null values.
+     * Nevertheless, client codes should be prepared to receive null values.
      *
      * @return the locale, or {@code null} if not explicitly defined.
      */
diff --git a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/logging/Logging.java b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/logging/Logging.java
index d25c1352ee..f9e9d5f6f1 100644
--- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/logging/Logging.java
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/logging/Logging.java
@@ -336,7 +336,7 @@ public final class Logging {
     public static boolean recoverableException(final Logger logger, final Class<?> classe,
                                                final String method, final Throwable error)
     {
-        final String classname = (classe != null) ? classe.getName() : null;
+        final String classname = (classe != null) ? classe.getCanonicalName() : null;
         return unexpectedException(logger, classname, method, error, Level.FINE);
     }