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,
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use axum::{
|
||||
extract::{Multipart, Path, State},
|
||||
Json,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{AppError, AppState};
|
||||
|
||||
const GALLERY_DIR: &str = "/data/gallery";
|
||||
|
||||
pub async fn list_all_gallery(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT g.id, g.catalog_id, g.filename, g.caption, g.created_at,
|
||||
c.name AS target_name, c.common_name AS target_common_name
|
||||
FROM gallery g
|
||||
LEFT JOIN catalog c ON c.id = g.catalog_id
|
||||
ORDER BY g.created_at DESC"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items: Vec<serde_json::Value> = rows.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
let id: i32 = r.try_get("id").unwrap_or_default();
|
||||
let catalog_id: String = r.try_get("catalog_id").unwrap_or_default();
|
||||
let filename: String = r.try_get("filename").unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"catalog_id": &catalog_id,
|
||||
"filename": &filename,
|
||||
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
|
||||
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
|
||||
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||
"target_name": r.try_get::<Option<String>, _>("target_name").unwrap_or_default(),
|
||||
"target_common_name": r.try_get::<Option<String>, _>("target_common_name").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn list_gallery(
|
||||
State(state): State<AppState>,
|
||||
Path(catalog_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT * FROM gallery WHERE catalog_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&catalog_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items: Vec<serde_json::Value> = rows.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
let id: i32 = r.try_get("id").unwrap_or_default();
|
||||
let filename: String = r.try_get("filename").unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"catalog_id": catalog_id,
|
||||
"filename": filename,
|
||||
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
|
||||
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
|
||||
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn upload_image(
|
||||
State(state): State<AppState>,
|
||||
Path(catalog_id): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let mut image_bytes: Option<Vec<u8>> = None;
|
||||
let mut orig_filename = String::from("image.jpg");
|
||||
let mut caption: Option<String> = None;
|
||||
let mut log_id: Option<i32> = None;
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| AppError::Internal(e.to_string()))? {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
orig_filename = field.file_name().unwrap_or("image.jpg").to_string();
|
||||
let bytes = field.bytes().await.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
if bytes.len() > 50 * 1024 * 1024 {
|
||||
return Err(AppError::BadRequest("File exceeds 50MB limit".to_string()));
|
||||
}
|
||||
image_bytes = Some(bytes.to_vec());
|
||||
}
|
||||
"caption" => {
|
||||
caption = Some(field.text().await.map_err(|e| AppError::Internal(e.to_string()))?);
|
||||
}
|
||||
"log_id" => {
|
||||
log_id = field.text().await.ok().and_then(|s| s.parse().ok());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = image_bytes.ok_or_else(|| AppError::BadRequest("No file uploaded".to_string()))?;
|
||||
|
||||
// Convert TIFF to JPEG if needed, else store as-is
|
||||
let ext = std::path::Path::new(&orig_filename)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("jpg")
|
||||
.to_lowercase();
|
||||
|
||||
let (final_bytes, final_ext) = if ext == "tiff" || ext == "tif" {
|
||||
match convert_tiff_to_jpeg(&bytes) {
|
||||
Ok(jpeg) => (jpeg, "jpg".to_string()),
|
||||
Err(_) => (bytes, ext),
|
||||
}
|
||||
} else {
|
||||
(bytes, ext)
|
||||
};
|
||||
|
||||
// Generate unique filename
|
||||
let uid = uuid::Uuid::new_v4();
|
||||
let filename = format!("{}.{}", uid, final_ext);
|
||||
|
||||
// Ensure directory exists
|
||||
let dir = PathBuf::from(GALLERY_DIR).join(&catalog_id);
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to create gallery dir: {}", e)))?;
|
||||
|
||||
let file_path = dir.join(&filename);
|
||||
tokio::fs::write(&file_path, &final_bytes)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to write image: {}", e)))?;
|
||||
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO gallery (catalog_id, log_id, filename, caption) VALUES (?, ?, ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(&catalog_id)
|
||||
.bind(log_id)
|
||||
.bind(&filename)
|
||||
.bind(&caption)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": id,
|
||||
"catalog_id": catalog_id,
|
||||
"filename": filename,
|
||||
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn delete_image(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let row = sqlx::query("SELECT catalog_id, filename FROM gallery WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Gallery image {} not found", id)))?;
|
||||
|
||||
use sqlx::Row;
|
||||
let catalog_id: String = row.try_get("catalog_id").unwrap_or_default();
|
||||
let filename: String = row.try_get("filename").unwrap_or_default();
|
||||
|
||||
let file_path = PathBuf::from(GALLERY_DIR).join(&catalog_id).join(&filename);
|
||||
let _ = tokio::fs::remove_file(&file_path).await;
|
||||
|
||||
sqlx::query("DELETE FROM gallery WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "status": "deleted" })))
|
||||
}
|
||||
|
||||
fn convert_tiff_to_jpeg(bytes: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let img = image::load_from_memory(bytes)?;
|
||||
let mut output = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut output);
|
||||
img.write_to(&mut cursor, image::ImageOutputFormat::Jpeg(90))?;
|
||||
Ok(output)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::astronomy::HorizonPoint;
|
||||
|
||||
use super::{AppError, AppState};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HorizonEntry {
|
||||
pub az_deg: i32,
|
||||
pub alt_deg: f64,
|
||||
}
|
||||
|
||||
pub async fn get_horizon(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let points: Vec<HorizonPoint> = sqlx::query_as(
|
||||
"SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "points": points })))
|
||||
}
|
||||
|
||||
pub async fn put_horizon(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Vec<HorizonEntry>>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if body.len() != 360 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Horizon must have exactly 360 points, got {}",
|
||||
body.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut tx = state.pool.begin().await?;
|
||||
for entry in &body {
|
||||
let az = entry.az_deg.rem_euclid(360);
|
||||
let alt = entry.alt_deg.clamp(0.0, 90.0);
|
||||
sqlx::query("UPDATE horizon SET alt_deg = ? WHERE az_deg = ?")
|
||||
.bind(alt)
|
||||
.bind(az)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "status": "updated", "count": body.len() })))
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{AppError, AppState};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LogQuery {
|
||||
pub page: Option<u32>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CreateLogEntry {
|
||||
pub catalog_id: String,
|
||||
pub session_date: String,
|
||||
pub filter_id: String,
|
||||
pub integration_min: i32,
|
||||
pub quality: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub guiding_rms: Option<f64>,
|
||||
pub mean_temp_c: Option<f64>,
|
||||
pub phd2_log_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct UpdateLogEntry {
|
||||
pub quality: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub guiding_rms: Option<f64>,
|
||||
}
|
||||
|
||||
pub async fn list_log(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<LogQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
let limit = params.limit.unwrap_or(50).min(200);
|
||||
let offset = (page - 1) * limit;
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT l.*, c.name, c.common_name, c.obj_type
|
||||
FROM imaging_log l
|
||||
JOIN catalog c ON c.id = l.catalog_id
|
||||
ORDER BY l.session_date DESC, l.created_at DESC
|
||||
LIMIT ? OFFSET ?"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items = rows.iter().map(row_to_json).collect::<Vec<_>>();
|
||||
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM imaging_log")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "limit": limit })))
|
||||
}
|
||||
|
||||
pub async fn get_target_log(
|
||||
State(state): State<AppState>,
|
||||
Path(catalog_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT l.*, c.name, c.common_name, c.obj_type
|
||||
FROM imaging_log l
|
||||
JOIN catalog c ON c.id = l.catalog_id
|
||||
WHERE l.catalog_id = ?
|
||||
ORDER BY l.session_date DESC"#,
|
||||
)
|
||||
.bind(&catalog_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items = rows.iter().map(row_to_json).collect::<Vec<_>>();
|
||||
|
||||
let total_min: Option<i64> = sqlx::query_scalar(
|
||||
"SELECT SUM(integration_min) FROM imaging_log WHERE catalog_id = ?",
|
||||
)
|
||||
.bind(&catalog_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
// Filter breakdown: keeper hours per filter
|
||||
let breakdown_rows = sqlx::query(
|
||||
r#"SELECT filter_id,
|
||||
SUM(integration_min) as total_min,
|
||||
COUNT(*) as sessions
|
||||
FROM imaging_log
|
||||
WHERE catalog_id = ? AND quality = 'keeper'
|
||||
GROUP BY filter_id"#,
|
||||
)
|
||||
.bind(&catalog_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let filter_breakdown: Vec<serde_json::Value> = breakdown_rows.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
|
||||
"total_min": r.try_get::<i64, _>("total_min").unwrap_or_default(),
|
||||
"sessions": r.try_get::<i64, _>("sessions").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"catalog_id": catalog_id,
|
||||
"items": items,
|
||||
"total_integration_min": total_min.unwrap_or(0),
|
||||
"filter_breakdown": filter_breakdown,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Export all imaging log entries as a CSV file.
|
||||
pub async fn export_log_csv(
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT l.session_date, c.name, c.common_name, c.obj_type,
|
||||
l.filter_id, l.integration_min, l.quality,
|
||||
l.guiding_rms, l.mean_temp_c, l.notes
|
||||
FROM imaging_log l
|
||||
JOIN catalog c ON c.id = l.catalog_id
|
||||
ORDER BY l.session_date DESC, l.created_at DESC"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut csv = String::from("date,target,common_name,type,filter,integration_min,quality,guiding_rms,temp_c,notes\n");
|
||||
for r in &rows {
|
||||
use sqlx::Row;
|
||||
let date = r.try_get::<String, _>("session_date").unwrap_or_default();
|
||||
let name = r.try_get::<String, _>("name").unwrap_or_default();
|
||||
let common = r.try_get::<Option<String>, _>("common_name").unwrap_or_default().unwrap_or_default();
|
||||
let obj_type = r.try_get::<String, _>("obj_type").unwrap_or_default();
|
||||
let filter = r.try_get::<String, _>("filter_id").unwrap_or_default();
|
||||
let mins = r.try_get::<i32, _>("integration_min").unwrap_or_default();
|
||||
let quality = r.try_get::<String, _>("quality").unwrap_or_default();
|
||||
let rms = r.try_get::<Option<f64>, _>("guiding_rms").unwrap_or_default()
|
||||
.map(|v| format!("{:.2}", v)).unwrap_or_default();
|
||||
let temp = r.try_get::<Option<f64>, _>("mean_temp_c").unwrap_or_default()
|
||||
.map(|v| format!("{:.1}", v)).unwrap_or_default();
|
||||
let notes = r.try_get::<Option<String>, _>("notes").unwrap_or_default()
|
||||
.unwrap_or_default().replace('"', "\"\"");
|
||||
csv.push_str(&format!(
|
||||
"{},{},{},{},{},{},{},{},{},\"{}\"\n",
|
||||
date, name, common, obj_type, filter, mins, quality, rms, temp, notes
|
||||
));
|
||||
}
|
||||
|
||||
(
|
||||
[
|
||||
("Content-Type", "text/csv; charset=utf-8"),
|
||||
("Content-Disposition", "attachment; filename=\"astronome_log.csv\""),
|
||||
],
|
||||
csv,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn create_log(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<CreateLogEntry>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let quality = body.quality.as_deref().unwrap_or("pending");
|
||||
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
r#"INSERT INTO imaging_log
|
||||
(catalog_id, session_date, filter_id, integration_min, quality, notes,
|
||||
guiding_rms, mean_temp_c, phd2_log_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(&body.catalog_id)
|
||||
.bind(&body.session_date)
|
||||
.bind(&body.filter_id)
|
||||
.bind(body.integration_min)
|
||||
.bind(quality)
|
||||
.bind(&body.notes)
|
||||
.bind(body.guiding_rms)
|
||||
.bind(body.mean_temp_c)
|
||||
.bind(body.phd2_log_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "status": "created" })))
|
||||
}
|
||||
|
||||
pub async fn update_log(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
Json(body): Json<UpdateLogEntry>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query(
|
||||
"UPDATE imaging_log SET quality = COALESCE(?, quality), notes = COALESCE(?, notes), guiding_rms = COALESCE(?, guiding_rms) WHERE id = ?",
|
||||
)
|
||||
.bind(&body.quality)
|
||||
.bind(&body.notes)
|
||||
.bind(body.guiding_rms)
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "status": "updated" })))
|
||||
}
|
||||
|
||||
pub async fn delete_log(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query("DELETE FROM imaging_log WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "status": "deleted" })))
|
||||
}
|
||||
|
||||
fn row_to_json(r: &sqlx::sqlite::SqliteRow) -> serde_json::Value {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"id": r.try_get::<i32, _>("id").unwrap_or_default(),
|
||||
"catalog_id": r.try_get::<String, _>("catalog_id").unwrap_or_default(),
|
||||
"session_date": r.try_get::<String, _>("session_date").unwrap_or_default(),
|
||||
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
|
||||
"integration_min": r.try_get::<i32, _>("integration_min").unwrap_or_default(),
|
||||
"quality": r.try_get::<String, _>("quality").unwrap_or_default(),
|
||||
"notes": r.try_get::<Option<String>, _>("notes").unwrap_or_default(),
|
||||
"guiding_rms": r.try_get::<Option<f64>, _>("guiding_rms").unwrap_or_default(),
|
||||
"mean_temp_c": r.try_get::<Option<f64>, _>("mean_temp_c").unwrap_or_default(),
|
||||
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||
"target_name": r.try_get::<Option<String>, _>("name").unwrap_or_default(),
|
||||
"target_common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||
"target_obj_type": r.try_get::<Option<String>, _>("obj_type").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
pub mod calendar;
|
||||
pub mod gallery;
|
||||
pub mod horizon;
|
||||
pub mod log;
|
||||
pub mod phd2;
|
||||
pub mod solar_system;
|
||||
pub mod stats;
|
||||
pub mod targets;
|
||||
pub mod tonight;
|
||||
pub mod weather;
|
||||
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::catalog::force_refresh_catalog;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("Database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
|
||||
};
|
||||
(status, Json(serde_json::json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_router(pool: SqlitePool) -> Router {
|
||||
let state = AppState { pool };
|
||||
|
||||
// Gallery static files
|
||||
let gallery_dir = std::path::PathBuf::from("/data/gallery");
|
||||
let _ = std::fs::create_dir_all(&gallery_dir);
|
||||
|
||||
Router::new()
|
||||
// Health
|
||||
.route("/api/health", get(health))
|
||||
// Targets
|
||||
.route("/api/targets", get(targets::list_targets))
|
||||
.route("/api/targets/:id", get(targets::get_target))
|
||||
.route("/api/targets/:id/visibility", get(targets::get_visibility))
|
||||
.route("/api/targets/:id/curve", get(targets::get_curve))
|
||||
.route("/api/targets/:id/filters", get(targets::get_filters))
|
||||
.route("/api/targets/:id/workflow/:filter_id", get(targets::get_workflow_handler))
|
||||
.route("/api/targets/:id/yearly", get(targets::get_yearly))
|
||||
.route("/api/targets/:id/notes", get(targets::get_notes).put(targets::put_notes))
|
||||
// Tonight
|
||||
.route("/api/tonight", get(tonight::get_tonight))
|
||||
// Calendar
|
||||
.route("/api/calendar", get(calendar::get_calendar))
|
||||
.route("/api/calendar/new-moon-windows", get(calendar::get_new_moon_windows))
|
||||
.route("/api/calendar/:date", get(calendar::get_calendar_date))
|
||||
// Weather
|
||||
.route("/api/weather", get(weather::get_weather))
|
||||
.route("/api/weather/forecast", get(weather::get_forecast))
|
||||
// Log
|
||||
.route("/api/log", get(log::list_log).post(log::create_log))
|
||||
.route("/api/log/export", get(log::export_log_csv))
|
||||
.route("/api/log/:catalog_id", get(log::get_target_log))
|
||||
.route("/api/log/entry/:id", put(log::update_log).delete(log::delete_log))
|
||||
// PHD2
|
||||
.route("/api/phd2/upload", post(phd2::upload_phd2))
|
||||
.route("/api/phd2", get(phd2::list_phd2))
|
||||
.route("/api/phd2/:id", get(phd2::get_phd2).delete(phd2::delete_phd2))
|
||||
// Gallery
|
||||
.route("/api/gallery", get(gallery::list_all_gallery))
|
||||
.route("/api/gallery/:catalog_id", get(gallery::list_gallery).post(gallery::upload_image))
|
||||
.route("/api/gallery/item/:id", delete(gallery::delete_image))
|
||||
// Horizon
|
||||
.route("/api/horizon", get(horizon::get_horizon).put(horizon::put_horizon))
|
||||
// Solar System
|
||||
.route("/api/solar-system", get(solar_system::get_solar_system))
|
||||
// Custom targets
|
||||
.route("/api/custom-targets", get(solar_system::list_custom_targets).post(solar_system::create_custom_target))
|
||||
.route("/api/custom-targets/:id", delete(solar_system::delete_custom_target))
|
||||
// Admin
|
||||
.route("/api/catalog/refresh", post(catalog_refresh))
|
||||
.route("/api/catalog/rebuild", get(catalog_rebuild))
|
||||
.route("/api/nightly/recompute", post(nightly_recompute))
|
||||
// Stats
|
||||
.route("/api/stats", get(stats::get_stats))
|
||||
// Static gallery files served via tower-http
|
||||
.nest_service(
|
||||
"/data/gallery",
|
||||
tower_http::services::ServeDir::new(gallery_dir),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn catalog_refresh(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let pool = state.pool.clone();
|
||||
tokio::spawn(async move {
|
||||
match force_refresh_catalog(&pool).await {
|
||||
Ok(n) => tracing::info!("Manual catalog refresh complete: {} objects", n),
|
||||
Err(e) => tracing::error!("Manual catalog refresh failed: {}", e),
|
||||
}
|
||||
});
|
||||
Ok(Json(serde_json::json!({ "status": "refresh_started" })))
|
||||
}
|
||||
|
||||
async fn catalog_rebuild(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let pool = state.pool.clone();
|
||||
|
||||
match catalog_rebuild_task(&pool).await {
|
||||
Ok(stats) => {
|
||||
tracing::info!(
|
||||
"Manual catalog rebuild complete: {} objects ({})",
|
||||
stats.total,
|
||||
stats.by_type.iter()
|
||||
.map(|(t, c)| format!("{}: {}", t, c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "success",
|
||||
"total": stats.total,
|
||||
"by_type": stats.by_type,
|
||||
"messier_count": stats.messier_count,
|
||||
"has_sizes": stats.has_sizes,
|
||||
})))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Manual catalog rebuild failed: {}", e);
|
||||
Err(AppError::Internal(format!("Rebuild failed: {}", e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RebuildStats {
|
||||
total: usize,
|
||||
by_type: std::collections::HashMap<String, usize>,
|
||||
messier_count: usize,
|
||||
has_sizes: usize,
|
||||
}
|
||||
|
||||
async fn catalog_rebuild_task(pool: &SqlitePool) -> Result<RebuildStats, Box<dyn std::error::Error>> {
|
||||
// Clear existing catalog
|
||||
sqlx::query("DELETE FROM catalog").execute(pool).await?;
|
||||
sqlx::query("DELETE FROM nightly_cache").execute(pool).await?;
|
||||
|
||||
// Build fresh catalog
|
||||
let entries = crate::catalog::build_catalog().await?;
|
||||
let total = entries.len();
|
||||
|
||||
// Compute stats
|
||||
let mut by_type: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for entry in &entries {
|
||||
*by_type.entry(entry.obj_type.clone()).or_insert(0) += 1;
|
||||
}
|
||||
let messier_count = entries.iter().filter(|e| e.messier_num.is_some()).count();
|
||||
let has_sizes = entries.iter().filter(|e| e.size_arcmin_maj.is_some()).count();
|
||||
|
||||
// Upsert entries to database
|
||||
crate::catalog::upsert_entries(pool, &entries).await?;
|
||||
|
||||
// Update catalog version
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('catalog_version', ?)")
|
||||
.bind(crate::catalog::CATALOG_VERSION)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Automatically trigger nightly recompute
|
||||
if let Err(e) = crate::jobs::precompute_tonight(pool).await {
|
||||
tracing::warn!("Nightly precompute after rebuild failed: {}", e);
|
||||
}
|
||||
|
||||
Ok(RebuildStats { total, by_type, messier_count, has_sizes })
|
||||
}
|
||||
|
||||
async fn nightly_recompute(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let pool = state.pool.clone();
|
||||
tokio::spawn(async move {
|
||||
match crate::jobs::nightly::precompute_tonight(&pool).await {
|
||||
Ok(()) => tracing::info!("Manual nightly recompute complete"),
|
||||
Err(e) => tracing::error!("Manual nightly recompute failed: {}", e),
|
||||
}
|
||||
});
|
||||
Ok(Json(serde_json::json!({ "status": "recompute_started" })))
|
||||
}
|
||||
|
||||
async fn health(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let catalog_size: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM catalog")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let catalog_last_refreshed: Option<i64> =
|
||||
sqlx::query_scalar("SELECT MAX(fetched_at) FROM catalog")
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
.flatten();
|
||||
|
||||
// SQLite page_count * page_size gives approximate DB size in bytes
|
||||
let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(4096);
|
||||
let db_size_bytes = page_count * page_size;
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"catalog_size": catalog_size,
|
||||
"catalog_last_refreshed": catalog_last_refreshed,
|
||||
"db_size_bytes": db_size_bytes,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use axum::{
|
||||
extract::{Multipart, Path, State},
|
||||
Json,
|
||||
};
|
||||
|
||||
use crate::phd2::parse_phd2_log;
|
||||
|
||||
use super::{AppError, AppState};
|
||||
|
||||
pub async fn upload_phd2(
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let mut filename = String::new();
|
||||
let mut content = String::new();
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| AppError::Internal(e.to_string()))? {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
filename = field.file_name().unwrap_or("phd2.log").to_string();
|
||||
let bytes = field.bytes().await.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
content = String::from_utf8_lossy(&bytes).to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if content.is_empty() {
|
||||
return Err(AppError::BadRequest("No file content".to_string()));
|
||||
}
|
||||
|
||||
let analysis = parse_phd2_log(&content)
|
||||
.map_err(|e| AppError::BadRequest(format!("PHD2 parse error: {}", e)))?;
|
||||
|
||||
let session_date = &analysis.session_date;
|
||||
|
||||
// Check for duplicates: same session_date, similar duration, and similar RMS stats
|
||||
let existing: Option<(i32, i32)> = sqlx::query_as(
|
||||
r#"SELECT id, duration_min FROM phd2_logs
|
||||
WHERE session_date = ?
|
||||
AND abs(duration_min - ?) < 2
|
||||
AND abs(rms_total - ?) < 0.1
|
||||
LIMIT 1"#
|
||||
)
|
||||
.bind(session_date)
|
||||
.bind(analysis.duration_min as i32)
|
||||
.bind(analysis.rms_total_arcsec)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if let Some((dup_id, _)) = existing {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"duplicate": true,
|
||||
"duplicate_id": dup_id,
|
||||
"message": format!("Duplicate session detected (ID: {}). Not inserted.", dup_id),
|
||||
"analysis": analysis,
|
||||
"filename": filename,
|
||||
})));
|
||||
}
|
||||
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
r#"INSERT INTO phd2_logs
|
||||
(session_date, filename, rms_total, rms_ra, rms_dec, peak_error,
|
||||
star_lost_count, duration_min, guide_star_snr)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(session_date)
|
||||
.bind(&filename)
|
||||
.bind(analysis.rms_total_arcsec)
|
||||
.bind(analysis.rms_ra_arcsec)
|
||||
.bind(analysis.rms_dec_arcsec)
|
||||
.bind(analysis.peak_error_arcsec)
|
||||
.bind(analysis.star_lost_count)
|
||||
.bind(analysis.duration_min)
|
||||
.bind(analysis.mean_snr)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": id,
|
||||
"duplicate": false,
|
||||
"analysis": analysis,
|
||||
"filename": filename,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn list_phd2(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT * FROM phd2_logs ORDER BY session_date DESC, created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items: Vec<serde_json::Value> = rows.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"id": r.try_get::<i32, _>("id").unwrap_or_default(),
|
||||
"session_date": r.try_get::<String, _>("session_date").unwrap_or_default(),
|
||||
"filename": r.try_get::<String, _>("filename").unwrap_or_default(),
|
||||
"rms_total": r.try_get::<Option<f64>, _>("rms_total").unwrap_or_default(),
|
||||
"rms_ra": r.try_get::<Option<f64>, _>("rms_ra").unwrap_or_default(),
|
||||
"rms_dec": r.try_get::<Option<f64>, _>("rms_dec").unwrap_or_default(),
|
||||
"peak_error": r.try_get::<Option<f64>, _>("peak_error").unwrap_or_default(),
|
||||
"star_lost_count": r.try_get::<Option<i32>, _>("star_lost_count").unwrap_or_default(),
|
||||
"duration_min": r.try_get::<Option<i32>, _>("duration_min").unwrap_or_default(),
|
||||
"guide_star_snr": r.try_get::<Option<f64>, _>("guide_star_snr").unwrap_or_default(),
|
||||
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn get_phd2(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let row = sqlx::query("SELECT * FROM phd2_logs WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PHD2 log {} not found", id)))?;
|
||||
|
||||
use sqlx::Row;
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": row.try_get::<i32, _>("id").unwrap_or_default(),
|
||||
"session_date": row.try_get::<String, _>("session_date").unwrap_or_default(),
|
||||
"filename": row.try_get::<String, _>("filename").unwrap_or_default(),
|
||||
"rms_total": row.try_get::<Option<f64>, _>("rms_total").unwrap_or_default(),
|
||||
"rms_ra": row.try_get::<Option<f64>, _>("rms_ra").unwrap_or_default(),
|
||||
"rms_dec": row.try_get::<Option<f64>, _>("rms_dec").unwrap_or_default(),
|
||||
"peak_error": row.try_get::<Option<f64>, _>("peak_error").unwrap_or_default(),
|
||||
"star_lost_count": row.try_get::<Option<i32>, _>("star_lost_count").unwrap_or_default(),
|
||||
"duration_min": row.try_get::<Option<i32>, _>("duration_min").unwrap_or_default(),
|
||||
"guide_star_snr": row.try_get::<Option<f64>, _>("guide_star_snr").unwrap_or_default(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn delete_phd2(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let result = sqlx::query("DELETE FROM phd2_logs WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound(format!("PHD2 log {} not found", id)));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "deleted",
|
||||
"id": id,
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
/// Solar System objects: planets, Moon, bright comets, and custom/TLE targets.
|
||||
/// Planet positions use low-precision analytical series accurate to ~1' for dates near J2000.
|
||||
use axum::{extract::State, Json};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::astronomy::{
|
||||
coords::{airmass, radec_to_altaz},
|
||||
julian_date,
|
||||
time::local_sidereal_time,
|
||||
moon_position,
|
||||
};
|
||||
use crate::config::{LAT, LON};
|
||||
use super::{AppError, AppState};
|
||||
|
||||
/// Propagate TLE to current position → (ra_deg, dec_deg, alt_deg, az_deg).
|
||||
/// Uses sgp4 crate; returns None on parse or propagation error.
|
||||
fn tle_position(line1: &str, line2: &str) -> Option<(f64, f64, f64, f64)> {
|
||||
use sgp4::{Constants, Elements};
|
||||
|
||||
let elements = Elements::from_tle(
|
||||
None,
|
||||
line1.as_bytes(),
|
||||
line2.as_bytes(),
|
||||
).ok()?;
|
||||
let constants = Constants::from_elements(&elements).ok()?;
|
||||
|
||||
// Minutes since TLE epoch
|
||||
let now = Utc::now();
|
||||
let epoch = chrono::DateTime::<Utc>::from_naive_utc_and_offset(elements.datetime, Utc);
|
||||
let minutes = (now - epoch).num_seconds() as f64 / 60.0;
|
||||
|
||||
let prediction = constants.propagate(sgp4::MinutesSinceEpoch(minutes)).ok()?;
|
||||
|
||||
// ECI position in km (TEME frame)
|
||||
let (x, y, z) = (prediction.position[0], prediction.position[1], prediction.position[2]);
|
||||
|
||||
// Convert ECI to RA/Dec (TEME ≈ J2000 for our purposes, error < 0.01°)
|
||||
let r = (x * x + y * y + z * z).sqrt();
|
||||
if r < 1.0 { return None; }
|
||||
|
||||
let ra_rad = y.atan2(x);
|
||||
let dec_rad = (z / r).asin();
|
||||
let ra_deg = ra_rad.to_degrees().rem_euclid(360.0);
|
||||
let dec_deg = dec_rad.to_degrees();
|
||||
|
||||
// Convert to Alt/Az
|
||||
let jd = julian_date(now);
|
||||
let lst = local_sidereal_time(jd, LON);
|
||||
let (alt, az) = radec_to_altaz(ra_deg, dec_deg, lst, LAT);
|
||||
|
||||
Some((ra_deg, dec_deg, alt, az))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SolarSystemObject {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub obj_type: String, // planet, moon, asteroid, comet
|
||||
pub ra_deg: f64,
|
||||
pub dec_deg: f64,
|
||||
pub ra_h: String,
|
||||
pub dec_dms: String,
|
||||
pub alt_deg: f64,
|
||||
pub az_deg: f64,
|
||||
pub airmass: f64,
|
||||
pub mag_v: Option<f64>,
|
||||
pub angular_size_arcsec: Option<f64>,
|
||||
pub phase_pct: Option<f64>, // 0–100
|
||||
pub distance_au: Option<f64>,
|
||||
pub elongation_deg: Option<f64>, // from Sun
|
||||
pub is_visible: bool, // alt > 15°
|
||||
}
|
||||
|
||||
fn fmt_ra(ra: f64) -> String {
|
||||
let total_sec = (ra / 15.0) * 3600.0;
|
||||
let h = (total_sec / 3600.0) as u32;
|
||||
let m = ((total_sec % 3600.0) / 60.0) as u32;
|
||||
let s = (total_sec % 60.0) as u32;
|
||||
format!("{:02}h {:02}m {:02}s", h, m, s)
|
||||
}
|
||||
|
||||
fn fmt_dec(dec: f64) -> String {
|
||||
let sign = if dec < 0.0 { "-" } else { "+" };
|
||||
let abs = dec.abs();
|
||||
let d = abs as u32;
|
||||
let m = ((abs - d as f64) * 60.0) as u32;
|
||||
let s = ((abs - d as f64) * 3600.0 % 60.0) as u32;
|
||||
format!("{}{}° {:02}′ {:02}″", sign, d, m, s)
|
||||
}
|
||||
|
||||
/// Low-precision planet positions (Jean Meeus, "Astronomical Algorithms", ch. 33).
|
||||
/// Returns (ra_deg, dec_deg, distance_au, mag_v, phase_pct, angular_size_arcsec).
|
||||
fn planet_position(name: &str, jd: f64) -> Option<(f64, f64, f64, f64, f64, f64)> {
|
||||
// T = Julian centuries from J2000.0
|
||||
let t = (jd - 2451545.0) / 36525.0;
|
||||
|
||||
// Sun's geometric mean longitude and anomaly (for elongation / phase)
|
||||
let l0_sun = (280.46646 + 36000.76983 * t).rem_euclid(360.0);
|
||||
let m_sun = (357.52911 + 35999.05029 * t - 0.0001537 * t * t).to_radians();
|
||||
let c_sun = (1.914602 - 0.004817 * t - 0.000014 * t * t) * m_sun.sin()
|
||||
+ (0.019993 - 0.000101 * t) * (2.0 * m_sun).sin()
|
||||
+ 0.000289 * (3.0 * m_sun).sin();
|
||||
let sun_lon = l0_sun + c_sun; // true longitude degrees
|
||||
let sun_lon_rad = sun_lon.to_radians();
|
||||
|
||||
// For each planet: orbital elements at epoch J2000 with linear drift.
|
||||
// Format: (L0, L1, a_AU, e0, e1, i0, i1, omega0, omega1, node0, node1)
|
||||
// L = mean longitude, a = semi-major axis, e = eccentricity,
|
||||
// i = inclination, omega = argument of perihelion, node = ascending node
|
||||
let (l0, l1, a, e0, e1, inc0, inc1, peri0, peri1, node0, node1) = match name {
|
||||
"Mercury" => (252.25032, 149472.67411, 0.38710, 0.20563, 0.000020, 7.00497, -0.00594, 77.45779, 0.15940, 48.33076, -0.12534),
|
||||
"Venus" => (181.97980, 58517.81538, 0.72333, 0.00677, -0.000048, 3.39468, -0.00788, 131.56370, 0.05127, 76.67984, -0.27769),
|
||||
"Mars" => (355.45332, 19140.30268, 1.52366, 0.09340, 0.000090, 1.84973, -0.00813, 336.04084, 0.44441, 49.55953, -0.29257),
|
||||
"Jupiter" => (34.89973, 3034.74612, 5.20260, 0.04849, 0.000163, 1.30327, -0.00557, 14.72847, 0.21252, 100.29205, 0.13447),
|
||||
"Saturn" => (50.07571, 1222.11494, 9.55491, 0.05551, -0.000346, 2.48888, 0.00449, 92.86136, 0.54479, 113.63998, -0.25015),
|
||||
"Uranus" => (314.05500, 428.46952, 19.21845, 0.04630, -0.000027, 0.77320, -0.00180, 172.43404, 0.09175, 73.96980, 0.05717),
|
||||
"Neptune" => (304.34866, 218.45945, 30.11039, 0.00899, 0.000006, 1.76995, 0.00022, 46.68158, 0.01367, 131.78406, -0.00762),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let l = (l0 + l1 * t / 36525.0).rem_euclid(360.0).to_radians();
|
||||
let e = e0 + e1 * t;
|
||||
let inc = (inc0 + inc1 * t).to_radians();
|
||||
let peri = (peri0 + peri1 * t).to_radians(); // longitude of perihelion
|
||||
let node = (node0 + node1 * t).to_radians();
|
||||
|
||||
// Mean anomaly
|
||||
let m = (l - peri).rem_euclid(2.0 * PI);
|
||||
|
||||
// Eccentric anomaly (Newton iteration)
|
||||
let mut ea = m;
|
||||
for _ in 0..10 {
|
||||
ea = m + e * ea.sin();
|
||||
}
|
||||
|
||||
// True anomaly
|
||||
let nu = 2.0 * ((((1.0 + e) / (1.0 - e)).sqrt() * (ea / 2.0).tan()).atan());
|
||||
|
||||
// Heliocentric distance
|
||||
let r = a * (1.0 - e * ea.cos());
|
||||
|
||||
// Heliocentric ecliptic coordinates
|
||||
let lon_helio = (nu + peri - node).rem_euclid(2.0 * PI) + node;
|
||||
let lat_helio = (lon_helio - node).sin() * inc.sin();
|
||||
let lat_helio = lat_helio.asin();
|
||||
let lon_helio = lon_helio;
|
||||
|
||||
// Convert to rectangular heliocentric
|
||||
let x_h = r * lat_helio.cos() * lon_helio.cos();
|
||||
let y_h = r * lat_helio.cos() * lon_helio.sin();
|
||||
let z_h = r * lat_helio.sin();
|
||||
|
||||
// Earth's heliocentric rectangular coordinates (using Sun's geocentric coords reversed)
|
||||
let r_earth = 1.000001018 * (1.0 - 0.0167086342 * ea.cos()); // rough
|
||||
let l_earth = sun_lon_rad + PI;
|
||||
let x_e = r_earth * l_earth.cos();
|
||||
let y_e = r_earth * l_earth.sin();
|
||||
|
||||
// Geocentric coordinates
|
||||
let dx = x_h - x_e;
|
||||
let dy = y_h - y_e;
|
||||
let dz = z_h;
|
||||
|
||||
// Geocentric ecliptic longitude/latitude
|
||||
let lam = dy.atan2(dx);
|
||||
let dist = (dx * dx + dy * dy + dz * dz).sqrt();
|
||||
let beta = (dz / dist).asin();
|
||||
|
||||
// Convert ecliptic → equatorial (obliquity ~23.439°)
|
||||
let eps = (23.439291 - 0.013004 * t).to_radians();
|
||||
let ra = (lam.sin() * eps.cos() - beta.tan() * eps.sin()).atan2(lam.cos());
|
||||
let ra_deg = ra.to_degrees().rem_euclid(360.0);
|
||||
let dec_deg = (beta.sin() * eps.cos() + beta.cos() * eps.sin() * lam.sin()).asin().to_degrees();
|
||||
|
||||
// Phase angle
|
||||
let phase_angle = ((r * r + dist * dist - r_earth * r_earth) / (2.0 * r * dist)).acos();
|
||||
let phase_pct = (1.0 + phase_angle.cos()) / 2.0 * 100.0;
|
||||
|
||||
// Approximate magnitude (very rough)
|
||||
let (h0, g_slope) = match name {
|
||||
"Mercury" => (-0.36, 0.0),
|
||||
"Venus" => (-4.34, 0.0),
|
||||
"Mars" => (-1.51, 0.0),
|
||||
"Jupiter" => (-9.25, 0.0),
|
||||
"Saturn" => (-8.88, 0.0),
|
||||
"Uranus" => (-7.19, 0.0),
|
||||
"Neptune" => (-6.87, 0.0),
|
||||
_ => (10.0, 0.0),
|
||||
};
|
||||
let mag = h0 + 5.0 * (r * dist).log10() - 2.5 * ((1.0 - g_slope) * (-3.33 * (phase_angle / 2.0).tan().powi(12)).exp() + g_slope * (-1.87 * (phase_angle / 2.0).tan().powi(6)).exp()).log10();
|
||||
|
||||
// Angular size (arcsec) — equatorial diameter at 1 AU
|
||||
let diam_1au_arcsec = match name {
|
||||
"Mercury" => 6.74,
|
||||
"Venus" => 16.92,
|
||||
"Mars" => 9.36,
|
||||
"Jupiter" => 196.74,
|
||||
"Saturn" => 165.6,
|
||||
"Uranus" => 65.8,
|
||||
"Neptune" => 62.2,
|
||||
_ => 0.0,
|
||||
};
|
||||
let ang_size = diam_1au_arcsec / dist;
|
||||
|
||||
Some((ra_deg, dec_deg, dist, mag, phase_pct, ang_size))
|
||||
}
|
||||
|
||||
fn sun_position(jd: f64) -> (f64, f64) {
|
||||
let t = (jd - 2451545.0) / 36525.0;
|
||||
let l0 = (280.46646 + 36000.76983 * t).rem_euclid(360.0);
|
||||
let m = (357.52911 + 35999.05029 * t).to_radians();
|
||||
let c = (1.914602 - 0.004817 * t) * m.sin()
|
||||
+ 0.019993 * (2.0 * m).sin()
|
||||
+ 0.000290 * (3.0 * m).sin();
|
||||
let sun_lon = (l0 + c).to_radians();
|
||||
let eps = (23.439291 - 0.013004 * t).to_radians();
|
||||
let ra = (sun_lon.sin() * eps.cos()).atan2(sun_lon.cos());
|
||||
let dec = (sun_lon.sin() * eps.sin()).asin();
|
||||
(ra.to_degrees().rem_euclid(360.0), dec.to_degrees())
|
||||
}
|
||||
|
||||
fn elongation(ra1: f64, dec1: f64, ra2: f64, dec2: f64) -> f64 {
|
||||
let r1 = ra1.to_radians();
|
||||
let d1 = dec1.to_radians();
|
||||
let r2 = ra2.to_radians();
|
||||
let d2 = dec2.to_radians();
|
||||
let cos_sep = d1.sin() * d2.sin() + d1.cos() * d2.cos() * (r1 - r2).cos();
|
||||
cos_sep.clamp(-1.0, 1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
pub async fn get_solar_system(
|
||||
State(_state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let now = Utc::now();
|
||||
let jd = julian_date(now);
|
||||
let lst = local_sidereal_time(jd, LON);
|
||||
|
||||
let (sun_ra, sun_dec) = sun_position(jd);
|
||||
let (moon_ra, moon_dec) = moon_position(jd);
|
||||
|
||||
let planet_names = ["Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune"];
|
||||
let mut objects: Vec<SolarSystemObject> = Vec::new();
|
||||
|
||||
// Moon
|
||||
{
|
||||
let (alt, az) = radec_to_altaz(moon_ra, moon_dec, lst, LAT);
|
||||
let am = if alt > 0.0 { airmass(alt) } else { 99.0 };
|
||||
objects.push(SolarSystemObject {
|
||||
id: "moon".to_string(),
|
||||
name: "Moon".to_string(),
|
||||
obj_type: "moon".to_string(),
|
||||
ra_deg: moon_ra,
|
||||
dec_deg: moon_dec,
|
||||
ra_h: fmt_ra(moon_ra),
|
||||
dec_dms: fmt_dec(moon_dec),
|
||||
alt_deg: (alt * 10.0).round() / 10.0,
|
||||
az_deg: (az * 10.0).round() / 10.0,
|
||||
airmass: (am * 100.0).round() / 100.0,
|
||||
mag_v: Some(-12.7),
|
||||
angular_size_arcsec: Some(1800.0),
|
||||
phase_pct: None, // from tonight data
|
||||
distance_au: None,
|
||||
elongation_deg: Some((elongation(moon_ra, moon_dec, sun_ra, sun_dec) * 10.0).round() / 10.0),
|
||||
is_visible: alt > 15.0,
|
||||
});
|
||||
}
|
||||
|
||||
// Sun
|
||||
{
|
||||
let (alt, az) = radec_to_altaz(sun_ra, sun_dec, lst, LAT);
|
||||
let am = if alt > 0.0 { airmass(alt) } else { 99.0 };
|
||||
objects.push(SolarSystemObject {
|
||||
id: "sun".to_string(),
|
||||
name: "Sun".to_string(),
|
||||
obj_type: "star".to_string(),
|
||||
ra_deg: sun_ra,
|
||||
dec_deg: sun_dec,
|
||||
ra_h: fmt_ra(sun_ra),
|
||||
dec_dms: fmt_dec(sun_dec),
|
||||
alt_deg: (alt * 10.0).round() / 10.0,
|
||||
az_deg: (az * 10.0).round() / 10.0,
|
||||
airmass: (am * 100.0).round() / 100.0,
|
||||
mag_v: Some(-26.7),
|
||||
angular_size_arcsec: Some(1919.0),
|
||||
phase_pct: None,
|
||||
distance_au: Some(1.0),
|
||||
elongation_deg: Some(0.0),
|
||||
is_visible: alt > 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
// Planets
|
||||
for name in &planet_names {
|
||||
if let Some((ra, dec, dist, mag, phase, ang_size)) = planet_position(name, jd) {
|
||||
let (alt, az) = radec_to_altaz(ra, dec, lst, LAT);
|
||||
let am = if alt > 0.0 { airmass(alt) } else { 99.0 };
|
||||
let elong = elongation(ra, dec, sun_ra, sun_dec);
|
||||
objects.push(SolarSystemObject {
|
||||
id: name.to_lowercase(),
|
||||
name: name.to_string(),
|
||||
obj_type: "planet".to_string(),
|
||||
ra_deg: (ra * 1000.0).round() / 1000.0,
|
||||
dec_deg: (dec * 1000.0).round() / 1000.0,
|
||||
ra_h: fmt_ra(ra),
|
||||
dec_dms: fmt_dec(dec),
|
||||
alt_deg: (alt * 10.0).round() / 10.0,
|
||||
az_deg: (az * 10.0).round() / 10.0,
|
||||
airmass: (am * 100.0).round() / 100.0,
|
||||
mag_v: Some((mag * 10.0).round() / 10.0),
|
||||
angular_size_arcsec: Some((ang_size * 10.0).round() / 10.0),
|
||||
phase_pct: Some((phase * 10.0).round() / 10.0),
|
||||
distance_au: Some((dist * 1000.0).round() / 1000.0),
|
||||
elongation_deg: Some((elong * 10.0).round() / 10.0),
|
||||
is_visible: alt > 15.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: visible first, then by altitude descending
|
||||
objects.sort_by(|a, b| {
|
||||
b.is_visible.cmp(&a.is_visible)
|
||||
.then(b.alt_deg.partial_cmp(&a.alt_deg).unwrap_or(std::cmp::Ordering::Equal))
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"computed_at": now.to_rfc3339(),
|
||||
"objects": objects,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Custom targets API
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CustomTargetInput {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub obj_type: Option<String>,
|
||||
pub ra_deg: Option<f64>,
|
||||
pub dec_deg: Option<f64>,
|
||||
pub tle_line1: Option<String>,
|
||||
pub tle_line2: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_custom_targets(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query("SELECT * FROM custom_targets ORDER BY created_at DESC")
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
use sqlx::Row;
|
||||
let items: Vec<serde_json::Value> = rows.iter().map(|r| {
|
||||
let ra: Option<f64> = r.try_get("ra_deg").unwrap_or_default();
|
||||
let dec: Option<f64> = r.try_get("dec_deg").unwrap_or_default();
|
||||
let has_tle = r.try_get::<Option<String>, _>("tle_line1").unwrap_or_default().is_some();
|
||||
|
||||
let mut obj = serde_json::json!({
|
||||
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||
"ra_deg": ra,
|
||||
"dec_deg": dec,
|
||||
"notes": r.try_get::<Option<String>, _>("notes").unwrap_or_default(),
|
||||
"has_tle": has_tle,
|
||||
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||
});
|
||||
|
||||
// Compute live position: prefer TLE propagation if available, else fixed RA/Dec
|
||||
let tle1 = r.try_get::<Option<String>, _>("tle_line1").unwrap_or_default();
|
||||
let tle2 = r.try_get::<Option<String>, _>("tle_line2").unwrap_or_default();
|
||||
|
||||
if let (Some(t1), Some(t2)) = (&tle1, &tle2) {
|
||||
if let Some((ra, dec, alt, az)) = tle_position(t1, t2) {
|
||||
obj["ra_deg"] = serde_json::json!((ra * 1000.0).round() / 1000.0);
|
||||
obj["dec_deg"] = serde_json::json!((dec * 1000.0).round() / 1000.0);
|
||||
obj["ra_h"] = serde_json::json!(fmt_ra(ra));
|
||||
obj["dec_dms"] = serde_json::json!(fmt_dec(dec));
|
||||
obj["alt_deg"] = serde_json::json!((alt * 10.0).round() / 10.0);
|
||||
obj["az_deg"] = serde_json::json!((az * 10.0).round() / 10.0);
|
||||
obj["tle_position_ok"] = serde_json::json!(true);
|
||||
} else {
|
||||
obj["tle_position_ok"] = serde_json::json!(false);
|
||||
}
|
||||
} else if let (Some(ra), Some(dec)) = (ra, dec) {
|
||||
let jd = julian_date(Utc::now());
|
||||
let lst = local_sidereal_time(jd, LON);
|
||||
let (alt, az) = radec_to_altaz(ra, dec, lst, LAT);
|
||||
obj["alt_deg"] = serde_json::json!((alt * 10.0).round() / 10.0);
|
||||
obj["az_deg"] = serde_json::json!((az * 10.0).round() / 10.0);
|
||||
obj["ra_h"] = serde_json::json!(fmt_ra(ra));
|
||||
obj["dec_dms"] = serde_json::json!(fmt_dec(dec));
|
||||
}
|
||||
obj
|
||||
}).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_custom_target(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CustomTargetInput>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if input.id.trim().is_empty() || input.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("id and name are required".to_string()));
|
||||
}
|
||||
let obj_type = input.obj_type.unwrap_or_else(|| "custom".to_string());
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO custom_targets (id, name, obj_type, ra_deg, dec_deg, tle_line1, tle_line2, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&input.id)
|
||||
.bind(&input.name)
|
||||
.bind(&obj_type)
|
||||
.bind(input.ra_deg)
|
||||
.bind(input.dec_deg)
|
||||
.bind(input.tle_line1.as_deref())
|
||||
.bind(input.tle_line2.as_deref())
|
||||
.bind(input.notes.as_deref())
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": input.id, "status": "created" })))
|
||||
}
|
||||
|
||||
pub async fn delete_custom_target(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(id): axum::extract::Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query("DELETE FROM custom_targets WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({ "id": id, "status": "deleted" })))
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use axum::{extract::State, Json};
|
||||
|
||||
use super::{AppError, AppState};
|
||||
|
||||
pub async fn get_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// Total sessions
|
||||
let total_sessions: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM imaging_log")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Total integration time
|
||||
let total_integration_min: Option<i64> =
|
||||
sqlx::query_scalar("SELECT SUM(integration_min) FROM imaging_log")
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
// Objects imaged (at least one keeper)
|
||||
let objects_with_keeper: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(DISTINCT catalog_id) FROM imaging_log WHERE quality = 'keeper'",
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Filter usage
|
||||
let filter_usage = sqlx::query(
|
||||
"SELECT filter_id, COUNT(*) as count, SUM(integration_min) as total_min FROM imaging_log GROUP BY filter_id",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let filter_stats: Vec<serde_json::Value> = filter_usage.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
|
||||
"count": r.try_get::<i64, _>("count").unwrap_or_default(),
|
||||
"total_min": r.try_get::<Option<i64>, _>("total_min").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Integration per month (last 12 months)
|
||||
let monthly = sqlx::query(
|
||||
r#"SELECT substr(session_date, 1, 7) as month,
|
||||
COUNT(*) as sessions,
|
||||
SUM(integration_min) as total_min
|
||||
FROM imaging_log
|
||||
WHERE session_date >= date('now', '-12 months')
|
||||
GROUP BY month
|
||||
ORDER BY month"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let monthly_stats: Vec<serde_json::Value> = monthly.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"month": r.try_get::<String, _>("month").unwrap_or_default(),
|
||||
"sessions": r.try_get::<i64, _>("sessions").unwrap_or_default(),
|
||||
"total_min": r.try_get::<Option<i64>, _>("total_min").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Object type breakdown
|
||||
let type_breakdown = sqlx::query(
|
||||
r#"SELECT c.obj_type, COUNT(*) as sessions, SUM(l.integration_min) as total_min
|
||||
FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id
|
||||
GROUP BY c.obj_type ORDER BY total_min DESC"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let type_stats: Vec<serde_json::Value> = type_breakdown.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||
"sessions": r.try_get::<i64, _>("sessions").unwrap_or_default(),
|
||||
"total_min": r.try_get::<Option<i64>, _>("total_min").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Quality breakdown
|
||||
let quality = sqlx::query(
|
||||
"SELECT quality, COUNT(*) as count FROM imaging_log GROUP BY quality",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let quality_stats: Vec<serde_json::Value> = quality.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"quality": r.try_get::<String, _>("quality").unwrap_or_default(),
|
||||
"count": r.try_get::<i64, _>("count").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Top targets by integration
|
||||
let top_targets = sqlx::query(
|
||||
r#"SELECT c.id, c.name, c.common_name, c.obj_type,
|
||||
COUNT(l.id) as sessions,
|
||||
SUM(l.integration_min) as total_min
|
||||
FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id
|
||||
GROUP BY l.catalog_id
|
||||
ORDER BY total_min DESC
|
||||
LIMIT 20"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let top_target_list: Vec<serde_json::Value> = top_targets.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
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(),
|
||||
"sessions": r.try_get::<i64, _>("sessions").unwrap_or_default(),
|
||||
"total_min": r.try_get::<Option<i64>, _>("total_min").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Guiding RMS over time
|
||||
let guiding = sqlx::query(
|
||||
"SELECT session_date, rms_total, rms_ra, rms_dec FROM phd2_logs ORDER BY session_date",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let guiding_data: Vec<serde_json::Value> = guiding.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"date": r.try_get::<String, _>("session_date").unwrap_or_default(),
|
||||
"rms_total": r.try_get::<Option<f64>, _>("rms_total").unwrap_or_default(),
|
||||
"rms_ra": r.try_get::<Option<f64>, _>("rms_ra").unwrap_or_default(),
|
||||
"rms_dec": r.try_get::<Option<f64>, _>("rms_dec").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"total_sessions": total_sessions,
|
||||
"total_integration_min": total_integration_min.unwrap_or(0),
|
||||
"objects_with_keeper": objects_with_keeper,
|
||||
"filter_usage": filter_stats,
|
||||
"monthly": monthly_stats,
|
||||
"by_type": type_stats,
|
||||
"quality": quality_stats,
|
||||
"top_targets": top_target_list,
|
||||
"guiding": guiding_data,
|
||||
})))
|
||||
}
|
||||
@@ -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" })))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use axum::{extract::State, Json};
|
||||
|
||||
use super::{AppError, AppState};
|
||||
|
||||
pub async fn get_tonight(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let row = sqlx::query("SELECT * FROM tonight WHERE id = 1")
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
use sqlx::Row;
|
||||
Ok(Json(serde_json::json!({
|
||||
"date": r.try_get::<Option<String>, _>("date").unwrap_or_default(),
|
||||
"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(),
|
||||
"moon_ra_deg": r.try_get::<Option<f64>, _>("moon_ra_deg").unwrap_or_default(),
|
||||
"moon_dec_deg": r.try_get::<Option<f64>, _>("moon_dec_deg").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(),
|
||||
"computed_at": r.try_get::<Option<i64>, _>("computed_at").unwrap_or_default(),
|
||||
})))
|
||||
}
|
||||
None => {
|
||||
// Compute live if not cached
|
||||
use crate::astronomy::*;
|
||||
use crate::config::{LAT, LON};
|
||||
|
||||
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_age = moon_age_days(jd);
|
||||
let phase = moon_phase_name(moon_illum, moon_age);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"date": today.to_string(),
|
||||
"astro_dusk_utc": dusk.to_rfc3339(),
|
||||
"astro_dawn_utc": dawn.to_rfc3339(),
|
||||
"moon_illumination": moon_illum,
|
||||
"moon_phase_name": phase,
|
||||
"moon_ra_deg": moon_ra,
|
||||
"moon_dec_deg": moon_dec,
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use axum::{extract::State, Json};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
|
||||
use super::{AppError, AppState};
|
||||
|
||||
/// Find the 7timer dataseries slot closest to tonight's dusk UTC.
|
||||
/// Falls back to slot[0] (now) if dusk is unavailable.
|
||||
fn find_tonight_slot(dataseries: &[serde_json::Value], init_str: &str, dusk_utc: Option<&str>) -> Option<serde_json::Value> {
|
||||
let dusk_utc = dusk_utc?;
|
||||
let dusk_dt = chrono::DateTime::parse_from_rfc3339(dusk_utc).ok()?;
|
||||
let dusk_epoch = dusk_dt.timestamp();
|
||||
|
||||
// 7timer init format: "2026040812" → 2026-04-08 12:00 UTC
|
||||
let init_dt = NaiveDateTime::parse_from_str(init_str, "%Y%m%d%H")
|
||||
.ok()
|
||||
.map(|dt| dt.and_utc().timestamp())?;
|
||||
|
||||
let mut best: Option<&serde_json::Value> = None;
|
||||
let mut best_diff = i64::MAX;
|
||||
for slot in dataseries {
|
||||
let tp = slot.get("timepoint")?.as_i64()?;
|
||||
let slot_epoch = init_dt + tp * 3600;
|
||||
let diff = (slot_epoch - dusk_epoch).abs();
|
||||
if diff < best_diff {
|
||||
best_diff = diff;
|
||||
best = Some(slot);
|
||||
}
|
||||
}
|
||||
best.cloned()
|
||||
}
|
||||
|
||||
pub async fn get_weather(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let row = sqlx::query("SELECT * FROM weather_cache WHERE id = 1")
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
use sqlx::Row;
|
||||
let seventimer_json: Option<String> = r.try_get("seventimer_json").unwrap_or_default();
|
||||
|
||||
let parsed_7t = seventimer_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
|
||||
|
||||
let dataseries = parsed_7t.as_ref()
|
||||
.and_then(|v| v.get("dataseries"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
let init_str = parsed_7t.as_ref()
|
||||
.and_then(|v| v.get("init"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Load tonight's dusk to pick the relevant forecast slot
|
||||
let dusk_utc: Option<String> = sqlx::query_scalar(
|
||||
"SELECT astro_dusk_utc FROM tonight WHERE id = 1"
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
// Try to get the slot nearest to tonight's dusk; fall back to first slot
|
||||
let tonight_slot = find_tonight_slot(dataseries, init_str, dusk_utc.as_deref())
|
||||
.or_else(|| dataseries.first().cloned());
|
||||
|
||||
// Also keep current slot (slot[0]) for actual current conditions
|
||||
let current_slot = dataseries.first().cloned();
|
||||
|
||||
let dew_alert = {
|
||||
let temp = r.try_get::<Option<f64>, _>("temp_c").unwrap_or_default().unwrap_or(20.0);
|
||||
let dew = r.try_get::<Option<f64>, _>("dew_point_c").unwrap_or_default().unwrap_or(10.0);
|
||||
let margin = temp - dew;
|
||||
if margin < 2.0 { Some("critical") }
|
||||
else if margin < 4.0 { Some("warning") }
|
||||
else { None }
|
||||
};
|
||||
|
||||
let go_nogo_str = r.try_get::<Option<String>, _>("go_nogo").unwrap_or_default();
|
||||
|
||||
// Build go_nogo_reasons from tonight's slot
|
||||
let slot_for_reasons = tonight_slot.as_ref().or(current_slot.as_ref());
|
||||
let go_nogo_reasons = slot_for_reasons.map(|slot| {
|
||||
let mut reasons = Vec::<String>::new();
|
||||
if let Some(cc) = slot.get("cloudcover").and_then(|v| v.as_i64()) {
|
||||
if cc > 4 { reasons.push(format!("Cloud cover {}/9", cc)); }
|
||||
}
|
||||
if let Some(see) = slot.get("seeing").and_then(|v| v.as_i64()) {
|
||||
if see > 5 { reasons.push(format!("Poor seeing ({}/8)", see)); }
|
||||
}
|
||||
if let Some(tr) = slot.get("transparency").and_then(|v| v.as_i64()) {
|
||||
if tr > 5 { reasons.push(format!("Low transparency ({}/8)", tr)); }
|
||||
}
|
||||
if let Some(li) = slot.get("lifted_index").and_then(|v| v.as_i64()) {
|
||||
if li < -2 { reasons.push(format!("Unstable atmosphere (LI {})", li)); }
|
||||
}
|
||||
reasons
|
||||
}).unwrap_or_default();
|
||||
|
||||
// Recompute go/nogo from tonight's slot
|
||||
let tonight_go_nogo = tonight_slot.as_ref().map(|slot| {
|
||||
let cc = slot.get("cloudcover").and_then(|v| v.as_i64()).unwrap_or(5);
|
||||
let see = slot.get("seeing").and_then(|v| v.as_i64()).unwrap_or(5);
|
||||
let tr = slot.get("transparency").and_then(|v| v.as_i64()).unwrap_or(5);
|
||||
if cc <= 2 && see <= 3 && tr <= 3 { "go" }
|
||||
else if cc <= 4 && see <= 5 { "marginal" }
|
||||
else { "nogo" }
|
||||
}).or(go_nogo_str.as_deref());
|
||||
|
||||
let s = tonight_slot.as_ref();
|
||||
Ok(Json(serde_json::json!({
|
||||
"dew_point_c": r.try_get::<Option<f64>, _>("dew_point_c").unwrap_or_default(),
|
||||
"temp_c": r.try_get::<Option<f64>, _>("temp_c").unwrap_or_default(),
|
||||
"humidity_pct": r.try_get::<Option<f64>, _>("humidity_pct").unwrap_or_default(),
|
||||
"go_nogo": tonight_go_nogo,
|
||||
"go_nogo_reasons": go_nogo_reasons,
|
||||
"fetched_at": r.try_get::<Option<i64>, _>("fetched_at").unwrap_or_default(),
|
||||
"dew_alert": dew_alert,
|
||||
// Tonight's forecast slot fields
|
||||
"cloudcover": s.and_then(|s| s.get("cloudcover")).and_then(|v| v.as_i64()),
|
||||
"seeing": s.and_then(|s| s.get("seeing")).and_then(|v| v.as_i64()),
|
||||
"transparency": s.and_then(|s| s.get("transparency")).and_then(|v| v.as_i64()),
|
||||
"lifted_index": s.and_then(|s| s.get("lifted_index")).and_then(|v| v.as_i64()),
|
||||
"wind10m": s.and_then(|s| s.get("wind10m")).cloned(),
|
||||
"rh2m": s.and_then(|s| s.get("rh2m")).and_then(|v| v.as_i64()),
|
||||
})))
|
||||
}
|
||||
None => Ok(Json(serde_json::json!({ "go_nogo": null, "fetched_at": null }))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_forecast(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let row = sqlx::query("SELECT seventimer_json FROM weather_cache WHERE id = 1")
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let forecast = row
|
||||
.and_then(|r| {
|
||||
use sqlx::Row;
|
||||
r.try_get::<Option<String>, _>("seventimer_json").ok().flatten()
|
||||
})
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
|
||||
Ok(Json(forecast))
|
||||
}
|
||||
Reference in New Issue
Block a user