feat(B05): 계획 종단선이 횡단배수 최소고를 자동 확보

계획선의 변화점(PVI)은 배수유역이 확정한 배관 배치 측점이다. 그 자리 계획고를
지반고와 같게 두면 관·구체가 들어갈 자리가 없어, 시설 제원만큼 들어 올린다.

- common_util_drainage_pipes: facility_clearance_m()/pipe_anchor_clearances() 신설
  (정본 산식) — 배수관 관경+토피 0.5, BOX암거 구체높이+토피 0.5,
  물넘이·세월교는 좌측 패널이 설계유량으로 산출한 월류 높이(ford_height_m)
- Engine_Sections: 관 지점에서 시설 여유를 함께 읽어(_load_pipe_anchors) 전달
- Engine_Grade_Profile: 앵커 목표 표고 = 지반고 + 시설 여유
- Profile_MinCover(화면 경고): 세월교·물넘이를 월류 높이 기준으로 추가.
  백엔드가 정본이고 이쪽은 편집 중 즉시 경고용 사본 — 값 일치는 테스트로 잠금

검증: 재생성한 계획선의 배관 측점 실측 — Ø1000 +1.50 / 세월교 +0.30 /
BOX +2.50 / Ø800 +1.30 (전부 요구 여유와 일치), 화면 경고 소거 확인.
pytest 10/10, tsc·ruff 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 14:12:38 +09:00
co-authored by Claude Opus 5
parent af9d2b5530
commit e1d0d6dda3
4 changed files with 108 additions and 17 deletions
@@ -119,6 +119,20 @@ def _profile_entry(
}
def _clearance_at(
clearances: dict[float, float], chainage_m: float, tolerance: float = 0.5
) -> float:
"""앵커 누가거리에 대응하는 최소 여유(m). 근처에 시설이 없으면 0."""
best = 0.0
closest = tolerance
for key, value in clearances.items():
gap = abs(key - chainage_m)
if gap <= closest:
closest = gap
best = value
return best
def design_pipe_anchored_profile(
longitudinal: dict[str, Any],
options: GradeDesignOptions,
@@ -126,6 +140,7 @@ def design_pipe_anchored_profile(
*,
station_interval_m: float | None = None,
edits: dict[str, Any] | None = None,
pipe_clearances: dict[float, float] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""배관 배치 측점을 변화점으로 삼는 1차 계획선.
@@ -182,8 +197,16 @@ def design_pipe_anchored_profile(
raise ValueError("계획선 변화점으로 쓸 배관 배치 측점이 없습니다.")
base_s = np.array([0.0, *anchors, total], dtype=np.float64)
# 목표: 배관 자리 계획고 = 지반고(지면선 교차점이 호 위). 시·종점만 오프셋을 얹는다.
# 목표: 배관 자리 계획고 = 지반고 + **시설 최소 여유**. 지반고에 딱 맞추면 관·구체가
# 들어갈 자리가 없다(2026-08-23 사용자 지시). 여유는 관경+토피/구체높이+토피/월류
# 높이로, 정본 산식은 `common_util_drainage_pipes.facility_clearance_m`이다.
# 시·종점만 오프셋을 얹는다.
target = np.interp(base_s, chainage, ground)
if pipe_clearances:
for index, value in enumerate(base_s):
clearance = _clearance_at(pipe_clearances, float(value))
if clearance > 0:
target[index] += clearance
target[0] = fixed[0]
target[-1] = fixed[1]
+21 -7
View File
@@ -20,7 +20,11 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from common_util.common_util_drainage_pipes import parse_pipe_points, route_signature
from common_util.common_util_drainage_pipes import (
parse_pipe_points,
pipe_anchor_clearances,
route_signature,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_geometry import RouteVertex
from common_util.common_util_surface_sampler import build_surface_sampler
@@ -89,7 +93,9 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
}
def _load_pipe_chainages(project_root: Path, polyline: list[list[float]]) -> list[float]:
def _load_pipe_anchors(
project_root: Path, polyline: list[list[float]]
) -> tuple[list[float], dict[float, float]]:
"""배수유역도가 확정한 배관 배치 측점(누가거리)을 읽는다.
관 지점 파일에는 저장 당시 노선 지문이 함께 있다 — 노선이 바뀌었으면 버린다
@@ -103,12 +109,12 @@ def _load_pipe_chainages(project_root: Path, polyline: list[list[float]]) -> lis
/ DRAINAGE_PIPE_POINTS_FILENAME
)
if not path.is_file():
return []
return [], {}
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("B05 계획선: 관 지점 파일을 읽지 못했습니다 (%s)", path)
return []
return [], {}
vertices = [
RouteVertex(
x=float(p[0]), y=float(p[1]), z=float(p[2]) if len(p) > 2 else 0.0, chainage_m=0.0
@@ -117,8 +123,12 @@ def _load_pipe_chainages(project_root: Path, polyline: list[list[float]]) -> lis
]
if str(document.get("route_signature") or "") != route_signature(vertices):
logger.info("B05 계획선: 노선이 바뀌어 저장된 관 지점을 쓰지 않습니다.")
return []
return [pipe.chainage_m for pipe in parse_pipe_points(document.get("points"))]
return [], {}
points = parse_pipe_points(document.get("points"))
# 시설 제원이 요구하는 최소 여유(관경+토피 등)를 함께 넘긴다 — 계획선이 그만큼
# 들려야 관·구체가 들어갈 자리가 생긴다(2026-08-23 사용자 지시).
clearances = {chainage: clearance for chainage, clearance in pipe_anchor_clearances(points)}
return [pipe.chainage_m for pipe in points], clearances
def _append_design_profiles(
@@ -126,6 +136,7 @@ def _append_design_profiles(
grade_options: GradeDesignOptions | None,
station_interval_m: float | None = None,
pipe_chainages: list[float] | None = None,
pipe_clearances: dict[float, float] | None = None,
) -> dict[str, Any] | None:
"""종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다.
@@ -147,6 +158,7 @@ def _append_design_profiles(
grade_options,
pipe_chainages,
station_interval_m=station_interval_m,
pipe_clearances=pipe_clearances,
)
except (ValueError, KeyError, ArithmeticError):
logger.exception("B05 배관 정착 계획선 산출 실패 — 직선 분할 선형으로 대체")
@@ -261,12 +273,14 @@ def run_section_generation(
cross_dir.mkdir(parents=True, exist_ok=True)
# 종단면 저장 (계획선은 저장 직전에 종단 데이터에 붙인다)
pipe_anchors = _load_pipe_anchors(project_root, polyline)
grade_summary = _append_design_profiles(
result["longitudinal"],
grade_options,
(result.get("options") or {}).get("station_interval_m"),
# 1차 계획선의 변화점 = 배수유역도가 확정한 배관 배치 측점.
pipe_chainages=_load_pipe_chainages(project_root, polyline),
pipe_chainages=pipe_anchors[0],
pipe_clearances=pipe_anchors[1],
)
# 계획선 경사 기반 포장 제안(법정 상한 초과 측점) — 비치명적.
try:
+22 -9
View File
@@ -10,8 +10,12 @@
* 산식(사용자 확정 예시 그대로):
* 배수관 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) = +1.5
* BOX암거 2.0×2.0 → 지반고 + 2.0(구체 높이) + 0.5(토피) = +2.5
* 물넘이·세월교 → 지반고 + 월류 높이(좌측 패널이 설계유량으로 산출한 `ford_height_m`)
* 토피 0.5m는 B06 배수관 엔진의 `MIN_PIPE_COVER_M`과 같은 값이다(교차 확인).
* 세월교·물넘이포장은 월류 구조라 최소 토피 개념이 다르다 — 이번 범위에서 뺀다.
*
* **정본 산식은 백엔드** `common_util_drainage_pipes.facility_clearance_m`이다 —
* 계획선 자동 생성이 그 값으로 변화점을 들어 올린다. 여기 있는 것은 편집 중 즉시
* 경고하기 위한 같은 산식의 화면 사본이며, 상수 일치는 테스트로 잠가 둔다.
* ========================================================================== */
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
@@ -29,6 +33,7 @@ export const MIN_COVER_M = 0.5;
/** 시설 제원이 비었을 때 쓰는 기본값 — 화면 기본 선택과 맞춘다. */
const DEFAULT_PIPE_DIAMETER_MM = 1000;
const DEFAULT_BOX_HEIGHT_M = 2;
const DEFAULT_FORD_HEIGHT_M = 0.3;
export interface MinCoverPoint {
chainage_m: number;
@@ -52,20 +57,28 @@ export function minCoverPoints(pipes: MinCoverPipe[]): MinCoverPoint[] {
for (const pipe of pipes) {
const facility = pipe.facility ?? "pipe";
const options = pipe.options ?? {};
if (facility === "pipe") {
const diameterM = numberOf(options.pipe_diameter_mm, DEFAULT_PIPE_DIAMETER_MM) / 1000;
result.push({
chainage_m: pipe.chainage_m,
clearance_m: diameterM + MIN_COVER_M,
label: `배수관 Ø${Math.round(diameterM * 1000)}`,
});
} else if (facility === "box_culvert") {
if (facility === "box_culvert") {
const heightM = numberOf(options.body_height_m, DEFAULT_BOX_HEIGHT_M);
result.push({
chainage_m: pipe.chainage_m,
clearance_m: heightM + MIN_COVER_M,
label: `BOX암거 H${heightM.toFixed(1)}`,
});
} else if (facility === "ford_pavement" || facility === "ford_bridge") {
// 월류 구조 — 토피가 아니라 월류 높이만큼 노면이 올라간다(좌측 패널 산출값).
const heightM = numberOf(options.ford_height_m, DEFAULT_FORD_HEIGHT_M);
result.push({
chainage_m: pipe.chainage_m,
clearance_m: heightM,
label: `${facility === "ford_bridge" ? "세월교" : "물넘이포장"} 월류 ${heightM.toFixed(2)}m`,
});
} else {
const diameterM = numberOf(options.pipe_diameter_mm, DEFAULT_PIPE_DIAMETER_MM) / 1000;
result.push({
chainage_m: pipe.chainage_m,
clearance_m: diameterM + MIN_COVER_M,
label: `배수관 Ø${Math.round(diameterM * 1000)}`,
});
}
}
return result.sort((a, b) => a.chainage_m - b.chainage_m);
+41
View File
@@ -266,3 +266,44 @@ def save_detail_basins(stored_path: str, features: list[dict[str, Any]]) -> Path
atomic_write_json(path, {"type": "FeatureCollection", "features": features})
logger.info("배수유역: 세부유역 %d개를 저장했습니다 (%s).", len(features), path.name)
return path
# ── 횡단배수 최소 계획고 (2026-08-23 사용자 확정) ─────────────────────────────
# 계획 종단선의 변화점(PVI)은 배수 시설 자리다. 그 자리에서 계획고를 지반고와 같게
# 두면 시설이 들어갈 자리가 없다 — 시설 제원만큼 계획고를 들어 올려야 한다.
# 배수관 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) = +1.5
# BOX암거 2×2 → 지반고 + 2.0(구체 높이) + 0.5(토피) = +2.5
# 물넘이·세월교 → 지반고 + 월류 높이(좌측 패널이 설계유량으로 산출한 값)
# 토피 0.5m는 B06 배수관 엔진(`MIN_PIPE_COVER_M`)과 같은 값이다.
MIN_PIPE_COVER_M = 0.5
DEFAULT_PIPE_DIAMETER_MM = 1000.0
DEFAULT_BOX_HEIGHT_M = 2.0
DEFAULT_FORD_HEIGHT_M = 0.3
def _positive(value: Any, fallback: float) -> float:
try:
parsed = float(value)
except (TypeError, ValueError):
return fallback
return parsed if parsed > 0 else fallback
def facility_clearance_m(facility: str, options: dict[str, Any] | None) -> float:
"""시설이 요구하는 지반고 대비 최소 여유(m). 계획선·경고가 같이 쓰는 정본 산식."""
values = options or {}
if facility == PIPE_FACILITY_BOX:
return _positive(values.get("body_height_m"), DEFAULT_BOX_HEIGHT_M) + MIN_PIPE_COVER_M
if facility in (PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_FORD_BRIDGE):
# 월류 구조라 토피가 아니라 월류 높이만큼 노면이 올라간다.
return _positive(values.get("ford_height_m"), DEFAULT_FORD_HEIGHT_M)
diameter_m = _positive(values.get("pipe_diameter_mm"), DEFAULT_PIPE_DIAMETER_MM) / 1000.0
return diameter_m + MIN_PIPE_COVER_M
def pipe_anchor_clearances(points: list[PipePoint]) -> list[tuple[float, float]]:
"""계획선 변화점으로 쓸 (누가거리, 최소 여유) 목록. 누가거리 오름차순."""
return sorted(
(float(point.chainage_m), facility_clearance_m(point.facility, point.options))
for point in points
)