안개는 ASOS·AWS·Open-Meteo 어디에도 없는 값이라 도로 CCTV 영상 분석이 유일한 소스다. 전국 593개 도로 지점에서 안개/비/눈 코드가 온다. **앞선 판단 정정**: 08-15~18에 이 API가 0행이라 서비스 종료를 의심했는데, 소급 반영이 늦었던 것이었다. 지금 조회하면 08/17이 83,478행으로 채워져 있고 08/14도 57,247 → 67,937행으로 늘었다. "지금 0행"으로 종료를 단정하면 안 된다. 안개가 있을 때만 한 줄 붙인다 — 매번 "안개 없음"을 쓰면 소음이 된다: 도로 안개: 보통 (CCTV 양재 관측, 13km · 11:20 기준) 어느 지점에서 몇 km 떨어진 값인지 밝혀 대표성 판단은 사용자에게 남긴다. 30km 밖이면 표시하지 않는다(도로변에만 있어 성기게 깔려 있다). 성능: 처음엔 응답이 7초 → 11초로 느려졌다. 조회 창을 90분에서 30분으로 줄이고 (지점 커버리지 584/593로 사실상 동일, 591KB → 87KB) 전국 한 벌을 5분 캐시해 구미 기준 7.1s → 2.7s로 되돌렸다. 같은 지점이 몇 분 간격으로 반복 보고되므로 지점별 최신 행만 남긴다. 야간에는 영상 분석이 없어 데이터가 안 나온다 — 안개 줄이 없는 게 정상이다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93 lines
3.7 KiB
TypeScript
93 lines
3.7 KiB
TypeScript
/**
|
|
* road-fog.test.ts
|
|
*
|
|
* CCTV 기반 도로날씨에서 안개 정보를 뽑는 로직. 안개는 ASOS·AWS·Open-Meteo 어디에도 없는
|
|
* 값이라 이 소스가 유일하다(2026-08-21 배선).
|
|
*
|
|
* 이 API의 성격을 알고 봐야 한다:
|
|
* - 같은 지점이 몇 분 간격으로 반복 보고된다 → 지점별 **최신 행만** 남겨야 한다.
|
|
* - 지점이 도로변에만 있다(593곳). 가까운 지점이 없는 동네가 많다.
|
|
* - 영상 분석이라 주간에만 나온다. 야간 0행은 고장이 아니다.
|
|
* - 자료가 며칠에 걸쳐 뒤늦게 채워진다 — 08-15~17이 한동안 0행이다가 나중에 채워졌고,
|
|
* 그때 "서비스 종료"로 성급히 판단할 뻔했다.
|
|
*/
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { pickLatestFogByStation, nearestFogReport } from '../src/tools/weather';
|
|
|
|
// TM,EQP_ID,STN_NM,LAT,LON,HT,WH_CD,FOG_CD,RN_CD,SN_CD
|
|
const row = (tm: string, id: string, name: string, lat: string, lon: string, fog: string) =>
|
|
[tm, id, name, lat, lon, '-99.0', fog, fog, '00', '00'];
|
|
|
|
describe('pickLatestFogByStation', () => {
|
|
test('같은 지점은 가장 최근 행만 남긴다', () => {
|
|
const out = pickLatestFogByStation([
|
|
row('202608211000', 'A', '영광', '35.3', '126.6', '11'),
|
|
row('202608211020', 'A', '영광', '35.3', '126.6', '00'),
|
|
row('202608211010', 'A', '영광', '35.3', '126.6', '13'),
|
|
]);
|
|
assert.equal(out.length, 1);
|
|
assert.equal(out[0].fog, '00'); // 10:20이 최신
|
|
assert.equal(out[0].tm, '202608211020');
|
|
});
|
|
|
|
test('서로 다른 지점은 각각 남는다', () => {
|
|
const out = pickLatestFogByStation([
|
|
row('202608211000', 'A', '영광', '35.3', '126.6', '11'),
|
|
row('202608211000', 'B', '양재', '37.4', '127.0', '12'),
|
|
]);
|
|
assert.equal(out.length, 2);
|
|
});
|
|
|
|
test('깨진 행은 버린다', () => {
|
|
const out = pickLatestFogByStation([
|
|
['짧은행'],
|
|
row('없는시각', 'A', '영광', '35.3', '126.6', '11'),
|
|
row('202608211000', 'B', '양재', 'NaN', '127.0', '11'), // 좌표 불량
|
|
row('202608211000', 'C', '정상', '37.4', '127.0', '11'),
|
|
]);
|
|
assert.deepEqual(out.map(s => s.name), ['정상']);
|
|
});
|
|
|
|
test('빈 입력은 빈 배열 (야간이면 정상)', () => {
|
|
assert.deepEqual(pickLatestFogByStation([]), []);
|
|
});
|
|
});
|
|
|
|
describe('nearestFogReport', () => {
|
|
const S = (name: string, lat: number, lon: number, fog: string) =>
|
|
({ tm: '202608211020', name, lat, lon, fog });
|
|
|
|
test('안개 없는 지점(00)은 후보에서 뺀다', () => {
|
|
// 코앞에 맑은 지점이 있어도, 안개를 보고한 지점을 골라야 한다.
|
|
const hit = nearestFogReport([
|
|
S('바로옆', 37.5665, 126.978, '00'),
|
|
S('조금멀리', 37.60, 126.99, '12'),
|
|
], 37.5665, 126.978);
|
|
assert.equal(hit?.name, '조금멀리');
|
|
assert.equal(hit?.fog, '12');
|
|
});
|
|
|
|
test('안개 지점 중 가장 가까운 곳을 고른다', () => {
|
|
const hit = nearestFogReport([
|
|
S('멀다', 36.0, 127.5, '13'),
|
|
S('가깝다', 37.58, 126.99, '11'),
|
|
], 37.5665, 126.978);
|
|
assert.equal(hit?.name, '가깝다');
|
|
assert.ok(hit!.km < 5);
|
|
});
|
|
|
|
test('maxKm 밖이면 null — 먼 안개를 여기 안개처럼 말하지 않는다', () => {
|
|
assert.equal(nearestFogReport([S('부산', 35.18, 129.08, '12')], 37.5665, 126.978), null);
|
|
});
|
|
|
|
test('안개 지점이 하나도 없으면 null (줄을 안 붙인다)', () => {
|
|
assert.equal(nearestFogReport([S('맑음', 37.57, 126.98, '00')], 37.5665, 126.978), null);
|
|
});
|
|
|
|
test('알 수 없는 코드는 무시한다', () => {
|
|
assert.equal(nearestFogReport([S('이상', 37.57, 126.98, '99')], 37.5665, 126.978), null);
|
|
});
|
|
});
|