feat: 날씨에 일 누적 강수량 + 연속 강수 사상 누적 추가

weather_kma가 RN1(직전 1시간)만 내보내서 "오늘 비 얼마나 왔어"에
답할 데이터가 없었다. 모델이 "1시간 값만 있어 일 누적은 못 낸다"고
한 건 정확한 진술이었고, 공백은 도구 쪽이었다.

초단기실황이 같은 날 과거 base_time을 받아준다는 걸 라이브 API로
확인해, 시간별 실측을 합쳐 일 누적을 만든다. 새 API 승인 불필요.
RN1(HH00)은 HH00에 "끝나는" 1시간이라 0000은 어제 23~24시 —
0100부터 합산한다.

여러 날에 걸친 비는 관측으로 안 된다. 초단기실황은 resultCode 10
("최근 1일 간의 자료만 제공합니다")으로 잘리고, ASOS 시간자료는
이 키로 403 미승인이다. 그래서 사상 누적만 Open-Meteo로 내고
오늘 누적(기상청 실측)과 한 숫자로 합치지 않는다 — 이음매에서
서울 기준 9.8 vs 10.8이 충돌하는데 섞은 값은 검증이 안 된다.

사상 판정: 6시간 이상 그치면 다른 비(기상청 강수 계속시간 관례),
시간당 0.1mm 미만은 강수시간 제외, 조회창 7일(꽉 차면 하한 표시).
이미 6시간 넘게 그친 비와 오늘 안에서 시작한 비는 표시하지 않는다.
forecast_days=1로 받은 뒤 현재 시각까지만 잘라 써서 미도래 예보가
누적에 섞이지 않게 한다.

교차검증(2026-08-16 서울, 같은 구간): KMA 9.8mm vs Open-Meteo 10.3mm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-16 11:52:57 +09:00
co-authored by Claude Opus 5
parent 955406e3bd
commit f5e0dcf9bd
3 changed files with 327 additions and 2 deletions
+163 -2
View File
@@ -790,6 +790,140 @@ async function kmaFetch(endpoint: string, params: Record<string, string>): 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) });
+86
View File
@@ -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');
});
});
+78
View File
@@ -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: [] });
});
});