Files
Aislo/common_util/common_util_wamis_rainfall.py

340 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""map.wamis.go.kr 확률강우량 등우선도 수급·내삽 (표준 라이브러리만 사용).
「전국 하천수계 홍수량 및 확률강우량 정보」가 공개하는 등우선 GeoJSON에서
지점의 지속시간×재현기간 확률강우량표를 만든다. 인증 불필요.
https://map.wamis.go.kr/data/gis/contour/{재현기간3자리}yr_{지속시간}.json
파일 안 등우선의 ``ELEV`` 값이 그 선의 확률강우량(mm), 좌표계는 EPSG:3857.
지점값은 값이 다른 인접 등우선 두 개까지의 거리비례 내부삽입으로 얻는다.
- 원본 96개(8빈도×12지속시간)는 전국 공통이라 전역 폴더에 1회 캐시한다.
- 1시간 미만 자료는 사이트에 없다(2026-08 실측). 5분 강우강도는 100년빈도
12점을 General형 강우강도식으로 적합해 얻는다(설계 근거:
docs/raw/guidelines/2026-08-05_임도_배수설계_규정_조사.md).
검증: 2026-08-05 실서버 실측 — 문경 100년 1시간 142.5mm / 24시간 387.5mm.
"""
from __future__ import annotations
import json
import logging
import math
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Callable, Iterable, Sequence
logger = logging.getLogger(__name__)
BASE_URL = "https://map.wamis.go.kr"
CONTOUR_PATH = "/data/gis/contour"
#: 사이트가 제공하는 재현기간(년). 2026-08 실측 8종.
RETURN_PERIODS: tuple[int, ...] = (2, 10, 30, 50, 80, 100, 200, 500)
#: 사이트가 제공하는 지속시간 (파일명 표기, 분). 2026-08 실측 12종 — 1시간 미만 없음.
DURATIONS: tuple[tuple[str, int], ...] = (
("01hr", 60),
("02hr", 120),
("03hr", 180),
("04hr", 240),
("05hr", 300),
("06hr", 360),
("08hr", 480),
("09hr", 540),
("10hr", 600),
("12hr", 720),
("18hr", 1080),
("24hr", 1440),
)
REQUEST_TIMEOUT = 120.0
EARTH_RADIUS_M = 6378137.0
_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"Accept": "application/json, text/plain, */*",
}
# --------------------------------------------------------------------------
# 좌표·주소
# --------------------------------------------------------------------------
def to_web_mercator(lat: float, lon: float) -> tuple[float, float]:
"""위경도(WGS84) → 웹지도 좌표(EPSG:3857) 미터."""
if not -90.0 < lat < 90.0:
raise ValueError(f"위도 범위를 벗어났습니다: {lat}")
if not -180.0 <= lon <= 180.0:
raise ValueError(f"경도 범위를 벗어났습니다: {lon}")
x = EARTH_RADIUS_M * math.radians(lon)
y = EARTH_RADIUS_M * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
return x, y
def contour_url(return_period: int, duration_label: str, base_url: str = BASE_URL) -> str:
"""``100yr_01hr.json`` 형태의 주소. 재현기간은 3자리 0채움."""
return f"{base_url.rstrip('/')}{CONTOUR_PATH}/{return_period:03d}yr_{duration_label}.json"
def cache_filename(return_period: int, duration_label: str) -> str:
return f"{return_period:03d}yr_{duration_label}.json"
# --------------------------------------------------------------------------
# 등우선 읽기·내삽
# --------------------------------------------------------------------------
def parse_contours(payload: dict) -> list[tuple[float, list[tuple[float, float]]]]:
"""GeoJSON에서 ``(강우량mm, 좌표열)`` 목록을 뽑는다."""
if not isinstance(payload, dict) or "features" not in payload:
raise RuntimeError("등우선 자료 형식이 아닙니다 (features 없음)")
lines: list[tuple[float, list[tuple[float, float]]]] = []
for feature in payload.get("features") or []:
if not isinstance(feature, dict):
continue
props = feature.get("properties") or {}
elev = props.get("ELEV", props.get("elev"))
try:
level = float(elev)
except (TypeError, ValueError):
continue
geometry = feature.get("geometry") or {}
for coords in _iter_linestrings(geometry):
points = [
(float(p[0]), float(p[1]))
for p in coords
if isinstance(p, (list, tuple)) and len(p) >= 2
]
if len(points) >= 2:
lines.append((level, points))
if not lines:
raise RuntimeError("등우선을 하나도 읽지 못했습니다 (ELEV 값 확인 필요)")
return lines
def _iter_linestrings(geometry: dict) -> Iterable[list]:
kind = geometry.get("type")
coords = geometry.get("coordinates") or []
if kind == "LineString":
yield coords
elif kind == "MultiLineString":
yield from coords
elif kind == "Polygon":
yield from coords
elif kind == "MultiPolygon":
for polygon in coords:
yield from polygon
#: 등우선 커버리지 밖 판정 여유(m). 서버 등우선은 제주도만 커버(2026-08-13 실측) —
#: 본토 좌표를 넣으면 수백 km 밖 보간으로 "조용히 틀린 값"이 나오므로 차단한다.
COVERAGE_MARGIN_M = 10_000.0
def value_at(
lines: Sequence[tuple[float, Sequence[tuple[float, float]]]], x: float, y: float
) -> float:
"""등우선에서 지점 ``(x, y)``의 확률강우량(mm) — 거리비례 내부삽입.
지점이 등우선 범위(+여유 30km) 밖이면 RuntimeError — 커버리지 밖 외삽 금지.
"""
if not lines:
raise RuntimeError("등우선이 비어 있습니다")
xs = [p[0] for _elev, points in lines for p in points]
ys = [p[1] for _elev, points in lines for p in points]
if not (
min(xs) - COVERAGE_MARGIN_M <= x <= max(xs) + COVERAGE_MARGIN_M
and min(ys) - COVERAGE_MARGIN_M <= y <= max(ys) + COVERAGE_MARGIN_M
):
raise RuntimeError("지점이 등우선 커버리지(제주도) 밖입니다 — 관측소 방식을 사용하세요")
distances = sorted((_distance_to_polyline(x, y, points), elev) for elev, points in lines)
d1, e1 = distances[0]
if d1 == 0.0:
return e1
for d2, e2 in distances[1:]:
if e2 != e1:
total = d1 + d2
return e1 if total == 0 else e1 + (e2 - e1) * (d1 / total)
return e1
def _distance_to_polyline(x: float, y: float, points: Sequence[tuple[float, float]]) -> float:
return min(_distance_to_segment(x, y, p0, p1) for p0, p1 in zip(points, points[1:]))
def _distance_to_segment(x, y, p0, p1) -> float:
x0, y0 = p0
x1, y1 = p1
dx, dy = x1 - x0, y1 - y0
if dx == 0.0 and dy == 0.0:
return math.hypot(x - x0, y - y0)
t = ((x - x0) * dx + (y - y0) * dy) / (dx * dx + dy * dy)
t = max(0.0, min(1.0, t))
return math.hypot(x - (x0 + t * dx), y - (y0 + t * dy))
# --------------------------------------------------------------------------
# 내려받기·전역 캐시
# --------------------------------------------------------------------------
def _download(url: str, timeout: float = REQUEST_TIMEOUT) -> bytes:
request = urllib.request.Request(url, headers=_HEADERS)
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
def ensure_contour_cache(
cache_dir: Path,
return_periods: Sequence[int] = RETURN_PERIODS,
durations: Sequence[tuple[str, int]] = DURATIONS,
base_url: str = BASE_URL,
progress: Callable[[str], None] | None = None,
) -> tuple[int, list[str]]:
"""전역 캐시 폴더에 없는 등우선 원본만 내려받는다.
Returns (캐시 확보 파일 수, 실패 메시지 목록). 실패는 비치명 — 있는 것만으로 진행.
"""
cache_dir = Path(cache_dir)
cache_dir.mkdir(parents=True, exist_ok=True)
ok, failures = 0, []
for period in return_periods:
for label, _minutes in durations:
path = cache_dir / cache_filename(period, label)
if path.exists() and path.stat().st_size > 0:
ok += 1
continue
url = contour_url(period, label, base_url)
if progress:
progress(f"등우선 내려받기 {period}년/{label}")
try:
raw = _download(url)
json.loads(raw) # JSON 검증 후에만 저장
tmp = path.with_suffix(".part")
tmp.write_bytes(raw)
tmp.replace(path)
ok += 1
except (urllib.error.URLError, OSError, ValueError) as exc:
failures.append(f"{period}년/{label}: {exc}")
return ok, failures
def _load_cached_lines(
cache_dir: Path, period: int, label: str
) -> list[tuple[float, list[tuple[float, float]]]] | None:
path = Path(cache_dir) / cache_filename(period, label)
if not path.exists():
return None
try:
return parse_contours(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError, RuntimeError) as exc:
logger.warning("등우선 캐시 파일을 읽지 못했습니다 (%s): %s", path, exc)
return None
# --------------------------------------------------------------------------
# General형 강우강도식 적합 — I(t) = a / (t^n + b) [t: 분, I: mm/hr]
# --------------------------------------------------------------------------
def fit_general_idf(points: Sequence[tuple[float, float]]) -> dict[str, float] | None:
"""``(지속시간 분, 강우강도 mm/hr)`` 점들로 General형 계수를 적합한다.
scipy가 있으면 curve_fit, 없거나 실패하면 b·n 격자탐색 + a 최소제곱 폴백.
1시간 미만(도달시간 5분~) 외삽의 근거식이므로 적합 잔차(rmse)를 함께 기록한다.
"""
pts = [(float(t), float(i)) for t, i in points if t > 0 and i > 0]
if len(pts) < 4:
return None
t = [p[0] for p in pts]
i = [p[1] for p in pts]
def rmse(a: float, b: float, n: float) -> float:
return math.sqrt(sum((a / (tk**n + b) - ik) ** 2 for tk, ik in zip(t, i)) / len(t))
best: tuple[float, float, float, float] | None = None
try:
from scipy.optimize import curve_fit # 프로젝트 GIS 스택에 이미 포함
popt, _ = curve_fit(
lambda tt, a, b, n: a / (tt**n + b),
t,
i,
p0=(3000.0, 15.0, 0.7),
bounds=((1.0, 0.0, 0.05), (1e6, 1e4, 1.5)),
maxfev=20000,
)
a, b, n = (float(v) for v in popt)
best = (rmse(a, b, n), a, b, n)
except Exception: # noqa: BLE001 — 폴백 격자탐색으로 계속
pass
if best is None:
for n in (x / 100.0 for x in range(30, 121, 5)):
for b in (0.0, 1.0, 2.0, 5.0, 10.0, 20.0, 40.0, 80.0):
denom = [tk**n + b for tk in t]
# 최소제곱: min Σ(a/d i)² → a = Σ(i/d) / Σ(1/d²)
a = sum(ik / dk for ik, dk in zip(i, denom)) / sum(1.0 / (dk * dk) for dk in denom)
err = rmse(a, b, n)
if best is None or err < best[0]:
best = (err, a, b, n)
if best is None:
return None
err, a, b, n = best
return {"a": round(a, 4), "b": round(b, 4), "n": round(n, 5), "rmse_mm_hr": round(err, 3)}
def idf_intensity(idf: dict[str, float], duration_min: float) -> float:
"""General형 계수로 지속시간(분)의 강우강도(mm/hr)를 계산한다."""
return float(idf["a"]) / (duration_min ** float(idf["n"]) + float(idf["b"]))
# --------------------------------------------------------------------------
# 프로젝트 강우량표 생성
# --------------------------------------------------------------------------
def build_rainfall_table(
lat: float,
lon: float,
cache_dir: Path,
design_return_period: int = 100,
return_periods: Sequence[int] = RETURN_PERIODS,
durations: Sequence[tuple[str, int]] = DURATIONS,
) -> dict[str, Any]:
"""전역 캐시된 등우선에서 지점 강우량표 + 설계빈도 IDF 적합계수를 만든다."""
x, y = to_web_mercator(lat, lon)
values: list[dict[str, float]] = []
failures: list[str] = []
design_points: list[tuple[float, float]] = []
for period in return_periods:
for label, minutes in durations:
lines = _load_cached_lines(cache_dir, period, label)
if lines is None:
failures.append(f"{period}년/{label}: 캐시 없음")
continue
try:
depth = value_at(lines, x, y)
except RuntimeError as exc:
failures.append(f"{period}년/{label}: {exc}")
continue
values.append(
{"return_period_yr": period, "duration_min": minutes, "depth_mm": round(depth, 2)}
)
if period == design_return_period:
design_points.append((minutes, depth / (minutes / 60.0)))
idf = fit_general_idf(design_points) if design_points else None
return {
"source": f"{BASE_URL} 확률강우량 등우선도 (거리비례 내부삽입)",
"lat": lat,
"lon": lon,
"design_return_period_yr": design_return_period,
"values": values,
# 설계빈도 IDF: I(t)=a/(t^n+b), t분 → mm/hr. 도달시간(≥5분) 강우강도 산출용.
"idf_design": idf,
"failures": failures,
}
def design_depth_mm(table: dict[str, Any], duration_min: int) -> float | None:
"""저장된 표에서 설계빈도·지속시간 강우량(mm)을 찾는다."""
period = table.get("design_return_period_yr")
for row in table.get("values") or []:
if row.get("return_period_yr") == period and row.get("duration_min") == duration_min:
return float(row["depth_mm"])
return None