diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74ece1f..383dc3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: '11' + java-version: '17' distribution: 'temurin' - name: Build with Gradle uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 diff --git a/build.gradle.kts b/build.gradle.kts index 089c76b..3053032 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,7 +43,7 @@ tasks.named("test") { java { toolchain { - languageVersion.set(JavaLanguageVersion.of(8)) + languageVersion.set(JavaLanguageVersion.of(17)) } withSourcesJar() withJavadocJar() diff --git a/src/main/java/org/mitre/caasd/commons/AltitudePath.java b/src/main/java/org/mitre/caasd/commons/AltitudePath.java new file mode 100644 index 0000000..90f96eb --- /dev/null +++ b/src/main/java/org/mitre/caasd/commons/AltitudePath.java @@ -0,0 +1,200 @@ +package org.mitre.caasd.commons; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.nonNull; +import static java.util.Objects.requireNonNull; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; + +/** + * An AltitudePath is a sequence of altitudes measured in feet. An AltitudePath can be combined with + * a LatLong64Path (or LatLongPath) to produce a sequence of (Lat, Long, Altitude) locations. + *

+ * AltitudePaths support "null altitudes" by using a constant int value that is close to, but not + * exactly, Integer.MIN_VALUE (i.e., -2147482414) + * + * @param altitudesInFeet + */ +public record AltitudePath(int[] altitudesInFeet) { + + /** + * This int represents having "NO ALTITUDE VALUE" at this sequence index. The constant value + * used -2147482414. + */ + public static final int NULL_ALTITUDE = Integer.MIN_VALUE + 1234; + + private static final Base64.Encoder BASE_64_ENCODER = Base64.getUrlEncoder().withoutPadding(); + + public AltitudePath { + requireNonNull(altitudesInFeet); + } + + /** + * Convert a List of Distances to an AltitudePath. Any null Distances in the list are converted + * to the NULL_ALTITUDE constant. + */ + public static AltitudePath from(List altitudes) { + requireNonNull(altitudes); + int[] alts = altitudes.stream().mapToInt(dist -> asInt(dist)).toArray(); + return new AltitudePath(alts); + } + + public static AltitudePath from(Distance... dist) { + return from(List.of(dist)); + } + + /** + * Create an AltitudePath that contains only null altitudes. (This is useful for building + * VehiclePaths from data that does not include altitude data). + */ + public static AltitudePath ofNulls(int n) { + int[] altitudes = new int[n]; + Arrays.fill(altitudes, NULL_ALTITUDE); + return new AltitudePath(altitudes); + } + + private static int asInt(Distance dist) { + return nonNull(dist) ? (int) dist.inFeet() : NULL_ALTITUDE; + } + + /** + * Create a new AltitudePath from an array of bytes that looks like: {altitudeInFeet_0, + * altitudeInFeet_1, altitudeInFeet_2, ...} (each altitude is encoded as one 4-byte int) + */ + public static AltitudePath fromBytes(byte[] bytes) { + requireNonNull(bytes); + checkArgument(bytes.length % 4 == 0, "The byte[] must have a multiple of 4 bytes"); + + ByteBuffer buffer = ByteBuffer.wrap(bytes); + int[] altData = new int[bytes.length / 4]; + for (int i = 0; i < altData.length; i++) { + altData[i] = buffer.getInt(); + } + + return new AltitudePath(altData); + } + + /** + * Create a new AltitudePath object. + * + * @param base64Encoding The Base64 safe and URL safe (no padding) encoding of a AltitudePath's + * byte[] + * + * @return A new AltitudePath object. + */ + public static AltitudePath fromBase64Str(String base64Encoding) { + return AltitudePath.fromBytes(Base64.getUrlDecoder().decode(base64Encoding)); + } + + public int size() { + return altitudesInFeet.length; + } + + public Distance get(int i) { + return (altitudesInFeet[i] == NULL_ALTITUDE) ? null : Distance.ofFeet(altitudesInFeet[i]); + } + + /** @return This AltitudePath as a byte[] containing 4 bytes per int in the path */ + public byte[] toBytes() { + ByteBuffer buffer = ByteBuffer.allocate(size() * 4); + for (int i = 0; i < altitudesInFeet.length; i++) { + buffer.putInt(altitudesInFeet[i]); + } + return buffer.array(); + } + + /** @return The Base64 file and url safe encoding of this AltitudePath's byte[] . */ + public String toBase64() { + return BASE_64_ENCODER.encodeToString(toBytes()); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(altitudesInFeet); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + AltitudePath other = (AltitudePath) obj; + if (!Arrays.equals(altitudesInFeet, other.altitudesInFeet)) return false; + return true; + } + + /** + * Compute the "total distance" between the altitudes in these two paths. (Null altitudes are + * treated as if Altitude = 0 for that index) + *

+ * The distance computed here is the sum of the distances between "altitudes pairs" taken from + * the two paths (e.g. the distance btw the 1st altitude from both paths PLUS the distance btw + * the 2nd altitude from both paths PLUS the distance btw the 3rd altitude from both paths + * ...). + *

+ * The "distanceBtw" between identical paths will be 0. The "distanceBtw" between nearly + * identical paths will be small. The "distanceBtw" between two very different paths will be + * large. + *

+ * The computation requires both Paths to have the same size. This is an important requirement + * for making a DistanceMetric using this method. + * + * @param p1 A path + * @param p2 Another path + * + * @return The sum of the pair-wise distance measurements + */ + public static Distance distanceBtw(AltitudePath p1, AltitudePath p2) { + requireNonNull(p1); + requireNonNull(p2); + checkArgument(p1.size() == p2.size(), "Paths must have same size"); + + return distanceBtw(p1, p2, p1.size()); + } + + /** + * Compute the "total distance" between the first n altitudes of these two paths. (Null + * altitudes are treated as if Altitude = 0 for that index) + *

+ * The distance computed here is the sum of the distances between "altitude pairs" taken from + * the two paths (e.g. the distance btw the 1st altitude from both paths PLUS the distance btw + * the 2nd altitude from both paths PLUS the distance btw the 3rd altitude from both paths + * ...). + *

+ * The "distanceBtw" between two identical paths will be 0. The "distanceBtw" between two nearly + * identical paths will be small. The "distanceBtw" between two very different paths will be + * large. + *

+ * This The computation requires both Paths to have the same size. This is an important + * requirement for making a DistanceMetric using this method. + * + * @param p1 A path + * @param p2 Another path + * @param n The number of points considered in the "path distance" computation + * + * @return The sum of the pair-wise distance measurements + */ + public static Distance distanceBtw(AltitudePath p1, AltitudePath p2, int n) { + requireNonNull(p1); + requireNonNull(p2); + checkArgument(n >= 0); + checkArgument(p1.size() >= n, "Path1 does not have the required length"); + checkArgument(p2.size() >= n, "Path2 does not have the required length"); + + double distanceSum = 0; + for (int i = 0; i < n; i += 1) { + // if either altitude is missing use 0 as the "stand in" altitude value + int mine = p1.altitudesInFeet[i] == NULL_ALTITUDE ? 0 : p1.altitudesInFeet[i]; + int his = p2.altitudesInFeet[i] == NULL_ALTITUDE ? 0 : p2.altitudesInFeet[i]; + distanceSum += Math.abs(mine - his); + } + + return Distance.ofFeet(distanceSum); + } +} diff --git a/src/main/java/org/mitre/caasd/commons/LatLong64.java b/src/main/java/org/mitre/caasd/commons/LatLong64.java index 10306c7..ee6d7e5 100644 --- a/src/main/java/org/mitre/caasd/commons/LatLong64.java +++ b/src/main/java/org/mitre/caasd/commons/LatLong64.java @@ -13,7 +13,7 @@ * used here saves 50% of the space while maintaining numeric equivalence for the first 7 decimal * places. This class uses two 32-bit ints to encode latitude and longitude values (as opposed to * directly storing the values in two 64-bit doubles). This space savings is relevant when you store - * many latitude & longitude pairs as seen in {@link LatLong64Path}. + * many latitude and longitude pairs as seen in {@link LatLong64Path}. *

* The goal of this class is to support a convenient transition to a more compact form for * {@link LatLongPath} data. It is NOT the goal of this class to provide a near-duplicate of @@ -24,7 +24,7 @@ * LatLong64. "LatLong.of(20*PI, -10*PI)" stores the 2 double primitives: (62.83185307179586, * -31.41592653589793). Whereas "LatLong64.of(20*PI, -10*PI)" stores 2 ints that equate to the * values: (62.8318531, -31.4159265). Notice, these approximate values are perfect to the 7th - * decimal place. Geo-Location data is difficult & expensive to measure beyond this level of + * decimal place. Geo-Location data is difficult and expensive to measure beyond this level of * accuracy. *

* LatLong64 purposefully does not implement java.io.Serializable. Instead, this class provides 3 diff --git a/src/main/java/org/mitre/caasd/commons/LatLong64Path.java b/src/main/java/org/mitre/caasd/commons/LatLong64Path.java index 4d3eb5c..89e0b9d 100644 --- a/src/main/java/org/mitre/caasd/commons/LatLong64Path.java +++ b/src/main/java/org/mitre/caasd/commons/LatLong64Path.java @@ -12,9 +12,9 @@ import java.util.stream.Stream; /** - * This class provides a byte-efficient way to store many latitude & longitude pairs. This class is - * NOT (currently) intended to duplicate the convenience of {@link LatLong} or {@link LatLongPath}. - * It simply provides a convenient transition to a more compact form: + * This class provides a byte-efficient way to store many latitude and longitude pairs. This class + * is NOT (currently) intended to duplicate the convenience of {@link LatLong} or + * {@link LatLongPath}. It simply provides a convenient transition to a more compact form: *

* The core usage idiom of this class is: * @@ -264,9 +264,8 @@ public static double distanceBtw(LatLong64Path p1, LatLong64Path p2) { * identical paths will be small. The "distanceBtw" between two very different paths will be * large. *

- * This - * The computation requires both Paths to have the same size. This is an important requirement - * for making a DistanceMetric using this method. + * This The computation requires both Paths to have the same size. This is an important + * requirement for making a DistanceMetric using this method. * * @param p1 A path * @param p2 Another path diff --git a/src/main/java/org/mitre/caasd/commons/PathPair.java b/src/main/java/org/mitre/caasd/commons/PathPair.java new file mode 100644 index 0000000..96028e0 --- /dev/null +++ b/src/main/java/org/mitre/caasd/commons/PathPair.java @@ -0,0 +1,57 @@ +package org.mitre.caasd.commons; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +/** + * A PathPair combines two VehiclePaths that have the same size. + *

+ * This record is for finding "similar Vehicle events". The idea is: "When important pair-wise + * vehicle interactions occur extract the path those two vehicles traveled around the time of the + * event. Then, keep a record of that "pair-wise interaction". This allows multiple important + * events that have the "similar paths" to be found. + * + * @param path0 + * @param path1 + */ +public record PathPair(VehiclePath path0, VehiclePath path1) { + + public PathPair { + requireNonNull(path0); + requireNonNull(path1); + checkArgument(path0.size() == path1.size()); + } + + /** @return The number of "locations" in each path (which must be the same). */ + public int size() { + return path0.size(); + } + + /** Compute the distance between these two paths (use the full paths) */ + public static double distanceBtw(PathPair a, PathPair b) { + requireNonNull(a); + requireNonNull(b); + checkArgument(a.size() == b.size(), "Paths must have same size"); + + return distanceBtw(a, b, a.size()); + } + + /** Compute the distance between these two paths using just the first n points of the paths */ + public static double distanceBtw(PathPair a, PathPair b, int n) { + requireNonNull(a); + requireNonNull(b); + checkArgument(n >= 0); + checkArgument(a.size() >= n, "PathPair1 does not have the required length"); + checkArgument(b.size() >= n, "PathPair2 does not have the required length"); + + // We don't know how to pair off the 4 vehicles in this "Path Pair" comparison + + // a0-to-b0 + a1-to-b1 + double opt1 = VehiclePath.distanceBtw(a.path0, b.path0, n) + VehiclePath.distanceBtw(a.path1, b.path1); + + // a0-to-b1 + a1-to-b0 + double opt2 = VehiclePath.distanceBtw(a.path0, b.path1, n) + VehiclePath.distanceBtw(a.path1, b.path0); + + return Math.min(opt1, opt2); + } +} diff --git a/src/main/java/org/mitre/caasd/commons/TimeWindow.java b/src/main/java/org/mitre/caasd/commons/TimeWindow.java index f2376a2..f2303ad 100644 --- a/src/main/java/org/mitre/caasd/commons/TimeWindow.java +++ b/src/main/java/org/mitre/caasd/commons/TimeWindow.java @@ -133,7 +133,7 @@ public Instant end() { return end; } - /** @deprecated */ + /** Equivalent to this.duration(). */ public Duration length() { return duration(); } diff --git a/src/main/java/org/mitre/caasd/commons/VehiclePath.java b/src/main/java/org/mitre/caasd/commons/VehiclePath.java new file mode 100644 index 0000000..aee1c83 --- /dev/null +++ b/src/main/java/org/mitre/caasd/commons/VehiclePath.java @@ -0,0 +1,99 @@ +package org.mitre.caasd.commons; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Base64; + +/** + * Combines a Vehicle's LatLong Path and its Altitude path into a single object + */ +public record VehiclePath(LatLong64Path latLongs, AltitudePath altitudes) { + + private static final Base64.Encoder BASE_64_ENCODER = Base64.getUrlEncoder().withoutPadding(); + + public VehiclePath { + requireNonNull(latLongs); + requireNonNull(altitudes); + checkArgument(latLongs.size() == altitudes.size()); + } + + public static VehiclePath withoutAltitudes(LatLong64Path latLongs) { + requireNonNull(latLongs); + return new VehiclePath(latLongs, AltitudePath.ofNulls(latLongs.size())); + } + + /** + * Create a new VehiclePath from an array of bytes that looks like: {LatLong64Path bytes ..., AltitudePath bytes ...} + */ + public static VehiclePath fromBytes(byte[] bytes) { + requireNonNull(bytes); + checkArgument(bytes.length % 12 == 0, "The byte[] must have a multiple of 12 bytes"); + + int n = bytes.length / 12; + + byte[] latLongBytes = Arrays.copyOfRange(bytes, 0, 8 * n); + byte[] altBytes = Arrays.copyOfRange(bytes, 8 * n, 12 * n); + + return new VehiclePath(LatLong64Path.fromBytes(latLongBytes), AltitudePath.fromBytes(altBytes)); + } + + /** + * Create a new VehiclePath object. + * + * @param base64Encoding The Base64 safe and URL safe (no padding) encoding of a VehiclePath's + * byte[] + * + * @return A new VehiclePath object. + */ + public static VehiclePath fromBase64Str(String base64Encoding) { + return VehiclePath.fromBytes(Base64.getUrlDecoder().decode(base64Encoding)); + } + + public int size() { + return latLongs.size(); + } + + /** @return This VehiclePath as a byte[] containing 12 bytes per item in the path */ + public byte[] toBytes() { + ByteBuffer buffer = ByteBuffer.allocate(size() * 12); + + buffer.put(latLongs.toBytes()); + buffer.put(altitudes.toBytes()); + + return buffer.array(); + } + + /** @return The Base64 file and url safe encoding of this AltitudePath's byte[] . */ + public String toBase64() { + return BASE_64_ENCODER.encodeToString(toBytes()); + } + + /** Compute the distance between these two paths (use the full paths) */ + public static double distanceBtw(VehiclePath a, VehiclePath b) { + requireNonNull(a); + requireNonNull(b); + checkArgument(a.size() == b.size(), "Paths must have same size"); + + return distanceBtw(a, b, a.size()); + } + + /** Compute the distance between these two paths using just the first n points of the paths */ + public static double distanceBtw(VehiclePath path1, VehiclePath path2, int n) { + requireNonNull(path1); + requireNonNull(path2); + checkArgument(n >= 0); + checkArgument(path1.size() >= n, "Path1 does not have the required length"); + checkArgument(path2.size() >= n, "Path2 does not have the required length"); + + // Dist in NM + double ld = LatLong64Path.distanceBtw(path1.latLongs, path2.latLongs, n); + Distance lateralDist = Distance.ofNauticalMiles(ld); + + Distance vertDist = AltitudePath.distanceBtw(path1.altitudes, path2.altitudes, n); + + return lateralDist.plus(vertDist).inFeet(); + } +} diff --git a/src/test/java/org/mitre/caasd/commons/AltitudePathTest.java b/src/test/java/org/mitre/caasd/commons/AltitudePathTest.java new file mode 100644 index 0000000..d9f39ca --- /dev/null +++ b/src/test/java/org/mitre/caasd/commons/AltitudePathTest.java @@ -0,0 +1,77 @@ +package org.mitre.caasd.commons; + +import static com.google.common.collect.Lists.newArrayList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mitre.caasd.commons.AltitudePath.NULL_ALTITUDE; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class AltitudePathTest { + + @Test + void basicConstruction() { + + int[] altitudes = new int[] {1, 2, 3}; + List distances = List.of(Distance.ofFeet(1), Distance.ofFeet(2), Distance.ofFeet(3)); + + AltitudePath path1 = new AltitudePath(altitudes); + AltitudePath path2 = AltitudePath.from(distances); + + assertThat(path1, is(path2)); + } + + @Test + void pathWithNulls() { + + int[] altitudes = new int[] {1, NULL_ALTITUDE, 3}; + List distances = newArrayList(Distance.ofFeet(1), null, Distance.ofFeet(3)); + + AltitudePath path1 = new AltitudePath(altitudes); + AltitudePath path2 = AltitudePath.from(distances); + + assertThat(path1, is(path2)); + } + + @Test + void pathOfNulls() { + + AltitudePath path = AltitudePath.ofNulls(3); + AltitudePath manual = new AltitudePath(new int[] {NULL_ALTITUDE, NULL_ALTITUDE, NULL_ALTITUDE}); + + assertThat(path, is(manual)); + } + + @Test + void distance_reflectsNull() { + + AltitudePath path1 = new AltitudePath(new int[] {1, 22, 3}); + + // This null altitude should be treated as "zero" by distanceBtw + AltitudePath path2 = new AltitudePath(new int[] {1, NULL_ALTITUDE, 3}); + + assertThat(AltitudePath.distanceBtw(path1, path2), is(Distance.ofFeet(22))); + } + + @Test + void toBytesAndBackGivesSameResult() { + + AltitudePath path1 = new AltitudePath(new int[] {1, 2, 3}); + byte[] asBytes = path1.toBytes(); + AltitudePath path2 = AltitudePath.fromBytes(asBytes); + + assertThat(path1, is(path2)); + } + + @Test + void toBase64AndBackGivesSameResult() { + + AltitudePath path1 = new AltitudePath(new int[] {1, 2, 3}); + String asBase64 = path1.toBase64(); + AltitudePath path2 = AltitudePath.fromBase64Str(asBase64); + + assertThat(path1, is(path2)); + } +} diff --git a/src/test/java/org/mitre/caasd/commons/PathPairTest.java b/src/test/java/org/mitre/caasd/commons/PathPairTest.java new file mode 100644 index 0000000..6d99765 --- /dev/null +++ b/src/test/java/org/mitre/caasd/commons/PathPairTest.java @@ -0,0 +1,63 @@ +package org.mitre.caasd.commons; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; + +import org.junit.jupiter.api.Test; + +class PathPairTest { + + @Test + void distanceChoosesCorrectArrangement() { + + // Want 2 wildly different VehiclePaths. + // The pack those SAME to VehiclePaths into 2 PathPairs but arranged different (i.e. ab and ba) + + // LatLong paths are DIFFERENT! + LatLong64Path lat_path_1 = LatLong64Path.from(LatLong.of(1.0, 2.0), LatLong.of(3.0, 4.0)); + LatLong64Path lat_path_2 = LatLong64Path.from(LatLong.of(2.0, 1.0), LatLong.of(4.0, 3.0)); + + // Alt paths are DIFFERENT! + AltitudePath vert_path_1 = AltitudePath.from(Distance.ofFeet(100), Distance.ofFeet(200)); + AltitudePath vert_path_2 = AltitudePath.from(Distance.ofFeet(20000), Distance.ofFeet(3000)); + + // Thus VehiclePaths are DIFFERENT + VehiclePath vp_1 = new VehiclePath(lat_path_1, vert_path_1); + VehiclePath vp_2 = new VehiclePath(lat_path_2, vert_path_2); + + PathPair a = new PathPair(vp_1, vp_2); + PathPair b = new PathPair(vp_2, vp_1); + + // Paths are wildly different ... but the different pairs are good + assertThat(VehiclePath.distanceBtw(vp_1, vp_2), greaterThan(1053393.0)); + + assertThat(PathPair.distanceBtw(a, b), is(0.0)); + assertThat(PathPair.distanceBtw(b, a), is(0.0)); + } + + @Test + void distanceReflectsAltitude() { + + // These are the same + LatLong64Path lat_path_1 = LatLong64Path.from(LatLong.of(1.0, 2.0), LatLong.of(3.0, 4.0)); + LatLong64Path lat_path_2 = LatLong64Path.from(LatLong.of(1.0, 2.0), LatLong.of(3.0, 4.0)); + + // Alt paths are slightly different + AltitudePath vert_path_1 = AltitudePath.from(Distance.ofFeet(100), Distance.ofFeet(200)); + AltitudePath vert_path_2 = AltitudePath.from(Distance.ofFeet(200), Distance.ofFeet(400)); + + // Thus VehiclePaths are DIFFERENT + VehiclePath vp_1 = new VehiclePath(lat_path_1, vert_path_1); + VehiclePath vp_2 = new VehiclePath(lat_path_2, vert_path_2); + + PathPair a = new PathPair(vp_1, vp_2); + PathPair b = new PathPair(vp_2, vp_1); + + // Paths are offset by 300 foot + assertThat(VehiclePath.distanceBtw(vp_1, vp_2), is(300.0)); + + assertThat(PathPair.distanceBtw(a, b), is(0.0)); + assertThat(PathPair.distanceBtw(b, a), is(0.0)); + } +} diff --git a/src/test/java/org/mitre/caasd/commons/VehiclePathTest.java b/src/test/java/org/mitre/caasd/commons/VehiclePathTest.java new file mode 100644 index 0000000..76c9b1f --- /dev/null +++ b/src/test/java/org/mitre/caasd/commons/VehiclePathTest.java @@ -0,0 +1,80 @@ +package org.mitre.caasd.commons; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.number.IsCloseTo.closeTo; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +public class VehiclePathTest { + + @Test + void basicConstruction() { + + LatLong64Path path_1 = LatLong64Path.from(LatLong.of(1.0, 2.0), LatLong.of(3.0, 4.0)); + AltitudePath path_2 = AltitudePath.from(Distance.ofFeet(100), Distance.ofFeet(200)); + + assertDoesNotThrow(() -> new VehiclePath(path_1, path_2)); + } + + @Test + void canMakeVehiclePath_withNullAltitude() { + + // We want to support full VehiclePaths even when an AltitudePath is empty + + LatLong64Path path_1 = LatLong64Path.from(LatLong.of(1.0, 2.0), LatLong.of(3.0, 4.0)); + + VehiclePath vp = VehiclePath.withoutAltitudes(path_1); + } + + @Test + void rejectsDifferentSizePaths() { + + LatLong64Path path_1 = LatLong64Path.from(LatLong.of(1.0, 2.0), LatLong.of(3.0, 4.0)); + AltitudePath path_2 = AltitudePath.from(Distance.ofFeet(100)); + + assertThrows(IllegalArgumentException.class, () -> new VehiclePath(path_1, path_2)); + } + + @Test + void toAndFromBytes() { + + LatLong64Path path_1 = LatLong64Path.from(LatLong.of(1.0, 2.0), LatLong.of(3.0, 4.0)); + AltitudePath path_2 = AltitudePath.from(Distance.ofFeet(100), Distance.ofFeet(200)); + + VehiclePath path = new VehiclePath(path_1, path_2); + VehiclePath path2 = VehiclePath.fromBytes(path.toBytes()); + + assertThat(path, (is(path2))); + } + + @Test + void toBase64AndBack() { + + LatLong64Path path_1 = LatLong64Path.from(LatLong.of(1.0, 2.0), LatLong.of(3.0, 4.0)); + AltitudePath path_2 = AltitudePath.from(Distance.ofFeet(100), Distance.ofFeet(200)); + + VehiclePath path = new VehiclePath(path_1, path_2); + VehiclePath path2 = VehiclePath.fromBase64Str(path.toBase64()); + + assertThat(path, (is(path2))); + } + + @Test + void distanceMeasurement() { + + LatLong64Path lat_path_1 = LatLong64Path.from(LatLong.of(1.0, 2.0), LatLong.of(3.0, 4.0)); + AltitudePath vert_path_1 = AltitudePath.from(Distance.ofFeet(100), Distance.ofFeet(200)); + VehiclePath vp_1 = new VehiclePath(lat_path_1, vert_path_1); + + LatLong64Path lat_path_2 = LatLong64Path.from(LatLong.of(1.0, 2.0), LatLong.of(3.0, 4.0)); + AltitudePath vert_path_2 = AltitudePath.from(Distance.ofFeet(200), Distance.ofFeet(300)); + VehiclePath vp_2 = new VehiclePath(lat_path_2, vert_path_2); + + // lateral path is the same, vertical path is offset by 100 ft across 2 points + double dist = VehiclePath.distanceBtw(vp_1, vp_2); + assertThat(dist, closeTo(200.0, 0.00001)); + } +}