계획선의 변화점(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>
392 lines
16 KiB
Python
392 lines
16 KiB
Python
"""B05 종단 계획선 선형 산출 오케스트레이터 (지반 추종 직선 분할 + 편집 재구성).
|
|
|
|
[[B05_Profile_Engine_Grade]] 가 확정한 설계 기준과
|
|
[[B05_Profile_Engine_Grade_Solver]] 의 수치 계산,
|
|
[[B05_Profile_Engine_Grade_Alignment]] 의 기하 파생을 묶어
|
|
`design_profiles` 배열에 넣을 계획선 한 벌을 만든다.
|
|
|
|
세 진입점이 있다.
|
|
- `design_pipe_anchored_profile()` : **1차(기본)**. 배수유역도가 산출한 배관 배치 측점을
|
|
변화점으로 삼아, 계획선이 각 배관 자리에서 지면선과 만나도록(계획고 = 지반고) 시작점 →
|
|
배관1 → 배관2 → … → 종점을 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정).
|
|
배관(암거)은 계곡 유하부라 계획선이 그 지점에 붙어야 복토·유입 조건이 성립한다.
|
|
- `design_alignment_profile()` : **2차(폴백)**. 배관이 없거나 1차 산출이 불가할 때
|
|
쓰는 기존 지반 추종 직선 분할 DP 선형.
|
|
- `rebuild_alignment_profile()`: 사용자 편집 확정 시. **저장된 자동 선형(base_pvi)과
|
|
정책을 그대로 재사용**하고 편집 델타만 다시 얹는다. DP를 다시 돌리면 기준선이
|
|
흔들려 "원복" 이 원래 위치로 돌아가지 않기 때문이다.
|
|
"""
|
|
|
|
from collections import Counter
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
|
|
from B05_Profile.B05_Profile_Engine_Grade import (
|
|
GradeDesignOptions,
|
|
detect_main_direction,
|
|
ground_profile,
|
|
)
|
|
from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
|
|
ALIGNMENT_SCHEMA_VERSION,
|
|
AlignmentPolicy,
|
|
build_alignment,
|
|
build_curves,
|
|
chainage_key,
|
|
evaluate,
|
|
)
|
|
from B05_Profile.B05_Profile_Engine_Grade_Solver import (
|
|
grade_limits,
|
|
integration_weights,
|
|
solve_alignment_elevations,
|
|
station_breakpoints,
|
|
)
|
|
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
|
|
|
|
ALIGNMENT_PROFILE_ID = "design_grade_line"
|
|
ALIGNMENT_BASIS = "station_alignment"
|
|
|
|
|
|
def infer_station_interval(stations: list[dict[str, Any]]) -> float:
|
|
"""측점 목록에서 가장 흔한 간격을 기준 측점간격으로 본다.
|
|
|
|
사용자가 추가한 비기준 측점(+18, +15 등)이 섞여 있어도 최빈값이 기준 간격이다.
|
|
"""
|
|
gaps: list[float] = []
|
|
for previous, current in zip(stations[:-1], stations[1:]):
|
|
gap = round(float(current["chainage_m"]) - float(previous["chainage_m"]), 1)
|
|
if gap > 0:
|
|
gaps.append(gap)
|
|
if not gaps:
|
|
return 20.0
|
|
return float(Counter(gaps).most_common(1)[0][0])
|
|
|
|
|
|
def _profile_entry(
|
|
alignment: dict[str, Any],
|
|
options: GradeDesignOptions,
|
|
direction: str,
|
|
balanced: bool,
|
|
warnings: list[str],
|
|
) -> dict[str, Any]:
|
|
"""`design_profiles` 배열 계약(기존 스키마)에 맞춰 계획선 한 벌을 만든다.
|
|
|
|
B06 횡단 계획고와 B08 CAD 계획선 레이어는 `samples`만 사용하므로, 선형 구조가
|
|
바뀌어도 하류 단계는 그대로 동작한다.
|
|
"""
|
|
balance = alignment["balance"]
|
|
grades = [abs(segment["grade_percent"]) for segment in alignment["segments"]]
|
|
return {
|
|
"schema_version": ALIGNMENT_SCHEMA_VERSION,
|
|
"id": ALIGNMENT_PROFILE_ID,
|
|
"name": "계획선",
|
|
"basis": ALIGNMENT_BASIS,
|
|
"criteria": {**options.as_dict(), "resolved_main_direction": direction},
|
|
# 구 스키마 호환: 변화점 목록을 pvis 이름으로도 노출한다.
|
|
"pvis": alignment["pvi"],
|
|
"samples": alignment["samples"],
|
|
"stations": alignment["stations"],
|
|
"balance_segments": [
|
|
{
|
|
"index": 0,
|
|
"start_chainage_m": alignment["segments"][0]["from_m"]
|
|
if alignment["segments"]
|
|
else 0.0,
|
|
"end_chainage_m": alignment["segments"][-1]["to_m"]
|
|
if alignment["segments"]
|
|
else 0.0,
|
|
"cut_area_m2": balance["cut_area_m2"],
|
|
"fill_area_m2": balance["fill_area_m2"],
|
|
"balance_error_m2": balance["net_area_m2"],
|
|
}
|
|
],
|
|
"summary": {
|
|
"cut_area_m2": balance["cut_area_m2"],
|
|
"fill_area_m2": balance["fill_area_m2"],
|
|
"balance_error_m2": balance["net_area_m2"],
|
|
"imbalance_percent": balance["imbalance_percent"],
|
|
"tolerance_percent": balance["tolerance_percent"],
|
|
"max_grade_pct": round(max(grades), 6) if grades else 0.0,
|
|
"vertical_curve_count": sum(1 for curve in alignment["curves"] if not curve["omitted"]),
|
|
"pvi_count": len(alignment["pvi"]),
|
|
"balance_segment_count": 1,
|
|
"balanced": bool(balanced and balance["within_tolerance"]),
|
|
"main_direction": direction,
|
|
"suggested_elevation_offset_m": None,
|
|
"edited_station_count": len(alignment["edits"]["station_offsets"]),
|
|
"warnings": warnings,
|
|
},
|
|
}
|
|
|
|
|
|
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,
|
|
pipe_chainages: list[float],
|
|
*,
|
|
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차 계획선.
|
|
|
|
기하 규칙(2026-08-03 사용자 확정): **배관 자리(지면선과 배관 세로선의 교점)가 호 위에
|
|
있어야 한다.** 시작점·종점과 각 호, 호와 호 사이는 직선이 접선(tangent)으로 잇는다.
|
|
대칭 종단곡선은 원래 변화점(꼭짓점)을 지나지 않으므로, 변화점 표고를 반복 보정해
|
|
**곡선이 정확히 배관 지반고를 통과**하도록 맞춘다(중앙종거만큼 꼭짓점을 밀어낸다).
|
|
|
|
호는 **길이 L을 기준**으로 잡는다(`default_curve_length_m`). 변화점 사이 대수차 A가
|
|
작아 R을 고정하면 L = R × A 가 변화점마다 널뛰기 때문이다. R = L / A 로 역산해 저장하며
|
|
법정 최소반경(`options.min_vertical_radius_m`) 아래로는 내려가지 않게 눌러 준다.
|
|
이 R은 편집 델타(curve_radii)로 저장돼 사용자가 B05 테이블에서 그대로 고칠 수 있다.
|
|
기울기 위반은 막지 않고 경고로 남긴다 — 배관 위치가 우선이고 조정은 사용자 몫이다.
|
|
"""
|
|
options.validate()
|
|
chainage, ground = ground_profile(longitudinal)
|
|
total = float(chainage[-1])
|
|
if total <= 0:
|
|
raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.")
|
|
|
|
stations = list(longitudinal.get("stations") or [])
|
|
interval = float(station_interval_m or 0) or infer_station_interval(stations)
|
|
policy = AlignmentPolicy.from_config(
|
|
station_interval_m=interval,
|
|
max_grade_pct=options.max_grade_pct,
|
|
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
|
|
paved=options.paved,
|
|
)
|
|
|
|
warnings = list(options.warnings)
|
|
fixed = (
|
|
float(ground[0]) + options.start_elevation_offset_m,
|
|
float(ground[-1]) + options.end_elevation_offset_m,
|
|
)
|
|
rise = fixed[1] - fixed[0]
|
|
direction, note = (
|
|
detect_main_direction(ground, rise)
|
|
if options.main_direction == "auto"
|
|
else (options.main_direction, None)
|
|
)
|
|
if note:
|
|
warnings.append(note)
|
|
|
|
# 범위 밖·양끝에 붙은 것은 버리고, 서로 붙은 배관(0.5m 미만)은 하나로 본다.
|
|
margin = 0.5
|
|
anchors: list[float] = []
|
|
for value in sorted(float(c) for c in pipe_chainages):
|
|
if value <= margin or value >= total - margin:
|
|
continue
|
|
if anchors and value - anchors[-1] < margin:
|
|
continue
|
|
anchors.append(round(value, 3))
|
|
if not anchors:
|
|
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]
|
|
|
|
target_length = float(FOREST_ROAD_PROFILE_ALIGNMENT["default_curve_length_m"])
|
|
min_radius = float(options.min_vertical_radius_m)
|
|
edits = dict(edits or {})
|
|
user_radii = dict(edits.get("curve_radii") or {})
|
|
|
|
def radii_for(nodes_z: np.ndarray) -> dict[str, float]:
|
|
"""현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다)."""
|
|
spans = base_s[1:] - base_s[:-1]
|
|
grades = (nodes_z[1:] - nodes_z[:-1]) / np.where(spans > 0, spans, 1.0)
|
|
resolved: dict[str, float] = {}
|
|
for index in range(1, len(base_s) - 1):
|
|
delta = abs(float(grades[index] - grades[index - 1]))
|
|
radius = target_length / delta if delta > 1e-9 else min_radius
|
|
resolved[chainage_key(base_s[index])] = max(radius, min_radius)
|
|
return {**resolved, **user_radii}
|
|
|
|
# 대칭 종단곡선은 꼭짓점(변화점)을 지나지 않는다 — 곡선이 배관 지반고를 통과하도록
|
|
# 변화점 표고를 반복 보정한다(호가 안쪽으로 파고드는 중앙종거만큼 꼭짓점을 밀어낸다).
|
|
# 표고가 움직이면 대수차도 변하므로 R도 매 회 다시 역산한다.
|
|
base_z = target.copy()
|
|
radii = radii_for(base_z)
|
|
for _ in range(12):
|
|
radii = radii_for(base_z)
|
|
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii)
|
|
plan_at = evaluate(base_s, base_z, curves, base_s)
|
|
error = target - plan_at
|
|
error[0] = 0.0
|
|
error[-1] = 0.0
|
|
if float(np.max(np.abs(error))) < 1e-4:
|
|
break
|
|
base_z = base_z + error
|
|
edits["curve_radii"] = radii
|
|
|
|
alignment = build_alignment(
|
|
base_s=base_s,
|
|
base_z=base_z,
|
|
chainage=chainage,
|
|
ground=ground,
|
|
stations=stations,
|
|
policy=policy,
|
|
edits=edits,
|
|
)
|
|
warnings.extend(alignment["warnings"])
|
|
balanced = bool(alignment["balance"]["within_tolerance"])
|
|
return alignment, _profile_entry(alignment, options, direction, balanced, warnings)
|
|
|
|
|
|
def design_alignment_profile(
|
|
longitudinal: dict[str, Any],
|
|
options: GradeDesignOptions,
|
|
*,
|
|
station_interval_m: float | None = None,
|
|
edits: dict[str, Any] | None = None,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""지반 종단을 직선 분할로 근사해 계획선 선형을 새로 만든다.
|
|
|
|
반환값은 (선형 구조, `design_profiles` 항목) 이다.
|
|
"""
|
|
options.validate()
|
|
chainage, ground = ground_profile(longitudinal)
|
|
total = float(chainage[-1])
|
|
if total <= 0:
|
|
raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.")
|
|
|
|
stations = list(longitudinal.get("stations") or [])
|
|
interval = float(station_interval_m or 0) or infer_station_interval(stations)
|
|
policy = AlignmentPolicy.from_config(
|
|
station_interval_m=interval,
|
|
max_grade_pct=options.max_grade_pct,
|
|
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
|
|
paved=options.paved,
|
|
)
|
|
|
|
warnings = list(options.warnings)
|
|
fixed = (
|
|
float(ground[0]) + options.start_elevation_offset_m,
|
|
float(ground[-1]) + options.end_elevation_offset_m,
|
|
)
|
|
rise = fixed[1] - fixed[0]
|
|
direction, note = (
|
|
detect_main_direction(ground, rise)
|
|
if options.main_direction == "auto"
|
|
else (options.main_direction, None)
|
|
)
|
|
if note:
|
|
warnings.append(note)
|
|
up_limit, down_limit = grade_limits(
|
|
options.max_grade_pct, options.max_reverse_grade_pct, direction
|
|
)
|
|
limit = up_limit if rise >= 0 else down_limit
|
|
if abs(rise) / total > limit + 1e-9:
|
|
raise ValueError(
|
|
f"시·종점 고도차({rise:.2f}m)를 연장 {total:.1f}m에서 기준 기울기 "
|
|
f"{limit * 100:.1f}% 이내로 연결할 수 없습니다."
|
|
)
|
|
|
|
station_chainages = np.array(
|
|
[float(station["chainage_m"]) for station in stations], dtype=np.float64
|
|
)
|
|
base_s = station_breakpoints(
|
|
chainage,
|
|
ground,
|
|
station_chainages if len(station_chainages) else chainage,
|
|
penalty_m2=policy.pvi_penalty_m2,
|
|
min_segment_stations=policy.min_segment_stations,
|
|
)
|
|
base_z, balanced = solve_alignment_elevations(
|
|
base_s,
|
|
chainage,
|
|
ground,
|
|
integration_weights(chainage),
|
|
fixed,
|
|
up_limit,
|
|
down_limit,
|
|
balance_tolerance_percent=policy.balance_tolerance_percent,
|
|
)
|
|
alignment = build_alignment(
|
|
base_s=base_s,
|
|
base_z=base_z,
|
|
chainage=chainage,
|
|
ground=ground,
|
|
stations=stations,
|
|
policy=policy,
|
|
edits=edits,
|
|
)
|
|
warnings.extend(alignment["warnings"])
|
|
return alignment, _profile_entry(alignment, options, direction, balanced, warnings)
|
|
|
|
|
|
def rebuild_alignment_profile(
|
|
longitudinal: dict[str, Any],
|
|
edits: dict[str, Any] | None,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""저장된 자동 선형을 기준으로 사용자 편집만 다시 얹는다.
|
|
|
|
직선 분할 DP를 다시 돌리지 않으므로 편집 델타의 기준선(base_pvi)이 고정되고,
|
|
편집 키를 지우면 최초 자동 선형으로 정확히 되돌아간다.
|
|
"""
|
|
stored = longitudinal.get("profile_alignment")
|
|
if not isinstance(stored, dict) or not stored.get("base_pvi"):
|
|
raise ValueError("저장된 계획선 선형이 없어 편집을 반영할 수 없습니다.")
|
|
|
|
chainage, ground = ground_profile(longitudinal)
|
|
base = stored["base_pvi"]
|
|
base_s = np.array([float(item["chainage_m"]) for item in base], dtype=np.float64)
|
|
base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64)
|
|
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
|
|
|
alignment = build_alignment(
|
|
base_s=base_s,
|
|
base_z=base_z,
|
|
chainage=chainage,
|
|
ground=ground,
|
|
stations=list(longitudinal.get("stations") or []),
|
|
policy=policy,
|
|
edits=edits,
|
|
)
|
|
previous = (longitudinal.get("design_profiles") or [{}])[0]
|
|
criteria = previous.get("criteria") or {}
|
|
options = GradeDesignOptions(
|
|
max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct),
|
|
max_reverse_grade_pct=float(criteria.get("max_reverse_grade_pct") or 5.0),
|
|
min_vertical_radius_m=float(criteria.get("min_vertical_radius_m") or 100.0),
|
|
min_curve_length_m=float(criteria.get("min_curve_length_m") or 20.0),
|
|
min_tangent_length_m=float(criteria.get("min_tangent_length_m") or 20.0),
|
|
vertical_curve_skip_delta_pct=policy.curve_skip_delta_pct,
|
|
design_speed_kph=int(criteria.get("design_speed_kph") or 20),
|
|
terrain_type=str(criteria.get("terrain_type") or "normal"),
|
|
paved=policy.paved,
|
|
main_direction=str(criteria.get("main_direction") or "auto"),
|
|
)
|
|
direction = str(criteria.get("resolved_main_direction") or "none")
|
|
return alignment, _profile_entry(
|
|
alignment,
|
|
options,
|
|
direction,
|
|
alignment["balance"]["within_tolerance"],
|
|
alignment["warnings"],
|
|
)
|