644 lines
27 KiB
Rust
644 lines
27 KiB
Rust
use axum::{
|
||
extract::{Path, Query, State},
|
||
Json,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::{
|
||
astronomy::{
|
||
astro_twilight, compute_visibility, compute_visibility_with_step, julian_date, moon_altitude, moon_illumination,
|
||
moon_position, HorizonPoint, MoonState, TonightWindow,
|
||
},
|
||
config::{LAT, LON},
|
||
filters::{get_workflow, recommend_filters},
|
||
};
|
||
|
||
use super::{AppError, AppState};
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct TargetsQuery {
|
||
#[serde(rename = "type")]
|
||
pub obj_type: Option<String>,
|
||
pub constellation: Option<String>,
|
||
pub filter: Option<String>,
|
||
pub tonight: Option<bool>,
|
||
pub search: Option<String>,
|
||
pub sort: Option<String>,
|
||
pub page: Option<u32>,
|
||
pub limit: Option<u32>,
|
||
pub min_alt_deg: Option<f64>,
|
||
pub min_usable_min: Option<i32>,
|
||
pub mosaic_only: Option<bool>,
|
||
pub not_imaged: Option<bool>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||
pub struct TargetRow {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub common_name: Option<String>,
|
||
pub obj_type: String,
|
||
pub ra_deg: f64,
|
||
pub dec_deg: f64,
|
||
pub ra_h: String,
|
||
pub dec_dms: String,
|
||
pub constellation: Option<String>,
|
||
pub size_arcmin_maj: Option<f64>,
|
||
pub size_arcmin_min: Option<f64>,
|
||
pub mag_v: Option<f64>,
|
||
pub surface_brightness: Option<f64>,
|
||
pub hubble_type: Option<String>,
|
||
pub messier_num: Option<i32>,
|
||
pub is_highlight: bool,
|
||
pub fov_fill_pct: Option<f64>,
|
||
pub mosaic_flag: bool,
|
||
pub mosaic_panels_w: i32,
|
||
pub mosaic_panels_h: i32,
|
||
pub difficulty: Option<i32>,
|
||
pub guide_star_density: Option<String>,
|
||
// From nightly_cache
|
||
pub max_alt_deg: Option<f64>,
|
||
pub usable_min: Option<i32>,
|
||
pub transit_utc: Option<String>,
|
||
pub recommended_filter: Option<String>,
|
||
pub best_start_utc: Option<String>,
|
||
pub best_end_utc: Option<String>,
|
||
pub moon_sep_deg: Option<f64>,
|
||
pub is_visible_tonight: Option<bool>,
|
||
}
|
||
|
||
pub async fn list_targets(
|
||
State(state): State<AppState>,
|
||
Query(params): Query<TargetsQuery>,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
let today = chrono::Utc::now().naive_utc().date().to_string();
|
||
let page = params.page.unwrap_or(1).max(1);
|
||
let limit = params.limit.unwrap_or(100).min(500);
|
||
let offset = (page - 1) * limit;
|
||
let tonight_filter = params.tonight.unwrap_or(true);
|
||
|
||
let mut conditions = vec!["1=1".to_string()];
|
||
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());
|
||
}
|
||
if let Some(ref con) = params.constellation {
|
||
conditions.push("c.constellation = ?".to_string());
|
||
bind_values.push(con.clone());
|
||
}
|
||
if let Some(ref f) = params.filter {
|
||
// 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()),
|
||
"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
|
||
_ => {
|
||
conditions.push("nc.recommended_filter = ?".to_string());
|
||
bind_values.push(f.clone());
|
||
}
|
||
}
|
||
}
|
||
if let Some(min_alt) = params.min_alt_deg {
|
||
conditions.push("nc.max_alt_deg >= ?".to_string());
|
||
bind_values.push(min_alt.to_string());
|
||
}
|
||
if let Some(min_min) = params.min_usable_min {
|
||
conditions.push("nc.usable_min >= ?".to_string());
|
||
bind_values.push(min_min.to_string());
|
||
}
|
||
if params.mosaic_only.unwrap_or(false) {
|
||
conditions.push("c.mosaic_flag = 1".to_string());
|
||
}
|
||
if params.not_imaged.unwrap_or(false) {
|
||
conditions.push("log_sum.total_min IS NULL".to_string());
|
||
}
|
||
// Tonight filter: show objects above MIN_ALT (15°) at any point tonight.
|
||
// Using max_alt_deg >= 15 (not usable_min > 0) so objects that peak at 15-30°
|
||
// (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());
|
||
}
|
||
if let Some(ref s) = params.search {
|
||
let like = format!("%{}%", s);
|
||
// Support M-number search (e.g. "M42" → messier_num = 42)
|
||
let m_num: Option<i32> = s.trim()
|
||
.strip_prefix(['M', 'm'])
|
||
.and_then(|n| n.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 = {})",
|
||
m
|
||
));
|
||
bind_values.push(like.clone());
|
||
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());
|
||
bind_values.push(like.clone());
|
||
bind_values.push(like);
|
||
}
|
||
}
|
||
|
||
let where_clause = conditions.join(" AND ");
|
||
// "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 20–150% FOV fill are penalised (too small or too large single-panel).
|
||
let best_score_expr = r#"(
|
||
COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40
|
||
+ CASE
|
||
WHEN c.fov_fill_pct IS NULL THEN 0.15
|
||
WHEN c.fov_fill_pct BETWEEN 20 AND 80 THEN (1.0 - ABS(c.fov_fill_pct - 50) / 50.0) * 0.30
|
||
WHEN c.fov_fill_pct > 80 THEN 0.10
|
||
ELSE 0.05
|
||
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",
|
||
_ => best_score_expr,
|
||
};
|
||
|
||
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.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
|
||
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
|
||
WHERE {where_clause}
|
||
ORDER BY {sort_col}
|
||
LIMIT {limit} OFFSET {offset}
|
||
"#,
|
||
today = today,
|
||
where_clause = where_clause,
|
||
sort_col = sort_col,
|
||
limit = limit,
|
||
offset = offset
|
||
);
|
||
|
||
// Use dynamic binding workaround since sqlx requires compile-time queries
|
||
let mut query = sqlx::query(&sql);
|
||
for val in &bind_values {
|
||
query = query.bind(val);
|
||
}
|
||
|
||
let rows = query
|
||
.fetch_all(&state.pool)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
|
||
let items: Vec<serde_json::Value> = rows.iter().map(|row| {
|
||
use sqlx::Row;
|
||
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(),
|
||
"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(),
|
||
"max_alt_deg": row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
||
"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(),
|
||
"best_start_utc": row.try_get::<Option<String>, _>("best_start_utc").unwrap_or_default(),
|
||
"best_end_utc": row.try_get::<Option<String>, _>("best_end_utc").unwrap_or_default(),
|
||
"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),
|
||
})
|
||
}).collect();
|
||
|
||
// Count with the same filters applied
|
||
let count_sql = format!(
|
||
r#"SELECT COUNT(*) 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
|
||
WHERE {where_clause}"#,
|
||
today = today,
|
||
where_clause = where_clause,
|
||
);
|
||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||
for val in &bind_values {
|
||
count_query = count_query.bind(val);
|
||
}
|
||
let total: i64 = count_query.fetch_one(&state.pool).await.unwrap_or(0);
|
||
|
||
Ok(Json(serde_json::json!({
|
||
"items": items,
|
||
"total": total,
|
||
"page": page,
|
||
"limit": limit
|
||
})))
|
||
}
|
||
|
||
pub async fn get_target(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
// 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 {
|
||
sqlx::query("SELECT * FROM catalog WHERE messier_num = ?")
|
||
.bind(n)
|
||
.fetch_optional(&state.pool)
|
||
.await?
|
||
} else {
|
||
sqlx::query("SELECT * FROM catalog WHERE id = ?")
|
||
.bind(&id)
|
||
.fetch_optional(&state.pool)
|
||
.await?
|
||
}
|
||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
||
|
||
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(),
|
||
})))
|
||
}
|
||
|
||
pub async fn get_visibility(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
let today = chrono::Utc::now().naive_utc().date().to_string();
|
||
|
||
let row = sqlx::query(
|
||
"SELECT * FROM nightly_cache WHERE catalog_id = ? AND night_date = ?",
|
||
)
|
||
.bind(&id)
|
||
.bind(&today)
|
||
.fetch_optional(&state.pool)
|
||
.await?;
|
||
|
||
match row {
|
||
Some(r) => {
|
||
use sqlx::Row;
|
||
Ok(Json(serde_json::json!({
|
||
"catalog_id": id,
|
||
"night_date": today,
|
||
"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(),
|
||
"rise_utc": r.try_get::<Option<String>, _>("rise_utc").unwrap_or_default(),
|
||
"set_utc": r.try_get::<Option<String>, _>("set_utc").unwrap_or_default(),
|
||
"best_start_utc": r.try_get::<Option<String>, _>("best_start_utc").unwrap_or_default(),
|
||
"best_end_utc": r.try_get::<Option<String>, _>("best_end_utc").unwrap_or_default(),
|
||
"usable_min": r.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
|
||
"meridian_flip_utc": r.try_get::<Option<String>, _>("meridian_flip_utc").unwrap_or_default(),
|
||
"airmass_at_transit": r.try_get::<Option<f64>, _>("airmass_at_transit").unwrap_or_default(),
|
||
"extinction_mag": r.try_get::<Option<f64>, _>("extinction_mag").unwrap_or_default(),
|
||
"moon_sep_deg": r.try_get::<Option<f64>, _>("moon_sep_deg").unwrap_or_default(),
|
||
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||
})))
|
||
}
|
||
None => compute_visibility_live(&state, &id).await,
|
||
}
|
||
}
|
||
|
||
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)
|
||
.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();
|
||
|
||
let today = chrono::Utc::now().naive_utc().date();
|
||
let (dusk, dawn) = astro_twilight(today, LAT, LON)
|
||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||
|
||
let jd = julian_date(dusk + (dawn - dusk) / 2);
|
||
let (moon_ra, moon_dec) = moon_position(jd);
|
||
let moon_illum = moon_illumination(jd);
|
||
let moon_alt = moon_altitude(jd, LAT, LON);
|
||
|
||
let horizon: Vec<HorizonPoint> = sqlx::query_as(
|
||
"SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg",
|
||
)
|
||
.fetch_all(&state.pool)
|
||
.await?;
|
||
|
||
let moon_state = MoonState {
|
||
ra_deg: moon_ra,
|
||
dec_deg: moon_dec,
|
||
illumination: moon_illum,
|
||
alt_at_midnight: moon_alt,
|
||
};
|
||
let window = TonightWindow { dusk, dawn };
|
||
let vis = compute_visibility(ra, dec, &window, &horizon, &moon_state);
|
||
let rec_filter = crate::filters::top_filter(&obj_type, moon_illum * 100.0, moon_alt, vis.moon_sep_deg);
|
||
|
||
Ok(Json(serde_json::json!({
|
||
"catalog_id": id,
|
||
"max_alt_deg": vis.max_alt_deg,
|
||
"transit_utc": vis.transit_utc.map(|t| t.to_rfc3339()),
|
||
"rise_utc": vis.rise_utc.map(|t| t.to_rfc3339()),
|
||
"set_utc": vis.set_utc.map(|t| t.to_rfc3339()),
|
||
"best_start_utc": vis.best_start_utc.map(|t| t.to_rfc3339()),
|
||
"best_end_utc": vis.best_end_utc.map(|t| t.to_rfc3339()),
|
||
"usable_min": vis.usable_min,
|
||
"meridian_flip_utc": vis.meridian_flip_utc.map(|t| t.to_rfc3339()),
|
||
"airmass_at_transit": vis.airmass_at_transit,
|
||
"extinction_mag": vis.extinction_at_transit,
|
||
"moon_sep_deg": vis.moon_sep_deg,
|
||
"recommended_filter": rec_filter,
|
||
"is_visible_tonight": vis.is_visible_tonight,
|
||
})))
|
||
}
|
||
|
||
pub async fn get_curve(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
) -> 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)
|
||
.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 date = chrono::Utc::now().naive_utc().date();
|
||
let (dusk, dawn) = astro_twilight(date, LAT, LON)
|
||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||
|
||
let jd = julian_date(dusk + (dawn - dusk) / 2);
|
||
let (moon_ra, moon_dec) = moon_position(jd);
|
||
let moon_illum = moon_illumination(jd);
|
||
let moon_alt = moon_altitude(jd, LAT, LON);
|
||
|
||
let horizon: Vec<HorizonPoint> = sqlx::query_as(
|
||
"SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg",
|
||
)
|
||
.fetch_all(&state.pool)
|
||
.await?;
|
||
|
||
let moon_state = MoonState {
|
||
ra_deg: moon_ra,
|
||
dec_deg: moon_dec,
|
||
illumination: moon_illum,
|
||
alt_at_midnight: moon_alt,
|
||
};
|
||
let window = TonightWindow { dusk, dawn };
|
||
// Use 1-minute resolution for the interactive altitude curve
|
||
let vis = compute_visibility_with_step(ra, dec, &window, &horizon, &moon_state, 1);
|
||
let curve = serde_json::to_value(&vis.curve).unwrap_or(serde_json::json!([]));
|
||
|
||
Ok(Json(serde_json::json!({ "catalog_id": id, "curve": curve })))
|
||
}
|
||
|
||
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)
|
||
.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?;
|
||
|
||
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),
|
||
r.try_get::<Option<f64>, _>("moon_ra_deg").unwrap_or_default().unwrap_or(0.0),
|
||
r.try_get::<Option<f64>, _>("moon_dec_deg").unwrap_or_default().unwrap_or(0.0),
|
||
),
|
||
None => (0.5, 0.0, 0.0),
|
||
};
|
||
|
||
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 recs = recommend_filters(&obj_type, moon_illum * 100.0, moon_alt, moon_sep);
|
||
Ok(Json(serde_json::json!({ "recommendations": recs })))
|
||
}
|
||
|
||
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)
|
||
.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()))
|
||
}
|
||
|
||
/// Yearly visibility graph: for each of the next 365 nights compute the
|
||
/// object's altitude at local astronomical midnight and the theoretical time
|
||
/// above 30°. This correctly shows seasonal variation (transit altitude is
|
||
/// constant but transit *time* shifts ~4 min/day so the midnight alt varies).
|
||
pub async fn get_yearly(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
use chrono::Duration;
|
||
use crate::astronomy::{
|
||
julian_date as jd_fn, moon_illumination,
|
||
coords::radec_to_altaz,
|
||
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)
|
||
.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();
|
||
|
||
// 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);
|
||
|
||
// Time above 30° per night (theoretical full night, unaffected by season).
|
||
// cos(H30) = (sin30 - sin_dec*sin_lat) / (cos_dec*cos_lat)
|
||
let sin_lat = LAT.to_radians().sin();
|
||
let cos_lat = LAT.to_radians().cos();
|
||
let sin_dec = dec.to_radians().sin();
|
||
let cos_dec = dec.to_radians().cos();
|
||
let cos_h30 = (30_f64.to_radians().sin() - sin_dec * sin_lat) / (cos_dec * cos_lat);
|
||
let usable_theoretical_min: u32 = if cos_h30.abs() <= 1.0 {
|
||
// 2 * H30 degrees * 4 min/degree of HA
|
||
(2.0 * cos_h30.acos().to_degrees() * 4.0) as u32
|
||
} else {
|
||
0
|
||
};
|
||
|
||
let today = chrono::Utc::now().naive_utc().date();
|
||
let mut points = Vec::with_capacity(365);
|
||
|
||
for day_offset in 0..365i64 {
|
||
let date = today + Duration::days(day_offset);
|
||
|
||
// Use 21:00 UTC as proxy for astronomical midnight in France
|
||
// (local midnight ≈ 23:00 local = 22:00 UTC in winter, 22:00 local = 20:00 UTC in summer)
|
||
// 21:00 UTC is a reasonable all-year compromise
|
||
let midnight_utc = date.and_hms_opt(21, 0, 0).unwrap().and_utc();
|
||
let jd = jd_fn(midnight_utc);
|
||
|
||
// Actual altitude at this midnight — this varies with date because LST shifts
|
||
let lst = local_sidereal_time(jd, LON);
|
||
let (alt_at_midnight, _az) = radec_to_altaz(ra, dec, lst, LAT);
|
||
|
||
// Moon illumination at this date
|
||
let moon_illum = moon_illumination(jd);
|
||
|
||
points.push(serde_json::json!({
|
||
"date": date.to_string(),
|
||
// Altitude at local midnight — varies seasonally
|
||
"alt_at_midnight": (alt_at_midnight * 10.0).round() / 10.0,
|
||
// Maximum possible altitude (at transit) — constant but useful reference
|
||
"transit_alt": (transit_alt * 10.0).round() / 10.0,
|
||
// Theoretical time above 30° if the whole night is available
|
||
"usable_min": usable_theoretical_min,
|
||
"moon_illumination": (moon_illum * 100.0).round() / 100.0,
|
||
"obj_type": &obj_type,
|
||
}));
|
||
}
|
||
|
||
Ok(Json(serde_json::json!({ "catalog_id": id, "points": points })))
|
||
}
|
||
|
||
pub async fn get_notes(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
let notes: Option<String> = sqlx::query_scalar(
|
||
"SELECT notes FROM target_notes WHERE catalog_id = ?",
|
||
)
|
||
.bind(&id)
|
||
.fetch_optional(&state.pool)
|
||
.await?
|
||
.flatten();
|
||
|
||
Ok(Json(serde_json::json!({
|
||
"catalog_id": id,
|
||
"notes": notes.unwrap_or_default(),
|
||
})))
|
||
}
|
||
|
||
#[derive(serde::Deserialize)]
|
||
pub struct NotesBody {
|
||
pub notes: String,
|
||
}
|
||
|
||
pub async fn put_notes(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
Json(body): Json<NotesBody>,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
sqlx::query(
|
||
"INSERT OR REPLACE INTO target_notes (catalog_id, notes, updated_at) VALUES (?, ?, unixepoch())",
|
||
)
|
||
.bind(&id)
|
||
.bind(&body.notes)
|
||
.execute(&state.pool)
|
||
.await?;
|
||
|
||
Ok(Json(serde_json::json!({ "catalog_id": id, "status": "updated" })))
|
||
}
|