AWS 매분자료에 RN-DAY(일 누적 강수량)가 그대로 들어 있다. 초단기실황을
시간별로 23번 불러 합산하던 걸 1회 조회로 대체한다. 격자 보간이 아니라
관측소 실측이고, 분 단위로 갱신된다.
지점은 ASOS(97개)가 아니라 AWS(745개) 중 최근접으로 따로 고른다.
ASOS에 묶어두면 시흥이 19.1km 떨어진 인천을 읽는데, AWS면 2.5km 시흥
관측소가 잡힌다(용인 17.3km → 처인역삼 1.2km). 실패하면 종전 초단기실황
합산으로 물러나므로 어디서든 값은 나온다.
디버깅에 시간을 쓴 함정 셋을 테스트로 고정했다:
1) 컬럼이 위치로만 구분된다. RN-12H(12)를 RN-DAY(13)로 착각해서
"AWS 9.8 vs 초단기실황 10.2"라는 가짜 불일치까지 만들어냈다.
바로잡으니 서울·강릉은 소수점까지 일치한다.
2) 마지막 행은 "지금 이 분"이라 아직 안 채워진 경우가 많다 — 전 컬럼
-99.9. 맨 끝 행을 그대로 쓰면 관측이 멀쩡한데 전부 null이 되어
폴백으로 샌다. 뒤에서부터 RN-DAY가 유효한 첫 행을 쓴다.
3) 정시 RN-60m만 더하면 마지막 정시 이후 자투리가 빠진다. 부산에서
27분 사이 4mm가 누락돼 "오늘 23.6 > 사상 19.6" 모순이 화면에 떴다.
차액을 현재 시각 한 칸으로 넣어 시간 합이 RN-DAY와 맞게 만들었다.
RE(강수감지)는 전 지점 -99.9(null)라 사상 판정에 못 쓰고, 매분 RN-60m의
정시 값을 그 시간의 강수량으로 쓴다.
알려진 미해결: geocodeCity('독도')가 전남을 돌려줘서 엉뚱한 관측소가
잡힌다(AWS 목록엔 96번 독도가 실재한다). 이번 변경과 무관한 기존
지오코더 문제로, 구미 때 KR_CITY_COORDS를 만든 것과 같은 종류다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 lines
4.2 KiB
TypeScript
99 lines
4.2 KiB
TypeScript
/**
|
||
* aws-minute.test.ts
|
||
*
|
||
* 방재기상관측(AWS) 매분자료 파싱. 오늘 누적 강수량을 초단기실황 23회 합산에서
|
||
* AWS `RN-DAY` 1회 조회로 바꾸면서 들어왔다(2026-08-16).
|
||
*
|
||
* 실제로 밟은 지뢰 세 개를 그대로 테스트로 남긴다:
|
||
*
|
||
* 1. 컬럼이 위치로만 구분된다. 헤더에 이름은 있지만 CSV엔 안 붙어서 한 칸 밀리면
|
||
* 기온을 강수량으로 읽는다 — RN-12H(12)를 RN-DAY(13)로 착각해 한 번 틀렸고,
|
||
* 그때 "AWS 9.8 vs 우리 10.2"라는 가짜 불일치까지 만들어냈다.
|
||
* 2. 마지막 행은 "지금 이 분"이라 아직 안 채워진 경우가 많다 — 전 컬럼 -99.9.
|
||
* 맨 끝 행을 그대로 쓰면 관측이 멀쩡한데 전부 null이 되어 폴백으로 샌다.
|
||
* 3. 정시 RN-60m만 더하면 마지막 정시 이후 자투리가 빠져서, "오늘 누적"이
|
||
* "이번 강수 누적"보다 커지는 모순이 화면에 뜬다(부산에서 27분에 4mm).
|
||
*/
|
||
|
||
import { test, describe } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { awsNum, parseAwsDay } from '../src/tools/weather';
|
||
|
||
// TM,STN,WD1,WS1,WDS,WSS,WD10,WS10,TA,RE,RN-15m,RN-60m,RN-12H,RN-DAY,HM,PA,PS,TD
|
||
const row = (tm: string, ta: string, rn60: string, rnDay: string) =>
|
||
`${tm},108,48.4,1.5,24.6,2.0,46.0,1.5,${ta},-99.9,0.0,${rn60},9.9,${rnDay},84.7,996.0,1005.7,22.6,=`;
|
||
const DEAD = (tm: string) => `${tm},108,` + Array(16).fill('-99.9').join(',') + ',=';
|
||
|
||
describe('awsNum — 결측 센티널', () => {
|
||
test('-50 이하는 관측 없음이라 null', () => {
|
||
// 0으로 읽으면 비가 안 온 것처럼 보이고, 더하면 합계가 음수로 망가진다.
|
||
assert.equal(awsNum('-99.9'), null);
|
||
assert.equal(awsNum('-50.1'), null);
|
||
});
|
||
test('정상값은 숫자로', () => {
|
||
assert.equal(awsNum('10.2'), 10.2);
|
||
assert.equal(awsNum('0.0'), 0);
|
||
assert.equal(awsNum(' 24.1 '), 24.1);
|
||
});
|
||
test('영하 기온은 살아남는다 (-50 초과)', () => {
|
||
assert.equal(awsNum('-15.3'), -15.3);
|
||
});
|
||
test('빈 값/undefined는 null', () => {
|
||
assert.equal(awsNum(undefined), null);
|
||
assert.equal(awsNum(''), null);
|
||
});
|
||
});
|
||
|
||
describe('parseAwsDay', () => {
|
||
test('RN-DAY를 13번 컬럼에서 읽는다 (12번은 RN-12H)', async () => {
|
||
const p = await parseAwsDay([row('202608161300', '24.3', '0.4', '10.2')].join('\n'));
|
||
assert.equal(p.rnDay, 10.2);
|
||
assert.equal(p.ta, 24.3);
|
||
});
|
||
|
||
test('마지막 행이 전부 결측이면 그 앞의 유효 행을 쓴다', async () => {
|
||
const csv = [
|
||
row('202608161325', '24.1', '0.2', '10.2'),
|
||
DEAD('202608161326'),
|
||
DEAD('202608161327'),
|
||
].join('\n');
|
||
const p = await parseAwsDay(csv);
|
||
assert.equal(p.rnDay, 10.2);
|
||
assert.equal(p.tm, '202608161325');
|
||
});
|
||
|
||
test('정시 행만 시간별 값으로 뽑는다', async () => {
|
||
const csv = [
|
||
row('202608160100', '24', '1.0', '1.0'),
|
||
row('202608160130', '24', '0.5', '1.5'), // 정시 아님 — 버려야 한다
|
||
row('202608160200', '24', '2.0', '3.0'),
|
||
].join('\n');
|
||
const p = await parseAwsDay(csv);
|
||
assert.deepEqual(p.hours.map(h => h.time), ['2026-08-16T01:00', '2026-08-16T02:00']);
|
||
});
|
||
|
||
test('자투리 구간을 채워 시간 합이 RN-DAY와 맞는다', async () => {
|
||
// 정시 합 3.0인데 RN-DAY 7.0 → 마지막 정시 이후 4.0이 더 왔다는 뜻.
|
||
const csv = [
|
||
row('202608161300', '22', '1.0', '3.0'),
|
||
row('202608161327', '22', '0.0', '7.0'),
|
||
].join('\n');
|
||
const p = await parseAwsDay(csv);
|
||
const summed = Math.round(p.hours.reduce((s, h) => s + h.mm, 0) * 10) / 10;
|
||
assert.equal(summed, p.rnDay);
|
||
assert.equal(p.hours[p.hours.length - 1].mm, 6); // 7.0 − 1.0(13시 정시)
|
||
assert.equal(p.hours[p.hours.length - 1].wet, true);
|
||
});
|
||
|
||
test('자투리가 없으면 덧붙이지 않는다', async () => {
|
||
const csv = [row('202608161300', '22', '2.5', '2.5')].join('\n');
|
||
const p = await parseAwsDay(csv);
|
||
assert.equal(p.hours.length, 1);
|
||
});
|
||
|
||
test('빈 응답은 전부 null', async () => {
|
||
const p = await parseAwsDay('#START7777\n#7777END\n');
|
||
assert.deepEqual({ rnDay: p.rnDay, tm: p.tm, hours: p.hours }, { rnDay: null, tm: null, hours: [] });
|
||
});
|
||
});
|