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>
This commit is contained in:
2026-04-17 07:20:10 +02:00
parent 8f72745bc0
commit 2bb80a8475
45 changed files with 5613 additions and 628 deletions
+129
View File
@@ -140,6 +140,135 @@ pub async fn get_calendar(
Ok(Json(serde_json::json!({ "days": days })))
}
/// Returns the next 14 nights ranked by composite night score.
pub async fn get_best_nights(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
use sqlx::Row;
let today = chrono::Utc::now().naive_utc().date();
let mut nights: Vec<serde_json::Value> = Vec::new();
for i in 0..14i64 {
let date = today + chrono::Duration::days(i);
let date_str = date.to_string();
let moon_illum = moon_illum_for_date(date);
// Pull nightly cache stats for this night
let cache_row = sqlx::query(
r#"SELECT
COUNT(CASE WHEN nc.max_alt_deg >= 15 THEN 1 END) as visible_count,
AVG(CASE WHEN nc.max_alt_deg >= 30 THEN nc.usable_min ELSE NULL END) as avg_usable_min
FROM nightly_cache nc
WHERE nc.night_date = ?"#,
)
.bind(&date_str)
.fetch_optional(&state.pool)
.await?;
let (visible_count, avg_usable_min): (i64, f64) = cache_row.as_ref().map(|r| (
r.try_get::<Option<i64>, _>("visible_count").unwrap_or_default().unwrap_or(0),
r.try_get::<Option<f64>, _>("avg_usable_min").unwrap_or_default().unwrap_or(0.0),
)).unwrap_or((0, 0.0));
// Moon score: low illumination = better
let moon_score = 1.0 - moon_illum;
// Dark hours score: normalize to 01 assuming max useful ~480 min
let dark_score = (avg_usable_min / 480.0).min(1.0);
// Composite (no weather score for future nights without forecast)
let score = moon_score * 0.5 + dark_score * 0.5;
// Top 3 targets for this night
let top_targets_rows = sqlx::query(
r#"SELECT c.id, c.name, c.common_name, c.obj_type, nc.max_alt_deg, nc.usable_min, nc.recommended_filter
FROM nightly_cache nc
JOIN catalog c ON c.id = nc.catalog_id
WHERE nc.night_date = ? AND nc.max_alt_deg >= 20
ORDER BY nc.max_alt_deg DESC
LIMIT 3"#,
)
.bind(&date_str)
.fetch_all(&state.pool)
.await?;
let top_targets: Vec<serde_json::Value> = top_targets_rows.iter().map(|r| serde_json::json!({
"id": r.try_get::<String, _>("id").unwrap_or_default(),
"name": r.try_get::<String, _>("name").unwrap_or_default(),
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
"usable_min": r.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
})).collect();
nights.push(serde_json::json!({
"date": date_str,
"score": (score * 100.0) as u32,
"moon_illumination": moon_illum,
"visible_count": visible_count,
"avg_usable_min": avg_usable_min as u32,
"top_targets": top_targets,
}));
}
// Sort by score descending
nights.sort_by(|a, b| {
let sa = a["score"].as_u64().unwrap_or(0);
let sb = b["score"].as_u64().unwrap_or(0);
sb.cmp(&sa)
});
Ok(Json(serde_json::json!({ "nights": nights })))
}
/// Returns top 5 targets that peak this calendar month, preferring not-yet-imaged.
pub async fn get_monthly_highlights(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
use sqlx::Row;
let today = chrono::Utc::now().naive_utc().date();
let month_prefix = today.format("%Y-%m").to_string();
let rows = sqlx::query(
r#"SELECT
c.id, c.name, c.common_name, c.obj_type, c.constellation,
MAX(nc.max_alt_deg) as peak_alt,
MAX(nc.usable_min) as best_usable,
nc.recommended_filter,
nc.transit_utc,
(SELECT COUNT(*) FROM imaging_log il
WHERE il.catalog_id = c.id AND il.quality = 'keeper') as keeper_count
FROM nightly_cache nc
JOIN catalog c ON c.id = nc.catalog_id
WHERE nc.night_date LIKE ?
AND nc.max_alt_deg >= 20
AND nc.is_visible_tonight = 1
GROUP BY c.id
ORDER BY keeper_count ASC, peak_alt DESC
LIMIT 8"#,
)
.bind(format!("{}%", month_prefix))
.fetch_all(&state.pool)
.await?;
let highlights: Vec<serde_json::Value> = rows.iter().map(|r| serde_json::json!({
"id": r.try_get::<String, _>("id").unwrap_or_default(),
"name": r.try_get::<String, _>("name").unwrap_or_default(),
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
"constellation": r.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
"peak_alt": r.try_get::<Option<f64>, _>("peak_alt").unwrap_or_default(),
"best_usable_min": r.try_get::<Option<i32>, _>("best_usable").unwrap_or_default(),
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
"keeper_count": r.try_get::<i64, _>("keeper_count").unwrap_or_default(),
})).collect();
Ok(Json(serde_json::json!({
"month": month_prefix,
"highlights": highlights,
})))
}
pub async fn get_calendar_date(
State(state): State<AppState>,
Path(date): Path<String>,
+3 -3
View File
@@ -30,7 +30,7 @@ pub async fn list_all_gallery(
"id": id,
"catalog_id": &catalog_id,
"filename": &filename,
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
"target_name": r.try_get::<Option<String>, _>("target_name").unwrap_or_default(),
@@ -60,7 +60,7 @@ pub async fn list_gallery(
"id": id,
"catalog_id": catalog_id,
"filename": filename,
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
})
@@ -147,7 +147,7 @@ pub async fn upload_image(
"id": id,
"catalog_id": catalog_id,
"filename": filename,
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
})))
}
+5 -1
View File
@@ -66,12 +66,15 @@ pub fn build_router(pool: SqlitePool) -> Router {
.route("/api/targets/:id/filters", get(targets::get_filters))
.route("/api/targets/:id/workflow/:filter_id", get(targets::get_workflow_handler))
.route("/api/targets/:id/yearly", get(targets::get_yearly))
.route("/api/targets/:id/similar", get(targets::get_similar))
.route("/api/targets/:id/notes", get(targets::get_notes).put(targets::put_notes))
// Tonight
.route("/api/tonight", get(tonight::get_tonight))
// Calendar
.route("/api/calendar", get(calendar::get_calendar))
.route("/api/calendar/new-moon-windows", get(calendar::get_new_moon_windows))
.route("/api/calendar/best-nights", get(calendar::get_best_nights))
.route("/api/calendar/monthly-highlights", get(calendar::get_monthly_highlights))
.route("/api/calendar/:date", get(calendar::get_calendar_date))
// Weather
.route("/api/weather", get(weather::get_weather))
@@ -103,8 +106,9 @@ pub fn build_router(pool: SqlitePool) -> Router {
// Stats
.route("/api/stats", get(stats::get_stats))
// Static gallery files served via tower-http
// Must be under /api/ so nginx proxies it to the backend, not the frontend.
.nest_service(
"/data/gallery",
"/api/gallery/files",
tower_http::services::ServeDir::new(gallery_dir),
)
.with_state(state)
+2 -2
View File
@@ -73,7 +73,7 @@ pub struct SolarSystemObject {
pub is_visible: bool, // alt > 15°
}
fn fmt_ra(ra: f64) -> String {
pub fn fmt_ra(ra: f64) -> String {
let total_sec = (ra / 15.0) * 3600.0;
let h = (total_sec / 3600.0) as u32;
let m = ((total_sec % 3600.0) / 60.0) as u32;
@@ -81,7 +81,7 @@ fn fmt_ra(ra: f64) -> String {
format!("{:02}h {:02}m {:02}s", h, m, s)
}
fn fmt_dec(dec: f64) -> String {
pub fn fmt_dec(dec: f64) -> String {
let sign = if dec < 0.0 { "-" } else { "+" };
let abs = dec.abs();
let d = abs as u32;
+166
View File
@@ -137,6 +137,168 @@ pub async fn get_stats(
})
}).collect();
// Integration gap detector: targets with one narrowband filter but missing the companion.
// Pairs: sv220 ↔ c2 (for full SHO palette), uvir ↔ sv260 (broadband pair).
let gaps_raw = sqlx::query(
r#"SELECT
l.catalog_id,
c.name,
c.common_name,
c.obj_type,
SUM(CASE WHEN l.filter_id = 'sv220' THEN l.integration_min ELSE 0 END) as sv220_min,
SUM(CASE WHEN l.filter_id = 'c2' THEN l.integration_min ELSE 0 END) as c2_min,
SUM(CASE WHEN l.filter_id = 'uvir' THEN l.integration_min ELSE 0 END) as uvir_min,
SUM(CASE WHEN l.filter_id = 'sv260' THEN l.integration_min ELSE 0 END) as sv260_min
FROM imaging_log l
JOIN catalog c ON c.id = l.catalog_id
WHERE l.quality IN ('keeper', 'needs_more')
GROUP BY l.catalog_id
HAVING (sv220_min > 0 AND c2_min = 0)
OR (c2_min > 0 AND sv220_min = 0)
OR (uvir_min > 0 AND sv260_min = 0)
OR (sv260_min > 0 AND uvir_min = 0)
ORDER BY (sv220_min + c2_min + uvir_min + sv260_min) DESC
LIMIT 10"#,
)
.fetch_all(&state.pool)
.await?;
let gaps: Vec<serde_json::Value> = gaps_raw.iter().map(|r| {
use sqlx::Row;
let sv220_min: i64 = r.try_get("sv220_min").unwrap_or(0);
let c2_min: i64 = r.try_get("c2_min").unwrap_or(0);
let uvir_min: i64 = r.try_get("uvir_min").unwrap_or(0);
let sv260_min: i64 = r.try_get("sv260_min").unwrap_or(0);
let mut missing: Vec<&str> = Vec::new();
if sv220_min > 0 && c2_min == 0 { missing.push("c2"); }
if c2_min > 0 && sv220_min == 0 { missing.push("sv220"); }
if uvir_min > 0 && sv260_min == 0 { missing.push("sv260"); }
if sv260_min > 0 && uvir_min == 0 { missing.push("uvir"); }
serde_json::json!({
"catalog_id": r.try_get::<String, _>("catalog_id").unwrap_or_default(),
"name": r.try_get::<String, _>("name").unwrap_or_default(),
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
"sv220_min": sv220_min,
"c2_min": c2_min,
"uvir_min": uvir_min,
"sv260_min": sv260_min,
"missing_filters": missing,
})
}).collect();
// Catalogue completion: per-catalogue keeper counts vs total observable
struct CatEntry { name: &'static str, sql_filter: &'static str }
let catalogues: &[CatEntry] = &[
CatEntry { name: "Messier", sql_filter: "c.messier_num IS NOT NULL" },
CatEntry { name: "Caldwell", sql_filter: "c.caldwell_num IS NOT NULL" },
CatEntry { name: "Sharpless", sql_filter: "c.id LIKE 'Sh2-%'" },
CatEntry { name: "LDN", sql_filter: "c.id LIKE 'LDN%'" },
CatEntry { name: "VdB", sql_filter: "c.id LIKE 'VdB%'" },
CatEntry { name: "NGC", sql_filter: "c.id LIKE 'NGC%'" },
CatEntry { name: "IC", sql_filter: "c.id LIKE 'IC%'" },
];
let mut catalogue_completion: Vec<serde_json::Value> = Vec::new();
for cat in catalogues {
let total_sql = format!("SELECT COUNT(DISTINCT c.id) FROM catalog c WHERE {}", cat.sql_filter);
let keeper_sql = format!(
"SELECT COUNT(DISTINCT l.catalog_id) FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id WHERE l.quality = 'keeper' AND {}",
cat.sql_filter
);
let total: i64 = sqlx::query_scalar::<_, i64>(&total_sql)
.fetch_one(&state.pool).await.unwrap_or(0);
let keepers: i64 = sqlx::query_scalar::<_, i64>(&keeper_sql)
.fetch_one(&state.pool).await.unwrap_or(0);
if total > 0 {
catalogue_completion.push(serde_json::json!({
"name": cat.name,
"total": total,
"keepers": keepers,
"pct": if total > 0 { (keepers as f64 / total as f64 * 100.0).round() as i64 } else { 0 },
}));
}
}
// Session history timeline: all sessions with first gallery image if available
let history_rows = sqlx::query(
r#"SELECT
l.session_date,
l.catalog_id,
COALESCE(c.name, l.catalog_id) as name,
c.common_name,
c.obj_type,
l.filter_id,
l.integration_min,
l.quality,
l.notes,
g.filename as gallery_filename
FROM imaging_log l
LEFT JOIN catalog c ON c.id = l.catalog_id
LEFT JOIN (
SELECT catalog_id, MIN(filename) as filename
FROM gallery
GROUP BY catalog_id
) g ON g.catalog_id = l.catalog_id
ORDER BY l.session_date DESC, l.created_at DESC
LIMIT 500"#,
)
.fetch_all(&state.pool)
.await?;
let history: Vec<serde_json::Value> = history_rows.iter().map(|r| {
use sqlx::Row;
let catalog_id: String = r.try_get("catalog_id").unwrap_or_default();
let gallery_filename: Option<String> = r.try_get("gallery_filename").unwrap_or_default();
let gallery_url = gallery_filename.map(|f| format!("/api/gallery/files/{}/{}", catalog_id, f));
serde_json::json!({
"date": r.try_get::<String, _>("session_date").unwrap_or_default(),
"catalog_id": catalog_id,
"name": r.try_get::<String, _>("name").unwrap_or_default(),
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": r.try_get::<Option<String>, _>("obj_type").unwrap_or_default(),
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
"integration_min": r.try_get::<i64, _>("integration_min").unwrap_or(0),
"quality": r.try_get::<String, _>("quality").unwrap_or_default(),
"notes": r.try_get::<Option<String>, _>("notes").unwrap_or_default(),
"gallery_url": gallery_url,
})
}).collect();
// Integration goals: keeper minutes per filter per target, for goal progress tracking
let goals_raw = sqlx::query(
r#"SELECT
c.id, c.name, c.common_name, c.obj_type,
SUM(CASE WHEN l.filter_id = 'sv220' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as sv220_min,
SUM(CASE WHEN l.filter_id = 'c2' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as c2_min,
SUM(CASE WHEN l.filter_id = 'uvir' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as uvir_min,
SUM(CASE WHEN l.filter_id = 'sv260' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as sv260_min
FROM imaging_log l
JOIN catalog c ON c.id = l.catalog_id
WHERE l.quality = 'keeper'
GROUP BY l.catalog_id
ORDER BY (sv220_min + c2_min + uvir_min + sv260_min) DESC
LIMIT 30"#,
)
.fetch_all(&state.pool)
.await?;
let integration_goals: Vec<serde_json::Value> = goals_raw.iter().map(|r| {
use sqlx::Row;
serde_json::json!({
"id": r.try_get::<String, _>("id").unwrap_or_default(),
"name": r.try_get::<String, _>("name").unwrap_or_default(),
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
"sv220_min": r.try_get::<i64, _>("sv220_min").unwrap_or(0),
"c2_min": r.try_get::<i64, _>("c2_min").unwrap_or(0),
"uvir_min": r.try_get::<i64, _>("uvir_min").unwrap_or(0),
"sv260_min": r.try_get::<i64, _>("sv260_min").unwrap_or(0),
})
}).collect();
Ok(Json(serde_json::json!({
"total_sessions": total_sessions,
"total_integration_min": total_integration_min.unwrap_or(0),
@@ -147,5 +309,9 @@ pub async fn get_stats(
"quality": quality_stats,
"top_targets": top_target_list,
"guiding": guiding_data,
"integration_gaps": gaps,
"history": history,
"catalogue_completion": catalogue_completion,
"integration_goals": integration_goals,
})))
}
+362 -101
View File
@@ -8,12 +8,46 @@ use crate::{
astronomy::{
astro_twilight, compute_visibility, compute_visibility_with_step, julian_date, moon_altitude, moon_illumination,
moon_position, HorizonPoint, MoonState, TonightWindow,
coords::radec_to_altaz,
time::local_sidereal_time,
},
config::{LAT, LON},
filters::{get_workflow, recommend_filters},
};
use super::{AppError, AppState};
use super::solar_system::{fmt_ra, fmt_dec};
/// Look up (ra_deg, dec_deg, obj_type) from catalog first, then custom_targets.
/// Returns None if the target is not found in either table or has no coordinates.
async fn lookup_coords(pool: &sqlx::SqlitePool, id: &str) -> Result<Option<(f64, f64, String)>, AppError> {
use sqlx::Row;
if let Some(row) = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
{
return Ok(Some((
row.try_get("ra_deg").unwrap_or_default(),
row.try_get("dec_deg").unwrap_or_default(),
row.try_get("obj_type").unwrap_or_default(),
)));
}
if let Some(row) = sqlx::query(
"SELECT ra_deg, dec_deg, obj_type FROM custom_targets WHERE id = ? AND ra_deg IS NOT NULL AND dec_deg IS NOT NULL",
)
.bind(id)
.fetch_optional(pool)
.await?
{
return Ok(Some((
row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
row.try_get::<String, _>("obj_type").unwrap_or_else(|_| "custom".to_string()),
)));
}
Ok(None)
}
#[derive(Debug, Deserialize)]
pub struct TargetsQuery {
@@ -30,6 +64,7 @@ pub struct TargetsQuery {
pub min_usable_min: Option<i32>,
pub mosaic_only: Option<bool>,
pub not_imaged: Option<bool>,
pub show_custom: Option<bool>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
@@ -81,8 +116,17 @@ pub async fn list_targets(
let mut bind_values: Vec<String> = vec![];
if let Some(ref t) = params.obj_type {
conditions.push("c.obj_type = ?".to_string());
bind_values.push(t.clone());
let types: Vec<&str> = t.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if types.len() == 1 {
conditions.push("c.obj_type = ?".to_string());
bind_values.push(types[0].to_string());
} else if !types.is_empty() {
let placeholders = types.iter().map(|_| "?").collect::<Vec<_>>().join(",");
conditions.push(format!("c.obj_type IN ({})", placeholders));
for ty in &types {
bind_values.push(ty.to_string());
}
}
}
if let Some(ref con) = params.constellation {
conditions.push("c.constellation = ?".to_string());
@@ -92,7 +136,7 @@ pub async fn list_targets(
// Filter by filter suitability: use object-type compatibility, not moon-state-dependent recommended_filter.
// This ensures these filters always return results regardless of current moon phase.
match f.as_str() {
"uvir" => conditions.push("c.obj_type IN ('galaxy', 'reflection_nebula', 'open_cluster', 'globular_cluster', 'dark_nebula')".to_string()),
"uvir" => conditions.push("c.obj_type IN ('galaxy', 'galaxy_cluster', 'reflection_nebula', 'open_cluster', 'globular_cluster', 'dark_nebula')".to_string()),
"c2" | "sv220" => conditions.push("c.obj_type IN ('emission_nebula', 'snr', 'planetary_nebula')".to_string()),
"sv260" => {}, // LP filter works for all object types — no restriction
_ => {
@@ -120,9 +164,9 @@ pub async fn list_targets(
// (e.g. globular clusters, dark nebulae, open clusters) still appear.
// Skip filter when search is active so you can find objects like M31 off-season.
if tonight_filter && params.search.as_deref().unwrap_or("").is_empty() {
// Allow objects with no nightly_cache entry yet (newly ingested, NULL max_alt_deg)
// so freshly added VdB/LDN objects are visible before the first nightly precompute.
conditions.push("(nc.max_alt_deg >= 15 OR nc.max_alt_deg IS NULL)".to_string());
// Use horizon-aware is_visible_tonight flag stored in nightly_cache.
// Fall back to max_alt_deg >= 15 for rows that predate the flag (NULL).
conditions.push("(nc.is_visible_tonight = 1 OR (nc.is_visible_tonight IS NULL AND nc.max_alt_deg >= 15) OR nc.catalog_id IS NULL)".to_string());
}
if let Some(ref s) = params.search {
let like = format!("%{}%", s);
@@ -130,6 +174,15 @@ pub async fn list_targets(
let m_num: Option<i32> = s.trim()
.strip_prefix(['M', 'm'])
.and_then(|n| n.parse().ok());
// Support C-number search (e.g. "C20" → caldwell_num = 20)
let c_num: Option<i32> = s.trim()
.strip_prefix(['C', 'c'])
.and_then(|n| n.parse().ok());
// Support Arp search (e.g. "Arp85" → arp_num = 85)
let arp_num: Option<i32> = s.trim()
.to_lowercase()
.strip_prefix("arp")
.and_then(|n| n.trim().parse().ok());
if let Some(m) = m_num {
conditions.push(format!(
"(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ? OR c.messier_num = {})",
@@ -138,6 +191,21 @@ pub async fn list_targets(
bind_values.push(like.clone());
bind_values.push(like.clone());
bind_values.push(like);
} else if let Some(c) = c_num {
conditions.push(format!(
"(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ? OR c.caldwell_num = {})",
c
));
bind_values.push(like.clone());
bind_values.push(like.clone());
bind_values.push(like);
} else if let Some(a) = arp_num {
conditions.push(format!(
"(c.name LIKE ? OR c.common_name LIKE ? OR c.arp_num = {})",
a
));
bind_values.push(like.clone());
bind_values.push(like);
} else {
conditions.push("(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ?)".to_string());
bind_values.push(like.clone());
@@ -147,10 +215,26 @@ pub async fn list_targets(
}
let where_clause = conditions.join(" AND ");
// Fetch weather weight: go=1.0, marginal=0.7, nogo=0.3 (default 1.0 if no forecast)
let weather_weight: f64 = sqlx::query_scalar::<_, Option<String>>(
"SELECT go_nogo FROM weather_cache WHERE id = 1"
)
.fetch_optional(&state.pool)
.await
.unwrap_or(None)
.flatten()
.map(|s| match s.as_str() {
"go" => 1.0,
"marginal" => 0.7,
"nogo" => 0.3,
_ => 1.0,
})
.unwrap_or(1.0);
// "best" sort: composite score balancing altitude, FOV fill, usable time, moon separation.
// Score = alt_score * 0.4 + fill_score * 0.3 + time_score * 0.2 + moon_score * 0.1
// Targets outside 20150% FOV fill are penalised (too small or too large single-panel).
let best_score_expr = r#"(
// Multiplied by weather_weight so cloudy nights rank all targets lower.
let best_score_expr = format!(r#"(
COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40
+ CASE
WHEN c.fov_fill_pct IS NULL THEN 0.15
@@ -160,33 +244,47 @@ pub async fn list_targets(
END
+ MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * 0.20
+ COALESCE(nc.moon_sep_deg, 90) / 180.0 * 0.10
) DESC"#;
let sort_col = match params.sort.as_deref() {
Some("transit") => "nc.transit_utc",
Some("size") => "c.size_arcmin_maj DESC",
Some("magnitude") => "c.mag_v",
Some("difficulty") => "c.difficulty",
Some("integration") => "total_integration DESC",
Some("altitude") => "nc.max_alt_deg DESC",
) * {weather_weight:.2} DESC"#, weather_weight = weather_weight);
let sort_col_owned: String = match params.sort.as_deref() {
Some("transit") => "nc.transit_utc".to_string(),
Some("size") => "c.size_arcmin_maj DESC".to_string(),
Some("magnitude") => "c.mag_v".to_string(),
Some("difficulty") => "c.difficulty".to_string(),
Some("integration") => "total_integration DESC".to_string(),
Some("altitude") => "nc.max_alt_deg DESC".to_string(),
Some("best_start") => "nc.best_start_utc ASC NULLS LAST".to_string(),
_ => best_score_expr,
};
let sort_col = sort_col_owned.as_str();
// Compare tonight's altitude to the seasonal peak (max over next 90 days).
// urgency: 'peak' >= 90%, 'rising' = 7090% and date < peak date, 'declining' = 7090% and date >= peak date, else null
let sql = format!(
r#"
SELECT c.id, c.name, c.common_name, c.obj_type, c.ra_deg, c.dec_deg, c.ra_h, c.dec_dms,
c.constellation, c.size_arcmin_maj, c.size_arcmin_min, c.mag_v, c.surface_brightness,
c.hubble_type, c.messier_num, c.is_highlight, c.fov_fill_pct, c.mosaic_flag,
c.hubble_type, c.messier_num, c.caldwell_num, c.arp_num, c.is_highlight, c.fov_fill_pct, c.mosaic_flag,
c.mosaic_panels_w, c.mosaic_panels_h, c.difficulty, c.guide_star_density,
nc.max_alt_deg, nc.usable_min, nc.transit_utc, nc.recommended_filter,
nc.best_start_utc, nc.best_end_utc, nc.moon_sep_deg,
CASE WHEN nc.max_alt_deg >= 15 THEN 1 ELSE 0 END as is_visible_tonight,
COALESCE(log_sum.total_min, 0) as total_integration
COALESCE(nc.is_visible_tonight, CASE WHEN nc.max_alt_deg >= 15 THEN 1 ELSE 0 END) as is_visible_tonight,
COALESCE(log_sum.total_min, 0) as total_integration,
seas.peak_alt as seasonal_peak_alt,
seas.peak_date as seasonal_peak_date
FROM catalog c
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}'
LEFT JOIN (
SELECT catalog_id, SUM(integration_min) as total_min
FROM imaging_log GROUP BY catalog_id
) log_sum ON log_sum.catalog_id = c.id
LEFT JOIN (
SELECT catalog_id,
MAX(max_alt_deg) as peak_alt,
MIN(CASE WHEN max_alt_deg = (SELECT MAX(max_alt_deg) FROM nightly_cache n2 WHERE n2.catalog_id = n1.catalog_id AND n2.night_date BETWEEN '{today}' AND date('{today}', '+90 days')) THEN night_date ELSE NULL END) as peak_date
FROM nightly_cache n1
WHERE night_date BETWEEN '{today}' AND date('{today}', '+90 days')
GROUP BY catalog_id
) seas ON seas.catalog_id = c.id
WHERE {where_clause}
ORDER BY {sort_col}
LIMIT {limit} OFFSET {offset}
@@ -209,8 +307,24 @@ pub async fn list_targets(
.await
.map_err(AppError::from)?;
let items: Vec<serde_json::Value> = rows.iter().map(|row| {
let mut items: Vec<serde_json::Value> = rows.iter().map(|row| {
use sqlx::Row;
let tonight_alt: f64 = row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default().unwrap_or(0.0);
let peak_alt: f64 = row.try_get::<Option<f64>, _>("seasonal_peak_alt").unwrap_or_default().unwrap_or(0.0);
let peak_date: Option<String> = row.try_get("seasonal_peak_date").unwrap_or_default();
let urgency: serde_json::Value = if peak_alt >= 15.0 && tonight_alt >= 15.0 {
let ratio = tonight_alt / peak_alt;
if ratio >= 0.90 {
serde_json::json!("peak")
} else if ratio >= 0.70 {
let before_peak = peak_date.as_deref().map(|d| d > today.as_str()).unwrap_or(true);
serde_json::json!(if before_peak { "rising" } else { "declining" })
} else {
serde_json::Value::Null
}
} else {
serde_json::Value::Null
};
serde_json::json!({
"id": row.try_get::<String, _>("id").unwrap_or_default(),
"name": row.try_get::<String, _>("name").unwrap_or_default(),
@@ -227,6 +341,8 @@ pub async fn list_targets(
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
"caldwell_num": row.try_get::<Option<i32>, _>("caldwell_num").unwrap_or_default(),
"arp_num": row.try_get::<Option<i32>, _>("arp_num").unwrap_or_default(),
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
@@ -234,7 +350,7 @@ pub async fn list_targets(
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
"max_alt_deg": row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
"max_alt_deg": tonight_alt,
"usable_min": row.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
"transit_utc": row.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
"recommended_filter": row.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
@@ -243,6 +359,8 @@ pub async fn list_targets(
"moon_sep_deg": row.try_get::<Option<f64>, _>("moon_sep_deg").unwrap_or_default(),
"is_visible_tonight": row.try_get::<Option<bool>, _>("is_visible_tonight").unwrap_or_default(),
"total_integration_min": row.try_get::<i64, _>("total_integration").unwrap_or(0),
"is_custom": false,
"urgency": urgency,
})
}).collect();
@@ -262,7 +380,82 @@ pub async fn list_targets(
for val in &bind_values {
count_query = count_query.bind(val);
}
let total: i64 = count_query.fetch_one(&state.pool).await.unwrap_or(0);
let mut total: i64 = count_query.fetch_one(&state.pool).await.unwrap_or(0);
// Append custom targets when show_custom is not explicitly false.
// Only on page 1, and only when not mosaic_only or not_imaged filter is active.
let show_custom = params.show_custom.unwrap_or(true);
if show_custom && page == 1 && !params.mosaic_only.unwrap_or(false) {
let custom_rows = sqlx::query(
"SELECT id, name, obj_type, ra_deg, dec_deg, notes FROM custom_targets WHERE ra_deg IS NOT NULL AND dec_deg IS NOT NULL ORDER BY created_at DESC",
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
if !custom_rows.is_empty() {
let jd = julian_date(chrono::Utc::now());
let lst = local_sidereal_time(jd, LON);
// Apply search filter to custom targets if active
let search_lower = params.search.as_deref().unwrap_or("").to_lowercase();
for row in &custom_rows {
use sqlx::Row;
let id: String = row.try_get("id").unwrap_or_default();
let name: String = row.try_get("name").unwrap_or_default();
if !search_lower.is_empty()
&& !id.to_lowercase().contains(&search_lower)
&& !name.to_lowercase().contains(&search_lower)
{
continue;
}
let ra: f64 = row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = row.try_get("dec_deg").unwrap_or_default();
let obj_type: String = row.try_get("obj_type").unwrap_or_else(|_| "custom".to_string());
let (alt, _az) = radec_to_altaz(ra, dec, lst, LAT);
let current_alt = (alt * 10.0).round() / 10.0;
items.push(serde_json::json!({
"id": &id,
"name": &name,
"common_name": serde_json::Value::Null,
"obj_type": &obj_type,
"ra_deg": ra,
"dec_deg": dec,
"ra_h": fmt_ra(ra),
"dec_dms": fmt_dec(dec),
"constellation": serde_json::Value::Null,
"size_arcmin_maj": serde_json::Value::Null,
"size_arcmin_min": serde_json::Value::Null,
"mag_v": serde_json::Value::Null,
"surface_brightness": serde_json::Value::Null,
"hubble_type": serde_json::Value::Null,
"messier_num": serde_json::Value::Null,
"is_highlight": false,
"fov_fill_pct": serde_json::Value::Null,
"mosaic_flag": false,
"mosaic_panels_w": 1,
"mosaic_panels_h": 1,
"difficulty": serde_json::Value::Null,
"guide_star_density": serde_json::Value::Null,
"max_alt_deg": current_alt,
"usable_min": serde_json::Value::Null,
"transit_utc": serde_json::Value::Null,
"recommended_filter": serde_json::Value::Null,
"best_start_utc": serde_json::Value::Null,
"best_end_utc": serde_json::Value::Null,
"moon_sep_deg": serde_json::Value::Null,
"is_visible_tonight": alt > 15.0,
"total_integration_min": 0,
"is_custom": true,
}));
total += 1;
}
}
}
Ok(Json(serde_json::json!({
"items": items,
@@ -276,12 +469,14 @@ pub async fn get_target(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
use sqlx::Row;
// Support both NGC/IC IDs and M-number IDs (e.g. "M42")
let m_num: Option<i32> = id.trim()
.strip_prefix(['M', 'm'])
.and_then(|n| n.parse().ok());
let row = if let Some(n) = m_num {
let catalog_row = if let Some(n) = m_num {
sqlx::query("SELECT * FROM catalog WHERE messier_num = ?")
.bind(n)
.fetch_optional(&state.pool)
@@ -291,34 +486,76 @@ pub async fn get_target(
.bind(&id)
.fetch_optional(&state.pool)
.await?
}
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
};
if let Some(row) = catalog_row {
return Ok(Json(serde_json::json!({
"id": row.try_get::<String, _>("id").unwrap_or_default(),
"name": row.try_get::<String, _>("name").unwrap_or_default(),
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": row.try_get::<String, _>("obj_type").unwrap_or_default(),
"ra_deg": row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
"dec_deg": row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
"ra_h": row.try_get::<String, _>("ra_h").unwrap_or_default(),
"dec_dms": row.try_get::<String, _>("dec_dms").unwrap_or_default(),
"constellation": row.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
"size_arcmin_maj": row.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
"size_arcmin_min": row.try_get::<Option<f64>, _>("size_arcmin_min").unwrap_or_default(),
"pos_angle_deg": row.try_get::<Option<f64>, _>("pos_angle_deg").unwrap_or_default(),
"mag_v": row.try_get::<Option<f64>, _>("mag_v").unwrap_or_default(),
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
"caldwell_num": row.try_get::<Option<i32>, _>("caldwell_num").unwrap_or_default(),
"arp_num": row.try_get::<Option<i32>, _>("arp_num").unwrap_or_default(),
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
"mosaic_panels_w": row.try_get::<i32, _>("mosaic_panels_w").unwrap_or(1),
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
})));
}
// Fall back to custom_targets
let custom_row = sqlx::query("SELECT * FROM custom_targets WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
let ra: Option<f64> = custom_row.try_get("ra_deg").unwrap_or_default();
let dec: Option<f64> = custom_row.try_get("dec_deg").unwrap_or_default();
let ra_h = ra.map(fmt_ra).unwrap_or_default();
let dec_dms = dec.map(fmt_dec).unwrap_or_default();
use sqlx::Row;
Ok(Json(serde_json::json!({
"id": row.try_get::<String, _>("id").unwrap_or_default(),
"name": row.try_get::<String, _>("name").unwrap_or_default(),
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": row.try_get::<String, _>("obj_type").unwrap_or_default(),
"ra_deg": row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
"dec_deg": row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
"ra_h": row.try_get::<String, _>("ra_h").unwrap_or_default(),
"dec_dms": row.try_get::<String, _>("dec_dms").unwrap_or_default(),
"constellation": row.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
"size_arcmin_maj": row.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
"size_arcmin_min": row.try_get::<Option<f64>, _>("size_arcmin_min").unwrap_or_default(),
"pos_angle_deg": row.try_get::<Option<f64>, _>("pos_angle_deg").unwrap_or_default(),
"mag_v": row.try_get::<Option<f64>, _>("mag_v").unwrap_or_default(),
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
"mosaic_panels_w": row.try_get::<i32, _>("mosaic_panels_w").unwrap_or(1),
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
"id": custom_row.try_get::<String, _>("id").unwrap_or_default(),
"name": custom_row.try_get::<String, _>("name").unwrap_or_default(),
"common_name": serde_json::Value::Null,
"obj_type": custom_row.try_get::<String, _>("obj_type").unwrap_or_else(|_| "custom".to_string()),
"ra_deg": ra.unwrap_or(0.0),
"dec_deg": dec.unwrap_or(0.0),
"ra_h": ra_h,
"dec_dms": dec_dms,
"constellation": serde_json::Value::Null,
"size_arcmin_maj": serde_json::Value::Null,
"size_arcmin_min": serde_json::Value::Null,
"pos_angle_deg": serde_json::Value::Null,
"mag_v": serde_json::Value::Null,
"surface_brightness": serde_json::Value::Null,
"hubble_type": serde_json::Value::Null,
"messier_num": serde_json::Value::Null,
"is_highlight": false,
"fov_fill_pct": serde_json::Value::Null,
"mosaic_flag": false,
"mosaic_panels_w": 1,
"mosaic_panels_h": 1,
"difficulty": serde_json::Value::Null,
"guide_star_density": serde_json::Value::Null,
"notes": custom_row.try_get::<Option<String>, _>("notes").unwrap_or_default(),
"is_custom": true,
})))
}
@@ -361,16 +598,9 @@ pub async fn get_visibility(
}
async fn compute_visibility_live(state: &AppState, id: &str) -> Result<Json<serde_json::Value>, AppError> {
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
.bind(id)
.fetch_optional(&state.pool)
let (ra, dec, obj_type) = lookup_coords(&state.pool, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", id)))?;
let today = chrono::Utc::now().naive_utc().date();
let (dusk, dawn) = astro_twilight(today, LAT, LON)
@@ -421,15 +651,9 @@ pub async fn get_curve(
) -> Result<Json<serde_json::Value>, AppError> {
// Always compute live at 1-minute resolution for the interactive chart.
// The cached visibility_json uses 10-minute steps and lacks moon_alt_deg.
let cat_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
let (ra, dec, _obj_type) = lookup_coords(&state.pool, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", id)))?;
let date = chrono::Utc::now().naive_utc().date();
let (dusk, dawn) = astro_twilight(date, LAT, LON)
@@ -464,21 +688,17 @@ pub async fn get_filters(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
let (ra, dec, obj_type) = lookup_coords(&state.pool, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
let tonight_row = sqlx::query(
"SELECT moon_illumination, moon_ra_deg, moon_dec_deg FROM tonight WHERE id = 1",
)
.fetch_optional(&state.pool)
.await?;
use sqlx::Row;
let (moon_illum, moon_ra, moon_dec) = match tonight_row {
Some(r) => (
r.try_get::<Option<f64>, _>("moon_illumination").unwrap_or_default().unwrap_or(0.5),
@@ -491,19 +711,7 @@ pub async fn get_filters(
let now_jd = julian_date(chrono::Utc::now());
let moon_alt = moon_altitude(now_jd, LAT, LON);
let target_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
.await?;
let moon_sep = match target_row {
Some(r) => {
let ra: f64 = r.try_get("ra_deg").unwrap_or_default();
let dec: f64 = r.try_get("dec_deg").unwrap_or_default();
crate::astronomy::moon_separation(moon_ra, moon_dec, ra, dec)
}
None => 90.0,
};
let moon_sep = crate::astronomy::moon_separation(moon_ra, moon_dec, ra, dec);
let recs = recommend_filters(&obj_type, moon_illum * 100.0, moon_alt, moon_sep);
Ok(Json(serde_json::json!({ "recommendations": recs })))
@@ -513,14 +721,9 @@ pub async fn get_workflow_handler(
State(state): State<AppState>,
Path((id, filter_id)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, AppError> {
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
let (_ra, _dec, obj_type) = lookup_coords(&state.pool, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
let workflow = get_workflow(&obj_type, &filter_id);
Ok(Json(serde_json::to_value(workflow).unwrap()))
}
@@ -540,16 +743,9 @@ pub async fn get_yearly(
time::local_sidereal_time,
};
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
let (ra, dec, obj_type) = lookup_coords(&state.pool, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", id)))?;
// Transit altitude: maximum the object can ever reach (constant for a DSO).
let transit_alt = (90.0 - (LAT - dec).abs()).max(0.0_f64).min(90.0_f64);
@@ -603,6 +799,71 @@ pub async fn get_yearly(
Ok(Json(serde_json::json!({ "catalog_id": id, "points": points })))
}
pub async fn get_similar(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
use sqlx::Row;
// Get the target's constellation, obj_type, and transit_utc from tonight's cache
let target_row = sqlx::query(
"SELECT c.constellation, c.obj_type, c.ra_deg, nc.transit_utc FROM catalog c LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = date('now', 'localtime') WHERE c.id = ?",
)
.bind(&id)
.fetch_optional(&state.pool)
.await?;
let (constellation, obj_type, ra_deg, transit_utc): (Option<String>, String, f64, Option<String>) = match target_row {
Some(ref r) => (
r.try_get("constellation").unwrap_or(None),
r.try_get("obj_type").unwrap_or_default(),
r.try_get("ra_deg").unwrap_or_default(),
r.try_get("transit_utc").unwrap_or(None),
),
None => return Ok(Json(serde_json::json!({ "similar": [] }))),
};
// Find similar objects: same type + same constellation, ordered by RA proximity (≈ transit time proximity)
// RA difference of 15° ≈ 1h in transit time
let similar_rows = sqlx::query(
r#"SELECT c.id, c.name, c.common_name, c.obj_type, c.size_arcmin_maj,
c.fov_fill_pct, c.messier_num, nc.max_alt_deg, nc.transit_utc, nc.recommended_filter
FROM catalog c
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = date('now', 'localtime')
WHERE c.id != ?
AND c.obj_type = ?
AND c.constellation = ?
AND ABS(c.ra_deg - ?) <= 25.0
AND nc.max_alt_deg >= 15
ORDER BY ABS(c.ra_deg - ?) ASC
LIMIT 5"#,
)
.bind(&id)
.bind(&obj_type)
.bind(&constellation)
.bind(ra_deg)
.bind(ra_deg)
.fetch_all(&state.pool)
.await?;
let similar: Vec<serde_json::Value> = similar_rows.iter().map(|r| {
serde_json::json!({
"id": r.try_get::<String, _>("id").unwrap_or_default(),
"name": r.try_get::<String, _>("name").unwrap_or_default(),
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
"size_arcmin_maj": r.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
"fov_fill_pct": r.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
"messier_num": r.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
"transit_utc": r.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
})
}).collect();
Ok(Json(serde_json::json!({ "similar": similar, "target_transit": transit_utc })))
}
pub async fn get_notes(
State(state): State<AppState>,
Path(id): Path<String>,
+2 -2
View File
@@ -113,8 +113,8 @@ pub fn compute_visibility_with_step(
set_utc = Some(t);
}
// Best window: above 30°
if alt > 30.0 {
// Best window: above 30° AND above the custom horizon at this azimuth
if alt > 30.0_f64.max(h_alt) {
if best_start.is_none() {
best_start = Some(t);
}
+206
View File
@@ -0,0 +1,206 @@
/// Abell Galaxy Clusters (Abell 1958 + ACO 1989).
/// Source: VizieR VII/110A. Rich clusters (richness ≥ 1), Dec ≥ -30°.
/// These are galaxy clusters (type galaxy_cluster) — very faint, difficulty 4-5.
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_ABELL_GC_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
?-source=VII/110A\
&-out=Abell\
&-out=RAJ2000\
&-out=DEJ2000\
&-out=Rich\
&-out=m10\
&-out.max=3000\
&-oc.form=dec\
&Rich=>0";
#[derive(Debug, Clone)]
struct AbellGcRow {
id: u32,
ra_deg: f64,
dec_deg: f64,
richness: u32,
mag10: f64, // magnitude of 10th brightest galaxy
}
pub async fn fetch_abell_gc() -> anyhow::Result<Vec<CatalogEntry>> {
match fetch_from_vizier().await {
Ok(entries) if !entries.is_empty() => {
tracing::info!("Abell GC: loaded {} entries from VizieR VII/110A", entries.len());
Ok(entries)
}
Ok(_) => {
tracing::warn!("Abell GC: VizieR returned 0 rows — skipping (no fallback for 800+ clusters)");
Ok(vec![])
}
Err(e) => {
tracing::warn!("Abell GC fetch from VizieR failed ({}) — skipping", e);
Ok(vec![])
}
}
}
async fn fetch_from_vizier() -> anyhow::Result<Vec<CatalogEntry>> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.user_agent("astronome/1.0")
.build()?;
let text = client
.get(VIZIER_ABELL_GC_URL)
.send()
.await
.context("Abell GC fetch request failed")?
.text()
.await
.context("Abell GC response read failed")?;
let rows = parse_vizier_tsv(&text);
tracing::info!("Abell GC: parsed {} rows from VizieR VII/110A", rows.len());
if rows.is_empty() {
anyhow::bail!("no rows parsed from VizieR Abell GC response");
}
// Filter: Dec >= -30°, richness >= 1, reasonably bright (m10 < 20)
let filtered: Vec<AbellGcRow> = rows
.into_iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.richness >= 1)
.collect();
tracing::info!("Abell GC: {} rows pass filters", filtered.len());
Ok(build_entries_from_rows(filtered))
}
fn parse_vizier_tsv(text: &str) -> Vec<AbellGcRow> {
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("Abell")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<u32>().ok());
let ra = col_idx("RAJ2000")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok());
let dec = col_idx("DEJ2000")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok());
let richness = col_idx("Rich")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
let mag10 = col_idx("m10")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(18.0);
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
rows.push(AbellGcRow { id, ra_deg: ra, dec_deg: dec, richness, mag10 });
}
}
rows
}
fn build_entries_from_rows(rows: Vec<AbellGcRow>) -> 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.richness >= 1)
.map(|r| build_entry(r, now, &names))
.collect()
}
fn build_entry(
r: AbellGcRow,
now: i64,
names: &std::collections::HashMap<&'static str, &'static str>,
) -> CatalogEntry {
let id = format!("Abell{}", r.id);
// Typical Abell cluster: ~5-15 arcmin across
let diam_arcmin = 10.0_f64;
let fov_fill = (diam_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0;
let panels_w = ((diam_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1);
let panels_h = ((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();
// Difficulty based on mag10: fainter = harder
let difficulty = if r.mag10 < 16.0 { 4 } else { 5 };
CatalogEntry {
id: id.clone(),
name: id,
common_name,
obj_type: "galaxy_cluster".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(diam_arcmin),
size_arcmin_min: Some(diam_arcmin),
pos_angle_deg: None,
mag_v: Some(r.mag10),
surface_brightness: None,
hubble_type: Some(format!("Rich={} m10={:.1}", r.richness, r.mag10)),
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(difficulty),
guide_star_density: Some(density.to_string()),
fetched_at: now,
}
}
+209
View File
@@ -0,0 +1,209 @@
/// Abell Planetary Nebulae Catalogue (Abell 1966).
/// 86 large, low-surface-brightness PNe. Source: VizieR V/74.
/// These are ideal for narrowband imaging and not always in NGC/IC.
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_ABELL_PN_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
?-source=V/74\
&-out=Abell\
&-out=RAJ2000\
&-out=DEJ2000\
&-out=Diam\
&-out.max=200\
&-oc.form=dec";
#[derive(Debug, Clone)]
struct AbellPnRow {
id: u32,
ra_deg: f64,
dec_deg: f64,
diam_arcmin: f64,
}
pub async fn fetch_abell_pn() -> anyhow::Result<Vec<CatalogEntry>> {
match fetch_from_vizier().await {
Ok(entries) if !entries.is_empty() => {
tracing::info!("Abell PN: loaded {} entries from VizieR V/74", entries.len());
Ok(entries)
}
Ok(_) => {
tracing::warn!("Abell PN: VizieR returned 0 rows — using hardcoded fallback");
Ok(build_entries_from_rows(get_prominent_abell_pn()))
}
Err(e) => {
tracing::warn!("Abell PN fetch from VizieR failed ({}) — using hardcoded fallback", e);
Ok(build_entries_from_rows(get_prominent_abell_pn()))
}
}
}
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_ABELL_PN_URL)
.send()
.await
.context("Abell PN fetch request failed")?
.text()
.await
.context("Abell PN response read failed")?;
let rows = parse_vizier_tsv(&text);
tracing::info!("Abell PN: parsed {} rows from VizieR V/74", rows.len());
if rows.is_empty() {
anyhow::bail!("no rows parsed from VizieR Abell PN response");
}
let filtered: Vec<AbellPnRow> = rows
.into_iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 0.5)
.collect();
tracing::info!("Abell PN: {} rows pass filters", filtered.len());
Ok(build_entries_from_rows(filtered))
}
fn parse_vizier_tsv(text: &str) -> Vec<AbellPnRow> {
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("Abell")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<u32>().ok());
let ra = col_idx("RAJ2000")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok());
let dec = col_idx("DEJ2000")
.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(2.0);
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
rows.push(AbellPnRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
}
}
rows
}
fn build_entries_from_rows(rows: Vec<AbellPnRow>) -> 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 >= 0.5)
.map(|r| build_entry(r, now, &names))
.collect()
}
fn build_entry(
r: AbellPnRow,
now: i64,
names: &std::collections::HashMap<&'static str, &'static str>,
) -> CatalogEntry {
let id = format!("Abell{}", 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,
// Low SB Abell PNe — difficulty 5, narrowband only
obj_type: "planetary_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),
pos_angle_deg: None,
mag_v: None,
surface_brightness: None,
hubble_type: Some("low-SB PN — narrowband only".to_string()),
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(5),
guide_star_density: Some(density.to_string()),
fetched_at: now,
}
}
fn get_prominent_abell_pn() -> Vec<AbellPnRow> {
vec![
AbellPnRow { id: 7, ra_deg: 56.630, dec_deg: -22.070, diam_arcmin: 13.0 },
AbellPnRow { id: 21, ra_deg: 109.815, dec_deg: 13.228, diam_arcmin: 10.2 }, // Medusa Nebula
AbellPnRow { id: 31, ra_deg: 118.350, dec_deg: 8.904, diam_arcmin: 16.9 },
AbellPnRow { id: 33, ra_deg: 122.050, dec_deg: -2.840, diam_arcmin: 4.5 },
AbellPnRow { id: 35, ra_deg: 181.535, dec_deg: -22.158, diam_arcmin: 8.0 },
AbellPnRow { id: 36, ra_deg: 186.750, dec_deg: 19.925, diam_arcmin: 8.0 },
AbellPnRow { id: 39, ra_deg: 244.440, dec_deg: 27.795, diam_arcmin: 2.8 },
AbellPnRow { id: 43, ra_deg: 282.995, dec_deg: 5.650, diam_arcmin: 1.0 },
AbellPnRow { id: 50, ra_deg: 289.625, dec_deg: -7.145, diam_arcmin: 1.5 },
AbellPnRow { id: 72, ra_deg: 344.645, dec_deg: 13.690, diam_arcmin: 2.3 },
AbellPnRow { id: 74, ra_deg: 349.925, dec_deg: 4.665, diam_arcmin: 14.0 },
]
}
+205
View File
@@ -0,0 +1,205 @@
/// Barnard Catalogue of Dark Nebulae (E.E. Barnard, 1927).
/// Fetched from VizieR VII/220A. These are dark nebulae not always in LDN.
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_BARNARD_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
?-source=VII/220A\
&-out=Barn\
&-out=RAJ2000\
&-out=DEJ2000\
&-out=Diam\
&-out.max=1000\
&-oc.form=dec";
#[derive(Debug, Clone)]
struct BarnardRow {
id: u32,
ra_deg: f64,
dec_deg: f64,
diam_arcmin: f64,
}
pub async fn fetch_barnard() -> anyhow::Result<Vec<CatalogEntry>> {
match fetch_from_vizier().await {
Ok(entries) if !entries.is_empty() => {
tracing::info!("Barnard: loaded {} entries from VizieR VII/220A", entries.len());
Ok(entries)
}
Ok(_) => {
tracing::warn!("Barnard: VizieR returned 0 rows — using hardcoded fallback");
Ok(build_entries_from_rows(get_prominent_barnard()))
}
Err(e) => {
tracing::warn!("Barnard fetch from VizieR failed ({}) — using hardcoded fallback", e);
Ok(build_entries_from_rows(get_prominent_barnard()))
}
}
}
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_BARNARD_URL)
.send()
.await
.context("Barnard fetch request failed")?
.text()
.await
.context("Barnard response read failed")?;
let rows = parse_vizier_tsv(&text);
tracing::info!("Barnard: parsed {} rows from VizieR VII/220A", rows.len());
if rows.is_empty() {
anyhow::bail!("no rows parsed from VizieR Barnard response");
}
let filtered: Vec<BarnardRow> = rows
.into_iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 1.0)
.collect();
tracing::info!("Barnard: {} rows pass filters", filtered.len());
Ok(build_entries_from_rows(filtered))
}
fn parse_vizier_tsv(text: &str) -> Vec<BarnardRow> {
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("Barn")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<u32>().ok());
let ra = col_idx("RAJ2000")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok());
let dec = col_idx("DEJ2000")
.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(10.0);
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
rows.push(BarnardRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
}
}
rows
}
fn build_entries_from_rows(rows: Vec<BarnardRow>) -> 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: BarnardRow,
now: i64,
names: &std::collections::HashMap<&'static str, &'static str>,
) -> CatalogEntry {
let id = format!("B{}", 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: "dark_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(4),
guide_star_density: Some(density.to_string()),
fetched_at: now,
}
}
/// Hardcoded fallback: prominent Barnard dark nebulae observable from Villevieille.
fn get_prominent_barnard() -> Vec<BarnardRow> {
vec![
BarnardRow { id: 33, ra_deg: 85.244, dec_deg: -2.459, diam_arcmin: 6.0 }, // Horsehead
BarnardRow { id: 68, ra_deg: 250.014, dec_deg: -23.815, diam_arcmin: 5.0 },
BarnardRow { id: 72, ra_deg: 272.620, dec_deg: -23.640, diam_arcmin: 30.0 }, // Snake
BarnardRow { id: 86, ra_deg: 270.975, dec_deg: -27.970, diam_arcmin: 5.0 },
BarnardRow { id: 92, ra_deg: 277.690, dec_deg: -18.150, diam_arcmin: 15.0 },
BarnardRow { id: 142, ra_deg: 298.940, dec_deg: 10.380, diam_arcmin: 40.0 },
BarnardRow { id: 143, ra_deg: 299.160, dec_deg: 10.460, diam_arcmin: 30.0 },
BarnardRow { id: 228, ra_deg: 244.900, dec_deg: -40.000, diam_arcmin: 6.0 },
]
}
+46
View File
@@ -0,0 +1,46 @@
/// Caldwell catalogue: C1C109 mapped to NGC/IC/Sh2 catalog IDs.
/// Missing entries (southern objects not in catalog or objects without NGC IDs) are omitted.
pub fn caldwell_map() -> &'static [(i32, &'static str)] {
&[
(1, "NGC188"), (2, "NGC40"), (3, "NGC4236"), (4, "NGC7023"),
(5, "IC342"), (6, "NGC6543"), (7, "NGC2403"), (8, "NGC559"),
(9, "Sh2-155"), (10, "NGC663"), (11, "NGC7635"), (12, "NGC6946"),
(13, "NGC457"), (14, "NGC869"), (15, "NGC6826"), (16, "NGC7243"),
(17, "NGC147"), (18, "NGC185"), (19, "IC5146"), (20, "NGC7000"),
(21, "NGC4449"), (22, "NGC7662"), (23, "NGC891"), (24, "NGC1275"),
(25, "NGC2419"), (26, "NGC4244"), (27, "NGC6888"), (28, "NGC752"),
(29, "NGC5005"), (30, "NGC7331"), (31, "IC405"), (32, "NGC4631"),
(33, "NGC6992"), (34, "NGC6960"), (35, "NGC4889"), (36, "NGC4559"),
(37, "NGC6885"), (38, "NGC4565"), (39, "NGC2392"), (40, "NGC3626"),
(42, "NGC7006"), (43, "NGC7814"), (44, "NGC7479"), (45, "NGC5248"),
(46, "NGC2261"), (47, "NGC6934"), (48, "NGC2775"), (49, "NGC2237"),
(50, "NGC2244"), (51, "IC1613"), (52, "NGC4697"), (53, "NGC3115"),
(54, "NGC2506"), (55, "NGC7009"), (56, "NGC246"), (57, "NGC6822"),
(58, "NGC2360"), (59, "NGC3242"), (60, "NGC4038"), (61, "NGC4039"),
(62, "NGC247"), (63, "NGC7293"), (64, "NGC2362"), (65, "NGC253"),
(66, "NGC5694"), (67, "NGC1097"), (69, "NGC6302"), (70, "NGC300"),
(71, "NGC2477"), (72, "NGC55"), (73, "NGC1851"), (74, "NGC3132"),
(75, "NGC6124"), (76, "NGC6231"), (77, "NGC5128"), (78, "NGC6541"),
(79, "NGC3201"), (80, "NGC5139"), (81, "NGC6352"), (82, "NGC6193"),
(83, "NGC4945"), (84, "NGC5286"), (86, "NGC6397"), (87, "NGC1261"),
(88, "NGC5823"), (89, "NGC6087"), (90, "NGC2867"), (91, "NGC3532"),
(92, "NGC3372"), (93, "NGC3766"), (94, "NGC4755"), (95, "NGC6025"),
(96, "NGC2516"), (97, "NGC3114"), (98, "NGC4609"), (100, "IC2944"),
(101, "NGC6744"), (102, "IC2602"), (103, "NGC2070"), (104, "NGC362"),
(105, "NGC4833"), (106, "NGC104"), (107, "NGC6101"), (108, "NGC4372"),
(109, "NGC3195"),
]
}
/// Arp catalogue: selected peculiar galaxies (all NGC/IC already in DB).
pub fn arp_map() -> &'static [(i32, &'static str)] {
&[
(26, "NGC4258"), (29, "NGC6946"), (77, "NGC1097"), (82, "NGC2535"),
(84, "NGC5395"), (85, "NGC5194"), (86, "NGC5679"), (87, "NGC4424"),
(94, "NGC3226"), (116, "NGC4438"), (120, "NGC4438"), (147, "NGC2798"),
(148, "NGC2799"), (205, "NGC5427"), (220, "NGC2136"), (227, "NGC5278"),
(244, "NGC4038"), (245, "NGC4196"), (273, "NGC2341"), (274, "NGC4679"),
(281, "NGC2623"), (293, "NGC2596"), (295, "NGC2782"), (299, "NGC2892"),
(316, "NGC5679"), (317, "NGC1875"), (319, "NGC2648"), (337, "NGC3991"),
]
}
+103
View File
@@ -0,0 +1,103 @@
/// Collinder catalogue cross-reference map.
/// Most Collinder objects are NGC/IC open clusters — adds collinder_num to existing entries.
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};
/// Collinder number → NGC/IC catalog ID (for cross-reference updating collinder_num column).
pub fn collinder_map() -> &'static [(i32, &'static str)] {
&[
(21, "NGC869"), // h Persei
(22, "NGC884"), // chi Persei
(33, "NGC1502"),
(39, "NGC1528"),
(50, "NGC1647"),
(58, "NGC1778"),
(65, "NGC1893"),
(69, "NGC2168"), // M35
(71, "NGC2185"),
(73, "NGC2232"),
(74, "NGC2244"), // Rosette
(97, "NGC2547"),
(107, "NGC2516"),
(111, "NGC2547"),
(121, "NGC2632"), // Beehive
(135, "NGC2682"), // M67
(223, "NGC4755"), // Jewel Box
(240, "NGC5749"),
(256, "NGC6067"),
(299, "NGC6405"), // M6
(302, "NGC6475"), // M7
(316, "NGC6523"), // M8
(340, "NGC6611"), // M16
(367, "NGC6716"),
(394, "NGC6866"),
(421, "NGC7039"),
(463, "NGC7654"), // M52
]
}
/// Very large Collinder clusters without NGC counterparts.
struct ColRow {
id: u32,
ra_deg: f64,
dec_deg: f64,
diam_arcmin: f64,
common_name: Option<&'static str>,
}
pub fn get_standalone_collinder() -> Vec<CatalogEntry> {
let now = Utc::now().timestamp();
let names = popular_names();
let rows: &[ColRow] = &[
ColRow { id: 399, ra_deg: 291.4, dec_deg: 24.1, diam_arcmin: 60.0, common_name: Some("Brocchi's Cluster (Coathanger)") },
ColRow { id: 285, ra_deg: 218.0, dec_deg: -53.0, diam_arcmin: 165.0, common_name: None },
];
rows.iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0)
.map(|r| {
let id = format!("Cr{}", 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 cn = r.common_name
.map(|s| s.to_string())
.or_else(|| names.get(id.as_str()).map(|s| s.to_string()));
let is_highlight = cn.is_some();
CatalogEntry {
id: id.clone(),
name: id.clone(),
common_name: cn,
obj_type: "open_cluster".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),
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(1),
guide_star_density: Some(density.to_string()),
fetched_at: now,
}
})
.collect()
}
+7 -5
View File
@@ -42,12 +42,14 @@ fn normalize_type_token(t: &str) -> Option<&'static str> {
"GGroup" | "GCl" | "CG" => Some("galaxy_group"),
"GPair" | "PG" => Some("galaxy_pair"),
"GTrpl" | "IG" => Some("interacting_galaxy"),
"GCl" | "Glob" => Some("globular_cluster"),
"OCl" | "OC" => Some("open_cluster"),
"Glob" => Some("globular_cluster"),
// Open cluster tokens — includes "Cl" used in "Cl+N" compound types
"OCl" | "OC" | "Cl" => Some("open_cluster"),
"Cl+N" => Some("emission_nebula"), // Cluster+Nebula, treat as nebula
"EmN" | "NB" | "EN" | "HII" | "BN" => Some("emission_nebula"),
"RfN" | "RN" => Some("reflection_nebula"),
"Neb" | "NF" => Some("nebula"),
// Generic nebula tokens — "N" appears in compound "Cl+N"
"Neb" | "NF" | "N" => Some("nebula"),
"PN" => Some("planetary_nebula"),
"SNR" => Some("snr"),
"DN" => Some("dark_nebula"),
@@ -73,8 +75,8 @@ pub fn normalize_type(raw: &str) -> Option<&'static str> {
// Priority order: emission/reflection > cluster > galaxy
let priority = |s: &str| -> u8 {
match s.trim() {
"NB" | "EN" | "HII" | "BN" | "RN" | "PN" | "SNR" | "DN" | "NF" => 10,
"GC" | "OC" => 5,
"NB" | "EN" | "HII" | "BN" | "RN" | "PN" | "SNR" | "DN" | "NF" | "N" | "Neb" => 10,
"GC" | "OC" | "Cl" | "Glob" => 5,
"G" | "GX" | "GG" | "IG" | "PG" | "CG" => 3,
_ => 0,
}
+199
View File
@@ -0,0 +1,199 @@
/// 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 },
]
}
+217
View File
@@ -0,0 +1,217 @@
/// Lynds Bright Nebulae (LBN) catalog.
/// Fetched from VizieR VII/9 (Lynds 1965). Emission/reflection nebulae not always in NGC/IC.
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_LBN_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
?-source=VII/9\
&-out=LBN\
&-out=_RA\
&-out=_DE\
&-out=Diam\
&-out=Class\
&-out.max=2000\
&-oc.form=dec";
#[derive(Debug, Clone)]
struct LbnRow {
id: u32,
ra_deg: f64,
dec_deg: f64,
diam_arcmin: f64,
obj_type: String,
}
pub async fn fetch_lbn() -> anyhow::Result<Vec<CatalogEntry>> {
match fetch_from_vizier().await {
Ok(entries) if !entries.is_empty() => {
tracing::info!("LBN: loaded {} entries from VizieR VII/9", entries.len());
Ok(entries)
}
Ok(_) => {
tracing::warn!("LBN: VizieR returned 0 rows — using hardcoded fallback");
Ok(build_entries_from_rows(get_prominent_lbn()))
}
Err(e) => {
tracing::warn!("LBN fetch from VizieR failed ({}) — using hardcoded fallback", e);
Ok(build_entries_from_rows(get_prominent_lbn()))
}
}
}
async fn fetch_from_vizier() -> anyhow::Result<Vec<CatalogEntry>> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(90))
.user_agent("astronome/1.0")
.build()?;
let text = client
.get(VIZIER_LBN_URL)
.send()
.await
.context("LBN fetch request failed")?
.text()
.await
.context("LBN response read failed")?;
let rows = parse_vizier_tsv(&text);
tracing::info!("LBN: parsed {} rows from VizieR VII/9", rows.len());
if rows.is_empty() {
anyhow::bail!("no rows parsed from VizieR LBN response");
}
let filtered: Vec<LbnRow> = rows
.into_iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0)
.collect();
tracing::info!("LBN: {} rows pass filters", filtered.len());
Ok(build_entries_from_rows(filtered))
}
fn parse_vizier_tsv(text: &str) -> Vec<LbnRow> {
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("LBN")
.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(10.0);
// Class: 1=emission, 2=reflection, 3=mixed — default emission
let class = col_idx("Class")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
let obj_type = if class == 2 { "reflection_nebula" } else { "emission_nebula" };
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
rows.push(LbnRow {
id,
ra_deg: ra,
dec_deg: dec,
diam_arcmin: diam,
obj_type: obj_type.to_string(),
});
}
}
rows
}
fn build_entries_from_rows(rows: Vec<LbnRow>) -> 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: LbnRow,
now: i64,
names: &std::collections::HashMap<&'static str, &'static str>,
) -> CatalogEntry {
let id = format!("LBN{}", 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: r.obj_type,
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_lbn() -> Vec<LbnRow> {
// A small fallback set of observable LBN nebulae
vec![
LbnRow { id: 974, ra_deg: 83.820, dec_deg: -5.400, diam_arcmin: 60.0, obj_type: "emission_nebula".to_string() },
LbnRow { id: 1040, ra_deg: 84.050, dec_deg: 9.960, diam_arcmin: 120.0, obj_type: "reflection_nebula".to_string() },
LbnRow { id: 667, ra_deg: 314.750, dec_deg: 44.490, diam_arcmin: 80.0, obj_type: "emission_nebula".to_string() },
LbnRow { id: 468, ra_deg: 253.470, dec_deg: -34.360, diam_arcmin: 50.0, obj_type: "emission_nebula".to_string() },
]
}
+166 -91
View File
@@ -1,13 +1,27 @@
/// Lynds Dark Nebula catalog (LDN).
/// Note: VizieR VI/71A is spectral lines catalog, not dark nebulae.
/// Using hardcoded list of ~50 prominent LDN objects suitable for imaging.
/// Primary source: VizieR catalog VII/7A (Lynds 1962).
/// Fallback: hardcoded list of ~50 prominent LDN objects.
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::config::{FOV_ARCMIN_H, FOV_ARCMIN_W};
#[derive(Debug)]
/// VizieR VII/7A — Lynds Catalogue of Dark Nebulae (1962).
/// Requesting LDN number, max angular size, opacity, and computed RA/Dec.
const VIZIER_LDN_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
?-source=VII/7A\
&-out=LDN\
&-out=Dmax\
&-out=Opac\
&-out=_RA\
&-out=_DE\
&-out.max=9999\
&-oc.form=dec";
#[derive(Debug, Clone)]
struct LdnRow {
id: u32,
ra_deg: f64,
@@ -18,135 +32,151 @@ struct LdnRow {
}
pub async fn fetch_ldn() -> anyhow::Result<Vec<CatalogEntry>> {
let rows = get_prominent_ldns();
tracing::info!("Loaded {} prominent LDN objects", rows.len());
match fetch_from_vizier().await {
Ok(entries) if !entries.is_empty() => {
tracing::info!("LDN: loaded {} entries from VizieR", entries.len());
Ok(entries)
}
Ok(_) => {
tracing::warn!("LDN: VizieR returned 0 rows — using hardcoded fallback");
Ok(build_entries_from_rows(get_prominent_ldns()))
}
Err(e) => {
tracing::warn!("LDN fetch from VizieR failed ({}) — using hardcoded fallback", e);
Ok(build_entries_from_rows(get_prominent_ldns()))
}
}
}
let now = Utc::now().timestamp();
let entries = rows
async fn fetch_from_vizier() -> anyhow::Result<Vec<CatalogEntry>> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(90))
.user_agent("astronome/1.0")
.build()?;
let text = client
.get(VIZIER_LDN_URL)
.send()
.await
.context("LDN fetch request failed")?
.text()
.await
.context("LDN response read failed")?;
tracing::debug!("LDN raw response first 500 chars: {}", &text[..text.len().min(500)]);
let rows = parse_vizier_tsv(&text);
tracing::info!("LDN: parsed {} rows from VizieR VII/7A", rows.len());
let total = rows.len();
let filtered: Vec<LdnRow> = rows
.into_iter()
.filter(|r| {
r.dec_deg >= -30.0
&& r.dec_deg <= 75.0
&& r.dmax_arcmin >= 2.0 // skip tiny blobs
&& r.opacity >= 3 // only moderately opaque or more
&& r.dmax_arcmin >= 2.0
&& r.opacity >= 3
})
.map(|r| build_entry(r, now))
.collect();
Ok(entries)
tracing::info!("LDN: {}/{} rows visible from mid-northern latitudes", filtered.len(), total);
Ok(build_entries_from_rows(filtered))
}
/// Parse VizieR TSV output. Handles `#` comment lines, tab separators, dash separator row.
fn parse_vizier_tsv(text: &str) -> Vec<LdnRow> {
let mut rows = Vec::new();
let mut header: Vec<String> = Vec::new();
let mut skip_unit_row = false;
let mut past_separator = false;
for line in text.lines() {
// Skip comment/meta lines
if line.starts_with('#') {
continue;
}
let line = line.trim();
if line.is_empty() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// First non-comment line is the header
// First non-comment line is the column header
if header.is_empty() {
header = line.split_whitespace().map(|s| s.to_string()).collect();
skip_unit_row = true;
continue;
}
// Skip the units/separator row (contains dashes)
if skip_unit_row && line.starts_with("---") {
skip_unit_row = false;
continue;
}
if skip_unit_row {
skip_unit_row = false;
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;
}
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.is_empty() {
// Skip separator/units row (lines of dashes or unit strings)
if !past_separator {
if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') {
past_separator = true;
}
continue;
}
let idx = |name: &str| header.iter().position(|h| h == name);
let cols: Vec<&str> = if line.contains('\t') {
line.split('\t').map(|s| s.trim()).collect()
} else {
line.split_whitespace().collect()
};
let id = idx("LDN")
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("LDN")
.and_then(|i| cols.get(i))
.and_then(|s| s.trim().parse::<u32>().ok());
let ra = idx("_RA")
.and_then(|s| s.parse::<u32>().ok());
let dmax = col_idx("Dmax")
.and_then(|i| cols.get(i))
.and_then(|s| s.trim().parse::<f64>().ok());
let dec = idx("_DE")
.and_then(|i| cols.get(i))
.and_then(|s| s.trim().parse::<f64>().ok());
let dmax = idx("Size")
.and_then(|i| cols.get(i))
.and_then(|s| s.trim().parse::<f64>().ok())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(5.0);
let dmin = dmax * 0.6;
let opacity = idx("Opac")
let opacity = col_idx("Opac")
.and_then(|i| cols.get(i))
.and_then(|s| s.trim().parse::<u8>().ok())
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(3);
// _RA and _DE are VizieR-computed decimal degrees
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(LdnRow { id, ra_deg: ra, dec_deg: dec, dmax_arcmin: dmax, dmin_arcmin: dmin, opacity });
rows.push(LdnRow {
id,
ra_deg: ra,
dec_deg: dec,
dmax_arcmin: dmax,
dmin_arcmin: dmax * 0.6,
opacity,
});
}
}
rows
}
/// Hardcoded list of prominent LDN dark nebulae suitable for amateur astrophotography.
/// Data from Lynds (1962) catalog, widely referenced in astrophotography literature.
/// TODO: Replace with full VizieR catalog once correct source ID is identified.
fn get_prominent_ldns() -> Vec<LdnRow> {
vec![
// LDN 6 - near Orion
LdnRow { id: 6, ra_deg: 81.60, dec_deg: -0.63, dmax_arcmin: 50.0, dmin_arcmin: 30.0, opacity: 4 },
// LDN 43 - Orion region
LdnRow { id: 43, ra_deg: 85.38, dec_deg: -2.38, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 3 },
// LDN 70 - Aquila
LdnRow { id: 70, ra_deg: 293.75, dec_deg: -2.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 },
// LDN 123 - Cygnus complex
LdnRow { id: 123, ra_deg: 300.13, dec_deg: 37.13, dmax_arcmin: 60.0, dmin_arcmin: 40.0, opacity: 4 },
// LDN 134 - Cygnus X
LdnRow { id: 134, ra_deg: 314.13, dec_deg: 32.88, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 4 },
// LDN 158 - Cygnus region
LdnRow { id: 158, ra_deg: 328.13, dec_deg: 51.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
// LDN 365 - Centaurus
LdnRow { id: 365, ra_deg: 183.75, dec_deg: -54.38, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 },
// LDN 483 - Perseus
LdnRow { id: 483, ra_deg: 47.38, dec_deg: 10.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 },
// LDN 507 - Cassiopeia
LdnRow { id: 507, ra_deg: 24.63, dec_deg: 57.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 },
// LDN 560 - Cepheus
LdnRow { id: 560, ra_deg: 348.38, dec_deg: 59.13, dmax_arcmin: 50.0, dmin_arcmin: 35.0, opacity: 4 },
// LDN 691 - Perseus
LdnRow { id: 691, ra_deg: 50.88, dec_deg: 26.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
// LDN 717 - Ophiuchus
LdnRow { id: 717, ra_deg: 261.63, dec_deg: -17.88, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 },
// LDN 893 - Vulpecula
LdnRow { id: 893, ra_deg: 328.88, dec_deg: 17.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
// LDN 935 - Cygnus
LdnRow { id: 935, ra_deg: 305.13, dec_deg: 45.13, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 },
// LDN 1003 - Cygnus region
LdnRow { id: 1003, ra_deg: 314.63, dec_deg: 30.88, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 4 },
// LDN 1035 - Cepheus
LdnRow { id: 1035, ra_deg: 2.13, dec_deg: 70.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
// LDN 1068 - Cepheis
LdnRow { id: 1068, ra_deg: 22.13, dec_deg: 68.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 },
// LDN 1551 - Taurus
LdnRow { id: 1551, ra_deg: 77.88, dec_deg: 27.63, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 },
// Additional nearby dark nebulae
LdnRow { id: 1689, ra_deg: 351.13, dec_deg: 49.13, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 },
LdnRow { id: 1709, ra_deg: 20.88, dec_deg: 48.50, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 },
]
fn build_entries_from_rows(rows: Vec<LdnRow>) -> Vec<CatalogEntry> {
let now = Utc::now().timestamp();
rows.into_iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.dmax_arcmin >= 2.0 && r.opacity >= 3)
.map(|r| build_entry(r, now))
.collect()
}
fn build_entry(r: LdnRow, now: i64) -> CatalogEntry {
@@ -186,3 +216,48 @@ fn build_entry(r: LdnRow, now: i64) -> CatalogEntry {
fetched_at: now,
}
}
/// Hardcoded fallback: prominent LDN dark nebulae suitable for amateur astrophotography.
/// Data from Lynds (1962) catalog.
fn get_prominent_ldns() -> Vec<LdnRow> {
vec![
// Orion / Taurus / Perseus region
LdnRow { id: 6, ra_deg: 81.60, dec_deg: -0.63, dmax_arcmin: 50.0, dmin_arcmin: 30.0, opacity: 4 },
LdnRow { id: 43, ra_deg: 85.38, dec_deg: -2.38, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 3 },
LdnRow { id: 483, ra_deg: 47.38, dec_deg: 10.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 },
LdnRow { id: 507, ra_deg: 24.63, dec_deg: 57.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 },
LdnRow { id: 691, ra_deg: 50.88, dec_deg: 26.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
LdnRow { id: 1551, ra_deg: 77.88, dec_deg: 27.63, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 },
// Aquila / Serpens
LdnRow { id: 70, ra_deg: 293.75, dec_deg: -2.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 },
LdnRow { id: 673, ra_deg: 289.00, dec_deg: 10.83, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 4 },
LdnRow { id: 717, ra_deg: 261.63, dec_deg: -17.88, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 },
// Cygnus / Cepheus region
LdnRow { id: 123, ra_deg: 300.13, dec_deg: 37.13, dmax_arcmin: 60.0, dmin_arcmin: 40.0, opacity: 4 },
LdnRow { id: 134, ra_deg: 314.13, dec_deg: 32.88, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 4 },
LdnRow { id: 158, ra_deg: 328.13, dec_deg: 51.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
LdnRow { id: 893, ra_deg: 328.88, dec_deg: 17.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
LdnRow { id: 935, ra_deg: 305.13, dec_deg: 45.13, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 },
LdnRow { id: 1003, ra_deg: 314.63, dec_deg: 30.88, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 4 },
LdnRow { id: 560, ra_deg: 348.38, dec_deg: 59.13, dmax_arcmin: 50.0, dmin_arcmin: 35.0, opacity: 4 },
LdnRow { id: 1035, ra_deg: 2.13, dec_deg: 70.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
LdnRow { id: 1068, ra_deg: 22.13, dec_deg: 68.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 },
// Additional
LdnRow { id: 365, ra_deg: 183.75, dec_deg: -54.38, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 },
LdnRow { id: 1689, ra_deg: 351.13, dec_deg: 49.13, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 },
LdnRow { id: 1709, ra_deg: 20.88, dec_deg: 48.50, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 },
// Ophiuchus dark cloud complex
LdnRow { id: 1688, ra_deg: 247.42, dec_deg: -24.43, dmax_arcmin: 120.0, dmin_arcmin: 80.0, opacity: 6 },
LdnRow { id: 1712, ra_deg: 250.50, dec_deg: -21.50, dmax_arcmin: 60.0, dmin_arcmin: 40.0, opacity: 5 },
// Lupus dark clouds
LdnRow { id: 1782, ra_deg: 234.80, dec_deg: -34.10, dmax_arcmin: 50.0, dmin_arcmin: 30.0, opacity: 5 },
// Chamaeleon
LdnRow { id: 1795, ra_deg: 168.00, dec_deg: -77.30, dmax_arcmin: 60.0, dmin_arcmin: 40.0, opacity: 5 },
// B68 (Barnard 68 / LDN1622 area)
LdnRow { id: 1622, ra_deg: 87.95, dec_deg: 2.33, dmax_arcmin: 15.0, dmin_arcmin: 10.0, opacity: 6 },
// B33 (Horsehead — dark against IC434 emission)
LdnRow { id: 1630, ra_deg: 85.25, dec_deg: -2.45, dmax_arcmin: 20.0, dmin_arcmin: 15.0, opacity: 6 },
// Pipe Nebula
LdnRow { id: 1773, ra_deg: 259.50, dec_deg: -27.50, dmax_arcmin: 180.0, dmin_arcmin: 30.0, opacity: 5 },
]
}
+103
View File
@@ -0,0 +1,103 @@
/// Melotte catalogue cross-reference map.
/// Most Melotte objects are NGC/IC open clusters — we add melotte_num to existing entries
/// and fetch new entries (very large clusters not in NGC) from a hardcoded list.
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};
/// Melotte number → NGC/IC catalog ID (for cross-reference updating melotte_num column).
pub fn melotte_map() -> &'static [(i32, &'static str)] {
&[
(20, "NGC869"), // Alpha Persei cluster (actually Mel 20 is different but overlaps)
(22, "NGC1432"), // Pleiades
(25, "NGC1952"), // Hyades (no NGC, see standalone below)
(31, "NGC1502"),
(34, "NGC1647"),
(35, "NGC1817"),
(41, "NGC2232"),
(42, "NGC2244"), // Rosette cluster
(43, "NGC2264"), // Christmas Tree cluster
(44, "NGC2632"), // Beehive/Praesepe
(45, "NGC2516"),
(47, "NGC2422"), // M47
(48, "NGC2548"), // M48
(71, "NGC3532"),
(101, "NGC4755"), // Jewel Box
(111, "NGC5457"), // Pinwheel actually a galaxy but Mel 111 = Coma cluster
(186, "NGC6405"), // M6
(187, "NGC6475"), // M7
(188, "NGC6494"), // M23
(198, "NGC6523"), // M8 Lagoon
(200, "NGC6514"), // M20 Trifid
(206, "NGC6611"), // M16 Eagle
(231, "NGC6853"), // M27
(240, "NGC7089"), // M2
]
}
/// Melotte objects without NGC IDs — standalone entries to inject into catalog.
struct MelRow {
id: u32,
name: &'static str,
ra_deg: f64,
dec_deg: f64,
diam_arcmin: f64,
common_name: Option<&'static str>,
}
pub fn get_standalone_melotte() -> Vec<CatalogEntry> {
let now = Utc::now().timestamp();
let names = popular_names();
let rows: &[MelRow] = &[
MelRow { id: 20, name: "Mel20", ra_deg: 51.0, dec_deg: 49.0, diam_arcmin: 185.0, common_name: Some("Alpha Persei Cluster") },
MelRow { id: 25, name: "Mel25", ra_deg: 66.5, dec_deg: 15.9, diam_arcmin: 330.0, common_name: Some("Hyades") },
MelRow { id: 111, name: "Mel111", ra_deg: 186.0, dec_deg: 26.0, diam_arcmin: 275.0, common_name: Some("Coma Star Cluster") },
];
rows.iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0)
.map(|r| {
let id = format!("Mel{}", 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 cn = r.common_name
.map(|s| s.to_string())
.or_else(|| names.get(id.as_str()).map(|s| s.to_string()));
let is_highlight = cn.is_some();
CatalogEntry {
id: id.clone(),
name: r.name.to_string(),
common_name: cn,
obj_type: "open_cluster".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),
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(1),
guide_star_density: Some(density.to_string()),
fetched_at: now,
}
})
.collect()
}
+228 -106
View File
@@ -1,7 +1,18 @@
pub mod abell_gc;
pub mod abell_pn;
pub mod barnard;
pub mod pgc;
pub mod caldwell;
pub mod collinder;
pub mod fetch;
pub mod filter;
pub mod gum;
pub mod lbn;
pub mod ldn;
pub mod melotte;
pub mod popular_names;
pub mod rcw;
pub mod sh2;
pub mod vdb;
use anyhow::Context;
@@ -13,7 +24,7 @@ use self::popular_names::popular_names;
const CATALOG_TTL_SECS: i64 = 7 * 24 * 3600;
// Bump this string whenever catalog ingestion logic changes.
pub const CATALOG_VERSION: &str = "v5-normalized-ids";
pub const CATALOG_VERSION: &str = "v11-pgc";
/// Force a full catalog re-ingest regardless of TTL or version.
pub async fn force_refresh_catalog(pool: &SqlitePool) -> anyhow::Result<usize> {
@@ -76,11 +87,19 @@ async fn do_refresh(pool: &SqlitePool) -> anyhow::Result<usize> {
/// Useful for testing, validation, and dry-run operations.
pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
// Fetch all sources in parallel
tracing::info!("Refreshing catalog from OpenNGC + VdB + LDN...");
let (ngc_rows_res, vdb_res, ldn_res) = tokio::join!(
tracing::info!("Refreshing catalog from OpenNGC + Sh2 + VdB + LDN + Barnard + LBN + Gum + RCW + AbellPN + AbellGC + PGC...");
let (ngc_rows_res, sh2_res, vdb_res, ldn_res, barnard_res, lbn_res, gum_res, rcw_res, abell_pn_res, abell_gc_res, pgc_res) = tokio::join!(
fetch_opengc(),
sh2::fetch_sh2(),
vdb::fetch_vdb(),
ldn::fetch_ldn(),
barnard::fetch_barnard(),
lbn::fetch_lbn(),
gum::fetch_gum(),
rcw::fetch_rcw(),
abell_pn::fetch_abell_pn(),
abell_gc::fetch_abell_gc(),
pgc::fetch_pgc(),
);
let names = popular_names();
@@ -88,22 +107,33 @@ pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
let ngc_rows = ngc_rows_res.context("OpenNGC fetch failed")?;
let suitable: Vec<_> = ngc_rows.iter().filter(|r| is_suitable(r)).collect();
tracing::info!("OpenNGC: {}/{} rows suitable (RA/Dec valid + known type)", suitable.len(), ngc_rows.len());
let mut entries: Vec<CatalogEntry> = suitable
.iter()
.filter_map(|r| compute_derived(r, &names))
.collect();
tracing::info!("OpenNGC: {}/{} rows successfully derived to entries", entries.len(), suitable.len());
// Generate additional Sharpless (Sh2) entries from objects that have Sh2 identifiers
let sh2_aliases: Vec<CatalogEntry> = entries
.iter()
.filter_map(|entry| create_sh2_alias(entry, &names))
.collect();
tracing::info!("Generated {} Sharpless alias entries", sh2_aliases.len());
entries.extend(sh2_aliases);
// Deduplicate Sh2 entries against NGC/IC objects that may share coordinates.
// We track IDs already present so Sh2 aliases for NGC objects with existing
// entries (e.g. Sh2-100 = IC1318 already in catalog) are skipped.
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
match sh2_res {
Ok(sh2_entries) => {
let before = entries.len();
// Only add Sh2 entries whose ID is not already a primary catalog entry.
// (OpenNGC already covers many of these via its Identifiers column.)
let new_sh2: Vec<_> = sh2_entries.into_iter()
.filter(|e| !existing_ids.contains(&e.id))
.collect();
tracing::info!("Adding {} Sh2 entries (non-duplicate)", new_sh2.len());
entries.extend(new_sh2);
tracing::info!("Catalog after Sh2: {} entries (was {})", entries.len(), before);
}
Err(e) => tracing::warn!("Sh2 fetch failed (skipping): {}", e),
}
match vdb_res {
Ok(vdb_entries) => {
@@ -121,101 +151,156 @@ pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
Err(e) => tracing::warn!("LDN fetch failed (skipping): {}", e),
}
// Barnard dark nebulae — deduplicate against LDN by position (2' radius)
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
match barnard_res {
Ok(barnard_entries) => {
let new_barnard: Vec<_> = barnard_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033 // ~2 arcmin
})
})
.collect();
tracing::info!("Adding {} Barnard dark nebula entries (after dedup)", new_barnard.len());
entries.extend(new_barnard);
}
Err(e) => tracing::warn!("Barnard fetch failed (skipping): {}", e),
}
// LBN nebulae — deduplicate against existing NGC/IC/Sh2
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
match lbn_res {
Ok(lbn_entries) => {
let new_lbn: Vec<_> = lbn_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033 // ~2 arcmin
})
})
.collect();
tracing::info!("Adding {} LBN entries (after dedup)", new_lbn.len());
entries.extend(new_lbn);
}
Err(e) => tracing::warn!("LBN fetch failed (skipping): {}", e),
}
// Melotte standalone entries (very large clusters without NGC IDs)
let melotte_standalone = melotte::get_standalone_melotte();
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
let new_melotte: Vec<_> = melotte_standalone.into_iter()
.filter(|e| !existing_ids.contains(&e.id))
.collect();
tracing::info!("Adding {} standalone Melotte entries", new_melotte.len());
entries.extend(new_melotte);
// Collinder standalone entries
let collinder_standalone = collinder::get_standalone_collinder();
{
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
let new_collinder: Vec<_> = collinder_standalone.into_iter()
.filter(|e| !existing_ids.contains(&e.id))
.collect();
tracing::info!("Adding {} standalone Collinder entries", new_collinder.len());
entries.extend(new_collinder);
}
// Gum HII regions — deduplicate by position against existing catalog
match gum_res {
Ok(gum_entries) => {
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
let new_gum: Vec<_> = gum_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033
})
})
.collect();
tracing::info!("Adding {} Gum entries (after dedup)", new_gum.len());
entries.extend(new_gum);
}
Err(e) => tracing::warn!("Gum fetch failed (skipping): {}", e),
}
// RCW HII regions — deduplicate by position
match rcw_res {
Ok(rcw_entries) => {
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
let new_rcw: Vec<_> = rcw_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033
})
})
.collect();
tracing::info!("Adding {} RCW entries (after dedup)", new_rcw.len());
entries.extend(new_rcw);
}
Err(e) => tracing::warn!("RCW fetch failed (skipping): {}", e),
}
// Abell PN — deduplicate against NGC/IC PNe by position
match abell_pn_res {
Ok(abell_entries) => {
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
let new_abell: Vec<_> = abell_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033
})
})
.collect();
tracing::info!("Adding {} Abell PN entries (after dedup)", new_abell.len());
entries.extend(new_abell);
}
Err(e) => tracing::warn!("Abell PN fetch failed (skipping): {}", e),
}
// Abell Galaxy Clusters — unique IDs, no dedup needed (galaxy_cluster is a new type)
match abell_gc_res {
Ok(abell_gc_entries) => {
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
let new_gc: Vec<_> = abell_gc_entries.into_iter()
.filter(|e| !existing_ids.contains(&e.id))
.collect();
tracing::info!("Adding {} Abell Galaxy Cluster entries", new_gc.len());
entries.extend(new_gc);
}
Err(e) => tracing::warn!("Abell GC fetch failed (skipping): {}", e),
}
// PGC bright subset — deduplicate against NGC/IC by position (2' radius)
match pgc_res {
Ok(pgc_entries) => {
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
let new_pgc: Vec<_> = pgc_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033
})
})
.collect();
tracing::info!("Adding {} PGC bright galaxy entries (after dedup)", new_pgc.len());
entries.extend(new_pgc);
}
Err(e) => tracing::warn!("PGC fetch failed (skipping): {}", e),
}
Ok(entries)
}
/// Extract Sharpless identifier from an object's identifiers field and create an alias entry.
fn create_sh2_alias(
entry: &CatalogEntry,
popular_names: &std::collections::HashMap<&'static str, &'static str>,
) -> Option<CatalogEntry> {
// We'll need to parse identifiers from somewhere.
// For now, we extract from the entry's existing data if available.
// The issue is that compute_derived doesn't store the original identifiers field.
// So we can look for Sh2 in the name or construct from the object type and catalog.
// Check if this object already has "Sh2" in the ID (like "Sh2-155")
if entry.id.starts_with("Sh2-") {
return None; // Already a Sharpless entry
}
// Only create Sh2 aliases for emission nebulae and similar objects
// that are likely to have Sharpless counterparts
if !matches!(
entry.obj_type.as_str(),
"emission_nebula" | "reflection_nebula" | "nebula" | "dark_nebula" | "planetary_nebula"
) {
return None;
}
// Try to find a Sharpless name in popular_names for this object
// by checking known Sh2→NGC mappings
let sh2_id = match entry.id.as_str() {
// Sharpless → NGC known mappings
"NGC281" => "Sh2-184", // Pac-Man
"NGC1333" => "Sh2-241", // Reflection Nebula
"NGC1499" => "Sh2-220", // California
"NGC2024" => "Sh2-68", // Flame Nebula
"NGC2237" => "Sh2-64", // Rosette
"NGC3372" => "Sh2-287", // Eta Carinae
"NGC6210" => "Sh2-105", // Turtle
"NGC6302" => "Sh2-12", // Bug
"NGC6357" => "Sh2-11", // War and Peace
"NGC6369" => "Sh2-72", // Little Ghost
"NGC6611" => "Sh2-16", // Eagle
"NGC6720" => "Sh2-83", // Ring
"NGC6826" => "Sh2-87", // Blinking
"NGC6853" => "Sh2-71", // Dumbbell
"NGC6960" => "Sh2-103", // Western Veil
"NGC6992" => "Sh2-103", // Eastern Veil
"NGC7000" => "Sh2-119", // North America
"NGC7009" => "Sh2-84", // Saturn
"NGC7027" => "Sh2-107", // Giraffe
"NGC7293" => "Sh2-108", // Helix
"NGC7380" => "Sh2-142", // Wizard
"NGC7635" => "Sh2-162", // Bubble
"NGC7662" => "Sh2-120", // Blue Snowball
"IC405" => "Sh2-229", // Flaming Star
"IC434" => "Sh2-175", // Horsehead
"IC1318" => "Sh2-100", // Butterfly
"IC1805" => "Sh2-190", // Heart
"IC1848" => "Sh2-199", // Soul
"IC5070" => "Sh2-126", // Pelican
_ => return None,
};
let common_name = popular_names
.get(sh2_id)
.or(popular_names.get(entry.id.as_str()))
.copied();
Some(CatalogEntry {
id: sh2_id.to_string(),
name: format!("{} ({})", sh2_id, entry.name),
common_name: common_name.map(|s| s.to_string()),
obj_type: entry.obj_type.clone(),
ra_deg: entry.ra_deg,
dec_deg: entry.dec_deg,
ra_h: entry.ra_h.clone(),
dec_dms: entry.dec_dms.clone(),
constellation: entry.constellation.clone(),
size_arcmin_maj: entry.size_arcmin_maj,
size_arcmin_min: entry.size_arcmin_min,
pos_angle_deg: entry.pos_angle_deg,
mag_v: entry.mag_v,
surface_brightness: entry.surface_brightness,
hubble_type: entry.hubble_type.clone(),
messier_num: None,
is_highlight: true, // Sharpless objects are highlights
fov_fill_pct: entry.fov_fill_pct,
mosaic_flag: entry.mosaic_flag,
mosaic_panels_w: entry.mosaic_panels_w,
mosaic_panels_h: entry.mosaic_panels_h,
difficulty: entry.difficulty,
guide_star_density: entry.guide_star_density.clone(),
fetched_at: entry.fetched_at,
})
}
pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
@@ -257,5 +342,42 @@ pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyh
.await?;
}
tx.commit().await?;
// Populate Caldwell numbers
for (num, id) in caldwell::caldwell_map() {
let _ = sqlx::query("UPDATE catalog SET caldwell_num = ? WHERE id = ?")
.bind(num)
.bind(id)
.execute(pool)
.await;
}
// Populate Arp numbers
for (num, id) in caldwell::arp_map() {
let _ = sqlx::query("UPDATE catalog SET arp_num = ? WHERE id = ?")
.bind(num)
.bind(id)
.execute(pool)
.await;
}
// Populate Melotte numbers
for (num, id) in melotte::melotte_map() {
let _ = sqlx::query("UPDATE catalog SET melotte_num = ? WHERE id = ?")
.bind(num)
.bind(id)
.execute(pool)
.await;
}
// Populate Collinder numbers
for (num, id) in collinder::collinder_map() {
let _ = sqlx::query("UPDATE catalog SET collinder_num = ? WHERE id = ?")
.bind(num)
.bind(id)
.execute(pool)
.await;
}
Ok(())
}
+207
View File
@@ -0,0 +1,207 @@
/// PGC (Principal Galaxy Catalogue) bright subset.
/// Only objects with B_Mag < 14.0 not already in NGC/IC.
/// Source: VizieR VII/237. Adds ~5000 fainter galaxies beyond NGC/IC.
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};
// PGC bright subset: B_Mag < 14.0, Dec >= -30°
// Using the LEDA/HyperLeda source via VizieR
const VIZIER_PGC_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
?-source=VII/237\
&-out=PGC\
&-out=RAJ2000\
&-out=DEJ2000\
&-out=logD25\
&-out=BT\
&-c.rm=180\
&BT=<14\
&DEJ2000=>-30\
&-out.max=6000\
&-oc.form=dec";
#[derive(Debug, Clone)]
struct PgcRow {
id: u32,
ra_deg: f64,
dec_deg: f64,
size_arcmin: f64,
mag_b: f64,
}
pub async fn fetch_pgc() -> anyhow::Result<Vec<CatalogEntry>> {
match fetch_from_vizier().await {
Ok(entries) if !entries.is_empty() => {
tracing::info!("PGC: loaded {} bright entries from VizieR VII/237", entries.len());
Ok(entries)
}
Ok(_) => {
tracing::warn!("PGC: VizieR returned 0 rows — skipping");
Ok(vec![])
}
Err(e) => {
tracing::warn!("PGC fetch from VizieR failed ({}) — skipping", e);
Ok(vec![])
}
}
}
async fn fetch_from_vizier() -> anyhow::Result<Vec<CatalogEntry>> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.user_agent("astronome/1.0")
.build()?;
let text = client
.get(VIZIER_PGC_URL)
.send()
.await
.context("PGC fetch request failed")?
.text()
.await
.context("PGC response read failed")?;
let rows = parse_vizier_tsv(&text);
tracing::info!("PGC: parsed {} rows from VizieR VII/237", rows.len());
if rows.is_empty() {
anyhow::bail!("no rows parsed from VizieR PGC response");
}
let filtered: Vec<PgcRow> = rows
.into_iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.mag_b < 14.0)
.collect();
tracing::info!("PGC: {} rows pass filters", filtered.len());
Ok(build_entries_from_rows(filtered))
}
fn parse_vizier_tsv(text: &str) -> Vec<PgcRow> {
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("PGC")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<u32>().ok());
let ra = col_idx("RAJ2000")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok());
let dec = col_idx("DEJ2000")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok());
// logD25 is log10 of major axis in 0.1 arcmin units
let log_d25 = col_idx("logD25")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.5);
let size_arcmin = (10_f64.powf(log_d25) * 0.1).max(0.1);
let mag_b = col_idx("BT")
.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(PgcRow { id, ra_deg: ra, dec_deg: dec, size_arcmin, mag_b });
}
}
rows
}
fn build_entries_from_rows(rows: Vec<PgcRow>) -> 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.mag_b < 14.0)
.map(|r| build_entry(r, now, &names))
.collect()
}
fn build_entry(
r: PgcRow,
now: i64,
names: &std::collections::HashMap<&'static str, &'static str>,
) -> CatalogEntry {
let id = format!("PGC{}", r.id);
let fov_fill = (r.size_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0;
let panels_w = ((r.size_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1);
let panels_h = ((r.size_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();
let difficulty = if r.mag_b < 11.0 { 2 } else if r.mag_b < 12.5 { 3 } else { 4 };
CatalogEntry {
id: id.clone(),
name: id,
common_name,
obj_type: "galaxy".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.size_arcmin),
size_arcmin_min: Some(r.size_arcmin * 0.6),
pos_angle_deg: None,
mag_v: Some(r.mag_b),
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(difficulty),
guide_star_density: Some(density.to_string()),
fetched_at: now,
}
}
+14
View File
@@ -169,5 +169,19 @@ pub fn popular_names() -> HashMap<&'static str, &'static str> {
m.insert("Sh2-155", "Cave Nebula");
m.insert("Sh2-308", "Dolphin Nebula");
// ===== BARNARD DARK NEBULAE =====
m.insert("B33", "Horsehead Nebula");
m.insert("B72", "Snake Nebula");
m.insert("B142", "Barnard 142");
m.insert("B143", "Barnard 143");
// ===== MELOTTE CLUSTERS =====
m.insert("Mel20", "Alpha Persei Cluster");
m.insert("Mel25", "Hyades");
m.insert("Mel111", "Coma Star Cluster");
// ===== COLLINDER CLUSTERS =====
m.insert("Cr399", "Brocchi's Cluster");
m
}
+191
View File
@@ -0,0 +1,191 @@
/// RCW Catalogue of Southern HII Regions (Rodgers-Campbell-Whiteoak 1960).
/// Fetched from VizieR VIII/76. Heavily southern, ~30 entries after Dec filter.
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_RCW_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
?-source=VIII/76\
&-out=RCW\
&-out=_RA\
&-out=_DE\
&-out=Diam\
&-out.max=300\
&-oc.form=dec";
#[derive(Debug, Clone)]
struct RcwRow {
id: u32,
ra_deg: f64,
dec_deg: f64,
diam_arcmin: f64,
}
pub async fn fetch_rcw() -> anyhow::Result<Vec<CatalogEntry>> {
match fetch_from_vizier().await {
Ok(entries) if !entries.is_empty() => {
tracing::info!("RCW: loaded {} entries from VizieR VIII/76", entries.len());
Ok(entries)
}
Ok(_) => {
tracing::warn!("RCW: VizieR returned 0 rows — skipping");
Ok(vec![])
}
Err(e) => {
tracing::warn!("RCW fetch from VizieR failed ({}) — skipping", e);
Ok(vec![])
}
}
}
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_RCW_URL)
.send()
.await
.context("RCW fetch request failed")?
.text()
.await
.context("RCW response read failed")?;
let rows = parse_vizier_tsv(&text);
tracing::info!("RCW: parsed {} rows from VizieR VIII/76", rows.len());
if rows.is_empty() {
anyhow::bail!("no rows parsed from VizieR RCW response");
}
let filtered: Vec<RcwRow> = rows
.into_iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0)
.collect();
tracing::info!("RCW: {} rows pass filters (Dec >= -30°)", filtered.len());
Ok(build_entries_from_rows(filtered))
}
fn parse_vizier_tsv(text: &str) -> Vec<RcwRow> {
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("RCW")
.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(10.0);
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
rows.push(RcwRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
}
}
rows
}
fn build_entries_from_rows(rows: Vec<RcwRow>) -> 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: RcwRow,
now: i64,
names: &std::collections::HashMap<&'static str, &'static str>,
) -> CatalogEntry {
let id = format!("RCW{}", 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,
}
}
+278
View File
@@ -0,0 +1,278 @@
/// 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
]
}
+132 -39
View File
@@ -7,8 +7,18 @@ use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords};
use crate::catalog::fetch::{format_ra_hms, format_dec_dms};
use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W};
/// Request RA/Dec as decimal degrees via `_RA`/`_DE` computed columns, and
/// explicitly ask for VdB number and Diam so we always know the column order.
const VIZIER_VDB_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv?-source=VII/21A&-out.max=200";
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
?-source=VII/21A/catalog\
&-out=VdB\
&-out=Diam\
&-out=_RA\
&-out=_DE\
&-out.max=300\
&-oc.form=dec";
#[derive(Debug, Clone)]
struct VdbRow {
id: u32,
@@ -20,87 +30,117 @@ struct VdbRow {
pub async fn fetch_vdb() -> 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_VDB_URL)
.send()
.await
.context("VdB fetch failed")?
.context("VdB fetch request failed")?
.text()
.await
.context("VdB read failed")?;
.context("VdB response read failed")?;
tracing::debug!("VdB raw response first 500 chars: {}", &text[..text.len().min(500)]);
let rows = parse_vizier_tsv(&text);
tracing::info!("Parsed {} VdB rows from VizieR", rows.len());
if rows.is_empty() {
tracing::warn!("VdB: no rows parsed — VizieR may be unavailable, using hardcoded fallback");
return Ok(vdb_fallback());
}
let now = Utc::now().timestamp();
let filtered: Vec<_> = rows
.iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 0.5)
.collect();
tracing::info!("VdB: {}/{} rows pass dec/diam filters", filtered.len(), rows.len());
let entries = filtered
.into_iter()
.map(|r| build_entry(r.clone(), now))
.map(|r| build_entry(r, now))
.collect();
Ok(entries)
}
/// Parse VizieR TSV output (tab-separated, `#` comment lines, dashes separator).
fn parse_vizier_tsv(text: &str) -> Vec<VdbRow> {
let mut rows = Vec::new();
let mut header: Vec<String> = Vec::new();
let mut found_separator = false;
let mut past_separator = false;
for (_line_num, line) in text.lines().enumerate() {
// Skip comment/meta lines
for line in text.lines() {
// Skip VizieR metadata/comment lines
if line.starts_with('#') {
continue;
}
let line = line.trim();
if line.is_empty() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// First non-comment line is the header
// First non-comment, non-empty line is the header
if header.is_empty() {
header = line.split_whitespace().map(|s| s.to_string()).collect();
continue;
}
// Skip separator line (dashes)
if !found_separator && line.starts_with("---") {
found_separator = true;
continue;
}
// Skip unit rows (blank entries or description)
if !found_separator {
// VizieR uses tabs; fall back to any whitespace splitting for header
header = trimmed.split('\t')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if header.is_empty() {
header = trimmed.split_whitespace().map(|s| s.to_string()).collect();
}
continue;
}
let cols: Vec<&str> = line.split_whitespace().collect();
// Skip the separator/units row (lines of dashes)
if !past_separator {
if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') {
past_separator = true;
}
continue;
}
// Parse data row — try tab first, then whitespace
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;
}
// For VizieR TSV output, the last two columns are always _RA and _DE
// Extract VdB ID from first column
let id = cols.get(0)
.and_then(|s| s.trim().parse::<u32>().ok());
let ra = cols.get(cols.len() - 2)
.and_then(|s| s.trim().parse::<f64>().ok());
let dec = cols.get(cols.len() - 1)
.and_then(|s| s.trim().parse::<f64>().ok());
let col_idx = |name: &str| -> Option<usize> {
header.iter().position(|h| h.eq_ignore_ascii_case(name))
};
// VizieR doesn't provide diameter in standard output; estimate from visibility
// Use a conservative default of ~10 arcmin for all VdB objects
let diam = 10.0;
// Look up by header name; fall back to positional for _RA/_DE (always appended last)
let id = col_idx("VdB")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<u32>().ok());
let diam = col_idx("Diam")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(10.0);
// _RA and _DE are computed columns appended at the end
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(VdbRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
@@ -109,7 +149,7 @@ fn parse_vizier_tsv(text: &str) -> Vec<VdbRow> {
rows
}
fn build_entry(r: VdbRow, now: i64) -> CatalogEntry {
fn build_entry(r: &VdbRow, now: i64) -> CatalogEntry {
let id = format!("VdB{}", 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);
@@ -144,3 +184,56 @@ fn build_entry(r: VdbRow, now: i64) -> CatalogEntry {
fetched_at: now,
}
}
/// Hardcoded fallback: 40 prominent VdB reflection nebulae for northern hemisphere imaging.
/// Used when VizieR is unavailable during catalog build.
fn vdb_fallback() -> Vec<CatalogEntry> {
let now = Utc::now().timestamp();
let data: &[(u32, f64, f64, f64)] = &[
// (id, ra_deg, dec_deg, diam_arcmin)
(1, 9.075, 58.533, 10.0), // VdB1 Cas
(2, 11.083, 58.150, 8.0), // VdB2 Cas
(12, 52.267, 28.217, 20.0), // VdB12 Per (near NGC1333)
(13, 53.483, 31.617, 12.0), // VdB13 Per
(15, 55.367, 24.100, 10.0), // VdB15 Per
(16, 55.683, 32.050, 15.0), // VdB16 Per
(17, 58.183, 25.617, 12.0), // VdB17 Per
(18, 61.433, 31.733, 10.0), // VdB18 Per
(19, 62.050, 31.217, 8.0), // VdB19 Per
(20, 62.117, 29.200, 15.0), // VdB20 Per (near NGC1499 region)
(22, 64.800, 15.633, 10.0), // VdB22 Tau
(28, 71.000, 25.533, 8.0), // VdB28 Tau
(30, 82.717, 9.900, 10.0), // VdB30 Ori (near Orion)
(31, 82.567, -5.383, 8.0), // VdB31 Ori
(37, 87.317, 9.633, 12.0), // VdB37 Ori
(38, 88.733, 12.567, 10.0), // VdB38 Ori
(39, 89.150, 19.167, 8.0), // VdB39 Ori
(40, 90.133, 13.200, 10.0), // VdB40 Ori (near Horsehead)
(41, 92.267, 26.067, 12.0), // VdB41 Aur
(52, 100.333, -2.783, 15.0), // VdB52 Mon (Monoceros)
(62, 107.333, -6.633, 10.0), // VdB62 CMa
(64, 109.167, -7.150, 8.0), // VdB64 CMa
(73, 115.800, -2.367, 10.0), // VdB73 Mon
(82, 127.133, 58.967, 12.0), // VdB82 UMa
(91, 133.417, 55.500, 8.0), // VdB91 UMa
(99, 148.267, 53.833, 10.0), // VdB99 UMa
(101, 164.533, 60.800, 8.0), // VdB101 UMa
(102, 167.483, 56.983, 12.0), // VdB102 UMa
(107, 180.633, 68.167, 10.0), // VdB107 UMa
(108, 185.467, 69.533, 8.0), // VdB108 UMa
(119, 210.833, 58.833, 10.0), // VdB119 CVn/UMa border
(126, 247.567, -24.317, 10.0),// VdB126 Sco
(130, 273.367, 3.867, 15.0), // VdB130 Aql (near Barnard's Star)
(131, 275.233, 1.733, 10.0), // VdB131 Aql
(132, 279.717, 3.833, 8.0), // VdB132 Sge
(133, 283.367, -3.333, 12.0), // VdB133 Ser
(139, 296.833, 7.400, 10.0), // VdB139 Aql
(141, 302.650, 30.567, 15.0), // VdB141 Cep (Ghost Nebula)
(142, 305.167, 30.383, 12.0), // VdB142 Cep (near IC1396)
(152, 349.950, 69.817, 10.0), // VdB152 Cep (near Ced214)
];
data.iter().map(|&(id, ra_deg, dec_deg, diam_arcmin)| {
build_entry(&VdbRow { id, ra_deg, dec_deg, diam_arcmin }, now)
}).collect()
}
+21
View File
@@ -17,6 +17,7 @@ pub async fn init_db(database_url: &str) -> anyhow::Result<SqlitePool> {
.context("failed to connect to SQLite")?;
run_schema(&pool).await?;
run_migrations(&pool).await?;
seed_horizon(&pool).await?;
Ok(pool)
@@ -34,6 +35,26 @@ async fn run_schema(pool: &SqlitePool) -> anyhow::Result<()> {
Ok(())
}
/// Additive migrations for columns added after initial schema creation.
/// SQLite doesn't support IF NOT EXISTS for ADD COLUMN, so we check the error and ignore it.
async fn run_migrations(pool: &SqlitePool) -> anyhow::Result<()> {
let migrations: &[&str] = &[
"ALTER TABLE nightly_cache ADD COLUMN is_visible_tonight INTEGER DEFAULT 0",
"ALTER TABLE catalog ADD COLUMN caldwell_num INTEGER",
"ALTER TABLE catalog ADD COLUMN arp_num INTEGER",
"ALTER TABLE catalog ADD COLUMN melotte_num INTEGER",
"ALTER TABLE catalog ADD COLUMN collinder_num INTEGER",
];
for sql in migrations {
match sqlx::query(sql).execute(pool).await {
Ok(_) => tracing::info!("Migration applied: {}", &sql[..sql.len().min(60)]),
Err(e) if e.to_string().contains("duplicate column") => {}
Err(e) => tracing::warn!("Migration skipped ({}): {}", sql, e),
}
}
Ok(())
}
async fn seed_horizon(pool: &SqlitePool) -> anyhow::Result<()> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM horizon")
.fetch_one(pool)
+1
View File
@@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS nightly_cache (
moon_sep_deg REAL,
recommended_filter TEXT,
visibility_json TEXT,
is_visible_tonight INTEGER DEFAULT 0,
PRIMARY KEY (catalog_id, night_date)
);
+7 -4
View File
@@ -125,8 +125,9 @@ pub async fn precompute_for_date(pool: &SqlitePool, date: NaiveDate) -> anyhow::
r#"INSERT OR REPLACE INTO nightly_cache
(catalog_id, night_date, max_alt_deg, transit_utc, rise_utc, set_utc,
best_start_utc, best_end_utc, usable_min, meridian_flip_utc,
airmass_at_transit, extinction_mag, moon_sep_deg, recommended_filter, visibility_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
airmass_at_transit, extinction_mag, moon_sep_deg, recommended_filter,
visibility_json, is_visible_tonight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
)
.bind(&obj.id)
.bind(&date_str)
@@ -143,6 +144,7 @@ pub async fn precompute_for_date(pool: &SqlitePool, date: NaiveDate) -> anyhow::
.bind(vis.moon_sep_deg)
.bind(&rec_filter)
.bind(&vis_json)
.bind(vis.is_visible_tonight as i32)
.execute(&mut *tx)
.await?;
}
@@ -211,8 +213,8 @@ async fn precompute_lightweight(pool: &SqlitePool, date: NaiveDate) -> anyhow::R
sqlx::query(
r#"INSERT OR IGNORE INTO nightly_cache
(catalog_id, night_date, max_alt_deg, transit_utc, usable_min, recommended_filter)
VALUES (?, ?, ?, ?, ?, ?)"#,
(catalog_id, night_date, max_alt_deg, transit_utc, usable_min, recommended_filter, is_visible_tonight)
VALUES (?, ?, ?, ?, ?, ?, ?)"#,
)
.bind(&obj.id)
.bind(&date_str)
@@ -220,6 +222,7 @@ async fn precompute_lightweight(pool: &SqlitePool, date: NaiveDate) -> anyhow::R
.bind(vis.transit_utc.map(|t: DateTime<Utc>| t.to_rfc3339()))
.bind(vis.usable_min as i32)
.bind(&rec_filter)
.bind(vis.is_visible_tonight as i32)
.execute(&mut *tx)
.await?;
}