152 lines
6.5 KiB
Rust
152 lines
6.5 KiB
Rust
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))
|
|
}
|