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
+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');
});
});