Source code for exosim.tasks.sed.download_sed

"""
DownloadPhoenix task: fetches stellar SEDs from online Phoenix spectral databases.

Two backends are supported, selected automatically by *model_name*:

* **phoenix-aces**
  PHOENIX-ACES-AGSS-COND-2011 from the Goettingen server
  (https://phoenix.astro.physik.uni-goettingen.de).
  The parameter grid is hardcoded; only astropy and numpy are required.

* **SVO** (bt-settl, bt-settl-cifist, bt-nextgen, nextgen, ...)
  The Spanish Virtual Observatory (https://svo2.cab.inta-csic.es/theory/newov2/)
  is queried at run time using stdlib ``urllib`` and ``html.parser``; no
  extra packages are required.

In both cases ``astropy.utils.data.download_file`` is used for the actual HTTP
transfer; files are cached in the default astropy cache directory so they are
not re-downloaded on subsequent calls.
"""

import logging
import os
import re
import socket
import urllib.parse
import urllib.request
from html.parser import HTMLParser

import numpy as np
from astropy import units as u
from astropy.io import fits
from astropy.utils.data import download_file

import exosim.models.signal as signal
import exosim.utils.checks as checks
from exosim.tasks.task import Task

[docs] logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # Goettingen Phoenix-ACES backend # --------------------------------------------------------------------------- _GOETTINGEN_BASE = "https://phoenix.astro.physik.uni-goettingen.de/data/v2.0/HiResFITS/" _ACES_MODEL = "PHOENIX-ACES-AGSS-COND-2011" _ACES_WAVE_URL = f"{_GOETTINGEN_BASE}WAVE_{_ACES_MODEL}.fits" # Full parameter grids for PHOENIX-ACES-AGSS-COND-2011 _ACES_TEFF = np.concatenate([np.arange(2300, 7100, 100), np.arange(7200, 12200, 200)]) _ACES_LOGG = np.arange(0.0, 6.5, 0.5) _ACES_FEH = np.array([-4.0, -3.0, -2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0]) def _snap(value, grid): """Return the element of *grid* nearest to *value*.""" return grid[int(np.argmin(np.abs(grid - value)))] def _aces_url(teff, logg, feh): """Build the Goettingen HiResFITS URL and return (url, snapped params).""" t = int(_snap(teff, _ACES_TEFF)) g = float(_snap(logg, _ACES_LOGG)) z = float(_snap(feh, _ACES_FEH)) # Filename convention: lte{TTTTT}-{logg:.2f}{feh:+.1f}.MODEL-HiRes.fits # The '-' before logg is a literal separator, not a sign. filename = f"lte{t:05d}-{g:.2f}{z:+.1f}.{_ACES_MODEL}-HiRes.fits" url = f"{_GOETTINGEN_BASE}{_ACES_MODEL}/Z{z:+.1f}/{filename}" return url, t, g, z def _fetch_aces(teff, logg, feh): """Download Phoenix-ACES wave + flux from Goettingen and return Quantities.""" url, t, g, z = _aces_url(teff, logg, feh) # Check Goettingen server reachability before attempting downloads if not _host_is_reachable(_GOETTINGEN_BASE): raise ConnectionError( f"Cannot reach Goettingen server at {_GOETTINGEN_BASE}; no network" ) logger.info(f"Downloading Phoenix files: wave={_ACES_WAVE_URL}, flux={url}") logger.debug("Downloading Phoenix WAVE: %s", _ACES_WAVE_URL) logger.debug("Downloading Phoenix flux: %s", url) wave_path = download_file(_ACES_WAVE_URL, pkgname="exosim", cache=True) flux_path = download_file(url, pkgname="exosim", cache=True) with fits.open(wave_path) as hdul: wl = np.asarray(hdul[0].data) * u.AA with fits.open(flux_path) as hdul: # Surface flux density: erg s-1 cm-2 cm-1 flux = np.asarray(hdul[0].data) * (u.erg / u.s / u.cm**2 / u.cm) meta = { "teff_grid": t, "logg_grid": g, "feh_grid": z, "model": _ACES_MODEL, "source": _GOETTINGEN_BASE, } # Prefer the remote filename (from URL path); fall back to cached file name try: fname = os.path.basename(urllib.parse.urlparse(url).path) or None if not fname: fname = os.path.basename(flux_path) meta.update({"filename": fname}) except Exception: pass return wl, flux, meta # --------------------------------------------------------------------------- # SVO backend # --------------------------------------------------------------------------- _SVO_BASE = "https://svo2.cab.inta-csic.es/theory/newov2/" def _host_is_reachable(base_url: str, timeout: float = 3.0) -> bool: """Return True if the host for *base_url* accepts TCP connections. This attempts a short TCP connection to port 443 for HTTPS (or 80 for HTTP). It may give false negatives in captive or filtered networks. """ try: parsed = urllib.parse.urlparse(base_url) host = parsed.hostname if not host: return False port = 443 if parsed.scheme == "https" else 80 conn = socket.create_connection((host, port), timeout=timeout) conn.close() return True except Exception: return False class _SVOModelsParser(HTMLParser): """Parse SVO index HTML to extract available model identifiers. The parser looks for ``<select name="models">``/``<option value="...">`` and for inputs named ``reqmodels[]`` with a ``value`` attribute. """ def __init__(self): super().__init__() self._in_models_select = False self.models: set[str] = set() def handle_starttag(self, tag, attrs): attrs = dict(attrs) if tag == "select" and attrs.get("name", "") in ("models", "reqmodels[]"): self._in_models_select = True elif (tag == "option" and self._in_models_select) or ( tag == "input" and attrs.get("name") == "reqmodels[]" ): val = attrs.get("value") if val: self.models.add(val) elif tag == "a": # Links like index.php?models=bt-settl appear as model selectors href = attrs.get("href", "") if "models=" in href: try: qs = urllib.parse.urlparse(href).query params = urllib.parse.parse_qs(qs) vals = params.get("models") or params.get("reqmodels[]") if vals: for v in vals: if v: self.models.add(v) except Exception: pass def handle_endtag(self, tag): if tag == "select": self._in_models_select = False
[docs] def get_svo_models() -> list[str]: """Query the SVO theory index page and return a sorted list of model ids. This performs a live HTTP GET against the SVO index and parses the HTML for model identifiers. Network errors will propagate as exceptions. """ url = urllib.parse.urljoin(_SVO_BASE, "index.php") # Quick connectivity check to fail fast if offline if not _host_is_reachable(_SVO_BASE): raise ConnectionError(f"Cannot reach SVO service at {_SVO_BASE}; no network") req = urllib.request.Request(url) with urllib.request.urlopen(req, timeout=30) as resp: html = resp.read().decode("utf-8", errors="replace") parser = _SVOModelsParser() parser.feed(html) return sorted(parser.models)
class _SVOTableParser(HTMLParser): """Minimal HTML parser that extracts rows from the SVO results table. The SVO HTML uses: - ``<td class="tabcab">`` for column headers - ``<td class="tabfld">`` for data cells; download links live inside an ``<a href="...">`` within the 3rd-from-last data cell of each row. """ def __init__(self): super().__init__() self.headers: list[str] = [] self._rows: list[list[tuple[str, str | None]]] = [] self._row: list[tuple[str, str | None]] | None = None self._mode = "" # "hdr" | "data" | "" self._cell_text = "" self._cell_link: str | None = None def handle_starttag(self, tag, attrs): attrs = dict(attrs) if tag == "tr": self._row = [] elif tag == "td": cls = attrs.get("class", "") if cls == "tabcab": self._mode = "hdr" elif cls == "tabfld": self._mode = "data" else: self._mode = "" self._cell_text = "" self._cell_link = None elif tag == "a" and self._mode == "data": href = attrs.get("href", "") if href: self._cell_link = href def handle_endtag(self, tag): if tag == "td": text = self._cell_text.strip() if self._mode == "hdr" and text: self.headers.append(text) elif self._mode == "data" and self._row is not None: self._row.append((text, self._cell_link)) self._mode = "" elif tag == "tr": if self._row: self._rows.append(self._row) self._row = None def handle_data(self, data): if self._mode in ("hdr", "data"): self._cell_text += data def models(self): """Return list of dicts with teff/logg/feh/alpha/url for each row.""" def _col(name, default): # case-insensitive partial match in headers name_l = name.lower() for i, h in enumerate(self.headers): if name_l in h.lower(): return i return None i_teff = _col("teff", None) i_logg = _col("logg", None) i_meta = _col("metallicit", None) i_alpha = _col("alpha", None) if i_teff is None or i_logg is None: return [] results = [] for row in self._rows: try: teff = float(row[i_teff][0]) logg = float(row[i_logg][0]) feh = float(row[i_meta][0]) if i_meta is not None else 0.0 alpha = float(row[i_alpha][0]) if i_alpha is not None else 0.0 except (IndexError, ValueError): continue # Download link is in the 3rd-from-last cell link = None for _, lnk in reversed(row): if lnk: link = lnk break if link is None: continue results.append( { "teff": teff, "logg": logg, "feh": feh, "alpha": alpha, "url": urllib.parse.urljoin(_SVO_BASE, link), } ) return results def _svo_nearest(model_name, teff, logg, feh, alpha): """POST to SVO, parse the HTML table, return the nearest model entry.""" post_body = urllib.parse.urlencode( { "models": model_name, "oby": "", "odesc": "", "sbut": "", # Wide ranges so we get the full grid back "params[bt-settl][teff][min]": "0", "params[bt-settl][teff][max]": "999999", "params[bt-settl][logg][min]": "-99", "params[bt-settl][logg][max]": "99", "params[bt-settl][meta][min]": "-99", "params[bt-settl][meta][max]": "99", "params[bt-settl][alpha][min]": "-99", "params[bt-settl][alpha][max]": "99", "nres": "all", "boton": "Search", "reqmodels[]": model_name, } ).encode("utf-8") # Check SVO reachability before making the POST if not _host_is_reachable(_SVO_BASE): raise ConnectionError(f"Cannot reach SVO service at {_SVO_BASE}; no network") req = urllib.request.Request( urllib.parse.urljoin(_SVO_BASE, "index.php"), data=post_body, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, ) with urllib.request.urlopen(req, timeout=120) as resp: html = resp.read().decode("utf-8", errors="replace") parser = _SVOTableParser() parser.feed(html) candidates = parser.models() if not candidates: raise ValueError( f"SVO returned no spectra for model '{model_name}'. " "Check the model name or its availability on the SVO service." ) # Nearest neighbour in normalised parameter space teff_a = np.array([m["teff"] for m in candidates]) logg_a = np.array([m["logg"] for m in candidates]) feh_a = np.array([m["feh"] for m in candidates]) alpha_a = np.array([m["alpha"] for m in candidates]) dist = ( ((teff_a - teff) / 100.0) ** 2 + ((logg_a - logg) / 0.5) ** 2 + ((feh_a - feh) / 0.5) ** 2 + ((alpha_a - alpha) / 0.2) ** 2 ) return candidates[int(np.argmin(dist))]
[docs] def air_to_vacuum_wavelength(lambda_air, max_iter=20, tol=1e-12): """ Convert air wavelengths to vacuum wavelengths using the Ciddor relation. Wavelengths must be in Angstrom. The relation is applied only for finite positive wavelengths greater than 2000 Angstrom. """ lambda_air = np.asarray(lambda_air, dtype=float) lambda_vac = np.full_like(lambda_air, np.nan, dtype=float) valid = np.isfinite(lambda_air) & (lambda_air > 2000.0) if not np.any(valid): return lambda_vac lam_air_valid = lambda_air[valid] lam_vac_valid = lam_air_valid.copy() for _ in range(max_iter): sigma2 = (1e4 / lam_vac_valid) ** 2 f = 1.0 + 0.05792105 / (238.0185 - sigma2) + 0.00167917 / (57.362 - sigma2) new_lam_vac_valid = lam_air_valid * f rel_diff = np.abs(new_lam_vac_valid - lam_vac_valid) / lam_vac_valid lam_vac_valid = new_lam_vac_valid if np.all(rel_diff < tol): break lambda_vac[valid] = lam_vac_valid return lambda_vac
def _fetch_svo(model_name, teff, logg, feh, alpha): """Download an SVO ASCII spectrum and return Quantities.""" best = _svo_nearest(model_name, teff, logg, feh, alpha) logger.info("Downloading SVO file: %s", best["url"]) logger.debug("SVO selection: %s", best) # Check SVO host before attempting to download if not _host_is_reachable(_SVO_BASE): raise ConnectionError(f"Cannot reach SVO service at {_SVO_BASE}; no network") # Try to determine a meaningful remote filename using HEAD (Content-Disposition # or final redirected URL). If the remote name is generic (ssap.php, index.php) # build a descriptive fallback name including the model and parameters. def _remote_filename(url: str) -> str | None: try: req = urllib.request.Request(url, method="HEAD") with urllib.request.urlopen(req, timeout=30) as resp: final = resp.geturl() cd = resp.headers.get("Content-Disposition") or resp.headers.get( "content-disposition" ) if cd: # Try filename* (RFC5987) first, then plain filename m = re.search( r"filename\*=(?:UTF-8''?)?(?P<f>[^;\n]+)", cd, flags=re.IGNORECASE ) if not m: m = re.search( r'filename=(?P<f>"[^"]+"|[^;\n]+)', cd, flags=re.IGNORECASE ) if m: f = m.group("f").strip().strip('"') return os.path.basename(f) return os.path.basename(urllib.parse.urlparse(final).path) or None except Exception: return None remote_fname = _remote_filename(best["url"]) or None local = download_file(best["url"], pkgname="exosim", cache=True) wl_list, flux_list = [], [] with open(local) as fh: for line in fh: line = line.strip() if not line or line.startswith("#"): continue parts = line.split() if len(parts) >= 2: try: wl_list.append(float(parts[0])) flux_list.append(float(parts[1])) except ValueError: continue if not wl_list: raise ValueError( f"Could not parse any spectral data from SVO file: {best['url']}" ) wl = np.array(wl_list) * u.AA # SVO BT-Settl files use erg s-1 cm-2 AA-1 flux = np.array(flux_list) * (u.erg / u.s / u.cm**2 / u.AA) meta = {k: best[k] for k in ("teff", "logg", "feh", "alpha")} # include model/source and prefer the remote filename from the URL; if that # is generic (ssap.php, index.php, contents) use a constructed descriptive name meta.update({"model": model_name, "source": _SVO_BASE}) try: generic_names = {"ssap.php", "index.php", "contents", ""} fname = remote_fname or os.path.basename(local) if fname and fname.lower() in generic_names: # construct descriptive fallback name safe_model = re.sub(r"[^A-Za-z0-9._+-]", "-", model_name) fname = ( f"{safe_model}_Teff{int(teff):05d}_logg{logg:.2f}_feh{feh:+.1f}.spec" ) meta.update({"filename": fname}) except Exception: pass return wl, flux, meta # --------------------------------------------------------------------------- # Task class # ---------------------------------------------------------------------------
[docs] class DownloadSed(Task): """ Downloads a stellar SED from an online Phoenix spectral database, selects the nearest grid model to the requested stellar parameters, and returns the spectrum scaled to the apparent flux at the observer. Two backends are available, chosen automatically by *model_name*: * ``"phoenix-aces"`` - PHOENIX-ACES-AGSS-COND-2011 from the Goettingen server. The parameter grid is hardcoded; only ``astropy`` and ``numpy`` are required. * Any SVO model name (``"bt-settl"``, ``"bt-settl-cifist"`, ``"bt-nextgen-agss2009"``, ``"nextgen"`, ...) - files are fetched from the Spanish Virtual Observatory using stdlib ``urllib``; no extra packages are needed. Default: ``bt-settl-cifist`` (SVO BT-Settl CIFIST). Files are cached in the astropy download cache (``~/.astropy/cache/download/``) so repeated calls with the same parameters do not re-download. Returns ------- :class:`~exosim.models.signal.Sed` Star SED (apparent flux at observer, units W m-2 um-1) Examples -------- Example (default model ``bt-settl-cifist``): >>> from exosim.tasks.sed import DownloadSed >>> import astropy.units as u, astropy.constants as cc, numpy as np >>> downloadSed = DownloadSed() >>> D = 12.975 * u.pc >>> T = 3016 * u.K >>> M = 0.15 * u.Msun >>> R = 0.218 * u.Rsun >>> g = (cc.G * M.si / R.si**2).to(u.cm / u.s**2) >>> logg = np.log10(g.value) >>> sed = downloadSed(T=T, D=D, R=R, logg=logg) BT-Settl CIFIST from the SVO (explicit): >>> sed = downloadSed( ... T=T, D=D, R=R, logg=logg, model_name="bt-settl-cifist" ... ) Raises ------ KeyError if a required stellar parameter is missing. ValueError if *model_name* is not recognised or the online query returns no data. """ def __init__(self): """ Parameters ---------- R : :class:`~astropy.units.Quantity` or float Star radius. If dimensionless, metres are assumed. D : :class:`~astropy.units.Quantity` or float Star distance. If dimensionless, metres are assumed. T : :class:`~astropy.units.Quantity` or float Effective temperature. If dimensionless, Kelvin are assumed. logg : float log10 of the surface gravity in cgs (log10 g [cm/s2]). z : float, optional Metallicity [Fe/H]. Default is 0.0. alpha : float, optional Alpha-element enhancement [alpha/Fe] (SVO models only). Default is 0.0. model_name : str, optional Phoenix or SVO model to download. Defaults to ``bt-settl-cifist`` (SVO BT-Settl CIFIST). Use ``"phoenix-aces"`` for PHOENIX-ACES-AGSS-COND-2011 from the Goettingen server, or any SVO model name (e.g. ``"bt-settl"``, ``"bt-settl-cifist"``). """ self.add_task_param("R", "star radius") self.add_task_param("D", "star distance") self.add_task_param("T", "star temperature") self.add_task_param("logg", "star logG") self.add_task_param("z", "star metallicity", 0.0) self.add_task_param("alpha", "alpha element enhancement", 0.0) self.add_task_param( "model_name", "phoenix or SVO model name", "bt-settl-cifist", )
[docs] def execute(self): R = self.get_task_param("R") D = self.get_task_param("D") T = self.get_task_param("T") logg = self.get_task_param("logg") z = self.get_task_param("z") alpha = self.get_task_param("alpha") model_name = self.get_task_param("model_name") # --- validate required parameters -------------------------------- for name, val in (("R", R), ("D", D), ("T", T), ("logg", logg)): if val is None: self.error(f"star {name} missing") raise KeyError(f"star {name} missing") if hasattr(T, "unit"): T = T.to(u.K) else: T = T * u.K self.debug("no units found for T: Kelvin are assumed.") R = checks.check_units(R, u.m, self) D = checks.check_units(D, u.m, self) teff = float(T.to(u.K).value) logg = float(logg) z = float(z) if z is not None else 0.0 alpha = float(alpha) if alpha is not None else 0.0 # --- dispatch to the appropriate backend ------------------------- self.info( f"downloading spectrum: Teff={teff:.0f} K, " f"logg={logg:.2f}, [Fe/H]={z:.2f}, model='{model_name}'" ) if model_name == "phoenix-aces": wl_ph, sed_ph, meta = _fetch_aces(teff, logg, z) else: # Attempt an SVO fetch for any other model name. If the SVO # query returns no candidates a ValueError will be raised. try: wl_ph_air, sed_ph, meta = _fetch_svo(model_name, teff, logg, z, alpha) # SVO BT-Settl files use air wavelengths in Angstrom; convert to vacuum self.debug("converting SVO wavelengths from air to vacuum") wl_ph = air_to_vacuum_wavelength(wl_ph_air.value) * u.AA except ValueError as exc: msg = ( f"Unknown or unavailable SVO model '{model_name}'. " "Check the model name or list available models with " "get_svo_models()." ) # Log the higher-level message but re-raise the original # ValueError so callers (and tests) can inspect the original # error message from the SVO backend. self.error(msg + f" Details: {exc}") raise meta.update({"model_name": model_name}) self.debug(f"spectrum downloaded: {len(wl_ph)} points; meta={meta}") # --- convert to W m-2 um-1 --------------------------------------- wl_ph = wl_ph.to(u.um, equivalencies=u.spectral()) sed_ph = sed_ph.to( u.W / u.m**2 / u.um, equivalencies=u.spectral_density(wl_ph), ) # Remove duplicates and sort by wavelength idx = np.nonzero(np.diff(wl_ph))[0] wl_ph = wl_ph[idx] sed_ph = sed_ph[idx] sort_idx = np.argsort(wl_ph) wl_ph = wl_ph[sort_idx] sed_ph = sed_ph[sort_idx] sed = signal.Sed(spectral=wl_ph, data=sed_ph) for k, v in meta.items(): sed.metadata[k] = v # --- geometric dilution (R/D)^2 ---------------------------------- sed *= (R / D) ** 2 self.debug(f"phoenix sed scaled: {sed.data}") self.set_output(sed)