Skip to content

Commit aa61a4e

Browse files
committed
Improve downloader reliability
1 parent cfa1d61 commit aa61a4e

8 files changed

Lines changed: 278 additions & 78 deletions

File tree

.github/workflows/tests.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-python@v5
13+
with:
14+
python-version: "3.11"
15+
- name: Install dependencies
16+
run: python -m pip install -r requirements.txt
17+
- name: Run offline tests
18+
run: python -m unittest discover -s tests -v

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,7 @@ img_ix*_annoted_*.jpg
139139
img_ix*_annoted_*.fits
140140
img_ix*_annoted_*_raw.fits
141141
ls_dr10_*.csv
142+
__pycache__/
143+
*.py[cod]
144+
.pytest_cache/
145+
.venv/

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ A collection of Python scripts and notebooks for retrieving astronomical imaging
1010
* [3. DECaLS Image Downloader (`decal_image_download.py`)](#3-decals-image-downloader-decal_image_downloadpy)
1111
* [4. SDSS Catalog Downloader (`download_SDSS_catalog_withSciServer.ipynb`)](#4-sdss-catalog-downloader-download_sdss_catalog_withsciserveripynb)
1212

13+
## Setup
14+
15+
Use Python 3.11 in a dedicated environment, especially for the DESI workflow:
16+
17+
```bash
18+
python3.11 -m venv .venv
19+
source .venv/bin/activate
20+
python -m pip install --upgrade pip
21+
python -m pip install -r requirements.txt
22+
```
23+
24+
The scripts validate coordinates and retry remote requests where supported, but survey services can still be temporarily unavailable. Outputs are intentionally ignored by Git so that large downloads do not enter the repository.
25+
1326
---
1427

1528
## 1. LS DR10 Photometry Downloader (`ls_dr10_catalog_download.py`)

decal_image_download.py

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
import csv
88
import math
99
import re
10+
import shutil
1011
import subprocess
1112
import sys
1213
import tempfile
14+
import time
1315
import urllib.error
1416
import urllib.request
1517
from dataclasses import dataclass
@@ -24,10 +26,12 @@
2426

2527
PIXEL_SCALE_ARCSEC = 0.262
2628
BASE_IMAGE_SIZE = 2048
29+
JPEG_EXPORT_SIZE_PX = 1200
2730
SURVEY_LAYER = "ls-dr9"
2831
DEFAULT_BRIGHTEST_COUNT = 5
2932
DEFAULT_AUTO_DESI_RADIUS_DEG = 0.02
3033
DEFAULT_AUTO_LS_RADIUS_DEG = 0.0166667
34+
DOWNLOAD_RETRY_ATTEMPTS = 3
3135
AUTO_INPUT_TOKEN = "__auto__"
3236
DESI_OBJECT_CATALOG_FILENAME = "object_catalog.csv"
3337
cosmo = FlatLambdaCDM(H0=70, Om0=0.3)
@@ -46,11 +50,35 @@ class HelperRunResult:
4650
def positive_float(value: str) -> float:
4751
"""Parse a positive float for argparse."""
4852
parsed = float(value)
49-
if parsed <= 0:
53+
if not math.isfinite(parsed) or parsed <= 0:
5054
raise argparse.ArgumentTypeError("value must be greater than 0")
5155
return parsed
5256

5357

58+
def ra_degrees(value: str) -> float:
59+
"""Parse and normalize a right ascension value in degrees."""
60+
parsed = float(value)
61+
if not math.isfinite(parsed):
62+
raise argparse.ArgumentTypeError("right ascension must be finite")
63+
return parsed % 360.0
64+
65+
66+
def dec_degrees(value: str) -> float:
67+
"""Parse a declination value in degrees."""
68+
parsed = float(value)
69+
if not math.isfinite(parsed) or not -90 <= parsed <= 90:
70+
raise argparse.ArgumentTypeError("declination must be between -90 and 90 degrees")
71+
return parsed
72+
73+
74+
def nonnegative_float(value: str) -> float:
75+
"""Parse a finite, non-negative float for redshift."""
76+
parsed = float(value)
77+
if not math.isfinite(parsed) or parsed < 0:
78+
raise argparse.ArgumentTypeError("value must be zero or greater")
79+
return parsed
80+
81+
5482
def sanitize_name(value: str) -> str:
5583
"""Make a user-provided label safe for filenames."""
5684
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip())
@@ -229,9 +257,27 @@ def ensure_overlay_inputs(
229257

230258

231259
def download_file(url: str, destination: Path) -> None:
232-
"""Download a remote file to disk."""
260+
"""Download a remote file atomically, with a bounded network timeout."""
233261
print(f"Downloading: {url}")
234-
urllib.request.urlretrieve(url, destination)
262+
destination.parent.mkdir(parents=True, exist_ok=True)
263+
with tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as handle:
264+
temporary_path = Path(handle.name)
265+
try:
266+
request = urllib.request.Request(url, headers={"User-Agent": "Astro-Scripts/1.0"})
267+
for attempt in range(1, DOWNLOAD_RETRY_ATTEMPTS + 1):
268+
try:
269+
with urllib.request.urlopen(request, timeout=60) as response, temporary_path.open("wb") as handle:
270+
shutil.copyfileobj(response, handle)
271+
temporary_path.replace(destination)
272+
return
273+
except (urllib.error.URLError, OSError, TimeoutError):
274+
if attempt == DOWNLOAD_RETRY_ATTEMPTS:
275+
raise
276+
print(f"Download failed on attempt {attempt}/{DOWNLOAD_RETRY_ATTEMPTS}; retrying...")
277+
time.sleep(attempt)
278+
finally:
279+
if temporary_path.exists():
280+
temporary_path.unlink()
235281

236282

237283
def compute_physical_scale_kpc(redshift: float) -> float | None:
@@ -491,8 +537,12 @@ def create_annotated_jpeg(
491537
image = mpimg.imread(temp_image_path)
492538
image_size = image.shape[0]
493539

494-
figure = plt.figure(figsize=(10, 10))
495-
plt.imshow(image)
540+
figure = plt.figure(
541+
figsize=(JPEG_EXPORT_SIZE_PX / 120, JPEG_EXPORT_SIZE_PX / 120),
542+
dpi=120,
543+
)
544+
axis = figure.add_axes([0, 0, 1, 1])
545+
axis.imshow(image)
496546

497547
if desi_csv is not None:
498548
catalog_rows = read_object_catalog(desi_csv)
@@ -516,7 +566,6 @@ def create_annotated_jpeg(
516566
draw_scale_bar(image_size=image_size, redshift=redshift, name=name)
517567

518568
format_axes(figure)
519-
plt.tight_layout()
520569
plt.savefig(output_path, dpi=120)
521570
plt.close()
522571
return output_path
@@ -561,11 +610,16 @@ def download_decal_image(
561610
) -> Path:
562611
"""Download a DECaLS cutout as a JPEG or a DS9-friendly FITS file."""
563612
size = int(BASE_IMAGE_SIZE / fraction_size)
613+
if size < 1:
614+
raise ValueError(f"fraction_size must be no greater than {BASE_IMAGE_SIZE}")
564615
url = build_cutout_url(ra, dec, size, fits_format=fits_format, pxscale=PIXEL_SCALE_ARCSEC)
565616

566617
if fits_format:
567618
output_path = Path(f"img_ix{index:05d}_annoted_{name}.fits")
568619
raw_path = output_path.with_name(f"{output_path.stem}_raw.fits")
620+
if output_path.exists() and not overwrite:
621+
print(f"Output already exists, skipping download: {output_path}")
622+
return output_path
569623

570624
try:
571625
download_file(url, raw_path)
@@ -629,12 +683,12 @@ def parse_args() -> argparse.Namespace:
629683
)
630684
)
631685
parser.add_argument("index", type=int, help="Identifier index or row number.")
632-
parser.add_argument("ra", type=float, help="Right ascension in degrees.")
633-
parser.add_argument("dec", type=float, help="Declination in degrees.")
686+
parser.add_argument("ra", type=ra_degrees, help="Right ascension in degrees.")
687+
parser.add_argument("dec", type=dec_degrees, help="Declination in degrees.")
634688
parser.add_argument("name", help="Object name used in the output filename.")
635689
parser.add_argument(
636690
"redshift",
637-
type=float,
691+
type=nonnegative_float,
638692
nargs="?",
639693
default=0.2,
640694
help="Target redshift used for the physical scale label (default: 0.2). Use 0 if it is unknown.",

0 commit comments

Comments
 (0)