Files
Astronome/backend/src/api/targets.rs
T
2026-04-10 00:20:32 +02:00

644 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 20150% 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" })))
}