fix(B05): 전체 측점 계획선에 종단곡선 복원 — 호가 지반고를 통과하도록 꼭짓점 반복 보정

`3651ad98` 의 `only_explicit_curves=True` 로 전체 측점 폴리라인에서 자동 종단곡선이
전부 꺼져, B05 종단 그래프의 계획선이 직선만으로 그려졌음.

- `design_ground_following_profile()` — 변화점마다 기본 L(15m) 종단곡선을 다시 삽입.
  대칭 종단곡선은 꼭짓점을 지나지 않으므로(중앙종거 |A|·L/8), 호와 측점 세로선의 교점이
  지반고가 되도록 꼭짓점 표고를 최대 12회 반복 보정 — `design_pipe_anchored_profile` 과
  같은 방식. 결과: 호의 중심이 측점 세로선 위, 좌우 직선과 접선, 계획고 = 지반고.
- `rebuild_alignment_profile()` — `GROUND_POLYLINE_BASIS` 곡선 생략 예외 제거.
  저장된 `base_pvi` 가 보정된 꼭짓점이라 편집 델타를 지우면 초기선으로 그대로 복귀함.
- 프론트는 payload `only_explicit_curves` 를 그대로 따르므로 코드 변경 없음.
- 호 크기 기준은 현행 유지(2026-09-03 사용자 결정) — L 고정·R 은 결과값, 법정 최소반경
  미만은 경고만.

검증: `tmp/tests/test_b05_ground_profile_curves.py` 4건 + 전체 173건 통과. route 127
실데이터 오프라인 산출에서 호 126개(저장분 0개), 측점 계획고−지반고 최대 0.063mm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 19:02:03 +09:00
co-authored by Claude Opus 5
parent 4fdbcaf09c
commit 0ce6b43bdc
+20 -11
View File
@@ -153,11 +153,13 @@ def design_ground_following_profile(
) -> tuple[dict[str, Any], dict[str, Any]]:
"""전체 측점을 변화점으로 삼고 계획고를 원지반고에 맞추는 1차 계획선.
규칙(2026-09-02 사용자 확정):
- **모든 측점이 변화점**이고 그 표고는 그 자리 지반고 그대로다. 절·성토가 거의 0이다.
- **종단곡선(라운드)은 자동으로 넣지 않는다.** 사용자가 [직선화]로 만든 직선을
틸팅할 때 그 변화점에만 R이 지정되고, 그때 곡선이 생긴다
(`build_alignment(only_explicit_curves=True)`).
규칙(2026-09-02 사용자 확정 + 2026-09-03 종단곡선 복원):
- **모든 측점이 변화점**이고 그 자리 계획고는 지반고 그대로다. 절·성토가 거의 0이다.
- **변화점마다 종단곡선을 넣는다.** 직선과 직선 사이에는 반드시 호가 있고, 호는
변화점 대칭이라 **호의 중심이 측점 세로선 위**에 놓이며 좌우 직선과 접선을 이룬다.
- 대칭 종단곡선은 꼭짓점을 지나지 않으므로 **호와 측점 세로선의 교점이 지반고**가
되도록 꼭짓점 표고를 중앙종거만큼 밀어내며 반복 보정한다
(`design_pipe_anchored_profile` 과 같은 방식).
- 기울기는 지형 그대로라 법정 상한을 넘길 수 있다 — 막지 않고 경고만 남긴다
(기존 정책과 같다).
@@ -204,6 +206,19 @@ def design_ground_following_profile(
if note:
warnings.append(note)
# 대칭 종단곡선은 꼭짓점을 지나지 않는다(중앙종거 |A|·L/8) — 보정 없이 곡선을 넣으면
# 계획고가 지반고에서 뜬다. 호가 측점 세로선과 만나는 점이 지반고가 되도록 꼭짓점을
# 밀어내며, 표고가 움직이면 대수차도 변하므로 수렴할 때까지 되풀이한다.
target = base_z.copy()
for _ in range(12):
curves, _curve_warnings = build_curves(base_s, base_z, policy)
error = target - evaluate(base_s, base_z, curves, base_s)
error[0] = 0.0
error[-1] = 0.0
if float(np.max(np.abs(error))) < 1e-4:
break
base_z = base_z + error
alignment = build_alignment(
base_s=base_s,
base_z=base_z,
@@ -212,7 +227,6 @@ def design_ground_following_profile(
stations=stations,
policy=policy,
edits=dict(edits or {}),
only_explicit_curves=True,
)
warnings.extend(alignment["warnings"])
balanced = bool(alignment["balance"]["within_tolerance"])
@@ -449,10 +463,6 @@ def rebuild_alignment_profile(
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
previous = (longitudinal.get("design_profiles") or [{}])[0]
# 전체 측점 폴리라인은 곡선을 자동으로 넣지 않는다 — 재구성도 같은 규칙이어야
# 편집 델타를 지웠을 때 초기 폴리라인으로 정확히 돌아간다(2026-09-02).
only_explicit = str(previous.get("basis") or "") == GROUND_POLYLINE_BASIS
alignment = build_alignment(
base_s=base_s,
base_z=base_z,
@@ -461,7 +471,6 @@ def rebuild_alignment_profile(
stations=list(longitudinal.get("stations") or []),
policy=policy,
edits=edits,
only_explicit_curves=only_explicit,
)
criteria = previous.get("criteria") or {}
options = GradeDesignOptions(