diff --git a/example.py b/example.py index fbf678d..78c6a6d 100644 --- a/example.py +++ b/example.py @@ -6,10 +6,9 @@ TYPE_INTEGER, EuringException, EuringRecord, - euring_dms_to_float, - euring_lat_to_dms, is_valid_euring_type, ) +from euring.coordinates import euring_coordinates_to_lat_lng, lat_lng_to_euring_coordinates def main(): @@ -25,10 +24,16 @@ def main(): # Test coordinate conversion print("\n2. Coordinate Conversion:") - dms = "+420500" - decimal = euring_dms_to_float(dms) - back_to_dms = euring_lat_to_dms(decimal) - print(f"DMS: {dms} -> Decimal: {decimal} -> Back to DMS: {back_to_dms}") + euring_geographical_coordinates = "+420500+0000000" + wgs84 = euring_coordinates_to_lat_lng(euring_geographical_coordinates) + lat = wgs84["lat"] + lng = wgs84["lng"] + encoded_lat_lng = lat_lng_to_euring_coordinates(lat, lng) + print( + f"Encoded coordinates: {euring_geographical_coordinates}" + f" -> Lat: {lat}, Lng: {lng}" + f" -> Back to encoded coordinates: {encoded_lat_lng}" + ) # Test decoding (using a minimal example) print("\n3. Record Decoding:") diff --git a/src/euring/__init__.py b/src/euring/__init__.py index c53fe5e..a82a203 100644 --- a/src/euring/__init__.py +++ b/src/euring/__init__.py @@ -13,6 +13,7 @@ from .__about__ import __version__ from .converters import convert_euring_record +from .coordinates import euring_coordinates_to_lat_lng, lat_lng_to_euring_coordinates from .exceptions import ( EuringConstraintException, EuringException, @@ -42,12 +43,8 @@ is_valid_euring_type, ) from .utils import ( - euring_dms_to_float, - euring_float_to_dms, euring_identification_display_format, euring_identification_export_format, - euring_lat_to_dms, - euring_lng_to_dms, euring_scheme_export_format, euring_species_export_format, ) @@ -77,10 +74,8 @@ "FORMAT_EURING2000PLUS", "FORMAT_EURING2020", "FORMAT_JSON", - "euring_dms_to_float", - "euring_float_to_dms", - "euring_lat_to_dms", - "euring_lng_to_dms", + "euring_coordinates_to_lat_lng", + "lat_lng_to_euring_coordinates", "euring_identification_display_format", "euring_identification_export_format", "euring_scheme_export_format", diff --git a/src/euring/codes.py b/src/euring/codes.py index 73c9176..0de74da 100644 --- a/src/euring/codes.py +++ b/src/euring/codes.py @@ -3,6 +3,7 @@ from datetime import date from typing import Any +from .coordinates import _validate_euring_coordinates, euring_coordinates_to_lat_lng from .data import ( load_code_map, load_other_marks_data, @@ -14,7 +15,7 @@ load_species_map, ) from .exceptions import EuringConstraintException, EuringLookupException -from .utils import euring_dms_to_float, is_all_hyphens +from .utils import is_all_hyphens LOOKUP_EURING_CODE_IDENTIFIER = load_code_map("euring_code_identifier") LOOKUP_CONDITION = load_code_map("condition") @@ -64,6 +65,7 @@ def _catching_method_code_filter(code: str) -> bool: _SCHEME_DETAILS = load_scheme_details() _PLACE_DETAILS = load_place_details() _RINGING_SCHEME_PATTERN = re.compile(r"^[A-Z]{3}$") +_PLACE_CODE_PATTERN = re.compile(r"^[A-Z]{2}([A-Z]{2}|[0-9]{2}|--)$") def lookup_description(value: str, lookup: Mapping[str, str] | Callable[[str], str] | None) -> str | None: @@ -178,15 +180,11 @@ def parse_geographical_coordinates(value: str | None) -> dict[str, float] | None raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') if value == "." * 15: return None - _validate_dms_component(value[:7], degrees_digits=2, max_degrees=90) - _validate_dms_component(value[7:], degrees_digits=3, max_degrees=180) + _validate_euring_coordinates(value) try: - lat = value[:7] - lng = value[7:] + return euring_coordinates_to_lat_lng(value) except (TypeError, IndexError): raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') - result = dict(lat=euring_dms_to_float(lat), lng=euring_dms_to_float(lng)) - return result def lookup_geographical_coordinates(value: dict[str, float] | None) -> str | None: @@ -224,13 +222,10 @@ def parse_direction(value: str) -> int | None: return parsed -_PLACE_CODE_RE = re.compile(r"^[A-Z]{2}([A-Z]{2}|[0-9]{2}|--)$") - - def parse_place_code(value: str) -> str: """Validate the place code pattern (AA##, AAAA, or AA--).""" value_str = f"{value}" - if not _PLACE_CODE_RE.match(value_str): + if not _PLACE_CODE_PATTERN.match(value_str): raise EuringConstraintException(f'Value "{value}" is not a valid place code format.') return value_str @@ -250,25 +245,6 @@ def _parse_decimal_coordinate(value: str, *, max_abs: int, max_decimals: int, fi return parsed -def _validate_dms_component(value: str | None, *, degrees_digits: int, max_degrees: int) -> None: - """Validate a DMS coordinate component.""" - if value is None: - raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') - expected_length = 1 + degrees_digits + 2 + 2 - if len(value) != expected_length: - raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') - sign = value[0] - if sign not in {"+", "-"}: - raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') - degrees = value[1 : 1 + degrees_digits] - minutes = value[1 + degrees_digits : 1 + degrees_digits + 2] - seconds = value[1 + degrees_digits + 2 :] - if not (degrees.isdigit() and minutes.isdigit() and seconds.isdigit()): - raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') - if int(degrees) > max_degrees or int(minutes) > 59 or int(seconds) > 59: - raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') - - def parse_old_greater_coverts(value: str) -> str: """Validate Old Greater Coverts codes (0-9 or A).""" if value not in {str(num) for num in range(10)} | {"A"}: diff --git a/src/euring/coordinates.py b/src/euring/coordinates.py new file mode 100644 index 0000000..1631b0b --- /dev/null +++ b/src/euring/coordinates.py @@ -0,0 +1,96 @@ +from euring.exceptions import EuringConstraintException + + +def lat_lng_to_euring_coordinates(lat: float, lng: float) -> str: + """Format latitude and longitude as EURING geographical coordinates.""" + return f"{_lat_to_euring_coordinate(lat)}{_lng_to_euring_coordinate(lng)}" + + +def euring_coordinates_to_lat_lng(value: str) -> dict[str, float]: + """Parse EURING geographical coordinates into latitude/longitude decimals.""" + lat_str = value[:7] + lng_str = value[7:] + return dict(lat=_euring_coordinate_to_decimal(lat_str), lng=_euring_coordinate_to_decimal(lng_str)) + + +def _euring_coordinate_to_decimal(value: str) -> float: + """Convert EURING geographical coordinate string to decimal coordinate.""" + try: + seconds = value[-2:] + minutes = value[-4:-2] + degrees = value[:-4] + result = float(degrees) + negative = result < 0 + result = abs(result) + (float(minutes) / 60) + (float(seconds) / 3600) + if negative: + result = -result + except (IndexError, ValueError): + raise EuringConstraintException('Could not parse coordinate "{value}" to decimal.') + return result + + +def _decimal_to_euring_coordinate(value: float, degrees_pos: int) -> str: + """Format a decimal coordinate into EURING DMS text with fixed degree width.""" + parts = _decimal_to_euring_coordinate_components(value) + return "{quadrant}{degrees}{minutes}{seconds}".format( + quadrant=parts["quadrant"], + degrees="{}".format(abs(parts["degrees"])).zfill(degrees_pos), + minutes="{}".format(parts["minutes"]).zfill(2), + seconds="{}".format(parts["seconds"]).zfill(2), + ) + + +def _lat_to_euring_coordinate(value: float) -> str: + """Convert a latitude in decimal degrees to a EURING coordinate string.""" + return _decimal_to_euring_coordinate(value, degrees_pos=2) + + +def _lng_to_euring_coordinate(value: float) -> str: + """Convert a longitude in decimal degrees to a EURING DMS coordinae string.""" + return _decimal_to_euring_coordinate(value, degrees_pos=3) + + +def _decimal_to_euring_coordinate_components(value: float) -> dict[str, int | float | str]: + """Convert a decimal coordinate into EURING geographical coordinate components.""" + degrees = int(value) + submin = abs((value - int(value)) * 60) + minutes = int(submin) + seconds = abs((submin - int(submin)) * 60) + quadrant = "-" if degrees < 0 else "+" + seconds = int(round(seconds)) + if seconds == 60: + seconds = 0 + minutes += 1 + if minutes == 60: + minutes = 0 + degrees = degrees + 1 if degrees >= 0 else degrees - 1 + return {"quadrant": quadrant, "degrees": degrees, "minutes": minutes, "seconds": seconds} + + +def _validate_euring_coordinates(value: str | None) -> None: + """Validate a combined EURING latitude/longitude coordinate string.""" + if value is None: + raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') + if len(value) != 15: + raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') + _validate_euring_coordinate_component(value[:7], degrees_digits=2, max_degrees=90) + _validate_euring_coordinate_component(value[7:], degrees_digits=3, max_degrees=180) + + +def _validate_euring_coordinate_component(value: str | None, *, degrees_digits: int, max_degrees: int) -> None: + """Validate a single EURING coordinate component.""" + if value is None: + raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') + expected_length = 1 + degrees_digits + 2 + 2 + if len(value) != expected_length: + raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') + sign = value[0] + if sign not in {"+", "-"}: + raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') + degrees = value[1 : 1 + degrees_digits] + minutes = value[1 + degrees_digits : 1 + degrees_digits + 2] + seconds = value[1 + degrees_digits + 2 :] + if not (degrees.isdigit() and minutes.isdigit() and seconds.isdigit()): + raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') + if int(degrees) > max_degrees or int(minutes) > 59 or int(seconds) > 59: + raise EuringConstraintException(f'Value "{value}" is not a valid set of coordinates.') diff --git a/src/euring/field_schema.py b/src/euring/field_schema.py index 1e90ce8..f9201cb 100644 --- a/src/euring/field_schema.py +++ b/src/euring/field_schema.py @@ -5,7 +5,8 @@ from datetime import date as dt_date from typing import Any -from euring.utils import euring_lat_to_dms, euring_lng_to_dms, is_all_hyphens, is_empty +from euring.coordinates import lat_lng_to_euring_coordinates +from euring.utils import is_all_hyphens, is_empty from .codes import lookup_description from .exceptions import EuringConstraintException, EuringTypeException @@ -142,10 +143,16 @@ def encode(self, value: Any | None) -> str: raise EuringConstraintException('Required field, empty value "" is not permitted.') return "" - if self.key == "geographical_coordinates" and isinstance(value, dict): - if "lat" not in value or "lng" not in value: - raise EuringConstraintException("Geographical coordinates require both lat and lng values.") - return f"{euring_lat_to_dms(float(value['lat']))}{euring_lng_to_dms(float(value['lng']))}" + if self.key == "geographical_coordinates": + coords: dict[str, object] | None = None + if isinstance(value, dict): + coords = value + elif isinstance(value, (tuple, list)) and len(value) == 2: + coords = {"lat": value[0], "lng": value[1]} + if coords is not None: + if "lat" not in coords or "lng" not in coords: + raise EuringConstraintException("Geographical coordinates require both lat and lng values.") + return lat_lng_to_euring_coordinates(float(coords["lat"]), float(coords["lng"])) if self.key == "date" and isinstance(value, dt_date): return value.strftime("%d%m%Y") @@ -175,10 +182,16 @@ def encode_for_format(self, value: Any | None, *, format: str) -> str: return "-" * self.length return "" - if self.key == "geographical_coordinates" and isinstance(value, dict): - if "lat" not in value or "lng" not in value: - raise EuringConstraintException("Geographical coordinates require both lat and lng values.") - return f"{euring_lat_to_dms(float(value['lat']))}{euring_lng_to_dms(float(value['lng']))}" + if self.key == "geographical_coordinates": + coords: dict[str, object] | None = None + if isinstance(value, dict): + coords = value + elif isinstance(value, (tuple, list)) and len(value) == 2: + coords = {"lat": value[0], "lng": value[1]} + if coords is not None: + if "lat" not in coords or "lng" not in coords: + raise EuringConstraintException("Geographical coordinates require both lat and lng values.") + return lat_lng_to_euring_coordinates(float(coords["lat"]), float(coords["lng"])) if self.key == "date" and isinstance(value, dt_date): str_value = value.strftime("%d%m%Y") diff --git a/src/euring/record.py b/src/euring/record.py index f26d9fb..861756c 100644 --- a/src/euring/record.py +++ b/src/euring/record.py @@ -4,6 +4,7 @@ import warnings from dataclasses import replace +from .coordinates import _lat_to_euring_coordinate, _lng_to_euring_coordinate from .exceptions import EuringConstraintException, EuringException from .field_schema import EuringField, coerce_field from .fields import EURING2000_FIELDS, EURING2000PLUS_FIELDS, EURING2020_FIELDS @@ -17,7 +18,7 @@ unknown_format_error, ) from .rules import record_rule_errors, requires_euring2020 -from .utils import euring_lat_to_dms, euring_lng_to_dms, is_all_hyphens, is_empty +from .utils import is_all_hyphens, is_empty class EuringRecord: @@ -415,8 +416,8 @@ def _apply_coordinate_downgrade( longitude = values_by_key.get("longitude", "") if not latitude or not longitude: return - lat = euring_lat_to_dms(float(latitude)) - lng = euring_lng_to_dms(float(longitude)) + lat = _lat_to_euring_coordinate(float(latitude)) + lng = _lng_to_euring_coordinate(float(longitude)) values_by_key["geographical_coordinates"] = f"{lat}{lng}" diff --git a/src/euring/utils.py b/src/euring/utils.py index 927fa33..5229d9e 100644 --- a/src/euring/utils.py +++ b/src/euring/utils.py @@ -1,18 +1,11 @@ import re from typing import Any -from .exceptions import EuringConstraintException - __all__ = [ - "euring_dms_to_float", - "euring_float_to_dms", - "euring_lat_to_dms", - "euring_lng_to_dms", "euring_identification_display_format", "euring_identification_export_format", "euring_scheme_export_format", "euring_species_export_format", - "is_empty", ] @@ -26,73 +19,6 @@ def is_all_hyphens(value: str) -> bool: return bool(value) and set(value) == {"-"} -def euring_dms_to_float(value: str) -> float: - """Convert EURING DMS coordinate text into decimal degrees.""" - try: - seconds = value[-2:] - minutes = value[-4:-2] - degrees = value[:-4] - result = float(degrees) - negative = result < 0 - result = abs(result) + (float(minutes) / 60) + (float(seconds) / 3600) - if negative: - result = -result - except (IndexError, ValueError): - raise EuringConstraintException('Could not parse coordinate "{value}" to decimal.') - return result - - -def euring_float_to_dms(value: float, round_seconds: bool = False) -> dict[str, int | float | str]: - """ - Convert a Decimal Degree Value into Degrees Minute Seconds Notation. - - Pass value as double - type = {Latitude or Longitude} as string - - returns a dict with quadrant, degreees, minutes, seconds - created by: anothergisblog.blogspot.com - modified by: Dylan Verheul - """ - degrees = int(value) - submin = abs((value - int(value)) * 60) - minutes = int(submin) - seconds = abs((submin - int(submin)) * 60) - if degrees < 0: - quadrant = "-" - else: - quadrant = "+" # includes 0 - if round_seconds: - seconds = int(round(seconds)) - if seconds == 60: - seconds = 0 - minutes += 1 - if minutes == 60: - minutes = 0 - degrees = degrees + 1 if degrees >= 0 else degrees - 1 - return {"quadrant": quadrant, "degrees": degrees, "minutes": minutes, "seconds": seconds} - - -def _euring_coord_to_dms(value: float, degrees_pos: int) -> str: - """Format a decimal coordinate into EURING DMS text with fixed degree width.""" - dms = euring_float_to_dms(value, round_seconds=True) - return "{quadrant}{degrees}{minutes}{seconds}".format( - quadrant=dms["quadrant"], - degrees="{}".format(abs(dms["degrees"])).zfill(degrees_pos), - minutes="{}".format(dms["minutes"]).zfill(2), - seconds="{}".format(dms["seconds"]).zfill(2), - ) - - -def euring_lat_to_dms(value: float) -> str: - """Convert a latitude in decimal degrees into EURING DMS text.""" - return _euring_coord_to_dms(value, degrees_pos=2) - - -def euring_lng_to_dms(value: float) -> str: - """Convert a longitude in decimal degrees into EURING DMS text.""" - return _euring_coord_to_dms(value, degrees_pos=3) - - def euring_identification_display_format(euring_number: Any) -> str: """ Return EURING number in upper case, with anything that is not a letter or digit removed. diff --git a/tests/test_cli.py b/tests/test_cli.py index fd78b15..49178f7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,9 +4,9 @@ import euring.main as main_module from euring import exceptions as euring_exceptions +from euring.coordinates import _lat_to_euring_coordinate, _lng_to_euring_coordinate from euring.fields import EURING_FIELDS from euring.main import app -from euring.utils import euring_lat_to_dms, euring_lng_to_dms def _make_euring2020_record_with_coords() -> str: @@ -549,8 +549,8 @@ def test_convert_cli_invalid_format(): def test_convert_cli_downgrade_with_coords(): runner = CliRunner() - lat = euring_lat_to_dms(52.3760) - lng = euring_lng_to_dms(4.9000) + lat = _lat_to_euring_coordinate(52.3760) + lng = _lng_to_euring_coordinate(4.9000) result = runner.invoke( app, [ diff --git a/tests/test_converters.py b/tests/test_converters.py index e2fc24a..bdfad58 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -6,8 +6,8 @@ import pytest from euring.converters import convert_euring_record +from euring.coordinates import _lat_to_euring_coordinate, _lng_to_euring_coordinate from euring.fields import EURING_FIELDS -from euring.utils import euring_lat_to_dms, euring_lng_to_dms def _load_fixture(module_name: str, attr: str) -> str: @@ -118,8 +118,8 @@ def test_convert_alpha_accuracy_invalid(): def test_convert_coordinate_downgrade_fills(): record = _make_euring2020_record_with_coords() - lat = euring_lat_to_dms(52.3760) - lng = euring_lng_to_dms(4.9000) + lat = _lat_to_euring_coordinate(52.3760) + lng = _lng_to_euring_coordinate(4.9000) converted = convert_euring_record(record, target_format="euring2000plus", force=True) fields = converted.split("|") geo_index = next(i for i, f in enumerate(EURING_FIELDS) if f["key"] == "geographical_coordinates") diff --git a/tests/test_coordinates.py b/tests/test_coordinates.py new file mode 100644 index 0000000..074e462 --- /dev/null +++ b/tests/test_coordinates.py @@ -0,0 +1,42 @@ +"""Tests for EURING coordinate utilities.""" + +import pytest + +from euring import EuringConstraintException +from euring.coordinates import ( + _decimal_to_euring_coordinate_components, + _euring_coordinate_to_decimal, + _lat_to_euring_coordinate, + _lng_to_euring_coordinate, + euring_coordinates_to_lat_lng, + lat_lng_to_euring_coordinates, +) + + +def test_coordinate_conversion(): + lat_decimal = _euring_coordinate_to_decimal("+420500") + lng_decimal = _euring_coordinate_to_decimal("-0100203") + assert abs(lat_decimal - 42.083333) < 1e-5 + assert abs(lng_decimal - (-10.034167)) < 1e-5 + + assert _lat_to_euring_coordinate(lat_decimal) == "+420500" + assert _lng_to_euring_coordinate(lng_decimal) == "-0100203" + + dms = _decimal_to_euring_coordinate_components(12.25) + assert dms["quadrant"] == "+" + assert dms["degrees"] == 12 + assert dms["minutes"] == 15 + assert dms["seconds"] == 0.0 + + +def test_coordinates_round_trip(): + value = "+420500-0100203" + parsed = euring_coordinates_to_lat_lng(value) + assert parsed["lat"] == pytest.approx(42.083333333333336) + assert parsed["lng"] == pytest.approx(-10.034166666666666) + assert lat_lng_to_euring_coordinates(parsed["lat"], parsed["lng"]) == value + + +def test_coordinate_conversion_invalid(): + with pytest.raises(EuringConstraintException): + _euring_coordinate_to_decimal("bogus") diff --git a/tests/test_utils.py b/tests/test_utils.py index 75d740f..54ea828 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,41 +3,14 @@ import pytest from euring import ( - EuringConstraintException, - euring_dms_to_float, - euring_float_to_dms, euring_identification_display_format, euring_identification_export_format, - euring_lat_to_dms, - euring_lng_to_dms, euring_scheme_export_format, euring_species_export_format, ) from euring.utils import is_all_hyphens, is_empty -def test_dms_conversion(): - # Test DMS to float - lat_decimal = euring_dms_to_float("+420500") - lng_decimal = euring_dms_to_float("-0100203") - assert abs(lat_decimal - 42.083333) < 1e-5 - assert abs(lng_decimal - (-10.034167)) < 1e-5 - - # Test float to DMS (round trip) - assert euring_lat_to_dms(lat_decimal) == "+420500" - assert euring_lng_to_dms(lng_decimal) == "-0100203" - dms = euring_float_to_dms(12.25) - assert dms["quadrant"] == "+" - assert dms["degrees"] == 12 - assert dms["minutes"] == 15 - assert dms["seconds"] == 0.0 - - -def test_dms_conversion_invalid(): - with pytest.raises(EuringConstraintException): - euring_dms_to_float("bogus") - - def test_identification_format(): assert euring_identification_display_format("ab.12-3") == "AB123" assert euring_identification_export_format("AB123") == "AB.....123"