2026-10-04 · updated 2026-10-05 · 16 min read

เขียนเว็บเตือนภัยน้ำท่วมด้วย Open API: Open-Meteo, ThaiWater, RainViewer และ Google Flood Hub

เขียนเว็บเตือนภัยน้ำท่วมด้วย Open API: Open-Meteo, ThaiWater, RainViewer และ Google Flood Hub

เว็บเตือนภัยน้ำท่วมที่ดีไม่ได้ต้องมีเซนเซอร์เป็นของตัวเอง งานหลักคือ เลือกแหล่งข้อมูลให้ถูก ตั้งเกณฑ์ให้มีเหตุผล และนำเสนอให้คนอ่านเข้าใจในไม่กี่วินาที บทความนี้จะพาไล่ดู API สาธารณะที่ใช้ได้จริงในประเทศไทย ทีละแหล่ง พร้อมโค้ดที่รันได้ทันทีด้วย Node.js 18 ขึ้นไป (มี fetch ในตัว ไม่ต้องติดตั้งแพ็กเกจเพิ่ม)

📌ข้อมูลในบทความเป็นตัวช่วย ไม่ใช่ประกาศทางการ

ค่าจากโมเดลและสถานีอัตโนมัติอาจคลาดเคลื่อนหรือขาดหาย เว็บเตือนภัยที่ทำเองควรลิงก์ไปยังประกาศของหน่วยงานรัฐ (กรมอุตุนิยมวิทยา, สสน., ปภ., กรมชลประทาน) เสมอ และไม่ควรใช้แทนคำสั่งอพยพหรือประกาศทางการ

1. เว็บเตือนภัยต้องใช้ข้อมูลอะไรบ้าง

ข้อมูลที่ต้องใช้แบ่งได้เป็น 3 ชั้น ตามลำดับเวลาที่น้ำมาถึง

ชั้นข้อมูลตอบคำถามแหล่งในบทความนี้
ฝน (พยากรณ์ + ตอนนี้)"จะมีน้ำเพิ่มเข้ามาในระบบไหม"Open-Meteo Forecast, RainViewer
ปริมาณน้ำในแม่น้ำ"แม่น้ำจะรับไหวไหมในอีกหลายวันข้างหน้า"Open-Meteo Flood API (GloFAS), Google Flood Forecasting API
ระดับน้ำจากสถานีจริง"ตอนนี้น้ำอยู่สูงแค่ไหนเทียบกับตลิ่ง"ThaiWater / สสน.
แผนภาพแหล่งข้อมูลสี่แหล่ง คือ Open-Meteo Forecast, Open-Meteo Flood, Google Flood Forecasting API และ ThaiWater ส่งข้อมูลเข้า backend ของเรา ซึ่ง cache และคำนวณระดับเตือนก่อนส่ง JSON ให้เบราว์เซอร์ ส่วนภาพแผนที่และเรดาร์เบราว์เซอร์โหลดตรง
รูปที่ 1 สถาปัตยกรรมเว็บเตือนภัยที่ใช้ในบทความนี้

หลักสำคัญมีสองข้อ

  • ข้อมูลตัวเลขให้ผ่าน backend ของเราเสมอ เพราะต้อง cache (ไม่ยิง API ต้นทางทุกครั้งที่มีคนเปิดหน้า) ต้องคำนวณเกณฑ์ และบางแหล่งส่ง JSON มาใหญ่หลาย MB
  • ภาพแผนที่ (tiles) ให้เบราว์เซอร์โหลดตรง เพราะเป็นไฟล์ภาพที่ CDN ของผู้ให้บริการ cache ไว้แล้ว

ชั้นข้อมูลฝนมีอีกทางเลือกหนึ่งคือ WeatherNext 3 ของ Google DeepMind ซึ่งให้พยากรณ์รายชั่วโมงพร้อมช่วงความไม่แน่นอน (64 ฉากทัศน์) แต่ต้องขอสิทธิ์และมีข้อจำกัดเรื่องฝนหนักที่ต้องรู้ก่อนใช้ → WeatherNext 3: ข้อจำกัดเมื่อใช้กับประเทศไทย

2. ฝนพยากรณ์: Open-Meteo Forecast API

Open-Meteo รวมโมเดลพยากรณ์อากาศของหน่วยงานอุตุนิยมวิทยาหลายประเทศ (เช่น ECMWF, NOAA, DWD) ไว้หลัง API เดียว ไม่ต้องสมัครและไม่ต้องใช้ API key ใช้ฟรีสำหรับงานที่ไม่ใช่เชิงพาณิชย์โดยขอให้ใช้อย่างเป็นธรรม (ถ้าเกิน 10,000 ครั้งต่อวันให้ติดต่อผู้ให้บริการ) ข้อมูลเผยแพร่ภายใต้ CC BY 4.0 จึงต้องแสดงเครดิตพร้อมลิงก์ไปยัง Open-Meteo ทุกที่ที่แสดงข้อมูล

ปริมาณฝนอย่างเดียวยังบอกไม่ได้ว่าน่ากังวลแค่ไหน จึงต้องแปลงเป็นระดับตามเกณฑ์ปริมาณฝนของกรมอุตุนิยมวิทยา ซึ่งพิจารณาฝนใน 24 ชั่วโมง ตั้งแต่ 07:00 น. ของวันหนึ่งถึง 07:00 น. ของวันรุ่งขึ้น

ปริมาณฝน 24 ชม. (07:00–07:00 น.)ระดับ
0.1–10.0 มม.ฝนเล็กน้อย
10.1–35.0 มม.ฝนปานกลาง
35.1–90.0 มม.ฝนหนัก
90.1 มม. ขึ้นไปฝนหนักมาก
rain.mjs
JS
// rain.mjs — ฝนพยากรณ์รายวันตามรอบวันของกรมอุตุนิยมวิทยา (07:00–07:00 น.) แล้วจัดระดับตามเกณฑ์ อต.
const RAIN_LEVELS = [
  { min: 90.1, label: "ฝนหนักมาก", level: 3 },
  { min: 35.1, label: "ฝนหนัก", level: 2 },
  { min: 10.1, label: "ฝนปานกลาง", level: 1 },
  { min: 0.1, label: "ฝนเล็กน้อย", level: 0 },
];
 
