Verified Commit cc2bccb9 authored by 35V LG84's avatar 35V LG84

tep-1010: Txn Geo Filter: BBox2D and BBox3D

Implement TxnFilterBBoxLatLon and TxnFilterBBoxLatLonAlt
filters (2D and 3D), and tests for those.
Signed-off-by: 35V LG84's avatar35V LG84 <[email protected]>
parent db87dae6
......@@ -16,8 +16,8 @@
*/
package fi.e257.tackler.api
import io.circe.{Decoder, Encoder}
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
import io.circe.{Decoder, Encoder}
import scala.util.{Failure, Success, Try}
......@@ -32,12 +32,22 @@ import scala.util.{Failure, Success, Try}
*/
class GeoPoint protected (val lat: BigDecimal, val lon: BigDecimal, val alt: Option[BigDecimal]) {
if (lat < -90 || 90 < lat) {
throw new IllegalArgumentException("Value out of specification for Latitude: " + GeoPoint.frmt(lat))
}
if (lon < -180 || 180 < lon) {
throw new IllegalArgumentException("Value out of specification for Longitude: " + GeoPoint.frmt(lon))
}
alt.foreach(z => {
if (z < -6378137) {
// Jules Verne: Voyage au centre de la Terre
throw new IllegalArgumentException("Value Out of specification for Altitude: " + GeoPoint.frmt(z))
}
})
override def toString: String = {
alt.fold(
"geo:" + GeoPoint.frmt(lat) + "," + GeoPoint.frmt(lon)
)(z =>
"geo:" + GeoPoint.frmt(lat) + "," + GeoPoint.frmt(lon) + "," + GeoPoint.frmt(z)
)
"geo:" + GeoPoint.frmt(lat) + "," + GeoPoint.frmt(lon) + alt.map("," + GeoPoint.frmt(_)).getOrElse("")
}
}
......@@ -55,21 +65,10 @@ object GeoPoint {
* @return Success with GeoPoint or Failure
*/
def toPoint(lat: BigDecimal, lon: BigDecimal, alt: Option[BigDecimal]): Try[GeoPoint] = {
val z = alt.getOrElse(BigDecimal(0))
if (lat < -90 || 90 < lat) {
Failure[GeoPoint](new IllegalArgumentException("Value out of specification for Latitude: " + GeoPoint.frmt(lat)))
}
else if (lon < -180 || 180 < lon) {
Failure[GeoPoint](new IllegalArgumentException("Value out of specification for Longitude: " + GeoPoint.frmt(lon)))
}
else if (z < -6378137) {
// Jules Verne: Voyage au centre de la Terre
Failure[GeoPoint](new IllegalArgumentException("Value Out of specification for Altitude: " + GeoPoint.frmt(z)))
}
else {
try {
Success(new GeoPoint(lat, lon, alt))
} catch {
case ex: IllegalArgumentException => Failure[GeoPoint](ex)
}
}
......
/*
* Copyright 2019 E257.FI
* Copyright 2018-2019 E257.FI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
......@@ -194,6 +194,119 @@ final case class TxnFilterTxnUUID(uuid: UUID) extends TxnFilter {
}
}
sealed abstract class BBoxLatLon extends TxnFilter {
val south: BigDecimal
val west: BigDecimal
val north: BigDecimal
val east: BigDecimal
if (north < south) {
throw new IllegalArgumentException("Invalid Bounding Box: North is below South. " +
"South: " + GeoPoint.frmt(south) + "; North: " + GeoPoint.frmt(north))
}
// east < west case is needed for filter over 180th meridian
// other pole is tested by north < south check
if (south < -90) {
throw new IllegalArgumentException("Invalid Bounding Box: South is beyond pole. " +
"South: " + GeoPoint.frmt(south))
}
// other pole is tested by north < south check
if (90 < north) {
throw new IllegalArgumentException("Invalid Bounding Box: North is beyond pole. " +
"North: " + GeoPoint.frmt(north))
}
if (west < -180 || 180 < west) {
throw new IllegalArgumentException("Invalid Bounding Box: West is beyond 180th Meridian. " +
"West: " + GeoPoint.frmt(west))
}
if (east < -180 || 180 < east) {
throw new IllegalArgumentException("Invalid Bounding Box: East is beyond 180th Meridian. " +
"East: " + GeoPoint.frmt(east))
}
}
/**
* Select Transaction if it has location attribute,
* and it's geo location is inside Bounding Box.
*
* This will ignore altitude, e.g. it will select 3D transaction if it fits 2D BBox.
* If transaction doesn't have location information, it will not be selected.
*
* @param south bbox bottom edge
* @param west bbox left edge
* @param north bbox top edge
* @param east bbox right edge
*/
final case class TxnFilterBBoxLatLon(
south: BigDecimal,
west: BigDecimal,
north: BigDecimal,
east: BigDecimal
) extends BBoxLatLon {
override def text(indent: String): Seq[String] = {
val myIndent = indent + " "
Seq(
indent + "Txn Bounding Box 2D",
myIndent + "North, East: " + "geo:" + GeoPoint.frmt(north) + "," + GeoPoint.frmt(east),
myIndent + "South, West: " + "geo:" + GeoPoint.frmt(south) + "," + GeoPoint.frmt(west),
)
}
}
/**
* Select Transaction if it has location attribute,
* and it's geo location is inside Bounding Box.
*
* This will select only transactions with altitude,
* e.g. it will not select any 2D transaction, even if it fits 2D BBox.
*
* If transaction doesn't have location information, it will not be selected.
*
* @param south bbox bottom edge
* @param west bbox left dege
* @param depth bbox floor
* @param north bbox top edge
* @param east bbox right edge
* @param height bbox ceiling
*/
final case class TxnFilterBBoxLatLonAlt(
south: BigDecimal,
west: BigDecimal,
depth: BigDecimal,
north: BigDecimal,
east: BigDecimal,
height: BigDecimal
) extends BBoxLatLon {
if (height < depth) {
throw new IllegalArgumentException("Invalid Bounding Box: height is less than depth. " +
"Depth: " + GeoPoint.frmt(depth) + "; Height: " + GeoPoint.frmt(height))
}
// height is tested by height < depth test
if (depth < -6378137) {
throw new IllegalArgumentException("Invalid Bounding Box: Depth is beyond center of Earth. " +
"Depth: " + GeoPoint.frmt(depth))
}
override def text(indent: String): Seq[String] = {
val myIndent = indent + " "
Seq(
indent + "Txn Bounding Box 3D",
myIndent + "North, East, Height: " + "geo:" + GeoPoint.frmt(north) + "," + GeoPoint.frmt(east) + "," + GeoPoint.frmt(height),
myIndent + "South, West, Depth: " + "geo:" + GeoPoint.frmt(south) + "," + GeoPoint.frmt(west) + "," + GeoPoint.frmt(depth)
)
}
}
/**
* Select transaction if regular expression matches any of txn comments.
*
......
/*
* Copyright 2018 E257.FI
* Copyright 2018-2019 E257.FI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
......@@ -16,8 +16,22 @@
*/
package fi.e257.tackler.filter
import fi.e257.tackler.api.{BBoxLatLon, GeoPoint}
import fi.e257.tackler.model.Transaction
trait CanTxnFilter[A] {
def filter(tf: A, txn: Transaction): Boolean
}
trait CanBBoxLatLonFilter[A <: BBoxLatLon] {
def bbox2d(tf: A, geo: GeoPoint): Boolean = {
if (tf.east < tf.west) {
// BBox is over 180th meridian
tf.south <= geo.lat && geo.lat <= tf.north &&
(tf.west <= geo.lon || geo.lon <= tf.east)
} else {
tf.south <= geo.lat && geo.lat <= tf.north &&
tf.west <= geo.lon && geo.lon <= tf.east
}
}
}
/*
* Copyright 2018 E257.FI
* Copyright 2019 E257.FI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
......@@ -16,9 +16,9 @@
*/
package fi.e257.tackler
import cats.implicits._
import fi.e257.tackler.api._
import fi.e257.tackler.model.Transaction
import cats.implicits._
package object filter {
......@@ -41,6 +41,8 @@ package object filter {
case tf: TxnFilterTxnCode => TxnFilterTxnCodeF.filter(tf, txn)
case tf: TxnFilterTxnDescription => TxnFilterTxnDescriptionF.filter(tf, txn)
case tf: TxnFilterTxnUUID => TxnFilterTxnUUIDF.filter(tf, txn)
case tf: TxnFilterBBoxLatLon => TxnFilterBBoxLatLonF.filter(tf, txn)
case tf: TxnFilterBBoxLatLonAlt => TxnFilterBBoxLatLonAltF.filter(tf, txn)
case tf: TxnFilterTxnComments => TxnFilterTxnCommentsF.filter(tf, txn)
// TXN Postings
......@@ -137,6 +139,42 @@ package object filter {
}
}
implicit object TxnFilterBBoxLatLonF
extends CanTxnFilter[TxnFilterBBoxLatLon]
with CanBBoxLatLonFilter[TxnFilterBBoxLatLon] {
override def filter(tf: TxnFilterBBoxLatLon, txn: Transaction): Boolean = {
txn.header.location match {
case Some(geo) => bbox2d(tf, geo)
case None => false
}
}
}
implicit object TxnFilterBBoxLatLonAltF
extends CanTxnFilter[TxnFilterBBoxLatLonAlt]
with CanBBoxLatLonFilter[TxnFilterBBoxLatLonAlt] {
override def filter(tf: TxnFilterBBoxLatLonAlt, txn: Transaction): Boolean = {
txn.header.location match {
case Some(geo) => {
geo.alt match {
case Some(z) => {
if (bbox2d(tf, geo) === false) {
false
}
else {
tf.depth <= z && z <= tf.height
}
}
case None => false // geo but no alt
}
}
case None => false // no geo
}
}
}
implicit object TxnFilterTxnCommentsF extends CanTxnFilter[TxnFilterTxnComments] {
override def filter(tf: TxnFilterTxnComments, txn: Transaction): Boolean = {
......
/*
* Copyright 2019 E257.FI
*
* Licensed 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 fi.e257.tackler.filter
import fi.e257.tackler.api.{GeoPoint, TxnFilterBBoxLatLonAlt, TxnFilterDefinition, TxnHeader}
import fi.e257.tackler.model.Transaction
import io.circe.parser.decode
import org.scalatest.FunSpec
class TxnFilterBBoxLatLonAltTest extends FunSpec with TxnFilterBBoxSpec {
describe("BBox 3D (Latitude, Longitude, Altitude)") {
/**
* test:
*/
it("Filter 2D Txns") {
val geo2DTxnStr =
s"""
|2019-02-02 'Helsinki
| # uuid: ${geo03}
| # location: geo:60.170833,24.9375
| e 1
| a
|
|""".stripMargin
val geo2DTxnData = tt.string2Txns(geo2DTxnStr)
val txnFilter = TxnFilterBBoxLatLonAlt(
40, 20, -2000, 65, 26, 14000
)
val txnData = geo2DTxnData.filter(TxnFilterDefinition(txnFilter))
assert(txnData.txns.size === 0)
}
/**
* test:
*/
it ("Filter 3D Txns") {
val geo3DTxnData = tt.string2Txns(geo3DTxnStr)
val txnFilter = TxnFilterBBoxLatLonAlt(
40, 20,-2000, 65, 26, 14000,
)
val txnData = geo3DTxnData.filter(TxnFilterDefinition(txnFilter))
assert(txnData.txns.size === 1)
assert(checkUUID(txnData, geo03))
}
}
describe("BBox 3D errors") {
/**
* test:
*/
it("detects illegal arguments") {
val errBBoxes: List[(BigDecimal, BigDecimal, BigDecimal, BigDecimal, BigDecimal, BigDecimal, String)] =
List(
(65.0, 0, 0, 40, 0, 0, "North is below South. South: 65.0; North: 40"),
(-2, 0, 0, -30.0, 0, 0, "North is below South. South: -2; North: -30.0"),
(25, 0, 0, -25, 0, 0, "North is below South. South: 25; North: -25"),
(0, 0, 10, 0, 0, 8.1, "height is less than depth. Depth: 10; Height: 8.1"),
(0, 0, -8.1, 0, 0, -10, "height is less than depth. Depth: -8.1; Height: -10"),
(0, 0, 2, 0, 0, -2, "height is less than depth. Depth: 2; Height: -2"),
(-90.1, 0, 0, 0, 0, 0, "South is beyond pole. South: -90.1"),
(0, 0, 0, 90.1, 0, 0, "North is beyond pole. North: 90.1"),
(0, -180.1, 0, 0, 0, 0, "West is beyond 180th Meridian. West: -180.1"),
(0, 180.1, 0, 0, 0, 0, "West is beyond 180th Meridian. West: 180.1"),
(0, 0, 0, 0, 180.1, 0, "East is beyond 180th Meridian. East: 180.1"),
(0, 0, 0, 0, -180.1, 0, "East is beyond 180th Meridian. East: -180.1"),
(0, 0, -6378137.1, 0, 0, -2, "Depth is beyond center of Earth. Depth: -6378137.1"),
)
val count = errBBoxes.map(errBBox => {
val ex = intercept[IllegalArgumentException]({
val _ = TxnFilterBBoxLatLonAlt(errBBox._1, errBBox._2, errBBox._3, errBBox._4, errBBox._5, errBBox._6)
})
assert(ex.getMessage.contains(errBBox._7))
1
}).foldLeft(0)(_ + _)
assert(count === 13)
}
/**
* test:
*/
it("detects illegal arguments via JSON") {
val errBBoxFilterJson =
"""
|{
| "txnFilter" : {
| "TxnFilterBBoxLatLonAlt" : {
| "south" : 0,
| "west" : 0,
| "depth" : 2,
| "north" : 60,
| "east" : 25,
| "height" : -2
| }
| }
|}
""".stripMargin
val ex = intercept[IllegalArgumentException] {
val _ = decode[TxnFilterDefinition](errBBoxFilterJson)
}
assert(ex.getMessage.contains("height is less than depth. Depth: 2; Height: -2"))
}
}
describe("BBox 3D (Latitude, Longitude, Altitude) verification tests") {
it ("Check edge cases (points and/or BBoxes)") {
val count = geo2d3dTests.map(t => {
val expectedCount = t._1
val bbox = t._3
val txnFilter = TxnFilterBBoxLatLonAlt(
bbox._1, bbox._2, bbox._3, bbox._4, bbox._5, bbox._6
)
val tvecs = t._4
val count = tvecs.map(v => {
val geo = GeoPoint.toPoint(v._1, v._2, v._3).get
val txn = Transaction(TxnHeader(date, None, None, None, Some(geo), None), posts)
assert(TxnFilterBBoxLatLonAltF.filter(txnFilter, txn) === v._5)
1
}).foldLeft(0)(_ + _)
assert(count === expectedCount, ", e.g. test vector size for one filter is wrong")
1
}).foldLeft(0)(_ + _)
assert(count === 7, ", e.g. test count for filter is wrong")
}
}
it ("check altitude functionality") {
val altTests = List[
(Int, // test count
(BigDecimal, BigDecimal, BigDecimal, BigDecimal, BigDecimal, BigDecimal), // 3D GEO Filter
List[(BigDecimal, BigDecimal, Option[BigDecimal], Boolean)]) // Test vectors and result
](
(4,
(20, 10, 22, 45, 25, 22),
List(
(30, 15, Some(22), true),
(30, 15, Some(-22), false),
(30, 15, Some(22.1), false),
(30, 15, Some(21.9), false),
)
),
(4,
(20, 10, -22, 45, 25, -22),
List(
(30, 15, Some(-22), true),
(30, 15, Some(22), false),
(30, 15, Some(-22.1), false),
(30, 15, Some(-21.9), false),
)
),
(7,
(20, 10, -10, 45, 25, 10),
List(
(30, 15, Some(0), true),
(30, 15, Some(5), true),
(30, 15, Some(-5), true),
(30, 15, Some(-10), true),
(30, 15, Some(10), true),
(30, 15, Some(-11), false),
(30, 15, Some(11), false),
)
),
(6,
(20, 10, -10, 45, 25, -1),
List(
(30, 15, Some(0), false),
(30, 15, Some(-5), true),
(30, 15, Some(-10), true),
(30, 15, Some(-1), true),
(30, 15, Some(-11), false),
(30, 15, Some(0), false),
)
),
)
val count = altTests.map(t => {
val expectedCount = t._1
val bbox = t._2
val txnFilter = TxnFilterBBoxLatLonAlt(
bbox._1, bbox._2, bbox._3, bbox._4, bbox._5, bbox._6
)
val tvecs = t._3
val count = tvecs.map(v => {
val geo = GeoPoint.toPoint(v._1, v._2, v._3).get
val txn = Transaction(TxnHeader(date, None, None, None, Some(geo), None), posts)
assert(TxnFilterBBoxLatLonAltF.filter(txnFilter, txn) === v._4)
1
}).foldLeft(0)(_ + _)
assert(count === expectedCount, ", e.g. test vector size for one filter is wrong")
1
}).foldLeft(0)(_ + _)
assert(count === 4, ", e.g. test count for filter is wrong")
}
}
/*
* Copyright 2019 E257.FI
*
* Licensed 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 fi.e257.tackler.filter
import fi.e257.tackler.api.{GeoPoint, TxnFilterBBoxLatLon, TxnFilterDefinition, TxnHeader}
import fi.e257.tackler.model.Transaction
import io.circe.parser.decode
import org.scalatest.FunSpec
class TxnFilterBBoxLatLonTest extends FunSpec with TxnFilterBBoxSpec {
describe("BBox 2D (Latitude, Longitude) basic tests") {
/**
* test:
*/
it("Filter 2D Txns") {
val geo2DTxnData = tt.string2Txns(geo2DTxnStr)
val txnFilter = TxnFilterBBoxLatLon(
40, 20, 65, 26
)
val txnData = geo2DTxnData.filter(TxnFilterDefinition(txnFilter))
assert(txnData.txns.size === 1)
assert(checkUUID(txnData, geo03))
}
/**
* test:
*/
it("Filter 3D Txns") {
val geo3DTxnData = tt.string2Txns(geo3DTxnStr)
val txnFilter = TxnFilterBBoxLatLon(
40, 20, 65, 26
)
val txnData = geo3DTxnData.filter(TxnFilterDefinition(txnFilter))
assert(txnData.txns.size === 2)
assert(checkUUID(txnData, geo03))
assert(checkUUID(txnData, geo04))
}
}
describe("BBox 2D (Latitude, Longitude) error cases") {
/**
* test:
*/
it("detects illegal arguments") {
val errBBoxes: List[(BigDecimal, BigDecimal, BigDecimal, BigDecimal, String)] =
List(
(65.0, 0, 40, 0, "North is below South. South: 65.0; North: 40"),
(-2, 0, -30.0, 0, "North is below South. South: -2; North: -30.0"),
(25, 0, -25, 0, "North is below South. South: 25; North: -25"),
(-90.1, 0, 0, 0, "South is beyond pole. South: -90.1"),
(0, 0, 90.1, 0, "North is beyond pole. North: 90.1"),
(0, -180.1, 0, 0, "West is beyond 180th Meridian. West: -180.1"),
(0, 180.1, 0, 0, "West is beyond 180th Meridian. West: 180.1"),
(0, 0, 0, 180.1, "East is beyond 180th Meridian. East: 180.1"),
(0, 0, 0, -180.1, "East is beyond 180th Meridian. East: -180.1"),
)
val count = errBBoxes.map(errBBox => {
val ex = intercept[IllegalArgumentException]({
val _ = TxnFilterBBoxLatLon(errBBox._1, errBBox._2, errBBox._3, errBBox._4)
})
assert(ex.getMessage.contains(errBBox._5))
1
}).foldLeft(0)(_ + _)
assert(count === 9)
}
/**
* test:
*/
it("detects illegal arguments via JSON") {
val errBBoxFilterJson =
"""
|{
| "txnFilter" : {
| "TxnFilterBBoxLatLon" : {
| "south" : 60,
| "west" : 0,
| "north" : 10,
| "east" : 0
| }
| }
|}
""".stripMargin
val ex = intercept[IllegalArgumentException] {
val _ = decode[TxnFilterDefinition](errBBoxFilterJson)
}
assert(ex.getMessage.contains("North is below South. South: 60; North: 10"))
}
}
describe("BBox 2D (Latitude, Longitude) verification tests") {
it ("Check edge cases (points and/or BBoxes)") {
val count = geo2d3dTests.map(t => {
val expectedCount = t._1
val bbox = t._2
val txnFilter = TxnFilterBBoxLatLon(
bbox._1, bbox._2, bbox._3, bbox._4
)
val tvecs = t._4
val count = tvecs.map(v => {
val geo = GeoPoint.toPoint(v._1, v._2, v._3).get
val txn = Transaction(TxnHeader(date, None, None, None, Some(geo), None), posts)
assert(TxnFilterBBoxLatLonF.filter(txnFilter, txn) === v._4)
1
}).foldLeft(0)(_ + _)
assert(count === expectedCount, ", e.g. test vector size for one filter is wrong")