Skip to content

Commit 8706cfd

Browse files
Merge pull request #223 from michealroberts/feature/iers/fetch_iers_rapid_service_data
feat: add cache strategy to fetch_iers_rapid_service_data in celerity module
2 parents dc03f4c + a7a945e commit 8706cfd

File tree

5 files changed

+130
-18
lines changed

5 files changed

+130
-18
lines changed

examples/iers.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,23 @@
66

77
# **************************************************************************************
88

9-
from celerity.iers import (
10-
IERS_DUT1_URL,
11-
fetch_iers_rapid_service_data,
12-
)
9+
from datetime import datetime, timezone
10+
11+
from celerity.temporal import get_ut1_utc_offset
1312

1413
# **************************************************************************************
1514

1615
if __name__ == "__main__":
1716
try:
18-
# Fetch the latest IERS Rapid Service data
19-
data = fetch_iers_rapid_service_data(url=IERS_DUT1_URL)
20-
print("IERS Rapid Service Data fetched successfully.")
21-
print(data)
17+
now = datetime.now(timezone.utc)
18+
# Fetch the IERS Rapid Service data again to demonstrate caching:
19+
dut1 = get_ut1_utc_offset(now)
20+
print(f"UT1-UTC offset at {now.isoformat()}: {dut1} seconds")
21+
22+
# This should hit the cache:
23+
dut1 = get_ut1_utc_offset(now)
24+
print(f"UT1-UTC offset at {now.isoformat()}: {dut1} seconds")
2225
except Exception as e:
23-
print(f"An error occurred while fetching IERS data: {e}")
26+
print(f"An error occurred while getting UT1-UTC offset: {e}")
2427

2528
# **************************************************************************************

