Compare commits

...

138 Commits

Author SHA1 Message Date
zms
c0f97a20a5 地图页面点击显示详细信息 2025-11-13 17:57:50 +08:00
zms
ac6ce044a8 修复引号问题 2025-11-13 10:26:13 +08:00
zms
9df02f5b5f 增加地图展示 在地图上面展示kml边界 2025-11-12 19:06:05 +08:00
65f95662ca fix: 彩云API 的时间转化为右端点 2025-10-23 13:24:08 +08:00
19ea80614c fix: 删除误操作的代码 2025-10-23 13:00:56 +08:00
zms
d1be656e13 修改时间戳为右端点 2025-10-23 12:33:46 +08:00
7bc2337549 fix: 雷达区域数据导出修改为本小时的45分 2025-10-17 18:26:55 +08:00
2ee30db410 feat: 新增WRF用雷达组合反射率导出 2025-10-17 16:10:46 +08:00
d78bfd381b feat: 前端新增雨量预测准确率 2025-10-17 11:25:05 +08:00
0caa1da229 feat: 前端新增雨量预测准确率 2025-10-17 10:56:41 +08:00
582270ce95 fix: 默认显示雷达组合反射率 2025-10-14 18:41:04 +08:00
35ba93a435 fix: 默认显示雷达组合反射率 2025-10-14 18:31:54 +08:00
bc7443ca8b feat: 新增更多地图瓦片的下载,用于优化地图显示 2025-10-14 18:22:32 +08:00
93a94c3149 feat: 优化地图瓦片显示 2025-10-14 18:08:14 +08:00
7b3ce46f04 feat: 优化地图瓦片显示 2025-10-14 17:49:14 +08:00
08fa1e8a04 fix: 修正雨量瓦片存储的时区 2025-10-14 15:23:33 +08:00
0fd915a2ee feat: 新增安装脚本 2025-10-14 13:26:09 +08:00
229bbe76e8 feat: 新增安装脚本 2025-10-14 13:21:56 +08:00
4f68fdc28e feat: 新增安装脚本 2025-10-14 12:32:42 +08:00
cfa69d4469 fix: 调整配置资源的路径 2025-10-14 12:27:09 +08:00
c421aed925 fix: 修正config路径错误 2025-10-14 11:57:33 +08:00
e8fcd550c1 fix: 修正微服务gin找不到templates的问题 2025-10-14 11:48:32 +08:00
5b7ec80473 fix: 修正融合出现的问题 2025-10-14 11:48:16 +08:00
0b5b26d5b0 refactor: 拆分 fusion 服务 2025-10-14 11:14:40 +08:00
b0086a984f refactor: 拆分 exporter、forecast fetch 、radar fetch 功能 2025-10-14 11:09:01 +08:00
2556ccf351 refactor: 拆分 API 和 UDP 服务。 2025-10-14 11:01:06 +08:00
050e189442 feat: 新增预报源 V4 融合方案 2025-10-14 10:31:51 +08:00
8797af23b5 feat: 新增一个雨量融合的工具 2025-10-14 10:16:25 +08:00
cbeb623f20 feat: 新增一个雨量融合的工具 2025-10-12 14:37:14 +08:00
0398b82675 feat: 新增一个雨量融合的工具 2025-10-12 12:59:32 +08:00
6f3dd6a3d0 feat: 新增一个雨量融合的工具 2025-10-11 12:13:47 +08:00
fef1825cb4 fix: 修改包名 2025-10-11 09:42:14 +08:00
4ca575d86c fix the string literal terminated 2025-10-11 09:40:47 +08:00
dc03e83562 feat: 调整前端页面。加入 Angular 框架,以期减少前端耦合 2025-10-11 09:35:07 +08:00
28ce15ce13 feat: 新增雨量瓦片的下载 2025-10-07 12:31:50 +08:00
9aaff59042 feat: 调整预报源的名字 2025-09-30 12:05:12 +08:00
4696a46c8c feat: 新增武汉江夏区雷达站 2025-09-29 15:42:52 +08:00
63e484870d feat: 新增武汉江夏区雷达站 2025-09-29 15:19:15 +08:00
0b0512f5b2 feat: 新增彩云气象数据下载的时间控制 2025-09-29 12:36:06 +08:00
aa53a21685 feat: 新增彩云气象数据下载的时间控制 2025-09-29 12:07:50 +08:00
9f960c6411 feat: 新增彩云气象数据下载的时间控制 2025-09-29 11:56:36 +08:00
13b4117d75 feat: 新增区域统计 2025-09-25 17:42:03 +08:00
5d8202311f fix: 修正8km下雨预警报警撤销 2025-09-25 13:07:08 +08:00
0449971bcb feat: 缩短瓦片下载周期时间 2025-09-25 11:17:09 +08:00
d0f96710e0 feat: 隐藏虚拟雷达站 2025-09-25 10:11:06 +08:00
b7183812fe feat: 新增8km范围识别 2025-09-25 10:03:28 +08:00
504e39f0a5 feat: 新增8km范围识别 2025-09-25 09:50:14 +08:00
9604c62f4c feat: 新增通用的雷达站 2025-09-24 19:06:38 +08:00
ef1d2f57e1 feat: 新增海珠和番禺气象站 2025-09-24 11:09:30 +08:00
e41a242223 feat: 新增海珠和番禺气象站 2025-09-24 10:58:48 +08:00
c14759933f feat: 新增海珠和番禺气象站 2025-09-24 10:51:05 +08:00
a127ddfeba feat: 新增海珠和番禺气象站 2025-09-24 10:50:56 +08:00
f1e54aab9f feat: 修正到达时间算法 2025-09-23 21:11:49 +08:00
5d6e967794 feat: 修正到达时间算法 2025-09-23 21:02:19 +08:00
a2a7cfd744 feat: 雷达历史数据 2025-09-23 17:15:42 +08:00
2085fd9a31 feat: 雷达历史数据 2025-09-23 17:06:53 +08:00
12b2ad5ace feat: 雷达历史数据 2025-09-23 16:54:35 +08:00
e6f9d500ea feat: 扇形雷达 2025-09-23 15:22:29 +08:00
11e5c73275 feat: 扇形雷达 2025-09-23 15:15:46 +08:00
da67660fe7 Revert "feat: 新增雷达图"
This reverts commit 448b13c2f6eb9b505e516858c27b571eafca1879.
2025-09-23 09:33:12 +08:00
a03c60469f Revert "feat: 新增雷达图"
This reverts commit 4fa9822405104095a9923e9762a2f65a1973d903.
2025-09-23 09:33:07 +08:00
2c7f9a0f47 Revert "feat: 新增雷达图"
This reverts commit 6bc0610c2deb96b8527047c657acf0f1f594d6a1.
2025-09-23 09:33:06 +08:00
cfb0bca723 Revert "feat: 新增雷达图"
This reverts commit df7358530f428751cdbce3f4220f1925e7b616c2.
2025-09-23 09:33:00 +08:00
0da2c838c2 Revert "feat: 新增雷达图"
- 使用了错误的方法,终止此特性分支

