diff --git a/config.yaml b/config.yaml index ac02389..00e8db6 100644 --- a/config.yaml +++ b/config.yaml @@ -12,6 +12,23 @@ database: forecast: caiyun_token: "ZAcZq49qzibr10F0" +radar: + realtime_enabled: true + realtime_interval_minutes: 60 + aliases: + - alias: "海珠雷达站" + lat: 23.09 + lon: 113.35 + z: 7 + y: 40 + x: 104 + - alias: "番禺雷达站" + lat: 23.0225 + lon: 113.3313 + z: 7 + y: 40 + x: 104 + mysql: host: "127.0.0.1" port: 3306 diff --git a/internal/config/config.go b/internal/config/config.go index d2c801a..7a67669 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,26 @@ type ForecastConfig struct { CaiyunToken string `yaml:"caiyun_token"` } +// RadarConfig 雷达相关配置 +type RadarConfig struct { + // RealtimeIntervalMinutes 彩云实况拉取周期(分钟)。允许值:10、30、60。默认 10。 + RealtimeIntervalMinutes int `yaml:"realtime_interval_minutes"` + // RealtimeEnabled 是否启用彩云实况定时任务。默认 false(不下载)。 + RealtimeEnabled bool `yaml:"realtime_enabled"` + // Aliases 配置化的雷达别名列表(可用于前端选择与实况拉取)。 + Aliases []RadarAlias `yaml:"aliases"` +} + +// RadarAlias 配置中的雷达别名条目 +type RadarAlias struct { + Alias string `yaml:"alias"` + Lat float64 `yaml:"lat"` + Lon float64 `yaml:"lon"` + Z int `yaml:"z"` + Y int `yaml:"y"` + X int `yaml:"x"` +} + // MySQLConfig MySQL 连接配置(用于 rtk_data) type MySQLConfig struct { Host string `yaml:"host"` @@ -42,6 +62,7 @@ type Config struct { Server ServerConfig `yaml:"server"` Database DatabaseConfig `yaml:"database"` Forecast ForecastConfig `yaml:"forecast"` + Radar RadarConfig `yaml:"radar"` MySQL MySQLConfig `yaml:"mysql"` } @@ -103,6 +124,12 @@ func (c *Config) validate() error { if c.MySQL.Port <= 0 { c.MySQL.Port = 3306 } + // Radar 默认拉取周期 + if c.Radar.RealtimeIntervalMinutes != 10 && c.Radar.RealtimeIntervalMinutes != 30 && c.Radar.RealtimeIntervalMinutes != 60 { + c.Radar.RealtimeIntervalMinutes = 10 + } + // 默认关闭实时抓取(可按需开启) + // 若用户已有旧配置未设置该字段,默认为 false // CaiyunToken 允许为空:表示不启用彩云定时任务 return nil } diff --git a/internal/radar/scheduler.go b/internal/radar/scheduler.go index 908db57..f3be7af 100644 --- a/internal/radar/scheduler.go +++ b/internal/radar/scheduler.go @@ -85,20 +85,32 @@ func Start(ctx context.Context, opts Options) error { }() // 瓦片:每3分钟查询一次 go loop3(ctx, loc, opts) - // 实况:每10分钟一次 - go loop10(ctx, loc, opts) - log.Printf("[radar] scheduler started (tiles=3m, realtime=10m, dir=%s, tile=%d/%d/%d)", opts.OutputDir, opts.Z, opts.Y, opts.X) + // 实况:按配置开关运行(默认关闭) + rtEnabled := config.GetConfig().Radar.RealtimeEnabled + rtMin := config.GetConfig().Radar.RealtimeIntervalMinutes + if rtEnabled { + if rtMin != 10 && rtMin != 30 && rtMin != 60 { + rtMin = 10 + } + go loopRealtime(ctx, loc, opts, time.Duration(rtMin)*time.Minute) + } + if rtEnabled { + log.Printf("[radar] scheduler started (tiles=3m, realtime=%dm, dir=%s, tile=%d/%d/%d)", rtMin, opts.OutputDir, opts.Z, opts.Y, opts.X) + } else { + log.Printf("[radar] scheduler started (tiles=3m, realtime=disabled, dir=%s, tile=%d/%d/%d)", opts.OutputDir, opts.Z, opts.Y, opts.X) + } return nil } -func loop10(ctx context.Context, loc *time.Location, opts Options) { +// loopRealtime 周期性拉取彩云实况,按 interval 对齐边界运行 +func loopRealtime(ctx context.Context, loc *time.Location, opts Options, interval time.Duration) { for { if ctx.Err() != nil { return } now := time.Now().In(loc) - // 对齐到10分钟边界 - runAt := roundDownN(now, 10*time.Minute).Add(10 * time.Minute) + // 对齐到 interval 边界 + runAt := roundDownN(now, interval).Add(interval) sleep := time.Until(runAt) if sleep < 0 { sleep = 0 @@ -285,11 +297,13 @@ var reDigits17 = regexp.MustCompile(`([0-9]{17})`) // runOnceFromNMC fetches NMC JSON, extracts timestamp, shifts +8h, then downloads CMA tile for opts.Z/Y/X. func runOnceFromNMC(ctx context.Context, opts Options) error { - // 保留原语义:两者都执行 if err := runTilesFromNMC(ctx, opts); err != nil { return err } - return runRealtimeFromCaiyun(ctx) + if config.GetConfig().Radar.RealtimeEnabled { + return runRealtimeFromCaiyun(ctx) + } + return nil } // 仅瓦片下载:查询 NMC,解析时间,按该时刻下载 CMA 瓦片(若DB已存在则跳过) @@ -375,23 +389,18 @@ func runTilesFromNMC(ctx context.Context, opts Options) error { // 仅彩云实况(10分钟一次) func runRealtimeFromCaiyun(ctx context.Context) error { - if err := fetchAndStoreRadarRealtimeFor(ctx, "南宁雷达站", 23.097234, 108.715433); err != nil { - log.Printf("[radar] realtime(NN) failed: %v", err) - } - if err := fetchAndStoreRadarRealtimeFor(ctx, "广州雷达站", 23.146400, 113.341200); err != nil { - log.Printf("[radar] realtime(GZ) failed: %v", err) - } - if err := fetchAndStoreRadarRealtimeFor(ctx, "海珠雷达站", 23.090000, 113.350000); err != nil { - log.Printf("[radar] realtime(HAIZHU) failed: %v", err) - } - if err := fetchAndStoreRadarRealtimeFor(ctx, "番禺雷达站", 23.022500, 113.331300); err != nil { - log.Printf("[radar] realtime(PANYU) failed: %v", err) + // 1) 配置中的别名列表 + cfg := config.GetConfig() + for _, a := range cfg.Radar.Aliases { + if err := fetchAndStoreRadarRealtimeFor(ctx, a.Alias, a.Lat, a.Lon); err != nil { + log.Printf("[radar] realtime(alias=%s) failed: %v", a.Alias, err) + } } - // WH65LP 设备批量 + // 2) WH65LP 设备批量 token := os.Getenv("CAIYUN_TOKEN") if token == "" { - token = config.GetConfig().Forecast.CaiyunToken + token = cfg.Forecast.CaiyunToken } if token == "" { log.Printf("[radar] skip station realtime: missing CAIYUN_TOKEN") @@ -537,12 +546,16 @@ func fetchAndStoreRadarRealtimeFor(ctx context.Context, alias string, lat, lon f return fmt.Errorf("realtime api status=%s", payload.Status) } - // Align to 10-minute bucket in Asia/Shanghai + // Align to configured bucket in Asia/Shanghai loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } - dt := roundDownN(time.Now().In(loc), 10*time.Minute) + bucketMin := config.GetConfig().Radar.RealtimeIntervalMinutes + if bucketMin != 10 && bucketMin != 30 && bucketMin != 60 { + bucketMin = 10 + } + dt := roundDownN(time.Now().In(loc), time.Duration(bucketMin)*time.Minute) // Store db := database.GetDB() diff --git a/internal/server/gin.go b/internal/server/gin.go index 15378dd..0c2e01d 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -44,9 +44,12 @@ func StartGinServer() error { api.GET("/forecast", getForecastHandler) api.GET("/radar/latest", latestRadarTileHandler) api.GET("/radar/at", radarTileAtHandler) + api.GET("/radar/nearest", nearestRadarTileHandler) api.GET("/radar/times", radarTileTimesHandler) api.GET("/radar/weather_latest", latestRadarWeatherHandler) api.GET("/radar/weather_at", radarWeatherAtHandler) + api.GET("/radar/weather_aliases", radarWeatherAliasesHandler) + api.GET("/radar/aliases", radarConfigAliasesHandler) } // 获取配置的Web端口 @@ -74,45 +77,45 @@ func indexHandler(c *gin.Context) { // radarNanningHandler 南宁雷达站占位页 func radarNanningHandler(c *gin.Context) { data := types.PageData{ - Title: "南宁雷达站", + Title: "雷达页面", ServerTime: time.Now().Format("2006-01-02 15:04:05"), OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()), TiandituKey: "0c260b8a094a4e0bc507808812cefdac", } - c.HTML(http.StatusOK, "radar_nanning.html", data) + c.HTML(http.StatusOK, "imdroid_radar.html", data) } // radarGuangzhouHandler 广州雷达站占位页 func radarGuangzhouHandler(c *gin.Context) { data := types.PageData{ - Title: "广州雷达站", + Title: "雷达页面", ServerTime: time.Now().Format("2006-01-02 15:04:05"), OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()), TiandituKey: "0c260b8a094a4e0bc507808812cefdac", } - c.HTML(http.StatusOK, "radar_guangzhou.html", data) + c.HTML(http.StatusOK, "imdroid_radar.html", data) } // radarHaizhuHandler 海珠雷达站占位页 func radarHaizhuHandler(c *gin.Context) { data := types.PageData{ - Title: "海珠雷达站", + Title: "雷达页面", ServerTime: time.Now().Format("2006-01-02 15:04:05"), OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()), TiandituKey: "0c260b8a094a4e0bc507808812cefdac", } - c.HTML(http.StatusOK, "radar_haizhu.html", data) + c.HTML(http.StatusOK, "imdroid_radar.html", data) } // radarPanyuHandler 番禺雷达站占位页 func radarPanyuHandler(c *gin.Context) { data := types.PageData{ - Title: "番禺雷达站", + Title: "雷达页面", ServerTime: time.Now().Format("2006-01-02 15:04:05"), OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()), TiandituKey: "0c260b8a094a4e0bc507808812cefdac", } - c.HTML(http.StatusOK, "radar_panyu.html", data) + c.HTML(http.StatusOK, "imdroid_radar.html", data) } func imdroidRadarHandler(c *gin.Context) { diff --git a/internal/server/radar_api.go b/internal/server/radar_api.go index ddadd89..9bdf09d 100644 --- a/internal/server/radar_api.go +++ b/internal/server/radar_api.go @@ -6,6 +6,7 @@ import ( "math" "net/http" "time" + "weatherstation/internal/config" "weatherstation/internal/database" "github.com/gin-gonic/gin" @@ -255,6 +256,104 @@ func radarTileTimesHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"times": times}) } +// nearestRadarTileHandler 返回最接近给定时间的瓦片(支持 z/y/x、容差分钟、偏好 lte 或 nearest) +func nearestRadarTileHandler(c *gin.Context) { + z := parseIntDefault(c.Query("z"), 7) + y := parseIntDefault(c.Query("y"), 40) + x := parseIntDefault(c.Query("x"), 102) + dtStr := c.Query("dt") + if dtStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"}) + return + } + tolMin := parseIntDefault(c.Query("tolerance_min"), 30) + prefer := c.DefaultQuery("prefer", "nearest") // nearest|lte + + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } + target, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"}) + return + } + from := target.Add(-time.Duration(tolMin) * time.Minute) + to := target.Add(time.Duration(tolMin) * time.Minute) + + db := database.GetDB() + var row *sql.Row + if prefer == "lte" { + const q = ` + SELECT dt FROM radar_tiles + WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 AND dt <= $6 + ORDER BY ($6 - dt) ASC + LIMIT 1` + row = db.QueryRow(q, z, y, x, from, to, target) + } else { + const q = ` + SELECT dt FROM radar_tiles + WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 + ORDER BY ABS(EXTRACT(EPOCH FROM (dt - $6))) ASC + LIMIT 1` + row = db.QueryRow(q, z, y, x, from, to, target) + } + var picked time.Time + if err := row.Scan(&picked); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "在容差范围内未找到匹配瓦片"}) + return + } + rec, err := getRadarTileAt(db, z, y, x, picked) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "未找到匹配瓦片数据"}) + return + } + + // 解码与 latest/at 相同 + w, h := rec.Width, rec.Height + if len(rec.Data) < w*h*2 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"}) + return + } + vals := make([][]*float64, h) + off := 0 + for rowi := 0; rowi < h; rowi++ { + rowVals := make([]*float64, w) + for col := 0; col < w; col++ { + v := int16(binary.BigEndian.Uint16(rec.Data[off : off+2])) + off += 2 + if v >= 32766 { + rowVals[col] = nil + continue + } + dbz := float64(v) / 10.0 + if dbz < 0 { + dbz = 0 + } else if dbz > 75 { + dbz = 75 + } + vv := dbz + rowVals[col] = &vv + } + vals[rowi] = rowVals + } + resp := radarTileResponse{ + DT: rec.DT.Format("2006-01-02 15:04:05"), + Z: rec.Z, + Y: rec.Y, + X: rec.X, + Width: rec.Width, + Height: rec.Height, + West: rec.West, + South: rec.South, + East: rec.East, + North: rec.North, + ResDeg: rec.ResDeg, + Values: vals, + } + c.JSON(http.StatusOK, resp) +} + func parseIntDefault(s string, def int) int { if s == "" { return def @@ -420,3 +519,51 @@ func radarWeatherAtHandler(c *gin.Context) { } c.JSON(http.StatusOK, resp) } + +// radarWeatherAliasesHandler 返回 radar_weather 中存在的站点别名及经纬度(按最近记录去重) +func radarWeatherAliasesHandler(c *gin.Context) { + const q = ` + SELECT DISTINCT ON (alias) alias, lat, lon, dt + FROM radar_weather + ORDER BY alias, dt DESC` + rows, err := database.GetDB().Query(q) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询别名失败"}) + return + } + defer rows.Close() + type item struct { + Alias string `json:"alias"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + } + var list []item + for rows.Next() { + var a string + var lat, lon float64 + var dt time.Time + if err := rows.Scan(&a, &lat, &lon, &dt); err != nil { + continue + } + list = append(list, item{Alias: a, Lat: lat, Lon: lon}) + } + c.JSON(http.StatusOK, gin.H{"aliases": list}) +} + +// radarConfigAliasesHandler 返回配置文件中的雷达别名列表(含 z/y/x 和经纬度) +func radarConfigAliasesHandler(c *gin.Context) { + cfg := config.GetConfig() + type item struct { + Alias string `json:"alias"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Z int `json:"z"` + Y int `json:"y"` + X int `json:"x"` + } + out := make([]item, 0, len(cfg.Radar.Aliases)) + for _, a := range cfg.Radar.Aliases { + out = append(out, item{Alias: a.Alias, Lat: a.Lat, Lon: a.Lon, Z: a.Z, Y: a.Y, X: a.X}) + } + c.JSON(http.StatusOK, gin.H{"aliases": out}) +} diff --git a/templates/imdroid_radar.html b/templates/imdroid_radar.html index 6454fcd..a2ee57f 100644 --- a/templates/imdroid_radar.html +++ b/templates/imdroid_radar.html @@ -31,19 +31,61 @@ const opt = document.createElement('option'); opt.value = ''; opt.textContent = '请选择站点…'; sel.appendChild(opt); - const res = await fetch('/api/stations'); - const stations = await res.json(); - stations - .filter(s => s.device_type === 'WH65LP' && s.latitude && s.longitude) - .forEach(s => { + try { + // 1) 实际设备站点 + const res = await fetch('/api/stations'); + const stations = await res.json(); + stations + .filter(s => s.device_type === 'WH65LP' && s.latitude && s.longitude) + .forEach(s => { + const o = document.createElement('option'); + o.value = s.station_id; // 用 station_id 作为联动主键 + const alias = (s.station_alias && String(s.station_alias).trim().length > 0) ? s.station_alias : s.station_id; + o.textContent = alias; // 仅显示别名 + o.dataset.z = s.z; o.dataset.y = s.y; o.dataset.x = s.x; + o.dataset.lat = s.latitude; o.dataset.lon = s.longitude; + o.dataset.kind = 'station'; + sel.appendChild(o); + }); + } catch {} + try { + // 2) 从配置读取别名(如 海珠/番禺),追加到同一下拉 + const res2 = await fetch('/api/radar/aliases'); + if (res2.ok) { + const j = await res2.json(); + (j.aliases || []).forEach(a => { + const o = document.createElement('option'); + o.value = a.alias; + o.textContent = a.alias; + o.dataset.z = a.z; o.dataset.y = a.y; o.dataset.x = a.x; + o.dataset.lat = a.lat; o.dataset.lon = a.lon; + o.dataset.kind = 'alias'; + sel.appendChild(o); + }); + } + } catch {} + } + + async function loadAliases() { + const sel = document.getElementById('aliasSelect'); + if (!sel) return; + sel.innerHTML = ''; + const opt = document.createElement('option'); + opt.value = ''; opt.textContent = '或选择雷达别名(海珠/番禺)…'; + sel.appendChild(opt); + try { + const res = await fetch('/api/radar/weather_aliases'); + if (!res.ok) return; + const j = await res.json(); + (j.aliases || []).forEach(a => { const o = document.createElement('option'); - o.value = s.station_id; // 用 station_id 作为联动主键 - const alias = (s.station_alias && String(s.station_alias).trim().length > 0) ? s.station_alias : s.station_id; - o.textContent = alias; // 仅显示别名 - o.dataset.z = s.z; o.dataset.y = s.y; o.dataset.x = s.x; - o.dataset.lat = s.latitude; o.dataset.lon = s.longitude; + o.value = a.alias; + o.textContent = a.alias; + o.dataset.lat = a.lat; + o.dataset.lon = a.lon; sel.appendChild(o); }); + } catch {} } function setRealtimeBox(j){ @@ -522,15 +564,15 @@ })(); document.getElementById('btnLoad').addEventListener('click', async ()=>{ const sel = document.getElementById('stationSelect'); - const alias = sel.value; - if (!alias) return; - gAlias = alias; + if (!sel || !sel.value) return; + const alias = sel.options[sel.selectedIndex].textContent || sel.value; gZ = Number(sel.options[sel.selectedIndex].dataset.z || 0); gY = Number(sel.options[sel.selectedIndex].dataset.y || 0); gX = Number(sel.options[sel.selectedIndex].dataset.x || 0); const lat = Number(sel.options[sel.selectedIndex].dataset.lat); const lon = Number(sel.options[sel.selectedIndex].dataset.lon); gStLat = isNaN(lat)? null : lat; gStLon = isNaN(lon)? null : lon; + gAlias = alias; document.getElementById('rt_zyx').textContent = `z=${gZ}, y=${gY}, x=${gX}`; try { await loadRealtime(alias); } catch (e) { console.warn(e); } if (gZ && gY && gX) { @@ -615,7 +657,7 @@