2025-12-01 14:32:45 +08:00

434 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package server
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"weatherstation/core/internal/data"
)
import "github.com/gin-gonic/gin"
func handleHealth(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "ts": time.Now().UTC().Format(time.RFC3339)})
}
func handleSystemStatus(c *gin.Context) {
online := data.OnlineDevices()
c.JSON(http.StatusOK, gin.H{
"online_devices": online,
"server_time": time.Now().Format("2006-01-02 15:04:05"),
})
}
func handleStations(c *gin.Context) {
stations, err := data.Stations()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("query stations failed: %v", err)})
return
}
for i := range stations {
if len(stations[i].StationID) > 6 {
hexID := stations[i].StationID[len(stations[i].StationID)-6:]
if decimalID, err := strconv.ParseInt(hexID, 16, 64); err == nil {
stations[i].DecimalID = strconv.FormatInt(decimalID, 10)
}
}
}
c.JSON(http.StatusOK, stations)
}
func handleData(c *gin.Context) {
idParam := c.Query("hex_id")
startTime := c.Query("start_time")
endTime := c.Query("end_time")
interval := c.DefaultQuery("interval", "1hour")
if idParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing hex_id"})
return
}
upper := strings.ToUpper(idParam)
var b strings.Builder
for i := 0; i < len(upper); i++ {
ch := upper[i]
if (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'F') {
b.WriteByte(ch)
}
}
hex := b.String()
if hex == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hex_id"})
return
}
if len(hex) < 6 {
hex = strings.Repeat("0", 6-len(hex)) + hex
} else if len(hex) > 6 {
hex = hex[len(hex)-6:]
}
stationID := fmt.Sprintf("RS485-%s", hex)
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
start, err := time.ParseInLocation("2006-01-02 15:04:05", startTime, loc)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid start_time"})
return
}
end, err := time.ParseInLocation("2006-01-02 15:04:05", endTime, loc)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid end_time"})
return
}
var points interface{}
if interval == "raw" {
points, err = data.SeriesRaw(stationID, start, end)
} else {
points, err = data.SeriesFrom10Min(stationID, start, end, interval)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("query failed: %v", err)})
return
}
c.JSON(http.StatusOK, points)
}
func handleForecast(c *gin.Context) {
stationID := c.Query("station_id")
from := c.Query("from")
to := c.Query("to")
provider := c.Query("provider")
versionsStr := c.DefaultQuery("versions", "1")
versions, _ := strconv.Atoi(versionsStr)
if versions <= 0 {
versions = 1
}
if stationID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing station_id"})
return
}
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
var start, end time.Time
var err error
if from == "" || to == "" {
now := time.Now().In(loc)
start = now.Truncate(time.Hour).Add(1 * time.Hour)
end = start.Add(3 * time.Hour)
} else {
start, err = time.ParseInLocation("2006-01-02 15:04:05", from, loc)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid from"})
return
}
end, err = time.ParseInLocation("2006-01-02 15:04:05", to, loc)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid to"})
return
}
}
points, err := data.Forecast(stationID, start, end, provider, versions)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("query forecast failed: %v", err)})
return
}
c.JSON(http.StatusOK, points)
}
// handleForecastPerf 过去N天大雨预报表现
// GET /api/forecast/perf?station_id=RS485-XXXXXX&days=30&provider=imdroid_mix
func handleForecastPerf(c *gin.Context) {
stationID := c.Query("station_id")
if stationID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing station_id"})
return
}
daysStr := c.DefaultQuery("days", "30")
provider := c.Query("provider")
days, _ := strconv.Atoi(daysStr)
if days <= 0 || days > 365 {
days = 30
}
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
now := time.Now().In(loc)
since := now.AddDate(0, 0, -days)
perf, err := data.HeavyRainPerf(stationID, since, provider)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("query perf failed: %v", err)})
return
}
c.JSON(http.StatusOK, perf)
}
// handleTSPage renders a very simple HTML page to query TS metrics by station and compare providers (+1/+2/+3/mixed).
// Route: GET /TS?station_id=RS485-XXXXXX&start=YYYY-MM-DD+HH:MM:SS&end=YYYY-MM-DD+HH:MM:SS&threshold=0.1
func handleTSPage(c *gin.Context) {
stationID := c.Query("station_id")
startStr := c.Query("start")
endStr := c.Query("end")
thrStr := c.DefaultQuery("threshold", "0.1")
providerSel := c.DefaultQuery("provider", "all")
viewSel := c.DefaultQuery("view", "all")
detailSel := c.DefaultQuery("detail", "none")
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
now := time.Now().In(loc)
// defaults: last 30 days
start := now.AddDate(0, 0, -30).Truncate(time.Hour)
end := now.Truncate(time.Hour)
if strings.TrimSpace(startStr) != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", startStr, loc); err == nil {
start = t
}
}
if strings.TrimSpace(endStr) != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", endStr, loc); err == nil {
end = t
}
}
if !end.After(start) {
end = start.Add(24 * time.Hour)
}
thr, _ := strconv.ParseFloat(thrStr, 64)
// Providers to compare or single
allProviders := []string{"imdroid_V6", "imdroid_V5", "imdroid_mix", "caiyun", "open-meteo", "imdroid"}
var providers []string
if providerSel == "all" || strings.TrimSpace(providerSel) == "" {
providers = allProviders
} else {
providers = []string{providerSel}
}
// Build HTML
var b strings.Builder
b.WriteString("<html><head><meta charset=\"utf-8\"></head><body>")
b.WriteString("<h2>TS</h2>")
b.WriteString("<form method='GET' action='/TS'>")
b.WriteString("站点ID: <input type='text' name='station_id' value='" + htmlEscape(stationID) + "' style='width:180px'> ")
b.WriteString("开始: <input type='text' name='start' value='" + start.Format("2006-01-02 15:04:05") + "' style='width:180px'> ")
b.WriteString("结束: <input type='text' name='end' value='" + end.Format("2006-01-02 15:04:05") + "' style='width:180px'> ")
// 阈值固定为默认值,不在页面提供输入框
// provider select
b.WriteString("预报源: <select name='provider'>")
opts := append([]string{"all"}, allProviders...)
for _, p := range opts {
sel := ""
if p == providerSel {
sel = " selected"
}
b.WriteString("<option value='" + htmlEscape(p) + "'" + sel + ">" + htmlEscape(p) + "</option>")
}
b.WriteString("</select> ")
// forecast point(select fixed lead or all)
b.WriteString("预报点: <select name='view'>")
viewOpts := []struct {
v string
n string
}{{"all", "所有(+1/+2/+3)"}, {"lead1", "+1h"}, {"lead2", "+2h"}, {"lead3", "+3h"}}
for _, it := range viewOpts {
sel := ""
if it.v == viewSel {
sel = " selected"
}
b.WriteString("<option value='" + it.v + "'" + sel + ">" + it.n + "</option>")
}
b.WriteString("</select> ")
// detail select
b.WriteString("详细: <select name='detail'>")
detOpts := []struct {
v string
n string
}{{"none", "不显示"}, {"brief", "简略"}, {"detailed", "详细"}}
for _, it := range detOpts {
sel := ""
if it.v == detailSel {
sel = " selected"
}
b.WriteString("<option value='" + it.v + "'" + sel + ">" + it.n + "</option>")
}
b.WriteString("</select> ")
b.WriteString("<button type='submit'>搜索</button></form>")
// Helper: common station IDs for quick input
b.WriteString("<div style='margin:8px 0;'>站点:</div>")
b.WriteString("<table border='1' cellpadding='6' cellspacing='0'>")
b.WriteString("<tr><th>station_id</th><th>location</th></tr>")
b.WriteString("<tr><td>RS485-002A39</td><td>兴山县二号气象站(湖北省宜昌市兴山县昭君镇黄家堑东北约244米)</td></tr>")
b.WriteString("<tr><td>RS485-002A30</td><td>第八台气象站(宾阳县松塘村西南约161米)</td></tr>")
b.WriteString("<tr><td>RS485-0029C3</td><td>第七台气象站(宾阳县细塘村东南约112米)</td></tr>")
b.WriteString("<tr><td>RS485-002964</td><td>兴山县一号气象站(湖北省宜昌市兴山县昭君镇贺家院子东北约484米)</td></tr>")
b.WriteString("<tr><td>RS485-002A69</td><td>第一台气象站(宾阳县陈平镇大平村西南约325米)</td></tr>")
b.WriteString("</table><hr/>")
if strings.TrimSpace(stationID) == "" {
b.WriteString("</body></html>")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(b.String()))
return
}
// header + legend
b.WriteString("<div>时间窗: " + start.Format("2006-01-02 15:04:05") + " ~ " + end.Format("2006-01-02 15:04:05") + "</div>")
b.WriteString("<div style='margin:6px 0;color:#444;'>说明: n=样本总数; n11=命中(预报雨且实况雨); n01=漏报(预报不雨但实况雨); n10=空报(预报雨但实况不雨); n00=正确未报(预报与实况均不雨)。CSI=临界成功指数; POD=命中率; FAR=误报率。</div>")
b.WriteString("<table border='1' cellpadding='6' cellspacing='0'>")
b.WriteString("<tr><th>Provider</th><th>预报点</th><th>n</th><th>n11</th><th>n01</th><th>n10</th><th>n00</th><th>CSI</th><th>POD</th><th>FAR</th></tr>")
addRow := func(pv, view string, s data.BinaryScores) {
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%s</td><td>%d</td><td>%d</td><td>%d</td><td>%d</td><td>%d</td><td>%.3f</td><td>%.3f</td><td>%.3f</td></tr>",
htmlEscape(pv), htmlEscape(view), s.N, s.N11, s.N01, s.N10, s.N00, s.CSI, s.POD, s.FAR))
}
var renderScores func(pv, view string)
renderScores = func(pv, view string) {
switch view {
case "lead1":
if s, err := data.ForecastBinaryScoresLead(stationID, start, end, pv, thr, 1); err == nil {
addRow(pv, "+1h", s)
} else {
addRow(pv, "+1h", data.BinaryScores{})
}
case "lead2":
if s, err := data.ForecastBinaryScoresLead(stationID, start, end, pv, thr, 2); err == nil {
addRow(pv, "+2h", s)
} else {
addRow(pv, "+2h", data.BinaryScores{})
}
case "lead3":
if s, err := data.ForecastBinaryScoresLead(stationID, start, end, pv, thr, 3); err == nil {
addRow(pv, "+3h", s)
} else {
addRow(pv, "+3h", data.BinaryScores{})
}
case "all":
renderScores(pv, "lead1")
renderScores(pv, "lead2")
renderScores(pv, "lead3")
}
}
for _, pv := range providers {
renderScores(pv, viewSel)
}
b.WriteString("</table>")
// detail view only when a single provider and single view selected
if providerSel != "all" && viewSel != "all" && (detailSel == "brief" || detailSel == "detailed") {
var rows []data.BinaryRow
var err error
switch viewSel {
case "lead1":
rows, err = data.ForecastBinarySeriesLead(stationID, start, end, providerSel, 1)
case "lead2":
rows, err = data.ForecastBinarySeriesLead(stationID, start, end, providerSel, 2)
case "lead3":
rows, err = data.ForecastBinarySeriesLead(stationID, start, end, providerSel, 3)
}
if err == nil {
b.WriteString("<h3>明细 (" + htmlEscape(providerSel) + ", " + htmlEscape(viewSel) + ")</h3>")
b.WriteString("<table border='1' cellpadding='6' cellspacing='0'>")
b.WriteString("<tr><th>编号</th><th>时间</th><th>实况(mm)</th><th>预报(mm)</th><th>发生?</th><th>命中?</th></tr>")
var n, n11, n01, n10, n00 int64
idx := 0
for _, r := range rows {
actualEvt := r.ActualMM > thr
predEvt := r.PredMM > thr
if detailSel == "brief" && !(actualEvt || predEvt) {
continue
}
idx++
n++
if predEvt && actualEvt {
n11++
} else if !predEvt && actualEvt {
n01++
} else if predEvt && !actualEvt {
n10++
} else {
n00++
}
hit := "否"
if predEvt && actualEvt {
hit = "是"
}
b.WriteString(fmt.Sprintf("<tr><td>%d</td><td>%s</td><td>%.3f</td><td>%.3f</td><td>%s</td><td>%s</td></tr>", idx, r.T.Format("2006-01-02 15:04:05"), r.ActualMM, r.PredMM, boolCN(actualEvt), hit))
}
var csi, pod, far float64
h, m, f := float64(n11), float64(n01), float64(n10)
if (h + m + f) > 0 {
csi = h / (h + m + f)
}
if (h + m) > 0 {
pod = h / (h + m)
}
if (h + f) > 0 {
far = f / (h + f)
}
b.WriteString("</table>")
b.WriteString(fmt.Sprintf("<div>统计: n=%d n11=%d n01=%d n10=%d n00=%d | CSI=%.3f POD=%.3f FAR=%.3f</div>", n, n11, n01, n10, n00, csi, pod, far))
}
}
b.WriteString("</body></html>")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(b.String()))
}
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&#39;")
return r.Replace(s)
}
func boolCN(b bool) string {
if b {
return "是"
}
return "否"
}
// handleForecastScores 过去N天的二分类评分TS/CSI, POD, FAR
// GET /api/forecast/scores?station_id=RS485-XXXXXX&days=30&provider=imdroid_mix&threshold=0
func handleForecastScores(c *gin.Context) {
stationID := c.Query("station_id")
if stationID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing station_id"})
return
}
daysStr := c.DefaultQuery("days", "30")
provider := c.Query("provider")
thrStr := c.DefaultQuery("threshold", "0")
days, _ := strconv.Atoi(daysStr)
if days <= 0 || days > 365 {
days = 30
}
thresholdMM, _ := strconv.ParseFloat(thrStr, 64)
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
now := time.Now().In(loc)
since := now.AddDate(0, 0, -days)
scores, err := data.ForecastBinaryScores(stationID, since, now, provider, thresholdMM)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("query scores failed: %v", err)})
return
}
c.JSON(http.StatusOK, scores)
}