init
This commit is contained in:
commit
d30f9ac56c
56 files changed
+40776
No files matched your search
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download a small, versionable 2MASS PSC field from the IRSA Gator API.
|
||||
#
|
||||
# The query is intentionally a 0.5-degree cone instead of the full PSC: the
|
||||
# all-sky catalog contains hundreds of millions of sources and is not suitable
|
||||
# for this repository. The output remains an original IPAC table so no
|
||||
# photometry or missing-value handling is silently changed.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
output="$repo_root/assets/2mass/raw/2mass_psc_m31_0p5deg.tbl"
|
||||
tmp_output=$(mktemp "${TMPDIR:-/tmp}/2mass_psc_m31_XXXXXX.tbl")
|
||||
trap 'rm -f "$tmp_output"' EXIT
|
||||
|
||||
mkdir -p "$(dirname -- "$output")"
|
||||
|
||||
curl --fail --silent --show-error --location --get \
|
||||
'https://irsa.ipac.caltech.edu/cgi-bin/Gator/nph-query' \
|
||||
--data-urlencode 'catalog=fp_psc' \
|
||||
--data-urlencode 'spatial=cone' \
|
||||
--data-urlencode 'objstr=10.6847083 41.26875' \
|
||||
--data-urlencode 'radius=0.5' \
|
||||
--data-urlencode 'radunits=deg' \
|
||||
--data-urlencode 'outfmt=1' \
|
||||
--data-urlencode 'selcols=designation,ra,dec,j_m,h_m,k_m' \
|
||||
--data-urlencode 'outrows=50000' \
|
||||
-o "$tmp_output"
|
||||
|
||||
grep -q "2MASS All-Sky Point Source Catalog" "$tmp_output"
|
||||
grep -q '^| *designation|' "$tmp_output"
|
||||
mv "$tmp_output" "$output"
|
||||
trap - EXIT
|
||||
|
||||
printf 'Wrote %s\n' "$output"
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download the M44 (Beehive/Praesepe) 2MASS PSC comparison field from IRSA.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
output="$repo_root/assets/2mass/raw/2mass_psc_m44_1p0deg.tbl"
|
||||
tmp_output=$(mktemp "${TMPDIR:-/tmp}/2mass_psc_m44_XXXXXX.tbl")
|
||||
trap 'rm -f "$tmp_output"' EXIT
|
||||
|
||||
mkdir -p "$(dirname -- "$output")"
|
||||
|
||||
curl --fail --silent --show-error --location --get \
|
||||
'https://irsa.ipac.caltech.edu/cgi-bin/Gator/nph-query' \
|
||||
--data-urlencode 'catalog=fp_psc' \
|
||||
--data-urlencode 'spatial=cone' \
|
||||
--data-urlencode 'objstr=129.99165 19.54139' \
|
||||
--data-urlencode 'radius=1.0' \
|
||||
--data-urlencode 'radunits=deg' \
|
||||
--data-urlencode 'outfmt=1' \
|
||||
--data-urlencode 'selcols=designation,ra,dec,j_m,h_m,k_m' \
|
||||
--data-urlencode 'outrows=100000' \
|
||||
-o "$tmp_output"
|
||||
|
||||
grep -q "2MASS All-Sky Point Source Catalog" "$tmp_output"
|
||||
grep -q '^| *designation|' "$tmp_output"
|
||||
mv "$tmp_output" "$output"
|
||||
trap - EXIT
|
||||
|
||||
printf 'Wrote %s\n' "$output"
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fit blackbody temperature and apparent brightness to a 2MASS PSC table.
|
||||
|
||||
The output is deliberately a small renderer-facing CSV: RA, Dec, blackbody
|
||||
temperature, and blackbody normalization. The latter is the fitted apparent
|
||||
solid angle Omega in F_nu = Omega B_nu(T), expressed in steradians.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import argparse
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
INPUT = ROOT / "assets/2mass/raw/2mass_psc_m31_0p5deg.tbl"
|
||||
OUTPUT = ROOT / "assets/2mass/processed/2mass_psc_m31_0p5deg_stars.csv"
|
||||
|
||||
# 2MASS effective wavelengths and Vega zero-magnitude flux densities.
|
||||
WAVELENGTH_M = np.array([1.235, 1.662, 2.159]) * 1e-6
|
||||
ZERO_POINT_JY = np.array([1594.0, 1024.0, 666.7])
|
||||
GOOD_RD_FLAGS = frozenset("123")
|
||||
MIN_TEMPERATURE_K = 300.0
|
||||
MAX_TEMPERATURE_K = 100000.0
|
||||
|
||||
PLANCK_H = 6.62607015e-34
|
||||
LIGHT_C = 299792458.0
|
||||
BOLTZMANN_K = 1.380649e-23
|
||||
|
||||
|
||||
def column_slices(path: Path) -> tuple[list[str], list[tuple[int, int]], int]:
|
||||
"""Return IPAC fixed-width column metadata and first data-line index."""
|
||||
lines = path.read_text(encoding="ascii").splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
if line.startswith("|") and "designation" in line:
|
||||
boundaries = [offset for offset, char in enumerate(line) if char == "|"]
|
||||
names = [line[left + 1:right].strip()
|
||||
for left, right in zip(boundaries, boundaries[1:])]
|
||||
slices = [(left + 1, right)
|
||||
for left, right in zip(boundaries, boundaries[1:])]
|
||||
return names, slices, index + 4
|
||||
raise ValueError(f"no IPAC table header in {path}")
|
||||
|
||||
|
||||
def load_required_columns(path: Path) -> dict[str, np.ndarray]:
|
||||
names, slices, first_data_line = column_slices(path)
|
||||
required = ("ra", "dec", "j_m", "h_m", "k_m", "rd_flg")
|
||||
indices = {name: names.index(name) for name in required}
|
||||
values: dict[str, list[str]] = {name: [] for name in required}
|
||||
|
||||
with path.open(encoding="ascii") as table:
|
||||
for line_number, line in enumerate(table):
|
||||
if line_number < first_data_line or not line.strip():
|
||||
continue
|
||||
for name, index in indices.items():
|
||||
left, right = slices[index]
|
||||
values[name].append(line[left:right].strip())
|
||||
|
||||
result: dict[str, np.ndarray] = {}
|
||||
for name in ("ra", "dec", "j_m", "h_m", "k_m"):
|
||||
result[name] = np.array(
|
||||
[float(value) if value else math.nan for value in values[name]], dtype=float
|
||||
)
|
||||
result["rd_flg"] = np.array(values["rd_flg"], dtype="U3")
|
||||
return result
|
||||
|
||||
|
||||
def log_planck_nu_jy_per_sr(log_temperature: np.ndarray) -> np.ndarray:
|
||||
"""Evaluate log B_nu in Jy/sr at the three 2MASS effective wavelengths."""
|
||||
temperature = np.exp(log_temperature)[:, None]
|
||||
frequency = LIGHT_C / WAVELENGTH_M
|
||||
exponent = PLANCK_H * frequency / (BOLTZMANN_K * temperature)
|
||||
radiance_si = 2.0 * PLANCK_H * frequency**3 / LIGHT_C**2 / np.expm1(exponent)
|
||||
return np.log(radiance_si / 1e-26)
|
||||
|
||||
|
||||
def fit_blackbody(log_flux_jy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Unweighted least-squares fit in log F_nu for T and apparent solid angle."""
|
||||
count = len(log_flux_jy)
|
||||
lower = np.full(count, math.log(MIN_TEMPERATURE_K))
|
||||
upper = np.full(count, math.log(MAX_TEMPERATURE_K))
|
||||
golden = (math.sqrt(5.0) - 1.0) / 2.0
|
||||
first = upper - golden * (upper - lower)
|
||||
second = lower + golden * (upper - lower)
|
||||
|
||||
def objective(log_temperature: np.ndarray) -> np.ndarray:
|
||||
model = log_planck_nu_jy_per_sr(log_temperature)
|
||||
residual = (log_flux_jy - log_flux_jy.mean(axis=1, keepdims=True)
|
||||
- (model - model.mean(axis=1, keepdims=True)))
|
||||
return np.sum(residual * residual, axis=1)
|
||||
|
||||
first_value = objective(first)
|
||||
second_value = objective(second)
|
||||
for _ in range(64):
|
||||
keep_left = first_value <= second_value
|
||||
upper = np.where(keep_left, second, upper)
|
||||
second = np.where(keep_left, first, second)
|
||||
second_value = np.where(keep_left, first_value, second_value)
|
||||
lower = np.where(keep_left, lower, first)
|
||||
first = np.where(keep_left, upper - golden * (upper - lower), second)
|
||||
first_value = np.where(keep_left, objective(first), second_value)
|
||||
second = np.where(keep_left, second, lower + golden * (upper - lower))
|
||||
second_value = np.where(keep_left, second_value, objective(second))
|
||||
|
||||
log_temperature = 0.5 * (lower + upper)
|
||||
log_radiance = log_planck_nu_jy_per_sr(log_temperature)
|
||||
log_solid_angle = np.mean(log_flux_jy - log_radiance, axis=1)
|
||||
return np.exp(log_temperature), np.exp(log_solid_angle)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fit a renderer-facing blackbody catalog from a 2MASS PSC IPAC table."
|
||||
)
|
||||
parser.add_argument("--input", type=Path, default=INPUT,
|
||||
help="raw 2MASS PSC IPAC table")
|
||||
parser.add_argument("--output", type=Path, default=OUTPUT,
|
||||
help="renderer-facing CSV to write")
|
||||
args = parser.parse_args()
|
||||
columns = load_required_columns(args.input)
|
||||
magnitudes = np.column_stack((columns["j_m"], columns["h_m"], columns["k_m"]))
|
||||
valid_photometry = np.isfinite(magnitudes).all(axis=1)
|
||||
valid_rd_flag = np.array(
|
||||
[len(flag) == 3 and all(value in GOOD_RD_FLAGS for value in flag)
|
||||
for flag in columns["rd_flg"]]
|
||||
)
|
||||
keep = valid_photometry & valid_rd_flag
|
||||
if not np.any(keep):
|
||||
raise ValueError("no sources retain valid J/H/Ks photometry")
|
||||
|
||||
flux_jy = ZERO_POINT_JY * np.power(10.0, -0.4 * magnitudes[keep])
|
||||
temperature_k, amplitude_sr = fit_blackbody(np.log(flux_jy))
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.output.open("w", newline="", encoding="ascii") as output:
|
||||
writer = csv.writer(output, lineterminator="\n")
|
||||
writer.writerow(("ra_deg", "dec_deg", "temperature_K", "amplitude_sr"))
|
||||
for ra, dec, temperature, amplitude in zip(
|
||||
columns["ra"][keep], columns["dec"][keep], temperature_k, amplitude_sr
|
||||
):
|
||||
writer.writerow((f"{ra:.6f}", f"{dec:.6f}", f"{temperature:.8g}",
|
||||
f"{amplitude:.9e}"))
|
||||
|
||||
print(f"input_rows={len(keep)}")
|
||||
print(f"discarded_invalid_photometry={np.count_nonzero(~valid_photometry)}")
|
||||
print(f"discarded_rd_flg_not_123={np.count_nonzero(~valid_rd_flag)}")
|
||||
print(f"retained_rows={np.count_nonzero(keep)}")
|
||||
print(f"wrote={args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user