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("
")
b.WriteString("TS
")
b.WriteString("")
// Helper: common station IDs for quick input
b.WriteString("站点:
")
b.WriteString("")
b.WriteString("| station_id | location |
")
b.WriteString("| RS485-002A44 | 第二台气象站(伶俐镇那车1号大桥西北约324米) |
")
b.WriteString("| RS485-0029CD | 第四台气象站(宾阳县宾州镇塘山2号大桥西北约293米) |
")
b.WriteString("| RS485-0029CA | 第五台气象站(宾阳县陈平镇和平3号大桥东北约197米) |
")
b.WriteString("| RS485-0029C3 | 第七台气象站(宾阳县细塘村东南约112米) |
")
b.WriteString("| RS485-002A30 | 第八台气象站(宾阳县松塘村西南约161米) |
")
b.WriteString("| RS485-002964 | 兴山县一号气象站(湖北省宜昌市兴山县昭君镇贺家院子东北约484米) |
")
b.WriteString("| RS485-002A39 | 兴山县二号气象站(湖北省宜昌市兴山县昭君镇黄家堑东北约244米) |
")
b.WriteString("| RS485-002A69 | 第一台气象站(宾阳县陈平镇大平村西南约325米)(V6使用彩云为基础数据) |
")
b.WriteString("| RS485-002A7B | 第三台气象站(宾阳县细坡西南约263米)(V6使用彩云为基础数据) |
")
b.WriteString("
")
if strings.TrimSpace(stationID) == "" {
b.WriteString("")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(b.String()))
return
}
// header + legend
b.WriteString("时间窗: " + start.Format("2006-01-02 15:04:05") + " ~ " + end.Format("2006-01-02 15:04:05") + "
")
b.WriteString("说明:n 为样本数;n11 为预报降雨且实况降雨(Hit);n01 为预报不降雨但实况降雨(漏报);n10 为预报降雨但实况不降雨(误报);n00 为预报与实况均不降雨。CSI 为临界成功指数,POD 为命中率,FAR 为误报率。
")
b.WriteString("")
b.WriteString("| Provider | 预报点 | n | n11 | n01 | n10 | n00 | CSI | POD | FAR |
")
addRow := func(pv, view string, s data.BinaryScores) {
b.WriteString(fmt.Sprintf("| %s | %s | %d | %d | %d | %d | %d | %.3f | %.3f | %.3f |
",
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("
")
// 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("明细 (" + htmlEscape(providerSel) + ", " + htmlEscape(viewSel) + ")
")
b.WriteString("")
b.WriteString("| 编号 | 时间 | 实况(mm) | 预报(mm) | 发生? | 命中? |
")
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("| %d | %s | %.3f | %.3f | %s | %s |
", 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("
")
b.WriteString(fmt.Sprintf("统计: n=%d n11=%d n01=%d n10=%d n00=%d | CSI=%.3f POD=%.3f FAR=%.3f
", n, n11, n01, n10, n00, csi, pod, far))
}
}
b.WriteString("