Files
Aislo/common_util/common_util_drainage_detail.py
T
eomsangdonandClaude Opus 5 8393dfd619 fix(B04): 세부유역 최소면적 필터 제거 — 작은 유역이 통째로 안 그려지던 문제
100㎡ 미만 유역은 `polygonize_labels` 의 최소면적 필터에 걸려 폴리곤이 아예 만들어지지
않았다. 유역 목록에는 있는데 지도에는 없는 상태 — 실측에서 용화 22개 중 1개(64㎡),
S자 48개 중 1개가 그랬다. 링 목록이 조각을 싣게 된 뒤로는 버릴 이유가 없다.

실측: 폴리곤 면적 오차(셀 면적 대비 평균) 용화 5.76%→1.86%, S자 3.56%→1.42%,
경계 없는 유역 1개→0개. 좌표점은 583→622 · 806→829 로 거의 그대로다.
1차 전체 배수유역 등 다른 호출부의 기본값(100㎡)은 그대로 둔다.

S자 실사례 검증: 용화 지형 위에 스위치백 3구간 합성 노선(정점 439·연장 4,528m)으로
신규 프로젝트를 만들어 자동 체인까지 완주 — 관 48개·세부유역 48개, 경계 없는 유역 0개.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:13:00 +09:00

693 lines
31 KiB
Python
Raw 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.
"""배수유역 세부 설계 공용 엔진 (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__)
# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조.
_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."""
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
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),
)
return routing
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