diff --git a/ip-intelligence/fiftyone_ipintelligence_onpremise/src/fiftyone_ipintelligence_onpremise/swig_data.py b/ip-intelligence/fiftyone_ipintelligence_onpremise/src/fiftyone_ipintelligence_onpremise/swig_data.py index 50c53f98..49fe459d 100644 --- a/ip-intelligence/fiftyone_ipintelligence_onpremise/src/fiftyone_ipintelligence_onpremise/swig_data.py +++ b/ip-intelligence/fiftyone_ipintelligence_onpremise/src/fiftyone_ipintelligence_onpremise/swig_data.py @@ -20,6 +20,9 @@ # such notice(s) shall fulfill the requirements of that article. # ********************************************************************* +import math +from decimal import Decimal, ROUND_HALF_UP + from fiftyone_pipeline_engines.aspectdata import AspectData from fiftyone_pipeline_core.aspectproperty_value import AspectPropertyValue @@ -27,9 +30,15 @@ from .constants import RESULTS_CLOSED -# Number of decimal places used when converting WKB geometry -# properties (e.g. Areas) to their WKT string form. -WKT_DECIMAL_PLACES = 16 +# Latitude and Longitude are given to this many decimal places, as the +# native code gives them in text and in the WKT of Areas. +COORDINATE_DECIMAL_PLACES = 3 + +# The step a coordinate is rounded to, worked out from the places above so +# that the number of places is written down once. +COORDINATE_QUANTUM = Decimal(1).scaleb(-COORDINATE_DECIMAL_PLACES) + +COORDINATE_PROPERTIES = ("latitude", "longitude") class OnPremiseMissingPropertyService(MissingPropertyService): @@ -166,6 +175,26 @@ def ip_address_value(self, value): return AspectPropertyValue(no_value_message="The IP address type is invalid.") + @staticmethod + def round_coordinate(value): + + """! + A latitude or longitude to COORDINATE_DECIMAL_PLACES places. + + A value half way between two places rounds away from zero, as the + native code rounds it, where Python's own round() would round it to + the nearer even place. A value that rounds to zero from below is + given as zero without a sign, because the native code writes the + same value as text without one. A value that is not finite is given + as it is, having no places to round. + """ + + if math.isfinite(value) is False: + return value + rounded = float(Decimal(value).quantize( + COORDINATE_QUANTUM, rounding=ROUND_HALF_UP)) + return 0.0 if rounded == 0 else rounded + def single_value(self, value, convert=None): """! @@ -237,9 +266,15 @@ def get_internal(self, key): return self.weighted_list_to_value( results.getValuesAsWeightedIntegerList(target)) + # Only the coordinates are rounded. + round_coordinate = ( + self.round_coordinate if key in COORDINATE_PROPERTIES + else None) + if property_type in ("weighteddouble", "weightedsingle"): return self.weighted_list_to_value( - results.getValuesAsWeightedDoubleList(target)) + results.getValuesAsWeightedDoubleList(target), + convert=round_coordinate) if property_type == "weightedbool": return self.weighted_list_to_value( @@ -269,7 +304,8 @@ def get_internal(self, key): return self.single_value(results.getValueAsInteger(target)) if property_type in ("double", "single"): - return self.single_value(results.getValueAsDouble(target)) + return self.single_value( + results.getValueAsDouble(target), convert=round_coordinate) if property_type == "bool": return self.single_value(results.getValueAsBool(target)) diff --git a/ip-intelligence/fiftyone_ipintelligence_onpremise/tests/test_swig_data_decimal_places.py b/ip-intelligence/fiftyone_ipintelligence_onpremise/tests/test_swig_data_decimal_places.py new file mode 100644 index 00000000..3de26193 --- /dev/null +++ b/ip-intelligence/fiftyone_ipintelligence_onpremise/tests/test_swig_data_decimal_places.py @@ -0,0 +1,117 @@ + +""" +IP Intelligence Latitude and Longitude are given to 3 decimal places, as the +value types page of the specification says. +""" + +import math +import unittest + +from fiftyone_ipintelligence_onpremise import swig_data +from fiftyone_ipintelligence_onpremise.swig_data import SwigData + + +class Weighted: + + def __init__(self, value, weight): + self.value = value + self.weight = weight + + def getValue(self): + return self.value + + def getWeight(self): + return self.weight + + +class WeightedList: + + def __init__(self, items): + self.items = items + + def hasValue(self): + return True + + def getValue(self): + return self.items + + +class Single: + + def __init__(self, value): + self.value = value + + def hasValue(self): + return True + + def getValue(self): + return self.value + + +class FakeResults: + + def getValuesAsWeightedDoubleList(self, name): + return WeightedList([Weighted(18.542741775512695, 65535), + Weighted(73.91278076171875, 100)]) + + def getValueAsDouble(self, name): + return Single(18.542741775512695) + + +class FakeElement: + datakey = "ip" + + def __init__(self, type_name, name): + self.properties = {name.lower(): {"name": name, "type": type_name}} + + +def read(type_name, name): + data = SwigData(FakeElement(type_name, name), FakeResults()) + return data.get_internal(name.lower()).value() + + +class DecimalPlacesTests(unittest.TestCase): + + def test_weighted_latitude_and_longitude_have_three_places(self): + for type_name in ("weighteddouble", "weightedsingle"): + for name in ("Latitude", "Longitude"): + values = [item["value"] for item in read(type_name, name)] + self.assertEqual([18.543, 73.913], values, (type_name, name)) + + def test_weights_are_unchanged(self): + weights = [item["weighting"] for item in read("weighteddouble", "Latitude")] + self.assertEqual([65535, 100], weights) + + def test_single_latitude_has_three_places(self): + for type_name in ("double", "single"): + self.assertEqual(18.543, read(type_name, "Latitude")) + + def test_other_numbers_are_not_rounded(self): + values = [item["value"] for item in read("weighteddouble", "Accuracy")] + self.assertEqual([18.542741775512695, 73.91278076171875], values) + + def test_the_unused_sixteen_place_setting_is_gone(self): + self.assertFalse(hasattr(swig_data, "WKT_DECIMAL_PLACES")) + + def test_a_half_rounds_away_from_zero(self): + for value, expected in ((0.0625, 0.063), + (-0.0625, -0.063), + (51.3125, 51.313)): + self.assertEqual(expected, SwigData.round_coordinate(value), value) + + def test_a_value_that_rounds_to_zero_from_below_has_no_sign(self): + for value in (-0.0004, -0.0): + rounded = SwigData.round_coordinate(value) + self.assertEqual(0.0, rounded, value) + self.assertFalse(math.copysign(1.0, rounded) < 0, + f"{value} was given as minus zero") + + def test_a_value_that_is_not_finite_is_kept(self): + infinity = float("inf") + self.assertEqual(infinity, SwigData.round_coordinate(infinity)) + self.assertEqual(-infinity, SwigData.round_coordinate(-infinity)) + self.assertTrue(math.isnan(SwigData.round_coordinate(float("nan")))) + + +if __name__ == "__main__": + unittest.main()