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("
") b.WriteString("站点ID: ") b.WriteString("开始: ") b.WriteString("结束: ") // 阈值固定为默认值,不在页面提供输入框 // provider select b.WriteString("预报源: ") // forecast point(select fixed lead or all) b.WriteString("预报点: ") // detail select b.WriteString("详细: ") b.WriteString("
") // Helper: common station IDs for quick input b.WriteString("
站点:
") b.WriteString("") b.WriteString("") b.WriteString("") b.WriteString("") b.WriteString("") b.WriteString("") b.WriteString("") b.WriteString("") b.WriteString("") b.WriteString("") b.WriteString("") b.WriteString("
station_idlocation
RS485-002A44第二台气象站(伶俐镇那车1号大桥西北约324米)
RS485-0029CD第四台气象站(宾阳县宾州镇塘山2号大桥西北约293米)
RS485-0029CA第五台气象站(宾阳县陈平镇和平3号大桥东北约197米)
RS485-0029C3第七台气象站(宾阳县细塘村东南约112米)
RS485-002A30第八台气象站(宾阳县松塘村西南约161米)
RS485-002964兴山县一号气象站(湖北省宜昌市兴山县昭君镇贺家院子东北约484米)
RS485-002A39兴山县二号气象站(湖北省宜昌市兴山县昭君镇黄家堑东北约244米)
RS485-002A69第一台气象站(宾阳县陈平镇大平村西南约325米)(V6使用彩云为基础数据)
RS485-002A7B第三台气象站(宾阳县细坡西南约263米)(V6使用彩云为基础数据)

") 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("") addRow := func(pv, view string, s data.BinaryScores) { b.WriteString(fmt.Sprintf("", 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("
Provider预报点nn11n01n10n00CSIPODFAR
%s%s%d%d%d%d%d%.3f%.3f%.3f
") // 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("") 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("", 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("
编号时间实况(mm)预报(mm)发生?命中?
%d%s%.3f%.3f%s%s
") 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("") 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) }