fix: 한국 지명을 관측지점 이름표로 푼다 — 지오코더가 66개 중 44개를 틀렸다

"독도 날씨"가 전남 고흥 관측소로 잡히길래 파봤더니 한 건이 아니었다.
KR_CITY_COORDS에 없는 ASOS 지명 66개를 기상청 공식 좌표와 대조한 결과
44개가 틀렸다 — 25개는 Open-Meteo 지오코더가 아예 못 찾고, 19개는
엉뚱한 좌표를 줬다.

  홍성 724km / 고산 629km / 보령 551km / 밀양 459km / 서산 / 홍천
      → 전부 북한 동명 지역
  동해 408km / 남원 246km / 제천 214km / 상주 188km / 남해 187km
      → 사람들이 실제로 묻는 도시들

이제 ASOS 97 + AWS 745 지점 이름표를 지오코딩 소스로 쓴다. 순서는
손으로 넣은 표 → 지점 이름표 → 지오코더이고, 한글이 섞인 입력만
이름표를 탄다(영문·해외 지명은 종전 경로 그대로). 지점 좌표는 기상청
발표값이라 이 문제가 구조적으로 안 생기고, 날씨를 묻는 맥락에서는
"그 지명의 관측소 위치"가 사실상 정답이다.

고친 뒤 ASOS 97개 전부 공식 좌표 20km 이내로 들어왔다(수정 전 53/97).

같은 이름이 100km 넘게 떨어져 둘씩 있는 3건(진안·옥천·가산)은 이름표에서
일부러 제외했다 — 조용히 한쪽을 고르면 틀렸을 때 드러나지 않는다. 대신
널리 통하는 행정구역 쪽을 KR_CITY_COORDS에 못박았다. 안 그러면 넘어간
지오코더가 옥천을 북한(39.94, 124.53)으로 보낸다. 버린 쪽은 전부 경기도의
동명 지점(진안 439, 옥천 449, 가산 473).

