Files
Astronome/backend/src/catalog/gum.rs
T
arnaudne 2bb80a8475 Add target comparison modal, integration goal progress, and session planning + full catalog expansion
Features added this session:
- Target comparison: side-by-side overlay (CompareModal) from Targets page via ⊕ button on each row; shows altitude curves, key times, filter recommendations and per-filter integration progress for two targets simultaneously
- Integration goal progress dashboard card: per-target keeper minutes vs goal hours (from CLAUDE.md §16.3) broken down by filter, with color-coded progress bars; powered by new stats.integration_goals backend query
- Session planning timeline: Gantt-style "Plan Tonight" section on Dashboard (PlanningTimeline component) — search targets, set durations, sequential scheduling from dusk, overrun warnings, clipboard export
- Slew-optimized run order toggle (nearest-neighbor sort by RA/Dec angular distance)
- Best Nights 14-day card + Monthly Highlights card on Dashboard

Catalog expansions:
- Sharpless (Sh2), VdB, LDN, Barnard dark nebulae, LBN, Melotte, Collinder, Gum, RCW, Abell PN, Abell GC, PGC bright subset
- Caldwell/Arp/Melotte/Collinder number columns + cross-reference maps
- Weather score multiplier applied to composite sort
- galaxy_cluster type (ACO badge) throughout TypeBadge, CSS, filter chips

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 07:20:10 +02:00

200 lines
6.1 KiB
Rust

/// 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<Vec<CatalogEntry>> {
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<Vec<CatalogEntry>> {
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<GumRow> = 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<GumRow> {
let mut rows = Vec::new();
let mut header: Vec<String> = 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<usize> {
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::<u32>().ok());
let ra = col_idx("_RA")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok());
let dec = col_idx("_DE")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok());
let diam = col_idx("Diam")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().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<GumRow>) -> Vec<CatalogEntry> {
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<GumRow> {
// 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 },
]
}