diff --git a/alertproviders/parksnpeaks.py b/alertproviders/parksnpeaks.py index f6df152..353dea3 100644 --- a/alertproviders/parksnpeaks.py +++ b/alertproviders/parksnpeaks.py @@ -4,7 +4,7 @@ from datetime import datetime import pytz from alertproviders.http_alert_provider import HTTPAlertProvider -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig from data.alert import Alert diff --git a/alertproviders/pota.py b/alertproviders/pota.py index 0d05778..f340a68 100644 --- a/alertproviders/pota.py +++ b/alertproviders/pota.py @@ -3,7 +3,7 @@ from datetime import datetime import pytz from alertproviders.http_alert_provider import HTTPAlertProvider -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig from data.alert import Alert diff --git a/alertproviders/sota.py b/alertproviders/sota.py index 6400c34..9abd5df 100644 --- a/alertproviders/sota.py +++ b/alertproviders/sota.py @@ -3,7 +3,7 @@ from datetime import datetime import pytz from alertproviders.http_alert_provider import HTTPAlertProvider -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig from data.alert import Alert diff --git a/alertproviders/wwff.py b/alertproviders/wwff.py index a754031..11443af 100644 --- a/alertproviders/wwff.py +++ b/alertproviders/wwff.py @@ -3,7 +3,7 @@ from datetime import datetime import pytz from alertproviders.http_alert_provider import HTTPAlertProvider -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig from data.alert import Alert diff --git a/core/constants.py b/core/constants.py index 17f8815..426e924 100644 --- a/core/constants.py +++ b/core/constants.py @@ -25,7 +25,7 @@ SIGS = [ SIG(name="WCA", description="World Castles Award", icon="chess-rook", ref_regex=r"[A-Z0-9]{1,3}\-\d+"), SIG(name="ZLOTA", description="New Zealand on the Air", icon="kiwi-bird", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d+"), SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania", ref_regex=r""), - SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{2}[0-9]{2}"), + SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"), SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}") ] diff --git a/core/geo_utils.py b/core/geo_utils.py new file mode 100644 index 0000000..bb8a82d --- /dev/null +++ b/core/geo_utils.py @@ -0,0 +1,103 @@ +import logging +import re +from math import floor + +from pyproj import Transformer + +TRANSFORMER_OS_GRID_TO_WGS84 = Transformer.from_crs("EPSG:27700", "EPSG:4326") +TRANSFORMER_IRISH_GRID_TO_WGS84 = Transformer.from_crs("EPSG:29903", "EPSG:4326") +TRANSFORMER_CI_UTM_GRID_TO_WGS84 = Transformer.from_crs("+proj=utm +zone=30 +ellps=WGS84", "EPSG:4326") + + +# Convert a Worked All Britain or Worked All Ireland reference to a lat/lon point. +def wab_wai_square_to_lat_lon(ref): + # First check we have a valid grid square, and based on what it looks like, use either the Ordnance Survey, Irish, + # or UTM grid systems to perform the conversion. + if re.match(r"^[HNOST][ABCDEFGHJKLMNOPQRSTUVWXYZ][0-9]{2}$", ref): + return os_grid_square_to_lat_lon(ref) + elif re.match(r"^[ABCDEFGHJKLMNOPQRSTUVWXYZ][0-9]{2}$", ref): + return irish_grid_square_to_lat_lon(ref) + elif re.match(r"^W[AV][0-9]{2}$", ref): + return utm_grid_square_to_lat_lon(ref) + else: + logging.warn("Invalid WAB/WAI square: " + ref) + return None + + +# Get a lat/lon point for the centre of an Ordnance Survey grid square +def os_grid_square_to_lat_lon(ref): + # Convert the letters into multipliers for the 500km squares and 100km squares + offset_500km_multiplier = ord(ref[0]) - 65 + offset_100km_multiplier = ord(ref[1]) - 65 + + # The letter "I" is not used in the grid, so any offset of 8 or more needs to be reduced by 1. + if offset_500km_multiplier >= 8: + offset_500km_multiplier = offset_500km_multiplier - 1 + if offset_100km_multiplier >= 8: + offset_100km_multiplier = offset_100km_multiplier - 1 + + # Convert the offsets into increments of 100km from the false origin (grid square SV): + easting_100km = ((offset_500km_multiplier - 2) % 5) * 5 + (offset_100km_multiplier % 5) + northing_100km = (19 - floor(offset_500km_multiplier / 5) * 5) - floor(offset_100km_multiplier / 5) + + # Take the numeric parts of the grid square and multiply by 10000 to get metres, then combine with the 100km + # box offsets + easting = int(ref[2]) * 10000 + easting_100km * 100000 + northing = int(ref[3]) * 10000 + northing_100km * 100000 + + # Add 5000m to each value to get the middle of the box rather than the south-west corner + easting = easting + 5000 + northing = northing + 5000 + + # Reproject to WGS84 lat/lon + lat, lon = TRANSFORMER_OS_GRID_TO_WGS84.transform(easting, northing) + return lat, lon + + +# Get a lat/lon point for the centre of an Irish Grid square. +def irish_grid_square_to_lat_lon(ref): + # Convert the letters into multipliers for the 100km squares + offset_100km_multiplier = ord(ref[0]) - 65 + + # The letter "I" is not used in the grid, so any offset of 8 or more needs to be reduced by 1. + if offset_100km_multiplier >= 8: + offset_100km_multiplier = offset_100km_multiplier - 1 + + # Convert the offsets into increments of 100km from the false origin: + easting_100km = offset_100km_multiplier % 5 + northing_100km = 4 - floor(offset_100km_multiplier / 5) + + # Take the numeric parts of the grid square and multiply by 10000 to get metres, then combine with the 100km + # box offsets + easting = int(ref[1]) * 10000 + easting_100km * 100000 + northing = int(ref[2]) * 10000 + northing_100km * 100000 + + # Add 5000m to each value to get the middle of the box rather than the south-west corner + easting = easting + 5000 + northing = northing + 5000 + + # Reproject to WGS84 lat/lon + lat, lon = TRANSFORMER_IRISH_GRID_TO_WGS84.transform(easting, northing) + return lat, lon + + +# Get a lat/lon point for the centre of a UTM grid square (supports only squares WA & WV for the Channel Islands, nothing else implemented) +def utm_grid_square_to_lat_lon(ref): + # Take the numeric parts of the grid square and multiply by 10000 to get metres from the corner of the letter-based grid square + easting = int(ref[2]) * 10000 + northing = int(ref[3]) * 10000 + + # Apply the appropriate offset based on whether the square is WA or WV + easting = easting + 500000 + if ref[1] == "A": + northing = northing + 5500000 + else: + northing = northing + 5400000 + + # Add 5000m to each value to get the middle of the box rather than the south-west corner + easting = easting + 5000 + northing = northing + 5000 + + # Reproject to WGS84 lat/lon + lat, lon = TRANSFORMER_CI_UTM_GRID_TO_WGS84.transform(easting, northing) + return lat, lon diff --git a/core/utils.py b/core/sig_utils.py similarity index 100% rename from core/utils.py rename to core/sig_utils.py diff --git a/data/alert.py b/data/alert.py index 6020cbe..93f6542 100644 --- a/data/alert.py +++ b/data/alert.py @@ -9,7 +9,7 @@ import pytz from core.constants import DXCC_FLAGS from core.lookup_helper import lookup_helper -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig # Data class that defines an alert. diff --git a/data/spot.py b/data/spot.py index c64b508..c1c9616 100644 --- a/data/spot.py +++ b/data/spot.py @@ -10,8 +10,9 @@ import pytz from pyhamtools.locator import locator_to_latlong, latlong_to_locator from core.constants import DXCC_FLAGS +from core.geo_utils import wab_wai_square_to_lat_lon from core.lookup_helper import lookup_helper -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig # Data class that defines a spot. @@ -20,7 +21,6 @@ class Spot: # Unique identifier for the spot id: str = None - # DX (spotted) operator info # Callsign of the operator that has been spotted @@ -51,12 +51,13 @@ class Spot: # lookup dx_latitude: float = None dx_longitude: float = None - # DX Location source. Indicates how accurate the location might be. Values: "SPOT", "QRZ, "DXCC", "NONE" + # DX Location source. Indicates how accurate the location might be. Values: "SPOT", "WAB/WAI GRID", "QRZ", "DXCC", "NONE" dx_location_source: str = "NONE" - # DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. + # DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is + # true if the location source is "SPOT" or "WAB/WAI GRID", or if the location source is "QRZ" and the DX callsign + # doesn't have a suffix like /P. dx_location_good: bool = False - # DE (Spotter) info # Callsign of the spotter @@ -77,7 +78,6 @@ class Spot: de_latitude: float = None de_longitude: float = None - # General QSO info # Reported mode, such as SSB, PHONE, CW, FT8... @@ -95,7 +95,6 @@ class Spot: # QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments. qrt: bool = False - # Special Interest Group info # Special Interest Group (SIG), e.g. outdoor activity programme such as POTA @@ -109,7 +108,6 @@ class Spot: # Activation score. SOTA only activation_score: int = None - # Display guidance (optional) # Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field @@ -120,7 +118,6 @@ class Spot: band_color: str = None band_contrast_color: str = None - # Timing info # Time of the spot, UTC seconds since UNIX epoch @@ -134,7 +131,6 @@ class Spot: # Time that this software received the spot, ISO 8601 received_time_iso: str = None - # Source info # Where we got the spot from, e.g. "POTA", "Cluster"... @@ -236,6 +232,19 @@ class Spot: if self.dx_latitude: self.dx_location_source = "SPOT" + # WAB/WAI grid to lat/lon + if not self.dx_latitude and self.sig and self.sig_refs and len(self.sig_refs) > 0 and ( + self.sig == "WAB" or self.sig == "WAI"): + ll = wab_wai_square_to_lat_lon(self.sig_refs[0]) + if ll: + self.dx_latitude = ll[0] + self.dx_longitude = ll[1] + try: + self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8) + except: + logging.debug("Invalid lat/lon received from WAB/WAI grid") + self.dx_location_source = "WAB/WAI GRID" + # QRT comment detection if self.comment and not self.qrt: self.qrt = "QRT" in self.comment.upper() @@ -272,7 +281,7 @@ class Spot: # DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator # is likely at home. - self.dx_location_good = self.dx_location_source == "SPOT" or ( + self.dx_location_good = self.dx_location_source == "SPOT" or self.dx_location_source == "WAB/WAI GRID" or ( self.dx_location_source == "QRZ" and not "/" in self.dx_call) # DE of "RBNHOLE", "SOTAMAT" and "ZLOTA" are not things we can look up location for diff --git a/requirements.txt b/requirements.txt index 71015ac..a1f3216 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ diskcache~=5.6.3 psutil~=7.1.0 requests-sse~=0.5.2 rss-parser~=2.1.1 +pyproj~=3.7.2 \ No newline at end of file diff --git a/spotproviders/dxcluster.py b/spotproviders/dxcluster.py index 1220579..a83dba6 100644 --- a/spotproviders/dxcluster.py +++ b/spotproviders/dxcluster.py @@ -8,7 +8,7 @@ import pytz import telnetlib3 from core.constants import SIGS -from core.utils import ANY_SIG_REGEX, ANY_XOTA_SIG_REF_REGEX, get_icon_for_sig, get_ref_regex_for_sig +from core.sig_utils import ANY_SIG_REGEX, ANY_XOTA_SIG_REF_REGEX, get_icon_for_sig, get_ref_regex_for_sig from data.spot import Spot from core.config import SERVER_OWNER_CALLSIGN from spotproviders.spot_provider import SpotProvider diff --git a/spotproviders/gma.py b/spotproviders/gma.py index 9209e5e..6cf1e5c 100644 --- a/spotproviders/gma.py +++ b/spotproviders/gma.py @@ -5,7 +5,7 @@ import pytz from requests_cache import CachedSession from core.constants import HTTP_HEADERS -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig from data.spot import Spot from spotproviders.http_spot_provider import HTTPSpotProvider diff --git a/spotproviders/hema.py b/spotproviders/hema.py index 9fbc5d3..0830554 100644 --- a/spotproviders/hema.py +++ b/spotproviders/hema.py @@ -5,7 +5,7 @@ import pytz import requests from core.constants import HTTP_HEADERS -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig from data.spot import Spot from spotproviders.http_spot_provider import HTTPSpotProvider diff --git a/spotproviders/parksnpeaks.py b/spotproviders/parksnpeaks.py index d30cb15..bb992f0 100644 --- a/spotproviders/parksnpeaks.py +++ b/spotproviders/parksnpeaks.py @@ -7,7 +7,7 @@ import pytz from requests_cache import CachedSession from core.constants import HTTP_HEADERS -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig from data.spot import Spot from spotproviders.http_spot_provider import HTTPSpotProvider diff --git a/spotproviders/pota.py b/spotproviders/pota.py index 76fccc2..cc37c1c 100644 --- a/spotproviders/pota.py +++ b/spotproviders/pota.py @@ -5,7 +5,7 @@ import pytz from requests_cache import CachedSession from core.constants import HTTP_HEADERS -from core.utils import get_icon_for_sig, get_ref_regex_for_sig +from core.sig_utils import get_icon_for_sig, get_ref_regex_for_sig from data.spot import Spot from spotproviders.http_spot_provider import HTTPSpotProvider diff --git a/spotproviders/sota.py b/spotproviders/sota.py index ff1bb91..c308fc6 100644 --- a/spotproviders/sota.py +++ b/spotproviders/sota.py @@ -5,7 +5,7 @@ import requests from requests_cache import CachedSession from core.constants import HTTP_HEADERS -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig from data.spot import Spot from spotproviders.http_spot_provider import HTTPSpotProvider diff --git a/spotproviders/wwbota.py b/spotproviders/wwbota.py index 4787084..1074141 100644 --- a/spotproviders/wwbota.py +++ b/spotproviders/wwbota.py @@ -1,7 +1,7 @@ import json from datetime import datetime -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig from data.spot import Spot from spotproviders.sse_spot_provider import SSESpotProvider diff --git a/spotproviders/wwff.py b/spotproviders/wwff.py index e06a545..879a31b 100644 --- a/spotproviders/wwff.py +++ b/spotproviders/wwff.py @@ -2,7 +2,7 @@ from datetime import datetime import pytz -from core.utils import get_icon_for_sig +from core.sig_utils import get_icon_for_sig from data.spot import Spot from spotproviders.http_spot_provider import HTTPSpotProvider diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index 54432c7..3e27ab7 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -551,13 +551,14 @@ components: description: Where we got the DX location (grid/latitude/longitude) from. If this was from the spot itself, it's likely quite accurate, but if we had to fall back to QRZ lookup, or even a location based on the DXCC itself, it will be a lot less accurate. enum: - SPOT + - "WAB/WAI GRID" - QRZ - DXCC - NONE example: SPOT dx_location_good: type: boolean - description: Does the software think the location is good enough to put a marker on a map? This is true if the source is "SPOT", or alternatively if the source is "QRZ" and the callsign doesn't have a slash in it (i.e. operator likely at home). + description: Does the software think the location is good enough to put a marker on a map? This is true if the source is "SPOT" or "WAB/WAI GRID", or alternatively if the source is "QRZ" and the callsign doesn't have a slash in it (i.e. operator likely at home). example: true de_call: type: string