438 lines
14 KiB
Go
438 lines
14 KiB
Go
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 为预报降雨且实况降雨;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("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
|
||
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)
|
||
}
|