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("")
+ // Helper: common station IDs for quick input
+ b.WriteString("站点:
")
+ b.WriteString("")
+ b.WriteString("| station_id | location |
")
+ b.WriteString("| RS485-002A39 | 兴山县二号气象站(湖北省宜昌市兴山县昭君镇黄家堑东北约244米) |
")
+ b.WriteString("| RS485-002A30 | 第八台气象站(宾阳县松塘村西南约161米) |
")
+ b.WriteString("| RS485-0029C3 | 第七台气象站(宾阳县细塘村东南约112米) |
")
+ b.WriteString("| RS485-002964 | 兴山县一号气象站(湖北省宜昌市兴山县昭君镇贺家院子东北约484米) |
")
+ b.WriteString("| RS485-002A69 | 第一台气象站(宾阳县陈平镇大平村西南约325米) |
")
+ 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=命中(预报雨且实况雨); 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("