"""B06 배수관 세트(배관·기슭막이·보호공) 횡단도 정보. B05 배수유역도가 찍은 관 지점(`pipe_points.json` = 배관 정본)을 읽어, 같은 누가거리의 횡단면에 그림을 그릴 수 있을 만큼의 제원을 `culvert` 키로 얹는다. 값을 새로 만들지 않고 **정본 + 레지스트리 기본값**만 조합한다 — 상수 사본을 두면 B05 폼과 갈라진다. 근거(지식DB): · 관은 **지반선상 매설** — 뜨게 매설은 부적합 (교본 8장, 그림 8-3·8-9) · 관 지름 1,000㎜ 이상(현지여건 800㎜ 이상) — 별표2 Ⅰ.2.사.(2) · 유입구 = 계곡부 기슭막이 / 계통적 옆도랑은 집수정, 유출구 = 기슭막이 (교본 8장 3·4절) · 기슭막이 계획비탈 1:0.3~1:0.5 (사방기술교본), 돌쌓기 전면 1:0.3 (교본 7-3) · 유출구부터 원지반까지 도수로·물받이 설치 의무 — 별표2 Ⅰ.2.사.(2) ⚠ 아래 세 상수는 **임도 확정값이 아니다**. 지식DB에 임도 기준이 없어 교차 참조로 채택했고(2026-08-19 사용자 확정), 임도 기준이 확보되면 그 값으로 교체한다. """ from __future__ import annotations import json import logging from functools import lru_cache from pathlib import Path from typing import Any from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points from config.config_system import ( DRAINAGE_CACHE_DIRNAME, DRAINAGE_EDITS_DIRNAME, DRAINAGE_PIPE_POINTS_FILENAME, ) logger = logging.getLogger(__name__) # 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. _CHAINAGE_TOLERANCE_M = 0.02 # ⚠ 교차 참조 ① 관 위 최소 토피(m). 임도 배수관 토피 규정이 지식DB에 없어 별표2 # 교량·암거 "복토 시 흙 두께 50㎝ 이상"을 끌어왔다. B05 계획고 하향 차단 기준이다. MIN_PIPE_COVER_M = 0.5 # ⚠ 교차 참조 ② 보호공(물받이) 길이 = 낙차고(기슭막이 높이) × 배수. # 사방기술교본 바닥막이 물받이 "길이 = 낙차고의 2~3배" 중 2배 채택. APRON_LENGTH_FACTOR = 2.0 # 보호공 두께(m) — 사용자 확정 0.45 (2026-08-20, 실무 돌붙임 L3=45와 정합). # 사방교본 물받이 "두께 1m 내외"는 바닥막이용 교차 참조라 채택하지 않았다. APRON_THICKNESS_M = 0.45 # 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3) = 사방교본 계획비탈 # 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다. REVET_FACE_SLOPE = 0.3 # (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지 # 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다. # 유입구 구조 선택지 — 레지스트리 `pipe.inlet_type` choices와 같은 문자열이다. INLET_STRUCTURE_BASIN = "집수정" def pipe_points_file(project_root: Path) -> Path: """프로젝트 저장소 안의 관 지점 정본 경로.""" return ( project_root / "B04_PreProcess" / DRAINAGE_CACHE_DIRNAME / DRAINAGE_EDITS_DIRNAME / DRAINAGE_PIPE_POINTS_FILENAME ) @lru_cache(maxsize=1) def _registry_pipe_defaults() -> dict[str, Any]: """레지스트리 `pipe` 타입 옵션의 기본값 묶음. 저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는 기본값과 항상 같은 값이 그림에 들어간다. """ pipe_type = structure_type_map().get("pipe") if pipe_type is None: return {} return {option.key: option.default for option in pipe_type.options} def _number(value: Any, fallback: float | None) -> float | None: """숫자 옵션 하나를 float로 정리한다. 문자열 저장분(관경 "1000")도 받는다.""" if isinstance(value, bool): return fallback if isinstance(value, (int, float)): return float(value) if isinstance(value, str): try: return float(value.strip()) except ValueError: return fallback return fallback def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]: """유입("inlet")·유출("outlet") 한쪽의 부속 제원. 구조가 집수정이면 기슭막이·보호공을 만들지 않는다 — 집수정 단면은 후속 작업이라 화면은 라벨만 쓴다(2026-08-19 사용자 확정). """ structure = options.get(f"{side}_type") or defaults.get(f"{side}_type") or "기슭막이" spec: dict[str, Any] = {"role": side, "structure": str(structure)} if spec["structure"] == INLET_STRUCTURE_BASIN: return spec height = _number( options.get(f"{side}_revet_height_m"), _number(defaults.get(f"{side}_revet_height_m"), None), ) length = _number( options.get(f"{side}_revet_length_m"), _number(defaults.get(f"{side}_revet_length_m"), None), ) form = options.get(f"{side}_revet_form") or defaults.get(f"{side}_revet_form") spec.update( { "revet_form": str(form) if form else None, "revet_height_m": height, "revet_length_m": length, "face_slope": REVET_FACE_SLOPE, } ) # 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다. if height is not None and height > 0: spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3) spec["apron_thickness_m"] = APRON_THICKNESS_M return spec def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]: """관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공).""" defaults = _registry_pipe_defaults() values = dict(options or {}) diameter_mm = _number( values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0) ) kind = values.get("pipe_kind") or defaults.get("pipe_kind") return { "type": "pipe", "pipe_kind": str(kind) if kind else None, "diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3), "min_cover_m": MIN_PIPE_COVER_M, "inlet": _side_spec(values, defaults, "inlet"), "outlet": _side_spec(values, defaults, "outlet"), } def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]: """관 지점 정본을 읽어 누가거리별 세트 제원을 돌려준다. 노선 지문은 보지 않는다 — 횡단면 자체가 현 노선에서 만들어진 것이라, 지문이 어긋난 옛 관 지점은 매칭될 측점이 없어 자연히 걸러진다. 파일이 없거나 깨졌으면 빈 값. """ path = pipe_points_file(project_root) if not path.is_file(): return {} try: document = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): logger.warning("B06 배수관 세트: 관 지점 파일을 읽지 못했습니다 (%s)", path) return {} sets: dict[float, dict[str, Any]] = {} for point in parse_pipe_points(document.get("points")): # BOX암거·물넘이포장·세월교는 그림이 다르다 — 이번 범위는 배관뿐이다. if point.facility != PIPE_FACILITY_PIPE: continue sets[round(float(point.chainage_m), 2)] = _culvert_set(point.options) return sets def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: """배관이 놓인 측점의 횡단 dict에 `culvert` 키를 얹는다. 얹은 개수를 돌려준다.""" sets = load_culvert_sets(project_root) if not sets: return 0 attached = 0 for section in cross_sections: chainage = _number(section.get("chainage_m"), None) if chainage is None: continue for pipe_chainage, spec in sets.items(): if abs(chainage - pipe_chainage) <= _CHAINAGE_TOLERANCE_M: section["culvert"] = spec attached += 1 break return attached