Compare commits
No commits in common. "91c881d0664ea7c8570cdd5819a9ffa7039f57bc" and "e4b1c19064cc6f48c620b0216a08c79c11da2770" have entirely different histories.
91c881d066
...
e4b1c19064
@ -7,7 +7,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"weatherstation/internal/database"
|
"weatherstation/internal/database"
|
||||||
"weatherstation/internal/forecast"
|
|
||||||
"weatherstation/internal/selftest"
|
"weatherstation/internal/selftest"
|
||||||
"weatherstation/internal/server"
|
"weatherstation/internal/server"
|
||||||
"weatherstation/internal/tools"
|
"weatherstation/internal/tools"
|
||||||
@ -26,8 +25,6 @@ func main() {
|
|||||||
// 自检控制
|
// 自检控制
|
||||||
var noSelftest = flag.Bool("no-selftest", false, "跳过启动自检")
|
var noSelftest = flag.Bool("no-selftest", false, "跳过启动自检")
|
||||||
var selftestOnly = flag.Bool("selftest_only", false, "仅执行自检后退出")
|
var selftestOnly = flag.Bool("selftest_only", false, "仅执行自检后退出")
|
||||||
// 预报抓取
|
|
||||||
var forecastOnly = flag.Bool("forecast_only", false, "仅执行一次open-meteo拉取并退出")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// 设置日志
|
// 设置日志
|
||||||
@ -48,15 +45,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单次 open-meteo 拉取
|
|
||||||
if *forecastOnly {
|
|
||||||
if err := forecast.RunOpenMeteoFetch(context.Background()); err != nil {
|
|
||||||
log.Fatalf("open-meteo 拉取失败: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("open-meteo 拉取完成")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backfill 调试路径
|
// Backfill 调试路径
|
||||||
if *doBackfill {
|
if *doBackfill {
|
||||||
if *bfFrom == "" || *bfTo == "" {
|
if *bfFrom == "" || *bfTo == "" {
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
"weatherstation/pkg/types"
|
"weatherstation/pkg/types"
|
||||||
@ -259,100 +258,3 @@ func buildWeatherDataQuery(interval string) string {
|
|||||||
LEFT JOIN rain_sum r ON r.time_group = g.time_group
|
LEFT JOIN rain_sum r ON r.time_group = g.time_group
|
||||||
ORDER BY g.time_group`
|
ORDER BY g.time_group`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetForecastData 获取指定站点的预报数据(优先最新issued_at)
|
|
||||||
func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time, provider string) ([]types.ForecastPoint, error) {
|
|
||||||
var query string
|
|
||||||
var args []interface{}
|
|
||||||
|
|
||||||
if provider != "" {
|
|
||||||
// 指定预报提供商
|
|
||||||
query = `
|
|
||||||
WITH latest_forecasts AS (
|
|
||||||
SELECT DISTINCT ON (forecast_time)
|
|
||||||
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
|
|
||||||
FROM forecast_hourly
|
|
||||||
WHERE station_id = $1 AND provider = $2
|
|
||||||
AND forecast_time BETWEEN $3 AND $4
|
|
||||||
ORDER BY forecast_time, issued_at DESC
|
|
||||||
)
|
|
||||||
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
|
|
||||||
FROM latest_forecasts
|
|
||||||
ORDER BY forecast_time`
|
|
||||||
args = []interface{}{stationID, provider, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")}
|
|
||||||
} else {
|
|
||||||
// 不指定预报提供商,取所有
|
|
||||||
query = `
|
|
||||||
WITH latest_forecasts AS (
|
|
||||||
SELECT DISTINCT ON (provider, forecast_time)
|
|
||||||
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
|
|
||||||
FROM forecast_hourly
|
|
||||||
WHERE station_id = $1 AND forecast_time BETWEEN $2 AND $3
|
|
||||||
ORDER BY provider, forecast_time, issued_at DESC
|
|
||||||
)
|
|
||||||
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
|
|
||||||
FROM latest_forecasts
|
|
||||||
ORDER BY forecast_time, provider`
|
|
||||||
args = []interface{}{stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")}
|
|
||||||
}
|
|
||||||
|
|
||||||
// log.Printf("执行预报数据查询: %s, args: %v", query, args)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("数据扫描错误: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
point.Source = "forecast"
|
|
||||||
points = append(points, point)
|
|
||||||
// log.Printf("成功扫描预报数据: %+v", point)
|
|
||||||
}
|
|
||||||
|
|
||||||
return points, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,182 +0,0 @@
|
|||||||
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) {
|
|
||||||
v.ws = data.Hourly.WindSpeed[i]
|
|
||||||
}
|
|
||||||
if i < len(data.Hourly.WindGusts) {
|
|
||||||
v.gust = data.Hourly.WindGusts[i]
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@ -36,7 +36,6 @@ func StartGinServer() error {
|
|||||||
api.GET("/system/status", systemStatusHandler)
|
api.GET("/system/status", systemStatusHandler)
|
||||||
api.GET("/stations", getStationsHandler)
|
api.GET("/stations", getStationsHandler)
|
||||||
api.GET("/data", getDataHandler)
|
api.GET("/data", getDataHandler)
|
||||||
api.GET("/forecast", getForecastHandler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置的Web端口
|
// 获取配置的Web端口
|
||||||
@ -137,60 +136,3 @@ func getDataHandler(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, points)
|
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")
|
|
||||||
|
|
||||||
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, start=%s, end=%s", stationID, provider, 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)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
"weatherstation/internal/config"
|
"weatherstation/internal/config"
|
||||||
"weatherstation/internal/forecast"
|
|
||||||
"weatherstation/internal/tools"
|
"weatherstation/internal/tools"
|
||||||
"weatherstation/model"
|
"weatherstation/model"
|
||||||
)
|
)
|
||||||
@ -145,20 +144,6 @@ func StartUDPServer() error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 后台定时:每小时拉取open-meteo(全站)
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
now := time.Now()
|
|
||||||
next := now.Truncate(time.Hour).Add(time.Hour)
|
|
||||||
time.Sleep(time.Until(next))
|
|
||||||
if err := forecast.RunOpenMeteoFetch(context.Background()); err != nil {
|
|
||||||
log.Printf("open-meteo 定时拉取失败: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("open-meteo 定时拉取完成")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
n, addr, err := conn.ReadFrom(buffer)
|
n, addr, err := conn.ReadFrom(buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -39,19 +39,3 @@ type SystemStatus struct {
|
|||||||
OnlineDevices int `json:"online_devices"`
|
OnlineDevices int `json:"online_devices"`
|
||||||
ServerTime string `json:"server_time"`
|
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"
|
|
||||||
}
|
|
||||||
|
|||||||
@ -448,14 +448,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-group">
|
|
||||||
<label for="forecastProvider">预报源:</label>
|
|
||||||
<select id="forecastProvider">
|
|
||||||
<option value="">不显示预报</option>
|
|
||||||
<option value="open-meteo" selected>Open-Meteo</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label for="startDate">开始时间:</label>
|
<label for="startDate">开始时间:</label>
|
||||||
<input type="datetime-local" id="startDate">
|
<input type="datetime-local" id="startDate">
|
||||||
@ -682,9 +674,8 @@
|
|||||||
// 初始化日期输入
|
// 初始化日期输入
|
||||||
function initializeDateInputs() {
|
function initializeDateInputs() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
//const startDate = new Date(now.getTime() - 48 * 60 * 60 * 1000); // 过去48小时
|
const endDate = new Date(now);
|
||||||
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 过去48小时
|
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24小时前
|
||||||
const endDate = new Date(now.getTime() + 3 * 60 * 60 * 1000); // 未来3小时
|
|
||||||
|
|
||||||
document.getElementById('startDate').value = formatDatetimeLocal(startDate);
|
document.getElementById('startDate').value = formatDatetimeLocal(startDate);
|
||||||
document.getElementById('endDate').value = formatDatetimeLocal(endDate);
|
document.getElementById('endDate').value = formatDatetimeLocal(endDate);
|
||||||
@ -1164,7 +1155,6 @@
|
|||||||
const startTime = document.getElementById('startDate').value;
|
const startTime = document.getElementById('startDate').value;
|
||||||
const endTime = document.getElementById('endDate').value;
|
const endTime = document.getElementById('endDate').value;
|
||||||
const interval = document.getElementById('interval').value;
|
const interval = document.getElementById('interval').value;
|
||||||
const forecastProvider = document.getElementById('forecastProvider').value;
|
|
||||||
|
|
||||||
if (!startTime || !endTime) {
|
if (!startTime || !endTime) {
|
||||||
alert('请选择开始和结束时间');
|
alert('请选择开始和结束时间');
|
||||||
@ -1172,74 +1162,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 查询历史数据
|
const params = new URLSearchParams({
|
||||||
const historyParams = new URLSearchParams({
|
|
||||||
decimal_id: decimalId,
|
decimal_id: decimalId,
|
||||||
start_time: startTime.replace('T', ' ') + ':00',
|
start_time: startTime.replace('T', ' ') + ':00',
|
||||||
end_time: endTime.replace('T', ' ') + ':00',
|
end_time: endTime.replace('T', ' ') + ':00',
|
||||||
interval: interval
|
interval: interval
|
||||||
});
|
});
|
||||||
|
|
||||||
const historyResponse = await fetch(`/api/data?${historyParams}`);
|
const response = await fetch(`/api/data?${params}`);
|
||||||
if (!historyResponse.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('查询历史数据失败');
|
throw new Error('查询失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = await historyResponse.json();
|
const data = await response.json();
|
||||||
const historyData = Array.isArray(responseData) ? responseData : [];
|
|
||||||
|
|
||||||
// 查询预报数据(如果选择了预报提供商且为1小时粒度)
|
if (data.length === 0) {
|
||||||
let forecastData = [];
|
|
||||||
if (forecastProvider && interval === '1hour') {
|
|
||||||
try {
|
|
||||||
// 将十进制ID转换为站点ID格式
|
|
||||||
const hexID = decimalToHex(decimalId);
|
|
||||||
const stationID = `RS485-${hexID}`;
|
|
||||||
|
|
||||||
// 检查是否查询未来时间(包含未来时间的查询)
|
|
||||||
const now = new Date();
|
|
||||||
const endDateTime = new Date(endTime.replace('T', ' ') + ':00');
|
|
||||||
const isIncludingFuture = endDateTime > now;
|
|
||||||
|
|
||||||
let forecastParams;
|
|
||||||
if (isIncludingFuture) {
|
|
||||||
// 查询包含未来时间:只查询未来部分的预报
|
|
||||||
const futureStartTime = now.toISOString().slice(0, 19).replace('T', ' ');
|
|
||||||
forecastParams = new URLSearchParams({
|
|
||||||
station_id: stationID,
|
|
||||||
from: futureStartTime,
|
|
||||||
to: endTime.replace('T', ' ') + ':00',
|
|
||||||
provider: forecastProvider
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 查询历史时间段:查询当时发布的历史预报
|
|
||||||
forecastParams = new URLSearchParams({
|
|
||||||
station_id: stationID,
|
|
||||||
from: startTime.replace('T', ' ') + ':00',
|
|
||||||
to: endTime.replace('T', ' ') + ':00',
|
|
||||||
provider: forecastProvider
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (forecastError) {
|
|
||||||
console.warn('查询预报数据失败:', forecastError);
|
|
||||||
// 预报查询失败不影响历史数据显示
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (historyData.length === 0 && forecastData.length === 0) {
|
|
||||||
alert('该时间段内无数据');
|
alert('该时间段内无数据');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
displayChart(historyData, forecastData);
|
displayChart(data);
|
||||||
displayTable(historyData, forecastData);
|
displayTable(data);
|
||||||
|
|
||||||
// 显示图表和表格
|
// 显示图表和表格
|
||||||
const chartContainer = document.getElementById('chartContainer');
|
const chartContainer = document.getElementById('chartContainer');
|
||||||
@ -1255,118 +1198,54 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('查询数据失败:', error);
|
console.error('查询历史数据失败:', error);
|
||||||
alert('查询数据失败: ' + error.message);
|
alert('查询历史数据失败: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示图表
|
// 显示图表
|
||||||
function displayChart(historyData = [], forecastData = []) {
|
function displayChart(data) {
|
||||||
// 确保数据是数组
|
const labels = data.map(item => item.date_time);
|
||||||
historyData = Array.isArray(historyData) ? historyData : [];
|
const temperatures = data.map(item => item.temperature);
|
||||||
forecastData = Array.isArray(forecastData) ? forecastData : [];
|
const humidities = data.map(item => item.humidity);
|
||||||
|
const rainfalls = data.map(item => item.rainfall);
|
||||||
// 如果没有任何数据,则不绘制图表
|
|
||||||
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 historyLabels = historyData.map(item => item.date_time);
|
|
||||||
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 historyRainfalls = allLabels.map(label => {
|
|
||||||
const item = historyData.find(d => d.date_time === label);
|
|
||||||
return item ? item.rainfall : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 准备预报数据
|
|
||||||
const forecastTemperatures = allLabels.map(label => {
|
|
||||||
const item = forecastData.find(d => d.date_time === label);
|
|
||||||
return item && item.temperature !== null ? item.temperature : null;
|
|
||||||
});
|
|
||||||
const forecastRainfalls = allLabels.map(label => {
|
|
||||||
const item = forecastData.find(d => d.date_time === label);
|
|
||||||
return item && item.rainfall !== null ? item.rainfall : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 销毁旧图表
|
// 销毁旧图表
|
||||||
if (combinedChart) combinedChart.destroy();
|
if (combinedChart) combinedChart.destroy();
|
||||||
|
|
||||||
// 创建数据集
|
|
||||||
const datasets = [
|
|
||||||
{
|
|
||||||
label: '温度 (°C) - 实测',
|
|
||||||
data: historyTemperatures,
|
|
||||||
borderColor: 'rgb(255, 99, 132)',
|
|
||||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
|
||||||
yAxisID: 'y-temperature',
|
|
||||||
tension: 0.4,
|
|
||||||
spanGaps: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '湿度 (%) - 实测',
|
|
||||||
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: '雨量 (mm) - 实测',
|
|
||||||
data: historyRainfalls,
|
|
||||||
type: 'bar',
|
|
||||||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
|
||||||
borderColor: 'rgb(54, 162, 235)',
|
|
||||||
yAxisID: 'y-rainfall'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// 添加预报数据集
|
|
||||||
if (forecastData.length > 0) {
|
|
||||||
datasets.push(
|
|
||||||
{
|
|
||||||
label: '温度 (°C) - 预报',
|
|
||||||
data: forecastTemperatures,
|
|
||||||
borderColor: 'rgb(255, 165, 0)',
|
|
||||||
backgroundColor: 'rgba(255, 165, 0, 0.1)',
|
|
||||||
borderDash: [5, 5], // 虚线
|
|
||||||
yAxisID: 'y-temperature',
|
|
||||||
tension: 0.4,
|
|
||||||
spanGaps: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '雨量 (mm) - 预报',
|
|
||||||
data: forecastRainfalls,
|
|
||||||
type: 'bar',
|
|
||||||
backgroundColor: 'rgba(255, 165, 0, 0.4)',
|
|
||||||
borderColor: 'rgb(255, 165, 0)',
|
|
||||||
yAxisID: 'y-rainfall'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建组合图表
|
// 创建组合图表
|
||||||
const ctx = document.getElementById('combinedChart').getContext('2d');
|
const ctx = document.getElementById('combinedChart').getContext('2d');
|
||||||
combinedChart = new Chart(ctx, {
|
combinedChart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: allLabels,
|
labels: labels,
|
||||||
datasets: datasets
|
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: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@ -1418,57 +1297,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 显示数据表格(按时间倒序)
|
// 显示数据表格(按时间倒序)
|
||||||
function displayTable(historyData = [], forecastData = []) {
|
function displayTable(data) {
|
||||||
const tbody = document.getElementById('tableBody');
|
const tbody = document.getElementById('tableBody');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
// 确保数据是数组
|
const rows = [...data].reverse();
|
||||||
historyData = Array.isArray(historyData) ? historyData : [];
|
rows.forEach(item => {
|
||||||
forecastData = Array.isArray(forecastData) ? forecastData : [];
|
|
||||||
|
|
||||||
// 合并数据并按时间排序
|
|
||||||
const allData = [];
|
|
||||||
|
|
||||||
// 添加历史数据
|
|
||||||
historyData.forEach(item => {
|
|
||||||
allData.push({
|
|
||||||
...item,
|
|
||||||
source: '实测'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加预报数据
|
|
||||||
forecastData.forEach(item => {
|
|
||||||
allData.push({
|
|
||||||
...item,
|
|
||||||
source: '预报',
|
|
||||||
// 预报数据中不包含的字段设为null
|
|
||||||
light: null,
|
|
||||||
wind_speed: item.wind_speed !== null ? item.wind_speed : 0,
|
|
||||||
wind_direction: item.wind_direction !== null ? item.wind_direction : 0
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 按时间倒序排列
|
|
||||||
allData.sort((a, b) => new Date(b.date_time) - new Date(a.date_time));
|
|
||||||
|
|
||||||
allData.forEach(item => {
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
// 预报数据使用不同的背景色
|
|
||||||
if (item.source === '预报') {
|
|
||||||
row.style.backgroundColor = 'rgba(255, 165, 0, 0.1)';
|
|
||||||
}
|
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td>${item.date_time} <span style="font-size: 12px; color: ${item.source === '预报' ? '#ff6600' : '#28a745'};">[${item.source}]</span></td>
|
<td>${item.date_time}</td>
|
||||||
<td>${item.temperature !== null && item.temperature !== undefined ? item.temperature.toFixed(2) : '-'}</td>
|
<td>${item.temperature.toFixed(2)}</td>
|
||||||
<td>${item.humidity !== null && item.humidity !== undefined ? item.humidity.toFixed(2) : '-'}</td>
|
<td>${item.humidity.toFixed(2)}</td>
|
||||||
<td>${item.pressure !== null && item.pressure !== undefined ? item.pressure.toFixed(2) : '-'}</td>
|
<td>${item.pressure.toFixed(2)}</td>
|
||||||
<td>${item.wind_speed !== null && item.wind_speed !== undefined ? item.wind_speed.toFixed(2) : '-'}</td>
|
<td>${item.wind_speed.toFixed(2)}</td>
|
||||||
<td>${item.wind_direction !== null && item.wind_direction !== undefined ? item.wind_direction.toFixed(2) : '-'}</td>
|
<td>${item.wind_direction.toFixed(2)}</td>
|
||||||
<td>${item.rainfall !== null && item.rainfall !== undefined ? item.rainfall.toFixed(3) : '-'}</td>
|
<td>${item.rainfall.toFixed(3)}</td>
|
||||||
<td>${item.light !== null && item.light !== undefined ? item.light.toFixed(2) : '-'}</td>
|
<td>${item.light.toFixed(2)}</td>
|
||||||
<td>${item.uv !== null && item.uv !== undefined ? item.uv.toFixed(2) : '-'}</td>
|
<td>${item.uv.toFixed(2)}</td>
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user