feat:支持 TS 评分
This commit is contained in:
parent
376a36df22
commit
da75f4a93e
@ -23,8 +23,7 @@ import (
|
|||||||
// - 仅生成 SQL 与日志,不写库
|
// - 仅生成 SQL 与日志,不写库
|
||||||
|
|
||||||
const (
|
const (
|
||||||
baseProvider = "imdroid_mix"
|
outProvider = "imdroid_V6"
|
||||||
outProvider = "imdroid_V6"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type v6Out struct {
|
type v6Out struct {
|
||||||
@ -33,13 +32,14 @@ type v6Out struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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(&stationsCSV, "stations", "", "逗号分隔的 station_id 列表,例如: RS485-000001,RS485-000002")
|
||||||
flag.StringVar(&startStr, "start", "", "开始时间,格式: 2006-01-02 15:00 或 2006-01-02(按整点对齐)")
|
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(&endStr, "end", "", "结束时间,格式: 2006-01-02 15:00 或 2006-01-02(不包含该时刻)")
|
||||||
flag.StringVar(&sqlOut, "sql", "v6_output.sql", "输出 SQL 文件路径")
|
flag.StringVar(&sqlOut, "sql", "v6_output.sql", "输出 SQL 文件路径")
|
||||||
flag.StringVar(&logOut, "log", "v6_output.log", "输出日志文件路径")
|
flag.StringVar(&logOut, "log", "v6_output.log", "输出日志文件路径")
|
||||||
flag.StringVar(&tzName, "tz", "Asia/Shanghai", "时区,例如 Asia/Shanghai")
|
flag.StringVar(&tzName, "tz", "Asia/Shanghai", "时区,例如 Asia/Shanghai")
|
||||||
|
flag.StringVar(&baseProvider, "base", "imdroid_mix", "基础预报源 provider(例如: imdroid_mix, caiyun, open-meteo)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if stationsCSV == "" || startStr == "" || endStr == "" {
|
if stationsCSV == "" || startStr == "" || endStr == "" {
|
||||||
@ -102,7 +102,7 @@ func main() {
|
|||||||
v6AtTime := make(map[time.Time]float64)
|
v6AtTime := make(map[time.Time]float64)
|
||||||
|
|
||||||
for t := start; t.Before(end); t = t.Add(time.Hour) {
|
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 {
|
if res.skipped {
|
||||||
logger.Printf("skip station=%s issued=%s: %s", st, t.Format("2006-01-02 15:04"), res.reason)
|
logger.Printf("skip station=%s issued=%s: %s", st, t.Format("2006-01-02 15:04"), res.reason)
|
||||||
continue
|
continue
|
||||||
@ -137,7 +137,7 @@ type v6Result struct {
|
|||||||
reason string
|
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
|
var res v6Result
|
||||||
|
|
||||||
// 读取基线:当期小时桶内 mix 最新 issued 的 +1/+2/+3
|
// 读取基线:当期小时桶内 mix 最新 issued 的 +1/+2/+3
|
||||||
@ -165,9 +165,9 @@ func computeV6AtHour(ctx context.Context, stationID string, issued time.Time, v6
|
|||||||
|
|
||||||
// 前一预测(优先 V6 缓存,否则退回 mix 历史)
|
// 前一预测(优先 V6 缓存,否则退回 mix 历史)
|
||||||
// +1:需要 (t-1) 发布、验证时刻 t 的预测值
|
// +1:需要 (t-1) 发布、验证时刻 t 的预测值
|
||||||
vPrev1, src1, ok1 := prevForValidation(ctx, stationID, issued, 1, v6AtTime)
|
vPrev1, src1, ok1 := prevForValidation(ctx, stationID, issued, 1, baseProvider, v6AtTime)
|
||||||
vPrev2, src2, ok2 := prevForValidation(ctx, stationID, issued, 2, v6AtTime)
|
vPrev2, src2, ok2 := prevForValidation(ctx, stationID, issued, 2, baseProvider, v6AtTime)
|
||||||
vPrev3, src3, ok3 := prevForValidation(ctx, stationID, issued, 3, v6AtTime)
|
vPrev3, src3, ok3 := prevForValidation(ctx, stationID, issued, 3, baseProvider, v6AtTime)
|
||||||
if !(ok1 && ok2 && ok3) {
|
if !(ok1 && ok2 && ok3) {
|
||||||
// 若冷启动,允许个别 lead 不可用时跳过;也可以只输出可用的 lead,这里采取全量可用才输出
|
// 若冷启动,允许个别 lead 不可用时跳过;也可以只输出可用的 lead,这里采取全量可用才输出
|
||||||
res.skipped, res.reason = true, fmt.Sprintf("prev missing leads: h1=%v h2=%v h3=%v", ok1, ok2, ok3)
|
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 的历史。
|
// 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
|
vt := issued // 验证在 t
|
||||||
// 先看 V6 缓存:我们在前面会把 V6 的结果按 forecast_time 存入 map
|
// 先看 V6 缓存:我们在前面会把 V6 的结果按 forecast_time 存入 map
|
||||||
|
|||||||
@ -12,17 +12,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
v6BaseProvider = "imdroid_mix"
|
v6OutProvider = "imdroid_V6"
|
||||||
v6OutProvider = "imdroid_V6"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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(&stationsCSV, "stations", "", "逗号分隔的 station_id 列表;为空则自动扫描有基线的站点")
|
||||||
flag.StringVar(&issuedStr, "issued", "", "指定 issued 时间(整点),格式: 2006-01-02 15:00;为空用当前整点")
|
flag.StringVar(&issuedStr, "issued", "", "指定 issued 时间(整点),格式: 2006-01-02 15:00;为空用当前整点")
|
||||||
flag.StringVar(&startStr, "start", "", "开始时间(整点),格式: 2006-01-02 15:00;与 --end 一起使用,end 为开区间")
|
flag.StringVar(&startStr, "start", "", "开始时间(整点),格式: 2006-01-02 15:00;与 --end 一起使用,end 为开区间")
|
||||||
flag.StringVar(&endStr, "end", "", "结束时间(整点),格式: 2006-01-02 15:00;与 --start 一起使用,end 为开区间")
|
flag.StringVar(&endStr, "end", "", "结束时间(整点),格式: 2006-01-02 15:00;与 --start 一起使用,end 为开区间")
|
||||||
flag.StringVar(&tzName, "tz", "Asia/Shanghai", "时区,例如 Asia/Shanghai")
|
flag.StringVar(&tzName, "tz", "Asia/Shanghai", "时区,例如 Asia/Shanghai")
|
||||||
|
flag.StringVar(&baseProvider, "base", "imdroid_mix", "基础预报源 provider(例如: imdroid_mix, caiyun, open-meteo)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@ -61,7 +61,7 @@ func main() {
|
|||||||
stations = splitStations(stationsCSV)
|
stations = splitStations(stationsCSV)
|
||||||
} else {
|
} else {
|
||||||
var err error
|
var err error
|
||||||
stations, err = listStationsWithBase(ctx, v6BaseProvider, t)
|
stations, err = listStationsWithBase(ctx, baseProvider, t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("list stations failed: %v", err)
|
log.Fatalf("list stations failed: %v", err)
|
||||||
}
|
}
|
||||||
@ -71,7 +71,7 @@ func main() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, st := range stations {
|
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)
|
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)
|
stations = splitStations(stationsCSV)
|
||||||
} else {
|
} else {
|
||||||
var err error
|
var err error
|
||||||
stations, err = listStationsWithBase(ctx, v6BaseProvider, issued)
|
stations, err = listStationsWithBase(ctx, baseProvider, issued)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("list stations failed: %v", err)
|
log.Fatalf("list stations failed: %v", err)
|
||||||
}
|
}
|
||||||
@ -104,7 +104,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, st := range stations {
|
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)
|
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
|
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
|
// 基线:当期小时桶内 mix 最新 issued
|
||||||
baseIssued, ok, err := data.ResolveIssuedAtInBucket(ctx, stationID, v6BaseProvider, issued)
|
baseIssued, ok, err := data.ResolveIssuedAtInBucket(ctx, stationID, baseProvider, issued)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return fmt.Errorf("base issued missing: %v ok=%v", err, 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 {
|
if err != nil || len(pts) < 3 {
|
||||||
return fmt.Errorf("base points insufficient: %v len=%d", err, len(pts))
|
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 历史
|
// 残差:优先 V6 历史,否则回退 mix 历史
|
||||||
vPrev1, ok1 := prevV6OrMix(ctx, stationID, issued, 1)
|
vPrev1, ok1 := prevV6OrMix(ctx, stationID, issued, 1, baseProvider)
|
||||||
vPrev2, ok2 := prevV6OrMix(ctx, stationID, issued, 2)
|
vPrev2, ok2 := prevV6OrMix(ctx, stationID, issued, 2, baseProvider)
|
||||||
vPrev3, ok3 := prevV6OrMix(ctx, stationID, issued, 3)
|
vPrev3, ok3 := prevV6OrMix(ctx, stationID, issued, 3, baseProvider)
|
||||||
if !(ok1 && ok2 && ok3) {
|
if !(ok1 && ok2 && ok3) {
|
||||||
return fmt.Errorf("prev missing leads: h1=%v h2=%v h3=%v", 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
|
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
|
vt := issued
|
||||||
// 先找 V6 历史:在 (t-lead) 桶内找 v6 的 issued,取 +lead @ vt
|
// 先找 V6 历史:在 (t-lead) 桶内找 v6 的 issued,取 +lead @ vt
|
||||||
@ -227,8 +227,8 @@ func prevV6OrMix(ctx context.Context, stationID string, issued time.Time, lead i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 退回 mix 历史
|
// 退回 mix 历史
|
||||||
if iss, ok, err := data.ResolveIssuedAtInBucket(ctx, stationID, v6BaseProvider, issued.Add(-time.Duration(lead)*time.Hour)); err == nil && ok {
|
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, v6BaseProvider, iss); err == nil && len(pts) >= lead {
|
if pts, err := data.ForecastRainAtIssued(ctx, stationID, baseProvider, iss); err == nil && len(pts) >= lead {
|
||||||
if v := pickRain(pts, vt); v >= 0 {
|
if v := pickRain(pts, vt); v >= 0 {
|
||||||
return v, true
|
return v, true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -175,3 +175,259 @@ func handleForecastPerf(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, perf)
|
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-002A39</td><td>兴山县二号气象站(湖北省宜昌市兴山县昭君镇黄家堑东北约244米)</td></tr>")
|
||||||
|
b.WriteString("<tr><td>RS485-002A30</td><td>第八台气象站(宾阳县松塘村西南约161米)</td></tr>")
|
||||||
|
b.WriteString("<tr><td>RS485-0029C3</td><td>第七台气象站(宾阳县细塘村东南约112米)</td></tr>")
|
||||||
|
b.WriteString("<tr><td>RS485-002964</td><td>兴山县一号气象站(湖北省宜昌市兴山县昭君镇贺家院子东北约484米)</td></tr>")
|
||||||
|
b.WriteString("<tr><td>RS485-002A69</td><td>第一台气象站(宾阳县陈平镇大平村西南约325米)</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)
|
||||||
|
}
|
||||||
|
|||||||
@ -49,6 +49,7 @@ func NewRouter(opts Options) *gin.Engine {
|
|||||||
api.GET("/data", handleData)
|
api.GET("/data", handleData)
|
||||||
api.GET("/forecast", handleForecast)
|
api.GET("/forecast", handleForecast)
|
||||||
api.GET("/forecast/perf", handleForecastPerf)
|
api.GET("/forecast/perf", handleForecastPerf)
|
||||||
|
api.GET("/forecast/scores", handleForecastScores)
|
||||||
api.GET("/radar/times", handleRadarTimes)
|
api.GET("/radar/times", handleRadarTimes)
|
||||||
api.GET("/radar/tiles_at", handleRadarTilesAt)
|
api.GET("/radar/tiles_at", handleRadarTilesAt)
|
||||||
api.GET("/radar/weather_nearest", handleRadarWeatherNearest)
|
api.GET("/radar/weather_nearest", handleRadarWeatherNearest)
|
||||||
@ -56,6 +57,9 @@ func NewRouter(opts Options) *gin.Engine {
|
|||||||
api.GET("/rain/tiles_at", handleRainTilesAt)
|
api.GET("/rain/tiles_at", handleRainTilesAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple TS page (no CSS), at /TS
|
||||||
|
r.GET("/TS", handleTSPage)
|
||||||
|
|
||||||
hasUI := strings.TrimSpace(opts.UIServeDir) != ""
|
hasUI := strings.TrimSpace(opts.UIServeDir) != ""
|
||||||
if hasUI {
|
if hasUI {
|
||||||
// Serve built Angular assets under /ui for static files
|
// Serve built Angular assets under /ui for static files
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user