feat: 新增彩云气象数据下载的时间控制

This commit is contained in:
yarnom 2025-09-29 11:56:36 +08:00
parent 13b4117d75
commit 9f960c6411
6 changed files with 294 additions and 45 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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()

View File

@ -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) {

View File

@ -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})
}

View File

@ -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 @@
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<label class="text-sm">选择站点:</label>
<select id="stationSelect" class="border rounded px-2 py-1 text-sm min-w-[280px]"></select>
<select id="stationSelect" class="border rounded px-2 py-1 text-sm min-w-[360px]"></select>
<button id="btnLoad" class="px-2.5 py-1 text-sm bg-blue-600 text-white rounded">加载数据</button>
</div>
<div class="flex flex-wrap items-center gap-2">