Skip to content

Commit e223f29

Browse files
committed
Refactor coordinates
1 parent 173c821 commit e223f29

File tree

2 files changed

+138
-0
lines changed

2 files changed

+138
-0
lines changed

src/euring/coordinates.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from euring.exceptions import EuringConstraintException
2+
3+
4+
def lat_lng_to_euring_coordinates(lat: float, lng: float) -> str:
5+
"""Format latitude and longitude as EURING geographical coordinates."""
6+
return f"{_lat_to_euring_coordinate(lat)}{_lng_to_euring_coordinate(lng)}"
7+
8+
9+
def euring_coordinates_to_lat_lng(value: str) -> dict[str, float]:
10+
"""Parse EURING geographical coordinates into latitude/longitude decimals."""
11+
lat_str = value[:7]
12+
lng_str = value[7:]
13+
return dict(lat=_euring_coordinate_to_decimal(lat_str), lng=_euring_coordinate_to_decimal(lng_str))
14+
15+
16+
def _euring_coordinate_to_decimal(value: str) -> float:
17+
"""Convert EURING geographical coordinate string to decimal coordinate."""
18+
try:
19+
seconds = value[-2:]
20+
minutes = value[-4:-2]
21+
degrees = value[:-4]
22+
result = float(degrees)
23+
negative = result < 0
24+
result = abs(result) + (float(minutes) / 60) + (float(seconds) / 3600)
25+
if negative:
26+
result = -result
27+
except (IndexError, ValueError):
28+
raise EuringConstraintException('Could not parse coordinate "{value}" to decimal.')
29+
return result
30+
31+
32+
def _decimal_to_euring_coordinate(value: float, degrees_pos: int) -> str:
33+
"""Format a decimal coordinate into EURING DMS text with fixed degree width."""
34+
parts = _decimal_to_euring_coordinate_components(value)
35+
return "{quadrant}{degrees}{minutes}{seconds}".format(
36+
quadrant=parts["quadrant"],
37+
degrees="{}".format(abs(parts["degrees"])).zfill(degrees_pos),
38+
minutes="{}".format(parts["minutes"]).zfill(2),
39+
seconds="{}".format(parts["seconds"]).zfill(2),
40+
)
41+
42+
43+
def _lat_to_euring_coordinate(value: float) -> str:
44+
"""Convert a latitude in decimal degrees to a EURING coordinate string."""
45+
return _decimal_to_euring_coordinate(value, degrees_pos=2)
46+
47+
48+
def _lng_to_euring_coordinate(value: float) -> str:
49+
"""Convert a longitude in decimal degrees to a EURING DMS coordinae string."""
50+
return _decimal_to_euring_coordinate(value, degrees_pos=3)
51+
52+
53+
def _decimal_to_euring_coordinate_components(value: float) -> dict[str, int | float | str]:
54+
"""Convert a decimal coordinate into EURING geographical coordinate components."""
55+
degrees = int(value)
56+
submin = abs((value - int(value)) * 60)
57+
minutes = int(submin)
58+
seconds = abs((submin - int(submin)) * 60)
59+
quadrant = "-" if degrees < 0 else "+"
60+
seconds = int(round(seconds))
61+
if seconds == 60:
62+
seconds = 0
63+
minutes += 1
64+
if minutes == 60:
65+
minutes = 0
66+
degrees = degrees + 1 if degrees >= 0 else degrees - 1
67+
return {"quadrant": quadrant, "degrees": degrees, "minutes": minutes, "seconds": seconds}
68+
69+
70+
def _validate_euring_coordinates(value: str | None) -> None:
71+
"""Validate a combined EURING latitude/longitude coordinate string."""
72+
if value is None:
73+
raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.')
74+
if len(value) != 15:
75+
raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.')
76+
_validate_euring_coordinate_component(value[:7], degrees_digits=2, max_degrees=90)
77+
_validate_euring_coordinate_component(value[7:], degrees_digits=3, max_degrees=180)
78+
79+
80+
def _validate_euring_coordinate_component(value: str | None, *, degrees_digits: int, max_degrees: int) -> None:
81+
"""Validate a single EURING coordinate component."""
82+
if value is None:
83+
raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.')
84+
expected_length = 1 + degrees_digits + 2 + 2
85+
if len(value) != expected_length:
86+
raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.')
87+
sign = value[0]
88+
if sign not in {"+", "-"}:
89+
raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.')
90+
degrees = value[1 : 1 + degrees_digits]
91+
minutes = value[1 + degrees_digits : 1 + degrees_digits + 2]
92+
seconds = value[1 + degrees_digits + 2 :]
93+
if not (degrees.isdigit() and minutes.isdigit() and seconds.isdigit()):
94+
raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.')
95+
if int(degrees) > max_degrees or int(minutes) > 59 or int(seconds) > 59:
96+
raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.')

tests/test_coordinates.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Tests for EURING coordinate utilities."""
2+
3+
import pytest
4+
5+
from euring import EuringConstraintException
6+
from euring.coordinates import (
7+
_decimal_to_euring_coordinate_components,
8+
_euring_coordinate_to_decimal,
9+
_lat_to_euring_coordinate,
10+
_lng_to_euring_coordinate,
11+
euring_coordinates_to_lat_lng,
12+
lat_lng_to_euring_coordinates,
13+
)
14+
15+
16+
def test_coordinate_conversion():
17+
lat_decimal = _euring_coordinate_to_decimal("+420500")
18+
lng_decimal = _euring_coordinate_to_decimal("-0100203")
19+
assert abs(lat_decimal - 42.083333) < 1e-5
20+
assert abs(lng_decimal - (-10.034167)) < 1e-5
21+
22+
assert _lat_to_euring_coordinate(lat_decimal) == "+420500"
23+
assert _lng_to_euring_coordinate(lng_decimal) == "-0100203"
24+
25+
dms = _decimal_to_euring_coordinate_components(12.25)
26+
assert dms["quadrant"] == "+"
27+
assert dms["degrees"] == 12
28+
assert dms["minutes"] == 15
29+
assert dms["seconds"] == 0.0
30+
31+
32+
def test_coordinates_round_trip():
33+
value = "+420500-0100203"
34+
parsed = euring_coordinates_to_lat_lng(value)
35+
assert parsed["lat"] == pytest.approx(42.083333333333336)
36+
assert parsed["lng"] == pytest.approx(-10.034166666666666)
37+
assert lat_lng_to_euring_coordinates(parsed["lat"], parsed["lng"]) == value
38+
39+
40+
def test_coordinate_conversion_invalid():
41+
with pytest.raises(EuringConstraintException):
42+
_euring_coordinate_to_decimal("bogus")

0 commit comments

Comments
 (0)