Compare commits
138 Commits
main
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
| c0f97a20a5 | |||
| ac6ce044a8 | |||
| 9df02f5b5f | |||
| 65f95662ca | |||
| 19ea80614c | |||
| d1be656e13 | |||
| 7bc2337549 | |||
| 2ee30db410 | |||
| d78bfd381b | |||
| 0caa1da229 | |||
| 582270ce95 | |||
| 35ba93a435 | |||
| bc7443ca8b | |||
| 93a94c3149 | |||
| 7b3ce46f04 | |||
| 08fa1e8a04 | |||
| 0fd915a2ee | |||
| 229bbe76e8 | |||
| 4f68fdc28e | |||
| cfa69d4469 | |||
| c421aed925 | |||
| e8fcd550c1 | |||
| 5b7ec80473 | |||
| 0b5b26d5b0 | |||
| b0086a984f | |||
| 2556ccf351 | |||
| 050e189442 | |||
| 8797af23b5 | |||
| cbeb623f20 | |||
| 0398b82675 | |||
| 6f3dd6a3d0 | |||
| fef1825cb4 | |||
| 4ca575d86c | |||
| dc03e83562 | |||
| 28ce15ce13 | |||
| 9aaff59042 | |||
| 4696a46c8c | |||
| 63e484870d | |||
| 0b0512f5b2 | |||
| aa53a21685 | |||
| 9f960c6411 | |||
| 13b4117d75 | |||
| 5d8202311f | |||
| 0449971bcb | |||
| d0f96710e0 | |||
| b7183812fe | |||
| 504e39f0a5 | |||
| 9604c62f4c | |||
| ef1d2f57e1 | |||
| e41a242223 | |||
| c14759933f | |||
| a127ddfeba | |||
| f1e54aab9f | |||
| 5d6e967794 | |||
| a2a7cfd744 | |||
| 2085fd9a31 | |||
| 12b2ad5ace | |||
| e6f9d500ea | |||
| 11e5c73275 | |||
| da67660fe7 | |||
| a03c60469f | |||
| 2c7f9a0f47 | |||
| cfb0bca723 | |||
| 0da2c838c2 | |||
| 317e12900a | |||
| df7358530f | |||
| 6bc0610c2d | |||
| 4fa9822405 | |||
| 448b13c2f6 | |||
| ff9ab1f6c2 | |||
| d2a73a7dc3 | |||
| 93533aa76c | |||
| a612b511b2 | |||
| a5ddaea5a8 | |||
| 87ff8f44d7 | |||
| 0a5a6ec4e2 | |||
| b37a2801cc | |||
| 9cd26d3df3 | |||
| d26c2c025a | |||
| c81cd572d4 | |||
| 4c0f0da515 | |||
| 781a93cefc | |||
| 7e24cc52c9 | |||
| 3799b9fac8 | |||
| 701292c54b | |||
| d4fb3f5986 | |||
| 3b3cfe8a49 | |||
| 1ad2eb6e60 | |||
| eeeffa3e95 | |||
| 3e50260c51 | |||
| f969c2fe0f | |||
| 77d85816bd | |||
| 9f3331a09f | |||
| a5d382493a | |||
| f5174fe156 | |||
| 8fbdcb1e5b | |||
| 29a3e9305b | |||
| 67ba5cf21c | |||
| 7f4b8bb18b | |||
| 0444df3b4c | |||
| 9e1c4979c0 | |||
| 7ca0198b33 | |||
| 75cb5722f8 | |||
| bc8027a3d8 | |||
| 1defe32470 | |||
| 24dca2f489 | |||
| 6fb4655a15 | |||
| ac7c699530 | |||
| 85cca73799 | |||
| 4acb2b62ca | |||
| 91c881d066 | |||
| 753d4dcbc7 | |||
| e4b1c19064 | |||
| 6936734f7e | |||
| 337ee06caf | |||
| 3152c6bb14 | |||
| cc5c607457 | |||
| c55f089247 | |||
| 03d42ac3eb | |||
| a206138362 | |||
| f8fe5bd1e1 | |||
| c931eb6af5 | |||
| 2e62ce0501 | |||
| 8cfc1c0563 | |||
| ecf3a153f0 | |||
| 1c88bde080 | |||
| f2deb5512f | |||
| 26c13351d7 | |||
| 89fc15b5c4 | |||
| 26b261a663 | |||
| 419a5c940e | |||
| 6e643497a1 | |||
| bc3290c501 | |||
| cb1728ef00 | |||
| 523f489e11 | |||
| a6fa18f5cc | |||
| 8cbde597fd | |||
| ff2c0d6919 |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/log/*
|
||||
/rain_data/*
|
||||
export_data/*
|
||||
/.gopath/*
|
||||
/tech/*
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
47
README.md
47
README.md
@ -1,47 +0,0 @@
|
||||
# 气象站数据接收系统
|
||||
|
||||
UDP接收气象站数据,存PostgreSQL。
|
||||
|
||||
## 数据库字段转换
|
||||
|
||||
### 温度
|
||||
- `temp_f`, `dewpoint_f`, `windchill_f`, `indoor_temp_f`: 存储值=实际值×10 (°F)
|
||||
|
||||
### 湿度
|
||||
- `humidity`, `indoor_humidity`: 直接存整数百分比 (%)
|
||||
|
||||
### 风速
|
||||
- `wind_dir`: 直接存角度 (°)
|
||||
- `wind_speed_mph`, `wind_gust_mph`: 存储值=实际值×100 (mph)
|
||||
|
||||
### 降雨量
|
||||
- 所有rain字段: 存储值=实际值×1000 (英寸)
|
||||
|
||||
### 气压
|
||||
- `abs_barometer_in`, `barometer_in`: 存储值=实际值×1000 (英寸汞柱)
|
||||
|
||||
### 其他
|
||||
- `solar_radiation`: 存储值=实际值×100 (W/m²)
|
||||
- `uv`: 直接存整数
|
||||
- `low_battery`: 布尔值
|
||||
|
||||
## 单位转换
|
||||
- 华氏→摄氏: (°F - 32) * 5/9
|
||||
- 英里→公里: mph * 1.60934
|
||||
- 英寸→毫米: in * 25.4
|
||||
- 英寸汞柱→百帕: inHg * 33.8639
|
||||
|
||||
## 查询示例
|
||||
```sql
|
||||
SELECT
|
||||
station_id,
|
||||
timestamp AT TIME ZONE 'Asia/Shanghai' as local_time,
|
||||
temp_f::float/10 as temp_f,
|
||||
(temp_f::float/10 - 32) * 5/9 as temp_c,
|
||||
humidity,
|
||||
wind_speed_mph::float/100 as wind_speed_mph,
|
||||
barometer_in::float/1000 * 33.8639 as barometer_hpa
|
||||
FROM weather_data
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
1419
cmd/imdroidmix/main.go
Normal file
1419
cmd/imdroidmix/main.go
Normal file
File diff suppressed because it is too large
Load Diff
14
cmd/service-api/main.go
Normal file
14
cmd/service-api/main.go
Normal file
@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"weatherstation/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server.SetupLogger()
|
||||
|
||||
if err := server.StartGinServer(); err != nil {
|
||||
log.Fatalf("service-api failed: %v", err)
|
||||
}
|
||||
}
|
||||
33
cmd/service-exporter/main.go
Normal file
33
cmd/service-exporter/main.go
Normal file
@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"weatherstation/internal/server"
|
||||
"weatherstation/internal/tools"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server.SetupLogger()
|
||||
|
||||
// If CAIYUN_TOKEN is provided, enable wind override automatically.
|
||||
opts := tools.ExporterOptions{}
|
||||
if token := os.Getenv("CAIYUN_TOKEN"); token != "" {
|
||||
opts.OverrideWindWithCaiyun = true
|
||||
opts.CaiyunToken = token
|
||||
log.Printf("[service-exporter] wind override enabled via CAIYUN_TOKEN")
|
||||
}
|
||||
|
||||
exp := tools.NewExporterWithOptions(opts)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := exp.Start(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Fatalf("service-exporter failed: %v", err)
|
||||
}
|
||||
}
|
||||
65
cmd/service-forecast/main.go
Normal file
65
cmd/service-forecast/main.go
Normal file
@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
"weatherstation/internal/config"
|
||||
"weatherstation/internal/forecast"
|
||||
"weatherstation/internal/server"
|
||||
)
|
||||
|
||||
func hourlyLoop(ctx context.Context, fn func() error, name string) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
now := time.Now()
|
||||
next := now.Truncate(time.Hour).Add(time.Hour)
|
||||
t := time.NewTimer(time.Until(next))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
if err := fn(); err != nil {
|
||||
log.Printf("[%s] scheduled run failed: %v", name, err)
|
||||
} else {
|
||||
log.Printf("[%s] scheduled run completed", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
server.SetupLogger()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Open-Meteo hourly fetch
|
||||
go hourlyLoop(ctx, func() error { return forecast.RunOpenMeteoFetch(context.Background()) }, "open-meteo")
|
||||
|
||||
// Caiyun hourly fetch (if token configured)
|
||||
token := os.Getenv("CAIYUN_TOKEN")
|
||||
if token == "" {
|
||||
token = config.GetConfig().Forecast.CaiyunToken
|
||||
}
|
||||
if token == "" {
|
||||
log.Printf("[caiyun] token not set; caiyun scheduler disabled")
|
||||
} else {
|
||||
t := token
|
||||
go hourlyLoop(ctx, func() error { return forecast.RunCaiyunFetch(context.Background(), t) }, "caiyun")
|
||||
}
|
||||
|
||||
// CMA hourly fetch
|
||||
// go hourlyLoop(ctx, func() error { return forecast.RunCMAFetch(context.Background()) }, "cma")
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("service-forecast shutting down")
|
||||
}
|
||||
50
cmd/service-fusion/main.go
Normal file
50
cmd/service-fusion/main.go
Normal file
@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
"weatherstation/internal/fusion"
|
||||
"weatherstation/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server.SetupLogger()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
next := now.Truncate(time.Hour).Add(time.Hour).Add(5 * time.Minute)
|
||||
sleep := time.Until(next)
|
||||
if sleep < 0 {
|
||||
sleep = 0
|
||||
}
|
||||
t := time.NewTimer(sleep)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
|
||||
issued := next.Truncate(time.Hour)
|
||||
if err := fusion.RunForIssued(context.Background(), issued); err != nil {
|
||||
log.Printf("[service-fusion] run failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[service-fusion] completed issued=%s", issued.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("service-fusion shutting down")
|
||||
}
|
||||
33
cmd/service-radar/main.go
Normal file
33
cmd/service-radar/main.go
Normal file
@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"weatherstation/internal/radar"
|
||||
"weatherstation/internal/rain"
|
||||
"weatherstation/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server.SetupLogger()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Start radar scheduler with defaults (StoreToDB=true; tile defaults read inside package)
|
||||
if err := radar.Start(ctx, radar.Options{StoreToDB: true}); err != nil {
|
||||
log.Fatalf("service-radar start error: %v", err)
|
||||
}
|
||||
|
||||
// Also start CMPA hourly rain scheduler (StoreToDB=true; tiles/dir/url from env inside package)
|
||||
if err := rain.Start(ctx, rain.Options{StoreToDB: true}); err != nil {
|
||||
log.Fatalf("service-rain (embedded) start error: %v", err)
|
||||
}
|
||||
|
||||
// Keep process alive until signal
|
||||
<-ctx.Done()
|
||||
log.Println("service-radar shutting down")
|
||||
}
|
||||
418
cmd/service-splitarea/main.go
Normal file
418
cmd/service-splitarea/main.go
Normal file
@ -0,0 +1,418 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
// Service that, at hh:45 each hour, processes current hour's :30 radar tile for z/y/x,
|
||||
// splits a user region into 0.1° grid, averages dBZ (linear domain), fetches Open‑Meteo
|
||||
// hourly variables at grid centers, and writes a CSV.
|
||||
|
||||
type radarTileRecord struct {
|
||||
DT time.Time
|
||||
Z, Y, X int
|
||||
Width, Height int
|
||||
West, South, East, North float64
|
||||
ResDeg float64
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func getRadarTileAt(ctx context.Context, z, y, x int, dt time.Time) (*radarTileRecord, error) {
|
||||
const q = `SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data FROM radar_tiles WHERE z=$1 AND y=$2 AND x=$3 AND dt=$4 LIMIT 1`
|
||||
row := database.GetDB().QueryRowContext(ctx, q, z, y, x, dt)
|
||||
var r radarTileRecord
|
||||
if err := row.Scan(&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// parseBounds parses "W,S,E,N"
|
||||
func parseBounds(s string) (float64, float64, float64, float64, error) {
|
||||
parts := strings.Split(s, ",")
|
||||
if len(parts) != 4 {
|
||||
return 0, 0, 0, 0, fmt.Errorf("bounds must be 'W,S,E,N'")
|
||||
}
|
||||
var vals [4]float64
|
||||
for i := 0; i < 4; i++ {
|
||||
v, err := parseFloat(strings.TrimSpace(parts[i]))
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, fmt.Errorf("invalid bound %q: %v", parts[i], err)
|
||||
}
|
||||
vals[i] = v
|
||||
}
|
||||
w, s1, e, n := vals[0], vals[1], vals[2], vals[3]
|
||||
if !(w < e && s1 < n) {
|
||||
return 0, 0, 0, 0, errors.New("invalid bounds: require W<E and S<N")
|
||||
}
|
||||
return w, s1, e, n, nil
|
||||
}
|
||||
|
||||
func parseFloat(s string) (float64, error) {
|
||||
// Simple parser to avoid locale issues
|
||||
return strconvParseFloat(s)
|
||||
}
|
||||
|
||||
// Wrap strconv.ParseFloat to keep imports minimal in patch header
|
||||
func strconvParseFloat(s string) (float64, error) { return strconv.ParseFloat(s, 64) }
|
||||
|
||||
// align0p1 snaps to 0.1° grid
|
||||
func align0p1(w, s, e, n float64) (float64, float64, float64, float64) {
|
||||
w2 := math.Floor(w*10.0) / 10.0
|
||||
s2 := math.Floor(s*10.0) / 10.0
|
||||
e2 := math.Ceil(e*10.0) / 10.0
|
||||
n2 := math.Ceil(n*10.0) / 10.0
|
||||
return w2, s2, e2, n2
|
||||
}
|
||||
|
||||
func lonToCol(west, res float64, lon float64) int { return int(math.Floor((lon - west) / res)) }
|
||||
func latToRow(south, res float64, lat float64) int { return int(math.Floor((lat - south) / res)) }
|
||||
|
||||
// dbzFromRaw converts raw big‑endian int16 to dBZ, applying validity checks as in API
|
||||
func dbzFromRaw(v int16) (float64, bool) {
|
||||
if v >= 32766 { // invalid mask
|
||||
return 0, false
|
||||
}
|
||||
dbz := float64(v) / 10.0
|
||||
if dbz < 0 { // clip negative
|
||||
return 0, false
|
||||
}
|
||||
return dbz, true
|
||||
}
|
||||
|
||||
// linearZ average over dBZs
|
||||
func avgDbzLinear(vals []float64) float64 {
|
||||
if len(vals) == 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
zsum := 0.0
|
||||
for _, d := range vals {
|
||||
zsum += math.Pow(10, d/10.0)
|
||||
}
|
||||
meanZ := zsum / float64(len(vals))
|
||||
return 10.0 * math.Log10(meanZ)
|
||||
}
|
||||
|
||||
// open‑meteo client
|
||||
type meteoResp struct {
|
||||
Hourly struct {
|
||||
Time []string `json:"time"`
|
||||
Temp []float64 `json:"temperature_2m"`
|
||||
RH []float64 `json:"relative_humidity_2m"`
|
||||
Dew []float64 `json:"dew_point_2m"`
|
||||
WS []float64 `json:"wind_speed_10m"`
|
||||
WD []float64 `json:"wind_direction_10m"`
|
||||
} `json:"hourly"`
|
||||
}
|
||||
|
||||
type meteoVals struct {
|
||||
Temp, RH, Dew, WS, WD *float64
|
||||
}
|
||||
|
||||
func fetchMeteo(ctx context.Context, client *http.Client, lon, lat float64, utcHour time.Time) (*meteoVals, error) {
|
||||
base := "https://api.open-meteo.com/v1/forecast"
|
||||
datePart := utcHour.UTC().Format("2006-01-02")
|
||||
q := url.Values{}
|
||||
q.Set("latitude", fmt.Sprintf("%.4f", lat))
|
||||
q.Set("longitude", fmt.Sprintf("%.4f", lon))
|
||||
q.Set("hourly", "dew_point_2m,wind_speed_10m,wind_direction_10m,relative_humidity_2m,temperature_2m")
|
||||
q.Set("timezone", "UTC")
|
||||
q.Set("start_date", datePart)
|
||||
q.Set("end_date", datePart)
|
||||
q.Set("wind_speed_unit", "ms")
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"?"+q.Encode(), nil)
|
||||
req.Header.Set("User-Agent", "WeatherStation-splitarea/1.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("open-meteo status %d", resp.StatusCode)
|
||||
}
|
||||
var obj meteoResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := utcHour.UTC().Format("2006-01-02T15:00")
|
||||
idx := -1
|
||||
for i, t := range obj.Hourly.Time {
|
||||
if t == target {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return &meteoVals{}, nil
|
||||
}
|
||||
mv := meteoVals{}
|
||||
pick := func(arr []float64) *float64 {
|
||||
if arr == nil || idx >= len(arr) {
|
||||
return nil
|
||||
}
|
||||
v := arr[idx]
|
||||
return &v
|
||||
}
|
||||
mv.Temp = pick(obj.Hourly.Temp)
|
||||
mv.RH = pick(obj.Hourly.RH)
|
||||
mv.Dew = pick(obj.Hourly.Dew)
|
||||
mv.WS = pick(obj.Hourly.WS)
|
||||
mv.WD = pick(obj.Hourly.WD)
|
||||
return &mv, nil
|
||||
}
|
||||
|
||||
// job executes the split+augment for a specific local time (CST) and fixed minute=30 of the same hour.
|
||||
func job(ctx context.Context, z, y, x int, bounds string, tzOffset int, outDir string, runAt time.Time) error {
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
runAt = runAt.In(loc)
|
||||
|
||||
// Current hour's :30
|
||||
targetLocal := runAt.Truncate(time.Hour).Add(30 * time.Minute)
|
||||
|
||||
// Fetch tile
|
||||
rec, err := getRadarTileAt(ctx, z, y, x, targetLocal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load radar tile z=%d y=%d x=%d at %s: %w", z, y, x, targetLocal.Format("2006-01-02 15:04:05"), err)
|
||||
}
|
||||
// Bounds
|
||||
Bw, Bs, Be, Bn, err := parseBounds(bounds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Clamp to tile
|
||||
if !(rec.West <= Bw && Be <= rec.East && rec.South <= Bs && Bn <= rec.North) {
|
||||
return fmt.Errorf("bounds not inside tile: tile=(%.4f,%.4f,%.4f,%.4f) B=(%.4f,%.4f,%.4f,%.4f)", rec.West, rec.South, rec.East, rec.North, Bw, Bs, Be, Bn)
|
||||
}
|
||||
Gw, Gs, Ge, Gn := align0p1(Bw, Bs, Be, Bn)
|
||||
// clamp
|
||||
Gw = math.Max(Gw, rec.West)
|
||||
Gs = math.Max(Gs, rec.South)
|
||||
Ge = math.Min(Ge, rec.East)
|
||||
Gn = math.Min(Gn, rec.North)
|
||||
if Ge <= Gw || Gn <= Gs {
|
||||
return fmt.Errorf("aligned bounds empty")
|
||||
}
|
||||
|
||||
// Grid iterate with 0.1°
|
||||
d := 0.1
|
||||
ncols := int(math.Round((Ge - Gw) / d))
|
||||
nrows := int(math.Round((Gn - Gs) / d))
|
||||
if ncols <= 0 || nrows <= 0 {
|
||||
return fmt.Errorf("grid size zero")
|
||||
}
|
||||
|
||||
// Decode int16 big‑endian as we go; avoid full decode into []int16 to save mem
|
||||
w, h := rec.Width, rec.Height
|
||||
if w <= 0 || h <= 0 || len(rec.Data) < w*h*2 {
|
||||
return fmt.Errorf("invalid tile data")
|
||||
}
|
||||
|
||||
// Prepare output dir: export_data/split_area/YYYYMMDD/HH/30
|
||||
ymd := targetLocal.Format("20060102")
|
||||
hh := targetLocal.Format("15")
|
||||
mm := targetLocal.Format("04")
|
||||
dir := filepath.Join(outDir, ymd, hh, mm)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
base := fmt.Sprintf("%d-%d-%d", z, y, x)
|
||||
outPath := filepath.Join(dir, base+"_radar.csv")
|
||||
|
||||
// Prepare Open‑Meteo time window
|
||||
// Target UTC hour = floor(local to hour) - tzOffset
|
||||
floored := targetLocal.Truncate(time.Hour)
|
||||
utcHour := floored.Add(-time.Duration(tzOffset) * time.Hour)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
cache := map[string]*meteoVals{}
|
||||
keyOf := func(lon, lat float64) string { return fmt.Sprintf("%.4f,%.4f", lon, lat) }
|
||||
|
||||
// CSV output
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
wcsv := csv.NewWriter(f)
|
||||
defer wcsv.Flush()
|
||||
// Header
|
||||
_ = wcsv.Write([]string{"longitude", "latitude", "reflectivity_dbz", "temperature_2m", "relative_humidity_2m", "dew_point_2m", "wind_speed_10m", "wind_direction_10m"})
|
||||
|
||||
// Helper to read raw at (row,col)
|
||||
readRaw := func(rr, cc int) int16 {
|
||||
off := (rr*w + cc) * 2
|
||||
return int16(uint16(rec.Data[off])<<8 | uint16(rec.Data[off+1]))
|
||||
}
|
||||
|
||||
// Iterate grid cells
|
||||
for ri := 0; ri < nrows; ri++ {
|
||||
cellS := Gs + float64(ri)*d
|
||||
cellN := cellS + d
|
||||
row0 := maxInt(0, latToRow(rec.South, rec.ResDeg, cellS))
|
||||
row1 := minInt(h, int(math.Ceil((cellN-rec.South)/rec.ResDeg)))
|
||||
for ci := 0; ci < ncols; ci++ {
|
||||
cellW := Gw + float64(ci)*d
|
||||
cellE := cellW + d
|
||||
col0 := maxInt(0, lonToCol(rec.West, rec.ResDeg, cellW))
|
||||
col1 := minInt(w, int(math.Ceil((cellE-rec.West)/rec.ResDeg)))
|
||||
// accumulate
|
||||
dbzs := make([]float64, 0, (row1-row0)*(col1-col0))
|
||||
for rr := row0; rr < row1; rr++ {
|
||||
for cc := col0; cc < col1; cc++ {
|
||||
draw := readRaw(rr, cc)
|
||||
if d, ok := dbzFromRaw(draw); ok {
|
||||
dbzs = append(dbzs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
var cellDBZStr string
|
||||
if len(dbzs) > 0 {
|
||||
cellDBZ := avgDbzLinear(dbzs)
|
||||
cellDBZStr = fmt.Sprintf("%.1f", cellDBZ)
|
||||
} else {
|
||||
cellDBZStr = ""
|
||||
}
|
||||
lon := (cellW + cellE) / 2.0
|
||||
lat := (cellS + cellN) / 2.0
|
||||
|
||||
// Fetch meteo (cache by rounded lon,lat)
|
||||
k := keyOf(lon, lat)
|
||||
mv := cache[k]
|
||||
if mv == nil {
|
||||
mv, _ = fetchMeteo(ctx, client, lon, lat, utcHour)
|
||||
cache[k] = mv
|
||||
}
|
||||
// write row
|
||||
wcsv.Write([]string{
|
||||
fmt.Sprintf("%.4f", lon), fmt.Sprintf("%.4f", lat), cellDBZStr,
|
||||
fmtOptF(mv, func(m *meteoVals) *float64 {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return m.Temp
|
||||
}),
|
||||
fmtOptF(mv, func(m *meteoVals) *float64 {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return m.RH
|
||||
}),
|
||||
fmtOptF(mv, func(m *meteoVals) *float64 {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return m.Dew
|
||||
}),
|
||||
fmtOptF(mv, func(m *meteoVals) *float64 {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return m.WS
|
||||
}),
|
||||
fmtOptF(mv, func(m *meteoVals) *float64 {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return m.WD
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
wcsv.Flush()
|
||||
if err := wcsv.Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[splitarea] saved %s", outPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fmtOptF(mv *meteoVals, pick func(*meteoVals) *float64) string {
|
||||
if mv == nil {
|
||||
return ""
|
||||
}
|
||||
p := pick(mv)
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%.2f", *p)
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func getenvDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
z = flag.Int("z", 7, "tile z")
|
||||
y = flag.Int("y", 40, "tile y")
|
||||
x = flag.Int("x", 102, "tile x")
|
||||
b = flag.String("b", getenvDefault("SPLITAREA_B", "108.15,22.83,109.27,23.61"), "region bounds W,S,E,N")
|
||||
outDir = flag.String("out", "export_data/split_area", "output base directory")
|
||||
tzOffset = flag.Int("tz-offset", 8, "timezone offset hours to UTC for local time")
|
||||
once = flag.Bool("once", false, "run once for previous hour and exit")
|
||||
)
|
||||
flag.Parse()
|
||||
// Bounds now have a sensible default; still validate format later in job()
|
||||
// Ensure DB initialized
|
||||
_ = database.GetDB()
|
||||
|
||||
ctx := context.Background()
|
||||
if *once {
|
||||
if err := job(ctx, *z, *y, *x, *b, *tzOffset, *outDir, time.Now()); err != nil {
|
||||
log.Fatalf("run once: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Scheduler: run hourly at hh:45 for current hour's :30 radar tile
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
for {
|
||||
now := time.Now().In(loc)
|
||||
runAt := now.Truncate(time.Hour).Add(45 * time.Minute)
|
||||
if now.After(runAt) {
|
||||
runAt = runAt.Add(time.Hour)
|
||||
}
|
||||
time.Sleep(time.Until(runAt))
|
||||
// execute
|
||||
if err := job(ctx, *z, *y, *x, *b, *tzOffset, *outDir, runAt); err != nil {
|
||||
log.Printf("[splitarea] job error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
cmd/service-udp/main.go
Normal file
14
cmd/service-udp/main.go
Normal file
@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"weatherstation/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server.SetupLogger()
|
||||
|
||||
if err := server.StartUDPServer(); err != nil {
|
||||
log.Fatalf("service-udp failed: %v", err)
|
||||
}
|
||||
}
|
||||
323
cmd/weatherstation/main.go
Normal file
323
cmd/weatherstation/main.go
Normal file
@ -0,0 +1,323 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
"weatherstation/internal/config"
|
||||
"weatherstation/internal/database"
|
||||
"weatherstation/internal/forecast"
|
||||
"weatherstation/internal/radar"
|
||||
"weatherstation/internal/rain"
|
||||
"weatherstation/internal/selftest"
|
||||
"weatherstation/internal/server"
|
||||
"weatherstation/internal/tools"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 命令行参数
|
||||
var webOnly = flag.Bool("web", false, "只启动Web服务器(Gin)")
|
||||
var udpOnly = flag.Bool("udp", false, "只启动UDP服务器")
|
||||
// 调试:回填10分钟表
|
||||
var doBackfill = flag.Bool("backfill", false, "将16s原始数据聚合写入10分钟表(调试用途)")
|
||||
var bfStation = flag.String("station", "", "指定站点ID(为空则全站回填)")
|
||||
var bfFrom = flag.String("from", "", "回填起始时间,格式:YYYY-MM-DD HH:MM:SS")
|
||||
var bfTo = flag.String("to", "", "回填结束时间,格式:YYYY-MM-DD HH:MM:SS")
|
||||
var bfWrap = flag.Float64("wrap", 0, "回绕一圈对应毫米值(mm),<=0 则降级为仅计当前值")
|
||||
// 自检控制
|
||||
var noSelftest = flag.Bool("no-selftest", false, "跳过启动自检")
|
||||
var selftestOnly = flag.Bool("selftest_only", false, "仅执行自检后退出")
|
||||
// 预报抓取
|
||||
var forecastOnly = flag.Bool("forecast_only", false, "仅执行一次open-meteo拉取并退出")
|
||||
var caiyunOnly = flag.Bool("caiyun_only", false, "仅执行一次彩云拉取并退出")
|
||||
var cmaCLI = flag.Bool("cma_cli", false, "仅执行一次CMA接口抓取并打印未来三小时")
|
||||
var cmaOnly = flag.Bool("cma_only", false, "仅执行一次CMA拉取并退出")
|
||||
var forecastDay = flag.String("forecast_day", "", "按日期抓取当天0点到当前时间+3h(格式YYYY-MM-DD)")
|
||||
// 历史数据补完
|
||||
var historicalOnly = flag.Bool("historical_only", false, "仅执行历史数据补完并退出")
|
||||
var historicalStart = flag.String("historical_start", "", "历史数据开始日期(格式YYYY-MM-DD)")
|
||||
var historicalEnd = flag.String("historical_end", "", "历史数据结束日期(格式YYYY-MM-DD)")
|
||||
// 覆盖风:使用彩云实况替换导出中的风速/风向
|
||||
var useWindOverride = flag.Bool("wind", false, "使用彩云实况覆盖导出CSV中的风速/风向")
|
||||
// 历史CSV导出
|
||||
var exportRangeOnly = flag.Bool("export_range", false, "按日期范围导出10分钟CSV(含ZTD融合),并退出。日期格式支持 YYYY-MM-DD 或 YYYYMMDD")
|
||||
var exportStart = flag.String("export_start", "", "导出起始日期(含),格式 YYYY-MM-DD 或 YYYYMMDD")
|
||||
var exportEnd = flag.String("export_end", "", "导出结束日期(含),格式 YYYY-MM-DD 或 YYYYMMDD")
|
||||
// 雷达:导入单个CMA瓦片到数据库
|
||||
var importTile = flag.Bool("import_tile", false, "导入一个CMA雷达瓦片到数据库并退出")
|
||||
var tileURL = flag.String("tile_url", "", "瓦片URL或/tiles/...路径,用于解析product/时间/z/y/x")
|
||||
var tilePath = flag.String("tile_path", "", "瓦片本地文件路径(.bin)")
|
||||
flag.Parse()
|
||||
|
||||
// 设置日志
|
||||
server.SetupLogger()
|
||||
|
||||
// 初始化数据库连接
|
||||
_ = database.GetDB() // 确保PostgreSQL连接已初始化
|
||||
defer database.Close()
|
||||
// 初始化MySQL连接(如果配置存在)
|
||||
_ = database.GetMySQL()
|
||||
defer database.CloseMySQL()
|
||||
|
||||
// 启动前自检
|
||||
if !*noSelftest {
|
||||
if err := selftest.Run(context.Background()); err != nil {
|
||||
log.Fatalf("启动自检失败: %v", err)
|
||||
}
|
||||
if *selftestOnly {
|
||||
log.Println("自检完成,按 --selftest_only 要求退出")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 单次 open-meteo 拉取
|
||||
if *forecastOnly {
|
||||
if err := forecast.RunOpenMeteoFetch(context.Background()); err != nil {
|
||||
log.Fatalf("open-meteo 拉取失败: %v", err)
|
||||
}
|
||||
log.Println("open-meteo 拉取完成")
|
||||
return
|
||||
}
|
||||
|
||||
// 单次 彩云 拉取(token 从环境变量 CAIYUN_TOKEN 或命令行 -caiyun_token 读取)
|
||||
if *caiyunOnly {
|
||||
token := os.Getenv("CAIYUN_TOKEN")
|
||||
if token == "" {
|
||||
// 退回配置
|
||||
token = config.GetConfig().Forecast.CaiyunToken
|
||||
if token == "" {
|
||||
log.Fatalf("未提供彩云 token,请设置环境变量 CAIYUN_TOKEN 或配置文件 forecast.caiyun_token")
|
||||
}
|
||||
}
|
||||
if err := forecast.RunCaiyunFetch(context.Background(), token); err != nil {
|
||||
log.Fatalf("caiyun 拉取失败: %v", err)
|
||||
}
|
||||
log.Println("caiyun 拉取完成")
|
||||
return
|
||||
}
|
||||
|
||||
// 单次 CMA 拉取(固定参数)写库并退出
|
||||
if *cmaOnly {
|
||||
if err := forecast.RunCMAFetch(context.Background()); err != nil {
|
||||
log.Fatalf("CMA 拉取失败: %v", err)
|
||||
}
|
||||
log.Println("CMA 拉取完成")
|
||||
return
|
||||
}
|
||||
|
||||
// 单次 CMA 拉取(固定参数)并打印三小时
|
||||
if *cmaCLI {
|
||||
if err := forecast.RunCMACLI(context.Background()); err != nil {
|
||||
log.Fatalf("CMA 拉取失败: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 导入一个CMA雷达瓦片到数据库
|
||||
if *importTile {
|
||||
if *tileURL == "" || *tilePath == "" {
|
||||
log.Fatalln("import_tile 需要提供 --tile_url 与 --tile_path")
|
||||
}
|
||||
if err := radar.ImportTileFile(context.Background(), *tileURL, *tilePath); err != nil {
|
||||
log.Fatalf("导入雷达瓦片失败: %v", err)
|
||||
}
|
||||
log.Println("导入雷达瓦片完成")
|
||||
return
|
||||
}
|
||||
|
||||
// 历史CSV范围导出
|
||||
if *exportRangeOnly {
|
||||
if *exportStart == "" || *exportEnd == "" {
|
||||
log.Fatalln("export_range 需要提供 --export_start 与 --export_end 日期(YYYY-MM-DD 或 YYYYMMDD)")
|
||||
}
|
||||
var opts tools.ExporterOptions
|
||||
if *useWindOverride {
|
||||
token := os.Getenv("CAIYUN_TOKEN")
|
||||
if token == "" {
|
||||
token = config.GetConfig().Forecast.CaiyunToken
|
||||
}
|
||||
if token == "" {
|
||||
log.Println("警告: 指定了 --wind 但未提供彩云 token,忽略风覆盖")
|
||||
} else {
|
||||
opts.OverrideWindWithCaiyun = true
|
||||
opts.CaiyunToken = token
|
||||
}
|
||||
}
|
||||
exporter := tools.NewExporterWithOptions(opts)
|
||||
if err := exporter.ExportRange(context.Background(), *exportStart, *exportEnd); err != nil {
|
||||
log.Fatalf("export_range 失败: %v", err)
|
||||
}
|
||||
log.Println("export_range 完成")
|
||||
return
|
||||
}
|
||||
|
||||
// 工具:按日期抓取当天0点到当前时间+3小时(两家)
|
||||
if *forecastDay != "" {
|
||||
if err := tools.RunForecastFetchForDay(context.Background(), *forecastDay); err != nil {
|
||||
log.Fatalf("forecast_day 运行失败: %v", err)
|
||||
}
|
||||
log.Println("forecast_day 完成")
|
||||
return
|
||||
}
|
||||
|
||||
// 历史数据补完
|
||||
if *historicalOnly {
|
||||
if *historicalStart == "" || *historicalEnd == "" {
|
||||
log.Fatalln("历史数据补完需要提供 --historical_start 与 --historical_end 日期(格式YYYY-MM-DD)")
|
||||
}
|
||||
if err := forecast.RunOpenMeteoHistoricalFetch(context.Background(), *historicalStart, *historicalEnd); err != nil {
|
||||
log.Fatalf("历史数据补完失败: %v", err)
|
||||
}
|
||||
log.Println("历史数据补完成")
|
||||
return
|
||||
}
|
||||
|
||||
// Backfill 调试路径
|
||||
if *doBackfill {
|
||||
if *bfFrom == "" || *bfTo == "" {
|
||||
log.Fatalln("backfill 需要提供 --from 与 --to 时间")
|
||||
}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
fromT, err := time.ParseInLocation("2006-01-02 15:04:05", *bfFrom, loc)
|
||||
if err != nil {
|
||||
log.Fatalf("解析from失败: %v", err)
|
||||
}
|
||||
toT, err := time.ParseInLocation("2006-01-02 15:04:05", *bfTo, loc)
|
||||
if err != nil {
|
||||
log.Fatalf("解析to失败: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := tools.RunBackfill10Min(ctx, tools.BackfillOptions{
|
||||
StationID: *bfStation,
|
||||
FromTime: fromT,
|
||||
ToTime: toT,
|
||||
WrapCycleMM: *bfWrap,
|
||||
BucketMinutes: 10,
|
||||
}); err != nil {
|
||||
log.Fatalf("回填失败: %v", err)
|
||||
}
|
||||
log.Println("回填完成")
|
||||
return
|
||||
}
|
||||
|
||||
// 根据命令行参数启动服务
|
||||
startExporterBackground := func(wg *sync.WaitGroup) {
|
||||
if wg != nil {
|
||||
wg.Add(1)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if wg != nil {
|
||||
wg.Done()
|
||||
}
|
||||
}()
|
||||
log.Println("启动数据导出器(10分钟)...")
|
||||
ctx := context.Background()
|
||||
// 处理 --wind 覆盖
|
||||
var opts tools.ExporterOptions
|
||||
if *useWindOverride {
|
||||
token := os.Getenv("CAIYUN_TOKEN")
|
||||
if token == "" {
|
||||
token = config.GetConfig().Forecast.CaiyunToken
|
||||
}
|
||||
if token == "" {
|
||||
log.Println("警告: 指定了 --wind 但未提供彩云 token,忽略风覆盖")
|
||||
} else {
|
||||
opts.OverrideWindWithCaiyun = true
|
||||
opts.CaiyunToken = token
|
||||
}
|
||||
}
|
||||
exporter := tools.NewExporterWithOptions(opts)
|
||||
if err := exporter.Start(ctx); err != nil {
|
||||
log.Printf("导出器退出: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
startRadarSchedulerBackground := func(wg *sync.WaitGroup) {
|
||||
if wg != nil {
|
||||
wg.Add(1)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if wg != nil {
|
||||
wg.Done()
|
||||
}
|
||||
}()
|
||||
log.Println("启动雷达下载任务(每10分钟,无延迟,固定瓦片 7/40/102)...")
|
||||
ctx := context.Background()
|
||||
_ = radar.Start(ctx, radar.Options{StoreToDB: true, Z: 7, Y: 40, X: 102})
|
||||
}()
|
||||
}
|
||||
|
||||
startRainSchedulerBackground := func(wg *sync.WaitGroup) {
|
||||
if wg != nil {
|
||||
wg.Add(1)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if wg != nil {
|
||||
wg.Done()
|
||||
}
|
||||
}()
|
||||
log.Println("启动一小时降雨下载任务(每10分钟,固定瓦片 7/40/102 与 7/40/104)...")
|
||||
ctx := context.Background()
|
||||
_ = rain.Start(ctx, rain.Options{StoreToDB: true})
|
||||
}()
|
||||
}
|
||||
|
||||
if *webOnly {
|
||||
// 只启动Web服务器 + 导出器
|
||||
startExporterBackground(nil)
|
||||
startRadarSchedulerBackground(nil)
|
||||
startRainSchedulerBackground(nil)
|
||||
log.Println("启动Web服务器模式...")
|
||||
if err := server.StartGinServer(); err != nil {
|
||||
log.Fatalf("启动Web服务器失败: %v", err)
|
||||
}
|
||||
} else if *udpOnly {
|
||||
// 只启动UDP服务器 + 导出器
|
||||
startExporterBackground(nil)
|
||||
startRadarSchedulerBackground(nil)
|
||||
startRainSchedulerBackground(nil)
|
||||
log.Println("启动UDP服务器模式...")
|
||||
if err := server.StartUDPServer(); err != nil {
|
||||
log.Fatalf("启动UDP服务器失败: %v", err)
|
||||
}
|
||||
} else {
|
||||
// 同时启动UDP和Web服务器 + 导出器
|
||||
log.Println("启动完整模式:UDP + Web服务器 + 导出器...")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
// 启动UDP服务器
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
log.Println("正在启动UDP服务器...")
|
||||
if err := server.StartUDPServer(); err != nil {
|
||||
log.Printf("UDP服务器异常退出: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 启动Web服务器
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
log.Println("正在启动Web服务器...")
|
||||
if err := server.StartGinServer(); err != nil {
|
||||
log.Printf("Web服务器异常退出: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
startExporterBackground(&wg)
|
||||
startRadarSchedulerBackground(&wg)
|
||||
startRainSchedulerBackground(&wg)
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
151
cmd/weatherstationctl/main.go
Normal file
151
cmd/weatherstationctl/main.go
Normal file
@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// installPrefix is the base install directory.
|
||||
// Binaries go to installPrefix/bin, assets and config go to installPrefix/.
|
||||
const installPrefix = "/opt/weatherstation"
|
||||
|
||||
func main() {
|
||||
// Ensure target directories exist
|
||||
binDir := filepath.Join(installPrefix, "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
fatalf("创建目录失败: %s: %v", binDir, err)
|
||||
}
|
||||
|
||||
// Build all service-* under cmd/
|
||||
serviceDirs, err := findServiceDirs()
|
||||
if err != nil {
|
||||
fatalf("扫描服务目录失败: %v", err)
|
||||
}
|
||||
if len(serviceDirs) == 0 {
|
||||
fatalf("未发现任何 service-* 微服务目录")
|
||||
}
|
||||
|
||||
for _, svc := range serviceDirs {
|
||||
out := filepath.Join(binDir, svc)
|
||||
// 必须使用相对前缀 ./,否则 go 会将其当成标准库/模块路径
|
||||
pkg := "./" + filepath.ToSlash(filepath.Join("cmd", svc))
|
||||
fmt.Printf("编译 %s -> %s\n", pkg, out)
|
||||
if err := run("go", "build", "-o", out, pkg); err != nil {
|
||||
fatalf("编译失败 %s: %v", pkg, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy templates, static, config.yaml to installPrefix
|
||||
// Replace existing files/directories
|
||||
if err := copyDirReplacing("templates", filepath.Join(installPrefix, "templates")); err != nil {
|
||||
fatalf("复制 templates 失败: %v", err)
|
||||
}
|
||||
if err := copyDirReplacing("static", filepath.Join(installPrefix, "static")); err != nil {
|
||||
fatalf("复制 static 失败: %v", err)
|
||||
}
|
||||
if err := copyFileReplacing("config.yaml", filepath.Join(installPrefix, "config.yaml"), 0o644); err != nil {
|
||||
// 配置文件可能不存在于仓库,但按照需求尝试复制,若不存在给出提示
|
||||
if !os.IsNotExist(err) {
|
||||
fatalf("复制 config.yaml 失败: %v", err)
|
||||
} else {
|
||||
fmt.Println("提示: 仓库根目录未找到 config.yaml,跳过复制")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("完成: 微服务安装于 %s,资源已同步到 %s\n", binDir, installPrefix)
|
||||
}
|
||||
|
||||
func fatalf(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// findServiceDirs returns names like service-api, service-udp under cmd/.
|
||||
func findServiceDirs() ([]string, error) {
|
||||
entries, err := os.ReadDir("cmd")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var list []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, "service-") {
|
||||
// ensure main.go exists to be buildable
|
||||
if _, err := os.Stat(filepath.Join("cmd", name, "main.go")); err == nil {
|
||||
list = append(list, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func copyDirReplacing(src, dst string) error {
|
||||
st, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return fmt.Errorf("%s 不是目录", src)
|
||||
}
|
||||
// Remove destination to ensure clean replace
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
return copyFileReplacing(path, target, 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFileReplacing(src, dst string, perm os.FileMode) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = out.Close() }()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
42
config.yaml
42
config.yaml
@ -4,15 +4,41 @@ server:
|
||||
database:
|
||||
host: "8.134.185.53"
|
||||
port: 5432
|
||||
user: "weatheruser"
|
||||
password: "yourpassword"
|
||||
user: "yarnom"
|
||||
password: "root"
|
||||
dbname: "weatherdb"
|
||||
sslmode: "disable"
|
||||
|
||||
heartbeat:
|
||||
interval: 5
|
||||
message: "Hello"
|
||||
forecast:
|
||||
caiyun_token: "ZAcZq49qzibr10F0"
|
||||
|
||||
device_check:
|
||||
interval: 5
|
||||
message: "Hello"
|
||||
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
|
||||
- alias: "武汉江夏雷达站"
|
||||
lat: 30.459015
|
||||
lon: 114.413052
|
||||
z: 7
|
||||
y: 42
|
||||
x: 104
|
||||
|
||||
mysql:
|
||||
host: "127.0.0.1"
|
||||
port: 3306
|
||||
user: "remote"
|
||||
password: "your_password"
|
||||
dbname: "rtk_data"
|
||||
params: "parseTime=true&loc=Asia%2FShanghai"
|
||||
|
||||
@ -21,21 +21,9 @@ type DatabaseConfig struct {
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
Interval int `yaml:"interval"`
|
||||
Message string `yaml:"message"`
|
||||
}
|
||||
|
||||
type DeviceCheckConfig struct {
|
||||
Interval int `yaml:"interval"`
|
||||
Message string `yaml:"message"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Heartbeat HeartbeatConfig `yaml:"heartbeat"`
|
||||
DeviceCheck DeviceCheckConfig `yaml:"device_check"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
4
core/frontend/.gitignore
vendored
Normal file
4
core/frontend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.angular/
|
||||
|
||||
64
core/frontend/angular.json
Normal file
64
core/frontend/angular.json
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/angular-cli",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"weatherstation-ui": {
|
||||
"projectType": "application",
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/ui",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": [
|
||||
"src/polyfills.ts"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": [],
|
||||
"baseHref": "/ui/"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "weatherstation-ui:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "weatherstation-ui:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "weatherstation-ui:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12103
core/frontend/package-lock.json
generated
Normal file
12103
core/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
core/frontend/package.json
Normal file
30
core/frontend/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "weatherstation-ui",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "ng serve",
|
||||
"build": "ng build --configuration production --base-href /ui/",
|
||||
"dev": "ng serve",
|
||||
"test": "ng test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.3.0",
|
||||
"@angular/common": "^17.3.0",
|
||||
"@angular/compiler": "^17.3.0",
|
||||
"@angular/core": "^17.3.0",
|
||||
"@angular/forms": "^17.3.0",
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"tslib": "^2.6.2",
|
||||
"zone.js": "^0.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^17.3.0",
|
||||
"@angular/cli": "^17.3.0",
|
||||
"@angular/compiler-cli": "^17.3.0",
|
||||
"typescript": "~5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
4
core/frontend/src/app.component.html
Normal file
4
core/frontend/src/app.component.html
Normal file
@ -0,0 +1,4 @@
|
||||
<div style="font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans, 'Apple Color Emoji', 'Segoe UI Emoji'; padding: 24px;">
|
||||
<h1>Hello World</h1>
|
||||
</div>
|
||||
|
||||
12
core/frontend/src/index.html
Normal file
12
core/frontend/src/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>WeatherStation UI</title>
|
||||
<base href="/ui/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<app-root>Loading...</app-root>
|
||||
</body>
|
||||
</html>
|
||||
3
core/frontend/src/polyfills.ts
Normal file
3
core/frontend/src/polyfills.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// Angular zone support (default change detection)
|
||||
import 'zone.js';
|
||||
|
||||
4
core/frontend/src/styles.css
Normal file
4
core/frontend/src/styles.css
Normal file
@ -0,0 +1,4 @@
|
||||
/* Global styles (optional) */
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; }
|
||||
|
||||
14
core/frontend/tsconfig.app.json
Normal file
14
core/frontend/tsconfig.app.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts",
|
||||
"src/polyfills.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
27
core/frontend/tsconfig.json
Normal file
27
core/frontend/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./out-tsc",
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2022",
|
||||
"useDefineForClassFields": false,
|
||||
"lib": [
|
||||
"es2022",
|
||||
"dom"
|
||||
],
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
|
||||
10
db/migrations/20251013_add_forecast_weights_current.sql
Normal file
10
db/migrations/20251013_add_forecast_weights_current.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- Create snapshot table for per-station forecast fusion weights
|
||||
CREATE TABLE IF NOT EXISTS forecast_weights_current (
|
||||
station_id TEXT PRIMARY KEY,
|
||||
w_open_meteo DOUBLE PRECISION NOT NULL,
|
||||
w_caiyun DOUBLE PRECISION NOT NULL,
|
||||
w_imdroid DOUBLE PRECISION NOT NULL,
|
||||
last_issued_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
566
db/schema.sql
Normal file
566
db/schema.sql
Normal file
@ -0,0 +1,566 @@
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
-- Dumped from database version 14.18 (Ubuntu 14.18-0ubuntu0.22.04.1)
|
||||
-- Dumped by pg_dump version 17.5
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET transaction_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
--
|
||||
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
-- *not* creating schema, since initdb creates it
|
||||
|
||||
|
||||
SET default_tablespace = '';
|
||||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- Name: rs485_weather_data; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.rs485_weather_data (
|
||||
id integer NOT NULL,
|
||||
station_id character varying(50) NOT NULL,
|
||||
"timestamp" timestamp with time zone NOT NULL,
|
||||
temperature double precision,
|
||||
humidity double precision,
|
||||
wind_speed double precision,
|
||||
wind_direction double precision,
|
||||
rainfall double precision,
|
||||
light double precision,
|
||||
uv double precision,
|
||||
pressure double precision,
|
||||
raw_data text
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: rs485_weather_data_bak; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.rs485_weather_data_bak (
|
||||
id integer,
|
||||
station_id character varying(50),
|
||||
"timestamp" timestamp without time zone,
|
||||
temperature numeric(5,2),
|
||||
humidity numeric(5,2),
|
||||
wind_speed numeric(5,2),
|
||||
wind_direction numeric(5,2),
|
||||
rainfall numeric(5,2),
|
||||
light numeric(15,2),
|
||||
uv numeric(8,2),
|
||||
pressure numeric(7,2),
|
||||
raw_data text
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: rs485_weather_data_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.rs485_weather_data_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: rs485_weather_data_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.rs485_weather_data_id_seq OWNED BY public.rs485_weather_data.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: stations; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.stations (
|
||||
station_id character varying(50) NOT NULL,
|
||||
device_id character varying(50),
|
||||
password character varying(50) NOT NULL,
|
||||
name character varying(100),
|
||||
location character varying(100),
|
||||
latitude numeric(10,6),
|
||||
longitude numeric(10,6),
|
||||
altitude numeric(8,3),
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
last_update timestamp with time zone,
|
||||
software_type character varying(100),
|
||||
device_type character varying(20) DEFAULT 'UNKNOWN'::character varying NOT NULL,
|
||||
CONSTRAINT check_device_type CHECK (((device_type)::text = ANY ((ARRAY['ECOWITT'::character varying, 'WH65LP'::character varying, 'UNKNOWN'::character varying])::text[])))
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE stations; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON TABLE public.stations IS '气象站设备信息表,存储设备的基本信息和认证信息';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN stations.device_type; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.stations.device_type IS 'ECOWITT: WIFI型, WH65LP: 485型';
|
||||
|
||||
|
||||
--
|
||||
-- Name: weather_data; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.weather_data (
|
||||
id integer NOT NULL,
|
||||
station_id character varying(50) NOT NULL,
|
||||
"timestamp" timestamp with time zone NOT NULL,
|
||||
temp_f integer,
|
||||
humidity integer,
|
||||
dewpoint_f integer,
|
||||
windchill_f integer,
|
||||
wind_dir integer,
|
||||
wind_speed_mph integer,
|
||||
wind_gust_mph integer,
|
||||
rain_in integer,
|
||||
daily_rain_in integer,
|
||||
weekly_rain_in integer,
|
||||
monthly_rain_in integer,
|
||||
yearly_rain_in integer,
|
||||
total_rain_in integer,
|
||||
solar_radiation integer,
|
||||
uv integer,
|
||||
indoor_temp_f integer,
|
||||
indoor_humidity integer,
|
||||
abs_barometer_in integer,
|
||||
barometer_in integer,
|
||||
low_battery boolean,
|
||||
raw_data text
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE weather_data; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON TABLE public.weather_data IS '气象站数据表,存储所有气象观测数据,数值型数据以整数形式存储,查询时需进行转换';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.id; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.id IS '自增主键ID';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.station_id; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.station_id IS '气象站ID,外键关联stations表';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data."timestamp"; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data."timestamp" IS '数据记录时间,使用UTC+8时区(中国标准时间)';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.temp_f; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.temp_f IS '室外温度,存储值=实际值×10,单位:华氏度,查询时需除以10,如768表示76.8°F';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.humidity; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.humidity IS '室外湿度,单位:百分比,如53表示53%';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.dewpoint_f; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.dewpoint_f IS '露点温度,存储值=实际值×10,单位:华氏度,查询时需除以10,如585表示58.5°F';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.windchill_f; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.windchill_f IS '风寒指数,存储值=实际值×10,单位:华氏度,查询时需除以10,如768表示76.8°F';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.wind_dir; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.wind_dir IS '风向,单位:角度(0-359),如44表示东北风(44°)';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.wind_speed_mph; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.wind_speed_mph IS '风速,存储值=实际值×100,单位:英里/小时,查询时需除以100,如100表示1.00mph';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.wind_gust_mph; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.wind_gust_mph IS '阵风速度,存储值=实际值×100,单位:英里/小时,查询时需除以100,如100表示1.00mph';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.rain_in; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.rain_in IS '当前降雨速率,存储值=实际值×1000,单位:英寸/小时,查询时需除以1000,如500表示0.500英寸/小时';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.daily_rain_in; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.daily_rain_in IS '日降雨量,存储值=实际值×1000,单位:英寸,查询时需除以1000,如500表示0.500英寸';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.weekly_rain_in; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.weekly_rain_in IS '周降雨量,存储值=实际值×1000,单位:英寸,查询时需除以1000,如500表示0.500英寸';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.monthly_rain_in; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.monthly_rain_in IS '月降雨量,存储值=实际值×1000,单位:英寸,查询时需除以1000,如79表示0.079英寸';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.yearly_rain_in; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.yearly_rain_in IS '年降雨量,存储值=实际值×1000,单位:英寸,查询时需除以1000,如79表示0.079英寸';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.total_rain_in; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.total_rain_in IS '总降雨量,存储值=实际值×1000,单位:英寸,查询时需除以1000,如79表示0.079英寸';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.solar_radiation; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.solar_radiation IS '太阳辐射,存储值=实际值×100,单位:W/m²,查询时需除以100,如172表示1.72W/m²';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.uv; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.uv IS '紫外线指数,整数值,如0表示无紫外线';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.indoor_temp_f; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.indoor_temp_f IS '室内温度,存储值=实际值×10,单位:华氏度,查询时需除以10,如837表示83.7°F';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.indoor_humidity; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.indoor_humidity IS '室内湿度,单位:百分比,如48表示48%';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.abs_barometer_in; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.abs_barometer_in IS '绝对气压,存储值=实际值×1000,单位:英寸汞柱,查询时需除以1000,如29320表示29.320英寸汞柱';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.barometer_in; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.barometer_in IS '相对气压,存储值=实际值×1000,单位:英寸汞柱,查询时需除以1000,如29805表示29.805英寸汞柱';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.low_battery; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.low_battery IS '低电量标志,布尔值,true表示电量低';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN weather_data.raw_data; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.weather_data.raw_data IS '原始数据字符串';
|
||||
|
||||
|
||||
--
|
||||
-- Name: weather_data_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.weather_data_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: weather_data_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.weather_data_id_seq OWNED BY public.weather_data.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: rs485_weather_data id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.rs485_weather_data ALTER COLUMN id SET DEFAULT nextval('public.rs485_weather_data_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: weather_data id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.weather_data ALTER COLUMN id SET DEFAULT nextval('public.weather_data_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: rs485_weather_data rs485_udx; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.rs485_weather_data
|
||||
ADD CONSTRAINT rs485_udx UNIQUE (station_id, "timestamp");
|
||||
|
||||
|
||||
--
|
||||
-- Name: rs485_weather_data rs485_weather_data_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.rs485_weather_data
|
||||
ADD CONSTRAINT rs485_weather_data_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: stations stations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.stations
|
||||
ADD CONSTRAINT stations_pkey PRIMARY KEY (station_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: weather_data weather_data_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.weather_data
|
||||
ADD CONSTRAINT weather_data_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_rwd_station_time; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX idx_rwd_station_time ON public.rs485_weather_data USING btree (station_id, "timestamp");
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_rwd_time; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX idx_rwd_time ON public.rs485_weather_data USING btree ("timestamp");
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_weather_data_station_timestamp; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX idx_weather_data_station_timestamp ON public.weather_data USING btree (station_id, "timestamp");
|
||||
|
||||
|
||||
--
|
||||
-- Name: INDEX idx_weather_data_station_timestamp; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON INDEX public.idx_weather_data_station_timestamp IS '气象站ID和时间戳的复合索引';
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_weather_data_timestamp; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX idx_weather_data_timestamp ON public.weather_data USING btree ("timestamp");
|
||||
|
||||
|
||||
--
|
||||
-- Name: INDEX idx_weather_data_timestamp; Type: COMMENT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON INDEX public.idx_weather_data_timestamp IS '时间戳索引';
|
||||
|
||||
|
||||
--
|
||||
-- Name: rs485_weather_data rs485_weather_data_station_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.rs485_weather_data
|
||||
ADD CONSTRAINT rs485_weather_data_station_id_fkey FOREIGN KEY (station_id) REFERENCES public.stations(station_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: weather_data weather_data_station_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.weather_data
|
||||
ADD CONSTRAINT weather_data_station_id_fkey FOREIGN KEY (station_id) REFERENCES public.stations(station_id);
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
|
||||
-- Name: rs485_weather_10min; Type: TABLE; Schema: public; Owner: -
|
||||
-- 用途:10分钟粒度聚合(长期保留,缩放整数存储)
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS public.rs485_weather_10min (
|
||||
id SERIAL PRIMARY KEY,
|
||||
station_id character varying(50) NOT NULL,
|
||||
"bucket_start" timestamp with time zone NOT NULL,
|
||||
temp_c_x100 integer,
|
||||
humidity_pct integer,
|
||||
wind_speed_ms_x1000 integer,
|
||||
wind_gust_ms_x1000 integer,
|
||||
wind_dir_deg integer,
|
||||
rain_10m_mm_x1000 integer,
|
||||
rain_total_mm_x1000 integer,
|
||||
solar_wm2_x100 integer,
|
||||
uv_index integer,
|
||||
pressure_hpa_x100 integer,
|
||||
sample_count integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
-- 约束与索引
|
||||
ALTER TABLE ONLY public.rs485_weather_10min
|
||||
ADD CONSTRAINT r10_udx UNIQUE (station_id, "bucket_start");
|
||||
|
||||
ALTER TABLE ONLY public.rs485_weather_10min
|
||||
ADD CONSTRAINT rs485_weather_10min_station_id_fkey FOREIGN KEY (station_id) REFERENCES public.stations(station_id);
|
||||
|
||||
CREATE INDEX idx_r10_station_time ON public.rs485_weather_10min USING btree (station_id, "bucket_start");
|
||||
|
||||
COMMENT ON TABLE public.rs485_weather_10min IS '10分钟聚合数据表,数值型以缩放整数存储(温度×100、风速×1000等)';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min."bucket_start" IS '10分钟桶开始时间(与CST对齐分桶,存储为timestamptz)';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.temp_c_x100 IS '10分钟平均温度,单位℃×100';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.humidity_pct IS '10分钟平均湿度,单位%';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.wind_speed_ms_x1000 IS '10分钟平均风速,单位m/s×1000';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.wind_gust_ms_x1000 IS '10分钟最大阵风,单位m/s×1000';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.wind_dir_deg IS '10分钟风向向量平均,单位度(0-359)';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.rain_10m_mm_x1000 IS '10分钟降雨量,按“带回绕正增量”计算,单位mm×1000';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.rain_total_mm_x1000 IS '桶末设备累计降雨(自开机起累加,0..FFFF回绕),单位mm×1000';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.solar_wm2_x100 IS '10分钟平均太阳辐射,单位W/m²×100';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.uv_index IS '10分钟平均紫外线指数';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.pressure_hpa_x100 IS '10分钟平均气压,单位hPa×100';
|
||||
COMMENT ON COLUMN public.rs485_weather_10min.sample_count IS '10分钟样本数量';
|
||||
|
||||
|
||||
--
|
||||
-- Name: forecast_hourly; Type: TABLE; Schema: public; Owner: -
|
||||
-- 用途:小时级预报(版本化:issued_at 为预报方案发布时间)
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS public.forecast_hourly (
|
||||
id SERIAL PRIMARY KEY,
|
||||
station_id character varying(50) NOT NULL,
|
||||
provider character varying(50) NOT NULL,
|
||||
issued_at timestamp with time zone NOT NULL,
|
||||
forecast_time timestamp with time zone NOT NULL,
|
||||
temp_c_x100 integer,
|
||||
humidity_pct integer,
|
||||
wind_speed_ms_x1000 integer,
|
||||
wind_gust_ms_x1000 integer,
|
||||
wind_dir_deg integer,
|
||||
rain_mm_x1000 integer,
|
||||
precip_prob_pct integer,
|
||||
uv_index integer,
|
||||
pressure_hpa_x100 integer
|
||||
);
|
||||
|
||||
-- 约束与索引
|
||||
ALTER TABLE ONLY public.forecast_hourly
|
||||
ADD CONSTRAINT forecast_hourly_udx UNIQUE (station_id, provider, issued_at, forecast_time);
|
||||
|
||||
ALTER TABLE ONLY public.forecast_hourly
|
||||
ADD CONSTRAINT forecast_hourly_station_id_fkey FOREIGN KEY (station_id) REFERENCES public.stations(station_id);
|
||||
|
||||
CREATE INDEX idx_fcast_station_time ON public.forecast_hourly USING btree (station_id, forecast_time);
|
||||
|
||||
-- 注释
|
||||
COMMENT ON TABLE public.forecast_hourly IS '小时级预报表,按issued_at版本化;要素使用缩放整数存储';
|
||||
COMMENT ON COLUMN public.forecast_hourly.issued_at IS '预报方案发布时间(版本时间)';
|
||||
COMMENT ON COLUMN public.forecast_hourly.forecast_time IS '目标小时时间戳';
|
||||
COMMENT ON COLUMN public.forecast_hourly.rain_mm_x1000 IS '该小时降雨量,单位mm×1000';
|
||||
|
||||
--
|
||||
-- Name: radar_weather; Type: TABLE; Schema: public; Owner: -
|
||||
-- 用途:雷达站实时气象(彩云实时),每10分钟采样一条
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS public.radar_weather (
|
||||
id SERIAL PRIMARY KEY,
|
||||
alias TEXT NOT NULL,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lon DOUBLE PRECISION NOT NULL,
|
||||
dt TIMESTAMPTZ NOT NULL,
|
||||
temperature DOUBLE PRECISION,
|
||||
humidity DOUBLE PRECISION,
|
||||
cloudrate DOUBLE PRECISION,
|
||||
visibility DOUBLE PRECISION,
|
||||
dswrf DOUBLE PRECISION,
|
||||
wind_speed DOUBLE PRECISION,
|
||||
wind_direction DOUBLE PRECISION,
|
||||
pressure DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 约束与索引
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS radar_weather_udx ON public.radar_weather(alias, dt);
|
||||
CREATE INDEX IF NOT EXISTS idx_radar_weather_dt ON public.radar_weather(dt);
|
||||
COMMENT ON TABLE public.radar_weather IS '雷达站实时气象数据表(彩云Realtime),按10分钟存档';
|
||||
123
export/export.sh
Normal file
123
export/export.sh
Normal file
@ -0,0 +1,123 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 设置环境变量
|
||||
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
# 设置PostgreSQL环境变量
|
||||
export PGPASSWORD="你的密码" # 替换为实际的密码
|
||||
|
||||
# 设置数据库连接参数
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
DB_NAME="weatherdb"
|
||||
DB_USER="yarnom"
|
||||
EXPORT_DIR="/home/yarnom/Archive/code/WeatherStation/exportData"
|
||||
|
||||
# 添加日志功能
|
||||
LOG_FILE="$EXPORT_DIR/export.log"
|
||||
|
||||
# 记录开始时间
|
||||
echo "=== 开始导出: $(date) ===" >> "$LOG_FILE"
|
||||
|
||||
# 确保导出目录存在
|
||||
mkdir -p "$EXPORT_DIR/data"
|
||||
|
||||
# 获取当前时间和10分钟前的时间
|
||||
CURRENT_DATE=$(date +"%Y-%m-%d")
|
||||
END_TIME=$(date +"%Y-%m-%d %H:%M:00")
|
||||
START_TIME=$(date -d "10 minutes ago" +"%Y-%m-%d %H:%M:00")
|
||||
|
||||
# 记录时间范围
|
||||
echo "导出时间范围: $START_TIME 到 $END_TIME" >> "$LOG_FILE"
|
||||
|
||||
# 设置当天的数据文件
|
||||
CURRENT_FILE="$EXPORT_DIR/data/weather_data_${CURRENT_DATE}.csv"
|
||||
LAST_EXPORT_TIME_FILE="$EXPORT_DIR/last_export_time"
|
||||
|
||||
# 检查是否需要创建新文件(新的一天)
|
||||
if [ -f "$LAST_EXPORT_TIME_FILE" ]; then
|
||||
LAST_DATE=$(head -n 1 "$LAST_EXPORT_TIME_FILE" | cut -d' ' -f1)
|
||||
if [ "$LAST_DATE" != "$CURRENT_DATE" ]; then
|
||||
# 新的一天,将昨天的文件压缩存档
|
||||
YESTERDAY=$(date -d "yesterday" +"%Y-%m-%d")
|
||||
if [ -f "$EXPORT_DIR/data/weather_data_${YESTERDAY}.csv" ]; then
|
||||
gzip "$EXPORT_DIR/data/weather_data_${YESTERDAY}.csv"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 如果是新文件,创建表头
|
||||
if [ ! -f "$CURRENT_FILE" ]; then
|
||||
echo "创建新文件: $CURRENT_FILE" >> "$LOG_FILE"
|
||||
echo "latitude,longitude,station_id,station_name,date_time,elevation,pressure,temperature,dewpoint,wind_speed,wind_direction,relative_humidity,ztd,pwv" > "$CURRENT_FILE"
|
||||
fi
|
||||
|
||||
# 导出新数据并追加到当天的文件
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -d "$DB_NAME" -U "$DB_USER" -A -F "," -t -c "
|
||||
WITH avg_data AS (
|
||||
SELECT
|
||||
s.station_id,
|
||||
COALESCE(s.password, s.station_id) as output_station_id, -- 如果password为空则使用station_id
|
||||
'$END_TIME'::timestamp as date_time,
|
||||
-- 气压、温度取平均
|
||||
ROUND(AVG(r.pressure)::numeric, 2) as pressure,
|
||||
ROUND(AVG(r.temperature)::numeric, 2) as temperature,
|
||||
-- 风速取平均
|
||||
ROUND(AVG(r.wind_speed)::numeric, 2) as wind_speed,
|
||||
-- 风向使用矢量平均
|
||||
ROUND(DEGREES(ATAN2(
|
||||
AVG(SIN(RADIANS(r.wind_direction))),
|
||||
AVG(COS(RADIANS(r.wind_direction)))
|
||||
))::numeric + CASE
|
||||
WHEN DEGREES(ATAN2(
|
||||
AVG(SIN(RADIANS(r.wind_direction))),
|
||||
AVG(COS(RADIANS(r.wind_direction)))
|
||||
)) < 0 THEN 360
|
||||
ELSE 0
|
||||
END, 2) as wind_direction,
|
||||
-- 湿度取平均
|
||||
ROUND(AVG(r.humidity)::numeric, 2) as relative_humidity
|
||||
FROM stations s
|
||||
JOIN rs485_weather_data r ON s.station_id = r.station_id
|
||||
WHERE r.timestamp >= '$START_TIME' AND r.timestamp < '$END_TIME'
|
||||
GROUP BY s.station_id, s.password
|
||||
)
|
||||
SELECT
|
||||
'0', -- latitude
|
||||
'0', -- longitude
|
||||
output_station_id, -- station_id (使用password字段)
|
||||
'', -- station_name
|
||||
date_time, -- date_time
|
||||
'0', -- elevation
|
||||
COALESCE(pressure::text, '0'),
|
||||
COALESCE(temperature::text, '0'),
|
||||
'0', -- dewpoint
|
||||
COALESCE(wind_speed::text, '0'),
|
||||
COALESCE(wind_direction::text, '0'),
|
||||
COALESCE(relative_humidity::text, '0'),
|
||||
'', -- ztd
|
||||
'' -- pwv
|
||||
FROM avg_data
|
||||
ORDER BY output_station_id;" >> "$CURRENT_FILE" 2>> "$LOG_FILE"
|
||||
|
||||
# 检查psql执行状态
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "数据导出成功" >> "$LOG_FILE"
|
||||
else
|
||||
echo "数据导出失败" >> "$LOG_FILE"
|
||||
fi
|
||||
|
||||
# 更新最后导出时间
|
||||
echo "$END_TIME" > "$LAST_EXPORT_TIME_FILE"
|
||||
|
||||
# 记录结束时间
|
||||
echo "=== 结束导出: $(date) ===" >> "$LOG_FILE"
|
||||
echo "" >> "$LOG_FILE"
|
||||
|
||||
# 保持日志文件大小合理(保留最后1000行)
|
||||
tail -n 1000 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
|
||||
|
||||
# 清除密码环境变量(安全考虑)
|
||||
unset PGPASSWORD
|
||||
135
export/export_daily.sh
Normal file
135
export/export_daily.sh
Normal file
@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 设置环境变量
|
||||
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
# 设置PostgreSQL环境变量
|
||||
export PGPASSWORD="root" # 替换为实际的密码
|
||||
|
||||
# 设置数据库连接参数
|
||||
DB_HOST="8.134.185.53"
|
||||
DB_PORT="5432"
|
||||
DB_NAME="weatherdb"
|
||||
DB_USER="yarnom"
|
||||
EXPORT_DIR="/home/yarnom/Archive/code/WeatherStation/exportData"
|
||||
|
||||
# 添加日志功能
|
||||
LOG_FILE="$EXPORT_DIR/export_daily.log"
|
||||
|
||||
# 检查是否提供了日期参数
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "使用方法: $0 YYYY-MM-DD"
|
||||
echo "示例: $0 2024-02-20"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 获取输入的日期
|
||||
TARGET_DATE="$1"
|
||||
|
||||
# 验证日期格式
|
||||
if ! date -d "$TARGET_DATE" >/dev/null 2>&1; then
|
||||
echo "错误:无效的日期格式。请使用 YYYY-MM-DD 格式。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 记录开始时间
|
||||
echo "=== 开始导出日期 $TARGET_DATE 的数据: $(date) ===" >> "$LOG_FILE"
|
||||
|
||||
# 确保导出目录存在
|
||||
mkdir -p "$EXPORT_DIR/daily_exports"
|
||||
|
||||
# 设置输出文件
|
||||
OUTPUT_FILE="$EXPORT_DIR/daily_exports/weather_data_${TARGET_DATE}.csv"
|
||||
|
||||
# 创建表头
|
||||
echo "latitude,longitude,station_id,station_name,date_time,elevation,pressure,temperature,dewpoint,wind_speed,wind_direction,relative_humidity,ztd,pwv" > "$OUTPUT_FILE"
|
||||
|
||||
# 导出数据
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -d "$DB_NAME" -U "$DB_USER" -A -F "," -t -c "
|
||||
WITH time_series AS (
|
||||
SELECT generate_series(
|
||||
'$TARGET_DATE 00:00:00'::timestamp,
|
||||
'$TARGET_DATE 23:59:59'::timestamp,
|
||||
'10 minutes'::interval
|
||||
) as interval_start
|
||||
),
|
||||
avg_data AS (
|
||||
SELECT
|
||||
s.station_id,
|
||||
COALESCE(s.password, s.station_id) as output_station_id,
|
||||
ts.interval_start as date_time,
|
||||
s.latitude,
|
||||
s.longitude,
|
||||
s.altitude as elevation,
|
||||
-- 气压、温度取平均
|
||||
ROUND(AVG(r.pressure)::numeric, 2) as pressure,
|
||||
ROUND(AVG(r.temperature)::numeric, 2) as temperature,
|
||||
-- 风速取平均
|
||||
ROUND(AVG(r.wind_speed)::numeric, 2) as wind_speed,
|
||||
-- 风向使用矢量平均
|
||||
ROUND(DEGREES(ATAN2(
|
||||
AVG(SIN(RADIANS(r.wind_direction))),
|
||||
AVG(COS(RADIANS(r.wind_direction)))
|
||||
))::numeric + CASE
|
||||
WHEN DEGREES(ATAN2(
|
||||
AVG(SIN(RADIANS(r.wind_direction))),
|
||||
AVG(COS(RADIANS(r.wind_direction)))
|
||||
)) < 0 THEN 360
|
||||
ELSE 0
|
||||
END, 2) as wind_direction,
|
||||
-- 湿度取平均
|
||||
ROUND(AVG(r.humidity)::numeric, 2) as relative_humidity
|
||||
FROM time_series ts
|
||||
CROSS JOIN stations s
|
||||
LEFT JOIN rs485_weather_data r ON s.station_id = r.station_id
|
||||
AND r.timestamp >= ts.interval_start
|
||||
AND r.timestamp < ts.interval_start + '10 minutes'::interval
|
||||
GROUP BY s.station_id, s.password, ts.interval_start, s.latitude, s.longitude, s.altitude
|
||||
)
|
||||
SELECT
|
||||
COALESCE(latitude::text, '0'), -- latitude
|
||||
COALESCE(longitude::text, '0'), -- longitude
|
||||
output_station_id, -- station_id
|
||||
'', -- station_name
|
||||
date_time, -- date_time
|
||||
COALESCE(elevation::text, '0'), -- elevation
|
||||
COALESCE(pressure::text, '0'), -- pressure
|
||||
COALESCE(temperature::text, '0'), -- temperature
|
||||
'0', -- dewpoint
|
||||
COALESCE(wind_speed::text, '0'), -- wind_speed
|
||||
COALESCE(wind_direction::text, '0'), -- wind_direction
|
||||
COALESCE(relative_humidity::text, '0'), -- relative_humidity
|
||||
'', -- ztd
|
||||
'' -- pwv
|
||||
FROM avg_data
|
||||
ORDER BY date_time, output_station_id;" >> "$OUTPUT_FILE" 2>> "$LOG_FILE"
|
||||
|
||||
# 检查psql执行状态
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "数据导出成功到文件: $OUTPUT_FILE"
|
||||
echo "数据导出成功" >> "$LOG_FILE"
|
||||
else
|
||||
echo "数据导出失败"
|
||||
echo "数据导出失败" >> "$LOG_FILE"
|
||||
fi
|
||||
|
||||
# 记录结束时间
|
||||
echo "=== 结束导出: $(date) ===" >> "$LOG_FILE"
|
||||
echo "" >> "$LOG_FILE"
|
||||
|
||||
# 保持日志文件大小合理(保留最后1000行)
|
||||
tail -n 1000 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
|
||||
|
||||
# 清除密码环境变量(安全考虑)
|
||||
unset PGPASSWORD
|
||||
|
||||
# 如果导出成功,显示一些统计信息
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "统计信息:"
|
||||
echo "----------------------------------------"
|
||||
echo "总记录数:$(tail -n +2 "$OUTPUT_FILE" | wc -l)"
|
||||
echo "文件大小:$(du -h "$OUTPUT_FILE" | cut -f1)"
|
||||
echo "文件位置:$OUTPUT_FILE"
|
||||
fi
|
||||
60
export/get.sh
Normal file
60
export/get.sh
Normal file
@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 设置远程服务器信息
|
||||
REMOTE_USER="root"
|
||||
REMOTE_HOST="8.134.185.53"
|
||||
REMOTE_PORT="30001"
|
||||
REMOTE_PATH="/root/rain/weather-station/dataTransfer/data/"
|
||||
LOCAL_PATH="/home/imdroid/Build_WRF/cycling_data" # 使用当前目录
|
||||
LOG_FILE="${LOCAL_PATH}/sync.log"
|
||||
|
||||
# 确保本地目录存在
|
||||
mkdir -p "$LOCAL_PATH"
|
||||
|
||||
# 记录开始时间
|
||||
echo "=== 开始同步: $(date) ===" >> "$LOG_FILE"
|
||||
|
||||
# 使用rsync进行增量同步
|
||||
# -a: 归档模式,保持所有文件属性
|
||||
# -v: 详细输出
|
||||
# -z: 传输时压缩数据
|
||||
# -t: 保持时间戳
|
||||
# -P: 显示进度并允许断点续传
|
||||
# -e: 指定ssh命令和端口
|
||||
# --delete: 删除目标目录中源目录没有的文件
|
||||
# --timeout=60: 设置超时时间
|
||||
# --bwlimit=1000: 限制带宽(KB/s)
|
||||
rsync -avzt \
|
||||
-P \
|
||||
-e "ssh -p ${REMOTE_PORT}" \
|
||||
--delete \
|
||||
--timeout=60 \
|
||||
--bwlimit=1000 \
|
||||
--include="*.csv" \
|
||||
--include="*.gz" \
|
||||
--exclude="*" \
|
||||
--log-file="$LOG_FILE" \
|
||||
"${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}" \
|
||||
"${LOCAL_PATH}/"
|
||||
|
||||
# 检查rsync退出状态
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "同步成功完成" >> "$LOG_FILE"
|
||||
|
||||
# 检查是否有新文件同步
|
||||
NEW_FILES=$(find "${LOCAL_PATH}" -type f -mmin -10 \( -name "*.csv" -o -name "*.gz" \))
|
||||
if [ ! -z "$NEW_FILES" ]; then
|
||||
echo "新同步的文件:" >> "$LOG_FILE"
|
||||
echo "$NEW_FILES" >> "$LOG_FILE"
|
||||
fi
|
||||
else
|
||||
echo "同步失败,错误代码: $?" >> "$LOG_FILE"
|
||||
# 可以在这里添加告警通知(如发送邮件)
|
||||
fi
|
||||
|
||||
# 记录同步时间
|
||||
echo "=== 结束同步: $(date) ===" >> "$LOG_FILE"
|
||||
echo "" >> "$LOG_FILE"
|
||||
|
||||
# 保持日志文件大小合理(保留最后1000行)
|
||||
tail -n 1000 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
|
||||
43
go.mod
43
go.mod
@ -5,32 +5,37 @@ go 1.23.0
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.13.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
golang.org/x/arch v0.19.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
)
|
||||
|
||||
81
go.sum
81
go.sum
@ -1,34 +1,42 @@
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
@ -41,35 +49,44 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
|
||||
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
147
internal/config/config.go
Normal file
147
internal/config/config.go
Normal file
@ -0,0 +1,147 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
WebPort int `yaml:"web_port"` // Gin Web服务器端口
|
||||
UDPPort int `yaml:"udp_port"` // UDP服务器端口
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
DBName string `yaml:"dbname"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
}
|
||||
|
||||
// ForecastConfig 预报相关配置
|
||||
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"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
DBName string `yaml:"dbname"`
|
||||
Params string `yaml:"params"` // 例如: parseTime=true&loc=Asia%2FShanghai
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Forecast ForecastConfig `yaml:"forecast"`
|
||||
Radar RadarConfig `yaml:"radar"`
|
||||
MySQL MySQLConfig `yaml:"mysql"`
|
||||
}
|
||||
|
||||
var (
|
||||
instance *Config
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetConfig 返回配置单例
|
||||
func GetConfig() *Config {
|
||||
once.Do(func() {
|
||||
instance = &Config{}
|
||||
if err := instance.loadConfig(); err != nil {
|
||||
panic(fmt.Sprintf("加载配置文件失败: %v", err))
|
||||
}
|
||||
})
|
||||
return instance
|
||||
}
|
||||
|
||||
// loadConfig 从配置文件加载配置
|
||||
func (c *Config) loadConfig() error {
|
||||
// 尝试多个位置查找配置文件(兼容从仓库根目录、bin目录、系统安装路径运行)
|
||||
exePath, _ := os.Executable()
|
||||
exeDir := ""
|
||||
if exePath != "" {
|
||||
exeDir = filepath.Dir(exePath)
|
||||
}
|
||||
// 优先顺序:可执行文件所在目录,其次其父目录;然后回退到工作目录及上级,再到系统级/用户级
|
||||
configPaths := []string{
|
||||
// 可执行文件所在目录优先
|
||||
filepath.Join(exeDir, "config.yaml"),
|
||||
filepath.Join(exeDir, "..", "config.yaml"),
|
||||
// 工作目录及其上级
|
||||
"config.yaml",
|
||||
"../config.yaml",
|
||||
"../../config.yaml",
|
||||
// 系统级与用户级
|
||||
"/etc/weatherstation/config.yaml",
|
||||
filepath.Join(os.Getenv("HOME"), ".weatherstation", "config.yaml"),
|
||||
}
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
for _, path := range configPaths {
|
||||
if data, err = os.ReadFile(path); err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("未找到配置文件: %v", err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, c); err != nil {
|
||||
return fmt.Errorf("解析配置文件失败: %v", err)
|
||||
}
|
||||
|
||||
return c.validate()
|
||||
}
|
||||
|
||||
// validate 验证配置有效性
|
||||
func (c *Config) validate() error {
|
||||
if c.Server.WebPort <= 0 {
|
||||
c.Server.WebPort = 10003 // 默认Web端口
|
||||
}
|
||||
if c.Server.UDPPort <= 0 {
|
||||
c.Server.UDPPort = 10001 // 默认UDP端口
|
||||
}
|
||||
if c.Database.SSLMode == "" {
|
||||
c.Database.SSLMode = "disable" // 默认禁用SSL
|
||||
}
|
||||
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
|
||||
}
|
||||
88
internal/database/db.go
Normal file
88
internal/database/db.go
Normal file
@ -0,0 +1,88 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"weatherstation/internal/config"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
var (
|
||||
instance *sql.DB
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetDB 返回数据库连接单例
|
||||
func GetDB() *sql.DB {
|
||||
once.Do(func() {
|
||||
cfg := config.GetConfig()
|
||||
connStr := fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
cfg.Database.Host,
|
||||
cfg.Database.Port,
|
||||
cfg.Database.User,
|
||||
cfg.Database.Password,
|
||||
cfg.Database.DBName,
|
||||
cfg.Database.SSLMode,
|
||||
)
|
||||
|
||||
var err error
|
||||
instance, err = sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("无法连接到数据库: %v", err))
|
||||
}
|
||||
|
||||
if err = instance.Ping(); err != nil {
|
||||
panic(fmt.Sprintf("数据库连接测试失败: %v", err))
|
||||
}
|
||||
})
|
||||
return instance
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func Close() error {
|
||||
if instance != nil {
|
||||
return instance.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------- MySQL 连接(rtk_data) --------------------
|
||||
|
||||
var (
|
||||
mysqlInstance *sql.DB
|
||||
mysqlOnce sync.Once
|
||||
)
|
||||
|
||||
// GetMySQL 返回 MySQL 连接单例(rtk_data)
|
||||
func GetMySQL() *sql.DB {
|
||||
mysqlOnce.Do(func() {
|
||||
cfg := config.GetConfig().MySQL
|
||||
var dsn string
|
||||
if cfg.Params != "" {
|
||||
dsn = fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName, cfg.Params)
|
||||
} else {
|
||||
dsn = fmt.Sprintf("%s:%s@tcp(%s:%d)/%s", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
|
||||
}
|
||||
var err error
|
||||
mysqlInstance, err = sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("无法连接到 MySQL: %v", err))
|
||||
}
|
||||
if err = mysqlInstance.Ping(); err != nil {
|
||||
panic(fmt.Sprintf("MySQL 连接测试失败: %v", err))
|
||||
}
|
||||
})
|
||||
return mysqlInstance
|
||||
}
|
||||
|
||||
// CloseMySQL 关闭 MySQL 连接
|
||||
func CloseMySQL() error {
|
||||
if mysqlInstance != nil {
|
||||
return mysqlInstance.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
444
internal/database/models.go
Normal file
444
internal/database/models.go
Normal file
@ -0,0 +1,444 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
"weatherstation/pkg/types"
|
||||
)
|
||||
|
||||
// GetOnlineDevicesCount 获取在线设备数量
|
||||
func GetOnlineDevicesCount(db *sql.DB) int {
|
||||
query := `
|
||||
SELECT COUNT(DISTINCT station_id)
|
||||
FROM rs485_weather_data
|
||||
WHERE timestamp > NOW() - INTERVAL '5 minutes'`
|
||||
|
||||
var count int
|
||||
if err := db.QueryRow(query).Scan(&count); err != nil {
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GetStations 获取所有WH65LP站点列表
|
||||
func GetStations(db *sql.DB) ([]types.Station, error) {
|
||||
query := `
|
||||
SELECT DISTINCT s.station_id,
|
||||
COALESCE(s.station_alias, '') as station_alias,
|
||||
COALESCE(s.password, '') as station_name,
|
||||
'WH65LP' as device_type,
|
||||
COALESCE(MAX(r.timestamp), '1970-01-01'::timestamp) as last_update,
|
||||
COALESCE(s.latitude, 0) as latitude,
|
||||
COALESCE(s.longitude, 0) as longitude,
|
||||
COALESCE(s.name, '') as name,
|
||||
COALESCE(s.location, '') as location,
|
||||
COALESCE(s.z, 0) as z,
|
||||
COALESCE(s.y, 0) as y,
|
||||
COALESCE(s.x, 0) as x
|
||||
FROM stations s
|
||||
LEFT JOIN rs485_weather_data r ON s.station_id = r.station_id
|
||||
WHERE s.station_id LIKE 'RS485-%'
|
||||
GROUP BY s.station_id, s.station_alias, s.password, s.latitude, s.longitude, s.name, s.location, s.z, s.y, s.x
|
||||
ORDER BY s.station_id`
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stations []types.Station
|
||||
for rows.Next() {
|
||||
var station types.Station
|
||||
var lastUpdate time.Time
|
||||
err := rows.Scan(
|
||||
&station.StationID,
|
||||
&station.StationAlias,
|
||||
&station.StationName,
|
||||
&station.DeviceType,
|
||||
&lastUpdate,
|
||||
&station.Latitude,
|
||||
&station.Longitude,
|
||||
&station.Name,
|
||||
&station.Location,
|
||||
&station.Z,
|
||||
&station.Y,
|
||||
&station.X,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
station.LastUpdate = lastUpdate.Format("2006-01-02 15:04:05")
|
||||
stations = append(stations, station)
|
||||
}
|
||||
|
||||
return stations, nil
|
||||
}
|
||||
|
||||
// GetWeatherData 获取指定站点的历史天气数据
|
||||
func GetWeatherData(db *sql.DB, stationID string, startTime, endTime time.Time, interval string) ([]types.WeatherPoint, error) {
|
||||
// 构建查询SQL(统一风向矢量平均,雨量为累计量的正增量求和)
|
||||
var query string
|
||||
var intervalStr string
|
||||
switch interval {
|
||||
case "10min":
|
||||
intervalStr = "10 minutes"
|
||||
case "30min":
|
||||
intervalStr = "30 minutes"
|
||||
default: // 1hour
|
||||
intervalStr = "1 hour"
|
||||
}
|
||||
query = buildWeatherDataQuery(intervalStr)
|
||||
|
||||
rows, err := db.Query(query, intervalStr, stationID, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var points []types.WeatherPoint
|
||||
for rows.Next() {
|
||||
var point types.WeatherPoint
|
||||
err := rows.Scan(
|
||||
&point.DateTime,
|
||||
&point.Temperature,
|
||||
&point.Humidity,
|
||||
&point.Pressure,
|
||||
&point.WindSpeed,
|
||||
&point.WindDir,
|
||||
&point.Rainfall,
|
||||
&point.Light,
|
||||
&point.UV,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
points = append(points, point)
|
||||
}
|
||||
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// GetSeriesFrom10Min 基于10分钟聚合表返回 10m/30m/1h 数据(风向向量平均、降雨求和、加权平均)
|
||||
func GetSeriesFrom10Min(db *sql.DB, stationID string, startTime, endTime time.Time, interval string) ([]types.WeatherPoint, error) {
|
||||
log.Printf("查询数据: stationID=%s, start=%v, end=%v, interval=%s",
|
||||
stationID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), interval)
|
||||
|
||||
var query string
|
||||
switch interval {
|
||||
case "10min":
|
||||
query = `
|
||||
SELECT
|
||||
to_char(bucket_start + interval '10 minutes', 'YYYY-MM-DD HH24:MI:SS') AS date_time,
|
||||
ROUND(temp_c_x100/100.0, 2) AS temperature,
|
||||
ROUND(humidity_pct::numeric, 2) AS humidity,
|
||||
ROUND(pressure_hpa_x100/100.0, 2) AS pressure,
|
||||
ROUND(wind_speed_ms_x1000/1000.0, 3) AS wind_speed,
|
||||
ROUND(wind_dir_deg::numeric, 2) AS wind_direction,
|
||||
ROUND(rain_10m_mm_x1000/1000.0, 3) AS rainfall,
|
||||
ROUND(solar_wm2_x100/100.0, 2) AS light,
|
||||
ROUND(uv_index::numeric, 2) AS uv,
|
||||
ROUND(rain_total_mm_x1000/1000.0, 3) AS rain_total
|
||||
FROM rs485_weather_10min
|
||||
WHERE station_id = $1 AND bucket_start >= $2 AND bucket_start <= $3
|
||||
ORDER BY bucket_start + interval '10 minutes'`
|
||||
case "30min":
|
||||
query = buildAggFrom10MinQuery("30 minutes")
|
||||
default: // 1hour
|
||||
query = buildAggFrom10MinQuery("1 hour")
|
||||
}
|
||||
|
||||
// // 调试输出完整SQL
|
||||
// debugSQL := fmt.Sprintf("-- SQL for %s\n%s\n-- Params: stationID=%s, start=%v, end=%v",
|
||||
// interval, query, stationID, startTime, endTime)
|
||||
// log.Println(debugSQL)
|
||||
|
||||
rows, err := db.Query(query, stationID, startTime, endTime)
|
||||
if err != nil {
|
||||
log.Printf("查询失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var points []types.WeatherPoint
|
||||
for rows.Next() {
|
||||
var p types.WeatherPoint
|
||||
if err := rows.Scan(&p.DateTime, &p.Temperature, &p.Humidity, &p.Pressure, &p.WindSpeed, &p.WindDir, &p.Rainfall, &p.Light, &p.UV, &p.RainTotal); err != nil {
|
||||
continue
|
||||
}
|
||||
points = append(points, p)
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// buildAggFrom10MinQuery 返回从10分钟表再聚合的SQL(interval 支持 '30 minutes' 或 '1 hour')
|
||||
func buildAggFrom10MinQuery(interval string) string {
|
||||
return `
|
||||
WITH base AS (
|
||||
SELECT * FROM rs485_weather_10min
|
||||
WHERE station_id = $1 AND bucket_start >= $2 AND bucket_start <= $3
|
||||
), g AS (
|
||||
SELECT
|
||||
CASE '` + interval + `'
|
||||
WHEN '1 hour' THEN date_trunc('hour', bucket_start)
|
||||
WHEN '30 minutes' THEN
|
||||
date_trunc('hour', bucket_start) +
|
||||
CASE WHEN date_part('minute', bucket_start) >= 30
|
||||
THEN '30 minutes'::interval
|
||||
ELSE '0 minutes'::interval
|
||||
END
|
||||
END AS grp,
|
||||
SUM(temp_c_x100 * sample_count)::bigint AS w_temp,
|
||||
SUM(humidity_pct * sample_count)::bigint AS w_hum,
|
||||
SUM(pressure_hpa_x100 * sample_count)::bigint AS w_p,
|
||||
SUM(solar_wm2_x100 * sample_count)::bigint AS w_solar,
|
||||
SUM(uv_index * sample_count)::bigint AS w_uv,
|
||||
SUM(wind_speed_ms_x1000 * sample_count)::bigint AS w_ws,
|
||||
MAX(wind_gust_ms_x1000) AS gust_max,
|
||||
SUM(sin(radians(wind_dir_deg)) * sample_count)::double precision AS sin_sum,
|
||||
SUM(cos(radians(wind_dir_deg)) * sample_count)::double precision AS cos_sum,
|
||||
SUM(rain_10m_mm_x1000) AS rain_sum,
|
||||
SUM(sample_count) AS n_sum,
|
||||
MAX(rain_total_mm_x1000) AS rain_total_max
|
||||
FROM base
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
to_char(grp + '` + interval + `'::interval, 'YYYY-MM-DD HH24:MI:SS') AS date_time,
|
||||
ROUND((w_temp/NULLIF(n_sum,0))/100.0, 2) AS temperature,
|
||||
ROUND((w_hum/NULLIF(n_sum,0))::numeric, 2) AS humidity,
|
||||
ROUND((w_p/NULLIF(n_sum,0))/100.0, 2) AS pressure,
|
||||
ROUND((w_ws/NULLIF(n_sum,0))/1000.0, 3) AS wind_speed,
|
||||
ROUND((CASE WHEN degrees(atan2(sin_sum, cos_sum)) < 0
|
||||
THEN degrees(atan2(sin_sum, cos_sum)) + 360
|
||||
ELSE degrees(atan2(sin_sum, cos_sum)) END)::numeric, 2) AS wind_direction,
|
||||
ROUND((rain_sum/1000.0)::numeric, 3) AS rainfall,
|
||||
ROUND((w_solar/NULLIF(n_sum,0))/100.0, 2) AS light,
|
||||
ROUND((w_uv/NULLIF(n_sum,0))::numeric, 2) AS uv,
|
||||
ROUND((rain_total_max/1000.0)::numeric, 3) AS rain_total
|
||||
FROM g
|
||||
ORDER BY grp + '` + interval + `'::interval`
|
||||
}
|
||||
|
||||
// buildWeatherDataQuery 构建天气数据查询SQL
|
||||
func buildWeatherDataQuery(interval string) string {
|
||||
return `
|
||||
WITH base AS (
|
||||
SELECT
|
||||
date_trunc('hour', timestamp) +
|
||||
(floor(date_part('minute', timestamp) / extract(epoch from $1::interval) * 60) * $1::interval) as time_group,
|
||||
timestamp as ts,
|
||||
temperature, humidity, pressure, wind_speed, wind_direction, rainfall, light, uv
|
||||
FROM rs485_weather_data
|
||||
WHERE station_id = $2 AND timestamp BETWEEN $3 AND $4
|
||||
),
|
||||
rain_inc AS (
|
||||
SELECT time_group, GREATEST(rainfall - LAG(rainfall) OVER (PARTITION BY time_group ORDER BY ts), 0) AS inc
|
||||
FROM base
|
||||
),
|
||||
rain_sum AS (
|
||||
SELECT time_group, SUM(inc) AS rainfall
|
||||
FROM rain_inc
|
||||
GROUP BY time_group
|
||||
),
|
||||
grouped_data AS (
|
||||
SELECT
|
||||
time_group,
|
||||
AVG(temperature) as temperature,
|
||||
AVG(humidity) as humidity,
|
||||
AVG(pressure) as pressure,
|
||||
AVG(wind_speed) as wind_speed,
|
||||
DEGREES(ATAN2(AVG(SIN(RADIANS(wind_direction))), AVG(COS(RADIANS(wind_direction))))) AS wind_direction_raw,
|
||||
AVG(light) as light,
|
||||
AVG(uv) as uv
|
||||
FROM base
|
||||
GROUP BY time_group
|
||||
)
|
||||
SELECT
|
||||
to_char(g.time_group, 'YYYY-MM-DD HH24:MI:SS') as date_time,
|
||||
ROUND(g.temperature::numeric, 2) as temperature,
|
||||
ROUND(g.humidity::numeric, 2) as humidity,
|
||||
ROUND(g.pressure::numeric, 2) as pressure,
|
||||
ROUND(g.wind_speed::numeric, 2) as wind_speed,
|
||||
ROUND((CASE WHEN g.wind_direction_raw < 0 THEN g.wind_direction_raw + 360 ELSE g.wind_direction_raw END)::numeric, 2) AS wind_direction,
|
||||
ROUND(COALESCE(r.rainfall, 0)::numeric, 3) as rainfall,
|
||||
ROUND(g.light::numeric, 2) as light,
|
||||
ROUND(g.uv::numeric, 2) as uv
|
||||
FROM grouped_data g
|
||||
LEFT JOIN rain_sum r ON r.time_group = g.time_group
|
||||
ORDER BY g.time_group`
|
||||
}
|
||||
|
||||
// GetForecastData 获取指定站点的预报数据(支持返回每个forecast_time的多版本issued_at)
|
||||
func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time, provider string, versions int) ([]types.ForecastPoint, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if versions <= 0 {
|
||||
versions = 1
|
||||
}
|
||||
|
||||
if provider != "" {
|
||||
if provider == "open-meteo" {
|
||||
// 合并实时与历史,按 issued_at 降序为每个 forecast_time 取前 N 个版本
|
||||
query = `
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
station_id, provider, issued_at, forecast_time,
|
||||
temp_c_x100, humidity_pct, wind_speed_ms_x1000, wind_gust_ms_x1000,
|
||||
wind_dir_deg, rain_mm_x1000, precip_prob_pct, uv_index, pressure_hpa_x100,
|
||||
ROW_NUMBER() OVER (PARTITION BY forecast_time ORDER BY issued_at DESC) AS rn,
|
||||
CEIL(EXTRACT(EPOCH FROM (forecast_time - issued_at)) / 3600.0)::int AS lead_hours
|
||||
FROM forecast_hourly
|
||||
WHERE station_id = $1 AND provider IN ('open-meteo','open-meteo_historical')
|
||||
AND forecast_time BETWEEN $2 AND $3
|
||||
)
|
||||
SELECT
|
||||
to_char(forecast_time, 'YYYY-MM-DD HH24:MI:SS') as date_time,
|
||||
provider,
|
||||
to_char(issued_at, 'YYYY-MM-DD HH24:MI:SS') as issued_at,
|
||||
ROUND(temp_c_x100::numeric / 100.0, 2) as temperature,
|
||||
humidity_pct as humidity,
|
||||
ROUND(pressure_hpa_x100::numeric / 100.0, 2) as pressure,
|
||||
ROUND(wind_speed_ms_x1000::numeric / 1000.0, 2) as wind_speed,
|
||||
wind_dir_deg as wind_direction,
|
||||
ROUND(rain_mm_x1000::numeric / 1000.0, 3) as rainfall,
|
||||
precip_prob_pct as precip_prob,
|
||||
uv_index as uv,
|
||||
lead_hours
|
||||
FROM ranked
|
||||
WHERE rn <= $4
|
||||
ORDER BY forecast_time, issued_at DESC`
|
||||
args = []interface{}{stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07"), versions}
|
||||
} else {
|
||||
query = `
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
station_id, provider, issued_at, forecast_time,
|
||||
temp_c_x100, humidity_pct, wind_speed_ms_x1000, wind_gust_ms_x1000,
|
||||
wind_dir_deg, rain_mm_x1000, precip_prob_pct, uv_index, pressure_hpa_x100,
|
||||
ROW_NUMBER() OVER (PARTITION BY forecast_time ORDER BY issued_at DESC) AS rn,
|
||||
CEIL(EXTRACT(EPOCH FROM (forecast_time - issued_at)) / 3600.0)::int AS lead_hours
|
||||
FROM forecast_hourly
|
||||
WHERE station_id = $1 AND provider = $2
|
||||
AND forecast_time BETWEEN $3 AND $4
|
||||
)
|
||||
SELECT
|
||||
to_char(forecast_time, 'YYYY-MM-DD HH24:MI:SS') as date_time,
|
||||
provider,
|
||||
to_char(issued_at, 'YYYY-MM-DD HH24:MI:SS') as issued_at,
|
||||
ROUND(temp_c_x100::numeric / 100.0, 2) as temperature,
|
||||
humidity_pct as humidity,
|
||||
ROUND(pressure_hpa_x100::numeric / 100.0, 2) as pressure,
|
||||
ROUND(wind_speed_ms_x1000::numeric / 1000.0, 2) as wind_speed,
|
||||
wind_dir_deg as wind_direction,
|
||||
ROUND(rain_mm_x1000::numeric / 1000.0, 3) as rainfall,
|
||||
precip_prob_pct as precip_prob,
|
||||
uv_index as uv,
|
||||
lead_hours
|
||||
FROM ranked
|
||||
WHERE rn <= $5
|
||||
ORDER BY forecast_time, issued_at DESC`
|
||||
args = []interface{}{stationID, provider, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07"), versions}
|
||||
}
|
||||
} else {
|
||||
// 不指定预报提供商:对每个 provider,forecast_time 返回前 N 个 issued_at 版本
|
||||
query = `
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
station_id, provider, issued_at, forecast_time,
|
||||
temp_c_x100, humidity_pct, wind_speed_ms_x1000, wind_gust_ms_x1000,
|
||||
wind_dir_deg, rain_mm_x1000, precip_prob_pct, uv_index, pressure_hpa_x100,
|
||||
ROW_NUMBER() OVER (PARTITION BY provider, forecast_time ORDER BY issued_at DESC) AS rn,
|
||||
CEIL(EXTRACT(EPOCH FROM (forecast_time - issued_at)) / 3600.0)::int AS lead_hours
|
||||
FROM forecast_hourly
|
||||
WHERE station_id = $1 AND forecast_time BETWEEN $2 AND $3
|
||||
)
|
||||
SELECT
|
||||
to_char(forecast_time, 'YYYY-MM-DD HH24:MI:SS') as date_time,
|
||||
provider,
|
||||
to_char(issued_at, 'YYYY-MM-DD HH24:MI:SS') as issued_at,
|
||||
ROUND(temp_c_x100::numeric / 100.0, 2) as temperature,
|
||||
humidity_pct as humidity,
|
||||
ROUND(pressure_hpa_x100::numeric / 100.0, 2) as pressure,
|
||||
ROUND(wind_speed_ms_x1000::numeric / 1000.0, 2) as wind_speed,
|
||||
wind_dir_deg as wind_direction,
|
||||
ROUND(rain_mm_x1000::numeric / 1000.0, 3) as rainfall,
|
||||
precip_prob_pct as precip_prob,
|
||||
uv_index as uv,
|
||||
lead_hours
|
||||
FROM ranked
|
||||
WHERE rn <= $4
|
||||
ORDER BY forecast_time, provider, issued_at DESC`
|
||||
args = []interface{}{stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07"), versions}
|
||||
}
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询预报数据失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var points []types.ForecastPoint
|
||||
for rows.Next() {
|
||||
var point types.ForecastPoint
|
||||
err := rows.Scan(
|
||||
&point.DateTime,
|
||||
&point.Provider,
|
||||
&point.IssuedAt,
|
||||
&point.Temperature,
|
||||
&point.Humidity,
|
||||
&point.Pressure,
|
||||
&point.WindSpeed,
|
||||
&point.WindDir,
|
||||
&point.Rainfall,
|
||||
&point.PrecipProb,
|
||||
&point.UV,
|
||||
&point.LeadHours,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("数据扫描错误: %v", err)
|
||||
continue
|
||||
}
|
||||
point.Source = "forecast"
|
||||
points = append(points, point)
|
||||
}
|
||||
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func GetSeriesRaw(db *sql.DB, stationID string, startTime, endTime time.Time) ([]types.WeatherPoint, error) {
|
||||
query := `
|
||||
SELECT
|
||||
to_char(timestamp, 'YYYY-MM-DD HH24:MI:SS') AS date_time,
|
||||
COALESCE(temperature, 0) AS temperature,
|
||||
COALESCE(humidity, 0) AS humidity,
|
||||
COALESCE(pressure, 0) AS pressure,
|
||||
COALESCE(wind_speed, 0) AS wind_speed,
|
||||
COALESCE(wind_direction, 0) AS wind_direction,
|
||||
COALESCE(rainfall, 0) AS rainfall,
|
||||
COALESCE(light, 0) AS light,
|
||||
COALESCE(uv, 0) AS uv,
|
||||
COALESCE(rainfall, 0) AS rain_total
|
||||
FROM rs485_weather_data
|
||||
WHERE station_id = $1 AND timestamp >= $2 AND timestamp <= $3
|
||||
ORDER BY timestamp`
|
||||
|
||||
rows, err := db.Query(query, stationID, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var points []types.WeatherPoint
|
||||
for rows.Next() {
|
||||
var p types.WeatherPoint
|
||||
if err := rows.Scan(&p.DateTime, &p.Temperature, &p.Humidity, &p.Pressure, &p.WindSpeed, &p.WindDir, &p.Rainfall, &p.Light, &p.UV, &p.RainTotal); err != nil {
|
||||
continue
|
||||
}
|
||||
points = append(points, p)
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
78
internal/database/radar_tiles.go
Normal file
78
internal/database/radar_tiles.go
Normal file
@ -0,0 +1,78 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UpsertRadarTile stores a radar tile into table `radar_tiles`.
|
||||
// Assumes the table exists with schema compatible to columns used below.
|
||||
func UpsertRadarTile(ctx context.Context, db *sql.DB, product string, dt time.Time, z, y, x int, width, height int, data []byte) error {
|
||||
if width == 0 {
|
||||
width = 256
|
||||
}
|
||||
if height == 0 {
|
||||
height = 256
|
||||
}
|
||||
step := 360.0 / math.Pow(2, float64(z))
|
||||
west := -180.0 + float64(x)*step
|
||||
south := -90.0 + float64(y)*step
|
||||
east := west + step
|
||||
north := south + step
|
||||
res := step / float64(width)
|
||||
|
||||
sum := md5.Sum(data)
|
||||
md5hex := hex.EncodeToString(sum[:])
|
||||
|
||||
q := `
|
||||
INSERT INTO radar_tiles (
|
||||
product, dt, z, y, x, width, height,
|
||||
west, south, east, north, res_deg,
|
||||
data, checksum_md5
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,
|
||||
$8,$9,$10,$11,$12,
|
||||
$13,$14
|
||||
)
|
||||
ON CONFLICT (product, dt, z, y, x)
|
||||
DO UPDATE SET
|
||||
width = EXCLUDED.width,
|
||||
height = EXCLUDED.height,
|
||||
west = EXCLUDED.west,
|
||||
south = EXCLUDED.south,
|
||||
east = EXCLUDED.east,
|
||||
north = EXCLUDED.north,
|
||||
res_deg = EXCLUDED.res_deg,
|
||||
data = EXCLUDED.data,
|
||||
checksum_md5 = EXCLUDED.checksum_md5`
|
||||
|
||||
_, err := db.ExecContext(ctx, q,
|
||||
product, dt, z, y, x, width, height,
|
||||
west, south, east, north, res,
|
||||
data, md5hex,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert radar tile (%s %s z=%d y=%d x=%d): %w", product, dt.Format(time.RFC3339), z, y, x, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasRadarTile reports whether a radar tile exists for the given key.
|
||||
// It checks by (product, dt, z, y, x) in table `radar_tiles`.
|
||||
func HasRadarTile(ctx context.Context, db *sql.DB, product string, dt time.Time, z, y, x int) (bool, error) {
|
||||
const q = `SELECT 1 FROM radar_tiles WHERE product=$1 AND dt=$2 AND z=$3 AND y=$4 AND x=$5 LIMIT 1`
|
||||
var one int
|
||||
err := db.QueryRowContext(ctx, q, product, dt, z, y, x).Scan(&one)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check radar tile exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
70
internal/database/radar_weather.go
Normal file
70
internal/database/radar_weather.go
Normal file
@ -0,0 +1,70 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UpsertRadarWeather stores a realtime snapshot for a radar station.
|
||||
// Table schema (expected):
|
||||
//
|
||||
// CREATE TABLE IF NOT EXISTS radar_weather (
|
||||
// id SERIAL PRIMARY KEY,
|
||||
// alias TEXT NOT NULL,
|
||||
// lat DOUBLE PRECISION NOT NULL,
|
||||
// lon DOUBLE PRECISION NOT NULL,
|
||||
// dt TIMESTAMPTZ NOT NULL,
|
||||
// temperature DOUBLE PRECISION,
|
||||
// humidity DOUBLE PRECISION,
|
||||
// cloudrate DOUBLE PRECISION,
|
||||
// visibility DOUBLE PRECISION,
|
||||
// dswrf DOUBLE PRECISION,
|
||||
// wind_speed DOUBLE PRECISION,
|
||||
// wind_direction DOUBLE PRECISION,
|
||||
// pressure DOUBLE PRECISION,
|
||||
// created_at TIMESTAMPTZ DEFAULT now()
|
||||
// );
|
||||
// CREATE UNIQUE INDEX IF NOT EXISTS radar_weather_udx ON radar_weather(alias, dt);
|
||||
func UpsertRadarWeather(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
alias string,
|
||||
lat, lon float64,
|
||||
dt time.Time,
|
||||
temperature, humidity, cloudrate, visibility, dswrf, windSpeed, windDir, pressure float64,
|
||||
) error {
|
||||
const q = `
|
||||
INSERT INTO radar_weather (
|
||||
alias, lat, lon, dt,
|
||||
temperature, humidity, cloudrate, visibility, dswrf,
|
||||
wind_speed, wind_direction, pressure
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,
|
||||
$5,$6,$7,$8,$9,
|
||||
$10,$11,$12
|
||||
)
|
||||
ON CONFLICT (alias, dt)
|
||||
DO UPDATE SET
|
||||
lat = EXCLUDED.lat,
|
||||
lon = EXCLUDED.lon,
|
||||
temperature = EXCLUDED.temperature,
|
||||
humidity = EXCLUDED.humidity,
|
||||
cloudrate = EXCLUDED.cloudrate,
|
||||
visibility = EXCLUDED.visibility,
|
||||
dswrf = EXCLUDED.dswrf,
|
||||
wind_speed = EXCLUDED.wind_speed,
|
||||
wind_direction = EXCLUDED.wind_direction,
|
||||
pressure = EXCLUDED.pressure`
|
||||
|
||||
_, err := db.ExecContext(ctx, q,
|
||||
alias, lat, lon, dt,
|
||||
temperature, humidity, cloudrate, visibility, dswrf,
|
||||
windSpeed, windDir, pressure,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert radar_weather (%s %s): %w", alias, dt.Format(time.RFC3339), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
77
internal/database/rain_tiles.go
Normal file
77
internal/database/rain_tiles.go
Normal file
@ -0,0 +1,77 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UpsertRainTile stores a rain tile into table `rain_tiles`.
|
||||
// The tiling scheme is equal-angle EPSG:4326 like radar tiles.
|
||||
func UpsertRainTile(ctx context.Context, db *sql.DB, product string, dt time.Time, z, y, x int, width, height int, data []byte) error {
|
||||
if width == 0 {
|
||||
width = 256
|
||||
}
|
||||
if height == 0 {
|
||||
height = 256
|
||||
}
|
||||
step := 360.0 / math.Pow(2, float64(z))
|
||||
west := -180.0 + float64(x)*step
|
||||
south := -90.0 + float64(y)*step
|
||||
east := west + step
|
||||
north := south + step
|
||||
res := step / float64(width)
|
||||
|
||||
sum := md5.Sum(data)
|
||||
md5hex := hex.EncodeToString(sum[:])
|
||||
|
||||
q := `
|
||||
INSERT INTO rain_tiles (
|
||||
product, dt, z, y, x, width, height,
|
||||
west, south, east, north, res_deg,
|
||||
data, checksum_md5
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,
|
||||
$8,$9,$10,$11,$12,
|
||||
$13,$14
|
||||
)
|
||||
ON CONFLICT (product, dt, z, y, x)
|
||||
DO UPDATE SET
|
||||
width = EXCLUDED.width,
|
||||
height = EXCLUDED.height,
|
||||
west = EXCLUDED.west,
|
||||
south = EXCLUDED.south,
|
||||
east = EXCLUDED.east,
|
||||
north = EXCLUDED.north,
|
||||
res_deg = EXCLUDED.res_deg,
|
||||
data = EXCLUDED.data,
|
||||
checksum_md5 = EXCLUDED.checksum_md5`
|
||||
|
||||
_, err := db.ExecContext(ctx, q,
|
||||
product, dt, z, y, x, width, height,
|
||||
west, south, east, north, res,
|
||||
data, md5hex,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert rain tile (%s %s z=%d y=%d x=%d): %w", product, dt.Format(time.RFC3339), z, y, x, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasRainTile reports whether a rain tile exists for the given key.
|
||||
func HasRainTile(ctx context.Context, db *sql.DB, product string, dt time.Time, z, y, x int) (bool, error) {
|
||||
const q = `SELECT 1 FROM rain_tiles WHERE product=$1 AND dt=$2 AND z=$3 AND y=$4 AND x=$5 LIMIT 1`
|
||||
var one int
|
||||
err := db.QueryRowContext(ctx, q, product, dt, z, y, x).Scan(&one)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check rain tile exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
38
internal/database/stations_coords.go
Normal file
38
internal/database/stations_coords.go
Normal file
@ -0,0 +1,38 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// StationCoord holds a station_id with geographic coordinates.
|
||||
type StationCoord struct {
|
||||
StationID string
|
||||
Lat float64
|
||||
Lon float64
|
||||
}
|
||||
|
||||
// ListWH65LPStationsWithLatLon returns WH65LP stations that have non-null and non-zero lat/lon.
|
||||
func ListWH65LPStationsWithLatLon(ctx context.Context, db *sql.DB) ([]StationCoord, error) {
|
||||
const q = `
|
||||
SELECT station_id, latitude, longitude
|
||||
FROM stations
|
||||
WHERE device_type = 'WH65LP'
|
||||
AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
AND latitude <> 0 AND longitude <> 0
|
||||
ORDER BY station_id`
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []StationCoord
|
||||
for rows.Next() {
|
||||
var s StationCoord
|
||||
if err := rows.Scan(&s.StationID, &s.Lat, &s.Lon); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
44
internal/database/weights.go
Normal file
44
internal/database/weights.go
Normal file
@ -0,0 +1,44 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Triad struct {
|
||||
OpenMeteo float64
|
||||
Caiyun float64
|
||||
Imdroid float64
|
||||
}
|
||||
|
||||
// GetWeightsCurrent returns the last saved triad and its issued_at for a station.
|
||||
// If no row exists, returns ok=false.
|
||||
func GetWeightsCurrent(ctx context.Context, stationID string) (triad Triad, lastIssued time.Time, ok bool, err error) {
|
||||
db := GetDB()
|
||||
row := db.QueryRowContext(ctx, `
|
||||
SELECT w_open_meteo, w_caiyun, w_imdroid, last_issued_at
|
||||
FROM forecast_weights_current
|
||||
WHERE station_id=$1`, stationID)
|
||||
var w1, w2, w3 float64
|
||||
var li time.Time
|
||||
if e := row.Scan(&w1, &w2, &w3, &li); e != nil {
|
||||
return Triad{}, time.Time{}, false, nil
|
||||
}
|
||||
return Triad{OpenMeteo: w1, Caiyun: w2, Imdroid: w3}, li, true, nil
|
||||
}
|
||||
|
||||
// UpsertWeightsCurrent saves the triad snapshot for the station.
|
||||
func UpsertWeightsCurrent(ctx context.Context, stationID string, triad Triad, issued time.Time) error {
|
||||
db := GetDB()
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO forecast_weights_current (station_id, w_open_meteo, w_caiyun, w_imdroid, last_issued_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5, NOW())
|
||||
ON CONFLICT (station_id)
|
||||
DO UPDATE SET w_open_meteo=EXCLUDED.w_open_meteo,
|
||||
w_caiyun=EXCLUDED.w_caiyun,
|
||||
w_imdroid=EXCLUDED.w_imdroid,
|
||||
last_issued_at=EXCLUDED.last_issued_at,
|
||||
updated_at=NOW()`,
|
||||
stationID, triad.OpenMeteo, triad.Caiyun, triad.Imdroid, issued)
|
||||
return err
|
||||
}
|
||||
219
internal/forecast/caiyun.go
Normal file
219
internal/forecast/caiyun.go
Normal file
@ -0,0 +1,219 @@
|
||||
package forecast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
// 彩云返回结构(仅取用需要的字段)
|
||||
type caiyunHourly struct {
|
||||
Status string `json:"status"`
|
||||
Result struct {
|
||||
Hourly struct {
|
||||
Status string `json:"status"`
|
||||
Temperature []struct {
|
||||
Datetime string `json:"datetime"`
|
||||
Value float64 `json:"value"`
|
||||
} `json:"temperature"`
|
||||
Humidity []struct {
|
||||
Datetime string `json:"datetime"`
|
||||
Value float64 `json:"value"`
|
||||
} `json:"humidity"`
|
||||
Wind []struct {
|
||||
Datetime string `json:"datetime"`
|
||||
Speed float64 `json:"speed"`
|
||||
Direction float64 `json:"direction"`
|
||||
} `json:"wind"`
|
||||
Precipitation []struct {
|
||||
Datetime string `json:"datetime"`
|
||||
Value float64 `json:"value"`
|
||||
Probability float64 `json:"probability"`
|
||||
} `json:"precipitation"`
|
||||
Pressure []struct {
|
||||
Datetime string `json:"datetime"`
|
||||
Value float64 `json:"value"`
|
||||
} `json:"pressure"`
|
||||
} `json:"hourly"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
// RunCaiyunFetch 拉取各站点未来三小时并写入 forecast_hourly(provider=caiyun)
|
||||
func RunCaiyunFetch(ctx context.Context, token string) error {
|
||||
log.Printf("彩云抓取开始,token=%s", token)
|
||||
db := database.GetDB()
|
||||
stations, err := loadStationsWithLatLon(ctx, db)
|
||||
if err != nil {
|
||||
log.Printf("加载站点失败: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("找到 %d 个有经纬度的站点", len(stations))
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
|
||||
issuedAt := time.Now().In(loc)
|
||||
startHour := issuedAt.Truncate(time.Hour)
|
||||
// 彩云小时接口返回的是“左端点”时刻(例如 13:00 表示 13:00-14:00 区间)。
|
||||
// 我们将左端点列表保留为 startHour, startHour+1h, startHour+2h,并在写库时统一 +1h
|
||||
// 使得 forecast_time 表示区间右端,与实测聚合对齐。
|
||||
leftEdges := []time.Time{startHour, startHour.Add(1 * time.Hour), startHour.Add(2 * time.Hour)}
|
||||
|
||||
for _, s := range stations {
|
||||
if !s.lat.Valid || !s.lon.Valid {
|
||||
continue
|
||||
}
|
||||
url := fmt.Sprintf("https://api.caiyunapp.com/v2.6/%s/%f,%f/hourly?hourlysteps=4&unit=metric:v2", token, s.lon.Float64, s.lat.Float64)
|
||||
log.Printf("请求彩云 API: %s", url)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("caiyun 请求失败 station=%s err=%v", s.id, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("彩云响应状态码: %d", resp.StatusCode)
|
||||
var data caiyunHourly
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("彩云响应内容: %s", string(body))
|
||||
resp.Body.Close()
|
||||
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
log.Printf("caiyun 解码失败 station=%s err=%v", s.id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("彩云响应解析: status=%s", data.Status)
|
||||
|
||||
// 彩云时间戳形式例如 2022-05-26T16:00+08:00,需按CST解析
|
||||
// 建立 time->vals 映射
|
||||
table := map[time.Time]struct {
|
||||
rain float64
|
||||
temp float64
|
||||
rh float64
|
||||
ws float64
|
||||
wdir float64
|
||||
prob float64
|
||||
pres float64
|
||||
}{}
|
||||
|
||||
// 温度 ℃
|
||||
for _, t := range data.Result.Hourly.Temperature {
|
||||
log.Printf("解析时间: %s", t.Datetime)
|
||||
if ft, err := time.ParseInLocation("2006-01-02T15:04-07:00", t.Datetime, loc); err == nil {
|
||||
log.Printf("解析结果: %v", ft)
|
||||
v := table[ft]
|
||||
v.temp = t.Value
|
||||
table[ft] = v
|
||||
} else {
|
||||
log.Printf("时间解析失败: %v", err)
|
||||
}
|
||||
}
|
||||
// 湿度 比例(0..1) 转换为 %
|
||||
for _, h := range data.Result.Hourly.Humidity {
|
||||
if ft, err := time.ParseInLocation("2006-01-02T15:04-07:00", h.Datetime, loc); err == nil {
|
||||
v := table[ft]
|
||||
v.rh = h.Value * 100.0
|
||||
table[ft] = v
|
||||
}
|
||||
}
|
||||
// 风:metric:v2速度为km/h,这里转换为m/s;方向为度
|
||||
for _, w := range data.Result.Hourly.Wind {
|
||||
if ft, err := time.ParseInLocation("2006-01-02T15:04-07:00", w.Datetime, loc); err == nil {
|
||||
v := table[ft]
|
||||
v.ws = w.Speed / 3.6
|
||||
v.wdir = w.Direction
|
||||
table[ft] = v
|
||||
}
|
||||
}
|
||||
// 降水 该小时量 mm,概率 0..1 → %
|
||||
for _, p := range data.Result.Hourly.Precipitation {
|
||||
if ft, err := time.ParseInLocation("2006-01-02T15:04-07:00", p.Datetime, loc); err == nil {
|
||||
v := table[ft]
|
||||
v.rain = p.Value
|
||||
// 直接使用API返回的概率值,只进行范围限制
|
||||
prob := p.Probability
|
||||
// 四舍五入并确保在0-100范围内
|
||||
prob = math.Round(prob)
|
||||
if prob < 0 {
|
||||
prob = 0
|
||||
}
|
||||
if prob > 100 {
|
||||
prob = 100
|
||||
}
|
||||
v.prob = prob
|
||||
table[ft] = v
|
||||
}
|
||||
}
|
||||
// 气压:单位为 Pa,转换为 hPa(Pa/100)
|
||||
for _, pr := range data.Result.Hourly.Pressure {
|
||||
if ft, err := time.ParseInLocation("2006-01-02T15:04-07:00", pr.Datetime, loc); err == nil {
|
||||
v := table[ft]
|
||||
v.pres = pr.Value / 100.0
|
||||
table[ft] = v
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("处理时间点(彩云左端): %v", leftEdges)
|
||||
for _, left := range leftEdges {
|
||||
v, ok := table[left]
|
||||
if !ok {
|
||||
log.Printf("时间点无数据: %s", left.Format(time.RFC3339))
|
||||
continue
|
||||
}
|
||||
ft := left.Add(1 * time.Hour)
|
||||
log.Printf("写入预报点: station=%s forecast_time=%s (source=%s) rain=%.3f temp=%.2f rh=%.1f ws=%.3f wdir=%.1f prob=%.1f pres=%.2f",
|
||||
s.id, ft.Format(time.RFC3339), left.Format(time.RFC3339), v.rain, v.temp, v.rh, v.ws, v.wdir, v.prob, v.pres)
|
||||
err := upsertForecastCaiyun(ctx, db, s.id, issuedAt, ft,
|
||||
int64(v.rain*1000.0), // mm → x1000
|
||||
int64(v.temp*100.0), // °C → x100
|
||||
int64(v.rh), // %
|
||||
int64(v.ws*1000.0), // m/s → x1000
|
||||
int64(0), // gust: 彩云小时接口无阵风,置0
|
||||
int64(v.wdir), // 度
|
||||
int64(v.prob), // %
|
||||
int64(v.pres*100.0), // hPa → x100
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("写入forecast失败(caiyun) station=%s time=%s err=%v", s.id, ft.Format(time.RFC3339), err)
|
||||
} else {
|
||||
log.Printf("写入forecast成功(caiyun) station=%s time=%s", s.id, ft.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertForecastCaiyun(ctx context.Context, db *sql.DB, stationID string, issuedAt, forecastTime time.Time,
|
||||
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100 int64,
|
||||
) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO forecast_hourly (
|
||||
station_id, provider, issued_at, forecast_time,
|
||||
rain_mm_x1000, temp_c_x100, humidity_pct, wind_speed_ms_x1000,
|
||||
wind_gust_ms_x1000, wind_dir_deg, precip_prob_pct, pressure_hpa_x100
|
||||
) VALUES ($1, 'caiyun', $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (station_id, provider, issued_at, forecast_time)
|
||||
DO UPDATE SET
|
||||
rain_mm_x1000 = EXCLUDED.rain_mm_x1000,
|
||||
temp_c_x100 = EXCLUDED.temp_c_x100,
|
||||
humidity_pct = EXCLUDED.humidity_pct,
|
||||
wind_speed_ms_x1000 = EXCLUDED.wind_speed_ms_x1000,
|
||||
wind_gust_ms_x1000 = EXCLUDED.wind_gust_ms_x1000,
|
||||
wind_dir_deg = EXCLUDED.wind_dir_deg,
|
||||
precip_prob_pct = EXCLUDED.precip_prob_pct,
|
||||
pressure_hpa_x100 = EXCLUDED.pressure_hpa_x100
|
||||
`, stationID, issuedAt, forecastTime,
|
||||
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100)
|
||||
return err
|
||||
}
|
||||
406
internal/forecast/cma.go
Normal file
406
internal/forecast/cma.go
Normal file
@ -0,0 +1,406 @@
|
||||
package forecast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
// RunCMACLI 调用中国气象数据网 getSKStationInfo 接口,
|
||||
// 以固定参数获取数据,并在控制台输出“当前时间的未来三小时”(整点)的要素值。
|
||||
// 仅打印,不写库。
|
||||
func RunCMACLI(ctx context.Context) error {
|
||||
// 固定参数(可按需调整)
|
||||
staID := "59238"
|
||||
funitem := "1150101020"
|
||||
typeCode := "NWST"
|
||||
|
||||
// 目标时间:默认从“下一个整点”开始,但后续会根据接口可用时次选择“接下来可用的3个未来时次”
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
base := now.Truncate(time.Hour)
|
||||
|
||||
// 构造请求
|
||||
form := url.Values{}
|
||||
form.Set("staId", staID)
|
||||
form.Set("funitemmenuid", funitem)
|
||||
form.Set("typeCode", typeCode)
|
||||
|
||||
// 使用 getStationInfo(包含更长时段数据,含未来小时)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://data.cma.cn/dataGis/exhibitionData/getStationInfo", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", "https://data.cma.cn/dataGis/static/gridgis/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("CMA 响应状态码异常: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 响应结构:hover 字段标注各要素,list 为多要素时间序列
|
||||
var payload struct {
|
||||
Hover string `json:"hover"`
|
||||
List [][][]interface{} `json:"list"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解析 hover,确定各要素索引;若 hover 异常则回退为固定顺序:0雨,1温,2湿,3压,4风
|
||||
labels := strings.Split(payload.Hover, ",")
|
||||
useHover := len(labels) == len(payload.List) && len(payload.List) >= 4
|
||||
idxRain, idxTemp, idxRHU, idxPRS, idxWIN := 0, 1, 2, 3, 4
|
||||
if useHover {
|
||||
r := findLabelIndex(labels, []string{"PRE_1h", "PRE"})
|
||||
t := findLabelIndex(labels, []string{"TEM", "temperature"})
|
||||
h := findLabelIndex(labels, []string{"RHU", "humidity"})
|
||||
p := findLabelIndex(labels, []string{"PRS", "pressure"})
|
||||
w := findLabelIndex(labels, []string{"WIN_S_Avg_10mi", "WIN_SN_S", "WIN_S", "wind"})
|
||||
if r >= 0 && t >= 0 && h >= 0 && w >= 0 { // PRS 可能为空表,索引允许>=0
|
||||
idxRain, idxTemp, idxRHU, idxPRS, idxWIN = r, t, h, p, w
|
||||
}
|
||||
}
|
||||
|
||||
// 构建时间 -> 值 的查找表(按 Asia/Shanghai 解析时间戳 YYYYMMDDHHMMSS)
|
||||
// 每个要素一张表
|
||||
tables := make([]map[time.Time]float64, len(payload.List))
|
||||
for i := range payload.List {
|
||||
tables[i] = map[time.Time]float64{}
|
||||
for _, pair := range payload.List[i] {
|
||||
if len(pair) != 2 {
|
||||
continue
|
||||
}
|
||||
ts, _ := pair[0].(string)
|
||||
val := toFloat(pair[1])
|
||||
if len(ts) != 14 {
|
||||
continue
|
||||
}
|
||||
t, err := time.ParseInLocation("20060102150405", ts, loc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tables[i][t] = val
|
||||
}
|
||||
}
|
||||
|
||||
// 统计可用时间范围(用于提示)和收集未来时次
|
||||
var latest time.Time
|
||||
timeSet := map[time.Time]struct{}{}
|
||||
for i := range tables {
|
||||
for t := range tables[i] {
|
||||
if t.After(latest) {
|
||||
latest = t
|
||||
}
|
||||
if t.After(base) { // 未来时次
|
||||
timeSet[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 选择“接下来可用的三个未来时次”(不强制为连续每小时)
|
||||
var targets []time.Time
|
||||
if len(timeSet) > 0 {
|
||||
// 排序并取前3
|
||||
targets = make([]time.Time, 0, len(timeSet))
|
||||
for t := range timeSet {
|
||||
targets = append(targets, t)
|
||||
}
|
||||
// 简单选择排序(避免引入 sort 包也没问题,但使用标准库更好)
|
||||
}
|
||||
|
||||
if len(timeSet) > 0 {
|
||||
// 使用标准库排序
|
||||
targets = sortTimesAsc(targets)
|
||||
if len(targets) > 3 {
|
||||
targets = targets[:3]
|
||||
}
|
||||
} else {
|
||||
// 回退到严格的下三个整点(若无则输出 NA)
|
||||
targets = []time.Time{base.Add(1 * time.Hour), base.Add(2 * time.Hour), base.Add(3 * time.Hour)}
|
||||
}
|
||||
|
||||
// 输出
|
||||
fmt.Fprintf(os.Stdout, "CMA next 3 hours (CST) station=%s\n", staID)
|
||||
fmt.Fprintln(os.Stdout, "Time, Rain(mm), Temp(°C), RHU(%), PRS(hPa), Wind(m/s)")
|
||||
for _, tt := range targets {
|
||||
var rain, temp, rhu, prs, wind string
|
||||
if idxRain >= 0 && idxRain < len(tables) {
|
||||
if v, ok := tables[idxRain][tt]; ok {
|
||||
rain = formatOrNA(v)
|
||||
} else {
|
||||
rain = "NA"
|
||||
}
|
||||
} else {
|
||||
rain = "NA"
|
||||
}
|
||||
if idxTemp >= 0 && idxTemp < len(tables) {
|
||||
if v, ok := tables[idxTemp][tt]; ok {
|
||||
temp = formatOrNA(v)
|
||||
} else {
|
||||
temp = "NA"
|
||||
}
|
||||
} else {
|
||||
temp = "NA"
|
||||
}
|
||||
if idxRHU >= 0 && idxRHU < len(tables) {
|
||||
if v, ok := tables[idxRHU][tt]; ok {
|
||||
rhu = formatOrNA(v)
|
||||
} else {
|
||||
rhu = "NA"
|
||||
}
|
||||
} else {
|
||||
rhu = "NA"
|
||||
}
|
||||
if idxPRS >= 0 && idxPRS < len(tables) {
|
||||
if v, ok := tables[idxPRS][tt]; ok {
|
||||
prs = formatOrNA(v)
|
||||
} else {
|
||||
prs = "NA"
|
||||
}
|
||||
} else {
|
||||
prs = "NA"
|
||||
}
|
||||
if idxWIN >= 0 && idxWIN < len(tables) {
|
||||
if v, ok := tables[idxWIN][tt]; ok {
|
||||
wind = formatOrNA(v)
|
||||
} else {
|
||||
wind = "NA"
|
||||
}
|
||||
} else {
|
||||
wind = "NA"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "%s, %s, %s, %s, %s, %s\n", tt.Format("2006-01-02 15:00"), rain, temp, rhu, prs, wind)
|
||||
}
|
||||
|
||||
if len(targets) > 0 && latest.Before(targets[0]) {
|
||||
fmt.Fprintf(os.Stdout, "Note: latest CMA data time is %s; no future values returned by this endpoint.\n", latest.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findLabelIndex(labels []string, keys []string) int {
|
||||
for i, s := range labels {
|
||||
for _, k := range keys {
|
||||
if strings.Contains(s, k) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func toFloat(v interface{}) float64 {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case float32:
|
||||
return float64(t)
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func formatOrNA(v float64) string {
|
||||
// 使用默认格式,保留最多1-2位小数(按需要精简,这里用 %.2f)
|
||||
// 但如果值为0,仍然输出 0
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", v), "0"), ".")
|
||||
}
|
||||
|
||||
func sortTimesAsc(ts []time.Time) []time.Time {
|
||||
sort.Slice(ts, func(i, j int) bool { return ts[i].Before(ts[j]) })
|
||||
return ts
|
||||
}
|
||||
|
||||
// RunCMAFetch 拉取一次CMA数据,并将“下一个整点开始的3个小时”写入 forecast_hourly。
|
||||
// 特殊规则:所有站点共用同一份数据;缺失项以0填充;provider='cma'。
|
||||
func RunCMAFetch(ctx context.Context) error {
|
||||
// 固定参数
|
||||
staID := "59238"
|
||||
funitem := "1150101020"
|
||||
typeCode := "NWST"
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
issuedAt := time.Now().In(loc)
|
||||
base := issuedAt.Truncate(time.Hour)
|
||||
|
||||
// 请求CMA
|
||||
form := url.Values{}
|
||||
form.Set("staId", staID)
|
||||
form.Set("funitemmenuid", funitem)
|
||||
form.Set("typeCode", typeCode)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://data.cma.cn/dataGis/exhibitionData/getStationInfo", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", "https://data.cma.cn/dataGis/static/gridgis/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("CMA status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Hover string `json:"hover"`
|
||||
List [][][]interface{} `json:"list"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 索引映射(容错)
|
||||
labels := strings.Split(payload.Hover, ",")
|
||||
useHover := len(labels) == len(payload.List) && len(payload.List) >= 4
|
||||
idxRain, idxTemp, idxRHU, idxPRS, idxWIN := 0, 1, 2, 3, 4
|
||||
if useHover {
|
||||
r := findLabelIndex(labels, []string{"PRE_1h", "PRE"})
|
||||
t := findLabelIndex(labels, []string{"TEM", "temperature"})
|
||||
h := findLabelIndex(labels, []string{"RHU", "humidity"})
|
||||
p := findLabelIndex(labels, []string{"PRS", "pressure"})
|
||||
w := findLabelIndex(labels, []string{"WIN_S_Avg_10mi", "WIN_SN_S", "WIN_S", "wind"})
|
||||
if r >= 0 && t >= 0 && h >= 0 && w >= 0 {
|
||||
idxRain, idxTemp, idxRHU, idxPRS, idxWIN = r, t, h, p, w
|
||||
}
|
||||
}
|
||||
|
||||
// 建表:要素 -> 时间 -> 值
|
||||
tables := make([]map[time.Time]float64, len(payload.List))
|
||||
for i := range payload.List {
|
||||
tables[i] = map[time.Time]float64{}
|
||||
for _, pair := range payload.List[i] {
|
||||
if len(pair) != 2 {
|
||||
continue
|
||||
}
|
||||
ts, _ := pair[0].(string)
|
||||
val := toFloat(pair[1])
|
||||
if len(ts) != 14 {
|
||||
continue
|
||||
}
|
||||
t, err := time.ParseInLocation("20060102150405", ts, loc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tables[i][t] = val
|
||||
}
|
||||
}
|
||||
|
||||
// 从可用数据中选择“接下来可用的三个未来时次”(按接口实际返回)
|
||||
timeSet := map[time.Time]struct{}{}
|
||||
for i := range tables {
|
||||
for t := range tables[i] {
|
||||
if t.After(base) {
|
||||
timeSet[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
var targets []time.Time
|
||||
for t := range timeSet {
|
||||
targets = append(targets, t)
|
||||
}
|
||||
targets = sortTimesAsc(targets)
|
||||
if len(targets) > 3 {
|
||||
targets = targets[:3]
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
// 无未来时次则不写库,避免写入“假想整点”。
|
||||
return nil
|
||||
}
|
||||
|
||||
// 读取站点列表(全部)
|
||||
db := database.GetDB()
|
||||
stationIDs, err := loadAllStationIDs(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 写库(缺失置0;单位转换同其他provider)
|
||||
for _, ft := range targets {
|
||||
rain := getOrZero(tables, idxRain, ft) // mm
|
||||
temp := getOrZero(tables, idxTemp, ft) // °C
|
||||
rhu := getOrZero(tables, idxRHU, ft) // %
|
||||
prs := getOrZero(tables, idxPRS, ft) // hPa
|
||||
ws := getOrZero(tables, idxWIN, ft) // m/s
|
||||
gust := 0.0 // 未提供
|
||||
wdir := 0.0 // 未提供
|
||||
prob := 0.0 // 未提供
|
||||
|
||||
rainMmX1000 := int64(rain * 1000.0)
|
||||
tempCx100 := int64(temp * 100.0)
|
||||
humidityPct := int64(rhu)
|
||||
wsMsX1000 := int64(ws * 1000.0)
|
||||
gustMsX1000 := int64(gust * 1000.0)
|
||||
wdirDeg := int64(wdir)
|
||||
probPct := int64(prob)
|
||||
pressureHpaX100 := int64(prs * 100.0)
|
||||
|
||||
for _, sid := range stationIDs {
|
||||
_ = upsertForecastWithProvider(ctx, db, sid, "cma", issuedAt, ft,
|
||||
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrZero(tables []map[time.Time]float64, idx int, t time.Time) float64 {
|
||||
if idx >= 0 && idx < len(tables) {
|
||||
if v, ok := tables[idx][t]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 加载所有站点ID
|
||||
func loadAllStationIDs(ctx context.Context, db *sql.DB) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT station_id FROM stations`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err == nil {
|
||||
list = append(list, s)
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
341
internal/forecast/open_meteo.go
Normal file
341
internal/forecast/open_meteo.go
Normal file
@ -0,0 +1,341 @@
|
||||
package forecast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
type openMeteoResponse struct {
|
||||
Hourly struct {
|
||||
Time []string `json:"time"`
|
||||
Rain []float64 `json:"rain"`
|
||||
Temperature []float64 `json:"temperature_2m"`
|
||||
Humidity []float64 `json:"relative_humidity_2m"`
|
||||
WindSpeed []float64 `json:"wind_speed_10m"`
|
||||
WindGusts []float64 `json:"wind_gusts_10m"`
|
||||
WindDir []float64 `json:"wind_direction_10m"`
|
||||
PrecipProb []float64 `json:"precipitation_probability"`
|
||||
SurfacePres []float64 `json:"surface_pressure"`
|
||||
} `json:"hourly"`
|
||||
}
|
||||
|
||||
// RunOpenMeteoFetch 拉取各站点未来三小时并写入 forecast_hourly
|
||||
func RunOpenMeteoFetch(ctx context.Context) error {
|
||||
db := database.GetDB()
|
||||
stations, err := loadStationsWithLatLon(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
|
||||
issuedAt := time.Now().In(loc)
|
||||
startHour := issuedAt.Truncate(time.Hour)
|
||||
targets := []time.Time{startHour.Add(1 * time.Hour), startHour.Add(2 * time.Hour), startHour.Add(3 * time.Hour)}
|
||||
|
||||
for _, s := range stations {
|
||||
apiURL := buildOpenMeteoURL(s.lat, s.lon)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("open-meteo 请求失败 station=%s err=%v", s.id, err)
|
||||
continue
|
||||
}
|
||||
var data openMeteoResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
resp.Body.Close()
|
||||
log.Printf("open-meteo 解码失败 station=%s err=%v", s.id, err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// 建立 time->vals 映射(按CST解析)
|
||||
table := map[time.Time]struct {
|
||||
rain float64
|
||||
temp float64
|
||||
rh float64
|
||||
ws float64
|
||||
gust float64
|
||||
wdir float64
|
||||
prob float64
|
||||
pres float64
|
||||
}{}
|
||||
for i := range data.Hourly.Time {
|
||||
t, err := time.ParseInLocation("2006-01-02T15:04", data.Hourly.Time[i], loc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
v := table[t]
|
||||
if i < len(data.Hourly.Rain) {
|
||||
v.rain = data.Hourly.Rain[i]
|
||||
}
|
||||
if i < len(data.Hourly.Temperature) {
|
||||
v.temp = data.Hourly.Temperature[i]
|
||||
}
|
||||
if i < len(data.Hourly.Humidity) {
|
||||
v.rh = data.Hourly.Humidity[i]
|
||||
}
|
||||
if i < len(data.Hourly.WindSpeed) {
|
||||
// 将 km/h 转换为 m/s: m/s = km/h ÷ 3.6
|
||||
v.ws = data.Hourly.WindSpeed[i] / 3.6
|
||||
}
|
||||
if i < len(data.Hourly.WindGusts) {
|
||||
// 将 km/h 转换为 m/s: m/s = km/h ÷ 3.6
|
||||
v.gust = data.Hourly.WindGusts[i] / 3.6
|
||||
}
|
||||
if i < len(data.Hourly.WindDir) {
|
||||
v.wdir = data.Hourly.WindDir[i]
|
||||
}
|
||||
if i < len(data.Hourly.PrecipProb) {
|
||||
v.prob = data.Hourly.PrecipProb[i]
|
||||
}
|
||||
if i < len(data.Hourly.SurfacePres) {
|
||||
v.pres = data.Hourly.SurfacePres[i]
|
||||
}
|
||||
table[t] = v
|
||||
}
|
||||
|
||||
for _, ft := range targets {
|
||||
if v, ok := table[ft]; ok {
|
||||
if err := upsertForecast(ctx, db, s.id, issuedAt, ft,
|
||||
int64(v.rain*1000.0),
|
||||
int64(v.temp*100.0),
|
||||
int64(v.rh),
|
||||
int64(v.ws*1000.0),
|
||||
int64(v.gust*1000.0),
|
||||
int64(v.wdir),
|
||||
int64(v.prob),
|
||||
int64(v.pres*100.0),
|
||||
); err != nil {
|
||||
log.Printf("写入forecast失败 station=%s time=%s err=%v", s.id, ft.Format(time.RFC3339), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type station struct {
|
||||
id string
|
||||
lat sql.NullFloat64
|
||||
lon sql.NullFloat64
|
||||
}
|
||||
|
||||
func loadStationsWithLatLon(ctx context.Context, db *sql.DB) ([]station, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT station_id, latitude, longitude FROM stations WHERE latitude IS NOT NULL AND longitude IS NOT NULL`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []station
|
||||
for rows.Next() {
|
||||
var s station
|
||||
if err := rows.Scan(&s.id, &s.lat, &s.lon); err != nil {
|
||||
continue
|
||||
}
|
||||
list = append(list, s)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func buildOpenMeteoURL(lat, lon sql.NullFloat64) string {
|
||||
q := url.Values{}
|
||||
q.Set("latitude", fmt.Sprintf("%f", lat.Float64))
|
||||
q.Set("longitude", fmt.Sprintf("%f", lon.Float64))
|
||||
q.Set("hourly", "rain,temperature_2m,relative_humidity_2m,wind_speed_10m,wind_gusts_10m,wind_direction_10m,precipitation_probability,surface_pressure")
|
||||
q.Set("timezone", "Asia/Shanghai")
|
||||
// 可以添加单位参数,但我们已经在代码中处理了单位转换,所以保持默认单位即可
|
||||
// 默认单位:风速 km/h,温度 °C,降水量 mm,气压 hPa
|
||||
return "https://api.open-meteo.com/v1/forecast?" + q.Encode()
|
||||
}
|
||||
|
||||
func upsertForecast(ctx context.Context, db *sql.DB, stationID string, issuedAt, forecastTime time.Time,
|
||||
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100 int64,
|
||||
) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO forecast_hourly (
|
||||
station_id, provider, issued_at, forecast_time,
|
||||
rain_mm_x1000, temp_c_x100, humidity_pct, wind_speed_ms_x1000,
|
||||
wind_gust_ms_x1000, wind_dir_deg, precip_prob_pct, pressure_hpa_x100
|
||||
) VALUES ($1, 'open-meteo', $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (station_id, provider, issued_at, forecast_time)
|
||||
DO UPDATE SET
|
||||
rain_mm_x1000 = EXCLUDED.rain_mm_x1000,
|
||||
temp_c_x100 = EXCLUDED.temp_c_x100,
|
||||
humidity_pct = EXCLUDED.humidity_pct,
|
||||
wind_speed_ms_x1000 = EXCLUDED.wind_speed_ms_x1000,
|
||||
wind_gust_ms_x1000 = EXCLUDED.wind_gust_ms_x1000,
|
||||
wind_dir_deg = EXCLUDED.wind_dir_deg,
|
||||
precip_prob_pct = EXCLUDED.precip_prob_pct,
|
||||
pressure_hpa_x100 = EXCLUDED.pressure_hpa_x100
|
||||
`, stationID, issuedAt, forecastTime,
|
||||
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100)
|
||||
return err
|
||||
}
|
||||
|
||||
// 新增:支持自定义provider的upsert
|
||||
func upsertForecastWithProvider(ctx context.Context, db *sql.DB, stationID, provider string, issuedAt, forecastTime time.Time,
|
||||
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100 int64,
|
||||
) error {
|
||||
// 调试日志
|
||||
if provider == "open-meteo_historical" {
|
||||
log.Printf("写入历史数据: station=%s, time=%s, temp=%.2f, humidity=%d",
|
||||
stationID, forecastTime.Format("2006-01-02 15:04:05"), float64(tempCx100)/100.0, humidityPct)
|
||||
}
|
||||
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO forecast_hourly (
|
||||
station_id, provider, issued_at, forecast_time,
|
||||
rain_mm_x1000, temp_c_x100, humidity_pct, wind_speed_ms_x1000,
|
||||
wind_gust_ms_x1000, wind_dir_deg, precip_prob_pct, pressure_hpa_x100
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (station_id, provider, issued_at, forecast_time)
|
||||
DO UPDATE SET
|
||||
rain_mm_x1000 = EXCLUDED.rain_mm_x1000,
|
||||
temp_c_x100 = EXCLUDED.temp_c_x100,
|
||||
humidity_pct = EXCLUDED.humidity_pct,
|
||||
wind_speed_ms_x1000 = EXCLUDED.wind_speed_ms_x1000,
|
||||
wind_gust_ms_x1000 = EXCLUDED.wind_gust_ms_x1000,
|
||||
wind_dir_deg = EXCLUDED.wind_dir_deg,
|
||||
precip_prob_pct = EXCLUDED.precip_prob_pct,
|
||||
pressure_hpa_x100 = EXCLUDED.pressure_hpa_x100
|
||||
`, stationID, provider, issuedAt, forecastTime,
|
||||
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100)
|
||||
return err
|
||||
}
|
||||
|
||||
// RunOpenMeteoHistoricalFetch 拉取指定时间段的历史数据并写入 forecast_hourly(provider=open-meteo_historical)
|
||||
func RunOpenMeteoHistoricalFetch(ctx context.Context, startDate, endDate string) error {
|
||||
db := database.GetDB()
|
||||
stations, err := loadStationsWithLatLon(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载站点失败: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
|
||||
log.Printf("开始补完历史数据: %s 到 %s,共 %d 个站点", startDate, endDate, len(stations))
|
||||
|
||||
for i, s := range stations {
|
||||
log.Printf("处理站点 %d/%d: %s", i+1, len(stations), s.id)
|
||||
|
||||
apiURL := buildOpenMeteoHistoricalURL(s.lat, s.lon, startDate, endDate)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("历史数据请求失败 station=%s err=%v", s.id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var data openMeteoResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
resp.Body.Close()
|
||||
log.Printf("历史数据解码失败 station=%s err=%v", s.id, err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// 处理并写入forecast_hourly(历史)
|
||||
count := 0
|
||||
issuedAt := time.Now().In(loc)
|
||||
for i := range data.Hourly.Time {
|
||||
// 解析时间(使用CST时区)
|
||||
t, err := time.ParseInLocation("2006-01-02T15:04", data.Hourly.Time[i], loc)
|
||||
if err != nil {
|
||||
log.Printf("解析时间失败: %s, err=%v", data.Hourly.Time[i], err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 收集并转换(与forecast_hourly缩放一致)
|
||||
rainMmX1000 := int64(0)
|
||||
if i < len(data.Hourly.Rain) {
|
||||
rainMmX1000 = int64(data.Hourly.Rain[i] * 1000.0)
|
||||
}
|
||||
tempCx100 := int64(0)
|
||||
if i < len(data.Hourly.Temperature) {
|
||||
tempCx100 = int64(data.Hourly.Temperature[i] * 100.0)
|
||||
}
|
||||
humidityPct := int64(0)
|
||||
if i < len(data.Hourly.Humidity) {
|
||||
humidityPct = int64(data.Hourly.Humidity[i])
|
||||
}
|
||||
wsMsX1000 := int64(0)
|
||||
if i < len(data.Hourly.WindSpeed) {
|
||||
wsMsX1000 = int64((data.Hourly.WindSpeed[i] / 3.6) * 1000.0)
|
||||
}
|
||||
gustMsX1000 := int64(0) // ERA5此接口未提供阵风,置0
|
||||
wdirDeg := int64(0)
|
||||
if i < len(data.Hourly.WindDir) {
|
||||
wdirDeg = int64(data.Hourly.WindDir[i])
|
||||
}
|
||||
probPct := int64(0) // 历史无降水概率,置0
|
||||
pressureHpaX100 := int64(0)
|
||||
if i < len(data.Hourly.SurfacePres) {
|
||||
pressureHpaX100 = int64(data.Hourly.SurfacePres[i] * 100.0)
|
||||
}
|
||||
|
||||
if err := upsertForecastWithProvider(
|
||||
ctx, db, s.id, "open-meteo_historical", issuedAt, t,
|
||||
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100,
|
||||
); err != nil {
|
||||
log.Printf("写入历史forecast失败 station=%s time=%s err=%v", s.id, t.Format(time.RFC3339), err)
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
}
|
||||
log.Printf("站点 %s 成功写入 %d 条历史forecast记录", s.id, count)
|
||||
// 防止请求过频
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildOpenMeteoHistoricalURL(lat, lon sql.NullFloat64, startDate, endDate string) string {
|
||||
q := url.Values{}
|
||||
q.Set("latitude", fmt.Sprintf("%f", lat.Float64))
|
||||
q.Set("longitude", fmt.Sprintf("%f", lon.Float64))
|
||||
q.Set("start_date", startDate)
|
||||
q.Set("end_date", endDate)
|
||||
q.Set("hourly", "temperature_2m,relative_humidity_2m,surface_pressure,wind_speed_10m,wind_direction_10m,rain")
|
||||
q.Set("timezone", "Asia/Shanghai")
|
||||
return "https://archive-api.open-meteo.com/v1/era5?" + q.Encode()
|
||||
}
|
||||
|
||||
func insertHistoricalData(ctx context.Context, db *sql.DB, stationID string, timestamp time.Time,
|
||||
temp, humidity, pressure, windSpeed, windDir, rainfall *float64) error {
|
||||
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO rs485_weather_data (
|
||||
station_id, timestamp, temperature, humidity, pressure,
|
||||
wind_speed, wind_direction, rainfall, raw_data
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (station_id, timestamp) DO UPDATE SET
|
||||
temperature = EXCLUDED.temperature,
|
||||
humidity = EXCLUDED.humidity,
|
||||
pressure = EXCLUDED.pressure,
|
||||
wind_speed = EXCLUDED.wind_speed,
|
||||
wind_direction = EXCLUDED.wind_direction,
|
||||
rainfall = EXCLUDED.rainfall,
|
||||
raw_data = EXCLUDED.raw_data
|
||||
`, stationID, timestamp, temp, humidity, pressure, windSpeed, windDir, rainfall,
|
||||
fmt.Sprintf("open-meteo-historical:%s", timestamp.Format(time.RFC3339)))
|
||||
|
||||
return err
|
||||
}
|
||||
796
internal/fusion/fusion.go
Normal file
796
internal/fusion/fusion.go
Normal file
@ -0,0 +1,796 @@
|
||||
package fusion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
const (
|
||||
providerOpenMeteo = "open-meteo"
|
||||
providerCaiyun = "caiyun"
|
||||
providerImdroid = "imdroid"
|
||||
outputProvider = "imdroid_mix"
|
||||
)
|
||||
|
||||
var defaultTriad = database.Triad{OpenMeteo: 0.4, Caiyun: 0.3, Imdroid: 0.3}
|
||||
|
||||
// RunForIssued runs fusion for all stations at the given issued (hour bucket, CST).
|
||||
// It persists weights into forecast_weights_current and fused rows into forecast_hourly.
|
||||
func RunForIssued(ctx context.Context, issued time.Time) error {
|
||||
db := database.GetDB()
|
||||
stations, err := loadStations(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, st := range stations {
|
||||
if err := runForStation(ctx, db, st, issued); err != nil {
|
||||
log.Printf("fusion: station=%s issued=%s error=%v", st.ID, issued.Format(time.RFC3339), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type stationInfo struct {
|
||||
ID string
|
||||
Alias string
|
||||
Lat sql.NullFloat64
|
||||
Lon sql.NullFloat64
|
||||
Z sql.NullInt64
|
||||
Y sql.NullInt64
|
||||
X sql.NullInt64
|
||||
}
|
||||
|
||||
func loadStations(ctx context.Context, db *sql.DB) ([]stationInfo, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT station_id,
|
||||
COALESCE(NULLIF(station_alias,''), station_id) AS alias,
|
||||
latitude, longitude, z, y, x
|
||||
FROM stations
|
||||
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
ORDER BY station_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []stationInfo
|
||||
for rows.Next() {
|
||||
var s stationInfo
|
||||
if err := rows.Scan(&s.ID, &s.Alias, &s.Lat, &s.Lon, &s.Z, &s.Y, &s.X); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, s)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
type providerMatrix struct {
|
||||
Provider string
|
||||
Samples []forecastSample
|
||||
}
|
||||
|
||||
type forecastSample struct {
|
||||
Rain float64
|
||||
Temp float64
|
||||
Humidity float64
|
||||
WindSpeed float64
|
||||
WindGust float64
|
||||
WindDir float64
|
||||
Prob float64
|
||||
Pressure float64
|
||||
UV float64
|
||||
}
|
||||
|
||||
func runForStation(ctx context.Context, db *sql.DB, st stationInfo, issued time.Time) error {
|
||||
// Log header
|
||||
log.Printf("")
|
||||
log.Printf("issue %s station=%s", issued.Format("2006-01-02 15:04:05"), st.ID)
|
||||
|
||||
// Presence at issued
|
||||
present := presentProviders(db, st.ID, issued)
|
||||
presOrdered := orderByProvidersAll(present)
|
||||
added, removed := diffPresence(db, st.ID, present)
|
||||
if len(added) > 0 || len(removed) > 0 {
|
||||
log.Printf("sources %v added=%v removed=%v", presOrdered, added, removed)
|
||||
}
|
||||
|
||||
// Load previous triad snapshot or default
|
||||
triad, _, ok, _ := database.GetWeightsCurrent(ctx, st.ID)
|
||||
if !ok {
|
||||
triad = defaultTriad
|
||||
}
|
||||
|
||||
// Apply missing/new rules
|
||||
triad = applyMissingTransfer(triad, present)
|
||||
triad = applyNewAllocation(triad, present)
|
||||
|
||||
// Learning (I-1 vs (I-2)+1)
|
||||
learnStart := issued.Add(-time.Hour)
|
||||
learnEnd := issued
|
||||
actual, actualOK, err := fetchActualHourlyRain(db, st.ID, learnStart, learnEnd)
|
||||
if err != nil {
|
||||
log.Printf("fusion: actual error station=%s issued=%s err=%v", st.ID, issued.Format(time.RFC3339), err)
|
||||
} else if actualOK {
|
||||
prevIssue := issued.Add(-2 * time.Hour)
|
||||
preds, names := lead1PredsAt(db, st.ID, prevIssue, presOrdered)
|
||||
log.Printf("在 [%s,%s) ,该小时本站实况雨量累计为:%.3f,预报源在 issue=%s 发布的 +1h %s-%s 预报值是 %s",
|
||||
learnStart.Format("15:04"), learnEnd.Format("15:04"), actual,
|
||||
prevIssue.Format("15:04"), learnStart.Format("15:04"), learnEnd.Format("15:04"),
|
||||
formatFloatSlice(preds))
|
||||
triad = learnTriad(triad, names, preds, actual)
|
||||
}
|
||||
|
||||
// Build matrix samples for issued (providers present only)
|
||||
matrix := buildMatrix(db, st.ID, issued, presOrdered)
|
||||
|
||||
// Effective weights (ordered by presOrdered)
|
||||
effBefore := triadToSlice(triad, presOrdered)
|
||||
eff := effBefore // learning already applied
|
||||
log.Printf("学习前权重 %s ,本轮使用权重 %s", toNamed(effBefore, presOrdered), toNamed(eff, presOrdered))
|
||||
|
||||
// Fuse with mask
|
||||
fused := fuseForecastsWith(matrix, eff)
|
||||
preMask := []float64{fused[0].Rain, fused[1].Rain, fused[2].Rain}
|
||||
maskApplied := false
|
||||
// Mask requires radar context
|
||||
if hasMaskCtx(st) {
|
||||
rep, err := checkRadarMask(db, buildMaskContext(st), issued)
|
||||
if err == nil && rep.Scanned {
|
||||
// per-hour apply: if hits==0 then zero rain
|
||||
for h := 0; h < 3; h++ {
|
||||
if rep.HitsByHour[h] == 0 {
|
||||
fused[h].Rain = 0
|
||||
maskApplied = true
|
||||
}
|
||||
}
|
||||
log.Printf("mask t=%s wind=%.1fm/s %.0f° tiles=%d hits=[%d,%d,%d] applied=%v",
|
||||
issued.Format("2006-01-02 15:04:05"), rep.WindSpeed, rep.WindDir, rep.Tiles,
|
||||
rep.HitsByHour[0], rep.HitsByHour[1], rep.HitsByHour[2], maskApplied)
|
||||
}
|
||||
}
|
||||
postMask := []float64{fused[0].Rain, fused[1].Rain, fused[2].Rain}
|
||||
log.Printf("预报 %s , mask=%v , 融合的结果 %s", formatFloatSlice(preMask), maskApplied, formatFloatSlice(postMask))
|
||||
|
||||
// Persist fused rows
|
||||
if err := upsertFused(ctx, db, st.ID, issued, fused); err != nil {
|
||||
return err
|
||||
}
|
||||
// Persist triad snapshot for next hour
|
||||
if err := database.UpsertWeightsCurrent(ctx, st.ID, triad, issued); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatFloatSlice(vs []float64) string {
|
||||
parts := make([]string, len(vs))
|
||||
for i, v := range vs {
|
||||
parts[i] = fmt.Sprintf("%.2f", v)
|
||||
}
|
||||
return "[" + strings.Join(parts, ",") + "]"
|
||||
}
|
||||
|
||||
func toNamed(ws []float64, names []string) string {
|
||||
parts := make([]string, len(ws))
|
||||
for i := range ws {
|
||||
parts[i] = fmt.Sprintf("%s=%.2f", names[i], ws[i])
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func triadToSlice(t database.Triad, names []string) []float64 {
|
||||
out := make([]float64, len(names))
|
||||
for i, n := range names {
|
||||
switch n {
|
||||
case providerOpenMeteo:
|
||||
out[i] = t.OpenMeteo
|
||||
case providerCaiyun:
|
||||
out[i] = t.Caiyun
|
||||
case providerImdroid:
|
||||
out[i] = t.Imdroid
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sliceToTriad(vals []float64, names []string) database.Triad {
|
||||
t := database.Triad{}
|
||||
for i, n := range names {
|
||||
switch n {
|
||||
case providerOpenMeteo:
|
||||
t.OpenMeteo = vals[i]
|
||||
case providerCaiyun:
|
||||
t.Caiyun = vals[i]
|
||||
case providerImdroid:
|
||||
t.Imdroid = vals[i]
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func presentProviders(db *sql.DB, stationID string, issued time.Time) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
// Check providers within [issued, issued+1h)
|
||||
for _, p := range []string{providerOpenMeteo, providerCaiyun, providerImdroid} {
|
||||
var cnt int
|
||||
_ = db.QueryRow(`SELECT COUNT(*) FROM forecast_hourly WHERE station_id=$1 AND provider=$2 AND issued_at >= $3 AND issued_at < $3 + interval '1 hour'`, stationID, p, issued).Scan(&cnt)
|
||||
if cnt > 0 {
|
||||
out[p] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orderByProvidersAll(present map[string]bool) []string {
|
||||
base := []string{providerOpenMeteo, providerCaiyun, providerImdroid}
|
||||
out := make([]string, 0, len(present))
|
||||
for _, p := range base {
|
||||
if present[p] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func diffPresence(db *sql.DB, stationID string, present map[string]bool) (added, removed []string) {
|
||||
// Use last hour presence from forecast_hourly as proxy
|
||||
prev := map[string]bool{}
|
||||
// naive: if provider had any row at issued-1h bucket, consider present
|
||||
// We don't know issued here; caller logs only.
|
||||
// Return stable order using base sequence.
|
||||
base := []string{providerOpenMeteo, providerCaiyun, providerImdroid}
|
||||
for _, p := range base {
|
||||
if present[p] && !prev[p] {
|
||||
added = append(added, p)
|
||||
}
|
||||
if !present[p] && prev[p] {
|
||||
removed = append(removed, p)
|
||||
}
|
||||
}
|
||||
return added, removed
|
||||
}
|
||||
|
||||
func applyMissingTransfer(t database.Triad, present map[string]bool) database.Triad {
|
||||
// Sum of missing weights
|
||||
missing := 0.0
|
||||
if !present[providerOpenMeteo] {
|
||||
missing += t.OpenMeteo
|
||||
t.OpenMeteo = 0
|
||||
}
|
||||
if !present[providerCaiyun] {
|
||||
missing += t.Caiyun
|
||||
t.Caiyun = 0
|
||||
}
|
||||
if !present[providerImdroid] {
|
||||
missing += t.Imdroid
|
||||
t.Imdroid = 0
|
||||
}
|
||||
if missing == 0 {
|
||||
return t
|
||||
}
|
||||
// Give to current max among present
|
||||
maxP := ""
|
||||
maxW := -1.0
|
||||
for p, ok := range present {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
w := 0.0
|
||||
switch p {
|
||||
case providerOpenMeteo:
|
||||
w = t.OpenMeteo
|
||||
case providerCaiyun:
|
||||
w = t.Caiyun
|
||||
case providerImdroid:
|
||||
w = t.Imdroid
|
||||
}
|
||||
if w > maxW {
|
||||
maxW = w
|
||||
maxP = p
|
||||
}
|
||||
}
|
||||
switch maxP {
|
||||
case providerOpenMeteo:
|
||||
t.OpenMeteo += missing
|
||||
case providerCaiyun:
|
||||
t.Caiyun += missing
|
||||
case providerImdroid:
|
||||
t.Imdroid += missing
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func applyNewAllocation(t database.Triad, present map[string]bool) database.Triad {
|
||||
// allocate 0.2 from current max to any newly present which currently has 0
|
||||
for _, p := range []string{providerOpenMeteo, providerCaiyun, providerImdroid} {
|
||||
// we don't track prior presence; heuristic: if present and weight==0 allocate
|
||||
if present[p] && getW(t, p) <= 0 {
|
||||
// find current max (excluding p)
|
||||
type pair struct {
|
||||
name string
|
||||
w float64
|
||||
}
|
||||
arr := []pair{{providerOpenMeteo, t.OpenMeteo}, {providerCaiyun, t.Caiyun}, {providerImdroid, t.Imdroid}}
|
||||
sort.Slice(arr, func(i, j int) bool { return arr[i].w > arr[j].w })
|
||||
src := arr[0]
|
||||
if src.name == p && len(arr) > 1 {
|
||||
src = arr[1]
|
||||
}
|
||||
delta := 0.2
|
||||
if src.w < delta {
|
||||
delta = src.w
|
||||
}
|
||||
t = setW(t, src.name, src.w-delta)
|
||||
t = setW(t, p, getW(t, p)+delta)
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func getW(t database.Triad, p string) float64 {
|
||||
switch p {
|
||||
case providerOpenMeteo:
|
||||
return t.OpenMeteo
|
||||
case providerCaiyun:
|
||||
return t.Caiyun
|
||||
case providerImdroid:
|
||||
return t.Imdroid
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func setW(t database.Triad, p string, v float64) database.Triad {
|
||||
switch p {
|
||||
case providerOpenMeteo:
|
||||
t.OpenMeteo = v
|
||||
case providerCaiyun:
|
||||
t.Caiyun = v
|
||||
case providerImdroid:
|
||||
t.Imdroid = v
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func learnTriad(t database.Triad, names []string, preds []float64, actual float64) database.Triad {
|
||||
if len(names) != len(preds) || len(names) == 0 {
|
||||
return t
|
||||
}
|
||||
// find best/worst among names by absolute error
|
||||
best, worst := 0, 0
|
||||
for i := range preds {
|
||||
if math.Abs(preds[i]-actual) < math.Abs(preds[best]-actual) {
|
||||
best = i
|
||||
}
|
||||
if math.Abs(preds[i]-actual) > math.Abs(preds[worst]-actual) {
|
||||
worst = i
|
||||
}
|
||||
}
|
||||
// log absolute errors
|
||||
parts := make([]string, 0, len(names))
|
||||
for i := range names {
|
||||
parts = append(parts, fmt.Sprintf("%s |%.2f-%.3f|=%.3f", names[i], preds[i], actual, math.Abs(preds[i]-actual)))
|
||||
}
|
||||
log.Printf("绝对误差:%s → best=%s,worst=%s。", strings.Join(parts, ","), names[best], names[worst])
|
||||
|
||||
// apply ±0.1 (cap by available on worst)
|
||||
alpha := 0.1
|
||||
worstW := getW(t, names[worst])
|
||||
delta := alpha
|
||||
if worstW < delta {
|
||||
delta = worstW
|
||||
}
|
||||
if delta <= 0 {
|
||||
log.Printf("按规则:best +0.1,worst −0.1 → 权重在下一轮将为 %s", formatFloatSlice(triadToSlice(t, []string{providerOpenMeteo, providerCaiyun, providerImdroid})))
|
||||
return t
|
||||
}
|
||||
t = setW(t, names[worst], worstW-delta)
|
||||
t = setW(t, names[best], getW(t, names[best])+delta)
|
||||
log.Printf("按规则:best +0.1,worst −0.1 → 权重在下一轮将为 %s", formatFloatSlice(triadToSlice(t, []string{providerOpenMeteo, providerCaiyun, providerImdroid})))
|
||||
return t
|
||||
}
|
||||
|
||||
func buildMatrix(db *sql.DB, stationID string, issued time.Time, ordered []string) []providerMatrix {
|
||||
out := make([]providerMatrix, 0, len(ordered))
|
||||
for _, p := range ordered {
|
||||
iss, ok, err := resolveIssuedAtInBucket(db, stationID, p, issued)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
samples, err := loadForecastSamples(db, stationID, p, iss)
|
||||
if err != nil || len(samples) < 3 {
|
||||
continue
|
||||
}
|
||||
out = append(out, providerMatrix{Provider: p, Samples: samples[:3]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fuseForecastsWith(matrix []providerMatrix, eff []float64) []forecastSample {
|
||||
result := make([]forecastSample, 3)
|
||||
n := len(matrix)
|
||||
if n == 0 {
|
||||
return result
|
||||
}
|
||||
for h := 0; h < 3; h++ {
|
||||
var windX, windY float64
|
||||
for i := 0; i < n; i++ {
|
||||
if len(matrix[i].Samples) <= h {
|
||||
continue
|
||||
}
|
||||
s := matrix[i].Samples[h]
|
||||
w := eff[i]
|
||||
result[h].Rain += s.Rain * w
|
||||
result[h].Temp += s.Temp * w
|
||||
result[h].Humidity += s.Humidity * w
|
||||
result[h].WindSpeed += s.WindSpeed * w
|
||||
result[h].WindGust += s.WindGust * w
|
||||
result[h].Prob += s.Prob * w
|
||||
result[h].Pressure += s.Pressure * w
|
||||
result[h].UV += s.UV * w
|
||||
rad := s.WindDir * math.Pi / 180
|
||||
windX += math.Cos(rad) * w
|
||||
windY += math.Sin(rad) * w
|
||||
}
|
||||
if windX == 0 && windY == 0 {
|
||||
result[h].WindDir = 0
|
||||
} else {
|
||||
dir := math.Atan2(windY, windX) * 180 / math.Pi
|
||||
if dir < 0 {
|
||||
dir += 360
|
||||
}
|
||||
result[h].WindDir = dir
|
||||
}
|
||||
result[h].Humidity = math.Round(result[h].Humidity)
|
||||
result[h].Prob = math.Round(result[h].Prob)
|
||||
result[h].UV = math.Round(result[h].UV)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func upsertFused(ctx context.Context, db *sql.DB, stationID string, issued time.Time, fused []forecastSample) error {
|
||||
for h := 1; h <= 3; h++ {
|
||||
ft := issued.Add(time.Duration(h) * time.Hour)
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO forecast_hourly (
|
||||
station_id, provider, issued_at, forecast_time,
|
||||
rain_mm_x1000, temp_c_x100, humidity_pct, wind_speed_ms_x1000,
|
||||
wind_gust_ms_x1000, wind_dir_deg, precip_prob_pct, pressure_hpa_x100, uv_index)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (station_id, provider, issued_at, forecast_time) DO UPDATE SET
|
||||
rain_mm_x1000=EXCLUDED.rain_mm_x1000,
|
||||
temp_c_x100=EXCLUDED.temp_c_x100,
|
||||
humidity_pct=EXCLUDED.humidity_pct,
|
||||
wind_speed_ms_x1000=EXCLUDED.wind_speed_ms_x1000,
|
||||
wind_gust_ms_x1000=EXCLUDED.wind_gust_ms_x1000,
|
||||
wind_dir_deg=EXCLUDED.wind_dir_deg,
|
||||
precip_prob_pct=EXCLUDED.precip_prob_pct,
|
||||
pressure_hpa_x100=EXCLUDED.pressure_hpa_x100,
|
||||
uv_index=EXCLUDED.uv_index`,
|
||||
stationID, outputProvider, issued, ft,
|
||||
int(math.Round(fused[h-1].Rain*1000)),
|
||||
int(math.Round(fused[h-1].Temp*100)),
|
||||
int(math.Round(fused[h-1].Humidity)),
|
||||
int(math.Round(fused[h-1].WindSpeed*1000)),
|
||||
int(math.Round(fused[h-1].WindGust*1000)),
|
||||
int(math.Round(fused[h-1].WindDir)),
|
||||
int(math.Round(fused[h-1].Prob)),
|
||||
int(math.Round(fused[h-1].Pressure*100)),
|
||||
int(math.Round(fused[h-1].UV)),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----- Helpers copied (simplified) from cmd/imdroidmix -----
|
||||
|
||||
func resolveIssuedAtInBucket(db *sql.DB, stationID, provider string, bucketHour time.Time) (time.Time, bool, error) {
|
||||
const q = `SELECT issued_at FROM forecast_hourly WHERE station_id=$1 AND provider=$2 AND issued_at >= $3 AND issued_at < $3 + interval '1 hour' ORDER BY issued_at DESC LIMIT 1`
|
||||
var t time.Time
|
||||
err := db.QueryRow(q, stationID, provider, bucketHour).Scan(&t)
|
||||
if err == sql.ErrNoRows {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
return t, true, nil
|
||||
}
|
||||
|
||||
func loadForecastSamples(db *sql.DB, stationID, provider string, issued time.Time) ([]forecastSample, error) {
|
||||
const q = `
|
||||
SELECT forecast_time,
|
||||
COALESCE(rain_mm_x1000, 0),
|
||||
COALESCE(temp_c_x100, 0),
|
||||
COALESCE(humidity_pct, 0),
|
||||
COALESCE(wind_speed_ms_x1000, 0),
|
||||
COALESCE(wind_gust_ms_x1000, 0),
|
||||
COALESCE(wind_dir_deg, 0),
|
||||
COALESCE(precip_prob_pct, 0),
|
||||
COALESCE(pressure_hpa_x100, 0),
|
||||
COALESCE(uv_index, 0)
|
||||
FROM forecast_hourly
|
||||
WHERE station_id=$1 AND provider=$2 AND issued_at=$3
|
||||
ORDER BY forecast_time ASC`
|
||||
rows, err := db.Query(q, stationID, provider, issued)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []forecastSample
|
||||
for rows.Next() {
|
||||
var ft time.Time
|
||||
var rainX1000, tempX100, hum, wsX1000, gustX1000, wdir, prob, presX100, uv int
|
||||
if err := rows.Scan(&ft, &rainX1000, &tempX100, &hum, &wsX1000, &gustX1000, &wdir, &prob, &presX100, &uv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, forecastSample{
|
||||
Rain: float64(rainX1000) / 1000.0,
|
||||
Temp: float64(tempX100) / 100.0,
|
||||
Humidity: float64(hum),
|
||||
WindSpeed: float64(wsX1000) / 1000.0,
|
||||
WindGust: float64(gustX1000) / 1000.0,
|
||||
WindDir: float64(wdir),
|
||||
Prob: float64(prob),
|
||||
Pressure: float64(presX100) / 100.0,
|
||||
UV: float64(uv),
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func lead1PredsAt(db *sql.DB, stationID string, prevIssue time.Time, names []string) ([]float64, []string) {
|
||||
preds := make([]float64, 0, len(names))
|
||||
used := make([]string, 0, len(names))
|
||||
for _, p := range names {
|
||||
iss, ok, err := resolveIssuedAtInBucket(db, stationID, p, prevIssue)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
samples, err := loadForecastSamples(db, stationID, p, iss)
|
||||
if err != nil || len(samples) < 1 {
|
||||
continue
|
||||
}
|
||||
preds = append(preds, samples[0].Rain)
|
||||
used = append(used, p)
|
||||
}
|
||||
return preds, used
|
||||
}
|
||||
|
||||
func fetchActualHourlyRain(db *sql.DB, stationID string, start, end time.Time) (float64, bool, error) {
|
||||
const q = `SELECT SUM(rain_10m_mm_x1000) FROM rs485_weather_10min WHERE station_id=$1 AND bucket_start >= $2 AND bucket_start < $3`
|
||||
var sum sql.NullInt64
|
||||
err := db.QueryRow(q, stationID, start, end).Scan(&sum)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if !sum.Valid {
|
||||
return 0, false, nil
|
||||
}
|
||||
return float64(sum.Int64) / 1000.0, true, nil
|
||||
}
|
||||
|
||||
// ----- Radar mask helpers (trimmed) -----
|
||||
|
||||
type maskContext struct {
|
||||
id, alias string
|
||||
lat, lon float64
|
||||
z, y, x int
|
||||
}
|
||||
|
||||
func hasMaskCtx(info stationInfo) bool {
|
||||
return info.Lat.Valid && info.Lon.Valid && info.Z.Valid && info.Y.Valid && info.X.Valid
|
||||
}
|
||||
func buildMaskContext(info stationInfo) maskContext {
|
||||
alias := info.Alias
|
||||
if strings.TrimSpace(alias) == "" {
|
||||
alias = info.ID
|
||||
}
|
||||
return maskContext{id: info.ID, alias: alias, lat: info.Lat.Float64, lon: info.Lon.Float64, z: int(info.Z.Int64), y: int(info.Y.Int64), x: int(info.X.Int64)}
|
||||
}
|
||||
|
||||
type tileRec struct {
|
||||
DT time.Time
|
||||
Width int
|
||||
Height int
|
||||
West float64
|
||||
South float64
|
||||
East float64
|
||||
North float64
|
||||
ResDeg float64
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type maskReport struct {
|
||||
Scanned bool
|
||||
WindOK bool
|
||||
UsedAlias string
|
||||
WindSpeed float64
|
||||
WindDir float64
|
||||
Tiles int
|
||||
HitsByHour [3]int
|
||||
}
|
||||
|
||||
func checkRadarMask(db *sql.DB, ctx maskContext, issued time.Time) (maskReport, error) {
|
||||
rep := maskReport{}
|
||||
windTime := issued.Add(-time.Hour)
|
||||
if spd, dir, ok, err := loadWindByAlias(db, ctx.id, windTime); err != nil {
|
||||
return rep, err
|
||||
} else if ok {
|
||||
rep.WindOK, rep.WindSpeed, rep.WindDir, rep.UsedAlias = true, spd, dir, ctx.id
|
||||
} else if spd2, dir2, ok2, err2 := loadWindByAlias(db, ctx.alias, windTime); err2 != nil {
|
||||
return rep, err2
|
||||
} else if ok2 {
|
||||
rep.WindOK, rep.WindSpeed, rep.WindDir, rep.UsedAlias = true, spd2, dir2, ctx.alias
|
||||
} else {
|
||||
return rep, nil
|
||||
}
|
||||
tiles, err := loadTiles(db, ctx, issued.Add(-time.Hour), issued)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
if len(tiles) == 0 {
|
||||
return rep, nil
|
||||
}
|
||||
rep.Tiles = len(tiles)
|
||||
rep.Scanned = true
|
||||
const circleR = 8000.0
|
||||
const halfAngle = 30.0
|
||||
for _, t := range tiles {
|
||||
vals, xs, ys, err := decodeTile(t)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for r := 0; r < len(vals); r++ {
|
||||
row := vals[r]
|
||||
lat := ys[r]
|
||||
for c := 0; c < len(row); c++ {
|
||||
v := row[c]
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
dbz := *v
|
||||
if dbz < 40 {
|
||||
continue
|
||||
}
|
||||
lon := xs[c]
|
||||
dist := haversine(ctx.lat, ctx.lon, lat, lon)
|
||||
if dist <= circleR {
|
||||
rep.HitsByHour[0]++
|
||||
}
|
||||
if rep.WindSpeed > 0 {
|
||||
brg := bearingDeg(ctx.lat, ctx.lon, lat, lon)
|
||||
if angDiff(brg, rep.WindDir) <= halfAngle {
|
||||
if dist <= rep.WindSpeed*3*3600 {
|
||||
rep.HitsByHour[0]++
|
||||
}
|
||||
if dist <= circleR {
|
||||
rep.HitsByHour[1]++
|
||||
}
|
||||
if dist <= rep.WindSpeed*2*3600 {
|
||||
rep.HitsByHour[1]++
|
||||
}
|
||||
if dist <= rep.WindSpeed*3*3600 {
|
||||
rep.HitsByHour[2]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func loadWindByAlias(db *sql.DB, alias string, target time.Time) (float64, float64, bool, error) {
|
||||
const q = `SELECT wind_speed, wind_direction FROM radar_weather WHERE alias=$1 AND dt <= $2 ORDER BY dt DESC LIMIT 1`
|
||||
var speed, dir sql.NullFloat64
|
||||
err := db.QueryRow(q, alias, target).Scan(&speed, &dir)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
if !speed.Valid || !dir.Valid {
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
return speed.Float64, dir.Float64, true, nil
|
||||
}
|
||||
|
||||
func loadTiles(db *sql.DB, ctx maskContext, from, to time.Time) ([]tileRec, error) {
|
||||
const q = `SELECT dt, width, height, west, south, east, north, res_deg, data FROM radar_tiles WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 ORDER BY dt`
|
||||
rows, err := db.Query(q, ctx.z, ctx.y, ctx.x, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []tileRec
|
||||
for rows.Next() {
|
||||
var t tileRec
|
||||
if err := rows.Scan(&t.DT, &t.Width, &t.Height, &t.West, &t.South, &t.East, &t.North, &t.ResDeg, &t.Data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, t)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func decodeTile(t tileRec) ([][]*float64, []float64, []float64, error) {
|
||||
w, h := t.Width, t.Height
|
||||
if w <= 0 || h <= 0 {
|
||||
return nil, nil, nil, fmt.Errorf("非法瓦片尺寸")
|
||||
}
|
||||
if len(t.Data) < w*h*2 {
|
||||
return nil, nil, nil, fmt.Errorf("数据长度不足")
|
||||
}
|
||||
xs := make([]float64, w)
|
||||
for c := 0; c < w; c++ {
|
||||
xs[c] = t.West + (float64(c)+0.5)*t.ResDeg
|
||||
}
|
||||
ys := make([]float64, h)
|
||||
for r := 0; r < h; r++ {
|
||||
ys[r] = t.South + (float64(r)+0.5)*t.ResDeg
|
||||
}
|
||||
vals := make([][]*float64, h)
|
||||
off := 0
|
||||
for r := 0; r < h; r++ {
|
||||
row := make([]*float64, w)
|
||||
for c := 0; c < w; c++ {
|
||||
v := int16(binary.BigEndian.Uint16(t.Data[off : off+2]))
|
||||
off += 2
|
||||
if v >= 32766 {
|
||||
row[c] = nil
|
||||
continue
|
||||
}
|
||||
dbz := float64(v) / 10.0
|
||||
if dbz < 0 {
|
||||
dbz = 0
|
||||
} else if dbz > 75 {
|
||||
dbz = 75
|
||||
}
|
||||
value := dbz
|
||||
row[c] = &value
|
||||
}
|
||||
vals[r] = row
|
||||
}
|
||||
return vals, xs, ys, nil
|
||||
}
|
||||
|
||||
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const R = 6371000.0
|
||||
dLat := toRad(lat2 - lat1)
|
||||
dLon := toRad(lon2 - lon1)
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
return R * c
|
||||
}
|
||||
func bearingDeg(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
φ1 := toRad(lat1)
|
||||
φ2 := toRad(lat2)
|
||||
Δλ := toRad(lon2 - lon1)
|
||||
y := math.Sin(Δλ) * math.Cos(φ2)
|
||||
x := math.Cos(φ1)*math.Sin(φ2) - math.Sin(φ1)*math.Cos(φ2)*math.Cos(Δλ)
|
||||
brg := toDeg(math.Atan2(y, x))
|
||||
if brg < 0 {
|
||||
brg += 360
|
||||
}
|
||||
return brg
|
||||
}
|
||||
func angDiff(a, b float64) float64 {
|
||||
d := math.Mod(a-b+540, 360) - 180
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
return math.Abs(d)
|
||||
}
|
||||
func toRad(d float64) float64 { return d * math.Pi / 180 }
|
||||
func toDeg(r float64) float64 { return r * 180 / math.Pi }
|
||||
618
internal/radar/scheduler.go
Normal file
618
internal/radar/scheduler.go
Normal file
@ -0,0 +1,618 @@
|
||||
package radar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"weatherstation/internal/config"
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
// Options controls the radar scheduler behavior.
|
||||
type Options struct {
|
||||
Enable bool
|
||||
OutputDir string
|
||||
Delay time.Duration // time after each 6-minute boundary to trigger download
|
||||
BaseURL string // optional: where to download from (template-based)
|
||||
MaxRetries int
|
||||
// Tile indices (4326 pyramid). Defaults: z=7,y=40,x=102 (Nanning region example)
|
||||
Z int
|
||||
Y int
|
||||
X int
|
||||
// StoreToDB controls whether to store fetched tiles into PostgreSQL `radar_tiles`.
|
||||
StoreToDB bool
|
||||
}
|
||||
|
||||
// Start starts the radar download scheduler. It reads options from env if zero value provided.
|
||||
// Env vars:
|
||||
//
|
||||
// RADAR_ENABLED=true|false (default: true)
|
||||
// RADAR_DIR=radar_data (default)
|
||||
// RADAR_DELAY_SEC=120 (2 minutes; trigger after each boundary)
|
||||
// RADAR_MAX_RETRIES=2
|
||||
// RADAR_BASE_URL=<template URL, optional>
|
||||
func Start(ctx context.Context, opts Options) error {
|
||||
if !opts.Enable && !envEnabledDefaultTrue() {
|
||||
log.Println("[radar] scheduler disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
if opts.OutputDir == "" {
|
||||
if v := os.Getenv("RADAR_DIR"); v != "" {
|
||||
opts.OutputDir = v
|
||||
} else {
|
||||
exe, _ := os.Executable()
|
||||
exeDir := filepath.Dir(exe)
|
||||
opts.OutputDir = filepath.Join(exeDir, "radar_data")
|
||||
}
|
||||
}
|
||||
// Delay 不再用于 10 分钟调度流程,这里保留读取但不使用
|
||||
if opts.Delay == 0 {
|
||||
delaySec := getenvIntDefault("RADAR_DELAY_SEC", 0)
|
||||
opts.Delay = time.Duration(delaySec) * time.Second
|
||||
}
|
||||
if opts.MaxRetries == 0 {
|
||||
opts.MaxRetries = getenvIntDefault("RADAR_MAX_RETRIES", 2)
|
||||
}
|
||||
if opts.BaseURL == "" {
|
||||
// Default to CMA image server tiles
|
||||
// Placeholders: %Y %m %d %H %M {z} {y} {x}
|
||||
opts.BaseURL = getenvDefault("RADAR_BASE_URL", "https://image.data.cma.cn/tiles/China/RADAR_L3_MST_CREF_GISJPG_Tiles_CR/%Y%m%d/%H/%M/{z}/{y}/{x}.bin")
|
||||
}
|
||||
if opts.Z == 0 && opts.Y == 0 && opts.X == 0 {
|
||||
// Default tile requested
|
||||
opts.Z, opts.Y, opts.X = getenvIntDefault("RADAR_Z", 7), getenvIntDefault("RADAR_Y", 40), getenvIntDefault("RADAR_X", 102)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create radar output dir: %w", err)
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
|
||||
// 先立即执行一次(不延迟):拉取一次瓦片并抓取一次彩云实况
|
||||
go func() {
|
||||
if err := runOnceFromNMC(ctx, opts); err != nil {
|
||||
log.Printf("[radar] first run error: %v", err)
|
||||
}
|
||||
}()
|
||||
// 瓦片:每3分钟查询一次
|
||||
go loop3(ctx, loc, opts)
|
||||
// 实况:按配置开关运行(默认关闭)
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 对齐到 interval 边界
|
||||
runAt := roundDownN(now, interval).Add(interval)
|
||||
sleep := time.Until(runAt)
|
||||
if sleep < 0 {
|
||||
sleep = 0
|
||||
}
|
||||
timer := time.NewTimer(sleep)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
if err := runRealtimeFromCaiyun(ctx); err != nil {
|
||||
log.Printf("[radar] realtime run error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 每3分钟的瓦片轮询
|
||||
func loop3(ctx context.Context, loc *time.Location, opts Options) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
runAt := roundDownN(now, 3*time.Minute).Add(3 * time.Minute)
|
||||
sleep := time.Until(runAt)
|
||||
if sleep < 0 {
|
||||
sleep = 0
|
||||
}
|
||||
timer := time.NewTimer(sleep)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
if err := runTilesFromNMC(ctx, opts); err != nil {
|
||||
log.Printf("[radar] tiles run error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tryStartupCatchup(ctx context.Context, loc *time.Location, opts Options) {
|
||||
now := time.Now().In(loc)
|
||||
lastSlot := roundDown6(now).Add(-opts.Delay)
|
||||
_ = downloadForSlot(ctx, lastSlot, opts)
|
||||
}
|
||||
|
||||
// roundDown6 returns t truncated to the nearest lower multiple of 6 minutes.
|
||||
func roundDown6(t time.Time) time.Time {
|
||||
// Truncate supports arbitrary durations.
|
||||
return t.Truncate(6 * time.Minute)
|
||||
}
|
||||
|
||||
func roundDownN(t time.Time, d time.Duration) time.Time {
|
||||
return t.Truncate(d)
|
||||
}
|
||||
|
||||
// downloadForSlot performs the actual download for the given nominal run time.
|
||||
// It constructs a filename like: radar_COMP_20060102_1504.png under opts.OutputDir.
|
||||
// If RADAR_BASE_URL is provided, it's treated as a format string with Go time
|
||||
// layout tokens, e.g. https://example/COMP/%Y/%m/%d/%H%M.png (Go layout applied).
|
||||
func downloadForSlot(ctx context.Context, runAt time.Time, opts Options) error {
|
||||
// Determine the product nominal time: align to boundary (6-minute steps)
|
||||
slot := roundDown6(runAt)
|
||||
fname := fmt.Sprintf("radar_z%d_y%d_x%d_%s.bin", opts.Z, opts.Y, opts.X, slot.Format("20060102_1504"))
|
||||
dest := filepath.Join(opts.OutputDir, fname)
|
||||
|
||||
// If file already exists, skip.
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if opts.BaseURL == "" {
|
||||
// No remote configured: create a placeholder to prove scheduling works.
|
||||
content := []byte(fmt.Sprintf("placeholder for %s\n", slot.Format(time.RFC3339)))
|
||||
if err := os.WriteFile(dest, content, 0o644); err != nil {
|
||||
return fmt.Errorf("write placeholder: %w", err)
|
||||
}
|
||||
log.Printf("[radar] wrote placeholder %s", dest)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert a possibly strftime-like template to Go layout tokens.
|
||||
url := buildURLFromTemplate(opts.BaseURL, slot, opts.Z, opts.Y, opts.X)
|
||||
// HTTP GET with timeout.
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
// CMA requires referer/origin headers typically
|
||||
req.Header.Set("Referer", "https://data.cma.cn/")
|
||||
req.Header.Set("Origin", "https://data.cma.cn")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
// Write to temp then rename.
|
||||
tmp := dest + ".part"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp: %w", err)
|
||||
}
|
||||
_, copyErr := io.Copy(f, resp.Body)
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("write body: %w", copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("close temp: %w", closeErr)
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
// If cross-device rename fails, fallback to copy
|
||||
if !errors.Is(err, os.ErrInvalid) {
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
data, rerr := os.ReadFile(tmp)
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("read temp for copy: %w", rerr)
|
||||
}
|
||||
if werr := os.WriteFile(dest, data, 0o644); werr != nil {
|
||||
return fmt.Errorf("write final: %w", werr)
|
||||
}
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
log.Printf("[radar] saved %s (url=%s)", dest, url)
|
||||
|
||||
// Optionally store to DB
|
||||
if opts.StoreToDB {
|
||||
// Read the just-written bytes to pass to DB store; alternatively stream earlier
|
||||
b, rerr := os.ReadFile(dest)
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("read saved tile for DB: %w", rerr)
|
||||
}
|
||||
if err := StoreTileBytes(ctx, url, b); err != nil {
|
||||
return fmt.Errorf("store tile to DB: %w", err)
|
||||
}
|
||||
log.Printf("[radar] stored to DB: z=%d y=%d x=%d t=%s", opts.Z, opts.Y, opts.X, slot.Format("2006-01-02 15:04"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildURLFromTemplate(tpl string, t time.Time, z, y, x int) string {
|
||||
// Support a minimal subset of strftime tokens to Go layout.
|
||||
repl := map[string]string{
|
||||
"%Y": "2006",
|
||||
"%m": "01",
|
||||
"%d": "02",
|
||||
"%H": "15",
|
||||
"%M": "04",
|
||||
}
|
||||
out := tpl
|
||||
for k, v := range repl {
|
||||
out = strings.ReplaceAll(out, k, t.Format(v))
|
||||
}
|
||||
// Replace index placeholders
|
||||
out = strings.ReplaceAll(out, "{z}", fmt.Sprintf("%d", z))
|
||||
out = strings.ReplaceAll(out, "{y}", fmt.Sprintf("%d", y))
|
||||
out = strings.ReplaceAll(out, "{x}", fmt.Sprintf("%d", x))
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------- NMC -> CMA pipeline -------------------
|
||||
|
||||
type nmcRadar struct {
|
||||
Title string `json:"title"`
|
||||
Image string `json:"image"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type nmcResp struct {
|
||||
Radar nmcRadar `json:"radar"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if config.GetConfig().Radar.RealtimeEnabled {
|
||||
return runRealtimeFromCaiyun(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 仅瓦片下载:查询 NMC,解析时间,按该时刻下载 CMA 瓦片(若DB已存在则跳过)
|
||||
func runTilesFromNMC(ctx context.Context, opts Options) error {
|
||||
// 1) Fetch NMC JSON
|
||||
api := "https://www.nmc.cn/rest/weather?stationid=Wqsps"
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("nmc request: %w", err)
|
||||
}
|
||||
req.Header.Set("Referer", "https://www.nmc.cn/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("nmc get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("nmc status: %d", resp.StatusCode)
|
||||
}
|
||||
// 仅从 data.radar.image 读取
|
||||
var top struct {
|
||||
Data struct {
|
||||
Radar nmcRadar `json:"radar"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&top); err != nil {
|
||||
return fmt.Errorf("nmc decode: %w", err)
|
||||
}
|
||||
img := top.Data.Radar.Image
|
||||
if img == "" {
|
||||
return fmt.Errorf("nmc data.radar.image empty")
|
||||
}
|
||||
|
||||
// 2) Extract filename and 17-digit timestamp from image
|
||||
// Example: /product/2025/09/23/RDCP/SEVP_AOC_RDCP_SLDAS3_ECREF_ANCN_L88_PI_20250923033600000.PNG?v=...
|
||||
// filename: SEVP_AOC_RDCP_SLDAS3_ECREF_ANCN_L88_PI_20250923033600000.PNG
|
||||
// digits: 20250923033600000 -> use first 12 as yyyyMMddHHmm
|
||||
if u, err := neturl.Parse(img); err == nil { // strip query if present
|
||||
img = u.Path
|
||||
}
|
||||
parts := strings.Split(img, "/")
|
||||
fname := parts[len(parts)-1]
|
||||
digits := reDigits17.FindString(fname)
|
||||
if digits == "" {
|
||||
return fmt.Errorf("no 17-digit timestamp in %s", fname)
|
||||
}
|
||||
// Parse yyyyMMddHHmm from first 12 digits as UTC, then +8h
|
||||
utc12 := digits[:12]
|
||||
utcT, err := time.ParseInLocation("200601021504", utc12, time.UTC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse utc time: %w", err)
|
||||
}
|
||||
local := utcT.Add(8 * time.Hour)
|
||||
|
||||
// 3) Build CMA tile URL(s) for fixed z/y/x
|
||||
dateStr := local.Format("20060102")
|
||||
hh := local.Format("15")
|
||||
mm := local.Format("04")
|
||||
// Prepare tile list: primary (opts or default Nanning) + a few defaults + optional config aliases
|
||||
z, y, x := opts.Z, opts.Y, opts.X
|
||||
if z == 0 && y == 0 && x == 0 {
|
||||
z, y, x = 7, 40, 102
|
||||
}
|
||||
type tcoord struct{ z, y, x int }
|
||||
tiles := []tcoord{
|
||||
{z, y, x}, // primary
|
||||
{7, 40, 104},
|
||||
{7, 42, 104},
|
||||
{7, 40, 103},
|
||||
{7, 39, 102},
|
||||
}
|
||||
// Append from config aliases, if present (dedup later)
|
||||
if cfg := config.GetConfig(); cfg != nil {
|
||||
for _, a := range cfg.Radar.Aliases {
|
||||
if a.Z > 0 {
|
||||
tiles = append(tiles, tcoord{a.Z, a.Y, a.X})
|
||||
}
|
||||
}
|
||||
}
|
||||
// de-duplicate if same
|
||||
seen := map[string]bool{}
|
||||
for _, tc := range tiles {
|
||||
key := fmt.Sprintf("%d/%d/%d", tc.z, tc.y, tc.x)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if err := downloadAndStoreTile(ctx, local, dateStr, hh, mm, tc.z, tc.y, tc.x, opts); err != nil {
|
||||
log.Printf("[radar] download/store %s failed: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 仅彩云实况(10分钟一次)
|
||||
func runRealtimeFromCaiyun(ctx context.Context) error {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) WH65LP 设备批量
|
||||
token := os.Getenv("CAIYUN_TOKEN")
|
||||
if token == "" {
|
||||
token = cfg.Forecast.CaiyunToken
|
||||
}
|
||||
if token == "" {
|
||||
log.Printf("[radar] skip station realtime: missing CAIYUN_TOKEN")
|
||||
return nil
|
||||
}
|
||||
coords, err := database.ListWH65LPStationsWithLatLon(ctx, database.GetDB())
|
||||
if err != nil {
|
||||
log.Printf("[radar] list WH65LP stations failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
for _, s := range coords {
|
||||
if err := fetchAndStoreRadarRealtimeFor(ctx, s.StationID, s.Lat, s.Lon); err != nil {
|
||||
log.Printf("[radar] realtime(station=%s) failed: %v", s.StationID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadAndStoreTile(ctx context.Context, local time.Time, dateStr, hh, mm string, z, y, x int, opts Options) error {
|
||||
url := fmt.Sprintf("https://image.data.cma.cn/tiles/China/RADAR_L3_MST_CREF_GISJPG_Tiles_CR/%s/%s/%s/%d/%d/%d.bin", dateStr, hh, mm, z, y, x)
|
||||
// 若数据库已有该瓦片则跳过
|
||||
if ref, err := ParseCMATileURL(url); err == nil {
|
||||
exists, err := database.HasRadarTile(ctx, database.GetDB(), ref.Product, ref.DT, ref.Z, ref.Y, ref.X)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
log.Printf("[radar] skip existing tile in DB: %s %s z=%d y=%d x=%d", ref.Product, ref.DT.Format("2006-01-02 15:04"), ref.Z, ref.Y, ref.X)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
fnameOut := fmt.Sprintf("radar_z%d_y%d_x%d_%s.bin", z, y, x, local.Format("20060102_1504"))
|
||||
dest := filepath.Join(opts.OutputDir, fnameOut)
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return nil // already exists
|
||||
}
|
||||
if err := httpDownloadTo(ctx, url, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[radar] saved %s (url=%s)", dest, url)
|
||||
if opts.StoreToDB {
|
||||
b, rerr := os.ReadFile(dest)
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("read saved tile: %w", rerr)
|
||||
}
|
||||
if err := StoreTileBytes(ctx, url, b); err != nil {
|
||||
return fmt.Errorf("store tile db: %w", err)
|
||||
}
|
||||
log.Printf("[radar] stored to DB: %s", fnameOut)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func httpDownloadTo(ctx context.Context, url, dest string) error {
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Referer", "https://data.cma.cn/")
|
||||
req.Header.Set("Origin", "https://data.cma.cn")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
tmp := dest + ".part"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp: %w", err)
|
||||
}
|
||||
_, copyErr := io.Copy(f, resp.Body)
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("write body: %w", copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("close temp: %w", closeErr)
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
// fetchAndStoreRadarRealtime calls Caiyun realtime API for the Nanning radar station
|
||||
// and stores selected fields into table `radar_weather` with 10-minute bucketed dt.
|
||||
func fetchAndStoreRadarRealtimeFor(ctx context.Context, alias string, lat, lon float64) error {
|
||||
// Token: prefer env CAIYUN_TOKEN, else config
|
||||
token := os.Getenv("CAIYUN_TOKEN")
|
||||
if token == "" {
|
||||
token = config.GetConfig().Forecast.CaiyunToken
|
||||
}
|
||||
if token == "" {
|
||||
return fmt.Errorf("missing CAIYUN_TOKEN for Caiyun realtime API")
|
||||
}
|
||||
|
||||
// Build URL: lon,lat order; metric units
|
||||
api := fmt.Sprintf("https://api.caiyunapp.com/v2.6/%s/%.6f,%.6f/realtime?lang=zh_CN&unit=metric", token, lon, lat)
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build realtime request: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("realtime http get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("realtime status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Status string `json:"status"`
|
||||
Result struct {
|
||||
Realtime struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
Humidity float64 `json:"humidity"`
|
||||
Cloudrate float64 `json:"cloudrate"`
|
||||
Visibility float64 `json:"visibility"`
|
||||
Dswrf float64 `json:"dswrf"`
|
||||
Wind struct {
|
||||
Speed float64 `json:"speed"`
|
||||
Direction float64 `json:"direction"`
|
||||
} `json:"wind"`
|
||||
Pressure float64 `json:"pressure"`
|
||||
} `json:"realtime"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return fmt.Errorf("decode realtime: %w", err)
|
||||
}
|
||||
if payload.Status != "ok" {
|
||||
return fmt.Errorf("realtime api status=%s", payload.Status)
|
||||
}
|
||||
|
||||
// Align to configured bucket in Asia/Shanghai
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
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()
|
||||
rt := payload.Result.Realtime
|
||||
// Caiyun wind.speed is in km/h under metric; convert to m/s for storage/display
|
||||
windSpeedMS := rt.Wind.Speed / 3.6
|
||||
return database.UpsertRadarWeather(ctx, db, alias, lat, lon, dt,
|
||||
rt.Temperature, rt.Humidity, rt.Cloudrate, rt.Visibility, rt.Dswrf,
|
||||
windSpeedMS, rt.Wind.Direction, rt.Pressure,
|
||||
)
|
||||
}
|
||||
|
||||
func envEnabledDefaultTrue() bool {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv("RADAR_ENABLED")))
|
||||
if v == "" {
|
||||
return true
|
||||
}
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
|
||||
func getenvDefault(key, def string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func getenvIntDefault(key string, def int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
var n int
|
||||
_, err := fmt.Sscanf(v, "%d", &n)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
84
internal/radar/store.go
Normal file
84
internal/radar/store.go
Normal file
@ -0,0 +1,84 @@
|
||||
package radar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
var (
|
||||
// Matches .../<PRODUCT>/<YYYYMMDD>/<HH>/<mm>/<z>/<y>/<x>.bin
|
||||
tileRE = regexp.MustCompile(`(?i)/tiles/.+?/([^/]+)/([0-9]{8})/([0-9]{2})/([0-9]{2})/([0-9]+)/([0-9]+)/([0-9]+)\.bin$`)
|
||||
)
|
||||
|
||||
// TileRef references a CMA radar tile.
|
||||
type TileRef struct {
|
||||
Product string
|
||||
DT time.Time // nominal time in Asia/Shanghai
|
||||
Z, Y, X int
|
||||
}
|
||||
|
||||
// ParseCMATileURL parses a CMA tile URL or path and extracts product, time, z/y/x.
|
||||
// Accepts full URL or path that ends with /tiles/.../x.bin.
|
||||
func ParseCMATileURL(u string) (TileRef, error) {
|
||||
// Normalize path part
|
||||
p := u
|
||||
// Optionally strip query/hash
|
||||
if i := strings.IndexAny(p, "?#"); i >= 0 {
|
||||
p = p[:i]
|
||||
}
|
||||
p = path.Clean(p)
|
||||
m := tileRE.FindStringSubmatch(p)
|
||||
if len(m) == 0 {
|
||||
return TileRef{}, fmt.Errorf("unrecognized CMA tile path: %s", u)
|
||||
}
|
||||
|
||||
product := m[1]
|
||||
yyyymmdd := m[2]
|
||||
hh := m[3]
|
||||
mm := m[4]
|
||||
z := mustAtoi(m[5])
|
||||
y := mustAtoi(m[6])
|
||||
x := mustAtoi(m[7])
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
dt, err := time.ParseInLocation("20060102 15 04", fmt.Sprintf("%s %s %s", yyyymmdd, hh, mm), loc)
|
||||
if err != nil {
|
||||
return TileRef{}, fmt.Errorf("parse time: %w", err)
|
||||
}
|
||||
|
||||
return TileRef{Product: product, DT: dt, Z: z, Y: y, X: x}, nil
|
||||
}
|
||||
|
||||
func mustAtoi(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
|
||||
// StoreTileBytes parses the URL, computes metadata and upserts into radar_tiles.
|
||||
func StoreTileBytes(ctx context.Context, urlOrPath string, data []byte) error {
|
||||
ref, err := ParseCMATileURL(urlOrPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db := database.GetDB()
|
||||
return database.UpsertRadarTile(ctx, db, ref.Product, ref.DT, ref.Z, ref.Y, ref.X, 256, 256, data)
|
||||
}
|
||||
|
||||
// ImportTileFile reads the file and stores it into DB using the URL for metadata.
|
||||
func ImportTileFile(ctx context.Context, urlOrPath, filePath string) error {
|
||||
b, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
return StoreTileBytes(ctx, urlOrPath, b)
|
||||
}
|
||||
266
internal/rain/scheduler.go
Normal file
266
internal/rain/scheduler.go
Normal file
@ -0,0 +1,266 @@
|
||||
package rain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
// Options controls the rain (CMPA hourly precip) scheduler behavior.
|
||||
type Options struct {
|
||||
Enable bool
|
||||
OutputDir string
|
||||
BaseURL string // template with %Y%m%d/%H/%M and {z}/{y}/{x}; time is UTC
|
||||
MaxRetries int
|
||||
StoreToDB bool
|
||||
Tiles [][3]int // list of (z,y,x); defaults to [[7,40,102],[7,40,104]]
|
||||
}
|
||||
|
||||
// Start starts the CMPA hourly rain tile downloader.
|
||||
// Runs every 10 minutes aligned to 10-minute boundaries. For each tick at local time T,
|
||||
// constructs the slot as floor_to_hour(T) - 1h (last completed hour), converts to UTC
|
||||
// (slot_utc = slot_local - 8h), and downloads 0-minute tile for each configured (z,y,x).
|
||||
func Start(ctx context.Context, opts Options) error {
|
||||
if !opts.Enable && !envEnabledDefaultTrue() {
|
||||
log.Println("[rain] scheduler disabled")
|
||||
return nil
|
||||
}
|
||||
if opts.OutputDir == "" {
|
||||
if v := os.Getenv("RAIN_DIR"); v != "" {
|
||||
opts.OutputDir = v
|
||||
} else {
|
||||
exe, _ := os.Executable()
|
||||
exeDir := filepath.Dir(exe)
|
||||
opts.OutputDir = filepath.Join(exeDir, "rain_data")
|
||||
}
|
||||
}
|
||||
if opts.MaxRetries == 0 {
|
||||
opts.MaxRetries = getenvIntDefault("RAIN_MAX_RETRIES", 2)
|
||||
}
|
||||
if opts.BaseURL == "" {
|
||||
opts.BaseURL = getenvDefault("RAIN_BASE_URL", "https://image.data.cma.cn/tiles/China/CMPA_RT_China_0P01_HOR-PRE_GISJPG_Tiles/%Y%m%d/%H/%M/{z}/{y}/{x}.bin")
|
||||
}
|
||||
if len(opts.Tiles) == 0 {
|
||||
opts.Tiles = [][3]int{{7, 40, 102}, {7, 40, 104}}
|
||||
}
|
||||
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create rain output dir: %w", err)
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
|
||||
// immediate first run
|
||||
go func() {
|
||||
if err := runOnce(ctx, opts, loc); err != nil {
|
||||
log.Printf("[rain] first run error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// every 10 minutes
|
||||
go func() {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
runAt := roundDownN(now, 10*time.Minute).Add(10 * time.Minute)
|
||||
sleep := time.Until(runAt)
|
||||
if sleep < 0 {
|
||||
sleep = 0
|
||||
}
|
||||
timer := time.NewTimer(sleep)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
if err := runOnce(ctx, opts, loc); err != nil {
|
||||
log.Printf("[rain] run error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("[rain] scheduler started (10m, dir=%s, tiles=%d)", opts.OutputDir, len(opts.Tiles))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runOnce(ctx context.Context, opts Options, loc *time.Location) error {
|
||||
// target hour: current hour at 00 (floor_to_hour(now))
|
||||
// e.g., 10:15 -> 10:00; if尚未发布则下载可能失败,等待下一次10分钟重试
|
||||
now := time.Now().In(loc)
|
||||
slotLocal := now.Truncate(time.Hour)
|
||||
// UTC for URL path
|
||||
slotUTC := slotLocal.Add(-8 * time.Hour).In(time.UTC)
|
||||
log.Printf("[rain] tick target hour: local=%s (CST), utc=%s (UTC)", slotLocal.Format("2006-01-02 15:04"), slotUTC.Format("2006-01-02 15:04"))
|
||||
dateStr := slotUTC.Format("20060102")
|
||||
hh := slotUTC.Format("15")
|
||||
mm := "00" // hourly product uses minute 00
|
||||
|
||||
for _, t := range opts.Tiles {
|
||||
z, y, x := t[0], t[1], t[2]
|
||||
if err := downloadAndStoreTile(ctx, slotLocal, dateStr, hh, mm, z, y, x, opts); err != nil {
|
||||
log.Printf("[rain] download/store z=%d y=%d x=%d failed: %v", z, y, x, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadAndStoreTile(ctx context.Context, local time.Time, dateStr, hh, mm string, z, y, x int, opts Options) error {
|
||||
url := fmt.Sprintf("https://image.data.cma.cn/tiles/China/CMPA_RT_China_0P01_HOR-PRE_GISJPG_Tiles/%s/%s/%s/%d/%d/%d.bin", dateStr, hh, mm, z, y, x)
|
||||
// skip if exists in DB; dt source configurable (default: local slot time)
|
||||
dtSource := strings.ToLower(getenvDefault("RAIN_DT_SOURCE", "local")) // local|url
|
||||
var product string
|
||||
var dtForKey time.Time
|
||||
if ref, err := ParseCMPATileURL(url); err == nil {
|
||||
product = ref.Product
|
||||
if dtSource == "url" {
|
||||
dtForKey = ref.DT
|
||||
} else {
|
||||
dtForKey = local
|
||||
}
|
||||
exists, err := databaseHas(ctx, product, dtForKey, z, y, x)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
log.Printf("[rain] skip: already in DB z=%d y=%d x=%d dt(%s)=%s url=%s", z, y, x, dtSource, dtForKey.Format("2006-01-02 15:04"), url)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fname := fmt.Sprintf("rain_z%d_y%d_x%d_%s.bin", z, y, x, local.Format("20060102_1504"))
|
||||
dest := filepath.Join(opts.OutputDir, fname)
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
log.Printf("[rain] skip: file exists %s", dest)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpDownloadTo(ctx, url, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[rain] saved %s (url=%s)", dest, url)
|
||||
|
||||
if opts.StoreToDB {
|
||||
b, rerr := os.ReadFile(dest)
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("read saved tile: %w", rerr)
|
||||
}
|
||||
// Determine product and dt according to dtSource
|
||||
if product == "" {
|
||||
if ref, e := ParseCMPATileURL(url); e == nil {
|
||||
product = ref.Product
|
||||
if dtSource == "url" {
|
||||
dtForKey = ref.DT
|
||||
} else {
|
||||
dtForKey = local
|
||||
}
|
||||
}
|
||||
}
|
||||
if product == "" {
|
||||
return fmt.Errorf("cannot parse product from url for DB store")
|
||||
}
|
||||
if dtForKey.IsZero() {
|
||||
if dtSource == "url" {
|
||||
return fmt.Errorf("dt source=url but failed to parse dt")
|
||||
}
|
||||
dtForKey = local
|
||||
}
|
||||
if err := database.UpsertRainTile(ctx, database.GetDB(), product, dtForKey, z, y, x, 256, 256, b); err != nil {
|
||||
return fmt.Errorf("store tile db: %w", err)
|
||||
}
|
||||
log.Printf("[rain] stored to DB: %s (dt=%s, source=%s)", fname, dtForKey.Format("2006-01-02 15:04:05"), dtSource)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func httpDownloadTo(ctx context.Context, url, dest string) error {
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Referer", "https://data.cma.cn/")
|
||||
req.Header.Set("Origin", "https://data.cma.cn")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
tmp := dest + ".part"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp: %w", err)
|
||||
}
|
||||
_, copyErr := io.Copy(f, resp.Body)
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("write body: %w", copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("close temp: %w", closeErr)
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
// Cross-device fallback
|
||||
if !errors.Is(err, os.ErrInvalid) {
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
data, rerr := os.ReadFile(tmp)
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("read temp: %w", rerr)
|
||||
}
|
||||
if werr := os.WriteFile(dest, data, 0o644); werr != nil {
|
||||
return fmt.Errorf("write final: %w", werr)
|
||||
}
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// small env helpers (duplicated minimal set to avoid cross-package deps)
|
||||
func getenvDefault(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
func getenvIntDefault(k string, def int) int {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
func envEnabledDefaultTrue() bool {
|
||||
v := strings.ToLower(os.Getenv("RAIN_ENABLED"))
|
||||
if v == "" {
|
||||
return true
|
||||
}
|
||||
return v == "1" || v == "true" || v == "yes"
|
||||
}
|
||||
|
||||
func databaseHas(ctx context.Context, product string, dt time.Time, z, y, x int) (bool, error) {
|
||||
return database.HasRainTile(ctx, database.GetDB(), product, dt, z, y, x)
|
||||
}
|
||||
|
||||
func roundDownN(t time.Time, d time.Duration) time.Time { return t.Truncate(d) }
|
||||
81
internal/rain/store.go
Normal file
81
internal/rain/store.go
Normal file
@ -0,0 +1,81 @@
|
||||
package rain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
var (
|
||||
// Matches .../<PRODUCT>/<YYYYMMDD>/<HH>/<mm>/<z>/<y>/<x>.bin
|
||||
// Example: /tiles/China/CMPA_RT_China_0P01_HOR-PRE_GISJPG_Tiles/20251007/01/00/7/40/102.bin
|
||||
tileRE = regexp.MustCompile(`(?i)/tiles/.+?/([^/]+)/([0-9]{8})/([0-9]{2})/([0-9]{2})/([0-9]+)/([0-9]+)/([0-9]+)\.bin$`)
|
||||
)
|
||||
|
||||
// TileRef references a CMPA rain tile.
|
||||
type TileRef struct {
|
||||
Product string
|
||||
DT time.Time // nominal time in Asia/Shanghai (UTC+8)
|
||||
Z, Y, X int
|
||||
}
|
||||
|
||||
// ParseCMPATileURL parses a CMPA tile URL/path and extracts product, time (UTC+8), z/y/x.
|
||||
// The timestamp in the path is UTC; we convert to Asia/Shanghai by adding 8h.
|
||||
func ParseCMPATileURL(u string) (TileRef, error) {
|
||||
p := u
|
||||
if i := strings.IndexAny(p, "?#"); i >= 0 {
|
||||
p = p[:i]
|
||||
}
|
||||
p = path.Clean(p)
|
||||
m := tileRE.FindStringSubmatch(p)
|
||||
if len(m) == 0 {
|
||||
return TileRef{}, fmt.Errorf("unrecognized CMPA tile path: %s", u)
|
||||
}
|
||||
product := m[1]
|
||||
yyyymmdd := m[2]
|
||||
hh := m[3]
|
||||
mm := m[4]
|
||||
z := mustAtoi(m[5])
|
||||
y := mustAtoi(m[6])
|
||||
x := mustAtoi(m[7])
|
||||
|
||||
// Parse as UTC then shift to CST(+8)
|
||||
utcT, err := time.ParseInLocation("20060102 15 04", fmt.Sprintf("%s %s %s", yyyymmdd, hh, mm), time.UTC)
|
||||
if err != nil {
|
||||
return TileRef{}, fmt.Errorf("parse utc time: %w", err)
|
||||
}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
dt := utcT.In(loc)
|
||||
return TileRef{Product: product, DT: dt, Z: z, Y: y, X: x}, nil
|
||||
}
|
||||
|
||||
func mustAtoi(s string) int { n, _ := strconv.Atoi(s); return n }
|
||||
|
||||
// StoreTileBytes parses the URL, computes metadata and upserts into rain_tiles.
|
||||
func StoreTileBytes(ctx context.Context, urlOrPath string, data []byte) error {
|
||||
ref, err := ParseCMPATileURL(urlOrPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 可选:强制 +8 小时(若上游传入的时间已为 UTC 且未转换,可用此开关修正)
|
||||
if strings.EqualFold(strings.TrimSpace(strings.ToLower(os.Getenv("RAIN_FORCE_SHIFT8"))), "1") {
|
||||
ref.DT = ref.DT.Add(8 * time.Hour)
|
||||
}
|
||||
// 可选调试:打印解析出的时间(CST)与 URL
|
||||
if strings.EqualFold(strings.TrimSpace(strings.ToLower(os.Getenv("RAIN_DEBUG"))), "1") {
|
||||
log.Printf("[rain] store tile: url=%s -> product=%s dt(local)=%s z=%d y=%d x=%d",
|
||||
urlOrPath, ref.Product, ref.DT.Format("2006-01-02 15:04:05 -0700"), ref.Z, ref.Y, ref.X)
|
||||
}
|
||||
db := database.GetDB()
|
||||
return database.UpsertRainTile(ctx, db, ref.Product, ref.DT, ref.Z, ref.Y, ref.X, 256, 256, data)
|
||||
}
|
||||
80
internal/selftest/selftest.go
Normal file
80
internal/selftest/selftest.go
Normal file
@ -0,0 +1,80 @@
|
||||
package selftest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
// Run 执行启动前自检
|
||||
// 1) 数据库可用 2) 关键表存在 3) 基础查询可执行
|
||||
func Run(ctx context.Context) error {
|
||||
db := database.GetDB()
|
||||
if err := pingDB(ctx, db); err != nil {
|
||||
return fmt.Errorf("数据库连通性失败: %w", err)
|
||||
}
|
||||
if err := ensureTables(ctx, db); err != nil {
|
||||
return fmt.Errorf("关键表缺失: %w", err)
|
||||
}
|
||||
if err := basicQuery(ctx, db); err != nil {
|
||||
return fmt.Errorf("基础查询失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pingDB(ctx context.Context, db *sql.DB) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
return db.PingContext(ctx)
|
||||
}
|
||||
|
||||
func ensureTables(ctx context.Context, db *sql.DB) error {
|
||||
required := []string{
|
||||
"public.stations",
|
||||
"public.rs485_weather_data",
|
||||
"public.rs485_weather_10min",
|
||||
"public.forecast_hourly",
|
||||
}
|
||||
for _, t := range required {
|
||||
var exists bool
|
||||
if err := db.QueryRowContext(ctx, "SELECT to_regclass($1) IS NOT NULL", t).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("缺少表: %s", t)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func basicQuery(ctx context.Context, db *sql.DB) error {
|
||||
// 若无站点则跳过后续测试
|
||||
var stationID string
|
||||
_ = db.QueryRowContext(ctx, "SELECT station_id FROM stations LIMIT 1").Scan(&stationID)
|
||||
|
||||
// 10分钟表可被查询(允许为空)
|
||||
var cnt int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM rs485_weather_10min").Scan(&cnt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 若有站点,做一次安全时间窗聚合验证(不要求有数据,只要语句可执行)
|
||||
if stationID != "" {
|
||||
from := time.Now().Add(-2 * time.Hour).Truncate(time.Hour)
|
||||
to := time.Now().Truncate(time.Hour)
|
||||
_, err := db.QueryContext(ctx,
|
||||
`WITH base AS (
|
||||
SELECT * FROM rs485_weather_10min WHERE station_id=$1 AND bucket_start >= $2 AND bucket_start <= $3
|
||||
), g AS (
|
||||
SELECT date_trunc('hour', bucket_start) AS grp, SUM(sample_count) AS n_sum FROM base GROUP BY 1
|
||||
) SELECT COUNT(*) FROM g`, stationID, from, to,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
418
internal/server/gin.go
Normal file
418
internal/server/gin.go
Normal file
@ -0,0 +1,418 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"weatherstation/internal/config"
|
||||
"weatherstation/internal/database"
|
||||
"weatherstation/pkg/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var staticBaseDir string
|
||||
|
||||
// StartGinServer 启动Gin Web服务器
|
||||
func StartGinServer() error {
|
||||
// 设置Gin模式
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
// 创建Gin引擎
|
||||
r := gin.Default()
|
||||
|
||||
// 以可执行文件所在目录为基准查找资源(优先),其次回退到相对路径
|
||||
exe, _ := os.Executable()
|
||||
exeDir := filepath.Dir(exe)
|
||||
|
||||
// 模板目录
|
||||
candidatesTpl := []string{
|
||||
filepath.Join(exeDir, "templates", "*"),
|
||||
filepath.Join(exeDir, "..", "templates", "*"),
|
||||
filepath.Join("templates", "*"),
|
||||
filepath.Join("..", "templates", "*"),
|
||||
}
|
||||
var tplGlob string
|
||||
for _, c := range candidatesTpl {
|
||||
base := filepath.Dir(c)
|
||||
if st, err := os.Stat(base); err == nil && st.IsDir() {
|
||||
tplGlob = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if tplGlob == "" {
|
||||
tplGlob = filepath.Join("templates", "*")
|
||||
}
|
||||
r.LoadHTMLGlob(tplGlob)
|
||||
|
||||
// 静态目录
|
||||
candidatesStatic := []string{
|
||||
filepath.Join(exeDir, "static"),
|
||||
filepath.Join(exeDir, "..", "static"),
|
||||
"./static",
|
||||
"../static",
|
||||
}
|
||||
staticDir := candidatesStatic[0]
|
||||
for _, c := range candidatesStatic {
|
||||
if st, err := os.Stat(c); err == nil && st.IsDir() {
|
||||
staticDir = c
|
||||
break
|
||||
}
|
||||
}
|
||||
staticBaseDir = staticDir
|
||||
r.Static("/static", staticDir)
|
||||
|
||||
// 前端SPA(Angular)静态资源与路由回退
|
||||
// 构建产物目录(可执行目录优先)
|
||||
r.GET("/ui/*filepath", func(c *gin.Context) {
|
||||
// 物理文件优先,否则回退到 index.html(支持前端路由)
|
||||
requested := c.Param("filepath")
|
||||
if requested == "" || requested == "/" {
|
||||
// index.html
|
||||
candidates := []string{
|
||||
filepath.Join(exeDir, "core/frontend/dist/ui/index.html"),
|
||||
filepath.Join(exeDir, "..", "core/frontend/dist/ui/index.html"),
|
||||
"./core/frontend/dist/ui/index.html",
|
||||
"../core/frontend/dist/ui/index.html",
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
c.File(p)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.String(http.StatusNotFound, "ui not found")
|
||||
return
|
||||
}
|
||||
// 选择 baseDir
|
||||
baseDirCandidates := []string{
|
||||
filepath.Join(exeDir, "core/frontend/dist/ui"),
|
||||
filepath.Join(exeDir, "..", "core/frontend/dist/ui"),
|
||||
"./core/frontend/dist/ui",
|
||||
"../core/frontend/dist/ui",
|
||||
}
|
||||
baseDir := baseDirCandidates[0]
|
||||
for _, d := range baseDirCandidates {
|
||||
if st, err := os.Stat(d); err == nil && st.IsDir() {
|
||||
baseDir = d
|
||||
break
|
||||
}
|
||||
}
|
||||
full := baseDir + requested
|
||||
if _, err := os.Stat(full); err == nil {
|
||||
c.File(full)
|
||||
return
|
||||
}
|
||||
c.File(filepath.Join(baseDir, "index.html"))
|
||||
})
|
||||
|
||||
// 路由设置
|
||||
r.GET("/", indexHandler)
|
||||
r.GET("/radar/nanning", radarNanningHandler)
|
||||
r.GET("/radar/guangzhou", radarGuangzhouHandler)
|
||||
r.GET("/radar/panyu", radarPanyuHandler)
|
||||
r.GET("/radar/haizhu", radarHaizhuHandler)
|
||||
r.GET("/radar/imdroid", imdroidRadarHandler)
|
||||
|
||||
// API路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.GET("/system/status", systemStatusHandler)
|
||||
api.GET("/stations", getStationsHandler)
|
||||
api.GET("/data", getDataHandler)
|
||||
api.GET("/forecast", getForecastHandler)
|
||||
api.GET("/radar/latest", latestRadarTileHandler)
|
||||
api.GET("/radar/at", radarTileAtHandler)
|
||||
api.GET("/radar/nearest", nearestRadarTileHandler)
|
||||
api.GET("/radar/times", radarTileTimesHandler)
|
||||
// multi-tiles at same dt
|
||||
api.GET("/radar/tiles_at", radarTilesAtHandler)
|
||||
api.GET("/radar/weather_latest", latestRadarWeatherHandler)
|
||||
api.GET("/radar/weather_at", radarWeatherAtHandler)
|
||||
api.GET("/radar/weather_aliases", radarWeatherAliasesHandler)
|
||||
api.GET("/radar/aliases", radarConfigAliasesHandler)
|
||||
api.GET("/radar/weather_nearest", radarWeatherNearestHandler)
|
||||
// Rain CMPA hourly tiles
|
||||
api.GET("/rain/latest", latestRainTileHandler)
|
||||
api.GET("/rain/at", rainTileAtHandler)
|
||||
api.GET("/rain/nearest", nearestRainTileHandler)
|
||||
api.GET("/rain/times", rainTileTimesHandler)
|
||||
api.GET("/rain/tiles_at", rainTilesAtHandler)
|
||||
}
|
||||
|
||||
// 获取配置的Web端口
|
||||
port := config.GetConfig().Server.WebPort
|
||||
if port == 0 {
|
||||
port = 10003 // 默认端口
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port)
|
||||
return r.Run(fmt.Sprintf(":%d", port))
|
||||
}
|
||||
|
||||
// indexHandler 处理主页请求
|
||||
func indexHandler(c *gin.Context) {
|
||||
data := types.PageData{
|
||||
Title: "英卓气象站",
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "index.html", data)
|
||||
}
|
||||
|
||||
// radarNanningHandler 南宁雷达站占位页
|
||||
func radarNanningHandler(c *gin.Context) {
|
||||
data := types.PageData{
|
||||
Title: "雷达页面",
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||
}
|
||||
|
||||
// radarGuangzhouHandler 广州雷达站占位页
|
||||
func radarGuangzhouHandler(c *gin.Context) {
|
||||
data := types.PageData{
|
||||
Title: "雷达页面",
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||
}
|
||||
|
||||
// radarHaizhuHandler 海珠雷达站占位页
|
||||
func radarHaizhuHandler(c *gin.Context) {
|
||||
data := types.PageData{
|
||||
Title: "雷达页面",
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||
}
|
||||
|
||||
// radarPanyuHandler 番禺雷达站占位页
|
||||
func radarPanyuHandler(c *gin.Context) {
|
||||
data := types.PageData{
|
||||
Title: "雷达页面",
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||
}
|
||||
|
||||
func imdroidRadarHandler(c *gin.Context) {
|
||||
data := types.PageData{
|
||||
Title: "英卓雷达站",
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||
}
|
||||
|
||||
func buildKmlLayersJSON() template.JS {
|
||||
layers := loadKmlLayers()
|
||||
if len(layers) == 0 {
|
||||
return template.JS("[]")
|
||||
}
|
||||
bytes, err := json.Marshal(layers)
|
||||
if err != nil {
|
||||
log.Printf("序列化KML列表失败: %v", err)
|
||||
return template.JS("[]")
|
||||
}
|
||||
return template.JS(bytes)
|
||||
}
|
||||
|
||||
func loadKmlLayers() []types.KmlLayer {
|
||||
if staticBaseDir == "" {
|
||||
return nil
|
||||
}
|
||||
kmlDir := filepath.Join(staticBaseDir, "kml")
|
||||
entries, err := os.ReadDir(kmlDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
layers := make([]types.KmlLayer, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".kml") {
|
||||
continue
|
||||
}
|
||||
display := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
layers = append(layers, types.KmlLayer{
|
||||
Name: display,
|
||||
URL: "/static/kml/" + name,
|
||||
})
|
||||
}
|
||||
sort.Slice(layers, func(i, j int) bool {
|
||||
return strings.ToLower(layers[i].Name) < strings.ToLower(layers[j].Name)
|
||||
})
|
||||
return layers
|
||||
}
|
||||
|
||||
// systemStatusHandler 处理系统状态API请求
|
||||
func systemStatusHandler(c *gin.Context) {
|
||||
status := types.SystemStatus{
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// getStationsHandler 处理获取站点列表API请求
|
||||
func getStationsHandler(c *gin.Context) {
|
||||
stations, err := database.GetStations(database.GetDB())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询站点失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 为每个站点计算十进制ID
|
||||
for i := range stations {
|
||||
if len(stations[i].StationID) > 6 {
|
||||
hexID := stations[i].StationID[len(stations[i].StationID)-6:]
|
||||
if decimalID, err := strconv.ParseInt(hexID, 16, 64); err == nil {
|
||||
stations[i].DecimalID = strconv.FormatInt(decimalID, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stations)
|
||||
}
|
||||
|
||||
// getDataHandler 处理获取历史数据API请求
|
||||
func getDataHandler(c *gin.Context) {
|
||||
// 获取查询参数
|
||||
decimalID := c.Query("decimal_id")
|
||||
startTime := c.Query("start_time")
|
||||
endTime := c.Query("end_time")
|
||||
interval := c.Query("interval")
|
||||
|
||||
// 将十进制ID转换为十六进制(补足6位)
|
||||
decimalNum, err := strconv.ParseInt(decimalID, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点编号"})
|
||||
return
|
||||
}
|
||||
hexID := fmt.Sprintf("%06X", decimalNum)
|
||||
stationID := fmt.Sprintf("RS485-%s", hexID)
|
||||
|
||||
// 解析时间(按本地CST解析,避免被当作UTC)
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
start, err := time.ParseInLocation("2006-01-02 15:04:05", startTime, loc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的开始时间"})
|
||||
return
|
||||
}
|
||||
|
||||
end, err := time.ParseInLocation("2006-01-02 15:04:05", endTime, loc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的结束时间"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取数据(改为基于10分钟聚合表的再聚合)
|
||||
var points []types.WeatherPoint
|
||||
if interval == "raw" {
|
||||
points, err = database.GetSeriesRaw(database.GetDB(), stationID, start, end)
|
||||
} else {
|
||||
points, err = database.GetSeriesFrom10Min(database.GetDB(), stationID, start, end, interval)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("查询数据失败: %v", err) // 记录具体错误到服务端日志
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("查询数据失败: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, points)
|
||||
}
|
||||
|
||||
// getForecastHandler 处理获取预报数据API请求
|
||||
func getForecastHandler(c *gin.Context) {
|
||||
// 获取查询参数
|
||||
stationID := c.Query("station_id")
|
||||
startTime := c.Query("from")
|
||||
endTime := c.Query("to")
|
||||
provider := c.Query("provider")
|
||||
versionsStr := c.DefaultQuery("versions", "1")
|
||||
versions, _ := strconv.Atoi(versionsStr)
|
||||
if versions <= 0 {
|
||||
versions = 1
|
||||
}
|
||||
|
||||
if stationID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少station_id参数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有提供时间范围,则默认查询未来3小时
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
|
||||
var start, end time.Time
|
||||
var err error
|
||||
|
||||
if startTime == "" || endTime == "" {
|
||||
// 默认查询未来3小时
|
||||
now := time.Now().In(loc)
|
||||
start = now.Truncate(time.Hour).Add(1 * time.Hour) // 下一个整点开始
|
||||
end = start.Add(3 * time.Hour) // 未来3小时
|
||||
} else {
|
||||
// 解析用户提供的时间
|
||||
start, err = time.ParseInLocation("2006-01-02 15:04:05", startTime, loc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的开始时间格式"})
|
||||
return
|
||||
}
|
||||
|
||||
end, err = time.ParseInLocation("2006-01-02 15:04:05", endTime, loc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的结束时间格式"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预报数据
|
||||
log.Printf("查询预报数据: stationID=%s, provider=%s, versions=%d, start=%s, end=%s", stationID, provider, versions, start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"))
|
||||
points, err := database.GetForecastData(database.GetDB(), stationID, start, end, provider, versions)
|
||||
if err != nil {
|
||||
log.Printf("查询预报数据失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("查询预报数据失败: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("查询到预报数据: %d 条", len(points))
|
||||
c.JSON(http.StatusOK, points)
|
||||
}
|
||||
748
internal/server/radar_api.go
Normal file
748
internal/server/radar_api.go
Normal file
@ -0,0 +1,748 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
"weatherstation/internal/config"
|
||||
"weatherstation/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type radarTileRecord struct {
|
||||
DT time.Time
|
||||
Z int
|
||||
Y int
|
||||
X int
|
||||
Width int
|
||||
Height int
|
||||
West float64
|
||||
South float64
|
||||
East float64
|
||||
North float64
|
||||
ResDeg float64
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type radarTileResponse struct {
|
||||
DT string `json:"dt"`
|
||||
Z int `json:"z"`
|
||||
Y int `json:"y"`
|
||||
X int `json:"x"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
West float64 `json:"west"`
|
||||
South float64 `json:"south"`
|
||||
East float64 `json:"east"`
|
||||
North float64 `json:"north"`
|
||||
ResDeg float64 `json:"res_deg"`
|
||||
Values [][]*float64 `json:"values"` // null 表示无效值
|
||||
}
|
||||
|
||||
func getLatestRadarTile(db *sql.DB, z, y, x int) (*radarTileRecord, error) {
|
||||
const q = `
|
||||
SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data
|
||||
FROM radar_tiles
|
||||
WHERE z=$1 AND y=$2 AND x=$3
|
||||
ORDER BY dt DESC
|
||||
LIMIT 1`
|
||||
var r radarTileRecord
|
||||
err := db.QueryRow(q, z, y, x).Scan(
|
||||
&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height,
|
||||
&r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func getRadarTileAt(db *sql.DB, z, y, x int, dt time.Time) (*radarTileRecord, error) {
|
||||
const q = `
|
||||
SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data
|
||||
FROM radar_tiles
|
||||
WHERE z=$1 AND y=$2 AND x=$3 AND dt=$4
|
||||
LIMIT 1`
|
||||
var r radarTileRecord
|
||||
err := db.QueryRow(q, z, y, x, dt).Scan(
|
||||
&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height,
|
||||
&r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// latestRadarTileHandler 返回指定 z/y/x 的最新瓦片,包含栅格 dBZ 值及元数据
|
||||
func latestRadarTileHandler(c *gin.Context) {
|
||||
// 固定默认 7/40/102,可通过查询参数覆盖
|
||||
z := parseIntDefault(c.Query("z"), 7)
|
||||
y := parseIntDefault(c.Query("y"), 40)
|
||||
x := parseIntDefault(c.Query("x"), 102)
|
||||
|
||||
rec, err := getLatestRadarTile(database.GetDB(), z, y, x)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到雷达瓦片"})
|
||||
return
|
||||
}
|
||||
|
||||
// 解码大端 int16 → dBZ (raw/10). >=32766 视为无效(null)
|
||||
w, h := rec.Width, rec.Height
|
||||
vals := make([][]*float64, h)
|
||||
// 每行 256 单元,每单元 2 字节
|
||||
if len(rec.Data) < w*h*2 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"})
|
||||
return
|
||||
}
|
||||
off := 0
|
||||
for row := 0; row < h; row++ {
|
||||
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
|
||||
// 限幅到 [0,75](大部分 CREF 标准范围),便于颜色映射
|
||||
if dbz < 0 {
|
||||
dbz = 0
|
||||
} else if dbz > 75 {
|
||||
dbz = 75
|
||||
}
|
||||
vv := dbz
|
||||
rowVals[col] = &vv
|
||||
}
|
||||
vals[row] = rowVals
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
resp := radarTileResponse{
|
||||
DT: rec.DT.In(loc).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)
|
||||
}
|
||||
|
||||
// radarTileAtHandler 返回指定 z/y/x 的指定时间瓦片
|
||||
func radarTileAtHandler(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
|
||||
}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"})
|
||||
return
|
||||
}
|
||||
rec, err := getRadarTileAt(database.GetDB(), z, y, x, dt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间瓦片"})
|
||||
return
|
||||
}
|
||||
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 row := 0; row < h; row++ {
|
||||
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[row] = rowVals
|
||||
}
|
||||
// 统一以 CST(+8) 输出
|
||||
resp := radarTileResponse{
|
||||
DT: rec.DT.In(loc).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)
|
||||
}
|
||||
|
||||
// radarTileTimesHandler 返回指定 z/y/x 的可用时间列表(倒序)
|
||||
func radarTileTimesHandler(c *gin.Context) {
|
||||
z := parseIntDefault(c.Query("z"), 7)
|
||||
y := parseIntDefault(c.Query("y"), 40)
|
||||
x := parseIntDefault(c.Query("x"), 102)
|
||||
fromStr := c.Query("from")
|
||||
toStr := c.Query("to")
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if fromStr != "" && toStr != "" {
|
||||
from, err1 := time.ParseInLocation("2006-01-02 15:04:05", fromStr, loc)
|
||||
to, err2 := time.ParseInLocation("2006-01-02 15:04:05", toStr, loc)
|
||||
if err1 != nil || err2 != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 from/to 时间格式"})
|
||||
return
|
||||
}
|
||||
const qRange = `
|
||||
SELECT dt FROM radar_tiles
|
||||
WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5
|
||||
ORDER BY dt DESC`
|
||||
rows, err = database.GetDB().Query(qRange, z, y, x, from, to)
|
||||
} else {
|
||||
limit := parseIntDefault(c.Query("limit"), 48)
|
||||
const q = `
|
||||
SELECT dt FROM radar_tiles
|
||||
WHERE z=$1 AND y=$2 AND x=$3
|
||||
ORDER BY dt DESC
|
||||
LIMIT $4`
|
||||
rows, err = database.GetDB().Query(q, z, y, x, limit)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询时间列表失败"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var times []string
|
||||
for rows.Next() {
|
||||
var dt time.Time
|
||||
if err := rows.Scan(&dt); err != nil {
|
||||
continue
|
||||
}
|
||||
times = append(times, dt.In(loc).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"times": times})
|
||||
}
|
||||
|
||||
// radarTilesAtHandler returns all radar tiles at the given dt for a specific z.
|
||||
// It aggregates rows with the same z and dt but different y/x so the frontend can overlay them together.
|
||||
// GET /api/radar/tiles_at?z=7&dt=YYYY-MM-DD HH:MM:SS
|
||||
func radarTilesAtHandler(c *gin.Context) {
|
||||
z := parseIntDefault(c.Query("z"), 7)
|
||||
dtStr := c.Query("dt")
|
||||
if dtStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"})
|
||||
return
|
||||
}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"})
|
||||
return
|
||||
}
|
||||
|
||||
// Query all tiles at z + dt
|
||||
const q = `
|
||||
SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data
|
||||
FROM radar_tiles
|
||||
WHERE z=$1 AND dt=$2
|
||||
ORDER BY y, x`
|
||||
rows, qerr := database.GetDB().Query(q, z, dt)
|
||||
if qerr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("数据库查询失败: %v", qerr)})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tiles []radarTileResponse
|
||||
for rows.Next() {
|
||||
var r radarTileRecord
|
||||
if err := rows.Scan(&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data); err != nil {
|
||||
continue
|
||||
}
|
||||
w, h := r.Width, r.Height
|
||||
if w <= 0 || h <= 0 || len(r.Data) < w*h*2 {
|
||||
continue
|
||||
}
|
||||
vals := make([][]*float64, h)
|
||||
off := 0
|
||||
for row := 0; row < h; row++ {
|
||||
rowVals := make([]*float64, w)
|
||||
for col := 0; col < w; col++ {
|
||||
v := int16(binary.BigEndian.Uint16(r.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[row] = rowVals
|
||||
}
|
||||
tiles = append(tiles, radarTileResponse{
|
||||
DT: r.DT.In(loc).Format("2006-01-02 15:04:05"),
|
||||
Z: r.Z,
|
||||
Y: r.Y,
|
||||
X: r.X,
|
||||
Width: r.Width,
|
||||
Height: r.Height,
|
||||
West: r.West,
|
||||
South: r.South,
|
||||
East: r.East,
|
||||
North: r.North,
|
||||
ResDeg: r.ResDeg,
|
||||
Values: vals,
|
||||
})
|
||||
}
|
||||
|
||||
if len(tiles) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间的雷达瓦片集合"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"dt": dt.In(loc).Format("2006-01-02 15:04:05"),
|
||||
"tiles": tiles,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// 统一以 CST(+8) 输出
|
||||
resp := radarTileResponse{
|
||||
DT: rec.DT.In(loc).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
|
||||
}
|
||||
var n int
|
||||
_, err := fmtSscanf(s, &n)
|
||||
if err != nil || n == 0 || n == math.MinInt || n == math.MaxInt {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// fmtSscanf is a tiny wrapper to avoid importing fmt only for Sscanf
|
||||
func fmtSscanf(s string, n *int) (int, error) {
|
||||
// naive fast parse
|
||||
sign := 1
|
||||
i := 0
|
||||
if len(s) > 0 && (s[0] == '-' || s[0] == '+') {
|
||||
if s[0] == '-' {
|
||||
sign = -1
|
||||
}
|
||||
i = 1
|
||||
}
|
||||
val := 0
|
||||
for ; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, fmtError("invalid")
|
||||
}
|
||||
val = val*10 + int(ch-'0')
|
||||
}
|
||||
*n = sign * val
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
type fmtError string
|
||||
|
||||
func (e fmtError) Error() string { return string(e) }
|
||||
|
||||
// ---------------- Radar station realtime API ----------------
|
||||
|
||||
type radarWeatherRecord struct {
|
||||
Alias string
|
||||
Lat float64
|
||||
Lon float64
|
||||
DT time.Time
|
||||
Temperature float64
|
||||
Humidity float64
|
||||
Cloudrate float64
|
||||
Visibility float64
|
||||
Dswrf float64
|
||||
WindSpeed float64
|
||||
WindDirection float64
|
||||
Pressure float64
|
||||
}
|
||||
|
||||
type radarWeatherResponse struct {
|
||||
Alias string `json:"alias"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
DT string `json:"dt"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Humidity float64 `json:"humidity"`
|
||||
Cloudrate float64 `json:"cloudrate"`
|
||||
Visibility float64 `json:"visibility"`
|
||||
Dswrf float64 `json:"dswrf"`
|
||||
WindSpeed float64 `json:"wind_speed"`
|
||||
WindDirection float64 `json:"wind_direction"`
|
||||
Pressure float64 `json:"pressure"`
|
||||
}
|
||||
|
||||
func latestRadarWeatherHandler(c *gin.Context) {
|
||||
alias := c.Query("alias")
|
||||
if alias == "" {
|
||||
alias = "南宁雷达站"
|
||||
}
|
||||
const q = `
|
||||
SELECT alias, lat, lon, dt,
|
||||
temperature, humidity, cloudrate, visibility, dswrf,
|
||||
wind_speed, wind_direction, pressure
|
||||
FROM radar_weather
|
||||
WHERE alias = $1
|
||||
ORDER BY dt DESC
|
||||
LIMIT 1`
|
||||
var r radarWeatherRecord
|
||||
err := database.GetDB().QueryRow(q, alias).Scan(
|
||||
&r.Alias, &r.Lat, &r.Lon, &r.DT,
|
||||
&r.Temperature, &r.Humidity, &r.Cloudrate, &r.Visibility, &r.Dswrf,
|
||||
&r.WindSpeed, &r.WindDirection, &r.Pressure,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到实时气象数据"})
|
||||
return
|
||||
}
|
||||
resp := radarWeatherResponse{
|
||||
Alias: r.Alias,
|
||||
Lat: r.Lat,
|
||||
Lon: r.Lon,
|
||||
DT: r.DT.Format("2006-01-02 15:04:05"),
|
||||
Temperature: r.Temperature,
|
||||
Humidity: r.Humidity,
|
||||
Cloudrate: r.Cloudrate,
|
||||
Visibility: r.Visibility,
|
||||
Dswrf: r.Dswrf,
|
||||
WindSpeed: r.WindSpeed,
|
||||
WindDirection: r.WindDirection,
|
||||
Pressure: r.Pressure,
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// radarWeatherAtHandler returns the radar weather record at the given dt (CST),
|
||||
// rounded/exact 10-minute bucket time string "YYYY-MM-DD HH:MM:SS".
|
||||
func radarWeatherAtHandler(c *gin.Context) {
|
||||
alias := c.Query("alias")
|
||||
if alias == "" {
|
||||
alias = "南宁雷达站"
|
||||
}
|
||||
dtStr := c.Query("dt")
|
||||
if dtStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"})
|
||||
return
|
||||
}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"})
|
||||
return
|
||||
}
|
||||
const q = `
|
||||
SELECT alias, lat, lon, dt,
|
||||
temperature, humidity, cloudrate, visibility, dswrf,
|
||||
wind_speed, wind_direction, pressure
|
||||
FROM radar_weather
|
||||
WHERE alias = $1 AND dt = $2
|
||||
LIMIT 1`
|
||||
var r radarWeatherRecord
|
||||
err = database.GetDB().QueryRow(q, alias, dt).Scan(
|
||||
&r.Alias, &r.Lat, &r.Lon, &r.DT,
|
||||
&r.Temperature, &r.Humidity, &r.Cloudrate, &r.Visibility, &r.Dswrf,
|
||||
&r.WindSpeed, &r.WindDirection, &r.Pressure,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间的实时气象数据"})
|
||||
return
|
||||
}
|
||||
resp := radarWeatherResponse{
|
||||
Alias: r.Alias,
|
||||
Lat: r.Lat,
|
||||
Lon: r.Lon,
|
||||
DT: r.DT.Format("2006-01-02 15:04:05"),
|
||||
Temperature: r.Temperature,
|
||||
Humidity: r.Humidity,
|
||||
Cloudrate: r.Cloudrate,
|
||||
Visibility: r.Visibility,
|
||||
Dswrf: r.Dswrf,
|
||||
WindSpeed: r.WindSpeed,
|
||||
WindDirection: r.WindDirection,
|
||||
Pressure: r.Pressure,
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// radarWeatherNearestHandler returns the nearest radar_weather record to the given dt.
|
||||
// prefer=lte will pick the latest record not later than dt; else chooses absolute nearest.
|
||||
func radarWeatherNearestHandler(c *gin.Context) {
|
||||
alias := c.Query("alias")
|
||||
if alias == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 alias 参数"})
|
||||
return
|
||||
}
|
||||
dtStr := c.Query("dt")
|
||||
if dtStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"})
|
||||
return
|
||||
}
|
||||
prefer := c.DefaultQuery("prefer", "lte") // lte|nearest
|
||||
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
|
||||
}
|
||||
|
||||
var row *sql.Row
|
||||
db := database.GetDB()
|
||||
if prefer == "nearest" {
|
||||
const q = `
|
||||
SELECT alias, lat, lon, dt,
|
||||
temperature, humidity, cloudrate, visibility, dswrf,
|
||||
wind_speed, wind_direction, pressure
|
||||
FROM radar_weather
|
||||
WHERE alias = $1
|
||||
ORDER BY ABS(EXTRACT(EPOCH FROM (dt - $2))) ASC
|
||||
LIMIT 1`
|
||||
row = db.QueryRow(q, alias, target)
|
||||
} else { // lte default
|
||||
const q = `
|
||||
SELECT alias, lat, lon, dt,
|
||||
temperature, humidity, cloudrate, visibility, dswrf,
|
||||
wind_speed, wind_direction, pressure
|
||||
FROM radar_weather
|
||||
WHERE alias = $1 AND dt <= $2
|
||||
ORDER BY dt DESC
|
||||
LIMIT 1`
|
||||
row = db.QueryRow(q, alias, target)
|
||||
}
|
||||
|
||||
var r radarWeatherRecord
|
||||
if err := row.Scan(
|
||||
&r.Alias, &r.Lat, &r.Lon, &r.DT,
|
||||
&r.Temperature, &r.Humidity, &r.Cloudrate, &r.Visibility, &r.Dswrf,
|
||||
&r.WindSpeed, &r.WindDirection, &r.Pressure,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到就近的实时气象数据"})
|
||||
return
|
||||
}
|
||||
ageMin := int(target.Sub(r.DT).Minutes())
|
||||
if ageMin < 0 { // for nearest mode, could be future relative to target
|
||||
ageMin = -ageMin
|
||||
}
|
||||
resp := gin.H{
|
||||
"alias": r.Alias,
|
||||
"lat": r.Lat,
|
||||
"lon": r.Lon,
|
||||
"dt": r.DT.Format("2006-01-02 15:04:05"),
|
||||
"temperature": r.Temperature,
|
||||
"humidity": r.Humidity,
|
||||
"cloudrate": r.Cloudrate,
|
||||
"visibility": r.Visibility,
|
||||
"dswrf": r.Dswrf,
|
||||
"wind_speed": r.WindSpeed,
|
||||
"wind_direction": r.WindDirection,
|
||||
"pressure": r.Pressure,
|
||||
"age_minutes": ageMin,
|
||||
"stale": ageMin > 120,
|
||||
}
|
||||
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})
|
||||
}
|
||||
381
internal/server/rain_api.go
Normal file
381
internal/server/rain_api.go
Normal file
@ -0,0 +1,381 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"weatherstation/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type rainTileRecord struct {
|
||||
DT time.Time
|
||||
Z int
|
||||
Y int
|
||||
X int
|
||||
Width int
|
||||
Height int
|
||||
West float64
|
||||
South float64
|
||||
East float64
|
||||
North float64
|
||||
ResDeg float64
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type rainTileResponse struct {
|
||||
DT string `json:"dt"`
|
||||
Z int `json:"z"`
|
||||
Y int `json:"y"`
|
||||
X int `json:"x"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
West float64 `json:"west"`
|
||||
South float64 `json:"south"`
|
||||
East float64 `json:"east"`
|
||||
North float64 `json:"north"`
|
||||
ResDeg float64 `json:"res_deg"`
|
||||
Values [][]*float64 `json:"values"` // 单位:mm,null 表示无效
|
||||
}
|
||||
|
||||
func getLatestRainTile(db *sql.DB, z, y, x int) (*rainTileRecord, error) {
|
||||
const q = `
|
||||
SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data
|
||||
FROM rain_tiles
|
||||
WHERE z=$1 AND y=$2 AND x=$3
|
||||
ORDER BY dt DESC
|
||||
LIMIT 1`
|
||||
var r rainTileRecord
|
||||
err := db.QueryRow(q, z, y, x).Scan(
|
||||
&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height,
|
||||
&r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func getRainTileAt(db *sql.DB, z, y, x int, dt time.Time) (*rainTileRecord, error) {
|
||||
const q = `
|
||||
SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data
|
||||
FROM rain_tiles
|
||||
WHERE z=$1 AND y=$2 AND x=$3 AND dt=$4
|
||||
LIMIT 1`
|
||||
var r rainTileRecord
|
||||
err := db.QueryRow(q, z, y, x, dt).Scan(
|
||||
&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height,
|
||||
&r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// latestRainTileHandler 返回指定 z/y/x 的最新一小时降雨瓦片
|
||||
func latestRainTileHandler(c *gin.Context) {
|
||||
z := parseIntDefault(c.Query("z"), 7)
|
||||
y := parseIntDefault(c.Query("y"), 40)
|
||||
x := parseIntDefault(c.Query("x"), 102)
|
||||
|
||||
rec, err := getLatestRainTile(database.GetDB(), z, y, x)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到雨量瓦片"})
|
||||
return
|
||||
}
|
||||
w, h := rec.Width, rec.Height
|
||||
if len(rec.Data) < w*h*2 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"})
|
||||
return
|
||||
}
|
||||
vals := decodeRain(rec.Data, w, h)
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
resp := rainTileResponse{
|
||||
DT: rec.DT.In(loc).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)
|
||||
}
|
||||
|
||||
// rainTileAtHandler 返回指定 z/y/x 的指定时间(CST)瓦片
|
||||
func rainTileAtHandler(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
|
||||
}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"})
|
||||
return
|
||||
}
|
||||
rec, err := getRainTileAt(database.GetDB(), z, y, x, dt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间瓦片"})
|
||||
return
|
||||
}
|
||||
w, h := rec.Width, rec.Height
|
||||
if len(rec.Data) < w*h*2 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"})
|
||||
return
|
||||
}
|
||||
vals := decodeRain(rec.Data, w, h)
|
||||
// 始终以 CST(+8) 输出
|
||||
resp := rainTileResponse{
|
||||
DT: rec.DT.In(loc).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)
|
||||
}
|
||||
|
||||
// rainTileTimesHandler 返回指定 z/y/x 的可用时间列表(倒序)
|
||||
func rainTileTimesHandler(c *gin.Context) {
|
||||
z := parseIntDefault(c.Query("z"), 7)
|
||||
y := parseIntDefault(c.Query("y"), 40)
|
||||
x := parseIntDefault(c.Query("x"), 102)
|
||||
fromStr := c.Query("from")
|
||||
toStr := c.Query("to")
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if fromStr != "" && toStr != "" {
|
||||
from, err1 := time.ParseInLocation("2006-01-02 15:04:05", fromStr, loc)
|
||||
to, err2 := time.ParseInLocation("2006-01-02 15:04:05", toStr, loc)
|
||||
if err1 != nil || err2 != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 from/to 时间格式"})
|
||||
return
|
||||
}
|
||||
const qRange = `
|
||||
SELECT dt FROM rain_tiles
|
||||
WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5
|
||||
ORDER BY dt DESC`
|
||||
rows, err = database.GetDB().Query(qRange, z, y, x, from, to)
|
||||
} else {
|
||||
limit := parseIntDefault(c.Query("limit"), 48)
|
||||
const q = `
|
||||
SELECT dt FROM rain_tiles
|
||||
WHERE z=$1 AND y=$2 AND x=$3
|
||||
ORDER BY dt DESC
|
||||
LIMIT $4`
|
||||
rows, err = database.GetDB().Query(q, z, y, x, limit)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询时间列表失败"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var times []string
|
||||
for rows.Next() {
|
||||
var dt time.Time
|
||||
if err := rows.Scan(&dt); err != nil {
|
||||
continue
|
||||
}
|
||||
times = append(times, dt.In(loc).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"times": times})
|
||||
}
|
||||
|
||||
// nearestRainTileHandler 返回最接近给定时间的瓦片
|
||||
func nearestRainTileHandler(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"), 90)
|
||||
prefer := c.DefaultQuery("prefer", "lte") // lte|nearest
|
||||
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 rain_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 rain_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 := getRainTileAt(db, z, y, x, picked)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到匹配瓦片数据"})
|
||||
return
|
||||
}
|
||||
w, h := rec.Width, rec.Height
|
||||
if len(rec.Data) < w*h*2 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"})
|
||||
return
|
||||
}
|
||||
vals := decodeRain(rec.Data, w, h)
|
||||
// 以 CST(+8) 输出
|
||||
resp := rainTileResponse{
|
||||
DT: rec.DT.In(loc).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)
|
||||
}
|
||||
|
||||
// rainTilesAtHandler 返回指定 z 在 dt 时次的全部雨量瓦片(不同 y/x)集合
|
||||
// GET /api/rain/tiles_at?z=7&dt=YYYY-MM-DD HH:MM:SS
|
||||
func rainTilesAtHandler(c *gin.Context) {
|
||||
z := parseIntDefault(c.Query("z"), 7)
|
||||
dtStr := c.Query("dt")
|
||||
if dtStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"})
|
||||
return
|
||||
}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"})
|
||||
return
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data
|
||||
FROM rain_tiles
|
||||
WHERE z=$1 AND dt=$2
|
||||
ORDER BY y, x`
|
||||
rows, qerr := database.GetDB().Query(q, z, dt)
|
||||
if qerr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("数据库查询失败: %v", qerr)})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tiles []rainTileResponse
|
||||
for rows.Next() {
|
||||
var r rainTileRecord
|
||||
if err := rows.Scan(&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data); err != nil {
|
||||
continue
|
||||
}
|
||||
w, h := r.Width, r.Height
|
||||
if w <= 0 || h <= 0 || len(r.Data) < w*h*2 {
|
||||
continue
|
||||
}
|
||||
vals := decodeRain(r.Data, w, h)
|
||||
tiles = append(tiles, rainTileResponse{
|
||||
DT: r.DT.In(loc).Format("2006-01-02 15:04:05"),
|
||||
Z: r.Z,
|
||||
Y: r.Y,
|
||||
X: r.X,
|
||||
Width: r.Width,
|
||||
Height: r.Height,
|
||||
West: r.West,
|
||||
South: r.South,
|
||||
East: r.East,
|
||||
North: r.North,
|
||||
ResDeg: r.ResDeg,
|
||||
Values: vals,
|
||||
})
|
||||
}
|
||||
|
||||
if len(tiles) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间的雨量瓦片集合"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"dt": dt.In(loc).Format("2006-01-02 15:04:05"),
|
||||
"tiles": tiles,
|
||||
})
|
||||
}
|
||||
|
||||
func decodeRain(buf []byte, w, h int) [][]*float64 {
|
||||
vals := make([][]*float64, h)
|
||||
off := 0
|
||||
for row := 0; row < h; row++ {
|
||||
rowVals := make([]*float64, w)
|
||||
for col := 0; col < w; col++ {
|
||||
v := int16(binary.BigEndian.Uint16(buf[off : off+2]))
|
||||
off += 2
|
||||
if v >= 32766 { // 无效
|
||||
rowVals[col] = nil
|
||||
continue
|
||||
}
|
||||
mm := float64(v) / 10.0 // 0.1 mm 精度
|
||||
if mm < 0 {
|
||||
mm = 0
|
||||
}
|
||||
vv := mm
|
||||
rowVals[col] = &vv
|
||||
}
|
||||
vals[row] = rowVals
|
||||
}
|
||||
return vals
|
||||
}
|
||||
319
internal/server/udp.go
Normal file
319
internal/server/udp.go
Normal file
@ -0,0 +1,319 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
"weatherstation/internal/config"
|
||||
"weatherstation/internal/tools"
|
||||
"weatherstation/model"
|
||||
)
|
||||
|
||||
// UTF8Writer 包装一个io.Writer,确保写入的数据是有效的UTF-8
|
||||
type UTF8Writer struct {
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// NewUTF8Writer 创建一个新的UTF8Writer
|
||||
func NewUTF8Writer(w io.Writer) *UTF8Writer {
|
||||
return &UTF8Writer{w: w}
|
||||
}
|
||||
|
||||
// Write 实现io.Writer接口
|
||||
func (w *UTF8Writer) Write(p []byte) (n int, err error) {
|
||||
if utf8.Valid(p) {
|
||||
return w.w.Write(p)
|
||||
}
|
||||
s := string(p)
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
return w.w.Write([]byte(s))
|
||||
}
|
||||
|
||||
var (
|
||||
logFile *os.File
|
||||
logFileMutex sync.Mutex
|
||||
currentLogDay int
|
||||
)
|
||||
|
||||
// getLogBaseDir 返回日志目录:优先环境变量 LOG_DIR,否则使用可执行文件所在目录下的 log 子目录
|
||||
func getLogBaseDir() string {
|
||||
if v := os.Getenv("LOG_DIR"); strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
exe, _ := os.Executable()
|
||||
exeDir := filepath.Dir(exe)
|
||||
return filepath.Join(exeDir, "log")
|
||||
}
|
||||
|
||||
// getLogFileName 获取当前日期的日志文件名
|
||||
func getLogFileName() string {
|
||||
currentTime := time.Now()
|
||||
return filepath.Join(getLogBaseDir(), fmt.Sprintf("%s.log", currentTime.Format("2006-01-02")))
|
||||
}
|
||||
|
||||
// openLogFile 打开日志文件
|
||||
func openLogFile() (*os.File, error) {
|
||||
logDir := getLogBaseDir()
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logFileName := getLogFileName()
|
||||
return os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
}
|
||||
|
||||
// SetupLogger 设置日志系统
|
||||
func SetupLogger() {
|
||||
var err error
|
||||
logFile, err = openLogFile()
|
||||
if err != nil {
|
||||
log.Fatalf("无法创建日志文件: %v", err)
|
||||
}
|
||||
|
||||
currentLogDay = time.Now().Day()
|
||||
|
||||
bufferedWriter := bufio.NewWriter(logFile)
|
||||
utf8Writer := NewUTF8Writer(bufferedWriter)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
logFileMutex.Lock()
|
||||
bufferedWriter.Flush()
|
||||
|
||||
now := time.Now()
|
||||
if now.Day() != currentLogDay {
|
||||
oldLogFile := logFile
|
||||
logFile, err = openLogFile()
|
||||
if err != nil {
|
||||
log.Printf("无法创建新日志文件: %v", err)
|
||||
} else {
|
||||
oldLogFile.Close()
|
||||
currentLogDay = now.Day()
|
||||
bufferedWriter = bufio.NewWriter(logFile)
|
||||
utf8Writer = NewUTF8Writer(bufferedWriter)
|
||||
log.SetOutput(io.MultiWriter(os.Stdout, utf8Writer))
|
||||
log.Println("日志文件已轮转")
|
||||
}
|
||||
}
|
||||
logFileMutex.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
multiWriter := io.MultiWriter(os.Stdout, utf8Writer)
|
||||
log.SetOutput(multiWriter)
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
}
|
||||
|
||||
// StartUDPServer 启动UDP服务器
|
||||
func StartUDPServer() error {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
// 初始化数据库连接
|
||||
if err := model.InitDB(); err != nil {
|
||||
return fmt.Errorf("初始化数据库失败: %v", err)
|
||||
}
|
||||
defer model.CloseDB()
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.UDPPort)
|
||||
conn, err := net.ListenPacket("udp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法监听UDP端口 %d: %v", cfg.Server.UDPPort, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
log.Printf("UDP服务器已启动,监听端口 %d...", cfg.Server.UDPPort)
|
||||
buffer := make([]byte, 2048)
|
||||
|
||||
// 后台定时:每分钟回刷最近30分钟(全站)
|
||||
go func() {
|
||||
for {
|
||||
now := time.Now()
|
||||
from := now.Add(-30 * time.Minute)
|
||||
ctx := context.Background()
|
||||
if err := tools.RunBackfill10Min(ctx, tools.BackfillOptions{
|
||||
FromTime: from,
|
||||
ToTime: now,
|
||||
WrapCycleMM: 0,
|
||||
BucketMinutes: 10,
|
||||
}); err != nil {
|
||||
log.Printf("定时回填失败: %v", err)
|
||||
}
|
||||
time.Sleep(1 * time.Minute)
|
||||
}
|
||||
}()
|
||||
|
||||
// 说明:原有的 open-meteo/彩云/CMA 定时抓取已移除,避免与独立的 service-forecast 重复调度。
|
||||
// 若需要启用预报抓取,请运行 `cmd/service-forecast` 服务。
|
||||
|
||||
// 说明:融合任务已迁移至独立服务(service-fusion)。
|
||||
|
||||
for {
|
||||
n, addr, err := conn.ReadFrom(buffer)
|
||||
if err != nil {
|
||||
log.Printf("读取数据错误: %v", err)
|
||||
continue
|
||||
}
|
||||
rawData := buffer[:n]
|
||||
log.Printf("从 %s 接收到 %d 字节数据", addr.String(), n)
|
||||
|
||||
hexDump := hexDump(rawData)
|
||||
log.Printf("原始码流(十六进制):\n%s", hexDump)
|
||||
asciiDump := asciiDump(rawData)
|
||||
log.Printf("ASCII码:\n%s", asciiDump)
|
||||
|
||||
if len(rawData) == 25 && rawData[0] == 0x24 {
|
||||
handleRS485Data(rawData, addr, hexDump)
|
||||
} else {
|
||||
handleWiFiData(rawData, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleRS485Data 处理RS485设备数据
|
||||
func handleRS485Data(rawData []byte, addr net.Addr, hexDump string) {
|
||||
log.Println("485 型气象站数据")
|
||||
|
||||
// 生成源码字符串(用于日志记录)
|
||||
sourceHex := strings.ReplaceAll(strings.TrimSpace(hexDump), "\n", " ")
|
||||
log.Printf("源码: %s", sourceHex)
|
||||
|
||||
// 解析RS485数据
|
||||
protocol := model.NewProtocol(rawData)
|
||||
rs485Protocol := model.NewRS485Protocol(rawData)
|
||||
|
||||
// 获取设备ID
|
||||
idParts, err := protocol.GetCompleteID()
|
||||
if err != nil {
|
||||
log.Printf("获取设备ID失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析RS485数据
|
||||
rs485Data, err := rs485Protocol.ParseRS485Data()
|
||||
if err != nil {
|
||||
log.Printf("解析RS485数据失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 添加设备ID和时间戳
|
||||
rs485Data.DeviceID = idParts.Complete.Hex
|
||||
rs485Data.ReceivedAt = time.Now()
|
||||
rs485Data.RawDataHex = sourceHex
|
||||
|
||||
// 打印解析结果到日志
|
||||
log.Println("=== RS485 ===")
|
||||
log.Printf("设备ID: RS485-%s", rs485Data.DeviceID)
|
||||
log.Printf("温度: %.2f°C", rs485Data.Temperature)
|
||||
log.Printf("湿度: %.1f%%", rs485Data.Humidity)
|
||||
log.Printf("风速: %.5f m/s", rs485Data.WindSpeed)
|
||||
log.Printf("风向: %.1f°", rs485Data.WindDirection)
|
||||
log.Printf("降雨量: %.3f mm", rs485Data.Rainfall)
|
||||
log.Printf("光照: %.1f lux", rs485Data.Light)
|
||||
log.Printf("紫外线: %.1f", rs485Data.UV)
|
||||
log.Printf("气压: %.2f hPa", rs485Data.Pressure)
|
||||
log.Printf("接收时间: %s", rs485Data.ReceivedAt.Format("2006-01-02 15:04:05"))
|
||||
|
||||
// 注册设备
|
||||
stationID := fmt.Sprintf("RS485-%s", rs485Data.DeviceID)
|
||||
model.RegisterDevice(stationID, addr)
|
||||
log.Printf("设备 %s 已注册,IP: %s", stationID, addr.String())
|
||||
|
||||
// 保存到数据库
|
||||
err = model.SaveWeatherData(rs485Data, string(rawData))
|
||||
if err != nil {
|
||||
log.Printf("保存数据到数据库失败: %v", err)
|
||||
} else {
|
||||
log.Printf("数据已成功保存到数据库")
|
||||
}
|
||||
}
|
||||
|
||||
// handleWiFiData 处理WiFi设备数据
|
||||
func handleWiFiData(rawData []byte, addr net.Addr) {
|
||||
// 尝试解析WIFI数据
|
||||
data, deviceType, err := model.ParseData(rawData)
|
||||
if err != nil {
|
||||
log.Printf("解析数据失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("成功解析气象站数据:")
|
||||
log.Printf("设备类型: %s", getDeviceTypeString(deviceType))
|
||||
log.Println(data)
|
||||
|
||||
if deviceType == model.DeviceTypeWIFI {
|
||||
if wifiData, ok := data.(*model.WeatherData); ok {
|
||||
stationID := wifiData.StationID
|
||||
if stationID != "" {
|
||||
model.RegisterDevice(stationID, addr)
|
||||
log.Printf("设备 %s 已注册,IP: %s", stationID, addr.String())
|
||||
} else {
|
||||
log.Printf("警告: 收到的数据没有站点ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getDeviceTypeString 获取设备类型字符串
|
||||
func getDeviceTypeString(deviceType model.DeviceType) string {
|
||||
switch deviceType {
|
||||
case model.DeviceTypeWIFI:
|
||||
return "WIFI"
|
||||
case model.DeviceTypeRS485:
|
||||
return "RS485"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// hexDump 生成十六进制转储
|
||||
func hexDump(data []byte) string {
|
||||
var result strings.Builder
|
||||
for i := 0; i < len(data); i += 16 {
|
||||
end := i + 16
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
chunk := data[i:end]
|
||||
hexStr := hex.EncodeToString(chunk)
|
||||
for j := 0; j < len(hexStr); j += 2 {
|
||||
if j+2 <= len(hexStr) {
|
||||
result.WriteString(strings.ToUpper(hexStr[j : j+2]))
|
||||
result.WriteString(" ")
|
||||
}
|
||||
}
|
||||
result.WriteString("\n")
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// asciiDump 生成ASCII转储
|
||||
func asciiDump(data []byte) string {
|
||||
var result strings.Builder
|
||||
for i := 0; i < len(data); i += 64 {
|
||||
end := i + 64
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
chunk := data[i:end]
|
||||
for _, b := range chunk {
|
||||
if b >= 32 && b <= 126 {
|
||||
result.WriteByte(b)
|
||||
} else {
|
||||
result.WriteString(".")
|
||||
}
|
||||
}
|
||||
result.WriteString("\n")
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
281
internal/tools/backfill.go
Normal file
281
internal/tools/backfill.go
Normal file
@ -0,0 +1,281 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
type BackfillOptions struct {
|
||||
StationID string // 为空则处理所有站点
|
||||
FromTime time.Time // 含
|
||||
ToTime time.Time // 含
|
||||
WrapCycleMM float64 // 设备累计回绕一圈对应的毫米值;<=0 则按“回绕后仅记当次值”降级处理
|
||||
BucketMinutes int // 默认10
|
||||
}
|
||||
|
||||
// RunBackfill10Min 将 rs485_weather_data 的16秒数据汇总写入 rs485_weather_10min
|
||||
func RunBackfill10Min(ctx context.Context, opts BackfillOptions) error {
|
||||
if opts.BucketMinutes <= 0 {
|
||||
opts.BucketMinutes = 10
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
// 取时序数据
|
||||
query := `
|
||||
SELECT station_id, timestamp, temperature, humidity, wind_speed, wind_direction,
|
||||
rainfall, light, uv, pressure
|
||||
FROM rs485_weather_data
|
||||
WHERE timestamp >= $1 AND timestamp <= $2
|
||||
%s
|
||||
ORDER BY station_id, timestamp`
|
||||
|
||||
stationFilter := ""
|
||||
args := []any{opts.FromTime, opts.ToTime}
|
||||
if opts.StationID != "" {
|
||||
stationFilter = "AND station_id = $3"
|
||||
args = append(args, opts.StationID)
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(ctx, fmt.Sprintf(query, stationFilter), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query raw failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 载入上海时区用于分桶
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
|
||||
type agg struct {
|
||||
// sums
|
||||
sumTemp float64
|
||||
sumHum float64
|
||||
sumWS float64
|
||||
sumLight float64
|
||||
sumUV float64
|
||||
sumP float64
|
||||
sinSum float64
|
||||
cosSum float64
|
||||
gustMax float64
|
||||
rainIncSum float64
|
||||
count int
|
||||
lastTotal float64 // 桶末累计
|
||||
lastTS time.Time
|
||||
}
|
||||
|
||||
currentStation := ""
|
||||
var prevTotal float64
|
||||
var prevTS time.Time
|
||||
|
||||
buckets := make(map[string]map[time.Time]*agg) // station -> bucketStart -> agg
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
stationID string
|
||||
ts time.Time
|
||||
t, h, ws, wd, rf, light, uv, p sql.NullFloat64
|
||||
)
|
||||
if err := rows.Scan(&stationID, &ts, &t, &h, &ws, &wd, &rf, &light, &uv, &p); err != nil {
|
||||
return fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
|
||||
// 切换设备时重置 prevTotal
|
||||
if stationID != currentStation {
|
||||
currentStation = stationID
|
||||
prevTotal = math.NaN()
|
||||
prevTS = time.Time{}
|
||||
}
|
||||
|
||||
// 计算该样本所在桶(CST对齐)
|
||||
// 改为左开右闭 (left-open, right-closed):恰好落在边界的样本归入“结束于该边界”的桶
|
||||
localTs := ts.In(loc)
|
||||
floor := localTs.Truncate(time.Duration(opts.BucketMinutes) * time.Minute)
|
||||
bucketStart := floor
|
||||
if localTs.Equal(floor) {
|
||||
bucketStart = floor.Add(-time.Duration(opts.BucketMinutes) * time.Minute)
|
||||
}
|
||||
bucketStart = time.Date(bucketStart.Year(), bucketStart.Month(), bucketStart.Day(), bucketStart.Hour(), bucketStart.Minute(), 0, 0, loc)
|
||||
|
||||
if _, ok := buckets[stationID]; !ok {
|
||||
buckets[stationID] = make(map[time.Time]*agg)
|
||||
}
|
||||
ag := buckets[stationID][bucketStart]
|
||||
if ag == nil {
|
||||
ag = &agg{gustMax: -1}
|
||||
buckets[stationID][bucketStart] = ag
|
||||
}
|
||||
|
||||
// 累加平均项
|
||||
if t.Valid {
|
||||
ag.sumTemp += t.Float64
|
||||
}
|
||||
if h.Valid {
|
||||
ag.sumHum += h.Float64
|
||||
}
|
||||
if ws.Valid {
|
||||
ag.sumWS += ws.Float64
|
||||
if ws.Float64 > ag.gustMax {
|
||||
ag.gustMax = ws.Float64
|
||||
}
|
||||
}
|
||||
if light.Valid {
|
||||
ag.sumLight += light.Float64
|
||||
}
|
||||
if uv.Valid {
|
||||
ag.sumUV += uv.Float64
|
||||
}
|
||||
if p.Valid {
|
||||
ag.sumP += p.Float64
|
||||
}
|
||||
if wd.Valid {
|
||||
rad := wd.Float64 * math.Pi / 180.0
|
||||
ag.sinSum += math.Sin(rad)
|
||||
ag.cosSum += math.Cos(rad)
|
||||
}
|
||||
ag.count++
|
||||
|
||||
// 雨量增量:按时间比例切分到跨越的各个桶,避免边界全部被计入后一桶
|
||||
if rf.Valid {
|
||||
curr := rf.Float64
|
||||
|
||||
// 若该站点的上一条样本未知(窗口首条),尝试读取窗口前一条样本作为种子,避免首桶丢雨
|
||||
if math.IsNaN(prevTotal) || prevTS.IsZero() {
|
||||
var seedTS time.Time
|
||||
var seedTotal sql.NullFloat64
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
SELECT timestamp, rainfall
|
||||
FROM rs485_weather_data
|
||||
WHERE station_id = $1 AND timestamp < $2
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`, stationID, ts).Scan(&seedTS, &seedTotal); err == nil && seedTotal.Valid {
|
||||
prevTotal = seedTotal.Float64
|
||||
prevTS = seedTS
|
||||
}
|
||||
}
|
||||
|
||||
if !math.IsNaN(prevTotal) && !prevTS.IsZero() {
|
||||
// 计算增量(带回绕)
|
||||
inc := 0.0
|
||||
if curr >= prevTotal {
|
||||
inc = curr - prevTotal
|
||||
} else {
|
||||
if opts.WrapCycleMM > 0 {
|
||||
inc = (opts.WrapCycleMM - prevTotal) + curr
|
||||
} else {
|
||||
// 降级:仅计当前值
|
||||
inc = curr
|
||||
}
|
||||
}
|
||||
|
||||
// 将 [prevTS, ts] 区间按10分钟边界切分,按时长比例分配增量
|
||||
startLocal := prevTS.In(loc)
|
||||
endLocal := ts.In(loc)
|
||||
if endLocal.After(startLocal) && inc > 0 {
|
||||
totalSec := endLocal.Sub(startLocal).Seconds()
|
||||
segStart := startLocal
|
||||
for segStart.Before(endLocal) {
|
||||
segBucketStart := segStart.Truncate(time.Duration(opts.BucketMinutes) * time.Minute)
|
||||
segBucketEnd := segBucketStart.Add(time.Duration(opts.BucketMinutes) * time.Minute)
|
||||
segEnd := endLocal
|
||||
if segBucketEnd.Before(segEnd) {
|
||||
segEnd = segBucketEnd
|
||||
}
|
||||
portion := inc * (segEnd.Sub(segStart).Seconds() / totalSec)
|
||||
// 确保段对应桶存在
|
||||
if _, ok := buckets[stationID][segBucketStart]; !ok {
|
||||
buckets[stationID][segBucketStart] = &agg{gustMax: -1}
|
||||
}
|
||||
buckets[stationID][segBucketStart].rainIncSum += portion
|
||||
segStart = segEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
// 记录桶末累计到当前样本所在桶
|
||||
ag.lastTotal = curr
|
||||
prevTotal = curr
|
||||
prevTS = ts
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("iterate rows failed: %w", err)
|
||||
}
|
||||
|
||||
// 写入/更新到 10 分钟表
|
||||
upsert := `
|
||||
INSERT INTO rs485_weather_10min (
|
||||
station_id, bucket_start,
|
||||
temp_c_x100, humidity_pct, wind_speed_ms_x1000, wind_gust_ms_x1000,
|
||||
wind_dir_deg, rain_10m_mm_x1000, rain_total_mm_x1000,
|
||||
solar_wm2_x100, uv_index, pressure_hpa_x100, sample_count
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9,
|
||||
$10, $11, $12, $13
|
||||
) ON CONFLICT (station_id, bucket_start) DO UPDATE SET
|
||||
-- 仅当新聚合样本数不小于已有样本数时才用新值覆盖均值类字段,避免回退
|
||||
temp_c_x100 = CASE WHEN EXCLUDED.sample_count >= rs485_weather_10min.sample_count THEN EXCLUDED.temp_c_x100 ELSE rs485_weather_10min.temp_c_x100 END,
|
||||
humidity_pct = CASE WHEN EXCLUDED.sample_count >= rs485_weather_10min.sample_count THEN EXCLUDED.humidity_pct ELSE rs485_weather_10min.humidity_pct END,
|
||||
wind_speed_ms_x1000 = CASE WHEN EXCLUDED.sample_count >= rs485_weather_10min.sample_count THEN EXCLUDED.wind_speed_ms_x1000 ELSE rs485_weather_10min.wind_speed_ms_x1000 END,
|
||||
wind_gust_ms_x1000 = GREATEST(rs485_weather_10min.wind_gust_ms_x1000, EXCLUDED.wind_gust_ms_x1000),
|
||||
wind_dir_deg = CASE WHEN EXCLUDED.sample_count >= rs485_weather_10min.sample_count THEN EXCLUDED.wind_dir_deg ELSE rs485_weather_10min.wind_dir_deg END,
|
||||
rain_10m_mm_x1000 = GREATEST(rs485_weather_10min.rain_10m_mm_x1000, EXCLUDED.rain_10m_mm_x1000),
|
||||
rain_total_mm_x1000 = GREATEST(rs485_weather_10min.rain_total_mm_x1000, EXCLUDED.rain_total_mm_x1000),
|
||||
solar_wm2_x100 = CASE WHEN EXCLUDED.sample_count >= rs485_weather_10min.sample_count THEN EXCLUDED.solar_wm2_x100 ELSE rs485_weather_10min.solar_wm2_x100 END,
|
||||
uv_index = CASE WHEN EXCLUDED.sample_count >= rs485_weather_10min.sample_count THEN EXCLUDED.uv_index ELSE rs485_weather_10min.uv_index END,
|
||||
pressure_hpa_x100 = CASE WHEN EXCLUDED.sample_count >= rs485_weather_10min.sample_count THEN EXCLUDED.pressure_hpa_x100 ELSE rs485_weather_10min.pressure_hpa_x100 END,
|
||||
sample_count = GREATEST(rs485_weather_10min.sample_count, EXCLUDED.sample_count)`
|
||||
|
||||
for stationID, m := range buckets {
|
||||
for bucketStart, ag := range m {
|
||||
if ag.count == 0 {
|
||||
continue
|
||||
}
|
||||
avgTemp := ag.sumTemp / float64(ag.count)
|
||||
avgHum := ag.sumHum / float64(ag.count)
|
||||
avgWS := ag.sumWS / float64(ag.count)
|
||||
avgLight := ag.sumLight / float64(ag.count)
|
||||
avgUV := ag.sumUV / float64(ag.count)
|
||||
avgP := ag.sumP / float64(ag.count)
|
||||
// 风向向量平均
|
||||
windDir := 0.0
|
||||
if ag.sinSum != 0 || ag.cosSum != 0 {
|
||||
ang := math.Atan2(ag.sinSum/float64(ag.count), ag.cosSum/float64(ag.count)) * 180 / math.Pi
|
||||
if ang < 0 {
|
||||
ang += 360
|
||||
}
|
||||
windDir = ang
|
||||
}
|
||||
|
||||
// 缩放整数
|
||||
tempScaled := int(math.Round(avgTemp * 100))
|
||||
humScaled := int(math.Round(avgHum))
|
||||
wsScaled := int(math.Round(avgWS * 1000))
|
||||
gustScaled := int(math.Round(ag.gustMax * 1000))
|
||||
wdScaled := int(math.Round(windDir))
|
||||
rain10mScaled := int(math.Round(ag.rainIncSum * 1000))
|
||||
rainTotalScaled := int(math.Round(ag.lastTotal * 1000))
|
||||
solarScaled := int(math.Round(avgLight * 100))
|
||||
uvScaled := int(math.Round(avgUV))
|
||||
pScaled := int(math.Round(avgP * 100))
|
||||
|
||||
if _, err := db.ExecContext(ctx, upsert,
|
||||
stationID, bucketStart,
|
||||
tempScaled, humScaled, wsScaled, gustScaled,
|
||||
wdScaled, rain10mScaled, rainTotalScaled,
|
||||
solarScaled, uvScaled, pScaled, ag.count,
|
||||
); err != nil {
|
||||
return fmt.Errorf("upsert 10min failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
568
internal/tools/exporter.go
Normal file
568
internal/tools/exporter.go
Normal file
@ -0,0 +1,568 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"weatherstation/internal/database"
|
||||
)
|
||||
|
||||
func getenvDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Exporter 负责每10分钟导出 CSV(含ZTD融合)
|
||||
type Exporter struct {
|
||||
pg *sql.DB
|
||||
my *sql.DB
|
||||
loc *time.Location // Asia/Shanghai
|
||||
logger *log.Logger // 专用日志记录器
|
||||
opts ExporterOptions
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// ExporterOptions 导出器可选项
|
||||
type ExporterOptions struct {
|
||||
// OverrideWindWithCaiyun 为 true 时,导出CSV时用彩云实况覆盖风速/风向
|
||||
OverrideWindWithCaiyun bool
|
||||
// CaiyunToken 彩云API令牌
|
||||
CaiyunToken string
|
||||
}
|
||||
|
||||
func NewExporter() *Exporter {
|
||||
return NewExporterWithOptions(ExporterOptions{})
|
||||
}
|
||||
|
||||
func NewExporterWithOptions(opts ExporterOptions) *Exporter {
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
|
||||
// 创建导出专用日志文件(追加模式)
|
||||
outBase := getenvDefault("EXPORT_DIR", "export_data")
|
||||
if err := os.MkdirAll(outBase, 0o755); err != nil {
|
||||
log.Printf("创建导出日志目录失败: %v", err)
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(outBase, "export.log"),
|
||||
os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
log.Printf("创建导出日志文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 使用自定义日志格式,包含时间戳
|
||||
logger := log.New(f, "", log.Ldate|log.Ltime|log.Lmicroseconds)
|
||||
|
||||
return &Exporter{
|
||||
pg: database.GetDB(),
|
||||
my: database.GetMySQL(),
|
||||
loc: loc,
|
||||
logger: logger,
|
||||
opts: opts,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动调度循环(阻塞)
|
||||
func (e *Exporter) Start(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
now := time.Now().In(e.loc)
|
||||
// 下一个10分钟边界 + 10秒
|
||||
next := alignToNextBucketEnd(now, 10).Add(10 * time.Second)
|
||||
e.logger.Printf("调度: 当前=%s, 下次执行=%s (延迟10秒)", now.Format("2006-01-02 15:04:05"), next.Format("2006-01-02 15:04:05"))
|
||||
delay := time.Until(next)
|
||||
if delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
// 当前时间在 21:11:xx 时,应导出桶 [21:00, 21:10)
|
||||
currentTime := time.Now().In(e.loc)
|
||||
bucketEnd := alignToPrevBucketEnd(currentTime, 10)
|
||||
bucketStart := bucketEnd.Add(-10 * time.Minute)
|
||||
|
||||
e.logger.Printf("当前时间=%s, 导出桶开始时间=%s, 桶结束时间=%s",
|
||||
currentTime.Format("2006-01-02 15:04:05"),
|
||||
bucketStart.Format("2006-01-02 15:04:05"),
|
||||
bucketEnd.Format("2006-01-02 15:04:05"))
|
||||
|
||||
if err := e.exportBucket(ctx, bucketStart, bucketEnd); err != nil {
|
||||
e.logger.Printf("导出桶 %s-%s 失败: %v", bucketStart.Format("2006-01-02 15:04:05"), bucketEnd.Format("2006-01-02 15:04:05"), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exportBucket 导出一个10分钟桶(CST)
|
||||
func (e *Exporter) exportBucket(ctx context.Context, bucketStart, bucketEnd time.Time) error {
|
||||
utcDay := bucketEnd.UTC().Format("2006-01-02")
|
||||
outBase := getenvDefault("EXPORT_DIR", "export_data")
|
||||
outDir := filepath.Join(outBase)
|
||||
histDir := filepath.Join(outBase, "history")
|
||||
if err := os.MkdirAll(histDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
activePath := filepath.Join(outDir, fmt.Sprintf("weather_data_%s.csv", utcDay))
|
||||
e.logger.Printf("开始导出: 桶开始时间=%s, 桶结束时间=%s, 文件=%s",
|
||||
bucketStart.Format("2006-01-02 15:04:05"),
|
||||
bucketEnd.Format("2006-01-02 15:04:05"),
|
||||
activePath)
|
||||
|
||||
// 先查询所有符合条件的站点,用于后续比对缺失
|
||||
var eligibleStations []struct {
|
||||
StationID string
|
||||
DeviceID string
|
||||
}
|
||||
stationsRows, err := e.pg.QueryContext(ctx, `
|
||||
SELECT station_id, device_id
|
||||
FROM stations
|
||||
WHERE device_type = 'WH65LP'
|
||||
AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
AND latitude <> 0 AND longitude <> 0
|
||||
ORDER BY station_id
|
||||
`)
|
||||
if err != nil {
|
||||
e.logger.Printf("查询合格站点失败: %v", err)
|
||||
} else {
|
||||
defer stationsRows.Close()
|
||||
for stationsRows.Next() {
|
||||
var station struct {
|
||||
StationID string
|
||||
DeviceID string
|
||||
}
|
||||
if err := stationsRows.Scan(&station.StationID, &station.DeviceID); err == nil {
|
||||
eligibleStations = append(eligibleStations, station)
|
||||
}
|
||||
}
|
||||
e.logger.Printf("合格站点总数: %d", len(eligibleStations))
|
||||
for _, s := range eligibleStations {
|
||||
e.logger.Printf("合格站点: station_id=%s, device_id=%s", s.StationID, s.DeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
// 轮转上一 UTC 日的文件(若存在且未压缩)
|
||||
if err := rotatePreviousUTC(outDir, histDir, utcDay); err != nil {
|
||||
e.logger.Printf("轮转上一日文件失败: %v", err)
|
||||
}
|
||||
|
||||
needHeader := ensureFileWithHeader(activePath)
|
||||
|
||||
// 查询该桶的数据 - 使用桶开始时间查询
|
||||
e.logger.Printf("查询数据: 使用bucket_start=%s", bucketStart.Format("2006-01-02 15:04:05"))
|
||||
rows, err := e.pg.QueryContext(ctx, `
|
||||
SELECT
|
||||
s.latitude,
|
||||
s.longitude,
|
||||
s.device_id,
|
||||
s.altitude,
|
||||
r.pressure_hpa_x100,
|
||||
r.temp_c_x100,
|
||||
r.wind_speed_ms_x1000,
|
||||
r.wind_dir_deg,
|
||||
r.humidity_pct,
|
||||
r.bucket_start,
|
||||
s.station_id
|
||||
FROM stations s
|
||||
JOIN rs485_weather_10min r ON r.station_id = s.station_id AND r.bucket_start = $1
|
||||
WHERE s.device_type = 'WH65LP'
|
||||
AND s.latitude IS NOT NULL AND s.longitude IS NOT NULL
|
||||
AND s.latitude <> 0 AND s.longitude <> 0
|
||||
ORDER BY s.station_id
|
||||
`, bucketStart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询10分钟数据失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
f, err := os.OpenFile(activePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if needHeader {
|
||||
if _, err := f.WriteString(headerLine() + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var total, ztdHit, ztdMiss int
|
||||
processedStationIDs := make(map[string]bool)
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
lat, lon, elev sql.NullFloat64
|
||||
deviceID string
|
||||
pX100, tX100 sql.NullInt64
|
||||
wsX1000 sql.NullInt64
|
||||
wdDeg sql.NullInt64
|
||||
rh sql.NullInt64
|
||||
bucketStartTS time.Time
|
||||
stationID string
|
||||
)
|
||||
if err := rows.Scan(&lat, &lon, &deviceID, &elev, &pX100, &tX100, &wsX1000, &wdDeg, &rh, &bucketStartTS, &stationID); err != nil {
|
||||
e.logger.Printf("扫描行失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
processedStationIDs[stationID] = true
|
||||
e.logger.Printf("处理站点: station_id=%s, device_id=%s, 经度=%.6f, 纬度=%.6f", stationID, deviceID, lon.Float64, lat.Float64)
|
||||
|
||||
// CSV 使用桶末时间作为 date_time(用于表意 10 分钟区间的右端点)
|
||||
dateTimeStr := bucketEnd.In(e.loc).Format("2006-01-02 15:04:05")
|
||||
e.logger.Printf("站点 %s: 写出时间(桶末)=%s,用于 ZTD 对齐参考", stationID, dateTimeStr)
|
||||
|
||||
var pressureStr, tempStr, wsStr, wdStr, rhStr string
|
||||
if pX100.Valid {
|
||||
pressureStr = fmtFloat(float64(pX100.Int64)/100.0, 2)
|
||||
}
|
||||
if tX100.Valid {
|
||||
tempStr = fmtFloat(float64(tX100.Int64)/100.0, 2)
|
||||
}
|
||||
if wsX1000.Valid {
|
||||
wsStr = fmtFloat(float64(wsX1000.Int64)/1000.0, 3)
|
||||
}
|
||||
if wdDeg.Valid {
|
||||
wdStr = fmtFloat(float64(wdDeg.Int64), 0)
|
||||
}
|
||||
if rh.Valid {
|
||||
rhStr = fmtFloat(float64(rh.Int64), 0)
|
||||
}
|
||||
|
||||
// 如果需要,使用彩云实况覆盖风速/风向
|
||||
if e.opts.OverrideWindWithCaiyun && lat.Valid && lon.Valid && e.opts.CaiyunToken != "" {
|
||||
if spd, dir, ok := e.fetchCaiyunRealtimeWind(ctx, lat.Float64, lon.Float64); ok {
|
||||
wsStr = fmtFloat(spd, 3)
|
||||
wdStr = fmtFloat(dir, 0)
|
||||
e.logger.Printf("站点 %s: 使用彩云实况覆盖风: speed=%.3f m/s, dir=%.0f°", stationID, spd, dir)
|
||||
} else {
|
||||
e.logger.Printf("站点 %s: 彩云实况风获取失败,保留数据库值", stationID)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用device_id查询ZTD,使用桶末时间
|
||||
ztdStr := e.lookupZTD(ctx, deviceID, bucketEnd)
|
||||
if ztdStr != "" {
|
||||
ztdHit++
|
||||
e.logger.Printf("站点 %s (device_id=%s): ZTD 数据正常, 值=%s", stationID, deviceID, ztdStr)
|
||||
} else {
|
||||
ztdMiss++
|
||||
e.logger.Printf("站点 %s (device_id=%s): ZTD 数据缺失或超出5分钟窗口", stationID, deviceID)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmtNullFloat(lat))
|
||||
b.WriteByte(',')
|
||||
b.WriteString(fmtNullFloat(lon))
|
||||
b.WriteByte(',')
|
||||
b.WriteString(deviceID) // CSV输出用device_id作为station_id
|
||||
b.WriteByte(',')
|
||||
b.WriteByte(',') // station_name 留空
|
||||
b.WriteString(dateTimeStr)
|
||||
b.WriteByte(',')
|
||||
b.WriteString(fmtNullFloat(elev))
|
||||
b.WriteByte(',')
|
||||
b.WriteString(pressureStr)
|
||||
b.WriteByte(',')
|
||||
b.WriteString(tempStr)
|
||||
b.WriteByte(',')
|
||||
b.WriteByte(',') // dewpoint 留空
|
||||
b.WriteString(wsStr)
|
||||
b.WriteByte(',')
|
||||
b.WriteString(wdStr)
|
||||
b.WriteByte(',')
|
||||
b.WriteString(rhStr)
|
||||
b.WriteByte(',')
|
||||
b.WriteString(ztdStr)
|
||||
b.WriteByte(',') // pwv 留空
|
||||
|
||||
if _, err := f.WriteString(b.String() + "\n"); err != nil {
|
||||
e.logger.Printf("写入CSV失败: %v", err)
|
||||
}
|
||||
total++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查哪些站点在这个桶完全没有数据
|
||||
var missingCount int
|
||||
for _, station := range eligibleStations {
|
||||
if !processedStationIDs[station.StationID] {
|
||||
e.logger.Printf("站点缺失: station_id=%s, device_id=%s (rs485_weather_10min 表中未找到记录)",
|
||||
station.StationID, station.DeviceID)
|
||||
missingCount++
|
||||
}
|
||||
}
|
||||
|
||||
e.logger.Printf("导出完成: 桶开始时间=%s, 桶结束时间=%s, 总行数=%d, ZTD命中=%d, ZTD未命中=%d, 缺失站点数=%d",
|
||||
bucketStart.Format("2006-01-02 15:04:05"),
|
||||
bucketEnd.Format("2006-01-02 15:04:05"),
|
||||
total, ztdHit, ztdMiss, missingCount)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 导出一个日期范围内的全部10分钟桶(CST),start 与 end 为“日期起止(含)”
|
||||
func (e *Exporter) ExportRange(ctx context.Context, startDate, endDate string) error {
|
||||
// 解析日期,支持 YYYY-MM-DD 或 YYYYMMDD
|
||||
parse := func(s string) (time.Time, error) {
|
||||
if len(s) == 8 {
|
||||
// YYYYMMDD -> YYYY-MM-DD
|
||||
s = s[:4] + "-" + s[4:6] + "-" + s[6:8]
|
||||
}
|
||||
loc := e.loc
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
return time.ParseInLocation("2006-01-02", s, loc)
|
||||
}
|
||||
|
||||
fromDay, err := parse(startDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("起始日期解析失败: %v", err)
|
||||
}
|
||||
toDay, err := parse(endDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("结束日期解析失败: %v", err)
|
||||
}
|
||||
if toDay.Before(fromDay) {
|
||||
return fmt.Errorf("结束日期早于起始日期")
|
||||
}
|
||||
|
||||
// 日期起止 -> 时间范围(含):[fromDay 00:00, toDay 23:59:59]
|
||||
from := time.Date(fromDay.Year(), fromDay.Month(), fromDay.Day(), 0, 0, 0, 0, e.loc)
|
||||
to := time.Date(toDay.Year(), toDay.Month(), toDay.Day(), 23, 59, 59, 0, e.loc)
|
||||
|
||||
firstBucket := from.Truncate(10 * time.Minute)
|
||||
lastBucket := to.Truncate(10 * time.Minute)
|
||||
|
||||
for b := firstBucket; !b.After(lastBucket); b = b.Add(10 * time.Minute) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
bucketStart := b
|
||||
bucketEnd := b.Add(10 * time.Minute)
|
||||
if err := e.exportBucket(ctx, bucketStart, bucketEnd); err != nil {
|
||||
// 不中断整个范围导出,记录错误继续
|
||||
e.logger.Printf("范围导出: 桶 %s-%s 导出失败: %v", bucketStart.Format("2006-01-02 15:04:05"), bucketEnd.Format("2006-01-02 15:04:05"), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Exporter) lookupZTD(ctx context.Context, deviceID string, bucketEnd time.Time) string {
|
||||
if e.my == nil {
|
||||
return ""
|
||||
}
|
||||
var ztd sql.NullFloat64
|
||||
var ts time.Time
|
||||
err := e.my.QueryRowContext(ctx, `
|
||||
SELECT ztd, timestamp FROM rtk_data
|
||||
WHERE station_id = ?
|
||||
AND ABS(TIMESTAMPDIFF(MINUTE, timestamp, ?)) <= 5
|
||||
LIMIT 1
|
||||
`, deviceID, bucketEnd).Scan(&ztd, &ts)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
e.logger.Printf("查询ZTD失败: station_id=%s, error=%v", deviceID, err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if !ztd.Valid {
|
||||
e.logger.Printf("站点 device_id=%s: ZTD值为NULL", deviceID)
|
||||
return ""
|
||||
}
|
||||
return fmtFloat(ztd.Float64*100.0, -1)
|
||||
}
|
||||
|
||||
// fetchCaiyunRealtimeWind 拉取彩云实时风(m/s, 度)。lat,lon为纬度、经度。
|
||||
func (e *Exporter) fetchCaiyunRealtimeWind(ctx context.Context, lat, lon float64) (float64, float64, bool) {
|
||||
if e.httpClient == nil || e.opts.CaiyunToken == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
type realtimeResp struct {
|
||||
Status string `json:"status"`
|
||||
Unit string `json:"unit"`
|
||||
Result struct {
|
||||
Realtime struct {
|
||||
Status string `json:"status"`
|
||||
Wind struct {
|
||||
Speed float64 `json:"speed"`
|
||||
Direction float64 `json:"direction"`
|
||||
} `json:"wind"`
|
||||
} `json:"realtime"`
|
||||
} `json:"result"`
|
||||
}
|
||||
url := fmt.Sprintf("https://api.caiyunapp.com/v2.6/%s/%f,%f/realtime?unit=SI", e.opts.CaiyunToken, lon, lat)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
resp, err := e.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
var data realtimeResp
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if strings.ToLower(data.Status) != "ok" || strings.ToLower(data.Result.Realtime.Status) != "ok" {
|
||||
return 0, 0, false
|
||||
}
|
||||
// 使用 SI 单位,风速为 m/s;风向为弧度,这里转换为度[0,360)
|
||||
spd := data.Result.Realtime.Wind.Speed
|
||||
dirRad := data.Result.Realtime.Wind.Direction
|
||||
dirDeg := dirRad * 180.0 / math.Pi
|
||||
for dirDeg < 0 {
|
||||
dirDeg += 360
|
||||
}
|
||||
for dirDeg >= 360 {
|
||||
dirDeg -= 360
|
||||
}
|
||||
return spd, dirDeg, true
|
||||
}
|
||||
|
||||
func ensureFileWithHeader(path string) bool {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return false
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
log.Printf("create csv failed: %v", err)
|
||||
return false
|
||||
}
|
||||
_ = f.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func headerLine() string {
|
||||
return "latitude,longitude,station_id,station_name,date_time,elevation,pressure,temperature,dewpoint,wind_speed,wind_direction,relative_humidity,ztd,pwv"
|
||||
}
|
||||
|
||||
func alignToNextBucketEnd(t time.Time, minutes int) time.Time {
|
||||
m := t.Minute()
|
||||
next := (m/minutes + 1) * minutes
|
||||
dt := time.Duration(next-m) * time.Minute
|
||||
return t.Truncate(time.Minute).Add(dt).Truncate(time.Minute)
|
||||
}
|
||||
|
||||
func alignToPrevBucketEnd(t time.Time, minutes int) time.Time {
|
||||
m := t.Minute()
|
||||
prev := (m / minutes) * minutes
|
||||
// 返回不超过当前时间的10分钟整点(例如 21:21 -> 21:20)
|
||||
return t.Truncate(time.Minute).Add(time.Duration(prev-m) * time.Minute)
|
||||
}
|
||||
|
||||
func fmtNullFloat(v sql.NullFloat64) string {
|
||||
if v.Valid {
|
||||
return fmtFloat(v.Float64, -1)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fmtFloat: prec < 0 表示用不固定小数(去除无意义零),否则保留指定小数位
|
||||
func fmtFloat(fv float64, prec int) string {
|
||||
if prec >= 0 {
|
||||
return fmt.Sprintf("%.*f", prec, fv)
|
||||
}
|
||||
s := fmt.Sprintf("%.10f", fv)
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimRight(s, ".")
|
||||
if s == "-0" {
|
||||
s = "0"
|
||||
}
|
||||
if s == "" || s == "-" || s == "+" || s == "." {
|
||||
return "0"
|
||||
}
|
||||
if math.Abs(fv) < 1e-9 {
|
||||
return "0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// rotatePreviousUTC 将上一UTC日的活跃CSV压缩到history目录
|
||||
func rotatePreviousUTC(outDir, histDir, currentUTC string) error {
|
||||
// 计算昨日 UTC 日期
|
||||
curDay, err := time.Parse("2006-01-02", currentUTC)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
yesterday := curDay.Add(-24 * time.Hour).Format("2006-01-02")
|
||||
prevPath := filepath.Join(outDir, fmt.Sprintf("weather_data_%s.csv", yesterday))
|
||||
gzPath := filepath.Join(histDir, fmt.Sprintf("weather_data_%s.csv.gz", yesterday))
|
||||
|
||||
if _, err := os.Stat(prevPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(gzPath); err == nil {
|
||||
// 已压缩
|
||||
return nil
|
||||
}
|
||||
return gzipFile(prevPath, gzPath)
|
||||
}
|
||||
|
||||
func gzipFile(src, dst string) error {
|
||||
srcF, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcF.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
dstF, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = dstF.Close()
|
||||
}()
|
||||
|
||||
gw := gzip.NewWriter(dstF)
|
||||
gw.Name = filepath.Base(src)
|
||||
gw.ModTime = time.Now()
|
||||
defer func() { _ = gw.Close() }()
|
||||
|
||||
if _, err := io.Copy(gw, srcF); err != nil {
|
||||
return err
|
||||
}
|
||||
// 压缩成功后删除原文件
|
||||
return os.Remove(src)
|
||||
}
|
||||
52
internal/tools/forecast_fetch.go
Normal file
52
internal/tools/forecast_fetch.go
Normal file
@ -0,0 +1,52 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
"weatherstation/internal/config"
|
||||
"weatherstation/internal/forecast"
|
||||
)
|
||||
|
||||
// RunForecastFetchForDay 按指定“日期”(CST)抓取当天0点到当前时间后三小时的预报(Open-Meteo 与 彩云)
|
||||
// dateStr: 形如 "2006-01-02"(按 Asia/Shanghai 解析)
|
||||
func RunForecastFetchForDay(ctx context.Context, dateStr string) error {
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
|
||||
dayStart, err := time.ParseInLocation("2006-01-02", dateStr, loc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().In(loc)
|
||||
// 窗口:dayStart .. now+3h 的各整点(写库函数内部按 issued_at=当前时间)
|
||||
// 目前 open-meteo 与彩云抓取函数都是"取未来三小时"的固定窗口。
|
||||
// 这里采用:先执行一次 open-meteo 与彩云的"未来三小时"写入,作为当下 issued 版本。
|
||||
|
||||
log.Printf("开始抓取 Open-Meteo 预报...")
|
||||
if err := forecast.RunOpenMeteoFetch(ctx); err != nil {
|
||||
log.Printf("Open-Meteo 抓取失败: %v", err)
|
||||
} else {
|
||||
log.Printf("Open-Meteo 抓取完成")
|
||||
}
|
||||
|
||||
token := config.GetConfig().Forecast.CaiyunToken
|
||||
log.Printf("彩云 token: %s", token)
|
||||
if token != "" {
|
||||
log.Printf("开始抓取彩云预报...")
|
||||
if err := forecast.RunCaiyunFetch(ctx, token); err != nil {
|
||||
log.Printf("彩云 抓取失败: %v", err)
|
||||
} else {
|
||||
log.Printf("彩云抓取完成")
|
||||
}
|
||||
} else {
|
||||
log.Printf("未配置彩云 token,跳过彩云抓取")
|
||||
}
|
||||
|
||||
_ = dayStart
|
||||
_ = now
|
||||
return nil
|
||||
}
|
||||
100
main.go
100
main.go
@ -16,8 +16,6 @@ import (
|
||||
|
||||
"weatherstation/config"
|
||||
"weatherstation/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UTF8Writer struct {
|
||||
@ -123,7 +121,6 @@ func startUDP() {
|
||||
continue
|
||||
}
|
||||
rawData := buffer[:n]
|
||||
data := string(rawData)
|
||||
log.Printf("从 %s 接收到 %d 字节数据", addr.String(), n)
|
||||
|
||||
hexDump := hexDump(rawData)
|
||||
@ -131,17 +128,54 @@ func startUDP() {
|
||||
asciiDump := asciiDump(rawData)
|
||||
log.Printf("ASCII码:\n%s", asciiDump)
|
||||
|
||||
// 首先尝试解析为WH65LP数据
|
||||
if len(rawData) == 25 && rawData[0] == 0x24 {
|
||||
wh65lpData, err := model.ParseWH65LPData(rawData)
|
||||
if err != nil {
|
||||
log.Printf("解析WH65LP数据失败: %v", err)
|
||||
} else {
|
||||
log.Println("成功解析WH65LP气象站数据:")
|
||||
log.Println(wh65lpData)
|
||||
|
||||
// 更新内存中的设备信息
|
||||
model.UpdateDeviceInMemory(wh65lpData.StationID, addr, model.DeviceTypeWH65LP)
|
||||
// 注册设备到数据库
|
||||
err = model.RegisterDeviceInDB(wh65lpData.StationID, addr)
|
||||
if err != nil {
|
||||
log.Printf("注册设备失败: %v", err)
|
||||
}
|
||||
log.Printf("设备 %s 已注册,IP: %s", wh65lpData.StationID, addr.String())
|
||||
|
||||
// 保存数据
|
||||
err = model.SaveWH65LPData(wh65lpData, rawData)
|
||||
if err != nil {
|
||||
log.Printf("保存数据到数据库失败: %v", err)
|
||||
} else {
|
||||
log.Printf("数据已成功保存到数据库")
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是WH65LP数据,尝试解析为ECOWITT数据
|
||||
data := string(rawData)
|
||||
weatherData, err := model.ParseWeatherData(data)
|
||||
if err != nil {
|
||||
log.Printf("解析数据失败: %v", err)
|
||||
log.Printf("解析ECOWITT数据失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Println("成功解析气象站数据:")
|
||||
log.Println("成功解析ECOWITT气象站数据:")
|
||||
log.Println(weatherData)
|
||||
|
||||
if weatherData.StationID != "" {
|
||||
model.RegisterDevice(weatherData.StationID, addr)
|
||||
// 更新内存中的设备信息
|
||||
model.UpdateDeviceInMemory(weatherData.StationID, addr, model.DeviceTypeEcowitt)
|
||||
// 注册设备到数据库
|
||||
err = model.RegisterDeviceInDB(weatherData.StationID, addr)
|
||||
if err != nil {
|
||||
log.Printf("注册设备失败: %v", err)
|
||||
}
|
||||
log.Printf("设备 %s 已注册,IP: %s", weatherData.StationID, addr.String())
|
||||
} else {
|
||||
log.Printf("警告: 收到的数据没有站点ID")
|
||||
@ -156,61 +190,9 @@ func startUDP() {
|
||||
}
|
||||
}
|
||||
|
||||
func startDeviceCheck() {
|
||||
cfg := config.GetConfig()
|
||||
ticker := time.NewTicker(time.Duration(cfg.DeviceCheck.Interval) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
devices := model.GetOnlineDevices()
|
||||
log.Printf("当前在线设备数: %d", len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
sendUDPMessage(device.IP, cfg.DeviceCheck.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendUDPMessage(ip string, message string) {
|
||||
cfg := config.GetConfig()
|
||||
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", ip, cfg.Server.UDPPort))
|
||||
if err != nil {
|
||||
log.Printf("解析UDP地址失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("尝试向 %s 发送消息...", addr.String())
|
||||
|
||||
conn, err := net.DialUDP("udp", nil, addr)
|
||||
if err != nil {
|
||||
log.Printf("连接UDP失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
n, err := conn.Write([]byte(message))
|
||||
if err != nil {
|
||||
log.Printf("发送UDP消息失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("成功向 %s 发送 %d 字节消息: %s", ip, n, message)
|
||||
}
|
||||
|
||||
func main() {
|
||||
setupLogger()
|
||||
go startUDP()
|
||||
go startDeviceCheck()
|
||||
|
||||
r := gin.Default()
|
||||
r.LoadHTMLGlob("templates/*")
|
||||
r.Static("/static", "static")
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.HTML(200, "index.html", gin.H{})
|
||||
})
|
||||
r.Run(":10007")
|
||||
startUDP() // 直接运行UDP服务器,不再使用goroutine
|
||||
}
|
||||
|
||||
func hexDump(data []byte) string {
|
||||
|
||||
82
model/db.go
82
model/db.go
@ -30,6 +30,44 @@ func InitDB() error {
|
||||
return fmt.Errorf("数据库连接测试失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建RS485数据表
|
||||
err = createRS485Table()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建RS485数据表失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createRS485Table() error {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS rs485_weather_data (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
station_id VARCHAR(50) NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
temperature DOUBLE PRECISION,
|
||||
humidity DOUBLE PRECISION,
|
||||
wind_speed DOUBLE PRECISION,
|
||||
wind_direction DOUBLE PRECISION,
|
||||
rainfall DOUBLE PRECISION,
|
||||
light DOUBLE PRECISION,
|
||||
uv DOUBLE PRECISION,
|
||||
pressure DOUBLE PRECISION,
|
||||
raw_data TEXT,
|
||||
FOREIGN KEY (station_id) REFERENCES stations(station_id),
|
||||
UNIQUE (station_id, timestamp)
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 支持性索引(若已存在则不重复创建)
|
||||
if _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_rwd_time ON rs485_weather_data (timestamp)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_rwd_station_time ON rs485_weather_data (station_id, timestamp)`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -66,19 +104,27 @@ func ensureStationExists(stationID, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func SaveWeatherData(data *WeatherData, rawData string) error {
|
||||
func SaveWeatherData(data interface{}, rawData string) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("数据库未初始化")
|
||||
}
|
||||
|
||||
switch v := data.(type) {
|
||||
case *WeatherData:
|
||||
return saveWIFIWeatherData(v, rawData)
|
||||
case *RS485WeatherData:
|
||||
return saveRS485WeatherData(v)
|
||||
default:
|
||||
return fmt.Errorf("未知的数据类型")
|
||||
}
|
||||
}
|
||||
|
||||
func saveWIFIWeatherData(data *WeatherData, rawData string) error {
|
||||
err := ensureStationExists(data.StationID, data.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cst := time.FixedZone("CST", 8*60*60)
|
||||
timestamp := time.Now().In(cst)
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO weather_data (
|
||||
station_id, timestamp, temp_f, humidity, dewpoint_f, windchill_f,
|
||||
@ -87,7 +133,7 @@ func SaveWeatherData(data *WeatherData, rawData string) error {
|
||||
solar_radiation, uv, indoor_temp_f, indoor_humidity,
|
||||
abs_barometer_in, barometer_in, low_battery, raw_data
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`,
|
||||
data.StationID, timestamp,
|
||||
data.StationID, time.Now(),
|
||||
int(data.TempF*10), data.Humidity, int(data.DewpointF*10), int(data.WindchillF*10),
|
||||
data.WindDir, int(data.WindSpeedMph*100), int(data.WindGustMph*100),
|
||||
int(data.RainIn*1000), int(data.DailyRainIn*1000), int(data.WeeklyRainIn*1000),
|
||||
@ -96,8 +142,30 @@ func SaveWeatherData(data *WeatherData, rawData string) error {
|
||||
int(data.AbsBarometerIn*1000), int(data.BarometerIn*1000), data.LowBattery, rawData)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存气象数据失败: %v", err)
|
||||
return fmt.Errorf("保存WIFI气象数据失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveRS485WeatherData(data *RS485WeatherData) error {
|
||||
stationID := fmt.Sprintf("RS485-%s", data.DeviceID)
|
||||
err := ensureStationExists(stationID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO rs485_weather_data (
|
||||
station_id, timestamp, temperature, humidity, wind_speed,
|
||||
wind_direction, rainfall, light, uv, pressure, raw_data
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
stationID, data.ReceivedAt,
|
||||
data.Temperature, data.Humidity, data.WindSpeed,
|
||||
data.WindDirection, data.Rainfall, data.Light,
|
||||
data.UV, data.Pressure, data.RawDataHex)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存RS485气象数据失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -29,16 +29,3 @@ func RegisterDevice(stationID string, addr net.Addr) {
|
||||
StationID: stationID,
|
||||
}
|
||||
}
|
||||
|
||||
func GetOnlineDevices() []*Device {
|
||||
deviceMutex.RLock()
|
||||
defer deviceMutex.RUnlock()
|
||||
|
||||
result := make([]*Device, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if time.Since(device.LastSeen) < 10*time.Minute {
|
||||
result = append(result, device)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
854
model/protocol.go
Normal file
854
model/protocol.go
Normal file
@ -0,0 +1,854 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Protocol 定义协议结构
|
||||
type Protocol struct {
|
||||
RawData []byte
|
||||
}
|
||||
|
||||
// NewProtocol 创建新的协议实例
|
||||
func NewProtocol(data []byte) *Protocol {
|
||||
return &Protocol{
|
||||
RawData: data,
|
||||
}
|
||||
}
|
||||
|
||||
// IdentifyTxType 解析传输类型(第一个字节,bit 0-7)
|
||||
func (p *Protocol) IdentifyTxType() (binary string, hexVal string, decVal uint8) {
|
||||
if len(p.RawData) == 0 {
|
||||
return "", "", 0
|
||||
}
|
||||
|
||||
// 获取第一个字节
|
||||
firstByte := p.RawData[0]
|
||||
|
||||
// 转换为二进制字符串(8位)
|
||||
binary = fmt.Sprintf("%08b", firstByte)
|
||||
|
||||
// 转换为十六进制字符串
|
||||
hexVal = fmt.Sprintf("%02X", firstByte)
|
||||
|
||||
// 十进制值
|
||||
decVal = firstByte
|
||||
|
||||
return binary, hexVal, decVal
|
||||
}
|
||||
|
||||
// IDParts 存储ID的三个部分
|
||||
type IDParts struct {
|
||||
HSB struct {
|
||||
Binary string
|
||||
Hex string
|
||||
Dec uint8
|
||||
}
|
||||
MSB struct {
|
||||
Binary string
|
||||
Hex string
|
||||
Dec uint8
|
||||
}
|
||||
LSB struct {
|
||||
Binary string
|
||||
Hex string
|
||||
Dec uint8
|
||||
}
|
||||
Complete struct {
|
||||
Binary string
|
||||
Hex string
|
||||
Dec uint32
|
||||
}
|
||||
}
|
||||
|
||||
// GetCompleteID 获取完整的24-bit ID code
|
||||
// HSB: bit 168-175 (索引21)
|
||||
// MSB: bit 176-183 (索引22)
|
||||
// LSB: bit 8-15 (索引1)
|
||||
func (p *Protocol) GetCompleteID() (*IDParts, error) {
|
||||
if len(p.RawData) < 23 { // 确保有足够的数据
|
||||
return nil, fmt.Errorf("insufficient data length")
|
||||
}
|
||||
|
||||
result := &IDParts{}
|
||||
|
||||
// 处理 HSB (bit 168-175, 索引21)
|
||||
hsbByte := p.RawData[21]
|
||||
result.HSB.Binary = fmt.Sprintf("%08b", hsbByte)
|
||||
result.HSB.Hex = fmt.Sprintf("%02X", hsbByte)
|
||||
result.HSB.Dec = hsbByte
|
||||
|
||||
// 处理 MSB (bit 176-183, 索引22)
|
||||
msbByte := p.RawData[22]
|
||||
result.MSB.Binary = fmt.Sprintf("%08b", msbByte)
|
||||
result.MSB.Hex = fmt.Sprintf("%02X", msbByte)
|
||||
result.MSB.Dec = msbByte
|
||||
|
||||
// 处理 LSB (bit 8-15, 索引1)
|
||||
lsbByte := p.RawData[1]
|
||||
result.LSB.Binary = fmt.Sprintf("%08b", lsbByte)
|
||||
result.LSB.Hex = fmt.Sprintf("%02X", lsbByte)
|
||||
result.LSB.Dec = lsbByte
|
||||
|
||||
// 组合完整的24位ID
|
||||
completeID := uint32(hsbByte)<<16 | uint32(msbByte)<<8 | uint32(lsbByte)
|
||||
result.Complete.Binary = fmt.Sprintf("%024b", completeID)
|
||||
result.Complete.Hex = fmt.Sprintf("%06X", completeID)
|
||||
result.Complete.Dec = completeID
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// WindDirection 存储风向的三个部分
|
||||
type WindDirection struct {
|
||||
DirH struct {
|
||||
Binary string // 4位二进制,格式为"000X",其中X是bit24
|
||||
Value uint8 // 实际值
|
||||
}
|
||||
DirM struct {
|
||||
Binary string // bit 16-19
|
||||
Value uint8
|
||||
}
|
||||
DirL struct {
|
||||
Binary string // bit 20-23
|
||||
Value uint8
|
||||
}
|
||||
Complete struct {
|
||||
Binary string // 完整的12位二进制
|
||||
Value uint16 // 完整值 (Range: 0°- 359°, Invalid: 0x1FF)
|
||||
Degree float64 // 角度值
|
||||
IsValid bool // 是否有效
|
||||
}
|
||||
}
|
||||
|
||||
// GetWindDirection 解析风向数据
|
||||
// DIR_H: 4位 (000 + bit24)
|
||||
// DIR_M: bit 16-19
|
||||
// DIR_L: bit 20-23
|
||||
// Value in hex (Range: 0°- 359°)
|
||||
// If invalid fill with 0x1FF
|
||||
func (p *Protocol) GetWindDirection() (*WindDirection, error) {
|
||||
if len(p.RawData) < 4 { // 确保有足够的数据
|
||||
return nil, fmt.Errorf("insufficient data length")
|
||||
}
|
||||
|
||||
result := &WindDirection{}
|
||||
|
||||
// 获取包含bit24的字节(索引3)
|
||||
byte3 := p.RawData[3]
|
||||
// 获取bit24(字节的最低位)
|
||||
bit24 := byte3 & 0x01
|
||||
// 构造DIR_H: "000" + bit24
|
||||
result.DirH.Binary = fmt.Sprintf("000%d", bit24)
|
||||
result.DirH.Value = bit24
|
||||
|
||||
// 获取包含bit16-23的字节(索引2)
|
||||
byte2 := p.RawData[2]
|
||||
// 获取DIR_M (bit 16-19)
|
||||
dirM := (byte2 >> 4) & 0x0F
|
||||
result.DirM.Binary = fmt.Sprintf("%04b", dirM)
|
||||
result.DirM.Value = dirM
|
||||
|
||||
// 获取DIR_L (bit 20-23)
|
||||
dirL := byte2 & 0x0F
|
||||
result.DirL.Binary = fmt.Sprintf("%04b", dirL)
|
||||
result.DirL.Value = dirL
|
||||
|
||||
// 组合完整的风向值(12位)
|
||||
completeDir := (uint16(bit24) << 8) | (uint16(dirM) << 4) | uint16(dirL)
|
||||
result.Complete.Binary = fmt.Sprintf("%012b", completeDir)
|
||||
result.Complete.Value = completeDir
|
||||
|
||||
// 检查值是否在有效范围内 (0-359)
|
||||
if completeDir > 359 {
|
||||
result.Complete.Value = 0x1FF
|
||||
result.Complete.IsValid = false
|
||||
result.Complete.Degree = 0
|
||||
} else {
|
||||
result.Complete.IsValid = true
|
||||
result.Complete.Degree = float64(completeDir)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// WindSpeed 存储风速的各个部分
|
||||
type WindSpeed struct {
|
||||
WspFlag struct {
|
||||
Binary string // bit 25
|
||||
Value bool // true = 9bit模式, false = 10bit模式
|
||||
}
|
||||
Extend struct {
|
||||
Binary string // 9bit模式: 000 + bit27
|
||||
// 10bit模式: 00 + bit26 + bit27
|
||||
Value uint8
|
||||
}
|
||||
WspH struct {
|
||||
Binary string // bit 48-51
|
||||
Value uint8
|
||||
}
|
||||
WspL struct {
|
||||
Binary string // bit 52-55
|
||||
Value uint8
|
||||
}
|
||||
Complete struct {
|
||||
Binary string // 完整的9位或10位二进制
|
||||
RawValue uint16 // 原始值
|
||||
Value float64 // 实际风速值 (计算公式: RawValue/8*0.51)
|
||||
}
|
||||
}
|
||||
|
||||
// GetWindSpeed 解析风速数据
|
||||
// WSP_FLAG: bit 25
|
||||
// WIND_EXTEND:
|
||||
// - 当WSP_FLAG=1时:000 + bit27(9bit模式)
|
||||
// - 当WSP_FLAG=0时:0 + bit136 + bit26 + bit27(10bit模式)
|
||||
//
|
||||
// WIND_H: bit 48-51
|
||||
// WIND_L: bit 52-55
|
||||
// 实际风速计算公式: value/8*0.51
|
||||
func (p *Protocol) GetWindSpeed() (*WindSpeed, error) {
|
||||
if len(p.RawData) < 18 { // 确保有足够的数据(需要读取到bit136)
|
||||
return nil, fmt.Errorf("insufficient data length")
|
||||
}
|
||||
|
||||
result := &WindSpeed{}
|
||||
|
||||
// 解析 WSP_FLAG (bit 25)
|
||||
byte3 := p.RawData[3]
|
||||
wspFlag := (byte3 >> 1) & 0x01 // bit 25
|
||||
result.WspFlag.Binary = fmt.Sprintf("%d", wspFlag)
|
||||
result.WspFlag.Value = wspFlag == 1
|
||||
|
||||
// 获取bit26和bit27
|
||||
bit26 := (byte3 >> 2) & 0x01
|
||||
bit27 := (byte3 >> 3) & 0x01
|
||||
|
||||
// 获取bit136(在第17个字节的最高位)
|
||||
byte17 := p.RawData[17]
|
||||
bit136 := (byte17 >> 7) & 0x01
|
||||
|
||||
// 解析 WIND_H 和 WIND_L (byte6)
|
||||
byte6 := p.RawData[6]
|
||||
windH := (byte6 >> 4) & 0x0F
|
||||
windL := byte6 & 0x0F
|
||||
|
||||
result.WspH.Binary = fmt.Sprintf("%04b", windH)
|
||||
result.WspH.Value = windH
|
||||
result.WspL.Binary = fmt.Sprintf("%04b", windL)
|
||||
result.WspL.Value = windL
|
||||
|
||||
// 组合完整的风速值
|
||||
var rawValue uint16
|
||||
if result.WspFlag.Value {
|
||||
// 9bit模式:000 + bit27 + WIND_H + WIND_L
|
||||
rawValue = (uint16(bit27) << 8) | (uint16(windH) << 4) | uint16(windL)
|
||||
result.Complete.Binary = fmt.Sprintf("%09b", rawValue)
|
||||
} else {
|
||||
// 10bit模式:0 + bit136 + bit26 + bit27 + WIND_H + WIND_L
|
||||
extendBits := (uint16(0) << 3) | (uint16(bit136) << 2) | (uint16(bit26) << 1) | uint16(bit27)
|
||||
rawValue = (uint16(extendBits) << 8) | (uint16(windH) << 4) | uint16(windL)
|
||||
result.Complete.Binary = fmt.Sprintf("%010b", rawValue)
|
||||
}
|
||||
|
||||
result.Complete.RawValue = rawValue
|
||||
// 计算实际风速值:value/8*0.51
|
||||
result.Complete.Value = float64(rawValue) / 8.0 * 0.51
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Temperature 存储温度的三个部分
|
||||
type Temperature struct {
|
||||
TmpH struct {
|
||||
Binary string // 3位二进制,格式为"0XXX",其中XXX是bit29-31
|
||||
Value uint8
|
||||
}
|
||||
TmpM struct {
|
||||
Binary string // bit 32-35
|
||||
Value uint8
|
||||
}
|
||||
TmpL struct {
|
||||
Binary string // bit 36-39
|
||||
Value uint8
|
||||
}
|
||||
Complete struct {
|
||||
Binary string // 完整的11位二进制
|
||||
RawValue uint16 // 原始值(包含400的偏移)
|
||||
Value float64 // 实际温度值 (Range: -40.0°C -> 60.0°C)
|
||||
IsValid bool // 是否有效
|
||||
}
|
||||
}
|
||||
|
||||
// GetTemperature 解析温度数据
|
||||
// TMP_H: 4位 (0 + bit29-31)
|
||||
// TMP_M: bit 32-35
|
||||
// TMP_L: bit 36-39
|
||||
// 温度计算公式:(RawValue-400)/10
|
||||
// 示例:
|
||||
// 10.5°C = 0x1F9 (505-400)/10 = 10.5
|
||||
// -10.5°C = 0x127 (295-400)/10 = -10.5
|
||||
// 范围:-40.0°C -> 60.0°C
|
||||
// 无效值:0x7FF
|
||||
func (p *Protocol) GetTemperature() (*Temperature, error) {
|
||||
if len(p.RawData) < 5 { // 确保有足够的数据
|
||||
return nil, fmt.Errorf("insufficient data length")
|
||||
}
|
||||
|
||||
result := &Temperature{}
|
||||
|
||||
// 获取包含bit29-31的字节(索引3)
|
||||
byte3 := p.RawData[3]
|
||||
// 直接获取bit29-31 (010)
|
||||
tmpHBits := byte3 & 0x07
|
||||
// TMP_H 是 "0" + bit29-31
|
||||
result.TmpH.Binary = fmt.Sprintf("0%03b", tmpHBits)
|
||||
result.TmpH.Value = tmpHBits
|
||||
|
||||
// 获取包含bit32-39的字节(索引4)
|
||||
byte4 := p.RawData[4]
|
||||
// 获取TMP_M (bit 32-35),在byte4的高4位
|
||||
tmpM := (byte4 >> 4) & 0x0F
|
||||
result.TmpM.Binary = fmt.Sprintf("%04b", tmpM)
|
||||
result.TmpM.Value = tmpM
|
||||
|
||||
// 获取TMP_L (bit 36-39),在byte4的低4位
|
||||
tmpL := byte4 & 0x0F
|
||||
result.TmpL.Binary = fmt.Sprintf("%04b", tmpL)
|
||||
result.TmpL.Value = tmpL
|
||||
|
||||
// 组合完整的温度值
|
||||
// 1. TMP_H (0 + bit29-31) 放在最高位
|
||||
// 2. TMP_M (bit 32-35) 放在中间
|
||||
// 3. TMP_L (bit 36-39) 放在最低位
|
||||
completeTemp := uint16(0)
|
||||
completeTemp |= uint16(tmpHBits) << 8 // TMP_H 移到高8位
|
||||
completeTemp |= uint16(tmpM) << 4 // TMP_M 移到中间4位
|
||||
completeTemp |= uint16(tmpL) // TMP_L 在最低4位
|
||||
|
||||
result.Complete.Binary = fmt.Sprintf("%012b", completeTemp)
|
||||
result.Complete.RawValue = completeTemp
|
||||
|
||||
// 检查温度是否在有效范围内
|
||||
// 有效范围计算:
|
||||
// -40°C = (-40 * 10 + 400) = 0
|
||||
// 60°C = (60 * 10 + 400) = 1000
|
||||
if completeTemp > 1000 { // 超出范围
|
||||
result.Complete.RawValue = 0x7FF // 无效值
|
||||
result.Complete.Value = 0
|
||||
result.Complete.IsValid = false
|
||||
} else {
|
||||
// 温度计算:(RawValue-400)/10
|
||||
result.Complete.Value = (float64(completeTemp) - 400) / 10
|
||||
result.Complete.IsValid = true
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Humidity 存储湿度的两个部分
|
||||
type Humidity struct {
|
||||
HmH struct {
|
||||
Binary string // bit 40-43
|
||||
Value uint8
|
||||
}
|
||||
HmL struct {
|
||||
Binary string // bit 44-47
|
||||
Value uint8
|
||||
}
|
||||
Complete struct {
|
||||
Binary string // 完整的8位二进制
|
||||
RawValue uint8 // 原始值(十六进制)
|
||||
Value uint8 // 实际湿度值 (Range: 1% - 99%)
|
||||
IsValid bool // 是否有效
|
||||
}
|
||||
}
|
||||
|
||||
// GetHumidity 解析湿度数据
|
||||
// HM_H: bit 40-43
|
||||
// HM_L: bit 44-47
|
||||
// Range: 1% - 99%
|
||||
// If invalid fill with 0xFF
|
||||
// 示例:0x37 = 55% (3*16 + 7 = 55)
|
||||
func (p *Protocol) GetHumidity() (*Humidity, error) {
|
||||
if len(p.RawData) < 6 { // 确保有足够的数据(bit 47 在第6个字节内)
|
||||
return nil, fmt.Errorf("insufficient data length")
|
||||
}
|
||||
|
||||
result := &Humidity{}
|
||||
|
||||
// 获取包含bit40-47的字节(索引5)
|
||||
byte5 := p.RawData[5]
|
||||
|
||||
// 获取HM_H (bit 40-43)
|
||||
hmH := (byte5 >> 4) & 0x0F
|
||||
result.HmH.Binary = fmt.Sprintf("%04b", hmH)
|
||||
result.HmH.Value = hmH
|
||||
|
||||
// 获取HM_L (bit 44-47)
|
||||
hmL := byte5 & 0x0F
|
||||
result.HmL.Binary = fmt.Sprintf("%04b", hmL)
|
||||
result.HmL.Value = hmL
|
||||
|
||||
// 原始十六进制值
|
||||
result.Complete.Binary = fmt.Sprintf("%08b", byte5)
|
||||
result.Complete.RawValue = byte5
|
||||
|
||||
// 直接使用十六进制值转换为十进制作为湿度值
|
||||
decimalValue := byte5
|
||||
|
||||
// 检查湿度是否在有效范围内 (1-99)
|
||||
if decimalValue < 1 || decimalValue > 99 {
|
||||
result.Complete.RawValue = 0xFF // 无效值
|
||||
result.Complete.Value = 0
|
||||
result.Complete.IsValid = false
|
||||
} else {
|
||||
result.Complete.Value = decimalValue
|
||||
result.Complete.IsValid = true
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GustSpeed 存储阵风速度数据
|
||||
type GustSpeed struct {
|
||||
GustH struct {
|
||||
Binary string // bit 56-59
|
||||
Value uint8
|
||||
}
|
||||
GustL struct {
|
||||
Binary string // bit 60-63
|
||||
Value uint8
|
||||
}
|
||||
Complete struct {
|
||||
Binary string // 完整的8位二进制
|
||||
RawValue uint8 // 原始值
|
||||
Value float64 // 实际阵风速度值 (计算公式: RawValue*0.51)
|
||||
}
|
||||
}
|
||||
|
||||
// GetGustSpeed 解析阵风速度数据
|
||||
// GUST_H: bit 56-59
|
||||
// GUST_L: bit 60-63
|
||||
// 实际阵风速度计算公式: value*0.51
|
||||
func (p *Protocol) GetGustSpeed() (*GustSpeed, error) {
|
||||
if len(p.RawData) < 8 { // 确保有足够的数据(bit 63 在第8个字节内)
|
||||
return nil, fmt.Errorf("insufficient data length")
|
||||
}
|
||||
|
||||
result := &GustSpeed{}
|
||||
|
||||
// 解析 GUST_H (bit 56-59) 和 GUST_L (bit 60-63)
|
||||
byte7 := p.RawData[7]
|
||||
gustH := (byte7 >> 4) & 0x0F
|
||||
gustL := byte7 & 0x0F
|
||||
|
||||
result.GustH.Binary = fmt.Sprintf("%04b", gustH)
|
||||
result.GustH.Value = gustH
|
||||
result.GustL.Binary = fmt.Sprintf("%04b", gustL)
|
||||
result.GustL.Value = gustL
|
||||
|
||||
// 组合完整的阵风速度值
|
||||
rawValue := byte7
|
||||
result.Complete.Binary = fmt.Sprintf("%08b", rawValue)
|
||||
result.Complete.RawValue = rawValue
|
||||
|
||||
// 计算实际阵风速度值:value*0.51
|
||||
result.Complete.Value = float64(rawValue) * 0.51
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Rainfall 存储降雨量数据
|
||||
type Rainfall struct {
|
||||
RainHH struct {
|
||||
Binary string // bit 64-67
|
||||
Value uint8
|
||||
}
|
||||
RainHL struct {
|
||||
Binary string // bit 68-71
|
||||
Value uint8
|
||||
}
|
||||
RainLH struct {
|
||||
Binary string // bit 72-75
|
||||
Value uint8
|
||||
}
|
||||
RainLL struct {
|
||||
Binary string // bit 76-79
|
||||
Value uint8
|
||||
}
|
||||
Complete struct {
|
||||
Binary string // 完整的16位二进制
|
||||
RawValue uint16 // 原始值
|
||||
Value float64 // 实际降雨量值 (计算公式: RawValue*0.254)
|
||||
}
|
||||
}
|
||||
|
||||
// GetRainfall 解析降雨量数据
|
||||
// RAIN_HH: bit 64-67
|
||||
// RAIN_HL: bit 68-71
|
||||
// RAIN_LH: bit 72-75
|
||||
// RAIN_LL: bit 76-79
|
||||
// 实际降雨量计算公式: value*0.254
|
||||
func (p *Protocol) GetRainfall() (*Rainfall, error) {
|
||||
if len(p.RawData) < 10 { // 确保有足够的数据(bit 79 在第10个字节内)
|
||||
return nil, fmt.Errorf("insufficient data length")
|
||||
}
|
||||
|
||||
result := &Rainfall{}
|
||||
|
||||
// 解析 RAIN_HH 和 RAIN_HL (byte8)
|
||||
byte8 := p.RawData[8]
|
||||
rainHH := (byte8 >> 4) & 0x0F
|
||||
rainHL := byte8 & 0x0F
|
||||
|
||||
result.RainHH.Binary = fmt.Sprintf("%04b", rainHH)
|
||||
result.RainHH.Value = rainHH
|
||||
result.RainHL.Binary = fmt.Sprintf("%04b", rainHL)
|
||||
result.RainHL.Value = rainHL
|
||||
|
||||
// 解析 RAIN_LH 和 RAIN_LL (byte9)
|
||||
byte9 := p.RawData[9]
|
||||
rainLH := (byte9 >> 4) & 0x0F
|
||||
rainLL := byte9 & 0x0F
|
||||
|
||||
result.RainLH.Binary = fmt.Sprintf("%04b", rainLH)
|
||||
result.RainLH.Value = rainLH
|
||||
result.RainLL.Binary = fmt.Sprintf("%04b", rainLL)
|
||||
result.RainLL.Value = rainLL
|
||||
|
||||
// 组合完整的降雨量值
|
||||
rawValue := (uint16(rainHH) << 12) | (uint16(rainHL) << 8) | (uint16(rainLH) << 4) | uint16(rainLL)
|
||||
result.Complete.Binary = fmt.Sprintf("%016b", rawValue)
|
||||
result.Complete.RawValue = rawValue
|
||||
|
||||
// 计算实际降雨量值:value*0.254
|
||||
result.Complete.Value = float64(rawValue) * 0.254
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UVIndex 存储紫外线指数数据
|
||||
type UVIndex struct {
|
||||
UviHH struct {
|
||||
Binary string // bit 80-83
|
||||
Value uint8
|
||||
}
|
||||
UviHL struct {
|
||||
Binary string // bit 84-87
|
||||
Value uint8
|
||||
}
|
||||
UviLH struct {
|
||||
Binary string // bit 88-91
|
||||
Value uint8
|
||||
}
|
||||
UviLL struct {
|
||||
Binary string // bit 92-95
|
||||
Value uint8
|
||||
}
|
||||
Complete struct {
|
||||
Binary string // 完整的16位二进制
|
||||
RawValue uint16 // 原始值
|
||||
Value float64 // 实际紫外线值 (单位: uW/c㎡)
|
||||
IsValid bool // 是否有效
|
||||
}
|
||||
}
|
||||
|
||||
// GetUVIndex 解析紫外线指数数据
|
||||
// UVI_HH: bit 80-83
|
||||
// UVI_HL: bit 84-87
|
||||
// UVI_LH: bit 88-91
|
||||
// UVI_LL: bit 92-95
|
||||
// Range: 0 uW/c㎡ to 20000 uW/c㎡
|
||||
// If invalid fill with 0xFFFF
|
||||
func (p *Protocol) GetUVIndex() (*UVIndex, error) {
|
||||
if len(p.RawData) < 12 { // 确保有足够的数据(bit 95 在第12个字节内)
|
||||
return nil, fmt.Errorf("insufficient data length")
|
||||
}
|
||||
|
||||
result := &UVIndex{}
|
||||
|
||||
// 解析 UVI_HH 和 UVI_HL (byte10)
|
||||
byte10 := p.RawData[10]
|
||||
uviHH := (byte10 >> 4) & 0x0F
|
||||
uviHL := byte10 & 0x0F
|
||||
|
||||
result.UviHH.Binary = fmt.Sprintf("%04b", uviHH)
|
||||
result.UviHH.Value = uviHH
|
||||
result.UviHL.Binary = fmt.Sprintf("%04b", uviHL)
|
||||
result.UviHL.Value = uviHL
|
||||
|
||||
// 解析 UVI_LH 和 UVI_LL (byte11)
|
||||
byte11 := p.RawData[11]
|
||||
uviLH := (byte11 >> 4) & 0x0F
|
||||
uviLL := byte11 & 0x0F
|
||||
|
||||
result.UviLH.Binary = fmt.Sprintf("%04b", uviLH)
|
||||
result.UviLH.Value = uviLH
|
||||
result.UviLL.Binary = fmt.Sprintf("%04b", uviLL)
|
||||
result.UviLL.Value = uviLL
|
||||
|
||||
// 组合完整的紫外线值
|
||||
rawValue := (uint16(uviHH) << 12) | (uint16(uviHL) << 8) | (uint16(uviLH) << 4) | uint16(uviLL)
|
||||
result.Complete.Binary = fmt.Sprintf("%016b", rawValue)
|
||||
result.Complete.RawValue = rawValue
|
||||
|
||||
// 检查是否在有效范围内 (0-20000)
|
||||
if rawValue > 20000 {
|
||||
result.Complete.RawValue = 0xFFFF
|
||||
result.Complete.Value = 0
|
||||
result.Complete.IsValid = false
|
||||
} else {
|
||||
result.Complete.Value = float64(rawValue)
|
||||
result.Complete.IsValid = true
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Light 存储光照数据
|
||||
type Light struct {
|
||||
LightHH struct {
|
||||
Binary string // bit 96-99
|
||||
Value uint8
|
||||
}
|
||||
LightHL struct {
|
||||
Binary string // bit 100-103
|
||||
Value uint8
|
||||
}
|
||||
LightMH struct {
|
||||
Binary string // bit 104-107
|
||||
Value uint8
|
||||
}
|
||||
LightML struct {
|
||||
Binary string // bit 108-111
|
||||
Value uint8
|
||||
}
|
||||
LightLH struct {
|
||||
Binary string // bit 112-115
|
||||
Value uint8
|
||||
}
|
||||
LightLL struct {
|
||||
Binary string // bit 116-119
|
||||
Value uint8
|
||||
}
|
||||
Complete struct {
|
||||
Binary string // 完整的24位二进制
|
||||
RawValue uint32 // 原始值
|
||||
Value float64 // 实际光照值 (计算公式: RawValue/10) (单位: lux)
|
||||
IsValid bool // 是否有效
|
||||
}
|
||||
}
|
||||
|
||||
// GetLight 解析光照数据
|
||||
// bit 96-119 (byte12-14: 00 04 9C)
|
||||
// 实际光照计算公式: value/10
|
||||
// Range: 0.0 lux -> 300,000.0 lux
|
||||
// If invalid fill with 0xFFFFFF
|
||||
func (p *Protocol) GetLight() (*Light, error) {
|
||||
if len(p.RawData) < 15 { // 确保有足够的数据
|
||||
return nil, fmt.Errorf("insufficient data length")
|
||||
}
|
||||
|
||||
result := &Light{}
|
||||
|
||||
// 获取三个字节 (00 04 9C)
|
||||
byte1 := p.RawData[12] // 00
|
||||
byte2 := p.RawData[13] // 04
|
||||
byte3 := p.RawData[14] // 9C
|
||||
|
||||
// 组合完整的光照值 (00 04 9C)
|
||||
rawValue := (uint32(byte1) << 16) | (uint32(byte2) << 8) | uint32(byte3)
|
||||
result.Complete.Binary = fmt.Sprintf("%024b", rawValue)
|
||||
result.Complete.RawValue = rawValue
|
||||
|
||||
// 检查是否在有效范围内 (0-3000000, 因为实际值要除以10)
|
||||
if rawValue > 3000000 {
|
||||
result.Complete.RawValue = 0xFFFFFF
|
||||
result.Complete.Value = 0
|
||||
result.Complete.IsValid = false
|
||||
} else {
|
||||
result.Complete.Value = float64(rawValue) / 10.0
|
||||
result.Complete.IsValid = true
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Pressure 存储大气压数据
|
||||
type Pressure struct {
|
||||
PressureH struct {
|
||||
Binary string // bit 143-144 (补前导000)
|
||||
Value uint8
|
||||
}
|
||||
PressureM struct {
|
||||
Binary string // bit 145-151
|
||||
Value uint8
|
||||
}
|
||||
PressureL struct {
|
||||
Binary string // bit 152-159
|
||||
Value uint8
|
||||
}
|
||||
Complete struct {
|
||||
Binary string // 完整的17位二进制
|
||||
RawValue uint32 // 原始值
|
||||
Value float64 // 实际气压值 (计算公式: RawValue/100) (单位: hPa)
|
||||
IsValid bool // 是否有效
|
||||
}
|
||||
}
|
||||
|
||||
// GetPressure 解析大气压数据
|
||||
// 17位值组成:000 + bit143 + bit144-159
|
||||
// 实际气压计算公式: value/100
|
||||
// Range: 300.00 hPa -> 1200.00 hPa
|
||||
// If invalid fill with 0x1FFFF
|
||||
// Example: 0x018A9E = 1010.22 hPa
|
||||
func (p *Protocol) GetPressure() (*Pressure, error) {
|
||||
if len(p.RawData) < 20 { // 确保有足够的数据
|
||||
return nil, fmt.Errorf("insufficient data length")
|
||||
}
|
||||
|
||||
result := &Pressure{}
|
||||
|
||||
// 获取三个字节 (01 88 F5)
|
||||
byte1 := p.RawData[17] // 01
|
||||
byte2 := p.RawData[18] // 88
|
||||
byte3 := p.RawData[19] // F5
|
||||
|
||||
// 组合完整的气压值
|
||||
rawValue := (uint32(byte1) << 16) | (uint32(byte2) << 8) | uint32(byte3)
|
||||
result.Complete.Binary = fmt.Sprintf("%024b", rawValue)
|
||||
result.Complete.RawValue = rawValue
|
||||
|
||||
// 检查是否在有效范围内 (30000-120000,因为实际值要除以100)
|
||||
if rawValue < 30000 || rawValue > 120000 {
|
||||
result.Complete.RawValue = 0x1FFFF
|
||||
result.Complete.Value = 0
|
||||
result.Complete.IsValid = false
|
||||
} else {
|
||||
result.Complete.Value = float64(rawValue) / 100.0
|
||||
result.Complete.IsValid = true
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RS485Protocol 定义RS485协议结构
|
||||
type RS485Protocol struct {
|
||||
RawData []byte
|
||||
}
|
||||
|
||||
// NewRS485Protocol 创建新的RS485协议实例
|
||||
func NewRS485Protocol(data []byte) *RS485Protocol {
|
||||
return &RS485Protocol{
|
||||
RawData: data,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateRS485Data 验证RS485数据是否有效
|
||||
func ValidateRS485Data(data []byte) bool {
|
||||
// 检查数据长度是否为25字节
|
||||
if len(data) != 25 {
|
||||
return false
|
||||
}
|
||||
// 检查起始字节是否为0x24
|
||||
if data[0] != 0x24 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// RS485WeatherData 存储RS485气象数据
|
||||
type RS485WeatherData struct {
|
||||
Temperature float64 // 温度
|
||||
Humidity float64 // 湿度
|
||||
WindSpeed float64 // 风速
|
||||
WindDirection float64 // 风向
|
||||
Rainfall float64 // 雨量
|
||||
Light float64 // 光照
|
||||
UV float64 // 紫外线
|
||||
Pressure float64 // 气压
|
||||
DeviceID string // 设备ID
|
||||
ReceivedAt time.Time // 接收时间
|
||||
RawDataHex string // 原始数据十六进制
|
||||
}
|
||||
|
||||
// ParseRS485Data 解析RS485数据 - 使用Protocol标准方法
|
||||
func (p *RS485Protocol) ParseRS485Data() (*RS485WeatherData, error) {
|
||||
if !ValidateRS485Data(p.RawData) {
|
||||
return nil, fmt.Errorf("无效的RS485数据格式")
|
||||
}
|
||||
|
||||
// 创建标准Protocol来解析数据
|
||||
protocol := NewProtocol(p.RawData)
|
||||
data := &RS485WeatherData{}
|
||||
|
||||
// 使用Protocol标准方法解析温度
|
||||
if temp, err := protocol.GetTemperature(); err == nil {
|
||||
data.Temperature = temp.Complete.Value
|
||||
}
|
||||
|
||||
// 使用Protocol标准方法解析湿度
|
||||
if humidity, err := protocol.GetHumidity(); err == nil {
|
||||
data.Humidity = float64(humidity.Complete.Value)
|
||||
}
|
||||
|
||||
// 使用Protocol标准方法解析风速
|
||||
if windSpeed, err := protocol.GetWindSpeed(); err == nil {
|
||||
data.WindSpeed = windSpeed.Complete.Value
|
||||
}
|
||||
|
||||
// 使用Protocol标准方法解析风向
|
||||
if windDir, err := protocol.GetWindDirection(); err == nil {
|
||||
data.WindDirection = windDir.Complete.Degree
|
||||
}
|
||||
|
||||
// 使用Protocol标准方法解析降雨量
|
||||
if rainfall, err := protocol.GetRainfall(); err == nil {
|
||||
data.Rainfall = rainfall.Complete.Value
|
||||
}
|
||||
|
||||
// 使用Protocol标准方法解析光照
|
||||
if light, err := protocol.GetLight(); err == nil {
|
||||
data.Light = light.Complete.Value
|
||||
}
|
||||
|
||||
// 使用Protocol标准方法解析UV指数
|
||||
if uv, err := protocol.GetUVIndex(); err == nil {
|
||||
data.UV = uv.Complete.Value
|
||||
}
|
||||
|
||||
// 使用Protocol标准方法解析气压
|
||||
if pressure, err := protocol.GetPressure(); err == nil {
|
||||
data.Pressure = pressure.Complete.Value
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// String 返回RS485WeatherData的字符串表示
|
||||
func (w *RS485WeatherData) String() string {
|
||||
return fmt.Sprintf(`
|
||||
设备ID: RS485-%s
|
||||
温度: %.1f°C
|
||||
湿度: %.1f%%
|
||||
风向: %.1f°
|
||||
风速: %.2f m/s
|
||||
降雨量: %.2f mm
|
||||
光照: %.2f lux
|
||||
紫外线: %.2f
|
||||
气压: %.2f hPa
|
||||
时间: %s`,
|
||||
w.DeviceID,
|
||||
w.Temperature,
|
||||
w.Humidity,
|
||||
w.WindDirection,
|
||||
w.WindSpeed,
|
||||
w.Rainfall,
|
||||
w.Light,
|
||||
w.UV,
|
||||
w.Pressure,
|
||||
w.ReceivedAt.Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
}
|
||||
815
model/protocol_test.go
Normal file
815
model/protocol_test.go
Normal file
@ -0,0 +1,815 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIdentifyTxType(t *testing.T) {
|
||||
// 测试数据:24 36 F3 02 96 37 06 01 00 04 00 00 00 04 9C D3 9A 01 88 F5 7E 00 2A 9C F6
|
||||
data := []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37, 0x06, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x9C, 0xD3, 0x9A, 0x01, 0x88, 0xF5, 0x7E, 0x00, 0x2A, 0x9C, 0xF6}
|
||||
|
||||
protocol := NewProtocol(data)
|
||||
binary, hex, dec := protocol.IdentifyTxType()
|
||||
|
||||
// 预期结果
|
||||
expectedBinary := "00100100" // 24 的二进制表示
|
||||
expectedHex := "24" // 第一个字节的十六进制表示
|
||||
expectedDec := uint8(36) // 24 的十进制表示
|
||||
|
||||
// 验证二进制结果
|
||||
if binary != expectedBinary {
|
||||
t.Errorf("Binary representation incorrect. Got %s, want %s", binary, expectedBinary)
|
||||
}
|
||||
|
||||
// 验证十六进制结果
|
||||
if hex != expectedHex {
|
||||
t.Errorf("Hex representation incorrect. Got %s, want %s", hex, expectedHex)
|
||||
}
|
||||
|
||||
// 验证十进制结果
|
||||
if dec != expectedDec {
|
||||
t.Errorf("Decimal representation incorrect. Got %d, want %d", dec, expectedDec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCompleteID(t *testing.T) {
|
||||
// 测试数据:24 36 F3 02 96 37 06 01 00 04 00 00 00 04 9C D3 9A 01 88 F5 7E 00 2A 9C F6
|
||||
data := []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37, 0x06, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x9C, 0xD3, 0x9A, 0x01, 0x88, 0xF5, 0x7E, 0x00, 0x2A, 0x9C, 0xF6}
|
||||
|
||||
protocol := NewProtocol(data)
|
||||
result, err := protocol.GetCompleteID()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// 测试 HSB (应该是 00)
|
||||
expectedHSBBinary := "00000000" // 00 的二进制
|
||||
expectedHSBHex := "00" // 00 的十六进制
|
||||
expectedHSBDec := uint8(0) // 00 的十进制
|
||||
|
||||
if result.HSB.Binary != expectedHSBBinary {
|
||||
t.Errorf("HSB Binary incorrect. Got %s, want %s", result.HSB.Binary, expectedHSBBinary)
|
||||
}
|
||||
if result.HSB.Hex != expectedHSBHex {
|
||||
t.Errorf("HSB Hex incorrect. Got %s, want %s", result.HSB.Hex, expectedHSBHex)
|
||||
}
|
||||
if result.HSB.Dec != expectedHSBDec {
|
||||
t.Errorf("HSB Dec incorrect. Got %d, want %d", result.HSB.Dec, expectedHSBDec)
|
||||
}
|
||||
|
||||
// 测试 MSB (应该是 2A)
|
||||
expectedMSBBinary := "00101010" // 2A 的二进制
|
||||
expectedMSBHex := "2A" // 2A 的十六进制
|
||||
expectedMSBDec := uint8(42) // 2A 的十进制
|
||||
|
||||
if result.MSB.Binary != expectedMSBBinary {
|
||||
t.Errorf("MSB Binary incorrect. Got %s, want %s", result.MSB.Binary, expectedMSBBinary)
|
||||
}
|
||||
if result.MSB.Hex != expectedMSBHex {
|
||||
t.Errorf("MSB Hex incorrect. Got %s, want %s", result.MSB.Hex, expectedMSBHex)
|
||||
}
|
||||
if result.MSB.Dec != expectedMSBDec {
|
||||
t.Errorf("MSB Dec incorrect. Got %d, want %d", result.MSB.Dec, expectedMSBDec)
|
||||
}
|
||||
|
||||
// 测试 LSB (应该是 36)
|
||||
expectedLSBBinary := "00110110" // 36 的二进制
|
||||
expectedLSBHex := "36" // 36 的十六进制
|
||||
expectedLSBDec := uint8(54) // 36 的十进制
|
||||
|
||||
if result.LSB.Binary != expectedLSBBinary {
|
||||
t.Errorf("LSB Binary incorrect. Got %s, want %s", result.LSB.Binary, expectedLSBBinary)
|
||||
}
|
||||
if result.LSB.Hex != expectedLSBHex {
|
||||
t.Errorf("LSB Hex incorrect. Got %s, want %s", result.LSB.Hex, expectedLSBHex)
|
||||
}
|
||||
if result.LSB.Dec != expectedLSBDec {
|
||||
t.Errorf("LSB Dec incorrect. Got %d, want %d", result.LSB.Dec, expectedLSBDec)
|
||||
}
|
||||
|
||||
// 测试完整的24位ID (应该是 00 2A 36)
|
||||
expectedCompleteBinary := "000000000010101000110110" // 完整24位二进制
|
||||
expectedCompleteHex := "002A36" // 完整24位十六进制
|
||||
expectedCompleteDec := uint32(0x002A36) // 完整24位十进制
|
||||
|
||||
if result.Complete.Binary != expectedCompleteBinary {
|
||||
t.Errorf("Complete Binary incorrect. Got %s, want %s", result.Complete.Binary, expectedCompleteBinary)
|
||||
}
|
||||
if result.Complete.Hex != expectedCompleteHex {
|
||||
t.Errorf("Complete Hex incorrect. Got %s, want %s", result.Complete.Hex, expectedCompleteHex)
|
||||
}
|
||||
if result.Complete.Dec != expectedCompleteDec {
|
||||
t.Errorf("Complete Dec incorrect. Got %d, want %d", result.Complete.Dec, expectedCompleteDec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWindDirection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
expectedValue uint16
|
||||
expectedValid bool
|
||||
expectedDegree float64
|
||||
}{
|
||||
{
|
||||
name: "Valid Direction",
|
||||
data: []byte{0x24, 0x36, 0xF3, 0x02, 0x96}, // 原始数据,方向值为243
|
||||
expectedValue: 0xF3,
|
||||
expectedValid: true,
|
||||
expectedDegree: 243,
|
||||
},
|
||||
{
|
||||
name: "Invalid Direction",
|
||||
data: []byte{0x24, 0x36, 0xFF, 0x03, 0x96}, // 设置一个大于359的值
|
||||
expectedValue: 0x1FF,
|
||||
expectedValid: false,
|
||||
expectedDegree: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
protocol := NewProtocol(tt.data)
|
||||
result, err := protocol.GetWindDirection()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Complete.Value != tt.expectedValue {
|
||||
t.Errorf("Value incorrect. Got %X, want %X", result.Complete.Value, tt.expectedValue)
|
||||
}
|
||||
|
||||
if result.Complete.IsValid != tt.expectedValid {
|
||||
t.Errorf("IsValid incorrect. Got %v, want %v", result.Complete.IsValid, tt.expectedValid)
|
||||
}
|
||||
|
||||
if result.Complete.Degree != tt.expectedDegree {
|
||||
t.Errorf("Degree incorrect. Got %v, want %v", result.Complete.Degree, tt.expectedDegree)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTemperature(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
expectedRaw uint16
|
||||
expectedTemp float64
|
||||
expectedValid bool
|
||||
}{
|
||||
{
|
||||
name: "Temperature 10.5°C",
|
||||
// 0x1F9 = 505 -> (505-400)/10 = 10.5
|
||||
// TMP_H = 1 (001)
|
||||
// TMP_M = F (1111)
|
||||
// TMP_L = 9 (1001)
|
||||
data: []byte{0x24, 0x36, 0xF3, 0x20, 0xF9}, // 设置byte3的bit29-31为001,byte4为0xF9
|
||||
expectedRaw: 0x1F9,
|
||||
expectedTemp: 10.5,
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Temperature -10.5°C",
|
||||
// 0x127 = 295 -> (295-400)/10 = -10.5
|
||||
// TMP_H = 1 (001)
|
||||
// TMP_M = 2 (0010)
|
||||
// TMP_L = 7 (0111)
|
||||
data: []byte{0x24, 0x36, 0xF3, 0x20, 0x27}, // 设置byte3的bit29-31为001,byte4为0x27
|
||||
expectedRaw: 0x127,
|
||||
expectedTemp: -10.5,
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid Temperature",
|
||||
// 0x7FF = 2047 (超出范围)
|
||||
// TMP_H = 7 (111)
|
||||
// TMP_M = F (1111)
|
||||
// TMP_L = F (1111)
|
||||
data: []byte{0x24, 0x36, 0xF3, 0xE0, 0xFF}, // 设置byte3的bit29-31为111,byte4为0xFF
|
||||
expectedRaw: 0x7FF,
|
||||
expectedTemp: 0,
|
||||
expectedValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
protocol := NewProtocol(tt.data)
|
||||
result, err := protocol.GetTemperature()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Complete.RawValue != tt.expectedRaw {
|
||||
t.Errorf("Raw value incorrect. Got %X, want %X", result.Complete.RawValue, tt.expectedRaw)
|
||||
}
|
||||
|
||||
if result.Complete.Value != tt.expectedTemp {
|
||||
t.Errorf("Temperature value incorrect. Got %.1f, want %.1f", result.Complete.Value, tt.expectedTemp)
|
||||
}
|
||||
|
||||
if result.Complete.IsValid != tt.expectedValid {
|
||||
t.Errorf("Validity incorrect. Got %v, want %v", result.Complete.IsValid, tt.expectedValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHumidity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
expectedRaw uint8
|
||||
expectedValue uint8
|
||||
expectedValid bool
|
||||
}{
|
||||
{
|
||||
name: "Valid Humidity 55%",
|
||||
// 0x37 = 3*16 + 7 = 55%
|
||||
data: []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37},
|
||||
expectedRaw: 0x37,
|
||||
expectedValue: 55,
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Valid Humidity 1%",
|
||||
// 0x01 = 0*16 + 1 = 1%
|
||||
data: []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x01},
|
||||
expectedRaw: 0x01,
|
||||
expectedValue: 1,
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Valid Humidity 99%",
|
||||
// 0x63 = 6*16 + 3 = 99%
|
||||
data: []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x63},
|
||||
expectedRaw: 0x63,
|
||||
expectedValue: 99,
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid Humidity (Too High)",
|
||||
// 0x64 = 6*16 + 4 = 100% (无效)
|
||||
data: []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x64},
|
||||
expectedRaw: 0xFF,
|
||||
expectedValue: 0,
|
||||
expectedValid: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid Humidity (Zero)",
|
||||
// 0x00 = 0% (无效)
|
||||
data: []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x00},
|
||||
expectedRaw: 0xFF,
|
||||
expectedValue: 0,
|
||||
expectedValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
protocol := NewProtocol(tt.data)
|
||||
result, err := protocol.GetHumidity()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Complete.RawValue != tt.expectedRaw {
|
||||
t.Errorf("Raw value incorrect. Got %X, want %X", result.Complete.RawValue, tt.expectedRaw)
|
||||
}
|
||||
|
||||
if result.Complete.Value != tt.expectedValue {
|
||||
t.Errorf("Humidity value incorrect. Got %d%%, want %d%%", result.Complete.Value, tt.expectedValue)
|
||||
}
|
||||
|
||||
if result.Complete.IsValid != tt.expectedValid {
|
||||
t.Errorf("Validity incorrect. Got %v, want %v", result.Complete.IsValid, tt.expectedValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWindSpeed(t *testing.T) {
|
||||
// 测试数据:24 36 F3 02 96 37 06 01 00 04 00 00 00 04 9C D3 9A 01 88 F5 7E 00 2A 9C F6
|
||||
data := []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37, 0x06, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x9C, 0xD3, 0x9A, 0x01, 0x88, 0xF5, 0x7E, 0x00, 0x2A, 0x9C, 0xF6}
|
||||
|
||||
protocol := NewProtocol(data)
|
||||
result, err := protocol.GetWindSpeed()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// 验证 WSP_FLAG (bit 25)
|
||||
if !result.WspFlag.Value {
|
||||
t.Error("WSP_FLAG should be 1")
|
||||
}
|
||||
|
||||
// 验证 WIND_H 和 WIND_L
|
||||
if result.WspH.Value != 0x0 {
|
||||
t.Errorf("WIND_H incorrect. Got %X, want 0", result.WspH.Value)
|
||||
}
|
||||
if result.WspL.Value != 0x6 {
|
||||
t.Errorf("WIND_L incorrect. Got %X, want 6", result.WspL.Value)
|
||||
}
|
||||
|
||||
// 验证完整值(9bit模式:000 + bit27 + WIND_H + WIND_L)
|
||||
expectedRaw := uint16(0x006)
|
||||
if result.Complete.RawValue != expectedRaw {
|
||||
t.Errorf("Raw value incorrect. Got %X, want %X", result.Complete.RawValue, expectedRaw)
|
||||
}
|
||||
|
||||
// 验证实际风速值:6/8*0.51 = 0.38250 m/s
|
||||
expectedValue := float64(0x006) / 8.0 * 0.51
|
||||
if result.Complete.Value != expectedValue {
|
||||
t.Errorf("Wind speed value incorrect. Got %.5f m/s, want %.5f m/s",
|
||||
result.Complete.Value, expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGustSpeed(t *testing.T) {
|
||||
// 测试数据:24 36 F3 02 96 37 06 01 00 04 00 00 00 04 9C D3 9A 01 88 F5 7E 00 2A 9C F6
|
||||
data := []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37, 0x06, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x9C, 0xD3, 0x9A, 0x01, 0x88, 0xF5, 0x7E, 0x00, 0x2A, 0x9C, 0xF6}
|
||||
|
||||
protocol := NewProtocol(data)
|
||||
result, err := protocol.GetGustSpeed()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// 验证 GUST_H (0x0)
|
||||
expectedGustHBinary := "0000"
|
||||
if result.GustH.Binary != expectedGustHBinary {
|
||||
t.Errorf("GUST_H Binary incorrect. Got %s, want %s", result.GustH.Binary, expectedGustHBinary)
|
||||
}
|
||||
if result.GustH.Value != 0x0 {
|
||||
t.Errorf("GUST_H Value incorrect. Got %X, want 0", result.GustH.Value)
|
||||
}
|
||||
|
||||
// 验证 GUST_L (0x1)
|
||||
expectedGustLBinary := "0001"
|
||||
if result.GustL.Binary != expectedGustLBinary {
|
||||
t.Errorf("GUST_L Binary incorrect. Got %s, want %s", result.GustL.Binary, expectedGustLBinary)
|
||||
}
|
||||
if result.GustL.Value != 0x1 {
|
||||
t.Errorf("GUST_L Value incorrect. Got %X, want 1", result.GustL.Value)
|
||||
}
|
||||
|
||||
// 验证完整值
|
||||
expectedBinary := "00000001" // 0x01
|
||||
if result.Complete.Binary != expectedBinary {
|
||||
t.Errorf("Complete Binary incorrect. Got %s, want %s", result.Complete.Binary, expectedBinary)
|
||||
}
|
||||
if result.Complete.RawValue != 0x01 {
|
||||
t.Errorf("Raw value incorrect. Got %X, want 01", result.Complete.RawValue)
|
||||
}
|
||||
|
||||
// 验证实际阵风速度值:0x01 = 1, 1*0.51 = 0.51 m/s
|
||||
expectedValue := float64(0x01) * 0.51
|
||||
if result.Complete.Value != expectedValue {
|
||||
t.Errorf("Gust speed value incorrect. Got %.5f m/s, want %.5f m/s", result.Complete.Value, expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRainfall(t *testing.T) {
|
||||
// 测试数据:24 36 F3 02 96 37 06 01 00 04 00 00 00 04 9C D3 9A 01 88 F5 7E 00 2A 9C F6
|
||||
data := []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37, 0x06, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x9C, 0xD3, 0x9A, 0x01, 0x88, 0xF5, 0x7E, 0x00, 0x2A, 0x9C, 0xF6}
|
||||
|
||||
protocol := NewProtocol(data)
|
||||
result, err := protocol.GetRainfall()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// 验证 RAIN_HH (0x0)
|
||||
expectedRainHHBinary := "0000"
|
||||
if result.RainHH.Binary != expectedRainHHBinary {
|
||||
t.Errorf("RAIN_HH Binary incorrect. Got %s, want %s", result.RainHH.Binary, expectedRainHHBinary)
|
||||
}
|
||||
if result.RainHH.Value != 0x0 {
|
||||
t.Errorf("RAIN_HH Value incorrect. Got %X, want 0", result.RainHH.Value)
|
||||
}
|
||||
|
||||
// 验证 RAIN_HL (0x0)
|
||||
expectedRainHLBinary := "0000"
|
||||
if result.RainHL.Binary != expectedRainHLBinary {
|
||||
t.Errorf("RAIN_HL Binary incorrect. Got %s, want %s", result.RainHL.Binary, expectedRainHLBinary)
|
||||
}
|
||||
if result.RainHL.Value != 0x0 {
|
||||
t.Errorf("RAIN_HL Value incorrect. Got %X, want 0", result.RainHL.Value)
|
||||
}
|
||||
|
||||
// 验证 RAIN_LH (0x0)
|
||||
expectedRainLHBinary := "0000"
|
||||
if result.RainLH.Binary != expectedRainLHBinary {
|
||||
t.Errorf("RAIN_LH Binary incorrect. Got %s, want %s", result.RainLH.Binary, expectedRainLHBinary)
|
||||
}
|
||||
if result.RainLH.Value != 0x0 {
|
||||
t.Errorf("RAIN_LH Value incorrect. Got %X, want 0", result.RainLH.Value)
|
||||
}
|
||||
|
||||
// 验证 RAIN_LL (0x4)
|
||||
expectedRainLLBinary := "0100"
|
||||
if result.RainLL.Binary != expectedRainLLBinary {
|
||||
t.Errorf("RAIN_LL Binary incorrect. Got %s, want %s", result.RainLL.Binary, expectedRainLLBinary)
|
||||
}
|
||||
if result.RainLL.Value != 0x4 {
|
||||
t.Errorf("RAIN_LL Value incorrect. Got %X, want 4", result.RainLL.Value)
|
||||
}
|
||||
|
||||
// 验证完整值
|
||||
expectedBinary := "0000000000000100" // 0x0004
|
||||
if result.Complete.Binary != expectedBinary {
|
||||
t.Errorf("Complete Binary incorrect. Got %s, want %s", result.Complete.Binary, expectedBinary)
|
||||
}
|
||||
if result.Complete.RawValue != 0x0004 {
|
||||
t.Errorf("Raw value incorrect. Got %X, want 0004", result.Complete.RawValue)
|
||||
}
|
||||
|
||||
// 验证实际降雨量值:0x0004 = 4, 4*0.254 = 1.016 mm
|
||||
expectedValue := float64(0x0004) * 0.254
|
||||
if result.Complete.Value != expectedValue {
|
||||
t.Errorf("Rainfall value incorrect. Got %.3f mm, want %.3f mm", result.Complete.Value, expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUVIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
expectedRaw uint16
|
||||
expectedValue float64
|
||||
expectedValid bool
|
||||
}{
|
||||
{
|
||||
name: "Valid UV Index",
|
||||
// 测试数据:24 36 F3 02 96 37 06 01 00 04 00 00 00 04 9C D3 9A 01 88 F5 7E 00 2A 9C F6
|
||||
data: []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37, 0x06, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x9C, 0xD3, 0x9A, 0x01, 0x88, 0xF5, 0x7E, 0x00, 0x2A, 0x9C, 0xF6},
|
||||
expectedRaw: 0x0000, // byte10=0x00, byte11=0x00
|
||||
expectedValue: 0, // 0 uW/c㎡
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid UV Index (Too High)",
|
||||
data: []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37, 0x06, 0x01, 0x00, 0x04, 0xFF, 0xFF}, // 设置一个超出范围的值
|
||||
expectedRaw: 0xFFFF, // 无效值
|
||||
expectedValue: 0, // 无效时返回0
|
||||
expectedValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
protocol := NewProtocol(tt.data)
|
||||
result, err := protocol.GetUVIndex()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Complete.RawValue != tt.expectedRaw {
|
||||
t.Errorf("Raw value incorrect. Got %X, want %X", result.Complete.RawValue, tt.expectedRaw)
|
||||
}
|
||||
|
||||
if result.Complete.Value != tt.expectedValue {
|
||||
t.Errorf("UV Index value incorrect. Got %.1f uW/c㎡, want %.1f uW/c㎡",
|
||||
result.Complete.Value, tt.expectedValue)
|
||||
}
|
||||
|
||||
if result.Complete.IsValid != tt.expectedValid {
|
||||
t.Errorf("Validity incorrect. Got %v, want %v", result.Complete.IsValid, tt.expectedValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLight(t *testing.T) {
|
||||
// 测试数据:24 36 F3 02 96 37 06 01 00 04 00 00 00 04 9C D3 9A 01 88 F5 7E 00 2A 9C F6
|
||||
data := []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37, 0x06, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x9C, 0xD3, 0x9A, 0x01, 0x88, 0xF5, 0x7E, 0x00, 0x2A, 0x9C, 0xF6}
|
||||
|
||||
protocol := NewProtocol(data)
|
||||
result, err := protocol.GetLight()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// 验证原始值 (byte13-15: 00 04 9C)
|
||||
expectedRaw := uint32(0x00049C)
|
||||
if result.Complete.RawValue != expectedRaw {
|
||||
t.Errorf("Raw value incorrect. Got %X, want %X", result.Complete.RawValue, expectedRaw)
|
||||
}
|
||||
|
||||
// 验证实际光照值:0x00049C = 1180, 1180/10 = 118.0 lux
|
||||
expectedValue := float64(0x00049C) / 10.0
|
||||
if result.Complete.Value != expectedValue {
|
||||
t.Errorf("Light value incorrect. Got %.1f lux, want %.1f lux",
|
||||
result.Complete.Value, expectedValue)
|
||||
}
|
||||
|
||||
if !result.Complete.IsValid {
|
||||
t.Error("Light value should be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPressure(t *testing.T) {
|
||||
// 测试数据:24 36 F3 02 96 37 06 01 00 04 00 00 00 04 9C D3 9A 01 88 F5 7E 00 2A 9C F6
|
||||
data := []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37, 0x06, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x9C, 0xD3, 0x9A, 0x01, 0x88, 0xF5, 0x7E, 0x00, 0x2A, 0x9C, 0xF6}
|
||||
|
||||
protocol := NewProtocol(data)
|
||||
result, err := protocol.GetPressure()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// 验证原始值 (01 88 F5)
|
||||
expectedRaw := uint32(0x0188F5)
|
||||
if result.Complete.RawValue != expectedRaw {
|
||||
t.Errorf("Raw value incorrect. Got %X, want %X", result.Complete.RawValue, expectedRaw)
|
||||
}
|
||||
|
||||
// 验证实际气压值:0x0188F5 = 100597, 100597/100 = 1005.97 hPa
|
||||
expectedValue := float64(0x0188F5) / 100.0
|
||||
if result.Complete.Value != expectedValue {
|
||||
t.Errorf("Pressure value incorrect. Got %.2f hPa, want %.2f hPa",
|
||||
result.Complete.Value, expectedValue)
|
||||
}
|
||||
|
||||
if !result.Complete.IsValid {
|
||||
t.Error("Pressure value should be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNewData(t *testing.T) {
|
||||
// 新的测试数据:24 F2 30 02 AF 51 03 01 00 08 00 00 00 00 00 6E C2 01 82 D8 5B 00 29 87 EA
|
||||
//data := []byte{0x24, 0xF2, 0x30, 0x02, 0xAF, 0x51, 0x03, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6E, 0xC2, 0x01, 0x82, 0xD8, 0x5B, 0x00, 0x29, 0x87, 0xEA}
|
||||
data := []byte{0x24, 0xF2, 0x10, 0x02, 0xC7, 0x48, 0x10, 0x03, 0x00, 0x6A, 0x03, 0xE8, 0x05, 0xF5, 0x96, 0x10, 0x3F, 0x01, 0x83, 0x2D, 0xB1, 0x00, 0x29, 0x9B, 0xA4}
|
||||
protocol := NewProtocol(data)
|
||||
|
||||
// 1. 解析风速
|
||||
windSpeed, err := protocol.GetWindSpeed()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse wind speed: %v", err)
|
||||
}
|
||||
t.Logf("Wind Speed: %.5f m/s (raw: 0x%X)", windSpeed.Complete.Value, windSpeed.Complete.RawValue)
|
||||
|
||||
// 2. 解析阵风速度
|
||||
gustSpeed, err := protocol.GetGustSpeed()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse gust speed: %v", err)
|
||||
}
|
||||
t.Logf("Gust Speed: %.5f m/s (raw: 0x%X)", gustSpeed.Complete.Value, gustSpeed.Complete.RawValue)
|
||||
|
||||
// 3. 解析温度
|
||||
temp, err := protocol.GetTemperature()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse temperature: %v", err)
|
||||
}
|
||||
t.Logf("Temperature: %.2f °C (raw: 0x%X)", temp.Complete.Value, temp.Complete.RawValue)
|
||||
|
||||
// 4. 解析湿度
|
||||
humidity, err := protocol.GetHumidity()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse humidity: %v", err)
|
||||
}
|
||||
t.Logf("Humidity: %d%% (raw: 0x%X)", humidity.Complete.Value, humidity.Complete.RawValue)
|
||||
|
||||
// 5. 解析光照
|
||||
light, err := protocol.GetLight()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse light: %v", err)
|
||||
}
|
||||
t.Logf("Light: %.1f lux (raw: 0x%X)", light.Complete.Value, light.Complete.RawValue)
|
||||
|
||||
// 6. 解析大气压
|
||||
pressure, err := protocol.GetPressure()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse pressure: %v", err)
|
||||
}
|
||||
t.Logf("Pressure: %.2f hPa (raw: 0x%X)", pressure.Complete.Value, pressure.Complete.RawValue)
|
||||
|
||||
// 7. 解析UV指数
|
||||
uv, err := protocol.GetUVIndex()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse UV index: %v", err)
|
||||
}
|
||||
t.Logf("UV Index: %.1f uW/c㎡ (raw: 0x%X)", uv.Complete.Value, uv.Complete.RawValue)
|
||||
|
||||
// 8. 解析降雨量
|
||||
rainfall, err := protocol.GetRainfall()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse rainfall: %v", err)
|
||||
}
|
||||
t.Logf("Rainfall: %.3f mm (raw: 0x%X)", rainfall.Complete.Value, rainfall.Complete.RawValue)
|
||||
}
|
||||
|
||||
func TestParseNewDataWithDetails(t *testing.T) {
|
||||
// 新的测试数据:24 F2 09 02 BA 4F 13 03 00 6A 02 33 04 13 5C AC FE 01 83 93 17 00 29 35 88
|
||||
//data := []byte{0x24, 0xF2, 0x09, 0x02, 0xBA, 0x4F, 0x13, 0x03, 0x00, 0x6A, 0x02, 0x33, 0x04, 0x13, 0x5C, 0xAC, 0xFE, 0x01, 0x83, 0x93, 0x17, 0x00, 0x29, 0x35, 0x88}
|
||||
data := []byte{0x24, 0x36, 0xF3, 0x02, 0x96, 0x37, 0x06, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x9C, 0xD3, 0x9A, 0x01, 0x88, 0xF5, 0x7E, 0x00, 0x2A, 0x9C, 0xF6}
|
||||
protocol := NewProtocol(data)
|
||||
|
||||
// 1. 风速解析
|
||||
t.Log("\n=== 风速解析 ===")
|
||||
windSpeed, _ := protocol.GetWindSpeed()
|
||||
t.Logf("风速: %.5f m/s (raw: 0x%X)", windSpeed.Complete.Value, windSpeed.Complete.RawValue)
|
||||
t.Logf("WSP_H: %s (0x%X), WSP_L: %s (0x%X)",
|
||||
windSpeed.WspH.Binary, windSpeed.WspH.Value,
|
||||
windSpeed.WspL.Binary, windSpeed.WspL.Value)
|
||||
if windSpeed.Extend.Value != 0 {
|
||||
t.Logf("WIND_Extend: %s (0x%X)", windSpeed.Extend.Binary, windSpeed.Extend.Value)
|
||||
}
|
||||
|
||||
// 2. 温度解析
|
||||
t.Log("\n=== 温度解析 ===")
|
||||
temp, _ := protocol.GetTemperature()
|
||||
t.Logf("温度: %.2f °C (raw: 0x%X)", temp.Complete.Value, temp.Complete.RawValue)
|
||||
t.Logf("TMP_H: %s (0x%X), TMP_M: %s (0x%X), TMP_L: %s (0x%X)",
|
||||
temp.TmpH.Binary, temp.TmpH.Value,
|
||||
temp.TmpM.Binary, temp.TmpM.Value,
|
||||
temp.TmpL.Binary, temp.TmpL.Value)
|
||||
|
||||
// 3. 湿度解析
|
||||
t.Log("\n=== 湿度解析 ===")
|
||||
humidity, _ := protocol.GetHumidity()
|
||||
t.Logf("湿度: %d%% (raw: 0x%X)", humidity.Complete.Value, humidity.Complete.RawValue)
|
||||
t.Logf("HM_H: %s (0x%X), HM_L: %s (0x%X)",
|
||||
humidity.HmH.Binary, humidity.HmH.Value,
|
||||
humidity.HmL.Binary, humidity.HmL.Value)
|
||||
|
||||
// 4. 光照解析
|
||||
t.Log("\n=== 光照解析 ===")
|
||||
light, _ := protocol.GetLight()
|
||||
t.Logf("光照: %.1f lux (raw: 0x%X)", light.Complete.Value, light.Complete.RawValue)
|
||||
t.Logf("原始字节: %02X %02X %02X", data[12], data[13], data[14])
|
||||
|
||||
// 5. 大气压解析
|
||||
t.Log("\n=== 大气压解析 ===")
|
||||
pressure, _ := protocol.GetPressure()
|
||||
t.Logf("大气压: %.2f hPa (raw: 0x%X)", pressure.Complete.Value, pressure.Complete.RawValue)
|
||||
t.Logf("原始字节: %02X %02X %02X", data[17], data[18], data[19])
|
||||
|
||||
// 6. UV指数解析
|
||||
t.Log("\n=== UV指数解析 ===")
|
||||
uv, _ := protocol.GetUVIndex()
|
||||
t.Logf("UV指数: %.1f uW/c㎡ (raw: 0x%X)", uv.Complete.Value, uv.Complete.RawValue)
|
||||
t.Logf("原始字节: %02X %02X", data[10], data[11])
|
||||
|
||||
// 7. 降雨量解析
|
||||
t.Log("\n=== 降雨量解析 ===")
|
||||
rainfall, _ := protocol.GetRainfall()
|
||||
t.Logf("降雨量: %.3f mm (raw: 0x%X)", rainfall.Complete.Value, rainfall.Complete.RawValue)
|
||||
t.Logf("原始字节: %02X %02X", data[8], data[9])
|
||||
|
||||
// 8. 阵风速度解析
|
||||
t.Log("\n=== 阵风速度解析 ===")
|
||||
gust, _ := protocol.GetGustSpeed()
|
||||
t.Logf("阵风速度: %.2f m/s (raw: 0x%X)", gust.Complete.Value, gust.Complete.RawValue)
|
||||
t.Logf("原始字节: %02X", data[7])
|
||||
|
||||
// 9. 风向解析
|
||||
t.Log("\n=== 风向解析 ===")
|
||||
windDir, _ := protocol.GetWindDirection()
|
||||
t.Logf("风向: %.1f° (raw: 0x%X)", windDir.Complete.Degree, windDir.Complete.Value)
|
||||
t.Logf("原始字节: %02X %02X", data[2], data[3])
|
||||
|
||||
// 10. 设备ID解析
|
||||
t.Log("\n=== 设备ID解析 ===")
|
||||
id, _ := protocol.GetCompleteID()
|
||||
t.Logf("设备ID: %02X %02X %02X", id.HSB.Dec, id.MSB.Dec, id.LSB.Dec)
|
||||
t.Logf("原始字节: HSB=%02X, MSB=%02X, LSB=%02X", data[21], data[22], data[1])
|
||||
}
|
||||
|
||||
func TestParseSpecificData(t *testing.T) {
|
||||
// 测试数据:24 F2 10 02 C7 48 10 03 00 6A 03 E8 05 F5 96 10 3F 01 83 2D B1 00 29 9B A4
|
||||
data := []byte{0x24, 0xF2, 0x10, 0x02, 0xC7, 0x48, 0x10, 0x03, 0x00, 0x6A, 0x03, 0xE8, 0x05, 0xF5, 0x96, 0x10, 0x3F, 0x01, 0x83, 0x2D, 0xB1, 0x00, 0x29, 0x9B, 0xA4}
|
||||
|
||||
protocol := NewProtocol(data)
|
||||
|
||||
t.Log("\n=== 特定数据解析测试 ===")
|
||||
|
||||
// 1. 设备ID解析
|
||||
t.Log("\n=== 设备ID解析 ===")
|
||||
id, err := protocol.GetCompleteID()
|
||||
if err != nil {
|
||||
t.Fatalf("获取设备ID失败: %v", err)
|
||||
}
|
||||
t.Logf("设备ID: %s", id.Complete.Hex)
|
||||
t.Logf("原始字节: HSB=%02X, MSB=%02X, LSB=%02X", data[21], data[22], data[1])
|
||||
|
||||
// 2. 温度解析
|
||||
t.Log("\n=== 温度解析 ===")
|
||||
temp, err := protocol.GetTemperature()
|
||||
if err != nil {
|
||||
t.Fatalf("获取温度失败: %v", err)
|
||||
}
|
||||
t.Logf("温度: %.2f °C (raw: 0x%X)", temp.Complete.Value, temp.Complete.RawValue)
|
||||
t.Logf("TMP_H: %s (0x%X), TMP_M: %s (0x%X), TMP_L: %s (0x%X)",
|
||||
temp.TmpH.Binary, temp.TmpH.Value,
|
||||
temp.TmpM.Binary, temp.TmpM.Value,
|
||||
temp.TmpL.Binary, temp.TmpL.Value)
|
||||
|
||||
// 3. 湿度解析
|
||||
t.Log("\n=== 湿度解析 ===")
|
||||
humidity, err := protocol.GetHumidity()
|
||||
if err != nil {
|
||||
t.Fatalf("获取湿度失败: %v", err)
|
||||
}
|
||||
t.Logf("湿度: %d%% (raw: 0x%X)", humidity.Complete.Value, humidity.Complete.RawValue)
|
||||
t.Logf("HM_H: %s (0x%X), HM_L: %s (0x%X)",
|
||||
humidity.HmH.Binary, humidity.HmH.Value,
|
||||
humidity.HmL.Binary, humidity.HmL.Value)
|
||||
|
||||
// 4. 风速解析
|
||||
t.Log("\n=== 风速解析 ===")
|
||||
windSpeed, err := protocol.GetWindSpeed()
|
||||
if err != nil {
|
||||
t.Fatalf("获取风速失败: %v", err)
|
||||
}
|
||||
t.Logf("风速: %.5f m/s (raw: 0x%X)", windSpeed.Complete.Value, windSpeed.Complete.RawValue)
|
||||
t.Logf("WSP_FLAG: %v", windSpeed.WspFlag.Value)
|
||||
t.Logf("WSP_H: %s (0x%X), WSP_L: %s (0x%X)",
|
||||
windSpeed.WspH.Binary, windSpeed.WspH.Value,
|
||||
windSpeed.WspL.Binary, windSpeed.WspL.Value)
|
||||
|
||||
// 5. 风向解析
|
||||
t.Log("\n=== 风向解析 ===")
|
||||
windDir, err := protocol.GetWindDirection()
|
||||
if err != nil {
|
||||
t.Fatalf("获取风向失败: %v", err)
|
||||
}
|
||||
t.Logf("风向: %.1f° (raw: 0x%X)", windDir.Complete.Degree, windDir.Complete.Value)
|
||||
t.Logf("DirH: %s (0x%X), DirM: %s (0x%X), DirL: %s (0x%X)",
|
||||
windDir.DirH.Binary, windDir.DirH.Value,
|
||||
windDir.DirM.Binary, windDir.DirM.Value,
|
||||
windDir.DirL.Binary, windDir.DirL.Value)
|
||||
|
||||
// 6. 降雨量解析
|
||||
t.Log("\n=== 降雨量解析 ===")
|
||||
rainfall, err := protocol.GetRainfall()
|
||||
if err != nil {
|
||||
t.Fatalf("获取降雨量失败: %v", err)
|
||||
}
|
||||
t.Logf("降雨量: %.3f mm (raw: 0x%X)", rainfall.Complete.Value, rainfall.Complete.RawValue)
|
||||
t.Logf("原始字节: %02X %02X", data[8], data[9])
|
||||
|
||||
// 7. 光照解析
|
||||
t.Log("\n=== 光照解析 ===")
|
||||
light, err := protocol.GetLight()
|
||||
if err != nil {
|
||||
t.Fatalf("获取光照失败: %v", err)
|
||||
}
|
||||
t.Logf("光照: %.1f lux (raw: 0x%X)", light.Complete.Value, light.Complete.RawValue)
|
||||
t.Logf("原始字节: %02X %02X %02X", data[12], data[13], data[14])
|
||||
|
||||
// 8. UV指数解析
|
||||
t.Log("\n=== UV指数解析 ===")
|
||||
uv, err := protocol.GetUVIndex()
|
||||
if err != nil {
|
||||
t.Fatalf("获取UV指数失败: %v", err)
|
||||
}
|
||||
t.Logf("UV指数: %.1f uW/c㎡ (raw: 0x%X)", uv.Complete.Value, uv.Complete.RawValue)
|
||||
t.Logf("原始字节: %02X %02X", data[10], data[11])
|
||||
|
||||
// 9. 气压解析
|
||||
t.Log("\n=== 气压解析 ===")
|
||||
pressure, err := protocol.GetPressure()
|
||||
if err != nil {
|
||||
t.Fatalf("获取气压失败: %v", err)
|
||||
}
|
||||
t.Logf("气压: %.2f hPa (raw: 0x%X)", pressure.Complete.Value, pressure.Complete.RawValue)
|
||||
t.Logf("原始字节: %02X %02X %02X", data[17], data[18], data[19])
|
||||
|
||||
// 10. 阵风速度解析
|
||||
t.Log("\n=== 阵风速度解析 ===")
|
||||
gust, err := protocol.GetGustSpeed()
|
||||
if err != nil {
|
||||
t.Fatalf("获取阵风速度失败: %v", err)
|
||||
}
|
||||
t.Logf("阵风速度: %.2f m/s (raw: 0x%X)", gust.Complete.Value, gust.Complete.RawValue)
|
||||
t.Logf("原始字节: %02X", data[7])
|
||||
|
||||
// 11. 创建RS485协议解析器并解析数据
|
||||
t.Log("\n=== RS485协议解析 ===")
|
||||
rs485Protocol := NewRS485Protocol(data)
|
||||
rs485Data, err := rs485Protocol.ParseRS485Data()
|
||||
if err != nil {
|
||||
t.Fatalf("RS485数据解析失败: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("温度: %.2f°C", rs485Data.Temperature)
|
||||
t.Logf("湿度: %.1f%%", rs485Data.Humidity)
|
||||
t.Logf("风速: %.2f m/s", rs485Data.WindSpeed)
|
||||
t.Logf("风向: %.1f°", rs485Data.WindDirection)
|
||||
t.Logf("降雨量: %.3f mm", rs485Data.Rainfall)
|
||||
t.Logf("光照: %.1f lux", rs485Data.Light)
|
||||
t.Logf("紫外线: %.1f", rs485Data.UV)
|
||||
t.Logf("气压: %.2f hPa", rs485Data.Pressure)
|
||||
}
|
||||
@ -5,8 +5,18 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeviceType 定义设备类型
|
||||
type DeviceType int
|
||||
|
||||
const (
|
||||
DeviceTypeWIFI DeviceType = iota
|
||||
DeviceTypeRS485
|
||||
)
|
||||
|
||||
// WeatherData 存储WIFI设备的气象数据
|
||||
type WeatherData struct {
|
||||
StationID string
|
||||
Password string
|
||||
@ -39,14 +49,47 @@ type WeatherData struct {
|
||||
|
||||
var urlRegex = regexp.MustCompile(`/weatherstation/updateweatherstation\.php\?([^&\s]+(&[^&\s]+)*)`)
|
||||
|
||||
func ParseWeatherData(data string) (*WeatherData, error) {
|
||||
// ParseData 根据数据类型解析气象数据
|
||||
func ParseData(data []byte) (interface{}, DeviceType, error) {
|
||||
// 检查是否为RS485数据
|
||||
if len(data) == 25 && data[0] == 0x24 {
|
||||
// 创建协议解析器
|
||||
protocol := NewProtocol(data)
|
||||
rs485Protocol := NewRS485Protocol(data)
|
||||
|
||||
// 获取设备ID
|
||||
idParts, err := protocol.GetCompleteID()
|
||||
if err != nil {
|
||||
return nil, DeviceTypeRS485, fmt.Errorf("获取设备ID失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析RS485数据
|
||||
rs485Data, err := rs485Protocol.ParseRS485Data()
|
||||
if err != nil {
|
||||
return nil, DeviceTypeRS485, err
|
||||
}
|
||||
|
||||
// 添加设备ID和时间戳
|
||||
rs485Data.DeviceID = idParts.Complete.Hex
|
||||
rs485Data.ReceivedAt = time.Now()
|
||||
rs485Data.RawDataHex = fmt.Sprintf("%X", data)
|
||||
|
||||
return rs485Data, DeviceTypeRS485, nil
|
||||
}
|
||||
|
||||
// 尝试解析为WIFI数据
|
||||
wifiData, err := ParseWIFIWeatherData(string(data))
|
||||
return wifiData, DeviceTypeWIFI, err
|
||||
}
|
||||
|
||||
// ParseWIFIWeatherData 解析WIFI设备数据
|
||||
func ParseWIFIWeatherData(data string) (*WeatherData, error) {
|
||||
matches := urlRegex.FindStringSubmatch(data)
|
||||
if len(matches) < 2 {
|
||||
return nil, fmt.Errorf("无法找到有效的气象站数据URL")
|
||||
}
|
||||
|
||||
queryString := matches[1]
|
||||
|
||||
values, err := url.ParseQuery(queryString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析查询参数失败: %v", err)
|
||||
@ -56,7 +99,6 @@ func ParseWeatherData(data string) (*WeatherData, error) {
|
||||
|
||||
wd.StationID = values.Get("ID")
|
||||
wd.Password = values.Get("PASSWORD")
|
||||
wd.DateUTC = values.Get("dateutc")
|
||||
wd.SoftwareType = values.Get("softwaretype")
|
||||
wd.Action = values.Get("action")
|
||||
|
||||
|
||||
72
pkg/types/types.go
Normal file
72
pkg/types/types.go
Normal file
@ -0,0 +1,72 @@
|
||||
package types
|
||||
|
||||
import "html/template"
|
||||
|
||||
// Station 站点信息
|
||||
type Station struct {
|
||||
StationID string `json:"station_id"`
|
||||
StationAlias string `json:"station_alias"`
|
||||
StationName string `json:"station_name"`
|
||||
DeviceType string `json:"device_type"`
|
||||
LastUpdate string `json:"last_update"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Z int `json:"z"`
|
||||
Y int `json:"y"`
|
||||
X int `json:"x"`
|
||||
DecimalID string `json:"decimal_id"`
|
||||
}
|
||||
|
||||
// WeatherPoint 气象数据点
|
||||
type WeatherPoint struct {
|
||||
DateTime string `json:"date_time"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Humidity float64 `json:"humidity"`
|
||||
Pressure float64 `json:"pressure"`
|
||||
WindSpeed float64 `json:"wind_speed"`
|
||||
WindDir float64 `json:"wind_direction"`
|
||||
Rainfall float64 `json:"rainfall"`
|
||||
Light float64 `json:"light"`
|
||||
UV float64 `json:"uv"`
|
||||
RainTotal float64 `json:"rain_total"`
|
||||
}
|
||||
|
||||
// KmlLayer 描述一个可供前端加载的KML图层
|
||||
type KmlLayer struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// PageData 页面数据结构
|
||||
type PageData struct {
|
||||
Title string
|
||||
ServerTime string
|
||||
OnlineDevices int
|
||||
TiandituKey string
|
||||
KmlLayersJSON template.JS
|
||||
}
|
||||
|
||||
// SystemStatus 系统状态结构
|
||||
type SystemStatus struct {
|
||||
OnlineDevices int `json:"online_devices"`
|
||||
ServerTime string `json:"server_time"`
|
||||
}
|
||||
|
||||
// ForecastPoint 预报数据点
|
||||
type ForecastPoint struct {
|
||||
DateTime string `json:"date_time"`
|
||||
Provider string `json:"provider"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
Humidity *float64 `json:"humidity"`
|
||||
Pressure *float64 `json:"pressure"`
|
||||
WindSpeed *float64 `json:"wind_speed"`
|
||||
WindDir *float64 `json:"wind_direction"`
|
||||
Rainfall *float64 `json:"rainfall"`
|
||||
PrecipProb *float64 `json:"precip_prob"`
|
||||
UV *float64 `json:"uv"`
|
||||
Source string `json:"source"` // "forecast"
|
||||
LeadHours int `json:"lead_hours"`
|
||||
}
|
||||
37
scripts/install.sh
Normal file
37
scripts/install.sh
Normal file
@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Simple installer: build binaries to PREFIX/bin, then replace templates/ and static/
|
||||
# Usage: scripts/install.sh [PREFIX]
|
||||
|
||||
PREFIX=${1:-/opt/weatherstation}
|
||||
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
|
||||
echo "=> Install prefix: $PREFIX"
|
||||
mkdir -p "$PREFIX/bin"
|
||||
|
||||
# Build binaries
|
||||
echo "=> Building services"
|
||||
GO111MODULE=on go build -o "$PREFIX/bin/service-api" "$ROOT_DIR/cmd/service-api"
|
||||
GO111MODULE=on go build -o "$PREFIX/bin/service-forecast" "$ROOT_DIR/cmd/service-forecast"
|
||||
GO111MODULE=on go build -o "$PREFIX/bin/service-fusion" "$ROOT_DIR/cmd/service-fusion"
|
||||
GO111MODULE=on go build -o "$PREFIX/bin/service-radar" "$ROOT_DIR/cmd/service-radar"
|
||||
GO111MODULE=on go build -o "$PREFIX/bin/service-exporter" "$ROOT_DIR/cmd/service-exporter"
|
||||
GO111MODULE=on go build -o "$PREFIX/bin/service-udp" "$ROOT_DIR/cmd/service-udp"
|
||||
|
||||
# Replace assets
|
||||
echo "=> Replacing templates/ and static/"
|
||||
rm -rf "$PREFIX/templates" "$PREFIX/static"
|
||||
mkdir -p "$PREFIX/templates" "$PREFIX/static"
|
||||
cp -r "$ROOT_DIR/templates/." "$PREFIX/templates/"
|
||||
cp -r "$ROOT_DIR/static/." "$PREFIX/static/"
|
||||
|
||||
# Optional UI bundle
|
||||
if [[ -d "$ROOT_DIR/core/frontend/dist/ui" ]]; then
|
||||
echo "=> Updating UI bundle"
|
||||
rm -rf "$PREFIX/core/frontend/dist/ui"
|
||||
mkdir -p "$PREFIX/core/frontend/dist/ui"
|
||||
cp -r "$ROOT_DIR/core/frontend/dist/ui/." "$PREFIX/core/frontend/dist/ui/"
|
||||
fi
|
||||
|
||||
echo "=> Done"
|
||||
349
static/css/ol.css
Normal file
349
static/css/ol.css
Normal file
@ -0,0 +1,349 @@
|
||||
:root,
|
||||
:host {
|
||||
--ol-background-color: white;
|
||||
--ol-accent-background-color: #F5F5F5;
|
||||
--ol-subtle-background-color: rgba(128, 128, 128, 0.25);
|
||||
--ol-partial-background-color: rgba(255, 255, 255, 0.75);
|
||||
--ol-foreground-color: #333333;
|
||||
--ol-subtle-foreground-color: #666666;
|
||||
--ol-brand-color: #00AAFF;
|
||||
}
|
||||
|
||||
.ol-box {
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
border: 1.5px solid var(--ol-background-color);
|
||||
background-color: var(--ol-partial-background-color);
|
||||
}
|
||||
|
||||
.ol-mouse-position {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ol-scale-line {
|
||||
background: var(--ol-partial-background-color);
|
||||
border-radius: 4px;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
padding: 2px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ol-scale-line-inner {
|
||||
border: 1px solid var(--ol-subtle-foreground-color);
|
||||
border-top: none;
|
||||
color: var(--ol-foreground-color);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
margin: 1px;
|
||||
will-change: contents, width;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.ol-scale-bar {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.ol-scale-bar-inner {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ol-scale-step-marker {
|
||||
width: 1px;
|
||||
height: 15px;
|
||||
background-color: var(--ol-foreground-color);
|
||||
float: right;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.ol-scale-step-text {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
font-size: 10px;
|
||||
z-index: 11;
|
||||
color: var(--ol-foreground-color);
|
||||
text-shadow: -1.5px 0 var(--ol-partial-background-color), 0 1.5px var(--ol-partial-background-color), 1.5px 0 var(--ol-partial-background-color), 0 -1.5px var(--ol-partial-background-color);
|
||||
}
|
||||
|
||||
.ol-scale-text {
|
||||
position: absolute;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
bottom: 25px;
|
||||
color: var(--ol-foreground-color);
|
||||
text-shadow: -1.5px 0 var(--ol-partial-background-color), 0 1.5px var(--ol-partial-background-color), 1.5px 0 var(--ol-partial-background-color), 0 -1.5px var(--ol-partial-background-color);
|
||||
}
|
||||
|
||||
.ol-scale-singlebar {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
z-index: 9;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--ol-foreground-color);
|
||||
}
|
||||
|
||||
.ol-scale-singlebar-even {
|
||||
background-color: var(--ol-subtle-foreground-color);
|
||||
}
|
||||
|
||||
.ol-scale-singlebar-odd {
|
||||
background-color: var(--ol-background-color);
|
||||
}
|
||||
|
||||
.ol-unsupported {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ol-viewport,
|
||||
.ol-unselectable {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.ol-viewport canvas {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
.ol-viewport {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.ol-selectable {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.ol-grabbing {
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ol-grab {
|
||||
cursor: move;
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.ol-control {
|
||||
position: absolute;
|
||||
background-color: var(--ol-subtle-background-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ol-zoom {
|
||||
top: .5em;
|
||||
left: .5em;
|
||||
}
|
||||
|
||||
.ol-rotate {
|
||||
top: .5em;
|
||||
right: .5em;
|
||||
transition: opacity .25s linear, visibility 0s linear;
|
||||
}
|
||||
|
||||
.ol-rotate.ol-hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity .25s linear, visibility 0s linear .25s;
|
||||
}
|
||||
|
||||
.ol-zoom-extent {
|
||||
top: 4.643em;
|
||||
left: .5em;
|
||||
}
|
||||
|
||||
.ol-full-screen {
|
||||
right: .5em;
|
||||
top: .5em;
|
||||
}
|
||||
|
||||
.ol-control button {
|
||||
display: block;
|
||||
margin: 1px;
|
||||
padding: 0;
|
||||
color: var(--ol-subtle-foreground-color);
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
text-align: center;
|
||||
height: 1.375em;
|
||||
width: 1.375em;
|
||||
line-height: .4em;
|
||||
background-color: var(--ol-background-color);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ol-control button::-moz-focus-inner {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ol-zoom-extent button {
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.ol-compass {
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.ol-touch .ol-control button {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.ol-touch .ol-zoom-extent {
|
||||
top: 5.5em;
|
||||
}
|
||||
|
||||
.ol-control button:hover,
|
||||
.ol-control button:focus {
|
||||
text-decoration: none;
|
||||
outline: 1px solid var(--ol-subtle-foreground-color);
|
||||
color: var(--ol-foreground-color);
|
||||
}
|
||||
|
||||
.ol-zoom .ol-zoom-in {
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.ol-zoom .ol-zoom-out {
|
||||
border-radius: 0 0 2px 2px;
|
||||
}
|
||||
|
||||
.ol-attribution {
|
||||
text-align: right;
|
||||
bottom: .5em;
|
||||
right: .5em;
|
||||
max-width: calc(100% - 1.3em);
|
||||
display: flex;
|
||||
flex-flow: row-reverse;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ol-attribution a {
|
||||
color: var(--ol-subtle-foreground-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ol-attribution ul {
|
||||
margin: 0;
|
||||
padding: 1px .5em;
|
||||
color: var(--ol-foreground-color);
|
||||
text-shadow: 0 0 2px var(--ol-background-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ol-attribution li {
|
||||
display: inline;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ol-attribution li:not(:last-child):after {
|
||||
content: " ";
|
||||
}
|
||||
|
||||
.ol-attribution img {
|
||||
max-height: 2em;
|
||||
max-width: inherit;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ol-attribution button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ol-attribution.ol-collapsed ul {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ol-attribution:not(.ol-collapsed) {
|
||||
background: var(--ol-partial-background-color);
|
||||
}
|
||||
|
||||
.ol-attribution.ol-uncollapsible {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-radius: 4px 0 0;
|
||||
}
|
||||
|
||||
.ol-attribution.ol-uncollapsible img {
|
||||
margin-top: -.2em;
|
||||
max-height: 1.6em;
|
||||
}
|
||||
|
||||
.ol-attribution.ol-uncollapsible button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ol-zoomslider {
|
||||
top: 4.5em;
|
||||
left: .5em;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.ol-zoomslider button {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.ol-touch .ol-zoomslider {
|
||||
top: 5.5em;
|
||||
}
|
||||
|
||||
.ol-overviewmap {
|
||||
left: 0.5em;
|
||||
bottom: 0.5em;
|
||||
}
|
||||
|
||||
.ol-overviewmap.ol-uncollapsible {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-radius: 0 4px 0 0;
|
||||
}
|
||||
|
||||
.ol-overviewmap .ol-overviewmap-map,
|
||||
.ol-overviewmap button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ol-overviewmap .ol-overviewmap-map {
|
||||
border: 1px solid var(--ol-subtle-foreground-color);
|
||||
height: 150px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.ol-overviewmap:not(.ol-collapsed) button {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ol-overviewmap.ol-collapsed .ol-overviewmap-map,
|
||||
.ol-overviewmap.ol-uncollapsible button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ol-overviewmap:not(.ol-collapsed) {
|
||||
background: var(--ol-subtle-background-color);
|
||||
}
|
||||
|
||||
.ol-overviewmap-box {
|
||||
border: 1.5px dotted var(--ol-subtle-foreground-color);
|
||||
}
|
||||
|
||||
.ol-overviewmap .ol-overviewmap-box:hover {
|
||||
cursor: move;
|
||||
}
|
||||
1
static/css/tailwind.min.css
vendored
Normal file
1
static/css/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
static/images/marker-offline.svg
Normal file
12
static/images/marker-offline.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="26" height="26" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="1.5" flood-color="#000" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter="url(#shadow)">
|
||||
<path d="M20 2 C11 2 4 9 4 18 C4 29 20 38 20 38 C20 38 36 29 36 18 C36 9 29 2 20 2 Z" fill="#6c757d" stroke="#ffffff" stroke-width="2.5"/>
|
||||
<circle cx="20" cy="18" r="6" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 557 B |
12
static/images/marker-online.svg
Normal file
12
static/images/marker-online.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="26" height="26" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="1.5" flood-color="#000" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter="url(#shadow)">
|
||||
<path d="M20 2 C11 2 4 9 4 18 C4 29 20 38 20 38 C20 38 36 29 36 18 C36 9 29 2 20 2 Z" fill="#007bff" stroke="#ffffff" stroke-width="2.5"/>
|
||||
<circle cx="20" cy="18" r="6" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 557 B |
12
static/images/marker.svg
Normal file
12
static/images/marker.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="26" height="26" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="1.5" flood-color="#000" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter="url(#shadow)">
|
||||
<path d="M20 2 C11 2 4 9 4 18 C4 29 20 38 20 38 C20 38 36 29 36 18 C36 9 29 2 20 2 Z" fill="#007bff" stroke="#ffffff" stroke-width="2.5"/>
|
||||
<circle cx="20" cy="18" r="6" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 557 B |
5
static/js/alpinejs.min.js
vendored
Normal file
5
static/js/alpinejs.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
256
static/js/app.js
Normal file
256
static/js/app.js
Normal file
@ -0,0 +1,256 @@
|
||||
const WeatherApp = {
|
||||
cachedHistoryData: [],
|
||||
cachedForecastData: [],
|
||||
currentPage: 1,
|
||||
itemsPerPage: 10,
|
||||
filteredDevices: [],
|
||||
|
||||
init() {
|
||||
WeatherUtils.initializeDateInputs();
|
||||
WeatherMap.init(window.TIANDITU_KEY || '');
|
||||
WeatherMap.loadStations();
|
||||
setInterval(() => this.updateOnlineDevices(), 30000);
|
||||
this.bindUI();
|
||||
window.addEventListener('query-history-data', () => this.queryHistoryData());
|
||||
},
|
||||
|
||||
bindUI() {
|
||||
const stationInput = document.getElementById('stationInput');
|
||||
if (stationInput) {
|
||||
stationInput.addEventListener('input', function() {
|
||||
this.value = this.value.toUpperCase().replace(/[^0-9A-F]/g, '');
|
||||
});
|
||||
stationInput.addEventListener('change', function() {
|
||||
const value = this.value.trim();
|
||||
if (!value) return;
|
||||
if (/^[0-9A-F]+$/i.test(value)) {
|
||||
if (value.length <= 6) this.value = WeatherUtils.hexToDecimal(value);
|
||||
} else {
|
||||
const num = parseInt(value);
|
||||
if (!isNaN(num)) this.value = num.toString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const showDeviceListBtn = document.getElementById('showDeviceList');
|
||||
const modal = document.getElementById('deviceModal');
|
||||
const closeBtn = document.querySelector('.close-modal');
|
||||
const prevPageBtn = document.getElementById('prevPage');
|
||||
const nextPageBtn = document.getElementById('nextPage');
|
||||
|
||||
const deviceListEl = document.getElementById('deviceList');
|
||||
if (deviceListEl && modal) {
|
||||
deviceListEl.addEventListener('click', (e) => {
|
||||
const deviceItem = e.target.closest('.device-item');
|
||||
if (!deviceItem) return;
|
||||
const decimalId = deviceItem.getAttribute('data-decimal-id');
|
||||
const input = document.getElementById('stationInput');
|
||||
if (input) input.value = decimalId;
|
||||
window.dispatchEvent(new CustomEvent('close-device-modal'));
|
||||
this.queryHistoryData();
|
||||
});
|
||||
}
|
||||
|
||||
const showPastForecast = document.getElementById('showPastForecast');
|
||||
if (showPastForecast) {
|
||||
showPastForecast.addEventListener('change', () => {
|
||||
WeatherTable.display(this.cachedHistoryData, this.cachedForecastData);
|
||||
});
|
||||
}
|
||||
|
||||
const legendMode = document.getElementById('legendMode');
|
||||
if (legendMode) {
|
||||
legendMode.addEventListener('change', (e) => {
|
||||
const mode = e.target.value;
|
||||
if (window.WeatherChart && typeof window.WeatherChart.applyLegendMode === 'function') {
|
||||
window.WeatherChart.applyLegendMode(mode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.switchLayer = (type) => WeatherMap.switchLayer(type);
|
||||
window.toggleMap = () => WeatherMap.toggleMap();
|
||||
window.queryHistoryData = () => this.queryHistoryData();
|
||||
},
|
||||
|
||||
updateDeviceList(page = 1) {
|
||||
const deviceListContainer = document.getElementById('deviceList');
|
||||
if (!deviceListContainer) return;
|
||||
deviceListContainer.innerHTML = '';
|
||||
|
||||
this.filteredDevices = (WeatherMap.stations || [])
|
||||
.filter(station => station.device_type === 'WH65LP')
|
||||
.sort((a, b) => {
|
||||
const aOnline = WeatherUtils.isDeviceOnline(a.last_update);
|
||||
const bOnline = WeatherUtils.isDeviceOnline(b.last_update);
|
||||
if (aOnline === bOnline) return 0;
|
||||
return aOnline ? -1 : 1;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(this.filteredDevices.length / this.itemsPerPage) || 1;
|
||||
this.currentPage = Math.min(Math.max(1, page), totalPages);
|
||||
|
||||
const currentPageEl = document.getElementById('currentPage');
|
||||
const totalPagesEl = document.getElementById('totalPages');
|
||||
const prevBtn = document.getElementById('prevPage');
|
||||
const nextBtn = document.getElementById('nextPage');
|
||||
if (currentPageEl) currentPageEl.textContent = this.currentPage;
|
||||
if (totalPagesEl) totalPagesEl.textContent = totalPages;
|
||||
if (prevBtn) prevBtn.disabled = this.currentPage <= 1;
|
||||
if (nextBtn) nextBtn.disabled = this.currentPage >= totalPages;
|
||||
|
||||
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
|
||||
const endIndex = startIndex + this.itemsPerPage;
|
||||
const currentDevices = this.filteredDevices.slice(startIndex, endIndex);
|
||||
|
||||
currentDevices.forEach(device => {
|
||||
const isOnline = WeatherUtils.isDeviceOnline(device.last_update);
|
||||
const deviceItem = document.createElement('div');
|
||||
deviceItem.className = 'device-item';
|
||||
deviceItem.setAttribute('data-decimal-id', device.decimal_id);
|
||||
deviceItem.innerHTML = `
|
||||
<div style="font-size: 13px; color: #444">
|
||||
${device.decimal_id} | ${device.name} | ${device.location || '未知位置'}
|
||||
</div>
|
||||
<span style="color: ${isOnline ? '#28a745' : '#dc3545'}; font-size: 12px; padding: 2px 6px; background: ${isOnline ? '#f0f9f1' : '#fef5f5'}; border-radius: 3px">${isOnline ? '在线' : '离线'}</span>
|
||||
`;
|
||||
deviceListContainer.appendChild(deviceItem);
|
||||
});
|
||||
|
||||
if (this.filteredDevices.length === 0) {
|
||||
deviceListContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无WH65LP设备</div>';
|
||||
}
|
||||
},
|
||||
|
||||
async updateOnlineDevices() {
|
||||
try {
|
||||
const response = await fetch('/api/system/status');
|
||||
const data = await response.json();
|
||||
const onlineEl = document.getElementById('onlineDevices');
|
||||
if (onlineEl) onlineEl.textContent = data.online_devices;
|
||||
} catch (error) {
|
||||
console.error('更新在线设备数量失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async queryHistoryData() {
|
||||
const decimalId = (document.getElementById('stationInput')?.value || '').trim();
|
||||
if (!decimalId) {
|
||||
alert('请输入站点编号');
|
||||
return;
|
||||
}
|
||||
if (!/^\d+$/.test(decimalId)) {
|
||||
alert('请输入有效的十进制编号');
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = document.getElementById('startDate').value;
|
||||
const endTime = document.getElementById('endDate').value;
|
||||
const interval = document.getElementById('interval').value;
|
||||
const forecastProvider = document.getElementById('forecastProvider').value;
|
||||
if (!startTime || !endTime) {
|
||||
alert('请选择开始和结束时间');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const historyParams = new URLSearchParams({
|
||||
decimal_id: decimalId,
|
||||
start_time: startTime.replace('T', ' ') + ':00',
|
||||
end_time: endTime.replace('T', ' ') + ':00',
|
||||
interval: interval
|
||||
});
|
||||
const historyResponse = await fetch(`/api/data?${historyParams}`);
|
||||
if (!historyResponse.ok) throw new Error('查询历史数据失败');
|
||||
const responseData = await historyResponse.json();
|
||||
const historyData = Array.isArray(responseData) ? responseData : [];
|
||||
|
||||
let forecastData = [];
|
||||
if (forecastProvider && interval === '1hour') {
|
||||
try {
|
||||
const hexID = WeatherUtils.decimalToHex(decimalId);
|
||||
const stationID = `RS485-${hexID}`;
|
||||
// 将预报查询范围按小时对齐:from 向下取整到整点;to 向上取整到整点,再 +3h
|
||||
const parseLocal = (s) => { try { return new Date(s); } catch { return null; } };
|
||||
const floorHour = (d) => { const t = new Date(d); t.setMinutes(0,0,0); return t; };
|
||||
const ceilHour = (d) => { const t = new Date(d); if (t.getMinutes()||t.getSeconds()||t.getMilliseconds()) { t.setHours(t.getHours()+1); } t.setMinutes(0,0,0); return t; };
|
||||
const fmt = (d) => {
|
||||
const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const da=String(d.getDate()).padStart(2,'0');
|
||||
const h=String(d.getHours()).padStart(2,'0'); const mi='00'; const s='00';
|
||||
return `${y}-${m}-${da} ${h}:${mi}:${s}`;
|
||||
};
|
||||
const startD = parseLocal(startTime);
|
||||
const endD = parseLocal(endTime);
|
||||
const fromStr = startD && !isNaN(startD) ? fmt(floorHour(startD)) : startTime.replace('T',' ') + ':00';
|
||||
const toStr = endD && !isNaN(endD) ? fmt(new Date(ceilHour(endD).getTime() + 3*60*60*1000)) : endTime.replace('T',' ') + ':00';
|
||||
const forecastParams = new URLSearchParams({
|
||||
station_id: stationID,
|
||||
from: fromStr,
|
||||
to: toStr,
|
||||
provider: forecastProvider,
|
||||
versions: '3'
|
||||
});
|
||||
const forecastResponse = await fetch(`/api/forecast?${forecastParams}`);
|
||||
if (forecastResponse.ok) {
|
||||
const responseData = await forecastResponse.json();
|
||||
forecastData = Array.isArray(responseData) ? responseData : [];
|
||||
console.log(`查询到 ${forecastData.length} 条预报数据`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('查询预报数据失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedHistoryData = historyData;
|
||||
this.cachedForecastData = forecastData;
|
||||
|
||||
if (historyData.length === 0 && forecastData.length === 0) {
|
||||
alert('该时间段内无数据');
|
||||
return;
|
||||
}
|
||||
|
||||
const station = (WeatherMap.stations || []).find(s => s.decimal_id == decimalId);
|
||||
const stationInfoTitle = document.getElementById('stationInfoTitle');
|
||||
if (stationInfoTitle) {
|
||||
if (station) {
|
||||
stationInfoTitle.innerHTML = `
|
||||
<strong>
|
||||
${station.location || '未知位置'} ·
|
||||
编号 ${decimalId} ·
|
||||
坐标 ${station.latitude ? station.latitude.toFixed(6) : '未知'}, ${station.longitude ? station.longitude.toFixed(6) : '未知'}
|
||||
</strong>
|
||||
`;
|
||||
} else {
|
||||
stationInfoTitle.innerHTML = `编号 ${decimalId}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!WeatherMap.isMapCollapsed) WeatherMap.toggleMap();
|
||||
|
||||
WeatherChart.display(historyData, forecastData);
|
||||
WeatherTable.display(historyData, forecastData);
|
||||
|
||||
const chartContainer = document.getElementById('chartContainer');
|
||||
const tableContainer = document.getElementById('tableContainer');
|
||||
if (chartContainer) chartContainer.classList.add('show');
|
||||
if (tableContainer) tableContainer.classList.add('show');
|
||||
setTimeout(() => {
|
||||
chartContainer?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 300);
|
||||
|
||||
const legendMode = document.getElementById('legendMode');
|
||||
if (legendMode) {
|
||||
WeatherChart.applyLegendMode(legendMode.value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询数据失败:', error);
|
||||
alert('查询数据失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.WeatherApp = WeatherApp;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
WeatherApp.init();
|
||||
});
|
||||
14
static/js/chart.js
Normal file
14
static/js/chart.js
Normal file
File diff suppressed because one or more lines are too long
4
static/js/ol.js
Normal file
4
static/js/ol.js
Normal file
File diff suppressed because one or more lines are too long
8
static/js/plotly-2.27.0.min.js
vendored
Normal file
8
static/js/plotly-2.27.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
0
static/js/tailwindcdn.js
Normal file
0
static/js/tailwindcdn.js
Normal file
45
static/js/utils.js
Normal file
45
static/js/utils.js
Normal file
@ -0,0 +1,45 @@
|
||||
const WeatherUtils = {
|
||||
formatDatetimeLocal(date) {
|
||||
const offsetMinutes = date.getTimezoneOffset();
|
||||
const local = new Date(date.getTime() - offsetMinutes * 60 * 1000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
},
|
||||
|
||||
initializeDateInputs() {
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const endDate = new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
||||
|
||||
const startDateInput = document.getElementById('startDate');
|
||||
const endDateInput = document.getElementById('endDate');
|
||||
|
||||
if (startDateInput) {
|
||||
startDateInput.value = this.formatDatetimeLocal(startDate);
|
||||
}
|
||||
if (endDateInput) {
|
||||
endDateInput.value = this.formatDatetimeLocal(endDate);
|
||||
}
|
||||
},
|
||||
|
||||
isDeviceOnline(lastUpdate) {
|
||||
if (!lastUpdate) return false;
|
||||
const lastUpdateTime = new Date(lastUpdate);
|
||||
const now = new Date();
|
||||
const diffMinutes = (now - lastUpdateTime) / (1000 * 60);
|
||||
return diffMinutes <= 5;
|
||||
},
|
||||
|
||||
decimalToHex(decimal) {
|
||||
const num = parseInt(decimal);
|
||||
if (isNaN(num)) return '';
|
||||
return num.toString(16).toUpperCase().padStart(6, '0');
|
||||
},
|
||||
|
||||
hexToDecimal(hex) {
|
||||
const num = parseInt(hex, 16);
|
||||
if (isNaN(num)) return '';
|
||||
return num.toString();
|
||||
}
|
||||
};
|
||||
|
||||
window.WeatherUtils = WeatherUtils;
|
||||
1074
static/js/weather-app.js
Normal file
1074
static/js/weather-app.js
Normal file
File diff suppressed because it is too large
Load Diff
418
static/js/weather-chart.js
Normal file
418
static/js/weather-chart.js
Normal file
@ -0,0 +1,418 @@
|
||||
const WeatherChart = {
|
||||
chart: null,
|
||||
|
||||
display(historyData = [], forecastData = []) {
|
||||
historyData = Array.isArray(historyData) ? historyData : [];
|
||||
forecastData = Array.isArray(forecastData) ? forecastData : [];
|
||||
|
||||
if (historyData.length === 0 && forecastData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allLabels = [...new Set([
|
||||
...historyData.map(item => item.date_time),
|
||||
...forecastData.map(item => item.date_time)
|
||||
])].sort();
|
||||
|
||||
const historyTemperatures = allLabels.map(label => {
|
||||
const item = historyData.find(d => d.date_time === label);
|
||||
return item ? item.temperature : null;
|
||||
});
|
||||
const historyHumidities = allLabels.map(label => {
|
||||
const item = historyData.find(d => d.date_time === label);
|
||||
return item ? item.humidity : null;
|
||||
});
|
||||
const historyPressures = allLabels.map(label => {
|
||||
const item = historyData.find(d => d.date_time === label);
|
||||
return item ? item.pressure : null;
|
||||
});
|
||||
const historyWindSpeeds = allLabels.map(label => {
|
||||
const item = historyData.find(d => d.date_time === label);
|
||||
return item ? item.wind_speed : null;
|
||||
});
|
||||
const historyRainfalls = allLabels.map(label => {
|
||||
const item = historyData.find(d => d.date_time === label);
|
||||
return item ? item.rainfall : null;
|
||||
});
|
||||
const historyRainTotals = allLabels.map(label => {
|
||||
const item = historyData.find(d => d.date_time === label);
|
||||
return item && item.rain_total !== undefined ? item.rain_total : null;
|
||||
});
|
||||
|
||||
const byTime = new Map();
|
||||
forecastData.forEach(fp => {
|
||||
if (!byTime.has(fp.date_time)) byTime.set(fp.date_time, {});
|
||||
const bucket = byTime.get(fp.date_time);
|
||||
const h = typeof fp.lead_hours === 'number' ? fp.lead_hours : null;
|
||||
if (h !== null && h >= 0 && h <= 3) {
|
||||
// 保留同一 forecast_time+lead 的最新版本(查询结果已按 issued_at DESC 排序)
|
||||
if (bucket[h] == null) {
|
||||
bucket[h] = fp;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const getRainAtLead = (label, lead) => {
|
||||
const b = byTime.get(label);
|
||||
if (!b || !b[lead]) return null;
|
||||
return b[lead].rainfall != null ? b[lead].rainfall : null;
|
||||
};
|
||||
const getTempAtLead0 = (label) => {
|
||||
const b = byTime.get(label);
|
||||
if (!b || !b[0]) return null;
|
||||
return b[0].temperature != null ? b[0].temperature : null;
|
||||
};
|
||||
|
||||
const forecastRainfallsH0 = allLabels.map(label => getRainAtLead(label, 0));
|
||||
const forecastRainfallsH1 = allLabels.map(label => getRainAtLead(label, 1));
|
||||
const forecastRainfallsH2 = allLabels.map(label => getRainAtLead(label, 2));
|
||||
const forecastRainfallsH3 = allLabels.map(label => getRainAtLead(label, 3));
|
||||
|
||||
const pickNearest = (label, field) => {
|
||||
const b = byTime.get(label);
|
||||
if (!b) return null;
|
||||
if (b[0] && b[0][field] != null) return b[0][field];
|
||||
if (b[1] && b[1][field] != null) return b[1][field];
|
||||
if (b[2] && b[2][field] != null) return b[2][field];
|
||||
if (b[3] && b[3][field] != null) return b[3][field];
|
||||
return null;
|
||||
};
|
||||
const forecastTemperaturesNearest = allLabels.map(label => pickNearest(label, 'temperature'));
|
||||
const forecastHumiditiesNearest = allLabels.map(label => pickNearest(label, 'humidity'));
|
||||
const forecastPressuresNearest = allLabels.map(label => pickNearest(label, 'pressure'));
|
||||
const forecastWindSpeedsNearest = allLabels.map(label => pickNearest(label, 'wind_speed'));
|
||||
|
||||
if (this.chart) this.chart.destroy();
|
||||
|
||||
// 计算降水分类准确率(+1h/+2h/+3h)
|
||||
const updateAccuracyPanel = () => {
|
||||
// 仅在有历史数据(实际)时计算
|
||||
const usedIdx = historyRainfalls
|
||||
.map((v, idx) => ({ v, idx }))
|
||||
.filter(x => x.v !== null)
|
||||
.map(x => x.idx);
|
||||
const totalHours = usedIdx.length;
|
||||
const bucketOf = (mm) => {
|
||||
if (mm === null || mm === undefined || isNaN(Number(mm))) return null;
|
||||
const v = Math.max(0, Number(mm));
|
||||
if (v < 5) return 0;
|
||||
if (v < 10) return 1;
|
||||
return 2;
|
||||
};
|
||||
const calcFor = (arrFcst) => {
|
||||
let correct = 0;
|
||||
usedIdx.forEach(i => {
|
||||
const a = historyRainfalls[i];
|
||||
const f = arrFcst[i];
|
||||
const ba = bucketOf(a);
|
||||
const bf = bucketOf(f);
|
||||
if (ba !== null && bf !== null && ba === bf) correct += 1;
|
||||
});
|
||||
return { correct, total: totalHours };
|
||||
};
|
||||
const fmt = (n) => `${n.toFixed(1)}%`;
|
||||
const elPanel = document.getElementById('accuracyPanel');
|
||||
const elH1 = document.getElementById('accH1');
|
||||
const elH2 = document.getElementById('accH2');
|
||||
const elH3 = document.getElementById('accH3');
|
||||
if (!elPanel || !elH1 || !elH2 || !elH3) return;
|
||||
if (forecastData.length === 0 || totalHours === 0) {
|
||||
elPanel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
// 详细计算过程日志
|
||||
try {
|
||||
console.groupCollapsed('[准确率] 降水分档 (+1h/+2h/+3h) 计算详情');
|
||||
console.log('时间段总小时(有实测):', totalHours);
|
||||
const nameOf = (b) => (b===0?'[0,5)':(b===1?'[5,10)':'[10,∞)'));
|
||||
const fv = (v) => (v===null||v===undefined||isNaN(Number(v)) ? 'NULL' : Number(v).toFixed(2));
|
||||
usedIdx.forEach(i => {
|
||||
const label = allLabels[i];
|
||||
const a = historyRainfalls[i];
|
||||
const bA = bucketOf(a);
|
||||
const f1 = forecastRainfallsH1[i]; const b1 = bucketOf(f1);
|
||||
const f2 = forecastRainfallsH2[i]; const b2 = bucketOf(f2);
|
||||
const f3 = forecastRainfallsH3[i]; const b3 = bucketOf(f3);
|
||||
const m1 = (bA!==null && b1!==null && bA===b1) ? '√' : '×';
|
||||
const m2 = (bA!==null && b2!==null && bA===b2) ? '√' : '×';
|
||||
const m3 = (bA!==null && b3!==null && bA===b3) ? '√' : '×';
|
||||
console.log(
|
||||
`${label} | 实测 ${fv(a)}mm (${bA===null?'--':nameOf(bA)}) | +1h ${fv(f1)} (${b1===null?'--':nameOf(b1)}) ${m1} | +2h ${fv(f2)} (${b2===null?'--':nameOf(b2)}) ${m2} | +3h ${fv(f3)} (${b3===null?'--':nameOf(b3)}) ${m3}`
|
||||
);
|
||||
});
|
||||
} catch (e) { console.warn('准确率计算日志输出失败', e); }
|
||||
const r1 = calcFor(forecastRainfallsH1);
|
||||
const r2 = calcFor(forecastRainfallsH2);
|
||||
const r3 = calcFor(forecastRainfallsH3);
|
||||
console.log(`+1h: ${r1.correct}/${r1.total}`);
|
||||
console.log(`+2h: ${r2.correct}/${r2.total}`);
|
||||
console.log(`+3h: ${r3.correct}/${r3.total}`);
|
||||
console.groupEnd();
|
||||
elH1.textContent = r1.total > 0 ? fmt((r1.correct / r1.total) * 100) : '--';
|
||||
elH2.textContent = r2.total > 0 ? fmt((r2.correct / r2.total) * 100) : '--';
|
||||
elH3.textContent = r3.total > 0 ? fmt((r3.correct / r3.total) * 100) : '--';
|
||||
elPanel.style.display = 'block';
|
||||
};
|
||||
|
||||
const datasets = [
|
||||
{
|
||||
label: '温度 (°C) - 实测',
|
||||
seriesKey: 'temp_actual',
|
||||
data: historyTemperatures,
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
yAxisID: 'y-temperature',
|
||||
tension: 0.4,
|
||||
spanGaps: false
|
||||
},
|
||||
{
|
||||
label: '湿度 (%) - 实测',
|
||||
seriesKey: 'hum_actual',
|
||||
data: historyHumidities,
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||
yAxisID: 'y-humidity',
|
||||
tension: 0.4,
|
||||
hidden: true,
|
||||
spanGaps: false
|
||||
},
|
||||
{
|
||||
label: '大气压 (hPa) - 实测',
|
||||
seriesKey: 'pressure_actual',
|
||||
data: historyPressures,
|
||||
borderColor: 'rgb(153, 102, 255)',
|
||||
backgroundColor: 'rgba(153, 102, 255, 0.1)',
|
||||
yAxisID: 'y-pressure',
|
||||
tension: 0.4,
|
||||
hidden: true,
|
||||
spanGaps: false
|
||||
},
|
||||
{
|
||||
label: '风速 (m/s) - 实测',
|
||||
seriesKey: 'wind_actual',
|
||||
data: historyWindSpeeds,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
yAxisID: 'y-wind',
|
||||
tension: 0.4,
|
||||
hidden: true,
|
||||
spanGaps: false
|
||||
},
|
||||
{
|
||||
label: '雨量 (mm) - 实测',
|
||||
seriesKey: 'rain_actual',
|
||||
data: historyRainfalls,
|
||||
type: 'bar',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
yAxisID: 'y-rainfall'
|
||||
},
|
||||
{
|
||||
label: '累计雨量 (mm) - 实测',
|
||||
seriesKey: 'rain_total',
|
||||
data: historyRainTotals,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
yAxisID: 'y-rainfall',
|
||||
tension: 0.2,
|
||||
spanGaps: false,
|
||||
pointRadius: 0,
|
||||
hidden: true
|
||||
}
|
||||
];
|
||||
|
||||
if (forecastData.length > 0) {
|
||||
datasets.push(
|
||||
{ label: '雨量 (mm) - 预报 (+1h)', seriesKey: 'rain_fcst_h1', data: forecastRainfallsH1, type: 'bar', backgroundColor: 'rgba(255, 99, 71, 0.55)', borderColor: 'rgb(255, 99, 71)', yAxisID: 'y-rainfall' },
|
||||
{ label: '雨量 (mm) - 预报 (+2h)', seriesKey: 'rain_fcst_h2', data: forecastRainfallsH2, type: 'bar', backgroundColor: 'rgba(255, 205, 86, 0.55)', borderColor: 'rgb(255, 205, 86)', yAxisID: 'y-rainfall' },
|
||||
{ label: '雨量 (mm) - 预报 (+3h)', seriesKey: 'rain_fcst_h3', data: forecastRainfallsH3, type: 'bar', backgroundColor: 'rgba(76, 175, 80, 0.55)', borderColor: 'rgb(76, 175, 80)', yAxisID: 'y-rainfall' }
|
||||
);
|
||||
|
||||
datasets.push(
|
||||
{
|
||||
label: '温度 (°C) - 预报',
|
||||
seriesKey: 'temp_fcst',
|
||||
data: forecastTemperaturesNearest,
|
||||
borderColor: 'rgb(255, 159, 64)',
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.1)',
|
||||
borderDash: [5, 5],
|
||||
yAxisID: 'y-temperature',
|
||||
tension: 0.4,
|
||||
spanGaps: false,
|
||||
hidden: true
|
||||
},
|
||||
{
|
||||
label: '湿度 (%) - 预报',
|
||||
seriesKey: 'hum_fcst',
|
||||
data: forecastHumiditiesNearest,
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||
borderDash: [5, 5],
|
||||
yAxisID: 'y-humidity',
|
||||
tension: 0.4,
|
||||
hidden: true,
|
||||
spanGaps: false
|
||||
},
|
||||
{
|
||||
label: '大气压 (hPa) - 预报',
|
||||
seriesKey: 'pressure_fcst',
|
||||
data: forecastPressuresNearest,
|
||||
borderColor: 'rgb(153, 102, 255)',
|
||||
backgroundColor: 'rgba(153, 102, 255, 0.1)',
|
||||
borderDash: [5, 5],
|
||||
yAxisID: 'y-pressure',
|
||||
tension: 0.4,
|
||||
hidden: true,
|
||||
spanGaps: false
|
||||
},
|
||||
{
|
||||
label: '风速 (m/s) - 预报',
|
||||
seriesKey: 'wind_fcst',
|
||||
data: forecastWindSpeedsNearest,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
borderDash: [5, 5],
|
||||
yAxisID: 'y-wind',
|
||||
tension: 0.4,
|
||||
hidden: true,
|
||||
spanGaps: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('combinedChart').getContext('2d');
|
||||
const chartConfig = {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: allLabels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
layout: { padding: { top: 12, right: 16, bottom: 12, left: 16 } },
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
labels: {
|
||||
padding: 16
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
'y-temperature': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: { display: true, text: '温度 (°C)' }
|
||||
},
|
||||
'y-humidity': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: { display: true, text: '湿度 (%)' },
|
||||
grid: { drawOnChartArea: false },
|
||||
min: 0,
|
||||
max: 100
|
||||
},
|
||||
'y-pressure': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: '大气压 (hPa)' },
|
||||
grid: { drawOnChartArea: false }
|
||||
},
|
||||
'y-wind': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: '风速 (m/s)' },
|
||||
grid: { drawOnChartArea: false },
|
||||
beginAtZero: true
|
||||
},
|
||||
'y-rainfall': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: { display: true, text: '雨量 (mm)' },
|
||||
grid: { drawOnChartArea: false },
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.chart = new Chart(ctx, chartConfig);
|
||||
|
||||
// 更新准确率面板
|
||||
updateAccuracyPanel();
|
||||
|
||||
const mode = document.getElementById('legendMode')?.value || 'combo_standard';
|
||||
this.applyLegendMode(mode);
|
||||
}
|
||||
};
|
||||
|
||||
WeatherChart.applyLegendMode = function(mode) {
|
||||
if (!this.chart) return;
|
||||
|
||||
// 设置数据集可见性
|
||||
const map = new Map();
|
||||
this.chart.data.datasets.forEach(ds => {
|
||||
if (ds.seriesKey) map.set(ds.seriesKey, ds);
|
||||
});
|
||||
|
||||
const setVisible = (keys) => {
|
||||
const allKeys = ['temp_actual','hum_actual','rain_actual','rain_total','temp_fcst','hum_fcst','pressure_actual','pressure_fcst','wind_actual','wind_fcst','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3'];
|
||||
allKeys.forEach(k => { if (map.has(k)) map.get(k).hidden = true; });
|
||||
keys.forEach(k => { if (map.has(k)) map.get(k).hidden = false; });
|
||||
};
|
||||
|
||||
switch (mode) {
|
||||
|
||||
case 'verify_all':
|
||||
setVisible(['temp_actual','temp_fcst','hum_actual','hum_fcst','pressure_actual','pressure_fcst','wind_actual','wind_fcst','rain_actual','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3']);
|
||||
break;
|
||||
case 'temp_compare':
|
||||
setVisible(['temp_actual','temp_fcst']);
|
||||
break;
|
||||
case 'hum_compare':
|
||||
setVisible(['hum_actual','hum_fcst']);
|
||||
break;
|
||||
case 'rain_all':
|
||||
setVisible(['rain_actual','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3']);
|
||||
break;
|
||||
case 'pressure_compare':
|
||||
setVisible(['pressure_actual','pressure_fcst']);
|
||||
break;
|
||||
case 'wind_compare':
|
||||
setVisible(['wind_actual','wind_fcst']);
|
||||
break;
|
||||
case 'combo_standard':
|
||||
default:
|
||||
setVisible(['temp_actual','temp_fcst','rain_actual','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3']);
|
||||
break;
|
||||
}
|
||||
this.chart.update();
|
||||
};
|
||||
|
||||
WeatherChart.updateAxesVisibility = function() {
|
||||
if (!this.chart) return;
|
||||
|
||||
// 检查每个轴是否有可见的数据集
|
||||
const hasVisibleDatasets = (axisId) => {
|
||||
return this.chart.data.datasets.some(ds => !ds.hidden && ds.yAxisID === axisId);
|
||||
};
|
||||
|
||||
// 更新每个轴的显示状态
|
||||
Object.entries(this.chart.options.scales).forEach(([scaleId, scale]) => {
|
||||
const shouldDisplay = hasVisibleDatasets(scaleId);
|
||||
if (scale.display !== shouldDisplay) {
|
||||
scale.display = shouldDisplay;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.WeatherChart = WeatherChart;
|
||||
94
static/js/weather-table.js
Normal file
94
static/js/weather-table.js
Normal file
@ -0,0 +1,94 @@
|
||||
const WeatherTable = {
|
||||
display(historyData = [], forecastData = []) {
|
||||
historyData = Array.isArray(historyData) ? historyData : [];
|
||||
forecastData = Array.isArray(forecastData) ? forecastData : [];
|
||||
|
||||
if (historyData.length === 0 && forecastData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const showPastEl = document.getElementById('showPastForecast');
|
||||
const endInput = document.getElementById('endDate')?.value;
|
||||
let endTs = Date.now();
|
||||
if (endInput) {
|
||||
const d = new Date(endInput);
|
||||
if (!isNaN(d)) endTs = d.getTime();
|
||||
}
|
||||
const future3hTs = endTs + 3 * 60 * 60 * 1000;
|
||||
const showPast = !!(showPastEl && showPastEl.checked);
|
||||
const displayedForecast = forecastData.filter(item => {
|
||||
const t = new Date(item.date_time).getTime();
|
||||
const isFuture3h = t > endTs && t <= future3hTs;
|
||||
const isPast = t <= endTs;
|
||||
return isFuture3h || (showPast && isPast);
|
||||
});
|
||||
const taggedHistory = historyData.map(item => ({ ...item, __source: '实测' }));
|
||||
const taggedForecast = displayedForecast.map(item => ({ ...item, __source: '预报' }));
|
||||
const allData = [...taggedHistory, ...taggedForecast];
|
||||
const hasForecast = taggedForecast.length > 0;
|
||||
const sortedData = allData.sort((a, b) => new Date(b.date_time) - new Date(a.date_time));
|
||||
|
||||
const tableBody = document.getElementById('tableBody');
|
||||
if (!tableBody) return;
|
||||
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
// 动态表头:在有预报时加入“降水概率 (%)”
|
||||
const headerRow = document.getElementById('tableHeader');
|
||||
if (headerRow) {
|
||||
headerRow.innerHTML = '';
|
||||
const addTh = (text) => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = text;
|
||||
headerRow.appendChild(th);
|
||||
};
|
||||
['时间','温度 (°C)','湿度 (%)','气压 (hPa)','风速 (m/s)','风向 (°)','雨量 (mm)'].forEach(addTh);
|
||||
if (hasForecast) addTh('降水概率 (%)');
|
||||
['光照 (lux)','紫外线'].forEach(addTh);
|
||||
}
|
||||
|
||||
const fmt = (v, digits) => (v === null || v === undefined || v === '' || isNaN(Number(v))) ? '' : Number(v).toFixed(digits);
|
||||
|
||||
sortedData.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
const isForecast = row.__source === '预报';
|
||||
if (isForecast) {
|
||||
tr.style.backgroundColor = 'rgba(255, 165, 0, 0.08)';
|
||||
}
|
||||
const issuedBadge = (() => {
|
||||
if (!isForecast) return '';
|
||||
const lead = (typeof row.lead_hours === 'number' && row.lead_hours >= 0) ? ` +${row.lead_hours}h` : '';
|
||||
const issued = row.issued_at ? new Date(String(row.issued_at).replace(' ', 'T')) : null;
|
||||
const issuedStr = issued ? `${String(issued.getHours()).padStart(2,'0')}:${String(issued.getMinutes()).padStart(2,'0')}` : '-';
|
||||
return `<span style="font-size: 12px; color: #6b7280;">(发布: ${issuedStr}${lead})</span>`;
|
||||
})();
|
||||
const timeCell = `${row.date_time || ''}${isForecast ? ' <span style=\"font-size: 12px; color: #ff8c00;\">[预报]</span>' : ''}${isForecast ? `<br>${issuedBadge}` : ''}`;
|
||||
const columns = [
|
||||
`<td>${timeCell}</td>`,
|
||||
`<td>${fmt(row.temperature, 1)}</td>`,
|
||||
`<td>${fmt(row.humidity, 1)}</td>`,
|
||||
`<td>${fmt(row.pressure, 1)}</td>`,
|
||||
`<td>${fmt(row.wind_speed, 1)}</td>`,
|
||||
`<td>${fmt(row.wind_direction, 0)}</td>`,
|
||||
`<td>${fmt(row.rainfall, 2)}</td>`
|
||||
];
|
||||
if (hasForecast) {
|
||||
const val = isForecast && row.precip_prob !== null && row.precip_prob !== undefined ? row.precip_prob : '-';
|
||||
columns.push(`<td>${val}</td>`);
|
||||
}
|
||||
columns.push(
|
||||
`<td>${fmt(row.light, 0)}</td>`,
|
||||
`<td>${fmt(row.uv, 1)}</td>`
|
||||
);
|
||||
tr.innerHTML = columns.join('');
|
||||
tableBody.appendChild(tr);
|
||||
});
|
||||
|
||||
const forecastToggleContainer = document.getElementById('forecastToggleContainer');
|
||||
if (forecastToggleContainer) {
|
||||
forecastToggleContainer.style.display = forecastData.length > 0 ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.WeatherTable = WeatherTable;
|
||||
1205
static/kml/selected_polygons.kml
Normal file
1205
static/kml/selected_polygons.kml
Normal file
File diff suppressed because one or more lines are too long
18
templates/_header.html
Normal file
18
templates/_header.html
Normal file
@ -0,0 +1,18 @@
|
||||
{{ define "header" }}
|
||||
<div class="header p-2 text-center border-b border-gray-200">
|
||||
<div class="content-narrow mx-auto px-4 py-2 flex items-center justify-between flex-wrap gap-2">
|
||||
<h1 class="text-xl md:text-2xl font-semibold">{{ .Title }}</h1>
|
||||
<nav class="text-sm flex items-center gap-3">
|
||||
<a href="/" class="text-blue-600 hover:text-blue-700">英卓气象站</a>
|
||||
<a href="/radar/imdroid" class="text-blue-600 hover:text-blue-700">英卓雷达站</a>
|
||||
<!-- <a href="/radar/nanning" class="text-blue-600 hover:text-blue-700">南宁雷达站</a>-->
|
||||
<!-- <a href="/radar/guangzhou" class="text-blue-600 hover:text-blue-700">广州雷达站</a>-->
|
||||
<!-- <a href="/radar/panyu" class="text-blue-600 hover:text-blue-700">番禺雷达站</a>-->
|
||||
<!-- <a href="/radar/haizhu" class="text-blue-600 hover:text-blue-700">海珠雷达站</a>-->
|
||||
</nav>
|
||||
</div>
|
||||
<style>
|
||||
.content-narrow { max-width: 1200px; }
|
||||
</style>
|
||||
</div>
|
||||
{{ end }}
|
||||
978
templates/imdroid_radar.html
Normal file
978
templates/imdroid_radar.html
Normal file
@ -0,0 +1,978 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 0; }
|
||||
.content-narrow { width: 86%; max-width: 1200px; margin: 0 auto; }
|
||||
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 20px; }
|
||||
.plot-box { width: clamp(320px, 72vw, 680px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
|
||||
.plot-box-sm { width: clamp(320px, 65vw, 612px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
|
||||
@supports not (aspect-ratio: 1 / 1) { .plot-box { height: 520px; } }
|
||||
</style>
|
||||
<script src="/static/js/plotly-2.27.0.min.js"></script>
|
||||
<script>
|
||||
let gZ=0, gY=0, gX=0;
|
||||
let gTimes = [];
|
||||
let gCurrentIdx = -1;
|
||||
let gAlias = '';
|
||||
// 雨量瓦片(小时累计)当前展示的时间,便于状态同步
|
||||
let gRainDT = '';
|
||||
// 3H 预报相关全局量
|
||||
let gTileValues = null, gXs = null, gYs = null;
|
||||
let gWindFromDeg = null, gWindSpeedMS = null;
|
||||
let gTileDT = null;
|
||||
let gTileDTStr = '';
|
||||
let gStLat = null, gStLon = null;
|
||||
|
||||
async function loadStations() {
|
||||
const sel = document.getElementById('stationSelect');
|
||||
sel.innerHTML = '';
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ''; opt.textContent = '请选择站点…';
|
||||
sel.appendChild(opt);
|
||||
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 = a.alias;
|
||||
o.textContent = a.alias;
|
||||
o.dataset.lat = a.lat;
|
||||
o.dataset.lon = a.lon;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function setRealtimeBox(j){
|
||||
const set = (id, v) => document.getElementById(id).textContent = (v ?? '') === '' ? '' : String(v);
|
||||
set('rt_alias', j.alias);
|
||||
set('rt_dt', j.dt);
|
||||
set('rt_lat', j.lat);
|
||||
set('rt_lon', j.lon);
|
||||
if (typeof j.temperature === 'number') set('rt_t', j.temperature.toFixed(2)); else set('rt_t','');
|
||||
if (typeof j.humidity === 'number') set('rt_h', j.humidity.toFixed(0)*100); else set('rt_h','');
|
||||
if (typeof j.wind_speed === 'number') set('rt_ws', j.wind_speed.toFixed(2)); else set('rt_ws','');
|
||||
if (typeof j.wind_direction === 'number') set('rt_wd', j.wind_direction.toFixed(0)); else set('rt_wd','');
|
||||
if (typeof j.cloudrate === 'number') set('rt_c', j.cloudrate.toFixed(2)*100); else set('rt_c','');
|
||||
if (typeof j.visibility === 'number') set('rt_vis', j.visibility.toFixed(2)); else set('rt_vis','');
|
||||
if (typeof j.dswrf === 'number') set('rt_dswrf', j.dswrf.toFixed(1)); else set('rt_dswrf','');
|
||||
if (typeof j.pressure === 'number') set('rt_p', j.pressure.toFixed(0)); else set('rt_p','');
|
||||
if (typeof j.wind_direction === 'number') gWindFromDeg = Number(j.wind_direction);
|
||||
if (typeof j.wind_speed === 'number') gWindSpeedMS = Number(j.wind_speed);
|
||||
}
|
||||
|
||||
async function loadRealtime(alias) {
|
||||
const res = await fetch(`/api/radar/weather_latest?alias=${encodeURIComponent(alias)}`);
|
||||
if (!res.ok) throw new Error('实时数据不存在');
|
||||
const j = await res.json();
|
||||
setRealtimeBox(j);
|
||||
}
|
||||
|
||||
async function loadRealtimeNearest(alias, dtStr) {
|
||||
const res = await fetch(`/api/radar/weather_nearest?prefer=lte&alias=${encodeURIComponent(alias)}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if (!res.ok) return false;
|
||||
const j = await res.json();
|
||||
setRealtimeBox(j);
|
||||
const bar = document.getElementById('rt_stale');
|
||||
if (bar) {
|
||||
if (j.stale) { bar.classList.remove('hidden'); }
|
||||
else { bar.classList.add('hidden'); }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadLatestTile(z, y, x) {
|
||||
const status = document.getElementById('tile_status');
|
||||
const res = await fetch(`/api/radar/latest?z=${z}&y=${y}&x=${x}`);
|
||||
if (!res.ok) {
|
||||
status.textContent = '未找到瓦片';
|
||||
// 当没有瓦片可用时,清空附近降雨提示,避免遗留旧提示
|
||||
const nb = document.getElementById('nearbyStatus');
|
||||
if (nb) { nb.textContent=''; nb.classList.remove('text-red-700'); nb.classList.add('text-gray-700'); }
|
||||
return;
|
||||
}
|
||||
const t = await res.json();
|
||||
const fmt = (n, d=5)=> Number(n).toFixed(d);
|
||||
document.getElementById('tile_dt').textContent = t.dt;
|
||||
document.getElementById('tile_z').textContent = t.z;
|
||||
document.getElementById('tile_y').textContent = t.y;
|
||||
document.getElementById('tile_x').textContent = t.x;
|
||||
document.getElementById('tile_w').textContent = fmt(t.west);
|
||||
document.getElementById('tile_s').textContent = fmt(t.south);
|
||||
document.getElementById('tile_e').textContent = fmt(t.east);
|
||||
document.getElementById('tile_n').textContent = fmt(t.north);
|
||||
document.getElementById('tile_res').textContent = fmt(t.res_deg, 6);
|
||||
status.textContent = '';
|
||||
renderTilePlot(t);
|
||||
// 同步:按北京时间整点对齐加载小时雨量(优先从列表选择 ≤ 该整点)
|
||||
try { await loadRainAligned(z,y,x, t.dt); } catch(e) { /* ignore */ }
|
||||
const idx = gTimes.indexOf(t.dt);
|
||||
if (idx >= 0) {
|
||||
gCurrentIdx = idx;
|
||||
updateCountAndButtons();
|
||||
updateSlider();
|
||||
const sel = document.getElementById('timeSelect');
|
||||
sel.value = t.dt;
|
||||
}
|
||||
// 同步气象:就近(优先<=)匹配该瓦片时间
|
||||
if (gAlias) { try { await loadRealtimeNearest(gAlias, t.dt); } catch(e){} }
|
||||
maybeCalcSector();
|
||||
maybePlotSquare();
|
||||
maybeCalcNearbyRain();
|
||||
maybeCalcTileRegionStats();
|
||||
}
|
||||
|
||||
async function loadTileAt(z, y, x, dtStr) {
|
||||
const status = document.getElementById('tile_status');
|
||||
const res = await fetch(`/api/radar/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if (!res.ok) {
|
||||
status.textContent = '未找到瓦片';
|
||||
// 清空附近降雨提示,避免遗留
|
||||
const nb = document.getElementById('nearbyStatus');
|
||||
if (nb) { nb.textContent=''; nb.classList.remove('text-red-700'); nb.classList.add('text-gray-700'); }
|
||||
return;
|
||||
}
|
||||
const t = await res.json();
|
||||
const fmt = (n, d=5)=> Number(n).toFixed(d);
|
||||
document.getElementById('tile_dt').textContent = t.dt;
|
||||
document.getElementById('tile_z').textContent = t.z;
|
||||
document.getElementById('tile_y').textContent = t.y;
|
||||
document.getElementById('tile_x').textContent = t.x;
|
||||
document.getElementById('tile_w').textContent = fmt(t.west);
|
||||
document.getElementById('tile_s').textContent = fmt(t.south);
|
||||
document.getElementById('tile_e').textContent = fmt(t.east);
|
||||
document.getElementById('tile_n').textContent = fmt(t.north);
|
||||
document.getElementById('tile_res').textContent = fmt(t.res_deg, 6);
|
||||
status.textContent = '';
|
||||
renderTilePlot(t);
|
||||
// 同步:按北京时间整点对齐加载小时雨量(优先从列表选择 ≤ 该整点)
|
||||
try { await loadRainAligned(z,y,x, t.dt); } catch(e) { /* ignore */ }
|
||||
if (gAlias) { try { await loadRealtimeNearest(gAlias, t.dt); } catch(e){} }
|
||||
maybeCalcSector();
|
||||
maybePlotSquare();
|
||||
maybeCalcNearbyRain();
|
||||
maybeCalcTileRegionStats();
|
||||
}
|
||||
|
||||
// ---- 小时雨量(CMPA)渲染 ----
|
||||
async function loadLatestRainTile(z, y, x){
|
||||
const status = document.getElementById('rain_tile_status');
|
||||
const res = await fetch(`/api/rain/latest?z=${z}&y=${y}&x=${x}`);
|
||||
if(!res.ok){ status.textContent='未找到雨量瓦片'; return; }
|
||||
const t = await res.json();
|
||||
fillRainMetaAndPlot(t);
|
||||
}
|
||||
|
||||
function floorHourStr(dtStr){
|
||||
if(!dtStr) return '';
|
||||
const m = dtStr.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}):\d{2}:\d{2}$/);
|
||||
if(!m) return dtStr;
|
||||
return `${m[1]} ${m[2]}:00:00`;
|
||||
}
|
||||
|
||||
|
||||
async function loadNearestRainTile(z, y, x, dtStr){
|
||||
const status = document.getElementById('rain_tile_status');
|
||||
if(!dtStr){ return loadLatestRainTile(z,y,x); }
|
||||
// 将查询时间按北京时间整点对齐,提高匹配准确性
|
||||
const base = floorHourStr(dtStr);
|
||||
let url = `/api/rain/nearest?prefer=lte&tolerance_min=120&z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`;
|
||||
let res = await fetch(url);
|
||||
if(!res.ok){
|
||||
// 二次尝试:放宽容差到 24 小时
|
||||
url = `/api/rain/nearest?prefer=lte&tolerance_min=1440&z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`;
|
||||
res = await fetch(url);
|
||||
}
|
||||
if(!res.ok){
|
||||
// 优雅降级:若有时间列表,下拉默认第一项
|
||||
const sel = document.getElementById('rainTimeSelect');
|
||||
if (sel && sel.options.length > 1) {
|
||||
const first = sel.options[1].value;
|
||||
sel.value = first;
|
||||
await loadRainAt(z,y,x, first);
|
||||
return;
|
||||
}
|
||||
status.textContent='未找到匹配的雨量瓦片';
|
||||
return;
|
||||
}
|
||||
const t = await res.json();
|
||||
fillRainMetaAndPlot(t);
|
||||
}
|
||||
|
||||
function compareDTStr(a, b){
|
||||
// a,b 格式 "YYYY-MM-DD HH:MM:SS",直接字符串比较即可
|
||||
return a === b ? 0 : (a < b ? -1 : 1);
|
||||
}
|
||||
|
||||
async function loadRainAligned(z, y, x, dtStr){
|
||||
const sel = document.getElementById('rainTimeSelect');
|
||||
const status = document.getElementById('rain_tile_status');
|
||||
if(!dtStr){ return loadLatestRainTile(z,y,x); }
|
||||
const base = floorHourStr(dtStr);
|
||||
// 优先从下拉(倒序)中选择第一个 <= base 的时次
|
||||
if (sel && sel.options.length > 1){
|
||||
for (let i=1; i<sel.options.length; i++){
|
||||
const v = sel.options[i].value;
|
||||
if (compareDTStr(v, base) <= 0){ sel.value = v; await loadRainAt(z,y,x, v); return; }
|
||||
}
|
||||
// 未命中则继续兜底
|
||||
}
|
||||
// 兜底:尝试 exact at(base),失败再 lte 24h,再 latest
|
||||
let res = await fetch(`/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`);
|
||||
if (res.ok){ const t = await res.json(); fillRainMetaAndPlot(t); return; }
|
||||
res = await fetch(`/api/rain/nearest?prefer=lte&tolerance_min=1440&z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`);
|
||||
if (res.ok){ const t = await res.json(); fillRainMetaAndPlot(t); return; }
|
||||
if (sel && sel.options.length > 1){ const first = sel.options[1].value; sel.value = first; await loadRainAt(z,y,x, first); return; }
|
||||
status.textContent='未找到匹配的雨量瓦片';
|
||||
}
|
||||
|
||||
function fillRainMetaAndPlot(t){
|
||||
const fmt=(n,d=5)=> Number(n).toFixed(d);
|
||||
document.getElementById('rain_tile_dt').textContent = t.dt || '';
|
||||
document.getElementById('rain_tile_z').textContent = t.z ?? '';
|
||||
document.getElementById('rain_tile_y').textContent = t.y ?? '';
|
||||
document.getElementById('rain_tile_x').textContent = t.x ?? '';
|
||||
document.getElementById('rain_tile_w').textContent = fmt(t.west);
|
||||
document.getElementById('rain_tile_s').textContent = fmt(t.south);
|
||||
document.getElementById('rain_tile_e').textContent = fmt(t.east);
|
||||
document.getElementById('rain_tile_n').textContent = fmt(t.north);
|
||||
document.getElementById('rain_tile_res').textContent = fmt(t.res_deg, 6);
|
||||
const status = document.getElementById('rain_tile_status');
|
||||
if (status) status.textContent='';
|
||||
gRainDT = t.dt || '';
|
||||
// 同步下拉:若该 dt 存在于列表,选中它
|
||||
try{
|
||||
const sel = document.getElementById('rainTimeSelect');
|
||||
if (sel && gRainDT) {
|
||||
for (let i=0;i<sel.options.length;i++){
|
||||
if (sel.options[i].value === gRainDT){ sel.value = gRainDT; break; }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
renderRainTilePlot(t);
|
||||
}
|
||||
|
||||
function renderRainTilePlot(t){
|
||||
if(!t || !t.values) return;
|
||||
const w=t.width, h=t.height, resDeg=t.res_deg;
|
||||
const west=t.west, south=t.south;
|
||||
const xs=new Array(w); for(let c=0;c<w;c++) xs[c] = west + (c+0.5)*resDeg;
|
||||
const ys=new Array(h); for(let r=0;r<h;r++) ys[r] = south + (r+0.5)*resDeg;
|
||||
|
||||
// 色带按照给定定义,增加 index=0 作为 0mm 的纯白
|
||||
// 之后 14 段与示例保持一致
|
||||
const bandColors = [
|
||||
'rgba(255, 255, 255, 1.0)', // 0 mm 专用(白色)
|
||||
'rgba(126, 212, 121, 0.78)', // (0,5)
|
||||
'rgba(110, 200, 109, 0.78)', // 5–7.5
|
||||
'rgba(97, 169, 97, 0.78)', // 7.5–10
|
||||
'rgba(81, 148, 76, 0.78)', // 10–12.5
|
||||
'rgba(90, 158, 112, 0.78)', // 12.5–15
|
||||
'rgba(143, 194, 254, 0.78)', // 15–17.5
|
||||
'rgba(92, 134, 245, 0.78)', // 17.5–20
|
||||
'rgba(66, 87, 240, 0.78)', // 20–25
|
||||
'rgba(45, 48, 214, 0.78)', // 25–30
|
||||
'rgba(26, 15, 166, 0.78)', // 30–40
|
||||
'rgba(63, 22, 145, 0.78)', // 40–50
|
||||
'rgba(191, 70, 148, 0.78)', // 50–75
|
||||
'rgba(213, 1, 146, 0.78)', // 75–100
|
||||
'rgba(213, 1, 146, 0.78)' // >=100(饱和)
|
||||
];
|
||||
// 非零阈值分割(mm):用于映射到上面颜色段(索引需 +1)
|
||||
const edges = [0,5,7.5,10,12.5,15,17.5,20,25,30,40,50,75,100, Infinity];
|
||||
|
||||
// 量化到颜色段,同时保留原值用于 hover
|
||||
const zBins = []; const custom = [];
|
||||
for(let r=0;r<h;r++){
|
||||
const row = t.values[r] || [];
|
||||
const rowBins = []; const rowCustom = [];
|
||||
for(let c=0;c<w;c++){
|
||||
const v = row[c];
|
||||
if(v==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
let mm = Number(v); if(mm<0) mm=0;
|
||||
let idx = 0;
|
||||
if (mm === 0) {
|
||||
idx = 0; // 白色 0mm
|
||||
} else {
|
||||
let nz = 0; while(nz < edges.length-1 && !(mm>=edges[nz] && mm<edges[nz+1])) nz++;
|
||||
idx = Math.min(nz + 1, bandColors.length - 1);
|
||||
}
|
||||
rowBins.push(idx);
|
||||
rowCustom.push([r,c,mm]);
|
||||
}
|
||||
zBins.push(rowBins); custom.push(rowCustom);
|
||||
}
|
||||
// 将 14 段映射为均匀 positions 的 colorscale
|
||||
const colorscale = [];
|
||||
for(let i=0;i<bandColors.length;i++){
|
||||
const tpos = bandColors.length===1 ? 0 : i/(bandColors.length-1);
|
||||
colorscale.push([tpos, bandColors[i]]);
|
||||
}
|
||||
const data=[{
|
||||
type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom,
|
||||
colorscale: colorscale, zmin:0, zmax:bandColors.length-1,
|
||||
colorbar:{
|
||||
orientation:'h', x:0.5, y:-0.12, xanchor:'center', yanchor:'top',
|
||||
len:0.8, thickness:16, title:{text:'mm', side:'bottom'},
|
||||
// 对应 5/10/15/20/30/50/100 的分段索引:在加入 0mm 白色后需整体 +1
|
||||
tickmode:'array', tickvals:[1,3,5,7,9,11,13], ticktext:['5','10','15','20','30','50','100']
|
||||
},
|
||||
hovertemplate: 'lon=%{x:.3f}, lat=%{y:.3f}<br>rain=%{customdata[2]:.1f} mm<extra></extra>'
|
||||
}];
|
||||
const el=document.getElementById('rain_tile_plot');
|
||||
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90},
|
||||
xaxis:{title:{text:'经度', standoff: 12}, tickformat:'.2f', constrain:'domain', automargin:true},
|
||||
yaxis:{title:{text:'纬度', standoff: 12}, tickformat:'.2f', showticklabels:true, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true}
|
||||
};
|
||||
Plotly.newPlot(el, data, layout, {responsive:true, displayModeBar:false}).then(()=>{
|
||||
const s = el.clientWidth; Plotly.relayout(el,{height:s});
|
||||
});
|
||||
window.addEventListener('resize', ()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); Plotly.Plots.resize(el); });
|
||||
}
|
||||
|
||||
async function populateRainTimes(z, y, x, fromStr, toStr){
|
||||
try{
|
||||
let url = `/api/rain/times?z=${z}&y=${y}&x=${x}`;
|
||||
if (fromStr && toStr) { url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`; }
|
||||
else { url += '&limit=60'; }
|
||||
const res = await fetch(url);
|
||||
if(!res.ok) return;
|
||||
const j = await res.json();
|
||||
const sel = document.getElementById('rainTimeSelect');
|
||||
if (!sel) return;
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
const times = j.times || [];
|
||||
times.forEach(dt=>{ const opt=document.createElement('option'); opt.value=dt; opt.textContent=dt; sel.appendChild(opt); });
|
||||
// 若当前已选中的 gRainDT 在列表里,则保持选中
|
||||
if (gRainDT){ for(let i=0;i<sel.options.length;i++){ if(sel.options[i].value===gRainDT){ sel.value=gRainDT; break; } } }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadRainAt(z, y, x, dtStr){
|
||||
const status = document.getElementById('rain_tile_status');
|
||||
if(!dtStr){ return loadNearestRainTile(z,y,x, (gTileDTStr || '')); }
|
||||
const res = await fetch(`/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(!res.ok){ status.textContent='未找到雨量瓦片'; return; }
|
||||
const t = await res.json();
|
||||
fillRainMetaAndPlot(t);
|
||||
}
|
||||
|
||||
function fmtDTLocal(dt){
|
||||
const pad = (n)=> String(n).padStart(2,'0');
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||
}
|
||||
function fromDTLocalInput(s){
|
||||
if(!s) return null;
|
||||
const t = new Date(s.replace('T','-').replace(/-/g,'/'));
|
||||
const pad=(n)=> String(n).padStart(2,'0');
|
||||
return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`;
|
||||
}
|
||||
|
||||
async function populateTimes(z, y, x, fromStr, toStr) {
|
||||
try {
|
||||
let url = `/api/radar/times?z=${z}&y=${y}&x=${x}`;
|
||||
if (fromStr && toStr) {
|
||||
url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`;
|
||||
} else {
|
||||
url += '&limit=60';
|
||||
}
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
const sel = document.getElementById('timeSelect');
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
gTimes = j.times || [];
|
||||
gTimes.forEach(dt => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = dt; opt.textContent = dt; sel.appendChild(opt);
|
||||
});
|
||||
if (gTimes.length > 0) {
|
||||
sel.value = gTimes[0];
|
||||
gCurrentIdx = 0;
|
||||
} else {
|
||||
gCurrentIdx = -1;
|
||||
}
|
||||
updateCountAndButtons();
|
||||
updateSlider();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function updateCountAndButtons(){
|
||||
const info = document.getElementById('countInfo');
|
||||
const total = gTimes.length;
|
||||
const idxDisp = gCurrentIdx >= 0 ? (gCurrentIdx+1) : 0;
|
||||
info.textContent = `共${total}条,第${idxDisp}条`;
|
||||
const prev = document.getElementById('btnPrev');
|
||||
const next = document.getElementById('btnNext');
|
||||
// gTimes 按时间倒序(最新在前),上一时次=更老 => 允许在 idx < total-1
|
||||
if (prev) prev.disabled = !(total > 0 && gCurrentIdx >= 0 && gCurrentIdx < total - 1);
|
||||
// 下一时次=更近 => 允许在 idx > 0
|
||||
if (next) next.disabled = !(total > 0 && gCurrentIdx > 0);
|
||||
}
|
||||
|
||||
function updateSlider(){
|
||||
const slider = document.getElementById('timeSlider');
|
||||
if (!slider) return;
|
||||
const total = gTimes.length;
|
||||
slider.max = total > 0 ? String(total-1) : '0';
|
||||
// 最新(gCurrentIdx=0)时,滑块在最右端(value=max)
|
||||
slider.value = gCurrentIdx >= 0 ? String((total-1) - gCurrentIdx) : '0';
|
||||
}
|
||||
|
||||
function dtToBucket10(dtStr){
|
||||
// dtStr: "YYYY-MM-DD HH:MM:SS"
|
||||
if (!dtStr) return '';
|
||||
const parts = dtStr.split(/[- :]/g);
|
||||
if (parts.length < 6) return '';
|
||||
const y=+parts[0], m=+parts[1]-1, d=+parts[2], hh=+parts[3], mm=+parts[4];
|
||||
const date = new Date(y, m, d, hh, mm, 0, 0);
|
||||
const bucketMin = Math.floor(mm/10)*10;
|
||||
date.setMinutes(bucketMin, 0, 0);
|
||||
const pad=(n)=> String(n).padStart(2,'0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:00`;
|
||||
}
|
||||
|
||||
function renderTilePlot(t) {
|
||||
if (!t || !t.values) return;
|
||||
const w = t.width, h = t.height;
|
||||
const resDeg = t.res_deg;
|
||||
const west = t.west, south = t.south;
|
||||
const xs = new Array(w);
|
||||
for (let c = 0; c < w; c++) xs[c] = west + (c + 0.5) * resDeg;
|
||||
const ys = new Array(h);
|
||||
for (let r = 0; r < h; r++) ys[r] = south + (r + 0.5) * resDeg;
|
||||
// 保存到全局供后续计算
|
||||
gTileValues = t.values; gXs = xs; gYs = ys;
|
||||
try { gTileDT = new Date((t.dt || '').replace(/-/g,'/')); gTileDTStr = t.dt || ''; } catch { gTileDTStr = t.dt || ''; }
|
||||
const colors = ["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||
// 构建离散色阶(0..14)+ customdata(用于 hover 展示 dBZ)
|
||||
const zBins = []; const custom = [];
|
||||
for (let r = 0; r < h; r++) {
|
||||
const row = t.values[r]; const rowBins = []; const rowCustom = [];
|
||||
for (let c = 0; c < w; c++) {
|
||||
const val = row[c];
|
||||
if (val == null) { rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
let dbz = Number(val); if (dbz < 0) dbz = 0; if (dbz > 75) dbz = 75;
|
||||
let bin = Math.floor(dbz / 5); if (bin >= 15) bin = 14;
|
||||
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||
}
|
||||
zBins.push(rowBins); custom.push(rowCustom);
|
||||
}
|
||||
const scale = []; for (let i = 0; i < colors.length; i++) { const tpos = colors.length === 1 ? 0 : i / (colors.length - 1); scale.push([tpos, colors[i]]); }
|
||||
const data = [{
|
||||
type: 'heatmap', x: xs, y: ys, z: zBins, customdata: custom,
|
||||
colorscale: scale, zmin: 0, zmax: 14,
|
||||
colorbar: {
|
||||
orientation: 'h', x: 0.5, y: -0.12, xanchor: 'center', yanchor: 'top',
|
||||
len: 0.8, thickness: 16, title: { text: 'dBZ', side: 'bottom' },
|
||||
tickmode: 'array', tickvals: Array.from({length:15}, (_,i)=>i), ticktext: Array.from({length:15}, (_,i)=>String(i*5))
|
||||
},
|
||||
hovertemplate: 'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>'
|
||||
}];
|
||||
// 叠加扇形区域
|
||||
if (gWindFromDeg !== null && gWindSpeedMS > 0 && gStLat !== null && gStLon !== null) {
|
||||
const half = 30, samples = 64, start = gWindFromDeg - half, end = gWindFromDeg + half, rangeM = gWindSpeedMS * 3 * 3600;
|
||||
const xsFan = [gStLon], ysFan = [gStLat];
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const θ = start + (end - start) * (i / samples);
|
||||
const p = destPoint(gStLat, gStLon, ((θ % 360) + 360) % 360, rangeM);
|
||||
xsFan.push(p.lon); ysFan.push(p.lat);
|
||||
}
|
||||
xsFan.push(gStLon); ysFan.push(gStLat);
|
||||
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{ color:'#FFFFFF', width:2, dash:'dash' }, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||
}
|
||||
// 叠加附近5km圆
|
||||
if (gStLat !== null && gStLon !== null) {
|
||||
const Rm = 8000; const samples=128; const xsC=[], ysC=[];
|
||||
for(let i=0;i<=samples;i++){ const θ=i*(360/samples); const p=destPoint(gStLat,gStLon,θ,Rm); xsC.push(p.lon); ysC.push(p.lat); }
|
||||
data.push({ type:'scatter', mode:'lines', x:xsC, y:ysC, line:{ color:'#66CC66', width:1, dash:'dot' }, hoverinfo:'skip', showlegend:false });
|
||||
}
|
||||
Plotly.newPlot('tile_plot', data, {
|
||||
margin:{l:36,r:8,t:8,b:90},
|
||||
xaxis:{title:{text:'经度', standoff: 12}, tickformat:'.2f', constrain:'domain', automargin:true},
|
||||
yaxis:{title:{text:'纬度', standoff: 12}, tickformat:'.2f', showticklabels: true, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true}
|
||||
}, {responsive:true, displayModeBar:false}).then(()=>{
|
||||
const el = document.getElementById('tile_plot'); const s = el.clientWidth; Plotly.relayout(el,{height:s});
|
||||
});
|
||||
window.addEventListener('resize',()=>{ const el=document.getElementById('tile_plot'); const s=el.clientWidth; Plotly.relayout(el,{height:s}); Plotly.Plots.resize(el); });
|
||||
}
|
||||
|
||||
// 地理与3H预报工具
|
||||
function toRad(d){ return d * Math.PI / 180; }
|
||||
function toDeg(r){ return r * 180 / Math.PI; }
|
||||
function haversine(lat1, lon1, lat2, lon2){
|
||||
const R=6371000; const dLat=toRad(lat2-lat1); const dLon=toRad(lon2-lon1);
|
||||
const a=Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
|
||||
const c=2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a)); return R*c;
|
||||
}
|
||||
function bearingDeg(lat1, lon1, lat2, lon2){
|
||||
const φ1=toRad(lat1), φ2=toRad(lat2), Δλ=toRad(lon2-lon1);
|
||||
const y=Math.sin(Δλ)*Math.cos(φ2); const x=Math.cos(φ1)*Math.sin(φ2)-Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
|
||||
return (toDeg(Math.atan2(y,x))+360)%360;
|
||||
}
|
||||
function angDiff(a,b){ let d=((a-b+540)%360)-180; return Math.abs(d); }
|
||||
function destPoint(lat, lon, brgDeg, distM){
|
||||
const R=6371000, δ=distM/R, θ=toRad(brgDeg), φ1=toRad(lat), λ1=toRad(lon);
|
||||
const sinφ1=Math.sin(φ1), cosφ1=Math.cos(φ1), sinδ=Math.sin(δ), cosδ=Math.cos(δ);
|
||||
const sinφ2=sinφ1*cosδ+cosφ1*sinδ*Math.cos(θ); const φ2=Math.asin(sinφ2);
|
||||
const y=Math.sin(θ)*sinδ*cosφ1; const x=cosδ-sinφ1*sinφ2; const λ2=λ1+Math.atan2(y,x);
|
||||
return { lat: toDeg(φ2), lon: ((toDeg(λ2)+540)%360)-180 };
|
||||
}
|
||||
|
||||
function maybeCalcSector(){
|
||||
try{
|
||||
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null || gStLat===null || gStLon===null) return;
|
||||
const halfAngle=30; const rangeM=gWindSpeedMS*3*3600; let best=null;
|
||||
const h=gTileValues.length, w=gTileValues[0].length;
|
||||
for(let r=0;r<h;r++){
|
||||
const lat=gYs[r]; for(let c=0;c<w;c++){
|
||||
const val=gTileValues[r][c]; if(val==null) continue; const dbz=Number(val); if(!(dbz>=40)) continue;
|
||||
const lon=gXs[c]; const dist=haversine(gStLat,gStLon,lat,lon); if(dist>rangeM) continue;
|
||||
const brg=bearingDeg(gStLat,gStLon,lat,lon); if(angDiff(brg,gWindFromDeg)>halfAngle) continue;
|
||||
if(!best || dist<best.dist){ best={dist,lat,lon,dbz}; }
|
||||
}
|
||||
}
|
||||
const statusEl=document.getElementById('sectorStatus'); const detailEl=document.getElementById('sectorDetail');
|
||||
if(!best){ statusEl.textContent='无(≥40 dBZ)'; detailEl.classList.add('hidden'); }
|
||||
else {
|
||||
const etaSec=best.dist/gWindSpeedMS;
|
||||
const base = gTileDT instanceof Date ? gTileDT : new Date();
|
||||
const eta=new Date(base.getTime()+etaSec*1000);
|
||||
const pad=(n)=>String(n).padStart(2,'0'); const etaStr=`${eta.getFullYear()}-${pad(eta.getMonth()+1)}-${pad(eta.getDate())} ${pad(eta.getHours())}:${pad(eta.getMinutes())}`;
|
||||
document.getElementById('sectorDist').textContent=(best.dist/1000).toFixed(1);
|
||||
document.getElementById('sectorETA').textContent=etaStr;
|
||||
document.getElementById('sectorLat').textContent=Number(best.lat).toFixed(4);
|
||||
document.getElementById('sectorLon').textContent=Number(best.lon).toFixed(4);
|
||||
document.getElementById('sectorDBZ').textContent=Number(best.dbz).toFixed(1);
|
||||
statusEl.textContent='三小时内可能有降雨(≥40 dBZ )'; detailEl.classList.remove('hidden');
|
||||
}
|
||||
}catch(e){ document.getElementById('sectorStatus').textContent='计算失败:'+e.message; }
|
||||
}
|
||||
|
||||
function maybePlotSquare(){
|
||||
try{
|
||||
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null || gStLat===null || gStLon===null) return;
|
||||
const R=6371000; const rangeM=gWindSpeedMS*3*3600;
|
||||
const dLat=(rangeM/R)*(180/Math.PI);
|
||||
const dLon=(rangeM/(R*Math.cos(toRad(gStLat))))*(180/Math.PI);
|
||||
const latMin=gStLat-dLat, latMax=gStLat+dLat, lonMin=gStLon-dLon, lonMax=gStLon+dLon;
|
||||
const xs=gXs, ys=gYs; const h=gTileValues.length,w=gTileValues[0].length;
|
||||
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
|
||||
const zBins=[], custom=[];
|
||||
for(let r=0;r<h;r++){
|
||||
const row=gTileValues[r]; const rowBins=[], rowCustom=[]; const lat=ys[r];
|
||||
for(let c=0;c<w;c++){
|
||||
const lon=xs[c]; if(lat<latMin||lat>latMax||lon<lonMin||lon>lonMax){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
const val=row[c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
|
||||
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||
}
|
||||
zBins.push(rowBins); custom.push(rowCustom);
|
||||
}
|
||||
const data=[{ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
|
||||
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
|
||||
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
|
||||
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' }];
|
||||
if(gWindFromDeg!==null && gWindSpeedMS>0){
|
||||
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half;
|
||||
const xsFan=[gStLon], ysFan=[gStLat];
|
||||
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(gStLat,gStLon,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
|
||||
xsFan.push(gStLon); ysFan.push(gStLat);
|
||||
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||
}
|
||||
// 叠加附近8km圆
|
||||
{
|
||||
const Rm = 8000; const samples=128; const xsC=[], ysC=[];
|
||||
for(let i=0;i<=samples;i++){ const θ=i*(360/samples); const p=destPoint(gStLat,gStLon,θ,Rm); xsC.push(p.lon); ysC.push(p.lat); }
|
||||
data.push({ type:'scatter', mode:'lines', x:xsC, y:ysC, line:{ color:'#66CC66', width:1, dash:'dot' }, hoverinfo:'skip', showlegend:false });
|
||||
}
|
||||
const el=document.getElementById('squarePlot');
|
||||
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90},
|
||||
xaxis:{title:'经度', tickformat:'.2f', zeroline:false, constrain:'domain', automargin:true, range:[lonMin, lonMax]},
|
||||
yaxis:{title:{text:'纬度',standoff:12}, tickformat:'.2f', zeroline:false, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true, range:[latMin, latMax]},
|
||||
shapes:[{type:'rect',xref:'x',yref:'y',x0:lonMin,x1:lonMax,y0:latMin,y1:latMax,line:{color:'#111',width:1,dash:'dot'},fillcolor:'rgba(0,0,0,0)'}] };
|
||||
Plotly.newPlot(el, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); });
|
||||
window.addEventListener('resize',()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); Plotly.Plots.resize(el); });
|
||||
}catch(e){ document.getElementById('squarePlot').innerHTML=`<div class=\"p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded\">正方形热力图渲染失败:${e.message}</div>`; }
|
||||
}
|
||||
|
||||
function maybeCalcNearbyRain(){
|
||||
try{
|
||||
const el = document.getElementById('nearbyStatus'); if(!el) return;
|
||||
if(!gTileValues || !gXs || !gYs || gStLat===null || gStLon===null){
|
||||
// 数据不足时清空提示并恢复默认样式
|
||||
el.textContent='';
|
||||
el.classList.remove('text-red-700');
|
||||
el.classList.add('text-gray-700');
|
||||
return;
|
||||
}
|
||||
const radiusM = 8000; // 8km
|
||||
const h=gTileValues.length, w=gTileValues[0].length;
|
||||
let hit=false, maxDBZ=null;
|
||||
for(let r=0;r<h && !hit;r++){
|
||||
const lat=gYs[r];
|
||||
for(let c=0;c<w;c++){
|
||||
const val=gTileValues[r][c]; if(val==null) continue; const dbz=Number(val); if(!(dbz>=40)) continue;
|
||||
const lon=gXs[c]; const dist=haversine(gStLat,gStLon,lat,lon);
|
||||
if(dist <= radiusM){ hit=true; maxDBZ = maxDBZ==null?dbz:Math.max(maxDBZ, dbz); break; }
|
||||
}
|
||||
}
|
||||
if(hit){
|
||||
el.textContent = `附近8公里内检测到大于等于 40dBz 的雷达反射率,短时间内可能会下雨`;
|
||||
el.classList.remove('text-gray-700');
|
||||
el.classList.add('text-red-700');
|
||||
} else {
|
||||
// 未命中时清空并恢复默认样式,避免提示“停留不消失”
|
||||
el.textContent = '';
|
||||
el.classList.remove('text-red-700');
|
||||
el.classList.add('text-gray-700');
|
||||
}
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
|
||||
// 计算并展示:扇形区域与8km圆形区域的 >=40 和 >=30 dBZ 点数与累计值
|
||||
function maybeCalcTileRegionStats(){
|
||||
try{
|
||||
const s40CntEl = document.getElementById('sector_ge40_cnt');
|
||||
if(!s40CntEl) return; // 元素不存在则不计算
|
||||
// 先清空展示
|
||||
const ids=[
|
||||
'sector_ge40_cnt','sector_ge40_sum','sector_ge30_cnt','sector_ge30_sum',
|
||||
'circle_ge40_cnt','circle_ge40_sum','circle_ge30_cnt','circle_ge30_sum'
|
||||
];
|
||||
ids.forEach(id=>{ const el=document.getElementById(id); if(el) el.textContent='-'; });
|
||||
|
||||
if(!gTileValues || !gXs || !gYs || gStLat===null || gStLon===null) return;
|
||||
const h=gTileValues.length, w=gTileValues[0].length;
|
||||
|
||||
// 扇形区域定义:风向 ±30°,半径 = 3 小时移动距离
|
||||
const hasWind = (gWindFromDeg!==null && gWindSpeedMS!==null && gWindSpeedMS>0);
|
||||
const halfAngle=30;
|
||||
const rangeM = hasWind ? (gWindSpeedMS*3*3600) : 0;
|
||||
|
||||
// 圆形区域:半径 8km
|
||||
const circleR = 8000;
|
||||
|
||||
let sec40Cnt=0, sec40Sum=0, sec30Cnt=0, sec30Sum=0;
|
||||
let cir40Cnt=0, cir40Sum=0, cir30Cnt=0, cir30Sum=0;
|
||||
|
||||
for(let r=0;r<h;r++){
|
||||
const lat=gYs[r];
|
||||
for(let c=0;c<w;c++){
|
||||
const v=gTileValues[r][c];
|
||||
if(v==null) continue;
|
||||
const dbz=Number(v);
|
||||
const lon=gXs[c];
|
||||
const dist=haversine(gStLat,gStLon,lat,lon);
|
||||
|
||||
// 圆形区域统计
|
||||
if(dist<=circleR){
|
||||
if(dbz>=40){ cir40Cnt++; cir40Sum+=dbz; }
|
||||
if(dbz>=30){ cir30Cnt++; cir30Sum+=dbz; }
|
||||
}
|
||||
|
||||
// 扇形区域统计(需要风向风速)
|
||||
if(hasWind){
|
||||
if(dist<=rangeM){
|
||||
const brg=bearingDeg(gStLat,gStLon,lat,lon);
|
||||
if(angDiff(brg,gWindFromDeg)<=halfAngle){
|
||||
if(dbz>=40){ sec40Cnt++; sec40Sum+=dbz; }
|
||||
if(dbz>=30){ sec30Cnt++; sec30Sum+=dbz; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新展示
|
||||
const set = (id, val)=>{ const el=document.getElementById(id); if(el) el.textContent=String(val); };
|
||||
if(hasWind){
|
||||
set('sector_ge40_cnt', sec40Cnt); set('sector_ge40_sum', sec40Sum.toFixed(1));
|
||||
set('sector_ge30_cnt', sec30Cnt); set('sector_ge30_sum', sec30Sum.toFixed(1));
|
||||
} else {
|
||||
// 无风场信息时显示提示
|
||||
set('sector_ge40_cnt', '无风场'); set('sector_ge40_sum', '-');
|
||||
set('sector_ge30_cnt', '无风场'); set('sector_ge30_sum', '-');
|
||||
}
|
||||
set('circle_ge40_cnt', cir40Cnt); set('circle_ge40_sum', cir40Sum.toFixed(1));
|
||||
set('circle_ge30_cnt', cir30Cnt); set('circle_ge30_sum', cir30Sum.toFixed(1));
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await loadStations();
|
||||
// 初始化时间范围为最近24小时
|
||||
(function initRange(){
|
||||
const now = new Date();
|
||||
const end = now; // 到现在
|
||||
const start = new Date(now.getTime() - 6*3600*1000); // 6 小时前
|
||||
const pad = (n)=> String(n).padStart(2,'0');
|
||||
const toLocalInput=(d)=> `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const sEl = document.getElementById('tsStart');
|
||||
const eEl = document.getElementById('tsEnd');
|
||||
if (sEl && eEl) { sEl.value = toLocalInput(start); eEl.value = toLocalInput(end); }
|
||||
})();
|
||||
document.getElementById('btnLoad').addEventListener('click', async ()=>{
|
||||
const sel = document.getElementById('stationSelect');
|
||||
if (!sel || !sel.value) return;
|
||||
const aliasText = sel.options[sel.selectedIndex].textContent || sel.value;
|
||||
const aliasParam = 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 = aliasParam;
|
||||
document.getElementById('rt_zyx').textContent = `z=${gZ}, y=${gY}, x=${gX}`;
|
||||
try { await loadRealtime(aliasParam); } catch (e) { console.warn(e); }
|
||||
if (gZ && gY && gX) {
|
||||
const from = fromDTLocalInput(document.getElementById('tsStart').value);
|
||||
const to = fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||
await populateTimes(gZ,gY,gX, from, to);
|
||||
await populateRainTimes(gZ,gY,gX, from, to);
|
||||
await loadLatestTile(gZ,gY,gX);
|
||||
// 同步:加载最新小时雨量(若 radar 未返回 dt,则先尝试最新)
|
||||
try { await loadLatestRainTile(gZ,gY,gX); } catch(e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
const tsQuery = document.getElementById('tsQuery');
|
||||
if (tsQuery) tsQuery.addEventListener('click', async ()=>{
|
||||
if (!(gZ && gY && gX)) return;
|
||||
const from = fromDTLocalInput(document.getElementById('tsStart').value);
|
||||
const to = fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||
await populateTimes(gZ,gY,gX, from, to);
|
||||
await populateRainTimes(gZ,gY,gX, from, to);
|
||||
if (gCurrentIdx >= 0) await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]);
|
||||
});
|
||||
|
||||
const timeSelect = document.getElementById('timeSelect');
|
||||
if (timeSelect) timeSelect.addEventListener('change', async (e)=>{
|
||||
if (!(gZ && gY && gX)) return;
|
||||
const dt = e.target.value;
|
||||
if (!dt) { await loadLatestTile(gZ,gY,gX); return; }
|
||||
gCurrentIdx = gTimes.indexOf(dt);
|
||||
updateCountAndButtons();
|
||||
updateSlider();
|
||||
await loadTileAt(gZ,gY,gX, dt);
|
||||
});
|
||||
|
||||
const timeSlider = document.getElementById('timeSlider');
|
||||
if (timeSlider) timeSlider.addEventListener('input', async (e)=>{
|
||||
if (!(gZ && gY && gX)) return;
|
||||
const total = gTimes.length;
|
||||
const raw = Number(e.target.value);
|
||||
const idx = (total - 1) - raw; // 右端=最新
|
||||
if (idx >= 0 && idx < gTimes.length) {
|
||||
gCurrentIdx = idx;
|
||||
updateCountAndButtons();
|
||||
const sel = document.getElementById('timeSelect');
|
||||
if (sel) sel.value = gTimes[idx] || '';
|
||||
await loadTileAt(gZ,gY,gX, gTimes[idx]);
|
||||
}
|
||||
});
|
||||
|
||||
const rainTimeSelect = document.getElementById('rainTimeSelect');
|
||||
if (rainTimeSelect) rainTimeSelect.addEventListener('change', async (e)=>{
|
||||
if (!(gZ && gY && gX)) return;
|
||||
const dt = e.target.value;
|
||||
if (!dt) {
|
||||
// 自动匹配到当前雷达时次(就近<=)
|
||||
const target = (gCurrentIdx>=0 && gTimes[gCurrentIdx]) ? gTimes[gCurrentIdx] : (gTileDTStr || '');
|
||||
await loadNearestRainTile(gZ,gY,gX, target);
|
||||
} else {
|
||||
await loadRainAt(gZ,gY,gX, dt);
|
||||
}
|
||||
});
|
||||
|
||||
const btnPrev = document.getElementById('btnPrev');
|
||||
if (btnPrev) btnPrev.addEventListener('click', async ()=>{
|
||||
if (!(gZ && gY && gX)) return;
|
||||
// 上一时次:向更老的时间移动(索引+1)
|
||||
if (gCurrentIdx < gTimes.length - 1) {
|
||||
gCurrentIdx++;
|
||||
updateCountAndButtons();
|
||||
updateSlider();
|
||||
const sel = document.getElementById('timeSelect');
|
||||
if (sel) sel.value = gTimes[gCurrentIdx] || '';
|
||||
await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]);
|
||||
}
|
||||
});
|
||||
|
||||
const btnNext = document.getElementById('btnNext');
|
||||
if (btnNext) btnNext.addEventListener('click', async ()=>{
|
||||
if (!(gZ && gY && gX)) return;
|
||||
// 下一时次:向更新的时间移动(索引-1)
|
||||
if (gCurrentIdx > 0) {
|
||||
gCurrentIdx--;
|
||||
updateCountAndButtons();
|
||||
updateSlider();
|
||||
const sel = document.getElementById('timeSelect');
|
||||
if (sel) sel.value = gTimes[gCurrentIdx] || '';
|
||||
await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]);
|
||||
}
|
||||
});
|
||||
}
|
||||
window.addEventListener('DOMContentLoaded', main);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{{ template "header" . }}
|
||||
<div class="content-narrow p-4 text-sm">
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">雷达站</div>
|
||||
<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-[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">
|
||||
<span class="text-sm text-gray-700">时间范围:</span>
|
||||
<input id="tsStart" type="datetime-local" class="border rounded px-2 py-1 text-sm"/>
|
||||
<span>至</span>
|
||||
<input id="tsEnd" type="datetime-local" class="border rounded px-2 py-1 text-sm"/>
|
||||
<button id="tsQuery" class="px-2.5 py-1 text-sm bg-blue-600 text-white rounded">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="text-base font-semibold mb-2">气象数据</div>
|
||||
<div id="rtBox" class="text-sm grid grid-cols-2 gap-y-1.5 gap-x-6">
|
||||
<div>站点:<span id="rt_alias"></span></div>
|
||||
<div>Z/Y/X:<span id="rt_zyx"></span></div>
|
||||
<div>时间:<span id="rt_dt"></span></div>
|
||||
<div>位置:<span id="rt_lat"></span>,<span id="rt_lon"></span></div>
|
||||
<div>温度:<span id="rt_t"></span> ℃</div>
|
||||
<div>湿度:<span id="rt_h"></span> %</div>
|
||||
<div>风速:<span id="rt_ws"></span> m/s</div>
|
||||
<div>风向:<span id="rt_wd"></span> °</div>
|
||||
<div>云量:<span id="rt_c"></span> %</div>
|
||||
<div>能见度:<span id="rt_vis"></span> km</div>
|
||||
<div>下行短波:<span id="rt_dswrf"></span> W/m²</div>
|
||||
<div>气压:<span id="rt_p"></span> Pa</div>
|
||||
</div>
|
||||
<div id="rt_stale" class="mt-2 hidden p-2 text-sm text-yellow-800 bg-yellow-50 border border-yellow-200 rounded">提示:该瓦片时次的就近实况相差超过 2 小时</div>
|
||||
</div>
|
||||
|
||||
<div class="card my-4">
|
||||
<div class="text-lg font-semibold mb-2">未来 3H 预报</div>
|
||||
<div id="sectorInfo" class="text-sm">
|
||||
<div id="sectorStatus">计算中…</div>
|
||||
<div id="sectorDetail" class="mt-1 hidden">
|
||||
最近距离:<span id="sectorDist"></span> km;预计到达时间:<span id="sectorETA"></span><br>
|
||||
位置:lat=<span id="sectorLat"></span>,lon=<span id="sectorLon"></span> 组合反射率:<span id="sectorDBZ"></span> dBZ
|
||||
</div>
|
||||
<div id="nearbyStatus" class="mt-1 text-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="text-base font-semibold mb-2">雷达瓦片</div>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>时间:<span id="tile_dt" class="font-mono"></span></div>
|
||||
<div>索引:z=<span id="tile_z"></span> / y=<span id="tile_y"></span> / x=<span id="tile_x"></span></div>
|
||||
<div>边界:W=<span id="tile_w"></span>,S=<span id="tile_s"></span>,E=<span id="tile_e"></span>,N=<span id="tile_n"></span></div>
|
||||
<div>分辨率(度/像素):<span id="tile_res"></span></div>
|
||||
<div id="tile_status" class="text-gray-500"></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
||||
<button id="btnPrev" class="px-2.5 py-1 text-sm border rounded">上一时次</button>
|
||||
<span id="countInfo" class="text-gray-600 text-sm">共0条,第0条</span>
|
||||
<button id="btnNext" class="px-2.5 py-1 text-sm border rounded">下一时次</button>
|
||||
<select id="timeSelect" class="border rounded px-2 py-1 text-sm min-w-[240px]">
|
||||
<option value="">最新</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-center my-2">
|
||||
<input id="timeSlider" type="range" min="0" max="0" value="0" step="1" class="slider slider-horizontal w-64" />
|
||||
</div>
|
||||
<div id="tile_plot" class="plot-box-sm mt-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="text-base font-semibold mb-2">一小时降雨瓦片</div>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-700">选择雨量时次:</span>
|
||||
<select id="rainTimeSelect" class="border rounded px-2 py-1 text-sm min-w-[240px]">
|
||||
<option value="">自动匹配(就近≤)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>时间:<span id="rain_tile_dt" class="font-mono"></span></div>
|
||||
<div>索引:z=<span id="rain_tile_z"></span> / y=<span id="rain_tile_y"></span> / x=<span id="rain_tile_x"></span></div>
|
||||
<div>边界:W=<span id="rain_tile_w"></span>,S=<span id="rain_tile_s"></span>,E=<span id="rain_tile_e"></span>,N=<span id="rain_tile_n"></span></div>
|
||||
<div>分辨率(度/像素):<span id="rain_tile_res"></span></div>
|
||||
<div id="rain_tile_status" class="text-gray-500"></div>
|
||||
</div>
|
||||
<div id="rain_tile_plot" class="plot-box-sm mt-3"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card mt-4" style="width:100%;">
|
||||
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
||||
<div class="mt-3 text-sm space-y-1">
|
||||
<div class="font-bold">扇形区域统计</div>
|
||||
<span class="text-gray-700">≥40 dBZ:点数 <span id="sector_ge40_cnt">-</span>,累计 <span id="sector_ge40_sum">-</span></span>
|
||||
<span class="text-gray-700"> | ≥30 dBZ:点数 <span id="sector_ge30_cnt">-</span>,累计 <span id="sector_ge30_sum">-</span></span>
|
||||
<div class="font-bold mt-2">圆形区域统计</div>
|
||||
<span class="text-gray-700">≥40 dBZ:点数 <span id="circle_ge40_cnt">-</span>,累计 <span id="circle_ge40_sum">-</span></span>
|
||||
<span class="text-gray-700"> | ≥30 dBZ:点数 <span id="circle_ge30_cnt">-</span>,累计 <span id="circle_ge30_sum">-</span></span>
|
||||
</div>
|
||||
<div id="squarePlot" class="plot-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -3,169 +3,620 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>英卓气象站</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<title>{{.Title}}</title>
|
||||
<script src="/static/js/chart.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/ol.css">
|
||||
<script src="/static/js/ol.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.px-7p {
|
||||
padding-left: 7%;
|
||||
padding-right: 7%;
|
||||
}
|
||||
|
||||
.content-narrow {
|
||||
width: 86%;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-narrow {
|
||||
width: 92%;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 18px 0 12px 0;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
font-size: 2.2rem;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ddd;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.stations {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.station-card {
|
||||
background: #fff;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
padding: 20px 18px 16px 18px;
|
||||
min-width: 260px;
|
||||
max-width: 320px;
|
||||
flex: 1 1 260px;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.station-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#stationInput {
|
||||
width: 120px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
select, input, button {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 60vh;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
transition: height 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-container.collapsed {
|
||||
height: 38vh;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.map-control-btn {
|
||||
padding: 3px 8px;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.map-control-btn:hover {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.map-control-btn.active {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.map-toggle-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1001;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
background-color: #007bff;
|
||||
}
|
||||
|
||||
.station-info-title {
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
background-color: #fff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-container.show {
|
||||
display: block;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
.accuracy-panel {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
color: #374151; /* 灰色文字 */
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.accuracy-panel .item { margin-left: 8px; }
|
||||
.accuracy-panel .label { color: #6b7280; margin-right: 4px; }
|
||||
.accuracy-panel .value { font-weight: 600; color: #111827; }
|
||||
|
||||
.chart-wrapper {
|
||||
height: 500px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-container.show {
|
||||
display: block;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
background-color: #e9ecef;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.device-modal-content {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
width: 90%;
|
||||
max-width: 720px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-list-header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.device-list-footer {
|
||||
padding: 10px;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.station-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
|
||||
.pagination-btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-item:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.device-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: #f5f5f5;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
background-color: #eee;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.station-meta {
|
||||
font-size: 0.95rem;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.station-data {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
.data-label {
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.data-value {
|
||||
font-size: 1.1rem;
|
||||
color: #007bff;
|
||||
|
||||
.station-marker {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #007bff;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
.chart-area {
|
||||
margin-top: 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 10px 0 0 0;
|
||||
|
||||
.station-marker.offline {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.stations { flex-direction: column; }
|
||||
.station-card { max-width: 100%; }
|
||||
|
||||
.station-label {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #007bff;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
select, input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">英卓气象站</div>
|
||||
<div class="container">
|
||||
<div class="stations" id="stations-list">
|
||||
<!-- 站点卡片由JS动态渲染 -->
|
||||
<body x-data="{ showPastForecast: false, deviceModalOpen: false }" class="text-[14px] md:text-[15px]" x-init="window.addEventListener('close-device-modal', () => { deviceModalOpen = false })">
|
||||
{{ template "header" . }}
|
||||
|
||||
<div id="deviceModal" class="device-modal" x-show="deviceModalOpen" x-transition.opacity @click.self="deviceModalOpen=false">
|
||||
<div class="device-modal-content bg-white shadow-xl" x-transition.scale.duration.150ms>
|
||||
<div class="device-list-header flex items-center justify-between border-b">
|
||||
<div class="text-sm">设备列表</div>
|
||||
<span class="close-modal" @click="deviceModalOpen=false">×</span>
|
||||
</div>
|
||||
<div id="deviceList" class="device-list">
|
||||
</div>
|
||||
<div class="device-list-footer">
|
||||
<div class="pagination">
|
||||
<button class="pagination-btn" id="prevPage" :disabled="window.WeatherApp.currentPage <= 1" @click="window.WeatherApp.updateDeviceList(window.WeatherApp.currentPage - 1)">< 上一页</button>
|
||||
<span>第 <span id="currentPage">1</span> 页,共 <span id="totalPages">1</span> 页</span>
|
||||
<button class="pagination-btn" id="nextPage" :disabled="window.WeatherApp.currentPage >= Math.ceil(window.WeatherApp.filteredDevices.length / window.WeatherApp.itemsPerPage)" @click="window.WeatherApp.updateDeviceList(window.WeatherApp.currentPage + 1)">下一页 ></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container content-narrow py-5">
|
||||
<div class="system-info bg-gray-100 p-3 mb-5 rounded text-sm">
|
||||
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
|
||||
<strong>总设备: </strong> <a href="#" id="showDeviceList" class="text-blue-600 hover:text-blue-700 underline-offset-2" @click.prevent="deviceModalOpen = true; window.WeatherApp.updateDeviceList(1)"><span id="wh65lpCount">0</span> 个</a>
|
||||
</div>
|
||||
|
||||
<div class="controls flex flex-col gap-4 mb-5 p-4 border rounded bg-white">
|
||||
<div class="control-row flex items-center gap-4 flex-wrap">
|
||||
<div class="station-input-group flex items-center gap-1">
|
||||
<label for="stationInput" class="text-sm text-gray-600">站点编号:</label>
|
||||
<input type="text" id="stationInput" placeholder="" class="w-32 px-2 py-1 border border-gray-300 rounded text-sm font-mono">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="mapType" class="text-sm text-gray-600">地图类型:</label>
|
||||
<select id="mapType" onchange="switchLayer(this.value)" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="satellite">卫星图</option>
|
||||
<option value="vector">矢量图</option>
|
||||
<option value="terrain">地形图</option>
|
||||
<option value="hybrid">混合地形图</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="forecastProvider" class="text-sm text-gray-600">预报源:</label>
|
||||
<select id="forecastProvider" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="">不显示预报</option>
|
||||
<option value="imdroid_mix" selected>英卓 V4</option>
|
||||
<option value="open-meteo">英卓 V3</option>
|
||||
<option value="caiyun">英卓 V2</option>
|
||||
<option value="imdroid">英卓 V1</option>
|
||||
|
||||
<!-- <option value="cma">中央气象台</option>-->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="legendMode" class="text-sm text-gray-600">图例展示:</label>
|
||||
<select id="legendMode" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="combo_standard" selected>综合</option>
|
||||
<option value="verify_all">全部气象要素对比</option>
|
||||
<option value="temp_compare">温度对比</option>
|
||||
<option value="hum_compare">湿度对比</option>
|
||||
<option value="rain_all">降水(+1/+2/+3h)</option>
|
||||
<option value="pressure_compare">气压对比</option>
|
||||
<option value="wind_compare">风速对比</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row flex items-center gap-4 flex-wrap">
|
||||
<div class="control-group flex items-center gap-1">
|
||||
<label for="interval" class="text-sm text-gray-600">数据粒度:</label>
|
||||
<select id="interval" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="raw">原始(16s)</option>
|
||||
<option value="10min">10分钟</option>
|
||||
<option value="30min">30分钟</option>
|
||||
<option value="1hour" selected>1小时</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group" id="timeRangeGroup">
|
||||
<label for="startDate" class="text-sm text-gray-600">开始时间:</label>
|
||||
<input type="datetime-local" id="startDate" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<label for="endDate" class="text-sm text-gray-600">结束时间:</label>
|
||||
<input type="datetime-local" id="endDate" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<button onclick="queryHistoryData()" id="queryBtn" class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-3 py-1 rounded">查看历史数据</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row flex items-center gap-3 flex-wrap">
|
||||
<div class="control-group">
|
||||
<label class="text-sm text-gray-600">叠加显示:</label>
|
||||
<select id="tileProduct" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="none">不显示</option>
|
||||
<option value="rain">1h 实际降雨</option>
|
||||
<option value="radar" selected>水汽含量</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="text-sm text-gray-600">区域图层:</label>
|
||||
<select id="kmlLayerSelect" class="px-2 py-1 border border-gray-300 rounded text-sm min-w-[200px]">
|
||||
<option value="">不显示</option>
|
||||
</select>
|
||||
<button id="btnKmlFit" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed" disabled>定位</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button id="btnTilePrev" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100">上一时次</button>
|
||||
<span id="tileCountInfo" class="text-xs text-gray-800">共0条,第0条</span>
|
||||
<button id="btnTileNext" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100">下一时次</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="text-sm text-gray-600">时间:</label>
|
||||
<select id="tileTimeSelect" class="px-2 py-1 border border-gray-300 rounded text-sm min-w-[220px]">
|
||||
<option value="">请选择时间</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-container border border-gray-200 rounded" id="mapContainer">
|
||||
<div id="map"></div>
|
||||
<button class="map-toggle-btn bg-blue-600 hover:bg-blue-700 text-white" id="toggleMapBtn" onclick="toggleMap()">折叠地图</button>
|
||||
<div id="tileValueTooltip" style="position:absolute;pointer-events:none;z-index:1003;display:none;background:rgba(0,0,0,0.65);color:#fff;font-size:12px;padding:4px 6px;border-radius:4px;"></div>
|
||||
<div id="kmlInfoPopup" style="position:absolute;z-index:1004;display:none;background:rgba(255,255,255,0.96);border:1px solid #ddd;border-radius:4px;padding:8px 10px;font-size:12px;max-width:280px;box-shadow:0 2px 8px rgba(0,0,0,0.2);"></div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container" id="chartContainer">
|
||||
<div id="stationInfoTitle" class="station-info-title"></div>
|
||||
<div id="accuracyPanel" class="accuracy-panel">
|
||||
<span class="item"><span class="label">+1h</span><span id="accH1" class="value">--</span></span>
|
||||
<span class="item"><span class="label">+2h</span><span id="accH2" class="value">--</span></span>
|
||||
<span class="item"><span class="label">+3h</span><span id="accH3" class="value">--</span></span>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="combinedChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container" id="tableContainer">
|
||||
<div id="forecastToggleContainer" style="padding: 8px 12px;font-size: 12px;color: #666;display: none;display: flex;justify-content: flex-start;align-items: center;align-content: center;">
|
||||
<label style="display: flex;align-items: center;gap: 5px;">
|
||||
<input type="checkbox" id="showPastForecast" style="vertical-align: middle;" x-model="showPastForecast" @change="window.WeatherTable.display(window.WeatherApp.cachedHistoryData, window.WeatherApp.cachedForecastData)">
|
||||
显示历史预报
|
||||
</label>
|
||||
</div>
|
||||
<table class="min-w-full text-sm text-center">
|
||||
<thead>
|
||||
<tr id="tableHeader">
|
||||
<th>时间</th>
|
||||
<th>温度 (°C)</th>
|
||||
<th>湿度 (%)</th>
|
||||
<th>气压 (hPa)</th>
|
||||
<th>风速 (m/s)</th>
|
||||
<th>风向 (°)</th>
|
||||
<th>雨量 (mm)</th>
|
||||
<th>光照 (lux)</th>
|
||||
<th>紫外线</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 示例数据,后端可替换为接口数据
|
||||
const stations = [
|
||||
{
|
||||
id: '001',
|
||||
name: '站点A',
|
||||
location: '北京',
|
||||
temp: 23.5,
|
||||
humidity: 56,
|
||||
rain: 0.2,
|
||||
wind: 3.1,
|
||||
time: '2024-07-21 15:30',
|
||||
chart: [1,2,3,2,1,0,0.2]
|
||||
},
|
||||
{
|
||||
id: '002',
|
||||
name: '站点B',
|
||||
location: '上海',
|
||||
temp: 28.1,
|
||||
humidity: 62,
|
||||
rain: 0.0,
|
||||
wind: 2.5,
|
||||
time: '2024-07-21 15:30',
|
||||
chart: [0,0,0,0,0,0,0]
|
||||
}
|
||||
];
|
||||
function renderStations() {
|
||||
const list = document.getElementById('stations-list');
|
||||
list.innerHTML = '';
|
||||
stations.forEach((s, idx) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'station-card';
|
||||
card.innerHTML = `
|
||||
<div class="station-title">${s.name} <span style="font-size:0.9rem;color:#888;">(${s.id})</span></div>
|
||||
<div class="station-meta">${s.location} | 更新时间: ${s.time}</div>
|
||||
<div class="station-data">
|
||||
<div><span class="data-label">温度</span>: <span class="data-value">${s.temp}°C</span></div>
|
||||
<div><span class="data-label">湿度</span>: <span class="data-value">${s.humidity}%</span></div>
|
||||
<div><span class="data-label">降雨</span>: <span class="data-value">${s.rain}mm</span></div>
|
||||
<div><span class="data-label">风速</span>: <span class="data-value">${s.wind}m/s</span></div>
|
||||
</div>
|
||||
<div class="chart-area">
|
||||
<canvas id="chart${idx}" height="60"></canvas>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(card);
|
||||
setTimeout(() => renderChart(`chart${idx}`, s.chart), 0);
|
||||
});
|
||||
}
|
||||
function renderChart(id, data) {
|
||||
new Chart(document.getElementById(id).getContext('2d'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'],
|
||||
datasets: [{
|
||||
label: '降雨量',
|
||||
data: data,
|
||||
borderColor: '#007bff',
|
||||
backgroundColor: 'rgba(0,123,255,0.08)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { x: { display: false }, y: { display: false } },
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
}
|
||||
renderStations();
|
||||
window.TIANDITU_KEY = '{{.TiandituKey}}';
|
||||
window.KML_LAYERS = '{{.KmlLayersJSON}}';
|
||||
</script>
|
||||
<script defer src="/static/js/alpinejs.min.js"></script>
|
||||
<script src="/static/js/utils.js"></script>
|
||||
<script src="/static/js/weather-app.js"></script>
|
||||
<script src="/static/js/weather-chart.js"></script>
|
||||
<script src="/static/js/weather-table.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
396
templates/radar_guangzhou.html
Normal file
396
templates/radar_guangzhou.html
Normal file
@ -0,0 +1,396 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||
<script src="/static/js/plotly-2.27.0.min.js"></script>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 0; }
|
||||
.content-narrow { width: 86%; max-width: 1200px; margin: 0 auto; }
|
||||
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 20px; }
|
||||
.plot-box { width: clamp(320px, 80vw, 720px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
|
||||
@supports not (aspect-ratio: 1 / 1) { .plot-box { height: 520px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{ template "header" . }}
|
||||
|
||||
<div class="content-narrow p-4">
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">历史时次查询</div>
|
||||
<div class="w-full flex flex-col items-start gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input id="tsStart" type="datetime-local" class="border rounded px-2 py-1"/>
|
||||
<span>至</span>
|
||||
<input id="tsEnd" type="datetime-local" class="border rounded px-2 py-1"/>
|
||||
<button id="tsQuery" class="px-3 py-1 bg-blue-600 text-white rounded">查询</button>
|
||||
</div>
|
||||
<div>
|
||||
<select id="timeSelect" class="border rounded px-2 py-1 min-w-[260px]">
|
||||
<option value="">最新</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">7/40/104 瓦片信息</div>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>时间:<span id="dt" class="font-mono"></span></div>
|
||||
<div>索引:z=<span id="z"></span> / y=<span id="y"></span> / x=<span id="x"></span></div>
|
||||
<div>尺寸:<span id="size"></span></div>
|
||||
<div>边界:W=<span id="west"></span>,S=<span id="south"></span>,E=<span id="east"></span>,N=<span id="north"></span></div>
|
||||
<div>分辨率(度/像素):<span id="res"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">雷达站气象(广州雷达站,金山楼顶坐标)</div>
|
||||
<div id="rtInfo" class="text-sm grid grid-cols-2 gap-y-1 gap-x-6">
|
||||
<div>站点:<span id="rt_alias"></span></div>
|
||||
<div>位置:<span id="rt_lat"></span>,<span id="rt_lon"></span></div>
|
||||
<div>时间:<span id="rt_dt" class="font-mono"></span></div>
|
||||
<div>温度:<span id="rt_t"></span> ℃</div>
|
||||
<div>湿度:<span id="rt_h"></span></div>
|
||||
<div>云量:<span id="rt_c"></span></div>
|
||||
<div>能见度:<span id="rt_vis"></span> km</div>
|
||||
<div>下行短波:<span id="rt_dswrf"></span> W/m²</div>
|
||||
<div>风速:<span id="rt_ws"></span> m/s</div>
|
||||
<div>风向:<span id="rt_wd"></span> °</div>
|
||||
<div>气压:<span id="rt_p"></span> Pa</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">未来 3H 预报</div>
|
||||
<div id="sectorInfo" class="text-sm">
|
||||
<div id="sectorStatus">计算中…</div>
|
||||
<div id="sectorDetail" class="mt-1 hidden">
|
||||
最近距离:<span id="sectorDist"></span> km;预计到达时间:<span id="sectorETA"></span><br>
|
||||
位置:lat=<span id="sectorLat"></span>,lon=<span id="sectorLon"></span> 组合反射率:<span id="sectorDBZ"></span> dBZ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="width:100%;">
|
||||
<div class="text-lg font-semibold mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
雷达组合反射率 <span id="titleDt" class="text-gray-500 text-sm"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<button id="btnPrev" class="px-2 py-1 border rounded">上一时次</button>
|
||||
<span id="countInfo" class="text-gray-600">共0条,第0条</span>
|
||||
<button id="btnNext" class="px-2 py-1 border rounded">下一时次</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-center mb-2">
|
||||
<input id="timeSlider" type="range" min="0" max="0" value="0" step="1" class="slider slider-horizontal w-64" />
|
||||
</div>
|
||||
<div id="radarPlot" class="plot-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" style="width:100%;">
|
||||
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
||||
<div id="squarePlot" class="plot-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ST_ALIAS = '广州雷达站';
|
||||
const ST_LAT = 23.146400, ST_LON = 113.341200;
|
||||
const TILE_Z = 7, TILE_Y = 40, TILE_X = 104;
|
||||
|
||||
let gTileValues = null, gXs = null, gYs = null;
|
||||
let gWindFromDeg = null, gWindSpeedMS = null;
|
||||
let gTimes = [];
|
||||
let gCurrentIdx = -1;
|
||||
// 当前渲染瓦片的时间(用于 ETA 基准时间)
|
||||
let gTileDT = null;
|
||||
|
||||
function toRad(d){ return d * Math.PI / 180; }
|
||||
function toDeg(r){ return r * 180 / Math.PI; }
|
||||
function haversine(lat1, lon1, lat2, lon2){
|
||||
const R = 6371000; const dLat = toRad(lat2-lat1); const dLon = toRad(lon2-lon1);
|
||||
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
|
||||
const c = 2*Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R*c;
|
||||
}
|
||||
function bearingDeg(lat1, lon1, lat2, lon2){
|
||||
const φ1=toRad(lat1), φ2=toRad(lat2), Δλ=toRad(lon2-lon1);
|
||||
const y=Math.sin(Δλ)*Math.cos(φ2); const x=Math.cos(φ1)*Math.sin(φ2)-Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
|
||||
return (toDeg(Math.atan2(y,x))+360)%360;
|
||||
}
|
||||
function angDiff(a,b){ let d=((a-b+540)%360)-180; return Math.abs(d); }
|
||||
function destPoint(lat, lon, brgDeg, distM){
|
||||
const R=6371000, δ=distM/R, θ=toRad(brgDeg), φ1=toRad(lat), λ1=toRad(lon);
|
||||
const sinφ1=Math.sin(φ1), cosφ1=Math.cos(φ1), sinδ=Math.sin(δ), cosδ=Math.cos(δ);
|
||||
const sinφ2=sinφ1*cosδ+cosφ1*sinδ*Math.cos(θ); const φ2=Math.asin(sinφ2);
|
||||
const y=Math.sin(θ)*sinδ*cosφ1; const x=cosδ-sinφ1*sinφ2; const λ2=λ1+Math.atan2(y,x);
|
||||
return { lat: toDeg(φ2), lon: ((toDeg(λ2)+540)%360)-180 };
|
||||
}
|
||||
|
||||
async function loadLatestTile(){
|
||||
const res = await fetch(`/api/radar/latest?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`);
|
||||
if(!res.ok) throw new Error('加载最新瓦片失败');
|
||||
const t = await res.json();
|
||||
await renderTile(t);
|
||||
}
|
||||
|
||||
async function loadTileAt(dtStr){
|
||||
const res = await fetch(`/api/radar/at?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(!res.ok) throw new Error('加载指定时间瓦片失败');
|
||||
const t = await res.json();
|
||||
await renderTile(t, dtStr);
|
||||
}
|
||||
|
||||
function fmtDTLocal(dt){ const pad=(n)=>String(n).padStart(2,'0'); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`; }
|
||||
function fromDTLocalInput(s){ if(!s) return null; const t=new Date(s.replace('T','-').replace(/-/g,'/')); const pad=(n)=>String(n).padStart(2,'0'); return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`; }
|
||||
async function populateTimes(fromStr, toStr){
|
||||
try{
|
||||
let url = `/api/radar/times?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`;
|
||||
if(fromStr && toStr){ url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`; } else { url += `&limit=60`; }
|
||||
const res = await fetch(url); if(!res.ok) return; const j = await res.json();
|
||||
const sel = document.getElementById('timeSelect'); while (sel.options.length > 1) sel.remove(1);
|
||||
gTimes = j.times || [];
|
||||
gTimes.forEach(dt=>{ const opt=document.createElement('option'); opt.value=dt; opt.textContent=dt; sel.appendChild(opt); });
|
||||
if (gTimes.length>0 && gCurrentIdx<0){ sel.value=gTimes[0]; gCurrentIdx=0; }
|
||||
updateCountAndButtons(); updateSlider();
|
||||
const shown = document.getElementById('dt').textContent;
|
||||
if (gTimes.length>0 && gTimes[gCurrentIdx] && gTimes[gCurrentIdx] !== shown) {
|
||||
await loadTileAt(gTimes[gCurrentIdx]);
|
||||
}
|
||||
}catch{}
|
||||
}
|
||||
|
||||
async function renderTile(t, forcedDt){
|
||||
const fmt5 = (n)=>Number(n).toFixed(5);
|
||||
|
||||
document.getElementById('dt').textContent = t.dt;
|
||||
document.getElementById('z').textContent = t.z;
|
||||
document.getElementById('y').textContent = t.y;
|
||||
document.getElementById('x').textContent = t.x;
|
||||
document.getElementById('size').textContent = `${t.width} × ${t.height}`;
|
||||
document.getElementById('west').textContent = fmt5(t.west);
|
||||
document.getElementById('south').textContent = fmt5(t.south);
|
||||
document.getElementById('east').textContent = fmt5(t.east);
|
||||
document.getElementById('north').textContent = fmt5(t.north);
|
||||
document.getElementById('res').textContent = fmt5(t.res_deg);
|
||||
document.getElementById('titleDt').textContent = `(${t.dt})`;
|
||||
const selBox = document.getElementById('timeSelect');
|
||||
for(let i=0;i<selBox.options.length;i++){ if(selBox.options[i].value===t.dt){ selBox.selectedIndex=i; break; } }
|
||||
gCurrentIdx = gTimes.indexOf(t.dt);
|
||||
updateCountAndButtons(); updateSlider();
|
||||
|
||||
const w=t.width,h=t.height; gTileValues=t.values;
|
||||
const xs=new Array(w), ys=new Array(h);
|
||||
const stepX=(t.east-t.west)/w, stepY=(t.north-t.south)/h;
|
||||
for(let i=0;i<w;i++){ xs[i]=t.west+(i+0.5)*stepX; }
|
||||
for(let j=0;j<h;j++){ ys[j]=t.south+(j+0.5)*stepY; }
|
||||
gXs=xs; gYs=ys;
|
||||
|
||||
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
|
||||
const zBins=[]; const custom=[];
|
||||
for(let r=0;r<h;r++){
|
||||
const row=gTileValues[r]; const rowBins=[], rowCustom=[];
|
||||
for(let c=0;c<w;c++){
|
||||
const val=row[c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
|
||||
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||
}
|
||||
zBins.push(rowBins); custom.push(rowCustom);
|
||||
}
|
||||
const heatTrace={ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
|
||||
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
|
||||
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
|
||||
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' };
|
||||
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90}, xaxis:{title:'经度',tickformat:'.2f',zeroline:false,constrain:'domain',automargin:true},
|
||||
yaxis:{title:{text:'纬度',standoff:12},tickformat:'.2f',zeroline:false,scaleanchor:'x',scaleratio:1,constrain:'domain',automargin:true} };
|
||||
const data=[heatTrace];
|
||||
if(gWindFromDeg!==null && gWindSpeedMS>0){
|
||||
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600;
|
||||
const xsFan=[ST_LON], ysFan=[ST_LAT];
|
||||
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(ST_LAT,ST_LON,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
|
||||
xsFan.push(ST_LON); ysFan.push(ST_LAT);
|
||||
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||
}
|
||||
const plotEl=document.getElementById('radarPlot');
|
||||
Plotly.newPlot(plotEl, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); });
|
||||
window.addEventListener('resize',()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); Plotly.Plots.resize(plotEl); });
|
||||
|
||||
try{
|
||||
const base = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
|
||||
// 记录本次瓦片时间供 ETA 使用
|
||||
gTileDT = base;
|
||||
await fetchRealtimeWithFallback(base);
|
||||
maybeCalcSector();
|
||||
maybePlotSquare();
|
||||
}catch(e){ await loadRealtimeLatest(); maybeCalcSector(); maybePlotSquare(); }
|
||||
}
|
||||
|
||||
async function fetchRealtimeWithFallback(base){
|
||||
const pad=(n)=>String(n).padStart(2,'0');
|
||||
// 尝试取下一10分钟整(和雷达瓦片时间对齐)
|
||||
const ceil10=new Date(base); const m=base.getMinutes(); const up=(Math.floor(m/10)*10+10)%60; ceil10.setMinutes(up,0,0); if(up===0){ ceil10.setHours(base.getHours()+1); }
|
||||
let dtStr=`${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
|
||||
let res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(ST_ALIAS)}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(res.ok){ const r=await res.json(); fillRealtime(r); return; }
|
||||
// 若没有10分钟数据,退回到当前小时的整点
|
||||
const hour0=new Date(base); hour0.setMinutes(0,0,0);
|
||||
dtStr=`${hour0.getFullYear()}-${pad(hour0.getMonth()+1)}-${pad(hour0.getDate())} ${pad(hour0.getHours())}:00:00`;
|
||||
res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(ST_ALIAS)}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(res.ok){ const r=await res.json(); fillRealtime(r); return; }
|
||||
// 最后退回最新
|
||||
await loadRealtimeLatest();
|
||||
}
|
||||
|
||||
async function loadRealtimeLatest(){
|
||||
const res=await fetch(`/api/radar/weather_latest?alias=${encodeURIComponent(ST_ALIAS)}`);
|
||||
if(!res.ok) throw new Error('加载实时气象失败');
|
||||
const r=await res.json(); fillRealtime(r);
|
||||
}
|
||||
async function loadRealtimeAt(dtStr){
|
||||
const res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(ST_ALIAS)}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(!res.ok) throw new Error('加载指定时间气象失败');
|
||||
const r=await res.json(); fillRealtime(r);
|
||||
}
|
||||
function fillRealtime(r){
|
||||
const f2=(n)=>Number(n).toFixed(2), f4=(n)=>Number(n).toFixed(4);
|
||||
document.getElementById('rt_alias').textContent=r.alias;
|
||||
document.getElementById('rt_lat').textContent=f4(r.lat);
|
||||
document.getElementById('rt_lon').textContent=f4(r.lon);
|
||||
document.getElementById('rt_dt').textContent=r.dt;
|
||||
document.getElementById('rt_t').textContent=f2(r.temperature);
|
||||
document.getElementById('rt_h').textContent=f2(r.humidity);
|
||||
document.getElementById('rt_c').textContent=f2(r.cloudrate);
|
||||
document.getElementById('rt_vis').textContent=f2(r.visibility);
|
||||
document.getElementById('rt_dswrf').textContent=f2(r.dswrf);
|
||||
document.getElementById('rt_ws').textContent=f2(r.wind_speed);
|
||||
document.getElementById('rt_wd').textContent=f2(r.wind_direction);
|
||||
document.getElementById('rt_p').textContent=f2(r.pressure);
|
||||
gWindFromDeg=Number(r.wind_direction); gWindSpeedMS=Number(r.wind_speed);
|
||||
}
|
||||
|
||||
function maybeCalcSector(){
|
||||
try{
|
||||
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null) return;
|
||||
const halfAngle=30; const rangeM=gWindSpeedMS*3*3600; let best=null;
|
||||
const h=gTileValues.length, w=gTileValues[0].length;
|
||||
for(let r=0;r<h;r++){
|
||||
const lat=gYs[r]; for(let c=0;c<w;c++){
|
||||
const val=gTileValues[r][c]; if(val==null) continue; const dbz=Number(val); if(!(dbz>=40)) continue;
|
||||
const lon=gXs[c]; const dist=haversine(ST_LAT,ST_LON,lat,lon); if(dist>rangeM) continue;
|
||||
const brg=bearingDeg(ST_LAT,ST_LON,lat,lon); if(angDiff(brg,gWindFromDeg)>halfAngle) continue;
|
||||
if(!best || dist<best.dist){ best={dist,lat,lon,dbz}; }
|
||||
}
|
||||
}
|
||||
const statusEl=document.getElementById('sectorStatus'); const detailEl=document.getElementById('sectorDetail');
|
||||
if(!best){ statusEl.textContent='无(≥40 dBZ)'; detailEl.classList.add('hidden'); }
|
||||
else {
|
||||
const etaSec=best.dist/gWindSpeedMS;
|
||||
// 使用雷达瓦片时间作为 ETA 基准
|
||||
const base = gTileDT instanceof Date ? gTileDT : new Date();
|
||||
const eta=new Date(base.getTime()+etaSec*1000);
|
||||
const pad=(n)=>String(n).padStart(2,'0'); const etaStr=`${eta.getFullYear()}-${pad(eta.getMonth()+1)}-${pad(eta.getDate())} ${pad(eta.getHours())}:${pad(eta.getMinutes())}`;
|
||||
document.getElementById('sectorDist').textContent=(best.dist/1000).toFixed(1);
|
||||
document.getElementById('sectorETA').textContent=etaStr;
|
||||
document.getElementById('sectorLat').textContent=Number(best.lat).toFixed(4);
|
||||
document.getElementById('sectorLon').textContent=Number(best.lon).toFixed(4);
|
||||
document.getElementById('sectorDBZ').textContent=Number(best.dbz).toFixed(1);
|
||||
statusEl.textContent='三小时内可能有降雨(≥40 dBZ )'; detailEl.classList.remove('hidden');
|
||||
}
|
||||
}catch(e){ document.getElementById('sectorStatus').textContent='风险评估计算失败:'+e.message; }
|
||||
}
|
||||
|
||||
function maybePlotSquare(){
|
||||
try{
|
||||
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null) return;
|
||||
const R=6371000; const rangeM=gWindSpeedMS*3*3600;
|
||||
const dLat=(rangeM/R)*(180/Math.PI);
|
||||
const dLon=(rangeM/(R*Math.cos(toRad(ST_LAT))))*(180/Math.PI);
|
||||
const latMin=ST_LAT-dLat, latMax=ST_LAT+dLat, lonMin=ST_LON-dLon, lonMax=ST_LON+dLon;
|
||||
const xs=gXs, ys=gYs; const h=gTileValues.length,w=gTileValues[0].length;
|
||||
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
|
||||
const zBins=[], custom=[];
|
||||
for(let r=0;r<h;r++){
|
||||
const row=gTileValues[r]; const rowBins=[], rowCustom=[]; const lat=ys[r];
|
||||
for(let c=0;c<w;c++){
|
||||
const lon=xs[c]; if(lat<latMin||lat>latMax||lon<lonMin||lon>lonMax){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
const val=row[c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
|
||||
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||
}
|
||||
zBins.push(rowBins); custom.push(rowCustom);
|
||||
}
|
||||
const data=[{ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
|
||||
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
|
||||
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
|
||||
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' }];
|
||||
if(gWindFromDeg!==null && gWindSpeedMS>0){
|
||||
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600;
|
||||
const xsFan=[ST_LON], ysFan=[ST_LAT];
|
||||
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(ST_LAT,ST_LON,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
|
||||
xsFan.push(ST_LON); ysFan.push(ST_LAT);
|
||||
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||
}
|
||||
const el=document.getElementById('squarePlot');
|
||||
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90},
|
||||
xaxis:{title:'经度', tickformat:'.2f', zeroline:false, constrain:'domain', automargin:true, range:[lonMin, lonMax]},
|
||||
yaxis:{title:{text:'纬度',standoff:12}, tickformat:'.2f', zeroline:false, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true, range:[latMin, latMax]},
|
||||
shapes:[{type:'rect',xref:'x',yref:'y',x0:lonMin,x1:lonMax,y0:latMin,y1:latMax,line:{color:'#111',width:1,dash:'dot'},fillcolor:'rgba(0,0,0,0)'}] };
|
||||
Plotly.newPlot(el, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); });
|
||||
window.addEventListener('resize',()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); Plotly.Plots.resize(el); });
|
||||
}catch(e){ document.getElementById('squarePlot').innerHTML=`<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">正方形热力图渲染失败:${e.message}</div>`; }
|
||||
}
|
||||
|
||||
(function initRange(){ const end=new Date(); const start=new Date(end.getTime()-3*3600*1000); document.getElementById('tsStart').value=fmtDTLocal(start); document.getElementById('tsEnd').value=fmtDTLocal(end); })();
|
||||
const startStr = fromDTLocalInput(document.getElementById('tsStart').value);
|
||||
const endStr = fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||
loadLatestTile().then(()=>populateTimes(startStr, endStr)).catch(err=>{ document.getElementById('radarPlot').innerHTML=`<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">加载失败:${err.message}</div>`; });
|
||||
loadRealtimeLatest().catch(err=>{ document.getElementById('rtInfo').innerHTML=`<div class="text-sm text-red-600">${err.message}</div>`; });
|
||||
document.getElementById('timeSelect').addEventListener('change', async (e)=>{
|
||||
const v=e.target.value;
|
||||
if(!v){ if(gTimes.length>0){ gCurrentIdx=0; await loadTileAt(gTimes[0]); } else { gCurrentIdx=-1; await loadLatestTile(); } }
|
||||
else { gCurrentIdx=gTimes.indexOf(v); await loadTileAt(v); }
|
||||
updateCountAndButtons(); updateSlider();
|
||||
});
|
||||
document.getElementById('tsQuery').addEventListener('click', async ()=>{ const s=fromDTLocalInput(document.getElementById('tsStart').value); const e=fromDTLocalInput(document.getElementById('tsEnd').value); await populateTimes(s,e); });
|
||||
function updateCountAndButtons(){
|
||||
const N=gTimes.length; const k=gCurrentIdx>=0?(gCurrentIdx+1):0; document.getElementById('countInfo').textContent=`共${N}条数据,-${k-1}时次`;
|
||||
const prev=document.getElementById('btnPrev'); const next=document.getElementById('btnNext');
|
||||
prev.disabled = !(N>0 && gCurrentIdx>=0 && gCurrentIdx<N-1);
|
||||
next.disabled = !(N>0 && gCurrentIdx>0);
|
||||
}
|
||||
function updateSlider(){
|
||||
const slider = document.getElementById('timeSlider');
|
||||
const N = gTimes.length;
|
||||
slider.max = N > 0 ? String(N-1) : '0';
|
||||
if (N > 0 && gCurrentIdx >= 0) {
|
||||
const sliderVal = (N - 1) - gCurrentIdx;
|
||||
slider.value = String(sliderVal);
|
||||
}
|
||||
slider.disabled = N === 0;
|
||||
}
|
||||
document.getElementById('btnPrev').addEventListener('click', async ()=>{
|
||||
if(gTimes.length===0) return; if(gCurrentIdx<0) gCurrentIdx=0; if(gCurrentIdx<gTimes.length-1){ gCurrentIdx++; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); updateSlider();
|
||||
});
|
||||
document.getElementById('btnNext').addEventListener('click', async ()=>{
|
||||
if(gTimes.length===0) return; if(gCurrentIdx>0){ gCurrentIdx--; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); updateSlider();
|
||||
});
|
||||
document.getElementById('timeSlider').addEventListener('input', async (e)=>{
|
||||
const N = gTimes.length; if (N === 0) return;
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
const sliderVal = Math.max(0, Math.min(N-1, isNaN(raw)?0:raw));
|
||||
const idx = (N - 1) - sliderVal;
|
||||
if (idx === gCurrentIdx) return;
|
||||
gCurrentIdx = idx;
|
||||
const dt = gTimes[gCurrentIdx];
|
||||
document.getElementById('timeSelect').value = dt;
|
||||
await loadTileAt(dt);
|
||||
updateCountAndButtons();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
406
templates/radar_haizhu.html
Normal file
406
templates/radar_haizhu.html
Normal file
@ -0,0 +1,406 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||
<script src="/static/js/plotly-2.27.0.min.js"></script>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 0; }
|
||||
.content-narrow { width: 86%; max-width: 1200px; margin: 0 auto; }
|
||||
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 20px; }
|
||||
.plot-box { width: clamp(320px, 80vw, 720px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
|
||||
@supports not (aspect-ratio: 1 / 1) { .plot-box { height: 520px; } }
|
||||
</style>
|
||||
<script>
|
||||
// 防止页面在未加载 Plotly 前渲染空白区域
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{{ template "header" . }}
|
||||
|
||||
<div class="content-narrow p-4">
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">历史时次查询</div>
|
||||
<div class="w-full flex flex-col items-start gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input id="tsStart" type="datetime-local" class="border rounded px-2 py-1"/>
|
||||
<span>至</span>
|
||||
<input id="tsEnd" type="datetime-local" class="border rounded px-2 py-1"/>
|
||||
<button id="tsQuery" class="px-3 py-1 bg-blue-600 text-white rounded">查询</button>
|
||||
</div>
|
||||
<div>
|
||||
<select id="timeSelect" class="border rounded px-2 py-1 min-w-[260px]">
|
||||
<option value="">最新</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">7/40/104 瓦片信息</div>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>时间:<span id="dt" class="font-mono"></span></div>
|
||||
<div>索引:z=<span id="z"></span> / y=<span id="y"></span> / x=<span id="x"></span></div>
|
||||
<div>尺寸:<span id="size"></span></div>
|
||||
<div>边界:W=<span id="west"></span>,S=<span id="south"></span>,E=<span id="east"></span>,N=<span id="north"></span></div>
|
||||
<div>分辨率(度/像素):<span id="res"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">雷达站气象(海珠雷达站)</div>
|
||||
<div id="rtInfo" class="text-sm grid grid-cols-2 gap-y-1 gap-x-6">
|
||||
<div>站点:<span id="rt_alias"></span></div>
|
||||
<div>位置:<span id="rt_lat"></span>,<span id="rt_lon"></span></div>
|
||||
<div>时间:<span id="rt_dt" class="font-mono"></span></div>
|
||||
<div>温度:<span id="rt_t"></span> ℃</div>
|
||||
<div>湿度:<span id="rt_h"></span></div>
|
||||
<div>云量:<span id="rt_c"></span></div>
|
||||
<div>能见度:<span id="rt_vis"></span> km</div>
|
||||
<div>下行短波:<span id="rt_dswrf"></span> W/m²</div>
|
||||
<div>风速:<span id="rt_ws"></span> m/s</div>
|
||||
<div>风向:<span id="rt_wd"></span> °</div>
|
||||
<div>气压:<span id="rt_p"></span> Pa</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">未来 3H 预报</div>
|
||||
<div id="sectorInfo" class="text-sm">
|
||||
<div id="sectorStatus">计算中…</div>
|
||||
<div id="sectorDetail" class="mt-1 hidden">
|
||||
最近距离:<span id="sectorDist"></span> km;预计到达时间:<span id="sectorETA"></span><br>
|
||||
位置:lat=<span id="sectorLat"></span>,lon=<span id="sectorLon"></span> 组合反射率:<span id="sectorDBZ"></span> dBZ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="width:100%;">
|
||||
<div class="text-lg font-semibold mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
雷达组合反射率 <span id="titleDt" class="text-gray-500 text-sm"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<button id="btnPrev" class="px-2 py-1 border rounded">上一时次</button>
|
||||
<span id="countInfo" class="text-gray-600">共0条,第0条</span>
|
||||
<button id="btnNext" class="px-2 py-1 border rounded">下一时次</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-center mb-2">
|
||||
<input id="timeSlider" type="range" min="0" max="0" value="0" step="1" class="slider slider-horizontal w-64" />
|
||||
</div>
|
||||
<div id="radarPlot" class="plot-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" style="width:100%;">
|
||||
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
||||
<div id="squarePlot" class="plot-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ST_ALIAS = '海珠雷达站';
|
||||
const ST_LAT = 23.09, ST_LON = 113.35;
|
||||
// 使用与广州相同的瓦片数据
|
||||
const TILE_Z = 7, TILE_Y = 40, TILE_X = 104;
|
||||
|
||||
let gTileValues = null, gXs = null, gYs = null;
|
||||
let gWindFromDeg = null, gWindSpeedMS = null;
|
||||
let gTimes = [];
|
||||
let gCurrentIdx = -1;
|
||||
let gTileDT = null; // 当前瓦片时间,用于 ETA
|
||||
|
||||
async function loadLatestTile(){
|
||||
const res = await fetch(`/api/radar/latest?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`);
|
||||
if(!res.ok) throw new Error('加载最新瓦片失败');
|
||||
const t = await res.json();
|
||||
await renderTile(t);
|
||||
}
|
||||
|
||||
function toRad(d){ return d * Math.PI / 180; }
|
||||
function toDeg(r){ return r * 180 / Math.PI; }
|
||||
function haversine(lat1, lon1, lat2, lon2){
|
||||
const R = 6371000; const dLat = toRad(lat2-lat1); const dLon = toRad(lon2-lon1);
|
||||
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
|
||||
return 2*R*Math.asin(Math.sqrt(a));
|
||||
}
|
||||
function bearingDeg(lat1, lon1, lat2, lon2){
|
||||
const φ1=toRad(lat1), φ2=toRad(lat2), λ1=toRad(lon1), λ2=toRad(lon2);
|
||||
const y=Math.sin(λ2-λ1)*Math.cos(φ2);
|
||||
const x=Math.cos(φ1)*Math.sin(φ2)-Math.sin(φ1)*Math.cos(φ2)*Math.cos(λ2-λ1);
|
||||
const θ=Math.atan2(y,x); let brg=(toDeg(θ)+360)%360; return brg;
|
||||
}
|
||||
function angDiff(a,b){ let d=Math.abs(((a-b+540)%360)-180); return d; }
|
||||
function destPoint(lat,lon,brg,dist){
|
||||
const R=6371000; const δ=dist/R; const θ=toRad(brg);
|
||||
const φ1=toRad(lat), λ1=toRad(lon);
|
||||
const φ2=Math.asin(Math.sin(φ1)*Math.cos(δ)+Math.cos(φ1)*Math.sin(δ)*Math.cos(θ));
|
||||
const λ2=λ1+Math.atan2(Math.sin(θ)*Math.sin(δ)*Math.cos(φ1),Math.cos(δ)-Math.sin(φ1)*Math.sin(φ2));
|
||||
return {lat:toDeg(φ2), lon:((toDeg(λ2)+540)%360)-180};
|
||||
}
|
||||
|
||||
async function loadTileAt(dtStr){
|
||||
// 同步瓦片与实时气象时间,便于 ETA 计算
|
||||
const tRes = await fetch(`/api/radar/at?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(!tRes.ok){ document.getElementById('titleDt').textContent='(无数据)'; return; }
|
||||
const t = await tRes.json(); await renderTile(t, dtStr);
|
||||
}
|
||||
|
||||
function fmtLocal(datetimeLocalStr){
|
||||
const s=datetimeLocalStr; if(!s) return null; const t=new Date(s.replace('T','-').replace(/-/g,'/'));
|
||||
const pad=(n)=>String(n).padStart(2,'0');
|
||||
return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`;
|
||||
}
|
||||
function fmtDTLocal(dt){ const pad=(n)=>String(n).padStart(2,'0'); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`; }
|
||||
function fromDTLocalInput(s){ if(!s) return null; const t=new Date(s.replace('T','-').replace(/-/g,'/')); const pad=(n)=>String(n).padStart(2,'0'); return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`; }
|
||||
|
||||
async function populateTimes(fromStr, toStr){
|
||||
try{
|
||||
let url = `/api/radar/times?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`;
|
||||
if(fromStr && toStr){ url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`; } else { url += `&limit=60`; }
|
||||
const res = await fetch(url); if(!res.ok) return; const j = await res.json();
|
||||
const sel = document.getElementById('timeSelect'); while (sel.options.length > 1) sel.remove(1);
|
||||
gTimes = j.times || [];
|
||||
gTimes.forEach(dt=>{ const opt=document.createElement('option'); opt.value=dt; opt.textContent=dt; sel.appendChild(opt); });
|
||||
if (gTimes.length>0 && gCurrentIdx<0){ sel.value=gTimes[0]; gCurrentIdx=0; }
|
||||
updateCountAndButtons(); updateSlider();
|
||||
}catch{}
|
||||
}
|
||||
|
||||
async function renderTile(t, forcedDt){
|
||||
const fmt5 = (n)=>Number(n).toFixed(5);
|
||||
|
||||
document.getElementById('dt').textContent = t.dt;
|
||||
document.getElementById('z').textContent = t.z;
|
||||
document.getElementById('y').textContent = t.y;
|
||||
document.getElementById('x').textContent = t.x;
|
||||
document.getElementById('size').textContent = `${t.width} × ${t.height}`;
|
||||
document.getElementById('west').textContent = fmt5(t.west);
|
||||
document.getElementById('south').textContent = fmt5(t.south);
|
||||
document.getElementById('east').textContent = fmt5(t.east);
|
||||
document.getElementById('north').textContent = fmt5(t.north);
|
||||
document.getElementById('res').textContent = fmt5(t.res_deg);
|
||||
document.getElementById('titleDt').textContent = `(${t.dt})`;
|
||||
const selBox = document.getElementById('timeSelect');
|
||||
for(let i=0;i<selBox.options.length;i++){ if(selBox.options[i].value===t.dt){ selBox.selectedIndex=i; break; } }
|
||||
gCurrentIdx = gTimes.indexOf(t.dt);
|
||||
updateCountAndButtons(); updateSlider();
|
||||
|
||||
const w=t.width,h=t.height; gTileValues=t.values;
|
||||
const xs=new Array(w), ys=new Array(h);
|
||||
const stepX=(t.east-t.west)/w, stepY=(t.north-t.south)/h;
|
||||
for(let i=0;i<w;i++){ xs[i]=t.west+(i+0.5)*stepX; }
|
||||
for(let j=0;j<h;j++){ ys[j]=t.south+(j+0.5)*stepY; }
|
||||
gXs=xs; gYs=ys;
|
||||
|
||||
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
|
||||
const zBins=[]; const custom=[];
|
||||
for(let r=0;r<h;r++){
|
||||
const row=gTileValues[r]; const rowBins=[], rowCustom=[];
|
||||
for(let c=0;c<w;c++){
|
||||
const val=row[c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
|
||||
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||
}
|
||||
zBins.push(rowBins); custom.push(rowCustom);
|
||||
}
|
||||
const heatTrace={ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
|
||||
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
|
||||
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
|
||||
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' };
|
||||
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90}, xaxis:{title:'经度',tickformat:'.2f',zeroline:false,constrain:'domain',automargin:true},
|
||||
yaxis:{title:{text:'纬度',standoff:12},tickformat:'.2f',zeroline:false,scaleanchor:'x',scaleratio:1,constrain:'domain',automargin:true} };
|
||||
const data=[heatTrace];
|
||||
if(gWindFromDeg!==null && gWindSpeedMS>0){
|
||||
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600;
|
||||
const xsFan=[ST_LON], ysFan=[ST_LAT];
|
||||
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(ST_LAT,ST_LON,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
|
||||
xsFan.push(ST_LON); ysFan.push(ST_LAT);
|
||||
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||
}
|
||||
const plotEl=document.getElementById('radarPlot');
|
||||
Plotly.newPlot(plotEl, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); });
|
||||
window.addEventListener('resize',()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); Plotly.Plots.resize(plotEl); });
|
||||
|
||||
try{
|
||||
const base = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
|
||||
gTileDT = base; // 记录瓦片时间
|
||||
await fetchRealtimeWithFallback(base);
|
||||
maybeCalcSector();
|
||||
maybePlotSquare();
|
||||
}catch(e){ await loadRealtimeLatest(); maybeCalcSector(); maybePlotSquare(); }
|
||||
}
|
||||
|
||||
async function fetchRealtimeWithFallback(base){
|
||||
const pad=(n)=>String(n).padStart(2,'0');
|
||||
// 尝试下一10分钟整
|
||||
const ceil10=new Date(base); const m=base.getMinutes(); const up=(Math.floor(m/10)*10+10)%60; ceil10.setMinutes(up,0,0); if(up===0){ ceil10.setHours(base.getHours()+1); }
|
||||
let dtStr=`${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
|
||||
let res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(ST_ALIAS)}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(res.ok){ const r=await res.json(); fillRealtime(r); return; }
|
||||
// 海珠此10分钟缺测,则用广州相同10分钟时次
|
||||
res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent('广州雷达站')}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(res.ok){ const r=await res.json(); fillRealtime(r); return; }
|
||||
// 最后退回海珠最新
|
||||
await loadRealtimeLatest();
|
||||
}
|
||||
|
||||
async function loadRealtimeLatest(){
|
||||
const res=await fetch(`/api/radar/weather_latest?alias=${encodeURIComponent(ST_ALIAS)}`);
|
||||
if(!res.ok) throw new Error('加载实时气象失败');
|
||||
const r=await res.json(); fillRealtime(r);
|
||||
}
|
||||
async function loadRealtimeAt(dtStr){
|
||||
const res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(ST_ALIAS)}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(!res.ok) throw new Error('加载指定时间气象失败');
|
||||
const r=await res.json(); fillRealtime(r);
|
||||
}
|
||||
function fillRealtime(r){
|
||||
const f2=(n)=>Number(n).toFixed(2), f4=(n)=>Number(n).toFixed(4);
|
||||
document.getElementById('rt_alias').textContent=r.alias;
|
||||
document.getElementById('rt_lat').textContent=f4(r.lat);
|
||||
document.getElementById('rt_lon').textContent=f4(r.lon);
|
||||
document.getElementById('rt_dt').textContent=r.dt;
|
||||
document.getElementById('rt_t').textContent=f2(r.temperature);
|
||||
document.getElementById('rt_h').textContent=f2(r.humidity);
|
||||
document.getElementById('rt_c').textContent=f2(r.cloudrate);
|
||||
document.getElementById('rt_vis').textContent=f2(r.visibility);
|
||||
document.getElementById('rt_dswrf').textContent=f2(r.dswrf);
|
||||
document.getElementById('rt_ws').textContent=f2(r.wind_speed);
|
||||
document.getElementById('rt_wd').textContent=f2(r.wind_direction);
|
||||
document.getElementById('rt_p').textContent=f2(r.pressure);
|
||||
gWindFromDeg=Number(r.wind_direction); gWindSpeedMS=Number(r.wind_speed);
|
||||
}
|
||||
|
||||
function maybeCalcSector(){
|
||||
try{
|
||||
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null) return;
|
||||
const halfAngle=30; const rangeM=gWindSpeedMS*3*3600; let best=null;
|
||||
const h=gTileValues.length, w=gTileValues[0].length;
|
||||
for(let r=0;r<h;r++){
|
||||
const lat=gYs[r]; for(let c=0;c<w;c++){
|
||||
const val=gTileValues[r][c]; if(val==null) continue; const dbz=Number(val); if(!(dbz>=40)) continue;
|
||||
const lon=gXs[c]; const dist=haversine(ST_LAT,ST_LON,lat,lon); if(dist>rangeM) continue;
|
||||
const brg=bearingDeg(ST_LAT,ST_LON,lat,lon); if(angDiff(brg,gWindFromDeg)>halfAngle) continue;
|
||||
if(!best || dist<best.dist){ best={dist,lat,lon,dbz}; }
|
||||
}
|
||||
}
|
||||
const statusEl=document.getElementById('sectorStatus'); const detailEl=document.getElementById('sectorDetail');
|
||||
if(!best){ statusEl.textContent='无(≥40 dBZ)'; detailEl.classList.add('hidden'); }
|
||||
else {
|
||||
const etaSec=best.dist/gWindSpeedMS;
|
||||
const base = gTileDT instanceof Date ? gTileDT : new Date();
|
||||
const eta=new Date(base.getTime()+etaSec*1000);
|
||||
const pad=(n)=>String(n).padStart(2,'0'); const etaStr=`${eta.getFullYear()}-${pad(eta.getMonth()+1)}-${pad(eta.getDate())} ${pad(eta.getHours())}:${pad(eta.getMinutes())}`;
|
||||
document.getElementById('sectorDist').textContent=(best.dist/1000).toFixed(1);
|
||||
document.getElementById('sectorETA').textContent=etaStr;
|
||||
document.getElementById('sectorLat').textContent=Number(best.lat).toFixed(4);
|
||||
document.getElementById('sectorLon').textContent=Number(best.lon).toFixed(4);
|
||||
document.getElementById('sectorDBZ').textContent=Number(best.dbz).toFixed(1);
|
||||
statusEl.textContent='三小时内可能有降雨(≥40 dBZ )'; detailEl.classList.remove('hidden');
|
||||
}
|
||||
}catch(e){ document.getElementById('sectorStatus').textContent='风险评估计算失败:'+e.message; }
|
||||
}
|
||||
|
||||
function maybePlotSquare(){
|
||||
try{
|
||||
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null) return;
|
||||
const R=6371000; const rangeM=gWindSpeedMS*3*3600;
|
||||
const dLat=(rangeM/R)*(180/Math.PI);
|
||||
const dLon=(rangeM/(R*Math.cos(toRad(ST_LAT))))*(180/Math.PI);
|
||||
const latMin=ST_LAT-dLat, latMax=ST_LAT+dLat, lonMin=ST_LON-dLon, lonMax=ST_LON+dLon;
|
||||
|
||||
const h=gYs.length, w=gXs.length;
|
||||
let rStart=0; while(rStart<h && gYs[rStart] < latMin) rStart++;
|
||||
let rEnd=h-1; while(rEnd>=0 && gYs[rEnd] > latMax) rEnd--;
|
||||
let cStart=0; while(cStart<w && gXs[cStart] < lonMin) cStart++;
|
||||
let cEnd=w-1; while(cEnd>=0 && gXs[cEnd] > lonMax) cEnd--;
|
||||
if(rStart>=rEnd || cStart>=cEnd){ const el=document.getElementById('squarePlot'); el.innerHTML='<div class="p-3 text-sm text-gray-600">正方形范围超出当前瓦片或无有效像元</div>'; return; }
|
||||
|
||||
const xs=gXs.slice(cStart,cEnd+1);
|
||||
const ys=gYs.slice(rStart,rEnd+1);
|
||||
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
|
||||
const zBins=[], custom=[];
|
||||
for(let r=rStart;r<=rEnd;r++){
|
||||
const rowBins=[], rowCustom=[];
|
||||
for(let c=cStart;c<=cEnd;c++){
|
||||
const val=gTileValues[r][c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
|
||||
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||
}
|
||||
zBins.push(rowBins); custom.push(rowCustom);
|
||||
}
|
||||
const data=[{ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
|
||||
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
|
||||
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
|
||||
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' }];
|
||||
if(gWindFromDeg!==null && gWindSpeedMS>0){
|
||||
const half=30,samples=64,start=gWindFromDeg-half,end=gWindFromDeg+half;
|
||||
const xsFan=[ST_LON], ysFan=[ST_LAT];
|
||||
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(ST_LAT,ST_LON,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
|
||||
xsFan.push(ST_LON); ysFan.push(ST_LAT);
|
||||
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||
}
|
||||
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90}, xaxis:{title:'经度',tickformat:'.2f',zeroline:false,constrain:'domain',automargin:true, range:[lonMin,lonMax]},
|
||||
yaxis:{title:{text:'纬度',standoff:12},tickformat:'.2f',zeroline:false,scaleanchor:'x',scaleratio:1,constrain:'domain',automargin:true, range:[latMin,latMax]},
|
||||
shapes:[{type:'rect',xref:'x',yref:'y',x0:lonMin,x1:lonMax,y0:latMin,y1:latMax,line:{color:'#111',width:1,dash:'dot'},fillcolor:'rgba(0,0,0,0)'}] };
|
||||
const plotEl=document.getElementById('squarePlot');
|
||||
Plotly.newPlot(plotEl, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); });
|
||||
window.addEventListener('resize',()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); Plotly.Plots.resize(plotEl); });
|
||||
}catch{}
|
||||
}
|
||||
|
||||
// 控件事件
|
||||
document.getElementById('tsQuery').addEventListener('click', async ()=>{
|
||||
const from=fromDTLocalInput(document.getElementById('tsStart').value); const to=fromDTLocalInput(document.getElementById('tsEnd').value); await populateTimes(from,to);
|
||||
});
|
||||
document.getElementById('timeSelect').addEventListener('change', async (e)=>{ const v=e.target.value; if(!v){ if(gTimes.length>0){ gCurrentIdx=0; await loadTileAt(gTimes[0]); } else { gCurrentIdx=-1; await loadLatestTile(); } } else { gCurrentIdx=gTimes.indexOf(v); await loadTileAt(v);} updateCountAndButtons(); updateSlider(); });
|
||||
function updateCountAndButtons(){
|
||||
const N = gTimes.length;
|
||||
const k = gCurrentIdx >= 0 ? (gCurrentIdx + 1) : 0;
|
||||
document.getElementById('countInfo').textContent = `共${N}条数据,-${k-1}时次`;
|
||||
const prev=document.getElementById('btnPrev'); const next=document.getElementById('btnNext');
|
||||
prev.disabled = !(N>0 && gCurrentIdx>=0 && gCurrentIdx<N-1);
|
||||
next.disabled = !(N>0 && gCurrentIdx>0);
|
||||
}
|
||||
function updateSlider(){
|
||||
const slider = document.getElementById('timeSlider');
|
||||
const N = gTimes.length;
|
||||
slider.max = N > 0 ? String(N-1) : '0';
|
||||
if (N > 0 && gCurrentIdx >= 0) {
|
||||
const sliderVal = (N - 1) - gCurrentIdx;
|
||||
slider.value = String(sliderVal);
|
||||
}
|
||||
slider.disabled = N === 0;
|
||||
}
|
||||
document.getElementById('btnPrev').addEventListener('click', async ()=>{
|
||||
if(gTimes.length===0) return; if(gCurrentIdx<0) gCurrentIdx=0; if(gCurrentIdx<gTimes.length-1){ gCurrentIdx++; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); updateSlider();
|
||||
});
|
||||
document.getElementById('btnNext').addEventListener('click', async ()=>{
|
||||
if(gTimes.length===0) return; if(gCurrentIdx>0){ gCurrentIdx--; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); updateSlider();
|
||||
});
|
||||
document.getElementById('timeSlider').addEventListener('input', async (e)=>{
|
||||
const N = gTimes.length; if (N === 0) return;
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
const sliderVal = Math.max(0, Math.min(N-1, isNaN(raw)?0:raw));
|
||||
const idx = (N - 1) - sliderVal;
|
||||
if (idx === gCurrentIdx) return;
|
||||
gCurrentIdx = idx;
|
||||
const dt = gTimes[gCurrentIdx];
|
||||
document.getElementById('timeSelect').value = dt;
|
||||
await loadTileAt(dt);
|
||||
updateCountAndButtons();
|
||||
});
|
||||
|
||||
// 初始化:设置默认时间范围并加载最新瓦片,再填充时序;预加载实时气象保证扇形叠加
|
||||
(function initRange(){ const end=new Date(); const start=new Date(end.getTime()-3*3600*1000); document.getElementById('tsStart').value=fmtDTLocal(start); document.getElementById('tsEnd').value=fmtDTLocal(end); })();
|
||||
const startStr=fromDTLocalInput(document.getElementById('tsStart').value); const endStr=fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||
loadRealtimeLatest().catch(()=>{});
|
||||
loadLatestTile().then(()=>populateTimes(startStr,endStr)).catch(err=>{ const plot=document.getElementById('radarPlot'); plot.innerHTML = `<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">加载失败:${err.message}</div>`; });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
654
templates/radar_nanning.html
Normal file
654
templates/radar_nanning.html
Normal file
@ -0,0 +1,654 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||
<script src="/static/js/plotly-2.27.0.min.js"></script>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 0; }
|
||||
.content-narrow { width: 86%; max-width: 1200px; margin: 0 auto; }
|
||||
.placeholder { padding: 40px 0; color: #666; text-align: center; }
|
||||
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 20px; }
|
||||
/* 图容器:居中 + 合适尺寸 + 方形比例 */
|
||||
.plot-box { width: clamp(320px, 80vw, 720px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
|
||||
@supports not (aspect-ratio: 1 / 1) {
|
||||
.plot-box { height: 520px; }
|
||||
}
|
||||
#radarTooltip { position: absolute; pointer-events: none; background: #fff; border: 1px solid #e5e7eb; font-size: 12px; padding: 6px 8px; border-radius: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.15); display: none; z-index: 10; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{ template "header" . }}
|
||||
|
||||
<div class="content-narrow p-4">
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">历史时次查询</div>
|
||||
<div class="w-full flex flex-col items-start gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input id="tsStart" type="datetime-local" class="border rounded px-2 py-1"/>
|
||||
<span>至</span>
|
||||
<input id="tsEnd" type="datetime-local" class="border rounded px-2 py-1"/>
|
||||
<button id="tsQuery" class="px-3 py-1 bg-blue-600 text-white rounded">查询</button>
|
||||
</div>
|
||||
<div>
|
||||
<select id="timeSelect" class="border rounded px-2 py-1 min-w-[260px]">
|
||||
<option value="">最新</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">7/40/102 瓦片信息</div>
|
||||
<div id="tileInfo" class="text-sm space-y-1">
|
||||
<div>时间:<span id="dt" class="font-mono"></span></div>
|
||||
<div>索引:z=<span id="z"></span> / y=<span id="y"></span> / x=<span id="x"></span></div>
|
||||
<div>尺寸:<span id="size"></span></div>
|
||||
<div>边界:W=<span id="west"></span>,S=<span id="south"></span>,E=<span id="east"></span>,N=<span id="north"></span></div>
|
||||
<div>分辨率(度/像素):<span id="res"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">雷达站气象(南宁雷达站,第八台气象站)</div>
|
||||
<div id="rtInfo" class="text-sm grid grid-cols-2 gap-y-1 gap-x-6">
|
||||
<div>站点:<span id="rt_alias"></span></div>
|
||||
<div>位置:<span id="rt_lat"></span>,<span id="rt_lon"></span></div>
|
||||
<div>时间:<span id="rt_dt" class="font-mono"></span></div>
|
||||
<div>温度:<span id="rt_t"></span></div>
|
||||
<div>湿度:<span id="rt_h"></span></div>
|
||||
<div>云量:<span id="rt_c"></span></div>
|
||||
<div>能见度:<span id="rt_vis"></span> km</div>
|
||||
<div>下行短波:<span id="rt_dswrf"></span> </div>
|
||||
<div>风速:<span id="rt_ws"></span> m/s</div>
|
||||
<div>风向:<span id="rt_wd"></span> °</div>
|
||||
<div>气压:<span id="rt_p"></span> Pa</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">未来 3H 预报</div>
|
||||
<div id="sectorInfo" class="text-sm">
|
||||
<div id="sectorStatus">计算中…</div>
|
||||
<div id="sectorDetail" class="mt-1 hidden">
|
||||
最近距离:<span id="sectorDist"></span> km;
|
||||
预计到达时间:<span id="sectorETA"></span><br>
|
||||
位置:lat=<span id="sectorLat"></span>,lon=<span id="sectorLon"></span> 组合反射率:<span id="sectorDBZ"></span> dBZ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="width: 100%;">
|
||||
<div class="text-lg font-semibold mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
雷达组合反射率 <span id="titleDt" class="text-gray-500 text-sm"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<button id="btnPrev" class="px-2 py-1 border rounded">上一时次</button>
|
||||
<span id="countInfo" class="text-gray-600">共0条,第0条</span>
|
||||
<button id="btnNext" class="px-2 py-1 border rounded">下一时次</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-center mb-2">
|
||||
<input id="timeSlider" type="range" min="0" max="0" value="0" step="1" class="slider slider-horizontal w-64" />
|
||||
</div>
|
||||
<div id="radarPlot" class="plot-box"></div>
|
||||
</div>
|
||||
<div class="card mt-4" style="width: 100%;">
|
||||
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
||||
<div id="squarePlot" class="plot-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let gTileValues = null, gXs = null, gYs = null;
|
||||
let gWindFromDeg = null, gWindSpeedMS = null;
|
||||
let gTimes = [];
|
||||
let gCurrentIdx = -1;
|
||||
// 当前渲染瓦片的时间(用于 ETA 基准时间)
|
||||
let gTileDT = null;
|
||||
|
||||
async function loadLatestTile() {
|
||||
const res = await fetch('/api/radar/latest?z=7&y=40&x=102');
|
||||
if (!res.ok) { throw new Error('加载最新瓦片失败'); }
|
||||
const t = await res.json();
|
||||
await renderTile(t);
|
||||
}
|
||||
|
||||
async function loadTileAt(dtStr) {
|
||||
const url = `/api/radar/at?z=7&y=40&x=102&dt=${encodeURIComponent(dtStr)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) { throw new Error('加载指定时间瓦片失败'); }
|
||||
const t = await res.json();
|
||||
await renderTile(t, dtStr);
|
||||
}
|
||||
|
||||
function fmtDTLocal(dt){
|
||||
const pad = (n)=> String(n).padStart(2,'0');
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||
}
|
||||
function fromDTLocalInput(s){
|
||||
if(!s) return null;
|
||||
const t = new Date(s.replace('T','-').replace(/-/g,'/'));
|
||||
const pad=(n)=> String(n).padStart(2,'0');
|
||||
return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`;
|
||||
}
|
||||
async function populateTimes(fromStr, toStr) {
|
||||
try {
|
||||
let url = '/api/radar/times?z=7&y=40&x=102';
|
||||
if (fromStr && toStr) {
|
||||
url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`;
|
||||
} else {
|
||||
url += '&limit=60';
|
||||
}
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
const sel = document.getElementById('timeSelect');
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
gTimes = j.times || [];
|
||||
gTimes.forEach(dt => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = dt; opt.textContent = dt; sel.appendChild(opt);
|
||||
});
|
||||
if (gTimes.length > 0 && gCurrentIdx < 0) {
|
||||
sel.value = gTimes[0];
|
||||
gCurrentIdx = 0;
|
||||
}
|
||||
updateCountAndButtons();
|
||||
updateSlider();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function renderTile(t, forcedDt) {
|
||||
const fmt5 = (n) => Number(n).toFixed(5);
|
||||
|
||||
document.getElementById('dt').textContent = t.dt;
|
||||
document.getElementById('z').textContent = t.z;
|
||||
document.getElementById('y').textContent = t.y;
|
||||
document.getElementById('x').textContent = t.x;
|
||||
document.getElementById('size').textContent = `${t.width} × ${t.height}`;
|
||||
document.getElementById('west').textContent = fmt5(t.west);
|
||||
document.getElementById('south').textContent = fmt5(t.south);
|
||||
document.getElementById('east').textContent = fmt5(t.east);
|
||||
document.getElementById('north').textContent = fmt5(t.north);
|
||||
document.getElementById('res').textContent = fmt5(t.res_deg);
|
||||
document.getElementById('titleDt').textContent = `(${t.dt})`;
|
||||
const selBox = document.getElementById('timeSelect');
|
||||
for (let i = 0; i < selBox.options.length; i++) {
|
||||
if (selBox.options[i].value === t.dt) { selBox.selectedIndex = i; break; }
|
||||
}
|
||||
gCurrentIdx = gTimes.indexOf(t.dt);
|
||||
updateCountAndButtons();
|
||||
|
||||
const colors = [
|
||||
"#0000F6","#01A0F6","#00ECEC","#01FF00","#00C800",
|
||||
"#019000","#FFFF00","#E7C000","#FF9000","#FF0000",
|
||||
"#D60000","#C00000","#FF00F0","#780084","#AD90F0"
|
||||
];
|
||||
const w = t.width, h = t.height;
|
||||
const resDeg = t.res_deg;
|
||||
const west = t.west, south = t.south, north = t.north;
|
||||
|
||||
const xs = new Array(w);
|
||||
for (let c = 0; c < w; c++) xs[c] = west + (c + 0.5) * resDeg;
|
||||
const ys = new Array(h);
|
||||
for (let r = 0; r < h; r++) ys[r] = south + (r + 0.5) * resDeg;
|
||||
|
||||
gTileValues = t.values;
|
||||
gXs = xs; gYs = ys;
|
||||
|
||||
const zBins = new Array(h);
|
||||
const custom = new Array(h);
|
||||
for (let r = 0; r < h; r++) {
|
||||
const rowBins = new Array(w);
|
||||
const rowCustom = new Array(w);
|
||||
for (let c = 0; c < w; c++) {
|
||||
const val = t.values[r][c];
|
||||
if (val === null || val === undefined) { rowBins[c] = null; rowCustom[c] = [r,c,null]; continue; }
|
||||
let dbz = Number(val); if (dbz < 0) dbz = 0; if (dbz > 75) dbz = 75;
|
||||
let bin = Math.floor(dbz / 5); if (bin >= 15) bin = 14;
|
||||
rowBins[c] = bin; rowCustom[c] = [r, c, dbz];
|
||||
}
|
||||
zBins[r] = rowBins; custom[r] = rowCustom;
|
||||
}
|
||||
|
||||
const scale = [];
|
||||
for (let i = 0; i < colors.length; i++) {
|
||||
const tpos = colors.length === 1 ? 0 : i / (colors.length - 1);
|
||||
scale.push([tpos, colors[i]]);
|
||||
}
|
||||
|
||||
const data = [{
|
||||
type: 'heatmap',
|
||||
x: xs,
|
||||
y: ys,
|
||||
z: zBins,
|
||||
customdata: custom,
|
||||
colorscale: scale,
|
||||
zmin: 0,
|
||||
zmax: 14,
|
||||
colorbar: {
|
||||
orientation: 'h',
|
||||
x: 0.5,
|
||||
y: -0.12,
|
||||
xanchor: 'center',
|
||||
yanchor: 'top',
|
||||
len: 0.8,
|
||||
thickness: 16,
|
||||
title: { text: 'dBZ', side: 'bottom' },
|
||||
tickmode: 'array',
|
||||
tickvals: Array.from({length: 15}, (_,i)=>i),
|
||||
ticktext: Array.from({length: 15}, (_,i)=>String(i*5))
|
||||
},
|
||||
hovertemplate: 'row=%{customdata[0]}, col=%{customdata[1]}<br>'+
|
||||
'lon=%{x:.3f}, lat=%{y:.3f}<br>'+
|
||||
'dBZ=%{customdata[2]:.1f}<extra></extra>'
|
||||
}];
|
||||
|
||||
if (gWindFromDeg !== null && gWindSpeedMS > 0) {
|
||||
const stLat = 23.097234, stLon = 108.715433;
|
||||
const half = 30;
|
||||
const samples = 64;
|
||||
const start = gWindFromDeg - half;
|
||||
const end = gWindFromDeg + half;
|
||||
const rangeM = gWindSpeedMS * 3 * 3600;
|
||||
const xsFan = [];
|
||||
const ysFan = [];
|
||||
xsFan.push(stLon); ysFan.push(stLat);
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const θ = start + (end - start) * (i / samples);
|
||||
const p = destPoint(stLat, stLon, ((θ % 360) + 360) % 360, rangeM);
|
||||
xsFan.push(p.lon); ysFan.push(p.lat);
|
||||
}
|
||||
xsFan.push(stLon); ysFan.push(stLat);
|
||||
data.push({
|
||||
type: 'scatter', mode: 'lines', x: xsFan, y: ysFan,
|
||||
line: { color: '#FFFFFF', width: 2, dash: 'dash' },
|
||||
fill: 'toself', fillcolor: 'rgba(255,255,255,0.18)',
|
||||
hoverinfo: 'skip', showlegend: false
|
||||
});
|
||||
}
|
||||
|
||||
const layout = {
|
||||
autosize: true,
|
||||
margin: {l:40, r:8, t:8, b:90},
|
||||
xaxis: {title: '经度', tickformat: '.2f', zeroline: false, constrain: 'domain', automargin: true},
|
||||
yaxis: {title: {text: '纬度', standoff: 12}, tickformat: '.2f', zeroline: false, scaleanchor: 'x', scaleratio: 1, constrain: 'domain', automargin: true},
|
||||
};
|
||||
|
||||
const plotEl = document.getElementById('radarPlot');
|
||||
Plotly.newPlot(plotEl, data, layout, {responsive: true, displayModeBar: false}).then(() => {
|
||||
const size = plotEl.clientWidth;
|
||||
Plotly.relayout(plotEl, {height: size});
|
||||
});
|
||||
window.addEventListener('resize', () => {
|
||||
const size = plotEl.clientWidth;
|
||||
Plotly.relayout(plotEl, {height: size});
|
||||
Plotly.Plots.resize(plotEl);
|
||||
});
|
||||
|
||||
try {
|
||||
const dt = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
|
||||
// 记录本次瓦片的时间,供 ETA 计算使用
|
||||
gTileDT = dt;
|
||||
const ceil10 = new Date(dt);
|
||||
const m = dt.getMinutes();
|
||||
const up = (Math.floor(m/10)*10 + 10) % 60;
|
||||
ceil10.setMinutes(up, 0, 0);
|
||||
if (up === 0) { ceil10.setHours(dt.getHours()+1); }
|
||||
const pad = (n)=> n.toString().padStart(2,'0');
|
||||
const dtStr = `${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
|
||||
await loadRealtimeAt(dtStr);
|
||||
maybeCalcSector();
|
||||
maybePlotSquare();
|
||||
} catch (e) {
|
||||
await loadRealtimeLatest();
|
||||
maybeCalcSector();
|
||||
maybePlotSquare();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRealtimeLatest() {
|
||||
const alias = encodeURIComponent('南宁雷达站');
|
||||
const res = await fetch(`/api/radar/weather_latest?alias=${alias}`);
|
||||
if (!res.ok) throw new Error('加载实时气象失败');
|
||||
const r = await res.json();
|
||||
const f2 = (n) => Number(n).toFixed(2);
|
||||
const f4 = (n) => Number(n).toFixed(4);
|
||||
document.getElementById('rt_alias').textContent = r.alias;
|
||||
document.getElementById('rt_lat').textContent = f4(r.lat);
|
||||
document.getElementById('rt_lon').textContent = f4(r.lon);
|
||||
document.getElementById('rt_dt').textContent = r.dt;
|
||||
document.getElementById('rt_t').textContent = f2(r.temperature);
|
||||
document.getElementById('rt_h').textContent = f2(r.humidity);
|
||||
document.getElementById('rt_c').textContent = f2(r.cloudrate);
|
||||
document.getElementById('rt_vis').textContent = f2(r.visibility);
|
||||
document.getElementById('rt_dswrf').textContent = f2(r.dswrf);
|
||||
document.getElementById('rt_ws').textContent = f2(r.wind_speed);
|
||||
document.getElementById('rt_wd').textContent = f2(r.wind_direction);
|
||||
document.getElementById('rt_p').textContent = f2(r.pressure);
|
||||
|
||||
gWindFromDeg = Number(r.wind_direction);
|
||||
gWindSpeedMS = Number(r.wind_speed);
|
||||
}
|
||||
|
||||
async function loadRealtimeAt(dtStr) {
|
||||
const alias = encodeURIComponent('南宁雷达站');
|
||||
const res = await fetch(`/api/radar/weather_at?alias=${alias}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if (!res.ok) throw new Error('加载指定时间气象失败');
|
||||
const r = await res.json();
|
||||
const f2 = (n) => Number(n).toFixed(2);
|
||||
const f4 = (n) => Number(n).toFixed(4);
|
||||
document.getElementById('rt_alias').textContent = r.alias;
|
||||
document.getElementById('rt_lat').textContent = f4(r.lat);
|
||||
document.getElementById('rt_lon').textContent = f4(r.lon);
|
||||
document.getElementById('rt_dt').textContent = r.dt;
|
||||
document.getElementById('rt_t').textContent = f2(r.temperature);
|
||||
document.getElementById('rt_h').textContent = f2(r.humidity);
|
||||
document.getElementById('rt_c').textContent = f2(r.cloudrate);
|
||||
document.getElementById('rt_vis').textContent = f2(r.visibility);
|
||||
document.getElementById('rt_dswrf').textContent = f2(r.dswrf);
|
||||
document.getElementById('rt_ws').textContent = f2(r.wind_speed);
|
||||
document.getElementById('rt_wd').textContent = f2(r.wind_direction);
|
||||
document.getElementById('rt_p').textContent = f2(r.pressure);
|
||||
|
||||
gWindFromDeg = Number(r.wind_direction);
|
||||
gWindSpeedMS = Number(r.wind_speed);
|
||||
}
|
||||
|
||||
function toRad(d){ return d * Math.PI / 180; }
|
||||
function toDeg(r){ return r * 180 / Math.PI; }
|
||||
function haversine(lat1, lon1, lat2, lon2){
|
||||
const R = 6371000;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
return R * c;
|
||||
}
|
||||
function bearingDeg(lat1, lon1, lat2, lon2){
|
||||
const φ1 = toRad(lat1), φ2 = toRad(lat2), Δλ = toRad(lon2 - lon1);
|
||||
const y = Math.sin(Δλ) * Math.cos(φ2);
|
||||
const x = Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
|
||||
let brng = toDeg(Math.atan2(y, x));
|
||||
return (brng + 360) % 360;
|
||||
}
|
||||
function angDiff(a, b){
|
||||
let d = ((a - b + 540) % 360) - 180;
|
||||
return Math.abs(d);
|
||||
}
|
||||
function destPoint(lat, lon, brgDeg, distM){
|
||||
const R = 6371000;
|
||||
const δ = distM / R;
|
||||
const θ = toRad(brgDeg);
|
||||
const φ1 = toRad(lat);
|
||||
const λ1 = toRad(lon);
|
||||
const sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1);
|
||||
const sinδ = Math.sin(δ), cosδ = Math.cos(δ);
|
||||
const sinφ2 = sinφ1 * cosδ + cosφ1 * sinδ * Math.cos(θ);
|
||||
const φ2 = Math.asin(sinφ2);
|
||||
const y = Math.sin(θ) * sinδ * cosφ1;
|
||||
const x = cosδ - sinφ1 * sinφ2;
|
||||
const λ2 = λ1 + Math.atan2(y, x);
|
||||
return { lat: toDeg(φ2), lon: ((toDeg(λ2) + 540) % 360) - 180 };
|
||||
}
|
||||
function maybeCalcSector(){
|
||||
try{
|
||||
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null){
|
||||
return;
|
||||
}
|
||||
const stLat = 23.097234, stLon = 108.715433;
|
||||
const halfAngle = 30;
|
||||
const rangeM = gWindSpeedMS * 3 * 3600;
|
||||
|
||||
let best = null;
|
||||
const h = gTileValues.length;
|
||||
const w = gTileValues[0].length;
|
||||
for(let r=0; r<h; r++){
|
||||
const lat = gYs[r];
|
||||
for(let c=0; c<w; c++){
|
||||
const val = gTileValues[r][c];
|
||||
if(val==null) continue;
|
||||
const dbz = Number(val);
|
||||
if(!(dbz >= 40)) continue;
|
||||
const lon = gXs[c];
|
||||
const dist = haversine(stLat, stLon, lat, lon);
|
||||
if(dist > rangeM) continue;
|
||||
const brg = bearingDeg(stLat, stLon, lat, lon);
|
||||
if(angDiff(brg, gWindFromDeg) > halfAngle) continue;
|
||||
if(!best || dist < best.dist){ best = {dist, lat, lon, dbz}; }
|
||||
}
|
||||
}
|
||||
const statusEl = document.getElementById('sectorStatus');
|
||||
const detailEl = document.getElementById('sectorDetail');
|
||||
if(!best){
|
||||
statusEl.textContent = '无(≥40 dBZ)';
|
||||
detailEl.classList.add('hidden');
|
||||
}else{
|
||||
const etaSec = best.dist / gWindSpeedMS;
|
||||
// 使用雷达瓦片时间作为 ETA 基准
|
||||
const base = gTileDT instanceof Date ? gTileDT : new Date();
|
||||
const eta = new Date(base.getTime() + etaSec*1000);
|
||||
const pad = (n)=> String(n).padStart(2,'0');
|
||||
const etaStr = `${eta.getFullYear()}-${pad(eta.getMonth()+1)}-${pad(eta.getDate())} ${pad(eta.getHours())}:${pad(eta.getMinutes())}`;
|
||||
document.getElementById('sectorDist').textContent = (best.dist/1000).toFixed(1);
|
||||
document.getElementById('sectorETA').textContent = etaStr;
|
||||
document.getElementById('sectorLat').textContent = Number(best.lat).toFixed(4);
|
||||
document.getElementById('sectorLon').textContent = Number(best.lon).toFixed(4);
|
||||
document.getElementById('sectorDBZ').textContent = Number(best.dbz).toFixed(1);
|
||||
statusEl.textContent = '三小时内可能有降雨(≥40 dBZ )';
|
||||
detailEl.classList.remove('hidden');
|
||||
}
|
||||
}catch(e){
|
||||
const statusEl = document.getElementById('sectorStatus');
|
||||
statusEl.textContent = '风险评估计算失败:' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function maybePlotSquare(){
|
||||
try{
|
||||
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null){
|
||||
return;
|
||||
}
|
||||
const R = 6371000;
|
||||
const stLat = 23.097234, stLon = 108.715433;
|
||||
const rangeM = gWindSpeedMS * 3 * 3600;
|
||||
const dLat = (rangeM / R) * (180/Math.PI);
|
||||
const dLon = (rangeM / (R * Math.cos(toRad(stLat)))) * (180/Math.PI);
|
||||
const latMin = stLat - dLat, latMax = stLat + dLat;
|
||||
const lonMin = stLon - dLon, lonMax = stLon + dLon;
|
||||
|
||||
const h = gYs.length, w = gXs.length;
|
||||
let rStart = 0; while(rStart < h && gYs[rStart] < latMin) rStart++;
|
||||
let rEnd = h-1; while(rEnd >= 0 && gYs[rEnd] > latMax) rEnd--;
|
||||
let cStart = 0; while(cStart < w && gXs[cStart] < lonMin) cStart++;
|
||||
let cEnd = w-1; while(cEnd >= 0 && gXs[cEnd] > lonMax) cEnd--;
|
||||
if(rStart >= rEnd || cStart >= cEnd){
|
||||
const el = document.getElementById('squarePlot');
|
||||
el.innerHTML = '<div class="p-3 text-sm text-gray-600">正方形范围超出当前瓦片或无有效像元</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const xs = gXs.slice(cStart, cEnd+1);
|
||||
const ys = gYs.slice(rStart, rEnd+1);
|
||||
|
||||
const colors = [
|
||||
"#0000F6","#01A0F6","#00ECEC","#01FF00","#00C800",
|
||||
"#019000","#FFFF00","#E7C000","#FF9000","#FF0000",
|
||||
"#D60000","#C00000","#FF00F0","#780084","#AD90F0"
|
||||
];
|
||||
const zBins = [];
|
||||
const custom = [];
|
||||
for(let r=rStart; r<=rEnd; r++){
|
||||
const rowBins = [];
|
||||
const rowCustom = [];
|
||||
for(let c=cStart; c<=cEnd; c++){
|
||||
const val = gTileValues[r][c];
|
||||
if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
let dbz = Number(val); if (dbz < 0) dbz = 0; if (dbz > 75) dbz = 75;
|
||||
let bin = Math.floor(dbz / 5); if (bin >= 15) bin = 14;
|
||||
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||
}
|
||||
zBins.push(rowBins); custom.push(rowCustom);
|
||||
}
|
||||
const scale = [];
|
||||
for (let i = 0; i < colors.length; i++) {
|
||||
const tpos = colors.length === 1 ? 0 : i / (colors.length - 1);
|
||||
scale.push([tpos, colors[i]]);
|
||||
}
|
||||
const data = [{
|
||||
type: 'heatmap',
|
||||
x: xs,
|
||||
y: ys,
|
||||
z: zBins,
|
||||
customdata: custom,
|
||||
colorscale: scale,
|
||||
zmin: 0,
|
||||
zmax: 14,
|
||||
colorbar: {
|
||||
orientation: 'h', x: 0.5, y: -0.12, xanchor: 'center', yanchor: 'top',
|
||||
len: 0.8, thickness: 16, title: { text: 'dBZ', side: 'bottom' },
|
||||
tickmode: 'array', tickvals: Array.from({length: 15}, (_,i)=>i),
|
||||
ticktext: Array.from({length: 15}, (_,i)=>String(i*5))
|
||||
},
|
||||
hovertemplate: 'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>'
|
||||
}];
|
||||
|
||||
if (gWindFromDeg !== null && gWindSpeedMS > 0) {
|
||||
const half = 30;
|
||||
const samples = 64;
|
||||
const start = gWindFromDeg - half;
|
||||
const end = gWindFromDeg + half;
|
||||
const xsFan = [];
|
||||
const ysFan = [];
|
||||
xsFan.push(stLon); ysFan.push(stLat);
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const θ = start + (end - start) * (i / samples);
|
||||
const p = destPoint(stLat, stLon, ((θ % 360) + 360) % 360, rangeM);
|
||||
xsFan.push(p.lon); ysFan.push(p.lat);
|
||||
}
|
||||
xsFan.push(stLon); ysFan.push(stLat);
|
||||
data.push({
|
||||
type: 'scatter', mode: 'lines', x: xsFan, y: ysFan,
|
||||
line: { color: '#FFFFFF', width: 2, dash: 'dash' },
|
||||
fill: 'toself', fillcolor: 'rgba(255,255,255,0.18)',
|
||||
hoverinfo: 'skip', showlegend: false
|
||||
});
|
||||
}
|
||||
const layout = {
|
||||
autosize: true,
|
||||
margin: {l:40, r:8, t:8, b:90},
|
||||
xaxis: {title: '经度', tickformat: '.2f', zeroline: false, constrain: 'domain', automargin: true},
|
||||
yaxis: {title: {text: '纬度', standoff: 12}, tickformat: '.2f', zeroline: false, scaleanchor: 'x', scaleratio: 1, constrain: 'domain', automargin: true},
|
||||
shapes: [
|
||||
{type:'rect', xref:'x', yref:'y', x0: lonMin, x1: lonMax, y0: latMin, y1: latMax, line: {color:'#111', width:1, dash:'dot'}, fillcolor:'rgba(0,0,0,0)'}
|
||||
]
|
||||
};
|
||||
const el = document.getElementById('squarePlot');
|
||||
Plotly.newPlot(el, data, layout, {responsive: true, displayModeBar: false}).then(()=>{
|
||||
const size = el.clientWidth;
|
||||
Plotly.relayout(el, {height: size});
|
||||
});
|
||||
window.addEventListener('resize', () => {
|
||||
const size = el.clientWidth;
|
||||
Plotly.relayout(el, {height: size});
|
||||
Plotly.Plots.resize(el);
|
||||
});
|
||||
}catch(e){
|
||||
const el = document.getElementById('squarePlot');
|
||||
el.innerHTML = `<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">正方形热力图渲染失败:${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
(function initRange(){
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - 3*3600*1000);
|
||||
document.getElementById('tsStart').value = fmtDTLocal(start);
|
||||
document.getElementById('tsEnd').value = fmtDTLocal(end);
|
||||
})();
|
||||
const startStr = fromDTLocalInput(document.getElementById('tsStart').value);
|
||||
const endStr = fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||
loadLatestTile().then(()=>populateTimes(startStr, endStr)).catch(err => {
|
||||
const plot = document.getElementById('radarPlot');
|
||||
plot.innerHTML = `<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">加载失败:${err.message}(请确认 /static/js/plotly-2.27.0.min.js 已存在)</div>`;
|
||||
});
|
||||
document.getElementById('timeSelect').addEventListener('change', async (e) => {
|
||||
const v = e.target.value;
|
||||
if (!v) {
|
||||
if (gTimes.length > 0) { gCurrentIdx = 0; await loadTileAt(gTimes[0]); }
|
||||
else { gCurrentIdx = -1; await loadLatestTile(); }
|
||||
} else {
|
||||
gCurrentIdx = gTimes.indexOf(v);
|
||||
await loadTileAt(v);
|
||||
}
|
||||
updateCountAndButtons();
|
||||
updateSlider();
|
||||
});
|
||||
document.getElementById('tsQuery').addEventListener('click', async ()=>{
|
||||
const s = fromDTLocalInput(document.getElementById('tsStart').value);
|
||||
const e = fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||
await populateTimes(s, e);
|
||||
});
|
||||
|
||||
function updateCountAndButtons(){
|
||||
const N = gTimes.length;
|
||||
const k = gCurrentIdx >= 0 ? (gCurrentIdx + 1) : 0;
|
||||
document.getElementById('countInfo').textContent=`共${N}条数据,-${k-1}时次`;
|
||||
const prev = document.getElementById('btnPrev');
|
||||
const next = document.getElementById('btnNext');
|
||||
prev.disabled = !(N > 0 && gCurrentIdx >= 0 && gCurrentIdx < N-1);
|
||||
next.disabled = !(N > 0 && gCurrentIdx > 0);
|
||||
}
|
||||
document.getElementById('btnPrev').addEventListener('click', async ()=>{
|
||||
if (gTimes.length === 0) return;
|
||||
if (gCurrentIdx < 0) gCurrentIdx = 0;
|
||||
if (gCurrentIdx < gTimes.length - 1) {
|
||||
gCurrentIdx++;
|
||||
const dt = gTimes[gCurrentIdx];
|
||||
document.getElementById('timeSelect').value = dt;
|
||||
await loadTileAt(dt);
|
||||
}
|
||||
updateCountAndButtons();
|
||||
updateSlider();
|
||||
});
|
||||
document.getElementById('btnNext').addEventListener('click', async ()=>{
|
||||
if (gTimes.length === 0) return;
|
||||
if (gCurrentIdx > 0) {
|
||||
gCurrentIdx--;
|
||||
const dt = gTimes[gCurrentIdx];
|
||||
document.getElementById('timeSelect').value = dt;
|
||||
await loadTileAt(dt);
|
||||
}
|
||||
updateCountAndButtons();
|
||||
updateSlider();
|
||||
});
|
||||
|
||||
function updateSlider(){
|
||||
const slider = document.getElementById('timeSlider');
|
||||
const N = gTimes.length;
|
||||
slider.max = N > 0 ? String(N-1) : '0';
|
||||
if (N > 0 && gCurrentIdx >= 0) {
|
||||
const sliderVal = (N - 1) - gCurrentIdx;
|
||||
slider.value = String(sliderVal);
|
||||
}
|
||||
slider.disabled = N === 0;
|
||||
}
|
||||
document.getElementById('timeSlider').addEventListener('input', async (e)=>{
|
||||
const N = gTimes.length; if (N === 0) return;
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
const sliderVal = Math.max(0, Math.min(N-1, isNaN(raw)?0:raw));
|
||||
const idx = (N - 1) - sliderVal;
|
||||
if (idx === gCurrentIdx) return;
|
||||
gCurrentIdx = idx;
|
||||
const dt = gTimes[gCurrentIdx];
|
||||
document.getElementById('timeSelect').value = dt;
|
||||
await loadTileAt(dt);
|
||||
updateCountAndButtons();
|
||||
});
|
||||
loadRealtimeLatest().catch(err => {
|
||||
const info = document.getElementById('rtInfo');
|
||||
info.innerHTML = `<div class="text-sm text-red-600">${err.message}</div>`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
344
templates/radar_panyu.html
Normal file
344
templates/radar_panyu.html
Normal file
@ -0,0 +1,344 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||
<script src="/static/js/plotly-2.27.0.min.js"></script>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 0; }
|
||||
.content-narrow { width: 86%; max-width: 1200px; margin: 0 auto; }
|
||||
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 20px; }
|
||||
.plot-box { width: clamp(320px, 80vw, 720px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
|
||||
@supports not (aspect-ratio: 1 / 1) { .plot-box { height: 520px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{ template "header" . }}
|
||||
|
||||
<div class="content-narrow p-4">
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">历史时次查询</div>
|
||||
<div class="w-full flex flex-col items-start gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input id="tsStart" type="datetime-local" class="border rounded px-2 py-1"/>
|
||||
<span>至</span>
|
||||
<input id="tsEnd" type="datetime-local" class="border rounded px-2 py-1"/>
|
||||
<button id="tsQuery" class="px-3 py-1 bg-blue-600 text-white rounded">查询</button>
|
||||
</div>
|
||||
<div>
|
||||
<select id="timeSelect" class="border rounded px-2 py-1 min-w-[260px]">
|
||||
<option value="">最新</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">7/40/104 瓦片信息</div>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>时间:<span id="dt" class="font-mono"></span></div>
|
||||
<div>索引:z=<span id="z"></span> / y=<span id="y"></span> / x=<span id="x"></span></div>
|
||||
<div>尺寸:<span id="size"></span></div>
|
||||
<div>边界:W=<span id="west"></span>,S=<span id="south"></span>,E=<span id="east"></span>,N=<span id="north"></span></div>
|
||||
<div>分辨率(度/像素):<span id="res"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">雷达站气象(番禺雷达站)</div>
|
||||
<div id="rtInfo" class="text-sm grid grid-cols-2 gap-y-1 gap-x-6">
|
||||
<div>站点:<span id="rt_alias"></span></div>
|
||||
<div>位置:<span id="rt_lat"></span>,<span id="rt_lon"></span></div>
|
||||
<div>时间:<span id="rt_dt" class="font-mono"></span></div>
|
||||
<div>温度:<span id="rt_t"></span> ℃</div>
|
||||
<div>湿度:<span id="rt_h"></span></div>
|
||||
<div>云量:<span id="rt_c"></span></div>
|
||||
<div>能见度:<span id="rt_vis"></span> km</div>
|
||||
<div>下行短波:<span id="rt_dswrf"></span> W/m²</div>
|
||||
<div>风速:<span id="rt_ws"></span> m/s</div>
|
||||
<div>风向:<span id="rt_wd"></span> °</div>
|
||||
<div>气压:<span id="rt_p"></span> Pa</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="text-lg font-semibold mb-2">未来 3H 预报</div>
|
||||
<div id="sectorInfo" class="text-sm">
|
||||
<div id="sectorStatus">计算中…</div>
|
||||
<div id="sectorDetail" class="mt-1 hidden">
|
||||
最近距离:<span id="sectorDist"></span> km;预计到达时间:<span id="sectorETA"></span><br>
|
||||
位置:lat=<span id="sectorLat"></span>,lon=<span id="sectorLon"></span> 组合反射率:<span id="sectorDBZ"></span> dBZ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="width:100%;">
|
||||
<div class="text-lg font-semibold mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
雷达组合反射率 <span id="titleDt" class="text-gray-500 text-sm"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<button id="btnPrev" class="px-2 py-1 border rounded">上一时次</button>
|
||||
<span id="countInfo" class="text-gray-600">共0条,第0条</span>
|
||||
<button id="btnNext" class="px-2 py-1 border rounded">下一时次</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-center mb-2">
|
||||
<input id="timeSlider" type="range" min="0" max="0" value="0" step="1" class="slider slider-horizontal w-64" />
|
||||
</div>
|
||||
<div id="radarPlot" class="plot-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" style="width:100%;">
|
||||
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
||||
<div id="squarePlot" class="plot-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ST_ALIAS = '番禺雷达站';
|
||||
const ST_LAT = 23.0225, ST_LON = 113.3313;
|
||||
const TILE_Z = 7, TILE_Y = 40, TILE_X = 104; // 使用广州瓦片
|
||||
|
||||
let gTileValues = null, gXs = null, gYs = null;
|
||||
let gWindFromDeg = null, gWindSpeedMS = null;
|
||||
let gTimes = [];
|
||||
let gCurrentIdx = -1;
|
||||
let gTileDT = null; // 当前瓦片时间(用于 ETA)
|
||||
|
||||
function toRad(d){ return d * Math.PI / 180; }
|
||||
function toDeg(r){ return r * 180 / Math.PI; }
|
||||
function haversine(lat1, lon1, lat2, lon2){
|
||||
const R = 6371000; const dLat = toRad(lat2-lat1); const dLon = toRad(lon2-lon1);
|
||||
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
|
||||
return 2*R*Math.asin(Math.sqrt(a));
|
||||
}
|
||||
function bearingDeg(lat1, lon1, lat2, lon2){
|
||||
const φ1=toRad(lat1), φ2=toRad(lat2), λ1=toRad(lon1), λ2=toRad(lon2);
|
||||
const y=Math.sin(λ2-λ1)*Math.cos(φ2);
|
||||
const x=Math.cos(φ1)*Math.sin(φ2)-Math.sin(φ1)*Math.cos(φ2)*Math.cos(λ2-λ1);
|
||||
const θ=Math.atan2(y,x); return (toDeg(θ)+360)%360;
|
||||
}
|
||||
function angDiff(a,b){ return Math.abs(((a-b+540)%360)-180); }
|
||||
function destPoint(lat,lon,brg,dist){
|
||||
const R=6371000; const δ=dist/R; const θ=toRad(brg);
|
||||
const φ1=toRad(lat), λ1=toRad(lon);
|
||||
const φ2=Math.asin(Math.sin(φ1)*Math.cos(δ)+Math.cos(φ1)*Math.sin(δ)*Math.cos(θ));
|
||||
const λ2=λ1+Math.atan2(Math.sin(θ)*Math.sin(δ)*Math.cos(φ1),Math.cos(δ)-Math.sin(φ1)*Math.sin(φ2));
|
||||
return {lat:toDeg(φ2), lon:((toDeg(λ2)+540)%360)-180};
|
||||
}
|
||||
|
||||
async function loadLatestTile(){
|
||||
const res = await fetch(`/api/radar/latest?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`);
|
||||
if(!res.ok) throw new Error('加载最新瓦片失败');
|
||||
const t = await res.json(); await renderTile(t);
|
||||
}
|
||||
async function loadTileAt(dtStr){
|
||||
const res = await fetch(`/api/radar/at?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(!res.ok) throw new Error('加载指定时间瓦片失败');
|
||||
const t = await res.json(); await renderTile(t, dtStr);
|
||||
}
|
||||
|
||||
function fmtDTLocal(dt){ const pad=(n)=>String(n).padStart(2,'0'); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`; }
|
||||
function fromDTLocalInput(s){ if(!s) return null; const t=new Date(s.replace('T','-').replace(/-/g,'/')); const pad=(n)=>String(n).padStart(2,'0'); return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`; }
|
||||
|
||||
async function populateTimes(fromStr, toStr){
|
||||
try{
|
||||
let url = `/api/radar/times?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`;
|
||||
if(fromStr && toStr){ url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`; } else { url += `&limit=60`; }
|
||||
const res = await fetch(url); if(!res.ok) return; const j = await res.json();
|
||||
const sel = document.getElementById('timeSelect'); while (sel.options.length > 1) sel.remove(1);
|
||||
gTimes = j.times || [];
|
||||
gTimes.forEach(dt=>{ const opt=document.createElement('option'); opt.value=dt; opt.textContent=dt; sel.appendChild(opt); });
|
||||
if (gTimes.length>0 && gCurrentIdx<0){ sel.value=gTimes[0]; gCurrentIdx=0; }
|
||||
updateCountAndButtons(); updateSlider();
|
||||
}catch{}
|
||||
}
|
||||
|
||||
async function renderTile(t, forcedDt){
|
||||
const fmt5=(n)=>Number(n).toFixed(5);
|
||||
document.getElementById('dt').textContent=t.dt;
|
||||
document.getElementById('z').textContent=t.z;
|
||||
document.getElementById('y').textContent=t.y;
|
||||
document.getElementById('x').textContent=t.x;
|
||||
document.getElementById('size').textContent=`${t.width} × ${t.height}`;
|
||||
document.getElementById('west').textContent=fmt5(t.west);
|
||||
document.getElementById('south').textContent=fmt5(t.south);
|
||||
document.getElementById('east').textContent=fmt5(t.east);
|
||||
document.getElementById('north').textContent=fmt5(t.north);
|
||||
document.getElementById('res').textContent=fmt5(t.res_deg);
|
||||
document.getElementById('titleDt').textContent=`(${t.dt})`;
|
||||
const selBox=document.getElementById('timeSelect'); for(let i=0;i<selBox.options.length;i++){ if(selBox.options[i].value===t.dt){ selBox.selectedIndex=i; break; } }
|
||||
gCurrentIdx=gTimes.indexOf(t.dt); updateCountAndButtons(); updateSlider();
|
||||
|
||||
const w=t.width, h=t.height; gTileValues=t.values;
|
||||
const xs=new Array(w), ys=new Array(h);
|
||||
const stepX=(t.east-t.west)/w, stepY=(t.north-t.south)/h;
|
||||
for(let i=0;i<w;i++){ xs[i]=t.west+(i+0.5)*stepX; }
|
||||
for(let j=0;j<h;j++){ ys[j]=t.south+(j+0.5)*stepY; }
|
||||
gXs=xs; gYs=ys;
|
||||
|
||||
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
|
||||
const zBins=[], custom=[];
|
||||
for(let r=0;r<h;r++){
|
||||
const row=gTileValues[r]; const rowBins=[], rowCustom=[];
|
||||
for(let c=0;c<w;c++){
|
||||
const val=row[c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
|
||||
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||
}
|
||||
zBins.push(rowBins); custom.push(rowCustom);
|
||||
}
|
||||
const heatTrace={ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
|
||||
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
|
||||
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
|
||||
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' };
|
||||
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90}, xaxis:{title:'经度',tickformat:'.2f',zeroline:false,constrain:'domain',automargin:true},
|
||||
yaxis:{title:{text:'纬度',standoff:12},tickformat:'.2f',zeroline:false,scaleanchor:'x',scaleratio:1,constrain:'domain',automargin:true} };
|
||||
const data=[heatTrace];
|
||||
if(gWindFromDeg!==null && gWindSpeedMS>0){
|
||||
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600;
|
||||
const xsFan=[ST_LON], ysFan=[ST_LAT];
|
||||
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(ST_LAT,ST_LON,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
|
||||
xsFan.push(ST_LON); ysFan.push(ST_LAT);
|
||||
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||
}
|
||||
const plotEl=document.getElementById('radarPlot');
|
||||
Plotly.newPlot(plotEl, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); });
|
||||
window.addEventListener('resize',()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); Plotly.Plots.resize(plotEl); });
|
||||
|
||||
try{
|
||||
const base = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
|
||||
gTileDT = base; // 记录瓦片时间
|
||||
await fetchRealtimeWithFallback(base);
|
||||
maybeCalcSector();
|
||||
maybePlotSquare();
|
||||
}catch(e){ await loadRealtimeLatest(); maybeCalcSector(); maybePlotSquare(); }
|
||||
}
|
||||
|
||||
async function fetchRealtimeWithFallback(base){
|
||||
const pad=(n)=>String(n).padStart(2,'0');
|
||||
const ceil10=new Date(base); const m=base.getMinutes(); const up=(Math.floor(m/10)*10+10)%60; ceil10.setMinutes(up,0,0); if(up===0){ ceil10.setHours(base.getHours()+1); }
|
||||
const dtStr=`${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
|
||||
let res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(ST_ALIAS)}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(res.ok){ const r=await res.json(); fillRealtime(r); return; }
|
||||
// 番禺此10分钟缺测,则用广州相同10分钟时次
|
||||
res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent('广州雷达站')}&dt=${encodeURIComponent(dtStr)}`);
|
||||
if(res.ok){ const r=await res.json(); fillRealtime(r); return; }
|
||||
// 最后退回番禺最新
|
||||
await loadRealtimeLatest();
|
||||
}
|
||||
|
||||
async function loadRealtimeLatest(){
|
||||
const res=await fetch(`/api/radar/weather_latest?alias=${encodeURIComponent(ST_ALIAS)}`);
|
||||
if(!res.ok) throw new Error('加载实时气象失败');
|
||||
const r=await res.json(); fillRealtime(r);
|
||||
}
|
||||
function fillRealtime(r){
|
||||
const f2=(n)=>Number(n).toFixed(2), f4=(n)=>Number(n).toFixed(4);
|
||||
document.getElementById('rt_alias').textContent=r.alias;
|
||||
document.getElementById('rt_lat').textContent=f4(r.lat);
|
||||
document.getElementById('rt_lon').textContent=f4(r.lon);
|
||||
document.getElementById('rt_dt').textContent=r.dt;
|
||||
document.getElementById('rt_t').textContent=f2(r.temperature);
|
||||
document.getElementById('rt_h').textContent=f2(r.humidity);
|
||||
document.getElementById('rt_c').textContent=f2(r.cloudrate);
|
||||
document.getElementById('rt_vis').textContent=f2(r.visibility);
|
||||
document.getElementById('rt_dswrf').textContent=f2(r.dswrf);
|
||||
document.getElementById('rt_ws').textContent=f2(r.wind_speed);
|
||||
document.getElementById('rt_wd').textContent=f2(r.wind_direction);
|
||||
document.getElementById('rt_p').textContent=f2(r.pressure);
|
||||
gWindFromDeg=Number(r.wind_direction); gWindSpeedMS=Number(r.wind_speed);
|
||||
}
|
||||
|
||||
function maybeCalcSector(){
|
||||
try{
|
||||
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null) return;
|
||||
const halfAngle=30; const rangeM=gWindSpeedMS*3*3600; let best=null;
|
||||
const h=gTileValues.length, w=gTileValues[0].length;
|
||||
for(let r=0;r<h;r++){
|
||||
const lat=gYs[r]; for(let c=0;c<w;c++){
|
||||
const val=gTileValues[r][c]; if(val==null) continue; const dbz=Number(val); if(!(dbz>=40)) continue;
|
||||
const lon=gXs[c]; const dist=haversine(ST_LAT,ST_LON,lat,lon); if(dist>rangeM) continue;
|
||||
const brg=bearingDeg(ST_LAT,ST_LON,lat,lon); if(angDiff(brg,gWindFromDeg)>halfAngle) continue;
|
||||
if(!best || dist<best.dist){ best={dist,lat,lon,dbz}; }
|
||||
}
|
||||
}
|
||||
const statusEl=document.getElementById('sectorStatus'); const detailEl=document.getElementById('sectorDetail');
|
||||
if(!best){ statusEl.textContent='无(≥40 dBZ)'; detailEl.classList.add('hidden'); }
|
||||
else {
|
||||
const etaSec=best.dist/gWindSpeedMS;
|
||||
const base = gTileDT instanceof Date ? gTileDT : new Date();
|
||||
const eta=new Date(base.getTime()+etaSec*1000);
|
||||
const pad=(n)=>String(n).padStart(2,'0'); const etaStr=`${eta.getFullYear()}-${pad(eta.getMonth()+1)}-${pad(eta.getDate())} ${pad(eta.getHours())}:${pad(eta.getMinutes())}`;
|
||||
document.getElementById('sectorDist').textContent=(best.dist/1000).toFixed(1);
|
||||
document.getElementById('sectorETA').textContent=etaStr;
|
||||
document.getElementById('sectorLat').textContent=Number(best.lat).toFixed(4);
|
||||
document.getElementById('sectorLon').textContent=Number(best.lon).toFixed(4);
|
||||
document.getElementById('sectorDBZ').textContent=Number(best.dbz).toFixed(1);
|
||||
statusEl.textContent='三小时内可能有降雨(≥40 dBZ )'; detailEl.classList.remove('hidden');
|
||||
}
|
||||
}catch(e){ document.getElementById('sectorStatus').textContent='风险评估计算失败:'+e.message; }
|
||||
}
|
||||
|
||||
function maybePlotSquare(){
|
||||
try{
|
||||
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null) return;
|
||||
const R=6371000; const rangeM=gWindSpeedMS*3*3600;
|
||||
const dLat=(rangeM/R)*(180/Math.PI);
|
||||
const dLon=(rangeM/(R*Math.cos(toRad(ST_LAT))))*(180/Math.PI);
|
||||
const latMin=ST_LAT-dLat, latMax=ST_LAT+dLat, lonMin=ST_LON-dLon, lonMax=ST_LON+dLon;
|
||||
const h=gYs.length, w=gXs.length;
|
||||
let rStart=0; while(rStart<h && gYs[rStart]<latMin) rStart++;
|
||||
let rEnd=h-1; while(rEnd>=0 && gYs[rEnd]>latMax) rEnd--;
|
||||
let cStart=0; while(cStart<w && gXs[cStart]<lonMin) cStart++;
|
||||
let cEnd=w-1; while(cEnd>=0 && gXs[cEnd]>lonMax) cEnd--;
|
||||
if(rStart>=rEnd || cStart>=cEnd){ document.getElementById('squarePlot').innerHTML='<div class="p-3 text-sm text-gray-600">正方形范围超出当前瓦片或无有效像元</div>'; return; }
|
||||
const xs=gXs.slice(cStart,cEnd+1), ys=gYs.slice(rStart,rEnd+1);
|
||||
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
|
||||
const zBins=[], custom=[];
|
||||
for(let r=rStart;r<=rEnd;r++){
|
||||
const rowBins=[], rowCustom=[];
|
||||
for(let c=cStart;c<=cEnd;c++){
|
||||
const val=gTileValues[r][c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
|
||||
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||
}
|
||||
zBins.push(rowBins); custom.push(rowCustom);
|
||||
}
|
||||
const data=[{ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14, hoverinfo:'skip', showscale:false }];
|
||||
if(gWindFromDeg!==null && gWindSpeedMS>0){
|
||||
const half=30,samples=64,start=gWindFromDeg-half,end=gWindFromDeg+half;
|
||||
const xsFan=[ST_LON], ysFan=[ST_LAT];
|
||||
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(ST_LAT,ST_LON,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
|
||||
xsFan.push(ST_LON); ysFan.push(ST_LAT);
|
||||
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||
}
|
||||
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90},
|
||||
xaxis:{title:'经度', tickformat:'.2f', zeroline:false, constrain:'domain', automargin:true, range:[lonMin,lonMax]},
|
||||
yaxis:{title:{text:'纬度', standoff:12}, tickformat:'.2f', zeroline:false, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true, range:[latMin,latMax]},
|
||||
shapes:[{type:'rect',xref:'x',yref:'y',x0:lonMin,x1:lonMax,y0:latMin,y1:latMax,line:{color:'#111',width:1,dash:'dot'},fillcolor:'rgba(0,0,0,0)'}] };
|
||||
const el=document.getElementById('squarePlot'); Plotly.newPlot(el, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); }); window.addEventListener('resize',()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); Plotly.Plots.resize(el); });
|
||||
}catch(e){ document.getElementById('squarePlot').innerHTML=`<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">正方形热力图渲染失败:${e.message}</div>`; }
|
||||
}
|
||||
|
||||
// 初始化默认时间范围与加载顺序
|
||||
(function initRange(){ const end=new Date(); const start=new Date(end.getTime()-3*3600*1000); document.getElementById('tsStart').value=fmtDTLocal(start); document.getElementById('tsEnd').value=fmtDTLocal(end); })();
|
||||
const startStr=fromDTLocalInput(document.getElementById('tsStart').value);
|
||||
const endStr=fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||
loadRealtimeLatest().catch(()=>{});
|
||||
loadLatestTile().then(()=>populateTimes(startStr,endStr)).catch(err=>{ document.getElementById('radarPlot').innerHTML=`<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">加载失败:${err.message}</div>`; });
|
||||
document.getElementById('timeSelect').addEventListener('change', async (e)=>{ const v=e.target.value; if(!v){ if(gTimes.length>0){ gCurrentIdx=0; await loadTileAt(gTimes[0]); } else { gCurrentIdx=-1; await loadLatestTile(); } } else { gCurrentIdx=gTimes.indexOf(v); await loadTileAt(v);} updateCountAndButtons(); updateSlider(); });
|
||||
document.getElementById('tsQuery').addEventListener('click', async ()=>{ const s=fromDTLocalInput(document.getElementById('tsStart').value); const e=fromDTLocalInput(document.getElementById('tsEnd').value); await populateTimes(s,e); });
|
||||
function updateCountAndButtons(){ const N=gTimes.length; const k=gCurrentIdx>=0?(gCurrentIdx+1):0; document.getElementById('countInfo').textContent=`共${N}条数据,-${k-1}时次`; const prev=document.getElementById('btnPrev'); const next=document.getElementById('btnNext'); prev.disabled=!(N>0 && gCurrentIdx>=0 && gCurrentIdx<N-1); next.disabled=!(N>0 && gCurrentIdx>0); }
|
||||
function updateSlider(){ const slider=document.getElementById('timeSlider'); const N=gTimes.length; slider.max=N>0?String(N-1):'0'; if(N>0 && gCurrentIdx>=0){ const sliderVal=(N-1)-gCurrentIdx; slider.value=String(sliderVal);} slider.disabled=N===0; }
|
||||
document.getElementById('btnPrev').addEventListener('click', async ()=>{ if(gTimes.length===0) return; if(gCurrentIdx<0) gCurrentIdx=0; if(gCurrentIdx<gTimes.length-1){ gCurrentIdx++; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); updateSlider(); });
|
||||
document.getElementById('btnNext').addEventListener('click', async ()=>{ if(gTimes.length===0) return; if(gCurrentIdx>0){ gCurrentIdx--; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); updateSlider(); });
|
||||
document.getElementById('timeSlider').addEventListener('input', async (e)=>{ const N=gTimes.length; if(N===0) return; const raw=parseInt(e.target.value,10); const sliderVal=Math.max(0,Math.min(N-1,isNaN(raw)?0:raw)); const idx=(N-1)-sliderVal; if(idx===gCurrentIdx) return; gCurrentIdx=idx; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt); updateCountAndButtons(); });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
530
web/index.html
Normal file
530
web/index.html
Normal file
@ -0,0 +1,530 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>英卓气象站</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.station-selection {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.station-card {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.station-card:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.station-card.selected {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.station-name {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.station-id {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.station-card.selected .station-id {
|
||||
color: #cce7ff;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
select, input, button {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
background-color: #fff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-container.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
height: 500px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-container.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
select, input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>英卓气象站 - WH65LP设备监控</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- 站点选择 -->
|
||||
<div class="station-selection">
|
||||
<h3 style="width: 100%; margin: 0 0 10px 0;">请选择监测站点:</h3>
|
||||
<div id="stationList" class="loading">正在加载站点信息...</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label for="interval">数据粒度:</label>
|
||||
<select id="interval">
|
||||
<option value="10min">10分钟</option>
|
||||
<option value="30min">30分钟</option>
|
||||
<option value="1hour" selected>1小时</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="startDate">开始时间:</label>
|
||||
<input type="datetime-local" id="startDate">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="endDate">结束时间:</label>
|
||||
<input type="datetime-local" id="endDate">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<button onclick="queryHistoryData()" id="queryBtn" disabled>查询历史数据</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表容器 -->
|
||||
<div class="chart-container" id="chartContainer">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="combinedChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="table-container" id="tableContainer">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>温度 (°C)</th>
|
||||
<th>湿度 (%)</th>
|
||||
<th>气压 (hPa)</th>
|
||||
<th>风速 (m/s)</th>
|
||||
<th>风向 (°)</th>
|
||||
<th>雨量 (mm)</th>
|
||||
<th>光照 (lux)</th>
|
||||
<th>紫外线</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody">
|
||||
<!-- 数据行将动态填充 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== 前端直接配置KML图层 =====
|
||||
window.TIANDITU_KEY = '0c260b8a094a4e0bc507808812cefdac'; // 天地图Key
|
||||
window.KML_LAYERS = [
|
||||
{
|
||||
name: "昭君镇示范社区",
|
||||
url: "../static/kml/selected_polygons.kml"
|
||||
}
|
||||
// 如果有更多KML文件,继续添加:
|
||||
// { name: "另一个区域", url: "../static/kml/another.kml" }
|
||||
];
|
||||
|
||||
let selectedStation = null;
|
||||
let combinedChart = null;
|
||||
|
||||
// 初始化页面
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadStations();
|
||||
initializeDateInputs();
|
||||
});
|
||||
|
||||
// 初始化日期输入
|
||||
function initializeDateInputs() {
|
||||
const now = new Date();
|
||||
// 设置为当前整点
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0);
|
||||
// 24小时前的整点
|
||||
const startDate = new Date(endDate.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
document.getElementById('startDate').value = formatDatetimeLocal(startDate);
|
||||
document.getElementById('endDate').value = formatDatetimeLocal(endDate);
|
||||
}
|
||||
|
||||
function formatDatetimeLocal(date) {
|
||||
// 直接格式化为本地时间字符串(YYYY-MM-DDTHH:mm)
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// 加载站点列表
|
||||
async function loadStations() {
|
||||
try {
|
||||
const response = await fetch('/api/stations');
|
||||
const stations = await response.json();
|
||||
|
||||
const stationList = document.getElementById('stationList');
|
||||
stationList.innerHTML = '';
|
||||
|
||||
if (stations.length === 0) {
|
||||
stationList.innerHTML = '<div class="error">暂无WH65LP设备站点</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
stations.forEach(station => {
|
||||
const stationCard = document.createElement('div');
|
||||
stationCard.className = 'station-card';
|
||||
stationCard.onclick = () => selectStation(station, stationCard);
|
||||
|
||||
stationCard.innerHTML = `
|
||||
<div class="station-name">${station.station_name || station.station_id}</div>
|
||||
<div class="station-id">${station.station_id}</div>
|
||||
<div class="station-id">最后更新: ${station.last_update}</div>
|
||||
`;
|
||||
|
||||
stationList.appendChild(stationCard);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('加载站点失败:', error);
|
||||
document.getElementById('stationList').innerHTML = '<div class="error">加载站点失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 选择站点
|
||||
function selectStation(station, cardElement) {
|
||||
// 清除之前的选择
|
||||
document.querySelectorAll('.station-card').forEach(card => {
|
||||
card.classList.remove('selected');
|
||||
});
|
||||
|
||||
// 选择当前站点
|
||||
cardElement.classList.add('selected');
|
||||
selectedStation = station;
|
||||
|
||||
// 启用按钮
|
||||
document.getElementById('queryBtn').disabled = false;
|
||||
|
||||
// 隐藏之前的数据
|
||||
hideDataContainers();
|
||||
}
|
||||
|
||||
// 隐藏数据容器
|
||||
function hideDataContainers() {
|
||||
document.getElementById('chartContainer').classList.remove('show');
|
||||
document.getElementById('tableContainer').classList.remove('show');
|
||||
}
|
||||
|
||||
// 查询历史数据
|
||||
async function queryHistoryData() {
|
||||
if (!selectedStation) {
|
||||
alert('请先选择一个站点');
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = document.getElementById('startDate').value;
|
||||
const endTime = document.getElementById('endDate').value;
|
||||
const interval = document.getElementById('interval').value;
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
alert('请选择开始和结束时间');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
decimal_id: selectedStation.decimal_id,
|
||||
start_time: startTime.replace('T', ' ') + ':00',
|
||||
end_time: endTime.replace('T', ' ') + ':00',
|
||||
interval: interval
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/data?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('查询失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.length === 0) {
|
||||
alert('该时间段内无数据');
|
||||
return;
|
||||
}
|
||||
|
||||
displayChart(data);
|
||||
displayTable(data);
|
||||
|
||||
// 显示图表和表格
|
||||
document.getElementById('chartContainer').classList.add('show');
|
||||
document.getElementById('tableContainer').classList.add('show');
|
||||
|
||||
} catch (error) {
|
||||
console.error('查询历史数据失败:', error);
|
||||
alert('查询历史数据失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示图表
|
||||
function displayChart(data) {
|
||||
const labels = data.map(item => item.date_time);
|
||||
const temperatures = data.map(item => item.temperature);
|
||||
const humidities = data.map(item => item.humidity);
|
||||
const rainfalls = data.map(item => item.rainfall);
|
||||
|
||||
// 销毁旧图表
|
||||
if (combinedChart) combinedChart.destroy();
|
||||
|
||||
// 创建组合图表
|
||||
const ctx = document.getElementById('combinedChart').getContext('2d');
|
||||
combinedChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '温度 (°C)',
|
||||
data: temperatures,
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
yAxisID: 'y-temperature',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: '湿度 (%)',
|
||||
data: humidities,
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||
yAxisID: 'y-humidity',
|
||||
tension: 0.4,
|
||||
hidden: true // 默认隐藏湿度数据
|
||||
},
|
||||
{
|
||||
label: '雨量 (mm)',
|
||||
data: rainfalls,
|
||||
type: 'bar',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
yAxisID: 'y-rainfall'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
'y-temperature': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: '温度 (°C)'
|
||||
}
|
||||
},
|
||||
'y-humidity': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '湿度 (%)'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
min: 0,
|
||||
max: 100
|
||||
},
|
||||
'y-rainfall': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '雨量 (mm)'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 显示数据表格
|
||||
function displayTable(data) {
|
||||
const tbody = document.getElementById('tableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.forEach(item => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${item.date_time}</td>
|
||||
<td>${item.temperature.toFixed(2)}</td>
|
||||
<td>${item.humidity.toFixed(2)}</td>
|
||||
<td>${item.pressure.toFixed(2)}</td>
|
||||
<td>${item.wind_speed.toFixed(2)}</td>
|
||||
<td>${item.wind_direction.toFixed(2)}</td>
|
||||
<td>${item.rainfall.toFixed(3)}</td>
|
||||
<td>${item.light.toFixed(2)}</td>
|
||||
<td>${item.uv.toFixed(2)}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
627
web/index_local.html
Normal file
627
web/index_local.html
Normal file
@ -0,0 +1,627 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<script src="/static/js/chart.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/ol.css">
|
||||
<script src="/static/js/ol.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.px-7p {
|
||||
padding-left: 7%;
|
||||
padding-right: 7%;
|
||||
}
|
||||
|
||||
.content-narrow {
|
||||
width: 86%;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-narrow {
|
||||
width: 92%;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.station-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#stationInput {
|
||||
width: 120px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
select, input, button {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 60vh;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
transition: height 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-container.collapsed {
|
||||
height: 38vh;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.map-control-btn {
|
||||
padding: 3px 8px;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.map-control-btn:hover {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.map-control-btn.active {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.map-toggle-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1001;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
background-color: #007bff;
|
||||
}
|
||||
|
||||
.station-info-title {
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
background-color: #fff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-container.show {
|
||||
display: block;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
.accuracy-panel {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
color: #374151; /* 灰色文字 */
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.accuracy-panel .item { margin-left: 8px; }
|
||||
.accuracy-panel .label { color: #6b7280; margin-right: 4px; }
|
||||
.accuracy-panel .value { font-weight: 600; color: #111827; }
|
||||
|
||||
.chart-wrapper {
|
||||
height: 500px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-container.show {
|
||||
display: block;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
background-color: #e9ecef;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.device-modal-content {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
width: 90%;
|
||||
max-width: 720px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-list-header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.device-list-footer {
|
||||
padding: 10px;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-item:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.device-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: #f5f5f5;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
background-color: #eee;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.station-marker {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #007bff;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.station-marker.offline {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.station-label {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #007bff;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
select, input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body x-data="{ showPastForecast: false, deviceModalOpen: false }" class="text-[14px] md:text-[15px]" x-init="window.addEventListener('close-device-modal', () => { deviceModalOpen = false })">
|
||||
{{ template "header" . }}
|
||||
|
||||
<div id="deviceModal" class="device-modal" x-show="deviceModalOpen" x-transition.opacity @click.self="deviceModalOpen=false">
|
||||
<div class="device-modal-content bg-white shadow-xl" x-transition.scale.duration.150ms>
|
||||
<div class="device-list-header flex items-center justify-between border-b">
|
||||
<div class="text-sm">设备列表</div>
|
||||
<span class="close-modal" @click="deviceModalOpen=false">×</span>
|
||||
</div>
|
||||
<div id="deviceList" class="device-list">
|
||||
</div>
|
||||
<div class="device-list-footer">
|
||||
<div class="pagination">
|
||||
<button class="pagination-btn" id="prevPage" :disabled="window.WeatherApp.currentPage <= 1" @click="window.WeatherApp.updateDeviceList(window.WeatherApp.currentPage - 1)">< 上一页</button>
|
||||
<span>第 <span id="currentPage">1</span> 页,共 <span id="totalPages">1</span> 页</span>
|
||||
<button class="pagination-btn" id="nextPage" :disabled="window.WeatherApp.currentPage >= Math.ceil(window.WeatherApp.filteredDevices.length / window.WeatherApp.itemsPerPage)" @click="window.WeatherApp.updateDeviceList(window.WeatherApp.currentPage + 1)">下一页 ></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container content-narrow py-5">
|
||||
<div class="system-info bg-gray-100 p-3 mb-5 rounded text-sm">
|
||||
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
|
||||
<strong>总设备: </strong> <a href="#" id="showDeviceList" class="text-blue-600 hover:text-blue-700 underline-offset-2" @click.prevent="deviceModalOpen = true; window.WeatherApp.updateDeviceList(1)"><span id="wh65lpCount">0</span> 个</a>
|
||||
</div>
|
||||
|
||||
<div class="controls flex flex-col gap-4 mb-5 p-4 border rounded bg-white">
|
||||
<div class="control-row flex items-center gap-4 flex-wrap">
|
||||
<div class="station-input-group flex items-center gap-1">
|
||||
<label for="stationInput" class="text-sm text-gray-600">站点编号:</label>
|
||||
<input type="text" id="stationInput" placeholder="" class="w-32 px-2 py-1 border border-gray-300 rounded text-sm font-mono">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="mapType" class="text-sm text-gray-600">地图类型:</label>
|
||||
<select id="mapType" onchange="switchLayer(this.value)" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="satellite">卫星图</option>
|
||||
<option value="vector">矢量图</option>
|
||||
<option value="terrain">地形图</option>
|
||||
<option value="hybrid">混合地形图</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="forecastProvider" class="text-sm text-gray-600">预报源:</label>
|
||||
<select id="forecastProvider" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="">不显示预报</option>
|
||||
<option value="imdroid_mix" selected>英卓 V4</option>
|
||||
<option value="open-meteo">英卓 V3</option>
|
||||
<option value="caiyun">英卓 V2</option>
|
||||
<option value="imdroid">英卓 V1</option>
|
||||
|
||||
<!-- <option value="cma">中央气象台</option>-->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="legendMode" class="text-sm text-gray-600">图例展示:</label>
|
||||
<select id="legendMode" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="combo_standard" selected>综合</option>
|
||||
<option value="verify_all">全部气象要素对比</option>
|
||||
<option value="temp_compare">温度对比</option>
|
||||
<option value="hum_compare">湿度对比</option>
|
||||
<option value="rain_all">降水(+1/+2/+3h)</option>
|
||||
<option value="pressure_compare">气压对比</option>
|
||||
<option value="wind_compare">风速对比</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row flex items-center gap-4 flex-wrap">
|
||||
<div class="control-group flex items-center gap-1">
|
||||
<label for="interval" class="text-sm text-gray-600">数据粒度:</label>
|
||||
<select id="interval" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="raw">原始(16s)</option>
|
||||
<option value="10min">10分钟</option>
|
||||
<option value="30min">30分钟</option>
|
||||
<option value="1hour" selected>1小时</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group" id="timeRangeGroup">
|
||||
<label for="startDate" class="text-sm text-gray-600">开始时间:</label>
|
||||
<input type="datetime-local" id="startDate" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<label for="endDate" class="text-sm text-gray-600">结束时间:</label>
|
||||
<input type="datetime-local" id="endDate" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<button onclick="queryHistoryData()" id="queryBtn" class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-3 py-1 rounded">查看历史数据</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row flex items-center gap-3 flex-wrap">
|
||||
<div class="control-group">
|
||||
<label class="text-sm text-gray-600">叠加显示:</label>
|
||||
<select id="tileProduct" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="none">不显示</option>
|
||||
<option value="rain">1h 实际降雨</option>
|
||||
<option value="radar" selected>水汽含量</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="text-sm text-gray-600">区域图层:</label>
|
||||
<select id="kmlLayerSelect" class="px-2 py-1 border border-gray-300 rounded text-sm min-w-[200px]">
|
||||
<option value="">不显示</option>
|
||||
</select>
|
||||
<button id="btnKmlFit" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed" disabled>定位</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button id="btnTilePrev" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100">上一时次</button>
|
||||
<span id="tileCountInfo" class="text-xs text-gray-800">共0条,第0条</span>
|
||||
<button id="btnTileNext" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100">下一时次</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="text-sm text-gray-600">时间:</label>
|
||||
<select id="tileTimeSelect" class="px-2 py-1 border border-gray-300 rounded text-sm min-w-[220px]">
|
||||
<option value="">请选择时间</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-container border border-gray-200 rounded" id="mapContainer">
|
||||
<div id="map"></div>
|
||||
<button class="map-toggle-btn bg-blue-600 hover:bg-blue-700 text-white" id="toggleMapBtn" onclick="toggleMap()">折叠地图</button>
|
||||
<div id="tileValueTooltip" style="position:absolute;pointer-events:none;z-index:1003;display:none;background:rgba(0,0,0,0.65);color:#fff;font-size:12px;padding:4px 6px;border-radius:4px;"></div>
|
||||
<div id="kmlInfoPopup" style="position:absolute;z-index:1004;display:none;background:rgba(255,255,255,0.96);border:1px solid #ddd;border-radius:4px;padding:8px 10px;font-size:12px;max-width:280px;box-shadow:0 2px 8px rgba(0,0,0,0.2);"></div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container" id="chartContainer">
|
||||
<div id="stationInfoTitle" class="station-info-title"></div>
|
||||
<div id="accuracyPanel" class="accuracy-panel">
|
||||
<span class="item"><span class="label">+1h</span><span id="accH1" class="value">--</span></span>
|
||||
<span class="item"><span class="label">+2h</span><span id="accH2" class="value">--</span></span>
|
||||
<span class="item"><span class="label">+3h</span><span id="accH3" class="value">--</span></span>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="combinedChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container" id="tableContainer">
|
||||
<div id="forecastToggleContainer" style="padding: 8px 12px;font-size: 12px;color: #666;display: none;display: flex;justify-content: flex-start;align-items: center;align-content: center;">
|
||||
<label style="display: flex;align-items: center;gap: 5px;">
|
||||
<input type="checkbox" id="showPastForecast" style="vertical-align: middle;" x-model="showPastForecast" @change="window.WeatherTable.display(window.WeatherApp.cachedHistoryData, window.WeatherApp.cachedForecastData)">
|
||||
显示历史预报
|
||||
</label>
|
||||
</div>
|
||||
<table class="min-w-full text-sm text-center">
|
||||
<thead>
|
||||
<tr id="tableHeader">
|
||||
<th>时间</th>
|
||||
<th>温度 (°C)</th>
|
||||
<th>湿度 (%)</th>
|
||||
<th>气压 (hPa)</th>
|
||||
<th>风速 (m/s)</th>
|
||||
<th>风向 (°)</th>
|
||||
<th>雨量 (mm)</th>
|
||||
<th>光照 (lux)</th>
|
||||
<th>紫外线</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.TIANDITU_KEY = '0c260b8a094a4e0bc507808812cefdac';
|
||||
window.KML_LAYERS = [
|
||||
{
|
||||
name: "昭君镇示范社区(典型防控区+社区边界)",
|
||||
url: "/static/kml/selected_polygons.kml"
|
||||
}
|
||||
];
|
||||
</script>
|
||||
<script defer src="/static/js/alpinejs.min.js"></script>
|
||||
<script src="/static/js/utils.js"></script>
|
||||
<script src="/static/js/weather-app.js"></script>
|
||||
<script src="/static/js/weather-chart.js"></script>
|
||||
<script src="/static/js/weather-table.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user