243 lines
7.7 KiB
Go
243 lines
7.7 KiB
Go
package radarfetch
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// StartJob launches a background ticker to download NMC images and CMA bin using Huanan time.
|
|
type Options struct {
|
|
OutRoot string
|
|
TZOffset int
|
|
Interval time.Duration
|
|
NMCChinaURL string
|
|
NMCHuananURL string
|
|
NMCNanningURL string
|
|
CMABase string
|
|
Z, Y, X int
|
|
}
|
|
|
|
// RunOnce executes a single download-render-save cycle.
|
|
func RunOnce(opts Options) error {
|
|
if opts.OutRoot == "" {
|
|
opts.OutRoot = "./radar_data"
|
|
}
|
|
if opts.TZOffset == 0 {
|
|
opts.TZOffset = 8
|
|
}
|
|
if opts.NMCChinaURL == "" {
|
|
opts.NMCChinaURL = "https://www.nmc.cn/publish/radar/chinaall.html"
|
|
}
|
|
if opts.NMCHuananURL == "" {
|
|
opts.NMCHuananURL = "https://www.nmc.cn/publish/radar/huanan.html"
|
|
}
|
|
if opts.NMCNanningURL == "" {
|
|
opts.NMCNanningURL = "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm"
|
|
}
|
|
if opts.CMABase == "" {
|
|
opts.CMABase = "https://image.data.cma.cn"
|
|
}
|
|
if opts.Z == 0 && opts.Y == 0 && opts.X == 0 {
|
|
opts.Z, opts.Y, opts.X = 7, 40, 102
|
|
}
|
|
fmt.Printf("[radar] start run: out=%s z/y/x=%d/%d/%d tz=%d\n", opts.OutRoot, opts.Z, opts.Y, opts.X, opts.TZOffset)
|
|
err := runDownload(opts.OutRoot, opts.TZOffset, opts.NMCChinaURL, opts.NMCHuananURL, opts.NMCNanningURL, opts.CMABase, opts.Z, opts.Y, opts.X)
|
|
if err != nil {
|
|
fmt.Printf("[radar] run failed: %v\n", err)
|
|
return err
|
|
}
|
|
fmt.Println("[radar] run done")
|
|
return nil
|
|
}
|
|
|
|
// Run starts the periodic downloader (blocking ticker loop).
|
|
func Run(opts Options) {
|
|
if opts.OutRoot == "" {
|
|
opts.OutRoot = "./radar_data"
|
|
}
|
|
if opts.TZOffset == 0 {
|
|
opts.TZOffset = 8
|
|
}
|
|
if opts.Interval <= 0 {
|
|
opts.Interval = 10 * time.Minute
|
|
}
|
|
if opts.NMCChinaURL == "" {
|
|
opts.NMCChinaURL = "https://www.nmc.cn/publish/radar/chinaall.html"
|
|
}
|
|
if opts.NMCHuananURL == "" {
|
|
opts.NMCHuananURL = "https://www.nmc.cn/publish/radar/huanan.html"
|
|
}
|
|
if opts.NMCNanningURL == "" {
|
|
opts.NMCNanningURL = "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm"
|
|
}
|
|
if opts.CMABase == "" {
|
|
opts.CMABase = "https://image.data.cma.cn"
|
|
}
|
|
if opts.Z == 0 && opts.Y == 0 && opts.X == 0 {
|
|
opts.Z, opts.Y, opts.X = 7, 40, 102
|
|
}
|
|
|
|
_ = RunOnce(opts)
|
|
t := time.NewTicker(opts.Interval)
|
|
for range t.C {
|
|
_ = RunOnce(opts)
|
|
}
|
|
}
|
|
|
|
func runDownload(outRoot string, tzOffset int, chinaURL, huananURL, nanningURL, cmaBase string, z, y, x int) error {
|
|
// 1) Fetch NMC pages and parse image/time (time from Huanan)
|
|
fmt.Println("[radar] fetch NMC China page ...")
|
|
chinaHTML, err := GetWithUA(chinaURL, DefaultUA, 15*time.Second, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch NMC China: %w", err)
|
|
}
|
|
chinaImg, _, ok := ExtractFirstImageAndTime(chinaHTML)
|
|
if !ok {
|
|
return fmt.Errorf("parse China page: data-img not found")
|
|
}
|
|
|
|
fmt.Println("[radar] fetch NMC Huanan page ...")
|
|
huananHTML, err := GetWithUA(huananURL, DefaultUA, 15*time.Second, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch NMC Huanan: %w", err)
|
|
}
|
|
huananImg, huananTime, ok := ExtractFirstImageAndTime(huananHTML)
|
|
if !ok {
|
|
return fmt.Errorf("parse Huanan page: data-img not found")
|
|
}
|
|
date, hour, minute, tsLocal := ParseNmcTime(huananTime, tzOffset)
|
|
|
|
fmt.Println("[radar] fetch NMC Nanning page ...")
|
|
nanningHTML, err := GetWithUA(nanningURL, DefaultUA, 15*time.Second, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch NMC Nanning: %w", err)
|
|
}
|
|
nanningImg, _, ok := ExtractFirstImageAndTime(nanningHTML)
|
|
if !ok {
|
|
return fmt.Errorf("parse Nanning page: data-img not found")
|
|
}
|
|
|
|
// Prepare out directory
|
|
outDir := filepath.Join(outRoot, fmt.Sprintf("%04d%02d%02d", date/10000, (date/100)%100, date%100), fmt.Sprintf("%02d", hour), fmt.Sprintf("%02d", minute))
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
return fmt.Errorf("mkdir outDir: %w", err)
|
|
}
|
|
|
|
// Download three images (with Referer)
|
|
imgHeaders := map[string]string{
|
|
"Referer": "https://www.nmc.cn/",
|
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
}
|
|
fmt.Println("[radar] download China/Huanan/Nanning images ...")
|
|
if b, err := GetWithUA(chinaImg, DefaultUA, 20*time.Second, imgHeaders); err == nil {
|
|
_ = os.WriteFile(filepath.Join(outDir, "nmc_chinaall.png"), b, 0o644)
|
|
}
|
|
if b, err := GetWithUA(huananImg, DefaultUA, 20*time.Second, imgHeaders); err == nil {
|
|
_ = os.WriteFile(filepath.Join(outDir, "nmc_huanan.png"), b, 0o644)
|
|
}
|
|
if b, err := GetWithUA(nanningImg, DefaultUA, 20*time.Second, imgHeaders); err == nil {
|
|
_ = os.WriteFile(filepath.Join(outDir, "nmc_nanning.png"), b, 0o644)
|
|
}
|
|
|
|
// 2) Fetch CMA BIN with Huanan time
|
|
binURL := BuildCMAURL(cmaBase, date, hour, minute, z, y, x)
|
|
binHeaders := map[string]string{
|
|
"Referer": "https://data.cma.cn/",
|
|
"Origin": "https://data.cma.cn",
|
|
"User-Agent": DefaultUA,
|
|
"Accept": "*/*",
|
|
}
|
|
fmt.Printf("[radar] download CMA bin z/y/x=%d/%d/%d ...\n", z, y, x)
|
|
binBytes, err := GetWithUA(binURL, DefaultUA, 30*time.Second, binHeaders)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch BIN: %w", err)
|
|
}
|
|
binPath := filepath.Join(outDir, fmt.Sprintf("%d-%d-%d.bin", z, y, x))
|
|
if err := os.WriteFile(binPath, binBytes, 0o644); err != nil {
|
|
return fmt.Errorf("save BIN: %w", err)
|
|
}
|
|
|
|
// Render BIN -> PNG
|
|
cmaPNG := filepath.Join(outDir, fmt.Sprintf("cma_%d-%d-%d.png", z, y, x))
|
|
fmt.Println("[radar] render bin -> png ...")
|
|
if err := RenderBinToPNG(binPath, cmaPNG, true); err != nil {
|
|
return fmt.Errorf("render PNG: %w", err)
|
|
}
|
|
|
|
// Decode grid and detect clusters (>=40 dBZ)
|
|
fmt.Println("[radar] decode grid & detect clusters ...")
|
|
grid := make([][]*float64, 256)
|
|
{
|
|
const w, h = 256, 256
|
|
if len(binBytes) == w*h*2 {
|
|
for row := 0; row < h; row++ {
|
|
line := make([]*float64, w)
|
|
for col := 0; col < w; col++ {
|
|
off := (row*w + col) * 2
|
|
u := uint16(binBytes[off])<<8 | uint16(binBytes[off+1])
|
|
vv := int16(u)
|
|
if vv == 32767 || vv < 0 {
|
|
line[col] = nil
|
|
continue
|
|
}
|
|
dbz := float64(vv) / 10.0
|
|
line[col] = &dbz
|
|
}
|
|
grid[row] = line
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) Write metadata and update latest
|
|
w, s, e, n, res := Bounds4326(z, y, x)
|
|
meta := Metadata{
|
|
TimestampLocal: tsLocal,
|
|
Date: date,
|
|
Hour: hour,
|
|
Minute: minute,
|
|
Z: z,
|
|
Y: y,
|
|
X: x,
|
|
Bounds: Bounds{West: w, South: s, East: e, North: n},
|
|
ResDeg: res,
|
|
Sources: Sources{NmcHTML: huananURL, NmcImg: huananImg, CmaBin: binURL},
|
|
Files: Files{HTML: "", PNG: filepath.Join(outDir, "nmc_huanan.png"), BIN: binPath, Metadata: filepath.Join(outDir, "metadata.json"), CMAPNG: cmaPNG},
|
|
Sizes: Sizes{PNG: fileSize(filepath.Join(outDir, "nmc_huanan.png")), BIN: int64(len(binBytes))},
|
|
CreatedAt: time.Now().Format(time.RFC3339),
|
|
}
|
|
// Attach clusters if grid decoded
|
|
if grid[0] != nil {
|
|
meta.Clusters = SegmentClusters(grid, Bounds{West: w, South: s, East: e, North: n}, res, 40.0)
|
|
// Render small PNGs per cluster
|
|
if len(meta.Clusters) > 0 {
|
|
if updated, err2 := AttachClusterPNGs(grid, 40.0, meta.Clusters, outDir); err2 == nil {
|
|
meta.Clusters = updated
|
|
}
|
|
}
|
|
meta.AnalysisNote = "clusters>=40dBZ; samples=center+4rays (N/S/E/W)"
|
|
// Build wind query plan with defaults
|
|
meta.QueryParams = QueryParams{MinAreaPx: 9, StrongDBZOverride: 50, MaxSamplesPerCluster: 5, MaxCandidatesTotal: 25}
|
|
cl2, cands := PlanWindQuery(meta.Clusters, meta.QueryParams)
|
|
meta.Clusters = cl2
|
|
meta.QueryCandidates = cands
|
|
}
|
|
if err := WriteMetadata(filepath.Join(outDir, "metadata.json"), &meta); err != nil {
|
|
return fmt.Errorf("write metadata: %w", err)
|
|
}
|
|
fmt.Println("[radar] update latest snapshot ...")
|
|
if err := UpdateLatest(outRoot, outDir, &meta); err != nil {
|
|
return fmt.Errorf("update latest: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fileSize(p string) int64 {
|
|
fi, err := os.Stat(p)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return fi.Size()
|
|
}
|