Files
Astronome/backend/src/catalog/sh2.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

279 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Sharpless (Sh2) emission nebula catalog.
/// Fetched from VizieR catalog VII/20 (Sharpless 1959).
/// These are H II regions not always present in OpenNGC.
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};
/// VizieR VII/20 — Sharpless Catalog of HII Regions (1959).
const VIZIER_SH2_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
?-source=VII/20\
&-out=Sh2\
&-out=MajDiam\
&-out=_RA\
&-out=_DE\
&-out.max=1000\
&-oc.form=dec";
#[derive(Debug, Clone)]
struct Sh2Row {
id: u32,
ra_deg: f64,
dec_deg: f64,
diam_arcmin: f64,
}
pub async fn fetch_sh2() -> anyhow::Result<Vec<CatalogEntry>> {
match fetch_from_vizier().await {
Ok(entries) if !entries.is_empty() => {
tracing::info!("Sh2: loaded {} entries from VizieR VII/20", entries.len());
Ok(entries)
}
Ok(_) => {
tracing::warn!("Sh2: VizieR returned 0 rows — using hardcoded fallback");
Ok(build_entries_from_rows(get_prominent_sh2()))
}
Err(e) => {
tracing::warn!("Sh2 fetch from VizieR failed ({}) — using hardcoded fallback", e);
Ok(build_entries_from_rows(get_prominent_sh2()))
}
}
}
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_SH2_URL)
.send()
.await
.context("Sh2 fetch request failed")?
.text()
.await
.context("Sh2 response read failed")?;
tracing::debug!("Sh2 raw response first 500 chars: {}", &text[..text.len().min(500)]);
let rows = parse_vizier_tsv(&text);
tracing::info!("Sh2: parsed {} rows from VizieR VII/20", rows.len());
if rows.is_empty() {
anyhow::bail!("no rows parsed from VizieR Sh2 response");
}
let total = rows.len();
let filtered: Vec<Sh2Row> = rows
.into_iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 1.0)
.collect();
tracing::info!("Sh2: {}/{} rows pass filters", filtered.len(), total);
Ok(build_entries_from_rows(filtered))
}
fn parse_vizier_tsv(text: &str) -> Vec<Sh2Row> {
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 = if trimmed.contains('\t') {
trimmed.split('\t').map(|s| s.trim().to_string()).collect()
} else {
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() < 2 {
continue;
}
let col_idx = |name: &str| -> Option<usize> {
header.iter().position(|h| h.eq_ignore_ascii_case(name))
};
let id = col_idx("Sh2")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<u32>().ok());
let diam = col_idx("MajDiam")
.or_else(|| col_idx("Diam"))
.or_else(|| col_idx("Dmaj"))
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(15.0);
let ra = col_idx("_RA")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok())
.or_else(|| cols.get(cols.len().saturating_sub(2)).and_then(|s| s.parse().ok()));
let dec = col_idx("_DE")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok())
.or_else(|| cols.last().and_then(|s| s.parse().ok()));
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
rows.push(Sh2Row { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
}
}
rows
}
fn build_entries_from_rows(rows: Vec<Sh2Row>) -> 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 >= 1.0)
.map(|r| build_entry(r, now, &names))
.collect()
}
fn build_entry(r: Sh2Row, now: i64, names: &std::collections::HashMap<&'static str, &'static str>) -> CatalogEntry {
let id = format!("Sh2-{}", 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,
}
}
/// Hardcoded fallback: ~80 prominent Sharpless HII regions accessible from northern latitudes.
fn get_prominent_sh2() -> Vec<Sh2Row> {
vec![
// Winter / Spring (Orion, Auriga, Gemini, Monoceros, Perseus, Cassiopeia)
Sh2Row { id: 1, ra_deg: 0.113, dec_deg: 64.083, diam_arcmin: 20.0 }, // Cas
Sh2Row { id: 7, ra_deg: 269.533, dec_deg: -23.450, diam_arcmin: 80.0 }, // Sgr
Sh2Row { id: 9, ra_deg: 253.783, dec_deg: -34.350, diam_arcmin: 60.0 }, // Sco
Sh2Row { id: 11, ra_deg: 264.167, dec_deg: -34.483, diam_arcmin: 80.0 }, // War & Peace
Sh2Row { id: 16, ra_deg: 274.683, dec_deg: -13.783, diam_arcmin: 60.0 }, // Eagle region
Sh2Row { id: 17, ra_deg: 275.000, dec_deg: -15.200, diam_arcmin: 40.0 }, // Sgr
Sh2Row { id: 25, ra_deg: 274.000, dec_deg: -23.833, diam_arcmin: 120.0 }, // Lagoon region
Sh2Row { id: 27, ra_deg: 84.917, dec_deg: 9.333, diam_arcmin: 370.0 }, // λ Ori ring
Sh2Row { id: 29, ra_deg: 18.867, dec_deg: 61.533, diam_arcmin: 12.0 }, // Cas
Sh2Row { id: 36, ra_deg: 82.550, dec_deg: 4.033, diam_arcmin: 8.0 }, // Ori
Sh2Row { id: 64, ra_deg: 98.067, dec_deg: 4.967, diam_arcmin: 80.0 }, // Rosette
Sh2Row { id: 68, ra_deg: 86.583, dec_deg: -1.950, diam_arcmin: 30.0 }, // Flame-adjacent
Sh2Row { id: 100, ra_deg: 305.967, dec_deg: 35.817, diam_arcmin: 180.0 }, // γ Cyg / Butterfly
Sh2Row { id: 101, ra_deg: 296.867, dec_deg: 35.417, diam_arcmin: 20.0 }, // Tulip
Sh2Row { id: 103, ra_deg: 311.283, dec_deg: 31.717, diam_arcmin: 230.0 }, // Veil Complex
Sh2Row { id: 106, ra_deg: 304.883, dec_deg: 37.367, diam_arcmin: 12.0 }, // Cygnus
Sh2Row { id: 108, ra_deg: 337.417, dec_deg: -21.933, diam_arcmin: 1200.0 }, // Helix (huge)
Sh2Row { id: 119, ra_deg: 315.617, dec_deg: 44.250, diam_arcmin: 180.0 }, // North America
Sh2Row { id: 126, ra_deg: 316.833, dec_deg: 44.533, diam_arcmin: 90.0 }, // Pelican
Sh2Row { id: 129, ra_deg: 328.150, dec_deg: 60.050, diam_arcmin: 140.0 }, // Flying Bat
Sh2Row { id: 132, ra_deg: 336.550, dec_deg: 56.133, diam_arcmin: 100.0 }, // Lion
Sh2Row { id: 140, ra_deg: 336.550, dec_deg: 63.183, diam_arcmin: 10.0 }, // Cepheus SFR
Sh2Row { id: 142, ra_deg: 341.467, dec_deg: 58.433, diam_arcmin: 12.0 }, // Wizard region
Sh2Row { id: 155, ra_deg: 344.983, dec_deg: 62.383, diam_arcmin: 50.0 }, // Cave
Sh2Row { id: 157, ra_deg: 350.817, dec_deg: 60.867, diam_arcmin: 60.0 }, // Lobster Claw
Sh2Row { id: 162, ra_deg: 350.183, dec_deg: 61.217, diam_arcmin: 15.0 }, // Bubble
Sh2Row { id: 163, ra_deg: 353.383, dec_deg: 61.117, diam_arcmin: 25.0 }, // Cas
Sh2Row { id: 168, ra_deg: 358.133, dec_deg: 61.383, diam_arcmin: 60.0 }, // Cas
Sh2Row { id: 171, ra_deg: 0.500, dec_deg: 67.833, diam_arcmin: 40.0 }, // Cep
Sh2Row { id: 175, ra_deg: 85.250, dec_deg: -2.450, diam_arcmin: 40.0 }, // Horsehead region
Sh2Row { id: 184, ra_deg: 13.533, dec_deg: 56.617, diam_arcmin: 35.0 }, // Pac-Man
Sh2Row { id: 188, ra_deg: 17.633, dec_deg: 58.783, diam_arcmin: 15.0 }, // Cas
Sh2Row { id: 190, ra_deg: 38.317, dec_deg: 61.450, diam_arcmin: 100.0 }, // Heart
Sh2Row { id: 199, ra_deg: 40.433, dec_deg: 60.517, diam_arcmin: 150.0 }, // Soul
Sh2Row { id: 206, ra_deg: 55.617, dec_deg: 19.917, diam_arcmin: 30.0 }, // Per
Sh2Row { id: 207, ra_deg: 56.583, dec_deg: 23.000, diam_arcmin: 15.0 }, // Per
Sh2Row { id: 212, ra_deg: 73.617, dec_deg: 44.217, diam_arcmin: 40.0 }, // Aur
Sh2Row { id: 219, ra_deg: 79.283, dec_deg: 45.150, diam_arcmin: 30.0 }, // Aur
Sh2Row { id: 220, ra_deg: 60.583, dec_deg: 36.417, diam_arcmin: 360.0 }, // California
Sh2Row { id: 223, ra_deg: 51.800, dec_deg: 60.067, diam_arcmin: 30.0 }, // Cas
Sh2Row { id: 224, ra_deg: 53.833, dec_deg: 60.700, diam_arcmin: 25.0 }, // Cas
Sh2Row { id: 229, ra_deg: 82.750, dec_deg: 34.317, diam_arcmin: 80.0 }, // Flaming Star
Sh2Row { id: 232, ra_deg: 86.117, dec_deg: 33.450, diam_arcmin: 30.0 }, // Aur
Sh2Row { id: 234, ra_deg: 90.133, dec_deg: 37.283, diam_arcmin: 15.0 }, // Aur
Sh2Row { id: 235, ra_deg: 92.383, dec_deg: 36.633, diam_arcmin: 10.0 }, // Aur
Sh2Row { id: 240, ra_deg: 92.683, dec_deg: 27.767, diam_arcmin: 25.0 }, // Per
Sh2Row { id: 241, ra_deg: 53.417, dec_deg: 31.500, diam_arcmin: 10.0 }, // Per
Sh2Row { id: 252, ra_deg: 99.500, dec_deg: 17.983, diam_arcmin: 60.0 }, // Monkey Head
Sh2Row { id: 254, ra_deg: 98.233, dec_deg: 15.833, diam_arcmin: 10.0 }, // Mon
Sh2Row { id: 261, ra_deg: 107.417, dec_deg: -1.167, diam_arcmin: 40.0 }, // Mon
Sh2Row { id: 273, ra_deg: 117.750, dec_deg: -10.117, diam_arcmin: 20.0 }, // CMa
Sh2Row { id: 274, ra_deg: 113.567, dec_deg: 10.050, diam_arcmin: 30.0 }, // Gem / Mon
Sh2Row { id: 275, ra_deg: 115.617, dec_deg: -11.317, diam_arcmin: 50.0 }, // CMa
Sh2Row { id: 277, ra_deg: 119.083, dec_deg: -9.233, diam_arcmin: 35.0 }, // CMa
Sh2Row { id: 280, ra_deg: 128.600, dec_deg: -17.617, diam_arcmin: 50.0 }, // Pup
Sh2Row { id: 284, ra_deg: 126.883, dec_deg: -3.333, diam_arcmin: 20.0 }, // Mon
Sh2Row { id: 287, ra_deg: 161.333, dec_deg: -59.883, diam_arcmin: 240.0 },// Eta Carina
Sh2Row { id: 289, ra_deg: 131.217, dec_deg: -39.467, diam_arcmin: 80.0 }, // Pup
Sh2Row { id: 292, ra_deg: 135.617, dec_deg: -23.350, diam_arcmin: 40.0 }, // Pup
// Summer (Sagittarius, Scorpius, Aquila, Cygnus, Vulpecula)
Sh2Row { id: 302, ra_deg: 186.967, dec_deg: -62.617, diam_arcmin: 80.0 }, // Cru
Sh2Row { id: 308, ra_deg: 107.800, dec_deg: -14.683, diam_arcmin: 40.0 }, // Dolphin
// Extra Sh2 objects with known popular names
Sh2Row { id: 71, ra_deg: 302.800, dec_deg: 22.717, diam_arcmin: 8.0 }, // Dumbbell PN area
Sh2Row { id: 72, ra_deg: 271.967, dec_deg: -22.533, diam_arcmin: 6.0 }, // Little Ghost area
Sh2Row { id: 83, ra_deg: 283.400, dec_deg: 33.033, diam_arcmin: 4.0 }, // Ring PN area
Sh2Row { id: 87, ra_deg: 298.733, dec_deg: 50.517, diam_arcmin: 5.0 }, // Blinking PN area
Sh2Row { id: 105, ra_deg: 280.650, dec_deg: 23.533, diam_arcmin: 3.0 }, // Turtle PN area
Sh2Row { id: 107, ra_deg: 307.483, dec_deg: 42.133, diam_arcmin: 2.0 }, // Giraffe PN area
Sh2Row { id: 12, ra_deg: 260.583, dec_deg: -37.100, diam_arcmin: 12.0 }, // Bug PN area
Sh2Row { id: 84, ra_deg: 321.033, dec_deg: -11.367, diam_arcmin: 3.0 }, // Saturn PN area
]
}