This reverts commit 317e12900a663ff4c1387b3430c660303a4e3462.
2025-09-23 09:32:30 +08:00
317e12900a feat: 新增雷达图 2025-09-21 22:24:57 +08:00
df7358530f feat: 新增雷达图 2025-09-21 22:03:20 +08:00
6bc0610c2d feat: 新增雷达图 2025-09-21 04:02:38 +08:00
4fa9822405 feat: 新增雷达图 2025-09-21 03:51:25 +08:00
448b13c2f6 feat: 新增雷达图 2025-09-21 02:40:42 +08:00
ff9ab1f6c2 fix: 修改地图折叠的高度 2025-09-11 17:50:37 +08:00
d2a73a7dc3 fix: 修正cma预报时间的错误 2025-09-11 15:08:56 +08:00
93533aa76c feat: 新增中央气象台数据 2025-09-11 14:57:07 +08:00
a612b511b2 fix: 修复前端页面的一些bug 2025-09-02 17:32:28 +08:00
a5ddaea5a8 fix: 修复前端页面的一些bug 2025-09-02 09:18:44 +08:00
87ff8f44d7 fix: 修复前端页面的一些bug 2025-09-01 19:45:55 +08:00
0a5a6ec4e2 fix: 修复前端页面的一些bug 2025-09-01 19:38:27 +08:00
b37a2801cc feat: 优化前端页面 2025-09-01 19:14:06 +08:00
9cd26d3df3 feat: 优化前端页面 2025-09-01 18:16:07 +08:00
d26c2c025a feat: 优化前端页面 2025-09-01 16:57:13 +08:00
c81cd572d4 feat: 优化前端页面 2025-08-31 00:22:07 +08:00
4c0f0da515 feat: 优化前端页面 2025-08-30 00:05:33 +08:00
781a93cefc Revert "feat: 优化前端页面"
This reverts commit 7e24cc52c9c380194fa7a480cb11dead2c5e6bb7.
2025-08-29 23:23:13 +08:00
7e24cc52c9 feat: 优化前端页面 2025-08-29 23:14:25 +08:00
3799b9fac8 feat: 新增3h预报 2025-08-29 20:41:18 +08:00
701292c54b feat: 新增3h预报 2025-08-29 20:23:13 +08:00
d4fb3f5986 fix: 优化前端页面 2025-08-27 18:19:46 +08:00
3b3cfe8a49 fix: 优化前端页面 2025-08-27 18:12:40 +08:00
1ad2eb6e60 fix: 修改导出的风向 2025-08-27 14:15:37 +08:00
eeeffa3e95 feat: 用彩云的预报替代风向风速 2025-08-27 12:47:55 +08:00
3e50260c51 feat: 新增总降雨的数值查看 2025-08-26 20:02:42 +08:00
f969c2fe0f fix: 修正回填逻辑 2025-08-26 19:37:13 +08:00
77d85816bd fix: 修正ztd 2025-08-25 09:31:17 +08:00
9f3331a09f feat: 优化 2025-08-24 15:23:07 +08:00
a5d382493a feat: 修复一些错误 2025-08-24 15:18:03 +08:00
f5174fe156 feat: 优化前端页面架构 2025-08-23 22:06:30 +08:00
8fbdcb1e5b feat: 优化前端页面架构 2025-08-23 22:06:13 +08:00
29a3e9305b feat: 优化前端页面架构 2025-08-23 20:04:47 +08:00
67ba5cf21c fix: 新增open-meteo历史数据 2025-08-23 03:06:35 +08:00
7f4b8bb18b fix: 修正导出的时间含义 2025-08-23 00:08:44 +08:00
0444df3b4c fix: 修正导出桶的时间 2025-08-22 21:27:22 +08:00
9e1c4979c0 fix: 延长数据统计时间 2025-08-22 21:19:27 +08:00
7ca0198b33 fix: 延长数据统计时间 2025-08-22 21:05:31 +08:00
75cb5722f8 fix: 修正时间 2025-08-22 20:57:27 +08:00
bc8027a3d8 fix: 修正数据库关联 2025-08-22 20:49:10 +08:00
1defe32470 feat:新增导出日志,方便查找问题 2025-08-22 20:30:44 +08:00
24dca2f489 feat:新增数据导出器 2025-08-22 20:15:07 +08:00
6fb4655a15 feat:新增deviceid 字段 2025-08-22 18:51:35 +08:00
ac7c699530 feat:新增彩云天气预报 2025-08-22 16:58:09 +08:00
85cca73799 feat:新增下雨概率预测 2025-08-22 16:05:16 +08:00
4acb2b62ca fix:修正风速单位 2025-08-22 13:53:06 +08:00
91c881d066 feat: 新增预报 open-meteo 预报 2025-08-22 13:44:40 +08:00
753d4dcbc7 feat: 接入雨量预测 2025-08-22 12:16:43 +08:00
e4b1c19064 fix:修正查询SQL 2025-08-22 10:03:34 +08:00
6936734f7e fix:修正存储 2025-08-22 09:13:25 +08:00
337ee06caf feat: 优化气象数据时间桶 2025-08-21 18:20:10 +08:00
3152c6bb14 feat: 优化项目结构 2025-08-21 15:57:09 +08:00
cc5c607457 fix: 优化平滑逻辑 2025-08-21 15:41:04 +08:00
c55f089247 fix: 恢复时间桶的收集逻辑 2025-08-20 11:55:00 +08:00
03d42ac3eb fix: 更改时间桶的收集逻辑 2025-08-20 11:42:32 +08:00
a206138362 feat: 新增按照时间导出 2025-08-18 15:23:37 +08:00
f8fe5bd1e1 feat: 优化前端页面 2025-08-10 01:13:39 +08:00
c931eb6af5 feat: 优化地图显示 2025-08-09 01:36:43 +08:00
2e62ce0501 feat: 优化地图显示 2025-08-09 01:36:34 +08:00
8cfc1c0563 feat: 更新地图 2025-08-08 11:19:17 +08:00
ecf3a153f0 feat: 更新数据表气象数据存储类型 2025-08-08 10:39:43 +08:00
1c88bde080 feat: 新增地图 2025-08-08 10:07:50 +08:00
f2deb5512f feat: 新增前端页面 2025-08-07 20:12:32 +08:00
26c13351d7 feat: 新增前端页面 2025-08-07 20:12:28 +08:00
89fc15b5c4 feat: 新增 485 的解析 2025-08-07 19:14:36 +08:00
26b261a663 feat: 新增 485 的解析 2025-08-03 14:05:01 +08:00
419a5c940e feat: 新增 485 的解析 2025-08-03 14:01:09 +08:00
6e643497a1 feat: 新增 485 的解析 2025-08-03 13:50:07 +08:00
bc3290c501 feat: 新增 485 的解析 2025-08-03 13:13:12 +08:00
cb1728ef00 feat: 新增 485 的解析 2025-08-03 11:25:54 +08:00
523f489e11 feat: 新增 485 的解析 2025-08-03 11:20:12 +08:00
a6fa18f5cc feat: 新增 485 的解析 2025-08-03 11:12:37 +08:00
8cbde597fd feat: Add new 485 协议 2025-08-03 10:59:29 +08:00
ff2c0d6919 fix: 去除一部分杂乱代码 2025-08-02 01:49:48 +08:00
89 changed files with 32429 additions and 334 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/log/*
/rain_data/*
export_data/*
/.gopath/*
/tech/*

8
.idea/.gitignore generated vendored Normal file
View 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

View File

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

File diff suppressed because it is too large Load Diff

14
cmd/service-api/main.go Normal file
View 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)
}
}

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

View 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")
}

View 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
View 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")
}

View 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 OpenMeteo
// 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 bigendian 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)
}
// openmeteo 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 bigendian 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 OpenMeteo 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
View 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
View 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()
}
}

View 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()
}

View File

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

View File

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

@ -0,0 +1,4 @@
node_modules/
dist/
.angular/

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

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

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

View File

@ -0,0 +1,3 @@
// Angular zone support (default change detection)
import 'zone.js';

View 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; }

View 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"
]
}

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

View 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
View 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
View 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
View 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
View 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
View File

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

@ -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
View 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
View 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
View 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分钟表再聚合的SQLinterval 支持 '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
}

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

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

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

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

View 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
View 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_hourlyprovider=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转换为 hPaPa/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
View 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
}

View 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_hourlyprovider=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
View 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=%sworst=%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.1worst 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.1worst 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
View 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
View 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
View 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
View 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)
}

View 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
View 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)
// 前端SPAAngular静态资源与路由回退
// 构建产物目录(可执行目录优先)
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)
}

View 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
View 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"` // 单位mmnull 表示无效
}
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
View 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
View 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
View 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分钟桶CSTstart 与 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)
}

View 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
View File

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

View File

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

View File

@ -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
View 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 + bit279bit模式
// - 当WSP_FLAG=0时0 + bit136 + bit26 + bit2710bit模式
//
// 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
View 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为001byte4为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为001byte4为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为111byte4为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)
}

View File

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

File diff suppressed because one or more lines are too long

View 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

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

File diff suppressed because one or more lines are too long

256
static/js/app.js Normal file
View 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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

0
static/js/tailwindcdn.js Normal file
View File

45
static/js/utils.js Normal file
View 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

File diff suppressed because it is too large Load Diff

418
static/js/weather-chart.js Normal file
View 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;

View 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;

File diff suppressed because one or more lines are too long

18
templates/_header.html Normal file
View 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 }}

View 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)', // 57.5
'rgba(97, 169, 97, 0.78)', // 7.510
'rgba(81, 148, 76, 0.78)', // 1012.5
'rgba(90, 158, 112, 0.78)', // 12.515
'rgba(143, 194, 254, 0.78)', // 1517.5
'rgba(92, 134, 245, 0.78)', // 17.520
'rgba(66, 87, 240, 0.78)', // 2025
'rgba(45, 48, 214, 0.78)', // 2530
'rgba(26, 15, 166, 0.78)', // 3040
'rgba(63, 22, 145, 0.78)', // 4050
'rgba(191, 70, 148, 0.78)', // 5075
'rgba(213, 1, 146, 0.78)', // 75100
'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>

View File

@ -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)">&lt; 上一页</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)">下一页 &gt;</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>

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

View 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
View 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
View 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
View 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)">&lt; 上一页</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)">下一页 &gt;</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>