diff --git a/src/tools/weather.ts b/src/tools/weather.ts index 73f79d0..559a6a1 100644 --- a/src/tools/weather.ts +++ b/src/tools/weather.ts @@ -790,6 +790,140 @@ async function kmaFetch(endpoint: string, params: Record): Promi return json?.response?.body?.items?.item || []; } +// 2026-08-16: weather_kma only ever reported RN1 (the last hour), so "오늘 비 얼마나 왔어?" +// had no data behind it and the model correctly-but-uselessly said it couldn't answer. +// 초단기실황 accepts past base_times for the same day (verified against the live API), so the +// daily total is just a sum of the hourly observations — no new API approval needed. +// +// RN1 at base_time HH00 is the rain that fell during the hour ENDING at HH00. So "today +// 00:00 → now" is base_times 0100..HH00; base_time 0000 covers yesterday 23:00–24:00 and is +// deliberately excluded. +export function sumKmaHourlyRain( + hours: Array<{ hour: number; rn1: string | null | undefined }>, +): { mm: number; missing: number[] } { + let mm = 0; + const missing: number[] = []; + for (const { hour, rn1 } of hours) { + if (rn1 == null || rn1 === '') { missing.push(hour); continue; } + // 초단기실황 sends a bare number, but sibling KMA endpoints use "강수없음" for zero — + // strip to digits so either shape lands on a number rather than counting as a gap. + const n = parseFloat(String(rn1).replace(/[^0-9.]/g, '')); + if (Number.isFinite(n)) mm += n; + } + // Sum of one-decimal observations, so bare addition drifts (0.1+0.2 = 0.30000000000000004). + return { mm: Math.round(mm * 10) / 10, missing }; +} + +// 강수 사상(event) 판정 상수. 6시간은 기상청이 강수 계속시간을 끊을 때 쓰는 관례에 맞춘 것 — +// 장마철처럼 오다 말다 하는 비를 매번 새 사상으로 쪼개지 않으면서, 며칠 전 다른 비까지 +// 끌어오지도 않는 선이다. 0.1mm는 관측 최소 단위라 그 미만은 "비가 온 시간"으로 안 친다. +const RAIN_EPISODE_GAP_HOURS = 6; +const RAIN_WET_HOUR_MM = 0.1; + +// 마지막 시각부터 거꾸로 걸어서 이번 강수가 언제 시작됐는지 찾는다. +// 반환이 null이면 (a) 비가 아예 없었거나 (b) 이미 GAP_HOURS 이상 그친 = 진행 중이 아닌 경우다. +// (b)를 굳이 걸러내는 이유: 그친 지 한참인 비의 합계를 "누적"이라고 계속 띄우면 +// 지금 오고 있는 비의 양으로 읽힌다. +export function findRainEpisode( + hours: Array<{ time: string; mm: number }>, + opts: { gapHours?: number; wetMm?: number } = {}, +): { startTime: string; totalMm: number; wetHours: number; truncated: boolean } | null { + const gapHours = opts.gapHours ?? RAIN_EPISODE_GAP_HOURS; + const wetMm = opts.wetMm ?? RAIN_WET_HOUR_MM; + if (!hours.length) return null; + + const isWet = (i: number) => (hours[i].mm ?? 0) >= wetMm; + + let lastWet = -1; + for (let i = hours.length - 1; i >= 0; i--) { + if (isWet(i)) { lastWet = i; break; } + } + if (lastWet === -1) return null; + // 마지막 강수 이후 지금까지 그친 시간이 이미 사상을 끊을 만큼 길면 진행 중이 아니다. + if (hours.length - 1 - lastWet >= gapHours) return null; + + let start = lastWet; + let dryRun = 0; + for (let i = lastWet - 1; i >= 0; i--) { + if (isWet(i)) { start = i; dryRun = 0; continue; } + if (++dryRun >= gapHours) break; + } + + let totalMm = 0; + let wetHours = 0; + for (let i = start; i < hours.length; i++) { + totalMm += hours[i].mm ?? 0; + if (isWet(i)) wetHours++; + } + return { + startTime: hours[start].time, + totalMm: Math.round(totalMm * 10) / 10, + wetHours, + // 조회 창 첫 시각까지 끊기지 않고 닿았으면 그 이전은 안 본 것이라 합계가 하한이다. + truncated: start === 0, + }; +} + +// KMA 관측은 최근 1일이 한계(resultCode 10 "최근 1일 간의 자료만 제공합니다")이고, +// 며칠까지 가는 ASOS 시간자료는 data.go.kr에서 별도 활용신청이 필요한데 이 키로는 아직 +// 403(SERVICE_KEY_IS_NOT_REGISTERED)이다. 그래서 여러 날짜에 걸친 사상 누적만 Open-Meteo로 +// 낸다 — 오늘 누적(KMA 실측)과 섞어서 한 숫자로 만들지 않고 출처를 갈라 따로 표기한다. +// (2026-08-16 서울 실측 교차검증: 같은 구간 KMA 9.8mm vs Open-Meteo 10.3mm, 5% 이내) +async function fetchRainEpisode( + lat: number, lon: number, +): Promise<{ startTime: string; totalMm: number; wetHours: number; truncated: boolean } | null> { + try { + const p = new URLSearchParams({ + latitude: String(lat), longitude: String(lon), + hourly: 'precipitation', past_days: '7', forecast_days: '1', timezone: 'Asia/Seoul', + }); + const res = await fetch(`https://api.open-meteo.com/v1/forecast?${p}`, { signal: AbortSignal.timeout(15_000) }); + if (!res.ok) return null; + const data: any = await res.json(); + const times: string[] = data?.hourly?.time || []; + const values: number[] = data?.hourly?.precipitation || []; + if (!times.length) return null; + // forecast_days=1이라 뒤쪽은 아직 안 온 예보다. 현재 시각까지만 남긴다 — + // 안 그러면 "예보된 비"가 누적에 섞여 이미 내린 양으로 보고된다. + const nowKstHour = new Date(Date.now() + 9 * 3600 * 1000).toISOString().slice(0, 13); + const hours = times + .map((t, i) => ({ time: t, mm: values[i] ?? 0 })) + .filter(h => h.time.slice(0, 13) <= nowKstHour); + return findRainEpisode(hours); + } catch { + return null; + } +} + +async function fetchKmaTodayRain( + nx: number, ny: number, base_date: string, latestHour: number, +): Promise<{ mm: number; missing: number[]; throughHour: number } | null> { + if (latestHour < 1) return null; + const hours = Array.from({ length: latestHour }, (_, i) => i + 1); + const results: Array<{ hour: number; rn1: string | null }> = []; + // data.go.kr throttles bursts, so walk in small batches rather than firing 23 at once. + for (let i = 0; i < hours.length; i += 6) { + const batch = hours.slice(i, i + 6); + const settled = await Promise.all(batch.map(async (hour) => { + try { + const items = await kmaFetch('getUltraSrtNcst', { + base_date, base_time: `${String(hour).padStart(2, '0')}00`, nx: String(nx), ny: String(ny), + }); + const rn1 = items.find((it: any) => it.category === 'RN1')?.obsrValue; + return { hour, rn1: rn1 == null ? null : String(rn1) }; + } catch { + // A single missing hour (NO_DATA, timeout) must not sink the whole total — it is + // reported as a gap so the caller can say the sum is partial instead of wrong. + return { hour, rn1: null }; + } + })); + results.push(...settled); + } + const { mm, missing } = sumKmaHourlyRain(results); + if (missing.length === hours.length) return null; + return { mm, missing, throughHour: latestHour }; +} + // Pure formatting/filtering half of the KMA→Open-Meteo forecast merge — split out (2026-08-10) // so it's unit-testable without a live API call. Given Open-Meteo's daily arrays and the last // YYYYMMDD date KMA's own forecast already covered, returns the lines for whatever days remain @@ -851,7 +985,7 @@ async function fetchOpenMeteoDailySupplement(lat: number, lon: number, lastCover export const weatherKmaTool = { name: 'weather_kma', - description: '기상청 공식 API로 한국 날씨 조회. 초단기실황(현재 기온·강수·바람·습도)과 단기예보(기상청 3일 + Open-Meteo로 자동 보완된 4~7일차)를 제공. 한국 내 위치에 가장 정확한 기상 데이터. type="forecast"는 이번 주말처럼 3일을 넘는 날짜도 한 번의 호출로 답변 가능(참고용 보완 데이터 자동 포함). API 키(data.go.kr) 필요.', + description: '기상청 공식 API로 한국 날씨 조회. 초단기실황(현재 기온·강수·바람·습도)에는 1시간 강수량과 함께 **오늘 00시부터 현재까지의 누적 강수량**(시간별 실측 합산)이 포함되므로, "오늘 비 얼마나 왔어" 같은 일 누적 질문도 이 도구 한 번으로 답할 수 있다. 비가 어제 이전부터 이어지고 있으면 **"이번 강수 누적"**(사상 시작 시각부터의 합계)도 함께 나오므로 "이번 비 얼마나 왔어"도 답할 수 있다 — 이 값만 Open-Meteo 추정이고 나머지는 기상청 실측이라, 답할 때 두 숫자를 섞지 말 것. 그리고 단기예보(기상청 3일 + Open-Meteo로 자동 보완된 4~7일차)를 제공. 한국 내 위치에 가장 정확한 기상 데이터. type="forecast"는 이번 주말처럼 3일을 넘는 날짜도 한 번의 호출로 답변 가능(참고용 보완 데이터 자동 포함). API 키(data.go.kr) 필요.', schema: { location: '한국 도시명 (예: "서울", "부산") 또는 "위도,경도" 형식', type: '"current" (초단기실황, 기본값) 또는 "forecast" (단기예보 3일)', @@ -890,6 +1024,31 @@ export const weatherKmaTool = { const get = (cat: string) => items.find((i: any) => i.category === cat)?.obsrValue ?? 'N/A'; const pty = get('PTY'); const vecDeg = parseFloat(get('VEC')) || 0; + + // Only meaningful when base_date is still today — between 00:00 and 00:40 KST + // getKmaBaseDateTime falls back to yesterday 2300, and there is no "today" to total yet. + const todayKst = new Date(Date.now() + 9 * 3600 * 1000).toISOString().slice(0, 10).replace(/-/g, ''); + const [todayRain, episode] = await Promise.all([ + base_date === todayKst + ? fetchKmaTodayRain(nx, ny, base_date, parseInt(base_time.slice(0, 2), 10)) + : Promise.resolve(null), + fetchRainEpisode(lat, lon), + ]); + const rainLine = todayRain + ? `오늘 누적 강수량: ${todayRain.mm} mm (00시~${String(todayRain.throughHour).padStart(2, '0')}시 실측 합산` + + (todayRain.missing.length ? `, ${todayRain.missing.length}개 시각 결측` : '') + + ')' + : '오늘 누적 강수량: 조회 불가'; + + // 사상이 오늘 안에서 시작했으면 위 "오늘 누적"과 같은 구간이라 한 줄 더 쓸 이유가 없다. + // 어제 이전부터 이어진 비일 때만 붙인다 — 사용자가 원한 게 정확히 그 경우다. + const epStartDay = episode?.startTime.slice(0, 10); + const episodeLine = episode && epStartDay && epStartDay < `${todayKst.slice(0,4)}-${todayKst.slice(4,6)}-${todayKst.slice(6,8)}` + ? `이번 강수 누적: ${episode.truncated ? '≥' : ''}${episode.totalMm} mm ` + + `(${epStartDay.slice(5,7)}/${epStartDay.slice(8,10)} ${episode.startTime.slice(11,13)}시부터 이어짐, ` + + `강수 ${episode.wetHours}시간${episode.truncated ? ', 7일 조회창을 넘겨 하한값' : ''} · Open-Meteo 추정)` + : null; + const lines = [ `기상청 초단기실황: ${displayName}`, `기준시각: ${base_date.slice(0,4)}-${base_date.slice(4,6)}-${base_date.slice(6,8)} ${base_time.slice(0,2)}:${base_time.slice(2,4)} KST`, @@ -897,13 +1056,15 @@ export const weatherKmaTool = { `기온: ${get('T1H')}°C`, `강수형태: ${PTY_LABEL[pty] ?? pty}`, `1시간 강수량: ${get('RN1')} mm`, + rainLine, + ...(episodeLine ? [episodeLine] : []), `습도: ${get('REH')}%`, `풍향: ${windDirectionKo(vecDeg)} (${get('VEC')}°)`, `풍속: ${get('WSD')} m/s`, '', '(미세먼지/PM2.5/PM10/황사는 이 데이터에 없음 — weather_airkorea 사용)', ]; - return { success: true, stdout: lines.join('\n'), data: { location: displayName, nx, ny, items } }; + return { success: true, stdout: lines.join('\n'), data: { location: displayName, nx, ny, items, todayRain, episode } }; } else { const { base_date, base_time } = getKmaBaseDateTime('fcst'); const items = await kmaFetch('getVilageFcst', { base_date, base_time, nx: String(nx), ny: String(ny) }); diff --git a/tests/rain-episode.test.ts b/tests/rain-episode.test.ts new file mode 100644 index 0000000..2fa0665 --- /dev/null +++ b/tests/rain-episode.test.ts @@ -0,0 +1,86 @@ +/** + * rain-episode.test.ts + * + * "연속적인 비라면 비온 날짜부터 누적강수량을 표시하면 좋겠는데" (2026-08-16 요청). + * + * 하루 누적은 기상청 실측으로 되지만, 초단기실황은 `resultCode 10 최근 1일 간의 자료만 + * 제공합니다`로 딱 잘린다. 며칠짜리 ASOS 시간자료는 이 키로 403(미승인)이라, 사상 누적만 + * Open-Meteo로 낸다. findRainEpisode는 그 시간별 배열에서 "이번 비가 언제 시작됐나"를 + * 거꾸로 걸어 찾는 순수 함수다. + * + * 판정 기준 두 개: 6시간 이상 그치면 다른 사상, 시간당 0.1mm 미만은 비 온 시간이 아님. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { findRainEpisode } from '../src/tools/weather'; + +// "..##...#" 같은 문자열을 시간별 배열로. '#'=1.0mm, 숫자=그 값, '.'=0 +function series(start: string, pattern: string): Array<{ time: string; mm: number }> { + const t0 = new Date(start + ':00:00Z').getTime(); + return [...pattern].map((c, i) => ({ + time: new Date(t0 + i * 3600e3).toISOString().slice(0, 16).replace('T', 'T'), + mm: c === '#' ? 1.0 : c === '.' ? 0 : Number(c) / 10, + })); +} + +describe('findRainEpisode — 이어지는 비의 시작 시각 찾기', () => { + test('4시간 소강은 같은 사상으로 이어붙인다', () => { + // 2026-08-16 서울 실제 패턴: 8/15 12시 시작, 중간 최대 4시간 소강 후 8/16 새벽까지. + const ep = findRainEpisode(series('2026-08-15T00', '............##...##....#########...')); + assert.ok(ep); + assert.equal(ep.startTime.slice(0, 13), '2026-08-15T12'); + assert.equal(ep.truncated, false); + }); + + test('6시간 이상 그치면 그 이전은 다른 비다', () => { + // 앞쪽 '##'은 8시간 공백 건너편 — 끌어오면 안 된다. + const ep = findRainEpisode(series('2026-08-16T00', '##........####.')); + assert.ok(ep); + assert.equal(ep.startTime.slice(0, 13), '2026-08-16T10'); + assert.equal(ep.totalMm, 4); + assert.equal(ep.wetHours, 4); + }); + + test('이미 6시간 넘게 그쳤으면 진행 중이 아니므로 null', () => { + // 그친 지 한참인 비의 합계를 "누적"으로 띄우면 지금 오는 비의 양으로 읽힌다. + assert.equal(findRainEpisode(series('2026-08-16T00', '####........')), null); + }); + + test('그친 지 5시간이면 아직 진행 중으로 본다 (경계)', () => { + const ep = findRainEpisode(series('2026-08-16T00', '####.....')); + assert.ok(ep); + assert.equal(ep.wetHours, 4); + }); + + test('0.1mm 미만 흔적은 비 온 시간으로 세지 않는다', () => { + // '1'=0.1mm는 젖은 시간, 하지만 0.0x는 없으니 여기선 '.'로 표현된 0만 마른 시간. + const ep = findRainEpisode(series('2026-08-16T00', '...1#..')); + assert.ok(ep); + assert.equal(ep.startTime.slice(0, 13), '2026-08-16T03'); + assert.equal(ep.wetHours, 2); + assert.equal(ep.totalMm, 1.1); + }); + + test('비가 전혀 없으면 null', () => { + assert.equal(findRainEpisode(series('2026-08-16T00', '............')), null); + }); + + test('빈 배열은 null', () => { + assert.equal(findRainEpisode([]), null); + }); + + test('조회창 첫 시각까지 끊기지 않으면 truncated로 표시한다', () => { + // 7일 창을 꽉 채운 비 — 그 이전은 안 봤으므로 합계는 하한값이고, 출력에 '≥'가 붙는다. + const ep = findRainEpisode(series('2026-08-10T00', '########')); + assert.ok(ep); + assert.equal(ep.truncated, true); + }); + + test('gapHours를 낮추면 같은 데이터가 더 짧은 사상으로 쪼개진다', () => { + const pattern = '##...##'; + assert.equal(findRainEpisode(series('2026-08-16T00', pattern))!.startTime.slice(11, 13), '00'); + const tight = findRainEpisode(series('2026-08-16T00', pattern), { gapHours: 3 }); + assert.equal(tight!.startTime.slice(11, 13), '05'); + }); +}); diff --git a/tests/weather-kma-daily-rain.test.ts b/tests/weather-kma-daily-rain.test.ts new file mode 100644 index 0000000..63f5c4b --- /dev/null +++ b/tests/weather-kma-daily-rain.test.ts @@ -0,0 +1,78 @@ +/** + * weather-kma-daily-rain.test.ts + * + * weather_kma의 초단기실황은 오랫동안 RN1(직전 1시간 강수량)만 내보냈다. 그래서 "오늘 비 + * 얼마나 왔어?"를 물으면 모델이 — 틀린 말은 아니지만 쓸모없게 — "1시간 강수량만 있고 일 + * 누적은 없어서 답할 수 없다"고 답했다(2026-08-16 실제 사례). + * + * 초단기실황이 같은 날 과거 base_time을 받아준다는 걸 라이브 API로 확인해서, 시간별 실측을 + * 합쳐 일 누적을 만든다. 이 파일은 그 합산 로직 — 네트워크를 안 타는 순수 함수다. + * + * 핵심 규칙: RN1(HH00)은 HH00에 "끝나는" 1시간의 강수량이다. 따라서 오늘 00시~현재는 + * base_time 0100..HH00의 합이고, 0000은 어제 23~24시라 빠져야 한다. 이 경계는 호출부 + * (fetchKmaTodayRain)가 지키고, 여기서는 넘어온 값들의 합산·결측 처리만 검증한다. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { sumKmaHourlyRain } from '../src/tools/weather'; + +describe('sumKmaHourlyRain — 시간별 RN1 실측 합산', () => { + test('숫자 문자열을 더해 일 누적을 낸다', () => { + const { mm, missing } = sumKmaHourlyRain([ + { hour: 1, rn1: '0' }, + { hour: 2, rn1: '3.1' }, + { hour: 3, rn1: '0' }, + { hour: 4, rn1: '1.5' }, + ]); + assert.equal(mm, 4.6); + assert.deepEqual(missing, []); + }); + + test('부동소수 드리프트를 소수 1자리로 정리한다', () => { + // 0.1 + 0.2 = 0.30000000000000004 — 관측값이 소수 1자리라 그냥 더하면 그대로 새어나온다. + const { mm } = sumKmaHourlyRain([ + { hour: 1, rn1: '0.1' }, + { hour: 2, rn1: '0.2' }, + ]); + assert.equal(mm, 0.3); + }); + + test('"강수없음"은 결측이 아니라 0으로 센다', () => { + // 초단기실황은 숫자를 주지만 형제 엔드포인트들은 0을 "강수없음"으로 보낸다. + // 이걸 결측으로 세면 멀쩡한 합계에 "결측" 딱지가 붙는다. + const { mm, missing } = sumKmaHourlyRain([ + { hour: 1, rn1: '강수없음' }, + { hour: 2, rn1: '2.0' }, + ]); + assert.equal(mm, 2); + assert.deepEqual(missing, []); + }); + + test('null/빈문자는 결측으로 기록하되 합산은 계속한다', () => { + // 한 시각이 NO_DATA로 빠져도 나머지 합계는 살아야 한다 — 대신 부분합임을 알려야 한다. + const { mm, missing } = sumKmaHourlyRain([ + { hour: 1, rn1: '1.0' }, + { hour: 2, rn1: null }, + { hour: 3, rn1: '' }, + { hour: 4, rn1: undefined }, + { hour: 5, rn1: '2.0' }, + ]); + assert.equal(mm, 3); + assert.deepEqual(missing, [2, 3, 4]); + }); + + test('전부 결측이면 0mm에 결측 목록만 남는다', () => { + // 호출부는 이 경우를 "조회 불가"로 바꿔서 0mm를 사실처럼 말하지 않게 한다. + const { mm, missing } = sumKmaHourlyRain([ + { hour: 1, rn1: null }, + { hour: 2, rn1: null }, + ]); + assert.equal(mm, 0); + assert.deepEqual(missing, [1, 2]); + }); + + test('빈 입력은 0mm', () => { + assert.deepEqual(sumKmaHourlyRain([]), { mm: 0, missing: [] }); + }); +});