function classifyRain(mm) {
  const { label, level } = RAIN_LEVELS.find((r) => mm >= r.min) ?? { label: "ไม่มีฝน", level: 0 };
  return { label, level };
}
 
// ฝนรายชั่วโมงของ Open-Meteo คือฝนสะสมของ "ชั่วโมงก่อนหน้า" เวลาที่ระบุ
// ค่าที่ติดป้าย 08:00 คือฝน 07:00–08:00 → ถอย 8 ชั่วโมงแล้วอ่านวันที่ = วันฝนที่เริ่ม 07:00 น.
function rainDayOf(localTime) {
  const t = new Date(localTime + "Z"); // อ่านตัวเลขตรง ๆ ไม่ขึ้นกับ timezone ของเครื่อง
  t.setUTCHours(t.getUTCHours() - 8);
  return t.toISOString().slice(0, 10);
}
 
export async function rainForecast(lat, lon, days = 3) {
  const url = new URL("https://api.open-meteo.com/v1/forecast");
  url.search = new URLSearchParams({
    latitude: lat,
    longitude: lon,
    hourly: "precipitation,precipitation_probability",
    timezone: "Asia/Bangkok", // เวลาใน response เป็นเวลาไทย
    forecast_days: days + 1, // เผื่อชั่วโมงถึง 07:00 น. ของวันสุดท้าย
  });
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Open-Meteo ${res.status}`);
  const { hourly } = await res.json();
 
  const byDay = new Map();
  hourly.time.forEach((time, i) => {
    const day = rainDayOf(time);
    const d = byDay.get(day) ?? { date: day, mm: 0, chance: 0, hours: 0 };
    d.mm += hourly.precipitation[i] ?? 0;
    d.chance = Math.max(d.chance, hourly.precipitation_probability[i] ?? 0);
    d.hours += 1;
    byDay.set(day, d);
  });
 
  return [...byDay.values()]
    .filter((d) => d.hours === 24) // ตัดวันที่ข้อมูลไม่ครบ 24 ชั่วโมง
    .slice(0, days)
    .map(({ date, mm, chance }) => ({ date, mm: Math.round(mm * 10) / 10, chance, ...classifyRain(mm) }));
}

พารามิเตอร์ที่ควรรู้

  • timezone=Asia/Bangkok ทำให้เวลาใน response เป็นเวลาไทย ถ้าไม่ใส่จะได้เวลา GMT
  • hourly=precipitation คือฝนสะสมของชั่วโมงก่อนหน้า เวลาที่ระบุ ค่าที่ติดป้าย 08:00 จึงเป็นฝนช่วง 07:00–08:00 โค้ดจึงถอย 8 ชั่วโมงก่อนอ่านวันที่ เพื่อให้ได้วันฝนที่เริ่ม 07:00 น.
  • forecast_days ใส่ได้สูงสุด 16 วัน แต่ความแม่นยำมักลดลงมากเมื่อพยากรณ์ไกลออกไป

กับดักที่ 1: รอบวันฝนไม่ใช่เที่ยงคืนถึงเที่ยงคืน

Open-Meteo มีตัวแปร daily=precipitation_sum ให้ใช้สะดวก แต่ตัดรอบที่เที่ยงคืน ส่วนเกณฑ์ของกรมอุตุนิยมวิทยานับ 07:00–07:00 น. ตัวอย่างจริงที่เชียงใหม่ตอนเขียนบทความ

วิธีตัดรอบฝนวันที่ 4 ต.ค. 2026ระดับ
เที่ยงคืนถึงเที่ยงคืน (daily=precipitation_sum)3.0 มม.ฝนเล็กน้อย
07:00–07:00 น. ตามกรมอุตุฯ (รวมจาก hourly)16.4 มม.ฝนปานกลาง

ข้อมูลพยากรณ์ชุดเดียวกัน แต่ได้ระดับต่างกัน เพราะฝนส่วนใหญ่ตกหลังเที่ยงคืนถึงเช้าวันที่ 5 ถ้าจะใช้คำเรียกระดับฝนของกรมอุตุฯ ต้องใช้รอบวันของกรมอุตุฯ ด้วย

หลักการเดียวกันนี้ใช้กับข้อมูลทุกแหล่ง สำหรับโมเดลที่ออกผลเป็นเวลา UTC อย่าง WeatherNext รอบวัน 07:00–07:00 น. ตรงกับวัน UTC พอดี จึงจัดกลุ่มได้ง่ายกว่า → รอบวันฝน 07:00–07:00 น. ตรงกับวัน UTC

3. ปริมาณน้ำในแม่น้ำ: Open-Meteo Flood API

Flood API ให้ค่า river discharge (ปริมาณน้ำไหลผ่าน หน่วย m³/s) จาก Global Flood Awareness System (GloFAS) ของโครงการ Copernicus (Alfieri et al., 2013; Harrigan et al., 2020) ค่าเริ่มต้นเป็น GloFAS v4 ความละเอียด 0.05° (ประมาณ 5 กม.) รายวัน ข้อมูลย้อนหลังตั้งแต่ปี 1984 และพยากรณ์ล่วงหน้าได้สูงสุด 210 วัน (forecast_days 0–210, ค่าเริ่มต้น 92 วัน)

TEXT
https://flood-api.open-meteo.com/v1/flood?latitude=15.72&longitude=100.14&daily=river_discharge&forecast_days=14

กับดักที่ 2: พิกัดต้องตกบนตัวแม่น้ำ

API จะเลือกจุดกริดที่ใกล้พิกัดที่ส่งไป ถ้าจุดนั้นเป็นลำน้ำสาขาเล็ก ๆ ค่าที่ได้จะต่างกันมาก ตัวอย่างจริงบริเวณนครสวรรค์ที่ทดลองตอนเขียนบทความ

พิกัดที่ส่งไปจุดกริดที่ได้ปริมาณน้ำ (m³/s)
15.70, 100.1115.675, 100.1251.82
15.72, 100.1415.725, 100.1252,248.05

ห่างกันแค่ประมาณ 5 กม. แต่ค่าต่างกันกว่าพันเท่า เพราะจุดหลังตกบนแม่น้ำเจ้าพระยา ส่วนจุดแรกไม่ใช่ เอกสารของ Open-Meteo เองก็เตือนว่าที่ความละเอียด 5 กม. อาจเลือกแม่น้ำผิด และแนะนำให้ลองขยับพิกัดทีละประมาณ 0.1° (พารามิเตอร์ cell_selection มีค่า land เป็นค่าเริ่มต้น และมี sea, nearest) ให้ดู latitude/longitude ใน response ทุกครั้ง และเทียบกับลำดับขนาดที่ควรเป็น ก่อนบันทึกพิกัดจุดเฝ้าระวังลงระบบ

กับดักที่ 3: ตัวเลขดิบไม่มีความหมายถ้าไม่มีเกณฑ์

2,248 m³/s มากหรือน้อย? คำตอบขึ้นกับแม่น้ำสายนั้น วิธีที่ใช้ง่ายและอธิบายได้คือ เทียบกับสถิติย้อนหลังของจุดเดียวกัน เช่น ถ้าเกิน percentile ที่ 90 ให้ "เฝ้าระวัง" ถ้าเกิน percentile ที่ 98 ให้ "วิกฤต" ตัวเลข P90/P98 นี้เป็นเกณฑ์สำหรับสอนแนวคิด ไม่ใช่เกณฑ์ทางการ (ระบบเตือนภัยของ GloFAS เองใช้เกณฑ์จากคาบการเกิดซ้ำ 2, 5 และ 20 ปี) และควรปรับให้เข้ากับแม่น้ำแต่ละสาย

ℹ️percentile สองแบบที่ชื่อเหมือนกัน

P90 ในหัวข้อนี้คือ percentile ของ สถิติย้อนหลัง 15 ปี ของจุดเดียวกัน (ตอบว่า "สูงผิดปกติไหม") ส่วน _p90 ของ WeatherNext 3 คือ percentile ของ 64 ฉากทัศน์ของพยากรณ์รอบเดียว (ตอบว่า "แย่ได้แค่ไหน") ทั้งสองแบบใช้ร่วมกันได้ เช่น เตือนเมื่อ _p90 ของพยากรณ์สูงกว่า P90 ของสถิติ → อ่านพยากรณ์แบบ ensemble ให้เป็น

river.mjs
JS
// river.mjs — เทียบปริมาณน้ำพยากรณ์กับสถิติย้อนหลังของจุดเดียวกัน
const FLOOD_API = "https://flood-api.open-meteo.com/v1/flood";
 
async function getDischarge(params) {
  const url = new URL(FLOOD_API);
  url.search = new URLSearchParams({ daily: "river_discharge", ...params });
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Flood API ${res.status}`);
  return res.json();
}
 
