diff --git a/src/layup/data/ObsCodes.json.gz b/src/layup/data/ObsCodes.json.gz new file mode 100644 index 00000000..88ba7098 Binary files /dev/null and b/src/layup/data/ObsCodes.json.gz differ diff --git a/src/layup/utilities/data_processing_utilities.py b/src/layup/utilities/data_processing_utilities.py index f55128bd..0014a308 100644 --- a/src/layup/utilities/data_processing_utilities.py +++ b/src/layup/utilities/data_processing_utilities.py @@ -1,7 +1,12 @@ +import gzip import logging +import os +import tempfile from concurrent.futures import ProcessPoolExecutor +from importlib.resources import files import numpy as np +import requests from sorcha.ephemeris.simulation_geometry import barycentricObservatoryRates from sorcha.ephemeris.simulation_parsing import Observatory as SorchaObservatory from sorcha.ephemeris.simulation_setup import furnish_spiceypy @@ -17,6 +22,25 @@ logger = logging.getLogger(__name__) +def write_fallback_obscodes(): + """Decompress the observatory-codes file bundled with layup to a plain JSON + file and return its path. + + The MPC observatory-codes file is normally downloaded from + minorplanetcenter.net on first use. When that download fails (e.g. the MPC + server is unreachable, as has happened on CI runners), we fall back to the + copy shipped in ``layup/data/ObsCodes.json.gz`` instead of failing the run. + Observatory codes change rarely, so a slightly stale fallback is far better + than a hard failure. Returns a path suitable for ``Observatory``'s + ``oc_file`` argument (which reads the decompressed JSON directly). + """ + compressed = files("layup.data").joinpath("ObsCodes.json.gz").read_bytes() + dest = os.path.join(tempfile.gettempdir(), "layup_obscodes_fallback.json") + with open(dest, "wb") as f: + f.write(gzip.decompress(compressed)) + return dest + + def process_data(data, n_workers, func, **kwargs): """ Process a structured numpy array in parallel for a given function and keyword arguments @@ -315,7 +339,22 @@ def __init__(self, cache_dir=None): # Furnish the spiceypy kernels layup_furnish_spiceypy(cache_dir) - super().__init__(FakeSorchaArgs(cache_dir), config.auxiliary) + try: + super().__init__(FakeSorchaArgs(cache_dir), config.auxiliary) + except requests.exceptions.RequestException as exc: + # The observatory-codes download from the MPC failed (server + # unreachable, timeout, etc.). Fall back to the copy bundled with + # layup so we don't fail the run over a transient network outage. + logger.warning( + "Could not fetch observatory codes from the MPC (%s); " + "falling back to the copy bundled with layup.", + exc, + ) + super().__init__( + FakeSorchaArgs(cache_dir), + config.auxiliary, + oc_file=write_fallback_obscodes(), + ) # A cache of barycentric positions for observatories of the form {obscode: {et: (x, y, z)}} self.cached_obs = {} diff --git a/tests/layup/test_data_processing_utilities.py b/tests/layup/test_data_processing_utilities.py index 00078e32..7a363610 100644 --- a/tests/layup/test_data_processing_utilities.py +++ b/tests/layup/test_data_processing_utilities.py @@ -11,6 +11,7 @@ has_cov_columns, parse_fit_result, process_data, + write_fallback_obscodes, ) from layup.utilities.data_utilities_for_tests import get_test_filepath from layup.utilities.file_io.CSVReader import CSVDataReader @@ -560,3 +561,43 @@ def test_parse_fit_result_missing_some_cov_columns(): assert len(fit_result.cov) == 36 assert np.all(np.array(fit_result.cov[:-1]) != 0.0) assert fit_result.cov[-1] == 0.0 + + +def test_write_fallback_obscodes(): + """The obscodes copy bundled with layup decompresses to valid JSON that + sorcha's Observatory can read directly (network-outage fallback).""" + import json + + path = write_fallback_obscodes() + with open(path) as f: + obs = json.load(f) + assert len(obs) > 2000 + assert "X05" in obs and "500" in obs # Rubin + geocenter + + +def test_layup_observatory_falls_back_on_mpc_failure(monkeypatch): + """If the MPC obscodes download fails (e.g. server unreachable), the + LayupObservatory should fall back to the bundled copy instead of raising.""" + import requests + + import layup.utilities.data_processing_utilities as dpu + + orig_init = dpu.SorchaObservatory.__init__ + calls = [] + + def fake_init(self, args, auxconfigs, oc_file=None): + calls.append(oc_file) + if oc_file is None: + # Simulate the MPC download failing. + raise requests.exceptions.ConnectTimeout("simulated MPC outage") + # The fallback path supplies a local oc_file; let the real init read it. + orig_init(self, args, auxconfigs, oc_file=oc_file) + + monkeypatch.setattr(dpu.SorchaObservatory, "__init__", fake_init) + + observatory = LayupObservatory() + + # First attempt (download) raised; the second used the bundled fallback. + assert calls[0] is None and calls[1] is not None + assert "X05" in observatory.ObservatoryXYZ + assert len(observatory.ObservatoryXYZ) > 2000