From 91c881d0664ea7c8570cdd5819a9ffa7039f57bc Mon Sep 17 00:00:00 2001 From: yarnom Date: Fri, 22 Aug 2025 13:44:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A2=84=E6=8A=A5=20?= =?UTF-8?q?open-meteo=20=E9=A2=84=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/database/models.go | 98 +++++++++++++ internal/server/gin.go | 58 ++++++++ pkg/types/types.go | 16 +++ templates/index.html | 269 ++++++++++++++++++++++++++++-------- 4 files changed, 384 insertions(+), 57 deletions(-) diff --git a/internal/database/models.go b/internal/database/models.go index f0fee5e..ef8cef5 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -2,6 +2,7 @@ package database import ( "database/sql" + "fmt" "log" "time" "weatherstation/pkg/types" @@ -258,3 +259,100 @@ func buildWeatherDataQuery(interval string) string { LEFT JOIN rain_sum r ON r.time_group = 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 +} diff --git a/internal/server/gin.go b/internal/server/gin.go index 6d06da2..3d2429f 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -36,6 +36,7 @@ func StartGinServer() error { api.GET("/system/status", systemStatusHandler) api.GET("/stations", getStationsHandler) api.GET("/data", getDataHandler) + api.GET("/forecast", getForecastHandler) } // 获取配置的Web端口 @@ -136,3 +137,60 @@ func getDataHandler(c *gin.Context) { 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) +} diff --git a/pkg/types/types.go b/pkg/types/types.go index f930099..b7c8045 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -39,3 +39,19 @@ 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" +} diff --git a/templates/index.html b/templates/index.html index 68256dd..661b0cc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -448,6 +448,14 @@ +
+ + +
+
@@ -674,8 +682,9 @@ // 初始化日期输入 function initializeDateInputs() { const now = new Date(); - const endDate = new Date(now); - const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24小时前 + //const startDate = new Date(now.getTime() - 48 * 60 * 60 * 1000); // 过去48小时 + const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 过去48小时 + const endDate = new Date(now.getTime() + 3 * 60 * 60 * 1000); // 未来3小时 document.getElementById('startDate').value = formatDatetimeLocal(startDate); document.getElementById('endDate').value = formatDatetimeLocal(endDate); @@ -1155,6 +1164,7 @@ 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('请选择开始和结束时间'); @@ -1162,27 +1172,74 @@ } try { - const params = new URLSearchParams({ + // 查询历史数据 + const historyParams = new URLSearchParams({ decimal_id: decimalId, 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 historyResponse = await fetch(`/api/data?${historyParams}`); + if (!historyResponse.ok) { + throw new Error('查询历史数据失败'); } - const data = await response.json(); + const responseData = await historyResponse.json(); + const historyData = Array.isArray(responseData) ? responseData : []; - if (data.length === 0) { + // 查询预报数据(如果选择了预报提供商且为1小时粒度) + 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('该时间段内无数据'); return; } - displayChart(data); - displayTable(data); + displayChart(historyData, forecastData); + displayTable(historyData, forecastData); // 显示图表和表格 const chartContainer = document.getElementById('chartContainer'); @@ -1198,54 +1255,118 @@ }); } catch (error) { - console.error('查询历史数据失败:', error); - alert('查询历史数据失败: ' + error.message); + 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); + function displayChart(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 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(); + // 创建数据集 + 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'); 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' - } - ] + labels: allLabels, + datasets: datasets }, options: { responsive: true, @@ -1297,23 +1418,57 @@ } // 显示数据表格(按时间倒序) - function displayTable(data) { + function displayTable(historyData = [], forecastData = []) { const tbody = document.getElementById('tableBody'); tbody.innerHTML = ''; - const rows = [...data].reverse(); - rows.forEach(item => { + // 确保数据是数组 + historyData = Array.isArray(historyData) ? historyData : []; + 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'); + // 预报数据使用不同的背景色 + if (item.source === '预报') { + row.style.backgroundColor = 'rgba(255, 165, 0, 0.1)'; + } + row.innerHTML = ` - ${item.date_time} - ${item.temperature.toFixed(2)} - ${item.humidity.toFixed(2)} - ${item.pressure.toFixed(2)} - ${item.wind_speed.toFixed(2)} - ${item.wind_direction.toFixed(2)} - ${item.rainfall.toFixed(3)} - ${item.light.toFixed(2)} - ${item.uv.toFixed(2)} + ${item.date_time} [${item.source}] + ${item.temperature !== null && item.temperature !== undefined ? item.temperature.toFixed(2) : '-'} + ${item.humidity !== null && item.humidity !== undefined ? item.humidity.toFixed(2) : '-'} + ${item.pressure !== null && item.pressure !== undefined ? item.pressure.toFixed(2) : '-'} + ${item.wind_speed !== null && item.wind_speed !== undefined ? item.wind_speed.toFixed(2) : '-'} + ${item.wind_direction !== null && item.wind_direction !== undefined ? item.wind_direction.toFixed(2) : '-'} + ${item.rainfall !== null && item.rainfall !== undefined ? item.rainfall.toFixed(3) : '-'} + ${item.light !== null && item.light !== undefined ? item.light.toFixed(2) : '-'} + ${item.uv !== null && item.uv !== undefined ? item.uv.toFixed(2) : '-'} `; tbody.appendChild(row); });