aiodpc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aiodpc/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """Async client for the Italian Civil Protection Department (DPC) bulletins.
2
+
3
+ It reads the national "Bollettino di criticità idrogeologica e idraulica"
4
+ (alert levels per risk) and "Bollettino di vigilanza meteorologica" (weather
5
+ vigilance and phenomena) published by DPC as open data.
6
+ """
7
+
8
+ from .client import DpcClient
9
+ from .const import (
10
+ ATTRIBUTION,
11
+ CRITICALITY_PAGE_URL,
12
+ DEFAULT_PHENOMENA_RADIUS_KM,
13
+ DEFAULT_REQUEST_TIMEOUT,
14
+ ROME_TZ,
15
+ VIGILANCE_PAGE_URL,
16
+ )
17
+ from .discovery import BulletinIdSource, MapPageIdSource, TextFetcher
18
+ from .exceptions import DpcConnectionError, DpcError, DpcLocationError, DpcParseError
19
+ from .models import (
20
+ AlertLevel,
21
+ Assessment,
22
+ BulletinKind,
23
+ CriticalityBulletin,
24
+ CriticalityDay,
25
+ Phenomenon,
26
+ PhenomenonCategory,
27
+ Risk,
28
+ VigilanceBulletin,
29
+ VigilanceDay,
30
+ rome_date,
31
+ )
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ "ATTRIBUTION",
37
+ "CRITICALITY_PAGE_URL",
38
+ "DEFAULT_PHENOMENA_RADIUS_KM",
39
+ "DEFAULT_REQUEST_TIMEOUT",
40
+ "ROME_TZ",
41
+ "VIGILANCE_PAGE_URL",
42
+ "AlertLevel",
43
+ "Assessment",
44
+ "BulletinIdSource",
45
+ "BulletinKind",
46
+ "CriticalityBulletin",
47
+ "CriticalityDay",
48
+ "DpcClient",
49
+ "DpcConnectionError",
50
+ "DpcError",
51
+ "DpcLocationError",
52
+ "DpcParseError",
53
+ "MapPageIdSource",
54
+ "Phenomenon",
55
+ "PhenomenonCategory",
56
+ "Risk",
57
+ "TextFetcher",
58
+ "VigilanceBulletin",
59
+ "VigilanceDay",
60
+ "__version__",
61
+ "rome_date",
62
+ ]
aiodpc/client.py ADDED
@@ -0,0 +1,351 @@
1
+ """Async client of the DPC bulletins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import datetime as dt
7
+ import json
8
+ import logging
9
+ import math
10
+ from typing import TYPE_CHECKING, Any, Final, Self
11
+
12
+ import aiohttp
13
+
14
+ from .const import DEFAULT_PHENOMENA_RADIUS_KM, DEFAULT_REQUEST_TIMEOUT, ROME_TZ
15
+ from .discovery import BulletinIdSource, MapPageIdSource
16
+ from .exceptions import DpcConnectionError, DpcError, DpcParseError
17
+ from .models import (
18
+ BulletinKind,
19
+ CriticalityBulletin,
20
+ CriticalityDay,
21
+ VigilanceBulletin,
22
+ VigilanceDay,
23
+ )
24
+ from .parsers import (
25
+ day_expires,
26
+ location_in_zones,
27
+ parse_criticality_day,
28
+ parse_criticality_id,
29
+ parse_phenomena,
30
+ parse_vigilance_day,
31
+ parse_vigilance_id,
32
+ )
33
+ from .urls import (
34
+ CRITICALITY_DAYS,
35
+ VIGILANCE_DAYS,
36
+ criticality_geojson_url,
37
+ phenomena_geojson_url,
38
+ vigilance_geojson_url,
39
+ )
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import Coroutine, Iterable
43
+
44
+ _LOGGER = logging.getLogger(__name__)
45
+
46
+ _MAX_LATITUDE: Final = 90.0
47
+ _MAX_LONGITUDE: Final = 180.0
48
+
49
+
50
+ class DpcClient:
51
+ """Async client of the DPC criticality and weather vigilance bulletins.
52
+
53
+ Pass the ``aiohttp.ClientSession`` of the application (it is never closed
54
+ by the client), or use the client as an async context manager to let it
55
+ manage its own session::
56
+
57
+ async with DpcClient() as client:
58
+ bulletin = await client.get_criticality(41.9, 12.5)
59
+
60
+ The bulletin files are large (a few MB per day): to poll, compare
61
+ :meth:`latest_bulletin_id` with the ID of the bulletin already fetched and
62
+ fetch the bulletin again only when it changed.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ session: aiohttp.ClientSession | None = None,
68
+ *,
69
+ request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
70
+ id_source: BulletinIdSource | None = None,
71
+ ) -> None:
72
+ """Initialize the client.
73
+
74
+ ``request_timeout`` applies to each HTTP request, in seconds.
75
+ ``id_source`` finds the latest bulletin IDs (default: the DPC map pages).
76
+ """
77
+ self._session = session
78
+ self._close_session = False
79
+ self._request_timeout = request_timeout
80
+ self._id_source: BulletinIdSource = id_source or MapPageIdSource()
81
+
82
+ async def __aenter__(self) -> Self:
83
+ """Enter the async context."""
84
+ return self
85
+
86
+ async def __aexit__(self, *_exc_info: object) -> None:
87
+ """Exit the async context, closing the session owned by the client."""
88
+ await self.close()
89
+
90
+ async def close(self) -> None:
91
+ """Close the session if the client created it."""
92
+ if self._session is not None and self._close_session:
93
+ await self._session.close()
94
+ self._session = None
95
+ self._close_session = False
96
+
97
+ async def latest_bulletin_id(self, kind: BulletinKind) -> str:
98
+ """Return the ID of the latest published bulletin of a kind.
99
+
100
+ Raises :class:`DpcConnectionError` or :class:`DpcParseError`.
101
+ """
102
+ kind = BulletinKind(kind)
103
+ bulletin_id = await self._id_source.latest_id(kind, self._fetch_text)
104
+ try:
105
+ if kind is BulletinKind.CRITICALITY:
106
+ parse_criticality_id(bulletin_id)
107
+ else:
108
+ parse_vigilance_id(bulletin_id)
109
+ except ValueError as err:
110
+ msg = f"Invalid {kind} bulletin ID from the ID source: {bulletin_id!r}"
111
+ raise DpcParseError(msg) from err
112
+ _LOGGER.debug("Latest %s bulletin: %s", kind, bulletin_id)
113
+ return bulletin_id
114
+
115
+ async def get_criticality(
116
+ self,
117
+ latitude: float,
118
+ longitude: float,
119
+ *,
120
+ municipality: str | None = None,
121
+ bulletin_id: str | None = None,
122
+ skip_expired: bool = True,
123
+ now: dt.datetime | None = None,
124
+ ) -> CriticalityBulletin:
125
+ """Return the criticality bulletin for a location.
126
+
127
+ ``municipality`` (optional) selects, among the zones listing it, the one
128
+ with the highest alert. ``bulletin_id`` defaults to the latest bulletin.
129
+ With ``skip_expired``, days already over at ``now`` (default: the
130
+ current time) are not downloaded and are missing from ``days``.
131
+
132
+ Raises :class:`DpcConnectionError`, :class:`DpcParseError` or
133
+ :class:`DpcLocationError`; ``ValueError`` for invalid arguments.
134
+ """
135
+ _check_location(latitude, longitude)
136
+ if bulletin_id is None:
137
+ bulletin_id = await self.latest_bulletin_id(BulletinKind.CRITICALITY)
138
+ published = parse_criticality_id(bulletin_id)
139
+ offsets = _offsets(
140
+ published.date(),
141
+ len(CRITICALITY_DAYS),
142
+ skip_expired=skip_expired,
143
+ now=now,
144
+ )
145
+ days = await _gather(
146
+ self._criticality_day(
147
+ bulletin_id, offset, latitude, longitude, municipality
148
+ )
149
+ for offset in offsets
150
+ )
151
+ return CriticalityBulletin(
152
+ id=bulletin_id, published=published, days=tuple(days)
153
+ )
154
+
155
+ async def get_vigilance(
156
+ self,
157
+ latitude: float,
158
+ longitude: float,
159
+ *,
160
+ municipality: str | None = None,
161
+ phenomena_radius_km: float = DEFAULT_PHENOMENA_RADIUS_KM,
162
+ bulletin_id: str | None = None,
163
+ skip_expired: bool = True,
164
+ now: dt.datetime | None = None,
165
+ ) -> VigilanceBulletin:
166
+ """Return the weather vigilance bulletin for a location.
167
+
168
+ Each day includes the phenomena within ``phenomena_radius_km`` of the
169
+ coordinates. The other arguments are as in :meth:`get_criticality`.
170
+
171
+ Raises :class:`DpcConnectionError`, :class:`DpcParseError` or
172
+ :class:`DpcLocationError`; ``ValueError`` for invalid arguments.
173
+ """
174
+ _check_location(latitude, longitude)
175
+ if not (math.isfinite(phenomena_radius_km) and phenomena_radius_km >= 0):
176
+ msg = f"Invalid phenomena radius: {phenomena_radius_km}"
177
+ raise ValueError(msg)
178
+ if bulletin_id is None:
179
+ bulletin_id = await self.latest_bulletin_id(BulletinKind.VIGILANCE)
180
+ published = parse_vigilance_id(bulletin_id)
181
+ offsets = _offsets(
182
+ published, len(VIGILANCE_DAYS), skip_expired=skip_expired, now=now
183
+ )
184
+ days = await _gather(
185
+ self._vigilance_day(
186
+ bulletin_id,
187
+ offset,
188
+ latitude,
189
+ longitude,
190
+ municipality,
191
+ phenomena_radius_km,
192
+ )
193
+ for offset in offsets
194
+ )
195
+ return VigilanceBulletin(id=bulletin_id, date=published, days=tuple(days))
196
+
197
+ async def is_location_covered(self, latitude: float, longitude: float) -> bool:
198
+ """Return whether a location is inside a DPC alert zone.
199
+
200
+ The zones of the latest criticality bulletin are used, or those of the
201
+ vigilance bulletin when the former cannot be fetched.
202
+
203
+ Raises the first :class:`DpcError` when no bulletin could be used.
204
+ """
205
+ _check_location(latitude, longitude)
206
+ sources = (
207
+ (BulletinKind.CRITICALITY, criticality_geojson_url),
208
+ (BulletinKind.VIGILANCE, vigilance_geojson_url),
209
+ )
210
+ errors: list[DpcError] = []
211
+ for kind, geojson_url in sources:
212
+ try:
213
+ bulletin_id = await self.latest_bulletin_id(kind)
214
+ except DpcError as err:
215
+ errors.append(err)
216
+ continue
217
+ for offset in (0, 1):
218
+ try:
219
+ geojson = await self._fetch_json(geojson_url(bulletin_id, offset))
220
+ return location_in_zones(geojson, latitude, longitude)
221
+ except DpcError as err:
222
+ errors.append(err)
223
+ raise errors[0]
224
+
225
+ async def _criticality_day(
226
+ self,
227
+ bulletin_id: str,
228
+ offset: int,
229
+ latitude: float,
230
+ longitude: float,
231
+ municipality: str | None,
232
+ ) -> CriticalityDay:
233
+ geojson = await self._fetch_json(criticality_geojson_url(bulletin_id, offset))
234
+ return parse_criticality_day(
235
+ geojson,
236
+ bulletin_id=bulletin_id,
237
+ offset=offset,
238
+ latitude=latitude,
239
+ longitude=longitude,
240
+ municipality=municipality,
241
+ )
242
+
243
+ async def _vigilance_day(
244
+ self,
245
+ bulletin_id: str,
246
+ offset: int,
247
+ latitude: float,
248
+ longitude: float,
249
+ municipality: str | None,
250
+ phenomena_radius_km: float,
251
+ ) -> VigilanceDay:
252
+ zones, phenomena = await _gather(
253
+ self._fetch_json(url(bulletin_id, offset))
254
+ for url in (vigilance_geojson_url, phenomena_geojson_url)
255
+ )
256
+ return parse_vigilance_day(
257
+ zones,
258
+ bulletin_id=bulletin_id,
259
+ offset=offset,
260
+ latitude=latitude,
261
+ longitude=longitude,
262
+ municipality=municipality,
263
+ phenomena=parse_phenomena(
264
+ phenomena,
265
+ latitude=latitude,
266
+ longitude=longitude,
267
+ radius_km=phenomena_radius_km,
268
+ ),
269
+ )
270
+
271
+ def _get_session(self) -> aiohttp.ClientSession:
272
+ if self._session is None:
273
+ self._session = aiohttp.ClientSession()
274
+ self._close_session = True
275
+ return self._session
276
+
277
+ async def _request(self, url: str) -> bytes:
278
+ """Return the body at a URL, mapping every failure to DpcConnectionError."""
279
+ session = self._get_session()
280
+ _LOGGER.debug("Fetching %s", url)
281
+ try:
282
+ async with (
283
+ asyncio.timeout(self._request_timeout),
284
+ session.get(url) as response,
285
+ ):
286
+ response.raise_for_status()
287
+ return await response.read()
288
+ except TimeoutError as err:
289
+ msg = f"Timeout fetching {url}"
290
+ raise DpcConnectionError(msg) from err
291
+ except aiohttp.ClientResponseError as err:
292
+ msg = f"HTTP status {err.status} fetching {url}"
293
+ raise DpcConnectionError(msg) from err
294
+ except (aiohttp.ClientError, OSError) as err:
295
+ msg = f"Error fetching {url}: {err!r}"
296
+ raise DpcConnectionError(msg) from err
297
+
298
+ async def _fetch_text(self, url: str) -> str:
299
+ return (await self._request(url)).decode("utf-8", errors="replace")
300
+
301
+ async def _fetch_json(self, url: str) -> object:
302
+ body = await self._request(url)
303
+ try:
304
+ data: object = json.loads(body)
305
+ except ValueError as err:
306
+ msg = f"Invalid JSON from {url}"
307
+ raise DpcParseError(msg) from err
308
+ return data
309
+
310
+
311
+ def _check_location(latitude: float, longitude: float) -> None:
312
+ if not (math.isfinite(latitude) and -_MAX_LATITUDE <= latitude <= _MAX_LATITUDE):
313
+ msg = f"Invalid latitude: {latitude}"
314
+ raise ValueError(msg)
315
+ if not (
316
+ math.isfinite(longitude) and -_MAX_LONGITUDE <= longitude <= _MAX_LONGITUDE
317
+ ):
318
+ msg = f"Invalid longitude: {longitude}"
319
+ raise ValueError(msg)
320
+
321
+
322
+ def _offsets(
323
+ first_day: dt.date, count: int, *, skip_expired: bool, now: dt.datetime | None
324
+ ) -> list[int]:
325
+ """Return the day offsets to fetch, without the expired days if asked."""
326
+ if not skip_expired:
327
+ return list(range(count))
328
+ if now is None:
329
+ now = dt.datetime.now(ROME_TZ)
330
+ elif now.tzinfo is None:
331
+ now = now.replace(tzinfo=ROME_TZ)
332
+ return [
333
+ offset
334
+ for offset in range(count)
335
+ if day_expires(first_day + dt.timedelta(days=offset)) > now
336
+ ]
337
+
338
+
339
+ async def _gather[T](coros: Iterable[Coroutine[Any, Any, T]]) -> list[T]:
340
+ """Run coroutines concurrently and return their results in order.
341
+
342
+ On the first failure the other coroutines are cancelled and that exception
343
+ is raised as is (not wrapped in an ExceptionGroup). Cancellation propagates.
344
+ """
345
+ try:
346
+ async with asyncio.TaskGroup() as group:
347
+ tasks = [group.create_task(coro) for coro in coros]
348
+ except ExceptionGroup as err:
349
+ error = err.exceptions[0]
350
+ raise error from error.__cause__
351
+ return [task.result() for task in tasks]
aiodpc/const.py ADDED
@@ -0,0 +1,42 @@
1
+ """Constants of the DPC open data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+ from zoneinfo import ZoneInfo
7
+
8
+ ROME_TZ: Final = ZoneInfo("Europe/Rome")
9
+ """Time zone of the bulletins: their days are Italian calendar days."""
10
+
11
+ ATTRIBUTION: Final = (
12
+ "Dipartimento della Protezione Civile – Presidenza del Consiglio dei Ministri" # noqa: RUF001
13
+ )
14
+ """Attribution required by the CC BY 4.0 license of the DPC data."""
15
+
16
+ CRITICALITY_PAGE_URL: Final = (
17
+ "https://mappe.protezionecivile.gov.it/it/mappe-rischi/bollettino-di-criticita/"
18
+ )
19
+ """Map page of the criticality bulletin ("Bollettino di criticità")."""
20
+
21
+ VIGILANCE_PAGE_URL: Final = (
22
+ "https://mappe.protezionecivile.gov.it/it/mappe-rischi/bollettino-di-vigilanza/"
23
+ )
24
+ """Map page of the weather vigilance bulletin ("Bollettino di vigilanza")."""
25
+
26
+ CRITICALITY_FILES_URL: Final = (
27
+ "https://raw.githubusercontent.com/pcm-dpc/"
28
+ "DPC-Bollettini-Criticita-Idrogeologica-Idraulica/master/files"
29
+ )
30
+ """Base URL of the criticality bulletin files published by DPC on GitHub."""
31
+
32
+ VIGILANCE_FILES_URL: Final = (
33
+ "https://raw.githubusercontent.com/pcm-dpc/"
34
+ "DPC-Bollettini-Vigilanza-Meteorologica/master/files"
35
+ )
36
+ """Base URL of the vigilance bulletin files published by DPC on GitHub."""
37
+
38
+ DEFAULT_REQUEST_TIMEOUT: Final = 30.0
39
+ """Default timeout of each HTTP request, in seconds."""
40
+
41
+ DEFAULT_PHENOMENA_RADIUS_KM: Final = 50.0
42
+ """Default radius around the location to report weather phenomena, in km."""
aiodpc/discovery.py ADDED
@@ -0,0 +1,87 @@
1
+ """Discovery of the ID of the latest bulletins.
2
+
3
+ The ID is what names the bulletin files, so it must be found before anything
4
+ else. The default :class:`MapPageIdSource` reads it from the DPC map pages
5
+ with a regular expression. Another source (for example the GitHub contents
6
+ API, which is rate-limited to 60 requests per hour without a token) can be
7
+ plugged into :class:`aiodpc.DpcClient` by implementing
8
+ :class:`BulletinIdSource`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections.abc import Awaitable, Callable, Mapping
15
+ from typing import Final, Protocol
16
+
17
+ from .const import CRITICALITY_PAGE_URL, VIGILANCE_PAGE_URL
18
+ from .exceptions import DpcParseError
19
+ from .models import BulletinKind
20
+ from .parsers import parse_criticality_id, parse_vigilance_id
21
+
22
+ type TextFetcher = Callable[[str], Awaitable[str]]
23
+ """Coroutine function returning the text at a URL.
24
+
25
+ It raises :class:`aiodpc.DpcConnectionError` when the URL cannot be fetched.
26
+ """
27
+
28
+ # 20261114_1430 (criticality) and 20261114.json (vigilance), as linked by the
29
+ # map pages; the lookarounds keep them from matching inside longer numbers.
30
+ _ID_PATTERNS: Final = {
31
+ BulletinKind.CRITICALITY: re.compile(r"(?<!\d)(\d{8}_\d{4})(?!\d)"),
32
+ BulletinKind.VIGILANCE: re.compile(r"(?<!\d)(\d{8})\.json", re.IGNORECASE),
33
+ }
34
+ _ID_PARSERS: Final[Mapping[BulletinKind, Callable[[str], object]]] = {
35
+ BulletinKind.CRITICALITY: parse_criticality_id,
36
+ BulletinKind.VIGILANCE: parse_vigilance_id,
37
+ }
38
+
39
+
40
+ def extract_bulletin_id(kind: BulletinKind, text: str) -> str:
41
+ """Return the latest bulletin ID of a kind found in a text (a DPC map page).
42
+
43
+ Matches that are not valid dates are ignored. When the text links more than
44
+ one bulletin (e.g. an "Aggiornamento" re-issue), the latest one is returned.
45
+
46
+ Raises :class:`aiodpc.DpcParseError` when the text contains no valid ID.
47
+ """
48
+ ids: set[str] = set()
49
+ for bulletin_id in _ID_PATTERNS[kind].findall(text):
50
+ try:
51
+ _ID_PARSERS[kind](bulletin_id)
52
+ except ValueError:
53
+ continue
54
+ ids.add(bulletin_id)
55
+ if not ids:
56
+ msg = f"No {kind} bulletin ID found in the DPC page"
57
+ raise DpcParseError(msg)
58
+ # The fixed-width IDs sort chronologically.
59
+ return max(ids)
60
+
61
+
62
+ class BulletinIdSource(Protocol):
63
+ """Strategy that finds the ID of the latest bulletin of a kind."""
64
+
65
+ async def latest_id(self, kind: BulletinKind, fetch_text: TextFetcher) -> str:
66
+ """Return the ID of the latest bulletin of ``kind``.
67
+
68
+ ``fetch_text`` performs the HTTP requests with the client's session and
69
+ timeout. Raise :class:`aiodpc.DpcConnectionError` or
70
+ :class:`aiodpc.DpcParseError` when the ID cannot be found.
71
+ """
72
+ ...
73
+
74
+
75
+ class MapPageIdSource:
76
+ """Find the latest bulletin IDs in the DPC map pages (the default source)."""
77
+
78
+ def __init__(self, page_urls: Mapping[BulletinKind, str] | None = None) -> None:
79
+ """Initialize the source, optionally with other page URLs."""
80
+ self._page_urls: Mapping[BulletinKind, str] = page_urls or {
81
+ BulletinKind.CRITICALITY: CRITICALITY_PAGE_URL,
82
+ BulletinKind.VIGILANCE: VIGILANCE_PAGE_URL,
83
+ }
84
+
85
+ async def latest_id(self, kind: BulletinKind, fetch_text: TextFetcher) -> str:
86
+ """Return the ID of the latest bulletin of ``kind`` linked by its page."""
87
+ return extract_bulletin_id(kind, await fetch_text(self._page_urls[kind]))
aiodpc/exceptions.py ADDED
@@ -0,0 +1,23 @@
1
+ """Exceptions raised by aiodpc."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class DpcError(Exception):
7
+ """Base class of the aiodpc errors."""
8
+
9
+
10
+ class DpcConnectionError(DpcError):
11
+ """DPC could not be reached: network error, HTTP error status or timeout."""
12
+
13
+
14
+ class DpcParseError(DpcError):
15
+ """DPC returned data that is not valid JSON or has an unexpected format."""
16
+
17
+
18
+ class DpcLocationError(DpcError):
19
+ """The location is not inside any alert zone of the bulletin.
20
+
21
+ This is permanent for the given coordinates (for example a location outside
22
+ Italy or at sea), so retrying does not help.
23
+ """
aiodpc/geo.py ADDED
@@ -0,0 +1,76 @@
1
+ """Geometry helpers on (latitude, longitude) points and GeoJSON geometries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import TYPE_CHECKING, Any, Final
7
+
8
+ from geojson_utils import point_distance, point_in_polygon
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
13
+ COMPASS_POINTS: Final = (
14
+ "N",
15
+ "NNE",
16
+ "NE",
17
+ "ENE",
18
+ "E",
19
+ "ESE",
20
+ "SE",
21
+ "SSE",
22
+ "S",
23
+ "SSW",
24
+ "SW",
25
+ "WSW",
26
+ "W",
27
+ "WNW",
28
+ "NW",
29
+ "NNW",
30
+ )
31
+
32
+ _AREA_TYPES: Final = frozenset({"Polygon", "MultiPolygon"})
33
+
34
+
35
+ def geojson_point(latitude: float, longitude: float) -> dict[str, Any]:
36
+ """Return a GeoJSON Point (GeoJSON orders coordinates as longitude, latitude)."""
37
+ return {"type": "Point", "coordinates": [longitude, latitude]}
38
+
39
+
40
+ def point_in_area(
41
+ latitude: float, longitude: float, geometry: Mapping[str, Any]
42
+ ) -> bool:
43
+ """Return whether a point is inside a Polygon or MultiPolygon geometry.
44
+
45
+ Points on the boundary count as inside; other geometry types never
46
+ contain a point.
47
+ """
48
+ if geometry.get("type") not in _AREA_TYPES:
49
+ return False
50
+ return bool(point_in_polygon(geojson_point(latitude, longitude), dict(geometry)))
51
+
52
+
53
+ def distance_km(point_a: tuple[float, float], point_b: tuple[float, float]) -> float:
54
+ """Return the great-circle distance in km between two (lat, lon) points."""
55
+ meters = point_distance(geojson_point(*point_a), geojson_point(*point_b))
56
+ return float(meters) / 1000
57
+
58
+
59
+ def calculate_initial_compass_bearing(
60
+ point_a: tuple[float, float], point_b: tuple[float, float]
61
+ ) -> tuple[str, int]:
62
+ """Return the compass point and degrees from point_a to point_b (lat, lon).
63
+
64
+ Credits: https://gist.github.com/jeromer/2005586
65
+ and https://gist.github.com/RobertSudwarts/acf8df23a16afdb5837f
66
+ """
67
+ lat1 = math.radians(point_a[0])
68
+ lat2 = math.radians(point_b[0])
69
+ diff_long = math.radians(point_b[1] - point_a[1])
70
+ x = math.sin(diff_long) * math.cos(lat2)
71
+ y = math.cos(lat1) * math.sin(lat2) - (
72
+ math.sin(lat1) * math.cos(lat2) * math.cos(diff_long)
73
+ )
74
+ compass_bearing = (math.degrees(math.atan2(x, y)) + 360) % 360
75
+ index = round(compass_bearing / (360.0 / len(COMPASS_POINTS)))
76
+ return COMPASS_POINTS[index % len(COMPASS_POINTS)], math.floor(compass_bearing)