From da75f4a93e0aed64fad30c3508f4a7d02994b3bf Mon Sep 17 00:00:00 2001 From: yarnom Date: Mon, 1 Dec 2025 14:32:45 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=94=AF=E6=8C=81=20TS=20?= =?UTF-8?q?=E8=AF=84=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/cmd/v6-export/main.go | 18 +-- core/cmd/v6-model/main.go | 32 ++-- core/internal/server/handlers.go | 256 +++++++++++++++++++++++++++++++ core/internal/server/router.go | 4 + 4 files changed, 285 insertions(+), 25 deletions(-) diff --git a/core/cmd/v6-export/main.go b/core/cmd/v6-export/main.go index f933014..6dcd08d 100644 --- a/core/cmd/v6-export/main.go +++ b/core/cmd/v6-export/main.go @@ -23,8 +23,7 @@ import ( // - 仅生成 SQL 与日志,不写库 const ( - baseProvider = "imdroid_mix" - outProvider = "imdroid_V6" + outProvider = "imdroid_V6" ) type v6Out struct { @@ -33,13 +32,14 @@ type v6Out struct { } func main() { - var stationsCSV, startStr, endStr, sqlOut, logOut, tzName string + var stationsCSV, startStr, endStr, sqlOut, logOut, tzName, baseProvider string flag.StringVar(&stationsCSV, "stations", "", "逗号分隔的 station_id 列表,例如: RS485-000001,RS485-000002") flag.StringVar(&startStr, "start", "", "开始时间,格式: 2006-01-02 15:00 或 2006-01-02(按整点对齐)") flag.StringVar(&endStr, "end", "", "结束时间,格式: 2006-01-02 15:00 或 2006-01-02(不包含该时刻)") flag.StringVar(&sqlOut, "sql", "v6_output.sql", "输出 SQL 文件路径") flag.StringVar(&logOut, "log", "v6_output.log", "输出日志文件路径") flag.StringVar(&tzName, "tz", "Asia/Shanghai", "时区,例如 Asia/Shanghai") + flag.StringVar(&baseProvider, "base", "imdroid_mix", "基础预报源 provider(例如: imdroid_mix, caiyun, open-meteo)") flag.Parse() if stationsCSV == "" || startStr == "" || endStr == "" { @@ -102,7 +102,7 @@ func main() { v6AtTime := make(map[time.Time]float64) for t := start; t.Before(end); t = t.Add(time.Hour) { - res := computeV6AtHour(ctx, st, t, v6AtTime, logger) + res := computeV6AtHour(ctx, st, t, baseProvider, v6AtTime, logger) if res.skipped { logger.Printf("skip station=%s issued=%s: %s", st, t.Format("2006-01-02 15:04"), res.reason) continue @@ -137,7 +137,7 @@ type v6Result struct { reason string } -func computeV6AtHour(ctx context.Context, stationID string, issued time.Time, v6AtTime map[time.Time]float64, logger *log.Logger) v6Result { +func computeV6AtHour(ctx context.Context, stationID string, issued time.Time, baseProvider string, v6AtTime map[time.Time]float64, logger *log.Logger) v6Result { var res v6Result // 读取基线:当期小时桶内 mix 最新 issued 的 +1/+2/+3 @@ -165,9 +165,9 @@ func computeV6AtHour(ctx context.Context, stationID string, issued time.Time, v6 // 前一预测(优先 V6 缓存,否则退回 mix 历史) // +1:需要 (t-1) 发布、验证时刻 t 的预测值 - vPrev1, src1, ok1 := prevForValidation(ctx, stationID, issued, 1, v6AtTime) - vPrev2, src2, ok2 := prevForValidation(ctx, stationID, issued, 2, v6AtTime) - vPrev3, src3, ok3 := prevForValidation(ctx, stationID, issued, 3, v6AtTime) + vPrev1, src1, ok1 := prevForValidation(ctx, stationID, issued, 1, baseProvider, v6AtTime) + vPrev2, src2, ok2 := prevForValidation(ctx, stationID, issued, 2, baseProvider, v6AtTime) + vPrev3, src3, ok3 := prevForValidation(ctx, stationID, issued, 3, baseProvider, v6AtTime) if !(ok1 && ok2 && ok3) { // 若冷启动,允许个别 lead 不可用时跳过;也可以只输出可用的 lead,这里采取全量可用才输出 res.skipped, res.reason = true, fmt.Sprintf("prev missing leads: h1=%v h2=%v h3=%v", ok1, ok2, ok3) @@ -211,7 +211,7 @@ func computeV6AtHour(ctx context.Context, stationID string, issued time.Time, v6 } // prevForValidation 返回用于“验证时刻=issued+0h”的上一预测:优先使用 V6 的缓存;如无则退回 mix 的历史。 -func prevForValidation(ctx context.Context, stationID string, issued time.Time, lead int, v6AtTime map[time.Time]float64) (float64, string, bool) { +func prevForValidation(ctx context.Context, stationID string, issued time.Time, lead int, baseProvider string, v6AtTime map[time.Time]float64) (float64, string, bool) { // 需要的验证时刻 vt := issued // 验证在 t // 先看 V6 缓存:我们在前面会把 V6 的结果按 forecast_time 存入 map diff --git a/core/cmd/v6-model/main.go b/core/cmd/v6-model/main.go index 5f311f1..087d7e4 100644 --- a/core/cmd/v6-model/main.go +++ b/core/cmd/v6-model/main.go @@ -12,17 +12,17 @@ import ( ) const ( - v6BaseProvider = "imdroid_mix" - v6OutProvider = "imdroid_V6" + v6OutProvider = "imdroid_V6" ) func main() { - var stationsCSV, issuedStr, startStr, endStr, tzName string + var stationsCSV, issuedStr, startStr, endStr, tzName, baseProvider string flag.StringVar(&stationsCSV, "stations", "", "逗号分隔的 station_id 列表;为空则自动扫描有基线的站点") flag.StringVar(&issuedStr, "issued", "", "指定 issued 时间(整点),格式: 2006-01-02 15:00;为空用当前整点") flag.StringVar(&startStr, "start", "", "开始时间(整点),格式: 2006-01-02 15:00;与 --end 一起使用,end 为开区间") flag.StringVar(&endStr, "end", "", "结束时间(整点),格式: 2006-01-02 15:00;与 --start 一起使用,end 为开区间") flag.StringVar(&tzName, "tz", "Asia/Shanghai", "时区,例如 Asia/Shanghai") + flag.StringVar(&baseProvider, "base", "imdroid_mix", "基础预报源 provider(例如: imdroid_mix, caiyun, open-meteo)") flag.Parse() ctx := context.Background() @@ -61,7 +61,7 @@ func main() { stations = splitStations(stationsCSV) } else { var err error - stations, err = listStationsWithBase(ctx, v6BaseProvider, t) + stations, err = listStationsWithBase(ctx, baseProvider, t) if err != nil { log.Fatalf("list stations failed: %v", err) } @@ -71,7 +71,7 @@ func main() { continue } for _, st := range stations { - if err := runV6ForStation(ctx, st, t); err != nil { + if err := runV6ForStation(ctx, st, t, baseProvider); err != nil { log.Printf("V6 station=%s issued=%s error: %v", st, t.Format("2006-01-02 15:04:05"), err) } } @@ -94,7 +94,7 @@ func main() { stations = splitStations(stationsCSV) } else { var err error - stations, err = listStationsWithBase(ctx, v6BaseProvider, issued) + stations, err = listStationsWithBase(ctx, baseProvider, issued) if err != nil { log.Fatalf("list stations failed: %v", err) } @@ -104,7 +104,7 @@ func main() { return } for _, st := range stations { - if err := runV6ForStation(ctx, st, issued); err != nil { + if err := runV6ForStation(ctx, st, issued, baseProvider); err != nil { log.Printf("V6 station=%s error: %v", st, err) } } @@ -140,13 +140,13 @@ func listStationsWithBase(ctx context.Context, provider string, issued time.Time return out, nil } -func runV6ForStation(ctx context.Context, stationID string, issued time.Time) error { +func runV6ForStation(ctx context.Context, stationID string, issued time.Time, baseProvider string) error { // 基线:当期小时桶内 mix 最新 issued - baseIssued, ok, err := data.ResolveIssuedAtInBucket(ctx, stationID, v6BaseProvider, issued) + baseIssued, ok, err := data.ResolveIssuedAtInBucket(ctx, stationID, baseProvider, issued) if err != nil || !ok { return fmt.Errorf("base issued missing: %v ok=%v", err, ok) } - pts, err := data.ForecastRainAtIssued(ctx, stationID, v6BaseProvider, baseIssued) + pts, err := data.ForecastRainAtIssued(ctx, stationID, baseProvider, baseIssued) if err != nil || len(pts) < 3 { return fmt.Errorf("base points insufficient: %v len=%d", err, len(pts)) } @@ -160,9 +160,9 @@ func runV6ForStation(ctx context.Context, stationID string, issued time.Time) er } // 残差:优先 V6 历史,否则回退 mix 历史 - vPrev1, ok1 := prevV6OrMix(ctx, stationID, issued, 1) - vPrev2, ok2 := prevV6OrMix(ctx, stationID, issued, 2) - vPrev3, ok3 := prevV6OrMix(ctx, stationID, issued, 3) + vPrev1, ok1 := prevV6OrMix(ctx, stationID, issued, 1, baseProvider) + vPrev2, ok2 := prevV6OrMix(ctx, stationID, issued, 2, baseProvider) + vPrev3, ok3 := prevV6OrMix(ctx, stationID, issued, 3, baseProvider) if !(ok1 && ok2 && ok3) { return fmt.Errorf("prev missing leads: h1=%v h2=%v h3=%v", ok1, ok2, ok3) } @@ -203,7 +203,7 @@ func runV6ForStation(ctx context.Context, stationID string, issued time.Time) er return nil } -func prevV6OrMix(ctx context.Context, stationID string, issued time.Time, lead int) (float64, bool) { +func prevV6OrMix(ctx context.Context, stationID string, issued time.Time, lead int, baseProvider string) (float64, bool) { // 验证时刻 vt := issued // 先找 V6 历史:在 (t-lead) 桶内找 v6 的 issued,取 +lead @ vt @@ -227,8 +227,8 @@ func prevV6OrMix(ctx context.Context, stationID string, issued time.Time, lead i } } // 退回 mix 历史 - if iss, ok, err := data.ResolveIssuedAtInBucket(ctx, stationID, v6BaseProvider, issued.Add(-time.Duration(lead)*time.Hour)); err == nil && ok { - if pts, err := data.ForecastRainAtIssued(ctx, stationID, v6BaseProvider, iss); err == nil && len(pts) >= lead { + if iss, ok, err := data.ResolveIssuedAtInBucket(ctx, stationID, baseProvider, issued.Add(-time.Duration(lead)*time.Hour)); err == nil && ok { + if pts, err := data.ForecastRainAtIssued(ctx, stationID, baseProvider, iss); err == nil && len(pts) >= lead { if v := pickRain(pts, vt); v >= 0 { return v, true } diff --git a/core/internal/server/handlers.go b/core/internal/server/handlers.go index c5567fe..c5ee8c6 100644 --- a/core/internal/server/handlers.go +++ b/core/internal/server/handlers.go @@ -175,3 +175,259 @@ func handleForecastPerf(c *gin.Context) { } 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("
station_idlocation
RS485-002A39兴山县二号气象站(湖北省宜昌市兴山县昭君镇黄家堑东北约244米)
RS485-002A30第八台气象站(宾阳县松塘村西南约161米)
RS485-0029C3第七台气象站(宾阳县细塘村东南约112米)
RS485-002964兴山县一号气象站(湖北省宜昌市兴山县昭君镇贺家院子东北约484米)
RS485-002A69第一台气象站(宾阳县陈平镇大平村西南约325米)

") + + 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=命中(预报雨且实况雨); 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) +} diff --git a/core/internal/server/router.go b/core/internal/server/router.go index ea5a49a..e621e3a 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -49,6 +49,7 @@ func NewRouter(opts Options) *gin.Engine { api.GET("/data", handleData) api.GET("/forecast", handleForecast) api.GET("/forecast/perf", handleForecastPerf) + api.GET("/forecast/scores", handleForecastScores) api.GET("/radar/times", handleRadarTimes) api.GET("/radar/tiles_at", handleRadarTilesAt) api.GET("/radar/weather_nearest", handleRadarWeatherNearest) @@ -56,6 +57,9 @@ func NewRouter(opts Options) *gin.Engine { api.GET("/rain/tiles_at", handleRainTilesAt) } + // Simple TS page (no CSS), at /TS + r.GET("/TS", handleTSPage) + hasUI := strings.TrimSpace(opts.UIServeDir) != "" if hasUI { // Serve built Angular assets under /ui for static files