blob: ba2fd69401cb77aee2eeda785fb2a46835802166 [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;
import java.util.Map;
import java.util.HashMap;
import org.opengis.util.FactoryException;
import org.opengis.parameter.ParameterValue;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.referencing.crs.GeodeticCRS;
import org.opengis.referencing.crs.GeographicCRS;
import org.opengis.referencing.crs.TemporalCRS;
import org.opengis.referencing.cs.EllipsoidalCS;
import org.opengis.referencing.operation.Matrix;
import org.opengis.referencing.operation.Conversion;
import org.opengis.referencing.operation.OperationMethod;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.referencing.privy.CoordinateOperations;
import org.apache.sis.referencing.privy.ReferencingUtilities;
import org.apache.sis.referencing.datum.DefaultGeodeticDatum;
import org.apache.sis.referencing.crs.DefaultGeographicCRS;
import org.apache.sis.referencing.operation.matrix.Matrix3;
import org.apache.sis.referencing.operation.matrix.Matrix4;
import org.apache.sis.referencing.operation.matrix.Matrices;
import org.apache.sis.referencing.operation.transform.MathTransforms;
// Test dependencies
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import org.apache.sis.test.TestCase;
import org.apache.sis.referencing.cs.HardCodedCS;
import org.apache.sis.referencing.crs.HardCodedCRS;
import org.apache.sis.referencing.datum.HardCodedDatum;
import org.apache.sis.parameter.DefaultParameterDescriptorTest;
import static org.apache.sis.test.Assertions.assertMessageContains;
import static org.apache.sis.test.Assertions.assertSerializedEquals;
// Specific to the geoapi-3.1 and geoapi-4.0 branches:
import static org.opengis.test.Assertions.assertMatrixEquals;
/**
* Tests {@link DefaultConversion}.
*
* @author Martin Desruisseaux (Geomatys)
*/
public final class DefaultConversionTest extends TestCase {
/**
* The rotation from a CRS using the Paris prime meridian to a CRS using the Greenwich prime meridian,
* in degrees. The definitive value is 2.5969213 grads.
*/
private static final double OFFSET = 2.33722917;
/**
* Creates a new test case.
*/
public DefaultConversionTest() {
}
/**
* Creates a CRS using the same ellipsoid as the "Nouvelle Triangulation Française (Paris)" datum (EPSG:6807),
* but with the prime meridian optionally set to Greenwich. Such CRS is not in real usage, but this is convenient
* for testing a conversion consisting of only a longitude rotation, which is a very simple operation easy to test.
*
* @param isSource {@code true} if creating the source CRS, or {@code false} if creating the target CRS.
* @param cs {@link HardCodedCS#GEODETIC_2D}, {@link HardCodedCS#GEODETIC_φλ} or other compatible coordinate system.
* @param useGreenwich {@code true} for using Greenwich prime meridian, or {@code false} for staying on the Paris one.
*
* @see HardCodedCRS#NTF
*/
private static GeographicCRS createParisCRS(final boolean isSource, final EllipsoidalCS cs, final boolean useGreenwich) {
DefaultGeodeticDatum datum = HardCodedDatum.NTF;
if (useGreenwich) {
datum = new DefaultGeodeticDatum(Map.of(DefaultGeodeticDatum.NAME_KEY, datum.getName()),
datum.getEllipsoid(), HardCodedDatum.GREENWICH);
}
return new DefaultGeographicCRS(Map.of(GeographicCRS.NAME_KEY, isSource ? HardCodedCRS.NTF.getName() : "Back to Greenwich"),
datum, cs);
}
/**
* Changes only the coordinate system of the given CRS, which is supposed geographic.
*/
private static GeographicCRS changeCS(final CoordinateReferenceSystem crs, final EllipsoidalCS cs) {
return new DefaultGeographicCRS(Map.of(DefaultGeographicCRS.NAME_KEY, crs.getName()),
((GeodeticCRS) crs).getDatum(), cs);
}
/**
* Creates a pseudo-conversion performing a longitude rotation between two-dimensional normalized CRS.
* The source CRS uses the Paris prime meridian and the target CRS "conceptually" uses the Greenwich
* prime meridian.
*
* <p><b>This is not really a valid conversion</b> since, strictly speaking, <cite>Longitude rotations</cite>
* are coordinate <em>transformations</em> rather than conversions (because they change the datum, since they
* change the prime meridian). However, we handle them as conversions for testing purpose only, because the
* longitude rotation is a very simple operation easy to test.</p>
*
* @param useGreenwich {@code true} for using Greenwich prime meridian in the {@code targetCRS},
* or {@code false} for staying on the Paris one.
* @return a pseudo-conversion performing a longitude rotation.
*/
public static DefaultConversion createLongitudeRotation(final boolean useGreenwich) {
return createLongitudeRotation(HardCodedCRS.NTF_NORMALIZED_AXES,
createParisCRS(false, HardCodedCS.GEODETIC_2D, useGreenwich), null);
}
/**
* Creates a very simple conversion performing a longitude rotation.
* The source CRS shall use the Paris prime meridian and the target CRS the Greenwich prime meridian,
* at least conceptually. See {@link #createLongitudeRotation(boolean)} for an explanation about why
* this is not really a valid conversion.
*
* @param sourceCRS a CRS using the Paris prime meridian.
* @param targetCRS a CRS using the Greenwich prime meridian.
* @param interpolationCRS a dummy interpolation CRS, or {@code null} if none.
*/
private static DefaultConversion createLongitudeRotation(final GeographicCRS sourceCRS,
final GeographicCRS targetCRS, final TemporalCRS interpolationCRS)
{
/*
* The following code fills the parameter values AND creates itself the MathTransform instance
* (indirectly, through the matrix). The latter step is normally not our business, since we are
* supposed to only fill the parameter values and let MathTransformFactory creates the transform
* from the parameters. But we don't do the normal steps here because this class is a unit test:
* we want to test DefaultConversion in isolation of MathTransformFactory.
*/
final int interpDim = ReferencingUtilities.getDimension(interpolationCRS);
final int sourceDim = sourceCRS.getCoordinateSystem().getDimension();
final int targetDim = targetCRS.getCoordinateSystem().getDimension();
final OperationMethod method = DefaultOperationMethodTest.create(
"Longitude rotation", "9601", "EPSG guidance note #7-2",
DefaultParameterDescriptorTest.createEPSG("Longitude offset", (short) 8602));
final ParameterValueGroup pg = method.getParameters().createValue();
pg.parameter("Longitude offset").setValue(OFFSET);
final Matrix rotation = Matrices.createDiagonal(
targetDim + interpDim + 1, // Number of rows.
sourceDim + interpDim + 1); // Number of columns.
rotation.setElement(interpDim, interpDim + sourceDim, OFFSET);
/*
* In theory we should not need to provide the parameters explicitly to the constructor since
* we are supposed to be able to find them from the MathTransform. But in this simple test we
* did not bothered to define a specialized MathTransform class for our case. So we will help
* a little bit DefaultConversion by telling it the parameters that we used.
*/
final Map<String, Object> properties = new HashMap<>(4);
properties.put(DefaultTransformation.NAME_KEY, "Paris to Greenwich");
properties.put(CoordinateOperations.PARAMETERS_KEY, pg);
return new DefaultConversion(properties, sourceCRS, targetCRS, interpolationCRS,
method, MathTransforms.linear(rotation));
}
/**
* Asserts that at least some of the properties of the given {@code op} instance have the expected values
* for an instance created by {@link #createLongitudeRotation(GeographicCRS, GeographicCRS, TemporalCRS)}.
*/
@SuppressWarnings("SuspiciousToArrayCall")
private static void verifyProperties(final DefaultConversion op, final boolean swapSourceAxes) {
assertEquals("Paris to Greenwich", op.getName().getCode());
assertEquals("NTF (Paris)", op.getSourceCRS().getName().getCode());
assertEquals("Back to Greenwich", op.getTargetCRS().getName().getCode());
assertEquals("Longitude rotation", op.getMethod().getName().getCode());
assertEquals("Longitude rotation", op.getParameterDescriptors().getName().getCode());
final ParameterValueGroup parameters = op.getParameterValues();
final ParameterValue<?>[] values = parameters.values().toArray(new ParameterValue<?>[1]);
assertEquals("Longitude rotation", parameters.getDescriptor().getName().getCode());
assertEquals("Longitude offset", values[0].getDescriptor().getName().getCode());
assertEquals(OFFSET, values[0].doubleValue());
assertEquals(1, values.length);
final Matrix3 expected = new Matrix3();
expected.m02 = OFFSET;
if (swapSourceAxes) {
expected.m00 = expected.m11 = 0;
expected.m01 = expected.m10 = 1;
}
assertMatrixEquals(expected, MathTransforms.getMatrix(op.getMathTransform()), STRICT,
"Longitude rotation of a two-dimensional CRS");
}
/**
* Tests a simple two-dimensional conversion performing a longitude rotation.
*/
@Test
public void testConstruction() {
/*
* Test construction of a valid conversion.
* This conversion use the same datum for the source and target CRS.
*/
verifyProperties(createLongitudeRotation(false), false);
/*
* Test a conversion with a source and target CRS using different datum.
* This is a violation of conversion definition, but SIS is tolerant to
* such violation. See DefaultConversion constructor javadoc for discussion.
*/
verifyProperties(createLongitudeRotation(true), false);
}
/**
* Creates a defining conversion and tests {@link DefaultConversion#specialize DefaultConversion.specialize(…)}.
* This test includes a swapping of axis order in the <em>source</em> CRS.
* By contrast, {@link #testSpecialize()} will test swapping axis order in the <em>target</em> CRS.
*
* @throws FactoryException if an error occurred while creating the conversion.
*/
@Test
public void testDefiningConversion() throws FactoryException {
final DefaultConversion reference = createLongitudeRotation(true);
final DefaultConversion definingConversion = new DefaultConversion(
IdentifiedObjects.getProperties(reference),
reference.getMethod(),
reference.getMathTransform(),
reference.getParameterValues());
/*
* By definition, defining conversions have no source and target CRS.
* This make them different from "normal" conversions.
*/
assertNull(definingConversion.getSourceCRS());
assertNull(definingConversion.getTargetCRS());
assertNotEquals(definingConversion, reference);
assertNotEquals(reference, definingConversion);
/*
* Now create a normal conversion from the defining one,
* but add a swapping of (latitude, longitude) axes.
*/
final Conversion completed = definingConversion.specialize(
changeCS(reference.getSourceCRS(), HardCodedCS.GEODETIC_φλ),
reference.getTargetCRS(),
null);
verifyProperties(assertInstanceOf(DefaultConversion.class, completed), true);
}
/**
* Tests {@link DefaultConversion#specialize DefaultConversion.specialize(…)} with new source and target CRS.
* This test attempts to swap axis order and change the number of dimensions of the <em>target</em> CRS.
* By contrast, {@link #testDefiningConversion()} tested swapping axis order in the <em>source</em> CRS.
*
* @throws FactoryException if an error occurred while creating the conversion.
*/
@Test
public void testSpecialize() throws FactoryException {
DefaultConversion op = createLongitudeRotation(
createParisCRS(true, HardCodedCS.GEODETIC_3D, false),
createParisCRS(false, HardCodedCS.GEODETIC_3D, true), null);
assertMatrixEquals(new Matrix4(1, 0, 0, OFFSET,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1),
MathTransforms.getMatrix(op.getMathTransform()), STRICT,
"Longitude rotation of a three-dimensional CRS");
/*
* When asking for a "specialization" with the same properties,
* we should get the existing instance since no change is needed.
*/
assertSame(op, op.specialize(op.getSourceCRS(), op.getTargetCRS(), null));
/*
* Reducing the number of dimensions to 2 and swapping (latitude, longitude) axes.
*/
Conversion c = op.specialize(op.getSourceCRS(), changeCS(op.getTargetCRS(), HardCodedCS.GEODETIC_φλ), null);
assertMatrixEquals(Matrices.create(3, 4, new double[] {
0, 1, 0, 0,
1, 0, 0, OFFSET,
0, 0, 0, 1
}), MathTransforms.getMatrix(c.getMathTransform()), STRICT,
"Longitude rotation of a two-dimensional CRS");
}
/**
* Tests {@link DefaultConversion#specialize DefaultConversion.specialize(…)} with an interpolation CRS.
* In this test, we invent an imaginary scenario where the longitude rotation to apply varies with time
* (a "moving prime meridian").
*
* <h4>Note</h4>
* From some point of view, this scenario is not as weird as it may look like. The Greenwich prime meridian
* was initially the meridian passing through the telescope of the Greenwich observatory. But when a new
* more powerful telescopes was built, is was installed a few metres far from the old one. So if we were
* staying to a strict interpretation like "the meridian passing through the main telescope",
* that meridian would indeed more with time.
*
* @throws FactoryException if an error occurred while creating the conversion.
*/
@Test
public void testWithInterpolationCRS() throws FactoryException {
DefaultConversion op = createLongitudeRotation(HardCodedCRS.NTF_NORMALIZED_AXES,
createParisCRS(false, HardCodedCS.GEODETIC_2D, true), HardCodedCRS.TIME);
assertMatrixEquals(new Matrix4(1, 0, 0, 0,
0, 1, 0, OFFSET,
0, 0, 1, 0,
0, 0, 0, 1),
MathTransforms.getMatrix(op.getMathTransform()), STRICT,
"Longitude rotation of a time-varying CRS");
Conversion c = op.specialize(
op.getSourceCRS(), // Keep the same source CRS.
changeCS(op.getTargetCRS(), HardCodedCS.GEODETIC_φλ), // Swap axis order.
null);
assertMatrixEquals(new Matrix4(1, 0, 0, 0,
0, 0, 1, 0,
0, 1, 0, OFFSET,
0, 0, 0, 1),
MathTransforms.getMatrix(c.getMathTransform()), STRICT,
"Longitude rotation of a time-varying CRS");
}
/**
* Ensures that {@link DefaultConversion#specialize DefaultConversion.specialize(…)} verifies the datum.
*
* @throws FactoryException if an error occurred while creating the conversion.
*/
@Test
public void testDatumCheck() throws FactoryException {
final DefaultConversion op = createLongitudeRotation(true);
IllegalArgumentException e;
e = assertThrows(IllegalArgumentException.class,
() -> op.specialize(HardCodedCRS.WGS84, HardCodedCRS.NTF_NORMALIZED_AXES, null),
"Should not have accepted to change the geodetic reference frame.");
assertMessageContains(e, "sourceCRS", "Nouvelle Triangulation Française");
e = assertThrows(IllegalArgumentException.class,
() -> op.specialize(HardCodedCRS.NTF_NORMALIZED_AXES, HardCodedCRS.WGS84, null),
"Should not have accepted to change the geodetic reference frame.");
assertMessageContains(e, "targetCRS", "Nouvelle Triangulation Française");
}
/**
* Tests serialization.
*/
@Test
public void testSerialization() {
verifyProperties(assertSerializedEquals(createLongitudeRotation(false)), false);
}
}