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
+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