/* * 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.test.referencing; import java.io.Flushable; import java.io.IOException; import java.io.PrintStream; import java.util.Random; import org.opengis.geometry.Envelope; import org.opengis.metadata.extent.GeographicBoundingBox; import org.opengis.referencing.IdentifiedObject; import org.opengis.referencing.crs.GeographicCRS; import org.opengis.referencing.crs.ProjectedCRS; import org.opengis.referencing.operation.*; import org.opengis.util.FactoryException; import org.apache.sis.geometry.Envelopes; import org.apache.sis.geometry.GeneralEnvelope; import org.apache.sis.metadata.iso.citation.Citations; import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox; import org.apache.sis.metadata.iso.extent.Extents; import org.apache.sis.referencing.CRS; import org.apache.sis.referencing.IdentifiedObjects; import org.apache.sis.referencing.crs.AbstractCRS; import org.apache.sis.referencing.cs.AxesConvention; import org.apache.sis.metadata.internal.ReferencingServices; import org.apache.sis.util.internal.StandardDateFormat; import org.apache.sis.referencing.util.Formulas; import org.apache.sis.io.TableAppender; import org.apache.sis.math.Statistics; /** * Compares coordinate operations performed with Apache SIS and {@literal Proj.4}. * This class is designed for being run from the command line, potentially with output redirected to a file. * Example: * *
{@code java org.apache.sis.test.referencing.CoordinateOperationComparator 1 --tabs > result.txt}
* * The {@code --tabs} option is for using tabulations as column separator instead than formatted outputs. * This make importation in spreadsheets easier. * * @author Martin Desruisseaux (Geomatys) * @version 0.8 * @since 0.8 * @module */ public class CoordinateOperationComparator { /** * Creates one of the predefined tests identified by the given sequential number. All pre-defined tests known * to the {@link #main(String[]) method} are listed here. This list may be expanded in any future version. * * @param n sequential number of the test to create. * @return the pre-defined test identified by the given number, or {@code null} if none. */ private static CoordinateOperationComparator create(final int n) throws FactoryException, TransformException, IOException { switch (n) { case 1: return new CoordinateOperationComparator("Cylindrical Equal Area (Spherical)", 4053, 3410); case 2: return new CoordinateOperationComparator("Cylindrical Equal Area", 4326, 6933); case 3: return new CoordinateOperationComparator("Pseudo-Mercator", 4326, 3857); case 4: return new CoordinateOperationComparator("Mercator", 4326, 3395); case 5: return new CoordinateOperationComparator("Lambert Conic Conformal", 4269, 3978); case 6: return new CoordinateOperationComparator("Polar stereographic", 4326, 3031); case 7: return new CoordinateOperationComparator("Albert Equal Area", 4269, 5070); case 8: return new CoordinateOperationComparator("Mercator 41 to Mercator", 3994, 3395); case 9: return new CoordinateOperationComparator("Tokyo to JGD2000", 4301, 4612); case 10: return new CoordinateOperationComparator("Tokyo to JGD2000 in UTM zone 54", 3095, 3100); case 11: return new CoordinateOperationComparator("OSGB 1936 to ED50 (UKOOA)", 4277, 4230); case 12: return new CoordinateOperationComparator("Martinique 1938 to RGAF09", 4625, 5489); case 13: return new CoordinateOperationComparator("Stereographic to stereographic", 2986, 7082); case 14: { /* * Use the same eccentricity than Jupiter, but scaled to Earth size in order to * have distance measurement in the same order of magnitude than other tests. * The inverse flattening factor is about 15.4144 (compared to 298.26 for Earth). * Such planet would have a polar radius of 5964 km instead of 6357 km. * * Reference: https://nssdc.gsfc.nasa.gov/planetary/factsheet/jupiterfact.html */ final ProjectedCRS targetCRS = (ProjectedCRS) CRS.fromWKT( "ProjectedCRS[“Lambert Equal Area on high eccentricity planet”,\n" + " BaseGeodCRS[“High eccentricity planet”,\n" + " Datum[“High eccentricity datum”,\n" + " Ellipsoid[“Jupiter eccentricity scaled to Earth size”, 6378100.0, 15.414402759810256]],\n" + " Unit[“degree”, 0.017453292519943295]],\n" + " Conversion[“High eccentricity planet to Lambert Equal Area”,\n" + " Method[“Lambert Cylindrical Equal Area”],\n" + " Parameter[“Latitude of 1st standard parallel”, 30]],\n" + " CS[Cartesian, 2],\n" + " Axis[“Easting (E)”, east],\n" + " Axis[“Northing (N)”, north],\n" + " Unit[“metre”, 1],\n" + " Scope[“Test only.”],\n" + " BBox[-86, -180, 86, 180]]"); final GeographicCRS sourceCRS = (GeographicCRS) AbstractCRS.castOrCopy(targetCRS.getBaseCRS()) .forConvention(AxesConvention.NORMALIZED); return new CoordinateOperationComparator("Jupiter eccentricity scaled to Earth size", new String[] {"Apache SIS", "Proj.4"}, targetCRS.getConversionFromBase(), Proj4.createOperation(sourceCRS, targetCRS, true)); } default: return null; } } /** * Number of dimensions in all coordinates to be used for the tests. */ private static final int DIM = 2; /** * Number of test coordinates along x and y axes (must be a power of 2). * The total number of coordinates used in each iteration will the the square of this value. */ private static final int NUM_PTS = 256; /** * Number of "transform projection - inverse projection" cycles to do. * GIGS (Geospatial Integrity of Geoscience Applications) recommends 100 iterations. */ private static final int NUM_LOOPS = 100; /** * Number of cycles to skip (for HotSpot warmup) before to collect statistics about performance. * Note that statistics about accuracy are collected for all iterations, regardless this value. */ private static final int WARMUP_LOOPS = 10; /** * The coordinates to transform as (x₀,y₀), (x₁,y₁), (x₂,y₂), (x₃,y₃), etc. tupples. * This array will be initialized by the constructor, then never modified. */ private final double[] inputs; /** * The array where to store results of coordinate operations. * This array will be overwritten at each iteration. */ private final double[] outputs; /** * {@code true} if the CRS is geographic, or {@code false} if the CRS is projected. * If {@code true}, then distances will be estimated using the nautical mile as an approximation. * * @see #distance(boolean, double[], double[], int) */ private final boolean isSourceGeographic, isTargetGeographic; /** * The coordinate operation defined by EPSG as provided by Apache SIS. This is the most authoritative * source of information that we have, since Apache SIS follows more closely EPSG definitions than many * other map projection libraries. However, axis order may be different than the order expected by other * implementations like Proj.4. */ private final CoordinateOperation authoritative; /** * The coordinate operations to compare. The first operation should be the same than {@link #authoritative}, * but with (longitude, latitude) axis order. We use that axis order for avoiding * complications with the use of Proj.4 or similar libraries. */ private final CoordinateOperation[] normalized; /** * A title for the test to be run, for information purpose only. */ private final String title; /** * Names of the implementation being tested, for information purpose only. */ private final String[] implementationNames; /** * Where to write benchmark results. */ @SuppressWarnings("UseOfSystemOutOrSystemErr") private static final PrintStream out = System.out; /** * {@code true} for using tabulations instead than formatting in tables. * This is enabled by the {@code --tabs}} flag on the command-line. */ private static boolean useTabulations; /** * {@code true} for flushing the output stream after each line when writing a table. * This allows more immediate feedback to the user, but sometime break the table layout. * We disable the immediate mode when writing to a file since the user would not see it anyway. */ private static boolean immediate; /** * Creates a comparator for coordinate operations between the given pair of EPSG codes. * Current implementations creates operations for Apache SIS and Proj.4 libraries, * but this list may be expanded in any future version. * * @param title a title for this benchmark. * @param source EPSG code of source CRS. * @param target EPSG code of target CRS. * @throws FactoryException if an error occurred while instantiating CRS or coordinate operation. * @throws TransformException if an error occurred while transforming coordinates. * @throws IOException if an error occurred while writing results. */ public CoordinateOperationComparator(final String title, final int source, final int target) throws FactoryException, TransformException, IOException { this(title, new String[] {"Apache SIS", "Proj.4"}, CRS.findOperation( CRS.forCode( "EPSG:" + source), CRS.forCode( "EPSG:" + target), null), Proj4.createOperation(Proj4.createCRS("+init=epsg:" + source + " +over", 2), Proj4.createCRS("+init=epsg:" + target + " +over", 2), true)); } /** * Creates a comparator for all the given coordinate operations. * The first coordinate operation shall be the authoritative one as defined by EPSG, * including domain of validity and positional accuracy metadata. * This is usually the operation created by Apache SIS. All other operations are implemented by other libraries. * All operations except the first (authoritative) one shall use the (longitude, latitude) * axis order, for simpler comparisons with Proj.4 and similar libraries. * * @param operations the operations to compare. The first operation shall be the authoritative one. * @param implementationNames names of the implementation being tested, for information purpose only. * @throws FactoryException if an error occurred while creating a coordinate operation. * @throws TransformException if an error occurred while computing the domain of validity. */ private CoordinateOperationComparator(final String title, final String[] implementationNames, CoordinateOperation... operations) throws FactoryException, TransformException, IOException { this.title = title; this.implementationNames = implementationNames.clone(); operations = operations.clone(); authoritative = operations[0]; final AbstractCRS sourceCRS = AbstractCRS.castOrCopy(authoritative.getSourceCRS()); final AbstractCRS targetCRS = AbstractCRS.castOrCopy(authoritative.getTargetCRS()); final AbstractCRS normalizedSource = sourceCRS.forConvention(AxesConvention.NORMALIZED); final AbstractCRS normalizedTarget = targetCRS.forConvention(AxesConvention.NORMALIZED); operations[0] = CRS.findOperation(normalizedSource, normalizedTarget, null); isSourceGeographic = (normalizedSource instanceof GeographicCRS); isTargetGeographic = (normalizedTarget instanceof GeographicCRS); normalized = operations; /* * Get the operation domain of validity in the units of source CRS, * then print a summary about the comparison we are about to do. */ GeographicBoundingBox bbox = CRS.getGeographicBoundingBox(authoritative); bbox = Extents.intersection(bbox, new DefaultGeographicBoundingBox(-179, 179, -89, 89)); final Envelope domain = Envelopes.transform(new GeneralEnvelope(bbox), normalizedSource); final Appendable table = newTable(); printIdentification(table, "Source CRS", sourceCRS); printIdentification(table, "Target CRS", targetCRS); printIdentification(table, "Operation", authoritative); printIdentification(table, "Method", method(authoritative)); table.append(String.format("Domain:\t\t%s%n", domain)); flush(table); /* * Fills the input array with random coordinates. * This array shall not be modified after construction. */ final Random random = new Random(); inputs = new double[NUM_PTS * NUM_PTS * DIM]; final double minX = domain.getMinimum(0); final double minY = domain.getMinimum(1); final double scaleX = domain.getSpan(0) / NUM_PTS; final double scaleY = domain.getSpan(1) / NUM_PTS; int n = 0; for (int j=0; j 0;) { final int j = random.nextInt(i) & ~1; double x = inputs[i ]; double y = inputs[i+1]; inputs[i ] = inputs[j ]; inputs[i+1] = inputs[j+1]; inputs[j ] = x; inputs[j+1] = y; } outputs = new double[inputs.length]; } /** * Get the operation method of the given coordinate operation. * This is used for information purpose only. */ private static OperationMethod method(final CoordinateOperation op) { if (op instanceof SingleOperation) { return ((SingleOperation) op).getMethod(); } if (op instanceof ConcatenatedOperation) { for (CoordinateOperation c : ((ConcatenatedOperation) op).getOperations()) { OperationMethod method = method(c); if (method != null) return method; } } return null; } /** * Compares the coordinate operation results of Apache SIS with other implementations. * This comparisons is performed before to execute the actual benchmark, so it already * causes some JVM warmup. For all comparisons, Apache SIS is taked as the reference. */ private void compareOperationResults() throws TransformException, IOException { out.printf("Difference in forward operation results between %s and other implementations:%n", implementationNames[0]); final Statistics stats = new Statistics("Difference"); final Appendable table = newTable(); table.append(String.format("Implementation\tDifference (m)\tStd. dev.%n")); final int numPts = inputs.length / DIM; normalized[0].getMathTransform().transform(inputs, 0, outputs, 0, numPts); final double[] intermediate = new double[inputs.length]; for (int j=1; j= 0;) { stats.accept(distance(isTargetGeographic, outputs, intermediate, i)); } table.append(String.format("%s\t%g\t%g%n", implementationNames[j], stats.mean(), stats.standardDeviation(false))); stats.reset(); } flush(table); } /** * Runs the benchmarks. First, this method compare operation results without measuring performance. * Then, this method performs "forward operation" followed by "inverse operation" one hundred times, * measuring performances and drifts at each iteration. * * @throws TransformException if an error occurred while transforming coordinates. * @throws IOException if an error occurred while writing results. */ public void run() throws TransformException, IOException { compareOperationResults(); for (int j=0; j= WARMUP_LOOPS) { forwardTime .accept(tf); inverseTime .accept(ti); cumulatedTime.accept(tc); } drift.reset(); } flush(table); out.println("Average execution time:"); table = newTable(); table.append(String.format("\tMean (ms)\tStd. dev%n")); table.append(String.format("Forward:\t%g\t%g%n", forwardTime.mean(), forwardTime.standardDeviation(false))); table.append(String.format("Inverse:\t%g\t%g%n", inverseTime.mean(), inverseTime.standardDeviation(false))); table.append(String.format("Cumulated:\t%g\t%g%n", cumulatedTime.mean(), cumulatedTime.standardDeviation(false))); flush(table); /* * Test with decreasing amount of coordinates transformed in one method call. * This increase the cost of determining which operation to execute. * We measure performance degradation caused by this increasing cost. */ out.println("Performance with decreasing amount of coordinates processed in one method call:"); table = newTable(); table.append(String.format("Block size\tForward time (ms)\tStd. dev.\tInverse time (ms)\tStd. dev.\tCumulated (ms)\tStd. dev.%n")); boolean first = true; for (int size = inputs.length / DIM; size >= 1; size /= 2) { forwardTime.reset(); inverseTime.reset(); cumulatedTime.reset(); for (int i=0; i<10; i++) { System.arraycopy(inputs, 0, outputs, 0, inputs.length); long tf = 0, ti = 0, tc = 0; for (int offset = 0; offset < inputs.length; offset += size * DIM) { final long t0 = System.nanoTime(); tr.transform(outputs, offset, outputs, offset, size); final long t1 = System.nanoTime(); inverse.transform(outputs, offset, outputs, offset, size); final long t2 = System.nanoTime(); tf += (t1 - t0); ti += (t2 - t1); tc += (t2 - t0); } forwardTime .accept(tf / (double) StandardDateFormat.NANOS_PER_MILLISECOND); inverseTime .accept(ti / (double) StandardDateFormat.NANOS_PER_MILLISECOND); cumulatedTime.accept(tc / (double) StandardDateFormat.NANOS_PER_MILLISECOND); } if (!first && immediate) ((Flushable) table).flush(); table.append(String.format("%d\t%g\t%g\t%g\t%g\t%g\t%g\n", size, forwardTime.mean(), forwardTime.standardDeviation(false), inverseTime.mean(), inverseTime.standardDeviation(false), cumulatedTime.mean(), cumulatedTime.standardDeviation(false))); collectDifferences(drift); first = false; } flush(table); if (drift.maximum() > Formulas.LINEAR_TOLERANCE) { out.println("WARNING: large drift in above performance check"); out.println(drift); } } /** * Adds distances between {@link #inputs} (expected coordinates) and {@link #outputs} (actual coordinates) to * the given statistics. This method is invoked after a full "forward operation - inverse operation" cycle. * * @param drift where to add statistics about distances between expected and actual coordinates. */ private void collectDifferences(final Statistics drift) { for (int i = inputs.length; (i -= DIM) >= 0;) { drift.accept(distance(isSourceGeographic, inputs, outputs, i)); } } /** * Returns an estimation of the distance between expected and actual coordinates. * *

NOTE: this method assumes that {@link #DIM} is equal to 2. * If this is no longer the case, replace {@link Math#hypot(double, double)} call * by {@link org.apache.sis.math.MathFunctions#magnitude(double...)}.

* * @param expected array of expected coordinates. * @param actual array of actual coordinates. * @param i index of the first ordinal value in {@code expected} and {@code actual} arrays. */ private static double distance(final boolean isGeographic, final double[] expected, final double[] actual, int i) { double d = Math.hypot(actual[i] - expected[i], actual[++i] - expected[i]); if (isGeographic) { d *= ReferencingServices.NAUTICAL_MILE * 60; } return d; } /** * Returns the destination where to write next tabular data. */ private static Appendable newTable() { if (useTabulations) return out; TableAppender table = new TableAppender(out); table.appendHorizontalSeparator(); return table; } /** * Flushes the output created by {@link #newTable()} and append a new line. * After this method call, the table should not be used anymore. */ private static void flush(final Appendable table) throws IOException { if (table instanceof TableAppender) { ((TableAppender) table).appendHorizontalSeparator(); } ((Flushable) table).flush(); out.println(); } /** * Prints the name of the given identified objects. * * @param label label to write before the identified object. * @param object object for which to write the name. */ private static void printIdentification(final Appendable table, final String label, final IdentifiedObject object) throws IOException { String id = IdentifiedObjects.toString(IdentifiedObjects.getIdentifier(object, Citations.EPSG)); table.append(String.format("%s:\t%s\t%s%n", label, (id != null) ? id : "", object.getName().getCode())); } /** * Runs the benchmark identified by the given number. Each number identifies a particular pair of source * and target CRS. For example the test #1 applies the "Cylindrical Equal Area (Spherical)" projection. * See source code of this class for the list of available tests. * * @param args number of the test to execute. * @throws FactoryException if an error occurred while instantiating CRS or coordinate operation. * @throws TransformException if an error occurred while transforming coordinates. * @throws IOException if an error occurred while writing results. */ public static void main(final String[] args) throws FactoryException, TransformException, IOException { /* * Where to write error messages or information. While this stream is called "the error stream", it is actually * also used for printing information (for example progress) that we don't want to include in the result file. * Note that this is also the stream used by {@code java.util.logging}. */ @SuppressWarnings("UseOfSystemOutOrSystemErr") final PrintStream info = System.err; immediate = (System.console() != null); String error = null; int sourceCRS = 0, targetCRS = 0; for (final String p : args) { if (p.equals("--tabs")) { useTabulations = true; } else if (p.startsWith("--")) { error = String.format("Unrecognized option: %s", p); break; } else { final int n; try { n = Integer.parseInt(args[0]); } catch (NumberFormatException e) { error = String.format("Invalid test number or EPSG code: %s", e.getLocalizedMessage()); break; } if (n <= 0) { error = String.format("Invalid test number or EPSG code: %d", n); break; } if (sourceCRS == 0) { sourceCRS = n; } else if (targetCRS == 0) { targetCRS = n; } else { error = "Too many EPSG codes (expected 2)."; break; } } } /* * If there is two numbers, they are interpreted as EPSG codes of source and target CRS respectively. * But if there is only one number (targetCRS == 0), then the source CRS is interpreted as the number * of a pre-defined test. */ CoordinateOperationComparator c = null; if (error == null) { if (sourceCRS == 0) { error = String.format("Usage: one or two numbers%n" + " [sequential number of pre-defined test]%n" + " [EPSG code of source CRS] [EPSG code of targetCRS]"); } else if (targetCRS == 0) { info.printf("Initializing pre-defined test #%d...%n", sourceCRS); c = create(sourceCRS); if (c == null) { error = "Error: no such pre-defined test."; } } else { String name = String.format("EPSG:%d to EPSG:%d", sourceCRS, targetCRS); info.printf("Initializing test for %s...%n", name); c = new CoordinateOperationComparator(name, sourceCRS, targetCRS); } } if (c == null) { info.println(error); System.exit(1); } c.run(); } }