미해결로 남긴 것: 'New York'이 네브래스카의 York로 간다. 영문·해외 지명
쪽 지오코더 문제라 이번 변경 경로와 무관하다(한글 없는 입력은 이름표를
안 탄다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-16 14:04:02 +09:00
co-authored by Claude Opus 5
parent 6836b12210
commit d1bc379093
2 changed files with 144 additions and 0 deletions
+56
View File
@@ -208,6 +208,13 @@ export const KR_CITY_COORDS: Record<string, [number, number]> = {
// No error was ever raised; the user just got someone else's weather.
// Any mid-size Korean city the geocoder cannot pin down belongs in this table.
'구미': [36.1195, 128.3446], 'gumi': [36.1195, 128.3446],
// 2026-08-16: 관측지점 이름표를 지오코딩 소스로 쓰면서 남은 동명 3건. 같은 이름의 지점이
// 100km 넘게 떨어져 둘씩 있어서 이름표는 "모호"로 넘기는데, 그러면 지오코더로 새고
// 옥천은 북한(39.94, 124.53)으로 간다. 널리 통하는 행정구역 쪽을 여기에 못박는다.
// (버린 쪽은 전부 경기도의 동명 지점 — 진안 439, 옥천 449, 가산 473)
'진안': [35.7604, 127.4374], 'jinan': [35.7604, 127.4374],
'옥천': [36.3000, 127.5965], 'okcheon': [36.3000, 127.5965],
'가산': [36.0956, 128.5001],
'김해': [35.2285, 128.8894], 'gimhae': [35.2285, 128.8894],
'안양': [37.3943, 126.9568], 'anyang': [37.3943, 126.9568],
'부천': [37.5034, 126.7660], 'bucheon': [37.5034, 126.7660],
@@ -302,12 +309,61 @@ export async function locationNotFoundError(location: string, toolName: string):
};
}
// 기상청 관측지점 이름 → 좌표. ASOS 97개 + AWS 745개에서 만든다.
//
// 왜 필요한가: Open-Meteo 지오코더는 한국 지명에 거의 못 쓴다. 2026-08-16에 ASOS 지명
// 66개(KR_CITY_COORDS에 없는 것)를 공식 좌표와 대조했더니 **44개가 틀렸다** —
// 25개는 아예 조회 실패, 19개는 엉뚱한 좌표. 홍성·보령·서산·홍천·밀양·고산은 북한 동명
// 지역으로, 동해는 408km, 제천 214km, 상주 188km 떨어진 곳으로 갔다. 전부 사람들이
// 실제로 묻는 도시다.
//
// 지점 좌표는 기상청이 발표한 값이라 이 문제가 구조적으로 안 생긴다. 게다가 날씨를 묻는
// 맥락에선 "그 지명의 관측소 위치"가 사실상 정답이다.
//
// 같은 이름이 멀리 떨어져 둘 이상 있으면(739개 중 진안·옥천·가산 3건) 어느 쪽인지 모르므로
// 쓰지 않고 넘긴다 — 조용히 한쪽을 고르면 틀렸을 때 드러나지 않는다.
let stationPlaceIndex: Map<string, { lat: number; lon: number }> | null = null;
function getStationPlaceIndex(): Map<string, { lat: number; lon: number }> {
if (stationPlaceIndex) return stationPlaceIndex;
const groups = new Map<string, Array<[number, string, number, number]>>();
for (const s of [...ASOS_STATIONS, ...AWS_STATIONS]) {
const k = stripAdmin(s[1]);
if (!k) continue;
const list = groups.get(k);
if (list) list.push(s); else groups.set(k, [s]);
}
const index = new Map<string, { lat: number; lon: number }>();
for (const [k, list] of groups) {
let spread = 0;
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
spread = Math.max(spread, haversineKm(list[i][2], list[i][3], list[j][2], list[j][3]));
}
}
if (spread > 20) continue; // 모호 — 지오코더로 넘긴다
index.set(k, { lat: list[0][2], lon: list[0][3] });
}
stationPlaceIndex = index;
return index;
}
export function lookupKoreanPlace(name: string): { lat: number; lon: number } | null {
const k = stripAdmin(String(name || ''));
return k ? (getStationPlaceIndex().get(k) ?? null) : null;
}
export async function geocodeCity(name: string): Promise<{ lat: number; lon: number; displayName: string } | null> {
const key = name.trim().toLowerCase();
if (KR_CITY_COORDS[key]) {
const [lat, lon] = KR_CITY_COORDS[key];
return { lat, lon, displayName: name };
}
// 손으로 넣은 표 다음, 지오코더보다 먼저. 한글이 섞인 입력만 본다 — 영문/외국 지명은
// 지점명과 부딪힐 일이 없고 지오코더가 제대로 처리한다.
if (/[가-힣]/.test(name)) {
const stn = lookupKoreanPlace(name);
if (stn) return { lat: stn.lat, lon: stn.lon, displayName: name };
}
const { city, countryCode } = parseExplicitCountry(name);
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=10&language=ko&format=json`;
// Open-Meteo geocoding throws a transient network/5xx blip now and then that
+88
View File
@@ -0,0 +1,88 @@
/**
* korean-place-lookup.test.ts
*
* 한국 지명 → 좌표를 기상청 관측지점 이름표(ASOS 97 + AWS 745)로 푼다.
*
* 계기는 "독도 날씨"가 전남 고흥 관측소로 잡힌 것이었는데, 파보니 한 건이 아니었다.
* KR_CITY_COORDS에 없는 ASOS 지명 66개를 공식 좌표와 대조하니 **44개가 틀렸다** —
* 25개는 Open-Meteo 지오코더가 아예 못 찾고, 19개는 엉뚱한 좌표를 줬다:
*
* 홍성 724km / 고산 629km / 보령 551km / 밀양 459km → 전부 북한 동명 지역
* 동해 408km · 제천 214km · 상주 188km · 남원 246km → 사람들이 실제로 묻는 도시들
*
* 지점 좌표는 기상청 발표값이라 이 문제가 구조적으로 안 생기고, 날씨 맥락에선
* "그 지명의 관측소 위치"가 사실상 정답이다. 고친 뒤 ASOS 97개 전부 20km 이내로 들어왔다.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { lookupKoreanPlace } from '../src/tools/weather';
// 기상청 공식 좌표(stn_inf.php)와의 거리
function km(a: { lat: number; lon: number }, lat: number, lon: number): number {
const R = (d: number) => d * Math.PI / 180;
const dLat = R(lat - a.lat), dLon = R(lon - a.lon);
const q = Math.sin(dLat / 2) ** 2 + Math.cos(R(a.lat)) * Math.cos(R(lat)) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.asin(Math.sqrt(q));
}
describe('lookupKoreanPlace — 지점 이름표로 한국 지명 풀기', () => {
test('지오코더가 북한으로 보내던 지명들이 제자리에 온다', () => {
// 좌: 공식 관측지점 좌표. 우: 예전 지오코더가 주던 곳.
const cases: Array<[string, number, number]> = [
['홍성', 36.6576, 126.6877], // → 42.71, 129.83 (조선민주주의인민공화국)
['보령', 36.3272, 126.5574], // → 40.88, 129.08
['밀양', 35.4915, 128.7441], // → 38.85, 125.75
];
for (const [name, lat, lon] of cases) {
const got = lookupKoreanPlace(name);
assert.ok(got, `${name} 조회 실패`);
assert.ok(km(got, lat, lon) < 5, `${name} ${km(got, lat, lon).toFixed(0)}km 어긋남`);
}
});
test('수백 km 어긋나던 도시들', () => {
const cases: Array<[string, number, number]> = [
['동해', 37.5071, 129.1243], // 예전 408km
['제천', 37.1593, 128.1943], // 예전 214km
['남원', 35.4213, 127.3965], // 예전 246km
['남해', 34.8166, 127.9264], // 예전 187km
];
for (const [name, lat, lon] of cases) {
const got = lookupKoreanPlace(name);
assert.ok(got && km(got, lat, lon) < 5, `${name} 어긋남`);
}
});
test('독도 — 이 작업의 발단', () => {
// AWS 96번 독도가 실재하는데 지오코더는 전남을 줬다.
const got = lookupKoreanPlace('독도');
assert.ok(got);
assert.ok(km(got, 37.2395, 131.8698) < 5);
});
test('지오코더가 아예 못 찾던 이름들도 잡힌다', () => {
for (const n of ['흑산도', '태백', '보은', '의성', '산청', '정읍']) {
assert.ok(lookupKoreanPlace(n), `${n} 조회 실패`);
}
});
test('행정 접미사를 붙여도 같은 곳', () => {
const a = lookupKoreanPlace('양산');
const b = lookupKoreanPlace('양산시');
assert.deepEqual(a, b);
});
test('멀리 떨어진 동명은 쓰지 않는다 (조용히 한쪽을 고르면 안 됨)', () => {
// 진안·옥천·가산은 100km 넘게 떨어진 동명 지점이 둘씩 있다. 이름표는 넘기고,
// 널리 통하는 행정구역 쪽은 KR_CITY_COORDS가 따로 못박는다.
for (const n of ['진안', '옥천', '가산']) {
assert.equal(lookupKoreanPlace(n), null, `${n}은 모호로 걸러져야 한다`);
}
});
test('한글이 아닌 이름은 이름표에 없다', () => {
assert.equal(lookupKoreanPlace('London'), null);
assert.equal(lookupKoreanPlace(''), null);
});
});