| 1 | // License: GPL. For details, see LICENSE file.
|
|---|
| 2 | package org.openstreetmap.josm.data.projection;
|
|---|
| 3 |
|
|---|
| 4 | import static org.junit.jupiter.api.Assertions.fail;
|
|---|
| 5 |
|
|---|
| 6 | import java.io.BufferedReader;
|
|---|
| 7 | import java.io.BufferedWriter;
|
|---|
| 8 | import java.io.File;
|
|---|
| 9 | import java.io.IOException;
|
|---|
| 10 | import java.io.OutputStreamWriter;
|
|---|
| 11 | import java.nio.charset.StandardCharsets;
|
|---|
| 12 | import java.nio.file.Files;
|
|---|
| 13 | import java.nio.file.Paths;
|
|---|
| 14 | import java.security.SecureRandom;
|
|---|
| 15 | import java.util.ArrayList;
|
|---|
| 16 | import java.util.List;
|
|---|
| 17 | import java.util.Locale;
|
|---|
| 18 | import java.util.Map;
|
|---|
| 19 | import java.util.Random;
|
|---|
| 20 | import java.util.Set;
|
|---|
| 21 | import java.util.TreeSet;
|
|---|
| 22 | import java.util.stream.Collectors;
|
|---|
| 23 |
|
|---|
| 24 | import org.junit.jupiter.api.Test;
|
|---|
| 25 | import org.openstreetmap.josm.JOSMFixture;
|
|---|
| 26 | import org.openstreetmap.josm.data.Bounds;
|
|---|
| 27 | import org.openstreetmap.josm.data.coor.EastNorth;
|
|---|
| 28 | import org.openstreetmap.josm.data.coor.LatLon;
|
|---|
| 29 | import org.openstreetmap.josm.testutils.annotations.ProjectionNadGrids;
|
|---|
| 30 | import org.openstreetmap.josm.tools.Pair;
|
|---|
| 31 | import org.openstreetmap.josm.tools.Utils;
|
|---|
| 32 |
|
|---|
| 33 | /**
|
|---|
| 34 | * This test is used to monitor changes in projection code.
|
|---|
| 35 | * <p>
|
|---|
| 36 | * It keeps a record of test data in the file nodist/data/projection/projection-regression-test-data.
|
|---|
| 37 | * This record is generated from the current Projection classes available in JOSM. It needs to
|
|---|
| 38 | * be updated, whenever a projection is added / removed or an algorithm is changed, such that
|
|---|
| 39 | * the computed values are numerically different. There is no error threshold, every change is reported.
|
|---|
| 40 | * <p>
|
|---|
| 41 | * So when this test fails, first check if the change is intended. Then update the regression
|
|---|
| 42 | * test data, by running the main method of this class and commit the new data file.
|
|---|
| 43 | */
|
|---|
| 44 | class ProjectionRegressionTest {
|
|---|
| 45 |
|
|---|
| 46 | private static final String PROJECTION_DATA_FILE = "nodist/data/projection/projection-regression-test-data";
|
|---|
| 47 |
|
|---|
| 48 | private static final class TestData {
|
|---|
| 49 | public String code;
|
|---|
| 50 | public LatLon ll;
|
|---|
| 51 | public EastNorth en;
|
|---|
| 52 | public LatLon ll2;
|
|---|
| 53 | }
|
|---|
| 54 |
|
|---|
| 55 | /**
|
|---|
| 56 | * Program entry point to update reference projection file.
|
|---|
| 57 | * @param args not used
|
|---|
| 58 | * @throws IOException if any I/O errors occurs
|
|---|
| 59 | */
|
|---|
| 60 | public static void main(String[] args) throws IOException {
|
|---|
| 61 | JOSMFixture.createUnitTestFixture().init();
|
|---|
| 62 |
|
|---|
| 63 | Map<String, Projection> supportedCodesMap = Projections.getAllProjectionCodes().stream()
|
|---|
| 64 | .collect(Collectors.toMap(code -> code, Projections::getProjectionByCode));
|
|---|
| 65 |
|
|---|
| 66 | List<TestData> prevData = new ArrayList<>();
|
|---|
| 67 | if (new File(PROJECTION_DATA_FILE).exists()) {
|
|---|
| 68 | prevData = readData();
|
|---|
| 69 | }
|
|---|
| 70 | Map<String, TestData> prevCodesMap = prevData.stream()
|
|---|
| 71 | .collect(Collectors.toMap(data -> data.code, data -> data));
|
|---|
| 72 |
|
|---|
| 73 | Set<String> codesToWrite = new TreeSet<>(supportedCodesMap.keySet());
|
|---|
| 74 | prevData.stream()
|
|---|
| 75 | .filter(data -> supportedCodesMap.containsKey(data.code)).map(data -> data.code)
|
|---|
| 76 | .forEach(codesToWrite::add);
|
|---|
| 77 |
|
|---|
| 78 | Random rand = new SecureRandom();
|
|---|
| 79 | try (BufferedWriter out = new BufferedWriter(new OutputStreamWriter(
|
|---|
| 80 | Files.newOutputStream(Paths.get(PROJECTION_DATA_FILE)), StandardCharsets.UTF_8))) {
|
|---|
| 81 | out.write("# Data for test/unit/org/openstreetmap/josm/data/projection/ProjectionRegressionTest.java\n");
|
|---|
| 82 | out.write("# Format: 1. Projection code; 2. lat/lon; 3. lat/lon projected -> east/north; 4. east/north (3.) inverse projected\n");
|
|---|
| 83 | for (String code : codesToWrite) {
|
|---|
| 84 | Projection proj = supportedCodesMap.get(code);
|
|---|
| 85 | Bounds b = proj.getWorldBoundsLatLon();
|
|---|
| 86 | double lat, lon;
|
|---|
| 87 | TestData prev = prevCodesMap.get(proj.toCode());
|
|---|
| 88 | if (prev != null) {
|
|---|
| 89 | lat = prev.ll.lat();
|
|---|
| 90 | lon = prev.ll.lon();
|
|---|
| 91 | } else {
|
|---|
| 92 | lat = b.getMin().lat() + rand.nextDouble() * (b.getMax().lat() - b.getMin().lat());
|
|---|
| 93 | lon = b.getMin().lon() + rand.nextDouble() * (b.getMax().lon() - b.getMin().lon());
|
|---|
| 94 | }
|
|---|
| 95 | EastNorth en = proj.latlon2eastNorth(new LatLon(lat, lon));
|
|---|
| 96 | LatLon ll2 = proj.eastNorth2latlon(en);
|
|---|
| 97 | out.write(String.format(
|
|---|
| 98 | "%s%n ll %s %s%n en %s %s%n ll2 %s %s%n", proj.toCode(), lat, lon, en.east(), en.north(), ll2.lat(), ll2.lon()));
|
|---|
| 99 | }
|
|---|
| 100 | }
|
|---|
| 101 | System.out.println("Update successful.");
|
|---|
| 102 | }
|
|---|
| 103 |
|
|---|
| 104 | private static List<TestData> readData() throws IOException {
|
|---|
| 105 | try (BufferedReader in = Files.newBufferedReader(Paths.get(PROJECTION_DATA_FILE), StandardCharsets.UTF_8)) {
|
|---|
| 106 | List<TestData> result = new ArrayList<>();
|
|---|
| 107 | String line;
|
|---|
| 108 | while ((line = in.readLine()) != null) {
|
|---|
| 109 | if (line.startsWith("#")) {
|
|---|
| 110 | continue;
|
|---|
| 111 | }
|
|---|
| 112 | TestData next = new TestData();
|
|---|
| 113 |
|
|---|
| 114 | Pair<Double, Double> ll = readLine("ll", in.readLine());
|
|---|
| 115 | Pair<Double, Double> en = readLine("en", in.readLine());
|
|---|
| 116 | Pair<Double, Double> ll2 = readLine("ll2", in.readLine());
|
|---|
| 117 |
|
|---|
| 118 | next.code = line;
|
|---|
| 119 | next.ll = new LatLon(ll.a, ll.b);
|
|---|
| 120 | next.en = new EastNorth(en.a, en.b);
|
|---|
| 121 | next.ll2 = new LatLon(ll2.a, ll2.b);
|
|---|
| 122 |
|
|---|
| 123 | result.add(next);
|
|---|
| 124 | }
|
|---|
| 125 | return result;
|
|---|
| 126 | }
|
|---|
| 127 | }
|
|---|
| 128 |
|
|---|
| 129 | private static Pair<Double, Double> readLine(String expectedName, String input) {
|
|---|
| 130 | String[] fields = input.trim().split("[ ]+", -1);
|
|---|
| 131 | if (fields.length != 3) throw new AssertionError();
|
|---|
| 132 | if (!fields[0].equals(expectedName)) throw new AssertionError();
|
|---|
| 133 | double a = Double.parseDouble(fields[1]);
|
|---|
| 134 | double b = Double.parseDouble(fields[2]);
|
|---|
| 135 | return Pair.create(a, b);
|
|---|
| 136 | }
|
|---|
| 137 |
|
|---|
| 138 | /**
|
|---|
| 139 | * Non-regression unit test.
|
|---|
| 140 | * @throws IOException if any I/O error occurs
|
|---|
| 141 | */
|
|---|
| 142 | @ProjectionNadGrids
|
|---|
| 143 | @Test
|
|---|
| 144 | void testNonRegression() throws IOException {
|
|---|
| 145 | List<TestData> allData = readData();
|
|---|
| 146 | Set<String> dataCodes = allData.stream().map(data -> data.code).collect(Collectors.toSet());
|
|---|
| 147 |
|
|---|
| 148 | StringBuilder fail = new StringBuilder();
|
|---|
| 149 |
|
|---|
| 150 | for (String code : Projections.getAllProjectionCodes()) {
|
|---|
| 151 | if (!dataCodes.contains(code)) {
|
|---|
| 152 | fail.append("Did not find projection ").append(code).append(" in test data!\n");
|
|---|
| 153 | }
|
|---|
| 154 | }
|
|---|
| 155 |
|
|---|
| 156 | double fact = 1200;
|
|---|
| 157 | if (Utils.getSystemProperty("os.name").toLowerCase(Locale.ENGLISH).startsWith("mac os x"))
|
|---|
| 158 | fact = 15000;
|
|---|
| 159 | for (TestData data : allData) {
|
|---|
| 160 | Projection proj = Projections.getProjectionByCode(data.code);
|
|---|
| 161 | if (proj == null) {
|
|---|
| 162 | fail.append("Projection ").append(data.code).append(" from test data was not found!\n");
|
|---|
| 163 | continue;
|
|---|
| 164 | }
|
|---|
| 165 | EastNorth en = proj.latlon2eastNorth(data.ll);
|
|---|
| 166 | LatLon ll2 = proj.eastNorth2latlon(data.en);
|
|---|
| 167 | if (!equalsJava9(en, data.en, fact)) {
|
|---|
| 168 | String error = String.format("%s (%s): Projecting latlon(%s,%s):%n" +
|
|---|
| 169 | " expected: eastnorth(%s,%s),%n" +
|
|---|
| 170 | " but got: eastnorth(%s,%s)!%n" +
|
|---|
| 171 | " test: [%s,%s] factor [%s,%s] threshold [%s,%s]%n",
|
|---|
| 172 | proj, data.code, data.ll.lat(), data.ll.lon(), data.en.east(), data.en.north(), en.east(), en.north(),
|
|---|
| 173 | Math.abs(en.east()-data.en.east()), Math.abs(en.north()-data.en.north()),
|
|---|
| 174 | Math.abs(en.east()-data.en.east())/Math.ulp(en.east()), Math.abs(en.north()-data.en.north())/Math.ulp(en.north()),
|
|---|
| 175 | Math.ulp(en.east()), Math.ulp(en.north()));
|
|---|
| 176 | fail.append(error);
|
|---|
| 177 | }
|
|---|
| 178 | if (!equalsJava9(ll2, data.ll2, fact)) {
|
|---|
| 179 | String error = String.format("%s (%s): Inverse projecting eastnorth(%s,%s):%n" +
|
|---|
| 180 | " expected: latlon(%s,%s),%n" +
|
|---|
| 181 | " but got: latlon(%s,%s)!%n" +
|
|---|
| 182 | " test: [%s,%s] factor [%s,%s], threshold [%s,%s]%n",
|
|---|
| 183 | proj, data.code, data.en.east(), data.en.north(), data.ll2.lat(), data.ll2.lon(), ll2.lat(), ll2.lon(),
|
|---|
| 184 | Math.abs(ll2.lat()-data.ll2.lat()), Math.abs(ll2.lon()-data.ll2.lon()),
|
|---|
| 185 | Math.abs(ll2.lat()-data.ll2.lat())/Math.ulp(ll2.lat()), Math.abs(ll2.lon()-data.ll2.lon())/Math.ulp(ll2.lon()),
|
|---|
| 186 | Math.ulp(ll2.lat()), Math.ulp(ll2.lon()));
|
|---|
| 187 | fail.append(error);
|
|---|
| 188 | }
|
|---|
| 189 | }
|
|---|
| 190 |
|
|---|
| 191 | if (fail.length() > 0) {
|
|---|
| 192 | System.err.println(fail);
|
|---|
| 193 | fail(fail.toString());
|
|---|
| 194 | }
|
|---|
| 195 | }
|
|---|
| 196 |
|
|---|
| 197 | private static boolean equalsDoubleMaxUlp(double d1, double d2, double fact) {
|
|---|
| 198 | // Due to error accumulation in projection computation, the difference can reach hundreds of ULPs
|
|---|
| 199 | // The worst error is 1168 ULP (followed by 816 ULP then 512 ULP) with:
|
|---|
| 200 | // NAD83 / Colorado South (EPSG:26955): Projecting latlon(32.24604527892822,-125.93039495227096):
|
|---|
| 201 | // expected: eastnorth(-1004398.8994415681,24167.8944844745),
|
|---|
| 202 | // but got: eastnorth(-1004398.8994415683,24167.894484478747)!
|
|---|
| 203 | return Math.abs(d1 - d2) <= fact * Math.ulp(d1);
|
|---|
| 204 | }
|
|---|
| 205 |
|
|---|
| 206 | private static boolean equalsJava9(EastNorth en1, EastNorth en2, double fact) {
|
|---|
| 207 | return equalsDoubleMaxUlp(en1.east(), en2.east(), fact) &&
|
|---|
| 208 | equalsDoubleMaxUlp(en1.north(), en2.north(), fact);
|
|---|
| 209 | }
|
|---|
| 210 |
|
|---|
| 211 | private static boolean equalsJava9(LatLon ll1, LatLon ll2, double fact) {
|
|---|
| 212 | return equalsDoubleMaxUlp(ll1.lat(), ll2.lat(), fact) &&
|
|---|
| 213 | equalsDoubleMaxUlp(ll1.lon(), ll2.lon(), fact);
|
|---|
| 214 | }
|
|---|
| 215 | }
|
|---|