Initial Commit

This commit is contained in:
2026-04-09 23:23:31 +02:00
commit 9223e4d35f
94 changed files with 15173 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "astronome"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "astronome"
path = "src/main.rs"
[dependencies]
axum = { version = "0.7", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
reqwest = { version = "0.11", features = ["json"] }
csv = "1"
tower-http = { version = "0.5", features = ["cors", "fs"] }
image = { version = "0.24", default-features = false, features = ["jpeg", "png", "tiff"] }
uuid = { version = "1", features = ["v4"] }
tokio-cron-scheduler = "0.9"
mime = "0.3"
bytes = "1"
sgp4 = "2.4.0"
+21
View File
@@ -0,0 +1,21 @@
FROM rust:latest AS builder
WORKDIR /app
ENV CARGO_HOME=/usr/local/cargo
ENV CARGO_TARGET_DIR=/app/target
RUN apt-get update && apt-get install -y pkg-config libssl-dev
COPY Cargo.toml ./
# clean build (important)
RUN cargo clean || true
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
COPY src ./src
RUN cargo build --release
+242
View File
@@ -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,
})))
}
+185
View File
@@ -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)
}
+50
View File
@@ -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() })))
}
+242
View File
@@ -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(),
})
}
+244
View File
@@ -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"),
}))
}
+160
View File
@@ -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,
})))
}
+436
View File
@@ -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>, // 0100
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" })))
}
+151
View File
@@ -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,
})))
}
+643
View File
@@ -0,0 +1,643 @@
use axum::{
extract::{Path, Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use crate::{
astronomy::{
astro_twilight, compute_visibility, compute_visibility_with_step, julian_date, moon_altitude, moon_illumination,
moon_position, HorizonPoint, MoonState, TonightWindow,
},
config::{LAT, LON},
filters::{get_workflow, recommend_filters},
};
use super::{AppError, AppState};
#[derive(Debug, Deserialize)]
pub struct TargetsQuery {
#[serde(rename = "type")]
pub obj_type: Option<String>,
pub constellation: Option<String>,
pub filter: Option<String>,
pub tonight: Option<bool>,
pub search: Option<String>,
pub sort: Option<String>,
pub page: Option<u32>,
pub limit: Option<u32>,
pub min_alt_deg: Option<f64>,
pub min_usable_min: Option<i32>,
pub mosaic_only: Option<bool>,
pub not_imaged: Option<bool>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct TargetRow {
pub id: String,
pub name: String,
pub common_name: Option<String>,
pub obj_type: String,
pub ra_deg: f64,
pub dec_deg: f64,
pub ra_h: String,
pub dec_dms: String,
pub constellation: Option<String>,
pub size_arcmin_maj: Option<f64>,
pub size_arcmin_min: Option<f64>,
pub mag_v: Option<f64>,
pub surface_brightness: Option<f64>,
pub hubble_type: Option<String>,
pub messier_num: Option<i32>,
pub is_highlight: bool,
pub fov_fill_pct: Option<f64>,
pub mosaic_flag: bool,
pub mosaic_panels_w: i32,
pub mosaic_panels_h: i32,
pub difficulty: Option<i32>,
pub guide_star_density: Option<String>,
// From nightly_cache
pub max_alt_deg: Option<f64>,
pub usable_min: Option<i32>,
pub transit_utc: Option<String>,
pub recommended_filter: Option<String>,
pub best_start_utc: Option<String>,
pub best_end_utc: Option<String>,
pub moon_sep_deg: Option<f64>,
pub is_visible_tonight: Option<bool>,
}
pub async fn list_targets(
State(state): State<AppState>,
Query(params): Query<TargetsQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
let today = chrono::Utc::now().naive_utc().date().to_string();
let page = params.page.unwrap_or(1).max(1);
let limit = params.limit.unwrap_or(100).min(500);
let offset = (page - 1) * limit;
let tonight_filter = params.tonight.unwrap_or(true);
let mut conditions = vec!["1=1".to_string()];
let mut bind_values: Vec<String> = vec![];
if let Some(ref t) = params.obj_type {
conditions.push("c.obj_type = ?".to_string());
bind_values.push(t.clone());
}
if let Some(ref con) = params.constellation {
conditions.push("c.constellation = ?".to_string());
bind_values.push(con.clone());
}
if let Some(ref f) = params.filter {
// Filter by filter suitability: use object-type compatibility, not moon-state-dependent recommended_filter.
// This ensures these filters always return results regardless of current moon phase.
match f.as_str() {
"uvir" => conditions.push("c.obj_type IN ('galaxy', 'reflection_nebula', 'open_cluster', 'globular_cluster', 'dark_nebula')".to_string()),
"c2" | "sv220" => conditions.push("c.obj_type IN ('emission_nebula', 'snr', 'planetary_nebula')".to_string()),
"sv260" => {}, // LP filter works for all object types — no restriction
_ => {
conditions.push("nc.recommended_filter = ?".to_string());
bind_values.push(f.clone());
}
}
}
if let Some(min_alt) = params.min_alt_deg {
conditions.push("nc.max_alt_deg >= ?".to_string());
bind_values.push(min_alt.to_string());
}
if let Some(min_min) = params.min_usable_min {
conditions.push("nc.usable_min >= ?".to_string());
bind_values.push(min_min.to_string());
}
if params.mosaic_only.unwrap_or(false) {
conditions.push("c.mosaic_flag = 1".to_string());
}
if params.not_imaged.unwrap_or(false) {
conditions.push("log_sum.total_min IS NULL".to_string());
}
// Tonight filter: show objects above MIN_ALT (15°) at any point tonight.
// Using max_alt_deg >= 15 (not usable_min > 0) so objects that peak at 15-30°
// (e.g. globular clusters, dark nebulae, open clusters) still appear.
// Skip filter when search is active so you can find objects like M31 off-season.
if tonight_filter && params.search.as_deref().unwrap_or("").is_empty() {
// Allow objects with no nightly_cache entry yet (newly ingested, NULL max_alt_deg)
// so freshly added VdB/LDN objects are visible before the first nightly precompute.
conditions.push("(nc.max_alt_deg >= 15 OR nc.max_alt_deg IS NULL)".to_string());
}
if let Some(ref s) = params.search {
let like = format!("%{}%", s);
// Support M-number search (e.g. "M42" → messier_num = 42)
let m_num: Option<i32> = s.trim()
.strip_prefix(['M', 'm'])
.and_then(|n| n.parse().ok());
if let Some(m) = m_num {
conditions.push(format!(
"(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ? OR c.messier_num = {})",
m
));
bind_values.push(like.clone());
bind_values.push(like.clone());
bind_values.push(like);
} else {
conditions.push("(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ?)".to_string());
bind_values.push(like.clone());
bind_values.push(like.clone());
bind_values.push(like);
}
}
let where_clause = conditions.join(" AND ");
// "best" sort: composite score balancing altitude, FOV fill, usable time, moon separation.
// Score = alt_score * 0.4 + fill_score * 0.3 + time_score * 0.2 + moon_score * 0.1
// Targets outside 20150% FOV fill are penalised (too small or too large single-panel).
let best_score_expr = r#"(
COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40
+ CASE
WHEN c.fov_fill_pct IS NULL THEN 0.15
WHEN c.fov_fill_pct BETWEEN 20 AND 80 THEN (1.0 - ABS(c.fov_fill_pct - 50) / 50.0) * 0.30
WHEN c.fov_fill_pct > 80 THEN 0.10
ELSE 0.05
END
+ MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * 0.20
+ COALESCE(nc.moon_sep_deg, 90) / 180.0 * 0.10
) DESC"#;
let sort_col = match params.sort.as_deref() {
Some("transit") => "nc.transit_utc",
Some("size") => "c.size_arcmin_maj DESC",
Some("magnitude") => "c.mag_v",
Some("difficulty") => "c.difficulty",
Some("integration") => "total_integration DESC",
Some("altitude") => "nc.max_alt_deg DESC",
_ => best_score_expr,
};
let sql = format!(
r#"
SELECT c.id, c.name, c.common_name, c.obj_type, c.ra_deg, c.dec_deg, c.ra_h, c.dec_dms,
c.constellation, c.size_arcmin_maj, c.size_arcmin_min, c.mag_v, c.surface_brightness,
c.hubble_type, c.messier_num, c.is_highlight, c.fov_fill_pct, c.mosaic_flag,
c.mosaic_panels_w, c.mosaic_panels_h, c.difficulty, c.guide_star_density,
nc.max_alt_deg, nc.usable_min, nc.transit_utc, nc.recommended_filter,
nc.best_start_utc, nc.best_end_utc, nc.moon_sep_deg,
CASE WHEN nc.max_alt_deg >= 15 THEN 1 ELSE 0 END as is_visible_tonight,
COALESCE(log_sum.total_min, 0) as total_integration
FROM catalog c
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}'
LEFT JOIN (
SELECT catalog_id, SUM(integration_min) as total_min
FROM imaging_log GROUP BY catalog_id
) log_sum ON log_sum.catalog_id = c.id
WHERE {where_clause}
ORDER BY {sort_col}
LIMIT {limit} OFFSET {offset}
"#,
today = today,
where_clause = where_clause,
sort_col = sort_col,
limit = limit,
offset = offset
);
// Use dynamic binding workaround since sqlx requires compile-time queries
let mut query = sqlx::query(&sql);
for val in &bind_values {
query = query.bind(val);
}
let rows = query
.fetch_all(&state.pool)
.await
.map_err(AppError::from)?;
let items: Vec<serde_json::Value> = rows.iter().map(|row| {
use sqlx::Row;
serde_json::json!({
"id": row.try_get::<String, _>("id").unwrap_or_default(),
"name": row.try_get::<String, _>("name").unwrap_or_default(),
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": row.try_get::<String, _>("obj_type").unwrap_or_default(),
"ra_deg": row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
"dec_deg": row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
"ra_h": row.try_get::<String, _>("ra_h").unwrap_or_default(),
"dec_dms": row.try_get::<String, _>("dec_dms").unwrap_or_default(),
"constellation": row.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
"size_arcmin_maj": row.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
"size_arcmin_min": row.try_get::<Option<f64>, _>("size_arcmin_min").unwrap_or_default(),
"mag_v": row.try_get::<Option<f64>, _>("mag_v").unwrap_or_default(),
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
"mosaic_panels_w": row.try_get::<i32, _>("mosaic_panels_w").unwrap_or(1),
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
"max_alt_deg": row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
"usable_min": row.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
"transit_utc": row.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
"recommended_filter": row.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
"best_start_utc": row.try_get::<Option<String>, _>("best_start_utc").unwrap_or_default(),
"best_end_utc": row.try_get::<Option<String>, _>("best_end_utc").unwrap_or_default(),
"moon_sep_deg": row.try_get::<Option<f64>, _>("moon_sep_deg").unwrap_or_default(),
"is_visible_tonight": row.try_get::<Option<bool>, _>("is_visible_tonight").unwrap_or_default(),
"total_integration_min": row.try_get::<i64, _>("total_integration").unwrap_or(0),
})
}).collect();
// Count with the same filters applied
let count_sql = format!(
r#"SELECT COUNT(*) FROM catalog c
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}'
LEFT JOIN (
SELECT catalog_id, SUM(integration_min) as total_min
FROM imaging_log GROUP BY catalog_id
) log_sum ON log_sum.catalog_id = c.id
WHERE {where_clause}"#,
today = today,
where_clause = where_clause,
);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
count_query = count_query.bind(val);
}
let total: i64 = count_query.fetch_one(&state.pool).await.unwrap_or(0);
Ok(Json(serde_json::json!({
"items": items,
"total": total,
"page": page,
"limit": limit
})))
}
pub async fn get_target(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
// Support both NGC/IC IDs and M-number IDs (e.g. "M42")
let m_num: Option<i32> = id.trim()
.strip_prefix(['M', 'm'])
.and_then(|n| n.parse().ok());
let row = if let Some(n) = m_num {
sqlx::query("SELECT * FROM catalog WHERE messier_num = ?")
.bind(n)
.fetch_optional(&state.pool)
.await?
} else {
sqlx::query("SELECT * FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
.await?
}
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
Ok(Json(serde_json::json!({
"id": row.try_get::<String, _>("id").unwrap_or_default(),
"name": row.try_get::<String, _>("name").unwrap_or_default(),
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": row.try_get::<String, _>("obj_type").unwrap_or_default(),
"ra_deg": row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
"dec_deg": row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
"ra_h": row.try_get::<String, _>("ra_h").unwrap_or_default(),
"dec_dms": row.try_get::<String, _>("dec_dms").unwrap_or_default(),
"constellation": row.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
"size_arcmin_maj": row.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
"size_arcmin_min": row.try_get::<Option<f64>, _>("size_arcmin_min").unwrap_or_default(),
"pos_angle_deg": row.try_get::<Option<f64>, _>("pos_angle_deg").unwrap_or_default(),
"mag_v": row.try_get::<Option<f64>, _>("mag_v").unwrap_or_default(),
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
"mosaic_panels_w": row.try_get::<i32, _>("mosaic_panels_w").unwrap_or(1),
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
})))
}
pub async fn get_visibility(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let today = chrono::Utc::now().naive_utc().date().to_string();
let row = sqlx::query(
"SELECT * FROM nightly_cache WHERE catalog_id = ? AND night_date = ?",
)
.bind(&id)
.bind(&today)
.fetch_optional(&state.pool)
.await?;
match row {
Some(r) => {
use sqlx::Row;
Ok(Json(serde_json::json!({
"catalog_id": id,
"night_date": today,
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
"transit_utc": r.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
"rise_utc": r.try_get::<Option<String>, _>("rise_utc").unwrap_or_default(),
"set_utc": r.try_get::<Option<String>, _>("set_utc").unwrap_or_default(),
"best_start_utc": r.try_get::<Option<String>, _>("best_start_utc").unwrap_or_default(),
"best_end_utc": r.try_get::<Option<String>, _>("best_end_utc").unwrap_or_default(),
"usable_min": r.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
"meridian_flip_utc": r.try_get::<Option<String>, _>("meridian_flip_utc").unwrap_or_default(),
"airmass_at_transit": r.try_get::<Option<f64>, _>("airmass_at_transit").unwrap_or_default(),
"extinction_mag": r.try_get::<Option<f64>, _>("extinction_mag").unwrap_or_default(),
"moon_sep_deg": r.try_get::<Option<f64>, _>("moon_sep_deg").unwrap_or_default(),
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
})))
}
None => compute_visibility_live(&state, &id).await,
}
}
async fn compute_visibility_live(state: &AppState, id: &str) -> Result<Json<serde_json::Value>, AppError> {
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
.bind(id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
let today = chrono::Utc::now().naive_utc().date();
let (dusk, dawn) = astro_twilight(today, LAT, LON)
.map_err(|e| AppError::Internal(e.to_string()))?;
let jd = julian_date(dusk + (dawn - dusk) / 2);
let (moon_ra, moon_dec) = moon_position(jd);
let moon_illum = moon_illumination(jd);
let moon_alt = moon_altitude(jd, LAT, LON);
let horizon: Vec<HorizonPoint> = sqlx::query_as(
"SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg",
)
.fetch_all(&state.pool)
.await?;
let moon_state = MoonState {
ra_deg: moon_ra,
dec_deg: moon_dec,
illumination: moon_illum,
alt_at_midnight: moon_alt,
};
let window = TonightWindow { dusk, dawn };
let vis = compute_visibility(ra, dec, &window, &horizon, &moon_state);
let rec_filter = crate::filters::top_filter(&obj_type, moon_illum * 100.0, moon_alt, vis.moon_sep_deg);
Ok(Json(serde_json::json!({
"catalog_id": id,
"max_alt_deg": vis.max_alt_deg,
"transit_utc": vis.transit_utc.map(|t| t.to_rfc3339()),
"rise_utc": vis.rise_utc.map(|t| t.to_rfc3339()),
"set_utc": vis.set_utc.map(|t| t.to_rfc3339()),
"best_start_utc": vis.best_start_utc.map(|t| t.to_rfc3339()),
"best_end_utc": vis.best_end_utc.map(|t| t.to_rfc3339()),
"usable_min": vis.usable_min,
"meridian_flip_utc": vis.meridian_flip_utc.map(|t| t.to_rfc3339()),
"airmass_at_transit": vis.airmass_at_transit,
"extinction_mag": vis.extinction_at_transit,
"moon_sep_deg": vis.moon_sep_deg,
"recommended_filter": rec_filter,
"is_visible_tonight": vis.is_visible_tonight,
})))
}
pub async fn get_curve(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
// Always compute live at 1-minute resolution for the interactive chart.
// The cached visibility_json uses 10-minute steps and lacks moon_alt_deg.
let cat_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
let date = chrono::Utc::now().naive_utc().date();
let (dusk, dawn) = astro_twilight(date, LAT, LON)
.map_err(|e| AppError::Internal(e.to_string()))?;
let jd = julian_date(dusk + (dawn - dusk) / 2);
let (moon_ra, moon_dec) = moon_position(jd);
let moon_illum = moon_illumination(jd);
let moon_alt = moon_altitude(jd, LAT, LON);
let horizon: Vec<HorizonPoint> = sqlx::query_as(
"SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg",
)
.fetch_all(&state.pool)
.await?;
let moon_state = MoonState {
ra_deg: moon_ra,
dec_deg: moon_dec,
illumination: moon_illum,
alt_at_midnight: moon_alt,
};
let window = TonightWindow { dusk, dawn };
// Use 1-minute resolution for the interactive altitude curve
let vis = compute_visibility_with_step(ra, dec, &window, &horizon, &moon_state, 1);
let curve = serde_json::to_value(&vis.curve).unwrap_or(serde_json::json!([]));
Ok(Json(serde_json::json!({ "catalog_id": id, "curve": curve })))
}
pub async fn get_filters(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
let tonight_row = sqlx::query(
"SELECT moon_illumination, moon_ra_deg, moon_dec_deg FROM tonight WHERE id = 1",
)
.fetch_optional(&state.pool)
.await?;
let (moon_illum, moon_ra, moon_dec) = match tonight_row {
Some(r) => (
r.try_get::<Option<f64>, _>("moon_illumination").unwrap_or_default().unwrap_or(0.5),
r.try_get::<Option<f64>, _>("moon_ra_deg").unwrap_or_default().unwrap_or(0.0),
r.try_get::<Option<f64>, _>("moon_dec_deg").unwrap_or_default().unwrap_or(0.0),
),
None => (0.5, 0.0, 0.0),
};
let now_jd = julian_date(chrono::Utc::now());
let moon_alt = moon_altitude(now_jd, LAT, LON);
let target_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
.await?;
let moon_sep = match target_row {
Some(r) => {
let ra: f64 = r.try_get("ra_deg").unwrap_or_default();
let dec: f64 = r.try_get("dec_deg").unwrap_or_default();
crate::astronomy::moon_separation(moon_ra, moon_dec, ra, dec)
}
None => 90.0,
};
let recs = recommend_filters(&obj_type, moon_illum * 100.0, moon_alt, moon_sep);
Ok(Json(serde_json::json!({ "recommendations": recs })))
}
pub async fn get_workflow_handler(
State(state): State<AppState>,
Path((id, filter_id)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, AppError> {
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
let workflow = get_workflow(&obj_type, &filter_id);
Ok(Json(serde_json::to_value(workflow).unwrap()))
}
/// Yearly visibility graph: for each of the next 365 nights compute the
/// object's altitude at local astronomical midnight and the theoretical time
/// above 30°. This correctly shows seasonal variation (transit altitude is
/// constant but transit *time* shifts ~4 min/day so the midnight alt varies).
pub async fn get_yearly(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
use chrono::Duration;
use crate::astronomy::{
julian_date as jd_fn, moon_illumination,
coords::radec_to_altaz,
time::local_sidereal_time,
};
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
// Transit altitude: maximum the object can ever reach (constant for a DSO).
let transit_alt = (90.0 - (LAT - dec).abs()).max(0.0_f64).min(90.0_f64);
// Time above 30° per night (theoretical full night, unaffected by season).
// cos(H30) = (sin30 - sin_dec*sin_lat) / (cos_dec*cos_lat)
let sin_lat = LAT.to_radians().sin();
let cos_lat = LAT.to_radians().cos();
let sin_dec = dec.to_radians().sin();
let cos_dec = dec.to_radians().cos();
let cos_h30 = (30_f64.to_radians().sin() - sin_dec * sin_lat) / (cos_dec * cos_lat);
let usable_theoretical_min: u32 = if cos_h30.abs() <= 1.0 {
// 2 * H30 degrees * 4 min/degree of HA
(2.0 * cos_h30.acos().to_degrees() * 4.0) as u32
} else {
0
};
let today = chrono::Utc::now().naive_utc().date();
let mut points = Vec::with_capacity(365);
for day_offset in 0..365i64 {
let date = today + Duration::days(day_offset);
// Use 21:00 UTC as proxy for astronomical midnight in France
// (local midnight ≈ 23:00 local = 22:00 UTC in winter, 22:00 local = 20:00 UTC in summer)
// 21:00 UTC is a reasonable all-year compromise
let midnight_utc = date.and_hms_opt(21, 0, 0).unwrap().and_utc();
let jd = jd_fn(midnight_utc);
// Actual altitude at this midnight — this varies with date because LST shifts
let lst = local_sidereal_time(jd, LON);
let (alt_at_midnight, _az) = radec_to_altaz(ra, dec, lst, LAT);
// Moon illumination at this date
let moon_illum = moon_illumination(jd);
points.push(serde_json::json!({
"date": date.to_string(),
// Altitude at local midnight — varies seasonally
"alt_at_midnight": (alt_at_midnight * 10.0).round() / 10.0,
// Maximum possible altitude (at transit) — constant but useful reference
"transit_alt": (transit_alt * 10.0).round() / 10.0,
// Theoretical time above 30° if the whole night is available
"usable_min": usable_theoretical_min,
"moon_illumination": (moon_illum * 100.0).round() / 100.0,
"obj_type": &obj_type,
}));
}
Ok(Json(serde_json::json!({ "catalog_id": id, "points": points })))
}
pub async fn get_notes(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let notes: Option<String> = sqlx::query_scalar(
"SELECT notes FROM target_notes WHERE catalog_id = ?",
)
.bind(&id)
.fetch_optional(&state.pool)
.await?
.flatten();
Ok(Json(serde_json::json!({
"catalog_id": id,
"notes": notes.unwrap_or_default(),
})))
}
#[derive(serde::Deserialize)]
pub struct NotesBody {
pub notes: String,
}
pub async fn put_notes(
State(state): State<AppState>,
Path(id): Path<String>,
Json(body): Json<NotesBody>,
) -> Result<Json<serde_json::Value>, AppError> {
sqlx::query(
"INSERT OR REPLACE INTO target_notes (catalog_id, notes, updated_at) VALUES (?, ?, unixepoch())",
)
.bind(&id)
.bind(&body.notes)
.execute(&state.pool)
.await?;
Ok(Json(serde_json::json!({ "catalog_id": id, "status": "updated" })))
}
+57
View File
@@ -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,
})))
}
}
}
+151
View File
@@ -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))
}
+44
View File
@@ -0,0 +1,44 @@
/// Convert RA/Dec to Altitude/Azimuth.
/// All inputs and outputs in degrees.
pub fn radec_to_altaz(
ra_deg: f64,
dec_deg: f64,
lst_deg: f64,
lat_deg: f64,
) -> (f64, f64) {
let ha = (lst_deg - ra_deg).rem_euclid(360.0);
let ha_rad = ha.to_radians();
let dec_rad = dec_deg.to_radians();
let lat_rad = lat_deg.to_radians();
let sin_alt = dec_rad.sin() * lat_rad.sin()
+ dec_rad.cos() * lat_rad.cos() * ha_rad.cos();
let alt_rad = sin_alt.asin();
let cos_az = (dec_rad.sin() - lat_rad.sin() * sin_alt)
/ (lat_rad.cos() * alt_rad.cos());
let cos_az = cos_az.clamp(-1.0, 1.0);
let az_rad = cos_az.acos();
let az_deg = if ha_rad.sin() < 0.0 {
az_rad.to_degrees()
} else {
360.0 - az_rad.to_degrees()
};
(alt_rad.to_degrees(), az_deg)
}
/// Rozenberg airmass formula — valid to horizon.
pub fn airmass(alt_deg: f64) -> f64 {
if alt_deg <= 0.0 {
return 40.0; // clamp at horizon
}
let z_rad = (90.0 - alt_deg).to_radians();
1.0 / (z_rad.cos() + 0.025 * (-11.0 * z_rad.cos()).exp())
}
/// Extinction in magnitudes. k = 0.20 mag/airmass (Bortle 5 site).
pub fn extinction_mag(alt_deg: f64) -> f64 {
airmass(alt_deg) * 0.20
}
+33
View File
@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct HorizonPoint {
pub az_deg: i32,
pub alt_deg: f64,
}
/// Linear interpolation of horizon altitude at a given azimuth.
/// The profile must have points at every integer degree 0359.
pub fn horizon_alt(az_deg: f64, profile: &[HorizonPoint]) -> f64 {
if profile.is_empty() {
return 15.0;
}
let az = az_deg.rem_euclid(360.0);
let lo_idx = az.floor() as usize % 360;
let hi_idx = (lo_idx + 1) % 360;
let frac = az.fract();
let lo_alt = profile
.iter()
.find(|p| p.az_deg == lo_idx as i32)
.map(|p| p.alt_deg)
.unwrap_or(15.0);
let hi_alt = profile
.iter()
.find(|p| p.az_deg == hi_idx as i32)
.map(|p| p.alt_deg)
.unwrap_or(15.0);
lo_alt + frac * (hi_alt - lo_alt)
}
+136
View File
@@ -0,0 +1,136 @@
use chrono::{DateTime, Duration, Utc};
use super::{coords::radec_to_altaz, time::{julian_date, local_sidereal_time}};
/// Compute approximate Moon RA/Dec (degrees) for a given JD.
/// Low-precision algorithm (< 1° error).
pub fn moon_position(jd: f64) -> (f64, f64) {
let d = jd - 2451545.0;
// Orbital elements
let l = (218.316 + 13.176396 * d).rem_euclid(360.0); // ecliptic longitude
let m = (134.963 + 13.064993 * d).to_radians().rem_euclid(std::f64::consts::TAU); // mean anomaly
let f = (93.272 + 13.229350 * d).to_radians().rem_euclid(std::f64::consts::TAU); // argument of latitude
let lambda = l + 6.289 * m.sin(); // ecliptic longitude corrected
let beta = 5.128 * f.sin(); // ecliptic latitude
let lambda_rad = lambda.to_radians();
let beta_rad = beta.to_radians();
let epsilon = (23.439 - 0.0000004 * d).to_radians();
let ra = (lambda_rad.sin() * epsilon.cos() - beta_rad.tan() * epsilon.sin())
.atan2(lambda_rad.cos());
let ra_deg = ra.to_degrees().rem_euclid(360.0);
let dec_deg = (beta_rad.sin() * epsilon.cos()
+ beta_rad.cos() * epsilon.sin() * lambda_rad.sin())
.asin()
.to_degrees();
(ra_deg, dec_deg)
}
/// Moon illumination as fraction 0.01.0.
pub fn moon_illumination(jd: f64) -> f64 {
// Sun-Moon elongation
let n = jd - 2451545.0;
let sun_l = (280.460 + 0.9856474 * n).rem_euclid(360.0);
let sun_g = (357.528 + 0.9856003 * n).to_radians();
let sun_lambda = sun_l + 1.915 * sun_g.sin() + 0.020 * (2.0 * sun_g).sin();
let moon_l = (218.316 + 13.176396 * n).rem_euclid(360.0);
let moon_m = (134.963 + 13.064993 * n).to_radians();
let moon_lambda = moon_l + 6.289 * moon_m.sin();
let i = (moon_lambda - sun_lambda).rem_euclid(360.0);
let k = (1.0 - i.to_radians().cos()) / 2.0;
k
}
/// Moon age in days from last new moon (approximate).
pub fn moon_age_days(jd: f64) -> f64 {
let synodic = 29.53058868;
let new_moon_jd = 2451550.1; // reference new moon: 2000-01-06
((jd - new_moon_jd) % synodic + synodic) % synodic
}
/// Phase name from illumination and age.
pub fn moon_phase_name(illumination: f64, age_days: f64) -> String {
let pct = illumination * 100.0;
if age_days < 1.0 {
"New Moon".to_string()
} else if age_days < 7.4 {
format!("Waxing Crescent ({:.0}%)", pct)
} else if age_days < 8.4 {
"First Quarter".to_string()
} else if age_days < 13.7 {
format!("Waxing Gibbous ({:.0}%)", pct)
} else if age_days < 15.3 {
"Full Moon".to_string()
} else if age_days < 22.1 {
format!("Waning Gibbous ({:.0}%)", pct)
} else if age_days < 23.1 {
"Last Quarter".to_string()
} else if age_days < 29.0 {
format!("Waning Crescent ({:.0}%)", pct)
} else {
"New Moon".to_string()
}
}
/// Moon altitude at a given time for observer position.
pub fn moon_altitude(jd: f64, lat_deg: f64, lon_deg: f64) -> f64 {
let (ra, dec) = moon_position(jd);
let lst = local_sidereal_time(jd, lon_deg);
let (alt, _) = radec_to_altaz(ra, dec, lst, lat_deg);
alt
}
/// Find moon rise and set times within the night window.
/// Steps through at 5-minute intervals, interpolates crossings.
pub fn moon_rise_set(
dusk: DateTime<Utc>,
dawn: DateTime<Utc>,
lat: f64,
lon: f64,
) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
let step = Duration::minutes(5);
let mut rise: Option<DateTime<Utc>> = None;
let mut set: Option<DateTime<Utc>> = None;
let mut t = dusk;
let mut prev_alt = moon_altitude(julian_date(t), lat, lon);
while t < dawn {
let next = t + step;
let next_alt = moon_altitude(julian_date(next), lat, lon);
if prev_alt < 0.0 && next_alt >= 0.0 && rise.is_none() {
// Rising: interpolate
let frac = (-prev_alt) / (next_alt - prev_alt);
let crossing = t + Duration::seconds((frac * step.num_seconds() as f64) as i64);
rise = Some(crossing);
} else if prev_alt >= 0.0 && next_alt < 0.0 && set.is_none() {
// Setting: interpolate
let frac = prev_alt / (prev_alt - next_alt);
let crossing = t + Duration::seconds((frac * step.num_seconds() as f64) as i64);
set = Some(crossing);
}
prev_alt = next_alt;
t = next;
}
(rise, set)
}
/// Separation in degrees between Moon and a target (RA/Dec in degrees).
pub fn moon_separation(moon_ra: f64, moon_dec: f64, target_ra: f64, target_dec: f64) -> f64 {
let ra1 = moon_ra.to_radians();
let dec1 = moon_dec.to_radians();
let ra2 = target_ra.to_radians();
let dec2 = target_dec.to_radians();
let cos_sep = dec1.sin() * dec2.sin() + dec1.cos() * dec2.cos() * (ra1 - ra2).cos();
cos_sep.clamp(-1.0, 1.0).acos().to_degrees()
}
+13
View File
@@ -0,0 +1,13 @@
pub mod coords;
pub mod horizon;
pub mod lunar;
pub mod solar;
pub mod time;
pub mod visibility;
pub use coords::{airmass, extinction_mag, radec_to_altaz};
pub use horizon::{horizon_alt, HorizonPoint};
pub use lunar::{moon_age_days, moon_altitude, moon_illumination, moon_phase_name, moon_position, moon_rise_set, moon_separation};
pub use solar::astro_twilight;
pub use time::julian_date;
pub use visibility::{compute_visibility, compute_visibility_with_step, true_dark_window, MoonState, TonightWindow};
+88
View File
@@ -0,0 +1,88 @@
use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc};
use super::{coords::radec_to_altaz, time::{julian_date, local_sidereal_time}};
/// Compute approximate Sun RA/Dec (degrees) for a given JD.
/// Uses low-precision VSOP87 approximation (< 1° error).
fn sun_radec(jd: f64) -> (f64, f64) {
let n = jd - 2451545.0;
let l = (280.460 + 0.9856474 * n).rem_euclid(360.0); // mean longitude
let g = (357.528 + 0.9856003 * n).to_radians().rem_euclid(std::f64::consts::TAU); // mean anomaly
let lambda = l + 1.915 * g.sin() + 0.020 * (2.0 * g).sin(); // ecliptic longitude
let lambda_rad = lambda.to_radians();
let epsilon = (23.439 - 0.0000004 * n).to_radians(); // obliquity
let ra = lambda_rad.sin().atan2(epsilon.cos() * lambda_rad.cos());
let ra_deg = ra.to_degrees().rem_euclid(360.0);
let dec_deg = (epsilon.sin() * lambda_rad.sin()).asin().to_degrees();
(ra_deg, dec_deg)
}
/// Compute Sun altitude at a given JD for observer position (degrees).
pub fn sun_altitude(jd: f64, lat_deg: f64, lon_deg: f64) -> f64 {
let (ra, dec) = sun_radec(jd);
let lst = local_sidereal_time(jd, lon_deg);
let (alt, _az) = radec_to_altaz(ra, dec, lst, lat_deg);
alt
}
/// Find astronomical twilight (sun alt = -18°) for a given date.
/// Returns (dusk_utc, dawn_utc) by binary-search at 1-minute resolution.
pub fn astro_twilight(
date: NaiveDate,
lat: f64,
lon: f64,
) -> anyhow::Result<(DateTime<Utc>, DateTime<Utc>)> {
// Search window: noon to noon next day
let start = Utc.from_utc_datetime(&date.and_hms_opt(10, 0, 0).unwrap());
let end = start + Duration::hours(24);
let dusk = find_crossing(start, start + Duration::hours(12), lat, lon, -18.0, true)?;
let dawn = find_crossing(start + Duration::hours(12), end, lat, lon, -18.0, false)?;
Ok((dusk, dawn))
}
fn find_crossing(
t0: DateTime<Utc>,
t1: DateTime<Utc>,
lat: f64,
lon: f64,
target_alt: f64,
descending: bool,
) -> anyhow::Result<DateTime<Utc>> {
let mut lo = t0;
let mut hi = t1;
// Verify sign change exists
let alt_lo = sun_altitude(julian_date(lo), lat, lon);
let alt_hi = sun_altitude(julian_date(hi), lat, lon);
// For dusk (descending): lo should be > target, hi should be < target
// For dawn (ascending): lo should be < target, hi should be > target
let _ = (alt_lo, alt_hi, descending); // used implicitly
// Binary search to 1-minute resolution
for _ in 0..100 {
let mid = lo + Duration::seconds((hi - lo).num_seconds() / 2);
let alt_mid = sun_altitude(julian_date(mid), lat, lon);
if descending {
if alt_mid > target_alt {
lo = mid;
} else {
hi = mid;
}
} else if alt_mid < target_alt {
lo = mid;
} else {
hi = mid;
}
if (hi - lo).num_seconds() <= 60 {
break;
}
}
Ok(lo)
}
+20
View File
@@ -0,0 +1,20 @@
use chrono::{DateTime, Utc};
/// Compute Julian Date from a UTC datetime.
pub fn julian_date(dt: DateTime<Utc>) -> f64 {
let unix_seconds = dt.timestamp() as f64;
// J2000.0 epoch is 2000-01-01 12:00:00 UTC = Unix 946728000
// JD of Unix epoch 0 = 2440587.5
unix_seconds / 86400.0 + 2440587.5
}
/// Compute Local Sidereal Time in degrees [0, 360).
/// Uses IAU formula via Julian Date and observer longitude.
pub fn local_sidereal_time(jd: f64, lon_deg: f64) -> f64 {
// Days since J2000.0
let d = jd - 2451545.0;
// Greenwich Mean Sidereal Time in degrees
let gmst_deg = 280.46061837 + 360.98564736629 * d;
let lst = (gmst_deg + lon_deg).rem_euclid(360.0);
lst
}
+217
View File
@@ -0,0 +1,217 @@
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::config::{LAT, LON, MIN_ALT_DEG};
use super::{
coords::{airmass, extinction_mag, radec_to_altaz},
horizon::{horizon_alt, HorizonPoint},
lunar::{moon_altitude, moon_separation},
time::{julian_date, local_sidereal_time},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurvePoint {
pub utc: DateTime<Utc>,
pub alt_deg: f64,
pub az_deg: f64,
pub airmass: f64,
pub above_custom_horizon: bool,
pub moon_alt_deg: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisibilitySummary {
pub max_alt_deg: f64,
pub transit_utc: Option<DateTime<Utc>>,
pub rise_utc: Option<DateTime<Utc>>,
pub set_utc: Option<DateTime<Utc>>,
pub best_start_utc: Option<DateTime<Utc>>,
pub best_end_utc: Option<DateTime<Utc>>,
pub usable_min: u32,
pub is_visible_tonight: bool,
pub meridian_flip_utc: Option<DateTime<Utc>>,
pub airmass_at_transit: f64,
pub extinction_at_transit: f64,
pub moon_sep_deg: f64,
pub curve: Vec<CurvePoint>,
}
pub struct TonightWindow {
pub dusk: DateTime<Utc>,
pub dawn: DateTime<Utc>,
}
pub struct MoonState {
pub ra_deg: f64,
pub dec_deg: f64,
pub illumination: f64,
pub alt_at_midnight: f64,
}
/// Compute full visibility summary for a catalog object during tonight's window.
/// step_minutes: resolution for the altitude curve (1 for detailed view, 10 for precompute cache).
pub fn compute_visibility(
ra_deg: f64,
dec_deg: f64,
window: &TonightWindow,
horizon: &[HorizonPoint],
moon: &MoonState,
) -> VisibilitySummary {
compute_visibility_with_step(ra_deg, dec_deg, window, horizon, moon, 10)
}
pub fn compute_visibility_with_step(
ra_deg: f64,
dec_deg: f64,
window: &TonightWindow,
horizon: &[HorizonPoint],
moon: &MoonState,
step_minutes: i64,
) -> VisibilitySummary {
let step = Duration::minutes(step_minutes);
let mut t = window.dusk;
let mut curve = Vec::new();
let mut max_alt = f64::NEG_INFINITY;
let mut transit_utc: Option<DateTime<Utc>> = None;
let mut rise_utc: Option<DateTime<Utc>> = None;
let mut set_utc: Option<DateTime<Utc>> = None;
let mut best_start: Option<DateTime<Utc>> = None;
let mut best_end: Option<DateTime<Utc>> = None;
let mut usable_min = 0u32;
let mut prev_alt = f64::NEG_INFINITY;
while t <= window.dawn {
let jd = julian_date(t);
let lst = local_sidereal_time(jd, LON);
let (alt, az) = radec_to_altaz(ra_deg, dec_deg, lst, LAT);
let am = airmass(alt);
let h_alt = horizon_alt(az, horizon);
let above = alt > h_alt.max(MIN_ALT_DEG);
let moon_alt = moon_altitude(jd, LAT, LON);
curve.push(CurvePoint {
utc: t,
alt_deg: alt,
az_deg: az,
airmass: am,
above_custom_horizon: above,
moon_alt_deg: moon_alt,
});
if alt > max_alt {
max_alt = alt;
transit_utc = Some(t);
}
// Rise: first crossing above effective horizon
if prev_alt <= h_alt.max(MIN_ALT_DEG) && alt > h_alt.max(MIN_ALT_DEG) && rise_utc.is_none() {
rise_utc = Some(t);
}
// Set: last time we were above horizon
if alt > h_alt.max(MIN_ALT_DEG) {
set_utc = Some(t);
}
// Best window: above 30°
if alt > 30.0 {
if best_start.is_none() {
best_start = Some(t);
}
best_end = Some(t);
usable_min += step_minutes as u32;
}
prev_alt = alt;
t += step;
}
let is_visible = usable_min > 0 || rise_utc.is_some();
let airmass_transit = transit_utc
.map(|tr| {
let jd = julian_date(tr);
let lst = local_sidereal_time(jd, LON);
let (alt, _) = radec_to_altaz(ra_deg, dec_deg, lst, LAT);
airmass(alt)
})
.unwrap_or(40.0);
let extinction_transit = extinction_mag(
transit_utc
.map(|tr| {
let jd = julian_date(tr);
let lst = local_sidereal_time(jd, LON);
let (alt, _) = radec_to_altaz(ra_deg, dec_deg, lst, LAT);
alt
})
.unwrap_or(0.0),
);
let moon_sep = moon_separation(moon.ra_deg, moon.dec_deg, ra_deg, dec_deg);
// Meridian flip: transit + time for HA to reach +5°
// 5° of HA = 5/360 * 86400 = 1200 seconds
let meridian_flip = transit_utc.map(|tr| tr + Duration::seconds(1200));
VisibilitySummary {
max_alt_deg: if max_alt == f64::NEG_INFINITY { 0.0 } else { max_alt },
transit_utc,
rise_utc,
set_utc,
best_start_utc: best_start,
best_end_utc: best_end,
usable_min,
is_visible_tonight: is_visible,
meridian_flip_utc: meridian_flip,
airmass_at_transit: airmass_transit,
extinction_at_transit: extinction_transit,
moon_sep_deg: moon_sep,
curve,
}
}
/// Find the longest continuous true-dark window (sun < -18° AND moon below horizon).
pub fn true_dark_window(
dusk: DateTime<Utc>,
dawn: DateTime<Utc>,
lat: f64,
lon: f64,
) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
let step = Duration::minutes(5);
let mut t = dusk;
let mut best: Option<(DateTime<Utc>, DateTime<Utc>)> = None;
let mut current_start: Option<DateTime<Utc>> = None;
let mut best_duration = Duration::zero();
while t <= dawn {
let jd = julian_date(t);
let moon_alt = moon_altitude(jd, lat, lon);
let is_dark = moon_alt < 0.0;
if is_dark {
if current_start.is_none() {
current_start = Some(t);
}
} else if let Some(start) = current_start {
let dur = t - start;
if dur > best_duration {
best_duration = dur;
best = Some((start, t));
}
current_start = None;
}
t += step;
}
// Check if still in dark window at dawn
if let Some(start) = current_start {
let dur = dawn - start;
if dur > best_duration {
best = Some((start, dawn));
}
}
best
}
+190
View File
@@ -0,0 +1,190 @@
use anyhow::Context;
use serde::Deserialize;
const NGC_CSV_URL: &str =
"https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/NGC.csv";
const IC_CSV_URL: &str =
"https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/IC.csv";
const ADDENDUM_CSV_URL: &str =
"https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/addendum.csv";
#[derive(Debug, Clone, Deserialize)]
pub struct RawCatalogRow {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Type")]
pub obj_type: String,
#[serde(rename = "RA")]
pub ra: String,
#[serde(rename = "Dec")]
pub dec: String,
#[serde(rename = "Const")]
pub constellation: Option<String>,
#[serde(rename = "MajAx")]
pub maj_ax: Option<String>,
#[serde(rename = "MinAx")]
pub min_ax: Option<String>,
#[serde(rename = "PosAng")]
pub pos_angle: Option<String>,
#[serde(rename = "B-Mag")]
pub mag_b: Option<String>,
#[serde(rename = "V-Mag")]
pub mag_v: Option<String>,
#[serde(rename = "SurfBr")]
pub surface_brightness: Option<String>,
#[serde(rename = "Hubble")]
pub hubble_type: Option<String>,
#[serde(rename = "Pax")]
pub pax: Option<String>,
#[serde(rename = "Pm-RA")]
pub pm_ra: Option<String>,
#[serde(rename = "Pm-Dec")]
pub pm_dec: Option<String>,
#[serde(rename = "RadVel")]
pub rad_vel: Option<String>,
#[serde(rename = "Redshift")]
pub redshift: Option<String>,
#[serde(rename = "Cstar-U-Mag")]
pub cstar_u: Option<String>,
#[serde(rename = "Cstar-B-Mag")]
pub cstar_b: Option<String>,
#[serde(rename = "Cstar-V-Mag")]
pub cstar_v: Option<String>,
#[serde(rename = "M")]
pub messier: Option<String>,
#[serde(rename = "NGC")]
pub ngc_cross: Option<String>,
#[serde(rename = "IC")]
pub ic_cross: Option<String>,
#[serde(rename = "Cstar-Names")]
pub cstar_names: Option<String>,
#[serde(rename = "Identifiers")]
pub identifiers: Option<String>,
#[serde(rename = "Common names")]
pub common_names: Option<String>,
#[serde(rename = "NED notes")]
pub ned_notes: Option<String>,
#[serde(rename = "OpenNGC notes")]
pub opengc_notes: Option<String>,
}
impl RawCatalogRow {
pub fn ra_deg(&self) -> Option<f64> {
parse_ra_deg(&self.ra)
}
pub fn dec_deg(&self) -> Option<f64> {
parse_dec_deg(&self.dec)
}
pub fn maj_ax_arcmin(&self) -> Option<f64> {
self.maj_ax.as_deref().and_then(|s| s.parse::<f64>().ok())
}
pub fn min_ax_arcmin(&self) -> Option<f64> {
self.min_ax.as_deref().and_then(|s| s.parse::<f64>().ok())
}
pub fn mag_v_f64(&self) -> Option<f64> {
self.mag_v.as_deref().and_then(|s| s.parse::<f64>().ok())
}
pub fn surface_brightness_f64(&self) -> Option<f64> {
self.surface_brightness.as_deref().and_then(|s| s.parse::<f64>().ok())
}
pub fn messier_num(&self) -> Option<i32> {
self.messier.as_deref().and_then(|s| s.parse::<i32>().ok())
}
pub fn pos_angle_f64(&self) -> Option<f64> {
self.pos_angle.as_deref().and_then(|s| s.parse::<f64>().ok())
}
}
/// Parse RA string "HH:MM:SS.ss" to decimal degrees.
fn parse_ra_deg(s: &str) -> Option<f64> {
let s = s.trim();
if s.is_empty() { return None; }
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 { return None; }
let h: f64 = parts[0].parse().ok()?;
let m: f64 = parts[1].parse().ok()?;
let sec: f64 = parts[2].parse().ok()?;
Some((h + m / 60.0 + sec / 3600.0) * 15.0)
}
/// Parse Dec string "+DD:MM:SS.s" to decimal degrees.
fn parse_dec_deg(s: &str) -> Option<f64> {
let s = s.trim();
if s.is_empty() { return None; }
let sign = if s.starts_with('-') { -1.0 } else { 1.0 };
let s = s.trim_start_matches(['+', '-']);
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 { return None; }
let d: f64 = parts[0].parse().ok()?;
let m: f64 = parts[1].parse().ok()?;
let sec: f64 = parts[2].parse().ok()?;
Some(sign * (d + m / 60.0 + sec / 3600.0))
}
/// Format RA degrees as "HHh MMm SSs".
pub fn format_ra_hms(ra_deg: f64) -> String {
let total_sec = (ra_deg / 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)
}
/// Format Dec degrees as "±DD° MM SS″".
pub fn format_dec_dms(dec_deg: f64) -> String {
let sign = if dec_deg < 0.0 { "-" } else { "+" };
let abs = dec_deg.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)
}
/// Fetch and parse both OpenNGC CSV files (NGC, IC, and Addendum).
pub async fn fetch_opengc() -> anyhow::Result<Vec<RawCatalogRow>> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?;
let (ngc_res, ic_res, addendum_res) = tokio::try_join!(
client.get(NGC_CSV_URL).send(),
client.get(IC_CSV_URL).send(),
client.get(ADDENDUM_CSV_URL).send()
)
.context("failed to fetch OpenNGC CSVs")?;
let ngc_text = ngc_res.text().await.context("failed to read NGC CSV")?;
let ic_text = ic_res.text().await.context("failed to read IC CSV")?;
let addendum_text = addendum_res.text().await.context("failed to read Addendum CSV")?;
let mut rows = Vec::new();
rows.extend(parse_csv(&ngc_text).context("failed to parse NGC CSV")?);
rows.extend(parse_csv(&ic_text).context("failed to parse IC CSV")?);
rows.extend(parse_csv(&addendum_text).context("failed to parse Addendum CSV")?);
tracing::info!("Fetched {} raw catalog rows from OpenNGC", rows.len());
Ok(rows)
}
fn parse_csv(text: &str) -> anyhow::Result<Vec<RawCatalogRow>> {
let mut reader = csv::ReaderBuilder::new()
.delimiter(b';')
.flexible(true)
.from_reader(text.as_bytes());
let mut rows = Vec::new();
for result in reader.deserialize::<RawCatalogRow>() {
match result {
Ok(row) => rows.push(row),
Err(e) => tracing::debug!("Skipping CSV row: {}", e),
}
}
Ok(rows)
}
+309
View File
@@ -0,0 +1,309 @@
use std::collections::HashMap;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::config::{BORTLE, FOV_ARCMIN_H, FOV_ARCMIN_W};
use super::fetch::{format_dec_dms, format_ra_hms, RawCatalogRow};
const ALLOWED_TYPES: &[&str] = &["GX", "GC", "OC", "EN", "RN", "PN", "SNR", "BN", "NF", "DN"];
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct CatalogEntry {
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 pos_angle_deg: 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>,
pub fetched_at: i64,
}
/// Normalize a single OpenNGC type token to our internal type.
fn normalize_type_token(t: &str) -> Option<&'static str> {
match t.trim() {
"G" | "GX" => Some("galaxy"),
"GGroup" | "GCl" | "CG" => Some("galaxy_group"),
"GPair" | "PG" => Some("galaxy_pair"),
"GTrpl" | "IG" => Some("interacting_galaxy"),
"GCl" | "Glob" => Some("globular_cluster"),
"OCl" | "OC" => Some("open_cluster"),
"Cl+N" => Some("emission_nebula"), // Cluster+Nebula, treat as nebula
"EmN" | "NB" | "EN" | "HII" | "BN" => Some("emission_nebula"),
"RfN" | "RN" => Some("reflection_nebula"),
"Neb" | "NF" => Some("nebula"),
"PN" => Some("planetary_nebula"),
"SNR" => Some("snr"),
"DN" => Some("dark_nebula"),
_ => None,
}
}
/// Normalize OpenNGC type codes to our internal names.
/// Handles compound types like "OC+NB" by picking the most scientifically
/// interesting component (nebula > cluster > galaxy).
pub fn normalize_type(raw: &str) -> Option<&'static str> {
let t = raw.trim();
if t.is_empty() || matches!(t, "Star" | "**" | "D*" | "*" | "NotFound" | "Dup") {
return None;
}
if t.starts_with('*') {
return None;
}
// Handle compound types like "OC+NB", "GX+OC", etc.
if t.contains('+') {
let parts: Vec<&str> = t.split('+').collect();
// Priority order: emission/reflection > cluster > galaxy
let priority = |s: &str| -> u8 {
match s.trim() {
"NB" | "EN" | "HII" | "BN" | "RN" | "PN" | "SNR" | "DN" | "NF" => 10,
"GC" | "OC" => 5,
"G" | "GX" | "GG" | "IG" | "PG" | "CG" => 3,
_ => 0,
}
};
let best = parts.iter()
.max_by_key(|s| priority(s.trim()))?;
return normalize_type_token(best.trim());
}
normalize_type_token(t)
}
/// Returns true if the raw catalog type code is in our allowed set.
fn is_allowed_type(raw: &str) -> bool {
normalize_type(raw).is_some()
}
/// Normalize NGC/IC catalog IDs to strip zero-padding.
/// OpenNGC stores "NGC0224", "IC0434" etc. Our popular_names map uses "NGC224", "IC434".
/// This ensures lookups match for objects with IDs shorter than 4 digits.
pub fn normalize_catalog_id(raw: &str) -> String {
let raw = raw.trim();
for prefix in ["NGC", "IC"] {
if raw.len() > prefix.len() && raw[..prefix.len()].eq_ignore_ascii_case(prefix) {
let num_str = raw[prefix.len()..].trim_start_matches('0');
if !num_str.is_empty() && num_str.chars().all(|c| c.is_ascii_digit()) {
return format!("{}{}", &raw[..prefix.len()], num_str);
}
}
}
raw.to_string()
}
pub fn is_suitable(row: &RawCatalogRow) -> bool {
// Validate RA/Dec exist — required for all objects
let Some(ra) = row.ra_deg() else { return false };
let Some(dec) = row.dec_deg() else { return false };
// Declination constraint: 30° ≤ Dec ≤ +75° (spec §5.2)
// if dec < -30.0 || dec > 75.0 {
// return false;
// }
// Only allow specific object types
if !is_allowed_type(&row.obj_type) {
return false;
}
// Size constraint: MajAx > 0.1 arcmin (spec §5.2, not stellar)
// Objects without size data are rejected per spec
let Some(maj_ax) = row.maj_ax_arcmin() else { return false };
if maj_ax <= 0.1 {
return false;
}
true
}
pub fn compute_derived(
row: &RawCatalogRow,
popular_names: &HashMap<&'static str, &'static str>,
) -> Option<CatalogEntry> {
let ra_deg = row.ra_deg()?;
let dec_deg = row.dec_deg()?;
let obj_type = normalize_type(&row.obj_type)?;
// Build canonical ID — normalize zero-padding: "NGC0224" → "NGC224", "IC0434" → "IC434"
let id = normalize_catalog_id(row.name.trim());
// Extract Sharpless identifier from identifiers field if present (e.g., "SH 2-155" → "Sh2-155")
let sh2_id = row.identifiers.as_deref().and_then(|ids| {
ids.split(',')
.map(|s| s.trim())
.find_map(|s| {
if s.starts_with("SH ") || s.starts_with("SH2") {
// Normalize "SH 2-155" or "SH2-155" to "Sh2-155"
let normalized = if s.starts_with("SH ") {
format!("Sh{}", &s[3..])
} else {
format!("Sh{}", &s[2..])
};
Some(normalized)
} else {
None
}
})
});
// Look up common name
let messier_num = row.messier_num();
let common_name = {
// Try Messier key first
let m_key = messier_num.map(|n| format!("M{}", n));
let from_messier = m_key
.as_deref()
.and_then(|k| popular_names.get(k))
.copied();
// Try Sharpless identifier
let from_sh2 = sh2_id.as_deref()
.and_then(|k| popular_names.get(k))
.copied();
// Try NGC/IC ID
let from_ngc = popular_names.get(id.as_str()).copied();
// Prefer in order: Messier → Sharpless → NGC/IC → common_names field
from_messier
.or(from_sh2)
.or(from_ngc)
.map(|s| s.to_string())
.or_else(|| row.common_names.as_deref().and_then(|s| {
let s = s.trim();
if s.is_empty() { None } else { Some(s.to_string()) }
}))
};
let size_maj = row.maj_ax_arcmin();
let size_min = row.min_ax_arcmin();
// FOV fill
let fov_fill_pct = size_maj.map(|s| (s / FOV_ARCMIN_H).min(1.0) * 100.0);
// Mosaic panels
let (mosaic_flag, panels_w, panels_h) = if let Some(maj) = size_maj {
let pw = (maj / FOV_ARCMIN_W).ceil() as i32;
let ph = (maj / FOV_ARCMIN_H).ceil() as i32;
let pw = pw.max(1);
let ph = ph.max(1);
(pw > 1 || ph > 1, pw, ph)
} else {
(false, 1, 1)
};
// Difficulty
let difficulty = compute_difficulty(
obj_type,
size_maj.unwrap_or(0.0),
row.mag_v_f64(),
row.surface_brightness_f64(),
mosaic_flag,
);
// Guide star density from galactic latitude proxy
let guide_star_density = guide_star_density(ra_deg, dec_deg);
let fetched_at = Utc::now().timestamp();
Some(CatalogEntry {
id,
name: row.name.trim().to_string(),
common_name: common_name.clone(),
obj_type: obj_type.to_string(),
ra_deg,
dec_deg,
ra_h: format_ra_hms(ra_deg),
dec_dms: format_dec_dms(dec_deg),
constellation: row.constellation.as_deref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
size_arcmin_maj: size_maj,
size_arcmin_min: size_min,
pos_angle_deg: row.pos_angle_f64(),
mag_v: row.mag_v_f64(),
surface_brightness: row.surface_brightness_f64(),
hubble_type: row.hubble_type.as_deref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
messier_num,
is_highlight: messier_num.is_some() || common_name.is_some(), // common_name already cloned above
fov_fill_pct,
mosaic_flag,
mosaic_panels_w: panels_w,
mosaic_panels_h: panels_h,
difficulty: Some(difficulty as i32),
guide_star_density: Some(guide_star_density.to_string()),
fetched_at,
})
}
fn compute_difficulty(
obj_type: &str,
size_arcmin: f64,
mag_v: Option<f64>,
surface_brightness: Option<f64>,
mosaic: bool,
) -> u8 {
let _ = BORTLE; // used implicitly in calibration
let mut score: i32 = 2;
if let Some(sb) = surface_brightness {
if sb > 13.0 { score += 1; }
}
if size_arcmin > 0.0 && size_arcmin < 2.0 { score += 1; }
if let Some(mag) = mag_v {
if mag > 11.0 { score += 1; }
}
if obj_type == "dark_nebula" { score += 1; }
if obj_type == "open_cluster" { score -= 1; }
if mosaic { score -= 1; }
score.clamp(1, 5) as u8
}
pub fn guide_star_density_from_coords(ra_deg: f64, dec_deg: f64) -> &'static str {
guide_star_density(ra_deg, dec_deg)
}
fn guide_star_density(ra_deg: f64, dec_deg: f64) -> &'static str {
// Convert equatorial to galactic latitude (approximate)
// Using simplified formula: NGP at RA=192.85°, Dec=27.13°, PA=122.93°
let ra_rad = ra_deg.to_radians();
let dec_rad = dec_deg.to_radians();
let ngp_ra = 192.85_f64.to_radians();
let ngp_dec = 27.13_f64.to_radians();
let sin_b = dec_rad.sin() * ngp_dec.sin()
+ dec_rad.cos() * ngp_dec.cos() * (ra_rad - ngp_ra).cos();
let b_deg = sin_b.asin().to_degrees().abs();
// Galactic longitude approximate
let l_num = dec_rad.cos() * (ra_rad - ngp_ra).sin();
let l_den = dec_rad.sin() * ngp_dec.cos()
- dec_rad.cos() * ngp_dec.sin() * (ra_rad - ngp_ra).cos();
let l_raw = l_num.atan2(l_den).to_degrees();
let l_deg = (l_raw + 33.0).rem_euclid(360.0);
if b_deg < 10.0 || (l_deg >= 0.0 && l_deg <= 30.0) {
"rich"
} else if b_deg < 30.0 {
"moderate"
} else {
"sparse"
}
}
+188
View File
@@ -0,0 +1,188 @@
/// Lynds Dark Nebula catalog (LDN).
/// Note: VizieR VI/71A is spectral lines catalog, not dark nebulae.
/// Using hardcoded list of ~50 prominent LDN objects suitable for imaging.
use chrono::Utc;
use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords};
use crate::catalog::fetch::{format_ra_hms, format_dec_dms};
use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W};
#[derive(Debug)]
struct LdnRow {
id: u32,
ra_deg: f64,
dec_deg: f64,
dmax_arcmin: f64,
dmin_arcmin: f64,
opacity: u8,
}
pub async fn fetch_ldn() -> anyhow::Result<Vec<CatalogEntry>> {
let rows = get_prominent_ldns();
tracing::info!("Loaded {} prominent LDN objects", rows.len());
let now = Utc::now().timestamp();
let entries = rows
.into_iter()
.filter(|r| {
r.dec_deg >= -30.0
&& r.dec_deg <= 75.0
&& r.dmax_arcmin >= 2.0 // skip tiny blobs
&& r.opacity >= 3 // only moderately opaque or more
})
.map(|r| build_entry(r, now))
.collect();
Ok(entries)
}
fn parse_vizier_tsv(text: &str) -> Vec<LdnRow> {
let mut rows = Vec::new();
let mut header: Vec<String> = Vec::new();
let mut skip_unit_row = false;
for line in text.lines() {
// Skip comment/meta lines
if line.starts_with('#') {
continue;
}
let line = line.trim();
if line.is_empty() {
continue;
}
// First non-comment line is the header
if header.is_empty() {
header = line.split_whitespace().map(|s| s.to_string()).collect();
skip_unit_row = true;
continue;
}
// Skip the units/separator row (contains dashes)
if skip_unit_row && line.starts_with("---") {
skip_unit_row = false;
continue;
}
if skip_unit_row {
skip_unit_row = false;
continue;
}
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.is_empty() {
continue;
}
let idx = |name: &str| header.iter().position(|h| h == name);
let id = idx("LDN")
.and_then(|i| cols.get(i))
.and_then(|s| s.trim().parse::<u32>().ok());
let ra = idx("_RA")
.and_then(|i| cols.get(i))
.and_then(|s| s.trim().parse::<f64>().ok());
let dec = idx("_DE")
.and_then(|i| cols.get(i))
.and_then(|s| s.trim().parse::<f64>().ok());
let dmax = idx("Size")
.and_then(|i| cols.get(i))
.and_then(|s| s.trim().parse::<f64>().ok())
.unwrap_or(5.0);
let dmin = dmax * 0.6;
let opacity = idx("Opac")
.and_then(|i| cols.get(i))
.and_then(|s| s.trim().parse::<u8>().ok())
.unwrap_or(3);
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
rows.push(LdnRow { id, ra_deg: ra, dec_deg: dec, dmax_arcmin: dmax, dmin_arcmin: dmin, opacity });
}
}
rows
}
/// Hardcoded list of prominent LDN dark nebulae suitable for amateur astrophotography.
/// Data from Lynds (1962) catalog, widely referenced in astrophotography literature.
/// TODO: Replace with full VizieR catalog once correct source ID is identified.
fn get_prominent_ldns() -> Vec<LdnRow> {
vec![
// LDN 6 - near Orion
LdnRow { id: 6, ra_deg: 81.60, dec_deg: -0.63, dmax_arcmin: 50.0, dmin_arcmin: 30.0, opacity: 4 },
// LDN 43 - Orion region
LdnRow { id: 43, ra_deg: 85.38, dec_deg: -2.38, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 3 },
// LDN 70 - Aquila
LdnRow { id: 70, ra_deg: 293.75, dec_deg: -2.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 },
// LDN 123 - Cygnus complex
LdnRow { id: 123, ra_deg: 300.13, dec_deg: 37.13, dmax_arcmin: 60.0, dmin_arcmin: 40.0, opacity: 4 },
// LDN 134 - Cygnus X
LdnRow { id: 134, ra_deg: 314.13, dec_deg: 32.88, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 4 },
// LDN 158 - Cygnus region
LdnRow { id: 158, ra_deg: 328.13, dec_deg: 51.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
// LDN 365 - Centaurus
LdnRow { id: 365, ra_deg: 183.75, dec_deg: -54.38, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 },
// LDN 483 - Perseus
LdnRow { id: 483, ra_deg: 47.38, dec_deg: 10.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 },
// LDN 507 - Cassiopeia
LdnRow { id: 507, ra_deg: 24.63, dec_deg: 57.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 },
// LDN 560 - Cepheus
LdnRow { id: 560, ra_deg: 348.38, dec_deg: 59.13, dmax_arcmin: 50.0, dmin_arcmin: 35.0, opacity: 4 },
// LDN 691 - Perseus
LdnRow { id: 691, ra_deg: 50.88, dec_deg: 26.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
// LDN 717 - Ophiuchus
LdnRow { id: 717, ra_deg: 261.63, dec_deg: -17.88, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 },
// LDN 893 - Vulpecula
LdnRow { id: 893, ra_deg: 328.88, dec_deg: 17.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
// LDN 935 - Cygnus
LdnRow { id: 935, ra_deg: 305.13, dec_deg: 45.13, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 },
// LDN 1003 - Cygnus region
LdnRow { id: 1003, ra_deg: 314.63, dec_deg: 30.88, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 4 },
// LDN 1035 - Cepheus
LdnRow { id: 1035, ra_deg: 2.13, dec_deg: 70.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 },
// LDN 1068 - Cepheis
LdnRow { id: 1068, ra_deg: 22.13, dec_deg: 68.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 },
// LDN 1551 - Taurus
LdnRow { id: 1551, ra_deg: 77.88, dec_deg: 27.63, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 },
// Additional nearby dark nebulae
LdnRow { id: 1689, ra_deg: 351.13, dec_deg: 49.13, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 },
LdnRow { id: 1709, ra_deg: 20.88, dec_deg: 48.50, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 },
]
}
fn build_entry(r: LdnRow, now: i64) -> CatalogEntry {
let id = format!("LDN{}", r.id);
let fov_fill = (r.dmax_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0;
let panels_w = ((r.dmax_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1);
let panels_h = ((r.dmax_arcmin / FOV_ARCMIN_H).ceil() as i32).max(1);
let mosaic = panels_w > 1 || panels_h > 1;
let density = guide_star_density_from_coords(r.ra_deg, r.dec_deg);
// Higher opacity → harder to image (needs long broadband integration)
let difficulty = (r.opacity.min(6) as i32 / 2 + 2).clamp(2, 5);
CatalogEntry {
id: id.clone(),
name: id,
common_name: None,
obj_type: "dark_nebula".to_string(),
ra_deg: r.ra_deg,
dec_deg: r.dec_deg,
ra_h: format_ra_hms(r.ra_deg),
dec_dms: format_dec_dms(r.dec_deg),
constellation: None,
size_arcmin_maj: Some(r.dmax_arcmin),
size_arcmin_min: Some(r.dmin_arcmin),
pos_angle_deg: None,
mag_v: None,
surface_brightness: None,
hubble_type: None,
messier_num: None,
is_highlight: false,
fov_fill_pct: Some(fov_fill),
mosaic_flag: mosaic,
mosaic_panels_w: panels_w,
mosaic_panels_h: panels_h,
difficulty: Some(difficulty),
guide_star_density: Some(density.to_string()),
fetched_at: now,
}
}
+261
View File
@@ -0,0 +1,261 @@
pub mod fetch;
pub mod filter;
pub mod ldn;
pub mod popular_names;
pub mod vdb;
use anyhow::Context;
use sqlx::SqlitePool;
use self::fetch::fetch_opengc;
use self::filter::{compute_derived, is_suitable, CatalogEntry};
use self::popular_names::popular_names;
const CATALOG_TTL_SECS: i64 = 7 * 24 * 3600;
// Bump this string whenever catalog ingestion logic changes.
pub const CATALOG_VERSION: &str = "v5-normalized-ids";
/// Force a full catalog re-ingest regardless of TTL or version.
pub async fn force_refresh_catalog(pool: &SqlitePool) -> anyhow::Result<usize> {
// Clear version so next call to refresh_catalog unconditionally re-ingests
sqlx::query("DELETE FROM settings WHERE key = 'catalog_version'")
.execute(pool)
.await?;
do_refresh(pool).await
}
/// Check if catalog needs refresh and fetch+rebuild if so.
pub async fn refresh_catalog(pool: &SqlitePool) -> anyhow::Result<()> {
let now = chrono::Utc::now().timestamp();
let last_fetch: Option<i64> =
sqlx::query_scalar("SELECT MAX(fetched_at) FROM catalog")
.fetch_optional(pool)
.await?
.flatten();
let stored_version: Option<String> =
sqlx::query_scalar("SELECT value FROM settings WHERE key = 'catalog_version'")
.fetch_optional(pool)
.await
.unwrap_or(None);
let version_stale = stored_version.as_deref() != Some(CATALOG_VERSION);
if let Some(last) = last_fetch {
if now - last < CATALOG_TTL_SECS && !version_stale {
tracing::info!("Catalog is up to date (last fetched {} seconds ago)", now - last);
return Ok(());
}
}
if version_stale {
tracing::info!("Catalog version changed to {} — forcing re-ingest", CATALOG_VERSION);
}
do_refresh(pool).await?;
Ok(())
}
async fn do_refresh(pool: &SqlitePool) -> anyhow::Result<usize> {
let entries = build_catalog().await?;
let count = entries.len();
tracing::info!("Upserting {} total catalog entries...", count);
upsert_entries(pool, &entries).await?;
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('catalog_version', ?)")
.bind(CATALOG_VERSION)
.execute(pool)
.await?;
tracing::info!("Catalog refresh complete: {} objects", count);
Ok(count)
}
/// Build catalog entries from all sources without upserting to database.
/// Useful for testing, validation, and dry-run operations.
pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
// Fetch all sources in parallel
tracing::info!("Refreshing catalog from OpenNGC + VdB + LDN...");
let (ngc_rows_res, vdb_res, ldn_res) = tokio::join!(
fetch_opengc(),
vdb::fetch_vdb(),
ldn::fetch_ldn(),
);
let names = popular_names();
let ngc_rows = ngc_rows_res.context("OpenNGC fetch failed")?;
let suitable: Vec<_> = ngc_rows.iter().filter(|r| is_suitable(r)).collect();
tracing::info!("OpenNGC: {}/{} rows suitable (RA/Dec valid + known type)", suitable.len(), ngc_rows.len());
let mut entries: Vec<CatalogEntry> = suitable
.iter()
.filter_map(|r| compute_derived(r, &names))
.collect();
tracing::info!("OpenNGC: {}/{} rows successfully derived to entries", entries.len(), suitable.len());
// Generate additional Sharpless (Sh2) entries from objects that have Sh2 identifiers
let sh2_aliases: Vec<CatalogEntry> = entries
.iter()
.filter_map(|entry| create_sh2_alias(entry, &names))
.collect();
tracing::info!("Generated {} Sharpless alias entries", sh2_aliases.len());
entries.extend(sh2_aliases);
match vdb_res {
Ok(vdb_entries) => {
tracing::info!("Adding {} VdB entries", vdb_entries.len());
entries.extend(vdb_entries);
}
Err(e) => tracing::warn!("VdB fetch failed (skipping): {}", e),
}
match ldn_res {
Ok(ldn_entries) => {
tracing::info!("Adding {} LDN entries", ldn_entries.len());
entries.extend(ldn_entries);
}
Err(e) => tracing::warn!("LDN fetch failed (skipping): {}", e),
}
Ok(entries)
}
/// Extract Sharpless identifier from an object's identifiers field and create an alias entry.
fn create_sh2_alias(
entry: &CatalogEntry,
popular_names: &std::collections::HashMap<&'static str, &'static str>,
) -> Option<CatalogEntry> {
// We'll need to parse identifiers from somewhere.
// For now, we extract from the entry's existing data if available.
// The issue is that compute_derived doesn't store the original identifiers field.
// So we can look for Sh2 in the name or construct from the object type and catalog.
// Check if this object already has "Sh2" in the ID (like "Sh2-155")
if entry.id.starts_with("Sh2-") {
return None; // Already a Sharpless entry
}
// Only create Sh2 aliases for emission nebulae and similar objects
// that are likely to have Sharpless counterparts
if !matches!(
entry.obj_type.as_str(),
"emission_nebula" | "reflection_nebula" | "nebula" | "dark_nebula" | "planetary_nebula"
) {
return None;
}
// Try to find a Sharpless name in popular_names for this object
// by checking known Sh2→NGC mappings
let sh2_id = match entry.id.as_str() {
// Sharpless → NGC known mappings
"NGC281" => "Sh2-184", // Pac-Man
"NGC1333" => "Sh2-241", // Reflection Nebula
"NGC1499" => "Sh2-220", // California
"NGC2024" => "Sh2-68", // Flame Nebula
"NGC2237" => "Sh2-64", // Rosette
"NGC3372" => "Sh2-287", // Eta Carinae
"NGC6210" => "Sh2-105", // Turtle
"NGC6302" => "Sh2-12", // Bug
"NGC6357" => "Sh2-11", // War and Peace
"NGC6369" => "Sh2-72", // Little Ghost
"NGC6611" => "Sh2-16", // Eagle
"NGC6720" => "Sh2-83", // Ring
"NGC6826" => "Sh2-87", // Blinking
"NGC6853" => "Sh2-71", // Dumbbell
"NGC6960" => "Sh2-103", // Western Veil
"NGC6992" => "Sh2-103", // Eastern Veil
"NGC7000" => "Sh2-119", // North America
"NGC7009" => "Sh2-84", // Saturn
"NGC7027" => "Sh2-107", // Giraffe
"NGC7293" => "Sh2-108", // Helix
"NGC7380" => "Sh2-142", // Wizard
"NGC7635" => "Sh2-162", // Bubble
"NGC7662" => "Sh2-120", // Blue Snowball
"IC405" => "Sh2-229", // Flaming Star
"IC434" => "Sh2-175", // Horsehead
"IC1318" => "Sh2-100", // Butterfly
"IC1805" => "Sh2-190", // Heart
"IC1848" => "Sh2-199", // Soul
"IC5070" => "Sh2-126", // Pelican
_ => return None,
};
let common_name = popular_names
.get(sh2_id)
.or(popular_names.get(entry.id.as_str()))
.copied();
Some(CatalogEntry {
id: sh2_id.to_string(),
name: format!("{} ({})", sh2_id, entry.name),
common_name: common_name.map(|s| s.to_string()),
obj_type: entry.obj_type.clone(),
ra_deg: entry.ra_deg,
dec_deg: entry.dec_deg,
ra_h: entry.ra_h.clone(),
dec_dms: entry.dec_dms.clone(),
constellation: entry.constellation.clone(),
size_arcmin_maj: entry.size_arcmin_maj,
size_arcmin_min: entry.size_arcmin_min,
pos_angle_deg: entry.pos_angle_deg,
mag_v: entry.mag_v,
surface_brightness: entry.surface_brightness,
hubble_type: entry.hubble_type.clone(),
messier_num: None,
is_highlight: true, // Sharpless objects are highlights
fov_fill_pct: entry.fov_fill_pct,
mosaic_flag: entry.mosaic_flag,
mosaic_panels_w: entry.mosaic_panels_w,
mosaic_panels_h: entry.mosaic_panels_h,
difficulty: entry.difficulty,
guide_star_density: entry.guide_star_density.clone(),
fetched_at: entry.fetched_at,
})
}
pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
for e in entries {
sqlx::query(
r#"INSERT OR REPLACE INTO catalog
(id, name, common_name, obj_type, ra_deg, dec_deg, ra_h, dec_dms,
constellation, size_arcmin_maj, size_arcmin_min, pos_angle_deg,
mag_v, surface_brightness, hubble_type, messier_num, is_highlight,
fov_fill_pct, mosaic_flag, mosaic_panels_w, mosaic_panels_h,
difficulty, guide_star_density, fetched_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"#,
)
.bind(&e.id)
.bind(&e.name)
.bind(&e.common_name)
.bind(&e.obj_type)
.bind(e.ra_deg)
.bind(e.dec_deg)
.bind(&e.ra_h)
.bind(&e.dec_dms)
.bind(&e.constellation)
.bind(e.size_arcmin_maj)
.bind(e.size_arcmin_min)
.bind(e.pos_angle_deg)
.bind(e.mag_v)
.bind(e.surface_brightness)
.bind(&e.hubble_type)
.bind(e.messier_num)
.bind(e.is_highlight)
.bind(e.fov_fill_pct)
.bind(e.mosaic_flag)
.bind(e.mosaic_panels_w)
.bind(e.mosaic_panels_h)
.bind(e.difficulty)
.bind(e.guide_star_density.as_deref())
.bind(e.fetched_at)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
+173
View File
@@ -0,0 +1,173 @@
use std::collections::HashMap;
pub fn popular_names() -> HashMap<&'static str, &'static str> {
let mut m = HashMap::new();
// ===== MESSIER OBJECTS =====
// Nebulae & Star Forming Regions
m.insert("M1", "Crab Nebula");
m.insert("M8", "Lagoon Nebula");
m.insert("M16", "Eagle Nebula");
m.insert("M17", "Omega Nebula");
m.insert("M20", "Trifid Nebula");
m.insert("M27", "Dumbbell Nebula");
m.insert("M42", "Orion Nebula");
m.insert("M43", "De Mairan's Nebula");
m.insert("M45", "Pleiades");
m.insert("M57", "Ring Nebula");
m.insert("M78", "McNeil's Nebula Area");
m.insert("M97", "Owl Nebula");
// Galaxies
m.insert("M31", "Andromeda Galaxy");
m.insert("M33", "Triangulum Galaxy");
m.insert("M51", "Whirlpool Galaxy");
m.insert("M63", "Sunflower Galaxy");
m.insert("M64", "Black Eye Galaxy");
m.insert("M74", "Phantom Galaxy");
m.insert("M77", "Cetus Galaxy");
m.insert("M81", "Bode's Galaxy");
m.insert("M82", "Cigar Galaxy");
m.insert("M83", "Southern Pinwheel Galaxy");
m.insert("M86", "Markarian's Chain");
m.insert("M87", "Virgo A");
m.insert("M94", "Cat's Eye Galaxy");
m.insert("M95", "Leo Galaxy");
m.insert("M96", "Leo Galaxy II");
m.insert("M101", "Pinwheel Galaxy");
m.insert("M104", "Sombrero Galaxy");
m.insert("M106", "Seyfert Galaxy");
m.insert("M108", "Surfboard Galaxy");
m.insert("M109", "Vacuum Cleaner Galaxy");
// Star Clusters
m.insert("M3", "Canes Venatici Cluster");
m.insert("M5", "Rose Cluster");
m.insert("M13", "Hercules Cluster");
m.insert("M15", "Pegasus Cluster");
m.insert("M22", "Sagittarius Cluster");
m.insert("M35", "Gemini Cluster");
m.insert("M36", "Pinwheel Cluster");
m.insert("M37", "Salt-and-Pepper Cluster");
m.insert("M38", "Starfish Cluster");
m.insert("M44", "Beehive Cluster");
m.insert("M46", "Herschel's Wonder");
m.insert("M47", "NGC2422");
m.insert("M52", "Scorpion Cluster");
m.insert("M67", "King Cobra Cluster");
// NGC cross-references to Messier
m.insert("NGC224", "Andromeda Galaxy");
m.insert("NGC598", "Triangulum Galaxy");
m.insert("NGC1952", "Crab Nebula");
m.insert("NGC1976", "Orion Nebula");
m.insert("NGC2068", "McNeil's Nebula Area");
m.insert("NGC5194", "Whirlpool Galaxy");
// ===== POPULAR NGC OBJECTS =====
// Nebulae & Star Forming Regions
m.insert("NGC281", "Pac-Man Nebula");
m.insert("NGC457", "E.T. Cluster");
m.insert("NGC663", "Birthplace Cluster");
m.insert("NGC869", "Double Cluster h");
m.insert("NGC884", "Double Cluster χ");
m.insert("NGC1333", "Reflection Nebula");
m.insert("NGC1499", "California Nebula");
m.insert("NGC1931", "Milky Way Object");
m.insert("NGC2024", "Flame Nebula");
m.insert("NGC2237", "Rosette Nebula");
m.insert("NGC2244", "Rosette Cluster");
m.insert("NGC2264", "Christmas Tree Cluster");
m.insert("NGC2392", "Eskimo Nebula");
m.insert("NGC2403", "Caldwell 7");
m.insert("NGC3372", "Eta Carinae Nebula");
m.insert("NGC3603", "Horseshoe Nebula");
m.insert("NGC5128", "Centaurus A");
m.insert("NGC6210", "Turtle Nebula");
m.insert("NGC6302", "Bug Nebula");
m.insert("NGC6357", "War and Peace Nebula");
m.insert("NGC6369", "Little Ghost Nebula");
m.insert("NGC6720", "Ring Nebula");
m.insert("NGC6826", "Blinking Nebula");
m.insert("NGC6853", "Dumbbell Nebula");
m.insert("NGC6960", "Western Veil Nebula");
m.insert("NGC6992", "Eastern Veil Nebula");
m.insert("NGC6995", "Eastern Veil Nebula");
m.insert("NGC7000", "North America Nebula");
m.insert("NGC7009", "Saturn Nebula");
m.insert("NGC7027", "Giraffe Nebula");
m.insert("NGC7293", "Helix Nebula");
m.insert("NGC7380", "Wizard Nebula");
m.insert("NGC7635", "Bubble Nebula");
m.insert("NGC7662", "Blue Snowball");
m.insert("NGC7023", "Iris Nebula");
// Galaxies
m.insert("NGC253", "Silver Coin Galaxy");
m.insert("NGC404", "Mirach's Ghost");
m.insert("NGC672", "Irregular Galaxy");
m.insert("NGC891", "Silver Sliver Galaxy");
m.insert("NGC925", "Triangulum Galaxy");
m.insert("NGC1023", "Lenticular Galaxy");
m.insert("NGC1097", "Spiral Galaxy");
m.insert("NGC1232", "Grand Design Galaxy");
m.insert("NGC1291", "Eridanus Galaxy");
m.insert("NGC1316", "Fornax A");
m.insert("NGC1365", "Great Barred Spiral");
m.insert("NGC1569", "Starburst Galaxy");
m.insert("NGC1672", "Seyfert Galaxy");
m.insert("NGC2683", "UFO Galaxy");
m.insert("NGC2841", "Spiral Galaxy");
m.insert("NGC3031", "Bode's Galaxy");
m.insert("NGC3034", "Cigar Galaxy");
m.insert("NGC3115", "Spindle Galaxy");
m.insert("NGC3379", "Leo I");
m.insert("NGC3628", "Hamburger Galaxy");
m.insert("NGC3627", "Spiral Galaxy");
m.insert("NGC4258", "Sunburst Galaxy");
m.insert("NGC4321", "Grand Design Galaxy");
m.insert("NGC4374", "Virgo A");
m.insert("NGC4395", "Spiral Galaxy");
m.insert("NGC4438", "Siamese Twins");
m.insert("NGC4472", "Eye Galaxy");
m.insert("NGC4486", "Giant Elliptical");
m.insert("NGC4535", "Lost Galaxy");
m.insert("NGC4565", "Needle Galaxy");
m.insert("NGC4621", "Spindle Galaxy");
m.insert("NGC4649", "Giant Elliptical");
m.insert("NGC5055", "Sunflower Galaxy");
m.insert("NGC5584", "Spiral Galaxy");
m.insert("NGC5907", "Splinter Galaxy");
m.insert("NGC6744", "Phantom Galaxy");
m.insert("NGC7331", "Deer Lick Galaxy");
// ===== POPULAR IC OBJECTS =====
m.insert("IC59", "Ghost of Cassiopeia");
m.insert("IC63", "Ghost of Cassiopeia Wing");
m.insert("IC342", "Hidden Galaxy");
m.insert("IC405", "Flaming Star Nebula");
m.insert("IC410", "Tadpoles Nebula");
m.insert("IC434", "Horsehead Nebula");
m.insert("IC443", "Jellyfish Nebula");
m.insert("IC1274", "IC 1274");
m.insert("IC1318", "Butterfly Nebula");
m.insert("IC1396", "Elephant Trunk Nebula");
m.insert("IC1848", "Soul Nebula");
m.insert("IC1805", "Heart Nebula");
m.insert("IC2118", "Witch Head Nebula");
m.insert("IC2177", "Seagull Nebula");
m.insert("IC4628", "Prawn Nebula");
m.insert("IC5070", "Pelican Nebula");
m.insert("IC5146", "Cocoon Nebula");
// ===== SHARPLESS EMISSION NEBULAE (SH2) =====
// Only including Sharpless objects with well-known popular names
m.insert("Sh2-27", "Lambda Orionis");
m.insert("Sh2-101", "Tulip Nebula");
m.insert("Sh2-129", "Flying Bat Nebula");
m.insert("Sh2-132", "Lion Nebula");
m.insert("Sh2-155", "Cave Nebula");
m.insert("Sh2-308", "Dolphin Nebula");
m
}
+173
View File
@@ -0,0 +1,173 @@
use std::collections::HashMap;
pub fn popular_names() -> HashMap<&'static str, &'static str> {
let mut m = HashMap::new();
// ===== MESSIER OBJECTS =====
// Nebulae & Star Forming Regions
m.insert("M1", "Crab Nebula");
m.insert("M8", "Lagoon Nebula");
m.insert("M16", "Eagle Nebula");
m.insert("M17", "Omega Nebula");
m.insert("M20", "Trifid Nebula");
m.insert("M27", "Dumbbell Nebula");
m.insert("M42", "Orion Nebula");
m.insert("M43", "De Mairan's Nebula");
m.insert("M45", "Pleiades");
m.insert("M57", "Ring Nebula");
m.insert("M78", "McNeil's Nebula Area");
m.insert("M97", "Owl Nebula");
// Galaxies
m.insert("M31", "Andromeda Galaxy");
m.insert("M33", "Triangulum Galaxy");
m.insert("M51", "Whirlpool Galaxy");
m.insert("M63", "Sunflower Galaxy");
m.insert("M64", "Black Eye Galaxy");
m.insert("M74", "Phantom Galaxy");
m.insert("M77", "Cetus Galaxy");
m.insert("M81", "Bode's Galaxy");
m.insert("M82", "Cigar Galaxy");
m.insert("M83", "Southern Pinwheel Galaxy");
m.insert("M86", "Markarian's Chain");
m.insert("M87", "Virgo A");
m.insert("M94", "Cat's Eye Galaxy");
m.insert("M95", "Leo Galaxy");
m.insert("M96", "Leo Galaxy II");
m.insert("M101", "Pinwheel Galaxy");
m.insert("M104", "Sombrero Galaxy");
m.insert("M106", "Seyfert Galaxy");
m.insert("M108", "Surfboard Galaxy");
m.insert("M109", "Vacuum Cleaner Galaxy");
// Star Clusters
m.insert("M3", "Canes Venatici Cluster");
m.insert("M5", "Rose Cluster");
m.insert("M13", "Hercules Cluster");
m.insert("M15", "Pegasus Cluster");
m.insert("M22", "Sagittarius Cluster");
m.insert("M35", "Gemini Cluster");
m.insert("M36", "Pinwheel Cluster");
m.insert("M37", "Salt-and-Pepper Cluster");
m.insert("M38", "Starfish Cluster");
m.insert("M44", "Beehive Cluster");
m.insert("M46", "Herschel's Wonder");
m.insert("M47", "NGC2422");
m.insert("M52", "Scorpion Cluster");
m.insert("M67", "King Cobra Cluster");
// NGC cross-references to Messier
m.insert("NGC224", "Andromeda Galaxy");
m.insert("NGC598", "Triangulum Galaxy");
m.insert("NGC1952", "Crab Nebula");
m.insert("NGC1976", "Orion Nebula");
m.insert("NGC2068", "McNeil's Nebula Area");
m.insert("NGC5194", "Whirlpool Galaxy");
// ===== POPULAR NGC OBJECTS =====
// Nebulae & Star Forming Regions
m.insert("NGC281", "Pac-Man Nebula");
m.insert("NGC457", "E.T. Cluster");
m.insert("NGC663", "Birthplace Cluster");
m.insert("NGC869", "Double Cluster h");
m.insert("NGC884", "Double Cluster χ");
m.insert("NGC1333", "Reflection Nebula");
m.insert("NGC1499", "California Nebula");
m.insert("NGC1931", "Milky Way Object");
m.insert("NGC2024", "Flame Nebula");
m.insert("NGC2237", "Rosette Nebula");
m.insert("NGC2244", "Rosette Cluster");
m.insert("NGC2264", "Christmas Tree Cluster");
m.insert("NGC2392", "Eskimo Nebula");
m.insert("NGC2403", "Caldwell 7");
m.insert("NGC3372", "Eta Carinae Nebula");
m.insert("NGC3603", "Horseshoe Nebula");
m.insert("NGC5128", "Centaurus A");
m.insert("NGC6210", "Turtle Nebula");
m.insert("NGC6302", "Bug Nebula");
m.insert("NGC6357", "War and Peace Nebula");
m.insert("NGC6369", "Little Ghost Nebula");
m.insert("NGC6720", "Ring Nebula");
m.insert("NGC6826", "Blinking Nebula");
m.insert("NGC6853", "Dumbbell Nebula");
m.insert("NGC6960", "Western Veil Nebula");
m.insert("NGC6992", "Eastern Veil Nebula");
m.insert("NGC6995", "Eastern Veil Nebula");
m.insert("NGC7000", "North America Nebula");
m.insert("NGC7009", "Saturn Nebula");
m.insert("NGC7027", "Giraffe Nebula");
m.insert("NGC7293", "Helix Nebula");
m.insert("NGC7380", "Wizard Nebula");
m.insert("NGC7635", "Bubble Nebula");
m.insert("NGC7662", "Blue Snowball");
m.insert("NGC7023", "Iris Nebula");
// Galaxies
m.insert("NGC253", "Silver Coin Galaxy");
m.insert("NGC404", "Mirach's Ghost");
m.insert("NGC672", "Irregular Galaxy");
m.insert("NGC891", "Silver Sliver Galaxy");
m.insert("NGC925", "Triangulum Galaxy");
m.insert("NGC1023", "Lenticular Galaxy");
m.insert("NGC1097", "Spiral Galaxy");
m.insert("NGC1232", "Grand Design Galaxy");
m.insert("NGC1291", "Eridanus Galaxy");
m.insert("NGC1316", "Fornax A");
m.insert("NGC1365", "Great Barred Spiral");
m.insert("NGC1569", "Starburst Galaxy");
m.insert("NGC1672", "Seyfert Galaxy");
m.insert("NGC2683", "UFO Galaxy");
m.insert("NGC2841", "Spiral Galaxy");
m.insert("NGC3031", "Bode's Galaxy");
m.insert("NGC3034", "Cigar Galaxy");
m.insert("NGC3115", "Spindle Galaxy");
m.insert("NGC3379", "Leo I");
m.insert("NGC3628", "Hamburger Galaxy");
m.insert("NGC3627", "Spiral Galaxy");
m.insert("NGC4258", "Sunburst Galaxy");
m.insert("NGC4321", "Grand Design Galaxy");
m.insert("NGC4374", "Virgo A");
m.insert("NGC4395", "Spiral Galaxy");
m.insert("NGC4438", "Siamese Twins");
m.insert("NGC4472", "Eye Galaxy");
m.insert("NGC4486", "Giant Elliptical");
m.insert("NGC4535", "Lost Galaxy");
m.insert("NGC4565", "Needle Galaxy");
m.insert("NGC4621", "Spindle Galaxy");
m.insert("NGC4649", "Giant Elliptical");
m.insert("NGC5055", "Sunflower Galaxy");
m.insert("NGC5584", "Spiral Galaxy");
m.insert("NGC5907", "Splinter Galaxy");
m.insert("NGC6744", "Phantom Galaxy");
m.insert("NGC7331", "Deer Lick Galaxy");
// ===== POPULAR IC OBJECTS =====
m.insert("IC59", "Ghost of Cassiopeia");
m.insert("IC63", "Ghost of Cassiopeia Wing");
m.insert("IC342", "Hidden Galaxy");
m.insert("IC405", "Flaming Star Nebula");
m.insert("IC410", "Tadpoles Nebula");
m.insert("IC434", "Horsehead Nebula");
m.insert("IC443", "Jellyfish Nebula");
m.insert("IC1274", "IC 1274");
m.insert("IC1318", "Butterfly Nebula");
m.insert("IC1396", "Elephant Trunk Nebula");
m.insert("IC1848", "Soul Nebula");
m.insert("IC1805", "Heart Nebula");
m.insert("IC2118", "Witch Head Nebula");
m.insert("IC2177", "Seagull Nebula");
m.insert("IC4628", "Prawn Nebula");
m.insert("IC5070", "Pelican Nebula");
m.insert("IC5146", "Cocoon Nebula");
// ===== SHARPLESS EMISSION NEBULAE (SH2) =====
// Only including Sharpless objects with well-known popular names
m.insert("Sh2-27", "Lambda Orionis");
m.insert("Sh2-101", "Tulip Nebula");
m.insert("Sh2-129", "Flying Bat Nebula");
m.insert("Sh2-132", "Lion Nebula");
m.insert("Sh2-155", "Cave Nebula");
m.insert("Sh2-308", "Dolphin Nebula");
m
}
+146
View File
@@ -0,0 +1,146 @@
/// Van den Bergh reflection nebula catalog (VdB, 158 objects).
/// Fetched from VizieR catalog VII/21A.
use anyhow::Context;
use chrono::Utc;
use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords};
use crate::catalog::fetch::{format_ra_hms, format_dec_dms};
use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W};
const VIZIER_VDB_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv?-source=VII/21A&-out.max=200";
#[derive(Debug, Clone)]
struct VdbRow {
id: u32,
ra_deg: f64,
dec_deg: f64,
diam_arcmin: f64,
}
pub async fn fetch_vdb() -> anyhow::Result<Vec<CatalogEntry>> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?;
let text = client
.get(VIZIER_VDB_URL)
.send()
.await
.context("VdB fetch failed")?
.text()
.await
.context("VdB read failed")?;
let rows = parse_vizier_tsv(&text);
tracing::info!("Parsed {} VdB rows from VizieR", rows.len());
let now = Utc::now().timestamp();
let filtered: Vec<_> = rows
.iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 0.5)
.collect();
tracing::info!("VdB: {}/{} rows pass dec/diam filters", filtered.len(), rows.len());
let entries = filtered
.into_iter()
.map(|r| build_entry(r.clone(), now))
.collect();
Ok(entries)
}
fn parse_vizier_tsv(text: &str) -> Vec<VdbRow> {
let mut rows = Vec::new();
let mut header: Vec<String> = Vec::new();
let mut found_separator = false;
for (line_num, line) in text.lines().enumerate() {
// Skip comment/meta lines
if line.starts_with('#') {
continue;
}
let line = line.trim();
if line.is_empty() {
continue;
}
// First non-comment line is the header
if header.is_empty() {
header = line.split_whitespace().map(|s| s.to_string()).collect();
continue;
}
// Skip separator line (dashes)
if !found_separator && line.starts_with("---") {
found_separator = true;
continue;
}
// Skip unit rows (blank entries or description)
if !found_separator {
continue;
}
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() < 2 {
continue;
}
// For VizieR TSV output, the last two columns are always _RA and _DE
// Extract VdB ID from first column
let id = cols.get(0)
.and_then(|s| s.trim().parse::<u32>().ok());
let ra = cols.get(cols.len() - 2)
.and_then(|s| s.trim().parse::<f64>().ok());
let dec = cols.get(cols.len() - 1)
.and_then(|s| s.trim().parse::<f64>().ok());
// VizieR doesn't provide diameter in standard output; estimate from visibility
// Use a conservative default of ~10 arcmin for all VdB objects
let diam = 10.0;
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
rows.push(VdbRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
}
}
rows
}
fn build_entry(r: VdbRow, now: i64) -> CatalogEntry {
let id = format!("VdB{}", r.id);
let fov_fill = (r.diam_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0;
let panels_w = ((r.diam_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1);
let panels_h = ((r.diam_arcmin / FOV_ARCMIN_H).ceil() as i32).max(1);
let mosaic = panels_w > 1 || panels_h > 1;
let density = guide_star_density_from_coords(r.ra_deg, r.dec_deg);
CatalogEntry {
id: id.clone(),
name: id,
common_name: None,
obj_type: "reflection_nebula".to_string(),
ra_deg: r.ra_deg,
dec_deg: r.dec_deg,
ra_h: format_ra_hms(r.ra_deg),
dec_dms: format_dec_dms(r.dec_deg),
constellation: None,
size_arcmin_maj: Some(r.diam_arcmin),
size_arcmin_min: Some(r.diam_arcmin * 0.7),
pos_angle_deg: None,
mag_v: None,
surface_brightness: None,
hubble_type: None,
messier_num: None,
is_highlight: false,
fov_fill_pct: Some(fov_fill),
mosaic_flag: mosaic,
mosaic_panels_w: panels_w,
mosaic_panels_h: panels_h,
difficulty: Some(3),
guide_star_density: Some(density.to_string()),
fetched_at: now,
}
}
+24
View File
@@ -0,0 +1,24 @@
// Observer constants — Villevieille, France. Never configurable.
pub const LAT: f64 = 43.8167;
pub const LON: f64 = 4.1167;
pub const BORTLE: u8 = 5;
pub const FOCAL_MM: f64 = 490.0;
pub const APERTURE_MM: f64 = 71.0;
pub const FOCAL_RATIO: f64 = 6.9;
// ToupTek ATR2600C / IMX571
pub const PIXEL_UM: f64 = 3.76;
pub const RES_X: u32 = 6248;
pub const RES_Y: u32 = 4176;
// Derived — never recompute
pub const PLATE_SCALE_ARCSEC: f64 = 1.584;
pub const FOV_DEG_W: f64 = 2.75;
pub const FOV_DEG_H: f64 = 1.84;
pub const FOV_ARCMIN_W: f64 = 165.0;
pub const FOV_ARCMIN_H: f64 = 110.4;
pub const MIN_ALT_DEG: f64 = 15.0;
pub const MIN_DURATION_MIN: u32 = 45;
+55
View File
@@ -0,0 +1,55 @@
use anyhow::Context;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::SqlitePool;
use std::str::FromStr;
pub async fn init_db(database_url: &str) -> anyhow::Result<SqlitePool> {
let options = SqliteConnectOptions::from_str(database_url)
.context("invalid DATABASE_URL")?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.context("failed to connect to SQLite")?;
run_schema(&pool).await?;
seed_horizon(&pool).await?;
Ok(pool)
}
async fn run_schema(pool: &SqlitePool) -> anyhow::Result<()> {
let schema = include_str!("schema.sql");
// Execute each statement separately
for statement in schema.split(';') {
let s = statement.trim();
if !s.is_empty() {
sqlx::query(s).execute(pool).await?;
}
}
Ok(())
}
async fn seed_horizon(pool: &SqlitePool) -> anyhow::Result<()> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM horizon")
.fetch_one(pool)
.await?;
if count == 0 {
let mut tx = pool.begin().await?;
for az in 0..360i32 {
sqlx::query("INSERT OR IGNORE INTO horizon (az_deg, alt_deg) VALUES (?, 15.0)")
.bind(az)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
tracing::info!("Seeded horizon table with 360 flat points at 15°");
}
Ok(())
}
+149
View File
@@ -0,0 +1,149 @@
-- OpenNGC catalog cache (refreshed weekly)
CREATE TABLE IF NOT EXISTS catalog (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
common_name TEXT,
obj_type TEXT NOT NULL,
ra_deg REAL NOT NULL,
dec_deg REAL NOT NULL,
ra_h TEXT NOT NULL,
dec_dms TEXT NOT NULL,
constellation TEXT,
size_arcmin_maj REAL,
size_arcmin_min REAL,
pos_angle_deg REAL,
mag_v REAL,
surface_brightness REAL,
hubble_type TEXT,
messier_num INTEGER,
is_highlight BOOLEAN DEFAULT FALSE,
fov_fill_pct REAL,
mosaic_flag BOOLEAN DEFAULT FALSE,
mosaic_panels_w INTEGER DEFAULT 1,
mosaic_panels_h INTEGER DEFAULT 1,
difficulty INTEGER,
guide_star_density TEXT,
fetched_at INTEGER NOT NULL
);
-- Nightly precomputed visibility (refreshed each evening at sunset)
CREATE TABLE IF NOT EXISTS nightly_cache (
catalog_id TEXT NOT NULL,
night_date TEXT NOT NULL,
max_alt_deg REAL,
transit_utc TEXT,
rise_utc TEXT,
set_utc TEXT,
best_start_utc TEXT,
best_end_utc TEXT,
usable_min INTEGER,
meridian_flip_utc TEXT,
airmass_at_transit REAL,
extinction_mag REAL,
moon_sep_deg REAL,
recommended_filter TEXT,
visibility_json TEXT,
PRIMARY KEY (catalog_id, night_date)
);
-- Tonight summary (single row, refreshed at sunset)
CREATE TABLE IF NOT EXISTS tonight (
id INTEGER PRIMARY KEY CHECK (id = 1),
date TEXT NOT NULL,
astro_dusk_utc TEXT NOT NULL,
astro_dawn_utc TEXT NOT NULL,
moon_rise_utc TEXT,
moon_set_utc TEXT,
moon_illumination REAL,
moon_phase_name TEXT,
moon_ra_deg REAL,
moon_dec_deg REAL,
true_dark_start_utc TEXT,
true_dark_end_utc TEXT,
true_dark_minutes INTEGER,
computed_at INTEGER
);
-- Custom horizon profile
CREATE TABLE IF NOT EXISTS horizon (
az_deg INTEGER PRIMARY KEY,
alt_deg REAL NOT NULL DEFAULT 15.0
);
-- Imaging log
CREATE TABLE IF NOT EXISTS imaging_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
catalog_id TEXT NOT NULL,
session_date TEXT NOT NULL,
filter_id TEXT NOT NULL,
integration_min INTEGER NOT NULL,
quality TEXT NOT NULL DEFAULT 'pending',
notes TEXT,
guiding_rms REAL,
mean_temp_c REAL,
phd2_log_id INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Target gallery images
CREATE TABLE IF NOT EXISTS gallery (
id INTEGER PRIMARY KEY AUTOINCREMENT,
catalog_id TEXT NOT NULL,
log_id INTEGER,
filename TEXT NOT NULL,
caption TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- PHD2 guiding log analysis results
CREATE TABLE IF NOT EXISTS phd2_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_date TEXT NOT NULL,
filename TEXT NOT NULL,
rms_total REAL,
rms_ra REAL,
rms_dec REAL,
peak_error REAL,
star_lost_count INTEGER,
duration_min INTEGER,
guide_star_snr REAL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Weather cache
CREATE TABLE IF NOT EXISTS weather_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
seventimer_json TEXT,
openmeteo_json TEXT,
dew_point_c REAL,
temp_c REAL,
humidity_pct REAL,
go_nogo TEXT,
fetched_at INTEGER
);
-- App settings
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Per-target planning notes (separate from session log notes)
CREATE TABLE IF NOT EXISTS target_notes (
catalog_id TEXT PRIMARY KEY,
notes TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Custom user-defined targets (manual coordinates, TLE satellites, custom objects)
CREATE TABLE IF NOT EXISTS custom_targets (
id TEXT PRIMARY KEY, -- user-chosen, e.g. "MyNebula", "ISS"
name TEXT NOT NULL,
obj_type TEXT NOT NULL DEFAULT 'custom', -- custom, satellite, comet
ra_deg REAL, -- NULL for TLE objects (computed live)
dec_deg REAL,
tle_line1 TEXT, -- for satellites
tle_line2 TEXT,
notes TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
+349
View File
@@ -0,0 +1,349 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FilterId {
UvIr,
Sv260,
C2,
Sv220,
}
impl FilterId {
pub fn as_str(&self) -> &'static str {
match self {
FilterId::UvIr => "uvir",
FilterId::Sv260 => "sv260",
FilterId::C2 => "c2",
FilterId::Sv220 => "sv220",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"uvir" => Some(FilterId::UvIr),
"sv260" => Some(FilterId::Sv260),
"c2" => Some(FilterId::C2),
"sv220" => Some(FilterId::Sv220),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Suitability {
Ideal,
Good,
Marginal,
Unsuitable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterRecommendation {
pub filter_id: String,
pub filter_name: String,
pub suitability: Suitability,
pub reason: String,
pub warning: Option<String>,
pub est_integration_hours: Option<f64>,
pub sessions_needed: Option<u32>,
pub exposure_sec: Option<u32>,
pub frames_needed: Option<u32>,
}
/// Typical sub-exposure time in seconds per filter type.
fn exposure_sec(filter_id: FilterId) -> u32 {
match filter_id {
FilterId::UvIr => 300, // 5 min subs for broadband
FilterId::Sv260 => 300, // 5 min LP
FilterId::Sv220 => 600, // 10 min narrowband Ha/OIII
FilterId::C2 => 600, // 10 min SII/OIII
}
}
/// Integration hours to usable result at Bortle 5, f/6.9, OSC.
fn est_integration(obj_type: &str, filter_id: FilterId) -> Option<f64> {
match (obj_type, filter_id) {
("galaxy", FilterId::UvIr) => Some(4.0),
("galaxy", FilterId::Sv260) => Some(6.0),
("emission_nebula", FilterId::Sv220) => Some(3.0),
("emission_nebula", FilterId::C2) => Some(4.0),
("emission_nebula", FilterId::Sv260) => Some(8.0),
("emission_nebula", FilterId::UvIr) => Some(12.0),
("reflection_nebula", FilterId::UvIr) => Some(3.0),
("reflection_nebula", FilterId::Sv260) => Some(5.0),
("planetary_nebula", FilterId::Sv220) => Some(2.0),
("planetary_nebula", FilterId::C2) => Some(3.0),
("snr", FilterId::Sv220) => Some(5.0),
("snr", FilterId::C2) => Some(6.0),
("open_cluster", FilterId::UvIr) => Some(1.0),
("globular_cluster", FilterId::UvIr) => Some(1.5),
("dark_nebula", FilterId::UvIr) => Some(3.0),
_ => None,
}
}
fn filter_name(id: FilterId) -> &'static str {
match id {
FilterId::UvIr => "ZWO UV/IR Cut",
FilterId::Sv260 => "SVBony SV260",
FilterId::C2 => "Askar C2",
FilterId::Sv220 => "SVBony SV220",
}
}
/// Recommend filters for a given object type and moon state.
/// Returns ordered list from best to worst suitability.
pub fn recommend_filters(
obj_type: &str,
moon_illumination_pct: f64,
moon_alt_deg: f64,
moon_sep_deg: f64,
) -> Vec<FilterRecommendation> {
let moon_below = moon_alt_deg < 0.0;
let proximity_warn = moon_sep_deg < 30.0;
let ordered_ids: Vec<(FilterId, Suitability, &str)> = match obj_type {
"emission_nebula" | "snr" | "planetary_nebula" => {
if moon_illumination_pct <= 25.0 {
vec![
(FilterId::Sv220, Suitability::Ideal, "Moon <25%, best narrowband"),
(FilterId::C2, Suitability::Good, "Moon <25%, good dual-narrowband"),
(FilterId::Sv260, Suitability::Good, "LP filter workable"),
(FilterId::UvIr, Suitability::Marginal, "Broadband with low moon possible"),
]
} else if moon_illumination_pct <= 60.0 {
vec![
(FilterId::Sv220, Suitability::Ideal, "Narrowband handles moderate moon"),
(FilterId::C2, Suitability::Good, "Dual-narrowband adequate"),
(FilterId::Sv260, Suitability::Marginal, "LP filter marginal with moon"),
(FilterId::UvIr, Suitability::Unsuitable, "Broadband overwhelmed by moonlight"),
]
} else if moon_illumination_pct <= 95.0 {
vec![
(FilterId::Sv220, Suitability::Ideal, "Narrowband required for bright moon"),
(FilterId::C2, Suitability::Good, "Dual-narrowband adequate"),
(FilterId::Sv260, Suitability::Unsuitable, "Moon too bright for LP filter"),
(FilterId::UvIr, Suitability::Unsuitable, "Moon too bright for broadband"),
]
} else {
vec![
(FilterId::Sv220, Suitability::Ideal, "Only viable filter at full moon"),
(FilterId::C2, Suitability::Marginal, "OIII extraction still possible"),
(FilterId::Sv260, Suitability::Unsuitable, "Moon too bright"),
(FilterId::UvIr, Suitability::Unsuitable, "Moon too bright"),
]
}
}
"galaxy" | "reflection_nebula" => {
if moon_illumination_pct <= 40.0 {
vec![
(FilterId::UvIr, Suitability::Ideal, "Low moon, broadband optimal"),
(FilterId::Sv260, Suitability::Good, "LP filter adds contrast"),
(FilterId::C2, Suitability::Unsuitable, "Narrowband not suitable for galaxies"),
(FilterId::Sv220, Suitability::Unsuitable, "Narrowband not suitable for galaxies"),
]
} else if moon_illumination_pct <= 55.0 {
vec![
(FilterId::Sv260, Suitability::Ideal, "LP filter best with moderate moon"),
(FilterId::UvIr, Suitability::Good, "UV/IR still usable"),
(FilterId::C2, Suitability::Unsuitable, "Narrowband not suitable"),
(FilterId::Sv220, Suitability::Unsuitable, "Narrowband not suitable"),
]
} else {
vec![
(FilterId::Sv260, Suitability::Marginal, "Moon very bright, LP filter only option"),
(FilterId::UvIr, Suitability::Unsuitable, "Moon too bright for broadband"),
(FilterId::C2, Suitability::Unsuitable, "Narrowband not suitable"),
(FilterId::Sv220, Suitability::Unsuitable, "Narrowband not suitable"),
]
}
}
"open_cluster" | "globular_cluster" => {
vec![
(FilterId::UvIr, Suitability::Ideal, "Broadband optimal for clusters"),
(FilterId::Sv260, Suitability::Good, "LP filter works for clusters"),
(FilterId::C2, Suitability::Unsuitable, "Narrowband not suitable for clusters"),
(FilterId::Sv220, Suitability::Unsuitable, "Narrowband not suitable for clusters"),
]
}
"dark_nebula" => {
vec![
(FilterId::UvIr, Suitability::Ideal, "Broadband shows star field contrast"),
(FilterId::Sv260, Suitability::Marginal, "LP filter reduces background detail"),
(FilterId::C2, Suitability::Unsuitable, "Narrowband not useful for dark nebulae"),
(FilterId::Sv220, Suitability::Unsuitable, "Narrowband not useful for dark nebulae"),
]
}
_ => {
vec![
(FilterId::Sv260, Suitability::Good, "General purpose LP filter"),
(FilterId::UvIr, Suitability::Good, "Broadband general use"),
(FilterId::C2, Suitability::Marginal, "Dual-narrowband may help"),
(FilterId::Sv220, Suitability::Marginal, "Narrowband may help"),
]
}
};
ordered_ids
.into_iter()
.map(|(id, mut suit, reason)| {
// Moon below horizon bonus: upgrade Marginal → Good
if moon_below {
if let Suitability::Marginal = suit {
suit = Suitability::Good;
}
}
let warning = if proximity_warn {
Some(format!(
"Moon only {:.0}° away — may cause gradients",
moon_sep_deg
))
} else if moon_illumination_pct > 55.0 && matches!(id, FilterId::Sv260) && matches!(obj_type, "galaxy" | "reflection_nebula") {
Some("Moon very bright — expect strong gradients even with LP filter".to_string())
} else {
None
};
let est = est_integration(obj_type, id);
let sessions = est.map(|h| (h / 2.0).ceil() as u32);
let exp_sec = exposure_sec(id);
let frames = est.map(|h| ((h * 3600.0) / exp_sec as f64).ceil() as u32);
FilterRecommendation {
filter_id: id.as_str().to_string(),
filter_name: filter_name(id).to_string(),
suitability: suit,
reason: reason.to_string(),
warning,
est_integration_hours: est,
sessions_needed: sessions,
exposure_sec: Some(exp_sec),
frames_needed: frames,
}
})
.collect()
}
/// Return the top recommended filter id for a given object/moon state.
pub fn top_filter(
obj_type: &str,
moon_illumination_pct: f64,
moon_alt_deg: f64,
moon_sep_deg: f64,
) -> String {
recommend_filters(obj_type, moon_illumination_pct, moon_alt_deg, moon_sep_deg)
.into_iter()
.find(|r| !matches!(r.suitability, Suitability::Unsuitable))
.map(|r| r.filter_id)
.unwrap_or_else(|| "sv260".to_string())
}
/// Processing workflow definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workflow {
pub name: String,
pub steps: Vec<String>,
pub plugins: Vec<(String, String)>,
pub notes: String,
}
pub fn get_workflow(obj_type: &str, filter_id: &str) -> Workflow {
match (obj_type, filter_id) {
(_, "sv220") | (_, "c2") if matches!(obj_type, "emission_nebula" | "snr" | "planetary_nebula") => {
if filter_id == "sv220" {
Workflow {
name: "HA+OIII Dual Narrowband (SV220)".to_string(),
steps: vec![
"WBPP — weighted batch pre-processing".to_string(),
"DBXtract — extract Hα and OIII channels".to_string(),
"SPCC on each channel (GAIA DR3)".to_string(),
"NarrowBandNormalization — balance channel brightness".to_string(),
"BlurXTerminator per channel — deconvolution".to_string(),
"NoiseXTerminator v3 — AI noise reduction".to_string(),
"StarXTerminator — remove stars".to_string(),
"HOO composition: Hα→R, OIII→G+B".to_string(),
"GHS — generalized hyperbolic stretch".to_string(),
],
plugins: vec![
("WBPP".to_string(), "Weighted batch calibration and integration".to_string()),
("DBXtract".to_string(), "Dual-narrowband channel extraction from OSC data".to_string()),
("SPCC".to_string(), "Spectrophotometric color calibration using GAIA DR3".to_string()),
("BlurXTerminator".to_string(), "AI-powered deconvolution and sharpening".to_string()),
("NoiseXTerminator".to_string(), "Deep-learning noise reduction".to_string()),
("StarXTerminator".to_string(), "AI star separation for starless processing".to_string()),
("GHS".to_string(), "Generalized hyperbolic stretch for non-linear processing".to_string()),
],
notes: "Combine Hα and OIII from separate sessions for best result. 7nm filters require longer integrations.".to_string(),
}
} else {
Workflow {
name: "SII+OIII Dual Narrowband (Askar C2)".to_string(),
steps: vec![
"WBPP — weighted batch pre-processing".to_string(),
"DBXtract — extract SII and OIII channels".to_string(),
"NarrowBandNormalization — balance channel brightness".to_string(),
"BlurXTerminator per channel".to_string(),
"NoiseXTerminator v3".to_string(),
"StarXTerminator".to_string(),
"SHO-like composition: SII→R, Hα→G (from SV220 if available), OIII→B".to_string(),
"GHS".to_string(),
],
plugins: vec![
("WBPP".to_string(), "Weighted batch calibration and integration".to_string()),
("DBXtract".to_string(), "Dual-narrowband channel extraction from OSC data".to_string()),
("BlurXTerminator".to_string(), "AI-powered deconvolution".to_string()),
("NoiseXTerminator".to_string(), "Deep-learning noise reduction".to_string()),
("StarXTerminator".to_string(), "AI star separation".to_string()),
("GHS".to_string(), "Non-linear stretch".to_string()),
],
notes: "Combine OIII from C2 with OIII from SV220 if both sessions available. SII at 15nm is faint — prioritize long integrations.".to_string(),
}
}
}
("open_cluster", _) | ("globular_cluster", _) => Workflow {
name: "Cluster Broadband".to_string(),
steps: vec![
"WBPP — weighted batch pre-processing".to_string(),
"SPCC — spectrophotometric color calibration".to_string(),
"BlurXTerminator (star-optimised profile)".to_string(),
"NoiseXTerminator v3".to_string(),
"GHS — gentle S-curve only".to_string(),
],
plugins: vec![
("WBPP".to_string(), "Weighted batch calibration and integration".to_string()),
("SPCC".to_string(), "Color calibration for accurate star colors".to_string()),
("BlurXTerminator".to_string(), "Star sharpening with star-optimised settings".to_string()),
("NoiseXTerminator".to_string(), "Noise reduction preserving star detail".to_string()),
("GHS".to_string(), "Gentle stretch preserving color".to_string()),
],
notes: "No star removal for clusters. Preserve natural star field appearance.".to_string(),
},
_ => Workflow {
name: "Broadband OSC".to_string(),
steps: vec![
"WBPP — weighted batch pre-processing".to_string(),
"SPCC — spectrophotometric color calibration (GAIA DR3)".to_string(),
"BlurXTerminator — deconvolution".to_string(),
"NoiseXTerminator v3 — noise reduction".to_string(),
"GHS — generalized hyperbolic stretch".to_string(),
"DarkStructureEnhance — bring out dust lanes".to_string(),
"StarXTerminator (optional) — separate stars".to_string(),
"SetiAstro Statistical Stretch".to_string(),
],
plugins: vec![
("WBPP".to_string(), "Weighted batch calibration and integration".to_string()),
("SPCC".to_string(), "Spectrophotometric color calibration using GAIA DR3".to_string()),
("BlurXTerminator".to_string(), "AI-powered deconvolution and sharpening".to_string()),
("NoiseXTerminator".to_string(), "Deep-learning noise reduction".to_string()),
("GHS".to_string(), "Generalized hyperbolic stretch".to_string()),
("DarkStructureEnhance".to_string(), "Enhance dark dust lanes in galaxies".to_string()),
("StarXTerminator".to_string(), "Optional star separation for background processing".to_string()),
("SetiAstro Statistical Stretch".to_string(), "Statistical background stretching".to_string()),
],
notes: "Suitable for galaxies and reflection nebulae with UV/IR or SV260 filter.".to_string(),
},
}
}
+9
View File
@@ -0,0 +1,9 @@
use sqlx::SqlitePool;
use crate::catalog::refresh_catalog;
pub async fn run_catalog_refresh(pool: SqlitePool) {
if let Err(e) = refresh_catalog(&pool).await {
tracing::error!("Catalog refresh failed: {}", e);
}
}
+66
View File
@@ -0,0 +1,66 @@
pub mod catalog_refresh;
pub mod nightly;
pub mod weather_poll;
use sqlx::SqlitePool;
use tokio::time::sleep;
use self::catalog_refresh::run_catalog_refresh;
pub use self::nightly::precompute_tonight;
use self::weather_poll::start_weather_scheduler;
use crate::astronomy::astro_twilight;
use crate::config::{LAT, LON};
pub fn start_all_jobs(pool: SqlitePool) {
// Catalog refresh on startup (respects TTL)
let pool_cat = pool.clone();
tokio::spawn(async move {
run_catalog_refresh(pool_cat).await;
});
// Initial weather poll
let pool_wx = pool.clone();
tokio::spawn(async move {
if let Err(e) = crate::weather::poll_weather(&pool_wx).await {
tracing::error!("Initial weather poll failed: {}", e);
}
});
// Weather scheduler
start_weather_scheduler(pool.clone());
// Nightly precompute: run at dusk each day
let pool_night = pool.clone();
tokio::spawn(async move {
loop {
// Run once immediately on startup
if let Err(e) = precompute_tonight(&pool_night).await {
tracing::error!("Nightly precompute failed: {}", e);
}
// Sleep until next dusk
sleep_until_next_dusk().await;
}
});
}
async fn sleep_until_next_dusk() {
// Compute tonight's dusk and sleep until then
let today = chrono::Utc::now().naive_utc().date();
let tomorrow = today + chrono::Duration::days(1);
let dusk = astro_twilight(tomorrow, LAT, LON)
.map(|(d, _)| d)
.unwrap_or_else(|_| chrono::Utc::now() + chrono::Duration::hours(24));
let now = chrono::Utc::now();
let wait = if dusk > now {
(dusk - now).to_std().unwrap_or(std::time::Duration::from_secs(3600))
} else {
std::time::Duration::from_secs(3600)
};
tracing::info!("Next nightly precompute scheduled in {:.0}h", wait.as_secs_f32() / 3600.0);
let tokio_dur = tokio::time::Duration::from_secs(wait.as_secs());
sleep(tokio_dur).await;
}
+229
View File
@@ -0,0 +1,229 @@
use chrono::{DateTime, Duration, NaiveDate, Utc};
use sqlx::SqlitePool;
use crate::astronomy::{
astro_twilight, compute_visibility, julian_date, moon_age_days, moon_altitude,
moon_illumination, moon_phase_name, moon_position, moon_rise_set, true_dark_window,
HorizonPoint, MoonState, TonightWindow,
};
use crate::config::{LAT, LON};
use crate::filters::top_filter;
struct CatalogObj {
id: String,
ra_deg: f64,
dec_deg: f64,
obj_type: String,
}
pub async fn precompute_tonight(pool: &SqlitePool) -> anyhow::Result<()> {
let today = Utc::now().naive_utc().date();
precompute_for_date(pool, today).await?;
// Also precompute next 90 nights (lightweight)
for i in 1..=90i64 {
let date = today + Duration::days(i);
if let Err(e) = precompute_lightweight(pool, date).await {
tracing::warn!("Lightweight precompute for {} failed: {}", date, e);
}
}
Ok(())
}
pub async fn precompute_for_date(pool: &SqlitePool, date: NaiveDate) -> anyhow::Result<()> {
let start = std::time::Instant::now();
tracing::info!("Nightly precompute for {}", date);
// 1. Compute twilight
let (dusk, dawn) = astro_twilight(date, LAT, LON)?;
// 2. Moon state
let midnight = dusk + (dawn - dusk) / 2;
let jd = julian_date(midnight);
let (moon_ra, moon_dec) = moon_position(jd);
let moon_illum = moon_illumination(jd);
let moon_age = moon_age_days(jd);
let moon_phase = moon_phase_name(moon_illum, moon_age);
let moon_alt = moon_altitude(jd, LAT, LON);
let (moon_rise, moon_set) = moon_rise_set(dusk, dawn, LAT, LON);
let true_dark = true_dark_window(dusk, dawn, LAT, LON);
let (true_dark_start, true_dark_end, true_dark_min) = match true_dark {
Some((s, e)) => (Some(s), Some(e), Some((e - s).num_minutes() as i32)),
None => (None, None, Some(0)),
};
// 3. Upsert tonight table
let now_ts = Utc::now().timestamp();
sqlx::query(
r#"INSERT OR REPLACE INTO tonight
(id, date, astro_dusk_utc, astro_dawn_utc,
moon_rise_utc, moon_set_utc, moon_illumination, moon_phase_name,
moon_ra_deg, moon_dec_deg,
true_dark_start_utc, true_dark_end_utc, true_dark_minutes, computed_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
)
.bind(date.to_string())
.bind(dusk.to_rfc3339())
.bind(dawn.to_rfc3339())
.bind(moon_rise.map(|t| t.to_rfc3339()))
.bind(moon_set.map(|t| t.to_rfc3339()))
.bind(moon_illum)
.bind(&moon_phase)
.bind(moon_ra)
.bind(moon_dec)
.bind(true_dark_start.map(|t| t.to_rfc3339()))
.bind(true_dark_end.map(|t| t.to_rfc3339()))
.bind(true_dark_min)
.bind(now_ts)
.execute(pool)
.await?;
// 4. Load horizon
let horizon: Vec<HorizonPoint> = sqlx::query_as(
"SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg",
)
.fetch_all(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 };
// 5. Load all catalog objects
let objects: Vec<CatalogObj> = sqlx::query_as::<_, (String, f64, f64, String)>(
"SELECT id, ra_deg, dec_deg, obj_type FROM catalog",
)
.fetch_all(pool)
.await?
.into_iter()
.map(|(id, ra, dec, obj_type)| CatalogObj { id, ra_deg: ra, dec_deg: dec, obj_type })
.collect();
let n_objects = objects.len();
// 6. Compute visibility for each object and upsert nightly_cache
let date_str = date.to_string();
let mut tx = pool.begin().await?;
for obj in &objects {
let vis = compute_visibility(obj.ra_deg, obj.dec_deg, &window, &horizon, &moon_state);
let rec_filter = top_filter(
&obj.obj_type,
moon_illum * 100.0,
moon_alt,
vis.moon_sep_deg,
);
let vis_json = serde_json::to_string(&vis.curve).unwrap_or_default();
sqlx::query(
r#"INSERT OR REPLACE INTO nightly_cache
(catalog_id, night_date, max_alt_deg, transit_utc, rise_utc, set_utc,
best_start_utc, best_end_utc, usable_min, meridian_flip_utc,
airmass_at_transit, extinction_mag, moon_sep_deg, recommended_filter, visibility_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
)
.bind(&obj.id)
.bind(&date_str)
.bind(vis.max_alt_deg)
.bind(vis.transit_utc.map(|t| t.to_rfc3339()))
.bind(vis.rise_utc.map(|t| t.to_rfc3339()))
.bind(vis.set_utc.map(|t| t.to_rfc3339()))
.bind(vis.best_start_utc.map(|t| t.to_rfc3339()))
.bind(vis.best_end_utc.map(|t| t.to_rfc3339()))
.bind(vis.usable_min as i32)
.bind(vis.meridian_flip_utc.map(|t| t.to_rfc3339()))
.bind(vis.airmass_at_transit)
.bind(vis.extinction_at_transit)
.bind(vis.moon_sep_deg)
.bind(&rec_filter)
.bind(&vis_json)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
tracing::info!(
"Nightly precompute complete: {} objects processed in {:.1}s",
n_objects,
start.elapsed().as_secs_f32()
);
Ok(())
}
/// Lightweight precompute: only max_alt, transit, usable_min, recommended_filter.
/// Skips full visibility curve for performance.
async fn precompute_lightweight(pool: &SqlitePool, date: NaiveDate) -> anyhow::Result<()> {
// Check if already computed
let existing: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM nightly_cache WHERE night_date = ?",
)
.bind(date.to_string())
.fetch_one(pool)
.await?;
if existing > 0 {
return Ok(());
}
let (dusk, dawn) = astro_twilight(date, LAT, LON)?;
let midnight = dusk + (dawn - dusk) / 2;
let jd = julian_date(midnight);
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(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 objects: Vec<CatalogObj> = sqlx::query_as::<_, (String, f64, f64, String)>(
"SELECT id, ra_deg, dec_deg, obj_type FROM catalog",
)
.fetch_all(pool)
.await?
.into_iter()
.map(|(id, ra, dec, ot)| CatalogObj { id, ra_deg: ra, dec_deg: dec, obj_type: ot })
.collect();
let date_str = date.to_string();
let mut tx = pool.begin().await?;
for obj in &objects {
let vis = compute_visibility(obj.ra_deg, obj.dec_deg, &window, &horizon, &moon_state);
let rec_filter = top_filter(&obj.obj_type, moon_illum * 100.0, moon_alt, vis.moon_sep_deg);
sqlx::query(
r#"INSERT OR IGNORE INTO nightly_cache
(catalog_id, night_date, max_alt_deg, transit_utc, usable_min, recommended_filter)
VALUES (?, ?, ?, ?, ?, ?)"#,
)
.bind(&obj.id)
.bind(&date_str)
.bind(vis.max_alt_deg)
.bind(vis.transit_utc.map(|t: DateTime<Utc>| t.to_rfc3339()))
.bind(vis.usable_min as i32)
.bind(&rec_filter)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
+29
View File
@@ -0,0 +1,29 @@
use sqlx::SqlitePool;
use tokio::time::{sleep, Duration};
use crate::weather::poll_weather;
pub fn start_weather_scheduler(pool: SqlitePool) {
// 3-hour weather poll
let pool_3h = pool.clone();
tokio::spawn(async move {
loop {
if let Err(e) = poll_weather(&pool_3h).await {
tracing::error!("Weather poll (3h) failed: {}", e);
}
sleep(Duration::from_secs(3 * 3600)).await;
}
});
// 15-minute dew point poll (open-meteo only)
tokio::spawn(async move {
loop {
sleep(Duration::from_secs(15 * 60)).await;
if let Err(e) = crate::weather::openmeteo::fetch_openmeteo().await {
tracing::warn!("Dew point poll failed: {}", e);
} else {
tracing::debug!("Dew point poll OK");
}
}
});
}
+47
View File
@@ -0,0 +1,47 @@
mod api;
mod astronomy;
mod catalog;
mod config;
mod db;
mod filters;
mod jobs;
mod phd2;
mod weather;
use tower_http::cors::{Any, CorsLayer};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "astronome=info,tower_http=info".into()),
)
.init();
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:///data/astronome.db".to_string());
tracing::info!("Connecting to database: {}", database_url);
let pool = db::init_db(&database_url).await?;
// Start background jobs
jobs::start_all_jobs(pool.clone());
// Build router
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = api::build_router(pool).layer(cors);
let bind_addr = "0.0.0.0:3301";
tracing::info!("Starting server on {}", bind_addr);
let listener = tokio::net::TcpListener::bind(bind_addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
+306
View File
@@ -0,0 +1,306 @@
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::config::PLATE_SCALE_ARCSEC;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Phd2Analysis {
pub session_date: String, // Extracted from log
pub duration_min: u32,
pub total_frames: u32,
pub rms_ra_arcsec: f64,
pub rms_dec_arcsec: f64,
pub rms_total_arcsec: f64,
pub peak_error_arcsec: f64,
pub star_lost_count: u32,
pub mean_snr: f64,
pub drift_ra_arcsec_per_min: f64,
pub drift_dec_arcsec_per_min: f64,
// Equipment details extracted from header
#[serde(skip_serializing_if = "Option::is_none")]
pub equipment_profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub camera_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exposure_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mount_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pixel_scale_arcsec: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hfd_px: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guide_star_snr_at_start: Option<f64>,
}
pub fn parse_phd2_log(content: &str) -> anyhow::Result<Phd2Analysis> {
// Extract session date from log header
// Look for patterns like "Log enabled at 2026-03-17 19:33:09" or "Guiding Begins at 2026-03-17 20:04:53"
let session_date = extract_session_date(content)
.unwrap_or_else(|| chrono::Utc::now().naive_utc().date().to_string());
// Extract header information before data section
let equipment_profile = extract_header_value(content, "Equipment Profile = ");
let camera_name = extract_camera_name(content);
let exposure_ms = extract_header_value(content, "Exposure = ")
.and_then(|s| s.trim_end_matches(" ms").parse::<u32>().ok());
let mount_name = extract_header_value(content, "Mount = ")
.map(|s| s.split(',').next().unwrap_or(&s).to_string());
let pixel_scale_arcsec = extract_header_value(content, "Pixel scale = ")
.and_then(|s| s.trim_end_matches(" arc-sec/px").parse::<f64>().ok());
let hfd_px = extract_header_value(content, "HFD = ")
.and_then(|s| s.trim_end_matches(" px").parse::<f64>().ok());
// PHD2 logs have a header block followed by CSV data
// Find the line starting with "Frame,Time,..."
let header_line = content
.lines()
.enumerate()
.find(|(_, line)| line.starts_with("Frame,Time,"))
.map(|(i, _)| i)
.context("PHD2 log: could not find data header line")?;
let data_lines: Vec<&str> = content
.lines()
.skip(header_line)
.collect();
if data_lines.is_empty() {
anyhow::bail!("PHD2 log: no data lines found");
}
// Parse header to find column indices
let headers: Vec<&str> = data_lines[0].split(',').collect();
let col = |name: &str| -> anyhow::Result<usize> {
headers.iter().position(|h| h.trim() == name)
.with_context(|| format!("PHD2 log: missing column '{}'", name))
};
let col_frame = col("Frame")?;
let col_time = col("Time")?;
let col_ra_raw = headers.iter().position(|h| h.trim() == "RARawDistance")
.or_else(|| headers.iter().position(|h| h.trim() == "RAGuideDistance"))
.context("PHD2 log: missing RA distance column")?;
let col_dec_raw = headers.iter().position(|h| h.trim() == "DECRawDistance")
.or_else(|| headers.iter().position(|h| h.trim() == "DECGuideDistance"))
.context("PHD2 log: missing Dec distance column")?;
let col_snr = headers.iter().position(|h| h.trim() == "SNR");
let col_err = headers.iter().position(|h| h.trim() == "ErrorCode");
let mut ra_vals: Vec<f64> = Vec::new();
let mut dec_vals: Vec<f64> = Vec::new();
let mut snr_vals: Vec<f64> = Vec::new();
let mut star_lost = 0u32;
let mut first_time: Option<f64> = None;
let mut last_time: Option<f64> = None;
let mut guide_star_snr_at_start: Option<f64> = None;
for line in data_lines.iter().skip(1) {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let fields: Vec<&str> = line.split(',').collect();
// Check error code
if let Some(ec) = col_err {
if let Some(err_str) = fields.get(ec) {
if let Ok(err_code) = err_str.trim().parse::<i32>() {
if err_code != 0 {
star_lost += 1;
continue;
}
}
}
}
let _frame: u32 = fields.get(col_frame)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let time: f64 = fields.get(col_time)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0.0);
if first_time.is_none() {
first_time = Some(time);
// Capture SNR from first frame for reference
if let Some(sc) = col_snr {
if let Some(snr) = fields.get(sc).and_then(|s| s.trim().parse::<f64>().ok()) {
guide_star_snr_at_start = Some(snr);
}
}
}
last_time = Some(time);
let ra: f64 = fields.get(col_ra_raw)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0.0);
let dec: f64 = fields.get(col_dec_raw)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0.0);
// Convert pixels to arcsec if values look like pixels (> 10.0)
let ra_arcsec = if ra.abs() < 30.0 && ra.abs() > 0.001 {
ra * PLATE_SCALE_ARCSEC
} else {
ra
};
let dec_arcsec = if dec.abs() < 30.0 && dec.abs() > 0.001 {
dec * PLATE_SCALE_ARCSEC
} else {
dec
};
ra_vals.push(ra_arcsec);
dec_vals.push(dec_arcsec);
if let Some(sc) = col_snr {
if let Some(snr) = fields.get(sc).and_then(|s| s.trim().parse::<f64>().ok()) {
snr_vals.push(snr);
}
}
}
let n = ra_vals.len() as f64;
if n == 0.0 {
anyhow::bail!("PHD2 log: no valid data frames found");
}
let rms_ra = (ra_vals.iter().map(|v| v * v).sum::<f64>() / n).sqrt();
let rms_dec = (dec_vals.iter().map(|v| v * v).sum::<f64>() / n).sqrt();
let rms_total = (rms_ra * rms_ra + rms_dec * rms_dec).sqrt();
let peak_ra = ra_vals.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
let peak_dec = dec_vals.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
let peak_error = peak_ra.max(peak_dec);
let mean_snr = if snr_vals.is_empty() {
0.0
} else {
snr_vals.iter().sum::<f64>() / snr_vals.len() as f64
};
let duration_sec = match (first_time, last_time) {
(Some(f), Some(l)) => (l - f).max(0.0),
_ => 0.0,
};
let duration_min = (duration_sec / 60.0) as u32;
// Simple linear drift: last half minus first half average
let drift_ra = if n > 4.0 {
let half = (n as usize) / 2;
let first_half_mean = ra_vals[..half].iter().sum::<f64>() / half as f64;
let second_half_mean = ra_vals[half..].iter().sum::<f64>() / (n as usize - half) as f64;
if duration_min > 0 {
(second_half_mean - first_half_mean) / (duration_min as f64 / 2.0)
} else {
0.0
}
} else {
0.0
};
let drift_dec = if n > 4.0 {
let half = (n as usize) / 2;
let first_half_mean = dec_vals[..half].iter().sum::<f64>() / half as f64;
let second_half_mean = dec_vals[half..].iter().sum::<f64>() / (n as usize - half) as f64;
if duration_min > 0 {
(second_half_mean - first_half_mean) / (duration_min as f64 / 2.0)
} else {
0.0
}
} else {
0.0
};
Ok(Phd2Analysis {
session_date,
duration_min,
total_frames: n as u32,
rms_ra_arcsec: rms_ra,
rms_dec_arcsec: rms_dec,
rms_total_arcsec: rms_total,
peak_error_arcsec: peak_error,
star_lost_count: star_lost,
mean_snr,
drift_ra_arcsec_per_min: drift_ra,
drift_dec_arcsec_per_min: drift_dec,
equipment_profile,
camera_name,
exposure_ms,
mount_name,
pixel_scale_arcsec,
hfd_px,
guide_star_snr_at_start,
})
}
fn extract_header_value(content: &str, key: &str) -> Option<String> {
content
.lines()
.find(|line| line.contains(key))
.and_then(|line| {
let parts: Vec<&str> = line.split(key).collect();
if parts.len() > 1 {
let value = parts[1].trim();
// Handle comma-separated values by taking up to first comma
let end_pos = value.find(',').unwrap_or(value.len());
Some(value[..end_pos].trim().to_string())
} else {
None
}
})
}
fn extract_camera_name(content: &str) -> Option<String> {
// Look for "Camera = XXX, ..." line
content
.lines()
.find(|line| line.trim().starts_with("Camera = "))
.and_then(|line| {
extract_header_value(&format!("{}\n", line), "Camera = ")
})
}
fn extract_session_date(content: &str) -> Option<String> {
// Look for patterns like:
// "Log enabled at 2026-03-17 19:33:09"
// "Guiding Begins at 2026-03-17 20:04:53"
for line in content.lines() {
// Try "Log enabled at" pattern first
if let Some(idx) = line.find("Log enabled at ") {
let date_time = &line[idx + 15..];
return extract_date_from_timestamp(date_time);
}
// Try "Guiding Begins at" pattern
if let Some(idx) = line.find("Guiding Begins at ") {
let date_time = &line[idx + 18..];
return extract_date_from_timestamp(date_time);
}
// Try "Calibration Begins at" pattern as fallback
if let Some(idx) = line.find("Calibration Begins at ") {
let date_time = &line[idx + 21..];
return extract_date_from_timestamp(date_time);
}
}
None
}
fn extract_date_from_timestamp(timestamp: &str) -> Option<String> {
// Extract date part from timestamp like "2026-03-17 19:33:09"
// Just take the first 10 characters which should be YYYY-MM-DD
if timestamp.len() >= 10 {
let date_part = &timestamp[..10];
// Validate it's in YYYY-MM-DD format
if date_part.chars().nth(4) == Some('-')
&& date_part.chars().nth(7) == Some('-')
&& date_part[..4].chars().all(|c| c.is_numeric())
&& date_part[5..7].chars().all(|c| c.is_numeric())
&& date_part[8..10].chars().all(|c| c.is_numeric())
{
return Some(date_part.to_string());
}
}
None
}
+94
View File
@@ -0,0 +1,94 @@
pub mod openmeteo;
pub mod seventimer;
use anyhow::Context;
use sqlx::SqlitePool;
use self::openmeteo::fetch_openmeteo;
use self::seventimer::{fetch_seventimer, go_nogo};
pub async fn poll_weather(pool: &SqlitePool) -> anyhow::Result<()> {
tracing::info!("Polling weather...");
let (seventimer_result, openmeteo_result) = tokio::join!(
fetch_seventimer(),
fetch_openmeteo()
);
let seventimer_json = seventimer_result
.map(|j| serde_json::to_string(&j).unwrap_or_default())
.unwrap_or_default();
let (dew_point, temp, humidity, go_nogo_str) = match openmeteo_result {
Ok(conditions) => {
let cc = 3u8; // default cloudcover if 7timer unavailable
let seeing = 3u8;
let transp = 3u8;
let gn = go_nogo(cc, seeing, transp).as_str().to_string();
(
Some(conditions.dew_point_c),
Some(conditions.temp_c),
Some(conditions.humidity_pct),
Some(gn),
)
}
Err(e) => {
tracing::warn!("Open-Meteo poll failed: {}", e);
(None, None, None, None)
}
};
let openmeteo_json = if temp.is_some() {
Some(serde_json::json!({
"temp_c": temp,
"humidity_pct": humidity,
"dew_point_c": dew_point
})
.to_string())
} else {
None
};
// Compute go/nogo from 7timer if available
let go_nogo_final = if !seventimer_json.is_empty() {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&seventimer_json) {
if let Some(dataseries) = json["dataseries"].as_array() {
if let Some(first) = dataseries.first() {
let cc = first["cloudcover"].as_u64().unwrap_or(5) as u8;
let seeing = first["seeing"].as_u64().unwrap_or(4) as u8;
let transp = first["transparency"].as_u64().unwrap_or(4) as u8;
Some(go_nogo(cc, seeing, transp).as_str().to_string())
} else {
go_nogo_str
}
} else {
go_nogo_str
}
} else {
go_nogo_str
}
} else {
go_nogo_str
};
let now = chrono::Utc::now().timestamp();
sqlx::query(
r#"INSERT OR REPLACE INTO weather_cache
(id, seventimer_json, openmeteo_json, dew_point_c, temp_c, humidity_pct, go_nogo, fetched_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?)"#,
)
.bind(&seventimer_json)
.bind(&openmeteo_json)
.bind(dew_point)
.bind(temp)
.bind(humidity)
.bind(&go_nogo_final)
.bind(now)
.execute(pool)
.await
.context("failed to upsert weather_cache")?;
tracing::info!("Weather poll complete. Go/Nogo: {:?}", go_nogo_final);
Ok(())
}
+56
View File
@@ -0,0 +1,56 @@
use anyhow::Context;
use serde::{Deserialize, Serialize};
const OPENMETEO_URL: &str =
"https://api.open-meteo.com/v1/forecast?latitude=43.8167&longitude=4.1167\
&current=temperature_2m,relative_humidity_2m,dew_point_2m&wind_speed_unit=ms";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrentConditions {
pub temp_c: f64,
pub humidity_pct: f64,
pub dew_point_c: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DewAlert {
Warning,
Critical,
}
pub fn dew_alert(temp_c: f64, dew_point_c: f64) -> Option<DewAlert> {
let margin = temp_c - dew_point_c;
if margin < 2.0 {
Some(DewAlert::Critical)
} else if margin < 4.0 {
Some(DewAlert::Warning)
} else {
None
}
}
pub async fn fetch_openmeteo() -> anyhow::Result<CurrentConditions> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
let resp = client
.get(OPENMETEO_URL)
.send()
.await
.context("Open-Meteo request failed")?;
let json: serde_json::Value = resp.json().await.context("Open-Meteo JSON parse failed")?;
let current = &json["current"];
let temp = current["temperature_2m"].as_f64().unwrap_or(0.0);
let humidity = current["relative_humidity_2m"].as_f64().unwrap_or(0.0);
let dew = current["dew_point_2m"].as_f64().unwrap_or(temp - 10.0);
Ok(CurrentConditions {
temp_c: temp,
humidity_pct: humidity,
dew_point_c: dew,
})
}
+48
View File
@@ -0,0 +1,48 @@
use anyhow::Context;
use serde::{Deserialize, Serialize};
const SEVENTIMER_URL: &str =
"http://www.7timer.info/bin/api.pl?lon=4.1167&lat=43.8167&product=astro&output=json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GoNogo {
Go,
Marginal,
Nogo,
}
impl GoNogo {
pub fn as_str(&self) -> &'static str {
match self {
GoNogo::Go => "go",
GoNogo::Marginal => "marginal",
GoNogo::Nogo => "nogo",
}
}
}
pub fn go_nogo(cloudcover: u8, seeing: u8, transparency: u8) -> GoNogo {
if cloudcover <= 2 && seeing <= 3 && transparency <= 3 {
GoNogo::Go
} else if cloudcover <= 4 && seeing <= 5 {
GoNogo::Marginal
} else {
GoNogo::Nogo
}
}
pub async fn fetch_seventimer() -> anyhow::Result<serde_json::Value> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
let resp = client
.get(SEVENTIMER_URL)
.send()
.await
.context("7timer request failed")?;
let json = resp.json::<serde_json::Value>().await.context("7timer JSON parse failed")?;
Ok(json)
}