blob: 0f39a1e23abb9329688c6e4602c78cae87416255 [file] [log] [blame]
/*
* 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.transform;
import javax.measure.Unit;
import javax.measure.quantity.Angle;
import javax.measure.quantity.Length;
import org.opengis.util.FactoryException;
import org.opengis.parameter.ParameterDescriptor;
import org.opengis.parameter.ParameterDescriptorGroup;
import org.opengis.referencing.datum.Ellipsoid;
import org.opengis.referencing.operation.Matrix;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.MathTransformFactory;
import org.opengis.referencing.operation.NoninvertibleTransformException;
import org.opengis.referencing.operation.TransformException;
import org.apache.sis.referencing.operation.provider.FranceGeocentricInterpolation;
import org.apache.sis.referencing.operation.provider.Molodensky;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.privy.Constants;
import org.apache.sis.metadata.iso.citation.Citations;
import org.apache.sis.parameter.ParameterBuilder;
import org.apache.sis.referencing.datum.DatumShiftGrid;
import org.apache.sis.referencing.operation.matrix.Matrices;
import org.apache.sis.referencing.internal.Resources;
// Specific to the main and geoapi-3.1 branches:
import org.opengis.geometry.MismatchedDimensionException;
/**
* Transforms between two geographic CRS by performing geocentric translations interpolated from a grid file.
* This transform is used mainly for <q>France geocentric interpolation</q> (ESPG:9655) datum shifts,
* but Apache SIS implementation allows the use for other regions.
*
* <h2>Algorithm</h2>
* This class transforms two- or three- dimensional coordinates from a geographic CRS to another geographic CRS.
* The changes between source and target coordinates are small (usually less than 400 metres), but vary for every
* position. Those changes are provided in a {@linkplain DatumShiftGrid datum shift grid}, usually loaded from one
* or two files.
*
* <p>Many datum shift grids like NADCON and NTv2 apply the changes directly on geographic coordinates.
* This relatively simple case is handled by {@link InterpolatedTransform}.
* But the {@code InterpolatedGeocentricTransform} algorithm uses the grid in a more complex way:</p>
*
* <ol>
* <li>Convert input geographic coordinate (λ,φ) to geocentric coordinate (X,Y,Z).</li>
* <li>Ask {@link DatumShiftGrid} for the offset to apply for coordinate (λ,φ).
* But instead of returning a (Δλ, Δφ) offset, the grid shall return a (ΔX, ΔY, ΔZ) offset.</li>
* <li>Convert the shifted geocentric coordinate (X+ΔX, Y+ΔY, Z+ΔZ) back to a geographic coordinate.</li>
* </ol>
*
* <h3>Reference</h3>
* IGN document {@code NTG_88.pdf},
* <q>Grille de paramètres de transformation de coordonnées</q>
* at <a href="http://www.ign.fr">http://www.ign.fr</a>.
* Note however that the signs of (ΔX, ΔY, ΔZ) values expected by this class are the opposite of the
* signs used in NTG_88 document. This is because NTG_88 grid defines shifts from target to source,
* while this class expects shifts from source to target.
*
* <p><b>Note:</b> this algorithm is not the same as a (theoretical) {@link EllipsoidToCentricTransform} →
* {@link InterpolatedTransform} → (inverse of {@code EllipsoidToCentricTransform}) concatenation
* because the {@code DatumShiftGrid} inputs are geographic coordinates even if the interpolated
* grid values are in geocentric space.</p>
*
* @author Simon Reynard (Geomatys)
* @author Martin Desruisseaux (Geomatys)
* @version 1.0
*
* @since 0.7
*/
public class InterpolatedGeocentricTransform extends DatumShiftTransform {
/**
* Serial number for inter-operability with different versions.
*/
private static final long serialVersionUID = 5503722845441653093L;
/**
* Parameter descriptor to use with the contextual parameters for the forward transformation.
* We do not use the <q>France geocentric interpolation</q> (ESPG:9655) descriptor
* because their "forward" transformation is our "inverse" transformation, and conversely.
* The {@code DESCRIPTOR} defined here is non-standard, but allows this class to be used
* for other geographic areas than France.
*/
static final ParameterDescriptorGroup DESCRIPTOR;
/**
* Parameter descriptor to use with the contextual parameters for the inverse transformation.
* We do not use the <q>France geocentric interpolation</q> (ESPG:9655) descriptor
* because it is specific to a single country, has hard-coded parameters and uses a sign
* convention for (ΔX,ΔY,ΔZ) translations different than the one used in this class.
* The {@code INVERSE} descriptor defined here is non-standard, but allows this class
* to be used for other geographic areas than France.
*/
private static final ParameterDescriptorGroup INVERSE;
static {
final ParameterBuilder builder = new ParameterBuilder()
.setRequired(true).setCodeSpace(Citations.SIS, Constants.SIS);
final ParameterDescriptor<?>[] param = new ParameterDescriptor<?>[] {
Molodensky.DIMENSION,
Molodensky.SRC_SEMI_MAJOR,
Molodensky.SRC_SEMI_MINOR,
Molodensky.TGT_SEMI_MAJOR,
Molodensky.TGT_SEMI_MINOR,
FranceGeocentricInterpolation.FILE
};
DESCRIPTOR = builder.addName("Geocentric interpolation").createGroup(param);
INVERSE = builder.addName("Geocentric inverse interpolation").createGroup(param);
}
/**
* Semi-major axis length (<var>a</var>) of the source ellipsoid.
*/
final double semiMajor;
/**
* Semi-major axis length of the source ellipsoid divided by semi-major axis length of the target ellipsoid.
* Used for converting normalized coordinates between the two geocentric coordinate reference systems.
*
* <p>This is a dimensionless quantity: the ellipsoid axis lengths must have been converted to the same unit
* before to compute this ratio.</p>
*/
final double scale;
/**
* The transform to apply before and after the geocentric translation. Shall be instance of
* {@link EllipsoidToCentricTransform} and {@code EllipsoidToCentricTransform.Inverse} respectively.
*/
@SuppressWarnings("serial") // Most SIS implementations are serializable.
final AbstractMathTransform ellipsoidToCentric, centricToEllipsoid;
/**
* The inverse of this interpolated geocentric transform.
*
* @see #inverse()
*/
private final InterpolatedGeocentricTransform inverse;
/**
* Constructs the inverse of an interpolated geocentric transform.
*
* @param inverse the transform for which to create the inverse.
* @param source the source ellipsoid of the given {@code inverse} transform.
* @param target the target ellipsoid of the given {@code inverse} transform.
*/
InterpolatedGeocentricTransform(final InterpolatedGeocentricTransform inverse, Ellipsoid source, Ellipsoid target) {
this(target, inverse.getTargetDimensions() > 2,
source, inverse.getSourceDimensions() > 2,
inverse.grid, inverse);
}
/**
* Creates a transform from the specified parameters.
* This {@code InterpolatedGeocentricTransform} class expects coordinate values in the following order and units:
* <ol>
* <li>longitudes in <strong>radians</strong> relative to the prime meridian (usually Greenwich),</li>
* <li>latitudes in <strong>radians</strong>,</li>
* <li>optionally heights above the ellipsoid, in same units as the source ellipsoid axes.</li>
* </ol>
*
* For converting geographic coordinates in degrees, {@code InterpolatedGeocentricTransform} instances
* need to be concatenated with the following affine transforms:
*
* <ul>
* <li><i>Normalization</i> before {@code InterpolatedGeocentricTransform}:<ul>
* <li>Conversion of (λ,φ) from degrees to radians.</li>
* </ul></li>
* <li><i>Denormalization</i> after {@code InterpolatedGeocentricTransform}:<ul>
* <li>Conversion of (λ,φ) from radians to degrees.</li>
* </ul></li>
* </ul>
*
* After {@code InterpolatedGeocentricTransform} construction,
* the full conversion chain including the above affine transforms can be created by
* <code>{@linkplain #getContextualParameters()}.{@linkplain ContextualParameters#completeTransform
* completeTransform}(factory, this)}</code>.
*
* @param source the source ellipsoid.
* @param isSource3D {@code true} if the source coordinates have a height.
* @param target the target ellipsoid.
* @param isTarget3D {@code true} if the target coordinates have a height.
* @param grid the grid of datum shifts from source to target datum.
* The {@link DatumShiftGrid#interpolateInCell DatumShiftGrid.interpolateInCell(…)} method
* shall compute (ΔX, ΔY, ΔZ) translations from <em>source</em> to <em>target</em> in the
* unit of source ellipsoid axes.
*
* @see #createGeodeticTransformation(MathTransformFactory, Ellipsoid, boolean, Ellipsoid, boolean, DatumShiftGrid)
*/
protected InterpolatedGeocentricTransform(final Ellipsoid source, final boolean isSource3D,
final Ellipsoid target, final boolean isTarget3D,
final DatumShiftGrid<Angle,Length> grid)
{
this(source, isSource3D, target, isTarget3D, grid, null);
}
/**
* Constructor for the forward and inverse transformations.
* If {@code inverse} is {@code null}, then this constructor creates the forward transformation.
* Otherwise this constructor creates the inverse of the given {@code inverse} transformation.
*/
@SuppressWarnings("this-escape")
private InterpolatedGeocentricTransform(final Ellipsoid source, final boolean isSource3D,
final Ellipsoid target, final boolean isTarget3D,
final DatumShiftGrid<?,?> grid,
InterpolatedGeocentricTransform inverse)
{
super((inverse != null) ? INVERSE : DESCRIPTOR, isSource3D, isTarget3D, grid);
ArgumentChecks.ensureNonNull("source", source);
ArgumentChecks.ensureNonNull("target", target);
ArgumentChecks.ensureNonNull("grid", grid);
final Unit<Length> unit = source.getAxisUnit();
ensureGeocentricTranslation(grid, unit);
/*
* Store the source and target axis lengths in the contextual parameters.
* We do not need to store all those information directly in the field of this class,
* but we need to have them in ContextualParameters for Well Known Text formatting.
*/
final double semiMinor;
semiMajor = source.getSemiMajorAxis();
semiMinor = source.getSemiMinorAxis();
setContextParameters(semiMajor, semiMinor, unit, target);
context.getOrCreate(Molodensky.DIMENSION).setValue(isSource3D ? 3 : 2);
grid.getParameterValues(context);
/*
* The above setContextParameters(…) method converted the axis lengths of target ellipsoid in the same units
* than source ellipsoid. Opportunistically fetch that value, so we don't have to convert the values ourselves.
*/
scale = semiMajor / context.doubleValue(Molodensky.TGT_SEMI_MAJOR);
/*
* Creates the Geographic ↔ Geocentric conversions. Note that the target ellipsoid keeps the unit specified
* by the user; we do not convert target axis length. This is okay since our work will be already finished
* when the conversion to target units will apply.
*/
if (inverse == null) {
ellipsoidToCentric = new EllipsoidToCentricTransform(semiMajor, semiMinor, unit, isSource3D,
EllipsoidToCentricTransform.TargetType.CARTESIAN);
centricToEllipsoid = (AbstractMathTransform) new EllipsoidToCentricTransform(
target.getSemiMajorAxis(),
target.getSemiMinorAxis(),
target.getAxisUnit(), isTarget3D,
EllipsoidToCentricTransform.TargetType.CARTESIAN).inverse();
} else try {
ellipsoidToCentric = (AbstractMathTransform) inverse.centricToEllipsoid.inverse();
centricToEllipsoid = (AbstractMathTransform) inverse.ellipsoidToCentric.inverse();
} catch (NoninvertibleTransformException e) {
throw new IllegalArgumentException(e); // Should never happen.
}
/*
* Usually, this is where we would initialize the normalization and denormalization matrices
* to degrees ↔ radians conversions. But in for this class we will rather copy the work done
* by EllipsoidToCentricTransform. Especially since the latter performs its own adjustment on
* height values.
*/
context.getMatrix(ContextualParameters.MatrixRole.NORMALIZATION).setMatrix(
ellipsoidToCentric.getContextualParameters().getMatrix(ContextualParameters.MatrixRole.NORMALIZATION));
context.getMatrix(ContextualParameters.MatrixRole.DENORMALIZATION).setMatrix(
centricToEllipsoid.getContextualParameters().getMatrix(ContextualParameters.MatrixRole.DENORMALIZATION));
/*
* Inverse transformation must be created only after everything else has been initialized.
*/
if (inverse == null) {
if (isSource3D || isTarget3D) {
inverse = new Inverse(this, source, target);
} else {
inverse = new InterpolatedGeocentricTransform2D.Inverse(this, source, target);
}
}
this.inverse = inverse;
}
/**
* Ensures that the {@link #grid} performs geocentric translations in the given units.
* This method is invoked by constructor for validation of given arguments.
*
* @param grid the grid to validate.
* @param unit the unit of semi-axis length of the <strong>source</strong> ellipsoid.
* @throws IllegalArgumentException if the given grid is not valid.
*/
private static void ensureGeocentricTranslation(final DatumShiftGrid<?,?> grid, final Unit<Length> unit)
throws IllegalArgumentException
{
final int dim = grid.getTranslationDimensions();
if (dim != 3) {
throw new MismatchedDimensionException(Errors.format(Errors.Keys.MismatchedDimension_3, "grid", 3, dim));
}
Object unitLabel = "ratio";
if (grid.isCellValueRatio() || (unitLabel = grid.getTranslationUnit()) != unit) {
throw new IllegalArgumentException(Resources.format(Resources.Keys.IllegalUnitFor_2, "translation", unitLabel));
}
}
/**
* Delegates to {@link ContextualParameters#completeTransform(MathTransformFactory, MathTransform)}
* for this transformation and for its dependencies as well.
*/
private MathTransform completeTransform(final MathTransformFactory factory, final boolean create) throws FactoryException {
ellipsoidToCentric.getContextualParameters().completeTransform(factory, null);
centricToEllipsoid.getContextualParameters().completeTransform(factory, null);
return context.completeTransform(factory, create ? this : null);
}
/**
* Creates a transformation between two geographic CRS. This factory method combines the
* {@code InterpolatedGeocentricTransform} instance with the steps needed for converting values between
* degrees to radians. The transform works with input and output coordinates in the following units:
*
* <ol>
* <li>longitudes in <strong>degrees</strong> relative to the prime meridian (usually Greenwich),</li>
* <li>latitudes in <strong>degrees</strong>,</li>
* <li>optionally heights above the ellipsoid, in same units as the source ellipsoids axes.</li>
* </ol>
*
* Note however that the given {@code grid} instance shall expect geographic coordinates (λ,φ)
* in <strong>radians</strong>.
*
* @param factory the factory to use for creating the transform.
* @param source the source ellipsoid.
* @param isSource3D {@code true} if the source coordinates have a height.
* @param target the target ellipsoid.
* @param isTarget3D {@code true} if the target coordinates have a height.
* @param grid the grid of datum shifts from source to target datum.
* The {@link DatumShiftGrid#interpolateInCell DatumShiftGrid.interpolateInCell(…)} method
* shall compute (ΔX, ΔY, ΔZ) translations from <em>source</em> to <em>target</em> in the
* unit of source ellipsoid axes.
* @return the transformation between geographic coordinates in degrees.
* @throws FactoryException if an error occurred while creating a transform.
*/
public static MathTransform createGeodeticTransformation(final MathTransformFactory factory,
final Ellipsoid source, final boolean isSource3D,
final Ellipsoid target, final boolean isTarget3D,
final DatumShiftGrid<Angle,Length> grid) throws FactoryException
{
final InterpolatedGeocentricTransform tr;
if (isSource3D || isTarget3D) {
tr = new InterpolatedGeocentricTransform(source, isSource3D, target, isTarget3D, grid);
} else {
tr = new InterpolatedGeocentricTransform2D(source, target, grid);
}
tr.inverse.completeTransform(factory, false);
return tr.completeTransform(factory, true);
}
/**
* Gets the dimension of input points.
*
* @return the input dimension, which is 2 or 3.
*/
@Override
public int getSourceDimensions() {
return ellipsoidToCentric.getSourceDimensions();
}
/**
* Gets the dimension of output points.
*
* @return the output dimension, which is 2 or 3.
*/
@Override
public int getTargetDimensions() {
return centricToEllipsoid.getTargetDimensions();
}
/**
* Transforms the (λ,φ) or (λ,φ,<var>h</var>) coordinates between two geographic CRS,
* and optionally returns the derivative at that location.
*
* @return {@inheritDoc}
* @throws TransformException if the point cannot be transformed or
* if a problem occurred while calculating the derivative.
*/
@Override
public Matrix transform(final double[] srcPts, final int srcOff,
final double[] dstPts, final int dstOff,
final boolean derivate) throws TransformException
{
/*
* Interpolate the geocentric translation (ΔX,ΔY,ΔZ) for the source coordinate (λ,φ).
* The translation that we got is in metres, which we convert into normalized units.
*/
final double[] vector = new double[3];
grid.interpolateInCell(normalizedToGridX(srcPts[srcOff]), // In radians
normalizedToGridY(srcPts[srcOff+1]), vector);
final double tX = vector[0] / semiMajor;
final double tY = vector[1] / semiMajor;
final double tZ = vector[2] / semiMajor;
/*
* Geographic → Geocentric conversion. Result stored in a temporary array
* because we do not check if the target points are two or three dimensional.
*/
final Matrix m1 = ellipsoidToCentric.transform(srcPts, srcOff, vector, 0, derivate);
/*
* Apply the geocentric translation on the geocentric coordinates. If the coordinates were in metres,
* we would have only additions. But since we use normalized units, we also need to convert from the
* normalization relative to the source ellipsoid to normalization relative to the target ellipsoid.
* This is the purpose of the scale factor.
*/
vector[0] = (vector[0] + tX) * scale;
vector[1] = (vector[1] + tY) * scale;
vector[2] = (vector[2] + tZ) * scale;
/*
* Final Geocentric → Geographic conversion, and compute the derivative matrix if the user asked for it.
*/
final Matrix m2 = centricToEllipsoid.transform(vector, 0, dstPts, dstOff, derivate);
if (m1 == null || m2 == null) {
return null;
}
return concatenate(m1, m2);
}
/**
* Computes the derivative by concatenating the "geographic to geocentric" and "geocentric to geographic" matrix,
* with the {@linkplain #scale} factor between them.
*
* <h4>Note</h4>
* we could improve a little bit the precision by computing the derivative in the interpolation grid:
*
* {@snippet lang="java" :
* grid.derivativeInCell(grid.normalizedToGridX(λ), grid.normalizedToGridY(φ));
* }
*
* But this is a little bit complicated (need to convert to normalized units and divide by the grid
* cell size) for a very small difference. For now we neglect that part.
*
* @param m1 the derivative computed by the "geographic to geocentric" conversion.
* @param m2 the derivative computed by the "geocentric to geographic" conversion.
* @return the derivative for the "interpolated geocentric" transformation.
*/
final Matrix concatenate(final Matrix m1, final Matrix m2) {
for (int i = m1.getNumCol(); --i >= 0;) { // Number of columns can be 2 or 3.
for (int j = 3; --j >= 0;) { // Number of rows cannot be anything else than 3.
m1.setElement(j, i, m1.getElement(j, i) * scale);
}
}
return Matrices.multiply(m2, m1);
}
/**
* Returns the inverse of this interpolated geocentric transform.
* The source ellipsoid of the returned transform will be the target ellipsoid of this transform, and conversely.
*
* @return a transform from the target ellipsoid to the source ellipsoid of this transform.
*/
@Override
public MathTransform inverse() {
return inverse;
}
/**
* The inverse of the enclosing {@link InterpolatedGeocentricTransform}.
* This transform applies an algorithm similar to the one documented in the enclosing class,
* with the following differences:
*
* <ol>
* <li>First, target coordinates are estimated using the ({@link #tX}, {@link #tY}, {@link #tZ}) translation.</li>
* <li>A new ({@code ΔX}, {@code ΔY}, {@code ΔZ}) translation is interpolated at the geographic coordinates found
* in above step, and target coordinates are recomputed again using that new translation.</li>
* </ol>
*
* @author Martin Desruisseaux (Geomatys)
*/
static class Inverse extends InterpolatedGeocentricTransform {
/**
* Serial number for inter-operability with different versions.
*/
private static final long serialVersionUID = -3481207454803064521L;
/**
* Initial translation values to use before to improve the accuracy with the interpolation.
*/
private final double tX, tY, tZ;
/**
* Constructs the inverse of an interpolated geocentric transform.
*
* @param inverse The transform for which to create the inverse.
* @param source The source ellipsoid of the given {@code inverse} transform.
* @param target The target ellipsoid of the given {@code inverse} transform.
*/
Inverse(final InterpolatedGeocentricTransform inverse, final Ellipsoid source, final Ellipsoid target) {
super(inverse, source, target);
tX = grid.getCellMean(0) / semiMajor;
tY = grid.getCellMean(1) / semiMajor;
tZ = grid.getCellMean(2) / semiMajor;
}
/**
* Transforms the (λ,φ) or (λ,φ,<var>h</var>) coordinates between two geographic CRS,
* and optionally returns the derivative at that location.
*
* @return {@inheritDoc}
* @throws TransformException if the point cannot be transformed or
* if a problem occurred while calculating the derivative.
*/
@Override
public Matrix transform(final double[] srcPts, final int srcOff,
final double[] dstPts, final int dstOff,
final boolean derivate) throws TransformException
{
/*
* Geographic → Geocentric conversion. Result stored in a temporary array
* because we do not check if the target points are two or three dimensional.
*/
final double[] vector = new double[3];
final Matrix m1 = ellipsoidToCentric.transform(srcPts, srcOff, vector, 0, derivate);
final double x = vector[0];
final double y = vector[1];
final double z = vector[2];
/*
* First approximation of geocentric translation, by using average values or values
* specified by the authority (e.g. as in the "France Geocentric Interpolation" method).
*/
vector[0] = x - tX;
vector[1] = y - tY;
vector[2] = z - tZ;
centricToEllipsoid.transform(vector, 0, vector, 0, derivate);
/*
* We got a (λ,φ) using an approximated geocentric translation. Now interpolate the "real"
* geocentric interpolation at that location and get the (λ,φ) again. In theory, we just
* iterate until we got the desired precision. But in practice a single interation is enough.
*/
grid.interpolateInCell(normalizedToGridX(vector[0]),
normalizedToGridY(vector[1]), vector);
vector[0] = (x - vector[0] / semiMajor) * scale;
vector[1] = (y - vector[1] / semiMajor) * scale;
vector[2] = (z - vector[2] / semiMajor) * scale;
/*
* Final Geocentric → Geographic conversion, and compute the derivative matrix if the user asked for it.
* We neglect the derivative provided by grid.derivativeInCell(…).
*/
final Matrix m2 = centricToEllipsoid.transform(vector, 0, dstPts, dstOff, derivate);
if (m1 == null || m2 == null) {
return null;
}
return concatenate(m1, m2);
}
}
}