From d236fe774f2ac2424665376258a049b8027bf692 Mon Sep 17 00:00:00 2001 From: Phil Anderson Date: Sun, 17 Nov 2024 19:47:47 +1000 Subject: [PATCH 1/5] Added tests to cover edge cases where divmod() and int() are not returning expected values because of the precision of float. The additional testing resulted in 7 unit tests failing. --- geodepy/tests/test_angles.py | 48 +++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/geodepy/tests/test_angles.py b/geodepy/tests/test_angles.py index 0a65957..cb214ce 100644 --- a/geodepy/tests/test_angles.py +++ b/geodepy/tests/test_angles.py @@ -1,5 +1,6 @@ import unittest -from math import radians +import os +from math import radians, pi from geodepy.angles import (DECAngle, HPAngle, GONAngle, DMSAngle, DDMAngle, dec2hp, dec2hpa, dec2gon, dec2gona, @@ -85,6 +86,18 @@ class TestConvert(unittest.TestCase): + def setUp(self): + self.testData = [] + degreeValues = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256] + for deg in degreeValues: + for min in range(60): + for sec in range(60): + hp = float(f'{deg:4d}.{min:02d}{sec:02d}') + dec = deg + (min / 60.0 + sec / 3600.0) + gon = dec * 400.0 / 360.0 + rad = dec * pi / 180.0 + self.testData.append([hp, dec, gon, rad]) + def test_DECAngle(self): # Test DECAngle Methods for num, ex in enumerate(deca_exs): @@ -465,6 +478,10 @@ def test_dec2hp(self): for num, ex in enumerate(hp_exs): self.assertAlmostEqual(ex, dec2hp(dec_exs[num]), 13) self.assertAlmostEqual(-ex, dec2hp(-dec_exs[num]), 13) + for check in self.testData: + hp, dec, gon, rad = check + self.assertAlmostEqual(hp, dec2hp(dec), 13) + self.assertAlmostEqual(-hp, dec2hp(-dec), 13) def test_dec2hpa(self): for num, ex in enumerate(dec_exs): @@ -475,6 +492,10 @@ def test_dec2gon(self): for num, ex in enumerate(dec_exs): self.assertAlmostEqual(dec2gon(ex), gon_exs[num], 13) self.assertAlmostEqual(dec2gon(-ex), -gon_exs[num], 13) + for check in self.testData: + hp, dec, gon, rad = check + self.assertAlmostEqual(gon, dec2gon(dec), 13) + self.assertAlmostEqual(-gon, dec2gon(-dec), 13) def test_dec2gona(self): for num, ex in enumerate(dec_exs): @@ -495,6 +516,11 @@ def test_hp2dec(self): for num, ex in enumerate(dec_exs): self.assertAlmostEqual(ex, hp2dec(hp_exs[num]), 13) self.assertAlmostEqual(-ex, hp2dec(-hp_exs[num]), 13) + for check in self.testData: + hp, dec, gon, rad = check + self.assertAlmostEqual(dec, hp2dec(hp), 13) + self.assertAlmostEqual(-dec, hp2dec(-hp), 13) + self.assertAlmostEqual(hp2dec(hp_exs[0]) + hp2dec(hp_exs[1]), dec_exs[0] + dec_exs[1], 13) # Test that invalid minutes and seconds components raise errors @@ -523,6 +549,10 @@ def test_hp2gon(self): for num, ex in enumerate(hp_exs): self.assertAlmostEqual(hp2gon(ex), gon_exs[num], 13) self.assertAlmostEqual(hp2gon(-ex), -gon_exs[num], 13) + for check in self.testData: + hp, dec, gon, rad = check + self.assertAlmostEqual(gon, hp2gon(hp), 13) + self.assertAlmostEqual(-gon, hp2gon(-hp), 13) def test_hp2gona(self): for num, ex in enumerate(hp_exs): @@ -533,6 +563,10 @@ def test_hp2rad(self): for num, ex in enumerate(hp_exs): self.assertEqual(hp2rad(ex), rad_exs[num]) self.assertEqual(hp2rad(-ex), -rad_exs[num]) + for check in self.testData: + hp, dec, gon, rad = check + self.assertAlmostEqual(rad, hp2rad(hp), 13) + self.assertAlmostEqual(-rad, hp2rad(-hp), 13) def test_hp2dms(self): self.assertEqual(dms_ex.degree, hp2dms(hp_ex).degree) @@ -552,6 +586,10 @@ def test_gon2dec(self): for num, ex in enumerate(gon_exs): self.assertAlmostEqual(gon2dec(ex), dec_exs[num], 14) self.assertAlmostEqual(gon2dec(-ex), -dec_exs[num], 14) + for check in self.testData: + hp, dec, gon, rad = check + self.assertAlmostEqual(dec, gon2dec(gon), 13) + self.assertAlmostEqual(-dec, gon2dec(-gon), 13) def test_gon2deca(self): for num, ex in enumerate(gon_exs): @@ -562,6 +600,10 @@ def test_gon2hp(self): for num, ex in enumerate(gon_exs): self.assertEqual(gon2hp(ex), hp_exs[num]) self.assertEqual(gon2hp(-ex), -hp_exs[num]) + for check in self.testData: + hp, dec, gon, rad = check + self.assertAlmostEqual(hp, gon2hp(gon), 13) + self.assertAlmostEqual(-hp, gon2hp(-gon), 13) def test_gon2hpa(self): for num, ex in enumerate(gon_exs): @@ -572,6 +614,10 @@ def test_gon2rad(self): for num, ex in enumerate(gon_exs): self.assertAlmostEqual(gon2rad(ex), rad_exs[num], 15) self.assertAlmostEqual(gon2rad(-ex), -rad_exs[num], 15) + for check in self.testData: + hp, dec, gon, rad = check + self.assertAlmostEqual(rad, gon2rad(gon), 13) + self.assertAlmostEqual(-rad, gon2rad(-gon), 13) def test_gon2dms(self): for num, ex in enumerate(gon_exs): From eb5614dc0d90875b5fef7b9127064b09327a595c Mon Sep 17 00:00:00 2001 From: Phil Anderson Date: Sun, 24 Nov 2024 16:35:23 +1000 Subject: [PATCH 2/5] Added test for very small angular differences (1e-9 seconds) at every second interval between 0 and 360 degrees. Fixed dec2hp() --- geodepy/angles.py | 31 ++++++++++++++++++++++++++++--- geodepy/tests/test_angles.py | 13 +++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/geodepy/angles.py b/geodepy/angles.py index 63ca274..4eb4872 100644 --- a/geodepy/angles.py +++ b/geodepy/angles.py @@ -32,7 +32,14 @@ """ from math import radians +import struct +def add_bits(flt, bits_to_add = 1)->float: + p_flt = struct.pack('@d', flt) + ll = struct.unpack('@q', p_flt)[0] + ll += bits_to_add + p_flt = struct.pack('@q', ll) + return struct.unpack('@d', p_flt)[0] class DECAngle(float): """ @@ -934,11 +941,29 @@ def dec2hp(dec): :type dec: float :return: HP Notation (DDD.MMSSSS) :rtype: float - """ + """ minute, second = divmod(abs(dec) * 3600, 60) degree, minute = divmod(minute, 60) - hp = degree + (minute / 100) + (second / 10000) - hp = round(hp, 16) + + # floating point precision is 13 places for the variable 'dec' where values + # are between 256 and 512 degrees. Precision improves for smaller angles. + # In calculating the variable 'second' the precision is degraded by a factor of 3600 + # Therefore 'second' should be rounded to 9 DP and tested for carry. + if round(second, 9) == 60: + second = 0 + minute += 1 + + # to avoid precision issues with floating point operations + # a string will be built to represent a sexagesimal number and then converted to float + degree = f'{int(degree)}' + minute = f'{int(minute):02}' + second = f'{second:012.9f}'.rstrip('0').replace('.', '') + + hp_string = f'{degree}.{minute}{second}' + hp = float(hp_string) + + if hp >= 360: + hp -= 360 return hp if dec >= 0 else -hp diff --git a/geodepy/tests/test_angles.py b/geodepy/tests/test_angles.py index cb214ce..6d52051 100644 --- a/geodepy/tests/test_angles.py +++ b/geodepy/tests/test_angles.py @@ -89,14 +89,27 @@ class TestConvert(unittest.TestCase): def setUp(self): self.testData = [] degreeValues = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256] + dec_places = 13 + error = 10**-(dec_places - 4) for deg in degreeValues: for min in range(60): for sec in range(60): + if sec: + hp_minus = float(f'{deg:4d}.{min:02d}{sec-1:02d}' + '9' * (dec_places - 4)) + dec_minus = deg + (min / 60.0 + (sec - error) / 3600.0) + gon_minus = dec * 400.0 / 360.0 + rad_minus = dec * pi / 180.0 + self.testData.append([hp_minus, dec_minus, gon_minus, rad_minus]) hp = float(f'{deg:4d}.{min:02d}{sec:02d}') + hp_plus = float(f'{deg:4d}.{min:02d}{sec:02d}' + '0' * (dec_places - 5) + '1') dec = deg + (min / 60.0 + sec / 3600.0) gon = dec * 400.0 / 360.0 rad = dec * pi / 180.0 self.testData.append([hp, dec, gon, rad]) + dec_plus = deg + (min / 60.0 + (sec + error) / 3600.0) + gon_plus = dec * 400.0 / 360.0 + rad_plus = dec * pi / 180.0 + self.testData.append([hp_plus, dec_plus, gon_plus, rad_plus]) def test_DECAngle(self): # Test DECAngle Methods From b8c787c9f913507b0b2b08af7b70596ff2da8cce Mon Sep 17 00:00:00 2001 From: Phil Anderson Date: Sun, 24 Nov 2024 17:53:05 +1000 Subject: [PATCH 3/5] fix ups for dec2hp() and test setup -removed containing angles to 360 degrees in dec2hp() -changed order of operands in test angle generation to optimise precision --- geodepy/angles.py | 9 --------- geodepy/tests/test_angles.py | 12 ++++++------ 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/geodepy/angles.py b/geodepy/angles.py index 4eb4872..8c29711 100644 --- a/geodepy/angles.py +++ b/geodepy/angles.py @@ -32,14 +32,7 @@ """ from math import radians -import struct -def add_bits(flt, bits_to_add = 1)->float: - p_flt = struct.pack('@d', flt) - ll = struct.unpack('@q', p_flt)[0] - ll += bits_to_add - p_flt = struct.pack('@q', ll) - return struct.unpack('@d', p_flt)[0] class DECAngle(float): """ @@ -962,8 +955,6 @@ def dec2hp(dec): hp_string = f'{degree}.{minute}{second}' hp = float(hp_string) - if hp >= 360: - hp -= 360 return hp if dec >= 0 else -hp diff --git a/geodepy/tests/test_angles.py b/geodepy/tests/test_angles.py index 6d52051..cdaede1 100644 --- a/geodepy/tests/test_angles.py +++ b/geodepy/tests/test_angles.py @@ -97,18 +97,18 @@ def setUp(self): if sec: hp_minus = float(f'{deg:4d}.{min:02d}{sec-1:02d}' + '9' * (dec_places - 4)) dec_minus = deg + (min / 60.0 + (sec - error) / 3600.0) - gon_minus = dec * 400.0 / 360.0 - rad_minus = dec * pi / 180.0 + gon_minus = 400.0 / 360.0 * dec_minus + rad_minus = pi / 180.0 * dec_minus self.testData.append([hp_minus, dec_minus, gon_minus, rad_minus]) hp = float(f'{deg:4d}.{min:02d}{sec:02d}') hp_plus = float(f'{deg:4d}.{min:02d}{sec:02d}' + '0' * (dec_places - 5) + '1') dec = deg + (min / 60.0 + sec / 3600.0) - gon = dec * 400.0 / 360.0 - rad = dec * pi / 180.0 + gon = 400.0 / 360.0 * dec + rad = pi / 180.0 * dec self.testData.append([hp, dec, gon, rad]) dec_plus = deg + (min / 60.0 + (sec + error) / 3600.0) - gon_plus = dec * 400.0 / 360.0 - rad_plus = dec * pi / 180.0 + gon_plus = 400.0 / 360.0 * dec_plus + rad_plus = pi / 180.0 * dec_plus self.testData.append([hp_plus, dec_plus, gon_plus, rad_plus]) def test_DECAngle(self): From 98fb9746155c1c9cadc55bd6aeb902336095e0d2 Mon Sep 17 00:00:00 2001 From: Phil Anderson Date: Sun, 24 Nov 2024 20:02:00 +1000 Subject: [PATCH 4/5] fix hp2dec() = now avoids precision problems when hp angle has a whole number of minutes. Now seperates deg min and sec by parsing float as a string instead of using floating point ops. --- geodepy/angles.py | 17 +++++++++-------- geodepy/tests/test_angles.py | 2 ++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/geodepy/angles.py b/geodepy/angles.py index 8c29711..57935e5 100644 --- a/geodepy/angles.py +++ b/geodepy/angles.py @@ -1031,18 +1031,19 @@ def hp2dec(hp): """ # Check if 1st and 3rd decimal place greater than 5 (invalid HP Notation) hp = float(hp) - hp_dec_str = f'{hp:.17f}'.split('.')[1] - if int(hp_dec_str[0]) > 5: + hp_deg_str, hp_mmss_str = f'{hp:.13f}'.split('.') + if int(hp_mmss_str[0]) > 5: raise ValueError(f'Invalid HP Notation: 1st decimal place greater ' f'than 5: {hp}') - if len(hp_dec_str) > 2: - if int(hp_dec_str[2]) > 5: + if len(hp_mmss_str) > 2: + if int(hp_mmss_str[2]) > 5: raise ValueError(f'Invalid HP Notation: 3rd decimal place greater ' f'than 5: {hp}') - degmin, second = divmod(abs(hp) * 1000, 10) - degree, minute = divmod(degmin, 100) - dec = degree + (minute / 60) + (second / 360) - dec = round(dec, 16) + deg = abs(int(hp_deg_str)) + min = int(hp_mmss_str[:2]) + sec = float(hp_mmss_str[2:4] + '.' + hp_mmss_str[4:]) + dec = sec / 3600 + min / 60 + deg + return dec if hp >= 0 else -dec diff --git a/geodepy/tests/test_angles.py b/geodepy/tests/test_angles.py index cdaede1..718c2e8 100644 --- a/geodepy/tests/test_angles.py +++ b/geodepy/tests/test_angles.py @@ -534,6 +534,8 @@ def test_hp2dec(self): self.assertAlmostEqual(dec, hp2dec(hp), 13) self.assertAlmostEqual(-dec, hp2dec(-hp), 13) + self.assertAlmostEqual(0, hp2dec(0), 13) + self.assertAlmostEqual(258, hp2dec(258), 13) self.assertAlmostEqual(hp2dec(hp_exs[0]) + hp2dec(hp_exs[1]), dec_exs[0] + dec_exs[1], 13) # Test that invalid minutes and seconds components raise errors From c9039c6215014c733a36cdd7a91e8b7e633e4975 Mon Sep 17 00:00:00 2001 From: Phil Anderson Date: Sun, 24 Nov 2024 20:57:32 +1000 Subject: [PATCH 5/5] adjust test assert threshholds for gon2dec() and hp2rad() --- geodepy/angles.py | 1 + geodepy/tests/test_angles.py | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/geodepy/angles.py b/geodepy/angles.py index 57935e5..49af0d8 100644 --- a/geodepy/angles.py +++ b/geodepy/angles.py @@ -1039,6 +1039,7 @@ def hp2dec(hp): if int(hp_mmss_str[2]) > 5: raise ValueError(f'Invalid HP Notation: 3rd decimal place greater ' f'than 5: {hp}') + # parse string to avoid precision problems with floating point ops and base 10 numbers deg = abs(int(hp_deg_str)) min = int(hp_mmss_str[:2]) sec = float(hp_mmss_str[2:4] + '.' + hp_mmss_str[4:]) diff --git a/geodepy/tests/test_angles.py b/geodepy/tests/test_angles.py index 718c2e8..e8d94fb 100644 --- a/geodepy/tests/test_angles.py +++ b/geodepy/tests/test_angles.py @@ -576,12 +576,12 @@ def test_hp2gona(self): def test_hp2rad(self): for num, ex in enumerate(hp_exs): - self.assertEqual(hp2rad(ex), rad_exs[num]) - self.assertEqual(hp2rad(-ex), -rad_exs[num]) + self.assertAlmostEqual(hp2rad(ex), rad_exs[num], 15) + self.assertAlmostEqual(hp2rad(-ex), -rad_exs[num], 15) for check in self.testData: hp, dec, gon, rad = check - self.assertAlmostEqual(rad, hp2rad(hp), 13) - self.assertAlmostEqual(-rad, hp2rad(-hp), 13) + self.assertAlmostEqual(rad, hp2rad(hp), 15) + self.assertAlmostEqual(-rad, hp2rad(-hp), 15) def test_hp2dms(self): self.assertEqual(dms_ex.degree, hp2dms(hp_ex).degree) @@ -603,8 +603,8 @@ def test_gon2dec(self): self.assertAlmostEqual(gon2dec(-ex), -dec_exs[num], 14) for check in self.testData: hp, dec, gon, rad = check - self.assertAlmostEqual(dec, gon2dec(gon), 13) - self.assertAlmostEqual(-dec, gon2dec(-gon), 13) + self.assertAlmostEqual(dec, gon2dec(gon), delta = 5.8e-14) + self.assertAlmostEqual(-dec, gon2dec(-gon), delta = 5.8e-14) def test_gon2deca(self): for num, ex in enumerate(gon_exs):