fix: 新增open-meteo历史数据

This commit is contained in:
yarnom 2025-08-23 03:06:35 +08:00
parent 7f4b8bb18b
commit 67ba5cf21c
5 changed files with 432 additions and 77 deletions

View File

@ -32,6 +32,10 @@ func main() {
var forecastOnly = flag.Bool("forecast_only", false, "仅执行一次open-meteo拉取并退出")
var caiyunOnly = flag.Bool("caiyun_only", false, "仅执行一次彩云拉取并退出")
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")
flag.Parse()
// 设置日志
@ -90,6 +94,18 @@ func main() {
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 == "" {

View File

@ -267,6 +267,65 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time,
if provider != "" {
// 指定预报提供商
if provider == "open-meteo" {
// 合并实时与历史优先实时的最新issued_at
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 IN ('open-meteo','open-meteo_historical')
AND forecast_time BETWEEN $2 AND $3
ORDER BY forecast_time,
CASE WHEN provider='open-meteo' THEN 0 ELSE 1 END,
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, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")}
// 调试日志
log.Printf("执行open-meteo合并查询: stationID=%s, start=%s, end=%s",
stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07"))
// 检查是否有历史数据
var histCount int
err := db.QueryRow(`SELECT COUNT(*) FROM forecast_hourly
WHERE station_id = $1 AND provider = 'open-meteo_historical'
AND forecast_time BETWEEN $2 AND $3`,
stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")).Scan(&histCount)
if err != nil {
log.Printf("查询历史数据计数失败: %v", err)
} else {
log.Printf("时间范围内历史数据计数: %d 条", histCount)
}
// 检查是否有实时数据
var rtCount int
err = db.QueryRow(`SELECT COUNT(*) FROM forecast_hourly
WHERE station_id = $1 AND provider = 'open-meteo'
AND forecast_time BETWEEN $2 AND $3`,
stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")).Scan(&rtCount)
if err != nil {
log.Printf("查询实时数据计数失败: %v", err)
} else {
log.Printf("时间范围内实时数据计数: %d 条", rtCount)
}
} else {
query = `
WITH latest_forecasts AS (
SELECT DISTINCT ON (forecast_time)
@ -293,6 +352,7 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time,
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 = `
@ -322,7 +382,7 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time,
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)
@ -351,7 +411,6 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time,
}
point.Source = "forecast"
points = append(points, point)
// log.Printf("成功扫描预报数据: %+v", point)
}
return points, nil

View File

@ -184,3 +184,158 @@ func upsertForecast(ctx context.Context, db *sql.DB, stationID string, issuedAt,
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
}

View File

@ -192,5 +192,11 @@ func getForecastHandler(c *gin.Context) {
}
log.Printf("查询到预报数据: %d 条", len(points))
// 调试:打印前几条记录
for i, p := range points {
if i < 5 {
log.Printf("预报数据 #%d: time=%s, provider=%s, issued=%s", i, p.DateTime, p.Provider, p.IssuedAt)
}
}
c.JSON(http.StatusOK, points)
}

View File

@ -103,7 +103,7 @@
}
.map-container.collapsed {
height: 100px;
height: 20vh;
}
#map {
@ -140,6 +140,29 @@
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;
@ -181,7 +204,7 @@
th, td {
border: 1px solid #ddd;
padding: 12px 8px;
text-align: left;
text-align: center;
}
th {
@ -476,10 +499,12 @@
<!-- 地图容器 -->
<div class="map-container" id="mapContainer">
<div id="map"></div>
<button class="map-toggle-btn" id="toggleMapBtn" onclick="toggleMap()">折叠地图</button>
</div>
<!-- 图表容器 -->
<div class="chart-container" id="chartContainer">
<div id="stationInfoTitle" class="station-info-title"></div>
<div class="chart-wrapper">
<canvas id="combinedChart"></canvas>
</div>
@ -487,9 +512,15 @@
<!-- 数据表格 -->
<div class="table-container" id="tableContainer">
<div id="forecastToggleContainer" style="padding: 8px 12px; font-size: 12px; color: #666; display: none; display: flex; justify-content: center; align-items: center;">
<label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="showPastForecast" style="vertical-align: middle;">
显示历史预报
</label>
</div>
<table>
<thead>
<tr>
<tr id="tableHeader">
<th>时间</th>
<th>温度 (°C)</th>
<th>湿度 (%)</th>
@ -497,7 +528,6 @@
<th>风速 (m/s)</th>
<th>风向 (°)</th>
<th>雨量 (mm)</th>
<th>降水概率 (%)</th>
<th>光照 (lux)</th>
<th>紫外线</th>
</tr>
@ -519,6 +549,10 @@
let singleStationLayer;
let combinedChart = null;
const CLUSTER_THRESHOLD = 10; // 缩放级别阈值,小于此值时启用集群
let isMapCollapsed = false; // 地图折叠状态
// 缓存最近一次查询数据,便于切换“显示历史预报”选项
let cachedHistoryData = [];
let cachedForecastData = [];
// 十六进制转十进制
function hexToDecimal(hex) {
@ -603,6 +637,14 @@
// 每30秒刷新在线设备数量
setInterval(updateOnlineDevices, 30000);
// 监听"显示历史预报"复选框,切换表格渲染
const showPastForecast = document.getElementById('showPastForecast');
if (showPastForecast) {
showPastForecast.addEventListener('change', function() {
displayTable(cachedHistoryData, cachedForecastData);
});
}
// 添加输入框事件监听
const stationInput = document.getElementById('stationInput');
stationInput.addEventListener('input', function(e) {
@ -1198,30 +1240,14 @@
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) {
@ -1235,11 +1261,37 @@
}
}
// 缓存本次结果,用于表格切换
cachedHistoryData = historyData;
cachedForecastData = forecastData;
if (historyData.length === 0 && forecastData.length === 0) {
alert('该时间段内无数据');
return;
}
// 查找当前选择的站点信息
const station = stations.find(s => s.decimal_id == decimalId);
// 更新站点信息标题
const stationInfoTitle = document.getElementById('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 (!isMapCollapsed) {
toggleMap();
}
displayChart(historyData, forecastData);
displayTable(historyData, forecastData);
@ -1250,11 +1302,13 @@
chartContainer.classList.add('show');
tableContainer.classList.add('show');
// 平滑滚动到图表位置
// 平滑滚动到图表位置,添加延时确保图表完全加载
setTimeout(() => {
chartContainer.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}, 300);
} catch (error) {
console.error('查询数据失败:', error);
@ -1428,10 +1482,58 @@
historyData = Array.isArray(historyData) ? historyData : [];
forecastData = Array.isArray(forecastData) ? forecastData : [];
// 计算将要展示的预报集合未来3小时 + 可选历史)
const nowTs = Date.now();
const future3hTs = nowTs + 3 * 60 * 60 * 1000;
const showPastForecast = document.getElementById('showPastForecast');
const shouldShowPast = !!(showPastForecast && showPastForecast.checked);
const displayedForecast = forecastData.filter(item => {
const t = new Date(item.date_time).getTime();
const isFuture3h = t > nowTs && t <= future3hTs;
const isPast = t <= nowTs;
return isFuture3h || (shouldShowPast && isPast);
});
const timesWithForecast = new Set(displayedForecast.map(f => f.date_time));
const hasForecast = displayedForecast.length > 0;
// 控制"显示历史预报"选择框的显示/隐藏
const forecastToggleContainer = document.getElementById('forecastToggleContainer');
if (forecastToggleContainer) {
forecastToggleContainer.style.display = forecastData.length > 0 ? 'block' : 'none';
}
// 动态构建表头
const thead = document.getElementById('tableHeader');
// 清除旧的表头
thead.innerHTML = '';
// 添加固定列
const fixedHeaders = ['时间', '温度 (°C)', '湿度 (%)', '气压 (hPa)', '风速 (m/s)', '风向 (°)', '雨量 (mm)'];
fixedHeaders.forEach(text => {
const th = document.createElement('th');
th.textContent = text;
thead.appendChild(th);
});
// 如果有预报数据,添加降水概率列
if (hasForecast) {
const th = document.createElement('th');
th.textContent = '降水概率 (%)';
thead.appendChild(th);
}
// 添加剩余列
const remainingHeaders = ['光照 (lux)', '紫外线'];
remainingHeaders.forEach(text => {
const th = document.createElement('th');
th.textContent = text;
thead.appendChild(th);
});
// 合并数据并按时间排序
const allData = [];
// 添加历史数据
// 添加历史数据(实测全量展示)
historyData.forEach(item => {
allData.push({
...item,
@ -1439,12 +1541,12 @@
});
});
// 添加预报数据
forecastData.forEach(item => {
// 添加将展示的预报
displayedForecast.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
@ -1458,21 +1560,38 @@
const row = document.createElement('tr');
// 预报数据使用不同的背景色
if (item.source === '预报') {
row.style.backgroundColor = 'rgba(255, 165, 0, 0.1)';
row.style.backgroundColor = 'rgba(255, 165, 0, 0.08)'; // 淡橘黄色背景
}
row.innerHTML = `
<td>${item.date_time} <span style="font-size: 12px; color: ${item.source === '预报' ? '#ff6600' : '#28a745'};">[${item.source}]</span></td>
<td>${item.temperature !== null && item.temperature !== undefined ? item.temperature.toFixed(2) : '-'}</td>
<td>${item.humidity !== null && item.humidity !== undefined ? item.humidity.toFixed(2) : '-'}</td>
<td>${item.pressure !== null && item.pressure !== undefined ? item.pressure.toFixed(2) : '-'}</td>
<td>${item.wind_speed !== null && item.wind_speed !== undefined ? item.wind_speed.toFixed(2) : '-'}</td>
<td>${item.wind_direction !== null && item.wind_direction !== undefined ? item.wind_direction.toFixed(2) : '-'}</td>
<td>${item.rainfall !== null && item.rainfall !== undefined ? item.rainfall.toFixed(3) : '-'}</td>
<td>${item.source === '预报' && item.precip_prob !== null && item.precip_prob !== undefined ? item.precip_prob : '-'}</td>
<td>${item.light !== null && item.light !== undefined ? item.light.toFixed(2) : '-'}</td>
<td>${item.uv !== null && item.uv !== undefined ? item.uv.toFixed(2) : '-'}</td>
`;
// 移除错误的覆盖逻辑,让实测数据正常显示
const overrideDash = false; // 不再覆盖实测数据为'-'
const fmt2 = v => (v === null || v === undefined ? '-' : Number(v).toFixed(2));
const fmt3 = v => (v === null || v === undefined ? '-' : Number(v).toFixed(3));
// 构建基础列
let columns = [
`<td>${item.date_time}${hasForecast ? ` <span style="font-size: 12px; color: ${item.source === '预报' ? '#ff8c00' : '#28a745'};">[${item.source}]</span>` : ''}</td>`,
`<td>${overrideDash ? '-' : fmt2(item.temperature)}</td>`,
`<td>${overrideDash ? '-' : fmt2(item.humidity)}</td>`,
`<td>${overrideDash ? '-' : fmt2(item.pressure)}</td>`,
`<td>${overrideDash ? '-' : fmt2(item.wind_speed)}</td>`,
`<td>${overrideDash ? '-' : fmt2(item.wind_direction)}</td>`,
`<td>${overrideDash ? '-' : fmt3(item.rainfall)}</td>`
];
// 如果显示预报,添加降水概率列
if (hasForecast) {
columns.push(`<td>${item.source === '预报' && item.precip_prob !== null && item.precip_prob !== undefined ? item.precip_prob : '-'}</td>`);
}
// 添加剩余列
columns.push(
`<td>${overrideDash ? '-' : (item.light !== null && item.light !== undefined ? Number(item.light).toFixed(2) : '-')}</td>`,
`<td>${overrideDash ? '-' : (item.uv !== null && item.uv !== undefined ? Number(item.uv).toFixed(2) : '-')}</td>`
);
row.innerHTML = columns.join('');
tbody.appendChild(row);
});
}