function percentile(sorted, p) {
  return sorted[Math.floor(p * (sorted.length - 1))];
}
 
// คำนวณเกณฑ์ครั้งเดียวแล้วเก็บไว้ (ไม่ต้องดึงข้อมูล 15 ปีทุกครั้ง)
export async function buildThresholds(lat, lon) {
  const { daily } = await getDischarge({
    latitude: lat, longitude: lon,
    start_date: "2011-01-01", end_date: "2025-12-31",
  });
  const values = daily.river_discharge.filter((v) => v != null).sort((a, b) => a - b);
  return { p50: percentile(values, 0.5), p90: percentile(values, 0.9), p98: percentile(values, 0.98) };
}
 
export async function riverStatus(lat, lon, t) {
  const data = await getDischarge({ latitude: lat, longitude: lon, forecast_days: 14 });
  const { time, river_discharge: q } = data.daily;
  const peak = Math.max(...q);
  const level = peak >= t.p98 ? "วิกฤต" : peak >= t.p90 ? "เฝ้าระวัง" : "ปกติ";
  return {
    cell: [data.latitude, data.longitude], // จุดกริดที่ API เลือกให้จริง
    today: q[0],
    peak,
    peakDate: time[q.indexOf(peak)],
    level,
  };
}

ทดลองเรียกทุกฟังก์ชันพร้อมกัน

try.mjs
JS
import { rainForecast } from "./rain.mjs";
import { buildThresholds, riverStatus } from "./river.mjs";
import { waterLevels } from "./waterlevel.mjs";
 
console.table(await rainForecast(18.79, 98.98)); // เชียงใหม่
 
const [lat, lon] = [15.72, 100.14]; // แม่น้ำเจ้าพระยา นครสวรรค์
const t = await buildThresholds(lat, lon);
console.log(await riverStatus(lat, lon, t));
 
console.table((await waterLevels("พระนครศรีอยุธยา")).slice(0, 5));
TEXT
$ node try.mjs
{ cell: [ 15.725006, 100.125 ], today: 2248.05, peak: 2248.05, peakDate: '2026-10-04', level: 'เฝ้าระวัง' }
กราฟปริมาณน้ำแม่น้ำเจ้าพระยาที่นครสวรรค์ ย้อนหลัง 30 วันและพยากรณ์ 30 วัน ค่าวันนี้ 2,248 m³/s อยู่เหนือเส้น P90 ที่ 2,117 แต่ต่ำกว่าเส้น P98 ที่ 3,295 และพยากรณ์มีแนวโน้มลดลง
รูปที่ 2 ปริมาณน้ำเทียบเกณฑ์จากสถิติ 15 ปี (ข้อมูล: Open-Meteo Flood API / GloFAS, CC BY 4.0, ดึงเมื่อ 4 ต.ค. 2026)
ℹ️GloFAS เป็นค่าจำลอง ไม่ใช่ค่าวัดจริง

GloFAS เหมาะกับการดูแนวโน้มและการเตือนล่วงหน้าระดับลุ่มน้ำ แต่ตัวเลขอาจต่างจากสถานีวัดน้ำของกรมชลประทานในจุดเดียวกัน จึงควรใช้คู่กับข้อมูลสถานีจริงในหัวข้อ 5 และเรียกเกณฑ์ว่า "สูงกว่าปกติของพื้นที่" ไม่ใช่ "น้ำจะท่วม"

