refactor(배수유역): 347-2 세부 유역 엔진 700줄 — 관경 산정(강우표 · 합리식 · 추천 구조물)을 common_util_drainage_sizing 으로 나눔

- 이름은 common_util_drainage_detail 에서도 그대로 읽힘(옛 자리 import 유지) · 내용 고침 없음
- 시험 test_b05_drainage_recommend · test_96_b08_hydro_linked · test_342_3_* 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-16 11:33:06 +09:00
co-authored by Claude Opus 5.5
parent 99a8714160
commit c0ad3edc80
2 changed files with 150 additions and 127 deletions
+5 -127
View File
@@ -25,7 +25,6 @@ from __future__ import annotations
import json import json
import logging import logging
import math
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -36,10 +35,11 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import find_inflow_h
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES 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_Flow import polygon_parts, polygonize_labels
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
from common_util.common_util_drainage_pipes import ( from common_util.common_util_drainage_sizing import ( # noqa: F401 — 옛 자리에서 읽는 곳
PIPE_FACILITY_BOX, estimate_pipe_diameter_mm,
PIPE_FACILITY_FORD_BRIDGE, load_rainfall_idf,
PIPE_FACILITY_PIPE, recommend_structure,
size_pipe,
) )
from common_util.common_util_route_geometry import ( from common_util.common_util_route_geometry import (
RouteVertex, RouteVertex,
@@ -48,24 +48,11 @@ from common_util.common_util_route_geometry import (
is_uphill_at, is_uphill_at,
) )
from common_util.common_util_structure_station import snap_candidates from common_util.common_util_structure_station import snap_candidates
from common_util.common_util_wamis_rainfall import idf_intensity
from config.config_system import ( from config.config_system import (
DRAINAGE_BOX_THRESHOLD_MM,
DRAINAGE_BRIDGE_THRESHOLD_MM, DRAINAGE_BRIDGE_THRESHOLD_MM,
DRAINAGE_DESIGN_FLOW_FACTOR,
DRAINAGE_DITCH_SAMPLE_M, DRAINAGE_DITCH_SAMPLE_M,
DRAINAGE_FLOW_AREA_RATIO,
DRAINAGE_MANNING_N,
DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MAX_SPACING_M,
DRAINAGE_PIPE_MIN_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__) logger = logging.getLogger(__name__)
@@ -610,112 +597,3 @@ def _outlet_elevation(routing: RoadRouting, pipe_order: int, pipe_of_slot: np.nd
elevations = routing.elevation[routing.road_cell_index[slots]] elevations = routing.elevation[routing.road_cell_index[slots]]
finite = elevations[np.isfinite(elevations)] finite = elevations[np.isfinite(elevations)]
return float(finite.min()) if finite.size else 0.0 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
+145
View File
@@ -0,0 +1,145 @@
"""배수 관경 산정 — 강우량표 읽기 · 합리식 유효직경 · 추천 구조물.
세부 유역 엔진(`common_util_drainage_detail` · 700줄로 나눔)이 유역 제원으로 부름 ·
이름은 그 파일에서도 그대로 읽힘.
"""
from __future__ import annotations
import json
import logging
import math
from pathlib import Path
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_PIPE,
)
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_FLOW_AREA_RATIO,
DRAINAGE_MANNING_N,
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__)
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