243 lines
9.8 KiB
Rust
243 lines
9.8 KiB
Rust
use axum::{
|
|
extract::{Path, Query, State},
|
|
Json,
|
|
};
|
|
use chrono::NaiveDate;
|
|
use serde::Deserialize;
|
|
|
|
use crate::astronomy::{julian_date, moon_illumination};
|
|
|
|
use super::{AppError, AppState};
|
|
|
|
/// Returns new moon windows (dates where moon < 5%) with top 3 emission nebulae each.
|
|
pub async fn get_new_moon_windows(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<serde_json::Value>, AppError> {
|
|
use sqlx::Row;
|
|
|
|
// Get all new moon dates in the next 365 days
|
|
let today = chrono::Utc::now().naive_utc().date();
|
|
let end = today + chrono::Duration::days(365);
|
|
|
|
let mut windows: Vec<serde_json::Value> = Vec::new();
|
|
let mut cur = today;
|
|
let mut prev_illum = moon_illum_for_date(cur);
|
|
|
|
while cur <= end {
|
|
let illum = moon_illum_for_date(cur);
|
|
let next_illum = moon_illum_for_date(cur + chrono::Duration::days(1));
|
|
|
|
// New moon = local minimum < 5%
|
|
if illum < 0.05 && illum <= prev_illum && illum <= next_illum {
|
|
let date_str = cur.to_string();
|
|
|
|
// Top 3 emission nebulae for this night from nightly_cache
|
|
let targets = sqlx::query(
|
|
r#"SELECT c.id, c.name, c.common_name, nc.max_alt_deg, nc.recommended_filter
|
|
FROM nightly_cache nc
|
|
JOIN catalog c ON c.id = nc.catalog_id
|
|
WHERE nc.night_date = ?
|
|
AND c.obj_type IN ('emission_nebula', 'snr', 'planetary_nebula')
|
|
AND nc.max_alt_deg >= 20
|
|
ORDER BY nc.max_alt_deg DESC
|
|
LIMIT 3"#,
|
|
)
|
|
.bind(&date_str)
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
let top_targets: Vec<serde_json::Value> = targets.iter().map(|r| serde_json::json!({
|
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
|
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
|
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
|
})).collect();
|
|
|
|
windows.push(serde_json::json!({
|
|
"date": date_str,
|
|
"illumination": illum,
|
|
"top_targets": top_targets,
|
|
}));
|
|
}
|
|
|
|
prev_illum = illum;
|
|
cur += chrono::Duration::days(1);
|
|
}
|
|
|
|
Ok(Json(serde_json::json!({ "windows": windows })))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CalendarQuery {
|
|
pub months: Option<u32>,
|
|
}
|
|
|
|
/// Compute moon illumination for a given calendar date (at 21:00 UTC = start of night).
|
|
fn moon_illum_for_date(date: NaiveDate) -> f64 {
|
|
let dt = date.and_hms_opt(21, 0, 0)
|
|
.map(|dt| chrono::DateTime::from_naive_utc_and_offset(dt, chrono::Utc))
|
|
.unwrap_or_else(chrono::Utc::now);
|
|
let jd = julian_date(dt);
|
|
moon_illumination(jd)
|
|
}
|
|
|
|
pub async fn get_calendar(
|
|
State(state): State<AppState>,
|
|
Query(params): Query<CalendarQuery>,
|
|
) -> Result<Json<serde_json::Value>, AppError> {
|
|
let months = params.months.unwrap_or(3).min(12) as i64;
|
|
let today = chrono::Utc::now().naive_utc().date();
|
|
let end = today + chrono::Duration::days(months * 30);
|
|
|
|
// Pull nightly cache data for the date range
|
|
let rows = sqlx::query(
|
|
r#"SELECT
|
|
nc.night_date,
|
|
COUNT(CASE WHEN nc.max_alt_deg >= 15 THEN 1 END) as visible_count,
|
|
MAX(nc.usable_min) as max_usable_min,
|
|
AVG(nc.max_alt_deg) as avg_max_alt
|
|
FROM nightly_cache nc
|
|
WHERE nc.night_date >= ? AND nc.night_date <= ?
|
|
GROUP BY nc.night_date
|
|
ORDER BY nc.night_date"#,
|
|
)
|
|
.bind(today.to_string())
|
|
.bind(end.to_string())
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
// Build a map from date string → nightly cache data
|
|
let cache_map: std::collections::HashMap<String, (i64, i64, f64)> = rows.iter().map(|r| {
|
|
use sqlx::Row;
|
|
let date = r.try_get::<Option<String>, _>("night_date").unwrap_or_default().unwrap_or_default();
|
|
let visible = r.try_get::<Option<i64>, _>("visible_count").unwrap_or_default().unwrap_or(0);
|
|
let usable = r.try_get::<Option<i64>, _>("max_usable_min").unwrap_or_default().unwrap_or(0);
|
|
let avg_alt = r.try_get::<Option<f64>, _>("avg_max_alt").unwrap_or_default().unwrap_or(0.0);
|
|
(date, (visible, usable, avg_alt))
|
|
}).collect();
|
|
|
|
// Generate a day entry for every calendar day in range (so moon is always shown)
|
|
let mut days = Vec::new();
|
|
let mut cur = today;
|
|
while cur <= end {
|
|
let date_str = cur.to_string();
|
|
let moon_illum = moon_illum_for_date(cur);
|
|
let (visible_count, max_usable_min, avg_max_alt) = cache_map.get(&date_str)
|
|
.copied()
|
|
.unwrap_or((0, 0, 0.0));
|
|
|
|
days.push(serde_json::json!({
|
|
"date": date_str,
|
|
"visible_count": visible_count,
|
|
"max_usable_min": max_usable_min,
|
|
"avg_max_alt": avg_max_alt,
|
|
"moon_illumination": moon_illum,
|
|
}));
|
|
cur += chrono::Duration::days(1);
|
|
}
|
|
|
|
Ok(Json(serde_json::json!({ "days": days })))
|
|
}
|
|
|
|
pub async fn get_calendar_date(
|
|
State(state): State<AppState>,
|
|
Path(date): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, AppError> {
|
|
use sqlx::Row;
|
|
|
|
// Top 10 targets for this night
|
|
let targets = sqlx::query(
|
|
r#"SELECT c.id, c.name, c.common_name, c.obj_type,
|
|
nc.max_alt_deg, nc.usable_min, nc.transit_utc, nc.recommended_filter
|
|
FROM nightly_cache nc
|
|
JOIN catalog c ON c.id = nc.catalog_id
|
|
WHERE nc.night_date = ? AND nc.max_alt_deg >= 15
|
|
ORDER BY nc.max_alt_deg DESC
|
|
LIMIT 10"#,
|
|
)
|
|
.bind(&date)
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
let target_list: Vec<serde_json::Value> = targets.iter().map(|r| {
|
|
serde_json::json!({
|
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
|
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
|
"usable_min": r.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
|
|
"transit_utc": r.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
|
|
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
|
})
|
|
}).collect();
|
|
|
|
// Tonight summary from the `tonight` table (only available for tonight's date)
|
|
let tonight_row = sqlx::query("SELECT * FROM tonight WHERE id = 1 AND date = ?")
|
|
.bind(&date)
|
|
.fetch_optional(&state.pool)
|
|
.await?;
|
|
|
|
let tonight_summary = tonight_row.map(|r| serde_json::json!({
|
|
"astro_dusk_utc": r.try_get::<Option<String>, _>("astro_dusk_utc").unwrap_or_default(),
|
|
"astro_dawn_utc": r.try_get::<Option<String>, _>("astro_dawn_utc").unwrap_or_default(),
|
|
"moon_rise_utc": r.try_get::<Option<String>, _>("moon_rise_utc").unwrap_or_default(),
|
|
"moon_set_utc": r.try_get::<Option<String>, _>("moon_set_utc").unwrap_or_default(),
|
|
"moon_illumination": r.try_get::<Option<f64>, _>("moon_illumination").unwrap_or_default(),
|
|
"moon_phase_name": r.try_get::<Option<String>, _>("moon_phase_name").unwrap_or_default(),
|
|
"true_dark_start_utc": r.try_get::<Option<String>, _>("true_dark_start_utc").unwrap_or_default(),
|
|
"true_dark_end_utc": r.try_get::<Option<String>, _>("true_dark_end_utc").unwrap_or_default(),
|
|
"true_dark_minutes": r.try_get::<Option<i32>, _>("true_dark_minutes").unwrap_or_default(),
|
|
}));
|
|
|
|
// Weather summary from cache (only meaningful for today/near future)
|
|
let weather_row = sqlx::query(
|
|
"SELECT go_nogo, temp_c, dew_point_c, seventimer_json FROM weather_cache WHERE id = 1"
|
|
)
|
|
.fetch_optional(&state.pool)
|
|
.await?;
|
|
|
|
let weather_summary = weather_row.map(|r| {
|
|
let go_nogo = r.try_get::<Option<String>, _>("go_nogo").unwrap_or_default();
|
|
let temp_c = r.try_get::<Option<f64>, _>("temp_c").unwrap_or_default();
|
|
let dew_c = r.try_get::<Option<f64>, _>("dew_point_c").unwrap_or_default();
|
|
let seventimer: Option<serde_json::Value> = r.try_get::<Option<String>, _>("seventimer_json")
|
|
.unwrap_or_default()
|
|
.and_then(|s| serde_json::from_str(&s).ok());
|
|
|
|
// Extract tonight's cloudcover/seeing from 7timer if available
|
|
let (cloudcover, seeing, transparency) = seventimer
|
|
.as_ref()
|
|
.and_then(|v| v.get("dataseries")?.as_array()?.first().cloned())
|
|
.map(|slot| (
|
|
slot.get("cloudcover").and_then(|v| v.as_i64()),
|
|
slot.get("seeing").and_then(|v| v.as_i64()),
|
|
slot.get("transparency").and_then(|v| v.as_i64()),
|
|
))
|
|
.unwrap_or((None, None, None));
|
|
|
|
serde_json::json!({
|
|
"go_nogo": go_nogo,
|
|
"temp_c": temp_c,
|
|
"dew_point_c": dew_c,
|
|
"cloudcover": cloudcover,
|
|
"seeing": seeing,
|
|
"transparency": transparency,
|
|
})
|
|
});
|
|
|
|
// Moon illumination for the requested date (always computable)
|
|
let requested_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d")
|
|
.unwrap_or_else(|_| chrono::Utc::now().naive_utc().date());
|
|
let moon_illum = moon_illum_for_date(requested_date);
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"date": date,
|
|
"moon_illumination": moon_illum,
|
|
"top_targets": target_list,
|
|
"tonight": tonight_summary,
|
|
"weather": weather_summary,
|
|
})))
|
|
}
|