สถิติในตัวอย่างคำนวณจากชุดข้อมูลแบบ seamless ของ Open-Meteo ซึ่งต่อข้อมูลย้อนหลัง (consolidated) เข้ากับข้อมูลพยากรณ์ในช่วงหลัง ถ้าจะใช้ในงานวิจัยหรือตั้งเกณฑ์จริง ควรเลือกช่วงที่เป็นข้อมูล consolidated ล้วน (ตาม Open-Meteo คือปี 1984 ถึงกลางปี 2022) หรือใช้ GloFAS-ERA5 reanalysis โดยตรง

ถ้าใช้ Open-Meteo ในงานเชิงพาณิชย์หรือปริมาณมาก ต้องสมัครแผนแบบเสียเงิน แล้วใช้โดเมน customer-… (เช่น customer-flood-api.open-meteo.com) คู่กับพารามิเตอร์ apikey

4. มุมของ Google: Flood Hub และ Flood Forecasting API

นอกจาก GloFAS แล้ว Google Research ก็มีระบบพยากรณ์น้ำท่วมในแม่น้ำด้วย AI ที่ครอบคลุมกว่า 150 ประเทศ รวมถึงประเทศไทย ข้อมูลเปิดให้ดูฟรีบน Flood Hub และให้นักพัฒนาดึงไปใช้ผ่าน API

4.1 ทำงานอย่างไร

ระบบของ Google มีสองแบบจำลองต่อกัน คือ แบบจำลองอุทกวิทยา (hydrologic model) ที่พยากรณ์ปริมาณน้ำในแม่น้ำ และ แบบจำลองพื้นที่น้ำท่วม (inundation model) ที่คาดว่าพื้นที่ใดจะถูกน้ำท่วมและลึกเท่าใด ข้อมูลนำเข้าเป็นผลพยากรณ์อากาศระดับโลก ข้อมูลสถานีวัดน้ำ และภาพดาวเทียมที่เปิดเผยต่อสาธารณะ ตามหน้าโครงการ พยากรณ์น้ำท่วมในแม่น้ำได้ล่วงหน้าสูงสุด 7 วัน และมีพยากรณ์น้ำท่วมฉับพลันในเมืองล่วงหน้าสูงสุด 24 ชั่วโมงที่ความละเอียด 20 × 20 กม.

งานวิจัยที่ตีพิมพ์ใน Nature (Nearing et al., 2024) รายงานว่าแบบจำลอง AI นี้พยากรณ์เหตุการณ์น้ำท่วมรุนแรงใน ลุ่มน้ำที่ไม่มีสถานีวัดน้ำ (ungauged) ล่วงหน้า 5 วัน ได้ความน่าเชื่อถือใกล้เคียงหรือดีกว่าการพยากรณ์ของ GloFAS ณ วันเกิดเหตุ (lead time 0 วัน) ซึ่งสำคัญกับประเทศที่สถานีวัดน้ำยังไม่ทั่วถึง

4.2 Flood Forecasting API

Flood Forecasting API ไม่มีค่าใช้จ่าย ข้อมูลอยู่ภายใต้ CC BY 4.0 แต่ต้องลงชื่อใน waitlist ก่อน เมื่อได้รับอนุมัติแล้ว ให้ส่ง Google Cloud Project ID กลับไป สร้าง API key และเปิดใช้ API ในโปรเจกต์ นอกจากนี้ยังมีชุดข้อมูลย้อนหลังให้ใช้ เช่น Google Runoff Reanalysis & Reforecast (GRRR) ปริมาณน้ำท่าปี 1980–2023 และ Inundation History

ข้อมูลความหมาย
gaugesจุดพยากรณ์ (gauge) พร้อมพิกัด ชื่อแม่น้ำ หน่วยงานเจ้าของข้อมูล (source) และ qualityVerified
floodStatusสถานะล่าสุดของแต่ละ gauge: severity (EXTREME, SEVERE, ABOVE_NORMAL, NO_FLOODING, UNKNOWN), แนวโน้ม forecastTrend (RISE, FALL, NO_CHANGE) และชุดแผนที่พื้นที่น้ำท่วม
google-flood.mjs
JS
// google-flood.mjs — สถานะน้ำท่วมล่าสุดในไทยจาก Google Flood Forecasting API
// ต้องลงชื่อใน waitlist ให้ได้รับอนุมัติ แล้วตั้ง GOOGLE_FLOOD_API_KEY ใน environment
const BASE = "https://floodforecasting.googleapis.com/v1";
const KEY = process.env.GOOGLE_FLOOD_API_KEY;
 
