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:
+163
-2
@@ -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) });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user