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:
@@ -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]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user