diff --git a/src/tools/weather.ts b/src/tools/weather.ts index bdead24..48c8a5c 100644 --- a/src/tools/weather.ts +++ b/src/tools/weather.ts @@ -136,7 +136,7 @@ const windDirectionKo = (deg: number) => // Geocode city name → lat/lon using Open-Meteo geocoding (no API key required) // Falls back to a built-in lookup table for common Korean cities. -const KR_CITY_COORDS: Record = { +export const KR_CITY_COORDS: Record = { '서울': [37.5665, 126.9780], 'seoul': [37.5665, 126.9780], '부산': [35.1796, 129.0756], 'busan': [35.1796, 129.0756], '인천': [37.4563, 126.7052], 'incheon': [37.4563, 126.7052], @@ -308,7 +308,7 @@ function parseCoords(location: string): [number, number] | null { // ── KMA grid coordinate conversion (Lambert Conformal Conic) ───────────────── // Pre-computed grid coordinates for major Korean cities (official KMA values) -const KMA_GRID: Record = { +export const KMA_GRID: Record = { '서울': [60, 127], 'seoul': [60, 127], '부산': [98, 76], 'busan': [98, 76], '인천': [55, 124], 'incheon': [55, 124], @@ -331,16 +331,24 @@ const KMA_GRID: Record = { '진주': [81, 75], 'jinju': [81, 75], '여수': [73, 66], 'yeosu': [73, 66], '목포': [50, 67], 'mokpo': [50, 67], - '구미': [75, 100], 'gumi': [75, 100], + // 2026-08-16: [75,100]이었는데 45km 떨어진 산간 격자였다. ASOS 구미(279) 실측 31.5°C에 + // 표는 25.3°C(Δ6.2), 좌표 변환값 [84,96]은 31.9°C(Δ0.4). 표가 틀린 게 확인돼 교체. + '구미': [84, 96], 'gumi': [84, 96], '안동': [91, 106], 'andong': [91, 106], '경주': [100, 91], 'gyeongju': [100, 91], '김해': [95, 77], 'gimhae': [95, 77], - '거제': [95, 71], 'geoje': [95, 71], + // 2026-08-16: [95,71]은 아예 자료가 없는 격자였다 — T1H가 기상청 결측 표식 -999로 왔다. + // 좌표 변환값 [90,69]는 ASOS 거제(294) 실측 30.8°C에 31.0°C(Δ0.2). + '거제': [90, 69], 'geoje': [90, 69], '순천': [70, 70], 'suncheon': [70, 70], '익산': [60, 91], 'iksan': [60, 91], '원주': [76, 122], 'wonju': [76, 122], '평택': [62, 114], 'pyeongtaek': [62, 114], - '시흥': [57, 120], 'siheung': [57, 120], + // 2026-08-16: [57,120]이었는데 안산 [58,121]보다 남쪽에 놓여 있었다. 시흥시(37.38°N)는 + // 안산시(37.32°N)보다 북쪽이라 ny가 더 커야 한다. ASOS 지점이 없어 실측 대조는 못 했고 + // (두 후보 다 값 자체는 그럴듯했다 — 해안 23.9°C/94% vs 내륙 30.2°C/67%), 인접 도시와의 + // 남북 순서로 판정했다. + '시흥': [57, 123], 'siheung': [57, 123], '부천': [56, 125], 'bucheon': [56, 125], '안산': [57, 121], 'ansan': [57, 121], }; diff --git a/tests/kma-grid-table.test.ts b/tests/kma-grid-table.test.ts new file mode 100644 index 0000000..3650240 --- /dev/null +++ b/tests/kma-grid-table.test.ts @@ -0,0 +1,52 @@ +/** + * kma-grid-table.test.ts + * + * KMA_GRID(도시명 → 격자) 표와 KR_CITY_COORDS(도시명 → 좌표) 표는 같은 도시를 두 방식으로 + * 적어둔 것이므로, 좌표를 격자로 변환하면 표의 격자와 같아야 한다. 이 불변식이 깨진 + * 항목은 둘 중 하나가 틀린 것이다. + * + * 2026-08-16에 이 대조를 처음 돌려서 두 건을 잡았다. ASOS 관측기온을 정답지로 확인한 결과: + * - 구미 표[75,100] → ASOS 31.5°C인데 25.3°C(Δ6.2). 45km 떨어진 산간 격자였다. + * 변환값 [84,96]은 31.9°C(Δ0.4) → 표를 교체. + * - 거제 표[95,71] → T1H가 기상청 결측 표식 -999. 자료가 없는 격자였다. + * 변환값 [90,69]는 30.8°C 대비 31.0°C(Δ0.2) → 표를 교체. + * + * 나머지 25개 도시는 ASOS 실측과 Δ2.1°C 이내로 멀쩡했다. 인천(Δ4.5)·창원(Δ3.2)도 검사에 + * 걸렸지만 표와 변환값이 동일했다 — 격자 오류가 아니라 관측소 위치와 격자 중심의 차이라 + * 손대지 않았다. 그래서 이 테스트의 기준은 "ASOS와 몇 도 차이냐"가 아니라 + * "두 표가 서로 일치하냐"다. 후자는 네트워크 없이 결정적으로 검증된다. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { KMA_GRID, KR_CITY_COORDS, latLonToKmaGrid } from '../src/tools/weather'; + +describe('KMA_GRID ↔ KR_CITY_COORDS 일치', () => { + const shared = Object.keys(KMA_GRID).filter(k => KR_CITY_COORDS[k]); + + test('대조할 도시가 실제로 있다 (표가 비면 통과해버리는 걸 막는다)', () => { + assert.ok(shared.length >= 20, `공통 도시 ${shared.length}개 — 너무 적다`); + }); + + for (const city of ['구미', '거제', '서울', '부산', '대전']) { + test(`${city}: 좌표 변환값이 표의 격자와 같다`, () => { + const [lat, lon] = KR_CITY_COORDS[city]; + const [nx, ny] = KMA_GRID[city]; + assert.deepEqual(latLonToKmaGrid(lat, lon), { nx, ny }); + }); + } + + test('공통 도시 전부: 표와 변환값이 2칸 이내', () => { + // 완전 일치를 요구하면 도시 중심점 정의 차이로 1칸씩 흔들리는 걸 잡아버린다. + // 문제가 됐던 건 구미 13칸, 거제 7칸 수준이라 2칸이면 충분히 걸러진다. + const off: string[] = []; + for (const city of shared) { + const [lat, lon] = KR_CITY_COORDS[city]; + const [nx, ny] = KMA_GRID[city]; + const g = latLonToKmaGrid(lat, lon); + const d = Math.abs(g.nx - nx) + Math.abs(g.ny - ny); + if (d > 2) off.push(`${city} 표[${nx},${ny}] vs 변환[${g.nx},${g.ny}] 거리${d}`); + } + assert.deepEqual(off, [], `격자가 어긋난 도시: ${off.join(' / ')}`); + }); +});