2025-12-01 18:20:19 +08:00

438 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-002A44</td><td>第二台气象站(伶俐镇那车1号大桥西北约324米)</td></tr>")
b.WriteString("<tr><td>RS485-0029CD</td><td>第四台气象站(宾阳县宾州镇塘山2号大桥西北约293米)</td></tr>")
b.WriteString("<tr><td>RS485-0029CA</td><td>第五台气象站(宾阳县陈平镇和平3号大桥东北约197米)</td></tr>")
b.WriteString("<tr><td>RS485-0029C3</td><td>第七台气象站(宾阳县细塘村东南约112米)</td></tr>")
b.WriteString("<tr><td>RS485-002A30</td><td>第八台气象站(宾阳县松塘村西南约161米)</td></tr>")
b.WriteString("<tr><td>RS485-002964</td><td>兴山县一号气象站(湖北省宜昌市兴山县昭君镇贺家院子东北约484米)</td></tr>")
b.WriteString("<tr><td>RS485-002A39</td><td>兴山县二号气象站(湖北省宜昌市兴山县昭君镇黄家堑东北约244米)</td></tr>")
b.WriteString("<tr><td>RS485-002A69</td><td>第一台气象站(宾阳县陈平镇大平村西南约325米)(V6使用彩云为基础数据)</td></tr>")
b.WriteString("<tr><td>RS485-002A7B</td><td>第三台气象站(宾阳县细坡西南约263米)(V6使用彩云为基础数据)</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 为预报降雨且实况降雨Hitn01 为预报不降雨但实况降雨漏报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)
}