Files
Aislo/B06_Section/B06_Section_Engine_Culvert.py
T
eomsangdonandClaude Opus 5 aad78ec942 feat(B06): 배수관 세트 제원 TS 짝 신설 — 브라우저에서도 계산 가능
B05·B06 계산을 서버·브라우저 양쪽에서 돌 수 있게 하는 작업의 1단계.
배수관 세트(배관·세월교·BOX암거·물넘이포장·독립 기슭막이) 제원이 서버에만
있어 브라우저는 스펙 칸을 하나씩 옮겨 적는 부분 사본으로 버티고 있었다.

- common_util_culvert_sets.ts 신설 — B06_Section_Engine_Culvert.py 의 짝.
  매 상세 조회마다 도는 자리라 Node 왕복 대신 짝을 택함(CLAUDE.md 5장 ①).
- 거울 테스트 추가 — 다섯 시설을 태워 측점별 딕셔너리째 대조.
- 흩어진 임시 산식 3곳(관경÷1000 · BOX 구체 길이 · 날개벽 바닥판 연장)을
  이 한 벌로 바꿈.

검증: tsc --noEmit 통과, pytest 387 passed.

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

442 lines
21 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.
"""B06 배수관 세트(배관·기슭막이·보호공) 횡단도 정보.
B05 배수유역도가 찍은 관 지점(`pipe_points.json` = 배관 정본)을 읽어, 같은 누가거리의
횡단면에 그림을 그릴 수 있을 만큼의 제원을 `culvert` 키로 얹는다. 값을 새로 만들지 않고
**정본 + 레지스트리 기본값**만 조합한다 — 상수 사본을 두면 B05 폼과 갈라진다.
⚠ TS 짝: `common_util/common_util_culvert_sets.ts` (2026-09-06). 한쪽만 고치면 화면과
저장본이 갈린다 — 거울 테스트 `tmp/tests/test_b06_culvert_sets_mirror.py` 로 지킨다.
매 상세 조회마다 도는 자리라 Node 왕복 대신 짝을 골랐다(CLAUDE.md 5장 「계산 자리」 ①).
근거(지식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
import math
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_BOX,
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_PIPE,
PIPE_FACILITY_REVET,
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
# 세트 종류 -> 횡단 dict 키. 그림이 다른 시설끼리 소비처가 섞이지 않게 나눠 둔다.
_SECTION_KEYS = {"ford": "ford", "box": "box", "ford_pavement": "ford_pavement"}
# 구조물 종류별 **인접 측점 연동**(2026-08-25 사용자 확정). 이름을 넣으면 도로 방향
# 폭(`span_m`)의 절반만큼 앞뒤 측점에도 같은 스펙이 붙어 횡단도가 한 벌 더 그려진다.
# 세월교·BOX암거는 끔 — 소유 측점 한 곳에만 선다. 3D는 그 측점에서 `span_m` 전체를
# 스윕하므로 길이가 줄지 않는다(인접 측점 기준으로 자르지 않는다).
# 물넘이포장만 켠다 — 노면을 월류 폭만큼 파는 시설이라 **범위 안 모든 횡단도**에
# 파임이 그려져야 한다(2026-08-28 사용자 확정).
# ※ 기슭막이 연동은 이 경로가 아니다 — 프론트 `culvertOwnerFor`가 따로 판정한다.
_SPAN_LINKED_TYPES: frozenset[str] = frozenset({"ford_pavement"})
# ⚠ 교차 참조 ① 관 위 최소 토피(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 = "집수정"
# ⚠ 교차 참조 ③ 세월교 바닥판 두께(m). 교본 물넘이 물받이 "최소 30㎝ 이상"을 끌어왔다
# (2026-08-25 사용자 확정). 측벽 두께는 근거 없이 집수정 부재 두께를 승계한다.
FORD_SLAB_THICKNESS_M = 0.3
FORD_WALL_THICKNESS_M = 0.2
# 월류 폭 기본값(m) — B05 폼이 세월교에 채우는 값과 같다.
FORD_DEFAULT_WIDTH_M = 10.0
# 물넘이포장 월류 폭 기본값(m) — 같은 폼이 물넘이에 채우는 값(2026-08-18 사용자 확정,
# 지식DB 폭 수치 근거 없음).
FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0
# BOX암거 부재 두께(m) — 지식DB에 암거 부재 두께 기준이 없어 세월교 값을 승계한다
# (2026-08-25 사용자 확정). 측벽·상판·저판은 각각 벽 두께·바닥판 두께를 그대로 쓴다.
# ⚠ 교차 참조 ④ 암거 위 복토(m) — 별표2 교량·암거 "복토 시 흙 두께 50㎝ 이상".
BOX_COVER_M = 0.5
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=4)
def _registry_defaults(type_id: str) -> dict[str, Any]:
"""레지스트리 타입 옵션의 기본값 묶음.
저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는
기본값과 항상 같은 값이 그림에 들어간다.
"""
structure_type = structure_type_map().get(type_id)
if structure_type is None:
return {}
return {option.key: option.default for option in structure_type.options}
def _registry_pipe_defaults() -> dict[str, Any]:
"""레지스트리 `pipe` 타입 옵션 기본값."""
return _registry_defaults("pipe")
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:
# 집수정 종방향 길이(3D 예상형상용, 기본 2m — 2026-08-23 사용자).
# 전/후는 기슭막이와 같은 체계로 따로 지정한다(기본 각 1m — 2026-08-24 사용자).
spec["basin_length_m"] = _number(
options.get("inlet_basin_length_m"),
_number(defaults.get("inlet_basin_length_m"), 2.0),
)
spec["basin_before_m"] = _number(
options.get("inlet_basin_before_m"),
_number(defaults.get("inlet_basin_before_m"), None),
)
spec["basin_after_m"] = _number(
options.get("inlet_basin_after_m"),
_number(defaults.get("inlet_basin_after_m"), None),
)
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")
# 기준측점 전/후 몶(3D 예상형상 종방향 배치 — 2026-08-23 사용자).
# 저장분이 없으면 길이 절반씩.
before = _number(
options.get(f"{side}_revet_before_m"),
_number(defaults.get(f"{side}_revet_before_m"), None),
)
after = _number(
options.get(f"{side}_revet_after_m"),
_number(defaults.get(f"{side}_revet_after_m"), None),
)
spec.update(
{
"revet_form": str(form) if form else None,
"revet_height_m": height,
"revet_length_m": length,
"revet_before_m": before,
"revet_after_m": after,
"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 _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
"""독립 기슭막이 한쪽 벽 제원 — 배관 유입/유출 벽과 같은 스펙 모양으로 만든다.
옵션 키도 배관 벽과 **같은 한 벌**(`{role}_revet_*`)을 쓴다 — B06 조정창의 구간값
(길이·전/후)이 그 키로 저장되므로, 여기서 다른 키를 읽으면 조정값이 매번 되돌아온다
(2026-08-28 화면 실측: 조정창 길이 10.5가 저장돼도 10으로 리셋). 역할 키가 없으면
B05 폼이 한 벌로 담던 옛 키(`length_m`…)로 폴백한다.
"""
height = _number(values.get(f"{role}_revet_height_m"), _number(values.get("height_m"), None))
form = values.get(f"{role}_revet_form") or values.get("form")
spec: dict[str, Any] = {
"role": role,
"structure": "기슭막이",
"revet_form": str(form) if form else None,
"revet_height_m": height,
"revet_length_m": _number(
values.get(f"{role}_revet_length_m"), _number(values.get("length_m"), None)
),
"revet_before_m": _number(
values.get(f"{role}_revet_before_m"), _number(values.get("before_m"), None)
),
"revet_after_m": _number(
values.get(f"{role}_revet_after_m"), _number(values.get("after_m"), None)
),
"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 _revet_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""독립 기슭막이 1개소 세트 — 배관 세트와 같은 모양이되 **관을 숨긴다**(hidden_pipe).
관경은 표시·수량에서 빠지므로 토큰값(0.3)만 둔다 — 벽 높이는 형태(재질) 한계와
사용자 지정 높이가 정한다. `side`(양쪽/좌/우)로 어느 벽을 세울지 B06이 가리고,
`tiers`로 다단을 요청한다.
"""
values = dict(options or {})
return {
"type": "pipe",
"hidden_pipe": True,
"side": str(values.get("side") or "양쪽"),
"tiers": int(_number(values.get("tiers"), 1.0) or 1),
"pipe_kind": None,
"diameter_m": 0.3,
"min_cover_m": 0.0,
"inlet": _revet_side(values, "inlet"),
"outlet": _revet_side(values, "outlet"),
}
def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
"""날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량.
날개벽은 횡단면에 보이지 않지만(도로 진행 방향으로 벌어진다), 벌어진 만큼
바닥판이 계류 상·하류로 길어진다 — 연장량 = 길이 × cos(각도)로,
각도는 관축(계류 방향) 기준 벌어짐각이다 (2026-08-25 사용자 확정).
"""
prefix = f"wing_{side}"
install = values.get(prefix, defaults.get(prefix))
length = _number(
values.get(f"{prefix}_length_m"), _number(defaults.get(f"{prefix}_length_m"), 0.0)
)
angle = _number(
values.get(f"{prefix}_angle_deg"), _number(defaults.get(f"{prefix}_angle_deg"), 45.0)
)
height = _number(
values.get(f"{prefix}_height_m"), _number(defaults.get(f"{prefix}_height_m"), 0.0)
)
installed = str(install or "").strip() != "없음"
extend = (length or 0.0) * math.cos(math.radians(angle or 0.0)) if installed else 0.0
return {
"installed": installed,
"height_m": height,
"length_m": length,
"angle_deg": angle,
"slab_extend_m": round(max(extend, 0.0), 3),
}
def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장).
측벽은 ㄴ형 집수정 형상을 양쪽에 쓰고, 관 위는 성토(노체)로 채운다.
구체는 월류 폭만큼 도로 방향으로 이어지며 기준 측점 전후로 절반씩 걸친다.
"""
defaults = _registry_defaults("ford_bridge")
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")
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None))
depth = _number(values.get("ford_height_m"), None)
return {
"type": "ford",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"pipe_count": max(int(count), 1) if count else 1,
"span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M,
# 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다(단면은 월류부 가장 아래를 자른
# 자리다). 계획고를 통째로 내려 측벽·바닥판·절성토 면적이 함께 따라간다
# (2026-08-30 사용자 확정). 값이 없으면 0 = 내리지 않는다.
"overflow_depth_m": depth if depth and depth > 0 else 0.0,
"slab_thickness_m": FORD_SLAB_THICKNESS_M,
"wall_thickness_m": FORD_WALL_THICKNESS_M,
"min_cover_m": MIN_PIPE_COVER_M,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
}
def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""물넘이포장 1개소 — 노면을 월류 폭만큼, 노선 중심에서 월류 높이만큼 판 자리.
구조물을 얹는 것이 아니라 **계획고를 파낸 노면**이라 다른 세트와 형상이 다르다.
바닥은 유입(상류) 쪽이 높고 유출 쪽이 낮게 기운다 — 경사를 사용자가 비워 두면
화면이 노면 일반 기울기로 채운다(2026-08-28 사용자 확정). 파임 폭은 노견까지 전폭.
"""
defaults = _registry_defaults("ford_pavement")
values = dict(options or {})
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
depth = _number(values.get("ford_height_m"), None)
slope = _number(values.get("ford_slope_pct"), None)
return {
"type": "ford_pavement",
"span_m": width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M,
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
"depth_m": depth if depth and depth > 0 else None,
"slope_pct": slope,
}
def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""BOX암거 1개소의 세트 제원(구체 + 날개벽 연장).
구체 축은 계류 방향이라 횡단도에는 종단 절단면(상판·내공·저판)이 보인다.
상판 윗면은 노면에서 복토 0.5m 아래이고, 저판은 날개벽 투영만큼 계류 상·하류로
길어진다 — 연장 산식은 세월교와 같다.
"""
defaults = _registry_defaults("box_culvert")
values = dict(options or {})
inner_width = _number(values.get("body_width_m"), _number(defaults.get("body_width_m"), 2.0))
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
wall = FORD_WALL_THICKNESS_M
slab = FORD_SLAB_THICKNESS_M
return {
"type": "box",
"inner_width_m": inner_width or 2.0,
"inner_height_m": inner_height or 2.0,
"wall_thickness_m": wall,
"slab_thickness_m": slab,
"top_thickness_m": slab,
"cover_m": BOX_COVER_M,
# 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. 이 폭만큼 측점에 걸친다.
"span_m": (inner_width or 2.0) + 2 * wall,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
}
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")):
if point.facility == PIPE_FACILITY_PIPE:
spec = _culvert_set(point.options)
elif point.facility == PIPE_FACILITY_FORD_BRIDGE:
spec = _ford_set(point.options)
elif point.facility == PIPE_FACILITY_BOX:
spec = _box_set(point.options)
elif point.facility == PIPE_FACILITY_FORD_PAVEMENT:
# 구조물이 아니라 파인 노면이다 — 스펙 모양도 소비처도 다르다(2026-08-28).
spec = _ford_pavement_set(point.options)
elif point.facility == PIPE_FACILITY_REVET:
# 독립 기슭막이 — 배관 세트 모양이되 관을 숨긴다. `culvert` 키로 얹혀 배관
# 경로(컴퓨트·렌더·패널·연동·경사·3D·수량)를 그대로 탄다.
spec = _revet_set(point.options)
else:
continue
sets[round(float(point.chainage_m), 2)] = spec
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():
# 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다(`_SPAN_LINKED_TYPES`).
reach = _CHAINAGE_TOLERANCE_M
if spec.get("type") in _SPAN_LINKED_TYPES:
reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2
if abs(chainage - pipe_chainage) <= reach:
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
kind = spec.get("type")
section[_SECTION_KEYS.get(str(kind), "culvert")] = spec
attached += 1
break
return attached