src/celerity/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
Python library for astronomical calculations.
1212
"""
1313

14+
# **************************************************************************************
15+
1416
__version__ = "0.40.0"
1517

1618
# **************************************************************************************

src/celerity/iers.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,46 @@
66

77
# **************************************************************************************
88

9+
from __future__ import annotations
10+
11+
from dataclasses import dataclass
912
from json import loads
10-
from typing import TypedDict
13+
from threading import Lock
14+
from time import monotonic
15+
from typing import Dict, TypedDict
1116
from urllib.request import Request, urlopen
1217

1318
# **************************************************************************************
1419

20+
URL_OPEN_TIMEOUT_SECONDS = 10
21+
22+
# **************************************************************************************
23+
24+
IERS_EOP_BASE_URL = "https://datacenter.iers.org/webservice/REST/eop/RestController.php"
25+
26+
# **************************************************************************************
27+
28+
29+
@dataclass
30+
class IERSCache:
31+
at: float
32+
entry: DUT1Entry
33+
34+
35+
# **************************************************************************************
36+
37+
_iers_cache_lock = Lock()
38+
39+
# **************************************************************************************
40+
41+
_iers_cache: Dict[str, IERSCache] = {}
42+
43+
# **************************************************************************************
44+
45+
MAX_CACHE_AGE_SECONDS = 6 * 60 * 60 # 6 hours
46+
47+
# **************************************************************************************
48+
1549

1650
class DUT1Entry(TypedDict):
1751
# The Modified Julian Date (MJD) of the DUT1 entry:
@@ -22,21 +56,26 @@ class DUT1Entry(TypedDict):
2256

2357
# **************************************************************************************
2458

25-
IERS_DUT1_URL = "https://datacenter.iers.org/webservice/REST/eop/RestController.php"
2659

27-
# **************************************************************************************
60+
def fetch_iers_rapid_service_data(url: str) -> DUT1Entry:
61+
now = monotonic()
2862

63+
with _iers_cache_lock:
64+
if url in _iers_cache and now - _iers_cache[url].at < MAX_CACHE_AGE_SECONDS:
65+
return _iers_cache[url].entry
2966

30-
def fetch_iers_rapid_service_data(url: str) -> DUT1Entry:
3167
# Ensure we always expect to accept JSON responses, whilst also letting the server
3268
# know that we are a client (e.g., celerity) to avoid any potential issues with
3369
# server-side rate limiting or blocking:
3470
request = Request(
3571
url,
36-
headers={"Accept": "application/json", "User-Agent": "celerity"},
72+
headers={
73+
"Accept": "application/json",
74+
"User-Agent": "celerity",
75+
},
3776
)
3877

39-
with urlopen(request) as response:
78+
with urlopen(request, timeout=URL_OPEN_TIMEOUT_SECONDS) as response:
4079
# Assume UTF-8 or ASCII text in the response:
4180
raw = response.read().decode("utf-8", errors="ignore")
4281

@@ -59,10 +98,18 @@ def fetch_iers_rapid_service_data(url: str) -> DUT1Entry:
5998
if data["MJD"] is None:
6099
raise ValueError("MJD is None, no valid data found.")
61100

62-
return DUT1Entry(
101+
entry = DUT1Entry(
63102
mjd=data["MJD"],
64103
dut1=float(data["Value"]) * 0.001,
65104
)
66105

106+
with _iers_cache_lock:
107+
_iers_cache[url] = IERSCache(
108+
at=now,
109+
entry=entry,
110+
)
111+
112+
return entry
113+
67114

68115
# **************************************************************************************

src/celerity/temporal.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from .common import GeographicCoordinate
1515
from .constants import J1900, J2000, JULIAN_DAYS_PER_CENTURY
16-
from .iers import IERS_DUT1_URL, fetch_iers_rapid_service_data
16+
from .iers import IERS_EOP_BASE_URL, fetch_iers_rapid_service_data
1717
from .tai import get_tai_utc_offset
1818

1919
# **************************************************************************************
@@ -350,7 +350,7 @@ def get_ut1_utc_offset(when: datetime) -> float:
350350

351351
# Construct the URL for the IERS Rapid Service data with the UT1-UTC, mjd and series
352352
# parameters set:
353-
url = f"{IERS_DUT1_URL}?{urlencode(q, safe=' ')}".replace("+", "%20")
353+
url = f"{IERS_EOP_BASE_URL}?{urlencode(q, safe=' ')}".replace("+", "%20")
354354

355355
# Fetch the DUT1 entry from the IERS Rapid Service data:
356356
entry = fetch_iers_rapid_service_data(url)

tests/test_iers.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
import unittest
1010
from datetime import datetime, timedelta, timezone
1111

12+
from celerity.iers import (
13+
IERS_EOP_BASE_URL,
14+
MAX_CACHE_AGE_SECONDS,
15+
_iers_cache,
16+
)
1217
from celerity.temporal import get_ut1_utc_offset
1318

1419
# **************************************************************************************
@@ -48,5 +53,60 @@ def test_get_ut1_utc_offset_invalid_date(self) -> None:
4853
with self.assertRaises(ValueError):
4954
get_ut1_utc_offset(datetime(1972, 1, 1, 0, 0, 0, tzinfo=timezone.utc))
5055

56+
def test_get_ut1_utc_offset_cache_hit(self) -> None:
57+
# Ensure we hit the cache on subsequent calls:
58+
_iers_cache.clear()
59+
60+
when = datetime(2023, 5, 15, 0, 0, 0, tzinfo=timezone.utc)
61+
62+
first = get_ut1_utc_offset(when)
63+
64+
(url, cache) = next(iter(_iers_cache.items()))
65+
66+
self.assertEqual(len(_iers_cache), 1)
67+
68+
self.assertTrue(url.startswith(IERS_EOP_BASE_URL))
69+
self.assertIn("param=UT1-UTC", url)
70+
self.assertIn("mjd=", url)
71+
self.assertIn("series=", url)
72+
73+
first_cached_at = cache.at
74+
first_cached_dut1 = float(cache.entry["dut1"])
75+
76+
second = get_ut1_utc_offset(when)
77+
self.assertEqual(len(_iers_cache), 1)
78+
self.assertIn(url, _iers_cache)
79+
80+
second_cached_at = _iers_cache[url].at
81+
second_cached_dut1 = float(_iers_cache[url].entry["dut1"])
82+
self.assertEqual(first_cached_at, second_cached_at)
83+
self.assertAlmostEqual(first, second, places=12)
84+
self.assertAlmostEqual(first, first_cached_dut1, places=12)
85+
self.assertAlmostEqual(second, second_cached_dut1, places=12)
86+
87+
def test_get_ut1_utc_offset_cache_expiry(self) -> None:
88+
_iers_cache.clear()
89+
90+
when = datetime(2023, 5, 15, 0, 0, 0, tzinfo=timezone.utc)
91+
92+
first = get_ut1_utc_offset(when)
93+
self.assertEqual(len(_iers_cache), 1)
94+
95+
(cached_url, cached_record) = next(iter(_iers_cache.items()))
96+
original_at = cached_record.at
97+
98+
# Force-expire the cache entry by moving its timestamp into the past:
99+
cached_record.at = original_at - (MAX_CACHE_AGE_SECONDS + 1)
100+
101+
second = get_ut1_utc_offset(when)
102+
103+
self.assertEqual(len(_iers_cache), 1)
104+
self.assertIn(cached_url, _iers_cache)
105+
self.assertGreater(_iers_cache[cached_url].at, original_at)
106+
107+
# DUT1 should remain reasonable/close; we cannot guarantee identical because IERS
108+
# can revise recent values, but for a historical date it should normally match:
109+
self.assertAlmostEqual(first, second, places=6)
110+
51111

52112
# **************************************************************************************

0 commit comments

Comments
 (0)