async function post(path, body) {
  const res = await fetch(`${BASE}/${path}?key=${KEY}`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${path} ${res.status}: ${await res.text()}`);
  return res.json();
}
 
// 1) สถานะล่าสุดของทุก gauge ในไทย (ค่าเริ่มต้นคืนเฉพาะ gauge ที่โมเดลผ่านการตรวจคุณภาพ)
const { floodStatuses = [] } = await post("floodStatus:searchLatestFloodStatusByArea", { regionCode: "TH" });
 
// 2) ชื่อสถานีและแม่น้ำ (อย่า cache ผลนี้นานเกิน 1 วัน เพราะ gauge ถูกเพิ่ม/ลบได้)
const { gauges = [] } = await post("gauges:searchGaugesByArea", { regionCode: "TH" });
const info = new Map(gauges.map((g) => [g.gaugeId, g]));
 
const ORDER = { EXTREME: 0, SEVERE: 1, ABOVE_NORMAL: 2, NO_FLOODING: 3, UNKNOWN: 4 };
const rows = floodStatuses
  .filter((s) => s.severity !== "NO_FLOODING")
  .sort((a, b) => ORDER[a.severity] - ORDER[b.severity])
  .map((s) => ({
    severity: s.severity,          // EXTREME / SEVERE / ABOVE_NORMAL / UNKNOWN
    trend: s.forecastTrend,        // RISE / FALL / NO_CHANGE
    river: info.get(s.gaugeId)?.river ?? "-",
    site: info.get(s.gaugeId)?.siteName ?? s.gaugeId,
    issued: s.issuedTime,
    source: s.source,              // หน่วยงานเจ้าของข้อมูล gauge
  }));
 
console.table(rows);
⚠️ข้อควรระวังเมื่อใช้ข้อมูลของ Google
  • ค่าเริ่มต้นของ API คืนเฉพาะ gauge ที่โมเดล ผ่านการตรวจคุณภาพ ถ้าเปิด includeNonQualityVerified เอกสารเตือนให้ใช้ด้วยความระมัดระวัง
  • รายการ gauge เปลี่ยนได้ เอกสารแนะนำ ไม่ให้ cache นานเกิน 1 วัน
  • ระดับ severity เป็นเกณฑ์ของระบบ Google ไม่ใช่ระดับเตือนภัยของกรมชลประทานหรือ สสน. ควรแสดงคู่กับระดับน้ำจากสถานีจริงในหัวข้อ 5 และอ้างอิงประกาศทางการ

Google มีอีกชิ้นหนึ่งที่ต่อกับระบบนี้ได้ คือฝนพยากรณ์จาก WeatherNext 3 ของ Google DeepMind ซึ่งเป็นโมเดลเบื้องหลัง Google Maps Platform Weather API ใช้แทนหรือคู่กับ Open-Meteo ในหัวข้อ 2 ได้ ดูวิธีเรียกที่ Weather API: พยากรณ์รายชั่วโมงของจุดหนึ่ง แต่ก่อนนำฝนจากโมเดลนี้มาใช้ตัดสินเตือนภัย ควรอ่าน ยังไม่ได้ประเมินฝนหนัก ก่อน

5. ระดับน้ำจากสถานีจริง: ThaiWater / สสน.

คลังข้อมูลน้ำแห่งชาติ (สสน.) รวมข้อมูลสถานีโทรมาตรจากหลายหน่วยงาน เช่น กรมชลประทาน กรมทรัพยากรน้ำ และกรมอุตุนิยมวิทยา ช่องทางเข้าถึงมีสองแบบ ซึ่งสถานะต่างกันมาก

5.1 ช่องทางทางการ: มาตรฐานแลกเปลี่ยนข้อมูล (standard.thaiwater.net)

standard.thaiwater.net เป็น ข้อกำหนด (specification) ที่หน่วยงานด้านน้ำใช้แลกเปลี่ยนข้อมูลกัน ไม่ใช่ endpoint เดียวที่เปิดให้ทุกคนเรียก ในเอกสารใช้ URL ตัวอย่างอย่าง https://api.my.go.th/twsapi/v1.0/ ส่วน URL จริงขึ้นกับหน่วยงานเจ้าของข้อมูล

ResourcePathข้อมูล
A001/Rainfallปริมาณฝน
A002/Runoffน้ำท่า (การไหลในลำน้ำ)
A003–A005/LargesizedWaterResources ฯลฯแหล่งน้ำขนาดใหญ่ / กลาง / เล็ก
A006/WaterQualityคุณภาพน้ำ
B001/StationInfoข้อมูลสถานี
B002–B004/LargesizedWaterResourcesInfo ฯลฯข้อมูลแหล่งน้ำ

พารามิเตอร์ใช้ชุดเดียวกันทุก resource เช่น latest, startDatetime/endDatetime, provinceCode, amphoeCode, basinCode, stationCode ผลลัพธ์เป็น JSON แบบ time series (metadata + timeSeriesObservation[]) ส่วนการยืนยันตัวตนรองรับ API Key, คู่กุญแจ หรือ OAuth 2.0 / OpenID Connect

💡ถ้าจะทำระบบจริง เริ่มที่ทางนี้

ติดต่อขอสิทธิ์จากหน่วยงานเจ้าของข้อมูลตามมาตรฐานนี้ จะได้ข้อตกลงการใช้งานที่ชัดเจนและ endpoint ที่ไม่เปลี่ยนโดยไม่แจ้งล่วงหน้า

5.2 สำหรับการเรียนรู้: JSON ที่หน้าเว็บ thaiwater.net ใช้

หน้าเว็บ thaiwater.net ดึงข้อมูลจาก api-v3.thaiwater.net โดยไม่ต้องใช้ key ดูได้จากแท็บ Network ใน DevTools เช่น

Endpoint (ต่อจาก /api/v1/thaiwater30)ข้อมูล
/public/thailandภาพรวมทั้งประเทศ: ระดับน้ำ ฝน เขื่อน พายุ (~6 MB)
/public/rain_24hฝนสะสม 24 ชม. รายสถานี
/public/thaiwater/weatherพยากรณ์อากาศ
⚠️ไม่ใช่ public API อย่างเป็นทางการ

endpoint เหล่านี้เป็นการสื่อสารภายในระหว่างหน้าเว็บกับ backend ของ thaiwater.net ไม่มีเอกสาร ไม่มีข้อตกลงการใช้งาน และอาจเปลี่ยนหรือปิดได้ทุกเมื่อ ใช้เพื่อศึกษารูปแบบข้อมูลได้ แต่ถ้าจะใช้จริงควรขอสิทธิ์ตามข้อ 5.1 และไม่ว่ากรณีใด ต้อง cache ฝั่ง server และไม่เรียกถี่เกินจำเป็น

ข้อมูลระดับน้ำแต่ละสถานีมีทั้ง ระดับน้ำ (ม.รทก.), ระดับตลิ่งต่ำสุด (min_bank) และ ระดับท้องน้ำ (ground_level) ค่า storage_percent คือระดับน้ำคิดเป็นร้อยละของความจุลำน้ำถึงตลิ่ง

TEXT
storage_percent = (ระดับน้ำ − ระดับท้องน้ำ) ÷ (ระดับตลิ่ง − ระดับท้องน้ำ) × 100

เช่น สถานี C.67 สะพานหัวเวียง: (5.51 − (−6.58)) ÷ (2.75 − (−6.58)) × 100 ≈ 129.6% หมายความว่าน้ำล้นตลิ่งแล้ว 2.76 ม. สูตรนี้ตรงกับค่าที่ API ส่งมาในสถานีส่วนใหญ่ (761 จาก 788 สถานีที่มีข้อมูลครบตอนทดสอบ) จึงควรใช้ค่า storage_percent ที่ API ให้มาโดยตรง และ situation_level 1–5 แปลงเป็นข้อความตามเกณฑ์ ≤10, >10, >30, >70, >100 เปอร์เซ็นต์

waterlevel.mjs
JS
// waterlevel.mjs — ระดับน้ำจากสถานีโทรมาตร (JSON ที่ thaiwater.net ใช้แสดงผล)
// ⚠️ endpoint นี้ไม่มีเอกสารทางการ อาจเปลี่ยนได้ทุกเมื่อ — ใช้เพื่อการเรียนรู้
const URL_TW = "https://api-v3.thaiwater.net/api/v1/thaiwater30/public/thailand";
 
const SITUATION = { 1: "น้ำน้อยวิกฤติ", 2: "น้ำน้อย", 3: "น้ำปกติ", 4: "น้ำมาก", 5: "ล้นตลิ่ง" };
const STALE_HOURS = 6; // ข้อมูลเก่ากว่านี้ = สถานีอาจขัดข้อง อย่าใช้ตัดสินเตือนภัย
 
export async function waterLevels(provinceName) {
  const res = await fetch(URL_TW);
  if (!res.ok) throw new Error(`ThaiWater ${res.status}`);
  const json = await res.json(); // ~6 MB — ควรเรียกจาก backend แล้ว cache เท่านั้น
 
  return json.waterlevel.data.data
    .filter((r) => r.geocode.province_name.th === provinceName && r.waterlevel_msl != null)
    .map((r) => ({
      station: r.station.tele_station_name.th,
      code: r.station.tele_station_oldcode,
      lat: r.station.tele_station_lat,
      lon: r.station.tele_station_long,
      time: r.waterlevel_datetime,
      level_msl: Number(r.waterlevel_msl),
      bank_msl: r.station.min_bank,
      percent_of_bank: Number(r.storage_percent),
      situation: SITUATION[r.situation_level] ?? "-",
      // เวลาของ ThaiWater เป็นเวลาไทย ไม่มี timezone ต่อท้าย
      stale: Date.now() - Date.parse(r.waterlevel_datetime.replace(" ", "T") + "+07:00") > STALE_HOURS * 3600e3,
    }))
    .sort((a, b) => b.percent_of_bank - a.percent_of_bank);
}

ข้อมูลสถานีชุดนี้ยังเป็น "ค่าวัดจริง" ที่ใช้ตรวจเทียบความแม่นของโมเดลพยากรณ์ในพื้นที่ของเราได้ด้วย → ตรวจเทียบกับฝนที่วัดได้จริง

กับดักที่ 4: ข้อมูลเก่าที่ดูเหมือนข้อมูลใหม่

ตอนทดสอบเจอสถานีหนึ่งในอยุธยาที่ค่าล่าสุดเป็นของ 2 วันก่อน API ยังส่งค่าออกมาตามปกติ ไม่ได้บอกว่าขาดหาย ถ้าไม่เช็กเวลา หน้าเว็บจะแสดงค่าเก่าเหมือนเป็นค่าปัจจุบัน ฟิลด์ stale ในโค้ดด้านบนมีไว้แก้ปัญหานี้ ให้แสดงสถานีเหล่านั้นเป็นสีเทา และไม่นำไปคำนวณการเตือน

6. รวมทุกแหล่งเป็น API เดียว พร้อม cache

backend ทำหน้าที่สามอย่าง คือ ดึงตามรอบ (แต่ละแหล่งตั้งอายุ cache ต่างกันตามความถี่ที่ข้อมูลเปลี่ยน), ส่งค่าเก่าพร้อมบอกว่าเก่าเมื่อแหล่งต้นทางล่ม และส่ง JSON ก้อนเดียวให้หน้าเว็บ

alerts.mjs
JS
// alerts.mjs — รวมทุกแหล่งเป็น endpoint เดียว พร้อม cache ในหน่วยความจำ
import http from "node:http";
import { readFile } from "node:fs/promises";
import { rainForecast } from "./rain.mjs";
import { buildThresholds, riverStatus } from "./river.mjs";
import { waterLevels } from "./waterlevel.mjs";
 
const cache = new Map();
async function cached(key, ttlMs, fn) {
  const hit = cache.get(key);
  if (hit && Date.now() - hit.at < ttlMs) return hit.value;
  try {
    const value = await fn();
    cache.set(key, { at: Date.now(), value });
    return value;
  } catch (err) {
    if (hit) return { ...hit.value, stale: true }; // แหล่งล่ม → ส่งค่าเก่าพร้อมบอกว่าเก่า
    throw err;
  }
}
 
const MIN = 60_000;
const SITE = { name: "นครสวรรค์", lat: 15.72, lon: 100.14, province: "นครสวรรค์" };
 
async function buildAlerts() {
  const thresholds = await cached("thresholds", 24 * 60 * MIN, () => buildThresholds(SITE.lat, SITE.lon));
  const [rain, river, stations] = await Promise.all([
    cached("rain", 30 * MIN, () => rainForecast(SITE.lat, SITE.lon)),
    cached("river", 60 * MIN, () => riverStatus(SITE.lat, SITE.lon, thresholds)),
    cached("water", 10 * MIN, () => waterLevels(SITE.province)),
  ]);
  const overBank = stations.filter((s) => !s.stale && s.percent_of_bank > 100);
  return {
    site: SITE.name,
    generatedAt: new Date().toISOString(),
    rain,
    river: { ...river, thresholds },
    overBank,
    stations,
    sources: ["Open-Meteo (CC BY 4.0)", "คลังข้อมูลน้ำแห่งชาติ (สสน.)"],
  };
}
 
http
  .createServer(async (req, res) => {
    if (req.url === "/") {
      res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
      return res.end(await readFile(new URL("./index.html", import.meta.url)));
    }
    if (req.url !== "/api/alerts") return res.writeHead(404).end();
    try {
      res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
      res.end(JSON.stringify(await buildAlerts(), null, 2));
    } catch (err) {
      res.writeHead(502).end(JSON.stringify({ error: String(err) }));
    }
  })
  .listen(3000, () => console.log("http://localhost:3000/api/alerts"));
ข้อมูลอายุ cacheเหตุผล
เกณฑ์จากสถิติ24 ชม.คำนวณจากข้อมูล 15 ปี เปลี่ยนช้ามาก
ปริมาณน้ำ GloFAS60 นาทีโมเดลอัปเดตวันละครั้ง
ฝนพยากรณ์30 นาทีโมเดลอัปเดตทุกไม่กี่ชั่วโมง
ระดับน้ำสถานี10 นาทีสถานีรายงานทุก 10–60 นาที

ตอนทดสอบ ครั้งแรกใช้เวลาประมาณ 20 วินาที (ส่วนใหญ่หมดไปกับการดึงสถิติ 15 ปี) ส่วนครั้งถัดไปที่อ่านจาก cache ใช้ไม่ถึง 50 มิลลิวินาที ในระบบจริงควรคำนวณเกณฑ์ล่วงหน้าแล้วเก็บลงฐานข้อมูล และย้าย cache ไปไว้ใน Redis หรือใช้ revalidate ของ framework เช่น Next.js

7. แผนที่: OpenStreetMap + เรดาร์ฝน RainViewer ด้วย Leaflet

RainViewer ให้ภาพเรดาร์ฝนเป็น tile วางซ้อนบนแผนที่ได้ ขั้นตอนคือเรียก weather-maps.json เพื่อหาเฟรมล่าสุด แล้วประกอบ URL ของ tile จาก host + path ของเฟรมนั้น

index.html
HTML
<!doctype html>
<html lang="th">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>เตือนภัยน้ำ</title>
  <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
  <style>
    body { margin: 0; font-family: system-ui, "Noto Sans Thai", sans-serif; background: #fbfaf7; color: #1f1d1a; }
    header { padding: 12px 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
    .card { border: 1px solid #d6d1c4; border-radius: 6px; padding: 8px 12px; background: #fff; }
    .watch { border-color: #c2410c; color: #c2410c; }
    #map { height: calc(100vh - 80px); }
  </style>
</head>
<body>
  <header>
    <strong id="site">กำลังโหลด…</strong>
    <span class="card" id="river"></span>
    <span class="card" id="rain"></span>
  </header>
  <div id="map"></div>
 
  <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
  <script type="module">
    const COLORS = { "ล้นตลิ่ง": "#b91c1c", "น้ำมาก": "#2563eb", "น้ำปกติ": "#15803d", "น้ำน้อย": "#ca8a04", "น้ำน้อยวิกฤติ": "#7c2d12" };
 
    const map = L.map("map").setView([15.2, 100.3], 8);
    L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
      maxZoom: 19,
      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
    }).addTo(map);
 
    // 1) เรดาร์ฝนล่าสุดจาก RainViewer (โหลดตรงจากเบราว์เซอร์)
    const rv = await (await fetch("https://api.rainviewer.com/public/weather-maps.json")).json();
    const frame = rv.radar.past.at(-1);
    L.tileLayer(`${rv.host}${frame.path}/256/{z}/{x}/{y}/2/1_1.png`, {
      opacity: 0.6,
      maxNativeZoom: 7, // ระดับซูมสูงสุดที่ฟรีให้ — ซูมเกินนี้ Leaflet จะขยายภาพเอง
      maxZoom: 19,
      attribution: '<a href="https://www.rainviewer.com/">RainViewer</a>',
    }).addTo(map);
 
    // 2) ข้อมูลตัวเลขจาก backend ของเรา
    const a = await (await fetch("/api/alerts")).json();
    document.querySelector("#site").textContent = `จุดเฝ้าระวัง: ${a.site}`;
    const river = document.querySelector("#river");
    river.textContent = `แม่น้ำ ${a.river.today.toLocaleString()} m³/s · ${a.river.level}`;
    if (a.river.level !== "ปกติ") river.classList.add("watch");
    const rainMax = a.rain.reduce((m, d) => (d.level > m.level ? d : m));
    document.querySelector("#rain").textContent = `ฝน 3 วัน: สูงสุด ${rainMax.label} (${rainMax.date})`;
 
    for (const s of a.stations) {
      L.circleMarker([s.lat, s.lon], {
        radius: 7,
        color: s.stale ? "#9ca3af" : COLORS[s.situation] ?? "#6b7280",
        fillOpacity: 0.8,
      })
        .bindPopup(`<b>${s.station}</b> (${s.code})<br>${s.level_msl} ม.รทก. · ${s.percent_of_bank}% ของตลิ่ง<br>${s.situation} · ${s.time}`)
        .addTo(map);
    }
  </script>
</body>
</html>
หน้าเว็บตัวอย่าง: แถบด้านบนแสดงจุดเฝ้าระวังนครสวรรค์ ปริมาณน้ำ 2,248 m³/s ระดับเฝ้าระวัง และฝนสูงสุดใน 3 วัน ด้านล่างเป็นแผนที่ประเทศไทยพร้อมจุดสถานีวัดน้ำสีเขียว น้ำเงิน และเหลือง
รูปที่ 3 หน้าเว็บตัวอย่างที่รันจากโค้ดในบทความ (แผนที่ © ผู้ร่วมให้ข้อมูล OpenStreetMap · เรดาร์ © RainViewer)

รันทั้งระบบด้วย node alerts.mjs แล้วเปิด http://localhost:3000

🚨เงื่อนไขการใช้ tile
  • OpenStreetMap (Tile Usage Policy): ต้องแสดงเครดิต © OpenStreetMap contributors ให้เห็นชัด, ต้องส่ง Referer/User-Agent ที่ระบุตัวแอปได้, ห้ามดาวน์โหลด tile จำนวนมากหรือโหลดล่วงหน้าเพื่อใช้ออฟไลน์, ต้องเคารพ HTTP cache และไม่มีการรับประกันความพร้อมใช้งาน (no SLA) ถ้าเว็บมีผู้ใช้มาก ควรใช้ผู้ให้บริการ tile เชิงพาณิชย์หรือตั้ง tile server เอง
  • RainViewer (สรุปการเปลี่ยนแปลง API): ตั้งแต่ 1 ม.ค. 2026 API ฟรีให้ใช้ เพื่อการส่วนตัวหรือการศึกษา เหลือเฉพาะเรดาร์ย้อนหลัง 2 ชั่วโมงทุก 10 นาที, ไม่มี nowcast, ซูมได้สูงสุดระดับ 7 (จึงตั้ง maxNativeZoom: 7), เหลือชุดสี Universal Blue ชุดเดียว และจำกัด 100 ครั้งต่อนาทีต่อ IP ต้องแสดงเครดิตพร้อมลิงก์ไปยัง rainviewer.com

8. เปรียบเทียบแหล่งข้อมูล

Open-Meteo ForecastOpen-Meteo FloodGoogle Flood ForecastingThaiWater (ทางการ)ThaiWater (หน้าเว็บ)RainViewer
ข้อมูลฝนพยากรณ์ปริมาณน้ำ (จำลอง GloFAS)สถานะน้ำท่วมราย gauge + แผนที่น้ำท่วม (AI)ฝน น้ำท่า แหล่งน้ำระดับน้ำ ฝน เขื่อนเรดาร์ฝน (ภาพ)
ต้องใช้ keyไม่ (งานไม่เชิงพาณิชย์)ไม่ (งานไม่เชิงพาณิชย์)ต้องผ่าน waitlist + API key (ฟรี)ต้องขอสิทธิ์ไม่ แต่ไม่มีข้อตกลงการใช้งานไม่ (ส่วนตัว/การศึกษา)
ความเสถียรของ APIมีเอกสาร มีเวอร์ชันมีเอกสาร มีเวอร์ชันมีเอกสาร (v1)ตามมาตรฐานเปลี่ยนได้ทุกเมื่อมีเอกสาร แต่ลดบริการลงแล้ว
ความละเอียดกริดของโมเดลกริด ~5 กม. รายวันราย gauge, ล่วงหน้าสูงสุด 7 วันรายสถานีรายสถานีภาพเรดาร์ ซูมสูงสุด 7
เครดิตCC BY 4.0CC BY 4.0CC BY 4.0ตามข้อตกลงระบุแหล่ง สสน.ต้องแสดงเครดิต

ตารางนี้เน้นแหล่งที่เรียกได้ทันที ถ้าต้องการเทียบกับแหล่งที่ต้องขอสิทธิ์อย่าง WeatherNext 3 และ TMD Weather Forecast API ของกรมอุตุฯ ดู ใช้คู่กับแหล่งข้อมูลอื่นอย่างไร

9. เช็กลิสต์ก่อนเปิดเว็บเตือนภัยจริง

  • พิกัดจุดเฝ้าระวังทุกจุดได้ตรวจแล้วว่าตกบนลำน้ำหลัก (กับดักที่ 2)
  • เกณฑ์เตือนอธิบายที่มาได้ และหน้าเว็บบอกว่าคิดจากอะไร (กับดักที่ 3)
  • สถานีที่ข้อมูลเก่าถูกตัดออกจากการตัดสิน (กับดักที่ 4)
  • ตรวจเทียบพยากรณ์กับค่าวัดจริงในพื้นที่อย่างน้อยหนึ่งฤดูฝน (วิธีตรวจเทียบ)
  • มี cache ฝั่ง server และมี fallback เมื่อแหล่งต้นทางล่ม
  • แสดงเวลาที่ดึงข้อมูลล่าสุดบนหน้าเว็บ
  • ฝนรายวันใช้รอบ 07:00–07:00 น. ก่อนเทียบเกณฑ์กรมอุตุฯ (กับดักที่ 1)
  • แสดงเครดิตแหล่งข้อมูลครบ (Open-Meteo, Google, สสน., OpenStreetMap, RainViewer)
  • มีลิงก์ไปประกาศทางการของกรมอุตุนิยมวิทยา สสน. และ ปภ.
  • แจ้งเตือนเฉพาะ ตอนระดับเปลี่ยน ไม่ใช่ทุกครั้งที่ดึงข้อมูล (ช่องทางเช่น LINE Messaging API หรืออีเมล ส่วน LINE Notify ปิดบริการไปแล้วเมื่อ 31 มี.ค. 2025)

โน้ตที่เชื่อมกัน

บทความนี้กับ WeatherNext 3 กับประเทศไทย เป็นโน้ตคู่กัน: บทความนี้เน้น ระบบ (ดึงข้อมูลหลายแหล่ง ตั้งเกณฑ์ แสดงผล) ส่วนบทความนั้นเน้น แหล่งข้อมูลฝนหนึ่งแหล่งแบบเจาะลึก จุดที่สองบทความต่อกัน

แนวคิดในบทความนี้ในบทความ WeatherNext 3
รอบวันฝน 07:00–07:00 น.กับดักที่ 1 (Open-Meteo, เวลาไทย)8.2 (WeatherNext, เวลา UTC)
เกณฑ์ปริมาณฝนของกรมอุตุฯหัวข้อ 28.1 (รวมเกณฑ์การกระจายของฝน)
percentileกับดักที่ 3 (สถิติย้อนหลัง)หัวข้อ 5 (ensemble)
ความละเอียดกริดกับค่าจุดเดียวกับดักที่ 2 (GloFAS 5 กม.)4.3 (ฝนเฉลี่ยกริด 10 กม.)
ข้อมูลสถานีจริงหัวข้อ 5 (ThaiWater)8.4 (ใช้ตรวจเทียบโมเดล)
AI ของ Googleหัวข้อ 4 (Flood Hub: แม่น้ำ)หัวข้อ 1 (WeatherNext 3: ฝน)

แหล่งอ้างอิง

งานวิชาการ

  • Alfieri, L., Burek, P., Dutra, E., Krzeminski, B., Muraro, D., Thielen, J., & Pappenberger, F. (2013). GloFAS – global ensemble streamflow forecasting and flood early warning. Hydrology and Earth System Sciences, 17(3), 1161–1175. https://doi.org/10.5194/hess-17-1161-2013
  • Harrigan, S., Zsoter, E., Alfieri, L., Prudhomme, C., Salamon, P., Wetterhall, F., Barnard, C., Cloke, H., & Pappenberger, F. (2020). GloFAS-ERA5 operational global river discharge reanalysis 1979–present. Earth System Science Data, 12(3), 2043–2060. https://doi.org/10.5194/essd-12-2043-2020
  • Nearing, G., Cohen, D., Dube, V., Gauch, M., Gilon, O., Harrigan, S., และคณะ (2024). Global prediction of extreme floods in ungauged watersheds. Nature, 627(8004), 559–563. https://doi.org/10.1038/s41586-024-07145-1
  • Zippenfenig, P. (2024). Open-Meteo.com Weather API (Version 1.4.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.7970649

เอกสารและแหล่งข้อมูล

By Watcharin Sarachai