package main import ( "context" "flag" "fmt" "log" "strings" "time" "weatherstation/core/internal/data" ) const ( v6BaseProvider = "imdroid_mix" v6OutProvider = "imdroid_V6" ) func main() { var stationsCSV, issuedStr, startStr, endStr, tzName 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.Parse() ctx := context.Background() loc, _ := time.LoadLocation(tzName) if loc == nil { loc = time.FixedZone("CST", 8*3600) } parse := func(s string) (time.Time, error) { var t time.Time var err error for _, ly := range []string{"2006-01-02 15:04", "2006-01-02 15", "2006-01-02"} { t, err = time.ParseInLocation(ly, s, loc) if err == nil { return t.Truncate(time.Hour), nil } } return time.Time{}, err } if strings.TrimSpace(startStr) != "" && strings.TrimSpace(endStr) != "" { start, err := parse(startStr) if err != nil { log.Fatalf("无法解析 start: %v", err) } end, err := parse(endStr) if err != nil { log.Fatalf("无法解析 end: %v", err) } if !end.After(start) { log.Fatalf("end 必须大于 start") } for t := start; t.Before(end); t = t.Add(time.Hour) { var stations []string if strings.TrimSpace(stationsCSV) != "" { stations = splitStations(stationsCSV) } else { var err error stations, err = listStationsWithBase(ctx, v6BaseProvider, t) if err != nil { log.Fatalf("list stations failed: %v", err) } } if len(stations) == 0 { log.Printf("no stations to process for issued=%s", t.Format("2006-01-02 15:04:05")) continue } for _, st := range stations { if err := runV6ForStation(ctx, st, t); err != nil { log.Printf("V6 station=%s issued=%s error: %v", st, t.Format("2006-01-02 15:04:05"), err) } } } return } var issued time.Time if strings.TrimSpace(issuedStr) != "" { var err error issued, err = parse(issuedStr) if err != nil || issued.IsZero() { log.Fatalf("无法解析 issued: %v", err) } } else { issued = time.Now().In(loc).Truncate(time.Hour) } var stations []string if strings.TrimSpace(stationsCSV) != "" { stations = splitStations(stationsCSV) } else { var err error stations, err = listStationsWithBase(ctx, v6BaseProvider, issued) if err != nil { log.Fatalf("list stations failed: %v", err) } } if len(stations) == 0 { log.Printf("no stations to process for issued=%s", issued.Format("2006-01-02 15:04:05")) return } for _, st := range stations { if err := runV6ForStation(ctx, st, issued); err != nil { log.Printf("V6 station=%s error: %v", st, err) } } } func splitStations(s string) []string { parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } // listStationsWithBase 与 v5 共用逻辑,通过 forecast_hourly 检索该 issued 桶内有基线的站点 func listStationsWithBase(ctx context.Context, provider string, issued time.Time) ([]string, error) { const q = `SELECT DISTINCT station_id FROM forecast_hourly WHERE provider=$1 AND issued_at >= $2 AND issued_at < $2 + interval '1 hour'` rows, err := data.DB().QueryContext(ctx, q, provider, issued) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var id string if err := rows.Scan(&id); err == nil { out = append(out, id) } } return out, nil } func runV6ForStation(ctx context.Context, stationID string, issued time.Time) error { // 基线:当期小时桶内 mix 最新 issued baseIssued, ok, err := data.ResolveIssuedAtInBucket(ctx, stationID, v6BaseProvider, issued) if err != nil || !ok { return fmt.Errorf("base issued missing: %v ok=%v", err, ok) } pts, err := data.ForecastRainAtIssued(ctx, stationID, v6BaseProvider, baseIssued) if err != nil || len(pts) < 3 { return fmt.Errorf("base points insufficient: %v len=%d", err, len(pts)) } ft1, ft2, ft3 := issued.Add(time.Hour), issued.Add(2*time.Hour), issued.Add(3*time.Hour) base1, base2, base3 := pickRain(pts, ft1), pickRain(pts, ft2), pickRain(pts, ft3) // 实况 actual, okA, err := data.FetchActualHourlyRain(ctx, stationID, issued.Add(-time.Hour), issued) if err != nil || !okA { return fmt.Errorf("actual missing: %v ok=%v", err, okA) } // 残差:优先 V6 历史,否则回退 mix 历史 vPrev1, ok1 := prevV6OrMix(ctx, stationID, issued, 1) vPrev2, ok2 := prevV6OrMix(ctx, stationID, issued, 2) vPrev3, ok3 := prevV6OrMix(ctx, stationID, issued, 3) if !(ok1 && ok2 && ok3) { return fmt.Errorf("prev missing leads: h1=%v h2=%v h3=%v", ok1, ok2, ok3) } e1 := actual - vPrev1 e2 := actual - vPrev2 e3 := actual - vPrev3 cand1 := base1 + 1.0*e1 cand2 := base2 + 0.5*e2 cand3 := base3 + (1.0/3.0)*e3 var out1, out2, out3 float64 if cand1 < 0 { out1 = base1 } else { out1 = cand1 } if cand2 < 0 { out2 = base2 } else { out2 = cand2 } if cand3 < 0 { out3 = base3 } else { out3 = cand3 } items := []data.UpsertRainItem{ {ForecastTime: ft1, RainMMx1000: toX1000(out1)}, {ForecastTime: ft2, RainMMx1000: toX1000(out2)}, {ForecastTime: ft3, RainMMx1000: toX1000(out3)}, } if err := data.UpsertForecastRain(ctx, stationID, v6OutProvider, issued, items); err != nil { return err } log.Printf("V6 %s issued=%s base=[%.3f,%.3f,%.3f] actual=%.3f prev=[%.3f,%.3f,%.3f] out=[%.3f,%.3f,%.3f]", stationID, issued.Format("2006-01-02 15:04:05"), base1, base2, base3, actual, vPrev1, vPrev2, vPrev3, out1, out2, out3) return nil } func prevV6OrMix(ctx context.Context, stationID string, issued time.Time, lead int) (float64, bool) { // 验证时刻 vt := issued // 先找 V6 历史:在 (t-lead) 桶内找 v6 的 issued,取 +lead @ vt if iss, ok, err := data.ResolveIssuedAtInBucket(ctx, stationID, v6OutProvider, issued.Add(-time.Duration(lead)*time.Hour)); err == nil && ok { if pts, err := data.ForecastRainAtIssued(ctx, stationID, v6OutProvider, iss); err == nil && len(pts) >= lead { if v := pickRain(pts, vt); v >= 0 { return v, true } switch lead { case 1: return toMM(pts[0].RainMMx1000), true case 2: if len(pts) >= 2 { return toMM(pts[1].RainMMx1000), true } case 3: if len(pts) >= 3 { return toMM(pts[2].RainMMx1000), true } } } } // 退回 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 v := pickRain(pts, vt); v >= 0 { return v, true } switch lead { case 1: return toMM(pts[0].RainMMx1000), true case 2: if len(pts) >= 2 { return toMM(pts[1].RainMMx1000), true } case 3: if len(pts) >= 3 { return toMM(pts[2].RainMMx1000), true } } } } return 0, false } func pickRain(points []data.PredictPoint, ft time.Time) float64 { for _, p := range points { if p.ForecastTime.Equal(ft) { return toMM(p.RainMMx1000) } } return -1 } func toMM(vx1000 int32) float64 { return float64(vx1000) / 1000.0 } func toX1000(mm float64) int32 { return int32(mm*1000 + 0.5) }