77import csv
88import math
99import re
10+ import shutil
1011import subprocess
1112import sys
1213import tempfile
14+ import time
1315import urllib .error
1416import urllib .request
1517from dataclasses import dataclass
2426
2527PIXEL_SCALE_ARCSEC = 0.262
2628BASE_IMAGE_SIZE = 2048
29+ JPEG_EXPORT_SIZE_PX = 1200
2730SURVEY_LAYER = "ls-dr9"
2831DEFAULT_BRIGHTEST_COUNT = 5
2932DEFAULT_AUTO_DESI_RADIUS_DEG = 0.02
3033DEFAULT_AUTO_LS_RADIUS_DEG = 0.0166667
34+ DOWNLOAD_RETRY_ATTEMPTS = 3
3135AUTO_INPUT_TOKEN = "__auto__"
3236DESI_OBJECT_CATALOG_FILENAME = "object_catalog.csv"
3337cosmo = FlatLambdaCDM (H0 = 70 , Om0 = 0.3 )
@@ -46,11 +50,35 @@ class HelperRunResult:
4650def 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+
5482def 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
231259def 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
237283def 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