Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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:")
Expand Down
11 changes: 3 additions & 8 deletions src/euring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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",
Expand Down
36 changes: 6 additions & 30 deletions src/euring/codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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"}:
Expand Down
96 changes: 96 additions & 0 deletions src/euring/coordinates.py
Original file line number Diff line number Diff line change
@@ -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.')
31 changes: 22 additions & 9 deletions src/euring/field_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
7 changes: 4 additions & 3 deletions src/euring/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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}"


Expand Down
Loading