/// Gum Catalogue of Southern HII Regions (Gum 1955). /// Fetched from VizieR XI/75. Mostly Dec < -30° but ~20-30 entries are in range. use anyhow::Context; use chrono::Utc; use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords}; use crate::catalog::fetch::{format_ra_hms, format_dec_dms}; use crate::catalog::popular_names::popular_names; use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; const VIZIER_GUM_URL: &str = "https://vizier.cds.unistra.fr/viz-bin/asu-tsv\ ?-source=XI/75\ &-out=Gum\ &-out=_RA\ &-out=_DE\ &-out=Diam\ &-out.max=200\ &-oc.form=dec"; #[derive(Debug, Clone)] struct GumRow { id: u32, ra_deg: f64, dec_deg: f64, diam_arcmin: f64, } pub async fn fetch_gum() -> anyhow::Result> { match fetch_from_vizier().await { Ok(entries) if !entries.is_empty() => { tracing::info!("Gum: loaded {} entries from VizieR XI/75", entries.len()); Ok(entries) } Ok(_) => { tracing::warn!("Gum: VizieR returned 0 rows — using hardcoded fallback"); Ok(build_entries_from_rows(get_prominent_gum())) } Err(e) => { tracing::warn!("Gum fetch from VizieR failed ({}) — using hardcoded fallback", e); Ok(build_entries_from_rows(get_prominent_gum())) } } } async fn fetch_from_vizier() -> anyhow::Result> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) .user_agent("astronome/1.0") .build()?; let text = client .get(VIZIER_GUM_URL) .send() .await .context("Gum fetch request failed")? .text() .await .context("Gum response read failed")?; let rows = parse_vizier_tsv(&text); tracing::info!("Gum: parsed {} rows from VizieR XI/75", rows.len()); if rows.is_empty() { anyhow::bail!("no rows parsed from VizieR Gum response"); } let filtered: Vec = rows .into_iter() .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0) .collect(); tracing::info!("Gum: {} rows pass filters (Dec >= -30°)", filtered.len()); Ok(build_entries_from_rows(filtered)) } fn parse_vizier_tsv(text: &str) -> Vec { let mut rows = Vec::new(); let mut header: Vec = Vec::new(); let mut past_separator = false; for line in text.lines() { if line.starts_with('#') { continue; } let trimmed = line.trim(); if trimmed.is_empty() { continue; } if header.is_empty() { header = trimmed.split('\t').map(|s| s.trim().to_string()).collect(); if header.len() < 2 { header = trimmed.split_whitespace().map(|s| s.to_string()).collect(); } continue; } if !past_separator { if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') { past_separator = true; } continue; } let cols: Vec<&str> = if line.contains('\t') { line.split('\t').map(|s| s.trim()).collect() } else { line.split_whitespace().collect() }; if cols.len() < 3 { continue; } let col_idx = |name: &str| -> Option { header.iter().position(|h| h.eq_ignore_ascii_case(name)) }; let id = col_idx("Gum") .and_then(|i| cols.get(i)) .and_then(|s| s.parse::().ok()); let ra = col_idx("_RA") .and_then(|i| cols.get(i)) .and_then(|s| s.parse::().ok()); let dec = col_idx("_DE") .and_then(|i| cols.get(i)) .and_then(|s| s.parse::().ok()); let diam = col_idx("Diam") .and_then(|i| cols.get(i)) .and_then(|s| s.parse::().ok()) .unwrap_or(15.0); if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) { rows.push(GumRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam }); } } rows } fn build_entries_from_rows(rows: Vec) -> Vec { let now = Utc::now().timestamp(); let names = popular_names(); rows.into_iter() .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0) .map(|r| build_entry(r, now, &names)) .collect() } fn build_entry( r: GumRow, now: i64, names: &std::collections::HashMap<&'static str, &'static str>, ) -> CatalogEntry { let id = format!("Gum{}", r.id); let fov_fill = (r.diam_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0; let panels_w = ((r.diam_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1); let panels_h = ((r.diam_arcmin / FOV_ARCMIN_H).ceil() as i32).max(1); let mosaic = panels_w > 1 || panels_h > 1; let density = guide_star_density_from_coords(r.ra_deg, r.dec_deg); let common_name = names.get(id.as_str()).map(|s| s.to_string()); let is_highlight = common_name.is_some(); CatalogEntry { id: id.clone(), name: id, common_name, obj_type: "emission_nebula".to_string(), ra_deg: r.ra_deg, dec_deg: r.dec_deg, ra_h: format_ra_hms(r.ra_deg), dec_dms: format_dec_dms(r.dec_deg), constellation: None, size_arcmin_maj: Some(r.diam_arcmin), size_arcmin_min: Some(r.diam_arcmin * 0.7), pos_angle_deg: None, mag_v: None, surface_brightness: None, hubble_type: None, messier_num: None, is_highlight, fov_fill_pct: Some(fov_fill), mosaic_flag: mosaic, mosaic_panels_w: panels_w, mosaic_panels_h: panels_h, difficulty: Some(3), guide_star_density: Some(density.to_string()), fetched_at: now, } } fn get_prominent_gum() -> Vec { // Small fallback — most Gum objects are far south, these are in range vec![ GumRow { id: 12, ra_deg: 126.0, dec_deg: -47.5, diam_arcmin: 36.0 }, GumRow { id: 17, ra_deg: 131.0, dec_deg: -43.0, diam_arcmin: 20.0 }, ] }