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