"""배수유역 세부 설계 공용 엔진 (B04 관리자 화면 · B05 사용자 화면 공용). **격자 해석은 하지 않는다.** B04 배수유역 분석이 미리 돌려 저장한 결과를 읽어, 사용자가 실제로 손대는 두 가지만 처리한다(2026-07-31 사용자 지시). ⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충 ⑩ 측구 흐름으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로 그 셀의 유역 번호로 삼아 세부유역을 나눈다 ⑪ 사용자가 관을 옮기거나 추가하면 ⑩만 다시 돈다 — 격자 해석은 재사용한다 읽어 오는 것(`{배수유역 폴더}/`): · `03_road_routing.geojson` — 계획도로선 · 기본 배관 · 2차 전체 배수유역 · `03_road_routing.npz` — 셀 → 도로 셀 귀속, 유하장, 강도, 도로 셀 제원, 셀 표고 화살표(방향 코드)나 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 — 여기서는 필요 없고 파일만 무거워진다. **왜 common_util인가**: B04(관리자 트러블슈팅)와 B05(일반 사용자)가 같은 이름의 버튼을 누르면 같은 결과가 나와야 한다(2026-08-01 사용자 지시). 두 벌로 두면 언젠가 갈라진다. 읽는 폴더만 다르므로 폴더를 인자로 받고, B04/B05 각자의 어댑터가 경로를 정한다. 격자 산출물의 규격(`GridSpec`·`STAGES`·`polygonize_labels`)은 B04가 만든 것이므로 정의처인 B04 엔진을 그대로 참조한다(역방향 참조 없음). """ from __future__ import annotations import json import logging import math from dataclasses import dataclass, field from pathlib import Path from typing import Any import numpy as np from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import find_inflow_hotspots from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import polygon_parts, polygonize_labels from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_BOX, PIPE_FACILITY_FORD_BRIDGE, PIPE_FACILITY_PIPE, ) from common_util.common_util_route_geometry import ( RouteVertex, StructureCandidate, interpolate_vertex, is_uphill_at, ) from common_util.common_util_wamis_rainfall import idf_intensity from config.config_system import ( DRAINAGE_BOX_THRESHOLD_MM, DRAINAGE_BRIDGE_THRESHOLD_MM, DRAINAGE_DESIGN_FLOW_FACTOR, DRAINAGE_DITCH_SAMPLE_M, DRAINAGE_FLOW_AREA_RATIO, DRAINAGE_MANNING_N, DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M, DRAINAGE_PIPE_SLOPE_DEG, DRAINAGE_RAINFALL_FILENAME, DRAINAGE_RECOMMEND_DIAMETERS_MM, DRAINAGE_RUNOFF_COEFFICIENT, DRAINAGE_TC_MIN_MINUTES, DRAINAGE_VELOCITY_MAX_MS, DRAINAGE_VELOCITY_MIN_MS, PIPE_DEFAULT_DIAMETER_MM, ) logger = logging.getLogger(__name__) # 마지막으로 읽은 격자 산출물 — {npz 경로: (파일 자국, 읽은 결과)}. `read_road_routing` 참조. _routing_cache: dict[str, tuple[tuple[tuple[float, int], ...], "RoadRouting"]] = {} # 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조. _SCORE_WEIGHT_STRENGTH = 0.7 _SCORE_WEIGHT_SAG = 0.3 # 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다. _SCORE_FILL_PENALTY = 0.5 @dataclass class DrainageDetail: """세부 설계 산출물 — 화면에 그릴 기하와 세부유역.""" route_lonlat: list[list[float]] = field(default_factory=list) basin_lonlat: list[list[float]] = field(default_factory=list) pipes: list[StructureCandidate] = field(default_factory=list) basins: list[WatershedBasin] = field(default_factory=list) grid_cell_m: float = 1.0 # B04가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — 여기서 다시 계산하지 않는다. flow_arrows: list[list[Any]] = field(default_factory=list) arrow_spacing_m: float = 0.0 # 유역 안쪽 상류 세류망(WGS84 lon/lat 조각들). 화면 강조 표시용 — 계산에는 쓰지 않는다. upstream_lonlat: list[list[list[float]]] = field(default_factory=list) # 도로 1m 구간별 유입 면적(㎡) — [누가거리, 면적]. 계획선을 색으로 칠하는 데 쓴다. strength_profile: list[list[float]] = field(default_factory=list) # 유입 집중점 — [누가거리, 유입면적, 구역번호, 구역 내 순위]. 관 자리를 판단하는 근거. inflow_hotspots: list[list[float]] = field(default_factory=list) @dataclass class WatershedBasin: """관 하나가 받는 세부 배수유역.""" index: int chainage_m: float outlet_x: float outlet_y: float # 유역 경계 — 조각마다 [외곽 링, 구멍 링...]. 도넛(아래 유역이 위 유역을 감싼 경우)과 # 떨어진 조각을 그대로 싣는다. 단일 링만 쓰던 시절에는 이 둘이 소실돼 화면에서 중첩· # 빈공간으로 보였다(2026-09-03). boundary_parts: list[list[list[tuple[float, float]]]] = field(default_factory=list) area_m2: float = 0.0 relief_m: float = 0.0 flow_length_m: float = 0.0 pipe_diameter_mm: float | None = None # 유효직경 산출 근거(합리식) — 도달시간·설계강우강도·설계유량. 미산출이면 None. tc_minutes: float | None = None intensity_mm_hr: float | None = None design_flow_m3s: float | None = None # 유효직경이 관 최대 규격을 넘는 계류 유역 — 관이 아니라 세월교·물넘이·교량 대상 # (임도설치규정 제12조). 화면은 "세월교 검토"로 표기하고 관경 자동 지정을 하지 않는다. bridge_required: bool = False # 필요 통수단면적(㎡) = 설계유량 / 유속. 물넘이·세월교 개략 단면의 출발값이다. required_area_m2: float | None = None # 유효직경으로 고른 추천 구조물·관경 (2026-08-17 사용자 확정: 유량 근거만). # 배관이면 레지스트리 선택지로 스냅한 관경이 붙고, BOX암거·세월교면 None이다. recommended_facility: str = "pipe" recommended_diameter_mm: int | None = None @property def boundary_xy(self) -> list[tuple[float, float]]: """가장 넓은 조각의 외곽 링 — 링 하나만 받는 옛 소비처를 위한 자리.""" return self.boundary_parts[0][0] if self.boundary_parts else [] @property def boundary_rings(self) -> list[list[tuple[float, float]]]: """조각 구분 없이 편 링 목록 — 캔버스는 even-odd로 한 번에 채운다.""" return [ring for part in self.boundary_parts for ring in part] @dataclass class RoadRouting: """B04가 남긴 배수유역 분석 결과 — 세부유역을 나누는 데 필요한 최소 묶음.""" spec: GridSpec # (R*C,) int32 — 셀이 물길을 따라 도달하는 도로 셀 슬롯(−1 = 미도달). road_slot: np.ndarray path_length: np.ndarray # (R*C,) float32 — 그 도로 셀까지 물길 길이(m) elevation: np.ndarray # (R*C,) float32 — 셀 표고(유역 낙차 계산용) road_cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스 road_chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m) strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수 # 화면에 그대로 그릴 기하(WGS84 lon/lat). route_lonlat: list[list[float]] = field(default_factory=list) basin_lonlat: list[list[float]] = field(default_factory=list) base_pipes: list[StructureCandidate] = field(default_factory=list) # 평균 흐름 화살표 — [x, y, 방위(도), 도로도달, 셀 수]. B04가 계산해 둔 그대로. flow_arrows: list[list[Any]] = field(default_factory=list) arrow_spacing_m: float = 0.0 @property def strength_curve(self) -> np.ndarray: """누가거리 1m 구간별 유입 면적(㎡) 곡선 — 관 보충 위치 점수의 근거.""" if self.road_chainage.size == 0: return np.zeros(1) bins = max(1, int(np.ceil(self.road_chainage.max())) + 1) index = np.clip(np.round(self.road_chainage).astype(np.int64), 0, bins - 1) weights = self.strength.astype(np.float64) * self.spec.cell_area_m2 return np.bincount(index, weights=weights, minlength=bins) def build_detail( directory: Path, vertices: list[RouteVertex], confirmed_chainages: list[float] | None = None, ) -> DrainageDetail | None: """B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다. `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다. """ routing = read_road_routing(directory) if routing is None or len(vertices) < 2: return None if confirmed_chainages: pipes = pipes_from_chainages(vertices, confirmed_chainages) else: # 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다. base = [ StructureCandidate( chainage_m=pipe.chainage_m, x=interpolate_vertex(vertices, pipe.chainage_m)[0], y=interpolate_vertex(vertices, pipe.chainage_m)[1], reason=pipe.reason, ) for pipe in routing.base_pipes ] pipes = place_pipes(vertices, base, routing.strength_curve) detail = DrainageDetail( route_lonlat=routing.route_lonlat, basin_lonlat=routing.basin_lonlat, pipes=pipes, grid_cell_m=routing.spec.cell_m, flow_arrows=routing.flow_arrows, arrow_spacing_m=routing.arrow_spacing_m, upstream_lonlat=read_upstream_lines(directory), strength_profile=build_strength_profile(routing), inflow_hotspots=[ [chainage, area, float(zone), float(rank)] for chainage, area, zone, rank in find_inflow_hotspots( routing.strength_curve, [pipe.chainage_m for pipe in routing.base_pipes], vertices[-1].chainage_m, ) ], ) if not pipes: return detail pipe_of_slot = assign_road_cells_to_pipes(vertices, pipes, routing.road_chainage) detail.basins = assemble_basins(routing, pipes, pipe_of_slot, load_rainfall_idf(directory)) logger.info( "배수유역: 세부 설계 — 관 %d개(기본 %d + 보충 %d), 세부유역 %d개", len(pipes), sum(1 for pipe in pipes if pipe.reason != "spacing"), sum(1 for pipe in pipes if pipe.reason == "spacing"), len(detail.basins), ) return detail def read_road_routing(directory: Path) -> RoadRouting | None: """`03_road_routing` 산출물을 읽는다. 없으면 None. 관을 하나 옮길 때마다 같은 파일(3.1MB)을 다시 읽어 45ms 를 썼다(2026-09-06 실측). 격자는 B04 분석이 다시 돌 때만 바뀌므로 **파일이 그대로면 앞서 읽은 것을 그대로 쓴다** — 파일 자국(수정시각·크기)이 열쇠라 분석이 다시 돌면 저절로 새로 읽는다. """ prefix = STAGES["road_routing"] array_path = directory / f"{prefix}_road_routing.npz" if not array_path.exists(): logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path) return None cached = _routing_cache.get(str(array_path)) stamp = _file_stamp(array_path, directory / f"{prefix}_road_routing.geojson") if cached is not None and cached[0] == stamp: return cached[1] try: with np.load(array_path, allow_pickle=False) as data: spec = GridSpec( x_min=float(data["x_min"]), y_max=float(data["y_max"]), cell_m=float(data["cell_m"]), n_rows=int(data["n_rows"]), n_cols=int(data["n_cols"]), ) routing = RoadRouting( spec=spec, road_slot=data["road_slot"].reshape(-1), path_length=data["path_length"].reshape(-1), elevation=data["elevation"].reshape(-1), road_cell_index=data["road_cell_index"], road_chainage=data["road_chainage"], strength=data["strength"], ) except (OSError, KeyError, ValueError): logger.warning("배수유역: B04 분석 결과를 읽지 못했습니다 (%s).", array_path) return None _read_geometry(directory / f"{prefix}_road_routing.geojson", routing) logger.info( "배수유역: B04 결과 로드 — 격자 %d×%d, 도로 셀 %d, 기본 관 %d", spec.n_rows, spec.n_cols, routing.road_cell_index.size, len(routing.base_pipes), ) # 프로젝트를 오가도 자국이 다르면 새로 읽으므로 한 벌만 들고 있으면 충분하다. _routing_cache.clear() _routing_cache[str(array_path)] = (stamp, routing) return routing def _file_stamp(*paths: Path) -> tuple[tuple[float, int], ...]: """파일들의 (수정시각, 크기) — 하나라도 바뀌면 값이 달라진다. 없는 파일은 (0, 0).""" marks = [] for path in paths: try: info = path.stat() marks.append((info.st_mtime, info.st_size)) except OSError: marks.append((0.0, 0)) return tuple(marks) def _read_geometry(path: Path, routing: RoadRouting) -> None: """계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다.""" if not path.exists(): logger.warning("배수유역: B04 기하 산출물이 없습니다 (%s).", path) return try: with path.open("r", encoding="utf-8") as file: document = json.load(file) except (OSError, json.JSONDecodeError): logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path) return routing.arrow_spacing_m = float( (document.get("properties") or {}).get("arrow_spacing_m") or 0.0 ) for feature in document.get("features", []): properties = feature.get("properties") or {} geometry = feature.get("geometry") or {} coordinates = geometry.get("coordinates") kind = properties.get("kind") if kind == "route" and geometry.get("type") == "LineString": routing.route_lonlat = coordinates elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates: routing.basin_lonlat = coordinates[0] elif kind == "flow_arrow" and geometry.get("type") == "Point": # 화면이 미터로 그리므로 속성의 x·y를 쓴다(기하는 저장 규약상 lon/lat). routing.flow_arrows.append( [ float(properties.get("x") or 0.0), float(properties.get("y") or 0.0), float(properties.get("azimuth_deg") or 0.0), bool(properties.get("reaches_road")), int(properties.get("cells") or 0), ] ) elif kind == "pipe" and geometry.get("type") == "Point": routing.base_pipes.append( StructureCandidate( chainage_m=float(properties.get("chainage_m") or 0.0), x=0.0, y=0.0, reason=str(properties.get("reason") or "stream"), ) ) def read_upstream_lines(directory: Path) -> list[list[list[float]]]: """`01_primary_region`에서 상류 세류망만 읽는다(화면 강조용). 유역 판정의 기준선이라 B04 오버레이에서도 같은 선을 굵게 그린다 — B05는 그 선을 그대로 받아 표시만 한다. """ path = directory / f"{STAGES['primary_region']}_primary_region.geojson" if not path.exists(): return [] try: with path.open("r", encoding="utf-8") as file: document = json.load(file) except (OSError, json.JSONDecodeError): logger.warning("배수유역: 상류 세류망을 읽지 못했습니다 (%s).", path) return [] lines: list[list[list[float]]] = [] for feature in document.get("features", []): properties = feature.get("properties") or {} geometry = feature.get("geometry") or {} if properties.get("kind") != "upstream": continue coordinates = geometry.get("coordinates") if geometry.get("type") == "LineString" and coordinates: lines.append(coordinates) elif geometry.get("type") == "MultiLineString" and coordinates: lines.extend(part for part in coordinates if part) return lines def build_strength_profile(routing: RoadRouting) -> list[list[float]]: """도로 1m 구간별 유입 면적(㎡) 곡선을 응답용으로 정리한다. 값이 0인 구간은 빼고 보낸다 — 노선이 길면 대부분이 0이라 그대로 보내면 응답만 커진다. 화면은 받은 구간만 색칠하고 나머지는 계획선 원래 색을 남긴다. """ curve = routing.strength_curve return [[float(index), float(value)] for index, value in enumerate(curve.tolist()) if value > 0] # ── ⑨ 관 최소 개수 보충 ───────────────────────────────────────────────────── def place_pipes( vertices: list[RouteVertex], base_pipes: list[StructureCandidate], strength_curve: np.ndarray, ) -> list[StructureCandidate]: """B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다. 기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다. """ total_length = vertices[-1].chainage_m base: list[StructureCandidate] = [] for candidate in sorted(base_pipes, key=lambda item: item.chainage_m): if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: continue base.append(candidate) filled: list[StructureCandidate] = [] previous = 0.0 for candidate in [*base, None]: boundary = candidate.chainage_m if candidate else total_length filled.extend(_fill_gap(vertices, strength_curve, previous, boundary)) if candidate: filled.append(candidate) previous = candidate.chainage_m else: previous = boundary filled.sort(key=lambda item: item.chainage_m) return filled def _fill_gap( vertices: list[RouteVertex], strength_curve: np.ndarray, start_m: float, end_m: float, ) -> list[StructureCandidate]: """[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다. 필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로 허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고 종단이 낮은 지점으로 붙는다. """ span = end_m - start_m if span <= DRAINAGE_PIPE_MAX_SPACING_M: return [] count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1 if count <= 0: return [] spacing = span / (count + 1) slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0) placed: list[StructureCandidate] = [] for order in range(1, count + 1): nominal = start_m + spacing * order low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack) high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack) chosen = _best_position(vertices, strength_curve, low, high, nominal) x, y, _ = interpolate_vertex(vertices, chosen) placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing")) return placed def _best_position( vertices: list[RouteVertex], strength_curve: np.ndarray, low_m: float, high_m: float, fallback_m: float, ) -> float: """허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다.""" if high_m <= low_m: return fallback_m positions = np.arange(low_m, high_m + 1.0, 1.0) if positions.size == 0: return fallback_m index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1) strength = strength_curve[index] heights = np.array([interpolate_vertex(vertices, float(p))[2] for p in positions]) strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength) height_span = float(heights.max() - heights.min()) sag_score = ( (heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights) ) score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score for order, position in enumerate(positions): if not is_uphill_at(vertices, float(position)): score[order] *= _SCORE_FILL_PENALTY return float(positions[int(np.argmax(score))]) def pipes_from_chainages( vertices: list[RouteVertex], chainages: list[float] ) -> list[StructureCandidate]: """사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다. 노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼 나와 좌표와 표기가 어긋난다. """ total_length = vertices[-1].chainage_m clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages} pipes: list[StructureCandidate] = [] for value in sorted(clamped): x, y, _ = interpolate_vertex(vertices, value) pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed")) return pipes # ── ⑩ 측구 흐름으로 도로 셀 → 담당 관 ─────────────────────────────────────── def assign_road_cells_to_pipes( vertices: list[RouteVertex], pipes: list[StructureCandidate], road_chainage: np.ndarray, ) -> np.ndarray: """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. 노면 물은 측구를 타고 종단 내리막으로 흐르므로 종단 계획선을 1차원 지형으로 본다. 1차원에서는 물이 **마루(구간 최고점)를 넘지 못한다** — 이웃한 두 관 사이의 최고점이 곧 분수령이고, 그 왼쪽은 앞 관이, 오른쪽은 뒤 관이 받는다. 첫 관 앞과 마지막 관 뒤는 그 관이 받는다. 옛 방식(한 칸 이웃만 보는 국소 하강 + 관 없는 저점은 최근접 관)은 계획고의 미세 요철에 걸려 멈췄다. 용화 실측: 측점 2,138개 중 1,898개(88.8%)가 저점에 갇혀 흐름이 아니라 **누가거리 최근접**으로 배정됐고, 그 결과 도로 셀 43.2%가 자기보다 높은 관에 배정됐다(최대 6.82m 오르막). 마루 기준은 미세 요철을 타지 않으므로 오르막 배정이 구조적으로 생기지 않는다(2026-09-03 사용자 확정). """ total_length = vertices[-1].chainage_m step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) stations = np.arange(0.0, total_length + step, step) pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) if pipe_chainages.size == 0: return np.full(road_chainage.size, -1, dtype=np.int32) heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations]) # 관 순서는 호출자가 준 그대로 돌려줘야 한다 — 누가거리로 정렬해 풀고 끝에 되돌린다. order = np.argsort(pipe_chainages, kind="stable") pipe_station = np.clip( np.round(pipe_chainages[order] / step).astype(np.int64), 0, stations.size - 1 ) owner = np.full(stations.size, pipe_station.size - 1, dtype=np.int64) # 마지막 관 뒤 owner[: pipe_station[0] + 1] = 0 # 첫 관 앞 for index in range(pipe_station.size - 1): left = pipe_station[index] right = pipe_station[index + 1] if right <= left: continue ridge = left + int(np.argmax(heights[left : right + 1])) owner[left : ridge + 1] = index owner[ridge + 1 : right + 1] = index + 1 return order[owner][slot_station].astype(np.int32) # ── ⑩ 세부유역 조립 ──────────────────────────────────────────────────────── def assemble_basins( routing: RoadRouting, pipes: list[StructureCandidate], pipe_of_slot: np.ndarray, idf: dict[str, float] | None = None, ) -> list[WatershedBasin]: """셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다.""" spec = routing.spec labels = np.full(spec.size, -1, dtype=np.int32) reached = routing.road_slot >= 0 labels[reached] = pipe_of_slot[routing.road_slot[reached]] # 최소면적 필터를 끈다 — 그 필터가 곧 빈공간이었다. 떨어진 조각을 100㎡ 미만이라고 # 버리면 유역 면적과 그림이 어긋난다(실측: 용화 5.76%→1.86%, S자 3.56%→1.42%, # 조각 유역 0→2·0→3 복원). 링 목록이 조각을 싣게 된 뒤로는 버릴 이유가 없고, 좌표점은 # 583→622·806→829로 거의 늘지 않는다(2026-09-03). 남은 오차는 simplify(2.0m) 몫. polygons = polygonize_labels(spec, labels, min_area_m2=0.0) cell_area = spec.cell_area_m2 basins: list[WatershedBasin] = [] for order, pipe in enumerate(pipes): member = labels == order count = int(member.sum()) if count == 0: continue geometry = polygons.get(order) elevations = routing.elevation[member] highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0 outlet_z = _outlet_elevation(routing, order, pipe_of_slot) area = count * cell_area relief = max(0.0, highest - outlet_z) flow_length = float(routing.path_length[member].max()) sizing = size_pipe(area, relief, flow_length, idf) facility, recommended = recommend_structure(sizing["diameter_mm"] if sizing else None) basins.append( WatershedBasin( index=len(basins) + 1, chainage_m=pipe.chainage_m, outlet_x=pipe.x, outlet_y=pipe.y, boundary_parts=polygon_parts(geometry) if geometry is not None else [], area_m2=area, relief_m=relief, flow_length_m=flow_length, pipe_diameter_mm=sizing["diameter_mm"] if sizing else None, tc_minutes=sizing["tc_minutes"] if sizing else None, intensity_mm_hr=sizing["intensity_mm_hr"] if sizing else None, design_flow_m3s=sizing["design_flow_m3s"] if sizing else None, bridge_required=bool( sizing and sizing["diameter_mm"] > DRAINAGE_BRIDGE_THRESHOLD_MM ), required_area_m2=sizing["required_area_m2"] if sizing else None, recommended_facility=facility, recommended_diameter_mm=recommended, ) ) return basins def _outlet_elevation(routing: RoadRouting, pipe_order: int, pipe_of_slot: np.ndarray) -> float: """관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고.""" slots = np.flatnonzero(pipe_of_slot == pipe_order) if slots.size == 0: return 0.0 elevations = routing.elevation[routing.road_cell_index[slots]] finite = elevations[np.isfinite(elevations)] return float(finite.min()) if finite.size else 0.0 def load_rainfall_idf(directory: Path) -> dict[str, float] | None: """배수유역 폴더의 rainfall_table.json에서 설계빈도 IDF 적합계수를 읽는다. 파일은 B04 전처리(`/drainage/rainfall`)가 만든다. 없으면 None — 유효직경은 "미정"으로 남고, 강우량표가 생기는 순간 다음 세부유역 계산부터 채워진다. """ path = Path(directory) / DRAINAGE_RAINFALL_FILENAME if not path.exists(): return None try: table = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): logger.warning("강우량표를 읽지 못했습니다 (%s) — 유효직경 미산출.", path) return None idf = table.get("idf_design") if not isinstance(idf, dict) or not all(k in idf for k in ("a", "b", "n")): return None return idf def size_pipe( area_m2: float, relief_m: float, flow_length_m: float, idf: dict[str, float] | None, ) -> dict[str, float] | None: """유역 제원 + 설계빈도 IDF로 배수 유효직경(mm)과 산출 근거를 계산한다. 시행규칙 별표2 (가) 1호 방법: 100년빈도 확률강우량 + 홍수도달시간 → 합리식. 전 과정·계수 근거: docs/raw/guidelines/2026-08-05_임도_배수설계_규정_조사.md 4·6절. ① 도달시간 tc = 0.0663·L^0.77·(H/L)^-0.385 [Kirpich, hr] — 하한 5분 ② 강우강도 I = General형 적합식 I(tc) [mm/hr] ③ 합리식 Q = (1/3.6)·C·I·A[km²], 설계유량 = 2.0·Q (별표2) ④ 유속 V = Manning(경사 10도 고정, n=0.024) → 0.8~3.0m/s 클램프 ⑤ 통수단면 70%만 유효 → D = √(4·(Q설계/V)/(0.7π)) """ if idf is None or area_m2 <= 0: return None length_km = max(flow_length_m, 1.0) / 1000.0 slope = max(relief_m, 0.1) / max(flow_length_m, 1.0) tc_hr = 0.0663 * (length_km**0.77) * (slope**-0.385) tc_min = max(DRAINAGE_TC_MIN_MINUTES, tc_hr * 60.0) intensity = idf_intensity(idf, tc_min) flow = DRAINAGE_RUNOFF_COEFFICIENT * intensity * (area_m2 / 1e6) / 3.6 design_flow = DRAINAGE_DESIGN_FLOW_FACTOR * flow if design_flow <= 0: return None pipe_slope = math.tan(math.radians(DRAINAGE_PIPE_SLOPE_DEG)) diameter = 0.5 for _ in range(20): velocity = ( (1.0 / DRAINAGE_MANNING_N) * (diameter / 4.0) ** (2.0 / 3.0) * math.sqrt(pipe_slope) ) velocity = min(DRAINAGE_VELOCITY_MAX_MS, max(DRAINAGE_VELOCITY_MIN_MS, velocity)) required_area = design_flow / velocity updated = math.sqrt(4.0 * required_area / (DRAINAGE_FLOW_AREA_RATIO * math.pi)) if abs(updated - diameter) < 1e-4: diameter = updated break diameter = updated return { "tc_minutes": round(tc_min, 1), "intensity_mm_hr": round(intensity, 1), "design_flow_m3s": round(design_flow, 4), "diameter_mm": round(diameter * 1000.0, 1), # 물넘이·세월교는 관이 아니라 개수로라 직경 대신 이 단면적에서 출발한다. "required_area_m2": round(required_area, 4), } def recommend_structure(diameter_mm: float | None) -> tuple[str, int | None]: """유효직경으로 추천 구조물과 관경을 고른다 (2026-08-17 사용자 확정: 유량 근거만). 지식DB 선정 트리(개거_세월시설 §5·횡단배수관_암거 §5)에서 유량 조건만 취했다 — 계곡 횡단경사·하천 차수는 지형 계산이 필요해 화면이 "현장 확인"으로 안내한다. D ≤ 1,500㎜ 배관 (레지스트리 선택지로 스냅, 하한 800㎜) 1,500 < D ≤ 2,000 BOX암거 후보 D > 2,000㎜ 세월교·물넘이 검토 (관 최대 규격 초과) 유효직경을 못 구한 유역(강우량표 없음)은 배관·관경 미정으로 둔다. """ if diameter_mm is None: return PIPE_FACILITY_PIPE, None if diameter_mm > DRAINAGE_BRIDGE_THRESHOLD_MM: return PIPE_FACILITY_FORD_BRIDGE, None if diameter_mm > DRAINAGE_BOX_THRESHOLD_MM: return PIPE_FACILITY_BOX, None # 별표2 (나) 예외 하한 800㎜ — 계산값이 더 작아도 그 아래로는 내리지 않는다. need = max(float(PIPE_DEFAULT_DIAMETER_MM), diameter_mm) size = next( (value for value in DRAINAGE_RECOMMEND_DIAMETERS_MM if value >= need), DRAINAGE_RECOMMEND_DIAMETERS_MM[-1], ) return PIPE_FACILITY_PIPE, int(size) def estimate_pipe_diameter_mm( area_m2: float, relief_m: float, flow_length_m: float, idf: dict[str, float] | None = None, ) -> float | None: """유역 제원으로 배수 유효직경(mm)만 돌려주는 축약형 — 상세는 size_pipe().""" sizing = size_pipe(area_m2, relief_m, flow_length_m, idf) return sizing["diameter_mm"] if sizing else None