diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index cc88197..fb93872 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -1,6 +1,14 @@ Changelog --------- +PyHamtools 0.10.0 +================ + +01. June 2024 + +* full support for 4, 6, 8 characters Maidenhead locator conversions + + PyHamtools 0.9.1 ================ diff --git a/pyhamtools/locator.py b/pyhamtools/locator.py index 2f0872a..dc963d5 100644 --- a/pyhamtools/locator.py +++ b/pyhamtools/locator.py @@ -3,12 +3,13 @@ from datetime import datetime, timezone import ephem -def latlong_to_locator (latitude, longitude): +def latlong_to_locator (latitude, longitude, precision=6): """converts WGS84 coordinates into the corresponding Maidenhead Locator Args: latitude (float): Latitude longitude (float): Longitude + precision (int): 4,6,8 chars (default 6) Returns: string: Maidenhead locator @@ -32,35 +33,54 @@ def latlong_to_locator (latitude, longitude): """ + if precision < 4 or precision ==5 or precision == 7 or precision > 8: + return ValueError + if longitude >= 180 or longitude <= -180: raise ValueError if latitude >= 90 or latitude <= -90: raise ValueError - longitude += 180; - latitude +=90; + longitude +=180 + latitude +=90 - locator = chr(ord('A') + int(longitude / 20)) - locator += chr(ord('A') + int(latitude / 10)) - locator += chr(ord('0') + int((longitude % 20) / 2)) - locator += chr(ord('0') + int(latitude % 10)) - locator += chr(ord('A') + int((longitude - int(longitude / 2) * 2) / (2 / 24))) - locator += chr(ord('A') + int((latitude - int(latitude / 1) * 1 ) / (1 / 24))) + # copied & adapted from github.com/space-physics/maidenhead + A = ord('A') + a = divmod(longitude, 20) + b = divmod(latitude, 10) + locator = chr(A + int(a[0])) + chr(A + int(b[0])) + lon = a[1] / 2.0 + lat = b[1] + i = 1 + + while i < precision/2: + i += 1 + a = divmod(lon, 1) + b = divmod(lat, 1) + if not (i % 2): + locator += str(int(a[0])) + str(int(b[0])) + lon = 24 * a[1] + lat = 24 * b[1] + else: + locator += chr(A + int(a[0])) + chr(A + int(b[0])) + lon = 10 * a[1] + lat = 10 * b[1] return locator -def locator_to_latlong (locator): +def locator_to_latlong (locator, center=True): """converts Maidenhead locator in the corresponding WGS84 coordinates Args: - locator (string): Locator, either 4 or 6 characters + locator (string): Locator, either 4, 6 or 8 characters + center (bool): Center of (sub)square. By default True. If False, the south/western corner will be returned Returns: tuple (float, float): Latitude, Longitude Raises: - ValueError: When called with wrong or invalid input arg + ValueError: When called with wrong or invalid Maidenhead locator string TypeError: When arg is not a string Example: @@ -79,7 +99,7 @@ def locator_to_latlong (locator): locator = locator.upper() - if len(locator) == 5 or len(locator) < 4: + if len(locator) < 4 or len(locator) == 5 or len(locator) == 7: raise ValueError if ord(locator[0]) > ord('R') or ord(locator[0]) < ord('A'): @@ -100,23 +120,44 @@ def locator_to_latlong (locator): if ord (locator[5]) > ord('X') or ord(locator[5]) < ord('A'): raise ValueError + if len(locator) == 8: + if ord(locator[6]) > ord('9') or ord(locator[6]) < ord('0'): + raise ValueError + if ord (locator[7]) > ord('9') or ord(locator[7]) < ord('0'): + raise ValueError + longitude = (ord(locator[0]) - ord('A')) * 20 - 180 latitude = (ord(locator[1]) - ord('A')) * 10 - 90 longitude += (ord(locator[2]) - ord('0')) * 2 - latitude += (ord(locator[3]) - ord('0')) + latitude += (ord(locator[3]) - ord('0')) * 1 - if len(locator) == 6: - longitude += ((ord(locator[4])) - ord('A')) * (2 / 24) - latitude += ((ord(locator[5])) - ord('A')) * (1 / 24) + if len(locator) == 4: - # move to center of subsquare - longitude += 1 / 24 - latitude += 0.5 / 24 + if center: + longitude += 2 / 2 + latitude += 1.0 / 2 + + elif len(locator) == 6: + longitude += (ord(locator[4]) - ord('A')) * 5.0 / 60 + latitude += (ord(locator[5]) - ord('A')) * 2.5 / 60 + + if center: + longitude += 5.0 / 60 / 2 + latitude += 2.5 / 60 / 2 + + elif len(locator) == 8: + longitude += (ord(locator[4]) - ord('A')) * 5.0 / 60 + latitude += (ord(locator[5]) - ord('A')) * 2.5 / 60 + + longitude += int(locator[6]) * 5.0 / 600 + latitude += int(locator[7]) * 2.5 / 600 + + if center: + longitude += 5.0 / 600 / 2 + latitude += 2.5 / 600 / 2 else: - # move to center of square - longitude += 1; - latitude += 0.5; + raise ValueError return latitude, longitude @@ -125,14 +166,14 @@ def calculate_distance(locator1, locator2): """calculates the (shortpath) distance between two Maidenhead locators Args: - locator1 (string): Locator, either 4 or 6 characters - locator2 (string): Locator, either 4 or 6 characters + locator1 (string): Locator, either 4, 6 or 8 characters + locator2 (string): Locator, either 4, 6 or 8 characters Returns: float: Distance in km Raises: - ValueError: When called with wrong or invalid input arg + ValueError: When called with wrong or invalid maidenhead locator strings AttributeError: When args are not a string Example: @@ -142,6 +183,9 @@ def calculate_distance(locator1, locator2): >>> calculate_distance("JN48QM", "QF67bf") 16466.413 + Note: + Distances is calculated between the centers of the (sub) squares + """ R = 6371 #earh radius @@ -160,15 +204,15 @@ def calculate_distance(locator1, locator2): c = 2 * atan2(sqrt(a), sqrt(1-a)) d = R * c #distance in km - return d; + return d def calculate_distance_longpath(locator1, locator2): """calculates the (longpath) distance between two Maidenhead locators Args: - locator1 (string): Locator, either 4 or 6 characters - locator2 (string): Locator, either 4 or 6 characters + locator1 (string): Locator, either 4, 6 or 8 characters + locator2 (string): Locator, either 4, 6 or 8 characters Returns: float: Distance in km @@ -184,6 +228,8 @@ def calculate_distance_longpath(locator1, locator2): >>> calculate_distance_longpath("JN48QM", "QF67bf") 23541.5867 + Note: + Distance is calculated between the centers of the (sub) squares """ c = 40008 #[km] earth circumference @@ -196,8 +242,8 @@ def calculate_heading(locator1, locator2): """calculates the heading from the first to the second locator Args: - locator1 (string): Locator, either 4 or 6 characters - locator2 (string): Locator, either 4 or 6 characters + locator1 (string): Locator, either 4, 6 or 8 characters + locator2 (string): Locator, either 4, 6 or 6 characters Returns: float: Heading in deg @@ -213,6 +259,9 @@ def calculate_heading(locator1, locator2): >>> calculate_heading("JN48QM", "QF67bf") 74.3136 + Note: + Heading is calculated between the centers of the (sub) squares + """ lat1, long1 = locator_to_latlong(locator1) @@ -236,8 +285,8 @@ def calculate_heading_longpath(locator1, locator2): """calculates the heading from the first to the second locator (long path) Args: - locator1 (string): Locator, either 4 or 6 characters - locator2 (string): Locator, either 4 or 6 characters + locator1 (string): Locator, either 4, 6 or 8 characters + locator2 (string): Locator, either 4, 6 or 8 characters Returns: float: Long path heading in deg @@ -253,6 +302,9 @@ def calculate_heading_longpath(locator1, locator2): >>> calculate_heading_longpath("JN48QM", "QF67bf") 254.3136 + Note: + Distance is calculated between the centers of the (sub) squares + """ heading = calculate_heading(locator1, locator2) @@ -265,7 +317,7 @@ def calculate_sunrise_sunset(locator, calc_date=None): """calculates the next sunset and sunrise for a Maidenhead locator at a give date & time Args: - locator1 (string): Maidenhead Locator, either 4 or 6 characters + locator1 (string): Maidenhead Locator, either 4, 6 or 8 characters calc_date (datetime, optional): Starting datetime for the calculations (UTC) Returns: diff --git a/pyhamtools/version.py b/pyhamtools/version.py index 3408010..8aeb2b4 100644 --- a/pyhamtools/version.py +++ b/pyhamtools/version.py @@ -1,3 +1,3 @@ -VERSION = (0, 9, 1) +VERSION = (0, 10, 0) __release__ = ''.join(['-.'[type(x) == int]+str(x) for x in VERSION])[1:] __version__ = '.'.join((str(VERSION[0]), str(VERSION[1]))) diff --git a/requirements-pytest.txt b/requirements-pytest.txt index c086f37..995a5fa 100644 --- a/requirements-pytest.txt +++ b/requirements-pytest.txt @@ -3,3 +3,4 @@ pytest==4.6.11; python_version=='3.6' pytest-blockage>=0.2.2 pytest-localserver>=0.5 pytest-cov>=2.12 +maidenhead==1.7.0 \ No newline at end of file diff --git a/test/test_locator_distances.py b/test/test_locator_distances.py index a53a765..79cf801 100644 --- a/test/test_locator_distances.py +++ b/test/test_locator_distances.py @@ -14,7 +14,12 @@ class Test_calculate_distance(): assert abs(calculate_distance("JN48QM", "FN44AB") - 5965) < 1 assert abs(calculate_distance("FN44AB", "JN48QM") - 5965) < 1 - assert abs(calculate_distance("JN48QM", "QF67bf") - 16467) < 1 + assert abs(calculate_distance("JN48QM", "QF67BF") - 16467) < 1 + assert abs(calculate_distance("JN48QM84", "QF67BF84") - 16467) < 1 + assert abs(calculate_distance("JN48QM84", "QF67BF") - 16464) < 1 + assert abs(calculate_distance("JN48QM84", "QF67") - 16506) < 1 + assert abs(calculate_distance("JN48QM", "QF67") - 16508) < 1 + assert abs(calculate_distance("JN48", "QF67") - 16535) < 1 def test_calculate_distance_invalid_inputs(self): with pytest.raises(AttributeError): diff --git a/test/test_locator_latlong_to_locator.py b/test/test_locator_latlong_to_locator.py index f34028a..0d1c970 100644 --- a/test/test_locator_latlong_to_locator.py +++ b/test/test_locator_latlong_to_locator.py @@ -8,10 +8,20 @@ class Test_latlong_to_locator(): assert latlong_to_locator(-89.97916, -179.95833) == "AA00AA" assert latlong_to_locator(89.97916, 179.9583) == "RR99XX" - def test_latlong_to_locator_normal_case(self): + def test_latlong_to_locator_4chars_precision(self): + + assert latlong_to_locator(48.52083, 9.3750000, precision=4) == "JN48" + assert latlong_to_locator(39.222916, -86.45416, 4) == "EM69" + + def test_latlong_to_locator_6chars_precision(self): assert latlong_to_locator(48.52083, 9.3750000) == "JN48QM" assert latlong_to_locator(48.5, 9.0) == "JN48MM" #center of the square + assert latlong_to_locator(39.222916, -86.45416, 6) == "EM69SF" + + def test_latlong_to_locator_8chars_precision(self): + assert latlong_to_locator(48.51760, 9.40345, precision=8) == "JN48QM84" + assert latlong_to_locator(39.222916, -86.45416, 4) == "EM69SF53" def test_latlong_to_locator_invalid_characters(self): diff --git a/test/test_locator_locator_to_latlong.py b/test/test_locator_locator_to_latlong.py index 22740c5..a5cf1d2 100644 --- a/test/test_locator_locator_to_latlong.py +++ b/test/test_locator_locator_to_latlong.py @@ -1,10 +1,12 @@ import pytest +import maidenhead from pyhamtools.locator import locator_to_latlong from pyhamtools.consts import LookupConventions as const + class Test_locator_to_latlong(): - def test_locator_to_latlong_edge_cases(self): + def test_locator_to_latlong_min_max_cases(self): latitude, longitude = locator_to_latlong("AA00AA") assert abs(latitude + 89.97916) < 0.00001 assert abs(longitude +179.95833) < 0.0001 @@ -13,23 +15,71 @@ class Test_locator_to_latlong(): assert abs(latitude - 89.97916) < 0.00001 assert abs(longitude - 179.9583) < 0.0001 - def test_locator_to_latlong_normal_case(self): - - latitude, longitude = locator_to_latlong("JN48QM") - assert abs(latitude - 48.52083) < 0.00001 - assert abs(longitude - 9.3750000) < 0.0001 + def test_locator_to_latlong_4chars_precision(self): latitude, longitude = locator_to_latlong("JN48") - assert abs(latitude - 48.5) < 0.001 - assert abs(longitude - 9.000) < 0.001 + assert abs(latitude - 48.5) < 0.1 + assert abs(longitude - 9.0) < 0.1 - def test_locator_to_latlong_mixed_signs(self): + latitude, longitude = locator_to_latlong("JN48", center=False) + assert abs(latitude - 48) < 0.1 + assert abs(longitude - 8) < 0.1 + + def test_locator_to_latlong_6chars_precision(self): + latitude, longitude = locator_to_latlong("JN48QM") + assert abs(latitude - 48.52083) < 0.00001 + assert abs(longitude - 9.37500) < 0.00001 + + def test_locator_to_latlong_8chars(self): + + latitude, longitude = locator_to_latlong("JN48QM84") + assert abs(latitude - 48.51875) < 0.00001 + assert abs(longitude - 9.40416) < 0.00001 + + latitude, longitude = locator_to_latlong("EM69SF53") + assert abs(latitude - 39.222916) < 0.00001 + assert abs(longitude + 86.45416) < 0.00001 + + def test_locator_to_latlong_consistency_checks_6chars_lower_left_corner(self): + + latitude_4, longitude_4 = locator_to_latlong("JN48", center=False) + latitude_6, longitude_6 = locator_to_latlong("JN48AA", center=False) + + assert latitude_4 == latitude_6 + assert longitude_4 == longitude_6 + + def test_locator_to_latlong_consistency_checks_8chars_lower_left_corner(self): + + latitude_6, longitude_6 = locator_to_latlong("JN48AA", center=False) + latitude_8, longitude_8 = locator_to_latlong("JN48AA00", center=False) + + assert latitude_6 == latitude_8 + assert longitude_6 == longitude_8 + + def test_locator_to_latlong_consistency_checks_against_maidenhead(self): + + locs = ["JN48", "EM69", "JN48QM", "EM69SF", "AA00AA", "RR99XX", "JN48QM84", "EM69SF53"] + + # lower left (south/east) corner + for loc in locs: + lat, lon = locator_to_latlong(loc, center=False) + lat_m, lon_m = maidenhead.to_location(loc) + assert abs(lat - lat_m) < 0.00001 + assert abs(lon - lon_m) < 0.00001 + + # center of square + for loc in locs: + lat, lon = locator_to_latlong(loc) # default: center=True + lat_m, lon_m = maidenhead.to_location(loc, center=True) + assert abs(lat - lat_m) < 0.1 + assert abs(lon - lon_m) < 0.1 + + def test_locator_to_latlong_upper_lower_chars(self): latitude, longitude = locator_to_latlong("Jn48qM") assert abs(latitude - 48.52083) < 0.00001 assert abs(longitude - 9.3750000) < 0.0001 - def test_locator_to_latlong_wrong_amount_of_characters(self): with pytest.raises(ValueError): @@ -43,12 +93,27 @@ class Test_locator_to_latlong(): with pytest.raises(ValueError): latitude, longitude = locator_to_latlong("JN8Q") + + with pytest.raises(ValueError): + latitude, longitude = locator_to_latlong("JN8QM1") def test_locator_to_latlong_invalid_characters(self): with pytest.raises(ValueError): latitude, longitude = locator_to_latlong("21XM99") + with pytest.raises(ValueError): + latitude, longitude = locator_to_latlong("48") + + with pytest.raises(ValueError): + latitude, longitude = locator_to_latlong("JNJN") + + with pytest.raises(ValueError): + latitude, longitude = locator_to_latlong("JN4848") + + with pytest.raises(ValueError): + latitude, longitude = locator_to_latlong("JN48QMaa") + with pytest.raises(ValueError): latitude, longitude = locator_to_latlong("****")