Initial Commit
This commit is contained in:
@@ -0,0 +1,643 @@
|
||||
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" })))
|
||||
}
|
||||
Reference in New Issue
Block a user