feat: 강수 사상 누적을 ASOS 실측으로 전환 + 격자 변환 +1 밀림 수정
ASOS 시간자료/일자료가 승인돼서, 사상 누적을 Open-Meteo 추정에서 기상청 관측으로 바꾼다. 두 소스의 창이 정확히 맞물린다 — ASOS는 "전날 자료까지"(오늘은 resultCode 99), 초단기실황은 "최근 1일"이라 자정에서 나누면 사상 전체가 관측값이 된다. rn 인코딩 주의: ""=무강수, "0.0"=미량(관측됐으나 0.05mm 미만). 둘을 뭉개면 이슬비로 이어지던 비가 소강으로 잡혀 사상이 끊긴다. 그래서 findRainEpisode가 wet을 명시로 받는다(안 주면 종전 mm 임계). 지점 좌표는 일부러 없다. 최근접 지점 방식을 만들다 폐기했다: 지점명 지오코딩이 홍성·보령·밀양을 북한 동명 지역으로, 남원을 제주도로 돌려줬다. 검증하려고 ASOS 관측기온 vs Open-Meteo 기온을 대조했으나 좌표가 정확한 서울조차 Δ3.1°C(도시열섬·모델편차)라 판별자가 못 됐다. 이름 매칭만 쓰고 못 맞히면 Open-Meteo로 물러난다. 지점 목록 97개는 지점정보 API가 없어서(NO_OPENAPI_SERVICE) 일자료 엔드포인트로 stnIds 90~300을 전수조사해 실응답에서 뽑았다. 같이 고친 것: latLonToKmaGrid가 공식 변환식의 반올림 항을 +0.5가 아닌 +1.5로 써서 nx·ny를 나란히 한 칸씩(대각 ~7km) 밀어 조회했다. 이름이 KMA_GRID 표에 있는 도시는 표를 타서 멀쩡했고 좌표 입력만 틀려서 여태 안 드러났다. 같은 서울을 이름과 좌표로 조회했을 때 일 누적이 9.8mm vs 29mm로 갈리며 발견. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+136
-18
@@ -345,7 +345,7 @@ const KMA_GRID: Record<string, [number, number]> = {
|
||||
'안산': [57, 121], 'ansan': [57, 121],
|
||||
};
|
||||
|
||||
function latLonToKmaGrid(lat: number, lon: number): { nx: number; ny: number } {
|
||||
export function latLonToKmaGrid(lat: number, lon: number): { nx: number; ny: number } {
|
||||
const DEGRAD = Math.PI / 180;
|
||||
const re = 6371.00877 / 5.0;
|
||||
const slat1 = 30.0 * DEGRAD;
|
||||
@@ -365,9 +365,14 @@ function latLonToKmaGrid(lat: number, lon: number): { nx: number; ny: number } {
|
||||
if (theta < -Math.PI) theta += 2 * Math.PI;
|
||||
theta *= sn;
|
||||
|
||||
// 기상청 공식 변환식의 마지막 항은 반올림용 +0.5다. 여기 오래 +1.5가 들어가 있어서
|
||||
// nx·ny가 나란히 한 칸씩 밀린 셀을 조회했다(격자 1칸 = 5km, 대각 ~7km).
|
||||
// 이름이 KMA_GRID에 있는 도시는 표를 타서 멀쩡했고 좌표 입력만 틀렸던 탓에 안 드러났다.
|
||||
// 2026-08-16 발견: 같은 서울을 "서울"과 "37.5665,126.978"로 조회했더니 일 누적이
|
||||
// 9.8mm vs 29mm로 갈렸다.
|
||||
return {
|
||||
nx: Math.floor(ra * Math.sin(theta) + XO + 1.5),
|
||||
ny: Math.floor(ro - ra * Math.cos(theta) + YO + 1.5),
|
||||
nx: Math.floor(ra * Math.sin(theta) + XO + 0.5),
|
||||
ny: Math.floor(ro - ra * Math.cos(theta) + YO + 0.5),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -825,14 +830,17 @@ const RAIN_WET_HOUR_MM = 0.1;
|
||||
// (b)를 굳이 걸러내는 이유: 그친 지 한참인 비의 합계를 "누적"이라고 계속 띄우면
|
||||
// 지금 오고 있는 비의 양으로 읽힌다.
|
||||
export function findRainEpisode(
|
||||
hours: Array<{ time: string; mm: number }>,
|
||||
hours: Array<{ time: string; mm: number; wet?: boolean }>,
|
||||
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;
|
||||
// `wet`을 명시로 받는 이유: ASOS는 무강수를 rn=""로, 미량 강수를 rn="0.0"으로 구분한다.
|
||||
// 0.0mm지만 비는 온 시각이라 mm만 보면 소강으로 오판해서 사상이 끊긴다.
|
||||
// 안 주면(Open-Meteo처럼 그 구분이 없는 소스) 종전대로 mm 임계로 판정한다.
|
||||
const isWet = (i: number) => hours[i].wet ?? ((hours[i].mm ?? 0) >= wetMm);
|
||||
|
||||
let lastWet = -1;
|
||||
for (let i = hours.length - 1; i >= 0; i--) {
|
||||
@@ -864,10 +872,104 @@ export function findRainEpisode(
|
||||
};
|
||||
}
|
||||
|
||||
// KMA 관측은 최근 1일이 한계(resultCode 10 "최근 1일 간의 자료만 제공합니다")이고,
|
||||
// 며칠까지 가는 ASOS 시간자료는 data.go.kr에서 별도 활용신청이 필요한데 이 키로는 아직
|
||||
// 403(SERVICE_KEY_IS_NOT_REGISTERED)이다. 그래서 여러 날짜에 걸친 사상 누적만 Open-Meteo로
|
||||
// 낸다 — 오늘 누적(KMA 실측)과 섞어서 한 숫자로 만들지 않고 출처를 갈라 따로 표기한다.
|
||||
// ASOS(종관기상관측) 지점 목록. 지점정보 API가 없어서(NO_OPENAPI_SERVICE_ERROR) 일자료
|
||||
// 엔드포인트로 stnIds 90~300을 전수조사해 실제 응답에서 뽑았다 — 2026-08-16 기준 97개.
|
||||
//
|
||||
// 좌표가 없는 건 의도적이다. 지점명을 지오코딩해 최근접 지점을 고르는 방식을 만들다 폐기했다:
|
||||
// 지오코더가 홍성/보령/밀양을 북한 동명 지역으로, 남원을 제주도로 돌려줬다. 좌표를
|
||||
// 검증하려고 ASOS 관측기온 vs Open-Meteo 기온을 대조해봤지만 좌표가 정확한 서울조차
|
||||
// Δ3.1°C라(도시열섬·모델편차) 판별자가 못 된다. 이름으로만 맞히면 이런 오배치가 구조적으로
|
||||
// 불가능하고, 못 맞히면 조용히 틀리는 대신 Open-Meteo로 명시적으로 물러난다.
|
||||
const ASOS_STATIONS: Array<[number, string]> = [
|
||||
[90, '속초'], [93, '북춘천'], [95, '철원'], [98, '동두천'], [99, '파주'],
|
||||
[100, '대관령'], [101, '춘천'], [102, '백령도'], [104, '북강릉'], [105, '강릉'],
|
||||
[106, '동해'], [108, '서울'], [112, '인천'], [114, '원주'], [115, '울릉도'],
|
||||
[119, '수원'], [121, '영월'], [127, '충주'], [129, '서산'], [130, '울진'],
|
||||
[131, '청주'], [133, '대전'], [135, '추풍령'], [136, '안동'], [137, '상주'],
|
||||
[138, '포항'], [140, '군산'], [143, '대구'], [146, '전주'], [152, '울산'],
|
||||
[155, '창원'], [156, '광주'], [159, '부산'], [162, '통영'], [165, '목포'],
|
||||
[168, '여수'], [169, '흑산도'], [170, '완도'], [172, '고창'], [174, '순천'],
|
||||
[177, '홍성'], [181, '서청주'], [184, '제주'], [185, '고산'], [188, '성산'],
|
||||
[189, '서귀포'], [192, '진주'], [201, '강화'], [202, '양평'], [203, '이천'],
|
||||
[211, '인제'], [212, '홍천'], [216, '태백'], [217, '정선군'], [221, '제천'],
|
||||
[226, '보은'], [232, '천안'], [235, '보령'], [236, '부여'], [238, '금산'],
|
||||
[239, '세종'], [243, '부안'], [244, '임실'], [245, '정읍'], [247, '남원'],
|
||||
[248, '장수'], [251, '고창군'], [252, '영광군'], [253, '김해시'], [254, '순창군'],
|
||||
[255, '북창원'], [257, '양산시'], [258, '보성군'], [259, '강진군'], [260, '장흥'],
|
||||
[261, '해남'], [262, '고흥'], [263, '의령군'], [264, '함양군'], [266, '광양시'],
|
||||
[268, '진도군'], [271, '봉화'], [272, '영주'], [273, '문경'], [276, '청송군'],
|
||||
[277, '영덕'], [278, '의성'], [279, '구미'], [281, '영천'], [283, '경주시'],
|
||||
[284, '거창'], [285, '합천'], [288, '밀양'], [289, '산청'], [294, '거제'],
|
||||
[295, '남해'], [296, '북부산'],
|
||||
];
|
||||
|
||||
// "부산광역시" → "부산", "김해시" → "김해". 지점명 쪽도 같은 함수를 태워서 양쪽을 맞춘다.
|
||||
const stripAdmin = (s: string) =>
|
||||
s.trim().replace(/\s+/g, '').replace(/(특별자치도|특별자치시|특별시|광역시)$/, '').replace(/(시|군|구)$/, '');
|
||||
|
||||
// 지점명에는 방위 접두사가 붙은 게 있다(북창원, 서청주, 북부산…). 사용자는 "창원"이라고
|
||||
// 쓰므로 접두사를 벗겨서도 비교하되, 정확히 일치하는 지점이 있으면 그쪽을 먼저 쓴다.
|
||||
export function findAsosStation(location: string): { id: number; name: string } | null {
|
||||
const q = stripAdmin(location);
|
||||
if (!q) return null;
|
||||
const cand = ASOS_STATIONS.map(([id, name]) => ({ id, name, bare: stripAdmin(name) }));
|
||||
const exact = cand.find(c => c.bare === q);
|
||||
if (exact) return { id: exact.id, name: exact.name };
|
||||
const deprefixed = cand.find(c => c.bare.replace(/^(북|서|남|동)/, '') === q);
|
||||
if (deprefixed) return { id: deprefixed.id, name: deprefixed.name };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ASOS 시간자료의 rn 인코딩: ""=무강수, "0.0"=미량(관측은 됐으나 0.05mm 미만), "1.0"=1.0mm.
|
||||
// ""와 "0.0"을 같게 뭉개면 이슬비로 이어지던 비가 소강으로 잡혀 사상이 끊긴다.
|
||||
export function normalizeAsosHour(tm: string, rn: string | null | undefined): { time: string; mm: number; wet: boolean } {
|
||||
const time = String(tm).trim().replace(' ', 'T').slice(0, 16);
|
||||
if (rn == null || String(rn).trim() === '') return { time, mm: 0, wet: false };
|
||||
const mm = parseFloat(String(rn));
|
||||
return { time, mm: Number.isFinite(mm) ? mm : 0, wet: true };
|
||||
}
|
||||
|
||||
const ASOS_BASE = 'http://apis.data.go.kr/1360000/AsosHourlyInfoService';
|
||||
|
||||
async function asosFetch(params: Record<string, string>): Promise<any[]> {
|
||||
const apiKey = getKmaApiKey();
|
||||
if (!apiKey) return [];
|
||||
const p = new URLSearchParams({ serviceKey: apiKey, dataType: 'JSON', numOfRows: '999', pageNo: '1', ...params });
|
||||
const res = await fetch(`${ASOS_BASE}/getWthrDataList?${p}`, { signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) throw new Error(`ASOS API 오류 ${res.status}`);
|
||||
const json: any = await res.json();
|
||||
if (json?.response?.header?.resultCode !== '00') {
|
||||
throw new Error(`ASOS: ${json?.response?.header?.resultMsg || json?.OpenAPI_ServiceResponse?.cmmMsgHeader?.errMsg || '알 수 없는 오류'}`);
|
||||
}
|
||||
return json?.response?.body?.items?.item || [];
|
||||
}
|
||||
|
||||
// 두 KMA 소스를 이어붙여 사상 누적을 관측값만으로 낸다. 창이 정확히 맞물린다:
|
||||
// ASOS 시간자료는 "전날 자료까지"(오늘치는 resultCode 99), 초단기실황은 "최근 1일".
|
||||
// 그래서 어제 이전은 ASOS, 오늘은 초단기실황으로 자정에서 딱 나눈다.
|
||||
// 지점명을 못 맞히면(좌표 입력, ASOS 없는 소도시) null을 주고 호출부가 Open-Meteo로 물러난다.
|
||||
async function fetchKmaRainEpisode(
|
||||
stnId: number, todayKst: string, todayHours: Array<{ time: string; mm: number; wet: boolean }>,
|
||||
): Promise<{ startTime: string; totalMm: number; wetHours: number; truncated: boolean } | null> {
|
||||
try {
|
||||
const d = (back: number) =>
|
||||
new Date(Date.now() + 9 * 3600 * 1000 - back * 86400 * 1000).toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const items = await asosFetch({
|
||||
dataCd: 'ASOS', dateCd: 'HR', stnIds: String(stnId),
|
||||
startDt: d(7), startHh: '00', endDt: d(1), endHh: '23',
|
||||
});
|
||||
const past = items
|
||||
.map((it: any) => normalizeAsosHour(it.tm, it.rn))
|
||||
.filter(h => h.time.slice(0, 10) < `${todayKst.slice(0,4)}-${todayKst.slice(4,6)}-${todayKst.slice(6,8)}`)
|
||||
.sort((a, b) => a.time.localeCompare(b.time));
|
||||
if (!past.length) return null;
|
||||
return findRainEpisode([...past, ...todayHours]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 지점명을 못 맞힌 위치(좌표 입력 등)를 위한 폴백. 모델 추정이라 출력에 출처를 밝힌다.
|
||||
// (2026-08-16 서울 실측 교차검증: 같은 구간 KMA 9.8mm vs Open-Meteo 10.3mm, 5% 이내)
|
||||
async function fetchRainEpisode(
|
||||
lat: number, lon: number,
|
||||
@@ -897,7 +999,7 @@ async function fetchRainEpisode(
|
||||
|
||||
async function fetchKmaTodayRain(
|
||||
nx: number, ny: number, base_date: string, latestHour: number,
|
||||
): Promise<{ mm: number; missing: number[]; throughHour: number } | null> {
|
||||
): Promise<{ mm: number; missing: number[]; throughHour: number; perHour: Array<{ time: string; mm: number; wet: boolean }> } | null> {
|
||||
if (latestHour < 1) return null;
|
||||
const hours = Array.from({ length: latestHour }, (_, i) => i + 1);
|
||||
const results: Array<{ hour: number; rn1: string | null }> = [];
|
||||
@@ -921,7 +1023,17 @@ async function fetchKmaTodayRain(
|
||||
}
|
||||
const { mm, missing } = sumKmaHourlyRain(results);
|
||||
if (missing.length === hours.length) return null;
|
||||
return { mm, missing, throughHour: latestHour };
|
||||
// 시간별 값도 같이 돌려준다 — 사상 누적이 오늘 구간을 다시 받아오지 않게(호출 2배 방지).
|
||||
// 초단기실황에는 ASOS의 미량("0.0") 구분이 없어서 wet은 mm 임계로 정한다.
|
||||
const iso = `${base_date.slice(0,4)}-${base_date.slice(4,6)}-${base_date.slice(6,8)}`;
|
||||
const perHour = results
|
||||
.filter(r => r.rn1 != null && r.rn1 !== '')
|
||||
.map(r => {
|
||||
const v = parseFloat(String(r.rn1).replace(/[^0-9.]/g, ''));
|
||||
const val = Number.isFinite(v) ? v : 0;
|
||||
return { time: `${iso}T${String(r.hour).padStart(2, '0')}:00`, mm: val, wet: val >= RAIN_WET_HOUR_MM };
|
||||
});
|
||||
return { mm, missing, throughHour: latestHour, perHour };
|
||||
}
|
||||
|
||||
// Pure formatting/filtering half of the KMA→Open-Meteo forecast merge — split out (2026-08-10)
|
||||
@@ -1028,12 +1140,18 @@ export const weatherKmaTool = {
|
||||
// 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 todayRain = base_date === todayKst
|
||||
? await fetchKmaTodayRain(nx, ny, base_date, parseInt(base_time.slice(0, 2), 10))
|
||||
: null;
|
||||
|
||||
// 지점명이 맞으면 ASOS+초단기실황으로 전부 관측값, 아니면 Open-Meteo 추정으로 물러난다.
|
||||
const station = findAsosStation(location) ?? findAsosStation(displayName);
|
||||
const observed = station && todayRain
|
||||
? await fetchKmaRainEpisode(station.id, todayKst, todayRain.perHour)
|
||||
: null;
|
||||
const episode = observed ?? await fetchRainEpisode(lat, lon);
|
||||
const episodeSource = observed ? `기상청 ${station!.name} 관측` : 'Open-Meteo 추정';
|
||||
|
||||
const rainLine = todayRain
|
||||
? `오늘 누적 강수량: ${todayRain.mm} mm (00시~${String(todayRain.throughHour).padStart(2, '0')}시 실측 합산`
|
||||
+ (todayRain.missing.length ? `, ${todayRain.missing.length}개 시각 결측` : '')
|
||||
@@ -1046,7 +1164,7 @@ export const weatherKmaTool = {
|
||||
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 추정)`
|
||||
+ `강수 ${episode.wetHours}시간${episode.truncated ? ', 7일 조회창을 넘겨 하한값' : ''} · ${episodeSource})`
|
||||
: null;
|
||||
|
||||
const lines = [
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* asos-station.test.ts
|
||||
*
|
||||
* ASOS(종관기상관측) 지점 매칭과 rn 인코딩 정규화.
|
||||
*
|
||||
* 지점 목록은 지점정보 API가 없어서(NO_OPENAPI_SERVICE_ERROR) 일자료 엔드포인트로
|
||||
* stnIds 90~300을 전수조사해 뽑았다 — 97개.
|
||||
*
|
||||
* 좌표 기반 최근접 지점 선택은 만들다 폐기했다: 지점명을 지오코딩했더니 홍성·보령·밀양이
|
||||
* 북한 동명 지역으로, 남원이 제주도로 나왔다. 좌표를 검증하려고 ASOS 관측기온과
|
||||
* Open-Meteo 기온을 대조했지만 좌표가 정확한 서울조차 Δ3.1°C라(도시열섬·모델편차)
|
||||
* 판별자가 못 됐다. 그래서 이름으로만 맞히고, 못 맞히면 Open-Meteo로 명시적으로 물러난다.
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { findAsosStation, normalizeAsosHour } from '../src/tools/weather';
|
||||
|
||||
describe('findAsosStation — 지점명 매칭', () => {
|
||||
test('정확히 일치하는 지점', () => {
|
||||
assert.deepEqual(findAsosStation('서울'), { id: 108, name: '서울' });
|
||||
assert.deepEqual(findAsosStation('구미'), { id: 279, name: '구미' });
|
||||
});
|
||||
|
||||
test('행정 접미사를 벗겨서 맞힌다', () => {
|
||||
assert.deepEqual(findAsosStation('부산광역시'), { id: 159, name: '부산' });
|
||||
// 반대 방향: 입력은 맨이름인데 지점명에 "시"가 붙어 있는 경우
|
||||
assert.deepEqual(findAsosStation('김해'), { id: 253, name: '김해시' });
|
||||
});
|
||||
|
||||
test('방위 접두사가 붙은 지점명도 맨이름으로 찾는다', () => {
|
||||
// 지점명은 "북창원"인데 사용자는 "창원"이라고 쓴다.
|
||||
assert.deepEqual(findAsosStation('창원'), { id: 155, name: '창원' });
|
||||
assert.deepEqual(findAsosStation('춘천'), { id: 101, name: '춘천' });
|
||||
});
|
||||
|
||||
test('정확 일치가 접두사 제거보다 우선한다', () => {
|
||||
// "부산"은 159(부산)과 296(북부산) 둘 다에 걸릴 수 있다 — 정확 일치가 이겨야 한다.
|
||||
assert.deepEqual(findAsosStation('부산'), { id: 159, name: '부산' });
|
||||
});
|
||||
|
||||
test('ASOS 지점이 없는 입력은 null (호출부가 Open-Meteo로 물러남)', () => {
|
||||
assert.equal(findAsosStation('우리집'), null);
|
||||
assert.equal(findAsosStation('37.5665,126.978'), null);
|
||||
assert.equal(findAsosStation(''), null);
|
||||
assert.equal(findAsosStation(' '), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAsosHour — rn 인코딩', () => {
|
||||
test('""는 무강수 (결측 아님)', () => {
|
||||
assert.deepEqual(normalizeAsosHour('2026-08-15 03:00', ''), { time: '2026-08-15T03:00', mm: 0, wet: false });
|
||||
});
|
||||
|
||||
test('"0.0"은 미량 강수 — 0mm지만 비는 온 시각', () => {
|
||||
// 이걸 무강수로 뭉개면 이슬비로 이어지던 비가 소강으로 잡혀 사상이 끊긴다.
|
||||
assert.deepEqual(normalizeAsosHour('2026-08-15 06:00', '0.0'), { time: '2026-08-15T06:00', mm: 0, wet: true });
|
||||
});
|
||||
|
||||
test('실제 강수량', () => {
|
||||
assert.deepEqual(normalizeAsosHour('2026-08-15 16:00', '1.0'), { time: '2026-08-15T16:00', mm: 1, wet: true });
|
||||
});
|
||||
|
||||
test('null/undefined는 무강수로 처리', () => {
|
||||
assert.equal(normalizeAsosHour('2026-08-15 01:00', null).wet, false);
|
||||
assert.equal(normalizeAsosHour('2026-08-15 01:00', undefined).wet, false);
|
||||
});
|
||||
|
||||
test('tm의 공백을 T로 바꿔 사상 판정에서 정렬 가능하게 만든다', () => {
|
||||
// 초단기실황 쪽 시각(2026-08-16T02:00)과 한 배열에 섞여 시간순 정렬되어야 한다.
|
||||
assert.equal(normalizeAsosHour('2026-08-15 23:00', '0.5').time, '2026-08-15T23:00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* kma-grid.test.ts
|
||||
*
|
||||
* 좌표 → 기상청 격자(nx, ny) 변환.
|
||||
*
|
||||
* 2026-08-16에 이 함수가 nx·ny를 나란히 한 칸씩 밀어서 조회하고 있었다는 걸 발견했다.
|
||||
* 기상청 공식 변환식의 마지막 항은 반올림용 +0.5인데 코드엔 +1.5가 들어 있었다.
|
||||
* 격자 1칸이 5km라 대각선으로 ~7km 떨어진 셀을 읽고 있었고, 같은 서울을 이름("서울")과
|
||||
* 좌표("37.5665,126.978")로 조회했을 때 오늘 누적 강수량이 9.8mm vs 29mm로 갈리면서 드러났다.
|
||||
*
|
||||
* 이름이 KMA_GRID 표에 있는 도시는 표를 타므로 멀쩡했고, 좌표로 들어온 위치만 틀렸다.
|
||||
* 그래서 몇 년 동안 아무도 못 봤다 — 아래 기대값은 그 표의 값이고, 변환식이 같은 답을
|
||||
* 내는지 대조하는 게 이 테스트의 요지다.
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { latLonToKmaGrid } from '../src/tools/weather';
|
||||
|
||||
describe('latLonToKmaGrid — 좌표에서 기상청 격자 좌표', () => {
|
||||
// 기대값은 기상청이 배포하는 "동네예보 격자" 표의 값(= 코드 내 KMA_GRID 표와 동일).
|
||||
const cases: Array<[string, number, number, number, number]> = [
|
||||
['서울', 37.5665, 126.9780, 60, 127],
|
||||
['부산', 35.1796, 129.0756, 98, 76],
|
||||
['대전', 36.3504, 127.3845, 67, 100],
|
||||
['구미', 36.1195, 128.3446, 84, 96],
|
||||
];
|
||||
|
||||
for (const [name, lat, lon, nx, ny] of cases) {
|
||||
test(`${name} (${lat}, ${lon}) → ${nx}, ${ny}`, () => {
|
||||
assert.deepEqual(latLonToKmaGrid(lat, lon), { nx, ny });
|
||||
});
|
||||
}
|
||||
|
||||
test('+1 밀림 회귀 방어: 서울이 61,128로 나오면 안 된다', () => {
|
||||
// 버그 당시의 정확한 출력. 상수를 되돌리면 이 테스트부터 깨진다.
|
||||
const g = latLonToKmaGrid(37.5665, 126.978);
|
||||
assert.notDeepEqual(g, { nx: 61, ny: 128 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user