/* * 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.Random; import java.awt.geom.AffineTransform; import Jama.Matrix; import org.apache.sis.math.Statistics; import org.apache.sis.internal.util.DoubleDouble; import org.apache.sis.test.TestCase; import org.apache.sis.test.TestUtilities; import org.apache.sis.test.DependsOnMethod; import org.junit.Test; import static org.apache.sis.test.Assert.*; /** * Base classes of tests for {@link MatrixSIS} implementations. * This class uses the following {@code Matrices} factory methods: * * <ul> * <li>{@link Matrices#createDiagonal(int, int)} (sometime delegates to {@link Matrices#createIdentity(int)})</li> * <li>{@link Matrices#create(int, int, double[])}</li> * <li>{@link Matrices#createZero(int, int)}</li> * </ul> * * So this class is indirectly a test of those factory methods. * However this class does not test any other {@code Matrices} methods. * * <p>This class uses <a href="http://math.nist.gov/javanumerics/jama">JAMA</a> as the reference implementation.</p> * * @author Martin Desruisseaux (Geomatys) * @version 1.0 * @since 0.4 * @module */ public abstract strictfp class MatrixTestCase extends TestCase { /** * {@code true} for reusing the same sequences of random numbers in every execution of test cases, or * {@code false} for "truly" random sequences of random numbers. This flag can be set to {@code false} * for testing purpose, but should be set to {@code true} otherwise for avoiding random test failure. * This is needed because we want to set {@link #TOLERANCE} to a small value, but it is very difficult * to guaranteed that a random sequence of numbers will not cause a larger discrepancy. * * <p>Note that this flag is set to {@code false} if double-double arithmetic is disabled because in such * case, the results should be identical to the JAMA results (i.e. equal using a {@link #TOLERANCE} of zero) * for any sequence of numbers.</p> */ protected static final boolean DETERMINIST = !DoubleDouble.DISABLED; /** * Tolerance factor for comparisons of floating point numbers between SIS and JAMA implementation, * which is {@value}. Note that the matrix element values used in this class vary between 0 and 100, * and the {@code StrictMath.ulp(100.0)} value is approximately 1.4E-14. * * <h4>How this value is determined</h4> * Experience (by looking at {@link #statistics}) shows that the differences are usually smaller than 1E-12. * However when using non-determinist sequence of random values ({@link #DETERMINIST} sets to {@code false}), * we do have from time-to-time a difference around 1E-9. * * Those differences exist because SIS uses double-double arithmetic, while JAMA uses ordinary double. * To remove that ambiguity, one can temporarily set {@link DoubleDouble#DISABLED} to {@code true}, * in which case the SIS results should be strictly identical to the JAMA ones. * * @see SolverTest#TOLERANCE * @see NonSquareMatrixTest#printStatistics() */ protected static final double TOLERANCE = DoubleDouble.DISABLED ? STRICT : 1E-11; /** * Number of random matrices to try in arithmetic operation tests. */ static final int NUMBER_OF_REPETITIONS = 100; /** * The threshold in matrix determinant for attempting to compute the inverse. * Matrix with a determinant of 0 are not invertible, but we keep a margin for safety. */ private static final double DETERMINANT_THRESHOLD = 0.001; /** * Statistics about the different between the JAMA and SIS matrix elements, or {@code null} * if those statistics do not need to be collected. This is used during the test development * phase for tuning the tolerance threshold. * * @see NonSquareMatrixTest#printStatistics() */ static final Statistics statistics = VERBOSE ? new Statistics("|SIS - JAMA|") : null; /** * Random number generator, created by {@link #initialize(long)} as the first operation of * any test method which will use random numbers. This random number generator will use a * fixed seed if {@link #DETERMINIST} is {@code true}, which is the normal case. */ private Random random; /** * For subclasses only. */ MatrixTestCase() { } /** * Initializes the random number generator to the given seed. If {@link #DETERMINIST} is {@code false} * (which happen only when performing some more extensive tests), then the given seed will be replaced * by a random one. * * @param seed the initial seed. */ final void initialize(final long seed) { random = DETERMINIST ? new Random(seed) : TestUtilities.createRandomNumberGenerator(); } /** * Computes a random size for the next matrix to create. This method is overridden * only by subclasses that test matrix implementations supporting arbitrary sizes. * * @param random the random number generator to use for computing a random matrix size. */ void prepareNewMatrixSize(final Random random) { } /** Returns the number of rows of the matrix being tested. */ abstract int getNumRow(); /** Returns the number of columns of the matrix being tested. */ abstract int getNumCol(); /** * Validates the given matrix. * The default implementation verifies only the matrix size. Subclasses should override this method * for additional checks, typically ensuring that it is an instance of the expected class. */ void validate(final MatrixSIS matrix) { assertEquals("numRow", getNumRow(), matrix.getNumRow()); assertEquals("numCol", getNumCol(), matrix.getNumCol()); } /** * Verifies that the SIS matrix is equals to the JAMA one, up to the given tolerance value. * * @param expected the JAMA matrix used as a reference implementation. * @param actual the SIS matrix to compare to JAMA. * @param tolerance the tolerance threshold, usually either {@link #STRICT} or {@link #TOLERANCE}. */ static void assertEqualsJAMA(final Matrix expected, final MatrixSIS actual, final double tolerance) { final int numRow = actual.getNumRow(); final int numCol = actual.getNumCol(); assertEquals("numRow", expected.getRowDimension(), numRow); assertEquals("numCol", expected.getColumnDimension(), numCol); final String name = actual.getClass().getSimpleName(); for (int j=0; j<numRow; j++) { for (int i=0; i<numCol; i++) { final double e = expected.get(j,i); final double a = actual.getElement(j,i); assertEquals(name, e, a, tolerance); assertEquals(name, e, actual.getNumber(j,i).doubleValue(), tolerance); if (tolerance != STRICT && statistics != null) { synchronized (statistics) { statistics.accept(StrictMath.abs(e - a)); } } } } } /** * Asserts that the given matrix is equals to the given expected values, up to the given tolerance threshold. * This method compares the elements values in two slightly redundant ways. */ static void assertEqualsElements(final double[] expected, final int numRow, final int numCol, final MatrixSIS actual, final double tolerance) { assertEquals("numRow", numRow, actual.getNumRow()); assertEquals("numCol", numCol, actual.getNumCol()); assertArrayEquals(expected, actual.getElements(), tolerance); // First because more informative in case of failure. assertTrue(Matrices.create(numRow, numCol, expected).equals(actual, tolerance)); } /** * Asserts that an element from the given matrix is equals to the expected value, using a relative threshold. */ private static void assertEqualsRelative(final String message, final double expected, final MatrixSIS matrix, final int row, final int column) { assertEquals(message, expected, matrix.getElement(row, column), StrictMath.abs(expected) * 1E-12); } /** * Returns the next random number as a value between approximately -100 and 100 * with the guarantee to be different than zero. The values returned by this method * are suitable for testing scale factors. */ private double nextNonZeroRandom() { double value = random.nextDouble() * 200 - 100; value += StrictMath.copySign(0.001, value); if (random.nextBoolean()) { value = 1 / value; } return value; } /** * Creates an array of the given length filled with random values. All random values are between 0 inclusive * and 100 exclusive. This method never write negative values. Consequently, any strictly negative value set * by the test method is guaranteed to be different than all original values in the returned array. */ final double[] createRandomPositiveValues(final int length) { final double[] elements = new double[length]; for (int k=0; k<length; k++) { elements[k] = random.nextDouble() * 100; } return elements; } /** * Creates a matrix initialized with a random array of element values, * then tests the {@link MatrixSIS#getElement(int, int)} method for each element. * This test will use {@link Matrices#create(int, int, double[])} for creating the matrix. * * <p>If this test fails, then all other tests in this class will be skipped since it would * not be possible to verify the result of any matrix operation.</p> */ @Test public void testGetElements() { initialize(3812872376135347328L); prepareNewMatrixSize(random); final int numRow = getNumRow(); final int numCol = getNumCol(); final double[] elements = createRandomPositiveValues(numRow * numCol); final MatrixSIS matrix = Matrices.create(numRow, numCol, elements); validate(matrix); /* * The JAMA constructor uses column-major array (FORTRAN convention), while SIS uses * row-major array. So we have to transpose the JAMA matrix after construction. */ assertEqualsJAMA(new Matrix(elements, numCol).transpose(), matrix, STRICT); assertArrayEquals("getElements", elements, matrix.getElements(), STRICT); } /** * Tests {@link MatrixSIS#getElement(int, int)} and {@link MatrixSIS#setElement(int, int, double)}. * This test sets random values in elements at random index, and compares with a JAMA matrix taken * as the reference implementation. */ @Test @DependsOnMethod("testGetElements") public void testSetElement() { initialize(-8079924100564483073L); prepareNewMatrixSize(random); final int numRow = getNumRow(); final int numCol = getNumCol(); final MatrixSIS matrix = Matrices.createZero(numRow, numCol); validate(matrix); final Matrix reference = new Matrix(numRow, numCol); /* * End of initialization - now perform the actual test. */ assertEqualsJAMA(reference, matrix, STRICT); for (int k=0; k<NUMBER_OF_REPETITIONS; k++) { final int j = random.nextInt(numRow); final int i = random.nextInt(numCol); final double e = random.nextDouble() * 100; reference.set(j, i, e); matrix.setElement(j, i, e); assertEqualsJAMA(reference, matrix, STRICT); } } /** * Tests {@link MatrixSIS#isIdentity()}. This method will first invoke {@link Matrices#createDiagonal(int, int)} * and ensure that the result contains 1 on the diagonal and 0 elsewhere. * * <p>This method will opportunistically tests {@link MatrixSIS#isAffine()}. The two methods are related * since {@code isIdentity()} delegates part of its work to {@code isAffine()}.</p> */ @Test @DependsOnMethod("testSetElement") public void testIsIdentity() { initialize(6173145457052452823L); prepareNewMatrixSize(random); final int numRow = getNumRow(); final int numCol = getNumCol(); final MatrixSIS matrix = Matrices.createDiagonal(numRow, numCol); validate(matrix); /* * End of initialization - now perform the actual test. */ assertEquals("isAffine", numRow == numCol, matrix.isAffine()); assertEquals("isIdentity", numRow == numCol, matrix.isIdentity()); for (int j=0; j<numRow; j++) { for (int i=0; i<numCol; i++) { final double element = matrix.getElement(j,i); assertEquals((i == j) ? 1 : 0, element, STRICT); matrix.setElement(j, i, random.nextDouble() - 1.1); assertEquals("isAffine", (numRow == numCol) && (j != numRow-1), matrix.isAffine()); assertFalse("isIdentity", matrix.isIdentity()); matrix.setElement(j, i, element); } } assertEquals("isAffine", numRow == numCol, matrix.isAffine()); assertEquals("isIdentity", numRow == numCol, matrix.isIdentity()); } /** * Tests {@link MatrixSIS#clone()}, {@link MatrixSIS#equals(Object)} and {@link MatrixSIS#hashCode()}. */ @Test @DependsOnMethod("testSetElement") public void testCloneEquals() { initialize(-4572234104840706847L); prepareNewMatrixSize(random); final int numRow = getNumRow(); final int numCol = getNumCol(); final double[] elements = createRandomPositiveValues(numRow * numCol); final MatrixSIS matrix = Matrices.create(numRow, numCol, elements); final MatrixSIS clone = matrix.clone(); validate(matrix); validate(clone); assertNotSame("clone", matrix, clone); assertEquals("equals", matrix, clone); assertEquals("hashCode", matrix.hashCode(), clone.hashCode()); for (int j=0; j<numRow; j++) { for (int i=0; i<numCol; i++) { final double element = clone.getElement(j,i); clone.setElement(j, i, random.nextDouble() - 2); // Negative value is guaranteed to be different. assertFalse(matrix.equals(clone)); assertFalse(clone.equals(matrix)); clone.setElement(j, i, element); } } assertEquals("equals", matrix, clone); } /** * Tests {@link MatrixSIS#transpose()}. */ @Test @DependsOnMethod("testGetElements") public void testTranspose() { initialize(585037875560696050L); prepareNewMatrixSize(random); final int numRow = getNumRow(); final int numCol = getNumCol(); final double[] elements = createRandomPositiveValues(numRow * numCol); final MatrixSIS matrix = Matrices.create(numRow, numCol, elements); validate(matrix); /* * The JAMA constructor uses column-major array (FORTRAN convention) while SIS uses row-major * array. In other words, the JAMA matrix is already transposed from the SIS point of view. */ matrix.transpose(); assertEqualsJAMA(new Matrix(elements, numCol), matrix, STRICT); } /** * Tests {@link MatrixSIS#normalizeColumns()}. */ @Test @DependsOnMethod("testGetElements") public void testNormalizeColumns() { initialize(1549772118153010333L); prepareNewMatrixSize(random); final int numRow = getNumRow(); final int numCol = getNumCol(); final double[] elements = createRandomPositiveValues(numRow * numCol); final MatrixSIS matrix = Matrices.create(numRow, numCol, elements); validate(matrix); matrix.normalizeColumns(); for (int i=0; i<numCol; i++) { double m = 0; for (int j=0; j<numRow; j++) { final double e = matrix.getElement(j, i); m += e*e; } m = StrictMath.sqrt(m); assertEquals(1, m, 1E-12); } } /** * Tests {@link MatrixSIS#convertBefore(int, Number, Number)} using {@link AffineTransform} * as a reference implementation. This test can be run only with matrices of size 3×3. * Consequently it is sub-classes responsibility to add a {@code testConvertBefore()} method * which invoke this method. * * @param matrix the matrix of size 3×3 to test. * @param withShear {@code true} for including shear in the matrix to test. * This value can be set to {@code false} if the subclass want to test a simpler case. * * @since 0.6 */ final void testConvertBefore(final MatrixSIS matrix, final boolean withShear) { initialize(4599164481916500056L); final AffineTransform at = new AffineTransform(); if (withShear) { at.shear(nextNonZeroRandom(), nextNonZeroRandom()); matrix.setElement(0, 1, at.getShearX()); matrix.setElement(1, 0, at.getShearY()); } for (int i=0; i<NUMBER_OF_REPETITIONS; i++) { /* * 1) For the first 30 iterations, test the result of applying only a scale. * 2) For the next 30 iterations, test the result of applying only a translation. * 3) For all remaining iterations, test combination of scale and translation. */ final Number scale = (i >= 60 || i < 30) ? nextNonZeroRandom() : null; final Number offset = (i >= 30) ? nextNonZeroRandom() : null; /* * Apply the scale and offset on the affine transform, which we use as the reference * implementation. The scale and offset must be applied in the exact same order than * the order documented in MatrixSIS.concatenate(…) javadoc. */ final int srcDim = (i & 1); if (offset != null) { switch (srcDim) { case 0: at.translate(offset.doubleValue(), 0); break; case 1: at.translate(0, offset.doubleValue()); break; } } if (scale != null) { switch (srcDim) { case 0: at.scale(scale.doubleValue(), 1); break; case 1: at.scale(1, scale.doubleValue()); break; } } /* * Apply the operation and compare with our reference implementation. */ matrix.convertBefore(srcDim, scale, offset); assertCoefficientsEqual(at, matrix); } } /** * Asserts that the given matrix has approximately the same coefficients than the given affine transform. */ private static void assertCoefficientsEqual(final AffineTransform at, final MatrixSIS matrix) { assertEqualsRelative("m20", 0, matrix, 2, 0); assertEqualsRelative("m21", 0, matrix, 2, 1); assertEqualsRelative("m22", 1, matrix, 2, 2); assertEqualsRelative("translateX", at.getTranslateX(), matrix, 0, 2); assertEqualsRelative("translateY", at.getTranslateY(), matrix, 1, 2); assertEqualsRelative("scaleX", at.getScaleX(), matrix, 0, 0); assertEqualsRelative("scaleY", at.getScaleY(), matrix, 1, 1); assertEqualsRelative("shearX", at.getShearX(), matrix, 0, 1); assertEqualsRelative("shearY", at.getShearY(), matrix, 1, 0); assertTrue("isAffine", matrix.isAffine()); } /** * Tests {@link MatrixSIS#convertAfter(int, Number, Number)} using {@link AffineTransform} * as a reference implementation. This test can be run only with matrices of size 3×3. * Consequently it is sub-classes responsibility to add a {@code testConvertAfter()} method * which invoke this method. * * @param matrix the matrix of size 3×3 to test. * * @since 0.6 */ final void testConvertAfter(final MatrixSIS matrix) { initialize(6501103578268988251L); final AffineTransform pre = new AffineTransform(); final AffineTransform at = AffineTransform.getShearInstance(nextNonZeroRandom(), nextNonZeroRandom()); matrix.setElement(0, 1, at.getShearX()); matrix.setElement(1, 0, at.getShearY()); for (int i=0; i<NUMBER_OF_REPETITIONS; i++) { final Number scale = nextNonZeroRandom(); final Number offset = nextNonZeroRandom(); final int tgtDim = (i & 1); switch (tgtDim) { default: pre.setToIdentity(); break; case 0: pre.setToTranslation(offset.doubleValue(), 0); pre.scale(scale.doubleValue(), 1); break; case 1: pre.setToTranslation(0, offset.doubleValue()); pre.scale(1, scale.doubleValue()); break; } at.preConcatenate(pre); matrix.convertAfter(tgtDim, scale, offset); assertCoefficientsEqual(at, matrix); } } /** * Tests {@link MatrixSIS#translate(double[])} using {@link AffineTransform} as a reference implementation. * This test can be run only with matrices of size 3×3. Consequently it is sub-classes responsibility to add * a {@code testTranslateVector()} method which invoke this method. * * @param matrix an initially empty matrix of size 3×3 to test. * * @since 1.0 */ final void testTranslateVector(final MatrixSIS matrix) { initialize(-1691066807752485433L); final AffineTransform at = new AffineTransform(); final double vector[] = new double[] {0, 0, 1}; for (int n=0; n<NUMBER_OF_REPETITIONS; n++) { if ((n % 10) == 0) { setRandomValues(at, matrix); } at.translate(vector[0] = random.nextDouble() * 50 - 25, vector[1] = random.nextDouble() * 50 - 25); matrix.translate(vector); assertMatrixEquals("translate", AffineTransforms2D.toMatrix(at), matrix, TOLERANCE); } } /** * Sets random values in the given affine transform and a copy of those values in the given matrix. */ private void setRandomValues(final AffineTransform at, final MatrixSIS matrix) { at.setToRotation(random.nextDouble() * StrictMath.PI); at.scale(nextNonZeroRandom(), nextNonZeroRandom()); at.translate(random.nextDouble() * 100 - 50, random.nextDouble() * 100 - 50); matrix.setElements(new double[] { at.getScaleX(), at.getShearX(), at.getTranslateX(), at.getShearY(), at.getScaleY(), at.getTranslateY(), 0, 0, 1 }); } /** * Tests {@link MatrixSIS#multiply(double[])} using {@link AffineTransform} as a reference implementation. * This test can be run only with matrices of size 3×3. Consequently it is sub-classes responsibility to add * a {@code testMultiplyVector()} method which invoke this method. * * @param matrix an initially empty matrix of size 3×3 to test. * * @since 0.8 */ final void testMultiplyVector(final MatrixSIS matrix) { initialize(8433903323905121506L); final AffineTransform at = new AffineTransform(); final double vector[] = new double[3]; for (int n=0; n<NUMBER_OF_REPETITIONS; n++) { if ((n % 10) == 0) { setRandomValues(at, matrix); } vector[0] = random.nextDouble() * 50 - 25; vector[1] = random.nextDouble() * 50 - 25; vector[2] = 1; final double[] result = matrix.multiply(vector); // The result to verify. at.transform(vector, 0, vector, 0, 1); // The expected result. assertEquals("x", vector[0], result[0], TOLERANCE); assertEquals("y", vector[1], result[1], TOLERANCE); } } /** * Tests {@link MatrixSIS#multiply(org.opengis.referencing.operation.Matrix)}. */ @Test @DependsOnMethod("testGetElements") public void testMultiply() { initialize(2478887638739725150L); for (int n=0; n<NUMBER_OF_REPETITIONS; n++) { prepareNewMatrixSize(random); final int numRow = getNumRow(); final int numCol = getNumCol(); double[] elements = createRandomPositiveValues(numRow * numCol); final MatrixSIS matrix = Matrices.create(numRow, numCol, elements); final Matrix reference = new Matrix(elements, numCol).transpose(); /* * Computes new random value for the argument. We mix positive and negative values, * but with more positive values than negative ones in order to reduce the chances * to have a product of zero for an element. */ final int nx = random.nextInt(8) + 1; elements = new double[numCol * nx]; for (int k=0; k<elements.length; k++) { elements[k] = 8 - random.nextDouble() * 10; } final Matrix referenceArg = new Matrix(elements, nx).transpose(); final MatrixSIS matrixArg = Matrices.create(numCol, nx, elements); /* * Performs the multiplication and compare. */ final Matrix referenceResult = reference.times(referenceArg); final MatrixSIS matrixResult = matrix.multiply(matrixArg); assertEqualsJAMA(referenceResult, matrixResult, TOLERANCE); } } /** * Tests {@link MatrixSIS#solve(org.opengis.referencing.operation.Matrix)}. * * @throws NoninvertibleMatrixException if the matrix can not be inverted. */ @Test @DependsOnMethod("testMultiply") public void testSolve() throws NoninvertibleMatrixException { initialize(2108474073121762243L); for (int n=0; n<NUMBER_OF_REPETITIONS; n++) { prepareNewMatrixSize(random); final int numRow = getNumRow(); final int numCol = getNumCol(); double[] elements = createRandomPositiveValues(numRow * numCol); final Matrix reference = new Matrix(elements, numCol).transpose(); if (!(reference.det() >= DETERMINANT_THRESHOLD)) { continue; // To close to a singular matrix - search an other one. } final MatrixSIS matrix = Matrices.create(numRow, numCol, elements); /* * Computes new random value for the argument. We mix positive and negative values, * but with more positive values than negative ones in order to reduce the chances * to have a product of zero for an element. */ final int nx = random.nextInt(8) + 1; elements = new double[numCol * nx]; for (int k=0; k<elements.length; k++) { elements[k] = 8 - random.nextDouble() * 10; } final Matrix referenceArg = new Matrix(elements, nx).transpose(); final MatrixSIS matrixArg = Matrices.create(numCol, nx, elements); /* * Performs the operation and compare. */ final Matrix referenceResult = reference.solve(referenceArg); final MatrixSIS matrixResult = matrix.solve(matrixArg); assertEqualsJAMA(referenceResult, matrixResult, SolverTest.TOLERANCE); } } /** * Tests {@link MatrixSIS#inverse()}. * SIS implements the {@code inverse} operation as a special case of the {@code solve} operation. * * @throws NoninvertibleMatrixException if the matrix can not be inverted. */ @Test @DependsOnMethod("testSolve") public void testInverse() throws NoninvertibleMatrixException { initialize(-9063921123024549789L); for (int n=0; n<NUMBER_OF_REPETITIONS; n++) { prepareNewMatrixSize(random); final int numRow = getNumRow(); final int numCol = getNumCol(); final double[] elements = createRandomPositiveValues(numRow * numCol); final Matrix reference = new Matrix(elements, numCol).transpose(); if (!(reference.det() >= DETERMINANT_THRESHOLD)) { continue; // To close to a singular matrix - search an other one. } final MatrixSIS matrix = Matrices.create(numRow, numCol, elements); assertEqualsJAMA(reference.inverse(), matrix.inverse(), TOLERANCE); } } /** * Tests matrix serialization. */ @Test public void testSerialization() { initialize(-3232759118744327281L); prepareNewMatrixSize(random); final int numRow = getNumRow(); final int numCol = getNumCol(); final MatrixSIS matrix = Matrices.create(numRow, numCol, createRandomPositiveValues(numRow * numCol)); assertNotSame(matrix, assertSerializedEquals(matrix)); } }