feat: 오늘 누적 강수량을 AWS 매분자료 실측으로 전환 (23회 → 1회 호출)
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>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 방재기상관측(AWS) 지점 목록 — [지점번호, 지점명, 위도, 경도]. 745개.
|
||||
*
|
||||
* 출처: 기상청 API허브 `typ01/url/stn_inf.php?inf=AWS` (2026-08-16 수신).
|
||||
* 응답이 EUC-KR이고 컬럼 순서가 LON→LAT이라 뒤집어 읽기 쉽다. ASOS(SFC) 목록과
|
||||
* 컬럼 배치도 다르다 — 여기선 STN_KO가 9번째다.
|
||||
*
|
||||
* ASOS 97개보다 7배 촘촘해서 최근접 관측소 거리가 확 줄어든다(시흥 기준 19.1km → 수 km).
|
||||
* ASOS 지점번호는 이 목록에도 같은 번호로 들어 있어서, 한 위치에 대해 두 망을 같은
|
||||
* 지점번호로 다룰 수 있다 — 오늘 누적(AWS 매분)과 과거 사상(ASOS 시간자료)을 섞을 때
|
||||
* 서로 다른 관측소를 참조해 합계가 어긋나는 걸 이 성질로 막는다.
|
||||
*
|
||||
* 별도 파일로 뺀 건 weather.ts가 이미 2천 줄이 넘어서다. 갱신은 위 URL을 다시 받아
|
||||
* 이 배열만 갈아끼우면 된다.
|
||||
*/
|
||||
export const AWS_STATIONS: Array<[number, string, number, number]> = [
|
||||
[42, '군산오식도', 35.9368, 126.5974], [43, '솔라시도', 34.7108, 126.4116], [44, '삼호', 34.7448, 126.4122],
|
||||
[45, '하이', 34.9189, 128.1181], [90, '속초', 38.2508, 128.5647], [92, '양양(공)', 38.0590, 128.6630],
|
||||
[93, '북춘천', 37.9474, 127.7544], [95, '철원', 38.1479, 127.3042], [96, '독도', 37.2395, 131.8698],
|
||||
[98, '동두천', 37.9019, 127.0607], [99, '파주', 37.8859, 126.7665], [100, '대관령', 37.6771, 128.7183],
|
||||
[101, '춘천', 37.9026, 127.7357], [102, '백령도', 37.9740, 124.7124], [104, '북강릉', 37.8046, 128.8553],
|
||||
[105, '강릉', 37.7515, 128.8910], [106, '동해', 37.5071, 129.1243], [108, '서울', 37.5714, 126.9658],
|
||||
[110, '김포(공)', 37.5589, 126.7945], [112, '인천', 37.4777, 126.6249], [113, '인천(공)', 37.4625, 126.4409],
|
||||
[114, '원주', 37.3375, 127.9466], [115, '울릉도', 37.4813, 130.8986], [116, '관악(레)', 37.4453, 126.9640],
|
||||
[119, '수원', 37.2575, 126.9830], [121, '영월', 37.1813, 128.4574], [127, '충주', 36.9704, 127.9525],
|
||||
[128, '청주(공)', 36.7167, 127.5000], [129, '서산', 36.7766, 126.4939], [130, '울진', 36.9918, 129.4128],
|
||||
[131, '청주', 36.6392, 127.4407], [133, '대전', 36.3720, 127.3721], [135, '추풍령', 36.2203, 127.9946],
|
||||
[136, '안동', 36.5729, 128.7073], [137, '상주', 36.4084, 128.1574], [138, '포항', 36.0320, 129.3800],
|
||||
[139, '포항(공)', 35.9833, 129.4167], [140, '군산', 36.0053, 126.7613], [142, '대구(공)', 35.8908, 128.6611],
|
||||
[143, '대구', 35.8780, 128.6530], [146, '전주', 35.8409, 127.1172], [151, '울산(공)', 35.5933, 129.3522],
|
||||
[152, '울산', 35.5824, 129.3347], [153, '김해(공)', 35.1692, 128.9344], [155, '창원', 35.1702, 128.5728],
|
||||
[156, '광주', 35.1729, 126.8916], [158, '광주(공)', 35.1250, 126.8117], [159, '부산', 35.1047, 129.0320],
|
||||
[160, '부산(레)', 35.1189, 128.9999], [161, '사천(공)', 35.0833, 128.0667], [162, '통영', 34.8454, 128.4356],
|
||||
[163, '무안(공)', 34.9914, 126.3831], [165, '목포', 34.8173, 126.3815], [167, '여수(공)', 34.8467, 127.6125],
|
||||
[168, '여수', 34.7393, 127.7406], [169, '흑산도', 34.6872, 125.4510], [170, '완도', 34.3959, 126.7018],
|
||||
[172, '고창', 35.3482, 126.5990], [174, '순천', 35.0204, 127.3694], [175, '진도(레)', 34.4721, 126.3238],
|
||||
[177, '홍성', 36.6576, 126.6877], [181, '서청주', 36.6399, 127.3846], [182, '제주(공)', 33.5167, 126.5000],
|
||||
[184, '제주', 33.5141, 126.5297], [185, '고산', 33.2938, 126.1628], [188, '성산', 33.3868, 126.8802],
|
||||
[189, '서귀포', 33.2462, 126.5653], [192, '진주', 35.1638, 128.0400], [201, '강화', 37.7074, 126.4463],
|
||||
[202, '양평', 37.4886, 127.4945], [203, '이천', 37.2640, 127.4842], [211, '인제', 38.0599, 128.1681],
|
||||
[212, '홍천', 37.6836, 127.8804], [216, '태백', 37.1704, 128.9893], [217, '정선군', 37.3807, 128.6731],
|
||||
[221, '제천', 37.1593, 128.1943], [226, '보은', 36.4876, 127.7341], [229, '북격렬비도', 36.6254, 125.5595],
|
||||
[230, '덕적북리', 37.2645, 126.1030], [232, '천안', 36.7622, 127.2928], [235, '보령', 36.3272, 126.5574],
|
||||
[236, '부여', 36.2724, 126.9208], [238, '금산', 36.1056, 127.4818], [239, '세종', 36.4852, 127.2444],
|
||||
[243, '부안', 35.7296, 126.7166], [244, '임실', 35.6120, 127.2856], [245, '정읍', 35.5633, 126.8390],
|
||||
[247, '남원', 35.4213, 127.3965], [248, '장수', 35.6570, 127.5203], [251, '고창군', 35.4235, 126.6923],
|
||||
[252, '영광군', 35.2837, 126.4778], [253, '김해시', 35.2298, 128.8907], [254, '순창군', 35.3713, 127.1286],
|
||||
[255, '북창원', 35.2266, 128.6726], [257, '양산시', 35.3074, 129.0201], [258, '보성군', 34.7634, 127.2123],
|
||||
[259, '강진군', 34.6446, 126.7841], [260, '장흥', 34.6889, 126.9195], [261, '해남', 34.5538, 126.5691],
|
||||
[262, '고흥', 34.6183, 127.2757], [263, '의령군', 35.3226, 128.2881], [264, '함양군', 35.5114, 127.7454],
|
||||
[266, '광양시', 34.9434, 127.6914], [268, '진도군', 34.4730, 126.2585], [269, '안마도', 35.3469, 126.0305],
|
||||
[271, '봉화', 36.9436, 128.9145], [272, '영주', 36.8718, 128.5169], [273, '문경', 36.6273, 128.1488],
|
||||
[276, '청송군', 36.4351, 129.0401], [277, '영덕', 36.5334, 129.4093], [278, '의성', 36.3561, 128.6886],
|
||||
[279, '구미', 36.1305, 128.3205], [281, '영천', 35.9774, 128.9514], [283, '경주시', 35.8175, 129.2012],
|
||||
[284, '거창', 35.6674, 127.9099], [285, '합천', 35.5650, 128.1699], [288, '밀양', 35.4915, 128.7441],
|
||||
[289, '산청', 35.4130, 127.8791], [294, '거제', 34.8882, 128.6046], [295, '남해', 34.8166, 127.9264],
|
||||
[296, '북부산', 35.2178, 128.9602], [300, '말도', 35.8580, 126.3151], [301, '임자도', 35.0951, 126.1164],
|
||||
[302, '장산도', 34.6427, 126.1826], [303, '가거도', 34.0727, 125.0974], [304, '신지도', 34.3180, 126.8485],
|
||||
[305, '여서도', 33.9862, 126.9201], [306, '소리도', 34.4318, 127.8011], [308, '옥도', 34.6870, 126.0730],
|
||||
[309, '판문점', 37.9464, 126.6866], [310, '궁촌', 37.3247, 129.2647], [311, '가야산', 35.7891, 128.0867],
|
||||
[312, '주왕산', 36.3931, 129.1413], [313, '양지암', 34.8821, 128.7413], [314, '설천봉', 35.8662, 127.7431],
|
||||
[315, '성삼재', 35.3067, 127.5108], [316, '무등산', 35.1144, 126.9974], [317, '구이', 35.7295, 127.1142],
|
||||
[318, '평창용산', 37.6533, 128.6821], [319, '천부', 37.5375, 130.8715], [320, '향로봉', 38.3310, 128.3137],
|
||||
[321, '원통', 38.1147, 128.1963], [322, '상서', 38.2007, 127.6626], [323, '마현', 38.2727, 127.5255],
|
||||
[324, '송계', 36.9102, 128.0745], [325, '백운', 37.1445, 128.0253], [326, '용문산', 37.5455, 127.6113],
|
||||
[327, '청주금천', 36.6261, 127.5128], [328, '상예', 33.2505, 126.3984], [329, '산천단', 33.4473, 126.5655],
|
||||
[330, '대흘', 33.5008, 126.6495], [331, '제주색달', 33.2955, 126.4205], [332, '등봉', 37.4434, 129.1221],
|
||||
[333, '동해심곡', 37.5810, 129.0829], [334, '중앙', 38.2070, 128.5919], [335, '강림', 37.3602, 128.1255],
|
||||
[336, '매일', 37.5609, 128.1102], [337, '우항', 37.4597, 128.0632], [338, '둔내', 37.5057, 128.2107],
|
||||
[339, '서원', 37.4892, 127.8819], [340, '공근', 37.5282, 127.9805], [341, '서전주', 35.8289, 127.0526],
|
||||
[342, '광산진곡', 35.2198, 126.7951], [343, '연천백의', 38.0222, 127.1097], [344, '칠성', 36.7876, 127.8473],
|
||||
[345, '학성', 37.3746, 127.9458], [346, '강릉구정', 37.7120, 128.8972], [347, '귀래', 37.2276, 127.8568],
|
||||
[348, '하조대', 38.0257, 128.7168], [349, '시동', 37.6000, 127.8431], [350, '조침령', 37.9953, 128.4956],
|
||||
[351, '남면', 37.8978, 126.9768], [352, '장흥면', 37.7179, 126.9412], [355, '서탄면', 37.1082, 127.0354],
|
||||
[356, '고덕면', 37.0423, 127.0178], [358, '현덕면', 36.9674, 126.9217], [359, '선단동', 37.8543, 127.1592],
|
||||
[360, '내촌면', 37.7860, 127.2288], [361, '영중면', 38.0053, 127.2449], [363, '북후', 36.6637, 128.7011],
|
||||
[364, '분당구', 37.3828, 127.1190], [365, '석수동', 37.4093, 126.8951], [366, '오전동', 37.3940, 127.0076],
|
||||
[367, '신현동', 37.4062, 126.7846], [368, '수택동', 37.5992, 127.1503], [369, '수리산길', 37.3640, 126.9169],
|
||||
[370, '이동어비', 37.1157, 127.2216], [371, '기흥구갈', 37.2805, 127.1145], [372, '은현면', 37.8750, 127.0246],
|
||||
[373, '양주1', 37.7733, 127.0438], [374, '청북', 37.0465, 126.9707], [375, '백석읍', 37.7946, 126.9855],
|
||||
[376, '광명노온', 37.4474, 126.8589], [377, '안양만안', 37.3952, 126.8919], [378, '정림', 36.3036, 127.3628],
|
||||
[379, '번암', 35.5405, 127.5463], [400, '강남', 37.4982, 127.0816], [401, '서초', 37.4846, 127.0260],
|
||||
[402, '강동', 37.5556, 127.1450], [403, '송파', 37.4743, 127.1254], [404, '강서', 37.5739, 126.8295],
|
||||
[405, '양천', 37.5282, 126.8794], [406, '도봉', 37.6656, 127.0304], [407, '노원', 37.6219, 127.0919],
|
||||
[408, '동대문', 37.5846, 127.0604], [409, '중랑', 37.5855, 127.0868], [410, '기상청', 37.4933, 126.9175],
|
||||
[411, '마포', 37.5517, 126.9292], [412, '서대문', 37.5705, 126.9408], [413, '광진', 37.5366, 127.0852],
|
||||
[414, '성북', 37.6113, 126.9998], [415, '용산', 37.5196, 126.9763], [416, '은평', 37.6465, 126.9427],
|
||||
[417, '금천', 37.4655, 126.9002], [418, '한강', 37.5249, 126.9390], [419, '중구', 37.5524, 126.9874],
|
||||
[421, '성동', 37.5472, 127.0388], [423, '구로', 37.4933, 126.8263], [424, '강북', 37.6380, 127.0098],
|
||||
[425, '남현', 37.4635, 126.9815], [426, '백령(레)', 37.9661, 124.6305], [427, '양촌', 37.6688, 126.6472],
|
||||
[428, '하남덕풍', 37.5512, 127.2128], [429, '부천원미', 37.5006, 126.7636], [430, '경기', 37.2746, 127.0097],
|
||||
[431, '신곡', 37.7487, 127.0718], [432, '향남', 37.1309, 126.9206], [433, '부천', 37.5006, 126.7636],
|
||||
[434, '안양', 37.3944, 126.9569], [435, '고잔', 37.3326, 126.8340], [436, '처인역삼', 37.2344, 127.1884],
|
||||
[437, '광명', 37.4787, 126.8653], [438, '군포', 37.3615, 126.9353], [439, '진안', 37.2088, 127.0357],
|
||||
[440, '설봉', 37.2784, 127.4406], [441, '김포', 37.6076, 126.7624], [442, '지월', 37.4188, 127.2857],
|
||||
[443, '보개', 37.0326, 127.3075], [444, '하남', 37.5379, 127.2137], [445, '의왕', 37.3375, 126.9680],
|
||||
[446, '남촌', 37.1403, 127.0637], [447, '북내', 37.3298, 127.6769], [448, '산북', 37.4023, 127.4640],
|
||||
[449, '옥천', 37.5100, 127.4481], [450, '주교', 37.5984, 126.8490], [451, '오남', 37.6985, 127.2048],
|
||||
[452, '신북', 37.9341, 127.2264], [453, '소하', 37.4421, 126.8964], [454, '하봉암', 37.9743, 127.0669],
|
||||
[455, '경기가평', 37.8460, 127.5008], [456, '연천', 38.0963, 127.0756], [457, '춘궁', 37.5220, 127.1941],
|
||||
[458, '퇴촌', 37.4747, 127.3058], [459, '오포', 37.3454, 127.1967], [460, '실촌', 37.3689, 127.4054],
|
||||
[461, '마장', 37.2490, 127.3580], [462, '모가', 37.1717, 127.4802], [463, '흥천', 37.3316, 127.5481],
|
||||
[464, '점동', 37.2042, 127.6609], [465, '가남', 37.2035, 127.5485], [466, '금사', 37.3897, 127.5344],
|
||||
[467, '양성', 37.0942, 127.2158], [468, '서운', 36.9514, 127.2582], [469, '일죽', 37.1408, 127.4337],
|
||||
[470, '고삼', 37.0806, 127.2696], [471, '송탄', 37.0664, 127.0649], [472, '포승', 36.9845, 126.8547],
|
||||
[473, '가산', 37.8486, 127.1864], [474, '영북', 38.0891, 127.2755], [475, '관인', 38.1577, 127.2512],
|
||||
[476, '화현', 37.9013, 127.2883], [477, '상패', 37.9056, 127.0441], [478, '왕징', 38.0586, 127.0128],
|
||||
[479, '장남', 37.9818, 126.8843], [480, '미산', 38.0474, 126.9929], [481, '탄현', 37.8025, 126.7162],
|
||||
[482, '광탄', 37.7760, 126.8513], [483, '진동', 37.9253, 126.7891], [484, '창현', 37.6466, 127.3165],
|
||||
[485, '신천', 37.6762, 127.4948], [486, '외서', 37.7347, 127.4145], [487, '대곶', 37.6453, 126.5523],
|
||||
[488, '송산', 37.2179, 126.7301], [489, '서신', 37.1666, 126.7088], [491, '군남', 38.0852, 127.0190],
|
||||
[492, '학온동', 37.4155, 126.8513], [493, '송악', 36.7362, 127.0080], [494, '세종고운', 36.5308, 127.2403],
|
||||
[495, '공도', 36.9806, 127.1625], [496, '세종금남', 36.4585, 127.2688], [497, '삽당령', 37.5752, 128.8515],
|
||||
[498, '구룡령', 37.8792, 128.5145], [500, '양도', 37.6657, 126.4702], [501, '대연평', 37.6589, 125.6982],
|
||||
[502, '교동', 37.7894, 126.2916], [503, '도라산', 37.9080, 126.7119], [504, '포천', 37.8704, 127.1807],
|
||||
[505, '가평조종', 37.8244, 127.3454], [506, '파주금촌', 37.7482, 126.7769], [507, '창수', 38.0248, 127.1436],
|
||||
[508, '왕산', 37.4665, 126.3609], [509, '관악', 37.4528, 126.9501], [510, '영등포', 37.5271, 126.9070],
|
||||
[511, '경서동', 37.5675, 126.6351], [512, '인천연수', 37.3968, 126.6621], [513, '덕적지도', 37.0655, 126.0143],
|
||||
[514, '대부도', 37.2377, 126.5787], [515, '운평', 37.0845, 126.7739], [516, '안성', 37.0037, 127.2502],
|
||||
[517, '간성', 38.3851, 128.4752], [518, '해안', 38.2696, 128.1211], [519, '사내', 38.0754, 127.5194],
|
||||
[520, '설악동', 38.1671, 128.5182], [522, '화촌', 37.7871, 127.9838], [523, '주문진', 37.8991, 128.8214],
|
||||
[524, '경포', 37.7863, 128.8938], [525, '봉평', 37.6211, 128.3589], [526, '평창', 37.3775, 128.3947],
|
||||
[527, '신동', 37.2111, 128.6413], [529, '원덕', 37.1416, 129.2859], [530, '태하', 37.5151, 130.8118],
|
||||
[531, '가평북면', 37.8981, 127.5522], [532, '의정부', 37.7348, 127.0735], [533, '백사', 37.3278, 127.4808],
|
||||
[534, '장호원', 37.1210, 127.6106], [535, '서석', 37.7170, 128.1826], [536, '횡성', 37.4876, 127.9724],
|
||||
[537, '임계', 37.4832, 128.8459], [538, '신서', 38.1725, 127.1030], [539, '포천이동', 38.0241, 127.3681],
|
||||
[540, '고양', 37.6373, 126.8920], [541, '남양주', 37.6341, 127.1505], [542, '청평', 37.6841, 127.3802],
|
||||
[543, '영종도', 37.4990, 126.5496], [544, '전곡항', 37.1857, 126.6544], [545, '안산', 37.2810, 126.8385],
|
||||
[546, '경기광주', 37.4353, 127.2592], [547, '양동', 37.4159, 127.7549], [548, '여주', 37.2688, 127.6396],
|
||||
[549, '용인', 37.2701, 127.2218], [550, '오산', 37.1693, 127.0527], [551, '평택', 36.9877, 127.1088],
|
||||
[552, '김화', 38.2315, 127.3894], [553, '죽정', 38.4570, 128.4179], [554, '미시령', 38.2144, 128.4371],
|
||||
[555, '화천', 38.0837, 127.6906], [556, '양구', 38.0980, 127.9853], [557, '기린', 37.9537, 128.3139],
|
||||
[558, '팔봉', 37.6861, 127.7007], [559, '내면', 37.7781, 128.3973], [560, '진부', 37.6479, 128.5645],
|
||||
[561, '청일', 37.5822, 128.1527], [563, '북평', 37.4636, 128.6828], [565, '시흥', 37.3915, 126.7780],
|
||||
[566, '연곡', 37.8516, 128.8192], [567, '적성', 37.9533, 126.9318], [568, '일동', 37.9588, 127.3135],
|
||||
[569, '구리', 37.5822, 127.1570], [570, '금곡', 37.6232, 126.6423], [571, '화성', 37.1953, 126.8209],
|
||||
[572, '성남', 37.4209, 127.1248], [573, '청운', 37.5582, 127.7135], [574, '대신', 37.3694, 127.5871],
|
||||
[575, '용인이동', 37.1058, 127.1877], [576, '백암', 37.1297, 127.3663], [577, '장봉도', 37.5335, 126.3366],
|
||||
[578, '호도', 36.3012, 126.2663], [579, '하장', 37.3668, 128.9133], [580, '옥계', 37.6135, 129.0289],
|
||||
[581, '상동', 37.1166, 128.7744], [582, '신림', 37.2315, 128.0799], [583, '안흥', 37.4646, 128.1551],
|
||||
[585, '신남', 37.9600, 128.0742], [586, '북산', 38.0321, 127.8769], [587, '방산', 38.2264, 127.9533],
|
||||
[588, '남산', 37.7907, 127.6429], [589, '고양고봉', 37.7024, 126.7903], [590, '과천', 37.4403, 127.0025],
|
||||
[591, '치악산', 37.4155, 128.0505], [592, '부론', 37.2316, 127.7487], [593, '양양영덕', 38.0073, 128.5407],
|
||||
[594, '서화', 38.2533, 128.2100], [595, '진부령', 38.2641, 128.3741], [596, '오색', 38.0770, 128.4932],
|
||||
[597, '대화', 37.5455, 128.4411], [598, '양주', 37.8312, 126.9905], [599, '광릉', 37.7617, 127.1698],
|
||||
[600, '금왕', 36.9737, 127.5897], [601, '단양', 37.0020, 128.3469], [602, '진천', 36.8634, 127.4623],
|
||||
[603, '괴산', 36.8161, 127.7776], [604, '옥천', 36.3000, 127.5965], [605, '영동', 36.1600, 127.7550],
|
||||
[606, '대산', 37.0106, 126.3881], [607, '근흥', 36.6731, 126.1354], [609, '삽시도', 36.3380, 126.3562],
|
||||
[610, '홍성죽도', 36.5200, 126.4424], [611, '세종연서', 36.5667, 127.2806], [612, '공주', 36.4828, 127.1365],
|
||||
[614, '서천', 36.0625, 126.7041], [615, '논산', 36.2116, 127.1081], [616, '당진', 36.8894, 126.6174],
|
||||
[617, '직산', 36.8906, 127.1456], [618, '청양', 36.4237, 126.7791], [619, '음성', 36.9230, 127.7024],
|
||||
[620, '엄정', 37.0737, 127.9035], [621, '수산', 36.9733, 128.2142], [622, '수안보', 36.8391, 128.0079],
|
||||
[623, '증평', 36.7956, 127.5620], [624, '상당', 36.6244, 127.6571], [625, '속리산', 36.5165, 127.8156],
|
||||
[626, '옥천청산', 36.3529, 127.8216], [627, '태안', 36.7540, 126.3295], [628, '예산', 36.7392, 126.8199],
|
||||
[629, '세종전의', 36.6908, 127.2005], [630, '노은', 37.0457, 127.7997], [631, '송도', 37.3477, 126.6154],
|
||||
[632, '유구', 36.5406, 126.9472], [633, '정안', 36.5511, 127.0938], [634, '아산', 36.8458, 126.8654],
|
||||
[635, '양화', 36.1319, 126.8602], [636, '계룡', 36.3132, 127.2407], [637, '신평', 36.8790, 126.8238],
|
||||
[638, '영춘', 37.0778, 128.4936], [639, '덕산', 36.9043, 128.1456], [640, '청천', 36.6711, 127.8653],
|
||||
[641, '가덕', 36.5461, 127.5239], [642, '오월드', 36.2913, 127.3959], [643, '세천', 36.3402, 127.4938],
|
||||
[644, '연무', 36.1366, 127.0900], [645, '서부', 36.5789, 126.5110], [646, '춘장대', 36.1741, 126.5285],
|
||||
[647, '가곡', 36.1247, 127.6851], [648, '장동', 36.4135, 127.4382], [649, '부평', 37.4722, 126.7507],
|
||||
[650, '양지', 38.2509, 127.2714], [651, '외촌', 38.2772, 127.2057], [652, '연천청산', 37.9900, 127.0727],
|
||||
[654, '자월도', 37.2540, 126.3076], [655, '소청도', 37.7602, 124.7291], [656, '볼음도', 37.6873, 126.1934],
|
||||
[657, '대천항', 36.3244, 126.5021], [658, '만리포', 36.7683, 126.1214], [659, '계룡산', 36.3430, 127.2057],
|
||||
[660, '면온', 37.5620, 128.3776], [661, '현내', 38.5425, 128.4019], [662, '승봉도', 37.1706, 126.2969],
|
||||
[663, '목덕도', 36.9286, 125.7869], [664, '영흥도', 37.2247, 126.4584], [665, '무의도', 37.3904, 126.4252],
|
||||
[666, '안도', 36.9587, 126.1683], [667, '옹도', 36.6476, 126.0093], [669, '외연도', 36.2292, 126.0758],
|
||||
[670, '양양', 38.0887, 128.6295], [671, '속초조양', 38.1922, 128.5761], [672, '상하', 35.4424, 126.4883],
|
||||
[673, '진영', 35.2822, 128.7175], [674, '사북', 37.2196, 128.8214], [675, '남이섬', 37.7898, 127.5277],
|
||||
[676, '위성센터', 36.9906, 127.4299], [677, '달방댐', 37.5173, 129.0213], [678, '강릉성산', 37.7249, 128.7786],
|
||||
[679, '강릉왕산', 37.6105, 128.7730], [680, '평화', 38.2128, 127.8447], [681, '간동', 38.0512, 127.7906],
|
||||
[682, '임남', 38.2832, 127.8625], [687, '석곡', 35.1262, 127.2529], [688, '봉산', 35.2688, 126.9391],
|
||||
[689, '광주남구', 35.1008, 126.8985], [690, '풍산', 35.3362, 127.1392], [691, '정산', 36.3862, 126.9565],
|
||||
[692, '백학', 38.0404, 126.9245], [694, '원효봉', 36.7043, 126.6094], [695, '광덕산', 38.1162, 127.4320],
|
||||
[696, '신기', 37.3466, 129.0861], [697, '서거차도', 34.2506, 125.9183], [698, '해제', 35.0939, 126.2851],
|
||||
[699, '무안', 34.9838, 126.4611], [700, '어청도', 36.1251, 125.9682], [701, '무주', 36.0017, 127.6684],
|
||||
[702, '익산', 35.9381, 126.9925], [703, '진안', 35.7604, 127.4374], [704, '변산', 35.6210, 126.4775],
|
||||
[706, '담양', 35.3102, 126.9727], [707, '지도', 35.0580, 126.2084], [708, '광산', 35.1289, 126.7452],
|
||||
[709, '구례', 35.2081, 127.4935], [710, '나주', 35.0250, 126.7440], [711, '이양', 34.8971, 126.9898],
|
||||
[712, '순천시', 34.9035, 127.5193], [713, '광양읍', 34.9751, 127.5826], [714, '자은도', 34.8791, 126.0287],
|
||||
[716, '하의도', 34.6307, 126.0320], [717, '의신', 34.4275, 126.3098], [718, '상조도', 34.3330, 126.0365],
|
||||
[719, '선유도', 35.8088, 126.3977], [720, '보길도', 34.1621, 126.5551], [721, '금일', 34.3390, 127.0282],
|
||||
[722, '조선대', 35.1368, 126.9287], [723, '거문도', 34.0279, 127.3090], [724, '추자도', 33.9578, 126.3016],
|
||||
[725, '우도', 33.5229, 126.9541], [726, '마라도', 33.1221, 126.2679], [727, '유수암', 33.4099, 126.3929],
|
||||
[730, '장성', 35.2794, 126.7611], [731, '영암', 34.8167, 126.6863], [732, '보성', 34.7619, 127.0921],
|
||||
[733, '함라', 36.0461, 126.8917], [734, '완주', 35.9855, 127.2457], [735, '덕유산', 35.8942, 127.7731],
|
||||
[736, '진봉', 35.8468, 126.7840], [737, '김제', 35.8090, 126.8777], [738, '줄포', 35.5795, 126.6739],
|
||||
[739, '심원', 35.5225, 126.5467], [741, '화순', 35.0567, 126.9852], [742, '운남', 34.9389, 126.3306],
|
||||
[743, '비금', 34.7726, 125.9469], [744, '산이', 34.6725, 126.4365], [745, '성전', 34.6850, 126.7255],
|
||||
[746, '땅끝', 34.3818, 126.5159], [747, '청산도', 34.1829, 126.8580], [748, '벌교', 34.8369, 127.3631],
|
||||
[749, '도양', 34.5347, 127.1272], [750, '백야', 34.6278, 127.6357], [751, '와산', 33.4793, 126.6939],
|
||||
[752, '서광', 33.3046, 126.3060], [753, '어리목', 33.3930, 126.4959], [754, '함평', 35.0604, 126.5273],
|
||||
[755, '백아면', 35.1596, 127.0853], [756, '위도', 35.6015, 126.2820], [757, '진안주천', 35.9744, 127.4360],
|
||||
[758, '동향', 35.8352, 127.5712], [759, '뱀사골', 35.3716, 127.5783], [760, '복흥', 35.4274, 126.9327],
|
||||
[761, '태인', 35.6508, 126.9346], [762, '임실강진', 35.5517, 127.1869], [763, '여산', 36.0592, 127.0620],
|
||||
[764, '신덕', 35.6736, 127.1414], [765, '복내', 34.8923, 127.1345], [766, '여수산단', 34.8495, 127.7150],
|
||||
[767, '포두', 34.5885, 127.3975], [768, '곡성', 35.2889, 127.2853], [769, '염산', 35.1993, 126.3834],
|
||||
[770, '다도', 34.9286, 126.8200], [771, '안좌', 34.7269, 126.1446], [772, '수유', 34.5321, 126.2396],
|
||||
[773, '학산', 34.7085, 126.6359], [774, '전남도청', 34.8175, 126.4662], [775, '월야', 35.1778, 126.6391],
|
||||
[776, '현산', 34.4600, 126.5571], [777, '관산', 34.5563, 126.9346], [778, '유치', 34.7959, 126.8218],
|
||||
[779, '한림', 33.3927, 126.2581], [780, '제주남원', 33.2772, 126.7044], [781, '구좌', 33.5199, 126.8777],
|
||||
[782, '성판악', 33.3851, 126.6194], [783, '과기원', 35.2303, 126.8408], [784, '시종', 34.8772, 126.6126],
|
||||
[785, '북일', 34.4638, 126.6763], [786, '돌산', 34.6381, 127.7741], [787, '도화', 34.5067, 127.3172],
|
||||
[788, '풍암', 35.1316, 126.8813], [789, '압해도', 34.8764, 126.2929], [790, '나로도', 34.5326, 127.4673],
|
||||
[791, '피아골', 35.2662, 127.5838], [792, '표선', 33.3630, 126.8184], [793, '대정', 33.2410, 126.2263],
|
||||
[794, '황전', 35.1007, 127.4274], [795, '옥과', 35.2753, 127.1390], [796, '초도', 34.2377, 127.2448],
|
||||
[797, '하태도', 34.3950, 125.2995], [798, '홍도', 34.6855, 125.1924], [799, '낙월도', 35.2020, 126.1349],
|
||||
[800, '평해', 36.7257, 129.4437], [801, '영양', 36.6250, 129.0880], [802, '온정', 36.7574, 129.3401],
|
||||
[803, '옥산', 36.4064, 128.7990], [804, '청하', 36.1906, 129.3392], [805, '죽장', 36.1793, 129.0943],
|
||||
[806, '선산', 36.2334, 128.2903], [807, '의흥', 36.1797, 128.6932], [808, '호미곶', 36.0760, 129.5667],
|
||||
[809, '대덕', 35.9354, 127.9792], [810, '성주', 35.9255, 128.2985], [811, '황성', 35.8630, 129.2093],
|
||||
[812, '고령', 35.6960, 128.2821], [813, '청도', 35.6540, 128.7318], [814, '부석', 36.9792, 128.6610],
|
||||
[815, '예천', 36.6600, 128.4646], [816, '구룡포', 35.9831, 129.5475], [817, '수비', 36.7827, 129.2212],
|
||||
[818, '마성', 36.7078, 128.1479], [819, '예안', 36.6587, 128.8864], [820, '하회', 36.5482, 128.5266],
|
||||
[821, '공성', 36.2819, 128.0739], [822, '김천', 36.1199, 128.1552], [823, '군위', 36.2493, 128.5558],
|
||||
[824, '가산', 36.0956, 128.5001], [825, '칠곡', 36.0398, 128.3813], [826, '신녕', 36.0471, 128.7900],
|
||||
[827, '경산', 35.8242, 128.7430], [828, '달성', 35.6902, 128.4238], [829, '외동', 35.7121, 129.3182],
|
||||
[830, '기계', 36.0689, 129.1996], [831, '석포', 37.0401, 128.9971], [832, '단북', 36.3907, 128.4286],
|
||||
[833, '은척', 36.5383, 128.0419], [834, '화서', 36.4412, 127.9442], [835, '봉화읍', 36.8932, 128.7160],
|
||||
[836, '현서', 36.2794, 128.8960], [837, '이산', 36.8268, 128.6429], [838, '동로', 36.7907, 128.2705],
|
||||
[839, '길안', 36.4583, 128.8954], [840, '하양', 35.9126, 128.8144], [841, '화북', 36.0849, 128.9111],
|
||||
[842, '산내', 35.7576, 129.0110], [843, '금강송', 36.9361, 129.2501], [844, '영덕읍', 36.4355, 129.3586],
|
||||
[845, '대구북구', 35.9084, 128.5907], [846, '대구서구', 35.8651, 128.5309], [847, '소보', 36.2762, 128.4664],
|
||||
[848, '청도금천', 35.6792, 128.8953], [849, '지보', 36.5469, 128.3871], [850, '감포', 35.7847, 129.4906],
|
||||
[851, '소곡', 37.0514, 129.3521], [852, '죽변', 37.0606, 129.4260], [853, '팔공산', 36.0183, 128.6213],
|
||||
[854, '삼동', 35.4953, 129.1336], [855, '가파도', 33.1733, 126.2683], [856, '광양백운산', 35.1018, 127.5982],
|
||||
[857, '완도읍', 34.3000, 126.7174], [858, '지산', 34.4296, 126.1701], [859, '토함산', 35.7549, 129.3687],
|
||||
[860, '신암', 35.8852, 128.6190], [861, '제주김녕', 33.5556, 126.7729], [862, '송당', 33.4706, 126.7792],
|
||||
[863, '외도', 33.4769, 126.4317], [864, '완산', 35.8215, 127.1550], [865, '오등', 33.4577, 126.5220],
|
||||
[867, '삼각봉', 33.3769, 126.5305], [868, '사제비', 33.3760, 126.4980], [869, '영실', 33.3483, 126.4964],
|
||||
[870, '진달래밭', 33.3698, 126.5557], [871, '윗세오름', 33.3623, 126.5181], [872, '지리산', 35.3192, 127.7564],
|
||||
[874, '동송', 38.1416, 127.2197], [875, '설악산', 38.1211, 128.4606], [876, '삼척', 37.4500, 129.1621],
|
||||
[877, '문막', 37.3134, 127.8049], [878, '도계', 37.2238, 129.0961], [881, '새만금', 35.7294, 126.5289],
|
||||
[882, '상무대', 35.2525, 126.6053], [883, '새별오름', 33.3623, 126.3599], [885, '한남', 33.3314, 126.6785],
|
||||
[886, '군산산단', 35.9505, 126.5912], [888, '청남대', 36.4627, 127.4907], [889, '현충원', 37.5004, 126.9765],
|
||||
[890, '제주가시리', 33.3854, 126.7336], [892, '성산수산', 33.4501, 126.8513], [893, '애월', 33.4659, 126.3274],
|
||||
[896, '군포금정', 37.3615, 126.9353], [897, '의왕이동', 37.3445, 126.9688], [898, '장생포', 35.5048, 129.3860],
|
||||
[899, '마산회원', 35.2235, 128.5822], [900, '두서', 35.6203, 129.1436], [901, '울기', 35.4868, 129.4353],
|
||||
[902, '시천', 35.2423, 127.8190], [903, '도천', 35.4039, 128.5149], [904, '사상', 35.1694, 128.9751],
|
||||
[905, '양산상북', 35.4414, 129.0429], [906, '화개', 35.2324, 127.6427], [907, '삼천포', 34.9321, 128.0631],
|
||||
[908, '진해', 35.1122, 128.7537], [909, '서이말', 34.7877, 128.7382], [910, '영도', 35.0661, 129.0742],
|
||||
[911, '매물도', 34.6489, 128.5771], [912, '백천', 35.5263, 127.7784], [913, '상주면', 34.7238, 127.9815],
|
||||
[914, '서하', 35.6376, 127.7156], [915, '삼가', 35.4135, 128.1018], [916, '단성', 35.3111, 127.9670],
|
||||
[917, '사천', 35.0369, 128.0677], [918, '고성', 34.9905, 128.3309], [919, '창녕', 35.5514, 128.4767],
|
||||
[920, '함안', 35.2961, 128.3994], [921, '가덕도', 34.9931, 128.8314], [922, '단장', 35.4864, 128.9291],
|
||||
[923, '기장', 35.2751, 129.2511], [924, '간절곶', 35.3587, 129.3603], [925, '생림', 35.3743, 128.8230],
|
||||
[926, '진북', 35.1262, 128.4748], [927, '송백', 35.5820, 128.8836], [929, '개천', 35.1137, 128.3070],
|
||||
[930, '사량도', 34.8099, 128.2381], [931, '욕지도', 34.6223, 128.2735], [932, '하동', 35.0606, 127.7666],
|
||||
[933, '금남', 34.9968, 127.8324], [934, '수곡', 35.2214, 127.9285], [935, '청덕', 35.5698, 128.3411],
|
||||
[936, '신포', 35.3326, 128.2023], [937, '해운대', 35.1772, 129.1607], [938, '부산진', 35.1589, 129.0193],
|
||||
[939, '금정구', 35.2932, 129.1035], [940, '동래', 35.2091, 129.0901], [941, '북구', 35.2130, 129.0026],
|
||||
[942, '부산남구', 35.1186, 129.0884], [943, '매곡', 35.6671, 129.3750], [944, '길곡', 35.3852, 128.5701],
|
||||
[945, '대병', 35.5345, 127.9901], [946, '북상', 35.7800, 127.8183], [947, '명사', 34.7219, 128.5908],
|
||||
[948, '삼장', 35.3107, 127.8345], [949, '정자', 35.6375, 129.4412], [950, '사하', 35.0901, 128.9839],
|
||||
[951, '내장산', 35.5087, 126.9073], [953, '장목', 34.9984, 128.6798], [954, '온산', 35.4310, 129.3601],
|
||||
[955, '서수도', 37.3250, 126.3933], [956, '가대암', 36.7700, 125.9767], [957, '십이동파', 35.9867, 126.2250],
|
||||
[958, '갈매여', 35.6133, 126.2450], [959, '해수서', 34.2633, 126.0267], [960, '지귀도', 33.2233, 126.6542],
|
||||
[961, '간여암', 34.2850, 127.8578], [963, '이덕서', 35.5667, 129.4750], [964, '마량', 34.4590, 126.8157],
|
||||
[965, '한라산남벽', 33.3524, 126.5330], [966, '풍도', 37.1161, 126.3856], [967, '도리도', 37.1189, 126.6128],
|
||||
[970, '철원장흥', 38.2016, 127.2502], [972, '안동옥동', 36.5498, 128.6834], [973, '화순능주', 35.0233, 126.9496],
|
||||
[974, '대곡', 35.2443, 128.1560], [977, '오창가곡', 36.7246, 127.4669], [978, '춘천신북', 37.9546, 127.7763],
|
||||
[980, '강정', 33.2599, 126.4892], [984, '오륙도', 35.0913, 129.1267], [989, '안덕화순', 33.2579, 126.3299],
|
||||
[990, '낙천', 33.3182, 126.2305], [991, '옥포', 35.8028, 128.4456], [992, '하빈', 35.9062, 128.4464],
|
||||
[993, '제주금악', 33.3422, 126.3098], [994, '심포', 35.8545, 126.6421], [995, '오천', 35.9286, 129.3822],
|
||||
[996, '화동', 36.3721, 127.8949],
|
||||
];
|
||||
+126
-22
@@ -4,6 +4,7 @@ import { getVault } from '../security/vault.js';
|
||||
import { getPaths, getWorkspacePath } from '../config/paths.js';
|
||||
import { buildImageMarkdown } from './image.js';
|
||||
import { splitOpenMeteoVariables, formatOpenMeteoReport } from './weather-openmeteo-format.js';
|
||||
import { AWS_STATIONS } from './kma-aws-stations.js';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
@@ -983,6 +984,35 @@ export function findAsosStation(location: string): { id: number; name: string }
|
||||
return null;
|
||||
}
|
||||
|
||||
// 두 지점망(ASOS 97개 / AWS 745개)이 같은 계산을 쓰므로 하버사인을 한 번만 둔다.
|
||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const toRad = (d: number) => d * Math.PI / 180;
|
||||
const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
function nearestIn(
|
||||
stations: Array<[number, string, number, number]>, lat: number, lon: number, maxKm: number,
|
||||
): { id: number; name: string; km: number } | null {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
let best: { id: number; name: string; km: number } | null = null;
|
||||
for (const [id, name, sLat, sLon] of stations) {
|
||||
const km = haversineKm(lat, lon, sLat, sLon);
|
||||
if (!best || km < best.km) best = { id, name, km: Math.round(km * 10) / 10 };
|
||||
}
|
||||
return best && best.km <= maxKm ? best : null;
|
||||
}
|
||||
|
||||
// AWS는 745개로 훨씬 촘촘해서 반경을 좁게 잡아도 대부분 걸린다. 20km면 웬만한 동네가
|
||||
// 자기 관측소를 쓴다 — ASOS(40km)보다 좁힌 건 그만큼 대표성을 높게 요구할 수 있어서다.
|
||||
export function nearestAwsStation(
|
||||
lat: number, lon: number, maxKm = 20,
|
||||
): { id: number; name: string; km: number } | null {
|
||||
return nearestIn(AWS_STATIONS, lat, lon, maxKm);
|
||||
}
|
||||
|
||||
// 이름으로 못 맞힌 위치(좌표 입력, ASOS 없는 소도시)를 위한 최근접 지점 선택.
|
||||
// maxKm 밖이면 null — 멀리 있는 관측소를 조용히 빌려 쓰면 "관측값"이라는 라벨이 거짓말이 된다.
|
||||
// 40km는 우리 ASOS 망(97개) 간격을 보고 잡은 값이고, 넘으면 Open-Meteo 추정으로 물러난다.
|
||||
@@ -990,19 +1020,7 @@ export function findAsosStation(location: string): { id: number; name: string }
|
||||
export function nearestAsosStation(
|
||||
lat: number, lon: number, maxKm = 40,
|
||||
): { id: number; name: string; km: number } | null {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const toRad = (d: number) => d * Math.PI / 180;
|
||||
let best: { id: number; name: string; km: number } | null = null;
|
||||
for (const [id, name, sLat, sLon] of ASOS_STATIONS) {
|
||||
// 하버사인. 한반도 규모에선 평면 근사로도 충분하지만, 위도에 따라 경도 1도의 거리가
|
||||
// 달라지는 걸 잘못 다루면 동서로 20% 가까이 어긋나서 그냥 정식으로 계산한다.
|
||||
const dLat = toRad(sLat - lat), dLon = toRad(sLon - lon);
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(lat)) * Math.cos(toRad(sLat)) * Math.sin(dLon / 2) ** 2;
|
||||
const km = 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
if (!best || km < best.km) best = { id, name, km: Math.round(km * 10) / 10 };
|
||||
}
|
||||
return best && best.km <= maxKm ? best : null;
|
||||
return nearestIn(ASOS_STATIONS, lat, lon, maxKm);
|
||||
}
|
||||
|
||||
// ASOS 시간자료의 rn 인코딩: ""=무강수, "0.0"=미량(관측은 됐으나 0.05mm 미만), "1.0"=1.0mm.
|
||||
@@ -1014,6 +1032,71 @@ export function normalizeAsosHour(tm: string, rn: string | null | undefined): {
|
||||
return { time, mm: Number.isFinite(mm) ? mm : 0, wet: true };
|
||||
}
|
||||
|
||||
// AWS 매분자료 컬럼 순서. 헤더에 이름은 나오지만 CSV에는 안 붙어서 위치로 읽어야 하고,
|
||||
// 한 칸 밀리면 기온을 강수량으로 읽는다(실제로 RN-12H를 RN-DAY로 착각해 한 번 틀렸다).
|
||||
const AWS_COL = { TM: 0, STN: 1, TA: 8, RE: 9, RN15: 10, RN60: 11, RN12H: 12, RNDAY: 13, HM: 14 } as const;
|
||||
|
||||
// -50 이하는 "관측 없음/에러"를 뜻하는 센티널이다(-99.9로 온다). 0으로 읽으면
|
||||
// 비가 안 온 것처럼 보이고, 그대로 더하면 합계가 음수로 망가진다.
|
||||
export function awsNum(raw: string | undefined): number | null {
|
||||
if (raw == null) return null;
|
||||
const v = parseFloat(String(raw).trim());
|
||||
return Number.isFinite(v) && v > -50 ? v : null;
|
||||
}
|
||||
|
||||
// 한 지점의 오늘 매분자료(00:00~현재)를 한 번에 받는다. 781행 74KB, 0.8초 정도.
|
||||
// RN-DAY가 그대로 일 누적이라 초단기실황을 시간별로 23번 부르던 걸 대체한다.
|
||||
// 시간별 값도 같이 뽑아 사상 판정에 넘긴다 — RE(강수감지)는 전 지점 null이라 못 쓰고,
|
||||
// 매분 RN-60m이 그 시각까지의 60분 누적이라 정시 값을 그 시간의 강수량으로 쓴다.
|
||||
export async function parseAwsDay(csv: string): Promise<{
|
||||
rnDay: number | null; ta: number | null; hm: number | null; tm: string | null;
|
||||
hours: Array<{ time: string; mm: number; wet: boolean }>;
|
||||
}> {
|
||||
const rows = csv.split('\n').filter(l => l.trim() && !l.startsWith('#')).map(l => l.split(',').map(s => s.trim()));
|
||||
if (!rows.length) return { rnDay: null, ta: null, hm: null, tm: null, hours: [] };
|
||||
// 맨 끝 행은 "지금 이 분"이라 아직 안 채워진 경우가 많다 — 전 컬럼이 -99.9로 온다.
|
||||
// 그걸 그대로 쓰면 관측이 멀쩡한데도 전부 null이 되어 폴백으로 새버린다.
|
||||
// 뒤에서부터 RN-DAY가 실제 값인 첫 행을 쓴다.
|
||||
const last = [...rows].reverse().find(r => awsNum(r[AWS_COL.RNDAY]) != null) ?? rows[rows.length - 1];
|
||||
const hours: Array<{ time: string; mm: number; wet: boolean }> = [];
|
||||
for (const r of rows) {
|
||||
const tm = r[AWS_COL.TM];
|
||||
if (!/^\d{12}$/.test(tm) || tm.slice(10) !== '00') continue; // 정시만
|
||||
const rn60 = awsNum(r[AWS_COL.RN60]);
|
||||
if (rn60 == null) continue;
|
||||
const iso = `${tm.slice(0,4)}-${tm.slice(4,6)}-${tm.slice(6,8)}T${tm.slice(8,10)}:00`;
|
||||
hours.push({ time: iso, mm: rn60, wet: rn60 >= RAIN_WET_HOUR_MM });
|
||||
}
|
||||
const rnDay = awsNum(last[AWS_COL.RNDAY]);
|
||||
const tm = /^\d{12}$/.test(last[AWS_COL.TM]) ? last[AWS_COL.TM] : null;
|
||||
|
||||
// 정시 RN-60m만 더하면 마지막 정시 이후의 자투리(예: 13:00~13:27)가 빠진다. 그대로 두면
|
||||
// "오늘 누적"(RN-DAY)이 "이번 강수 누적"(시간 합)보다 커지는 모순이 화면에 그대로 뜬다 —
|
||||
// 실제로 부산에서 27분 사이 4mm가 빠져 23.6 vs 19.6으로 갈렸다.
|
||||
// 차액을 현재 시각의 한 칸으로 넣어 시간 배열의 합이 RN-DAY와 맞게 만든다.
|
||||
if (rnDay != null && tm) {
|
||||
const summed = hours.reduce((s, h) => s + h.mm, 0);
|
||||
const rest = Math.round((rnDay - summed) * 10) / 10;
|
||||
if (rest > 0) {
|
||||
hours.push({
|
||||
time: `${tm.slice(0,4)}-${tm.slice(4,6)}-${tm.slice(6,8)}T${tm.slice(8,10)}:${tm.slice(10,12)}`,
|
||||
mm: rest,
|
||||
wet: rest >= RAIN_WET_HOUR_MM,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { rnDay, ta: awsNum(last[AWS_COL.TA]), hm: awsNum(last[AWS_COL.HM]), tm, hours };
|
||||
}
|
||||
|
||||
async function fetchAwsToday(stnId: number, todayKst: string) {
|
||||
const now = new Date(Date.now() + 9 * 3600 * 1000);
|
||||
const tm2 = todayKst + String(now.getUTCHours()).padStart(2, '0') + String(now.getUTCMinutes()).padStart(2, '0');
|
||||
const csv = await apiHubFetch(
|
||||
`typ01/cgi-bin/url/nph-aws2_min?tm1=${todayKst}0000&tm2=${tm2}&stn=${stnId}&disp=1&help=0`);
|
||||
return parseAwsDay(csv);
|
||||
}
|
||||
|
||||
const ASOS_BASE = 'http://apis.data.go.kr/1360000/AsosHourlyInfoService';
|
||||
|
||||
async function asosFetch(params: Record<string, string>): Promise<any[]> {
|
||||
@@ -1225,17 +1308,34 @@ 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 = base_date === todayKst
|
||||
? await fetchKmaTodayRain(nx, ny, base_date, parseInt(base_time.slice(0, 2), 10))
|
||||
: null;
|
||||
|
||||
// 이름으로 지점을 맞히고, 안 되면 좌표로 최근접 지점(40km 이내)을 잡는다.
|
||||
// 둘 다 실패해야 Open-Meteo 추정으로 물러난다.
|
||||
const named = findAsosStation(location) ?? findAsosStation(displayName);
|
||||
const near = named ? null : nearestAsosStation(lat, lon);
|
||||
const station = named ?? near;
|
||||
const observed = station && todayRain
|
||||
? await fetchKmaRainEpisode(station.id, todayKst, todayRain.perHour)
|
||||
|
||||
// 오늘 누적은 AWS 매분자료의 RN-DAY를 쓴다 — 관측소 실측이고 1회 호출이다.
|
||||
// 지점은 ASOS(97개)가 아니라 AWS(745개) 중 최근접으로 따로 고른다. ASOS에 묶어두면
|
||||
// 시흥이 19.1km 떨어진 인천을 읽는데, AWS로 고르면 2.5km 시흥 관측소가 잡힌다.
|
||||
//
|
||||
// 사상 누적은 과거 구간이 ASOS라서 관측소가 갈릴 수 있다. 그래도 "오늘 > 사상" 같은
|
||||
// 모순은 안 난다 — 사상 합계는 오늘치를 통째로 포함하고 과거 몫은 음수가 될 수 없다.
|
||||
// 두 줄에 각각 관측소 이름이 찍히므로 어디서 온 값인지도 드러난다.
|
||||
// 둘 다 없으면 초단기실황 합산으로 물러난다(격자 보간이라 값은 조금 다르지만 어디서든 나온다).
|
||||
const awsStn = nearestAwsStation(lat, lon) ?? station;
|
||||
let aws: Awaited<ReturnType<typeof fetchAwsToday>> | null = null;
|
||||
if (awsStn && base_date === todayKst) {
|
||||
try { aws = await fetchAwsToday(awsStn.id, todayKst); } catch { aws = null; }
|
||||
}
|
||||
|
||||
const todayRain = (aws?.rnDay == null && base_date === todayKst)
|
||||
? await fetchKmaTodayRain(nx, ny, base_date, parseInt(base_time.slice(0, 2), 10))
|
||||
: null;
|
||||
|
||||
// 사상 판정에 넘길 오늘 구간: AWS를 썼으면 그 시간별 값, 아니면 초단기실황 쪽.
|
||||
const todayHours = aws?.hours.length ? aws.hours : (todayRain?.perHour ?? []);
|
||||
const observed = station && todayHours.length
|
||||
? await fetchKmaRainEpisode(station.id, todayKst, todayHours)
|
||||
: null;
|
||||
const episode = observed ?? await fetchRainEpisode(lat, lon);
|
||||
// 최근접으로 잡았으면 거리를 밝힌다 — 그 지점이 대표성이 있는지는 사용자가 판단할 몫이다.
|
||||
@@ -1243,8 +1343,12 @@ export const weatherKmaTool = {
|
||||
? `기상청 ${station!.name} 관측${near ? ` (${near.km}km)` : ''}`
|
||||
: 'Open-Meteo 추정';
|
||||
|
||||
const rainLine = todayRain
|
||||
? `오늘 누적 강수량: ${todayRain.mm} mm (00시~${String(todayRain.throughHour).padStart(2, '0')}시 실측 합산`
|
||||
const rainLine = aws?.rnDay != null
|
||||
? `오늘 누적 강수량: ${aws.rnDay} mm (AWS ${awsStn!.name} 관측`
|
||||
+ (aws.tm ? `, ${aws.tm.slice(8,10)}:${aws.tm.slice(10,12)} 기준` : '')
|
||||
+ ')'
|
||||
: todayRain
|
||||
? `오늘 누적 강수량: ${todayRain.mm} mm (00시~${String(todayRain.throughHour).padStart(2, '0')}시 격자 합산`
|
||||
+ (todayRain.missing.length ? `, ${todayRain.missing.length}개 시각 결측` : '')
|
||||
+ ')'
|
||||
: '오늘 누적 강수량: 조회 불가';
|
||||
@@ -1273,7 +1377,7 @@ export const weatherKmaTool = {
|
||||
'',
|
||||
'(미세먼지/PM2.5/PM10/황사는 이 데이터에 없음 — weather_airkorea 사용)',
|
||||
];
|
||||
return { success: true, stdout: lines.join('\n'), data: { location: displayName, nx, ny, items, todayRain, episode } };
|
||||
return { success: true, stdout: lines.join('\n'), data: { location: displayName, nx, ny, items, todayRain, aws, episode } };
|
||||
} else {
|
||||
const { base_date, base_time } = getKmaBaseDateTime('fcst');
|
||||
const items = await kmaFetch('getVilageFcst', { base_date, base_time, nx: String(nx), ny: String(ny) });
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user