Initial Commit

This commit is contained in:
2026-04-09 23:23:31 +02:00
commit a68677681f
94 changed files with 15170 additions and 0 deletions
+643
View File
@@ -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 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" })))
}