Initial Commit

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