"""D 노선분석 지도 산사태취약지역 겹(PLAN 584-3 · 요구 443). - 출처 = 시도 표(data.go.kr · 이용허락범위 제한 없음) 한 벌 = `resources/data_landslide_zones/ <시도코드>_<시도이름>.csv`(UTF-8 · 머리 `#` 줄 = 이름 · 받은 날 · 기준일 · 출처) · 해마다 파일만 바꿈 - 시도마다 열 이름이 다름 → `COLUMNS` 별칭으로 읽음 · 위경도(도 · 분 · 초) 없는 시도(경남) = 지번만 - 위경도 있는 줄 = 받은 범위 안 점 · 지번(시군구 · 읍면동 · 리 · 산 · 본번 · 부번)이 지적 필지와 맞으면 필지 테두리(지정 넓이 ≪ 필지 넓이 — 칠하지 않음) - 사업지 시도 = 지적 필지 `sido_cd` · 표 없는 시도 = `missing` - 저장 = 파생 저장본(열쇠 = 표 지문 · 지적 지문 · 범위) 계산 자리 = ③ 서버 단독. """ from __future__ import annotations import csv import json import re from functools import lru_cache from pathlib import Path from typing import Any from common_util.common_util_derived_store import ( derived_key, file_stamp, read_derived, write_derived, ) Box = tuple[float, float, float, float] DATA_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_landslide_zones" ZONE_KIND = "plan_landslide_zone" ZONE_VERSION = 1 # 열쇠 → 시도 표 열 이름 별칭(경북 · 충남 · 광주 · 경남) COLUMNS: dict[str, tuple[str, ...]] = { "sgg": ("시군구", "조사지(시군구)"), "emd": ("읍면동", "조사지(동)"), "ri": ("리",), "jibun": ("지번",), "lat_d": ("(위도)도", "위도(도)"), "lat_m": ("(위도)분", "위도(분)"), "lat_s": ("(위도)초", "위도(초)"), "lon_d": ("(경도)도", "경도(도)"), "lon_m": ("(경도)분", "경도(분)"), "lon_s": ("(경도)초", "경도(초)"), "kind": ("취약지역유형",), "area": ("지정면적(제곱미터)",), } JIBUN = re.compile(r"^\s*(산)?\s*(\d+)(?:\s*-\s*(\d+))?") ParcelKey = tuple[str, str, str, bool, int, int] def jibun_key(text: str) -> tuple[bool, int, int] | None: """「산42임」 · 「산77-1번지」 · 「130-61임, 파도리 산65」 → (산, 본번, 부번) · 첫 지번만 · 못 읽으면 None.""" found = JIBUN.match(str(text or "")) if not found: return None return bool(found.group(1)), int(found.group(2)), int(found.group(3) or 0) def _name(text: Any) -> str: return re.sub(r"\s+", "", str(text or "")) def _number(text: Any) -> float | None: try: return float(str(text).strip()) except (TypeError, ValueError): return None def _dms(row: dict[str, str], prefix: str) -> float | None: parts = [_number(row.get(f"{prefix}_{p}")) for p in "dms"] if parts[0] is None: return None d, m, s = (v or 0.0 for v in parts) return d + m / 60 + s / 3600 def read_table(path: Path) -> tuple[dict[str, str], list[dict[str, Any]]]: """시도 표 → (머리 {이름 · 받은 날 · 기준일 · 출처}, 줄 [{sgg, emd, ri, jibun, key, lon, lat, kind, area_m2}]) — 위경도 없거나 못 읽으면 lon · lat = None.""" return _read_table(str(path), file_stamp(path)) @lru_cache(maxsize=8) def _read_table(path: str, _stamp: str | None) -> tuple[dict[str, str], list[dict[str, Any]]]: head: dict[str, str] = {} body: list[str] = [] for line in Path(path).read_text(encoding="utf-8-sig").splitlines(): if line.startswith("#"): name, _, value = line[1:].partition(":") head[name.strip()] = value.strip() elif line.strip(): body.append(line) reader = csv.DictReader(body) fields = reader.fieldnames or [] alias = { key: next((f for f in fields if f.strip() in names), None) for key, names in COLUMNS.items() } rows: list[dict[str, Any]] = [] for raw in reader: row = {key: (raw.get(col) or "").strip() if col else "" for key, col in alias.items()} lat, lon = _dms(row, "lat"), _dms(row, "lon") rows.append( { "sgg": row["sgg"], "emd": row["emd"], "ri": row["ri"], "jibun": row["jibun"], "key": jibun_key(row["jibun"]), "lon": lon if lat is not None else None, "lat": lat if lon is not None else None, "kind": row["kind"] or None, "area_m2": _number(row["area"]), } ) return head, rows def table_of(code: str) -> Path | None: """시도 코드(두 자리) → 표 파일 · 없으면 None.""" return next(iter(sorted(DATA_DIR.glob(f"{code}_*.csv"))), None) def parcel_key(props: dict[str, Any]) -> ParcelKey | None: """지적 필지 속성 → 맞춤 열쇠(임야대장 `gbn_cd` 2 = 산).""" try: main, sub = int(props.get("mnnm")), int(props.get("slno") or 0) except (TypeError, ValueError): return None san = str(props.get("gbn_cd")) == "2" return ( _name(props.get("sgg_nm")), _name(props.get("emd_nm")), _name(props.get("ri_nm")), san, main, sub, ) def row_key(row: dict[str, Any]) -> ParcelKey | None: if row["key"] is None: return None return (_name(row["sgg"]), _name(row["emd"]), _name(row["ri"]), *row["key"]) def match_zones( parcels: list[dict[str, Any]], tables: dict[str, Path | None], box: Box, epsg: int ) -> dict[str, Any]: """지적 필지 · 시도 표 → 받은 범위 안 점 · 지번 맞는 필지 테두리 · 시도별 표 · 없는 시도.""" from pyproj import Transformer from shapely.geometry import Point from shapely.geometry import box as rect to_m = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True).transform frame = rect(*box) index: dict[ParcelKey, list[dict[str, Any]]] = {} for parcel in parcels: key = parcel_key(parcel.get("properties") or {}) if key is not None: index.setdefault(key, []).append(parcel) names = { str((p.get("properties") or {}).get("sido_cd")): str( (p.get("properties") or {}).get("sido_nm") ) for p in parcels } points: list[dict[str, Any]] = [] hits: dict[int, dict[str, Any]] = {} sidos: list[dict[str, Any]] = [] missing: list[str] = [] for code, path in sorted(tables.items()): if path is None: missing.append(names.get(code) or code) continue head, rows = read_table(path) located = 0 for row in rows: place = f"{row['sgg']} {row['emd']} {row['ri']} {row['jibun']}".replace( " ", " " ).strip() if row["lon"] is not None: located += 1 if frame.contains(Point(to_m(row["lon"], row["lat"]))): points.append( { "lon": round(row["lon"], 7), "lat": round(row["lat"], 7), "kind": row["kind"], "area_m2": row["area_m2"], "place": place, } ) for parcel in index.get(row_key(row) or ("",), []): hit = hits.setdefault( id(parcel), {"parcel": parcel, "count": 0, "kinds": set(), "area_m2": 0.0} ) hit["count"] += 1 hit["area_m2"] += row["area_m2"] or 0.0 if row["kind"]: hit["kinds"].add(row["kind"]) sidos.append( { "code": code, "name": names.get(code) or path.stem.split("_", 1)[-1], "rows": len(rows), "located": located, "title": head.get("이름"), "basis": head.get("기준일"), "source": head.get("출처"), } ) features = [ { "type": "Feature", "geometry": hit["parcel"]["geometry"], "properties": { "jibun": (hit["parcel"].get("properties") or {}).get("jibun"), "count": hit["count"], "kinds": sorted(hit["kinds"]), "area_m2": round(hit["area_m2"], 1), }, } for hit in hits.values() ] return { "points": points, "parcels": {"type": "FeatureCollection", "features": features}, "sidos": sidos, "missing": missing, } def zone_layer(project_root: Path, cadastral: Path | None, box: Box, epsg: int) -> dict[str, Any]: """산사태취약지역 — 지적 필지 시도의 표로 맞춤 · 지적이 없으면(못 받음) 위경도 있는 표 모두 점만.""" parcels = [] if cadastral is not None: parcels = (json.loads(cadastral.read_text(encoding="utf-8")) or {}).get("features") or [] codes = sorted({str((p.get("properties") or {}).get("sido_cd")) for p in parcels} - {"None"}) if not codes: codes = sorted(path.name.split("_", 1)[0] for path in DATA_DIR.glob("*.csv")) tables = {code: table_of(code) for code in codes} key = derived_key( ZONE_KIND, ZONE_VERSION, {code: file_stamp(path) for code, path in tables.items()}, file_stamp(cadastral), int(epsg), [round(v, 1) for v in box], ) saved = read_derived(project_root, ZONE_KIND, key) if saved is not None: return saved["value"] value = match_zones(parcels, tables, box, epsg) value["cadastral"] = cadastral is not None write_derived(project_root, ZONE_KIND, key, value) return value